Hashtabeller Lars Vidar Magnusson 12.2.2014 Kapittel 11 Direkte adressering Hashtabeller Chaining Åpen-adressering
Dictionaries Mange applikasjoner trenger dynamiske sett som bare har dictionary oparsjonene Insert, Search og Delete. En symboltabell i en kompilator er et eksempel. Det er mulig å implementere dictionariy operasjonene på en vanlig array, men det vil i mange situasjoner være lite praktiskt. Hashtabeller kan være et effektivt alternativ.
Direkteadresserte Arrays Direkteadresserte arrays eller tabeller kan brukes til å enkelt implementere en dictionary. Hvert element x har en nøkkel i universet U = {0, 1,..., m 1}, hvor m er relativt liten i.e vi har ganske få ulike nøkler. Hver nøkkel er unik i.e. ingen elementer har samme nøkkel. Direkteadresserte tabeller blir representert av en array T [0... m 1] Hver plass (array indeks) fungerer som en nøkkel i U. Hvis det finnes element x med nøkkel k i settet, så vil T [k] inneholde en peker til elementet. Hvis ikke vil T [k] være tom (representert av nil).
Hvordan Direkteadresserte Arrays Fungerer Figuren under viser hvordan direkteadresserte tabeller henger sammen med universet U av mulige nøkler, settet K med faktiske nøkler, og selve elementene.
Dictionary Operasjoner på Direkteadresserte Tabeller Implementasjonen av dictionary operasjoner på direkteadresserte tabeller er trivielt. Alle operasjonene har kjøretid på Θ(1).
Hashtabeller Problemet med direkteadresserte tabeller er at universet U kan bli stort i.e. det er mange mulige nøkler. I mange tilfeller blir U så stor at en tabell med plass til hver nøkkel er upraktiskt eller til og med umulig. Ofte er antall nøkler som faktiskt blir lagret liten, som vil gi mye bortkastet plass med direkteadresserte tabeller. Når settet med faktiske nøkler K er mye mindre enn universet U med alle mulige nøkler. Kan redusere lagringsbehovet til Θ( K ). Kan få O(1) i gjennomsnitt (men ikke i verstefall).
Ideen bak Hashtabeller Ideen bak hashtabeller kan summeres med at i stedet for å lagre et element x med nøkkel k på plass k, så tar vi i bruk en funksjon h og lagrer elementet på plass h(k). Vi bruker fortsatt en array T [0, 1,..., m 1], men nå er ikke m antall mulige nøkler som ved direkteadressert tabeller men ledige plasser. Vi kaller h en hashfunksjon. h : U {0, 1,..., m 1} i.e. h er en funksjon som mapper fra elementene i U til en gyldig plass i T. Vi sier at k hashes til h(k).
Hvordan Fungerer Hashtabeller Figuren under viser hvordan nøklene i K U henger sammen med hashfunksjonen h og tabellen T.
Hashtabeller og Kollisjoner Når to nøkler får samme plass i hashtabellen får vi en kollisjon. Kan forekomme hvis h er en dårlig hashfunksjon (kommer tilbake til dette senere) Kan forekomme hvis U > m (som er vanlig når hashtabeller velges fremfor direkte adressering) Må forekomme hvis K > m, men ikke nødvendigvis hvis K m. Det finnes ulike strategier for å overkomme problemet med kollisjoner. Chaining (Lenkede Lister) Åpen adressering (open addressing)
Ordne Kollisjoner med Chaining Chaining er en metode som tar i bruk lenkede lister for å overkomme problemet med hashkollisjoner. T [j] inneholder en peker til en lenket liste med alle elementer med h(k) = j. T [j] er nil hvis plassen er tom. Chaining fungerer stort sett bedre enn åpen adresssering, men strategien krever også mere plass pga. alle pekerne.
Hvordan Fungerer Chaining Figuren under viser hvordan lenkede lister kobles inn i oppsettet med nøkler og hashtabellen.
Chained-Hash-Insert Algoritmen Chained-Hash-Insert brukes for å legge til nye elementer i tabellen. Kjøretiden er Θ(1). Vi setter alltid inn det nye elementet først i den eventuelle listen. Vi antar at elementet ikke allerede er satt inn, siden det ville krevd et ekstra søk for å finne det ut.
Chained-Hash-Search Algoritmen Chained-Hash-Search algoritmen brukes til å søke etter et element x med nøkkel k. Kjøretiden er proporsjonal med antall elementer i listen i.e. elementer med samme nøkkel, men det kan bevises at gjennomsnitts kjøretid er O(1). Vi skal holde oss til en nokså overfladisk analyse av Chained-Hash-Search i denne forelesningen.
Chained-Hash-Delete Algoritmen Chained-Hash-Delete brukes til å slette elementer fra en hashtabell med lenkede lister. Vi tar en peker til elementet x som argument i stedet for nøkkelen. Dette gjør at vi kan utføre slettingen i konstant tid (Θ(1)) så lenge vi bruker en dobbeltlenket liste. Hvis vi hadde brukt en enkeltlenket liste måtte vi først utføre et søk for å finne korrekt element.
Analyse av Søking i Hashtabeller med Chaining Analysen av operasjonene på hashtabeller med chaining baseres på den såkalte load faktoren α = n/m n er antall elementer i tabellen m er antall plasser i tabellen Load faktoren α kan sees som gjennomsnitts antall elementer i hver liste (så lenge hashingen er uniform). Vi kan ha α < 1, α = 1 eller α > 1, men lavere load faktorer er naturlig nok å foretrekke.
Analyse av Søking i Hashtabeller med Chaining Verstefall kjøretid for søk i hashtabeller forekommer når alle elementene hashes til samme plass. Vi får en enkelt lang list med alle elementene. Verstefall kjøretid for å søke tabellen blir da Θ(n) pluss tiden det tar å regne ut hashverdien (normalt O(1)). Gjennomsnittsytelsen til en hashtabell er avhengig av hvor godt hashfunksjonen distruberer elementene. Det kan bevises (se i boka) at gjennomsnittskjøretiden til et søk er Θ(1 + α). Hvis n = O(m), så er α = n/m = O(m)/m = O(1) i.e. hvis antall elementer ikke er polynomisk større enn antall plasser så er kjøretiden konstant.
Hashfunksjoner En god hashfunksjon skal i prinsippet være uniform i.e. den skal sørge for at elementene blir jevnt fordelt utover tabellen. I praksis er dette normalt sett ikke mulig siden vi ikke vet noe om distribusjonen nøklene og hvorvidt de er uavhengige trukket eller ikke. Man tyr derfor ofte til heuristics, som kan beskrives som en kvalifisert gjetning eller tilnærming til et problem.
Nøkler som Naturlige Tall Hashfunksjoner går utifra at alle nøkler er naturlige tall. Når de ikke er det e.g. tekststrenger, må de konverteres til naturlige tall. Eksempel: En tekststreng med ASCII-tegn kan konvertes til et tall med radix notasjon. Vi har tekststrengen CLRS. C = 67, L = 76, R = 82 og S = 83 i ASCII verdier. Vi kan da si at CLRS = (67 128 3 ) + (76 128 2 ) + (82 128) + 83 = 141764947
Divisjonsmetoden som Hashfunksjon En hashfunksjon som bruker divisjonsmetoden kan beskrives med følgende ligning. h(k) = k mod m Eksempel: m = 20 og k = 91 gir en hashverdi på k mod m = 11.
Fordeler og Ulemper med Divisjonsmetoden Fordelen divisjonsmetoden er at den er rask siden bare en enkelt divisjon må utføres. Ulempen er at man må være forsiktig med valget av størrelsen på tabellen m. 2 er potenser er generelt sett dårlig. Hvis m = 2 p så blir h(k) bare de p minste-signifikante bittene i k. Hvis k er en tekststreng i en radix 2 p så er m = 2 p 1 et dårlig valg siden dette vil gjøre ulike permutasjoner av de samme tegnene for samme hashverdi. Et godt valg for m er typisk et primtall som er relativt langt unna en 2 er potens.
Multiplikasjonsmetoden En hashfunksjon som bruker multiplikasjonsmetoden kan beskrives med følgende ligning. h(k) = m(ka mod 1). Hvor ka mod 1 = ka ka i.e. desimaldelen av ka Vi kan også beskrive hashfunksjonen med følgende steg 1 Velg en konstant 0 < A < 1 2 Gang nøkkelen k med A 3 Trekk ut desimaldelen av resultatet 4 Gang desimaldelelen med antall plasser i tabellen m. 5 Finn nærmest heltall mindre enn resultatet og bruk denne som indeks i tabellen.
Fordeler og Ulemper med Multiplikasjonsmetoden Fordelen med multiplikasjonsmetoden er at den ikke blir preget av størrelsen m slik som divisjonsmetoden. Ulempen er at metoden er tregere en divisjonsmetoden. Det kan også være vanskelig å finne en god verdi for konstanten A. Alle gyldige verdier er mulige (0 < A < 1) Donald Knuth foreslår å bruke A ( 5 1)/2
Åpen Adressering Åpen adressering (open addressing) er et alternativ til chaining for å overkomme kollisjoner i hashverdiene. Ideen går ut på å lagre nøklene i tabellen direkte i stedet for i lenkede lister. Hvert felt inneholder enten en nøkkel eller nil. Vi utfører såkalte probes for å sjekke om en plass er ledig eller ikke. Hashfunksjonen må utvides slik at den kan returnere en sekvens av plasser å utføre probes på. Vi får da en hashfunksjon h(k, i) hvor k er nøkkelen som skal hashes og i {0, 1,... m 1}. Sekvensen av posisjoner som testes må være en permutasjon av 0, 1,..., m 1 slik at vi er innom alle posisjoner.
Hash-Search Algoritmen Hash-Search algoritmen kan brukes for å søke i en hashtabell med åpen adressering. Algoritmen returnerer enten posisjonen til elementet, eller nil hvis elementet ikke ikke ble funnet. Kjøretiden til algoritmen avhenger av hvor mange probes som må utføres før elementet finnes eller vi kommer til en ledig plass.
Hash-Insert Algoritmen Hash-Insert algoritmen brukes til å sette inn elementer inn i en hashtabell med åpen adressering. Algoritmen returner posisjonen til det nye elementet eller den kaster en exception hvis tabellen er full. Kjøretiden til algoritmen avhenger av hvor mange probes som må utføres før vi kommer til en ledig plass.
Sletting fra Hashtabeller med Åpen Adressering Man kan ikke bare slette et element fra en tabell ved å sette plassen som ledig da dette vil ødelegge både probe sekvensen. Vi prøver oss på å slette et element ved å sette posisjonen til ledig. Vi antar at elementet var det andre av tre elementer med samme nøkkel Fremtidige søk vil ikke lenger ha mulighet til å finne det siste elementet siden kjeden er brutt. En løsning er å i stedet sette posisjonen som deleted. Dette fikser problemet med søkingen. Problemet med denne løsningen er at søketiden nå ikke lenger er avhengig av load faktoren α.
Hvordan Genererer Probe-Sekvenser Ideelt sett så hadde vi hatt uniform hashing hvor alle permutasjoner m! er like sansynlige som probe-sekvenser. I praksis er det vanskelig å implementere uniform hashing Vi bruker i stedet tilnærminger, men ingen av disse er kapable til å generere m! sekvenser.
Lineær Probing I lineær probing tar vi bruk en auxiliary hashfunksjon h som fungerer som en normal hashfunksjon. Gitt en nøkkel k og et probe nummber 0 i < m så implmenteres lineær probing med følgende ligning. h(k, i) = (h (k) + i) mod m Den første probeposisjonen bestemmer hele sekvensen i.e. vi har m mulige sekvenser. Ulempen med lineær probing er at metoden lider av at det bygger seg lange sammenhengende sekvenser som påvirker søketiden.
Kvadratisk Probing Som i lineær probing så bygger kvadratisk probing på en auxiliary hashfunksjon h som angir startposisjonen. I motsetning til lineær probing så hopper vi med kvadratisk probing rundt utifra en kvadratisk funksjon. h(k, i) = (h (k) + c 1 i + c 2 i 2 ) mod m Den første probeposisjonen bestemmer hele sekvensen også her i.e. vi har m mulige sekvenser. Kvadratisk probing lider også av at elementer hoper seg opp, men med denne strategien er sjansen mindre.
Dobbelhash Probing I dobbelhash probing (double hashing) benytter vi i motsetning til de andre metodene to auxiliary hashfunksjoner h 1 og h 2. h 1 bestemmer første probeposisjon, mens h 2 bestemmer de resterende. h(k, i) = (h 1 (k) + ih 2 (k)) mod m Metoden gir Θ(n 2 ) mulige sekvenser, som er en vesentlig forbedring. Hashfunksjonen h 2 må ha få faktorer felles med m for å besøke alle posisjonene. Kan velge m til å være en 2 er potens og la h 2 alltid generere oddetall større enn 1. Kan la m være et primtall og la 1 < h 2(k) < m.
Analyse av Åpen Adressering Som med chaining så er effektiviteten til åpen adressering avhengig av load faktoren α = n/m. Det kan bevises (se fagboka) at det forventede antallet probes i et søk som ikke finner elementet er 1/(1 α). Dette gir en konstant kjøretid på begge operasjonene så lenge α er konstant.