(estructuras dinámicas de datos)
Una de las aplicaciones más interesantes y potentes de la memoria dinámica y de los punteros son, las estructuras dinámicas de datos. Las estructuras básicas disponibles en C y C++ (structs y arrays) tienen una importante limitación: no pueden cambiar de tamaño durante la ejecución. Los arrays están compuestos por un determinado número de elementos, número que se decide en la fase de diseño, antes de que el programa ejecutable sea creado.
Las estructuras dinámicas nos permiten crear estructuras de datos que se adapten a las necesidades reales a las que suelen enfrentarse nuestros programas. Pero no sólo eso, como veremos, también nos permitirán crear estructuras de datos muy flexibles, ya sea en cuanto al orden, la estructura interna o las relaciones entre los elementos que las componen.
Las estructuras de datos están compuestas de otras pequeñas estructuras a las que llamaremos nodos o elementos, que agrupan los datos con los que trabajará nuestro programa y además uno o más punteros autoreferenciales, es decir, punteros a objetos del mismo tipo nodo.
Una estructura básica de un nodo para crear listas de datos seria:
struct nodo { int dato; struct nodo *otronodo; };
El campo "otronodo" puede apuntar a un objeto del tipo nodo. De este modo, cada nodo puede usarse como un ladrillo para construir listas de datos, y cada uno mantendrá ciertas relaciones con otros nodos.
Para acceder a un nodo de la estructura sólo necesitaremos un puntero a un nodo.
Durante el presente curso usaremos gráficos para mostrar la estructura de las estructuras de datos dinámicas. El nodo anterior se representará asi:
Nodo
Las estructuras dinámicas son una implementación de TDAs o TADs (Tipos Abstractos de Datos). En estos tipos el interés se centra más en la estructura de los datos que en el tipo concreto de información que almacenan.
CONCEPTOS BÁSICOS:
- Listas abiertas: cada elemento sólo dispone de un puntero, que apuntará al siguiente elemento de la lista o valdrá NULL si es el último elemento.
- Pilas: son un tipo especial de lista, conocidas como listas LIFO (Last In, First Out: el último en entrar es el primero en salir). Los elementos se "amontonan" o apilan, de modo que sólo el elemento que está encima de la pila puede ser leído, y sólo pueden añadirse elementos encima de la pila.
- Colas: otro tipo de listas, conocidas como listas FIFO (First In, First Out: El primero en entrar es el primero en salir). Los elementos se almacenan en fila, pero sólo pueden añadirse por un extremo y leerse por el otro.
- Listas circulares: o listas cerradas, son parecidas a las listas abiertas, pero el último elemento apunta al primero. De hecho, en las listas circulares no puede hablarse de "primero" ni de "último". Cualquier nodo puede ser el nodo de entrada y salida.
- Listas doblemente enlazadas: cada elemento dispone de dos punteros, uno a punta al siguiente elemento y el otro al elemento anterior. Al contrario que las listas abiertas anteriores, estas listas pueden recorrerse en los dos sentidos.
- Arboles: cada elemento dispone de dos o más punteros, pero las referencias nunca son a elementos anteriores, de modo que la estructura se ramifica y crece igual que un árbol.
- Arboles binarios: son árboles donde cada nodo sólo puede apuntar a dos nodos.
- Arboles binarios de búsqueda (ABB): son árboles binarios ordenados. Desde cada nodo todos los nodos de una rama serán mayores, según la norma que se haya seguido para ordenar el árbol, y los de la otra rama serán menores.
- Arboles AVL: son también árboles de búsqueda, pero su estructura está más optimizada para reducir los tiempos de búsqueda.
- Arboles B: son estructuras más complejas, aunque también se trata de árboles de búsqueda, están mucho más optimizados que los anteriores.
- Tablas HASH: son estructuras auxiliares para ordenar listas.
- Grafos: es el siguiente nivel de complejidad, podemos considerar estas estructuras como árboles no jerarquizados.
ºLISTAS ABIERTAS
La forma más simple de estructura dinámica es la lista abierta. En esta forma los nodos se organizan de modo que cada uno apunta al siguiente, y el último no apunta a nada, es decir, el puntero del nodo siguiente vale NULL.
En las listas abiertas existe un nodo especial: el primero. Normalmente diremos que nuestra lista es un puntero a ese primer nodo y llamaremos a ese nodo la cabeza de la lista. Eso es porque mediante ese único puntero podemos acceder a toda la lista.
Cuando el puntero que usamos para acceder a la lista vale NULL, diremos que la lista está vacía.
El nodo típico para construir listas tiene esta forma:
struct nodo { int dato; struct nodo *siguiente; };
las listas son secuenciales de 0 o mas elementos de un tipo de datos almacenado e memoria. son estructuras lineales donde cada elemento de una lista excepto el ultimo tiene un sucesor
typedef struct _nodo { int dato; struct _nodo *siguiente; } tipoNodo; typedef tipoNodo *pNodo; typedef tipoNodo *Lista;
tipo Nodo es el tipo para declarar nodos, evidentemente.
pNodo es el tipo para declarar punteros a un nodo.
Lista es el tipo para declarar listas, como puede verse, un puntero a un nodo y una lista son la misma cosa. En realidad, cualquier puntero a un nodo es una lista, cuyo primer elemento es el nodo apuntado. Lista enlazada
EJEMPLO DE LISTAS ABIERTAS EN C++
// ListaAb.h: Plantilla para lista abierta ordenada // C con Clase. (C) Marzo de 2002 // Plantilla para lista abierta // Posibles mejoras: // * Implementar constructor copia. #ifndef _LISTAABIERTA_ #define _LISTAABIERTA_ template<class DATO> class Lista { private: //// Clase local de Lista para Nodo de Lista: template<class DATON> class Nodo { public: // Constructor: Nodo(const DATON dat, Nodo<DATON> *sig) : dato(dat), siguiente(sig) {} // Miembros: DATON dato; Nodo<DATON> *siguiente; }; // Punteros de la lista, para cabeza y nodo actual: Nodo<DATO> *primero; Nodo<DATO> *actual; public: // Constructor y destructor básicos: Lista() : primero(NULL), actual(NULL) {} ~Lista(); // Funciones de inserción: void InsertarFinal(const DATO dat); void InsertarPrincipio(const DATO dat); bool InsertarActual(const DATO dat); void Insertar(const DATO dat); // Funciones de borrado: void BorrarActual(); bool BorrarPrimerValor(const DATO dat); // Función de búsqueda: bool BuscarPrimerValor(const DATO dat); // Comprobar si la lista está vacía: bool Vacia() { return primero==NULL; } // Devolver referencia al dato del nodo actual: DATO &ValorActual() { return actual->dato; } // Hacer que el nodo actual sea el primero: void Primero() { actual = primero; } // Comprobar si el nodo actual es válido: bool Actual() { return actual != NULL; } // Moverse al siguiente nodo de la lista: void Siguiente() { if(actual) actual = actual->siguiente; } // Sobrecargar operator++ en forma sufija para los mismo: void operator++(int) { Siguiente(); } // Aplicar una función a cada elemento de la lista: void ParaCada(void (*func)(DATO&)); }; //////// Implementación: // Destructor template<class DATO> Lista<DATO>::~Lista() { while(!Vacia()) { actual = primero; primero = primero->siguiente; delete actual; } } template<class DATO> void Lista<DATO>::InsertarFinal(const DATO dat) { Nodo<DATO> *ultimo; // Si la lista está vacía, insertar al principio: if(Vacia()) InsertarPrincipio(dat); else { // Si no lo está: // Buscar el último nodo: ultimo = primero; while(ultimo->siguiente) ultimo = ultimo->siguiente; // Insertar a continuación: ultimo->siguiente = new Nodo<DATO>(dat, NULL); } } template<class DATO> void Lista<DATO>::InsertarPrincipio(const DATO dat) { primero = new Nodo<DATO>(dat, primero); } template<class DATO> bool Lista<DATO>::InsertarActual(const DATO dat) { // Sólo si la lista no está vacía y actual es válido: if(!Vacia() && actual) { actual->siguiente = new Nodo<DATO>(dat, actual->siguiente); return true; } // Si no se puede insertar, retornar con false: return false; } // Insertar ordenadamente: template<class DATO> void Lista<DATO>::Insertar(const DATO dat) { Nodo<DATO> *temp = primero; Nodo<DATO> *anterior = NULL; // Si la lista está vacía, insertar al principio: if(Vacia()) InsertarPrincipio(dat); else { // Buscar el nodo anterior al primer nodo con un dato mayor qur 'dat' while(temp && temp->dato < dat) { anterior = temp; temp = temp->siguiente; } // Si no hay anterior, insertar al principio, // nuestro valor es el menor de la lista: if(!anterior) InsertarPrincipio(dat); else // Insertar: anterior->siguiente = new Nodo<DATO>(dat, temp); } } template<class DATO> void Lista<DATO>::BorrarActual() { Nodo<DATO> *anterior; // Si el nodo actual es el primero: if(actual && actual == primero) { // El primer nodo será ahora el segundo: // Sacar el nodo actual de la lista: primero = actual->siguiente; // Borrarlo: delete actual; actual = NULL; } else if(actual && !Vacia()) { // Buscar el nodo anterior al actual: anterior = primero; while(anterior && anterior->siguiente != actual) anterior = anterior->siguiente; // Sacar el nodo actual de la lista: anterior->siguiente = actual->siguiente; // Borrarlo: delete actual; actual = NULL; } } // Borrar el primer nodo cuyo dato sea igual a 'dat': template<class DATO> bool Lista<DATO>::BorrarPrimerValor(const DATO dat) { Nodo<DATO> *anterior = NULL; Nodo<DATO> *temp = primero; if(!Vacia()) { // Si la lista no está vacía, buscar el nodo a borrar (temp) // y el nodo anterior a ese (anterior): while(temp && temp->dato != dat) { anterior = temp; temp = temp->siguiente; } // Si el valor está en la lista: if(temp) { // Si anterior es válido, no es el primer valor de la lista if(anterior) // Sacar nodo temp de la lista anterior->siguiente = temp->siguiente; else // Ahora el primero es el segundo: primero = temp->siguiente; // Borrar primer elemento // Borrar nodo: delete temp; return true; // Se ha encontrado y borrado dat } } return false; // valor no encontrado } // Busca el primer nodo con valor 'dat': template<class DATO> bool Lista<DATO>::BuscarPrimerValor(const DATO dat) { actual = primero; // Si la lista no está vacía: if(!Vacia()) { while(actual && actual->dato != dat) { actual = actual->siguiente; } } // Si el nodo es válido, se ha encontrado el valor: return actual != NULL; } // Aplicar una función a cada nodo de la lista: template<class DATO> void Lista<DATO>::ParaCada(void (*func)(DATO&)) { Nodo<DATO> *temp = primero; // Recorrer la lista: while(temp) { // Aplicar la función: func(temp->dato); temp = temp->siguiente; } } // La función "func" debe ser una plantilla de una función // que no retorne valor y que admita un parámetro del mismo // tipo que la lista: // template <class DATO> // void <funcion>(DATO d); #endif
ºPILAS
Una pila es un tipo especial de lista abierta en la que sólo se pueden insertar y eliminar nodos en uno de los extremos de la lista. Estas operaciones se conocen como "push" y "pop", respectivamente "empujar" y "tirar". Además, las escrituras de datos siempre son inserciones de nodos, y las lecturas siempre eliminan el nodo leído.
Estas características implican un comportamiento de lista LIFO (Last In First Out), el último en entrar es el primero en salir.
El símil del que deriva el nombre de la estructura es una pila de platos. Sólo es posible añadir platos en la parte superior de la pila, y sólo pueden tomarse del mismo extremo.
Los tipos que definiremos normalmente para manejar pilas serán casi los mismos que para manejar listas, tan sólo cambiaremos algunos nombres:struct nodo { int dato; struct nodo *siguiente; };
typedef struct _nodo { int dato; struct _nodo *siguiente; } tipoNodo; typedef tipoNodo *pNodo; typedef tipoNodo *Pila;
EJEMPLOS DE PILAS EN C++
class nodo { public: nodo(int v, nodo *sig = NULL) { valor = v; siguiente = sig; } private: int valor; nodo *siguiente; friend class pila; }; typedef nodo *pnodo; class pila { public: pila() : ultimo(NULL) {} ~pila(); void Push(int v); int Pop(); private: pnodo ultimo; };
º COLAS
Una cola es un tipo especial de lista abierta en la que sólo se pueden insertar nodos en uno de los extremos de la lista y sólo se pueden eliminar nodos en el otro. Además, como sucede con las pilas, las escrituras de datos siempre son inserciones de nodos, y las lecturas siempre eliminan el nodo leído.
Este tipo de lista es conocido como lista FIFO (First In First Out), el primero en entrar es el primero en salir.
El símil cotidiano es una cola para comprar, por ejemplo, las entradas del cine. Los nuevos compradores sólo pueden colocarse al final de la cola, y sólo el primero de la cola puede comprar la entrada.
typedef struct _nodo { int dato; struct _nodo *siguiente; } tipoNodo; typedef tipoNodo *pNodo; typedef tipoNodo *Cola;
EJEMPLO DE COLA EN C++
º LISTAS CIRCULARES
Ya hemos visto que las colas son casos particulares de listas abiertas, pero más simples. Como en los casos anteriores, veremos ahora un ejemplo de cola usando clases.
Para empezar, y como siempre, necesitaremos dos clases, una para nodo y otra para cola. Además la clase para nodo debe ser amiga de la clase cola, ya que ésta debe acceder a los miembros privados de nodo.class nodo { public: nodo(int v, nodo *sig = NULL) { valor = v; siguiente = sig; } private: int valor; nodo *siguiente; friend class cola; }; typedef nodo *pnodo; class cola { public: cola() : ultimo(NULL), primero(NULL) {} ~cola(); void Anadir(int v); int Leer(); private: pnodo primero, ultimo;Los algoritmos para Anadir y Leer son los mismos que expusimos para el ejemplo C, tan sólo cambia el modo de crear y destruir nodos.
º LISTAS CIRCULARES
Una lista circular es una lista lineal en la que el último nodo a punta al primero.
Las listas circulares evitan excepciones en la operaciones que se realicen sobre ellas. No existen casos especiales, cada nodo siempre tiene uno anterior y uno siguiente.
En algunas listas circulares se añade un nodo especial de cabecera, de ese modo se evita la única excepción posible, la de que la lista esté vacía.
typedef struct _nodo { int dato; struct _nodo *siguiente; } tipoNodo; typedef tipoNodo *pNodo; typedef tipoNodo *Lista;
tipoNodo es el tipo para declarar nodos, evidentemente.
pNodo es el tipo para declarar punteros a un nodo.
Lista es el tipo para declarar listas, tanto abiertas como circulares. En el caso de las circulares, apuntará a un nodo cualquiera de la lista.
Lista circular
A pesar de que las listas circulares simplifiquen las operaciones sobre ellas, también introducen algunas complicaciones. Por ejemplo, en un proceso de búsqueda, no es tan sencillo dar por terminada la búsqueda cuando el elemento buscado no existe.
Por ese motivo se suele resaltar un nodo en particular, que no tiene por qué ser siempre el mismo. Cualquier nodo puede cumplir ese propósito, y puede variar durante la ejecución del programa.
Otra alternativa que se usa a menudo, y que simplifica en cierto modo el uso de listas circulares es crear un nodo especial de hará la función de nodo cabecera. De este modo, la lista nunca estará vacía, y se eliminan casi todos los casos especiales.
EJEMPLO LISTAS CIRCULAR EN C++
class nodo { public: nodo(int v, nodo *sig = NULL) { valor = v; siguiente = sig; } private: int valor; nodo *siguiente; friend class lista; }; typedef nodo *pnodo; class lista { public: lista() { actual = NULL; } ~lista(); void Insertar(int v); void Borrar(int v); bool ListaVacia() { return actual == NULL; } void Mostrar(); void Siguiente(); bool Actual() { return actual != NULL; } int ValorActual() { return actual->valor; } private: pnodo actual; };
º LISTAS DOBLES ENLAZADAS
Una lista doblemente enlazada es una lista lineal en la que cada nodo tiene dos enlaces, uno al nodo siguiente, y otro al anterior.
Las listas doblemente enlazadas no necesitan un nodo especial para acceder a ellas, pueden recorrerse en ambos sentidos a partir de cualquier nodo, esto es porque a partir de cualquier nodo, siempre es posible alcanzar cualquier nodo de la lista, hasta que se llega a uno de los extremos.
typedef struct _nodo { int dato; struct _nodo *siguiente; struct _nodo *anterior; } tipoNodo; typedef tipoNodo *pNodo; typedef tipoNodo *Lista;
EJEMPLOS DE LISTAS DOBLES ENLAZADAS EN C++
class nodo { public: nodo(int v, nodo *sig = NULL, nodo *ant = NULL) : valor(v), siguiente(sig), anterior(ant) {} private: int valor; nodo *siguiente; nodo *anterior; friend class lista; }; typedef nodo *pnodo; class lista { public: lista() : lista(NULL) {} ~lista(); void Insertar(int v); void Borrar(int v); bool ListaVacia() { return lista == NULL; } void Mostrar(); void Siguiente(); void Anterior(); void Primero(); void Ultimo(); pnodo Actual() { return lista; } int ValorActual() { return lista->valor; } private: pnodo lista; };
Un árbol es una estructura no lineal en la que cada nodo puede apuntar a uno o varios nodos.
También se suele dar una definición recursiva: un árbol es una estructura en compuesta por un dato y varios árboles.
Esto son definiciones simples. Pero las características que implican no lo son tanto.
Definiremos varios conceptos. En relación con otros nodos:
- Nodo hijo: cualquiera de los nodos apuntados por uno de los nodos del árbol. En el ejemplo, 'L' y 'M' son hijos de 'G'.
- Nodo padre: nodo que contiene un puntero al nodo actual. En el ejemplo, el nodo 'A' es padre de 'B', 'C' y 'D'.
Los árboles con los que trabajaremos tienen otra característica importante: cada nodo sólo puede ser apuntado por otro nodo, es decir, cada nodo sólo tendrá un padre. Esto hace que estos árboles estén fuertemente jerarquizados, y es lo que en realidad les da la apariencia de árboles.
En cuanto a la posición dentro del árbol:
- Nodo raíz: nodo que no tiene padre. Este es el nodo que usaremos para referirnos al árbol. En el ejemplo, ese nodo es el 'A'.
- Nodo hoja: nodo que no tiene hijos. En el ejemplo hay varios: 'F', 'H', 'I', 'K', 'L', 'M', 'N' y 'O'.
- Nodo rama: aunque esta definición apenas la usaremos, estos son los nodos que no pertenecen a ninguna de las dos categorías anteriores. En el ejemplo: 'B', 'C', 'D', 'E', 'G' y 'J'.
Otra característica que normalmente tendrán nuestros árboles es que todos los nodos contengan el mismo número de punteros, es decir, usaremos la misma estructura para todos los nodos del árbol. Esto hace que la estructura sea más sencilla, y por lo tanto también los programas para trabajar con ellos.
Tampoco es necesario que todos los nodos hijos de un nodo concreto existan. Es decir, que pueden usarse todos, algunos o ninguno de los punteros de cada nodo.
Un árbol en el que en cada nodo o bien todos o ninguno de los hijos existe, se llama árbol completo.
En una cosa, los árboles se parecen al resto de las estructuras que hemos visto: dado un nodo cualquiera de la estructura, podemos considerarlo como una estructura independiente. Es decir, un nodo cualquiera puede ser considerado como la raíz de un árbol completo.
Existen otros conceptos que definen las características del árbol, en relación a su tamaño:
- Orden: es el número potencial de hijos que puede tener cada elemento de árbol. De este modo, diremos que un árbol en el que cada nodo puede apuntar a otros dos es de orden dos, si puede apuntar a tres será de orden tres, etc.
- Grado: el número de hijos que tiene el elemento con más hijos dentro del árbol. En el árbol del ejemplo, el grado es tres, ya que tanto 'A' como 'D' tienen tres hijos, y no existen elementos con más de tres hijos.
- Nivel: se define para cada elemento del árbol como la distancia a la raíz, medida en nodos. El nivel de la raíz es cero y el de sus hijos uno. Así sucesivamente. En el ejemplo, el nodo 'D' tiene nivel 1, el nodo 'G' tiene nivel 2, y el nodo 'N', nivel 3.
- Altura: la altura de un árbol se define como el nivel del nodo de mayor nivel. Como cada nodo de un árbol puede considerarse a su vez como la raíz de un árbol, también podemos hablar de altura de ramas. El árbol del ejemplo tiene altura 3, la rama 'B' tiene altura 2, la rama 'G' tiene altura 1, la 'H' cero, etc.
º ARBOLES VINARIOS DE BÚSQUEDA (ABB)
Se trata de árboles de orden 2 en los que se cumple que para cada nodo, el valor de la clave de la raíz del subárbol izquierdo es menor que el valor de la clave del nodo y que el valor de la clave raíz del subárbol derecho es mayor que el valor de la clave del nodo.
Árbol binario de búsqueda
º ARBOLES AVL
Un árbol AVL (llamado así por las iniciales de sus inventores: Adelson-Velskii y Landis) es un árbol binario de búsqueda en el que para cada nodo, las alturas de sus subárboles izquierdo y derecho no difieren en más de 1.
No se trata de árboles perfectamente equilibrados, pero sí son lo suficientemente equilibrados como para que su comportamiento sea lo bastante bueno como para usarlos donde los ABB no garantizan tiempos de búsqueda óptimos.
El algoritmo para mantener un árbol AVL equilibrado se basa en reequilibrados locales, de modo que no es necesario explorar todo el árbol después de cada inserción o borrado.
FE = altura subárbol derecho - altura subárbol izquierdo;

FE = altura subárbol derecho - altura subárbol izquierdo;

contiene muy buena informacion, es entendible, tiene buena presentacion y tiene un orden no esta todo revuelto
ResponderEliminar