web-dev-qa-db-fra.com

Algorithme de tableau de suffixes

Après pas mal de lecture, j'ai compris ce que représente un tableau de suffixes et un tableau LCP.

Tableau de suffixes : représente le rang _lexicographique de chaque suffixe d'un tableau.

Tableau LCP : contient la correspondance de préfixe de longueur maximale entre deux suffixes consécutifs, après qu'ils soient triés lexicographiquement.

Depuis quelques jours, je m'efforce de comprendre comment fonctionne exactement le tableau de suffixes et l'algorithme LCP.

Voici le code, qui est tiré de Codeforces :

/*
Suffix array O(n lg^2 n)
LCP table O(n)
*/
#include <cstdio>
#include <algorithm>
#include <cstring>

using namespace std;

#define REP(i, n) for (int i = 0; i < (int)(n); ++i)

namespace SuffixArray
{
    const int MAXN = 1 << 21;
    char * S;
    int N, gap;
    int sa[MAXN], pos[MAXN], tmp[MAXN], lcp[MAXN];

    bool sufCmp(int i, int j)
    {
        if (pos[i] != pos[j])
            return pos[i] < pos[j];
        i += gap;
        j += gap;
        return (i < N && j < N) ? pos[i] < pos[j] : i > j;
    }

    void buildSA()
    {
        N = strlen(S);
        REP(i, N) sa[i] = i, pos[i] = S[i];
        for (gap = 1;; gap *= 2)
        {
            sort(sa, sa + N, sufCmp);
            REP(i, N - 1) tmp[i + 1] = tmp[i] + sufCmp(sa[i], sa[i + 1]);
            REP(i, N) pos[sa[i]] = tmp[i];
            if (tmp[N - 1] == N - 1) break;
        }
    }

    void buildLCP()
    {
        for (int i = 0, k = 0; i < N; ++i) if (pos[i] != N - 1)
        {
            for (int j = sa[pos[i] + 1]; S[i + k] == S[j + k];)
            ++k;
            lcp[pos[i]] = k;
            if (k)--k;
        }
    }
} // end namespace SuffixArray

Je ne peux pas, je ne peux pas comprendre comment cet algorithme fonctionne. J'ai essayé de travailler sur un exemple en utilisant un crayon et du papier, et j'ai écrit les étapes impliquées, mais j'ai perdu le lien entre les deux car c'est trop compliqué, du moins pour moi.

Toute aide concernant l'explication, en utilisant un exemple peut-être, est très appréciée.

45
Spandan

Aperçu

Ceci est un algorithme O (n log n) pour la construction d'un tableau de suffixes (ou plutôt, ce serait, si au lieu de ::sort un tri en deux étapes a été utilisé).

