Subsecuencia creciente más larga (LIS)
Nos dan un arreglo con números: . La tarea es encontrar la subsecuencia estrictamente creciente más larga en .
Formalmente buscamos la secuencia más larga de índices tal que
En este artículo discutimos varios algoritmos para resolver esta tarea. También discutiremos algunos otros problemas que se pueden reducir a este problema.
Solución en con programación dinámica {data-toc-label=“Solución en O(n^2) con programación dinámica”}
La programación dinámica es una técnica muy general que permite resolver una clase enorme de problemas. Aquí aplicamos la técnica a nuestra tarea específica.
Primero buscaremos solo la longitud de la subsecuencia creciente más larga, y recién después aprenderemos a reconstruir la subsecuencia en sí.
Encontrar la longitud
Para completar esta tarea, definimos un arreglo , donde es la longitud de la subsecuencia creciente más larga que termina en el elemento del índice .
Ejemplo
La subsecuencia creciente más larga que termina en el índice 4 es con longitud 3, la más larga que termina en el índice 8 es o , ambas de longitud 5, y la más larga que termina en el índice 9 es de longitud 2.
Calcularemos este arreglo de a poco: primero , después , y así sucesivamente. Después de calcular este arreglo, la respuesta al problema será el valor máximo en el arreglo .
Sea el índice actual . Es decir, queremos calcular el valor y todos los valores anteriores ya se conocen. Entonces hay dos opciones:
-
: la subsecuencia buscada consiste solo del elemento .
-
: la subsecuencia terminará en , y justo antes habrá algún número con y .
Es fácil ver que la subsecuencia que termina en será a su vez una de las subsecuencias crecientes más largas que terminan en . El número solo extiende esa subsecuencia creciente más larga en un número.
Por lo tanto, podemos iterar sobre todos los con , y tomar la secuencia más larga que obtenemos al agregar a la subsecuencia creciente más larga que termina en . La subsecuencia creciente más larga que termina en tiene longitud , y extenderla en uno da la longitud .
Si combinamos estos dos casos obtenemos la respuesta final para :
Implementación
Aquí hay una implementación del algoritmo descrito arriba, que calcula la longitud de la subsecuencia creciente más larga.
int lis(vector<int> const& a) {
int n = a.size();
vector<int> d(n, 1);
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (a[j] < a[i])
d[i] = max(d[i], d[j] + 1);
}
}
int ans = d[0];
for (int i = 1; i < n; i++) {
ans = max(ans, d[i]);
}
return ans;
}Reconstruir la subsecuencia
Hasta ahora solo aprendimos a encontrar la longitud de la subsecuencia, pero no a encontrar la subsecuencia en sí.
Para poder reconstruir la subsecuencia generamos un arreglo auxiliar adicional que calcularemos junto con el arreglo . será el índice del penúltimo elemento en la subsecuencia creciente más larga que termina en . En otras palabras, el índice es el mismo índice en el que se obtuvo el valor más alto . Este arreglo auxiliar apunta, en cierto sentido, a los ancestros.
Después, para obtener la subsecuencia, solo empezamos en el índice con el máximo, y seguimos a los ancestros hasta deducir la subsecuencia completa, es decir, hasta llegar al elemento con .
Implementación de la reconstrucción
Cambiaremos un poco el código de las secciones anteriores. Calcularemos el arreglo junto con , y después calcularemos la subsecuencia.
Por conveniencia, inicialmente asignamos los ancestros con . Para elementos con , el valor de los ancestros permanecerá , lo cual será un poco más conveniente para reconstruir la subsecuencia.
vector<int> lis(vector<int> const& a) {
int n = a.size();
vector<int> d(n, 1), p(n, -1);
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (a[j] < a[i] && d[i] < d[j] + 1) {
d[i] = d[j] + 1;
p[i] = j;
}
}
}
int ans = d[0], pos = 0;
for (int i = 1; i < n; i++) {
if (d[i] > ans) {
ans = d[i];
pos = i;
}
}
vector<int> subseq;
while (pos != -1) {
subseq.push_back(a[pos]);
pos = p[pos];
}
reverse(subseq.begin(), subseq.end());
return subseq;
}Forma alternativa de reconstruir la subsecuencia
También es posible reconstruir la subsecuencia sin el arreglo auxiliar . Simplemente podemos recalcular el valor actual de y ver también cómo se alcanzó el máximo.
Este método lleva a un código un poco más largo, pero a cambio ahorramos algo de memoria.
Solución en con programación dinámica y búsqueda binaria {data-toc-label=“Solución en O(n log n) con programación dinámica y búsqueda binaria”}
Para obtener una solución más rápida del problema, construimos una solución distinta de programación dinámica que corre en , y después la mejoramos a .
Usaremos el arreglo de programación dinámica . Esta vez no corresponde al elemento ni a un prefijo del arreglo. será el elemento más pequeño en el que termina una subsecuencia creciente de longitud .
Inicialmente asumimos y para todas las demás longitudes .
Procesaremos de nuevo los números de a poco, primero , después , etc., y en cada paso mantendremos el arreglo actualizado.
Ejemplo
Dado el arreglo , aquí están todos sus prefijos y su arreglo de programación dinámica. Nótese que los valores del arreglo no siempre cambian al final.
Al procesar , podemos preguntarnos: ¿qué condiciones tienen que cumplirse para que escribamos el número actual en el arreglo ?
Ponemos si existe una subsecuencia creciente más larga de longitud que termina en , y no existe una subsecuencia creciente más larga de longitud que termine en un número más pequeño. De forma similar al enfoque anterior, si quitamos el número de la subsecuencia creciente más larga de longitud , obtenemos otra subsecuencia creciente más larga de longitud . Así que queremos extender una subsecuencia creciente más larga de longitud con el número , y obviamente la subsecuencia creciente más larga de longitud que termina con el elemento más pequeño funcionará mejor; en otras palabras, la secuencia de longitud que termina en el elemento .
Existe una subsecuencia creciente más larga de longitud que podemos extender con el número exactamente si . Así que podemos iterar sobre cada longitud y comprobar si podemos extender una subsecuencia creciente más larga de longitud verificando el criterio.
Además también hay que comprobar si tal vez ya encontramos una subsecuencia creciente más larga de longitud con un número más pequeño al final. Así que solo actualizamos si .
Después de procesar todos los elementos de la longitud de la subsecuencia deseada es el mayor con .
int lis(vector<int> const& a) {
int n = a.size();
const int INF = 1e9;
vector<int> d(n+1, INF);
d[0] = -INF;
for (int i = 0; i < n; i++) {
for (int l = 1; l <= n; l++) {
if (d[l-1] < a[i] && a[i] < d[l])
d[l] = a[i];
}
}
int ans = 0;
for (int l = 0; l <= n; l++) {
if (d[l] < INF)
ans = l;
}
return ans;
}Ahora hacemos dos observaciones importantes.
-
El arreglo siempre estará ordenado: para todo .
Esto es trivial, porque se puede quitar el último elemento de la subsecuencia creciente de longitud y se obtiene una subsecuencia creciente de longitud con un número final más pequeño.
-
El elemento actualizará a lo sumo un valor .
Esto se sigue de inmediato de la implementación de arriba. Solo puede haber un lugar en el arreglo con .
Así podemos encontrar este elemento en el arreglo usando búsqueda binaria en . De hecho, simplemente podemos buscar en el arreglo el primer número estrictamente mayor que , e intentar actualizar este elemento de la misma forma que en la implementación de arriba.
Implementación
Esto nos da la implementación mejorada en :
int lis(vector<int> const& a) {
int n = a.size();
const int INF = 1e9;
vector<int> d(n+1, INF);
d[0] = -INF;
for (int i = 0; i < n; i++) {
int l = upper_bound(d.begin(), d.end(), a[i]) - d.begin();
if (d[l-1] < a[i] && a[i] < d[l])
d[l] = a[i];
}
int ans = 0;
for (int l = 0; l <= n; l++) {
if (d[l] < INF)
ans = l;
}
return ans;
}Reconstruir la subsecuencia
También es posible reconstruir la subsecuencia con este enfoque. Esta vez hay que mantener dos arreglos auxiliares. Uno que nos dice el índice de los elementos en . Y de nuevo hay que crear un arreglo de “ancestros” . será el índice del elemento anterior para la subsecuencia óptima que termina en el elemento .
Es fácil mantener estos dos arreglos durante la iteración sobre el arreglo junto con los cálculos de . Y al final no es difícil reconstruir la subsecuencia deseada usando estos arreglos.
Solución en con estructuras de datos {data-toc-label=“Solución en O(n log n) con estructuras de datos”}
En lugar del método de arriba para calcular la subsecuencia creciente más larga en también podemos resolver el problema de otra forma: usando algunas estructuras de datos simples.
Volvamos al primer método. Recordemos que es el valor con y .
Así, si definimos un arreglo adicional tal que
entonces el problema de calcular el valor es equivalente a encontrar el valor máximo en un prefijo del arreglo :
El problema de encontrar el máximo de un prefijo de un arreglo (que cambia) es un problema estándar que se puede resolver con muchas estructuras de datos distintas. Por ejemplo, podemos usar un Árbol de Segmentos o un Árbol de Fenwick.
Este método tiene obviamente algunas desventajas: en cuanto a longitud y complejidad de la implementación, este enfoque será peor que el método que usa búsqueda binaria. Además, si los números de entrada son especialmente grandes, tendríamos que usar algunos trucos, como comprimir los números (es decir, renumerarlos de a ), o usar un árbol de segmentos dinámico (generar solo las ramas del árbol que son importantes). Si no, el consumo de memoria será demasiado alto.
Por otro lado, este método también tiene algunas ventajas: con este método no hay que pensar en propiedades rebuscadas de la solución de programación dinámica. Y este enfoque nos permite generalizar el problema con mucha facilidad (ver más abajo).
Tareas relacionadas
Aquí hay varios problemas que están estrechamente relacionados con el problema de encontrar la subsecuencia creciente más larga.
Subsecuencia no decreciente más larga
De hecho esto es casi el mismo problema. Solo que ahora está permitido usar números idénticos en la subsecuencia.
La solución es esencialmente también casi la misma. Solo hay que cambiar los signos de desigualdad, y hacer una ligera modificación a la búsqueda binaria.
Número de subsecuencias crecientes más largas
Podemos usar el primer método discutido, ya sea la versión o la versión con estructuras de datos. Solo hay que guardar además de cuántas formas podemos obtener subsecuencias crecientes más largas que terminan en los valores .
El número de formas de formar una subsecuencia creciente más larga que termina en es la suma de todas las formas para todas las subsecuencias crecientes más largas que terminan en donde es máximo. Puede haber varios de esos , así que hay que sumarlos todos.
Usando un Árbol de Segmentos este enfoque también se puede implementar en .
No es posible usar el enfoque de búsqueda binaria para esta tarea.
Menor número de subsecuencias no crecientes que cubren una secuencia
Para un arreglo dado con números hay que colorear los números con la menor cantidad de colores, de modo que cada color forme una subsecuencia no creciente.
Para resolver esto, notamos que el número mínimo de colores requeridos es igual a la longitud de la subsecuencia creciente más larga.
Demostración: Hay que demostrar la dualidad de estos dos problemas.
Denotemos por la longitud de la subsecuencia creciente más larga y por el menor número de subsecuencias no crecientes que forman una cubierta. Hay que demostrar que .
Es claro que no es posible, porque si tenemos elementos estrictamente crecientes, entonces ningún par puede ser parte de la misma subsecuencia no creciente. Por lo tanto tenemos .
Ahora mostramos que no es posible, por contradicción. Supongamos que . Entonces consideramos cualquier conjunto óptimo de subsecuencias no crecientes. Transformamos este conjunto de la siguiente forma: mientras haya dos de esas subsecuencias tales que la primera empiece antes que la segunda, y la primera secuencia empiece con un número mayor o igual que el de la segunda, entonces desenganchamos este número inicial y lo adherimos al comienzo de la segunda. Después de un número finito de pasos tenemos subsecuencias, y sus números iniciales formarán una subsecuencia creciente de longitud . Como asumimos que llegamos a una contradicción.
Así se sigue que .
Reconstrucción de las secuencias: La partición deseada de la secuencia en subsecuencias se puede hacer de forma voraz. Es decir, recorremos de izquierda a derecha y asignamos el número actual a aquella subsecuencia que termina con el número mínimo que es mayor o igual que el actual.