jueves, 17 de abril de 2014

Ordenando listas enlazadas simples

Ordenamiento de listas enlazadas simples

Ordenamiento de listas enlazadas simples

Leandro Rabindranath León

Aunque en posts previos he presentado esquemas de ordenamientos para listas enlazadas, voy a permitirme este para explicar cómo instrumentar el quicksort y el mergesort sobre listas simplemente enlazadas.

Contents

1  Doble o simplemente enlazada, ¿cuál y cuándo usar una o la otra?

En general, las listas enlazadas deben usarse cuando se manejan secuencias de longitud variable; es decir, cuando no tenemos idea precisa o aproximada acerca de la cantidad de elementos que manejaremos. Si tenemos idea precisa sobre la escala o al menos podemos acotarla y su escala no es muy fluctuante, entonces es preferible un arreglo; ocupa menos memoria y tenemos otras ventajas. Por el contrario, cuando la escala es desconocida o fluctuante, entonces la lista enlazada es la escogencia para representar secuencias.

Con listas enlazadas tenemos dos sabores: simple o doble. ¿Cuándo simple y cuándo doble? Esencialmente yo manejo una sola razón para usar listas doblemente enlazadas, cual es la necesidad de eliminar lo más eficientemente posible en cualquier posición de la lista. Este es caso, por ejemplo, de una dicola o dipolo, en el cual se eliminan elementos por las dos puntas de la secuencia. Notemos que es sólo la eliminación en O(1), por ambos extremos, la que exige el doble enlace. Cuando se trata de añadidura es posible insertar en una lista simple por ambos extremos.

Alguien pudiera objetar la eliminación en cualquier posición y para ello plantearía la necesidad de recorrer la lista desde alguno de sus extremos para poder encontrar el elemento deseado. Esto no es necesariamente correcto.

Supongamos por ejemplo que deseamos mantener un cache de entradas leídas a un archivo (o fichero si eres español). Usamos una tabla hash para buscar en O(1) en el cache. Puesto que el cache es finito y muchísimo más pequeño que el archivo, éste se llenará en algún momento. Así que surge la pregunta, ¿cuál entrada sustituimos?. En los cursos de sistemas operativos se aprende las bondades de la política LRU; o sea “Least Recently Used” o “menos recientemente utilizada”. En cristiano: eliminamos la entrada que tiene más tiempo dentro del cache sin ser accedida. Ajá, pero ¿cómo determinamos cuál la más antigua? ¿y la más nueva? Bueno, una solución es una lista doblemente enlazada ordenada por acceso. Cuándo se añade una nueva entrada al cache se inserta en la cola. De este modo, el frente de la cola contiene el elemento más antiguo. ¿Y los accesos? Bueno, cada vez que realizamos un acceso eliminamos la entrada de la cola y la reinsertamos. De este modo, la entrada recién accedida deviene en la cola la más reciente o la más nueva. La búsqueda en la tabla hash nos permite recuperar la entrada en O(1). En ese momento, puesto que la entrada pertenece a una lista doble, tenemos todo el contexto necesario para eliminarla de la lista.

Como en la anterior, hay otra situaciones más a menudo reservadas a mundos de programación de élite: compiladores, sistemas operativos, manejadores gráficos, bases de datos y otros tantos.

Hay otra gran ventaja de una lista doble y que es justo que forme parte del criterio para decidir si usarla o no: es más sencilla de implementar que la simple. Por tanto, si requerimos una lista y no encontramos en una situación de prototipado la escogencia es la doble.

Ahora bien, en el mundo de producción el espectro de situaciones que realmente ameritan una lista doble es bastante reducido. Yo diría que la probabilidad de encontrarse en una situación de programación que amerite una lista doblemente enlazada oscila entre el 1 % y el 5 %. Lo que a efectos prácticos significaría que el 95 % de las veces usaremos listas simples ... y en el 95 % de las veces que requiramos ordenar listas será sobre listas simples. Ordenar listas simples es pues una necesidad.

Muchos programadores se desaniman por los rumores de dificultad que tienen las listas simples y optan por listas dobles cuando requieren ordenarlas. Lo cual es lamentable, pues ordenar listas simples es tan o más fácil que ordenar las dobles. Veamos.

2  Estructura e interfaz de la lista

En este post quizá sí sea adecuado un poco más de detalle sobre la representación de la lista.

2.1  El nodo

Comencemos por la representación del nodo:

class Slinknc
{
  Slinknc * next;

public:

  // ...
};

    template <typename T>
class Snodenc : public Slinknc
{
  T data;

public:

  // ...
};

Separamos el nodo en dos clases porque a veces sólo requerimos el enlace. El resto de la interfaz no es necesario para el contexto de este escrito.

2.2  La lista

La siguiente clase está tomada directamente de Aleph-w:

class HTList
{
  Slinknc * head;
  Slinknc * tail;

public:

};

El nombre HTList es de Head-Tail list.

head apunta al primer elemento de la lista y tail al último. Cuando la lista se construye o está vacía los valores de head y tail son nulos.

La lista maneja objetos de tipo Slinknc ; o sea, sólo maneja enlaces simples. Los contenidos de los nodos, es decir el dato, jamás son accedidos por HTList. No se requiere conocer el dato para efectuar operaciones sobre HTList. De este modo podríamos, por ejemplo, manejar nodos que pertenezcan a varias listas. Para ello, el nodo contiene tantos enlaces simples, o sea, objetos de tipos de Slinknc m como listas donde pueda pertenecer.

Para listas que manejen memoria dinámica, Aleph-w exporta el siguiente tipo de dato:

    template <typename T>
class DynList : public HTList
{
    // ..
};

El cual representa secuencias de elementos de tipo genérico T implementadas mediante listas simplemente enlazadas. En esta clase se maneja la memoria y se oculta el nodo; sólo se muestran los datos de tipo genérico T. La herencia pública hace que DynList sea también un objeto de tipo HTList. Por consiguiente métodos y funciones que operen sobre objetos de tipo HTList también operan sobre elementos de tipo DynList.

Alguien podrá observar que para ordenar requerimos mirar el dato del nodo, lo cual es correcto, pero mantendremos la independencia con el dato a través de la comparación, operación que dilucidaremos cuando llegue el momento. Por lo pronto, es importante aceptar que no necesitamos conocer el tipo de dato para operar listas de tipo HTList.

2.2.1  Algunos observadores

Una primera manera de aprehender la independencia entre las operaciones sobre una lista enlazada y el dato que se maneja e mirando los siguientes métodos observadores:

     /// Retorna true si la lista está vacía
  bool is_empty() const { return head == NULL; }

      /// Retorna true si la lista contiene exactamente un solo elemento
  bool is_unitarian() const { return head != NULL and head == tail; }

      /// Retorna true si la lista tiene un elemento o está vacía
  bool is_unitarian_or_empty() const { return head == tail; }

      /// Retorna el primer elemento de la lista
  Slinknc * get_first() const { return head; }

      /// Retorna el último elemento de la lista
  Slinknc * get_last() const { return tail; }
   

2.2.2  Inserción

Hay dos tipos de inserción, por el frente o por el final. La inserción por el final es así:

      // inserta link al final de la lista this
  void append(Slinknc * link)
  {
    if (head == NULL)
      {
        head = tail = link;
        return;
      }

    NEXT(tail) = link;
    tail = link;
  }

NEXT(p) es un macro que nos edulcora p->next. La inserción por el frente te la dejamos en ejercicio.

