CAPITULO 3 AA

Anuncio
3.1
Estructuras de datos y diseño de algoritmos.
Introducción
Para procesar información en un computador es necesario hacer una abstracción de los
datos que tomamos del mundo real -abstracción en el sentido de que se ignoran algunas
propiedades de los objetos reales, es decir, se simplifican-. Se hace una selección de los
datos más representativos de la realidad a partir de los cuales pueda trabajar el
computador para obtener unos resultados.
Cualquier lenguaje suministra una serie de tipos de datos simples, como son los números
enteros, caracteres, números reales. En realidad suministra un subconjunto de éstos, pues
la memoria del ordenador es finita. Los punteros (si los tiene) son también un tipo de
datos. El tamaño de todos los tipos de datos depende de la máquina y del compilador
sobre los que se trabaja.
En principio, conocer la representación interna de estos tipos de datos no es necesaria para
realizar un programa, pero sí puede afectar en algunos casos al rendimiento.
¿Qué es una estructura de datos?
Se trata de un conjunto de variables de un determinado tipo agrupadas y organizadas de
alguna manera para representar un comportamiento. Lo que se pretende con las
estructuras de datos es facilitar un esquema lógico para manipular los datos en función del
problema que haya que tratar y el algoritmo para resolverlo. En algunos casos la dificultad
para resolver un problema radica en escoger la estructura de datos adecuada. Y, en
general, la elección del algoritmo y de las estructuras de datos que manipulará estarán
muy relacionadas.
Según su comportamiento durante la ejecución del programa distinguimos estructuras de
datos:
- Estáticas: su tamaño en memoria es fijo. Ejemplo: arrays.
- Dinámicas: su tamaño en memoria es variable. Ejemplo: listas enlazadas con punteros,
ficheros, etc.
Las estructuras de datos que trataremos aquí son los arrays, las pilas y las colas, los
árboles, y algunas variantes de estas estructuras. La tabla que se encuentra al comienzo
de esta página agrupa todas las estructuras de datos que emplearán los algoritmos
explicados en esta web.
Se recomienda tratar en profundidad los temas de estructuras de datos antes de entrar de
lleno en los algoritmos, si bien es muy recomendable al menos leer la introducción a los
algoritmos y algunos de los temas que suelen ser más conocidos, tales como la ordenación
y la búsqueda.
En primer lugar es fundamental el conocimiento de la recursividad, inherente a muchas
estructuras de datos y algoritmos. Es este por tanto el primer tema que hay tratar.
Posteriormente conviene estudiar los temas de arrays y listas enlazadas, puesto que son
básicos para implementar el resto de estructuras de datos. Los temas de pilas y colas son
fundamentales, y mucho más sencillas de entender y aplicar que los temas restantes.
Como temas avanzados (y no por ello menos importantes, además de que requieren el
conocimiento de los temas anteriores) figuran los grafos, si bien en la sección de
estructuras de datos se estudiarán sólo sus implementaciones y recorridos, los árboles y
los montículos, una implementación especial de árbol. De manera independiente puede
leerse el tema de conjuntos. Asimismo, suele ocurrir que unos temas entran en el terreno
de otros, y por lo tanto serán habituales las referencias a otros temas, ya sean a
estructuras de datos y a algoritmos.
- 35 -
3.1.1
Estructura de datos lineales.
En este tema se estudia la primera gran familia de TADs, todos ellos derivados del
concepto de secuencia. Primero se definen las secuencias como conjuntos de elementos
entre los que se establece una relación de predecesor y sucesor. Los diferentes TADs
basados en este concepto se diferenciaran por las operaciones de acceso a los elementos y
manipulación de la estructura. Desde el punto de vista de la informática, existen tres
estructuras lineales especialmente importantes: las pilas, las colas y las listas. Su
importancia radica en que son muy frecuentes en los esquemas algorítmicos.
Las operaciones básicas para dichas estructuras son:
crear la secuencia vacía.
añadir un elemento a la secuencia.
borrar un elemento a la secuencia.
consultar un elemento de la secuencia.
comprobar si la secuencia está vacía.
La diferencia entre las tres estructuras que se estudiarán vendrá dada por la posición del
elemento a añadir, borrar y consultar:
Pilas: las tres operaciones actúan sobre el final de la secuencia
Colas: se añade por el final y se borra y consulta por el principio
Listas: las tres operaciones se realizan sobre una posición privilegiada de la secuencia, la
cual puede desplazarse
Se presenta el TAD de las pilas de elementos arbitrarios. Tras especificarlo, se muestra su
implementación en vector (posteriormente se verá su implementación con memoria
dinámica) y se discuten sus ventajas y desventajas.
Después se muestran las colas siguiendo un proceso idéntico al del subtema anterior. Se
presenta y discute la implementación en vector circular (también posteriormente se verá
su implementación en memoria dinámica).
Respecto a las listas, dado que hay muchas versiones diferentes se escoge una como
base. Concretamente las listas con punto de interés, donde existe un elemento que sirve
de referencia a las operaciones de inserción, supresión y consulta. Estas listas tienen el
interés añadido de que son equivalentes a la noción de secuencia que los estudiantes
conocen de Programación. Se da una especificación formal de estas listas y se discuten las
diferentes implementaciones. Tras considerar una implementación secuencial, que resulta
ineficiente en general, se detalla la representación encadenada, mucho más eficiente
(coste constante en todas las operaciones), usando vectores. En la representación
encadenada se ve la utilidad de introducir un elemento fantasma, que evita casos
especiales en los algoritmos y simplifica el código.
Ante el problema de previsión de memoria necesaria a reservar, se presenta la utilización
de memoria dinámica. Se exponen todos los inconvenientes asociados al uso de memoria
dinámica (generación de basura, referencias colgadas, compartición de memoria, etc.) y se
ilustran los peligros asociados a las implementaciones que los usan. Se muestra de forma
muy natural la implementación con punteros de las listas, y se recuerdan las pilas y las
colas comentando su implementación dinámica.
Para terminar se presentan algunas variantes de la representación encadenada. En
particular las listas circulares y las listas doblemente encadenadas. Para cada una de ellas
se muestra su utilidad en distintos contextos.
3.1.2
Estructura de datos no-lineales.
Las estructuras de daros no lineales se definen como:
a) Arreglos
b) Conjuntos
- 36 -
c) Strings
d) Registros
e) Archivos
3.1.3
Procedimientos recursivos.
Se dice que algo es recursivo si se define en función de sí mismo o a sí mismo. También se
dice que nunca se debe incluir la misma palabra en la definición de ésta. El caso es que las
definiciones recursivas aparecen con frecuencia en matemáticas, e incluso en la vida real.
Un ejemplo: basta con apuntar una cámara al monitor que muestra la imagen que muestra
esa cámara. El efecto es verdaderamente curioso, en especial cuando se mueve la cámara
alrededor del monitor.
En matemáticas, tenemos múltiples definiciones recursivas:
- Números naturales:
(1) 1 es número natural.
(2) el siguiente número de un número natural es un número natural
- El factorial: n!, de un número natural (incluido el 0):
(1) si n = 0 entonces: 0! = 1.
(2) si n > 0 entonces: n! = n · (n-1)!
Asimismo, puede definirse un programa en términos recursivos, como una serie de pasos
básicos, o paso base (también conocido como condición de parada), y un paso recursivo,
donde vuelve a llamarse al programa. En un computador, esta serie de pasos recursivos
debe ser finita, terminando con un paso base. Es decir, a cada paso recursivo se reduce el
número de pasos que hay que dar para terminar, llegando un momento en el que no se
verifica la condición de paso a la recursividad. Ni el paso base ni el paso recursivo son
necesariamente únicos.
Por otra parte, la recursividad también puede ser indirecta, si tenemos un procedimiento P
que llama a otro Q y éste a su vez llama a P. También en estos casos debe haber una
condición de parada.
Existen ciertas estructuras cuya definición es recursiva, tales como los árboles, y los
algoritmos que utilizan árboles suelen ser en general recursivos.
Un ejemplo de programa recursivo en C, el factorial:
int factorial(int n)
{
if (n == 0) return 1;
return n * factorial(n-1);
}
Como se observa, en cada llamada recursiva se reduce el valor de n, llegando el caso en el
que n es 0 y no efectúa más llamadas recursivas. Hay que apuntar que el factorial puede
obtenerse con facilidad sin necesidad de emplear funciones recursivas, es más, el uso del
programa anterior es muy ineficiente, pero es un ejemplo muy claro.
A continuación se expone un ejemplo de programa que utiliza recursión indirecta, y nos
dice si un número es par o impar. Al igual que el programa anterior, hay otro método
mucho más sencillo de determinar si un número es par o impar, basta con determinar el
resto de la división entre dos. Por ejemplo: si hacemos par(2) devuelve 1 (cierto). Si
hacemos impar(4) devuelve 0 (falso).
/* declaracion de funciones, para evitar errores */
int par(int n);
int impar(int n);
int par(int n)
{
- 37 -
if (n == 0) return 1;
return impar(n-1);
}
int impar(int n)
{
if (n == 0) return 0;
return par(n-1);
}
En Pascal se hace así (notar el uso de forward):
function impar(n : Integer) : Boolean; forward;
function par(n : Integer) : Boolean; forward;
function par(n : Integer) : Boolean;
begin
if n = 0 then par := true
else par := impar(n-1)
end;
function impar(n : Integer) : Boolean;
begin
if n = 0 then impar := false
else impar := par(n-1)
end;
Ejemplo: si hacemos la llamada impar(3) hace las siguientes llamadas:
par(2)
impar(1)
par(0) -> devuelve 1 (cierto)
Por lo tanto 3 es un número impar.
¿Qué pasa si se hace una llamada recursiva que no termina?
Cada llamada recursiva almacena los parámetros que se pasaron al procedimiento, y otras
variables necesarias para el correcto funcionamiento del programa. Por tanto si se produce
una llamada recursiva infinita, esto es, que no termina nunca, llega un momento en el que
no quedará memoria para almacenar más datos, y en ese momento se abortará la
ejecución del programa. Para probar esto se puede intentar hacer esta llamada en el
programa factorial definido anteriormente:
factorial(-1);
Por supuesto no hay que pasar parámetros a una función que estén fuera de su dominio,
pues el factorial está definido solamente para números naturales, pero es un ejemplo
claro.
¿Cuándo utilizar la recursión?
Para empezar, algunos lenguajes de programación no admiten el uso de recursividad,
como por ejemplo el ensamblador o el FORTRAN. Es obvio que en ese caso se requerirá
una solución no recursiva (iterativa). Tampoco se debe utilizar cuando la solución iterativa
sea clara a simple vista. Sin embargo, en otros casos, obtener una solución iterativa es
mucho más complicado que una solución recursiva, y es entonces cuando se puede
plantear la duda de si merece la pena transformar la solución recursiva en otra iterativa.
Posteriormente se explicará como eliminar la recursión, y se basa en almacenar en una pila
los valores de las variables locales que haya para un procedimiento en cada llamada
recursiva. Esto reduce la claridad del programa. Aún así, hay que considerar que el
compilador transformará la solución recursiva en una iterativa, utilizando una pila, para
- 38 -
cuando compile al código del computador.
Por otra parte, casi todos los algoritmos basados en los esquemas de vuelta atrás y divide
y vencerás son recursivos, pues de alguna manera parece mucho más natural una solución
recursiva.
Aunque parezca mentira, es en general mucho más sencillo escribir un programa recursivo
que su equivalente iterativo. Si el lector no se lo cree, posiblemente se deba a que no
domine todavía la recursividad. Se propondrán diversos ejemplos de programas recursivos
de diversa complejidad para acostumbrarse a la recursión.
Ejercicio
La famosa sucesión de Fibonacci puede definirse en términos de recurrencia de la siguiente
manera:
(1)
Fib(1) = 1 ; Fib(0) = 0.
(2)
Fib(n) = Fib(n-1) + Fib(n-2) si n >= 2
¿Cuantas llamadas recursivas se producen para Fib(6)?. Codificar un programa que calcule
Fib(n) de forma iterativa.
Nota: no utilizar estructuras de datos, puesto que no queremos almacenar los números de
Fibonacci anteriores a n; sí se permiten variables auxiliares.
Ejemplos de programas recursivos
- Dados dos números a (número entero) y b (número natural mayor o igual que cero)
determinar a^b.
int potencia(int a, int b)
{
if (b == 0) return 1;
else return a * potencia(a, b-1);
}
La condición de parada se cumple cuando el exponente es cero. Por ejemplo, la evaluación
de potencia(-2, 3) es:
potencia(-2, 3) ->
(-2) · potencia(-2, 2) ->
(-2) · (-2) · potencia(-2, 1) ->
(-2) · (-2) · (-2) · potencia(-2, 0) ->
(-2) · (-2) · (-2) · 1
y a la vuelta de la recursión se tiene:
(-2) · (-2) · (-2) · 1 /=/ (-2) · (-2) · (-2) · potencia(-2,0)
< (-2) · (-2) · (-2) /=/ (-2) · (-2) · potencia(-2, 1)
< (-2) · 4 /=/ (-2) · potencia(-2,2)
< -8 /=/ potencia(-2,3)
en negrita se ha resaltado la parte de la expresión que se evalúa en cada llamada
recursiva.
- Dado un array constituido de números enteros y que contiene N elementos siendo N >=
1, devolver la suma de todos los elementos.
int sumarray(int numeros[], int posicion, int N)
{
if (posicion == N-1) return numeros[posicion];
else return numeros[posicion] + sumarray(numeros, posicion+1, N);
- 39 -
}
...
int numeros[5] = {2,0,-1,1,3};
int N = 5;
printf("%d\n",sumarray(numeros, 0, N));
Notar que la condición de parada se cumple cuando se llega al final del array. Otra
alternativa es recorrer el array desde el final hasta el principio (de derecha a izquierda):
int sumarray(int numeros[], int posicion)
{
if (posicion == 0) return numeros[posicion];
else return numeros[posicion] + sumarray(numeros, posicion-1);
}
...
int numeros[5] = {2,0,-1,1,3};
int N = 5;
printf("%d\n",sumarray(numeros, N-1));
- Dado un array constituido de números enteros, devolver la suma de todos los elementos.
En este caso se desconoce el número de elementos. En cualquier caso se garantiza que el
último elemento del array es -1, número que no aparecerá en ninguna otra posición.
int sumarray(int numeros[], int posicion)
{
if (numeros[posicion] == -1) return 0;
else return numeros[posicion] + sumarray(numeros, posicion+1);
}
...
int numeros[5] = {2,4,1,-3,-1};
printf("%d\n",sumarray(numeros, 0));
La razón por la que se incluye este ejemplo se debe a que en general no se conocerá el
número de elementos de la estructura de datos sobre la que se trabaja. En ese caso se
introduce un centinela -como la constante -1 de este ejemplo o la constante NULO para
punteros, u otros valores como el mayor o menor entero que la máquina pueda
representar- para indicar el fin de la estructura.
- Dado un array constituido de números enteros y que contiene N elementos siendo N >=
1, devolver el elemento mayor.
int mayor(int numeros[], int posicion)
{
int aux;
if (posicion == 0)
return numeros[posicion];
else {
aux = mayor(numeros, posicion-1);
if (numeros[posicion] > aux)
return numeros[posicion];
else
return aux;
}
}
- 40 -
...
int numeros[5] = {2,4,1,-3,-1};
int N = 5;
printf("%d\n", mayor(numeros, 4));
- Ahora uno un poco más complicado: dados dos arrays de números enteros A y B de
longitud n y m respectivamente, siendo n >= m, determinar si B está contenido en A.
Ejemplo:
A = {2,3,4,5,6,7,-3}
B = {7,-3} -> contenido; B = {5,7} -> no contenido; B = {3,2} -> no contenido
Para resolverlo, se parte del primer elemento de A y se compara a partir de ahí con todos
los elementos de B hasta llegar al final de B o encontrar una diferencia.
A = {2,3,4,5}, B = {3,4}
-2,3,4,5
3,4
^
En el caso de encontrar una diferencia se desplaza al segundo elemento de A y así
sucesivamente hasta demostrar que B es igual a un subarray de A o que B tiene una
longitud mayor que el subarray de A.
3,4,5
3,4
Visto de forma gráfica consiste en deslizar B a lo largo de A y comprobar que en alguna
posición B se suporpone sobre A.
Se han escrito dos funciones para resolverlo, contenido y esSubarray. La primera devuelve
cierto si el subarray A y el array B son iguales; tiene dos condiciones de parada: o que se
haya recorrido B completo o que no coincidan dos elementos. La segunda función es la
principal, y su cometido es ir 'deslizando' B a lo largo de A, y en cada paso recursivo llamar
una vez a la función contenido; tiene dos condiciones de parada: que el array B sea mayor
que el subarray A o que B esté contenido en un subarray A.
int contenido(int A[], int B[], int m, int pos, int desp)
{
if (pos == m) return 1;
else if (A[desp+pos] == B[pos])
return contenido(A,B,m, pos+1, desp);
else
return 0;
}
int esSubarray(int A[], int B[], int n, int m, int desp)
{
if (desp+m > n)
return 0;
else if (contenido(A, B, m, 0, desp))
return 1;
else
return esSubarray(A, B, n, m, desp+1);
}
...
int A[4] = {2, 3, 4, 5};
int B[3] = {3, 4, 5};
if (esSubarray(A, B, 4, 5, 0)) printf("\nB esta contenido en A");
else printf("\nB no esta contenido en A");
- 41 -
Hay que observar que el requisito n >= m indicando en el enunciado es innecesario, si m >
n entonces devolverá falso nada más entrar en la ejecución de esSubarray.
Este algoritmo permite hacer búsquedas de palabras en textos. Sin embargo existen
algoritmos mejores como el de Knuth-Morris-Prat, el de Rabin-Karp o mediante autómatas
finitos; estos algoritmos som más complicados pero mucho más efectivos.
- Dado un array constituido de números enteros y que contiene N elementos siendo N >=
1, devolver el elemento mayor. En este caso escribir un procedimiento, es decir, que el
elemento mayor devuelto sea una variable que se pasa por referencia.
void mayor(int numeros[], int posicion, int *m)
{
if (posicion == 0)
*m = numeros[posicion];
else {
mayor(numeros, posicion-1, m);
if (numeros[posicion] > *m)
*m = numeros[posicion];
}
}
...
int numeros[5] = {2,4,1,-3,-1};
int M;
mayor(numeros, 5-1, &M);
printf("%d\n", M);
Hay que tener cuidado con dos errores muy comunes: el primero es declarar la variable
para que se pase por valor y no por referencia, con lo cual no se obtiene nada. El otro
error consiste en llamar a la función pasando en lugar del parámetro por referencia una
constante, por ejemplo: mayor(numeros, 5-1, 0); en este caso además se producirá un
error de compilación.
- La función de Ackermann, siendo n y m números naturales, se define de la siguiente
manera:
Ackermann(0, n) = n + 1
Ackermann(m, 0) = A(m-1, 1)
Ackermann(m, n) = A(m-1, A(m, n-1))
Aunque parezca mentira, siempre se llega al caso base y la función termina. Probar a
ejecutar esta función con diversos valores de n y m... ¡que no sean muy grandes!. En
Internet pueden encontrarse algunas cosas curiosas sobre esta función y sus aplicaciones.
Ejercicios propuestos
Nota: para resolver los ejercicios basta con hacer un único recorrido sobre el array.
Tampoco debe utilizarse ningún array auxiliar, pero si se podrán utilizar variables de tipo
entero o booleano.
- Dado un array constituido de números enteros y que contiene N elementos siendo N >=
1, escribir una función que devuelva la suma de todos los elementos mayores que el último
elemento del array.
- Dado un array constituido de números enteros y que contiene N elementos siendo N >=
1, escribir una función que devuelva cierto si la suma de la primera mitad de los enteros
del array es igual a la suma de la segunda mitad de los enteros del array.
- Dados dos arrays A y B de longitud n y m respectivamente, n >= m cuyos elementos
estén ordenados y no se repiten, determinar si todos los elementos de B están contenidos
en A. Recordar que los elementos están ordenados, de esta manera basta con realizar un
- 42 -
único recorrido sobre cada array.
Conclusiones
En esta sección se ha pretendido mostrar que la recursividad es una herramienta potente
para resolver múltiples problemas. Es más, todo programa iterativo puede realizarse
empleando expresiones recursivas y viceversa.
3.2
Tipo de datos abstractos.
Los tipos abstractos de datos (TAD) permiten describir una estructura de datos en función
de las operaciones que pueden efectuar, dejando a un lado su implementación.
Los TAD mezclan estructuras de datos junto a una serie de operaciones de manipulación.
Incluyen una especificación, que es lo que verá el usuario, y una implementación
(algoritmos de operaciones sobre las estructuras de datos y su representación en un
lenguaje de programación), que el usuario no tiene necesariamente que conocer para
manipular correctamente los tipos abstractos de datos.
Se caracterizan por el encapsulamiento. Es como una caja negra que funciona simplemente
conectándole unos cables. Esto permite aumentar la complejidad de los programas pero
manteniendo una claridad suficiente que no desborde a los desarrolladores. Además, en
caso de que algo falle será más fácil determinar si lo que falla es la caja negra o son los
cables. Por último, indicar que un TAD puede definir a otro TAD. Por ejemplo, en próximos
apartados se indicará como construir pilas, colas y árboles a partir de arrays y listas
enlazadas. De hecho, las listas enlazadas también pueden construirse a partir de arrays y
viceversa.
3.2.1
Registros, arreglos, conjuntos.
Un array es un tipo de estructura de datos que consta de un número fijo de elementos del
mismo tipo. En una máquina, dichos elementos se almacenan en posiciones contiguas de
memoria. Estos elementos pueden ser variables o estructuras. Para definirlos se utiliza la
expresión:
tipo_de_elemento nombre_del_array[número_de_elementos_del_array];
int mapa[100];
Cada uno de los elementos de los que consta el array tiene asignado un número (índice).
El
primer
elemento
tiene
índice
0
y
el
último
tiene
índice
número_de_elementos_del_array-1. Para acceder a ese elemento se pone el nombre del
array con el índice entre corchetes:
nombre_del_array[índice]
mapa[5]
Los elementos no tienen por qué estar determinados por un solo índice, pueden estarlo por
dos (por ejemplo, fila y columna), por tres (p.e. las tres coordenadas en el espacio), o
incluso por más. A estos arrays definidos por más de un índice se le llaman arrays
multidimensionales o matrices, y se definen:
tipo_de_elemento nombre_del_array[número1] [número2]... [númeroN];
int mapa[100][50][399];
Y para acceder a un elemento de índices i1,i2,...,iN, la expresión es similar:
nombre_del_array[i1][i2]...[iN]
mapa[34][2][0]
Hay que tener cuidado con no utilizar un índice fuera de los límites, porque dará resultados
inesperados (tales como cambio del valor de otras variables o finalización del programa,
con error "invalid memory reference").
- 43 -
Manejo de arrays
Para manejar un array, siempre hay que manejar por separado sus elementos, esto es, NO
se pueden utilizar operaciones tales como dar valores a un array (esperando que todos los
elementos tomen ese valor). También hay que tener en cuenta de que al definir un array,
al igual que con una variable normal, los elementos del array no están inicializados. Para
ello, hay que dar valores uno a uno a todos los elementos. Para arrays unidimensionales,
se utiliza un for:
for(i=0;i<N;i++)
array[i]=0;
y para arrays multidimensionales se haría así:
for(i1=0;i1<N1;i1++)
for(i2=0;i2<N2;i2++)
...
for(iN=0;iN<NN;iN++)
array1[i1][i2]...[iN]=0;
Hay una función que, en determinadas ocasiones, es bastante útil para agilizar esta
inicialización, pero que puede ser peligrosa si se usa sin cuidado: memset (incluida en
string.h). Lo que hace esta función es dar, byte a byte, un valor determinado a todos los
elementos:
memset(array,num,tamaño);
donde array es el nombre del array (o la dirección del primer elemento), num es el valor
con el que se quiere inicializar los elementos y tamaño es el tamaño del array, definido
como el número de elementos por el tamaño en bytes de cada elemento. Si el tamaño de
cada elemento del array es 1 byte, no hay problema, pero si son más, la función da el
valor num a cada byte de cada elemento, con lo que la salida de un programa del tipo:
#include<stdio.h>
#include<string.h>
void main()
{
short mapa[10];
memset(mapa,1,10*sizeof(short));
printf("%d",mapa[0]);
}
no es 1 (0000000000000001 en base 2), como cabría esperar, sino 257
(0000000100000001 en base 2).
También hay otra función que facilita el proceso de copiar un array en otro: memcpy
(incluido también en string.h). Esta función copia byte a byte un array en otro.
Arrays dinámicos.
Si al iniciar un programa no se sabe el número de elementos del que va a constar el array,
o no se quiere poner un límite predetermiado, lo que hay que hacer es definir el array
dinámicamente. Para hacer esto, primero se define un puntero, que señalará la dirección
de memoria del primer elemento del array:
tipo_de_elemento *nombre_de_array;
y luego se utiliza la función malloc (contenida en stdlib.h) para reservar memoria:
nombre_de_array=(tipo_de_elemento *)malloc(tamaño);
donde tamaño es el número de elementos del array por el tamaño en bytes de cada
elemento. La función malloc devuelve un puntero void, que indica la posición del primer
elemento. Antes de asignarlo a nuestro puntero, hay que convertir el puntero que devuelve
- 44 -
el malloc al tipo de nuestro puntero (ya que no se pueden igualar punteros de distintos
tipos).
Para arrays bidimensionales, hay que hacerlo dimensión a dimensión; primero se define un
puntero de punteros:
int **mapa;
Luego se reserva memoria para los punteros:
mapa=(int **)malloc(sizeof(int *)*N1);
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);
Ya se puede utilizar el array normalmente. Para arrays de más de dos dimensiones, se
hace de forma similar.
Conjuntos.
Los conjuntos son una de las estructuras básicas de las matemáticas, y por tanto de la
informática. No se va a entrar en la definición de conjuntos ni en sus propiedades. Se
supondrá que el lector conoce algo de teoría de conjuntos. Con lo más básico es suficiente.
En realidad las estructuras de datos que se han implementado hasta ahora no son más que
elementos diferentes entre sí (en general) en los que se ha definido una relación. Por
ejemplo, en las listas ordenadas o los árboles binarios de búsqueda se tiene una serie de
elementos que están ordenados entre sí. Obviando las propiedades de las estructuras, se
ve que forman un conjunto, y su cardinal es el número de elementos que contenga la
estructura. En los conjuntos no existen elementos repetidos, y esto se respeta en las
implementaciones que se ofrecen a continuación.
Ahora bien, en esta sección se van definir unas implementaciones que permitan aplicar el
álgebra de conjuntos, ya sea unión, intersección, pertenencia, etc. Se realizan tres
implementaciones: array de bits, arrays y listas enlazadas.
Representación mediante arrays de bits
Ciertamente, un bit no da más que para representar dos estados diferentes. Por supuesto,
pueden ser atributos muy variados, por ejemplo, ser hombre o mujer, adulto o niño,
Windows o Linux, etc. También sirve para indicar si un elemento está o no dentro de un
conjunto.
El array se utiliza para representar un conjunto de números naturales (u otro tipo de datos
cuyos elementos se identifiquen por un número natural único mediante una
correspondencia) entre 0 y N, siendo N la capacidad del array unidimensional (es decir, un
vector); almacenará valores booleanos, esto es, 1 ó 0.
Por ejemplo, suponer el conjunto universal formado por los enteros entre 0 y 4: U = {0, 1,
2, 3, 4}, y el conjunto C = {1, 2}. Se representará de esta manera:
0
1
2
3
4
0
1
1
0
0
1 : indica que el elemento pertenece al conjunto.
0 : indica que el elemento no pertenece al conjunto.
Ahora bien, se ha dicho que se va a emplear un array de bits. ¿Qué se quiere decir con
esto? Que no se va a emplear un array o vector como tal, sino un tipo de datos definido
por el lenguaje de programación, que suele ocupar entre 8 y 64 bits, y por tanto podrá
incluir hasta 64 elementos en el conjunto. Por ejemplo, en C o Pascal se define un tipo que
ocupa 8 bits:
unsigned char conjunto8;
- 45 -
var conjunto8 : byte;
Si todos los bits de conjunto8 están a 1 entonces se tiene el conjunto: U = {0, 1, 2, 3, 4,
5, 6, 7}, y su cardinal es 8. Si todos los bits están a 0 se tiene el conjunto vacío. El bit más
significativo señalará al elemento de mayor valor, el bit menos significativo al de menor
valor. Ejemplos (bit más significativo a la izquierda):
11111111 -> U = {0, 1, 2, 3, 4, 5, 6, 7}
11110001 -> U = {0, 4, 5, 6, 7}
01010101 -> U = {0, 2, 4, 6}
00000000 -> U = vacío
La razón para emplear los arrays de bits es que las operaciones sobre los conjuntos se
realizan de manera muy rápida y sencilla, al menos con los computadores actuales, que
tienen un tamaño de palabra múltiplo de 8. Por supuesto, la ocupación en memoria está
optimizada al máximo.
El inconveniente es que el rango de representación es muy limitado. Por eso su aplicación
es muy restringida, y depende fuertemente del compilador y el computador sobre el que se
implementan, pero es increíblemente rápida.
Nota: Pascal cuenta con un juego de instrucciones para manipular conjuntos definidos
mediante arrays de bits, dando al usuario transparencia total sobre su manejo.
A continuación se implementa un TAD sobre conjuntos en C mediante array de bits.
- Tipo de datos empleado:
typedef unsigned long tconjunto;
El tipo long suele ocupar 32 bits, por tanto el rango será: [0..31].
Nota importante: en los ejemplos se muestran conjuntos que sólo tienen un máximo de 8
elementos (8 bits). Esto está puesto simplemente por aumentar la claridad, y cómo no, por
ahorrar ceros.
- Definición de conjunto vacío y universal:
const tconjunto Universal = 0xFFFFFFFF;
const tconjunto vacio = 0;
Es decir, 32 bits puestos a 1 para el conjunto universal, 32 bits puestos a 0 para el
conjunto vacío.
- Unión:
Se realiza mediante la operación de OR inclusivo. Ejemplo (con 8 bits en lugar de 32):
11001100 -> A = {2,3,6,7}
Or 10010100 -> B = {2,4,7}
--------11011100 -> C = {2,3,4,6,7}
y se codifica así:
tconjunto unircjto(tconjunto A, tconjunto B)
{ return (A | B); }
- Intersección:
Se realiza mediante la operación AND. Ejemplo:
11001100 -> A = {2,3,6,7}
And 10010100 -> B = {2,4,7}
--------10000100 -> C = {2,7}
y se codifica así:
tconjunto interseccion(tconjunto A, tconjunto B)
- 46 -
{ return (A & B); }
- Diferencia:
Para obtener C = A-B se invierten todos los bits de B y se hace un AND entre A y B
negado. Ejemplo:
10011101 -> A = {0,2,3,4,7}
10110010 -> B = {1,4,5,7}
B negado: 01001101 -> B(negado) = {0,2,3,6}
10011101
And 01001101
--------00001101 -> C = {0,2,3}
y se codifica así:
tconjunto diferencia2(tconjunto A, tconjunto B)
{ return (A & ~B); }
- Diferencia simétrica:
C = (A-B) Unión (B-A)
Se realiza mediante la operación de OR exclusivo (XOR) o aplicando las primitivas definidas
anteriormente. Ejemplo:
11110000 -> A = {4,5,6,7}
Xor 00011110 -> B = {1,2,3,4}
--------11101110 -> C = {1,2,3,5,6,7}
y se codifica así:
tconjunto difsim(tconjunto A, tconjunto B)
{ return (A ^ B); }
- Igualdad de conjuntos:
La implementación es directa, si todos los bits de A y B se corresponden entonces son
iguales:
int iguales(tconjunto A, tconjunto B)
{ return (A == B); }
- Subconjuntos:
Si un conjunto A es subconjunto (considerando que un conjunto cualquiera es subconjunto
de si mismo) de otro B entonces verifica esta relación: A intersección B = A. Notar que A
es subconjunto de A, pues A intersección A = A.
Ejemplo:
A = {1,2,3,4}, B = {0,1,2,3}
C = A intersección B = {1,2,3}; C es distinto de A.
Se codifica así:
int subconjunto(tconjunto A, tconjunto B)
{ return (iguales(interseccion(A,B),A)); }
- Pertenencia:
Determinar si un elemento pertenece a un conjunto requiere efectuar una operación de
desplazamiento a nivel de bits y una posterior comprobación del bit de signo resultante.
Como siempre, un ejemplo o dos lo aclaran:
Sea x = 0 y A = {0,1,2,5}. Determinar si x pertecene a A.
00100111 -> A.
Primero se desplazan los bits de A tantas veces a la derecha como valga x, en el ejemplo
no se desplazan; se obtiene A'. A continuación se aplica el test del bit de signo sobre A',
- 47 -
que consiste en obtener el resto de la división entera entre dos. Si el resto es uno,
entonces x pertenece a A. En caso contrario no pertenece a A. En el ejemplo:
A' mod 2 = 1, luego x pertenece a A.
Otro ejemplo: x = 3, A = {0,1,2,5}.
Se desplazan los bits de A tres posiciones a la derecha:
00000100 -> A'.
Se hace el test de signo: A' mod 2 = 0. x no pertenece a A.
La codificación es la siguiente:
int pertenece(tconjunto A, int x)
{ return ((A >> x) % 2); }
- Inserción y borrado:
Para insertar un elemento x es necesario poner a 1 el bit correspondiente. Una manera
sencilla de hacerlo es mediante una suma. Hay que sumar un valor que se corresponda con
el bit que se quiere establecer a 1. Para hacerlo se volverá a aplicar una operación de
desplazamiento, pero esta vez hacia la izquierda y aplicada sobre el número 1. Se
desplazan x bits hacia la izquierda, suponiendo que el compilador llena con ceros por la
derecha.
Por ejemplo, partir de A = conjunto vacío: { }.
Se quieren insertar los elementos 0,2,3 sobre A.
Insertar 0:
x = 0. p = 1, (00000001 en binario). Se desplaza p x (0) bits a la izquierda, p' = 1, y se
suma a A. Queda: A <- A + p'. A = 1.
Insertar 2:
x = 2. p = 1. Se desplaza p x (2) bits a la izquierda, p' = 4 (000000100 en binario). A <- A
(1) + p' (4), A = 5 (000000101).
Insertar 3:
x =3. p = 1. Se desplaza p x (3) bits a la izquierda, p' = 8. A <- A (5) + p' (8), A = 13
(00001101).
El borrado es exactamente lo mismo, pero hay que restar p en vez de sumar. Ejemplo:
borrar 3 de A.
A <- A (13) - p' (8), A = 5 (00000101)
Antes de la codificación, hay que considerar otro detalle: es necesario comprobar
previamente si el elemento ya está en el conjunto para evitar problemas inesperados. Por
tanto, la codificación queda así para la inserción:
tconjunto insertar(tconjunto A, int x)
{
if (pertenece(A,x)) return A;
else return (A + ((tconjunto)1 << x));
}
y para el borrado:
tconjunto borrar(tconjunto A, int x)
{
if (pertenece(A,x)) return A;
else return (A - ((tconjunto)1 << x));
}
Conclusiones
Sin duda alguna, la gran ventaja de esta implementación es la rapidez de ejecución de
- 48 -
todas las operaciones, que se ejecutan en tiempo constante: O(1). Además los elementos
se encuentran empaquetados ocupando el menor espacio posible, esto es, un único bit.
La desventaja es que no admiten un rango muy amplio de representación. Aun así, para
incrementar el rango basta con crear un array de tipo conjunto, por ejemplo: tconjunto
superconjunto[10], y aplicar las operaciones sobre los bits en todos los elementos del
array, excepto para la inserción y borrado, en cuyo caso hay que encontrar el bit exacto a
manipular.
Representación mediante array
Los elementos del conjunto se guardan uno a continuación de otro empleando una lista
densa representada mediante un array.
Ejemplo: Sea el conjunto C = {1, 2}. Se representará de esta manera:
0
1
2
3
4
1
2
y su cardinal es 2.
Esta representación no limita el rango de representación más que al tipo de datos
empleado. Por supuesto, ya no puede definirse explícitamente el conjunto universal.
Por razones de eficiencia a la hora de implementar las primitivas, las estructuras se pasan
por referencia. Es un detalle importante, porque C garantiza que un array se pasa siempre
por referencia, pero eso no es cierto si el array se pasa como parte de una estructura.
No se implementan rutinas de control de errores ni su detección. Se produce un error
cuando se tratan de añadir elementos y estos desbordan la capacidad del array.
Nota importante: los elementos dentro del array no están ordenados entre sí.
- Tipo de datos empleado:
typedef int tTipo;
typedef struct
{
tTipo elems[MAXELEM];
int cardinal;
} tconjunto;
- Definición de conjunto vacío:
Un conjunto está vacío si su cardinal es cero. Para inicializar un conjunto a vacío basta con
una instrucción:
A->cardinal = 0
- Pertenencia:
Para determinar si un elemento x pertenece al conjunto basta con recorrer el array hasta
encontrarlo. Se devuelve True si se encuentra. Codificación:
int pertenece(tconjunto *A, tTipo x)
{
int i;
for (i = 0; i < A->cardinal; i++)
if (A->elems[i] == x) return 1;
return 0;
}
- Inserción y borrado:
Para insertar un elemento, primero debe comprobarse que no está, después se inserta en
la última posición, esto es, la que señale el cardinal, que se incrementa en una unidad.
Codificación:
- 49 -
void insertar(tconjunto *A, tTipo x)
{
if (!pertenece(A, x))
A->elems[A->cardinal++] = x;
}
Borrar es aparentemente más complicado. No se puede eliminar el elemento y dejar un
hueco, puesto que en ese caso ya no se tiene una lista. Para eliminar este problema se
sustituye el elemento borrado por el último de la lista. Codificación:
void borrar(tconjunto *A, tTipo x)
{
int i;
for (i = 0; i < A->cardinal; i++)
if (A->elems[i] == x) {
A->elems[i] = A->elems[--A->cardinal]; return;
}
}
- Unión:
Para hacer C = A Unión B, se introducen en C todos los elementos de A y todos los
elementos de B que no pertenezcan a A. Codificación:
void unircjto(tconjunto *A, tconjunto *B, tconjunto *C)
{
int i;
*C = *A;
for (i = 0; i < B->cardinal; i++)
if (!pertenece(A, B->elems[i]))
insertar(C, B->elems[i]);
}
- Intersección:
Para hacer C = A intersección B, se hace un recorrido sobre A (o B) y se insertan en C los
elementos que estén en B (o A).
El pseudocódigo es:
C = vacío
para cada x elemento de A
si x pertenece a B entonces insertar x en C.
En C:
void interseccion(tconjunto *A, tconjunto *B, tconjunto *C)
{
int i;
C->cardinal = 0;
for (i = 0; i < A->cardinal; i++)
if (pertenece(B, A->elems[i]))
insertar(C, A->elems[i]);
}
- Diferencia:
Para hacer C = A-B, se hace un recorrido sobre A (o B) y se insertan en C los elementos
que no estén en B (o A).
El pseudocódigo es:
C = vacío
para cada x elemento de A
si x no pertenece a B entonces insertar x en C.
- 50 -
En C:
void diferencia(tconjunto *A, tconjunto *B, tconjunto *C)
{
int i;
C->cardinal = 0;
for (i = 0; i < A->cardinal; i++)
if (!pertenece(B, A->elems[i]))
insertar(C, A->elems[i]);
}
- Diferencia simétrica:
Sea C = (A-B) Unión (B-A). Para obtener este resultado se puede aprovechar el código
estudiado anteriormente.
El pseudocódigo es:
C = vacío
para cada x elemento de A
si x no pertenece a B entonces insertar x en C
para cada x elemento de B
si x no pertenece a A entonces insertar x en C
En C:
void difsim(tconjunto *A, tconjunto *B, tconjunto *C)
{
int i;
C->cardinal = 0;
for (i = 0; i < A->cardinal; i++)
if (!pertenece(B, A->elems[i]))
insertar(C, A->elems[i]);
for (i = 0; i < B->cardinal; i++)
if (!pertenece(A, B->elems[i]))
insertar(C, B->elems[i]);
}
- Subconjuntos:
Determinar si un conjunto A es subconjunto de B se reduce a comprobar si todo elemento
de A es elemento de B. Se devuelve True si A es subconjunto de B. Codificación:
int subconjunto(tconjunto *A, tconjunto *B) /
{
int i, esta;
esta = 1;
for (i = 0; i < A->cardinal; i++)
if (!pertenece(B, A->elems[i])) return 0;
return 1;
}
- Igualdad de conjuntos:
Un conjunto A es igual a otro B si A es subconjunto de B y ambos tienen los mismos
elementos. Se devuelve True si A es igual a B. Codificación:
int iguales(tconjunto *A, tconjunto *B)
{
return (subconjunto(A,B) && A->cardinal == B->cardinal);
}
- 51 -
Conclusiones
La ventaja de esta implementación es que no limita el rango de representación de los
elementos del conjunto, y por supuesto tampoco limita el tipo de datos, siempre y cuando
se pueda deducir cuando un elemento es igual a otro o no.
La desventaja de esta implementación con respecto a la de arrays de bits es su mala
eficacia con respecto al tiempo de ejecución. El coste de la inserción y borrado es O(1).
Siendo |A| el cardinal de un conjunto cualquiera A las operaciones de pertenencia se
ejecuta en un tiempo O(|A|). En las restantes operaciones, que implican a dos conjuntos,
la complejidad es O(|A|·|B|)
El espacio que ocupa un conjunto es de O(|MAXIMO|), siendo MAXIMO el tamaño del
array.
Representación mediante lista enlazada
Esta representación es muy parecida a la implementación mediante un array, pero con
alguna particularidad que la hace más interesante en algunos casos. Por supuesto, los
tipos de datos de los elementos que se insertan son igualmente admisibles con listas como
lo eran con arrays.
Suponer que entre los elementos del conjunto se puede definir una relación de orden, es
decir, que se puede determinar si un elemento es mayor que otro. En este caso se pueden
insertar y borrar elementos del conjunto de forma que la lista que los mantiene esté
ordenada. Esto puede resultar interesante en algunas aplicaciones.
Sea |A| y |B| el cardinal de unos conjuntos cualesquiera A y B. Aplicando la suposición
anterior las operaciones de búsqueda, inserción y borrado se ejecutan en un tiempo
O(|A|).
Pero hay una gran ventaja, y es que las restantes operaciones se ejecutan en un tiempo
O(|A|+|B|). ¿Cómo se consigue ésto? Aprovechando las propiedades de tener listas
ordenadas basta con hacer un único recorrido sobre cada lista. Esto es posible
implementando un algoritmo basado en el algoritmo de fusión de dos listas ordenadas, que
obtiene una lista ordenada a partir de dos o más listas ordenadas con un único recorrido
de cada lista. (Es recomendable ver primero los Algoritmos de ordenación de listas y
entender el proceso de intercalación o fusión, pero NO es necesario estudiar el proceso
recursivo ya que no tiene interés aquí). Las operaciones de unión, intersección, etcétera, e
incluiso el determinar si un conjunto es subconjunto de otro se efectúan haciendo
pequeñas variaciones sobre el algoritmo de intercalación.
Nota sobre la implementación: Al estudiar la codificación se podrá notar que los conjuntos
A y B sobre los que se hacen los recorridos no se modifican sino que quedan como están.
Para ganar en eficiencia se puede hacer que el nuevo conjunto C no cree su propia lista de
elementos sino que simplemente aproveche los enlaces de las listas que mantienen los
conjuntos A y B, deshaciendo estos. Esto es algo opcional y no se ha implementado, pero
es útil si dichos conjuntos no se van a emplear más. Los punteros c1 y c2 recorren las
listas que representan a los conjuntos A y B respectivamente. El puntero c3 y aux sirven
para crear el nuevo conjunto C.
Definición y tipo de datos empleado:
Se empleará una lista enlazada con cabecera ficticia y centinela. La razón es que se
realizarán inserciones y búsquedas sobre la lista que contiene los elementos del conjunto.
Como se ha comentado anteriormente, los elementos de la lista estarán ordenados. Por
tanto, para emplear esta representación los elementos deben ser ordenables. En el código
propuesto, se tratarán conjuntos de números enteros.
Los tipos de datos se declaran así:
typedef struct lista
{
- 52 -
int elem;
struct lista *sig;
} lista;
typedef struct tconjunto
{
lista *cabecera,
*centinela;
int cardinal;
} tconjunto;
- Creación del conjunto vacío:
La creación de un nuevo conjunto (vacío) se realiza estableciendo a cero el número de
elementos y reservando memoria para los elementos de cabecera y centinela de la lista.
void crearcjto(struct tconjunto *cjto)
{
cjto->cabecera = (lista *) malloc(sizeof(lista));
cjto->centinela = (lista *) malloc(sizeof(lista));
cjto->cabecera->sig = cjto->centinela;
cjto->centinela->sig = cjto->centinela; /* opcional, por convenio */
cjto->cardinal = 0;
}
- Pertenencia:
Para determinar si un elemento x pertenece al conjunto basta con recorrer la lista hasta
encontrarlo o llegar al final de ésta. Se devuelve True si se encuentra antes del centinela.
int pertenece(tconjunto cjto, int x)
{
lista *actual;
actual = cjto.cabecera->sig;
cjto.centinela->elem = x;
while (actual->elem != x)
actual = actual->sig;
if (actual == cjto.centinela)
return 0; else
return 1;
}
- Inserción y borrado:
Para insertar un elemento primero debe comprobarse que no está, después se inserta
ordenadamente en la lista, y se incrementa el cardinal en una unidad.
void insertar(tconjunto *cjto, int x)
{
lista *anterior, *actual, *nuevo;
/* 1.- busca */
anterior = cjto->cabecera;
actual = cjto->cabecera->sig;
cjto->centinela->elem = x;
while (actual->elem < x) {
anterior = actual;
actual = actual->sig;
}
- 53 -
if (actual->elem != x || actual == cjto->centinela) {
/* 2.- crea */
nuevo = (lista *) malloc(sizeof(lista));
nuevo->elem = x;
/* 3.- enlaza */
nuevo->sig = actual;
anterior->sig = nuevo;
cjto->cardinal++;
}
}
Para borrar un elemento basta con localizarlo dentro de la lista y eliminarlo.
void borrar(tconjunto *cjto, int x)
{
lista *anterior, *actual;
/* 1.- busca */
anterior = cjto->cabecera;
actual = cjto->cabecera->sig;
cjto->centinela->elem = x;
while (actual->elem < x) {
anterior = actual;
actual = actual->sig;
}
/* 2.- borra si existe */
if (actual != cjto->centinela && actual->elem == x) {
anterior->sig = actual->sig;
free(actual);
}
}
- Unión:
A partir de los conjuntos A y B se crea un nuevo conjunto C. Se supone que el conjunto C
no ha sido inicializado antes. En cada paso se añade siempre un nuevo elemento. Por
último se comprueba que no queden elementos sin copiar.
void unioncjto(tconjunto A, tconjunto B, tconjunto *C)
{
lista *c1, *c2, *c3, *aux;
crearcjto(C);
c3 = C->cabecera;
c1 = A.cabecera->sig; c2 = B.cabecera->sig;
while (c1 != A.centinela && c2 != B.centinela) {
aux = (lista *) malloc(sizeof(lista));
if (c1->elem < c2->elem) {
aux->elem = c1->elem;
c1 = c1->sig;
}
else if (c1->elem > c2->elem) {
aux->elem = c2->elem;
c2 = c2->sig;
}
else {
aux->elem = c1->elem; /* tambien vale: aux->elem = c2->elem */
- 54 -
c1 = c1->sig;
c2 = c2->sig;
}
aux->sig = C->centinela;
c3->sig = aux; c3 = aux;
C->cardinal++;
}
/* copia los elementos restantes si los hubiera */
if (c1 != A.centinela) {
while (c1 != A.centinela) {
aux = (lista *) malloc(sizeof(lista));
aux->elem = c1->elem;
aux->sig = C->centinela;
c3->sig = aux; c3 = aux;
C->cardinal++;
c1 = c1->sig;
}
}
else if (c2 != B.centinela) {
while (c2 != B.centinela) {
aux = (lista *) malloc(sizeof(lista));
aux->elem = c2->elem;
aux->sig = C->centinela;
c3->sig = aux; c3 = aux;
C->cardinal++;
c2 = c2->sig;
}
}
}
- Intersección:
C = A Intersección B, es el nuevo conjunto que se crea. Se añade un elemento cuando
coincide en ambas listas a la vez (c1->elem == c2->elem).
void interseccion(tconjunto A, tconjunto B, tconjunto *C)
{
lista *c1, *c2, *c3, *aux;
crearcjto(C);
c3 = C->cabecera;
c1 = A.cabecera->sig; c2 = B.cabecera->sig;
while (c1 != A.centinela && c2 != B.centinela) {
if (c1->elem < c2->elem)
c1 = c1->sig;
else if (c1->elem > c2->elem)
c2 = c2->sig;
else {
aux = (lista *) malloc(sizeof(lista));
aux->elem = c1->elem; /* tambien vale: aux->elem = c2->elem */
aux->sig = C->centinela;
c3->sig = aux; c3 = aux;
C->cardinal++;
c1 = c1->sig;
c2 = c2->sig;
}
- 55 -
}
}
- Diferencia:
C = A-B. Se añade un nuevo elemento sólo cuando (c1->elem < c2->elem).
void diferencia(tconjunto A, tconjunto B, tconjunto *C)
{
lista *c1, *c2, *c3, *aux;
crearcjto(C);
c3 = C->cabecera;
c1 = A.cabecera->sig; c2 = B.cabecera->sig;
while (c1 != A.centinela && c2 != B.centinela) {
if (c1->elem < c2->elem) {
aux = (lista *) malloc(sizeof(lista));
aux->elem = c1->elem;
aux->sig = C->centinela;
c3->sig = aux; c3 = aux;
C->cardinal++;
c1 = c1->sig;
}
else if (c1->elem > c2->elem)
c2 = c2->sig;
else
c1 = c1->sig,
c2 = c2->sig;
}
/* aniade lo que quede de A */
while (c1 != A.centinela) {
aux = (lista *) malloc(sizeof(lista));
aux->elem = c1->elem;
aux->sig = C->centinela;
c3->sig = aux; c3 = aux;
C->cardinal++;
c1 = c1->sig;
}
}
- Diferencia simétrica:
C = (A-B) Unión (B-A). Es decir, todos los elementos no comunes de ambos conjuntos. Se
añaden elementos si (c1->elem != c2->elem).
void difsim(tconjunto A, tconjunto B, tconjunto *C)
{
lista *c1, *c2, *c3, *aux;
crearcjto(C);
c3 = C->cabecera;
c1 = A.cabecera->sig; c2 = B.cabecera->sig;
while (c1 != A.centinela && c2 != B.centinela) {
if (c1->elem != c2->elem) {
aux = (lista *) malloc(sizeof(lista));
if (c1->elem < c2->elem) { aux->elem = c1->elem; c1 = c1->sig; }
else { aux->elem = c2->elem; c2 = c2->sig; }
aux->sig = C->centinela;
- 56 -
c3->sig = aux; c3 = aux;
C->cardinal++;
}
else {
c1 = c1->sig;
c2 = c2->sig;
}
}
/* copia los elementos restantes si los hubiera */
if (c1 != A.centinela) {
while (c1 != A.centinela) {
aux = (lista *) malloc(sizeof(lista));
aux->elem = c1->elem;
aux->sig = C->centinela;
c3->sig = aux; c3 = aux;
C->cardinal++;
c1 = c1->sig;
}
}
else if (c2 != B.centinela) {
while (c2 != B.centinela) {
aux = (lista *) malloc(sizeof(lista));
aux->elem = c2->elem;
aux->sig = C->centinela;
c3->sig = aux; c3 = aux;
C->cardinal++;
c2 = c2->sig;
}
}
}
- Subconjuntos:
Determinar si un conjunto A es subconjunto de B se reduce a comprobar si todo elemento
de A es elemento de B. Se devuelve True si A es subconjunto de B. Observar que si (c1>elem < c2->elem) entonces A ya no puede ser subconjunto de B, pues implica que dicho
elemento no está en B, ya que c2 representa al menor de los elementos restantes del
conjunto. Por último, observar la última condición: return (essub && c1 == A.centinela);.
Es decir, quedan elementos de A que no han sido recorridos, pero B ya está totalmente
recorrido, luego A no es subconjunto de B. Si se da el caso de que essub = true y c1 !=
A.centinela entonces se puede devolver un tercer valor que indique que B es subconjunto
de A.
int subconjunto(tconjunto A, tconjunto B)
{
int essub = 1;
lista *c1, *c2;
c1 = A.cabecera->sig; c2 = B.cabecera->sig;
while (c1 != A.centinela && c2 != B.centinela && essub) {
if (c1->elem < c2->elem)
essub = 0;
else if (c1->elem > c2->elem)
c2 = c2->sig;
else {
c1 = c1->sig;
- 57 -
c2 = c2->sig;
}
}
return (essub && c1 == A.centinela);
}
- Igualdad de conjuntos:
Un conjunto A es igual a otro B si ambos tienen los mismos elementos. Se devuelve True si
A es igual a B. Se comprueba primero el cardinal de ambos conjuntos.
int iguales(tconjunto A, tconjunto B)
{
int igual;
lista *c1, *c2;
igual = A.cardinal == B.cardinal;
c1 = A.cabecera->sig; c2 = B.cabecera->sig;
while (c1 != A.centinela && c2 != B.centinela && igual) {
if (c1->elem != c2->elem)
igual = 0;
c1 = c1->sig;
c2 = c2->sig;
}
return (igual);
}
Programa de prueba:
int main(void)
{
tconjunto A, B, C, p1, p2, p3, p4, p5, p6;
crearcjto(&A);
crearcjto(&B);
crearcjto(&C);
/* A = {2,3,5}
B = {1,2,3,4,5}
C= {3,4,6}
*/
insertar(&A, 2); insertar(&A, 3); insertar(&A, 5);
insertar(&B, 1); insertar(&B, 2); insertar(&B, 3); insertar(&B, 4); insertar(&B, 5);
insertar(&C, 3); insertar(&C, 4); insertar(&C, 6);
if (pertenece(A, 5)) printf("5 pertenece a A");
if (!pertenece(B, 6)) printf("\n6 no pertenece a B");
/* p1 = {2,3,4,5,6} */
unioncjto(A,C,&p1);
/* p2 = {3} */
interseccion(A,C,&p2);
/* p3 = {1,4} */
- 58 -
diferencia(B,A,&p3);
/* p4 = p6 = {2,4,5,6}, p5 = vacío */
difsim(C,A,&p4);
difsim(A,C,&p6);
difsim(B,B,&p5);
if
if
if
if
if
(iguales(p4,p6)) printf("\np4 = p6");
(subconjunto(B,B)) printf("\nB es subconjunto de B");
(!subconjunto(B,C)) printf("\nB no es subconjunto de C");
(subconjunto(A,B)) printf("\nA es subconjunto de B");
(subconjunto(p5, A)) printf("\np5 es subconjunto de A");
return 0;
}
Conclusiones
Esta implementación tampoco limita el rango de representación de los elementos del
conjunto, y por supuesto tampoco limita el tipo de datos, siempre y cuando se pueda
deducir cuando un elemento es igual a otro o no.
Dado un conjunto A y B, las operaciones de inserción, borrado y pertenencia se ejecutan
en un tiempo de O(|A|). Las operaciones de unión, intersección, diferencia, diferencia
simétrica, subconjunto e igualdad se ejecutan en un tiempo de O(|A|+|B|).
El espacio que ocupa un conjunto es de O(|A|), siendo |A| el cardinal del conjunto A. Por
supuesto es proporcional al tamaño del conjunto implementado mediante array,
multiplicado por una constante debido al espacio ocupado por los punteros.
3.2.2
Pilas, colas y listas.
Una pila es una estructura de datos de acceso restrictivo a sus elementos. Se puede
entender como una pila de libros que se amontonan de abajo hacia arriba. En principio no
hay libros; después ponemos uno, y otro encima de éste, y así sucesivamente.
Posteriormente los solemos retirar empezando desde la cima de la pila de libros, es decir,
desde el último que pusimos, y terminaríamos por retirar el primero que pusimos,
posiblemente ya cubierto de polvo.
En los programas estas estructuras suelen ser fundamentales. La recursividad se simula en
un computador con la ayuda de una pila. Asimismo muchos algoritmos emplean las pilas
como estructura de datos fundamental, por ejemplo para mantener una lista de tareas
pendientes que se van acumulando.
Las pilas ofrecen dos operaciones fundamentales, que son apilar y desapilar sobre la cima.
El uso que se les de a las pilas es independiente de su implementación interna. Es decir, se
hace un encapsulamiento. Por eso se considera a la pila como un tipo abstracto de datos.
Es una estructra de tipo LIFO (Last In First Out), es decir, último en entrar, primero en
salir.
A continuación se expone la implementación de pilas mediante arrays y mediante listas
enlazadas. En ambos casos se cubren cuatro operaciones básicas: Inicializar, Apilar,
Desapilar, y Vacía (nos indica si la pila está vacía). Las claves que contendrán serán
simplemente números enteros, aunque esto puede cambiarse a voluntad y no supone
ningún inconveniente.
Implementación mediante array
- 59 -
Esta implementación es estática, es decir, da un tamaño máximo fijo a la pila, y si se
sobrepasa dicho límite se produce un error. La comprobación de apilado en una pila llena o
desapilado en una pila vacía no se han hecho, pero sí las funciones de comprobación, que
el lector puede modificar según las necesidades de su programa.
- Declaración:
struct tpila
{
int cima;
int elementos[MAX_PILA];
};
Nota: MAX_PILA debe ser mayor o igual que 1.
- Procedimiento de Creación:
void crear(struct tpila *pila)
{
pila->cima = -1;
}
- Función que devuelve verdadero si la pila está vacía:
int vacia(struct tpila *pila)
{
return (pila->cima == -1);
}
- Función que devuelve verdadero si la pila está llena:
int llena(struct tpila *pila)
{
return (pila->cima == MAX_PILA);
}
- Procedimiento de apilado:
void apilar(struct tpila *pila, int elem)
{
pila->elementos[++pila->cima] = elem;
}
- Procedimiento de desapilado:
void desapilar(struct tpila *pila, int *elem)
{
*elem = pila->elementos[pila->cima--];
}
Programa de prueba:
#include <stdio.h>
int main(void)
{
struct tpila pila;
int elem;
crear(&pila);
if (vacia(&pila)) printf("\nPila vacia.");
- 60 -
if (llena(&pila)) printf("\nPila llena.");
apilar(&pila, 1);
desapilar(&pila, &elem);
return 0;
}
Puesto que son muy sencillos, el usuario puede decidir implementar una pila 'inline', es
decir, sin usar procedimientos ni funciones, lo cual aumentará el rendimiento a costa de
una cierta legibilidad. Es más, los problemas que aparecen resueltos en esta web en
general utilizan las pilas con arrays de forma 'inline'. Además, esta implementación es algo
más rápida que con listas enlazadas, pero tiene un tamaño estático.
En C y en algún otro lenguaje de programación puede modificarse el tamaño de un array si
éste se define como un puntero al que se le reserva una dirección de memoria de forma
explícita (mediante malloc en C). Sin embargo, a la hora de alterar dinámicamente esa
región de memoria, puede ocurrir que no haya una región en la que reubicar el nuevo
array (mediante realloc en C) impidiendo su crecimiento.
Implementación mediante lista enlazada
Para hacer la implementación se utiliza una lista con cabecera ficticia (ver apartado de
listas). Dado el carácter dinámico de esta implementación no existe una función que
determine si la pila está llena. Si el usuario lo desea puede implementar un análisis del
código devuelto por la función de asignación de memoria.
- Declaración:
struct tpila
{
int clave;
struct tpila *sig;
};
- Procedimiento de creación:
void crear(struct tpila **pila)
{
*pila = (struct tpila *) malloc(sizeof(struct tpila));
(*pila)->sig = NULL;
}
- Función que devuelve verdadero si la pila está vacía:
int vacia(struct tpila *pila)
{
return (pila->sig == NULL);
}
- Procedimiento de apilado (apila al comienzo de la lista):
void apilar(struct tpila *pila, int elem)
{
struct tpila *nuevo;
nuevo = (struct tpila *) malloc(sizeof(struct tpila));
nuevo->clave = elem;
nuevo->sig = pila->sig;
pila->sig = nuevo;
}
- Procedimiento de desapilado (desapila del comienzo de la lista):
void desapilar(struct tpila *pila, int *elem)
{
- 61 -
struct tpila *aux;
aux = pila->sig;
*elem = aux->clave;
pila->sig = aux->sig;
free(aux);
}
Programa de prueba:
int main(void)
{
struct tpila *pila;
int elem;
crear(&pila);
if (vacia(pila)) printf("\nPila vacia!");
apilar(pila, 1);
desapilar(pila, &elem);
return 0;
}
En este caso, hacerlo 'inline' puede afectar seriamente la legibilidad del programa.
Si el usuario desea hacer un programa a prueba de balas puede probar el siguiente
procedimiento de apilado, que simplemente comprueba si hay memoria para una
asignación de memoria:
void apilar(struct tpila *pila, int elem)
{
struct tpila *nuevo;
if ((nuevo = (struct tpila *) malloc(sizeof(struct tpila))) == NULL)
generar_error();
else {
nuevo->clave = elem;
nuevo->sig = pila->sig;
pila->sig = nuevo;
}
}
Es obvio que si se llama al procedimiento generar_error es que el sistema se ha quedado
sin memoria, o al menos se ha agotado la región de memoria que el sistema operativo y/o
el compilador dedican para almacenar los datos que la ejecución del programa crea.
Otras consideraciones
- ¿Cuantos elementos hay apilados?
En algunos casos puede ser interesante implementar una función para contar el número de
elementos que hay sobre la pila. En la implementación con arrays esto es directo. Si se
hace sobre listas enlazadas entonces hay que hacer alguna pequeña modificación sobre la
declaración e implementación:
struct nodo
{
int clave;
struct nodo *sig;
};
struct tpila
{
int numero_elems; /* mantiene el numero de elementos */
struct nodo *cima;
- 62 -
};
Los detalles de la implementación no se incluyen, pues es sencilla.
- ¿Cómo vaciar la pila?
En el caso de la implementación con array es directo, basta con inicializar la cima al valor
de vacío. Si es una lista enlazada hay que ir borrando elemento a elemento (o desapilarlos
todos). Los detalles se dejan para el lector.
Elegir entre implementación con listas o con arrays.
El uso del array es idóneo cuando se conoce de antemano el número máximo de elementos
que van a ser apilados y el compilador admite una región contigua de memoria para el
array. En otro caso sería más recomendable usar la implementación por listas enlazadas,
también si el número de elementos llegase a ser excesivamente grande.
La implementación por array es ligeramente más rápida. En especial, es mucho más rápido
a la hora de eliminar los elementos que hayan quedado en la pila. Por lista enlazada esto
no es tan rápido. Por ejemplo, piénsese en un algoritmo que emplea una pila y que en
algunos casos al terminar éste su ejecución deja algunos elementos sobre la pila. Si se
implementa la pila mediante una lista enlazada entonces quedarían en memoria una serie
de elementos que es necesario borrar. La única manera de borrarlos es liberar todas las
posiciones de memoria que le han sido asignadas a cada elemento, esto es, desapilar
todos los elementos. En el caso de una implementación con array esto no es necesario,
salvo que quiera liberarse la región de memoria ocupada por éste.
Colas.
Una cola es una estructura de datos de acceso restrictivo a sus elementos. Un ejemplo
sencillo es la cola del cine o del autobús, el primero que llegue será el primero en entrar, y
afortunadamente en un sistema informático no se cuela nadie salvo que el programador lo
diga. Las colas serán de ayuda fundamental para ciertos recorridos de árboles y grafos.
Las colas ofrecen dos operaciones fundamentales, que son encolar (al final de la cola) y
desencolar (del comienzo de la cola). Al igual que con las pilas, la implementación de las
colas suele encapsularse, es decir, basta con conocer las operaciones de manipulación de
la cola para poder usarla, olvidando su implementación interna. Es una estructra de tipo
FIFO (First In First Out), es decir: primero en entrar, primero en salir. A continuación se
expone la implementación de colas, con arrays y con listas enlazadas circulares. En ambos
casos se cubren cuatro operaciones básicas: Inicializar, Encolar, Desencolar, y Vacía. Las
claves que contendrán serán simplemente números enteros.
Implementación mediante array circular
Esta implementación es estática, es decir, da un tamaño máximo fijo a la cola. No se
incluye comprobación de errores dentro del encolado y el desencolado, pero se
implementan como funciones aparte.
¿Por qué un array circular? ¿Qué es eso? Como se aprecia en la implemetación de las pilas,
los elementos se quitan y se ponen sobre la cima, pero en este caso se introducen por un
sitio y se quitan por otro. Podría hacerse con un array secuencial, como se muestra en las
siguientes figuras. 'Entrada' es la posición de entrada a la cola, y 'Salida' por donde salen.
En esta primera figura se observa que se han introducido tres elementos: 3, 1 y 4 (en ese
orden):
- 63 -
se desencola, obteniendo un 3:
se encola un 7:
Figura 3.1
Enseguida se aprecia que esto tiene un grave defecto, y es que llega un momento en el
que se desborda la capacidad del array. Una solución nada efectiva es incrementar su
tamaño. Esta implementación es sencilla pero totalmente ineficaz.
Como alternativa se usa el array circular. Esta estructura nos permite volver al comienzo
del array cuando se llegue al final, ya sea el índice de entrada o el índice de salida.
Se implementarán dos versiones de arrays circulares, con lo que el programador podrá
escoger entre la que más le guste.
Primera versión:
Esta versión requiere el uso de la operación módulo de la división para determinar la
siguiente posición en el array.
Por ejemplo, supóngase un array de N = 2 elementos, contando desde 0 hasta 1. Suponer
que entrada = 0, salida = 1; Para determinar la posición siguiente del índice i en el array
se procede así:
i <- (i+1) Mod N
siendo Mod la operación resto de la división entera. Asi:
- sustituyendo i por salida se determina que salida = 0.
- sustituyendo i por entrada se determina que entrada = 1.
Nota: si el array está indexado entre 1 y N -como suele ser habitual en Pascal- entonces la
expresión que determina la posición siguiente es esta:
i <- (i Mod N) + 1
si entrada = 1, salida = 2, entonces:
- sustituyendo i por salida se determina que salida = 1.
- sustituyendo i por entrada se determina que entrada = 2.
De esta manera se van dando vueltas sobre el array. La lógica es la siguiente:
Para encolar: se avanza el índice entrada a la siguiente posición, y se encola en la posición
que apunte éste.
Para desencolar: el elemento desencolado es el que apunta el índice salida, y
posteriormente se avanza salida a la siguiente posición.
- 64 -
Cola vacía: la cola está vacía si el elemento siguiente a entrada es salida, como sucede en
el ejemplo anterior.
Cola llena: la cola está llena si el elemento que sigue al que sigue a entrada es salida.
Esto obliga a dejar un elemento vacío en el array, puesto que se reserva una posición para
separar los índices entrada y salida.
Para aclararlo, se muestran una serie de gráficos explicativos, partiendo de un array de
tres elementos, es decir, una cola de DOS elementos.
Cola vacía:
Se encola un 3.
Se desencola el 3; ahora se tiene una cola vacía.
Se encolan el 5 y el 7. Se obtiene una cola llena.
Figura 3.2
Si se desencola se obtiene el 5. ¡Si en lugar de desencolar se encola un elemento
cualquiera se obtiene una cola vacía!.
- Declaración:
struct tcola
{
int entrada, salida;
int elementos[MAX_COLA];
};
Una cola que tenga un elemento requiere que MAX_COLA = 2.
- Función que devuelve la posición siguiente a i en el array circular.
int siguiente(int i)
{
- 65 -
return ((i+1) % MAX_COLA);
}
- Creación:
void crear(struct tcola *cola)
{
cola->salida = 0;
cola->entrada = MAX_COLA - 1;
}
- Función que devuelve verdadero si la cola está vacía, cosa que ocurre cuando el siguiente
tras entrada es salida:
int vacia(struct tcola *cola)
{
return (siguiente(cola->entrada) == cola->salida);
}
- Función que devuelve verdadero si la cola está llena, caso que se da cuando el siguiente
elemento que sigue a entrada es salida:
int llena(struct tcola *cola)
{
return (siguiente(siguiente(cola->entrada)) == cola->salida);
}
- Encolado:
void encolar(struct tcola *cola, int elem)
{
cola->entrada = siguiente(cola->entrada);
cola->elementos[cola->entrada] = elem;
}
- Desencolado:
void desencolar(struct tcola *cola, int *elem)
{
*elem = cola->elementos[cola->salida];
cola->salida = siguiente(cola->salida);
}
- Programa de prueba:
#include <stdio.h>
#define MAX_COLA 50 /* cola de 49 elementos */
int main(void)
{
struct tcola cola;
int elem;
crear(&cola);
if (vacia(&cola)) printf("\nCola vacia.");
if (llena(&cola)) printf("\nCola llena.");
encolar(&cola, 1);
desencolar(&cola, &elem);
- 66 -
return 0;
}
Segunda versión:
En este caso se omite la función siguiente, y se aprovechan todos los elementos. Sin
embargo se contabiliza en una variable el número de elementos que hay en un momento
dado en la cola. Esta implementación es parecida a la secuencial, pero vigilando que los
índices no se pasen de rosca.
¿Cómo se determina la siguiente posición? Se avanza una posición, y si llega al límite del
array el índice se actualiza al primer elemento. La lógica es la siguiente:
Para encolar: se encola en la posición indicada por entrada, y se avanza una posición.
Para desencolar: el elemento desencolado es el que apunta el índice salida, y
posteriormente se avanza salida a la siguiente posición.
Cola vacía: la cola está vacía si el número de elementos es cero.
Cola llena: la cola está llena si el número de elementos es el máximo admitido.
- Declaración:
struct tcola
{
int elems;
int entrada, salida;
int elementos[MAX_COLA];
};
Una cola que tenga dos elementos requiere que MAX_COLA = 2.
- Creación:
void crear(struct tcola *cola)
{
cola->elems = cola->salida = cola->entrada = 0;
}
- Función que devuelve verdadero si la cola está vacía:
int vacia(struct tcola *cola)
{
return (cola->elems == 0);
}
- Función que devuelve verdadero si la cola está llena:
int llena(struct tcola *cola)
{
return (cola->elems == MAX_COLA);
}
- Encolado:
void encolar(struct tcola *cola, int elem)
{
cola->elems++;
cola->elementos[cola->entrada++] = elem;
if (cola->entrada == MAX_COLA)
cola->entrada = 0;
}
- Desencolado:
- 67 -
void desencolar(struct tcola *cola, int *elem)
{
cola->elems--;
*elem = cola->elementos[cola->salida++];
if (cola->salida == MAX_COLA)
cola->salida = 0;
}
- Programa de prueba:
El mismo de antes sirve.
Implementación mediante lista enlazada
Para hacer la implementación se utilizará una lista circular sin cabecera.
La cola estará inicialmente vacía. Cuando se añadan elementos el puntero que mantiene la
cola apunta al último elemento introducido, y el siguiente elemento al que apunta es al
primero que está esperando para salir.
- ¿Cómo encolar?. Se crea el nuevo elemento, se enlaza con el primero de la cola. Si no
está vacía hay que actualizar el enlace del, hasta el momento de la inserción, último
elemento introducido. Por último se actualiza el comienzo de la cola, esté vacía o no.
- ¿Cómo desencolar?. Si tiene un sólo elemento se borra y se actualiza el puntero a un
valor nulo. Si tiene dos o más elementos entonces se elimina el primero y el último
apuntará al segundo.
Ejemplo gráfico de encolado. Partiendo de una cola que tiene el elemento 3, se van
añadiendo el 5 y el 7 (observar de izquierda a derecha). A la hora de desencolar se extrae
el siguiente al que apunta Cola.
Figura 3.3
Ejemplo gráfico de desencolado. Partiendo de la cola formada anteriormente, se han
quitado los dos primeros elementos introducidos (observar de izquierda a derecha).
Figura 3.4
- 68 -
- Declaración:
struct tcola
{
int clave;
struct tcola *sig;
};
- Creación:
void crear(struct tcola **cola)
{
*cola = NULL;
}
- Función que devuelve cierto si la cola está vacía:
int vacia(struct tcola *cola)
{
return (cola == NULL);
}
- Encolado:
void encolar(struct tcola **cola, int elem)
{
struct tcola *nuevo;
nuevo = (struct tcola *) malloc(sizeof(struct tcola));
nuevo->clave = elem;
if (*cola == NULL)
nuevo->sig = nuevo;
else {
nuevo->sig = (*cola)->sig;
(*cola)->sig = nuevo;
}
(*cola) = nuevo;
}
- Desencolado:
void desencolar(struct tcola **c1, int *elem)
{
struct tcola *aux;
*elem = (*c1)->sig->clave;
if ((*c1) == (*c1)->sig) {
free(*c1);
*c1 = NULL;
}
else {
aux = (*c1)->sig;
(*c1)->sig = aux->sig;
free(aux);
}
}
- Programa de prueba:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
- 69 -
struct tcola *cola;
int elem;
crear(&cola);
if (vacia(cola)) printf("\nCola vacia!");
encolar(&cola, 3);
desencolar(&cola, &elem);
return 0;
}
Al igual que en las pilas implementadas por listas enlazadas, es recomendable analizar el
código devuelto por la función de asignación de memoria para evitar posibles problemas en
un futuro.
Otras consideraciones
En algunos casos puede ser interesante implementar una función para contar el número de
elementos que hay en la cola. Una manera de hacer esto con listas enlazadas es
empleando la siguiente declaración:
struct nodo
{
int clave;
struct nodo *sig;
};
struct tcola
{
int numero_elems; /* mantiene el numero de elementos */
struct nodo *cola;
};
Los detalles de la implementación no se incluyen, pues es sencilla.
¿Qué implementación es mejor, arrays o listas?
Al igual que con las pilas, la mejor implementación depende de la situación particular. Si se
conocen de antemano el número de elementos entonces lo ideal es una implementación
por array. En otro caso se recomienda el uso de lista enlazada circular.
Lista.
Una lista es una estructura de datos secuencial.
Una manera de clasificarlas es por la forma de acceder al siguiente elemento:
- lista densa: la propia estructura determina cuál es el siguiente elemento de la lista.
Ejemplo: un array.
- lista enlazada: la posición del siguiente elemento de la estructura la determina el
elemento actual. Es necesario almacenar al menos la posición de memoria del primer
elemento. Además es dinámica, es decir, su tamaño cambia durante la ejecución del
programa.
Una lista enlazada se puede definir recursivamente de la siguiente manera:
- una lista enlazada es una estructura vacía o
- un elemento de información y un enlace hacia una lista (un nodo).
Gráficamente se suele representar así:
Figura 3.5
- 70 -
Como se ha dicho anteriormente, pueden cambiar de tamaño, pero su ventaja fundamental
es que son flexibles a la hora de reorganizar sus elementos; a cambio se ha de pagar una
mayor lentitud a la hora de acceder a cualquier elemento.
En la lista de la figura anterior se puede observar que hay dos elementos de información, x
e y. Supongamos que queremos añadir un nuevo nodo, con la información p, al comienzo
de la lista. Para hacerlo basta con crear ese nodo, introducir la información p, y hacer un
enlace hacia el siguiente nodo, que en este caso contiene la información x.
¿Qué ocurre si quisiéramos hacer lo mismo sobre un array?. En ese caso sería necesario
desplazar todos los elementos de información "hacia la derecha", para poder introducir el
nuevo elemento, una operación muy engorrosa.
Implementación
Para representar en lenguaje C esta estructura de datos se utilizarán punteros, un tipo de
datos que suministra el lenguaje. Se representará una lista vacía con la constante NULL.
Se puede definir la lista enlazada de la siguiente manera:
struct lista
{
int clave;
struct lista *sig;
};
Como se puede observar, en este caso el elemento de información es simplemente un
número entero. Además se trata de una definición autorreferencial. Pueden hacerse
definiciones más complejas. Ejemplo:
struct cl
{
char nombre[20];
int edad;
};
struct lista
{
struct cl datos;
int clave;
struct lista *sig;
};
Cuando se crea una lista debe estar vacía. Por tanto para crearla se hace lo siguiente:
struct lista *L;
L = NULL;
Operaciones básicas sobre listas
- Inserción al comienzo de una lista:
Es necesario utilizar una variable auxiliar, que se utiliza para crear el nuevo nodo mediante
la reserva de memoria y asignación de la clave. Posteriormente es necesario reorganizar
los enlaces, es decir, el nuevo nodo debe apuntar al que era el primer elemento de la lista
y a su vez debe pasar a ser el primer elemento.
En el siguiente ejemplo se muestra un programa que crea una lista con cuatro números.
Notar que al introducir al comienzo de la lista, los elementos quedan ordenados en sentido
inverso al de su llegada. Notar también que se ha utilizado un puntero auxiliar p para
mantener correctamente los enlaces dentro de la lista.
#include <stdlib.h>
struct lista
{
- 71 -
int clave;
struct lista *sig;
};
int main(void)
{
struct lista *L;
struct lista *p;
int i;
L = NULL; /* Crea una lista vacia */
for (i = 4; i >= 1; i--)
{
/* Reserva memoria para un nodo */
p = (struct lista *) malloc(sizeof(struct lista));
p->clave = i; /* Introduce la informacion */
p->sig = L; /* reorganiza */
L = p;
/* los enlaces */
}
return 0;
}
- Recorrido de una lista.
La idea es ir avanzando desde el primer elemento hasta encontrar la lista vacía. Antes de
acceder a la estructura lista es fundamental saber si esa estructura existe, es decir, que no
está vacía. En el caso de estarlo o de no estar inicializada es posible que el programa falle
y sea difícil detectar donde, y en algunos casos puede abortarse inmediatamente la
ejecución del programa, lo cual suele ser de gran ayuda para la depuración.
Como se ha dicho antes, la lista enlazada es una estructura recursiva, y una posibilidad
para su recorrido es hacerlo de forma recursiva. A continuación se expone el código de un
programa que muestra el valor de la clave y almacena la suma de todos los valores en una
variable pasada por referencia (un puntero a entero). Por el hecho de ser un proceso
recursivo se utiliza un procedimiento para hacer el recorrido. Nótese como antes de hacer
una operación sobre el elemento se comprueba si existe.
int main(void)
{
struct lista *L;
struct lista *p;
int suma;
L = NULL;
/* crear la lista */
...
suma = 0;
recorrer(L, &suma);
return 0;
}
void recorrer(struct lista *L, int *suma)
{
if (L != NULL) {
printf("%d, ", L->clave);
*suma = *suma + L->clave;
- 72 -
recorrer(L->sig, suma);
}
}
Sin embargo, a la hora de hacer un programa, es más eficaz si el recorrido se hace de
forma iterativa. En este caso se necesita una variable auxiliar que se desplace sobre la lista
para no perder la referencia al primer elemento. Se expone un programa que hace la
misma operación que el anterior, pero sin recursión.
int main(void)
{
struct lista *L;
struct lista *p;
int suma;
L = NULL;
/* crear la lista */
...
p = L;
suma = 0;
while (p != NULL) {
printf("%d, ", p->clave);
suma = suma + p->clave;
p = p->sig;
}
return 0;
}
A menudo resulta un poco difícil de entender la instrucción p = p->sig; Simplemente
cambia la dirección actual del puntero p por la dirección del siguiente enlace. También es
común encontrar instrucciones del estilo: p = p->sig->sig; Esto puede traducirse en dos
instrucciones, de la siguiente manera:
p = p->sig;
p = p->sig;
Obviamente sólo debe usarse cuando se sepa que p->sig es una estructura no vacía,
puesto que si fuera vacía, al hacer otra vez p = p->sig se produciría una referencia a
memoria no válida.
¿Y si queremos insertar en una posición arbitraria de la lista o queremos borrar un
elemento? Como se trata de operaciones algo más complicadas (tampoco mucho) se
expone su desarrollo y sus variantes en los siguientes tipos de listas: las listas ordenadas y
las listas reorganizables. Asimismo se estudiarán después las listas que incorporan
cabecera y centinela. También se estudiarán las listas con doble enlace. Todas las
implementaciones se harán de forma iterativa, y se deja propuesta por ser más sencilla su
implementación recursiva, aunque es recomendable utilizar la versión iterativa.
Listas ordenadas
Las listas ordenadas son aquellas en las que la posición de cada elemento depende de su
contenido. Por ejemplo, podemos tener una lista enlazada que contenga el nombre y
apellidos de un alumno y queremos que los elementos -los alumnos- estén en la lista en
orden alfabético.
La creación de una lista ordenada es igual que antes:
struct lista *L;
L = NULL;
Cuando haya que insertar un nuevo elemento en la lista ordenada hay que hacerlo en el
- 73 -
lugar que le corresponda, y esto depende del orden y de la clave escogidos. Este proceso
se
realiza
en
tres
pasos:
1.- Localizar el lugar correspondiente al elemento a insertar. Se utilizan dos punteros:
anterior
y
actual,
que garanticen la
correcta
posición de cada
enlace.
2.- Reservar memoria para él (puede hacerse como primer paso). Se usa un puntero
auxiliar (nuevo) para reservar memoria.
3.- Enlazarlo. Esta es la parte más complicada, porque hay que considerar la diferencia de
insertar al principio, no importa si la lista está vacía, o insertar en otra posición. Se utilizan
los tres punteros antes definidos para actualizar los enlaces.
A continuación se expone un programa que realiza la inserción de un elemento en una lista
ordenada. Suponemos claves de tipo entero ordenadas ascendentemente.
#include <stdio.h>
#include <stdlib.h>
struct lista
{
int clave;
struct lista *sig;
};
/* prototipo */
void insertar(struct lista **L, int elem);
int main(void)
{
struct lista *L;
L = NULL; /* Lista vacia */
/* para probar la insercion se han tomado 3 elementos */
insertar(&L, 0);
insertar(&L, 1);
insertar(&L, -1);
return 0;
}
void insertar(struct lista **L, int elem)
{
struct lista *actual, *anterior, *nuevo;
/* 1.- se busca su posicion */
anterior = actual = *L;
while (actual != NULL && actual->clave < elem) {
anterior = actual;
actual = actual->sig;
}
/* 2.- se crea el nodo */
nuevo = (struct lista *) malloc(sizeof(struct lista));
nuevo->clave = elem;
/* 3.- Se enlaza */
if (anterior == NULL || anterior == actual) { /* inserta al principio */
nuevo->sig = anterior;
- 74 -
*L = nuevo; /* importante: al insertar al principio actuliza la cabecera */
}
else {
/* inserta entre medias o al final */
nuevo->sig = actual;
anterior->sig = nuevo;
}
}
Se puede apreciar que se pasa la lista L con el parámetro **L . La razón para hacer esto es
que cuando se inserta al comienzo de la lista (porque está vacía o es donde corresponde)
se cambia la cabecera.
Un ejemplo de prueba: suponer que se tiene esta lista enlazada: 1 -> 3 -> 5 -> NULL
Queremos insertar un 4. Al hacer la búsqueda el puntero actual apunta al 5. El puntero
anterior apunta al 3. Y nuevo contiene el valor 4. Como no se inserta al principio se hace
que el enlace siguiente a nuevo sea actual, es decir, el 5, y el enlace siguiente a anterior
será nuevo, es decir, el 4.
La mejor manera de entender el funcionamiento es haciendo una serie de seguimientos a
mano o con la ayuda del depurador.
A continuación se explica el borrado de un elemento. El procedimiento consiste en
localizarlo y borrarlo si existe. Aquí también se distingue el caso de borrar al principio o
borrar en cualquier otra posición. Se puede observar que el algoritmo no tiene ningún
problema si el elemento no existe o la lista está vacía.
void borrar(struct lista **L, int elem)
{
struct lista *actual, *anterior;
/* 1.- busca su posicion. Es casi igual que en la insercion, ojo al (<) */
anterior = actual = *L;
while (actual != NULL && actual->clave < elem) {
anterior = actual;
actual = actual->sig;
}
/* 2.- Lo borra si existe */
if (actual != NULL && actual->clave == elem) {
if (anterior == actual)
/* borrar el primero */
*L = actual->sig; /* o tambien (*L)->sig; */
else
/* borrar en otro sitio */
anterior->sig = actual->sig;
free(actual);
}
}
Ejemplo: para borrar la clave '1' se indica así: borrar(&L, 1);
Listas reorganizables
Las listas reorganizables son aquellas en las que cada vez que se accede a un elemento
éste se coloca al comienzo de la lista. Si el elemento al que se accede no está en la lista
entonces se añade al comienzo de la misma. Cuando se trata de borrar un elemento se
procede de la misma manera que en la operación de borrado de la lista ordenada. Notar
que el orden en una lista reorganizable depende del acceso a un elemento, y no de los
valores de las claves.
No se va a desarrollar el procedimiento de inserción / acceso en una lista, se deja como
ejercicio. De todas formas es sencillo. Primero se busca ese elemento, si existe se pone al
comienzo de la lista, con cuidado de no perder los enlaces entre el elemento anterior y el
- 75 -
siguiente. Y si no existe pues se añade al principio y ya está. Por último se actualiza la
cabecera.
Cabecera ficticia y centinela
Como se ha observado anteriormente, a la hora de insertar o actualizar elementos en una
lista ordenada o reorganizable es fundamental actualizar el primer elemento de la lista
cuando sea necesario. Esto lleva un coste de tiempo, aunque sea pequeño salvo en el caso
de numerosas inserciones y borrados. Para subsanar este problema se utiliza la cabecera
ficticia.
La cabecera ficticia añade un elemento (sin clave, por eso es ficticia) a la estructura
delante del primer elemento. Evitará el caso especial de insertar delante del primer
elemento. Gráficamente se puede ver así:
Figura 3.6
Se declara una lista vacía con cabecera, reservando memoria para la cabecera, de la
siguiente manera:
struct lista {
int clave;
struct lista *sig;
}
...
struct lista *L;
L = (struct lista *) malloc(sizeof(struct lista));
L->sig = NULL;
Antes de implementar el proceso de inserción en una lista con cabecera, se explicará el uso
del centinela, y se realizarán los procedimientos de inserción y borrado aprovechando
ambas ideas.
El centinela es un elemento que se añade al final de la estructura, y sirve para acotar los
elementos de información que forman la lista. Pero tiene otra utilidad: el lector habrá
observado que a la hora de buscar un elemento de información, ya sea en la inserción o en
el borrado, es importante no dar un paso en falso, y por eso se comprueba que no se está
en una posición de información vacía. Pues bien, el centinela evita ese problema, al tiempo
que acelera la búsqueda.
A la hora de la búsqueda primero se copia la clave que buscamos en el centinela, y a
continuación se hace una búsqueda por toda la lista hasta encontrar el elemento que se
busca. Dicho elemento se encontrará en cualquier posición de la lista, o bien en el
centinela en el caso de que no estuviera en la lista. Como se sabe que el elemento está en
algún lugar de la lista (aunque sea en el centinela) no hay necesidad de comprobar si
estamos en una posición vacía.
Cuando la lista está vacía la cabecera apunta al centinela. El centinela siempre se apunta a
si mismo. Esto se hace así por convenio.
Gráficamente se puede representar así:
- 76 -
Figura 3.7
A continuación se realiza una implementación de lista enlazada ordenada, que incluye a la
vez cabecera y centinela.
struct lista
{
int clave;
struct lista *sig;
};
/* lista con cabecera y centinela */
struct listacc
{
struct lista *cabecera,
*centinela;
};
Procedimiento de inicialización (nótese el *LCC):
void crearLCC(struct listacc *LCC)
{
LCC->cabecera = (struct lista *) malloc(sizeof(struct lista));
LCC->centinela = (struct lista *) malloc(sizeof(struct lista));
LCC->cabecera->sig = LCC->centinela;
LCC->centinela->sig = LCC->centinela; /* opcional, por convenio */
}
Procedimiento de inserción:
void insertarLCC(struct listacc LCC, int elem)
{
struct lista *anterior, *actual, *nuevo;
/* 1.- busca */
anterior = LCC.cabecera;
actual = LCC.cabecera->sig;
LCC.centinela->clave = elem;
while (actual->clave < elem) {
anterior = actual;
actual = actual->sig;
}
/* 2.- crea */
nuevo = (struct lista *) malloc(sizeof(struct lista));
nuevo->clave = elem;
/* 3.- enlaza */
nuevo->sig = actual;
anterior->sig = nuevo;
- 77 -
}
Procedimiento de borrado:
void borrarLCC(struct listacc LCC, int elem)
{
struct lista *anterior, *actual;
/* 1.- busca */
anterior = LCC.cabecera;
actual = LCC.cabecera->sig;
LCC.centinela->clave = elem;
while (actual->clave < elem) {
anterior = actual;
actual = actual->sig;
}
/* 2.- borra si existe */
if (actual != LCC.centinela && actual->clave == elem) {
anterior->sig = actual->sig;
free(actual);
}
}
Ejemplo de uso:
#include <stdio.h>
#include <stdlib.h>
struct lista
{
int clave;
struct lista *sig;
};
struct listacc
{
struct lista *cabecera,
*centinela;
};
void crearLCC(struct listacc *LCC);
void insertarLCC(struct listacc LCC, int elem);
void borrarLCC(struct listacc LCC, int elem);
int main(void)
{
struct listacc LCC;
crearLCC(&LCC);
insertarLCC(LCC, 3);
borrarLCC(LCC, 3);
return 0;
}
La realización de la lista reorganizable aprovechando la cabecera y el centinela se deja
propuesta como ejercicio.
Listas doblemente enlazadas
Son listas que tienen un enlace con el elemento siguiente y con el anterior. Una ventaja
que tienen es que pueden recorrerse en ambos sentidos, ya sea para efectuar una
- 78 -
operación con cada elemento o para insertar/actualizar y borrar. La otra ventaja es que las
búsquedas son algo más rápidas puesto que no hace falta hacer referencia al elemento
anterior. Su inconveniente es que ocupan más memoria por nodo que una lista simple.
Se realizará una implementación de lista ordenada con doble enlace que aproveche el uso
de la cabecera y el centinela. A continuación se muestra un gráfico que muestra una lista
doblemente enlazada con cabecera y centinela, para lo que se utiliza un único nodo que
haga las veces de cabecera y centinela.
Figura 4.8
- Declaración:
struct listaDE
{
int clave;
struct listaDE *ant,
*sig;
};
- Procedimiento de creación:
void crearDE(struct listaDE **LDE)
{
*LDE = (struct listaDE *) malloc(sizeof(struct listaDE));
(*LDE)->sig = (*LDE)->ant = *LDE;
}
- Procedimiento de inserción:
void insertarDE(struct listaDE *LDE, int elem)
{
struct listaDE *actual, *nuevo;
/* busca */
actual = LDE->sig;
LDE->clave = elem;
while (actual->clave < elem)
actual = actual->sig;
/* crea */
nuevo = (struct listaDE *) malloc(sizeof(struct listaDE));
nuevo->clave = elem;
- 79 -
/* enlaza */
actual->ant->sig = nuevo;
nuevo->ant = actual->ant;
nuevo->sig = actual;
actual->ant = nuevo;
}
- Procedimiento de borrado:
void borrarDE(struct listaDE *LDE, int elem)
{
struct listaDE *actual;
/* busca */
actual = LDE->sig;
LDE->clave = elem;
while (actual->clave < elem)
actual = actual->sig;
/* borra */
if (actual != LDE && actual->clave == elem) {
actual->sig->ant = actual->ant;
actual->ant->sig = actual->sig;
free(actual);
}
}
Para probarlo se pueden usar las siguientes instrucciones:
struct listaDE *LDE;
...
crearDE(&LDE);
insertarDE(LDE, 1);
borrarDE(LDE, 1);
Listas circulares
Las listas circulares son aquellas en las que el último elemento tiene un enlace con el
primero. Su uso suele estar relacionado con las colas, y por tanto su desarrollo se realizará
en el tema de colas. Por supuesto, se invita al lector a desarrollarlo por su cuenta.
Algoritmos de ordenación de listas
* Un algoritmo muy sencillo:
Se dispone de una lista enlazada de cualquier tipo cuyos elementos son todos comparables
entre sí, es decir, que se puede establecer un orden, como por ejemplo números enteros.
Basta con crear una lista de tipo ordenada e ir insertando en ella los elementos que se
quieren ordenar al tiempo que se van borrando de la lista original sus elementos. De esta
manera se obtiene una lista ordenada con todos los elementos de la lista original. Este
algoritmo se llama Inserción Directa; ver Algoritmos de Ordenación. La complejidad para
ordenar una lista de n elementos es: cuadrática en el peor caso (n * n) -que se da cuando
la lista inicial ya está ordenada- y lineal en el mejor (n) -que se da cuanda la lista inicial
está ordenada de forma inversa.
Para hacer algo más rápido el algoritmo se puede implementar modificando los enlaces
entre los elementos de la lista en lugar de aplicar la idea propuesta anteriormente, que
- 80 -
requiere crear una nueva lista y borrar la lista no ordenada.
El algoritmo anterior es muy rápido y sencillo de implementar, pues ya están creadas las
estructuras de listas ordenadas necesarias para su uso. Eso sí, en general es ineficaz y no
debe emplearse para ordenar listas grandes. Para ello se emplea la ordenación por fusión
de listas.
* Un algoritmo muy eficiente: ordenación por fusión o intercalación .
Problemas propuestos:
- La ordenación por fusión no recursiva: consiste en desarrollar un algoritmo para fusionar
dos listas pero que no sea recursivo. No se trata de desarrollar una implementación
iterativa del programa anterior, sino de realizar una ordenación por fusión ascendente. Se
explica mediante un ejemplo:
3 -> 2 -> 1 -> 6 -> 9 -> 0 -> 7 -> 4 -> 3 -> 8
se fusiona el primer elemento con el segundo, el tercero con el cuarto, etcétera:
[(3) -> (2)] -> [(1) -> (6)] -> [(9) -> (0)] -> [(7) -> (4)] -> [(3) -> (8)]
queda:
2 -> 3 -> 1 -> 6 -> 0 -> 9 -> 4 -> 7 -> 3 -> 8
se fusionan los dos primeros (primera sublista) con los dos siguientes (segunda sublista),
la tercera y cuarta sublista, etcétera. Observar que la quinta sublista se fusiona con una
lista vacía, lo cual no supone ningún inconveniente para el algoritmo de fusión.
[(2 -> 3) -> (1 -> 6)] -> [(0 -> 9) -> (4 -> 7)] -> [(3 -> 8)]
queda:
1 -> 2 -> 3 -> 6 -> 0 -> 4 -> 7 -> 9 -> 3 -> 8
se fusionan los cuatro primeros con los cuatro siguientes, y aparte quedan los dos últimos:
[(1 -> 2 -> 3 -> 6) -> (0 -> 4 -> 7 -> 9)] -> [(3 -> 8)]
queda:
0 -> 1 -> 2 -> 3 -> 4 -> 6 -> 7 -> 9 -> 3 -> 8
se fusionan los ocho primeros con los dos últimos, y el resultado final es una lista
totalmente ordenada:
0 -> 1 -> 2 -> 3 -> 3 -> 4 -> 6 -> 7 -> 8 -> 9
Para una lista de N elementos, ordena en el mejor y en el peor caso en un tiempo
proporcional a: N·logN. Observar que para ordenar una lista de 2 elementos requiere un
paso de ordenación, una lista de 4 elementos requiere dos pasos de ordenación, una lista
de 8 elementos requiere tres pasos de ordenación, una lista de 16 requiere cuatro pasos,
etcétera. Es decir:
log 2 = 1
log 4 = 2
log 8 = 3
log 16 = 4
log 32 = 5
De ahí el logaritmo en base 2.
N aparece porque en cada paso se requiere recorrer toda la lista, luego el tiempo es
proporcional a N·logN.
Se pide: codificar el algoritmo de ordenación por fusión ascendente.
Conclusión
Las listas enlazadas son muy versátiles. Además, pueden definirse estructuras más
complejas a partir de las listas, como por ejemplo arrays de listas, etc. En algunas
ocasiones los grafos se definen como listas de adyacencia. También se utilizan para las
tablas de hash (dispersión) como arrays de listas.
Son eficaces igualmente para diseñar colas de prioridad, pilas y colas sin prioridad, y en
general cualquier estructura cuyo acceso a sus elementos se realice de manera secuencial.
- 81 -
3.2.4
Árboles.
Un árbol es una estructura de datos, que puede definirse de forma recursiva como:
- Una estructura vacía o
- Un elemento o clave de información (nodo) más un número finito de estructuras tipo
árbol, disjuntos, llamados subárboles. Si dicho número de estructuras es inferior o igual a
2, se tiene un árbol binario.
Es, por tanto, una estructura no secuencial.
Otra definición nos da el árbol como un tipo de grafo: un árbol es un grafo acíclico, conexo
y no dirigido. Es decir, es un grafo no dirigido en el que existe exactamente un camino
entre todo par de nodos. Esta definición permite implementar un árbol y sus operaciones
empleando las representaciones que se utilizan para los grafos. Sin embargo, en esta
sección no se tratará esta implementación.
Formas de representación
- Mediante un grafo:
Figura 3.10
- Mediante un diagrama encolumnado:
a
b
d
c
e
f
En la computación se utiliza mucho una estructura de datos, que son los árboles binarios.
Estos árboles tienen 0, 1 ó 2 descendientes como máximo. El árbol de la figura anterior es
un ejemplo válido de árbol binario.
Nomenclatura sobre árboles
- Raíz: es aquel elemento que no tiene antecesor; ejemplo: a.
- Rama: arista entre dos nodos.
- Antecesor: un nodo X es es antecesor de un nodo Y si por alguna de las ramas de X se
puede llegar a Y.
- 82 -
- Sucesor: un nodo X es sucesor de un nodo Y si por alguna de las ramas de Y se puede
llegar a X.
- Grado de un nodo: el número de descendientes directos que tiene. Ejemplo: c tiene
grado 2, d tiene grado 0, a tiene grado 2.
Hoja:
nodo
que
no
tiene
descendientes:
grado
0.
Ejemplo:
d
Nodo
interno:
aquel
que
tiene
al
menos
un
descendiente.
- Nivel: número de ramas que hay que recorrer para llegar de la raíz a un nodo. Ejemplo:
el nivel del nodo a es 1 (es un convenio), el nivel del nodo e es 3.
- Altura: el nivel más alto del árbol. En el ejemplo de la figura 1 la altura es 3.
- Anchura: es el mayor valor del número de nodos que hay en un nivel. En la figura, la
anchura es 3.
Aclaraciones: se ha denominado a a la raíz, pero se puede observar según la figura que
cualquier nodo podría ser considerado raíz, basta con girar el árbol. Podría determinarse
por ejemplo que b fuera la raíz, y a y d los sucesores inmediatos de la raíz b. Sin embargo,
en las implementaciones sobre un computador que se realizan a continuación es necesaria
una jerarquía, es decir, que haya una única raíz.
Declaración de árbol binario
Se definirá el árbol con una clave de tipo entero (puede ser cualquier otra tipo de datos) y
dos hijos: izquierdo (izq) y derecho (der). Para representar los enlaces con los hijos se
utilizan punteros. El árbol vacío se representará con un puntero nulo.
Un árbol binario puede declararse de la siguiente manera:
typedef struct tarbol
{
int clave;
struct tarbol *izq,*der;
} tarbol;
Otras declaraciones también añaden un enlace al nodo padre, pero no se estudiarán aquí.
Recorridos sobre árboles binarios
Se consideran dos tipos de recorrido: recorrido en profundidad y recorrido en anchura o a
nivel. Puesto que los árboles no son secuenciales como las listas, hay que buscar
estrategias alternativas para visitar todos los nodos.
- Recorridos en profundidad:
* Recorrido en preorden: consiste en visitar el nodo actual (visitar puede ser simplemente
mostrar la clave del nodo por pantalla), y después visitar el subárbol izquierdo y una vez
visitado, visitar el subárbol derecho. Es un proceso recursivo por naturaleza.
Si se hace el recorrido en preorden del árbol de la figura 1 las visitas serían en el orden
siguiente: a,b,d,c,e,f.
void preorden(tarbol *a)
{
if (a != NULL) {
visitar(a);
preorden(a->izq);
preorden(a->der);
}
}
* Recorrido en inorden u orden central: se visita el subárbol izquierdo, el nodo actual, y
después se visita el subárbol derecho. En el ejemplo de la figura 1 las visitas serían en este
- 83 -
orden: b,d,a,e,c,f.
void inorden(tarbol *a)
{
if (a != NULL) {
inorden(a->izq);
visitar(a);
inorden(a->der);
}
}
* Recorrido en postorden: se visitan primero el subárbol izquierdo, después el subárbol
derecho, y por último el nodo actual. En el ejemplo de la figura 1 el recorrido quedaría así:
d,b,e,f,c,a.
void postorden(arbol *a)
{
if (a != NULL) {
postorden(a->izq);
postorden(a->der);
visitar(a);
}
}
La ventaja del recorrido en postorden es que permite borrar el árbol de forma consistente.
Es decir, si visitar se traduce por borrar el nodo actual, al ejecutar este recorrido se
borrará el árbol o subárbol que se pasa como parámetro. La razón para hacer esto es que
no se debe borrar un nodo y después sus subárboles, porque al borrarlo se pueden perder
los enlaces, y aunque no se perdieran se rompe con la regla de manipular una estructura
de datos inexistente. Una alternativa es utilizar una variable auxiliar, pero es innecesario
aplicando este recorrido.
- Recorrido en amplitud:
Consiste en ir visitando el árbol por niveles. Primero se visitan los nodos de nivel 1 (como
mucho hay uno, la raíz), después los nodos de nivel 2, así hasta que ya no queden más.
Si se hace el recorrido en amplitud del árbol de la figura una visitaría los nodos en este
orden:
a,b,c,d,e,f
En este caso el recorrido no se realizará de forma recursiva sino iterativa, utilizando una
cola como estructura de datos auxiliar. El procedimiento consiste en encolar (si no están
vacíos) los subárboles izquierdo y derecho del nodo extraido de la cola, y seguir
desencolando
y
encolando
hasta
que
la
cola
esté
vacía.
En la codificación que viene a continuación no se implementan las operaciones sobre colas.
void amplitud(tarbol *a)
{
tCola cola; /* las claves de la cola serán de tipo árbol binario */
arbol *aux;
if (a != NULL) {
CrearCola(cola);
encolar(cola, a);
while (!colavacia(cola)) {
desencolar(cola, aux);
visitar(aux);
if (aux->izq != NULL) encolar(cola, aux->izq);
if (aux->der != NULL) encolar(cola, aux->der);
}
- 84 -
}
}
Por último, considérese la sustitución de la cola por una pila en el recorrido en amplitud.
¿Qué tipo de recorrido se obtiene?
Construcción de un árbol binario
Hasta el momento se ha visto la declaración y recorrido de un árbol binario. Sin embargo
no se ha estudiado ningún método para crearlos. A continuación se estudia un método
para crear un árbol binario que no tenga claves repetidas partiendo de su recorrido en
preorden e inorden, almacenados en sendos arrays.
Antes de explicarlo se recomienda al lector que lo intente hacer por su cuenta, es sencillo
cuando uno es capaz de construir el árbol viendo sus recorridos pero sin haber visto el
árbol terminado.
Partiendo de los recorridos preorden e inorden del árbol de la figura 1 puede determinarse
que la raíz es el primer elemento del recorrido en preorden. Ese elemento se busca en el
array inorden. Los elementos en el array inorden entre izq y la raíz forman el subárbol
izquierdo. Asimismo los elementos entre der y la raíz forman el subárbol derecho. Por
tanto se tiene este árbol:
Figura 3.11
A continuación comienza un proceso recursivo. Se procede a crear el subárbol izquierdo,
cuyo tamaño está limitado por los índices izq y der. La siguiente posición en el recorrido en
preorden es la raíz de este subárbol. Queda esto:
Figura 3.12
- 85 -
El subárbol b tiene un subárbol derecho, que no tiene ningún descendiente, tal y como
indican los índices izq y der. Se ha obtenido el subárbol izquierdo completo de la raíz a,
puesto que b no tiene subárbol izquierdo:
Figura 3.13
Después seguirá construyéndose el subárbol derecho a partir de la raíz a.
La implementación de la construcción de un árbol partiendo de los recorridos en preorden y
en inorden puede consultarse aquí (en C).
Árbol binario de búsqueda
Un árbol binario de búsqueda es aquel que es:
- Una estructura vacía o
- Un elemento o clave de información (nodo) más un número finito -a lo sumo dos- de
estructuras tipo árbol, disjuntos, llamados subárboles y además cumplen lo siguiente:
* Todas las claves del subárbol izquierdo al nodo son menores que la clave del nodo.
* Todas las claves del subárbol derecho al nodo son mayores que la clave del nodo.
* Ambos subárboles son árboles binarios de búsqueda.
Un ejemplo de árbol binario de búsqueda:
Figura 3.14
- 86 -
Al definir el tipo de datos que representa la clave de un nodo dentro de un árbol binario de
búsqueda es necesario que en dicho tipo se pueda establecer una relación de orden. Por
ejemplo, suponer que el tipo de datos de la clave es un puntero (da igual a lo que apunte).
Si se codifica el árbol en Pascal no se puede establecer una relación de orden para las
claves, puesto que Pascal no admite determinar si un puntero es mayor o menor que otro.
En el ejemplo de la figura las claves son números enteros. Dada la raíz 4, las claves del
subárbol izquierdo son menores que 4, y las claves del subárbol derecho son mayores que
4. Esto se cumple también para todos los subárboles. Si se hace el recorrido de este árbol
en orden central se obtiene una lista de los números ordenada de menor a mayor.
Cuestión: ¿Qué hay que hacer para obtener una lista de los números ordenada de mayor a
menor?
Una ventaja fundamental de los árboles de búsqueda es que son en general mucho más
rápidos para localizar un elemento que una lista enlazada. Por tanto, son más rápidos para
insertar y borrar elementos. Si el árbol está perfectamente equilibrado -esto es, la
diferencia entre el número de nodos del subárbol izquierdo y el número de nodos del
subárbol derecho es a lo sumo 1, para todos los nodos- entonces el número de
comparaciones necesarias para localizar una clave es aproximadamente de logN en el peor
caso. Además, el algoritmo de inserción en un árbol binario de búsqueda tiene la ventaja sobre los arrays ordenados, donde se emplearía búsqueda dicotómica para localizar un
elemento- de que no necesita hacer una reubicación de los elementos de la estructura para
que esta siga ordenada después de la inserción. Dicho algoritmo funciona avanzando por el
árbol escogiendo la rama izquierda o derecha en función de la clave que se inserta y la
clave del nodo actual, hasta encontrar su ubicación; por ejemplo, insertar la clave 7 en el
árbol de la figura 5 requiere avanzar por el árbol hasta llegar a la clave 8, e introducir la
nueva clave en el subárbol izquierdo a 8.
El algoritmo de borrado en árboles es algo más complejo, pero más eficiente que el de
borrado en un array ordenado.
Ahora bien, suponer que se tiene un árbol vacío, que admite claves de tipo entero.
Suponer que se van a ir introduciendo las claves de forma ascendente. Ejemplo:
1,2,3,4,5,6
Se crea un árbol cuya raíz tiene la clave 1. Se inserta la clave 2 en el subárbol derecho de
1. A continuación se inserta la clave 3 en el subárbol derecho de 2.
Continuando las inserciones se ve que el árbol degenera en una lista secuencial,
reduciendo drásticamente su eficacia para localizar un elemento. De todas formas es poco
probable que se de un caso de este tipo en la práctica. Si las claves a introducir llegan de
forma más o menos aleatoria entonces la implementación de operaciones sobre un árbol
binario de búsqueda que vienen a continuación son en general suficientes.
Existen variaciones sobre estos árboles, como los AVL o Red-Black (no se tratan aquí), que
sin llegar a cumplir al 100% el criterio de árbol perfectamente equilibrado, evitan
problemas como el de obtener una lista degenerada.
Operaciones básicas sobre árboles binarios de búsqueda
- Búsqueda
Si el árbol no es de búsqueda, es necesario emplear uno de los recorridos anteriores sobre
el árbol para localizarlo. El resultado es idéntico al de una búsqueda secuencial.
Aprovechando las propiedades del árbol de búsqueda se puede acelerar la localización.
Simplemente hay que descender a lo largo del árbol a izquierda o derecha dependiendo del
elemento que se busca.
boolean buscar(tarbol *a, int elem)
{
if (a == NULL) return FALSE;
else if (a->clave < elem) return buscar(a->der, elem);
else if (a->clave > elem) return buscar(a->izq, elem);
- 87 -
else return TRUE;
}
- Inserción
La inserción tampoco es complicada. Es más, resulta practicamente idéntica a la búsqueda.
Cuando se llega a un árbol vacío se crea el nodo en el puntero que se pasa como
parámetro por referencia, de esta manera los nuevos enlaces mantienen la coherencia. Si
el elemento a insertar ya existe entonces no se hace nada.
void insertar(tarbol **a, int elem)
{
if (*a == NULL) {
*a = (arbol *) malloc(sizeof(arbol));
(*a)->clave = elem;
(*a)->izq = (*a)->der = NULL;
}
else if ((*a)->clave < elem) insertar(&(*a)->der, elem);
else if ((*a)->clave > elem) insertar(&(*a)->izq, elem);
}
- Borrado
La operación de borrado si resulta ser algo más complicada. Se recuerda que el árbol debe
seguir siendo de búsqueda tras el borrado. Pueden darse tres casos, una vez encontrado el
nodo a borrar:
1) El nodo no tiene descendientes. Simplemente se borra.
2) 2) El nodo tiene al menos un descendiente por una sola rama. Se borra dicho nodo,
y su primer descendiente se asigna como hijo del padre del nodo borrado. Ejemplo:
en el árbol de la figura 5 se borra el nodo cuya clave es -1. El árbol resultante es:
Figura 3.15
3) El nodo tiene al menos un descendiente por cada rama. Al borrar dicho nodo es
necesario mantener la coherencia de los enlaces, además de seguir manteniendo la
estructura como un árbol binario de búsqueda. La solución consiste en sustituir la
información del nodo que se borra por el de una de las hojas, y borrar a continuación dicha
hoja. ¿Puede ser cualquier hoja? No, debe ser la que contenga una de estas dos claves:
· la mayor de las claves menores al nodo que se borra. Suponer que se quiere borrar el
nodo 4 del árbol de la figura 5. Se sustituirá la clave 4 por la clave 2.
· la menor de las claves mayores al nodo que se borra. Suponer que se quiere borrar el
nodo 4 del árbol de la figura 5. Se sustituirá la clave 4 por la clave 5.
El algoritmo de borrado que se implementa a continuación realiza la sustitución por la
- 88 -
mayor de las claves menores, (aunque se puede escoger la otra opción sin pérdida de
generalidad). Para lograr esto es necesario descender primero a la izquierda del nodo que
se va a borrar, y después avanzar siempre a la derecha hasta encontrar un nodo hoja. A
continuación se muestra gráficamente el proceso de borrar el nodo de clave 4:
Figura 3.16
Codificación: el procedimiento sustituir es el que desciende por el árbol cuando se da el
caso del nodo con descencientes por ambas ramas.
void borrar(tarbol **a, int elem)
{
void sustituir(tarbol **a, tarbol **aux);
tarbol *aux;
if (*a == NULL) /* no existe la clave */
return;
if ((*a)->clave < elem) borrar(&(*a)->der, elem);
else if ((*a)->clave > elem) borrar(&(*a)->izq, elem);
else if ((*a)->clave == elem) {
aux = *a;
if ((*a)->izq == NULL) *a = (*a)->der;
else if ((*a)->der == NULL) *a = (*a)->izq;
else sustituir(&(*a)->izq, &aux); /* se sustituye por
la mayor de las menores */
free(aux);
}
}
Ejercicio resuelto
Escribir una función que devuelva el numero de nodos de un árbol binario. Una solución
recursiva puede ser la siguiente:
funcion nodos(arbol : tipoArbol) : devuelve entero;
inicio
si arbol = vacio entonces devolver 0;
en otro caso devolver (1 + nodos(subarbol_izq) + nodos(subarbol_der));
fin
Adaptarlo para que detecte si un árbol es perfectamente equilibrado o no.
Problemas propuestos
- 89 -
Árboles binarios:
Aplicación práctica de un árbol
Se tiene un fichero de texto ASCII. Para este propósito puede servir cualquier libro
electrónico de la librería Gutenberg o Cervantes, que suelen tener varios cientos de miles
de palabras. El objetivo es clasificar todas las palabras, es decir, determinar que palabras
aparecen, y cuantas veces aparece cada una. Palabras como 'niño'-'niña', 'vengo'-'vienes'
etc, se consideran diferentes por simplificar el problema.
Escribir un programa, que recibiendo como entrada un texto, realice la clasificación
descrita
anteriormente.
Ejemplo:
Texto: "a b'a c. hola, adios, hola"
La salida que produce es la siguiente:
a2
adios 1
b1
c1
hola 2
Nótese que el empleo de una lista enlazada ordenada no es una buena solución. Si se
obtienen hasta 20.000 palabras diferentes, por decir un número, localizar una palabra
cualquiera puede ser, y en general lo será, muy costoso en tiempo. Se puede hacer una
implementación por pura curiosidad para evaluar el tiempo de ejecución, pero no merece
la pena.
La solución pasa por emplear un árbol binario de búsqueda para insertar las claves. El
valor de log(20.000) es aproximadamente de 14. Eso quiere decir que localizar una
palabra entre 20.000 llevaría en el peor caso unos 14 accesos. El contraste con el empleo
de una lista es simplemente abismal. Por supuesto, como se ha comentado anteriormente
el árbol no va a estar perfectamente equilibrado, pero nadie escribe novelas manteniendo
el orden lexicográfico (como un diccionario) entre las palabras, asi que no se obtendrá
nunca un árbol muy degenerado. Lo que está claro es que cualquier evolución del árbol
siempre será mejor que el empleo de una lista.
Por último, una vez realizada la lectura de los datos, sólo queda hacer un recorrido en
orden central del árbol y se obtendrá la solución pedida en cuestión de segundos.
Una posible definición de la estructura árbol es la siguiente:
typedef struct tarbol
{
char clave[MAXPALABRA];
int contador; /* numero de apariciones. Iniciar a 0 */
struct tarbol *izq,
*der;
} tarbol;
3.3
Operaciones con las estructuras de datos
Una estructura de datos define la organización e interrelación de éstos y un conjunto de
operaciones que se pueden realizar sobre ellos. Las operaciones básicas son:
Alta, adicionar un nuevo valor a la estructura.
Baja, borrar un valor de la estructura.
Búsqueda, encontrar un determinado valor en la estructura para realizar una operación con
este valor, en forma SECUENCIAL o BINARIO (siempre y cuando los datos estén
ordenados)...
Otras operaciones que se pueden realizar son:
- 90 -
Ordenamiento, de los elementos pertenecientes a la estructura.
Apareo, dadas dos estructuras originar una nueva ordenada y que contenga a las
apareadas.
Cada estructura ofrece ventajas y desventajas en relación a la simplicidad y eficiencia para
la realización de cada operación. De esta forma, la elección de la estructura de datos
apropiada para cada problema depende de factores como la frecuencia y el orden en que
se realiza cada operación sobre los datos.
3.3.1
Inserción y eliminación.
Podemos clasificar a las operaciones en las que intervienen arreglos y en generala
estrcutruras de datos de la siguiente manera:
Lectura/escritura
Asignación
Actualización : Inserción
Eliminación
Modificación
Ordenación
Búsqueda
Como los arreglos son datos estructurados muchas de estas operaciones no pueden
llevarse a cabo de manera global sino que debe trabajar sobre cada componente.
A continuación se analizara cada una de estas operaciones. Para ilustrarlas se utilizaran los
ejemplos presentados anteriormente.
Lectura
El proceso de lectura de un arreglo consiste en leer y asignar un valor a cada uno de sus
componentes. Supóngase que se desee leer todos los elementos del arreglo V en forma
consecutiva. Podría hacerse de la siguiente manera:
Leer V[1],
Leer V[2],
...
Leer V[50]
De esta forma nos resulta práctico, por lo tanto se usara un ciclo para leer todos los
elementos del arreglo.
Repetir con I desde 1 hasta 50
Leer V[1]
Al variar el valor de I, cada elemento leido se asigna al componente del arreglo según la
posición indicada por I.
para I= 1, se lee V[1]
I= 2, se lee V[2]
...
I= N, se lee V[N]
Al finalizar el ciclo de lectura se tendrá asignado un valor a cada uno de los componentes
del arreglo V.
Puede suceder que no necesite leer todos los componentes, sino solamente algunos de
ellos. Supóngase por ejemplo que debe leerse los elementos con índices comprendidos
entre el 1 y el 30 inclucive.El ciclo nesesario es el siguiente:
- 91 -
Repetir con I desde 1 hasta 30
Leer V[I]
Escritura
El caso de la escritura es similar al de la lectura. Se debe escribir el valor de cada uno de
los componentes. Supóngase que se desea escribir los primeros N componentes del arreglo
V en forma consecutuva.los pasos a seguir son los siguientes:
repetir con I desde 1 hasta N
escribir v[I]
al variar el valor de I se escribe el elemento de V correspondiente a la posición
indicada I.
Para I =1, se escribe el valor de V [1] .
I=2, se escribe el valor de V [2] .
...
I=N, se escribe el valor de V [N].
Asignación.
En general no es posible asignar directamente un valor a todo el arreglo; sino que se debe
asignar el valor deseado a cada componente. En seguida se analizan algunos ejemplos de
asignación.
En los dos primeros casos se asigna un valor a una determinada casilla del arreglo (en el
primero a la señalada por el índice “ene”, y en el segundo a la indicada por el índice mar
CICLO[ene] - 123.89
CICLO[mar] - CICLO[ene] /2
En el tercer caso se asigna el 0 a todas las casillas del arreglo
como se muestra en la figura 1.9.
con lo que este queda
Reprtir con MES desde ene hasta dic.
Haser CICLO [MES ] -0
ciclo
ene
feb
mar
.....
dic
Asignacion de arreglos
en algunos lenguajes es posible asignar una variable tipo arreglo a otra exactamente del
mismo tipo.
V1-V
La expresión anterior es equivalente a :
repetir con I hasta 50
- 92 -
Haser V1 [i]- v[I]
Actualización
Resulta interesante dado que un arreglo, puedan insertarse nuevos elementos.
Eliminar y/o modificar algunos de los ya existentes para llevar a cabo estas operaciones
eficientemente se debe tener en cuenta si el arreglo está o no ordenado. Es decir si sus
componentes respetan algún orden (creciente o decreciente). Las operaciones de inserción,
eliminación y modificación serán tratadas separadamente para arreglos desordenados y
ordenados.
ELIMINACION: Para eliminar un elemento x de un arreglo ordenado v debe verificarse que
el arreglo no éste vacío. Si se cumple esta condición entonces tendrá que buscarse la
posición del elemento a eliminar. si el resultado de la función es un valor positivo, quiere
decir que el elemento se encuentra en el arreglo y por lo tanto puede ser eliminado; en
otro caso no se puede ejecutar la eliminación.
El que sigue es el algoritmo de eliminación en arreglos
algoritmo.1.8 Eliminaordenado
ELIMINAORDENADO (V,N,X)
{El algoritmo elimina un elemento x de un arreglo ordenado v de n
elementos}
{POS e I son variables de tipo entero}
ordenados
1.Si N >o
entonces
llamar al algoritmo BUSCA con V,N,X POS
1.1Si POS <O {No se puede eliminar sino existe}
entonces
escribir" el elemento no existe"
si no
Hacer N-N-1
1.1.1Repetir con I desde POS hasta N
hacer V [I]-V [I+1]
1.1.2 {fin del ciclo del paso 1.1.1}
1.2{fin del condicional del paso 1.1}
si no
escribir "el arreglo esta vacio"
2.{fin del condicional del paso 1}
Modificación: Se procede de manera similar a la eliminación de un elemento en un arreglo
ordenado. La variante se presenta en que al modificador el valor x por un valor y, de ve
verificarse que el orden del arreglo no se altere. Si esto llegara a suceder, entonces podría
rechazarse la operación o reordenar el arreglo.
Se ha visto hasta el momento como declarar arreglos y como usarlos. Ahora puede darse
la solución al problema del ejemplo 1.1 usando arreglos.
El algoritmo que viene en seguida resuelve ese caso:
- 93 -
CONARREGLOS (CAL)
{Este algoritmo resuelve el problema del ejemplo
CAL es un arreglo de 50 elementos de números reales}
{AC,I Y CONT son variables de
real}
1.Hacer AC-0eI-1
2.Repetir mientras (I<_50)
Leer CAL [I]
Hacer AC-AC+CAL [I] e I-I+1
3.{fin del ciclo del paso 2}
4.Hacer PROM-AC/50,CONT-0eI-1
5.Repetir mientras (I<_50)
5.1Si CAL [I] > PROM entonces
Hacer CONT-CONT+1
5.2{fin del condicional del paso 5.1}
Hacer I-I+1
6.{fin del ciclo del paso 5}
7.Escribir CONT
tipo
entero.
PROM
1.1
es
aplicando
una
variable
arreglos.
de
tipo
Con esta solución se evitan los problemas mencionados anteriormente.se realiza una única
lectura de los datos y además se define una única variable para almacenar las 50
calificaciones. Al usar el arreglo puede disponerse de los datos tantas veces como sea
necesario sin tener que volver a leerlos, ya que estos permanecen en memoria. Además se
facilita el procesamiento de los datos, al poder generalizar ciertas operaciones.
3.3.2
Búsquedas.
Un algoritmo de búsqueda es aquel que está diseñado para localizar un elemento concreto
dentro de una estructura de datos. Consiste en solucionar un problema booleano de
existencia o no de un elemento determinado en un conjunto finito de elementos, es decir al
finalizar el algoritmo este debe decir si el elemento en cuestión existe o no en ese conjunto
(si pertenece o no a él), además, en caso de existir, el algoritmo podría proporcionar la
localización del elemento dentro del conjunto. Este problema puede reducirse a devolver la
existencia de un número en un vector.
Cuando el contenido del Vector no está o no puede ser ordenado, necesitaremos realizar
una búsqueda completa, ya que, en principio, la existencia se puede asegurar desde el
momento que el elemento es localizado, pero no podemos asegurar la no existencia hasta
pasar por el último elemento. La idea sería recorrer todos los elementos secuencialmente
y, si el elemento es localizado, devolver verdadero (o su posición dentro del vector). Si el
elemento no es localizado se sigue recorriendo todo el vector hasta llegar al último
elemento y si llegamos al final y no lo hemos encontrado se devuelve falso. El algoritmo en
pseudocódigo sería algo así:
Datos de Entrada:
vec: vector de enteros
tam: tamaño del vector
dato: entero que se quiere buscar dentro del vector
Variables
pos: tipo entero
- 94 -
pos=0
Mientras (pos<tam) Hacer
Si (vec[pos]=dato) Entonces
Devolver verdadero /* o Devolver pos, la posición del elemento localizado*/
Fin Sí
pos=pos+1
Fin Mientras
Devolver Falso /* o devolver '-1' como convenio para cuando el elemento no se haya
encontrado*/
Búsqueda binaria (sobre un vector ordenado)
Cuando el Vector en el que queremos determinar la existencia o no de un elemento está
ordenado, o puede estarlo, puede ser conveniente utilizar un algoritmo de Búsqueda
específico. La utilización de éste permitirá reducir considerablemente el tiempo de proceso,
ya que lo reduce exponencialmente.
Datos de Entrada:
vec: vector de enteros
tam: tamaño del vector
dato: entero que se quiere buscar dentro del vector
Variables
centro: tipo entero
izq: tipo entero
der: tipo entero
Mientras (izq<=der) Hacer
centro=(izq+der)/2 /* división entera: se trunca la parte decimal */
Si (vec[centro]=dato) Entonces
Devolver verdadero /* o Devolver pos, la posición del elemento localizado*/
Fin Si
Si (dato<vec[centro]) Entonces
der=centro-1
Sino
izq=centro+1
Fin Si
Fin Mientras
Devolver Falso /* o devolver '-1' como convenio para cuando el elemento no se haya
encontrado*/
3.3.3
Ordenación e intercalamiento.
En computación y matemáticas un algoritmo de ordenamiento es un algoritmo que pone
elementos de una lista o un vector en una secuencia dada por una relación de orden, es
decir, el resultado de salida ha de ser una permutación —o reordenamiento— de la entrada
que satisfaga la relación de orden dada. Las relaciones de orden más usadas son el orden
numérico y el orden lexicográfico. Ordenamientos eficientes son importantes para
optimizar el uso de otros algoritmos (como los de búsqueda y fusión) que requieren listas
ordenadas para una ejecución rápida. También es útil para poner datos en forma canónica
y para generar resultados legibles por humanos.
Desde los comienzos de la computación, el problema del ordenamiento ha atraído gran
cantidad de investigación, tal vez debido a la complejidad de resolverlo eficientemente a
- 95 -
pesar de su planteamiento simple y familiar. Por ejemplo, BubbleSort fue analizado desde
1956.1 Aunque muchos puedan considerarlo un problema resuelto, nuevos y útiles
algoritmos de ordenamiento se siguen inventado hasta el día de hoy (por ejemplo, el
ordenamiento de biblioteca se publicó por primera vez en el 2004). Los algoritmos de
ordenamiento son comunes en las clases introductorias a la computación, donde la
abundancia de algoritmos para el problema proporciona una gentil introducción a la
variedad de conceptos núcleo de los algoritmos, como notación de O mayúscula,
algoritmos divide y vencerás, estructuras de datos, análisis de los casos peor, mejor, y
promedio, y límites inferiores.
Los algoritmos de ordenamiento se pueden clasificar de las siguientes maneras:
La más común es clasificar según el lugar donde se realice la ordenación
Algoritmos de ordenamiento interno: en la memoria del ordenador.
Algoritmos de ordenamiento externo: en un lugar externo como un disco duro.
Por el tiempo que tardan en realizar la ordenación, dadas entradas ya ordenadas o
inversamente ordenadas:
Algoritmos de ordenación natural: Tarda lo mínimo posible cuando la entrada está
ordenada.
Algoritmos de ordenación no natural: Tarda lo mínimo posible cuando la entrada está
inversamente ordenada.
Por estabilidad: un ordenamiento estable mantiene el orden relativo que tenían
originalmente los elementos con claves iguales. Por ejemplo, si una lista ordenada por
fecha se reordena en orden alfabético con un algoritmo estable, todos los elementos cuya
clave alfabética sea la misma quedarán en orden de fecha. Otro caso sería cuando no
interesan las mayúsculas y minúsculas, pero se quiere que si una clave aBC estaba antes
que AbC, en el resultado ambas claves aparezcan juntas y en el orden original: aBC, AbC.
Cuando los elementos son indistinguibles (porque cada elemento se ordena por la clave
completa) la estabilidad no interesa. Los algoritmos de ordenamiento que no son estables
se pueden implementar para que sí lo sean. Una manera de hacer esto es modificar
artificialmente la clave de ordenamiento de modo que la posición original en la lista
participe del ordenamiento en caso de coincidencia.
3.3.3.2
Método de la burbuja.
El Ordenamiento de Burbuja (Bubble Sort en inglés) es un sencillo algoritmo de
ordenamiento. Funciona revisando cada elemento de la lista que va a ser ordenada con el
siguiente, intercambiándolos de posición si están en el orden equivocado. Es necesario
revisar varias veces toda la lista hasta que no se necesiten más intercambios, lo cual
significa que la lista está ordenada. Este algoritmo obtiene su nombre de la forma con la
que suben por la lista los elementos durante los intercambios, como si fueran pequeñas
"burbujas". También es conocido como el método del intercambio directo.
Dado que solo usa comparaciones para operar elementos, se lo considera un algoritmo de
comparación, siendo el más sencillo de implementar.
Una manera simple de expresar el ordenamiento de burbuja en pseudocódigo es la
siguiente:
Procedimiento
Haga lo siguiente:
Para
hasta
haga lo siguiente:
- 96 -
Si
entonces:
Repita mientras
La instrucción
con el de
sigue:
significa que se debe intercambiar el valor de
. El algorítmo también puede ser expresado de manera equivalente como
Procedimiento
Para
hasta
Para
Si
hasta
entonces:
haga lo siguiente:
haga lo siguiente:
En lenguaje C, el programa seria parecido a esto:
void bubble(int *start, int *end) //Ordena un conjunto de números enteros de menor a
mayor
{
short fin;
do
{
fin=0;
for (int *i=start;i!=*end;i++)
{
if (*i>*(i+1))
{
intercambia(i, i+1);
fin=1;
}
}
}while (fin!=1);
}
3.3.3.3
Método del Quicksort.
El ordenamiento rápido (quicksort en inglés) es un algoritmo basado en la técnica de divide
y vencerás, que permite, en promedio, ordenar n elementos en un tiempo proporcional a n
log n. Esta es la técnica de ordenamiento más rápida conocida. Fue desarrollada por C.
Antony R. Hoare en 1960. El algoritmo original es recursivo, pero se utilizan versiones
iterativas para mejorar su rendimiento (los algoritmos recursivos son en general más
lentos que los iterativos, y consumen más recursos).
El algoritmo fundamental es el siguiente:
Elegir un elemento de la lista de elementos a ordenar, al que llamaremos pivote.
Resituar los demás elementos de la lista a cada lado del pivote, de manera que a un lado
queden todos los menores que él, y al otro los mayores. En este momento, el pivote ocupa
- 97 -
exactamente el lugar que le corresponderá en la lista ordenada.
La lista queda separada en dos sublistas, una formada por los elementos a la izquierda del
pivote, y otra por los elementos a su derecha.
Repetir este proceso de forma recursiva para cada sublista mientras éstas contengan más
de un elemento. Una vez terminado este proceso todos los elementos estarán ordenados.
Como se puede suponer, la eficiencia del algoritmo depende de la posición en la que
termine el pivote elegido.
En el mejor caso, el pivote termina en el centro de la lista, dividiéndola en dos sublistas de
igual tamaño. En este caso, el orden de complejidad del algoritmo es O(n·log n).
En el peor caso, el pivote termina en un extremo de la lista. El orden de complejidad del
algoritmo es entonces de O(n²). El peor caso dependerá de la implementación del
algoritmo, aunque habitualmente ocurre en listas que se encuentran ordenadas, o casi
ordenadas.
En el caso promedio, el orden es O(n·log n).
No es extraño, pues, que la mayoría de optimizaciones que se aplican al algoritmo se
centren en la elección del pivote.
En lenguaje C, la implementación quedaría muy próximo a lo siguiente:
#include <conio.h>
#include <stdio.h>
#include <stdlib.h> //libreria con el prototipo de la funcion rand()
int quicksort_iterativo(int A[],int ini,int fin){
int _ini_,_fin_,pos,aux,band;
_ini_=ini;
_fin_=fin;
pos=ini;
band=1;
while (band==1){
band=0;
while((A[pos]<=A[_fin_])&&(pos!=_fin_)){
_fin_--;
}
if (pos!=_fin_){
aux=A[pos];A[pos]=A[_fin_];
A[_fin_]=aux;
pos=_fin_;
while ((A[pos]>=A[_ini_])&&(pos!=_ini_)){
_ini_++;
}
if(pos!=_ini_){
band=1;
aux=A[pos];
A[pos]=A[_ini_];
A[_ini_]=aux;
pos=_ini_;
}
}
}
if ((pos-1)>ini){
quicksort_iterativo(A,ini,pos-1);
}
if (fin>(pos+1)){
quicksort_iterativo(A,pos+1,fin);
}
return 0;
}
void main(){
int A[10],c;
clrscr();
randomize();
for(c=1;c<=10;c++){
- 98 -
}
A[c]=rand()%10;
printf("%d, ",A[c]);
printf("\nValores ordenados: \n");
quicksort_iterativo(A,1,10);
}
3.3.3.4
for(c=1;c<=10;c++){
printf("%d, ",A[c]);
}
getch();
Método de mezclas.
El algoritmo de Ordenamiento por mezcla (Merge sort en inglés) es un algoritmo de
ordenación externo estable basado en la técnica divide y vencerás. Fue desarrollado en
1945 por John Von Neumann.
A grandes rasgos, el algoritmo consiste en dividir en dos partes iguales el vector a ordenar,
ordenar por separado cada una de las partes, y luego mezclar ambas partes, manteniendo
la ordenación, en un solo vector ordenado. A continuación se describe el algoritmo en
pseudocódigo (se advierte de que no se incluyen casos especiales para vectores vacíos,
etc.; una implementación en un lenguaje de programación real debería tener en cuenta
estos detalles):
function mergesort(array A[0..n])
begin
array A1 := mergesort(A[0..(int(n / 2))])
array A2 := mergesort(A[int(1 + n / 2)..n])
return merge(A1, A2)
end
function merge(array A1[0..n1], array A2[0..n2])
begin
integer p1 := 0
integer p2 := 0
array R[0..(n1 + n2 + 1)]
while (p1 <= n1 or p2 <= n2):
if (p1 <= n1 and A1[p1] <= A2[p2]):
R[p1 + p2] := A1[p1]
p1 := p1 + 1
if (p2 <= n2 and A1[p1] > A2[p2]):
R[p1 + p2] := A2[p2]
p2 := p2 + 1
return R
end
- 99 -
En lenguaje C++ la implementación sería muy cercana a esto:
void fusiona(vector<ELEMENTO>& v, int ini, int med, int fin) {
vector<ELEMENTO> aux(fin - ini + 1);
int i = ini; // Índice de la parte izquierda
int j = med + 1; // Índice de la parte derecha
int k = 0; // Índice del vector aux
/* Mientras ninguno de los indices llegue a su fin vamos realizando
comparaciones. El elemento más pequeño se copia al vector aux */
while (i <= med and j <= fin) {
if (v[i] < v[j]) {
aux[k] = v[i];
++i;
}
else {
aux[k] = v[j];
++j;
}
++k;
}
/* Uno de los dos sub-vectores ya ha sido copiado del todo, simplemente
debemos copiar todo el sub-vector que nos falte */
while (i <= med) {
aux[k] = v[i];
++i;
++k;
}
}
while (j <= fin) {
aux[k] = v[j];
++j;
++k;
}
/* Copiamos los elementos ordenados de aux al vector original v */
for (int n = 0; n < aux.size(); ++n) v[ini + n] = aux[n];
void merge_sort(vector<ELEMENTO>& v, int ini, int fin) {
/* Si ini = fin el sub-vector es de un solo elemento y, por lo tanto
ya está ordenado por definición */
if (ini < fin) {
int med = (ini + fin)/2;
merge_sort(v, ini, med);
merge_sort(v, med + 1, fin);
fusiona(v, ini, med, fin);
}
}
- 100 -
Descargar