Desculpas se minha resposta parecer redundante, mas eu implementei o algoritmo de Ukkonen recentemente e me vi lutando com ele por dias; Eu tive que ler vários artigos sobre o assunto para entender o porquê e como de alguns aspectos centrais do algoritmo.
Achei a abordagem de 'regras' das respostas anteriores inútil para entender os motivos subjacentes ; por isso, escrevi tudo abaixo focando apenas na pragmática. Se você se esforçou para seguir outras explicações, assim como eu, talvez minha explicação suplementar faça com que ela seja um 'clique' para você.
Publiquei minha implementação em C # aqui: https://github.com/baratgabor/SuffixTree
Observe que eu não sou especialista neste assunto; portanto, as seções a seguir podem conter imprecisões (ou coisa pior). Se você encontrar algum, sinta-se à vontade para editar.
Pré-requisitos
O ponto de partida da explicação a seguir pressupõe que você esteja familiarizado com o conteúdo e o uso de árvores de sufixos e as características do algoritmo de Ukkonen, por exemplo, como você está estendendo o caractere da árvore de sufixos por caractere, do início ao fim. Basicamente, suponho que você já tenha lido algumas das outras explicações.
(No entanto, eu tive que adicionar uma narrativa básica para o fluxo, para que o começo possa parecer redundante.)
A parte mais interessante é a explicação sobre a diferença entre usar links de sufixo e redigitalizar a partir da raiz . Isso foi o que me deu muitos bugs e dores de cabeça na minha implementação.
Nós folha abertos e suas limitações
Tenho certeza que você já sabe que o 'truque' mais fundamental é perceber que podemos deixar o final dos sufixos 'aberto', ou seja, referenciar o comprimento atual da string em vez de definir o final para um valor estático. Dessa forma, quando adicionarmos caracteres adicionais, esses caracteres serão implicitamente adicionados a todos os rótulos de sufixo, sem a necessidade de visitar e atualizar todos eles.
Mas esse final aberto de sufixos - por razões óbvias - funciona apenas para nós que representam o final da string, ou seja, os nós das folhas na estrutura da árvore. As operações de ramificação que executamos na árvore (a adição de novos nós de ramificação e nós de folha) não se propagam automaticamente em todos os lugares que precisam.
Provavelmente é elementar, e não exigiria menção, que substrings repetidos não apareçam explicitamente na árvore, já que a árvore já os contém em virtude de serem repetições; no entanto, quando a substring repetitiva termina encontrando um caractere não repetitivo, precisamos criar uma ramificação nesse ponto para representar a divergência a partir desse ponto em diante.
Por exemplo, no caso da string 'ABCXABCY' (veja abaixo), uma ramificação para X e Y precisa ser adicionada a três sufixos diferentes, ABC , BC e C ; caso contrário, não seria uma árvore de sufixos válida e não poderíamos encontrar todas as substrings da string combinando caracteres da raiz para baixo.
Mais uma vez, para enfatizar - qualquer operação que executamos em um sufixo na árvore também precisa ser refletida por seus sufixos consecutivos (por exemplo, ABC> BC> C); caso contrário, eles simplesmente deixam de ser sufixos válidos.
Mas mesmo se aceitarmos que precisamos fazer essas atualizações manuais, como sabemos quantos sufixos precisam ser atualizados? Como, quando adicionamos o caractere repetido A (e o restante dos caracteres repetidos em sucessão), ainda não temos idéia de quando / onde precisamos dividir o sufixo em dois ramos. A necessidade de dividir é verificada apenas quando encontramos o primeiro caractere não repetitivo, neste caso, Y (em vez do X que já existe na árvore).
O que podemos fazer é corresponder à string mais longa repetida que pudermos e contar quantos sufixos precisamos atualizar posteriormente. É isso que significa "restante" .
O conceito de "restante" e "nova verificação"
A variável remainder
nos diz quantos caracteres repetidos adicionamos implicitamente, sem ramificação; ou seja, quantos sufixos precisamos visitar para repetir a operação de ramificação depois que encontramos o primeiro caractere que não podemos corresponder. Isso é essencialmente igual a quantos caracteres 'profundos' estamos na árvore desde a raiz.
Portanto, permanecendo no exemplo anterior da sequência ABCXABCY , correspondemos a parte ABC repetida 'implicitamente', incrementando remainder
cada vez, o que resulta no restante de 3. Em seguida, encontramos o caractere não repetitivo 'Y' . Aqui nós dividir o adicionado anteriormente ABCX em ABC -> X e ABC -> Y . Depois, diminuímos remainder
de 3 para 2, porque já cuidamos da ramificação do ABC . Agora repetimos a operação combinando os dois últimos caracteres - BC - da raiz para chegar ao ponto em que precisamos dividir, e também dividimos o BCX em BC-> X e BC -> Y . Novamente, decrementamos remainder
para 1 e repetimos a operação; até que remainder
seja 0. Por fim, precisamos adicionar o caractere atual ( Y ) à raiz também.
Essa operação, seguindo os sufixos consecutivos da raiz, simplesmente para chegar ao ponto em que precisamos realizar uma operação, é chamada de 'redigitalização' no algoritmo de Ukkonen, e normalmente essa é a parte mais cara do algoritmo. Imagine uma string mais longa, na qual você precisará 'redigitalizar' substrings longos, em várias dezenas de nós (discutiremos isso mais adiante), potencialmente milhares de vezes.
Como solução, apresentamos o que chamamos de 'links de sufixo' .
O conceito de 'links de sufixo'
Os links de sufixo apontam basicamente para as posições às quais normalmente teríamos que 'redigitalizar' , então, em vez da operação cara de digitalização, podemos simplesmente pular para a posição vinculada, fazer nosso trabalho, pular para a próxima posição vinculada e repetir - até não há mais posições para atualizar.
Obviamente, uma grande questão é como adicionar esses links. A resposta existente é que podemos adicionar os links quando inserimos novos nós de ramificação, utilizando o fato de que, em cada extensão da árvore, os nós de ramificação são naturalmente criados um após o outro na ordem exata em que precisamos vinculá-los . No entanto, precisamos vincular o último nó de ramificação criado (o sufixo mais longo) ao nó criado anteriormente, portanto, precisamos armazenar em cache o último que criamos, vincular esse ao próximo que criamos e armazenar em cache o recém-criado.
Uma conseqüência é que, na verdade, geralmente não temos links de sufixo a seguir, porque o nó de ramificação fornecido foi criado. Nesses casos, ainda temos que voltar à raiz da 'varredura' acima mencionada . É por isso que, após uma inserção, você é instruído a usar o link do sufixo ou ir para a raiz.
(Ou, alternativamente, se você estiver armazenando ponteiros pai nos nós, tente seguir os pais, verifique se eles têm um link e use-o. Descobri que isso é muito raramente mencionado, mas o uso do link com sufixo não é set em pedras. Existem várias abordagens possíveis, e se você entender o mecanismo subjacente que você pode implementar um que satisfaça suas necessidades o melhor.)
O conceito de 'ponto ativo'
Até agora, discutimos várias ferramentas eficientes para a construção da árvore e nos referimos vagamente a percorrer várias arestas e nós, mas ainda não exploramos as conseqüências e complexidades correspondentes.
O conceito explicado anteriormente de 'restante' é útil para acompanhar onde estamos na árvore, mas precisamos entender que ele não armazena informações suficientes.
Em primeiro lugar, sempre residimos em uma borda específica de um nó, portanto, precisamos armazenar as informações da borda. Vamos chamar isso de 'borda ativa' .
Em segundo lugar, mesmo depois de adicionar as informações da borda, ainda não temos como identificar uma posição mais abaixo na árvore e não diretamente conectada ao nó raiz . Portanto, precisamos armazenar o nó também. Vamos chamar esse 'nó ativo' .
Por fim, podemos notar que o 'restante' é inadequado para identificar uma posição em uma borda que não está diretamente conectada à raiz, porque 'restante' é o comprimento de toda a rota; e provavelmente não queremos nos preocupar em lembrar e subtrair o comprimento das arestas anteriores. Portanto, precisamos de uma representação que seja essencialmente o restante na borda atual . Isso é o que chamamos de "comprimento ativo" .
Isso leva ao que chamamos de 'ponto ativo' - um pacote de três variáveis que contêm todas as informações que precisamos manter sobre nossa posição na árvore:
Active Point = (Active Node, Active Edge, Active Length)
Você pode observar na imagem a seguir como a rota correspondente do ABCABD consiste em 2 caracteres na borda AB (da raiz ) e mais 4 caracteres na borda CABDABCABD (do nó 4) - resultando em um 'restante' de 6 caracteres. Portanto, nossa posição atual pode ser identificada como Nó Ativo 4, Borda Ativa C, Comprimento Ativo 4 .
Outro papel importante do 'ponto ativo' é que ele fornece uma camada de abstração para o nosso algoritmo, o que significa que partes do nosso algoritmo podem fazer seu trabalho no 'ponto ativo' , independentemente de esse ponto ativo estar na raiz ou em qualquer outro lugar . Isso facilita a implementação do uso de links de sufixo em nosso algoritmo de maneira limpa e direta.
Diferenças entre redigitalização e uso de links de sufixo
Agora, a parte complicada, algo que - na minha experiência - pode causar muitos bugs e dores de cabeça, e é pouco explicado na maioria das fontes, é a diferença no processamento dos casos de link de sufixo versus os casos de nova varredura.
Considere o seguinte exemplo da cadeia 'AAAABAAAABAAC' :
Você pode observar acima como o 'restante' de 7 corresponde à soma total de caracteres da raiz, enquanto 'comprimento ativo' de 4 corresponde à soma de caracteres correspondentes da borda ativa do nó ativo.
Agora, após executar uma operação de ramificação no ponto ativo, nosso nó ativo pode ou não conter um link de sufixo.
Se um link de sufixo estiver presente: precisamos processar apenas a parte do 'comprimento ativo' . O 'restante' é irrelevante, porque o nó para o qual pulamos através do link do sufixo já codifica o 'restante' correto implicitamente , simplesmente por estar na árvore em que está.
Se um link de sufixo NÃO estiver presente: Precisamos 'digitalizar novamente' a partir de zero / raiz, o que significa processar todo o sufixo desde o início. Para esse fim, precisamos usar todo o "restante" como base para a nova verificação.
Exemplo de comparação de processamento com e sem um link de sufixo
Considere o que acontece na próxima etapa do exemplo acima. Vamos comparar como obter o mesmo resultado - ou seja, passar para o próximo sufixo a ser processado - com e sem um link de sufixo.
Usando 'link de sufixo'
Observe que, se usarmos um link de sufixo, estaremos automaticamente 'no lugar certo'. O que geralmente não é estritamente verdadeiro devido ao fato de que o 'comprimento ativo' pode ser 'incompatível' com a nova posição.
No caso acima, como o 'comprimento ativo' é 4, estamos trabalhando com o sufixo ' ABAA' , começando no Nó 4. vinculado. Mas depois de encontrar a borda que corresponde ao primeiro caractere do sufixo ( 'A' ), notamos que nosso 'comprimento ativo' ultrapassa essa borda em três caracteres. Então, pulamos a borda inteira, para o próximo nó, e diminuímos o 'comprimento ativo' pelos caracteres que consumimos com o salto.
Então, depois que encontramos a próxima aresta 'B' , correspondente ao sufixo decrescente 'BAA ', finalmente observamos que o comprimento da aresta é maior que o restante 'comprimento ativo' de 3, o que significa que encontramos o lugar certo.
Observe que parece que essa operação geralmente não é chamada de 'redigitalização', embora, para mim, pareça ser o equivalente direto da digitalização, apenas com um comprimento reduzido e um ponto inicial não raiz.
Usando 'redigitalizar'
Observe que, se usarmos uma operação tradicional de 'nova varredura' (aqui, fingindo que não tínhamos um link de sufixo), começamos no topo da árvore, na raiz e precisamos trabalhar novamente no lugar certo, seguindo ao longo de todo o comprimento do sufixo atual.
O tamanho desse sufixo é o 'restante' que discutimos anteriormente. Temos que consumir a totalidade desse restante, até chegar a zero. Isso pode (e geralmente inclui) pular através de vários nós, a cada salto diminuindo o restante pelo comprimento da borda pela qual pulamos. Finalmente, chegamos a um limite que é mais longo que o restante 'restante' ; aqui, definimos a aresta ativa para a aresta especificada, definimos 'comprimento ativo' para o restante 'restante ' e pronto.
Observe, no entanto, que a variável 'restante' real precisa ser preservada e decrementada apenas após a inserção de cada nó. Então, o que eu descrevi acima assumiu o uso de uma variável separada inicializada para 'restante' .
Notas sobre links de sufixo e rescansões
1) Observe que ambos os métodos levam ao mesmo resultado. O salto de link de sufixo é, no entanto, significativamente mais rápido na maioria dos casos; essa é toda a lógica por trás dos links de sufixo.
2) As implementações algorítmicas reais não precisam diferir. Como mencionei acima, mesmo no caso de usar o link do sufixo, o 'comprimento ativo' geralmente não é compatível com a posição vinculada, pois esse ramo da árvore pode conter ramificações adicionais. Então, basicamente, você só precisa usar 'comprimento ativo' em vez de 'restante' e executar a mesma lógica de redigitalização até encontrar uma borda menor do que o comprimento restante do sufixo.
3) Uma observação importante referente ao desempenho é que não há necessidade de verificar todos os caracteres durante a nova verificação. Devido à maneira como uma árvore de sufixos válida é criada, podemos assumir com segurança que os caracteres correspondem. Portanto, você está contando principalmente os comprimentos, e a única necessidade de verificação de equivalência de caracteres surge quando saltamos para uma nova aresta, já que as arestas são identificadas pelo primeiro caractere (que é sempre único no contexto de um determinado nó). Isso significa que a lógica de 'redigitalização' é diferente da lógica de correspondência completa de cadeias de caracteres (ou seja, procurando por uma subcadeia na árvore).
4) O link do sufixo original descrito aqui é apenas uma das abordagens possíveis . Por exemplo, NJ Larsson et al. nomeia essa abordagem como de cima para baixo orientada a nós e a compara com de baixo para cima e orientada a nós e duas variedades orientadas a arestas. As diferentes abordagens têm diferentes desempenhos, requisitos, limitações, etc. típicos e no pior caso, mas geralmente parece que as abordagens orientadas a borda são uma melhoria geral do original.