2.2.3  Eliminación

Como ya lo explicamos al inicio, con listas simples sólo es posible eliminar por el frente:

      // Elimina y retorna el primer elemento de la lista this
  Slinknc * remove_first()
  {
    if (is_empty())
      throw std::underflow_error("HTList is empty");

    Slinknc * ret_val = head;
    if (head == tail) // Lista con un solo elemento?
      head = tail = NULL;
    else
      {
        head = NEXT(head);
        if (head == NULL)
          tail = NULL;
      }

    ret_val->reset();
    return ret_val;
  }
   

Una manera de aprehender la versatilidad de la lista simple es estudiando cómo esta puede implementar una pila o una cola.

Para ver la lista como una pila siempre insertamos por el frente (con insert(), que no está mostrado). La eliminación siempre sacara el elemento más joven dentro de la secuencia, o sea el último que fue insertado.

Para verla como cola siempre insertamos por el final (con append()). La eliminación por el frente sacará el elemento más antiguo de la secuencia.

2.2.4  Concatenación de listas

Puesto que tenemos las direcciones del primer y último elemento, podemos concatenar dos listas en O(1):

      // concatena la lista l la lista this. l deviene vacía
  void append(HTList & l)
  {
    if (l.is_empty()) 
      return; // si l está vacía ==> this es el resultado

    if (this->is_empty())
      {      // si this está vacía ==> l es el resultado
        this->swap(l); 
        return;
      }
    
    NEXT(tail) = l.head;
    tail = l.tail;
    l.head = l.tail = NULL; // vaciar l
  }

  void concat_list(HTList & l) { append(l); } // Sinónimo

La sobrecarga de append() implementa la concatenación. En algunas circunstancias, ésta por ejemplo, conviene ser más específico e indicar que se trata de una concatenación. De allí pues el sinónimo.

3  Quicksort

Comencemos por el quicksort, el cual es el método más fácil de implementar. Grosso modo, el quicksort es estructuralmente simple y se resume del siguiente modo:

template <typename T, class Compare> 
void quicksort(HTList & list, Compare & cmp) 
{
  if (list.is_unitarian_or_empty()) 
    return;

  Snodenc<T> * pivot = // seleccionar pivote y particionar lista

  quicksort <T, Compare> (bigger, cmp);  
  quicksort <T, Compare> (smaller, cmp);

  list.concat_list(smaller); // restaurar listas ordenadas en list
  list.append(pivot);
  list.concat_list(bigger);
} 

Como ya lo debes saber, el trabajo del quicksort lo realiza el proceso de partición. cmp es el criterio de comparación entre los elementos.

Un paréntesis. Al momento de redacción de este post, yo ya he incorporado a Aleph-w muchas funcionalidades del nuevo estándar C++llamado C++11. Esa es en parte la razón por la cual el criterio de comparación se pasa como parámetro. Una sobrecarga del método de ordenamiento se plantea así:

    template <typename T, class Compare = Aleph::less<T>>
void quicksort(DynList<T> & list, Compare && cmp = Compare())
{
  quicksort<T, Compare>((HTList&) list, cmp);
}

Esta sobrecarga conlleva dos ventajas. En primer lugar, puedo escribir algo como quicksort(lista);, en cuyo caso el criterio de comparación es el operador relacional “menor que” invocado sobre el tipo de elementos que maneja la lista. El segundo, y esto es grandioso, puedo escribir una función de primer orden para especificar el criterio de ordenamiento. Supongamos que tenemos una lista DynList que guarda registros de tipo Persona y que deseo ordenar por un campo llamado apellido. Podría escribirlo así:

quicksort(lista, [] (const Persona & p1, const Persona & p2)
                     {
                       return p1->apellido < p2->apellido;
                     });

En previos posts hablé sobre una limitación que tiene la partición sobre listas enlazadas, cual es la dificultad para seleccionar un buen pivote en tiempo constante. Con una lista sólo podemos mirar en tiempo constante los primeros elementos de la lista o exactamente el último.

Recordemos que la eficiencia del quicksort con listas enlazadas depende más de la suerte conque venga la permutación y no hay mucho que podamos hacer al respecto sin pagar tiempo adicional O(n).

Asumiendo que el pivote es el primer elemento, la partición se realizaría del siguiente modo:

  Snodenc<T> * pivot = (Snodenc<T> *) list.remove_first();
  HTList smaller, bigger; // listas de menores y mayores que pivot

  while (not list.is_empty()) 
    {
      Snodenc<T> * p = (Snodenc<T> *) list.remove_first();
      if (cmp(p->get_data(), pivot->get_data()))
        smaller.append(p);
      else
        bigger.append(p);
    }

Plantéate como ejercicio el seleccionar como pivote el último elemento de la lista. Es fácil.

Con buena suerte, el quicksort será O(nlgn). Con mala suerte puede tender a O(n2), coste tan terrible que amerita revisar las cosas. Al respecto tenemos dos alternativas:

  1. Mejorar la selección del pivote
  2. Utilizar un método más seguro; por instancia, el mergesort.

El pivote idóneo es la mediana de la secuencia. Muchos programadores “perfeccionista” han tratado de calcular este pivote y han caído en algunas trampas que vale la pena señalarlas.

Podemos hacer una barrido de toda la secuencia y encontrar la mediana en nlgn. Hay básicamente dos maneras, ambas innecesarias para esta situación, de acometer el problema puntual de encontrar la mediana. Una primera es usar un árbol binario de búsqueda, en cuyo caso ya con el árbol podemos obtener la secuencia ordenada. La segunda es con dos heaps, la cual, aunque también es O(n lgn) es mucho más rápida que con un árbol binario. Pero en dos casos pagamos O(n) en espacio adicional.

Ahora la trampa: si estamos dispuestos a pagar el consumo en espacio de O(n), entonces es preferible olvidarnos de calcular la mediana y valernos de un heap para ordenar. Hacemos un primer barrido para almacenar todos los elementos en un heap; luego vaciamos el heap y obtenemos la secuencia ordenada. Esta tercera alternativa hace innecesarias las dos anteriores u es mucho más eficiente, pero no olvides que pagas O(n) en espacio. Esta es una versión lista del heapsort.

Espero que hayas entendido las trampas.

Si aceptamos una mediana no tan perfecta, o sea de mediana calidad, entonces podemos seleccionar un buen pivote en O(n), cual es una mejor complejidad que O(n ŋ n)). Para ello decidimos un tamaño de muestra, realizamos un barrido que cuesta O(n) y seleccionamos una muestra al azar durante el barrido. Una vez que tenemos la muestra, escogemos como pivote su mediana, la cual tenderá a ser buena.

El coste esperado del quicksort se puede desglosar del siguiente modo:

FaseCoste
Selección del pivoteO(n)
ParticiónO(n)
Invocación recursiva≈ 2 T(n/2))
TotalO(n lgn)

A pesar de que la selección del pivote es O(n) el método sigue siendo O(n lgn). Si se optase por el quicksort como método general para ordenar listas, opción que personalmente no recomiendo, entonces es altamente recomendable pagar este O(n) y seleccionar un buen pivote, pues si no nos arriesgamos a tener desempeños cuadráticos. Pero aquí se nos aparece otra consideración: si pagamos este coste O(n) para seleccionar un buen pivote entonces el mergesort deviene competitivo, pues su estructura de ejecución contiene exactamente la misma cantidad de iteraciones: O(n) + 2T(n/2) + O(n) con la notable diferencia de que es un tiempo determinista y no esperado como en el caso del quicksort.

