Lõpprekursioon ja keerukus
Kui rekursiivne avaldis sisaldab
mitut (varemarvutatud) avaldist, peab lõpprekursiivse programmi saamiseks
"kaasa vedama" mitut seniarvutatud jadaliiget ja lõpprekursiivse programmi saamine
võib tunduda liiga komplitseerituna, et sellega
üldse jännata.
Vaatleme näitena Fibonacci arve. Itaalia linna Pisa matemaatik Leonardo Fibonacci uuris oma aastal 1200
ilmunud raamatus "Liber Abaci" ("Raamat Arvelauast") järgmist probleemi:
Kui iga vähemalt 1 kuu vana paar küülikuid "toodab" järgmise kuu lõpuks uue
paari küülikuid ja meil on algul vaid
üks paar vastsündinud küülikuid, siis kui palju paare on meil poole aasta, aasta
või kahe aasta pärast
(eeldades, et kõik küülikud on surematud)?
Kui N-dal kuul on K(N) paari
küülikuid, siis järgmise kuu lõpul on alles need K(N) paari.
Kuna eelmise kuu lõpul olemas olnud
K(N-1) paari on kõik juba rohkem kui
1 kuu vanad ja paljunevad, siis
K(N+1) = K(N) + K(N-1)
Eelmisel kuul sündinud K(N)-K(N-1) paari
järgmisel kuul veel ei paljune; tulemusena saame nn Fibonacci rea:
1,1,2,3,5,8,11,...
kus iga järgmine liige on kahe eelmise liikme summa. Selle rea liikemete arvutamine Prologi predikaadina on täiesti analoogiline
faktoriaali arvutamisega, kuid ühe funktsiooni
K(N) väärtuse asemel peetakse meeles kogu rida K(0), K(1), K(2),...:
fibonacci_jada(0,[1]) :-
!.
fibonacci_jada(1,[1,1]) :-
!.
fibonacci_jada(N,[P,P1,P2|Muud]) :-
N1 is N - 1,
fibonacci_jada(N1,[P1,P2|Muud]),
P is P1 + P2.
Kui see predikaat ei ole lõpprekursiivne. Laseme Prologil endal arvutada, mitu alamülesannet fibonacci_jada ta päringu
fibonacci_jada(N,F) täimisel käivitab:
alamylesandeid(0,1) :-
!.
alamylesandeid(1,1) :-
!.
alamylesandeid(N,Ylesandeid) :-
N1 is N - 1,
N2 is N - 2,
alamylesandeid(N1,Ylesandeid1),
alamylesandeid(N2,Ylesandeid2),
Ylesandeid is Ylesandeid1 + Ylesandeid2 + 1.
Et hinnata alamülesannete arvu kasvu, teeme mõne päringu:
?- alamylesandeid(10,N).
N = 177
?- alamylesandeid(20,N).
N = 21891
?- alamülesandeid(30,N).
N = 2692537 ;
ja päringule
?- alamylesandeid(50,N).
tuli 15 minuti pärast vastus:
Local stack full
Et paremini aru saada, kuidas alamülesannete arv kasvab, on kõrval lastud
Prologil välja joonestada selle funktsiooni graafik
(vastavat programmi on selgitatud SWI-Prologi graafikalaiendust
XPCE käsitlevas
osas); on selge, et see funktsioon kasvab eksponentsiaalselt, s.t. selliselt
programmeeritud Fibonacci arvude arvutamise programm kõlbab vaid väga väikeste
argumendi N väärtuste korral.
Fibonacci arvude lõpprekursiivse programmi saame, kui peame meeles (näiteks nimistuna) kaks viimast Fibonacci arvu:
fibonacci_arv(0,1):-!.
fibonacci_arv(N,F):-
fibonacci_jada1(1,N,[1,1],[F|_]).
fibonacci_jada1(N,N,L,L):-!.
fibonacci_jada1(N1,N,[F1,F],L):-
N2 is N1+1,
F2 is F1+F,
fibonacci_jada1(N2,N,[F2,F1],L).
Nüüd kasvab alamülesannete arv lineaarselt: N ühe võrra suurendades lisandub vaid kaks arvutusoperatsiooni:
alamylesandeid1(0,1):-!.
alamylesandeid1(N,Y):-
N1 is N-1,
alamylesandeid1(N1,Y1),
Y is Y1 + 2.
?- alamylesandeid1(20,Y)
Y = 41
?- alamylesandeid1(100,Y)
Y = 201
Alamülesannete arvu täpne hindamine on sageli raske (kas nimistu esitamine [Pea|Keha] notatsioonis on alamülesanne?).
Swi-Prologis on olemas sisseehitatud protseduur time(Eesmärk), mis väljastab Eesmärgi tõestamiseks kulunud aja ja selle aja jooksul
Prologi järeldusmehhanismi poolt tehtud sammude arvu.
Vaatleme näitena nimistu tagurpidi pööramist. Naiivne ettekujutus selle ülesande sooritamiseks vajalikust predikaadist
on tavaliselt selline: pöörame nimistu Keha tagurpidi ja siis liidame selle lõppu nimistu Pea, s.t. üheelemendilise nimistu, mis
koosneb nimistu Pea-st:
nreverse([X|L0],L) :-
nreverse(L0,L1),
concatenate(L1,[X],L).
nreverse([],[]).
concatenate([X|L1],L2,[X|L3]) :-
concatenate(L1,L2,L3).
concatenate([],L,L).
Predikaat nreverse pole lõpprekursiivne. Tegelikult on nimistu tagurpidi pööramine väga lihtne lõpprekursiivne ülesanne: kui
järjest tõsta esialgse nimistu esimene element teise nimistu (mis alguses on tühi) algusesse, siis viimasena tõstetakse esimese nimistu viimane element,
s.t. tulemuseks saamegi esialgse nimistu tagurpidi kujul:
pööra([E|L1],L2,L):-
pööra(L1,[E|L2],L).
pööra([], L,L).
Nende algoritmide hindamiseks genereerime algul predikaadi integers(N,NL) abil nimistu, mis koosneb kõigist täisarvudest 1..N ja
hindame siis mõlema algoritmi nimistu NL tagurpidi pööramisel tehtud järelduste arvu:
integers(N,NL):-
integers(1,N,NL).
integers(Low, High, [Low | Rest]) :-
Low =< High, !,
M is Low+1,
integers(M, High, Rest).
integers(_,_,[]).
test1(N) :-
integers(N,L),
nreverse(L,X).
test2(N):-
integers(N,L),
reverse1(L,[],TL).
?- time(test1(200)).
% 20,906 inferences, 0.01 CPU in 0.01 seconds (100% CPU, 2087594 Lips)
?- time(test2(200)).
% 806 inferences, 0.00 CPU in 0.00 seconds (?% CPU, Infinite Lips)
- lõpprekursiivse meetodi kasutamisel tegi Prolog peaaegu kaks pool korda vähem
tööd
!
Ülesandeid:
1.
Fibonacci jada tüüpi rekursiivselt määratud jadasid kasutatakse sageli nn pseudojuhuslike arvude
saamiseks (arvuti on deterministlik ja siin täiesti juhuslikke arve pole võimalik saada); sageli kasutatakse näiteks jadasid
X(N) = X(N-17) + X(N-5) (mod 2**31)
(selle jada periood on 2**47). Tee programm selliste pseudojuhuslike arvude arvutamiseks (loomulikult peab arvud X(0),X(1),...,X(16) ette andma,
kuid need võib valida vabalt, kui vad vaid ei ole kõik samad).
Küsimused, probleemid:
©2004
Jaak Henno