LA COMPRESSIONE Dispensa per il Corso di Tecnologie Informatiche di Chiara Epifanio Attenzione: si tratta di una copia preliminare, la copia definitiva verrà messa quanto prima online. Indice 1 La compressione 2 Algoritmi elementari di codifica 2.1 Eliminazione di ripetizioni . . . 2.2 Codifica differenziale . . . . . . 2.3 Rappresentazione topografica . 2.4 Codifica a blocchi . . . . . . . . 1 . . . . 9 9 10 11 12 3 Compressione statistica, o probabilistica 3.1 Algoritmo di Huffman . . . . . . . . . . . . . . . . . . . . . . 3.2 Codifica di Shannon-Fano . . . . . . . . . . . . . . . . . . . . 3.3 Codici aritmetici . . . . . . . . . . . . . . . . . . . . . . . . . 12 12 17 20 4 I compressori linguistici 4.1 LZ77: una compressione sliding window . 4.2 Problemi derivanti dall’uso di LZ77 . . . 4.3 LZ78: un nuovo algoritmo . . . . . . . . 4.4 LZW . . . . . . . . . . . . . . . . . . . . 22 23 25 27 35 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 La compressione Principale obiettivo di questa breve dispensa è quello di approfondire nozioni e tecniche legate alla problematica della compressione dati. Da un punto di vista intuitivo “comprimere” un testo, un’immagine, un suono o più in generale un oggetto significa ridurne le dimensioni. Lo scopo della compressione è, quindi, quello di fornire una rappresentazione dei dati in una forma ridotta senza che il significato degli stessi venga cambiato. In tal modo sarà possibile ricostruire in un momento successivo i dati originali o una loro approssimazione. Diamo adesso due definizioni utili per potere approfondire meglio questo nuovo concetto. Definizione 1.1 : Si definisce Lossy data compression un qualsiasi algoritmo che permetta di ottenere, tramite la decodifica della codifica dell’input, non i dati iniziali, bensı̀ una loro approssimazione. Definizione 1.2 : Si definisce Lossless data compression un qualsiasi algoritmo che permetta di ottenere, tramite la decodifica della codifica dell’input, esattamente i dati iniziali. Gli algoritmi di cui alla definizione 1.1 sono utili in casi nei quali non sia necessario riottenere esattamente l’input dato, ma ne basta una ricostruzione abbastanza fedele. Un esempio si può avere nel caso della compressione di immagini, in cui basta ottenere, tramite decodifica, un’immagine che differisca dall’input in qualche pixel, in modo che l’occhio umano non si possa accorgere della differenza. Gli algoritmi di cui alla definizione 1.2, invece, sono più appropriati per comprimere testi (in una qualsiasi lingua o codice), casi in cui, quindi, non è 1 consentito perdere neanche un bit dell’informazione di partenza. Nonostante ciò, esistono alcuni algoritmi lossless utilizzati per comprimere immagini. Supponendo che l’alfabeto sorgente sia A e che il codominio della nostra funzione sia 2 = {0, 1} (considerando le codifiche all’interno di un qualsiasi altro alfabeto otterremmo gli stessi risultati) e chiamando A∗ l’insieme di tutte le stringhe ottenute, tramite concatenazione, a partire da elementi di A, diamo la seguente definizione. Definizione 1.3 : Si definisce compressore, o compressione, una qualsiasi funzione parziale α : A∗ → 2∗ (la “parzialità” è richiesta per poter raggiungere coefficienti di compressione migliori, dal momento che il compressore può cosı̀ tralasciare parole che, per il nostro scopo, sono inessenziali). Se α è una funzione iniettiva, allora l’algoritmo dato sarà lossless. Infatti, in tal caso l’operazione di codifica sarà tale che in fase di decodifica si avrà un’unica ricostruzione dell’input, quella originale. Assumeremo d’ora in poi di considerare solo “lossless data compression”. Per far ciò sarà necessario, come detto, che la compressione sia una funzione iniettiva. Alla fine degli anni ’40, i primi anni della teoria dell’informazione, ci si cominciò a porre il problema di creare nuovi algoritmi di codifica che si basassero sui concetti di ridondanza e di quantità di informazione. Ci si rese conto che, conoscendo le probabilità dei simboli di un testo qualsiasi, sarebbe stato possibile limitare la quantità di memoria necessaria per lo stesso. I primi algoritmi effettivi di compressione dati sono stati introdotti nel 1950 e sono quelli di Shannon-Fano e di Huffman, che verranno esaminati nel prossimo paragrafo, che si basavano proprio su concetti statistici. In quegli anni, però, non ci si poteva avvalere certamente di processori 2 molto efficienti, per cui, ad una attenta analisi, il rapporto costi-benefici tra i costi computazionali per i processi di codifica e di decodifica ed i benefici ottenuti per far ciò non risultava molto conveniente. Con l’avvento di microprocessori economici, invece, la compressione ha assunto un ruolo più rilevante ed è diventata una componente essenziale nel campo della teoria dell’informazione e nello studio delle sue applicazioni all’immagazzinamento di dati e alla trasmissione. Questi sono, infatti, i campi in cui viene maggiormente sfruttato l’uso di compressori. Esaminiamo brevemente la situazione in entrambi i casi. Per quanto riguarda l’immagazzinamento dei dati, essi vengono compressi prima di essere archiviati in un supporto di memoria in modo che esso possa contenere il più possibile dei dati, che poi verranno decompressi all’atto della lettura. USER ENCODER STORAGE DEVICE DECODER USER Nonostante l’esistenza ormai sul mercato di supporti di memorizzazione capaci di contenere al loro interno una gran quantità di dati, gli algoritmi di compressione mantengono la loro notevole importanza: per esempio sono molto interessanti per la creazione di archivi o, anche, per l’applicazione a tipi particolari di dati, quali le immagini. Per quanto riguarda la trasmissione, invece, sarà necessario che colui che trasmette i dati li comprima prima di farlo e che chi li riceve li decomprima prima di leggerli. 3 SENDER DECODER ENCODER RECEIVER Trasmissione Per esempio, consideriamo un’azienda che abbia 20 linee telefoniche dedicate che collegano Palermo con Roma e che trasmetta, attraverso queste linee, dati grazie a dei modem. Se i dati possono essere compressi di un fattore 2, cioè viene impiegata la metà dei bits necessari per trasmettere l’input per intero, allora la velocità con cui verranno trasmessi viene raddoppiata (indipendentemente dalla velocità dei modems). L’azienda potrà, dunque, decidere se inviare e ricevere il doppio dei dati ogni giorno, ovvero se mantenere solo 10 linee telefoniche dedicate. La codifica consiste nella trasformazione di un insieme di simboli emessi da una sorgente in un nuovo insieme che passa poi per un canale di trasmissione. All’uscita dal canale, l’insieme di simboli viene trasformato mediante un decoder, che permette al receiver di ottenere l’input dato. Il canale può essere un supporto fisico, una linea telefonica o altro. Durante la codifica e la decodifica, un insieme può dovere soddisfare certe proprietà, o vincoli, e può subire alcune alterazioni (in questo caso l’insieme verrà modificato e si dirà che è un “canale con rumore”, o “noisy channel”). Il processo di codifica-decodifica dovrà rispondere alle esigenze di chi lo applica, cioè alle prestazioni richieste dall’applicazione specifica. Nel nostro caso, cioè la compressione, lo scopo sarà ovviamente quello di ridurre la dimensione dell’informazione trasmessa o immagazzinata. Vi sono altri casi di processi di codifica-decodifica: ad esempio quando parliamo di crittografia lo scopo primo è che i dati codificati siano accessibili solo alle persone autorizzate. Il codice correttore di errori, invece, cercherà di “correggere gli errori” apportati da un noisy channel ad un insieme dato. In altri casi, l’insieme verrà modificato in modo da potere soddisfare a certi vincoli che riguardano 4 la suddivisione dei simboli dell’insieme che dovrà passare per il canale. Tutti questi generi di codifica appena esaminati possono essere racchiusi in un unico schema (cf. fig. seguente), in cui l’ordine con il quale si presentano i vari processi di codifica è essenziale. Per esempio la decodifica di compressione, come d’altronde anche quella di crittografia, dovrà avere a che fare solo con canali senza rumore. Definizione 1.4 : Definiamo Canale senza rumore un canale che non “sporchi” il segnale emesso (colui che riceve i dati ottiene esattamente quelli trasmessi, senza alcuna “sbavatura”). CANALE SENZA RUMORE CANALE CON RUMORE SOURCE ENCODER COMPRESSIONE ENCODER CORRETTORE DI ERRORI ENCODER CHANNEL VINCOLI RUMORE DECODER DECODER DECODER VINCOLI CORRETTORE DI ERRORI COMPRESSIONE D’ora in poi considereremo solo canali senza rumore, in modo da ottenere risultati che dipendano esclusivamente dall’algoritmo di compressione adoperato. Questa assunzione non costituirà una limitazione dal momento che, come abbiamo detto, grazie ai codici correttori di errori potremo sempre ottenere canali senza rumore o buone approssimazioni di essi. 5 RECEIVER Definizione 1.5 : Diciamo che il nostro algoritmo di compressione-decompressione lavora in tempo reale se esiste una costante k tale che in ogni k unità di tempo esattamente un nuovo carattere viene letto dall’encoder e uno viene scritto dal decoder. L’unica eccezione a ciò è che noi possiamo avere un certo ritardo tra l’encoder e il decoder. Quindi, esiste un’altra costante l tale che durante le prime l unità di tempo il decoder può non produrre caratteri e, nel caso in cui l’input sia finito, possono trascorrere l unità di tempo tra quando l’encoder ha finito di leggere e quando il decoder ha finito di restituire caratteri. Definizione 1.6 : Definiamo on-line un algoritmo che lavora man mano che riceve i dati, off-line, invece, uno che esamina tutti i dati prima di produrre l’output. Se gli algoritmi considerati sono on-line, allora l’immagazzinamento di dati può essere visto come un caso particolare di trasmissione, caso in cui il sender ed il receiver coincidono. E’ da notare, comunque, che algoritmi on-line lavorano anche off-line, ma non vale il viceversa. Adesso dobbiamo però stabilire come valutare se un algoritmo di compressione è “buono”, e quindi avere dei criteri su cui basarci per scegliere se in un dato caso è più conveniente adoperare un tipo di codifica o un altro. Per far ciò diamo la seguente definizione. Definizione 1.7 : Dato un compressore α : A∗ → 2∗ definiamo coefficiente di compressione della parola w ∈ A∗ τ (w) = 6 |w| |α(w)| In generale, risulterà ovviamente che |α(w)| < |w| e che, quindi, τ (w) > 1 . È ovvio, inoltre, in base a quanto detto ed alle definizioni date, che un alto coefficiente di compressione rappresenta una codifica più efficiente. Secondo l’input da comprimere, possono, infatti, esistere compressori più o meno adatti. Possono esistere, però, anche input per i quali un compressore produca un output più voluminoso! Osservazione 1.1 Alcuni testi presentano il coefficiente di compressione come |α(w)| . |w| Seguendo una tale definizione, ovviamente, le codifiche più efficienti avranno coefficienti di compressione più bassi. Un’altra grandezza molto importante è l’entropia di una sorgente. Esistono due tipi fondamentali di entropia: l’entropia statistica e l’entropia topologica di una sorgente. Nella teoria dell’informazione - e in rapporto alla teoria dei segnali - l’entropia misura la quantità di incertezza o informazione presente in un segnale aleatorio. Da un altro punto di vista l’entropia è la minima complessità descrittiva di una variabile aleatoria, ovvero il limite inferiore della compressione dei dati. Essa rappresenta un lower bound sulla possibile compressione lossless: il minore numero di bit necessari per codificare un messaggio. Si deve a Claude Shannon lo studio dell’entropia in tale contesto. Nel primo teorema di Shannon, o teorema di Shannon sulla codifica di sorgente, egli dimostrò che una sorgente casuale d’informazione non può essere rappresentata con un numero di bit inferiore alla sua entropia, cioè alla sua autoinformazione media. L’entropia di una variabile aleatoria X è la media dell’autoinformazione I(xi ) delle realizzazioni della variabile stessa (x1 , x2 , . . . , xn ): 7 H(X) = E [I (xi )] = n X I(xi ) · P (xi ) = i=1 n X P (xi ) · log2 i=1 1 . P (xi ) La base del logaritmo originariamente utilizzata da Shannon fu quella naturale, tuttavia è oggi di uso comune la base 2 in quanto consente di ottenere dei risultati più chiari (in particolare, il valore di entropia ottenuta è misurato in bit). Si nota molto facilmente che H(X) ≥ 0. L’entropia di una sorgente binaria con probabilità p ed 1 − p è: 1 1 + (1 − p) log2 . p 1−p Vale quindi 1 bit in caso di equiprobabilità dei risultati, e 0 bit nel caso in cui la sorgente sia completamente prevedibile (e cioè emetta sempre zeri o sempre uni). Tale risultato è ragionevole in quanto nel primo caso si afferma che è necessario un bit d’informazione per ogni messaggio emesso dalla sorgente, mentre nel secondo caso non è necessario alcun bit in quanto si conosce a priori il valore di tutti i messaggi e quindi la sorgente è del tutto inutile. Si dimostra che per l’entropia vale sempre la relazione: H(X) = p log2 H(X) ≤ log2 n. dove n la cardinalità dell’alfabeto di messaggi considerati (nel caso della moneta, n = 2). In analoga maniera si può parlare di entropia topologica di una sorgente, concetto sul quale non ci dilungheremo, ma che ha la seguente definizione. Definizione 1.8 Si definisce entropia topologica della sorgente X la quantità H(X) = lim sup n→∞ 8 1 log(sn ) n dove sn = |X ∩ An |, essendo An l’insieme delle stringhe di lunghezza n sull’alfabeto A (per convenzione assumiamo che log(0) = 0). Descriviamo adesso alcuni particolari algoritmi di compressione dati. 2 Algoritmi elementari di codifica Esaminiamo adesso alcuni algoritmi elementari di codifica che possono essere utili ai fini della compressione dati. 2.1 Eliminazione di ripetizioni Supponiamo di avere un testo s ad elementi in A che contenga un certo numero di ripetizioni di caratteri. All’interno di esso sostituiamo ogni sequenza di n lettere α con &αn dove & è un nuovo carattere, che ovviamente verrà omesso nel caso in cui |A| = 1, che sta ad indicare il fatto che la lettera che lo segue è ripetuta. In termini matematici ciò corrisponde a scrivere αn . Questo algoritmo è molto utile in quanto vengono eliminate molte ridondanze e quindi viene risparmiato spazio superfluo. Non sarà conveniente se la stringa considerata contiene poche ripetizioni, dal momento che occuperemmo 3 unità di memoria (&αn, una per ogni simbolo), ottenendo un output più lungo dell’input dato. In questo caso, quindi, non si potrebbe certo parlare di compressione. 9 2.2 Codifica differenziale La codifica differenziale viene adoperata comunemente per creare archivi di successive versioni di uno stesso file. Il file iniziale viene interamente memorizzato sul disco e per ogni versione successiva vengono registrate solo le differenze con le precedenti. Supponiamo di avere una sequenza (u1 , u2 , ..., un ) di dati, essa può essere rappresentata dalla sequenza di differenze (u1 , u2 − u1 , ..., un − un−1 ) dove “-” deve essere un’operazione appropriata. Un esempio può essere quello della memorizzazione di successive foto di uno stesso panorama. Lo spostamento delle foglie causato dal vento potrebbe essere l’unica differenza tra due di esse. Sarebbe, pertanto, inutile memorizzare in entrambi i file tutti i particolari delle foto, lo si potrebbe fare solo in uno di essi e nel secondo conservare le informazioni riguardo la differenza tra i due panorami. Un altro esempio di codifica differenziale può essere dato dall’esame delle righe di una pagina. Una prima codifica viene effettuata sulle righe consecutive: se la codifica binaria della n-esima di esse è 0111001 0101101 1010101 1010100 1010110 1010000 e quella della (n + 1)-esima è 0101001 0101101 0111010 1101010 1101110 0001000 la riga che deve essere trasmessa sarà 0010000 0000000 1101111 0111110 0111000 1011000. 10 Se in quest’ultima le sequenze di “1” vengono codificate tramite la loro posizione e la loro lunghezza all’interno della riga, otteniamo: (3, 1), (12, 2), (2, 4), (2, 5), (3, 3), (4, 1), (2, 2)... (il che significa che al 3◦ posto c’è un 1, successivamente dal 12◦ posto in poi ci sono due 1 successivi, ...). Pagine con righe diverse tra di loro porteranno, ovviamente, a coefficienti di compressione molto bassi; i migliori coefficienti verranno raggiunti nel caso di pagine interamente bianche (o nere). 2.3 Rappresentazione topografica Un altro algoritmo elementare è la rappresentazione topografica. A tal fine si utilizza uno sfondo uniforme e si codificano le posizioni di alcuni caratteri predominanti nel testo e diversi dal carattere che riempie lo sfondo. Assumiamo, ad esempio, che ad ogni carattere venga associata la sua codifica ASCII e che lo sfondo sia composto da “-” (cioè 00101101), allora la sequenza “ − e − e − −ee”, cioè 00101101 01100101 00101101 01100101 00101101 00101101 01100101 01100101 viene trasformata in 01010011 01100101, il che corrisponde a “Se” nel codice ASCII. La stringa 01010011 (“S”) determina la posizione della “e” all’interno dell’input dato, infatti troviamo un “1” nella posizione di ogni carattere diverso da “-”; in tal modo sarà molto semplice la decodifica. 11 2.4 Codifica a blocchi Gli algoritmi più comuni per comprimere un testo sfruttano la codifica di ogni simbolo in esso presente tramite una stringa di lunghezza dlogk (n)e (in cui n rappresenta la cardinalità dell’insieme sorgente e k quella del nuovo alfabeto). Questo algoritmo viene denominato “codifica a blocchi k-aria”. Un esempio si ha nel caso del codice ASCII, che trasforma ognuno dei 256 caratteri ASCII in una stringa binaria costituita da 8 cifre successive facenti parte dell’alfabeto {0, 1}; questo è, ovviamente, un esempio di codifica binaria a blocchi. 3 Compressione statistica, o probabilistica Agli algoritmi di codifica presentati nella sezione precedente si affiancano nella letteratura classica due grosse classi di compressori: gli algoritmi statistici si basano sulla probabilità dei singoli simboli dell’input di presentarsi all’interno dello stesso per poterlo comprimere; quelli linguistici, invece, comprimono una sequenza sostituendo al suo interno sottosequenze della stessa con puntatori a strutture dati chiamate dizionari. La ricerca nel campo della compressione sta facendo, però, notevoli passi avanti, il che ha portato a nuovi tipi di algoritmi di compressione dati, tra i quali quelli che fanno uso di “antidizionari”. In questa sezione approfondiremo gli algoritmi statistici. 3.1 Algoritmo di Huffman La Codifica di Huffman è un algoritmo greedy (un algoritmo greedy permette di ottenere una soluzione ottima di un problema prendendo in ogni punto di scelta dell’algoritmo la decisione che, al momento, appare la migliore localmente ottima) di codifica usato per la compressione lossless di dati. Il 12 suo inventore è David A. Huffman, che la presentò nell’articolo A Method for the Construction of Minimum-Redundancy Codes, pubblicato nel 1952. Nel 1951 a David Huffman, ai tempi dottorando in Teoria dell’Informazione al Massachusetts Institute of Technology (MIT), fu assegnata da Robert M. Fano una tesi sul problema di trovare il codice binario più efficiente per rappresentare una sorgente probabilistica. A Huffman venne l’idea di usare un albero binario ordinato in base alle frequenze dei singoli simboli emessi dalla sorgente e cosı̀ inventò un nuovo algoritmo di codifica che creava un codice a lunghezza variabile. Supponiamo di avere una stringa w ad elementi in un alfabeto Σ e di esaminarla carattere per carattere (o equivalentemente una sorgente probabilistica che emette simboli ognuno con una certa probabilità). Immaginiamo, inoltre, di conoscere per ogni carattere si di w la probabilità che esso compaia nella stringa w. Il criterio su cui si basa questo algoritmo è che ai simboli più probabili vengono associate le parole più brevi (foglie meno profonde nell’albero che verrà in seguito descritto) e a quelli meno probabili quelle più lunghe (foglie più profonde). Dunque, le codifiche ottenute avranno lunghezza variabile, esattamente ognuna di esse richiederà un numero intero di bits. Cosı̀ facendo, la lunghezza del testo codificato sarà ridotta al minimo e il codice ottenuto sarà prefisso, il che permetterà di ottenere la decodifica esatta (dal momento che nessuna codifica può essere prefisso di nessun’altra). Ad ogni carattere si dell’input w sull’alfabeto Σ viene associata una parola Huff(si ) (dove con Huff abbiamo indicato la funzione di codifica), sull’alfabeto {0, 1}, del codice prefisso da determinare. Se riscriviamo le parole Huff(si ) su un albero binario A, le foglie sono in corrispondenza biunivoca con le lettere. 13 Definizione 3.1 : Se nx è il numero di occorrenze del simbolo x all’interno di w (informazione che ci fornisce la probabilità che esso si presenti all’interno del testo), definiamo peso di A, la X nx d(fx ) dove d(fx ) rappresenta la distanza dalla radice della foglia associata alla lettera x. Il nostro scopo è, dunque, quello di determinare, dato un testo w, l’albero A di peso minimo. Algoritmo di Huffman (costruzione dell’albero di peso minimo) In tale algoritmo ricopre un ruolo fondamentale l’insieme N = {(wi , pi )/wi ∈ Σ∗ } di coppie costituite da sottostringhe di w e pesi delle sottostringhe. Analizziamo, dunque, l’algoritmo 1. Inizializziamo N = {(si , nsi )/si ∈ Σ, si ∈ w} con le lettere di w e i relativi numeri di occorrenze in w e ordiniamo i suoi elementi in ordine decrescente secondo i pesi/occorrenze; 2. Ripetiamo iterativamente fino a che la cardinalità di N non diventi 1 le seguenti operazioni: • consideriamo i due elementi di N di peso minimo e attribuiamo ai nodi corrispondenti uno stesso padre il cui peso sarà la somma di quelli dei nodi dati; • eliminiamo da N i due nodi del punto precedente e inseriamo un elemento rappresentante il padre (di peso la somma dei due pesi degli elementi eliminati) . 14 Dal momento che esiste la possibilità, dato un nodo, di scegliere arbitrariamente le codifiche dei suoi figli (0 al sinistro e 1 al destro o viceversa) e, a parità di peso, quale altro nodo scegliere per renderlo suo fratello, la codifica non sarà unica. Esempio: Sia w = abracadabra. Le occorrenze delle lettere sono: na = 5, nb = 2, nc = 1, nd = 1, nr = 2. Un albero creato dall’algoritmo è RADICE 0 1 5 6 0 5 1 2 4 0 5 2 1 2 2 0 5 a 2 b 1 2 1 1 r c d 15 Le parole di {0, 1}∗ associate alle lettere a, b, c, d, r sono, dunque: Huff(a) = 0, Huff(b) = 10, Huff(c) = 1110, Huff(d) = 1111, Huff(r) = 110 e quindi, la codifica di w sull’alfabeto {0, 1} sarà: Huff(w) = 0 10 110 0 1110 0 1111 0 10 110 0 a b r a c a d a b r a di lunghezza 23. Casi limite della codifica di Huffman sono collegati alla sequenza di Fibonacci. Infatti se le frequenze dei caratteri coincidono con i termini della successione di Fibonacci, l’albero risultante appare bilanciato tutto da una parte, quindi la lunghezza dei codici per ogni carattere, a partire da quello più frequente che sarà codificato con un bit, aumenterà di un bit per ogni carattere man mano che si scende nell’albero. Solo i due caratteri meno frequenti avranno un codice della stessa lunghezza, differente soltanto per l’ultimo bit. Vediamo adesso la decodifica. L’algoritmo di Huffman è lossless, quindi è caratterizzato da una funzione iniettiva. Il fatto che il codice creato è prefisso fa sı̀ che la suddivisione di Huff(w) in codifiche di simboli, e dunque la decodifica, sia unica. Il principale difetto dell’algoritmo di Huffman è che il testo in input deve essere letto due volte: la prima per calcolare le frequenze delle lettere, e la seconda per codificare il testo. Si potrebbe risolvere il problema qualora si considerassero delle frequenze medie; in questo modo, però, l’algoritmo non assicurerebbe, ovviamente, la lunghezza minima del testo codificato. Un’alternativa per risolvere il problema è data dall’algoritmo adattativo di Huffman che costruisce l’albero e lo modifica man mano che legge il testo. 16 3.2 Codifica di Shannon-Fano Il primo algoritmo famoso di codifica di simboli è stato introdotto alla fine degli anni ’40 contemporaneamente da Shannon nei laboratori Bell e da Fano al M.I.T. Analogamente a quanto visto per la codifica di Huffman, questo è un algoritmo statistico che si basa sulla probabilità di ogni simbolo di presentarsi all’interno del messaggio. Le codifiche ottenute saranno tali che: • la codifica di ogni simbolo sarà differente da quella di ogni altro, dunque la decodifica sarà unica; • codifiche di simboli con bassa probabilità occuperanno più bits, viceversa codifiche di simboli con alta probabilità richiederanno un numero minore di bits. Algoritmo di Shannon-Fano (costruzione dell’albero di Shannon-Fano) L’algoritmo di Shannon-Fano e quello di Huffman sono molto simili: differiscono solo nella costruzione dell’albero. Infatti, mentre l’albero di Huffman viene costruito a partire dalle foglie per poi risalire alla radice (“bottomup”), quello di Shannon-Fano viene, invece, costruito dall’alto verso il basso (“top-down”). Nonostante siano simili, però, l’algoritmo di Huffman è preferibile perché porta a risultati migliori. Descriviamo allora questo nuovo algoritmo: • Data una lista di simboli, costruiamo una tabella delle probabilità per ognuno di essi contando le loro occorrenze nel testo; • ordiniamo in maniera decrescente secondo le probabilità i simboli di cui sopra; • creiamo un albero costituito dalla sola radice, associato alla tabella intera; 17 • ripetiamo iterativamente, finché ad ogni simbolo non corrisponda una foglia dell’albero associato alla tabella, le tre operazioni seguenti: i) suddividiamo la tabella data in due sottotabelle in modo tale che la somma delle occorrenze dei simboli della prima sia il più possibile vicina a quella della seconda; ii) concateniamo alle codifiche dei simboli della prima sottotabella la codifica binaria 0, a quelli del secondo 1 e in corrispondenza creiamo due figli del nodo associato alla tabella, il figlio sinistro associato alla prima sottotabella, il figlio destro alla seconda; iii) applichiamo la i) alle nuove tabelle. Dal momento che abbiamo per ben due volte la possibilità di fare delle scelte arbitrarie, nella suddivisione di ogni tabella e nell’assegnazione delle etichette ai rami (0 al sinistro e 1 al destro o viceversa), la codifica di Shannon-Fano non porta ad un unico risultato. Esempio: Consideriamo lo stesso esempio di sopra, cioè s = abracadabra. Esaminando le occorrenze, e quindi le probabilità, delle singole lettere di s, ed ordinandole decrescentemente, otteniamo Simboli Probabilita‘ a b r c d 5 2 2 1 1 Consideriamo adesso la i) e la ii): applicandole una prima volta otteniamo 18 Prima Suddivisione Simboli Probabilita‘ a 5 0 b r c 2 2 1 1 1 d 1 1 1 ed iterativamente, tramite la iii), la tabella seguente. Prima Suddivisione Terza Suddivisione Seconda Suddivisione Quarta Suddivisione Simboli Probabilita‘ Codifiche a 5 0 b 2 100 r 2 101 c 1 110 d 1 111 L’albero relativo alla codifica di Shannon-Fano appena effettuata di s = abracadabra è 19 RADICE 0 1 a 0 0 b 1 1 0 r c 1 d Le parole di {0, 1}∗ associate alle lettere a, b, c, d, r sono: SF (a) = 0, SF (b) = 100, SF (c) = 110, SF (d) = 111, SF (r) = 101. La codifica di s sull’alfabeto {0, 1} è, dunque: SF (s) = 0 100 101 0 110 0 111 0 100 101 0 a b r a c a d a b r a di lunghezza 23. In generale, Huffman porta a risultati di gran lunga migliori di quelli ottenuti con l’applicazione di Shannon-Fano. 3.3 Codici aritmetici Negli anni ’80 sono stati introdotti nuovi tipi di algoritmi statistici, i codici aritmetici, che hanno ovviato ad alcuni problemi che si presentavano con 20 l’algoritmo di Huffman. Infatti, come vedremo, i codici aritmetici codificano simboli, o gruppi di simboli, con numeri decimali, e quindi permettono di adoperare per codifica numeri frazionari di bits. Il codice di Huffman, invece, associa un intero ad ogni elemento della sorgente, il che porta ad un dispendio eccessivo in termini di memoria. Se esaminiamo un solo simbolo, ovviamente, i risultati ottenuti con i due diversi algoritmi saranno molto simili; ma una volta che esaminiamo un testo lungo, la somma degli arrotondamenti (tali si possono considerare i bits adoperati dalla codifica di Huffman rispetto a quelli dei codici aritmetici) per i singoli simboli porta ad una differenza netta con i risultati ottenuti con i codici aritmetici. Descriviamo adesso questi nuovi algoritmi; essi si basano sul fatto che, una volta note le probabilità dei singoli simboli di un alfabeto A di comparire in una stringa data, si può suddividere l’intervallo [0, 1) in tanti suoi sottointervalli, in modo tale che ad ogni stringa di elementi dell’alfabeto sia associato uno di essi. Grazie a questo algoritmo sia l’encoder che il decoder lavorano on-line. Infatti, esaminando la stringa l’encoder può direttamente considerare carattere per carattere ed associare delle codifiche ad ogni gruppo di simboli esaminati; il decoder a sua volta, riuscirà a risalire immediatamente alla decodifica. Per chiarire meglio il concetto facciamo un esempio. Esempio: Supponiamo di non considerare la stringa, già più volte esaminata, abracadabra, ma per semplicità una sua sottostringa s = abracadabr, in modo da potere suddividere più facilmente l’intervallo [0, 1) e da rendere cosı̀ maggiormente comprensibile l’esempio stesso. Le probabilità delle singole lettere saranno: na = 4, nb = 2, nc = 1, nd = 1, nr = 2. 21 Considerando solo alcune delle possibili concatenazioni di lettere, otteniamo la seguente tabella, nella quale è stato dato un particolare ordine ai caratteri, ma bisogna sottolineare che questo può essere cambiato in qualsiasi altra maniera. Sottostringhe a b c d r aa ab ac ad ar 4 Probabilità [0,.4) [.4,.6) [.6,.7) [.7,.8) [.8,1) [0,.16) [.16,.24) [.24,.28) [.28,.32) [.32,.4) I compressori linguistici Gli algoritmi di Lempel e Ziv si inquadrano tra quegli algoritmi di compressione che analizzano le proprietà “strutturali” o “linguistiche” del testo (cioè il fatto che certe concatenazioni di caratteri possano comparire, mentre altre non sono presenti) per potere ridurre le dimensioni dello stesso. Questi tipi di codifica che introdurremo ora si basano sulla possibilità di effettuare sostituzioni all’interno della stringa. Il principio su cui ci si basano è che possano esistere delle sottostringhe dell’input che si ripetono, e quindi che si possa sostituire ognuna di esse con un puntatore (chiamato codeword) alla posizione in cui questa stessa si è presentata per la prima volta all’interno del testo. L’output dell’algoritmo descritto, quindi, sarà una successione di caratteri dell’alfabeto sorgente, intervallati da alcune codewords. Dal momento che si potrebbe presentare anche la possibilità di avere come input 22 un singolo carattere, dobbiamo essere in grado di potere rappresentare anche questo con un puntatore, quello al carattere stesso. Generalizzando, possiamo assumere che l’output di una qualsiasi stringa sia una successione di codewords (si presenteranno due diversi tipi di puntatori: quelli a stringhe e quelli ai caratteri stessi da cui sono richiamati). Un altro modo di rappresentare l’output è quello di creare una lista di stringhe, o dizionario, rappresentante i singoli fattori in cui viene suddiviso l’input dato. Tra gli algoritmi che sfruttano le sostituzioni, l’algoritmo di Lempel e Ziv è, senza dubbio, uno dei più importanti nel campo della compressione ed ha riscosso successo soprattutto per le sue applicazioni. Più precisamente le sue numerose varianti costituiscono probabilmente l’algoritmo in uso più diffuso (tra di esse vi è, per esempio, la utility “compress” del sistema UNIX). Esistono due algoritmi di Lempel e Ziv, uno risalente al 1977, LZ77, ed uno al 1978, LZ78. A queste ne va affiancato un terzo, LZW, che rappresenta l’implementazione da parte di Terry Welch di LZ78. Va, infine, tenuto conto delle altre versioni introdotte da numerosi studiosi per migliorare sempre più i risultati ottenuti. 4.1 LZ77: una compressione sliding window Nel 1977 Lempel e Ziv pubblicarono un articolo, A Universal Algorithm for Sequential Data Compression nel quale introducevano un nuovo algoritmo di compressione dati, LZ77. La struttura principale di questo algoritmo è una “finestra di testo” (chiamata sliding window per i motivi che adesso vedremo) di piccole dimensioni, suddivisa in due parti. La prima di esse rappresenta il corpo principale ed è costituita da un insieme, la cui cardinalità dipenderà dall’ampiezza della sliding window, di elementi di s recentemente codificati. La seconda, invece, di dimensioni notevolmente minori, è un look-ahead buffer, cioè una fine- 23 stra contenente alcuni caratteri non ancora esaminati di s, immediatamente successivi a quelli del corpo principale, per la codifica dei quali è necessario “guardare indietro” nella sliding window. In genere la prima contiene al suo interno alcune migliaia di caratteri, il look-ahead buffer, invece, da 10 a 100. Scopo dell’algoritmo è quello di codificare s facendo scorrere la sliding window all’interno di esso. Più precisamente, noi esaminiamo il testo in input poco per volta, considerando la finestra di testo che man mano si sposta lungo lo stesso, e cerchiamo l’esistenza nel corpo principale, che rappresenta il dizionario, di una stringa che combaci con almeno una parte di quella contenuta nel look-ahead buffer. In tal modo otteniamo una codifica data dalla tripla: a) posizione all’interno del corpo principale del primo carattere della più lunga sottostringa comune (essa potrà avere anche lunghezza 0); b) lunghezza della sottostringa di cui al punto a); c) carattere ad essa successivo nel look-ahead buffer. Esempio: Supponiamo di considerare s = abracadabra abracadabra abracadabra, e che ad un certo istante t la finestra sia quella descritta dalla figura seguente. abracadabra abracadabra abra corpo principale Look-Ahead Buffer 24 Notiamo che la stringa ’ abra’ è interamente contenuta nel dizionario e la codifichiamo tramite la tripla (12,5,”). Infatti la posizione nel corpo principale del carattere iniziale della prima occorrenza della stringa data è 12, la sua lunghezza è 5 ed infine non abbiamo caratteri aggiuntivi, in quanto il look-ahead buffer contiene solo questa sottostringa di s. Dopo avere effettuato questa codifica la finestra si sposta esattamente di tanti posti quanto è la lunghezza della stringa codificata (nel nostro caso 5) e viene esaminata una nuova stringa rispetto ad un nuovo dizionario. Otteniamo, dunque, adabra abracadabra abra corpo principale 4.2 cadab Look-Ahead Buffer Problemi derivanti dall’uso di LZ77 L’algoritmo LZ77, però, andava incontro a vari problemi derivanti dalla limitatezza della sliding window. Più esattamente, l’avere imposto che il corpo principale, cioè il dizionario, fosse limitato faceva sı̀ che una parola ripetuta spesso all’interno di un testo molto lungo, come può essere nel nostro caso il termine “compressione”, poteva essere richiamata raramente proprio perché raro era il caso in cui si potevano trovare due occorrenze della stessa vicine tra di loro. Un altro problema era correlato all’ampiezza limitata del lookahead buffer; per questo motivo non si potevano avere puntatori a parole lunghe, anche quando se ne fosse presentata l’occasione. Esempio 25 Supponiamo di riconsiderare l’esempio del paragrafo precedente abracadabra abracadabra abracadabra e di aggiungere dopo il look-ahead buffer qualche altro carattere successivo di s. abracadabra abracadabra corpo principale abra cadabra Look-Ahead Buffer In questo caso, anche se fosse possibile richiamare l’intera parola abracadabra, dovremmo considerare solo la sua sottostringa abra, il che limita di gran lunga i risultati ottenuti e porta a coefficienti non del tutto esaurienti. (Ovviamente in caso di testi all’interno dei quali abbiamo poche ripetizioni LZ77 conserva ancora tutta la sua efficienza). Si potrebbe pensare di risolvere il problema aumentando le dimensioni della finestra, sia del dizionario che del buffer, ma anche questo porta delle difficoltà. Se noi aumentiamo l’ampiezza del dizionario, per esempio, da 4K a 64K, abbiamo bisogno di 4 bits suppletivi per codificare la posizione della stringa richiamata; esattamente invece di 12 bits dovremo utilizzarne 16 (ricordiamo che 212 = 4096 e 216 = 65536). Inoltre, l’aumento di dimensione del buffer da 16 bits a 1K comporta l’uso non più di 4 ma di 10 bits per codificare la lunghezza di una frase (24 = 16, 210 = 1024). Tutto ciò porta, quindi, ad un incremento del costo necessario per codificare una stringa da 17 a 27 bits, il che provoca un notevole aumento del coefficiente di compressione, e quindi una corrispondente diminuzione dell’efficienza dell’algoritmo dato. 26 4.3 LZ78: un nuovo algoritmo Di tutti i problemi derivanti da LZ77 si resero conto gli stessi Lempel e Ziv che nel 1978 pubblicarono un articolo, Compression of Individual Sequences via Variable-Rate Coding, nel quale illustravano un nuovo algoritmo, LZ78, molto simile a LZ77 ma differente da esso nella descrizione del dizionario (questa versione rappresenta una compressione universale ottimale su sorgenti fattoriali). LZ78 abbandona il concetto della finestra di testo: se LZ77 teneva conto di un dizionario di stringhe definite all’interno di una finestra fissata e finita, con LZ78 il dizionario diventa un insieme potenzialmente illimitato di stringhe. Questo nuovo algoritmo somiglia a quello precedente in molti suoi aspetti: per esempio LZ77 dava in output un insieme di triple (la posizione della precedente occorrenza della stringa, la lunghezza della stessa e il carattere ad essa seguente); un medesimo output viene dato da LZ78, facendo eccezione per la lunghezza dell’espressione che non è necessaria dato che il decoder già la conosce. Le uniche differenze tra i due algoritmi si riscontrano proprio in quei punti essenziali a far sı̀ che LZ78 sia molto più efficiente di LZ77, cioè che dia risultati migliori. Infatti, invece che una sliding window di dimensione prefissata, LZ78 utilizza un dizionario la cui dimensione cresce man mano che viene esaminato il testo. Le stringhe del dizionario saranno richiamate ogni qualvolta sarà necessario ed inoltre in qualsiasi momento, anche dopo avere esaminato milioni di ulteriori caratteri (contrariamente a quanto avveniva per LZ77 con il quale una stringa veniva richiamata solo se ad una piccola distanza dal look-ahead buffer). Esaminiamo adesso più da vicino questo nuovo algoritmo. La funzione totale LZ : A∗ → 2∗ lavora nel modo seguente: considerata una qualsiasi parola w ∈ A∗ , per essa troviamo una fattorizzazione unica definita da: 27 n ∈ IN + , w = w0 w1 · · · wn in modo che: • w0 sia la parola vuota; • per ogni i tale che 1 ≤ i ≤ (n − 1), wi è il più lungo prefisso di wi · · · wn che sia possibile scrivere nella forma wi = wji ai , con ai ∈ A e ji < i; • wn = wjn , con jn < n. Dunque il primo fattore deve essere vuoto ed ogni altro, tranne l’ultimo, deve essere costituito da un fattore precedentemente incontrato seguito da un elemento di A. È ovvio che bisogna scegliere una giusta struttura dati per rappresentare tale dizionario in maniera tale che l’algoritmo risulti efficiente. Tra le possibili rappresentazioni in memoria del dizionario, si può utilizzare un albero A(w) in cui vengono immesse le parole wi . In tale albero i rami sono etichettati da elementi di A tali che figli dello stesso padre siano collegati ad esso tramite rami aventi labels diverse. In particolare per la codifica del dizionario di LZ78 possiamo utilizzare un patricia tree, avente al più 256 (quanti sono i caratteri ASCII) figli per ogni nodo. Codificare il testo nel suo corrispondente binario prima di comprimerlo può essere utile non solo per riuscire ad ottenere una quantità maggiore di ripetizioni, ma anche per potere semplificare la struttura dati utilizzata per rappresentare l’albero, che cosı̀ diventa binario. Ogni fattore wi corrisponde ad un nodo di A(w) e, labellandolo con i, si può facilmente vedere che la condizione di iniettività richiesta ad ogni lossless data compression viene verificata (basta considerare l’applicazione w → (A(w), jn )). 28 Per effettuare l’operazione di decodifica, bisogna ricostruire A(w) cosı̀ come lo si costruisce in fase di codifica e da questo si ottengono i fattori wi di w. Dunque l’output deve essere tale da potere effettuare una ricostruzione sincronizzata dell’albero dato. Descriviamo adesso l’output del nostro algoritmo. Ogni fattore wi , 1 ≤ i ≤ (n − 1), viene trasmesso tramite la coppia (ji , ai ), mentre wn tramite jn . Dal momento che per trasmettere ji abbiamo bisogno di dlog(i)e bits (necessari e sufficienti per specificare uno qualsiasi tra 0, 1, ..., i − 1), e che il carattere ai può trasmettere se stesso, allora la lunghezza dell’output dipenderà esclusivamente dal numero (n − 1) di rami di A(w). Otterremo quindi, per un’opportuna costante K: | LZ(w) |= n X dlog(i)e + K(n − 1). i=1 Tenuto conto che dlog(i)e ≤ 1 + log(n − 1)1 , otteniamo: | LZ(w) |≤ n[1 + log(n − 1)] + K(n − 1) ≤ (n − 1) log(n − 1) + K 0 (n − 1). con K 0 costante opportuna dipendente da A, costante che può essere anche negativa. Dunque, comunque sia fatto questo albero A(w), l’output dipende solamente da n − 1 numeri di lunghezza predeterminata (al più dlog(n − 1)e) e da n − 1 caratteri di A. Tenuto conto, però, che il termine dominante nella somma è il primo, notiamo che K 0 non è influente nel calcolo del coefficiente di compressione. 1 La diseguaglianza dlog(i)e ≤ 1 + log(n − 1) vale perché: • per ogni i ≤ (n − 1) risulta dlog(i)e ≤ 1 + log(i) ≤ 1 + log(n − 1), • dlog(n)e ≤ 1 + log(n − 1) 29 Analizziamo adesso la lunghezza dell’input w in funzione dell’albero costruito a partire da esso. Ogni fattore wi influisce su | w | esattamente in misura pari alla profondità del vertice i, denotata con height(i), nell’albero dato. Otterremo, pertanto: | w |= height(jn ) + n−1 X height(i). i=1 P Il termine dominante della somma soprascritta, n−1 i=1 height(i), dipende esclusivamente dalla forma dell’albero e viene denominato internal path length dell’albero dato. LZ78, inoltre, rappresenta una compressione continua. Per definire rigorosamente la continuità conviene collocarsi nell’ambito delle parole infinite. Denotiamo con Aω l’insieme delle parole infinite su A. La metrica rispetto a cui la compressione risulta continua è quella del “più lungo prefisso comune”. Definiamo questa metrica d nel seguente modo: date due parole x e y appartenenti ad Aω diremo che: • d(x, y) = 0, se le due parole sono uguali; • d(x, y) = 21a , se x coincide con y solo nei primi a caratteri, ovvero se esistono x0 prefisso di x e y 0 prefisso di y, tali che | x0 |=| y 0 |= a, con x0 e y 0 i più lunghi prefissi rispettivamente di x e di y soddisfacenti tale proprietà. 30 x’ x y’ y Esempi: 1. Se x e y sono due parole infinite uguali, allora d(x, y) = 0; 2. se x e y, invece, coincidono per intero tranne che nel primo carattere, allora a = 0 e quindi d(x, y) = 1. Proviamo adesso che la funzione LZ è continua rispetto alla distanza appena definita. Ciò equivale a dimostrare che: lim LZ(x) = LZ(y) x→y Ma che significa che x → y? Significa che d(x, y) → 0, cioè che, se x0 è il più lungo prefisso comune a x e y, 2|x1 0 | → 0, quindi che | x0 |→ ∞, cioè ancora che x e y coincidono tranne che in al più un numero finito di caratteri finali, quindi gli alberi LZ(x) e LZ(y) coincidono tranne che in al più un numero finito di rami, da cui LZ(x) → LZ(y). Dunque, otteniamo la seguente: Proposizione 4.1 LZ : A∗ → 2∗ è una compressione continua. 31 Sia w ∈ A∗ tale che A(w) abbia a rami, sia I l’internal path length di A(w) e sia τ il coefficiente di compressione2 di LZ, allora: τ (w) ≤ alog(a) + Ka , I con K costante dipendente da A. Riassumiamo adesso il procedimento adottato da LZ78 per potere fare lo stesso esempio fatto per LZ77 e fare, quindi, i debiti paragoni. Il dizionario viene inizializzato con la stringa vuota ²; man mano che si va avanti nel testo viene aggiornato con nuove stringhe, date dalla concatenazione di una “frase” precedente (la più lunga stringa comune al testo ed al dizionario, essa potrebbe essere anche la prima stringa, quella vuota) con un nuovo carattere. Il processo di codifica, ovviamente, termina non appena il cursore che si muove all’interno del testo arriva alla fine dello stesso. Questo è ciò che dà in output anche LZ77: la posizione all’interno della finestra della precedente occorrenza dell’espressione data e il carattere aggiuntivo. LZ78 fa un ulteriore passo, aggiunge questa nuova stringa al dizionario. Talvolta, però, LZ77 può risultare più efficiente; è il caso in cui abbiamo un testo molto corto con ripetizioni di frammenti dello stesso molto vicine tra di loro. È il caso, però, anche di un testo lungo all’interno del quale non si abbiano ridondanze, o comunque se ne presentino poche. In quest’ultima situazione LZ78 adopera un dizionario di notevoli dimensioni senza che esso sia utilizzabile, ma solo sciupando una notevole quantità di memoria; LZ77, al contrario, fa uso, come dizionario, di una piccola finestra che scorre all’interno del testo e per la quale, quindi, non si fa un uso spropositato di memoria. Si riscontra, pertanto, una notevole differenza tra i risultati ottenuti, dal momento che il dizionario di LZ78 non viene utilizzato quasi mai, ma apporta solo un 2 coefficiente definito come τ (w) = |LZ(w)| |w| 32 dispendio in termini di memoria e di tempo di calcolo (infatti dobbiamo ricordare che la parte di testo da codificare deve essere confrontata con ogni singola stringa del dizionario). ESEMPIO: Facciamo lo stesso esempio di sopra: abracadabra abracadabra abracadabra Nel caso di compressione mediante l’utilizzo di LZ78 otteniamo la tabella seguente, rappresentante per ogni stringa del dizionario il modo in cui la si è ottenuta. POSIZIONE NEL DIZIONARIO DELLA STRINGA RICHIAMATA CARATTERE SUPPLETIVO 0 0 0 1 1 1 3 0 6 4 0 9 8 2 10 12 a b r c d b a ’ ’ r a d a a r d ε 33 DIZIONARIO POS. STR. 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ε ’a’ ’b’ ’r’ ’ac’ ’ad’ ’ab’ ’ra’ ’ ’ ’abr’ ’aca’ ’d’ ’abra’ ’ a’ ’br’ ’acad’ Per quanto riguarda, invece, l’albero della figura seguente, ho voluto mostrare qualche fase della costruzione del dizionario, evidenziando quella di inizializzazione (I) ed alcune di aggiornamento (II dopo un passo, III dopo due, IV dopo 6). a b a a c I II III d b r b IV Alla fine dell’algoritmo l’albero rappresentante il dizionario sarà 34 a c 4.4 d b a r d a b a r d ’’ r a LZW Nel 1984 Terry Welch pubblicò un articolo, A Technique for High-Performance Data Compression, nel quale illustrava un nuovo algoritmo, LZW, basato su LZ78. Questo algoritmo si basa sulla costruzione di una tabella, dizionario, che contiene stringhe e trasforma sequenze di caratteri dell’input dato in codici di lunghezza fissata. Questa tabella, che viene costruita man mano che il testo viene esaminato, ha una proprietà molto importante: il prefisso di ogni stringa in essa contenuta fa parte ancora di essa. Ciò significa che se nel dizionario troviamo wa, con w stringa ed a carattere aggiuntivo, allora vi sarà anche w. Per fare ciò il dizionario deve essere inizializzato con tutti i simboli dell’alfabeto. Ovviamente grazie a questo algoritmo si risparmia molto 35 tempo, però si presenta il problema per cui alcuni simboli poco diffusi occupano memoria senza essere necessari e il patricia tree assume, cosı̀, dimensioni notevoli senza motivo. Se consideriamo tutti i caratteri ASCII, infatti, il dizionario contiene, già prima di iniziare la compressione, ben 256 stringhe e la prima posizione possibile all’interno dello stesso sarà la 257-esima. Si potrebbe ovviare a questo nuovo problema eliminando dal dizionario originale esattamente quei simboli che non appariranno all’interno del testo in input. Ricordiamo, inoltre, che se un dizionario di 256 stringhe comporta l’uso di 8 bits, uno che ne contenga un numero compreso tra 257 e 512 impiegherà 9 bits e se facciamo un esame in termini di bytes, basta avere anche una sola stringa in più, cioè 257, per avere bisogno non più di un byte, ma di due, spreco eccessivo soprattutto per un algoritmo di compressione. Questo è forse l’unico difetto di LZW. Esempio: Consideriamo sempre lo stesso esempio: abracadabra abracadabra abracadabra. Le nuove stringhe possono essere racchiuse nella tabella seguente. 36 NUOVE STRINGHE DEL DIZIONARIO 256 = ’ab’ 257 = ’br’ 258 = ’ra’ 259 = ’ac’ 260 = ’ca’ 261 = ’ad’ 262 = ’da’ 263 =’abr’ 264 = ’ra’ 265 = ’ a’ 266 =’abra’ 267 = ’aca’ 268 = ’ada’ 269 =’abra’ Ricordo, però, che questa non coincide con il dizionario; infatti, per ottenere il risultato finale dobbiamo aggiungere al risultato ottenuto le stringhe iniziali, cioè quelle coincidenti con i singoli caratteri. Saranno perciò necessari 9 bits. La scoperta di Welch sta nell’avere limitato la quantità di bits da usare per codificare un qualsiasi testo. Lempel e Ziv, infatti, da “buoni matematici” avevano ipotizzato di potere usare un numero infinito di bits, cosa impossibile dato che il computer è una macchina fisica e quindi finita. Welch, invece, prova che sono sufficienti 13 bits. Infatti, non appena il dizionario contiene 8191 (213 = 8192) stringhe procediamo nel seguente modo: eliminiamo le codifiche non adoperate e shiftiamo quelle rimaste in modo da lasciare spazio libero alla fine della tabella; se cosı̀ non può essere, cioè tutte le codifiche sono state utilizzate, consideriamo quelle richiamate più recentemente e più 37 frequentemente ed eliminiamo le altre. In questo modo otterremo il risultato di potere utilizzare una quantità finita di bits, esattamente al più 13, per codificare un testo di dimensioni qualsiasi, paradossalmente anche infinite. 38