4  Mergesort

Al igual que el quicksort, el mergesort es un método dividir/combinar el cual se estructura del siguiente modo:

    template <class Tlist, class Compare> inline 
void mergesort(Tlist & list, Compare & cmp)
{
  if (list.is_unitarian_or_empty()) 
    return; // una lista vacía o unitaria está ordenada

  Tlist l, r;
  list.split_list(l, r);    // dividir en dos listas l y r respectivamente

  mergesort <Tlist, Compare> (l, cmp);  // ordenar l
  mergesort <Tlist, Compare> (r, cmp);  // ordenar r
  
      // en este punto, l y r ya están ordenadas
  merge_lists <Tlist, Compare> (l, r, list, cmp); // mezclar l con r
}

Antes de adentrarnos a explicar las partes del método, conviene señalar que esta versión funciona para listas simples y dobles. La condición es que las listas posean los métodos is_unitarian_or_empty() y split_list().

4.1  Partición de listas

La solución cándida consiste en efectuar una primera pasada para contar la cantidad de elementos n. Luego se eliminan los n/2 primeros nodos de la lista y listo. Esta solución es O(n) pero requiere examinar 3/2 n nodos.

Hay dos formas de particionar una lista en dos listas equitativas en tamaño.

4.1.1  Método 1

Supongamos dos corredores p y q arrancan simultáneamente en carrera desde el mismo punto hacia un destino común. Sin embargo, el corredor q se mueve al doble de velocidad que el corredor p. Cuando q llegue al destino p se encontrará en el punto intermedio del trayecto. Este es el principio de partición el cual se instrumenta del siguiente modo:

  void split_list(HTList & l, HTList & r)
  {
    if (is_empty())
      return;

    if (is_unitarian())
      {
        swap(l);
        return;
      }

    Slinknc * p = head;
    Slinknc * q = head;
    while (q != tail and q != NULL)
      {
        q = NEXT(q); ++count;
        if (q == tail or q == NULL)
          break;

        p = NEXT(p); 
        q = NEXT(q);
      }

    l.head = head;
    l.tail = p;

    r.head = NEXT(p);
    r.tail = tail;

    head = tail = NULL;
  } 

Este es el método de Aleph-w porque preserva el orden original de los elementos. Adicionalmente, la versión de Aleph-w cuenta la cantidad de elementos revisados.

4.1.2  Método 2

Si aprehendemos que mientras haya desorden el orden de los elementos no importa para el método, entonces podemos diseñar una partición ligeramente más eficiente:

  void split_list(HTList & l, HTList & r)
  {
    while (not this->empty())
      {
        l.append(this->remove_first());
        if (not this->is_empty())
          r.append(this->remove_first());
      }
  }

4.1.3  Mezcla de listas

Toda la maquinaria necesaria para la mezcla la ofrece la interfaz de HTList:

    template <class Tlist, class Compare> inline
void merge_lists(Tlist & l1, Tlist & l2, Tlist & result, Compare & cmp)
{
  while (not l1.is_empty() and not l2.is_empty())
    if (cmp(l1.get_first(), l2.get_first()))
      result.append(l1.remove_first());
    else
      result.append(l2.remove_first());

  if (l1.is_empty())
    result.concat_list(l2);
  else
    result.concat_list(l1);
}

Observa sin embargo que este método también funciona para listas dobles.

5  ¿Cuál utilizar: quicksort o mergesort?

La pregunta se puede reformular en términos de desempeño como: ¿qué es mejor, un probable O(n lgn) del quicksort o un completamente seguro O(n lgn) del mergesort? Yo diría que la respuesta depende de responder ¿cuán probable es el rendimiento esperado del quicksort?.

Si tuviésemos la certeza de que la partición fuese aleatoria, entonces la degradación del quicksort en O(n2) sería muy poco probable. Puesto que es difícil garantizar esta aleatoriedad y que aún en ese caso usar el quicksort como método general conlleva un riesgo, yo transo por el mergesort como método general de ordenamiento de una lista enlazada, pues tengo determinismo y el coste cuadrático que podría acarrear es demasiado alto, especialmente para secuencias de alta escala.

Una lección/recomendación colateral de este post es que el ordenamiento no constituye un pretexto para usar listas enlazadas dobles.


This document was translated from LATEX by HEVEA.

domingo, 29 de septiembre de 2013

¡Gracias Treaps!

¡Gracias Treaps!

¡Gracias Treaps!

Leandro Rabindranath León

Los árboles binarios de búsqueda son una de las opciones principales para manejar conjuntos en los cuales haya que insertar, buscar y eliminar rápidamente. En añadidura, los árboles binarios de búsqueda permiten obtener la secuencia ordenada en O(N).
Como muchos lo saben, los árboles binarios de búsqueda son muy simples de instrumentar y sus desempeño puede ser espectacular si el orden de inserción de claves es aleatorio. Lamentablemente no siempre se puede garantizar aleatoriedad en la inserción. Por esto, desde hace más de 50 años los investigadores en algoritmos han buscado y encontrado esquemas para equilibrar árboles binarios de búsqueda. Podemos clasificar los esquemas en tres tipos:
  1. Equilibrio aleatorizado: hacemos cosas al azar de modo tal que se “filtre” cualquier sesgo en las operaciones.Los principales exponentes de estos tipos de árbol son los árboles aleatorizados y los treaps.
  2. Equilibrio garantizado: imponemos sobre el árbol alguna condición que supedite el desequilibrio entre las ramas de los nodos de modo tal de tener una garantía. Ejemplos de estos árboles son lo AVL y los rojo-negro; ambos garantizan la altura máxima del árbol a O(lnn); donde n es el número de claves. Sin embargo, a pesar de esta garantía, los algoritmos son complejos y el coste constante para garantizar el equilibrio se recompensa cuando se tienen muchísimas claves.
  3. Equilibrio amortizado: hacemos algunas operaciones que a la larga deparan en un coste promedio de lgn. El exponente más popular de este tipo de estructura es el árbol splay. Es relativamente simple y rápido cuando hay localidad de referencia, pero el coste constante por la operaciones amortizadas es importante y se recompensa para aplicaciones que explotan la localidad de referencia.
Puedo ufanarme de haber implementado todos estos esquemas de árboles binarios. También puedo ufanarme de haberlos usado en aplicaciones reales. Así que me siento con un poquitín de autoridad para criticarlos. En este post presentaré el treap, el cual, luego de evaluar factores tales como el tiempo de codificación, el entendimiento de los algoritmos, el desempeño y los costes de operación constantes para bajas escalas, es el primer mejor compromiso como árbol binario de búsqueda. Si en incertidumbre me preguntaran cuál de ellos elegir, sin titubear mi respuesta serían los treaps.
Este post está estructurado en una introducción en la cual presentaré los árboles binarios de búsqueda, para aquellos que no lo recuerden bien, y el heap. Ambas estructuras son de interés para comprender el treap. Después presentaré el treap, la idea, los algoritmos, su análisis y otras consideraciones. Para que no me acusen de falta de rigor, al final pongo el análisis de los árboles binarios de búsqueda.

Contents

1  Introducción

Para comprender el treap es necesario entender las dos estructuras de datos que lo fundamentan: árbol binario de búsqueda y heap.

1.1  Árbol binario de búsqueda

