Funkcja Ackermanna

Funkcja Ackermanna – funkcja matematyczna odkryta przez Wilhelma Ackermanna w 1928 roku. Cechą charakterystyczną tej dwuargumentowej funkcji jest jej nadzwyczaj szybki wzrost. Funkcja Ackermanna jest prostym przykładem funkcji rekurencyjnej, niebędącej funkcją pierwotnie rekurencyjną. Funkcje pierwotnie rekurencyjne to większość znanych funkcji, między innymi dodawanie, funkcja wykładnicza itp.

Funkcja Ackermanna często jest używana do testowania jakości optymalizacji kompilatorów – stosunki czasu wykonania pomiędzy różnymi kompilatorami tego samego języka czasami sięgają tysiąca. Jakość kodu generowana dla tego typu funkcji jest szczególnie ważna w językach funkcyjnych, gdzie używa się rekursji.

Inną funkcją o własnościach podobnych do funkcji Ackermanna (tzn. będąca rekurencyjną i nie pierwotnie rekurencyjną) jest funkcja Sudana.

Definicja

Matematyczna definicja funkcji opisana jest wzorem

Własności

Funkcja Ackermanna jest rekurencyjna.

Schemat dowodu twierdzenia funkcja Ackermanna nie jest pierwotnie rekurencyjna: definiuje się rodzinę funkcji Ackermanna (każda z tych funkcji jest pierwotnie rekurencyjna). Z tego wynika, że każda funkcja pierwotnie rekurencyjna jest majoryzowana przez pewną funkcję Ackermanna. Następnie dowodzi się, że wszystkie jednoargumentowe funkcje Ackermanna będą z kolei majoryzowane przez funkcję Wynika z tego, że nie jest pierwotnie rekurencyjna.

Dowód przez indukcję:

  • Niech m = 0. Wtedy:

zgodnie z definicją dla funkcji posiadającej 0 jako pierwszy argument. z kolei jest zawsze większe od 0, ponieważ już w przypadku n = 0, n + 1 = 0 + 1 = 1.

  • (Hipoteza 1) Zakładając, że obowiązuje:

pokazujemy poprzez indukcję na argumencie n, że

  • W tym celu niech n = 0. Zgodnie z hipotezą 1 obowiązuje

i poprzez to

wedle definicji funkcji dla argumentu n = 0.

  • (Hipoteza 2) Zakładając, że obowiązuje:

pokazujemy, że

  • Ponieważ wedle hipotezy 2 zachodzi: musi obowiązywać:

a następnie zgodnie z hipotezą 1:

oraz wedle definicji funkcji dla argumentów różnych od 0:

Łącząc te trzy relacje otrzymujemy dowód twierdzenia:

Tabela wartości

m\n01234n
012345
123456
2357911
35132961125
41365533265536 – 3(3, 265536 – 3)(3, (4, 3))
565533(4, 65533)(4, (5, 1))(4, (5, 2))(4, (5, 3))
6(5, 1)(5, (5, 1))(5, (6, 1))(5, (6, 2))(5, (6, 3))

Wartość (4,2) jest dużo większa niż liczba cząsteczek w obserwowalnym wszechświecie podniesiona do dwusetnej potęgi.

Przykłady

Pseudokod (oraz kod w języku Python) algorytmu obliczającego funkcję Ackermanna:

def Ack(m, n):
   if m==0: return n+1
   if m>0 and n==0: return Ack(m-1, 1)
   if m>0 and n>0: return Ack(m-1, Ack(m, n-1))

Kod metody obliczającej funkcję w przykładowym języku funkcyjnym, np. Erlangu:

ack(0,N) -> N+1;
  ack(M,0) -> ack(M-1, 1);
  ack(M,N) -> ack(M-1, ack(M, N-1)).

