INF1010 notat: Binærsøking og quicksort Ragnhild Kobro Runde Februar 2004 I dette notatet skal vi ta for oss ytterligere to eksempler der rekursjon har en naturlig anvendelse, nemlig binærsøking og quicksort. Som repetisjon(?) skal vi også ta med lineær søking og innstikksortering. 1 Søking Problemstilling: Gitt en array og et element, finnes elementet i arrayen? For enkelhets skyld skal vi i det følgende holde oss til arrayer der elementene er heltall, men de samme strategiene kan selvfølgelig også brukes med andre typer elementer. 1.1 Lineær søking En vanlig strategi for å lete etter et element i en array er denne: 1. Start på begynnelsen av arrayen. 2. Let gjennom arrayen element for element, inntil (a) det søkte elementet er funnet, eller (b) slutten på arrayen er nådd 1
I Java kan dette for eksempel programmeres som følger: int linearsok(int[] a, int elem) { boolean funnet = false; int indeks = 0; while (indeks < a.length &&!funnet) { if (a[indeks] == elem) { funnet = true; else { indeks++; if (funnet) { return indeks; else { return -1; Hvis elementet finnes i arrayen, returneres indeksen det ble funnet på. Finnes det flere like elementer i arrayen, vil lineærsøk gi indeksen til det første. Hvis elementet det søkes etter ikke finnes i arrayen i det hele tatt, returneres her -1 for å indikere dette. For å kunne vurdere hvor bra denne søke-strategien er, ser vi på hvor mange elementer vi må lete gjennom før vi eventuelt finner det vi søker etter. Hvis vi er riktig heldige, finnes elementet vi leter etter øverst i arrayen, og vi får bare ett oppslag før vi er ferdige. I verste fall ligger elementet helt sist, og vi må lete gjennom alle de andre elementene før vi finner det vi søker etter. For elementer som finnes i arrayen, må vi i snitt lete gjennom halvparten av elementene. For et element som ikke finnes i arrayen, må vi alltid lete gjennom alle elementene før vi kan si at det ikke finnes. For en array med 1 million elementer, må vi altså i snitt regne med å bruke 500 000 oppslag, og i verste fall 1 million oppslag på å finne et element som eksisterer i arrayen. For å finne ut at et element ikke eksisterer bruker vi 1 million oppslag. Generelt: Med n elementer i arrayen, må vi i verste fall lete gjennom alle n elementene. Med dobbelt så mange elementer, risikerer vi dobbelt så mange oppslag. Dette kalles lineær tid. 2
1.2 Binærsøking For en usortert array, er det generelt ikke mulig å gjøre det bedre enn med lineær søking. Hvis arrayen derimot er sortert, har vi en meget effektiv søkestrategi kalt binærsøk. Ideen her er at vi ved hvert oppslag i arrayen halverer søkerommet, det vil si at vi halverer antall mulige elementer vi må lete gjennom (i motsetning til ved lineær søking, der vi ved hvert oppslag bare kan utelukke ett element). I pseudokode kan denne ideen uttrykkes ved: if < antall elementer det skal søkes gjennom = 0 > return -1; else { midten = < indeksen til det midterste elementet > if (elem == a[midten]) return midten; else if (elem < a[midten]) < let etter elem i første halvdel av arrayen > else // Vet: elem > a[midten] < let etter elem i andre halvdel av arrayen > Det er her naturlig å bruke rekursive kall til de to delene som søker gjennom mindre områder av arrayen. Som parametre til metoden sender vi derfor med første og siste indeks i det array-segmentet vi skal lete i, i tillegg til selve arrayen og elementet det letes etter. Med start-indeks fra og slutt-indeks til, kan vi beregne indeksen midt mellom disse som (fra+til)/2. Hvis elementet på denne midt-indeksen er lik det vi leter etter, er vi ferdige. Hvis derimot dette midt-elementet er større enn det vi leter etter, gjør vi et rekursivt kall for å lete videre i første halvdel av det aktuelle array-segmentet. Siden vi allerede har sjekket midt-elementet, holder det å lete opp til elementet på indeks midten-1. Tilsvarende holder det å lete fra midten+1 hvis elementet vi leter etter er større enn midt-elementet. 3
a a 10 11 12 13 14 1 2 3 5 7 11 13 17 19 23 29 31 37 41 43 fra midten 10 11 12 13 14 til 1 2 3 5 7 11 13 17 19 23 29 31 37 41 43 a fra midten til 10 11 12 13 14 ' (! " # # $ $ % % & & ' (! " # # $ $ % % & & 1 2 3 5 7 11 13 17 ' ( 19 23 29 31! " 37 # # $ $ 41 % % & & 43 fra til midten Figur 1: Eksempel på binærsøking (leter etter 11) Fullstendig Java-kode for binær-søking blir da: int binarsok(int[] a, int fra, int til, int elem) { int midten; if (fra > til) { return -1; else { midten = (fra + til) / 2; if (elem == a[midten]) { return midten; else if (elem < a[midten]) { return binarsok(a, fra, midten-1, elem); else { // elem > a[midten] return binarsok(a, midten+1, til, elem); Som ellers når vi bruker rekursjon, må vi sørge for at metoden terminerer, og ikke gir uendelig rekursjon. Dette gjøres generelt ved at det må finnes minst ett basistilfelle, som de rekursive kallene må gå i retning av. For binærsøking er basistilfellet når elementet er funnet, eller array-segmentet vi skal søke i inneholder null elementer. Progresjonen sikres ved at størrelsen på arraysegmentet alltid reduseres når det rekursive kallet gjøres. Figur 1 viser et eksempel på hvordan binærsøking fungerer når vi leter etter elementet 11. Skraveringen viser hvilke elementer vi til nå har kunnet utelukke. Etter tre oppslag, finner vi elementet vi søker etter. Figur 2 på neste side viser hva som skjer når vi leter etter elementet 25, som ikke eksisterer. 4
a 10 11 12 13 14 1 2 3 5 7 11 13 17 19 23 29 31 37 41 43 fra a a midten 10 11 12 13 14 1 2 3 5 7 11 13 17 19 23 29 31 37 41 43 fra fra til midten midten 10 11 12 13 14!! " " # $ % % & & ' ' ( (!! " " # $ % % & & ' ' ( ( 1 2 3 5 7 11 13 17 19 23 29!! " " 31 # $ 37 % % & & 41 ' ' ( ( 43 a 10 11 12 13 14 ) ) * * +,.. 0 0 1 2 4 4 5 6 7 7 8 8 9 : ; ; < < = = > >? @ A A B B C C D D ) ) * * +,.. 0 0 1 2 4 4 5 6 7 7 8 8 9 : ; ; < < = = > >? @ A A B B C C D D ) ) * * 1 +, 2 - -.. 3 / / 0 0 5 1 2 7 3 3 4 4 11 5 6 13 7 7 8 8 17 9 : 19 ; ; < < 23 29 = = > > 31? @ 37 A A B B 41 C C D D 43 a til fra midten 10 11 12 13 14 E E F F G H J J L L M N P P Q R S S T T U V W W X X Y Z [ [ \ \ ] ^ _ _ ` ` a a b b E E F F G H J J L L M N P P Q R S S T T U V W W X X Y Z [ [ \ \ ] ^ _ _ ` ` a a b b E E F F 1 G H 2 I I J J 3 K K L L 5 M N 7 O O P P 11 Q R 13 S S T T 17 U V 19 W W X X 23 Y Z 29 [ [ \ \ 31 ] ^ 37 _ _ ` ` 41 a a b b 43 til fra Figur 2: Eksempel på binærsøking som feiler (leter etter 25) til til For en array med 1 million elementer, vil vi ved binærsøking aldri trenge mer enn 20 oppslag, uansett om elementet finnes i arrayen eller ikke. Generelt har vi at siden søkerommet halveres ved hvert oppslag, vil dobbelt så mange elementer i arrayen bare føre til ett oppslag ekstra. Vi sier at binærsøking bruker logaritmisk tid. 1 2 Sortering Problemstilling: Gitt en array med elementer, sorter denne! Også her skal vi for enkelhets skyld holde oss til heltalls-elementer. 2.1 Innstikksortering Hovedprinsippet i innstikksortering kan forklares som følger: Gitt en sortert array og et element, legg ( stikk ) elementet inn i arrayen på en slik måte at 1 Mer om dette i INF1020: Algoritmer og datastrukturer. 5
denne forblir sortert. Dette kan gjøres ved å 1. Lete frem til indeksen der elementet skal inn 2. Sette elementet på riktig plass 3. Skyve de resterende elementene ett hakk til høyre. Den vanlige implementasjonen av innstikksortering slår sammen flere av disse ved å: 1. Starte på slutten av arrayen 2. Så lenge array-elementet er større enn elementet som skal legges til, flyttes array-elementet ett hakk til høyre. 3. Plassere det nye elementet på riktig plass. I Java blir dette seende slik ut (der tmp er elementet som skal legges til, og i er initialisert til å være indeksen til det siste elementet i arrayen): while (i >= 0 && a[i] > tmp) { a[i+1] = a[i]; i--; a[i+1] = tmp; For å sortere en hel (usortert) array, vil innstikksortering hele tiden sørge for at arrayen er sortert fra indeks 0 og et stykke utover ett hakk lenger for hver gang. Hvis arrayen er sortert fra 0 til k, vil neste skritt være å sortere inn elementet på indeks k+1, etter beskrivelsen over. Når dette er gjort, vil arrayen være sortert fra 0 til k+1, og sorteringen fortsetter med elementet på indeks k+2 og så videre inntil hele arrayen er sortert. En viktig observasjon som sikrer at dette fungerer, er at array-segmentet fra indeks 0 til indeks 0 alltid er sortert (det inneholder jo bare ett element). Første skritt i innstikksortering blir dermed å sortere elementet på indeks 1 riktig i forhold til dette sorterte segmentet (på ett element). 6
Opprinnelig: 8 1 4 9 0 3 5 Etter k = 0: 1 8 4 9 0 3 5 Etter k = 1: 1 4 8 9 0 3 5 Etter k = 2: 1 4 8 9 0 3 5 Etter k = 3: 0 1 4 8 9 3 5 Etter k = 4: 0 1 3 4 8 9 5 Etter k = 5: 0 1 3 4 5 8 9 Figur 3: Eksempel på innstikksortering Den komplette metoden for innstikksortering i Java, ser da slik ut: void innstikksortering(int[] a) { for (int k = 0; k < a.length-1; k++) { if (a[k+1] < a[k]) { // a[k+1] står på feil plass, ta den ut int tmp = a[k+1]; // Skyv array-elementene mot høyre, ett hakk // om gangen til vi finner riktig plass til tmp int i = k; while (i >= 0 && a[i] > tmp) { a[i+1] = a[i]; i--; // Sett tmp inn på riktig plass a[i+1] = tmp; Eksempelet i figur 3 viser hvordan arrayen vil se ut etter hver runde i forløkken. Oppgave: If-testen i koden for innstikksortering er egentlig unødvendig. Forklar hvorfor vi får riktig resultat selv om den droppes (mens resten av koden er uendret). 7
2.2 Quicksort Det viser seg at innstikksortering er en grei metode å bruke for å sortere små datamengder (typisk 50 100 elementer). For større mengder data, vil det stort sett være mer effektivt å bruke quicksort. 2 Selv om det går ganske greit å forstå de grunnleggende ideene i quicksort, har den i praksis vist seg å være forbausende vanskelig å implementere korrekt. Den implementasjonen som gis her er hentet fra [Wei99]. Quicksort fungerer etter følgende prinsipp: Del arrayen i to omtrent like store deler, der alle elementene i den ene delen er mindre enn eller lik alle elementene i den andre delen, og sorter de to delene hver for seg etter samme prinsipp. Analogt med binærsøk, har vi at det å sortere de to delene hver for seg etter samme prinsipp antyder bruk av rekursjon. Det vi må se nærmere på, er hvordan selve delingen kan foretas på en effektiv måte. Ideen er her å finne et egnet element, kalt pivot-elementet, som ferdig sortert skal ligge omtrent midt i arrayen. Selve delingen (gjerne kalt partisjoneringen) vil da si å bytte om på elementene i arrayen slik at alle elementene til venstre for pivot-elementet er mindre enn eller lik dette, og alle elementene til høyre er større enn eller lik. Følgende gir en foreløpig skisse av quicksort: void quicksort(int[] a, int fra, int til) { if (til - fra <= 1) { // Et array-segment på 0 eller 1 element er sortert return; int pivotindeks; int pivot = <et egnet pivot-element> < Partisjoner arrayen slik at: - a[pivotindeks] = pivot - alle elementer før a[pivotindeks] <= pivot - alle elementer etter a[pivotindeks] >= pivot > quicksort(a, fra, pivotindeks-1); quicksort(a, pivotindeks+1, til); 2 For nærmere analyse av innstikksortering og quicksort henvises til INF1020. 8
Hvordan velge pivot-elementet? Det kanskje vanskeligste spørsmålet i quicksort er hvordan velge pivot-elementet. For det første ønsker vi at de to array-segmentene skal bli omtrent like store, for det andre er det viktig at valg av pivot-element ikke tar for lang tid. Et dårlig valg av pivot-element, og quicksort blir ikke særlig effektiv likevel... Det finnes flere mulige strategier, et generelt godt valg er det som kalles midten-av-tre partisjonering. Ideen her er å se på det første, midterste og siste elementet i arrayen, og så velge pivot-elementet lik det mellomste av disse: int midtenavtre(int[] a, int fra, int til) { int midten = (fra + til) / 2; if (a[midten] < a[fra]) { bytt(a, fra, midten); if (a[til] < a[fra]) { bytt(a, fra, til); if (a[til] < a[midten]) { bytt(a, midten, til); // Vet nå: a[fra] <= a[midten] <= a[til], // og vi kan velge pivot = a[midten] // For at resten av partisjoneringen skal bli // mest mulig effektiv, "skjuler" vi pivot-elementet // ved å legge det nest lengst til høyre: bytt(a, midten, til-1); return a[til-1]; // pivot-elementet Vi ser at midtenavtre ikke bare finner et forslag til pivot-element, det sorterer også de tre elementene som vurderes. Siden vi fortsatt ikke vet den nøyaktige indeksen til pivot-elementet, legger vi dette midlertidig i indeksen nest lengst til høyre, det vi si i a[til-1]. Som forklart over består selve partisjoneringen av å flytte alle elementene mindre enn pivot-elementet til begynnelsen av arrayen, og alle elementene 9
større enn pivot-elementet til slutten av arrayen. Siden pivot-elementet ikke nødvendigvis er nøyaktig det midterste elementet, vet vi ikke på forhånd hvor skillet mellom de to array-segmentene vil gå. Vi arbeider derfor fra begge ender av arrayen samtidig. Når midtenavtre returnerer, vet vi altså at a[fra] <= a[til-1] <= a[til], det vil si at elementene a[fra] og a[til] allerede ligger i riktig arraysegment. Det som gjenstår er å partisjonere elementene fra og med a[fra+1] til og med a[til-2]. (Når dette er gjort, vil vi også vite den nøyaktige indeksen til pivot-elementet.) Vi lar dermed en hjelpevariabel i starte på indeks fra+1 og hoppe over alle elementer som er mindre enn pivot-elementet. Motsatt vei lar vi hjelpevariabelen j gå nedover fra til-2 og hoppe over alle elementer som er større enn pivot-elementet. Elementet a[i] er nå for stort i forhold til halvdelen det befinner seg i, mens a[j] er for lite i forhold til sin halvdel. Disse to må derfor bytte plass, før i og j leter videre etter feilplasserte elementer. Som et eksempel, anta at arrayen vår i utgangspunktet ser slik ut (med fra lik 0, og til lik 9): a 8 1 4 9 0 3 5 2 7 6 Etter at midtenavtre har sortert elementene a[0], a[4] og a[9], ser arrayen slik ut: a 0 1 4 9 6 3 5 2 7 8 Vi skjuler så pivot-elementet nest lengst til høyre, ved å bytte om a[4] og a[8]: a 0 1 4 9 7 3 5 2 6 8 Dette er altså situasjonen når midtenavtre returnerer. Vi lar så i starte på indeks 1 og gå mot høyre til vi treffer på a[3] som er lik 9, og altså større enn pivot-elementet (6). Tilsvarende vil j starte på indeks 7, og allerede her finner vi 2 som er mindre enn pivot-elementet (6): a 0 1 4 9 7 3 5 2 6 8 Elementene på indeks i og j står feil, og vi bytter dem: i a 0 1 4 2 7 3 5 9 6 8 i 10 j j
Letingen etter feilplasserte elementer fortsetter: a 0 1 4 2 7 3 5 9 6 8 Igjen bytter vi om på de feilplasserte elementene: i a 0 1 4 2 5 3 7 9 6 8 Videre leting etter feilplasserte elementer vil gi: i a 0 1 4 2 5 3 7 9 6 8 i og j har nå krysset hverandre, som en indikasjon på at søkingen etter feilplasserte elementer er ferdig. Når partisjoneringen er ferdig, vil i inneholde indeksen til det første elementet som er større enn pivot-elementet. Men dette er egentlig den riktige indeksen til pivot-elementet, og vi bytter dermed denne tilbake fra den skjulte plasseringen nest lengst til høyre: a 0 1 4 2 5 3 6 9 7 8 Videre sortering vil nå skje ved rekursive kall på quicksort, der det første rekursive kallet vil sortere array-segmentet fra 0 (fra) til 5 (pivotindeks-1), mens det andre tar seg av array-segmentet fra 7 (pivotindeks+1) til 9 (til). Selve implementasjonen av quicksort ser da ut som gitt på neste side. j j i i j j 11
void quicksort(int[] a, int fra, int til) { if (til - fra <= 1) { // Et array-segment på 0 eller 1 element er sortert return; int pivotindeks; int pivot = midtenavtre(a, fra, til); // Vet: a[fra] <= a[til-1] <= a[til], // der pivot = a[til-1] // Partisjonerer arrayen fra indeks fra+1 // til indeks til-2 int i = fra; int j = til-1; while (true) { while (a[++i] < pivot) { while (a[--j] > pivot) { if (i < j) { bytt(a, i, j); else { break; // a[i] er nå første element i andre halvdel av arrayen // Bytter pivot-elementet tilbake hit pivotindeks = i; bytt(a, pivotindeks, til-1); // Har nå: // - a[pivotindeks] = pivot // - alle elementer før a[pivotindeks] <= pivot // - alle elementer etter a[pivotindeks] >= pivot // Sorterer de to halvdelene hver for seg: quicksort(a, fra, pivotindeks-1); quicksort(a, pivotindeks+1, til); 12
Koden som sørger for partisjoneringen kan nok ved første øyekast se temmelig kryptisk ut, men det som skjer er egentlig ikke noe annet enn det som er beskrevet over. Siden midtenavtre har sørget for at a[fra] <= pivot, får vi at j senest vil stoppe på dette elementet. For i har vi at denne senest vil stoppe på a[til-1], det vil si pivot-elementet. Vi slipper derfor å sjekke noen arraygrenser! For små arrayer vil innstikksortering være mer lønnsomt enn quicksort. Det er derfor vanlig å kombinere quicksort og innstikksortering ved å bruke quicksort et stykke på vei, men når array-segmentet er blitt lite nok (typisk 5-20 elementer) brukes innstikksortering istedenfor. (Dette sørger også for at array-segmentet alltid vil bestå av minst tre elementer når midtenavtre kalles!) Hvis vi lar CUTOFF være en konstant som angir hvor mange elementer som heller skal sorteres ved innstikksortering, vil den samlede metoden for quicksort se slik ut: void quicksort(int[] a, int fra, int til) { if (fra + CUTOFF <= til) { < Bruk quicksort som over > else { < Lite array-segment, bruk innstikksortering > Oppgave: I beskrivelsen av quicksort er det ikke sagt så mye om hva som skjer med elementer som er lik pivot-elementet. Sorter 3, 1, 4, 1, 5, 9, 2, 5, 5, 3, 5 ved hjelp av quicksort-metoden over (uten cutoff), og se hva som skjer. Synes du dette virker fornuftig? Hva skjer hvis arrayen består av bare like elementer? Referanser [Bud01] Timothy Budd. Classic data structures in Java. Addison-Wesley, 2001. [Hoa62] C.A.R. Hoare. Quicksort. The Computer Journal, 5:10 15, 1962. [Mai03] Michael Main. Data Structures & Other Objects Using Java. Addison-Wesley, second edition, 2003. [Wei99] Mark Allen Weiss. Data Structures & Algorithm Analysis in Java. Addison-Wesley, 1999. 13