Będę przez jakiś czas owijać w bawełnę, ale jest sens.
Półgrupy
Odpowiedź brzmi: asocjacyjna właściwość operacji binarnej redukcji .
To dość abstrakcyjne, ale mnożenie jest dobrym przykładem. Jeśli x , y i z są pewnymi liczbami naturalnymi (lub liczbami całkowitymi, liczbami wymiernymi, liczbami rzeczywistymi, liczbami zespolonymi, macierzami N × N lub dowolną liczbą innych elementów), to x × y jest tego samego rodzaju liczby jako zarówno x, jak i y . Zaczęliśmy od dwóch liczb, więc jest to operacja binarna, i otrzymaliśmy jedną, więc zmniejszyliśmy liczbę liczb o jedną, dzięki czemu jest to operacja redukcji. A ( x × y ) × z jest zawsze takie samo jak x × ( y ×z ), która jest własnością asocjacyjną.
(Jeśli już to wszystko wiesz, możesz przejść do następnej sekcji).
Kilka innych rzeczy, które często widzisz w informatyce, które działają w ten sam sposób:
- dodawanie dowolnego z tych liczb zamiast mnożenia
- łączenie ciągów (
"a"+"b"+"c"
to "abc"
, czy rozpocząć "ab"+"c"
lub "a"+"bc"
)
- Łącząc dwie listy razem.
[a]++[b]++[c]
jest podobnie [a,b,c]
albo od tyłu do przodu lub od przodu do tyłu.
cons
na głowie i ogonie, jeśli myślisz o głowie jako liście singletonów. To tylko połączenie dwóch list.
- biorąc związek lub przecięcie zbiorów
- Boolean i, Boolean lub
- bitowe
&
, |
i^
- skład funkcji: ( f ∘ g ) ∘ h x = f ∘ ( g ∘ h ) x = f ( g ( h ( x )))
- maksimum i minimum
- dodatek modulo p
Niektóre rzeczy, które nie:
- odejmowanie, ponieważ 1- (1-2) ≠ (1-1) -2
- x ⊕ y = tan ( x + y ), ponieważ tan (π / 4 + π / 4) jest niezdefiniowany
- mnożenie przez liczby ujemne, ponieważ -1 × -1 nie jest liczbą ujemną
- podział liczb całkowitych, który ma wszystkie trzy problemy!
- logiczne nie, ponieważ ma tylko jeden operand, a nie dwa
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, jak print2( print2(x,y), z );
i print2( x, print2(y,z) );
mają różne dane wyjściowe.
Nazwaliśmy go wystarczająco przydatną koncepcją. Zestaw z operacją, która ma te właściwości, jest półgrupą . Tak więc liczby rzeczywiste po pomnożeniu są półgrupą. A twoje pytanie okazuje się jednym ze sposobów, w jaki taka abstrakcja staje się przydatna w prawdziwym świecie. Operacje na półgrupach można zoptymalizować tak, jak pytasz.
Wypróbuj to w domu
O ile mi wiadomo, technika ta została po raz pierwszy opisana w 1974 r. W pracy Daniela Friedmana i Davida Wise'a „Składanie stylizowanych rekurencji w iteracjach” , chociaż przyjęli oni kilka więcej właściwości, niż się okazało, że musieli.
Haskell jest świetnym językiem do zilustrowania tego, ponieważ ma Semigroup
klasę w swojej standardowej bibliotece. Nazywa to operacją rodzajową Semigroup
operator <>
. Ponieważ listy i łańcuchy są instancjami Semigroup
, ich instancje definiują na przykład <>
jako operator konkatenacji ++
. A przy odpowiednim imporcie [a] <> [b]
jest alias [a] ++ [b]
, który jest [a,b]
.
Ale co z liczbami? Właśnie widzieliśmy, że typy liczbowe to półgrupy z dodawaniem lub mnożeniem! Który może być <>
dla Double
? Cóż, jedno z nich! Haskell określa typy Product Double
, where (<>) = (*)
(to znaczy rzeczywista definicja Haskell), a także Sum Double
, where (<>) = (+)
.
Jedna zmarszczka polega na tym, że wykorzystałeś fakt, że 1 to tożsamość multiplikatywna. Półgrupa z tożsamością nazywana jest monoidem i jest zdefiniowana w pakiecie Haskell Data.Monoid
, który wywołuje ogólny element tożsamości klasy mempty
. Sum
, Product
a lista ma każdy element tożsamości (odpowiednio 0, 1 i []
), więc są to zarówno wystąpienia, Monoid
jak i Semigroup
. (Nie mylić z monadą , więc zapomnij, że nawet je wychowałem.)
To wystarczająca ilość informacji, aby przetłumaczyć twój algorytm na funkcję Haskella za pomocą monoidów:
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.
Co ważne, zauważ, że jest to półgrupa modulo rekurencji ogona: każdy przypadek jest albo wartością, wywołaniem rekurencyjnym ogona, albo iloczynem półgrupy obu. Ten przykład przydał się również mempty
w jednym z przypadków, ale gdybyśmy go nie potrzebowali, moglibyśmy to zrobić przy użyciu bardziej ogólnej klasy Semigroup
.
Załadujmy ten program do GHCI i zobaczmy, jak to działa:
*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49
Pamiętasz, jak zadeklarowaliśmy pow
rodzaj ogólny Monoid
, którego typ nazwaliśmy a
? Daliśmy GHCI wystarczających informacji, aby wywnioskować, że typ a
o to Product Integer
, co stanowi instance
o Monoid
których <>
praca jest liczbą całkowitą mnożenia. Tak pow 2 4
rozwija się rekurencyjnie 2<>2<>2<>2
, który jest 2*2*2*2
lub 16
. Jak na razie dobrze.
Ale nasza funkcja używa tylko ogólnych operacji monoidów. Wcześniej mówiłem, że istnieje inna instancja Monoid
wywołana Sum
, której <>
operacją jest +
. Czy możemy tego spróbować?
*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14
To samo rozszerzenie daje nam teraz 2+2+2+2
zamiast 2*2*2*2
. Mnożenie jest dodawaniem, ponieważ potęgowanie polega na mnożeniu!
Podałem jednak jeszcze jeden przykład monoidu Haskella: listy, których operacja polega na konkatenacji.
*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]
Pisanie [2]
informuje kompilator, że jest to lista, <>
na listach jest ++
, tak [2]++[2]++[2]++[2]
jest [2,2,2,2]
.
Wreszcie algorytm (dwa, w rzeczywistości)
Przez prostą wymianę x
z [x]
, konwertowanie ogólny algorytm, który używa rekursji modulo półgrupa na taki, który tworzy listę. Która lista Lista elementów, których dotyczy algorytm <>
. Ponieważ użyliśmy tylko operacji na półgrupach, które również mają listy, wynikowa lista będzie izomorficzna w stosunku do pierwotnego obliczenia. A ponieważ pierwotna operacja była asocjacyjna, równie dobrze możemy ocenić elementy od tyłu do przodu lub od przodu do tyłu.
Jeśli Twój algorytm kiedykolwiek osiągnie przypadek podstawowy i zakończy działanie, lista nie będzie pusta. Ponieważ sprawa terminalu zwróciła coś, będzie to ostatni element listy, więc będzie miała co najmniej jeden element.
Jak zastosować binarną operację redukcji do każdego elementu listy w kolejności? Zgadza się, fold. Więc można zastąpić [x]
przez x
, uzyskać listę elementów, aby zmniejszyć przez <>
, a następnie albo w prawo lub w lewo-fold-krotnie listy:
*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16
Wersja z foldr1
faktycznie istnieje w standardowej bibliotece, zarówno sconcat
dla , jak Semigroup
i mconcat
dla Monoid
. Robi leniwy prawy fold na liście. Oznacza to, że rozszerza [Product 2,Product 2,Product 2,Product 2]
się 2<>(2<>(2<>(2)))
.
W tym przypadku nie jest to skuteczne, ponieważ nie możesz nic zrobić z poszczególnymi warunkami, dopóki nie wygenerujesz wszystkich z nich. (W pewnym momencie miałem tutaj dyskusję na temat tego, kiedy stosować prawe fałdy, a kiedy ścisłe lewe fałdy, ale poszło to zbyt daleko.)
Wersja z foldl1'
jest ściśle ocenianą lewą zakładką. Oznacza to, że funkcja rekurencyjna ogona ze ścisłym akumulatorem. To (((2)<>2)<>2)<>2
oblicza, obliczane natychmiast, a nie później, gdy jest to potrzebne. (Przynajmniej nie ma żadnych opóźnień w owczarni się. Listę zagiętą generowany jest tutaj przez inną funkcję, które mogą zawierać ocenę leniwy) Tak, zagięcia oblicza (4<>2)<>2
, a następnie natychmiast oblicza 8<>2
, potem 16
. Właśnie dlatego potrzebowaliśmy, aby operacja była asocjacyjna: właśnie zmieniliśmy grupowanie nawiasów!
Ściśle lewy pas jest odpowiednikiem tego, co robi GCC. Najbardziej wysunięta w lewo liczba w poprzednim przykładzie to akumulator, w tym przypadku działający produkt. Na każdym kroku jest on mnożony przez następny numer na liście. Innym sposobem wyrażenia tego jest: iterujesz wartości, które chcesz pomnożyć, utrzymując działający produkt w akumulatorze, a przy każdej iteracji mnożymy akumulator przez następną wartość. Oznacza to, że jest to while
pętla w przebraniu.
Czasami może być tak samo wydajny. Kompilator może być w stanie zoptymalizować strukturę danych listy w pamięci. Teoretycznie ma wystarczająco dużo informacji w czasie kompilacji, aby dowiedzieć się, że powinien to zrobić tutaj: [x]
jest singletonem, więc [x]<>xs
jest taki sam jak cons x xs
. Każda iteracja funkcji może być w stanie ponownie użyć tej samej ramki stosu i zaktualizować parametry w miejscu.
W danym przypadku bardziej odpowiednie może być prawe lub ścisłe lewe pasowanie, więc wiedz, który z nich chcesz. Są też rzeczy, które może wykonać tylko prawy fold (np. Generowanie interaktywnego wyjścia bez czekania na wszystkie dane wejściowe i działanie na nieskończonej liście). Tutaj jednak redukujemy sekwencję operacji do prostej wartości, więc chcemy ścisłego zagięcia w lewo.
Tak więc, jak widać, możliwe jest automatyczne optymalizowanie modułu rekurencji ogona dowolnej półgrupy (jednym z przykładów jest dowolny z typowych typów numerycznych pod zwielokrotnieniem) do leniwego prawego fałdu lub ścisłego lewego fałdu w jednej linii Haskell.
Dalsze uogólnianie
Dwa argumenty operacji binarnej nie muszą być tego samego typu, o ile wartość początkowa jest tego samego typu co wynik. (Oczywiście możesz zawsze odwracać argumenty, aby dopasować kolejność składania, którą wykonujesz, w lewo lub w prawo.) Możesz więc wielokrotnie dodawać łaty do pliku, aby uzyskać zaktualizowany plik, lub zaczynając od wartości początkowej 1.0, podziel przez liczby całkowite, aby zgromadzić wynik zmiennoprzecinkowy. Lub dodaj elementy do pustej listy, aby uzyskać listę.
Innym typem uogólnienia jest stosowanie fałd nie do list, ale do innych Foldable
struktur danych. Często niezmienna liniowo połączona lista nie jest strukturą danych, którą chcesz dla danego algorytmu. Jedną kwestią, o której nie wspomniałem powyżej, jest to, że o wiele bardziej wydajne jest dodawanie elementów z przodu listy niż z tyłu, a gdy operacja nie jest przemienna, zastosowanie x
po lewej i prawej stronie operacji nie jest to samo. Musisz więc użyć innej struktury, takiej jak para list lub drzewo binarne, aby przedstawić algorytm, który można zastosować zarówno x
po prawej, <>
jak i po lewej stronie.
Należy również pamiętać, że właściwość asocjacyjna umożliwia przegrupowanie operacji na inne użyteczne sposoby, takie jak podział i podbij:
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)
Lub automatyczny paralelizm, w którym każdy wątek redukuje podzakres do wartości, która jest następnie łączona z innymi.
if(n==0) return 0;
(nie zwraca 1 jak w twoim pytaniu).x^0 = 1
, więc to jest błąd. Ale to nie ma znaczenia dla reszty pytania; iteracyjny asm sprawdza najpierw ten szczególny przypadek. Ale, co dziwne, iteracyjna implementacja wprowadza wielokrotność tego,1 * x
czego nie było w źródle, nawet jeśli stworzymyfloat
wersję. gcc.godbolt.org/z/eqwine (i gcc tylko się udaje-ffast-math
.)