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.