estructura de la informacion

Anuncio
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:
(n)
<l->siguiente->elemento, l->siguiente->siguiente->elemento, ... ,l->siguiente-> ->siguiente>elemento>
(n+1)
Donde r->siguiente->
->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:
(n)
<(*p)->elemento, (*p)->siguiente->elemento, ... , (*p)->siguiente-> ->siguiente->elemento>.
(n+1)
con (*p)->siguiente->
->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(n)
>ant->siguiente-> ->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
30
48
40 = 1.15 * 10 posibles funciones del conjunto de claves en la tabla, y sólo 40*39*11 =
41
40!/10! = 2.25 * 10 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
n-1
2E ((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.
Descargar