Con base a que T = ∅ es un árbol binario de búsqueda (ABB), un árbol binario T = <LLINK(T), ni, RLINK(T)>, con raíz ni se le dice “de búsqueda” si y sólo si:
  


   ∀ n< ∈ LLINK(T) ⇒ KEY(n<) < KEY(ni)      y 
   ∀ n> ∈ RLINK(T) ⇒ KEY(n>) > KEY(ni
Dicho coloquialmente, para todo nodo ni con clave KEY(ni), entonces todas las claves contenidas en su rama izquierda son menores que KEY(ni), mientras que las contenidas en su rama derecha son mayores. Este ejemplo de ABB con claves generadas al azar ayudará a constatar:
Es conveniente notar algunas cosas:
  1. El recorrido infijo (o preorden) del árbol es completamente ordenado. Para corroborarlo, proyecta perpendicularmente los nodos del árbol anterior sobre una línea horizontal situada debajo del árbol; verás que las claves están ordenadas.
  2. El menor elemento es el nodo más a la izquierda.
  3. El mayor elemento es el nodo más a la derecha

1.1.1  Búsqueda

Un ABB pretende emular la búsqueda binaria. La raíz divide el conjunto de claves; las menores a su izquierda, la mayores a su derecha. Una forma de aprehender el parecido con la búsqueda binaria es mirar el procedimiento de búsqueda sobre un ABB:
Node * search(Node * root, const Key & key)
{
  if (root == Node::NullPtr)
    return Node::NullPtr; // key no se encuentra en el árbol

  if (key < KEY(root))
    return search(LLINK(root), key);
  else if (KEY(root) < key) 
    return search(RLINK(root), key);

  return root; // root contiene a key
} 
   
Si key < KEY(root) entonces buscamos key en la rama izquierda; si no, entonces comparamos KEY(root) < key, lo cual es equivalente a efectuar key > KEY(root). De lo contrario, concluimos key == KEY(root) y retornamos root.
Si el ABB con raíz root contiene la clave key, entonces search(root, key) retorna un puntero al nodo contentivo de key ; de lo contrario, se retorna Node::NullPtr.
Para que la búsqueda sobre un ABB emule perfectamente a la búsqueda binaria, en necesario que la raíz de un ABB divida al conjunto de claves en dos partes equitativas y así recurrentemente para el resto de los nodos, tal como sucede con la búsqueda binaria sobre un arreglo.
El código anterior está extraído de ALEPH−ω ℵω, el cual puede descargarse de:
https://sourceforge.net/projects/aleph-w/
Hay algunas modificaciones respecto al código de ALEPH−ω ℵω. En primer lugar los nombres; por ejemplo search() se llama en ALEPH−ω ℵωsearchInBinTree(). También, ALEPH−ω ℵωmaneja una comparación genérica la cual, si bien se omite del código anterior, le brinda sentido a la estructura de decisión, la cual sólo usa el operador <.
Visualmente podemos tener una idea inmediata de que tanto un ABB emula a la búsqueda binaria. Simplemente miramos los nodos y que tanto se equilibran sus ramas. El árbol anterior no es perfecto, pero en el balance está equilibrado.
Si un árbol binario está equitativamente equilibrado, entonces la búsqueda es O(lgn), donde n es la cantidad de nodos que contiene el árbol.

1.1.2  Inserción

La inserción es ligeramente más complicada, pero aún muy simple:
Node * insert(Node *& root, Node * p)
{
  if (root == Node::NullPtr) 
    return root = p; // encontré árbol vacío donde insertar p

  if (KEY(p) < KEY(root))
    return insert(LLINK(root), p); // inserte en la rama izquierda
  else if (KEY(root) > KEY(p))
    return insert(RLINK(root), p); // inserta en la rama derecha

  return Node::NullPtr; // clave repetida
}
   
insert() se parece a demasiado a search() porque en el fondo es una búsqueda, pero fallida, para encontrar un árbol vacío en el cual se colocaría el nuevo nodo. Por instancia, para el siguiente ABB:
El siguiente dibujo muestra los rangos entre corchetes en los cuales se colocarían nuevas claves:
Si vamos a insertar, por ejemplo, el 33, entonces lo colocamos en el rango [31..49] el cual está a la derecha del nodo 30. Así que buscamos el 33, lo cual nos encontrará el árbol vacío correspondiente al rango [31..49] en donde colocaremos el 33.

1.1.3  Unión exclusiva

Eliminar es más complicado que insertar, pero aún suficientemente simple si vamos por partes. La primera de ellas consiste en estudiar cómo se unen dos árboles con rangos disjuntos.
Supongamos que tenemos dos árboles T< y T>. Cuando decimos que sus rangos son disjuntos queremos decir que la mayor clave de T< es menor que la menor de T>. Aclarado esto, y asumiendo que T<=tg y T>=tg, entonces T< ∪ T> se puede implementar del siguiente modo:
Node * join_exclusive(Node *& ts, Node *& tg)
{
  if (ts == Node::NullPtr) 
    return tg;

  if (tg == Node::NullPtr) 
    return ts;

  LLINK(tg) = join_ex(RLINK(ts), LLINK(tg));

  RLINK(ts) = tg;
  Node * ret_val = ts;
  ts = tg = Node::NullPtr; // deben quedar vacíos después del join

  return ret_val;
}   
La rutina se llama join_exclusive() para expresar con el sufijo “exclusivo” que la unión (join) es disjunta. join_exclusive() retorna la raíz de un nuevo ABB conteniendo la unión; los árboles ts y tg devienen vacíos.
En mi opinión, su sentido se explica mucho mejor pictóricamente. Supongamos que la operación se esquematiza del siguiente modo:
Entonces, la unión exclusiva se expresa así:
Es decir, escogemos como raíz del resultado a T< (ts) y su rama derecha T> (tg).
Habríamos podido escoger como raíz a T> en lugar de T<. La figura sería simétrica.

1.1.4  Eliminación

Ahora sí vayamos a la segunda parte de la eliminación. Si tenemos un ABB con raíz root y deseamos eliminar el nodo contentivo de la clave key, entonces podemos hacerlo de la siguiente manera:
Node * remove(Node *& root, const Key & key)
{
  if (root == Node::NullPtr) 
    return Node::NullPtr;

  if (key < KEY(root))
    return remove(LLINK(root), key);
  else if (KEY(root) < key)
    return remove(RLINK(root), key);

  Node * ret_val = root; // respaldar root que se va a borrar
  root = join_exclusive(LLINK(root), RLINK(root));

  LLINK(ret_val) = RLINK(ret_val) = Node::NullPtr;

  return ret_val;
}
De nuevo notemos es el parecido con la búsqueda, lo cual se explica por el hecho de que para poder eliminar key primero debemos buscarla (y encontrarla). Si key no está presente en el árbol, entonces no hacemos nada y retornamos Node::NullPtr. De lo contrario, la recursión en algún momento se detendrá en un nodo root cuya clave es key. En este momento todo es simplísimo: root será la unión exclusiva de sus ramas.

1.1.5  Desempeño de los ABB

Los algoritmos sobre ABB son extraordinariamente simples, mas no su análisis, el cual, si bien no es excesivamente complicado, por estar fuera del propósito de este escrito yo lo te lo pondré como anexo al final de este escrito. Por ahora lo esencial a saber es:
  1. Si la secuencia de inserción es aleatoria, entonces el promedio de nodos que se visitan en una búsqueda fallida al azar es O(lgn).
  2. Con base al resultado anterior (no demostrado; ver anexo), sabemos que una búsqueda, exitosa o fallida, tendrá un desempeño esperado de O(lgn).
  3. Una inserción paga una búsqueda fallida, lo demás es constante. Consecuentemente, una inserción también cuesta en promedio O(lg). Si inserciones sucesivas son aleatorias, entonces se siguen manteniendo las expectativas en torno a O(lgn),
  4. Las eliminaciones rompen las expectativas. Esto se intuye si se aprehende que la unión exclusiva ejecutada por la eliminación es asimétrica; en nuestro caso se escoge arbitrariamente la rama izquierda del nodo eliminado T<.
En síntesis, si las inserciones son aleatorias y no tenemos eliminaciones (o efectuamos pocas), entonces el ABB es una estructura estupenda para operar muy eficientemente sobre conjuntos de claves.

1.2  Heaps

Hace ya unos cuantos años CACM publicaba una bellísima columna llamada “Programing Pearls”, de Jon Bentley. Todas fueron excelentes, pero a la memoria de este escrito vale la pena recordar una, paráfrasis de este texto, llamada “Thanks Heaps”, la cual, por supuesto, trataba sobre el heap, una especie de árbol binario completo con las siguientes, Bentley dixit, dos propiedades fundamentales sobre un heap T_H:
Orden
nk ∈ T_H ⇒ KEY(LLINK(ni)) > KEY(ni) ∧ KEY(RLINK(ni)) > KEY(ni), KEY(∅) = ∞. Es decir, para todo nodo nk, sus dos hijos tienen claves mayores que KEY(nk). Por simplicidad, asumimos KEY(∅) = ∞.
Forma
Un heap siempre tiene forma triangular; o sea:
Dicho de otro modo, un heap siempre está perfectamente equilibrado.
El siguiente árbol es un ejemplo de heap:
Como se puede apreciar, las dos propiedades se satisfacen.
Puesto que la pretensión de este escrito trata sobre el treap, que no el heap, no nos enfrascaremos en analizar el heap. Basta con decir que el heap recupera el menor de todos los elementos de un conjunto en O(1) e inserta y elimina en O(lgn); las tres operaciones son deterministas; es decir, garantizadas. ¡Un desempeño espectacular pues!
En caso de que no conozcas sobre los heaps, pues nada mejor que la propia columna de Bentley:
“Programming pearls: thanks, heaps”. Jon Bentley. Communications of the ACM. Volume 28 Issue 3, March 1985. Págs 245-250
También se pueden encontrar en el siguiente compendio:
“Programming pearls”. Jon Bentley. Addison-Wesley. ISBN 0201657880.

2  Treap

El treap fue descubierto en 1980 por Jean Vuillemin y fue rigurosamente estudiado por Cecilia Aragon y Raimund Seidel. El nombre se debe a una combinación entre tree y heap; es decir, es un árbol binario de búsqueda pero con algo de heap, concretamente la propiedad de orden.
Un treap es un árbol binario T en cual cada nodo ni tiene dos campos a saber:
  1. KEY(ni) ∈ K es la clave de búsqueda, donde K es un conjunto ordenable cualquiera.
  2. PRIO(ni) ∈ P es la prioridad del nodo, donde P es un conjunto ordenable cualquiera.
Un árbol binario cuyos nodos tengan esta estructura es un treap si y sólo si:
  • Condición de orden: T es un árbol binario de búsqueda según las claves K. Dado un nodo ni, KEY(ni) refiere a su clave. Las claves K están ordenadas como en un árbol binario de búsqueda.
  • Condición de prioridad:niT, PRIO(ni) ≤ PRIO(LLINK(ni)) y PRIO(ni) ≤ PRIO(RLINK(ni)) . Dado un nodo ni, PRIO(ni) refiere a su “prioridad”. En este sentido, T es un heap: para cada nodo, las prioridades de sus hijos son mayores.
Nada mejor que un ejemplo para terminar de aprehender el concepto:
Los campos superiores de los nodos son las claves del árbol binario de búsqueda, mientras que los inferiores las prioridades del pseudoheap.
Como informalmente lo demostraremos más adelante, la enorme bondad de un treap es que si las prioridades son escogidas al azar, entonces no importa el orden en que aparezcan las claves, no importa si se intercalan eliminaciones, un treap siempre, ¡siempre!, será equivalente a un árbol binario de búsqueda construido a partir de una secuencia de inserción aleatoria.
Recordemos que un árbol binario de búsqueda construido al azar tiende a estar equitativamente equilibrado; consecuentemente, la búsqueda e inserción son O(lgn) esperado. En un treap, esta expectativa se extiende a la eliminación y a otras operaciones.

2.1  Operaciones sobre los treaps

2.1.1  Rotaciones

Para que un treap tienda a ser equilibrado como lo seria un árbol binario de búsqueda, es necesario que de vez en cuando se efectúen algunos ajustes. Hay diversos tipos de árboles de búsqueda que efectúan ajustes a efectos de equilibrar; prácticamente todos ellos emplean “rotaciones” para realizar estos ajustes.
Una rotación se entiende rápidamente mediante la siguiente figura:
El árbol con raíz p se puede rotar hacia la derecha y se obtiene el árbol con raíz q. Del mismo modo, si el árbol con raíz q se rota hacia la izquierda, entonces se obtiene el original de raíz p.
El recorrido infijo del árbol con raíz p es (α A β) B χ, mientras que el del árbol con raíz q es α AB χ ); o sea, son iguales, lo que demuestra que la rotación no afecta la propiedad de orden de un árbol binario de búsqueda.
Cuando se rota a la derecha el árbol con raíz p, el árbol resultante con raíz q pierde una unidad de altura por su rama izquierda, la cual se traduce en una ganancia de una unidad de altura por rama derecha. Simétricamente ocurre con la rotación hacia la izquierda. Esta es la característica que hace de la rotación la operación fundamental para equilibrar árboles.
La rotación hacia derecha se implanta del siguiente modo:
Node * rotate_to_right(Node * p)
{
  Node * q = LLINK(p);
  LLINK(p) = RLINK(q);
  RLINK(q) = p;
  return q;           
}  
La rotación hacia la izquierda es simétrica:
Node* rotate_to_left(Node * p)
{
  Node * q = RLINK(p);
  RLINK(p) = LLINK(q);
  LLINK(q) = p;
  return q;                  
}  
Esencial notar que la rotación es O(1).

