Sökning, indexering och symboltabeller

Först några korta ord om att söka i en godtycklig samling, sedan ska vi titta på hur datat kan struktureras så att det går snabbare att söka i det.

Att söka i en godtycklig samling

Linjärsökning

Om vi ska söka efter ett visst element i en samling data och inte har en aning om hur samlingen är strukturerad finns det bara ett sätt. Att loopa igenom alla element tills vi hittar det sökta. Kommer vi till samlingens slut utan att ha hittat elementet fanns det inte. Denna enkla algoritm har T(n) = O(n), eftersom det i snitt krävs n/2 jämförelser för att hitta det sökta elementet. Detta förutsatt att elementet finns i samlingen, desto fler element som inte finns desto närmre n kommer det genomsnittliga antalet jämförelser.

Binärsökning

Om samlingen är en sorterad sekvens finns det en snabbare algoritm. Jämför först med elementet i mitten. Om det sökta elementet är mindre måste det finnas i den ena halvan, är det större måste det finns i den andra. Jämför sedan med det mittersta elementet i den halva där det sökta elementet finns osv. Här kommer ett program som implementerar denna algoritm, BinarySearch.c . Nu är T(n) = O(log(n)) eftersom sekvensen halveras vid varje rekursivt anrop. Notera dock att detta bara fungerar om sekvensen är sorterad. Det är ingen idé att sortera en osorterad sekvens för att kunna använda binärsökning i stället för linjärsökning. Detta eftersom de snabbaste sorteringsalgoritmerna hade T(n) = O(n), vilket även gäller linjärsökning.

Indexering

Ett index är ett set som bara innehåller nycklar och postnummer. När vi har hittat en nyckel i ett index kan vi på konstant tid hitta hela posten genom att läsa på angivet postnummer (se figurerna på sid 210). Det finns två bra anledningar till att skapa index:
  1. Hela sekvensen som ska hanteras får inte plats i primärminnet därför att posterna är för stora. Om ett index får plats i primärminnet går det mycket fortare att sortera eller söka i sekvensen genom sortera/söka i indexet och sedan läsa aktuell post på det postnummer som anges.
  2. Antag att en samling vars poster innehåller tex personnummer, förnamn och efternamn ska struktureras på något bra sätt så att det går snabbare att söka i den. Antag vidare att vi betraktar personnummer som nyckel och sorterar samlingen efter dem. Då kommer det att gå snabbt att söka efter en post med ett visst personnummer men vill vi hitta ett visst förnamn eller efternamn finns det ingen annan utväg än att använda linjärsökning (se ovan). Detta kan lösas genom att upprätta tre olika index till samlingen, ett som sorteras efter förnamn, ett som sorteras efter efternamn och ett som sorteras efter personnummer. Nu går det, med hjälp av rätt index, att snabbt söka efter en viss post oavsett om förnamn, efternamn eller personnummer är givet.

Symboltabeller

Datastrukturen symboltabell består av ett set av poster där varje post består av av en nyckel och ett värde . En nyckel identifierar en unik post. Notera än en gång att det krävs unika nycklar, det går alltså inte att hantera sekvenser av nycklar (därför att de kan innehålla dubletter). Typiska operationer är När en symboltabell används för ett index (se ovan) är nycklarna indexnycklar och motsvarande värden är postnummer till datasamlingen.

Benämningen symboltabell är inte helt vedertagen, ibland kallas de hashtabeller i stället, oberoende av hur de är implementerade.

