Sunteți pe pagina 1din 19

Lectia nr. 2. – Utilizarea STL.

Hash-uri

Utilizarea STL. Hash-uri


Introducere
Multe aplicații necesită o mulțime dinamică pentru care să se aplice numai operațiile specifice pentru
dicționare: inserează, caută, șterge.

O tabelă de dispersie (tabelă hash) este o structură eficientă de date pentru implementarea
dicționarelor.

O tabelă de dispersie este o generalizare a noţiunii mai simple de tablou.

Adresarea directă într-un tablou foloseşte abilitatea noastră de a examina o poziţie arbitrară în timp
𝑂(1). Adresarea directă este aplicabilă numai în cazul în care ne permitem sa alocăm un tablou care are
câte o poziţie pentru fiecare cheie posibilă.

Figura 1.

Implementarea unei mulţimi dinamice printr-un tablou cu adresare directă 𝑇.

Fiecare cheie din universul 𝑈= 0,1,2,…,9 corespunde unui indice în tablou.

Mulţimea 𝐾= 2,3,5,8 a cheilor efective determină locaţiile din tablou care conţin pointeri către
elemente.

Celelalte locaţii, haşurate mai întunecat, conţin 𝑁𝐼𝐿.

Când numărul cheilor memorate efectiv este relativ mic faţă de numărul total de chei posibile, tabelele
de dispersie devin o alternativă eficientă la adresarea directă într-un tablou, deoarece se foloseşte un
tablou de mărime proporţională cu numărul de chei memorate efectiv.

În loc să se folosească direct cheia ca indice în tablou, indicele este calculat pe baza cheii, folosind o
funcţie de dispersie (hash), care face maparea spaţiului de chei într-un spaţiu de adrese.

Funcţia generează o adresă printr-un calcul simplu, aritmetic sau logic asupra cheii sau asupra unei părţi
a cheii.
Lectia nr. 2. – Utilizarea STL. Hash-uri

Să considerăm 𝐴 o mulțime cu 𝑛 valori care trebuie stocate în structura de date (aceste valori sunt
denumite chei) și 𝑇 un vector cu 𝑚 componente (denumit tabel hash).

Vom considera o funcție hash prin care se asociază fiecărei chei un număr întreg din intervalul 0,𝑚−1 :
ℎ:𝐴→ [0,𝑚−1]

Funcția hash are proprietatea că valorile ei sunt uniform distribuite în intervalul specificat. Aceste valori
vor fi utilizate ca indici în vectorul 𝑇. Mai exact, cheia 𝑥 va fi plasată în tabela hash pe poziția ℎ 𝑥 . 4

Datorită faptului că spaţiul de chei este de obicei mult mai mare decât spaţiul de adrese, se poate
întâmpla ca mai multe chei să aibă aceeaşi adresă. Aceasta se numeşte coliziune între înregistrări.

Figura 2.

Folosirea unei funcţii de dispersie 𝒉 pentru a transforma chei în poziţii din tabela de dispersie. Cheile 𝑘2
şi 𝑘5 se transformă în aceeaşi poziţie, deci sunt în coliziune.

Funcții hash

Metoda Diviziunii (restul împărțirii la m)


ℎ 𝑥 =𝑥 % 𝑚
În acest caz este indicat să evităm ca m să fie de forma 2𝑝 (pentru că atunci ℎ(𝑥) ar avea ca valoare
ultimii 𝑝 biți ai lui 𝑥). Exceptând cazul în care știm că toate secvențele binare de lungime 𝑝 apar cu
acceași probabilitate ca ultimi 𝑝 biți ai cheii, este de preferat să utilizăm o funcție în care se utilizează
toți biții cheii.

Un număr prim apropiat de o putere a lui 2 este adesea o bună alegere pentru 𝑚. Se alege un număr
prim pentru ca înmulțirea a doi termeni care au suferit operația modulo să dea rezultat nul numai daca
unul dintre aceștia este nul.
Lectia nr. 2. – Utilizarea STL. Hash-uri

Metoda înmulțirii
ℎ(𝑥) = [𝑚∗(𝑓𝑟𝑎𝑐(𝑥∗𝑦)]
Se înmulțește cheia 𝑥 cu un număr subunitar 0<𝑦<1, considerăm partea fracționară a lui 𝑥∗𝑦, o
înmulțim cu 𝑚 și reținem doar partea întreagă a rezultatului.

Un avantaj al acestei metode este că nu există valori particulare pentru 𝑚 care să trebuiască evitate ca
la Metoda Diviziunii, prin urmare de obicei 𝑚 poate fi o putere a lui 2, pentru a implementa eficient
funcția hash.

Metoda probabilistică (Hashing-ul universal)


Dacă un adversar “răutăcios” va alege cheile astfel încât toate să aibă aceeași valoare pentru o anumită
funcție hash, atunci obținem un timp de execuție liniar. Soluția ar fi să alegem o funcție hash
independentă de cheile ce urmează a fi stocate.

Ideea de bază este să selectăm funcția hash la întâmplare dintr-o listă predefinită de funcții hash la
începutul execuției programului.

Se numește familie universală de funcții hash o mulțime 𝐻 de funcții definite pe 𝐴 cu valori în {0,1,
…,𝑚−1} astfel încât pentru orice pereche de chei 𝑥,𝑦 (𝑥 ≠ 𝑦) numărul funcțiilor hash din familie pentru
care ℎ(𝑥)=ℎ(𝑦) este cel mult 𝐻 𝑚.

Cu alte cuvinte, dacă alegem aleator o funcție din această familie, probabilitatea de obține o coliziune
pentru cheile 𝑥 și 𝑦 este < 1𝑚 .

Un exemplu care se comportă foarte bine în practică este următorul: se generează aleator un număr
natural impar 𝑟. Valoarea funcției pentru un întreg 𝑘 se află înmulțind 𝑘 cu 𝑟 și păstrând cei mai
semnificativi 𝑝 biți ai rezultatului (înmulțirea se face pe 32 de biți, și ignorăm overflow).

Interpretarea cheilor ca numere naturale

Majoritatea funcţiilor de dispersie presupun universul cheilor din mulţimea ℕ= 0,1,2,… a numerelor
naturale. Astfel, dacă cheile nu sunt numere naturale, trebuie găsită o modalitate pentru a le interpreta
ca numere naturale.

De exemplu, o cheie care este un şir de caractere poate fi interpretată ca un întreg într-o bază de
numeraţie aleasă convenabil. Prin urmare, identificatorul 𝑝𝑡 poate fi interpretat ca o pereche de
numere zecimale (112,116), pentru că 𝑝=112 şi 𝑡=116 în mulţimea codurilor ASCII; atunci 𝑝𝑡 exprimat
ca un întreg în baza 128 devine (112 ∙ 128)+116 = 14452.

În mod obişnuit, în orice aplicaţie se poate crea direct o asemenea metodă simplă de a interpreta
fiecare cheie ca un număr natural (posibil mare).

Coliziunile
Lectia nr. 2. – Utilizarea STL. Hash-uri

Așa cum spuneam și mai devreme, datorită faptului că spaţiul de chei este de obicei mult mai mare
decât spaţiul de adrese, se poate întâmpla ca mai multe chei să aibă aceeaşi adresă. Aceasta se numeşte
coliziune între înregistrări.

Prin urmare este necesar şi un mecanism de rezolvare a coliziunilor. Există două metode de rezolvare a
coliziunilor.

Chaining (înlănţuire)
Toate valorile sinonime (care au aceeaşi valoare hash) vor fi plasate într-o listă înlănţuită. Astfel în
vectorul 𝑇 pe poziţia 𝑖 vom reţine un pointer către începutul listei înlănţuite formate din toate valorile 𝑥
pentru care ℎ(𝑥)=𝑖.

Operaţia de inserare se execută în 𝑂(1) (se inserează valoarea 𝑥 la începutul listei 𝑇 ℎ 𝑥 ).

Operaţia de căutare presupune căutarea elementului cu cheia 𝑥 în lista înlănţuită 𝑇 ℎ 𝑥 .

Operaţia de stergere presupune căutarea elementului cu cheia 𝑥 în lista înlănţuită 𝑇 ℎ 𝑥 , apoi ştergerea
din listă a acestuia. 6

Ultimele două operaţii au timpul de execuţie proporţional cu lungimea listei 𝑇 ℎ 𝑥 .

Analizând complexitatea în cazul cel mai defavorabil, lungimea listei poate fi 𝑂(𝑛) (atunci când toate
elementele sunt sinonime).

Analizând complexitatea în medie, observăm că lungimea listei depinde de cât de uniform distribuie
funcţia hash cheile.

Dacă presupunem că funcţia hash va distribui uniform valorile, atunci lungimea medie a listei este 𝑛𝑚
(această valoare este denumită factor de încărcare a tabelei), deci timpul de execuţie va fi 𝑂(1+𝑛/𝑚) .

Open-addressing (adresare deschisă)


În tabela hash nu sunt memoraţi pointeri, ci sunt memorate elementele mulţimii 𝐴. Astfel în tabelă se
vor afla poziţii ocupate cu elemente din 𝐴 şi poziţii libere (acestea vor fi marcate cu o valoare specială
pe care o vom denumi 𝑁𝐼𝐿).

În acest caz, pentru a insera un element 𝑥 în tabela hash se vor executa mai multe încercări, până când
determinăm o poziţie liberă, în care putem plasa elementul 𝑥. Poziţiile care se examinează depind de
cheia 𝑥.

În acest caz, funcţia hash va avea doi parametri: cheia 𝑥 şi numărul încercării curente (numerotarea
încercărilor va începe de la 0 ): ℎ:𝐴 × {0,1,...,𝑚−1} → {0,1,...,𝑚−1}

Operaţia de inserare: Pentru a insera elementul 𝑥 în tabela 𝑇 se examinează în ordine poziţiile ℎ 𝑥,0 ,
ℎ(𝑥,1), …, ℎ(𝑥,𝑚 − 1). Această secvenţă de poziţii trebuie să fie o permutare a mulţimii {0,1,…,𝑚−1}.

Operaţia de căutare: Căutarea unui element în tabelă presupune examinarea aceleiaşi secvenţe de
poziţii ca şi inserarea. Dacă în tabel nu se efectuează ştergeri, căutarea se termină fără succes la
întâlnirea primei poziţii libere.

Operaţia de ştergere: Ştergerea unui element dintr-o tabelă hash este dificilă. Nu putem doar marca
poziţia 𝑖 pe care este plasată cheia 𝑥 ca fiind liberă. În acest mod, căutare oricărei chei pentru care pe
parcursul inserării am examinat poziţia 𝑖 şi am găsit-o ocupată se va termina fără succes.
Lectia nr. 2. – Utilizarea STL. Hash-uri

O soluţie ar fi ca la ştergerea unui element pe poziţia respectivă să nu plasăm valoarea 𝑁𝐼𝐿, ci o altă
valoare specială (pe care o vom denumi 𝐷𝐸𝐿𝐸𝑇𝐸𝐷). Funcţia de inserare poate fi modificată astfel
încât să insereze elementul 𝑥 pe prima poziţie pe care se află 𝑁𝐼𝐿 sau 𝐷𝐸𝐿𝐸𝑇𝐸𝐷.

Dacă alegem această soluţie, timpul de execuţie al căutării nu mai este proporţional cu factorul de
încărcare a tabelei.

Din acest motiv, tehnica de rezolvare a coliziunilor prin înlănţuire este mai utilizată pentru cazul în care
structura de date suportă şi operaţii de ştergere.

Distribuția cheilor
Pentru ca distribuţia cheilor să fie uniformă, funcţia hash trebuie să genereze cu egală probailitate
oricare dintre cele 𝑚! permutări ale mulţimii {1,2,...,𝑚−1}.

Pentru a determina secvenţa poziţiilor de examinat se utilizează 3 tehnici. Fiecare dintre aceste 3 tehnici
garantează că pentru orice cheie secvenţa poziţiilor de examinat este o permutare a mulţimii
{0,1,...,𝑚−1}. Nici una dintre acestea nu asigură o distribuţie perfect uniformă.

Examinarea liniară
Considerând o funcţie hash “obişnuită” ℎ′: 𝑈 → {0,1,...,𝑚 − 1} (denumită funcţie hash auxiliară), tehnica
examinării liniare utilizează funcţia: ℎ (𝑥,𝑖) = (ℎ′ (𝑥) +𝑖) % 𝑚

Observaţii

Deoarece prima poziţie din secvenţă determină întreaga secvenţă de poziţii de examinat, deducem că
există doar m secvenţe de examinare distincte.

Examinarea liniară este uşor de implementat, dar generează un fenomen denumit primary clustering
(secvenţa de poziţii ocupate devine mare, şi astfel timpul mediu de execuţie al căutării creşte).

Examinarea pătratică
Examinarea pătratică utilizează o funcţie hash de forma: ℎ 𝑥,𝑖 = ℎ′ 𝑥 +𝑐1𝑖 + 𝑐2𝑖2 % 𝑚

unde ℎ′ este funcţia hash auxiliară, iar 𝑐1 şi 𝑐2 ≠0 sunt constante auxiliare.

Observaţie

Această metodă funcţionează mai bine decât examinarea liniară, dar pentru a garanta faptul că întreaga
tabelă este utilizată, 𝑚, 𝑐1 şi 𝑐2 trebuie să îndeplinească anumite condiţii.

Hashing dublu
Hashing-ul dublu utilizează o funcţie de forma: ℎ 𝑥,𝑖 = ℎ1 𝑥 +𝑖ℎ2 𝑥 % 𝑚

unde ℎ1 şi ℎ2 sunt funcţii hash auxiliare.

Pentru ca întreaga tabelă să fie examinată valoarea ℎ2(𝑥) trebuie să fie realtiv primă cu 𝑚. O metodă
sigură este de a alege pe 𝑚 o putere a lui 2, iar ℎ2 să producă numai valori impare. Sau 𝑚 să fie un
număr prim, iar ℎ2 să producă numere <𝑚.
Lectia nr. 2. – Utilizarea STL. Hash-uri

Observaţie

Hashingul dublu este mai eficient decât cel liniar sau pătratic.

Hashing-ul perfect

Există aplicaţii în care mulţimea cheilor nu se schimbă (de exemplu, cuvintele rezervate ale unui limbaj
de programare, numele fişierelor scrise pe un CD, etc). Hashing-ul poate fi utilizat şi pentru probleme în
care mulţimea cheilor este statică (odată ce au fost inserate cheile în tabela hash, aceasta nu se mai
schimbă). În acest caz, timpul de execuţie în cazul cel mai defavorabil este 𝑂(1).

Hashing-ul se numeşte hashing perfect dacă nu există coliziuni.

Hashing-ul perfect se realizează pe două niveluri.


La primul nivel, este similar cu hashing-ul cu înlănţuire: cele 𝑛 chei vor fi distribuite cu ajutorul unei funcţii hash ℎ
în 𝑚 locaţii.

Dar în loc de a crea o listă înlănţuită cu toate cheile distribuite în locaţia 𝑖, la cel de al doilea nivel vom utiliza câte
o tabelă hash secundară 𝑆𝑖 care are asociată o funcţie hash ℎ𝑖 pentru fiecare locaţie în care există coliziuni.

Pentru a garanta faptul că nu vor exista coliziuni în tabela hash secundară, dimensiunea 𝑚𝑖 a tabelei hash
secundare 𝑆𝑖 trebuie să fie cel puţin egală cu 𝑛𝑖2 (unde 𝑛𝑖 este numărul de chei care sunt distribuite în locaţia 𝑖).

Evident, descrierea grupurilor de la primul nivel trebuie să conţină şi funcţia hash aleasă pentru grupul respectiv,
precum şi dimensiunea spaţiului de memorie alocat tabelei hash secundare.

O alegere a funcţiei hash ℎ dintr-o familie universală de funcţii hash asigură că dimensiunea totală a spaţiului de
memorie utilizat este 𝑂(𝑛).

Hash-uri în STL

hash_map

hash_multimap

dupa C++.11 sau numit:

unordered_map

unordered_multimap

(Pentru mai multe detalii consultați documentația STL sau cppreferences)

Aplicații
Algoritmul Rabin - Karp
Fie 𝑇 un şir de 𝑛 caractere şi 𝑃 un pattern de 𝑚 caractere.

Să se verifice dacă 𝑃 apare sau nu ca subsecvenţă în şirul 𝑇.

Soluţie:
Lectia nr. 2. – Utilizarea STL. Hash-uri

Fie 𝑏 numărul de caractere distincte care apar în 𝑇. Un şir de lungime 𝑚 poate fi considerat un număr în
baza 𝑏+1 având 𝑚 cifre (se folosesc cifre de la 1 la 𝑏).

Se asociază pattern-ului 𝑃 o valoare 𝑝 (şirul de caractere este considerat ca un număr întreg în baza
𝑏+1).

Pentru fiecare secvenţă de 𝑚 caractere din şirul 𝑇 se calculează 𝑡ℎ, valoarea în baza 𝑏+1 a subsecvenţei
din 𝑇 care începe la poziţia 𝑖 (𝑇[𝑖…𝑖+𝑚−1]). Evident, 𝑝=𝑡𝑖 dacă şi numai dacă 𝑇[𝑖…𝑖+𝑚−1].

Valorile 𝑡𝑖 pentru i=1,… 𝑖+𝑚−1 se pot calcula astfel:


𝑡𝑖+1= 𝑏+1 ∗ 𝑡𝑖− 𝑏 + 1 𝑚−1∗𝑇 𝑖 +𝑇 𝑖+𝑚 .
Singura dificultate constă în faptul că 𝑝 şi 𝑡𝑖 pot fi numere foarte mari. Prin urmare, operaţiile cu ele nu
se execută în timp constant.

Se va lucra cu 𝑝 şi 𝑡𝑘 mod 𝑄, unde 𝑄 este un număr prim convenabil ales, aşadar se foloseşte o funcţie
de dispersie. Pot apărea probleme deoarece 𝑝 𝑚𝑜𝑑 𝑄= 𝑡𝑖 𝑚𝑜𝑑 𝑄 nu implică faptul că 𝑝=𝑡𝑖 (apar
coliziuni). Dar dacă 𝑝 𝑚𝑜𝑑 𝑄≠𝑡𝑖 𝑚𝑜𝑑 𝑄, atunci sigur 𝑃≠𝑇[𝑖…𝑖+𝑚−1].

Aşadar, algoritmul Rabin - Karp este unul probabilistic, eficienţa în detectarea potrivirii pattern-ului
depinzând de funcţia de dispersie. Pentru a elimina potrivirile “false”, se poate aplica o verificare
normală pentru fiecare potrivire detectată sau se pot folosi mai multe funcţii de dispersie.

Pseudocodul pentru algoritmul Rabin - Karp

potrivireRabinKarp(T, P, d, q)
n <- lungime[T]
m <- lungime[P]
h <- dm-1 mod q
p <- 0
t0 <- 0
pentru i <- 1, m executa
p <- (dp + P[i]) mod q
t0 <- (dt0 + T[i]) mod q
pentru s <- 0, n - m executa
daca p = ts atunci
daca P[1..m] = T[s + 1..s + m] atunci
tipareste "Modelul apare cu deplasamentul" s
daca s < n - m atunci
ts + 1 <- ( d( ts - T[s + 1]h ) + T[s + m + 1] ) mod q
O implementare in limbajul C++
#include <iostream>
#include <cstring>
using namespace std;
unsigned int q = 65521; /* numarul prim < 2^16 */
unsigned int d = 257; /* baza pentru interpretarea numerica a
stringului */
unsigned int dM; /* d^(M-1), unde M este lungimea modelului */
Lectia nr. 2. – Utilizarea STL. Hash-uri

void hash_init(int M) {
dM = 1;
for (int i = 1; i < M; i++) dM = (dM * d) % q ;
}

unsigned int Hash(char* t, int n){


unsigned h = t[0];
for (int i = 1; i < n; i++)
h = (d * h + t[i]) % q;
return h;
}

unsigned int hash_next(char* t, int M, int h){


unsigned int oldest = (dM * t[0]) % q;
unsigned int oldest_removed = ( (h + q) - oldest ) % q;
return (d * oldest_removed + t[M]) % q;
}

int equal_string(char* p, char* q, int n){


/* returneaza true daca primele n caractere ale lui p si q sunt
identice */
return n == 0 || ( *p == *q && equal_string(p + 1, q + 1, n -
1) );
}

int rksearch(char* p, char* a)


/*
returneaza indicele primei aparitii a sirului p in a.
returneaza lungimea sirului a daca sirul p nu apare in sirul a.
*/
{
int M = strlen(p);
int N = strlen(a);

hash_init(M);
unsigned int h1 = Hash(p, M);
cout << h1 << "\n";
unsigned int h2 = Hash(a, M);
cout << h2 << "\n";
for (char* b = a; b < a + N - M; b++) {
cout << h1 << " " << h2 << " " << equal_string(p, b, M) << "
" << b << "\n";
if (h2 == h1 && equal_string(p, b, M)) return b-a;
h2 = hash_next(b, M, h2);
}

return N;
}

int main()
{
int index;
char* p = "hello";
char* a = "All test programs contain the word hello, bye.";
Lectia nr. 2. – Utilizarea STL. Hash-uri

cout << "\nGiven pattern \n\"" << p << "\" and text \n\"" << a <<
"\"\n";
index = rksearch(p,a);
cout << "rksearch returns " << index << "\n";

a = "hello, world";
cout << " Given pattern \n\"" << p << "\" and text \n\"" << a <<
"\"\n";
index = rksearch(p,a);
cout << "rksearch returns " << index << "\n";

a = "Goodbye, cruel world";


cout << " Given pattern \n\"" << p << "\" and text \n\"" << a <<
"\"\n";
index = rksearch(p,a);
cout << "rksearch returns " << index << "\n";

return 0;
}

Alte probleme
Hashuri (Infoarena)

Loto (Infoarena)

Eqs (Infoarena)

Oite (Infoarena)

Strmatch (Infoarena)

Abc2 (Infoarena)

String (Infoarena)

Circular (Campion)

Repeat (Campion)

Registration system (Codeforces)

Ograzi (Infoarena)

Rk (Infoarena)

Patrate3 (Infoarena)

Flori2 (Infoarena)

Banana (Infoarena)

Take5 (Infoarena)

Radio2 (Infoarena)

Petr# (Codeforces)
Lectia nr. 2. – Utilizarea STL. Hash-uri

Legături

MIT - Lecture 7: Hashing, Hash Functions

MIT - Lecture 8: Universal Hashing, Perfect Hashing

Bibliografie

Thomas H. Cormen, Charles E. Leiserson, Ronald R. Rivest, “Introducere în algoritmi”, Editura Computer
Libris Agora, Cluj Napoca, 2000

Emanuela Cerchez, Marinel Șerban, “Programarea în limbajul C/C++ pentru liceu” volumul III, Editura
Polirom, Iași, 2006

Dana Lica, Mircea Pașoi, “Fundamentele programării” volumul III, Editura L&S SOFT, București, 2006.

Anexe
Solutia problema Hashuri infoarena
#include <bits/stdc++.h>
using namespace std;
ifstream fin("hashuri.in");
ofstream fout("hashuri.out");
int x,y,t,h;
const int M=700095;
vector <int>v[M];
void adauga()
{
for(auto it:v[h])
if(it==x)return;
v[h].push_back(x);
}
void sterge()
{
for(vector<int>::iterator it=v[h].begin();it!=v[h].end();it++)
if(*it==x){
v[h].erase(it);
return;
}
}
Lectia nr. 2. – Utilizarea STL. Hash-uri

bool querry()
{
for(auto it:v[h])
if(it==x)return 1;
return 0;
}
int main()
{
ios_base::sync_with_stdio(0);
fin.tie(0);
fin>>t;
while(t--)
{
fin>>y>>x;
h=x%M;
if(y==1)adauga();
else if(y==2)sterge();
else fout<<querry()<<'\n';
}
return 0;
}

Necesitatea tabelelor hash

In multe aplicatii lucram cu structuri mari de date in care avem nevoie sa facem cautari, inserari,
modificari si stergeri. Aceste structuri pot fi vectori, matrice, liste etc. In cazurile mai fericite ale
vectorilor, acestia pot fi sortati, caz in care localizarea unui element se face prin metoda injumatatirii
intervalului, adica in timp logaritmic. Chiar daca nu avem voie sa sortam vectorul, tot se pot face anumite
optimizari care reduc foarte mult timpul de cautare. De exemplu, probabil ca multi dintre cititori au idee
despre ce inseamna indexarea unei baze de date. Daca avem o baza de date cu patru elemente de tip
string, si anume

B = ("bac", "zugrav", "abac", "zarva")

putem construi un vector Ind care sa ne indice ordinea in care s-ar cuveni sa fie asezate cuvintele in
vectorul sortat. Ordinea alfabetica (din cartea de telefon) a cuvintelor este: "abac", "bac", "zarva",
"zugrav", deci vectorul Ind este:

Ind = (3, 1, 4, 2)

semnificand ca primul cuvant din vectorul sortat ar trebui sa fie al treilea din vectorul B, respectiv "abac",
si asa mai departe. In felul acesta am obtinut un vector sortat, care presupune o indirectare a elementelor.
Vectorul sortat este

B' = (B(Ind(1)), B(Ind(2)), B(Ind(3)), B(Ind(4)).


Lectia nr. 2. – Utilizarea STL. Hash-uri

Aceasta operatie se numeste indexare. Ce-i drept, constructia vectorului Ind nu se poate face intr-un timp
mai bun decat O(N log N), dar dupa ce acest lucru se face (o singura data, la inceputul programului),
cautarile se pot face foarte repede. Daca pe parcurs se fac adaugari sau stergeri de elemente in/din baza de
date, se va pierde catva timp pentru mentinerea indexului, dar in practica timpul acesta este mult mai mic
decat timpul care s-ar pierde cu cautarea unor elemente in cazul in care vectorul ar fi neindexat. Nu vom
intra in detalii despre indexare, deoarece nu acesta este obiectul articolului de fata.

In unele situatii nu se poate face nici indexarea structurii de date. Sa consideram cazul unui program care
joaca sah. Numarul de pozitii posibile ale pieselor pe tabla de sah este mult prea mare. Nu poate fi vorba
nici macar de retinerea tuturor, cu atat mai putin de indexarea lor. In esenta, modul de functionare al
acestui program se reduce la o rutina care primeste o pozitie pe tabla si o variabila care indica daca la
mutare este albul sau negrul, rutina intorcand cea mai buna mutare care se poate efectua din acea pozitie.
Majoritatea programelor de sah incep sa expandeze respectiva pozitie, examinand tot felul de variante ce
pot decurge din ea si alegand-o pe cea mai promitatoare, asa cum fac si jucatorii umani. Pozitiile
analizate, fiind mult mai putine decat cele posibile, sunt stocate in memorie sub forma unei liste.

Sa ne inchipuim acum urmatoarea situatie. Este posibil ca, prin expandarea unei configuratii initiale a
tablei sa se ajunga la aceeasi configuratie finala pe doua cai diferite. Spre exemplu, daca albul muta intai
calul la f3, apoi nebunul la c4, pozitia rezultata va fi aceeasi ca si cand s-ar fi mutat intai nebunul si apoi
calul (considerand bineinteles ca negrul da in ambele situatii aceeasi replica). Daca configuratia finala a
fost deja analizata pentru prima varianta, este inutil sa o mai analizam si pentru cea de-a doua, pentru ca
rezultatul (concluzia la care se va ajunge) va fi exact acelasi. Dar cum isi poate da programul seama daca
pozitia pe care are de gand s-o analizeze a fost analizata deja sau nu?

Cea mai simpla metoda este o scanare a listei de configuratii examinate din memorie. Daca in aceasta
lista se afla pozitia curenta de analizat, inseamna ca ea a fost deja analizata si vom renunta la ea. Daca nu,
o vom analiza acum. Ideea in mare a algoritmului este:

functia Analizeaza(Pozitie P)
cauta P in lista de pozitii deja analizate
daca P nu exista in lista
expandeaza P si afla cea mai buna mutare M
adauga P la lista de pozitii analizate
intoarce M
altfel
intoarce valoarea M atasata pozitiei P gasite in lista
sfarsit

Nu vom insista asupra a cum se expandeaza o pozitie si cum se calculeaza efectiv cea mai buna mutare.
Noi ne vom interesa de un singur aspect, si anume cautarea unei pozitii in lista. Tehnica cea mai
"naturala" este o parcurgere a listei de la cap la coada, comparand pe rand pozitia cautata cu fiecare
pozitie din lista. Daca lista are memorate N pozitii, atunci in cazul unei cautari cu succes (pozitia este
gasita), numarul mediu de comparatii facute este N/2, iar numarul cel mai defavorabil ajunge pana la N.
In cazul unei cautari fara succes (pozitia nu exista in lista), numarul de comparatii este intotdeauna N. De
altfel, cazul cautarii fara succes este mult mai frecvent pentru problema jocului de sah, unde numarul de
pozitii posibile creste exponential cu numarul de mutari. Acelasi numar de comparatii il presupune si
stergerea unei pozitii din lista (care presupune intai gasirea ei) si adaugarea (care presupune ca pozitia de
adaugat sa nu existe deja in lista).
Lectia nr. 2. – Utilizarea STL. Hash-uri

Pentru imbunatatirea practica a acestui timp sunt folosite tabelele de dispersie sau tabelele hash (engl.
hash = a toca, tocatura). Mentionam de la bun inceput ca tabelele hash nu au nicio utilitate din punct de
vedere teoretic. Daca suntem rau intentionati, este posibil sa gasim exemple pentru care cautarea intr-o
tabela hash sa dureze la fel de mult ca intr-o lista simplu inlantuita, respectiv O(N). Dar in practica timpul
cautarii si al adaugarii de elemente intr-o tabela hash coboara uneori pana la O(1), iar in medie scade
foarte mult (de mii de ori).

Descrierea structurii de date

Iata despre ce este vorba. Sa presupunem pentru inceput ca in loc de pozitii pe tabla de sah, lista noastra
memoreaza numere intre 0 si 999. In acest caz, tabela hash ar fi un simplu vector H cu 1000 de elemente
booleene. Initial, toate elementele lui H au valoarea False (sau 0). Daca numarul 473 a fost gasit in lista,
nu avem decat sa setam valoarea lui H(473) la True (sau 1). La o noua aparitie a lui 473 in lista, vom
examina elementul H(473) si, deoarece el este True, inseamna ca acest numar a mai fost gasit. Daca
dorim stergerea unui element din hash, vom reseta pozitia corespunzatoare din H. Practic, avem de-a face
cu un exemplu rudimentar de ceea ce se cheama functie de dispersie, adica h(x) = x. O proprietate foarte
importanta a acestei functii este injectivitatea; este imposibil ca la doua numere distincte sa corespunda
aceeasi intrare in tabela. Sa incercam o reprezentare grafica a metodei:

Iata primul set de functii de gestionare a unui hash.

const int M = 1000;


typedef int DataType;
typedef DataType Hash[M];
Hash H;

void InitHash1(Hash H) {
for (int i = 0; i < M; H[i++] = 0);
}
Lectia nr. 2. – Utilizarea STL. Hash-uri

inline int h(DataType K) {


return K;
}

int Search1(Hash H, DataType K) {


// Intoarce -1 daca elementul nu exista in hash
// sau indicele in hash daca el exista
return H[h(K)] ? h(K) : -1;
}
void Add1(Hash H, DataType K) {
H[h(K)] = 1;
}
void Delete1(Hash H, DataType K) {
H[h(K)] = 0;
}

Prin "numar de intrari" in tabela se intelege numarul de elemente ale vectorului H; in general, orice tabela
hash este un vector. Pentru ca functiile sa fie cat mai generale, am dat tipului de date int un nou nume
DataType. In principiu, tabelele hash se aplica oricarui tip de date. In realitate, fenomenul este tocmai cel
invers: orice tip de date trebuie "convertit" printr-o metoda sau alta la tipul de date int, iar functia de
dispersie primeste ca parametru un intreg. Functiile hash prezentate in viitor nu vor mai lucra decat cu
variabile de tip intreg. Vom vorbi mai tarziu despre cum se poate face conversia. Acum sa generalizam
exemplul de mai sus.

Intr-adevar, cazul anterior este mult prea simplu. Sa ne inchipuim de pilda ca in loc de numere mai mici
ca 1.000, avem numere de pana la 2.000.000.000. In aceasta situatie posibilitatea de a reprezenta tabela
ca un vector caracteristic iese din discutie. Numarul de intrari in tabela este de ordinul miilor, cel mult al
zecilor de mii, deci cu mult mai mic decat numarul total de chei (numere) posibile. Ce avem de facut?
Am putea incerca sa adaugam un numar K intr-o tabela cu M intrari (numerotate de la 0 la M-1) pe
pozitia K mod M, adica h(K) = K mod M. Care va fi insa rezultatul? Functia h isi va pierde proprietatea de
injectivitate, deoarece mai multor chei le poate corespunde aceeasi intrare in tabela, cum ar fi cazul
numerelor 1.234 si 2.001.234, ambele dand acelasi rest la impartirea prin M = 1.000. Nu putem avea insa
speranta de a gasi o functie injectiva, pentru ca atunci numarul de intrari in tabela ar trebui sa fie cel putin
egal cu numarul de chei distincte. Vrand-nevrand, trebuie sa rezolvam coliziunile (sau conflictele) care
apar, adica situatiile cand mai multe chei distincte sunt repartizate la aceeasi intrare.

Vom reveni ulterior la oportunitatea alegerii functiei modul si a numarului de 1.000 de intrari in tabela.
Deocamdata vom folosi aceste date pentru a explica modul de functionare a tabelei hash pentru functii
neinjective. Sa presupunem ca avem doua chei K1 si K2 care sunt repartizate de functia h la aceeasi
intrare X, adica h(K1) = h(K2) = X. Solutia cea mai comoda este ca H(X) sa nu mai fie un numar, ci o
lista liniara simplu inlantuita care sa contina toate cheile gasite pana acum si repartizate la aceeasi intrare
X. Prin urmare vectorul H va fi un vector de liste:
Lectia nr. 2. – Utilizarea STL. Hash-uri

Sa analizam acum complexitatea noilor functii de cautare, adaugare si stergere. Cautarea nu se va mai
face in toata lista, ci numai in lista corespunzatoare din H. Altfel spus, o cheie K se va cauta numai in
lista H(h(K)), deoarece daca cheia K a mai aparut, ea a fost in mod sigur repartizata la intrarea H(h(K)).
De aceea, cautarea poate ajunge, in cazul cel mai defavorabil cand toate cheile din lista se repartizeaza la
aceeasi intrare in hash, la o complexitate O(N). Daca reusim insa sa gasim o functie care sa distribuie
cheile cat mai aleator, timpul de intrare se va reduce de M ori. Avantajele sunt indiscutabile pentru M =
10.000 de exemplu.

Intrucat operatiile cu liste liniare sunt in general cunoscute, nu vom insista asupra lor.

Prezentam aici numai adaugarea si cautarea, lasandu-va ca tema scrierea functiei de stergere din tabela.

const int M = 1000;


typedef struct List_ {
long P;
struct List_ *Next;
} List;
typedef List* Hash[M];
Hash H;

void InitHash2(Hash H){


for (int i = 0; i < M; H[i++] = NULL);
}
Lectia nr. 2. – Utilizarea STL. Hash-uri

int h2(int K) {
return K % M;
}

int Search2(Hash H, int K) {


// Intoarce 0 daca elementul nu exista in hash
// sau 1 daca el exista
List *L;
for (L = H[h2(K)]; L && (L->P != K); L = L->Next);
return L != NULL;
}

void Add2(Hash H, int K) {


List *L = (List *) malloc(sizeof(List));
L->P = K;
L->Next = H[h2(K)];
H[h2(K)] = L;
}

Am spus ca functiile de dispersie sunt concepute sa lucreze numai pe date de tip intreg; celelalte tipuri de
date trebuie convertite in prealabil la tipuri de date intregi. Iata cateva exemple:

 Variabilele de tip string pot fi transformate in numere in baza 256 prin inlocuirea fiecarui caracter cu
codul sau ASCII. De exemplu, sirul "abac" poate fi privit ca un numar de 4 cifre in baza 256, si anume
numarul (97 98 97 99). Conversia lui in baza 10 se face astfel:

X = ((97 * 256 + 98) * 256 + 97) * 256 + 99 = 1.633.837.411

Pentru stringuri mai lungi, rezulta numere mai mari. Uneori, ele nici nu mai pot fi reprezentate cu tipurile
de date ordinare. Totusi, acest dezavantaj nu este suparator, deoarece majoritatea functiilor de dispersie
presupun o impartire cu rest, care, indiferent de marimea numarului de la intrare, produce un numar
controlabil.

 Variabilele de tip data se pot converti la intreg prin formula:

X = A * 366 + L * 31 + Z

unde A, L si Z sunt respectiv anul, luna si ziua datei considerate. De fapt, aceasta functie aproximeaza
numarul de zile scurse de la inceputul secolului I. Ea nu are pretentii de exactitate (ca dovada, toti anii
sunt considerati a fi bisecti si toate lunile a avea 31 de zile), deoarece s-ar consuma timp inutil cu calcule
mai sofisticate, fara ca dispersia insasi sa fie imbunatatita cu ceva. Conditia care trebuie neaparat
respectata este ca functia de conversie data intreg sa fie injectiva, adica sa nu se intample ca la doua
date D1 si D2 sa li se ataseze acelasi intreg X; daca acest lucru se intampla, pot aparea erori la cautarea in
tabela (de exemplu, se poate raporta gasirea datei D1 cand de fapt a fost gasita data D2). Pentru a respecta
injectivitatea, s-au considerat coeficientii 366 si 31 in loc de 365 si 30. Daca numarul de zile scurse de la
1 ianuarie anul 1 d.H. ar fi fost calculat cu exactitate, functia de conversie ar fi fost si surjectiva, dar, dupa
cum am mai spus, acest fapt nu prezinta interes.
Lectia nr. 2. – Utilizarea STL. Hash-uri

 Analog, variabilele de tip ora se pot converti la intreg cu formula:

X = (H * 60 + M) * 60 + S

unde H, M si S sunt respectiv ora, minutul si secunda considerate, sau cu formula

X = ((H * 60 + M) * 60 + S) * 100

daca se tine cont si de sutimile de secunda. De data aceasta, functia este surjectiva (oricarui numar intreg
din intervalul 0 - 8.639.999 ii corespunde in mod unic o ora).

 In majoritatea cazurilor, datele sunt structuri care contin numere si stringuri. O buna metoda de conversie
consta in alipirea tuturor acestor date si in convertirea la baza 256. Caracterele se convertesc prin simpla
inlocuire cu codul ASCII corespunzator, iar numerele prin convertirea in baza 2 si taierea in "bucati" de
cate opt biti. Rezulta numere cu multe cifre (prea multe chiar si pentru tipul long long), care sunt supuse
unei operatii de impartire cu rest. Functia de conversie trebuie sa fie injectiva. De exemplu, in cazul tablei
de sah despre care am amintit mai inainte, ea poate fi transformata intr-un vector cu 64 de cifre in baza
16, cifra 0 semnificand un patrat gol, cifrele 1-6 semnificand piesele albe (pion, cal, nebun, turn, regina,
rege), iar cifrele 7-12 semnificand piesele negre. Prin trecerea acestui vector in baza 256, rezulta un
numar cu 32 de cifre. La acesta se mai pot adauga alte cifre, respectiv partea la mutare ($0$ pentru alb, 1
pentru negru), posibilitatea de a efectua rocada mica/mare de catre alb/negru, numarul de mutari scurse
de la inceputul partidei si asa mai departe.

Vom termina prin a prezenta doua functii de dispersie foarte des folosite.

Metoda impartirii cu rest

Despre aceasta metoda am mai vorbit. Functia hash este: h(x) = x mod M unde M este numarul de intrari
in tabela. Problema care se pune este sa-l alegem pe M cat mai bine, astfel incat numarul de coliziuni
pentru oricare din intrari sa fie cat mai mic. De asemenea, trebuie ca M sa fie cat mai mare, pentru ca
media numarului de chei repartizate la aceeasi intrare sa fie cat mai mica. Totusi, experienta arata ca nu
orice valoare a lui M este buna.

De exemplu, la prima vedere s-ar putea spune ca o buna valoare pentru M este o putere a lui 2, cum ar fi
1024, pentru ca operatia de impartire cu rest se poate face foarte usor in aceasta situatie. Totusi, functia
h(x) = x mod 1024 are un mare defect: ea nu tine cont decat de ultimii 10 biti ai numarului x. Daca datele
de intrare sunt numere in mare majoritate pare, ele vor fi repartizate in aceeasi proportie la intrarile cu
numar de ordine par, pentru ca functia h pastreaza paritatea. Din aceleasi motive, alegerea unei valori ca
1000 sau 2000 nu este prea inspirata, deoarece tine cont numai de ultimele 3-4 cifre ale reprezentarii
zecimale. Multe valori pot da acelasi rest la impartirea prin 1000. De exemplu, daca datele de intrare sunt
anii de nastere ai unor persoane dintr-o agenda telefonica, iar functia h(x) = x mod 1000, atunci
majoritatea cheilor se vor ingramadi (termenul este sugestiv) intre intrarile 920 si 990, restul ramanand
nefolosite.

Practic, trebuie ca M sa nu fie un numar rotund in nicio baza aritmetica, sau cel putin nu in bazele 2 si 10.
O buna alegere pentru M sunt numerele prime care sa nu fie apropiate de nicio putere a lui 2. De
exemplu, in locul unei tabele cu M = 10.000 de intrari, care s-ar comporta dezastruos, putem folosi una
cu 9973 de intrari. Chiar si aceasta alegere poate fi imbunatatita; intre puterile lui 2 vecine, respectiv
8192 si 16384, se poate alege un numar prim din zona 11.000 - 12.000. Risipa de memorie de circa 1.000
- 2.000 de intrari in tabela va fi pe deplin compensata de imbunatatirea cautarii.
Lectia nr. 2. – Utilizarea STL. Hash-uri

Metoda inmultirii

Pentru aceasta metoda functia hash este h(x) = [M * {x*A}]. Aici A este un numar pozitiv subunitar, 0 <
A < 1, iar prin {x*A} se intelege partea fractionara a lui x*A, adica x*A - [x*A]. De exemplu, daca alegem
M = 1234 si A = 0.3, iar x = 1997, atunci avem h(x) = [1234 * {599.1}] = [1234 * 0.1] = 123. Se
observa ca functia h produce numere intre 0 si M-1. Intr-adevar 0 ≤ {x*A} < 1 0 ≤ M * {x*A} < M.

In acest caz, valoarea lui M nu mai are o mare importanta. O putem deci alege cat de mare ne convine,
eventual o putere a lui 2. In practica, s-a observat ca dispersia este mai buna pentru unele valori ale lui A

si mai proasta pentru altele. Donald Knuth propune valoarea .

Ca o ultima precizare necesara la acest articol, mentionam ca functia de cautare e bine sa nu intoarca pur
si simplu 0 sau 1, dupa cum cheia cautata a mai aparut sau nu inainte intre datele de intrare. E
recomandabil ca functia sa intoarca un pointer la zona de memorie in care se afla prima aparitie a cheii
cautate. Vom da acum un exemplu in care aceasta valoare returnata este utila. Daca, in cazul prezentat
mai sus al unui program de sah, se ajunge la o anumita pozitie P dupa ce albul a pierdut dreptul de a face
rocada, aceasta pozitie va fi retinuta in hash. Retinerea nu se va face nicidecum efectiv (toata tabla),
pentru ca s-ar ocupa foarte multa memorie. Se va memora in loc numai un pointer la pozitia respectiva
din lista de pozitii analizate. Pe langa economia de memorie in cazul cheilor de mari dimensiuni, mai
exista si alt avantaj. Sa ne inchipuim ca, analizand in continuare tabla, programul va ajunge la aceeasi
pozitie P, dar in care albul are inca dreptul de a face rocada. E limpede ca aceasta varianta este mai
promitatoare decat precedenta, deoarece albul are o libertate mai mare de miscare. Se impune deci fie
stergerea vechii pozitii P din lista si adaugarea noii pozitii, fie modificarea celei vechi prin setarea unei
variabile suplimentare care indica dreptul albului de a face rocada. Aceasta modificare este usor de facut,
intrucat cautarea in hash va returna chiar un pointer la pozitia care trebuie modificata. Bineinteles, in
cazul in care pozitia cautata nu se afla in hash, functia de cautare trebuie sa intoarca NULL.

In incheiere, prezentam un exemplu de functie de dispersie pentru cazul tablei de sah.

const int M = 9973; // Numarul de "intrari".


typedef struct {
// Tabla de joc, cu 0 <= T[i][j] <= 12.
char b_T[8][8];

// Ultimii doi biti ai lui b_CastleW indica daca albul are dreptul de a
// efectua rocada mare, respectiv pe cea mica. Analog pentru b_CastleB.
char b_CastleW, b_CastleB;

// 0 sau 1, dupa cum la mutare este albul sau negrul.


char b_Side;

// 0..8, indicand coloana (0..7) pe care partea la mutare poate efectua o


// captura "en passant". 8 indica ca nu exista aceasta posibilitate.
char b_EP;

// Numarul de mutari efectuate.


int b_NMoves;
Lectia nr. 2. – Utilizarea STL. Hash-uri

} Board;
Board B;

int h3(Board *B) {


int i, j;

// Valoarea initiala a lui S este un numar pe 17 biti care


// inglobeaza toate variabilele suplimentare pe langa T.
// S se va lua apoi modulo M.
long S = (B->b_NMoves // 8 biti
+(B->b_CastleW << 8) // 2 biti
+(B->b_CastleB << 10) // 2 biti
+(B->b_Side << 12) // 1 bit
+(B->b_EP << 13)) % M; // 4 biti

for (i = 0; i <= 7; i++)


for (j = 0; j <= 7; j++) {
S = (16 * S + B->b_T[i][j]) % M;
}
return S;
}

Probleme propuse

Iata si o scurta lista de probleme ce se rezolva eficient folosind tabelele hash:

 SGU 174 - Walls


 UVA 10748 - Knights Roaming
 UVA 10815 - Andy's First Dictionary
 UVA 10887 - Concatenation of Languages
 UVA 704 - Colour Hash

S-ar putea să vă placă și