Académique Documents
Professionnel Documents
Culture Documents
Objetivo de la
unidad
Introducción a la
asignatura
Desglose de
temas
Glosario
ESTRUCTURA DE DATOS I
VARGAS
FECHA DE ENTRADA
APROBÓ: CUERPO COLEGIADO TIC-SI EN VIGOR: SEPTIEMBRE 2004
ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE
I. DIRECTORIO
ÍNDICE
Preámbulo…………………………………………………………………………………………………………5
1. Antecedentes……………………………………………………………………………9.
1.1. Introducción a la orientación a objetos……………………………………………9
1.2. Tipos de datos abstractos…………………………………………………………...11
1.3. Definición de estructuras de datos………………………………………………….14
1.4. Acceso directo y Secuencial a los datos………………………………… 16
1.5. Iteradores……………………………………………………………………………………………… 16
1.6. Apuntadores o punteros…………………………………………………………...16
1.7. Plantillas (Templates)……………………………………………………………..34
1.8. La biblioteca STL…………………………………………………………………38
2. Arreglos………………………………………………………………………………..34
2.1. Introducción…..…………………………………………………………………...34
2.2. Arreglos dinámicos………………………………………………………………..36
2.3. La clase VECTOR………………………………………………………………...36
3. Listas…………………………………………………………………………………...44
3.1. Definición de lista…………………………………………………………………44
3.2. Operaciones básicas con listas…………………………………………………….45
3.2.1. Insertar un elemento en la lista……………………………………………..45
3.2.2. Localizar un elemento en la lista…………………………………………...47
3.2.3. Eliminar elementos de la lista……………………………………………...48
3.2.4. Moverse a través de una lista………………………………………………50
3.2.5. Borrar una lista completa…………………………………………………..50
3.2.6. Ejemplo de lista Ordenada………………………………………………....50
3.2.7. Ejemplo de lista en C++ usando clases…………………………………….53
3.3. La clase LIST de STL……………………………………………………………..54
4. Pilas…………………………………………………………………………………….58
4.1. Definición de pilas……………………………………………………………...…58
4.2. Operaciones básicas con pilas……………………………………………………..59
4.2.1. Push, insertar elemento…………………………………………………….59
4.2.2. Pop, leer y eliminar un elemento…………………………………………...60
4.3. Implementación de pilas…………………………………………………………..60
4.3.1. Ejemplo de pilas en C++ usando clases……………………………………61
4.4. La clase STACK de STL………………………………………………………….62
5. Colas…...………………………………………………………………………………65
5.1. Definición de colas………………………………………………………………..65
5.2. Operaciones básicas con colas…………………………………………………….66
5.2.1. Añadir un elemento………………………………………………………...66
5.2.2. Leer un elemento…………………………………………………………..67
5.3. Implementación de colas………………………………………………………….69
5.3.1. Ejemplo de una cola en C++ usando clases………………………………..70
5.4. La clase QUEUE de STL………………………………………………………….71
6. Árboles…….…………………………………………………………………………..73
6.1. Definición de árboles……………………………………………………………...73
Preámbulo.
Resumidamente, el ANSI define un conjunto de reglas. Cualquier compilador de C
o de C++ debe cumplir esas reglas, si no, no puede considerarse un compilador de
C o C++. Estas reglas definen las características de un compilador en cuanto a
palabras reservadas del lenguaje, comportamiento de los elementos que lo
componen, funciones externas que se incluyen, etc. Un programa escrito en ANSI
C o en ANSI C++, podrá compilarse con cualquier compilador que cumpla la
norma ANSI. Se puede considerar como una homologación o etiqueta de calidad
de un compilador.
Este curso es sobre C++, con respecto a las diferencias entre C y C++, habría
mucho que hablar, pero no es este el momento adecuado. Pero para comprender
muchas de estas diferencias necesitarás cierto nivel de conocimientos de C++.
Los programas C y C++ se escriben con la ayuda de un editor de textos del mismo
modo que cualquier texto corriente. Los ficheros que contiene programas en C o
C++ en forma de texto se conocen como ficheros fuente, y el texto del programa
que contiene se conoce como programa fuente. Nosotros siempre escribiremos
programas fuente y los guardaremos en ficheros fuente.
Los programas fuente no pueden ejecutarse. Son ficheros de texto, pensados para
que los comprendan los seres humanos, pero incomprensibles para los
ordenadores.
Para conseguir un programa ejecutable hay que seguir algunos pasos. El primero
es compilar o traducir el programa fuente a su código objeto equivalente. Este es
el trabajo que hacen los compiladores de C y C++. Consiste en obtener un fichero
Los compiladores son programas que leen un fichero de texto que contiene el
programa fuente y generan un fichero que contiene el código objeto.
El código objeto no tiene ningún significado para los seres humanos, al menos no
directamente. Además es diferente para cada ordenador y para cada sistema
operativo. Por lo tanto existen diferentes compiladores para diferentes sistemas
operativos y para cada tipo de ordenador.
Librerías:
Hay un conjunto de librerías muy especiales, que se incluyen con todos los
compiladores de C y de C++. Son las librerías ANSI o estándar. Pero también hay
librerías no estándar, y dentro de estas las hay públicas y comerciales. En este
curso sólo usaremos librerías ANSI.
Existe un programa que hace todas estas cosas, se trata del "link", o enlazador. El
enlazador toma todos los ficheros objeto que componen nuestro programa, los
combina con los ficheros de librería que sea necesario y crea un fichero ejecutable.
Errores:
Por supuesto, somos humanos, y por lo tanto nos equivocamos. Los errores de
programación pueden clasificarse en varios tipos, dependiendo de la fase en que
se presenten.
Errores de diseño: finalmente los errores más difíciles de corregir y prevenir. Si nos
hemos equivocado al diseñar nuestro algoritmo, no habrá ningún programa que
nos pueda ayudar a corregir los nuestros. Contra estos errores sólo cabe practicar
y pensar.
Propósito de C/C++
Oirás y leerás mucho sobre este tema. Sobre todo diciendo que estos lenguajes
son complicados y que requieren páginas y páginas de código para hacer cosas
que con otros lenguajes se hacen con pocas líneas. Esto es una verdad a medias.
Es cierto que un listado completo de un programa en C o C++ para gestión de
bases de datos (por poner un ejemplo) puede requerir varios miles de líneas de
código, y que su equivalente en Visual Basic sólo requiere unos pocos cientos.
Pero detrás de cada línea de estos compiladores de alto nivel hay cientos de líneas
de código en C, la mayor parte de estos compiladores están respaldados por
enormes librerías escritas en C. Nada te impide a ti, como programador, usar
librerías, e incluso crear las tuyas propias.
Además, los programas escritos en C o C++ tienen otras ventajas sobre el resto.
Con la excepción del ensamblador, generan los programas más compactos y
rápidos. El código es transportable, es decir, un programa ANSI en C o C++ podrá
ejecutarse en cualquier máquina y bajo cualquier sistema operativo. Y si es
necesario, proporcionan un acceso a bajo nivel de hardware sólo igualado por el
ensamblador.
1. Antecedentes
Encapsulación.
En un objeto, los datos y el código, o ambos, pueden ser privados para ese
objeto o públicos. Los datos o el código privado solo los conoce o son accesibles
por otra parte del objeto. Es decir, una parte del programa que esta fuera del
objeto no puede acceder al código o a los datos privados. Cuando los datos o el
código son públicos, otras partes del programa pueden acceder a ellos, incluso
aunque este definido dentro de un objeto. Normalmente, las partes públicas de un
objeto se utilizan para proporcionar una interfaz controlada a las partes privadas
del objeto.
Para todos los propósitos, un objeto es una variable de un tipo definido por
el usuario. Puede parecer extraño que un objeto que enlaza código y datos se
pueda contemplar como una variable. Sin embargo, en programación orientada a
objetos, este es precisamente el caso. Cada vez que se define un nuevo objeto, se
esta creando un nuevo tipo de dato. Cada instancia específica de este tipo de dato
es una variable compuesta.
Polimorfismo.
Herencia.
En cualquier caso, la clase hija hereda todas las cualidades asociadas con
la clase padre y le añade sus propias características definitorias. Sin el uso de
clasificaciones ordenadas, cada objeto tendría que definir todas las características
que se relacionan con él explícitamente.
Los TDA por lo general manejan memoria dinámica, esto es, la asignación
dinámica de memoria es una característica que le permite al usuario crear tipos de
datos y estructuras de cualquier tamaño de acuerdo a las necesidades que se
tengan en el programa, para ello se emplean funciones típicas como malloc y free.
Arrays (Arreglos)
o Vectores
o Matrices
Listas Enlazadas
o Listas Simples
o Listas Dobles
o Listas Circulares
Pilas (stack)
Colas (queue)
Árboles
o Árboles Binarios
Árbol binario de búsqueda
Árbol binario de búsqueda autoajustable
Árboles Biselados (Árboles Splay)
o Árboles Multicamino (Multirrama)
Árboles B
Árboles B+
Árboles B*
Conjuntos (set)
Grafos
Montículos (o heaps)
1.5 Iteradores
Un iterador es una especie de puntero utilizado por un algoritmo para recorrer los
elementos almacenados en un contenedor. Dado que los distintos algoritmos
necesitan recorrer los contenedores de diversas maneras para realizar diversas
operaciones, y los contenedores deben ser accedidos de formas distintas, existen
Sintaxis:
tipo * nombre_puntero;
Ejemplo 1:
En la siguiente declaración, p es un puntero a float, i es una variable tipo int y q es
un puntero a int.
int i, *q;
float *p;
Ejemplo 2:
int A, *p;
p = &A;
Asignación de punteros
Ejemplo 3:
int A, *p, *q;
p = &A;
q = p;
Ejemplo 10:
La siguiente función pretende intercambiar los valores de dos variables de tipo
float:
La forma de invocar esta función podría ser swap(a, b), siendo a y b las
variables cuyos valores queremos intercambiar. De todas maneras, la función no
altera los valores de a y de b porque sólo intercambia “copias” de estas variables.
Hace un pasaje de argumentos por valor. Por lo tanto, esto no es correcto.
Ejemplo 11:
Luego de analizar el ejemplo anterior, observemos la siguiente función.
void swap(float *x, float *y)
{
int aux;
aux = *x;
*x = *y;
*y = aux;
}
Los tipos base apuntados pueden ser todos los provistos de manera
standard por el C++: int, char, long, float, double. También puede apuntar a un tipo
no especificado de dato como void, o a otro puntero.
Punteros a caracteres
Sintaxis:
char * nombre_cadena;
Ejemplo 13:
El siguiente programa imprime en pantalla la frase “color azul”.
Punteros a estructura
Sintaxis:
struct tipo_estructura * nombre_estructura;
Ejemplo 17:
Se crea un tipo de dato estructura con el nombre fecha. Luego se declara una
variable de tipo estructura-fecha con el nombre hoy, y finalmente un puntero a
estructura-fecha llamado F.
struct fecha {
int día;
int mes;
int anio;
};
struct fecha hoy;
struct fecha * F;
Plantillas de funciones
Supóngase que se quiere crear una función que devolviese el mínimo entre
dos valores independientemente de su tipo (se supone que ambos tienen el mismo
tipo). Se podría pensar en definir la función tantas veces como tipos de datos se
puedan presentar (int, long, float, double, etc.). Aunque esto es posible, éste es un
caso ideal para aplicar plantillas de funciones. Esto se puede hacer de la siguiente
manera:
En ese caso con <classT> se está indicando que se trata de una plantilla
cuyo parámetro va a ser el tipo T y que tanto el valor de retorno como cada uno de
los dos argumentos va a ser de este tipo de dato T. En la definición y declaración
de la plantilla puede ser que se necesite utilizar mas de un tipo de dato e incluido
algún otro parámetro constante que pueda ser utilizado en las declaraciones. Por
ejemplo, si hubiera que pasar dos tipos a la plantilla, se podría escribir:
#include <iostream.h>
template <class T> T minimo(T a, T b);
void main(void)
{
int euno=1;
int edos=5;
cout << minimo(euno, edos) << endl;
long luno=1;
long ldos=5;
cout << minimo(luno, ldos) << endl;
char cuno='a';
char cdos='d';
cout << minimo(cuno, cdos) << endl;
double duno=1.8;
double ddos=1.9;
cout << minimo(duno, ddos) << endl;
}
#include <iostream.h>
template <class S> void permutar(S&, S&);
void main(void)
{
int i=2, j=3;
cout << "i=" << i << " " << "j=" << j << endl;
permutar(i, j);
cout << "i=" << i << " " << "j=" << j << endl;
double x=2.5, y=3.5;
cout << "x=" << x << " " << "y=" << y << endl;
permutar(x, y);
cout << "x=" << x << " " << "y=" << y << endl;
}
template <class S> void permutar(S& a, S& b)
{ S temp;
temp = a;
a = b;
b = temp;
}
Plantillas de clases
// fichero Pila.h
template <class T>
// declaración de la clase
class Pila
{
public:
Pila(int nelem=10); // constructor
void Poner(T);
void Imprimir();
private:
int nelementos;
T* cadena;
int limite;
};
// definición del constructor
template <class T> Pila<T>::Pila(int nelem)
{
nelementos = nelem;
cadena = new T(nelementos);
limite = 0;
};
// definición de las funciones miembro
template <class T> void Pila<T>::Poner(T elem)
{
if (limite < nelementos)
cadena[limite++] = elem;
};
template <class T> void Pila<T>::Imprimir()
{
int i;
for (i=0; i<limite; i++)
cout << cadena[i] << endl;
};
#include <iostream.h>
#include "Pila.h"
void main()
{
Pila <int> p1(6);
p1.Poner(2);
p1.Poner(4);
p1.Imprimir();
Pila <char> p2(6);
p2.Poner('a');
p2.Poner('b');
p2.Imprimir();
}
Puede pensarse que las plantillas y el polimorfismo son dos utilidades que
se excluyen mutuamente. Aunque es verdad que el parecido entre ambas es
grande, hay también algunas diferencias que pueden hacer necesarias ambas
características. El polimorfismo necesita punteros y su generalidad se limita a
jerarquías. Recuérdese que el polimorfismo se basa en que en el momento de
compilación se desconoce a qué clase de la jerarquía va a apuntar un puntero que
se ha definido como puntero a la clase base. Desde este punto de vista las
plantillas pueden considerarse como una ampliación del polimorfismo. Una
desventaja de las plantillas es que tienden a crear un código ejecutable grande
porque se crean tantas versiones de las funciones como son necesarias.
Secuenciales:
o Vectores: contienen elementos contiguos almacenados al estilo de
un array o vector del lenguaje C++.
o Listas: secuencias de elementos almacenados en una lista enlazada.
o Deques: contenedores parecidos a los vectores, excepto que
permiten inserciones y borrados en tiempo constante tanto al
principio como al final.
Adaptadores:
o Colas: contenedores que ofrecen la funcionalidad de listas " primero
en entrar, primero en salir".
o Pilas: contenedores asociados a listas " primero en entrar, último en
salir".
o Colas con prioridad: en este caso, los elementos de la cola salen de
ella de acuerdo con una prioridad (que se estableció en la inserción).
Asociativos.
o Conjuntos de bits: contenedor para almacenar bits.
o Mapas: almacenan pares "clave, objeto", es decir, almacenan objetos
referidos mediante un identificador único.
o Multimapas: mapas que permiten claves duplicadas.
o Conjuntos: conjuntos ordenados de objetos únicos.
o Multiconjuntos: conjuntos ordenados de objetos que pueden estar
duplicados.
2. Arreglos
2.1 Introducción.
Sintaxis:
<tipo> <identificador>[<núm_elemen>][[<núm_elemen>]...];
Ahora podemos ver que las cadenas de caracteres son un tipo especial de
arrays. Se trata en realidad de arrays de una dimensión de objetos de tipo char.
Los subíndices son enteros, y pueden tomar valores desde 0 hasta <número
de elementos>-1. Esto es muy importante, y hay que tener mucho cuidado, por
ejemplo:
int Vector[10];
Creará un array con 10 enteros a los que accederemos como Vector[0] a Vector[9].
Ejemplo:
int Tabla[10][10];
char DimensionN[4][15][6][8][11];
...
DimensionN[3][11][0][4][6] = DimensionN[0][12][5][3][1];
Tabla[0][0] += Tabla[9][9];
Inicialización de arrays.
Ejemplos:
En el caso 3, será 4.
Ya hemos visto que se puede usar el operador de asignación con arrays para
asignar valores iniciales. El otro operador que tiene sentido con los arrays es
sizeof.
#include <iostream>
using namespace std;
int main()
{
int array[231];
Las dos formas son válidas, pero la segunda es, tal vez, más general.
tipo_de_elemento *nombre_de_array;
nombre_de_array=(tipo_de_elemento *)malloc(tamaño);
int **mapa;
y, por último, para cada puntero se reserva memoria para los elementos:
for(i1=0;i1<N1;i1++)
mapa[i1]=(int *)malloc(sizeof(int)*N2);
vector<tipo> objeto;
donde tipo puede ser cualquier tipo o clase de los que ofrece C++, así como
cualquier otra clase implementada por un usuario. Así, podríamos declarar los
siguientes vectores:
Una declaración:
#include <iostream>
#include <cassert>
#include <vector>
using namespace std;
int main()
{
cout << "Demostrando los constructores más simple del vector" << endl;
vector<char> vector1, vector2(3, 'x');
assert (vector1.size() == 0);
assert (vector2.size() == 3);
assert (vector2[0] == 'x' && vector2[1] == 'x' &&
vector2[2] == 'x');
assert (vector2 == vector<char>(3, 'x') &&
vector2 != vector<char>(4, 'x'));
cout << " --- Ok." << endl;
return 0;
}
Otro constructor, el de copia, crea un vector a partir de un "trozo" de un array u
otro vector, o de un vector completo. Veamos el siguiente ejemplo:
#include <iostream>
#include <cassert>
#include <vector>
using namespace std;
int main()
{
cout << "Demostrando el constructor de copia del vector." << endl;
vector<int> hijito1(otroVector);
assert (hijito1 == otroVector);
#include <vector>
#include <iostream>
int main()
{
vector<int> v(5);
int x;
int cont = 0;
#include <iostream>
#include <cassert>
#include <vector>
#include <algorithm> // Biblioteca para find
using namespace std;
int main()
{
vector<char> vectorCar;
vectorCar.push_back('h');
vectorCar.push_back('o');
vectorCar.push_back('l');
vectorCar.push_back('a');
vectorCar.push_back(' ');
vectorCar.push_back('q');
vectorCar.push_back('u');
vectorCar.push_back('e');
vectorCar.push_back(' ');
vectorCar.push_back('t');
vectorCar.push_back('a');
vectorCar.push_back('l');
cout << "Demostración de la función find con un vector de caracteres. " << endl;
#include <iostream>
#include <vector>
#include <cassert>
#include <algorithm> // reverse
using namespace std;
int main()
{
vector<char> vectorCar;
vectorCar.push_back('h');
vectorCar.push_back('o');
vectorCar.push_back('l');
vectorCar.push_back('a');
vector<char> vectorCarAlReves;
vectorCarAlReves.push_back('a');
vectorCarAlReves.push_back('l');
vectorCarAlReves.push_back('o');
vectorCarAlReves.push_back('h');
reverse(vectorCar.begin(), vectorCar.end());
assert (vectorCar == vectorCarAlReves);
cout << " --- Ok." << endl;
return 0;
}
#include <iostream>
#include <algorithm>
#include <vector>
#include <cassert>
using namespace std;
int main()
{
vector<int> v(1000);
for (int i = 0; i < 1000; ++i)
v[i] = 1000 - i - 1;
sort(v.begin(), v.end());
#include <iostream>
#include <algorithm>
#include <vector>
#include <cassert>
using namespace std;
int main()
{
vector<int> v(1000);
for (int i = 0; i < 1000; ++i)
v[i] = i;
copy -> Recibe tres argumentos: dos iteradores indicando principio y fin del
vector origen y un tercero que indica el inicio en el destino.
#include <iostream>
#include <cassert>
#include <algorithm>
#include <vector>
#include <string>
#include <iostream>
using namespace std;
int main()
{
cout << "Ejemplo de copia genérica." << endl;
string s("abcdefghihklmnopqrstuvwxyz");
vector<char> vector1(s.begin(), s.end());
3 Listas
3.1 Definición de 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;
};
Donde:
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, por ejemplo:
Este es otro caso especial. Para este caso partimos de una lista no vacía:
Para recorrer una lista procederemos siempre del mismo modo, usaremos
un puntero auxiliar como índice:
Por ejemplo, para mostrar todos los valores de los nodos de una lista,
podemos usar el siguiente bucle:
...
indice = Lista;
while(indice && indice->dato <= 100) {
printf("%d\n", indice->dato);
indice = indice->siguiente;
}
...
expresiones resulta falsa, de modo que la expresión "indice->dato <= 100" nunca
se evaluará si indice es NULL.
Es el caso más simple. Partimos de una lista con uno o más nodos, y
usaremos un puntero auxiliar, nodo:
En todos los demás casos, eliminar un nodo se puede hacer siempre del
mismo modo. Supongamos que tenemos una lista con al menos dos elementos, y
un puntero al nodo anterior al que queremos eliminar. Y un puntero auxiliar nodo.
Sólo hay un modo de moverse a través de una lista abierta, hacia delante.
Aún así, a veces necesitaremos acceder a determinados elementos de una lista
abierta. Veremos ahora como acceder a los más corrientes: el primero, el último, el
siguiente y el anterior.
Basta con comparar el puntero Lista con NULL, si Lista vale NULL la lista está
vacía.
Algoritmo de inserción:
nodo = *lista;
anterior = NULL;
while(nodo && nodo->valor < v) {
anterior = nodo;
nodo = nodo->siguiente;
}
if(!nodo || nodo->valor != v) return;
else { /* Borrar el nodo */
if(!anterior) /* Primer elemento */
*lista = nodo->siguiente;
else /* un elemento cualquiera */
anterior->siguiente = nodo->siguiente;
free(nodo);
}
}
Usando clases el programa cambia bastante, aunque los algoritmos son los
mismos.
Para empezar, necesitamos dos clases, una para nodo y otra para lista. Además la
clase para nodo debe ser amiga de la clase lista, 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;
class lista {
public:
lista() { primero = actual = NULL; }
~lista();
private:
pnodo primero;
pnodo actual;
};
Hemos hecho que la clase para lista sea algo más completa que la equivalente en
C, aprovechando las prestaciones de las clases. En concreto, hemos añadido
funciones para mantener un puntero a un elemento de la lista y para poder
moverse a través de ella.
El contenedor de decencia LIST cuenta con una eficiente combinación para las
operaciones de inserción y eliminación en cualquier posición del contenedor. La
clase LIST se implementa como una lista doblemente enlazada: cada nodo en una
lista contiene un apuntador al nodo anterior y al nodo siguiente de esta lista. Esto
permite a la clase LIST soportar iteradotes bidireccionales que permiten que el
contenedor se recorra tanto hacia delante como hacia atrás.
4 Pilas
4.1 Definición de pilas
Una manera de ver esta estructura es pensar en las pilas como si fuese una
pila de bandejas de un autoservicio. Las bandejas se ponen en la pila por arriba, la
bandeja de arriba se la lleva de la pila un cliente que este en la cola. Este
escenario se denomina modelo del último en llegar-primero en salir: la última
bandeja que se ponga en la pila será la primera que se lleven.
struct nodo {
int dato;
struct nodo *siguiente;
};
Los tipos que se definen normalmente para manejar pilas son casi los
mismos que para manejar listas, tan sólo se cambian algunos nombres de la
siguiente manera:
Donde:
Es evidente, a la vista del gráfico, que una pila es una lista abierta. Así que
sigue siendo muy importante que un programa nunca pierda el valor del puntero al
primer elemento, igual que pasa con las listas abiertas.
Las pilas tienen un conjunto de operaciones muy limitado, sólo permiten las
operaciones de "push" y "pop":
Las operaciones con pilas son muy simples, no hay casos especiales, salvo
que la pila esté vacía.
Ahora sólo existe un caso posible, ya que sólo podemos leer desde un
extremo de la pila. Partiremos de una pila con uno o más nodos, y usaremos un
puntero auxiliar, nodo:
Si la pila sólo tiene un nodo, el proceso sigue siendo válido, ya que el valor
de Pila->siguiente es NULL, y después de eliminar el último nodo la pila quedará
vacía, y el valor de Pila será NULL.
Las clases para pilas son versiones simplificadas de las mismas clases que
usamos para listas. Para empezar, necesitaremos dos clases, una para nodo y
otra para pila. Además la clase para nodo debe ser amiga de la clase pila, 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;
class pila {
public:
pila() : ultimo(NULL) {}
~pila();
private:
pnodo ultimo;
};
using std::cout;
using std::endl;
int main()
{
// pila con deque subyacente predeterminado
std::stack< int > intDequePila;
return 0;
} // fin de main
5 Colas
5.1 Definición de colas
Una cola es un tipo especial de lista abierta en la que sólo se puede 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.
Un ejemplo 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.
struct nodo {
int dato;
struct nodo *siguiente;
};
Los tipos que se definen normalmente para manejar colas son casi los
mismos que para manejar listas y pilas, tan sólo se cambian algunos nombres:
Donde:
Es evidente que una cola es una lista abierta. Así que sigue siendo muy
importante que un programa nunca pierda el valor del puntero al primer elemento,
igual que pasa con las listas abiertas. Además, debido al funcionamiento de las
colas, también se debe mantener un puntero para el último elemento de la cola,
que será el punto donde insertemos nuevos nodos.
De nuevo nos encontramos ante una estructura con muy pocas operaciones
disponibles. Las colas sólo permiten añadir y leer elementos:
Las operaciones con colas son muy sencillas, prácticamente no hay casos
especiales, salvo que la cola esté vacía.
Ahora, también existen dos casos, que la cola tenga un solo elemento o que
tenga más de uno.
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, necesitamos 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;
class cola {
public:
cola() : ultimo(NULL), primero(NULL) {}
~cola();
private:
pnodo primero, ultimo;
};
using std::cout;
using std::endl;
int main()
{
std::queue< double > valores;
while ( !valores.empty() ) {
cout << valores.front() << ' '; // ver elemento inicial
valores.pop(); // eliminar elemento
return 0;
} // fin de main
6 Árboles
6.1 Definición de árboles
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, la forma gráfica se puede
apreciar como sigue:
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'.
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'.
Un árbol en el que en cada nodo o bien todos o ninguno de los hijos existe,
se llama árbol completo. 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.
struct nodo {
int dato;
struct nodo *rama1;
struct nodo *rama2;
struct nodo *rama3;
};
O generalizando más:
#define ORDEN 5
struct nodo {
int dato;
struct nodo *rama[ORDEN];
};
Al igual que con las listas, declaramos un tipo tipoNodo para declarar
nodos, y un tipo pNodo para es el tipo para declarar punteros a un nodo. Arbol es
el tipo para declarar árboles de orden ORDEN.
Salvo que trabajemos con árboles especiales, como los que veremos más
adelante, las inserciones serán siempre en punteros de nodos hoja o en punteros
libres de nodos rama. Con estas estructuras no es tan fácil generalizar, ya que
existen muchas variedades de árboles.
Los algoritmos de inserción y borrado dependen en gran medida del tipo de árbol
que estemos implementando, de modo que por ahora los pasaremos por alto y nos
centraremos más en el modo de recorrer árboles.
Esos recorridos dependen en gran medida del tipo y propósito del árbol, pero hay
ciertos recorridos que usaremos frecuentemente. Se trata de aquellos recorridos
que incluyen todo el árbol.
Hay tres formas de recorrer un árbol completo, y las tres se suelen implementar
mediante recursividad. En los tres casos se sigue siempre a partir de cada nodo
todas las ramas una por una.
RecorrerArbol(raiz);
void RecorrerArbol(Arbol a) {
if(a == NULL) return;
RecorrerArbol(a->rama[0]);
RecorrerArbol(a->rama[1]);
RecorrerArbol(a->rama[2]);
}
Pre-orden:
En este tipo de recorrido, el valor del nodo se procesa antes de recorrer las ramas:
void PreOrden(Arbol a) {
if(a == NULL) return;
Procesar(dato);
RecorrerArbol(a->rama[0]);
RecorrerArbol(a->rama[1]);
RecorrerArbol(a->rama[2]);
}
ABEKFCGLMDHIJNO
In-orden:
void InOrden(Arbol a) {
if(a == NULL) return;
RecorrerArbol(a->rama[0]);
Procesar(dato);
RecorrerArbol(a->rama[1]);
RecorrerArbol(a->rama[2]);
}
KEBFALGMCHDINJO
Post-orden:
En este tipo de recorrido, el valor del nodo se procesa después de recorrer todas
las ramas:
void PostOrden(Arbol a) {
if(a == NULL) return;
RecorrerArbol(a->rama[0]);
RecorrerArbol(a->rama[1]);
RecorrerArbol(a->rama[2]);
Procesar(dato);
}
KEFBLMGCHINOJDA
El proceso general es muy sencillo en este caso, pero con una importante
limitación, sólo podemos borrar nodos hoja:
Cuando el nodo a borrar no sea un nodo hoja, diremos que hacemos una "poda", y
en ese caso eliminaremos el árbol cuya raíz es el nodo a borrar. Se trata de un
procedimiento recursivo, aplicamos el recorrido PostOrden, y el proceso será
borrar el nodo.
En el árbol del ejemplo, para podar la rama 'B', recorreremos el subárbol 'B' en
postorden, eliminando cada nodo cuando se procese, de este modo no perdemos
los punteros a las ramas apuntadas por cada nodo, ya que esas ramas se borrarán
antes de eliminar el nodo.
KEFyB
Árboles ordenados
A partir del siguiente capítulo sólo hablaremos de árboles ordenados, ya que son
los que tienen más interés desde el punto de vista de TAD, y los que tienen más
aplicaciones genéricas.
Un árbol ordenado, en general, es aquel a partir del cual se puede obtener una
secuencia ordenada siguiendo uno de los recorridos posibles del árbol: inorden,
preorden o postorden.
Un árbol binario esta vacío o consta de un nodo denominado raíz junto con
dos árboles binarios llamados subárbol izquierdo y subárbol derecho de la raíz.
Con dos nodos en el árbol, uno de ellos será la raíz y el otro estará en un
subárbol. Así, uno de los subárboles de la izquierda o derecha debe estar vacío y
el otro contendrá un nodo. De ahí que haya dos árboles binarios diferentes con dos
nodos.
En el caso de un árbol binario con tres nodos, uno de estos será la raíz y los
otros dos se dividirán entre los subárboles de la izquierda y derecha en una de las
siguientes formas: 2 + 0, 1 + 1 y 0 + 2.
Como hay dos árboles binarios con dos nodos y solo un árbol vacío, en el
primer caso da dos árboles binarios. El tercero también lo hace. En el segundo
caso, los subárboles izquierdo y derecho tienen un nodo, y solo hay un árbol
binario con un nodo y por eso hay uno en el segundo. Así pues, en total existen
cinco árboles binarios con tres nodos.
Se trata de árboles de orden 2 en los que se cumple que para cada nodo, el
valor del nodo raíz del subárbol izquierdo es menor que el valor del nodo raíz y que
el valor del nodo raíz del subárbol derecho es mayor que el valor del nodo raíz.
Buscar un elemento.
Insertar un elemento.
Eliminar un elemento.
Movimientos a través del árbol:
o Izquierda.
o Derecha.
o Raíz.
Información:
o Comprobar si un árbol está vacío.
o Calcular el número de nodos.
o Comprobar si el nodo es hoja.
o Calcular la altura de un nodo.
o Calcular la altura de un árbol.
Padre = NULL
nodo = Raiz
Bucle: mientras actual no sea un árbol vacío o hasta que se encuentre el
elemento.
o Si el valor del nodo raíz es mayor que el elemento que buscamos,
continuaremos la búsqueda en el árbol izquierdo:
Padre=nodo, nodo=nodo->izquierdo.
Padre=nodo, nodo=nodo->derecho.
Padre = NULL
Si el árbol está vacío: el elemento no está en el árbol, por lo tanto salimos
sin eliminar ningún elemento.
(*) Si el valor del nodo raíz es igual que el del elemento que buscamos,
estamos ante uno de los siguientes casos:
o El nodo raíz es un nodo hoja:
Si 'Padre' es NULL, el nodo raíz es el único del árbol, por lo
tanto el puntero al árbol debe ser NULL.
Si raíz es la rama derecha de 'Padre', hacemos que esa rama
apunte a NULL.
Si raíz es la rama izquierda de 'Padre', hacemos que esa rama
apunte a NULL.
Eliminamos el nodo, y salimos.
o El nodo no es un nodo hoja:
Buscamos el 'nodo' más a la izquierda del árbol derecho de
raíz o el más a la derecha del árbol izquierdo. Hay que tener
en cuenta que puede que sólo exista uno de esos árboles. Al
mismo tiempo, actualizamos 'Padre' para que apunte al padre
de 'nodo'.
Intercambiamos los elementos de los nodos raíz y 'nodo'.
Borramos el nodo 'nodo'. Esto significa volver a (*), ya que
puede suceder que 'nodo' no sea un nodo hoja.
Si el valor del nodo raíz es mayor que el elemento que buscamos,
continuaremos la búsqueda en el árbol izquierdo.
Si el valor del nodo raíz es menor que el elemento que buscamos,
continuaremos la búsqueda en el árbol derecho.
Para movernos a través del árbol usaremos punteros auxiliares, de modo que
desde cualquier puntero los movimientos posibles serán: moverse al nodo raíz de
la rama izquierda, moverse al nodo raíz de la rama derecha o moverse al nodo
Raíz del árbol.
Información
Hay varios parámetros que podemos calcular o medir dentro de un árbol. Algunos
de ellos nos darán idea de lo eficientemente que está organizado o el modo en que
funciona.
Tenemos dos opciones para hacer esto, una es llevar siempre la cuenta de nodos
en el árbol al mismo tiempo que se añaden o eliminan elementos. La otra es,
sencillamente, contarlos.
Para contar los nodos podemos recurrir a cualquiera de los tres modos de recorrer
el árbol: inorden, preorden o postorden, como acción sencillamente
incrementamos el contador.
Esto es muy sencillo, basta con comprobar si tanto el árbol izquierdo como el
derecho están vacíos. Si ambos lo están, se trata de un nodo hoja.
No hay un modo directo de hacer esto, ya que no nos es posible recorrer el árbol
en la dirección de la raíz. De modo que tendremos que recurrir a otra técnica para
calcular la altura.
La altura del árbol es la altura del nodo de mayor altura. Para buscar este valor
tendremos que recorrer todo el árbol, de nuevo es indiferente el tipo de recorrido
que hagamos, cada vez que cambiemos de nivel incrementamos la variable que
contiene la altura del nodo actual, cuando lleguemos a un nodo hoja
compararemos su altura con la variable que contiene la altura del árbol si es
mayor, actualizamos la altura del árbol.
2, 4, 5, 8, 9, 12
BIBLIOGRAFIA