O, P, N och NP samt lite algoritmer en kortfattad introduktion för studenter på Intro:DV DSV En enkel algoritm Ponera att du spelar poker och har fått korten till höger. Eftersom det bara rör sig om fem kort så är det inte speciellt svårt att se att du har ett par, men det skulle vara enklare om korten var sorterade efter valör. En möjlig hand i klassisk femkortspoker Att sortera fem kort är nu inte speciellt svårt. Redan med de kunskaper ni har nu så skulle det vara möjligt, men vi vill ha en lösning som fungerar lika bra oavsett hur många kort vi har; eller för den skull om vi skulle vilja sortera alla atomer i universum efter atomnummer. För detta behöver vi ett generellt sätt att sortera: en algoritm. Här nedanför ser du en textuell beskrivning av en enkel sorteringsalgoritm: insertion sort. Den fungerar ungefär som människor gör när de sorterar kort: man börjar med ett kort, och sen tar man kort efter kort och placerar in på rätt plats, och medan man gör det så tvingas man flytta alla kort som är högre än det nya kortet till höger för att göra plats för det nya. Indata: en lista, eller array, med data som ska sorteras Sätt variabeln sorteras till 1 (alltså andra positionen i listan) Upprepa nedanstående steg till sorteras är större än det största indexet Sätt variabeln värde till det värde som står i listan på plats sorteras Sätt variabeln nyplats till samma värde som sorteras Upprepa nedanstående två steg så länge som nyplats är större än 0 (det minsta möjliga indexet) och det värde som står på plats nyplats-1 är större än det värde som står i variabeln värde Skriv över det värde som står på plats nyplats med det värde som står på plats nyplats-1, alltså direkt till vänster Minska nyplats med 1 Sätt värdet på plats nyplats till värdet i variabeln värde Insertion sort pseudokod 1
Kursbokens kapitel 10 ger en kortfattad introduktion till algoritmer, och också exempel på sorteringsalgoritmer. Wikipedias beskrivning (http://en.wikipedia.org/wiki/algorithm) är mer omfattande och teknisk, och ger också många länkar till mer material. Algoritmens effektivitet Hur pass effektiv är algoritmen på föregående sida? För att vi ska kunna diskutera det måste vi bestämma oss för förutsättningarna. Antag till exempel att vi har en lista med 100 slumpmässiga heltal som vi vill sortera. Algoritmen kommer då att uppträda så här: Säg att vi redan har gått nio varv igenom den yttre loopen och alltså de tio första talen är sorterade. Vi ska nu placera in det elfte talet på rätt plats. Eftersom talen var slumpmässiga så kommer det att vara mindre än ungefär hälften av de redan sorterade talen, och alltså måste vi flytta ungefär hälften av dem för att få plats med det. Det här gäller naturligtvis bara i genomsnitt, det elfte talet kan ju råka Snabbkoll: varför är tio tal sorterade när vi har gått nio varv i den yttre loopen? vara det största i hela listan, men det gäller, och det gäller för alla talen. I genomsnitt måste vi flytta hälften av de tal som står till vänster varje gång. Hur många är då det? Jo, om vi har 100 tal, så är det genomsnittliga antalet tal till vänster 50, och vi måste i genomsnitt flytta hälften av dessa, alltså 25. Det totala antalet förflyttningar är alltså 25 gånger antalet tal, som är 100, eller 2500. Men, vad säger det här om algoritmens effektivitet? Var det inte det som var den egentliga frågan? Låt oss titta på vad som händer om vi har 200 tal, alltså dubbelt så många. Det genomsnittliga antalet förflyttningar är då 50, gånger 200 tal = 10000. En fördubbling av storleken leder alltså till en fyrdubbling av antalet förflyttningar. Om vi fortsätter att titta på större tal så inser vi att vi har hittat ett samband mellan storleken på listan och antalet förflyttningar: Ordo Det här sättet att undersöka hur många operationer en algoritm kräver är väldigt vanlig, men i praktiken är man sällan lika noga som vi har varit här. Det visar sig att det man oftast är ute efter bara är en hyfsad uppskattning av tillväxttakten. För att ta ett enkelt exempel. Antag att vi har två program som bägge går igenom en lista av personer och skriver ut deras namn. Det ena programmet skriver ut namnen precis som de står i listan, medan det andra programmet först gör om dem till stora bokstäver och sen skriver ut dem. Naturligtvis är det första programmet snabbare än det andra, det gör ju mindre arbete. Men, på algoritmnivå spelar det ingen roll. Om vi har skulle bygga en dator som har en specialinstruktion för att skriva ut text med stora bokstäver så skulle programmen gå lika fort. De viktigaste är att bägge programmen måste gå igenom samtliga namn i listan ett efter ett. Det kan vi aldrig komma undan. Man brukar säga att bägge programmen är linjära, vilket skrivs och uttyds på svenska som, där n i det här fallet är antalet namn i listan. 2
säger ingenting om hur snabbt en viss algoritmimplementation är, utan bara att om vi dubblar storleken på indatat så tar det ungefär dubbelt så lång tid att köra programmet, och det är det vi är ute efter. När man gör ordoberäkningar så gör man ett överslag, allting som inte är väldigt viktigt ignoreras. Om vi återvänder till vår sorteringsalgoritm från första sidan, så skulle man normalt säga att det är en - algoritm, alltså en algoritm som tar fyra gånger så lång tid om vi dubblar storleken på det som ska sorteras. och är exempel på så kallade komplexitetsklasser. Det visar sig nämligen att väldigt många algoritmer är ungefär lika bra, och man har därför skapat namn på dessa grupper av algoritmer. Tabellen till höger tar upp några av de vanligaste klasserna och ger också exempel. En algoritm ur en klass högre upp i tabellen är normalt sett att föredra jämfört med en algoritm för att lösa samma problem som tillhör en komplexitetsklass som står längre ner. : konstant tid; alla vanliga programsatser : logaritmisk tid; binärsökning : linjär tid; att söka igenom en osorterad lista steg för steg : effektiva sorteringsalgoritmer : kvadratisk; ineffektiva sorteringsalgoritmer : exponentiell; generera alla möjliga kombinationer Vanliga komplexitetsklasser med exempel Wikipedias beskrivning av Ordo (http://sv.wikipedia.org/wiki/ordo) är tämligen intetsägande. Den engelska versionen, http://en.wikipedia.org/wiki/big_o_notation, är bättre, men fullständigt oläsbar om man inte är förtjust i matte. Rob Bells A Beginners Guide to Big O Notation går inte speciellt långt, men exemplen är vettiga. Du hittar den på http://rob-bell.net/2009/06/a-beginners-guide-tobig-o-notation/ Vad betyder detta för inlämningsuppgiften? Ta en titt på det sista exemplet i tabellen ovan. Ett vanligt förslag på lösning för inlämningsuppgiften är att generera alla möjliga kombinationer av bokstäverna och sen kontrollera dem mot ordlistan. Är det här en fungerande lösning? Svaret på frågan är ja. Med nio bokstäver så blir det strax under en miljon kombinationer att kontrollera, och under förutsättning att vi sköter kontrollen hyfsat effektivt så är det inget problem. På föreläsningen demonstrerades olika versioner av denna lösning som tog mellan 6-7 minuter och 400 millisekunder på sig beroende på hur kontrollen sköttes. 400 millisekunder är inte mycket, det är mindre än en halv sekund. Inte ens en normal användare hinner bli trött på den tiden. Ovanstående gäller dock bara så länge vi inte ändrar på förutsättningarna. Lägger vi till ett par bokstäver till så blir situationen helt annorlunda. Vid 12 bokstäver så tar det minuter oavsett hur effektiv kontrollen än är. 3
P, N och NP Förutom O så är det ytterligare några bokstäver man bör känna till därför att de begränsar vad som överhuvudtaget är möjligt att göra med datorer. Den första av dessa är P, som står för alla problem som kan lösas i polynomiell tid. Enkelt uttryckt är detta de problem som vi kan, eller med tillräckligt kraftfulla datorer, skulle kunna lösa inom rimlig tid. Vad som är rimligt är naturligtvis en definitionsfråga. Det finns problem i den här klassen där algoritmerna skulle ta många miljoner år på sig att lösa problemet för stora mängder data med dagens datorer, men det är detaljer. Den praktiska gränsen går någonstans mellan och, och det är därför man pratar om polynomiell tid, eftersom är ett polynom men inte är det. Nästa bokstav är N, som inte är en egen klass, men som förtjänar uppmärksamhet eftersom den står för ett begrepp som brukar vara förvirrande. N står för non-deterministic. eller icke-deterministisk på svenska. En icke-deterministisk algoritm är en algoritm som när den ställs inför ett val som den inte kan veta vilket alternativ som är rätt så väljer den alltid rätt. Det här låter löjligt när man säger det på det här sättet, hur skulle vi någonsin kunna göra en dator som skulle kunna bete sig på det här sättet? Själva begreppet är dock vettigt, och ni kommer att se exempel på hur icke-determinism kan användas i praktiken vid flera tillfällen senare under er utbildning. Slutligen så har vi NP, som står för Non-deterministic Polynomial, eller icke-deterministiskt polynomiell. Detta är alltså klassen av problem för vilka vi har ickedeterministiska algoritmer som fungerar i polynomiell tid. Dessa utgör alltså en mellanklass mellan de enkla problemen i P och de svåra problemen som inte är i P. NP-kompletta problem En komplikation med P och NP är att ingen är riktigt säker på deras relation. Alla problem som är i P är automatiskt i NP, så långt är det lätt. Problemet är att vi inte vet om problemen i NP också är i P. Bara för att vi inte har kommit på någon deterministisk algoritm för ett visst problem så betyder det ju inte att den inte existerar. För att vi ska kunna säga det så måste vi bevisa att det inte finns någon sådan algoritm. Vill du vinna en miljon dollar, och dessutom evig ära? Det enda du behöver göra är att bevisa att, eller att Matematikerna har försökt i flera årtionden, och även om alla är övertygade om att så har ingen lyckats bevisa det än. För några år sedan så utlystes det därför ett pris på en miljon dollar till den som först lyckas. För mer information om detta pris se http://www.claymath.org/ millennium/p_vs_np/pvsnp. pdf Tänkte du försöka göra anspråk på pengarna så kan det vara bra att känna till de så kallade NPkompletta problemen. Detta är en grupp problem, sinsemellan väldigt olika, som alla har den egenskapen att om man lyckas hitta en P-lösning på problemet, eller ett bevis för att en sådan inte existerar, så har man gjort det för alla NP-problem överhuvudtaget. Vill du veta mer så är http://sv.wikipedia.org/wiki/komplexitetsteori en bra plats att börja. 4
Sökning Sökning är, tillsammans med bland annat sortering, ett av de klassiska problemen inom datavetenskapen. Här och nu kommer vi att begränsa oss till sökning i de enklaste av strukturer: en linjär lista eller array. Senare under kursen, och definitivt senare under utbildningen, kan det komma exempel på sökning i mer avancerade strukturer, men just nu håller vi det enkelt. Så, här nedanför har vi en lista på tal som vi vill söka igenom för att se om ett visst tal existerar eller inte. Hur gör vi? 90 43 0 49 67 98 19 38 21 38 75 95 8 93 3 45 83 81 43 Svaret är att vi börjar längst till vänster och tittar efter om det värde som står där är det sökta. Om det är det är vi klara, annars går vi till nästa och upprepar kontrollen. Om vi har gått igenom alla talen utan att hitta det sökta värdet så är vi också klara. Ovanstående är ett typexempel på en linjär algoritm, en algoritm. Det betyder att om vi dubblar mängden data så tar det dubbelt så lång tid att söka. Så, nu till följdfrågan: spelar det någon roll om listan är osorterad, som i exemplet ovan, eller om den är sorterad från början som nedan? 0 3 8 19 21 38 38 43 43 45 49 67 75 81 83 90 93 95 98 Det är exakt samma siffror i bägge listorna, men jag hävdar att det går att hitta 95 fortare i den andra än i den första. Om vi testkör algoritmen vi redan har diskuterat så verkar påståendet fel. I den osorterade listan så måste vi söka igenom tolv platser innan vi hittar värdet vi sökte, medan vi i den andra måste söka igenom arton. Problemet är att vi inte tar hänsyn till det faktum att listan är sorterad. Om vi utnyttjar detta faktum på rätt sätt så borde vi kunna få en rejäl prestandahöjning. Det enklaste sättet att utnyttja det faktum att listan är sorterad är att börja söka från slutet istället för från början. 95 är ju ett högt tal så det borde stå i slutet. I just det här fallet så skulle vi vinna på det, men det är inte garanterat. Listan kanske består av en 95a och resten 96or, och i så fall förlorar vi på att söka från slutet. Problemet med algoritmen som vi diskuterat hittills är att den plockar bort ett enda tal för varje jämförelse. Vill vi ha en snabbare algoritm så måste vi få bort större grupper, och allra helst en rätt stor del. Så, hur gör vi för att få bort så mycket som möjligt? Jo vi börjar söka i mitten. 0 3 8 19 21 38 38 43 43 45 49 67 75 81 83 90 93 95 98 49 67 75 81 83 90 93 95 98 90 93 95 98 45 är mindre än 95, och då vet vi att om 95 finns, så måste det stå i den högra halvan av listan. Så, med en enda jämförelse så har vi halverat problemet, och det bästa är att vi kan fortsätta så här. I det här fallet behöver vi totalt tre jämförelser för att hitta 95, eller fyra om vi hade ansett att 93 var mitten. 5
Så, hur bra är den här algoritmen? Dubblar vi storleken på listan så tar det en enda jämförelse mer. Jämför det med den ursprungliga algoritmen. Den här är alltså mycket, mycket mer effektiv. Så effektiv att den inte är linjär utan den tillhör klassen. Vad detta betyder ligger utanför denna introduktion, så den som är intresserad och inte redan vet det får slå upp logaritmer. Det finns hundratals böcker om algoritmer, och de allra flesta tar upp exempel på sökning i linjära strukturer. Wikipedias beskrivning av linjär och binär sökning kan också vara av intresse. De finns på http://en.wikipedia.org/wiki/linear_search respektive http://en.wikipedia.org/wiki/binary_search. Mer om sortering Även sortering finns det hur mycket material som helst om. I princip kan du googla på namnet på vilken sorteringsalgoritm som helst och få ett dussin bra förklaringar till hur den fungerar. Om vi till exempel googlar bubble sort så hamnar vi på http://en.wikipedia.org/wiki/bubble_sort. Ett bra ställe att börja titta är på är också någon av de många sidor på nätet som visar animationer av hur olika sorteringsalgoritmer fungerar. http://www.sorting-algorithms.com/ är en av de tydligaste, även om den bara jämför åtta olika algoritmer. Du kommer inte att lära dig sorteringsalgoritmer genom att titta på dessa animationer, men de är till stor nytta tillsammans med en beskrivning. Med utgångspunkt från det material som tagits upp här så är den stora skillnaden mellan olika sorteringsalgoritmer om de är eller. Insertion sort, som vi tog upp, är ett exempel på den senare kategorin, liksom nästan alla andra enkla sorteringsalgoritmer. Dessa fungerar bra för små datamängder, men så fort det börjar bli mycket data så bryter de ihop. Quick och merge sort är de vanligaste exemplen på -algoritmer. De är mer komplicerade att koda, och gör mer jobb för varje jämförelse, men i gengäld så gör de mycket färre jämförelser än de enkla algoritmerna normalt gör för stora datamängder. [Ovanstående stycke innehöll åtminstone två seriösa fel, men det tar vi när ni kommer till kursen Algoritmer och datastrukturer.] Så, vilken sorteringsalgoritm ska man välja? Svaret på den frågan är antingen väldigt svårt, eller väldigt lätt. Det är svårt därför att det beror på så många olika faktorer: storlek på indatat, typ av indata, fördelning, struktur, dubletter, etc. etc. Samtidigt är det lätt eftersom om man inte har några väldigt speciella behov så är svaret nästan alltid: ta den som redan finns implementerad. Alla programspråk idag har stöd för sortering, och normalt sett har den som skrivit den koden gjort ett väldigt bra jobb. Kom ihåg Larry Wall, Randal L. Schwartz och Tom Christiansens lista over goda egenskaper hos en programmerare: lathet, otålighet och hybris. Rekursion Den klassiska definition av rekursion är: se rekursion. Utrymmet tillåter mig inte att säga mer om ämnet här, men slå gärna upp det. Det kan komma till nytta på inlämningsuppgiften. To iterate is human, to recurse divine. L. Peter Deutsch 6