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_])&#038;&#038;(pos!=_fin_)){ _fin_--; } if (pos!=_fin_){ aux=A[pos];A[pos]=A[_fin_]; A[_fin_]=aux; pos=_fin_; while ((A[pos]>=A[_ini_])&#038;&#038;(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 -