Logo Studenta

Reconocimiento-de-palndromos-con-brecha-en-una-de-sus-mitades-y-su-implementacion-en-java

¡Este material tiene más páginas!

Vista previa del material en texto

UNIVERSIDAD NACIONAL AUTÓNOMA 
 DE MÉXICO 
 
 FACULTAD DE CIENCIAS 
 
 
RECONOCIMIENTO DE PALÍNDROMOS CON 
BRECHA EN UNA DE SUS MITADES Y SU 
IMPLEMENTACIÓN EN JAVA 
 
 
T E S I S 
 
 QUE PARA OBTENER EL TÍTULO DE: 
 
LICENCIADO EN CIENCIAS DE LA 
COMPUTACIÓN 
 
 
 P R E S E N T A : 
 
VÍCTOR ZAMORA GUTIÉRREZ 
 
 
 
 
DIRECTOR DE TESIS: 
DRA. ELISA VISO GUROVICH 
 
2018 
 
Margarita
Texto escrito a máquina
CIUDAD UNIVERSITARIA, CD. MX.
Margarita
Texto escrito a máquina
 
UNAM – Dirección General de Bibliotecas 
Tesis Digitales 
Restricciones de uso 
 
DERECHOS RESERVADOS © 
PROHIBIDA SU REPRODUCCIÓN TOTAL O PARCIAL 
 
Todo el material contenido en esta tesis esta protegido por la Ley Federal 
del Derecho de Autor (LFDA) de los Estados Unidos Mexicanos (México). 
El uso de imágenes, fragmentos de videos, y demás material que sea 
objeto de protección de los derechos de autor, será exclusivamente para 
fines educativos e informativos y deberá citar la fuente donde la obtuvo 
mencionando el autor o autores. Cualquier uso distinto como el lucro, 
reproducción, edición o modificación, será perseguido y sancionado por el 
respectivo titular de los Derechos de Autor. 
 
 
 