2.1.2  Inserción


Dado un treap con raíz root el algoritmo general de inserción de un nodo p se puede resumir en:
  1. PRIO(p) = número al azar (la prioridad se escoge al azar)
  2. Insertar p exactamente como en un árbol binario de búsqueda.
  3. Rotar p de forma que éste suba de nivel hasta que su prioridad PRIO(p) sea mayor que la de su padre.
Notemos que la diferencia entre este algoritmo general y el de inserción en un árbol binario de búsqueda es tan solo la línea 3.
Supongamos el siguiente treap al cual recién se le acaba de insertar, como en un árbol binario de búsqueda, un nodo (38,26).
Como el nodo fue insertado como en un árbol binario de búsqueda, éste no viola la condición de orden, mas sí viola la condición de prioridad, pues 26 < 54. Así que lo que hacemos es rotar el nodo (28,54) hacia la izquierda, lo que hace que (28,54) quede por debajo de (38,26):
Pero (38,26) viola la prioridad de su padre (39,30), lo cual requiere que (39,30) sea rotado hacia la derecha:
De nuevo, (38,26) viola la condición de prioridad de su padre (12,28), lo cual acarrea su rotación hacia la izquierda, la cual depara en el siguiente resultado definitivo:
El cual ya es un treap.
¿Y cómo se instrumenta este procedimiento? Básicamente de la misma manera en que el método genérico: inserte en un árbol binario de búsqueda y luego rote hasta restaurar la condición de prioridad:
Node * insert(Node * root, Node * p)
{
  if (root == Node::NullPtr) 
    return p;

  Node * ins_ret = NULL;
  if (KEY(p) < KEY(root)) 
    {
      ins_ret = insert(LLINK(root), p);
      if (ins_ret == Node::NullPtr) 
        return Node::NullPtr;

      LLINK(root) = ins_ret;
      if (PRIO(ins_ret) < PRIO(root))
        return rotate_to_right(root);
      else
        return root;
    }
  else if (KEY(root) < KEY(p))
    {
      ins_ret = insert(RLINK(root), p);
      if (ins_ret == Node::NullPtr) 
        return Node::NullPtr;

      RLINK(root) = ins_ret;
      if (PRIO(ins_ret) < PRIO(root))
        return rotate_to_left(root);
      else
        return root;
    }

  return Node::NullPtr;
} 
Para el análisis, es importante notar el parecido entre el algoritmo de inserción de un árbol binario de búsqueda y éste que acabamos de presentar.

