Sto andando a battere il cespuglio per un po ', ma c'è un punto.
semigruppi
La risposta è la proprietà associativa dell'operazione di riduzione binaria .
È piuttosto astratto, ma la moltiplicazione è un buon esempio. Se x , y e z sono dei numeri naturali (o interi o numeri razionali, o numeri reali o numeri complessi o N × N matrici o qualsiasi di un sacco altre cose), allora x × y è lo stesso tipo di numero come x e y . Abbiamo iniziato con due numeri, quindi è un'operazione binaria e ne abbiamo ottenuta una, quindi abbiamo ridotto il numero di numeri che avevamo di uno, rendendo l'operazione di riduzione. E ( x × y ) × z è sempre uguale a x × ( y ×z ), che è la proprietà associativa.
(Se conosci già tutto questo, puoi passare alla sezione successiva.)
Alcune altre cose che vedi spesso in informatica che funzionano allo stesso modo:
- aggiungendo uno di quei tipi di numeri invece di moltiplicarli
- concatenare stringhe (
"a"+"b"+"c"
è "abc"
se inizi con "ab"+"c"
o "a"+"bc"
)
- Unire due liste insieme.
[a]++[b]++[c]
è allo stesso [a,b,c]
modo da dietro a davanti o da davanti a dietro.
cons
su una testa e una coda, se pensi alla testa come a una lista singleton. Questo è solo concatenare due elenchi.
- prendendo l'unione o l'intersezione di insiemi
- Booleano e, Booleano o
- bit a bit
&
, |
e^
- composizione delle funzioni: ( f ∘ g ) ∘ h x = f ∘ ( g ∘ h ) x = f ( g ( h ( x )))
- massimo e minimo
- aggiunta modulo p
Alcune cose che non lo fanno:
- sottrazione, perché 1- (1-2) ≠ (1-1) -2
- x ⊕ y = tan ( x + y ), perché tan (π / 4 + π / 4) non è definito
- moltiplicazione sui numeri negativi, perché -1 × -1 non è un numero negativo
- divisione di numeri interi, che presenta tutti e tre i problemi!
- logico no, perché ha un solo operando, non due
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, come print2( print2(x,y), z );
e print2( x, print2(y,z) );
hanno output diverso.
È un concetto abbastanza utile che l'abbiamo chiamato. Un set con un'operazione che ha queste proprietà è un semigruppo . Quindi, i numeri reali sotto moltiplicazione sono un semigruppo. E la tua domanda si rivela essere uno dei modi in cui questo tipo di astrazione diventa utile nel mondo reale. Le operazioni di un semigruppo possono essere tutte ottimizzate nel modo richiesto.
Prova questo a casa
Per quanto ne so, questa tecnica è stata descritta per la prima volta nel 1974, nel documento di Daniel Friedman e David Wise, "Ripiegare le ricorsioni stilizzate in iterazioni" , sebbene abbiano assunto alcune proprietà in più rispetto a quanto risultasse necessario.
Haskell è un ottimo linguaggio per illustrarlo, perché ha la Semigroup
typeclass nella sua libreria standard. Chiama l'operazione di un generico Semigroup
l'operatore <>
. Poiché gli elenchi e le stringhe sono istanze di Semigroup
, le loro istanze definiscono <>
l'operatore di concatenazione ++
, ad esempio. E con la giusta importazione, [a] <> [b]
è un alias per [a] ++ [b]
, che è [a,b]
.
Ma che dire dei numeri? Abbiamo appena visto che i tipi numerici sono semigruppi sotto sia aggiunta o la moltiplicazione! Quindi quale si ottiene <>
per un Double
? Bene, uno dei due! Haskell definisce i tipi Product Double
, where (<>) = (*)
(che è la definizione stessa in Haskell), e anche Sum Double
, where (<>) = (+)
.
Una ruga è che hai usato il fatto che 1 è l'identità moltiplicativa. Un semigruppo con un'identità è chiamato monoid ed è definito nel pacchetto Haskell Data.Monoid
, che chiama l'elemento di identità generico di una typeclass mempty
. Sum
, Product
E la lista ciascuno ha un elemento di identità (0, 1 e []
, rispettivamente), quindi sono esempi di Monoid
come Semigroup
. (Da non confondere con una monade , quindi dimentica anche di averli menzionati.)
Sono sufficienti informazioni per tradurre l'algoritmo in una funzione di Haskell usando i monoidi:
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.
È importante sottolineare che si tratta del semigruppo modulo ricorsione di coda: ogni caso è un valore, una chiamata ricorsiva di coda o il prodotto semigruppo di entrambi. Inoltre, questo esempio è stato usato mempty
per uno dei casi, ma se non ne avessimo avuto bisogno, avremmo potuto farlo con la tabella dei tipi più generale Semigroup
.
Cariciamo questo programma in GHCI e vediamo come funziona:
*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49
Ricordi come abbiamo dichiarato pow
per un generico Monoid
, di chi abbiamo chiamato a
? Abbiamo fornito a GHCI informazioni sufficienti per dedurre che il tipo a
qui è Product Integer
, che è una instance
delle Monoid
cui <>
operazioni è la moltiplicazione intera. Quindi si pow 2 4
espande in modo ricorsivo a 2<>2<>2<>2
, che è 2*2*2*2
o 16
. Fin qui tutto bene.
Ma la nostra funzione utilizza solo operazioni monoid generiche. In precedenza, ho detto che esiste un'altra istanza di Monoid
chiamata Sum
, la cui <>
operazione è +
. Possiamo provarlo?
*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14
La stessa espansione ora ci dà 2+2+2+2
invece di 2*2*2*2
. La moltiplicazione è aggiunta come l'espiazione è la moltiplicazione!
Ma ho dato un altro esempio di un monoide di Haskell: liste, la cui operazione è la concatenazione.
*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]
La scrittura [2]
dice al compilatore che questa è una lista, <>
su liste è ++
, così [2]++[2]++[2]++[2]
è [2,2,2,2]
.
Finalmente un algoritmo (due, in realtà)
Sostituendo semplicemente x
con [x]
, si converte l'algoritmo generico che utilizza la ricorsione modulo un semigruppo in uno che crea un elenco. Quale lista? L'elenco degli elementi a cui si applica l'algoritmo <>
. Poiché abbiamo utilizzato solo le operazioni di semigruppo che hanno anche gli elenchi, l'elenco risultante sarà isomorfo rispetto al calcolo originale. E poiché l'operazione originale era associativa, possiamo ugualmente valutare bene gli elementi da davanti a dietro o da davanti a dietro.
Se il tuo algoritmo raggiunge mai un caso base e termina, l'elenco sarà non vuoto. Poiché il case terminale ha restituito qualcosa, quello sarà l'ultimo elemento dell'elenco, quindi avrà almeno un elemento.
Come si applica un'operazione di riduzione binaria a tutti gli elementi di un elenco in ordine? Esatto, una piega. Così si può sostituire [x]
per x
, ottenere un elenco di elementi da ridurre di <>
, e poi o destra-fold o sinistra volte la lista:
*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16
La versione con foldr1
effettivamente esiste nella libreria standard, come sconcat
per Semigroup
e mconcat
per Monoid
. Fa una piega a destra pigra sulla lista. Cioè, si espande [Product 2,Product 2,Product 2,Product 2]
a 2<>(2<>(2<>(2)))
.
Questo non è efficace in questo caso perché non puoi fare nulla con i singoli termini finché non li generi tutti. (A un certo punto ho discusso qui su quando usare le pieghe di destra e quando usare le pieghe di sinistra rigorose, ma è andato troppo lontano.)
La versione con foldl1'
è una piega a sinistra rigorosamente valutata. Vale a dire, una funzione ricorsiva della coda con un accumulatore rigoroso. Questo viene valutato (((2)<>2)<>2)<>2
, calcolato immediatamente e non più tardi quando è necessario. (Almeno, non ci sono ritardi all'interno della piega stessa: l'elenco che viene piegato viene generato qui da un'altra funzione che potrebbe contenere una valutazione pigra.) Quindi, la piega calcola (4<>2)<>2
, quindi calcola immediatamente 8<>2
, quindi 16
. Ecco perché avevamo bisogno che l'operazione fosse associativa: abbiamo appena cambiato il raggruppamento delle parentesi!
La stretta piega a sinistra è l'equivalente di ciò che GCC sta facendo. Il numero più a sinistra nell'esempio precedente è l'accumulatore, in questo caso un prodotto in esecuzione. Ad ogni passaggio, viene moltiplicato per il numero successivo nell'elenco. Un altro modo per esprimerlo è: si scorre sui valori da moltiplicare, mantenendo il prodotto in esecuzione in un accumulatore e su ogni iterazione, si moltiplica l'accumulatore per il valore successivo. Cioè, è un while
ciclo sotto mentite spoglie.
A volte può essere reso altrettanto efficiente. Il compilatore potrebbe essere in grado di ottimizzare la struttura dei dati dell'elenco in memoria. In teoria, ha informazioni sufficienti al momento della compilazione per capire che dovrebbe farlo qui: [x]
è un singleton, quindi [x]<>xs
è lo stesso di cons x xs
. Ogni iterazione della funzione potrebbe essere in grado di riutilizzare lo stesso frame dello stack e aggiornare i parametri in atto.
O una piega a destra o una piega a sinistra rigorosa potrebbe essere più appropriata, in un caso particolare, quindi sappi quale vuoi. Ci sono anche alcune cose che solo una piega a destra può fare (come generare output interattivi senza attendere tutto l'input e operare su un elenco infinito). Qui, tuttavia, stiamo riducendo una sequenza di operazioni a un valore semplice, quindi una piega a sinistra rigorosa è ciò che vogliamo.
Quindi, come puoi vedere, è possibile ottimizzare automaticamente il modulo di ricorsione della coda in qualsiasi semigruppo (un esempio dei quali è uno dei soliti tipi numerici sotto moltiplicazione) su una piega destra pigra o una piega sinistra rigida, in una riga di Haskell.
Generalizzare ulteriormente
I due argomenti dell'operazione binaria non devono essere dello stesso tipo, purché il valore iniziale sia dello stesso tipo del risultato. (Ovviamente puoi sempre capovolgere gli argomenti in modo che corrispondano all'ordine del tipo di piega che stai facendo, a sinistra oa destra.) Quindi potresti aggiungere ripetutamente patch a un file per ottenere un file aggiornato o iniziare con un valore iniziale di 1.0, dividi per numeri interi per accumulare un risultato in virgola mobile. O anteporre elementi all'elenco vuoto per ottenere un elenco.
Un altro tipo di generalizzazione è applicare le pieghe non agli elenchi ma ad altre Foldable
strutture di dati. Spesso, un elenco di collegamenti lineari immutabili non è la struttura di dati desiderata per un determinato algoritmo. Un problema che non ho affrontato in precedenza è che è molto più efficiente aggiungere elementi in primo piano rispetto a un elenco piuttosto che in fondo, e quando l'operazione non è commutativa, l'applicazione x
a sinistra e a destra dell'operazione non lo sono lo stesso. Quindi avresti bisogno di usare un'altra struttura, come una coppia di liste o albero binario, per rappresentare un algoritmo che potrebbe essere applicato sia x
a destra <>
che a sinistra.
Si noti inoltre che la proprietà associativa consente di raggruppare le operazioni in altri modi utili, come divide-and-conquer:
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)
O parallelismo automatico, in cui ogni thread riduce una sottorange a un valore che viene quindi combinato con gli altri.
if(n==0) return 0;
(non restituire 1 come nella tua domanda).x^0 = 1
, quindi è un bug. Non che sia importante per il resto della domanda, comunque; l'aser iterativo controlla prima quel caso speciale. Ma stranamente, l'implementazione iterativa introduce una moltiplicazione di1 * x
ciò che non era presente nella fonte, anche se ne facciamo unafloat
versione. gcc.godbolt.org/z/eqwine (e gcc riesce solo con-ffast-math
.)