Estructura de Datos Unidad Temas 1 Análisis de algoritmos. 1.1 1.2 1.3 1.4 Subtemas Concepto de Complejidad de algoritmos. Aritmética de la notación O. Complejidad. 1.3.1 Tiempo de ejecución de un algoritmo. 1.3.2 Complejidad en espacio. Selección de un algoritmo. 2 Manejo de memoria. 2.1 Manejo de memoria estática. 2.2 Manejo de memoria dinámica. 3 Estructuras lineales estática y dinámicas. 3.1 Pilas. 3.2 Colas. 3.3 Listas enlazadas. 3.3.1 Simples. 3.3.2 Dobles. 4 Recursividad. 4.1 4.2 4.3 4.4 5 Estructuras no lineales estáticas y dinámicas. 5.1 Concepto de árbol. 5.1.1 Clasificación de árboles. 5.2 Operaciones Básicas sobre árboles binarios. 5.2.1 Creación. 5.2.2 Inserción. 5.2.3 Eliminación. 5.2.4 Recorridos sistemáticos. 5.2.5 Balanceo. 6 Ordenación interna. 6.1 Algoritmos de Ordenamiento por Intercambio. 6.1.1 Burbuja. 6.1.2 Quicksort. 6.1.3 ShellSort. 6.2 Algoritmos de ordenamiento por Distribución. Definición. Procedimientos recursivos. Mecánica de recursión. Transformación de algoritmos recursivos a iterativos. 4.5 Recursividad en el diseño. 4.6 Complejidad de los algoritmos recursivos. 6.2.1 Radix. 7 Ordenación externa. 7.1 Algoritmos de ordenación externa. 7.1.1 Intercalación directa. 7.1.2 Mezcla natural. 8 Métodos de búsqueda. 8.1 Algoritmos de ordenación externa. 8.1.1 Secuencial. 8.1.2 Binaria. 8.1.3 Hash. 8.2 Búsqueda externa. 8.2.1 Secuencial. 8.2.2 Binaria. 8.2.3 Hash. Unidad 1. Análisis de algoritmos. Introducción La resolución práctica de un problema exige por una parte un algoritmo o método de resolución y por otra un programa o codificación de aquel en un ordenador real. Ambos componentes tienen su importancia; pero la del algoritmo es absolutamente esencial, mientras que la codificación puede muchas veces pasar a nivel de anécdota. A efectos prácticos o ingenieriles, nos deben preocupar los recursos físicos necesarios para que un programa se ejecute. Aunque puede haber muchos parametros, los mas usuales son el tiempo de ejecución y la cantidad de memoria (espacio). Ocurre con frecuencia que ambos parametros están fijados por otras razones y se plantea la pregunta inversa: ¿cual es el tamano del mayor problema que puedo resolver en T segundos y/o con M bytes de memoria? En lo que sigue nos centraremos casi siempre en el parametro tiempo de ejecución, si bien las ideas desarrolladas son fácilmente aplicables a otro tipo de recursos. Para cada problema determinaremos un medida N de su tamaño (por número de datos) e intentaremos hallar respuestas en función de dicho N. El concepto exacto que mide N depende de la naturaleza del problema. Así, para un vector se suele utizar como N su longitud; para una matriz, el número de elementos que la componen; para un grafo, puede ser el número de nodos (a veces es mas importante considerar el número de arcos, dependiendo del tipo de problema a resolver); en un fichero se suele usar el número de registros, etc. Es imposible dar una regla general, pues cada problema tiene su propia lógica de coste. 1.1 Concepto de Complejidad de algoritmos. La complejidad nos sirve para ver cuanto cuesta la ejecución de un programa. Asintotas Por una parte necesitamos analizar la potencia de los algoritmos independientemente de la potencia de la máquina que los ejecute e incluso de la habilidad del programador que los codifique. Por otra, este análisis nos interesa especialmente cuando el algoritmo se aplica a problema grandes. Casi siempre los problemas pequeños se pueden resolver de cualquier forma, apareciendo las limitaciones al atacar problemas grandes. No debe olvidarse que cualquier técnica de ingeniería, si funciona, acaba aplicándose al problema más grande que sea posible: las tecnologias de éxito, antes o después, acaban llevándose al límite de sus posibilidades. Las consideraciones anteriores nos llevan a estudiar el comportamiento de un algoritmo cuando se fuerza el tamaño del problema al que se aplica. Matemáticamente hablando, cuando N tiende a infinito. Es decir, su comportamiento asintótico. Sean "g(n)" diferentes funciones que determinan el uso de recursos. Habra funciones "g" de todos los colores. Lo que vamos a intentar es identificar "familias" de funciones, usando como criterio de agrupación su comportamiento asintótico. A un conjunto de funciones que comparten un mismo comportamiento asintótico le denominaremos un órden de complejidad'. Habitualmente estos conjuntos se denominan O, existiendo una infinidad de ellos. Para cada uno de estos conjuntos se suele identificar un miembro f(n) que se utiliza como representante de la clase, hablándose del conjunto de funciones "g" que son del orden de "f(n)", denotándose como g IN O(f(n)) Con frecuencia nos encontraremos con que no es necesario conocer el comportamiento exacto, sino que basta conocer una cota superior, es decir, alguna función que se comporte "aún peor". La definición matemática de estos conjuntos debe ser muy cuidadosa para involucrar ambos aspectos: identificación de una familia y posible utilización como cota superior de otras funciones menos malas: Dícese que el conjunto O(f(n)) es el de las funciones de orden de f(n), que se define como O(f(n))= {g: INTEGER -> REAL+ tales que existen las constantes k y N0 tales que para todo N > N0, g(N) <= k*f(N) } en palabras, O(f(n)) esta formado por aquellas funciones g(n) que crecen a un ritmo menor o igual que el de f(n). De las funciones "g" que forman este conjunto O(f(n)) se dice que "están dominadas asintóticamente" por "f", en el sentido de que para N suficientemente grande, y salvo una constante multiplicativa "k", f(n) es una cota superior de g(n). Reglas Prácticas Aunque no existe una receta que siempre funcione para calcular la complejidad de un algoritmo, si es posible tratar sistematicamente una gran cantidad de ellos, basandonos en que suelen estar bien estructurados y siguen pautas uniformes. Loa algoritmos bien estructurados combinan las sentencias de alguna de las formas siguientes 1. 2. 3. 4. 5. sentencias sencillas secuencia (;) decisión (if) bucles llamadas a procedimientos Sentencias sencillas Nos referimos a las sentencias de asignación, entrada/salida, etc. siempre y cuando no trabajen sobre variables estructuradas cuyo tamaño este relacionado con el tamaño N del problema. La inmensa mayoría de las sentencias de un algoritmo requieren un tiempo constante de ejecución, siendo su complejidad O(1). Secuencia (;) La complejidad de una serie de elementos de un programa es del orden de la suma de las complejidades individuales, aplicándose las operaciones arriba expuestas. Decisión (if) La condición suele ser de O(1), complejidad a sumar con la peor posible, bien en la rama THEN, o bien en la rama ELSE. En decisiones multiples (ELSE IF, SWITCH CASE), se tomara la peor de las ramas. Bucles En los bucles con contador explícito, podemos distinguir dos casos, que el tamaño N forme parte de los límites o que no. Si el bucle se realiza un número fijo de veces, independiente de N, entonces la repetición sólo introduce una constante multiplicativa que puede absorberse. Ej.- for (int i= 0; i < K; i++) { algo_de_O(1) } => K*O(1) = O(1) Si el tamaño N aparece como límite de iteraciones ... Ej.- for (int i= 0; i < N; i++) { algo_de_O(1) } Ej.- for (int i= 0; i < N; i++) { for (int j= 0; j < N; j++) { algo_de_O(1) } } tendremos N * N * O(1) = O(n2) Ej.- for (int i= 0; i < N; i++) { for (int j= 0; j < i; j++) { algo_de_O(1) } } => N * O(1) = O(n) el bucle exterior se realiza N veces, mientras que el interior se realiza 1, 2, 3, ... N veces respectivamente. En total, 1 + 2 + 3 + ... + N = N*(1+N)/2 -> O(n2) A veces aparecen bucles multiplicativos, donde la evolución de la variable de control no es lineal (como en los casos anteriores) Ej.- c= 1; while (c < N) { algo_de_O(1) c= 2*c; } El valor incial de "c" es 1, siendo "2k" al cabo de "k" iteraciones. El número de iteraciones es tal que 2k >= N => k= eis (log2 (N)) [el entero inmediato superior] y, por tanto, la complejidad del bucle es O(log n). Ej.- c= N; while (c > 1) { algo_de_O(1) c= c / 2; } Un razonamiento análogo nos lleva a log2(N) iteraciones y, por tanto, a un orden O(log n) de complejidad. Ej.- for (int i= 0; i < N; i++) { c= i; while (c > 0) { algo_de_O(1) c= c/2; } } tenemos un bucle interno de orden O(log n) que se ejecuta N veces, luego el conjunto es de orden O(n log n) Llamadas a procedimientos La complejidad de llamar a un procedimiento viene dada por la complejidad del contenido del procedimiento en sí. El coste de llamar no es sino una constante que podemos obviar inmediatamente dentro de nuestros análisis asintóticos. El cálculo de la complejidad asociada a un procedimiento puede complicarse notáblemente si se trata de procedimientos recursivos. Es fácil que tengamos que aplicar técnicas propias de la matemática discreta, tema que queda fuera de los límites de esta nota técnica. Ejemplo: evaluación de un polinomio 1.2 Aritmética de la notación O. Órdenes de Complejidad Se dice que O(f(n)) define un "orden de complejidad". Escogeremos como representante de este orden a la función f(n) más sencilla del mismo. Así tendremos O(1) orden constante O(log n) orden logarítmico O(n) orden lineal O(n log n) O(n2) orden cuadrático O(na) orden polinomial (a > 2) O(an) orden exponencial (a > 2) O(n!) orden factorial Es más, se puede identificar una jerarquía de órdenes de complejidad que coincide con el orden de la tabla anterior; jerarquía en el sentido de que cada orden de complejidad superior tiene a los inferiores como subconjuntos. Si un algoritmo A se puede demostrar de un cierto orden O 1, es cierto que tambien pertenece a todos los órdenes superiores (la relación de orden çota superior de' es transitiva); pero en la práctica lo útil es encontrar la "menor cota superior", es decir el menor orden de complejidad que lo cubra. Impacto Práctico Para captar la importancia relativa de los órdenes de complejidad conviene echar algunas cuentas. Sea un problema que sabemos resolver con algoritmos de diferentes complejidades. Para compararlos entre si, supongamos que todos ellos requieren 1 hora de ordenador para resolver un problema de tamaño N=100. ¿Qué ocurre si disponemos del doble de tiempo? Notese que esto es lo mismo que disponer del mismo tiempo en un odenador el doble de potente, y que el ritmo actual de progreso del hardware es exactamente ese: "duplicación anual del número de instrucciones por segundo". ¿Qué ocurre si queremos resolver un problema de tamaño 2n? O(f(n)) N=100 t=2h N=200 log n 1 h n 10000 1.15 h 1h 200 2h n log n 1 h 199 2.30 h n2 1h 141 4h n3 1h 126 8h 2n 1h 101 1030 h Los algoritmos de complejidad O(n) y O(n log n) son los que muestran un comportamiento más "natural": prácticamente a doble de tiempo, doble de datos procesables. Los algoritmos de complejidad logarítmica son un descubrimiento fenomenal, pues en el doble de tiempo permiten atacar problemas notablemente mayores, y para resolver un problema el doble de grande sólo hace falta un poco más de tiempo (ni mucho menos el doble). Los algoritmos de tipo polinómico no son una maravilla, y se enfrentan con dificultad a problemas de tamaño creciente. La práctica viene a decirnos que son el límite de lo "tratable". Sobre la tratabilidad de los algoritmos de complejidad polinómica habria mucho que hablar, y a veces semejante calificativo es puro eufemismo. Mientras complejidades del orden O(n2) y O(n3) suelen ser efectivamente abordables, prácticamente nadie acepta algoritmos de orden O(n100), por muy polinómicos que sean. La frontera es imprecisa. Cualquier algoritmo por encima de una complejidad polinómica se dice "intratable" y sólo será aplicable a problemas ridiculamente pequeños. A la vista de lo anterior se comprende que los programadores busquen algoritmos de complejidad lineal. Es un golpe de suerte encontrar algo de complejidad logarítmica. Si se encuentran soluciones polinomiales, se puede vivir con ellas; pero ante soluciones de complejidad exponencial, más vale seguir buscando. No obstante lo anterior ... ... si un programa se va a ejecutar muy pocas veces, los costes de codificación y depuración son los que más importan, relegando la complejidad a un papel secundario. ... si a un programa se le prevé larga vida, hay que pensar que le tocará mantenerlo a otra persona y, por tanto, conviene tener en cuenta su legibilidad, incluso a costa de la complejidad de los algoritmos empleados. ... si podemos garantizar que un programa sólo va a trabajar sobre datos pequeños (valores bajos de N), el orden de complejidad del algoritmo que usemos suele ser irrelevante, pudiendo llegar a ser incluso contraproducente. Por ejemplo, si disponemos de dos algoritmos para el mismo problema, con tiempos de ejecución respectivos: algoritmo tiempo complejidad f 100 n O(n) g n2 O(n2) asintóticamente, "f" es mejor algoritmo que "g"; pero esto es cierto a partir de N > 100. Si nuestro problema no va a tratar jamás problemas de tamaño mayor que 100, es mejor solución usar el algoritmo "g". El ejemplo anterior muestra que las constantes que aparecen en las fórmulas para T(n), y que desaparecen al calcular las funciones de complejidad, pueden ser decisivas desde el punto de vista de ingeniería. Pueden darse incluso ejemplos más dramaticos: algoritmo tiempo complejidad f n O(n) g 100 n O(n) aún siendo dos algoritmos con idéntico comportamiento asintótico, es obvio que el algoritmo "f" es siempre 100 veces más rápido que el "g" y candidato primero a ser utilizado. ... usualmente un programa de baja complejidad en cuanto a tiempo de ejecución, suele conllevar un alto consumo de memoria; y viceversa. A veces hay que sopesar ambos factores, quedándonos en algún punto de compromiso. ... en problemas de cálculo numérico hay que tener en cuenta más factores que su complejidad pura y dura, o incluso que su tiempo de ejecución: queda por considerar la precisión del cálculo, el máximo error introducido en cálculos intermedios, la estabilidad del algoritmo, etc. etc. Propiedades de los Conjuntos O(f) No entraremos en muchas profundidades, ni en demostraciones, que se pueden hallar en los libros especializados. No obstante, algo hay que saber de cómo se trabaja con los conjuntos O() para poder evaluar los algoritmos con los que nos encontremos. Para simplificar la notación, usaremos O(f) para decir O(f(n)) Las primeras reglas sólo expresan matemáticamente el concepto de jerarquía de órdenes de complejidad: A. La relación de orden definida por f < g <=> f(n) IN O(g) es reflexiva: f(n) IN O(f) y transitiva: f(n) IN O(g) y g(n) IN O(h) => f(n) IN O(h) B. f IN O(g) y g IN O(f) <=> O(f) = O(g) Las siguientes propiedades se pueden utilizar como reglas para el cálculo de órdenes de complejidad. Toda la maquinaria matemática para el cálculo de límites se puede aplicar directamente: C. Lim(n->inf)f(n)/g(n) = 0 => f IN O(g) D. Lim(n->inf)f(n)/g(n) = k => f IN O(g) => g NOT_IN O(f) => O(f) es subconjunto de O(g) => => E. Lim(n->inf)f(n)/g(n)= INF => f NOT_IN O(g) => => g IN O(f) O(f) = O(g) g IN O(f) O(f) es superconjunto de O(g) Las que siguen son reglas habituales en el cálculo de límites: F. Si f, g IN O(h) => f+g IN O(h) G. Sea k una constante, f(n) IN O(g) => k*f(n) IN O(g) H. Si f IN O(h1) y g IN O(h2) => f+g IN O(h1+h2) I. Si f IN O(h1) y g IN O(h2) => f*g IN O(h1*h2) J. Sean los reales 0 < a < b => O(na) es subconjunto de O(nb) K. Sea P(n) un polinomio de grado k => P(n) IN O(nk) L. Sean los reales a, b > 1 => O(loga) = O(logb) La regla [L] nos permite olvidar la base en la que se calculan los logaritmos en expresiones de complejidad. La combinación de las reglas [K, G] es probablemente la más usada, permitiendo de un plumazo olvidar todos los componentes de un polinomio, menos su grado. Por último, la regla [H] es la basica para analizar el concepto de secuencia en un programa: la composición secuencial de dos trozos de programa es de orden de complejidad el de la suma de sus partes. 1.3 Complejidad. 1.3.1 Tiempo de ejecución de un algoritmo. Tiempo de Ejecución Una medida que suele ser útil conocer es el tiempo de ejecución de un programa en función de N, lo que denominaremos T(N). Esta función se puede medir físicamente (ejecutando el programa, reloj en mano), o calcularse sobre el código contando instrucciones a ejecutar y multiplicando por el tiempo requerido por cada instrucción. Así, un trozo sencillo de programa como S1; for (int i= 0; i < N; i++) S2; requiere T(N)= t1 + t2*N siendo t1 el tiempo que lleve ejecutar la serie "S1" de sentencias, y t2 el que lleve la serie "S2". Prácticamente todos los programas reales incluyen alguna sentencia condicional, haciendo que las sentencias efectivamente ejecutadas dependan de los datos concretos que se le presenten. Esto hace que mas que un valor T(N) debamos hablar de un rango de valores Tmin(N) <= T(N) <= Tmax(N) los extremos son habitualmente conocidos como "caso peor" y "caso mejor". Entre ambos se hallara algun "caso promedio" o más frecuente. Cualquier fórmula T(N) incluye referencias al parámetro N y a una serie de constantes "Ti" que dependen de factores externos al algoritmo como pueden ser la calidad del código generado por el compilador y la velocidad de ejecución de instrucciones del ordenador que lo ejecuta. Dado que es fácil cambiar de compilador y que la potencia de los ordenadores crece a un ritmo vertiginoso (en la actualidad, se duplica anualmente), intentaremos analizar los algoritmos con algun nivel de independencia de estos factores; es decir, buscaremos estimaciones generales ampliamente válidas. Complejidad en tiempo: tiempo necesario para la ejecución del algoritmo. 1.3.2 Complejidad en espacio. Complejidad en espacio: se refiere a la cantidad de memoria necesaria para la ejecución del algoritmo. 1.4 Selección de un algoritmo. Unidad 2. Manejo de memoria. 2.3 Manejo de memoria estática. 2.4 Manejo de memoria dinámica. Manejo de memoria dinámica. Sobre el tratamiento de memoria, GLib™ dispone de una serie de instrucciones que sustituyen a las ya conocidas por todos malloc, free, etc. y, siguiendo con el modo de llamar a las funciones en GLib™, las funciones que sustituyen a las ya mencionadas son g_malloc y g_free. Reserva de memoria. La función g_malloc posibilita la reserva de una zona de memoria, con un número de bytes que le pasemos como parámetro. Además, también existe una función similar llamada g_malloc0 que, no sólo reserva una zona de memoria, sino que, además, llena esa zona de memoria con ceros, lo cual nos puede beneficiar si se necesita un zona de memoria totalmente limpia. gpointer g_malloc (numero_de_bytes ); gulong numero_de_bytes ; gpointer g_malloc0 (numero_de_bytes ); gulong numero_de_bytes ; Existe otro conjunto de funciones que nos permiten reservar memoria de una forma parecida a cómo se hace en los lenguajes orientados a objetos. Esto se realiza mediante las siguientes macros definidas en GLib™ /gmem.h: /* Convenience memory allocators */ #define g_new(struct_type, n_structs) \ ((struct_type *) g_malloc (((gsize) sizeof (struct_type)) * ((gsize) (n_structs)))) #define g_new0(struct_type, n_structs) \ ((struct_type *) g_malloc0 (((gsize) sizeof (struct_type)) * ((gsize) (n_structs)))) #define g_renew(struct_type, mem, n_structs) \ ((struct_type *) g_realloc ((mem), ((gsize) sizeof (struct_type)) * ((gsize) (n_structs)))) Como se puede apreciar, no son más que macros basadas en g_malloc, g_malloc0 y g_realloc. La forma de funcionamiento de g_new y g_new0 es mediante el nombre de un tipo de datos y un número de elementos de ese tipo de datos, de forma que se puede hacer: GString *str = g_new (GString,1); GString *arr_str = g_new (GString, 5); En estas dos líneas de código, se asigna memoria para un elemento de tipo GString, que queda almacenado en la variable str, y para un array de cinco elementos de tipo GString, que queda almacenado en la variable arr_str). funciona de la misma forma que g_new, con la única diferencia de que inicializa a 0 toda la memoria asignada. En cuanto a g_renew, ésta funciona de la misma forma que g_realloc, es decir, reasigna la memoria asignada anteriormente. g_new0 Liberación de memoria. Cuando se hace una reserva de memoria con g_malloc y, en un momento dado, el uso de esa memoria no tiene sentido, es el momento de liberar esa memoria. Y el sustituto de free es g_free que, básicamente, funciona igual que la anteriormente mencionada. void g_free (memoria_reservada ); gpointer memoria_reservada ; Realojamiento de memoria. En determinadas ocasiones, sobre todo cuando se utilizan estructuras de datos dinámicas, es necesario ajustar el tamaño de una zona de memoria (ya sea para hacerla más grande o más pequeña). Para eso, GLib™ ofrece la función g_realloc, que recibe un puntero a memoria que apunta a una región que es la que será acomodada al nuevo tamaño y devuelve el puntero a la nueva zona de memoria. El anterior puntero es liberado y no se debería utilizar más: gpointer g_realloc (memoria_reservada , numero_de_bytes ); gpointer memoria_reservada ; gulong numero_de_bytes ; Otras funciones de manejo de memoria dinámica void* calloc ( unsigned nbytes ); Como malloc, pero rellena de ceros la zona de memoria. char* strdup ( char* cadena ); Crea un duplicado de la cadena. Se reservan strlen(cadena)+1 bytes. Unidad 3. Estructuras lineales estática y dinámicas. 3.4 Pilas. Las pilas son otro tipo de estructura de datos lineales, las cuales presentan restricciones en cuanto a la posición en la cual pueden realizarse las inserciones y las extracciones de elementos. Una pila es una lista de elementos en la que se pueden insertar y eliminar elementos sólo por uno de los extremos. Como consecuencia, los elementos de una pila serán eliminados en orden inverso al que se insertaron. Es decir, el último elemento que se metió a la pila será el primero en salir de ella. En la vida cotidiana existen muchos ejemplos de pilas, una pila de platos en una alacena, una pila de latas en un supermercado, una pila de papeles sobre un escritorio, etc. Debido al orden en que se insertan y eliminan los elementos en una pila, también se le conoce como estructura LIFO (Last In, First Out: último en entrar, primero en salir). Representación en Memoria Las pilas no son estructuras de datos fundamentales, es decir, no están definidas como tales en los lenguajes de programación. Las pilas pueden representarse mediante el uso de : Arreglos. Listas enlazadas. Nosotros ahora usaremos los arreglos. Por lo tanto debemos definir el tamaño máximo de la pila, además de un apuntador al último elemento insertado en la pila el cual denominaremos SP. La representación gráfica de una pila es la siguiente: Como utilizamos arreglos para implementar pilas, tenemos la limitante de espacio de memoria reservada. Una vez establecido un máximo de capacidad para la pila, ya no es posible insertar más elementos. Una posible solución a este problema es el uso de espacios compartidos de memoria. Supongase que se necesitan dos pilas , cada una con un tamaño máximo de n elementos. En este caso se definirá un solo arreglo de 2*n elementos, en lugar que dos arreglos de n elementos. En este caso utilizaremos dos apuntadores: SP1 para apuntar al último elemento insertado en la pila 1 y SP2 para apuntar al último elemento insertado en la pila 2. Cada una de las pilas insertará sus elementos por los extremos opuestos, es decir, la pila 1 iniciará a partir de la localidad 1 del arreglo y la pila 2 iniciará en la localidad 2n. De este modo si la pila 1 necesita más de n espacios (hay que recordar que a cada pila se le asignaron n localidades) y la pila 2 no tiene ocupados sus n lugares, entonces se podrán seguir insertando elementos en la pila 1 sin caer en un error de desbordamiento. Notación Infija, Postfija y Prefija Las pilas son estructuras de datos muy usadas para la solución de diversos tipos de problemas. Pero tal vez el principal uso de estas estructuras es el tratamiento de expresiones matemáticas. ALGORITMO PARA CONVERTIR EXPRESIONES INFIJAS EN POSTFIJAS (RPN) 1. Incrementar la pila 2. Inicializar el conjunto de operaciones 3. Mientras no ocurra error y no sea fin de la expresión infija haz o Si el carácter es: 1. PARENTESIS IZQUIERDO. Colocarlo en la pila 2. PARENTESIS DERECHO. Extraer y desplegar los valores hasta encontrar paréntesis izquierdo. Pero NO desplegarlo. 3. UN OPERADOR. Si la pila esta vacía o el carácter tiene más alta prioridad que el elemento del tope de la pila insertar el carácter en la pila. En caso contrario extraer y desplegar el elemento del tope de la pila y repetir la comparación con el nuevo tope. 4. OPERANDO. Desplegarlo. 4. Al final de la expresión extraer y desplegar los elementos de la pila hasta que se vacíe. ALGORITMO PARA EVALUAR UNA EXPRESION RPN 1. Incrementar la pila 2. Repetir o Tomar un caracter. o Si el caracter es un operando colocarlo en la pila. o Si el caracter es un operador entonces tomar los dos valores del tope de la pila, aplicar el operador y colocar el resultado en el nuevo tope de la pila. (Se produce un error en caso de no tener los 2 valores) 3. Hasta encontrar el fin de la expresión RPN. Operaciones en Pilas Las principales operaciones que podemos realizar en una pila son: Insertar un elemento (push). Eliminar un elemento (pop). Los algoritmos para realizar cada una de estas operaciones se muestran a continuación. La variable máximo para hacer referencia al máximo número de elementos en la pila. Inserción (Push) si sp=máximo entonces mensaje (overflow) en caso contrario sp<-- sp+1 pila[sp]<-- valor Eliminación (Pop) si sp=0 entonces mensaje (underflow) en caso contrario x<--pila[sp] sp<--sp-1 3.5 Colas. Una cola es una estructura de almacenamiento, donde la podemos considerar como una lista de elementos, en la que éstos van a ser insertados por un extremo y serán extraídos por otro. Las colas son estructuras de tipo FIFO (first-in, first-out), ya que el primer elemento en entrar a la cola será el primero en salir de ella. Existen muchísimos ejemplos de colas en la vida real, como por ejemplo: personas esperando en un teléfono público, niños esperando para subir a un juego mecánico, estudiantes esperando para subir a un camión escolar, etc. Representación en Memoria Podemos representar a las colas de dos formas : Como arreglos Como listas ordenadas En esta unidad trataremos a las colas como arreglos de elementos, en donde debemos definir el tamaño de la cola y dos apuntadores, uno para accesar el primer elemento de la lista y otro que guarde el último. En lo sucesivo, al apuntador del primer elemento lo llamaremos F, al de el último elemento A y MAXIMO para definir el número máximo de elementos en la cola. Cola Lineal La cola lineal es un tipo de almacenamiento creado por el usuario que trabaja bajo la técnica FIFO (primero en entrar primero en salir). Las colas lineales se representan gráficamente de la siguiente manera: Las operaciones que podemos realizar en una cola son las de inicialización, inserción y extracción. Los algoritmos para llevar a cabo dichas operaciones se especifican más adelante. Las condiciones a considerar en el tratamiento de colas lineales son las siguientes: Overflow (cola llena), cuando se realice una inserción. Underflow(cola vacía), cuando se requiera de una extracción en la cola. Vacío ALGORITMO DE INICIALIZACIÓN F < -- 1 A <-- 0 ALGORITMO PARA INSERTAR Si A=máximo entonces mensaje (overflow) en caso contrario A<-- A+1 cola[A]<-- valor ALGORITMO PARA EXTRAER Si A&ltF entonces mensaje (underflow) en caso contrario F <-- F+1 x <-- cola[F] Cola Circular Las colas lineales tienen un grave problema, como las extracciones sólo pueden realizarse por un extremo, puede llegar un momento en que el apuntador A sea igual al máximo número de elementos en la cola, siendo que al frente de la misma existan lugares vacíos, y al insertar un nuevo elemento nos mandará un error de overflow (cola llena). Para solucionar el problema de desperdicio de memoria se implementaron las colas circulares, en las cuales existe un apuntador desde el último elemento al primero de la cola. La representación gráfica de esta estructura es la siguiente: La condición de vacío en este tipo de cola es que el apuntador F sea igual a cero. Las condiciones que debemos tener presentes al trabajar con este tipo de estructura son las siguientes: Over flow, cuando se realice una inserción. Under flow, cuando se requiera de una extracción en la cola. Vacio ALGORITMO DE INICIALIZACIÓN F < -- 0 A<-- 0 ALGORITMO PARA INSERTAR Si (F+1=A) ó (F=1 y A=máximo) entonces mensaje (overflow) en caso contrario inicio si A=máximo entonces A<--1 cola[A]<-- valor en caso contrario A <--A+1 cola[A]<-- valor si F=0 entonces F <-- 1 fin ALGORITMO PARA EXTRAER Si F=0 entonces mensaje (underflow) en caso contrario x <-- cola[F] si F=A entonces F <-- 0 A<-- 0 en caso contrario si F=máximo entonces F <--1 en caso contrario F <-- F+1 Doble Cola Esta estructura es una cola bidimensional en que las inserciones y eliminaciones se pueden realizar en cualquiera de los dos extremos de la bicola. Gráficamente representamos una bicola de la siguiente manera: Existen dos variantes de la doble cola: Doble cola de entrada restringida. Doble cola de salida restringida. La primer variante sólo acepta inserciones al final de la cola, y la segunda acepta eliminaciones sólo al frente de la cola ALGORITMOS DE ENTRADA RESTRINGIDA Algoritmo de Inicialización F < -- 1 A <-- 0 Algoritmo para Insertar Si A=máximo entonces mensaje (overflow) en caso contrario A <--A+1 cola[A]<-- valor Algoritmo para Extraer Si F&gtA entonces mensaje (underflow) en caso contrario mensaje (frente/atrás) si frente entonces x <-- cola[F] F <-- F+1 en caso contrario x <-- cola[A] A <-- A-1 ALGORITMOS DE SALIDA RESTRINGIDA Algoritmo de Inicialización F <--1 A <-- 0 Algoritmo para Insertar Si F&gtA entonces mensaje (overflow) en caso contrario mensaje (Frente/Atrás) si Frente entonces cola[F] <--valor en caso contrario A <-- A+1 cola[A] <--valor Algoritmo para Extraer Si F=0 entonces mensaje (underflow) en caso contrario x <--cola[F] F <-- F+1 Cola de Prioridades Esta estructura es un conjunto de elementos donde a cada uno de ellos se les asigna una prioridad, y la forma en que son procesados es la siguiente: 1. Un elemento de mayor prioridad es procesado al principio. 2. Dos elementos con la misma prioridad son procesados de acuerdo al orden en que fueron insertados en la cola. Algoritmo para Insertar x <--1 final<--verdadero para i desde 1 hasta n haz Si cola[i]&gtprioridad entonces x <--i final <--falso salir si final entonces x <--n+1 para i desde n+1 hasta x+1 cola[i] <--prioridad n <-- n+1 Algoritmo para Extraer Si cola[1]=0 entonces mensaje(overflow) en caso contrario procesar <--cola[1] para i desde 2 hasta n haz cola[i-1] <--cola[1] n <-- n-1 Operaciones en Colas Las operaciones que nosotros podemos realizar sobre una cola son las siguientes: Inserción. Extracción. Las inserciones en la cola se llevarán a cabo por atrás de la cola, mientras que las eliminaciones se realizarán por el frente de la cola (hay que recordar que el primero en entrar es el primero en salir). 3.6 Listas enlazadas. 3.6.1 Simples. Una lista enlazada o encadenada es una colección de elementos ó nodos, en donde cada uno contiene datos y un enlace o liga. Un nodo es una secuencia de caracteres en memoria dividida en campos (de cualquier tipo). Un nodo siempre contiene la dirección de memoria del siguiente nodo de información si este existe. Un apuntador es la dirección de memoria de un nodo La figura siguiente muestra la estructura de un nodo: El campo liga, que es de tipo puntero, es el que se usa para establecer la liga con el siguiente nodo de la lista. Si el nodo fuera el último, este campo recibe como valor NIL (vacío). A continuación se muestra el esquema de una lista : Operaciones en Listas Enlazadas Las operaciones que podemos realizar sobre una lista enlazada son las siguientes: Recorrido. Esta operación consiste en visitar cada uno de los nodos que forman la lista . Para recorrer todos los nodos de la lista, se comienza con el primero, se toma el valor del campo liga para avanzar al segundo nodo, el campo liga de este nodo nos dará la dirección del tercer nodo, y así sucesivamente. Inserción. Esta operación consiste en agregar un nuevo nodo a la lista. Para esta operación se pueden considerar tres casos: o Insertar un nodo al inicio. o Insertar un nodo antes o después de cierto nodo. o Insertar un nodo al final. Borrado. La operación de borrado consiste en quitar un nodo de la lista, redefiniendo las ligas que correspondan. Se pueden presentar cuatro casos: o Eliminar el primer nodo. o Eliminar el último nodo. o Eliminar un nodo con cierta información. o Eliminar el nodo anterior o posterior al nodo cierta con información. Búsqueda. Esta operación consiste en visitar cada uno de los nodos, tomando al campo liga como puntero al siguiente nodo a visitar. Listas Lineales En esta sección se mostrarán algunos algoritmos sobre listas lineales sin nodo de cabecera y con nodo de cabecera. Una lista con nodo de cabecera es aquella en la que el primer nodo de la lista contendrá en su campo dato algún valor que lo diferencíe de los demás nodos (como : *, -, +, etc). Un ejemplo de lista con nodo de cabecera es el siguiente: En el caso de utilizar listas con nodo de cabecera, usaremos el apuntador CAB para hacer referencia a la cabeza de la lista. Para el caso de las listas sin nodo de cabecera, se usará la expresión TOP para referenciar al primer nodo de la lista, y TOP(dato), TOP(liga) para hacer referencia al dato almacenado y a la liga al siguiente nodo respectivamente. Algoritmo de Creación top<--NIL repite new(p) leer(p(dato)) si top=NIL entonces top<--p en caso contrario q(liga)<--p p(liga)<--NIL q<--p mensaje('otro nodo?') leer(respuesta) hasta respuesta=no Algoritmo para Recorrido p<--top mientras p<>NIL haz escribe(p(dato)) p<--p(liga:) Algoritmo para insertar al final p<--top mientras p(liga)<>NIL haz p<--p(liga) new(q) p(liga)<--q q(liga)<--NIL Algoritmo para insertar antes/después de 'X' información p<--top mensaje(antes/despues) lee(respuesta) si antes entonces mientras p<>NIL haz si p(dato)='x' entonces new(q) leer(q(dato)) q(liga)<--p si p=top entonces top<--q en caso contrario r(liga)<--q p<--nil en caso contrario r<--p p<--p(link) si despues entonces p<--top mientras p<>NIL haz si p(dato)='x' entonces new(q) leer(q(dato)) q(liga)<--p(liga) p(liga)<--q p<--NIL en caso contrario p<--p(liga) p<--top mientras p(liga)<>NIL haz p<--p(liga) new(q) p(liga)<--q q(liga)<--NIL Algoritmo para borrar un nodo p<--top leer(valor_a_borrar) mientras p<>NIL haz si p(dato)=valor_a_borrar entonces si p=top entonces si p(liga)=NIL entonces top<--NIL en caso contrario top(liga)<--top(liga) en caso contrario q(liga)<--p(liga) dispose(p) p<--NIL en caso contrario q<--p p<--p(liga) Algoritmo de creación de una lista con nodo de cabecera new(cab) cab(dato)<--'*' cab(liga)<--NIL q<--cab repite new(p) leer(p(dato)) p(liga)<--NIL q<--p mensaje(otro nodo?) leer(respuesta) hasta respuesta=no Algoritmo de extracción en una lista con nodo de cabecera leer(valor_a_borrar) p<--cab q<--cab(liga) mientras q<>NIL haz si q(dato)=valor_a_borrar entonces p<--q(liga) dispose(q) q<--NIL en caso contrario p<--q q<--q(liga) 3.6.2 Dobles. Una lista doble , ó doblemente ligada es una colección de nodos en la cual cada nodo tiene dos punteros, uno de ellos apuntando a su predecesor (li) y otro a su sucesor(ld). Por medio de estos punteros se podrá avanzar o retroceder a través de la lista, según se tomen las direcciones de uno u otro puntero. La estructura de un nodo en una lista doble es la siguiente: Existen dos tipos de listas doblemente ligadas: Listas dobles lineales. En este tipo de lista doble, tanto el puntero izquierdo del primer nodo como el derecho del último nodo apuntan a NIL. Listas dobles circulares. En este tipo de lista doble, el puntero izquierdo del primer nodo apunta al último nodo de la lista, y el puntero derecho del último nodo apunta al primer nodo de la lista. Debido a que las listas dobles circulares son más eficientes, los algoritmos que en esta sección se traten serán sobre listas dobles circulares. En la figura siguiente se muestra un ejemplo de una lista doblemente ligada lineal que almacena números: En la figura siguiente se muestra un ejemplo de una lista doblemente ligada circular que almacena números: A continuación mostraremos algunos algoritmos sobre listas enlazadas. Como ya se mencionó, llamaremos li al puntero izquierdo y ld al puntero derecho, también usaremos el apuntador top para hacer referencia al primer nodo en la lista, y p para referenciar al nodo presente. Algoritmo de creación top<--NIL repite si top=NIL entonces new(p) lee(p(dato)) p(ld)<--p p(li)<--p top<--p en caso contrario new(p) lee(p(dato)) p(ld)<--top p(li)<--p p(ld(li))<--p mensaje(otro nodo?) lee (respuesta) hasta respuesta=no Algoritmo para recorrer la lista --RECORRIDO A LA DERECHA. p<--top repite escribe(p(dato)) p<--p(ld) hasta p=top --RECORRIDO A LA IZQUIERDA. p<--top repite escribe(p(dato)) p<--p(li) hasta p=top(li) Algoritmo para insertar antes de 'X' información p<--top mensaje (antes de ?) lee(x) repite si p(dato)=x entonces new(q) leer(q(dato)) si p=top entonces top<--q q(ld)<--p q(li)<--p(li) p(ld(li))<--q p(li)<--q p<--top en caso contrario p<--p(ld) hasta p=top Algoritmo para insertar despues de 'X' información p<--top mensaje(despues de ?) lee(x) repite si p(dato)=x entonces new(q) lee(q(dato)) q(ld)<--p(ld) q(li)<--p p(li(ld))<--q p(ld)<--q p<--top en caso contrario p<--p(ld) hasta p=top Algoritmo para borrar un nodo p<--top mensaje(Valor a borrar) lee(valor_a_borrar) repite si p(dato)=valor_a_borrar entonces p(ld(li))<--p(ld) p(li(ld))<--p(li) si p=top entonces si p(ld)=p(li) entonces top<--nil en caso contrario top<--top(ld) dispose(p) p<--top en caso contrario p<--p(ld) hasta p=top Unidad 4. Recursividad. INTRODUCCIÓN El área de la programación es muy amplia y con muchos detalles. Los programadores necesitan ser capaces de resolver todos los problemas que se les presente a través del computador aun cuando en el lenguaje que utilizan no haya una manera directa de resolver los problemas. En el lenguaje de programación C, así como en otros lenguajes de programación, se puede aplicar una técnica que se le dio el nombre de recursividad por su funcionalidad. Esta técnica es utilizada en la programación estructurada para resolver problemas que tengan que ver con el factorial de un número, o juegos de lógica. Las asignaciones de memoria pueden ser dinámicas o estáticas y hay diferencias entre estas dos y se pueden aplicar las dos en un programa cualquiera. 4.7 Definición. Hablamos de recursividad, tanto en el ámbito informático como en el ámbito matemático, cuando definimos algo (un tipo de objetos, una propiedad o una operación) en función de si mismo.La recursividad en programación es una herramienta sencilla, muy útil y potente. Ejemplo: La potenciación con exponentes enteros se puede definir: a=1 an = a*a(n-1) si n> El factorial de un entero positivo suele definirse: !=1 n! = n*(n-1)! si n> En programación la recursividad supone la posibilidad de permitir a un subprograma llamadas a si mismo, aunque también supone la posibilidad de definir estructuras de datos recursivas. La recursividad es una técnica de programación importante. Se utiliza para realizar una llamada a una función desde la misma función. Como ejemplo útil se puede presentar el cálculo de números factoriales. Él factorial de 0 es, por definición, 1. Los factoriales de números mayores se calculan mediante la multiplicación de 1 * 2 * ..., incrementando el número de 1 en 1 hasta llegar al número para el que se está calculando el factorial. El siguiente párrafo muestra una función, expresada con palabras, que calcula un factorial. "Si el número es menor que cero, se rechaza. Si no es un entero, se redondea al siguiente entero. Si el número es cero, su factorial es uno. Si el número es mayor que cero, se multiplica por él factorial del número menor inmediato." Para calcular el factorial de cualquier número mayor que cero hay que calcular como mínimo el factorial de otro número. La función que se utiliza es la función en la que se encuentra en estos momentos, esta función debe llamarse a sí misma para el número menor inmediato, para poder ejecutarse en el número actual. Esto es un ejemplo de recursividad. La recursividad y la iteración (ejecución en bucle) están muy relacionadas, cualquier acción que pueda realizarse con la recursividad puede realizarse con iteración y viceversa. Normalmente, un cálculo determinado se prestará a una técnica u otra, sólo necesita elegir el enfoque más natural o con el que se sienta más cómodo. Claramente, esta técnica puede constituir un modo de meterse en problemas. Es fácil crear una función recursiva que no llegue a devolver nunca un resultado definitivo y no pueda llegar a un punto de finalización. Este tipo de recursividad hace que el sistema ejecute lo que se conoce como bucle "infinito". Para entender mejor lo que en realidad es el concepto de recursión veamos un poco lo referente a la secuencia de Fibonacci. Principalmente habría que aclarar que es un ejemplo menos familiar que el del factorial, que consiste en la secuencia de enteros. 0,1,1,2,3,5,8,13,21,34,..., Cada elemento en esta secuencia es la suma de los precedentes (por ejemplo 0 + 1 = 0, 1 + 1 = 2, 1 + 2 = 3, 2 + 3 = 5, ...) sean fib(0) = 0, fib (1) = 1 y así sucesivamente, entonces puede definirse la secuencia de Fibonacci mediante la definición recursiva (define un objeto en términos de un caso mas simple de si mismo): fib (n) = n if n = = 0 or n = = 1 fib (n) = fib (n - 2) + fib (n - 1) if n >= 2 Por ejemplo, para calcular fib (6), puede aplicarse la definición de manera recursiva para obtener: Fib (6) = fib (4) + fib (5) = fib (2) + fib (3) + fib (5) = fib (0) + fib (1) + fib (3) + fib (5) = 0 + 1 fib (3) + fib (5) 1. + fib (1) + fib (2) + fib(5) = 1. + 1 + fib(0) + fib (1) + fib (5) = 2. + 0 + 1 + fib(5) = 3 + fib (3) + fib (4) = 3. + fib (1) + fib (2) + fib (4) = 3 + 1 + fib (0) + fib (1) + fib (4) = 4. + 0 + 1 + fib (2) + fib (3) = 5 + fib (0) + fib (1) + fib (3) = 5. + 0 + 1 + fib (1) + fib (2) = 6 + 1 + fib (0) + fib (1) = 6. + 0 + 1 = 8 Obsérvese que la definición recursiva de los números de Fibonacci difiere de las definiciones recursivas de la función factorial y de la multiplicación . La definición recursiva de fib se refiere dos veces a sí misma . Por ejemplo, fib (6) = fib (4) + fib (5), de tal manera que al calcular fib (6), fib tiene que aplicarse de manera recursiva dos veces. Sin embargo calcular fib (5) también implica calcular fib (4), así que al aplicar la definición hay mucha redundancia de cálculo. En ejemplo anterior, fib(3) se calcula tres veces por separado. Sería mucho mas eficiente "recordar" el valor de fib(3) la primera vez que se calcula y volver a usarlo cada vez que se necesite. Es mucho mas eficiente un método iterativo como el que sigue parar calcular fib (n). If (n < = 1) return (n); lofib = 0 ; hifib = 1 ; for (i = 2; i < = n; i ++) { x = lofib ; lofib = hifib ; hifib = x + lofib ; } /* fin del for*/ return (hifib) ; Compárese el numero de adiciones (sin incluir los incrementos de la variable índice, i) que se ejecutan para calcular fib (6) mediante este algoritmo al usar la definición recursiva. En el caso de la función factorial, tienen que ejecutarse el mismo numero de multiplicaciones para calcular n! Mediante ambos métodos: recursivo e iterativo. Lo mismo ocurre con el numero de sumas en los dos métodos al calcular la multiplicación. Sin embargo, en el caso de los números de Fibonacci, el método recursivo es mucho mas costoso que el iterativo. El concepto de recursividad va ligado al de repetición. Son recursivos aquellos algoritmos que, estando encapsulados dentro de una función, son llamados desde ella misma una y otra vez, en contraposición a los algoritmos iterativos, que hacen uso de bucles while, do-while, for, etc. Algo es recursivo si se define en términos de sí mismo (cuando para definirse hace mención a sí mismo). Para que una definición recursiva sea válida, la referencia a sí misma debe ser relativamente más sencilla que el caso considerado. Ejemplo: definición de nº natural: -> el N º 0 es natural -> El Nº n es natural si n-1 lo es. En un algoritmo recursivo distinguimos como mínimo 2 partes: a). Caso trivial, base o de fin de recursión: Es un caso donde el problema puede resolverse sin tener que hacer uso de una nueva llamada a sí mismo. Evita la continuación indefinida de las partes recursivas. b). Parte puramente recursiva: Relaciona el resultado del algoritmo con resultados de casos más simples. Se hacen nuevas llamadas a la función, pero están más próximas al caso base. EJEMPLO ITERATIVO: int Factorial( int n ) { int i, res=1; for(i=1; i<=n; i++ ) res = res*i; return(res); } RECURSIVO: int Factorial( int n ) { if(n==0) return(1); return(n*Factorial(n-1)); } TIPOS DE RECURSIÓN Recursividad simple: Aquella en cuya definición sólo aparece una llamada recursiva. Se puede transformar con facilidad en algoritmos iterativos. Recursividad múltiple: Se da cuando hay más de una llamada a sí misma dentro del cuerpo de la función, resultando más dificil de hacer de forma iterativa. int Fib( int n ) /* ej: Fibonacci */ { if(n<=1) return(1); return(Fib(n-1) + Fib(n-2)); } Recursividad anidada: En algunos de los arg. de la llamada recursiva hay una nueva llamada a sí misma. int Ack( int n, int m ) /* ej: Ackerman */ { if(n==0 ) return(m+1); else if(m==0) return(Ack(n-1,1)); return(Ack(n-1, Ack(n,m-1))); } Recursividad cruzada o indirecta: Son algoritmos donde una función provoca una llamada a sí misma de forma indirecta, a través de otras funciones. Ej: Par o Impar: int par( int nump ) { if(nump==0) return(1); return( impar(nump-1)); } int impar( int numi ) { if(numi==0) return(0); return( par(numi-1)); } LA PILA DE RECURSIÓN La memoria del ordenador se divide (de manera lógica, no física) en varios segmentos (4): Segmento de código: Parte de la memoria donde se guardan las instrucciones del programa en cod. Máquina. Segmento de datos: Parte de la memoria destinada a almacenar las variables estáticas. Montículo: Parte de la memoria destinada a las variables dinámicas. Pila del programa: Parte destinada a las variables locales y parámetros de la función que está siendo ejecutada. Llamada a una función: Se reserva espacio en la pila para los parámetros de la función y sus variables locales. Se guarda en la pila la dirección de la línea de código desde donde se ha llamado a la función. Se almacenan los parámetros de la función y sus valores en la pila. Al terminar la función, se libera la memoria asignada en la pila y se vuelve a la instruc. Actual. Llamada a una función recursiva: En el caso recursivo, cada llamada genera un nuevo ejemplar de la función con sus correspondientes objetos locales: La función se ejecutará normalmente hasta la llamada a sí misma. En ese momento se crean en la pila nuevos parámetros y variables locales. El nuevo ejemplar de función comieza a ejecutarse. Se crean más copias hasta llegar a los casos bases, donde se resuelve directamente el valor, y se va saliendo liberando memoria hasta llegar a la primera llamada (última en cerrarse) EJERCICIOS a). Torres de Hanoi: Problema de solución recursiva, consiste en mover todos los discos (de diferentes tamaños) de una aguja a otra, usando una aguja auxiliar, y sabiendo que un disco no puede estar sobre otro menor que éste. _|_ [___] [_____] | | | | | | [ ] | | ------------------------------------A B C /* Solucion: 1- Mover n-1 discos de A a B 2- Mover 1 disco de A a C 3- Mover n-1 discos de B a C */ void Hanoi( n, inicial, aux, final ) { if( n>0 ) { Hanoi(n-1, inicial, final, aux ); printf("Mover %d de %c a %c", n, inicial, final ); Hanoi(n-1, aux, inicial, final ); } } b). Calcular x elevado a n de forma recursiva: float xelevn( float base, int exp ) { if(exp == 0 ) return(1); return( base*xelevn(base,exp-1)); } c). Multiplicar 2 nºs con sumas sucesivas recurs: int multi( int a, int b ) { if(b == 0 ) return(0); return( a + multi(a, b-1)); } d). ¿Qué hace este programa?: void cosa( char *cad, int i) { if( cad[i] != '\0' ) { cosa(cad,i+1); printf("%c", cad[i] ); } } Sol: Imprime la cadena invertida. 4.8 Procedimientos recursivos. Un procedimiento recursivo es aquél que se llama a sí mismo. Por ejemplo, el siguiente procedimiento utiliza la recursividad para calcular el factorial de su argumento original: Function Factorial(ByVal N As Integer) As Integer If N <= 1 Then Return 1 Else ' Reached end of recursive calls. ' N = 0 or 1, so climb back out of calls. ' N > 1, so call Factorial again. Return Factorial(N - 1) * N End If End Function Nota Si un procedimiento Function se llama a sí mismo de manera recursiva, su nombre debe ir seguido de un paréntesis, aunque no exista una lista de argumentos. De lo contrario, se considerará que el nombre de la función representa al valor devuelto por ésta. Los programas tienen una cantidad de espacio limitado para las variables. Cada vez que un procedimiento se llama a sí mismo, se utiliza más espacio. Si este proceso continúa indefinidamente, se acaba produciendo un error de espacio de la pila. La causa puede ser menos evidente si dos procedimientos se llaman entre sí indefinidamente, o si nunca se cumple una condición que limita la recursividad. Debe asegurarse de que los procedimientos recursivos no se llamen a sí mismos indefinidamente, o tantas veces que puedan agotar la memoria. La recursividad normalmente puede sustituirse por bucles. Diseñando Procedimientos Recursivos: Ejemplo: Encontrar raíces. Queremos escribir un procedimiento para encontrar la raíz cúbica de a, es decir, queremos un procedimiento que calcule f(a)=y , tal que a = y³ . Método de bisección: Suponiendo que queremos encontrar la raíz cúbica de a, y tenemos dos valores x0 y x1, tal que x03 <= a <= x13. Partimos el intervalo (x0, x1) en dos definiendo xm= (x0 + x1)/2, vemos enseguida xm3 habiendo tres posibilidades. 1. x0 = x1, hemos encontrado el resultado. 2. xm3 >a, entonces la raíz se encuentra en el intervalo (x0, xm) 3. xm < a, entonces la raíz se encuentra en el intervalo (xm, x1) y así se repite hasta “encajonar” la raíz. Necesitamos construir primero un procedimiento auxiliar que calcule x3: (define cube (lambda (x) (* x (* x x))) El procedimiento para calcular raíces cúbicas por el método de bisección debe entonces ser capaz de tomar una desición respecto a que acción seguir si el valor es mayor o menor a los límites de la bisección: (define cube_root_solve (lambda (a x0 x1) (if (= x0 x1) x0 (if (> (cube (/ (+ x0 x1) 2.0)) a) (cube_root_solve a x0 (/ (+ x0 x1) 2.0)) (cube_root_solve a (/ (+ x0 x1) 2.0) x1))))) Enseguida necesitamos introducir una precisión, esto para indicar al programa cuando debe de terminar la ejecución, puesto que en ocasiones no es posible llegar al valor esperado y entonces es necesario conformarse con una aproximación, que es precisamente la que dicta el valor de epsilon en el siguiente caso: (define epsilon 0.005) (define cube_root (lambda (a) (cube_root_solve a x0 x1))) (define cube_root_solve (lambda (a x0 x1) (if ( < (- x1 x0) epsilon) (/ (+ (x0 x1) 2.0) (if (> ( cube (/ (+ x1 x0) 2.0)) a) (cube-root-solve a x0 (/ (+ x0 x1) 2.0)) (cube-root-solve a (/ (+ x0 x1) 2.0) x1))))) Y una vez que se llega al valor de x0 ó se rebase el valor de epsilon, el programa tendrá un resultado y culminará su ejecución. 4.9 Mecánica de recursión. 4.10 Transformación de algoritmos recursivos a iterativos. 4.11 Recursividad en el diseño. 4.12 Complejidad de los algoritmos recursivos. El análisis de algoritmos recursivos requiere utilizar técnicas especiales. La técnica más adecuada consiste simplemente en utilizar ciertas fórmulas conocidas, las cuales son válidas para la mayoría de funciones recursivas. La primera fórmula se aplica a funciones recursivas en las que el tamaño de los datos decrece de forma aritmética: La segunda fórmula se aplica a funciones recursivas en las que el tamaño de los datos decrece de forma geométrica: donde: a = nº de veces que se activa la función recursiva en cada llamada. a=1 en la función factorial; a=2 en fibonacci. c = constante que determina la velocidad con que disminuyen los datos (decremento de una progresión aritmética en el primer caso, razón de una progresión geométrica en el segundo caso). La demostración de las fórmulas anteriores puede encontrarse en la bibliografía. Propiedades de las definiciones o algoritmos recursivos: Un requisito importante para que sea correcto un algoritmo recursivo es que no genere una secuencia infinita de llamadas así mismo. Claro que cualquier algoritmo que genere tal secuencia no termina nunca. Una función recursiva f debe definirse en términos que no impliquen a f al menos en un argumento o grupo de argumentos. Debe existir una "salida" de la secuencia de llamadas recursivas. Si en esta salida no puede calcularse ninguna función recursiva. Cualquier caso de definición recursiva o invocación de un algoritmo recursivo tiene que reducirse a la larga a alguna manipulación de uno o casos mas simples no recursivos. Unidad 5. Estructuras no lineales estáticas y dinámicas. 5.3 Concepto de árbol. A los arboles ordenados de grado dos se les conoce como arboles binarios ya que cada nodo del árbol no tendrá más de dos descendientes directos. Las aplicaciones de los arboles binarios son muy variadas ya que se les puede utilizar para representar una estructura en la cual es posible tomar decisiones con dos opciones en distintos puntos. La representación gráfica de un árbol binario es la siguiente: Hay dos formas tradicionales de representar un árbol binario en memoria: Por medio de datos tipo punteros también conocidos como variables dinámicas o listas. Por medio de arreglos. Sin embargo la más utilizada es la primera, puesto que es la más natural para tratar este tipo de estructuras. Los nodos del árbol binario serán representados como registros que contendrán como mínimo tres campos. En un campo se almacenará la información del nodo. Los dos restantes se utilizarán para apuntar al subarbol izquierdo y derecho del subarbol en cuestión. Cada nodo se representa gráficamente de la siguiente manera: 5.3.1 Clasificación de árboles. Existen cuatro tipos de árbol binario:. A. B. Distinto. A. B. Similares. A. B. Equivalentes. A. B. Completos. A continuación se hará una breve descripción de los diferentes tipos de árbol binario así como un ejemplo de cada uno de ellos. A. B. DISTINTO Se dice que dos árboles binarios son distintos cuando sus estructuras son diferentes. Ejemplo: A. B. SIMILARES Dos arboles binarios son similares cuando sus estructuras son idénticas, pero la información que contienen sus nodos es diferente. Ejemplo: A. B. EQUIVALENTES Son aquellos arboles que son similares y que además los nodos contienen la misma información. Ejemplo: A. B. COMPLETOS Son aquellos arboles en los que todos sus nodos excepto los del ultimo nivel, tiene dos hijos; el subarbol izquierdo y el subarbol derecho. Arboles Enhebrados Existe un tipo especial de árbol binario llamado enhebrado, el cual contiene hebras que pueden estar a la derecha o a la izquierda. El siguiente ejemplo es un árbol binario enhebrado a la derecha. ARBOL ENHEBRADO A LA DERECHA. Este tipo de árbol tiene un apuntador a la derecha que apunta a un nodo antecesor. ARBOL ENHEBRADO A LA IZQUIERDA. Estos arboles tienen un apuntador a la izquierda que apunta al nodo antecesor en orden. 5.4 Operaciones Básicas sobre árboles binarios. 5.4.1 Creación. El algoritmo de creación de un árbol binario es el siguiente: Procedimiento crear(q:nodo) inicio mensaje("Rama izquierda?") lee(respuesta) si respuesta = "si" entonces new(p) q(li) <-- nil crear(p) en caso contrario q(li) <-- nil mensaje("Rama derecha?") lee(respuesta) si respuesta="si" entonces new(p) q(ld)<--p crear(p) en caso contrario q(ld) <--nil fin INICIO new(p) raiz<--p crear(p) FIN Implementaciones del Árbol binario Al igual que ocurre en el caso de las listas, podemos implementar un árbol binario mediante estructuras estáticas o mediante estructuras dinámicas. En ambos casos, cada nodo del árbol contendrá tres valores: • La información de un tipobase dado contenida en el nodo. • Un enlace al hijo derecho (raíz del subárbol derecho) • Un enlace al hijo izquierdo (raíz del subárbol izquierdo) Gráficamente: 5.4.2 Inserción. INSERCION EN UN ARBOL DE BUSQUEDA BINARIA El siguiente algoritmo realiza una búsqueda en un árbol de búsqueda binaria e inserta un nuevo registro si la búsqueda resulta infructuosa. (Suponemos la existencia de una función maketree que construye un árbol binario consistente en un solo nodo cuyo campo de información se transfiere como argumento y da como resultado un apuntador al árbol. Sin embargo, en nuestra versión particular, suponemos que maketree acepta dos argumentos, un registro y una llave). q = null; p = tree; while (p != null ) { if (key == k(p) ) return(p) q = p; if (key k(p) ) p = left(p); else p = right(p); } /* fin del while */ v = maketree(rec, key); if (q == null) tree = v; else if (key k(q) ) left(q) = v; else right(q) = v; return(v); Inserción de un elemento La operación de inserción de un nuevo nodo en un árbol binario de búsqueda consta de tres fases básicas: 1. Creación del nuevo nodo 2. Búsqueda de su posición correspondiente en el árbol. Se trata de encontrar la posición que le corresponde para que el árbol resultante siga siendo de búsqueda. 3. Inserción en la posición encontrado. Se modifican de modo adecuado los enlaces de la estructura. La creación de un nuevo nodo supone simplemente reservar espacio para el registro asociado y rellenar sus tres campos. Dado que no nos hemos impuesto la restricción de que el árbol resultante sea equilibrado, consideraremos que la posición adecuada para insertar el nuevo nodo es la hoja en la cual se mantiene el orden del árbol. Insertar el nodo en una hoja supone una operación mucho menos complicada que tener que insertarlo como un nodo interior y modificar la posición de uno o varios subárboles completos. La inserción del nuevo nodo como una hoja supone simplemente modificar uno de los enlaces del nodo que será su padre. Veamos con un ejemplo la evolución de un árbol conforme vamos insertando nodos siguiendo el criterio anterior respecto a la posición adecuada. 5.4.3 Eliminación. Eliminación de un elemento La eliminación de un nodo de un árbol binario de búsqueda es más complicada que la inserción, puesto que puede suponer la recolocación de varios de sus nodos. En líneas generales un posible esquema para abordar esta operación es el siguiente: 1. Buscar el nodo que se desea borrar manteniendo un puntero a su padre. 2. Si se encuentra el nodo hay que contemplar tres casos posibles: a. Si el nodo a borrar no tiene hijos, simplemente se libera el espacio que ocupa b. Si el nodo a borrar tiene un solo hijo, se añade como hijo de su padre, sustituyendo la posición ocupada por el nodo borrado. c. Si el nodo a borrar tiene los dos hijos se siguen los siguientes pasos: i. Se busca el máximo de la rama izquierda o el mínimo de la rama derecha. ii. Se sustituye el nodo a borrar por el nodo encontrado. Veamos gráficamente varios ejemplos de eliminación de un nodo: ELIMINACION EN UN ARBOL DE BUSQUEDA BINARIA Presentemos ahora un algoritmo para eliminar un nodo con llave key de un árbol de búsqueda binaria. Hay tres casos a considerar. Si el nodo a ser eliminado no tiene hijos, puede eliminarse sin ajustes posteriores al árbol, si el nodo a eliminado tiene sólo un subárbol, su único hijo puede moverse hacia arriba y ocupar su lugar. Sin embargo, si el nodo p a ser eliminado tiene dos subárboles, su sucesor en orden s (o predecesor) debe tomar su lugar. El sucesor en orden no puede tener un subárbol izquierdo (dado que un descendiente izquierdo sería el sucesor en orden de p). Así el hijo derecho de s puede moverse hacia arriba para ocupar el lugar s. En el siguiente algoritmo, sino existe nodo con llave key en el árbol, el árbol se deje intacto. p = tree; q = null; /* buscar el nodo con la llave key, apuntar dicho nodo */ /* con p y señalar a su padre con q, si existe*/ while (p != null && key != k(p) ) q = p; p = (key k(p) ) ? left(p) : right(p); P = (key k(p)) ? left(p) : ringht(p); } /* fin del while */ if (p == null ) /* la llave no está en el árbol dejar el árbol sin modificar */ return; /* asignar a la variable rp el nodo que reemplazará a nodo (p) */ /*los primeros dos casos: el nodo que se eliminará,*/ /*a lo más tener un hijo*/ if (left (p) == null) rp = right(p); else if (rght(p) == null) rp = left(p); else { /* Tercer caso: node (p) tiene dos hijos. Asignar a rp*/ /* al sucesor inorden de p y a f el padre de rp */ f = p; rp = right(p); s = left(rp); /* s es siempre el hijo izquierdo de rp */ while (s != null) { f = rp; rp = s; s = left(rp); } /* fin del while */ /* En este punto rp es el sucesor inorden de p */ if (f != p) { /* p no es el padre de rp y rp = = left(f) */ left(f) = right(rp); /* eliminar node(rp) de su posición actual y reemplazarlo */ /* con el hijo derecho del node(rp), node(rp) toma el lugar */ /* de node(p) */ ringht(rp) = right(p); } /* fin de if */ /*asignar al hijo izquierdo de node(rp) un valor tal */ /* que node(rp) tome el lugar de node(p) left(rp) = left(p); } /* fin de if */ /* insertar node(rp) en la posición antes /* ocupada por node(p) */ */ */ if (q = = null) /* node (p) era la raíz del árbol */ tree = rp; else (p = = left(q) ) ? left(q) = rp : right(q) = rp; freenode(p); return; 5.4.4 Recorridos sistemáticos. Recorrido de un Árbol binario Recorrer un árbol consiste en acceder una sola vez a todos sus nodos. Esta operación es básica en el tratamiento de árboles y nos permite, por ejemplo, imprimir toda la información almacenada en el árbol, o bien eliminar toda esta información o, si tenemos un árbol con tipo base numérico, sumar todos los valores... En el caso de los árboles binarios, el recorrido de sus distintos nodos se debe realizar en tres pasos: • acceder a la información de un nodo dado, • acceder a la información del subárbol izquierdo de dicho nodo, • acceder a la información del subárbol derecho de dicho nodo. Imponiendo la restricción de que el subárbol izquierdo se recorre siempre antes que el derecho, esta forma de proceder da lugar a tres tipos de recorrido, que se diferencian por el orden en el que se realizan estos tres pasos. Así distinguimos: • Preorden: primero se accede a la información del nodo, después al subárbol izquierdo y después al derecho. Inorden: primero se accede a la información del subárbol izquierdo, después se accede a la información del nodo y, por último, se accede a la información del subárbol derecho. Postorden: primero se accede a la información del subárbol izquierdo, después a la del subárbol derecho y, por último, se accede a la información del nodo. Si el nodo del que hablamos es la raíz del árbol, estaremos recorriendo todos sus nodos. Debemos darnos cuenta de que esta definición del recorrido es claramente recursiva, ya que el recorrido de un árbol se basa en el recorrido de sus subárboles izquierdo y derecho usando el mismo método. Aunque podríamos plantear una implementación iterativa de los algoritmos de recorrido, el uso de la recursión simplifica enormemente esta operación. Recorrido de un Arbol Binario Hay tres manera de recorrer un árbol : en inorden, preorden y postorden. Cada una de ellas tiene una secuencia distinta para analizar el árbol como se puede ver a continuación: 1. INORDEN o Recorrer el subarbol izquierdo en inorden. o Examinar la raíz. o Recorrer el subarbol derecho en inorden. 2. PREORDEN o Examinar la raíz. o Recorrer el subarbol izquierdo en preorden. o recorrer el subarbol derecho en preorden. 3. POSTORDEN o Recorrer el subarbol izquierdo en postorden. o Recorrer el subarbol derecho en postorden. o Examinar la raíz. A continuación se muestra un ejemplo de los diferentes recorridos en un árbol binario. Inorden: GDBHEIACJKF Preorden: ABDGEHICFJK Postorden: GDHIEBKJFCA RECORRIDO EN UN ARBOL BINARIO Otra operación común es recorrer un árbol binario; esto es, pasar a través del árbol, enumerando cada uno de sus nodos una vez. Quizá solo se desee imprimir los contenidos de cada nodo al enumerarlos, o procesar los nodos en otra forma. En cualquier caso, se habla de visitar cada nodo al enumerar éste. El orden en que se visitan los nodos de una lista lineal es, de manera clara, del primero al último. Sin embargo, no hay tal orden lineal "natural" para los nodos de un árbol. Así, se usan diferentes ordenamientos para el recorrido en diferentes casos. Enseguida se definen tres de estos métodos de recorrido. En cada uno de ellos, no hay que hacer nada para recorrer un árbol binario vacío. Todos los métodos se definen en forma recursiva, de manera que el recorrido de un árbol binario implica la visita de la raíz y el recorrido de sus subárboles izquierdo y derecho. La única diferencia entre los métodos es el orden en que se ejecutan esas tres operaciones. Para recorrer un árbol binario lleno en preorden (conocido también como orden con prioridad a la profundidad o depth-first order), se ejecuta de la siguientes tres operaciones: 1. Visitar la raíz. 2. Recorrer el subárbol izquierdo en preorden. 3. Recorrer el subárbol derecho en preorden. Para recorrer un árbol binario lleno en orden (u orden simétrico) 1. Recorrer el subárbol izquierdo en orden. 2. Visitar la raíz. 3. Recorrer el subárbol derecho en orden. Para recorrer un árbol binario lleno en postorden: 1. Recorrer el subárbol izquierdo en postorden. 2. Recorrer el subárbol derecho en postorden. 3. Visitar la raíz. Es posible implantar el recorrido de árboles binarios en C por medio de rutinas recursivas que reflejen las definiciones de recorrido. Las tres rutinas en C: pretav, intrav y posttrav, imprimen los contenidos de un árbol binario en preorden, orden y postorden, respectivamente. El parámetro de cada rutina es un apuntador al nodo raíz de un árbol binario. Se usa la representación dinámica de los nodos para un árbol binario: Pretav(tree) NODEPTR tree; { if (tree != NULL) { printf("%d/n", tree - >info); pretrav(tree - > left); subárbol izquierdo */ pretrav(tree - > right); derecho */ /* visitando la raíz */ /* recorriendo el /* recorriendo el subárbol } } /* /* fin del if */ fin de petrav*/ intrav(tree) NODEPTR tree; { if (tree != NULL) { intrav(tree - > left); /* recorriendo el subárbol izquierdo */ printf("%d/n", tree - >info); /* visitando la raíz */ intrav(tree - > right); /* recorriendo el subárbol derecho */ } /* fin del if */ } /* fin de intrav*/ posttrav(tree) NODEPTR tree; { if (tree != NULL) { posttrav(tree - > left); /* recorriendo el subárbol izquierdo */ posttrav(tree - > right); /* recorriendo el subárbol derecho */ printf("%d/n", tree - >info); /* visitando la raíz */ } /* fin del if */ } /* fin de posttrav*/ Por supuesto, las rutinas pueden escribirse de manera no recursiva para ejecutar la inserción o eliminación necesarias de manera explícita. Por ejemplo, la siguiente rutina no recursiva para recorrer un árbol binario en orden: #define MAXSTACK 100 intrav2(tree) NODEPTR tree; { struct stack { int top; NODEPTR item[ MAXSTACK ]; } s; NODEPTR p; s.top = -1; p = tree; do { /* descender por las ramas izquierdas tanto como sea posible, */ /* en el camino guardando los apuntadores a los nodos */ while (p != NULL ) { push (s, p); p = p - > left; } /* fin del while */ /* verificar si se terminó */ if (!empty(s) ) { /* en este punto el subárbol izquierdo está vacío */ p = ( pop(s) ); printf("%d/n", p - >info); /* visitando la raíz */ p = p - >right; /* recorriendo el subárbol derecho */ } } /* fin del if */ } while (!empty(s) && p != NULL ); /* fin del intrav2 */ 5.4.5 Balanceo. ARBOLES BALANCEADOS Definamos primero de manera más precisa la notación de un árbol "balanceado". La altura de un árbol binario es el nivel máximo de sus hojas (también se conoce a veces como la profundidad del árbol ). Por conveniencia, la altura del árbol nulo se define como –1. Un árbol binario balanceado (a veces llamado árbol AVL) Es un árbol binario en el cual las alturas de los subárboles de todo nodo difieren a los sumo en 1. El balance de un nodo en un árbol binario se define como la altura de su subárbol izquierdo menos la altura de su sibárbol derecho. Cada nodo de un árbol binario balanceado tiene balance igual a 1, -1 o 0 , dependiendo de si la altura de sus sibárbol izquierdo es mayor que, menor que o igual a la altura de su subárbol derecho. Para que el árbol se mantenga balanceado es necesario realizar una transformación en el mismo de manera que: 1. El recorrido en orden del árbol transformado sea el mismo que para el árbol original (es decir, que el árbol transformado siga siendo un árbol de búsqueda binaria) 2. El árbol transformado esté balanceado. 3. Un algoritmo para implantar una rotación izquierda de un subárbol con raíz en p es el siguiente: q= right (p); hold = left (q); left (q) = p; Unidad 6. Ordenación interna. 6.3 Algoritmos de Ordenamiento por Intercambio. Introducción. El ordenamiento es una labor común que realizamos continuamente. ¿Pero te has preguntado qué es ordenar? ¿No? Es que es algo tan corriente en nuestras vidas que no nos detenemos a pensar en ello. Ordenar es simplemente colocar información de una manera especial basándonos en un criterio de ordenamiento. En la computación el ordenamiento de datos también cumple un rol muy importante, ya sea como un fin en sí o como parte de otros procedimientos más complejos. Se han desarrollado muchas técnicas en este ámbito, cada una con características específicas, y con ventajas y desventajas sobre las demás. Aquí voy a mostrarte algunas de las más comunes, tratando de hacerlo de una manera sencilla y comprensible. 6.3.1 Burbuja. Descripción. Este es el algoritmo más sencillo probablemente. Ideal para empezar. Consiste en ciclar repetidamente a través de la lista, comparando elementos adyacentes de dos en dos. Si un elemento es mayor que el que está en la siguiente posición se intercambian. ¿Sencillo no? Pseudocódigo en C. Tabla de variables Tipo Nombre lista Cualquiera Lista a ordenar TAM Constante entera Tamaño de la lista i Entero Contador j Entero Contador temp El mismo que los elementos de la lista Para realizar los intercambios 1. for (i=1; i<TAM; i++) 2. for j=0 ; j<TAM - 1; j++) 3. if (lista[j] > lista[j+1]) 4. temp = lista[j]; 5. lista[j] = lista[j+1]; 6. lista[j+1] = temp; Un ejemplo Uso Vamos a ver un ejemplo. Esta es nuestra lista: 4-3-5-2-1 Tenemos 5 elementos. Es decir, TAM toma el valor 5. Comenzamos comparando el primero con el segundo elemento. 4 es mayor que 3, así que intercambiamos. Ahora tenemos: 3-4-5-2-1 Ahora comparamos el segundo con el tercero: 4 es menor que 5, así que no hacemos nada. Continuamos con el tercero y el cuarto: 5 es mayor que 2. Intercambiamos y obtenemos: 3-4-2-5-1 Comparamos el cuarto y el quinto: 5 es mayor que 1. Intercambiamos nuevamente: 3-4-2-1-5 Repitiendo este proceso vamos obteniendo los siguientes resultados: 3-2-1-4-5 2-1-3-4-5 1-2-3-4-5 Optimizando. Se pueden realizar algunos cambios en este algoritmo que pueden mejorar su rendimiento. Si observas bien, te darás cuenta que en cada pasada a través de la lista un elemento va quedando en su posición final. Si no te queda claro mira el ejemplo de arriba. En la primera pasada el 5 (elemento mayor) quedó en la última posición, en la segunda el 4 (el segundo mayor elemento) quedó en la penúltima posición. Podemos evitar hacer comparaciones innecesarias si disminuimos el número de éstas en cada pasada. Tan sólo hay que cambiar el ciclo interno de esta manera: for (j=0; j<TAM - i; j++) Puede ser que los datos queden ordenados antes de completar el ciclo externo. Podemos modificar el algoritmo para que verifique si se han realizado intercambios. Si no se han hecho entonces terminamos con la ejecución, pues eso significa que los datos ya están ordenados. Te dejo como tarea que modifiques el algoritmo para hacer esto :-). Otra forma es ir guardando la última posición en que se hizo un intercambio, y en la siguiente pasada sólo comparar hasta antes de esa posición. Análisis del algoritmo. Éste es el análisis para la versión no optimizada del algoritmo: Estabilidad: Este algoritmo nunca intercambia registros con claves iguales. Por lo tanto es estable. Requerimientos de Memoria: Este algoritmo sólo requiere de una variable adicional para realizar los intercambios. Tiempo de Ejecución: El ciclo interno se ejecuta n veces para una lista de n elementos. El ciclo externo también se ejecuta n veces. Es decir, la complejidad es n * n = O(n2). El comportamiento del caso promedio depende del orden de entrada de los datos, pero es sólo un poco mejor que el del peor caso, y sigue siendo O(n2). Ventajas: Fácil implementación. No requiere memoria adicional. Desventajas: Muy lento. Realiza numerosas comparaciones. Realiza numerosos intercambios. Este algoritmo es uno de los más pobres en rendimiento. Si miras la demostración te darás cuenta de ello. No es recomendable usarlo. Tan sólo está aquí para que lo conozcas, y porque su sencillez lo hace bueno para empezar. Ya veremos otros mucho mejores. Ahora te recomiendo que hagas un programa y lo pruebes. Si tienes dudas mira el programa de ejemplo. Método de la "Burbuja" Sin duda este método es un método "clásico" por ponerle un nombre, ya que es uno de los mas básicos en lo que a ordenaciones se refiere, aunque no es el mas eficiente por su tiempo de ejecución, si es uno de los mas fáciles de programar en cualquier lenguaje de programación. Su funcionamiento se basa principalmente en comparar la primera posición del arreglo con la siguiente superior del mismo, y si es mayor (orden ascendente), se intercambian lugares y se prosigue con la siguiente posición, hasta terminar con la longitud del arreglo. Todo esto tiene su objetivo y que a través de la primera pasada o recorrido sobre el vector nos aseguramos que el valor mayor de los datos contenidos en el arreglo se coloqué en la ultima posición del mismo. Después de la primer pasada se sigue iterando hasta el grado de quedar ordenado todo el arreglo. Seguimiento Logico Analisis Tiempo: El tiempo de programación en base a la escala de la cual se hizo mención en la introducción es muy tardado, por que visita todas las posiciones del vector y compara todas contra todas lo que hace de este método un método muy lento, podemos darle un tiempo de programación de: 9 unidades de tiempo. Costo: En lo que respecta al costo, al ser un programa no muy difícil en programar el costo también es proporcional al tiempo de ejecución / programación, por lo tanto le asigno: 2 unidades de costo. Espacio: Es espacio en disco físico de disco que requiere este método es de: 1 Kb. BURBUJA Funcion principal Programa void burbuja(void) { while ((sigue) && (vueltas <= n-1)) { sigue=0; for (d=1; d <= n-vueltas; d++) //num de comp. por vuelta { if(a[d] > a[d+1]) { temp=a[d]; a[d]=a[d+1]; a[d+1]=temp; ic++; sigue = 1; } comp++; } vueltas++; } } 6.3.2 Quicksort. Esta es probablemente la técnica más rápida conocida. Fue desarrollada por C.A.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: Eliges un elemento de la lista. Puede ser cualquiera. Lo llamaremos elemento de división. Buscas la posición que le corresponde en la lista ordenada (explicado más abajo). Acomodas los elementos de la lista a cada lado del elemento de división, de manera que a un lado queden todos los menores que él y al otro los mayores (explicado más abajo también). En este momento el elemento de división separa la lista en dos sublistas (de ahí su nombre). Realizas esto de forma recursiva para cada sublista mientras éstas tengan un largo mayor que 1. Una vez terminado este proceso todos los elementos estarán ordenados. Una idea preliminar para ubicar el elemento de división en su posición final sería contar la cantidad de elementos menores y colocarlo un lugar más arriba. Pero luego habría que mover todos estos elementos a la izquierda del elemento, para que se cumpla la condición y pueda aplicarse la recursividad. Reflexionando un poco más se obtiene un procedimiento mucho más efectivo. Se utilizan dos índices: i, al que llamaremos contador por la izquierda, y j, al que llamaremos contador por la derecha. El algoritmo es éste: Recorres la lista simultáneamente con i y j: por la izquierda con i (desde el primer elemento), y por la derecha con j (desde el último elemento). Cuando lista[i] sea mayor que el elemento de división y lista[j] sea menor los intercambias. Repites esto hasta que se crucen los índices. El punto en que se cruzan los índices es la posición adecuada para colocar el elemento de división, porque sabemos que a un lado los elementos son todos menores y al otro son todos mayores (o habrían sido intercambiados). Al finalizar este procedimiento el elemento de división queda en una posición en que todos los elementos a su izquierda son menores que él, y los que están a su derecha son mayores. Pseudocódigo en C. Tabla de variables Nombre Tipo Uso lista Cualquiera Lista a ordenar inf Entero Elemento inferior de la lista sup Entero Elemento superior de la lista elem_div El mismo que los elementos de la lista El elemento divisor temp El mismo que los elementos de la lista Para realizar los intercambios i Entero Contador por la izquierda j Entero Contador por la derecha cont Entero El ciclo continua mientras cont tenga el valor 1 Nombre Procedimiento: OrdRap Parámetros: lista a ordenar (lista) índice inferior (inf) índice superior (sup) // Inicialización de variables 1. elem_div = lista[sup]; 2. i = inf - 1; 3. j = sup; 4. cont = 1; // Verificamos que no se crucen los límites 5. if (inf >= sup) 6. retornar; // Clasificamos la sublista 7. while (cont) 8. while (lista[++i] < elem_div); 9. while (lista[--j] > elem_div); 10. if (i < j) 11. temp = lista[i]; 12. lista[i] = lista[j]; 13. lista[j] = temp; 14. else 15. cont = 0; // Copiamos el elemento de división // en su posición final 16. temp = lista[i]; 17. lista[i] = lista[sup]; 18. lista[sup] = temp; // Aplicamos el procedimiento // recursivamente a cada sublista 19. OrdRap (lista, inf, i - 1); 20. OrdRap (lista, i + 1, sup); Nota: La primera llamada debería ser con la lista, cero (0) y el tamaño de la lista menos 1 como parámetros. Un ejemplo Esta vez voy a cambiar de lista ;-D 5-3-7-6-2-1-4 Comenzamos con la lista completa. El elemento divisor será el 4: 5-3-7-6-2-1-4 Comparamos con el 5 por la izquierda y el 1 por la derecha. 5-3-7-6-2-1-4 5 es mayor que cuatro y 1 es menor. Intercambiamos: 1-3-7-6-2-5-4 Avanzamos por la izquierda y la derecha: 1-3-7-6-2-5-4 3 es menor que 4: avanzamos por la izquierda. 2 es menor que 4: nos mantenemos ahí. 1-3-7-6-2-5-4 7 es mayor que 4 y 2 es menor: intercambiamos. 1-3-2-6-7-5-4 Avanzamos por ambos lados: 1-3-2-6-7-5-4 En este momento termina el ciclo principal, porque los índices se cruzaron. Ahora intercambiamos lista[i] con lista[sup] (pasos 16-18): 1-3-2-4-7-5-6 Aplicamos recursivamente a la sublista de la izquierda (índices 0 - 2). Tenemos lo siguiente: 1-3-2 1 es menor que 2: avanzamos por la izquierda. 3 es mayor: avanzamos por la derecha. Como se intercambiaron los índices termina el ciclo. Se intercambia lista[i] con lista[sup]: 1-2-3 Al llamar recursivamente para cada nueva sublista (lista[0]-lista[0] y lista[2]-lista[2]) se retorna sin hacer cambios (condición 5.).Para resumir te muestro cómo va quedando la lista: Segunda sublista: lista[4]-lista[6] 7-5-6 5-7-6 5-6-7 Para cada nueva sublista se retorna sin hacer cambios (se cruzan los índices). Finalmente, al retornar de la primera llamada se tiene el arreglo ordenado: 1-2-3-4-5-6-7 Optimizando. Sólo voy a mencionar algunas optimizaciones que pueden mejorar bastante el rendimiento de quicksort: Hacer una versión iterativa: Para ello se utiliza una pila en que se van guardando los límites superior e inferior de cada sublista. No clasificar todas las sublistas: Cuando el largo de las sublistas va disminuyendo, el proceso se va encareciendo. Para solucionarlo sólo se clasifican las listas que tengan un largo menor que n. Al terminar la clasificación se llama a otro algoritmo de ordenamiento que termine la labor. El indicado es uno que se comporte bien con listas casi ordenadas, como el ordenamiento por inserción por ejemplo. La elección de n depende de varios factores, pero un valor entre 10 y 25 es adecuado. Elección del elemento de división: Se elige desde un conjunto de tres elementos: lista[inferior], lista[mitad] y lista[superior]. El elemento elegido es el que tenga el valor medio según el criterio de comparación. Esto evita el comportamiento degenerado cuando la lista está prácticamente ordenada. Análisis del algoritmo. Estabilidad: No es estable. Requerimientos de Memoria: No requiere memoria adicional en su forma recursiva. En su forma iterativa la necesita para la pila. Tiempo de Ejecución: o Caso promedio. La complejidad para dividir una lista de n es O(n). Cada sublista genera en promedio dos sublistas más de largo n/2. Por lo tanto la complejidad se define en forma recurrente como: f(1) = 1 f(n) = n + 2 f(n/2) La forma cerrada de esta expresión es: f(n) = n log2n Es decir, la complejidad es O(n log2n). o El peor caso ocurre cuando la lista ya está ordenada, porque cada llamada genera sólo una sublista (todos los elementos son menores que el elemento de división). En este caso el rendimiento se degrada a O(n2). Con las optimizaciones mencionadas arriba puede evitarse este comportamiento. Ventajas: Muy rápido No requiere memoria adicional. Desventajas: Implementación un poco más complicada. Recursividad (utiliza muchos recursos). Mucha diferencia entre el peor y el mejor caso. La mayoría de los problemas de rendimiento se pueden solucionar con las optimizaciones mencionadas arriba (al costo de complicar mucho más la implementación). Este es un algoritmo que puedes utilizar en la vida real. Es muy eficiente. En general será la mejor opción. Intenta programarlo. Mira el código si tienes dudas. Método Quick Sort Es el método sin duda de los más rápidos, pero tiene a su vez 3 variantes de él mismo las cuales son: Pivote en la posicion Inicio Pivote en la posicion Final Pivote en la posicion diferente de Inicio – Final Este método es igual en sus tres vertientes, lo único que difiere uno de otro es en el momento de saber donde deben iniciar las comparaciones, ya que como sus respectivos nombres lo dicen son en diferentes posiciones. Según sea el que se utiliza donde se encuentre el pivote, se compara con el extremo si es mayor No hay cambio (para ordenación ascendente), en caso contrario si lo hay; y ese elemento ahora se convierte en pivote y se compara con todas las demás posiciones del vector de manera que va avanzando y dejando al final el elemento mayor. El pivote cambia según sea la posición actual en la que se encuentra si los elementos son mayores o menores, de esta manera se compara de un lado con el extremo, se regresa el pivote y se analiza el otro lado hasta su extremo. Como se indica en la fig. sig. Seguimiento Lógico Analisis Tiempo: Este método como se comentaba al inicio de la explicación es una de los mas rápidos por su análisis, aunque no se pueda decir lo mismo de su programación, por lo cual, le asignaremos: 6 unidades de tiempo de tardanza en ejecución. Costo: El costo que le asignaremos a este método es de 4 unidades de costo por que su implementación en algún lenguaje de programación es algo tedioso por que son tres variantes y las tres se tendrían que programar. Espacio: EL espacio que ocupa en disco es de: 1Kb QUICK SORT Función Principal del programa void quick sort(int top, int fin, int pos) { int stackmin[30], stackmax[30], ini; stackmin[top] = top; stackmax[top] = fin; while (top > 0) { ini = stackmin[top]; fin = stackmax[top]; top--; pos = ((fin - ini)/2) + 1; reduceint(&ini,&fin,&pos); if (ini < pos-1) { top++; stackmin[top] = ini; stackmax[top] = pos-1; } if (fin > pos+1) { top++; stackmin[top] = pos+1; stackmax[top] = fin; } } } 6.3.3 ShellSort. Método Shell El método Shell, creado por Donald Shell, de ahí su nombre, es un método mas eficiente en comparación con su antecesor el de burbuja, porque utiliza casi la mitad del tiempo requerido para realizar la ordenación de datos contenidos en un vector. El mecanismo de funcionamiento del método shell, es partir el total de elemento (n), entre dos para evaluar el vector en dos partes. Después de esta división se toma el primer elemento de la primera parte y se compara con la posición del resultado de la división inicial mas una posición, avanzando de uno en uno hasta llegar al final; si se realiza algún movimiento entre las comparaciones se vuelve a iniciar las comparaciones con saltos de la misma longitud anterior, en caso contrario se vuelve a dividir el salto actual entre dos y se sigue el mismo procedimiento, hasta que los saltos sean de una posición y es así cuando llegamos a la utilización del método de la burbuja. De ahí que es mas rápido este método por que divide el vector y no es tan rápido como se quisiera ya que recurre a la burbuja para ordenar al final, y ya que el tiempo no es una de las ventajas de la burbuja pues en la rapidez afecta al método shell, pero en la que a eficiencia se refiere si supera el shell al método de la burbuja. Seguimiento Lógico Análisis Tiempo: El tiempo de programación es un poco más elevado, y el tiempo de ejecución es menos que el anterior pero aun así es tardado. Existen dos vertientes de este método, el normal y el personalizado y podemos darle un tiempo de programación y ejecución de: 7.5 unidades de tiempo. Costo: En lo que respecta al costo, al ser un programa un poco mas elevado que el anterior el costo también es proporcional al tiempo de ejecución / programación, por lo tanto le asigno: 3.5 unidades de costo. Espacio: Es espacio en disco físico de disco que requiere este método es de: 1 Kb. METODO SHELL Función Principal del programa void shell(int inter) { int band, i, aux; int j,k; // inter = n/2; while (inter>0) { for(i = inter+1; i <= n; i++) { j=i-inter; while(j>=1) { k=j+inter; if(a[j]<=a[k]) { j=1; } else { aux=a[j]; a[j]=a[k]; a[k]=aux; ic++; } comp++; j=j-inter; } } inter=inter/2; } } 6.4 Algoritmos de ordenamiento por Distribución. Principios de distribución Cuando los datos tienen ciertas caracteristicas como por ejemplo estar dentro de determinado rango y no haber elementos repetidos, pueden aprovecharse estas caracteristicas para colocar un elemento en su lugar, por ejemplo: Origen 0 1 2 3 4 5 6 7 8 9 7 1 3 0 4 2 6 5 8 9 Destino 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 A continuación se presenta el código para cambiar los valores del Origen al Destino: for(int x=0; x<10;x++) destino[origen[x]]=origen[x]; ¿Que hacer cuando se repitan los datos? Lo que debemos hacer es incrementar la capacidad de la urna. Para lograrlo podemos hacer lo siguiente: 1.- Definir un arreglo en el que cada posición puede ser ocupada por mas de un registro (un arreglo de arreglo de registros) puede darse la situación de ser insuficiente la cantidad de registros adicionales o de existir demadiado desperdicio de memoria. 2.- Definir el tamaño de la urna variable a través del uso de estructuras como las listas simples enlazadas. Urna simple struct nodo { _______ info; struct nodo *sig; } nodo *nuevo, *ini[10], *fin[10]; int i,p; void main() { for(i=0;i<10:i++) ini[i]=fin[i]=NULL; for(i=0;i<n’i++) { nuevo=new nodo; nuevo->info=A[i]; nuevo-> sig=NULL; if(ini[A[i]]==NULL) ini=fin=nuevo; else { fin->sig=nuevo; fin=nuevo; } for(i=0,p=0; i<10;i++) { nuevo=ini[i]; while(nuevo) { A[p]=nuevo->info; p++; ini[i]=nuevo->sig; delete nuevo; nuevo=ini; } } } ¿Que hacer cuando el rango de los valores que queremos ordenar es de 100 a 999? Aplicar urnas simples tantas veces como digítos tenga el mayor de los números a ordenar. Para la ordenación se hará de la siguiente manera: En la primera pasada se tomará en consideración el digíto menos significativo (unidades), en la siguiente vuelta se tomará el siguiente digíto hasta terminar (Decenas, Centena, ...). void main() { for(cont=1; cont<=veces; cont++) { for (y=0; i<n; y++) { np=A[i]% (i*10cont); np=np/(1* 10 cont - 1 ); urnas_simples(); } } } Método de Distribución simple o Dispersión El método que ahora analizáremos, es muy sencillo en su descripción, por que lo que hace es crear categorías de números para que según su categoría se acomoden en función de su cifra mas significativa, y si en cada categoría existen mas de un elemento, se crea una lista ligada que en su parte inicial todas apuntaran a NULL, para que al ir visitando las categorías, a su vez se visitara cada nodo que contenga las lista que se irán insertando de manera ordenada, de esta manera se ordena el vector. En lo referente a su programación, tampoco es muy difícil, aunque si lo será si aun no se tiene bien clara la implementación de una lista. Seguimiento lógico Se desea ordenar el siguiente vector: Funcion principal del programa Análisis Tiempo: El tiempo de ejecución para este programa es muy bueno y rápido por lo que se le puede asignar: 4 unidades de tiempo. Costo: El costo si es un buen punto, por que se pude alzar el precio pero no por que sea difícil la programación del método si no por la deficiencia en el aprendizaje del manejo de listas, por que si tomamos en cuenta que las listas están mas que aprendidas damos en valor de 6 unidades de costo. Espacio: En cuestión de espacio se siguen manejando el mismo para todos los métodos: 1 Kb. MÉTODO DE DISTRIBUCIÓN SIMPLE O DISPERSIÓN struct nodo { int dato; struct nodo *apuntador; }; main() { nodo *x=NULL;/*apuntador tipo nodo */ clrscr(); // printf("%p\n",x);/*imprime el apuntador con direccion a null*/ x=new(nodo);/*apuntador apunta al nodo se asigna memoria*/ // printf("%p\n",x);/*imprime la direccion del nodo*/ if(x) { /*verifca si fue asignada la direccion de memoria*/ x->dato=0;/*se le asigna el valor al campo de la estructura dato*/ x->apuntador=NULL;/*asigna null al apuntador de X*/ } else cout<<"no hay memoria disponible"; //ciclo que pide 10 nodos a la memoria nodo *aux,*nuevo;//se utilizan estas variable para no perder el inicio de la lista aux=x; for(int i=1;i<=10;i++) { nuevo=new(nodo); // printf("%p\n",nuevo); if(nuevo) { nuevo->dato=i; nuevo->apuntador=NULL; aux->apuntador=nuevo; aux=nuevo; } else { cout<<"no hay memoria disponible"; i=11; }; }; aux=x; while(aux!=NULL) { cout <<aux->dato<<"\n"; aux=aux->apuntador; } getch(); return 0; } /*OTRO PROGRAMA ESTE SE LLAMA DIFUSION*/ void ordenar_por_difusion(int ent[], int a, int b, int sal[]); void mostrar_vector(int ent[],int k); void mezclar_vectores(int ent1[], int ent2[], int n1, int n2, int sal[]); int comparaciones = 0; main() { int vector_ent[20] = {6, 7, 5, 8, 4, 9, 3, 0, 2}; int n = 9; int vector_sal[20]; printf("Vector no ordenado => "); mostrar_vector(vector_ent,n); ordenar_por_difusion(vector_ent,0,n-1,vector_sal); printf("Vector ordenado => "); mostrar_vector(vector_sal,n); printf("\nEl numero de comparaciones es %d",comparaciones); } void mostrar_vector(int ent[], int k) { int i; for(i = 0; i < k; i++) printf("%4d",ent[i]); printf("\n"); } void ordenar_por_difusion(int ent[], int a, int b, int sal[]) { int m; int sal1[20], sal2[20]; /* Comprobar si el vector contiene solo un elemento */ if(a == b) sal[0] = ent[a]; /* Devuelve el único elemento */ else /* Comprobar si el vector contiene dos elementos. */ if(1 == (b - a)) { if(ent[a] <= ent[b]) /* No intercambiar los elementos. */ { sal[0] = ent[a]; sal[1] = ent[b]; } else /* Intercambiar los elementos. */ { sal[0] = ent[b]; sal[1] = ent[a]; } comparaciones++; } else { /* Dividir el vector de tres o mas elementos. */ m = a + (b - a)/2; /* Cálculo de la mitad */ ordenar_por_difusion(ent,a,m,sal1); /* Ordenar primera mitad */ ordenar_por_difusion(ent,m+1,b,sal2);/* Ordenar segunda mitad */ /* Mezclar las dos mitades. */ mezclar_vectores(sal1,sal2,1+m-a,b-m,sal); } } void mezclar_vectores(int ent1[], int ent2[], int n1, int n2, int sal[]) { int i = 0,j = 0,k = 0; while((i < n1) && (j < n2)) { /* Comprobar si el primer elemento del vector es el más pequeño */ if(ent1[i] <= ent2[j]) { sal[k] = ent1[i]; i++; /* Actualizar el índice */ } else /* El segundo elemento es más pequeño. */ { sal[k] = ent2[j]; j++; /* Actualizar el índice. */ } k++; /* Actualizar el índice de salida. */ comparaciones++; } /* Comprobar si hay elementos a la izquierda en el primer vector. */ if(i != n1) { do /* Escribir los elementos restantes de ent1 al vector de salida. */ { sal[k] = ent1[i]; i++; k++; } while(i < n1); } else /* Escribir los elementos restantes de ent2 al vector de salida. */ { do { sal[k] = ent2[j]; j++; k++; } while(j < n2); } } 6.4.1 Radix. Este método se emplea para organizar información por mas de un criterio. Ejemplo: Criterio de orden 3 1 2 Nombre Carrera Calificación Ana ISC 90 Beatriz LI 90 Anibal ISC 91 Beto LI 90 Roberto ISC 90 Anibal ISC 91 Ana ISC 90 Roberto ISC 90 Beatriz LI 90 Beto LI 90 Lo que hacemos es determinar la importancia de los criterios de ordenación y aplicar ordenación estable tantas veces como criterios se tengan, empezando por el criterio menos importante y determinando por el criterio más importante. Método Radix El método Radix Sort, no varia con respecto a su antecesor el método de Distribución simple o dispersión, lo que varia en este caso es que para el acomodo de datos, se utiliza la cifra menos significativa y después vuelve a caer a la distribución simple, tomando la cifra mas significativa por que en la primera pasada aun no esta ordenado el vector. De nueva cuenta se visitan todas las categorías de números y si existe una lista de igual manera se recorre, previamente que los datos insertados en esa lista este ya ordenados. Y se despliegan los datos y quedan de forma ordenada, ascendentemente, si se desea hacer una ordenación descendente, solo basta con recorrer el vector de categorías de abajo hacia arriba. Análisis Tiempo: El tiempo de programación y de ejecución es muy pobre ya que tiene que hacer su función como método y todavía utiliza el distribución simple para ordenar, lo que representa mas tardanza, por lo que se asignamos 5 unidades de tiempo de ejecución. Costo: El costo que le podemos dar a este método es muy bueno, por lo que 6 unidades de costo por la programación del método serian buenos, aunque el costo es alto por que utiliza dos métodos innecesariamente. Espacio: Como los demás métodos, utilizan 1Kb de memorias en disco. METODO RADIX Función principal del programa void radixsort(int x[], int n) { int front[10], rear[10]; struct { int info; int next; } node[numelts]; int exp, first, i, j, k, p, q, y; for(i=0; i<n-1; i++) {node[i].info=x[i]; node[i].next = i+1; } node[n-1].info=x[n-1]; node[n-1].next = -1; first = 0; //first es la cabeza de la lista ligada for(k=1; k<5; k++) { for(i=0; i<10; i++) { rear[i] = -1; front[i] = -1; } while( first != -1) { p = first; first = node[first].next; y = node[p].info; exp = pow(10, k-1); //elevar 10 a la (k-1)-esima potencia j = (y/exp)%10; //insertar y en queue[j] q = rear[j]; if(q==-1) front[j] = p; else node[q].next = p; rear[j] = p; } //fin del while for(j=0; j<10 && front[j]==-1; j++); first = front[j]; while( j<= 9) { //verificar si se termino for(i = j+1; i<10 && front[i]==-1; i++); if(i<=9) { p = i; node[rear[j]].next = front[i]; } //fin del if j = i; } //fin del while node[rear[p]].next = -1; }//fin del for for(i=0; i<n; i++) { x[i]=node[first].info; first = node[first].next; } //fin del for } //fin del radix sort Graficas de Rendimiento de los Métodos de Ordenación Interna En este apartado observamos, gráficamente como se comporta cada método con los demás, de esta manera es mas visible y mas practico ver por cual decidirse. Vemos que en los primeros criterios si hay diferencia entre métodos, pero en lo que al espacio se refiere vemos que no hay variación siempre la capacidad de cada método es de 1Kb: Unidad 7. Ordenación externa. 7.2 Algoritmos de ordenación externa. 7.2.1 Intercalación directa. Método de Mezcla Directa Es un método muy interesante que permite obtener una mayor velocidad en la ordenación y en un análisis muy personal es un método muy eficiente por que se basa en particiones y fusiones en dos archivos (F2,F3), y en un archivo desordenado (F1). Se toma el primer elemento de F1 y se almacena en el archivo F2, se recorre una posición en F1 y el siguiente elemento se almacena en F3 de esta manera obtenemos un archivo F2 t F3 que contienen elemento de F1. Se procede a hacer la primera partición de uno y la fusión de dos, lo que significa que al hacer la partición de uno, se toma el elemento del archivo F2 y el primer elemento del archivo F3 y se comparan según sea mayor o menor se acomoda en el archivo F1 (el original), se sigue con la siguiente posición de uno hasta que se acaba. Para la siguiente pasada se aumenta el doble la partición y la fusión, por ejemplo, en la siguiente pasada la partición será de 2 y la fusión de 4, y se procede de la misma manera tomando el primer elemento de la partición en F2 y el primer elemento de F3, el que resulte menor se acomoda y después el que resulta mayor también se pone en el F1.Estos pasos se realizan de forma continua hasta terminar todas las particiones. El final de seguir aumentando al doble las fusiones y las particiones será en el momento que las fusiones excedan el numero de elementos, entonces termina el proceso, y la ordenación quedara en el archivo F1. Análisis Tiempo: En cuestión de Tiempo, es un método muy rápido así que le asigno por su rapidez en ejecución: 2 unidades de tiempo, lo que significa que usa muy poco tiempo para ordenar dos archivos de registros. Costo: El costo si se eleva por su funcionalidad y por su dificultad para programar ordenaciones en archivos. Por lo que 8 unidades de costo son buenos. Espacio: El espacio sigue de manera constante para todos los métodos: 1Kb Mezcla directa - Combinación de secuencias en una sola ordenada por selección repetida de componentes accesibles en cada momento. - Algoritmo 1. Dividir la secuencia a en dos mitades, b y c 2. Mezclar b y c combinando cada elemento en pares ordenados 3. Llamar a a la secuencia mezclada y repetir los pasos 1 y 2, combinando los pares ordenados en cuádruplos ordenados 4. Repetir hasta que quede ordenada toda la secuencia. Fuente 44 55 12 42 94 18 06 67 Separación en 2 fuentes: Fuente 1 44 55 12 42 Fuente 2 94 18 06 67 Se ordenan en pares ordenados Destino 44 94/ 18 55/ 06 12/ 42 67 Separación en 2 fuentes: Fuente 1 44 94/ 18 55 Fuente 2 06 12/ 42 67 Se ordenan los pares ordenados se compara 06 y 44 se escribe 06; se compara 44 y 12 se escribe 12; se escriben el 44 y el 94 sin comparación porque se sabe que están ordenados Destino 06 12 44 94/ 18 42 55 67 Fuente 1 06 12 44 94 Fuente 2 18 42 55 67 Destino 06 12 18 42 44 55 67 94 Terminología - Fase. Operación que trata el conjunto completo de datos - Pase o etapa. Proceso más corto que repetido constituye el proceso de clasificación. - Un pase consta de dos fases: una de división una de combinación - Al acabar un pase se origina una cinta - Mezcla de 2 fases. En cada pase 2 fases (división y combinación) - Mezcla de 1 fase o Balanceada: Eliminar la fase de división Clasificación por mezcla directa, con 2 arreglos fuente destino ijkl mezcla división Fase Combinada de mezcla-división - Las dos secuencias destino están representadas por los extremos de un sólo arreglo - Después de cada pase la fuente se convierte en destino y viceversa Representación de los datos y algoritmo de mezcla a: ARRAY 1..2*n OF Tipo_datos i,j: índices a elementos fuente k,l: índices a elementos destino up El metodo de ordenacion por Mezcla Directa es posiblemente el mas utilizado por su facil comprension. La idea central de este algoritmo consiste en la relacion sucesiva de una particion y una fusion que produce secuencias ordenadas de longitud cada vez mayor. En la primera pasada la particion es de longitud 1 y la fusion o mezcla produce secuencias ordenadas de longitud 2. En la segunda pasada la particion es de longitud 2 y la fusion o mezcla produce secuencias ordenadas de longitud 4. Este proceso se repite hasta que la longitud de la secuencia para la particion sea mayor o igual que el numero de elementos en el archivo original. Supongase que se desea ordenar las claves del archivo F. Para realizar tal actividad se utilizan dos archivos auxiliares a los que se les denominara F1 y F2. F : 09, 75, 14, 68, 29, 17, 31, 25, 04, 05, 13, 18, 72, 46, 61 Particion en secuencias de longitud 1. F1 : 09',14', 29', 31', 04', 13', 72', 61' F2 : 75', 68', 17', 25', 05', 18', 46' Fusion en secuencias de longitud 2 F: 09, 75', 14, 68', 17, 29', 25, 31', 04, 05', 13, 18', 46, 72', 61' Particion en secuencias de longitud 2 F1 : 09, 75', 17, 29', 04, 05', 46, 72' F2 : 14, 68', 25, 31', 13', 18', 61' Fusion en secuencias de longitud 4 F : 09, 14, 68, 75', 17, 25, 29, 31', 04, 05, 13, 18', 46, 61, 72' Particion en secuencias de logitud 4 F1 : 09, 14, 68, 75', 04, 05, 13, 18' F2 : 17, 25, 29, 31', 46, 61, 72' Fusion en secuencias de longitud 8 F : 09, 14, 17, 25, 29, 31, 68, 75', 04, 05, 13, 18, 46, 61, 72' Particion en secuencias de longitud 16 F1 : 09, 14, 17, 25, 29, 31, 68, 75' F2 : 04, 05, 13, 18, 46, 61, 72' Fusion en secuencias de longitud 16 F : 04, 05, 09, 13, 14, 17, 18, 25, 29, 31, 46, 61, 68, 72, 75 7.2.2 Mezcla natural. El Metodo de ordenacion por Mezcla Equilibrada, conocido tambien con el nombre de Mezcla Natural, es una optimizacion del Metodo de Mezcla Directa. La idea central de este algoritmo consiste en realizar las particiones tomando secuencias ordenadas de maxima longitud en lugar de secuencias de tamaño fijo previamente determinadas. Luego realiza la fusion de las secuencias ordenadas, alternativamente sobre dos archivos. Aplicando estas acciones en forma repetida se lograra que el archivo original quede ordenado. Para la realizacion de este proceso de ordenacion se necesitaran cuatro archivos. El archivo original F y tres archivos auxiliares a los que se denominaran F1, F2, y F3. De estos archivos, dos seran considerados de entrada y dos de salida; esta alternativamente con el objeto de realizar la fusion - particion. El proceso termina cuando en la realizacion de una fucion - particion el segundo archivo quede vacio. F : 09, 75, 14, 68, 29, 17, 31, 25, 04, 05, 13, 18, 72, 46, 61 Los pasos que se realizan son los siguientes Particion Inicial F2 : 09',75', 29', 25', 46, 61' F3 : 14, 68', 17, 31', 04, 05, 13, 18, 72' Primera Fusio - Particion F: 09, 14, 68, 75', 04, 05, 13, 18, 25, 46, 61, 72' F1 : 17, 29, 31' Segunda Fusion - Particion F2 : 09, 14, 17, 29, 31, 68, 75' F3 : 04, 05, 13, 08, 25, 46, 61, 72' Tercera Fusion - Particion F : 04, 05, 09, 13, 14, 17, 18, 25, 29, 31, 46, 61, 68, 72, 75 F1 : Observese que al realizar la tercera Fusion - Particion el segundo archivo queda vacio, por lo que puede afirmarse que el archivo ya se encuentra ordenado. Algoritmo de mezcla natural En cuanto a los ficheros secuenciales, el método más usado es el de mezcla natural. Es válido para ficheros de tamaño de registro variable. Es un buen método para ordenar barajas de naipes, por ejemplo. Cada pasada se compone de dos fases. En la primera se separa el fichero original en dos auxiliares, los elementos se dirigen a uno u otro fichero separando los tramos de registros que ya estén ordenados. En la segunda fase los dos ficheros auxiliares se mezclan de nuevo de modo que de cada dos tramos se obtiene siempre uno ordenado. El proceso se repite hasta que sólo obtenemos un tramo. Por ejemplo, supongamos los siguientes valores en un fichero de acceso secuencial, que ordenaremos de menor a mayor: 3, 1, 2, 4, 6, 9, 5, 8, 10, 7 Separaremos todos los tramos ordenados de este fichero: [3], [1, 2, 4, 6, 9], [5, 8, 10], [7] La primera pasada separará los tramos alternándolos en dos ficheros auxiliares: aux1: [3], [5, 8, 10] aux2: [1, 2, 4, 6, 9], [7] Ahora sigue una pasada de mezcla, mezclaremos un tramo de cada fichero auxiliar en un único tramo: mezcla: [1, 2, 3, 4, 6, 9], [5, 7, 8, 10] Ahora repetimos el proceso, separando los tramos en los ficheros auxiliares: aux1: [1, 2, 3, 4, 6, 9] aux2: [5, 7, 8, 10] Y de mezclándolos de nuevo: mezcla: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 El fichero ya está ordenado, para verificarlo contaremos los tramos obtenidos después de cada proceso de mezcla, el fichero estará desordenado si nos encontramos más de un tramo. Ejemplo: // mezcla.c : Ordenamiento de archivos secuenciales // Ordena ficheros de texto por orden alfabético de líneas // Usando el algoritmo de mezcla natural #include <stdio.h> #include <stdlib.h> #include <string.h> void void void bool Mostrar(FILE *fich); Mezcla(FILE *fich); Separar(FILE *fich, FILE **aux); Mezclar(FILE *fich, FILE **aux); int main() { FILE *fichero; fichero = fopen("mezcla.txt", "r+"); puts("Fichero desordenado\n"); Mostrar(fichero); puts("Ordenando fichero\n"); Mezcla(fichero); puts("Fichero ordenado\n"); Mostrar(fichero); fclose(fichero); system("PAUSE"); return 0; } // Muestra el contenido del fichero "fich" void Mostrar(FILE *fich) { char linea[128]; rewind(fich); fgets(linea, 128, fich); while(!feof(fich)) { puts(linea); fgets(linea, 128, fich); } } // Algoritmo de mezcla: void Mezcla(FILE *fich) { bool ordenado; FILE *aux[2]; // Bucle que se repite hasta que el fichero esté ordenado: do { // Crea los dos ficheros auxiliares para separar los tramos: aux[0] = fopen("aux1.txt", "w+"); aux[1] = fopen("aux2.txt", "w+"); rewind(fich); Separar(fich, aux); rewind(aux[0]); rewind(aux[1]); rewind(fich); ordenado = Mezclar(fich, aux); fclose(aux[0]); fclose(aux[1]); } while(!ordenado); // Elimina los ficheros auxiliares: remove(aux[0]); remove(aux[1]); } // Separa los tramos ordenados alternando entre los ficheros auxiliares: void Separar(FILE *fich, FILE **aux) { char linea[128], anterior[2][128]; int salida = 0; // Volores iniciales para los últimos valores // almacenados en los ficheros auxiliares strcpy(anterior[0], ""); strcpy(anterior[1], ""); // Captura la primero línea: fgets(linea, 128, fich); while(!feof(fich)) { // Decide a qué fichero de salida corresponde la línea leída: if(salida == 0 && strcmp(linea, anterior[0]) < 0) salida = 1; else if(salida == 1 && strcmp(linea, anterior[1]) < 0) salida = 0; // Almacena la línea actual como la última añadida: strcpy(anterior[salida], linea); // Añade la línea al fichero auxiliar: fputs(linea, aux[salida]); // Lee la siguiente línea: fgets(linea, 128, fich); } } // Mezcla los ficheros auxiliares: bool Mezclar(FILE *fich, FILE **aux) { char ultima[128], linea[2][128], anterior[2][128]; int entrada; int tramos = 0; // Lee la primera línea de cada fichero auxiliar: fgets(linea[0], 128, aux[0]); fgets(linea[1], 128, aux[1]); // Valores iniciales; strcpy(ultima, ""); strcpy(anterior[0], ""); strcpy(anterior[1], ""); // Bucle, mientras no se acabe ninguno de los ficheros auxiliares (quedan tramos por mezclar): while(!feof(aux[0]) && !feof(aux[1])) { // Selecciona la línea que se añadirá: if(strcmp(linea[0], linea[1]) <= 0) entrada = 0; else entrada = 1; // Almacena el valor como el último añadido: strcpy(anterior[entrada], linea[entrada]); // Añade la línea al fichero: fputs(linea[entrada], fich); // Lee la siguiente línea del fichero auxiliar: fgets(linea[entrada], 128, aux[entrada]); // Verificar fin de tramo, si es así copiar el resto del otro tramo: if(strcmp(anterior[entrada], linea[entrada]) >= 0) { entrada == 0 ? entrada = 1 : entrada = 0; tramos++; // Copia lo que queda del tramo actual al fichero de salida: do { strcpy(anterior[entrada], linea[entrada]); fputs(linea[entrada], fich); fgets(linea[entrada], 128, aux[entrada]); } while(!feof(aux[entrada]) && strcmp(anterior[entrada], linea[entrada]) <= 0); } } // Añadir tramos que queden sin mezclar: if(!feof(aux[0])) tramos++; while(!feof(aux[0])) { fputs(linea[0], fich); fgets(linea[0], 128, aux[0]); } if(!feof(aux[1])) tramos++; while(!feof(aux[1])) { fputs(linea[1], fich); fgets(linea[1], 128, aux[1]); } return(tramos == 1); } Ordenar archivos es siempre una tarea muy lenta y requiere mucho tiempo. Este algoritmo, además requiere el doble de espacio en disco del que ocupa el fichero a ordenar, por ejemplo, para ordenar un fichero de 500 megas se necesitan otros 500 megas de disco libres. Sin embargo, un fichero como el mencionado, sería muy difícil de ordenar en memoria. Método de Mezcla Equilibrada A juicio personal es el mejor método de ordenación externa por que realiza una combinación de los dos métodos anteriores y se logra con su unión, este método muy rápido en su ejecución y muy eficiente. La manera en que opera este método, es que realiza particiones tomando en cuenta secuencias ordenadas de máxima longitud, es decir que del archivo original verifica si el elemento inmediato superior es mayor que él, si es mayor se va formando la secuencia y se envía al archivo F2 también el apuntador se mueve al archivo F3, si no es mayor se verifica si el siguiente elemento en comparación con el actual es mayor si lo es se envía el archivo F3, y el apuntador se regresa al archivo F2, este proceso se realiza hasta que se terminen los elementos de F1. Al tener las particiones hechas se procede a comparar por medio de un recorrido por el método de distribución simple, para ver el nuevo orden que tendrá el archivo F1, así también se vuelve a repetir todos los procesos antes descritos y de esta manera queda el archivo ordenado y/o cuando el tercer archivo queda con NULL o vacío. Análisis Tiempo: El tiempo que necesita para ordenar un archivo es muy corto, así que para asignarle un tiempo, valoraremos su rapidez de ejecución y se le puede asignar 1 unidad de tiempo de tardanza en las ordenaciones de archivos externos. Costo: El costo se eleva considerablemente por que es la implementación de dos métodos en uno solo y eso requiere de un análisis muy detallando, por lo que se le asignan 9unidades de costo a este método. Espacio: El almacenamiento de este método es reducido de manera que es igual a los demás: 1Kb Unidad 8. Métodos de búsqueda. 8.3 Algoritmos de ordenación externa. Una búsqueda es el proceso mediante el cual podemos localizar un elemento con un valor especifico dentro de un conjunto de datos. Terminamos con éxito la búsqueda cuando el elemento es encontrado. 8.3.1 Secuencial. Búsqueda Secuencial A este método tambien se le conoce como búsqueda lineal y consiste en empezar al inicio del conjunto de elementos , e ir atravez de ellos hasta encontrar el elemento indicado ó hasta llegar al final de arreglo. Este es el método de búsqueda más lento, pero si nuestro arreglo se encuentra completamente desordenado es el único que nos podrá ayudar a encontrar el dato que buscamos. ind <- 1 encontrado <- falso mientras no encontrado y ind < N haz si arreglo[ind] = valor_buscado entonces encontrado <- verdadero en caso contrario ind <- ind +1 Búsqueda secuencial, también se le conoce como búsqueda lineal. Supongamos una colección de registros organizados como una lista lineal. El algoritmo básico de búsqueda secuencial consiste en empezar al inicio de la lista e ir a través de cada registro hasta encontrar la llave indicada (k), o hasta al final de la lista. La situación óptima es que el registro buscado sea el primero en ser examinado. El peor caso es cuando las llaves de todos los n registros son comparados con k (lo que se busca). El caso promedio es n/2 comparaciones. Este método de búsqueda es muy lento, pero si los datos no están en orden es el único método que puede emplearse para hacer las búsquedas. Si los valores de la llave no son únicos, para encontrar todos los registros con una llave particular, se requiere buscar en toda la lista. Mejoras en la eficiencia de la búsqueda secuencial 1)Muestreo de acceso Este método consiste en observar que tan frecuentemente se solicita cada registro y ordenarlos de acuerdo a las probabilidades de acceso detectadas. 2)Movimiento hacia el frente Este esquema consiste en que la lista de registros se reorganicen dinámicamente. Con este método, cada vez que búsqueda de una llave sea exitosa, el registro correspondiente se mueve a la primera posición de la lista y se recorren una posición hacia abajo los que estaban antes que el. 3)Transposición Este es otro esquema de reorganización dinámica que consiste en que, cada vez que se lleve a cabo una búsqueda exitosa, el registro correspondiente se intercambia con el anterior. Con este procedimiento, entre mas accesos tenga el registro, mas rápidamente avanzara hacia la primera posición. Comparado con el método de movimiento al frente, el método requiere mas tiempo de actividad para reorganizar al conjunto de registros . Una ventaja de método de transposición es que no permite que el requerimiento aislado de un registro, cambie de posición todo el conjunto de registros. De hecho, un registro debe ganar poco a poco su derecho a alcanzar el inicio de la lista. 4)Ordenamiento Una forma de reducir el numero de comparaciones esperadas cuando hay una significativa frecuencia de búsqueda sin éxito es la de ordenar los registros en base al valor de la llave. Esta técnica es útil cuando la lista es una lista de excepciones, tales como una lista de decisiones, en cuyo caso la mayoría de las búsquedas no tendrán éxito. Con este método una búsqueda sin éxito termina cuando se encuentra el primer valor de la llave mayor que el buscado, en lugar de la final de la lista. 8.3.2 Binaria. Búsqueda Binaria Las condiciones que debe cumplir el arreglo para poder usar búsqueda binaria son que el arreglo este ordenado y que se conozca el numero de elementos. Este método consiste en lo siguiente: comparar el elemento buscado con el elemento situado en la mitad del arreglo, si tenemos suerte y los dos valores coinciden, en ese momento la búsqueda termina. Pero como existe un alto porcentaje de que esto no ocurra, repetiremos los pasos anteriores en la mitad inferior del arreglo si el elemento que buscamos resulto menor que el de la mitad del arreglo, o en la mitad superior si el elemento buscado fue mayor. La búsqueda termina cuando encontramos el elemento o cuando el tamaño del arreglo a examinar sea cero. encontrado <- falso primero <- 1 ultimo <- N mientras primero <= ultimo y no encontrado haz mitad <- (primero + ultimo)/2 si arreglo[mitad] = valor_buscado entonces encntrado <- verdadero en caso contrario si arreglo[mitad] > valor_buscado entonces ultimo <- mitad - 1 en caso contrario primero <- mitad + 1 Se puede aplicar tanto a datos en listas lineales como en árboles binarios de búsqueda. Los prerrequisitos principales para la búsqueda binaria son: La lista debe estar ordenada en un orden especifíco de acuerdo al valor de la llave. Debe conocerse el número de registros. Algoritmo 1. Se compara la llave buscada con la llave localizada al centro del arreglo. 2. Si la llave analizada corresponde a la buscada fin de búsqueda si no. 3. Si la llave buscada es menor que la analizada repetir proceso en mitad superior, sino en la mitad inferior. 4. El proceso de partir por la mitad el arreglo se repite hasta encontrar el registro o hasta que el tamaño de la lista restante sea cero , lo cual implica que el valor de la llave buscada no esta en la lista. El esfuerzo máximo para este algoritmo es de log2n. El mínimo de 1 y en promedio ½ log2 n. 8.3.3 Hash. Búsqueda por Hash La idea principal de este método consiste en aplicar una función que traduce el valor del elemento buscado en un rango de direcciones relativas. Una desventaja importante de este método es que puede ocasionar colisiones. funcion hash (valor_buscado) inicio hash <- valor_buscado mod numero_primo fin inicio <- hash (valor) il <- inicio encontrado <- falso repite si arreglo[il] = valor entonces encontrado <- verdadero en caso contrario il <- (il +1) mod N hasta encontrado o il = inicio Hasta ahora las técnicas de localización de registros vistas, emplean un proceso de búsqueda que implica cierto tiempo y esfuerzo. El siguiente método nos permite encontrar directamente el registro buscado. La idea básica de este método consiste en aplicar una función que traduce un conjunto de posibles valores llave en un rango de direcciones relativas. Un problema potencial encontrado en este proceso, es que tal función no puede ser uno a uno; las direcciones calculadas pueden no ser todas únicas, cuando R(k1 )= R(k2) Pero : K1 diferente de K2 decimos que hay una colisión. A dos llaves diferentes que les corresponda la misma dirección relativa se les llama sinónimos. A las técnicas de calculo de direcciones también se les conoce como : Técnicas de almacenamiento disperso Técnicas aleatorias Métodos de transformación de llave - a- dirección Técnicas de direccionamiento directo Métodos de tabla Hash Métodos de Hashing Pero el término mas usado es el de hashing. Al cálculo que se realiza para obtener una dirección a partir de una llave se le conoce como función hash. Ventaja 1. Se pueden usar los valores naturales de la llave, puesto que se traducen internamente a direcciones fáciles de localizar 2. Se logra independencia lógica y física, debido a que los valores de las llaves son independientes del espacio de direcciones 3. No se requiere almacenamiento adicional para los índices. Desventajas 1. 2. 3. 4. No pueden usarse registros de longitud variable El archivo no esta clasificado No permite llaves repetidas Solo permite acceso por una sola llave Costos Tiempo de procesamiento requerido para la aplicación de la función hash Tiempo de procesamiento y los accesos E/S requeridos para solucionar las colisiones. La eficiencia de una función hash depende de: 1. La distribución de los valores de llave que realmente se usan 2. El numero de valores de llave que realmente están en uso con respecto al tamaño del espacio de direcciones 3. El numero de registros que pueden almacenarse en una dirección dad sin causar una colisión 4. La técnica usada para resolver el problema de las colisiones Las funciones hash mas comunes son: Residuo de la división Medio del cuadrado Pliegue HASHING POR RESIDUO DE LA DIVISIÓN La idea de este método es la de dividir el valor de la llave entre un numero apropiado, y después utilizar el residuo de la división como dirección relativa para el registro (dirección = llave módulo divisor). Mientras que el valor calculado real de una dirección relativa, dados tanto un valor de llave como el divisor, es directo; la elección del divisor apropiado puede no ser tan simple. Existen varios factores que deben considerarse para seleccionar el divisor: 1. El rango de valores que resultan de la operación "llave % divisor", va desde cero hasta el divisor 1. Luego, el divisor determina el tamaño del espacio de direcciones relativas. Si se sabe que el archivo va a contener por lo menos n registros, entonces tendremos que hacer que divisor > n, suponiendo que solamente un registro puede ser almacenado en una dirección relativa dada. 2. El divisor deberá seleccionarse de tal forma que la probabilidad de colisión sea minimizada. ¿Como escoger este numero? Mediante investigaciones se ha demostrado que los divisores que son números pares tienden a comportase pobremente, especialmente con los conjuntos de valores de llave que son predominantemente impares. Algunas investigaciones sugieren que el divisor deberá ser un numero primo. Sin embargo, otras sugieren que los divisores no primos trabajan también como los divisores primos, siempre y cuando los divisores no primos no contengan ningún factor primo menor de 20. Lo mas común es elegir el número primo mas próximo al total de direcciones. Ejemplo: Independientemente de que tan bueno sea el divisor, cuando el espacio de direcciones de un archivo esta completamente lleno, la probabilidad de colisión crece dramáticamente. La saturación de archivo de mide mediante su factor de carga, el cual se define como la relación del numero de registros en el archivo contra el numero de registros que el archivo podría contener si estuviese completamente lleno. Todas las funciones hash comienzan a trabajar probablemente cuando el archivo esta casi lleno. Por lo general el máximo factor de carga que puede tolerarse en un archivo para un rendimiento razonable es de entre el 70 % y 80 %. HASHING POR MEDIO DEL CUADRADO En esta técnica, la llave es elevada al cuadrado, después algunos dígitos específicos se extraen de la mitad del resultado para constituir la dirección relativa. Si se desea una dirección de n dígitos, entonces los dígitos se truncan en ambos extremos de la llave elevada al cuadrado, tomando n dígitos intermedios. Las mismas posiciones de n dígitos deben extraerse para cada llave. Ejemplo: Utilizando esta función hashing el tamaño del archivo resultante es de 10 n donde n es el numero de dígitos extraídos de los valores de la llave elevada al cuadrado. HASHING POR PLIEGUE En esta técnica el valor de la llave es particionada en varias partes, cada una de las cuales (excepto la ultima) tiene el mismo numero de dígitos que tiene la dirección relativa objetivo. Estas particiones son después plegadas una sobre otra y sumadas. El resultado, es la dirección relativa. Igual que para el método del medio del cuadrado, el tamaño del espacio de direcciones relativas es una potencia de 10. Ejemplo: COMPARACIÓN ENTRE LAS FUNCIONES HASH Aunque alguna otra técnica pueda desempeñarse mejor en situaciones particulares, la técnica del residuo de la división proporciona el mejor desempeño. Ninguna función hash se desempeña siempre mejor que las otras. El método del medio del cuadrado puede aplicarse en archivos con factores de cargas bastantes bajas para dar generalmente un buen desempeño. El método de pliegues puede ser la técnica mas fácil de calcular pero produce resultados bastante erráticos, a menos que la longitud de la llave se aproximadamente igual a la longitud de la dirección. Si la distribución de los valores de llaves no es conocida, entonces el método del residuo de la división es preferible. Note que el hashing puede ser aplicado a llaves no numéricas. Las posiciones de ordenamiento de secuencia de los caracteres en un valor de llave pueden ser utilizadas como sus equivalentes "numéricos". Alternativamente, el algoritmo hash actúa sobre las representaciones binarias de los caracteres. Todas las funciones hash presentadas tienen destinado un espacio de tamaño fijo. Aumentar el tamaño del archivo relativo creado al usar una de estas funciones, implica cambiar la función hash, para que se refiera a un espacio mayor y volver a cargar el nuevo archivo. MÉTODOS PARA RESOLVER EL PROBLEMA DE LAS COLISIONES Considere las llaves K1 y K2 que son sinónimas para la función hash R. Si K1 es almacenada primero en el archivo y su dirección es R(K1), entonces se dice que K1 esta almacenado en su dirección de origen. Existen dos métodos básicos para determinar donde debe ser alojado K2 : Direccionamiento abierto.- Se encuentra entre dirección de origen para K2 dentro del archivo. Separación de desborde (Area de desborde).- Se encuentra una dirección para K2 fuera del área principal del archivo, en un área especial de desborde, que es utilizada exclusivamente para almacenar registro que no pueden ser asignados en su dirección de origen Los métodos mas conocidos para resolver colisiones son: Sondeo lineal Que es una técnica de direccionamiento abierto. Este es un proceso de búsqueda secuencial desde la dirección de origen para encontrar la siguiente localidad vacía. Esta técnica es también conocida como método de desbordamiento consecutivo. Para almacenar un registro por hashing con sondeo lineal, la dirección no debe caer fuera del limite del archivo, En lugar de terminar cuando el limite del espacio de dirección se alcanza, se regresa al inicio del espacio y sondeamos desde ahí. Por lo que debe ser posible detectar si la dirección base ha sido encontrada de nuevo, lo cual indica que el archivo esta lleno y no hay espacio para la llave. Para la búsqueda de un registro por hashing con sondeo lineal, los valores de llave de los registros encontrados en la dirección de origen, y en las direcciones alcanzadas con el sondeo lineal, deberá compararse con el valor de la llave buscada, para determinar si el registro objetivo ha sido localizado o no. El sondeo lineal puede usarse para cualquier técnica de hashing. Si se emplea sondeo lineal para almacenar registros, también deberá emplearse para recuperarlos. Doble hashing En esta técnica se aplica una segunda función hash para combinar la llave original con el resultado del primer hash. El resultado del segundo hash puede situarse dentro del mismo archivo o en un archivo de sobreflujo independiente; de cualquier modo, será necesario algún método de solución si ocurren colisiones durante el segundo hash. La ventaja del método de separación de desborde es que reduce la situación de una doble colisión, la cual puede ocurrir con el método de direccionamiento abierto, en el cual un registro que no esta almacenado en su dirección de origen desplazara a otro registro, el que después buscará su dirección de origen. Esto puede evitarse con direccionamiento abierto, simplemente moviendo el registro extraño a otra localidad y almacenando al nuevo registro en la dirección de origen ahora vacía. Puede ser aplicado como cualquier direccionamiento abierto o técnica de separación de desborde. Para ambas métodos para la solución de colisiones existen técnicas para mejorar su desempeño como: 1.- Encadenamiento de sinónimos Una buena manera de mejorar la eficiencia de un archivo que utiliza el calculo de direcciones, sin directorio auxiliar para guiar la recuperación de registros, es el encadenamiento de sinónimos. Mantener una lista ligada de registros, con la misma dirección de origen, no reduce el numero de colisiones, pero reduce los tiempos de acceso para recuperar los registros que no se encuentran en su localidad de origen. El encadenamiento de sinónimos puede emplearse con cualquier técnica de solución de colisiones. Cuando un registro debe ser recuperado del archivo, solo los sinónimos de la llave objetivo son accesados. 2.- Direccionamiento por cubetas Otro enfoque para resolver el problema de las colisiones es asignar bloques de espacio (cubetas), que pueden acomodar ocurrencias múltiples de registros, en lugar de asignar celdas individuales a registros. Cuando una cubeta es desbordada, alguna nueva localización deberá ser encontrada para el registro. Los métodos para el problema de sobrecupo son básicamente los mismo que los métodos para resolver colisiones. COMPARACIÓN ENTRE SONDEO LINEAL Y DOBLE HASHING De ambos métodos resultan distribuciones diferentes de sinónimos en un archivo relativo. Para aquellos casos en que el factor de carga es bajo (< 0.5), el sondeo lineal tiende a agrupar los sinónimos, mientras que el doble hashing tiende a dispersar los sinónimos mas ampliamente a travéz del espacio de direcciones. El doble hashing tiende a comportarse casi también como el sondeo lineal con factores de carga pequeños (< 0.5), pero actúa un poco mejor para factores de carga mayores. Con un factor de carga > 80 %, el sondeo lineal por lo general resulta tener un comportamiento terrible, mientras que el doble hashing es bastante tolerable para búsquedas exitosas pero no así en búsquedas no exitosas. 8.4 Búsqueda externa. 8.4.1 Secuencial. 8.4.2 Binaria. 8.4.3 Hash. Método de Intercalación Simple.htm Manejo de archivos_ Capítulo 005.htm http://www.itlp.edu.mx/publica/tutoriales/estructdatos2/unidad5.htm http://www.itlp.edu.mx/publica/tutoriales/estructdatos2/tema%205_1.htm http://www.itlp.edu.mx/publica/tutoriales/estructdatos2/tema%205_2.htm http://www.itlp.edu.mx/publica/tutoriales/estructdatos2/tema%205_3.htm http://www.itcerroazul.edu.mx/estructura/UNI5.html http://www.lab.dit.upm.es/~lprg/material/apuntes/o/ http://216.239.57.104/search?q=cache:d1flvblkUCcJ:www.lsi.upc.es/~virtual/pbd/02Recursividad.pdf+%22complejidad+de+algoritmos+recursivos%22&hl=es&start=14 http://pinsa.escomposlinux.org/sromero/prog/recursividad.php http://cronos.dci.ubiobio.cl/~gpoo/documentos/librognome/glib-memory.html http://labsopa.dis.ulpgc.es/cpp/intro_c/introc77.htm http://msdn.microsoft.com/library/spa/default.asp?url=/library/SPA/vbcn7/html/vaconcreatingrec ursiveprocedures.asp http://www.fisica.uson.mx/carlos/Nirvana/CComp/cursos/CC11/lecture5.html http://www.monografias.com/trabajos14/recursividad/recursividad.shtml