Cela fonctionne en triant d'abord les 2 grammes(*), puis les 4 grammes, puis les 8 grammes, et ainsi de suite, de la chaîne d'origine S, donc dans la i-ième itération, nous trions les 2je-grammes. Il ne peut évidemment y avoir plus que le journal2(n) de telles itérations, et l'astuce est que le tri des 2je-grammes dans la i-ème étape est facilitée en s'assurant que chaque comparaison de deux 2je-grams se fait en O(1) temps (plutôt qu'en O (2je) temps).

Comment fait-il cela? Eh bien, lors de la première itération il trie les 2 grammes (aka bigrammes), puis exécute ce qu'on appelle renommage lexicographique. Cela signifie qu'il crée un nouveau tableau (de longueur n) qui stocke, pour chaque bigramme, son rank dans le tri des bigrammes.

Exemple de changement de nom lexicographique: Supposons que nous ayons une liste triée de certains bigrammes {'ab','ab','ca','cd','cd','ea'}. Nous attribuons ensuite rangs (c'est-à-dire les noms lexicographiques) en allant de gauche à droite, en commençant par le rang 0 et en incrémentant le rang chaque fois que nous rencontrons un nouveau changement de bigramme. Les rangs que nous attribuons sont donc les suivants:

ab : 0
ab : 0   [no change to previous]
ca : 1   [increment because different from previous]
cd : 2   [increment because different from previous]
cd : 2   [no change to previous]
ea : 3   [increment because different from previous]

Ces classements sont appelés noms lexicographiques.

Maintenant, dans la prochaine itération , nous trions 4 grammes. Cela implique beaucoup de comparaisons entre différents 4 grammes. Comment comparer deux 4 grammes? Eh bien, nous pourrions les comparer caractère par caractère. Ce serait jusqu'à 4 opérations par comparaison. Mais au lieu de cela, nous les comparons en levant les yeux les rangs des deux bigrammes qu'ils contiennent, en utilisant le tableau de classement généré dans les étapes précédentes. Ce rang représente le rang lexicographique du tri précédent de 2 grammes, donc si pour un 4 grammes donné, son premier 2 grammes a un rang plus élevé que le premier 2 grammes d'un autre 4 grammes, alors il doit être lexicographiquement supérieur quelque part dans les deux premiers caractères. Par conséquent, si pour deux 4 grammes le rang des 2 premiers grammes est identique, ils doivent être identiques dans les deux premiers caractères. En d'autres termes, deux recherches dans le tableau de classement sont suffisants pour comparer les 4 caractères des deux 4 grammes.

Après le tri, nous créons à nouveau de nouveaux noms lexicographiques, cette fois pour les 4 grammes.

Dans la troisième itération, nous devons trier par 8 grammes. Encore une fois, deux recherches dans le tableau de classement lexicographique de l'étape précédente sont suffisantes pour comparer les 8 caractères de deux 8 grammes donnés.

Et ainsi de suite. Chaque itération i comporte deux étapes:

  1. Tri par 2je-grammes, utilisant les noms lexicographiques de l'itération précédente pour permettre des comparaisons en 2 étapes (c'est-à-dire O(1) fois) chacune

  2. Création de nouveaux noms lexicographiques

Nous répétons cela jusqu'à ce que tous les 2je-les programmes sont différents. Si cela se produit, nous avons terminé. Comment savoir si tous sont différents? Eh bien, les noms lexicographiques sont une séquence croissante d'entiers, commençant par 0. Donc, si le nom lexicographique le plus élevé généré dans une itération est le même que n-1, puis chaque 2je-gram doit avoir reçu son propre nom lexicographique distinct.


La mise en oeuvre

Examinons maintenant le code pour confirmer tout cela. Les variables utilisées sont les suivantes: sa[] est le tableau de suffixes que nous construisons. pos[] est la table de recherche de rang (c'est-à-dire qu'elle contient les noms lexicographiques), en particulier, pos[k] contient le nom lexicographique du k- ème m-gramme de l'étape précédente. tmp[] est un tableau auxiliaire utilisé pour aider à créer pos[].

Je vais donner plus d'explications entre les lignes de code:

void buildSA()
{
    N = strlen(S);

    /* This is a loop that initializes sa[] and pos[].
       For sa[] we assume the order the suffixes have
       in the given string. For pos[] we set the lexicographic
       rank of each 1-gram using the characters themselves.
       That makes sense, right? */
    REP(i, N) sa[i] = i, pos[i] = S[i];

    /* Gap is the length of the m-gram in each step, divided by 2.
       We start with 2-grams, so gap is 1 initially. It then increases
       to 2, 4, 8 and so on. */
    for (gap = 1;; gap *= 2)
    {
        /* We sort by (gap*2)-grams: */
        sort(sa, sa + N, sufCmp);

        /* We compute the lexicographic rank of each m-gram
           that we have sorted above. Notice how the rank is computed
           by comparing each n-gram at position i with its
           neighbor at i+1. If they are identical, the comparison
           yields 0, so the rank does not increase. Otherwise the
           comparison yields 1, so the rank increases by 1. */
        REP(i, N - 1) tmp[i + 1] = tmp[i] + sufCmp(sa[i], sa[i + 1]);

        /* tmp contains the rank by position. Now we map this
           into pos, so that in the next step we can look it
           up per m-gram, rather than by position. */
        REP(i, N) pos[sa[i]] = tmp[i];

        /* If the largest lexicographic name generated is
           n-1, we are finished, because this means all
           m-grams must have been different. */
        if (tmp[N - 1] == N - 1) break;
    }
}

À propos de la fonction de comparaison

La fonction sufCmp est utilisée pour comparer lexicographiquement deux (2 * écart) -rammes. Donc, dans la première itération, il compare les bigrammes, dans la deuxième itération, 4 grammes, puis 8 grammes et ainsi de suite. Ceci est contrôlé par gap, qui est une variable globale.

Une implémentation naïve de sufCmp serait la suivante:

bool sufCmp(int i, int j)
{
  int pos_i = sa[i];
  int pos_j = sa[j];

  int end_i = pos_i + 2*gap;
  int end_j = pos_j + 2*gap;
  if (end_i > N)
    end_i = N;
  if (end_j > N)
    end_j = N;

  while (i < end_i && j < end_j)
  {
    if (S[pos_i] != S[pos_j])
      return S[pos_i] < S[pos_j];
    pos_i += 1;
    pos_j += 1;
  }
  return (pos_i < N && pos_j < N) ? S[pos_i] < S[pos_j] : pos_i > pos_j;
}

Cela comparerait le (2 * écart) -gram au début du i-ème suffixe pos_i:=sa[i] avec celui trouvé au début du jième suffixe pos_j:=sa[j]. Et cela les comparerait caractère par caractère, c'est-à-dire en comparant S[pos_i] avec S[pos_j], puis S[pos_i+1] avec S[pos_j+1] etc. Cela continue tant que les personnages sont identiques. Une fois qu'ils diffèrent, il renvoie 1 si le caractère du i-ème suffixe est plus petit que celui du j-ème suffixe, 0 sinon. (Notez que return a<b dans une fonction renvoyant int signifie que vous renvoyez 1 si la condition est vraie et 0 si elle est fausse.)

La condition d'apparence compliquée dans l'instruction return renvoie au cas où l'un des (2 * espaces) -grammes est situé à la fin de la chaîne. Dans ce cas, soit pos_i ou pos_j atteindra N avant que tous les caractères (2 * écart) aient été comparés, même si tous les caractères jusqu'à ce point sont identiques. Il renverra alors 1 si le i-ème suffixe est à la fin, et 0 si le j-ème suffixe est à la fin. C'est correct car si tous les caractères sont identiques, celui plus court est lexicographiquement plus petit. Si pos_i a atteint la fin, le i-ème suffixe doit être plus court que le j-ème suffixe.

Clairement, cette implémentation naïve est O (écart), c'est-à-dire que sa complexité est linéaire dans la longueur des (2 * écart) -grammes. La fonction utilisée dans votre code, cependant, utilise les noms lexicographiques pour ramener cela à O(1) (spécifiquement, jusqu'à un maximum de deux comparaisons):

