Árboles de búsqueda óptimos
Nuestro problema se puede describir como: encontrar un árbol de búsqueda para donde la profundidad promedio de una hoja sea . Note que cada árbol de búsqueda que podamos diseñar sobre corresponde a un algoritmo, o a una estrategia, de búsqueda, mientras que cada búsqueda en concreto es un camino de comparaciones desde la raíz hasta la hoja correspondiente.
Algoritmos de Huffman y Hu-Tucker
El problema de, dado un conjunto de probabilidades , encontrar el árbol binario que minimice , donde es la profundidad de la hoja de probabilidad , es conocido en compresión. Una estrategia que entrega el árbol óptimo es el algoritmo de Huffman, que puede describirse de la siguiente forma.
- Crear un bosque con árboles, cada uno consistente de un único nodo de peso .
- Tomar los dos árboles y del bosque con menor peso, sean y esos pesos, y colocarlos como hijos izquierdo y derecho de un nuevo nodo.
- Sacar y del bosque, y agregar el nuevo árbol, con peso .
- Volver al punto 2 a menos que el bosque contenga un solo árbol.
El árbol final que entrega este algoritmo tiene la propiedad de que minimiza , y además puede demostrarse que , es decir, la profundidad promedio de las hojas es menos que la entropía más 1.
Antes de continuar, ¿cuánto tiempo requiere este algoritmo? Es fácil ver que se puede hacer en tiempo , mediante almacenar los pesos de los árboles en una cola de prioridad, implementada por ejemplo con un heap. Se crea con elementos en tiempo y luego se realizan extracciones y reinserciones. ¿Puede hacerse mejor? No se sabe en general, pero si los elementos ya vienen ordenados por probabilidad, entonces puede hacerse en tiempo . Ponga los nodos iniciales en una cola, y prepare otra cola vacía de tamaño para encolar los árboles nuevos que va produciendo. Esta nueva cola también estará ordenada porque los pesos de los árboles que se van generando son siempre crecientes. Entonces, cuando tenga que extraer los dos nodos de peso mínimo, extráigalos de cualquiera de las dos colas (uno de cada una o los dos de una cola, según dónde estén los menores).
De todos modos, ¿el árbol de Huffman sirve como árbol de búsqueda? Supongamos con probabilidades y . El árbol de Huffman tendrá un hijo de la raíz para la hoja y el otro será un nodo interno, con hijos hoja para y . No es posible hacer una comparación tipo "¿?" para separar de y . La razón es que el algoritmo de Huffman desordena las hojas, por lo cual no necesariamente entrega un árbol de búsqueda.
Existe un algoritmo que entrega el mejor árbol posible sin desordenar las hojas, toma tiempo y garantiza que . Se llama algoritmo de Hu-Tucker. Esto significa que existe un algoritmo de búsqueda para arreglos ordenados que realiza en promedio menos de comparaciones por encima de la cota inferior de Teoría de la Información.
En realidad, podemos demostrar que la cota no es ajustada, y que lo más cercano ajustado es, precisamente, . Considere con probabilidades y , para tan pequeño como se quiera. Entonces vale , el cual tiende a cero cuando . Sin embargo, el mejor árbol de búsqueda posible tiene al nodo a profundidad y un costo promedio de búsqueda , que tiende a cuando .
No describiremos el algoritmo de Hu-Tucker en este apunte, por ser demasiado complicado y alejarse demasiado de nuestro tema principal. En cambio, veremos un algoritmo más costoso que resuelve un problema más general.
Construcción general
Consideremos un modelo distinto, en que procedemos por comparaciones pero éstas pueden ser , , ó . Esto significa que podemos detenernos en un nodo interno del árbol de búsqueda si encontramos el elemento que buscamos. En este caso, en vez de comparaciones, contaremos la cantidad de accesos a .
Para generalizar, supondremos que las búsquedas pueden ser exitosas o infructuosas. Una búsqueda exitosa encuentra lo que busca en el elemento del arreglo, el cual se busca con probabilidad . Una búsqueda infructuosa no encuentra lo que busca, sino que determina que debería estar entre los elementos y del arreglo, suponiendo implícitamente que y . Diremos que esta búsqueda ocurre con probabilidad . Tenemos entonces .
El árbol de búsqueda se puede ver entonces como un árbol de nodos donde los nodos internos representan la lectura de un elemento, y entendemos que, si buscamos ese elemento, la búsqueda termina allí. Los punteros a nulo que salen de las hojas son las búsquedas infructuosas, cuyos resultados se deducen sólo al final del camino hacia una hoja. El costo de una búsqueda exitosa, medido en cantidad de accesos a , es la profundidad del nodo interno correspondiente, y el de una búsqueda infructuosa es la profundidad del nodo hoja correspondiente (y es igual a la búsqueda exitosa para ese nodo). Nuestro modelo anterior se puede simular suponiendo un arreglo de tamaño , cuyas búsquedas exitosas tienen probabilidad , y sólo existen búsquedas infructuosas (que terminan en hojas).
Podemos construir el árbol binario óptimo para este problema mediante programación dinámica. Llamemos
a la probabilidad de que la búsqueda recaiga sobre el rango , incluyendo las búsquedas infructuosas en sus extremos. Entonces, el costo óptimo para buscar en se puede encontrar como (arreglo vacío, búsqueda infructuosa), (arreglo de 1 elemento, búsqueda exitosa o infructuosa), y, para ,
donde representa la raíz que elijamos para este subárbol de búsqueda, es decir, el elemento de sobre el que haremos la primera comparación. Debemos registrar dónde queda esta raíz, para luego poder reconstruir el árbol:
Una vez calculadas estas dos matrices (por ejemplo por diagonales, partiendo de la diagonal principal y la que está bajo ella, y progresando hasta la diagonal de largo 1 de la celda ), tendremos en el costo promedio de la búsqueda usando la mejor estrategia posible, y en la raíz que debemos usar para esa estrategia (es decir, debemos partir examinando la celda ). El hijo izquierdo debe tener raíz y el derecho , y así sucesivamente.
Note que, si bien el árbol final requiere de solamente espacio, necesitamos espacio para encontrarlo usando programación dinámica. El tiempo para calcular la matriz es también , pues cada celda puede calcularse con y luego para todo . En cambio, las matrices y requieren tiempo , pues deben considerarse todos los posibles valores de .
Cuando los costos de acceso son iguales (unitarios, en nuestro modelo), es posible calcular y en tiempo , mediante usar la importante propiedad (que no demostraremos en este apunte) de que
Eso significa que podemos reescribir la fórmula para calcular las celdas como
y similarmente para . Para ver que esto reduce el costo total a , consideremos el costo a lo largo de una diagonal de la matriz, donde . Entonces, calcular la celda cuesta
Calcular la siguiente celda de la diagonal cuesta
Y la siguiente cuesta
Si sumamos estos costos, puede verse que cada segundo término se cancela con el primer término anterior. Por lo tanto la suma es telescópica, y a lo largo de la diagonal el costo es a lo sumo . A lo largo de las diagonales, el costo suma . Este es otro pequeño ejemplo de análisis amortizado: una celda puede demorar en calcularse, pero vemos que las celdas no requieren más de operaciones.