Vi ska nu titta på några olika sätt att implementera en symboltabell. Den första frågan är varför inte sorteringsalgoritmerna i föregående avsnitt kan användas. Vi kom fram till sorteringsalgoritmer med T(n) = O(nlogn), varför inte använda någon av dem för att sortera nycklarna och sedan använda binärsökning för att hitta nycklar i samlingen? För det första för att den strategin inte alls löser problemet att lägga in och ta bort nycklar. För det andra för att det är för långsamt: Om vi sorterade om nycklarna varje gång en nyckel skulle sökas fick get T(n) = O(nlog(n)) och om vi alltid höll nycklarna sorterade skulle put och remove ha T(n) = O(n). Här kommer vi i stället att komma fram till en implementation som har T(n) = O(1) för alla operationer. Priset blir att nycklarna inte är sorterade och därför måste sorteras varje gång vi vill läsa igenom dem i sorterad ordning. Kontentan av detta resonemang är att sökning och sortering är oberoende operationer. Normalt väljer vi en datastruktur som gör att sökning går snabbt. När vi vill ha datat sorterat måste det sorteras med hjälp av någon lämplig sorteringsalgoritm.

Nu över till implementationerna av symboltabeller:

Hashtabeller

Motivet för denna datastruktur är att exekveringstiden för både put, get och remove ska vara oberoende av antalet poster, dvs alla ska ha T(n) = O(1)!!! Detta åstadkoms genom att nyckelns värde samtidigt är en pekare till dess position i minnet. Antag till exempel att nycklarna är bytes och alltså kan anta värden mellan 0 och 255. Då kan hashtabellens värden sparas i en array med 255 positioner. Implementationen blir då helt enkelt:
ValueType hashTable[256];

ValueType get(byte key) {
    return hashTable[key];
}

