Rekurzija in rekurzivni tipi
Vsebina
6. Rekurzija in rekurzivni tipi¶
Rekurzija je eden od osnovnih konceptov v računalništvu, ki se pojavlja na različnih nivojih.
6.1. Splošna oblika rekurzije¶
Obravnavajmo rekurzivno funkcijo f
, ki računa faktorielo. V Javi bi jo zapisali takole:
public static int f(int n) {
if (n == 0) {
return 1 ;
} else {
return n * f(n - 1)
}
}
Ekvivalentna definicija v Pythonu:
def f(n):
if n == 0:
return 1
else:
return n * f(n -1)
Ekvivalentna definicija v OCamlu:
let rec f n =
if n = 0 then 1 else n * f (n - 1)
Definicijo razstavimo na dva dela: na telo rekurzije, ki samo po sebi ni rekurzivno, in na
rekurzivni sklic funkcije f
same nase:
let telo g n =
if n = 0 then 1 else n * g (n - 1)
let rec f n = telo f n
Samo drugi del je rekurziven. Še malo drugače:
let telo g = fun n -> if n = 0 then 1 else n * g (n - 1)
let rec f n = telo f n
Vsako rekurzivno funkcijo lahko razstavimo na ta način in drugi del je vedno enak.
Definirajmo si funkcijo rek
(v angleščini običajno rec
ali fix
), ki sprejme telo
rekurzivne definicije t
in vrne pripadajočo rekurzivno funkcijo:
let rec rek t = fun x -> t (rek t) x
Vsa rekurzija je shranjena v rek
, od tu naprej je ne potrebujemo več:
let f = rek (fun self -> (fun n -> if n = 0 then 1 else n * self (n - 1)))
Poglejmo si tip funkcije rek
. OCaml je izpeljal njen tip:
(('a -> 'b) -> 'a -> 'b) -> 'a -> 'b
Tipa 'a
in 'b
sta parametra, ki označujeta poljubna tipa. Zapišimo lepše z α
in β
:
((α → β) → (α → β)) → (α → β)
Preberemo: »rek
je funkcija, ki sprejme funkcijo t
tipa (α → β) → (α → β)
in vrne funkcijo
tipa α → β
.«
Poglejmo si postopek še enkrat, tokrat zapisan z λ-računom:
Prvotna definicija
f
se glasi:f n = if n = 0 then 1 else n * f (n - 1)
Zapišemo s pomočjo λ-abstrackije:
f = λ n . if n = 0 then 1 else n * f (n - 1)
Ločimo rekurzijo in telo funkcije:
f = t f
kjer jet = (λ g n . if n = 0 then 1 else n * g (n - 1))
S pomočjo
rek
definiramof
:f = rek t
Kadar imamo preslikavo \(h\) in točko \(x\), ki zadošča enačbi \(x = h(x)\), pravimo, da je \(x\) negibna točka preslikave \(h\). V numeričnih metodah je eden od osnovih postopkov reševanja enačb ta, da enačbo zapišemo v obliki \(x = h (x)\) in nato iščemo njeno rešitev kot zaporedje približkov
Negibne točke so pomembne tudi na drugih področjih matematike in o njih matematiki veliko vedo.
Opomba: če imam enačbo \(\ell(x) = d(x)\), jo lahko prepišemo v obliko \(x = d(x) - \ell(x) + x\) in definiramo \(h(x) = d(x) - \ell(x) + x\), da dobimo \(x = h(x)\).
Ugotovili smo, da je tudi rekurzivna definicija funkcije \(f\) pravzaprav enačba, ki ima oblike negibne točke:
Pomnimo:
Rekurzivno definirana funkcija je negibna točka.
6.1.1. Rekurzivna funkcija več argumentov¶
Ali to deluje tudi za rekurzivne funkcije dveh argumentov? Seveda!
s (n, k) = (if k = 0 then n else s (n + k, k - 1))
s = λ (n, k) . if k = 0 then n else s (n + k, k - 1)
s = t s
, kjer jet = λ self . λ (n, k) . if k = 0 then n else self (n + k, k - 1)
s = rek t
6.1.2. Hkratna rekurzivna definicija¶
Kaj pa definicija rekurzivnih funkcij f
in g
, ki kličeta druga drugo?
Primer: funkcija f
kliče f
in g
, funkcija g
pa kliče f
:
let rec f x = if x = 0 then 1 + f (x - 1) else 2 + g (x - 1)
and g y = if y = 0 then 1 else 3 * f (y - 1)
Če obravnavamo f
in g
skupaj kot urejeni par (f, g)
dobimo
(f, g) = ((λ x . if x = 0 then 1 + f (x - 1) else 2 + g (x - 1)),
(λ y . if y = 0 then 1 else 3 * f (y - 1)))
To je rekurzivna definicija urejenega para (funkcij), kar prepišemo v
(f, g) = t (f, g)
kjer je
t = λ (f', g') . ((λ x . if x = 0 then 1 + f' (x - 1) else 2 + g' (x - 1)),
(λ y . if y = 0 then 1 else 3 * f' (y - 1)))
Torej tudi za hkratne rekurzivne definicije velja, da so to negibne točke.
6.1.3. Iteracija je poseben primer rekurzije¶
V proceduralnem programiranju poznamo zanke, na primer zanko while
. Ali je tudi ta negibna
točka? Če upoštevamo ekvivalenco
while b do c done
in
if b then (c ; while b do c done) else skip
vidimo, da je zanka while b do c done
negibna točka. Če pišemo W
za našo zanko:
W ≡ (if b then (c ; W) else skip)
Torej je W
in s tem zanka while b do c done
negibna točka funkcije:
t = (λ W . if b then (c ; W) else skip)
saj velja
(while b do c done) = t (while b do c done)
Tudi iteracija je negibna točka!
Opomba: zanko while
lahko na zgornji način »odvijamo v nedolged«:
Faza 0:
while b do c done
Faza 1:
if b
then
(c ; while b do c done)
else skip
Faza 2:
if b
then
(c ;
if b
then
(c ; while b do c done)
else skip
)
else skip
Faza 3:
if b
then
(c ;
if b
then
(c ;
if b
then
(c ; while b do c done)
else skip
)
else skip
)
else skip
In tako naprej. Če bi lahko imeli neskončno programsko kodo, ne bi potrebovali zank!
6.1.4. Rekurzivne podatkovne strukture¶
Rekurzivno lahko definiramo tudi ostale strukture, ne samo funkcije. Na primer, neskončni seznam
ℓ = [1; 2; 1; 2; 1; 2; ...]
lahko definiramo kot
ℓ = 1 :: 2 :: ℓ
OCaml dopušča take definicije v omejeni meri (in tudi niso uporabne, ker je OCaml neučakan jezik). Haskell omogoča splošne rekurzivne definicije podatkov.
6.2. Rekurzivni in induktivni tipi¶
Do sedaj smo spoznali podatkovne tipe:
produkt
a * b
in zapisivsota
a + b
eksponent
a → b
S temi konstrukcijami ne moremo dobro predstaviti bolj naprednih podatkovnih tipov, kot so seznami in drevesa. Poglejmo si na primer, kako se tvori sezname celih števil:
prazen seznam:
[]
je seznamsestavljen seznam: če je
x
celo število inℓ
seznam, je tudix :: ℓ
seznam
Zapis [1; 2; 3]
je okrajšava za 1 :: (2 :: (3 :: []))
.
Seznami so rekurzivni podatkovni tip, saj gradimo sezname iz seznamov. Brez uporabe
posebnih oznak []
in ::
bi zgornjo definicijo zapisali takole (oznaki Nil
in Cons
izhajata
iz programskega jezika LISP, kjer pišemo nil
in (cons x ℓ)
):
prazen seznam:
Nil
je seznamsestavljen seznam: če je
x
celo število inℓ
seznam, je tudiCons (x, ℓ)
seznam
Seznam [1; 2; 3]
je okrajšava za Cons (1, Cons (2, Cons (3, Nil)))
.
V OCamlu se tako definicijo zapiše takole:
type seznam =
| Nil
| Cons of int * seznam
Spet imamo opravka z rekuzijo. Tipi, ki se sklicujejo sami nase v svoji definiciji, se imenujejo rekurzivni tipi.
In spet vidimo, da je rekuzija negibna točka. Podatkovni tipi seznam
je negibna točka za
preslikavo T
, ki slika tipe v tipe:
seznam = T (seznam)
kjer je T
definiran kot
T a = (Nil | Cons of int * a)
Z besedami: T
je funkcija, ki sprejme poljuben tip a
in vrne vsoto tipov
Nil | Cons of int * a
.
6.2.1. Induktivni tipi¶
Izhajamo iz definicije seznama:
type seznam = Nil | Cons of int * seznam
Vprašajmo se: ali ta definicija zajema neskončne sezname? Na primer:
Cons (1, Cons (2, Cons (3, Cons (4, Cons (5, ...)))))
Ali se mora to kdaj zaključiti z Nil
? Možna sta dva odgovora. Če zahtevamo, da morajo
biti elementi rekurzivnega tipa končni, govorimo o induktivnih tipih. Če pa dovolimo
neskončne elemente, govorimo o koinduktivnih tipih.
Poglejmo si najprej induktivne podatkovne tipe. To so rekurzivni tipi, v katerih vrednosti sestavljamo začenši z osnovnimi s pomočjo konstruktorjev in neskončne vrednosti niso dovoljene. Primeri:
naravna števila
končni seznami
končna drevesa
abstraktna sintaksa jezika:
programski jeziki
jeziki za označevanje podatkov
hierarhija elementov v uporabniškem vmesniku
6.3. Primer: naravna števila¶
Definicija naravnega števila:
0
je naravno številoče je
n
naravno število, je tudin⁺
naravno število (ki mu rečemo »naslednikn
«)
Deficija podatkovnega tipa:
type stevilo = Nic | Naslednik of stevilo
Ta definicija ni učinkovita, ker predstavi naravna števila z naslednikom, torej v »eniškem« sistemu. Naravna števila bi lahko definirali tudi takole:
0
je naravno številoče je
n
naravno število, je tudishl0 n
naravno številoče je
n
naravno število, je tudishl1 n
naravno število
Ideja: z shl0 n
predstavimo število 2 · n + 0
in z shl1 n
predstavimo število 2 · n + 1
.
Primer: število
shl0 (shl1 (shl0 (shl1 0)))
je število 10
. Kot podatkovni tip:
type stevilo = Zero | Shl0 of stevilo | Shl1 of stevilo
Vendar to še vedno ni optimalna rešitev, ker lahko število nič predstavimo na neskončno načinov:
0 = Shl0 0 = Shl0 (Shl0 0) = Shl0 (Shl0 (Shl0 0)) = ⋯
Vaja
Poiščite predstavitev dvojiških števil z induktivnimi tipi (lahko jih je več), da bo imelo vsako nenegativno celo število natanko enega predstavnika.
Primer
Definicijo standarda JSON in iz nje izluščimo podatkovni tip:
type json =
| String of string
| Number of int
| Object of (string * json) list
| Array of json array (* Ocaml ima vgrajen array *)
| True
| False
| Null
6.3.1. Splošni rekurzivni tipi¶
Rekurzivni tipi so lahko zelo nenavadni:
type d = Foo of (d -> bool)
Poskusite si predstavljati, kaj so vrednosti tega tipa…
6.3.2. Strukturna rekurzija¶
Ker so induktivni podatkovni tipi definirani rekurzivno, jih običajno obdelujemo z rekurzivnimi funkcijami. Kot primer si oglejmo, kako bi implementirali iskalna drevesa.
Obravnavajmo preprosta iskalna drevesa, v katerih hranimo cela števila. Iskalno drevo je
bodisi prazno
bodisi sestavljeno iz korena, ki je označen s številom
x
, ter dveh poddrevesl
inr
pri čemer velja:vsa števila v vozliščih
l
so manjša odx
,vsa števila v vozliščih
r
so večja odx
Primer:
5
/ \
3 8
\ \
4 10
/ \
9 20
Podatkovni tip v OCaml se glasi:
type searchtree = Empty | Node of int * searchtree * searchtree
V tipu nismo shranili informacije o tem, da je iskalno drevo urejeno! Če bo programer ustvaril iskalno drevo, ki ni pravilno urejeno, prevajalnik tega ne bo zaznal.
Naloga
Sestavite funkcije za iskanje, vstavljanje in brisanje elementov v iskalnem drevesu.
6.4. Koinduktivni tipi¶
Poznamo še en pomembno vrsto rekurzivnih tipov, to so koinduktivni tipi. Pojavljajo se v računskih postopkih, ki so po svoji naravi lahko neskonči.
Tipičen primer koinduktivnega tipa je komunikacijski tok podatkov:
bodisi je tok podatkov prazen (komunikacije je konec)
bodisi je na voljo sporočilo
x
in preostanek toka
Primere take komunikacije najdemo povsod, kjer program komunicira z okoljem ali z drugim programom: odjemalec s strežnikom, dva programa med seboj, program z uporabnikom ipd.
Če preberemo zgornjo definicijo kot induktivni tip, se ne razlikuje od definicije seznamov. To bi pomenilo, da bi moral biti komunikacijski tok vedno končen, kar je nespametna predpostavka. V praksi seveda komunikacija ni dejansko neksnončna, a je potencialno neskončna, kar pomeni, da lahko dva procesa komunicirata v nedogled in brez vnaprej postavljene omejitve.
Koinduktivni tipi so rekurzivni tipi, ki dovoljujejo tudi neskončne vrednosti. Vendar pozor, kadar imamo opravka z neskončno velikimi seznami, drevesi itd., moramo paziti, kako z njimi računamo. Izogniti se moramo temu, da bi neskončno veliko drevo ali komunikacijski tok poskušali izračunati v celoti do konca.
Haskell ima koinduktivne podatkovne tipe.
6.4.1. Tokovi¶
Poglejmo si različico tokov, ki so vedno neskončni, ker pri njih koinduktivna narava pride še bolj do izraza. Tok je:
sestavljen iz sporočila in preostanka toka
Če to definicijo preberemo induktivno, dobimo prazen tip, saj ne moremo začeti. Res, če zapišemo v OCaml
type 'a stream = Cons of 'a * 'a stream
dobimo podatkovni tip, ki nima nobene končne vrednosti. Vrednost bi bila nujno neskončna, na primer:
Cons (1, Cons (2, Cons (3, Cons (4, …))))
Cons (1, Cons (1, Cons (1, Cons (1, …))))
OCaml sicer dopušča nekatere take vrednosti z definicijo let rec
:
# let rec s = Cons (1, Cons (2, s)) ;;
val s : int stream = Cons (1, Cons (2, <cycle>))
A te vrednosti le pokvarijo induktivno naravo tipov, hkrati pa ne dovoljujejo poljubnih neskončnih vrednosti. Če bi imele v praksi uporabno vrednost, bi jih morda tolerirali, ker pa jih redkokdaj uporabimo, smo lahko nekoliko razočarani nad odločitvijo snovalcev OCamla, da jih dovolijo.
6.4.2. Tokovi v Haskellu¶
Ista definicija v Haskellu deluje, ker ima Haskell koinduktivne tipe.
V Haskellu podatkovne tipe pišemo nekoliko drugače:
imena tipov se piše z velikimi začetnicami:
Bool
,Integer
, …produkt tipov
a
inb
zapišemo(a,b)
, se pravi tako kot urejene pare. Na primer, elmenti tipa(Bool, Int)
so(False, 0)
,(False, 42)
,(True, 23)
itd.enotski tip pišemo
()
, torej tako kot njegovo edino vrednost.zapis
e :: t
pomeni »e
ima tipt
«, zapise : t
pa seznam z glavoe
in repomt
(ravno obratno, kot v OCamlu)podatkovni tip uvedemo z določilom
data
in parameter pišemo za ime tipa z malimi črkami. Torej namesto'a stream
zapišemoStream a
.
A to so le podrobnosti konkretne sintakse.
Poglejmo si definicijo tokov:
data Stream a = Cons (a, Stream a)
-- tok iz samih enic
enice :: Stream Integer
enice = Cons (1, enice)
-- prvih n elementov toka daj v seznam
to_list :: Integer -> Stream a -> [a]
to_list 0 _ = []
to_list n (Cons (x, s)) = x : (to_list (n-1) s)
-- tok števil od k naprej
from :: Integer -> Stream Integer
from k = Cons (k, from (k + 1))
6.4.3. Tokovi v OCamlu¶
V OCaml lahko simuliramo tokove z uporabo tehnike zavlačevanja (angl. »thunk«).
Denimo da imamo izraz e
tipa t
, ki ga zaenkrat še ne želimo izračunati. Tedaj ga lahko
predelamo v funkcijo fun () -> e
(po angleško se imenuje taka funkcija thunk), ki je
tipa unit -> t
. Ker je e
znotraj telesa funkcije, se bo izračunal šele, ko funkcijo
uporabimo na ()
. Od tod dobimo idejo, kako bi predstavili t.i. lene vrednosti v OCaml:
type 'a stream = Cons of 'a * (unit -> 'a stream)
Primeri uporabe:
type 'a stream = Cons of 'a * (unit -> 'a stream)
(* Neskončni tok enic *)
let enice =
let rec e () = Cons (1, e) in
e ()
(* Neskončni tok k, k+1, k+2, ... *)
let rec count k = Cons (k, (fun () -> count (k+1)))
(* Neskončni tok enic *)
let rec enice =
let rec generate () = Cons (1, generate) in
generate ()
(* Prvih n elementov toka pretvori v seznam *)
let rec to_list k s =
match (k, s) with
| (0, _) -> []
| (n, Cons (x, s)) -> x :: to_list (n-1) (s ())
(* n-ti element toka *)
let rec elementAt k s =
match (k, s) with
| (0, Cons (x, _)) -> x
| (n, Cons (_, s)) -> elementAt (n-1) (s ())
(* Potenčne vrste. Tok a₀, a₁, a₂, ... predstavlja potenčno vrsto
a₀ + a₁ x + a₂ x² + a₃ x³ + ...
*)
(* Izračunaj vrednost potenčne vrste s v točki x, uporabi prvih n členov *)
let rec eval n x s =
let rec loop k xpow v (Cons (a, t)) =
match k with
| 0 -> v
| k -> loop (k-1) (xpow *. x) (v +. a *. xpow) (t ())
in
loop n 1.0 0.0 s
(* V pythonu:
def eval (n, x, s):
k = n
xpow = 1.0
v = 0
while k > 0:
a = s.getNext()
v = v + a * xpow
xpow = xpow * x
k = k - 1
return v
*)
(* Potenčna vrsta za eksponentno funkcijo exp(x). Koeficienti so:
1/0!, 1/1!, 1/2!, 1/3!, 1/4!, …
*)
let exp =
let rec exp k a = Cons (a, fun () -> exp (k+1) (a /. float k)) in
exp 1 1.0
(* Potenčna vrsta za sinus *)
let sin =
let rec sin k a = Cons (0.0, fun () -> Cons (a, fun () -> sin (k+2) (-. a /. float (k * (k+1)))))
in sin 2 1.0
(* Odvod vrste
a₀ + a₁ x + a₂ x² + a₃ x³ + a₄ x⁴ + ...
je
a₁ + 2 a₂ x¹ + 3 a₃ x² + 4 a₄ x³ + ...
*)
let diff (Cons (_, s)) =
let rec diff' k (Cons (a, s)) = Cons (float k *. a, fun () -> diff' (k+1) (s ()))
in diff' 1 (s ())
let cos = diff sin
Vhod/izhod kot koinduktivni tip¶
Še en primer koinduktivnega tipa je vhod/izhod (input/output). Tokrat s koinduktivnim tipom izrazimo strukturo programa, ki izvaja operaciji Read
in Write
:
-- prazna vrstica, ker se ni vidi vrha zaslona
--
-- Program, ki izvaja operaciji Read in Write
data InputOutput a =
Read (String -> InputOutput a) -- program prebere niz in nadaljuje delo
| Write (String, InputOutput a) -- program izpiše niz in nadaljuje delo
| Return a -- program konča z danim rezultatom
-- Primer: program, ki izpiše "Hello, world!" in konča z rezultatom ()
hello_world :: InputOutput ()
hello_world = Write ("Hello, world!", Return ())
-- Primer: greeter n uporabnika n-krat vpraša po imenu in ga pozdravi, nato
-- vrne 42
greeter :: Integer -> InputOutput Integer
greeter 0 = Return 42
greeter n = Write ("Your name?", Read (\ str -> Write ("Hello, " ++ str, greeter (n-1))))
-- Lahko bi naredili tudi potencialno neskončni program?
annoyance :: InputOutput ()
annoyance = Write ("Should I stop? (Y/N)",
Read (\answer -> case answer of { "Y" -> Write ("Bye", Return ()) ; _ -> annoyance }))
-- Ker simuliramo I/O, moramo simulirati tudi operacijski sistem.
-- To naredimo s funkcijo, ki sprejme seznam vhodnih nizov,
-- niz, v katerem hranimo do sedaj izpisane podatke, in program.
-- Funkcija vrne rezultat programa in skupni niz, ki ga je program izpisal.
os :: [String] -> String -> InputOutput a -> (a, String)
os _ output (Return v) = (v, output)
os (str : input) output (Read p) = os input output (p str)
os [] output (Read _) = error "kernel panic: input not available"
os input output (Write (str, p)) = os input (output ++ str) p
primer1 = os [] "" hello_world
-- Dobimo: ((),"Hello, world!")
primer2 = os ["Janezek", "Micka", "Mojca", "Darko"] "" (greeter 3)
-- Dobimo: (42,"Your name?Hello, JanezekYour name?Hello, MickaYour name?Hello, Mojca")
primer3 = os ["Janezek", "Micka", "Mojca", "Darko"] "" (greeter 5)
-- Dobimo izjemo: *** Exception: kernel panic: input not available
-- Neskončen seznam "N"
no = "N" : no
primer4 = os no "" annoyance
-- Dobimo: se zacikla