Pourquoi sum est-il tellement plus rapide que inject (: +)?

129

J'exécutais donc quelques benchmarks dans Ruby 2.4.0 et je me suis rendu compte que

(1...1000000000000000000000000000000).sum

calcule immédiatement alors que

(1...1000000000000000000000000000000).inject(:+)

prend tellement de temps que je viens d'avorter l'opération. J'avais l'impression que Range#sumc'était un alias Range#inject(:+)mais il semble que ce n'est pas vrai. Alors, comment ça summarche et pourquoi est-ce tellement plus rapide queinject(:+) ?

NB La documentation de Enumerable#sum(qui est implémentée par Range) ne dit rien sur l'évaluation paresseuse ou quoi que ce soit de ce genre.

Eli Sadoff
la source

Réponses:

227

Réponse courte

Pour une plage entière:

  • Enumerable#sum Retour (range.max-range.min+1)*(range.max+range.min)/2
  • Enumerable#inject(:+) itère sur chaque élément.

Théorie

La somme des nombres entiers compris entre 1 et nest appelée un nombre triangulaire et est égale à n*(n+1)/2.

La somme des entiers entre net mest le nombre triangulaire de mmoins le nombre triangulaire de n-1, qui est égal à m*(m+1)/2-n*(n-1)/2, et peut être écrit (m-n+1)*(m+n)/2.

# Somme énumérable dans Ruby 2.4

Cette propriété est utilisée Enumerable#sumpour les plages d'entiers:

if (RTEST(rb_range_values(obj, &beg, &end, &excl))) {
    if (!memo.block_given && !memo.float_value &&
            (FIXNUM_P(beg) || RB_TYPE_P(beg, T_BIGNUM)) &&
            (FIXNUM_P(end) || RB_TYPE_P(end, T_BIGNUM))) { 
        return int_range_sum(beg, end, excl, memo.v);
    } 
}

int_range_sum ressemble à ça :

VALUE a;
a = rb_int_plus(rb_int_minus(end, beg), LONG2FIX(1));
a = rb_int_mul(a, rb_int_plus(end, beg));
a = rb_int_idiv(a, LONG2FIX(2));
return rb_int_plus(init, a);

ce qui équivaut à:

(range.max-range.min+1)*(range.max+range.min)/2

l'égalité mentionnée ci-dessus!

Complexité

Merci beaucoup à @k_g et @ Hynek-Pichi-Vychodil pour cette partie!

somme

(1...1000000000000000000000000000000).sum nécessite trois ajouts, une multiplication, une soustraction et une division.

C'est un nombre constant d'opérations, mais la multiplication est O ((log n) ²), donc Enumerable#sum comme O ((log n) ²) pour une plage d'entiers.

injecter

(1...1000000000000000000000000000000).inject(:+)

nécessite 999999999999999999999999999998 ajouts!

L'addition est O (log n), tout Enumerable#injectcomme O (n log n).

Avec 1E30comme entrée, injectavec jamais de retour. Le soleil va exploser bien avant!

Tester

Il est facile de vérifier si des entiers Ruby sont ajoutés:

module AdditionInspector
  def +(b)
    puts "Calculating #{self}+#{b}"
    super
  end
end

class Integer
  prepend AdditionInspector
end

puts (1..5).sum
#=> 15

puts (1..5).inject(:+)
# Calculating 1+2
# Calculating 3+3
# Calculating 6+4
# Calculating 10+5
#=> 15

En effet, d'après les enum.ccommentaires:

Enumerable#summethod peut ne pas respecter la redéfinition de méthode de "+" méthodes telles que Integer#+.

Eric Duminil
la source
17
C'est une très bonne optimisation à avoir car le calcul de la somme d'une plage de nombres est trivial si vous utilisez la bonne formule, et atroce si vous procédez de manière itérative. C'est comme essayer d'implémenter la multiplication comme une série d'opérations d'addition.
tadman
Donc, l'augmentation des performances concerne n+1uniquement les gammes? Je n'ai pas installé 2.4 ou je me testerais moi-même, mais d'autres objets énumérables sont traités par ajout de base, car ils seraient en inject(:+)moins la surcharge du symbole à proc.
ingénieursmnky
8
Lecteurs, rappelez-vous de vos mathématiques de lycée qui n, n+1, n+2, .., mconstituent une série arithmétique dont la somme est égale (m-n+1)*(m+n)/2. De même, la somme d'une série géométrique , n, (α^1)n, (α^2)n, (α^3)n, ... , (α^m)n. peut être calculé à partir d'une expression de forme fermée.
Cary Swoveland
4
\ begin {nitpick} Enumerable # sum est O ((log n) ^ 2) et inject est O (n log n) lorsque vos nombres sont autorisés à être illimités. \ end {nitpick}
k_g
6
@EliSadoff: Cela signifie de très gros chiffres. Cela signifie des nombres qui ne correspondent pas au mot d'architecture, c'est-à-dire ne peuvent pas être calculés par une instruction et une opération dans le cœur du processeur. Le nombre de taille N peut être codé par log_2 N bits donc l'addition est une opération O (logN) et la multiplication est O ((logN) ^ 2) mais pourrait être O ((logN) ^ 1,585) (Karasuba) ou même O (logN * log (logN) * ​​log (log (LogN)) (FFT).
Hynek -Pichi- Vychodil