Tema 13.4: Variables dinámicas
(4).
El último dÃa vimos cómo hacer listas dinámicas
enlazadas, y cómo podÃamos ir insertando los elementos en
ellas de forma que siempre estuviesen ordenadas.
Hay varios casos particulares. Sólo comentaré algunos
de ellos de pasada:
-
Una pila es un caso particular de lista, en la que los elementos
siempre se introducen y se sacan por el mismo extremo (se apilan o se desapilan).
Es como una pila de libros, en la que para coger el tercero deberemos apartar
los dos primeros (excluyendo malabaristas, que los hay). Este tipo
de estructura se llama LIFO (Last In, First Out: el último
en entrar es el primero en salir).
-
Una cola es otro caso particular, en el que los elementos se introducen
por un extremo y se sacan por el otro. Es como se supone que deberÃa
ser la cola del cine: los que llegan, se ponen al final, y se atiende primero
a los que están al principio. Esta es una estructura FIFO
(First In, First Out).
Estas dos son estructuras más sencillas de programar de lo
que serÃa una lista en su caso general, pero que son también
útiles en muchos casos. De momento no incluyo ejemplos de ninguna
de ellas, y me lo reservo para los ejercicios y para cuando lleguemos a
Programación Orientada a Objetos, y será entonces cuando
creemos nuestro objeto Pila y nuestro objeto Cola (recordádmelo
si se me pasa). Aun asÃ, si alguien tiene dudas ahora, que
no se corte en decirlo, o que se espere un poco, hasta ver las soluciones
de los "deberes"... }
Finalmente, antes de pasar con los "árboles", comentaré
una mejora a estas listas enlazadas que hemos visto. Tal y como las
hemos tratado, tienen la ventaja de que no hay limitaciones tan rÃgidas
en cuanto a tamaño como en las variables estáticas, ni hay
por qué saber el número de elementos desde el principio.
Pero siempre hay que recorrerlas desde DELANTE hacia ATRAS, lo que puede
resultar lento. Una mejora relativamente evidente es lo que se llama
una lista doble o lista doblemente enlazada: si guardamos punteros
al dato anterior y al siguiente, en vez de sólo al siguiente, podremos
avanzar y retroceder con comodidad. Pero tampoco me enrollo más
con ello, lo dejo como ejercicio para quien tenga inquietudes.
ARBOLES.
Vamos allá. En primer lugar, veamos de donde viene el nombrecito.
En las listas, después de cada elemento venÃa otro (o ninguno,
si habÃamos llegado al final). Pero también nos puede
interesar tener varias posibilidades después de cada elemento, 3
por ejemplo. De cada uno de estos 3 saldrÃan otros 3, y asÃ
sucesivamente. ObtendrÃamos algo que recuerda a un árbol:
un tronco del que nacen 3 ramas, que a su veces se subdividen en otras
3 de menor tamaño, y asà sucesivamente hasta llegar a las
hojas.
Pues eso será un árbol: una estructura dinámica
en la que cada nodo (elemento) puede tener más de un "siguiente".
Nos centraremos en los árboles binarios, en los que cada nodo puede
tener un hijo izquierdo, un hijo derecho, ambos o ninguno (dos hijos como
máximo).
Para puntualizar aun más, aviso que trataremos los árboles
binarios de búsqueda, en los que tenemos prefijado un cierto orden,
que nos ayudará a encontrar un cierto dato dentro de un árbol
con mucha rapidez.
¿Y como es este "orden prefijado"? Sencillo: para cada
nodo tendremos que:
- la rama de la izquierda contendrá elementos menores.
- la rama de la derecha contendrá elementos mayores.
¿Asusta? Con un ejemplo seguro que no: Vamos a introducir
en un árbol binario de búsqueda los datos 5,3,7,2,4,8,9
Primer número: 5 (directo)
5
Segundo número: 3 (menor que 5)
5
/
3
Tercer número: 7 (mayor que 5)
5
/ \
3 7
Cuarto: 2 (menor que 5, menor que 3)
5
/ \
3 7
/
2
Quinto: 4 (menor que 5, mayor que 3)
5
/ \
3 7
/ \
2 4
Sexto: 8 (mayor que 5, mayor que 7)
5
/ \
3 7
/ \ \
2 4 8
Séptimo: 9 (mayor que 5, mayor que 7, mayor que 8)
5
/ \
3 7
/ \ \
2 4 8
\
9
¿Y qué ventajas tiene esto? Pues la rapidez:
tenemos 7 elementos, lo que en una lista supone que si buscamos un dato
que casualmente está al final, haremos 7 comparaciones; en este
árbol, tenemos 4 alturas => 4 comparaciones como máximo.
Y si además hubiéramos "equilibrado" el árbol
(irlo recolocando, de modo que siempre tenga la menor altura posible),
serÃan 3 alturas.
Esto es lo que se hace en la práctica cuando en el árbol
se va a hacer muchas más lecturas que escrituras: se reordena internamente
después de añadir cada nuevo dato, de modo que la altura
sea mÃnima en cada caso.
De este modo, el número máximo de comparaciones que tendrÃamos
que hacer serÃa log2 , lo que supone que si tenemos 1000 datos,
en una lista podrÃamos llegar a tener que hacer 1000 comparaciones,
y en un arbol binario, log2(1000) => 10 comparaciones como máximo.
La ganancia es clara, ¿verdad?
No vamos a ver cómo se hace eso de los "equilibrados", que considero
que serÃa propio de un curso de programación más avanzado,
o incluso de uno de "Tipos Abstractos de Datos" o de "AlgorÃtmica",
y vamos a empezar a ver rutinas para manejar estos árboles binarios
de búsqueda.
Recordemos que la idea importante es todo dato menor estará a
la izquierda del nodo que miramos, y los datos mayores estarán a
su derecha.
Ahora la estructura de cada nodo (dato) será:
type
TipoDato = string[10]; { Vamos a guardar
texto, por ejemplo }
Puntero = ^TipoBase; { El
puntero al tipo base }
TipoBase = record
{ El tipo base en sÃ: }
dato: TipoDato;
{ - un dato }
hijoIzq: Puntero;
{ - puntero a su hijo izquierdo }
hijoDer: Puntero;
{ - puntero a su hijo derecho }
end;
Y las rutinas de inserción, búsqueda, escritura, borrado,
etc., podrán ser recursivas. Como primer ejemplo, la de escritura
de todo el árbol (la más sencilla) serÃa:
procedure Escribir(punt: puntero);
begin
if punt <> nil then
{ Si no hemos llegado a una hoja }
begin
Escribir(punt^.hijoIzq); { Mira
la izqda recursivamente }
write(punt^.dato);
{ Escribe el dato del nodo }
Escribir(punt^.hijoDer); { Y luego
mira por la derecha }
end;
end;
Si alguien no se cree que funciona, que coja lápiz y papel y
lo compruebe con el árbol que hemos puesto antes como ejemplo.
Es MUY IMPORTANTE que este procedimiento quede claro antes de seguir leyendo,
porque los demás serán muy parecidos.
La rutina de inserción serÃa:
procedure Insertar(var punt: puntero; valor: TipoDato);
begin
if punt = nil then
{ Si hemos llegado a una hoja }
begin
new(punt);
{ Reservamos memoria }
punt^.dato := valor;
{ Guardamos el dato }
punt^.hijoIzq := nil;
{ No tiene hijo izquierdo }
punt^.hijoDer := nil;
{ Ni derecho }
end
else
{ Si no es hoja }
if punt^.dato > valor
{ Y encuentra un dato mayor }
Insertar(punt^.hijoIzq, valor)
{ Mira por la izquierda }
else
{ En caso contrario (menor) }
Insertar(punt^.hijoDer, valor)
{ Mira por la derecha }
end;
Y finalmente, la de borrado de todo el árbol, casi igual
que la de escritura:
procedure BorrarArbol(punt: puntero);
begin
if punt <> nil then
{ Si queda algo que borrar }
begin
BorrarArbol(punt^.hijoIzq); { Borra
la izqda recursivamente }
dispose(punt);
{ Libera lo que ocupaba el nodo }
BorrarArbol(punt^.hijoDer); { Y
luego va por la derecha }
end;
end;
Sólo un comentario: esta última rutina es peligrosa,
porque indicamos que "punt" está libre y después miramos
cual es su hijo izquierdo (después de haber borrado la variable).
Creo recordar que esto funciona en Turbo Pascal O
porque marca esa zona de memoria como disponible pero no la borra fÃsicamente.
Esto puede dar problemas con otros compiladores o si se adapta esta
rutina a otros lenguajes (como C). Una forma más segura
de hacer lo anterior serÃa:
procedure BorrarArbol2(punt: puntero);
var derecha: puntero;
{ Aquà guardaremos el hijo derecho }
begin
if punt <> nil then
{ Si queda algo que borrar }
begin
BorrarArbol2(punt^.hijoIzq); { Borra la izqda
recursivamente }
derecha := punt^.hijoDer;
{ Guardamos el hijo derecho <=== }
dispose(punt);
{ Libera lo que ocupaba el nodo }
BorrarArbol2(derecha);
{ Y luego va hacia por la derecha }
end;
end;
O bien, simplemente, se pueden borrar recursivamente los dos hijos antes
que el padre (ahora ya no hace falta ir "en orden", porque no estamos leyendo,
sino borrando todo):
procedure BorrarArbol(punt: puntero);
begin
if punt <> nil then
{ Si queda algo que borrar }
begin
BorrarArbol(punt^.hijoIzq); { Borra
la izqda recursivamente }
BorrarArbol(punt^.hijoDer); { Y
luego va hacia la derecha }
dispose(punt);
{ Libera lo que ocupaba el nodo }
end;
end;
Finalmente, vamos a juntar casi todo esto en un ejemplo "que funcione":
{--------------------------}
{ Ejemplo en Pascal: }
{ }
{ Ejemplo de árboles }
{ binarios de búsqueda }
{ ARBOL.PAS }
{ }
{ Este fuente procede de }
{ CUPAS, curso de Pascal }
{ por Nacho Cabanes }
{ }
{ Comprobado con: }
{ - Turbo Pascal 7.0 }
{ - Tmt Pascal Lt 1.20 }
{--------------------------}
type
TipoDato = integer; { Vamos a guardar texto, por ejemplo }
Puntero = ^TipoBase; { El puntero al tipo base }
TipoBase = record { El tipo base en sÃ: }
dato: TipoDato; { - un dato }
hijoIzq: Puntero; { - puntero a su hijo izquierdo }
hijoDer: Puntero; { - puntero a su hijo derecho }
end;
procedure Escribir(punt: puntero);
begin
if punt <> nil then { Si no hemos llegado a una hoja }
begin
Escribir(punt^.hijoIzq); { Mira la izqda recursivamente }
write(punt^.dato, ' '); { Escribe el dato del nodo }
Escribir(punt^.hijoDer); { Y luego mira por la derecha }
end;
end;
procedure Insertar(var punt: puntero; valor: TipoDato);
begin
if punt = nil then { Si hemos llegado a una hoja }
begin
new(punt); { Reservamos memoria }
punt^.dato := valor; { Guardamos el dato }
punt^.hijoIzq := nil; { No tiene hijo izquierdo }
punt^.hijoDer := nil; { Ni derecho }
end
else { Si no es hoja }
if punt^.dato > valor { Y encuentra un dato mayor }
then
Insertar(punt^.hijoIzq, valor) { Mira por la izquierda }
else { En caso contrario (menor) }
Insertar(punt^.hijoDer, valor) { Mira por la derecha }
end;
{ Cuerpo del programa }
var
arbol1: Puntero;
begin
arbol1 := nil;
Insertar(arbol1, 5);
Insertar(arbol1, 3);
Insertar(arbol1, 7);
Insertar(arbol1, 2);
Insertar(arbol1, 4);
Insertar(arbol1, 8);
Insertar(arbol1, 9);
Escribir(arbol1);
end.
Nota: en versiones anteriores de este fuente, la variable se
llamaba "arbol". En la versión 3.5.1 del curso, he cambiado
esta variable por "arbol1", dado que Tmt Pascal Lite protesta si usamos
alguna variable que se llame igual que el nombre del programa (avisa de
que estamos usando dos veces un identificador: "duplicate identifier").
Tema 13.5: Ejercicios.
Aquà van unos ejercicios propuestos:
-
Implementar una pila de strings[20].
-
Implementar una cola de enteros.
-
Implementar una lista doblemente enlazada
que almacene los datos leÃdos de un fichero de texto (mejorando
el lector de ficheros que vimos).
-
Hacer lo mismo con una lista simple, pero
cuyos elementos sean otras listas de caracteres, en vez de strings
de tamaño fijo.
-
Añadir la función "buscar" a
nuestro árbol binario, que diga si un dato que nos interesa
pertenece o no al árbol (TRUE cuando sà pertenece; FALSE
cuando no).
-
¿Cómo se borrarÃa un
único elemento del árbol?
N.
|