martes, 20 de septiembre de 2011

Recursivas

Las listas tienen la propiedad de la clausura y pueden contener otras listas. Una lista jerárquica es una lista cuyos elementos son datos atómicos u otras listas jerárquicas. Por ejemplo, supongamos la siguiente lista:
((1 2 3) 4 5)
Esta lista define la siguiente estructura jerárquica de dos niveles:
*
       /|\
      * 4 5
     /|\
    1 2 3
En este tipo de jerarquía cada lista define un nodo que representa un nuevo nivel en la jerarquía. Los datos de la estructura definen los elementos finales de la jerarquía y los llamamos hojas.
Como sabes, los árboles son el tipo de datos típico que se usa en computación para representar estructuras jerárquicas. ¿Qué diferencia fundamental hay entre los árboles y las listas jerárquicas? En un árbol todos los nodos tienen un dato asociado, mientras que en las listas jerárquicas los datos sólo se encuentran en las hojas. Por esta razón, los árboles definidos por estas listas no son verdaderos y los llamaremos pseudo árboles.
La utilidad principal de las listas jerárquicas es doble. Por una parte definen una estructura relativamente sencilla en la que podemos definir y probar un conjunto de funciones que después van a ser directamente adaptables a los árboles. Por otra parte, representan una estructura de datos muy flexible con la que representar gran cantidad de objetos.
En los nombres de las funciones definidas para listas jerárquicas usaremos el sufijo pt (pseudo tree) para no confundirlas con las funciones que definiremos más adelante para árboles verdaderos. Por ejemplo, la función levels-pt contará el número de niveles de un pseudo árbol o lista jerárquica, mientras que la función levels-tree contará el número de niveles de un árbol genérico.
  • (leaf? dato): indica si un dato de una lista jerárquica es una hoja o no
  • (count-leaves-pt lista): devuelve el número de elementos atómicos de la estructura lista
  • (levels-pt lista): cuenta el número de niveles que contiene la lista jerárquica lista
  • (member-pt? dato lista): indica si el dato es un dato atómico que pertenece a la lista jerárquica lista
  • (map-pt f lista): devuelve una nueva lista jerárquica con la misma estructura que lista con los datos resultantes de aplicar f a los datos originales de lista