2.1.3  Eliminación

La eliminación se puede plantear de dos maneras. La primera es estructuralmente idéntica a la eliminación que presentamos del árbol binario de búsqueda basada en la operación join(). La segunda, que desarrollaremos se basa en el siguiente algoritmo general:
  1. Busque y encuentre la clave a eliminar. Sea p el nodo encontrado contentivo de la clave a eliminar (si no se encuentra la clave el algoritmo termina).
  2. Rote p para aumentarlo de nivel hasta que ésta devenga una hoja. En este paso no importa que p viole la propiedad de orden, pues éste será eliminado. Sin embargo, si para los puritanos, basta con asignarle a p prioridad ∞ para que las rotaciones restablezcan la condición de prioridad.
  3. Una vez que p devino hoja, la eliminación consiste simplemente en “cortarla” del árbol.
Sorprendentemente, el lugar por donde finalmente se elimina un nodo es exactamente el mismo por donde se insertaría. Para ello, veamos como eliminamos el nodo (38,26) del siguiente treap:
Aquí debes percatarte de que el sentido de rotación no es arbitrario. Si rotamos (38,26) hacia la izquierda, entonces el nodo (39,30) violará la condición de prioridad respecto al nodo (12,28). Por tanto, debemos rotarlo hacia la derecha, de modo tal que la menor de la prioridades de los hijos de p sea la que suba. Así, el estado del treap luego de esta rotación es:
La regla general para decidir el sentido de rotación es hacia el lado contrario de donde se encuentre su hijo de menor prioridad. Así, la siguiente rotación se efectúa hacia la izquierda, o sea hacia el lado contrario donde se encuentra su hijo derecho (39,30), lo que nos arroja el siguiente resultado:
En esta situación nos encontramos con que (38,26) “tiene un solo hijo”; así que si lo rotamos hacia el lado donde no tiene su hijo entonces (38,26) deviene una hoja:
Aquí (38,26) ya es una hoja, por lo que sólo nos queda cortarlo para que culminemos la eliminación.
Un truco muy interesante
Una manera de simplificar la instrumentación del algoritmo de eliminación, la cual nos ahorra un if para verificar si alguno de los hijos es o no un nodo nulo (o árbol vacío) consiste en usar un nodo centinela para representar el árbol vacío. Así, la constante Node::NullPtr representará al nodo nulo y tendrá el mayor valor de prioridad posible. De este modo, la eliminación efectúa las rotaciones hacia los sentidos correctos sin percatarse si alguno de los hijos es o no el árbol vacío.
Con este truco en mano estamos listos para presentar la instrumentación del algoritmo de eliminación:
  Node * remove(Node *& root, const Key & key)
  { // 1ra parte: buscar y encontrar la clave
    Node head; // cabecera auxiliar centinela
    Node ** pp = &RLINK(head);
    while (p != Node::NullPtr)
      if (key < KEY(p))
        {
          pp = &LLINK(p);
          p  = LLINK(p);
        }
      else if (KEY(p) < key)
        {
          pp = &RLINK(p);
          p  = RLINK(p);
        }
      else
        break; // clave encontrada!

    if (p == Node::NullPtr) 
      return NULL; // clave encontrada

        // 2da parte: rotar p hasta que devenga hoja
    while (not (LLINK(p) == Node::NullPtr and RLINK(p) == Node::NullPtr))
      if (PRIO(LLINK(p)) < PRIO(RLINK(p)))
        {
          *pp = rotate_to_right(p);
          pp  = &RLINK(*pp);
        }
      else
        {
          *pp = rotate_to_left(p);
          pp  = &LLINK(*pp);
        }
    *pp = Node::NullPtr;
    p->reset();

    return p;
  }  
   
La rutina retorna el nodo podado del treap si la clave key en efecto se encuentra en árbol; o NULL en caso contrario.
Notemos que la rutina es iterativa, lo que la hace más eficiente que su contraparte recursiva, pues nos ahorramos tiempo por el pase de parámetros, la instanciación de registros de activación y las llamadas recursivas.
Las dos partes usan un doble puntero pp que apunta a la celda de un nodo que contiene un puntero. Este truco nos evita volver a preguntar de que lado se encuentra el hijo.
Notemos que antes de la poda del nodo a eliminar, el estado del treap es exactamente el mismo que se habría tenido si el nodo a eliminar estuviese recién insertado.

2.1.4  Eliminación basada en el join

Podríamos basar la eliminación del treap tal como la hicimos para el árbol binario de búsqueda ; es decir, sobre la unión exclusiva. Pero para ello debemos diseñar una unión que preserve la condición de prioridad:
  Node * join_treaps (Node * t1, Node * t2)
  {
    if (t1 == Node::NullPtr)
      return t2;

    if (t2 == Node::NullPtr)
      return t1;

    if (PRIO (t1) < PRIO (t2) )
      {
        RLINK (t1) = join_treaps (RLINK (t1), t2);
        return t1;
      }
    else
      {
        LLINK (t2) = join_treaps (t1, LLINK (t2) );
        return t2;
      }
  } 
Con este join, la eliminación del treap deviene estructuralmente idéntica a la del árbol binario de búsqueda:
  Node * remove(Node *& root, const Key & key)
  {
    if (root == Node::NullPtr)
      return Node::NullPtr;

    if (key < KEY(root))
      return remove(LLINK (root), key);
    else if (KEY(root) < key)
      return remove(RLINK (root), key);

    Node * ret_val = root;
    root = join_treaps(LLINK (root), RLINK (root) );
 
    return ret_val;
  }
Esta eliminación es más simple pero ligeramente de menor desempeño que la anterior. Si las prioridades son únicas, entonces los árboles producidos por ambas eliminaciones son exactamente los mismos.
Yo presento dos métodos de eliminación sobre el treap porque cuando plantee las demostraciones deseo ofrecer dos perspectivas para decir que la eliminación del treap es equivalente a la eliminación sobre un árbol binario de búsqueda.