bool sufCmp(int i, int j)
{
  if (pos[i] != pos[j])
    return pos[i] < pos[j];
  i += gap;
  j += gap;
  return (i < N && j < N) ? pos[i] < pos[j] : i > j;
}

Comme vous pouvez le voir, au lieu de rechercher des caractères individuels S[i] et S[j], on vérifie le rang lexicographique des i-ème et j-ème suffixes. Les rangs lexicographiques ont été calculés dans l'itération précédente pour les écarts-grammes. Donc si pos[i] < pos[j], puis le i-ème suffixe sa[i] doit commencer par un intervalle-gramme qui est lexicographiquement plus petit que l'écart-gramme au début de sa[j]. En d'autres termes, simplement en recherchant pos[i] et pos[j] et en les comparant, nous avons comparé les premiers gap caractères des deux suffixes.

Si les rangs sont identiques, nous continuons en comparant pos[i+gap] avec pos[j+gap]. Cela revient à comparer les caractères - gap suivants des (2 * gap) -grammes, c'est-à-dire les seconde moitié. Si les rangs sont à nouveau identiques, les deux (2 * espaces) sont identiques, donc nous retournons 0. Sinon, nous retournons 1 si le i-ème suffixe est plus petit que le j-ème suffixe, 0 sinon.


Exemple

L'exemple suivant illustre le fonctionnement de l'algorithme et illustre notamment le rôle des noms lexicographiques dans l'algorithme de tri.

