Estructura de datos_ISC

Anuncio
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
Documentos relacionados
Descargar