2.2  La verdad sobre los treaps (análisis)

Lo que hace a un treap extraordinariamente interesante es el siguiente teorema.

2.2.1  Teorema de equivalencia entre un treap y un árbol binario de búsqueda


Si las prioridades de un treap T son escogidas al azar, entonces T siempre es completamente equivalente a un árbol binario de búsqueda construido al azar.
Antes de proceder a la demostración, reflexionemos sobre las consecuencias de la verdad de este conocimiento.
Sabemos que si el orden de inserción es aleatorio, entonces un árbol binario de búsqueda tiene un desempeño de O(lgn) para la búsqueda, inserción y al menos una eliminación. Pero si el teorema es verdadero, entonces en un treap no importa en orden de inserción, pues éste será equivalente a un árbol binario de búsqueda aleatorio. Mejor aún, en el treap podemos efectuar cuantas eliminaciones queramos sin perder la esperanza de tener un tiempo O(lgn).
Pero todo esto no es verdad hasta que esté demostrado. Así que aboquémosnos a ello.

2.2.2  Lema de la unicidad del treap

Sea K = { k1, k2, …, kn } un conjunto de claves y P = { p1, p2, … pn } un conjunto de prioridades. Entonces, el conjunto { (k1, p1), (k2, p2), … (kn, pn), } ⊂ K× P tiene un único treap.
La demostración es por inducción sobre la cantidad de nodos n:
  • n = 1: En este caso existe un único treap conformado por un par único. El lema es cierto para n=1.
  • n > 1: Ahora asumimos que el lema es cierto para todo n y verificamos su veracidad para n +1. Puesto que las prioridades son únicas, el treap de n +1 nodos sólo puede tener una raíz cuya prioridad es la menor de todas. Sea (ki, pi) el nodo raíz del treap. Una vez determinada la raíz del treap y a causa de la propiedad de orden de un ABB, el conjunto de nodos de las ramas izquierda y derecha queda determinado en K< = { (k1, p1), …, (Ki−1, pi−1) } y K> = { (ki+1, pi+1), …, (kn,pn) }, respectivamente. Por la hipótesis inductiva, K< y K>, cuyas cardinalidades son inferiores a n+1, tienen treaps únicos. El lema es cierto para todo n
El lema de la unicidad nos explica por qué el árbol por donde se inserta una nueva clave es idéntico al árbol por donde la misma clave se elimina: el treap siempre es único.

2.2.3  Demostración del teorema de equivalencia

Asumamos un conjunto KP = {(k1,p1),(k2,p2),…,(kn,pn)} de pares (clave,prioridad), en el cual las claves se adjudican al azar.
Notemos que según el lema de la unicidad el treap resultante del conjunto KP es único, independientemente del orden en que se inserten los pares. En virtud de esto, podemos suponer una secuencia especial de inserción KP= {(k1,p1),(k2,p2),…,(kn,pn)} que está ordenada por prioridad; es decir, la prioridad p1 en (k1,p1) es mínima y pn en (kn,pn) máxima. Esta asunción es posible porque de todos modos, comprobado por el lema de la unicidad, el treap resultante será el mismo que el de cualquier otra secuencia de inserción. Pero la secuencia KP es de sumo interés en virtud de las siguientes observaciones:
  1. KP es una secuencia aleatoria, independientemente de que haya sido ordenada por prioridad. Probemos informalmente esta proposición mediante un ejercicio de imaginación.
    Consideremos el orden en que dispondremos que un grupo de n estudiantes se sienten en un salón de clase. Para ello supongamos que los pupitres están enumerado desde 1 hasta n según orden en la fila, luego en la columna . Si no hacemos nada, entonces el orden en que los estudiantes se sienten está sesgado por diversos factores tales como el orden de llegada, la costumbre de cada estudiante y otras cosas, pero en todo caso, la disposición de los estudiantes en los n pupitres no será aleatoria.
    Ahora supongamos que cuando llega un estudiante se sortea uniformemente el pupitre donde le corresponderá sentarse . En este caso, el lugar se decide estrictamente al azar, no por algún otro factor. Ahora bien, si vemos los pupitres enumerados de 1 a n entonces las permutación de nombres de estudiantes sentados por este mecanismo aleatorio de disposición es una permutación aleatoria.
    En síntesis, Los pupitres están enumerados y ordenados, pero los estudiantes están sentados al azar.
    La secuencia de estudiantes es aleatoria o lo que a nuestro interés es lo mismo: la secuencia KP es aleatoria
    Así, para un treap cualquiera, en el ejercicio de ordenar KP por prioridad y obtener KP, éste último (KP) es aleatorio, pues recordemos o insistamos que sabemos por el lema de la unicidad que el treap resultante será el mismo. KP siempre puede verse como una secuencia aleatoria. Nos resta demostrar que su treap es equivalente, idéntico, al que se obtendría con un árbol binario de búsqueda.
  2. La secuencia KP no causa absolutamente ninguna rotación en el treap. En efecto, como KP está ordenado por prioridad, cada inserción de un par ki,pi tendrá a su padre con una prioridad menor, razón por la cual nunca habría necesidad de rotarlo. ¡Aja! pero resulta que en ausencia de rotaciones ¡la inserción en un treap es computacionalmente idéntica a la inserción en un árbol binario de búsqueda! Es decir, la tercera parte del algoritmo presentado en § 2.1.2 (“Rotar p de forma que éste suba de nivel hasta que su prioridad PRIO(p) sea mayor que la de su padre”) jamás se ejecutará. Elimina esta parte del algoritmo y lo resultante es exactamente la inserción en un árbol binario de búsqueda.
Al ser KP una secuencia de inserción aleatoria, que no causa rotaciones en la inserción del treap, entonces el treap es equivalente a un árbol binario de búsqueda construido aleatoriamente; con el gran añadido de que no interesa el orden de inserción ni que ocurran eliminaciones ▪

2.2.4  Consumo de espacio

Quizá la principal crítica contra el treap sea el consumo de espacio debido a la prioridad que hay que poner en cada nodo. Con claves enteras y en arquitecturas de 64 bits, cada nodo ocupa 2× 8
 
 
2× 8


punteros
 +
 
 
8


clave
 + 
 
 
8


prioridad
= 32 bytes     (1)
Un árbol rojo-negro o uno AVL puede implementarse en 24 bytes por nodo. Si guardamos en memoria unos 4295 millones de enteros, entonces requeriríamos 30 × 32/10243 ≈ 128 Gb, mientras que con un AVL requeriríamos 30 × 24/10243 ≈ 96 Gb.
Podría parecerte exagerado 4295 millones de nodos. ¡Pues no si tomamos en cuenta las aplicaciones modernas; en mi caso especial el mundo del “Data Mining”.
Existe, empero, una manera de no guardar la prioridad y así tener el mismo coste en espacio que los AVL o rojo-negro.

2.2.5  Prioridades como una función hash

El truco es extremadamente simple: en lugar de seleccionar aleatoriamente un valor de prioridad, ésta se calcula mediante una función hash h(k): —→ N que transforma la clave a un valor de prioridad. Cada vez que se desee obtener la prioridad se recalcula la función.
Por supuesto, la calidad del treap depende de cuan buena sea la función hash.

3  Conclusión

