Storseminarium 3.2 - En sak i taget
Innan seminariet ska du ha gått igenom Inför seminariet nedan och gjort tillhörande quiz. Syftet med detta är att du ska bekanta dig med innehållet så eventuella frågor kan redas ut under seminariet.
Denna sida visar en del av det som kommer att diskuteras på seminariet. Det kan hända att handledarna också tar upp andra uppgifter som inte behöver något specifikt studiematerial och då syns dessa uppgifter inte på sidan.
Inför seminariet
Hyperoperationer
Vi ska börja med att titta på en funktion som är dubbelrekursiv och se hur tunga beräkningar detta kan leda till. Vi ska inte gå på djupet in i vad funktionen faktiskt gör och man behöver inte känna sig stressad om man inte förstår varför funktionen kan göra vad den gör. Det gäller även assistenterna, för vilka detta troligtvis är en ny funktion.
Detta ska istället dels som en övning i att översätta matematisk notation till Python, och dels som en illustration av hur snabbt dubbelrekursion kan leda till så många rekursiva anrop att det inte är praktiskt görbart att beräkna. Lägg alltså inte för mycket tid på denna enda uppgift.
Vi ska titta på konceptet hyperoperation. Hyperoperationer definieras med hjälp av dubbelrekursion. Hyperoperationen $H_n(a, b)$ omnämndes först i början av 1900-talet men är mest associerad med Wilhelm Ackermann som definierade den snarlika Ackermannfunktionen i slutet av 1920-talet. Både hyperoperationen och Ackermannfunktionen är relaterade till den teoretiska grunden för datavetenskap och varför vissa funktioner är eller inte är beräkningsbara. Mer specifikt är de exempel på funktioner som inte kan lösas med for
-loopar eftersom det inte är möjligt att beräkna antalet steg som krävs utan att genomföra själva beräkningen.
Vad hyperoperationen faktiskt gör ska vi försöka lista ut genom att implementera den i Python och testa med olika värden.
Den n:te hyperoperationen av två tal $a$ och $b$ definieras som följande:
$$ H_n(a,b) =% a[n]b = \begin{cases} S(b) & \text{if } n = 0 \\ a & \text{if } n = 1 \text{ och } b = 0 \\ 0 & \text{if } n = 2 \text{ och } b = 0 \\ 1 & \text{if } n \ge 3 \text{ och } b = 0 \\ H_{n-1}(a, H_n(a, b - 1)) & \text{i alla andra fall} \end{cases} $$
I definitionen används $S(b)$ för att beteckna successor-funktionen, dvs. funktionen som returnerar “nästa heltal”. Alltså, $S(b) = b + 1$. Vi kan, om än på ett lite opraktiskt sätt, definiera alla positiva heltal med hjälp av successor-funktionen och utgångsvärdet $0$, t.ex. gäller att $1 = S(0)$ och $2 = S(S(0))$, osv. Det här sättet att definiera heltal kallas Peano-aritmetik och är en grundläggande byggsten i matematiken. Faktiskt så grundläggande att det är svårt att föreställa sig att det finns något mer grundläggande än så, och också så grundläggande att man nog aldrig ens tänkt på att det kan behöva definieras.
I Python kan vi dock helt enkelt byta ut alla ställen där $S(b)$ används med b + 1
.
Ett annat sätt att definiera $H_n$, som introducerades av Reuben Goldstein på 40-talet, kan vara lite lättare att översätta till Python:
$$ H_n(a,b) = G(n,a,b) =% a[n]b = \begin{cases} S(b) & \text{if } n = 0 \\ a & \text{if } n = 1 \text{ och } b = 0 \\ 0 & \text{if } n = 2 \text{ och } b = 0 \\ 1 & \text{if } n \ge 3 \text{ och } b = 0 \\ G(n-1, a, G(n, a, b - 1)) & \text{i alla andra fall} \end{cases} $$
Uppgift:
Vi ska definiera en funktion hyperop(n, a, b)
som tar tre argument: ett icke-negativt heltal n
, och två positiva heltal a
och b
. Funktionen ska beräkna hyperoperationen av ordning n
av a
och b
.
def hyperop(n, a, b):
if n == 0:
return b + 1
elif n == 1 and b == 0:
return a
elif n == 2 and b == 0:
return 0
elif n >= 3 and b == 0:
return 1
else:
return hyperop(n-1, a, hyperop(n, a, b - 1))
Uppgift:
Vi kan nu testa vår funktion med några små värden på n
, a
och b
. Sätter vi a
och b
till 0, 1, eller till och med 2, så är resultatet svårt att tolka. Det är först när vi sätter a
eller b
till 3 eller mer som det börjar bli lättare att se vad som händer. Vi kan t.ex. beräkna hyperop(n, 2, 3)
för olika värden på n
. Testa följande kod:
print(f"{hyperop(0, 2, 3)=}")
print(f"{hyperop(1, 2, 3)=}")
print(f"{hyperop(3, 2, 3)=}")
print(f"{hyperop(4, 2, 3)=}")
Vi kan också testa att sätta a
till 3 och b
till 2. Testa följande kod:
print(f"{hyperop(0, 3, 2)=}")
print(f"{hyperop(1, 3, 2)=}")
print(f"{hyperop(3, 3, 2)=}")
print(f"{hyperop(4, 3, 2)=}")
Kan vi tänka oss vad hyperop
egentligen beräknar? Vad är det för operationer som sker när n
är 0, 1, 2, 3 och 4?
hyperop(0, 2, 3)=4 # 3 + 1
hyperop(1, 2, 3)=5 # 2 + 3
hyperop(2, 2, 3)=6 # 2 * 3
hyperop(3, 2, 3)=8 # 2 ** 3
hyperop(4, 2, 3)=16 # (2 ** 2) ** 2
hyperop(0, 3, 2)=3 # 2 + 1
hyperop(1, 3, 2)=5 # 3 + 2
hyperop(2, 3, 2)=6 # 3 * 2
hyperop(3, 3, 2)=9 # 3 ** 2
hyperop(4, 3, 2)=27 # 3 ** 3
I kapitlet om enkelrekursion såg vi att multiplikation kan implementeras som upprepad addition. Hyperoperationer generaliserar detta koncept ytterligare genom att definiera en serie av operationer som bygger på varandra. Alla med successor-funktionen som utgångspunkt. Upprepning av successor ger addition, upprepning av addition ger multiplikation, upprepning av multiplikation ger exponentiering, och så vidare.
Mer formellt för den nyfikne:
Hyperoperationen definierar en serie av operationer som bygger på varandra:
- När $n = 0$, blir operationen $S(b) = b + 1$, dvs. vi lägger till 1 till $b$. Alltså, $H_0(a, b) = b + 1$.
- När $n = 1$, blir operationen addition. Alltså $H_1(a, b) = a + b$. Detta kan ses från basfallet där om $b = 0$, så returneras $a$, och för andra värden av $b$, adderas $a$ till $b$ genom att successor appliceras $b$ gånger på $a$. Dvs. $H_1(a, b) = S(S(…S(a)…))$ där $S$ appliceras $b$ gånger på $a$.
- När $n = 2$, blir operationen multiplikation. Alltså $H_2(a, b) = a \times b$ . Detta kan också härledas från basfallet där om $b = 0$, så returneras $0$, och för andra värden av $b$, multipliceras $a$ med $b$ genom att $a$ adderas till sig själv $b$ gånger. Dvs. $H_2(a, b) = a + a + … + a$ där $a$ adderas $b$ gånger. Själva additionen implementeras i sin tur rekursivt genom att successor appliceras $b$ gånger på $a$.
- När $n = 3$, blir operationen exponentiering. Alltså $H_3(a, b) = a^b$ ("$a$ upphöjt till $b$"). Detta kan ses från basfallet där om $b = 0$, så returneras $1$, och för andra värden av $b$, beräknas $a$ upphöjt till $b$ genom att a multipliceras med sig självt $b$ gånger. Dvs. $H_3(a, b) = a \times a \times … \times a$ där $a$ multipliceras $b$ gånger. Själva multiplikationen implementeras i sin tur rekursivt… Etc.
- När $n = 4$, kallas operationen tetration, vilket innebär att vi bygger en exponentiellt växande stapel av $a$ upphöjt till sig själv $b$ gånger. I matematisk notation skrivs detta, när det behöver skrivas, $H_4(a, b)={^ba}$. Detta växer mycket snabbt och är inte lika vanligt förekommande som de tidigare operationerna.
På samma sätt fortsätter hyperoperationen med högre ordningar som pentation ($n=5$), hexation ($n=6$), och så vidare, där varje operation innebär att vi applicerar den föregående operationen på $a$ exakt $b$ gånger. Detta är definierat för alla värden på $n$, även om det inte finns namn på operationerna för stora $n$ annat än just $H_n$.
Om man kan ha hyperoperationer till någon annan praktisk användning än bevisföring inom teoretisk datalogi är jag tveksam till. Det är bevisligen ett extremt ineffektivt sätt att beräkna vanliga aritmetiska uttryck. Det är dock en intressant (nåja) matematisk konstruktion som visar att addition, multiplikation, potens, osv. hänger ihop och alla kan härledas från, och definieras i termer av, successor-funktionen, dvs. funktionen som returnerar “nästa heltal”. Samtidigt illustrerar den hur snabbt antalet anrop kan växa när vi använder dubbelrekursion.
Hyperoperationerna visar också några samband som kan vara intressanta att notera, t.ex. att $H_n(2, 2)$ för alla $n > 0$ är 4. Dvs:
$$ \begin{align*} H_1(2, 2) & = 2 + 2 = 4 \\ H_2(2, 2) & = 2 \times 2 = 4 \\ H_3(2, 2) & = 2^2 = 4 \\ H_4(2, 2) & = {^2}2 = 4 \\ & \vdots \\ H_n(2, 2) & = 4 \text{ för alla } n > 0 \end{align*} $$
För den med intresse för matematik, matematisk filosofi, eller teoretisk datalogi, kan hyperoperationer vara ett intressant ämne att utforska vidare. En bra startpunkt är Wikipedia-artikeln om hyperoperationer.
Uppgift:
Vad händer om vi försöker beräkna hyperop(5, 7, 3)
? Kan vi tänka oss vad resultatet borde bli? Kan vi beräkna det?
Detta blir snabbt mycket stora tal och mycket komplexa operationer. Vi kan se att även för små värden på a
och b
, så blir resultaten av hyperoperationen mycket stora när n
ökar.
Sätter vi a = 5
och b = 7
och försöker beräkna hyperop(5, 7, 3)
dvs. 5**7
så kommer vi inte kunna få fram något resultat eftersom vi slår i Pythons begränsningar för anropsstacken. Dvs. vi får ett RecursionError
.
Vi kan höja gränsen för anropsstacken med sys.setrecursionlimit
, men det ger oss bara möjlighet att beräkna lite större värden. Väldigt snabbt slår vi istället i taket för hur mycket minne som operativsystemet tillåter Python att använda. Vi kan t.ex. testa följande kod:
import sys
sys.setrecursionlimit(100000)
print(f"{hyperop(4, 5, 7)=}")
Här får vi inte ens ett RecursionError
, utan programmet kraschar fullständigt med ett SIGSEGV
. Segmentation fault, eller address boundary error, dvs. Python har försökt använda minne utanför vad som allokerats av operativsystemet. Det är alltså inte ens Pythons felhantering som träder in här utan en skyddsmekanism i operativsystemet. Exakta begränsningarna kan variera lite från dator till dator, men en gräns finns alltid där.
uppgift:
Modifiera hyperop
så att de första raderna i funktionen ser ut så här:
global call_count
call_count += 1
Testa sedan att köra:
call_count = 0
print(f"{hyperop(3, 3, 2)=} took {call_count} calls")
call_count = 0
print(f"{hyperop(3, 2, 3)=} took {call_count} calls")
call_count = 0
print(f"{hyperop(3, 3, 3)=} took {call_count} calls")
call_count = 0
print(f"{hyperop(3, 4, 3)=} took {call_count} calls")
call_count = 0
print(f"{hyperop(3, 3, 4)=} took {call_count} calls")
call_count = 0
print(f"{hyperop(3, 4, 4)=} took {call_count} calls")
Vad ser du? Kan du förklara varför det blir så många anrop?
Detta beror på den rekursiva egenskapen hos hyperoperationen, där varje nivå av operationen i princip “öppnar upp” för fler anrop. Ju högre nivå av hyperoperation vi arbetar med, desto fler anrop krävs för att beräkna resultatet, vilket leder till exponentiell tillväxt i antalet anrop. Detta illustrerar hur dubbelrekursion kan leda till en explosion i antalet anrop, vilket gör det opraktiskt att beräkna hyperoperationer även för relativt små värden av a
, b
och n
.
Maximal nivå av nästlade listor
Nu när vi sett den praktiska gränsen för vad en dubbelrekursiv funktion kan göra så ska vi titta på några mer realistiska exempel. Vi ska börja med att definiera en funktion max_nesting_level(struct)
som tar en struktur struct
av listor och heltal som argument och returnerar den maximala nivån av nästling i listan. Nivån av nästling definieras som det största antalet nivåer av inbäddade listor inom listan.
Exempel
>>> max_nesting_level([1, 2, 3])
0
>>> max_nesting_level([1, [2, 3], 4])
1
>>> max_nesting_level([1, [2, [3, 4]], 5])
2
>>> max_nesting_level([1, [2, [3, [4, 5]]], 6])
3
Explicit dubbelrekursiv lösning:
|
|
Lösning med ackumulator och for
-loop:
|
|
Överkursvariant med generatoruttryck:
|
|
Maximal nivå av nästling med olika typer
Nu ska vi modifiera max_nesting_level
så att den kan hantera listor som innehåller olika typer av element, inklusive andra listor, tuples, mängder och dictionaries. Funktionen ska fortfarande returnera den maximala nivån av nästling i strukturen. Kalla funktionen max_nesting_level_any(struct)
.
Exempel
>>> max_nesting_level_any([])
0
>>> max_nesting_level_any((1, 2, 3))
0
>>> max_nesting_level_any((1, 2, 3, []))
1
>>> max_nesting_level_any([1, (2, 3), 4])
1
>>> max_nesting_level_any([1, (2, 3, []), 4])
2
>>> max_nesting_level_any([1, [2, {3: 4}], {5}])
3
>>> max_nesting_level_any([1, (2, {3: [4, 5]}, 6)])
4
>>> max_nesting_level_any([1, (2, {(3, (4, 5)): [6, 7]}, 8)])
5
Explicit dubbelrekursiv lösning:
|
|
Lösning med ackumulator och for
-loop:
|
|
Ta bort värden ur nästlade listor
Vi ska nu definiera en funktion remove_value(struct, value)
som tar en struktur struct
som kan innehålla listor och heltal och vara nästlad på flera nivåer. Funktionen ska returnera ett objekt med samma struktur som struct
men där alla förekomster av value
har tagits bort.
Exempel
|
|
Explicit dubbelrekursiv lösning:
|
|
Lösning med ackumulator och for
-loop:
|
|
Räkna upp heltal
Vi ska nu definiera funktion increment_ints(struct)
som tar en struktur som kan innehålla listor, tupler, dictionaries, heltal, flyttal och strängar och vara nästlad på flera nivåer. Funktionen ska returnera ett objekt med samma struktur som struct
men där alla heltal har ökats med 1.
Exempel
|
|
def increment_ints(struct):
if isinstance(struct, int):
return struct + 1
if not struct:
return struct
if isinstance(struct, list):
return [increment_ints(struct[0])] + increment_ints(struct[1:])
elif isinstance(struct, tuple):
return (increment_ints(struct[0]),) + increment_ints(struct[1:])
elif isinstance(struct, dict):
# Vi konverterar dictionaryt till en tuple av dess items
# för att kunna traversera den som en sekvens
struct = tuple(struct.items())
# Vi försäkrar att nyckel-värde-paren i dictionaryt återställs till
# dictionaryformatet när vi är klara med traverseringen
return dict((increment_ints(struct[0]),) + increment_ints(struct[1:]))
else:
return struct
|
|
Sidansvarig: Johan Falkenjack
Senast uppdaterad: 2025-10-03