Je vais tourner autour du pot pendant un moment, mais il y a un point.
Demi-groupes
La réponse est la propriété associative de l'opération de réduction binaire .
C'est assez abstrait, mais la multiplication est un bon exemple. Si x , y et z sont des nombres naturels (ou des nombres entiers, ou des nombres rationnels, ou des nombres réels, ou des nombres complexes, ou des matrices N × N , ou tout un tas d'autres choses), alors x × y est du même genre du nombre à la fois x et y . Nous avons commencé avec deux nombres, c'est donc une opération binaire, et nous en avons obtenu un, nous avons donc réduit le nombre de nombres que nous avions d'un, ce qui en fait une opération de réduction. Et ( x × y ) × z est toujours identique à x × ( y ×z ), qui est la propriété associative.
(Si vous savez déjà tout cela, vous pouvez passer à la section suivante.)
Quelques autres choses que vous voyez souvent en informatique qui fonctionnent de la même manière:
- en ajoutant l'un de ces types de nombres au lieu de multiplier
- concaténer des chaînes (
"a"+"b"+"c"
est "abc"
que vous commencez avec "ab"+"c"
ou "a"+"bc"
)
- Épissage de deux listes ensemble.
[a]++[b]++[c]
est similaire [a,b,c]
de l'arrière vers l'avant ou de l'avant vers l'arrière.
cons
sur une tête et une queue, si vous pensez à la tête comme une liste singleton. C'est simplement concaténer deux listes.
- prendre l'union ou l'intersection d'ensembles
- Booléen et, booléen ou
- au niveau du bit
&
, |
et^
- composition des fonctions: ( f ∘ g ) ∘ h x = f ∘ ( g ∘ h ) x = f ( g ( h ( x )))
- maximum et minimum
- addition modulo p
Certaines choses qui ne le font pas:
- soustraction, car 1- (1-2) ≠ (1-1) -2
- x ⊕ y = tan ( x + y ), car tan (π / 4 + π / 4) n'est pas défini
- multiplication sur les nombres négatifs, car -1 × -1 n'est pas un nombre négatif
- division d'entiers, qui a les trois problèmes!
- logique non, car il n'a qu'un opérande, pas deux
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, as print2( print2(x,y), z );
et print2( x, print2(y,z) );
ont une sortie différente.
C'est un concept suffisamment utile que nous l'avons nommé. Un ensemble avec une opération qui possède ces propriétés est un semi - groupe . Ainsi, les nombres réels sous multiplication sont un semi-groupe. Et votre question s'avère être l'une des façons dont ce type d'abstraction devient utile dans le monde réel. Les opérations de semi-groupe peuvent toutes être optimisées comme vous le demandez.
Essayez ceci à la maison
Pour autant que je sache, cette technique a été décrite pour la première fois en 1974, dans l'article de Daniel Friedman et David Wise, «Folding Stylized Recursions into Iterations» , bien qu'ils aient assumé quelques propriétés de plus qu'il ne leur en fallait.
Haskell est un excellent langage pour illustrer cela, car il a la Semigroup
classe de types dans sa bibliothèque standard. Il appelle l'opération d'un générique Semigroup
l'opérateur <>
. Comme les listes et les chaînes sont des instances de Semigroup
, leurs instances se définissent <>
comme l'opérateur de concaténation ++
, par exemple. Et avec la bonne importation, [a] <> [b]
est un alias pour[a] ++ [b]
, ce qui est [a,b]
.
Mais qu'en est-il des chiffres? Nous avons vu juste que les types numériques sont semigroupes sous soit plus ou multiplication! Alors, lequel devient <>
un Double
? Eh bien, l'un ou l'autre! Haskell définit les types Product Double
, where (<>) = (*)
(qui est la définition même dans Haskell), et aussi Sum Double
, where (<>) = (+)
.
Une ride est que vous avez utilisé le fait que 1 est l'identité multiplicative. Un semi-groupe avec une identité est appelé un monoïde et est défini dans le package Haskell Data.Monoid
, qui appelle l'élément d'identité générique d'une classe de types mempty
. Sum
, Product
Et une liste chacun a un élément d'identité (0, 1 et []
, respectivement), de sorte qu'ils sont des instances de Monoid
ainsi que Semigroup
. (A ne pas confondre avec un monade , alors oubliez juste que j'en ai même parlé.)
C'est assez d'informations pour traduire votre algorithme en une fonction Haskell à l'aide de monoïdes:
module StylizedRec (pow) where
import Data.Monoid as DM
pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
- itself n times. This is already in Haskell as Data.Monoid.mtimes, but
- let’s write it out as an example.
-}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.
Il est important de noter qu'il s'agit du semi-groupe modulo de récursion de queue: chaque cas est soit une valeur, un appel récursif de queue, soit le produit du semi-groupe des deux. En outre, cet exemple est arrivé à utilisermempty
pour l'un des cas, mais si nous n'en avions pas eu besoin, nous aurions pu le faire avec la classe de type plus générale Semigroup
.
Chargeons ce programme dans GHCI et voyons comment cela fonctionne:
*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49
Rappelez-vous comment nous avons déclaré pow
pour un générique Monoid
, dont nous avons appelé le type a
? Nous avons donné suffisamment d' informations GHCi pour en déduire que le type a
est là Product Integer
, qui est un instance
de Monoid
dont le <>
fonctionnement est la multiplication entier. Doncpow 2 4
S'étend récursivement à 2<>2<>2<>2
, qui est 2*2*2*2
ou16
. Jusqu'ici tout va bien.
Mais notre fonction n'utilise que des opérations monoïdes génériques. Auparavant, j'ai dit qu'il y avait un autre exemple de Monoid
appelé Sum
, dont<>
fonctionnement est +
. Pouvons-nous essayer cela?
*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14
La même expansion nous donne maintenant 2+2+2+2
au lieu de 2*2*2*2
. La multiplication, c'est l'addition comme l'exponentiation, c'est la multiplication!
Mais j'ai donné un autre exemple d'un monoïde Haskell: les listes, dont le fonctionnement est la concaténation.
*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]
L' écriture [2]
indique au compilateur que ceci est une liste, <>
sur les listes est ++
, donc [2]++[2]++[2]++[2]
est [2,2,2,2]
.
Enfin, un algorithme (deux, en fait)
En remplaçant simplement x
par [x]
, vous convertissez l'algorithme générique qui utilise le module de récursivité un semi-groupe en un qui crée une liste. Quelle liste? La liste des éléments auxquels l'algorithme s'applique <>
. Étant donné que nous avons utilisé uniquement les opérations de semi-groupe que les listes possèdent également, la liste résultante sera isomorphe au calcul d'origine. Et comme l'opération d'origine était associative, on peut tout aussi bien évaluer les éléments d'arrière en avant ou d'avant en arrière.
Si votre algorithme atteint un cas de base et se termine, la liste ne sera pas vide. Puisque le cas terminal a renvoyé quelque chose, ce sera le dernier élément de la liste, donc il aura au moins un élément.
Comment appliquez-vous une opération de réduction binaire à chaque élément d'une liste dans l'ordre? C'est vrai, un pli. Ainsi , vous pouvez remplacer [x]
pour x
, obtenir une liste des éléments pour réduire par <>
, puis soit à droite ou à gauche fois fois la liste:
*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16
La version avec foldr1
existe en fait dans la bibliothèque standard, comme sconcat
pour Semigroup
et mconcat
pour Monoid
. Il fait un pli paresseux à droite sur la liste. Autrement dit, il se développe [Product 2,Product 2,Product 2,Product 2]
à 2<>(2<>(2<>(2)))
.
Ce n'est pas efficace dans ce cas, car vous ne pouvez rien faire avec les termes individuels tant que vous ne les avez pas tous générés. (À un moment donné, j'ai eu une discussion ici sur le moment d'utiliser les plis droits et le moment d'utiliser des plis gauches stricts, mais cela allait trop loin.)
La version avec foldl1'
est un pli gauche strictement évalué. C'est-à-dire une fonction récursive de queue avec un accumulateur strict. Cela évalue à (((2)<>2)<>2)<>2
, calculé immédiatement et pas plus tard lorsque cela est nécessaire. (Au moins, il n'y a aucun retard dans le pli lui-même: la liste en cours de pliage est générée ici par une autre fonction qui pourrait contenir une évaluation paresseuse.) Ainsi, le pli calcule (4<>2)<>2
, puis calcule immédiatement 8<>2
, puis16
. C'est pourquoi nous avions besoin que l'opération soit associative: nous venons de changer le regroupement des parenthèses!
Le pli gauche strict est l'équivalent de ce que fait GCC. Le nombre le plus à gauche dans l'exemple précédent est l'accumulateur, dans ce cas un produit en cours d'exécution. À chaque étape, il est multiplié par le nombre suivant de la liste. Une autre façon de l'exprimer est la suivante: vous parcourez les valeurs à multiplier, en gardant le produit en cours d'exécution dans un accumulateur, et à chaque itération, vous multipliez l'accumulateur par la valeur suivante. Autrement dit, c'est une while
boucle déguisée.
Il peut parfois être rendu aussi efficace. Le compilateur peut peut-être optimiser la structure des données de la liste en mémoire. En théorie, il a suffisamment d'informations au moment de la compilation pour comprendre qu'il devrait le faire ici: [x]
est un singleton, [x]<>xs
est donc le même que cons x xs
. Chaque itération de la fonction peut être en mesure de réutiliser le même cadre de pile et de mettre à jour les paramètres en place.
Un pli droit ou un pli gauche strict pourrait être plus approprié, dans un cas particulier, alors sachez lequel vous voulez. Il y a aussi certaines choses que seul un pli droit peut faire (comme générer une sortie interactive sans attendre toutes les entrées et fonctionner sur une liste infinie). Ici, cependant, nous réduisons une séquence d'opérations à une valeur simple, donc un pli gauche strict est ce que nous voulons.
Ainsi, comme vous pouvez le voir, il est possible d'optimiser automatiquement le module de récursion de queue de n'importe quel semi-groupe (dont un exemple est l'un des types numériques habituels en cours de multiplication) en un pli droit paresseux ou un pli gauche strict, en une seule ligne de Haskell.
Généraliser davantage
Les deux arguments de l'opération binaire ne doivent pas nécessairement être du même type, tant que la valeur initiale est du même type que votre résultat. (Vous pouvez bien sûr toujours retourner les arguments pour qu'ils correspondent à l'ordre du type de pli que vous faites, à gauche ou à droite.) Vous pouvez donc ajouter plusieurs fois des correctifs à un fichier pour obtenir un fichier mis à jour, ou en commençant par une valeur initiale de 1.0, divisez par des nombres entiers pour accumuler un résultat en virgule flottante. Ou ajoutez des éléments à la liste vide pour obtenir une liste.
Un autre type de généralisation consiste à appliquer les plis non à des listes mais à d'autres Foldable
structures de données. Souvent, une liste liée linéaire immuable n'est pas la structure de données que vous souhaitez pour un algorithme donné. Un problème que je n'ai pas abordé ci-dessus est qu'il est beaucoup plus efficace d'ajouter des éléments au début d'une liste qu'à l'arrière, et lorsque l'opération n'est pas commutative, l'application x
à gauche et à droite de l'opération ne l'est pas. le même. Il vous faudrait donc utiliser une autre structure, telle qu'une paire de listes ou un arbre binaire, pour représenter un algorithme qui pourrait s'appliquer x
à droite <>
comme à gauche.
Notez également que la propriété associative vous permet de regrouper les opérations d'autres manières utiles, telles que diviser pour mieux régner:
times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n = y <> y
| otherwise = x <> y <> y
where y = times x (n `quot` 2)
Ou le parallélisme automatique, où chaque thread réduit une sous-gamme à une valeur qui est ensuite combinée avec les autres.
if(n==0) return 0;
(pas de retour 1 comme dans votre question).x^0 = 1
, c'est donc un bug. Mais ce n'est pas important pour le reste de la question; l'asm itératif vérifie d'abord ce cas spécial. Mais étrangement, l'implémentation itérative introduit une multiplicité de ce1 * x
qui n'était pas présent dans la source, même si nous faisons unefloat
version. gcc.godbolt.org/z/eqwine (et gcc ne réussit qu'avec-ffast-math
.)