Los treaps son mi primera opción a la hora de escoger un árbol binario de búsqueda. Lo que sigue es mi por qué.
Por lo general, la estructuras de manipulación sobre conjuntos de claves basadas en árboles binarios manejan tiempos por operación de O(lgn). En añadidura, los árboles binarios tienen la extrema bondad de preservar el orden entre las claves, lo que le permite a un iterador mirar las claves en orden, sin sacrificio del desempeño O(lgn).
Los árboles tradicionales son muy simples, pero la esperanza O(lg n) para árboles clásicos se mantiene sí y sólo si la secuencia de inserción es aleatoria y no ocurren eliminaciones, lo cual no siempre es el caso. Por esa razón se añaden condiciones de equilibrio que traten con los sesgos. En el caso del treap es la condición de prioridad aunado a su selección aleatoria lo que rompe el sesgo y proporciona una expectativa O(lgn) para todas las condiciones, siempre y cuando la selección de prioridad sea aleatoria o, si es el caso, la función hash emule a una distribución aleatoria. El siguiente árbol de 512 claves ilustra el equilibrio de un treap:
En contraste, otros enfoques deterministas de equilibrio producen mejores árboles. Por ejemplo, árbol anterior como AVL se ve del siguiente modo:
Como se ve, es más o menos la mitad de altura, lo que redunda en mejores búsquedas. Sin embargo, aunque mucho mejor como árbol, el AVL conlleva un coste constante cuya recompensa se obtiene cuando se tienen muchos nodos.
Hechos estos planteamientos, puedo decir que mi preferencia por los treaps se resume en que su enfoque para mantener el equilibrio es el más simple que conozco. Otros árboles, los aleatorizados, splay, avl y rojo-negro, por ejemplos, tienen condiciones de equilibrio más difíciles de implementar pero, sobre todo, con mayores costes constantes.
Si el espacio es determinante, entonces yo usaría treaps que calculen su prioridad con una función hash.
Sólo si la cantidad de claves es muy grande o el consumo de memoria es determinante, entoces yo optaría por un árbol determinista; un AVL o rojo-negro, por ejemplos.

4  Opcional: análisis de los ABB

La verdad sobre los treaps depende de la verdad sobre los árbol binario de búsqueda, la cual se confirma con la proposición siguiente:
Sea T un árbol binario de búsqueda de n nodos construido aleatoriamente según una secuencia de inserción aleatoria. No hay supresiones. Sean:
  • Sn el número de nodos visitados durante una búsqueda exitosa, y
  • Un el número de nodos visitados durante una búsqueda fallida.
Entonces:
    S_n=




1 + 
1
n
 



Hn    −    3    ≈    1.386 lgn      =   O(lgn)  y 
    U_n=Hn+1    −    2    ≈    1.386 lg (n+1)      =   O(lgn)
Donde S_n y U_n denotan los promedios de Sn y Un respectivamente, mientras que Hn es el n-ésimo número armónico1.
Demostración
El número de nodos visitados en una búsqueda exitosa es la longitud del camino desde raiz(T) hasta el nodo encontrado. Si ni es el nodo encontrado, entonces |C raiz(T), ni| = nivel(ni) + 1, pues el nivel comienza desde cero. Por definición de promedio, tenemos:
     
     S_n =
 
1
n
 
∀ ni ∈ T
 (nivel(ni) + 1)   
 
  =
 
1
n
 
∀ ni ∈ T
nivel(ni)    +    1   
 
  =
 
1
n
 IPL(T)    +    1  
    (2)
Otro teorema de los árboles binarios nos dice que:
   EPL(T) = (m −1) IPL(T) + m   |T|     (3)
Sustituimos (3) en (2):
S_n = 
EPL(T) − 2n
n
    +    1       (4)
Ahora planteamos una ecuación similar para una búsqueda infructuosa. Puesto que el nodo no se encuentra en el árbol, la búsqueda descenderá hasta un nodo externo. El número de nodos visitados en una búsqueda infructuosa es, pues, equivalente a la longitud del camino desde la raíz hasta el nodo externo. Podemos entonces plantear el promedio de longitudes entre todos los caminos desde la raíz hasta un nodo externo. Esto es:
     
U_n =
1
n+1
 
∀ nx
nx  nodo externo
     nivel(nx)   
 
  =
 
EPL(T)
n+1
 =⇒  
 
EPL(T)= (n+1) U_n        (5)
Sustituimos (5) en (4):
S_n = 
 (n+1) U_n − 2n
n
     +    1       (6)
La ecuación (6) nos plantea las dos incógnitas que intentamos encontrar. Para poder resolverla requerimos una segunda ecuación, independiente de (6), que también nos relacione S_n y U_n,
Supongamos que la permutación de inserción es n0 n1 n2nn −2 nn −1. Notemos que la cantidad de nodos que se visitan cuando se busca exitosamente el nodo ni es equivalente a uno más el número de nodos que se visitaron durante la búsqueda infructuosa que se realizó durante la inserción de ni. Esto sólo es correcto si se asume que no hay supresiones. La ausencia de supresiones garantiza que los nodos nunca cambian de lugar en el árbol. Podemos entonces decir que:
S_i = U_i-1 + 1      (7)
Ahora, por la misma definición de promedio, podemos plantear una nueva ecuación:
     
     S_n=
 
 (U_0 + 1) + (U_1 + 1) + (U_2 + 1) + ⋯ + (U_n-1 + 1)
n
   
 
  =
 1    +    
1
n
n−1
i = 0
U_i   
    (8)
Igualamos (8) con (6):
     
     
 (n+1) U_n − 2n
n
     +    1
 =
 1    +    
1
n
n−1
i = 0
U_i       =⇒ 
 
     (n+1)U_n =
 
n−1
i=0
U_i     +    2n   
    (9)
Evaluamos (9) para n−1 y obtenemos:
n U_n-1 = 
n−2
i=0
U_i     +    2 (n−1)       (10)
Restamos (9) menos (10):
     
     (n+1)U_n    −    n U_n-1= U_n-1    +    2    =⇒    
     U_n =
 U_n-1 + 
2
n+1
    (11)
Lo que da una ecuación recurrente de U_n que podemos resolver por expansión sucesiva hasta el último término U_0 = 0:
     U_n=
U_n-1 + 
2
n+1
 
 =
U_n-2 + 
2
n
 + 
2
n+1
 
  
 =
U_0 + 
2
2
 + 
2
3
 + ⋯ + 
2
n−1
2
n
 + 
2
n+1
 
 =
 (
 (
 (
 (
 (
1
2
 + 
1
3
 + ⋯ + 
1
n−1
1
n
 + 
1
n+1
 



La serie ∑i=1n1/i es el n-ésimo número armónico. Así pues:
U_n  =  2 (Hn+1 − 1)  =  2Hn+1 −2  ≈        2 ( ln (n+1) − γ) −2 ≈  1,386 lg (n+1)           ▫              (12)
2
Ahora sustituimos (12) en (6):
     S_n=
 (n+1) 2 (Hn+1 − 1) − 2n
n
     +    1 
 =
n+1
n
 (2 (Hn+1 − 1))    −    1 
 =
n+1
n
  (
 (
 (
 (
 (
Hn + 
1
n+1
 − 1 



   −    1 
 =
n+1
n
 Hn − 3  ≈  2 lnn  ≈  1,386 lgn      ▪

1
Hn = ∑i=1n1/i.
2
El símbolo γ denota la constante de Euler caracterizada por:
γ = 
 
lim
n—→∞
(Hn − lnn) ≈ 0,5772156649…
.

This document was translated from LATEX by HEVEA.