Optymalizacja wywołania ogona jest obecna w wielu językach i kompilatorach. W tej sytuacji kompilator rozpoznaje funkcję formularza:
int foo(n) {
...
return bar(n);
}
Tutaj język jest w stanie rozpoznać, że zwracany wynik jest wynikiem innej funkcji i zmienić wywołanie funkcji z nową ramką stosu na skok.
Zrozum, że klasyczna metoda czynnikowa:
int factorial(n) {
if(n == 0) return 1;
if(n == 1) return 1;
return n * factorial(n - 1);
}
nie jest optymalizowany na podstawie ogona z powodu koniecznej kontroli po powrocie. ( Przykładowy kod źródłowy i skompilowane dane wyjściowe )
Aby zoptymalizować to połączenie z ogonem,
int _fact(int n, int acc) {
if(n == 1) return acc;
return _fact(n - 1, acc * n);
}
int factorial(int n) {
if(n == 0) return 1;
return _fact(n, 1);
}
Kompilowanie tego kodu za pomocą gcc -O2 -S fact.c
(-O2 jest konieczne, aby umożliwić optymalizację w kompilatorze, ale przy większej optymalizacji -O3 trudno jest odczytać człowiekowi ...)
_fact(int, int):
cmpl $1, %edi
movl %esi, %eax
je .L2
.L3:
imull %edi, %eax
subl $1, %edi
cmpl $1, %edi
jne .L3
.L2:
rep ret
( Przykładowy kod źródłowy i skompilowane dane wyjściowe )
Widać w segmencie .L3
, jne
a nie w call
(który wywołuje podprogram z nową ramką stosu).
Należy pamiętać, że zostało to zrobione przy użyciu C. Optymalizacja wywołania Tail w Javie jest trudna i zależy od implementacji JVM (co powiedziawszy, nie widziałem tego, ponieważ to jest trudne i implikacje wymaganego modelu bezpieczeństwa Java wymagającego ramek stosu - czego unika całkowity koszt posiadania) - rekursja ogona + Java i rekursja ogona + optymalizacja to dobre zestawy znaczników do przeglądania. Można znaleźć inne języki JVM są w stanie optymalizować rekursji ogonowej lepiej (Clojure try (co wymaga ponownie wystąpi zadzwonić ogon Optimize) lub Scala).
To mówi,
Jest pewna radość, wiedząc, że napisałeś coś dobrze - w idealny sposób, aby to zrobić.
A teraz wezmę trochę szkockiej i założę niemiecką elektronikę ...
Do ogólnego pytania dotyczącego „metod unikania przepełnienia stosu w algorytmie rekurencyjnym” ...
Innym podejściem jest włączenie licznika rekurencji. Jest to bardziej przydatne do wykrywania nieskończonych pętli spowodowanych sytuacjami, na które nie ma się wpływu (i słabym kodowaniem).
Licznik rekurencji ma postać
int foo(arg, counter) {
if(counter > RECURSION_MAX) { return -1; }
...
return foo(arg, counter + 1);
}
Za każdym razem, gdy wykonujesz połączenie, zwiększasz licznik. Jeśli licznik staje się zbyt duży, popełniasz błąd (w tym przypadku jest to tylko zwrot -1, chociaż w innych językach możesz preferować wyjątek). Chodzi o to, aby zapobiec wystąpieniu gorszych rzeczy (błędów pamięci) podczas wykonywania rekurencji, która jest znacznie głębsza niż oczekiwano i prawdopodobnie nieskończonej pętli.
Teoretycznie nie powinieneś tego potrzebować. W praktyce widziałem źle napisany kod, który go dotknął z powodu mnóstwa drobnych błędów i złych praktyk kodowania (problemy z wielowątkową współbieżnością, w których coś zmienia coś poza metodą, która powoduje, że kolejny wątek przechodzi w nieskończoną pętlę wywołań rekurencyjnych).
Użyj właściwego algorytmu i rozwiąż właściwy problem. Wydaje się, że specjalnie dla Collatz Conjecture próbujesz rozwiązać to w sposób xkcd :
Zaczynasz od liczby i przechodzisz przez drzewo. To szybko prowadzi do bardzo dużej przestrzeni wyszukiwania. Szybki bieg w celu obliczenia liczby iteracji dla poprawnej odpowiedzi daje około 500 kroków. Nie powinno to stanowić problemu w przypadku rekurencji z ramką o małym stosie.
Chociaż znajomość rozwiązania rekurencyjnego nie jest złą rzeczą, należy również zdać sobie sprawę, że wielokrotnie rozwiązanie iteracyjne jest lepsze . Wiele sposobów podejścia do konwertowania algorytmu rekurencyjnego na iteracyjny można znaleźć na stronie Przepełnienie stosu w Way, aby przejść od rekurencji do iteracji .