Hashing
O(log n) - søk Søking i et balansert søketre med n elementer er alltid O(log n) Søkingen er basert på parvise sammenligninger av to og to verdier Er svært raskt uansett hvor stort søketreet er, fordi logaritmen vokser meget langsomt......men vil allikevel gi lengre søketider for voksende n Kan vi klare å lage en datastruktur som har samme effektivitet uansett hvor stor n er?
O(1) søking? O(1) - søking: Uavhengig av antall elementer Finn verdien med bare ett direkte oppslag i datastrukturen Er praktisk mulig bare hvis vi har en nummerering av alle mulige elementer som kan lagres i datastrukturen, og vi vet hvor hvert element ligger Eksempel: Data om alle personer i Norge, lagret i en array der indeksen til data om hver person er personnummeret
Hashing: Et forsøk på O(1) effektivitet Alle dataene lagres i en lang array med «nok plass», en hashtabell (aka «buckets») Hvert element som lagres har en nøkkelverdi Ut i fra nøkkelverdien beregnes hvilken indeks i hashtabellen som et element skal ligge på Indeksen beregnes med en hashfunksjon Indeksen som beregnes kalles en hashverdi Hvis alle hashverdier som beregnes er ulike, har alle elementer en unik indeks søking blir O(1)!
Typiske anvendelser av hashing Databasesystemer Minnehåndtering i operativsystemer Håndtering av variabler og metoder i kompilatorer Rask gjenfinning av grafikkelementer i 3D-spill Stavekontroll i editorer og tekstbehandlere: Riktig stavede ord kan legges i en hashtabell i stedet for å sorteres alfabetisk i et søketre
Hashing: Eksempel Skal lage et register for maksimalt 1000 objekter Unike nøkkelverdier: Syvsifret tall, f.eks. 4618996 Kan bruke en array med 10 mill. elementer, men... Bruker en array med lengde 1000 (hashlengden) Tre siste sifre i nøkkelverdi brukes som hashverdi: hash(key) = key % 1000 hash(4618996) = 996 (lagres på indeks 996) Og da er alt i orden og vi har en O(1) struktur?
Problem: Kollisjoner Kollisjon*: To elementer får samme indeks Eksempel: hash(key) = key % 1000 hash(6894331) = 331 hash(7462331) = 331 Hashing kan bli O(n) hvis det er mange kollisjoner i hashtabellen! *: Aka «hash-clash»
Og kollisjoner skjer «hele tiden» «En ulykke skjer sjelden alene» «Alt» har en tendens til å opptre i klynger/clustere: Byer, bilkøer, industriklynger, sosiale samlinger Fornavn Maurtuer, fiskestimer, gresshoppesvermer Galakser, stjernehoper Hvorfor det alltid er slik vet vi egentlig ikke, men: Det skal «veldig lite til» før ting begynner å kollidere Klassisk eksempel: Fødselsdagsparadokset
Hashing, kollisjoner og effektivitet Det er alltid mange kollisjoner i hashing med store datamengder For at hashing skal være effektivt må: Hashfunksjonen som brukes gi et lite antall kollisjoner og spre dataene godt i hashtabellen Kollisjoner håndteres så effektivt som mulig Hvis disse to kravene tilfredsstilles, kan hashing være mer effektivt enn både søketrær og B-trær for svært store datamengder
Hashfunksjoner Hashfunksjonen beregner en indeks i hashtabellen basert på nøkkelverdien som vi søker etter Hash: «Kutte opp i biter og blande sammen» Perfekt hashfunksjon: Lager aldri kollisjoner Alle elementer får en unik indeks Kan bare lages i tilfeller der alle data er kjent
Effektive hashfunksjoner Krav til en effektiv hashfunksjon: Beregning av hashverdien må være rask og O(1) Sprer hashverdiene jevnt i hashtabellen Gir et lite antall kollisjoner Utvikling av effektive hashfunksjoner er ikke en «eksakt vitenskap»: Antall kollisjoner avhenger både av datasettet og av lengden på hashtabellen Baserer seg i stor grad på heuristikk og empiri
Hashfunksjoner og hashlengde Hashlengde: Antall elementer i hashtabellen Hashfunksjonen beregner en indeks i hashtabellen: Verdien som returneres må være større eller lik 0 (null) og mindre enn hashlengden Beregner en verdi h basert på nøkkelverdien Returnerer resten ved heltallsdivisjon av h med hashlengden: h % hashlengde Vi vil alltid få bedre spredning og færre kollisjoner hvis hashlengden er et primtall: F.eks. er 997 og 1009 bedre hashlengder enn 1000
Noen typer hashfunksjoner Avkorting Sammenslåing / Folding Midten-av-kvadratet Bytte av tallsystem Utplukk og ombytting Basert på lengde av nøkkelverdi
Hashfunksjon: Avkorting (truncation) «Klipper» bare ut en del av nøkkelverdien Eksempel, nøkkelverdi er en streng: Bruk de k første bokstavene, tolket som siffer Eksempel, nøkkelverdi er et heltall: Bruk de k siste sifferne Finnes med heltallsdivisjon: nøkkelverdi % 10 k Fordel: Rask beregning av hashverdi Ulempe: Fordeler ujevnt, gir mye kollisjoner i tabellen
Eksempel: Hashing med enkel avkorting hashlengde = 8 hash(key) = key % hashlengde hash(36) = 36 % 8 = 4 hash(18) = 18 % 8 = 2 hash(72) = 72 % 8 = 0 hash(43) = 43 % 8 = 3 hash(6) = 6 % 8 = 6 0 72 1 2 18 3 43 4 36 5 6 6 7
Hashfunksjon: Sammenslåing / Folding Del opp nøkkelverdien i flere «småbiter» Slå sammen «småbitene», med f.eks. aritmetiske operasjoner, til en hashverdi Eksempel: Nøkkelverdi: 625381194 Lengde av hashtabell (hashlengde) 1000 Hashverdi: (625 + 381 + 194) % 1000 = 100 Folding sprer bedre enn avkorting
Enkel folding av tekststrenger int hash(string S) { int h = 0; for (int i = 0; i < S.length(); i++) h += (int)(s.charat(i)); return h % hashlengde; } Noen verdier med hashlengde = 1009 : hash("vw Karmann Ghia") = 317 hash("porsche 356A") = 979 hash("alfa Romeo Spider") = 556 hash("renault Floride") = 463
Problem: Enkel folding sprer dårlig I eksemplet på forrige side vil de fleste hashverdiene havne i et relativt lite intervall noen hundre opp til et par tusen for tekst-strenger av begrenset lengde Ubrukelig for store datasett med millioner av data For å spre bedre kan vektet folding brukes: Gi tegnene en vekt basert på posisjon Tolker strengen noe tilsvarende som et desimalt tall, der hvert tegn er et siffer som angir antallet av en potens av 10
Eksempel: Vektet folding av tekststrenger Tolk f.eks. 4 og 4 tegn i strengen som «desimale tall»: VW_Karmann_Ghia Beregn: ((V + W*10 + _*100 + K*1000) + (a + r*10 + m*100 + a*1000) + (n + n*10 + _*100 + G*1000) + (h + i*10 + a*100)) % hashlengde Gir mye større spenn i verdiene som beregnes
Kode: Vektet folding av tekststrenger int hash(string S) { int h = 0, i = 0; while (i < S.length()) { int potens = 1; for (int j = 0; (j < 4 && i < S.length()); j++) { h += (int)(s.charat(i)) * potens; potens *= 10; i++; } } return h % hashlengde; }
Javas egen hashfunksjon for strenger Merk: String.hashCode() ignorerer overflow og kan returnere negative verdier(!)
Hashfunksjon: Midten-av-kvadratet Anta nøkkelverdien er et heltall* Beregn kvadratet av nøkkelverdien Hashverdi: Siffere i midten av kvadratet Eksempel: Bruker hashverdier med tre sifre (000-999) Nøkkelverdi: 18562 18562 18562 = 344547844 hash(18562) = 547 *: For ikke-numeriske nøkkelverdier kan vi bare tolke bitsekvensene som tall
Hashfunksjon: Bytte av tallsystem* Antar nøkkelverdi er et desimalt heltall: Grunntallet er 10, sifferne er 0, 1, 2, 3,..., 8, 9 1369 = 1369 10 = 9 + 6 10 1 + 3 10 2 + 1 10 3 Skriver om nøkkelverdien til f.eks. 8-tallsystemet: Grunntallet er 8, sifferne er 0, 1, 2, 3, 4, 5, 6, 7 2531 8 = 1 + 3 8 1 + 5 8 2 + 2 8 3 = 1369 10 Hvis hashlengden er 1009: hash(1369) = 2531 % 1009 = 513 *: Det finnes flere metoder for bytte av tallsystemer i Java, se f.eks. Integer.toOctalString
Hashfunksjon: Utplukk og ombytting Plukker ut noen siffer eller tegn fra nøkkelverdien Bytter deretter om på disse sifferne/tegnene Eksempel: Vil ha fire-sifrede hashverdier Nøkkelverdier med tolv siffer: 783248695301 Tar ut siffer nummer tre, seks, ni og tolv: 3851 Hashverdi: En eller annen omstokking av sifferne: Reversering: 1583 Høyreskift: 1385 Venstreskift: 8513 Parvis bytting: 8315
Hashfunksjon: Basert på lengde av nøkkelverdi Beregner en hashverdi basert på en nøkkelverdi og nøkkelverdiens lengde Eksempel: Nøkkelverdier med åtte siffer: 46286947 Ganger de tre første sifrene med lengden av nøkkelverdiene: 462 8 = 3696 Deler på det siste sifferet: 3696 / 7 = 528 hashverdi = 528 % hashlengde
Test av ulike hashfunksjoner Enkelt testprogram: hashfunctions.java Tester følgende metoder ved å telle antall kollisjoner: 1. Enkel folding 2. Vektet folding 3. Javas innebygde hashfunksjon (vektet folding) 4. Midten-av-kvadrat 5. Bytte av tallsystem 6. Utplukk og bytting 7. Lengde av dataverdi Hasher hele linjer med tekst fra standard input Testdatasett: cars.txt
Innsetting av dataelement i hashtabell Algoritme: 1. Bruk en hashfunksjon til å beregne hashverdi basert på dataelementets nøkkelverdi 2. Sett inn dataelementet i hashtabellen Innsetting i hashtabell er O(1) hvis problemet med kollisjoner kan løses med en O(1) operasjon Innsetting blir langsommere enn O(1) når hashtabellen blir «for full» av data og det blir mange kollisjoner
Load factor Hashing er svært effektivt så lenge hashtabellen har «mange ledige plasser» og det er lite kollisjoner «Load factor» L er et mål på hvor full en hashtabell er L = n / h n: Antall elementer lagret i hashtabellen h: hashlengden Tolking av load factor: L = 0.5 Halvfull hashtabell L < 1.0 L > 1.0 Færre datalelementer enn arrayplasser Flere datalelementer enn arrayplasser
To metoder for håndtering av kollisjoner Åpen adressering (open adressing): Hvis en indeks i hash-tabellen er opptatt, legges elementet et annet sted i tabellen på en eller annen systematisk måte (f.eks. neste ledige) Load factor maks 1.0 Kjeding (chaining): Hvert element i hash-tabellen er en lenket liste (eller et søketre?) som inneholder alle elementer med samme hashverdi Load factor kan være større enn 1.0
Åpen adressering Hvis hashindeksen som beregnes er opptatt, gjør vi en «probing» (et søk) etter en annen ledig indeks i hashtabellen der vi kan legge elementet Probingen må være systematisk/deterministisk, slik at vi kan finne igjen elementet ved søking Må kunne håndtere muligheten for at vi ikke finner noen ledig plass i tabellen på en robust måte
Enkleste variant av åpen adressering: Lineær probing Beregn dataelementets hashverdi h Hvis indeks h i hashtabellen er opptatt: Sett inn nytt dataelement på første ledige indeks etter indeks h Hvis indeks h + 1 er opptatt, prøv å sette inn på indeks h + 2, h + 3, h + 4, osv., inntil en ledig plass er funnet Gjør en «wrap-around» (fortsett med indeks 0) hvis vi kommer til slutten av tabellen
Lineær probing: Eksempel
Fordeler med lineær probing Enkelt å programmere: hashlinear.java Svært rask beregning av probes (kun én addisjon) Det kan bevises at: Hvis dataene som settes inn er «rimelig tilfeldige» og load factor er 0.5 (maks. halvfull hashtabell), vil lineær probing alltid gi hashtabeller med O(1) effektivitet
Problemer med lineær probing Lite effektiv håndtering av clustering / klumping: Sprer ikke data som hashes til samme område i hashtabellen «primary clustering» Alle elementer som har samme hashverdi vil bli liggende i en «klump» i tabellen Innsetting og søking lite effektivt når tabellen er full: Lineær probing får problemer for load factor > 0.7 Får lange «opptatte sekvenser» i tabellen som må gås gjennom for å finne en ledig plass
Åpen adressering med kvadratisk probing Beregn dataelementets hashverdi h Hvis indeks h i hashtabellen er opptatt: Forsøk å sette inn på indeks h + 1 Hvis indeks h + 1 er opptatt, prøv å sette inn på indeksene: h + 4, h + 9, h + 16, h + 25, h + 36, osv., inntil en ledig plass finnes Gjør en «wrap-around» (fortsett med indeks 0) hvis vi havner utenfor hashtabellen
Kvadratisk probing: Eksempel
Fordeler med kvadratisk probing Enkelt å programmere: hashquadratic.java Sprer elementene bedre enn lineær probing Løser opp «primary clustering» ved å flytte elementer med lik hashverdi langt fra hverandre Det kan bevises at: Hvis load factor er 0.5 og hashlengden er et primtall, vil kvadratisk probing alltid gi hashtabeller med O(1) effektivitet
Problemer med kvadratisk probing Beregning av kvadratiske «probes» er mer kostbart enn lineære Løser ikke opp «secondary clusters»: Elementer som har hashverdier som ligger nære hverandre i hashtabellen, vil i liten grad spres Matematisk analyse av kvadratisk probing er vanskeligere enn for lineær probing og ikke komplett, de mest brukte løsningene er i stor grad basert på «best practice»
Lineær og kvadratisk probing: Sammenligning av effektivitet av søking Konstant load factor lik 0.9, varierende hashlengder Søk der element ble funnet Søk der element ikke ble funnet
Lineær og kvadratisk probing: Sammenligning av effektivitet av søking Konstant hashlengde lik 1000, varierende load factor Søk der element ble funnet Søk der element ikke ble funnet
Åpen adressering med rehashing Probing med rehashing*: Bruk en annen og anderledes hashfunksjon for å finne neste indeks/probe ved kollisjoner Hvis neste indeks også er opptatt, prøv med f.eks. 2 ganger ny hashverdi, deretter 3 ganger ny verdi etc. Gir ofte bedre spredning enn lineær og kvadratisk probing Kan løse opp både primære og sekundære clustere *: Også kalt «dataavhengig probing»
Åpen adressering med bruk av en randomgenerator Probing med en (pseudo) randomgenerator: Hvis kollisjon, bruk hashverdien som seed i en randomgenerator og beregn nye indekser som en sekvens av «tilfeldige» tall (% hashlengde) inntil ledig plass funnet Fungerer fordi alle randomgeneratorer egentlig er deterministiske (pseudo random) og sekvensen av tilfeldige tall kan gjenskapes Kan også gi bedre spredning enn lineær og kvadratisk probing
Når hashtabellen blir full Med åpen adressering bør hashlengden økes når: Load factor blir > 0.8, eller: Vi ikke finner noen ledig indeks ved kollisjoner Vanlig å doble lengden av hashtabellen: Vet at effektiviteten er garantert for load factor < 0.5 Velger alltid hashlengde lik nærmeste primtall Økning av hashlengden er en O(n) operasjon: Alle elementer må hashes på nytt for at de skal kunne finnes igjen med ny hashlengde
Fjerning av elementer fra en hashtabell med åpen adressering Problem: Vi risikerer å «bryte kjeden» ved å fjerne et element som ligger i en liste av probes Vanlig løsning: Ikke fjern elementer, men bruk i stedet en ekstra boolsk array til å merke at elementer skal slettes Ved kollisjoner kan vi stoppe kjeden av probes når vi kommer til et element som er merket som fjernet, og bare overskrive dette elementet Alle elementer som er merket som fjernet, blir tatt vekk hver gang vi må gjøre en fullstendig rehashing i forbindelse med økning av lengden på hastabellen
Hashing med kjeding Hashtabellen er en array av lister (buckets): Vanlig å bruke usorterte lenkede lister Alternativt kan listene «simuleres» ved å legge elementer som kolliderer i et «overflow» område i hashtabellen, for å spare overhead til pekere* Kollisjoner løses enkelt: Alle elementer som får samme hashverdi legges inn i listen som ligger på denne indeksen i hashtabellen Hashing med kjeding partisjonerer dataene opp i små delmengder som kan behandles effektivt *: Se figur E.3 i Appendix E i læreboka
Kjeding: Eksempel
Effektivitet av hashing med kjeding Innsetting og søking krever: Beregning av hashverdi / indeks i hashtabellen Sekvensielt søk i den usorterte listen som ligger lagret på denne indeksen Enkel implementasjon: hashchained.java Hashing med kjeding kan bli O(1) hvis: Hashfunksjonen sprer jevnt, slik at listene blir omtrent like lange Load factor ikke er for stor, slik at listene ikke blir svært lange
Load factor, hashlengde og kjeding Kjeding fungerer også for load factor større enn 1.0, hvis hashfunksjonen sprer jevnt Fra Wikipedia: Chained hash tables remain effective even when the number of table entries n is much higher than the number of slots. Their performance degrades more gracefully (linearly) with the load factor. For example, a chained hash table with 1000 slots and 10,000 stored keys (load factor 10) is five to ten times slower than a 10,000-slot table (load factor 1); but still 1000 times faster than a plain sequential list, and possibly even faster than a balanced search tree.
Effektivitet av kjeding: Test av søking Load factor = 1.0 Varierende hashlengde Fast hashlengde Varierende load factor
Kjeding: Fordeler og ulemper Fordeler: Raskt, sammenligner bare elementer med lik hashverdi Tåler load factor >> 1.0, mindre behov for økning av hashlengde og full rehashing Fjerning av elementer er enkelt (lenket liste) Ulemper: Krever O(n) ekstra plass til referanser/pekere Økning av hashlengde med full rehashing er mer komplisert/tidkrevende enn for åpen adressering, fordi vi må håndtere dynamisk hukommelse/pekere
Sammenligning: Søkealgoritmer og datastrukturer Søkealgoritme Datastruktur Sortert? Innsetting Søking Fjerning Sekvensiell Liste/array Nei O(1) O(n) O(n) Binærsøk Array Ja O(n) O(log n) O(n) Søketre* Binært tre Ja O(log n) O(log n) O(log n) Hashing** Hashtabell Tja O(1) O(1) O(1) *: Garantert oppførsel (AVL) **: Ikke garantert