Download ESTRUCTURAS DINAMICAS

Document related concepts

Lista enlazada wikipedia , lookup

Lista doblemente enlazada wikipedia , lookup

Árbol B+ wikipedia , lookup

Árbol binario wikipedia , lookup

Árbol-B wikipedia , lookup

Transcript
ESTRUCTURAS DINAMICAS
1. INTRODUCCIÓN.
Un programa puede guardar información en la memoria del ordenador de dos
formas. La primera utiliza variables locales y globales (incluyendo vectores y
estructuras). En el caso de variables globales y estáticas (static), el establecimiento
se establece fuera del tiempo de ejecución del programa en la zona de memoria
correspondiente a los datos del programa. Para las variables locales, el almacenamiento
se establece en la pila. Estas variables requieren que el programador conozca, de
antemano, la cantidad de espacio necesario para cada una de ellas.
La segunda forma de almacenar información es mediante la utilización del
sistema de asignación dinámica. Con este método, se asigna tanto espacio de memoria
libre para el almacenamiento de la información como sea necesario y se vuelve a
convertir en memoria libre cuando ya se ha utilizado. La región de memoria libre se
encuentra entre el área de almacenamiento permanente del programa (segmento de
datos) y la pila. Esta región se denomina zona de memoria dinámica o heap.
Una ventaja que tiene la utilización de asignación dinámica de memoria para
almacenar datos es que la misma memoria se puede utilizar para varios objetos distintos
durante el curso de la ejecución de un programa, ya que en tiempo de ejecución es
asignada y posteriormente liberada. Otra de las ventajas es que la asignación dinámica
permite la creación de listas enlazadas y árboles binarios.
Para realizar asignación dinámica de memoria, se usan los punteros. Un puntero
es una referencia a datos o código en un programa. Es literalmente la dirección en
memoria del elemento al que apunta. El uso de punteros permite escribir programas
grandes y más flexibles, y es especialmente útil cuando se escriben programas
orientados a objetos. Básicamente, se necesita usar punteros por varias razones:
! Si es necesario el paso por referencia de un parámetro a una función.
! Si se usan datos de tamaño desconocido en tiempo de compilación.
! Si los programas manejan datos de múltiples tipos.
! Si los programas utilizan listas encadenadas de registros o de objetos.
Pero, a diferencia de la declaración de variables simples, tales como los tipos
char, short, int, float o double, donde se reserva automáticamente el espacio necesario
para almacenar el dato asociado a la variable, cuando se declara un puntero no se
reserva ningún espacio de memoria, por lo que hay que recurrir a funciones
especializadas que realicen esta acción. En el caso de los vectores puede ocurrir que se
reserve o no, dependiendo del tipo de declaración que se realice. Por ejemplo, si se hace
la siguiente declaración:
char texto[20];
el vector texto tiene reservada memoria para 20 variables de tipo char; sin embargo si se
hace cualquiera de las siguientes:
char texto[];
char *texto;
el vector no reserva ningún espacio y hay que recurrir a funciones específicas para
reservar el espacio que se necesite. Este sistema tiene la ventaja, como se ha dicho
anteriormente, que puede decidirse en tiempo de ejecución cuánta memoria se necesita;
en el caso anterior el bloque reservado es fijo y no puede modificarse después de
compilado el programa.
Las funciones estándar de C que permiten reservar y liberar bloques de memoria
se incluyen en stdlib.h y son las siguientes:
free() Permite liberar el espacio asignado previamente
malloc() Permite alojar un bloque de memoria
calloc() Permite alojar un bloque de memoria cuyos elementos se inicializan a
0
realloc() Permite volver a alojar un bloque de memoria
• Manejo de datos de tamaño desconocido (Gestión dinámica de memoria).
Algunos tipos de datos de C (en particular cadenas de caracteres y arrays)
necesitan tener especificadas sus dimensiones en tiempo de compilación, incluso
aunque pueda no necesitarse todo el espacio reservado cuando se ejecute el programa.
Un ejemplo sencillo puede ser un programa que lee una cadena de usuario. Para
almacenar el nombre en una variable ordinaria de tipo cadena de caracteres, se debe
reservar espacio suficiente para la cadena más larga posible, aunque el nombre
introducido sólo tenga unas pocas letras. Si se espera a reservar memoria para la
variable sobre el heap (*) en tiempo de ejecución, se pueden asignar exactamente el
número de bytes necesarios para que quepa la cadena de caracteres introducida.
Esto es un ejemplo trivial, pero en una aplicación con cientos o miles de datos
de este tipo (como con múltiples ventanas o listas leídas desde ficheros), reservar sólo el
espacio necesario puede marcar la diferencia entre funcionar correctamente y dar error
al quedarse sin memoria.
(*) El heap es toda la memoria que deja disponible el sistema operativo y que no está
siendo usada por el código del programa, el segmento de datos y el de pila (stack). Se
puede controlar la cantidad de espacio de heap disponible mediante opciones de
configuración de un proyecto en C.
Como ejemplo supongamos la siguiente estructura de datos:
struct reg_alumno
{
char nombre[15];
char apellidos[30];
int edad;
};
Y la función que nos permite asignar valores a la estructura:
void LeerAlumno(struct reg_alumno *a)
{
printf(“Nombre del alumno: “);
gets(a->nombre);
printf(“Apellidos: “);
gets(a->apellidos);
printf(“Edad: “);
scanf(“%d”,&a->edad);
}
Si el usuario no rellena la estructura completamente, se estará desperdiciando
memoria; por ejemplo, si introduce los siguientes datos:
Eva
Gil Pérez
23
La pérdida de memoria se puede observar en la siguiente figura:
nombre: 15 bytes E v a \0
apellidos: 30 bytes G i l P é r e z \0
edad: 4 bytes 23
De los 49 bytes reservados sólo están ocupados 18.
Este problema se puede solucionar si utilizamos vectores definidos de forma
dinámica; para ello debemos primero evaluar en tiempo de ejecución cuánta memoria
se necesita, y una vez conocido el tamaño se pide mediante la función malloc(),
cuyo prototipo es el siguiente:
void *malloc(size_t size);
La función malloc() devuelve un puntero al primer byte de una región de
memoria, asignada en el heap, cuyo tamaño en bytes es el valor indicado en el
parámetro size. Si no hay suficiente memoria en la zona de memoria dinámica para
satisfacer la petición, malloc() devuelve un puntero nulo (valor NULL). Siempre es
importante comprobar que esta función no devuelve un puntero nulo antes de intentar
utilizar dicho puntero. Si se intenta utilizar un puntero nulo, normalmente provocará una
caída del sistema.
Así pues, para solucionar el problema de pérdida de memoria del ejemplo
anterior, vamos a definir la estructura eliminando los arrays estáticos y definiéndolos
como punteros a char del siguiente modo:
struct reg_alumno
{
char *nombre;
char *apellidos;
int edad;
};
Cuando sólo contiene punteros, el compilador no reserva memoria para
almacenar el contenido que deseamos introducir. Debido a esta situación, la función que
rellena la estructura debe encargarse de la petición de memoria para introducir los datos.
Esta petición se puede hacer de manera que el tamaño de cada cadena se ajuste
exactamente al tamaño de la memoria que se necesita. Para ello se utiliza una variable
auxiliar que permite la introducción de datos de tamaño cualquiera (menor que 80) y,
una vez que el usuario ha introducido el dato que realmente desea almacenar, se calcula
el tamaño de dicho dato y se ajusta la petición de memoria a ese tamaño. De esta
manera la función queda:
int LeerAlumno(struct reg_alumno *a)
{
char aux[80];
printf("Nombre del alumno: ");
gets(aux);
a->nombre=(char *)malloc((strlen(aux)+1)*sizeof(char));
if (a->nombre==NULL)
return 1;
else
{
strcpy(a->nombre,aux);
printf("Apellidos: ");
gets(aux);
a->apellidos=(char *)malloc((strlen(aux)+1)*sizeof(char));
if (a->apellidos==NULL)
return 1;
else
{
strcpy(a->apellidos,aux);
printf("Edad: ");
scanf("%d",&a->edad);
fflush(stdin);
return 0;
}
}
}
Obsérvese que la función malloc() devuelve un puntero inespecífico (de tipo
void), por lo que hay que realizar lo que se denomina un filtro; es decir, hay que
anteponer al nombre de la función, y entre paréntesis, el tipo de puntero que se desea:
a->nombre=(char *)malloc((strlen(aux)+1)*sizeof(char));
El problema de la gestión dinámica es la no-destrucción por parte del
compilador de esa memoria cuando se acaba la validez de esa variable. El compilador
liberará la memoria ocupada por los punteros, pero no la memoria a la que apuntan
dichos punteros. Es decir, el programador es el encargado de liberar esa memoria, por lo
que se deberá implementar una función que la libere totalmente, utilizando la función
free(), cuyo prototipo es el siguiente:
void free(void *block);
La siguiente función liberará la memoria dinámica usada por el ejemplo anterior:
void DestruirAlumno(struct reg_alumno *a)
{
free(a->nombre);
free(a->apellidos);
}
Una función principal que use las anteriores funciones podría ser:
main()
{
struct reg_alumno alumno;
int error;
error=LeerAlumno(&alumno);
if (error)
printf("No hay memoria suficiente");
...
DestruirAlumno(&alumno);
...
}
Del mismo modo que se pueden gestionar los vectores de caracteres de forma
dinámica, podemos hacerlo con los vectores de estructuras. Por ejemplo, si tuviéramos
un número indeterminado de alumnos, podríamos pedir por teclado el número exacto de
alumnos con los que se desea trabajar y luego crear el vector de la forma siguiente:
int tamanio;
struct reg_alumno *alumno;
...
printf(“¿Cuántos alumnos desea gestionar?”);
scanf(“%d”,&tamanio);
alumno=(struct reg_alumno *)malloc(tamanio * sizeof(struct reg_alumno));
...
El siguiente código de programa utiliza asignación dinámica para un vector de
estructuras con 3 elementos, aceptando los datos desde teclado (con la función
LeerAlumno() vista anteriormente) y destruyendo la memoria ocupada mediante la
función DestruirAlumno():
main()
{
int tamanio=3;
struct reg_alumno *alumno;
alumno=(struct reg_alumno *)malloc(tamanio*sizeof(struct reg_alumno));
int error;
for (int i=0;i<tamanio;i++)
{
error=LeerAlumno(alumno+i);
if (error)
printf("No hay memoria suficiente");
}
for (int i=0;i<tamanio;i++)
{
DestruirAlumno(alumno+i);
}
}
Esta gestión, aunque es más adaptable y dinámica que la realizada mediante
vectores predefinidos, no es del todo dinámica, ya que no se pueden almacenar más
valores en el vector que los que se han pedido mediante malloc(), puesto que no se
puede modificar el tamaño del vector.
La única solución a este problema utilizando vectores es la petición de uno
nuevo que sea mayor que el primero, y usar la función realloc() para volver a
asignar memoria suficiente. El prototipo de esta función es:
void *realloc(void *block, size_t size);
donde block representa el vector actual y size el nuevo tamaño (en bytes)
necesario. La función, en caso de que no devuelva NULL, copiará todos los datos de
block en el nuevo vector dinámico.
A continuación se presenta un ejemplo del uso de esta función:
main()
{
int tamanio=3;
struct reg_alumno *alumno;
alumno=(struct reg_alumno *)malloc(tamanio*sizeof(struct reg_alumno));
int error;
for (int i=0;i<tamanio;i++)
{
error=LeerAlumno(alumno+i);
if (error)
printf("No hay memoria suficiente");
}
alumno=(struct reg_alumno *)realloc(alumno,(tamanio+1)*sizeof(struct
reg_alumno));
error=LeerAlumno(alumno+3);
if (error)
printf("No hay memoria suficiente");
for (int i=0;i<tamanio+1;i++)
DestruirAlumno(alumno+i);
}
Para finalizar con las funciones de gestión dinámica de memoria, sólo nos queda
la función calloc(), que funciona de la misma forma que malloc(), salvo que
inicializa los elementos a 0. Su prototipo es:
void *calloc(size_t nitems, size_t size);
siendo nitems el número de elementos que se desea gestionar y size el número en bytes
de cada elemento. Por ejemplo, para asignar de forma dinámica espacio para un vector
de 20 enteros con valor inicial 0 se puede escribir:
v=(int *)calloc(20,sizeof(int));
Al igual que malloc(), calloc() también devolverá un puntero NULL si
no hay memoria suficiente.
• Organización de la información mediante listas enlazadas.
Cuando se procesan conjuntos de datos cuyo espacio de almacenamiento no se
puede predecir 'a priori' (en tiempo de compilación) y además la actividad de los
mismos (inserciones y borrados) es frecuente, las estructuras de datos estáticas (los
vectores) no son adecuadas para su implementación. Las razones son varias:
1. Los vectores deben ser declarados en tamaño en el programa fuente,
de modo que si se elige uno mayor que el necesario, entonces se
malgasta espacio de memoria, y si se hace pequeño, podría no
ejecutarse el programa.
2. La operación de añadir datos al final del vector es un proceso rápido;
sin embargo, las inserciones y eliminaciones en el interior del vector
son lentas y complejas, ya que puede ser necesario desplazar cada
elemento del éste para hacer espacio al nuevo elemento, o bien cerrar
el espacio dejado por una eliminación.
Si a esta dificultad se añaden los casos en que las operaciones anteriores sean
frecuentes, se puede deducir que en estos casos las estructuras más idóneas son las
estructuras dinámicas de datos. Se clasifican en los siguientes tipos:
" Listas enlazadas
o Listas (inserción y borrado en cualquier lugar, según clave)
o Pilas (inserción y borrado sólo por el final –LIFO Last In, First Out-)
o Colas (inserción por el final, borrado por el principio –FIFO First In, First Out-)
" Árboles
Las listas enlazadas son una secuencia de nodos que se encuentran enlazados
cada uno con el siguiente mediante un enlace o puntero. Cada elemento (nodo) de una
lista enlazada debe tener dos campos: un campo (información) que contiene el valor de
ese elemento y un campo (enlace o puntero) que indica la posición del siguiente
elemento.
Los elementos de una lista están conectados o "enlazados" mediante sus campos
enlace o puntero.
Los componentes de un nodo se llaman campos. Un nodo tiene al menos un
campo de datos o valor y un campo enlace o siguiente. El campo enlace apunta (indica
la dirección) al siguiente nodo de la lista. Existe una marca para fin de lista, que es la
constante NULL.
El campo de datos puede contener cualquier tipo estándar o definido por el
usuario, pudiendo estar compuesto de varios campos, donde, generalmente, uno de ellos
se considera clave o identificador; es decir, que los nodos de la lista seguirán un orden
(creciente o decreciente) de su campo clave. El campo enlace contiene el puntero al
siguiente elemento de la lista.
La sintaxis de la definición de un nodo se compone de una estructura donde se
almacena la información, y de la parte necesaria para almacenar la referencia al
siguiente:
struct nodo
{
struct nombre_estructura informacion;
struct nodo *siguiente;
};
Si cada nodo únicamente tiene una referencia al nodo siguiente, se dice que la
lista está enlazada de forma simple. Cuando se tienen dos referencias, una al nodo
anterior y otra al siguiente, se dice que la lista está doblemente enlazada. Los nodos de
una lista doblemente enlazada tienen la siguiente sintaxis:
struct nodo
{
struct nombre_estructura informacion;
struct nodo *siguiente;
struct nodo *anterior;
};
Además de la división de las listas en simples y doblemente enlazadas se puede
establacer otra división en función de la referencia que guarda el último elemento de la
lista. Si el puntero del último elemento de la lista apunta al principio de la lista, se dice
que la lista es circular. Cuando esto no ocurre, el puntero debe tener el valor NULL
(==0). Este valor concreto representa que no está apuntando a ninguna dirección válida,
siendo necesario para poder recorrer la lista.
Es imprescindible tener siempre un puntero que referencie al principio de la
lista, ya que si se pierde esta referencia se pierde el contenido de toda la lista.
Veamos un ejemplo de declaración de un nodo de una lista simple (por ejemplo,
de enteros):
struct nodo
{
int valor;
struct nodo *siguiente;
};
Obsérvese la definición del tipo puntero siguiente. Esta es la única situación
en que está permitido utilizar un identificador antes de ser definido.
NOTA: En C existe la palabra reservada typedef que permite crear un tipo definido
que es sinónimo de otro tipo de datos. Por ejemplo, el código:
typedef int entero;
hace un nuevo tipo, entero, equivalente a int. Por tanto, las declaraciones siguientes
son equivalentes:
int variable;
entero variable;
La palabra reservada typedef se suele usar con mucha frecuencia cuando se
trabaja con estructuras, ya que el programador ahorra tiempo de escritura de sus
programas al poder referenciar el tipo de dato estructura con una sola palabra. Por
ejemplo:
struct reg_alumno
{
char *nombre;
int edad;
};
obligaría a declarar variables de ese tipo de la forma:
struct reg_alumno alumno;
Usando typedef podemos definir la estructura de la siguiente forma:
typedef struct reg_alumno alum;
struct reg_alumno
{
char *nombre;
int edad;
};
y así podríamos declarar el dato alumno de la siguiente forma:
alum alumno;
Así pues, la declaración de la estructura nodo de la lista enlazada simple podría
hacerse de la siguiente forma:
typedef struct nodo elemento;
struct nodo
{
int valor;
elemento *siguiente;
};
Las operaciones básicas necesarias para manipular listas enlazadas son:
!Inicialización
!Inserción
!Búsqueda
!Recorrido
!Borrado
Los procesos de inserción, borrado y búsqueda dependerán de si la lista está o no
ordenada por un campo clave (que formará parte de la información del nodo).
A continuación se definen, mediante ejemplos, los procesos necesarios para
crear una lista ordenada con los siguientes valores enteros: 7, 9, 8 y 4.
• Inicialización
elemento *primero;Declaración del puntero al principio de la lista
primero=NULL; Lista vacía
• Inserción
Inserción del primer elemento (7)
En este caso, la condición es que el puntero al inicio de la lista coincida con
NULL:
primero=(elemento*)malloc(sizeof(elemento));
primero->valor=7;
primero->siguiente=NULL;
Inserción de un elemento al final de la lista (9)
En este caso, ya existe, al menos, un elemento al principio de la lista, por lo que
basta recorrer la lista para conocer cuál es el último elemento:
elemento *aux, *nuevo;
aux=primero;
while (aux->siguiente!=NULL) Igual que while (aux->siguiente)
{
aux=aux->siguiente;
}
nuevo=(elemento *)malloc(sizeof(elemento));
aux->siguiente=nuevo;
nuevo->valor=9;
nuevo->siguiente=NULL;
Inserción de un elemento en medio de la lista (8)
En este caso comprobaremos dónde debe situarse el nuevo elemento y, a
continuación, deberemos asignar la dirección de memoria del nuevo elemento al
anterior y dar valor al puntero del nuevo elemento con la dirección del siguiente:
elemento *auxsig, *auxant, *nuevo;
auxsig=primero;
auxant=primero;
while (auxsig && auxsig->valor<8)
{
auxant=auxsig;
auxsig=auxsig->siguiente;
}
nuevo=(elemento *)malloc(sizeof(elemento));
auxant->siguiente=nuevo;
nuevo->valor=8;
nuevo->siguiente=auxsig;
Inserción al principio de la lista (4)
En el caso anterior, puede comprobarse que siempre auxant y auxsig son
dos direcciones de memoria distintas, ya que el elemento a insertar debe ir entre dos
elementos ya existentes. En el caso de que el elemento a insertar debe ser el primero de
la lista (auxsig==auxant) es necesario modificar la referencia al puntero primero
(inicio de lista).
primero=nuevo;
primero->valor=4;
primero->siguiente=auxsig; o primero->siguiente=auxant;
Recorrido
El ejemplo siguiente presenta en pantalla los elementos de la lista:
elemento *movil;
movil=primero;
while (movil)
{
printf(“%d\n”,movil->valor);
movil=movil->siguiente;
}
Antes de ver los procesos de borrado de un elemento de la lista veamos dos
programas ejemplos de inserción, búsqueda y recorrido:
EJEMPLO 1.- A continuación se muestra un pequeño ejemplo de tratamiento de una
lista simple que almacena números enteros, de forma que no siguen ningún orden, por
lo que los nuevos valores siempre se añadirán al final. Los datos irán tomándose desde
teclado. Cuando termine la introducción de datos (valor 0), el programa presenta en
pantalla todos los elementos de la lista.
Creación, inserción y recorrido de una lista simple de elementos con
información de tipo entero, sin seguir ningún orden: insertando al final.
#include <stdio.h>
#include <stdlib.h>
typedef struct nodo elemento;
struct nodo
{
int valor;
elemento *siguiente;
};
elemento * NuevoElemento(void)
{
return ((elemento *)malloc(sizeof(elemento)));
}
void ListaElementos(elemento *p)
{
elemento *movil;
movil=p;
while (movil)
{
printf("%d\n",movil->valor);
movil=movil->siguiente;
}
getchar();
}
main()
{
elemento *nuevo,*primero,*aux;
int n;
primero=NULL; Creación
printf("Introduzca valores numéricos enteros(FIN 0):\n");
scanf("%d",&n);
fflush(stdin);
while (n)
{
if (primero==NULL) Inserción del primer valor
{
primero=NuevoElemento();
primero->valor=n;
primero->siguiente=NULL;
}
else Inserción del resto de valores
{
aux=primero;
while (aux->siguiente) Búsqueda del último elemento
{
aux=aux->siguiente;
}
nuevo=NuevoElemento(); Inserción de un nuevo valor
aux->siguiente=nuevo;
nuevo->valor=n;
nuevo->siguiente=NULL;
}
scanf("%d",&n);
fflush(stdin);
}
puts("Los elementos de la lista son: ");
ListaElementos(primero); Recorrido completo de la lista
}
EJEMPLO 2.- El ejemplo anterior se considera simple, puesto que no se ha seguido
ningún orden en la introducción de datos en la lista. El siguiente ejemplo sí tiene en
cuenta la inserción de un nuevo valor siguiendo un orden ascendente; por tanto, es
necesario, previamente a la inserción, localizar la posición que debe ocupar ese nuevo
elemento.
Creación, inserción y recorrido de una lista simple de elementos con
información de tipo entero, siguiendo un orden ascendente.
#include <stdio.h>
#include <stdlib.h>
typedef struct nodo elemento;
struct nodo
{
int valor;
elemento *sig;
};
elemento * Reserva(void)
{
return ((elemento *)malloc(sizeof(elemento)));
}
void ListaElementos(elemento *primero)
{
elemento *aux;
aux=primero;
while (aux)
{
printf("%d\t",aux->valor);
aux=aux->sig;
}
}
void Insertar(elemento primero,int n)
{
elemento *aux,*ant,*nuevo;
aux=*primero;
ant=*primero;
while (aux && aux->valor<n)
{
ant=aux;
aux=aux->sig;
}
nuevo=Reserva();
if (ant==aux) Inserción al principio de la lista
{
*primero=nuevo;
(*primero)->valor=n;
(*primero)->sig=aux;
}
else Inserción en medio o al final de la lista
{
ant->sig=nuevo;
nuevo->valor=n;
nuevo->sig=aux;
}
}
main()
{
elemento *primero;
int n;
primero=NULL; Creación
puts("Introduzca valores enteros (FIN=0)");
scanf("%d",&n);
fflush(stdin);
while (n)
{
if (primero==NULL) Inserción del primer elemento
{
primero=Reserva();
primero->valor=n;
primero->sig=NULL;
}
else
{
Insertar(&primero,n);
}
ListaElementos(primero); Recorrido completo
puts("\nIntroduzca valores enteros (FIN=0)");
scanf("%d",&n);
fflush(stdin);
}
}
Borrado
El borrado consiste en la reorganización de punteros de forma que un elemento
antes existente en la lista ya no pueda ser referenciado desde nuestro programa: ningún
elemento apunta al elemento borrado. El proceso de borrado no sólo debe realizar lo
anterior, sino que debe liberar la memoria ocupada por el elemento.
Para eliminar un elemento habrá que conocer su clave o identificador (forma
parte de los campos de información). Una vez localizado el elemento, se procederá a
borrarlo. Es posible que el elemento no se encuentre en la lista.
A continuación se presenta una función de borrado de un elemento de una lista
simple de enteros. La función devolverá un 0 si el elemento no existe y un 1 en caso
contrario. Es necesario pasar a la función la dirección del puntero de inicio de la lista,
puesto que el elemento a borrar puede ocupar la primera posición.
int Borrado(elemento p,int n)
{
elemento *aa,*as;
aa=as=*p;
while (as && as->valor!=n)
{
aa=as;
as=as->sig;
}
if (!as)
return 0;
else
{
if (aa==as)
*p=(*p)->sig;
else
aa->sig=as->sig;
free(as);
return 1;
}
}
Si lo que se quiere es eliminar totalmente la lista, es decir, liberar toda la
memoria ocupada y dejarla vacía, podría implementarse la siguiente función:
elemento * LiberaLista(elemento *primero)
{
elemento *aux;
aux=primero;
while (primero)
{
primero=primero->sig;
free(aux);
aux=primero;
}
return NULL;
}
A la cual hay que llamar de la forma:
primero=LiberaLista(primero);
Hasta este momento, las listas enlazadas se han recorrido en un solo sentido, de
izquierda a derecha. En muchas aplicaciones se requiere recorrer la lista en ambas
direcciones. Estos recorridos se pueden conseguir manteniendo dos campos de enlace en
cada nodo, en lugar de uno. Estos enlaces (punteros) se utilizan para denotar la
dirección del predecesor y sucesor de un nodo dado. El predecesor se llama enlace
anterior y el sucesor enlace siguiente. Una lista cuya estructura de nodos contiene dos
campos de enlace se denominará lista lineal doblemente enlazada.
Las siguientes declaraciones describen un nodo general de una lista doblemente
enlazada:
typedef struct nodo elemento;
struct nodo
{
struct nombre_estructura informacion;
elemento *anterior;
elemento *siguiente;
};
Todos los ejemplos que se presentan a continuación se basan en las siguientes
declaraciones:
typedef struct nodo elemento;
struct nodo
{
int valor;
elemento * anterior;
elemento * siguiente:
};
...
main()
{
...
elemento *primero; Puntero al principio de la lista
primero=NULL; Lista vacía
...
}
Las listas doblemente enlazadas presentan la ventaja de que pueden ser
recorridas en ambos sentidos. Para recorrerla desde el último elemento al primero
basta conocer la dirección de memoria del último elemento de la lista. Podría usarse la
siguiente función, que devuelve NULL si no hay elementos en la lista o la dirección de
memoria del último elemento en caso contrario:
elemento * Ultimo(elemento *primero)
{
if (primero)
while (primero->siguiente)
primero=primero->siguiente;
return primero;
}
Tras la llamada a esta función, el recorrido a la inversa podría ser:
int Lista_Hacia_Atras(elemento *ultimo)
{
int hay=0;
while (ultimo)
{
hay=1;
printf(“%d\n”,ultimo->valor);
ultimo=ultimo->anterior;
}
return hay;
}
que devuelve 0 si no hay elementos en la lista. La llamada a la anterior función
sería:
if (¡Lista_Hacia_Atrás(Ultimo(primero)))
puts(“No hay elementos en la lista”);
Los procesos de inserción y borrado en una lista doblemente enlazada difieren
un poco de los realizados sobre las listas simples. Las funciones siguientes realzan en
negrita las consideraciones a tener en cuenta:
Inserción:
int Inserta(elemento primero,int n)
{
elemento *aux,*ant,*nuevo;
aux=*primero;
ant=*primero;
while (aux && aux->valor<n)
{
ant=aux;
aux=aux->sig;
}
if (aux && aux->valor==n)
return 0; 0 si el elemento ya se encuentra en la lista
else
{
nuevo=Reserva(); No se contempla que no haya memoria
if (ant==aux)
{
*primero=nuevo;
(*primero)->valor=n;
(*primero)->sig=aux;
(*primero)->ant=NULL;
}
else
{
ant->sig=nuevo;
nuevo->valor=n;
nuevo->sig=aux;
nuevo->ant=ant;
}
return 1; 1 si se ha insertado con éxito
}
}
Borrado:
int Borra(elemento p,int valor)
{
elemento *aux=*p;
while (aux && aux->valor!=valor)
{
aux=aux->sig;
}
if (aux==NULL)
return 0; 0 si el elemento no está en la lista
else
{
if (aux==*p) Se borra el primer elemento
(*p)=(*p)->sig;
else
if (aux->sig) Si el elemento a borrar no es el último
{
aux->ant->sig=aux->sig;
aux->sig->ant=aux->ant;
}
else El elemento a borrar es el último
aux->ant->sig=NULL;
free(aux);
return 1; 1 si el elemento se ha borrado con éxito
}
}
• Organización de la información mediante pilas.
Las listas que hemos estudiado están ordenadas en función de una clave. Las
inserciones se realizaban siempre en la posición que le correspondía al nodo en función
de la clave o identificador que tenía. Otras maneras de organizar la información en una
lista es usar una pila. Cuando ordenamos los datos mediante una pila, el último
elemento que insertamos también es el primero que debe salir (estructura LIFO: Last In,
First Out). La idea que se pretende representar mediante una pila es la de un conjunto
de objetos colocados en columna, de manera que a medida que se van almacenando
unos encima de otros, no se puede coger un objeto situado debajo hasta que no se quite
el objeto superior. En este caso no es necesario buscar la posición donde se debe
insertar/borrar el nodo, puesto que siempre será en la posición indicada por el puntero al
principio. Como estos procesos son muy fáciles se deja al alumno su implementación.
EJERCICIO.Implementar las funciones de inserción (Poner) y borrado (Quitar) sobre una pila
diseñada mediante una lista enlazada simple.
• Organización de la información mediante colas.
La otra forma de organizar la información en una estructura dinámica tipo lista
es la cola. Cuando ordenamos datos sobre una cola, el último en insertar es también el
último en salir, y el primero que insertamos debe ser el primero en salir (estructura
FIFO: First In, First Out). La idea que se pretende representar mediante una lista es la
de un conjunto de objetos colocados en fila, de manera que a medida que se van
almacenando unos se van colocando tras los anteriores. En este caso tampoco es
necesario buscar la posición donde se debe insertar el nodo, puesto que siempre será en
la posición indicada por el puntero al último que se insertó. Tampoco hay que buscar la
posición del elemento a borrar, puesto que estará al principio de la lista. Al igual que
antes, el alumno debe realizar el siguiente ejercicio.
EJERCICIO.Implementar las funciones de inserción (Poner) y borrado (Quitar) sobre una
cola diseñada mediante una lista doblemente enlazada.
NOTA: Aplicación en informática de pilas y colas.La pila (stack, para almacenamiento de llamadas a funciones –véase más
adelante recursividad - y variables locales y de retorno) es una estructura de datos
relativamente simple y muy utilizada en informática en general y en programación en
particular, tanto en programación de aplicaciones como en programación de sistemas
(desarrollo de lenguajes y compiladores).
Las colas (spool en caso de impresión, buffer en caso de memorias intermedias)
se utilizan con frecuencia en los sistemas informáticos, siempre que más de un proceso
requiera un recurso específico, tal como una impresora o una unidad de disco.
• Organización de la información mediante árboles binarios.
Las listas enlazadas son estructuras de datos lineales que si bien ofrecen una
gran flexibilidad de diseño, determinados problemas son difíciles o, en su caso, lentos
de resolver: por ejemplo, el caso de la búsqueda de un determinado elemento que exige
un recorrido secuencial que ralentiza el proceso. Los árboles son estructuras que
permiten representar datos que tienen enlaces jerárquicos entre ellos, siendo la
representación más corriente los árboles genealógicos o los resultados de un
campeonato de tenis.
Un árbol se define como un conjunto de uno o más nodos, tales que:
- Existe un nodo especial llamado raíz.
- Los restantes nodos o subárboles, cada uno de los cuales es, a su vez,
un árbol.
En la terminología de árboles se destacan los siguientes conceptos:
- Descendiente: un nodo situado directamente debajo de otro.
- Ascendiente: un nodo situado directamente encima de otro.
- Hoja: elemento o nodo terminal, sin descendiente.
- Grado: El número más elevado de descendientes directos de un nodo.
Los árboles binarios son árboles de grado 2 cuyos elementos guardan una
relación de orden. No es lo mismo
que
Definición:
Un árbol binario es un conjunto finito de m (m>=0) nodos, que es o bien vacío
(m==0) o consta de un nodo raíz con dos subárboles binarios llamados subárbol
izquierdo y subárbol derecho (que pueden ser vacíos). Un árbol binario de 0 nodos se
dice que está vacío.
La representación de un árbol binario requiere nodos que deben contener, al
menos, tres partes:
• Información: Campos de datos (o una estructura)
• Un puntero al subárbol izquierdo (puede ser NULL)
• Un puntero al subárbol derecho (puede ser NULL)
La sintaxis de declaración de una estructura árbol binario en C es:
struct nodo
{
struct nodo *Puntero_A_Izquierda;
struct nombre_estructura informacion;
struct nodo *Puntero_A_Derecha;
};
Si la información (clave) de los nodos de la izquierda es comparativamente
menor que la que aparece en el nodo raíz y en los nodos de la derecha se dice que es un
árbol de búsqueda.
A continuación vamos a desarrollar los procesos de inserción, recorrido y
borrado sobre un árbol binario de búsqueda. Todos los ejemplos se basan en la siguiente
declaración de nodo:
typedef struct nodo elem;
struct nodo
{
int info;
elem *izq, *der;
};
Todas las operaciones sobre árboles, en su implementación más fácil, son
recursivas ya que desde un nodo, lo que se tiene a la derecha y a la izquierda es,
precisamente, un árbol binario. El concepto de recursividad se presenta en el apéndice
de este tema.
Inserción en un árbol
La inserción de un nuevo nodo en un árbol de búsqueda se resuelve con el
siguiente procedimiento:
int Inserta(elem p,int valor) 0 No hay memoria o ya existe
//1 Insertado con éxito
{
if (*p==NULL)
{
*p=Reserva();
if (*p)
{
(*p)->info=valor;
(*p)->izq=NULL;
(*p)->der=NULL;
return 1;
}
else
return 0;
}
else
if (valor==(*p)->info)
return 0;
else
{
if (valor<(*p)->info)
Inserta(&(*p)->izq,letra);
else
Inserta(&(*p)->der,letra);
return 1;
}
}
Recorrido
Las operaciones que tengamos que realizar sobre un árbol necesitarán recorrer
secuencialmente cada nodo del mismo siguiendo un orden previamente establecido. Hay
tres posibles ordenaciones, según se efectúe el recorrido de los nodos:
• Preorden.
Se considera la raíz antes que los subárboles, que se recorren de
izquierda a derecha. (RID)
• Orden Central.
Se considera primero el árbol de más a la izquierda, seguido de la
raíz y los demás subárboles de izquierda a derecha. (IRD)
• Postorden.**
Se consideran los subárboles ordenados de izquierda a derecha
seguidos de la raíz. (IDR)
Las funciones recursivas que presentan la información de cada nodo usando los
distintos recorridos son:
int Orden(elem *p)
{
if (p)
{
Orden(p->izq);
printf("%d\t",p->info);
Orden(p->der);
return 1;
}
else return 0;
}
int PreOrden(elem *p)
{
if (p)
{
printf("%d\t",p->info);
PreOrden(p->izq);
PreOrden(p->der);
return 1;
}
else return 0;
}
int PosOrden(elem *p)
{
if (p)
{
PosOrden(p->izq);
PosOrden(p->der);
printf("%d\t",p->info);
return 1;
}
else return 0;
}
Todas las funciones devuelven 0 si el árbol está vacío.
Borrado
Cuando se desea borrar un nodo de un árbol binario se presentan dos casos
posibles: que dicho nodo sea un nodo terminal (hoja), con lo que el borrado se realiza
directamente eliminando el nodo, o que el nodo sea un nodo intermedio. En este caso al
borrar el nodo hay que reequilibrar el árbol para que siga manteniendo su estructura.
Este algoritmo cae fuera de las pretensiones de este módulo, por lo que se propone al
alumno que realice el borrado de un nodo intermedio mediante la creación de un nuevo
árbol donde se van insertando los nodos del árbol original, excepto el nodo que se desea
eliminar.