Trudność przy obliczeniach funkcji Ackermanna leży głównie w złożoności wywołań rekurencyjnych, które łatwo mogą doprowadzić do przepełnienia stosu (ang. stack overflow). Podczas wykonywania obliczenia dochodzi wielokrotnie do wywołań funkcji z identycznymi argumentami. W poniższym przykładzie między innymi A(1, 1), A(1, 0), A(0, 2) zostają wywołane dwukrotnie. Odpowiednia implementacja funkcji może wykorzystać powtarzające się instrukcje do znacznego skrócenia procesu obliczania. Przykładowo wartość A(2, 0) jest równa 3, co jest widoczne w wierszu 6. Podobnie wartość A(1, 2) wynosi 4, co daje się odczytać w przedostatnim wierszu. Oprócz tego wartość funkcji wywołanej z argumentami (2, 1) odpowiada wartości funkcji wywołanej z argumentami (1, 3) lub (0, 4). Różnica polega na tym, że ostatnia kombinacja argumentów może zostać obliczona w czasie krótszym (1 wywołanie funkcji) niż druga (8 wywołań funkcji) lub pierwsza (14 wywołań).

A(2, 1) = A(1, A(2, 0))
        = A(1, A(1, 1))
        = A(1, A(0, A(1, 0)))
        = A(1, A(0, A(0, 1)))
        = A(1, A(0, 2))
        = A(1, 3)
        = A(0, A(1, 2))
        = A(0, A(0, A(1, 1)))
        = A(0, A(0, A(0, A(1, 0))))
        = A(0, A(0, A(0, A(0, 1))))
        = A(0, A(0, A(0, 2)))
        = A(0, A(0, 3))
        = A(0, 4)
        = 5

Języki programowania i kompilatory często stosują tzw. optymalizację wywołań ogonowych, które ułatwiają, przyśpieszają obliczenia funkcji rekurencyjnych podobnych do funkcji Ackermana, i używają znacznie mniej pamięci na stosie.

Inną metodą stosowaną przez pewne kompilatory i języki programowania, jest tzw. spamiętywanie (tablicowanie, memoizacja, forma pamięci podręcznej) wartości funkcji – raz obliczona wartość funkcji A(m, n), jest zapamiętywana w specjalnej tablicy, którą można oznaczyć na przykład przez A[m, n]; przy następnych wywołaniach zamiast wykonywać całość obliczeń z nią związanych odczytuje się wcześniej obliczony wynik bezpośrednio z tablicy w jednym kroku. Przykład w języku Python:

Ack_tab = {}
def Ack(m, n):
   if (m, n) in Ack_tab:
      return Ack_tab[(m, n)]
   else:
     if m==0: r = n + 1
     if m>0 and n==0: r = Ack(m-1, 1)
     if m>0 and n>0: r = Ack(m-1, Ack(m, n-1))
     Ack_tab[(m, n)] = r
     return r

albo:

def ack(m, n, cache={}):
    if (m, n) in cache:
        return cache[(m, n)]
    elif m==0:
        cache[(m, n)] = n + 1
    elif m>0 and n==0:
        cache[(m,n)] = ack(m-1, 1, cache)
    elif m>0 and n>0:
        cache[(m, n)] = ack(m-1, ack(m, n-1, cache), cache)
    return cache[(m,n)]

W porównaniu do poprzedniego przykładu (14 wywołań), ta wersja wykona 10 wywołań rekurencyjnych, przy czym jedno z nich – A(1,1) zostanie spamiętane i wykorzystane w 11 wywołaniu funkcji do bezpośredniego zwrócenia wyniku. Przy większych n oraz m zysk jest również większy, dla przykładu A(3,3) wymaga 2432 wywołań, a wersja ze spamiętywaniem 186 wywołań, zapamiętując w tablicy 154 różne pary (m, n) wraz z odpowiadającymi im wartościami.

Odwrotna funkcja Ackermanna

Jeśli oznaczymy to możemy rozpatrywać również jej odwrotność Jest to funkcja niesłychanie wolno rosnąca (asymptotycznie wolniej niż logarytm, a nawet logarytm iterowany). We wszystkich wyobrażalnych praktycznych zastosowaniach można uznać tę funkcję za stałą, nie większą niż 5, ponieważ wynosi około Używając funkcji można wyznaczyć, że tempo wzrostu w szybko rosnącej hierarchii można zapisać jako:

Definiuje się również dwuargumentową odwrotność funkcji Ackermanna:

Pojawia się ona czasami przy badaniu złożoności obliczeniowej (np. algorytm Union-Find).

Linki zewnętrzne