A continuación comentamos la implementación de cada una de estas funciones.
  • La función leaf? nos dice si un nodo es una hoja. No tenemos más que comprobar si se trata de una pareja o un dato atómico.
    (define (leaf? x)
       (not (pair? x)))
  • La función count-leaves-pt recorre todos los niveles de la lista jerárquica y devuelve el número de elementos atómicos que contiene. Utilizando la terminología de árboles, devuelve el número de hojas del pseudo árbol que se le pasa como argumento.
    (define (count-leaves-pt x)
       (cond
          ((null? x) 0)
          ((leaf? x) 1)
          (else (+ (count-leaves-pt (car x))
                   (count-leaves-pt (cdr x))))))
  • La función levels-pt devuelve el número de nives de la lista que se le pasa como argumento. Para su implementación, realiza una llamada recursiva con el primer dato de la lista y el resto de la lista, por lo que el argumento x puede ser un dato o un lista.
    (define (levels-pt x)
       (cond 
          ((null? x) 0)
          ((leaf? x) 0)
          (else (max (+ 1 (levels-pt (car x)))
                     (levels-pt (cdr x))))))
  • La función member-pt? recorre el pseudo árbol y comprueba si alguno de sus datos coincide con el argumento que le pasamos. La implementación realiza un recorrido del pseudo árbol llamando de forma recursiva a la propia función con el primer elemento de la lista (que puede ser, a su vez, otra lista jerárquica) y con el resto de la lista.
    (define (member-pt? x lista)
             (cond 
                 ((null? lista) #f)
                 ((leaf? lista) (= x lista))
                 (else (or (member-pt? x (car lista))
                           (member-pt? x (cdr lista))))))
  • La función square-pt construye una nueva lista jerárquica con los elementos de la que se le pasa como parámetro elevados al cuadrado. Llama a la función cons para montar la nueva lista con el resultado que devuelve las llamadas recursivas.
    (define (square-pt lista)
      (cond ((null? lista) '())
            ((leaf? lista) (square lista))
            (else (cons (square-pt (car lista))
                        (square-pt (cdr lista)) )) ))
  • La función map-pt generaliza la función anterior, ya que permite pasar como argumento la función que se aplica a los datos de la lista.
    (define (map-pt f lista)
      (cond ((null? lista) '())
            ((leaf? lista) (f lista))
            (else (cons (map-pt f (car lista))
                        (map-pt f (cdr lista)) )) ))
Un ejemplo de uso de listas jerárquicas es el propio Scheme. Las expresiones de Scheme son listas jerárquicas. Por ejemplo, la expresión
(let ((x 12)
      (y 5))
   (+ x y)))
representa la siguiente estructura jerárquica

Árboles binarios

Un árbol binario es una estructura que contiene tres elementos: un dato, un árbol binario izquierdo y un árbol binario derecho. Esta definición es recursiva y necesita un caso base. Es el siguiente: un árbol binario puede ser también un árbol-vacío, un símbolo especial (usaremos la lista vacíadatum-bt) que simboliza un árbol que no tiene datos ni hijos.
En nuestra implementación la estructura en la que se guarda los elementos del árbol es una lista de tres elementos.
Llamamos raíz al nodo inicial del árbol y rama izquierda y rama derecha a los árboles izquierdos y derechos.
Las funciones que definen la barrera de abstracción del tipo de datos son las siguientes.
Constructores:
  • (make-bt dato izq der): construye un árbol binario a partir de un dato y otros dos árboles binarios
  • empty-bt: define el árbol binario vacío
Selectores:
  • (empty-bt? btree): comprueba si un árbol binario es vacío
  • (datum-bt btree): devuelve el dato de la raíz del árbol y provoca un error si el árbol está vacío
  • (left-bt btree): devuelve el hijo izquierdo de la raíz del árbol y provoca un error si está vacío
  • (right-bt btree): devuelve el hijo derecho de la raíz del árbol y provoca un error si está vacío
  • (empty-bt? btree): comprueba si un árbol binario está vacío
La implementación en Scheme de estas funciones es como se muestra a continuación.
(define (make-bt dato izq der)
    (list dato izq der))
(define empty-bt '())

(define empty-bt? null?)
(define (datum-bt btree) (car btree))
(define (left-bt btree) (car (cdr btree)))
(define (right-bt btree) (car (cdr (cdr btree))))

(define (leaf-bt? btree)
   (and (empty-bt? (left-bt btree))
        (empty-bt? (right-bt btree))))
Supongamos el siguiente árbol binario, que representa una expresión matemática.
*
          / \
         +   8
        / \
       5   3
El siguiente código muestra cómo se construye este árbol binario con los constructores que hemos definido.
(define t1 (make-bt 5 empty-bt empty-bt))
(define t2 (make-bt 3 empty-bt empty-bt))
(define t3 (make-bt '+ t1 t2))
(define t4 (make-bt 8 empty-bt empty-bt))
(define t5 (make-bt '* t3 t4))
Veamos ahora algunas operaciones sobre árboles binarios.
  • La función bt-to-list construye una lista con los elementos contenidos en un árbol binario.
    (define (bt-to-list btree)
       (if (empty-bt? btree)
          '()
          (append (bt-to-list (left-bt btree)) 
                  (list (datum-bt btree))
                  (bt-to-list (right-bt btree)))))
    
    La recursión de esta función se basa en obtener el dato de la raíz, obtener la lista de elementos de la rama izquierda (confía en la recursión, ¿recuerdas?) y la lista de elementos de la rama derecha. La función append concatena todos los elementos. Dependiendo del orden en que los concatenemos aparecerán los elementos en pre-order, in-order o post-order.
  • La función insert-bt realiza una inserción de un dato en un árbol binario ordenado. El hecho de que el árbol es ordenado implica que todos los datos del hijo izquierdo son menores que el dato de la raíz y los del hijo derecho son mayores. Por eso la recursión de la función se basa en identificar en qué subárbol va el dato que estamos insertando. Si el dato es mayor que la raíz hay que ponerlo en el hijo derecho, por lo que construimos un nuevo árbol con la misma raíz e hijo izquierdo que el actual y como hijo derecho ponemos el resultado de la llamada recursiva de insertar un dato en el árbol derecho (¡confía en la recursión!). Si el dato es menor hacemos lo contrario.
    (define (insert-bt x bt)
       (cond
          ((empty-bt? bt) (make-bt x empty-bt empty-bt))
          ((< x (datum-bt bt))
             (make-bt (datum-bt bt) 
                        (insert-bt x (left-bt bt)) 
                        (right-bt bt)))
          ((> x (datum-bt bt))
             (make-bt (datum-bt bt)
                        (left-bt bt)
                        (insert-bt x (right-bt bt))))
          (else bt)))
    
    Es muy importante notar que en programación funcional no es posible modificar las estructuras de datos porque no existe el concepto de estado de la computacion. Por eso, las funciones siempre devuelven estructuras nuevas, creadas a partir de los parámetros de entrada. Esto tiene sus ventajas e inconvenientes.
    Entre las ventajas se encuentra la sencillez, mantenibilidad, reusabilidad y robustez del enfoque debido a la no existencia de efectos laterales en las funciones. En la computación procedural, donde existen estructuras de datos que pueden ser modificadas por distintos procedimientos, hay que tener mucho cuidado los efectos laterales provocados por que una función modifica una estructura en la que se basa otra función.
    Entre los inconvenientes se encuentra el coste temporal de la operación. Al no existir la posibilidad de modificar sólo una parte de la estructura se obliga a copiar todos sus datos en una estructura nueva recién creada. Esto impone un coste mínimo de O(n).
  • La función insert-list-bt se basa en la función anterior para insertar todos los elementos de una lista en un árbol binario. Al igual que en el caso anterior, hay que hacer notar que no existe tal inserción, sino que se construye un árbol nuevo que se devuelve como resultado.
    (define (insert-list-bt list bt)
       (if (null? list)
          bt
          (insert-list-bt (cdr list) 
              (insert-bt (car list) bt))))
    
  • La función list-to-bt se podría considerar un constructor de árboles binarios orddenados a partir de una lista. Está basado en las funciones anteriores. Otro posible nombre para esta función sería make-bt-from-list.
    (define (list-to-bt list)
       (if (null? list)
          list
          (insert-list-bt 
              (cdr list) 
              (make-bt (car list) 
                          empty-bt
                          empty-bt))))
    
  • La función member-bt? comprueba si un dato pertenece a un árbol binario ordenado. Está basada en la típica búsqueda binaria con coste log(n).
    (define (member-bt? x bt)
       (cond 
          ((empty-bt? bt) #f)
          ((= x (datum-bt bt)) #t)
          ((< x (datum-bt bt))
             (member-bt? x (left-bt bt)))
          (else
             (member-bt? x (right-bt bt)))))
    

Árboles genéricos

Los nodos de los árboles genéricos pueden tener un número variable de hijos, a diferencia de los nodos de los árboles binarios que sólo pueden tener dos hijos como máximo. Se usan en un gran número de dominios en los que es necesario representar estructuras jerárquicas: procesadores de lenguaje, documentos XML, documentos HTML, etc.
Definición del tipo de datos árbol:
  • Un árbol genérico está formado a partir de un nodo raíz que contiene un dato y una lista de hijos que, a su vez, son árboles genéricos. La lista de hijos puede ser vacía.
A diferencia de los árboles binarios, en los árboles genéricos no usamos el concepto de árbol-vacío. Un árbol o nodo hoja es un árbol cuya lista de hijos es una lista vacía.
En Scheme es muy fácil implementar un árbol genérico, construyendo una lista cuyo primer elemento es el dato del nodo y cuyo resto de elementos (cdr) son los hijos.
Constructores:
(define (make-tree dato lista-hijos)  (cons dato lista-hijos))
Selectores:
(define (datum-tree tree)  (car tree))
(define (children-tree tree)  (cdr tree))
Ejemplo de uso:
(define t1 (make-tree 4 '()))
(define t2 (make-tree 5 '()))
(define t3 (make-tree 3 '()))
(define t4 (make-tree '* (list t1 t2 t3)))
(define tree (make-tree '+ (list t3 t4)))
Un árbol binario usando esta implementación:
(define t1 (make-tree 9 '()))
(define t2 (make-tree 32 '()))
(define t3 (make-tree 52 '()))
(define t4 (make-tree 102 '()))
(define t5 (make-tree 28 (list t1 t2)))
(define t6 (make-tree 70 (list t3 t4)))
(define tree (make-tree 40 (list t5 t6)))
Algunas operaciones sobre árboles genéricos.
  • La función square-tree devuelve el árbol resultante a aplicar la función square a los números del árbol que se pasa como parámetro.
    Es importante notar, al igual que hacíamos cuando hablábamos de árboles binarios, que el árbol resultante es una copia del árbol que se pasa como parámetro y que no se pueden modificar directamente los datos del árbol original porque nos encontramos en el paradigma de programación funcional.
    (define (square-tree tree)
      (make-tree (square (dato tree))
                 (map square-tree (children-tree tree)) ))
    
    La implementación es bastante interesante. La recursión se basa en aplicar la propia función a cada uno de los hijos. Para ello se obtiene la lista de hijos con la función children-tree y se usa la función map para aplicar la propia función a todos los elementos de esa lista (que son árboles). La lista de árboles resultante se utiliza para construir el árbol que se devuelve añadiendo como dato el cuadrado del dato original.
  • La función tree-to-list devuelve una lista con todos los elementos del árbol.
    (define (tree-to-list tree)
       (if (null? (children-tree tree))
           (list (datum-tree tree))
           (cons (datum-tree tree)
                 (forest-to-list (children-tree tree)))))
    
    (define (forest-to-list forest)
       (if (null? forest)
           forest
           (append (tree-to-list (car forest))
                   (forest-to-list (cdr forest)))))
    La recursión de esta función es muy interesante, ya que se basa en una recursión mútua. La función tree-to-list obtiene la lista de hijos del nodo actual. A esta lista de árboles la llamamos forest (bosque). Se hace entonces una llamada a una función forest-to-list que devolverá una lista con todos los datos que haya en todos los árboles hijos. A su vez, esta función forest-to-list es una función recursiva que toma una lista de árboles y va construyendo una lista de datos a base de llamadas a la función tree-to-list para cada árbol de la lista.
  • Por último, la función map-tree es la generalización de la función square-tree y permite aplicar una función cualquiera de un argumento a todos los datos del árbol inicial, devolviendo el árbol resultante.
    (define (map-tree f tree)
       (make-tree (f (datum-tree tree))
                  (map-forest f (children-tree tree))))
    
    (define (map-forest f forest)
       (if (null? forest)
           forest
           (cons (map-tree f (car forest))
                 (map-forest f (cdr forest)))))
    La implementación de esta función tiene un patrón similar a tree-to-list.

Referencias

Para saber más de los temas que hemos tratado en esta clase puedes consultar las siguientes referencias:

1 comentario: