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