Föreläsning 9: Hashning, Bloomfilter • • • • • • • • • Idén med hashning Komplexiteten för sökning Dimensionering av hashtabellen Hashfunktionen Krockhantering Javaklassen Hashtable Om stavningskontroll Boolesk hashtabell Bloomfilter Idén med hashning Binärsökning i en ordnad vektor går visserligen snabbt, men sökning i en hashtabell är oöverträffat snabbt. Och ändå är tabellen helt oordnad (hash betyder ju hackmat, röra). Låt oss säga att vi söker efter Kalas i en hashtabell av längd 10000. Då räknar vi först fram hashfunktionen för söknyckeln Kalas och det ger detta resultat. "Kalas".hashCode() -> 72260712 Hashvärdets rest vid division med 10000 beräknas nu 72260712 % 10000 -> 712 och när vi kollar hashtabellens index 712 hittar vi Kalas just där! Hur kan detta vara möjligt? Ja, det är inte så konstigt egentligen. När Kalas skulle läggas in i hashtabellen gjordes samma beräkning och det är därför ordet lagts in just på 712. Hur hashfunktionen räknar fram sitt stora tal spelar just ingen roll. Huvudsaken är att det går fort, så att inte den tid man vinner på inbesparade jämförelser äts upp av beräkningstiden för hashfunktionen. Komplexiteten för sökning Linjär sökning i en oordnad vektor av längd N tar i genomsnitt N/2 jämförelser, binär sökning i en ordnad vektor log N men hashning går direkt på målet och kräver bara drygt en jämförelse. Varför drygt? Det beror på att man aldrig helt kan undvika krockar, där två olika namn hamnar på samma index. Dimensionering av hashtabellen Ju större hashtabell man har, desto mindre blir risken för krockar. En tumregel är att man bör ha femtio procents luft i vektorn. Då kommer krockarna att bli få. En annan regel är att tabellstorleken bör vara ett primtal. Då minskar också krockrisken, som vi ska se nedan. Hashfunktionen Egentligen skulle man vilja ha en perfekt hashfunktion, dvs en funktion som ger olika värden för olika söknycklar. I regel är dock detta inte praktiskt möjligt, eftersom det kräver 1. en hashtabell som har minst lika många platser som det finns söknycklar, 2. en perfekt hashfunktion som inte är för tidskrävande att beräkna, 3. att man lyckas hitta en perfekt hashfunktion (dom är sällsynta). I vissa specialfall (t ex när man vill skapa en tabell över reserverade ord) kan det finnas anledning att försöka hitta en perfekt hashfunktion och det finns algoritmer för detta, t.ex. Cichelli's metod och FHCD-algoritmen i Drozdeks bok. Men vi begränsar oss här till enklare metoder. Ofta gäller det först att räkna om en String till ett stort tal. I Java gör man ingen skillnad på en bokstav och dess nummer i UNICODE-alfabetet, därför kan ABC uppfattas som 656667. Det man då gör är att multiplicera den första bokstaven med 10000, den andra med 100, den tredje med 1 och slutligen addera talen. På liknande sätt gör metoden hashCode() men den använder 31 i stället för 100. Ur javadoc för String.hashcode(): public int hashCode() Returns a hashcode for this string. The hashcode for a String object is computed as s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1] using int arithmetic, where s[i] is the ith character of the string, n is the length of the string, and ^ indicates exponentiation (…) Om man vill söka på datum eller personnummer kan man använda det som stort heltal utan särskild hashfunktion. Exempel: sexsiffriga datum kan hashas in i hashvektorn med 990323 % size. En olämplig storlek är 10000, ty 990323 % 10000 --> 323 och vi ser att endast 366 av de 10 000 platserna kommer att utnyttjas. Det säkraste sättet att undvika sådan snedfördelning är att byta 10000 mot ett närliggande primtal, till exempel 10007. Det visar sej nämligen att primtalsstorlek ger bäst spridning. Alla objekt i Java får automatiskt en hashCode()-metod (ärvd från klassen Object). Men i regel returnerar metoden bara objektets minnesadress omvandlat till ett heltal, vilket vi inte har någon större nytta av. Krockhantering Det naturliga är att lägga alla namn som hashar till ett visst index som en länkad krocklista. Om man har femtio procents luft i sin vektor blir krocklistorna i regel mycket korta. Krocklistorna bör behandlas som stackarna, och hashtabellen innehåller då bara toppekarna top till stackarna. Den andra idén är att vid krock lägga posten på första lediga plats (linear probing). En fördel är att man slipper alla pekare. En nackdel blir att man sedan inte enkelt kan ta bort poster utan att förstöra hela systemet, vilket man kan lösa genom att markera poster som borttagna istället för att ta bort dom. Ett annat problem man brukar råka ut för här är klustring, man får stora klumpar med poster, vilket gör att det kan ta lång tid att hitta nästa lediga plats. Om man vid en krock på plats n istället väljer att titta på plats n+12, n+22, n+32 osv (quadratic probing) får man bättre fördelning av posterna. Javaklassen Hashtable Hashtable är en utmärkt och lättskött klass med två anrop, put och get. Första parametern till put är söknyckeln, till exempel personens namn. Andra parametern är ett objekt med alla tänkbara data om personen. Metoden get har söknyckeln som indata och returnerar dataobjektet om nyckeln finns i hashvektorn, annars returneras null. Här är ett exempel från Hashtables dokumentation där söknyckeln är talets namn ("one") och data är ett Integer-objekt som representerar talet. Hashtable table = new Hashtable(); table.put("one", new Integer(1)); table.put("two", new Integer(2)); table.put("three", new Integer(3)); För att få tillbaka talet skriver man så här: Integer n = (Integer)table.get("two"); if (n != null) { System.out.println("two = " + n); } Eftersom det som kommer ut är av typen Object, måste man oftast typa om det till aktuell klass. Om det har skapats med new Elefant() ser omtypningen ut så här: data=(Elefant)table.get(key); Hashtabell eller Binära sökträd Sökning sker med O(1) respektive O(log n). Det är viss skillnad men inte jättestor. Binära träd är känsligt för sorterad indata. Binära sökträd har ett par fördelar, t.ex. att man kan skriva ut alla element i ordning. Man kan dessutom skriva ut flera element i ett visst intervall. Detta går inte med en hashtabell. Stavningskontroll Ett stavningskontrollprogram ska läsa en text och markera alla ord som är felstavade. Om man har tillgång till en ordlista som innehåller alla riktiga svenska ord kan man använda följande enkla algoritm för att stavningskontrollera en text. • • • Läs in ordlistan i en lämplig datastruktur. Öppna textfilen. Så länge filslut inte nåtts: o Läs in nästa ord från filen. o Slå upp ordet i ordlistan och skriv ut det på skärmen om det inte finns med. Enda problemet är hur man ska välja datastruktur för lagring av ordlistan. Svenska akademiens ordlista innehåller ungefär 200000 ord. Förutom dessa ord finns en hel del böjningsformer och oändligt många tänkbara sammansättningar. Låt oss bortse från detta och anta att vi har köpt en ordlista med dom 200000 vanligaste orden i svenskan. Om vi snabbt ska kunna stavningskontrollera en stor text med en normal persondator måste följande krav på datastrukturen vara uppfyllda. • • • • Uppslagning måste gå jättesnabbt. Datastrukturen får inte ta så mycket minne. Orden måste vara kodade (eftersom ordlistan är köpt och inte får spridas). Vi kan tillåta att uppslagningen gör fel någon gång ibland. Den sista punkten är inte ett krav utan en egenskap hos vårt problem som vi kan utnyttja. Det är nämligen inte hela världen om programmet missar något enstaka felstavat ord i en jättestor text. Vanliga datastrukturer (sorterad array, sökträd, hashtabell) faller alla på något av kraven ovan. Försök med datastruktur: boolesk hashtabell Låt oss först försöka med hashning där vi inte lagrar själva orden och inte tar hand om eventuella krockar. Vi har en hashfunktion f(ord)=index som för varje ord anger en position i en boolesk hashtabell tab. Den booleska variabeln tab[f(ord)] låter vi vara sann (1) då ord ingår i ordlistan. Detta ger en snabb, minnessnål och kodad datastruktur, men den har en stor brist: Om det råkar bli så att hashfunktionen antar samma värde för ett ord i ordlistan som för ett felstavat ord så kommer det felstavade ordet att godkännas. Om hashtabellen exempelvis är fylld till häften med ettor så är sannolikheten för att ett felstavat ord ska godkännas ungefär 50% vilket är alldeles för mycket. Bloomfilter Lösningen är att använda många hashfunktioner som alla ger index i samma hashtabell tab. I Viggos stavningskontrollprogram Stava används till exempel 14 olika hashfunktioner f0(ord),f1(ord), f2(ord),...,f13(ord). Ett ord godkänns bara om alla dessa 14 hashfunktioner samtidigt ger index till platser i tab som innehåller sant (det vill säga 1). Uppslagning av ett ord kan då ske på följande sätt: for(int i=0; i<14; i++) if(!tab[f(i,ord)]) return false; return true; Om hashtabellen är till hälften fylld med ettor blir sannolikheten för att ett felstavat ord godkänns så liten som (1/2)14=0.006%. Denna datastruktur kallas bloomfilter efter en datalogiforskare vid namn Bloom. Ett abstrakt bloomfilter har bara två operationer: insert(x) som stoppar in x i datastrukturen och isIn(x) som kollar ifall x finns med i datastrukturen. Programmet Stava kan köras på Nadas Unixdatorer med kommandot stava filnamn