La chaîne que nous voulons trier est abcxabcd. Il faut trois itérations pour générer le tableau de suffixes pour cela. Dans chaque itération, je vais afficher S (la chaîne), sa (l'état actuel du tableau de suffixes) et tmp et pos, qui représentent les noms lexicographiques.

Tout d'abord, nous initialisons:

S   abcxabcd
sa  01234567
pos abcxabcd

Notez comment les noms lexicographiques, qui représentent initialement le rang lexicographique des unigrammes, sont simplement identiques aux caractères (c'est-à-dire les unigrammes) eux-mêmes.

Première itération:

Tri sa, en utilisant des bigrammes comme critère de tri:

sa  04156273

Les deux premiers suffixes sont 0 et 4 car ce sont les positions du bigramme "ab". Puis 1 et 5 (positions du bigramme 'bc'), puis 6 (bigramme 'cd'), puis 2 (bigramme 'cx'). puis 7 (bigramme incomplet 'd'), puis 3 (bigramme 'xa'). De toute évidence, les positions correspondent à l'ordre, basées uniquement sur des bigrammes de personnage.

Génération des noms lexicographiques:

tmp 00112345

Comme décrit, les noms lexicographiques sont attribués sous forme d'entiers croissants. Les deux premiers suffixes (tous deux commençant par bigram 'ab') obtiennent 0, les deux suivants (tous deux commençant par bigram 'bc') obtiennent 1, puis 2, 3, 4, 5 (chacun un bigram différent).

Enfin, nous mappons ceci en fonction des positions dans sa, pour obtenir pos:

sa  04156273
tmp 00112345
pos 01350124

(La façon dont pos est généré est la suivante: Parcourez sa de gauche à droite et utilisez l'entrée pour définir l'index dans pos. Utilisez l'entrée correspondante dans tmp pour définir la valeur de cet index. Donc pos[0]:=0, pos[4]:=0, pos[1]:=1, pos[5]:=1, pos[6]:=2, etc. L'index provient de sa, la valeur de tmp.)

Deuxième itération:

Nous trions sa à nouveau, et encore une fois nous regardons les bigrammes de pos (qui représentent chacun une séquence de deux bigrammes de la chaîne d'origine).

sa  04516273

Remarquez comment la position de 1 5 a changé par rapport à la version précédente de sa. Auparavant, il était 15, il est maintenant 51. C'est parce que le bigramme à pos[1] et le bigram à pos[5] était identique (les deux bc) lors de l'itération précédente, mais maintenant le bigramme à pos[5] est 12, tandis que le bigramme à pos[1] est 13. Alors positionnez 5 vient avant position 1. Cela est dû au fait que les noms lexicographiques représentent désormais chacun des bigrammes de la chaîne d'origine: pos[5] représente bc et pos[6] représente 'cd'. Donc, ensemble, ils représentent bcd, tandis que pos[1] représente bc et pos[2] représente cx, donc ensemble ils représentent bcx, ce qui est en effet lexicographiquement supérieur à bcd.

Encore une fois, nous générons des noms lexicographiques en filtrant la version actuelle de sa de gauche à droite et en comparant les bigrammes correspondants dans pos:

tmp 00123456

Les deux premières entrées sont toujours identiques (toutes deux 0), car les bigrammes correspondants dans pos sont tous les deux 01. Le reste est une séquence strictement croissante d'entiers, car tous les autres bigrammes de pos sont chacun uniques.

Nous effectuons le mappage vers le nouveau pos comme auparavant (en prenant les indices de sa et les valeurs de tmp):

sa  04516273
tmp 00123456
pos 02460135

Troisième itération:

Nous trions à nouveau sa, en prenant des bigrammes de pos (comme toujours), qui représentent désormais chacun une séquence de 4 bigrammes de la chaîne d'origine.

sa  40516273

Vous remarquerez que maintenant les deux premières entrées ont changé de position: 04 est devenu 40. C'est parce que le bigramme à pos[0] est 02 tandis que celui de pos[4] est 01, ce dernier étant évidemment lexicographiquement plus petit. La raison profonde est que ces deux représentent abcx et abcd, respectivement.

La génération de noms lexicographiques donne:

tmp 01234567

Ils sont tous différents, c'est-à-dire que le plus élevé est 7, lequel est n-1. Nous avons donc terminé, car le tri est désormais basé sur des m-grammes tous différents. Même si nous continuions, l'ordre de tri ne changerait pas.


Suggestion d'amélioration

L'algorithme utilisé pour trier les 2je-grammes dans chaque itération semble être le sort intégré (ou std::sort). Cela signifie que c'est un tri par comparaison, qui prend du temps O (n log n) dans le pire des cas, à chaque itération. Puisqu'il y a log n itérations dans le pire des cas, cela en fait un O (n (log n)2) -algorithme de temps. Cependant, le tri pourrait être effectué en utilisant deux passes de tri par compartiment, car les clés que nous utilisons pour la comparaison de tri (c'est-à-dire les noms lexicographiques de l'étape précédente), forment une séquence entière croissante. Cela pourrait donc être amélioré en un algorithme réel O (n log n) pour le tri des suffixes.


Remarque

Je crois que c'est l'algorithme original pour la construction de tableaux de suffixes qui a été suggéré dans l'article de 1992 par Manber et Myers ( lien sur Google Scholar ; il devrait être le premier hit, et il peut avoir un lien vers a PDF there). Ceci (en même temps, mais indépendamment d'un article de Gonnet et Baeza-Yates) a été ce qui a introduit les tableaux de suffixes (également connus sous le nom de tableaux de pat à l'époque) en tant que structure de données intéressante pour un complément d'étude.

Les algorithmes modernes pour la construction de tableaux de suffixes sont O (n), donc ce qui précède n'est plus le meilleur algorithme disponible (du moins pas en termes de complexité théorique, dans le pire des cas).


Notes de bas de page

(*)  Par 2 grammes je veux dire une séquence de deux consécutifs caractères de la chaîne d'origine. Par exemple, lorsque S=abcde est la chaîne, puis ab, bc, cd, de sont les 2 grammes de S. De même, abcd et bcde sont les 4 grammes. Généralement, un m-gramme (pour un entier positif m) est une séquence de m caractères consécutifs. 1 gramme est aussi appelé unigramme, 2 gramme est appelé bigramme, 3 gramme est appelé trigramme. Certaines personnes continuent avec des tétragrammes, des pentagrammes, etc.

Notez que le suffixe de S qui démarre et positionne i, est un (n-i) -gramme de S. De plus, chaque m-gramme (pour tout m) est un préfixe de l'un des suffixes de S. Par conséquent, le tri des m-grammes (pour un m aussi grand que possible) peut être la première étape vers le tri des suffixes.

106
jogojapan