1. Datos del alumno
Zamora
Gutiérrez
Víctor
55 23 15 47
Universidad Naciona Autónoma de 
México
Facultad de Ciencias
Ciencias de la Computación
311529058
2. Datos del tutor
Dra.
Elisa
Viso
Gurovich
3. Datos del sinodal 1
Dr.
José de Jesús
Galaviz
Casas
4. Datos del sinodal 2
Dra.
María de Luz
Gasca
Soto
5. Datos del sinodal 3
Dra.
Amparo
López
Gaona
6. Datos del sinodal 4
Dr.
Canek
Peláez
Valdés
7. Datos del trabajo escrito
Reconocimiento de palíndromos con brecha en una de sus mitades y su 
implementación en Java
61 p
2018
Índice general
Introducción III
1. Preliminares 1
2. Algoritmo de Ukkonen 5
2.1. Ejemplo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.2. Código . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
3. Construcción de estructuras auxiliares 27
3.1. Construcción de arreglos de sufijos . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
3.1.1. Código . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
3.2. Construcción del arreglo de sufijos invertido . . . . . . . . . . . . . . . . . . . . . . . 29
3.2.1. Código . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
3.3. Construcción de arreglo LCP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
3.3.1. Código . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
3.4. Cálculo de consultas de rango mı́nimo . . . . . . . . . . . . . . . . . . . . . . . . . . 32
3.4.1. Código . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
4. Reconocimiento de SAGP 35
4.1. Propiedades . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
4.2. Algoritmo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
4.2.1. Código . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
4.3. Cálculo de Pals . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
4.3.1. Código . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
4.3.2. Complejidad . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
4.4. Cálculo de SAGP1(T ) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
i
4.4.1. Algoritmo ingenuo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
4.4.2. Algoritmo cuadrático simple . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
4.5. Cálculo de SAGP2(T ) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
4.5.1. Cálculo de FindR . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
4.5.2. Código para calcular SAGP2 . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
Conclusiones 55
Introducción
El análisis del ADN es uno de los campos más importantes de la bioloǵıa molecular, ya que
nos ayuda a entender mejor el funcionamiento de los organismos. Como el ADN se representa por
medio de cadenas, y estas contienen patrones, encontrar y estudiar estos patrones es un tema de
gran interés. En particular varias estructuras importantes tienen forma de paĺındromos. Por ejemplo,
algunas estructuras de horquillas y triplex con esta forma se pueden relacionar con la presencia de
cáncer en un organismo [7]. Por esto es importante diseñar algoritmos que encuentren rápidamente
presencias de paĺındromos en textos grandes.
Sin embargo, a veces buscar paĺındromos no nos basta para estudiar completamente estas es-
tructuras, ya que en la naturaleza pueden ocurrir mutaciones en el ADN.
Algunas veces es útil incluir paĺındromos con errores en nuestras búsquedas. De aqúı surgen los
paĺındromos aproximados, que son cadenas parecidas a los paĺındromos, pero con alguna imperfec-
ción, como puede ser un carácter de más o un hueco.
El incluir paĺındromos aproximados en nuestras búsquedas complica las cosas, ya que en general,
estas cadenas son más dif́ıciles de reconocer. Dado que los textos de ADN suelen ser bastante grandes,
buscamos que el reconocimiento de paĺındromos aproximados sea lo más eficiente posible. Además,
los algoritmos deben ser prácticos y fáciles de implementar.
Los SAGP 1 o paĺındromos con brecha en una de sus mitades son un tipo de paĺındromos
aproximados. Fueron descritos originalmente por Narisada et al. en [8].
El objetivo de este trabajo es mostrar e implementar algunos algoritmos de reconocimiento de
SAGP .
Como los SAGP son un concepto relativamente nuevo, casi no hay literatura sobre ellos. Este
trabajo está escrito con el fin de que más gente pueda entender cómo y por qué funcionan los
algoritmos de reconocimiento de estas cadenas.
Varios de los algoritmos quizá puedan parecer complicados al principio (sobre todo porque la
notación es una barrera grande en el reconocimiento de cadenas), y si uno los lee en las fuentes
originales, requiere bastante tiempo y paciencia entenderlos. Mi meta es hacer del reconocimiento
de SAP algo más fácil de entender, además de proporcionar al lector algunas pruebas de la validez
de los algoritmos y una implementación en código 2.
A lo largo del trabajo, mostraré el funcionamiento de cada algoritmo, aśı como fragmentos de
1Del inglés single-arm-gapped palindromes
2Este trabajo contiene fragmentos del código fuente. El código completo se encuentra en mi repositorio de Github,
en la siguiente liga: https://github.com/victorz3/Implementation-of-SAGP-recognition-in-Java
iii
iv INTRODUCCIÓN
su código fuente.
Los algoritmos están implementados en el lenguaje de programación Java, sin utilizar bibliotecas
externas.
El trabajo está organizado de la siguiente manera. En el caṕıtulo 1, explico algunos conceptos
básicos de cadenas para introducir al lector en el contexto del problema. En el caṕıtulo 2, me dedico
a construir árboles de sufijos por medio del algoritmo de Ukkonen. En el caṕıtulo 3, construyo otras
estructuras auxiliares para el reconocimiento de SAGP . En el caṕıtulo 4, explico cómo reconocer
los SAGP . Por último, el caṕıtulo 5 contiene las conclusiones del trabajo.
Caṕıtulo 1
Preliminares
Sea Σ un conjunto de caracteres, lo llamaremos alfabeto. Un lenguaje L es un subconjunto de
Σ∗, donde ∗ es la operación conocida como estrella de Kleene, es decir, el conjunto de todas las
cadenas posibles de tamaño finito sobre śımbolos en Σ (incluyendo a la cadena vaćıa, ε).
A lo largo de este trabajo, consideraremos al alfabeto Σ como fijo y finito. Dentro de la compu-tadora, podemos considerar al alfabeto como el conjunto de caracteres en la tabla ASCII.
Para toda cadena w, denotamos su longitud con |w|. Para todo 1 ≤ i ≤ |w|, denotamos con
w[i] al carácter en la i-ésima posición de w. Una subcadena de w es una cadena x tal que existe un
1 ≤ j ≤ |w| que cumple que para todo 1 ≤ i ≤ |x|, x[i] = w[j+ i]. En particular, la cadena vaćıa y la
cadena total son subcadenas. Podemos denotar a x como w[j..j + |x| − 1]. Por convención, decimos
que si i > j, w[i..j] es la cadena vaćıa.
En una cadena w = xyz, decimos que x es un prefijo y z es un sufijo. Es decir, un prefijo es
una subcadena x = w[1..i], para 0 ≤ i ≤ |w| y un sufijo es una subcadena z = w[j..|w|], para
1 ≤ j ≤ |w| + 1. En particular, w y ε son el prefijo y sufijo de w con mayor y menor tamaño,
respectivamente.
Denotamos la reversa de w como wR = w[|w|]...w[1].
Para dos cadenas X e Y , denotamos la longitud de su prefijo más grande en común con
lcp(X,Y )1.
Decimos que una cadena es paĺındromo si se lee de la misma forma hacia adelante y hacia atrás.
Es decir, un paĺındromo es una cadena x = wgwR, donde g es un carácter o la cadena vaćıa.
Una cadena se llama paĺındromo con brecha si es de la forma wgwR, donde w es una cadena no
vaćıa y |g| ≥ 0. En particular todo paĺındromo es también un paĺındromo con brecha.
Un paĺındromo con brecha en una de sus mitades (SAGP en inglés) es una cadena ya sea de la
forma wuguRbwR o de la forma wbuguRwR, donde w y u son cadenas no vaćıas, |g| ≤ 1 y |b| ≥ 1.
Llamamos brecha a la cadena b.
Para una cadena texto, su conjunto de subcadenas (también llamadas factores) se denota como
F(texto).
1Del inglés longest common prefix.
1
2 CAPÍTULO 1. PRELIMINARES
Sea T un árbol dirigido con ráız, tal que tiene etiquetas en las aristas. Denotamos con etiqueta(e)
a la etiqueta de la arista e. La etiqueta de un camino π es la concatenación de las etiquetas de las
aristas de π y se representa con etiqueta(π). Además, denotamos al conjunto de etiquetas de T con
Etiquetas(T ). Es decir, Etiquetas(T ) = {etiqueta(π)|π es un camino dirigido que empieza en la
ráız de T}.
Decimos que un árbol T representa a los factores de una cadena w si Etiquetas(T ) = F(w).
El árbol de sufijos T de una cadena w es un árbol dirigido con ráız que representa los factores
de w y además cumple que:
Todo nodo no hoja tiene al menos dos hijos (al menos dos aristas salientes).
Toda arista representa una cadena no vaćıa.
Todo camino π desde la ráız a una hoja de T representa un sufijo de w.
Todas las aristas salientes de un nodo empiezan con caracteres distintos.
En el árbol de sufijos de una cadena w, cada hoja representa un sufijo de w. Por lo tanto,
tendremos O(|w|) hojas, pues w tiene exactamente |w|+ 1 sufijos.
Como cada nodo no hoja tiene al menos dos hijos, entonces hay un número logaŕıtmico de nodos
internos.
Por lo tanto, el árbol tiene tamaño O(|w|).
El arreglo de sufijos de una cadena w, SAw, es un arreglo que contiene todos los sufijos de w
en orden lexicográfico. En realidad no es necesario guardar los sufijos como tales en este arreglo,
ya que podemos representar a un sufijo con un entero, el del ı́ndice con el que empieza. Es decir,
podemos decir que el ı́ndice i representar al sufijo w[i..|w|]. En general, consideraremos ambas
representaciones como válidas y utilizaremos la que más nos convenga. Cuando hablemos de cadenas,
utilizaremos la representación que guarda los sufijos completos, mientras que cuando hablemos de
enteros utilizaremos la otra. A la hora de programarlo, es más conveniente utilizar una representación
con enteros para no gastar memoria de manera innecesaria.
El arreglo de sufijos invertido de una cadena w, SA−1w , es un arreglo que cumple SA
−1
w [SAw[i]] = i
para 1 ≤ i ≤ |w|.
El arreglo LCP (Longest Common Prefix en inglés) o del mayor prefijo en común, guarda las
longitudes de los prefijos en común más grandes entre dos cadenas consecutivas en el arreglo de
sufijos. Este arreglo se denota como LCPw para una cadena w.
Formalmente, LCPw se define como:
LCPw[1] = −1.
Para 1 ≤ i ≤ |w|, LCPw[i] = lcp(SAw[i− 1], SAw[i]).
Definimos para una cadena T el arreglo Pals(T ) que tiene en la posición i la longitud de mitad
del paĺındromo más grande de T centrado en i.
3
También definimos la función LMostT que cumple que LMostT (c) = el ı́ndice de la primera
presencia de c en T (de izquierda a derecha).
Sea A un arreglo con valores enteros. Una consulta de rango mı́nimo (del inglés range minimum
query), CRM(i, j), nos dice el ı́ndice de [i, j] con menor valor en A.
Tras un preprocesamieno lineal, CRM(i, j) puede obtenerse en tiempo constante para cuales-
quiera dos ı́ndices i y j.
4 CAPÍTULO 1. PRELIMINARES
Caṕıtulo 2
Algoritmo de Ukkonen
Los árboles de sufijos tienen un uso muy amplio en algoritmos sobre cadenas [2]. Para construirlos
hay varios algoritmos entre los que se encuentra el algoritmo de Ukkonen, que es el que utilicé en
este trabajo.
Propuesto por Esko Ukkonen en 1995, el algoritmo de Ukkonen se utiliza para construir el árbol
de sufijos de una cadena en tiempo lineal sobre el tamaño de esta [9].
Aunque no fue el primer algoritmo de construcción de árboles de sufijos en tiempo lineal, el
algoritmo de Ukkonen tiene varias ventajas sobre sus predecesores. Primero, busca ser un algoritmo
fácil de entender, aunque en mi opinión no lo es tanto. Además, el algoritmo es en ĺınea, lo que
quiere decir que procesa la entrada carácter por carácter, leyendo de izquierda a derecha y conforme
va construyendo el árbol.
Esta implementación está hecha en el lenguaje de programación Java. Busqué que la implemen-
tación fuera transparente y fácil de entender. Por lo mismo, la implementación no está basada en
el art́ıculo original de Ukkonen, sino en la explicación, bastante más sencilla presentada por Goller
[4].
Al ser una explicación un tanto informal, hay varios casos que no deja claros para los cuales
tuve que idear estrategias, teniendo en cuenta el no incrementar la complejidad del algoritmo.
Para entender la implementación, es necesario entender el algoritmo de Ukkonen primero, aśı
que daré una breve explicación de este.
Lo que queremos es un árbol de tamaño lineal que contenga todos los sufijos de x. Vamos a ir
construyendo el árbol de manera iterativa y añadiendo sufijos en cada iteración.
Un detalle importante es que para que el árbol esté bien constrúıdo al final (con todos los sufijos
insertados), es necesario agregar un carácter especial de terminación. Esto es para asegurar que
todos los nodos que no son hojas tengan dos o más hijos, lo cual solamente se puede hacer si ningún
sufijo es prefijo de otro sufijo. Para este programa, decid́ı utilizar #.
El algoritmo a grandes rasgos funciona aśı:
Se empieza con un nodo ráız y tres variables, punto activo, restantes y el ı́ndice i del carácter
que está siendo procesado. La variable punto activo representa el punto del árbol sobre el que se
5
6 CAPÍTULO 2. ALGORITMO DE UKKONEN
insertará el siguiente sufijo, el cual puede ser tanto un nodo como un punto en una arista. La variable
restantes indica el número de sufijos por insertar en la iteración en la que estamos. La idea es que
restantes se incremente en 1 en cada iteración y que se disminuya cada vez que insertemos un sufijo.
Tanto restantes como i se inicializan en 0 y punto activo se inicializa en la ráız. El punto activo
se representa con una 3-tupla (nodo activo, arista activa, longitud), que tiene el siguiente significa-
do:
nodo activo es el nodo a partir del cual encontraremos el punto activo. El punto activo puede
ser el nodo activo o un punto sobre alguna de las aristas que salen de este.
arista activa es la arista sobre la que vamos a insertar el siguiente sufijo, si es aplicable (es
decir, si vamos a insertar sobre un nodo, la arista activa es nula). Para fines didácticos vamos
a representar a la arista activa conel carácter con el que empieza, ya que al ser este un árbol
de sufijos, todas las aristas salientes de un nodo (en este caso, el nodo activo) empiezan con
un carácter distinto. Sin embargo, a la hora de programar, veremos que no siempre conviene
esa representación. Inicialmente, la arista activa vale ‘\0’, es decir, el carácter nulo, lo que
quiere decir que la arista en śı es nula.
longitud es el punto en la arista activa sobre el que insertaremos. Al inicio del algoritmo vale
0. Siempre que longitud valga 0, insertaremos sobre un nodo en lugar de sobre una arista.
Se itera sobre los caracteres de la cadena. En cada iteración, se incrementa i y se le suma uno
a restantes.
El ı́ndice i no solo funciona como apuntador en la cadena, también tiene la función de decirnos
hasta qué punto llega una arista. Es decir, como las subcadenas de las aristas se representan por
dos ı́ndices, inicio y fin (donde inicio dice dónde empieza la subcadena y fin nos dice dónde
termina), cada vez que creamos una arista, decimos que termina en i. Aśı, cuando actualicemos i,
la subcadena de la arista se actualizará automáticamente.
Por ejemplo, digamos que estamos construyendo el árbol de sufijos para la cadena banana. En
la primera iteración, vamos a insertar la b. Pero en lugar de representarla con la pareja (1, 1), la
representaremos con la pareja (1, i). Aśı:
[1, i]
Es decir que nuestra arista representa a la cadena b:
b
Luego en la siguiente iteración, i se incrementa. Aunque nuestra arista sigue viéndose aśı:
[1, i]
La cadena que representa ahora es banana#[1..2]:
7
ba
Sabiendo esto, el algoritmo es el siguiente:
En cada iteración, leemos un nuevo carácter. Esto a su vez significa que tenemos que insertar
un nuevo sufijo.
Los sufijos que vamos a insertar en cada iteración no son sufijos de la cadena total en śı, sino
de la cadena léıda hasta el momento, ya que el algoritmo es en ĺınea. Aqúı es donde entra el truco
de que el extremo derecho de las aristas se actualice automáticamente conforme crece la i; aśı, no
tenemos que preocuparnos por hacer esta actualización a mano.
Para el carácter que léımos, tenemos dos casos:
(a) El carácter que léımos ya fue insertado justo después del punto activo.
(b) El carácter que léımos no ha sido insertado justo después del punto activo.
Caso (a)
En el caso (a), incrementamos longitud y actualizamos la arista activa de ser necesario. Si la
arista activa era nula, entonces hay que buscar la arista que comience con el carácter que léımos.
En otro caso, solo hay que movernos un espacio sobre la arista activa. A veces nos saldremos de
la arista activa y en estos casos hay que actualizar el punto activo, que va a quedar en el extremo
derecho de la arista activa.
Por ejemplo, observemos la siguiente arista activa:
n1 n2
abc
Supongamos que el punto activo está justo antes de la c. Es decir, longitud vale 2. Supongamos
también que el siguiente carácter léıdo es c. Entonces, nos damos cuenta de que c ya fue insertado
(pues bien, está justo después del punto activo) y lo único que hacemos es incrementar longitud
como dicta el algoritmo. Entonces longitud vale 3, que es la longitud de la arista activa. En este
caso, tendremos que actualizar el punto activo a (n2, ‘\0’, 0) para que sea válido.
Caso (b)
Ya sabemos que el carácter actual no ha sido insertado. El punto activo nos indica en dónde
tenemos que insertarlo.
Nos falta insertar restantes sufijos, desde cadena[i− restantes+ 1..i] hasta cadena[i]. Para ello,
utilizaremos un ciclo while. Mientras restantes sea mayor que 0, nos fijamos si el carácter actual
ya fue insertado (nótese que en la primera iteración del while, esta condición nunca va a darse;
podŕıamos utilizar un do..while para ahorrarnos esta verificación). Si ya fue insertado, entonces
incrementamos longitud (como en el caso (a)) y nos salimos del while. En otro caso, hay que
8 CAPÍTULO 2. ALGORITMO DE UKKONEN
insertar el siguiente sufijo, es decir, hay que insertar (cadena[i− restantes+ 1..i]). Para esto, si el
punto activo es un nodo, entonces creamos una nueva arista y lo insertamos ah́ı. En otro caso, lo
vamos a insertar por medio de una operación llamada split, que básicamente parte la arista activa
a partir del punto activo, dándonos aśı tres aristas (la arista original y dos nuevos hijos) lo cual nos
genera un nuevo sufijo. Por ejemplo, si tenemos la siguiente arista:
a1 a2
abcabcf
Si el punto activo está justo después de la primera ‘c’, entonces al hacer un split, obtenemos la
siguiente figura:
a1 a2
b1
abcf
b2
f
abc
Después de insertar, disminuimos restantes, actualizamos el punto activo y volvemos al inicio
del while. Además, en caso de que hayamos hecho un split, es necesario revisar si es el primero
de la iteración, ya que si no lo es, tenemos que crear una arista invisible que va desde el último
nodo al que se hizo split hacia este. Esta arista invisible se llama liga de sufijo y se utiliza en la
actualización del punto activo.
La actualización del punto activo es la parte más importante (y complicada) del algoritmo. Para
explicarla, será más fácil utilizar un ejemplo. Veamos cómo se construye el árbol de sufijos de la
palabra abcabxabc.
2.1. Ejemplo
El primer paso en el algoritmo es pegarle el carácter de terminación (#) a nuestra cadena.
Entonces, vamos a construir el árbol de sufijos de la cadena abcabxabc#. Partimos de un nodo ráız
al que llamaré root.
root
nodo activo=root
arista activa=‘\0’
longitud=0
restantes=0
El nodo activo siempre estará en color rojo. En este caso, el nodo activo empieza siendo la ráız,
como hab́ıamos mencionado anteriormente.
Luego, leemos el primer carácter de nuestra cadena. Es ‘a’. Incrementamos restantes e i, lo que
los pone en 1. Como ‘a’ no ha sido insertado, lo insertamos en el punto activo, y como este es un
nodo, hay que crear una nueva arista que vaya desde la posición de ‘a’ (1) hasta i. La arista irá a
un nuevo nodo n1. Se verá aśı:
2.1. EJEMPLO 9
root n1
[1..i]
Que es la implementación de:
root n1
a
nodo activo=root
arista activa=‘\0’
longitud=0
restantes=0
Disminúımos restantes y leemos el siguiente carácter. Para los siguientes dos caracteres, pasará
lo mismo (se insertarán sin problemas), quedando aśı nuestro árbol, tras tres iteraciones 1:
root
n1
abc
n2
bc
n3
c
nodo activo=root
arista activa=‘\0’
longitud=0
restantes=0
En la siguiente iteración empieza lo interesante. Leemos el siguiente carácter, es decir ‘a’. Nos
fijamos que a partir del punto activo (que en este caso consiste del nodo activo), el carácter ‘a’
ya fue insertado (en la arista que va a n3). Por lo tanto, en lugar de insertar el siguiente sufijo,
movemos el punto activo a (root,‘a’, 1). Es decir, el punto activo está en la arista que va de root a
n1, entre la a y la b. Además, como incrementamos i y léımos otro carácter, el árbol se actualiza
automáticamente y queda aśı:
root
n1
abca
n2
bca
n3
ca
nodo activo=root
arista activa=‘a’
longitud=1
restantes=1
En la siguiente iteración, volvemos a incrementar restantes (ahora vale 2) y leemos el siguiente
1De aqúı adelante graficaré la abstracción del árbol, que es más clara que su implementación
10 CAPÍTULO 2. ALGORITMO DE UKKONEN
carácter. Para esto, incrementamos i. Como el siguiente carácter (‘b’) ya fue insertado a partir del
punto activo, solamente incrementamos longitud y el árbol queda:
root
n1
abcab
n2
bcab
n3
ca
b
nodo activo=root
arista activa=‘a’
longitud=2
restantes=2
En la siguiente iteración incrementamos i y leemos ‘x’. Al actualizar la i, se actualizan las aristas
del árbol aśı:
root
n1
abcabx
n2
bcabx
n3
ca
bx
nodo activo=root
arista activa=‘a’
longitud=2
restantes=3
Como ‘x’ no ha sido insertado a partir del punto activo, debemos insertarlo por medio de un
split. El árbol queda como sigue:
root
n1
n4
cabx
n5
x
ab
n2
bcabx
n3
ca
bx
nodo activo=root
arista activa=‘b’longitud=1
restantes=2
2.1. EJEMPLO 11
Decrementamos restantes en 1. Además, actualizamos el punto activo de acuerdo a la siguiente
regla:
Regla de actualización 1: Cuando hagamos una inserción en la que
root es el nodo activo
o
el punto activo es un nodo,
el punto activo se actualiza de la siguiente manera:
El nodo activo es root.
La arista activa se actualiza a la arista correspondiente al primer carácter del siguiente sufijo
por insertar. Los sufijos se van insertando de más grande a más pequeño. Como acabamos de
insertar el sufijo abx (el más grande de los 3 restantes), el siguiente sufijo por insertar será
el siguiente en tamaño, es decir, bx. Por lo tanto, la arista activa se actualiza a ‘b’.
La longitud se reduce en 1.
Notemos que la actualización de la arista activa solo se da si longitud no se vuelve 0.
Como aún nos hace falta insertar 2 sufijos, volvemos a verificar si el carácter ‘x’ ya fue insertado
a partir del punto activo. Como no es aśı, hacemos otro split.
De nuevo, actualizaremos el punto activo de acuerdo a la regla de actualización 1. Como longitud
ya vale 0, el punto activo automáticamente vuelve a ser un nodo (el nodo activo), por lo que la
arista activa vuelve a ser nula y el punto activo es root. Además, como hicimos dos split en la
misma iteración, debemos aplicar la siguiente regla:
Regla de split: Si hacemos un split sobre un nodo n y este no es el primer split de la iteración,
entonces es necesario conectar el nodo sobre el que se hizo el último split a n por medio de lo que
se llama una liga de sufijo.
En este caso, creamos una liga de sufijo desde n1 hasta n2. En cada nodo, escribiremos su liga
de sufijo (si existe) debajo del nombre del nodo.
12 CAPÍTULO 2. ALGORITMO DE UKKONEN
root
n1
n2
n4
cabx
n5
x
ab
n2 n6
cabx
n7
x
b
n3
ca
bx
nodo activo=root
arista activa=‘\0’
longitud=0
restantes=1
Falta insertar un sufijo más. Como ‘x’ no ha sido insertado, lo insertamos en el punto activo.
Es decir, creamos una arista que sale directamente de root.
root
n1
n2
n4
cabx
n5
x
ab
n2 n6
cabx
n7
x
b
n3
cab
x
n8
x
nodo activo=root
arista activa=‘\0’
longitud=0
restantes=0
Como ya insertamos todos los sufijos restantes, seguimos con el algoritmo.
Incrementamos i, dejándonos el siguiente árbol:
2.1. EJEMPLO 13
root
n1
n2
n4
cabxa
n5
xa
ab
n2 n6
cabxa
n7
xa
b
n3
cab
xa
n8
xa
nodo activo=root
arista activa=‘a’
longitud=1
restantes=1
El siguiente carácter léıdo es ‘a’, y como ya fue insertado, el punto activo cambia a (root,‘a’, 1).
Nos movemos a la siguiente iteración.
De nuevo incrementamos i, lo que nos da el siguiente árbol:
root
n1
n2
n4
cabxab
n5
xab
ab
n2 n6
cabxab
n7
xa
b
b
n3
cab
xab
n8
xa
b
nodo activo=n1
arista activa=‘\0’
longitud=0
restantes=2
Como la ’b’ ya fue insertada, el punto activo debeŕıa moverse a (root,‘a’, 2). Pero notemos que
este punto activo se sale de la arista activa. En este caso, lo que tenemos que hacer es mover el
punto activo al nodo derecho de dicha arista. Es decir, el punto activo en realidad se mueve a (n1,
14 CAPÍTULO 2. ALGORITMO DE UKKONEN
‘\0’, 0).
Incrementamos i y leemos el siguiente carácter, que es ‘c’. Como este ya fue insertado, solo es
necesario actualizar el punto activo. El árbol queda aśı:
root
n1
n2
n4
cabxabc
n5
xab
c
ab
n2 n6
cabxabc
n7
xa
bc
b
n3
cab
xab
c
n8
xa
bc
nodo activo=n1
arista activa=‘c’
longitud=1
restantes=3
Por último, incrementamos i y leemos el carácter de terminación.
root
n1
n2
n4
cabxabc#
n5
xab
c#
ab
n2 n6
cabxabc#
n7
xa
bc
#
b
n3
cab
xab
c#
n8
xa
bc
#
nodo activo=n1
arista activa=‘c’
longitud=1
restantes=4
Esta vez, el carácter léıdo (#) no ha sido insertado a partir del punto activo (pues efectivamente,
es la primera vez que lo vemos). Por lo tanto, hay que insertarlo por medio de un split:
2.1. EJEMPLO 15
root
n1
n2
n4
n9
abxabc#
n10
#c
n5
xab
c#
ab
n2 n6
cabxabc#
n7
xa
bc
#
b
n3
cab
xab
c#
n8
xa
bc
#
nodo activo=n2
arista activa=‘c’
longitud=1
restantes=3
En este caso la actualización del punto activo se hace de acuerdo a una nueva regla:
Regla de actualización 2: Después de hacer un split sobre un punto activo con nodo activo
distinto de root:
Si el nodo activo tiene liga de sufijo, entonces el nodo al que está ligado se vuelve el nuevo
nodo activo. Si no la tiene, root se asigna como el nuevo nodo activo.
La arista activa se cambia dependiendo de si el nodo activo teńıa liga de sufijo o no. En el
primer caso, la arista activa se queda igual. En el segundo caso, la arista activa se actualiza
al carácter i− restantes (donde si restantes es 0, se asigna nula).
La longitud no se cambia.
Tras hacer la actualización siguiendo esta regla, el punto activo será (n2,‘c’, 1).
Ahora, como aún nos falta insertar sufijos, hay que revisar si el carácter actual ya fue insertado.
Como no es aśı, hacemos otro split, por lo que el árbol queda como sigue:
16 CAPÍTULO 2. ALGORITMO DE UKKONEN
root
n1
n2
n4
n6
n9
abxabc#
n10
#c
n5
xab
c#
ab
n2 n6
n11
abxabc#
n12
#
c
n7
xa
bc
#
b
n3
cab
xab
c#
n8
xa
bc
#
nodo activo=root
arista activa=‘c’
longitud=1
restantes=2
El nodo activo vuelve a actualizarse de acuerdo a la regla 2. Además, no olvidemos la regla de
split, que nos indica que debe haber una liga de sufijo entre n4 y n6.
Nos falta insertar dos sufijos. Como de nuevo ‘#’ no ha sido insertado, hacemos otro split,
quedando el árbol como se ve abajo.
root
n1
n2
n4
n6
n9
abxabc#
n10
#
c
n5
xabc#
ab
n2
n6
n3
n11
abxabc#
n12
#c
n7
xab
c#b
n3 n13
abxabc#
n14
#
c
n8
xa
bc
#
nodo activo=root
arista activa=‘\0’
longitud=0
restantes=1
Podemos observar en este árbol que insertamos el sufijo c#. Además, agregamos otra liga de
sufijo de n6 a n3.
2.2. CÓDIGO 17
Nos falta insertar un último sufijo. De acuerdo a la regla 1, el punto activo se actualizó a
(root,‘\0’, 0), por lo que el último sufijo se insertará directamente sobre la ráız, como se observa en
la siguiente figura.
root
n15
# n1
n2
n4
n6
n9
abxabc#
n10
#
c
n5
xabc#
ab
n2
n6
n3
n11
abxabc#
n12
#c
n7
xab
c#
b
n3 n13
abxabc#
n14
#
c
n8
xa
bc
#
nodo activo=root
arista activa=‘\0’
longitud=0
restantes=0
Como restantes ya vale 0 y ya no hay más caracteres que leer, el árbol queda terminado.
Con esto, tenemos todas las reglas de árboles de sufijos.
2.2. Código
Codificar el algoritmo de Ukkonen viene con varios problemas. En la teoŕıa, como pudimos
observar, el algoritmo no es terriblemente complicado. Sin embargo, hay varios detalles que hacen
que su implementación no sea tan limpia como uno deseaŕıa.
El código consiste de una clase principal (Ukkonen.java) y varias clases para definir tipos de
datos. La clase principal solamente implementa el algoritmo y varios métodos auxiliares.
La clase Ukkonen tiene las siguientes variables:
private final String s; /* La cadena para la cuál construı́mos el árbol */
/* Las siguientes variables son variables especiales del algoritmo de Ukkonen */
private Arista activeEdge = null;
18 CAPÍTULO 2. ALGORITMO DE UKKONEN
private int activeLength = 0;
private int restantes = 0; /* Sufijos por insertar en la ronda actual del algoritmo de
Ukkonen */
private final Nodo root = new Nodo(); /* La raı́z del árbol */
private Nodo activeNode = root; /* El nodo activo debe inicializarse en la raı́z */
Es necesario guardar la cadena s no solo para poder leer caracteres en ĺınea, sino también para
poder sacar las subcadenas correspondientes a cada arista (pues recordemos, la representación de
una arista debe tener tan solo dos ı́ndices que indiquen donde empieza y donde termina su subcadena;
de otra manera, el árbol no tendŕıa tamaño lineal). Por lo mismo, la cadena está declarada como
final. De modificarla, los ı́ndices de las aristas perdeŕıan sentido.
Las clases que utilizo además de esta son Nodo, Arista y SuffixTree,que definen los tipos
respectivos con sus operaciones.
La clase Nodo sirve para representar a los nodos del árbol. Cada nodo puede guardar su liga de
sufijos, de qué arista viene (es decir, a la derecha de qué arista está el nodo), una lista de aristas
que salen de él y la longitud de la subcadena que va desde la ráız hasta el nodo.
public class Nodo{
private Nodo suffixLink = null; /* Suffix Link */
private Arista padre; /* La arista que incide en el nodo */
private List<Arista> aristas = new ArrayList<>(); /* Aristas que salen del Nodo */
private int longitud = 0; /* Longitud de la subcadena que va desde la raı́z hasta este
Nodo en el árbol de sufijos. */
La longitud la guardamos para poder realizar la actualización del punto activo en tiempo cons-
tante. En algunos casos, saltamos desde un nodo a otro mediante una liga de sufijos, y es necesario
saber en qué parte de la cadena estamos para poder actualizar el punto activo de manera correcta.
Por ejemplo, si tenemos la siguiente figura:
n1 n2 n3
n4
bc
n5
a
ac
Supongamos que acabamos de saltar a n2. Digamos que el siguiente sufijo a insertar es cabcad
y la arista activa vale ‘a’. Si longitud es mayor que 1 entonces, nos vamos a salir de la arista y
debemos elegir una de las aristas que salen de n3 para insertar el sufijo por ah́ı.
Es importante saber si la a de la arista que acabamos de leer es la a después de la primera c o
después de la segunda (para saber hacia dónde tenemos que recorrer el punto activo). Utilizando la
longitud de n3, podemos saber en qué parte de la subcadena estamos parados.
Es necesario mantener ordenada la lista de aristas del Nodo. Esto para poder hacer DFS lexi-
cográfico en tiempo lineal.2
2Imprimir los sufijos en orden lexicográfico es una funcionalidad deseable de los árboles de sufijos.
2.2. CÓDIGO 19
La longitud de un nodo empieza en 0 y se actualiza cuando hacemos un split sobre él. Teniendo
esto en cuenta, la longitud no siempre va a estar actualizada. Sin embargo, śı estará actualizada
siempre que la necesitemos (la longitud solo será necesaria en nodos sobre los que hayamos hecho
split, pues solo se necesita para decidir “qué camino tomar”). Con esto, mantenemos la complejidad
en tiempo del algoritmo.
Mantener ordenada la lista de aristas de un Nodo nos toma tiempo constante, ya que un nodo
tiene a lo más tantas aristas que salen de él como el tamaño del alfabeto (esto por la regla de que
las aristas salientes de un nodo deben empezar con caracteres distintos) y el alfabeto es de tamaño
constante.
Utilicé el siguiente código para agregar una nueva Arista a la lista del Nodo.
public void nuevaArista(Arista a){
int pos = Collections.binarySearch(aristas, a);
if (pos < 0){
aristas.add(-pos-1, a);
}
}
El código anterior busca la posición de inserción utilizando búsqueda binaria y mete la Arista
ah́ı.
Para poder utilizar la búsqueda binaria de la clase Collections, fue necesario hacer a la clase
Arista comparable. Compararar dos aristas consiste en comparar el carácter con el que comienzan,
tomando en cuenta que el carácter especial ‘#’ debe ser menor que cualquier otro carácter.
Además, para evitar el trabajo de insertar aristas en la lista del Nodo, el constructor de Arista
la inserta automáticamente aśı:
20 CAPÍTULO 2. ALGORITMO DE UKKONEN
public Arista(char primero, Nodo desde, Nodo hasta, int inicio, MutableInt fin){
this.primero = primero;
this.desde = desde;
this.hasta = hasta;
this.hasta.setPadre(this);
this.inicio = inicio;
this.fin = fin;
desde.nuevaArista(this);
this.hasta.setLongitud(desde.getLongitud() + this.longitud());
}
Las aristas guardan: su primer carácter, sus nodos izquierdo y derecho, aśı como sus ı́ndices de
inicio y fin. El fin se guarda como un MutableInt. Más adelante, explicaré cómo funciona dicha
clase, pero por ahora, podemos verlo como una envoltura de enteros que puede mutar (a diferencia
de la clase Integer de Java, que utiliza valores constantes).
El algoritmo consiste de un ciclo principal. En dicho ciclo, se va actualizando la i y se lee el
siguiente carácter de la cadena, la cual fue recibida como entrada previamente.
Como queremos que el algoritmo ocupe memoria lineal sobre el tamaño de la cadena (pues no
tendŕıa chiste usar este algoritmo de otra manera), entonces es necesario que las aristas contengan
solamente dos ı́ndices, en lugar de subcadenas. Aśı, cada arista ocupa memoria constante. Además,
no nos basta usar una estructura de datos primitiva int para los ı́ndices, pues queremos que se
actualicen automáticamente en nuestras aristas en cada iteración. En Java, esto se logra con una
clase auxiliar MutableInt, que representa un objeto entero que, cuando se incrementa, automáti-
camente se actualiza en todas las estructuras que lo utilicen3. La clase MutableInt es muy sencilla,
ya que solo es una envoltura de int con operaciones de autoincremento y comparación. Su código
es el siguiente:
public class MutableInt implements Comparable<Integer>{
private int value; /* Valor del entero */
/* Crea un nuevo entero mutable */
public MutableInt(int value){
this.value = value;
}
/* Autoincremento */
public void plusplus(){
value++;
}
/* Regresa el valor del entero */
public int getValue(){
return value;
3En general, los objetos en Java son mutables, por lo que al actualizarlos se modifican todas las instancias que
hacen referencia a ellos. Por lo mismo, a primera vista, parece conveniente utilizar la clase Integer para nuestra
representación de aristas. Sin embargo, esto no basta, ya que la clase Integer no es mutable por diseño.
2.2. CÓDIGO 21
}
/* Compara nuestro mutableInt con un objeto de la clase Integer */
@Override
public int compareTo(Integer i){
return this.value - i;
}
/* Nos dice si dos enteros mutables tienen el mismo valor */
@Override
public boolean equals(Object o){
if(!(o instanceof MutableInt))
return false;
MutableInt otro = (MutableInt) o;
return otro.getValue() == value;
}
/* Devuelve una cadena con el número */
@Override
public String toString(){
return Integer.toString(value);
}
El ciclo principal del algoritmo consiste de un for que va incrementando un MutableInt me-
diante el método plusplus:
MutableInt i = new MutableInt(0); /* Contador. */
for(;i.compareTo(s.length()) < 0; i.plusplus()){
En cada iteración del ciclo, inicializamos unas variables y leemos el siguiente carácter de la
cadena (el i-ésimo).
Nodo ultimoSplit = null; /* Último nodo sobre el que se hizo split */
boolean primeroInsertado = true; /* Nos dice si el sufijo fue el primero en insertarse
en esta iteración */
actual = s.charAt(i.getValue()); /* Leemos el siguiente carácter */
restantes++; /* Un sufijo más por insertar */
if(insertado(actual))
avanzaPuntoActivo(actual);
La variable ultimoSplit representa el último nodo sobre el que se hizo un split. primeroInsertado
es un valor lógico que nos indica si ya hicimos split o no (para saber si hay que actualizar enlaces
de sufijo).
De ah́ı, para ver si este ya fue insertado, utilizamos el siguiente método:
/* Nos dice si el carácter ya fue insertado a partir del punto activo.
* También actualiza la arista activa si esta no existe. */
public boolean insertado(char caracter){
if(activeEdge == null){ /* No hay arista activa */
22 CAPÍTULO 2. ALGORITMO DE UKKONEN
for(Arista vecino : activeNode.getAristas()){
if(startsWith(caracter, vecino))
return true;
}
}else
if(get(activeLength, activeEdge) == caracter)
return true;
return false;
}
Básicamente lo que hace ese método es dividir en dos casos: el punto activo es un vértice o el
punto activo está dentro de una arista.
En el primer caso nos fijamos, para las aristas que salen del vértice, si alguna comienza con el
carácter léıdo. Si es aśı, regresamos true. De otra manera, regresamos false.
En el segundo caso, nos fijamos si el siguiente carácter en la arista activa es el que léımos.
El método startsWithnos indica si la arista inicia con un carácter dado. El método getAristas
devuelve todas las aristas que salen de un nodo.
Si el carácter se encuentra, se actualiza el punto activo tal cual de acuerdo a las reglas. Esto lo
realiza el método avanzaPuntoActivo:
public void avanzaPuntoActivo(char actual){
if(activeEdge == null) /* Si no hay arista activa, la creamos */
activeEdge = this.busca(actual); /* Buscamos la arista que empieza con el
carácter */
/* Verificamos si ya nos salimos de la arista */
if(activeLength +1 == activeEdge.longitud()){
activeNode = activeEdge.getHasta();
activeLength = 0;
activeEdge = null;
}else /* No nos salimos de la arista */
activeLength++;
}
Esta rutina incrementa la longitud activa y actualiza la arista activa de ser necesario.
Si tenemos el siguiente punto activo:
n1 n2
a nodo activo=n1
arista activa=‘\0’
longitud=0
Al leer ‘a’, el punto activo se actualizará a (n2,‘\0’, 0), en lugar de a (n1,‘a’, 1).
La parte dif́ıcil del algoritmo empieza cuando el carácter no ha sido insertado, ya que entonces
tenemos que insertar un sufijo. Aqúı utilizo otro ciclo. Esta vez es un while que en el mejor de los
casos, se ejecuta mientras restantes > 0 (esto no siempre será aśı, pues como vimos anteriormente
hay veces en las que no es posible insertar todos los sufijos en la misma iteración). El cuerpo del
while básicamente hace todo lo que ya vimos: ver si el nodo activo es arista o vértice, insertar o
2.2. CÓDIGO 23
hacer un split, y después actualizar el punto activo y las ligas de sufijo de acuerdo a las reglas.
while(restantes > 0){
if(insertado(actual)){
avanzaPuntoActivo(actual);
break;
}
if(activeEdge == null){ /* El caso de insertar en una arista completamente nueva */
Nodo nuevo = new Nodo(); /* Extremo de la nueva arista */
Arista nueva = new Arista(s.charAt(i.getValue()), activeNode, nuevo,
i.getValue(), i);
restantes--;
primeroInsertado = false;
if(activeNode != root){
if(restantes == 1){
activeNode = root;
activeEdge = null;
activeLength = 0;
continue;
}else
regla2(i);
}
}else{ /* Partimos la arista */
split(i);
if(!primeroInsertado)
ultimoSplit.setSuffixLink(activeEdge.getHasta()); /* Actualización de enlace
de sufijo */
else
primeroInsertado = false;
/* Actualizamos el último nodo insertado */
ultimoSplit = activeEdge.getHasta();
restantes--;
if(activeNode == root){
activeLength--;
activeEdge = activeLength == 0 ? null :
busca(s.charAt((i.getValue()-restantes)+1));
verificaSalida(i);
}else
regla2(i);
}
}
Si el carácter no ha sido insertado, se manejan dos casos: insertar de nodo o insertar de arista.
Aunque las cosas se vuelven un tanto complicadas aqúı, realmente solo es cuestión de seguir
las reglas con cuidado. Para saber si hay que actualizar alguna liga de sufijo, guardo una varible
ultimoSplit que contienen el último nodo sobre el que se hizo un split.
Para la actualización del punto activo, sigo las reglas del algoritmo. Sin embargo, estas no toman
en cuenta el hecho de que al actualizar el punto activo, podemos caer en un punto inválido. Para
esto, hice una rutina que verifica si en la actualización caemos en un punto inválido y si es aśı,
24 CAPÍTULO 2. ALGORITMO DE UKKONEN
mueve el punto activo a donde debeŕıa estar. El método que realiza esto es el siguiente:
public void verificaSalida(MutableInt i){
while(activeEdge != null && activeLength >= activeEdge.longitud()){
activeNode = activeEdge.getHasta();
activeLength -= activeEdge.longitud();
if(activeLength != 0 && s.length() >
((i.getValue()-restantes)+1)+activeNode.getLongitud())
activeEdge =
busca(s.charAt(((i.getValue()-restantes)+1)+activeNode.getLongitud()));
else
activeEdge = null;
}
}
Justamente aqúı es cuando utilizamos la longitud de los nodos.
El código para hacer un split es el siguiente:
public void split(MutableInt indice){
int puntoPartida = activeEdge.getInicio()+(activeLength-1); /* Punto en el que
partimos nuestra arista */
if(activeEdge.getHasta().esHoja()){
activeEdge.setFin(new MutableInt(puntoPartida));
/* Dos nuevos nodos en los que se divide la arista: */
Nodo nuevo1 = new Nodo();
Nodo nuevo2 = new Nodo();
Arista nueva1 = new Arista(s.charAt(puntoPartida+1), activeEdge.getHasta(),
nuevo1, puntoPartida+1, indice);
Arista nueva2 = new Arista(s.charAt(indice.getValue()), activeEdge.getHasta(),
nuevo2, indice.getValue(), indice);
}else{
Nodo nuevo1 = new Nodo(); /* Nuevo nodo hasta el que llega la arista */
Nodo derecho = activeEdge.getHasta(); /* Extremo derecho de la arista activa */
MutableInt acaba = activeEdge.getFin(); /* Donde acaba la arista activa */
activeEdge.setHasta(nuevo1);
activeEdge.setFin(new MutableInt(puntoPartida));
Arista conexion = new Arista(s.charAt(puntoPartida+1), nuevo1, derecho,
puntoPartida+1, acaba);
Nodo nuevo2 = new Nodo(); /* Nodo recién insertado */
Arista nueva = new Arista(s.charAt(indice.getValue()), nuevo1, nuevo2,
indice.getValue(), indice);
}
}
Como mencioné previamente, dentro de los split actualizamos la longitud de los nodos. Esto no
se hace directamente, sino en los métodos setFin y setHasta de la clase Arista:
public void setHasta(Nodo n){
this.hasta = n;
n.setPadre(this);
2.2. CÓDIGO 25
this.hasta.setLongitud(desde.getLongitud() + this.longitud());
}
public void setFin(MutableInt fin){
this.fin = fin;
this.hasta.setLongitud(desde.getLongitud() + this.longitud());
}
Al hacer un split solo hay que crear dos nuevas aristas y actualizar ı́ndices. Cuando nuestra
arista no termine en una hoja, debemos tener cuidado de no perder la referencia del subárbol que
sale de ella.
Para saber si una arista termina en hoja o no, utilizamos el método esHoja que nos dice si un
nodo tiene aristas o no.
Por último, el método regla2 es el siguiente:
public void regla2(MutableInt i){
activeNode = activeNode.getSuffixLink();
if(activeNode == null){
activeNode = root; /* Si no habı́a enlace de sufijo, la raı́z se vuelve el nodo
activo */
activeEdge = busca(s.charAt(i.getValue() - restantes + 1));
activeLength = restantes-1;
}else{
if(activeEdge != null)
activeEdge = busca(activeEdge.getPrimero());
}
verificaSalida(i);
}
Como su nombre lo dice, este método actualiza el punto activo de acuerdo a la regla 2 del
algoritmo. Al final, llamamos a verificaSalida por si el punto activo es inválido.
26 CAPÍTULO 2. ALGORITMO DE UKKONEN
Caṕıtulo 3
Construcción de estructuras auxiliares
En este caṕıtulo me dedicaré a construir otras estructuras auxiliares para el reconocimiento de
SAGP .
3.1. Construcción de arreglos de sufijos
Ahora que sabemos cómo construir árboles de sufijos, construir arreglos de sufijos es sencillo. El
algoritmo para construir un arreglo de sufijos de una cadena w es el siguiente:
1. Construimos el árbol de sufijos, T , de w.
2. Recorremos T con DFS, tomando la rama de menor orden lexicográfico en cada paso1. Como
nuestras aristas están ordenadas, esto no require ningún cómputo adicional.
3. Cada vez que lleguemos a una hoja, copiamos su sufijo correspondiente en un arreglo. Para
sacar el sufijo correspondiente a una hoja, basta con tener una variable auxiliar que contenga
la cadena perteneciente al camino que hemos recorrido.
El algoritmo anterior tiene complejidad O(|w|), ya que, como hab́ıamos visto, el paso 1 toma
O(|w|) y al tener T tamaño lineal, recorrerlo también toma O(|w|).
3.1.1. Código
El código para la construcción de arreglos de sufijos se encuentra en la clase SuffixTree.java,
la cual tiene los siguientes atributos:
public class SuffixTree{
private final String cadena; /* La cadena sobre la que se elaboró el árbol */
private final Nodo raiz; /* La raı́z del árbol */
1Consideraremos al carácter especial # como el carácter de menor peso lexicográfico.
27
28 CAPÍTULO 3. CONSTRUCCIÓN DE ESTRUCTURAS AUXILIARES
private List<Integer> suffixArray; /* El arreglo de sufijos de la cadena del árbol */
private int[] reversed;/* El arreglo de sufijos invertido */
//Resto del código
}
Es necesario guardar la cadena total para poder identificar la subcadena que representa cada
arista.
Como cada nodo tiene una referencia a las aristas que salen de él, no es necesario guardar nada
además de la ráız del árbol.
Guardé el arreglo de sufijos como variable porque lo vamos a necesitar en el futuro, y aśı
podremos tener acceso a él sin necesidad de calcularlo más de una vez. No lo instanciaremos sino
hasta que lo vayamos a utilizar por primera vez.
Similarmente, el arreglo de sufijos invertido y el arreglo LCP se guardan como variables.
Para obtener el arreglo de sufijos, utilicé los siguientes métodos:
public List<Integer> getSuffixArray(){
if(this.suffixArray == null)
this.suffixArray = suffixArray(this.raiz);
return this.suffixArray;
}
private List<Integer> suffixArray(Nodo n){
ArrayList<Integer> regreso = new ArrayList<>(); /* Lista a regresar */
n.visita(true);
if(n.esHoja()){
regreso.add(cadena.length()+1);
return regreso;
}
for(Arista vecino: n.getAristas()){
Nodo sig = vecino.getHasta(); /* Siguiente Nodo a visitar */
if(sig.visitado())
continue;
for(Integer sub: this.suffixArray(sig))
regreso.add(sub - vecino.subcadena(this.cadena).length());
}
return regreso;
}
El primer método es lo que llamamos un singleton, un método que verifica si algo ya fue instan-
ciado y si es aśı, simplemente lo regresa. En otro caso, lo crea.
La lista que regresa este método es una lista de enteros, no de cadenas. Esto va de acuerdo con
la definición de arreglos de sufijos que vimos anteriormente.
El método es recursivo y lo que hace es obtener la lista de sufijos a partir de un vértice. Para
esto se fija si el vértice es hoja. Si es aśı, su lista de sufijos contiene únicamente a la cadena vaćıa
(la cuál, como previamente vimos, se puede representar como s[|s|+ 1..|s|]). Si no es hoja, entonces
tenemos que sacar la lista de sufijos de cada uno de sus hijos y restarle a todos los elementos de esta
3.2. CONSTRUCCIÓN DEL ARREGLO DE SUFIJOS INVERTIDO 29
lista la longitud de la cadena que representa a la arista entre el vértice y su hijo. El método devuelve
las cadenas en orden lexicográfico gracias a que ordenamos previamente las aristas salientes de cada
vértice.
Para obtener el arreglo de sufijos de la cadena total, simplemente llamamos al método anterior
con la ráız como parámetro. Aunque el método técnicamente nos regresa una lista, al ser esta
instancia de ArrayList la complejidad de las operaciones es la misma que la de un arreglo.
3.2. Construcción del arreglo de sufijos invertido
Construir el arreglo de sufijos invertido de una cadena w es muy sencillo una vez que tenemos
el arreglo de sufijos. Lo que hace el algoritmo es recorrer el arreglo de sufijos utilizando un ı́ndice i.
En cada paso, se guarda este ı́ndice en la posición SAw[i].
3.2.1. Código
El siguiente código obtiene el arreglo de sufijos invertido.
private int[] reversedSuffixArray(){
List<Integer> sA = this.getSuffixArray(); /* Obtenemos el arreglo de
* sufijos utilizando el singleton */
int[] rev = new int[sA.size()]; /* El arreglo que vamos a regresar */
for(int i = 0; i < sA.size(); ++i)
rev[sA.get(i)-1] = i+1;
return rev;
}
Al igual que en el caso del arreglo de sufijos, hay un método singleton que acompaña al método
anterior.
El método es bastante directo. La única dificultad es cuidar los ı́ndices dentro del for.
3.3. Construcción de arreglo LCP
Para construir el arreglo LCP utilicé el algoritmo de Kasai et al. [5]. El algoritmo utiliza tanto
el arreglo de sufijos como el arreglo de sufijos invertido.
Sea w una cadena. Recordemos que el arreglo LCPw contiene la longitud del mayor prefijo en
común para sufijos consecutivos en SAw. Es decir, LCPw[i] = lcp(SAw[i− 1], SAw[i]).
Para calcular este arreglo, hay que notar varias propiedades de la función lcp.
Propiedad 1. lcp(SAw[i− 1], SAw[i]) ≥ lcp(SAw[x], SAw[i]) para x ≤ i− 1
Demostración.
Por contradicción. Supongamos lcp(SAw[x], SAw[i]) > lcp(SAw[i− 1], SAw[i]). Sea z el prefijo más
30 CAPÍTULO 3. CONSTRUCCIÓN DE ESTRUCTURAS AUXILIARES
grande en común entre SAw[i− 1] y SAw[i] y sea y el prefijo más grande en común entre SAw[x] y
SAw[i]. Tenemos que z es prefijo de y, es decir, y = zα, con |α| > 0.
Sabemos que SAw[i− 1] = zβ. Aqúı tenemos dos casos, que β sea vaćıa o que no lo sea. Si β es
la cadena vaćıa, entonces tenemos que SAw[i− 1] es lexicográficamente menor a SAw[x], lo cual es
una contradicción, pues está después en el arreglo de sufijos.
Supongamos entonces que β tiene al menos un carácter. Sea c el primer carácter de β.
De manera análoga, digamos que SAw[x] = zγ. Aqúı no podemos tener γ vaćıa porque sabemos
que |SAw[x]| ≥ |y| > |z|. Sea entonces d el primer carácter de γ. Sabemos que d 6= c, pues si fueran
iguales, habŕıa un prefijo en común más grande que z para SAw[i− 1] y SAw[i].
Aqúı tenemos nuevamente dos casos: d > c o d < c.2 Si d > c, entonces tenemos que SAw[i− 1]
debeŕıa estar antes de SAw[x] en el arreglo de sufijos, lo cual es una contradicción. En el otro caso,
tenemos que SAw[i− 1] debeŕıa estar después de SAw[i] en el arreglo de sufijos (pues zd es prefijo
de SAw[i]), lo cual también es una contradicción.
Por lo tanto, lcp(SAw[i− 1], SAw[i]) ≥ lcp(SAw[x], SAw[i])
También necesitamos el siguiente resultado:
Propiedad 2. LCPw[SA
−1
w [i+ 1]] ≥ LCPw[SA−1w [i]]− 1 para 0 ≤ i < |w|
Demostración.
Recordemos que SA−1w [i] contiene la posición en SAw del sufijo w[i..|w|].3 Entonces LCPw[SA−1w [i]]
es la longitud del mayor prefijo común entre la cadena w[i..|w|] y su predecesor lexicográfico, es
decir, la cadena inmediatamente anterior a esta en el arreglo de sufijos. Denotemos w[i..|w|] con wi
y a su predecesor lexicográfico como wj , de manera que wj = w[j..|w|].
Tenemos dos casos, lcp(wi, wj) = 0. En este caso, la propiedad se cumple, pues lcp(a, b) > −1
para todas las cadenas a y b.
El segundo caso ocurre cuando lcp(wi, wj) ≥ 1. Entonces sabemos que wi y wj empiezan con
el mismo carácter, digamos c. Es decir, wi = cwi+1 y wj = cwj+1. Y como wj < wi, tenemos que
wj+1 < wi+1.
Sea wk el predecesor lexicográfico de wi+1. Entonces LCPw[SA
−1
w [i+ 1]] = lcp(wi+1, wk).
Por la propiedad 1, sabemos que lcp(wi+1, wk) ≥ lcp(wi+1, wj+1). Pero lcp(wi+1, wj+1) =
lcp(wi, wj)−1. Por lo tanto LCPw[SA−1w [i+1]] = lcp(wi+1, wk) ≥ lcp(wi, wj)−1 = LCPw[SA−1w [i]]−
1, como queŕıamos demostrar.
Utilizando la propiedad 2, podemos derivar un algoritmo muy sencillo para calcular el arreglo
LCP .
2En estas desigualdades utilizamos el orden lexicográfico.
3Para evitarnos tecnicismos innecesarios, diremos que los ı́ndices de nuestros arreglos van desde 1 hasta n, con n
la longitud del arreglo, aunque esto no es cierto en Java.
3.3. CONSTRUCCIÓN DE ARREGLO LCP 31
El algoritmo empieza por calcular LCPw[SA
−1
w [1]] manualmente (recorriendo w1 y a su predece-
sor lexicográfico) y a partir de ah́ı, va incrementando un contador i y calculando LCPw[SA
−1
w [i+1]]
utilizando la propiedad 2; es decir que no tenemos que revisar los primeros LCPw[SA
−1
w [i]] − 1
caracteres de las cadenas para sacar su lcp.
El algoritmo termina cuando hayamos calculado LCPw[SA
−1
w [|w|]]. Además, el algoritmo no se
atora; todas las subcadenas de w tienen predecesor lexicográfico debido a que estamos utilizando el
carácter especial de terminado y este tiene el menor peso lexicográfico por convención.
Calcular el arreglo LCPw de esta manera toma tiempo lineal. Más adelante explicaré por qué
pasa esto. Pero primero, me gustaŕıa que veamos el código del algoritmo.
3.3.1. Código
El siguiente método obtiene el arreglo LCP . Nuevamente tendremos otro método singleton
getLCP() que será el que utilicemos para obtener el arreglo LCP . Por esto mismo, el método a
continuación es privado:
private int[] lcp(){
List<Integer> sa = this.getSuffixArray(); /* Arreglo de sufijos. */int[] rev = this.getReversedSuffixArray(); /* Arreglo de sufijos invertido */
int longest = 0; /* El lcp de los sufijos que están siendo analizados */
int[] arreglolcp = new int[sa.size()]; /* El arreglo que vamos a regresa. */
arreglolcp[0] = -1; /* La primera posición vale -1 pues el sufijo no
* tiene predecesor lexicográfico */
for(int i = 0; i < cadena.length()-1; i++){
int pos = rev[i] - 1; /* Posición en SA del sufijo que empieza en el
* i-ésimo carácter. */
int pred = sa.get(pos - 1); /* Predecesor lexicográfico del sufijo. */
while(i+longest < cadena.length() && (pred - 1)+longest < cadena.length() &&
cadena.charAt(i+longest) == cadena.charAt((pred-1)+longest))
longest++;
arreglolcp[pos] = longest;
if(longest > 0)
longest--;
}
return arreglolcp;
}
Teniendo este código, podemos calcular la complejidad del algoritmo.
El cuerpo del for se ejecuta O(|cadena|) veces. Dentro del for, todas las operaciones toman
tiempo constante excepto quizás el while. Cada vez que se ejecuta este while, longest crece en 1.
longest empieza valiendo 0, no puede crecer más que la longitud de la cadena, y se decrementa a
lo más O(|cadena|) veces, por lo que podemos concluir que el while se ejecuta O(|cadena|) veces
en total. Por lo tanto, el algoritmo tiene complejidad O(|cadena|).
32 CAPÍTULO 3. CONSTRUCCIÓN DE ESTRUCTURAS AUXILIARES
3.4. Cálculo de consultas de rango mı́nimo
Como mencioné en la sección de preliminares, una consulta de rango mı́nimo es una operación
que toma dos ı́ndices en un arreglo, x e y, y nos regresa el ı́ndice con el menor elemento en el
intervalo [x, y].
Para poder hacer consultas de manera rápida, vamos a realizar un preprocesamiento. Lo ideal
es optimizar tanto el tiempo de preprocesamiento como el tiempo por consulta individual.
Como mencioné anteriormente, es posible realizar consultas en tiempo O(1) tras un preprocesa-
miento de tiempo lineal, utilizando memoria auxiliar de tamaño lineal. Sin embargo, este algoritmo
implica una transformación muy elaborada del arreglo que considero fuera del alcance de este tra-
bajo.
Por esto, decid́ı implementar las consultas de rango mı́nimo mediante un algoritmo que toma
tiempo O(n log n) en preprocesamiento, donde n es el tamaño del arreglo. Las consultas siguen
siendo en tiempo O(1) y utilizamos un arreglo auxiliar de tamaño O(n log n).
Aśı, perdemos un poco de tiempo en preprocesamiento y un poco de espacio, pero no demasiado,
y podemos seguir realizando cada consulta individual en tiempo constante. A cambio, obtenemos
un algoritmo que es muy sencillo tanto de entender como de programar.
En este algoritmo, los ı́ndices van
El algoritmo hace lo siguiente. Creamos una tabla con filas de 0 a n−1 y columnas de 0 a dlog ne,
donde n es el tamaño del arreglo original. En la posición (i, j) de nuestra tabla, guardamos el ı́ndice
del mı́nimo en el intervalo [i, i + 2j ]. Es decir, que en cada posición de nuestra tabla guardaremos
el mı́nimo de un intervalo de tamaño potencia de 2. Por esto, la tabla tiene O(n log n) entradas.
Ahora, para llenar la tabla en tiempo O(n log n) utilizamos programación dinámica. Para todos
los intervalos de longitud 1 (los de la forma (i, 0)), su valor es i. Para el resto, utilizamos la siguiente
regla: el mı́nimo del intervalo (i, j) es el menor de los mı́nimos de los intervalos (i, j − 1) e (i +
2j−1, j − 1). En otras palabras, el mı́nimo de un bloque de tamaño 2j es el menor de los mı́nimos
de los dos bloques de tamaño 2j−1 que lo conforman.
Una vez llenada la tabla, podemos obtener el mı́nimo para dos ı́ndices arbitrarios (a, b). Sea
k = blog(b− a)c. Entonces el mı́nimo del intervalo (a, b) es el menor de los mı́nimos de los intervalos
[a, a + 2k − 1] y [b − 2k + 1, b], es decir la menor de las entradas (a, k) y (j − 2k + 1, k) en nuestra
tabla. Esta operación toma tiempo O(1).
3.4.1. Código
El código para realizar consultas de rango mı́nimo es sencillo. Utilicé una clase CRM que almacena
tanto el arreglo original como el arreglo para realizar las consultas. Su constructor es el siguiente:
public CRM(int[] arreglo){
this.arreglo = arreglo;
this.query = new
Integer[arreglo.length][(int)(Math.log(arreglo.length)/Math.log(2))+1];
}
3.4. CÁLCULO DE CONSULTAS DE RANGO MÍNIMO 33
El código para realizar una consulta es el siguiente:
public int consulta(int i, int d){
if(i == d)
return i;
if(i > d)
return consulta(d, i);
else{
int k = (int)(Math.log(d-i)/Math.log(2)); /* Tama~no de bloque a revisar */
int indice1 = getMin(i, k); /* Índice del bloque izquierdo */
int indice2 = getMin((int)(d-Math.pow(2, k)+1), k); /* Índice del bloque derecho
*/
int valor1 = arreglo[indice1]; /* Valor del bloque izquierdo */
int valor2 = arreglo[indice2]; /* Valor del bloque derecho */
return valor1 < valor2 ? indice1 : indice2;
}
}
Donde el método getMin(a, b) nos regresa la posición (a, b) de la tabla de consultas. Utilicé este
método para realizar el preprocesamiento conforme sea necesario. Esto nos ayuda si solo queremos
realizar un par de consultas.
El código del método getMin es el siguiente:
public int getMin(int i, int k){
if(query[i][k] == null){
if(k == 0)
query[i][k] = i;
else{
int indice1 = getMin(i, k-1); /* Índice del mı́nimo del lado izquierdo */
int potencia = (int)Math.pow(2, k-1); /* 2^k-1 */
int indice2 = i + potencia >= arreglo.length ? indice1 : getMin(i+potencia,
k-1); /* Índice del mı́nimo del lado derecho */
query[i][k] = arreglo[indice1] <= arreglo[indice2] ? indice1 : indice2;
}
}
return query[i][k];
}
34 CAPÍTULO 3. CONSTRUCCIÓN DE ESTRUCTURAS AUXILIARES
Caṕıtulo 4
Reconocimiento de SAGP
Siguiendo la visión de los autores del art́ıculo original, vamos a considerar únicamente paĺındro-
mos con brecha en la mitad izquierda y sin carácter a la mitad (es decir, la cadena c a la mitad del
paĺındromo siempre será ε). Los algoritmos para los demás casos son análogos.
Sea p = wbuuRwR un SAGP . El pivote de p es la posición i que está entre u y uR.
Podemos abreviar un SAGP s = wbuuRwR con una 4-tupla (i, |w|, |b|, |u|), donde i es el ı́ndice
del pivote de s en la cadena T en la que se encuentra.
Por ejemplo, para la cadena baaabaabaacbaabaabac, el SAGP baaab se representa mediante la
4-tupla (3, 1, 1, 1) y el SAGP abaacbaabaaba se representa con la 4-tupla (13, 4, 1, 2).
Definimos la longitud de brazo, longb(p) como |wu|.
Sea T una cadena que representa un texto sobre el que buscamos presencias de SAGP . Decimos
que para un pivote 1 ≤ i ≤ |T | − 1, el SAGP maximal centrado en i es una cadena x tal que:
1. x es SAGP
2. i es el pivote de x.
3. Para toda y tal que cumple 1 y 2, longb(x) ≥ longb(y).
Además, decimos que un SAGP x es maximal canónico para un pivote i si x es un SAGP
maximal centrado en i y para todo SAGP y maximal centrado en i, si x = w1b1u1u
R
1 w
R
1 e y =
w2b2u2u
R
2 w
R
2 , se cumple |u1| ≥ |u2|.
Denotamos con SAGP (T ) al conjunto de todos los paĺındromos que son maximales canónicos
para cada pivote i en T . En este trabajo, nos interesa obtener SAGP (T ) para algún texto T .
Para una posición i en T , se dice que es de tipo 1 si existe un SAGP wbuuRwR tal que uuR es
el paĺındromo maximal centrado en i. Es decir que para todo otro paĺındromo vvR centrado en i,
|u| ≥ |v|. Todas las posiciones que no son de tipo 1 se dicen de tipo 2.
Por ejemplo, si T = baaaccbbccca, la posición 5 es de tipo 1, pues el paĺındromo maximal centrado
en ella es cc y hay un SAGP con u = c, es decir baaaccb. En cambio, la posición 7 es de tipo 2,
pues el paĺındromo maximal centrado en 7 es ccbbcc pero este no forma parte de ningún SAGP .
35
36 CAPÍTULO 4. RECONOCIMIENTO DE SAGP
En particular, las posiciones que no son pivote para ningún SAGP son consideradas de tipo 2.
Sean SAGP1(T ) y SAGP2(T ) los conjuntos de paĺındromos maximales canónicos para posiciones
de tipo 1 y tipo 2 respectivamente. Como las posiciones forman una partición de los ı́ndices deT ,
SAGP1(T ) ∪ SAGP2(T ) = SAGP (T ) y SAGP1(T ) ∩ SAGP2(T ) = ∅.
Antes de presentar los algoritmos para calcular SAGP (T ), debemos presentar las siguientes
propiedades:
4.1. Propiedades
Lema 1. Sea T una cadena. Sea i una posición de tipo 1 en T y s un SAGP de la forma wbuuRwR
con pivote en i. Si T = t1wbuu
RwRt2, donde b es de la forma b
′α, con α un carácter y b′ una
cadena potencialmente vaćıa, entonces s es maximal canónico si y solo si se cumplen las siguientes
propiedades:
1. uuR es el máximo paĺındromo centrado en i.
2. wR es el mayor prefijo de wRt2 tal que w aparece al menos una vez en t1wb
′.
Demostración.
⇒) Tenemos que s es un SAGP maximal canónico. Supongamos que la propiedad 1 no se
cumple. Entonces existe otro paĺındromo mayor vuuRvR centrado en i. Como sabemos que i es una
posición de tipo 1, esto implica que existe un SAGP w2b2vuu
RvRwR con pivote en i. Pero este
paĺındromo tiene longitud de brecha mayor a |wu|. Por lo tanto, s no puede ser maximal canónico,
lo cual es una contradicción.
Ahora, para probar la propiedad 2, procedamos nuevamente por contradicción. Es decir, supon-
gamos que existe otra cadena más grande, xR, que es prefijo de wRt2 y que cumple que x aparece
al menos una vez en t1wb
′.
Entonces t1wb
′ = m1xm2 y w
Rt2 = x
Rt3. Entonces tenemos una cadena s
′ = xm2αuu
RxR que
es SAGP , ya que m2α > 0. Además longb(s
′) > longb(s). Por lo tanto, s no es maximal canónico,
lo que es una contradicción.
⇐) Queremos probar que s es maximal canónico. Esto significa que para todo s′ = w′b′u′u′Rw′R
SAGP con pivote en i, |w′u′| ≤ |wu| y |u′u′R| ≤ |uuR|.
La segunda condición se cumple por 1.
Para probar la primera condición supongamos que existe otro SAGP , s′ = w′b′u′u′Rw′R tal
que |w′u′| > |wu|. Como sabemos que |u| > |u′| por la segunda condición, entonces es necesario
que |w′| > |w|. Tenemos entonces que w′R es un prefijo de wRt2 que necesariamente aparece en
t1wb
′, pues hay un SAGP con pivote en i que lo contiene. Esto contradice 2, por lo que la primera
condición también debe cumplirse.
4.2. ALGORITMO 37
Lema 2. Sea i una posición en T y uuR el mayor paĺındromo centrado en i. Entonces, la posición
i es de tipo 1 si y solo si el carácter T [i+ |u|] aparece al menos una vez en T [1..i− |u| − 1].
Demostración.
⇒) Tenemos que la posición i es de tipo 1. Esto significa que existe un SAGP , s = wbuuRwR
con pivote en i. Como w no puede ser vaćıa, entonces T [i + |u|] = wR[1] y como w aparece en
T [1..i−|u|−1], entonces en particular wR[1] también aparece. Y esto es lo que queŕıamos demostrar.
⇐) Sea c el carácter T [i+ |u|]. Como c aparece en T [1..i−|u|− 1], existe un SAGP cbuuRc, con
|b| ≥ 1. Este SAGP tiene pivote en i y contiene al mayor paĺındromo centrado en i. Por lo tanto
podemos decir que i es una posición de tipo 1.
4.2. Algoritmo
El algoritmo para calcular SAGP (T ) se basa en la propiedad de que SAGP1(T ) y SAGP2(T )
forman una partición de SAGP (T ). Lo que vamos a hacer es calcular SAGP1(T ) y SAGP2(T ) por
separado.
Para esto, primero vamos a decidir, para cada posición de T si es de tipo 1 o de tipo 2, utilizando
el lema 2. Es decir, vamos a fijarnos si el carácter T [i+|u|] está en T [1..i−|u|−1]. Esto auxiliándonos
con el arreglo Pals, que como vimos al inicio, contiene la mitad de la longitud del mayor paĺındromo
centrado en i.
Afortundamente, el arreglo Pals(T ) puede construirse en tiempo O(|T |) utilizando el algoritmo
propuesto por Glenn Manacher en [6].
Una vez que hemos clasificado las posiciones como de tipo 1 y tipo 2, solo hace falta calcular
SAGP1(T ) y SAGP2(T ) por separado. Esto se puede hacer de varias formas, de las cuales hablare-
mos más adelante.
4.2.1. Código
Hice una clase SAGP, para la cual el constructor clasifica las posiciones en SAGP1 y SAGP2, y
crea los arreglos necesarios para ejecutar el algoritmo en general. Más adelante, mostraré el código
que obtiene espećıficamente los SAGP de tipo 1 y tipo 2 por separado.
El constructor mencionado es el siguiente:
public SAGP(String texto){
this.t = texto;
this.sagp = new ArrayList<>(texto.length());
while(sagp.size() < texto.length()) sagp.add(null); /* Incrementamos el tama~no de
nuestra lista con nulos para poder usar insert. */
/* Algoritmo para calcular SAGP(T): */
pals = StringUtil.pals(t);
38 CAPÍTULO 4. RECONOCIMIENTO DE SAGP
char c; /* El carácter actual */
nextPos = new Integer[texto.length()]; /* Arreglo con la siguiente posición en la que
aparece el carácter de cada ı́ndice */
int tamano = 256 < texto.length() ? 256 : texto.length(); /* Tama~no del alfabeto */
lMost = new Hashtable<>(tamano); /* Diccionario con la posición más a la izquierda de
cada carácter en el texto */
tipo1 = new LinkedList<>();
tipo2 = new LinkedList<>();
/* Llenamos arreglos necesarios para el algoritmo */
for(int i = texto.length()-1; i >= 0; --i){/* Solo vamos a clasificar pivotes entre 1
y length-1 */
c = texto.charAt(i);
nextPos[i] = lMost.get(c);
lMost.put(c, i);
}
/* Clasificamos posiciones de acuerdo a su tipo (1 o 2). */
for(int i = 1; i < texto.length(); ++i){
if(pals[i] > 0 && texto.length() > i + pals[i] && lMost.get(texto.charAt(i +
pals[i])) < i - pals[i])
tipo1.add(i);
else
tipo2.add(i);
}
}
La variable sagp es una lista de listas de pares. En cada posición de la lista, guardamos una lista
con los SAGP de dicha posición. Representamos los SAGP mediante pares de enteros (aśı como
representábamos las subcadenas en el árbol de sufijos), utilizando la clase Par, que funciona como
envoltura de dos enteros.
El método pals obtiene el arreglo pals de una cadena. Más adelante veremos su código.
lMost contiene la posición más a la izquierda de un carácter, y se va actualizando conforme
recorremos la cadena de derecha a izquierda. Este diccionario se utiliza para saber si el carácter
T [i+ |u|] de la cadena T está en T [1..i− |u| − 1].
nextPos[i] nos dice la siguiente posición del carácter en la i-ésima posición de una cadena. Por
ejemplo, para la cadena abba, nextPos[0] = 3, nextPos[1] = 2 y nextPos[3] = nextPos[4] = null.
Es importante usar Integer en este arreglo para tener acceso a valores nulos.
El arreglo nextPos se utilizará más adelante, pero es conveniente calcularlo en este recorrido de
la cadena.
4.3. Cálculo de Pals
Para calcular Pals(T ), utilizaremos el algoritmo de Manacher. Este algoritmo toma tiempo
O(|T |) y se basa en el principio de que, si se tiene un paĺındromo que tiene un subpaĺındromo con
centro en su mitad izquierda, entonces la mitad derecha debe contener un subpaĺındromo igual pero
reflejado.
4.3. CÁLCULO DE PALS 39
El algoritmo calcula las longitudes máximas de los paĺındromos en cada posición posible de la
cadena, donde una posición es el espacio entre dos caracteres.
Para poder utilizar la propiedad de reflejo mecionada anteriormente, tendremos a lo largo del
algoritmo un paĺındromo “grande” llamado actual sobre el cual utilizaremos las longitudes de
paĺındromos de la mitad izquierda para calcular las de la mitad derecha.
En cada posición de T , calcularemos la longitud del mayor paĺındromo centrado en ella y la
guardaremos en pals.
El algoritmo utiliza tres variables: centro, der, e i. El ı́ndice i itera sobre las posiciones entre
los caracteres de la cadena; centro y der son las posiciones del centro y el extremo derecho del
paĺındromo actual respectivamente. Notemos que teniendo el extremo derecho, no es necesario
guardar el extremo izquierdo de dicho paĺındromo.
Inicialmente, centro y der se declaran en 0 (pues al principio no hemos encontrado ningún
paĺındromo).
Haremos |T | − 1 iteraciones.
En cada iteración, primero revisamos si i se encuentra dentro del paĺındromo actual. Para esto,
basta con que i sea menor al extremo derecho de este.
Si no es aśı, entonces tenemos que revisar si i es centro de un paĺındromo de forma manual. Para
esto, hacemos lo que se llama una expansión, que consisteen comparar caracteres a ambos lados
de i hasta que estos dejen de ser iguales. Con ello, obtenemos la longitud del máximo paĺındromo
centrado en i.
Si i está dentro del paĺındromo actual, entonces podemos reutilizar algo de información. Calcu-
lamos el reflejo de i y sacamos de este su longitud respectiva en el arreglo pals, siempre y cuando se
cumpla la condición de que i+ pals[reflejo(i)] no se salga del paĺındromo actual, es decir, que no
sea mayor a der. Esto porque solo podemos reutilizar información dentro del paĺındromo actual.
Si i + pals[reflejo(i)] se sale de actual, entonces no nos queda más que expandir i a partir de
der (y el reflejo de der con respecto a i), que es hasta donde podemos asegurar que la información
es reutilizable.
Finalmente, después de expandir, si el paĺındromo que obtuvimos va más allá de der, entonces
reemplazamos el paĺındromo actual por este.
4.3.1. Código
El siguiente código regresa el arreglo Pals(s) de la longitud de mitad de los paĺındromos pares
en s:
public static int[] pals(String s){
String relleno = rellena(s); /* Cadena rellenada */
int[] pals = new int[s.length()];
int centro = 0, der = 0; /* Posición del centro y derecha del palı́ndromo sobre el que
estamos parados */
for(int i = 1; i < s.length(); ++i){
int espejo = 2*centro - i; /* Reflejamos i con respecto al centro del palı́ndromo
40 CAPÍTULO 4. RECONOCIMIENTO DE SAGP
actual. */
if(i < der)
pals[i] = Math.min(der -i, pals[espejo]);
while(relleno.charAt(pals[i]+i+1) == relleno.charAt(i - pals[i])) /* Expandemos
el palı́ndromo carácter a carácter. */
pals[i]++;
if(pals[i] > 0 && i + pals[i] - 1 > der){ /* Actualizamos el palı́ndromo actual */
centro = i;
der = i + pals[i] -1;
}
}
return pals;
}
Algo a notar es que cuando i < der se cumple, le asignamos a pals[i] el mı́nimo de der − i y
pals[espejo]. El código está hecho de esta forma para ahorrar ĺıneas, pero en realidad, en el caso
de que pals[espejo] < der − i, sabemos que el valor de pals[i] será exactamente pals[espejo]. Sin
embargo, en el código realizamos la expansión, pues toma tiempo constante y mejora la legibilidad.
El método rellena simplemente pega un carácter especial al principio y final de la cadena para
evitar que las comparaciones dentro del while se salgan de rango.
4.3.2. Complejidad
A primera vista, no es tan fácil ver por qué el algoritmo de Manacher toma tiempo lineal.
El método rellena toma tiempo constante y el ciclo for dentro del método que calcula pals se
ejecuta un número lineal de veces. Sin embargo, dentro de este ciclo tenemos otro ciclo que también
puede ejecutarse un número lineal de veces, el ciclo que expande carácter por carácter. Aqúı hay
que ver una invariante.
Cada vez que iteramos, ocurre una de dos cosas: o podemos copiar la longitud del paĺındromo
reflejado o tenemos que expandir; este segundo caso ocurre cuando el paĺındromo del reflejo se sale
de nuestro paĺındromo principal.
En el primer caso, como solo hay que copiar la longitud del paĺındromo; la operación toma
tiempo constante.
El segundo caso es más interesante. Copiamos la longitud hasta el ĺımite del lado derecho y
expandimos a partir de ah́ı. A su vez, esto nos lleva a dos casos. Si no podemos expandir más
entonces gastamos O(1) pasos y el algoritmo continua, leyendo el siguiente carácter. Si podemos
expandir más del extremo derecho, entonces nuestro paĺındromo principal cambia, lo cuál a su vez
mueve el extremo derecho a la derecha tantas veces como expansiones hagamos. Como el extremo
derecho solo puede moverse O(|T |) veces en total, el total de las expansiones no puede pasar de
O(|T |) (independientemente de que las expansiones se hagan dentro de un ciclo).
Por esto el algoritmo toma:
O(|T |) (número total de expansiones) +O(|T |) (iteraciones) +O(1) (relleno de la cadena) =
4.4. CÁLCULO DE SAGP1(T ) 41
O(|T |).
4.4. Cálculo de SAGP1(T )
Una vez que tenemos divididas las posiciones de tipo 1 y tipo 2, y tenemos el arreglo Pals,
debemos calcular, por separado, SAGP1(T ) y SAGP2(T ).
El art́ıculo original sobre el que se basa este trabajo lista varias formas de calcular SAGP1(T ).
Vamos a mostrar la implementación de cada una de ellas.
4.4.1. Algoritmo ingenuo
Como su nombre lo sugiere, el algoritmo ingenuo es el más intuitivo de todos. Sin embargo, es
también el de mayor complejidad computacional.
Sea i la posición para la cual buscamos encontrar el SAGP maximal canónico. Como i es de tipo
1, entonces sabemos que uuR es el paĺındromo máximo centrado en i, por lo cual podemos utilizar
Pals(T ) para conocer |u|.
Ahora solo nos falta encontrar w y la brecha del SAGP . Sabemos que la longitud de la brecha
debe estar entre 1 e i−|u|−1. Entonces, la idea es probar todas las brechas posibles hasta encontrar
la w más grande.
Es decir, para cada brecha b, tenemos que calcular la longitud del máximo prefijo común entre
T [1..i−pals[i]−|b|]R y T [i+pals[i]+1..|T |]. O dicho de otra forma, tenemos que calcular lcp(T [1..i−
pals[i]− |b|]R, T [i+ pals[i] + 1..|T |]). Para esto, utilizaremos el arreglo LCP de la cadena T$TR#
y la siguiente propiedad del arreglo LCP :
Propiedad 3. Sea T una cadena. Entonces, si i < j, lcp(SAT [i], SAT [j]) = min(LCPT [i +
1], LCPT [i+ 2], ..., LCPT [j]).
Demostración.
Primero, observemos que lcp(SAT [j−1], SAT [j]) ≥ lcp(SAT [j−2], SAT [j]) ≥ ... ≥ lcp(SAT [i], SAT [j]).
Si no fuera aśı, tendŕıamos que para k > n, lcp(SAT [n], SAT [j]) > lcp(SAT [k], SAT [j]), lo cual no
puede ser porque SAT [k] es lexicográficamente más cercano a SAT [j] que SAT [n].
Además, sabemos que para todo w < j−1, lcp(SAT [w], SAT [j]) ≤ lcp(SAT [w], SAT [w+1]). De
nuevo, esto es debido a que SAT [w + 1] es lexicográficamente más cercana a SAT [w] que SAT [j].
Entonces, juntando las dos desigualdades, tenemos que lcp(SAT [w], SAT [w + 1]) ≥
lcp(SAT [i], SAT [j]), para todo i ≤ w ≤ j − 1. Es decir, LCPT [w] ≥ lcp(SAT [i], SAT [j]) para todo
i ≤ w ≤ j − 1. Por lo que lcp(SAT [i], SAT [j]) ≤ min(LCPT [i+ 1], LCPT [i+ 2], ..., LCPT [j]).
Ahora, veamos que lcp(SAT [i], SAT [j]) ≮ min(LCPT [i+1], LCPT [i+2], ..., LCPT [j]). Sabemos
que lcp(SAT [i], SAT [j]) = y, con y ≥ 0. Es decir, SAT [i] = a1a2...ayα y SAT [j] = a1a2...ayβ, con
ai caracteres y α y β cadenas tales que |α| ≥ 0 y |β| > 0.
Ahora, supongamos que y < min(LCPT [i+ 1], LCPT [i+ 2], ..., LCPT [j]). Sea k el ı́ndice para el
cual LCPT [k] = min(LCPT [i+1], LCPT [i+1], ..., LCPT [j]). Entonces tenemos que LCPT [k] ≥ y+1.
42 CAPÍTULO 4. RECONOCIMIENTO DE SAGP
Como además sabemos que LCPT [i+1], LCPT [i+2], ..., LCPT [k] también son mayores o iguales
a y+ 1, entonces SAT [i] tiene al menos y+ 1 caracteres de prefijo en común con SAT [i+ 1], y como
este tiene al menos y + 1 caracteres de prefijo en común con SAT [i + 2], entonces también SAT [i]
y SAT [i + 2] tienen y + 1 caracteres de prefijo en común. Siguiendo este razonamiento, SAT [i] y
SAT [k] tienen y + 1 caracteres en común.
De manera similar, SAT [j] y SAT [j − 1] tienen al menos y + 1 caracteres de prefijo común, y
siguiendo el mismo procedimiento de arriba, podemos mostrar que SAT [j] y SAT [k] tienen y + 1
caracteres de prefijo en común.
Entonces los primeros y + 1 caracteres de SAT [i], SAT [j] y SAT [k] son iguales. Lo cual es una
contradicción, pues hab́ıamos dicho que lcp(SAT [i], SAT [j]) = y.
Por lo tanto, lcp(SAT [i], SAT [j]) = min(LCPT [i+ 1], LCPT [i+ 2], ..., LCPT [j])
Un corolario de esta propiedad es que podemos obtener lcp(T [1..i−pals[i]−|b|]R, T [i+pals[i] +
1..|T |]) sacando el mı́nimo del arreglo LCPT$TR# entre los sufijos correspondientes a estas cadenas.
Para esto, vamos a utilizar una CRM (mencionada en la sección 2), lo cual nos permite obtener
el lcp en tiempo constante tras un preprocesamiento lineal sobre el tamaño del arreglo LCP .
En resumen, el algoritmo consiste de los siguientes pasos:
Recorrer las posiciones de tipo 1.
Para cada una de estas posiciones, recorrer

Continuar navegando