Eu me sinto um pouco grossa neste momento. Passei dias tentando entender completamente a construção de árvores com sufixos, mas como não tenho formação matemática, muitas das explicações me iludem quando começam a fazer uso excessivo da simbologia matemática. A mais próxima de uma boa explicação que encontrei é a Pesquisa Rápida de Cordas com Árvores de Sufixo , mas ele encobre vários pontos e alguns aspectos do algoritmo permanecem obscuros.
Uma explicação passo a passo desse algoritmo aqui no Stack Overflow seria inestimável para muitos outros além de mim, tenho certeza.
Para referência, aqui está o artigo de Ukkonen sobre o algoritmo: http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf
Meu entendimento básico, até agora:
- Eu preciso percorrer cada prefixo P de uma determinada string T
- Eu preciso percorrer cada sufixo S no prefixo P e adicioná-lo à árvore
- Para adicionar o sufixo S à árvore, preciso percorrer cada caractere em S, com as iterações consistindo em percorrer um ramo existente que começa com o mesmo conjunto de caracteres C em S e potencialmente dividir uma borda em nós descendentes quando I alcance um caractere diferente no sufixo, OU se não houver uma borda correspondente para descer. Quando nenhuma borda correspondente é encontrada para descer para C, uma nova borda da folha é criada para C.
O algoritmo básico parece ser O (n 2 ), como é indicado na maioria das explicações, pois precisamos percorrer todos os prefixos e, em seguida, percorrer cada um dos sufixos de cada prefixo. O algoritmo de Ukkonen é aparentemente único por causa da técnica de ponteiro de sufixo que ele usa, embora eu ache que é isso que estou tendo problemas para entender.
Também estou tendo problemas para entender:
- exatamente quando e como o "ponto ativo" é atribuído, usado e alterado
- o que está acontecendo com o aspecto de canonização do algoritmo
- Por que as implementações que eu vi precisam "consertar" variáveis delimitadoras que elas estão usando
Aqui está o código fonte C # completo . Ele não apenas funciona corretamente, mas suporta a canonização automática e renderiza um gráfico de texto com melhor aparência da saída. O código-fonte e a amostra de saída estão em:
Atualização 2017-11-04
Depois de muitos anos, encontrei um novo uso para árvores de sufixo e implementei o algoritmo em JavaScript . A essência está abaixo. Deve estar livre de erros. Coloque-o em um arquivo js, npm install chalk
no mesmo local e execute com o node.js para ver uma saída colorida. Há uma versão simplificada no mesmo Gist, sem nenhum código de depuração.
https://gist.github.com/axefrog/c347bf0f5e0723cbd09b1aaed6ec6fc6
fonte
Respostas:
A seguir, é apresentada uma tentativa de descrever o algoritmo Ukkonen, mostrando primeiro o que ele faz quando a string é simples (ou seja, não contém caracteres repetidos) e, em seguida, estendendo-o para o algoritmo completo.
Primeiro, algumas declarações preliminares.
O que estamos construindo é basicamente como um trie de pesquisa. Portanto, há um nó raiz, arestas saindo dele levando a novos nós, e outras arestas saindo deles, e assim por diante
Mas : diferentemente de uma pesquisa, os rótulos de borda não são caracteres únicos. Em vez disso, cada extremidade é rotulada usando um par de números inteiros
[from,to]
. Estes são indicadores para o texto. Nesse sentido, cada aresta carrega uma etiqueta de comprimento arbitrário, mas ocupa apenas O (1) de espaço (dois ponteiros).Principio básico
Gostaria de demonstrar primeiro como criar a árvore de sufixos de uma sequência particularmente simples, uma sequência sem caracteres repetidos:
O algoritmo funciona em etapas, da esquerda para a direita . Há uma etapa para cada caractere da sequência . Cada etapa pode envolver mais de uma operação individual, mas veremos (ver as observações finais no final) que o número total de operações é O (n).
Assim, começamos da esquerda e primeiro inserimos apenas o caractere único
a
, criando uma aresta do nó raiz (à esquerda) para uma folha e rotulando-a como[0,#]
, o que significa que a aresta representa a subseqüência começando na posição 0 e terminando no final atual . Eu uso o símbolo#
para significar o final atual , que está na posição 1 (logo apósa
).Portanto, temos uma árvore inicial, que se parece com isso:
E o que isso significa é o seguinte:
Agora, avançamos para a posição 2 (logo após
b
). Nosso objetivo em cada etapa é inserir todos os sufixos até a posição atual . Fazemos issoa
margem existente paraab
b
Em nossa representação, isso parece
E o que isso significa é:
Observamos duas coisas:
ab
é a mesma , uma vez que usada para estar na árvore inicial:[0,#]
. Seu significado mudou automaticamente porque atualizamos a posição atual#
de 1 para 2.Em seguida, incrementamos a posição novamente e atualizamos a árvore anexando a
c
a todas as arestas existentes e inserindo uma nova aresta para o novo sufixoc
.Em nossa representação, isso parece
E o que isso significa é:
Nós observamos:
#
e a inserção de uma nova aresta para o caractere final pode ser feita em O (1). Portanto, para uma sequência de comprimento n, apenas o tempo O (n) é necessário.Primeira extensão: repetições simples
É claro que isso funciona tão bem apenas porque nossa string não contém repetições. Agora, olhamos para uma sequência mais realista:
Começa com
abc
como no exemplo anterior, depoisab
é repetido e seguido porx
e, em seguida,abc
é repetido seguido pord
.Etapas 1 a 3: Após as 3 primeiras etapas, temos a árvore do exemplo anterior:
Etapa 4: passamos
#
para a posição 4. Isso atualiza implicitamente todas as arestas existentes para isso:e precisamos inserir o sufixo final da etapa atual,,
a
na raiz.Antes de fazer isso, apresentamos mais duas variáveis (além de
#
), que obviamente estão lá o tempo todo, mas ainda não as usamos:(active_node,active_edge,active_length)
remainder
, que é um número inteiro, indicando quantos novos sufixos precisamos inserirO significado exato desses dois ficará claro em breve, mas por enquanto vamos apenas dizer:
abc
exemplo simples , o ponto ativo era sempre(root,'\0x',0)
, ou seja,active_node
era o nó raiz,active_edge
era especificado como o caractere nulo'\0x'
eactive_length
era zero. O efeito disso foi que a única nova borda que inserimos em todas as etapas foi inserida no nó raiz como uma nova borda criada. Veremos em breve por que um triplo é necessário para representar essa informação.remainder
sempre foi definido como 1 no início de cada etapa. O significado disso era que o número de sufixos que tivemos que inserir ativamente no final de cada etapa era 1 (sempre apenas o caractere final).Agora isso vai mudar. Quando inserir o caractere final atual
a
na raiz, notamos que já existe uma extremidade de saída começando coma
, especificamente:abca
. Aqui está o que fazemos nesse caso:[4,#]
no nó raiz. Em vez disso, simplesmente notamos que o sufixoa
já está em nossa árvore. Termina no meio de uma borda mais longa, mas não nos incomodamos com isso. Apenas deixamos as coisas do jeito que são.(root,'a',1)
. Isso significa que o ponto ativo está agora em algum lugar no meio da borda de saída do nó raiz que começa coma
, especificamente, após a posição 1 nessa borda. Percebemos que a aresta é especificada simplesmente por seu primeiro caracterea
. Isso é suficiente, pois só pode haver uma aresta começando com qualquer caractere em particular (confirme se isso é verdade depois de ler toda a descrição).remainder
, portanto, no início da próxima etapa, será 2.Observação: Quando o sufixo final que precisamos inserir já existe na árvore , a árvore em si não é alterada (atualizamos apenas o ponto ativo e
remainder
). A árvore não é mais uma representação precisa da árvore de sufixos até a posição atual , mas contém todos os sufixos (porque o sufixo finala
está implicitamente contido ). Portanto, além de atualizar as variáveis (todas com comprimento fixo, portanto é O (1)), não houve trabalho realizado nesta etapa.Etapa 5: atualizamos a posição atual
#
para 5. Isso atualiza automaticamente a árvore para isso:E como
remainder
é 2 , precisamos inserir dois sufixos finais da posição atual:ab
eb
. Isto é basicamente porque:a
sufixo da etapa anterior nunca foi inserido corretamente. Portanto, permaneceu e, desde que avançamos um passo, passou dea
paraab
.b
.Na prática, isso significa que vamos ao ponto ativo (que aponta atrás do
a
que agora é aabcab
aresta) e inserimos o caractere final atualb
. Mas: Novamente, acontece queb
também já está presente nessa mesma extremidade.Então, novamente, não mudamos a árvore. Simplesmente:
(root,'a',2)
(mesmo nó e borda que antes, mas agora apontamos para atrás dob
)remainder
para 3 porque ainda não inserimos corretamente a aresta final da etapa anterior e também não inserimos a aresta final atual.Para ficar claro: tivemos que inserir
ab
eb
na etapa atual, mas comoab
já foi encontrado, atualizamos o ponto ativo e nem tentamos inserirb
. Por quê? Porque seab
está na árvore, todo sufixo (inclusiveb
) deve estar na árvore também. Talvez apenas implicitamente , mas deve estar lá, devido à maneira como construímos a árvore até agora.Prosseguimos para a etapa 6 incrementando
#
. A árvore é atualizada automaticamente para:Porque
remainder
é de 3 , temos que inserirabx
,bx
ex
. O ponto ativo nos diz ondeab
termina, então precisamos apenas pular lá e inserir ox
. Na verdade,x
ainda não existe, então dividimos aabcabx
borda e inserimos um nó interno:As representações de arestas ainda são ponteiros para o texto, portanto, a divisão e a inserção de um nó interno podem ser feitas no tempo O (1).
Então, lidamos com
abx
e diminuímosremainder
para 2. Agora precisamos inserir o próximo sufixo restantebx
,. Mas antes de fazer isso, precisamos atualizar o ponto ativo. A regra para isso, depois de dividir e inserir uma aresta, será chamada Regra 1 abaixo e será aplicada sempre queactive_node
for raiz (aprenderemos a regra 3 para outros casos mais adiante). Aqui está a regra 1:Portanto, o novo ponto triplo ativo
(root,'b',1)
indica que a próxima inserção deve ser feita nabcabx
borda, atrás de 1 caractere, ou seja, atrásb
. Podemos identificar o ponto de inserção no tempo O (1) e verificar sex
já está presente ou não. Se estivesse presente, encerraríamos a etapa atual e deixaríamos tudo do jeito que está. Comox
não está presente, inserimos-o dividindo a borda:Novamente, isso levou tempo O (1) e atualizamos
remainder
para 1 e o ponto ativo para a(root,'x',0)
regra 1.Mas há mais uma coisa que precisamos fazer. Vamos chamar isso de regra 2:
Ainda precisamos inserir o sufixo final da etapa atual
x
,. Como oactive_length
componente do nó ativo caiu para 0, a inserção final é feita diretamente na raiz. Como não há uma borda de saída no nó raiz começando comx
, inserimos uma nova borda:Como podemos ver, na etapa atual foram feitas todas as inserções restantes.
Prosseguimos para a etapa 7 configurando
#
= 7, que anexa automaticamente o próximo caracterea
, a todas as bordas das folhas, como sempre. Em seguida, tentamos inserir o novo caractere final no ponto ativo (a raiz) e descobrimos que ele já está lá. Portanto, finalizamos a etapa atual sem inserir nada e atualizamos o ponto ativo para(root,'a',1)
.Na etapa 8 ,
#
= 8, acrescentamosb
e, como visto anteriormente, isso significa apenas que atualizamos o ponto ativo(root,'a',2)
e aumentamosremainder
sem fazer mais nada, porqueb
já está presente. No entanto, notamos (no tempo O (1)) que o ponto ativo está agora no final de uma aresta. Nós refletimos isso redefinindo-o para(node1,'\0x',0)
. Aqui, eu usonode1
para me referir ao nó interno no qual aab
borda termina.Então, na etapa
#
= 9 , precisamos inserir 'c' e isso nos ajudará a entender o truque final:Segunda extensão: usando links de sufixo
Como sempre, a
#
atualização é anexadac
automaticamente às bordas das folhas e vamos ao ponto ativo para ver se podemos inserir 'c'. Acontece que 'c' já existe nessa extremidade, portanto, definimos o ponto ativo como(node1,'c',1)
, incrementamosremainder
e não fazemos mais nada.Agora, na etapa
#
= 10 ,remainder
é 4 e, portanto, precisamos primeiro inserirabcd
(o que resta de 3 etapas atrás) inserindod
no ponto ativo.Tentar inserir
d
no ponto ativo causa uma divisão da aresta no tempo O (1):A
active_node
partir da qual a divisão foi iniciada está marcada em vermelho acima. Aqui está a regra final, Regra 3:Portanto, o ponto ativo é agora
(node2,'c',1)
enode2
está marcado em vermelho abaixo:Como a inserção de
abcd
está concluída, decrementamosremainder
para 3 e consideramos o próximo sufixo restante da etapa atualbcd
,. A regra 3 definiu o ponto ativo como o nó e a aresta certos, para que a inserçãobcd
possa ser feita simplesmente inserindo seu caractere finald
no ponto ativo.Isso causa outra divisão de borda e, devido à regra 2 , devemos criar um link de sufixo do nó inserido anteriormente para o novo:
Observamos: Links de sufixo nos permitem redefinir o ponto ativo para que possamos fazer a próxima inserção restante no esforço O (1). Observe o gráfico acima para confirmar que o nó no rótulo
ab
está vinculado ao nó emb
(seu sufixo) e o nó emabc
está vinculadobc
.A etapa atual ainda não está concluída.
remainder
agora é 2 e precisamos seguir a regra 3 para redefinir o ponto ativo novamente. Como o atualactive_node
(vermelho acima) não possui link de sufixo, redefinimos para root. O ponto ativo é agora(root,'c',1)
.Daí a próxima inserção ocorre na uma extremidade de saída do nó raiz cuja começa etiqueta com
c
:cabxabcd
, atrás do primeiro caractere, ou seja, por trásc
. Isso causa outra divisão:E como isso envolve a criação de um novo nó interno, seguimos a regra 2 e definimos um novo link de sufixo a partir do nó interno criado anteriormente:
(Estou usando o Graphviz Dot para esses pequenos gráficos. O novo link de sufixo fez com que o ponto reorganizasse as arestas existentes; portanto, verifique cuidadosamente para confirmar que a única coisa que foi inserida acima é um novo link de sufixo.)
Com isso,
remainder
pode ser definido como 1 e, como oactive_node
é raiz, usamos a regra 1 para atualizar o ponto ativo(root,'d',0)
. Isso significa que a inserção final da etapa atual é inserir uma únicad
na raiz:Essa foi a etapa final e estamos prontos. Há um número de observações finais , no entanto:
Em cada passo, avançamos
#
uma posição. Isso atualiza automaticamente todos os nós folha no tempo O (1).Mas não lida com a) quaisquer sufixos restantes das etapas anteriores eb) com o único caractere final da etapa atual.
remainder
nos diz quantas inserções adicionais precisamos fazer. Essas inserções correspondem um a um aos sufixos finais da sequência que termina na posição atual#
. Consideramos um após o outro e fazemos a inserção. Importante: Cada inserção é feita no tempo O (1) desde que o ponto ativo nos diz exatamente para onde ir, e precisamos adicionar apenas um único caractere no ponto ativo. Por quê? Como os outros caracteres estão contidos implicitamente (caso contrário, o ponto ativo não estaria onde está).Após cada inserção, decrementamos
remainder
e seguimos o link do sufixo, se houver algum. Caso contrário, vamos para a raiz (regra 3). Se já estamos na raiz, modificamos o ponto ativo usando a regra 1. De qualquer forma, leva apenas O (1) tempo.Se, durante uma dessas inserções, descobrirmos que o caractere que deseja inserir já existe, não fazemos nada e encerramos a etapa atual, mesmo que
remainder
> 0. O motivo é que todas as inserções restantes serão sufixos da que acabamos de tentar fazer. Portanto, eles estão todos implícitos na árvore atual. O fato de queremainder
> 0 garante que lidemos com os sufixos restantes posteriormente.E se no final do algoritmo
remainder
> 0? Este será o caso sempre que o final do texto for uma substring que ocorreu em algum lugar antes. Nesse caso, devemos acrescentar um caractere extra no final da string que não ocorreu antes. Na literatura, geralmente o cifrão$
é usado como um símbolo para isso. Por que isso importa? -> Se mais tarde usarmos a árvore de sufixos completa para procurar sufixos, devemos aceitar correspondências somente se elas terminarem em uma folha . Caso contrário, teríamos muitas correspondências falsas, porque existem muitas seqüências implicitamente contidas na árvore que não são sufixos reais da cadeia principal. Forçarremainder
ser 0 no final é essencialmente uma maneira de garantir que todos os sufixos terminem em um nó folha. No entanto, se queremos usar a árvore para procurar substrings gerais , não apenas sufixos da string principal, essa etapa final não é necessária, como sugerido pelo comentário do OP abaixo.Então, qual é a complexidade de todo o algoritmo? Se o texto tiver n caracteres, obviamente haverá n etapas (ou n + 1 se adicionarmos o cifrão). Em cada etapa, não fazemos nada (além de atualizar as variáveis) ou fazemos
remainder
inserções, cada uma levando tempo O (1). Comoremainder
indica quantas vezes não fizemos nada nas etapas anteriores e é diminuído para cada inserção que fazemos agora, o número total de vezes que fazemos algo é exatamente n (ou n + 1). Portanto, a complexidade total é O (n).No entanto, há uma pequena coisa que não expliquei corretamente: pode acontecer que sigamos um link de sufixo, atualizemos o ponto ativo e depois descubram que seu
active_length
componente não funciona bem com o novoactive_node
. Por exemplo, considere uma situação como esta:(As linhas tracejadas indicam o restante da árvore. A linha pontilhada é um link de sufixo.)
Agora deixe o ponto ativo
(red,'d',3)
, para que ele aponte para o local atrásf
dadefg
borda. Agora suponha que fizemos as atualizações necessárias e agora siga o link do sufixo para atualizar o ponto ativo de acordo com a regra 3. O novo ponto ativo é(green,'d',3)
. No entanto, ad
borda que sai do nó verde éde
, portanto, possui apenas 2 caracteres. Para encontrar o ponto ativo correto, obviamente precisamos seguir essa borda até o nó azul e redefinir para(blue,'f',1)
.Em um caso particularmente ruim,
active_length
pode ser tão grande quantoremainder
, que pode ser tão grande quanto n. E pode muito bem acontecer que, para encontrar o ponto ativo correto, precisamos não apenas pular um nó interno, mas talvez muitos, até n no pior caso. Isso significa que o algoritmo tem uma complexidade O (n 2 ) oculta , porque em cada etaparemainder
geralmente é O (n), e os pós-ajustes no nó ativo após seguir um link de sufixo também podem ser O (n)?Não. O motivo é que, se tivermos de ajustar o ponto ativo (por exemplo, de verde para azul como acima), isso nos leva a um novo nó que possui seu próprio link de sufixo e
active_length
será reduzido. À medida que seguimos a cadeia de links de sufixos, fazemos as inserções restantes,active_length
só podemos diminuir e o número de ajustes de pontos ativos que podemos fazer no caminho não pode ser maior do queactive_length
em um determinado momento. Comoactive_length
nunca pode ser maior queremainder
, eremainder
é O (n), não apenas em cada etapa, mas a soma total de incrementos já feitos aoremainder
longo de todo o processo é O (n) também, o número de ajustes de pontos ativos é também delimitada por O (n).fonte
abcdefabxybcdmnabcdex
. A parte inicial deabcd
é repetida emabxy
(isso cria um nó interno depoisab
) e novamente emabcdex
, e termina embcd
, que aparece não apenas nobcdex
contexto, mas também nobcdmn
contexto. Depoisabcdex
é inserido, seguimos o link sufixo para inserirbcdex
, e há canonicize vai chutar.Tentei implementar a Árvore de Sufixo com a abordagem dada na resposta do jogojapan, mas não funcionou em alguns casos devido ao uso das regras. Além disso, mencionei que ninguém conseguiu implementar uma árvore de sufixos absolutamente correta usando essa abordagem. Abaixo, escreverei uma "visão geral" da resposta do jogojapan com algumas modificações nas regras. Também descreverei o caso em que esquecemos de criar links importantes de sufixos.
Variáveis adicionais usadas
Vamos usar o conceito de nó interno - todos os nós, exceto a raiz e as folhas, são nós internos .
Observação 1
Quando o sufixo final que precisamos inserir já existe na árvore, a árvore em si não é alterada (apenas atualizamos o
active point
eremainder
).Observação 2
Se, em algum momento,
active_length
for maior ou igual ao comprimento da aresta atual (edge_length
), movemos nossaactive point
seta para baixo até queedge_length
seja estritamente maior queactive_length
.Agora, vamos redefinir as regras:
Regra 1
Regra 2
Essa definição de
Rule 2
é diferente de jogojapan ', pois aqui levamos em consideração não apenas os nós internos recém-criados , mas também os nós internos, dos quais fazemos uma inserção.Regra 3
Nesta definição de
Rule 3
, também consideramos as inserções dos nós das folhas (não apenas dos nós divididos).E finalmente, observação 3:
Quando o símbolo que queremos adicionar à árvore já estiver no limite, de acordo com
Observation 1
, atualizamos apenasactive point
eremainder
, deixando a árvore inalterada. MAS, se houver um nó interno marcado como necessitando de link de sufixo , devemos conectar esse nó à nossa correnteactive node
por meio de um link de sufixo.Vejamos o exemplo de uma árvore de sufixos para cdddcdc se adicionarmos um link de sufixo nesse caso e se não o fizermos:
Se não conectarmos os nós através de um link de sufixo:
Se nós FAZER conectar os nós através de um link sufixo:
Parece que não há diferença significativa: no segundo caso, existem mais dois links de sufixo. Mas esses links de sufixo estão corretos , e um deles - do nó azul ao vermelho - é muito importante para a nossa abordagem com ponto ativo . O problema é que, se não colocarmos um link de sufixo aqui, mais tarde, quando adicionarmos novas letras à árvore, poderemos omitir a adição de alguns nós à árvore devido a
Rule 3
, porque, de acordo com ela, se não houver um link de sufixo, então devemos colocar oactive_node
na raiz.Quando estávamos adicionando a última letra à árvore, o nó vermelho já existia antes de fazermos uma inserção a partir do nó azul (a borda denominada 'c' ). Como houve uma inserção do nó azul, nós a marcamos como necessitando de um link de sufixo . Então, contando com a abordagem do ponto ativo , o valor
active node
foi definido no nó vermelho. Mas não fazemos uma inserção a partir do nó vermelho, pois a letra 'c' já está no limite. Isso significa que o nó azul deve ser deixado sem um link de sufixo? Não, devemos conectar o nó azul ao vermelho através de um link de sufixo. Por que isso está correto? Porque o ponto ativoEssa abordagem garante que chegamos ao lugar certo, ou seja, ao próximo local onde devemos processar uma inserção de um sufixo mais curto .Finalmente, aqui estão minhas implementações da Árvore Suffix:
Espero que esta "visão geral" combinada com a resposta detalhada do jogojapan ajude alguém a implementar sua própria Árvore de Sufixo.
fonte
aabaacaad
é um dos casos que mostra que adicionar um link de sufixo extra pode reduzir o tempo de atualização do triplo. A conclusão nos dois últimos parágrafos do post de jogojapan está errada. Se não adicionarmos os links de sufixo mencionados neste post, a complexidade do tempo médio deverá ser O (nlong (n)) ou mais. Porque leva um tempo extra para andar na árvore para encontrar o corretoactive_node
.Obrigado pelo tutorial bem explicado por @jogojapan , implementei o algoritmo em Python.
Alguns pequenos problemas mencionados por @jogojapan acabam sendo mais sofisticados do que eu esperava e precisam ser tratados com muito cuidado. Custou-me vários dias para que minha implementação fosse robusta o suficiente (suponho). Problemas e soluções estão listados abaixo:
Terminar com
Remainder > 0
Acontece que essa situação também pode acontecer durante a etapa de desenvolvimento , não apenas no final de todo o algoritmo. Quando isso acontece, podemos deixar inalterados o restante, o nó de atuação, o comprimento de ação e o comprimento de ato , encerrar a etapa atual de desdobramento e iniciar outra etapa, continuando a dobrar ou desdobrar, dependendo se o próximo caractere na sequência original estiver no caminho atual ou não.Salto sobre nós: quando seguimos um link de sufixo, atualize o ponto ativo e, em seguida, descubra que seu componente active_length não funciona bem com o novo active_node. Temos que avançar para o lugar certo para dividir ou inserir uma folha. Esse processo pode não ser tão simples porque, durante a movimentação, o comprimento do ato e o comprimento da haste continuam mudando completamente; quando você precisa voltar para o nó raiz , o comprimento da haste e o comprimento do comprimento podem estar errados por causa desses movimentos. Precisamos de variáveis adicionais para manter essas informações.
Os outros dois problemas foram de alguma forma apontados por @managonov
A divisão pode se deteriorar Ao tentar dividir uma aresta, em algum momento você encontrará a operação de divisão diretamente no nó. Nesse caso, precisamos apenas adicionar uma nova folha a esse nó, tomá-la como uma operação de divisão de borda padrão, o que significa que os links de sufixo, se houver algum, devem ser mantidos correspondentemente.
Links de sufixo ocultos Há outro caso especial incorrido pelo problema 1 e pelo problema 2 . Às vezes, precisamos pular vários nós para o ponto certo para dividir; podemos ultrapassar o ponto certo se avançarmos comparando a string restante e os rótulos dos caminhos. Nesse caso, o link do sufixo será negligenciado acidentalmente, se houver algum. Isso pode ser evitado lembrando-se do ponto certo ao avançar. O link do sufixo deve ser mantido se o nó de divisão já existir ou o problema 1 ocorrer durante uma etapa de desenvolvimento.
Finalmente, minha implementação em Python é a seguinte:
fonte
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ímosremainder
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, decrementamosremainder
para 1 e repetimos a operação; até queremainder
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.
fonte
@jogojapan você trouxe explicações e visualizações incríveis. Mas como o @makagonov mencionou, faltam algumas regras em relação à configuração de links de sufixos. É visível de maneira agradável quando você segue passo a passo em http://brenden.github.io/ukkonen-animation/ através da palavra 'aabaaabb'. Quando você passa da etapa 10 para a etapa 11, não há link de sufixo do nó 5 para o nó 2, mas o ponto ativo se move subitamente para lá.
@makagonov desde que moro no mundo Java, também tentei seguir sua implementação para entender o fluxo de trabalho de construção de ST, mas foi difícil para mim por causa de:
Então, acabei com essa implementação em Java que, espero, reflita todas as etapas de maneira mais clara e reduza o tempo de aprendizado para outras pessoas em Java:
fonte
Minha intuição é a seguinte:
Após k iterações do loop principal, você construiu uma árvore de sufixos que contém todos os sufixos da cadeia completa iniciados nos primeiros k caracteres.
No início, isso significa que a árvore de sufixos contém um único nó raiz que representa toda a cadeia (este é o único sufixo que começa em 0).
Após as iterações len (string), você tem uma árvore de sufixos que contém todos os sufixos.
Durante o loop, a chave é o ponto ativo. Meu palpite é que isso representa o ponto mais profundo da árvore de sufixos que corresponde a um sufixo adequado dos primeiros k caracteres da string. (Acho que adequado significa que o sufixo não pode ser a string inteira.)
Por exemplo, suponha que você tenha visto os caracteres 'abcabc'. O ponto ativo representaria o ponto na árvore correspondente ao sufixo 'abc'.
O ponto ativo é representado por (origem, primeiro, último). Isso significa que você está no ponto da árvore em que você começa, iniciando na origem do nó e, em seguida, alimentando os caracteres na string [first: last]
Ao adicionar um novo caractere, você olha para ver se o ponto ativo ainda está na árvore existente. Se for, então você está pronto. Caso contrário, você precisará adicionar um novo nó à árvore de sufixos no ponto ativo, fazer fallback para a próxima correspondência mais curta e verificar novamente.
Nota 1: Os ponteiros de sufixo fornecem um link para a próxima correspondência mais curta de cada nó.
Nota 2: Ao adicionar um novo nó e fallback, você adiciona um novo ponteiro de sufixo para o novo nó. O destino desse ponteiro de sufixo será o nó no ponto ativo reduzido. Esse nó já existe ou será criado na próxima iteração desse loop de fallback.
Nota 3: A parte da canonização economiza tempo na verificação do ponto ativo. Por exemplo, suponha que você sempre usou origin = 0 e apenas alterou o primeiro e o último. Para verificar o ponto ativo, é necessário seguir a árvore de sufixos toda vez em todos os nós intermediários. Faz sentido armazenar em cache o resultado de seguir esse caminho gravando apenas a distância do último nó.
Você pode dar um exemplo de código do que você quer dizer com "corrigir" variáveis delimitadoras?
Aviso de integridade: também achei esse algoritmo particularmente difícil de entender; portanto, saiba que essa intuição provavelmente está incorreta em todos os detalhes importantes ...
fonte
Oi eu tentei implementar a implementação explicada acima em ruby, por favor, confira. parece funcionar bem.
a única diferença na implementação é que, tentei usar o objeto edge em vez de apenas usar símbolos.
também está presente em https://gist.github.com/suchitpuri/9304856
fonte