Informationsteknologi
Malin Källén, Tom Smedsaas
15 mars 2017
Algoritmanalys
Inledning
Exempel 1: xn
När vi talade om rekursion presenterade vi två olika sätt att beräkna
xn ,
ett iterativt:
xn = x · x · x · x · . . . · x
och ett rekursivt:
(
1
xn =
xxn−1
Båda dessa algoritmer genererar
1000)
att generera
1000
n
om
om
(1)
n=0
n>0
(2)
multiplikationer. Exempelvis så kommer anropet
multiplikationer oavsett om
power
power(x,
är implementerad enligt denition
1 eller denition 2. En annan möjlig denition är:


1
n
x = (xn/2 )2


x(xn/2 )2
Notera att
n/2
om
om
om
n=0
n>0
n>0
och
och
n
n
jämn
(3)
udda
betecknar heltalsdivision!
Denna denition är rekursiv, precis som denition 2, men i stället för att återföra lösningen
av ett problem av storleken
av storleken
n/2,
n på ett problem av storleken n − 1 så använder vi oss av problem
d.v.s. ett betydligt mindre problem.
Javakod:
public static double power(double x, int n) {
if (n == 0)
return 1.;
else {
double p = power(x, n/2);
if (n % 2 == 0)
//n jämn
return p*p;
else
//n udda
return x*p*p;
}
}
Anropet
power(x,1000)
kommer att generera följande sekvens av anrop:
power(x,1000) -> power(x,500) -> power(x,250) -> power(x,125) ->
power(x,62) -> power(x,31) -> power(x,15) -> power(x,7) ->
power(x,3)
-> power(x,1) -> power(x,0)
1
Varje uppväckning av
power
utom den sista innehåller en eller två multiplikationer (kvadre-
ring och eventuell multiplikation med
x).
Sammanlagt krävs endast 15 multiplikationer för att
beräkna resultatet!
Övning 1:
Modiera programmet så att det även hanterar negativa exponenter. Tänk re-
kursivt! Hur många anrop kommer ett anrop
power(x, -1000)
att generera?
Asymptotisk notation
Om vi beräknar
xn
enligt denition 1 eller 2, kommer vi att behöva utföra
Om vi däremot använder denition 3, kommer vi bara att behöva utföra
tioner, förutsatt att
n
är en jämn tvåpotens. Om
n
n multiplikationer.
log2 (n) multiplika-
inte är en jämn tvåpotens, krävs högst en
extra multiplikation vid varje halvering av problemet, d.v.s. totalt
2 · log2 (n)
multiplikationer.
Med de första två denitionerna växer antalet multiplikationer linjärt med
säga att algoritmerna är
O(n)
n.
Man brukar
n. O(n) uttalas ordo n. Med
log2 (n). Man säger att denna algoritm är
log(n). (I nästa avsnitt ska vi se varför man inte
eller att (tids)komplexiteten är
denition 3 växer antalet multiplikationer med
O(log(n)),
eller att (tids)komplexiteten är
behöver ange vilken logaritm som avses.)
Mer formellt
O anger egentligen en övre gräns: En funktion t(n) är O(f (n)) om det existerar två konstanter
c och n0 sådana att:
|t(n)| ≤ c · |f (n)|
∀n > n0
(4)
Om vi applicerar denna denition på exemplen med antalet multiplikationer ovan, betecknar
|t(n)|
antalet multiplikationer som behöver utföras. Använder vi någon av de två första de-
nitionerna är
fall är
c=1
och
f (n) = n.
Med denition 3 är
f (n) = log2 (n)
och
c = 2.
I samtliga
n0 = 0.
I de här exemplen används alltså
O
för att ange (en övre gräns för) hur många gånger en
central operation (i det här fallet multiplikation) används. Notationen kan också användas för
att ange en övre gräns för hur lång tid eller hur stort lagringsutrymme en algoritm behöver.
Tidsåtgången för en algoritm växer normalt lika fort som antalet operationer, men värdet på
c
beror då även på sådant som vilket språk algoritmen är implementerad i och vilken dator
implementationen körs på.
Eftersom
O(2n )
O
betecknar en övre gräns, är en algoritm som är
O(log(n))
också
O(n), O(n2 ),
och så vidare.
Tecknet
Ω
används för att ange en undre gräns: En funktion
två konstanter
c
och
n0
Ω(f (n))
∀n > n0
O(f (n)) och Ω(f (n)) sägs den vara Θ(f (n)).
konstanter c för den övre och den undre gränsen.)
t(n)
man använda olika
är
om det existerar
sådana att:
|t(n)| ≥ c · |f (n)|
Om funktionen
t(n)
är både
2
(5)
(Naturligtvis måste
Ofta säger man
O
när man egentligen menar
Θ.
Notera att det inte spelar någon roll vilken logaritm man använder i
eftersom
loga (x) =
och
1
logb (a) kan bakas in i
c.
O-, Θ-
och
Ω-uttrycken,
1
· logb (x)
logb (a)
Därför behöver man heller inte ange vilken logaritm som avses.
Fler exempel
I detta avsnitt analyserar vi några algoritmer som ni känner till sedan tidigare.
Exempel 2: Indicering i array
Antag att du har en array som du ska hämta första elementet (eller något annat givet element) ur. Detta är ett exempel på när tidsåtgången inte växer med problemstorleken. Oavsett
hur lång arrayen är kommer det att krävas lika många operationer för att hämta elementet.
Indicering i array är alltså
Θ(1)
eller
O(1)
vilket är samma sak.
Exempel 3: Sökning i osorterad array
När det gäller sökning brukar man vid algoritmanalys skilja på lyckad och misslyckad sökning.
Om värdet vi letar efter inte nns i arrayen, säger vi att sökningen misslyckas. En misslyckad
sökning kräver att vi kontrollerar varje element i arrayen. (Hur ska vi annars kunna vara säkra
på att värdet inte nns?) Antalet operationer växer alltså linjärt med problemstorleken (d.v.s.
arrayens längd) och vi säger att en misslyckad sökning i en osorterad array är
Θ(n).
Om det eftersökta värdet nns i arrayen, säger vi att sökningen lyckas. Här är det relevant
att skilja på bästa fall, värsta fall och genomsnitt. I värsta fall behöver vi leta igenom hela
arrayen innan vi hittar det eftersökta värdet. I genomsnitt behöver vi leta igenom halva arrayen
för att hitta det eftersökta värdet. I båda dessa fall växer antalet operationer linjärt med
problemstorleken. I bästa fall hittar vi det eftersökta värdet på det första stället vi letar på.
Då behövs ett konstant antal operationer -det spelar ingen roll hur lång arrayen är. En lyckad
sökning i en osorterad array är alltså:
• Θ(1)
i bästa fall
• Θ(n)
i genomsnitt
• Θ(n)
i värsta fall
Generellt är man intresserad av en algoritms komplexitet i värsta fall och/eller i genomsnitt.
Exempel 4: Sökning i sorterad array
Om arrayen som vi ska söka i är sorterad, minskar mängden arbete avsevärt. I detta exempel
kallar vi arrayen för
a
och det eftersökta värdet för
i stigande ordning, d.v.s. så att
a[i+1] ≥ a[i]
v.
1. Om arrayen är tom har sökningen misslyckats.
3
Vi antar att elementen i
för alla index
v
i.
a
är sorterade
En sökning går då till såhär:
kan ju omöjligt nnas i en tom array.
2. Om
a[n/2] == v
är vi klara.
3. Annars, upprepa proceduren, den här gången med en hälften så lång array:
a[n/2] > v, ge a[n/2+1:n]
n/2:te elementet) som input.
•
Om
•
Om a[n/2] < v, ge a[0:n/2-1]
n/2:te elementet) som input.
(d.v.s. den delen av
(d.v.s. den delen av
a
a
som ligger till höger om det
som ligger till vänster om det
På det här sättet halverar vi problemstorleken i varje steg. Jämför med hur du själv skulle
leta i en mängd sorterade element, exempelvis en telefonkatalog, en klasslista eller registret i
en bok!
Vid en misslyckad sökning behöver vi fortsätta tills arrayen är tom. Om vi skulle dubblera
längd, skulle antalet nödvändiga operationer öka med
logaritmiskt; algoritmen är
a:s
1. Med andra ord växer mängden arbete
Θ(log(n)).
Vid en lyckad sökning behöver vi utföra samma procedur, men kommer i genomsnitt att hitta
v
efter halva tiden. Mängden arbete växer alltså som
och kan bakas in i
Övning 2:
c.
Algoritmen är alltså
Θ(log(n))
0, 5 · log(n), men 0, 5 är bara en konstant
i genomsnitt.
Hur många operationer kräver en lyckad sökning i en sorterad array i bästa
respektive värsta fall?
Exempel 5: Fibonaccitalen
När vi arbetade med rekursion, visade vi en enkel (men inte särskilt eektiv) metod för att
beräkna det
n:te
Fibonaccitalet:
public static long fib(int n) {
if ( n==0 )
return 0;
else if ( n==1 )
return 1;
else
return fib(n-1) + fib(n-2);
}
Tiden
t(n)
det tar att exekvera denna metod växer exponentiellt med
n.
Mer exakt så är
t(n) ≈ c · 1.618n
där
c
är en konstant som beror på dator, programmeringsspråk och så vidare.
Bevis*
antal
För att inse det kan vi räkna hur många additioner anropet
t(n)
gäller:
(
0
t(n) =
1 + t(n − 1) + t(n − 2)
Observera likheten med Fibonaccitalen!
4
om
om
n ≤ 1,
n > 1.
fib(n)
utför. För detta
Detta är en linjär dierensekvation och den homogena ekvationen (som är lika med Fibonaccitalen!) har den karaktäristiska ekvationen
r2 − r − 1 = 0,
som har lösningen
r1,2
√
1± 5
=
,
2
d.v.s. den homogena ekvationen har lösningen
F (n) = ar1n + br2n ,
där
a
och
b
bestämmes ur begynnelsevillkoren.
Eftersom
t(n) = −1
är en partikulärlösning, kan den allmänna lösningen skrivas
t(n) = ar1n + br2n − 1.
Med hjälp av begynnelsevillkoren kan
a
och
b
bestämmas och ger lösningen
a =(1 − r2 )/(r1 − r2 )
b = − (1 − r1 )/(r1 − r2 )
Eftersom
r1 ≈ 1.618
och
r2 ≈ 0.618 < 1
så ser man att, för stora
n,
gäller
t(n) ≈ 1.618n
Antalet additioner växer således exponentiellt och därmed växer också tiden
t(n) exponentiellt.
QED
Övning 3:
Tag reda på hur lång tid anropen
fib(50)
respektive
fib(100)
tar (skulle ta) på
din dator. Svara med lämpliga enheter! (Sekunder är inte en lämplig enhet om det tar era
timmar. Timmar är inte en lämplig enhet om det tar era dagar eller år.)
♦
Programmet är trädrekursivt, det vill säga varje anrop resulterar i två nya anrop. Detta kan
potentiellt ge orimliga exekveringstider. Om du, när vi arbetade med rekursionsmaterialet,
testkörde växlingsprogrammet med stora värden på
a
och
n,
märkte du förmodligen att exe-
kveringstiden växte väldigt snabbt. Även den algoritmen är trädrekursiv och dess tidsåtgång
växer exponentiellt. Med s.k. dynamisk programmering skulle växlingsproblemet kunna lösas
eektivare, men det ligger utanför den här kursen.
Som vi ser i avsnittet om sortering nedan, behöver trädrekursiva algoritmer inte nödvändigtvis
ge orimliga exekveringstider. I själva verket är många klassiska eektiva algoritmer (t.ex.
sorteringsalgoritmer och snabb Fouriertransform) trädrekursiva.
Övning 4:
I den här övningen vill vi att ni går tillbaka till
SpecialStack,
i vilken ni ck
ändra och implementera några metoder när vi arbetade med rekursion. Tag genom mätning
5
och teoretiska beräkningar reda på hur lång tid detta program tar på din dator när stackens
storlek är 10, 50 respektive 100. Svara med lämpliga enheter! (Sekunder är inte en lämplig
enhet om det tar era timmar. Timmar är inte en lämplig enhet om det tar era dagar eller
år.)
♦
Sortering
Här presenterar och analyserar vi två rekursiva sorteringsalgoritmer.
Instickssortering
Den vanliga enkla instickssorteringen kan uttryckas rekursivt enligt följande:
Induktionsantagande: Vi kan sortera
n−1
element.
Basfall: Vi kan sortera 1 element.
Induktionssteg: Stoppa in det
n:te
elementet bland de
n−1
redan sorterade elementen så att
sorteringen bibehålls.
Javakod:
public static void sort(double [] a, int n) {
if ( n > 1 ) {
sort( a, n-1 );
// sortera de n-1 första
double x = a[n-1];
int i = n-2;
// flytta undan
while ( i>=0 && a[i]>x ) {
a[i+1] = a[i];
i--;
}
a[i+1] = x;
// lägg in sista
}
}
Tiden att sortera
n
tal med denna algoritm beror på hur talen är permuterade. Det är enkelt
att visa att tiden i genomsnitt är
Övning 5:
sortera
n
Θ(n2 ).
Hur lång tid tar instickssorteringen i bästa respektive värsta fall på sig för att
element? Svaret ska ges på formen
Θ(f (n)).
♦
Mergesort
Nästa algoritm vi går igenom kallas för mergesort (eller samsortering på svenska). Den är,
som analysen och övningen nedan visar, väsentligt eektivare än instickssorteringen.
6
Induktionsantagande: Vi kan sortera
n/2
element.
Induktionssteg: Dela mängden i två delar med vardera
n/2
element, Sortera dessa var för sig
och sammanfoga sedan de två sorterade delarna.
Sortera
n
element
1. dela i två lika stora delar
2. sortera delarna var för sig
3. sammanfoga delarna
Vi ger just nu ingen kod för denna sorteringsmetod. Det beror på att den är lite knölig att
implementera och inte heller blir så bra för just arrayer. Vi återkommer till den senare för
andra datastrukturer.
Arbetet att sammanfoga de två sorterade delarna är proportionellt mot antalet element. (Ut-
Θ(f (n))
t(n) = c · f (n).)
trycket tiden är
d.v.s.
Låt
t(n)
kan, löst uttryckt, tolkas om att tiden är proportionell mot
f (n)
n element. Då gäller
(
c
om n = 0,
t(n) =
2t(n/2) + dn om n > 0.
beteckna tiden att sortera
Om n är en jämn 2-potens,
n = 2k
så gäller
t(n) =2t(n/2) + dn = 2(2t(n/4) + dn/2) + dn =
=4t(n/4) + dn + dn = . . .
=2k t(n/2k ) + dnk =
=nt(1) + dn log n
d.v.s. tiden är
Övning 6:
Θ(n log n).
Antag att instickssortering och mergesort tar lika lång tid för 1000 tal -säg 1
sekund. Hur lång tid tar det för respektive algoritm att sortera
106
tal respektive
109
tal?
Svara med lämpliga enheter!
♦
Praktiska tillämpningar
Som vi har sett i ett par olika exempel ovan, kan tidsåtgången för algoritmer snabbt bli orimligt
stor om man väljer fel algoritm (eller om det inte nns någon eektiv algoritm tillgänglig). Vi
vill ha algoritmer för vilka tidsåtgången växer långsamt. Tiden för att lösa ett litet problem är
ofta försumbar; det är när problemstorleken växer som det börjar bli viktigt att ha en eektiv
algoritm.
Som följande exempel visar, ska man dock inte stirra sig blind på komplexiteten. I praktiken
7
kan till exempel en algoritm som är
är
Θ(n),
lika eektiv som en algoritm som
om konstanten (c) är mycket större för den senare.
Övning 7:
Vi låter
Θ(n · log(n)) vara minst
n
Antag att du kan välja mellan två algoritmer, A och B, för att lösa ett problem.
beteckna antalet element i den datastruktur som algoritmerna opererar på. Du vet
att algoritm A löser ett problem av storlek
n
på
n
sekunder. Tidsåtgången för algoritm B är
c · n · log(n) sekunder, där c är en konstant. Du testkör algoritm B på din dator och nner
den tar 1 sekund på sig för att lösa ett problem när n = 10. Hur stort måste n vara för
att
att
algoritm A ska ta kortare tid på sig än algoritm B?
♦
Det nns givetvis tillfällen då man faktiskt behöver lösa så här stora problem, men i många
verkliga tillämpningar är antalet element förmodligen mindre.
Det här var allt vi hade att säga om algoritmanalys för den här gången, men misströsta inte!
När vi senare i kursen arbetar med listor och träd kommer ni att få er tillfällen att räkna på
olika algoritmers komplexitet.
8