Julien Clément (GREYC, Caen)
Remerciements à Véronique Terrier pour les emprunts et son aide
Quelques liens utiles
Rechercher un motif dans un texte, indexer des données textuelles, expliciter les régularités d'un texte sont des problèmes omniprésents en informatique
→
éditeur de texte, moteur de recherche, bases de données textuelles, analyse de séquences biologiques, compression
UTF-8
Depuis : 87,6 % en 2016, 90,5 % en 2017 et près de 93.1% en février 2019
(usage pour le web - sources : wikipedia)
Un préfixe, suffixe ou facteur d'un mot $w$ est propre, s'il est différent de $w$ lui-même.
Soit $w = abbaac$, on a
Trouver les occurrences d'un motif $u\in \Sigma^+$ dans un texte $t\in \Sigma^+$
Quel type d'occurrences? chevauchantes ? non-chevauchantes ?
Je n'aborde pas ici les techniques classiques de théorie des automates
On lit le texte au travers d'une fenêtre coulissante de la taille du mot.
Positionner la fenêtre au début du texte
Tant que la fenêtre est sur le texte
Si fenêtre == motif alors /* Balayage */
Signaler occurrence
Décaler la fenêtre /* Décalage */
def naive_search(u, t):
m = len(u)
n = len(t)
i = 0
j = 0
while i < n-m+1:
while j < m and u[j] == t[i+j]: # scan
j = j+1
if j == m:
signal_occurrence(i) # signaler une occurrence à la position i
i = i+1 # shift
j = 0
→ $24$ comparaisons
Quel est le nombre maximal de comparaisons de l'algorithme naïf avec un texte de longueur $n$ et un motif de longueur $m$ ?
$(n-m+1)\times m$, i.e., une complexité au pire en $\color{red}O(nm)$ → quadratique !
Le coût moyen est linéaire en la taille du texte, i.e., en $O(n)$
Calculer un "bon" décalage suite à un échec à la position $i$ :
→ notion de bord
Pour tout mot $u\in \Sigma^+$ et tout caractère $a\in \Sigma$ :
\[ \text{Bord}(ua)= \begin{cases} \text{Bord}(u)a &\text{si $\text{Bord}(u)a$ est un préfixe de $u$}\\ \text{Bord}(\text{Bord}(u)a)&\text{sinon} \end{cases} \]def Bord(u):
m = len(u)
TB = [0 for i in range(m+1)]
j = -1
for i in range(0,m):
TB[i] = j
while(j >= 0 and u[j] != u[i]):
j = TB[j]
j = j+1
TB[m] = j
return TB
La complexité au pire est linéaire en la taille $m$ du mot
Complexité est linéaire en le nombre de comparaisons entre lettres
$\approx$ Nombre total de fois où la condition du while
est examinée
def Bord(u):
m = len(u)
TB = [0 for i in range(m+1)]
j = -1
for i in range(0,m):
TB[i] = j
while(j >= 0 and u[j] != u[i]): # comparaisons effectuées ici !!!
j = B[j]
j = j+1
TB[m] = j
return TB
(Preuve typique) La quantité $\Delta(i, j) = 2i-j$ augmente au moins de 1 à chaque comparaison
$16$ comparaisons
(À chaque étape, il est inutile de tester les $B[i]$ premiers caractères du motif; $TB[0] =-1$ permet de toujours décaler au moins de 1)
def findMP(u, t, TB=None):
m = len(u)
n = len(t)
if TB is None:
TB = Bord(u)
i, j = 0, 0
while i < n-m+1:
while j < m and u[j] == t[i+j]:
j = j+1
if j == m:
signal_occurrence(i)
i = i + j - TB[j] # décalage calculé avec la table des bords
if j != 0:
j = TB[j] # pas la peine de comparer les symboles déjà appareillés
→ Décalage : j-TB[j]
La complexité au pire est linéaire en $m+n$, somme des tailles du mot et du texte
Calculer un « bon » décalage suite à un échec à la position $i$ :
→ notion de bord strict
« Le bord strict maximal » de $v$ (relativement à $w$), noté $\text{BordStrict}(v, u)$, est le plus long bord strict de $v$ quand il existe.
Remarque : Le bord strict n'est pas toujours défini.
Par exemple, le préfixe $ab$ n'admet pas de bord strict relativement à $aba$
On calcule facilement la table des bords stricts à partir de celle des bords \[ SB[0]= -1, \qquad SB[|w|]= TB[|w|], \] et pour $0\lt i \lt |w|$, on a \[ SB[i]= \begin{cases} TB[i]&\text{si $w[i]\neq w[TB[i]]$}\\ SB[TB[i]]&\text{sinon} \end{cases} \]
$w = ababa$
Entrée : Un mot \$u$ de longueur $m$ et un texte $t$ de longueur $n$
i = i+j-TB[j]
→ i = i+j-SB[j]
Mais alors ?
Utile si on veut un algo à faible délai entre deux caractères traités (applications temps réel)
Recherche : On applique l'automate qui reconnaît les suffixes du miroir sur les lettres de la fenêtre lue de droite à gauche
$u=bababb$
Dans le pire des cas : l'algorithme original e complexité en temps en $O(nm)$ si le motif est présent, $O(n+m)$ si le motif n'est pas présent
Très efficace en pratique (et compatible avec le codage UTF-8)
Cela fonctionne «bien» (sous-linéaire) lorsque l'alphabet est grand
Un cas important : le motif recherché est un ensemble (fini) de mots (finis)
On peut toujours effectuer la recherche un à un de chaque mot de l'ensemble mais ça ne va pas être efficace.
Si l'on note $m=|u_1|+\dots+|u_k|$ la taille de l'ensemble $X$ et $n$ la taille du texte, on souhaiterait des algorithmes linéaires en $m+n$
Une structure de donnée adéquate pour représenter un ensemble de mots
À chaque nœud de l'arbre correspond le mot formé des caractères qui étiquettent le chemin de la racine à ce nœud.
Les feuilles et certains nœuds internes sont marqués, ils représentent les mots de l'ensemble.
Problème : L'automate est complet → beaucoup de transitions inutiles/redondantes
L'automate de Aho-Corasick associé à un ensemble de mots $X$ est décrit par :
Important Cet automate est aussi important conceptuellement : il représente l'ensemble des corrélation entre les mots
$f(u)$ est le plus long suffixe propre de $u$ dans $\text{Pref}(X)$
Remarque : La fonction de suppléance pour un mot seul correspond aux bords maximaux
Comment retrouver les transitions $\delta(p,x)$ de l'automate arbre avec la fonction de suppléance $f$ ?
La définition \[ \delta(p,x)= \begin{cases} px&\text{si $px\in \text{Pref}(X)$}\\ \text{le plus long suffixe propre de $px \in \text{Pref}(X)$}&\text{sinon} \end{cases} \] se traduit en \[ \delta(p,x)= \begin{cases} px&\text{si $px\in \text{Pref}(X)$}\\ \delta(f(p),x)&\text{si $p\neq\varepsilon$ et $px\notin \text{Pref}(X)$}\\ \varepsilon&\text{si $p=\varepsilon$ et $x\notin \text{Pref}(X)$} \end{cases} \]
Pour un texte $t$ de longueur $n$, le chemin parcouru est de longueur au plus $2n$
À partir de l'ensemble des mots $X$, on doit construire :
La fonction de suppléance $f$ et la fonction de transition $\delta$ ont des définitions croisées : \begin{align*} f(ux)&=\left\{\begin{array}{ll} \delta(f(u),x)&\text{si }u\neq\varepsilon\\ \varepsilon&\text{si $u=\varepsilon$ (par convention)} \end{array}\right.\\ \delta(p,x)&=\left\{\begin{array}{ll} px&\text{si }px\in \text{Pref}(X)\\ \delta(f(p),x)&\text{si }p\neq\varepsilon\text{ et }px\notin \text{Pref}(X)\\ \varepsilon&\text{si }p=\varepsilon\text{ et }x\notin \text{Pref}(X) \end{array}\right. \end{align*} Grâce à un parcours en largeur de l'arbre : tout ce qu'il faut en temps utile pour construire $f$ → Pré-calcul en $O(n)$
Remarque : la fonction de suppléance peut être «optimisée» (idem bord vs bord strict)
Déchiffrer grâce aux fréquences des lettres : «A good glass in the bishop's hostel in the devil's seat twenty-one degrees and thirteen minutes northeast and by north main branch seventh limb east side shoot from the left eye of the death's-head a bee line from the tree through the shot fifty feet out.»
→ L'intérêt pour les occurrences dans les textes a une longue histoire
Trouver les occurrences d'un motif $u$ dans un texte $t$, lorsque le texte $t$ est fixe.
statistique sur le texte, trouver des répétitions ou des régularités/irrégularités, compression du texte...
Différentes façons de représenter l'index d'un texte :
La structure construite une fois pour toute, la recherche de motif dans le texte doit se faire en temps linéaire (ou quasi-linéaire) en la taille du motif
On ajoute à la fin du texte $t$ un caractère $\$$ distinct de tous les autres caractères du texte.
Ce marqueur $\$$ garantit :
texte : $ababbb$
Le trie des suffixes du texte $a^4b^4$ possède $(4 + 1)2=10$ noeuds internes
Le nombre de nœuds est quadratique en la taille du texte :
c'est trop !
→ Éliminer les nœuds de degré sortant $1$
Une version compacte du trie ($\sim$ arbre PATRICIA « Practical Algorithm To Retrieve Information Coded In Alphanumeric »)
L'arbre des suffixes de $t = a^4b^4$
Le nombre de nœuds d'un arbre des suffixes est linéaire en la taille du texte
Pour un texte $t$ de longueur $n$, le nombre de nœuds de l'arbre des suffixes est d'au plus $2n$
Mais l'ensemble des étiquettes consomme un espace en $O(n^2)$ caractères. C'est trop !
On stocke le texte $t$ et on code chaque étiquette (un facteur de $t$) via les deux indices debut
et fin
délimitant une plage du facteur dans $t$.
$t=aaaabbbb$
Complexité en temps $O(n^2)$
On doit ajouter des liens → les liens suffixes
Pour un nœud d'étiquette $au$, le lien suffixe pointe vers le nœud d'étiquette $u$.
Schéma de l'algorithme :
Avec un parcours (en largeur ou en profondeur) de l'arbre des suffixes de $t$,
déterminer le(s) nœud(s) interne(s) de profondeur maximale
(ici profondeur : longueur de l'étiquette du nœud)
Le noeud d'étiquette CTAC a la profondeur maximale → CATC plus long facteur répété
Parcours de l'arbre $O(n)$
L’arbre des suffixes associé au texte TCCATCATCC
Avec un parcours de l'arbre des suffixes de $t$, faire la somme les longueurs des étiquettes des arcs (on omet les marqueurs $\$$)
Parcours de l'arbre $O(n)$
algorithmes assez faciles à concevoir, l'information est facilement accessible
structure de données un peu lourde (texte à stocker, structure arborescente, gestion des pointeurs, liens suffixes)
C'est super... mais en pratique on préfère souvent la table des suffixes !
Conçu par Manber & Myers (1993)
le texte : $t=abracadabra$ ($|t|=11$)
$i$ | $t|i..n-1]$ |
---|---|
0 | abracadabra |
1 | bracadabra |
2 | racadabra |
3 | acadabra |
4 | cadabra |
5 | adabra |
6 | dabra |
7 | abra |
8 | bra |
9 | ra |
10 | a |
$i$ | $t[TS[i]..n-1]$ | $TS[i]$ |
---|---|---|
0 | a | 10 |
1 | abra | 7 |
2 | abracadabra | 0 |
3 | acadabra | 3 |
4 | adabra | 5 |
5 | bra | 8 |
6 | bracadabra | 1 |
7 | cadabra | 4 |
8 | dabra | 6 |
9 | ra | 9 |
10 | racadabra | 2 |
L'inverse de la table des suffixes (c'est la permutation inverse)
\[
ITS[i]=j \Leftrightarrow TS[j]=i
\]
→ $ITS[i]$ le rang dans l'ordre lexicographique du suffixe $t[i..n-1]$
Plusieurs algorithmes assez élaborés ont un coût en temps et en espace en $O(n)$
Ces méthodes directes sont un grand progrès !
(Auparavant, en pratique, méthodes de tri en $O(n \log n)$)
Regardons plus en détails l'algorithme de Kärkkäinen et Sanders
et l'on procède en trois étapes :
Principe :
Si l'ensemble $\Sigma$ est supposé de taille constante, le tri s'effectue en temps et espace « linéaire » $O(n)$.
def countsort(a, b, n, k) : # a est un tableau de n entiers de 1..k, b sera le tableau trié
c = array("i", [0]*(k+1)) # tableau pour compter les effectifs
for i in range(n) : # on compte les effectifs c[k] = nombre de symboles k dans a
c[a[i]]+=1
somme = 0 # on calcule les effectifs cumulés c[k] = nombre de symboles < k dans a
for i in range(k+1):
freq, c[i] = c[i], somme
somme += freq
for i in range(n) : # on remet les choses à leur place
b[c[a[i]]]] = a[i]
c[a[i]] += 1
$w=agctctctttt$ → $w=agctctctttt\textcolor{green}{\$\$}$
On ne garde que les suffixes $w[i..n-1]$ avec $i \mod 3 \not=0$
on trie dans l'ordre lexicographique les facteurs de longueur $3$ débutant aux positions $i \mod 3 \neq 0$ → tri radix (3 passes) qui s'effectue en temps linéaire.
Si les facteurs sont tous distincts, c'est terminé : on obtient directement la table ${TS^{12}}$
Dans le cas général, on recode chaque facteur de longueur 3 (par rapport à son rang dans l'ordre lexicographique)
À partir de ce renommage \[ ctc\rightarrow A, gct\rightarrow B, t\$\$\rightarrow C, tct\rightarrow D, $ttt\rightarrow E, \] on construit un nouveau texte $w'$, concaténation des rangs des facteurs en position $i \mod 3 =1$ suivis des rangs des facteurs en position $i \mod 3 =2$.
Ce texte $w' = BAECADE$ a une taille réduite au $2/3$ de la taille du texte initial.>
On construit la table des suffixes $TS'$ de $w'$ en appliquant de manière récursive l'algorithme sur $w'$.
La table des suffixes de $w'$ correspond à la table $TS^{12}$ que l'on veut construire.
La complexité est ainsi déterminée par l'équation de récurrence (de type diviser pour régner) \[ T(n) = O(n) + T(2n/3) \]
l'algorithme a une complexité en $O(n)$
Dans les applications mettant en jeu des textes de grande taille, la table de suffixes supplante l'arbre des suffixes :
Mais la table des suffixes seule ne permet pas de retrouver toute la structure de l'arbre des suffixes. La table des suffixes est ainsi souvent combinée avec des structures additionnelles (la table $HTR$, l'arbre d'intervalles de $HTR$...).
La table et ses structures auxiliaires restent économes en mémoire et permettent de définir l'équivalent d'un arbre des suffixes.
La table $HTR$ stocke les longueurs des préfixes communs aux éléments consécutifs dans la table des suffixes
On dénote par $\text{lcp}(s,t)$ le plus long préfixe commun aux mots $s$ et $t$ \[ \text{lcp}(\text{vélocypède},\text{vélocité}) = \text{véloc} \]
$HTR[i]$ est la longueur du plus long préfixe commun aux deux suffixes consécutifs (pour l'ordre lexicographique) indexés dans la table $TS$ par $i-1$ et $i$.
\[ HTR[i] = |\text{lcp}(t[TS[i-1]..],t[TS[i]..])| \]La construction s'effectue en temps linéaire.
La table $HTR$ du texte $abracadabra$
Propriété La longueur du prefixe commun des suffixes de la plage $i < j$ est \[ \text{lcp}(texte[TS[i]..n-1],texte[TS[j]..n-1]) = \min\limits_{i\, <\, k \,\leq \,j}(HTR[k]) \]
Les utilisations de la table des suffixes sont multiples (et les mêmes que pour l'arbre des suffixes) :
Examinons quelques cas concrets
On veut détecter le ou les plus longs facteurs répétés dans un texte.
Un facteur qui est répété, est préfixe commun de suffixes distincts. Et ces suffixes sont consécutifs dans la table des suffixes.
Les facteurs répétés les plus longs sont ainsi caractérisés par
la valeur maximale de la table $HTR$.
détecter le ou les plus longs facteurs qui apparaissent au moins $k$ fois dans un texte.
2 facteurs de longueur 3 au moins trois fois:
Un texte de longueur $n$ comprend $n(n+1)/2$ facteurs non vides
$ababbb$ possède $21=\frac{6\times 7}{2}$ facteurs :
Le nombre de facteurs distincts est $21 - 2 - 1 - 1 - 2 = 15$
De façon générale, le nombre de répétitions est $\sum\limits_i HTR[i]$ et le nombre de facteurs distincts est $n(n+1)/2 - \sum\limits_i HTR[i]$
Une répétition supermaximale d'un texte est un facteur répété qui n'est facteur propre d'aucune autre répétition
La stratégie :
sur un exemple : GATAAGATTGATG
Et les données peuvent être grandes !
Idée : Sacrifier un peu d'efficacité au profit d'un gain d'espace, en utilisant une structure qui contient à la fois le texte et l'index.
→ FM index (Ferragina & Manzini) : Structure basée sur la transformée de Burrows-Wheeler (1994)
Étant donné un texte $t$
Remarque : trier les rotations ou trier les suffixes, c'est pareil !
Les occurrences d'un même caractère dont les contextes droits sont « proches » sont regroupées
Cette transformée permet d'expliciter les redondances du texte et favorise ainsi une bonne compression.
La transformée de Burrows-Wheeler est l'étape préliminaire utilisée dans l'algorithme de compression bzip2
Après application de la transformée de Burrows-Wheeler, la séquence obtenue est o$lag. Quel est le texte initial ?
Le cas est simple, tous les caractères du texte $algo$ étant distincts
Les occurrences d'un même caractère ont le même ordre dans $F$ et dans $L$
Quel que soit caractère $c$, la $i$-ème occurrence de $c$ dans $L$ et la $i$-ème occurrence de $c$ dans~$F$ correspondent au même $c$ du texteLes occurrences d'un même caractère ont le même ordre dans $F$ et dans $L$
On définit
On a alors $LF[i] = \text{first}[L[i]] + \text{rang}[i]$
La recherche est guidée par la fonction $LF$ en lisant le motif à rebours (de droite à gauche, comme Frédéric Paccaut)
(À voir en séance d'exercice)
On parle d'auto-index, car l'index contient le texte ($\not=$ arbre des suffixes)
On veut les positions : il faut retrouver l'ordre, i.e., la table $TS$ → On échantillonne et on recalcule avec $LF$ en cas d'erreur de cache
On utilise des vecteurs de bits (librairies) pour calculer rapidement $\text{rang}$ et $LF$ (en $O(1)$ pour un alphabet de taille $o(\text{polylog}(n))$
On utilise les primitives suivantes sur un tableau de bits $B$