INSTITUTO SUPERIOR TECNOLÓGICO NORBERT WIENER Manual del Alumno ASIGNATURA: Estructura de la Información PROGRAMA: S3C Lima-Perú 2 Manual del Alumno LISTAS 1. INTRODUCCIÓN. Dado un dominio D, una lista de elementos de dicho conjunto es una sucesión finita de elementos del mismo.En lenguaje matemático, una lista es una aplicación de un conjunto de la forma {1,2, ... ,n} en un dominio D: R:{1,2, ... ,n} ---> D Una lista se suele representar de la forma: <a1,a2, ... ,an> con ai = a(i) A n se le llama longitud de la lista. A 1,2,...,n se les llama posiciones de la lista. El elemento a(i)=ai, se dice que ocupa la posicion i. Si la lista tiene n elementos, no existe ningún elemento que ocupe la posición n+1. Sin embargo, conviene tener en cuenta dicha posición, a la que se llama posición detras de la última, ya que esta posición indicará el final de la lista. A a 1 se le llama primer elemento de la lista y a an último elemento de la lista. Si n=0 diremos que la lista está vacía y lo representaremos como <>. Los elementos de una lista estan ordenados por su posición. Así, se dice que ai precede a ai+1 y que ai sigue a ai-1. A continuación vamos a especificar un ejemplo de posibles operaciones primitivas entre listas. Al conjunto de las listas (es decir, al tipo lista) lo llamaremos tLista. Al conjunto de los elementos básicos(es decir, al tipo que se almacenará en la lista) tElemento. También vamos a considerar el tipo posición como tPosicion. Esto lo haremos así, ya que no siempre las posiciones las vamos a representar por números naturales del lenguaje que se utilice. Lo único importante de la representación que se utilice es que sea un conjunto finito y totalmente ordenado: hay un primer y un último elemento y dado un elemento se puede determinar el siguiente (si no es último) y el anterior (si no es el primero). Hay que tener en cuenta que si una lista tiene longitud n y se elimina el elemento que ocupa una determinada posición intermedia i, entonces la longitud pasa a ser n-1 y el elemento que estaba en la posición i+1 pasará a ocupar la posición i, el de la posición i+2 pasará a ocupar la posición i+1 y así sucesivamente. 2. OPERACIONES PRIMITIVAS DE LAS LISTAS. Dentro del tipo abstracto de listas podemos proponer las siguientes primitivas: void anula(tLista *l) tPosicion primero(tLista l) tPosicion fin(tLista l) tPosicion siguiente(tPosicion p, tLista l) tPosicion anterior(tPosicion p, tLista l) tPosicion posicion(tElemento x, tLista l) tElemento elemento(tPosicion p, tLista l) void insertar(tElemento x, tPosicion p, tLista t) void borrar(tPosicion p, tLista l) 3 Manual del Alumno ESPECIFICACIÓN SEMANTICA Y SINTACTICA. void anula(tLista *l) PRE: l = <a1,a2, ... ,an> POST: (*l) = <> {vacía la lista} tPosicion primero(tLista l) PRE: l está inicializada. POST: RESULTADO = (1) {devuelve la primera posición de la lista. Si la lista es <> coincide con fin(l)} tPosicion fin(tLista l) PRE: l está inicializada. POST: RESULTADO = (n + 1) {posición detrás de la última} tPosicion siguiente(tPosicion p, tLista l) PRE: l = <a1,a2, ... ,an>, 1 <= p <= n POST: RESULTADO = p + 1 {devuelve la posición siguiente a la posición p} tPosicion anterior(tPosicion p, tLista l) PRE: l = <a1,a2, ... ,an>, 2 <= p <= n+1 POST: RESULTADO = p - 1 {devuelve la posición anterior a la posición p} tPosicion posicion(tElemento x, tLista l) PRE: l está inicializada. POST: Si existe j perteneciente a {1,2, ... ,n} tal que aj = x entonces RESULTADO = i, donde i verifica: ai=x y si aj=x entonces j>=i. {da la posición de la primera aparición de x en la lista l} tElemento elemento(tPosicion p, tLista l) PRE: l = <a1,a2, ... ,an>, 1 <= p <= n POST: RESULTADO = ap {devuelve el elemento situado en la posición p} void insertar(tElemento t, tPosicion p, tLista l) PRE: l = <a1,a2, ... ,an>, 1 <= p <= n+1 POST: l = <a1, ... ,ap-1, x, ap, ... ,an > {Resulta una lista de longitud n+1, en la que x ocupa la posicion p. Si p=n entonces la lista resultante es l=< a1,...,an-1,x,an>} void borrar(tPosicion p, tLista l) PRE: l = <a1,a2, ... ,an>, 1 <= p <= n POST: l = <a1, ... ,ap-1, ap+1, ... ,an > {elimina el elemento que ocupa la posición p, de forma que ahora la posición p la ocupa el elemento que se encontraba en la posición p+1} Respecto al conjunto de primitivas que hemos presentado, no son más que un ejemplo representativo de las primitivas más importantes que nos sirve para ilustrar la forma en que se debe construir el tipo de dato abstracto Lista. Obviamente, en una implementacion real es posible optar por un conjunto distinto de primitivas teniendo en cuenta varios puntos: El conjunto de primitivas tiene que ser completo en el sentido de que tiene que ser posible construir cualquier algoritmo que use listas utilizando únicamente las primitivas que se incluyen. 4 Manual del Alumno También debe ser suficiente pero no obligatoriamente mínimo. Aunque no sea necesario incluir nuevas primitivas, puede ser conveniente añadir nuevas funciones si existen motivos como: a. La función va a ser probablemente muy usada. Es el caso de primitivas como la de posición o anterior, las cuales pueden ser perfectamente programadas en base a las demás. Es fácil imaginar nuevas primitivas como pueden ser una primitiva de copia de una lista en otra, una primitiva de ordenación eficiente de los elementos de la lista,etc. b. La función va a ser usada con cierta asiduidad y por otra parte la implementación haciendo uso de las demás funciones primitivas empeora sustancialmente la velocidad de operación. Es el caso de primitivas como anterior que pueden ser programadas en base a primero y siguiente pero que en determinadas implementaciones pasaría esta operación de tener orden constante a tener orden de la longitud de la lista. Es posible tener que rehacer el conjunto de primitivas atendiendo a razones referentes a una eficiente utilización de los recursos hardware. Es el caso por ejemplo de la función anula, la cual en ciertas implementaciones es altamente probable se a transformada en una función de creación que se ve completada con otra nueva de destrucción: . i. CrearVacia: Devuelve una lista que está vacía. CrearCopia: Toma como argumento una lista ya creada y devuelve una lista distinta que es copia de la primera. Las cabeceras de las funciones pueden necesitar ser modificadas para hacer viable su implementación. Es el caso por ejemplo de que una función no pueda devolver un tipo de dato o que el tipo de dato sea muy complejo y que pasarlo por valor o devolverlo como salida de una función pueda convertirse en algo ineficente dado su tamaño. En muchos casos, por tanto, será aconsejable no pasar estructuras directamente sino un puntero a ellas, que una función no devuelva un valor sino que se devuelva mediante un puntero en uno de sus parámetros,etc. Un tipo de dato abstracto es un producto software y como tal es algo dinámico que está sujeto a un mantenimiento. De esta forma tendremos que tener en cuenta que el conjunto de primitivas de un TDA es algo extensible. En este sentido, el conjunto de funciones que incorporamos a un TDA no debe ser diseñado considerando que debemos añadir todas y cada una de primitvas que creemos que se necesitarán, es decir, puede ser más conveniente retrasar la incorporación de ciertas primitavas en caso de que dudemos de su utilidad. Téngase en cuenta que, desde el punto de vista del mantenimiento del software que usa el TDA, es mucho menos costosa la adicíon de nuevas primitivas que la supresion de algunas ya existentes. EJEMPLOS DE USO. Es importante aprender a usar las listas basándonos en estas especificaciones, aunque este tipo no venga en el lenguaje en el que estemos trabajando y no conozcamos la implementación que se va a usar. Por ejemplo, vamos a escribir un porcedimiento que escriba todos los elementos de una lista. Suponiendo que para tElemento existe un procedimiento, ,escribe(x), que escribe un elemento de dicho tipo. void salida (tLista t) { 5 Manual del Alumno tposicion p; telemento x; for (p = primero(l); p != fin(l); p = siguiente(p, l)){ x = elemento(p, l); escribe(x); } } Veamos otro ejemplo de copia de una lista en otra: tLista copia (tLista l) { tLista l2; tPosicion p; anula(&l2); for (p=primero(l);p!=fin(l);p=siguiente(p,l)) insertar(elemento(p,l),fin(l2),l2); return l2; } En el siguiente ejemplo, vamos a ver un porcedimiento para eliminar todos los elementos repetidos de la lista. Suponemos que el tipo básico es tElemento y que existe una función lógica, igual(x,y), que nos dice cuando son iguales dos elementos de este tipo. Se podría pensar en que bastaría considerar la igualdad del C(==), pero es posible que no coincidad con la igualdad de tElemento. Por ejemplo, consideremos los numeros racionales definidos como: typedef struct { int num; int den; }racional; 6 Manual del Alumno Entonces si x,y son de tipo racional, entonces pueden representar el mismo racional, ser iguales, aunque no se verifique x.num==y.num && x.den==y.den .La función igual sería en este caso: int igual (int x;int y) { return (x.num*y.den == y.num*x.den); } Con estas consideraciones, el procedimiento para eliminar las repeticiones de una lista sería como sigue: void elimina (tLista l, int (*es_igual)(tElemento, tElemento)) { tposicion p, q; for (p = primero(l); p != fin(l); p = siguiente(p, l)){ q = siguiente(p ,l); while (q != fin(l)) if ((*es_igual)(elemento(p, l), elemento(q, l))) borrar(q, l); else q = siguiente(q, l); } } Unos comentarios respecto a esto ejemplo: La forma de usar esta función con nuestro ejemplo sobre una lista l es mediante la llamada elimina(l,igual). La variable l es un parámetro que se pasa por valor. Nótese que su valor no cambia a lo largo de la función dado que en las especificaciones siempre se pasa este parámetro de esta forma. La única función de las especificadas que se puede usar para cambiar el valor de una variable de tipo tLista es anula. 7 Manual del Alumno Se puede observar que tan solo se pasa a la posición siguiente cuando no se borra el elemento que se encuentra en la posición p ya que en el caso de que sea borrado ese elemento habrá que analizar el elemento que se encuentra en esa posición p. 3. IMPLEMENTACIÓN DE LAS LISTAS. IMPLEMENTACIÓN DE LISTAS MEDIANTE VECTORES. Las listas se pueden implementar usando las posiciones consecutivas de un vector.Como las listas tienen longitud variable y los vectores longitud fija, esto se resuelve considerando vectores de tamaño igual a la longitud maxima de la lista y un entero donde se indica la posición donde se encuentra el último elemento de la lista. Así podriamos tener: #define LMAX = 100; typedef int tElemento; /* Una constante adecuada. */ /* Por ejemplo. */ typedef struct{ tElemento elementos[LMAX]; int n; } Lista; typedef Lista *tLista; typedef int tPosicion; FUNCIÓN DE ABSTRACCIÓN: Dado el objeto del tipo rep r = {elementos, n}, el objeto abstracto que representa es: <r.elementos[0], r.elementos[1], ... , r.elementos[n-1]> INVARIANTE DE LA REPRESENTACIÓN: VERDAD. 8 Manual del Alumno La implementación de la mayoria de las operaciones es prácticamente inmediata. Por ejemplo, las mas simples son: static void error (char *mensaje) { fprintf(stderr, "%s\n", mensaje); exit(-1); } void anula (tLista *l) { *l = (tLista) malloc (sizeof(Lista)); if (*l==NULL) { error("No hay memoria."); } (*l)->n=-1; } tPosicion primero (tLista l) { return 0; } tPosicion fin (tLista l) { return(l->n+1); } tPosicion siguiente (tPosicion p, tLista l) { if ((p < 0) || (p > l->n)) 9 Manual del Alumno error("Posición no válida."); return (p + 1); } tPosicion anterior (tPosicion p, tLista l) { if ((p <= 0) || (p > l->n+1)) error("Posición no válida."); return (p - 1); } tElemento elemento (tPosicion p, tLista l) { if ((p < 0) || (p > l->n)) error("Posición no válida."); return (l->elementos[p]); } Las únicas operaciones que pueden presentar un poco de dificultad son las de insertar,borrar y posicion. La función posición tiene que realizar una búsqueda lineal en un vector. En caso de que el elemento considerado no esté en el vector, esta función debe devolver lo mismo que fin(l). tPosicion posicion (tElemento x, tLista l) { tPosicion q; int encontrado; q = encontrado = 0; while ((q <= l->n) && (!encontrado)) { if (l->elementos[q] == x) encontrado=1; else q++; }; 10 Manual del Alumno return q; } Para la operación de inserción hay que hacer previamente un hueco donde realizar dicha inserción. Para el borrado, hay que "rellenar" el hueco dejado por el elemento borrado. En la figura podemos observar en las flechas superiores los movimientos de los elementos que se han tenido que realizar para insertar en la posición p (coinciden con los movimientos en sentido contrario que se deben realizar para borrar el elemento que se encuentra en dicha posición). Como consecuencia de ello, habrá que mover, en ambos casos, todos los elementos que ocupen una posición superior a la considerada para realizar la inserción o borrado. Esto tiene como consecuencia que la eficiencia de las operaciones no es muy buena, del orden del tamaño de la lista. void insertar (tElemento x, tPosicion p, tLista l) { tPosicion q; if ((p > l->n+1) || (p < 0)) error("Error p incorrecta."); else if (l->n >= LMAX-1) error("Lista llena"); else{ for (q = l->n; q >= p; q--) l->elementos[q+1] = l->elementos[q]; l->n++; l->elementos[p] = x; } } 11 Manual del Alumno void borrar (tPosicion p,tLista l) { if ((p > l->n) || (p < 0)) error("p incorrecta."); else { l->n--; for (; p <= l->n; p++) l->elementos[p]=l->elementos[p+1]; } } Aparte de la mala eficiencia de estas dos operaciones, que veremos como se mejorará en otras implementaciones, otro inconveniente de esta implementación es que las listas tienen un tamaño máximo del que no se puede pasar. Es decir, no corresponden exactamente a las especificaciones consideradas en un principio. Por otra parte, siempre hay una porción de espacio reservada para los elementos de la lista, y que no se utiliza al ser el tamaño de la lista, en un momento dado menor que el tamaño máximo. Esto se hace más grave si las distintas listas que se representen son de un tamaño muy distinto. Otro detalle importante de esta implementación es, cómo hemos mencionado anteriormente, la necesidad de una función de destrucción ya que ahora mismo la memoria que se requiere cada vez que se hace una llamada a la función anula no es recuperada en ningún momento.Sería interesante añadir una nueva función tal como la siguiente (Nótese que si la constante LMAX es grande y se hace uso de un número alto de listas esta función no sólo se hace interesante sino que necesaria: void destruye (tLista l) { free(l); } Teniendo en cuenta los problemas que presenta la implementación que hemos presentado mediante vectores y considerando las posibilidades que nos brinda el lenguaje C, podemos proponer una versión mas optimizada: typedef int tElemento /* Por ejemplo. */ 12 Manual del Alumno typedef struct{ tElemento *elementos; int Lmax; int n; }Lista; typedef Lista *tLista; typedef int tPosicion; tLista crear (int tamanoMax) { tLista l; l = (tLista) malloc(sizeof(Lista)); if (l == NULL) error("Memoria Insuficiente"); l->Lmax = tamanoMax; l->n = -1; l->elementos = (tElemento *)malloc(tamanoMax*sizeof(tElemento)); if (l->elementos == NULL) error("Memoria Insuficiente."); } void destruir (tLista l) { free(l->elementos); free(l); } 13 Manual del Alumno Donde las demás primitivas quedarían de la misma forma sustituyendo LMAX por l->LMAX En esta nueva implementación conseguimos resolver con exito dos cosas: 1. Tamaños variables: Ahora la primitiva anula ha sido sustituida por la primitiva crear a la que se pasa un parámetro indicando el tamaño maximo que tendra la lista.La mejora, por lo tanto, ha sido sustancial teniendo en cuenta que el tamaño máximo que es necesario para la versión anterior debe ser superior a la más grande de las listas que se manejan y por consiguiente para pequeñas listas habría una gran cantidad de memoria desperdiciada. 2. Creación y Destrucción: Aunque en la versión anterior se solucionó el problema al proponer la función destruye, es importante destacar que en esta versión también se ofrece el constructor y destructor del tipo de dato permitiendo de esta forma recuperar los recursos ocupados por las listas que no se volverán a usar. Es importante destacar la forma en que se deben usar las funciones de un tipo de dato abstracto (normalmente en la especificación junto con algún ejemplo si es necesario). Así destacaremos que que en este nuevo conjunto de primitivas incluyendo crear y destruir el uso del TDA debe ser: 1. 2. 3. 4. Declaración de la variable de tipo tLista. Creación de la lista mediante la primitva crear. Uso de la lista mediante primitivas distintas a la de creación y destrucción. Destrucción de la lista mediante la primitiva destruir. Teniendo en cuenta: 1. El uso de la primitiva crear sobre una lista ya creada provocará una pérdida de los 2. 3. recursos de memoria ocupados por esta lista y la actualización de su valor a la lista vacía. El uso de la primitiva destruir a una lista no creada o a una lista que aunque se creó ha sido destruida es erróneo y provocará resultados imprevisibles. Obviamente, después de la destrucción de una lista, se podrá usar de nuevo la misma variable en la creación, uso y destrucción de una nueva lista. Como ejemplo mostramos una función que guarda en una lista los números enteros del 0 al 9, despues la recorre eliminando los impares y por último escribe el resultado dos veces, desde el primer elemento al último y desde el último al primero: void EJEMPLO () { int a; tLista l; tPosicion p; l = crear(10); for (a=0; a<10; a++) insertar(a, primero(l), l); 14 Manual del Alumno for (p=primero(l); p!=fin(l); ) { a = elemento(p,l); if (a%2) borrar(p,l); else p = siguiente(p,l); } for (p=primero(l); p!=fin(l); p=siguiente(p,l)) { a = elemento(p,l); printf("Elemento: %d \n",a); } printf(" \n \n "); for (p=fin(l); p!=primero(l); p=anterior(p,l)) { a = elemento(anterior(p,l), l); printf("Elemento: %d \n",a); } destruir(l); } IMPLEMENTACIÓN DE LISTAS MEDIANTE CELDAS ENLAZADAS POR PUNTEROS. Una implementación de las listas que evita los problemas anteriormente mencionados para los vectores, es la que está basada en el uso de punteros. Esta implementación se basa en representar cada elemento, ai, de una lista <a1,a2, ...,an> como una celda dividida en dos partes: un primer campo donde se almacena el elemento en cuestión; y un segundo campo donde se almacena un puntero, que nos indica donde está el siguiente elemento de la lista, tal como se muestra en la parte (a) de la figura. La celda que contiene el último elemento de la lista tiene un puntero donde se almacena NULL. Así, la lista quedaria como se muestra en la parte (b) de la figura. 15 Manual del Alumno Para realizar más facilmente las operaciones es conveniente considerar una celda inicial, llamada de cabecera y donde no se almacena ningún elemento de la lista. De esta forma la lista propiamente diche vendrá representada por un puntero que indique la dirección de la cabecera y que permite obtener los distintos elementos de la misma como se muestra finalmente en la parte (c) de la figura. Para estas listas es conveniente representar la posición mediante un puntero que acceda al elemento correspondiente. Sin embargo, no se va a consider un puntero con la dirección de la celda donde está el elemento considerado, sino la dirección de la celda donde está el elemento anterior. Con esto se puede acceder a dicho elemento (mediante el puntero correspondiente), y también será más útil para las operaciones de inserción y borrado. La posición del primer elemento, vendrá representada entonces por un puntero apuntando a la celda de cabecera, es decir, idéntico a la lista, l. La posición del elemento ai se representará mediante un puntero, indicando la celda del elemento ai-1. La posición detrás del último elemento será un puntero apuntando a an. Debido a que la posición lógica de un elemento viene determinada por la posición física del anterior puede dar lugar a un error de programación si se trabaja con varias posiciones a la vez y se realizan borrados. Por ejemplo, consideremos una lista con 3 elementos y 2 punteros indicando la posición del segundo (puntero p) y tercer (puntero q) elemento (ver figura). Si no atendemos a la implementación, el borrar el elemento de la posición p (elemento a 2) podemos considerar dos resultados: Dado que q apunta al tercer elemento y quedan dos, q resulta apuntando a fin(l). Dado que a3 pasa a ser el segundo elemento y q apuntaba a a3, ahora q apunta al elemento segundo de la lista. En general, el primer caso corresponde a la implementación realizada mediante vectores y el segundo a la realizada mediante celdas enlazadas teniendo en cuenta: 16 Manual del Alumno En el caso de las listas mediante celdas enlazadas, el comportamiento es válido excepto para el caso de dos posiciones consecutivas. Si en el ejemplo que nos ocupa borramos el segundo elemento, la zona a la que apunta q es liberada y por tanto es incorrecto usar su contenido además de haber quedado fuera de la lista y por tanto es una posición no válida. En el caso de las matrices,ocurre de forma paralela que el comportamiento es válido excepto si una posición indicaba el final de la lista. Al eliminar un elemento, el final de la lista se ve modificado y por tanto si una posición indicaba el final, queda apuntando a una zona fuera de la lista. Es por ello que el uso simultáneo de varias posiciones conviene que sea manejado con cuidado. Obviamente, el que el acceso a un elemento se produzca por medio del elemento anterior conviene que sea indicado en la especificación del TDA mediante el correspondiente aviso de que el borrado de un elemento invalidad los valores de posición del inmediatamente posterior (por ejemplo, se puede indicar el comportamiento de los valores posición cuando se usan las funciones de inserción y de borrado). En C, la definición de tipos correspondiente a la implementación por punteros sería: typedef struct Celda{ tElemento elemento; struct Celda *siguiente; }celda; typedef celda *tPosicion; typedef celda *tLista; FUNCIÓN DE ABSTRACCIÓN Dado el objeto del tipo rep l={elemento, siguiente}, el objeto abstracto que representa es: <l->siguiente->elemento, l->siguiente->siguiente->elemento, ... ,l->siguiente-> (n) ->siguiente>elemento> Donde r->siguiente-> (n+1) ->siguiente == NULL. INVARIANTE DE REPRESENTACIÓN Todas las direcciones de los campos siguiente proceden de llamadas (tposicion) malloc(sizeof(celda)) o son NULL. Y las operaciones se pueden implementar como sigue: tLista crear () { tLista l; l = (tLista)malloc(sizeof(celda)); if (l == NULL) 17 Manual del Alumno error("Memoria Insuficiente."); l->siguiente = NULL; return l; } void destruir (tLista l) { tPosicion p; for (p = l; l != NULL; p = l){ l = l->siguiente; free(p); } } tPosicion fin (tLista l) { tPosicion p; p=l; While (p->siguiente != NULL) { p = p->siguiente; } return p; } Repecto a esta función es importante senñalar , que siempre tiene que recorrer toda la lista para devolver el puntero que se muestra en la figura. Por lo que su eficiencia es del orden de la longitud de la lista. Habría que procurar no utilizarla demasiado si se usa esta implementación. 18 Manual del Alumno Por ejemplo, un ciclo while con una condición p!=fin(l) se debe sustituir por: q=fin(l); while (p!=q)... void insertar (tElemento x, tPosicion p, tLista l) { tPosicion q; q = (tPosicion)malloc(sizeof(celda)); if (q == NULL) error("Memoria Insuficiente."); q->elemento = x; q->siguiente = p->siguiente; p->siguiente = q; } La forma en la que se realiza la inserción puede observarse en la figura. 19 Manual del Alumno Es importante señalar varias cosas de este procedimiento: Tarda siempre un tiempo constante. No como en la implementación vectorial en que tardaba un tiempo proporcional a la longitud de la lista. No comprueba la precondición. Se podría hacer, pero entonces se perdería mucho tiempo en la comprobación. Es responsabilidad del programador utilizarlo siempre con posiciones de esta lista. Si no se hace así, puede dar lugar a graves errores. El procedimiento funciona bien en los casos extremos de la primera posición y la posición fin(l). En las listas sin cabecera estos casos habría que haberlos considerado aparte. tPosicion siguiente (tPosicion p, tLista l) { if (p->siguiente==NULL) { error("No hay siguiente de fin."); } return p->siguiente; } tPosicion primero (tLista l) 20 Manual del Alumno { return l; } tPosicion posicion (tElemento x, tLista l) { tPosicion p; int encontrado; p = primero(l); encontrado = 0; while ((p->siguiente != NULL) && (!encontrado)) { if (p->siguiente->elemento == x) encontrado=1; else p = p->siguiente; } return p; } Notas referentes a la función posicion: Es importante comprobar que la función verifica las postcondiciones en los dos casos posibles: cuando esté y cuando no esté el elemento buscado en la lista. La complejidad es igual al caso de la implementación mediante vectores. En término medio hay que recorrer la mitad de la lista. En la condición del bucle aparece la comparación (p->siguente != NULL). Esta es equivalente a (p !=fin(l)), pero entonces aumentaria mucho la complejidad debido a la poca eficiencia de la función fin(l). Se podría pensar en sustituir en cualquier programa, esta condición por la que hemos usado aquí. Pero esto lo hemos podido hacer porque ésta es una operación primitva y se puede hacer referencia a la implementación. En un programa que use las listas no se debe hacer. tElemento elemento (tPosicion p, tLista l) { 21 Manual del Alumno if (p->siguiente == NULL) { error("Error: posicion fin(l)."); } return p->siguiente->elemento; } void borrar (tPosicion p, tLista l) { tPosicion q; if (p->siguiente == NULL) error("Error: posicion fin(l)."); q = p->siguiente; p->siguiente = q->siguiente; free(q); } Respecto a esta implementación son válidos los mismos comentarios que para la función insercion. 4. COMPARACIÓN DE MÉTODOS. Resulta de interés saber si es mejor usar una implementación de listas basada celdas enlazadas o en matrices en una circunstancia dada. Frecuentemente la contestación depende de las operaciones uqe queramos llevar a cabo, o de cuales son llevadas a cabo con mayor asiduiadad. Otras veces, la decisión es en base a la longitud de la lista. Los puntos principales a considerar son los siguentes: 1. La implementación matricial nos obliga a especificar el tamaño máximo de una lista en tiempo de compilación. Si no podemos poner una cota a la longitud de la lista, posiblemente deberíamos coger una implementación basada en punteros. Lógicamente, este problema ha sido parcialmente solucionado con la parametrización del tamaño máximo de la lista, pero aún así hay que delimitar el tamaño máximo para cada una de las listas. 2. Ciertas operaciones requieren más tiempo en unas implementaciones que en otras. Por ejemplo insertar y borrar realizan un número constante de pasos para una lista enlazada, pero necesitan tiempo proporcional al número de elementos siguientes cuando usamos la representación matricial.Inversamente, ejecutar fin requiere tiempo constante con la implementación matricial, pero tiempo proporcional a la lista si usamos la implementación por punteros simplemente-enlazadas (aunque recordemos que el problema es solucionable añadiendo un puntero). Por otro lado, en las listas 22 Manual del Alumno doblemente-enlazadas se requiere tiempo constante para todas las operaciones (excepto la de posición que requiere un tiempo proporcional a la longitud de la lista). 3. La implementación matricial puede derrochar espacio, ya que usa la cantidad máxima de espacio independientemente del número de elementos presentes en la lista en un momento dado. La implementación por punteros usa tanto espacio como necesita para los elementos que hay en la lista, pero necesita espacio adicional para los punteros de cada celda.Por último, las listas doblemente-enlazadas aunque son las más eficientes requieren dos punteros para cada elemento. 4. En las listas enlazadas la posición de un elemento se determina con un puntero a la celda del elemento anterior por lo que hay que tener cuidado con la operación de borrado si se trabaja con varias posiciones tal y como vimos anteriormente. En el caso de la implementación matricial, si borramos un elemento, todas las posiciones posteriores a ese elemento apuntarán al siguiente al que apuntaban y si existe una posición apuntando al final de la lista, ésta queda invalidada. (El comportamiento tambien es distinto para la inserción). En el caso de las listas doblemente-enlazadas, su comportamiento es el mas cómodo siempre que la implementación realizada no provoque que la posición usada en el borrado quede invalidada. PILAS 1. INTRODUCCIÓN. Una Pila es una clase especial de lista en la cual todas las inserciones y borrados tienen lugar en un extremo denominado extremo, cabeza o tope. otro nombre para las pilas son listas FIFO (último en entrar, primero en salir) o listas pushdown (empujdas hacia abajo). El modelo intuitivo de una pila es un conjunto de objetos apilados de forma que al añadir un objeto se coloca encima del ultimo añadido y para quitar un objeto del montón hay que quitar antes los que están por encima de él.Un tipo de dato abstracto PILA incluye las siguientes operaciones. 2. OPERACIONES PRIMITIVAS DE LAS PILAS. Dentro del tipo abstracto de pila podemos proponer las siguientes primitivas: CREAR() DESTRUIR(P) TOPE(P) POP(P) PUSH(x,P) VACIA(P) ESPECIFICACIÓN SEMANTICA Y SINTACTICA pila crear () Efecto: Devuelve un valor del tipo pila preparado para ser usado y que contiene un valor de pila vacia.Esta operación es la misma que la de las listas generales. void destruir (pila *P) Argumentos: Una pila P. Efecto: Libera los recursos que mantienen la lista P de forma que para volver a usarla se debe asignar una nueva pila con la operación de creación. Esta operación es la misma que la de las listas generales. 23 Manual del Alumno telemento tope (pila P) Argumentos: Una pila P que debe ser no vacía. Efecto: Devuelve el elemento en la cabeza de la pila P. Si, como es lógico, identificamos la cabeza de una pila con la posición 1, entonces TOPE(P) puede escribirse en términos de operaciones de listas como ELEMENTO (PRIMERO(P),P). void pop (pila P) Argumentos: Una pila P que debe ser no vacía. Es modificada. Efecto: Borra el elemento del tope de la pila P, esto es, BORRA (PRIMERO(P),P). Algunas veces es conveniente implementar POP como una función que devuelve el elemento que acaba de borrar. void push (telemento x, pila P) Argumentos: x: Un elemento que deseamos poner en la pila. p: Una pila P valí donde deseamos poner el elemento x. Efecto:Inserta el elemento x en el tope de la pila P. El elemento tope antiguo se convierte en el siguiente al tope y asi sucesivamente. En términos de primitivas de listas esta operación es INSERTA (x,PRIMERO(P),P). int vacia (pila P) Argumentos: Una pila P. Efecto: Devuelve si P es una pila vacía. EQUIVALENCIA CON LAS LISTAS 3. IMPLEMENTACIÓN DE LAS PILAS. Todas las implementaciones de las listas que hemos descrito son validas para las pilas ya que una pila junto con sus operaciones es un caso especial de una lista con sus operaciones. Aún asi conviene destacar que las operaciones de las pilas son más específicas y que por lo tanto la implementación puede ser mejorada especialmente en el caso de la implementación matricial. IMPLEMENTACIÓN MATRICIAL DE LAS PILAS. La implementacion basada en matrices para las listas que dimos anteriormente, no es particularmente buena para las pilas, porque cada PUSH o POP requiere mover la lista entera hacia arriba o hacia abajo y por tanto, requiere un tiempo proporcional al número de elementos en la pila. Una forma mejor de usar matrices toma en cuenta el hecho de que inserciones y borrados ocurren solamente en el tope y por lo tanto dichas operaciones sólo se efectuarán en un extremo de la estructura. Obsérvese que la mejora puede ser introducida haciendo las inserciones y borrados al final de la lista dentro de la implementación matricial de las listas. Podemos situar el fondo de la pila en el primer elemento de la matriz y hacer crecer la pila 24 Manual del Alumno hacia el ultimo elemento de la matriz. Un cursor llamado tope indica la posición actual del primer elemento de la pila. Para esta implementacion basada en matrices de pilas definimos el tipo de dato abstracto Pila por typedef int tElemento /* Por ejemplo */ typedef struct { tElemento *elementos; int Lmax; int tope; } tipoPila; typedef tipoPila *pila; FUNCIÓN DE ABSTRACCIÓN. Dado el objeto del tipo rep p, *p = (elemento, Lmax, tope), el objeto abstracto que representa es: <p->elemento[p->tope], p->elemento[p->tope - 1],..., p->elemento[0]>. INVARIANTE DE LA REPRESENTACIÓN. Dado el objeto del tipo rep p, *p = (elemento, Lmax, tope) debe cumplir: a. p tiene valores obtenidos de llamadas (pila) malloc(sizeof(tipopila)); b. p->elemento tiene una dirección válida de tipo telemento*. c. p->Lmax > 0. d. -1 <= p->tope <= p->Lmax - 1. Las operaciones tipicas sobre las pilas están implementadas en las siguientes funciones y procedimientos. pila CREAR (int tamanoMax) { pila P; P = (pila) malloc(sizeof(tipoPila)); if (P == NULL) 25 Manual del Alumno error("No hay memoria suficiente"); P->Lmax = tamanoMax; P->tope = -1; P->elementos = (tElemento *) malloc(tamanoMax, sizeof(tElemento)); if (P->elementos == NULL) error("No hay memoria suficiente."); return P; } void DESTRUIR (pila *P) { free((*P)->elementos); free(*P); *P = NULL; } int VACIA (pila P) { return(P->tope == -1); } tElemento TOPE (pila P) { if (VACIA(P)) { error("No hay elementos en la pila."); return(P->elementos[P->tope]); } void POP (pila P) { if (VACIA(P)) { error("No hay elementos en la pila."); P->tope--; } void PUSH (tElemento x, pila P) { if (P->tope==P->Lmax-1) { error("Pila llena"); p->tope++; p->elementos[p->tope] = x; } Como puede observar el lector, esta implementación es justamente la realizada sobre las listas mediante vectores pero simplificada de una forma considerable. IMPLEMENTACIÓN DE LAS PILAS MEDIANTE CELDAS ENLAZADAS. La representación por celdas enlazadas de una pila es facil, porque PUSH y POP operan sólamente sobre la celda de cabecera. De hecho, las cabeceras pueden ser punteros mejor que celdas completas, ya que no hay noción de posición para las pilas y por tanto no necesitamos representar la posición 1 en una forma análoga a otras posiciones tal y como muestra figura. 26 Manual del Alumno Obviamente, el que las funciones sobre pilas sean más especificas que sobre listas implica que en general se simplificará la implementación (que responde a la estructura de la figura anterior). FUNCIÓN DE ABSTRACCIÓN. Dado el objeto del tipo rep p, el objeto abstracto que representa es: <(*p)->elemento, (*p)->siguiente->elemento, ... , (*p)->siguiente-> (n) ->siguiente->elemento>. con (*p)->siguiente-> (n+1) ->siguiente = NULL. INVARIANTE DE LA REPRESENTACIÓN. Dado un objeto del tipo rep p, debe cumplir: a. p tiene valores obtenidos de llamadas (tiponodo **) malloc(sizeof(tiponodo *)); b. Los campos siguiente de los nodos tienen direcciones válidas, obtenidas de llamadas a (tiponodo *) malloc(sizeof(tiponodo)). Sólo es NULL el último. typedef struct pnodo { tElemento elemento; struct pnodo *siguiente; } tipopnodo; typedef tipopnodo **pila; pila CREAR () { pila P; P = (tipopnodo **) malloc(sizeof(tipopnodo *)); if (P == NULL) { error("Memoria insuficiente."); *P = NULL; return P; } void DESTRUIR (pila P) { while (!VACIA(P)) POP(P); free(P); } tElemento TOPE (pila P) { if (VACIA(P)) error("No existe tope."); return((*P)->elemento); 27 Manual del Alumno } void POP (pila P) { tipopnodo *q; if (VACIA(P)) error("No existe tope."); q = (*P); (*P) = q->siguiente; free(q); } void PUSH (tElemento x,pila P) { tipopnodo *q; q = (tipopnodo *) malloc(sizeof(tipopnodo)); if (q == NULL) { error("No hay memoria."); q->elemento = x; q->siguiente = (*P); (*P) = q; } int VACIA (Pila P) { return (*P == NULL); } 4. EJEMPLO DE APLICACIÓN. Editor de líneas. #: carácter de borrado @: carácter de cancelación de línea IDEA: procesar una línea de texto usando una pila. Leer un carácter Si el carácter no es '#' ni '@' meterlo en la pila Si el carácter es '#' sacar de la pila Si el carácter es '@' vacia la pila El código podría ser: editar (void) { pila p, q; char c; p = crear(); while ((c = (char)getchar()) != EOF) { if (c == '#') quitar(p); else if (c == '@') { destruir(&p); p = crear(); } else 28 Manual del Alumno poner(c, p); }; q = crear(); while (!vacia(p)) { poner(tope(p), q); quitar(p); }; while (!vacia(q)) { printf("%c", tope(q)); quitar(q); }; destruir(&q); destruir(&p); } COLAS 1. INTRODUCCIÓN. Una Cola es otro tipo especial de lista en el cual los elementos se insertan por un extremo (el posterior) y se suprimen por el otro (el anterior o frente). Las colas se conocen tambien como listas FIFO (primero en entrar,primero en salir). Las operaciones para las colas son análogas a las de las pilas. Las diferencias sustanciales consisten en que las inserciones se hacen al final de la lista, y no al principio, y en que la terminología tradicional para colas y listas no es la misma. Las primitivas que vamos a considerar para las colas son las siguientes. 2. OPERACIONES PRIMITIVAS DE LAS COLAS. Dentro del tipo abstracto de cola podemos proponer las siguientes primitivas: CREAR() DESTRUIR(C) FRENTE(C) PONER_EN_COLA(x,C) QUITAR_DE_COLA(C) VACIA(C) ESPECIFICACIÓN SEMANTICA Y SINTACTICA. cola crear () Argumentos: Ninguno. Efecto: Devuelve una cola vacia preparada para ser usada. void destruir(cola C) Argumentos: Una cola C. Efecto: Destruye el objeto C liberando los recursos que mantiene que empleaba.Para volver a usarlo habrá que crearlo de nuevo. 29 Manual del Alumno tElemento frente (cola C) Argumentos: Recibe una cola C no vacía. Efecto: Devuelve el valor del primer elemento de la cloa C. Se puede escribir en función de las operaciones primitivas de las listas como: ELEMENTO(PRIMERO(C),C). void poner_en_cola (tElemento x, cola C) Argumentos: x: Elemento que queremos insertar en la cola. C: Cola en la que insertamos el elemento x. Efecto: Inserta el elemento x al final de la cola C. En función de las operaciones de las listas seria: INSERTA(x,FIN(C),C). void quitar_de_cola (cola C) Argumentos: Una cola C que debe ser no vacía. Efecto: Suprime el primer elemento de la cola C. En función de las operaciones de listas seria: BORRA(PRIMERO(C),C). int vacia (cola C) Argumentos: Una cola C. Efecto: Devuelve si la cola C es una cola vacía. EQUIVALENCIA CON LAS LISTAS 3. IMPLEMENTACIÓN DE LAS COLAS. IMPLEMENTACIÓN DE COLAS BASADA EN CELDAS ENLAZADAS. Igual que en el caso de las pilas, cualquier implementación de listas es válida para las colas. No obstante, para aumentar la eficiencia de PONER_EN_COLA es posible aprovechar el hecho de que las inserciones se efectúan sólo en el extremo posterior de forma que en lugar de recorrer la lista de principio a fin cada vez que desea hacer una inserción se puede mantener un apuntador al último elemento. Como en las listas de cualquier clase, tambien se mantiene un puntero al frente de la lista. En las colas ese puntero es útil para ejecutar mandatos del tipo FRENTE o QUITA_DE_COLA. Utilizaremos al igual que para las listas, una celda cabecera con el puntero frontal apuntándola con lo que nos permitirá un manejo más cómodo. Gráficamente, la estructura de la cola es tal y como muestra la figura: Una cola es pues un puntero a una estructura compuesta por dos punteros, uno al extremo anterior de la cola y otro al extremo posterior. La primera celda es una celda cabecera cuyo campo elemento se ignora. La definición de tipos es la siguiente: typedef struct Celda{ 30 Manual del Alumno tElemento elemento; struct Celda *siguiente; } celda; typedef struct { celda *ant,*post; } tcola; typedef tcola *cola; FUNCIÓN DE ABSTRACCIÓN. Dado el objeto del tipo rep c, *c = (ant, post), el objeto abstracto que representa es: <c->ant->siguiente->elemento, c->ant->siguiente->siguiente->elemento, ..., c>ant->siguiente-> (n) ->siguiente->elemento>, tal que c->siguiente->siguiente-> (n) ->siguiente = c->post. INVARIANTE DE LA REPRESENTACIÓN. Dado un objeto del tipo rep c, *c = (ant, post), debe cumplir: a. c tiene valores obtenidos de llamadas (tcola **) malloc(sizeof(tcola)); b. Los campos siguiente de los nodos, c->ant y c->post tienen direcciones válidas, obtenidas de llamadas a (celda) malloc(sizeof(celda)). Sólo es NULL el últimode los campos siguiente. Con estas definiciones, la implementación de las primitivas es la siguiente: cola CREAR () { cola C; C = (tcola *) malloc(sizeof(tcola)); if (C == NULL) error("Memoria insuficiente."); C->ant = C->post = (celda *)malloc(sizeof(celda)); if (C->ant == NULL) error("Memoria insuficiente."); C->ant->siguiente = NULL; return C; } void DESTRUIR (cola C) { while (!VACIA(C)) QUITAR_DE_COLA(C); free(C->ant); free(C); } int VACIA (cola C) { return(C->ant == C->post); } tElemento FRENTE (cola C) { 31 Manual del Alumno if (VACIA(C)) { error("Error: Cola Vacia."); } return(C->ant->siguiente->elemento); } void PONER_EN_COLA (tElemento x,cola C) { C->post->siguiente = (celda *) malloc(sizeof(celda)); if (C->post->siguiente == NULL) error("Memoria insuficiente."); C->post = C->post->siguiente; C->post->elemento = x; C->post->siguiente = NULL; } void QUITAR_DE_COLA (cola C) { celda *aux; if (VACIA(C)) error("Cola vacia."); aux = C->ant; C->ant = C->ant->siguiente; free(aux); } Este procedimiento QUITAR_DE_COLA suprime el primer elemento de C desconectando el encabezado antiguo de la cola,de forma que el primer elemento de la cola se convierte en la nueva cabecera. En la figura siguiente puede verse esquematicamente el resultado de hacer consecutivamente las siguientes operaciones: C=CREAR(C); PONER_EN_COLA(x,C);PONER_EN_COLA(y,C); QUITAR_DE_COLA(C); DESTRUIR(C); 32 Manual del Alumno Se puede observar que en el primer caso, la memoria que se obtiene del sistema es la de la estructura de tipo celda que hace de cabecera y la memoria para ubicar los dos punteros anterior y posterior. En los dos últimos casos, la línea punteada indica la memoria que es liberada. IMPLEMENTACIÓN DE LAS COLAS USANDO MATRICES CIRCULARES. La implementación matrical de las listas no es muy eficiente para las colas, puesto que si bien con el uso de un apuntador al último elemento es posible ejecutar PONER_EN_COLA en un tiempo constante, QUITAR_DE_COLA, que suprime le primer elemento, requiere que la cola completa ascienda una posición en la matriz con lo que tiene un orden de eficiencia lineal proporcional al tamaño de la cola. Para evitarlo se puede adoptar un criterio diferente. Imaginemos a la matriz como un circulo en el que la primera posición sigue a la última, en la forma en la que se ve en la figura siguiente. La cola se encuentra en alguna parte de ese círculo ocupando posiciones consecutivas. Para insertar un elemento en la cola se mueve el apuntador post una posición en el sentido de las agujas del reloj y se escribe el elemento en esa posición. Para suprimir un elemento simplemente se mueve ant una posición en el sentido de las agujas del reloj. De esta forma, la cola se mueve en ese sentido conforme se insertan y suprimen elementos. Obsérvese que utilizando este modelo los procedimientos PONER_EN_COLA y QUITAR_DE_COLA se pueden implementar de manera que su ejecución se realice en tiempo constante. 33 Manual del Alumno Existe un probelma que aparece en la representación de la figura anterior y en cualquier variación menor de esta estrategia (p.e. que post apunte a la última posición en el sentido de las agujas del reloj). El problema es que no hay forma de distinguir una cola vacia de una que llene el círculo completo salvo que mantengamos un bit que sea verdad si y solo si la cola está vacia. Si no deseamos mantener este bit debemos prevenir que la cola llene alguna vez la matriz. Para ver por qué puede pasar esto, supongamos que la cola de la figura anterior tuviera MAX_LONG elementos. Entonces, post apuntaría a la posición anterior en el sentido de las agujas del reloj de ant. ¿Qué pasaria si la cola estuviese vacia?. Para ver como se representa una cola vacia, consideramos primero una cola de un elemento. Entonces post y ant apuntarian a la misma posición. Si extraemos un elemento, ant se mueve una posición en el sentido de las agujas del reloj, formando una cola vacia. Por tanto una cola vacia tiene post a una posición de ant en el sentido de las agujas del reloj, que es exactamente la misma posición relativa que cuando la cola tenia MAX_LONG elementos. Por tanto vemos que aún cuando la matriz tenga MAX_LONG casillas, no podemos hacer crecer la cola más allá de MAX_LONG-1 casillas, a menos que introduzcamos un mecanismo para distinguir si la cola está vacía o llena. Ahora escribimos las primitivas de las colas usando esta representación para una cola: typedef struct { tElemento *elementos; int Lmax; int ant,post; } tipocola; typedef tipocola *cola; cola CREAR (int tamanoMax) { cola C; C = (cola) malloc(sizeof(tipocola)); if (C == NULL) error("No hay memoria."); C->Lmax = tamanoMax+1; C->ant = 0; C->post = C->Lmax-1; C->elementos = (tElemento *) calloc((tamanoMax+1), sizeof(tElemento)); 34 Manual del Alumno if (C->elementos == NULL) error("No hay memoria."); return C; } void DESTRUIR (cola *C) { free(*C->elementos); free(*C); *C == NULL; } int VACIA (cola C) { return((C->post+1)%(C->Lmax) == C->ant) } tElemento FRENTE (cola C) { if (VACIA(C)) error("Cola vacia."); return(C->elementos[C->ant]); } void PONER_EN_COLA (tElemento x,cola C) { if ((C->post+2) % (C->Lmax) == C->ant) error("Cola llena."); C->post = (C->post+1) % (C->Lmax); C->elementos[C->post] = x; } 35 Manual del Alumno void QUITAR_DE_COLA (cola C) { if (VACIA(C)) error("Cola vacia."); C->ant = (C->ant+1) % (C->Lmax); } En esta implementación podemos observar que se reserva una posicón más que la especificada en el parametro de la función CREAR. La razón de hacerlo es que no se podrán ocupar todos los elementos de la matriz ya que debemos distinguir la cola llena de la cola vacía. Estas dos situaciones por lo tanto vienen representadas tal y como se muestra en la figura siguiente. Se puede observar en el caso de la cola llena en la figura como la posición siguiente a post no es usada y por lo tanto es necesario crear una matriz de un tamaño N+1 para tener una capacidad para almacenar N elementos en cola. 36 Manual del Alumno LISTAS DOBLEMENTE ENLAZADAS 1. INTRODUCCIÓN. En algunas aplicaciones podemos desear recorrer la lista hacia adelante y hacia atrás, o dado un elemento, podemos desear conocer rápidamente los elementos anterior y siguiente. En tales situaciones podríamos desear darle a cada celda sobre una lista un puntero a las celdas siguiente y anterior en la lista tal y como se muestra en la figura. Otra ventaja de las listas doblemente enlazadas es que podemos usar un puntero a la celda que contiene el i-ésimo elemento de una lista para representar la posición i, mejor que usar el puntero a la celda anterior aunque lógicamente, también es posible la implementación similar a la expuesta en las listas simples haciendo uso de la cabecera. El único precio que pagamos por estas características es la presencia de un puntero adicional en cada celda y consecuentemente procedimientos algo más largos para algunas de las operaciones básicas de listas. Si usamos punteros (mejor que cursores) podemos declarar celdas que consisten en un elemento y dos punteros a través de: typedef struct celda{ tipoelemento elemento; struct celda *siguiente,*anterior; }tipocelda; typedef tipocelda *posicion; Un procedimiento para borrar un elemento en la posición p en una lista doblemente enlazada es: void borrar (posicion p) 37 Manual del Alumno { if (p->anterior != NULL) p->anterior->siguiente = p->siguiente; if (p->siguiente != NULL) p->siguiente->anterior = p->anterior; free(p); } El procedimiento anterior se expresa de forma gráfica en como muestra la figura: Donde los trazos contínuos denotan la situación inicial y los punteados la final. El ejemplo visto se ajusta a la supresión de un elemento o celda de una lista situada en medio de la misma. Para obviar los problemas derivados de los elementos extremos (primero y último) es práctica común hacer que la cabecera de la lista doblemente enlazada sea una celda que efectivamente complete el círculo, es decir, el anterior a la celda de cabecera sea la última celda de la lista y la siguiente la primera. De esta manera no necesitamos chequear para NULL en el anterior procedimiento borrar. Por consiguiente, podemos realizar una implementación de listas doblemente enlazadas con cabecera tal que tenga una estructura circular en el sentido de que dado un nodo y por medio de los punteros siguiente podemos volver hasta él como se puede observar en la figura (de forma analoga para anterior). Es importante notar que aunque la estructura física de la lista puede hacer pensar que mediante la operación siguiente podemos alcanzar de nuevo un nodo de la lista, la estructura lógica es la de una lista y por lo tanto habrá una posición primero y una posición fin de forma que al aplicar una operación anterior o siguiente respectivamente sobre estas posiciones el resultado será un error. Respecto a la forma en que trabajarán las funciones de la implementación que proponemos hay que hacer constar los siguientes puntos: La función de creación debe alojar memoria para la cabecera y hacer que los punteros siguiente y anterior apunten a ella, devolviendo un puntero a dicha cabecera. 38 Manual del Alumno La función primero(l) devolverá un puntero al nodo siguiente a la cabecera. La función fin(l) devolvera un puntero al nodo cabecera. Trabajar con varias posiciones simultáneamente tendrá un comportamiento idéntico al de las listas enlazadas excepto respecto al problema referente al borrado cuando se utilizan posiciones consecutivas. Es posible implementar la función de borrado de tal forma que borrar un elemento de una posición p invalida el valor de dicha posición p y no afecta a ninguna otra posición. Nosotros en nuestra implementación final optaremos por pasar un puntero a la posición para el borrado de forma que la posición usada quede apuntando al elemento siguente que se va a borrar al igual que ocurría en el caso de las listas simples. Otra posible solución puede ser que la función devuelva la posición del elemento siguiente a ser borrado. La inserción se debe hacer a la izquierda del nodo apuntado por la posición ofrecida a la función insertar. Esto implica que al contrario que en las listas simples, al insertar un nodo, el puntero utilizado sigue apuntando al mismo elemento al que apuntaba y no al nuevo elemento insertado. Si se desea, es posible modificar la función de forma que se pase un puntero a la posición de inserción para poder modificarla y hacer que apunte al nuevo elemento insertado. En cualquier caso, el comportamiento final de la función deberá quedar reflejado en el conjunto de especificaciones del TDA. 2. OPERACIONES PRIMITIVAS DE LISTAS DOBLES. Dentro del tipo abstracto de listas doblemente enlazadas podemos proponer las siguientes primitivas: tLista crear () void destruir (tLista l) tPosicion primero (tLista l) tPosicion fin (tLista l) void insertar (tElemento x, tPosicion p, tLista l) void borrar (tPosicion *p, tLista l) tElemento elemento(tPosicion p, tLista l) tPosicion siguiente (tPosicion p, tLista l) tPosicion anterior (tPosicion p, tLista l) tPosicion posicion (tElemento x, tLista l) ESPECIFICACIÓN SEMANTICA Y SINTACTICA. tLista crear () Argumentos: Ninguno. Efecto: (Constructor primitivo). Crea un objeto del tipo tLista. void destruir (tLista l) Argumentos: Una lista. Efecto: Destruye el objeto l liberando los recursos que empleaba. Para volver a usarlo habrá que crearlo de nuevo. tPosicion primero (tLista l) Argumentos: Una lista. Efecto: Devuelve la posición del primer elemento de la lista. tPosicion fin (tLista l) Argumentos: Una lista. Efecto: Devuelve la posición posterior al último elemento de la lista. 39 Manual del Alumno void insertar (tElemento x, tPosicion p, tLista l) Argumentos: l: Es modificada. p: Es una posición válida para la lista l. x: Dirección válida de un elemento del tipo T con que se instancia la lista, distinta de NULL. Efecto: Inserta elemento x en la posición p de la lista l desplazando todos los demás elementos en una posición. void borrar (tPosicion *p, tLista l) Argumentos: l: Es modificada. p: Es una posición válida para la lista l. Efecto: Elimina el elemento de la posición p de la lista l desplazando todos los demás elementos un una posición. tElemento elemento(tPosicion p, tLista l) Argumentos: l: Una lista. p: Es una posción válida de la lista l. Efecto: Devuelve el elemento que se encuentra en la posición p de la lista l. tPosicion siguiente (tPosicion p, tLista l) Argumentos: l: Una lista. p: Es una posición válida para la lista l, distinta de fin(l). Efecto: Devuelve la posición siguiente a p en l. tPosicion anterior (tPosicion p, tLista l) Argumentos: l: Una lista. p: Es una posición válida para la lista l, distinta de primero(l). Efecto: Devuelve la posición que precede a p en l. tPosicion posicion (tElemento x, tLista l) Argumentos: l: Una lista. x: Dirección válida de un elemento del tipo T con que se instancia la lista, distinta de NULL. Efecto: Si x se encuentra entre los elementos de la lista l, devuelve la posición de su primera ocurrencia. En otro caso, devuelve la posición fin(l). 40 Manual del Alumno 3. EFICIENCIA. Comparación de la eficiencia para las distintas implementaciones de las listas: 4. IMPLEMENTACIÓN DE LISTAS DOB. ENLAZADAS. Una vez aclaradas las posibles ambigüedades y dudas que se pueden plantear, la implementación de las listas doblemente enlazadas quedaría como sigue: typedef struct celda { tElemento elemento; struct celda *siguiente,*anterior; } tipocelda; typedef tipocelda *tPosicion; typedef tipocelda *tLista; static void error(char *cad) { fprintf(stderr, "ERROR: %s\n", cad); exit(1); } tLista Crear() { tLista l; l = (tLista)malloc(sizeof(tipocelda)); if (l == NULL) Error("Memoria insuficiente."); l->siguiente = l->anterior = l; return l; } 41 Manual del Alumno void Destruir (tLista l) { tPosicion p; for (p=l, l->anterior->siguiente=NULL; l!=NULL; p=l) { l = l->siguiente; free(p); } } tPosicion Primero (tLista l) { return l->siguiente; } tPosicion Fin (tLista l) { return l; } void Insertar (tElemento x, tPosicion p, tLista l) { tPosicion nuevo; nuevo = (tPosicion)malloc(sizeof(tipocelda)); if (nuevo == NULL) Error("Memoria insuficiente."); nuevo->elemento = x; nuevo->siguiente = p; nuevo->anterior = p->anterior; p->anterior->siguiente = nuevo; p->anterior = nuevo; } void Borrar (tPosicion *p, tLista l) { tPosicion q; if (*p == l){ Error("Posicion fin(l)"); } q = (*p)->siguiente; (*p)->anterior->siguiente = q; q->anterior = (*p)->anterior; free(*p); (*p) = q; } tElemento elemento(tPosicion p, tLista l) { if (p == l){ Error("Posicion fin(l)"); } return p->elemento; } 42 Manual del Alumno tPosicion siguiente (tPosicion p, tLista l) { if (p == l){ Error("Posicion fin(l)"); } return p->siguiente; } tPosicion anterior( tPosicion p, tLista l) { if (p == l->siguiente){ Error("Posicion primero(l)"); } return p->anterior; } tPosicion posicion (tElemento x, tLista l) { tPosicion p; int encontrado; p = primero(l); encontrado = 0; while ((p != fin(l)) && (!encontrado)) if (p->elemento == x) encontrado = 1; else p = p->siguiente; return p; } MULTILISTAS 1. TDA FRENTE A ESTRUCTURA DE DATOS. Tipo de Dato Abstracto (TDA): Modelo formal de un ente junto con un conjunto de operaciones definidas sobre el modelo que nos permite procesarlo. Estructuras de Datos: Organización lógica de la información con que representamos los Datos. 43 Manual del Alumno 2. ENTIDADES Y RELACIONES. Tipos de Relación: Uno a uno (Ejemplo: Nombre <--> D.N.I.). Uno a muchos (Ejemplo: Equipo <-->> Jugador). Muchos a muchos (Ejemplo: Alumno <<-->> Asignatura). Representación de relaciones muchos a muchos. Matriz. Listas. Multilistas. 3. ESTRUCTURA DE DATOS MULTILISTA Conjunto de nodos en que algunos tienen más de un puntero y pueden estar en más de una lista simultáneamente. Para cada tipo de nodo es importante distinguir los distintos campos puntero para realizar los recorridos adecuados y evitar confusiones. Estructura básica para Sistemas de Bases de Datos en Red. 4. IMPLEMENTACIÓN DE MULTILISTAS Dados dos tipos de entidades, TipoA y TipoB, se necesitan: Dos nuevos tipos correspondientes a los nodos para cada clase de entidad, que junto con la información propia de la entidad incluye los punteros necesarios para mantener la estructura. typedef struct NodoTipoA { TipoA Info; NodoRelacion *PrimerB; } NodoTipoA; typedef struct NodoTipoB{ TipoB Info; NodoRelacion *PrimerA; } NodoTipoB; Una estructura para agrupar los objetos de cada tipo de entidad (Array, Lista,Árbol, Tabla Hash, ...). Un TDA Nodo Relacion que incluye un puntero por cada lista así como información propia de la relación. typedef struct NodoRelacion { NodoTipoA *SiguienteA; NodoTipoB *SiguienteB; <tipo1> campo1; ........ <tipon> campo_n; 44 Manual del Alumno } NodoRelacion; Un nodo Multilista que engloba los distintos tipos de nodos (entidad A, entidad B y relación). El tipo de dato para construir esto es el registro variante: typedef enum {NODO_A, NODO_B, NODO_ML} TipoNodo; typedef struct NodoMultilista { TipoNodo tipo; union { NodoTipoA a; NodoTipoB b; NodoRelacion nr; } cont; } NodoMultilista; 5. CONSULTA SOBRE UNA ESTRUCTURA MULTILISTA. Localizar todas las entidades de TipoA relacionadas con la entidad B de TipoB. void BuscarEntidadesA (EntidadB B){ NodoMultilista a, b, r; b = Direccion(B); /* Depende de como se agrupen los NodoTipoB. */ r = b.cont.b.PrimerA; /* Mediante r se recorre el conjunto de entidades TipoA para B. */ while (r.tipo == NODO_ML) { a = r; do a = a.cont.nr.SiguienteB; while (a.tipo == NODO_ML) Escribe(a.cont.a.Info); r = r.cont.nr.SiguienteA; }; }; 45 Manual del Alumno 6. TDA RELACIÓN. TDA Relacion: crear, añadeAlum, añadeAsig, borrarAlum, borrarAsig, añadir, borrar, existe, escribeAsig, escribeAlum, destruir. Definición: Dados los TDAs Alumno y Asignatura, los objetos Relacion representan las relaciones (matrícula, calificación) entre un conjunto de Alumnos y un conjunto de Asignaturas. Son objetos mutables. Residen en memoria dinámica. OPERACIONES: Relacion crear(int NumAlum, int NumAsig) Argumentos: NumAlum: Número máximo de alumnos. NumAsig: Número máximo de asignaturas. Efecto: (Constructor Primitivo): Crea un objeto del tipo Relacion, que representa las matrículas de hasta un máximo de NumAlum alumnos en un máximo de NumAsig asignaturas.Devuelve un objeto vacío, sin alumnos, asignaturas, ni vínculos entre éstos. void añadeAlum(Alumno al, Relacion r) Argumentos: al: Un alumno. r: El número de alumnos debe ser menor del máximo. Es modificada. Efecto: Añade el alumno al a la relación r, sin establecer ningún vínculo con las asignaturas. void añadeAsig(Asignatura as, Relacion r) Argumentos: 46 Manual del Alumno as: Una asignatura. r: El número de asignaturas debe ser menor del máximo.Es modificada. Efecto: Añade la asignatura as a la relación r, sin establecer ningún vínculo con los alumnos. void borrarAlum(Alumno al, Relacion r) Argumentos: al: Alumno que debe existir en r. r: Es modificada. Efecto: Elimina todos los vínculos del alumno al con asignaturas en r. Después elimina el alumno de r. void borrarAsig(Asignatura as, Relacion r) Argumentos: as: Asignatura que debe existir en r. r: Es modificada. Efecto: Elimina todos los vínculos de la asignatura as con alumnos en r. Después elimina la asignatura de r. void añadir(Alumno al, Asignatura as, Relacion r) Argumentos: al: Alumno que debe existir en r. as: Asignatura que debe existir en r. r: Es modificada. Efecto: Establece un vínculo entre el alumno al y la asignatura as. logico existe(Alumno al, Asignatura as, Relacion r) Argumentos: al: Alumno que debe existir en r. as: Asignatura que debe existir en r. r: Una relación. Efecto: Si en r existe un vínculo entre el alumno al y la asignatura as, devuelve VERDAD.En otro caso, devuelve FALSO. void borrar(Alumno al, Asignatura as, Relacion r) Argumentos: al: Alumno que debe existir en r. as: Asignatura que debe existir en r. r: Es modificada. Efecto: Si existe un vínculo entre el alumno al y la asignatura as, es eliminado. void escribeAsignaturas(Alumno al, relacion r) Argumentos: 47 Manual del Alumno al: Alumno que debe existir en r. r: Una relación. Efecto:Escribe en la salida estándar una lista de las asignaturas relacionadas con el alumno al en r. void destruir(Relacion r) Argumentos: r: Es modificada. Efecto Destruye el objeto r liberando los recursos que empleaba.Para volver a usarlo habrá que crearlo de nuevo. typedef void *pnodo; typedef struct NodoTipoA { TipoA info; pnodo *PrimerB; } NodoTipoA; typedef struct NodoTipoB { TipoB info; pnodo *PrimerA; } NodoTipoB; typedef struct NodoRel { pnodo SiguienteB; pnodo SiguienteA; } NodoRel; typedef enum {NODO_A, NODO_B, NODO_REL} TipoNodo; typedef struct nodo { TipoNodo tipo; union { NodoRel nr; NodoTipoA na; NodoTipoB nb; } cont; } nodo; TABLAS HASH 1. INTRODUCCIÓN. Una aproximación a la búsqueda radicalmente diferente a las anteriores consiste en proceder, no por comparaciones entre valores clave, sino encontrando alguna función h(k) que nos dé directamente la localización de la clave k en la tabla. 48 Manual del Alumno La primera pregunta que podemos hacernos es si es fácil encontrar tales funciones h. La respuesta es, en principio, bastante pesimista, puesto que si tomamos como situacion ideal el que tal función dé siempre localizaciones distintas a claves distintas y pensamos p.ej. en una tabla de tamaño 40 en donde queremos direccionar 30 claves, nos encontramos con que hay 4030 = 1.15 * 1048 posibles funciones del conjunto de claves en la tabla, y sólo 40*39*11 = 40!/10! = 2.25 * 1041 de ellas no generan localizaciones duplicadas. En otras palabras, sólo 2 de cada 10 millones de tales funciones serian 'perfectas' para nuestros propósitos. Esa tarea es factible sólo en el caso de que los valores que vayan a pertenecer a la tabla hash sean conocidas a priori. Existen algoritmos para construir funciones hash perfectas que son utilizadas para organizar las palabras clave en un compilador de forma que la búsqueda de cualquiera de esas palabras clave se realice en tiempo constante. Las funciones que evitan valores duplicados son sorprendentemente dificiles de encontrar, incluso para tablas pequeñas. Por ejemplo, la famosa "paradoja del cumpleaños" asegura que si en una reunión están presentes 23 ó más presonas, hay bastante probabilidad de que dos de ellas hayan nacido el mismo dia del mismo mes. En otras palabras, si seleccionamos una función aleatoria que aplique 23 claves a una tabla de tamaño 365 la probabilidad de que dos claves no caigan en la misma localización es de sólo 0.4927. En consecuencia, las aplicaciones h(k), a las que desde ahora llamaremos funciones hash, tienen la particularidad de que podemos esperar que h( ki ) = h( kj ) para bastantes pares distintos ( ki,kj ). El objetivo será pues encontrar una función hash que provoque el menor número posible de colisiones (ocurrencias de sinónimos), aunque esto es solo un aspecto del problema, el otro será el de diseñar métodos de resolución de colisiones cuando éstas se produzcan. 2. FUNCIONES HASH. El primer problema que hemos de abordar es el cálculo de la función hash que transforma claves en localizaciones de la tabla. Más concretamente, necesitamos una función que transforme claves(normalmente enteros o cadenas de caracteres) en enteros en un rango [0..M-1], donde M es el número de registros que podemos manejar con la memoria de que dispongamos.como factores a tener en cuenta para la elección de la función h(k) están que minimice las colisiones y que sea relativamente rápida y fácil de calcular, aunque la situación ideal sería encontrar una función h que generara valores aleatorios uniformemente sobre el intervalo [0..M-1]. Las dos aproximaciones que veremos están encaminadas hacia este objetivo y ambas están basadas en generadores de números aleatorios. Hasing Multiplicativo. Esta técnica trabaja multiplicando la clave k por sí misma o por una constante, usando después alguna porción de los bits del producto como una localización de la tabla hash. Cuando la elección es multiplicar k por sí misma y quedarse con alguno de los bits centrales, el método se denomina el cuadrado medio. Este metodo aún siendo simple y pudiendo cumplir el criterio de que los bits elegidos para marcar la localización son función de todos los bits originales de k, tiene como principales inconvenientes el que las claves con muchos ceros se reflejarán en valores hash también con muchos ceros, y el que el tamaño de la tabla está restringido a ser una potencia de 2. Otro método multiplicativo, que evita las restricciones anteriores consiste en calcular h(k) = Int[M * Frac(C*k)] donde M es el tamaño de la tabla y 0 <= C <= 1, siendo importante elegir C con cuidado para evitar efectos negativos como que una clave alfabética K sea sinónima a otras claves obtenidas permutando los caracteres de k. Knuth (ver bibliografía) prueba que un valor recomendable es: 49 Manual del Alumno Hasing por División. En este caso la función se calcula simplemente como h(k) = k mod M usando el 0 como el primer índice de la tabla hash de tamaño M. Aunque la fórmula es aplicable a tablas de cualquier tamaño es importante elegir el valor de M con cuidado. Por ejemplo si M fuera par, todas las claves pares (resp. impares) serían aplicadas a localizaciones pares (resp. impares), lo que constituiría un sesgo muy fuerte. Una regla simple para elegir M es tomarlo como un número primo. En cualquier caso existen reglas mas sofisticadas para la elección de M (ver Knuth), basadas todas en estudios téoricos de funcionamiento de los métodos congruenciales de generación de números aleatorios. 3. RESOLUCIÓN DE COLISIONES. El segundo aspecto importante a estudiar en el hasing es la resolución de colisiones entre sinónimos. Estudiaremos tres métodos basicos de resolución de colisiones, uno de ellos depende de la idea de mantener listas enlazadas de sinónimos, y los otros dos del cálculo de una secuencia de localizaciones en la tabla hash hasta que se encuentre que se encuentre una vacía. El análisis comparativo de los métodos se hará en base al estudio del número de localizaciones que han de examinarse hasta determinar donde situar cada nueva clave en la tabla. Para todos los ejemplos el tamaño de la tabla será M=13 y la función hash h1(k) que utilizaremos será: HASH = Clave Mod M y los valores de la clave k que consideraremos son los expuestos en la siguiente tabla: Suponiendo que k=0 no ocurre de forma natural, podemos marcar todas las localizaciones de la tabla, inicialmente vacías, dándoles el valor 0. Finalmente y puesto que las operaciones de búsqueda e inserción están muy relacionadas, se presentaran algoritmos para buscar un item insertándolo si es necesario (salvo que esta operación provoque un desbordamiento de la tabla) devolviendo la localización del item o un -1 (NULL) en caso de desbordamiento. Encadenamiento separado o Hasing Abierto. La manera más simple de resolver una colisión es construir, para cada localización de la tabla, una lista enlazada de registros cuyas claves caigan en esa dirección. Este método se conoce normalmente con el nombre de encadenamiento separado y obviamente la cantidad de tiempo requerido para una búsqueda dependerá de la longitud de las listas y de las posiciones relativas de las claves en ellas. Existen variantes dependiendo del mantenimiento que 50 Manual del Alumno hagamos de las listas de sinónimos (FIFO, LIFO, por valor Clave, etc), aunque en la mayoría de los casos, y dado que las listas individuales no han de tener un tamaño excesivo, se suele optar por la alternativa más simple, la LIFO. En cualquier caso, si las listas se mantienen en orden esto puede verse como una generalización del método de búsqueda secuencial en listas. La diferencia es que en lugar de mantener una sola lista con un solo nodo cabecera se mantienen M listas con M nodos cabecera de forma que se reduce el número de comparaciones de la búsqueda secuencial en un factor de M (en media) usando espacio extra para M punteros. Para nuestro ejemplo y con la alternativa LIFO, la tabla quedaría como se muestra en la siguiente figura: A veces y cuando el número de entradas a la tabla es relativamente moderado, no es conveniente dar a las entradas de la tabla hash el papel de cabeceras de listas, lo que nos conduciría a otro método de encadenamiento, conocido como encadenamiento interno. En este caso, la unión entre sinónimos está dentro de la propia tabla hash, mediante campos cursores (punteros) que son inicializados a -1 (NULL) y que irán apuntando hacia sus respectivos sinónimos. Direccionamiento abierto o Hasing Cerrado. Otra posibilidad consiste en utilizar un vector en el que se pone una clave en cada una de sus casillas. En este caso nos encontramos con el problema de que en el caso de que se produzca una colisión no se pueden tener ambos elementos formando parte de una lista paraesa casilla. Para solucionar ese problema se usa lo que se llama rehashing. El rehashing consiste en que una vez producida una colisión al insertar un elemento se utiliza una función adicional para determinar cual será la casilla que le corresponde dentro de la tabla, aesta función la llamaremos función de rehashing,rehi(k). A la hora de definir una función de rehashing existen múltiples posibilidades, la más simple consiste en utilizar una función que dependa del número de intentos realizados para encontrar una casilla libre en la que realizar la inserción, a este tipo de rehashing se le conoce como hashing lineal. De esta forma la función de rehashing quedaria de la siguiente forma: rehi(k) = (h(k)+(i-1)) mod M i=2,3,... 51 Manual del Alumno En nuestro ejemplo, después de insertar las 7 primeras claves nos aparece la tabla A, (ver la tabla siguiente). Cuando vamos a insertar la clave 147, esta queda situada en la casilla 6, (tabla B) una vez que no se han encontrado vacías las casillas 4 y 5. Se puede observar que antes de la inserción del 147 había agrupaciones de claves en las localizaciones 4,5 y 7,8, y después de la inserción, esos dos grupos se han unido formando una agrupación primaria mayor, esto conlleva que si se trata de insertar un elemento al que le corresponde algunas de las casillas que están al principio de esa agrupación el proceso de rehashing tendrá de recorrer todas esas casillas con lo que se degradará la eficiencia de la inserción. Para solucionar este problema habrá que buscar un método de rehashing que distribuya de la forma más aleatoria posible las casillas vacías. Despues de llevar a cabo la inserción de las claves consideradas en nuestro ejemplo, el estado de la tabla hash será el que se puede observar en la tabla (C) en la que adémas aparece el número de intentos que han sido necesarios para insertar cada una de las claves. Para intentar evitar el problema de las agrupaciones que acabamos de ver podríamos utilizar la siguiente función de rehashing: rehi(k) = (h(k)+(i-1)*C) mod M C>1 y primo relativo con M pero aunque esto evitaría la formación de agrupaciones primarias, no solventaría el problema de la formación de agrupaciones secundarias (agrupaciones separadas por una distancia C). El problema básico de rehashing lineal es que para dos claves distintas que tengan el mismo valor para la función hash se irán obteniendo exactamente la misma secuencia de valores al aplicar la función de rehashing, cunado lo interenante seria que la secuencia de valores obtenida por el proceso de rehashing fuera distinta. Así, habrá que buscar una función de rehashing que cumpla las siguientes condiciones: Sea fácilmente calculable (con un orden de eficiencia constante), que evite la formación de agrupaciones, que genere una secuencia de valores distinta para dos claves distintas aunque tenga el mismo valor de función hash, y por último que garantice que todas las casillas de la tabla son visitadas. 52 Manual del Alumno si no cumpliera esto último se podría dar el caso de que aún quedaran casillas libres pero no podemos insertar un determinado elemento porque los valores correspondientes a esas casillas no son obtenidos durante el rehashing. Una función de rehashing que cumple las condiciones anteriores es la función de rehashing doble. Esta función se define de la siguiente forma: hi(k) = (hi-1(k)+h0(k)) mod M i=2,3,... con h0(k) = 1+k mod (M-2) y h1(k) = h(k). Existe la posibilidad de hacer otras elecciones de la función h0(k) siempre que la función escogida no sea constante. Esta forma de rehashing doble es particularmente buena cuando M y M-2 son primos relativos. Hay que tener en cuenta que si M es primo entonces es seguro que M-2 es primo relativo suyo (exceptuando el caso trivial de que M=3). El resultado de aplicar este método a nuestro ejemplo puede verse en las tablas siguientes. En la primera se incluyen los valores de h para cada clave y en la segunda pueden verse las localizaciones finales de las claves en la tabla así como las pruebas requeridas para su inserción. 53 Manual del Alumno 4. BORRADOS Y REHASING. Cuando intentamos borrar un valor k de una tabla que ha sido generada por direccionamiento abierto, nos encontramos con un problema. Si k precede a cualquier otro valor k en una secuencia de pruebas, no podemos eliminarlo sin más, ya que si lo hiciéramos, las pruebas siguientes para k se encontrarian el "agujero" dejado por k por lo que podríamos concluir que k no está en la tabla, hecho que puede ser falso.Podemos comprobarlo en nuestro ejemplo en cualquiera de las tablas. La solución es que necesitamos mirar cada localización de la tabla hash como inmersa en uno de los tres posibles estados: vacia, ocupada o borrada, de forma que en lo que concierne a la busqueda, una celda borrada se trata exectamente igual que una ocupada.En caso de inserciones, podemos usar la primera localización vacia o borrada que se encuentre en la secuencia de pruebas para realizar la operación. Observemos que este problema no afecta a los borrado de las listas en el encadenamiento separado. Para la implementación de la idea anterior podria pensarse en la introducción en los algorítmos de un valor etiqueta para marcar las casillas borradas, pero esto sería solo una solución parcial ya que quedaría el problema de que si los borrados son frecuentes, las búsquedas sin éxitopodrían requerir O(M) pruebas para detectar que un valor no está presente. Cuando una tabla llega a un desbordamiento o cuando su eficiencia baja demasiado debido a los borrados, el único recurso es llevarla a otra tabla de un tamaño más apropiado, no necesariamente mayor, puesto que como las localizaciones borradas no tienen que reasignarse, la nueva tabla podría ser mayor, menor o incluso del mismo tamaño que la original. Este proceso se suele denominar rehashing y es muy simple de implementar si el arca de la nueva tabla es distinta al de la primitiva, pero puede complicarse bastante si deseamos hacer un rehashing en la propia tabla. 5. EVALUACIÓN DE LOS MÉTODOS DE RESOLUCIÓN. El aspecto más significativo de la búsqueda por hashing es que si eficiencia depende del denominado factor de almacenamiento Ó= n/M con n el número de items y M el tamaño de la tabla. Discuteremos el número medio de pruebas para cada uno de los métodos que hemos visto de resolución de colisiones, en términos de BE (búsqueda con éxito) y BF (búsqueda sin éxito). Las demostraciones de las fórmulas resultantes pueden encontrarse en Knuth (ver bibliografía). Encadenamiento separado. 54 Manual del Alumno Aunque puede resultar engañoso comparar este método con los otros dos, puesto que en este caso puede ocurrir que Ó>1, las fórmulas paroximadas son: Estas expresiones se aplican incluso cuando Ó>>1, por lo que para n>>M, la longitud media de cada lista será Ó, y deberia esperarse en media rastrear la mitad de la lista, antes de encontrar un determinado elemento. Hasing Lineal. Las fórmulas aproximadas son: Como puede verse, este método, aun siendo satisfactorio para Ó pequeños, es muy pobre cuando Ó -> 1, ya que el límite de los valores medios de BE y BF son respectivamente: En cualquier caso, el tamaño de la tabla en el hash lineal es mayor que en el encadenamiento separado, pero la cantidad de memoria total utilizada es menor al no usarse punteros. Hasing Doble. Las fórmulas son ahora: BE=-(1/1-Ó) * ln(1-Ó) BF=1/(1-Ó) con valores medios cuando Ó -> 1 de M y M/2, respectivamente. Para facilitar la comprensión de las fórmulas podemos construir una tabla en la que las evaluemos para distintos valores de Ó: La elección del mejor método hash para una aplicación particular puede no ser fácil. Los distintos métodos dan unas características de eficiencia similares. Generalmente, lo mejor es usar el encadenamiento separado para reducir los tiempos de búsqueda cuando el número de registros a procesar no se conoce de antemano y el hash doble para buscar claves cuyo número pueda, de alguna manera, predecirse de antemano. En comparación con otras técnicas de búsqueda, el hashing tiene ventajas y desventajas. En general, para valores grandes de n (y razonables valores de Ó) un buen esquema de hashing requiere normalmente menos pruebas (del orden 1.5 - 2) que cualquier otro método de búsqueda, incluyendo la búsqueda en árboles binarios. Por otra parte, en el caso peor, puede comportarse muy mal al requerir O(n) pruebas. También puede considerarse como una ventaja el hecho de que debemos tener alguna estimación a priori de número máximo de items que vamos a colocar en la tabla aunque si no disponemos de tal estimación siempre nos quedaría la opción de usar el metodo de encadenamiento separado en donde el desbordamiento de la tabla no constituye ningún problema. Otro problema relativo es que en una tabla hash no tenemos ninguna de las ventajas que tenemos cuando manejamos relaciones ordenadas, y así p.e. no podemos procesar los items en la tabla secuencialmente, ni concluir tras una búsqueda sin éxito nada sobre los items que 55 Manual del Alumno tienen un valor cercano al que buscamos, pero en cualquier caso el mayor problema que tener el hashing cerrado es el de los borrados dentro de la tabla. 6. IMPLEMENTACIÓN DE LAS TABLAS HASH. Implementación de Hasing Abierto. En este apartado vamos a realizar una implementación simple del hasing abierto que nos servirá como ejemplo ilustrativo de su funcionamiento. Para ello supondremos un tipo de dato char * para el cual diseñaremos una función hash simple consistente en la suma de los codigos ASCII que componen dicha cadena. Una posible implementación utilizando el tipo de dato abstracto lista sería la siguiente: #define NCASILLAS 100 /*Ejemplo de número de entradas en la tabla.*/ typedef tLista *TablaHash; Para la cual podemos diseñar las siguientes funciones de creación y destrución: TablaHash CrearTablaHash () { tLista *t; register i; t=(tLista *)malloc(NCASILLAS*sizeof(tLista)); if (t==NULL) error("Memoria insuficiente."); for (i=0;i<NCASILLAS;i++) t[i]=crear(); return t; } void DestruirTablaHash (TablaHash t) { register i; for (i=0;i<NCASILLAS;i++) destruir(t[i]); free(t); } Como fue mencionado anteriormente la función hash que será usada es: int Hash (char *cad) { int valor; unsigned char *c; for (c=cad,valor=O;*c;c++) 56 Manual del Alumno valor+=(int)(*c); return(valor%NCASILLAS); } Y funciones del tipo MiembroHash, InsertarHash, BorrarHash pueden ser programadas: int MiembroHash (char *cad,TablaHash t) { tPosicion p; int enc; int pos=Hash(cad); p=primero(t[pos]); enc=O; while (p!=fin(t[pos]) && !enc) { if (strcmp(cad,elemento(p,t[pos]))==O) enc=1; else p=siguiente(p,t[pos]); } return enc; } void InsertarHash (char *cad,TablaHash t) { int pos; if (MiembroHash(cad,t)) return; pos=Hash(cad); insertar(cad,primero(t[pos]),t[pos]); } void BorrarHash (char *cad,TablaHash t) { tPosicion p; int pos=Hash(cad); p=primero(t[pos]); while (p!=fin(t[pos]) && !strcmp(cad,elemento(p,t[pos]))) p=siguiente(p,t[pos])); if (p!=fin(t[pos])) borrar(p,t[pos]); } Como se puede observar esta implementación es bastante simple de forma que puede sufrir bastantes mejoras. Se propone como ejercicio el realizar esta labor dotando al tipo de dato de posibilidades como: Determinación del tamaño de la tabla en el momento de creación. Modificación de la función hash utilizada, mediante el uso de un puntero a función. Construcción de una función que pasa una tabla hash de un tamaño determinado a otra tabla con un tamaño superior o inferior. 57 Manual del Alumno Construcción de un iterador a través de todos los elementos de la tabla. etc... Implementación de Hasing Cerrado. En este apartado vamos a realizar una implementación simple del hashing cerrado. Para ello supondremos un tipo de datochar * al igual que en el apartado anterior, para el cual diseñaremos la misma función hash. Una posible implementación de la estructura a conseguir es la siguiente: #define NCASILLAS 100 #define VACIO NULL static char * BORRADO=''''; typedef char **TablaHash; Para la cual podemos diseñar las siguientes funciones de creación y destrución: TablaHash CrearTablaHash () { TablaHash t; register i; t=(TablaHash)malloc(NCASILLAS*sizeof(char *)); if (t==NULL) error("Memoria Insuficiente."); for (i=0;i<NCASILLAS;i++) t[i]=VACIO; return t; } void DestruirTablaHash (TablaHash t) { register i; for (i=O;i<NCASILLAS;i++) if (t[i]!=VACIO && t[i]!=BORRADO) free(t[i]); free t; } La función hash que será usada es igual a la que ya hemos usado para la implementación del Hasing Abierto. Y funciones del tipo MiembroHash, InsertarHash, BorrarHash pueden ser programadas tal como sigue, teniendo en cuenta que en esta implementación haremos uso de un rehashing lineal. int Hash (char *cad) { int valor; unsigned char *c; for (c=cad, valor=0; *c; c++) 58 Manual del Alumno valor += (int)*c; return (valor%NCASILLAS); } int Localizar (char *x,TablaHash t) /* Devuelve el sitio donde esta x o donde deberia de estar. */ /* No tiene en cuenta los borrados. */ { int ini,i,aux; ini=Hash(x); for (i=O;i<NCASILLAS;i++) { aux=(ini+i)%NCASILLAS; if (t[aux]==VACIO) return aux; if (!strcmp(t[aux],x)) return aux; } return ini; } int Localizar1 (char *x,TablaHash t) /* Devuelve el sitio donde podriamos poner x */ { int ini,i,aux; ini=Hash(x); for (i=O;i<NCASILLAS;i++) { aux=(ini+i)%NCASILLAS; if (t[aux]==VACIO || t[aux]==BORRADO) return aux; if (!strcmp(t[aux],x)) return aux; } return ini; } int MiembroHash (char *cad,TablaHash t) { int pos=Localizar(cad,t); if (t[pos]==VACIO) return 0; else return(!strcomp(t[pos],cad)); } void InsertarHash (char *cad,TablaHash t) { int pos; 59 Manual del Alumno if (!cad) error("Cadena inexistente."); if (!MiembroHash(cad,t)) { pos=Localizar1(cad,t); if (t[pos]==VACIO || t[pos]==BORRADO) { t[pos]=(char *)malloc((strlen(cad)+1)*sizeof(char)); strcpy(t[pos],cad); } else { error("Tabla Llena. \n"); } } } void BorrarHash (char *cad,TablaHash t) { int pos = Localizar(cad,t); if (t[pos]!=VACIO && t[pos]!=BORRADO) { if (!strcmp(t[pos],cad)) { free(t[pos]); t[pos]=BORRADO; } } } Obviamente, esta implementación al igual que la del hasing abierto es también mejorable de forma que se propone el ejercicio de diseñar e implementar una versión mejorada con posibilidades similares a las enumeradas en el apartado anterior. ARBOLES GENERALES 1. INTRODUCCIÓN. Hasta ahora las estructuras de datos que hemos estudiado eran de tipo lineal, o sea,existía una relación de anterior y siguiente entre los elementos que la componían(cada elemento tendrá uno anterior y otro posterior , salvo los casos de primero y último).Pues bien, aquí se va a estudiar una estructuración de los datos más compleja: los árboles. Este tipo de estructura es usual incluso fuera del campo de la informática.El lector seguramente conoce casos como los árboles gramaticales para analizar oraciones,los árboles genealógicos ,representación de jerarquías,etc...La estructuración en árbol de los elementos es fundamental dentro del campo de la informática aplicándose en una amplia variedad de problemas como veremos más adelante. En principio podemos considerar la estructura de árbol de manera intuitiva como una estructura jerárquica.Por tanto,para estructurar un conjunto de elementos ei en árbol, deberemos escoger uno de ellos e1 al que llamaremos raíz del árbol.Del resto de los elementos se selecciona un subconjunto e2,...,ek estableciendo una relación padre-hijo entre la raíz y cada uno de dichos 60 Manual del Alumno elementos de manera que e1 es llamado el padre de e2,de e3,...ek y cada uno de ellos es llamado un hijo de e1.Iterativamente podemos realizar la misma operación para cada uno de estos elementos asignando a cada uno de ellos un número de 0 o más hijos hasta que no tengamos más elementos que insertar.El único elemento que no tiene padre es e1,la raíz del árbol.Por otro lado hay un conjunto de elementos que no tienen hijos aunque sí padre que son llamados hojas.Como hemos visto la relación de paternidad es una relación uno a muchos. Para tratar esta estructura cambiaremos la notación: Las listas tienen posiciones.Los árboles tienen nodos. Las listas tienen un elemento en cada posición.Los árboles tienen una etiqueta en cada nodo (algunos autores distinguen entre árboles con y sin etiquetas.Un árbol sin etiquetas tiene sentido aunque en la inmensa mayoría de los problemas necesitaremos etiquetar los nodos. Es por ello por lo que a partir de ahora sólo haremos referencia a árboles etiquetados). Usando esta notación,un árbol tiene uno y sólo un nodo raíz y uno o más nodos hoja. Desde un punto de vista formal la estructura de datos árbol es un caso particular de grafo, más concretamente,en la teoría de grafos se denota de forma similar como árbol dirigido. A pesar de ello,la definición formal más usual de árbol en ciencias de la computación es la recursiva: El caso básico es un árbol con un único nodo.Lógicamente este nodo es a la vez raíz y hoja del árbol. Para construir un nuevo árbol a partir de un nodo nr y k árboles A1 ,A2,...,Ak de raíces n1,n2,...,nk con N1,N2,...,Nk elementos cada uno establecemos una relación padre-hijo entre nr y cada una de las raíces de los k árboles.El árbol resultante de N=1 + N1 + ... + Nk nodos tiene como raíz el nodo nr, los nodos n1,n2,...,nk son los hijos de nr y el conjunto de nodos hoja está formado por la unión de los k conjuntos hojas iniciales. Además a cada uno de los Ai se les denota subárboles de la raíz. Ejemplo: Consideremos el ejemplo de la siguiente figura. Podemos observar que cada uno de los identificadores representa un nodo y la relación padrehijo se señala con una línea.Los árboles normalmente se presentan en forma descendente y se interpretan de la siguiente forma: E es la raíz del árbol. S1,S2,S3 son los hijos de E. S1,D1 componen un subárbol de la raíz. D1,T1,T2,T3,D3,S3 son las hojas del árbol. 61 Manual del Alumno etc... Además de los términos introducidos consideraremos la siguiente terminología: 1. Grado de salida o simplemente grado.Se denomina grado de un nodo al número de hijos que tiene.Así el grado de un nodo hoja es cero.En la figura anterior el nodo con etiqueta E tiene grado 3. 2. Caminos.Si n1,n2,...,nk es una sucesión de nodos en un árbol tal que ni es el padre de ni+1 para 1<=i<=k-1 ,entonces esta sucesión se llama un camino del nodo ni al nodo nk.La longitud de un camino es el número de nodos menos uno, que haya en el mismo.Existe un camino de longitud cero de cada nodo a sí mismo.Ejemplos sobre la figura anterior: E,S2,D2,T3 es un camino de E a T3 ya que E es padre de S2,éste es padre de D2,etc. S1,E,S2 no es un camino de S1 a S2 ya que S1 no es padre de E. 3. Ancestros y descendientes.Si existe un camino,del nodo a al nodo b ,entonces a es un ancestro de b y b es un descendiente de a.En el ejemplo anterior los ancestros de D2 son D2,S2 y E y sus descendientes D2,T1,T2 y T3(cualquier nodo es a la vez ancestro y descendiente de sí mismo). Un ancestro o descendiente de un nodo,distinto de sí mismo,se llama un ancestro propio o descendiente propio respectivamente.Podemos definir en términos de ancestros y descendientes los conceptos de raíz,hoja y subárbol: En un árbol,la raíz es el único nodo que no tiene ancestros propios. Una hoja es un nodo sin descendientes propios. Un subárbol de un árbol es un nodo,junto con todos sus descendientes. Algunos autores prescinden de las definiciones de ancestro propio y descendiente propio asumiendo que un nodo no es ancestro ni descendiente de sí mismo. 4. Altura.La altura de un nodo en un árbol es la longitud del mayor de los caminos del nodo a cada hoja.La altura de un árbol es la altura de la raíz.Ejemplo: en la figura anterior la altura de S2 es 2 y la del árbol es 3. 5. Profundidad.La profundidad de un nodo es la longitud del único camino de la raíz a ese nodo.Ejemplo: en la figura anterior la profundidad de S2 es 1. 6. Niveles.Dado un árbol de altura h se definen los niveles 0...h de manera que el nivel i está compuesto por todos los nodos de profundidad i. 7. Orden de los nodos.Los hijos de un nodo usualmente están ordenados de izquierda a derecha.Si deseamos explícitamente ignorar el orden de los dos hijos, nos referiremos a un árbol como un árbol no-ordenado. La ordenación izquierda-derecha de hermanos puede ser extendida para comparar cualesquiera dos nodos que no están relacionados por la relación ancestro-descendiente.La regla a usar es que si n1 y n2 son hermanos y n1 está a la izquierda de n2, entonces todos los descendientes de n1 están a la izquierda de todos los descendientes de n2. 62 Manual del Alumno RECORRIDOS DE UN ÁRBOL. En una estructura lineal resulta trivial establecer un criterio de movimiento por la misma para acceder a los elementos, pero en un árbol esa tarea no resulta tan simple.No obstante, existen distintos métodos útiles en que podemos sistemáticamente recorrer todos los nodos de un árbol.Los tres recorridos más importantes se denominan preorden,inorden y postorden aunque hay otros recorridos como es el recorrido por niveles. Si consideramos el esquema general de un árbol tal como muestra la figura siguiente,los recorridos se definen como sigue: 8. El listado en preorden es: Si el árbol tiene un único elemento, dicho elemento es el listado en preorden. Si el árbol tiene más de un elemento,es decir,una estructura como muestra la figura 2,el listado en preorden es listar el nodo raíz seguido del listado en preorden de cada uno de los subárboles hijos de izquierda a derecha. 9. El listado en inorden es: Si el árbol tiene un único elemento,dicho elemento es el listado en inorden. Si el árbol tiene una estructura como muestra la figura 2,el listado en inorden es listar el subárbol A1 en inorden,y listar el nodo raíz seguido del listado en inorden de cada uno de los subárboles hijos de izquierda a derecha restantes. 10. El listado en postorden es: Si el árbol tiene un único elemento,dicho elemento es el listado en postorden. 63 Manual del Alumno Si el árbol tiene una estructura como muestra la figura 2,el listado en postorden es listar en postorden cada uno de los subárboles hijos de izquierda a derecha seguidos por el nodo raíz. 11. El listado por niveles es: desde i=0 hasta la altura h del árbol,listar de izquierda a derecha los elementos de profundidad i.Como podemos observar,un nodo n1 aparece antes que n2 en el listado por niveles si la profundidad de n1 es menor que la profundidad de n2 usando el orden de los nodos definido anteriormente para el caso en que tengan la misma profundidad. Como ejemplo de listados veamos el resultado que se obtendría sobre el árbol A de la figura 3. Los resultados de los listados de preorden,postorden e inorden son los siguientes: 12. Listado preorden. 13. 14. A=Ar=rAvAs=rvAuAwAs= rvuAwAs=rvuwAxAyAzAs= rvuwxAyAzAs=rvuwxyAzAs=rvuwxyzAs =rvuwxyzsApAq=rvuwxyzspAq=rvuwxyzspq. Listado postorden. A=Ar=AvAsr=AuAwvAsr= uAwvAsr=uAxAyAzwvAsr= uxAyAzwvAsr=uxyAzwvAsr=uxyzwvAsr= uxyzwvApAqsr=uxyzwvpAqsr=uxyzwvpqsr. Listado inorden. A=Ar=AvrAs=AuvAwrAs= uvAwrAs=uvAxwAyAzrAs=uvxw AyAzrAs=uvxwyAzrAs=uvxwyzrAs= uvxwyzrApsAq=uvxwyzrpsAq=uvxwyzrpsq. Por último,el listado por niveles de este árbol es el siguiente:r,v,s,u,w,p,q,x,y,z. Finalmente es interesante conocer que un árbol no puede,en general,recuperarse con uno solo de sus recorridos.Por ejemplo:Dada la lista en inorden:vwyxzrtupsq,los árboles de la figura 4 tienen ese mismo recorrido en inorden. 64 Manual del Alumno 2. UNA APLICACIÓN: ARBOLES DE EXPRESIÓN. Una importante aplicación de los árboles en la informática es la representación de árboles sintácticos,es decir,árboles que contienen las derivaciones de una gramática necesarias para obtener una determinada frase de un lenguaje. Podemos etiquetar los nodos de un árbol con operandos y operadores de manera que un árbol represente una expresión.Por ejemplo. en la figura 5 se representa un árbol con la expresión aritmética (x-y)*(z/t). Para que un árbol represente una expresión,hay que tener en cuenta que: Cualquier hoja está etiquetada con uno y sólo un operando. Cualquier nodo interior n está etiquetado por un operador. En los árboles de expresión,la sucesión del preorden de etiquetas nos da lo que se conoce como la forma prefijo de una expresión, en la que el operador precede a su operando izquierdo y su operando derecho.En el ejemplo de la figura 5,el preorden de etiquetas del árbol es *-xy/zt . Análogamente,la sucesión postorden de las etiquetas de un árbol expresión nos da lo que se conoce como la representación postfijo de una expresión.Así en el ejemplo,la expresión postfijo del árbol es xy-zt/*. Finalmente,el inorden de una expresión en un árbol de expresión da la expresión infijo en sí misma,pero sin paréntesis.En el ejemplo,la sucesión inorden del árbol anterior es x-y*z/t. 3. EL TIPO DE DATO ABSTRACTO "ARBOL". La estructura de árbol puede ser tratada como un tipo de dato abstracto.A continuación presentaremos varias operaciones sobre árboles y veremos como los algoritmos de árboles pueden diseñarse en términos de estas operaciones.Al igual que con otros TDA,existe una gran variedad de operaciones que pueden llevarse a cabo sobre árboles. 65 Manual del Alumno Como podremos observar,cuando se construye una instancia de este tipo,tiene al menos un elemento, es decir,hasta ahora no hemos hablado de la existencia de un árbol vacío .Realmente, según la definición que vimos,efectivamente el número mínimo de nodos de un árbol es 1.En las implementaciones usaremos un valor especial ARBOL_VACIO para el caso en que el árbol no contenga nodos,al igual que en listas existe el concepto de lista vacía. De igual forma es necesario expresar en algunos casos que un nodo no existe para lo cual también usaremos otro valor especial NODO_NULO.Un ejemplo de su uso puede ser cuando intentemos extraer el nodo hijo a la izquierda de un nodo hoja. A continuación mostramos el conjunto de primitivas que nosotros consideraremos: 1. CREAR_RAIZ(u).Construye un nuevo nodo r con etiqueta u y sin hijos.Se devuelve el 2. 3. 4. 5. árbol con raíz r,es decir,un árbol con un único nodo. DESTRUIR(T).Libera los recursos que mantienen el árbol T de forma que para volver a usarlo se debe de asignar un nuevo valor con la operación de creación. PADRE(n,T).Esta función devuelve el padre del nodo n en el árbol T .Si n es la raíz ,que no tiene padre,devuelve NODO_NULO(un valor que será usado para indicar que hemos intentado salirnos del árbol).Como precondición n no es NODO_NULO (por tanto T no es vacío). . HIJO_IZQDA(n,T).Devuelve el descendente más a la izquierda en el siguiente nivel del nodo n en el árbol T, y devuelve NODO_NULO si n no tiene hijo a la izquierda.Como precondición n no es NODO_NULO. HERMANO_DRCHA(n,T).Devuelve el descendiente a la derecha del nodo n en el árbol T ,definido para ser aquel nodo m con el mismo padre que n ,es decir, padre p,de tal manera que m cae inmediatamente a la derecha de n en la ordenación de los hijos de p (Por ejemplo,véase el árbol de la figura 6). Devuelve NODO_NULO si n no tiene hermano a la derecha.Como precondición n no es NODO_NULO. 6. ETIQUETA(n,T).Devuelve la etiqueta del nodo n en el árbol T (manejaremos árboles 7. 8. 9. etiquetados,sin embargo no es obligatorio definir etiquetas para cada árbol).Como precondición n no es NODO_NULO. REETIQUETA(e,n,T).Asigna una nueva etiqueta e al nodo n en el árbol T.Como precondición n no es NODO_NULO. RAIZ(T).Devuelve el nodo que está en la raíz del árbol T o NODO_NULO si T es el árbol vacío. INSERTAR_HIJO_IZQDA(n,Ti,T).Inserta el árbol Ti como hijo a la izquierda del nodo n que pertenece al árbol T.Como precondición n no es NODO_NULO y Ti no es el árbol vacío. 66 Manual del Alumno 10. INSERTAR_HERMANO_DRCHA(n,Td,T).Inserta el árbol Td como hermano a la 11. 12. derecha del nodo n que pertenece al árbol T.Como precondición n no es NODO_NULO y Td no es el árbol vacío. PODAR_HIJO_IZQDA(n,T).Devuelve el subárbol con raíz hijo a la izquierda de n del árbol T el cual se ve privado de estos nodos.Como precondición n no es NODO_NULO. PODAR_HERMANO_DRCHA(n,T).Devuelve el subárbol con raíz hermano a la derecha de n del árbol T el cual se ve privado de estos nodos.Como precondición n no es NODO_NULO. A continuación veremos cómo implementar el TDA árbol y posteriormente implementaremos los algoritmos de recorrido:PREORDEN,POSTORDEN,INORDEN. IMPLEMENTACIÓN DE ÁRBOLES. UNA IMPLEMENTACIÓN MATRICIAL Sea A un árbol en el cual los nodos se etiquetan 0,1,2,...,n-1,es decir,cada nodo contiene un campo de información que contendrá estos valores.La representación más simple de A que soporta la operación PADRE es una matriz lineal P en la cual el valor de P[i] es un valor o un cursor al padre del nodo i.La raíz de A puede distinguirse dándole un valor nulo o un valor a él mismo como padre.Por ejemplo.,podemos usar un esquema de cursores donde P[i]=j si el nodo j es el padre del nodo i,y P[i]=-1 (suponemos que NODO_NULO=-1) si el nodo i es la raíz.La definición del tipo sería: #define MAXNODOS 100 #define NODO_NULO -1 typedef int nodo; typedef int *ARBOL; /*Por ejemplo*/ /*Indica una casilla de la matriz*/ Esta representación usa la propiedad de los árboles de que cada nodo tiene un único padre.Con esta representación el padre de un nodo puede encontrarse en tiempo constante.Un camino hacia arriba en el árbol puede seguirse atravesando el árbol en tiempo proporcional al número de nodos en el camino.Podemos soportar también el operador ETIQUETA añadiendo otra matriz L ,tal que L[i] es la etiqueta del nodo i ,o haciendo que los elementos de la matriz A sean registros consistiendo en un entero(cursor)y una etiqueta.EJEMPLO:Véase el árbol de la figura 7: 67 Manual del Alumno La representación de padre por cursores no facilita las operaciones que requieren información de hijos.Dado un nodo n ,es costoso determinar los hijos de n o la altura de n.Además,la representación por cursores del padre no especifica el orden de los hijos de un nodo.Por tanto,operaciones como HIJO_IZQDA y HERMANO_DRCHA no están bien definidas.Podríamos imponer un orden artificial,por ejemplo,numerando los hijos de cada nodo después de numerar el padre,y numerar los hijos en orden creciente de izquierda a derecha. Nota:Téngase en cuenta que aunque esta implementación no parece muy adecuada, es posible ampliarla con la utilización de nuevos campos de cursores.Por ejemplo:Podemos añadir dos matrices adicionales para almacenar para cada nodo tanto el hijo a la izquierda como el hermano a la derecha. IMPLEMENTACIÓN DE ÁRBOLES POR LISTAS DE HIJOS Una forma útil e importante de representar árboles es formar para cada nodo una lista de sus hijos.Las listas pueden representarse por cualquier método,pero como el número de hijos que cada nodo puede tener puede ser variable,las representaciones por listas enlazadas son las más apropiadas.La figura 8 sugiere como puede representarse el árbol del ejemplo de la figura 7: 68 Manual del Alumno Hay una matriz de celdas de cabecera indexadas por nodos ,que suponemos numerados 0,1,2,...,n-1. Cada punto de cabecera apunta a una lista enlazada de elementos que son nodos.Los elementos sobre una lista encabezada por cabecera[i] son los hijos de i(por ejemplo, 9 y 4 son los hijos de 8).Si desarrollamos la estructura de datos que necesitamos en términos de un tipo de dato abstracto tLista (de nodos) y damos una implementación particular de listas,puede verse como las abstracciones encajan. #include /*Definidas apropiadamente*/ #define MAXNODOS 100 /*Por ejemplo*/ #define NODO_NULO -1 typedef int nodo; typedef struct { tLista cabecera[MAXNODOS]; tEtiqueta etiquetas[MAXNODOS]; nodo raiz; }ARBOL; Suponemos que la raíz de cada árbol está almacenada explícitamente en el campo raíz.El -1 en el campo raíz se usa para representar el árbol nulo o vacío.La siguiente función muestra el código para la operación HIJO_IZQDA: nodo HIJO_IZQDA(nodo n,ARBOL T) { tLista L; L=T.cabecera[n]; if(PRIMERO(L)==FIN(L)) return NODO_NULO; /*No tiene hijos*/ else return RECUPERA(PRIMERO(L),L); /*Recupera el primero(izqda)*/ 69 Manual del Alumno } Las demás operaciones son también fáciles de implementar utilizando la anterior estructura para el tipo de dato y usando las primitivas del TDA Lista. Nota:Las funciones PRIMERO,FIN y RECUPERA usadas en el ejemplo anterior pertenecen al TDA Lista anteriormente estudiado. IMPLEMENTACIÓN DE ÁRBOLES BASADA EN CELDAS ENLAZADAS Al igual que ocurre en los TDA estudiados (Listas,Pilas o Colas), un nodo puede ser declarado de forma que la estructura del árbol pueda ir en aumento mediante la obtención de memoria de forma dinámica,haciendo una petición de memoria adicional cada vez que se quiere crear un nuevo nodo. #define ARBOL_VACIO NULL #define NODO_NULO NULL typedef int tEtiqueta /*Algún tipo adecuado*/ typedef struct tipocelda{ struct tipocelda *padre,*hizqda,*herdrchaAr; tEtiqueta etiqueta; }*nodo; typedef nodo tArbol; Observemos que bajo esta implementación cada nodo de un árbol contiene 3 punteros: padre que apunta al padre,hizqda que apunta al hijo izquierdo y herdrcha que apunta al hermano a la derecha del nodo.Para esta implementación de árbol vamos a presentar las funciones primitivas de las que hablábamos al principio.Suponemos que para referenciar el nodo i la variable puntero apuntará a ese nodo.Suponemos también unas variables de tipo nodo y que la variable T de tipo árbol apunta a la raíz del árbol. nodo PadreAr(nodo n,tArbol T) { return n->padre; } nodo HizqdaAr(nodo n,tArbol T) { return n->hizqda; } nodo HerdrchaAr(nodo n,tArbol T) { return n->herdrchaAr; } tEtiqueta EtiquetaAr(nodo n,tArbol T) { return n->etiqueta; } void ReEtiquetaAr(tEtiqueta e,nodo n,tArbol T) { n->etiqueta=e; } nodo RaizAr(tArbol T) 70 Manual del Alumno { return T; } tArbol Crea0(tEtiqueta et) { tArbol raiz; raiz=(tArbol)malloc (sizeof(struct tipocelda)); if (!raiz){ error("Memoria Insuficiente."); } raiz->padre=NULL; raiz->hizqda=NULL; raiz->etiqueta=et; return raiz; } void Destruir(tArbol T) { if(T){ destruir(T->hizqda); destruir(T->herdrcha); free(T); } } void Insertar_hijo_izqda(nodo n,tArbol Ti,tArbol T) { Ti->herdrcha=n->hizqda; Ti->padre=n; n->hizqda=Ti; } void Insertar_hermano_drcha(nodo n,tArbol Td,tArbol T) { if(n==raizAr(T)){ error("Memoria Insuficiente."); } Td->herdrcha=n->herdrcha; Td->padre=n->padre; n->herdrcha=Td; } tArbol Podar_hijo_izqda(nodo n,tArbol T) { tArbol Taux; Taux=n->hizqda; if(Taux!=ARBOL_VACIO){ n->hizqda=Taux->herdrcha; Taux->padre=NODO_NULO; Taux->herdrcha=NODO_NULO; } 71 Manual del Alumno return Taux; } tArbol Podar_hermano_drcha(nodo n,tArbol T) { tArbol Taux; Taux=n->herdrcha; if(Taux!=ARBOL_VACIO){ n->herdrcha=Taux->herdrcha; Taux->padre=NODO_NULO; Taux->herdrcha=NODO_NULO; } return Taux; } Como vemos hemos implementado creaRaiz de manera que el árbol devuelto es un único nodo.Es posible construir en C un procedimiento con un número variable de parámetros: El primero de los parámetros una etiqueta para el nodo raíz. Los restantes parámetros de tipo tArbol que se insertarán como subárboles(hijos) del nodo raíz. Los podemos realizar mediante la implementación de un número de parámetros indeterminado y haciendo uso del tipo va_list que podemos encontrar en el fichero cabecera stdarg.h.El procedimiento podría ser el siguiente: tArbol CreaRaiz(tEtiqueta et,tArbol T1,...,tArbol Tn,NULL) { va_list ap; nodo n,aux,raiz; /*Reservamos memoria para el nodo raiz*/ raiz=(nodo)malloc(sizeof(struct tipocelda)); if(!raiz){ error("Memoria Insuficiente."); } /*Inicializamos el nodo raiz*/ raiz->padre=NULL; raiz->hizqda=NULL; raiz->herdrcha=NULL; raiz->etiqueta=et; /*Un bucle para insertar los subarboles*/ va_start(ap,et); /*Inicio de argumentos*/ for(;;){ n=(nodo)va_arg(ap,nodo); if(n==NULL)break; /*No quedan mas hijos*/ if(raiz->hizqda)aux->herdrcha=n; else raiz->hizqda=n; aux=n; aux->herdrcha=NULL; aux->padre=raiz; } va_end(ap); /*Final de argumentos*/ return(tArbol)raiz; } 72 Manual del Alumno La llamada a la función tendría como parámetros una etiqueta para el nodo raíz del árbol resultante y una lista de nodos que podría ser vacía en cuyo caso el árbol que resulta tiene un único nodo:su raíz con etiqueta et. Por último,después de dicha lista,es necesario un parámetro adicional(NULL) que indica el final de la lista tras cuya lectura el procedimiento dejaría de añadir más hijos al nodo raíz que se está construyendo. IMPLEMENTACIÓN DE LOS RECORRIDOS DE UN ÁRBOL Recordemos que los recorridos de un árbol pueden ser de una forma directa en Preorden, Inorden y Postorden.A continuación veremos la implementación de estos tres recorridos. Así mismo,veremos un procedimiento de lectura de un árbol en preorden. PREORDEN 1. Visitar la raíz. 2. Recorrer el subárbol más a la izquierda en preorden. 3. Recorrer el subárbol de la derecha en preorden. Vamos a escribir dos procedimientos uno recursivo y otro no recursivo que toman un árbol y listan las etiquetas de sus nodos en preorden.Supongamos que existen los tipos nodo y tArbol con etiquetas del tipo tEtiqueta definidos anteriormente en la implementación por punteros.El siguiente procedimiento muestra un procedimiento recursivo que , dado el nodo n,lista las etiquetas en preorden del subárbol con raíz en n. void PreordenArbol(nodo n,tArbol T) { Escribir(etiquetaAr(n,T)); for(n=hizqdaAr(n,T);n!=NODO_NULO;n=herdrchaAr(n,T)) PreordenArbol(n,T); } En esta función hemos supuesto que existe una rutina Escribir que tiene como parámetro de entrada un valor de tipo tEtiqueta que se encarga de imprimir en la salida estándar.Por ejemplo,si hemos realizado typedef int tEtiqueta la función podría ser la siguiente: void Escribir(tEtiqueta et) { fprintf(stdout,"%d",(int)et); } Por otro lado,en los programas C hemos usado el operador de desigualdad entre un dato de tipo nodo y la constante ARBOL_VACIO.Para hacerlo más independiente de la impementación sería conveniente programar una función que podríamos llamar Arbol_Vacio que se añadiría como una nueva primitiva que nos devuelve si el subárbol que cuelga del nodo es un árbol vacío. Para el procedimiento no recursivo,usaremos una pila para encontrar el camino alrededor del árbol.El tipo PILA es realmente pila de nodos,es decir,pila de posiciones de nodos. La idea básica subyacente al algoritmo es que cuando estamos en la posición p,la pila alojará el camino desde la raíz a p,con la raíz en el fondo de la pila y el nodo p a la cabeza.El programa tiene dos modos de operar.En el primer modo desciende por el camino más a la izquierda en el árbol,escribiendo y apilando los nodos a lo largo del camino,hasta que encuentra una hoja.A continuación el programa entra en el segundo modo de operación en el cual vuelve hacia atrás por el camino apilado en la pila,extrayendo los nodos de la pila hasta que se encuentra un nodo en el camino con un hermano a la derecha.Entonces el programa vuelve al primer modo de operación,comenzando el descenso desde el inexplorado hermano de la derecha.El programa comienza en modo uno en la raíz y termina cuando la pila está vacía. void PreordenArbol(tArbol T) { pila P; /*Pila de posiciones:tElemento de la pila es el tipo nodo*/ 73 Manual del Alumno nodo m; P=CREAR(); /*Funcion de creacion del TDA PILA*/ m=raizAr(T); do{ if(m!=NODO_NULO){ Escribir(etiquetaAr(n,T)); PUSH(m,P); m=hizqdaAr(m,T); } else if(!VACIA(P)){ m=herdrchaAr(TOPE(P),T); POP(P); } }while(!VACIA(P)); DESTRUIR(P); /*Funcion del TDA PILA*/ } INORDEN 1. Recorrer el subárbol más a la izquierda en inorden. 2. Visitar la raíz. 3. Recorrer el subárbol del siguiente hijo a la derecha en inorden. Vamos a escribir un procedimiento recursivo para listar las etiquetas de sus nodos en inorden. void InordenArbol(nodo n,tArbol T) { nodo c; c=hizqdaAr(n,T); if(c!=NODO_NULO){ InordenArbol(c,T); Escribir(etiquetaAr(n,T)); for(c=herdrchaAr(c,T);c!=NODO_NULO;c=herdrchaAr(c,T)) InordenArbol(c,T); } else Escribir(etiquetaAr(n,T)); } POSTORDEN 1. Recorrer el subárbol más a la izquierda en postorden. 2. Recorrer el subárbol de la derecha en postorden. 3. Visitar la raíz. El procedimiento recursivo para listar las etiquetas de sus nodos en postorden es el siguiente: void PostordenArbol(nodo n,tArbol T) { nodo c; for(c=hizqdaAr(n,T);c!=NODO_NULO;c=herdrchaAr(c,T)) 74 Manual del Alumno PostordenArbol(c,T); Escribir(etiquetaAr(n,T)); } LECTURA A continuación veremos un procedimiento que nos realizará la lectura de los nodos de un árbol introduciéndolos en preorden.La función implementada se llama Lectura aunque se listan dos funciones(la rutina Lectura2 es una función auxiliar que es usada por la primera). void Lectura2(nodo n,tArbol T) { tEtiqueta etHijo,etHermano; tArbol Hijo,Hermano; fprintf(stdout,"Introduce hijo_izqda de: "); Escribir(etiquetaAr(n,T)); Leer(&etHijo); if(comparar(etHijo,FINAL)){ Hijo=creaRaiz(etHijo); insertar_hijo_izqda(n,Hijo,T); Lectura2(hizqdaAr(n,T),T); } fprintf(stdout,"Introduce her_drcha de: "); Escribir(etiquetaAr(n,T)); Leer(&etHermano); if(comparar(etHermano,FINAL)){ Hermano=creaRaiz(etHermano); insertar_hermano_drcha(n,Hermano,T); Lectura2(herdrchaAr(n,T),T); } } tArbol Lectura() { tArbol T; tEtiqueta et; fprintf(stdout,"En caso de que no exista el hijo_izqdo o el" "hermano_derecho introducir el valor: "); Escribir(FINAL); /*FINAL actua de centinela*/ fprintf(stdout,"\nIntroduce la raiz del arbol: "); Leer(&et); T=creaRaiz(et); Lectura2(raizAr(T),T); } Es interesante observar 5 puntos en esta rutina: Hemos supuesto que existe una función Leer que tiene como parámetro de entrada un puntero a una zona de memoria que almacena un valor de tipo tEtiqueta,y que sirve para leer de la entrada estándar un dato de ese tipo y almacenarlo en dicha zona de memoria. 75 Manual del Alumno Existe una variable FINAL que contiene un valor para la etiqueta que "no es legal" para indicar la inexistencia de un hijo a la izquierda y/o de un hermano a la derecha. Suponemos que existe una función comparar que tiene como parámetros de entrada dos variables de tipo tEtiqueta y que devuelve un valor entero distinto de 0 en caso de que las variables sean distintas según el criterio implementado en la función. Las sentencias insertar_hijo_izqda(...);Lectura2(...);no son intercambiables,es decir,si hubieramos programado esas sentencias en otro orden (Lectura2(...);insertar_hijo_izqda(...);) la función de lectura no funcionaría correctamente.La comprobación de que esta afirmación es correcta se deja como ejercicio al lector. En la segunda sentencia if ocurre una situación similar al punto anterior. Se puede completar la rutina de lectura para que prescinda de la lectura de un posible hermano a la derecha de la raíz simplemente preguntándonos si n es la raíz del árbol T. ARBOLES BINARIOS 1. INTRODUCCIÓN. Un árbol binario puede definirse como un árbol que en cada nodo puede tener como mucho grado 2,es decir,a lo más 2 hijos.Los hijos suelen denominarse hijo a la izquierda e hijo a la derecha,estableciéndose de esta forma un orden en el posicionamiento de los mismos. Todas las definiciones básicas que se dieron para árboles generales permanecen inalteradas sin más que hacer las particularizaciones correspondientes.En los árboles binarios hay que tener en cuenta el orden izqda-drcha de los hijos.Por ejemplo:los árboles binarios a) y b) de la figura 1(adoptamos el convenio de que los hijos a la izquierda son extraídos extendiéndonos hacia la izquierda y los hijos a la derecha a la derecha) son diferentes,puesto que difieren en el nodo 5.El árbol c por convenio se supone igual al b) y no al a). 76 Manual del Alumno 2. EL TIPO DE DATO ABSTRACTO ARBOL BINARIO. Para construir el TDA Arbol Binario bastaría con utilizar las primitivas de los árboles generales pero dado la gran importancia y peculiaridades que tienen este tipo de árboles, construiremos una serie de operaciones específicas.Consideraremos las siguientes: 1. CREAR(e,Ti,Td).Devuelve un árbol cuya raíz contiene la etiqueta e asignando como hijo a la izquierda Ti y como hijo a la derecha Td. 2. DESTRUIR(T).Libera los recursos que mantienen el árbol T de forma que para volver a usarlo se debe asignar un nuevo valor con la operación de creación. 3. PADRE(n,T).Esta función devuelve el padre del nodo n en el árbol T.En caso de no existir,devuelve NODO_NULO.Como precondición n no es NODO_NULO. 4. HIJO_IZQDA(n,T).Devuelve el hijo a la izquierda del nodo n en el árbol T,y devuelve 5. 6. 7. 8. 9. 10. NODO_NULO si n no tiene hijo a la izquierda.Como precondición, n no es NODO_NULO. HIJO_DRCHA(n,T).Devuelve el hijo a la derecha del nodo n en el árbol T,y devuelve NODO_NULO si n no tiene hijo a la derecha.Como precondición, n no es NODO_NULO. ETIQUETA(n,T).Devuelve la etiqueta del nodo n en el árbol T. Como precondición, n no es NODO_NULO. REETIQUETA(e,n,T).Asigna una nueva etiqueta e al nodo n en el árbol T.Como precondición n no es NODO_NULO. RAIZ(T).Devuelve el nodo que está en la raíz del árbol T o NODO_NULO si T es el árbol vacío. INSERTAR_HIJO_IZQDA(n,Ti,T).Inserta el árbol Ti como hijo a la izquierda del nodo n que pertenece al árbol T.En el caso de que existiese ya el hijo a la izquierda,la primitiva se encarga de que sea destruído junto con sus descendientes. Como precondiciones,Ti no es ARBOL_VACIO y n no es NODO_NULO. INSERTAR_HIJO_DRCHA(n,Td,T).Inserta el árbol Td como hijo a la derecha del nodo n que pertenece al árbol T.En el caso de que existiese ya el hijo a la derecha,la primitiva se encarga de que sea destruído junto con sus descendientes.Como precondiciones,Td no es ARBOL_VACIO y n no es NODO_NULO. 77 Manual del Alumno 11. PODAR_HIJO_IZQDA(n,T).Devuelve el subárbol con raíz hijo a la izquierda de n del 12. árbol T el cual se ve privado de estos nodos.Como precondición, n no es NODO_NULO. PODAR_HIJO_DRCHA(n,T).Devuelve el subárbol con raíz hijo a la derecha de n del árbol T el cual se ve privado de estos nodos.Como precondición, n no es NODO_NULO. 3. IMPLEMENTACIÓN DEL TDA ARBOL BINARIO Y DE LOS RECORRIDOS. Vamos a realizar una implementación mediante punteros,para la cual hay que realizar la siguiente declaración de tipos: #define BINARIO_VACIO NULL #define NODO_NULO NULL typedef int tEtiqueta /*Algun tipo adecuado*/ typedef struct tipoceldaB{ struct tipoceldaB *padre,*hizqda,*hdrcha; tEtiqueta etiqueta; }*nodoB; typedef nodoB tArbolB; Una posible implementación para las primitivas de árboles binarios es la siguiente: tArbolBin Crear0(tEtiqueta et) { tArbolBin raiz; raiz = (tArbolBin)malloc(sizeof(struct tipoceldaBin)); if (raiz==NULL) error(\"Memoria Insuficiente.\"); raiz->padre = NODO_NULO; raiz->hizda = NODO_NULO; raiz->hdcha = NODO_NULO; raiz->etiqueta = et; return(raiz); } tArbolBin Crear2(tEtiqueta et,tArbolBin ti,tArbolBin td) { tArbolBin raiz; raiz=(tarbolBin)malloc(sizeof(struct tipoceldaBin)); if(!raiz){ error("Memoria Insuficiente."); } raiz->padre=NULL; raiz->hizqda=ti; raiz->hdrcha=td; raiz->etiqueta=et; if(ti!=NULL) td->padre=raiz; 78 Manual del Alumno return raiz; } void Destruir(tArbolBin A) { if(A){ Destruir(A->hizqda); Destruir(A->hdrcha); free(A); } } nodoBin Padre(nodoBin n,tArbolBin A) { return(n->padre); } nodoBin Hderecha(nodoBin n,tArbolBin A) { return(n->hdrcha); } nodoBin Hizquierda(nodoBin n,tArbolBin A) { return(n->hizqda); } tEtiqueta Etiqueta(nodoBin n,tArbolBin A) { return(n->etiqueta); } void ReEtiqueta(tEtiqueta e,nodoBin n,tArbolBin A) { n->etiqueta=e; } nodoBin Raiz(tArbolBin A) { return A; } void InsertarHijoIzda(nodoBin n,tArbolBin ah,tArbolBin A) { Destruir(n->hizqda); n->hizqda=ah; ah->padre=n; } 79 Manual del Alumno void InsertarHijoDrchaB(nodoBin n,tArbolBin ah,tArbolBin A) { Destruir(n->hdrcha); n->hdrcha=ah; ah->padre=n; } tArbolBin PodarHijoIzqda(nodoBin n,tArbolBin A) { tArbolBin Aaux; Aaux=n->hizqda; n->hizqda=BINARIO_VACIO; if(Aaux) Aaux->padre=BINARIO_VACIO; return Aaux; } tArbolBin PodarHijoDrcha(nodoBin n,tArbolBin A) { tArbolBin Aaux; Aaux=n->hdrcha; n->hdrcha=BINARIO_VACIO; if(Aaux) Aaux->padre=BINARIO_VACIO; return Aaux; } Con las cuales podemos hacer la siguiente implementación de los recorridos en preorden, postorden e inorden: void PreordenArbol(nodoBin n,tArbolBin A) { if(n!=NODO_NULO){ Escribir(Etiqueta(n,A)); PreordenArbol(HizqdaB(n,A),A); PreordenArbol(HdrchaB(n,A),A); } } void PostordenArbol(nodoBin n,tArbolBin A) { if(n!=NODO_NULO){ PostordenArbol(Hizquierda(n,A),A); PostordenArbol(Hderecha(n,A),A); Escribir(etiquetaB(n,A)); } } 80 Manual del Alumno void InordenArbol(nodoBin n,tArbolBin A) { if(n!=NODO_NULO){ InordenArbol(Hizquierda(n,A),A); Escribir(Etiqueta(n,A)); InordenArbol(HderechaB(n,A),A); } } ARBOLES BINARIOS DE BUSQUEDA 1. INTRODUCCIÓN. La búsqueda en árboles binarios es un método de búsqueda simple, dinámico y eficiente considerado como uno de los fundamentales en Ciencia de la Computación. De toda la terminología sobre árboles,tan sólo recordar que la propiedad que define un árbol binario es que cada nodo tiene a lo más un hijo a la izquierda y uno a la derecha.Para construir los algoritmos consideraremos que cada nodo contiene un registro con un valor clave a través del cual efectuaremos las búsquedas.En las implementaciones que presentaremos sólo se considerará en cada nodo del árbol un valor del tipo tElemento aunque en un caso general ese tipo estará compuesto por dos:una clave indicando el campo por el cual se realiza la ordenación y una información asociada a dicha clave o visto de otra forma,una información que puede ser compuesta en la cual existe definido un orden. Un árbol binario de búsqueda(ABB) es un árbol binario con la propiedad de que todos los elementos almacenados en el subárbol izquierdo de cualquier nodo x son menores que el elemento almacenado en x ,y todos los elementos almacenados en el subárbol derecho de x son mayores que el elemento almacenado en x. La figura 1 muestra dos ABB construidos en base al mismo conjunto de enteros: 81 Manual del Alumno Obsérvese la interesante propiedad de que si se listan los nodos del ABB en inorden nos da la lista de nodos ordenada.Esta propiedad define un método de ordenación similar al Quicksort,con el nodo raíz jugando un papel similar al del elemento de partición del Quicksort aunque con los ABB hay un gasto extra de memoria mayor debido a los punteros.La propiedad de ABB hace que sea muy simple diseñar un procedimiento para realizar la búsqueda. Para determinar si k está presente en el árbol la comparamos con la clave situada en la raíz, r.Si coinciden la búsqueda finaliza con éxito, si k<r es evidente que k,de estar presente,ha de ser un descendiente del hijo izquierdo de la raíz,y si es mayor será un descendiente del hijo derecho.La función puede ser codificada fácilmente de la siguiente forma: #define ABB_VACIO NULL #define TRUE 1 #define FALSE 0 typedef int tEtiqueta /*Algun tipo adecuado*/ typedef struct tipoceldaABB{ struct tipoceldaABB *hizqda,*hdrcha; tEtiqueta etiqueta; }*nodoABB; typedef nodoABB ABB; ABB Crear(tEtiqueta et) { ABB raiz; raiz = (ABB)malloc(sizeof(struct tceldaABB)); if (raiz == NULL) error("Memoria Insuficiente."); raiz->hizda = NODO_NULO; raiz->hdcha = NODO_NULO; raiz->etiqueta = et; return(raiz); } int Pertenece(tElemento x,ABB t) { if(!t) return FALSE else if(t->etiqueta==x) return TRUE; else if(t->etiqueta>x) return pertenece(x,t->hizqda); else return pertenece(x,t->hdrcha); } Es conveniente hacer notar la diferencia entre este procedimiento y el de búsqueda binaria.En éste podría pensarse en que se usa un árbol binario para describir la secuencia de comparaciones hecha por una función de búsqueda sobre el vector.En cambio en los ABB se construye una estructura de datos con registros conectados por punteros y se usa esta estructura para la búsqueda.El procedimiento de construcción de un ABB puede basarse en un 82 Manual del Alumno procedimiento de inserción que vaya añadiendo elementos al árbol. Tal procedimiento comenzaría mirando si el árbol es vacío y de ser así se crearía un nuevo nodo para el elemento insertado devolviendo como árbol resultado un puntero a ese nodo.Si el árbol no está vacio se busca el elemento a insertar como lo hace el procedimiento pertenece sólo que al encontrar un puntero NULL durante la búsqueda,se reemplaza por un puntero a un nodo nuevo que contenga el elemento a insertar.El código podría ser el siguiente: void Inserta(tElemento x,ABB *t) { if(!(*t)){ *t=(nodoABB)malloc(sizeof(struct tipoceldaABB)); if(!(*t)){ error("Memoria Insuficiente."); } (*t)->etiqueta=x; (*t)->hizqda=NULL; (*t)->hdrcha=NULL; } else if(x<(*t)->etiqueta) inserta(x,&((*t)->hizqda)); else inserta(x,&((*t)->hdrcha)); } Por ejemplo supongamos que queremos construir un ABB a partir del conjunto de enteros {10,5,14,7,12} aplicando reiteradamente el proceso de inserción.El resultado es el que muestra la figura 2. 2. ANÁLISIS DE LA EFICIENCIA DE LAS OPERACIONES. 83 Manual del Alumno Puede probarse que una búsqueda o una inserción en un ABB requiere O(log2n) operaciones en el caso medio,en un árbol construido a partir de n claves aleatorias, y en el peor caso una búsqueda en un ABB con n claves puede implicar revisar las n claves,o sea,es O(n). Es fácil ver que si un árbol binario con n nodos está completo (todos los nodos no hojas tienen dos hijos) y así ningún camino tendrá más de 1+log2n nodos.Por otro lado,las operaciones pertenece e inserta toman una cantidad de tiempo constante en un nodo.Por tanto,en estos árboles, el camino que forman desde la raíz,la secuencia de nodos que determinan la búsqueda o la inserción, es de longitud O(log2n),y el tiempo total consumido para seguir el camino es también O(log2n). Sin embargo,al insertar n elementos en un orden aleatorio no es seguro que se sitúen en forma de árbol binario completo.Por ejemplo,si sucede que el primer elemento(de n situados en orden) insertado es el más pequeño el árbol resultante será una cadena de n nodos donde cada nodo,excepto el más bajo en el árbol,tendrá un hijo derecho pero no un hijo izquierdo.En este caso,es fácil demostrar que como lleva i pasos insertar el i-ésimo elemento dicho proceso de n inserciones necesita pasos o equivalentemente O(n) pasos por operación. Es necesario pues determinar si el ABB promedio con n nodos se acerca en estructura al árbol completo o a la cadena,es decir,si el tiempo medio por operación es O(log2n),O(n) o una cantidad intermedia.Como es difícil saber la verdadera frecuencia de inserciones sólo se puede analizar la longitud del camino promedio de árboles "aleatorios" adoptando algunas suposiciones como que los árboles se forman sólo a partir de inserciones y que todas las magnitudes de los n elementos insertados tienen igual probabilidad.Con esas suposiciones se puede calcular P(n),el número promedio de nodos del camino que va de la raíz hacia algún nodo(no necesariamente una hoja).Se supone que el árbol se formó con la inserción aleatoria de n nodos en un árbol que se encontraba inicialmente vacío,es evidente que P(0)=0 y P(1)=1.Supongamos que tenemos una lista de n>=2 elementos para insertar en un árbol vacío,el primer elemento de la lista,x,es igual de probable que sea el primero,el segundo o el nésimo en la lista ordenada.Consideremos que i elementos de la lista son menores que x de modo que n-i-1 son mayores. Al construir el árbol,x aparecerá en la raíz,los i elementos más pequeños serán descendientes izquierdos de la raíz y los restantes n-i-1 serán descendientes derechos.Esquemáticamente quedaría como muestra la figura 3. Al tener tanto en un lado como en otro todos los elementos igual probabilidad se espera que los subárboles izqdo y drcho de la raíz tengan longitudes de camino medias P(i) y P(n-i-1) respectivamente.Como es posible acceder a esos elementos desde la raíz del árbol completo es necesario agregar 1 al número de nodos de cada camino de forma que para todo i entre 0 y n-1,P(n) puede calcularse obteniendo el promedio de la suma: El primer término es la longitud del camino promedio en el subárbol izquierdo ponderando su tamaño.El segundo término es la cantidad análoga del subárbol derecho y el término 1/n 84 Manual del Alumno representa la contribución de la raíz.Al promediar la suma anterior para todo i entre 1 y n se obtiene la recurrencia: y con unas transformaciones simples podemos ponerla en la forma: y el resto es demostrar por inducción sobre n que P(n)<=1+4log2n. En consecuencia el tiempo promedio para seguir un camino de la raíz a un nodo aleatorio de un ABB construido mediante inserciones aleatorias es O(log2n).Un análisis más detallado demuestra que la constante 4 es en realidad una constante cercana a 1.4. De lo anterior podemos concluir que la prueba de pertenencia de una clave aleatoria lleva un tiempo O(log2n).Un análisis similar muestra que si se incluyen en la longitud del camino promedio sólo aquellos nodos que carecen de ambos hijos o solo aquellos que no tienen hijo izqdo o drcho también la longitud es O(log2n). Terminaremos este apartado con algunos comentarios sobre los borrados en los ABB.Es evidente que si el elemento a borrar está en una hoja bastaría eliminarla,pero si el elemento está en un nodo interior,eliminándolo,podríamos desconectar el árbol.Para evitar que esto suceda se sigue el siguiente procedimiento:si el nodo a borrar u tiene sólo un hijo se sustituye u por ese hijo y el ABB quedar&aacue; construido.Si u tiene dos hijos,se encuentra el menor elemento de los descendientes del hijo derecho(o el mayor de los descendientes del hijo izquierdo) y se coloca en lugar de u,de forma que se continúe manteniendo la propiedad de ABB. B-ÁRBOLES 1. INTRODUCCIÓN. Los B-árboles sugieron en 1972 creados por R.Bayer y E.McCreight.El problema original comienza con la necesidad de mantener índices en almacenamiento externo para acceso a bases de datos,es decir,con el grave problema de la lentitud de estos dispositivos se pretende aprovechar la gran capacidad de almacenamiento para mantener una cantidad de información muy alta organizada de forma que el acceso a una clave sea lo más rápido posible. Como se ha visto anteriormente existen métodos y estructuras de datos que permiten realizar una búsqueda dentro de un conjunto alto de datos en un tiempo de orden O(log2n). Así tenemos el caso de los árboles binarios AVL.¿Por qué no usarlos para organizar a través de ellos el índice de una base de datos?la respuesta aparece si estudiamos más de cerca el problema de acceso a memoria externa.Mientras que en memoria interna el tiempo de acceso a n datos situados en distintas partes de la memoria es independiente de las direcciones que estos ocupen(n*cte donde cte es el tiempo de acceso a 1 dato),en memoria externa es 85 Manual del Alumno fundamental el acceder a datos situados en el mismo bloque para hacer que el tiempo de ejecución disminuya debido a que el tiempo depende fuertemente del tiempo de acceso del dispositivo externo,si disminuimos el número de accesos a disco lógicamente el tiempo resultante de ejecución de nuestra búsqueda se ve fuertemente recortado.Por consiguiente,si tratamos de construir una estructura de datos sobre disco es fundamental tener en cuenta que uno de los factores determinantes en el tiempo de ejecución es el número total de accesos,de forma que aunque dicho n&uacite;mero pueda ser acotado por un orden de eficiencia es muy importante tener en cuenta el número real ya que el tiempo para realizar un acceso es suficientemente alto como para que dos algoritmos pese a tener un mismo orden,puedan tener en un caso un tiempo real de ejecución aceptable y en otro inadmisible. De esta forma,si construimos un árbol binario de búsqueda equilibrado en disco,los accesos a disco serán para cargar en memoria uno de los nodos,es decir,para poder llevar a memoria una cantidad de información suficiente como para poder decidir entre dos ramas.Los árboles de múltiples ramas tienen una altura menor que los árboles binarios pues pueden contener más de dos hijos por nodo,además de que puede hacerse corresponder los nodos con las páginas en disco de forma que al realizar un único acceso se leen un número alto de datos que permiten elegir un camino de búsqueda no entre dos ramas,sino en un número considerablemente mayor de ellas.Además,este tipo de árboles hace más fácil y menos costoso conseguir equilibrar el árbol. En resumen,los árboles con múltiples hijos hacen que el mantenimiento de índices en memoria externa sea mucho más eficiente y es justamente éste el motivo por el que este tipo de árboles han sido los que tradicionalmente se han usado para el mantenimiento de índices en sistemas de bases de datos.Lógicamente,aunque este tipo de estructuras sean más idóneas para mantener grandes cantidades de datos en almacenamiento externo es posible construirlas de igual forma en memoria principal,y por consiguiente pueden ser mantenidas en memoria (mediante el uso de punteros por ejemplo)al igual que las que hemos estudiado hasta ahora. 2. B-ÁRBOLES. DEFINICIÓN. Los B-árboles son árboles cuyos nodos pueden tener un número múltiple de hijos tal como muestra el esquema de uno de ellos en la figura 1. Como se puede observar en la figura 1,un B-árbol se dice que es de orden m si sus nodos pueden contener hasta un máximo de m hijos.En la literatura también aparece que si un árbol es de orden m significa que el mínimo número de hijos que puede tener es m+1(m claves).Nosotros no la usaremos para diferenciar el caso de un número máximo par e impar de claves en un nodo. El conjunto de claves que se sitúan en un nodo cumplen la condición: 86 Manual del Alumno de forma que los elementos que cuelgan del primer hijo tienen una clave con valor menor que K1,los que cuelgan del segundo tienen una clave con valor mayor que K1 y menor que K2,etc...Obviamente,los que cuelgan del último hijo tienen una clave con valor mayor que la última clave(hay que tener en cuenta que el nodo puede tener menos de m hijos y por consiguiente menos de m-1 claves). Para que un árbol sea B-árbol además deberá cumplir lo siguiente: Todos los nodos excepto la raíz tienen al menos E((m-1)/2) claves.Lógicamente para los nodos interiores eso implica que tienen al menos E((m+1)/2) hijos. Todas las hojas están en el mismo nivel. El hecho de que la raíz pueda tener menos descendientes se debe a que si el crecimiento del árbol hace que la raíz se divida en dos hay que permitir dicha situación para que los nuevos nodos mantengan esa propiedad.En el caso de que eso ocurra en un nodo interior distinto a la raíz se soluciona propagando hacia arriba;lógicamente esta operación no se puede realizar en el caso de raíz. Por otro lado,con el hecho de que los nodos interiores tengan un número mínimo de descendientes aseguramos que en el nivel n(nivel 1 corresponde a la raíz)haya un mínimo de 2En-1((m+1)/2)(el 2 es el mínimo de hijos de la raíz y E((m+1)/2) el mínimo para los demás)y teniendo en cuenta que un árbol con N claves tiene N+1 descendientes en el nivel de las hojas,podemos establecer la siguiente desigualdad: Resolviendo: que nos da una cota superior del número de nodos a recorrer para localizar un elemento en el árbol. BÚSQUEDA EN UN B-ÁRBOL. Localizar una clave en un B-árbol es una operación simple pues consiste en situarse en el nodo raíz del árbol,si la clave se encuentra ahí hemos terminado y si no es así seleccionamos de entre los hijos el que se encuentra entre dos valores de clave que son menor y mayor que la buscada respectivamente y repetimos el proceso hasta que la encontremos.En caso de que se llegue a una hoja y no podamos proseguir la búsqueda la clave no se encuentra en el árbol.En definitiva,los pasos a seguir son los siguientes: 1. Seleccionar como nodo actual la raíz del árbol. 2. Comprobar si la clave se encuentra en el nodo actual: 1. Si la clave está, fin. 2. Si la clave no está: Si estamos en una hoja,no se encuentra la clave.Fin. Si no estamos en una hoja,hacer nodo actual igual al hijo que corresponde según el valor de la clave a buscar y los valores de las claves del nodo actual(i buscamos la clave K en un nodo con n claves:el hijo izquierdo si K<K1,el hijo derecho si K>Kn y el hijo i-ésimo si Ki<K<Ki+1)y volver al segundo paso. 87 Manual del Alumno INSERCIÓN EN UN B-ÁRBOL. Para insertar una nueva clave usaremos un algoritmo que consiste en dos pasos recursivos: 1. Buscamos la hoja donde debieramos encontrar el valor de la clave de una forma 2. totalmente paralela a la búsqueda de ésta tal como comentabamos en la sección anterior(si en esta búsqueda encontramos en algun lugar del árbol la clave a insertar,el algoritmo no debe hacer nada más).Si la clave no se encuentra en el árbol habremos llegado a una hoja que es justamente el lugar donde debemos realizar esa inserción. Situados en un nodo donde realizar la inserción si no está completo,es decir,si el número de claves que existen es menor que el orden menos 1 del árbol,el elemento puede ser insertado y el algoritmo termina.En caso de que el nodo esté completo insertamos la clave en su posición y puesto que no caben en un único nodo dividimos en dos nuevos nodos conteniendo cada uno de ellos la mitad de las claves y tomando una de éstas para insertarla en el padre(se usará la mediana).Si el padre está también completo,habrá que repetir el proceso hasta llegar a la raíz.En caso de que la raíz esté completa,la altura del árbol aumenta en uno creando un nuevo nodo raíz con una única clave. En la figura 2 podemos observar el efecto de insertar una nueva clave en un nodo que está lleno. Podemos realizar una modificación al algoritmo de forma que se retrase al máximo el momento de romper un nodo en dos.Con ello podríamos vernos beneficiados por dos razones fundamentalmente: 88 Manual del Alumno 1. La razón más importante para modificar así el algoritmo es que los nodos en el árbol 2. están más llenos con lo cual el gasto en memoria para mantener la estructura es mucho menor. Retrasamos el momento en que la raíz llega a dividirse y por consiguiente retrasamos el momento en que la altura del árbol aumenta. La forma más sencilla de realizar esta modificación es que en el caso de que tengamos que realizar esa división,antes de llevarla a cabo,comprobemos si los hermanos adyacentes tienen espacio libre de forma que si alguno de ellos lo tiene se redistribuyen las claves que se encuentran en el nodo actual más las de ese hermano m&as la clave que los separa(que se encuentra en el padre)más la clave a insertar de forma que en el padre se queda la mediana y las demás quedan distribuidas entre los dos nodos. En la figura 3 podemos observar el efecto de insertar una nueva clave en un nodo que está lleno pero con redistribución. BORRADO EN UN B-ÁRBOL. La idea para realizar el borrado de una clave es similar a la inserción teniendo en cuenta que ahora,en lugar de divisiones,realizamos uniones.Existe un problema añadido,las claves a borrar pueden aparecer en cualquier lugar del árbol y por consiguiente no coincide con el caso de la inserción en la que siempre comenzamos desde una hoja y propagamos hacia arriba.La solución a esto es inmediata pues cuando borramos una clave que está en un nodo interior,lo primero que realizamos es un intercambio de este valor con el inmediato sucesor en el árbol,es decir,el hijo más a la izquierda del hijo derecho de esa clave. Las operaciones a realizar para poder llevar a cabo el borrado son por tanto: 1. Redistribución:la utilizaremos en el caso en que al borrar una clave el nodo se queda 2. con un número menor que el mínimo y uno de los hermanos adyacentes tiene al menos uno más que ese mínimo,es decir,redistribuyendo podemos solucionar el problema. Unión:la utilizaremos en el caso de que no sea posible la redistribución y por tanto sólo será posible unir los nodos junto con la clave que los separa y se encuentra en el padre. En definitiva,el algoritmo nos queda como sigue: 89 Manual del Alumno 1. Localizar el nodo donde se encuentra la clave. . 2. Si el nodo localizado no es una hoja,intercambiar el valor de la clave localizada con el 3. 4. 5. valor de la clave más a la izquierda del hijo a la derecha.En definitiva colocar la clave a borrar en una hoja.Hacemos nodo actual igual a esa hoja. Borrar la clave. Si el nodo actual contiene al menos el mínimo de claves como para seguir siendo un Bárbol,fin. Si el nodo actual tiene un número menor que el mínimo: 1. Si un hermano tiene más del mínimo de claves,redistribución y fin. 2. Si ninguno de los hermanos tiene más del mínimo,unión de dos nodos junto con la clave del padre y vuelta al paso 4 para propagar el borrado de dicha clave(ahora en el padre). 3. PRIMITIVAS DE UN B-ÁRBOL. AB Crear0(int ne) { AB raiz; raiz = (AB)malloc(sizeof(struct AB)); if (raiz == NULL) error("Memoria Insuficiente."); raiz->n_etiquetas = ne; for (int i=1; i<=(ne+1); i++) { raiz->hijos[i] = NULO; } return(raiz); } AB Crear(int ne, int eti[]) { AB raiz; raiz = (AB)malloc(sizeof(struct AB)); if (raiz == NULL) error("Memoria Insuficiente."); raiz->n_etiquetas = ne; for (int i=1; i<=(ne+1); i++) { raiz->hijos[i] = NULO; } for (int i=1; i<=lenght(eti[]); i++) { raiz->etiquetas[i] = eti[i]; } return(raiz); } int Buscar(int eti, int *nod, int *pos) { int i,l; l = lenght(nod->etiquetas[]); for(i=0;inod->etiquetas[i];i++) ; *pos = i; if(*posetiquetas[*pos]) return 1; else; 90 Manual del Alumno return 0; } int BuscarNodo(int eti, int *nod, int *pos) { int i=0, enc; enc = Buscar(eti,&nod,&pos); if (enc == 1) return 1; do { if (etietiquetas[i] && nod->hijos[i]!=NULO) enc = BuscarNodo(eti,&nod->hijos[i],&pos); else if ((etietiquetas[i+1]||nod->etiquetas[i+1]==-1)&&nod->hijos[i+1]!=-1) enc = BuscarNodo(eti,&nod->hijos[i+1],&pos); i++; } while (i<lenght(nod->etiquetas[]) && enc==0); return (enc); } ÁRBOLES B* 1. INTRODUCCIÓN. En 1973,Knuth propone nuevas reglas para realizar el mantenimiento de los B-árboles de forma que no se realiza una división de un nodo en dos ya que eso hace que los nodos resultantes tengan la mitad de claves,sino que se realizan divisiones de dos nodos completos a tres de forma que los nodos resultantes tienen dos tercios del total.Por consiguiente,este tipo de árboles son muy similares a los anteriores pero teniendo en cuenta: 1. 2. 3. 4. 5. 6. 7. Cada nodo tiene un máximo de m descendientes. Cada nodo excepto el raíz tiene al menos (2m-1)/3 hijos. La raíz tiene al menos dos descendientes (a menos de que sea hoja). Una hoja contiene al menos E[(2m-1)/3] claves. Todas las hojas aparecen en el mismo nivel. Un nodo que no sea hoja con k descendientes contiene k-1 llaves. Un nodo hoja contiene por lo menos E[(2m-1)/3] llaves, y no más de m-1. Existe un problema añadido a este tipo de árboles pues cuando la división de nodos se propaga hasta la raíz dividirla según hemos visto implicaría que junto a uno de sus hermanos que estuvieran completos se crearán tres nuevos nodos llenos en dos terceras partes.Esto no es posible pues la raíz no tiene hermanos.La solución al problema puede ser permitir que la raíz tenga un número superior de claves de forma que si se divide se puedan producir 3 nodos cumpliendo las características de los árboles B*. Los cambios críticos entre el anterior conjunto de propiedades y el conjunto que se define para un árbol B convencional están en las reglas 2 y 6: un árbol B* tiene nodos que contienen un 91 Manual del Alumno mínimo de (2m-1)/3 llaves. Por supuesto, esta nueva propiedad afecta los procedimientos de eliminación y redistribución. Para realizar los procedimientos de árboles B* también se debe abordar la cuestión de dividir la raíz, la cual, por definición, nunca tiene hermanos. Si no existen hermanos, no es posible la división de dos a tres. Knuth sugiere permitir que la raíz crezca hasta un tamaño mayor que los demás nodos, de tal forma que, cuando se divida, pueda producir dos nodos cada uno lleno casi a las dos terceras partes. Esta sugerencia tiene la ventaja de asegurar que todos los nodos por debajo del nivel de la raíz se adhieren a las características de los árboles B *. Sin embargo, tiene la desventaja de requerir que los procedimientos sean capaces de manejar un nodo que sea de mayor tamaño que todos los demás. Otra solución es realizar la división de la raíz como una división convencional de uno a dos. Esta segunda solución evita cualquier lógica especial de manejo de nodos. Por otro lado, complica la eliminación, la redistribución y otros procedimientos que deben ser sensibles al número mínimo de llaves permitidas en un nodo. Tales procedimientos tendrían que ser capaces de reconocer que los nodos descendientes de la raíz legalmente pueden estar solo llenos. ÁRBOLES B+ 1. INTRODUCCIÓN Los árboles B+ constituyen otra mejora sobre los árboles B,pues conservan la propiedad de acceso aleatorio rápido y permiten además un recorrido secuencial rápido.En un árbol B+ todas las claves se encuentran en hojas,duplicándose en la raíz y nodos interiores aquellas que resulten necesarias para definir los caminos de búsqueda.Para facilitar el recorrido secuencial rápido las hojas se pueden vincular,obteniéndose ,de esta forma,una trayectoria secuencial para recorrer las claves del árbol. Su principal característica es que todas las claves se encuentran en las hojas.Los árboles B+ ocupan algo más de espacio que los árboles B,pues existe duplicidad en algunas claves.En los árboles B+ las claves de las páginas raíz e interiores se utilizan únicamente como índices. El orden de inserción de los diversos elementos fue: p v d e b c s a r f t q 2. BUSQUEDA EN UN ÁRBOL B+ 92 Manual del Alumno En este caso,la búsqueda no debe detenerse cuando se encuentre la clave en la página raíz o en una página interior,si no que debe proseguir en la página apuntada por la rama derecha de dicha clave. 3. INSERCIÓN EN UN ÁRBOL B+ Su diferencia con el proceso de inserción en árboles B consiste en que cuando se inserta una nueva clave en una página llena,ésta se divide también en otras dos,pero ahora la primera contendrá con m/2 claves y la segunda 1+m/2, y lo que subirá a la página antecesora será una copia de la clave central. 4. BORRADO EN UN ÁRBOL B+ La operación de borrado debe considerar: Si al eliminar la clave(siempre en una hoja)el número de claves es mayor o igual a m/2 el proceso ha terminado. Las claves de las páginas raíz o internas no se modifican aunque sean una copia de la eliminada,pues siguen constituyendo un separador válido entre las claves de las páginas descendientes. Si al eliminar la clave el número de ellas en la página es menor que m/2 será necesaria una fusión y redistribución de las mismas tanto en las páginas hojas como en el índice. Arboles AVL. 1. MOTIVACIÓN. Comencemos con un ejemplo: Supongamos que deseamos construir un ABB para la siguiente tabla de datos: El resultado se muestra en la figura siguiente: 93 Manual del Alumno Como se ve ha resultado un árbol muy poco balanceado y con características muy pobres para la búsqueda. Los ABB trabajan muy bien para una amplia variedad de aplicaciones, pero tienen el problema de que la eficiencia en el peor caso es O(n). Los árboles que estudiaremos a continuación nos darán una idea de cómo podria resolverse el problema garantizando en el peor caso un tiempo O(log2 n). 2. ARBOLES EQUILIBRADOS AVL. Diremos que un árbol binario está equilibrado (en el sentido de Addelson-Velskii y Landis) si, para cada uno de sus nodos ocurre que las alturas de sus dos subárboles difieren como mucho en 1. Los árboles que cumplen esta condición son denominados a menudo árboles AVL. En la primera figura se muestra un árbol que es AVL, mientras que el de la segunda no lo es al no cumplirse la condición en el nodo k. 94 Manual del Alumno A través de los árboles AVL llegaremos a un procedimiento de búsqueda análogo al de los ABB pero con la ventaja de garantizaremos un caso peor de O(log2 n), manteniendo el árbol en todo momento equilibrado. Para llegar a este resultado , podríamos preguntarnos cual podría ser el peor AVL que podríamos construir con n nodos, o dicho de otra forma cuanto podríamos permitir que un árbol binario se desequilibrara manteniendo la propiedad de AVL. Para responder a la pregunta podemos construir para una altura h el AVL Th, con mínimo número de nodos. Cada uno de estos árboles mínimos debe constar de una raiz, un subárbol AVL minimo de altura h-1 y otro subárbol AVL también minimo de altura h-2. Los primeros Ti pueden verse en la siguiente figura: 95 Manual del Alumno Es fácil ver que el número de nodos n(T h) está dado por la relación de recurencia [1]: n(Th) = 1 + n(Th-1) + n(Th-2) Relación similar a la que aparece en los números de Fibonacci (F n = Fn-1 + Fn-2) , de forma que la ss, de valores para n(Th) está relacionada con los valores de la ss. de Fibonacci: AVL -> -, -, 1, 2, 4, 7, 12, ... FIB -> 1, 1, 2, 3, 5, 8, 13, ... es decir [2], n(Th) = Fh+2 - 1 Resolviendo [1] y utilizando [2] llegamos tras algunos cálculos a: log2(n+1) <= h < 1.44 log2(n+2)-0.33 o dicho de otra forma, la longitud de los caminos de búsqueda (o la altura) para un AVL de n nodos, nunca excede al 44% de la longitud de los caminos (o la altura) de un árbol completamente equilibrado con esos n nodos. En consecuencia, aún en el peor de los casos llevaría un tiempo O(log2 n) al encontrar un nodo con una clave dada. Parece, pues, que el único problema es el mantener siempre tras cada inserción la condición de equilibrio, pero esto puede hacerse muy fácilmente sin más que hacer algunos reajustes locales, cambiando punteros. Antes de estudiar mas detalladamente este tipo de árboles realizamos la declaración de tipos siguiente: typedef int tElemento; typedef struct NODO_AVL { tElemento elemento; struct AVL_NODO *izqda; struct AVL_NODO *drcha; int altura; } nodo_avl; typedef nodo_avl *arbol_avl; 96 Manual del Alumno #define AVL_VACIO NULL #define maximo(a,b) ((a>b)?(a):(b)) En muchas implementaciones, para cada nodo no se almacena la altura real de dicho nodo en el campo que hemos llamada altura, en su lugar se almacena un valor del conjunto {-1,0,1} indicando la relación entre las alturas de sus dos hijos. En nuestro caso almacenamos la altura real por simplicidad. Por consiguiente podemos definir la siguiente macro: #define altura(n) (n?n->altura:-1) La cual nos devuelve la altura de un nodo_avl. Con estas declaraciones la funciones de creación y destrucción para los árboles AVLpueden ser como sigue: arbolAVL Crear_AVL() { return AVL_VACIO; } void Destruir_AVL (arbolAVL A) { if (A) { Destruir_AVL(A->izqda); Destruir_AVL(A->drcha); free(A); } } Es sencillo realizar la implementación de una función que podemos llamar miembro que nos devuelve si un elemento pertenece al árbol AVL. Podría ser la siguiente: int miembro_AVL(tElemento e,arbolAVL A) { if (A == NULL) return 0; if (e == A->elemento) return 1; else if (e < A->elemento) return miembro_AVL(e,A->izqda); else return miembro_AVL(e,A->drcha); } Veamos ahora la forma en que puede afectar una inserción en un árbol AVL y la forma en que deberiamos reorganizar los nodos de manera que siga equilibrado. Consideremos el esquema general de la siguiente figura, supongamos que la inserción ha provocado que el subárbol que cuelga de Ai pasa a tener una altura 2 unidades mayor que el subárbol que cuelga de Ad . ¿Qué operaciones son necesarias para que el nodo r tenga 2 subárboles que cumplan la propiedad de árboles AVL?. 97 Manual del Alumno Para responder a esto estudiaremos dos situaciones distintas que requieren 2 secuencias de operaciones distintas: La inserción se ha realizado en el árbol A. La operación a realizar es la de una rotación simple a la derecha sobre el nodo r resultando el árbol mostrado en la siguiente figura. La inserción se ha realizado en el árbol B. (supongamos tiene raiz b, subárbol izquierdo B1 y subárbol derecho B2). La operación a realizar es la rotación doble izquierdaderecha la cual es equivalente a realizar una rotación simple a la izquierda sobre el nodo Ai y despues una rotación simple a la derecha sobre el nodo r (por tanto, el árbol B queda dividido). El resultado se muestra en la figura siguiente: 98 Manual del Alumno En el caso de que la inserción se realice en el subárbol Ad la situación es la simétrica y para las posibles violaciones de equilibrio se aplicará la misma técnica mediante la rotación simple a la izquierda o la rotación doble izquierda-derecha. Se puede comprobar que si los subárboles Ad y Ai son árboles AVL, estas operaciones hacen que el árbol resultante también sea AVL. Por último, destacaremos que para realizar la implementación definitiva en base a la declaración de tipos que hemos propuesto tendremos que realizar un ajuste de la altura de los nodos involucrados en la rotación además del ya mencionado ajuste de punteros. Por ejemplo: En la rotación simple que se ha realizado en la primera de las situaciones, el campo de altura de los nodos r y Ai puede verse modificado. Estas operaciones básicas de simple y doble rotación se pueden implementar de la siguiente forma: void Simple_derecha(arbolAVL *A) { nodoAVL *p; p = (*A)->izqda; (*A)->izqda = p->drcha; p->drcha = (*A); (*A) = p; /* Ajustamos las alturas */ p = (*A)->drcha; p->altura = maximo(altura(p->izqda),altura(p->drcha))+1; (*A)->altura = maximo(a1tura((*T)->izqda),altura((*T)->drcha))+1; } void Simple_izquierda(arbolAVL *A) { nodoAVL *p; p = (*A)->drcha; (*A)->drcha = p->izqda; p->izqda = (*A); (*A) = p; /*Ajustamos las alturas */ p = (*A)->izqda; p->altura = maximo(altura(p->izqda),altura(p->drcha))+1; 99 Manual del Alumno (*A)->altura = maximo(altura((*A)->izqda),altura((*A)->drcha))+1; } void Doble_izquierda_derecha (arbolAVL *AT) { simple_izquierda(&((*A)->izqda)); simple_derecha(A); } void Doble_derecha_izquierda (arbolAVL *A) { simple_derecha(&((*A)->drcha)); simple_izquierda(A); } Obviamente, el reajuste en los nodos es necesario tanto para la operación de inserción como para la de borrado. Por consiguiente, se puede programar la inserción de forma que descendamos en el árbol hasta llegar a una hoja donde insertar y después recorrer el mismo camino hacia arriba realizando los ajustes necesarios (igualmente en el borrado se realizaría algo similar). Para hacer más fácil la implementación, construiremos la función ajusta_avl(e,&T) cuya misión consiste en ajustar los nodos que existen desde el nodo conteniendo la etiqueta e hasta el nodo raiz en el árbol T. La usaremos como función auxiliar para implementar las funciones de inserción y de borrado. El código es el siguiente: void ajusta_AVL (tElemento e, arbolAVL *A) { if (!(*A)) return; if (e > (*A)->elemento) ajusta_AVL(e,&((*A)->drcha)); else if (e < (*A)->elemento) ajusta_avl(e,&((*A)->izqda)); switch (altura((*A)->izqda)-altura((*A)->drcha)) { case 2: if (altura((*A)->izqda->izqda) > altura((*A)->izqda->drcha)) simple_derecha(A); else doble_izquierda_derecha(A); break; case -2: if (altura((*A)->drcha->drcha) > altura((*A)->drcha->izqda)) simple_izquierda(A); else doble_derecha_izquierda(A); break; default: (*A)->altura = maximo(altura((*A)->izqda),altura((*A)->drcha))+1; } } Para la operación de inserción se deberá profundizar en el árbol hasta llegar a un nodo hoja o un nodo con un solo hijo de forma que se añade un nuevo hijo con el elemento insertado. Una vez añadido sólo resta ajustar los nodos que existen en el camino de la raíz al nodo insertado. El código es el siguiente: void insertarAVL (tElemento e, arbolAVL *A) { nodoAVL **p; p=T; while (*p!=NULL) if ((*p)->elemento > e) 100 Manual del Alumno p = &((*p)->izqda); else p = &((*p)->drcha); (*p)=(nodo_avl *)malloc(sizeof(nodoAVL)); if (!(*p)) error("Error: Memoria insuficiente."); (*p)->elemento = e; (*p)->altura = 0; (*p)->izqda = NULL; (*p)->drcha = NULL; ajustaAVL(e,A); } En el caso de la operación de borrado es un poco más complejo pues hay que determinar el elemento que se usará para la llamada a la función de ajuste. Por lo demás es muy similar al borrado en los árboles binarios de búsqueda. En la implementación que sigue usaremos la variable elem para controlar el elemento involucrado en la función de ajuste. void borrarAVL (tElemento e, arbolAVL *A) { nodoAVL **p,**aux,*dest; tElemento elem; p=A; elem=e; while ((*p)->elemento!=e) { elem=(*p)->elemento; if ((*p)->elemento > e) p=&((*p)->izqda); else p=&((*p)->drcha); } if ((*p)->izqda!=NULL && (*p)->drcha!=NULL) { aux=&((*p)->drcha); elem=(*p)->elemento; while ((*aux)->izqda) { elem=(*aux)->elemento; aux=&((*aux)->izqda); } (*p)->elemento = (*aux)->elemento; p=aux; } if ((*p)->izqda==NULL && (*p)->drcha==NULL) { free(*p); (*p) = NULL; } else if ((*p)->izqda == NULL) { dest = (*p); (*p) = (*p)->drcha; free(dest); } else { dest = (*p); (*p) = (*p)->izqda; free(dest); } ajustaAVL(elem,A); } 101 Manual del Alumno Arboles Binarios Parcialmente Ordenados. 1. INTRODUCCIÓN. Un árbol A se dice parcialmente ordenado (APO) si cumple la condición de que la etiqueta de cada nodo es menor (de igual forma mayor) o igual que las etiquetas de los hijos (se supone que el tipo_elemento base admite un orden) manteniéndose además tan balanceado como sea posible, en el caso óptimo equilibrado. Las operaciones básicas en este tipo de árboles son la de inserción de un elemento y la de borrado del elemento de menor etiqueta (la raiz) ,con la consiguiente problemática que se plantea al tener que dejar el árbol tras cualquier operación tanto equilibrado como cumpliendo la condición de orden parcial. Un ejemplo de este tipo de árboles muestra en la siguiente figura: 2. BORRADO EN LOS APO. Para ejecutar el borrado (y eventual almacenamiento del valor) de la raíz,no se puede quitar el nodo sin más ya que se desconectaria la estructura. Por otro lado si se quiere mantener la propiedad de orden parcial y el mayor balanceo posible con las hojas en el nivel más bajo alojadas de izquierda a derecha lo que podría hacerse es poner provisionalmente la hoja más a la derecha del nivel más bajo como raíz provisional. Empujaremos entonces esta raíz hacia abajo intercambiándola con el hijo de etiqueta menor hasta que no podamos hacerlo más (porque sea ya una hoja o porque la etiqueta sea ya menor que la de cualquiera de sus hijos). El anterior proceso aplicado a un árbol con n nodos toma un tiempo O(log2 n) puesto que en el árbol ningún camino tiene más de 1+log2 n nodos y el proceso de empujar hacia abajo intercambiando con los hijos toma un tiempo constante por nodo. En la siguiente figura podemos observar este proceso sobre un ejemplo de borrado en un APO en el cual se elimina el nodo a para seguidamente subir a la raíz el nodo g que es empujado hacia abajo. 102 Manual del Alumno 103 Manual del Alumno 3. INSERCIÓN EN LOS APO. Para implementar la inserción habría que hacer unas consideraciones similares a las anteriores. El nuevo elemento que se inserta, lo podriamos situar provisionalmente en el nivel más bajo tan a la izquierda como sea posible (se comienza en un nuevo nivel si el último nivel está completo). A continuación se intercambia con su padre repitiéndose este proceso hasta que se cumpla la condición de orden parcial (bien porque ya esté en la raíz o porque tenga ya una etiqueta mayor que la de su padre). Al igual que en el borrado puede verse fácilmente que este proceso no lleva más de O(log 2 n) pasos. En la siguiente figura podemos ver un ejemplo de inserción en un APO. 104 Manual del Alumno 105 Manual del Alumno 4. IMPLEMENTACIÓN MATRICIAL DE ARBOLES APO. El hecho de que los árboles que hemos estado considerando sean binarios, tan balanceados como sea posible y tengan las hojas en el nivel inferior empujadas hacia la izquierda hace que podamos usar una representación muy usual para estos árboles llamada MONTON que, básicamente es un vector en el que guardamos los nodos del árbol por niveles. Si existen n nodos, se usan las n primeras posiciones de un vector M (M[0] aloja la raíz). El hijo izquierdo del nodo en M[k], si existe, está en M[2k+1], y el hijo derecho, si existe, está en M[2k+2] ,en consecuencia el padre de M[k] sea M[(k-1)/2], para i>0. 106 Manual del Alumno Podemos declarar un APO de elementos de algún tipo, digamos tipoelemento, que consistira en un vector de tipoelemento y un entero 'último' indicando el último elemento actual (en uso) del vector. Asi podriamos declarar: typedef int tElemento; typedef struct nodoAPO { int ultimo; int maximo; tElemento *apo; } *APO; Y la implementación de las operaciones sería como sigue: APO CrearAPO (int max) { APO A; if (max < 1) { error("El árbol debe tener al menos un nodo."); } A = (APO)malloc(sizeof(struct nodoAPO)); if (A == NULL) error("No hay memoria suficiente."); A->apo = (tElemento*)malloc(max*sizeof(tElemento)); if (A->apo == NULL) error("No hay memoria suficiente."); A->ultimo = -1; A->maximo = max; return A; } 107 Manual del Alumno void DestruirAPO (APO A) { free(A->apo); free(A); } void InsertaAPO (tElemento el, APO A) { int pos; tElemento aux; if (A->ultimo == A->maximo-1) { error("No caben mas elementos."); } A->ultimo++; pos=A->ultimo; A->apo[pos]=el; /* Bucle para subir el elemento hasta su posición. */ while ((pos>0) && (A->apo[pos] < A->apo[(pos-1)/2])) { aux = A->apo[pos]; A->apo[pos] = A->apo[(pos-1)/2]; A->apo[(pos-1)/2] = aux; pos = (pos-1)/2; } } tElemento BorrarMinimo (APO A) { int pos; int pos_min,acabar; tElemento minimo,aux; if (A->ultimo == -1) { error("No hay elementos."); } minimo = A->apo[0]; A->apo[0] = A->apo[A->ultimo]; A->ultimo--; if (A->ultimo <= 0) return minimo; pos = 0; acabar = 0; while (pos <= (A->ultimo-1)/2 && !acabar) { if (2*pos+1 == A->ultimo) pos_min = 2*pos+1; else if (A->apo[2*pos+1] < A->apo[2*pos+2]) pos_min = 2*pos+1; else pos_min = 2*pos+2; if (A->apo[pos] > A->apo[pos_min]) { aux = A->apo[pos]; A->apo[pos] = A->apo[pos_min]; A->apo[pos_min] = aux; pos = pos_min; } else acabar=1; } 108 Manual del Alumno return minimo; } GRAFOS 1. INTRODUCCIÓN. El origen de la palabra grafo es griego y su significado etimológico es "trazar". Aparece con gran frecuencia como respuesta a problemas de la vida cotidiana,algunos ejemplos podrían ser los siguientes:un gráfico de una serie de tareas a realizar indicando su secuenciación (un organigrama),grafos matem´ticos que representan las relaciones binarias,una red de carreteras,la red de enlaces ferroviarios o aéreos o la red eléctrica de una ciudad.(Véase la figura 1).En cada caso,es conveniente representar gráficamente el problema dibujando un grafo como un conjunto de puntos(vértices)con líneas conectándolos (arcos). De aquí se podría deducir que un grafo es básicamente un objeto geométrico aunque en realidad sea un objeto combinatorio,es decir,un conjunto de puntos y un conjunto de líneas tomado de entre el conjunto de líneas que une cada par de vértices.Por otro lado,y debido a su generalidad y a la gran diversidad de formas que pueden usarse,resulta complejo tratar con todas las ideas relacionadas con un grafo. Para facilitar el estudio de este tipo de dato,a continuación se realizará un estudio de la teoría de grafos desde el punto de vista de las ciencias de la computación. Considerando que dicha teoría es compleja y amplia,aquí sólo se realizará una introducción a la misma,describiéndose el grafo como un tipo de dato y mostrándose los problemas típicos y los algoritmos que permiten solucionarlos usando un ordenador. 109 Manual del Alumno Los grafos son estructuras de datos no lineales que tienen una naturaleza generalmente dinámica. Su estudio podría dividirse en dos grandes bloques: Grafos Dirigidos. Grafos no Dirigidos(pueden ser considerados un caso particular de los anteriores). Un ejemplo de grafo dirigido lo constituye la red de aguas de una ciudad ya que cada tubería sólo admite que el agua la recorra en un único sentido.Por el contrario,la red de carreteras de un país representa en general un grafo no dirigido,puesto que una misma carretera puede ser recorrida en ambos sentidos.No obstante,podemos dar unas definiciones generales para ambos tipos. A continuación daremos definiciones de los dos tipos de grafos y de los conceptos que llevan asociados. 2. DEFINICIONES Y TERMINOLOGÍA FUNDAMENTAL. Un grafo G es un conjunto en el que hay definida una relación binaria,es decir,G=(V,A) tal que V es un conjunto de objetos a los que denominaremos vértices o nodos y relación binaria a cuyos elementos denominaremos arcos o aristas. Dados ,puede ocurrir que: 1. , en cuyo caso diremos que x e y están unidos mediante un arco,y 2. , en cuyo caso diremos que no lo están. es una Si las aristas tienen asociada una dirección(las aristas (x,y) y (y,x) no son equivalentes) diremos que el grafo es dirigido,en otro caso ((x,y)=(y,x)) diremos que el grafo es no dirigido. Conceptos asociados a grafos: Diremos que un grafo es completo si A=VxV,o sea,si para cualquier pareja de vértices existe una arista que los une(en ambos sentidos si el grafo es no dirigido).El número de aristas será: o grafos dirigidos: o grafos no dirigidos: 110 Manual del Alumno donde n=|V| Un grafo dirigido es simétrico si para toda arista (x,y)perteneciente a A también aparece la arista (y,x)perteneciente a A;y es antisimétrico si dada una arista (x,y) perteneciente a A implica que (y,x) no pertenece a A. Tanto a las aristas como a los vértices les puede ser asociada información.A esta información se le llama etiqueta.Si la etiqueta que se asocia es un número se le llama peso,costo o longitud.Un grafo cuyas aristas o vértices tienen pesos asociados recibe el nombre de grafo etiquetado o ponderado. El número de elementos de V se denomina orden del grafo.Un grafo nulo es un grafo de orden cero. Se dice que un vértice x es incidente a un vértice y si existe un arco que vaya de x a y ((x,y)pertenece a A),a x se le denomina origen del arco y a y extremo del mismo.De igual forma se dirá que y es adyacente a x.En el caso de que el grafo sea no dirigido si x es adyacente(resp. incidente) a y entonces y también es adyacente (resp. incidente) a x. Se dice que dos arcos son adyacentes cuando tienen un vértice común que es a la vez origen de uno y extremo del otro. Se denomina camino (algunos autores lo llaman cadena si se trata de un grafo no dirigido)en un grafo dirigido a una sucesión de arcos adyacentes: C={(v1,v2),(v2,v3),...,(vn-1,vn), para todo vi perteneciente a V} La longitud del camino es el número de arcos que comprende y en el caso en el que el grafo sea ponderado se calculará como la suma de los pesos de las aristas que lo constituyen. Ejemplo. o En el grafo dirigido de la figura 2,un camino que une los vértices 1 y 4 es C= {(1,3),(3,2),(2,1)},su longitud es 3. o En el grafo no dirigido de la figura 2,un camino que une los vértices 1 y 4 es C'= {(1,2),(2,4)}.Su longitud es 2. Un camino se dice simple cuando todos sus arcos son distintos y se dice elemental cuando no utiliza un mismo vértice dos veces.Por tanto todo camino elemental es simple y el recíproco no es cierto. Un camino se dice Euleriano si es simple y además contiene a todos los arcos del grafo. Un circuito(o ciclo para grafos no dirigidos)es un camino en el que coinciden los vértices inicial y final.Un circuito se dice simple cuando todos los arcos que lo forman son distintos y se dice elemental cuando todos los vértices por los que pasa son distintos.La longitud de un circuito es el número de arcos que lo componen.Un bucle es un circuito de longitud 1(están permitidos los arcos de la forma(i,i) y notemos que un grafo antisimétrico carecería de ellos). 111 Manual del Alumno Un circuito elemental que incluye a todos los vértices de un grafo lo llamaremos circuito Hamiltoniano. Un grafo se denomina simple si no tiene bucles y no existe más que un camino para unir dos nodos. Diremos que un grafo no dirigido es bipartido si el conjunto de sus vértices puede ser dividido en dos subconjuntos(disjuntos) de tal forma que cualquiera de las aristas que componen el grafo tiene cada uno de sus extremos en un subconjunto distinto.Un grafo no dirigido será bipartido si y sólo si no contiene ciclos con un número de aristas par. Dado un grafo G=(V,A),diremos que G'=(V,A') con es un grafo parcial de G y ' ' ' un subgrafo de G es todo grafo G =(V ,A ) con y donde A' será el conjunto de todas aquellas aristas que unían en el grafo G dos vértices que están en V'. Se podrían combinar ambas definiciones dando lugar a lo que llamaremos subgrafo parcial Se denomina grado de entrada de un vértice x al número de arcos incidentes en él.Se denota . Se denomina grado de salida de un vértice x al número de arcos adyacentes a él.Se denota . Para grafos no dirigidos tanto el grado de entrada como el de salida coinciden y hablamos entonces de grado y lo notamos por . A todo grafo no dirigido se puede asociar un grafo denominado dual construido de la siguiente forma: donde A' está construido de la siguiente forma:si e1,e2 pertenece a A son adyacentes -> (e1,e2)pertenece a A' con e1,e2 pertenece a V'.En definitiva,para construir un grafo dual se cambian vértices por aristas y viceversa. 112 Manual del Alumno Dado un grafo G,diremos que dos vértices están conectados si entre ambos existe un camino que los une. Llamaremos componente conexa a un conjunto de vértices de un grafo tal que entre cada par de vértices hay al menos un camino y si se añade algún otro vértice esta concición deja de verificarse.Matemáticamente se puede ver como que la conexión es una relación de equivalencia que descompone a V en clases de equivalencia,cada uno de los subgrafos a los que da lugar cada una de esas clases de equivalencia constituiría una componente conexa.Un grafo diremos que es conexo si sólo existe una componente conexa que coincide con todo el grafo. . 3. TDA GRAFO. A la hora de diseñar el TDA grafo hay que tener en cuenta que hay que manejar datos correspondientes a sus vértices y aristas,pudiendo cada uno de ellos estar o no etiquetados.Además hay que proporcionar operaciones primitivas que permitan manejar el tipo de dato sin necesidad de conocer la implementación.Así,los tipos de datos que se usarán y las operaciones primitivas consideradas son las siguientes: NUEVOS TIPOS APORTADOS. Los nuevos tipos aportados por el TDA grafo son los siguientes: grafo. vertice. arista. 4. REPRESENTACIONES PARA EL TDA GRAFO. Existen diversas representaciones de naturaleza muy diferente que resultan adecuadas para manejar un grafo,y en la mayoría de los casos no se puede decir que una sea mejor que otra siempre ya que cada una puede resultar más adecuada dependiendo del problema concreto al que se desea aplicar.Así,si existe una representación que es peor que otra para todas las operaciones excepto una es posible que aún así nos decantemos por la primera porque precisamente esa operación es la única en la que tenemos especial interés en que se realice de forma eficiente.A continuación veremos dos de las representaciones más usuales:Matriz de adyacencia(o booleana) y Lista de adyacencia. 113 Manual del Alumno MATRIZ DE ADYACENCIA. Grafos dirigidos. G=(V,A) un grafo dirigido con |V|=n .Se define la matriz de adyacencia o booleana asociada a G como Bnxn con Como se ve,se asocia cada fila y cada columna a un vértice y los elementos bi,j de la matriz son 1 si existe el arco (i,j) y 0 en caso contrario. Grafos no dirigidos. G=(V,A) un grafo no dirigido con |V|=n .Se define la matriz de adyacencia o booleana asociada a G como Bnxn con: La matriz B es simetrica con 1 en las posiciones ij y ji si existe la arista (i,j). EJEMPLO: 114 Manual del Alumno Si el grafo es etiquetado,entonces tanto bi,j como bi,j representan al coste o valor asociado al arco (i,j) y se suelen denominar matrices de coste. Si el arco (i,j) no pertenece a A entonces se asigna bi,j o bi,j un valor que no puede ser utilizado como una etiqueta valida. La principal ventaja de la matriz de adyacencia es que el orden de eficiencia de las operaciones de obtencion de etiqueta de un arco o ver si dos vertices estan conectados son independientes del número de vértices y de arcos. Por el contrario, existen dos grandes inconvenientes: Es una representación orientada hacia grafos que no modifica el número de sus vertices ya que una matriz no permite que se le o supriman filas o columnas. Se puede producir un gran derroche de memoria en grafos poco densos (con gran número de vértices y escaso número de arcos). Para evitar estos inconvenientes se introduce otra representación: las listas de adyacencia. LISTAS DE ADYACENCIA. En esta estructura de datos la idea es asociar a cada vertice i del grafo una lista que contenga todos aquellos vértices j que sean adyacentes a él. De esta forma sóllo reservará memoria para los arcos adyacentes a i y no para todos los posibles arcos que pudieran tener como origen i. El grafo, por tanto, se representa por medio de un vector de n componentes (si |V|=n) donde cada componente va a ser una lista de adyacencia correspondiente a cada uno de los vertices del grafo. Cada elemento de la lista consta de un campo indicando el vértice adyacente. En caso de que el grafo sea etiquetado, habrá que añadir un segundo campo para mostrar el valor de la etiqueta. 115 Manual del Alumno Esta representacion requiere un espacio proporcional a la suma del número de vértices, más el nùmero de arcos, y se suele usar cuando el número de arcos es mucho menor que el número de arcos de un grafo completo. Una desventaja es que puede llevar un tiempo O(n) determinar si existe un arco del vértice i al vértice j, ya que puede haber n vertices en la lista de adyacencia asociada al vértice i. Mediante el uso del vector de listas de adyacencias sólo se reserva memoria para los arcos existentes en el grafo con el consiguiente ahorro de la misma. Sin embargo, no permite que 116 Manual del Alumno haya vértices que puedan ser añadidos o suprimidos del grafo, debido a que la dimension del grafo debe ser predeterminadoa y fija. Para solucionar esto se puede usar una lista de listas de adyacencia. Sólo los vértices del grafo que sean origen de algun arco aparecerán en la lista. De esta forma se pueden añadir y suprimir arcos sin desperdicio de memoria ya que simplemente habrá que modificar la lista de listas para reflejar los cambios. Como puede verse en el ejemplo de las figuras anteriores tanto el vector de listas de adyacencias como en la lista de listas se ha razonado en función de los vértices que actúan como origenes de los arcos. Análogamente se podía haber hecho con lod vertices destino, y combinando ambas representaciones podría pensarse en utilizar dos vectores de listas de adyacencia o dos listas de listas de adyacencia. REPRESENTACION PROPUESTA. La elección de una estructura idónea para representar el TDA grafo no es una tarea fácil ya que existen dos representaciones totalmente contrapuestas: por un lado tenemos la matriz de adyacencias que es muy eficiente para comprobar si existe una arista uniendo dos vertices peero que sin embargo desperdicia una gran cantidad de espacio si el grafo no es completo o esta lejos de serlo, además no tiene la posibilidad de añadir nuevos vértices; y por otra parte está la lista de adyacencias que no tiene el problema de la anterior respecto al espacio pero que sin embargo no es tan eficiente a la hora de ver si existe una arista entre dos nodos determinados. Teniendo en cuenta estas consideraciones se ha optado por realizar una mezcla de ambas representaciones intentando aprovechar de alguna forma las ventajas que ambas poseen. Por otra parte siguiendo con la idea de tratar tanto los grafos dirigidos como los no dirigidos bajo una misma estructura, la estructura elegida posee dos apariencias ligeramente diferentes para tratar de forma adecuada cada uno de estos dos tipos de grafos. La estructura consiste (en el caso de que tengamos un grafo dirigido en una lista de vértices donde cada uno de estos posee dos listas, una de aristas incidentes a él y otra de adyacentes. Cada vez que se añade una arista al grafo se inserta en la lista de aristas adyacentes del vertice origen y en la de incidentes del vértice destino. De esta forma la estructura desplegada se asemejaría a una matriz de adyacencia en la cual hay una arista por cada 1 y el índice de la matriz es la posición dentro de la lista de vertices. 117 Manual del Alumno Graficamente la estructura para un grafo dirigido queda como se puede apreciar en la siguiente figura.El puntero que de la estructura arco que apunta al destino se ha sustituido por la etiqueta del nodo destino en el grafico para simplificarlo y hacerlo mas claro. Esta estructura no seria la mas idonea si trabajamos con solo con grafos no dirigidos ya que por cada arista no dirigida tendriamos que insertar en la estructura una misma arista dirigida repetida dos veces (una con un vértice como origen y el otro como destino y al contrario). En muchos problemas si asumimos el desperdicio de espacio podria , de todas formas, resultar interesante representar un grafo no dirigido como un grafo dirigido simetrico, el problema se preesenta cuando al tener dos aristas dirigidas esto supone la presencia de un ciclo en el grafo que realmente no existe. 118 Manual del Alumno Teniendo en cuenta el razonamiento anterior, en el caso de que queramos manejar grafos no dirigido la estructura consistiria en tener una lista de adyacencia para cada uno de los vertices pero tratando aquellas aristas que aparecen en la lista de adyacencia de dos vertices distintos y que unen ambos vértices como una única arista lógica (a estas dos aristas que forman una misma arista lógica las llamaremos aristas gemelas). Estructuras Internas. Esta representacion tiene tres estructuras diferenciadas: Estructura correspondiente a un vértice. o o o o o nodo: Codigo interno que permite numerar los nodos de 1 a n. etiq: Puntero a caracter en el que se encuentra la información que posee ese vértice, es decir su etiqueta. ady: Es un puntero a una lista que contiene las aristas que tienen como origen ese vértice. inc: Es un puntero a una lista que contiene las aristas que tienen como destino ese vértice (solo para grafos dirigidos). sig: Es un puntero que apunta al vértice que ocupa la posicion siguiente dentro de la lista de vertices. Estructura básica del grafo. En realidad se usa la misma estructura que para los nodos pero poniendo los campos etiq, ady y sig a NULL. Los dos campos restantes contienen: o nodo: Contien el número de nodos del grafo. o sig: Es un puntero que apunta al vértice que ocupa la primera posicion dentro de la lista de vertices. Estructura correspondiente a una arista (grafo dirigido). o o o o origen: Es un puntero al vértice que es el origen de esa arista. destino: Es un puntero al vértice que es el destino de esa arista.(Nosotros hemos sustituido el puntero por la etiqueta del nodo destino para mayor claridad del dibujo). valor: Este campo contiene el peso de la arista que sera un numero entero. sig: Puntero que apunta a la siguente arista dentro de la lista de aristas adyacentes o incidentes. Estructuras Internas del TDA grafo. /* Implementacion basada en una lista de nodos de los que cuelga */ /* la lista de arcos de salida. */ #include #include #include #define TE 5 #define Nulo NULL typedef char *tetq; typedef float tvalor; 119 Manual del Alumno typedef struct arco { struct nodo *origen; struct nodo *destino; tvalor valor; struct arco *sig; } *tarco; typedef struct nodo { int nodo; tetq etiq; tarco ady; tarco inc; struct nodo *sig; } *tnodo; typedef tnodo tgrafo; 5. IMPLEMENTACIÓN DE EL TDA GRAFO. LISTA DE PRIMITIVAS. Lista de primitivas para los grafos dirigidos: Crear: Función que se encarga de crear un grafo vacio. Etiqueta: Funcion que devuelve la etiqueta asociada a un nodo en un grafo. Label: Funcion que devuelve la Label de un nodo en el grafo. LocalizaLabel: Esta función recibe el entero l (el label asociado a un nodo que se supone pertenece al grafo y nos devuelve el nodo asociado con esa label. ExisteArco: Función que devuelve 1 si existe un arco entre el nodo o y el nodo d en el grafo g, si no existe dicho arco devuelve 0. PrimerArco: Devuelve el primer arco que sale del nodo n en el grafo g, si no existe dicho primer arco devuelve Nulo. SiguienteArco: Función que devuelve el arco siguiente al arco a en el nodo n si no existe dicho arco devuelve Nulo. PrimerArcoInv: Devuelve el primer arco que entra en el nodo n en el grafo g. Si no existe dicho arco devuelve Nulo. SiguienteArcoInv: Devuelve el siguiente arco tras a que entra en el nodo n, si no existe dicho arco devuelve Nulo. PrimerNodo: Devuelve el primer nodo del grafo G, si no existe devuelve nulo. SiguienteNodo: Devuelve el nodo siguiente en orden al nodo n en el grafo g. Si no existe devuelve nulo. NodoOrigen: Devuelve el nodo origen del arco a. NodoDestino: Devuelve el nodo destino del arco a. presentarGrafo: Escribe el grafo g en pantalla. NumeroNodos: Devuelve el numero de nodos de un grafo g. grafoVacio: Devuelve Nulo si el grafo esta vacio. EtiqArco: Funcion que devuelve la etiqueta asociada a un arco, es decir el peso del arco. InsertarNodo: Funcion que inserta un nodo nuevo en un grafo. InsertarArco: Funcion que se encarga de insertar un arco entre el nodo org y el dest en el grafo g, asociado al arco le podemos dar un valor. BorrarArco: Funcion que borra el arco existente entre los nodos org y dest. DesconectarNodo: Función que devuelve el grafo que se obtiene al eliminar un nodo de un grafo G.Todos los arcos que entran o salen del nodo a eliminar tambien desaparecen. Destruir: Funcion que destruye el grafo g liberando la memoria que ocupa. CopiarGrafo: Funcion que hace una copia del grafo g. 120 Manual del Alumno IMPLEMENTACIÓN DE LAS PRIMITIVAS. tgrafo Crear(void) { tnodo aux; aux = (tnodo)malloc(sizeof(struct nodo)); if (aux == NULL) { error(\"Error en Crear.\"); } else { aux->nodo = 0; aux->etiq = NULL; aux->ady = NULL; aux->inc = NULL; aux->sig = NULL; return aux; } } tetiq Etiqueta(tnodo n, tgrafo g) { return(n->etiq); } int Label(tnodo n, tgrafo g) { return(n->nodo); } tnodo LocalizaLabel(int l, tgrafo g) { tnodo n; int enc=0; for (n=g->sig; n!=NULL && !enc; ) { if (n->nodo == l) enc = 1; else n = n->sig; } return n; } int ExisteArco(tnodo o, tnodo d, tgrafo g) { tarco a; a=o->ady; while (a!=NULL) { if ((a->origen==o) && (a->destino==d)) return 1; 121 Manual del Alumno else a = a->sig; } return 0; } tarco PrimerArco(tnodo n, tgrafo g) { return(n->ady); } tarco SiguienteArco(tnodo n, tarco a, tgrafo g) { return(a->sig); } tarco PrimerArcoInv(tnodo n, tgrafo g) { return(n->inc); } tarco SiguienteArcoInv(tnodo n, tarco a, tgrafo g) { return(a->sig); } tnodo PrimerNodo(tgrafo g) { return(g->sig); } tnodo SiguienteNodo(tnodo n, tgrafo g) { return(n->sig); } tnodo NodoOrigen(tarco a, tgrafo g) { return(a->origen); } tnodo NodoDestino(tarco a, tgrafo g) { return(a->destino); } 122 Manual del Alumno void PresentarGrafo(tgrafo g) { tnodo n; tarco a; n=PrimerNodo(g); while (n!=Nulo) { a=PrimerArco(n,g); while (a!=Nulo) { printf(\"%s -> %s \",a->origen->etiq,a->destino->etiq); printf(\" (%f)\\n\",a->valor); a=SiguienteArco(n,a,g); } n=SiguienteNodo(n,g); } } int NumeroNodos(tgrafo g) { return(g->nodo); } int GrafoVacio(tgrafo g) { return(g->sig == NULL); } float EtiqArco(tnodo o, tnodo d, tgrafo g) { tarco a; a=o->ady; while (a!=NULL) { if ((a->origen == o) && (a->destino == d)) return (a->valor); else a = a->sig; } return 0; } void InsertarNodo(tetq dato, tgrafo g) { tnodo aux,p; aux = (tnodo)malloc(sizeof(struct nodo)); if (aux == NULL) error(\"Error Memoria Insuficiente.\"); else { p=g; while(p->sig != NULL) p = p->sig; aux->etiq = (char *)malloc(sizeof (char)*TE);"+ 123 Manual del Alumno if (aux->etiq == NULL) error(\"Error Memoria Insuficiente.\"); aux->nodo = p->nodo+1; strcpy(aux->etiq,dato);+ aux->ady = NULL; aux->inc = NULL; aux->sig = NULL; p->sig = aux; g->nodo++; } } void InsertarArco (tnodo org,tnodo dest,tvalor valor,tgrafo g) { tarco aux; tarco aux_inv; aux = (tarco)malloc(sizeof(struct arco)); aux_inv= (tarco)malloc(sizeof(struct arco)); if ((aux==NULL) || (aux_inv==NULL)) error("Memoria Insuficiente."); else { aux->origen = org; aux->destino = dest; aux->valor = valor; aux-> sig= org->ady; org->ady = aux; aux_inv->origen = org; aux_inv->destino = dest; aux_inv-> valor= valor; aux_inv-> sig= dest->inc; des_inc-> = aux_inv; } } void BorrarArco(tnodo org, tnodo dest, tgrafo g) { tarco a,ant; int enc=0; if (org->ady==NULL) return; else if (org->ady->destino==dest) { a = org->ady; org->ady = a->sig; free(a); } else { ant = org->ady; a = ant->sig; while (!enc && (a!=NULL)) { if (a->destino==dest) enc=1; else { a = a->sig; ant = ant->sig; } } 124 Manual del Alumno if (a==NULL) return; else { ant->sig = a->sig; free(a); } } enc=0; if (dest->inc==NULL) return; else if (dest->inc->origen==org) { a = dest->inc; dest->inc = a->sig; free(a); } else { ant = dest->inc; a = ant->sig; while (!enc && (a!=NULL)) { if (a->origen == org) enc=1; else { a = a->sig; ant = ant->sig; } } if (a==NULL) return; else { ant->sig = a->sig; free(a); } } } void Destruir(tgrafo G) { tnodo n; tarco a_aux; while (g->sig != NULL) { n = g->sig; while (n->ady != NULL) { a_aux = n->ady; n->ady = a_aux->sig; free(a_aux); } while (n->inc != NULL) { a_aux = n->inc; n->inc = a_aux->sig; free(a_aux); } g->sig = n->sig; free(n->etiq); free(n); } free(g); } tgrafo DesconectarNodo(tnodo a_eliminar,tgeafo g) 125 Manual del Alumno { tgrafo g_nd; tnodo n; tnodo org;dst; tnodo o,d; tarco a; g_nd = Crear(); for (n=PrimerNodo(g); n!=NULL; n=SiguienteNodo(n,g)) InsertarNodo(Etiqueta(n,g),g_nd); for (n=PrimerNodo(g); n!=NULL; n=SiguienteNodo(n,g)) for (a=PrimerArco(n,g); a!=NULL; a=SiguienteArco(n,a,g)) { org = NodoOrigen(a,g); dst = NodoDestino(a,g); if ((org!=a_eliminar) && dst!=a_eliminar)) { o = LocalizaLabel(Label(org,g), g_nd); d = LocalizaLabel(Label(dst,g), g_nd); InsertarArco(o,d,g_nd); } } return g_nd; } tgrafo CopiarGrafo(tgrafo g) { tgrafo g_nd; tnodo n; tnodo org;dst; tnodo o,d; tarco a; int lb; g_nd = Crear(); for (n=PrimerNodo(g); n!=NULL; n=SiguienteNodo(n,g)) InsertarNodo(Etiqueta(n,g),g_nd); for (n=PrimerNodo(g); n!=NULL; n=SiguienteNodo(n,g)) for (a=PrimerArco(n,g); a!=NULL; a=SiguienteArco(n,a,g)) { org = NodoOrigen(a,g); dst = NodoDestino(a,g); o = LocalizaLabel(Label(org,g), g_nd); d = LocalizaLabel(Label(dst,g), g_nd); InsertarArco(o,d,g_nd); } } return g_nd; } 126 Manual del Alumno GRAFOS 1. INTRODUCCIÓN. El origen de la palabra grafo es griego y su significado etimológico es "trazar". Aparece con gran frecuencia como respuesta a problemas de la vida cotidiana,algunos ejemplos podrían ser los siguientes:un gráfico de una serie de tareas a realizar indicando su secuenciación (un organigrama),grafos matem´ticos que representan las relaciones binarias,una red de carreteras,la red de enlaces ferroviarios o aéreos o la red eléctrica de una ciudad.(Véase la figura 1).En cada caso,es conveniente representar gráficamente el problema dibujando un grafo como un conjunto de puntos(vértices)con líneas conectándolos (arcos). De aquí se podría deducir que un grafo es básicamente un objeto geométrico aunque en realidad sea un objeto combinatorio,es decir,un conjunto de puntos y un conjunto de líneas tomado de entre el conjunto de líneas que une cada par de vértices.Por otro lado,y debido a su generalidad y a la gran diversidad de formas que pueden usarse,resulta complejo tratar con todas las ideas relacionadas con un grafo. Para facilitar el estudio de este tipo de dato,a continuación se realizará un estudio de la teoría de grafos desde el punto de vista de las ciencias de la computación. Considerando que dicha teoría es compleja y amplia,aquí sólo se realizará una introducción a la misma,describiéndose el grafo como un tipo de dato y mostrándose los problemas típicos y los algoritmos que permiten solucionarlos usando un ordenador. Los grafos son estructuras de datos no lineales que tienen una naturaleza generalmente dinámica. Su estudio podría dividirse en dos grandes bloques: Grafos Dirigidos. Grafos no Dirigidos(pueden ser considerados un caso particular de los anteriores). Un ejemplo de grafo dirigido lo constituye la red de aguas de una ciudad ya que cada tubería sólo admite que el agua la recorra en un único sentido.Por el contrario,la red de carreteras de 127 Manual del Alumno un país representa en general un grafo no dirigido,puesto que una misma carretera puede ser recorrida en ambos sentidos.No obstante,podemos dar unas definiciones generales para ambos tipos. A continuación daremos definiciones de los dos tipos de grafos y de los conceptos que llevan asociados. 2. DEFINICIONES Y TERMINOLOGÍA FUNDAMENTAL. Un grafo G es un conjunto en el que hay definida una relación binaria,es decir,G=(V,A) tal que V es un conjunto de objetos a los que denominaremos vértices o nodos y relación binaria a cuyos elementos denominaremos arcos o aristas. Dados ,puede ocurrir que: 1. , en cuyo caso diremos que x e y están unidos mediante un arco,y 2. , en cuyo caso diremos que no lo están. es una Si las aristas tienen asociada una dirección(las aristas (x,y) y (y,x) no son equivalentes) diremos que el grafo es dirigido,en otro caso ((x,y)=(y,x)) diremos que el grafo es no dirigido. Conceptos asociados a grafos: Diremos que un grafo es completo si A=VxV,o sea,si para cualquier pareja de vértices existe una arista que los une(en ambos sentidos si el grafo es no dirigido).El número de aristas será: o grafos dirigidos: o grafos no dirigidos: 128 Manual del Alumno donde n=|V| Un grafo dirigido es simétrico si para toda arista (x,y)perteneciente a A también aparece la arista (y,x)perteneciente a A;y es antisimétrico si dada una arista (x,y) perteneciente a A implica que (y,x) no pertenece a A. Tanto a las aristas como a los vértices les puede ser asociada información.A esta información se le llama etiqueta.Si la etiqueta que se asocia es un número se le llama peso,costo o longitud.Un grafo cuyas aristas o vértices tienen pesos asociados recibe el nombre de grafo etiquetado o ponderado. El número de elementos de V se denomina orden del grafo.Un grafo nulo es un grafo de orden cero. Se dice que un vértice x es incidente a un vértice y si existe un arco que vaya de x a y ((x,y)pertenece a A),a x se le denomina origen del arco y a y extremo del mismo.De igual forma se dirá que y es adyacente a x.En el caso de que el grafo sea no dirigido si x es adyacente(resp. incidente) a y entonces y también es adyacente (resp. incidente) a x. Se dice que dos arcos son adyacentes cuando tienen un vértice común que es a la vez origen de uno y extremo del otro. Se denomina camino (algunos autores lo llaman cadena si se trata de un grafo no dirigido)en un grafo dirigido a una sucesión de arcos adyacentes: C={(v1,v2),(v2,v3),...,(vn-1,vn), para todo vi perteneciente a V} La longitud del camino es el número de arcos que comprende y en el caso en el que el grafo sea ponderado se calculará como la suma de los pesos de las aristas que lo constituyen. Ejemplo. o En el grafo dirigido de la figura 2,un camino que une los vértices 1 y 4 es C= {(1,3),(3,2),(2,1)},su longitud es 3. o En el grafo no dirigido de la figura 2,un camino que une los vértices 1 y 4 es C'= {(1,2),(2,4)}.Su longitud es 2. Un camino se dice simple cuando todos sus arcos son distintos y se dice elemental cuando no utiliza un mismo vértice dos veces.Por tanto todo camino elemental es simple y el recíproco no es cierto. Un camino se dice Euleriano si es simple y además contiene a todos los arcos del grafo. Un circuito(o ciclo para grafos no dirigidos)es un camino en el que coinciden los vértices inicial y final.Un circuito se dice simple cuando todos los arcos que lo forman son distintos y se dice elemental cuando todos los vértices por los que pasa son distintos.La longitud de un circuito es el número de arcos que lo componen.Un bucle es un circuito de longitud 1(están permitidos los arcos de la forma(i,i) y notemos que un grafo antisimétrico carecería de ellos). Un circuito elemental que incluye a todos los vértices de un grafo lo llamaremos circuito Hamiltoniano. Un grafo se denomina simple si no tiene bucles y no existe más que un camino para unir dos nodos. 129 Manual del Alumno Diremos que un grafo no dirigido es bipartido si el conjunto de sus vértices puede ser dividido en dos subconjuntos(disjuntos) de tal forma que cualquiera de las aristas que componen el grafo tiene cada uno de sus extremos en un subconjunto distinto.Un grafo no dirigido será bipartido si y sólo si no contiene ciclos con un número de aristas par. Dado un grafo G=(V,A),diremos que G'=(V,A') con es un grafo parcial de G y ' ' ' un subgrafo de G es todo grafo G =(V ,A ) con y donde A' será el conjunto de todas aquellas aristas que unían en el grafo G dos vértices que están en V'. Se podrían combinar ambas definiciones dando lugar a lo que llamaremos subgrafo parcial Se denomina grado de entrada de un vértice x al número de arcos incidentes en él.Se denota . Se denomina grado de salida de un vértice x al número de arcos adyacentes a él.Se denota . Para grafos no dirigidos tanto el grado de entrada como el de salida coinciden y hablamos entonces de grado y lo notamos por . A todo grafo no dirigido se puede asociar un grafo denominado dual construido de la siguiente forma: donde A' está construido de la siguiente forma:si e1,e2 pertenece a A son adyacentes -> (e1,e2)pertenece a A' con e1,e2 pertenece a V'.En definitiva,para construir un grafo dual se cambian vértices por aristas y viceversa. 130 Manual del Alumno Dado un grafo G,diremos que dos vértices están conectados si entre ambos existe un camino que los une. Llamaremos componente conexa a un conjunto de vértices de un grafo tal que entre cada par de vértices hay al menos un camino y si se añade algún otro vértice esta concición deja de verificarse.Matemáticamente se puede ver como que la conexión es una relación de equivalencia que descompone a V en clases de equivalencia,cada uno de los subgrafos a los que da lugar cada una de esas clases de equivalencia constituiría una componente conexa.Un grafo diremos que es conexo si sólo existe una componente conexa que coincide con todo el grafo. . 3. TDA GRAFO. A la hora de diseñar el TDA grafo hay que tener en cuenta que hay que manejar datos correspondientes a sus vértices y aristas,pudiendo cada uno de ellos estar o no etiquetados.Además hay que proporcionar operaciones primitivas que permitan manejar el tipo de dato sin necesidad de conocer la implementación.Así,los tipos de datos que se usarán y las operaciones primitivas consideradas son las siguientes: NUEVOS TIPOS APORTADOS. Los nuevos tipos aportados por el TDA grafo son los siguientes: grafo. vertice. arista. 4. REPRESENTACIONES PARA EL TDA GRAFO. Existen diversas representaciones de naturaleza muy diferente que resultan adecuadas para manejar un grafo,y en la mayoría de los casos no se puede decir que una sea mejor que otra siempre ya que cada una puede resultar más adecuada dependiendo del problema concreto al que se desea aplicar.Así,si existe una representación que es peor que otra para todas las operaciones excepto una es posible que aún así nos decantemos por la primera porque precisamente esa operación es la única en la que tenemos especial interés en que se realice de forma eficiente.A continuación veremos dos de las representaciones más usuales:Matriz de adyacencia(o booleana) y Lista de adyacencia. 131 Manual del Alumno MATRIZ DE ADYACENCIA. Grafos dirigidos. G=(V,A) un grafo dirigido con |V|=n .Se define la matriz de adyacencia o booleana asociada a G como Bnxn con Como se ve,se asocia cada fila y cada columna a un vértice y los elementos bi,j de la matriz son 1 si existe el arco (i,j) y 0 en caso contrario. Grafos no dirigidos. G=(V,A) un grafo no dirigido con |V|=n .Se define la matriz de adyacencia o booleana asociada a G como Bnxn con: La matriz B es simetrica con 1 en las posiciones ij y ji si existe la arista (i,j). EJEMPLO: 132 Manual del Alumno Si el grafo es etiquetado,entonces tanto bi,j como bi,j representan al coste o valor asociado al arco (i,j) y se suelen denominar matrices de coste. Si el arco (i,j) no pertenece a A entonces se asigna bi,j o bi,j un valor que no puede ser utilizado como una etiqueta valida. La principal ventaja de la matriz de adyacencia es que el orden de eficiencia de las operaciones de obtencion de etiqueta de un arco o ver si dos vertices estan conectados son independientes del número de vértices y de arcos. Por el contrario, existen dos grandes inconvenientes: Es una representación orientada hacia grafos que no modifica el número de sus vertices ya que una matriz no permite que se le o supriman filas o columnas. Se puede producir un gran derroche de memoria en grafos poco densos (con gran número de vértices y escaso número de arcos). Para evitar estos inconvenientes se introduce otra representación: las listas de adyacencia. LISTAS DE ADYACENCIA. En esta estructura de datos la idea es asociar a cada vertice i del grafo una lista que contenga todos aquellos vértices j que sean adyacentes a él. De esta forma sóllo reservará memoria para los arcos adyacentes a i y no para todos los posibles arcos que pudieran tener como origen i. El grafo, por tanto, se representa por medio de un vector de n componentes (si |V|=n) donde cada componente va a ser una lista de adyacencia correspondiente a cada uno de los vertices del grafo. Cada elemento de la lista consta de un campo indicando el vértice adyacente. En caso de que el grafo sea etiquetado, habrá que añadir un segundo campo para mostrar el valor de la etiqueta. 133 Manual del Alumno Esta representacion requiere un espacio proporcional a la suma del número de vértices, más el nùmero de arcos, y se suele usar cuando el número de arcos es mucho menor que el número de arcos de un grafo completo. Una desventaja es que puede llevar un tiempo O(n) determinar si existe un arco del vértice i al vértice j, ya que puede haber n vertices en la lista de adyacencia asociada al vértice i. Mediante el uso del vector de listas de adyacencias sólo se reserva memoria para los arcos existentes en el grafo con el consiguiente ahorro de la misma. Sin embargo, no permite que 134 Manual del Alumno haya vértices que puedan ser añadidos o suprimidos del grafo, debido a que la dimension del grafo debe ser predeterminadoa y fija. Para solucionar esto se puede usar una lista de listas de adyacencia. Sólo los vértices del grafo que sean origen de algun arco aparecerán en la lista. De esta forma se pueden añadir y suprimir arcos sin desperdicio de memoria ya que simplemente habrá que modificar la lista de listas para reflejar los cambios. Como puede verse en el ejemplo de las figuras anteriores tanto el vector de listas de adyacencias como en la lista de listas se ha razonado en función de los vértices que actúan como origenes de los arcos. Análogamente se podía haber hecho con lod vertices destino, y combinando ambas representaciones podría pensarse en utilizar dos vectores de listas de adyacencia o dos listas de listas de adyacencia. REPRESENTACION PROPUESTA. La elección de una estructura idónea para representar el TDA grafo no es una tarea fácil ya que existen dos representaciones totalmente contrapuestas: por un lado tenemos la matriz de adyacencias que es muy eficiente para comprobar si existe una arista uniendo dos vertices peero que sin embargo desperdicia una gran cantidad de espacio si el grafo no es completo o esta lejos de serlo, además no tiene la posibilidad de añadir nuevos vértices; y por otra parte está la lista de adyacencias que no tiene el problema de la anterior respecto al espacio pero que sin embargo no es tan eficiente a la hora de ver si existe una arista entre dos nodos determinados. Teniendo en cuenta estas consideraciones se ha optado por realizar una mezcla de ambas representaciones intentando aprovechar de alguna forma las ventajas que ambas poseen. Por otra parte siguiendo con la idea de tratar tanto los grafos dirigidos como los no dirigidos bajo una misma estructura, la estructura elegida posee dos apariencias ligeramente diferentes para tratar de forma adecuada cada uno de estos dos tipos de grafos. La estructura consiste (en el caso de que tengamos un grafo dirigido en una lista de vértices donde cada uno de estos posee dos listas, una de aristas incidentes a él y otra de adyacentes. Cada vez que se añade una arista al grafo se inserta en la lista de aristas adyacentes del vertice origen y en la de incidentes del vértice destino. De esta forma la estructura desplegada se asemejaría a una matriz de adyacencia en la cual hay una arista por cada 1 y el índice de la matriz es la posición dentro de la lista de vertices. 135 Manual del Alumno Graficamente la estructura para un grafo dirigido queda como se puede apreciar en la siguiente figura.El puntero que de la estructura arco que apunta al destino se ha sustituido por la etiqueta del nodo destino en el grafico para simplificarlo y hacerlo mas claro. Esta estructura no seria la mas idonea si trabajamos con solo con grafos no dirigidos ya que por cada arista no dirigida tendriamos que insertar en la estructura una misma arista dirigida repetida dos veces (una con un vértice como origen y el otro como destino y al contrario). En muchos problemas si asumimos el desperdicio de espacio podria , de todas formas, resultar interesante representar un grafo no dirigido como un grafo dirigido simetrico, el problema se preesenta cuando al tener dos aristas dirigidas esto supone la presencia de un ciclo en el grafo que realmente no existe. 136 Manual del Alumno Teniendo en cuenta el razonamiento anterior, en el caso de que queramos manejar grafos no dirigido la estructura consistiria en tener una lista de adyacencia para cada uno de los vertices pero tratando aquellas aristas que aparecen en la lista de adyacencia de dos vertices distintos y que unen ambos vértices como una única arista lógica (a estas dos aristas que forman una misma arista lógica las llamaremos aristas gemelas). Estructuras Internas. Esta representacion tiene tres estructuras diferenciadas: Estructura correspondiente a un vértice. o o o o o nodo: Codigo interno que permite numerar los nodos de 1 a n. etiq: Puntero a caracter en el que se encuentra la información que posee ese vértice, es decir su etiqueta. ady: Es un puntero a una lista que contiene las aristas que tienen como origen ese vértice. inc: Es un puntero a una lista que contiene las aristas que tienen como destino ese vértice (solo para grafos dirigidos). sig: Es un puntero que apunta al vértice que ocupa la posicion siguiente dentro de la lista de vertices. Estructura básica del grafo. En realidad se usa la misma estructura que para los nodos pero poniendo los campos etiq, ady y sig a NULL. Los dos campos restantes contienen: o nodo: Contien el número de nodos del grafo. o sig: Es un puntero que apunta al vértice que ocupa la primera posicion dentro de la lista de vertices. Estructura correspondiente a una arista (grafo dirigido). o o o o origen: Es un puntero al vértice que es el origen de esa arista. destino: Es un puntero al vértice que es el destino de esa arista.(Nosotros hemos sustituido el puntero por la etiqueta del nodo destino para mayor claridad del dibujo). valor: Este campo contiene el peso de la arista que sera un numero entero. sig: Puntero que apunta a la siguente arista dentro de la lista de aristas adyacentes o incidentes. Estructuras Internas del TDA grafo. /* Implementacion basada en una lista de nodos de los que cuelga */ /* la lista de arcos de salida. */ #include #include #include #define TE 5 #define Nulo NULL typedef char *tetq; typedef float tvalor; 137 Manual del Alumno typedef struct arco { struct nodo *origen; struct nodo *destino; tvalor valor; struct arco *sig; } *tarco; typedef struct nodo { int nodo; tetq etiq; tarco ady; tarco inc; struct nodo *sig; } *tnodo; typedef tnodo tgrafo; 5. IMPLEMENTACIÓN DE EL TDA GRAFO. LISTA DE PRIMITIVAS. Lista de primitivas para los grafos dirigidos: Crear: Función que se encarga de crear un grafo vacio. Etiqueta: Funcion que devuelve la etiqueta asociada a un nodo en un grafo. Label: Funcion que devuelve la Label de un nodo en el grafo. LocalizaLabel: Esta función recibe el entero l (el label asociado a un nodo que se supone pertenece al grafo y nos devuelve el nodo asociado con esa label. ExisteArco: Función que devuelve 1 si existe un arco entre el nodo o y el nodo d en el grafo g, si no existe dicho arco devuelve 0. PrimerArco: Devuelve el primer arco que sale del nodo n en el grafo g, si no existe dicho primer arco devuelve Nulo. SiguienteArco: Función que devuelve el arco siguiente al arco a en el nodo n si no existe dicho arco devuelve Nulo. PrimerArcoInv: Devuelve el primer arco que entra en el nodo n en el grafo g. Si no existe dicho arco devuelve Nulo. SiguienteArcoInv: Devuelve el siguiente arco tras a que entra en el nodo n, si no existe dicho arco devuelve Nulo. PrimerNodo: Devuelve el primer nodo del grafo G, si no existe devuelve nulo. SiguienteNodo: Devuelve el nodo siguiente en orden al nodo n en el grafo g. Si no existe devuelve nulo. NodoOrigen: Devuelve el nodo origen del arco a. NodoDestino: Devuelve el nodo destino del arco a. presentarGrafo: Escribe el grafo g en pantalla. NumeroNodos: Devuelve el numero de nodos de un grafo g. grafoVacio: Devuelve Nulo si el grafo esta vacio. EtiqArco: Funcion que devuelve la etiqueta asociada a un arco, es decir el peso del arco. InsertarNodo: Funcion que inserta un nodo nuevo en un grafo. InsertarArco: Funcion que se encarga de insertar un arco entre el nodo org y el dest en el grafo g, asociado al arco le podemos dar un valor. BorrarArco: Funcion que borra el arco existente entre los nodos org y dest. DesconectarNodo: Función que devuelve el grafo que se obtiene al eliminar un nodo de un grafo G.Todos los arcos que entran o salen del nodo a eliminar tambien desaparecen. Destruir: Funcion que destruye el grafo g liberando la memoria que ocupa. CopiarGrafo: Funcion que hace una copia del grafo g. 138 Manual del Alumno IMPLEMENTACIÓN DE LAS PRIMITIVAS. tgrafo Crear(void) { tnodo aux; aux = (tnodo)malloc(sizeof(struct nodo)); if (aux == NULL) { error(\"Error en Crear.\"); } else { aux->nodo = 0; aux->etiq = NULL; aux->ady = NULL; aux->inc = NULL; aux->sig = NULL; return aux; } } tetiq Etiqueta(tnodo n, tgrafo g) { return(n->etiq); } int Label(tnodo n, tgrafo g) { return(n->nodo); } tnodo LocalizaLabel(int l, tgrafo g) { tnodo n; int enc=0; for (n=g->sig; n!=NULL && !enc; ) { if (n->nodo == l) enc = 1; else n = n->sig; } return n; } int ExisteArco(tnodo o, tnodo d, tgrafo g) { tarco a; a=o->ady; while (a!=NULL) { if ((a->origen==o) && (a->destino==d)) return 1; 139 Manual del Alumno else a = a->sig; } return 0; } tarco PrimerArco(tnodo n, tgrafo g) { return(n->ady); } tarco SiguienteArco(tnodo n, tarco a, tgrafo g) { return(a->sig); } tarco PrimerArcoInv(tnodo n, tgrafo g) { return(n->inc); } tarco SiguienteArcoInv(tnodo n, tarco a, tgrafo g) { return(a->sig); } tnodo PrimerNodo(tgrafo g) { return(g->sig); } tnodo SiguienteNodo(tnodo n, tgrafo g) { return(n->sig); } tnodo NodoOrigen(tarco a, tgrafo g) { return(a->origen); } tnodo NodoDestino(tarco a, tgrafo g) { return(a->destino); } 140 Manual del Alumno void PresentarGrafo(tgrafo g) { tnodo n; tarco a; n=PrimerNodo(g); while (n!=Nulo) { a=PrimerArco(n,g); while (a!=Nulo) { printf(\"%s -> %s \",a->origen->etiq,a->destino->etiq); printf(\" (%f)\\n\",a->valor); a=SiguienteArco(n,a,g); } n=SiguienteNodo(n,g); } } int NumeroNodos(tgrafo g) { return(g->nodo); } int GrafoVacio(tgrafo g) { return(g->sig == NULL); } float EtiqArco(tnodo o, tnodo d, tgrafo g) { tarco a; a=o->ady; while (a!=NULL) { if ((a->origen == o) && (a->destino == d)) return (a->valor); else a = a->sig; } return 0; } void InsertarNodo(tetq dato, tgrafo g) { tnodo aux,p; aux = (tnodo)malloc(sizeof(struct nodo)); if (aux == NULL) error(\"Error Memoria Insuficiente.\"); else { p=g; while(p->sig != NULL) p = p->sig; aux->etiq = (char *)malloc(sizeof (char)*TE);"+ 141 Manual del Alumno if (aux->etiq == NULL) error(\"Error Memoria Insuficiente.\"); aux->nodo = p->nodo+1; strcpy(aux->etiq,dato);+ aux->ady = NULL; aux->inc = NULL; aux->sig = NULL; p->sig = aux; g->nodo++; } } void InsertarArco (tnodo org,tnodo dest,tvalor valor,tgrafo g) { tarco aux; tarco aux_inv; aux = (tarco)malloc(sizeof(struct arco)); aux_inv= (tarco)malloc(sizeof(struct arco)); if ((aux==NULL) || (aux_inv==NULL)) error("Memoria Insuficiente."); else { aux->origen = org; aux->destino = dest; aux->valor = valor; aux-> sig= org->ady; org->ady = aux; aux_inv->origen = org; aux_inv->destino = dest; aux_inv-> valor= valor; aux_inv-> sig= dest->inc; des_inc-> = aux_inv; } } void BorrarArco(tnodo org, tnodo dest, tgrafo g) { tarco a,ant; int enc=0; if (org->ady==NULL) return; else if (org->ady->destino==dest) { a = org->ady; org->ady = a->sig; free(a); } else { ant = org->ady; a = ant->sig; while (!enc && (a!=NULL)) { if (a->destino==dest) enc=1; else { a = a->sig; ant = ant->sig; } } 142 Manual del Alumno if (a==NULL) return; else { ant->sig = a->sig; free(a); } } enc=0; if (dest->inc==NULL) return; else if (dest->inc->origen==org) { a = dest->inc; dest->inc = a->sig; free(a); } else { ant = dest->inc; a = ant->sig; while (!enc && (a!=NULL)) { if (a->origen == org) enc=1; else { a = a->sig; ant = ant->sig; } } if (a==NULL) return; else { ant->sig = a->sig; free(a); } } } void Destruir(tgrafo G) { tnodo n; tarco a_aux; while (g->sig != NULL) { n = g->sig; while (n->ady != NULL) { a_aux = n->ady; n->ady = a_aux->sig; free(a_aux); } while (n->inc != NULL) { a_aux = n->inc; n->inc = a_aux->sig; free(a_aux); } g->sig = n->sig; free(n->etiq); free(n); } free(g); } tgrafo DesconectarNodo(tnodo a_eliminar,tgeafo g) 143 Manual del Alumno { tgrafo g_nd; tnodo n; tnodo org;dst; tnodo o,d; tarco a; g_nd = Crear(); for (n=PrimerNodo(g); n!=NULL; n=SiguienteNodo(n,g)) InsertarNodo(Etiqueta(n,g),g_nd); for (n=PrimerNodo(g); n!=NULL; n=SiguienteNodo(n,g)) for (a=PrimerArco(n,g); a!=NULL; a=SiguienteArco(n,a,g)) { org = NodoOrigen(a,g); dst = NodoDestino(a,g); if ((org!=a_eliminar) && dst!=a_eliminar)) { o = LocalizaLabel(Label(org,g), g_nd); d = LocalizaLabel(Label(dst,g), g_nd); InsertarArco(o,d,g_nd); } } return g_nd; } tgrafo CopiarGrafo(tgrafo g) { tgrafo g_nd; tnodo n; tnodo org;dst; tnodo o,d; tarco a; int lb; g_nd = Crear(); for (n=PrimerNodo(g); n!=NULL; n=SiguienteNodo(n,g)) InsertarNodo(Etiqueta(n,g),g_nd); for (n=PrimerNodo(g); n!=NULL; n=SiguienteNodo(n,g)) for (a=PrimerArco(n,g); a!=NULL; a=SiguienteArco(n,a,g)) { org = NodoOrigen(a,g); dst = NodoDestino(a,g); o = LocalizaLabel(Label(org,g), g_nd); d = LocalizaLabel(Label(dst,g), g_nd); InsertarArco(o,d,g_nd); } } return g_nd; } 144 Manual del Alumno EJERCICIOS DE ÁRBOLES GENERALES Ejercicio No 1: Escribir una función para calcular la altura de un árbol cualquiera. Ejercicio No 2: Escribir una función no recursiva para calcular la altura de un árbol cualquiera Ejercicio No 3: Responder a las siguientes preguntas sobre el árbol siguiente: 1. ¿Qué nodo es la raíz? 2. ¿Cuántos caminos diferentes de longitud tres hay? 3. ¿Es un camino la sucesión de nodos HGFBACI? 4. ¿Qué nodos son los ancestros de K? 5. ¿Qué nodos son los ancestros propios de N? 6. ¿Qué nodos son los descendientes propios de M? 7. ¿Qué nodos son las hojas? 8. ¿Cuál es la altura del nodo C? 9. ¿Cuál es la altura del árbol? 10. ¿Cuál es la profundidad del nodo C? 11. ¿Cuál es el hermano a la derecha de D? 12. ¿Es I hermano a la derecha de F? 13. ¿Está F a la izquierda de J? 14. ¿Está L a la derecha de J? 15. ¿Qué nodos están a la izquierda y a la derecha de J? 16. ¿Cuántos hijos tiene A? 17. Listar los nodos del árbol en preorden,postorden e inorden. 145 Manual del Alumno Ejercicio No 4: Considerando la función de listado en preorden de un árbol general que se ha presentado en la página web referente a árboles generales,escribir dos funciones de escritura y lectura de un árbol con etiquetas de tipo entero usando la misma estructura recursiva.Supóngase que se dispone de dos funciones de escritura y lectura (Escribir y Leer respectivamente)así como de una variable FINAL tal como se indicó en teoría de árboles generales EJERCICIOS DE ÁRBOLES BINARIOS Ejercicio No 1: Supongamos que tenemos una función valor tal que dado un valor de tipo char (una letra del alfabeto)devuelve un valor entero asociado a dicho identificador.Supongamos tambien la existencia de un árbol de expresión T cuyos nodos hoja son letras del alfabeto y cuyos nodos interiores son los caracteres *,+,-,/.Diseñar una función que tome como parámetros un nodo y un árbol binario y devuelva el resultado entero de la evaluación de la expresión representada. Ejercicio No 2: El recorrido en preorden de un determinado árbol binario es: GEAIBMCLDFKJH y en inorden IABEGLDCFMKHJ .Resolver: A)Dibujar el árbol binario. B)Dar el recorrido en postorden. C)Diseñar una función para dar el recorrido en postorden dado el recorrido en preorden e inorden y escribir un programa para comprobar el resultado del apartado anterior Ejercicio No 3: Implementar una función no recursiva para recorrer un árbol binario en inorden Ejercicio No 4: Implementar una función no recursiva para recorrer un árbol binario en postorden. Ejercicio No 5: Escribir una función que realice la reflexión de un árbol binario. Ejercicio No 6: Escribir una función recursiva que encuentre el número de nodos de un árbol binario. Ejercicio No 7: Escribir una función recursiva que encuentre la altura de un árbol binario EJERCICIOS DE ÁRBOLES BINARIOS DE BÚSQUEDA 146 Manual del Alumno Ejercicio No 1: ¿Puede reconstruirse de forma única un ABB dado su inorden? ¿Y dados el preorden y el postorden? Ejercicio No 2: Construir un ABB con las claves 50,25,75,10,40,60,90,35,45,70,42. Ejercicio No 3: Construir un ABB equilibrado a partir de las claves 10,75,34,22,64,53,41,5,25,74,20,15,90. Ejercicio No 4: ¿Bajo qué condiciones puede un árbol ser parcialmente ordenado y binario de búsqueda simultáneamente?Razonar la respuesta. EJERCICIOS DE ÁRBOLES AVL Ejercicio No 1: Dada la secuencia de claves enteras:100,29,71,82,48,39,101,22,46, 17,3,20,25,10.Representar gráficamente el árbol AVL correspondiente. Elimine claves consecutivamente hasta encontrar un desequilibrio y dibuje la estructura del árbol tras efectuarse la oportuna restauración Ejercicio No 2: Obtener la secuencia de rotaciones resultante de la inserción del conjunto de elementos {1,2,3,4,5,6,7,15,14,13,12,11,10,9,8} en un árbol AVL Ejercicio No 3: Inserte las claves en el orden indicado a fin de incorporarlas a un árbol AVL. 1. 10,100,20,80,40,70. 2. 5,10,20,30,40,50,60. EJERCICIOS DE ÁRBOLES APO Ejercicio No 1: Construir un APO con las claves 50,25,75,10,40,60,90,35,45,70,42. 147 Manual del Alumno Ejercicio No 2: Construir el TDA APO a partir del TDA pila. Ejercicio No 3: ¿Puede construirse un APO de forma unívoca dado su recorrido en preorden? EJERCICIOS DE ÁRBOLES B ,B* y B+ Ejercicio No 1: Dada la secuencia de claves enteras:190,57,89,90,121,170,35,48, 91,22,126,132 y 80;dibuje el árbol B de orden 5 cuya raíz es R,que se corresponde con dichas claves Ejercicio No 2: En el árbol R del problema anterior,elimine la clave 91 y dibuje el árbol resultante.Elimine ahora la clave 48.Dibuje el árbol resultante,¿ha habido reducción en el número de nodos? Ejercicio No 3: Dada la siguiente secuencia de claves:7,25,27,15,23,19,14,29,10, 50,18,22,46,17,70,33 y 58;dibuje el árbol B+ de orden 5 cuya raíz es R,que se corresponde con dichas claves. Ejercicio No 4: Construir cada uno de los B-árboles que se van generando conforme se van insertando los números 1,9,32,3,53,43,44,57,67,7,45,34,23,12,23,56,73,65,49,85,89, 64,54,75,77,49, en un Bárbol de orden 5. Ejercicio No 5: Supongamos que se insertan un conjunto de elementos en un B-árbol en un determinado orden.¿La altura del B-árbol resultado es independiente del orden en que se han insertado los elementos?.Razónese la respuesta. EJERCICIOS DE GRAFOS Ejercicio No 1: Realizar un procedimiento que imprima un grafo Ejercicio No 2: 148 Manual del Alumno Construir un procedimiento que determine el número de componentes conexas que posee un grafo. Ejercicio No 3: Construir procedimientos que devuelvan el grado de entrada y grado de salida de un vértice para un grafo dirigido y grado de un vértice para uno no dirigido. Ejercicio No 4: Un grafo no dirigido se dice de Euler si existe un camino Euleriano que incluye a todas sus aristas.Construir una función que dado un grafo no dirigido determine si es de Euler. Ejercicio No 5: Realizar un procedimiento que dado un grafo no dirigido determine cual es su grafo dual. Ejercicio No 6: ¿Puede recuperarse un grafo no dirigido a partir de sus recorridos en anchura y profundidad? Ejercicio No 7: Dado un grafo no dirigido G=(V,E),con v>1 vértices,demostrar que las 3 siguientes afirmaciones son equivalentes: a.-G es conexo y no tiene ciclos simples. b.-G es conexo y tiene v-1 aristas. c.-Cada par de vértices de G están conectados por exactamente un camino.