per archivi statici: possibili miglioramenti
I. Introduzione al problema
II. Idee generali
III. Generatori di numeri casuali
IV. Dizionario
V. Funzione Hash
VI. Allegati
VII. Indici
Si può dimostrare che dato un qualche insieme di chiavi, tutte distinte e conosciute, si può costruire una funzione hash minima e perfetta. Tuttavia lalgoritmo che si ottiene dalla dimostrazione ha una complessità in tempo esponenziale e quindi non può essere usato in modo conveniente per un numero di chiavi abbastanza elevato. Si è quindi cercato di ottenere algoritmi con complessità minore, in particolare con complessità polinomiale. Lalgoritmo illustrato in seguito ha una complessità lineare in tempo e spazio, se il numero delle chiavi è infinito: ovvero la probabilità di ottenere una funzione hash perfetta e minima tende ad 1 al crescere del numero delle chiavi e analogamente la complessità tende ad allinearsi con quella lineare. Quindi non ci dovrà preoccupare una complessità non esattamente lineare per un numero basso di chiavi.
Lalgoritmo proposto cerca a caso una funzione hash opportuna dopo aver diviso le chiavi in gruppi più piccoli: anche questa divisione avviene in modo casuale. È importante per il corretto funzionamento dellalgoritmo che i numeri casuali siano davvero tali, o almeno unottima approssimazione, e per questo mi sono preoccupato di costruire un generatore di numeri casuale e di testarlo per verificarne lattendibilità. Inoltre sono necessarie al funzionamento dellalgoritmo un numero di chiavi, nel nostro caso, parole, sufficientemente alto, ma tutte distinte. Perciò ho approntato un programma per generare un certo numero di parole, di dimensioni variabili, tutte differenti, in modo da simulare un vero dizionario.
Infatti un interessante uso per questo algoritmo è quello di creare un efficiente sistema di ricerca in database molto grandi e statici. Infatti una volta trovata la funzione hash si può ricercare una chiave in questo database in tempo nullo. Il tempo per calcolare la funzione hash sappiamo essere un tempo umanamente accettabile per la complessità lineare tendenziale dellalgoritmo. È inoltre opportuno ricordare che il calcolo della funzione hash va fatto una sola volta e quindi non è negativo che il tempo di creazione di questa sia elevato, anche perché la complessità lineare ci garantisce la non esplosione di questi tempi.
II. Idee generali
II.1 Principio di funzionamento dellalgoritmo
Lidea generale su cui si basa lalgoritmo è quella di dividere le chiavi in tanti piccoli gruppi con le seguenti caratteristiche:
Le chiavi devono essere distribuite in modo uniforme in questi gruppi, e quindi ognuno deve contenerne il minor numero possibile
Non deve esserci alcuna relazione tra le chiavi contenute in ciascun gruppo: in altre parole la suddivisione deve essere quanto più casuale possibile.
È ovviamente impossibile ottenere le due caratteristiche al meglio e una parte del programma cerca appunto di risolvere questo problema: in particolare si ottimizza il primo punto rispetto al secondo, cioè si cerca di ottenere gruppi il più possibile uniformi di chiavi scelte casualmente. Una volta che abbiamo ottenuto questi gruppi applichiamo la seguente funzione hash:
Espressione -1- Funzione Hash 1
Ove "key" è la chiave, h0 e h2 sono due funzioni opportune, mentre "i" indica il gruppo di cui "key" fa parte. Lultimo passo dellalgoritmo è appunto dedicato alla ricerca di tale funzione g(.) in modo che tutte le h(key) risultino distinte. Si può dimostrare che con delle opportune h0 e h2, la ricerca di tale funzione g ha sempre successo in un tempo che cresce linearmente con il numero delle chiavi se questo numero è molto grande. Grande importanza per lalgoritmo riveste come si può capire fin dora il poter disporre di numeri realmente casuali. Questo non è possibile e poiché i normali generatori di numeri casuali hanno un range molto limitato ho preferito utilizzare un diverso generatore di numeri casuali, secondo il metodo della congruenza lineare che poi ho testato.
Tutti i programmi sono stati scritti in C++ ad oggetti. Questa scelta è stata fatta perché nel procedere con il programma si devono creare delle strutture che poi possono essere facilmente riutilizzate in altri programmi. Per cercare di superare il limite dei 640 KB imposti dal sistema operativo DOS ho compilato i programmi per Windows a 16 bit, per poter sfruttare la compilazione EasyWindows che permette di aggirare la gestione dellinterfaccia e si vede come un programma DOS.
III. Generatori di numeri casuali
III.1 Struttura
Il metodo seguito è quello di D. Lehmer, detto della congruenza lineare. La sua implementazione non richiede particolari difficoltà tranne alcuni semplici accorgimenti che illustro.
class TRandom
{
private:
ulong a;
ulong m, m1, b;
ulong mult(ulong, ulong); // calcola p*q % m senza overflow
public:
TRandom(ulong seed)
{ a = seed; m = 100140049L; m1 = 10007L; b = 31415821L;}
ulong succ(ulong);
};
Questo è loggetto che genera numeri casuali. Nella parte riservata ci sono le variabili "a", "m" e "b" necessarie per lalgoritmo. È inoltre presente una variabile m1 di appoggio perché, essendo necessario eseguire moltiplicazioni tra numeri molto grandi di cui si deve poi fare il modulo, è probabile che durante la moltiplicazione si verifichi un overflow. A ciò si rimedia con unapposita funzione mult() che accetta in ingresso due interi p e q, per restituire (p*q)%m. Per questo deve essere m1^2 = m.
Il costruttore Trandom provvede ad inizializzare tutte le variabile ed a porre "a" pari al valore passato come argomento, il seme della sequenza casuale. In seguito si è posto anche il problema di come trovare un seme sempre diverso, in modo da ottenere numeri casuali differenti.
La funzione succ richiede come argomento un range e restituisce un numero casuale opportuno, calcolato secondo il noto algoritmo:
Algoritmo -2 - Calcolo del numero pseud. casuale successivo 1
ulong TRandom::succ(ulong r)
{
a = (mult(a, b) + 1) % m;
return (((a/m1)*r)/m1); // migliore di a%r
}
Con questoggetto poss ottenere numeri casuali tra 0 e 2^32-1.
III.2 Test
Ho eseguito quattro gruppi di prove, generando rispettivamente numeri fino a 100, 1000, 10000, 20000 per verificare lefficienza del sistema proposto in confronto con la funzione random del C, per la quale sono state svolte le medesime prove.
Ci si è avvalsi della formula
Espressione -1 - Test 1 di casualità
![]()
Dove Nprove è il numero di numeri casuali generati; Range è il range e Freq(i) rappresenta la frequenza con cui ogni numero da 0 a Range è stato generato durante le Nprove. Ovviamente Nprove >> range (per la precisione si è fissato il rapporto Nprove/range pari a 100).
Notiamo che se le frequenze sono tutte uguali, pari a Nprove/range, test è uguale a 0. Quindi un numero pari a 0 indica numeri molto casuali, mentre un numero molto lontano indica che ci sono delle preferenze. Eseguendo i test si ottengono i seguenti risultati
![]()
A questo grafico è associata la Tabella 1 (*) per vedere i numeri esatti. Come si vede bene dal grafico il generatore standard del C (tratteggiato) tende a non funzionare bene per Range alti, mentre quello implementato, secondo il modello di Lehmer funziona sempre con la stessa regolarità. Questo pregio lo paghiamo in velocità: non utilizzando routine assembler questo sistema è circa 10 volte più lento.
Ho avuto modo di notare che anche il generatore nuovo tende ad esplodere per range elevati, ma questo è probabilmente dovuto ad un problema di overflow nel calcolo del numero casuale nella funzione succ. Per evitare questo si può sostituire Algoritmo III-2 sopra definito con la seguente:
Algoritmo -3 - Calcolo del numero pseud. casuale successivo 2
long double TRandom::succ(void)
{
a = (mult(a, b) + 1) % m;
return (((long double)a)/m);
}
che genera un numero tra 0 e 1 estremamente preciso. Sarà poi sufficiente moltiplicarlo per il Range per ottenere il risultato voluto. I dati del grafico di sopra sono stati ottenuti con questa nuova funzione.
IV. Dizionario
IV.1 Idea e problema
Abbiamo detto che per costruire una hash perfetta e minima dobbiamo conoscere in anticipo tutte le chiavi e queste devono essere, ovviamente, tutte distinte. Per testare lalgoritmo servono molte parole differenti e non disponendo di un vero dizionario ho ideato un programma per generare un dizionario casuale con queste caratteristiche:
possa generare almeno un centomila parole;
generi parole tutte differenti tra di loro;
queste parole siano di dimensione variabile.
Quindi ho pensato di utilizzare il seguente schema:
<I1I2I3>[(I3)(I3+1)...(I3+18)]
Cioè ogni parola è composta da un gruppo di tre lettere a cui si aggiungono altre lettere (da 0 a 18) che sono la lettera dopo i3, la lettera dopo ancora, e così via. Per le prime tre lettere si effettuano delle permutazioni. Così si ottiene, per esempio:
aaa aaaa aaaab aaaabc ...
aab aabb aabbc aabbcd...
aaz aazz aazza aazzab..
...
Si ottengono così 26^3*19 > 300000 parole. Queste poi sono tutte differenti perché, a parità delle prime tre lettere che sono tutte differenti, cambia la lunghezza. Inoltre vengono coinvolte velocemente tutte le lettere dellalfabeto. Come alfabeto semplicemente quello inglese, ma non cè nessuna difficoltà a considerare anche numeri o altri simboli.
IV.2 Il programma.
Per ottenere i risultati di cui sopra è stato creato un apposito oggetto chiamato TParolaCasuale.
Algoritmo -1 - Definizione delloggetto per creare un dizionario casuale
class TParolaCasuale
{
private:
char id[3];
char par[19];
long max;
long att;
void Forma(long);
void Calcola(char);
public:
TParolaCasuale() {...}
char* Leggi();
int operator ++(int) {...}
int operator --(int) {...}
};
Le variabili id e par contengono le due parti della parola; max contiene il numero massimo delle parole, att la parola che viene generata in quel momento. Le due funzioni riservate Forma e Calcola rispettivamente formano e calcolano la seconda e la prima parte della parola, basandosi sulla precedente. Vengono chiamate dal costruttore e dai due operatori.
Il costruttore viene chiamato senza argomenti e inizializza le variabili e forma la prima parola. Leggi() restituisce la parola attuale, mentre per spostarsi alla parola successiva o tornare alla precedente è sufficiente usare gli operatori ++ e -- in forma postfissa che sono stati ridefiniti. Per esempio
Algoritmo -2 - Esempio di uso di TParolaCasuale
TParolaCasuale miapar();
char buf[30];
strcpy(buf, miapar.Leggi()); miapar++;
Ovviamente non è stato implementato un metodo per accedere ad una parola qualunque perché oltre ad essere inutile è dispendioso in tempo in quanto le permutazioni sono un tipico problema esponenziale non riducibile. Per scrivere su disco le varie parole man mano create si è fatto uso degli stream predisposti nelle librerie del C++. Le possibilità che si superi il numero massimo di parole consentito o si scenda sotto il valore zero sono gestite allinterno degli operatori ++ e -- che in tali casi restituiscono 0 e gestiscono lerrore, altrimenti restituiscono 1.
Non ci sono problemi di complessità che per generare una nuova parola è dellordine di O(1) e anche lo spazio occupato è insignificante.
V.1 Dettagli sul funzionamento dellalgoritmo.
Abbiamo detto che la gran parte dellalgoritmo serve per definire la funzione g(i) presente allinterno di h(key). Tuttavia sono lì presenti altre due funzioni h0 e h2 e indirettamente anche una terza funzione h1. Lalgoritmo si suddivide in tre parti delle quali la prima serve per definire queste funzioni.
Mapping. Questo passo crea anzitutto tre tabelle di dimensione MAXCAR x MAXLUNG di numeri casuali. MAXCAR rappresenta la cardinalità dellalfabeto considerato, nel nostro caso 26. MAXLUNG rappresenta la massima lunghezza delle parole, nel nostro caso 22. È necessario conoscere questi valori in anticipo per poterli comunicare al programma. A questo punto definiamo
Espressione -1 - Funzioni h0, h1, h2
dove % indica loperazione modulo e la k vale il numero delle parole se i = 0 e 2, mentre vale r nel caso i = 1. "r" è un valore definito in modo opportuno allaumentare del quale diminuisce la memoria occupata ed aumenta il tempo di calcolo. Il valore minimo per r è dato da:
dove c in genere vale 4. Qualora n sia troppo piccolo questa espressione assume valori troppo grandi e se supera la metà del numero delle parole si prende r uguale a questultimo valore.
Ordering. Questo passo si occupa formare i citati gruppetti, uniformi e casuali: questo è molto semplice in quanto questi gruppetti corrispondo a quelli che hanno la medesima h1. Si verranno perciò a creare r insiemi di cardinalità varia o anche vuoti, detti in seguito K-Liste. Si stabilisce inoltre lordine con cui tali gruppetti dovranno essere trattati nel passo successivo nel seguente modo: prima quelli di cardinalità maggiore, a parità di questa quelli con h1 più piccola.
Searching. Come dice il nome questo passo cerca il valore per la funzione g. Procede nel seguente modo. Considera il primo gruppetto del passo 2 e assegna un valore casuale di g per quel gruppetto e calcola la funzione h(key) per tutte le chiavi del gruppetto. Se vengono assegnate a posizioni vuote le alloca, altrimenti cerca un nuovo valore di g. Poiché il valore della funzione g deve essere compreso tra 0 e numero delle parole anziché un generatore di numeri casuali si considera il resto nelle divisioni con un qualche numero primo e con i suoi multipli. Quindi si prova con il secondo gruppo, dove lordine è stato stabilito nel passo 2, e così via. Se lallocazione di un gruppetto non riesce alla prima (cosa probabile) si effettua un backtracking o allinterno dello stesso passo o se è necessario dal passo 1, ricalcolando le tabelle e la suddivisione in gruppetti.
V.2 Strutture e oggetti preliminari
Per costruire la mia tabella Hash ho creato una libreria di oggetti standard. Questa contiene oltre al già citato generato di numeri casuali una struttura per gestire vettori dinamici di interi positivi.
Algoritmo -1 - Oggetto Vettore dinamico di interi positivi
class TVettoreDin
{
private:
unsigned dim; ulong* p;
public:
TVettoreDin(unsigned ddim) { ... }
~TVettoreDin(void) {...}
void Init(ulong a = 0);
void scrivi(unsigned x, ulong cosa) { ...}
ulong operator[](unsigned x) { ...}
friend ostream& operator << (ostream& s, TVettoreDin* q);
};
Il costruttore richiede la dimensione. Per leggerlo è sufficiente utilizzare il solito operatore [] opportunamente sovrascritto, analogamente per loutput si può usare loperatore <<. Per scrivere si deve chiamare la funzioni scrivi con argomenti la posizione ed il valore da scrivere. Init() azzera il vettore e viene sempre chiamata dal costruttore.
Algoritmo -2 - Oggetto Tabella dinamica di interi positivi
class TTab
{
protected:
unsigned x, y;
ulong** p;
public:
TTab(unsigned dx, unsigned dy);
TTab(char*);
TTab(istream *f) {... }
~TTab (void);
virtual void Init(void);
void scrivi(unsigned x, unsigned y, ulong cosa) { ... }
ulong* operator[] (unsigned dx) {...}
friend ostream& operator<< (ostream& s, TTab* q);
friend istream& operator>> (istream& s, TTab* q);
virtual void Salva(char*);
virtual void Salva(ofstream *f) { ... }
};
I costruttori sono di tre tipi: il primo richiede le due dimensioni e prepara una tabella vuota; il secondo chiede il nome di un file da cui caricarla; il terzo chiede uno stream da cui caricarla. Questo stream può derivare dal nome di un file. Per la scrittura di un valore viene definita la funzioni scrivi, analogamente per la lettura viene ridefinito loperatore []. Vengono ridefiniti gli operatori << e >> per lingresso e luscita dellintera tabella; la funzione Salva trasmette la tabella ad un file o ad uno stream a seconda di come viene chiamata.
Algoritmo -3 - Oggetto Tabella dinamica di interi positivi random
class TTabRand : public TTab
{
private:
ulong max;
PTRandom rand;
unsigned userandom;
public:
TTabRand(unsigned dx, unsigned dy, unsigned m) : TTab(dx, dy)
{ ... }
TTabRand(unsigned dx, unsigned dy, ulong m, PTRandom r) : TTab(dx,dy) {... }
TTabRand(ifstream *f) : TTab(f) {};
TTabRand(char* nome) : TTab(nome) {};
void Init(void);
};
Derivata da TTab. I costruttori qui sono quattro: due caricano la tabella da stream o da file e si limitano a chiamare il costruttore in TTab; il primo chiede la dimensione x, y ed un valore massimo che non può superare 65535: la tabella viene costruita utilizzando la funzione random del C; il terzo costruttore oltre agli argomenti precedenti un puntatore alloggetto Generatori numeri casuali e questa volta la tabella viene inizializzata utilizzando questo generatore ed la variabile m può valere fino a 4 miliardi circa. Se non servono numeri molto grandi è più veloce chiamare il costruttore 1. La funzione Init viene ridefinita. Anziché azzerare la tabella, assegna nuovi valori casuali.
Algoritmo -4 - Oggetto Vettore dinamico di stringhe di lunghezza variabile.
class TVetString
{
protected:
unsigned dim;
char** p;
public:
TVetString(char*, unsigned);
TVetString(unsigned, ...);
~TVetString(void);
char* operator[] (unsigned dx) {...}
friend ostream& operator <<(ostream& s, TVetString q);
};
Il costruttore 1 carica le stringhe da un file e richiede come secondo argomento il numero di stringhe da caricare. Per ogni stringa viene allocata la memoria strettamente necessaria. Il costruttore 2 chiede anzitutto il numero delle stringhe che poi devono essere scritte una di seguito allaltra separate da una virgola. Vengono ridefiniti gli operatori [] e << per la lettura rispettivamente di una e di tutte le stringhe. Questo oggetto funziona solo in lettura.
La creazione di queste strutture è il minimo necessario per il funzionamento dellalgoritmo senza decidere a priori il numero delle chiavi. Sono state tolte tutte le implementazioni che si trovano nellallegato, quelle inline sono state tutte sostituite da { ... }.
V.3 Struttura THash
Anche la tabella Hash è un oggetto
Algoritmo -5 - Oggetto Tabella hash
class THash : public TVetString
{
private:
PTRandom rand;
PTTabRand tab0, tab1, tab2;
PTVettoreDin h0, h1, h2;
PTVettoreDin cardin;
PTVettoreDin* Kliste;
PTVettoreDin assegnati;
PTVettoreDin Gvalori;
PTVettoreDin Mark;
unsigned* ordins;
PTVettoreDin Hash;
unsigned maxcard;
unsigned r;
unsigned primo;
unsigned cardmag1;
void Init(unsigned);
void Creah1(void);
void CreaKliste();
void verifica(void);
void AssegnaKLista(unsigned, unsigned, unsigned *ok);
int CreaHash(void);
void CercaHash(void);
public:
THash(unsigned c, char *nome, unsigned ddim) : TVetString(nome, ddim) { ... }
~THash();
};
Come si vede THash deriva dal vettore di stringhe dinamico perché alla fine è solo un particolare tipo di vettore di stringhe dinamico. Come si vede dalla dichiarazioni delle variabili vengono utilizzate tutte le strutture del precedente paragrafo. Il funzionamento dei punti cruciali verrà illustrato in seguito. Qui mi limito ad esporre il funzionamento. Disponendo di questoggetto e di un file con le stringhe è sufficiente scrivere:
Algoritmo -6 - Utilizzo delloggetto THash
void main ()
{
THash* h;
h = new THash(c, nomefile, numkey);
delete h;
}
per creare la Tabella Hash. c è un valore che deve essere scelto opportunamente in base a numkey ed alla memoria disponibile, generalmente si sceglie 4. È inutile allocare staticamente la tabelle Hash in quando una volta chiamato il costruttore e creata la tabella la struttura diviene inutile. Questa istruzione chiama il costruttore e questo procede costruendo la tabella Hash e salvando le tre tabelle; il vettore Gvalori e il vettore Mark. Il meccanismo con cui la tabella Hash viene creata è il seguente:
Il costruttore THash chiama il costruttore di TVetString e carica le stringhe in memoria; quindi chiama Init().
Init alloca tutte le strutture dinamiche di cui adesso è nota la dimensione, tranne le KListe di cui ancora non si conosce la dimensione; quindi chiama verifica()
Verifica chiama Creah1() che calcola i valori per h0, h1, h2 dalla tre tabelle e dalle chiavi; calcola inoltre il vettore delle cardinalità in base ai valori di h1 e trova il valore massimo tra queste cardinalità. Chiama CreaKListe()
Alloca le KListe e gli assegna i valori opportuni. Viene determinato e memorizzato lordine con cui le KListe dovranno essere allocate nella tabella Hash. A questo punto Klista per Klista, ovvero a parità di h1, si verifica se non ci sono triple uguali. Se ci sono le KListe vengono deallocate e si torna al punto 3, altrimenti si procede.
Si esce da verifica() e si torna ad Init(). Questultima chiama CercaHash()
Viene chiamata CreaHash() la quale cerca di collocare nella tabella tutte le Kliste una per una chiamando AssegnaKLista(...). CreaHash() procede finché AssegnaKLista(...) non restituisce un valore zero. In tal caso anche CreaHash si chiude restituendo il valore del massimo gruppo allocato. Se tutte le KListe vengono assegnate CreaHash restituisce zero.
Se in ritorno da CreaHash non cè zero questa viene richiamata di nuovo con diverse condizioni. Dopo un numero elevato di chiamate a CreaHash viene rifatto tutto dal punto 3. Quando CreaHash restituisce 0 si passa avanti
Vengono salvate le tabelle e i tre vettore e si esce da Init(). Quindi si esce anche dal costruttore.
Ai fini dei test ho considerato
MAPPING il punto 2.
ORDERING il punto 3 e 4
SEARCHING i punti 6 e 7
Con n, numkey, dim si intende sempre e solo il numero delle chiavi. Con r in tutto il programma si farà riferimento al valore di r sopra definito.
V.4 Mapping Step
Questo passo è abbastanza semplice. Le tre tabelle vengono create utilizzando loggetto TTabRand, derivato da TTab. Inoltre viene calcolata r come sopra definita. Per il calcolo del logaritmo di 2 non ho usato le funzioni standard, ma ho contato il numero di shift a destra necessari per azzerare dim. Infine vengono allocati tutti i vari vettori dinamici.
while ((dim>>abc) > 0) abc++;
r = (unsigned) c*dim/abc;
if (r < dim/3) r = dim /3;
tab0 = new TTabRand(MAXCAR, MAXLUNG);
tab1 = new TTabRand(MAXCAR, MAXLUNG);
tab2 = new TTabRand(MAXCAR, MAXLUNG);
I tempi necessari per questo non bassi e crescono sub linearmente con la dim. In realtà le tabelle sono tutte uguali, ma forse incide il fatto che si debbano utilizzare puntatori lunghi con dim molto alta.
Figura -1 - Tempi per lallocazione dei dati e la creazione delle tabelle (ms)
![]()
Ricordo che ogni funzione polinomiale si presenta lineare in un grafico con scala logaritmica che sono costretto ad usare per leggibilità: per valutare la linearità è necessario vedere qual è la funzione esponenziale "interpolante" e ricavare quindi il grado del polinomio. Se per esempio la funzione esponenziale è 10*exp(0.69x) allora landamento è circa lineare: infatti exp(0.69) = 2 e lungo le ascisse la scala raddoppia ad ogni segno. Se 10*exp(1.79x) è cubica perché exp(1.79) = 6 e 6/ 2 = 3 se la scala delle ascisse segue un andamento tipo k*2^x
Nel caso specifico abbiamo una sub-linearità, in quanto il grado ottenuto è leggermente superiore ad 0 (grado = 0.16), come daltra parte ci si aspettava in quanto questa parte è abbastanza indipendente da numkey.
Questi tempi quindi, già trascurabili con 16000 chiavi, resteranno tali anche per qualche milione di chiavi. I tempi per il calcolo di h0, h1, h2 sono inclusi nei tempi successivi.
V.5 Ordering Step
Questo passo si occupa solo di dividere nei vari gruppetti le chiavi. A tal proposito si utilizza un vettore di puntatori a TVettoriDin, chiamato Kliste. Ci sono r puntatori a TVettoreDin, uno per ciascun valore di h1 e ogni TVettoreDin ha una dimensione pari alla cardinalità per quel valore di h1. In pratica KListe[i] punta al vettore che conterrà tutte le chiavi con h1(key) = i e KListe[i][0] ... KListe[i][cardin(h1)] contengono tutti i valori del numero della chiave con h1(key) = i.
TVettoreDin pos(r);
Kliste = new PTVettoreDin[r];
for (i = 0; i < r; i++)
Kliste[i] = new TVettoreDin((unsigned)(*cardin)[i]);
7Queste righe allocano KListe. Il vettore dinamico pos viene posto a 0 dal costruttore e serve nella carte successiva per sapere in quale posizione poter memorizzare la chiave. La memorizzazione delle chiavi avviene nel semplice ciclo:
for (i = 0; i < dim; i++)
{
Kliste[(unsigned)(*h1)[i]]->scrivi((unsigned)pos[(unsigned)(*h1)[i]], i);
pos.scrivi((unsigned)(*h1)[i], pos[(unsigned)(*h1)[i]] + 1);
}
Complessità di scrittura dovuta ai puntatori il seguente ciclo si comporta nel modo seguente:
Memorizza il numero della chiave i-esima (quindi il valore i) in KListe[h1(key)][a]. Qual è "a"? Questo valore è scritto in Pos[h1(key)] e allinizio è zero, poi mano a mano che si inseriscono chiavi con medesimo h1 si incrementa Pos.
Incrementa di uno Pos[h1(key)] in modo da memorizzare la successiva chiave con medesimo h1 nella posizione successiva.
Quindi sostanzialmente un ciclo for: difatti non cè possibilità di errore nella creazione delle KListe.
Algoritmo -11 - Calcolo dellordine di inserimento
nt = 0;
for (i = maxcard; i > 1; i--) for (j = 0; j < r; j++)
if ((*cardin)[j] == i) ordins[nt++] = j+1;
Questi due cicli nidificati calcolano lordine con cui le K-Liste dovranno essere inserite nella tabella hash, ovvero da quella con cardinalità maggiore fino a quelle con cardinalità 2. Pur essendo due cicli uno interno allaltro la complessità resta lineare. Infatti maxcard cresce come O(log(n)), mentre r è per definizione O(n/log(n)). Dunque la complessità del ciclo è proprio O(n). Nel caso in cui r sia n/2 la complessità di questo ciclo sale a O(n*logn).
Più difficile lanalisi del ciclo successivo che si preoccupa di verificare che le triple siano distinte. Per migliorare la complessità si effettuano solo confronti allinterno di ciascuna K-Lista con almeno due elementi, dove già sappiamo essere uguale il valore di h1.
Algoritmo -12 - Verifica della non coincidenza delle triple h0,h1,h2
for (k = 0; k < cardmag1 && !l ; k++)
{
nkl = ordins[k] - 1;
c = (unsigned)(*cardin)[nkl];
for (i = 0; i < c && !l; i++)
for (j = i+1; j < c && !l; j++)
{
nk1 = (unsigned)(*(Kliste[nkl]))[i];
nk2 = (unsigned)(*(Kliste[nkl]))[j];
if ((*h0)[nk1] == (*h0)[nk2] &&
(*h2)[nk1] == (*h2)[nk2])
{
tab0->Init(); tab1->Init(); tab2->Init();
cardin->Init();
for (l = 0; l < r; l++) delete (Kliste[l]);
delete[] Kliste;
for (l = 0; l < r; l++) ordins[l] = 0;
l = 1;
collisioni++;
}
}
}
Essendoci tre cicli nidificati la complessità asintotica è data dal prodotto di cardmag1*c*c; il primo è dellordine di O(n/log(n)) in quanto dipende direttamente da r e nel caso migliore vale esattamente r; c come sopra è dellordine di O(log(n)) e nel caso migliore tutti i valori di c sono uguali pari esattamente ad log(n). Quindi il limite inferiore della complessità è O(n*log(n)). Nel caso peggiore cardmag1 vale esattamente 1, mentre c vale n ed è il caso in cui tutti i valori di h1 sono uguali. In questa situazione del tutto improbabile per lefficienza dei generatori di numeri casuali proposti la complessità è proporzionale a n^2, ma questo caso oltre ad essere improbabile è anche inaccettabile per il funzionamento del programma: il suo verificarsi dovrebbe far rivedere tutto.
Figura -2 - Tempi per il calcolo di h1 e di verifica triple distinte(ms) nel caso di r minimo
![]()
Il grado ottenuto è leggermente inferiore ad 1, 0,97 per la precisione. Come si vede le previsione teoriche vengono rispettate e si evidenzia una linearità netta. Nel caso r = n/2, previsto non lineare non si riescono comunque a notare scostamenti dalla linearità (grado = 0.97 nuovamente), anche se i tempi risultano tutti più alti (vedi Tabella 2 *).
V.6 Searching Step - Algoritmo
Questo passo si occupa di allocare nella tabella Hash le K-Liste. Si compone di tre funzioni:
AssegnaKLista cerca di allocare una ben precisa K-Lista, ovvero tutte le sue chiavi. Questa funzione può non riuscire se il valore Hash di una chiave, al variare di tutti i possibili valori di g, coincide con una posizione precedentemente assegnata. È quindi importante che la funzione Hash, fissati h0 e h2 generi il maggior numero di posizioni differenti al variare di g.
CreaHash cerca di allocare tutte le chiavi chiamando AssegnaKLista. In un primo tempo si era costruito allinterno di questa funzione un meccanismo di backtracking che forse può avvantaggiare per valori di n molto alti e solo quando quasi tutte le K-Liste sono state assegnate. Nella versione proposta non appena una Klista non viene allocata CreaHash si interrompe comunicando il fallimento.
CercaHash chiama CreaHash finché la tabella hash non viene creata. Nel caso, frequente, che non riesca opera due forme di backtracking che fanno in ogni caso ripartire CreaHash dalla prima K-Lista. Nel primo caso, detto semplicemente backtracking, si limita a richiamare CreaHash variando il valore di g iniziale e molto probabilmente tutti i valori seguenti; nel secondo, detto ricalcolo, vengono calcolate di nuovo le tabelle e quindi rifatte le K-Liste.
Lefficienza di questo passo dipende sostanzialmente dalla funzione Hash che deve avere la caratteristica di essere un buon generatore di numeri casuali ed anche di generare molte posizioni differenti al variare di g. Non si può fare una semplice analisi della complessità di questi algoritmi, in quanto sono nettamente probabilistici. Mi limiterò a trarre delle conclusioni dai test effettuati.
V.7 Sulla funzione Hash proposta
Riscontrando problemi nella ricerca della funzione Hash, ovvero dei valori della funzione g(i), i = 0, ..., r ho pensato di testare il comportamento casuale della funzione proposta e anche di una mia funzione Hash, sempre quadratica data dalla seguente:
Espressione -2 - Funzione Hash 2
Poiché riscontravo valori del tutto non accettabili con il test di casualità precedente lho sostituito in questa nuova serie di test con il seguente.
Espressione -3 - Test di casualità 2
![]()
Anche questo test come il precedente tende a zero se la distribuzione è uniforme, tuttavia ha una precisione molto maggiore. Durante i test eseguiti è stato fatto assumere a g tutti i valori possibili, h0 e h2 sono stati calcolati attraverso il generatore di numeri casuali, in quanto leffetto che si ottiene con la funzione h0 e h2 è esattamente quello. Sono stati fatti test al variare del numero delle chiavi, mantenendo r fisso pari alla metà del numero delle chiavi. Poi con due numeri di chiavi particolare sono stati fatti test al variare di r, in quanto come vedremo è soprattutto questo numero ad influenzare i risultati. Riportiamo il primo grafico con i valori del test, al crescere del numero delle chiavi per il generatore di numeri casuali utilizzato; la funzione Hash proposta dallalgoritmo (Hash 1) e dalla funzione Hash di cui sopra (Hash 2).
Figura -3 - Casualità delle funzioni Hash
![]()
Come si vede dal grafico, in scala logaritmica su ambedue gli assi, soltanto la funzione Hash 2 segue landamento del generatore di numeri casuali, mentre la prima se ne discosta molto per valori inferiori a 4000. Peraltro proprio per quel valore cambia landamento della curva, prima fortemente lineare. Questo indica un difetto del generatore di numeri casuali che tuttavia ritroviamo anche con il generatore del C. Per valori elevati il generatore random tende a scendere verso lo zero e così anche le due funzioni Hash, daltra parte sono esse stesse generatori random, e la prima segue il generatore random meglio della seconda. Sembra infine che la prima sia leggermente migliore della seconda per valori alti, ma del tutto insoddisfacente per un numero di chiavi bassi. I valori ottenuti da questo grafico sono riassunti nella
Tabella 9
.
Detto questo lalgoritmo di Searching non funziona con nessuna delle due: in particolare riusciamo ad ottenere un qualche risultato con la Hash 1, ma non si riesce ad ottenere alcun risultato ragionevole con la Hash 2. A questo punto mi sono chiesto quale deve essere la caratteristica non tanto di una generica funzione Hash, ma della funzione Hash per il mio algoritmo. Si nota che il comportamento casuale è necessario a garantire una elevata velocità, ma non sufficiente: difatti se non riesce subito lallocazione variando g è utile che vari anche la funzione hash. Ho perciò definito due indici per misurare ciò:
Valori differenti complessivi (VDC): dato dal numero di valori differenti per la funzione hash generati da differenti valori di g.
Valori differenti utili (VDU): dato dal rapporto tra il numero di volte che la funzione hash è riuscita a generare al variare di g almeno la metà dei valori possibili e il numero di prove totali.
Espressione -4 - Test VDU
![]()
Freq(i) contiene il numero di volte che nelle numerose prove sono state generati i posizioni differenti.
Il primo indica la possibilità che vengano generate tutte le posizioni in un ciclo al variare di g; il secondo è come il primo, ma vengono escluse tutte le frequenze più piccole ed è quindi un indice più severo, ma più indicativo. Vale generalmente:
Esistono funzioni hash in grado di raggiungere il valore massimo, come la seguente:
Espressione -5 - Funzione Hash 4
Si vede bene che se fissiamo h0 ed h2 al variare di g tra 0 e quanti otteniamo, per definizione di modulo, tutti i valori tra 0 e quanti. Quindi in tal caso VDU = VDC = 1. Chiameremo questa funzione Hash 4. Definiamo inoltre una funzione Hash 3:
Espressione -6 - Funzione Hash 3
Sia la Hash 3 sia la Hash 4 ha un comportamento nel test di casualità come la Hash 2. Tuttavia per la Hash 4 troviamo che non è un buon generatore di numeri casuali perché il suo periodo, a parità di h0 e h2, è numkey. Inoltre la Hash 1 e la Hash 2 non hanno dei buoni valori di VDC e VDU, anzi il secondo che è il più importante è molto, troppo, basso. A scopo di esemplificazione un VDU pari a 0,18 significa che cè solo il 18% di probabilità che al variare di g da 0 a numkey la funzione Hash assuma più di numkey/2 valori differenti o anche che generalmente vengono generati numkey/10 valori differenti. Questi valori sono casuali, con h0 e h2 differenti, tuttavia quando la tabella è già molto piena è molto utile che la funzione hash sia in grado di scandirla quasi tutta e comunque maggiore è il numero di posizioni differenti rintracciate dalla funzione Hash, maggiore è la possibilità che il gruppetto venga allocato. Allora la soluzione migliore sembrerebbe la Hash 4, ma non è così: questa infatti scandisce tutta la tabella, ma è poco casuale e quando deve allocare due chiavi e quindi se è sempre in grado di trovare un valore buono per la prima chiave, la scarsa casualità "impedisce" che tale valore di g sia buono anche per la seconda chiave. In parole povere la Hash 4 è molto buona quando teniamo fermi h0 ed h2 e facciamo variare g, ma non altrettanto quando g è fissa e variano h0 ed h2. Riepilogando:
la Hash 1 (2) al variare di h0 ed h2 con g fissa produce pochi numeri differenti e produce molti numeri differenti con h0 ed h2 fisse, al variare di g;
la Hash 4 al variare di g produce numeri sempre differenti e produce numeri poco differenti con h0 e h2 variabili e g fissata.
La Hash 3 è un compromesso tra le due ed è quella con cui condurrò gran parte dei miei test. In ogni caso ho effettuato test anche con la Hash 1 e la Hash 4 per controprova di queste considerazioni.
V.8 Test sul Searching Step
Ho eseguito vari test per verificare:
I tempi di costruzione della tabella al variare della funzione Hash, del numero delle chiavi e di r
Il numero dei backtracking ed il numero dei ricalcoli.
Sono stati fatti anche test utilizzando tecniche di backtracking "interno", ovvero nella funzione CreaHash stessa, ma non sono riportati in quanto non significanti: i tempi ottenuti non sono affatto accettabili, anche se è presumibile che quando il numero delle chiavi cresce di molto oltre il mio limite massimo (16000) potrebbe essere utile considerare un backtracking "interno" quando si arriva alle ultime K-Liste. Cominciamo con i test effettuati con la Hash 3.
Figura -4 - Tempi (s) di creazione tabelle Hash con r minimo
![]()
La funzione presenta un andamento non del tutto lineare: grado = 1.42, ma dal grafico sembra evidenziarsi un andamento quadratico che fuori dalla scala logaritmica indica un andamento esponenziale. Questo è dovuto in parte al numero delle prove, in parte alla variazione di r ed in ogni caso il tutto è attribuibile solo al numero di backtracking esterni effettuati, in quanto il tempo che ogni chiamata a CreaHash cresce linearmente come si vede nella figura successiva. Daltra parte con la variazione di r verso il valore minimo è ragionevole aspettarsi un aumento dei tempi.
Figura -5 - Tempi necessari per una chiamata a CreaHash (ms) con r minimo
![]()
Oltre al tempo per creare la tabella ho contato anche il numero di chiamate a CreaHash ed il numero di ricalcoli (vedi Tabella 3*). I tempi rappresentati nella figura di sopra sono quelli necessari per effettuare una chiamata a CreaHash e quindi si vede che, in condizioni ideali o comunque con un numero di prove infinito, la crescita è nettamente lineare (grado = 1,023), anche se notiamo uno scostamento per valori elevati a causa della variazione di r che inizia farsi sentire. Le stesse prove sono state fatte utilizzando un valore di r costante ed elevato pari ad n/2.
Figura -6 - Tempi (ms) di creazione della tabella Hash con r costante
![]()
Il grado di questa funzione è circa 1,28 e quindi non molto lineare, ma questo è dovuto probabilmente a causa di due valori molto incerti, per 2000 e 8000 chiavi. In ogni caso la costanza di r ha portato il tutto verso una maggiore linearità rispetto alla Figura V-4 *
Figura -7 - Tempi (ms) di chiamata della funzione CreaHash con r costante
![]()
Si nota che sono gli stessi della Figura V-6 (grado di questo 1,08). Anche per questo caso è stato calcolato il numero di backtracking e il numero di ricalcoli: vedi Tabella 1 *. Di seguito vediamo come quando r tende al valore minimo i tempi salgono bruscamente.
Figura -8 - Tempi(s) searching al variare di r con n costante
![]()
Questi dati fanno riferimento alla Tabella 5, *. Come si può notare avvicinandosi al valore minimo i tempi subiscono una forte impennata. Vediamo adesso i tempi globali attraverso alcuni grafici cumulativi in scala logaritmica.
Figura -9 - Tempi (ms) cumulativi nelle varie fasi
![]()
Qui possiamo notare in scala logaritmica i tempi delle tre fasi: dal basso verso lalto troviamo Mapping, Ordering e Searching. Si può notare che cè un ordine di grandezza di differenza tra i due grafici che hanno rispettivamente r costante pari ad n/2 e r minima. Notiamo altresì che la velocità con cui crescono i tempi è sempre maggiore: si va dalla quasi costanza del Mapping (grado = 0.18) fino alla scarsa linearità del Searching (grado = 1.3 circa). Questi grafici riassumo i dati della Tabella 6* e della Tabella 7*.
Detto ciò, considerando che i tempi per il Mapping e lOrdering non dipendono dalla funzione Hash vediamo quali risultati otteniamo con le funzioni Hash 1 ed Hash 4 nel caso specifico in cui r è numkey/2 e confrontiamo con gli stessi dati ottenuti dalla Hash 3. Vedremo che i risultati di cui al paragrafo 7 * troveranno conferma.
Figura -10 - Confronto tra funzioni Hash
![]()
Ricordo che la Hash 3 è data dallEspressione V-6*, la Hash 4 dallEspressione V-5* e la Hash 1 dallEspressione II-1*. Notiamo che la crescita della Hash 1 sembra essere quadratica nel grafico, cioè esponenziale nella realtà, in ogni caso il grado del polinomio che si ottiene è 3,27. Le altre due crescono abbastanza linearmente sul grafico. Tuttavia questo per la Hash 4 corrisponde ad un grado pari a 1,67 e quindi possiamo parlare di crescita quadratica. Per la Hash 3 otteniamo un grado 1,12 abbastanza lineare: anzi è il migliore ottenuto e questo conferma che se r è costante la crescita è poco più che lineare.
V.9 Conclusioni
In conclusione posso dire che:
la migliore funzione hash è la Hash 3 data dallEspressione V-6* in quanto non solo presenta i tempi più bassi rispetto alle altre, ma in ogni caso il grado che si ottiene è il più piccolo rispetto alle altre funzioni. Da escludere la Hash 1 che si è rivelata, come previsto dalla misure di VDC e VDU del tutto inutilizzabile anche per il comportamento quasi esponenziale dei tempi nella fase di Searching: ad esempio non sono mai riuscito a far calcolare una tabella di 1000 elementi.
Il comportamento trovato è poco più che lineare (NlogN?). Questo si trova considerando i due casi ad r costante in cui abbiamo trovato un grado minore di 1,3. Questo è in linea con quanto previsto teoricamente a parte la differente funzione Hash. Gli altri tempi, trascurabili in ogni caso, crescono comunque al massimo linearmente o sub-linearmente.
Non sembra rendersi utile un meccanismo di backtracking interno alla funzione CreaHash, se non per valori molto maggiori di quelli da me considerati. Questo comunque porta ad un minor numero di backtracking esterni, anche se cresce notevolmente il tempo per una chiamata a CreaHash. Tuttavia è semplice dimostrare che se si continua a prendere il valore più piccolo per r, la probabilità di allocare lultima K-Lista tende a 0 quando numkey tende ad infinito. Ovvero allaumentare del numero delle chiavi potrebbe essere utile cercare di ricominciare dagli ultimi gruppi invece che dal primo per amplificare questa probabilità.
La dipendenza tra r e numkey è molto stretta: per quanto sembra è impossibile scendere sotto il valore indicato come minimo ed anzi avendo a disposizione molta memoria sarà opportuno tenere alto r anche e sopra numkey/2: i tempi decadono esponenzialmente.
VI. Allegati
VI.1 Tabelle
Tabella 1- Risultati test casualità
Generatore Lehmer |
Generatore C |
|
100 |
0,97 |
0,95 |
1000 |
1,003 |
1,03 |
10000 |
1,06 |
2,8 |
20000 |
0,997 |
9,6 |
Tabella 2 - Tempi nelle varie fasi iniziali (MAPPING e ORDERING)
tabelle (ms) |
K-liste e verifica (ms) |
K-liste e verifica (ms) |
|
500 |
25 |
22 |
26,5 |
1000 |
28 |
50 |
57,5 |
2000 |
40,5 |
107 |
118,5 |
4000 |
45 |
148 |
169 |
8000 |
59 |
320 |
396 |
16000 |
108 |
691 |
766 |
Tabella 3 - Risultati del test di creazione tabella con r minimo
Tempo(s) |
Backtracking |
Ricalcoli |
T/B |
B/R |
|
500 |
6,600 |
502 |
2,6 |
13,15 |
193 |
1000 |
8,090 |
331 |
0,75 |
24,44 |
441 |
2000 |
16,690 |
319 |
0,25 |
52,30 |
1276 |
4000 |
45,530 |
574 |
0,2 |
79,30 |
2870 |
8000 |
262,240 |
1208 |
0,25 |
217,10 |
4832 |
16000 |
976,200 |
1625 |
0,2 |
600,70 |
8125 |
Tabella 4 - Risultati del test di creazione tabella con r costante pari a n/2.
Tempo(s) |
Backtracking |
Ricalcoli |
T/B |
B/R |
|
500 |
0,149 |
15,1 |
0 |
9,8 |
infinito |
1000 |
0,906 |
51,2 |
0 |
18 |
infinito |
2000 |
0,435 |
7,9 |
0 |
55 |
infinito |
4000 |
5,720 |
83 |
0 |
69 |
infinito |
8000 |
3,560 |
14,6 |
0 |
244 |
infinito |
16000 |
28,340 |
64 |
0 |
443 |
infinito |
Tabella 5 - Risultati al variare di r con n = 1000 costante
Tempo(s) |
Backtracking |
T/B |
|
500 |
0,906 |
51,2 |
18 |
416 |
0,981 |
48,7 |
20 |
333 |
8,090 |
331 |
24 |
Tabella 6 - Tempi complessivi (ms) con r costante
Mapping |
Ordering |
Searching |
|
500 |
25 |
26,5 |
149 |
1000 |
28 |
57,5 |
906 |
2000 |
40,5 |
118,5 |
435 |
4000 |
45 |
169 |
5720 |
8000 |
59 |
396 |
3560 |
16000 |
108 |
766 |
28340 |
Tabella 7 - Tempi complessivi (ms) con r minima
Mapping |
Ordering |
Searching |
|
500 |
25 |
22 |
6600 |
1000 |
28 |
50 |
8090 |
2000 |
40,5 |
107 |
16690 |
4000 |
45 |
148 |
45530 |
8000 |
59 |
320 |
262240 |
16000 |
108 |
691 |
976200 |
Hash 1 |
Hash 4 |
Hash 3 |
|
50 |
0,165 |
0,039 |
0,026 |
100 |
0,562 |
0,131 |
0,030 |
200 |
4,201 |
0,683 |
0,077 |
400 |
44,835 |
1,305 |
0,147 |
Tabella 9 - Verifica della casualità delle funzioni Hash al crescere del numero delle chiavi
Generatore random |
Funzione Hash 1 |
Funzione Hash 2 |
||||
Numkey |
Media |
Dev.Std. |
Media |
Dev.Std. |
Media |
Dev.Std. |
500 |
496 |
17 |
33041 |
185 |
509 |
38 |
1000 |
985 |
35 |
30498 |
319 |
1055 |
11 |
2000 |
2039 |
29 |
4528 |
766 |
2046 |
53 |
4000 |
3992 |
112 |
5280 |
1082 |
4044 |
29 |
8000 |
2568 |
115 |
2438 |
1618 |
2574 |
108 |
16000 |
1878 |
993 |
2141 |
426 |
2083 |
894 |
32000 |
1854 |
354 |
1804 |
495 |
1929 |
818 |
Tabella 10 - Valori di VDC e VDU per le varie funzione Hash con numkey = 50
VDC |
VDU |
|
Hash 1 |
0,415 |
0,190 |
Hash 2 |
0,325 |
0,145 |
Hash 3 |
0,623 |
0,400 |
Hash 4 |
1 |
1 |