void put(byte key, ValueType value) {
      hashTable[key] = value;
}
Alltså helt oberoende av antalet poster!! Tyvärr är det inte alls lika enkelt för mer generella nycklar eftersom vi då först måste omvandla nyckeln till ett heltal. Lösningen är att utifrån nycklarnas värden beräkna ett tal (kallas hashtal eller hashkod ) som håller sig inom ett givet intervall. Vi kan då implementera hashtabellen med en array som har alla tillåtna hashkoder som index. Denna lösning ger oss två nya problem. Hur beräknas hashkoden och vad göra när två olika nycklar ger samma hashkod? Dessa två problem har varit föremål för mycket forskning, vi ska nu titta på några beprövade lösningar.
Hashfunktioner (Att beräkna hashtal)
Som för alla andra algoritmer ställer vi krav på hashfunktionen vad gäller exekveringshastighet, minnesåtgång, hur lätt koden är att konstruera och underhålla osv. Dessutom måste den fördela nycklarnas hashtal så jämnt som möjligt för att minimera antalet kollisioner (tillfällen när olika nycklar får samma hashtal. För att minimera antalet kollisioner måste alla bitar i nyckeln påverka hashtalet i stor utsträckning, annars är det stor risk att alla nycklar som endast skiljer sig i för hashfunktionen obetydliga bitar får samma hashtal eller hashtal som ligger nära varandra. Det senare leder till att posterna i hashtabellen klumpar ihop sig i kluster (Ett kluster består av flera celler i hashtabellen bredvid varandra som alla är upptagna). Som vi ska se i nästa stycke innebär kluster att hashtabellen blir långsammare.

Nu är det dags att börja skriva en hashfunktion. Den måste omvandla en godtycklig datatyp till ett heltal, vi låter hashtalen vara av typen int. Vi betraktar nycklarna som en följd av bytes för att vara oberoende av deras typ. Ett enkelt sätt att omvandla dessa bytes till en int är att betrakta den som ett tal med basen 256. Om en nyckel till exempel utgörs av strängen "Hammarby" kan vi omvandla den till en int genom att beräkna 'y' * 256 0 + 'b' * 2561 + 'r' * 2562 + ... + 'H' * 256 7 där 'x' är ASCII-koden för x. Denna enkla lösning fungerar tyvärr inte eftersom 2567 = (2 8) 7 = 256 och en int knappast innehåller mer än 32 bitar. Tecknet 'H' påverkar alltså inte alls hashtalet varför till exempel "Hammarby" och "Gammarby" får samma hashkod. En bättre metod är att skriva om ett polynom på formen a3X 3 + a2X2 + a 1X1 + A 0X0 till (((a3 )X + a2)X + a 1 )X + a0. Detta har flera fördelar. För det första undviker vi stora delresultat (som till exempel 'H' * 256 7) vilka direkt försvinner pga overflow. För det andra krävs inga beräkningar av potenser, värdet av ett n-gradspolynom beräknas med endast n multiplikationer och n additioner. För det tredje behöver vi inte veta strängens längd eftersom beräkningen använder tecknen från vänster till höger (detta under förutsättning att strängen slutar med någon sorts stopptecken). Tyvärr kvarstår problemet med overflow eftersom slutresultatet fortfarande inte får plats i en int. Detta löses genom att ta resultatet mod tableSize, där tableSize är sista index i hashtabellen och därmed största tillåtna hashtal. Genom att utföra operationen mod efter varje multiplikation/addition undviker vi att tidiga delresultat tappar betydelse genom att flyttas allt längre ut åt höger i hashtalet. Här kommer ett program med denna hashfunktion, SimpleHash.c . De undersökta nycklarna är alla textsträngar av angiven längd (endast små bokstäver används). Med 1000 olika hashkoder och 456976 olika nycklar ger hashfunktionen detta resultat:

There were 1000 hashCodes
There were 456976 different keys
There were 0 unused hashcodes
There were 455976 collisions, number of keys - number of hash codes = 455976
Max 568 keys got the same hashCode, number of keys / number of hashcodes = 456
Min 414 keys got the same hashCode, number of keys / number of hashcodes = 456
Inte illa, jämnt fördelat hade det blivit 456 nycklar per hashkod och med den här enkla algoritmen blev det mellan 414 och 568 nycklar per hashkod. Det tog cirka 1.53s att köra hela programmet. Körs programmet med bara 676 olika nycklar blir resultatet lite tråkigare, 44 hashkoder används av två olika nycklar trots att alla nycklar skulle kunnat ge unika hashkoder.

Ett problem med ovanstående funktion är att den långsamma operation modulo utförs en gång per byte i nyckeln. Vi kan snabba upp funktionen genom att endast utföta modulo en gång, precis innan hashtalet returneras. För att förhindra att tidiga tecken försvinner pga overflow multiplicerar vi med 32 i stället för 128 och gör dessutom xor på hashtalet från föregående varv i loopen. Detta innebär att nyckelns byte inte längre betraktas som ett tal med basen 256, men det saknar helt betydelse. Det viktiga är att alla bitar i nyckeln påverkar hashtalet. Denna typ av hashfunktion kallas rotating hash och är en relativt enkel algoritm som står sig väl jämfört med mer komplicerade algoritmer. Notera att denna algoritm resulterar i overflow om nyckeln är tillräckligt lång. Det gör inget så länge alla bitar i nyckeln påverkar hashtalet. En effekt av detta är dock att samma nyckel kan ge olika hashtal på olika typer av datorer. Här kommer ett test av denna algoritm, RotatingHash.c . Det blev lite bättre än föregående algoritm:

There were 1000 hashCodes
There were 456976 different keys
There were 0 unused hashcodes
There were 455976 collisions, number of keys - number of hash codes = 455976
Max 488 keys got the same hashCode, number of keys / number of hashcodes = 456
Min 414 keys got the same hashCode, number of keys / number of hashcodes = 456
Det var närmre en rektangulärfördelning av hashkoderna, mellan 414 och 488 nycklar per hashkod. Dessutom tog det bara 1.41s att exekvera programmet. Med 676 olika nycklar fick vi nu 676 unika hashkoder.

En ofta föreslagen optimering (av exekveringshastigheten) för en hashfunktion av denna typ är att använda vänstershift i stället för multiplikation och xor i stället för addition.  Denna typ av optimeringar kan ge önskat resultat men bara om man har mycket god kännedom både den kompilator och den datorarkitektur som används. Dessutom kan funktionen bara optimeras för en viss datorarkitektur, det går inte att göra optimeringar av denna typ som alltid fungerar. Ett test av den optimerade rutinen OptimizedRotatingHash.c visar att exekveringstiden blir identisk med den ooptimerade funktionens exekveringstid på min dator. Det beror säkerligen på att kompilatorn själv gör denna typ av optimeringar. Detta bevisar en än gång optimeringens gyllene regel: Optimera inte utan att det behövs och heller inte utan att kunna verifiera resultatet .

Mycket mer teorier om och exempel på hashfunktioner finns på denna sajt, http://www.burtleburtle.net/bob/ , speciellt på den här sidan, http://www.burtleburtle.net/bob/hash/doobs.html .

Hur hantera krockar?
Den andra stora frågan, förutm att konstruera hashfunktionen, var vad som ska hända när två nycklar får samma hashkod. Ett sätt vore att undvika problemet genom att konstruera en hashfunktion som genererade unika hashkoder för alla nycklar (kallas perfect hash ). Det är dock bara möjligt om vi känner alla möjliga värden på nycklarna.

Den enklaste strategin att hantera krockar, linear probing, är att helt enkelt söka framåt i arrayen tills en ledig plats hittas. Antag att vi har implementerat hashtabellen som en array med tio platser och att det finns en post i cell nio:

0  
1  
2  
3  
4  
5  
6  
7  
8  
9 post 1
Om vi nu försöker lägga in en ny post vars nyckel också får hashtalet nio kommer hashtabellen att leta framåt i arrayen (cell nio kommer efter cell noll) till den hittar en ledig cell:
0 post 2
1  
2  
3  
4  
5  
6  
7  
8  
9 post 1
Vid sökning efter post 2 kommer hashtabellen först att beräkna hastalet för dess nyckel, vilket var nio. Därefter kollar den om nyckeln för posten i cell nio är den sökta nyckeln. När så inte är fallet fortsättar den till cell noll och undersöker nyckeln där osv tills den hittar posten med rätt nyckel (eller tills den kommer till en tom cell i vilket fall det inte fanns någon post med den sökta nyckeln). För att detta ska fungera måste vi kunna markera poster som upptagna, lediga eller borttagna. Vid en första anblick kan det tyckas som det inte gör så mycket att vi ibland måste undersöka fler celler men faktum är att prestanda sjunker ganska snabbt när arrayen börjar bli full eftersom de upptagna cellerna tenderar att klumpa ihop sig (kallas primär klustring, primary clustering). Betrakta följande hashtabell:
0 upptagen
1  
2  
3  
4  
5  
6  
7  
8 upptagen
9 upptagen
Oavsett om vi försöker lägga in en post i cell 8, 9, 0 eller 1 hamnar den i cell1. Klustret växer om vi lägger in en post i cell 7 eller 1. Sannolikheten att klustret ska växa när nästa post läggs in är alltså 50% trots att endast 30% av cellerna är upptagna. Att 30% av cellerna är upptagna kallas att hashtabellens load factor, L = 0,3 (egentligen är symbolen lambda men jag skriver 'L' i stället eftersom inte alla browsers kan hantera tecknet lambda).

Betrakta nu denna tabell:

0 upptagen
1 upptagen
2 upptagen
3  
4  
5  
6  
7  
8 upptagen
9 upptagen
Här kunde det tyckas att det genomsnittliga antalet celler som måste undersökas för att hitta en ledig cell skulle vara två eftersom hälften av cellerna är lediga (L = 0,5). Sanningen är dock att det är (1+1+1+1+1+2+3+4+5+6)/10 = 2,5!! Detta resonemang visar både att primär klustring tenderar uppstå och att det påverkar hashtabellens prestanda. Det går att visa att om linear probing används måste operationerna get och put i genomsnitt undersöka ungefär (1+1/(1-L)2)/2  celler, alltså ett kvadratiskt beroende av L. Om L är 0.5 måste i snitt 2.5 celler undersökas (som vi såg ovan). Om L däremot är 0.9 måste i snitt 50,5 celler undersökas och det är bara ett genomsnitt, vissa hashkoder kommer att leda till att väsentligt fler celler undersöks!! Trots dessa problem är linear probing inte alls oanvändbar. Det är en enkel algoritm att implementera och den exekveras snabbt. Om vi håller L litet leder den inte heller till någon oacceptabel prestandaförlust. Vanligtvis brukar L hållas under 0,5 om linear probing används. När L når över 0,5 dubblas arrayens storlek. Observera att det inte går att kopiera över den ursprungliga arrayen till den nya eftersom nycklarnas hashkod är beroende av antalet platser i tabellen. Det är alltså nödvändigt att beräkna nya hashkoder för alla nycklar. Denna procedur kallas rehashing.

En annan strategi för att hantera krockar är quadratic probing . Om det blir en krock undersöks då inte cellerna 1, 2, 3... steg framåt utan de som ligger 12, 22, 3 2 , ... steg framåt. Med denna metod undviks primär klustring eftersom andrahandsvalet för nycklar med olika hashkod alltid blir olika. Denna strategi klarar dock inte heller (av andra skäl) L > 0,5 varför vi inte vinner mer än 0,5 undersökta celler i genomsnitt vid put och get (enligt formeln ovan). Detta åstadkoms dessutom till priset av en krångligare algoritm. Därför används quadratic probing sällan.

En vanligare strategi är separate chaining. Här utgörs hashtabellen av en array av länkade listor. hashkoden talar om i vilken lista posten ska finnas. Vid put läggs ett element in på godtycklig plats i rätt lista. Vid get läses listan linjärt tills rätt post hittas, eller tills listan är slut om den sökta posten inte finns. L anger här listornas genomsnittliga längd. Antalet celler som mnåste undersökas blir för put oberoende av L medans det för get blir L om posten inte finns och ungefär 1 + L/2 om posten finns. Ett bra värde på L med denna startegi är 1,0. Ett lägre värde kräver mer minne och ger ingen större prestandaökning. Eftersom beroendet av L är linjärt och inte kvadratiskt som med linear probing innebär ett lite högre värde på L en väsentligt mindre prestandaförlust varför det är lättare att undvika rehashing med denna strategi. Detta är en populär strategi eftersom den är relativt lätt att implementera, har bra prestanda och kräver mindre minne än linear probing. Mindre minne gäller dock bara om posterna är stora jämfört med det extra minne som krävs för pekarna i de länkade listorna. Resonemanget om self-adjusting lists som fördes för det fall att hela symboltabellen implementerades med en lista gäller även här.

Notera innan vi lämnar hashtabellerna att oavsett implementation fanns det ingen operation vars komplexitetsfunktion var beroende av n, vilket var själva motivationen till hashtabellernas exeistens.

B-träd

Om datastrukturen inte får plats i primärminnet utan måste lagras på hårddisk eller band blir, enligt samma resonemang som i avsnittet om extern sortering , beräkningarna av O(T(n)) helt meningslösa. Det enda som då har betydelse för exekveringshastigheten är antalet accesser av det externa lagringsmediat. B-träd är ett träd som är lämpligt att använda när datat lagras externt.

Ett B-träd fungerar som ett binärt sökträd så tillvida att varje nod innehåller tillräcklig information för att vi ska veta i vilken av dess barn den sökta posten finns. Vidare krävs det (oavsett vilken typ av träd vi har) en diskaccess för att läsa (eller skriva) en nod eftersom vi måste tolka innehållet i en nod för att veta vilken nod som behöver underökas därnäst. Idén med ett B-träd är att desto fler barn en nod har desto grundare blir trädet och desto färre diskaccesser krävs det för att hitta en viss post.

Det finns många varianter på definitionen av B-träd men denna är vanlig:

Låt oss ta ett konkret exempel. Antag att vi har ett register med 10 7 poster där varje post tar 256 byte och varje nyckel 32 byte. Antag vidare att ett diskblock rymmer 8192 byte. Vi vill att varje nod ska innehålla så mycket data som möjligt (för att trädet ska bli platt) men noderna måste kunna läsas med en diskaccess (för att inte tappa exekveringshastighet). Varje nod innehåller M-1 nycklar vilka kräver 32N-32 byte. Dessutom innehåller noderna maximalt M pekare till sina M barn. Om en pekare kräver 4 byte innehåller varje nod högst 36M-32 byte. Det största M för vilket detta inte överskrider 8192 är 228, alltså väljer vi M=228. Löven ska innehålla så många 256-bytes poster som ryms på ett diskblock om 8192 byte, dvs 32. Vi väljer alltså L=32. Eftersom varje löv måste innehålla minst L/2 = 32/2 = 16 poster och det finns 107 poster kan det högst finnas 10 7/16 = 625000 löv. Eftersom varje nod (utom roten) har minst M/2 = 228/2 = 114 barn och roten har minst två barn finns det på nivå x 114(x-2) * 2 noder. Detta innebär att de 10 7 posterna i värsta fall kräver höjden 5. En post nås alltså på fem diskaccesser. Hade vi istället använt ett binärt sökträd hade det i bästa fall (trädet är balancerat) krävts i genomsnitt log 2(10 7) / 2 = 12 diskaccesser. Worst case vill vi inte tänka på, det skulle innebära 107 diskaccesser.

Att lägga in och ta bort en nod i ett B-träd blir ganska komplicerade operationer men det är OK om antalet diskaccesser är lågt.  I stora drag blir algoritmerna som följer (det finns många variationer):

Hur välja implementation av symboltabellen?

Vi har nu sett en mängd olika sätt att implementera en symboltabell. Vilken är att föredra?
  1. Om det är väldigt få poster, säg max 20, är en vanlig länkad lista bäst. De krångligare algoritmerna för de andra strukturerna ger ingen större prestandavinst för så få poster.
  2. Om det finns fler poster måste vi välja mellan en hashtabell och ett träd. Hashtabellens fördel är att den är snabbare på att läsa, skriva och ta bort poster(O(1) jämfört med O(logn) för trädet) samt att den inte kräver kännedom om nycklarna. Trädets fördel är att det är snabbare på att läsa största eller minsta element och på att läsa alla elementen i sorterad ordning(O(1) eftersom posterna redan är sorterade jämfört med O(nlog(n)) för hashtabellen där en helt separat sortering krävs). Dessutom tappar trädet (speciellt B-träd) prestanda om inte nycklarna är unika. För att ha unika nycklar måste vi antingen kunna använda nycklarna direkt, utan att beräkna hashkod eller kunna utföra perfect hashing vilket kräver att nycklarna är kända. Valet avgörs alltså dels av om det är vanligt att sortera elementen dels av hur generella nycklar vi vill ha.
  3. Om en hashtabell väljs är separate chaining lämplig eftersom det är både enkelt och effektivt. Främsta skälet att inte välja det är om posterna är väldigt små, ungefär som en pekare. Då kan det extra minnesutrymme som krävs för pekarna motivera att i stället välja linear probing. Om separate chaining väljs är det lämpligt att välja en self-adjusting list (tex att flytta senast accessad post först vilket är lätt att implementera) om vi vet att det råder locality of reference vid anropen av symboltabellens operationer.
  4. Om vi inte väljer en hashtabell utan ett träd är det lämpligt att använda ett binärt sökträd om hela symboltabellen får plats i primärminnet och ett B-träd om den ska lagras externt.