Académique Documents
Professionnel Documents
Culture Documents
Usando lenguaje C.
Sinopsis.
El texto centra su atención en el diseño de algoritmos para realizar las acciones básicas de:
Ordenar, buscar, seleccionar y calcular, empleando diferentes estructuras de datos. Por
ejemplo, se pueden ordenar los elementos de una lista o de un arreglo; se puede buscar un valor
en un árbol, en una lista o en un arreglo.
Contenidos.
La sección de Conceptos básicos, es el núcleo del curso, y contiene las ideas principales del
texto.
La sección Buscar, ilustra algoritmos más evolucionados de búsqueda. De los cuales pueden
seleccionarse algunos capítulos para exponer en clases, otros pueden plantearse como proyectos
ha ser desarrollados en grupos de trabajo. Se incluyen los algoritmos clásicos deterministas y
también los aleatorizados y amortizados. El algoritmo Treaps, ilustra el diseño de algoritmos en
base a los desarrollados en Conceptos básicos, mostrando que éstos constituyen elementos de
diseño.
En la sección Calcular se han seleccionado dos temas que son de interés en electrónica. Se
muestran los principales algoritmos empleados en los simuladores analógicos, y se desarrolla la
transformada rápida de Fourier.
Los Apéndices contienen algunos conceptos formales sobre lenguajes, una exposición básica del
lenguaje C, y una breve descripción de análisis léxico, con el objeto de facilitar el llenado de las
estructuras con datos provenientes de archivos de texto. Lo cual puede emplearse para probar
las funciones con una cantidad realista de datos.
Conceptos básicos.
Capítulo 1 Introducción.
Capítulo 2 Diseño de estructuras de datos.
Capítulo 3 Administración de la memoria.
Capítulo 4 Complejidad temporal.
Capítulo 5 Listas, stacks, colas.
Capítulo 6 Árboles binarios de búsqueda.
Capítulo 7 Tablas de hash.
Capítulo 8 Colas de prioridad.
Capítulo 9 Ordenar.
Capítulo 10 Grafos.
Buscar.
Seleccionar.
Capítulo 18 Pagodas
Capítulo 19 Leftist
Capítulo 20 Skewheap
Capítulo 21 Árboles binomiales
Capítulo 22 Pairing heaps
Calcular.
Capítulo 1.
Con estos elementos pueden describirse estructuras abstractas de datos como: listas, árboles,
conjuntos, grafos. Usando estructuras abstractas de datos se pueden modelar los datos de
sistemas más complejos, como: sistemas operativos, la información que viaja en los paquetes de
datos de una red, o complejas relaciones entre componentes de datos en un sistema de bases de
datos, por mencionar algunos ejemplos.
Las relaciones de los elementos de datos, en sus diferentes niveles, pueden conceptualizarse en
el siguiente diagrama:
bit
Luego de esta estructuración, las acciones realizadas electrónicamente pueden ser abstraídas
como instrucciones de máquina. Mediante la ayuda de compiladores es posible traducir las
acciones o sentencias del lenguaje de alto nivel en instrucciones de máquina. En el nivel de los
lenguajes de programación, se dispone de operadores que permiten construir expresiones y
condiciones; agregando las acciones que implementan las funciones combinacionales se pueden
abstraer las acciones en términos de alternativas, condicionales y switches; la capacidad de
implementar máquinas de estados permite la realización de iteraciones y repeticiones. Las
organizaciones y arquitecturas se estudian en un curso de estructuras de computadores.
El tema cubierto en este texto centra su atención en el diseño de algoritmos para realizar las
acciones básicas de: Ordenar, buscar, seleccionar y calcular, empleando diferentes
estructuras de datos. Por ejemplo, se pueden ordenar los elementos de una lista o de un arreglo;
se puede buscar un valor en un árbol, en una lista o en un arreglo.
1.3. Lenguajes.
En los niveles físicos, se emplean Lenguajes de Descripción de Hardware (HDL). Para describir
los procesadores se emplea lenguaje assembler, cada procesador tiene un lenguaje assembler
propio.
En lenguajes de alto nivel ha existido una rápida evolución. Muchos de los que fueron más
universalmente usados han pasado a la historia: Fortran, Algol, APL, Pascal, Modula, Basic.
Los lenguajes como Pascal, C y otros tienen la posibilidad de programación de tipos abstractos.
El lenguaje C evolucionó rápidamente para convertirse en uno de los más usados. Su principio
de diseño es que compile eficientemente y que permita usar los recursos de hardware de los
procesadores. Por esta razón se lo seguirá empleando en cursos de estructuras de computadores
y en el diseño de microcontroladores. Sin embargo sus capacidades de manejo de memoria
Seguramente con los años aparecerán nuevos y mejores lenguajes, pero los principales
conceptos que aprendamos en este texto seguramente perseverarán, ya que son básicos.
1.4. Genialidades.
Los más importantes algoritmos que estudiaremos emplean ideas geniales y brillantes. Desde
que son descubiertos se los empieza a usar en las aplicaciones, debido a sus ventajas. Sin
embargo exponer ideas: a veces sofisticadas, otras decididamente rebuscadas, en algunos casos
aparentemente simplistas, y que han resuelto grandes problemas son parte de la dificultad de
este curso.
No se estudian cuestiones sencillas, la mayor parte de ellas son muy elaboradas, y algunas veces
difíciles de captar en su profundidad.
Debido a lo anterior, no se espera que después de estudiar este texto se esté en condiciones de
inventar algoritmos que pasen a la historia, pero si conocer las principales estructuras abstractas
de uso general, sus posibles aplicaciones, y los mejores algoritmos conocidos para esas
estructuras.
1.6. Definiciones.
1.6.1. Algoritmo.
Cada operación debe tener un significado preciso y debe ser realizada en un lapso finito de
tiempo y con un esfuerzo finito. Un algoritmo debe terminar después de ejecutar un número
finito de instrucciones.
Se pueden analizar algoritmos, en forma abstracta, es decir sin emplear un determinado lenguaje
de programación; el análisis se centra en los principios fundamentales del algoritmo y no en una
implementación en particular. En estos casos puede emplearse para su descripción un
pseudocódigo.
Existen métodos cuantitativos para determinar los recursos espaciales y temporales que requiere
un algoritmo. En particular se estudiarán métodos para calcular la complejidad temporal de un
algoritmo.
1.6.2. Heurística.
Una estructura de datos bien organizada debe permitir realizar un conjunto de acciones sobre los
datos de tal forma de minimizar el uso de los recursos y el tiempo empleado para efectuar la
operación.
Se describen algunos algoritmos, considerados clásicos, para ilustrar el camino que debe
recorrerse desde su diseño hasta traducirlo a un lenguaje de programación.
En opinión del Profesor Dijkstra, las computadoras manipulan símbolos y producen resultados;
y un programa, que describe un algoritmo, es un manipulador abstracto de símbolos.
Visto de este modo un algoritmo es una fórmula de algún sistema formal; sin embargo debido a
que estas fórmulas resultan mucho más largas y elaboradas que las usuales, no suele
reconocérselas como tales.
En primer lugar debe describirse lo que se desea realizar en lenguaje natural, desgraciadamente
esto puede producir más de alguna ambigüedad. Luego debe modelarse matemáticamente la
situación que interesa describir; es decir, apoyado en conceptos matemáticos y lógicos se
describe la función o fórmula que debe realizarse, empleando un pseudolenguaje; que es más
preciso y formal que el lenguaje común. Una vez obtenida una representación formal se la
traduce empleando un lenguaje de programación.
Euclides fue un matemático griego que vivió alrededor del año 300 a.C. una de sus
contribuciones es el algoritmo para obtener el máximo común divisor de dos números enteros,
lo cual sería la descripción en lenguaje natural, del problema que se desea resolver.
Si x e y son enteros, no ambos ceros, su máximo común divisor, que anotaremos mcd(x,y), es el
mayor entero que los divide a ambos exactamente; es decir, sin resto.
mcd( x, 0) = |x|
mcd( x, y) = mcd(y, x)
mcd(-x, y) = mcd(x, y)
Entonces para enteros mayores que cero, tenemos el siguiente algoritmo, descrito en lenguaje
natural:
Como veremos, a través de los siguientes ejemplos, existen numerosas formas de describir un
algoritmo.
do
{
while (x>y) x=x-y;
while (y>x) y=y-x;
} while (x!=y);
// x es el mcd
La expresión muestra cómo reducir uno de los números, manteniendo en el primer lugar al
mayor. La transformación debe repetirse hasta que el menor de los números sea igual a cero.
El cambio de posición de los números requiere una variable temporal que denominaremos resto.
while (y!=0)
{ resto = x % y;
x=y;
y=resto;
}
// x es el mcd
Erastótenes fue un matemático griego (275-194 A.C.) que desarrolló un algoritmo que se
conoce como la criba de Erastótenes (sieve en inglés), que permite determinar los números
primos menores que un número dado. El nombre deriva de un “colador” en que se colocan
todos los números, y sólo deja pasar los números primos.
Para desarrollar el modelo matemático que permita elaborar el algoritmo, pueden emplearse las
siguientes definiciones de un número primo:
Un número primo es un entero positivo que no es el producto de dos enteros positivos menores.
Un número primo es un entero positivo que tiene exactamente dos factores enteros positivos: 1
y sí mismo.
#include <stdio.h>
#define n 40
#define primo 1
#define noprimo 0
int a[n];
void Eratostenes(void)
{ int i, p, j;
a[1] = noprimo; for (i= 2; i<=n; i++) a[i]= primo;
//Se inicia arreglo, marcando todos los números como primos, excepto el primero.
p = 2;
while (p*p <= n)
{
j = p*p; //El primer múltiplo de p no primo es su cuadrado.
while (j <= n)
{
a[j] = noprimo;
j = j+p; //siguiente múltiplo de p
}
//printf("Elimina múltiplos de %d: ", p); mostrar(); putchar('\n');
do p = p+1; while (a[p] == noprimo); //se salta los no primos.
}
}
int main(void)
{
Eratostenes();
mostrar();
return (0);
}
La criba de Atkin es un algoritmo moderno, más rápido, para encontrar todos los primos hasta
un entero dado
Si tenemos la permutación: 13254, el dígito dos es menor que 5, y el primero mayor que 2 es 4.
Resulta, luego del intercambio: 13452. Lo que resta es reordenar los dígitos siguientes a 4, hasta
la posición del último, es este ejemplo hay que intercambiar el 2 con el 5, resultando: 13425 que
es la permutación siguiente, en orden lexicográfico, a la inicial.
Veamos otro caso, a partir de: 15342. El primero menor que el dígito siguiente es el 3; y el
primero mayor que 3 es el dígito 4; luego del intercambio, se tiene: 15432. Y reordenando los
dígitos siguientes a 4, se obtiene la siguiente permutación: 15423.
Encontrar el dígito menor que el siguiente, a partir de la posición más significativa, puede
escribirse:
i = n-1; while (a[i] >= a[i+1]) i--;
Nótese que si i es menor que uno, al salir del while, la secuencia es monótonamente decreciente,
y no existe una permutación siguiente. Se asume que en la posición 0 del arreglo se ha
almacenado un dígito menor que los dígitos almacenados en las posiciones de 1 a n. Esto es
importante ya que debe asegurarse que el while termine, y también porque el lenguaje C no
verifica que el índice de un arreglo esté dentro del rango en que ha sido definido.
Si existe la próxima permutación: Se busca a partir del último, el primer dígito que sea mayor
que el menor ya seleccionado, lo cual puede traducirse según:
Para el intercambio de los dígitos en posiciones i y j, se requiere una variable auxiliar temp, el
uso del siguiente macro simplifica la escritura del intercambio.
Problemas resueltos.
Si tenemos que el conjunto C está formado por los dígitos {1, 2, 3, 4, 5} las combinaciones
formadas con tres elementos del conjunto, en orden creciente numérico, son la 6 siguientes:
123
124
125
134
135
Podemos describir con más detalle: si la combinación tiene k elementos y el conjunto tiene n
elementos. En la posición k-ésima de la combinación el mayor número que puede escribirse es
el n-ésimo del conjunto. En la posición (k-1)-ésima de la combinación el mayor número que
puede escribirse es el (n-1)-ésimo del conjunto. La última combinación tiene en la posición 1 de
la combinación el número (n-k+1) del conjunto.
Con n=7 y k=5, si se tiene la combinación 12567, la segunda cifra de la combinación es menor
que el máximo en esa posición, que corresponde a 4. Entonces se incrementa el 2 en uno, y se
completa la combinación, generando: 13456. En este caso, la última combinación que puede
generarse es 34567.
El número de combinaciones de k elementos sobre un total de n, está dado por C(n, k) donde C
es el coeficiente binomial.
C(n, 0) = 1 n >=0
C(n, k) = 0 n<k
C(n, k) = C(n-1, k) + C(n-1, k-1) 0<=k<=n
C(n, k) = n! / k!(n-k)!
Si suponemos un arreglo de enteros únicos (no repetidos), con índices desde 1 a n, y valores
entre 1 y n, la generación de las combinaciones de k elementos, mayores en orden lexicográfico
que la inicial, pueden generarse mediante:
mostrarcomb(a,k);
i=k; /revisa la siguiente.
}
}
La función que permite generar la siguiente combinación, a partir de una inicial, puede extraerse
del algoritmo anterior.
Debe evitar invocarse a la función pasando como dato inicial, la última combinación.
f(x)=(1/x) -a
1/a x
xk xk+1
Debe notarse que si el valor inicial de x se escoge muy grande, no se producirá el acercamiento
a la raíz. La pendiente en x = 2/a, intercepta al eje x en cero, y en este punto la función tiene
derivada con valor infinito; por lo tanto puede escogerse un valor inicial en el intervalo:
0 < x0 < 2/a, para garantizar la convergencia.
Relación que muestra que el error disminuye cuadráticamente. Si el error es una etapa es
pequeño, en la etapa siguiente disminuye mucho más.
xk (1 1 axk 1 ) xk 1 (1 ek 1 ) xk 1
2
ek ek 1
Siempre que se diseña un lazo while, debe verificarse que éste termine. En este caso se requiere
verificar que el error va disminuyendo, cada vez que se ejecuta el bloque de repetición dentro
del lazo.
De la expresión para el error relativo, puede deducirse una relación para el error en la etapa
k-ésima en función del error inicial:
Es importante destacar el razonamiento inductivo que permite escribir el caso final. En este caso
basta observar que la suma del subíndice más el exponente de la potencia de dos, siempre
suman k.
Debido a que el exponente es par, para que el error disminuya al aumentar k, debe cumplirse:
e0 1
1 ax0 1
La anterior implica:
1 1 ax0 1
Y también:
0 ax0 2
2
0 x0
a
La primera modificación del algoritmo básico anterior es la elección de un valor inicial para
valores elevados del argumento. Para valores de a mayores que 2, el argumento real puede
expresarse:
a f 2e
f=a; e=0;
while (f >= 1) {e++; f=f/2;}
while (f< 0.5) {e--; f=f*2;}
Debe notarse que sólo se efectúa uno de los lazos, y que después de ambos se tiene que se
cumple la condición para f.
El recíproco, en términos de e y f:
1 1 e
2
a f
Entonces el valor inicial debe cumplir:
2 2 e
0 x0 2
a f
Usando el mayor valor posible para f, se tiene:
e
0 x0 2 2
e
x0 2
Y empleando la definición del error relativo, con el valor inicial anterior, se tiene:
e0 1 ax0 1 f 1 f
if (a<1.)
{x=1.; err=1.-a;}
else
{ // a > 1.
f=a; e=0; p=1; //mediante p se calcula 2^(-e)
while (f >= 1.) {e++; f=f/2; p=p/2;}
while (f< 0.5) {e--; f=f*2; p=p*2;}
x=p; err=1-f;
}
while (err>epsilon)
{ x = (1.+err)*x;
err=err*err;
}
if (sign) x=-x;
return(x);
}
Los valores iniciales, para a>1, pueden calcularse con funciones de la biblioteca (math.h), según
se ilustra a continuación.
Con: a f 2e , la función frexp, calcula mantisa (1/2 <= f < 1), y exponente e (de tipo entero).
Ejercicios propuestos.
Ahora el arreglo almacena los dígitos desde 0 hasta (n-1). De este modo no puede emplearse el
elemento 0 del arreglo como centinela y es preciso modificar el código propuesto antes.
Con: 0 a 2
x0 a
Para i=0
e0 1 a
e
xi (1 i 1 ) xi 1
Para i>0
2
3 ei 1
ei ei2 1 ( )
4 4
lim xi a
i
Debido a que el error relativo puede tener signo, la condición de término de la iteración, puede
lograrse con: fabs (ei ) epsilon . Que emplea la función valor absoluto para números flotantes.
Comprobar que empleando el método de Newton, se obtiene el algoritmo para calcular la raíz
cuadrada, según:
1 a
xk 1 ( xk )
2 xk
Converge con: x0 0
Referencias.
CAPÍTULO 1. ............................................................................................................................................ 1
INTRODUCCIÓN A LAS ESTRUCTURAS DE DATOS Y ALGORITMOS. ................................... 1
1.1. ESTRUCTURA DE LOS DATOS. ............................................................................................................. 1
1.2. ESTRUCTURA DE LAS ACCIONES. ALGORITMOS. ................................................................................ 2
1.3. LENGUAJES. ....................................................................................................................................... 3
1.4. GENIALIDADES. ................................................................................................................................. 4
1.5. TEORÍA Y PRÁCTICA........................................................................................................................... 4
1.6. DEFINICIONES. ................................................................................................................................... 4
1.6.1. Algoritmo. .................................................................................................................................. 4
1.6.2. Heurística. ................................................................................................................................. 5
1.6.3. Estructuras de datos. ................................................................................................................. 5
1.7. ALGORITMOS CLÁSICOS. .................................................................................................................... 5
1.7.1. Algoritmo de Euclides. .............................................................................................................. 6
1.7.2. Algoritmo el colador de Erastótenes. ........................................................................................ 8
1.7.3. Permutaciones en orden lexicográfico. Algoritmo de Dijkstra (1930-2002). .......................... 9
PROBLEMAS RESUELTOS. ........................................................................................................................ 11
P1. Combinaciones en orden lexicográfico. ...................................................................................... 11
P2. Cálculo del recíproco. ................................................................................................................ 15
EJERCICIOS PROPUESTOS. ....................................................................................................................... 19
E1. Diseñar una función que genera la próxima permutación. ......................................................... 19
E2. Codificar el siguiente algoritmo, para la raíz cuadrada. ........................................................... 20
E3. Codificar el siguiente algoritmo, para la raíz cuadrada. ........................................................... 20
REFERENCIAS. ........................................................................................................................................ 20
ÍNDICE GENERAL. ................................................................................................................................... 21
ÍNDICE DE FIGURAS................................................................................................................................. 21
Índice de figuras.
Capítulo 2.
Se desea conocer las técnicas para diseñar estructuras de datos. Se repasan los conceptos de
tipos básicos, para luego desarrollar las herramientas de creación de nuevas estructuras,
haciendo énfasis en los conceptos de agrupación y vinculación. Dando ejemplos que
posteriormente se emplearán en el texto.
Para los tipos primitivos existen numerosos y variados operadores que permiten construir
expresiones con variables y constantes de dichos tipos. En determinados casos existen
mecanismos automáticos para convertir valores de un tipo en otro, sin embargo es preferible la
conversión explícita a través del molde o “cast”.
La estructura (struct) permite agrupar elementos de diferente tipo. El arreglo agrupa elementos
de igual tipo. El string agrupa caracteres.
Se dispone de una forma de definir variables de esos grupos básicos, y los mecanismos de
acceso a las componentes: el discriminador del campo (un punto) para las estructuras; y los
paréntesis cuadrados o corchetes para delimitar el índice de la componente del arreglo. Las
operaciones que están definidas para estos grupos básicos son muy reducidas, el programador
debe crear sus propias funciones o métodos para efectuar operaciones más complejas sobre
estos grupos básicos. En el caso de strings existe una biblioteca estándar de funciones que los
manipulan (ver Apéndice 2).
Las agrupaciones pueden ser tan complejas como sea necesario: ya que es posible crear arreglos
de estructuras, y también estructuras que contengan arreglos y strings.
2.2.2. Vínculos.
Pueden establecerse relaciones entre elementos de datos. Los siguientes diagramas ilustran las
formas básicas de vinculación: vínculos de orden o secuencia en listas, relaciones jerárquicas en
árboles, e interconexiones complejas en grafos.
árboles
listas
grafos
El programador debe disponer de elementos del lenguaje que le permitan crear nuevos tipos de
datos, y mediante estos tipos crear funciones o métodos que realicen operaciones sobre las
componentes.
Otro mecanismo para establecer vínculos es el puntero, que es una variable que contiene la
dirección de otra componente. La vinculación a través de punteros suele emplearse
preferentemente en situaciones dinámicas, en las cuales el número de las componentes varía
durante la ejecución del programa; esto debido a que se crean, insertan y descartan
componentes.
El acceso a componentes se realiza vía indirección de un puntero constante que es el nombre del
arreglo, con un offset dado por el índice del elemento.
Es práctica usual, definir mediante una constante el tamaño máximo del arreglo
#define MaxEntradas 10
A las funciones que manipulan arreglos se les suele pasar el arreglo por referencia.
Por ejemplo la función que imprime en una línea las componentes de un arreglo a.
Los ejemplos anteriores ilustran que el acceso a componentes de un arreglo se efectúa mediante
indirección con el puntero que es el nombre del arreglo.
La expresión: A[c] se interpreta como el contenido de la dirección:
A + c*(tamaño del tipo del arreglo).
Donde A es la dirección de la primera componente del arreglo.
Empleando el lenguaje:
A[c] equivale a: *(A+c) y A es equivalente a &A[0].
Definición de matrices.
Puede definirse una matriz de caracteres, arr, de R renglones y C columnas mediante:
#define R 8 //renglones
#define C 10 //columnas
char arr[R][C];
arr
arr[3][5]
arr[3]
La expresión: a[3][5], denota el sexto carácter del cuarto renglón. La cual puede escribirse,
usando notación con punteros, mediante: * ( *(arr+3) + 5 ).
Puede concluirse que arr es un puntero a un puntero a carácter.
Definición de arreglo de arreglos.
Empleando definición de tipos puede crearse el tipo renglón, como un arreglo de C caracteres.
Y mediante este nuevo tipo puede definirse una matriz ar, como un arreglo de renglones.
Definiciones que se muestran a continuación.
#define R 8 //renglones
#define C 10 //columnas
typedef char renglon[C];
renglon ar[R];
Con el mismo procedimiento se pueden estructurar organizaciones más complejas. Por ejemplo
una matriz cuyo contenido sea un arreglo.
Los arreglos aseguran que las componentes quedan adyacentes en memoria. En el caso de
matrices, el último elemento del primer renglón queda adyacente con el primer elemento del
segundo renglón.
Arreglo de punteros a renglones.
Un ejemplo de creación de estructuras de datos, empleando el lenguaje C, es diseñar un arreglo
de punteros pt, que apunten a los renglones de una matriz.
ar
pt
(*pt[2])[5]
Es preciso vincular los punteros del arreglo con los renglones, lo cual se efectúa con:
for(i=0; i<R; i++) pt[i]=&ar[i]; //llena arreglo de punteros a renglones.
Entonces: pt[2] contiene un puntero al tercer renglón. Lo cual puede escribirse: *(pt +2 ).
La expresión: *(pt[2]) es el tercer renglón, que en este caso es un arreglo de C caracteres; es
decir: *( *(pt+2)) es el valor del puntero al inicio de ese renglón. Y consecuentemente:
*( *(pt+2)) +5 es el puntero al sexto carácter del tercer renglón.
Finalmente para accesar al elemento ubicado en el tercer renglón y en la sexta columna, debe
indireccionarse el puntero anterior, lo cual puede escribirse: *( *( *(pt+2)) +5), o empleando
notación de arreglos: (*pt[2])[5] .
#define MaxEntradas 4
#define fin_de_lista -1
1 2 3 4
//molde. Declaración.
struct fecha
{ int dia;
int mes;
int agno;
};
//instancias.
struct fecha fecha1; //definición.
struct fecha fecha2={1,5,2004}; //definición e inicialización
Nótese que se requiere preceder con la palabra struct al nombre del molde.
En este ámbito, las definiciones de las instancias para las variables, quedan:
Fecha fecha3 = {1, 5, 2004}; //definición e inicialización.
pFecha pfecha3=&fecha3;
Observar que las definiciones no son precedidas por struct.
Es preferible pasar una referencia a la estructura, ya que esto ocupa menos espacio en el frame.
Sólo es preciso copiar un puntero.
void printfecharef(pfecha p) //paso por referencia.
{
printf(" Día = %d Mes = %d Año = %d \n", p->dia, (*p).mes, p->agno);
}
Ejemplos de uso:
printfecha(fecha3);
printfecharef(pfecha3);
Ejemplo de uso:
fecha3 = setfecha(15, 6, 2005);
También suele ser necesario desarrollar funciones para realizar operaciones de comparación
entre estructuras. El siguiente prototipo ilustra un operador de comparación “mayor o igual
que”, al cual se le pasan punteros a estructuras y devuelve un puntero a la estructura que es
mayor o igual que la otra, el diseño de la función se deja como tarea.
//instancias. Definiciones.
nodo nodo1={1,NULL};
nodo nodo2={2,NULL};
nodo nodo3={3,NULL};
pnodo lista=&nodo1;
lista
1 2 3
En caso de definir la lista en forma dinámica, sólo es preciso definir la variable lista. El espacio
para los nodos se solicita a través de llamados a malloc; debido a esto los nodos no tienen un
nombre y sólo se los puede accesar vía punteros.
En el caso del ejemplo, los nodos se han definido en forma estática, y tienen un nombre de
variable asociado.
La palabra static, que precede a la definición, explícitamente indica que la tabla debe
almacenarse en un segmento estático de la memoria. Es decir estará disponible, durante toda la
ejecución del programa.
0 String i
1
2
….
B-1 String j
String k
#define B 10 /* 10 celdas */
static celda hashtab[B]; /*arreglo de celdas */
static int ocupados; //ocupados de la tabla
2.5.3. Multiárboles.
La descripción de un nodo que pueda tener un número variable de punteros que apunten a sus
descendientes no es práctica. Ya que esto implica almacenar los vínculos en un arreglo, y si es
arreglo debe tener un tamaño fijo. El cual debe escogerse considerando el máximo número de
descendientes que soportará la estructura; mal empleando los recursos en el caso de un nodo sin
hijos, o de nodos con un pequeño número de descendientes. Además siempre estará el riesgo de
que aparezca un nodo con mayor número de descendientes que el tamaño máximo escogido.
2 3 4
5 6 7 8 9 10 11 12 13
El número variable de los vínculos asociados a un nodo pueden reemplazarse por sólo dos
vínculos: uno que relaciona el nodo con el primer descendiente izquierdo, y otro que vincula a
cada nodo con su hermano derecho. Nótese que en cada nivel puede observarse una lista de
nodos que parte del nodo ubicado más a la izquierda y termina en un vínculo nulo después del
último descendiente de un nodo.
Se ha considerado que se almacenará un entero como clave o valor distintivo del nodo. Se ha
agregado un puntero a una estructura que podría almacenar otros datos asociados al nodo, con el
campo datos_periféricos. También se agrega un puntero al padre, ya que esta información
reduce el tiempo de buscar al padre de un nodo determinado.
2.5.3.2. Descripción mediante arreglos de cursores.
Se describe a continuación los valores que describen el multiárbol, de la Figura 2.6, mediante
arreglos de cursores para el Hijo izquierdo y Hermano Derecho. Se ha considerado que un valor
cero indica que no hay hijo izquierdo o hermano derecho o padre; si se desea usar el nodo cero,
podría elegirse -1 como el cursor nulo.
Se ha agregado un arreglo de cursores para especificar el padre del nodo. Esta información
facilita la búsqueda del padre de un nodo dado.
Un diseño de la estructura de datos considera al multiárbol como una estructura cuyos campos
son los diferentes arreglos.
#define MAXNODOS 13
struct moldemultiarbol
{
int Hijo_izquierdo[MAXNODOS];
int Hermano_derecho[MAXNODOS];
int Padre[MAXNODOS];
int clave[MAXNODOS];
int dato1[MAXNODOS];
} arbol;
1 2 3
4 5 6 7 8 9 10 11 12
En un ambiente de grafos, es de interés describir un árbol que interconecta todos los vértices sin
formar circuitos. Se ilustra un árbol de un grafo orientado, en el que se muestra el número
asociado a cada vértice; se ha considerado el vértice 0, esto debido a que los arreglos en C,
parten de índice cero.
Vértice 0 1 2 3 4 5 6 7 8 9 10 11 12
Padre del vértice 0 0 0 0 1 1 2 2 2 3 3 3 3
La realidad suele ser más compleja que los casos idealizados que se exponen en los cursos
básicos.
El diseño de una estructura más realista, es la que describe los encabezados de los paquetes
ICMP e IP en un ambiente de red.
Primero se definen dos tipos básicos, con fines de compatibilidad. Se mapean los tipos del
lenguaje C, a tipos lógicos. Si los tamaños de los tipos básicos de un compilador para un
procesador específico son diferentes, basta cambiar el nombre de los tipos básicos. Toda la
codificación no es necesario modificarla, ya que queda en términos de u8_t y u16_t, que son los
tipos usados en el programa.
Problemas resueltos.
#include <stdlib.h>
#include <stdio.h>
#define MAX 5
typedef struct nn
{ int i1;
int i2;
struct nn * next;
} nodo, * pnodo;
pnodo lista1=NULL;
pnodo lista2=NULL;
pnodo getnodo(void)
{ pnodo p;
if( (p=(pnodo)malloc(sizeof(nodo)))==NULL) return(NULL); else return(p);
}
void crealista(void)
{ int i;
for(i=0; i<MAX; i++)
{ Push(&lista1, i, i+1); Push(&lista2, MAX-i, i-1); }
}
int main(void)
{ pnodo tt;
crealista();
printf(" %d \n", ( *(busca(lista1)) ).i1);
printf(" %d \n", busca(lista2)->i2);
if( (tt=busca2(lista1, 3, lista2)) !=NULL) printf(" %d \n", tt->i2);
return(0);
}
Solución.
a) En la función crealista, se tiene un ejemplo de uso de Push.
Consideremos el llamado: Push(&lista1, 0, 1).
Datos Stack
Lista1
newnodo ?
ref
dato1 = 0
dato2 = 1
Figura P2.1.
Nótese que los argumentos son variables almacenadas en el stack, iniciadas con los valores de
los parámetros de la invocación. La variable local newnodo, al no estar inicializada en su
definición apunta a cualquier lado. Razón por la cual, conviene definirlas e inicializarlas
simultáneamente.
Después de un llamado exitoso a getnodo(); es decir, malloc asignó una estructura en el heap, un
diagrama de la situación es el siguiente:
Lista1
newnodo i1 = ?
ref i2 = ?
dato1 = 0 next = ?
dato2 = 1
Figura P2.2.
Lista1
newnodo i1 = 0
ref i2 = 1
dato1 = 0 next
dato2 = 1
Figura P2.3
Lista1
i1 = 0
i2 = 1
next
Figura P2.4
Datos heap
Lista1 i1=4 i1 = 3 i1 = 2 i1 = 1 i1 = 0
i2 = 5 i2 = 4 i2 = 3 i2 = 2 i2 = 1
next next next next next
Lista2 i1 =1 i1 = 2 i1 = 3 i1 = 4 i1 = 5
i2 = 3 i2 = 2 i2 = 1 i2 = 0 i2 = -1
next next next next next
Figura P2.5.
c) A la función busca, se le pasa la dirección del primer nodo de la lista. Si la lista es vacía,
retorna un puntero nulo. Si no es vacía, con pp recorre la lista, dejando qq apuntado al nodo
corriente y con pp al próximo. Cuando se llega con pp, al final de la lista, qq apunta al último
nodo de la lista.
d) La función busca2 intenta encontrar el valor j en el campo i1 del nodo apuntado por el
argumento p. Si la lista es vacía retorna un puntero nulo; en caso de encontrar el valor j, busca a
partir del nodo apuntado por q, el valor del campo i1 que sea igual al valor del campo i2 del
nodo donde quedó apuntando p.
Si lo encuentra, retorna un puntero al nodo que cumple la condición anterior; en caso contrario,
retorna un puntero nulo.
La invocación: busca2(lista1, 3, lista2), después del primer while, deja p apuntando al segundo
nodo de la lista1, el segundo while deja q apuntando al cuarto nodo de la lista2; ya que busca en
ésta el valor 4 en el campo i1.
El diseño de la función incurre en un error frecuente, en este tipo de problemas. ¿Qué ocurre si
el valor j no se encuentra en ningún campo i1 de la lista apuntada por p?.
Cuando p tome valor nulo, intentará leer p->i1 en las primeras direcciones de memoria, las
cuales suelen pertenecer a un segmento del sistema operativo. Lo más seguro que la ejecución
del proceso aborte debido a un error de segmentación.
Se podría corregir, cambiando:
while( p->i1 != j ) p = p->next;
Imprimiría:
0
-1
0
int btoi(char *str, int *pvalor) que convierta un string binario pasado en str (Ej. "110101") a un
entero a ser retornado en pvalor (Ej. 53). La función retorna 0 si no hay error y retorna -1 si el
string binario tiene un carácter que no es '0' o '1'.
Nota: 1101 (en binario) = 1x23+1x22+0x21+1x20 = 8 + 4 + 1 = 13 en decimal
/* Uso de la funcion */
#include <stdio.h>
#include <math.h>
#include <string.h>
int btoi(char *str, int *pvalor);
main()
{
int valor=0;
char str[10];
strcpy(str, "110101");
if( !btoi(str, &valor) ) printf("string binario: %s, int:%d\n", str, valor);
}
Solución.
Una alternativa es sumar las potencias presentes de dos, desde la menos significativa.
while(i >=0)
{
if (*(str + i) == '1')
{ temp += pot; printf(" %d %d \n",temp, pot);}
else if (*(str + i) == '0') ;
else return -1; //no es 1 ó 0
i--;pot*=2;
}
*pvalor = temp;
return 0;
}
Solución.
El cast (char) c se emplea, debido a que el segundo argumento de la función es de tipo entero,
para convertirlo a carácter y efectuar la comparación con un carácter del string. No es
necesario, ya que por defecto, los valores de tipo carácter son promovidos automáticamente a
enteros con signo. Por ejemplo se puede pasar equivalentemente el valor 65 ó 0x45 ó „A‟. Esto
permite pasar caracteres de control que no son imprimibles, como argumentos. La comparación
también se podía haber explicado según: ( (int) *s == c ). Es preferible usar cast, de tal modo
de comparar valores de igual tipo.
El cast en return (char *) s; no es necesario, ya que s es de tipo puntero a char.
El cast return (char *) 0; es necesario para retornar un puntero nulo a carácter.
a) Explicar que realiza la función. Indicando las condiciones en las que retorna un cero o un uno
b) Dar un ejemplo de uso. Definiendo las variables que sean necesarias.
Solución.
a) Compara dos strings. Retorna 1 sólo si los strings son iguales. Reconoce la igualdad de dos
strings nulos. Retorna cero, si los strings son diferentes. Si los caracteres, en la misma
posición, de s1 y s2 son iguales va recorriendo ambos strings.
Si los primeros caracteres de los strings fuesen iguales, retorna un cero si string s1 es más corto
que el string s2 (ya que no se cumple la condición del while); lo mismo sucede si string 1 es más
largo que el string s2.
b) char *s1="12345678";
char *s2="12345";
clave
p1 p2 p3
4 6
p1 p2 p3 p1 p2 p3
Figura P2.6.
Solución.
a)
typedef struct moldenodo
{ int clave;
struct moldenodo *p1;
struct moldenodo *p2;
struct moldenodo *p3;
} nodo , *pnodo;
b)
pnodo creanodo(int clave)
{ pnodo p;
if ((p = (pnodo) malloc(sizeof(nodo))) == NULL) {
printf ("Memoria insuficiente para crear nodo\n");
exit(1);
}
p->clave=clave;p->p2=NULL;p->p1=NULL;p->p3=NULL;
return(p);
}
Figura P2.7.
pn->p3=pn;
pn
5
Figura P2.8.
pn
pn->p1=creanodo(3);
5
Figura P2.9.
pn->p1->p3=pn;
pn
5
Figura P2.10.
3
8
Figura P2.11.
pn->p2->p3=pn->p1;
pn
5
3
8
Figura P2.12.
c)
pn= creanodo(6); pn->p2=pn; pn->p3=pn;
pn->p1=creanodo(4); pn->p1->p1=pn->p1;
pn->p1->p3=pn; pn->p1->p2=pn;
pn
4 6
p1 p2 p3 p1 p2 p3
Figura P2.13.
#include <stdlib.h>
typedef struct nn
{ int x;
struct nn * p;
struct nn * q;
int y;
} t, *pt;
t w, z;
pt px=&w;
void main(void)
{ px->p=&z;
w.q = px->p;
px->q->q=px;
z.p = w.p;
w.x = z.x = 2;
(*px).y = 8;
(*(w.q)).y =9;
px=(pt) malloc(sizeof(t));
px->p=px->q = (pt ) 0;
px->x=px->y=12;
}
Solución.
Antes de main, el espacio de variables puede visualizarse según:
px
w
z
x
x
p q
p q
y
y
Figura P2.13.
px
p q
y
w.q=px->p;
px->p =&z
p q
Figura P2.14.
px
p q
px->q->q=px
z
z.p=w.p p q
Figura P2.15.
px
x 2
p q
w.x=z.x=2 y 8 (*px).y=8;
x 2
p q
(*(w.q)).y=9
y 9
Figura P2.16.
px =(pt)malloc(sizeof(t)); px
x 2
p q
x 12
y 8
p q
z
y 12
x 2
px ->p=px->q=(pt)0;
p q
y 9
px ->x=px->y=12;
Figura P2.17.
Ejercicios propuestos.
#include <stdio.h>
int func(int *, float *, char);
void show();
void show()
{ printf("\ni1= %d i2= %d" ,i1,i2);
printf("\nf1= %f f2= %f",f1, f2); }
#include <stdio.h>
void main(void)
{ arreglo(1), show();
arreglo(2), show();
arreglo(a[2]+*a+3), show();
while(1);
}
void f1(int& i)
{ i = (i | 0x8000); }
void f2(int& i)
{ i = (i>>1); i = (i&0x7fff); }
void prt(int i)
{ int j;
for (j=15; j>=0; j--)(1<< j ) &i ? printf( '1') : printf( '0'); printf( '\n');
}
#include <stdio.h>
int f1(int& , int);
int f2(int *, int);
void main()
{ int k, m =1;
k = f1(m,5); printf(“%d \n %d “, k, m);
k = 2; m = 3;
#include <stdio.h>
struct punto{
int x;
int y; };
struct molde{
int a[2];
char c;
punto p; };
void main()
{
molde m1={{2,4},'p',{3,5}}, m2={{5,6},'q',{7,1}};
molde *pm=&m2;
int *pi=&(m1.p.y);
printf(“%d \n”, *pi ) ;
printf(“%d \n”, cout << m2.a[1] + m1.p.x );
printf(“%d \n”, pm->a[1] + pm->p.x );
printf(“%d \n “, *(pi-1)) ;
pi = &m2.a[0];
printf(%d \n “, *(pi+1));
}
#include <stdio.h>
#include <stdio.h>
int arr[10];
int main(void)
{
f1(5, arr); f2(5, arr);
return(0);
}
#include <stdio.h>
int arr[10];
int *pi;
#define NULL ( (char *) 0)
int main(void)
{
void prtint(int i)
{ int j, k=1;
for (j=15; j>=0; j--) if( (k<<j)&i ) putchar(‟1‟); else putchar(‟0‟);
}
Mediante un arreglo de listas de los hijos de cada nodo. El arreglo debe tener una entrada por
cada nodo, además considerar que la raíz pueda se cualquier nodo.
CAPÍTULO 2. ............................................................................................................................................ 1
DEFINICIÓN DE ESTRUCTURAS DE DATOS EN C......................................................................... 1
2.1. TIPOS PRIMITIVOS. ............................................................................................................................. 1
2.2. MECANISMOS DE ESTRUCTURACIÓN. ................................................................................................. 1
2.2.1. Grupos básicos. ......................................................................................................................... 1
2.2.2. Vínculos. .................................................................................................................................... 2
2.3. EJEMPLOS BASADOS EN ARREGLOS. ................................................................................................... 3
2.3.1. Acceso a componentes del arreglo. ........................................................................................... 3
Definición de matrices. .................................................................................................................................... 4
Definición de arreglo de arreglos. .................................................................................................................... 5
Arreglo de punteros a renglones. ..................................................................................................................... 5
Arreglo de punteros a caracteres. ..................................................................................................................... 6
2.3.2. Lista simplemente enlazada en base a cursores. ....................................................................... 6
2.4. EJEMPLOS BASADOS EN ESTRUCTURAS. ............................................................................................. 7
2.4.1. Estructura para fecha. ............................................................................................................... 7
2.4.2. Lista simplemente enlazada en base a punteros. ....................................................................... 9
2.5. ESTRUCTURAS MÁS COMPLEJAS....................................................................................................... 10
2.5.1. Arreglo de listas. ..................................................................................................................... 10
2.5.2. Arreglo de estructuras. ............................................................................................................ 11
2.5.3. Multiárboles. ........................................................................................................................... 11
2.5.3.1. Descripción mediante punteros. ........................................................................................................ 12
2.5.3.2. Descripción mediante arreglos de cursores. ...................................................................................... 12
2.5.3.3. Descripción por arreglo de padres. .................................................................................................... 13
2.6. UN EJEMPLO REAL DE ESTRUCTURAS. .............................................................................................. 14
PROBLEMAS RESUELTOS. ........................................................................................................................ 15
P2.1. Se tiene el siguiente programa: ............................................................................................... 15
P2.2. Escribir una función: ............................................................................................................... 20
P2.3. Se tiene la siguiente función: ................................................................................................... 21
P2.4. Se tiene la siguiente función: ................................................................................................... 22
P2.5. Se tiene la estructura para un nodo, ........................................................................................ 22
P2.6. Se tiene el siguiente programa. ................................................................................................ 26
EJERCICIOS PROPUESTOS. ....................................................................................................................... 29
E1. Determinar qué imprimen los siguientes segmentos: ................................................................ 29
E2. Colocar paréntesis y evaluar las expresiones siguientes: ........................................................... 29
E3. Se tiene el siguiente programa: .................................................................................................. 29
E4. Escribir programa. ..................................................................................................................... 30
E5. Indicar que escribe el programa. ................................................................................................ 30
E6. Indicar qué imprime el programa. ............................................................................................. 30
E7. Indicar qué imprime el programa. .............................................................................................. 31
E8. Indicar qué imprime el programa. .............................................................................................. 31
E9. Indicar qué escribe el programa. ................................................................................................ 32
E10. Determinar la salida. ................................................................................................................ 32
E11. Determinar la salida. ................................................................................................................ 33
E12. Determinar la salida. ................................................................................................................ 33
E13. Explicar que realizan las funciones. ......................................................................................... 34
E14. Describir un multiárbol ............................................................................................................ 34
Índice de figuras.
Capítulo 3
Administración de la memoria en C.
Los datos se almacenan en uno de los tres segmentos de memoria que el programador dispone.
La zona estática para datos, que permite almacenar variables globales durante la ejecución de un
programa.
El stack que permite almacenar los argumentos y variables locales durante la ejecución de las
funciones.
heap
Direcciones
stack
altas
Estas variables son visibles para todas las funciones que estén definidas después de ellas.
El compilador asigna un espacio determinado para las variables y genera las referencias para
accesar a las variables del stack y de la zona estática. El tamaño de las variables de estas zonas
no puede cambiarse durante la ejecución del programa, es asignado en forma estática.
El tiempo de vida de las variables de la zona estática es la duración del programa; las variables
denominadas automáticas, o en la zona del stack, existen durante la ejecución de la función que
las referencia.
Los argumentos y variables locales, son asignados y desasignados en forma dinámica durante la
ejecución de las funciones; pero en forma automática, el programador no tiene responsabilidad
en ese proceso.
Es importante notar que varias funciones pueden emplear los mismos nombres para las variables
locales y argumentos y esto no provoca confusión; existe independencia temporal de las
variables de una función. Si se emplea una global, con igual nombre que una local, dentro de la
función se ve la local; y fuera existe la global.
Por la misma razón, no pueden comunicarse los valores de variables locales de una función a
otra.
Cuando un programa se carga en la memoria, desde el disco, sólo se traen la zona de códigos y
los datos de la zona estática. Las zonas de stack y heap, son creadas en memoria antes de la
ejecución del programa.
Cada función al ser invocada crea un frame o registro de activación en el stack, en el cual se
almacenan los valores de los argumentos y de las variables locales. Los valores de los
argumentos son escritos en memoria, antes de dar inicio al código asociado a la función. Es
responsabilidad de la función escribir valores iniciales a las variables locales, antes de que éstas
sean utilizadas; es decir, que aparezcan en expresiones en donde se leen estas variables.
int x=7;
/*En este punto aún no existen las variables: local1, local2, arg1 y arg2. */
x = función1(4, 8);
/*Desde aquí en adelante no existe espacio asociado a los argumentos y variables locales de la
función 1 */
El diagrama de la Figura 3.1, ilustra el espacio de memoria asignado a las variables, después de
invocada la función y justo después de la definición de la variables local2. La variable local1, no
está iniciada y puede contener cualquier valor.
x 7 local1
local2 5
Frame de función1
arg1 4
arg2 8
Al salir de la función, el espacio de memoria asignado a las variables, puede visualizarse según:
x 20
Los diagramas anteriores describen lógicamente el espacio asignado en la memoria para las
variables estáticas y automáticas. Cada compilador implementa físicamente la creación de estos
espacios de manera diferente; en el frame de la función se suelen almacenar: la dirección de
retorno, los valores de los registros que la función no debe alterar; además del espacio para
locales y argumentos. Adicionalmente cada compilador establece convenios para pasar los
valores y obtener el retorno de la función, en algunos casos lo hace a través de registros, en
otras a través del stack. El uso detallado del stack según lo requiere un programador assembler
es cubierto en cursos de estructuras de computadores.
Se describe aquí una visualización lógica del segmento del stack, que es la que requiere un
programador en lenguajes de alto nivel.
Ejemplo 3.2. Riesgos de la desaparición del frame.
La siguiente función plocal retorna un puntero a una variable local, lo cual es un gran error, ya
que al salir de la función, deja de existir la local; y el puntero retornado apunta a una dirección
del stack que no está asignada.
int* plocal(void)
{
int local;
// ****
return(&local); // retorna puntero a local
}
La función que invoca a plocal posiblemente produzca una falla seria del programa, o generará
un error de difícil depuración.
Tampoco puede tomarse la dirección de una variable local, declarada de tipo registro. Ya que
los registros no tienen asociada una dirección de la memoria. El calificar una variable local o a
un argumento de tipo register, es una indicación para que el compilador intente emplear
registros en su manipulación, con ventajas temporales de acceso.
Puede llamarse a una función con los valores de los argumentos o variables locales (o más
generalmente mediante una expresión de éstas) de otra función. Sin embargo la función que es
llamada, crea una copia de los valores de sus argumentos, en su propio frame. Lo cual garantiza
la independencia de funcionamiento de las funciones, ya que una función que es invocada por
otra, puede cambiar sus argumentos sin alterar los argumentos de la que la invocó.
A su vez esta organización implica crear la copia, lo cual puede ser muy costoso en tiempo si el
tipo del argumento tiene un gran tamaño en bytes. Para solucionar el problema de la copia, se
puede pasar una referencia a un argumento de grandes dimensiones, esto se logra pasando el
valor de un puntero (se copia el valor del puntero, el cual ocupa normalmente el tamaño de un
entero). Esto se verá en pasos de argumentos por referencia.
Ejemplo 3.3. Una función f que invoca a otra función g.
Se tienen las siguientes definiciones de las funciones. Nótese que los nombres de los
argumentos y una de las locales tienen iguales nombres.
Se ilustran los frames, con el estado de las variables, después de los printf.
3 local1 c 16
x
local2 d 5
Frame de f
arg1 a 5
arg2 b 6
Al entrar en g, se copian valores en los argumentos. Los frames se apilan hacia arriba, lo cual se
muestra en la Figura 3.4.
Frame activo
x 3 local1 c ?
arg1 b 22 Frame de g
arg2 a 5
local1 c 16
local2 d 5
Frame de f
arg1 a 5
arg2 b 6
Frame activo
x 3 local1 c 31
arg1 b 22 Frame de g
arg2 a 27
local1 c 16
local2 d 5
Frame de f
arg1 a 5
arg2 b 6
3 local1 c 16
x
local2 d 31
Frame de f
arg1 a 36
arg2 b 42
x 33
int x=4;
x = función1(4, función1(2,3)) + x;
Si una función invoca a otra, en este caso invoca a la misma función, mantiene sus locales y
argumentos. Dichas variables existen hasta el término de la ejecución de la función.
Zona estática Stack
Frame activo
4 local1 10
x
local2 5 Frame de segunda
arg1 2 invocación a
función1
arg2 3
local1 ?
local2 ?
Frame de función1
arg1 4
arg2 ?
4 local1 22
x
local2 5
Frame de función1
arg1 4
arg2 13
Finalmente queda x con valor 29, y no está disponible el frame de la función en el stack.
3.2.4. Recursión.
Se define recursión como un proceso en el cual una función se llama a sí misma repetidamente,
hasta que se cumpla determinada condición.
Un algoritmo recursivo puede usarse para computaciones repetitivas, en las cuales cada acción
se plantea en términos de resultados previos. Dos condiciones deben tenerse en cuenta en estos
diseños: Una es que cada llamado a la función conduzca a acercarse a la solución del problema;
la otra, es que se tenga un criterio para detener el proceso.
En general los diseños recursivos requieren más espacio de memoria y ocupan mayor tiempo en
ejecutarse. Sin embargo generan diseños simples cuando las estructuras de datos quedan
naturalmente definidas en términos recursivos. Es el caso de los árboles y de algunas funciones
matemáticas.
Más adelante veremos una estructura básica de datos, denominada stack de usuario (no
confundir con el stack que maneja las variables automáticas). Se puede demostrar que un
algoritmo recursivo siempre se puede plantear en forma iterativa con la ayuda del stack de
usuario. Y un programa iterativo, que requiera de un stack de usuario, se puede plantear en
forma recursiva (sin stack).
Si existe una forma recursiva de resolver un problema, entonces existe también una forma
iterativa de hacerlo; y viceversa.
factorial( 0 ) = 1
factorial( n ) = n * factorial( n-1 )
La condición para detener la recursión, el caso base, es que factorial de cero es uno. También se
puede detener con factorial(1) = 1.
Ejemplo 3.5. Diseño recursivo.
El siguiente diseño recursivo, condiciona la re-invocación de la función cuando se llega al caso
base:
El diseño está restringido a valores de n positivos y pequeños; ya que existe un máximo entero
representable, y la función factorial crece rápidamente.
La metodología empleada en C de pasar los argumentos de las funciones por valor, logra la
independencia temporal de las variables, lo cual obliga a copiar los valores de los argumentos en
el frame de la función, antes de ejecutar las acciones asociadas a ésta.
Otra limitación del diseño es que una función sólo puede retornar un valor, de esta forma la
función sólo se comunica con una única variable mediante la asignación del valor retornado.
Ejemplo 3.7. Comunicación del valor retornado por una función.
Veamos un ejemplo simple:
Se tiene visible una variable x. Se modifica el valor almacenado en x, con el resultado de la
invocación a una función f, de dos argumentos, que retorna un entero:
int x;
x= f(3, 5);
Donde a y b deben ser variables visibles, o dentro del alcance de la función, e inicializadas si
son variables locales. Antes de invocar a la función, se evalúan las expresiones y se copian los
valores resultantes en el espacio de memoria del frame de la función. Luego de la ejecución de
las acciones de la función, el único valor retornado por la función es copiado en el espacio
asignado a x.
El programador debe asegurar que los tipos declarados para los argumentos formales, en la
definición de la función, sean compatibles con los tipos resultantes de las expresiones en el
momento del llamado a la función. También es responsabilidad del programador que el tipo
declarado para el retorno de la función sea compatible con el tipo de la variable que recibe el
resultado de la función.
x = f(a, b) + c
Estas limitaciones pueden superarse si la función escribe o lee variables globales. En este caso
no hay restricciones para el número de variables que una función puede modificar. Lo anterior
también elimina el tener que copiar los valores en argumentos, pudiéndose diseñar funciones
con menor número de argumentos de entrada. Esta práctica genera efectos laterales de difícil
depuración, en un ambiente en el que varias personas diferentes diseñan las funciones de un
programa.
El concepto de puntero permite pasar la referencia a una variable que existe fuera de la función,
eliminando, de esta forma, la copia de variables de grandes dimensiones, ya que sólo es preciso
copiar el valor del puntero.
Si se elige que el retorno de la función entregue la suma de los argumentos, es preciso agregar
un argumento que pase el valor de la dirección de variable externa a la función, en donde se
escribirá la diferencia:
Es conveniente que el código de la función sólo escriba una vez, en la variable pasada por
referencia. Lo cual delimita los efectos laterales.
Nótese que la declaración del tipo de la variable por referencia, recuerda cómo debe ser
empleada la variable dentro de la función. Desde este punto de vista conviene colocar el
asterisco precediendo a la variable.
Donde los valores 3 y 4, pueden ser reemplazados por expresiones con valores enteros.
Si se tuviera definida una variable para almacenar el valor del puntero a la variable d, según:
c = f(3, 4, vpd);
Ejemplo de uso:
pi pd=&d;
c = f(3, 4, pd);
Retorno de estructura.
Esta forma posibilita devolver más de un resultado en el retorno de la función.
c = g(3, 4);
Retorno en arreglo.
Un caso particular es cuando todos los valores que se desean retornar son de igual tipo. En esta
situación se puede efectuar los retornos en un arreglo.
g2(5, 2);
printf(" %d %d\n", a[0], a[1]);
Se puede diseñar una función g3, que escriba en las dos primeras posiciones de un arreglo que
se pasa por referencia:
Ahora el llamado se efectúa pasando como tercer argumento el nombre del arreglo.
g3(3, 4, a);
printf(" %d %d\n", a[0], a[1]);
En general, en el lenguaje C, los arreglos, strings y estructuras se manipulan con funciones a las
cuales se les pasa una referencia. Las funciones pueden retornar una referencia a una
agrupación.
Ejemplo 3.9. Árbol binario.
Veamos un primer ejemplo de una estructura que puede ocupar grandes dimensiones.
Se tienen los tipos de datos: nodo y pnodo, definidos según:
3
pn
1 6
4 8
Nótese que para cada nodo: los valores de los nodos que están vinculados por la derecha son
mayores que los valores de los nodos que están conectados por la izquierda. En la Figura 3.10,
se tienen dos variables de tipo puntero a nodo, una apunta a un nodo denominado raíz, el otro
(pn) es una variable auxiliar.
Se desea diseñar una función que encuentre el nodo con mayor valor.
No es razonable pasar como argumento el árbol completo a la función, por el espacio requerido
en el stack, y porque sería necesario además copiar la estructura completa. Entonces podemos
pasar un puntero a un nodo como argumento, esto sólo requiere crear el espacio y copiar el valor
de un puntero (normalmente el tamaño ocupado por un entero). El retorno de la función, será un
puntero al nodo que tenga el mayor valor.
pnodo busca_max(pnodo T)
{ if (T != NULL)
while (T->right != NULL) T = T->right; /* Iterativo. Siempre por la derecha */
return(T);
}
La función considera que se la podría invocar con un argumento apuntando a un árbol vacío, en
ese caso se decide retornar un puntero con valor nulo, que indica un árbol (o subárbol vacío).
Teniendo en cuenta la propiedad del árbol binario, basta descender por la vía derecha, hasta
encontrar un nodo sin descendiente u hoja.
Un ejemplo de uso:
pn = busca_max(raiz);
pn = busca_max(raiz->left);
La definición de un string como arreglo de caracteres, debe incluir un espacio para el carácter
fin de string (el carácter NULL, con valor cero). Quizás es mejor definir el terminador de string
como null (o eos: end of string), para evitar confusiones con el valor de un puntero nulo.
str
\0
Figura 3.11. Representación en memoria de un string.
Con la definición anterior el string almacenado en el arreglo puede tener un largo máximo de
cinco caracteres.
El nombre del arreglo str, es un puntero constante que apunta al primer carácter del string, por
ser constante no se le puede asignar nuevos valores o modificar. Las direcciones de memoria
deben considerarse consecutivas; en la dirección más alta se almacena el fin de string.
b) Puntero a carácter.
La definición de un string como un puntero a carácter, puede ser inicializada asignándole una
constante de tipo string. La que se define como una secuencia de cero o más caracteres entre
comillas dobles; el compilador agrega el carácter ‘\0’ automáticamente al final.
Si dentro del string se desea emplear la comilla doble debe precedérsela por un \.
char * str1 = "123456789"; /* tiene 10 caracteres, incluido el NULL que termina el string.*/
Un argumento de tipo puntero a carácter puede ser reemplazado en una lista de parámetros, en
la definición de una función por un arreglo de caracteres sin especificar el tamaño. En el caso
del ejemplo anterior, podría escribirse: char str1[ ]. La elección entre estas alternativas suele
realizarse según sea el tratamiento que se realice dentro de la función; es decir, si las
expresiones se elaboran en base a punteros o si se emplea manipulación de arreglos.
Nótese que str ocupa el espacio con que fue definido el arreglo, mientras que str1 es un puntero.
str1
1
2
3
4
5
6
7
8
9
\0
Los argumentos y valor de retorno son punteros a carácter. Lo cual evita la copia de los
argumentos. A continuación se dan explicaciones de diversos aspectos de la sintaxis del
lenguaje.
destino
cp fuente
El diagrama ilustra los punteros fuente y cp, después de haberse realizado la copia del primer
carácter. Se muestra el movimiento de copia y el de los punteros.
El operador de postincremento opera sobre un left value (que recuerda un valor que puede
colocarse a la izquierda de una asignación). Un lvalue es un identificador o expresión que está
relacionado con un objeto que puede ser accesado y cambiado en la memoria.
( * (fuente++) ) .
Puede evitarse la acción doble relacionada con los operadores de pre y postincremento, usando
éstos en expresiones que sólo contengan dichos operadores. En el caso de la acción de
repetición:
while(*cp++ = *fuente++) continue;
Puede codificarse:
while( *cp = *fuente) {cp++, fuente++};
Sin embargo los programadores no suelen emplear esta forma. Adicionalmente no producen
igual resultado, ya que en la primera forma los punteros quedan apuntando una posición más
allá de los caracteres de fin de string; la segunda forma deja los punteros apuntando a los
terminadores de los strings. Ambas formas satisfacen los requerimientos de srtcpy.
Cuando en la lista de parámetros de una función aparece la palabra reservada const precediendo
a una variable de tipo puntero, el compilador advierte un error si la función modifica la variable
a la que el puntero apunta. Además cuando se dispone de diferentes tipos de memorias (RAM,
EEPROM o FLASH) localiza las constantes en ROM o FLASH. Si se desea que quede en un
segmento de RAM, se precede con volatile, en lugar de const.
Un ejemplo de uso.
#include <string.h>
#include <stdio.h>
char string[10]; /*crea string con espacio para 10 caracteres */
char * str1 = "abcdefghi"; /* tiene 10 caracteres, incluido el NULL que termina el string.*/
int main(void)
{ strcpy(string, str1);
printf("%s\n", string);
printf(str1); //sin string de formato
return 0;
}
Note que en la invocación se pasan los nombres de los strings, que son considerados punteros
constantes. En lugar de string, se podría haber escrito: &string[0].
No se ocupa el retorno de la función, en este caso se usa como procedimiento no como función.
Debido a que en el lenguaje C, la asignación es una expresión, y por lo tanto tiene un valor, se
puede escribir:
Las versiones 3 y 4, reflejan en sus argumentos que el diseño está basado en punteros.
La última versión, strcpy4 puede ser difícil de entender. Empleando un compilador que
optimice el código assembler en velocidad, entrega costos similares para los cuatro diseños.
En este caso la versión mediante arreglos emplea una instrucción menos que las versiones con
punteros. La decisión de cual es el mejor código resultará de la comparación de los ciclos de
reloj que tarda la ejecución de las instrucciones del bloque repetitivo, ya que cada instrucción
puede durar diferentes ciclos de reloj.
La invocación a las funciones se logra pasando los argumentos vía registros R12 y R14.
La moraleja de esto es escribir código del cual se tenga seguridad de lo que realiza.
El compilador asigna un espacio determinado para las variables y genera las referencias para
accesar a las variables del stack y de la zona estática. El tamaño de las variables no puede
cambiarse durante la ejecución del programa, es asignado en forma estática.
El tiempo de vida de las variables de la zona estática es la duración del programa; las variables
denominadas automáticas, o en la zona del stack, existen durante la ejecución de la función que
las referencia. Los frames en el stack, son asignados y desasignados en forma dinámica durante
la ejecución de las funciones; pero en forma automática, el programador no tiene
responsabilidad en ese proceso.
Este mecanismo permite al programador tener un mayor control de la memoria, tanto en tamaño
como en tiempo de vida, pero al mismo tiempo le da la responsabilidad de administrarla
correctamente. Un arreglo en la zona estática debe ser definido con un tamaño determinado, el
cual no puede cambiar durante la ejecución del programa, sin embargo en el heap se puede
solicitar un arreglo del tamaño que se desee, siempre que no exceda el tamaño máximo asignado
al heap.
Escribir programas que manejen el heap es notablemente más difícil, por esta razón lenguajes
más modernos efectúan automáticamente la programación del heap, y no le permiten al
programador realizar esta tarea. Algunos errores de programas que manejan el heap, compilan
correctamente, sin embargo al ejecutarlos se producen errores difíciles de depurar.
En <stdlib.h> están los prototipos de las funciones de biblioteca que asignan y desasignan
bloques de memoria. Describiremos las dos fundamentales.
El programador debe asignar el valor retornado a una variable de tipo puntero, estableciendo la
referencia; dicho puntero debe existir en alguna de las otras zonas de memoria. La única forma
de establecer la referencia es mediante un puntero, que inicialmente debe apuntar a NULL.
El contenido del bloque debe ser inicializado antes de ser usado, ya que inicialmente contiene
los valores que estaban previamente almacenados en el bloque del heap.
Suele emplearse el operador sizeof(tipo o nombre_de_variable) que retorna un valor entero sin
signo con el número de bytes con que se representa el tipo o la variable, para calcular el tamaño
del bloque que debe solicitarse.
3.3.2.2. void free(void * puntero)
Free libera el bloque apuntado por puntero y lo devuelve al heap. El valor del puntero debe
haber sido obtenido a través de malloc; produce errores difíciles de depurar invocar a free con
un puntero no devuelto por malloc.
Después de ejecutar free, el programador no puede volver a referenciar datos a través de ese
puntero; debería asignarle NULL, para prevenir errores. Tampoco puede liberarse un bloque
más de una vez.
Es importante liberar el bloque cuando el programa no siga utilizando los datos que almacenó
en el bloque, de esta forma puede volver a utilizarse dicho espacio. En caso de no hacerlo, van
quedando bloques inutilizables en el heap, lo cual origina en el largo plazo la fragmentación y
finalmente el rebalse de éste.
El administrador del heap, que es invocado a través de las funciones anteriores, mantiene sus
propias estructuras de datos en las cuales registra los bloques que están ocupados y los que están
disponibles, y también el tamaño de los bloques. También interactúa con el sistema operativo
para solicitar memoria adicional en caso de crecimiento del heap.
Ejemplo 3.11. Arreglo dinámico de enteros.
El siguiente ejemplo emplea una función para crear, usar y liberar un arreglo dinámico de
enteros de tamaño determinado por el argumento size de la función.
Es práctica recomendable condicionar la asignación inicial del puntero Arreglo, a que exista
espacio disponible en el heap. Y salir del programa, a través de exit y comentado la causa, en
caso de no existir memoria disponible. Una alternativa es codificar, mediante assert (requiere
incluir assert.h), que aborta la ejecución si su argumento es falso:
Una vez depurado el programa, conviene emplear el código condicional en lugar de assert, ya
que esta función almacena el sendero de la función que la invoca, además del texto de la
condición y de los números de líneas del archivo fuente.
Debido a que el puntero Arreglo es variable local, debe liberarse el bloque dentro de la función.
Después de salir de la función, queda indefinida la variable Arreglo.
El esquema empleado en este ejemplo, es que la función que solicita el espacio, sea la
responsable de liberarlo.
El administrador del heap, también dispone de la función realloc que permite cambiar
dinámicamente el tamaño de un bloque, y a la vez se encarga de copiar los valores previos de
manera eficiente. El nuevo tamaño puede ser mayor o menor que el original; si es menor sólo se
copian los datos existentes; si es mayor, los nuevos elementos se consideran no iniciados. El
nuevo espacio sigue siendo contiguo, el administrador se encarga de conseguir un nuevo bloque,
si es necesario, de copiar los valores, y de liberar el bloque original.
La función CreaString retorna un puntero al string creado en el heap, e iniciado con el valor del
string fuente. Las funciones strlen y strcpy tienen sus prototipos en <string.h>
La función que invoca a CreaString es responsable de liberar el espacio, lo cual se ilustra en el
siguiente segmento:
char * texto;
free(texto);
t[i][j] ó *(*(t+i)+j)
t[i] ó *(t+i)
La creación de listas, árboles, colas, stacks, grafos, etc. puede realizarse eficientemente en el
heap.
La siguiente función solicita y asigna el nodo obtenido en el heap, si es que había espacio
disponible, retornado un puntero al nodo, y dejando iniciadas las variables del nodo.
LiberaNodo(root);
Índice general.
CAPÍTULO 3 ..............................................................................................................................................1
ADMINISTRACIÓN DE LA MEMORIA EN C. ....................................................................................1
3.1. MANEJO ESTÁTICO DE LA MEMORIA. ..................................................................................................1
3.2. MANEJO AUTOMÁTICO DE LA MEMORIA EN C.....................................................................................2
3.2.1. Asignación, Referencias y tiempo de vida. .................................................................................2
3.2.2. Argumentos y variables locales. ................................................................................................2
Ejemplo 3.1. Función con dos argumentos de tipo valor, con dos locales, retorno de entero. ......................... 3
3.2.3. Visión lógica del stack. ..............................................................................................................4
Ejemplo 3.2. Riesgos de la desaparición del frame. ......................................................................................... 4
3.2.4. Copia de argumentos. ................................................................................................................5
Ejemplo 3.3. Una función f que invoca a otra función g. ................................................................................. 5
Ejemplo 3.4. La misma función anterior invocada dos veces........................................................................... 8
3.2.4. Recursión. ..................................................................................................................................9
Ejemplo 3.5. Diseño recursivo. ...................................................................................................................... 10
Ejemplo 3.6. Diseño iterativo. ........................................................................................................................ 11
3.2.6. Parámetros pasados por referencia y valor único de retorno. ................................................11
Ejemplo 3.7. Comunicación del valor retornado por una función. ................................................................. 11
Ejemplo 3.8. Se desea diseñar función que retorne dos resultados. ............................................................... 12
Paso por referencia. ................................................................................................................................... 12
Retorno de estructura. ............................................................................................................................... 13
Retorno en arreglo. .................................................................................................................................... 14
3.2.6. Evitación de la copia de argumentos que ocupan muchos bytes. ............................................15
Ejemplo 3.9. Árbol binario............................................................................................................................. 15
Ejemplo 3.10 Manipulación de strings. .......................................................................................................... 16
Definición de string. .................................................................................................................................. 16
a) Arreglo de caracteres. ............................................................................................................................ 16
b) Puntero a carácter. ................................................................................................................................. 17
c) Strcpy. Copia el string fuente en el string destino. .............................................................................. 18
3.3. MANEJO DINÁMICO DE LA MEMORIA EN C. .......................................................................................22
3.3.1. Asignación, Referencias y tiempo de vida. ...............................................................................22
3.3.2. Funciones para el manejo del heap. ........................................................................................22
3.3.2.1. void * malloc(size_t tamaño) ............................................................................................................ 23
3.3.2.2. void free(void * puntero) .................................................................................................................. 23
Ejemplo 3.11. Arreglo dinámico de enteros. ............................................................................................. 23
Ejemplo 3.12. Strings dinámicos. .............................................................................................................. 25
Ejemplo 3.13. Matriz de enteros, de r renglones y n columnas. ................................................................ 25
Ejemplo 3.14. Crear nodo de un árbol binario........................................................................................... 26
ÍNDICE GENERAL. ....................................................................................................................................28
ÍNDICE DE FIGURAS. ................................................................................................................................29
Índice de figuras.
Capítulo 4.
Se desea tener una medida de la duración del tiempo de ejecución de un algoritmo en función
del tamaño de la entrada.
A través de llamados al sistema operativo se puede conocer el valor del reloj de tiempo real.
Invocando al reloj, antes y después de realizar el algoritmo se tendrá una medida de la duración
del tiempo de ejecución. Sin embargo esta medida es muy dependiente del hardware (memoria,
reloj, procesador), del sistema operativo (multitarea, multiusuario) y puede variar
significativamente dependiendo del computador, del compilador, y de la carga del sistema. Al
disponer de sistemas con multiprocesadores o que la ejecución sea distribuida también afecta
medir el tiempo con cronómetro.
Por la razón anterior como una medida del tiempo de ejecución, se considera contar las
instrucciones del lenguaje de alto nivel que son necesarias realizar.
El tamaño de la entrada debe ser precisado con más detalle. Podría ser el número de bits que
miden la información que el algoritmo procesa, pero en forma tradicional se considera el
número de elementos o componentes básicas que son sometidos al proceso.
Por ejemplo si tenemos un arreglo de n componentes, y el algoritmo tiene por objetivo, sumar
los valores de las componentes, o bien ordenar las componentes, se suele decir que n es el
tamaño de la entrada. Independientemente si el arreglo es de enteros, o de estructuras.
En general, a medida que aumenta n, las exponenciales son mayores que las polinomiales; a su
vez éstas son mayores que las logarítmicas, que son mayores que las constantes.
Veremos algunas definiciones que permiten clasificar las funciones por su orden de magnitud.
Interesa encontrar una cota superior de la complejidad temporal. Consideremos la siguiente
definición preliminar de la función O mayúscula (big oh), con la intención de clasificar
funciones polinomiales.
4n3
2n3
(n+1)2
Se advierte que T(n) queda acotada por arriba por 2n3 para n>1.5. Entonces T(n) es O(n3).
4n3
4n2
(n+1)2
Si para i=2, intentamos encontrar un c menor, por ejemplo 2, se tendrá que T(n) queda acotada
para n>2.41412:
4n2
2n2
(n+1)2
Se dice que T(n) es O(ni) , si existen c y n0 tales que: T(n) <= c ni con n>=n0
4.5. Función O.
Se dice que T(n) es O( f(n) ) , si existen c y n0 tales que: T(n) <= c f(n) con n>=n0
Sin embargo se necesita un mejor concepto para acotar el orden o magnitud de una función.
Esto considerando el primer ejemplo, en el que se podía decir que T(n) era O(n3) y también que
era O(n2).
4.6. Función .
Una mejor definición para acotar funciones, es la función que define simultáneamente cotas
superior e inferior para T(n).
3n3 + 2 n2
3n3
Aplicando la definición puede comprobarse que las funciones constantes tienen complejidad
O(1).
Una de las mayores simplificaciones para cálculos de complejidad es considerar que las
acciones primitivas del lenguaje son de costo unitario.
Por ejemplo es usual considerar que el cálculo de una expresión, la asignación, son de
complejidad O(1). Excepcionalmente, en comparaciones más detalladas de algoritmos se
cuentan aparte las comparaciones, y los movimientos o copias de datos.
Teorema de sumas.
Demostración:
Por definición:
T1(n) <= c1 f(n) para n >= n1
T2(n) <= c2 g(n) para n >= n2
Sea n0 = max(n1, n2)
Para n>=n0 se tiene: T(n) = T1(n) + T2(n) <= c1 f(n) + c2 g(n) (1)
Corolario.
Ejemplo:
O( n2 + n ) = O (n2) para valores de n tales que n2 > n.
Demostración.
Por definición:
T1(n) <= c1 f(n) para n >= n1
T2(n) <= c2 g(n) para n >= n2
Sea n0 = max(n1, n2)
Ejemplos:
O( 3 n2 ) = O (n2) ya que 3 es O(1), y n2 es O(n2).
La regla del producto también puede aplicarse en: n*O( n ) = O (n2)
O(1)
O( f (n)) O( g (n))
O(1)
O(1)
O( f (n))
O( g (n)) O( g (n))
O( f (n))
O(1)
j n j n j i
La cual puede componerse según: j j j
j i 1 j 1 j 1
Pero son conocidas las sumas de los enteros y los cuadrados de los números:
j n n(n 1)
j O(n 2 )
j 1 2
j n n(n 1)(2n 1)
j O ( n3 )
2
j 1 6
Reemplazando la primera fórmula en las sumatorias del lado derecho, se logra:
j n j n j i n(n 1) i (i 1)
j j j
j i 1 j 1 j 1 2 2
Sea:
T(n) = T(n/2) + c con T(1) = c.
Si a partir del caso conocido se van evaluando casos más complejos, pueden obtenerse:
T(1) = c = c = 0+c
T(2) = T(1) + c = 2c = c + c
T(4) = T(2) + c = 3c = 2c + c
T(8) = T(4) + c = 4c = 3c + c
T(16) = T(8) + c = 5c = 4c + c
Nótese que se han evaluado en números que son potencias de dos, para obtener con facilidad
una expresión para el término general.
Con 2i = n, se tiene sacando logaritmo de dos en ambos lados:
log2 (2i) = log2(n), pero log2(2i) = i log2(2) = i. Resultando i = log2(n).
2log2(n)
c(log2(n) +1)
log2(n)
n0
Las gráficas de la Figura 4.7, muestran que para T(n) existen c1 y c2 que la acotan por encima y
por debajo, para n> 2,2. Finalmente, se tiene que la solución de la ecuación de recurrencia es:
O(n)
O(log2(n))
Si T(n) refleja el número de instrucciones de costo unitario que deben realizarse para resolver
para n entradas, puede tenerse una medida en unidades de tiempo conociendo el valor
aproximado de la duración de una instrucción.
Si O(1) es equivalente a 1 [seg], se puede construir la siguiente tabla, en cada columna se tiene
una complejidad temporal diferente:
n 3n2 + 7n n2 n log2n
100 0,03 [seg] 0,01 [seg] 6,6 [mseg]
10.000 5 [minutos] 1,7 [minutos] 133 [mseg]
100.000 8 [horas] 3 [horas] 1,66[seg]
1.000.000 35 [días] 12 [días] 6 [seg]
Usando teoremas sobre comparación de funciones, se tiene que: O(3n2 + 7n) = O(n2 ).
La tabla muestra que las complejidades de los dos algoritmos cuadráticos son comparables en el
orden de magnitud.
min = A[0];
Si la comparación y la asignación son de costo O(1), entonces el if, en peor caso, es de costo
O(1) + O(1) = O(1).
El for se realiza (n-1) veces, su costo es: (n-1) O(1) = O(n-1) = O(n).
La concatenación de la asignación con el for es de costo: O(1) + O(n) = O(n)
Finalmente el segmento es de orden de complejidad O(n).
Algoritmo 2.
Suma = n*(n+1)/2;
Algoritmo 1.
La primera asignación a la variable suma es O(1).
El for realiza una vez la asignación inicial y n veces: test de condición, suma, e incremento de
variable de control; más un test de condición con el que se termina el for.
Para el for, entonces, se tiene: O(1) + n*(O(1)+O(1) +O(1)) + O(1) = O(n)
Algoritmo 2.
Costo de la suma, más costo de la multiplicación, más costo de la división. Es decir:
O(1)+O(1)+O(1), lo cual resulta O(1).
Un problema básico es buscar si un valor está presente en una de las componentes de un arreglo.
La búsqueda secuencial compara la clave con cada una de las componentes. La primera vez que
encuentre un elemento del arreglo igual al valor buscado se detiene el proceso. No encuentra
claves repetidas y no se requiere que el arreglo esté ordenado.
Si lo recorre en su totalidad, cuidando de no exceder los rangos del arreglo, y no lo encuentra
debe indicarlo con algún valor específico de retorno. En el diseño se considera retornar el índice
de la componente que cumple el criterio de búsqueda, se decide entonces que un retorno con
valor -1 (ya que no es un índice válido), indicará que el valor buscado no fue hallado.
La iniciación del for es O(1). El test de la condición del for es O(1), también el incremento de i
es O(1). El bloque se repite: (Sup-Inf +1) veces en peor caso.
La complejidad es:
O(1) + (Sup-Inf +1)*(O(1) +(O(1) + O(1)) +O(1) )
Simplificando:
(Sup-Inf+1) O(1) = O(Sup-Inf+1)
Se requiere tener un arreglo ordenado en forma ascendente. El algoritmo está basado en ubicar,
mediante la variable auxiliar M, la mitad del arreglo aproximadamente. Entonces o se lo
encuentra justo en el medio; o en la mitad con índices menores que M si el valor buscado es
menor que el de la componente ubicada en la mitad; o en la mitad con índices mayores que M si
el valor buscado es mayor. El costo de encontrar la mitad es de costo constante. El ajustar los
índices también es de costo constante, corresponde a los dos if then else anidados. Si se tienen n
componentes, la complejidad puede describir según:
T(n) = T(n/2) +c
while (verdadero)
{
M = (Inf + Sup)/2;
if (Clave < A[M])
Sup = M - 1;
else if (Clave > A[M])
Inf = M + 1;
else return M;
if (Inf > Sup) return (noencontrado) ;
}
}
0
1
2
…
….
M-1
M
M+1
….
….
n-3
n-2
n-1
Un aspecto importante del diseño de un bloque repetitivo es asegurar que éste termina. Al inicio
se tiene que Sup> Inf. Cada vez que Sup disminuye, Inf no cambia; y también cuando Inf
aumenta Sup no cambia. En ambos casos la condición Inf >Sup cada vez está más cercana a
cumplirse.
Si el valor buscado es menor que el menor valor contenido en el arreglo, después de algunas
iteraciones, no importando si n inicialmente es par o impar, se llega a que Inf, Sup y M apuntan
a la primera componente del arreglo, etapa en la que se compara con la primera componente,
produciendo Inf>Sup. Similar situación se produce cuando el valor buscado es mayor que la
mayor componente del arreglo. Lo cual verifica que el algoritmo siempre termina en un número
finito de pasos, y que trata bien el caso de que la búsqueda falle.
Las personas que conocen los detalles internos de un procesador saben que una suma demora
menos que una multiplicación o división; esto si los números son enteros. En el caso de
flotantes los costos son aún mayores que para enteros.
Veamos esto con más detalle. Si consideramos números de n bits, y un sumador organizado de
tal manera que sume primero los bits menos significativos, luego la reserva de salida de éstos
más los dos bits siguientes; y así sucesivamente. Si se considera O(1) el costo de la suma de un
bit, entonces la suma de dos enteros de n bits será O(n). Esta estructura de un sumador con
propagación ondulada de la reserva puede mejorarse con circuitos adicionales para generar
reservas adelantadas.
Esto implica efectuar n sumas si los operandos son de n bits. Lo cual implica un costo O(n2)
para la multiplicación, considerando que cada suma es de costo O(n). Razón por la cual se
suelen diseñar unidades de multiplicación, en hardware, con mejores algoritmos.
/* retorna m*n */
unsigned int multipliquelineal(unsigned int m, unsigned int n)
{ unsigned int r=0;
while ( n>0 )
{ r+=m; n--; }
return(r);
}
/* retorna m*n */
unsigned int multipliquelog(unsigned int m, unsigned int n)
{ unsigned int r=0;
while ( n>0 )
{ if(n&1) r+=m;
m*=2; n/=2;
}
return(r);
}
Resultado obtenido antes, como solución de una ecuación de recurrencia, con costo logarítmico:
O(log2n); concluyendo que la segunda rutina, es mucho mejor que la primera. Debe notarse que
en este ejemplo se emplea el valor de uno de los operandos como el tamaño de la entrada.
Dependiendo de lo que consideremos como costo unitario, un algoritmo puede tener costos muy
diferentes.
En su peor caso, con denominador unitario, y n el máximo representable, se tiene costo: O(n).
El primer while duplica el denominador hasta que sea mayor que el numerador.
Por ejemplo para operandos de 8 bits, el mayor representable será: 11111111 en binario, lo cual
equivale a 255. Si d es 1, el while (en su peor caso) genera los siguientes valores para dd: 2, 4,
8, 16, 32, 64 128, 256. Es decir se realiza 8 veces. Lo cual también se puede calcular mediante
log2(256) = log2(28) = 8.
En su peor caso con denominador unitario y con el máximo valor para n, se tienen log2n
repeticiones.
El segundo while divide por 2 el tamaño de dd, cada vez que se realiza el lazo, su costo es:
O(log2dd).
Sumando estas complejidades, en un peor caso, se tendrá que O(log2n). Con n el mayor número
representable.
Conclusión: En los algoritmos que estudiaremos, en este curso, es importante tener claridad
acerca del significado del costo O(1).
Se obtiene, finalmente:
T(n) = n c *( log2(n) + 1)
2n(log2(n))
n(log2(n)+1)
n(log2(n))
n2
n(log2(n))
La n*log(n) crece mucho más rápidamente que la lineal, lo que se muestra a continuación:
n(log2(n))
n2
n(lo
g2(n n(log2(n))
))
n
log2(n)
Cuando un programador principiante encuentra que el primer algoritmo que se le ocurrió, para
resolver un problema, es O(n2), es posible que le sorprenda la existencia de un algoritmo (que
estudiaremos en este texto) de complejidad O(nlog(n)).
Lo mismo puede decirse de primeros intentos de diseño de algoritmos que conducen a uno de
costo O(n), que pueden ser planteados con complejidad O(log(n)).
a0 xk a1 x k 1 a2 x k 2 ... ak 0
i k
T (n) ci xin
i 1
Ejemplo 4.6.
La ecuación de recurrencia para cálculos de complejidad en árboles AVL, queda dada por la
ecuación de recurrencia de segundo orden, de Fibonacci, con n 2 :
T (n) T (n 1) T (n 2)
1 1 5 n 1 1 5 n
T (n) ( ) ( )
5 2 5 2
Se obtiene el gráfico:
> plot([.4472135952*1.618033988^n,.1*1.618^n,1.618^n],n=20..30,
thickness=2,color=[black,red,blue]);
( x x1 )m ( x x2 ) ... ( x xk m1 ) 0
i m i k
T (n) ci n x i 1 n
1 cx n
i i m 1
i 1 i m 1
i m
c n
i 1
i
i 1 n
x (c1n0 c2 n1 ... cm n m1 ) x1n
1
La segunda sumatoria es una combinación lineal de las (m-k) raíces diferentes restantes.
Ejemplo 4.7.
Para la ecuación, con n 3 :
T (n) 5T (n 1) 8T (n 2) 4T (n 3)
Una gráfica del polinomio, muestra que tiene una raíz en x=1.
> plot(x^3-5*x^2+8*x-4,x=0.5..3);
x3 5 x 2 8 x 4
x 2 4 x 4 ( x 2) 2
( x 1)
x3 5 x 2 8 x 4 ( x 2) 2 ( x 1) 0
> solve({c1+c3=0,2*c1+2*c2+c3=2,4*c1+8*c2+c3=8},{c1,c2,c3});
T ( n) n 2 n
Empleando Maple:
> S1:=rsolve({T(n)= 5*T(n-1)-8*T(n-2)+4*T(n-3),
T(0)=0,T(1)=2,T(2)=8},T(n)):
> simplify(factor(S1));
2n n
Puede graficarse la función y sus cotas, mediante:
> plot([S1,2*S1,0.5*S1],n=4..9,color=[black,blue,red]);
Ejemplo 4.8.
Sea la relación de recurrencia no homogénea, con n 1:
T (n) 2T (n 1) 3n
T (n 1) 2T (n) 3n 1
T (n) c1 3n c2 2n
T (0) c1 30 c2 20 0
T (1) c1 31 c2 21 3
Se obtienen: c1 3, c2 3
Finalmente:
T (n) 3 3n 3 2n
En Maple:
> S4:= rsolve( { T(n)-2*T(n-1) =3^n, T(0) = 0, T(1)=3}, T(n));
S4 := 3 2 n 3 3n
Pueden encontrarse dos funciones que acoten, por encima y por debajo, a la función, mediante:
> plot([S4,1*3^n,2*3^n],n=1..5,thickness=2,color=[black,red,blue]);
La cual puede tratarse como una ecuación homogénea con raíces múltiples. La raíz múltiple
estará asociada a un polinomio en n, y ya conocemos un método general para resolverlas.
Ejemplo 4.9.
T (n) 2T (n 1) n
Con T(0) =0, resultan: T(1)=1, T(2)=4; y pueden calcularse las constantes:
c1 2, c2 2, c3 1
Finalmente, la solución resulta:
x3 4x2 5x 2 0
T (n) 4T (n 1) 5T (n 2) 2T (n 3)
2T (n 1) 5T (n 2) 2T (n 3) n
Entonces para transformar en una ecuación homogénea se nos debería haber ocurrido derivar la
ecuación homogénea anterior a partir de la original no homogénea. Para esto es preciso
encontrar una expresión para n que dependa de T (n 1) , T (n 2) y T (n 3) .
T (n 1) 2T (n 2) n 1
T (n 2) 2T (n 3) n 2
Eliminando éstas en la ecuación anterior se comprueba la igualdad del lado izquierdo con n.
> plot([S3,2^n,3*2^n],n=2..6,thickness=2,color=[black,red,blue]);
Tp (n) pd (n)n mb n
Ejemplo 4.10.
Para:
T (n) 2T (n 1) 3n
Con condición inicial: T (0) 0
Th (n) c2n
Tp (n) a 3n
Con a el coeficiente de un polinomio de grado 0, que deberá determinarse:
a 3n 2(a 3n 1 ) 3n
Arreglando, resulta:
2a n
(a ) 3 3n
3
Ejemplo 4.11.
T (n) 2T (n 1) n
Con condición inicial: T (0) 0
(a n b) 2 (a(n 1) b) n
(a) n (2a b) n
De la cual se pueden plantear:
a 1
2a b 0
Entonces: Tp (n) an b n 2
La solución general: T (n) c 2n n 2
Ejemplo 4.12.
T (n) 2T (n 1) 2 n
Con condición inicial: T (0) 0
Como b es igual a la raíz de la ecuación homogénea, se tendrá con d=0, m=1, b=2, que la
solución particular resulta:
Tp (n) pd (n)n mb n a n 2n
a n 2n 2(a (n 1)2n 1 ) 2n
T (0) c 20 0 20 0
Como c 0 , la solución es:
T ( n ) n 2 n ( n 2 n )
Nótese que n debe ser 2 o mayor. Para n=2, puede calcularse: T (2) 4T (1) 2 6 ; por esta
razón la condición inicial se da con n=1.
T (2k ) 4T (2 k 1 ) 2 k
U (k ) c 4k 2k
T (2k ) c (2k ) 2 2k
Expresando en términos de n:
T ( n) c n 2 n
> plot([S6,1*n^2,2*n^2],n=2..8,thickness=2,color=[black,red,blue]);
Ejemplo 4.14.
T (n) 2T (n / 2) n
Nótese que n debe ser 2 o mayor. Para n=2, se tiene: T (2) 2T (1) 2 . Por esta razón la
condición inicial se da con n=1.
U (k ) c 2k k 2k T (2k )
T (1) c 1 1 0 1
Finalmente:
T (n) n n log 2 (n) (n log 2 (n))
En Maple:
> S7:= rsolve( { T(n)-2*T(n/2) =n, T(1) = 1}, T(n));
n ln( n )
S7 := n
ln( 2 )
La determinación del orden de complejidad se logra con:
> plot([S7,n*ln(n)/ln(2),2*n*ln(n)/ln(2)],n=2..8,thickness=2,
color=[black,red,blue]);
Si bien no es necesario conocer la función realizada por el algoritmo para efectuar el cálculo de
la complejidad temporal, la siguiente función implementa un algoritmo de ordenamiento
conocido como burbuja. Opera sobre un arreglo de enteros de n posiciones.
Se calculan las operaciones elementales asociadas a cada línea considerando como operación
elemental, a: comparaciones, asignaciones, sumas, restas, y acceso a componentes de un vector.
Línea 1: se ejecuta una asignación de inicio; una resta y una comparación de salida; una resta,
una comparación y una suma por cada una de las iteraciones del lazo.
Línea 2: se ejecutan una resta y asignación de inicio y una suma más una comparación de salida;
una suma y una comparación más una resta por cada una de las iteraciones.
Línea 3: se efectúa la condición, con 4 O(1): una diferencia, dos accesos a un vector, y una
comparación.
En el peor caso se efectúa siempre el bloque asociado al if. Tenemos entonces que el lazo
interno se realiza:
n 1
T1 (n) 2 O(1) (2 4 9 1) O(1) 2 O(1)
j i 1
(n 1) (i 1) 1 (n i 1)
n2
T (n) 1 O(1) (2 (4 16 (n i 1)) 1) O(1) 2 O(1)
i 0
Arreglando, y considerando que los términos que no dependen de i, se suman (n-1) veces, se
obtiene:
n2
T (n) 3 (7 16n 16)(n 1) 16 i O(1)
i 0
n2 n2
(n 2)(n 1)
i i
i 0 i 1 2
Se considera que las líneas 4, 5 y 6 son O(1), la acción compuesta tiene costo: O(1)+O(1)+O(1),
es decir 3O(1).
El peor caso es que se realice siempre el bloque del if, y si se considera que éste es de costo
O(1), entonces, las líneas 3, 4, 5 y 6 son 4O(1).
n2
(n 2)(n 1)
T (n) 4(n i 1) O(1) 4(n 1)(n 2) 4 O(1)
i 0 2
Resultando:
T (n) (2n 2 6n 4) O(1) O(n 2 )
Debe notarse que el costo unitario, del cálculo basado en contar instrucciones de alto nivel, es
diferente del realizado contando operaciones elementales.
Puede comprobarse que los costos, de los dos procedimientos de cálculo, difieren en una
constante:
(8n 2 n 4)
R ( n)
(2n 2 6n 4)
lim R(n) 4
n
Se obtiene, mediante:
Una aproximación para efectuar la cuenta es considerar que todas las instrucciones tienen igual
costo.
Al listado se le han agregado comentarios para hacerlo más legible, y del listado pueden
obtenerse las siguientes cuentas:
Antes de realizar i=0, se han ejecutado 9 instrucciones assembler, cuestión que no se contempla
en los cálculos anteriores; tampoco se contemplan las 6 instrucciones que se realizan para salir
de la función; corresponden a armar y desarmar el frame de la función. También aparecen las
tres instrucciones necesarias para pasar los argumentos e invocar a la función.
El desarrollo del if resulta con 11 instrucciones. La acción compuesta dentro del if, se logra con
21 instrucciones. Reinicio de los for con 3 instrucciones; evaluación de las condiciones de los
for en 4 instrucciones. Inicio del primer for con una instrucción, del segundo for con 3.
n 1
T1 (n) 7 O(1) 39 O(1) (7 39( n i 1)) O(1) (39n 32 39i) O(1)
j i 1
Existen tres adicionales, requeridas para pasarle valores a los argumentos e invocar a la función:
Alg1(a, N);
0025EE 3E400500 mov.w #0x5, R14 ;copia valor constante N en R14
0025F2 3C400011 mov.w #0x1100,R12 ;copia valor de la dirección de a en R12
0025F6 B0126E25 call #Alg1 ;Invocación de la función
Otros aspectos que muestra el código assembler, es la aritmética de punteros y las instrucciones
relativas a registros para indireccionar; además del uso de registros y saltos.
El conteo de las instrucciones dependerá de la calidad del compilador que se esté empleando, y
si dispone o no de niveles de optimización. También dependerá del procesador y su repertorio
de instrucciones, y del sistema operativo que se esté empleando. Por estas razones, en cálculos
de complejidad, no suelen contarse las instrucciones de un procesador, sino las del lenguaje de
alto nivel o del pseudo código.
El orden de complejidad, que resulta de contar las instrucciones assembler, también es (n 2 ) .
Puede compararse el costo obtenido mediante contar instrucciones assembler, con la cuenta de
operaciones elementales, mediante:
(39n 2 11n 18)
R ( n) 2
(8n 2 n 4)
lim R(n) 2
n
Se observa que R(n) tiende rápidamente a la constante dos.
Se concluye de este ejemplo, que una metodología razonablemente útil para calcular la
complejidad de un algoritmo es contar las instrucciones del lenguaje de alto nivel o del pseudo
código.
4.23. Resumen.
Problemas resueltos.
P4.1
Solución.
T(2) = T(1) + 2 = 4 = 22
T(4) = T(2) + 4 =8 = 23
T(8) = T(4) + 8 = 16 = 24
T(16) = T(8) + 16 = 32 = 25
T(n) = 2*n
Entonces: T(n) es ( n ), para todo n.
3*n
2*n
1*n
E4.1.
Dado un número n, encontrar a y b tales que: a*a+b*b = n*n, con a, b y n enteros mayores que
cero.
Determinar la complejidad temporal y su orden de crecimiento.
E4.2.
E4.3.
Calcular la complejidad:
T(n) = 3T(n–1) + 4T(n–2) si n>1; T(0) = 0; T(1) = 1.
Sol. T(n)=O(4n)
E4.4.
Calcular la complejidad:
T(n) = 2T(n–1) – (n+5)3n si n>0; T(0) = 0.
E4.5.
Calcular la complejidad:
CAPÍTULO 4. .............................................................................................................................................1
COMPLEJIDAD TEMPORAL DE ALGORITMOS. ............................................................................1
4.1. TIEMPO DE EJECUCIÓN Y TAMAÑO DE LA ENTRADA. ...........................................................................1
4.2. COMPLEJIDAD TEMPORAL. DEFINICIÓN. .............................................................................................1
4.3. TIPOS DE FUNCIONES. .........................................................................................................................2
4.4. ACOTAMIENTO DE FUNCIONES. ..........................................................................................................2
4.5. FUNCIÓN O. ........................................................................................................................................4
4.6. FUNCIÓN . ........................................................................................................................................4
4.7. COSTO UNITARIO. ...............................................................................................................................5
4.8. REGLA DE CONCATENACIÓN DE ACCIONES. REGLA DE SUMAS...........................................................5
Teorema de sumas................................................................................................................................5
Corolario. ............................................................................................................................................6
4.9. REGLA DE PRODUCTOS. ......................................................................................................................6
4.10. REGLA DE ALTERNATIVA..................................................................................................................6
4.11. REGLA DE ITERACIÓN. ......................................................................................................................7
Ejemplo 4.1. .........................................................................................................................................8
4.12. ALGORITMOS RECURSIVOS. ..............................................................................................................8
Ejemplo 4.2. Evaluando la complejidad en función del tiempo. ........................................................10
Ejemplo 4.3. Aplicación a un algoritmo sencillo. ..............................................................................10
Ejemplo 4.4. Comparación de complejidad entre dos algoritmos. ....................................................11
Ejemplo 4.5. Búsqueda en arreglos. ..................................................................................................11
4.13. BÚSQUEDA SECUENCIAL.................................................................................................................12
4.14. BÚSQUEDA BINARIA (BINARY SEARCH) .........................................................................................12
4.15. SOBRE EL COSTO O(1). ...................................................................................................................14
4.17.1. Algoritmos de multiplicación. ................................................................................................14
4.17.2. Algoritmos de división. ..........................................................................................................15
4.18. COMPLEJIDAD NLOG(N). ................................................................................................................16
4.19. COMPARACIÓN ENTRE COMPLEJIDADES TÍPICAS.............................................................................18
4.20. ESTUDIO ADICIONAL. .....................................................................................................................19
4.21. SOLUCIÓN DE ECUACIONES DE RECURRENCIA. ...............................................................................19
4.21.1. Recurrencias homogéneas. ....................................................................................................19
4.21.1.1. Raíces diferentes. ............................................................................................................................ 20
4.21.1.2. Raíces múltiples. ............................................................................................................................. 22
4.21.2. Recurrencias no homogéneas. ...............................................................................................25
4.21.2.1. Excitación potencia de n. ................................................................................................................ 25
4.21.2.2. Excitación polinómica. .................................................................................................................... 27
4.21.2.3. Método de los coeficientes indeterminados. .................................................................................... 28
4.21.2.4. Método cuando n es potencia de dos. .............................................................................................. 31
4.22. CÁLCULOS DE COMPLEJIDAD A PARTIR DEL CÓDIGO.......................................................................34
4.22.1. Cálculo de complejidad basada en operaciones elementales. ...............................................34
4.22.2. Cálculo de complejidad basada en instrucciones del lenguaje de alto nivel. ........................35
4.22.3. Cálculo de complejidad basada en instrucciones del lenguaje de máquina. .........................37
4.23. RESUMEN. ......................................................................................................................................41
PROBLEMAS RESUELTOS. ........................................................................................................................42
P4.1 ....................................................................................................................................................42
EJERCICIOS PROPUESTOS. ........................................................................................................................43
Índice de figuras.
Capítulo 5.
Conjuntos dinámicos.
5.1. Nodos.
Cada elemento o nodo se representa por una estructura, cuyos campos pueden ser leídos y
escritos a través de un puntero a la estructura.
Suele existir un campo que se denomina clave, que identifica unívocamente al nodo; otros
campos suelen contener punteros a otros nodos de la estructura. La clave puede ser numérica o
alfanumérica.
5.2. Operaciones.
5.2.1. Consultas:
Buscar un nodo de la estructura que tenga igual valor de clave, que un valor que se pasa como
argumento; retornando un puntero al nodo encontrado o NULL si no está presente.
Seleccionar un nodo de la estructura que tenga el menor o mayor valor de la clave.
Hay otras consultas que pueden hacerse, como buscar el sucesor o antecesor de un nodo.
5.2.2. Modificaciones.
Algunos algoritmos no requieren implementar todas las operaciones. Por ejemplo los que tienen
sólo las operaciones de buscar, insertar y descartar suelen denominarse diccionarios. Los
algoritmos en que sólo se busque e inserte se denominan arreglos asociativos, o tablas de
símbolos. En un diccionario puro sólo se implementa buscar.
Los principales conjuntos dinámicos que estudiaremos son: listas, stacks, colas, árboles binarios
de búsqueda, tablas de hash y colas de prioridad.
5.3. Listas.
La lista más básica es la simplemente enlazada, la que puede definirse como la secuencia de
cero (lista vacía) o más elementos de un determinado tipo. Los elementos quedan ordenados
linealmente por su posición en la secuencia. Se requiere sólo un enlace entre un elemento y su
sucesor.
Cada nodo está conectado con el siguiente mediante un puntero que es un campo del nodo.
Los elementos del arreglo se direccionan en tiempo constante, O(1). Los elementos de las listas
tienen un costo de acceso O(n), en peor caso.
Las operaciones sobre listas deben considerar que ésta puede estar vacía, lo cual requiere un
tratamiento especial; así también los elementos ubicados al inicio y al final de la lista deben
considerarse especialmente.
Los siguientes diagramas ilustran una lista vacía y una lista con tres elementos. Si los nodos se
crean en el heap, la variable lista, de la Figura 5.1, debe estar definida en el stack, o en la zona
estática, con el tipo puntero a nodo.
Note que el programador no dispone de nombres de variables para los nodos, éstos sólo pueden
ser accesados vía puntero (esto debido a que en el momento de la compilación no se conocen las
direcciones de los nodos; estas direcciones serán retornadas por malloc en tiempo de ejecución).
lista
lista
1 2 3
Se denominan listas con cabecera (header) o centinela aquellas que tienen un primer nodo al
inicio de la lista. Con esta definición algunas de las operaciones sobre listas resultan más
simples, que el caso anterior.
lista
lista
c 1 2 3
c
nodo1 nodo2 nodo3
El caso de lista vacía y las acciones con el primer o último elemento de la lista han intentado ser
resueltas agregando un nodo de encabezado o un centinela al fin de la lista. Estos elementos
facilitan que las funciones diseñadas traten en forma homogénea a todos los elementos de la
lista; por ejemplo, la inserción al inicio se trata de igual forma que la inserción en otra posición;
el costo del mayor tamaño es despreciable comparado con los beneficios.
pn
dato
El diagrama de la Figura 5.3, ilustra la situación justo antes de salir de la función. Después de
salir no existe la variable pn, ya que es automática.
lista
listaC
0
Se ha considerado valor de clave 0 en el encabezado, pero podría ser otro valor; por ejemplo,
uno que no sea usado por los valores que se almacenarán en la lista.
Debe liberarse, el espacio adquirido mediante malloc, cuando deje de usarse, y dentro del
alcance de lista, y siempre que la lista no esté vacía. Esto se logra con:
free(lista);
Si lista está definida dentro de una función, debe liberarse el espacio, antes de salir de ésta, ya
que luego será imposible liberar el espacio, debido a que las variables locales dejan de existir al
salir de la función. El ejemplo anterior libera el espacio del nodo que está al inicio de la lista; el
borrado de la lista completa requiere liberar el espacio de cada uno de los nodos.
int LargoLista(pnodo p)
{ int numeroelementos = 0;
while (p != NULL) {
numeroelementos ++;
p = p ->proximo; //recorre la lista
}
return (numeroelementos);
}
lista p->proximo
1 2 3
p numeroelementos
int LargoLista(pnodo p)
{ int numeroelementos = 0;
for( ; p != NULL; p=p->proximo) numeroelementos ++;
return (numeroelementos);
}
Otras operaciones que demandan recorrer la lista son el despliegue de los elementos de la lista o
buscar un nodo que tenga un determinado valor de clave.
b) Buscar elemento.
Se da una lista y un valor de la clave: se retorna un puntero al nodo de la lista que tiene igual
valor de clave, que el valor pasado como argumento; retorna NULL, si no encuentra dicho valor
en la lista.
Ejemplo de uso.
pnodo q;
if ( (q= Buscar(lista, 5)) == NULL) { /* no encontró nodo con clave igual a 5*/ }
else
{ /* lo encontró. …..*/ }
pnodo SeleccionarMinimo(pnodo p)
{ int min;
pnodo t;
if (p==NULL) return (NULL);
else
{min=p->clave; //Inicia min
t=p;
p=p->proximo;
}
while (p != NULL) {
if (p->clave <min ) {min=p->clave; t=p;}
p = p ->proximo; //recorre la lista. O(n)
}
return (t);
}
pnodo SelMin(pnodo p)
{ int min= INT_MAX; //requiere incluir limits.h
pnodo t=NULL;
while (p != NULL) {
if (p->clave < min ) {min=p->clave; t=p;}
p = p ->proximo; //recorre la lista. O(n)
}
return (t);
}
Supongamos que tenemos dos variables de tipo puntero a nodo: p apunta a un nodo de una lista
y n apunta a un nodo correctamente inicializado (por ejemplo, el retorno de CreaNodo). La
situación se ilustra en la Figura 5.7 a la izquierda, donde las variables n y p, se han diagramado
por pequeños rectángulos. Los nodos se han representado por círculos, con una casilla para la
clave, y otra para el puntero al nodo siguiente.
El nodo n puede ser insertado después del nodo apuntado por p. La primera escritura en un
campo de la estructura puede describirse por:
n->proximo = p->proximo;
Después de esta acción, la situación puede verse en el diagrama a la derecha de la Figura 5.7.
p p->proximo p p->proximo
1 2 1 2
n n
3 3
n->proximo n->proximo
La segunda escritura, que termina de encadenar la lista, y que necesariamente debe realizarse
después de la primera, puede describirse por:
p->proximo = n;
p p->proximo
1 2
n
3
n->proximo
Los valores que toman las variables de tipo puntero son direcciones de memoria, y no son de
interés para el programador. Es de fundamental importancia apoyarse en un diagrama para
escribir correctamente expresiones en que estén involucrados punteros. Debe considerarse que si
en el diseño se elige que las variables n y p sean los argumentos de la función que inserta un
nodo, después de ejecutada la función, automáticamente ellas dejan de existir.
int temp;
n->proximo = p->proximo;
p->proximo = n;
temp=p->clave; p->clave=n->clave; n->clave=temp; //importa el orden de la secuencia.
p p->proximo
3 2
temp
1 n
1
n->proximo
Si la lista es sin cabecera, la inserción al inicio, debe codificarse en forma especial, ya que no
existe en este caso la variable p->proximo. El inicio de la lista sin cabecera es una variable de
tipo puntero a nodo, no es de tipo nodo, y por lo tanto no tiene el campo próximo.
b) Análisis de la operación descarte.
En el descarte de un nodo, si consideramos pasar como argumento un puntero a la posición del
nodo anterior al que se desea descartar, se requiere escribir una dirección y mantener una
referencia al nodo que se desea liberar a través de free.
Entonces la variable p apunta al nodo anterior al que se desea descartar, y t apunta al nodo que
se desea desligar de la lista. Se ilustra en la Figura 5.10, la situación de las variables, después de
ejecutada la acción:
t=p->proximo;
p p->proximo
1 2 3
t t->proximo
La siguiente acción es la escritura en un campo, para mantener la lista ligada. Esto se logra con:
p->proximo = t->proximo;
p p->proximo
1 2 3
t t->proximo
Ahora puede liberarse el espacio, del nodo que será descartado, mediante:
free(t);
También puede descartarse el nodo apuntado por el argumento, pero se requiere copiar los
valores del nodo siguiente, enlazar con el subsiguiente y liberar el espacio del nodo siguiente.
También debe notarse que descartar el primer nodo requiere un tratamiento especial, ya que se
requiere escribir en el puntero a un nodo, que define el inicio, y en éste no existe el campo
próximo.
p p->proximo
1 3
?
t t->proximo
Aparentemente las operaciones de modificación de listas son sencillas, pero como veremos a
continuación aún hay detalles que analizar.
c) Análisis adicionales en operación Insertar después.
Considerando lo analizado anteriormente un primer diseño de la función es el siguiente:
El diseño considera que si la función retorna NULL, implica que la inserción falló.
La función funciona bien si la posición apunta al primer nodo, a uno intermedio o al último; ya
que todos éstos tienen el campo próximo. Pero si el argumento posición toma valor NULL, se
producirá un serio error, ya que posición->proximo apunta a cualquier parte, lo cual podría
suceder si se intenta insertar en una lista vacía sin header. Esto lleva a agregar otra alternativa en
el cuerpo de la función:
En el caso de lista con header, el argumento listaC, no será NULL, en caso de lista vacía. El
llamado: InsertarDespues(listaC, CreaNodo(1)); inserta correctamente el nuevo nodo al inicio
de la lista. El valor de retorno apunta al recién agregado a la lista.
pr nx
clave
Los diagramas describen el estado de las variables, antes y después de la operación de insertar el
nodo apuntado por q, después del nodo apuntado por p:
p
p
q q
q->nx = p->nx;
q->pr = p;
p->nx = q ;
q->nx->pr = q ;
Las operaciones de insertar, buscar y descartar deben considerar las condiciones en los bordes, y
que la lista pueda estar vacía.
Las listas circulares doblemente enlazadas con cabecera son más sencillas de implementar y
manipular. Las listas circulares simplemente enlazadas ocupan menos espacio pero su
codificación debe incluir varios casos especiales, lo cual aumenta el código necesario para
implementarlas y el tiempo para ejecutar las acciones.
lista
lista
h
Tarea: Desarrollar las operaciones: Insertar, descartar y buscar en una lista doblemente enlazada
circular.
En listas simplemente enlazadas, sin o con cabecera, puede escogerse que el último nodo apunte
al primero, con esto se logra que el primer nodo pueda ser cualquier nodo de la lista.
lista
1 2 3 4
La inserción al inicio, en el caso de la Figura 5.16, debe tratarse de manera especial, con costo
O(n), para que el último nodo apunte al nuevo primero. Si la lista es con cabecera, y si el último
apunta a la cabecera, no es necesario introducir código adicional.
La operación buscar mueve a la primera posición el elemento encontrado. De esta manera los
elementos más buscados van quedando más cerca del inicio de la lista.
Se mantiene, según el orden de la lista, los valores ordenados de las claves. La inserción
requiere primero buscar la posición para intercalar el nuevo nodo.
a) Insertar antes.
Para el diseño de la función suponemos que disponemos del valor nuevo, un puntero que apunta
a un nodo inicializado.
nuevo
dato
También disponemos del valor posición, un puntero que apunta al nodo sucesor del que será
insertado. Se ilustran dos posibles escenarios, cuando existe lista y el caso de lista vacía.
lista posición
posición
lista
1 2 3
Observamos que en caso de lista no vacía, debe escribirse en el campo nuevo->proximo el valor
del argumento posición, y retornar el valor de nuevo. Si la lista, estaba originalmente vacía no
es preciso escribir el puntero nulo en el campo nuevo->posición, si es que estaba correctamente
inicializado.
lista
nuevo
dato
1 2 3
posición
nuevo
dato
lista
1 8 2 3
7 1 2 3
1 2 3
nuevo 4
Figura 5.22. Inserción del nodo con valor 4, después del nodo 2 en Figura 5.18.
cabeza
1 2 3 4
cola
if(cabeza==NULL) cola=t;
t->proximo=cabeza; cabeza=t; //O(1)
return(t);
}
if(cola==NULL) { cola=cabeza=t;}
else { cola->proximo=t; cola=t;} //O(1)
return(t);
}
Ejemplos de uso.
Insertanodo_ref(&lista1, CreaNodo(5)); //Paso por referencia. Aparece &.
Insertanodo_ref(&lista2, CreaNodo(3)); // Se inserta en lista2.
lista1
p 1 2 3 4
t
5
En el diseño anterior, se pasa como argumento un puntero a un puntero a nodo. Lo cual permite
pasar la dirección de la variable que define la lista.
Debido a que descartar un nodo implica mantener la estructura de la lista, resulta sencilla la
operación de borrar el siguiente a la posición pasada como argumento.
pnodo Descartar(pnodo p)
{ pnodo t = p;
Borrar el nodo apuntado por p, requiere recorrer la lista, para encontrar el nodo anterior al que
se desea borrar; contemplando el caso que el nodo ha ser borrado sea el primero de la lista. Esta
operación es O(n).
5.5.1. Definición.
Describiremos ahora lo que suele denominarse stack de usuario, como una estructura de datos
que permite implementar el proceso de componentes con la política de atención: la última que
entró, es la primera en ser atendida.
El stack es una lista restringida, en cuanto a operaciones, ya que sólo permite inserciones y
descartes en un extremo, el cual se denomina tope del stack.
Debido a esta restricción suelen darse nombres especializados a las operaciones. Se denomina
push (o empujar en la pila) a la inserción; y pop (o sacar de la pila) al descarte. No suele
implementarse la operación buscar, ya que en esta estructura la complejidad de esta operación
es O(n); en algunas aplicaciones se dispone de la operación leer el primer elemento del stack,
sin extraerlo.
stack
Base del stack
0
1
Último ocupado
NumeroDeElementos 2
4 3
4
5
… Parte vacía del stack
MAXN-1
Si se desea utilizar en alguna implementación la estructura de datos stack, es una práctica usual
definir un archivo con extensión h (por header o encabezado), en el que se describen los
prototipos de las funciones asociadas al stack. Esto permite conocer las operaciones
implementadas y sus argumentos, acompañando a este archivo está el del mismo nombre, pero
con extensión .c, que contiene las definiciones de las operaciones; en éste, se suele incluir al
principio el archivo con extensión h, de tal modo que si existen funciones que invoquen a otras
del mismo paquete, no importe el orden en que son definidas, ya que se conocen los prototipos.
En el texto se incluye un archivo datos.h que permite, usando la misma técnica, definir
focalizadamente los tipos de datos que emplee la aplicación que use la herramienta stack. En
este caso en particular debe definirse el tipo de datos ElementoStack, que describe la estructura
de una componente del arreglo.
#include "datos.h"
#define push2(A, B) StackPush((B)); StackPush((A));
void StackInit(int);
int StackEmpty(void);
int StackFull(void);
void StackPush(ElementoStack);
ElementoStack StackPop(void);
void StackDestroy(void);
#endif /* __STACK_H__ */
El ejemplo también ilustra la definición de una macro: push2, que se implementa mediante el
reemplazo del macro por dos invocaciones a funciones del paquete. Note que los argumentos se
definen entre paréntesis.
El diseño de las funciones contempla tres variables globales asociadas al stack. Tope y
NumeroDeElementos, que ya han sido definidas; además emplea la global MAXN, para
almacenar el máximo número de elementos, ya que el tamaño del stack, se solicita
dinámicamente, y no está restringido a ser una constante.
Las variables globales simplifican el paso de argumentos de las operaciones; sin embargo
restringen las operaciones a un solo stack. Si la aplicación empleara varios stacks diferentes, las
funciones tendrían que ser redefinidas.
int StackFull(void)
{
return(NumeroDeElementos == MAXN) ; //Retorna verdadero si stack lleno
}
void StackDestroy(void)
{
free(stack);
}
Es buena práctica que las funciones StackInit y StackDestroy se invoquen en una misma
función, para asegurar la liberación del espacio.
El siguiente paso en el desarrollo es la descripción por seudo código, en la cual se establecen las
variables y el nombre de las funciones.
Las expresiones aritméticas que generalmente escribimos están en notación “in situ” o fija. En
esta notación los operadores se presentan entre dos operandos; por ejemplo: 2 + 3 * 4. Esta
notación no explica el orden de precedencia de los operadores; debido a esto los lenguajes de
programación tienen reglas de que establecen cuales operadores reciben primero sus operandos.
En el lenguaje C, la multiplicación tiene mayor precedencia que el operador suma; entonces, en
el caso del ejemplo, se realizará primero la multiplicación y luego la suma.
La relación entre operadores y operandos puede hacerse explícita mediante el uso de paréntesis.
La escritura de ( 2 + 3) *4 y 2 + (3 * 4) asocia operadores y operandos mediante paréntesis.
La notación inversa desarrollada por Jan Lukasiewicz (1878 - 1956) y empleada por los
ingenieros de Hewlett-Packard para simplificar el diseño electrónico de las primeras
calculadoras, permite escribir expresiones sin emplear paréntesis y definiendo prioridades para
los operadores. En esta notación el operador sigue a los operandos. La expresión infija 3 + 4
tiene su equivalente en notación inversa como: 3 4 +. Y el ejemplo inicial: 2 + 3 * 4, se
representa, en notación inversa, según: 2 3 4 * +.
Una generalización es agregar el nombre de funciones a los operadores. Normalmente las
funciones son operadores monádicos: sin[123 + 45 ln(27 - 6)]
a) Ejemplo de evaluación.
La expresión: (3 + 5) * (7 - 2) puede escribirse: 3 5 + 7 2 - *
Se emplea para convertir las expresiones infijas y evaluarlas en un stack. Para especificar el
algoritmo es preciso establecer las reglas de precedencia de operadores. La más alta prioridad
está asociada a los paréntesis, los cuales se tratan como símbolos; prioridad media tienen la
operaciones de multiplicación y división; la más baja la suma y resta.
Se asume solamente la presencia de paréntesis redondos en expresiones.
Como la notación polaca inversa no requiere de paréntesis, éstos no se sacarán hacia la salida.
Notar que el orden en que aparecen los números son iguales en ambas representaciones, sólo
difieren en el orden y el lugar en que aparecen los operadores.
Se empleará el stack para almacenar los operadores y el símbolo de apertura de paréntesis.
Una cola es una lista con restricciones. En ésta las inserciones ocurren en un extremo y los
descartes en el otro. La atención a los clientes en un banco, el pago de peaje en autopistas, son
ejemplos cotidianos de filas o colas de atención.
Si se conoce el máximo número de componentes que tendrán que esperar en la cola, se suele
implementar en base a arreglos.
Se requieren ahora dos variables para administrar los índices de la posición del elemento que
será insertado o encolado (cola, tail en inglés); y también el índice de la posición de la
componente que será descartada o desencolada en la parte frontal (cabeza. head).
- 1 2 3 4 - -
out in
cabeza cola
cabeza cola
Se observa que a medida que se consumen o desencolan componentes, van quedando espacios
disponibles en las primeras posiciones del arreglo. También a medida que se encolan elementos
va disminuyendo el espacio para agregar nuevos elementos, en la zona alta del arreglo. Una
mejor utilización del espacio se logra con un buffer circular, en el cual la posición siguiente a la
última del arreglo es la primera del arreglo.
La variable cola puede variar entre 0 y N-1. Si cola tiene valor N-1, al ser incrementada
en uno (módulo N), tomará valor cero.
cabeza
N-1 0
1
2
3
4
5
cola
Los números, del diagrama, muestran los valores del índice de cada casilla del arreglo circular.
El diagrama a la izquierda ilustra una cola vacía; la de la derecha una cola con un espacio
disponible. En esta última situación, el cursor cola (tail) dio la vuelta completa y está marcando
como posición disponible para encolar la posición anterior a la que tocaría consumir. Si se
encola un nuevo elemento, se producirá la condición de cola llena; pero esta situación es
indistinguible de la de cola vacía.
cola
N -1 N -1 0
0
1 1
2 2
3 3 cola
4 4
5 5 cabeza
De esta forma no es posible distinguir entre las dos situaciones: cola llena o vacía.
Una de las múltiples soluciones a este problema, es registrar en una variable adicional la cuenta
de los elementos encolados; esto además facilita el diseño de las funciones que determinan cola
vacía o llena.
Si la variable la denominamos encolados. Entonces con cola vacía, encolados toma valor cero.
La cola llena se detecta cuando encolados toma valor N.
El algoritmo se basa en las funciones que operan sobre una cola circular basada en arreglos. Con
operaciones de colocar en la cola (put), sacar de la cola (get) y verificar si la cola está vacía o
llena.
void QUEUEdestroy(void)
{ free ( q ); }
En un caso práctico las funciones cola llena y vacía se implementan con macros.
#define QUEUEempty() (encolados == 0)
#define QUEUEfull() (encolados == N)
Las dos aplicaciones, el stack de usuario y la cola, se emplearán en algoritmos para construir
árboles en grafos.
Ejemplo 5.6. Diseño de buffer circular estático de caracteres.
Para insensibilizarse de las diferentes velocidades que pueden tener un consumidor y un
productor de caracteres, se suele emplear un buffer.
En el caso de un computador alimentando a una impresora, la velocidad de producción de
caracteres del procesador es mucho mayor que la que tiene la impresora para liberar los
caracteres hacia el medio de impresión; el disponer de un buffer de impresora, permite al
procesador escribir en el buffer y no tener que esperar que la impresora escriba un carácter. Lo
mismo ocurre cuando un usuario escribe caracteres desde un teclado; su velocidad de digitación
es bastante menor que la velocidad con que el procesador utiliza los caracteres.
Se emplea la variable cnt para llevar la cuenta de los elementos almacenados en el buffer.
#define SIZE 16
#define LLENO (cnt==SIZE)
#define VACIO (cnt==0)
2 2
rd
wr
Usualmente una de las rutinas opera por interrupciones. La rutina que no es de interrupción debe
modificar la variable común cnt deshabilitando el tipo de interrupción.
P5.1
Se tienen los diagramas de una lista circular vacía, y luego de haber insertado uno, dos y tres
elementos. Notar que el puntero a la lista referencia el último nodo insertado en la estructura.
1 1 2 1 2 3
Definir tipos de datos: nodo es el tipo de datos del nodo, y pnodo es el nombre del tipo puntero
a nodo. El valor almacenado en el nodo es de tipo entero. En cada caso ilustrar un ejemplo de
uso, mostrando las variables que sean necesarias, con diagramas que ilustren la relación entre
los datos.
c) Asumir que se tienen varias listas circulares, cada una de ellas referenciadas por un puntero
almacenado en una variable de tipo pnodo. Se tiene la siguiente función, en la cual el argumento
sirve para referenciar a una de las listas.
Solución.
a)
pnodo Insertar(int valor)
{ pnodo pn=NULL;
if ( (pn = (pnodo) malloc(sizeof(nodo))) == NULL) return NULL;
pn->clave = valor;
if (listac == NULL){pn->proximo = pn;}
else {pn->proximo = listac->proximo; listac->proximo = pn;}
listac = pn;
return (pn);
}
La siguiente definición, debe estar fuera de las funciones, y ubicada antes de la definición de la
función Insertar:
pnodo listac=NULL;
b)
int Sumar(pnodo p)
{ pnodo t = p;
int sum = 0;
if(p == NULL) {printf(" Lista vacía. "); return(0);}
sum += t->clave;
for(t = p->proximo; t != p; t = t->proximo)
sum += t->clave;
return (sum);
}
Retorna puntero al que antes era el primero de la lista, nulo en caso de lista vacía.
En el caso de la lista con tres elementos, dada al inicio, después de invocar a la función, en esa
lista, debe retornar un puntero al nodo con valor 3, y la lista apunta al elemento con valor 1,
según se ilustra en el siguiente diagrama.
Lista1
1 2 3
Figura P5.2.
pnodo t=NULL;
if( (t=avanzar(&Lista1))!=NULL) printf("el anterior era %d\n", t->clave);
Imprime el valor 3.
Ejercicios propuestos.
a+b*c+(d*e+f)*g
La salida, en notación polaca inversa, se genera en el siguiente orden:
abc
abc*+
abc*+
abc*+d
abc*+de
abc*+de*
abc*+de*f
a b c * + d e* f +
a b c * + d e* f +
a b c * + d e* f + g
a b c * + d e* f + g * +
Efectuar una traza del contenido del stack, a medida que se van procesando los símbolos de
entrada.
Las funciones de inserción deben considerar la posibilidad de insertar en una cola vacía.
inicial
final
El proceso de reorganizar una lista por transposición, tiene por objetivo mejorar el tiempo
promedio de acceso para futuras búsquedas, moviendo los nodos más accesados hacia el
comienzo de la lista.
Diseñar una rutina, en C, que busque un elemento en una lista en base a punteros. Y tal que
cuando encuentre un elemento lo trasponga con el anterior, excepto cuando lo encuentre en la
primera posición.
Comparar las dos funciones para insertar un nodo en una lista ordenada.
header.proximo = p;
Referencias.
En el apéndice: Assemblers, Linkers, and the SPIM Simulator de James R. Larus, del libro de
Patterson A. David y Hennessy L. John, Computer Organization and Design: The
Hardware/software Interface, Morgan Kaufmann 2004, aparece una excelente descripción del
proceso de compilación, de la creación de archivos objetos, del proceso de ligado y carga de un
programa.
CAPÍTULO 5. ............................................................................................................................................ 1
CONJUNTOS DINÁMICOS. ................................................................................................................... 1
LISTAS, STACKS, COLAS. ..................................................................................................................... 1
5.1. NODOS. .............................................................................................................................................. 1
5.2. OPERACIONES. ................................................................................................................................... 1
5.2.1. Consultas:.................................................................................................................................. 1
5.2.2. Modificaciones. ......................................................................................................................... 1
5.3. LISTAS. .............................................................................................................................................. 2
5.3.1. Lista simplemente enlazada. ...................................................................................................... 2
5.3.1.1. Crea Nodo ........................................................................................................................................... 3
5.3.1.2. Operaciones de consultas en listas. ..................................................................................................... 4
a) Recorrer la lista. ...................................................................................................................................... 4
b) Buscar elemento. .................................................................................................................................... 5
c) Seleccionar un valor extremo. ................................................................................................................. 6
d) Buscar el último nodo. ............................................................................................................................ 7
5.3.1.3. Operaciones de modificación de listas. ............................................................................................... 7
a) Análisis de inserción. .............................................................................................................................. 7
b) Análisis de la operación descarte. ........................................................................................................... 9
c) Análisis adicionales en operación Insertar después............................................................................... 11
5.3.2. Listas doblemente enlazadas. .................................................................................................. 12
5.3.3. Lista circular. .......................................................................................................................... 14
5.3.4. Lista auto organizada. ............................................................................................................. 14
5.3.5. Lista ordenada. ........................................................................................................................ 15
5.3.6. Listas en base a cursores. ........................................................................................................ 15
5.4. EJEMPLOS DE OPERACIONES EN LISTAS SIN CENTINELA. .................................................................. 15
Ejemplo 5.1 Inserción de un nodo. .................................................................................................... 15
a) Insertar antes. ........................................................................................................................................ 15
b) Insertar después. ........................................................................................................................................ 17
c) Insertar al final. .......................................................................................................................................... 17
d) Insertar al inicio y al final. ......................................................................................................................... 18
e) Procedimiento de inserción. ....................................................................................................................... 18
f) Error común en pasos por referencia. ......................................................................................................... 19
Ejemplo 5.2. Descartar o Borrar nodo.............................................................................................. 20
5.5. STACK. PILA. ESTRUCTURA LIFO (LAST-IN, FIRST-OUT), ................................................................ 21
5.5.1. Definición. ............................................................................................................................... 21
5.5.2. Diagrama de un stack. Variables. ........................................................................................... 21
5.5.3. Archivo de encabezado ( *.h). ................................................................................................. 22
5.5.4. Implementación de operaciones. ............................................................................................. 23
Ejemplo 5.3. Uso de stack. Balance de paréntesis. ........................................................................... 24
a) Especificación del algoritmo: .................................................................................................................... 24
b) Descripción inicial. .................................................................................................................................... 25
Ejemplo 5.4. Evaluación de expresiones en notación polaca inversa. .............................................. 25
a) Ejemplo de evaluación. .............................................................................................................................. 25
b) Especificación. .......................................................................................................................................... 26
c) Seudo código. ............................................................................................................................................ 26
Índice de figuras.
Capítulo 6.
Un árbol presenta vínculos jerárquicos entre sus componentes y permite representar variadas
situaciones de interés. Por ejemplo: la estructura de un directorio, la conectividad entre vértices
de un grafo, representación de expresiones, el almacenamiento de las palabras reservadas,
diccionarios, etc.
6.1. Definiciones.
Un árbol es una colección de cero o más nodos vinculados con una relación de jerarquía.
Un árbol con cero nodos se denomina árbol vacío.
Una trayectoria del nodo ni al nodo nk, es una secuencia de nodos desde ni hasta nk, tal que ni es
el padre de ni+1. Existe un solo enlace o vínculo entre un padre y sus hijos.
Largo de una trayectoria es el número de enlaces en la trayectoria. Una trayectoria de k nodos
tiene largo k-1.
Alto de un nodo: largo de la trayectoria más larga de ese nodo a una hoja.
Profundidad de un nodo: es el largo de la trayectoria de la raíz a ese nodo.
árbol
raíz
1 6 0
4 8
5 9
hoja
El nodo con valor 3 es la raíz. Los nodos con valores: 1, 5 y 9 son hojas. Los nodos con valores:
4, 6 y 8 son nodos internos. El nodo con valor 6 es hijo de 3 y padre de 8. El nodo con valor 4
es ancestro del nodo con valor 5. {8, 9} y {4, 5} son subárboles. El nodo con valor 1 es subárbol
de la raíz. Los nodos con valores: 1, 6 y 0 son hermanos, por tener el mismo padre. El conjunto
de nodos con valores: {3, 6, 4, 5} es una trayectoria, de largo 3. Alto del nodo con valor 6 es 2.
La profundidad del nodo con valor 5 es 3.
Todos los nodos que están a igual profundidad están en el mismo nivel.
La profundidad del árbol es la profundidad de la hoja más profunda.
Se dice que un árbol es una estructura ordenada, ya que los hijos de un nodo se consideran
ordenados de izquierda a derecha. También es una estructura orientada, ya que hay un camino
único desde un nodo hacia sus descendientes.
Cada nodo puede tener: un hijo izquierdo, o un hijo derecho, o ambos o sin hijos. A lo más cada
nodo puede tener dos hijos.
Un árbol binario está formado por un nodo raíz y un subárbol izquierdo I y un subárbol derecho
D. Donde I y D son árboles binarios. Los subárboles se suelen representar gráficamente como
triángulos.
Árbol_ binario
I D
Usualmente el árbol se trata como conjunto dinámico, mediante la creación de sus nodos bajo
demanda. Es decir, un nodo se crea con malloc, y contiene punteros a otros nodos de la
estructura.
En caso de un árbol binario, debe disponerse al menos de dos punteros. Se ilustra un ejemplo
con una clave entera, no se muestra espacio para la información periférica que puede estar
asociada al nodo. En la implementación de algunas operaciones conviene disponer de un
puntero al padre del nodo, que tampoco se declara en el molde del nodo.
Las claves de los nodos del subárbol izquierdo deben ser menores que la clave de la raíz.
Las claves de los nodos del subárbol derecho deben ser mayores que la clave de la raíz
.
a a
I D
I D
Se indican en el diagrama de la Figura 6.3, el descendiente del subárbol izquierdo con mayor
clave y el descendiente del subárbol derecho con menor valor de clave; los cuales son el
antecesor y sucesor de la raíz.
El siguiente árbol no es binario de búsqueda, ya que el nodo con clave 2, ubicado en el subárbol
derecho de la raíz, tiene clave menor que ésta.
1 4
2 5
2
3 1
La generación de estos árboles depende del orden en que se ingresen las claves en los nodos, a
partir de un árbol vacío. El de la izquierda se generó insertando las claves en orden de llegada:
2, 1, 4, 3, 5 (o bien: 2, 4, 1, 5, 3). El de más a la derecha, se generó con la llegada en el orden: 5,
4, 3, 2, 1.
Los dos árboles de más a la izquierda, en la Figura 6.5, se denominan balanceados, ya que las
diferencias en altura de los subárboles izquierdo y derecho, para todos los nodos, difieren a lo
más en uno. Los tres a la derecha están desbalanceados. El último tiene la estructura de una
lista, y es un árbol degenerado.
Se denomina árbol completo, a aquél que tiene presentes todas las hojas en el menor nivel. La
raíz es de nivel cero, los hijos de la raíz están en nivel 1; y así sucesivamente.
Deduciremos, de manera inductiva la altura de las hojas en función del número de nodos.
El caso más simple de un árbol completo tiene tres nodos, un nivel y altura dos. Hemos
modificado levemente la definición de altura, como el número de nodos que deben ser revisados
desde la raíz a las hojas, ya que la complejidad de los algoritmos dependerá de esta variable.
Árbol de nivel 1.
Nodos = 3 = 22-1
Árbol de nivel 2.
Nodos = 7 = 23-1
Altura = 3
Árbol de nivel 3.
Nodos = 15 = 24-1
Altura = 4
Árbol de nivel m.
Nodos = n = 2A-1
Altura = A = m+1
Hojas = 2m
Nodos internos = n – Hojas
A = log2(n+1) = O(log n)
Resultado que es simple de interpretar, ya que cada vez que se sigue una trayectoria por un
determinado subárbol, se descarta la mitad de los nodos. Es decir, se rige por la relación de
recurrencia: T(n) = T(n/2) +c, con solución logarítmica. Esta propiedad le otorga, a la
estructura de árbol binario de búsqueda, grandes ventajas para implementar conjuntos
dinámicos, en relación a las listas, que permiten elaborar algoritmos de complejidad O(n).
La demostración por inducción matemática completa, del resultado anterior, es sencilla de
deducir a partir del caso m-avo.
También se denominan árboles perfectamente balanceados, en éstos todas las hojas tienen igual
profundidad.
Se ilustran los tres casos de árboles, de nivel dos, con un nivel de desbalance, para n= 4, 5 y 6.
Un árbol con 3 nodos es completo en caso de aceptarse sólo un nivel de desbalance. Lo mismo
puede decirse de un árbol con 7 nodos y que tenga un nivel de desbalance.
Árboles de nivel 2.
Nodos de 4 a 6. De 23-1 hasta 23- 2.
Altura = 3 en peor caso.
Árboles de nivel 3.
Nodos de 8 a 14. De 24-1 hasta 24-2
Altura = 4 en peor caso.
Árboles de m niveles.
Nodos de 2A-1 hasta 2A-2.
Altura A.
La inecuación:
2 A−1 ≤ n ≤ 2 A − 2 tiene como solución: A <= (1 + log2(n) ) para la primera desigualdad y
A>= log2(n+2) para la segunda.
Se pueden encontrar constantes que acoten, por arriba y por abajo a ambas funciones:
1*log2n <=log2(n+2) <= A <= 1+ log2n <= 1.3* log2n para n>10,079.
> solve(1+(ln(n)/ln(2))<1.3*ln(n)/ln(2));
RealRange ( Open ( 10.07936840 ), ∞ )
Entonces, se tiene:
A = Θ(log n)
El peor caso para la altura, se tiene con un árbol degenerado en una lista, en el cual la altura
resulta O(n).
Lo que se desea conocer es la altura An, definida como la altura promedio de las búsquedas de
las n claves y promediadas sobre los n! árboles que se generan a partir de las n! permutaciones
que se pueden generar con la n claves diferentes.
Si el orden de llegada de las claves que se insertan al árbol se genera en forma aleatoria, la
probabilidad de que la primera clave, que es la raíz, tenga valor i es 1/n. Esto en caso de que
todas las claves sean igualmente probables.
i-1 n-i
Se considera que el subárbol izquierdo contiene (i-1) nodos; por lo tanto el subárbol derecho
contiene (n-i) nodos. Esto debido a la propiedad de árbol de búsqueda, en el subárbol izquierdo
debe tenerse los (i-1) valores menores que la clave i de la raíz.
Es decir:
Los i-1 nodos del subárbol izquierdo tienen largo de trayectorias promedio igual a Ai −1 +1.
Los n-i nodos del subárbol derecho tienen largo de trayectorias promedio igual a An −i +1.
La raíz tiene un largo de trayectoria igual a 1.
(i − 1)( Ai −1 + 1) + 1 + (n − i )( An −i + 1)
An (i ) =
n
1 i=n i − 1 1 n−i
An = ∑ (
n i =1 n
( Ai −1 + 1) + 1 +
n n
( An −i + 1)
efectuando factorizaciones:
i =n
1
An =
n2
∑ ((i − 1) A
i =1
i −1 + n + (n − i ) An −i )
En la última sumatoria, los dos factores a sumar, dan origen a iguales sumandos. En ambas
sumatorias se obtiene: 0 ⋅ A0 + 1 ⋅ A1 + ...(n − 1) ⋅ Ai −1
Resulta entonces:
i=n
2
An = 1 +
n2
∑ (i − 1) A
i =1
i −1
Se tiene una relación de recurrencia de orden (n-1). Se requieren conocer (n-1) términos para
calcular el n-avo.
La cual puede ser transformada en una relación de recurrencia de primer orden, por el siguiente
procedimiento:
2 i =n−2
An −1 = 1 + ∑ iAi
(n − 1) 2 i =1
i =n−2
2 2
An = 1 +
n 2
(n − 1) An −1 + 2
n
∑ iA
i =1
i
2 ( n + 1 ) Ψ( n + 1 ) + 2 γ − 3 n + 2 γ n
An =
n
Donde Ψ(n) es la función digama.
Para verificar que es solución puede reemplazarse ésta en la ecuación de recurrencia, con lo que
se obtiene:
n +1 1 n
2 H n − 3 = 2 ((n 2 − 1)(2 H n −1 − 3) + 2n − 1)
n n n −1
Retomando el cálculo, la función armónica puede ser aproximada por la función gama
(constante de Euler, γ=0,577….), mediante:
1
H n = γ + ln(n) + + ....
12n 2
Si n es grande:
H n ≈ ln(n)
An ≈ 2 H n − 3
1,4 log2(n)
Generado aleatoriamente
balanceado
La gráfica muestra que para árboles de tipo 1000 nodos, deben recorrerse cerca de nueve nodos
desde la raíz hasta las hojas (peor caso), si está balanceado. El largo promedio de los recorridos
es 12 en un árbol generado aleatoriamente, y 1000 en peor caso.
El resultado establece que el promedio de alargue es de un 39%. Pero esta cota es para el caso
promedio, además si n es relativamente grande el alargue es menor. Se muestra una gráfica del
alargue, en función de n. Para árboles con menos de 2000 nodos el alargue es menor que un
22%.
Si dada la naturaleza de la aplicación se desea tener un peor caso con complejidad logarítmica,
debemos garantizar que el árbol se mantendrá lo más balanceado posible. Existen una serie de
algoritmos que logran este objetivo: Árboles: coloreados, 2-3, AVL.
2 5
1 3 6
Teorema
Para un árbol binario de búsqueda de n nodos internos el correspondiente árbol binario externo
tiene: (2n + 1) nodos. Hay (n+1) nodos externos u hojas.
Demostración.
Por inducción completa, cuyo método resumimos a continuación:
Se tiene un conjunto y una propiedad que puede expresarse mediante una fórmula P(n), se
verifica:
1° Que la propiedad se cumpla para el primer elemento o los primeros elementos del conjunto.
2° Que dado P(n) se cumpla que: P(n) ⇒ P(n+1).
La conclusión es: Todos los elementos del conjunto cumplen esa propiedad. Esta forma de
razonamiento también se denomina por recurrencia.
2 1
1 2
En este caso se pueden formar dos árboles. Si a partir de un árbol vacío, llegan las claves en
orden 2,1 ó 1, 2, se tienen los diagramas anteriores.
Para ni = n+1 podemos agregar el nodo interno de dos formas: Una reemplazando a un nodo
externo, o bien intercalándolo entre dos nodos internos.
En el primer caso, disminuye en uno el número de nodos externos, pero luego se agregan dos
nodos externos, se tendrá:
ne’ = (n+1) -1 + 2 = (n+1) + 1 = ni + 1 y se cumple la propiedad.
a a
En el segundo caso:
ne’’ = (n+1) + 1 = ni + 1 también se cumple la propiedad.
a
a
b
c
c
a=1 1
a=2
I(1) = 1, E(1) = 2 + 2 = 4
E(1) = I(1) + (2*1 + 1) = 4
2 1
1 2
I(2) = 1 + 2 = 3
E(2) = 3 + 3 + 2 = 8
E(2) = I(2) + (2*2 +1) = 8 Entonces P(2) se cumple.
Nótese que I(n) es el número de comparaciones de claves que deben realizarse para encontrar
todos los nodos. También se tiene que E(n) –(n+1) es el número de comparaciones que deben
realizarse para encontrar las hojas o nodos externos; se cuentan sólo las comparaciones de
claves.
Se reemplaza cualquiera de los nodos externos, digamos uno ubicado a altura d de la raíz, por
un nodo interno y sus dos hijos externos:
a a
d d b
n
d+1
n+1
Los nuevos largos, para el árbol con (n+1) nodos internos quedan:
I(n+1) = I(n) + d ya que el nodo b tiene altura d respecto de la raíz.
E(n+1)’ = E(n) – d + 2(d+1) ya que pierde un nodo con altura d, pero adquiere dos a distancia
(d+1).
Los nuevos largos en función de la situación para n nodos internos, se pueden plantear:
I(n+1) = I(n) + d + k. Con k igual al número de nodos internos bajo el insertado.
E(n+1)’’ = E(n) + (d+1) + j. Con j igual al número de nodos externos bajo el insertado.
Se tiene que j = k+1, ya que para un árbol de k nodos internos se tienen (k+1) nodos externos.
Reemplazando j y el valor de E(n) mediante la propiedad P(n) se tiene:
E(n+1)’’ = (I(n) + 2n+1) + (d+1) + k+1 = I(n) +d +k + 2n+3.
Sea S(n) el número de comparaciones en una búsqueda exitosa en un árbol binario de búsqueda
de n nodos internos, construido aleatoriamente.
Para un árbol dado el número esperado de comparaciones para encontrar todas las claves es:
S(n) = I(n)/n
S(1) = 1
S(2) = 3/2
I(1) = 1, I(2) = 3
a=1
Resulta:
1
S (n) = (1 + )U (n) − 1
n
Entonces: Se requiere una comparación adicional para encontrar una clave en una búsqueda
exitosa que lo que se requiere para insertar la clave en una búsqueda no exitosa que precede a su
inserción.
Como el nodo buscado puede haber sido insertado en un árbol vacío o en un árbol que ya
tuviera 1, 2, .. o (n-1) nodos se tiene, el siguiente promedio para las comparaciones:
1 1 k = n −1
(1 + )U (n) − 1 = 1 + ∑ U (k )
n n k =0
Despejando:
k = n −1
(n + 1)U (n) = 2n + ∑ U (k )
k =0
k =n−2
nU (n − 1) = 2(n − 1) + ∑ U (k )
k =0
(n + 1)U (n) − nU (n − 1) = 2 + U (n − 1)
Despejando U(n), se logra, la relación de recurrencia de primer orden, con U(0) =0:
2
U (n) = U (n − 1) +
n +1
2 2 2 2 2 2
U (n) = U (0) + + + ... + + + +
2 3 n − 2 n −1 n n +1
U (n) = U (0) + 2( H (n + 1) − 1)
Finalmente, con U(0)=0:
U (n) = 2( H (n + 1) − 1)
Aproximando:
S ( n) ≈ 2 H ( n)
S (n) ≈ 2 ln(n) = 2 ln(2) log 2 (n) = 1,39 log 2 (n) = Θ(log 2 (n))
Finalmente:
S (n) = Θ(log 2 (n))
1,3log2(n)
S(n)
log2(n+2)
Se denominan recorridos a la forma en que son visitados todos los nodos de un árbol.
Existen tres modos de recorrido, con las siguientes definiciones recursivas.
6.5.1. En orden:
Se visita el subárbol izquierdo en orden;
Se visita la raíz;
Se visita el subárbol derecho en orden;
6.5.2. Pre orden:
Se visita la raíz;
Se visita el subárbol izquierdo en preorden;
Se visita el subárbol derecho en preorden;
6.5.3. Post orden:
Se visita el subárbol izquierdo en postorden;
Se visita el subárbol derecho en postorden;
Se visita la raíz;
6.5.4. Ejemplo de recorridos.
Recorrer el árbol formado con el siguiente conjunto de claves: { n0, n1, n2, n3, n4, n5}, y cuyo
diagrama se ilustra en la figura 6.24.
En orden:
{n1, n3, n4}, n0, {n2, n5}
n3, n1, n4, n0, {n2, n5}
n3, n1, n4, n0, n2, n5
Pre orden
n0, {n1, n3, n4}, {n2, n5}
n0, n1, n3, n4, {n2, n5}
n0, n1, n3, n4, n2, n5
Post orden
{n1, n3, n4}, {n2, n5}, n0
n3, n4, n1, {n2, n5},n0
n3, n4, n1, n5, n2, n0
n1 n2
n3 n5
n4
Figura 6.24. Árbol con claves {n0, n1, n2, n3, n4, n5}.
* +
a b c d
sentencia
if (Condición) sentencia
Al estudiar las operaciones más frecuentes en árboles y al analizar sus complejidades se podrá
notar el poder de esta estructura de datos para implementar los conceptos de buscar y
pnodo arbol=NULL;
6.6.1.2. Crea nodo inicializado con un valor de clave.
pnodo CreaNodo(int valor)
{ pnodo pi=NULL;
La complejidad de un recorrido que debe visitar n nodos puede intuirse que será Θ(n).
Si se desea mostrar el nivel, de cada nodo, basta una pequeña modificación, agregando un
argumento nivel; cada vez que se desciende un nivel se incrementa éste en uno. Lo cual es un
ejemplo de las posibilidades que tienen los algoritmos recursivos.
Ejemplo de uso:
inorder(arbol, 0); //Imprime considerando la raíz de nivel cero.
pnodo BuscarMinimoIterativo(pnodo t) {
while ( t != NULL){
if ( t->left == NULL ) return (t); //apunta al mínimo.
else t=t->left; //desciende
}
return (t); /* NULL si árbol vacío*/
}
t
t->left
Otra forma de concebir la función, es plantear primero las condiciones de término, y luego
seguir tratando de hacer lo que la función realiza, pero acercándose a la solución.
pnodo BuscaMinimo(pnodo t)
{
if (t == NULL) return(NULL); //si árbol vacío retorna NULL
if (t->left == NULL) return(t ); // Si no tiene hijo izquierdo: lo encontró.
return( BuscaMinimo (t->left) ); //busca en subárbol izquierdo.
}
t
t
t t->left t->left
La complejidad del algoritmo es O(a), donde a es la altura del árbol. Ésta en promedio es
O(log2(n)) , y en peor caso O(n).
Algoritmo: Descender a partir de la raíz por el subárbol derecho hasta encontrar un nodo con
hijo derecho nulo, el cual contiene el valor máximo de clave. Debe considerarse que el árbol
puede estar vacío.
/* Recursivo */
pnodo BuscaMaximo(pnodo t)
{
if (t == NULL) return(NULL); //si árbol vacío retorna NULL
if (t->right == NULL) return(t ); // Si no tiene hijo derecho: lo encontró.
return( BuscaMaximo (t->right) ); //sigue buscando en subárbol derecho.
}
Si en las funciones obtenidas en 6.6.3.2. se cambian left por right y viceversa se obtienen las
funciones anteriores. Esta es una importante propiedad de los árboles binarios de búsqueda.
6.6.3.3. Nodo descendiente del subárbol derecho con menor valor de clave.
Se ilustra un ejemplo de árbol binario de búsqueda, en el cual si t apunta a la raíz, se tendrá que
el nodo con clave 6 es el menor descendiente del subárbol derecho.
t
3 8
2 4 6 9
7
1
Diseño recursivo.
Se emplea la función BuscaMínimo, que es recursiva.
pnodo MenorDescendienteSD(pnodo t)
{
if (t == NULL) return(NULL); //si árbol vacío retorna NULL
if (t->right == NULL) return(NULL ); // Si no tiene hijo derecho no hay sucesor.
return( BuscaMinimo (t->right) ); //sigue buscando en subárbol derecho.
}
El caso D1, un nodo sin hijo izquierdo, indica que se encontró el mínimo.
El caso D2, debe descenderse por el subárbol derecho de t, por la izquierda, mientras se tengan
hijos por la izquierda.
t
t
D1
D2
pnodo MenorDescendienteIterativoSD(pnodo t)
/*menor descendiente de subárbol derecho. */
{ pnodo p;
if (t == NULL) return(NULL); //si árbol vacío retorna NULL
if (t->right == NULL) return(NULL ); // Si no tiene hijo derecho no hay sucesor.
else p = t->right;
while ( p->left != NULL) { /* Mientras no tenga hijo izq descender por la izq. */
p = p->left;
}
/*Al terminar el while p apunta al menor descendiente */
return (p); /* Retorna el menor */
}
6.6.3.4. Sucesor.
Dado un nodo encontrar su sucesor no es el mismo problema anterior, ya que el nodo podría ser
una hoja o un nodo sin subárbol derecho. Por ejemplo en la Figura 6.29, el sucesor del nodo con
clave 4 es el nodo con clave 5. El sucesor del nodo 2 es el nodo con valor 3.
Se requiere disponer de un puntero al padre del nodo, para que la operación sea de costo
logarítmico, en promedio.
Si un nodo tiene subárbol derecho, el sucesor de ese nodo es el ubicado más a la izquierda en
ese subárbol (problema que se resolvió en 6.6.3.3); si no tiene subárbol derecho, es el menor
ancestro (que está sobre el nodo en la trayectoria hacia la raíz) que tiene a ese nodo en su
subárbol izquierdo.
1 8
2 6 9
3 7
t 4
Algoritmo:
Si el árbol no es vacío.
Si no tiene subárbol derecho:
Mientras exista el padre y éste apunte al nodo dado por la derecha se asciende:
Hasta encontrar el primer padre por la izquierda.
Si no existe ese padre, se retorna NULL, t era el nodo con valor máximo
Si tiene subárbol derecho, el sucesor es el mínimo del subárbol derecho.
Revisar el algoritmo para los diferentes nodos del árbol de la Figura 6.31, especialmente para
los nodos con claves 4 y 9. En este último caso debe asumirse que se ha definido que el padre
de la raíz tiene valor NULL.
pnodo Sucesor(pnodo t)
{ pnodo p;
if (t == NULL) return(NULL); //si árbol vacío retorna NULL
if (t->right == NULL)
{ p = t->padre; //p apunta al padre de t
while( p!=NULL && t == p->right)
{t=p; p=t->padre;} //se asciende
return(p); //
}
else
return( BuscaMinimo (t->right) ); //busca mínimo en subárbol derecho.
}
Como en peor caso debe ascenderse un trayectoria del nodo hacia la raíz, el costo será O(a),
donde a es la altura del árbol.
6.6.3.5. Nodo descendiente del subárbol izquierdo con mayor valor de clave.
Basta intercambiar left por right y viceversa en el diseño desarrollado en 6.6.3.3.
6.6.3.6. Predecesor.
El código de la función predecesor es la imagen especular del código de sucesor.
Es preciso tener implementados los operadores de igualdad y menor que, en caso de que éstos
no existan en el lenguaje, para el tipo de datos de la clave. Por ejemplo si la clave es
alfanumérica (un string), una estructura, etc.
Complejidad de la búsqueda.
Si T(a) es la complejidad de la búsqueda en un árbol de altura a.
En cada iteración, el problema se reduce a uno similar, pero con la altura disminuida en uno, y
tiene costo constante el disminuir la altura.
Entonces:
T(a) = T(a-1)+Θ(1) con T(0) = 0
En caso de activarse una secuencia de llamados recursivos, los retornos de éstos, son pasados a
través de la asignación a la variable t.
Pueden eliminarse las asignaciones y el retorno final, del diseño anterior, de la siguiente forma:
Si CreaNodo retorna un NULL, si no había espacio en el heap, sin invocar a exit, se tendrá un
retorno nulo, si no se pudo insertar.
Si p y raiz son de tipo pnodo, el siguiente segmento ilustra un ejemplo de uso de la función,
considerando la inserción en un árbol vacío:
Se recorre una trayectoria de la raíz hasta una hoja. Entonces, si a es la altura, la complejidad de
la inserción es: T(a).
En la Figura 6.31.b, se desea insertar un nodo con valor 4; se muestra el descenso a partir de la
raíz, hasta encontrar el nodo con valor 3, que tiene subárbol derecho nulo. Nótese que p queda
apuntando a un puntero.
1 p 8
3 6 9
2 7
Figura 6.31.b. Variables al salir del while.
La inserción en un árbol vacío debe condicionarse, para poder escribir en la variable externa,
denominada raiz. Si p y raiz son de tipo pnodo, el siguiente segmento ilustra el uso de la
función, considerando la inserción en un árbol vacío:
Diseño recursivo.
pnodo InsertarRecursivo( pnodo t, int valor)
{
if (t == NULL) t = CreaNodo(valor); //insertar en árbol vacío o en hoja.
else
if (valor < t->clave) //insertar en subárbol izquierdo.
t->left = InsertarRecursivo(t->left, valor);
else
if (valor > t->clave) //insertar el subárbol derecho
t->right = InsertarRecursivo (t->right, valor);
/* else: valor ya estaba en el árbol. No hace nada. */
return(t);
}
La inserción en un árbol vacío debe poder escribir en la variable externa, denominada raiz. El
siguiente segmento ilustra el uso de la función:
raiz=InsertRecursivo(raiz, 4);
Debe notarse que se reescriben los punteros de los nodos que forman la ruta de descenso, lo cual
es una sobrecarga de trabajo innecesario, salvo que el último retorno escribe en la raíz. Si desea
conocerse si CreaNodo falla, retornando NULL, habría que comentar la excepción o salir del
programa.
Trayectoria en el descenso.
Veremos que en el algoritmo recursivo, la trayectoria recorrida desde la raíz hasta la posición
para insertar queda registrada en el stack.
En el árbol de la Figura 6.32, se especifican los valores de los punteros, y se desea insertar un
nodo con clave igual a 7.
raiz
t0
5
t1
3 8
t3 t2
2 4 6 9
Al ejecutarse el código de este segundo llamado se determina que 7 es menor que 8 y se genera
un tercer llamado a la función con: InsertarRecursivo (t3, 7); después de este tercer llamado, el
stack queda:
Al ejecutar el código del tercer llamado, se determina que 7 es mayor que 6, y se produce el
cuarto llamado: InsertarRecursivo (t3->right, 7); si denominamos por t4 al valor t3->right, el
esquema que muestra las variables en el stack, queda como sigue:
t valor Llam ado núm ero
t0 7 1
t1 7 2
t3 7 3
t4 7 4
Al iniciar la ejecución del código del cuarto llamado, se tiene en el stack los valores de los
punteros que recuerdan la trayectoria del descenso hasta la posición de inserción. Al ejecutar
el código del cuarto llamado se determina que t4 es un puntero nulo, con lo cual, se crea el nodo
y se retorna en t (que es t4, la cuarta encarnación de t) el valor de un puntero al nodo recién
creado. En este momento se sale del cuarto llamado y el stack queda:
Pero como, dentro del tercer llamado, t tiene el valor de t3, esta instrucción pega efectivamente
el nuevo nodo. Esta instrucción es la última del tercer llamado, con lo cual termina retornando el
valor t3. El stack queda ahora:
Y reanudamos la ejecución del segundo llamado, que había quedado pendiente. Efectuando la
asignación:
t->left = <valor retornado de t por el tercer llamado t3>
Sobrescribiendo un valor igual al existente, copia en t1->left el valor de t3. Obviamente esta
escritura es un costo adicional de la recursividad. Terminando así el segundo llamado, el cual
retorna el valor de t1.
El stack queda:
t valor Llam ado núm ero
t0 7 1
raiz=InsertarRecursivo(raiz, 7);
Si el valor del nodo que se desea insertar es igual a uno ya perteneciente al árbol, el llamado
también retorna t0.
El diseño recursivo recorre desde la raíz hasta el punto de inserción, quedando la ruta de
descenso en el stack; luego de la inserción, recorre la ruta en sentido inverso; lo cual permite
agregar alguna operación a los nodos involucrados. La acción debe realizarse antes del retorno.
Ver el punto 6.6.5.11 Inserción en la raíz.
La operación insertar en el lugar de la raíz, es más compleja, ya que requiere modificar el árbol.
Requiere primero insertar el nodo, y luego efectuar rotaciones para llevar ese nodo al lugar de la
raíz, preservando la propiedad de un árbol binario de búsqueda. También se puede implementar
si se dispone de una función que parta un árbol en dos (split).
En este caso, la operación es trivial, basta escribir un puntero con valor nulo. La estructura se
conserva.
i) con un hijo
I D
Para conservar la estructura del árbol, se debe buscar I, el mayor descendiente del hijo
izquierdo; o bien D, el menor descendiente del hijo derecho. Luego reemplazar la hoja obtenida
por el nodo a descartar. Se implementa la operación buscando D.
a b
A
q
a b
El descarte de la raíz, o de un nodo con dos hijos, está basado en encontrar el menor
descendiente del hijo derecho, o el mayor descendiente del hijo izquierdo.
pnodo Descarte_Raiz(pnodo t)
{
pnodo *p = NULL, temp;
if (rand()%2) { /*Existen dos soluciones */
/* Busca mayor descendiente del subárbol izquierdo */
p = &(t->left);
while ((*p)->right != NULL)
p = &((*p)->right);
} else {
/* o Busca menor descendiente del subárbol derecho */
p = &(t->right);
while ((*p)->left != NULL)
p = &((*p)->left);
}
t->clave = (*p)->clave; /*copia los valores del encontrado en la raíz. */
if ((*p)->left == NULL) {
temp = *p;
*p = (*p)->right;
} else { /* ((*p)->right == NULL) */
temp = *p;
*p = (*p)->left;
pnodo deltree(pnodo t)
{
if (t != NULL) {
t->left = deltree(t->left);
t->right = deltree(t->right);
free(t);
}
return NULL;
}
A D
a b d e
Si los dos subárboles no son vacíos, en la primera iteración se da valor inicial a t, la variable de
retorno que apuntará a la nueva raíz. Ésta puede ser l o r, dependiendo del azar.
Luego se mantiene en p: la dirección del puntero derecho de la raíz, si comienza por el subárbol
izquierdo; o la dirección del puntero izquierdo de la raíz, si comienza por el subárbol derecho.
p A l D
a b d e
Si en la segunda iteración, realiza la parte del else, después de efectuado éste, queda:
t
A
a D
p
r
l
b d e
Lográndose que l y r apunten a dos subárboles. Se repite el while, hasta que uno de los dos
subárboles quede vacío. La alternativa final, pega el subárbol restante, a la estructura.
La solución trivial de agregar el subárbol derecho al nodo con valor mayor de clave del subárbol
cuya raíz es l, alarga la altura en la suma de las alturas individuales. Lo mismo ocurre si se une l
al menor elemento de r.
l
a b
r
d e
pnodo DescartarRaiz(pnodo t)
{ pnodo temp = t;
t = join(t->left, t->right);
free(temp);
return t;
}
Las rutinas split y join usan intensivamente punteros. Entenderlas es un indicador que esos
conceptos y sus principales usos han logrado ser dominados.
6.6.5.10. Rotaciones
Rotaciones simples a la izquierda y a la derecha
Un esquema de las variables, que permiten diseñar las funciones, se ilustra en las Figuras 6.49 y
6.50.
pnodo rrot(pnodo t)
{ pnodo temp = t;
t = t->left;
temp->left = t->right;
t->right = temp;
return t;
}
Las siguientes funciones son mejores que las anteriores, ya que tienen una asignación menos,
sólo escriben en tres punteros.
/* Rotación Izquierda*/
pnodo rotL(pnodo t)
{ pnodo temp = t->right;
t->right = temp->left;
temp->left = t;
return ( temp);
}
A B temp
temp
t
a B A
c
b c a b
antes después
/* Rotación derecha*/
pnodo rotR(pnodo t)
{ pnodo temp = t->left;
t->left = temp->right;
temp->right = t;
return (temp);
}
A B
c a
a b c
b
antes después
Las rotaciones pueden cambiar la forma del árbol, pero no la relación de orden; en el caso de las
figuras anteriores se preserva la relación: a<A<b<B<c.
Solamente afectan a los nodos rotados y sus hijos inmediatos, los ancestros y los nietos no son
modificados.
Pueden diseñarse funciones que pasen argumentos por referencia. Se ilustra el diseño de la
rotación simple a la derecha:
rightRotRef( &(root->right));
2 2
root
root
1 6 1 4
4 7 3 6
3 7
En el diseño basado en retornos por punteros, es preciso escribir en una variable, mediante el
retorno de la función.
La inserción en la raíz coloca el nodo que será insertado en la posición de la raíz actual. De este
modo los nodos más recientemente insertados quedan más cercanos a la raíz que los más
antiguamente insertados.
Para mantener la propiedad del árbol binario de búsqueda, se inserta de manera convencional,
como una hoja, y luego mediante rotaciones se lo hace ascender a la posición de la raíz.
1 6 1 6 1 5 2 6
5 7
4 7 4 7 4 6 1 4 7
4
3 5 3 7 3
3 3
El algoritmo está basado en observar que cuando se desciende por la izquierda desde un nodo n,
durante la búsqueda para insertar, se debe rotar hacia la derecha en n. Y si se desciende por la
rama derecha, ese nodo debe rotarse hacia la izquierda. En el nodo con clave 6, se descendió por
la izquierda; luego el nodo 6 se rota a la derecha.
A medida que se desciende, para buscar la posición para insertar, se registra el nodo; luego
después de la inserción, se retorna a cada uno de estos nodos, en orden inverso, y se realiza la
rotación.
Problemas resueltos.
P6.1.
a) Diseñar función que borre el subárbol apuntado por t, liberando todo el espacio que haya sido
solicitado en forma dinámica.
b) Diseñar función que cuente en el subárbol, apuntado por t, los nodos que tengan valores
menores o iguales que k.
Solución.
a) Puede diseñarse considerando:
pnodo BorrarArbol(pnodo t)
{
if (t != NULL) {
t->left = BorrarArbol (t->left);
t->right = BorrarArbol (t->right);
if (t->v != NULL) free(t->v);
free(t);
}
return NULL;
}
Es preciso que la función retorne un puntero a nodo, para pasar los datos de los retornos de los
llamados recursivos. El esquema anterior, borra primero las hojas. El recorrido del árbol es
subárbol izquierdo, subárbol derecho y finalmente la raíz ( en orden).
El espacio adicional se requiere debido a que strlen retorna el largo del string, sin incluir el
carácter de fin de string.
La function retorna NULL, si no pudo pegar el string al nodo, en caso contrario retorna un
puntero al inicio del string.
b) Contar en subárbol apuntado por t los nodos, que tengan valores menores o iguales que el
valor entero k.
El valor retornado por la función corresponde al número de nodos que cumplen la condición.
P6.2.
3 8
1 4
2 6
Figura P6.1.
Solución.
a) Llega primero el 7. Luego pueden llegar el 3 o el 8. Luego del 3 pueden llegar el 1 o el 4. El
2 debe llegar después del 1; y el 6 después del 4.
b) 2 1 6 4 3 8 7. El 1 antes del 6, y el 4 luego de éste.
c) El 2 es el primero, luego viene el 1. No hay nada antes del 2, ya que es el primero.
d) Luego de insertar el 5 y descartar el 4, se tienen dos posibles soluciones para descartar el
nodo con valor 7:
7 6 8
3 8 3 8 3
1 6 1 5 1 6
2 5 2 2 5
Figura P6.2.
P6.3.
Para un árbol binario de búsqueda, diseñar una función, con el siguiente prototipo:
int trayectoria(pnodo t, int valor);
a) Que imprima la trayectoria desde el nodo con clave igual al argumento valor hasta la raíz, si
éste se encuentra en el árbol, y “no encontrado” en caso contrario.
b) Discuta la forma de diseño, si se desea imprimir los valores de los nodos desde la raíz, hasta
el nodo que tiene igual valor de clave que el argumento dado.
Solución.
No es necesaria la variable flag, ya que se puede emplear el retorno entero de la función, como
puede apreciarse en el siguiente diseño:
b) La impresión desde la raíz hasta el nodo con el valor buscado, puede resolverse empleando
una cola. Se encolan los valores desde la raíz, pero considerando que si se llega a un puntero
nulo, no deben imprimirse. Es más directo en este caso un diseño iterativo.
Otra solución es reemplazar en el código de la parte a), los printf por la acción de empujar los
valores a imprimir a un stack. Luego de terminada la función si el stack queda vacío, entonces la
clave no se encontró; si no está vacío, se imprimen desde el tope. De esta forma se imprimen los
valores desde la raíz hasta la hoja encontrada.
Ejercicio propuestos.
E6.1.
Desarrollar programa que efectúe listado postorden para multiárbol descrito por arreglos, con
hijo más izquierdista y hermano derecho.
E6.2.
Para un árbol binario de búsqueda, determinar procedimiento que escriba sólo las hojas.
a) Desde la hoja más izquierdista hasta la hoja más derechista.
b) Desde la hoja más derechista hasta la hoja más izquierdista.
E6.3.
Diseñar función no recursiva que borre la raíz de un subárbol, que se pasa como argumento y
retorne un puntero a la nueva raíz.
Índice general.
CAPÍTULO 6. .............................................................................................................................................1
ÁRBOLES BINARIOS DE BÚSQUEDA.................................................................................................1
6.1. DEFINICIONES. ...................................................................................................................................1
Ejemplos de definiciones......................................................................................................................2
6.2. ÁRBOL BINARIO..................................................................................................................................2
Definición de tipos de datos.................................................................................................................3
6.3. ÁRBOL BINARIO DE BÚSQUEDA. .........................................................................................................3
6.4. CÁLCULOS DE COMPLEJIDAD O ALTURA EN ÁRBOLES.........................................................................4
6.4.1. Árbol completo...........................................................................................................................4
6.4.2 Árboles incompletos con un nivel de desbalance........................................................................6
6.4.3. Árboles construidos en forma aleatoria.....................................................................................7
6.4.4. Número de comparaciones promedio en un árbol binario de búsqueda..................................11
6.4.4.1. Árbol binario externo ........................................................................................................................11
6.4.4.2. Largos de trayectorias interna y externa............................................................................................13
6.4.4.3. Búsquedas exitosas y no exitosas. ..................................................................................................... 15
6.5. RECORRIDOS EN ÁRBOLES. ...............................................................................................................19
6.5.1. En orden:..............................................................................................................................................19
6.5.2. Pre orden: .............................................................................................................................................19
6.5.3. Post orden: ...........................................................................................................................................19
6.5.4. Ejemplo de recorridos. .........................................................................................................................19
6.5.5. Árboles de expresiones. .......................................................................................................................20
6.5.6. Árboles de derivación. .........................................................................................................................20
6.6. OPERACIONES EN ÁRBOLES BINARIOS. .............................................................................................20
6.6.1. Operaciones básicas ................................................................................................................21
6.6.1.1. Crear árbol vacío. ..............................................................................................................................21
6.6.1.2. Crea nodo inicializado con un valor de clave....................................................................................21
6.6.1.3. Ejemplo de uso..................................................................................................................................21
6.6.2. Operaciones de recorrido ........................................................................................................21
6.6.2.1. Mostrar en orden ...............................................................................................................................21
6.6.2.2. Mostrar en post-orden .......................................................................................................................23
6.6.2.3. Mostrar en pre-orden.........................................................................................................................23
6.6.3. Operaciones de consulta..........................................................................................................23
6.6.3.1. Seleccionar el nodo con valor mínimo de clave. ...............................................................................23
6.6.3.2. Seleccionar el nodo con valor máximo de clave................................................................................24
6.6.3.3. Nodo descendiente del subárbol derecho con menor valor de clave. ................................................25
6.6.3.4. Sucesor..............................................................................................................................................26
6.6.3.5. Nodo descendiente del subárbol izquierdo con mayor valor de clave. ..............................................27
6.6.3.6. Predecesor. ........................................................................................................................................27
6.6.3.7. Buscar ...............................................................................................................................................28
Complejidad de la búsqueda......................................................................................................................28
6.6.4. Operaciones de modificación...................................................................................................29
6.6.4.1. Insertar nodo .....................................................................................................................................29
Diseño iterativo. ........................................................................................................................................29
Diseño recursivo........................................................................................................................................32
Índice de figuras.
Capítulo 7
Tablas de hash.
7.1. Operaciones.
La tabla de hash pertenece a la categoría de diccionarios que son aquellas estructuras de datos y
algoritmos que permiten buscar, insertar y descartar elementos.
Si las operaciones se reducen solamente a buscar e insertar se llaman tablas de símbolos.
En diccionarios puros sólo se implementa buscar.
7.2. Clave.
La información que se desea buscar suele ser una estructura que organiza la información. Uno
de los campos de esa estructura se denomina clave, y debe ser única. Sólo una de las estructuras
puede tener un determinado valor de la clave.
Si la clave es un string, deben definirse operadores de comparación, los cuales generalmente
comparan en forma alfabética.
Un arreglo es una estructura de datos que permite implementar tablas de acceso directo. Es este
caso la clave es el índice del arreglo, y existe una posición del arreglo para cada posible clave.
Si el contenido de una celda del arreglo es un puntero a la estructura con los datos asociados a la
clave, se puede implementar las operaciones en forma sencilla. Si el elemento asociado a una
clave no está presente se lo indica con un puntero de valor NULL.
Índice Tabla
0 Estructura 0
1
2 Estructura 2
3
4 Estructura 4
Para emplear esta solución el tamaño del arreglo, debe ser igual al número de claves posibles y
éstas deben estar entre 0 y N-1.
Ejemplos:
Claves alfanuméricas: Con las letras del abecedario se pueden escribir un gran número de
palabras, pero el número de palabras empleadas como identificadores por un programador es
muchísimo menor; y se desea almacenar los identificadores del programador solamente.
Claves enteras: el número de RUTs posibles asciende a varios millones, pero el número de
RUTs de los alumnos de una carrera no sobrepasa los mil; y se desea almacenar solamente los
alumnos de una carrera.
Las tablas de hash se implementan basadas en un arreglo cuyo tamaño debe ser proporcional al
número de claves almacenadas en la tabla.
Lo fundamental del método es encontrar una función que a partir de la clave (proveniente de un
universo de N claves posibles), sea ésta numérica o alfanumérica, encuentre un entero sin signo
entre 0 y B-1, si la tabla de hash está formada por B elementos. Con N>> B.
La función de hash muele o desmenuza o hace un picadillo con la clave, de allí el nombre; que
es el significado de la palabra inglesa hash (no es un apellido).
La función h(x) debe distribuir las claves, lo más equitativamente posible, entre los B valores.
Si la función de hash es “buena”, habrán pocas colisiones; si hay c colisiones en promedio, las
operaciones resultarán de complejidad O(c), constante; prácticamente independiente de B. Si
todas las claves que se buscan en la tabla, producen colisiones, que es el peor caso, las
operaciones resultarán O(B), esto en caso de tener B claves que colisionen en la misma entrada
de la tabla.
Una función bastante simple es dividir la clave por un número primo cercano al número de
baldes, y luego sacar módulo B.
También es deseable tener una función de hash que para claves muy parecidas genere valores de
hash con mucha separación, esta propiedad es muy útil en hash lineal. Existe una metodología
denominada clase universal de funciones de hash, en la cual p es un número primo mayor que B,
con a y b números enteros, y B no necesariamente primo.
Puede demostrarse que con estas funciones la probabilidad de tener colisión entre dos claves
diferentes es menor o igual a 1/B.
Para claves numéricas existen variados procedimientos experimentales. Todos ellos basados en
lograr una mezcla de los números que forman la clave, lo más aleatoria posible, mediante
corrimientos y operaciones lógicas, que son eficientemente traducidos a lenguaje de máquina.
Cambiando las siguientes definiciones de tipos las funciones para claves enteras se pueden
emplear en máquinas de diferente largo de palabra.
Para claves alfanuméricas puede emplearse la suma de los valores enteros equivalentes de los
caracteres, y aplicando módulo B a la suma total, para entregar un valor de balde válido.
Una mejor función, también propuesta por Brian Kernighan y Dennis Ritchie:
unsigned int stringhash(char *s)
{ int i;
unsigned int h=0;
for( i=0; *s; s++ ) h = 131*h + *s; /* 31 131 1313 13131 131313 .... */
return (h%B);
}
Robert Sedgwicks en su libro Algorithms in C, propone la siguiente función, que emplea dos
multiplicaciones para generar el valor de hash, y que evaluada experimentalmente, tiene muy
buen comportamiento.
La siguiente función propuesta por Serge Vakulenko, genera mediante dos rotaciones y una
resta, por cada carácter del string, el valor aleatorio.
MD5 es un algoritmo de reducción criptográfico diseñado en 1991 por Ronald Rivest del MIT
(Instituto Tecnológico de Masachusets).
Su uso principal es generar una firma digital de 128 bits (fingerprint) a partir de un documento
de largo arbitrario. La cual aplicada a strings podría emplearse como función de hash,
seleccionando algunos de los bits de los 128.
Sigue empleándose para verificar si un determinado archivo ha sido modificado, pudiendo ser
éste un correo, imagen, o un programa ejecutable. El originador del documento puede establecer
cuál es su firma digital, y el usuario debería comprobar que su copia tiene la misma huella
digital, regenerándola localmente y comparándola con la de la distribución.
Si el número de valores a los cuales se les aplica la función de hash son mucho mayores que el
número de valores que produce la función, se producirán colisiones. En un buen algoritmo de
hash, se producirán menores colisiones.
Se denomina hash abierto o externo o encadenado, a la estructura que resuelve las colisiones
mediante una lista. En éstas el número n, de elementos almacenados en la tabla, podría ser
superior al número B de elementos del arreglo ( n B ).
Se denomina hash cerrado, a las estructuras que almacenan los datos en las mismas casillas del
arreglo; debiendo disponerse de un método para determinar si el elemento del arreglo está vacío,
ocupado o descartado. En éstas el número n de elementos almacenados en la tabla no puede ser
superior al número B de elementos del arreglo ( n B ).
Se ilustra una tabla de B entradas, que puede considerarse como un arreglo de punteros.
En la figura 7.2: la entrada 1 está vacía; la entrada 0 tiene un elemento; la entrada dos, tiene dos
elementos, y muestra que las colisiones se resuelven mediante una lista. En la estructura de
datos asociada a cada nodo, sólo se ilustra el almacenamiento de la clave.
#define B 10 /* 10 baldes */
static pcelda hashtabla[B]; /*tabla punteros */
La siguiente función solicita espacio en el heap, y a través de strcpy copia el valor del
argumento en el espacio recién creado.
if (( cp = buscar(s)) == NULL)
{
cp = (pcelda ) malloc(sizeof (tcelda ));
if (cp == NULL) return (NULL);
if (( cp-> nombre = strsave(s)) == NULL ) return (NULL);
hval = h(cp -> nombre);
cp -> next = hashtabla[hval];
hashtabla[hval] = cp;
return (cp);
}
else return (NULL);
}
7.6.1.3.5. Descartar.
Cálcula índice.
Si no hay elementos ligados al balde, retorna 1.
Si hay elementos:
Busca en el primer elemento, si lo encuentra liga la lista.
Si no es el primer elemento recorre la lista, manteniendo un puntero q al anterior.
Si lo encuentra a partir del segundo: Pega la lista, mediante q;
Si estaba en la lista: Liberar espacio; retorna 0, indicando función realizada.
Si no la encontró: retorna 1, error de descarte.
FC = n/B
Llenar una tabla con n items tiene complejidad: O( n(1+n/B) ). Ya que se debe buscar n veces.
7.6.1.4.2. Distribución binomial:
Con el siguiente modelo:
Para 100 baldes se ilustran los largos promedios de las listas con 90 y 50 elementos en la tabla.
Se aprecia que es baja la probabilidad de encontrar listas de largo mayor que 4.
n=90
n=50
La tabla es un arreglo de estructuras. Con un campo para la clave y otro para el estado de la
celda. En este ejemplo se usan claves enteras.
#define B 10 /* 10 celdas */
static tcelda hashtab[B]; /*tabla de estructura */
int n; //ocupados de la tabla
7.6.2.2. Colisiones.
Si todas las localizaciones están ocupadas, la tabla está llena, y la operación falla.
La más simple forma de rehash es colocar los elementos que colisionan en las posiciones
siguientes al valor de hash j, en forma “ascendente”. Se denomina hash lineal (linear probing) al
siguiente conjunto de posiciones:
0 j
B-1
j+i
De esta forma se genera una secuencia de elementos que tienen el mismo valor j de hash; estos
elementos no son necesariamente consecutivos, debido a que algunas celdas pueden estar
ocupadas por secuencias producidas por otros valores de hash, tanto anteriores como posteriores
al valor j; también pueden existir elementos que han sido descartados. En la Figura 7.5, se
muestra la secuencia de elementos asociados al valor j de hash, los elementos descartados se
ilustran rellenos de color gris, y los pertenecientes a otros valores de hash se muestran rellenos
de color negro.
vacía
Si se desea eliminar un ítem de la tabla no se lo puede marcar como vacío, ya que se asumió que
esta es una condición para el término de la secuencia de colisiones; debido a esto se agrega el
estado descartado.
La búsqueda se detiene cuando se encuentra una posición vacía; pero debe seguir buscando si la
posición está descartada, ya que el descarte puede haber roto una cadena de colisiones, asociada
a la secuencia.
La inserción debe colocar el ítem en el primer elemento vacío o descartado de la secuencia que
comienza en j; sin embargo debe recorrer toda la secuencia asociada al valor de hash j, para
determinar si en ésta se encuentra la clave que se desea insertar, ya que no se aceptan claves
repetidas. Si se encuentra, en la secuencia la clave que se desea insertar, y la celda está ocupada
es un error de inserción. Además no debe insertarse en una tabla llena.
Entonces en hash lineal, las colisiones se resuelven probando en las siguientes posiciones de la
tabla. Pero existen numerosas maneras de resolver colisiones en una tabla de hash cerrado.
Si luego de una colisión, el primer elemento que debe seleccionarse para colocar el nuevo ítem
puede escogerse de (B-1) formas, la siguiente posición para colocar la siguiente colisión podrá
escogerse de (B-2) formas; con lo cual pueden tenerse (B-1)! trayectorias posibles dentro de una
tabla. El hash lineal es una de esas formas.
No todas las formas son satisfactorias, ya que algunas pueden producir apilamiento. Esto puede
notarse observando que mientras más larga es una secuencia de colisiones, más probables son
las colisiones con ella cuando se desee agregar nuevos elementos a la tabla. Adicionalmente,
una secuencia larga de colisiones tiene gran probabilidad de colisionar con otras secuencias ya
existentes, lo cual tiende a dispersar aún más la secuencia.
Si sólo se implementa buscar e insertar, las funciones resultan más sencillas, y sólo son
necesarios los estados vacío y ocupado. Debido a las dificultades que genera la operación
descartar en hash cerrado, no suele estar implementada, y si se desea tenerla es preferible
emplear tablas de hash abiertas.
En hash doble se usa otra función de hash, para calcular el incremento (número entre 0 y B-1),
para obtener la posición del siguiente intento.
En hash aleatorio, se genera un número aleatorio entre 0 y B-1 para obtener el incremento.
En algunos casos, si las claves que pueden almacenarse en la tabla son fijas, puede encontrarse
una función de hash perfecta que no produzca colisiones.
void PrtTabla(void)
{ int i;
if(n>0)
{ printf("Tabla\n");
for(i=0; i<B; i++) PrtItem(i);
putchar('\n');
}
else printf("Tabla vacía\n");
}
7.6.2.5.3. Buscar.
int buscar(int clave)
{ int i, last;
if (n>0)
{ for(i=hash(clave), last=(i-1+B)%B; i!=last && hashtab[i].estado != vacio; i=(i+1)%B)
{if (hashtab[i].estado == descartado) continue;
else if (hashtab[i].clave == clave) break; //sólo compara clave si está ocupado
}
if (hashtab[i].clave == clave && hashtab[i].estado == ocupado ) return (i);
else { printf("Error en búsqueda: No encontró %d\n", clave); return (-1);}
}
else { printf("Error en búsqueda de clave %d: Tabla vacía\n", clave); return (-2);}
}
7.6.2.5.4. Insertar.
int insertar(int clave)
{ int i, last, pd=-1; //en pd se almacena posición de primer descartado.
//Al inicio esa posición no existe.
if (n<B)
{
for(i=hash(clave),last=(i-1+B) % B; i!=last && hashtab[i].estado != vacio; i=(i+1)% B)
{if (hashtab[i].estado == descartado) {if(pd == -1) pd=i; continue;}
else if (hashtab[i].clave == clave) break; //sólo compara clave si está ocupado
}
if ( hashtab[i].clave == clave && hashtab[i].estado == ocupado )
{ printf("Error en inserción: Clave %d ya estaba en la tabla\n",clave);
return(1);
}
else
Se asume que todas las claves posibles son igualmente probables, y que la función de hash las
distribuye uniformemente en los baldes.
7.7.1. En inserción:
Se desea calcular Ek+1, el valor esperado promedio de los intentos necesarios para insertar una
clave en una tabla de n posiciones, cuando ya hay k posiciones ocupadas.
En la segunda vez hay (n-1) casillas disponibles, de las cuales pueden haber (k-1) ocupadas ya
que se está seguro de que la primera estaba ocupada.
k 0 k 1 k 2 k i 2 n k
pi * * ..... * *
n 0 n 1 n 2 n i 2 n i 1
El número esperado de intentos requeridos para insertar una clave, cuando ya hay k en una tabla
de n posiciones es:
k 1
Ek 1 i pi
i 1
También es el número de intentos esperado para insertar la clave (k+1).
n 2 2 n 2 2 2 1 n 2
E2 1 1* p1 2 * p2 3 * p3 1* 2* * 3* * *
n n n 1 n n 1 n 2
n 1
E2 1
n 2 1
De lo anterior puede inducirse, que el número esperado de intentos requeridos para insertar una
clave, cuando ya hay k en una tabla de n posiciones es:
n 1
Ek 1
n k 1
7.7.2. En búsqueda:
Se desea calcular EB, el número de intentos necesarios para accesar una clave cualquiera en una
tabla en la que hay m elementos.
Podemos aprovechar el resultado anterior, observando que el número de intentos para buscar un
item es igual al número de intentos para insertarlo.
De antes teníamos que para insertar el primer elemento se requerían E1 intentos. Para insertar el
segundo se requieren E2 intentos, y así sucesivamente hasta que para insertar el m avo elemento
se requieren Em intentos.
E1 E2 ... Em 1 m
EB * Ek
m m k1
Reemplazando los valores de Ek, se obtiene:
n 1 1 1 1 1 1
EB ( ... )
m n 1 n n 1 n 2 n m 2
Para evaluar la sumatoria anterior, puede emplearse la siguiente sumatoria, conocida como la
función armónica, ya que para ella se conoce, en forma aproximada su suma:
n 1 n 1
EB (H n 1 Hn m 1 ) (ln(n 1) ln(n m 1))
m m
Agrupando las funciones logaritmo y definiendo el factor de carga como:
m
n 1
Se obtiene:
n 1 n 1 ln(1 )
EB ln
m n 1 m
EB
Para tabla casi llena, con =99 % se tiene una búsqueda en menos de 5 intentos. Puede decirse
que en promedio se requieren 3 ó 4 intentos para buscar un elemento, con tabla casi llena.
La tabla de hash es mejor que los árboles binarios de búsqueda en inserción y búsqueda.
Sin embargo tiene las limitaciones de los métodos estáticos, ya que el tamaño de la tabla debe
ser estimada a priori; además la complejidad aumenta si se implementa la operación descarte.
Problemas resueltos.
P7.1.
Para una tabla de hash cerrado de 10 posiciones, se tienen las siguientes funciones:
void imprime(void)
{ int i;
for(i=0; i<B; i++)
{
if (hashtab[i].estado==vacio) printf("%2d v ", hashtab[i].clave);
else if (hashtab[i].estado==descartado) printf("%2d d ", hashtab[i].clave);
else if (hashtab[i].estado==ocupado) printf("%2d o ", hashtab[i].clave);
else printf("%2d e ", hashtab[i].clave);
}
putchar('\n');
}
Solución.
Un diagrama con los valores y estado del arreglo, a medida que se ejecutan las instrucciones:
Índice 1 2 3 4 5 6
0 vacío vacío 17 ocupado 17 ocupado 17 ocupado 17 ocupado
1 vacío vacío vacío vacío vacío 24 ocupado
2 vacío vacío vacío vacío vacío vacío
3 vacío vacío vacío vacío vacío vacío
4 vacío vacío vacío vacío vacío 14 ocupado
5 vacío 5 ocupado 5 ocupado 5 ocupado 5 ocupado 5 ocupado
6 vacío 6 ocupado 6 ocupado 6 descartado 25 ocupado 25 ocupado
7 vacío 7 ocupado 7 ocupado 7 ocupado 7 descartado 4 ocupado
8 vacío vacío 15 ocupado 15 ocupado 15 ocupado 15 ocupado
9 vacío vacío 16 ocupado 16 ocupado 16 ocupado 16 ocupado
El programa imprimiría:
0v 1v 2v 3v 4v 5o 6o 7o 8v 9v
17 o 1 v 2 v 3 v 4 v 5 o 6 o 7 o 15 o 16 o
17 o 1 v 2 v 3 v 4 v 5 o 25 o 7 d 15 o 16 o
8e
17 o 24 o 2 v 3 v 14 o 5 o 25 o 4 o 15 o 16 o
P7.2.
Para una tabla de hash cerrado de tamaño 9, se tiene la siguiente función de hash:
h(x) = (7x + 1) % 9;
Solución.
Buscar un valor tal que el retorno de la función de hash tenga valor 3, implica 5 comparaciones,
ya que la búsqueda se detiene al encontrar una casilla vacía.
La inserción de un valor tal que el retorno de la función de hash tenga valor 3, implica 1
comparación, ya que la búsqueda se detiene al encontrar una casilla descartada o vacía.
Buscar un valor tal que el retorno de la función de hash tenga valor 4, implica 4 comparaciones,
ya que la búsqueda se detiene al encontrar una casilla vacía.
La inserción de un valor tal que el retorno de la función de hash tenga valor 4, implica 4
comparaciones, ya que la búsqueda se detiene al encontrar una casilla descartada o vacía.
Entonces:
La búsqueda de un valor tal que el retorno de la función de hash tenga valor 2 es la que tiene
mayor costo en comparaciones, requiere seis comparaciones.
La inserción de un valor tal que el retorno de la función de hash tenga valor 4 es la que tiene
mayor costo en comparaciones, requiere cuatro comparaciones.
Ejercicios propuestos.
E7.1.
Se desea reemplazar una tabla de hash abierta de B1 baldes, que tiene bastante más que B1
elementos, por otra tabla de hash de B2 baldes. Describir la construcción de la nueva tabla a
partir de la vieja.
DejarTablaVacia();
insertar(6); insertar(7); insertar(16); descartar(7); insertar(5); insertar(26); PrtTabla();
DejarTablaVacia();
insertar(5); insertar(6); insertar(7); insertar(15); descartar(6); descartar(7); insertar(25);
insertar(26); PrtTabla();
}
Diseñar funciones para una tabla de hash abierto que resuelve las colisiones empleando una
tabla de hash cerrado. Ver 11.5 en texto de Cormen.
Referencias.
CAPÍTULO 7 ..............................................................................................................................................1
TABLAS DE HASH. ..................................................................................................................................1
7.1. OPERACIONES. ...................................................................................................................................1
7.2. CLAVE. ...............................................................................................................................................1
7.3. TABLA DE ACCESO DIRECTO. ..............................................................................................................1
7.4. TABLAS DE HASH. ..............................................................................................................................2
7.5. FUNCIÓN DE HASH. .............................................................................................................................2
7.5.1. Funciones de hash para enteros. ...............................................................................................3
7.5.2. Funciones de hash para strings alfanuméricos. .........................................................................4
7.6. TIPOS DE TABLA. ................................................................................................................................6
7.6.1. HASH ABIERTO. ...............................................................................................................................6
7.6.1.1. Diagrama de la estructura. .....................................................................................................6
7.6.1.2. Declaración de tipos, definición de variables. ........................................................................7
7.6.1.3. Operaciones en hash abierto. .................................................................................................7
7.6.1.3.1. Crear tabla vacía. .............................................................................................................................. 7
7.6.1.3.2. Función de hash................................................................................................................................ 7
7.6.1.3.3. Buscar si un string está en la tabla.................................................................................................... 7
7.6.1.3.4. Insertar string en la tabla. ................................................................................................................. 7
7.6.1.3.5. Descartar. ......................................................................................................................................... 8
7.6.1.4. Análisis de complejidad en hash abierto. ................................................................................9
7.6.1.4.1 Caso ideal. ......................................................................................................................................... 9
7.6.1.4.2. Distribución binomial: ...................................................................................................................... 9
7.6.2. HASH CERRADO. ............................................................................................................................10
7.6.2.1. Estructura de datos. ..............................................................................................................10
7.6.2.2. Colisiones. .............................................................................................................................10
7.6.2.3. Hash lineal. ...........................................................................................................................11
7.6.2.4. Otros métodos de resolver colisiones. ...................................................................................13
7.6.2.5. Operaciones en tabla de hash cerrado lineal. ......................................................................13
7.6.2.5.1. Crear tabla vacía. ............................................................................................................................ 13
7.6.2.5.2. Imprimir item y la tabla. Listador................................................................................................... 13
7.6.2.5.3. Buscar. ........................................................................................................................................... 14
7.6.2.5.4. Insertar. .......................................................................................................................................... 14
7.6.2.5.5. Descartar. ....................................................................................................................................... 15
7.7. ANÁLISIS DE COMPLEJIDAD EN HASH CERRADO................................................................................15
7.7.1. En inserción: ............................................................................................................................15
7.7.2. En búsqueda: ...........................................................................................................................17
7.8. VENTAJAS Y DESVENTAJAS. .............................................................................................................18
PROBLEMAS RESUELTOS. ........................................................................................................................19
P7.1. ...................................................................................................................................................19
P7.2. ...................................................................................................................................................20
EJERCICIOS PROPUESTOS. ........................................................................................................................22
E7.1. ...................................................................................................................................................22
E7.2. Hash Cerrado. ..........................................................................................................................22
E7.3. Para la funciones definidas en 7.6.2.5. Determinar las impresiones. ......................................23
P7.4. Hash abierto y cerrado. ............................................................................................................23
REFERENCIAS. .........................................................................................................................................23
Índice de figuras.
Capítulo 8
Colas de prioridad.
Se desea disponer de una estructura de datos y encontrar los algoritmos asociados que sean
eficientes para seleccionar un elemento de un grupo.
8.1. Operaciones.
Se definen dos operaciones: Insertar un elemento en el conjunto, y otra que busca y descarta
(selecciona) el elemento con valor menor del campo prioridad.
Es necesario encontrar una nueva estructura, ya que las vistas anteriormente tienen limitaciones.
La tabla de hash permite insertar con costo constante, pero encontrar y descartar el mínimo tiene
alta complejidad.
Una lista ordenada en forma ascendente por la prioridad, permite seleccionar el mínimo con
costo O(1). Pero la inserción, manteniendo en orden tiene costo promedio O(n); si encuentra el
lugar para insertar a la primera es costo 1; pero si debe recorrer toda la lista, debe efectuar n
comparaciones; en promedio debe efectuar un recorrido de n/2 pasos.
Estudiaremos usar la estructura de un árbol binario, ya que ésta garantiza que las operaciones de
inserción y descarte sean de complejidad O( log2 (n) ), pero deberemos efectuar modificaciones
ya que en una cola de prioridad se permiten claves duplicadas.
La altura es el número de nodos de una trayectoria desde la raíz hasta las hojas, incluyendo
ambos.
Los siguientes diagramas ilustran árboles binarios balaceados completos, con todas las hojas en
el nivel más bajo.
En un caso general:
n = 2h -1, h = m +1 y h=log2 (n+1), despejando h de la primera relación.
Se estudian a continuación las relaciones para árboles incompletos, pero tal que las hojas que
faltan se encuentran en el último nivel y siempre a la derecha de las presentes.
Para árboles incompletos de altura cuatro se tienen árboles con nodos entre 8 y 9. Es decir para
n >= 24-1 y n < 24-1.
2h 1
n 2h 1
Se puede encontrar un n0 y un c tal que c*log2 (n+1) sea una cota superior para h.
Entonces:
h = Parte entera( log2 (n+1) ) = O( log2 (n) )
1,03log2 (n)
n0
h(n)
log2 (n)
Si escogemos un árbol binario parcialmente completo, lo más balanceado posible; que podemos
definir como un árbol que en su nivel más bajo (cerca de las hojas) cumple con la condición de
que las hojas que le faltan están a la derecha de las presentes.
Además debemos definirlo como parcialmente ordenado, ya que es posible tener elementos
repetidos. Esta condición la logramos estableciendo, que los nodos queden ordenados según:
Estas definiciones, determinan que en la raíz se encuentra el nodo con valor mínimo; y que la
posición para insertar queda definida como la hoja de menor nivel que falta.
mínimo
Posición
para
insertar
Esta estructura se denomina heap. Que equivale, en español, a grupo de cosas unas al lado de
otras (montón, colección).
Se visualiza como un árbol binario, pero el valor de cualquier nodo es menor o igual a los
valores de los nodos hijos. La Figura 8.5, muestra 12 valores almacenados en un heap, todos los
nodos cumplen la propiedad.
Se agregan nodos primero en el sub-árbol izquierdo, y luego en el derecho, manteniendo un
árbol binario lo más balanceado posible.
12
20 16
29 20 18 21
35 38 24 25 20 last
Figura 8.5. Árbol binario con relación de orden tipo heap.
De esta forma el nodo de mayor prioridad (el de menor valor) se encuentra ubicado en la raíz.
La siguiente idea, además de ser una genialidad, es el concepto fundamental del heap.
La clave de la raíz se almacena en el primer elemento del arreglo, con índice 1 (no se usa el 0);
luego se almacenan los nodos de primer nivel (de izquierda a derecha); luego los de segundo
nivel (siempre de izquierda a derecha) y así sucesivamente; el último presente debe marcarse
con el cursor last:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
12 20 16 29 20 18 21 35 38 24 25 20
last max
Se denomina cursor a una variable que almacena un índice de un arreglo, para diferenciarla de
un puntero que almacena una dirección.
De este modo:
el hijo izquierdo del nodo i tiene el índice 2*i.
el hijo derecho del nodo i, si existe, tiene el índice: 2*i + 1
el padre del nodo i, tiene el índice: i/2
el nodo no existe si: (i < 1) o (i > last)
La estructura anterior se denomina heap.
Si la raíz tiene índice 1, sus hijos tienen índices 2 y 3 respectivamente; si se hubiera escogido la
raíz con índice 0, tendríamos que tratar en forma excepcional a los hijos de la raíz; del mismo
modo esta elección permite determinar que el nodo raíz no tiene padre, ya que 1/2, en división
entera resulta con valor cero.
Existen algoritmos eficientes para agregar un nodo, manteniendo la propiedad del heap; y para
extraer la raíz, y reordenar los elementos de tal modo que se mantenga la propiedad de ser un
heap.
8.5. Complejidad.
En el caso del heap, como veremos a continuación, la inserción tiene complejidad log2(n); y la
extracción del mínimo, manteniendo el heap, también es log2(n).
Si se realizan n operaciones en un heap éstas tienen un costo: nlog2(n). Como veremos más
adelante se requieren realizar n operaciones para formar un heap a partir de un arreglo
desordenado, mostrando de este modo que el heap puede emplearse para desarrollar un eficiente
algoritmo de ordenamiento.
Cuando se busca: se tiene el valor a buscar; la operación de seleccionar es escoger el nodo que
tiene determinada propiedad, en este caso el de menor valor de prioridad. La selección retorna el
valor buscado.
12
20 16
29 20 18 21
35 38 24 25 20 13
Figura 8.7. Inserción de nodo con valor 13 en un heap que tenía 12 elementos.
Se agrega al final, y luego se lo hace ascender, manteniendo la propiedad del heap: El valor de
cualquier nodo es menor o igual a los valores de los nodos hijos.
Solo es necesario comparar el valor del nodo con el valor del padre del nodo, ya que el hermano
tiene un valor mayor o igual que el del padre. Se intercambia el nodo con valor 18 y el recién
insertado. Aún no se cumple la propiedad de ser un heap.
12
20 16
29 20 13 21
35 38 24 25 20 18
Figura 8.8. Ascenso del nodo con valor 13 por intercambio con el padre.
Debe continuarse la revisión, en forma ascendente hacia la raíz, para que se mantenga la
propiedad del heap. Y debe intercambiarse el nodo de valor 13, con su padre, el nodo 16.
12
20 13
29 20 16 21
35 38 24 25 20 18
Figura 8.9. Sigue ascendiendo el nodo con valor 13. Queda un heap de 13 elementos.
Comparando con la raíz, se advierte que el recién insertado no debe intercambiarse. Luego de
estas operaciones, se mantiene la propiedad del heap.
#define Ntareas 10
typedef registro heap[Ntareas+1];
Definiremos un registro, al cual se apunta con nuevo, para mostrar la inserción de un nuevo
elemento al heap.
El algoritmo consiste en colocar el elemento apuntado por last en el lugar de la raíz, y hacer
descender este nodo, de tal modo de mantener la propiedad de un heap. Al final la postcondición
es: heap(1, n).
A partir de la Figura 8.5., se tiene, después de sacar el nodo con valor 12, y mover el último al
lugar de la raíz y decrementar last:
12
20
20 16
29 20 18 21
35 38 24 25 last
Se busca el hijo menor de la raíz y se lo intercambia con el padre, si el hijo tiene un valor menor
que el padre.
16
20 20
29 20 18 21
35 38 24 25 last
Es necesario comparar la raíz con los dos hijos, esto en el caso de que ambos existan, y efectuar
el intercambio con el hijo con clave menor.
Aún es necesario seguir intercambiando, para mantener propiedad de heap, en la Figura 8.11, se
intercambia ahora el nodo con valor 20, con su hijo izquierdo, que tiene valor menor. El
recorrido se efectúa en log (n) pasos.
Basada en while.
void siftdown(int n)
{ int i=1, j;
registro temp; /* al inicio i apunta a la raíz; j al hijo izquierdo */
while (( j=(i<<1)) <= n ) /* j = 2*i */
{ if ( ( (j+1)<=n ) && ( r[j+1].prioridad < r[j].prioridad)) j++;
/*Si existe hijo derecho y es menor que el hijo izquierdo, j apunta al derecho */
Basada en for:
void siftdown(int n)
{ int i, j;
registro temp; /* al inicio i apunta a la raíz; j al hijo izquierdo */
for(i=1, j=i<<1; j<=n; i=j, j=i<<1) /* j = 2*i */
{ if ( ( (j+1)<=n ) && ( r[j+1].prioridad < r[j].prioridad)) j++;
/*Si existe hijo derecho y es menor que el hijo izquierdo, j apunta al derecho */
if (r[i].prioridad <= r[j].prioridad) break;
temp=r[j], r[j]= r[i], r[i]=temp; /*Intercambia si el hijo es menor que el padre */
/* La condición de reinicio apunta a nueva raíz en el descenso: i=j; */
}
}
Si asumimos que la raíz se copia en el registro Tarea, la extracción del mínimo se puede
escribir:
int extraemin(void)
{
if (last>=1)
{ Tarea = r[1] ; /*salvar raiz*/
printf(" %d ", Tarea.ntarea); //Hace algo con la tarea
r[1]=r[last--]; //Mueve el ultimo a la posición de la raíz. Decrementa last.
siftdown(last); //Lo hace descender, manteniendo propiedad de heap.
return(0);
}
else return(1); /*error intento extracción en heap vacio*/
}
void llenecola(void)
{ int i;
for(i=1; i< Ntareas+1; i++)
/*Si se insertan con prioridad creciente. Las tareas deben salir en orden creciente*/
//Tarea.prioridad=i;
/*Si se prueban con prioridad decreciente. Deben salir tareas en orden decreciente*/
//Tarea.prioridad=Ntareas+1-i;
La función Sch es un itinerador, que extrae uno a uno los elementos; cuando el heap queda
vacío se lo vuelve a llenar, y se avanza a la siguiente línea.
void Sch(void)
{
if ( extraemin()!= 0) { putchar('\n'); llenecola();}
}
void main(void)
{
for(;;) { Sch(); }
}
Probar las funciones requiere un diseño cuidadoso, para asegurar que éstas cumplan las
especificaciones. El ejemplo anterior, sirve de inicio para diferentes pruebas a realizar. Por
ejemplo, qué pasa si se intenta agregar más tareas de las que puede soportar el tamaño definido
para el heap. Verificar que efectivamente siempre seleccionará al menor, requiere iniciar las
prioridades con valores diferentes.
8.7. Resumen:
La complejidad queda dada por altura del árbol, que es la trayectoria más larga desde la raíz
hasta las hojas, y es: O(log2(n))
En inserción:
Se puede cambiar la relación de orden, de tal modo que el valor numérico del padre sea mayor
que el de los hijos. En este caso se hacen descender a los más livianos, y ascender a los más
pesados.
Referencias.
Robert W. Floyd. “Algorithm 113: Treesort,” Communications of the ACM , Volume 5 pág.
434. 1962.
J.W.J. Williams. Algorithm 232 (Heapsort). Communications of the ACM, Volume 7, pp.347-
348, 1964.
CAPÍTULO 8 ............................................................................................................................................. 1
COLAS DE PRIORIDAD. ........................................................................................................................ 1
8.1. OPERACIONES. ................................................................................................................................... 1
8.2. RELACIONES ENTRE EL NÚMERO DE NODOS Y LA ALTURA EN ÁRBOLES BINARIOS. ............................ 1
8.3. CARACTERÍSTICAS DE UNA POSIBLE ESTRUCTURA. ............................................................................ 3
8.4. DESCRIPCIÓN DE LA ESTRUCTURA HEAP. ........................................................................................... 4
8.5. COMPLEJIDAD. ................................................................................................................................... 5
8.5.1. La operación de inserción. ........................................................................................................ 5
8.5.2. Declaraciones de estructuras de datos y variables. .................................................................. 7
8.5.3. Inserción. ................................................................................................................................... 8
8.5.4. Extracción del mínimo............................................................................................................... 8
8.5.5. Descenso en el heap. ................................................................................................................. 9
8.5.6. Extracción del mínimo. ............................................................................................................ 10
8.6. PRUEBA DE LAS FUNCIONES. ............................................................................................................ 10
8.7. RESUMEN: ....................................................................................................................................... 11
REFERENCIAS. ........................................................................................................................................ 12
ÍNDICE GENERAL. ................................................................................................................................... 13
ÍNDICE DE FIGURAS................................................................................................................................. 13
Índice de figuras.
Capítulo 9
Ordenar
Se desea ordenar una serie de estructuras, que contienen información, de acuerdo al valor de un
campo de la estructura denominado clave.
Se denomina ordenamiento interno in situ, a aquel que no requiere espacio adicional. Es decir se
ordena en el mismo arreglo.
Suelen tenerse dos medidas de eficiencia: C(n) el número de comparaciones de claves, y M(n) el
número de movimientos de datos, necesarios para lograr el ordenamiento.
Se denomina métodos directos a aquellos algoritmos primitivos de ordenamiento, que suelen ser
fáciles de entender, y que tienen buen comportamiento para n pequeños. Sin embargo suelen ser
de complejidad O(n2). Suelen ser el punto de partida de los métodos más elaborados que suelen
ser O(n*log2(n) ).
i j
9.1.1. Selección.
9.1.1.1. Algoritmo:
Repetir hasta que quede un arreglo de un elemento:
Seleccionar el elemento de la última posición del arreglo. Sea j su cursor.
Buscar el elemento con la mayor clave en el resto del arreglo
Intercambiar el elemento de la última posición del arreglo con el de mayor clave.
Disminuir el rango del arreglo en uno.
Buscar el elemento con la mayor clave en el resto del arreglo puede traducirse por:
for (i=j-1; i>=0; i--)
{ Seleccione el mayor entre a[j-1] y a[0]; }
9.1.1.2. Operación.
/* Ordena ascendente. Desde Inf hasta Sup */
int selectsort(Tipo a[], Indice Inf, Indice Sup)
{ int op=0;
Indice i, j, max;
Tipo temp;
La figura a la derecha ilustra un caso con un arreglo de entrada ordenado en forma aleatoria.
Una variante es seleccionar el menor e intercambiarlo con el de la posición menor, del
subarreglo.
9 8 7 6 5 4 3 2 1 0 44 55 1 2 4 2 9 4 1 8 6 6 7 3 3 7 2
0 8 7 6 5 4 3 2 1 9 j= 9 4 4 5 5 1 2 4 2 7 2 1 8 6 6 7 3 3 9 4 j= 9
0 1 7 6 5 4 3 2 8 9 j= 8 4 4 5 5 1 2 4 2 3 3 1 8 6 6 7 7 2 9 4 j= 8
0 1 2 6 5 4 3 7 8 9 j= 7 4 4 5 5 1 2 4 2 3 3 1 8 6 6 7 7 2 9 4 j= 7
0 1 2 3 5 4 6 7 8 9 j= 6 4 4 6 1 2 4 2 3 3 1 8 5 5 6 7 7 2 9 4 j= 6
0 1 2 3 4 5 6 7 8 9 j= 5 1 8 6 1 2 4 2 3 3 4 4 5 5 6 7 7 2 9 4 j= 5
0 1 2 3 4 5 6 7 8 9 j= 4 1 8 6 1 2 3 3 4 2 4 4 5 5 6 7 7 2 9 4 j= 4
0 1 2 3 4 5 6 7 8 9 j= 3 1 8 6 1 2 3 3 4 2 4 4 5 5 6 7 7 2 9 4 j= 3
0 1 2 3 4 5 6 7 8 9 j= 2 1 2 6 1 8 3 3 4 2 4 4 5 5 6 7 7 2 9 4 j= 2
0 1 2 3 4 5 6 7 8 9 j= 1 6 1 2 1 8 3 3 4 2 4 4 5 5 6 7 7 2 9 4 j= 1
op= 54 o p= 54
Suma que tiene por resultado: n*(n-1)/2. A esta suma debe agregarse la selección, que es O(1),
y el intercambio (que también es O(1)); acciones que se repiten (n-1) veces. La complejidad es
T(n) = n*(n-1)/2 +(n-1). Entonces T(n) es de complejidad O(n2).
Se comparan e intercambian pares adyacentes de ítems, hasta que todos estén ordenados.
Los más conocidos son: bubblesort y shakesort.
En la etapa i-ésima se inserta a[i] en su lugar correcto entre: a[0], a[1],…, a[i-1] que están
previamente ordenados.
Hay que desplazar los elementos previamente ordenados, uno a uno, hasta encontrar la posición
donde será insertado a[i].
En el peor caso: si a[0] es mayor que a[i] hay que efectuar i copias.
En el mejor caso si a[i-1] es menor que a[i], no hay desplazamientos.
Si existen claves repetidas, éstas conservan el orden original, se dice que el algoritmo de
ordenamiento es estable.
Puede entenderse como el procedimiento usado para ordenar una mano de naipes.
Dentro de éstos se encuentran la inserción directa y la inserción binaria.
9.1.3.1. Declaraciones de tipos y macros.
typedef int Tipo; /* tipo de item del arreglo */
typedef int Indice; /* tipo del índice */
#define compGT(a, b) (a > b)
9.1.3.2. Operación.
void InsertSort(Tipo *a, Indice inf, Indice sup)
{ Tipo t;
Indice i, j;
/*Ordena ascendentemente */
for (i = inf + 1; i <= sup; i++)
{ t = a[i]; //se marca el elemento que será insertado.
/* Desplaza elementos hasta encontrar punto de inserción */
for (j = i-1; j >= inf && compGT(a[j], t); j--) a[j+1] = a[j];
a[j+1] = t; /* lo inserta */
}
}
9 8 7 6 5 4 3 2 1 0 44 55 12 42 94 18 6 67 33 72
8 9 7 6 5 4 3 2 1 0 i= 1 44 55 12 42 94 18 6 67 33 72 i= 1
7 8 9 6 5 4 3 2 1 0 i= 2 12 44 55 42 94 18 6 67 33 72 i= 2
6 7 8 9 5 4 3 2 1 0 i= 3 12 42 44 55 94 18 6 67 33 72 i= 3
5 6 7 8 9 4 3 2 1 0 i= 4 12 42 44 55 94 18 6 67 33 72 i= 4
4 5 6 7 8 9 3 2 1 0 i= 5 12 18 42 44 55 94 6 67 33 72 i= 5
3 4 5 6 7 8 9 2 1 0 i= 6 6 12 18 42 44 55 94 67 33 72 i= 6
2 3 4 5 6 7 8 9 1 0 i= 7 6 12 18 42 44 55 67 94 33 72 i= 7
1 2 3 4 5 6 7 8 9 0 i= 8 6 12 18 33 42 44 55 67 94 72 i= 8
0 1 2 3 4 5 6 7 8 9 i= 9 6 12 18 33 42 44 55 67 72 94 i= 9
op= 54 op= 30
Se agregan (n-1) movimientos por el almacenamiento del elemento que será insertado, más la
escritura en la posición de inserción; ambas son O(1).
En el mejor de los casos con un arreglo previamente ordenado en forma ascendente, el for
interno no se realiza nunca; ya que no son necesarios los desplazamientos que éste efectúa; el
lazo externo debe efectuarse (n-1) vez. Este caso es de complejidad: T(n) = O(n).
Mientras más ordenado parcialmente esté el arreglo, mejor será el comportamiento del
algoritmo de inserción.
Este ejemplo ilustra que no siempre se logra mayor eficiencia en un algoritmo efectuando
modificaciones que parecen convenientes. Éstas deben ser evaluadas, determinando su
complejidad.
Es uno de los algoritmos más rápidos para ordenar arreglos de algunas decenas de componentes.
Es un método adaptivo que funciona mejor si los arreglos están casi ordenados. El análisis de su
complejidad es difícil.
Se ordenan elementos que están a una distancia h entre ellos en pasadas consecutivas.
Al inicio h es un valor elevado y disminuye con cada pasada.
Sea hk cuando la distancia de comparación es k. La pasada se denomina fase hk .
El algoritmo termina con h1 .
Se emplea el algoritmo de inserción directa para ordenar cada pasada. Este algoritmo funciona
bien ya que los subarreglos son pequeños o están casi ordenados.
El ordenamiento burbuja ordena en fase h1 . Necesariamente shellsort tiene que llegar a ordenar
la fase h1 , pero su ventaja es que el número de intercambios en la fase h1 es menor que los
intercambios necesarios en inserción o por burbuja. La razón de esto es que si a una lista
ordenada de fase hk se la somete a un ordenamiento de fase hk −1 sigue hk ordenada.
El algoritmo original emplea que el nuevo valor de hk sea el anterior dividido por dos.
Debido al gran número de algoritmos para ordenar, se estudiarán en detalle dos de los más
conocidos y eficientes.
Heapsort que en peor caso es O(n*log2(n)), y que una refinación del método de selección.
Los primeros en exponer este algoritmo fueron: J.W.J. Williams y Robert Floyd.
44
55 12
42 94 18 06
67 last
En la primera iteración con n=8 e i=4, no se producen cambios. Se revisa el subárbol del padre
del último.
44
55 06
i =4
42 94 18 12
67 last
44
55 i =3 06
42 94 18 12
67 last
i =2 42 06
55 94 18 12
67 last
Luego de esto se tiene que el subárbol cuya raíz está apuntada por i=2, es un heap.
Finalmente la ejecución de siftdown(1,8) hace descender la raíz. Primero la intercambia con 06,
y luego con 12, resultando:
i =1 06
42 12
55 94 18 44
67 last
Cada descenso tiene un costo menor a log2(n), ya que en los primeros casos los subárboles
tienen menos nodos, salvo el descenso desde la raíz que tiene un costo log2(n). Como existen
(n/2) subárboles, en peor caso el costo total es de (n/2)*log2(n); es decir O(n*log2(n))
Entonces: for(i=last/2; i>0; i--) siftdown(i, last); revisa todos los subárboles desde el padre
del último.
Se revisan las trayectorias en los subárboles desde su inicio hasta las hojas en siftdown.
Como se realiza n veces el lazo for, si se asume costo constante para el intercambio, se tiene un
costo O(n*log2(n)).
Mediante Maple pueden comprobarse que existen las constantes c1 y c2 para n0 >10.
Sum( 1+ln(k)/ln(2), k=2..n-1)=sum( 1+ln(k)/ln(2), k=1..n);
plot([0.8*n*ln(n)/ln(2),sum(1+ln(k)/ln(2), k=2..n-1),n*ln(n)/ln(2)],
n=10..2000, color=[red,black,blue]);
Se agregan argumentos al segmento analizado antes: Se pasa un puntero a al arreglo que será
oredenado, y los índices inicial (ini debe ser siempre 1, salvo que se modifiquen las rutinas de
ascenso y descenso) y final (last) entre los cuales debe ordenar.
Se parte el arreglo a ordenar en dos subarreglos, dejando los elementos mayores que uno
denominado pivote en una parte, y los menores en la otra. Si los subarreglos no están ordenados,
se los vuelve a partir, hasta lograr el ordenamiento.
Es del tipo:
donde c es el costo de dividir en dos partes. La solución de esta relación, ver Capítulo 4,
Ejemplo 4.14, es:
Este costo es en el mejor caso, en el cual siempre se puede efectuar la división en mitades.
Es un tipo de ordenamiento por intercambio, en éstos se compara e intercambia los ítems hasta
ordenar.
El algoritmo burbuja es del tipo por intercambio, y requiere n2 comparaciones entre ítems
adyacentes.
9.2.2.1. Partición.
El núcleo del algoritmo es el ordenamiento de una partición respecto de un pivote.
i j
piv
Se asume que las direcciones de las celdas contiguas del arreglo aumentan hacia la derecha.
Se desea tener que los elementos sobre el pivote sean mayores o iguales que el valor asociado
al pivote, y los elementos bajo el pivote sean menores o iguales que el valor de éste.
Se analiza el código que realiza la partición mediante el ordenamiento por intercambio respecto
al pivote. Es un código complejo que muestra genialidad.
do {
while ( a[i].clave < piv.clave) i++; //encuentra uno mayor o igual que el valor del pivote
Se ha usado el pivote como una estructura de igual tipo que las celdas del arreglo. Y se abrevia
el intercambio con el llamado a swap, que realiza:
i j
piv 42
i j
piv 42
En el lado izquierdo se busca un elemento mayor o igual que el pivote, y en el derecho uno
menor o igual que éste.
18 55 12 42 94 06 44 67
i j
piv 42
18 06 12 42 94 55 44 67
i j
42
piv
El resultado es que los elementos sobre el pivote son mayores o iguales que el valor asociado al
pivote, y los elementos bajo el pivote son menores o iguales que éste.
En este caso se efectuó un intercambio demás (lo cual podría eliminarse si el intercambio se
protege con un if, que lo realice sólo si i es diferente de j).
Traza con valores repetidos en la partición.
Veamos un ejemplo con algunos valores iguales en la partición.
1 1 1 2 1 1 1
i j
piv 2
1 1 1 2 1 1 1
i j
piv 2
i j
piv 2
Debe notarse que la función partición cambia el orden original de las componentes con igual
valor de clave, por esto se dice que no es ordenamiento estable.
En la segunda pasada, i queda apuntando al último (recordar que se compara con el valor
almacenado en la estructura pivote); j no cambia.
1 1 1 1 1 1 2
j i
piv 2
1 2 1 2 6 2 5
i j
piv 2
1 2 1 2 6 2 5
i j
piv 2
1 2 1 2 6 2 5
i j
piv 2
1 2 1 2 6 2 5
i j
piv 2
1 2 1 2 6 2 5
j i
piv 2
Luego del análisis de estos casos, puede refinarse el segmento que realiza la partición. No
realiza el intercambio si: i es igual a j o si las claves son iguales.
do {
while ( a[i].clave < piv.clave) i++; //encuentra uno mayor o igual que el valor del pivote
while( piv.clave < a[j].clave) j--; //al salir apunta a uno menor o igual que el pivote
if (i<=j)
{ if ( (i!=j) && (a[i].clave!=a[j].clave) ) swap(i, j) ;
i++; j--;
}
} while( i<=j );
Si el cursor del pivote se elige en forma aleatoria, existirá buen comportamiento; sin embargo la
generación repetida de los números aleatorios puede requerir un tiempo no despreciable.
Encontrar la mediana de los números, que sería una mejor opción, también aumenta los costos,
debido a la complejidad de encontrar la mediana.
Una alternativa es escoger la mediana de tres números. Si l y r son los cursores del primer y
último elemento del arreglo, para formar el tercer número se toma el valor central. Es decir, se
toma la mediana de los tres números con cursores: l, r y (l+r)/2.
Se inicia una partición luego se aplica el mismo proceso a las dos particiones.
Las particiones menores, vuelven a generar dos particiones y así sucesivamente hasta que la
partición tiene un solo ítem, en este caso no se generan nuevas recursiones.
Sólo se llama recursivamente por un lado. Esto sucede siempre que la elección del pivote apunte
al elemento mayor de la subpartición. Este peor caso, de baja probabilidad daría origen a un
costo O( n2).
En este particular caso, se tiene: T(n) = T(n-1) +n con T(1)=1, cuya solución es:
n(n + 1)
T ( n) = = Θ( n 2 )
2
2 4 6 8 1 5 3 7
i j
piv 8
Se asume que el tamaño de la primera partición puede ser cualquier valor entre 1 y (n-1), y que
cada tamaño es igualmente probable que ocurra.
1
Es decir, la probabilidad de que ocurra cualquiera de los tamaños es:
n
Si al formar dos particiones, la primera tiene i elementos, la segunda tendrá (n-i) elementos.
Entonces la complejidad temporal, puede expresarse por:
T (n) = T (i) + T (n − i ) + cn
Para pasar a una relación de recurrencia de primer orden, podemos determinar la relación para
(n-1), en la fórmula anterior, resulta:
n−2
(n − 1)T (n − 1) = 2 ∑ T ( j ) + c(n − 1) 2
j =1
Despejando T(n):
nT (n) = (n + 1)T (n − 1) + 2cn − c
Para resolver esta relación, al dividir, en ambos lados, por n(n+1) se obtiene:
T (n) T (n − 1) 2c
= +
n +1 n n +1
T (n − 2) T (n − 3) 2c
= +
n −1 n−2 n −1
…….
T (2) T (1) 2c
= +
3 2 3
Si se suman, se obtiene:
T (n) T (1) 1 1 1
= + 2c( + + ... + )
n +1 2 n +1 n 3
Más exactamente:
T ( n ) := 2 γ − 4 n + 2 γ n + 2 ( n + 1 ) Ψ ( n + 1 )
2n log2(n)
T(n)
0.5n log2(n)
Que es igual a la complejidad del mejor caso, es decir, que cada vez que se efectúa una partición
se divide ésta en dos mitades.
9.2.2.5. Variantes de quicksort.
Existen muchas variantes del algoritmo.
Se plantea en rutina aparte la partición; sin embargo es preferible incluirla cómo código dentro
la función, para evitar la sobrecarga de un llamado a función.
Nótese que en piv, se guarda ahora el valor de la clave. Y que el valor se escoge, no en el centro
sino en un extremo de la partición.
El macro SAWP requiere la variable local temp.
La función que ordena, de tipo recursiva, se muestra a continuación:
Una alternativa frecuente es desarrollar una función no recursiva. La cual en lugar de invocarse
a sí misma, almacena en un stack solamente los argumentos. Luego mientras el stack no esté
vacío los va sacando.
#include "datos.h"
#include "stack.h"
#define Maxitems 10
//tamaño del stack = x. Con 2^x =N Sedgewick 7.3 pág. 314
#define SIZE 4
StackInit(SIZE);
push2(left, right); //inicia stack con los argumentos originales
Conviene definir un archivo con algunos tipos de datos que emplearán las aplicaciones.
/*datos.h> */
#ifndef __DATOS_H__
#define __DATOS_H__
#endif /* __DATOS_H__ */
La compilación condicional asegura que este archivo sólo se incluirá una vez.
La funciones que emplean el stack, suelen describirse en un archivo, mostrando los prototipos.
/*stack.h> */
#ifndef __STACK_H__
#define __STACK_H__
#endif /* __STACK_H__ */
int StackEmpty(void)
{ return( N == 0) ; //Retorna verdadero si stack vacío }
int StackFull(void)
{
return( N == MAXN) ; //Retorna verdadero si stack lleno
}
void StackDestroy(void)
{
free(s);
}
Por ejemplo:
En este caso la primera acción de qsort es retornar si el tamaño de la partición es menor que un
valor dado.
La acción de unir (merging) es combinar dos estructuras ordenadas en una sola. Suele emplearse
para ordenamiento externo; es decir para ordenar archivos, pero también puede emplearse para
ordenamiento interno. Presenta ventajas en el ordenamiento de listas.
Fue presentado por John Von Neumann en 1945.
En quicksort se selecciona un pivote, y se efectúa una partición respecto del valor de éste, luego
en forma recursiva se ordena la partición izquierda y derecha.
En mergesort se parte la estructura en dos; luego en forma recursiva se ordena la parte izquierda
y derecha, y finalmente se mezclan (merge) las dos partes en una sola ordenada y mayor.
Sus ventajas son: es O(nlog(n)) en peor caso, y es estable; es decir mantiene el orden original
de los elementos repetidos. Los algoritmos heapsort y quicksort no son estables. Sus desventajas
son que requiere un espacio adicional proporcional a n y que su complejidad de mejor caso
también es O(nlog(n)).
a b
2 4 5 7 8 1 2 3 6
1 2 2 3 4 5 6 7 8
c
Si uno de los arreglos se agota, insertar los elementos restantes en c; en caso contrario: escoger
el menor valor entre a[i] y b[j] e insertarlo en c[k], ajustando los índices.
c j
n+m
a+left
right-left+1
7 2 8 5 4
7 2 8 5 4
7 2
2 4 5 7 8
2 7 8 4 5
2 7 8 5 4
7 2
Complejidad.
La complejidad de la mezcla o merge es O(n).
Supongamos que deseamos efectuar la mezcla desde a[left] hasta a[mitad] con el subarreglo
a[mitad+1] hasta a[right].
a[left], a[left+1], …., a[mitad] y a[mitad+1], a[mitad+2], …., a[right]
Y luego dejar el resultado en el mismo arreglo a.
En estas condiciones es más costoso discernir cuando se han agotado los arreglos izquierdo y
derecho, suelen usarse centinelas con este propósito. Una alternativa es copiar los valores a un
arreglo auxiliar, pero el segundo arreglo en orden inverso.
De esta forma, como se verá, no se requieren centinelas y se evitan los test para ver si se llegó o
no al final de los arreglos, mediante los cursores i (progresivo) y j (regresivo). La dificultad es
que este método deja no estable al algoritmo.
Para ordenar arreglos formados por enteros positivos entre 0 y k, se han obtenido algoritmos
de complejidad O(n). Uno de ellos está basado en contar las ocurrencias de las claves, y
mediante éstas llevar los números a sus posiciones. Es un algoritmo estable.
a 7 2 8 5 4
c 0 0 1 0 1 1 0 1 1
Se modifica el arreglo c, de tal modo que el contenido sea el número de claves menores que
el índice. De este modo el contenido determina la posición del valor del índice.
0 1 2 3 4 5 6 7 8
c 0 0 0 1 1 2 3 3 4
Se copian los datos del arreglo a, a su posición en otro arreglo auxiliar b, mediante:
b[ c[ a[i] ] ] = a[i]
0 1 2 3 4
a 7 2 8 5 4
Luego se copia el arreglo b, en el arreglo original. Las claves repetidas mantienen las posiciones
que tenían en el arreglo original, por lo cual es un ordenamiento estable.
free(c);
free(b);
}
Problemas resueltos.
P9.1.
Modificar la rutina quicksort, tanto en sus tipos como en el algoritmo, para que ordene un
arreglo de registros ordenando por la clave primaria en forma ascendente y por la clave
secundaria en forma descendente.
Solución.
Como los elementos son menores que 100, puede formarse una clave compuesta del siguiente
modo:
E9.1.
4 5 1 4 3 1 5 6 2 3
Efectuar una traza de qsort indicando los subarreglos que resultan antes de los llamados
recursivos. Hacia abajo debe reflejarse el tiempo.
E9.2.
E9.3
Referencias.
Knuth, D. E. “The Art of Computer Programming. Vol. 3: Sorting and Searching.” Addison-
Wesley, Reading, MA, 1998.
Marcin Ciura, “Best Increments for the Average Case of Shellsort”, 13th International
Symposium on Fundamentals of Computation Theory, Riga, Latvia, Aug 22 2001.
Hoare, C. A. R. "Partition: Algorithm 63," "Quicksort: Algorithm 64," and "Find: Algorithm
65." Comm. ACM 4, 321-322, 1961.
H. H. Goldstine and J. von Neumann. “Planning and coding of problems for an electronic
computing instrument. Part II Volume 2”. Reprinted in “John von Neumann Collected Works
Volume V. Design of Computers. Theory of Automata and Numerical Analysis”. Pergamon
Press. Oxford. England. pp 152-214. 1963.
Índice general.
CAPÍTULO 9 ............................................................................................................................................. 1
ORDENAR ................................................................................................................................................. 1
9.1. MÉTODOS DIRECTOS. ......................................................................................................................... 2
9.1.1. Selección.................................................................................................................................... 2
9.1.1.1. Algoritmo:........................................................................................................................................... 2
9.1.1.2. Operación............................................................................................................................................ 2
9.1.1.3. Ejemplo............................................................................................................................................... 2
9.1.1.4. Análisis de complejidad. ..................................................................................................................... 3
9.1.2. Intercambio. .............................................................................................................................. 4
9.1.2.1. Algoritmo bubblesort de una pasada................................................................................................... 4
9.1.2.2. Operación............................................................................................................................................ 4
9.1.2.3. Análisis de complejidad. ..................................................................................................................... 4
9.1.2.4. Algoritmo shakesort o bubblesort de dos pasadas:.............................................................................. 4
9.1.3. Inserción.................................................................................................................................... 5
9.1.3.1. Declaraciones de tipos y macros. ........................................................................................................ 5
9.1.3.2. Operación............................................................................................................................................ 5
9.1.3.3. Ejemplo de inserción directa............................................................................................................... 5
9.1.3.4. Análisis de complejidad. ..................................................................................................................... 6
9.1.3.5. Inserción binaria. ................................................................................................................................ 7
9.1.4. Shellsort. (1959) ........................................................................................................................ 7
9.1.4.1. Shell Sort original. .............................................................................................................................. 8
9.1.4.2. Selección de la secuencia de valores para h. ....................................................................................... 8
9.1.4.3. Shell sort. Knuth. ................................................................................................................................ 9
9.1.4.4. Shell sort Ciura. .................................................................................................................................. 9
9.2. MÉTODOS AVANZADOS.................................................................................................................... 10
9.2.1. Heapsort (1964). ..................................................................................................................... 10
9.2.1.1. Formación del heap. Revisión de subárboles. ................................................................................... 10
9.2.1.2. Ordenamiento del heap. .................................................................................................................... 13
9.2.2. Quicksort (1961). .................................................................................................................... 14
9.2.2.1. Partición............................................................................................................................................ 14
Traza de la ejecución del segmento partición............................................................................................ 15
Traza con valores repetidos en la partición. .............................................................................................. 16
Traza con valores repetidos del pivote en la partición. ............................................................................. 17
Sobre la elección del pivote. ..................................................................................................................... 19
9.2.2.2. Ordenamiento usando recursión........................................................................................................ 19
9.2.2.3. Complejidad en Peor caso................................................................................................................. 20
9.2.2.4. Complejidad en Caso promedio. ....................................................................................................... 21
9.2.2.5. Variantes de quicksort....................................................................................................................... 23
9.2.3. Quicksort. No recursivo. ......................................................................................................... 24
9.2.3.1. Stack de usuario. ............................................................................................................................... 25
9.2.3.2. Observaciones finales. ...................................................................................................................... 27
9.2.4. Merging y Mergesort (1945) ................................................................................................... 27
9.2.4.1. Mezcla de dos arreglos. Ordenamiento estable. ................................................................................ 28
9.2.4.2. Algoritmo básico estable................................................................................................................... 29
Índice de figuras.
Capítulo 10
Grafos.
Los grafos enfatizan las relaciones complejas de conexión entre elementos de datos. Los árboles
son un caso simple de grafos.
Los grafos permiten estudiar problemas como: menor distancia o costo entre puntos; búsquedas
a través de enlaces en la web; redes eléctricas, cableado de impresos; itineración de tareas,
siguiendo trayectorias; sintonizar o relacionar postulantes con ofertas, partes con proveedores;
conectividad entre pares de sitios en la red; especificación de aplicaciones mostrando las
relaciones entre subsistemas; etc.
10.1. Definiciones.
Quedan definidos por un conjunto de vértices V, más un conjunto de elementos E que conectan
pares de vértices distintos. El grafo se describe por: G(V, E).
Vértices
elemento
adyacentes
vértice
Elemento
incidente
en vértice
Figura 10.1. Elemento, vértice, incidencia.
Grado de incidencia, del vértice, es el número de elementos que son incidentes en ese vértice.
En grafos simples se aceptan sólo un enlace entre un par de vértices diferentes. Es decir, no se
aceptan lazos (elementos adyacentes en el mismo vértice), ni elementos en paralelo (dos
elementos que son adyacentes con un mismo par de vértices.
Se dice que un grafo es denso si su densidad en proporcional a V. Esto implica que el número
de elementos E será proporcional a V2. En caso contrario es un grafo liviano (sparse).
El concepto de densidad tiene relación con el tipo de algoritmo y la estructura de datos que se
empleará para representar el grafo. Complejidades típicas son V2 y E*logE, la primera es más
adecuada a grafos densos; y la segunda a grafos livianos.
Grafos ponderados: Los elementos ( w(E) ) o los vértices tienen asociado una distancia o
costo.
Árbol de cobertura (Spanning tree) de un grafo conectado, con pesos y no dirigido, es un árbol
cuyo peso se calcula según:
w(T ) w( E )
E T
10.2. Representaciones.
Para grafos densos se prefiere la matriz de adyacencias, para grafos livianos suele emplearse
una lista de adyacencias.
Ejemplo:
La matriz m se define según:
int m[2][4];
2x4
Se almacenan por renglones. La siguiente figura ilustra el almacenamiento de los elementos del
arreglo, con direcciones crecientes hacia abajo:
m[0][0]
m[0][3]
m[1][0]
m[1][3]
Índice más derechista varía más rápido, si se accesa según el orden de almacenamiento.
Definición de variables.
Las siguientes definiciones crean el espacio. Se define un grafo G, cuya matriz de adyacencias
se define según un arreglo de r punteros para almacenar las direcciones de los arreglos de c
elementos:
V
E t
adj j
i
t[i][j] ó **(t+i+j)
t[i] ó *(t+i)
También se puede definir una matriz estática (sin llamar a malloc()) : como un arreglo de r
punteros que apuntan a arreglos con la información de las columnas.
Graph GRAPHinit(int V)
{ Graph G = malloc(sizeof *G); //crea cabecera del grafo
G->V = V; G->E = 0; //Con V vértices y 0 elementos
G->adj = MATRIXint(V, V, 0); //Lo inicia con ceros.
return G;
}
La siguiente función, libera el espacio asociado al grafo, cuidando de borrar en orden inverso al
solicitado, de tal modo de no perder las referencias.
10.2.4.2. Liberación del espacio.
void BorreGrafo(Graph G)
{ int i;
int **t = G->adj;
for (i = 0; i < G->V; i++) free(t[i]); //primero borra los renglones
free(t); //luego el arreglo de punteros a los renglones
free(G); //finalmente la cabecera
}
Si se tiene la definición:
Graph Grafo;
#define ELEMENTOS 6
Edge Elementos[ELEMENTOS]={{1,2},{1,4},{2,3},{3,4},{4,0},{3,0} };
elemento v w
0 1 2
1 1 4
2 2 3
3 3 4
4 4 0
5 3 0
Esta descripción es única, en el sentido que asocia nombres de elementos con nombres de
vértices.
Entonces la creación de la matriz de incidencia de un grafo de cinco vértices, a partir del arreglo
de elementos, definido por seis elementos, puede realizarse, según:
Mostrando que la matriz de incidencia puede obtenerse del arreglo de los elementos, lo cual
indica que son representaciones equivalentes.
1 0 2
1 4
3 2
4
5
0 3
//muestra la matriz como listas de vértices que tienen conexión con cada vértice.
void GRAPHshowL(Graph G)
{ int i, j;
printf("%d vertices, %d edges\n", G->V, G->E); //número total de elementos y vértices.
for (i = 0; i < G->V; i++)
{ printf("%2d:", i);
for (j = 0; j < G->V; j++)
if (G->adj[i][j] == 1) printf(" %2d", j);
putchar(‘\n’);
}
}
De complejidad O(V2).
Se advierte que debido a los dos for anidados es O( (V2-V)/2 ), ya que revisa sobre la diagonal.
Nótese que al recorrer la submatriz sobre la diagonal, por renglones, va reasignando, a partir de
cero, los nombres de los elementos, y sus correspondientes vértices.
0 1 2 3 4 elemento v w 1 2
0 0 3
2
0 0 0 0 1 1
1 0 0 1 0 1 1 0 4
3 4
2 1 2 4
2 0 1 0 1 0 5
3 1 4 1
3 1 0 1 0 1
4 2 3
4 1 1 0 1 0 0
5 3 4 0 3
Figura 10.9. Generación de los elementos a partir de matriz de adyacencias.
Un ejemplo de invocación:
Se visita y marca un vértice como visitado, luego se visitan (recursivamente) todos los vértices
adyacentes al recién marcado que no estén visitados. Esto va formando un árbol, con el orden en
que se van visitando los vértices.
1 2 1 3 2
4 4
4
2
1
0 3 0 3
Figura 10.10. Orden de visitas, generación de llamados.
El llamado con el elemento (0, 3) marca el 3 con la cuenta 1, y se generan los llamados: con (3,
2), (3,4).
El llamado con el elemento (3, 2) marca el 2 con la cuenta 2, y se genera el llamado: (2,1).
El llamado con el elemento (2, 1) marca el 1 con la cuenta 3, y se genera el llamado: (1,4).
El llamado con el elemento (1, 4) marca el 4 con la cuenta 4 y no genera nuevos llamados.
Se visitan todos los elementos y todos los vértices conectados al vértice de partida, no
importando el orden en que revisa los elementos incidentes en ese vértice. La estrategia
recursiva implica un orden: el último que entró es el primero que salió (LIFO), de los elementos
posibles se elige el más recientemente encontrado.
10.3.1.3. Modificación para entender la operación.
Se agrega la variable estática indente, para ir mostrando los elementos que son sometidos a
revisión. Cada vez que se genera un llamado se produce un mayor nivel de indentación; el cual
es repuesto al salir del llamado recursivo.
Se agrega un asterisco para mostrar los elementos que generan llamados recursivos.
0-3
* 3-0
3-2
* 2-1
* 1-2
1-4
* 4-0
4-1
4-3
2-3
3-4
0-4
Este algoritmo puede emplearse: para detectar circuitos, para saber si existe una trayectoria que
conecte a dos vértices dados, para determinar si es un grafo conectado, para encontrar un árbol.
Ver propiedades de los árboles dfs en Sedgewick 18.4.
10.3.1.4. Arreglo de padres.
La siguiente modificación permite almacenar en el arreglo st el padre del vértice w. Esa
información es útil para aplicaciones de este algoritmo. Y es una forma de describir el árbol.
El árbol que genera este algoritmo, se produce revisando los elementos en el siguiente orden:
0-3 , *3-0 , 3-2, *2-1, *1-2, 1-4, *4-0, 4-1, 4-3, 2-3, 3-4, 0-4. Los que generan llamados
recursivos, se preceden con un asterisco. La ilustración muestra que se avanza primero en
profundidad.
3 4
0 2 4
1 3
2 4
0 1 3
void GRAPHsearchDFSnr(Graph G)
{ int v;
cnt = 0;
for (v = 0; v < G->V; v++) {pre[v] = -1; st[v] = -1;}
for (v = 0; v < G->V; v++)
if (pre[v] == -1)
dfs(G, EDGE(v, v));
}
Se parte de un nodo, y se busca el siguiente nodo en todas las rutas de largo uno que existan;
luego se buscan todas las rutas de largo dos, y así sucesivamente.
Explorar los vértices, de acuerdo a su distancia al de partida, implica que de los elementos que
son posibles, se escoge uno y los otros se salvan para ser posteriormente explorados. Este orden
es: el primero que se encuentra, es el primero en ser procesado (FIFO).
Para visitar un vértice: se buscan los elementos que son incidentes con ese vértice, y los
elementos que tienen el otro vértice no visitado, se encolan.
Para el siguiente grafo:
1 2
0 3
Figura 10.12. Ejemplo para búsqueda primero en extensión.
A partir del nodo inicial 0, se marca el 0, y se exploran los elementos: 0-3 y 0-4. Se encolan el
0-3 y el 0-4. Ya que el 3 y 4 no han sido visitados aún.
Se desencola el 0-3, se marca el 3, y se revisan el 3-0, el 3-2 y el 3-4. Se encolan el 3-2 y el 3-4.
Ya que el 2 y el 4 no han sido visitados aún.
Se desencola el 0-4, se marca el 4, y se revisan el 4-0, 4-1 y 4-3. Se encola el 4-1.
3 4
0 2 4 0 1 3
1 3 2 4
Si los elementos tienen asociado un peso se desea encontrar un árbol tal que la suma de los
pesos de sus ramas sea mínimo.
Previo a resolver este problema es necesario modificar las estructuras de datos para incorporar
el peso del elemento. Puede escogerse un número real entre 0. y menor que 1. Esto puede
lograrse dividiendo los pesos reales por el peso del mayor levemente incrementado.
Se elige:
typedef struct
{
int v; //vértice inicial
int w; //vértice final
float wt; //peso. Puede ser un double
} Edge;
Donde la función que localiza espacio dinámico para la matriz de adyacencias es:
float **MATRIXfloat(int r, int c, float wt)
{ int i, j;
float **t = malloc(r * sizeof(float *));
for (i = 0; i < r; i++)
t[i] = malloc(c * sizeof(float));
for (i = 0; i < r; i++)
for (j = 0; j < c; j++)
t[i][j] = wt; //**(t+i+j) = wt;
return t;
}
int GRAPHedges(Edge a[], Graph G) //Forma arreglo de elementos a partir del grafo
{ int v, w, E = 0;
for (v = 0; v < G->V; v++)
for (w = v+1; w < G->V; w++)
if (G->adj[v][w] != maxWT) a[E++] = EDGE(v, w, G->adj[v][w]);
return E;
}
La descripción del grafo, descrito por sus elementos, según la lista de vértices adyacentes
resulta:
La cual entrega en st la descripción del árbol a partir de un arreglo de padres de los vértices.
0 1 2 3 4 5 6 7 Vértices.
0 0 0 4 7 3 4 1 Padre del vértice.
1.0 0.32 0.29 0.34 0.46 0.18 0.52 0.21 Peso del elemento entre vértice y su Padre.
La raíz del árbol (vértice 0) tiene un lazo de peso infinito (valor 1.0) consigo misma.
Este árbol cubre todos los nodos, pero no es un árbol de cobertura mínima.
Para un grafo dado existe un muy elevado número de árboles. No es fácil encontrar el árbol de
cobertura mínima.
Un conjunto de corte son los elementos que unen dos conjuntos disjuntos de vértices.
Uno de los elementos del conjunto de corte debe ser rama.
Si los elementos tienen pesos diferentes, la rama del conjunto de corte debe tener peso mínimo.
Si existen múltiples elementos mínimos en el corte, al menos uno de ellos debe estar presente.
Así también las cuerdas deben tener los máximos pesos de los circuitos que formen con el
mínimo árbol de cobertura.
La formación de un MST (minimum spanning tree) consiste en aplicar las reglas anteriores, para
rechazar elementos que sean cuerdas, con peso máximo; y aceptar ramas, con pesos mínimos.
El algoritmo de Kruskal procesa los elementos ordenados por sus pesos. Se va agregando al
MST un elemento que no forme circuitos con los previamente agregados, y se detiene si se han
agregado (V-1) elementos. Esto porque en grafos conectados el árbol tiene (V-1) ramas.
Si se tienen vértices en el MST, se busca un vértice w que aún no está en el árbol, tal que esté a
menor distancia de los vértices del MST. Para esto es preciso registrar las menores distancias,
de cada nodo no perteneciente al MST, a los nodos pertenecientes al MST; y elegir la menor.
MST w
El árbol se describe por el arreglo padre[v], donde se almacena el padre del vértice v. Se inicia
con valores iguales a menos uno, para indicar que ningún vértice forma parte del árbol.
Antes se empleó un arreglo wt[w] para almacenar los pesos de los elementos, ahora se usa para
almacenar la mínima distancia al árbol, si el vértice w aún no pertenece al árbol; y la distancia al
padre, si el vértice w ya pertenece al MST. Al inicio se lo llena con maxWT, para indicar que
esta información sobre pesos mínimos aún no se conoce.
En la variable local min se almacena el vértice, que aún no pertenece al MST, y el cual debe
cumplir la propiedad de tener distancia mínima con los vértices que ya están en el MST. Para
asociarle un peso, se agrega una entrada al arreglo wt, con valor maxWT, y se lo inicia con valor
V (vértice que no existe en el grafo). De esta forma wt[min] tendrá un espacio y valor definido.
for (cnt=0; cnt< G->V; cnt++) //agrega todos los vertices O(V2)
{
v = min; padre[v] = DistaMenosDe[v]; //agrega vértice v al MST
//printf(" %d \n", v); //orden en que son agregados los vértices.
//Selecciona vértice min con distancia mínima a los vértices del mst
for (w = 0, min = G->V; w < G->V; w++)
//Al inicio min es un vértice que no existe.
if (padre[w] == NoVisitado) //si w no está aún en el MST
{
if (Peso < wt[w])
{ wt[w] = Peso; DistaMenosDe[w] = v; } //salva distancia menor y el vértice.
if (wt[w] < wt[min]) min = w; //selecciona nuevo vértice a distancia mínima
}
//Al salir del for interno se tiene el nuevo vértice que se agrega al MST
}
}
Para los datos anteriores, luego de ejecutado el Algoritmo de Prim, con raíz en vértice 0, se
genera:
0 1 2 3 4 5 6 7 Vértices.
0 7 0 4 7 3 7 0 Padre del vértice.
0 0.21 0.29 0.34 0.46 0.18 0.25 0.31 Peso elemento entre vértice y su Padre.
0 7 0 4 7 3 7 0 Vértice que dista menos del vértice del árbol
Se inicia con un conjunto vacío los vértices del árbol de mínima cobertura. Luego de ordenados
los elementos por sus pesos, se agrega un elemento por vez, los de menor peso se intentan
agregar primero. Antes de agregar los vértices del elemento al árbol, se revisa que no se formen
circuitos. El procedimiento termina cuando se tengan todos los vértices en el conjunto o se
hayan agregado (Vértices-1) ramas al árbol.
Verificar que los vértices agregados, asociados a un elemento, no formen circuitos no es una
tarea sencilla. Una primera aproximación es definir un arreglo id de vértices que mantenga la
información booleana de si un vértice ha sido agregado o no.
La Figura 10.15, muestra las ramas, definidas por sus vértices: (1, 4), (1, 5) y (0, 3), que ya han
sido agregadas al árbol. Al inicio se marcan todos los vértices con 0, indicando que aún no han
sido considerados. Entonces si el and de id[v] e id[w] es verdadero no se agrega el elemento
(u,w) y se considera el siguiente elemento de mayor peso; si es falso se marcan los vértices con
1, indicando su consideración.
Los vértices: 2 y 6 aún no han sido agregados, y los elementos considerados forman una foresta
o un conjunto de subárboles no conectados entre sí.
1 4 0
0 1 2 3 4 5 6
1 1 0 1 1 1 0
5 3
La información anterior no permitiría agregar una rama que conecte, por ejemplo los vértices 3
y 4; ya que estos vértices ya están marcados. Para superar esto pueden identificarse en el arreglo
a cual de los subárboles pertenecen los vértices. Se inicia el arreglo foresta con el índice
correspondiente al vértice; de este modo, al inicio cada vértice queda asociado a un subárbol
vacío. Al agregar un elemento cuyos dos vértices no pertenezcan a ninguno de los subárboles se
los marca pertenecientes a un árbol de la foresta; se emplea números para identificar los árboles
con enteros mayores que el número de vértices.
Después de agregar el elemento (1, 4) el arreglo foresta queda como se indica en la Figura
10.15a.
1 4 foresta
0 1 2 3 4 5 6
0 7 2 3 7 5 6
1 4 foresta
0 1 2 3 4 5 6
0 7 2 3 7 7 6
Si se agrega el elemento (0, 3), se crea un nuevo subárbol con identificador 8. Resulta la Figura
10.15c.
1 4 foresta
0 0 1 2 3 4 5 6
8 7 2 8 7 7 6
5 3
Si se desea agregar el elemento (3, 4), con la información de los subárboles de la foresta, ahora
es posible efectuarlo. Sin embargo deben unirse los subárboles, esto puede efectuarse cambiado
los identificadores de uno de los subárboles. Esto se muestra en la Figura 10.15d.
1 4 foresta
0 0 1 2 3 4 5 6
7 7 2 7 7 7 6
5 3
int foresta[VERTICES];
static int cntf;
La rutina de ordenamiento quicksort, se modifica, para adecuarla a los tipos de datos del grafo,
y para ordenar según el peso:
Las funciones que manejan conjuntos, se encuentra en las primeras páginas del texto de R.
Sedgewick.
int find(int x)
{ int i = x;
while (i != id[i]) i = id[i]; return i; }
void UFdestroy(void)
{
free(id); free(sz);
}
La trayectoria más corta es la suma menor de las ponderaciones o pesos de los elementos, a
través de una trayectoria orientada.
Se denominan redes a los grafos orientados con pesos para sus elementos.
Es preciso modificar las rutinas anteriores para tratar grafos dirigidos con pesos. Se considera
ahora con peso cero a los elementos de la diagonal principal, reflejando que existe conexión del
vértice con sí mismo. No se aceptan elementos en paralelo, ni elementos con pesos negativos.
Existen tres tipos de problemas. Uno de ellos es: dados dos vértices encontrar la trayectoria
orientada más corta entre los vértices, se lo denomina fuente-sumidero. Otro caso: es encontrar
10.5.1. Modificación de las funciones para tratar grafos orientados con pesos.
Ahora se coloca en la matriz que cada vértice está a distancia cero consigo mismo.
void BorreGrafo(Graph G)
{ int i;
float **t = G->adj;
for (i = 0; i < G->V; i++) free(t[i]);
free(t);
free(G);
}
Graph GRAPHinit(int V) //Crea grafo vacío, sin elementos. Sólo los vértices.
{ Graph G = malloc(sizeof *G); //crea cabecera del grafo
G->V = V; G->E = 0;
G->adj = MATRIXfloat(V, V, maxWT);
return G;
}
//Variables
Graph Grafo;
Edge Elementos[ELEMENTOS]={{0,1,.41},{1,2,.51},{2,3,.50},{4,3,.36},\
{3,5,.38},{3,0,.45},{0,5,.29},{5,4,.21},\
{1,4,.32},{4,2,.32},{5,1,.29} };
La cual muestra:
6 vértices, 11 elementos.
0: 0.00 0.41 0.29
1: 0.00 0.51 0.32
2: 0.00 0.50
3: 0.45 0.00 0.38
4: 0.32 0.36 0.00
5: 0.29 0.21 0.00
GRAPHshowM(Grafo);
Que muestra:
6 vértices, 11 elementos.
0 1 2 3 4 5
0: 0.00 0.41 * * * 0.29
1: * 0.00 0.51 * 0.32 *
2: * * 0.00 0.50 * *
3: 0.45 * * 0.00 * 0.38
4: * * 0.32 0.36 0.00 *
5: * 0.29 * * 0.21 0.00
0 1
0.41
0.29 0.29
5 0.32 0.51
0.45 0.21
0.32
0.38 0.36
4
2
0.50
3
Se puede crear un árbol de trayectorias, entre un vértice y el resto, a partir del grafo G, con una
búsqueda primero en profundidad, invocando a: GRAPHsearchDFS(Grafo);
0 1 2 3 4 5 Vértices.
0 0 1 2 5 3 Padre del vértice.
0.00 0.41 0.92 1.42 2.01 1.80 Peso trayecto entre vértice y la raíz.
0 1 2 3 5 4 Orden en que visita los vértices.
Determina un SPT, un árbol con las mínimas trayectorias, desde un vértice a los demás.
No se aceptan elementos en paralelo, ni elementos con pesos negativos.
Escoger raíz.
Repetir hasta agregar todos los vértices:
Encontrar un nodo (sea min) cuya distancia a la raíz sea la menor entre todos los nodos no
pertenecientes al SPT.
Marcar ese nodo (sea v) como perteneciente al SPT.
Repetir para cada w nodo no perteneciente al SPT:
Si hay conexión entre v y w, y si la distancia del nodo raíz a v más la distancia
de v a w es menor que la distancia actual de la raíz a w:
Actualizar la distancia de la raíz a w como la distancia de la raíz a v más
la distancia de v a w.
Actualizar el nuevo padre de w
Actualizar el vértice que está a distancia mínima.
raíz
wt[v]
wt[w]
for (cnt=0; cnt< G->V; cnt++) //agrega todos los vértices. O(V2)
{
v = min; padre[min] = DistaMenosDe[min]; //agrega vértice v al SPT
//printf(" %d \n", min);//orden en que agrega los vértices
El siguiente llamado genera un shortest path tree, como un arreglo de padres, a partir del grafo
G, con fuente o raíz 2.
sptDijkstra(Grafo, 2);
0 1 2 3 4 5 Vértices.
3 5 2 2 5 3 Padre del vértice.
0.95 1.17 0.00 0.50 1.09 0.88 Peso trayecto entre vértice y raíz. wt[i]
3 5 2 2 5 3 Vértice que Dista Menos Del vértice del árbol
La raíz 2 a distancia cero de sí misma.
0 1
0.29
5
0.45 0.21
0.38
4
2
0.50
3
Problemas resueltos.
Determinar el arreglo de pesos del trayecto de peso mínimo entre cada vértice y la raíz, y el
arreglo padres del vértice.
a) Para raíz igual al vértice 4.
b) Para raíz igual al vértice 2.
Solución:
a)
0 1 2 3 4 Vértices.
4 0 3 4 4 Padre del vértice.
0.30 0.70 1.00 0.50 0.00 Peso trayecto entre vértice y raíz.
b)
0 1 2 3 4 Vértices.
4 2 2 1 1 Padre del vértice.
0.90 0.50 0.00 0.80 0.60 Peso trayecto entre vértice y raíz.
Edge Elementos[ELEMENTOS]={{0,1,.4},{1,2,.5},{2,3,.5},{3,4,.5},\
{1,4,.1},{0,4,.3},{1,3,.3}};
a) Dibujar el grafo.
b) Determinar el mínimo árbol de cobertura aplicando algoritmo de Kruskal. Indicando el orden
en que se eligen las ramas. Dibujar el árbol.
c) Determinar el mínimo árbol de cobertura aplicando algoritmo de Prim. Indicando el orden en
que se agregan los vértices, y los arreglos de padres y de pesos entre el vértice y su padre.
Dibujar el árbol.
d) Modificar la función de Prim para imprimir el orden en que se eligen los vértices.
Solución.
c)
Se agregan en orden: 0, 4, 1, 3, 2.
0 1 2 3 4 Vértices.
0 4 1 1 0 Padre del vértice.
1.00 0.10 0.50 0.30 0.30 Peso elemento entre vértice y su Padre.
2
1
0,3 0,5
0,1
0
0,3 4 3
Ejercicios propuestos.
E10.1.
4 C
A 10 18 I
H
3 17 22
4 J
B 14
7
E 6 5
8 G 8
22 9 13
F 13 K
D
CAPÍTULO 10 ........................................................................................................................................... 1
GRAFOS. .................................................................................................................................................... 1
10.1. DEFINICIONES. ................................................................................................................................. 1
10.2. REPRESENTACIONES. ....................................................................................................................... 2
10.2.1. Matriz de adyacencia de los elementos en los vértices. .......................................................... 2
10.2.2. Matrices estáticas en C. .......................................................................................................... 3
10.2.3. Matrices dinámicas en C. ........................................................................................................ 4
Declaración de estructuras de datos para un grafo. .......................................................................................... 4
Definición de variables. ................................................................................................................................... 4
10.2.4. Funciones para grafos descritos por su matriz de adyacencias. ............................................. 5
10.2.4.1. Creación. ........................................................................................................................................... 5
10.2.4.2. Liberación del espacio. ..................................................................................................................... 6
10.2.4.3. Definición de un elemento. ............................................................................................................... 6
10.2.4.4. Inserción de elemento en un grafo. ................................................................................................... 6
10.2.4.5. Eliminación de elemento. .................................................................................................................. 7
10.2.4.6. Creación de los elementos................................................................................................................. 7
10.2.4.7. Despliegue de un grafo. Lista de vértices. ......................................................................................... 8
10.2.4.8. Despliegue de la matriz de incidencias. ............................................................................................ 9
10.2.4.9. Generación de los elementos a partir de la matriz de incidencias. .................................................... 9
10.3. TRAYECTORIAS EN GRAFOS. .......................................................................................................... 10
10.3.1. Búsqueda primero en profundidad. ....................................................................................... 11
10.3.1.1. Definición de búsqueda primero en profundidad. ........................................................................... 11
10.3.1.2. Diseño de la operación. ................................................................................................................... 11
10.3.1.3. Modificación para entender la operación. ....................................................................................... 12
10.3.1.4. Arreglo de padres. ........................................................................................................................... 13
10.3.1.5. Uso de stack en búsqueda en profundidad. ..................................................................................... 14
10.3.2. Búsqueda primero en extensión. ............................................................................................ 15
10.3.2.1. Definición de la búsqueda primero en extensión............................................................................. 15
10.3.2.2. Diseño de la operación. ................................................................................................................... 16
10.4. ÁRBOLES CON PESO. ...................................................................................................................... 17
Modificación de las funciones para tratar grafos con pesos............................................................. 17
10.5. MÍNIMO ÁRBOL DE COBERTURA. .................................................................................................... 21
10.5.1. Algoritmo de Prim. ................................................................................................................ 22
10.5.2. Algoritmo de Kruskal. ........................................................................................................... 23
10.5. TRAYECTORIAS MÁS CORTAS EN GRAFOS ORIENTADOS. ................................................................ 28
10.5.1. Modificación de las funciones para tratar grafos orientados con pesos. .............................. 29
10.5.2. Algoritmo de Dijkstra. ........................................................................................................... 33
REFERENCIAS. ........................................................................................................................................ 37
PROBLEMAS RESUELTOS. ........................................................................................................................ 38
P10.1. Para el siguiente grafo orientado: ......................................................................................... 38
P10.2. Se tiene un grafo definido por un arreglo de elementos. ....................................................... 38
Ejercicios propuestos. ....................................................................................................................... 39
E10.1. ................................................................................................................................................ 39
ÍNDICE GENERAL. ................................................................................................................................... 41
ÍNDICE DE FIGURAS................................................................................................................................. 42
Capítulo 11
El alto de un árbol es el largo de la trayectoria más larga de una hoja hasta la raíz.
Adel'son-Vel'skii y Landis (1962) definieron árboles AVL en los cuales, para cada nodo, el alto
del subárbol derecho difiere del alto del subárbol izquierdo a lo más en uno.
Se define el factor de balance como el alto del subárbol derecho menos el alto del subárbol
izquierdo. Entonces en un árbol AVL, todos los nodos cumplen la propiedad de tener valores
del factor de balance iguales a: -1, 0, ó +1.
Sea nh el mínimo número de nodos en un árbol AVL de altura h dada, que se encuentra en su
peor caso de desbalance, si se agrega un nodo, tal que la nueva altura sea (h+1), dejan de ser
AVL.
Los siguientes diagramas ilustran dichos árboles, denominados de Fibonacci, y los factores de
balance de sus nodos, para alturas 0, 1 y 2. Se muestran todos los casos, separados por un eje de
simetría; a la derecha del eje se muestran los desbalanceados por la derecha; y a la izquierda los
desbalanceados por la izquierda. Las imágenes en ambos lados del eje se obtienen como
imágenes especulares de las del otro lado.
Lo que se desea encontrar es la altura máxima h de todos los árboles balanceados de n nodos.
Para resolver esto se da una altura h determinada y se intenta construir árboles balanceados
AVL con el mínimo número de nodos, éstos son los árboles de Fibonacci.
n1 = 2
-1 1
h=1
0 0
n2 = 4
-1 -1 1 1
1 -1 0 0 1 0 -1
0
0 0 0 0
Se cumple que: n2 = n1 + n0 + 1
Para construir el árbol de Fibonacci de altura h, a la raíz se agrega un subárbol de altura (h-1) y
otro de altura (h-2). La Figura 11.2 ilustra un ejemplo, de los 16 posibles, de la generación de
un árbol de Fibonacci de altura 3, mediante dos subárboles de altura 1 y 2.
n3 = 7
Se tiene: n3 = n2 + n1 + 1
1
1 1
h=3
0 0 1
Se destaca el hecho de que estos árboles son el peor caso: logran máxima altura, con el mínimo
número de nodos.
Como ejemplo de árbol con altura 4, a la raíz se agrega por la derecha un árbol de Fibonacci de
altura 3, y por la izquierda uno de altura 2, resulta la Figura 11.3.
1
1
0 1 1
1
0_ 0 0 1
nh nh 1 nh 2 1 con n0 1 y n1 2
Lo cual implica que un árbol AVL está formado por dos subárboles AVL.
La secuencia generada es: 1, 2, 4, 7, 12, 20, 33, 54… para h=0, 1, 2….
Evaluado numéricamente:
n(h) 1.894427191(1.618033988)h +.1055728091(-.6180339886)h 1
Para acotar por arriba, se desea encontrar el valor de la constante c que satisface:
c*ln(n)/ln(2)=2.078086923*ln(.5278640450*n)
Resulta:
ln( .5278640450 n )
c = 1.440420092
ln( n )
El factor que depende de n, tiende a uno:
ln ( .5278640450 n)
c= lim 1.440420092 1.440420092
n
ln ( n )
h(bst) = log(n+1)
h=1,44..log(n)
altura AVL
altura BST
La siguiente gráfica muestra la diferencia de altura del árbol AVL, respecto de uno
perfectamente balanceado, en función de n.
2, 07808ln(0,527864n)
Es decir la gráfica del cuociente:
log 2 (n)
Notar que para árboles con menos de 7000 nodos, la altura sólo se alarga en cuatro.
Otra forma de encontrar la solución de la recurrencia para n(h), es relacionarla con la secuencia
de Fibonacci, para la cual se conoce la solución.
1 1 5 n 1 1 5 n
F ( n) ( ) ( )
5 2 5 2
1 5 1 5
Empleando: 1, 61803.. se tiene que: 1 . Donde es la razón áurea.
2 2
C
AC
=
AB
A B
Figura 11.6.a. Razón áurea .
1 1 1
F (h) ( )h (1 )h ( )h
5 5 5
Observando las dos secuencias de números que generan n(h) y F(h), se encuentra:
h 0 1 2 3 4 5 6 7
n(h) 1 2 4 7 12 20 33 54
F(h) 0 1 1 2 3 5 8 13
F(h+3) 2 3 5 8 13 21 34 55
Entonces, se tiene:
n(h) = F(h + 3) – 1.
La función de inserción debe ser modificada para mantener la propiedad de árbol AVL.
Existen inserciones que sólo implican recalcular los factores de balance, ya que el árbol sigue
siendo AVL. Por ejemplo las dos inserciones siguientes, en la Figura 11.7. izquierda, sólo
modifican los factores de balance de algunos nodos ancestros del insertado, que están en la
trayectoria del recién insertado hacia la raíz.
recalculado recalculados
1 1
1 1
1 1
1 0 1 0
0 1 1 0
1 0 0_ 0 1 1
0_ 0 0
0 0 0
insertados
a) Al insertar por la izquierda, y en el proceso de ascenso, por la trayectoria desde el nodo recién
insertado hacia la raíz, revisando los factores de balance, si se llega a un nodo con factor uno,
basta corregir el factor de ese nodo (quedando éste en 0) y no es preciso seguir corrigiendo en el
ascenso. Esto debido a que ese nodo no cambiará su altura; estaba en h y queda en h.
La figura 11.8 izquierda, ilustra una situación general antes de la inserción por la izquierda; la
figura 11.8 derecha, muestra después de la inserción y de la corrección del factor de balance.
A A
0 detener
h 1
h -1 h h
h
h+1 h
h h
La Figura 11.9, a la izquierda, ilustra la situación antes de la inserción por la izquierda; la figura
a la derecha muestra después de la inserción y de la corrección del factor de balance. Pero la
inserción deja el subárbol cumpliendo la propiedad AVL.
Existen dos casos adicionales, que corresponden a inserciones por la derecha, y pueden
visualizarse con las imágenes especulares de las mostradas.
Para encontrar en qué situaciones se producen desbalances que rompan la propiedad AVL,
basaremos nuestro análisis en el siguiente subárbol AVL, al cual si se le inserta un nodo en el
subárbol derecho, quedará no AVL.
Dada la estructura de un árbol AVL, el cual está formado por subárboles AVL, se analiza un
árbol AVL de altura dos, pero el análisis es válido para cualquier subárbol AVL. Se escoge un
caso sencillo para extraer de él, el caso general:
0 0
0 0
Trataremos de insertar en posiciones que desbalanceen el árbol, notando que se deben recalcular
los factores de balance, a través de la trayectoria desde el nodo insertado hacia la raíz, y si
aparece uno con factor 2, se pierde la propiedad AVL.
11.2.3.1. Inserción externa por la derecha.
c) Si se inserta nodo F, en la rama externa más larga del subárbol derecho:
La relación de orden del árbol binario es: A<B<C<D<E<F
0 A 1 D
0 C 1 E
0 F
Se trata igual el caso: F<E. Inserciones de nodos con valores menores que B, mejoran los
factores de balance, mantienen la propiedad AVL, y no existe necesidad de corregir. Han sido
tratados en los casos a y b.
Se detecta la pérdida de propiedad AVL, para este caso, cuando el factor de balance de un nodo
recalculado después de la inserción es +2, y el factor de balance del hijo derecho de éste es
positivo.
11.2.3.2. Inserción interna por la derecha.
d) Si se inserta nodo D, en la rama interna más larga del subárbol derecho.
Con orden: A<B<C<D<E<F
2 B
0 A -1 E
1 C 0 F
0 D
Se trata igual el caso D<C. Inserciones para nodos con valores menores que B, mejoran los
factores de balance, y no existe necesidad de corregir.
Esta situación se detecta cuando el factor de balance de un nodo es +2, y el factor de balance del
hijo derecho de éste es negativo.
El caso c) requiere una reestructuración de los nodos para mantener la propiedad AVL.
Manteniendo la relación de orden: A<B<C<D<E<F, se denomina rotación simple a la izquierda,
la que deja al subárbol, según:
0 D
0 B 1 E
0 A 0 C 0 F
Figura 11.13 Árbol AVL de Figura 11.11, después de rotación simple a la izquierda.
El caso d) de la Figura 11.12, también requiere reestructurar para mantener el árbol con la
propiedad AVL.
Se corrige con una doble rotación. Primero una a la derecha, que hace ascender C (la situación
después de esta rotación, se muestra en la parte izquierda de la Figura 11.14) y luego otra a la
izquierda, que hace ascender C hasta la raíz, después de la cual se muestra en la Figura 11.14 a
la derecha.
2 B
0 C
0 A 2 C
-1 B 0 E
0 E
0 A 0 D 0 F
0 D 0 F
Existen dos casos adicionales, que corresponden inserciones por la izquierda, y pueden
visualizarse con las imágenes especulares de las mostradas.
11.3.4.1. Corrección con rotación simple en inserción.
Se puede generalizar, el caso c) con el diagrama de la Figura 11.15. La figura a la izquierda
ilustra la situación antes de agregar un nodo en el subárbol derecho de B. La figura al centro
muestra el árbol desbalanceado, no AVL. A la derecha se muestra después de una rotación
simple a la izquierda, la cual mejora el balance y genera un árbol AVL.
h h h+1
h h h h+1 h h
Sólo se afectan los factores de balance del trayecto ascendente desde el nodo insertado hacia la
raíz.
11.3.4.2. Corrección con rotación doble en inserción.
El caso d) también se puede generalizar. Si se agrega nodo en el subárbol izquierdo o derecho
del nodo B , Figura 11.16 izquierda, se producirán cambios en los factores de balance, desde el
nodo insertado hacia la raíz. Ocasionando el desbalance que se muestra en la figura a la derecha,
el árbol no es AVL.
A A
negativo
1 2
C C
0 -1
B B
0 -1
h h h h
h-1 h-1 h h-1
Lo cual se corrige, con una rotación a la derecha, que hace ascender B , Figura 11.17 izquierda,
y luego otra a la izquierda para llevar B a la raíz del subárbol, produciendo un árbol AVL.
h h-1
h h
h h
h h-1
Del análisis anterior, el algoritmo de inserción, debe implementar la rotación simple a la derecha
y la rotación simple a la izquierda.
Observando las correcciones después de las rotaciones, se concluye que no es necesario seguir
revisando los factores de balance de los nodos superiores a la raíz del subárbol que
originalmente produjo la necesidad de balancear (el que llegó a +2 ó -2), ya que éste queda con
factor de balance 0. El alto de ese nodo es (h+1), antes y después de la inserción y las
correcciones.
Se descarta en forma similar a un árbol binario. Sin embargo, debido a la propiedad AVL, si el
nodo a descartar tiene un solo subárbol (derecho o izquierdo), ese subárbol debe ser una hoja.
Si tiene dos subárboles, de acuerdo al factor de balance se descarta el nodo que mejore el
balance.
Luego del descarte, debe ascenderse para mantener los factores de balance de la trayectoria
hacia la raíz, se pueden presentar varios casos. Debido a la simetría sólo se analizan casos de
descarte por la izquierda, la solución para descartes por la derecha, se obtiene mediante
imágenes especulares.
h h h-1 h
h+1 h h h
h h-1 h
h h h h h-1 h
Figura 11.20 Descarte por rama izquierda. Deja de ser AVL. Caso c.
h h-1 h
h-1 h h-1 h h-1 h-1
Las figuras centrales de 11.21 y 11.22 muestran que se puede discernir entre los casos d y e,
observando el hijo derecho del nodo que pasó a tener factor de balance dos.
Esto implica que la función descartar debe analizar 10 casos. Cinco en descartes por la izquierda
y 5 por la derecha.
Se analizan en detalle rotaciones simples, que se deben emplear para mantener la propiedad de
árbol AVL, en aquellos casos que lo requieren.
temp A B t
temp 0
2 t
B A
a 1
0 c
b c
a
h b
h h+1
h h h+1
En caso de un árbol AVL, la corrección de los factores de balance se puede efectuar según:
Sin embargo para lograr una rutina general de rotación se analiza la siguiente situación:
a
b c
a b c
Sean a, b y c los altos de los subárboles, que no cambian. Antes de la rotación, los factores de
balance de los nodos A y B son x e y, respectivamente; luego de la rotación éstos se denominan:
nA y nB, según se muestra en la Figura 11.24.
Cálculo de nA:
Reemplazando (b-a) por (x-1) para b>c, y por (c-a)-(c-b) para c>b, se obtienen:
nA=x-1-max(y,0)
Lo cual puede comprobarse, ya que si y es negativo (para b>c), queda nA=x-1; y si y es positivo
(para c>b), queda nA=x-1-y.
Cálculo de nB:
Para a>b, se tiene reemplazando (c-a) por (b-a)+(c-b) si b>c se obtiene:
nB=(c-a)-1=(x-1)+y-1 es decir: nB=x-2+y
Para a>b, se tiene reemplazando (c-a) por (x-1) si c>b se obtiene
nB=(c-a)-1=x-2+0.
Las dos relaciones anteriores pueden anotarse, en forma compacta:
nB=x-2+min(y, 0)
Lo cual puede comprobarse, ya que si y es negativo (para b>c) se tiene que min(y,0) es y; si
y>0, se tiene que min(y,0) es cero.
nB = c-(max(a,b)+1)
nB = min(c-a-1, c-b-1)
nB = min(nB, y-1)
Finalmente, reemplazando nB, en la expresión en la derecha por x-2+min(y, 0), calculada antes,
se obtiene:
nB = min(x-2+min(y,0), y-1)
11.5 Operaciones.
tree insertR(tree t)
{
if (t == NULL){ /* Llegó a un punto de inserción */
t = CreaNodo(key); /* Crea nuevo nodo */
t->bal = 0; /* Los dos hijos son nulos */
flag = 1; /* Marca necesidad de revisar balances */
return t; /* retorna puntero al insertado */
}
else if (t->clave < key){
//desciende por la derecha
t->right = insertR(t->right);
//se pasa por la siguiente línea en la revisión ascendente
t->bal += flag; /* Incrementa factor de balance */
}
else if (t->clave > key){
//desciende por la izquierda
t->left = insertR(t->left);
//se corrige en el ascenso
t->bal -= flag; /* Decrementa balance */
}
else { /* (t->k == key) Ya estaba en el árbol */
Error(1);
flag = 0;
}
pnodo deleteR(pnodo t)
{ pnodo p;
if(t == NULL) { /* No encontró nodo a descartar */
Error(0);
flag = 0;
}
else if(t->clave < key) {
//Comienza el descenso por la derecha
t->right = deleteR(t->right);
//aquí se llega en el retorno ascendente.
t->bal -= flag; /* Se descartó por la derecha. Disminuye factor */
/* Mantiene árbol balanceado avl. Sólo una o dos rotaciones por descarte */
if (flag == 0 ) /* No hay que rebalancear. Sigue el ascenso, sin rebalancear */
return t;
11.5.5. Rotaciones.
/* Rotación Izquierda
*
* A B
* / \ / \
* a B ==> A c
* / \ / \
* b c a b
* Sólo cambian los factores de balance de los nodos A y B
* Los factores de balance de los sub-árboles no cambian.
*/
/* Rotación derecha
*
* A B
* / \ / \
* B c ==> a A
* / \ / \
* a b b c
*
*/
static pnodo rrot(pnodo t)
{ tree temp = t;
int x,y;
t = t->left;
temp->left = t->right;
t->right = temp;
x = temp->bal;
y = t->bal;
/* x=c-1-a ó x=c-1-b. y = b-a
* nA = c-b. nB =c+1-a ó nB=b+1-a
* nA= x+1-y o nA=x+1-0
* nA = x+1-min(y,0)
* nB = max(b,c)+1-a => max(b-a+1,c-a+1)
* => max(y+1,x+2+max(y,0))
*/
temp->bal = x+1-min(y, 0);
t->bal = max(x+2+max(y, 0), y+1);
return t;
}
int Altura(void)
{
return alto_avl;
}
pnodo deltreeR(pnodo t)
{
if (t != NULL) {
t->left = deltreeR(t->left);
t->right = deltreeR(t->right);
free(t); //borra la raíz subárbol
}
return NULL;
}
Problemas resueltos.
3 8
1 4
Figura P11.1.
Solución.
a) Los factores de balance:
-1 7
0 3 0 8
1 0 4 0
Figura P11.2.
b) Luego de insertar el 2, no queda AVL. Con una rotación simple a la derecha, se reestablece
la propiedad AVL.
+1
3
-2 7 0
3
+1 1 -1 7
3 -1 0 7
8 +1 1 0
0 2 +1 4 8 0
1 +1 4 0 0 8 0
0 2 4
6 0
2 0
Figura P11.3.
Ejercicios propuestos.
4 12
2 7 9 14
1 3 5 11 13 15
1
Figura E11.1.
a) Determinar el árbol y el factor de balance de cada nodo después de Insertar nodos con
valores 6 y 10. Especificar las operaciones elementales para mantener el balance, indicando
cómo queda el árbol.
b) Determinar el árbol y el factor de balance de cada nodo después de Descartar nodos con
valores 13, 14, 15, 9 y 11. Especificar las operaciones elementales para mantener el balance,
indicando cómo queda el árbol.
Nota: en a) y b) se parte del árbol dado. Son situaciones independientes.
Referencias.
G.M. Adel'son-Vel'skii and E.M. Landis. “An algorithm for the organization of information.”
Soviet Mathematics Monthly, Volume 3, pp.1259-1263, 1962.
CAPÍTULO 11 ............................................................................................................................................1
ÁRBOLES BINARIOS BALANCEADOS DE BÚSQUEDA. AVL. ......................................................1
11.1 ANÁLISIS DE COMPLEJIDAD. ..............................................................................................................1
11.2. ANÁLISIS DE LA INSERCIÓN. .............................................................................................................6
11.2.1. Detención de revisión en ascenso. ...........................................................................................7
11.2.2. Continuar revisando factores de balance en ascenso. .............................................................7
11.2.3. Casos que producen desbalances. ............................................................................................8
11.2.3.1. Inserción externa por la derecha. ....................................................................................................... 8
11.2.3.2. Inserción interna por la derecha. ....................................................................................................... 9
11.3.4. Rotaciones para mantener propiedad AVL. .............................................................................9
11.3.4.1. Corrección con rotación simple en inserción................................................................................... 10
11.3.4.2. Corrección con rotación doble en inserción. ................................................................................... 11
11.3. ANÁLISIS DEL DESCARTE DE UN NODO. ..........................................................................................12
11.3.1. Detención de la revisión de los factores de balance en el descarte. ......................................12
11.3.2. Continuar revisando factores de balance en el descarte. ......................................................13
11.3.3. Rotación simple para corregir desbalance en descarte. Detener revisión.............................13
11.3.4. Rotación doble para corregir el desbalance en descarte. ......................................................14
11.3.5. Rotación simple para corregir desbalance en descarte. Continuar revisión. ........................14
11.4. ROTACIÓN SIMPLE A LA IZQUIERDA. ...............................................................................................15
11.5 OPERACIONES. ................................................................................................................................17
11.5.1. Definición de tipos. ................................................................................................................17
11.5.2. Definición variables globales: Flag, key, alto_avl. ..............................................................18
11.5.3. Inserta nodo en sub-árbol apuntado por t. ............................................................................18
11.5.4. Descarta nodo en sub-árbol apuntado por t. .........................................................................19
11.5.5. Rotaciones. .............................................................................................................................21
11.5.6. Otras funciones. .....................................................................................................................23
PROBLEMAS RESUELTOS. ........................................................................................................................24
P11.1. Para el siguiente árbol AVL ...................................................................................................24
EJERCICIOS PROPUESTOS. ........................................................................................................................25
E11.1 Dado el siguiente árbol AVL: ..................................................................................................25
REFERENCIAS. .........................................................................................................................................25
ÍNDICE GENERAL. ....................................................................................................................................26
ÍNDICE DE FIGURAS. ................................................................................................................................27
Índice de figuras.
Capítulo 12.
Si las claves siempre ingresan ordenadas en forma aleatoria, puede emplearse un árbol binario
de búsqueda. Sin embargo si por momentos se producen ingresos de claves en orden, debe
escogerse una estructura que pueda rebalancear dinámicamente el árbol. El árbol coloreado,
como se verá, tiene un criterio de balance un tanto más relajado que un AVL.
Por esta razón, si los datos ingresan preponderantemente ordenados el árbol AVL tiene un mejor
comportamiento que el red-black, ya que tiene una altura menor.
Si los datos son accesados mayormente en forma secuencial, el árbol desplegado, splay, tiene un
mejor comportamiento que los anteriores.
Se agrega a cada nodo un dato que indica su color. Existen reglas que deben cumplirse para
asignar el color a cada nodo, de tal modo que una trayectoria cualquiera desde un nodo a una
hoja no sea mayor que el doble del largo de cualquier otra. Esto asegura que el árbol coloreado
se mantenga más o menos balanceado, asegurando un costo logarítmico para las operaciones en
peor caso.
Propiedades:
1. Cualquier nodo es rojo o negro.
2. Cualquier descendiente de hoja se considera negro. (Los nodos externos, son negros; éstos no
son nodos con información.) La raíz debe ser negra.
3. Si un nodo es rojo, sus hijos son negros. No hay dos rojos adyacentes.
4. Cualquier trayecto desde un nodo hacia una hoja contiene el mismo número de nodos negros.
La Figura 15.1 muestra los nodos con valores 4, 7, 12 y 20 de color rojo. La estructura cumple
las propiedades anteriores.
4 15
2 5 12 20
También puede verse, con esta representación, que en un árbol coloreado todas las hojas tienen
la misma altura. El árbol coloreado es un caso particular de un B-Tree, en los cuales los nodos
pueden almacenar varias claves.
4 9
2 5 7 12 15 20
Debido a la presencia de nodos rojos, al menos la mitad de los nodos de un trayecto de un nodo
hasta las hojas deben ser negros.
La trayectoria más larga, una que alterna entre nodos rojos y negros, es sólo el doble del largo
de la trayectoria más corta, la formada sólo por nodos negros.
Luego de insertar desde el 1 hasta el 14, que es un peor caso de árbol binario de búsqueda, en el
árbol coloreado que se muestra en la Figura 12.2, el trayecto más largo es de 6 nodos por la vía
más larga y de tres nodos por la más corta.
En la Figura 12.2, puede comprobarse que las alturas negras de todos los nodos son iguales, y
que no hay dos rojos adyacentes; además la raíz es negra, cumpliendo todas las propiedades.
En un árbol coloreado, con n nodos internos, debe cumplirse que la altura h es a lo más:
h 2log(n 1)
Se define la función altura negra de un nodo x, bh(x), como el número de nodos negros de
cualquier trayectoria desde x hasta una hoja, no contando el nodo x.
Se desea probar, mediante inducción, que un subárbol, que parte en el nodo x, contiene a lo
menos 2bh(x)-1 nodos internos.
Considerando verdadera la proposición para el número de nodos internos del subárbol que
comienza en x; los subárboles de los hijos de x, deben entonces tener a lo menos: 2bh(x)-1-1
nodos internos.
Para obtener el número de nodos internos del subárbol que comienza en x, sumamos los nodos
internos de los subárboles hijos, más el nodo interno x, se obtiene:
Pero en un árbol coloreado al menos la mitad de los nodos de un trayecto de un nodo hasta las
hojas deben ser negros, entonces si h es la altura de un árbol, que comienza en x, se tiene que:
h
bh( x)
2
n 2bh( x) 1 2h / 2 1
Despejando h, se logra:
h 2log(n 1) (log(n))
Al insertar o descartar nodos en un árbol coloreado, pueden violarse las propiedades que los
definen; y para mantenerlo coloreado, como se verá más adelante, deben cambiarse los colores
Para un árbol AVL, la cota para la altura resulta menor que en árbol coloreado:
La Figura 12.2a, muestra la complejidad del árbol coloreado, respecto del AVL, y puede
observarse que son muy similares. Pero las operaciones se realizan en menor tiempo, en un
árbol coloreado.
coloreado
AVL
Bst promedio
balanceado
La inserción de un nuevo nodo siempre se realiza como descendiente de una hoja. Se inserta el
nuevo nodo con color rojo, ya que esto no altera las alturas negras de los trayectos. Cuando la
hoja donde se insertará el nuevo nodo es roja, se pierde la propiedad de ser un árbol coloreado,
ya que se producen dos nodos rojos adyacentes.
Si se inserta un nodo con color rojo, y si el árbol estaba vacío, debe cambiarse a negro para
mantener la propiedad de que la raíz sea negra.
Si se inserta un nodo en la raíz, como ésta es negra, se mantienen las propiedades de los árboles
coloreados.
En caso de inserción en árboles de mayores niveles, la inserción de un rojo en una hoja roja
requiere efectuar modificaciones para preservar las propiedades, en algunos casos bastará una
Lo primero que observamos es que cuando se produce un doble rojo, el abuelo del nodo rojo
insertado o del rojo que asciende debe ser negro, a este último nodo lo denominaremos con x, en
los diagramas. Esto es así ya que antes de la inserción el árbol era coloreado, y por lo tanto no
pueden tenerse tres rojos adyacentes. La segunda consideración es que las alturas negras del
nodo denominado abuelo deben ser iguales antes y después de la inserción; esto se ilustrará en
los diagramas, colocando nodos negros de tal modo de reflejar las alturas negras iguales. Se
prefiere esta representación a la de mostrar subárboles descendientes con especificaciones de
alturas negras. Una tercera consideración es que el tío del nodo rojo que asciende puede ser
negro o rojo, y que deberán analizarse ambos casos.
Se ilustran en la Figura 12.3, alturas negras del abuelo iguales a dos (más la altura negra de los
descendientes, que no se muestran), se muestra el tío de color negro.
Dicho de otra forma, el diagrama insinúa que las alturas negras del tío y de los hijos negros de
los nodos rojos deben ser iguales, y menores en una unidad a la altura negra del abuelo.
abuelo abuelo
tío tío
x
x
Existen dos casos adicionales, que son las imágenes especulares de las mostradas en la Figura
12.3, y que corresponden a los casos en que el nodo x es insertado como descendiente derecho
del nodo abuelo.
Existen dos situaciones que deben analizarse: una corresponde a tío rojo, la otra a tío negro.
Si en la Figura 12.4 a la izquierda, se cambian los colores del abuelo, el padre y el tío, se
mantienen las alturas negras del nodo abuelo hacia abajo, y no se tienen dos rojos adyacentes.
Sin embargo es necesario seguir la revisión ascendente debido a que el cambio de color del
abuelo podría producir nuevamente un doble rojo en el trayecto hacia la raíz. Es decir debe
volver a repetirse el proceso ascendiendo el puntero x a la posición del abuelo.
Si los cambios se propagarán hasta el caso en que el abuelo es la raíz, debería cambiarse el color
de ésta, pero se mantendrían altura negras iguales; en este caso aumenta la altura negra del
árbol.
abuelo abuelo
padre tío padre tío
x x
Cuando el nodo denominado x es descendiente derecho del padre, se soluciona de igual forma.
Se ilustra el caso en que el padre y el nodo x son descendientes izquierdos del nodo abuelo. Si se
cambian colores al padre y al abuelo, se disuelve el doble rojo. Sin embargo se altera la altura
negra del subárbol derecho del abuelo, como se muestra en la Figura 12.5, disminuye su cuenta
en uno.
abuelo abuelo
x x
Figura 12.5 Recoloración con tío negro, no quedan dos rojos adyacentes.
A partir del nodo padre hacia abajo se mantienen los largos negros de los subárboles y además
no se puede volver a producir un doble rojo en el trayecto de ascenso hacia la raíz, ya que el
nodo, denominado padre, en la Figura 12.6 derecha, es negro. Nótese que en la Figura 12.6 a la
derecha, se han conservado los nombres originales para los nodos: padre, abuelo y tío; sin
embargo luego de la rotación éstos nombres pierden su significado original. También puede
observarse que luego de la rotación el sub-árbol, cuya raíz es el padre, queda más balanceado.
Esta situación da por terminada la revisión ascendente.
abuelo
Antes de producirse este doble rojo, ya sea por inserción o por revisión ascendente, las alturas
negras del padre (del abuelo y sus ancestros) eran iguales. La rotación izquierda del par x-
padre, respecto al padre, no altera esas cuentas. La Figura 12.7 a la derecha, es el caso analizado
antes, que se muestra en la Figura 12.5; para el cual ya se obtuvo una solución, pero cambiando
el puntero x a la posición ocupada finalmente por el padre.
Los casos en que el nodo x es agregado como descendiente del subárbol derecho del abuelo son
situaciones con simetría especular a los tratados.
Un caso particular es cuando el tío no existe, es decir cuando es un nodo externo; en este caso se
considera tío de color negro.
Deben analizarse tres casos, que el nodo que se desea descartar tenga dos descendientes, uno
solo o ninguno.
Cuando se borra un nodo rojo se mantienen las propiedades de los árboles coloreados. Ya que
no cambian las alturas negras de los nodos.
Cuando se borra un nodo apuntado por t, que tiene los dos subárboles descendientes, se elige el
mayor descendiente del subárbol izquierdo (I) o el menor descendiente del subárbol derecho (D)
y se cambian los datos del nodo que será eliminado con el seleccionado. Si el nodo seleccionado
(I o D) es rojo, no puede tener descendientes y será una hoja; estos casos se muestran en la
Figura 12.8.
t
t
I D I D
Si el nodo seleccionado es negro, sólo puede tener un hijo rojo ya que el árbol es coloreado,
antes del descarte. Situación que se muestra en la Figura 12.9.
t
t
I D
I D
Figura 12.9. Nodo seleccionado para ser descartado es nodo negro con hijo rojo.
Luego de copiar los datos del nodo seleccionado en el nodo apuntado por t, se procede a
descartar el seleccionado; el cual debe ser una hoja roja o un nodo negro con sólo un hijo que
debe ser rojo. Estos casos se analizan a continuación.
12.4.2. Un descendiente.
Cuando se descarta un nodo t con un solo hijo, se presentan tres casos, que se ilustran en la
Figura 12.10, cuando t tiene hijo izquierdo y es un descendiente izquierdo. Debe notarse que el
nodo t debe ser negro, y que su único hijo debe ser rojo.
t t t
Se preservan las propiedades de los árboles coloreados si se liga el hijo de t, con el padre de
éste; y se le cambia el color a negro, para mantener iguales alturas negras, y no tener dos rojos
adyacentes. Igual solución se aplica si el hijo de t es descendiente derecho.
Las soluciones se muestran en la Figura 12.10a.
t t t
t t t t t
Cuando el padre es rojo, éste debe tener dos hijos negros. Luego de borrar el nodo hay que
cambiar el color del padre y del tío; esto preserva iguales alturas negras.
Cuando el nodo para descartar es rojo, hay dos casos: con hermano nulo o con hermano rojo.
Ambos casos no requieren modificaciones, y sólo es preciso borrar el nodo.
Cuando el nodo y su padre son negros se tienen dos casos, con hermano negro o rojo. En este
último caso, luego de borrar el nodo el árbol deja de ser coloreado, ya que disminuye la altura
negra del subárbol izquierdo; se soluciona rotando a la izquierda, el par padre-hermano, y
cambiando de rojo a negro el color del nuevo padre, para igualar las alturas negras. Lo cual se
muestra en la Figura 12.12. En este caso, como el hermano es rojo, el padre necesariamente
debe ser negro.
El último caso que debe analizarse es el de hijo, padre y hermano de colores negros. Si se
elimina la hoja y se cambia a color rojo el huérfano, se produce una disminución de la altura
negra del nodo padre, denominado x, en la Figura 12.13, lo cual hará perder al árbol sus
propiedades. Nótese que de x hacia abajo el árbol es coloreado.
x
t
Sea x un puntero al subárbol coloreado, que disminuyó su altura negra, y desde el cual debe
revisarse hacia arriba para mantener la propiedad de iguales alturas negras de los subárboles. La
raíz de este subárbol es negra.
h
x x
h h
x h
Si la altura negra de x es bh(x), las alturas negras de los subárboles descendientes del hermano
rojo son iguales a bh(x)+1. Esto se ilustra con dos descendientes negros en los subárboles de h.
Si el árbol era coloreado, antes del descarte, necesariamente x tiene padre, hermano y
descendientes.
Si se cambia el color del padre y del hermano se tiene el resultado que se muestra en la Figura
12.14, al centro. Luego rotando a la izquierda el par padre-hermano, se obtiene la situación
mostrada a la derecha de la Figura 12.14, en la cual se ha reposicionado h, apuntado al hermano
actual de x.
Tanto el actual abuelo como el padre de x, siguen desbalanceados. El objetivo de esta
transformación es lograr que x tenga un hermano negro. Que es el siguiente caso que se estudia.
b) Hermano negro.
Si el hermano es negro, el padre puede ser rojo o negro. Lo cual se representará por un signo de
interrogación al lado del nodo.
En esta situación se tienen tres casos que analizar: Una es si ambos hijos del hermano son
negros; la segunda es si el hijo derecho del hermano es negro y el izquierdo es rojo; y la tercera
es que el hijo derecho del hermano sea rojo, pudiendo ser rojo o negro el hijo izquierdo del
hermano.
b1) Sobrinos negros.
Si x y su hermano son negros, los subárboles descendientes de h, tienen altura negra igual a
bh(x)+1, lo cual se insinúa con un descendiente, en la Figura 12.15. Esto es así, ya que
originalmente se borró un descendiente negro de x, cuando el árbol era coloreado.
Si se cambia el color del hermano a rojo, y se mueve x, apuntando al padre, se tendrá que desde
el nuevo x hacia abajo el árbol es coloreado. Además se fuerza a negro el padre original de x,
para evitar un posible doble rojo.
? x
x
h h
x
Luego de esta transformación la altura negra del nuevo x, aumenta en uno; y se debe seguir
revisando en forma ascendente hacia la raíz. No es necesario continuar, si el nuevo x es la raíz.
b2) Sobrino derecho negro, sobrino izquierdo rojo.
Como x y su hermano son negros, el padre de x puede ser rojo o negro. Nuevamente los
descendientes de h, deben tener alturas negras: bh(x)+1.
? ? ?
x h x h x h
h h h
Primero se cambian los colores del hermano negro y del sobrino rojo, esto se muestra en el
medio de la Figura 12.16. Luego se rota a la derecha el par hermano-sobrino derecho, lo cual se
muestra a la derecha en la Figura 12.16, en la cual se mueve h, para apuntar al nuevo hermano
de x. Esta transformación conduce al caso siguiente, en la que el sobrino derecho es rojo.
b3) Sobrino derecho rojo, sobrino izquierdo negro.
El padre de x, puede ser rojo o negro. Se procede a copiar el color del padre en el hermano, y
pintar de negro al padre y al sobrino rojo, resultado que se muestra al centro de la Figura 12.17.
? ? h
x h x h
h ? h
h x
El caso en que ambos sobrinos son rojos, se trata de igual forma que el caso b3).
?
x h
h
Sin embargo si las exigencias de espacio no son fundamentales la elección de una estructura que
adicionalmente mantenga un puntero al padre del nodo, presenta ventajas en la velocidad de
ejecución de las operaciones. Además las funciones resultan iterativas, y no se produce un gran
espacio del stack, para almacenar los argumentos de las funciones recursivas.
12.5.3. Sucesor.
pnodo sucesor(pnodo x)
{ pnodo y;
if (x->right!=NULL)
{
/* Si hay subárbol derecho desciende por la izquierda en éste,
** hasta encontrar nodo sin descendiente izquierdo.
*/
for (y=x->right; y->left!=NULL; y=y->left);
}
else
{
/* Ascender hasta encontrar nodo que esté a la izquierda de su padre
** ( o la raíz) entonces retornar el padre.
*/
for(y=x->padre; y!=NULL && x==y->right; x=y, y=y->padre );
}
return(y);
}
12.5.4. Predecesor.
pnodo predecesor(pnodo x)
{ pnodo y;
if (x->left!=NULL) for (y=x->left; y->right!=NULL; y=y->right);
else
{ y=x->padre;
while(y!=NULL && x==y->left){x=y; y=y->padre;}
}
return(y);
}
Se pasa la raíz del árbol por referencia. Esto en caso que el nodo Y, se convierta en la nueva
raíz.
/*
** Rotación izquierda con padre
**
** X lrot(X) ---> Y
** / \ / \
** A Y <--- rrot(Y) X C
** / \ / \
** B C A B
** Se asume que x o y no son nulos
*/
void lrot(pnodo * raiz, pnodo x)
{ pnodo y;
//assert(x!=NULL);
//assert(x->right!=NULL);
/* Padre de y es el padre de x */
y->padre = x->padre;
/* Padre de y es el padre de x */
y->padre = x->padre;
nleft=bh(x->left);
nright=bh(x->right);
//si hay error al retornar de los llamados recursivos
if (nleft==-1 || nright==-1) return(-1); //propaga el error
if (nleft != nright)
{
printf("Negros del izquierdo=%d difieren de los del derecho=%d, clave=%d\n",
nleft, nright, x->clave);
return(-1);
}
if (x->color == BLACK)
{
nleft++; //acumula los negros en nleft
}
return(nleft);
}
int RevisaPropiedades(pnodo x)
{ int bhl=1,bhr=1;
if (x==NULL) {printf("Árbol vacío\n"); return (1);}
if (x->left==NULL && x->right==NULL)
{
if(x->color!=BLACK) printf("Raíz no es negra\n"); return(1);
}
if (x->left!=NULL && x->right==NULL)
if(x->color!=BLACK && x->left->color!=RED)
{
printf("Nodo con solo hijo izquierdo no es negro ni tiene hijo rojo\n"); return(1);
if (x->left != NULL)
{
if (x->left->padre != x)
{
printf("Hijo izquierdo de x no apunta al padre, x=%d", x->clave);
return(1);
}
bhl=bh(x->left);
if (bhl==-1)
{ printf("Subárbol izquierdo no es coloreado\n");
return(1);
}
}
if (x->right != NULL)
{
if (x->right->padre != x)
{
printf("Hijo derecho de x no apunta al padre, x=%d", x->clave);
return(1);
}
bhr=bh(x->right);
if (bhr==-1)
{ printf("Subárbol derecho no es coloreado\n");
return(1);
}
}
if(bhl!=bhr)
{ printf("Arbol no es coloreado. l=%d r=%d\n",bhl,bhr);
return(1);
}
return(0); //si cumple propiedades.
}
nuevo->padre=y;
if (y == NULL) {*tree=nuevo; nuevo->color=BLACK; return(nuevo);}
else if (nuevo->clave < y->clave) y->left=nuevo;
else y->right=nuevo;
12.7. Descarte.
Para marcar con x la raíz de un subárbol, que disminuyó su altura negra, se emplea un nodo
externo, de color negro, para almacenar un puntero al padre de éste, cuando el nodo que debe
ser descartado es negro y es una hoja. Se pasa como argumento, a la función que restaura las
propiedades de un árbol coloreado, en caso de descartar un nodo negro, si el nodo x es un
centinela (nodo externo); de esta forma antes de modificar el puntero x, se podrá escribir el
valor nulo en el padre de x.
if(y->color==BLACK)
{ //y es negro.
if (x==NULL) {centinela=1; x=&externo; x->color=BLACK;}
if (y->padre == NULL)
{ if(centinela) *rootp=NULL; else *rootp=x;}
else if (y==y->padre->left) y->padre->left = x;
else y->padre->right = x;
free(y);
}
if ( (w->right==NULL || w->right->color==BLACK)
&& (w->left==NULL || w->left->color==BLACK))
{
w->color=RED;
if(esexterno)
{x->padre->right=NULL; esexterno=0;}
x=x->padre;
}
else
{
if (w->left==NULL || w->left->color == BLACK)
{
w->right->color=BLACK;
w->color=RED;
lrot(rootp, w);
w=x->padre->left;
}
w->color=x->padre->color;
x->padre->color = BLACK;
#define N 14
pnodo arbol=NULL;
int main(void)
{ int i;
printf("Insertando ascendente\n");
for(i=1;i<=N;i++)
{insertar(&arbol, CreaNodo(i));
printf("%d->", i);
//prtinorder(arbol,0); putchar('\n');
RevisaPropiedades(arbol);}
printf("\nDescartando\n");
for(i=1;i<=N;i++)
{descarta(&arbol, buscar(&arbol,i ));
printf("%d",i);
RevisaPropiedades(arbol);
}
printf("Insertando descendente\n");
for(i=N;i>0;i--)
{insertar(&arbol, CreaNodo(i));
printf("%d->",i);
//prtinorder(arbol,0); putchar('\n');
RevisaPropiedades(arbol);
}
printf("\nDescartando\n");
for(i=N;i>0;i--)
{descarta(&arbol, buscar(&arbol,i ));
return(0);
}
Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein. “Introduction
to Algorithms”, Second Edition. MIT Press and McGraw-Hill, 2001.
Capítulo 13
13.1 Definición.
Es un árbol de búsqueda autoorganizado que emplea rotaciones para mover cualquier clave
accesada, ya sea en búsqueda, inserción o descarte, a la raíz.
Esto deja a los nodos más recientemente accesados cerca de la raíz, haciendo que la posterior
búsqueda de ellos sea eficiente.
La forma del árbol va variando de acuerdo a los nodos que son más recientemente accesados.
Fueron desarrollados por Sleator y Tarjan in 1985, en la publicación del ACM Journal, “Self-
organizing Binary Search Trees” como una alternativa a los algoritmos que mantienen
balanceado un árbol binario de búsqueda.
En el caso de los árboles splay se lleva el elemento buscado o insertado a la posición de la raíz.
En la búsqueda o la inserción bottom-up, se realiza un recorrido desde la raíz hasta encontrar el
elemento buscado; o bien hasta encontrar una hoja, en caso de inserción. Luego de lo anterior se
realiza una operación splay para mover el elemento a la posición de la raíz.
La operación splay, consiste de una secuencia de dobles rotaciones, hasta que el nodo quede a
un nivel debajo de la raíz; en este caso basta una rotación simple para completar la operación.
En cada operación splay se hace ascender al nodo en uno o dos niveles, dependiendo de su
orientación relativa respecto de su nodo abuelo.
Gráficamente:
y x
Zig.
x C y
A
A B B C
y A y
D
x z
C B
Figura 13.2. Operación Zig-Zig.
A B C D
Si inicialmente t apunta al abuelo de x. Se rota el abuelo de x, y luego el padre del nodo x.
Se logra con la secuencia :
t=rrot(t);
t=rrot(t);
Pasar de la figura derecha a la izquierda, el ascenso de z a la raíz se denomina Zag-zag.
Zig-Zag.
z x
y y z
D
x
A A B C D
B C
Mover a la raíz.
Debe notarse que mover un nodo hacia la raíz, siguiendo en forma inversa la trayectoria de
búsqueda desde a raíz hasta el nodo, no es enteramente equivalente a las dobles rotaciones
propuestas en árboles splay. Las operaciones Zig, Zag, Zig-Zag y Zag-Zig, son equivalentes a
las que produce el mover hacia la raíz; la diferencia está en las operaciones Zig-Zig y Zag-Zag.
En el caso de mover hacia la raíz, se rota el padre de x a la derecha y luego el nuevo padre de x,
a la derecha.
z x
y A z
D
x y D
C
B C
A B
Figura 13.4. Mover x hacia la raíz.
Medir el efecto de estos dos tipos de rotaciones requiere un análisis de costos denominado
“Análisis amortizado”. Se puede verificar que el costo amortizado de m operaciones splay sobre
un árbol con n nodos, es: O( (m + n) * log2(n + m) )
Es con este fundamento que se eligen las dobles rotaciones en este tipo de árboles, y como se
verá a través de ejemplos, tienden a acortar la altura del árbol.
Existen dos tipos de algoritmos, bottom-up (de abajo hacia arriba) o top-down (de arriba hacia
abajo).
1 1 1 3
2 2 3 1 6
6 6 2 6 2 4
Zig-Zig
Zag-Zig
5 3 4 Zag 5
4 4 5
3 5
Splay(1, root);
7 7 7 1
6 6 6 6
Zig-Zig
5 5 1 4 7
4 4 Zag-Zag 4 2 5
3 1 2 5 3
2 Zig-Zig 2 3
1 3
7 1
6 7
5 6
Mueve a la raíz
4 5
3 4
2 3
1 2
7 7 7 Zig-Zag 5
3 8 3 8 3 8 3 7
1 4 1 5 1 4 6 8
1 4
2 6 2 4 6 2
2 6 Zag-Zig
Insert(5, root)
5
Splay(5, root)
La operación descarte(4, root), ubica el nodo con valor 4, y lo lleva a la raíz. Luego se efectúa:
splay(3, TL), se descarta el nodo con valor 4, y se efectúa la unión de dos subárboles (join).
Descartar(4, root).
7 3
4 4
6 6
TL 2
2 6 TL 3 6
5 5 7
7 1
1 3 5
2 2 5 7
4 1
1
3
Figura 13.9. Operación Descartar(4, root)
Descartar(6, root)
3 6 6 5
TL
1 4 7 Zag 5 7 4 7
Zag-zag
2 6 3 5 8 4 8 3 8
5 7 1 3 1
8 2 1 2
LiberarRaíz
Splay(6, root) Splay(5, TL) join(L,R)
2
Para tener un conjunto de operaciones que consideren las propiedades de esta estructura, se
pueden definir:
accesar(i, t): Si i está en el árbol t, retorna un puntero al nodo encontrado, en caso contrario
retorna NULL. Busca el nodo con valor i, y efectúa splay en ese nodo; si no lo encuentra,
efectúa la operación splay con el último nodo accesado buscando i.
join (L, R): Retorna árbol formado por la combinación del árbol L y el árbol R, asumiendo que
todos los ítems de L tienen claves menores que cualquier item de R.
Para esto aplica splay en el nodo con mayor valor de L, luego agrega R como subárbol derecho
de la raíz.
split (i, t): Parte el árbol t en dos subárboles, L que contiene todos los items con claves menores
a i; y en R deja los nodos con claves mayores que i.
Realiza accesar(i, t) luego parte el árbol en la raíz.
inserte(i, t):
Realiza split(i, t), luego convierte i en la raíz de los dos árboles que retorna split.
descartar(i, t):
Realiza accesar(i, t) luego descarta la raíz y realiza la unión de los subárboles.
Se parte el árbol en dos subárboles, uno con claves menores al buscado y otro con claves
mayores al buscado, y a medida que se desciende se van efectuado las rotaciones. Cuando se
encuentra el nodo en la raíz del subárbol central, se unen los subárboles, dejando como raíz al
nodo.
Cada vez que se desciende desde un nodo x, por un enlace izquierdo, entonces x y su subárbol
derecho serán mayores que el nodo (que será insertado o que es buscado). De esta forma se
puede formar un subárbol, con x y su subárbol derecho, sea este subárbol R. El caso simétrico,
que se produce cuando se sigue un enlace derecho, permite identificar el subárbol izquierdo de
la nueva raíz, sea este subárbol denominado L.
Como se recorre sólo una vez, ocupa la mitad del tiempo que el bottom-up.
L X R L R L R
Y Y
Y X X
XR YL YL YR
YL YR YR XR XR
Se aplica operación splay al nodo con valor Y. Mediante rotación derecha Y llega a ser la raíz,
entonces el nodo X y su subárbol derecho (XR), se convierten en el hijo izquierdo del nodo con
menor valor en R. En este caso, Y pasa a ser la raíz del subárbol central.
L R L R
X Z
Y Y
XR ZL ZR
Z X
YR
ZL ZR YR XR
L R L
X R
Z
Y Y X
XR ZL ZR
Z
YL YL XR
ZL ZR
L R X
X
L R
XL XR
XL XR
Figura 13.14. Top-down Join.
Si L y R eran los punteros a las raíces de los subárboles izquierdo y derecho respectivamente, la
secuencia siguiente implementa la transformación de la Figura 13.14:
*l = t->left; *r = t->right;
t->left=L; t->right=R;
Ejemplo top-down.
Asumiendo que se busca E.
Se encuentra C, descendiendo dos nodos; se pasan A y B a R. (Zig-Zig).
L R L C R
A
B D B
E A
C
L C L E R
R
D B C B
E A A
D
Figura 13.16. Top-down Zig-Zag en E.
B
C
A
D
13.4. Animaciones.
http://www.ibr.cs.tu-bs.de/lehre/ss98/audii/applets/BST/SplayTree-Example.html
http://www.cs.technion.ac.il/~itai/ds2/framesplay/splay.html
http://webpages.ull.es/users/jriera/Docencia/AVL/AVL%20tree%20applet.htm
13.5. Códigos.
/* splay.h*/
/*
* 1985 D. D. Sleator R. E. Tarjan
*/
typedef int tipoclave;
typedef struct moldenode {
tipoclave clave; /* Clave */
struct moldenode *left, *right;
} nodo, *arbol;
/* Definiciones de macros */
#define max(A,B) ((A)>(B)?(A):(B))
#define search(valor,t) ((t)=splayBU((valor),(t),0), ((t)!=NULL&&(t)-
>clave==(valor))?(t):NULL)
//funciones definidas en splay.c Pueden invocarse si se incluye splay.h
extern arbol splayBU(tipoclave, arbol, int);
extern arbol splayTD(tipoclave, arbol);
extern arbol insertar(tipoclave, arbol);
extern arbol borrar(tipoclave, arbol);
extern int AlturaArbol(arbol);
extern int ContarNodos(arbol);
extern arbol BorraArbol(arbol);
/* end of splay.h */
/* splay.c */
/*
* Árbol binario autoorganizado.
*/
#include <stdlib.h>
#include <stdio.h>
}
if (t == NULL && NodoInsercion != NULL) { /* */
t=NodoInsercion; /* inserta y lo deja en la raíz */
NodoInsercion=NULL; /* reinicia global */
}
/*
* insertar(valor, t): inserta nodo con clave igual a valor en arbol t
*/
arbol insertar(tipoclave valor, arbol t)
{
arbol p;
NodoInsercion = CreaNodo(valor); /* Crea el nodo y pega en la global */
/*
* descartar(valor, t): borra nodo con clave == valor en árbol t
* No se implementa mediante splay. Lo que se borra no se volverá a emplear.
*/
arbol descartar(tipoclave valor, arbol t)
{
arbol *p = &t;
arbol temp;
while (*p != NULL && (*p)->clave != valor) {//descenso iterativo
if((*p)->clave < valor)
p = &((*p)->right);
else
p = &((*p)->left);
}
if (*p != NULL) { /* (*p)->clave == valor. Encontró el nodo para descartar */
temp = *p;
/* Uno o dos hijos? */
if ((*p)->left == NULL) *p = (*p)->right;
else if ((*p)->right == NULL)*p = (*p)->left;
else /* si tiene dos hijos */
*p =join((*p)->left,(*p)->right);
LiberaNodo(temp);
}
else /* No lo encontró */
Error(0,valor);
return(t);
}
//join (l, r): Retorna el árbol formado por la combinación del árbol "l", y del árbol "r".
//Se asume que cualquier item en "l" tiene valores menores que cualquier item en "r".
static arbol join(arbol l, arbol r)
{
arbol t;
arbol *p = &t;
while (l != NULL && r != NULL) {
*p = l;
l = l->right;
(*p)->right = r;
p = &(r->left);
r = r->left;
}
if (l == NULL) *p = r;
int AlturaArbol(arbol t)
{
if (t == NULL) return 0;
else return 1+max(AlturaArbol(t->left),AlturaArbol(t->right));
}
int ContarNodos(arbol t)
{
#define maxnodos 2
/*Probar con:
*
* 4 4 4 4 4 4
* / \ / \ / \ / \ / \
* 2 6 2 6 2 6 2 6 2 6
* / \ / \
* 1 3 5 7
*/
void main(void)
{
root=insertarrecursivo(4, root); //crea raíz
CreaArbol(root,arr); //agrega nodos
MuestraArbol(root, 1);
//comenzar test
//…………..
root=delarbol(root); //despedida limpia
}
La metodología está basada en la siguiente situación análoga: Una deuda bancaria genera
intereses todos los meses; la deuda puede pagarse mediante amortizaciones mensuales. Si la
amortización es mayor que los intereses la deuda disminuye; en caso contrario aumenta. Si se
decide aumentar la amortización en algunos períodos, en otros se la podrá disminuir.
En el caso de algoritmos, se puede “pagar más” (pre pagar) por una operación, los créditos
generados compensarán, más adelante operaciones que sean más costosas; lo que no se puede
hacer es ir aumentando la deuda.
El método consiste en asignar un costo artificial para cada operación de la secuencia, este costo
artificial es denominado costo amortizado de una operación. La propiedad clave para asignar un
costo amortizado es que los costos reales de la secuencia de operaciones queden acotados por el
costo total amortizado de todas las operaciones. Entonces para analizar un algoritmo se pueden
emplear los costos amortizados, en lugar de los reales. La ventaja es que existe cierta
flexibilidad en la asignación de los costos amortizados.
Agregación:
Se demuestra que todas las secuencias de m operaciones tienen complejidad temporal O(g(n)),
entonces cada operación tendrá costo amortizado: O( g(n)/m) . Su dificultad para aplicarlo es
que normalmente el costo real de las operaciones depende fuertemente de los datos.
Primero consideremos una deuda bancaria, de valor D0, que se va pagando mediante
amortizaciones periódicas. Sea j el período; i la tasa de interés en el período j; a la
amortización; Dj, el valor de la deuda al finalizar el período j.
D
Dj-1
D0 Dj
j
t
j
Entonces, en una secuencia de n incrementos, el costo de peor caso por incremento está acotado
por n(1+floor(log n)) = O(n log n).
Si el costo total de la secuencia de n operaciones de incremento es 2n, entonces esto implica que
el costo amortizado de un incremento es 2.
Si pagamos dos pesos por la operación de incremento (este es el costo amortizado) veamos
cuánto cuesta realmente efectuar el incremento: En general se tiene una serie de bits menos
significativos que cambian de 1 a 0, y finalmente un bit cambia de 0 a 1, terminando el proceso.
Para los bits que cambian de 1 a 0, teníamos un peso asociado a ese bit, justamente para pagar
Si se asignan dos pesos por la operación de incremento (costo amortizado) esto garantiza que
siempre tendremos suficiente dinero para pagar por la operación, no importando cuánto cueste,
en forma real efectuar la operación de incremento.
Esto prueba que el costo amortizado de la operación incremento es dos.
Método del potencial.
En lugar de almacenar el dinero asociado a los elementos de la estructura, se puede
conceptualizar que el dinero total está asociado a la estructura completa de datos. El dinero total
almacenado, en el banco, se denomina función potencial.
Si escogemos:
(contador) = Número de unos en el contador.
Usando este formalismo, se define el costo amortizado de una operación. Si el sistema cambia
de estado D a D’ como resultado de una operación, se define:
Costo amortizado = costo real + = costo real + (D’)- (D)
Si el potencial es siempre positivo, y comienza desde cero, como en el ejemplo, se tendrá, que
la suma de costos amortizados es una cota para la suma de los costos reales:
m m
cˆi (ci )
i 1 i 1
Puede comprobarse, que para cualquier operación incremento, el costo amortizado será dos.
En la fórmula general, reemplazamos la suma de los costos amortizados por 2*m (ya que la
amortización es 2 por cada operación, y tenemos m operaciones); y la diferencia de potencial
por (0 –n) ; y a su vez expresamos n en función de m, se obtiene:
m m
ci (cˆi ) ( Dinicial ) ( D final ) = 2*m + (0 – n) = 2*m- log2(m+1)
i 1 i 1
m
Costo real de m operaciones = ci = 2*m- log2(m+1) = O(m)
i 1
Es O(m) ya que m es mucho mayor que log(m) para valores grandes de m.
Esto comprueba que el costo amortizado de una operación es O(m/m) = O(1), es decir,
constante.
13.8.5. Definiciones.
Para aplicar el método del potencial, al análisis de algoritmos, efectuemos algunas definiciones.
Como se estableció antes, lo que interesa es el costo promedio en una secuencia de operaciones,
entonces para m operaciones:
m m
cˆi (ci ( Di ) ( Di 1 ))
i 1 i 1
Si se logra tener una función potencial que cumpla con ( D0 ) 0 (potencial de referencia) y
como el número de operaciones que se realizarán no suele conocerse por adelantado, entonces
lo que basta demostrar es que ( Di ) 0 para todo i; de esta forma se asegura que el costo
m m
amortizado será una cota para el costo real. Es decir: cˆi (ci ) con ( Dm ) 0 para todos
i 1 i 1
los valores de m.
Entonces: Se paga por la secuencia de operaciones op1 , op2 ,… , opm no más que la disminución
n
de potencial más la suma de los costos de las amortizaciones cˆi .
i 1
El costo real se calcula sumando al costo amortizado la diferencia de potencial:
m m
ci (cˆi ) ( D0 ) ( Dm )
i 1 i 1
En un caso general, el potencial puede ser negativo y no comenzar de cero. Lo que debe
observarse son los potenciales inicial y final.
Primero escoger una función potencial adecuada (esto es casi arte). Segundo, empleando la
función potencial acotar el costo amortizado de la secuencia de operaciones en que se está
interesado. Y tercero, es preciso acotar ( Dinicial ) ( D final ) , para obtener una cota para los
costos reales.
Puede notarse que si el costo actual de una operación es elevado, y si se desea pagar una
amortización baja, entonces el potencial debe disminuir bastante para efectuar la cancelación.
Para el caso de los árboles splay, el análisis está basado en definir una función potencial, y
demostrar que cada operación splay tiene costo amortizado O( log(n) ), si esto es así, se puede
concluir que la secuencia completa tiene costo O (m log (n) ) .
En árboles balanceados, se tiene O(log n) por operación, y O(m log n) para m operaciones.
En árboles splay, como se verá, se podría tener O(n) para alguna operación en peor caso, y O(m
log n) como costo promedio para la secuencia de m operaciones.
Sin embargo las reglas heurísticas asociadas a las rotaciones no tienen beneficios obvios. Las
reglas para las rotaciones en árboles splay se obtuvieron ensayando diversas heurísticas, hasta
encontrar un conjunto que redujera los costos amortizados.
Para un árbol T, se puede definir la función tamaño s(i) como la suma de pesos positivos
arbitrarios w(i), asignados a cada nodo, de todos los nodos del árbol Ti, que tienen al i como
raíz:
s(i ) w(i )
i Ti
Por ejemplo puede definirse w(i) como q(i), el número de accesos al nodo i, partido por el
número de operaciones splay, realizadas sobre la estructura.
n
Sea w(i) = q(i)/m. Nótese que en este caso: q(i) m
i 1
Esta elección de los pesos permite obtener cotas más adecuadas en la ejecución de casos reales.
Si cada nodo es accesado sólo una vez: w(i) sería el número de descendientes del nodo i, con
éste incluido, partido por el número de nodos en el árbol.
La elección de los pesos para cada nodo nos permitirá acotar los costos reales de una secuencia
de operaciones.
Sea además la función rango r(i) = floor(log2S(i)). Lo cual permite rangos enteros.
7-2 7 7-2
4
6-2 6
3-1 2 6 3-1
5-2 5
1 3 5 7
4-2 2
1-0 1-0 1-0 1-0
4 2-1
1-0 1
s(i)-r(i)
3
Figura 13.19. 1-0 Tamaños y rangos de los nodos.
En las figuras se han anotado los valores de s(i) y r(i) para cada nodo.
Notar que s(4)=2 y r(4)=1 en la figura izquierda; y que s(4)=7 y r(4) =2 en la de la derecha. Los
nodos más alejados de la raíz tienen rangos menores; a medida que ascienden, aumentan su
rango; y el rango de las hojas es 0.
Si consideramos los r(i) como pesos asociados al nodo, en el caso del método del banquero,
puede apreciarse que se requiere invertir poco en nodos muy alejados de la raíz. Ya que las
operaciones los llevaran a rangos mayores. Otra forma de verlo, es que debe tenerse mucha
plata en el banco si el árbol, está muy desbalanceado.
Se dispone de (9-4)=5 pesos en la estructura para un costo real de 4 rotaciones. Si se efectúa una
amortización, ésta aumentará el potencial de la estructura.
La ventaja de esta definición, es que en general, a lo más tres nodos cambian su función s (y por
lo tanto r): El abuelo, el padre y el nieto. Estos cambios pueden calcularse, como se verá a
continuación.
Se desea demostrar que los costos amortizados para cada operación individual quedan acotados
según:
Si x es la raíz: ĉi = 1
Zig: ĉi 3(r’(x) - r(x)) + 1
Zig-zig: ĉi 3(r’(x) - r(x))
Zig-zag: ĉi 3(r’(x) - r(x))
Se calculan las cotas para los costos amortizados por operación, contando cada rotación como
un costo real O(1) y calculado la diferencia de potencial, antes y después de la operación.
Caso: Nodo x es la raíz
No cambia la función potencial, los nodos permanecen con sus mismos rangos individuales. El
costo de encontrar al nodo en la raíz es constante, y se le da valor 1.
Entonces:
cˆi ci ( Di ) ( Di 1 ) = 1 + 0 = 1
Caso: Zig
y x
x C y
A
A B B C
Figura 13.20. Cambios de tamaños y rangos en Zig.
z x
y y z
D
x
A A B C D
B C
Figura 13.21. Cambios de tamaños y rangos en Zig-Zag.
z x
y A y
D
x z
C B
A B C D
Se tienen;
s'(x) = s(z) , s'(x) >= s'(y), s(y) >= s(x), s(x) + s'(z) <= s'(x)
La última refleja que todos los nodos bajo x, luego de la rotación, deben ser al menos la suma de
todos los nodos del árbol.
Calculando ahora el costo amortizado de Zig-Zig, que también tiene costo real de dos
rotaciones:
cˆi ci ( Di ) ( Di 1 )
cˆi = 2 + ( r’(x)+r’(y)+r’(z) ) - ( r(x)+r(y)+r(z) )
cˆi = 2 + ( r’(y)+r’(z) ) - ( r(x)+r(y) ) usando en la anterior que r'(x) = r(z)
Buscando acotar la amortización, se tiene:
cˆi <=2 + r'(x) + r'(z) - 2r(x) usando r'(x) >= r'(y) y r(y) >= r(x)
Usando log(a) log(b) 2log(c) 2 , con s(x) + s'(z) <= s'(x) se tiene:
r(x)+r’(z)<=2r’(x) -2 la que puede escribirse:
2 <= 2r'(x) - r(x) - r'(z) , lo cual puede reemplazarse en el costo amortizado por el primer 2.
Se obtiene:
cˆi <= (2r'(x) - r(x) - r'(z) ) + r'(x) + r'(z) - 2r(x) y arreglando, finalmente.
cˆi <= ( 3r'(x) - 3r(x) )
Ejemplo
Veamos un cálculo amortizado para splay(3, root)
1 1 1 3
2 2 3 1 6
6 6 2 6 2 4
Zig-Zig
Zag-Zig
5 3 4 Zag 5
4 4 5
3 5
Costo total:
3
cˆi <= 3(r’(x) - r(x) ) + 3(r’’(x) - r’(x) ) + 3(r’’’(x) - r’’(x)) + 1
i 1
Debido a la suma telescópica se cancelan r’(x) y r’’(x). Además la última rotación deja al nodo
en la raíz, por lo tanto:
3
cˆi <= 3(r’’’(x) –r(x)) +1 = 3( r(raíz) – r(x) ) +1
i 1
El costo total amortizado de una operación splay, de un nodo x en un árbol cuya raíz es t, será
debido a las sumas telescópicas: 3(r(t)-r(x)) +1
Lo cual puede escribirse:
s(t ) s(t ) n
3(r (t ) r ( x)) 1 3log( ) 1 O(log( )) O(log( ))
s ( x) s ( x) p
Ya que s(t) es n, el número de nodos en el árbol; y s(x) = p, donde p<n es el número de nodos
en subárbol con raíz x, incluida la raíz x. Si el nodo x, es hoja, entonces p=1, y el costo total
amortizado será: O(log(n)).
El mayor cambio de potencial se produce cuando un nodo se mueve desde la raíz a una hoja.
En el peor caso el potencial inicial está asociado a una lista.
(T D0 ) log(1) log(2) log(3) log(4)... log(n) log( (n 1)) O(n log(n))
En el peor caso el potencial final tiene valor cero. En el cual el árbol está formado por una sola
hoja.
Entonces la mayor diferencia de potencial se tiene para:
( D0 ) ( Dm ) O(n log(n)) 0
Una secuencia de m operaciones splay en un árbol de n nodos tiene una complejidad temporal:
O(m log(n) + n log(n)).
Demostración:
Con r(t) <= log(n) y r(x) >=0 se obtiene el costo amortizado de una operación: 3log(n) 1
Entonces, las m operaciones, más el cambio de potencial, se expresa según:
m(3log(n) 1) O(n log(n)) la cual es O(m log(n) + n log(n)).
Completando la demostración.
Referencias.
Daniel Sleator, Robert Tarjan, “Self-Adjusting Binary Search Trees”, Journal of the Association
for Computing Machinery. Vol. 32, No. 3, July 1985, pp. 652-686.
CAPÍTULO 13 ............................................................................................................................................1
ÁRBOLES DESPLEGADOS. SPLAY TREES.......................................................................................1
13.1 DEFINICIÓN. ......................................................................................................................................1
13.2 OPERACIÓN SPLAY. ...........................................................................................................................1
Zig. .......................................................................................................................................................2
Zig-Zag.................................................................................................................................................2
Mover a la raíz. ....................................................................................................................................3
13.3 TIPOS DE ALGORITMOS. .....................................................................................................................3
13.3.1. Splay Bottom-up. ......................................................................................................................3
13.3.2. Ejemplos de operaciones splay bottom-up. .......................................................................................... 4
Splay(3, root); ............................................................................................................................................. 4
Splay(1, root); ............................................................................................................................................. 4
MuevealaRaiz(1, root)................................................................................................................................. 5
Insertar nodo con valor 5. ............................................................................................................................ 5
Descartar(4, root). ....................................................................................................................................... 6
Descartar(6, root) ........................................................................................................................................ 6
13.3.2. Splay top-down.........................................................................................................................7
Zig-Zig ............................................................................................................................................................. 8
Zig-Zag. ........................................................................................................................................................... 9
Join................................................................................................................................................................... 9
Ejemplo top-down. ......................................................................................................................................... 10
13.4. ANIMACIONES. ...............................................................................................................................11
13.5. CÓDIGOS. .......................................................................................................................................11
13.6. OPERACIONES UTILITARIAS. ...........................................................................................................17
SE AGREGA DESCARTAR PARA COMPLETAR LAS OPERACIONES BÁSICAS ........................................................17
13.7. FUNCIONES PARA EFECTUAR TEST DE SPLAY. .................................................................................19
13.8. ANÁLISIS DE COMPLEJIDAD. ...........................................................................................................20
13.8.1 Objetivo del análisis amortizado.............................................................................................20
13.8.2. Tipos de análisis.....................................................................................................................21
13.8.3. Analogía para la función potencial. .......................................................................................21
13.8.4. Ejemplo de análisis amortizado. ............................................................................................22
Método de agregación. ................................................................................................................................... 23
Método del banquero. .................................................................................................................................... 23
Método del potencial. ..................................................................................................................................... 24
13.8.5. Definiciones. ..........................................................................................................................25
13.8.6. Pasos en la aplicación del método del potencial. ..................................................................26
13.8.7. Función potencial en árboles splay. ......................................................................................27
Ejemplo función potencial. ............................................................................................................................ 28
13.8.8 Cálculo de costos amortizados por operación. .......................................................................29
Caso: Nodo x es la raíz .................................................................................................................................. 29
Caso: Zig ........................................................................................................................................................ 29
Caso: Zig-Zag ................................................................................................................................................ 30
Caso: Zig-Zig. ................................................................................................................................................ 31
Ejemplo .......................................................................................................................................................... 31
13.8.9. Costo total amortizado de una operación splay. ....................................................................32
13.8.10. Cambios de potencial. ..........................................................................................................33
Índice de figuras.
Capítulo 14.
Para lograr recorridos eficientes en un árbol puede modificarse la estructura del nodo agregando
un puntero al padre, o bien agregando un par de punteros al sucesor y predecesor formando de
este modo listas doblemente enlazadas.
Una alternativa, que demanda menos bits en cada nodo, es emplear un puntero derecho con
valor nulo, para apuntar al nodo sucesor de éste, y similarmente emplear un puntero izquierdo
nulo para apuntar a su predecesor; obviamente esto requiere dos bits adicionales por nodo para
indicar si el puntero referencia a un nodo hijo o al nodo sucesor o antecesor. Estos árboles se
denominan enhebrados (threaded). La idea es utilizar de mejor forma los (n+1) punteros que
tienen almacenados valores nulos en un árbol con n elementos.
Si sólo interesa acelerar la operación de encontrar al sucesor se emplean hebras solamente en los
punteros derechos de las hojas, para hilvanar los nodos con sus sucesores, dando origen a
árboles enhebrados por la derecha (right-threaded tree). Situación que será analizada en este
capítulo.
Los recorridos en árboles enhebrados siguen siendo de costo O(n) pero existirá un considerable
ahorro en tiempo al poder diseñar rutinas iterativas.
2 6
1 4 8
5 7 9
Debido a que ahora cada nodo puede tener dos punteros que lo referencian, el del padre y el de
su antecesor, las operaciones de inserción y descarte resultan levemente más complejas, para
mantener el árbol correctamente hilvanado.
Si un nodo tiene hijo derecho, su sucesor pertenecerá al subárbol derecho y será un nodo sin hijo
izquierdo; es decir el sucesor será el menor descendiente del subárbol derecho. Un nodo tiene
una hebra derecha apuntándolo si y sólo si tiene hijo izquierdo, es el caso de los nodos con
valores 2, 3, 6, y 8 en la Figura 14.1.
14.1.1. Buscar.
La búsqueda resulta una operación asimétrica, ya que debemos distinguir si el puntero derecho
referencia a un hijo o es una hebra.
La búsqueda falla si al descender por la izquierda se llega a un puntero nulo, o si se llega a una
hebra descendiendo por la derecha. En forma excepcional la búsqueda falla si el árbol está
vacío.
14.1.2. Inserción.
Como es usual se inserta en una hoja. Se denomina ndes al campo que describe si el puntero
derecho apunta a un HIJO o a una HEBRA. El nombre de la variable abrevia: el nodo derecho
es.
Si p apunta a la hoja donde debe insertarse el nuevo nodo, y t es el puntero que indica la
posición para insertar, pueden ocurrir dos situaciones.
a) Inserción en hoja, descendiendo por la izquierda.
Se inserta el nuevo nodo y su puntero derecho se marca como hebra, apuntando a p.
t t
El nuevo nodo tendrá clave mayor que la del apuntado por p, y menor que la apuntada por la
hebra de p; por esta razón el nuevo nodo tiene como sucesor al sucesor de p, antes de la
inserción.
14.1.3. Descarte.
En el descarte de un nodo en un árbol binario de búsqueda suelen analizarse los tres siguientes
casos: nodo que será descartado es hoja, el nodo tiene un hijo o tiene dos hijos. Sin embargo en
estos árboles enhebrados la clasificación de los casos conviene efectuarla considerando si el
nodo que será descartado tiene o no hijo izquierdo.
Esto es así, ya que si no hay hijo izquierdo no puede existir hebra que lo apunte, y no será
necesario efectuar escrituras en punteros para mantener el árbol correctamente hilvanado.
Si existe hijo izquierdo, existirá un puntero, el del antecesor, que referencia al que será
descartado y que debe ser actualizado.
if(t->left==NULL)
if(t->ndes==HIJO) caso a) //un hijo
else caso b) //hoja
else
if(t->left->ndes==HEBRA) caso c) //dos hijos o hijo izquierdo y hebra derecha
else caso d) //dos hijos o hijo izquierdo y hebra derecha
Si el nodo que debe descartarse es la raíz, será preciso actualizar el árbol, para que apunte a la
nueva raíz. En este caso no existe p, el padre de t; sin embargo para evitar escribir códigos para
tratar el caso particular, se inicia p, apuntando a un nodo, denominado centinela, cuyo hijo
derecho apunta a t. Esto se desarrolla en 14.4.
p p
t t
x x
Ejemplo de este caso es el descarte del nodo con valor 4 en la Figura 14.1.
b) Nodo t con hebra derecha e hijo izquierdo nulo.
En este caso t es hoja con hebra derecha.
b1) Si t es descendiente derecho de su padre p, se debe marcar que p tiene hebra derecha, y
copiar en el puntero derecho de p, la dirección del sucesor de t.
p p
s
s
t t
p p
t s t s
p p
t t
b
l s l
b
a a
Figura 14.7 Descarte de nodo t con hijo izquierdo. Descendiente izquierdo con hebra.
p
p
t c
c
l b
b l
a a
Figura 14.8 Descarte de nodo t con hijo izquierdo. Descendiente izquierdo con hijo.
b a
l b
l
a
p
t
c
t
c
b a
l
b
a l
p p
t a t a
c c
b b
l l
Esta solución no modifica las hebras de los descendientes del subárbol c, ni del subárbol b.
Si el nodo t es la raíz, debe modificarse ésta para apuntar ahora al descendiente derecho del
centinela, que es apuntado por p.
El descarte en un árbol enhebrado por la derecha es muy similar al descarte en árboles binarios
de búsqueda, difieren en las instrucciones que escriben el tipo de enlace derecho y en la
determinación de uno de los casos, por esta razón suele implementarse árboles enhebrados por
la derecha y no los árboles hilvanados con sucesores y antecesores.
a) Se inicia en el nodo ubicado más a la izquierda del árbol, se muestra el valor de la clave y se
sigue su hebra derecha.
b) Si se sigue una hebra a la derecha se muestra el valor de la clave del sucesor y se continúa
por el enlace derecho de éste.
c) Si se sigue un enlace a un hijo a la derecha, primero se desciende hasta el nodo ubicado más a
la izquierda, se imprime su clave y se continúa en b).
Si se tiene un arreglo y se insertan las claves en un árbol enhebrado por la derecha, luego con
un recorrido en orden puede generarse el arreglo ordenado ascendentemente.
#define HEBRA 1
#define HIJO 0
typedef struct tnode
{ int clave;
int ndes; //nodo derecho es: Hebra o Hijo
struct tnode *left; struct tnode *right;
} nodo, * pnodo;
pnodo MasIzquierdista(pnodo t)
{
if (t == NULL) { return NULL;}
while (t->left != NULL) t = t->left;
return t;
}
14.2.5. Sucesor.
pnodo sucesor(pnodo t)
{
If (t==NULL) return NULL;
if (t->ndes==HIJO) return( MasIzquierdista(t) );
else if (t->right ==NULL) return NULL; //no hay sucesor
else return(t->right);
}
14.2.6. Buscar.
14.3. Insertar.
while ( t != NULL)
{
if ( t->clave == valor )
{/*lo encontró, no inserta. No se aceptan claves repetidas en conjuntos*/
return (t); //devuelve el encontrado.
}
else
{ p=t ;
if (t->clave > valor) {t = t->left; porlado=left;}
else
{ porlado=right;
if(t->ndes==HIJO) t = t->right;
else break;
}
}
}
/*Al salir del while p apunta al nodo donde se insertará el nuevo, y porlado la dirección */
/* El argumento t apunta a NULL o al sucesor de p */
t = getnodo(valor); //se pega el nuevo nodo en t.
if(t==NULL) return(NULL);
if (porlado==left)
{p->left=t; t->ndes=HEBRA; t->right=p;}
else if(porlado==right)
{t->right=p->right; t->ndes=HEBRA; p->right=t;p->ndes=HIJO;}
return (t); /* Apunta al recién insertado. Null si no se pudo insertar*/
}
14.4. Descartar.
praiz p
raiz
centinela
t
for ( ;; )
{
if (t->clave > valor)
{ if(t->left==NULL) return(0); else {p=t;t = t->left;}
//porlado=left;
}
else if (t->clave < valor)
{ if(t->ndes==HIJO) {p=t; t = t->right;} else return(0);
//porlado=right;
}
else break; //lo encontró
}
/*Al salir del for p apunta al padre de t */
/* t apunta al nodo que será descartado */
if(t->left==NULL)
{
if(t->ndes==HIJO) //caso a) un hijo
{ if ( t==p->right) p->right = t->right; else p->left=t->right; }
else //caso b) hoja
{ if (t==p->right) {p->right=t->right; p->ndes=HEBRA;} else p->left=t->left; }
}
else
{ pnodo l= t->left; //variable local al bloque para hijo izquierdo de t.
if(t->left->ndes==HEBRA) //caso c) dos hijos o hijo izquierdo y hebra derecha
{ l->right = t->right; l->ndes = t->ndes;
La siguiente función recursiva aplica las propiedades que deben cumplir los nodos de un árbol
enhebrado por la derecha y es útil para verificar el diseño de las funciones.
//retorna 1 si es rtbst
int testrtbst(pnodo t)
{ int l=1, r=1;
//Si arbol está vacío, es rtbst
if (t==NULL) return 1;
//test subarbol izq
if (t->left != NULL)
{ if (t->clave > t->left->clave) l=testrtbst(t->left);
else l=0;
}
Referencias.
Índice de figuras.
Capítulo 15
Árboles AA.
Los árboles AA, así denominados por las iniciales de su creador, son una simplificación de las
reglas de los árboles coloreados y originan un número bastante menor de situaciones que deben
ser analizadas. Además de las reglas de los árboles coloreados, sólo se permiten nodos rojos
como descendientes derechos.
En estos árboles, en lugar de la información sobre el color, se almacena el nivel del nodo; las
hojas son de nivel 1. En lugar de color rojo se habla de enlace derecho horizontal, o de un nodo
descendiente derecho con igual nivel que su padre. La Figura 15.1, muestra las estructuras de
árboles AA, con número de nodos n, de uno a tres. A la derecha se muestra el nivel de los
nodos.
n=1 1
0
n=2 1
0
2
n=3
1
0
Un nuevo nodo que será de nivel 1, se inserta en las hojas, que también son de nivel 1.
Se presentan dos casos.
a) Si se inserta por la izquierda se viola la regla de sólo horizontales derechos, y debe efectuarse
una rotación derecha.
Descendiendo desde la raíz, se determina la posición para insertar, ésta se muestra con una
flecha, en la Figura 15.2, superior. Al centro, se muestra una representación convencional, con
un nodo descendiente horizontal izquierdo. En la parte inferior se muestra luego de una rotación
derecha, respecto del padre del nodo insertado; ahora se cumplen las propiedades de un árbol
AA. Se definió esta operación como torcer (skew).
La inserción por la izquierda seguida de una torsión, puede originar una nueva violación a las
propiedades de los árboles AA. Esto se ilustra en la Figura 15.3 en la parte superior; luego se
dibuja en forma convencional, de acuerdo a los niveles de los nodos. La tercera ilustración
muestra la situación luego del skew; lo cual produce el doble rojo, o dos hijos horizontales, debe
notarse que el recién insertado es la nueva raíz. La situación se corrige con una rotación a la
izquierda, respecto del nodo recién insertado, y luego incrementando el nivel del nodo central;
lo que se muestra en el diagrama inferior de la Figura 15.3. Esta última operación se denomina
partir (split), y se deriva de los B-trees antecesores de los árboles AA, en los cuales se divide un
multinodo que excede el máximo número de claves en dos multinodos.
1
2
1
Puede observarse que cuando se ejecuta un skew seguido de un split, en el mismo nivel de
recursión, puede simplificarse la secuencia evitando las rotaciones, ya que basta incrementar el
nivel del nodo raíz en el segundo diagrama; pasando de este modo al diagrama inferior de la
Figura 15.3.
Otra situación de inserción por la izquierda se muestra en la Figura 15.4. Luego de un skew en
el padre del recién insertado resulta el diagrama al centro. Y al retornar por la vía por la cual se
descendió para insertar, debe efectuarse un split en el nuevo padre del recién insertado,
resultando el diagrama inferior de la Figura 15.4. Nótese que el skew y el split se aplican en
nodos diferentes, en dos niveles de recursión.
1
Figura 15.4. Inserción por la izquierda. Skew y split en niveles de recursión diferentes.
1 1
2
1 1
Se han analizado las diferentes situaciones de inserción que se producen en el primer nivel de un
árbol AA. Esto se logró efectuando inserciones en un árbol con un nodo, e inserciones en un
árbol con dos nodos.
Aplicando las operaciones anteriores, puede comprobarse la estructura de los árboles AA, para
4, 5, 6 y 7 nodos que se muestran en la Figura 15.6. Debe notarse que en cada diagrama hay
que estudiar (n+1) casos de inserción, ya que éste es el número de nodos externos, o los posibles
lugares para insertar el nuevo nodo.
Sin embargo si se observan las formas que se producen en los diferentes niveles de la Figura
15.6, puede deducirse que los casos de inserción analizados antes son los únicos que se
presentan.
2
n=4 1
2
n=5 1
2
n=6 1
3
2
n=7
1
Si se realiza una inserción de claves en orden ascendente, desde el 1 hasta el 15, se obtiene
luego de las operaciones de rebalance, después de cada inserción, la Figura 15.7,
inmediatamente luego de insertado el nodo con valor 15.
Se devuelve siguiendo la ruta desde la raíz al nodo insertado, pero en forma ascendente. Como
el nodo 15 cumple las propiedades, es un árbol AA; se asciende al 14, que también es AA. Pero
al ascender al 13, éste tiene dos hijos horizontales; entonces debe realizarse un split en el nodo
13, con lo cual resulta la Figura 15.8. El árbol cuya raíz es el nodo con valor 14 es AA, notar
que ésta es la raíz luego del split.
Al ascender al nodo con valor 12, también se cumplen las propiedades de los árboles AA. Pero
al ascender al nodo con valor 10, debe volver a efectuarse un split, resultando la Figura 15.9.
Figura 15.12. Luego de insertar el nodo con valor 7 y luego del skew en 8.
Puede comprobarse que luego de las inserciones de los nodos 6 a 1, y de las posteriores
verificaciones del cumplimiento de las propiedades, resulta la Figura 15.10.
Si un nodo tiene más de un hermano derecho de igual nivel debe partirse (split), aumentando el
nivel de cada segundo nodo en el descenso. Se desciende por la derecha, efectuando rotaciones
izquierdas.
Debido a que tanto la inserción como el descarte requieren recorrer una ruta desde la raíz hasta
las hojas, es preferible realizar la mantención de las propiedades de los árboles AA, desde abajo
hacia arriba, efectuando operaciones a medida que se asciende por la ruta de descenso. Para
a) B de nivel i.
Si el subárbol derecho era AA, los nodos C y D deben tener nivel (i-1). En la Figura 15.14, se
muestra el nodo n de nivel i, lo que viola las propiedades de un árbol AA, y la estructura debe
ser corregida. Según se analizó antes, la única forma en que un nodo aumenta su nivel es
teniendo ambos hijos de igual nivel. Entonces si antes de insertar el nodo n el árbol era AA, y si
el nodo n alcanza grado i quiere decir que lo ha logrado teniendo hijos de igual nivel. Esto
implica que a y b deben ser de nivel (i-1). Si A tiene padre debe ser de nivel (i+1).
t A i
n i B i
En este caso luego de un skew en t, resulta el diagrama a la izquierda de la Figura 15.15; y luego
del split en el nuevo t, resulta el subárbol AA a la derecha de la Figura 15.15. En este caso,
debido a que el nodo A aumenta su nivel inicial, es preciso continuar la revisión ascendente. La
situación puede ser corregida, sin realizar rotaciones, cambiando solamente el nivel del nodo A,
en la Figura 15.14. Nótese que se preserva la propiedad de que el nodo que aumenta su nivel
tiene ambos hijos iguales y de un nivel menor.
t n i
t A i+1
a i-1 A i
b i-1
B i n i B i
n i B i-1
Luego del skew en t, el árbol es AA, y el diagrama se muestra en la Figura 15.17. Es preciso
continuar la revisión ascendente si A era un hijo derecho, ya que en este caso podrían producirse
tres nodos consecutivos de igual nivel.
t n i
a i-1 A i
b i-1
B i-1
C i-2 D i-1
En este caso B debe ser de nivel (i-1). Si en el ascenso por el lado derecho, n incrementó su
nivel, debe cumplirse que sus hijos son de nivel (i-1). El árbol es AA, y no requiere
modificaciones. Debe continuarse la revisión ascendente si A es hijo derecho, ya que podrían
producirse tres nodos adyacentes de igual nivel.
t A i
B i-1 n i
La operación torcer (skew) remueve los enlaces horizontales izquierdos (en el mismo nivel),
rotando en el padre a la derecha. No se propagan cambios hacia abajo, ya que se cambia un
horizontal izquierdo por un horizontal derecho.
Para mantener las propiedades de un árbol AA, es necesario balancear los nodos, que se
recorren al descender para insertar, en forma ascendente hasta la raíz.
Sin embargo de acuerdo al análisis realizado, en cada nivel de recursión se efectúa un cambio de
nivel, o un skew o un split. Lo cual puede plantearse:
El siguiente segmento explica las operaciones, que deben efectuarse en cada nodo de la ruta
ascendente, en términos de rotaciones. La rotación izquierda debe incorporar el cambio de nivel
de la nueva raíz.
En el proceso de ajuste de niveles en el ascenso, se ha borrado una hoja en uno de los subárboles
de t, de nivel (i+1). El árbol cuya raíz es n, ya ha sido corregido y cumple las propiedades; así
también el árbol cuya raíz es B, ya que no ha sido modificado. Corresponde recuperar las
propiedades del árbol con raíz A, apuntado por t.
En el trabajo original se plantea, como ejercicio, que el peor caso de desbalance en el descarte
puede ser corregido por a lo más 3 skews y 2 splits. Es decir con:
Sin embargo el análisis de los diferentes casos resulta complejo como se verá a continuación.
t A i+1 t A i
C i D i C i D i
a i-1 E i F i a i-1 E i F i
La Figura 15.20 a la izquierda muestra el diagrama luego del skew en t->right. A la derecha de
la Figura 15.20, se muestra luego del skew en t->right->right (el nieto de t).
a i-1 B i n i-1 C i
E i
E i D i a i-1 B i
e i-1 D i
e i-1 f i-1 F i f i-1 F i
La Figura 15.21 a la izquierda muestra el diagrama luego del split en t. A la derecha de la Figura
15.21, se muestra luego del split en t->right.
t C i+1
t C i+1
A i B i+1
A i E i
n i-1 a i-1 B i n i-1 a i-1 E i D i
e i-1 D i
f i-1 F i e i-1 f i-1 F i
Figura 15.21. Ascenso por la izquierda. Luego de las dos operaciones partir.
t A i+1 t A i
C i D i C i D i
F i F i
a i-1 E i-1 a i-1 E i-1
a i-1 B i
E i-1 D i
F i
F i
A i D i+1
n i-1 a i-1 B i F i
E i-1
t C i+1
A i B i
F i-1
Figura 15.24b. Ascenso por la izquierda. Con E de nivel (i-1), y F de nivel (i-1).
t A i+1 t A i
n i-1 B i n i-1 B i
C i-1 D i C i-1 D i
A i D i
n i-1 C i-1
a i-2 E i-1
Pueden resumirse las acciones necesaria para balancear el árbol cuando se asciende por la
izquierda, con el siguiente segmento, que muestra las acciones en términos de rotaciones. Se
emplea la variable ok, para continuar o no la revisión de las propiedades en el ascenso.
Lo cual muestra que en el peor caso se requieren 4 rotaciones para mantener las propiedades de
un árbol AA, en los nodos de la ruta ascendente. Pero el código es similar en complejidad a los
árboles coloreados.
B i n i-1 B i n i-1
C i-1 D i C i-1 D i
t B i
C i-1 A i
D i n i-1
b i-1 F i-1
f i-2 a i-1
C i-1 D i
b i-1 A i
F i-1 n i-1
f i-2 a i-1
B i A i
f i-2 a i-1
B i n i-1 B i n i-1
C i-1 A i
D i-1 n i-1
b i-2 F i-1
No se realizan los skew en el hijo y en el nieto derecho de t. Tampoco se realizan los split.
El siguiente segmento ilustra las acciones para mantener las propiedades en caso de ascenso por
la derecha.
En aplicaciones con estructuras que tienen un inicio y un final, el diseño de las funciones
requiere un tratamiento especial en los bordes. Tradicionalmente puede uniformarse el
tratamiento al inicio introduciendo como primer elemento un nodo denominado encabezado o
header. Así también para finalizar la estructura puede agregarse un nodo denominado centinela
o fondo que facilite las operaciones en la base de la estructura.
Además la introducción de este nodo permite uniformar el tratamiento de funciones que operan
con los descendientes de un nodo, como es el caso de las operaciones skew y split en los árboles
AA.
2
1
En otras aplicaciones los enlaces del centinela pueden apuntar al nodo raíz, logrando de este
modo listas circulares. Si los nodos tienen padre, el del centinela puede apuntar a la raíz o a
NULL dependiendo de la condición que se desee simplificar.
El siguiente segmento ilustra el diseño, se ha supuesto un nodo centinela apuntado por una
variable externa a la función denominada nil.
//Varibles globales
pnodo nil;
nodo centinela;
void initglobalvariables()
{ nil=¢inela;
nil->nivel = 0; //nivel del centinela, está bajo las hojas.
nil->left = nil;
nil->right = nil;
}
La inserción se produce en las hojas, y siempre en nivel 1. Además el nuevo nodo tiene sus
descendientes apuntando al centinela.
Con fines de depurar las funciones, conviene disponer de una función que muestre en forma de
texto, los valores almacenados en el árbol.
void prtnivel(pnodo p)
{ if (p!= nil)
{
prtnivel(p->left);
printf ("%d,%d ", p->clave, p->nivel);
prtnivel(p->right);
}
}
/* rotación derecha */
pnodo rrot(pnodo t)
{ register pnodo temp=t;
t = t->left;
temp->left = t->right;
t->right = temp;
return (t);
}
/* rotación izquierda */
pnodo lrot (pnodo t)
{ register pnodo temp=t;
t = t->right;
temp->right = t->left;
t->left = temp;
t->nivel++;
return(t);
}
if (t->right->right->nivel== t->nivel )
{ /* rotación izquierda */
temp = t;
t = t->right;
temp->right = t->left;
t->left = temp;
t->nivel = t->nivel +1;
}
return(t);
}
#define lrotm(t) do { \
{ temp=t; \
t = t->right; \
temp->right = t->left;\
t->left= temp; \
t->nivel++; \
} \
} while(0)
15.9. Buscar.
El mismo creador de los árboles AA, plantea una forma original de efectuar una búsqueda en
árboles, empleando solamente una comparación. Para visualizar la diferencia se tiene la
búsqueda clásica con dos comparaciones y una asignación en el lazo.
La siguiente tiene una comparación y dos asignaciones en el peor caso, en cada pasada por el
lazo de repetición. Se emplea esta forma en la operación descarte.
15.10. Insertar.
Diseño recursivo. Empleando rotaciones para mantener las propiedades de los árboles AA.
15.11. Descartar.
El diseño es más complejo y las operaciones para preservar las propiedades ocupan parte
importante del código; sin embargo son más sencillas que en el caso de árboles coloreados. Las
operaciones se han planteado en términos de rotaciones, en lugar de las de torcer y partir (skew
y split).
La siguiente función recursiva, efectúa un recorrido en todos los nodos del árbol, revisando si se
cumplen las propiedades. Es útil en la verificación de las funciones.
void check(pnodo p)
{ int k, tipo;
if (p!= nil)
{
check(p->left);
k=0;
if (p->left->nivel== p->nivel ) {tipo=1; k++;} //no se permiten hijos izquierdos horizontales
if (p->right->right->nivel== p->nivel ){tipo=2;k++;}//no pueden existir dos rojos adyacentes
if(k)
{printf("Error=%d %d \n",tipo, p->clave);
prtinorder(p); putchar('\n');
prtinorder(tree); putchar('\n');
prtnivel(tree); putchar('\n');
}
check(p->right);
}
}
#define N 79
arbol tree;
int main(void)
{ int i;
clock_t start, stop; //tipo definido en time.h
int totaltime = 0;
initglobalvariables();
tree=nil;
start = clock();
srand(1);
for(i=1;i<=N;i++)
{ tree=insertar(rand()%1023, tree);
check(tree);
//prtinorder(tree);putchar('\n');
}
Referencias.
Arne Andersson. “Balanced Search Trees Made Simple”. Workshop on Algorithms and Data
Structures, pages 60-71. Springer Verlag, 1993.
CAPÍTULO 15 ............................................................................................................................................1
ÁRBOLES AA. ...........................................................................................................................................1
15.1. PROPIEDADES DE LOS ÁRBOLES AA. ................................................................................................1
15.2. ANÁLISIS DE LA OPERACIÓN INSERCIÓN. ..........................................................................................2
15.2.1. Ejemplos de inserción. .............................................................................................................5
15.2.2. Diseño de las operaciones skew y split. ...................................................................................7
15.2.3. Inserción por la izquierda. .......................................................................................................8
a) B de nivel i. .................................................................................................................................................. 8
b) B de nivel (i-1). ............................................................................................................................................ 9
15.2.4. Inserción por la derecha. .........................................................................................................9
15.3. ANÁLISIS DEL DESCARTE. ...............................................................................................................10
15.3.1. Ascenso por la izquierda. .......................................................................................................11
a1) B de nivel (i+1). ....................................................................................................................................... 11
a11) Con E y F de nivel i.......................................................................................................................... 11
a12) Con E de nivel (i-1) y F de nivel i. .................................................................................................... 12
a13) Con E de nivel (i-1) y F de nivel ( i-1). ............................................................................................. 13
a14) Con E de nivel i y F de nivel ( i-1). .................................................................................................. 14
a2) B de nivel i. .............................................................................................................................................. 14
a21) Con D de nivel i. ............................................................................................................................... 14
a22) Con D de nivel (i-1). ......................................................................................................................... 14
15.3.2. Ascenso por la derecha. .........................................................................................................15
b1) Con D de nivel i. ...................................................................................................................................... 15
b2) Con D de nivel i-1.................................................................................................................................... 16
15.4. CENTINELAS Y ENCABEZADOS. .......................................................................................................17
15.5. TIPOS DE DATOS. ............................................................................................................................18
15.6. CREACIÓN DE NODO. ......................................................................................................................19
15.7. LISTADOR DE LA ESTRUCTURA. ......................................................................................................19
15.8. OPERACIONES BÁSICAS. .................................................................................................................20
15.9. BUSCAR. .........................................................................................................................................21
15.10. INSERTAR. ....................................................................................................................................21
15.11. DESCARTAR..................................................................................................................................22
15.12. VERIFICACIÓN DE LAS PROPIEDADES. ...........................................................................................23
15.13. TEST DE LAS FUNCIONES. .............................................................................................................24
REFERENCIAS. .........................................................................................................................................25
ÍNDICE GENERAL. ....................................................................................................................................26
ÍNDICE DE FIGURAS. ................................................................................................................................27
Capítulo 16.
Cuando los ítems almacenados en un árbol de búsqueda binario cambian en el tiempo, debido a
que éstos pueden ser insertados o descartados de manera no predecible, no puede asegurarse que
todas las hojas mantengan profundidades similares. Debido a esto se han desarrollado diversos
algoritmos determinísticos para lograr mantener una estructura balanceada: Árboles AVL,
árboles 2-3, árboles coloreados, árboles AA y muchos otros; todos ellos modifican la estructura
basados en pequeñas operaciones locales denominadas rotaciones y en un análisis cuidadoso de
las diferentes y determinadas situaciones que se producen.
Antes se demostró que un árbol generado aleatoriamente tiene una altura esperada que varía
logarítmicamente con respecto al número de nodos almacenados en la estructura, y que tiende a
ser balanceado.
Se estudiará uno de estos métodos que consiste en introducir un número aleatorio en cada nodo.
El número aleatorio será la prioridad del nodo. Las claves y las prioridades deben ser diferentes
y pertenecer a un universo totalmente ordenado. En el árbol, las claves están almacenadas según
un árbol binario de búsqueda, y las prioridades según una cola de prioridad. Por esta razón a la
estructura se la denomina treaps que es un acrónimo de trees y heaps.
Si las claves y las prioridades son diferentes para un conjunto determinado de valores de claves
y prioridades el treap es único; y su forma correspondería al árbol binario de búsqueda que se
obtendría al insertar, a partir de un árbol vacío, los ítems en orden de prioridad creciente.
Lo notable de esta estructura es que está basada en dos de las fundamentales que se estudian en
un curso básico de estructuras de datos: el árbol binario de búsqueda y una cola de prioridad o
heap. Si se tuviera una función de hash que genere un valor aleatorio (la prioridad), a partir de
la clave, no sería necesario almacenar el valor de prioridad en cada nodo, reduciendo el tamaño
del almacenamiento, ya que mediante la función pueden calcularse las prioridades de los nodos
cuando sea necesario disponer de ellas. En esta forma de diseño, a partir de tres estructuras
fundamentales de datos se construye una nueva.
Si se inserta un nodo con clave determinada, de manera usual, en las hojas de un árbol binario,
se tiene que todas las claves de los nodos cumplen la propiedad de un árbol binario de
búsqueda; sin embargo puede ser que la propiedad del heap, que es tener prioridades de los hijos
mayores que el padre, no se cumpla. Para reestablecer esa propiedad, al nodo recién insertado se
lo hace ascender, mediante rotaciones, mientras el padre tenga prioridad mayor. El proceso se
detiene si se encuentra un padre con prioridad menor o si el nuevo nodo se convierte en la raíz.
El descarte de un nodo se logra invirtiendo las operaciones que se realizan para insertarlo; es
decir, una vez ubicada la clave, se hace descender al nodo, mediante rotaciones con el nodo con
menor prioridad de sus hijos, hasta que el nodo sea una hoja; instancia en que se lo puede
descartar, preservándose las propiedades de los árboles binarios de búsqueda y de los heaps.
La Figura 16.1, ilustra un treap, donde en la parte superior se muestra la clave y en la inferior de
cada nodo la prioridad. El árbol se formó ingresando las claves de acuerdo a un orden creciente
de las prioridades; es decir en orden: 10, 12, 13, 18, 22, 24, 33 y 40. El nodo con menor valor
de prioridad se ubica en la raíz.
9
10
4 15
12 13
2 5 12 20
22 24 18 40
7
33
Si se inserta un nodo con clave 8 y prioridad 11, de acuerdo al algoritmo de inserción debe
agregárselo a la derecha del nodo con clave 7. Esto se muestra a la izquierda de la Figura 16.2.
Como no se cumple la propiedad que la prioridad del nodo 8 sea mayor que la de su padre,
4 15 4 15
12 13 12 13
2 5 12 20 2 5 12 20
22 24 18 40 22 24 18 40
7 8
33 11
8 7
11 33
Del nodo con clave 8 hacia abajo se tiene un heap, pero no hacia arriba. Por esto es preciso
efectuar otra rotación a la izquierda en el nodo padre del 8, el nodo con clave 5 en la Figura 16.2
a la derecha. Luego de realizada, se obtiene el diagrama a la izquierda de la Figura 16.3.
Finalmente volviendo a rotar a la izquierda en el nodo con clave 4, padre del nodo con clave 8,
se logra el treap a la derecha de la Figura 16.3.
9 9
10 10
4 15 8 15
12 13 11 13
2 8 12 20 4 12 20
22 11 18 40 12 18 40
5 2 5
24 22 24
7 7
33 33
El descarte del nodo con clave 8, sigue el proceso inverso a su inserción, se lo hace descender,
mediante rotaciones con el nodo hijo que tenga menor prioridad, hasta llegar a ser una hoja.
La operación descarte se ve simplificada si los enlaces derecho e izquierdo de las hojas apuntan
a un nodo de fondo o centinela que tenga un valor mayor de prioridad que los valores de
prioridad que puedan tener los nodos.
Entonces los condicionales, que deberían efectuarse cerca de las hojas, para asegurar la
existencia de los valores p->left->prioridad o p->right->prioridad no son necesarios.
La operación de inserción debe descender hasta las hojas y luego ascender preservando el heap.
Esto puede lograrse de manera simple mediante un diseño recursivo, o empleando un stack, para
almacenar la ruta de descenso; una vez ubicada la posición para insertar, al retornar de las
invocaciones recursivas, se dispone del nodo padre, en el cual deben realizarse las rotaciones
que corresponda.
La operación de descarte, debe descender hasta encontrar el nodo que se busca para descartar, y
luego seguir descendiendo hasta las hojas, manteniendo el heap. Para lograrlo, de manera
iterativa, es necesario mantener en el descenso un puntero al padre del nodo que está
descendiendo para ser descartado, además de la dirección de descenso; ya que es preciso
mantener los enlaces. Lo anterior también puede simplificarse si los nodos contienen un puntero
al padre, pero esto aumenta el tamaño del almacenamiento del nodo.
Si la función que genera aleatoriamente las prioridades, produjera algunos números con iguales
valores, los algoritmos de inserción y descarte se realizan normalmente. En la inserción no se
realizan rotaciones adicionales, y quedarían elementos con prioridades iguales adyacentes,
alargando la altura del último nodo ingresado; pero si esto ocurre con baja probabilidad puede
tolerarse. En el descarte, si la prioridad del nodo es igual a la de uno de sus hijos, disminuye el
largo de secuencias de igual prioridad y mejora el balance; por otro lado si los hijos tienen
iguales prioridades, tiende a aumentar la altura del último nodo. Por lo tanto es perfectamente
tolerable aceptar un generador aleatorio simple que demande bajo número de operaciones y que
produzca un limitado número de colisiones en las prioridades.
Los autores del algoritmo diseñan recursivamente ambas operaciones de actualización. También
demuestran que el valor esperado de la altura de un treap, con n nodos, es O(log(n)) , y que el
valor esperado de las rotaciones necesarias para mantener el treap es menor que 2; es decir un
número constante.
También muestran que una función de hash adecuada, para generar las prioridades, a partir de
una clave i, es un polinomio de grado 4.
Donde las constantes deben ser números aleatorios entre 0 y (U-1). Con U un número primo
mayor que n3 , con n elementos en el árbol; lo cual logra asegurar que dos elementos pueden
tener prioridades iguales con una probabilidad menor que 1/n.
16.2. Complejidad.
Una variable aleatoria indicadora I (e) asociada con el evento e de un espacio muestreal S, está
definida según:
I (e) =1 si ocurre el evento e, y 0 si el evento e no ocurre.
Se define el valor esperado como el promedio de los valores que la variable puede tomar. En un
espacio discreto, es la suma ponderada, de acuerdo a su probabilidad, de todos los valores que
puede tomar la variable X:
E[ X ] x Pr( X x)
x
Las variables aleatorias indicadoras son útiles para analizar situaciones en las que se realizan
repetidos ensayos aleatorios.
Si se emplea la variable indicadora Vi para asociarla con la producción del evento e en el
ensayo i-ésimo. Entonces la variable aleatoria V que indica el número total de eventos e en los n
ensayos, queda descrita por:
i n
V Vi
i 1
Y el valor esperado de V, se logra tomando la expectación en ambos lados de la expresión
anterior:
i n
E[V ] E[ Vi ]
i 1
Pero el operador expectación es lineal, y se tendrá que el valor esperado de la suma de dos
variables aleatorias es la suma de sus expectaciones, esto aún si las variables no son
independientes. Para el caso de la sumatoria anterior, se tiene:
i n
E[V ] E[Vi ]
i 1
16.2.2. Altura.
Sea xk el nodo que tiene la k-ésima clave menor en el treap; y ai ,k una función que vale 1 si xi es
ancestro propio de xk , y 0 en caso contrario.
Entonces el número de nodos del trayecto de xk a la raíz, está dada por:
i n
h( xk ) ai ,k
i 1
La Figura 16.4, ilustra la situación de los nodos del conjunto. Sin perder generalidad se ha
supuesto que las claves están formadas por números entre 1 y n. El diagrama a la derecha
muestra un caso en que i < k , y el izquierdo un caso en que i > k.
i i
i+1 i+1
k k
Debido a la construcción del treap, puede asegurarse que xi es un ancestro propio de xk si y sólo
si xi tiene la menor prioridad entre todos los nodos en el subconjunto X (i, k ) .
d) Si xi y xk no son la raíz y están en el mismo subárbol, se desciende hasta que uno de ellos
sea la raíz o hasta encontrar una raíz x j que los deje en subárboles diferentes y se vuelven
(inductivamente) a aplicar las consideraciones realizadas en a), b) y c).
Como cada nodo del subconjunto X (i, k ) tiene una prioridad elegida en forma independiente,
como una variable aleatoria con una distribución continua, que i tenga la prioridad menor del
conjunto ocurre con probabilidad:
1
Pr(ai ,k 1)
|k i| 1
Es decir, es uno entre todos los nodos del conjunto.
El resultado anterior permite calcular el valor esperado para la altura, definida como el número
de nodos del trayecto de xk hasta la raíz, según:
i n i k 1 i n
1 1
h Pr(ai ,k 1)
i 1 i 1 k i 1 i k 1i k 1
j k j n k 1
1 1
h 2 H (k ) H (n k 1) 2
j 1 j j 1 j
Con H (n) la serie armónica:
i n
1
H ( n) ln(n)
i 1 i
Con =0,577. Para n >>1, se tiene:
h ln(n) O(log(n))
Debemos suponer que k >1 y n > k, es decir un treap que contenga un número mayor de nodos
que la k-ésima clave menor del treap. Con estas condiciones puede realizarse, mediante Maple,
las siguientes gráficas, para ilustrar la complejidad de la altura de un treap.
> h:=sum(1/(k-i+1),i=1..k-1)+sum(1/(i-k+1),i=k+1..n);
> k:=30;
> plot([1.1*ln(n)/ln(2),h,0.8*ln(n)/ln(2)],
n=k..100*k,color=[red,black,blue],thickness=2);
Se han elegido constantes de 1,1 y 0,8 para acotar por arriba y por abajo a la función h.
Los diagramas, para la 30ava clave menor del treap, se muestran en la Figura 16.6.
Figura 16.6. Valor esperado para la altura de un nodo con valor pequeño de clave.
Puede determinarse el largo del trayecto formado por los descendientes izquierdos de la raíz
(que se define como espina o columna vertebral izquierda). Si suponemos un treap formado por
n claves y consideramos, sin perder generalidad, que las claves almacenadas en el árbol son los
números de 1 a n, podemos definir el indicador X i ,k con valor uno, si el nodo con clave i está en
la espina izquierda del subárbol con raíz k; y cero en caso contrario.
En un treap debe cumplirse que k > i y la prioridad del nodo con clave k debe ser menor que la
prioridad del nodo con clave i. Además para cada nodo con clave z, con i < z < k, debe
cumplirse que la prioridad de z debe ser menor que la prioridad del nodo con clave i.
k
(k i 1)! 1
Pr[ X i ,k 1]
(k i 1)! (k i)(k i 1)
El numerador contabiliza el número de permutaciones que pueden escribirse con las claves
desde i hasta k, es decir con (k-i+1) claves. De todas las anteriores, aquellas que comienzan en k
y terminan en i, son las que pueden generarse con las permutaciones de las cifras contenidas
entre ambas; es decir con (k-i-1) claves.
El valor esperado para el largo de la espina izquierda, es la suma de los nodos que están
presentes en la espina:
i k 1 i k 1
1
E(I ) Pr[ X i ,k 1]
i 1 i 1 (k i)(k i 1)
j k 1
1 1
E(I ) 1
j 1 j ( j 1) k
1 1 1
P(k 1) 1 1
k k (k 1) k 1
El valor esperado del largo de la espina derecha, se realiza de igual forma, pero sumando desde
1 hasta (n-k).
j n k
1 1
E ( D) 1
j 1 j ( j 1) n k 1
Si xk es la raíz, la espina izquierda se calcula sumando Pr[ X i ,k 1] desde i igual a 1 a (k-1); para
la espina derecha pueden cambiarse los índices para sumar desde i igual 1 hasta (n-k), según se
muestra en la Figura 16.8. A la derecha se ilustra con i=5, k=8 y n=11.
k 8
El número de rotaciones necesarias para insertar una determinada clave es el mismo que el
número de rotaciones para descartarla. Para descartar un clave se realizan rotaciones que la
hacen descender; en cada rotación a la izquierda la espina derecha disminuye en uno, y en cada
rotación a la derecha la espina izquierda disminuye en uno. Al llegar la clave a la posición de
una hoja, ambas espinas son cero. Entonces el número de rotaciones para descartar una clave es
la suma de las espinas derecha e izquierda de esa clave, antes de descartarla.
Los siguientes comandos Maple, muestran las gráficas del número de rotaciones para tres
valores de k.
> rot:=2-1/k-1/(n-k+1);
> subs(k=1,rot),subs(k=n/2,rot),subs(k=n,rot);
1 2 1 1
1 ,2 ,1
n n 1 n
n 1
2
> plot([subs(k=1,rot),subs(k=n/2,rot),subs(k=n,rot)],n=1..20,
color=[red,black,blue],thickness=2);
Para k=n/2, el número de rotaciones es menor que 2, lo que se muestra en la Figura 16.9.
Se emplea un tipo especial para la clave, para facilitar los cambios en caso que la clave no sea
entera.
typedef int data;
void initglobalvariables()
{ nil=¢inela;
nil->prioridad = UINT_MAX; //
nil->left = nil; nil->right = nil;
}
Si la clave no es numérica puede tomarse q(&p), para generar la prioridad del nodo.
Las rotaciones se implementan como macros. Se agregan globales para contar las rotaciones, en
el momento de la depuración, luego pueden eliminarse los contadores.
int opl=0;
int opr=0;
#define rrotm(t) do { \
{ temp=t; \
t = t->left; \
temp->left = t->right; \
t->right = temp; \
opr++; \
} \
} while(0)
#define lrotm(t) do { \
{ temp=t; \
t = t->right; \
temp->right = t->left;\
t->left= temp; \
opl++; \
} \
} while(0)
16.7. Insertar.
#define raiz 2
#define izq 1
#define der 0
#define AjustaPadre(p) if (dir==der) pp->right=(p); else if(dir==izq) pp->left=(p);else if(dir==raiz)*t=(p);
int espina=0;
int espinas(pnodo t)
{ int n=0;
pnodo p;
if (t!=NULL)
if (t!=nil )
{ p=t->left; //cuenta descendientes de t en espina izquierda
while (p!=nil) { n++; p=p->left;}
p=t->right; //cuenta descendientes de t en espina derecha
while (p!=nil) { n++; p=p->right;}
}
return n;
}
La función check efectúa un recorrido en el árbol, verificando las propiedades del treap en cada
nodo.
void check(pnodo p)
{ int k, tipo;
if (p!= nil)
{ check(p->left);
k=0;
//Hay error si prioridad del padre mayor o igual que la del hijo izq
if (p->left->prioridad < p->prioridad ) {tipo=1; k++;}
if (p->left->prioridad == p->prioridad ) {tipo=2; k++;} //warning
//Hay error si prioridad del padre mayor que la del hijo der
if (p->right->prioridad < p->prioridad ){tipo=3;k++;}
if (p->right->prioridad == p->prioridad ){tipo=4;k++;}//warning
//Se pueden agregar algunos test para verificar la estructura de árbol de búsqueda
//hijo izquierdo debe tener clave menor que su padre
if(p->left !=nil) if (p->left->clave >= p->clave ) {tipo=5; k++;}
//hijo derecho debe tener clave mayor que su padre
if(p->right !=nil) if(p->right->clave <= p->clave ) {tipo=6; k++;}
if(k)
{ if(tipo==2 || tipo==4) printf("Warning=%d %d \n", tipo, p->clave);
else printf("Error=%d %d \n", tipo, p->clave);
// prtprioridades(tree); putchar('\n');
}
check(p->right);
}
}
#define N …
int instrucciones=0;
int main(void)
{ int i;
clock_t start, stop; //tipo definido en time.h
int totaltime = 0;
initglobalvariables();
tree=nil;
start = clock();
srand(1);
for(i=1; i<=N; i++)
{ int aleatorio=rand()%1023;
tree=insertar(aleatorio, tree);
check(tree);
espina+=espinas(Buscar(aleatorio, tree));
//prtinorder(tree); putchar('\n');
}
instrucciones+=N;
E16.1.
A veces es deseable partir un conjunto de items X en dos conjuntos. Uno X1 con las claves de X
que son menores que a; y otro X2 con las claves mayores que a. O bien es necesario unir dos
conjuntos X1 y X2, cuando se asume que las claves del conjunto X1 son menores que las claves
del conjunto X2.
Estas operaciones son fáciles de implementar con las operaciones de inserción y descarte en un
treap. Para partir (split) un treap que almacena X, basta insertar la clave a, con prioridad
mínima, lo cual lleva dicho ítem a la raíz; dejando X1 como el subárbol izquierdo y X2 como el
subárbol derecho. Para unir o mezclar (join) se forma un treap con una raíz con prioridad
máxima, y con subárbol izquierdo el treap formado por X1, y con subárbol derecho el treap
formado por X2; luego se descarta la raíz.
E16.2.
Modificar la función de inserción cuando se pasa la dirección del nodo antecesor o sucesor de la
clave que será insertada.
Modificar la función de descarte cuando se pasa la dirección del nodo será descartado.
E16.3.
E16.4.
Referencias.
Raimund Seidel, Cecilia Aragon. “Randomized search trees”. Algorithmica 16:464-497, 1996.
Capítulo 17.
B-Trees.
n 1
log 2t (n 1) 1 h logt ( )
2
Demostración.
En el caso de que todos los nodos, incluida la raíz tengan el menor número de nodos, se tiene
que la raíz tiene dos hijos; un nodo con (t-1) claves tiene t hijos y t 2 nietos. La Figura 17.1,
ilustra el crecimiento del B-Tree con t=4. El menor número de claves que puede tener la raíz es
una.
1
2
3
Figura 17.1 B-tree con (t-1) claves en un nodo. Cada nodo tiene t hijos.
La siguiente serie geométrica, genera el número de nodos en uno de los subárboles de la raíz. En
el primer nivel hay un nodo, en el segundo se tienen t nodos, y en el tercero t 2 ; por inducción se
tendrán t h 1 en el nivel h.
k h
th 1
tk 1
1 t t 2 ... t h 1
k 1 t 1
k h
k 1 th 1
n 1 2(t 1) t 1 2(t 1)( ) 2t h 1
k 1 t 1
Figura 17.2 B-tree con máximo número de claves en cada nodo, con t=4.
La suma total de nodos del B-Tree, en este caso, puede expresarse según:
k h 1
(2t ) h 1 1
(2t ) k 1
1 2t (2t ) 2 ... (2t ) h
k 1 2t 1
Entonces el número total de claves almacenadas en un B-Tree de altura h, debe ser menor que:
k h 1
(2t ) h 1 1
n (2t 1) (2t ) k 1
(2t 1)( ) (2t ) h 1
1
k 1 2t 1
log 2t (n 1) 1 h
Binario balanceado
B-Tree
El número de total de claves en un B-Tree de grado t, con altura h está en el siguiente intervalo.
(2t ) h 1
1 n 2t h 1
El esquema de la Figura 17.5, muestra que si se tienen 3 claves, el nodo debe contener el
número de claves más uno, de tal forma que puedan definirse donde se almacenaran las claves
menores y mayores que cada una de las claves del nodo.
a0 a1 a2
p0 p3
p1 p2
b0 b1 b2 c0 c1 c2 d0 d1 d2 e0 e1 e2
El ordenamiento de las claves, en los nodos, en forma similar a un árbol binario de búsqueda,
cumple las relaciones, para el caso de la Figura 17.5:
e2 e1 e0 a2 d2 d1 d0 a1 c2 c1 c0 a0 b2 b1 b0
Se tiene que p0 apunta a descendientes con valores de claves menores que a0 ; y p1 apunta a
descendientes con valores de claves menores que a1 , y mayores que a0 .
Para buscar una clave en un B-Tree de grado t, el peor caso es cuando se tienen (t-1) claves en
cada nodo, ya que en este caso se tiene la mayor altura. Si buscamos la clave en un nodo,
mediante una búsqueda binaria, aprovechando que el arreglo de claves está ordenado, y luego
repetimos esta búsqueda en cada uno de los nodos de la ruta de descenso, que es el peor caso en
búsqueda, tendremos:
T ( n) h TBB (t 1)
n 1
La altura en peor caso es: h logt ( )
2
Reemplazando, se obtiene:
n 1
T (n) logt ( ) (log 2 (t 1) 1)
2
Lo que finalmente permite obtener la complejidad de buscar una clave en un B-Tree de grado t:
n 1
T (n) log 2 ( ) (log(n))
2
Que muestra que la búsqueda de una clave, en peor caso, en un B-Tree, tiene complejidad
logarítmica.
Asumiremos un nodo con 4 claves máximo y 2 claves mínimo. Insertaremos las claves en forma
ascendente a partir del número 1, lo cual es el peor caso para un árbol binario de búsqueda. Esta
estructura es una variación del B-Tree de grado t; en el caso del ejemplo se denomina 2-4-Tree.
Luego de crear el nodo raíz, se pueden agregar 4 claves. Cuando se agrega el número 5, se
produce un rebalse del nodo raíz; y es preciso dividir las claves del nodo raíz en dos nodos
descendientes, de esta manera el B-Tree crece un nivel. La altura crece por rebalse de las hojas.
Se activan dos punteros en el nodo raíz, como se muestra al centro de la Figura 17.6.
Como tenemos 5 claves, podemos dejar los nodos descendientes con dos claves cada una, y de
este modo quedan con el mínimo posible; y además mover la clave 3, del nodo que rebalsó, a la
raíz. Quedando ésta con una sola clave, cumpliendo las propiedades de un B-tree. Luego se
procede al ingreso de las claves 6 y 7, completando las claves de ese nodo, lo cual se muestra en
la Figura 17.6 a la derecha.
Las claves siempre se insertan en las hojas, para cumplir la propiedad de que un B-Tree tiene
hojas de igual profundidad.
Luego de ingresar la clave 11, se activa un nuevo puntero en la raíz, debido a la división de un
nodo en dos, el ascenso del 9 y la redistribución de claves. La Figura 17.8, a la derecha muestra
el nodo con todas sus claves, luego de ingresadas las claves 12 y 13.
Al insertar la clave 14, se activa el último puntero de la raíz, y además ésta queda con todas sus
claves ocupadas.
Se pueden ingresar las claves 15 y 16, completando las claves del nodo ubicado más a la
derecha, lo que se muestra en la Figura 17.10.
Al introducir la clave 17, se divide el nodo ascendiendo la clave 15, pero ésta no puede ser
almacenada en la raíz, y se procede a la división del nodo raíz, ascendiendo la clave 9, que se
deposita en una nueva raíz; de este modo se vuelve a incrementar la altura del B-Tree.
La Figura 17.12, muestra que después de ingresada la clave 25, se han agregado dos nodos al
subárbol izquierdo, quedando completos los nodos de más a la derecha del segundo y tercer
nivel.
Después de ingresar la clave 26, asciende la clave 24, y posteriormente la clave 18.
Cada vez que asciende una clave, se activan nuevos puntero, y se dividen los nodos.
Otra forma de diseñar la operación es insertar siempre la nueva clave en una hoja que no esté
completa; para lograr esto a medida que se desciende desde la raíz, buscando la posición para
insertar, si se encuentra un nodo completo, se procede a la división de éste, repartiendo
equitativamente las claves; y repitiendo este procedimiento hasta insertar finalmente la clave en
una hoja que no está completa.
Es importante destacar que antes de invocar a insertar, debe asegurarse que la clave no esté
presente, ya que en el descenso se modifica la estructura del B-Tree, y es posible que la hoja
donde debería insertarse la clave quede con menos de (t-1) claves, violando las propiedades de
un B-Tree.
a) Árbol vacío.
Se crea un nodo vacío y se le agrega el valor en la primera posición, el nodo se marca como
hoja.
Se ubica el índice i, cuya clave ai es menor que el valor v que debe insertarse. Se desplazan las
claves, a partir de la clave ai 1 , en una posición hacia la derecha. Se inserta la clave v, a la
derecha de la clave ai . Se aumenta el número de claves activas en p.
p
i n
a0 … ai v ai+1 … an-1
Buscar el índice i, tal que ai v , mediante búsqueda binaria tiene un costo de O(log(2t 1)) .
En el peor caso, cuando i 1 , es decir cuando a0 v , hay que desplazar (2t 2) claves y
además copiar la clave v, entonces el costo de esta operación es:
p
i
a0 … ai ai+1 … ana-1
pi+1
La clave central ct 1 se inserta a la derecha de ai , previo a esto deben desplazarse una posición
a la derecha, las claves y los punteros desde ai 1 hasta el final, incrementando en uno el número
de claves activas en nodo p. Esto es posible siempre que p apunte a un nodo que no está
completo. Se crea un nuevo nodo, que se muestra como pi 2 en la Figura 17.17, y se copian las
(t 1) claves mayores del nodo pi 1 en las primeras posiciones del nuevo nodo. Además se
disminuye a (t 1) el número de claves activas de pi 1 . Si el ancestro de v es una hoja, también
será hoja el nuevo nodo; y si el ancestro de v es un nodo interno, el nuevo nodo también debe
ser nodo interno. En este último caso es preciso copiar también los punteros a descendientes en
las primeras posiciones del nuevo nodo.
p i
a0 … ai ct-1 ai+1 … ana-1
pi+1 pi+2
c0 … ct-2 ct … c2t-2
raíz
En este caso se crea un nuevo nodo raíz, y en la primera clave se coloca la clave central de p. Se
crea un nuevo nodo en el cual se colocan las (t-1) claves mayores de p. Se actualiza la raíz, y se
procede a insertar v, en la nueva raíz, pero ahora ésta no está completa. Esto da inicio al proceso
de insertar la clave en un nodo ( p0 o p1 ) que no esté completo.
raíz 0
ct-1 …
p
p0 p1
c0 … ct-2 ct … c2t-2
Si se asume que el costo de insertar una clave en un nodo que no esté lleno, en el peor caso es
de costo O(t ) ; y como esto se repetirá h veces, uno por cada descenso desde la raíz hasta la
hoja en que se insertará efectivamente la clave. Siendo h la altura del B-Tree, la complejidad de
la operación insertar, será O(th) .
n 1
Antes se demostró que: O(h) O(logt ( )) O (logt (n ))
2
Entonces finalmente, la operación insertar tiene complejidad: O (t log t ( n)) , siendo n el número
de claves almacenadas en el B-Tree.
En un multiárbol, se puede borrar la clave en cualquier nodo; sin embargo, si el nodo en el cual
se encuentra la clave que será descartada tenía el número mínimo de claves, dejará de cumplirse
una de las propiedades del B-Tree, y será preciso corregir la estructura. La estrategia es
descartar la clave en una hoja que tenga más del número mínimo de claves.
La operación como se verá a continuación es bastante más compleja que la inserción, sin
embargo como en un B-Tree, el mayor número de claves se encuentran en las hojas, es más alta
la probabilidad de descartar una clave que se encuentre en una hoja.
Veremos primero el caso de descartar la clave en una hoja, y siempre que ésta tenga más del
número mínimo de claves almacenadas.
a0 a1 a2 … an-1
En este caso sólo es preciso desplazar las claves en una posición hacia la izquierda, a partir de la
siguiente a la descartada, y disminuir en uno el número de claves activas en el nodo. No es
preciso copiar los punteros, pues éstos almacenan valores iguales a nulos.
Un caso particular es si el nodo es la raíz y tiene un solo elemento; en este caso el B-Tree queda
vacío.
Buscar la clave dentro del nodo, en el peor caso, es de complejidad O(2t-1) si se emplea
búsqueda secuencial y O(log(2t-1)) si se emplea búsqueda binaria. Se requiera además mover,
en el peor caso, (2t-2) claves lo cual implica complejidad O(2t-2).
Si se emplea búsqueda binaria, esta operación tiene un costo:
Para lograr que el nodo que contenga la clave que será descartada, tenga más del número
mínimo de elementos, puede procederse al descenso desde la raíz hasta la hoja que contenga el
nodo con la clave buscada, realizando modificaciones para asegurar que siempre se producirá un
descenso a un nodo que tenga más de (t-1) claves, cuya solución se acaba de describir.
pi pi+1
b0 … bi … bnb-1 c0 … ci … cnc-1
Figura 17.21. Nodo interno con más de (t-1) claves, y clave con hijo izquierdo y derecho.
pi
b0 … bi … bnb-1
Debe notarse que en este caso, se desciende un nivel, y se está borrando una clave en un nodo
que tiene más de (t-1) claves. Esta operación incurre en el costo de encontrar la clave, y luego
efectuar una copia; entonces se tiene, para búsqueda binaria:
p
i
a0 … c0 ai+1 … ana-1
pi+1
c0 … ci … cnc-1
pi pi+1
nb 2t-2
b0 … v … cnc-1 c0 … ci … cnc-1
Esto implica desplazar las claves y los punteros en el nodo apuntado por p. Además deben
copiarse las nc claves en pi , a continuación de v; y si pi no es hoja, los punteros del nodo pi 1
deben también copiarse al nodo pi .
Si consideramos que el costo de encontrar la clave es O(log(2t 1)) , en el peor caso; y que
además deben moverse (2t 2) claves y (2t 1) punteros en p; y que también deben moverse t
claves, (t 1) desde pi 1 y la clave v desde p; y finalmente t punteros desde pi 1 , se tiene un
costo de: O(log(2t 1)) O(2t 2) O(2t 1) O(t ) O(t ) .
Nodo raíz con claves mínimas.
Si el nodo p es la raíz, puede quedar luego de la operación con menos de (t-1) claves. Esto se
ilustra en la Figura 17.23, con t=3, cuando se desea descartar la clave 15. En este caso la clave
está en el nodo apuntado por p, y luego de la operación, propuesta antes, se obtiene la Figura
pi pi+1
4 6 18 20
Figura 17.23. Hijos con claves mínimas, raíz con dos elementos.
p
0
10
t-1 pi pi+1
4 6 15 18 20 18 20
pi pi+1
4 8 14 18
raíz p
0
10
pi
t-1 pi+1
4 8 10 14 18 14 18
Si se ubica, con la posición i, la clave ai que es menor que el valor v buscado para descartar, se
tendrá que pi 1 apunta al nodo que contiene el valor, o a alguno de sus descendientes, que
contiene efectivamente el valor v.
p
i
a0 … ai ai+1 … ana-1
pi pi+1 pi+2
Se asume que p tiene más de (t 1) claves, excepto que sea la raíz. Y lo que desea asegurar es
que se descenderá a un nodo con más de (t 1) claves.
Ancestro de v, tiene más de (t-1) claves.
Si pi 1 tiene más de (t 1) claves, se procede a descartar recursivamente v, en el nodo apuntado
por pi 1 , descendiendo un nivel.
Esta operación tiene el costo de encontrar la clave: O(log(2t 1)) .
pi pi+1 pi+2
Si consideramos que el costo de encontrar la clave es O(log(2t 1)) , en el peor caso; y que
además deben moverse (t 1) claves y t punteros en pi 1 ; y que también deben efectuarse dos
copias de claves y una de puntero, se tiene un costo de:
p
i
a0 … ai d0 … ana-1
1
pi pi+1 pi+2
Esta operación puede aplicarse si el nodo p es la raíz, sin que ésta cambie.
pi pi+1 pi+2
Si consideramos que el costo de encontrar la clave es O(log(2t 1)) , en el peor caso; y que
además deben copiarse t claves y (t 1) punteros en pi ; y que también deben efectuarse
(t 2) movimientos de clave y (t 1) de punteros en nodo p, se tiene un costo de:
a0 … ai ai+2 … ana-1
pi+1
pi+2
c0 … cnc-1 ai+1 d0 .. dnd-1 d0 … di … dnd-1
Si el nodo p es la raíz, y tiene una sola clave, debe cambiarse la raíz al nodo que queda con
(2t 1) claves, y liberar además el antiguo nodo raíz.
Si se asume que el costo de descartar una clave en un nodo con al menos t elementos, en el peor
caso es de costo O(t ) ; y como esto se repetirá h veces, uno por cada descenso desde la raíz
hasta la hoja en que se descarta efectivamente una clave. Siendo h la altura del B-Tree, la
complejidad de la operación descartar, será O(th) .
n 1
Antes se demostró que: O(h) O(logt ( )) O(logt (n))
2
Entonces finalmente, la operación descartar tiene complejidad: O (t log t (n )) , siendo n el
número de claves almacenadas en el B-Tree.
Una buena interpretación de los nodos rojos en un árbol coloreado, es dibujarlos en un nivel
horizontal, de este modo se refleja que no alteran las alturas negras de los nodos. Esto se
muestra en la Figura 17.33, para el árbol coloreado de la Figura 17.32.
9
4 15
2 5 12 20
En la representación con rojos en nivel horizontal, debe cuidarse que cada nodo tenga dos hijos,
para que el árbol sea binario. En el par 4-9, el primero se considera rojo; y en el par 5-7 el
segundo se considera rojo. La representación muestra que todas las hojas están en el mismo
nivel.
4 9
2 5 7 12 15 20
Luego los nodos que están unidos por horizontales se consideran pertenecientes a un nodo de un
B-tree de grado dos, lo cual se muestra en la Figura 17.34.
4 9
p0
p1 p2
2 5 7 12 15 20
Figura 17.34. B-tree de grado dos, equivalente del coloreado de la Figura 17.32.
Con esta interpretación pueden diseñarse las operaciones de inserción y descarte en árboles
coloreados, agrupando las claves que pertenezcan a un nodo de un B-Tree y realizando las
operaciones de mezcla y separación que se realizan en esta estructura, pero es preferible
especializar las operaciones, debido a que la estructura interna de un nodo de un árbol coloreado
es bastante más simple que la de un B-Tree.
typedef struct nn
{
int clave[N]; //con N claves en el Nodo
struct nn *pn[N+1];
int act; //número de claves activas en el nodo
int hoja; // 1 es hoja; 0 en nodo interno
} nodo, *pnodo;
pnodo CreaNodo()
{ int i;
pnodo p=malloc(sizeof(nodo));
if (p!=NULL)
{ p->act=0;p->hoja=esnodointerno;
for(i=0;i<N+1;i++) p->pn[i]=NULL;
for(i=0;i<N;i++) p->clave[i]=0;
}
return(p);
}
void PrtNodo(pnodo p)
{ int i;
if(p->act==0) printf("Nodo vacío\n");
else for(i=0; i<p->act;i++) printf("%d ", p->clave[i]);
//putchar('\n');
}
En casos prácticos el número de claves en un nodo es elevado, por lo que suele emplearse
búsqueda binaria.
x->pn[i+1]=z;
17.9. Inserción.
pnodo btree=NULL;
int main(void)
{ int i;
/* Test básico
PrtNodo(btree);
descartar(&btree,1);
inserte(&btree,1);
PrtNodo(btree);
descartar(&btree,1);
PrtNodo(btree);
*/
for(i=1;i<=Nclaves;i++) inserte(&btree,i);
if (debug) PrtBtree(btree,1);
for(i=Nclaves;i>0;i--) descartar(&btree,i);
for(i=1;i<Nclaves;i++) inserte(&btree,i);
for(i=1;i<Nclaves;i++) descartar(&btree,i);
for(i=Nclaves;i>0;i--) inserte(&btree,i);
if (debug) PrtBtree(btree,1);
for(i=1;i<=Nclaves;i++) descartar(&btree,i);
for(i=Nclaves;i>0;i--) inserte(&btree,i);
for(i=Nclaves;i>0;i--) descartar(&btree,i);
//test de búsquedas
for(i=1;i<=Nclaves;i++) inserte(&btree,i);
for(i=1;i<=Nclaves;i++)
if (search(btree,i)==0) printf("Error no encuentra clave %d que está en B-tree\n",i);
for(i=Nclaves+1;i<=Nclaves+10;i++)
if (search(btree,i)) printf("Encuentra clave %d que no está en B-tree\n",i);
for(i=1;i<=Nclaves;i++) descartar(&btree,i);
printf("Ok\n");
return (0);
Referencias.
Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein. “Introduction
to Algorithms”, Second Edition. MIT Press and McGraw-Hill, 2001.
Índice de figuras.
FIGURA 17.1 B-TREE CON (T-1) CLAVES EN UN NODO. CADA NODO TIENE T HIJOS. ....................................... 1
FIGURA 17.2 B-TREE CON MÁXIMO NÚMERO DE CLAVES EN CADA NODO, CON T=4. ..................................... 2
FIGURA 17.3 ALTURA DE B-TREE CON T=4. .................................................................................................. 3
FIGURA 17.4 CLAVES MÁXIMAS Y MÍNIMAS ALMACENADAS EN B-TREE CON T=4. ....................................... 3
FIGURA 17.5 ESQUEMA DE B-TREE CON T=2................................................................................................. 4
FIGURA 17.6. B-TREE DESPUÉS DE INGRESAR LA CLAVE 4, 5 Y 7. ................................................................. 5
FIGURA 17.7. B-TREE DESPUÉS DE INGRESAR LA CLAVE 8, Y 10. .................................................................. 6
FIGURA 17.8. B-TREE DESPUÉS DE INGRESAR LA CLAVE 11, Y 13. ................................................................ 6
FIGURA 17.9. B-TREE DESPUÉS DE INGRESAR LA CLAVE 14. ......................................................................... 6
FIGURA 17.10 B-TREE DESPUÉS DE INGRESAR LA CLAVE 16. ........................................................................ 6
FIGURA 17.11 B-TREE DESPUÉS DE INGRESAR LA CLAVE 17. ........................................................................ 7
FIGURA 17.12 B-TREE DESPUÉS DE INGRESAR LA CLAVE 25. ........................................................................ 7
FIGURA 17.13 B-TREE DESPUÉS DE INGRESAR LA CLAVE 26. ........................................................................ 7
FIGURA 17.14 INSERCIÓN EN HOJA QUE NO ESTÁ LLENA. .............................................................................. 8
FIGURA 17.15 DESPUÉS DE INSERTAR V EN HOJA QUE NO ESTABA LLENA. .................................................... 8
FIGURA 17.16 DESCENDER A UN NODO QUE ESTÁ COMPLETO. ...................................................................... 9
FIGURA 17.17 DESPUÉS DE PARTIR ANCESTRO DE V. ..................................................................................... 9
FIGURA 17.18 INSERCIÓN EN RAÍZ COMPLETA. ........................................................................................... 10
FIGURA 17.19 DESPUÉS DE INTENTAR INSERTAR EN RAÍZ COMPLETA. ........................................................ 10
FIGURA 17.20. HOJA CON MÁS DE (T-1) CLAVES. ........................................................................................ 11
FIGURA 17.21. NODO INTERNO CON MÁS DE (T-1) CLAVES, Y CLAVE CON HIJO IZQUIERDO Y DERECHO. ..... 12
FIGURA 17.21A. SE RECURRE A BORRAR EL PREDECESOR DE V. .................................................................. 12
FIGURA 17.21B. SE RECURRE A BORRAR EL SUCESOR DE V. ........................................................................ 13
FIGURA 17.22. MEZCLA DEL HIJO IZQUIERDO Y DERECHO. ......................................................................... 13
FIGURA 17.23. HIJOS CON CLAVES MÍNIMAS, RAÍZ CON DOS ELEMENTOS. .................................................. 14
FIGURA 17.24. FUSIÓN DE HIJOS. ................................................................................................................ 14
FIGURA 17.25. HIJOS CON CLAVES MÍNIMAS, RAÍZ CON UN ELEMENTO. ...................................................... 14
FIGURA 17.26. NUEVA RAÍZ, Y FUSIÓN DE HIJOS. ........................................................................................ 15
FIGURA 17.27. DESCARTE EN NODO INTERNO QUE NO CONTIENE LA CLAVE BUSCADA. .............................. 15
FIGURA 17.28. PRÉSTAMO DEL HERMANO IZQUIERDO. ............................................................................... 16
FIGURA 17.29. PRÉSTAMO DEL HERMANO DERECHO................................................................................... 17
FIGURA 17.30. FUSIÓN DEL ANCESTRO CON EL HERMANO IZQUIERDO. ....................................................... 17
FIGURA 17.31. FUSIÓN DEL ANCESTRO CON EL HERMANO DERECHO. ......................................................... 18
FIGURA 17.32. ÁRBOL BINARIO COLOREADO. ............................................................................................. 19
FIGURA 17.33. COLOREADO CON ROJOS HORIZONTALES............................................................................. 19
FIGURA 17.34. B-TREE DE GRADO DOS, EQUIVALENTE DEL COLOREADO DE LA FIGURA 17.32. .................. 19
Capítulo 18
Pagodas. Seleccionar.
Una pagoda es una representación de un árbol binario tal que los hijos tienen claves mayores o
iguales a las del padre. Es un algoritmo de selección que puede agregar ítems en forma
dinámica, a diferencia del heap implícito, árbol binario embebido en un arreglo, que utiliza
memoria estática.
18.1. Propiedades.
Cada nodo s de una pagoda contiene dos punteros i(s) y d(s) definidos como sigue:
(1) si s es la raíz o es un hijo derecho: i(s) apunta al nodo más izquierdista del subárbol de raíz s.
(2) si s es un hijo izquierdo, i(s) apunta al padre de s.
El puntero d(s) está definido de una manera simétrica:
(1) si s es la raíz o es un hijo izquierdo d(s) apunta al nodo más derechista del subárbol de raíz s.
(2) si s es un hijo derecho, d(s) apunta al padre de s.
1
1
2 3
2 3
4 6 5
4 6 5
9 8 7
9 8 7
Una pagoda es un edificio de varios niveles, común en países asiáticos. La Figura 18.1, a la
derecha adopta una forma similar a una pagoda, que se muestra en la Figura 18.2, por esto el
nombre.
Sin embargo si se redibuja la Figura 18.1, a la derecha, según un árbol binario convencional, se
advierten dos listas circulares asociadas a la raíz; una formada por los punteros derechos del
subárbol derecho; y otra formada por los punteros izquierdos del subárbol izquierdo. Esto se
ilustra en la Figura 18.3. A partir de la raíz, las listas están en orden descendente, y los
elementos menores de estas listas apuntan a la raíz. Si se desea eliminar la raíz, deben
modificarse esas dos direcciones.
1
4 7
2 5
8 3
6
Cada nodo puede considerarse la raíz de una pagoda, que debe cumplir las propiedades que la
definen, para los descendientes de ese nodo. Por ejemplo, en el nodo con valor 2, se tiene una
pagoda que tiene lista circular izquierda nula; en forma similar las pagodas asociadas a los
nodos 8 y 3 pueden considerarse formadas por un solo elemento.
La operación básica es la de mezclar o fundir (meld or merg), que puede definirse como unir
dos estructuras de datos, con determinadas propiedades, en una estructura mayor, que cumple la
misma propiedad.
El descarte, remoción o selección de la raíz, que tiene el valor de prioridad mínimo, va seguida
de la mezcla de las subpagodas izquierda y derecha de la raíz.
Éstas se obtienen desplazando los elementos menores a la posición de la nueva raíz de las
subpagodas. Esto se muestra en la Figura 18.4, considerando que se descartó la raíz de valor 1,
en la Figura 18.3.
left
2 3
7
4 6
9 5
Si p apunta a la raíz, y la pagoda izquierda es apuntada por left, el siguiente segmento determina
el puntero left, que define la subpagoda izquierda. Se tiene código especular para obtener la
pagoda derecha.
La mezcla de dos pagodas a y b, en rn, se realiza uniendo la lista circular izquierda de una
pagoda con la lista circular derecha de la otra.
3 7 5
Se recorren ambas listas en forma descendente, colocando en una nueva pagoda los elementos
de las listas circulares; es decir agregando el 7, luego el 5, después el 4, a continuación el 3, y
finalmente el 2. Se emplea rn para apuntar a la nueva pagoda en formación.
Para recorrer las listas se emplean los punteros la y lb. El siguiente segmento inicia los punteros
a las listas circulares:
3 la 7 5
Si como en el caso del ejemplo, el elemento mayor se encuentra en la lista derecha, el siguiente
código va formando la nueva pagoda:
t = la->right;
if ( rn==NULL ) la->right = la; // pagoda con un elemento. Padre de sí mismo
else
{ la->right = rn->right; //apunta al mayor de lista derecha
Después de ejecutado el segmento anterior, del cual se ejecuta el if, la Figura 18.7, muestra la
situación:
rn
3 la 5
Si se vuelve a repetir el código anterior, se ejecutará el else, y luego de éste se tiene la Figura
18.8.
t
a rn
3 la 5 7
la rn
a
3 5
8 7
De acuerdo a la Figura 18.6, ahora debe agregarse el nodo 4, que pertenece a la lista circular
izquierda, ya que es mayor que el nodo que aún resta considerar en la lista circular derecha. Para
esto debe ejecutarse el código especular del anterior:
4 4
8 8
5 5
7 7
Habiéndose agotado la lista circular derecha, es preciso finalizar la pagoda agregando el resto de
la lista circular izquierda, esto puede lograrse, con el siguiente segmento:
b->left = rn->left;
rn->left = lb;
b lb
2
a->right = rn->right;
rn->right = la;
3 6
4 9
La función completa para la mezcla de dos pagodas a y b, contempla los casos particulares en
los cuales una de las pagodas es nula.
18.9. Insertar.
Si se introducen los valores de los nodos en forma ascendente, se produce una lista circular
izquierda con todos los nodos menos la raíz, y una derecha vacía. La pagoda degenera en una
lista circular. En este caso el costo de insertar n ítems es O(n), ya que en cada inserción se
realiza una vez el ciclo while de la función mezclar; y el descarte es O(1), ya que una de las
listas circulares es vacía y no se ejecuta el ciclo while de la función mezclar.
En el ejercicio E1, se muestra un diseño alternativo de la inserción en una pagoda, la cual se
construye aplicando las propiedades de una pagoda, sin implementar en base a la operación
mezclar.
int prtpagoda(pnodo p)
{ pnodo t;
if (p!=NULL) printf("rn=%d ri->", p->prioridad);
else {printf("Pagoda nula\n"); return (0);}
for(t=p->right; t!=NULL && t!=p; t=t->right) printf("%d ", t->prioridad);
printf(" le->");
for(t=p->left; t!=NULL && t!=p; t=t->left) printf("%d ", t->prioridad); putchar('\n');
return(1);
}
pnodo pagoda=NULL;
Lo cual genera:
Nótese que la función descartar, va imprimiendo los valores de los ítems seleccionados, de
menor a mayor. El ciclo while de mezclar se repite 34 veces para la inserción de los 20 items, y
37 veces para descartarlos; esta cuenta es fácil de obtener agregando un contador de las veces
que se ejecuta el lazo de mezcla. Nótese que para 20 ítems, resultan en este caso aleatorio listas
circulares de largo dos.
Ejercicios.
E18.1.
Verificar que la siguiente función inserta en una pagoda, cuya dirección es pasada por
referencia.
E18.2.
Verificar que la siguiente función retorna un puntero al nodo con valor mínimo, en una pagoda,
cuya dirección es pasada por referencia.
Verificar que las siguientes funciones implementan una cola de prioridad empleando una lista
simplemente enlazada, ordenada por el valor de prioridad.
void prtcola(plista p)
{
for( ; p!=NULL; p=p->proximo)
printf( "%d ", p->prioridad);
putchar('\n');
}
E18.4.
Referencias.
Douglas W. Jones, “An empirical comparison of priority queue and even-set implementations”,
Communications of the ACM, April 1986 Volume 29 Number 4.
CAPÍTULO 18 ........................................................................................................................................... 1
PAGODAS. SELECCIONAR. .................................................................................................................. 1
18.1. PROPIEDADES. ................................................................................................................................. 1
18.2. LISTAS CIRCULARES DE LA RAÍZ DE UNA PAGODA............................................................................ 2
18.3. MEZCLA DE PAGODAS. ..................................................................................................................... 2
18.4. ANÁLISIS DE LA MEZCLA. ................................................................................................................ 3
18.5. DEFINICIÓN DE TIPOS. ...................................................................................................................... 7
18.6. CREACIÓN DE NODO......................................................................................................................... 7
18.7. FUNCIÓN MEZCLAR. ......................................................................................................................... 7
18.8. DESCARTAR. .................................................................................................................................... 9
18.9. INSERTAR. ....................................................................................................................................... 9
18.10. MOSTRAR LISTAS CIRCULARES DE LA RAÍZ DE UNA PAGODA. ...................................................... 10
18.11. TEST DE LAS FUNCIONES. ............................................................................................................. 10
EJERCICIOS. ............................................................................................................................................ 10
E18.1. ................................................................................................................................................ 10
E18.2. ................................................................................................................................................ 11
E18.3. ................................................................................................................................................ 13
E18.4. ................................................................................................................................................ 14
REFERENCIAS. ........................................................................................................................................ 14
ÍNDICE GENERAL. ................................................................................................................................... 15
ÍNDICE DE FIGURAS................................................................................................................................. 15
Índice de figuras.
Capítulo 19
Un leftist es una representación de un árbol binario tal que los hijos tienen claves mayores o
iguales a las del padre y se mantiene balanceado.
Es un algoritmo de selección que puede agregar ítems en forma dinámica, a diferencia del heap
implícito, árbol binario embebido en un arreglo, que utiliza memoria estática.
19.1. Propiedades.
Usa un árbol binario con estructura de heap, el cual se representa con punteros de padres a hijos.
Los dos hijos, de cada nodo, están ordenados tal que la trayectoria derecha es la más corta desde
la raíz hasta un nodo externo, el largo de la trayectoria derecha se almacena en cada nodo.
La distancia equivale a contar los nodos recorridos desde el nodo hasta la hoja ubicada más a la
derecha de ese nodo. Las hojas tienen distancia 1.
Si se aplican las propiedades anteriores y se introducen nodos con prioridades desde el 1 hasta el
10, en orden ascendente, se forma al árbol binario de prioridad leftist, que se muestra en la
Figura 19.1. Las distancias se han colocado a la derecha del nodo. Se aprecia que el árbol tiene
más nodos ubicados en ramas izquierdas, de allí el nombre. Debe notarse que el árbol está
balanceado en forma similar a un heap, pero la ubicación de las hojas en los últimos niveles es
diferente.
1 3
3 2 2 2
4 1 5 1 7 2 6 1
8 1 9 1 10 1
Para insertar se colocan los nuevos datos en un nodo, en un árbol con un elemento, y se mezcla
con el árbol existente.
n 1
Para descartar el mínimo, se remueve la raíz, y se mezclan los subárboles izquierdo y derecho.
Dos leftist se mezclan, en un nuevo árbol, fundiendo sus ramas de más a la derecha, en orden
descendente; es decir el nodo con menor valor de prioridad queda más abajo. Debido a la forma
en que se define la operación mezcla, y para lograr una operación eficiente, la estructura intenta
mantener las trayectorias derechas lo más cortas que sea posible.
Luego se mantiene, descendiendo por la derecha, el balance del árbol registrando, para cada
item en el descenso, la distancia a la hoja derecha más cercana, y siempre manteniendo
ordenados los dos hijos de un ítem de tal modo que la trayectoria hasta la hoja más cercana esté
a través del hijo derecho.
a r b
3 2 4 2
6 1 5 1 8 1 7 1
Se mezclan las ramas derechas de los árboles. Manteniendo el nodo con mayor prioridad en la
raíz de la mezcla.
El siguiente segmento describe el bloque repetitivo, que realiza la mezcla de las ramas derechas:
En el caso del ejemplo de la Figura 19.3, en primer lugar se ejecuta el código del if, resultando
la Figura 19.4:
r a b
3 5 4 2
6 8 1 7 1
a r b
5 4 7
8 3
Se vuelve a repetir el lazo de mezcla, ejecutándose nuevamente el if, se agota la lista derecha
del leftist a, y resulta la Figura 19.6:
a r b
5 1 7 1
4 2
8 1 3 2
6 1
Una vez ejecutado el código de terminación, resulta la Figura 19.7, a la derecha. Notar que se
alteran las distancias de los nodos del trayecto formado en la mezcla.
7 1
8 1 3 2 8 1 3 2
d=1
6 1 6 1
8 1 5 1
6 1
7 1
4 2 6 1
8 1 5 1
d=2
7 1
qraiz leftist
raíz
Se pasa la cola por referencia. Retorna el nodo con valor de prioridad mínimo.
queue leftist=NULL;
srand(1);
for(i=0; i<20; i++) enqueue(&leftist, rand()%100);
for(i=0; i<20; i++)
{ t=dequeue(&leftist);
printf("%d ", t->prioridad); free(t);
}; putchar('\n');
19.11. Ejemplo.
Para el leftist de la Figura 19.1, si se elimina el mínimo, luego de formar la mezcla con
prioridades descendentes se obtiene la Figura 19.11 a la izquierda. Debe notarse que las
distancias de los nodos involucrados en el trayecto, 5, 3 y 2, tienen distancias no válidas.
5 1 6 1
3 2 10 1 7 2 3 2
4 1 2 1 9 1 4 1 5 1
8 1
7 2 6 1
8 1 9 1 10 1
2 3
7 2 3 2
8 1 9 1 4 1 5 1
6 1
10 1
Figura 19.12. Inserción de nodo con menor prioridad que el de la raíz de la Figura 19.11.
Si se inserta un nodo con valor 11, en el leftist a la derecha de la Figura 19.11, la mezcla se
ejecuta más lentamente, ya que debe formarse el trayecto descendente y luego rebalancear los
nodos del trayecto. Resulta el leftist que se muestra en la Figura 19.13.
2 3
7 2 3 2
8 1 9 1 5 2 4 1
6 1 11 1
10 1
Figura 19.13. Inserción de nodo con mayor prioridad que el de la raíz de la Figura 19.11.
Referencias.
Knuth. D. E. “The Art of Computer Programming”. Vol. 3, Sorting and Searching. Addison-
Wesley, Reading, Mass., 1973. Knuth acredita el trabajo a Clark A. Crane.
C. A. Crane, “Linear lists and priority queues as balanced binary trees”, Technical Report
STAN-Cs-72-259, Computer Science Dept., Stanford Univ., Stanford, CA, 1972.
CAPÍTULO 19 ........................................................................................................................................... 1
ÁRBOLES IZQUIERDISTAS. SELECCIONAR. .................................................................................. 1
19.1. PROPIEDADES. ................................................................................................................................. 1
19.2. MEZCLA DE LEFTIST. ....................................................................................................................... 1
19.3. ANÁLISIS DE LA MEZCLA. ................................................................................................................ 2
19.4. DEFINICIÓN DE TIPOS. ...................................................................................................................... 5
19.5. CREACIÓN DE NODO Y DE COLA. ...................................................................................................... 6
19.6. TEST DE COLA VACÍA. ...................................................................................................................... 6
19.7. INSERTAR. ENCOLAR. ...................................................................................................................... 6
19.8. SELECCIONAR EL MÍNIMO. DESENCOLAR. ........................................................................................ 7
19.9. FUNCIÓN MEZCLAR. ......................................................................................................................... 7
19.10. TEST DE LAS FUNCIONES. ............................................................................................................... 8
19.11. EJEMPLO. ....................................................................................................................................... 8
REFERENCIAS. ........................................................................................................................................ 10
ÍNDICE GENERAL. ................................................................................................................................... 11
ÍNDICE DE FIGURAS................................................................................................................................. 11
Índice de figuras.
Capítulo 20
Un skew heap es una representación de un árbol binario tal que los hijos tienen claves mayores
o iguales a las del padre y que mantiene las trayectorias derechas lo más cortas que sea posible.
Es un algoritmo de selección que puede agregar ítems en forma dinámica, a diferencia del heap
implícito, árbol binario embebido en un arreglo, que utiliza memoria estática.
Es un algoritmo derivado de los árboles izquierdistas, leftist, pero que no almacena información
de los trayectos derechos en cada nodo. Su característica es que es auto ajustable o
autoorganizado, en el sentido de que en cada operación de actualización se efectúa una
modificación sencilla de la estructura, tal que esto reduzca el costo de las operaciones futuras. Si
bien esto incurre en un costo mayor de las operaciones, en promedio se logra un
comportamiento eficiente. Esto lo logra no intentando optimizar el peor caso de una operación
individual, sino que el peor caso de una secuencia de operaciones.
La codificación resulta más sencilla que los algoritmos que garantizan el balance de la
estructura, y demandan menos espacio para almacenar cada nodo.
En forma similar a un leftist, las operaciones están basadas en la mezcla de los trayectos
derechos de los dos heaps, pero con la modificación de que en lugar de mantener distancias
derechas más cortas, se efectúa directamente un intercambio de los hijos derecho e izquierdo de
cada nodo en el trayecto de la mezcla; salvo en el último nodo.
1
2
3 4
3 5
7 6
8 7 8
8 9 10
En la Figura 20.2, a la izquierda se muestra la mezcla de los dos trayectos derechos, se forma
una ruta derecha más larga, formada por la secuencia: 1, 2, 4, 5, 6 y 8. Luego a la derecha se
muestra el intercambio de los hijos de cada nodo del trayecto derecho; lo cual deja un skew
heap con trayecto derecho de un nodo.
1
1
3 2 2 3
3 4
4 3
8 7 5 5 7 8
8 9 7 6 6 7 8 9
10 8 8 10
El algoritmo puede realizar simultáneamente la mezcla y el intercambio de los hijos, para esto
se recorren los dos trayectos derechos de arriba hacia abajo (top-down) y cuando se llega al final
de uno de los trayectos, el resto del otro se enlaza al final y el proceso termina.
Sólo los nodos visitados en el trayecto tienen sus hijos intercambiados, el último nodo que tiene
intercambio de hijos es el último nodo del trayecto que se recorre totalmente; el nodo con valor
6 en la Figura 20.2.
Se tienen que h1 apunta al heap cuya raíz tiene prioridad menor, y h2 al heap con raíz con
prioridad mayor, y h al heap con la mezcla, que inicialmente tiene valor nulo.
h1 h2
a d
b c e f
h = h1;
y = h1; // apunta al último agregado
h1 = y->right; //desciende en trayecto derecho
y->right = y->left; //intercambia hijos
h
y
a
h1
c b
Luego debe repetirse un procesamiento similar hasta agotar uno de los trayectos:
Para insertar se colocan los nuevos datos en un nodo, en un heap con un elemento, y se mezcla
con el árbol existente.
n 1
Para descartar el mínimo, se remueve la raíz, y se mezclan los subárboles izquierdo y derecho.
q skew
*q
raíz
Se pasa la cola por referencia. Retorna el nodo con valor de prioridad mínimo.
queue skewheap=NULL;
srand(1);
for(i=0; i<20; i++)
{ enqueue(getnodo(rand()%100), &skewheap );
prtskew(skewheap); //muestra los largos derechos
}
Pueden lograrse mejores resultados para las operaciones de mezcla e inserción, si las mezclas e
intercambios de hijos de los trayectos derechos se efectúa de abajo hacia arriba.
Se inicia la mezcla e intercambio de hijos en los nodos más derechistas de los heaps, cuando se
llega al tope de uno de los trayectos, la raíz de uno de los heaps que se están mezclando, se pega
como hijo derecho del último nodo del otro trayecto, el que aún no ha participado de la mezcla.
El intercambio de hijos se realiza incluyendo el nodo raíz del heap que se agotó primero, luego
no se sigue con el intercambio.
3 4
5
7 6 7 8
8 9 10 8 9
En el caso del ejemplo que se muestra en la Figura 20.7, se deben mezclar los nodos 8, 5 con los
nodos 6, 4, 1, en ese orden. El último nodo que intercambia sus hijos es el con valor 5, luego
éste se pega como hijo derecho del nodo con valor 4, que aún no se ha considerado en la
mezcla. El resultado de la mezcla de abajo hacia arriba, se muestra en la Figura 20.8.
3 4
7 5
8 9 6 7
8 10 8
0
La realización eficiente de esta operación requiere tener punteros al padre y referencias a los
nodos más derechistas de ambos heaps. Sin embargo, en forma similar a las pagodas, pueden
emplearse punteros al padre y al nodo ubicado más a la derecha del hijo izquierdo. La raíz
La Figura 20.9, a la izquierda, muestra un heap mediante un árbol binario con punteros a los
hijos. A la derecha se muestra con los punteros derechos la referencia al padre, el puntero
derecho de la raíz referencia al hijo más derechista. Con los punteros izquierdos se apunta al
hijo más derechista del subárbol izquierdo, o a sí mismo si no tiene descendientes izquierdos.
1
1
2 3
2 3
4 6 5
4 6 5
9 8 7
9 8 7
Puede redibujarse como un árbol binario, lo cual se muestra en la Figura 20.10, a la izquierda.
Pero una mejor representación es mediante listas circulares o anillos, en la cual se tienen listas
ordenadas descendentes con los punteros derechos y listas ordenadas ascendentes hacia abajo, lo
cual se muestra a la derecha de la Figura 20.10.
1 h
1 7 5 3
6 7
8
9 5 6 2
2
8 3
4 9 4
Puede resumirse las propiedades notando que en cada nodo el puntero derecho apunta a un nodo
con menor prioridad, y que hacia abajo se apunta a uno con mayor prioridad. La raíz es la
excepción ya que hacia la derecha apunta a una lista circular ordenada descendentemente y
hacia abajo a una lista ascendente. Desde cualquier nodo pueden seguirse rutas descendentes
Si el elemento que se inserta es menor o igual que la raíz, se coloca el nuevo elemento como la
raíz; y se coloca el primer elemento de la lista, el mayor de ésta, como descendiente ascendente
de la nueva raíz. Notar que la raíz antigua ya estaba ligada al elemento mayor. Efectuando tres
asignaciones se logra la Figura 20.11, a partir de la inserción de un nodo con valor 0, en el heap
que se muestra a la derecha de la Figura 20.10. Si se insertan secuencias de nodos con
prioridades descendentes, crecerá la lista ascendente hacia abajo.
h
7 5 3 1
8
6 2
9 4
Si el elemento que se inserta es mayor o igual que el primero de la lista descendente derecha se
lo coloca como el primero de la lista. La Figura 20.12, muestra la inserción del elemento con
prioridad 9, en el heap de la Figura 20.10 a la derecha. Su realización se logra con la escritura de
dos punteros. Si se insertan secuencias de nodos con prioridades ascendentes, crecerá la lista
derecha.
1 9 7 5 3
8
6 2
9 4
Figura 20.12. Inserción de elemento mayor o igual que el primero de la lista derecha.
Esto se logra con dos punteros auxiliares, y que busca la posición para insertar; y el puntero z
que apunta al descendiente hacia abajo.
Al inicio ambos punteros auxiliares apuntan al primero de la lista derecha. Donde n es el nodo
que se insertará, y h apunta a la raíz del heap.
y = z = h->right;
while (n->prioridad < y->right->prioridad)
{ y=y->right; //avanza en la lista descendente.
t=z; z=y->down; y->down=t; // swap(z, y->down)
}
n->right=y->right; n->down=z; //enlaza lista ascendente y descendente.
y->right=h->right=n; // cambia inicio lista y encadena el nuevo nodo
La Figura 20.13 ilustra la inserción del nodo con prioridad 6, en la Figura 20.10, a la derecha.
En este caso no se efectúa el código del while.
h
1 6 5 3
6 2 7 8
9 4
La Figura 20.14, muestra la inserción de un nodo con valor 4, en el heap de la Figura 20.10, a la
derecha. En este caso se efectúa el código del while, al salir de éste z apunta al nodo con valor 8,
y el puntero y al elemento inmediatamente mayor que el que se insertará, al 5 en este caso.
1 4 3
8 5
6 2
7
9 4
Debe notarse que en el caso de inserción en medio de la lista, el largo de ésta tiende a acortarse.
Si bien el costo de esta operación es más compleja, las siguientes inserciones resultarán más
simples. Equivale a un pago por adelantado, con el fin de disminuir el costo total de la deuda; es
realmente una amortización.
Si se desea borrar la raíz del heap que se muestra a la derecha de la Figura 20.10, deben
reescribirse los punteros derechos de los nodos 2 y 3, que son los finales de las listas principales
que derivan de la raíz. Se mezclan los trayectos derechos de las listas, intercambiado los hijos a
través del trayecto de mezcla, y se detiene la mezcla cuando se llega a la raíz en ambos
trayectos.
Se tratan como casos especiales que el heap esté vacío, y que una de las listas esté vacía. En el
primer caso se retorna un puntero nulo; en el segundo, no es necesario realizar la mezcla, sino
que encontrar el nodo con prioridad mínima, recorriendo la lista descendente hasta el final.
En un caso general, se requieren tres variables auxiliares: mayor y menor que apuntan a los
elementos de las listas que deben mezclarse, next se emplea para escoger el mayor de los
elementos, y si es preciso se cambian los punteros mayor y menor:
Luego de esto se procede a un lazo de mezcla, el cual agrega el nodo next al nuevo heap, el que
debe repetirse hasta encontrar la raíz. Sea r la raíz que apunta al elemento mínimo que se desea
descartar.
next
d e
f … g
while (next != r)
{ mayor = next->right; //avanza en lista mayor
next->right = next->down; //intercambia hijos de next
next->down = h->right; //agrega nodos con prioridades mayores
h->right = next; //enlaza a con el padre d
h = next; //la nueva raíz absorbe nodo next
//selecciona siguiente
if (mayor->prioridad < menor->prioridad)
{ next = menor; menor = mayor;}
else {next = mayor;}
}
h
d f … g
mayor
e
b … c a
La creación de una nueva cola de prioridad, y el test de cola vacía, resultan idénticas a las
funciones para el caso top-down.
//selecciona siguiente
if (mayor->prioridad < menor->prioridad)
{ next = menor; menor = mayor;}
else {next = mayor;}
}
queue skewup=NULL;
#define N 10
return(0);
}
E20.1.
Referencias.
D. D. Sleator, R.E. Tarjan., “Self-adjusting Heaps”, SIAM J. Comput. Vol 15, N° 1, Feb 1986.
CAPÍTULO 20 ........................................................................................................................................... 1
SKEW HEAPS. SELECCIONAR. ........................................................................................................... 1
20.1. MEZCLA DE DOS SKEW HEAPS. ......................................................................................................... 1
20.2. OPERACIÓN MEZCLA CON INTERCAMBIO. TOP-DOWN. .................................................................... 2
20.3. DEFINICIÓN DE TIPOS. ...................................................................................................................... 4
20.4. CREACIÓN DE NODO Y DE COLA. ...................................................................................................... 4
20.5. TEST DE COLA VACÍA. ...................................................................................................................... 4
20.6. INSERTAR. ENCOLAR. TOP-DOWN. ................................................................................................... 4
20.7. SELECCIONAR EL MÍNIMO. DESENCOLAR. TOP-DOWN. .................................................................... 5
20.8. FUNCIÓN MEZCLAR. TOP-DOWN...................................................................................................... 5
20.9. TEST DE LAS FUNCIONES. ................................................................................................................. 6
20.10. MEZCLA ASCENDENTE (BOTTOM-UP). ............................................................................................ 7
20.11. ANÁLISIS DE INSERCIÓN BOTTOM UP. ............................................................................................ 9
20.12. ANÁLISIS DE DESCARTAR EL MÍNIMO: BOTTOM UP....................................................................... 11
20.13. TIPOS Y OBTENCIÓN DE NUEVO NODO. ......................................................................................... 12
20.13. INSERTAR. BOTTOM-UP. .............................................................................................................. 13
20.14. DESCARTAR. BOTTOM-UP. ........................................................................................................... 13
20.15. TEST DE LAS FUNCIONES. ............................................................................................................. 15
EJERCICIOS. ............................................................................................................................................ 16
E20.1. ................................................................................................................................................ 16
REFERENCIAS. ........................................................................................................................................ 16
ÍNDICE GENERAL. ................................................................................................................................... 17
ÍNDICE DE FIGURAS................................................................................................................................. 17
Índice de figuras.
Capítulo 21
Una cola de prioridad binomial considera los datos almacenados en una foresta; ésta puede ser
definida como una colección de subárboles, en la que cada subárbol es un árbol binomial; y tal
que cada uno de ellos cumple la propiedad de que los hijos tienen claves o prioridades mayores
o iguales a las del padre. Esta estructura favorece la operación de mezclar dos colas de
prioridad, para formar una nueva.
Un árbol binomial Bp 1 , de orden p+1, es un multiárbol formado por la unión de dos árboles
binomiales de orden p, para p 0 . B0 está formado por un elemento. Entendemos por unión el
que la raíz de uno de los árboles binomiales B p es el hijo más izquierdista de la raíz del otro
árbol binomial B p , según se muestra en la Figura 21.1.
B0 Bp+1
Bp
Bp
B0 B1 B2 B3
Puede demostrarse por inducción que un árbol binomial B p tiene 2 p nodos; que la raíz tiene p
hijos; y que la máxima profundidad del árbol es p.
Es importante destacar que si n 2 p , entonces: p log 2 (n) . Las principales operaciones sobre
esta estructura dependen del número de hijos de la raíz.
p
Se tienen nodos con profundidad k en B p . Debido al coeficiente binomial, que figura en la
k
relación anterior, se da el nombre de binomiales a estos árboles.
p p
Para la raíz y el nodo más profundo de B p , se tienen: 1.
p 0
p p!
Los p hijos de la raíz de B p , se obtienen de: p.
1 1!( p 1)!
Como Bp está formado por la unión de dos árboles Bp-1, un nodo a profundidad k en Bp-1 aparece
en Bp una vez a profundidad k y una vez a profundidad k + 1. Entonces para obtener los nodos a
profundidad k en Bp debemos sumar los nodos a profundidad k en Bp-1, más los nodos a
profundidad k-1 en Bp-1.
k-1 k
p p 1 p 1
k k k 1
( p 1)! ( p 1)!
k !( p 1 k )! (k 1)!( p 1 (k 1))!
( p 1)! 1 k
( )
k ! ( p 1 k )! ( p k )!
Multiplicando y dividiendo el primer término por p-k, factorizando y aplicando la definición de
coeficiente binomial, se tiene:
( p 1)! p! p
(( p k ) k )
k !( p k )! k !( p k )! k
Una forma alternativa de definir Bp 1 , es agregar como hijos de la raíz a los (p+1) árboles
binomiales: Bp , Bp 1 , Bp 2 ,..., B2 , B1 , B0 . La Figura 21.5, muestra la generación de B3 .
B3
B2 B1 B0
Si se desea representar conjuntos de n elementos, tal que n no sea una potencia de dos, puede
considerarse la representación de n en el sistema binario, mediante la secuencia de (p+1) cifras
binarias, con el equivalente decimal:
i p
n bi 2i
i 0
Entonces una cola de prioridad de n elementos puede ser representada por una foresta Fn , donde
cada uno de los árboles binomiales que la forman, cumplen la propiedad de que los hijos tienen
prioridades mayores que la de su padre.
Observando la Figura 21.5, una cola de prioridad con n elementos también puede representarse
con la raíz como el nodo con prioridad mínima unido a una foresta Fn-1.
Dadas dos forestas: Fn y Fm la unión estará formada por (n+m) elementos en una foresta Fn m .
Es notable que la operación mezcla pueda realizarse en forma similar a una suma binaria. El
siguiente ejemplo describe el algoritmo:
Al sumar (unir, mezclar o fundir) dos árboles binomiales Bi se produce reserva, ésta es un árbol
binomial Bi 1 .
B3 B2 B0
+ B2 B0
B4 B1
Observando la Figura 21.2, puede notarse que la unión de dos árboles binomiales puede
realizarse en tiempo constante, basta escribir una referencia para el árbol descendiente. En el
peor caso un heap con n elementos está formado por log(n) árboles binomiales, entonces
obtener uno, mediante mezclas, demanda un costo O(log(n)).
La inserción puede realizarse como la mezcla de un árbol binomial con un elemento con la
estructura ya existente. También su costo es O(log(n)).
La Figura 21.6, muestra las prioridades almacenadas en los multiárboles binomiales: B3 , B2, B1 ,
B0.
1
1
2 3 5
1 2 3
4 6 7
1 2 4
8
Inicialmente se considera un nodo binario, que minimiza el tamaño de memoria ocupada por el
nodo.
La Figura 21.7, muestra una posible estructura que emplea los punteros derechos para
referenciar el hijo más derechista del nodo, y los punteros izquierdos para enlazar los hermanos
hacia la izquierda. Las listas de hermanos son circulares: Si no hay hermanos de un nodo, el
puntero izquierdo apunta al mismo nodo; el último nodo de la lista de hermanos apunta al
hermano más derechista. Las hojas tienen punteros derechos, o hacia abajo, nulos.
Se han ilustrado árboles binomiales, donde la raíz tiene lista izquierda vacía, sin embargo puede
acordarse unir los árboles binomiales de la foresta en la lista circular de la raíz.
1 3 2 5
1 2 3 4 6 7
1 2 4 8
Por ejemplo si a B1 en la Figura 21.7, se agrega un nodo con prioridad 3, el árbol binomial B0,
que lo representa, se coloca en la lista izquierda de la raíz, esto se muestra en la Figura 21.8.
Debe notarse que la operación es sencilla si el número de elementos en la foresta es par; ya que
esto implica agregar el árbol binomial B0, formado por un elemento.
3 1
A la derecha de la Figura 21.8, se muestra un foresta con 7 elementos, donde se ha enlazado B0,
formado por el nodo con valor 7, con B1 y B2. Esta inserción también es sencilla.
5 7 1
5 1
6 2 3 6 2 3
4 4
La inserción del elemento con valor 8, en el heap a la derecha de la Figura 21.9, debería formar
el árbol binomial B3, que se muestra a la derecha de la Figura 21.7. Esto se logra con la unión de
dos árboles B1, para formar un árbol B2; y luego la suma de dos árboles B2, para formar un árbol
B3.
B2 B1 B0
+ B0
B3
Si se almacena el grado del árbol binomial en el nodo, puede representarse la foresta como una
lista enlazada de las raíces de los árboles binomiales ordenadas por grado, ya que esto facilita la
operación unión de dos árboles binomiales de igual grado. Sin embargo el descarte del mínimo
requiere recorrer la lista completa, pero el largo de ésta está acotada; el largo, en el peor caso, es
el logaritmo del número de nodos almacenados en la cola de prioridad. Para representar los
multiárboles se emplea la organización hijo más izquierdista-hermano derecho.
1
3
1 5 3 2
2 2 1 0
3 2 7 6 4
1 0 1 0 0
4 8
0 0
La Figura 21.12, muestra una cola de prioridad con 13 nodos, mediante el enlace de tres árboles
binomiales: B0, B2 y B3. El enlace derecho de las raíces de los árboles binomiales se emplea
para formar una lista ordenada en forma descendente por grado; debe notarse que no quedan
ordenados por prioridad. El ejemplo muestra que el nodo con prioridad mínima puede estar en
cualquier posición de la lista; en la Figura 21.12, se ilustra al final de ésta.
9 10 1
0 2 3
11 12 5 3 2
1 0 2 1 0
14 7 6 4
0 1 0 0
8
0
Ordenando de este modo la lista, se facilita la operación de unir (o sumar) dos forestas. Equivale
a realizar las sumas binarias desde los dígitos menos significativos.
Si la foresta no contiene un árbol binomial B0, la inserción coloca el nuevo nodo al comienzo de
la lista de raíces. Si está presente B0, se une a éste el nodo que se desea insertar, formando un
árbol binomial B1; y debe seguirse efectuado uniones si pueden formarse árboles binomiales de
mayores grados, esto se realiza recorriendo la lista de raíces.
Si antes de la inserción, la foresta no puede tener dos árboles binomiales de igual grado,
entonces la lista debe recorrerse mientras el grado de la última unión sea igual al grado del
siguiente árbol binomial de la lista.
Se recorre la lista hasta encontrar el mínimo, y se liga la lista con el siguiente. Los hijos del
nodo mínimo forman otra lista de árboles binomiales; sin embargo, debido a la construcción,
está ordenada por grados en forma descendente. Entonces se debe reversar la lista de hijos del
nodo mínimo, dejándola ordenada en forma ascendente por grado.
Luego se procede a mezclar las dos listas ordenadas por grados, dejando adyacentes a los nodos
que tengan grados iguales. Luego debe procederse a unir los árboles de igual grado, y si es
posible, formar árboles de mayores grados; esta operación debe recorrer la lista completa.
Se considera un nodo binario más la información del grado. De ser necesario puede emplearse
un puntero al padre.
Enlaza árbol de grado (k-1) con raíz mayorp, con árbol de grado (k-1) con raíz menorp, por
nodo con menor prioridad. Ver Figura 1.
Donde menorp es el padre de mayorp, y además es la raíz de un nuevo árbol de grado k. Se
coloca como comentario, la mantención de punteros al padre.
5 3 2
2 1 0
7 6 4
1 0 0
8
0
La función emplea tres punteros para el análisis de los casos: un puntero al nodo previo, otro al
actual, y otro al siguiente. Al inicio el puntero previo apunta a nulo.
Debido a que los operadores lógicos operan con cortocircuitos, la realización de la segunda
parte de un or, sólo se efectúa si el primer operando es falso; ya que si el primero es verdadero
la condición será verdadera no importando el valor lógico de la segunda. Entonces, la detección
de tres adyacentes iguales puede codificarse, como el segundo término del or, según:
La formulación de la segunda parte del or, se plantea mediante un and lógico para asegurar la
existencia del tercer nodo; ya que si no existe el tercer nodo, preguntar por su grado produce un
error en ejecución.
El caso 1, se produce cuando los nodos raíces tienen grados diferentes, y se activa con la
primera parte del or. El caso 2, se presenta cuando existe suma de árboles binomiales de igual
grado con reserva.
Cuando debe efectuarse el enlace binomial de dos árboles de igual grado, se tienen dos casos
que se disciernen según la prioridad de los nodos raíces. Son los casos 3 y 4. Se emplea el
puntero previo para enlazar las listas.
Una pequeña refinación de la operación, se logra aislando dos situaciones triviales. La primera
es que la cola esté vacía; la otra es que el número de nodos en la foresta sea par, en este caso no
hay al principio de la lista un árbol binomial de un nodo, o de grado cero, y basta insertar al
inicio. Puede especializarse aún más la operación, ya que en este caso no pueden presentarse
tres árboles de igual grado, y tampoco es preciso recorrer toda la lista.
void enqueue( pnodo n, queue *q )
{ pnodo p=*q;
if (p==NULL) *q=n;
else if (p->grado!=0) {n->hermano=*q; *q=n;}
else
*q= BinomialHeapUnion(p, n);
} /* enqueue */
21.15. Descartar.
La operación desencolar retorna el nodo con valor mínimo de prioridad, y deja una foresta
disminuida en un elemento.
Para esto, luego de extraer de la lista el árbol binomial cuya raíz es el nodo con prioridad
mínima, deja ligada la lista con los árboles binomiales siguientes al que contiene el mínimo.
Posteriormente reversa el orden de la lista de hijos del mínimo, dejándola ordenada ascendente.
Finalmente realiza la suma de las dos forestas.
//reversar lista descendente (ordenada por grado) de hijos del mínimo, apuntada por x.
ListaHijosMinimo=ReversarLista(min->hijo);
//Queda lista ordenada ascendente por grado, apuntada por x.
//Se mezcla lista de árboles sin el mínimo, con la lista de hijos del mínimo
*q=BinomialHeapUnion(*q, ListaHijosMinimo);
return (min);
} /* dequeue */
Se emplean variables locales que apuntan al nodo actual de la lista p, y al nodo mínimo min.
Además para facilitar el ligado de la lista se emplean punteros al padre de p (pp) y al padre de
min (pm). Se enlaza lista sin el árbol cuya raíz es el mínimo
while (p!=NULL)
{ if (min->prioridad > p->prioridad) { min=p; pm=pp;}
pp=p; p=p->hermano;
}
if (pm==NULL) *q=min->hermano; //cambia la raíz. Si el mínimo era el primero.
else pm->hermano=min->hermano; //liga resto de la lista
return (min);
}
El código puede simplificarse usando un nodo de encabezado, lo que permite tratar al primer
nodo dentro del lazo while. Se coloca el máximo valor de prioridad en el nodo centinela, de
acuerdo al tipo del campo prioridad; estos valores se encuentran en <limits.h>.
Reversar lista descendente, ordenada por grado, de hijos del mínimo, apuntada por x, dejándola
ordenada en forma ascendente. La operación contempla el caso en que la estructura emplee
punteros al padre, dejando como comentarios las mantenciones de esos punteros.
Se emplean los punteros y y t, para recorrer la lista. Donde y es el siguiente de x, y t el siguiente
de y.
La siguiente función retorna un puntero al nodo con valor de prioridad mínima, pero no lo
extrae de la foresta.
/*retorna puntero al nodo con prioridad mínima en un heap binomial con n elementos*/
pnodo BinomialHeapMinumun(queue *q)
{
pnodo pmin =NULL; //puntero al mínimo, aún no se conoce
pnodo p =*q;
int min =INT_MAX;
while (p!=NULL)
{ if (p->prioridad < min) { min = p->prioridad; pmin = p;}
p =p->hermano;
}
return pmin;
}
//lista raíz-grado
int prtbinomial(queue *q)
{
pnodo p=*q, t;
if (p!=NULL) printf("%d-%d ", p->prioridad, p->grado);
else {printf("Binomial nulo\n"); return(0);}
putchar('\n');
return(1);
}
queue binomial=NULL;
#define N 30
int main (void)
{ pnodo t;
int i;
initqueue( &binomial);
srand(1);
for(i=1;i<=N;i++)
{ enqueue2(getnodo(rand()%100), &binomial);
//prtbinomial(&binomial);
}
putchar('\n');
for(i=1;i<=N;i++)
{ t= dequeue(&binomial);
//prtbinomial(&binomial);
printf(" %d ", t->prioridad);
free(t);
}
putchar('\n');
return(0);
}
Referencias.
J. Vuillemin, “A Data Structure for Manipulating Priority Queues”, CACM Vol. 21, No. 4,
April 1978, 309-315.
M. R. Brown. PhD thesis, “The Analysis of a Practical and Nearly Optimal Priority Queue”,
Stanford University, March 1977.
Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein. “Introduction
to Algorithms”, Second Edition. MIT Press and McGraw-Hill, 2001.
Índice general.
CAPÍTULO 21 ........................................................................................................................................... 1
HEAPS BINOMIALES. SELECCIONAR. ............................................................................................. 1
21.1. ÁRBOL BINOMIAL. ........................................................................................................................... 1
21.2. COLA DE PRIORIDAD BINOMIAL. ...................................................................................................... 3
21.3. ANÁLISIS DE LA OPERACIÓN UNIÓN. ................................................................................................ 4
21.4. ESTRUCTURA DE DATOS................................................................................................................... 5
21.4.1. Nodo binario. .......................................................................................................................... 5
21.4.2. Nodo binario aumentado con información del grado. ............................................................ 7
21.4.3. Nodo binario con punteros al padre. ....................................................................................... 8
21.5. ANÁLISIS DE LA INSERCIÓN. ............................................................................................................ 8
21.6. ANÁLISIS DE LA EXTRACCIÓN DEL MÍNIMO. ..................................................................................... 9
21.7. TIPOS DE DATOS............................................................................................................................... 9
21.8. CREACIÓN DE NODO......................................................................................................................... 9
21.9. INICIO DE COLA. ............................................................................................................................... 9
21.10. TEST DE COLA VACÍA. .................................................................................................................. 10
21.11. UNIÓN DE DOS ÁRBOLES BINOMIALES DE IGUAL GRADO. ............................................................. 10
21.12. MEZCLA DOS LISTAS ORDENADAS POR CAMPO GRADO. ............................................................... 10
21.13. MEZCLA DOS COLAS BINOMIALES. ............................................................................................... 11
21.14. INSERTAR..................................................................................................................................... 13
21.15. DESCARTAR. ................................................................................................................................ 13
21.16. EXTRACCIÓN DEL MÍNIMO. .......................................................................................................... 14
21.17. REVERSAR LISTA. ........................................................................................................................ 14
21.18. BUSCA MÍNIMO. ........................................................................................................................... 15
21.19. LISTA DE RAÍCES DE ÁRBOLES BINOMIALES. ................................................................................ 15
21.20. TEST DE LAS FUNCIONES. ............................................................................................................. 16
REFERENCIAS. ........................................................................................................................................ 16
ÍNDICE GENERAL. ................................................................................................................................... 17
ÍNDICE DE FIGURAS................................................................................................................................. 17
Índice de figuras.
Capítulo 22
El algoritmo de aparear heaps, considera los datos en un multiárbol en el que cada subárbol
cumple la propiedad de que los hijos tienen claves o prioridades mayores o iguales a las del
padre.
6 4 7 5
Es un algoritmo de selección que puede agregar ítems en forma dinámica, a diferencia del heap
implícito, árbol binario embebido en un arreglo, que utiliza memoria estática.
22.1. Propiedades.
La operación de insertar se realiza en tiempo constante agregando el nuevo ítem como la raíz
del árbol, o agregándolo como un nuevo hijo de la raíz.
A partir del heap de la Figura 22.1, se muestra en la Figura 22.2, la inserción de un elemento
con prioridad 1, menor que la de la raíz; y a la derecha, la inserción de ítem con prioridad 3,
mayor que la de la raíz.
Si los hijos de la raíz se consideran enlazados en una lista, puede escogerse insertar el nuevo
elemento al inicio de la lista, lo que da características de auto organizado o autoajustable, de tal
modo que los elementos más recientemente insertados están primero.
2 2
6 4 7 5 6 4 7 5 3
Para seleccionar la nueva raíz el algoritmo consiste en formar una lista de pares de heaps, donde
cada par se construye enlazando dos subheaps, cada uno de estos enlaces tiene costo O(1).
4 5 3
6 7
Si se enlaza nuevamente de izquierda a derecha, primero se unen los subárboles con raíces 4 y
5, quedando como raíz el nodo con prioridad 4; luego se enlaza este subheap, con el cuya raíz es
3, resultando la Figura 22.4.
5 6
La operación descarte al mismo tiempo que encuentra la nueva raíz, reorganiza el heap. Puede
notarse que se acortan las listas de los hijos de los nodos. Los autores del algoritmo demuestran
empleando técnicas de análisis amortizado de la complejidad, que la operación descartar tiene
costo O(log(n)) amortizado.
En un heap de n nodos, en peor caso la raíz tiene (n-1) hijos, entonces formar los pares de heaps
y luego enlazarlos será en peor caso de complejidad O(n). Sin embargo si lo que interesa es el
tiempo total de ejecución, y no la realización de una sola operación, una buena medida del costo
Una manera muy simplificada de verlo es considerar que la operación descarte es más compleja,
pero reorganiza el árbol, implicando que otras operaciones sucesivas de descarte o inserción
serán de menor costo.
La inserción de un nodo, con menor prioridad que la raíz, coloca a éste como la nueva raíz; y la
raíz antigua como descendiente del nuevo nodo. Si la prioridad del nuevo nodo es mayor que la
de raíz, se inserta al inicio de la lista de hijos de la raíz.
La raíz tiene puntero izquierdo nulo, ya que no tiene hermanos; y un nodo que se inserta como
hijo, tiene puntero hacia abajo nulo.
6 4 7 5 3
8 10 12 9 6
6 4 7 5 3
8 10 12 9 6
Es notable destacar que en la concepción de este algoritmo se emplean listas para representar
multiárboles que representan colas de prioridad. Mostrando las posibilidades de las estructuras
básicas, que se exponen en este curso, para construir nuevas estructuras más complejas.
Se recorren las listas de hijos, tomando dos adyacentes; se mezclan y se va formando una lista
con los menores elementos de cada par. Suponemos una lista ya formada apuntada por lp, y dos
heaps adyacentes: ha y hb, como se muestra en la Figura .7.
hb ha
lp
b a
db da dl
Si ha y hb son heaps, debe cumplirse que da, los descendientes de a, son mayores que a; y
db>b. La Figura 22.8, muestra que lp es un heap, ya que cumple la propiedad de que los hijos
tienen claves mayores o iguales a las del padre.
a b
dl dl
b a
da db
db da
Si el número de hijos de la raíz es impar, el último heap se coloca al inicio de la lista de pares.
Para el caso de la Figura 22.6., se procesa primero el par 3-5, quedando al fondo de la lista, en
este caso se ejecuta el if. Luego se agrega el par 7-4, ejecutándose el else; finalmente se agrega
al inicio de la lista el heap con raíz 6.
lp
3 4 6
5 12 7 8 10
9 6
Figura 22.9. Lista de pares para lista de hijos de la raíz de la Figura .6.
Debe notarse que en la lista izquierda de lp, los elementos no quedan ordenados. Sin embargo
de una lista hijos de cinco elementos, ahora se tiene una lista de los menores de los pares
formada por tres elementos. También debe observarse que la nueva raíz puede ser cualquier
nodo de la lista lp, y por lo tanto deberá recorrerse ésta hasta el final.
hb ha
b a
db da
b a
da db
db da
Para la Figura 22.9, se ejecuta primero el if, y resulta la Figura 22.12, a la izquierda. Luego se
ejecuta el else y se obtiene el heap, que se muestra a la derecha, en la Figura 22.12; el inicio de
la lista apunta a la nueva raíz. Si bien las listas de hijos no quedan ordenadas, disminuye el largo
de éstas.
ha
3
ha
3 4 5 4
5 12 7 6 9 6 12 7 6
9 6 8 10 8 10
22.9. Insertar.
Al centro se muestra la situación de las variables luego de ejecutadas las acciones realizadas en
el if, cuando a<b. Donde a toma el valor de (*q)->prioridad; y b es la prioridad del nuevo nodo
que será insertado: n->prioridad. A la derecha luego de las acciones del else.
q q
q
*q *q
cola *q a a n
a n
a b
a b n
b a
da da da
22.10. Desencolar.
queue pairingheap=NULL;
#define N 30
int main (void)
{ pnodo t;
int i;
//ascendente
for(i=1; i<=N; i++)
{enqueue(getnodo(i), &pairingheap);
//prtpair(pairingheap);
}
putchar('\n');
srand(1);
for(i=1; i<=N; i++)
{enqueue(getnodo(rand()%100), &pairingheap); putchar('\n');
//prtpair(pairingheap);
}
for(i=1; i<=N; i++)
{ //prtpair(pairingheap);
t= dequeue(&pairingheap);
printf(" %d ", t->prioridad);
free(t);
}
putchar('\n');
return(0);
}
Referencias.
Fredman, Sedgewick, Sleator and Tarjan, "Pairing Heaps: A New Form of Self-Adjusting
Heaps", Algorithmica, (1986), vol. 1, 111-129).
Índice general.
CAPÍTULO 22 ........................................................................................................................................... 1
PAIRING HEAPS. SELECCIONAR. ...................................................................................................... 1
22.1. PROPIEDADES. ................................................................................................................................. 1
22.1. ESTRUCTURA DE DATOS................................................................................................................... 3
22.3. OPERACIÓN PARA FORMAR PARES ADYACENTES DE HEAPS.............................................................. 4
22.4. SELECCIÓN DEL MÍNIMO Y FORMACIÓN DEL NUEVO HEAP. .............................................................. 5
22.5. TIPOS DE DATOS............................................................................................................................... 6
22.6. CREACIÓN DE NODO......................................................................................................................... 7
22.7. INICIO DE COLA................................................................................................................................ 7
22.8. TEST DE COLA VACÍA. ...................................................................................................................... 7
22.9. INSERTAR. ....................................................................................................................................... 7
22.10. DESENCOLAR. ................................................................................................................................ 8
22.11. TEST DE LAS FUNCIONES. ............................................................................................................... 9
REFERENCIAS. ........................................................................................................................................ 10
ÍNDICE GENERAL. ................................................................................................................................... 11
ÍNDICE DE FIGURAS................................................................................................................................. 11
Índice de figuras.
Capítulo 23
Algoritmos numéricos.
Si se aplica método nodal con modificaciones, para tratar fuentes de voltajes controladas e
independientes, se obtiene un sistema de ecuaciones, del tipo:
A x b
Existen dos esquemas generales para resolver sistemas lineales de ecuaciones: Métodos de
eliminación directa y Métodos Iterativos. Los métodos directos, están basados en la técnica de
eliminación de Gauss, que mediante la aplicación sistemática de operaciones sobre los
renglones transforma el problema original de ecuaciones en uno más simple de resolver.
A L U
Donde L es una matriz triangular inferior (lower), y U es una matriz triangular superior (upper).
L U x b
L d b
U x d
Los dos sistemas anteriores son sencillos de resolver, como se verá más adelante. El sistema con
matriz L, puede ser resuelto por substituciones hacia adelante; el sistema con matriz U se
resuelve por substituciones hacia atrás.
Existen varias formas de efectuar la descomposición, el método de Doolittle asigna unos a los
elementos de la diagonal principal de L.
Veremos a través de un ejemplo, las principales ideas, intentando obtener un algoritmo para el
cálculo.
Una vez conocido u11, la primera columna de A permite determinar el primer renglón de L, se
obtienen:
Si bien se ha desarrollado para una matriz de 4x4, de las expresiones obtenidas puede inducirse
relaciones generales, como veremos a continuación:
De la relación:
L d b
Se obtiene:
l11 0 0 0 d1 b1
l21 l22 0 0 d2 b2
l31 l32 l33 0 d3 b3
l41 l42 l43 l44 d4 b4
l11d1 b1
l21d1 l22 d 2 b2
l31d1 l32 d 2 l33 d 3 b3
l41d1 l42 d 2 l43 d 3 l44 d 4 b4
Una vez obtenido d1, se substituye en la expresión siguiente para calcular d2; con d1 y d2, se
puede calcular d3; y así sucesivamente. Por esta razón, al procedimiento se lo denomina
substitución hacia adelante (forward).
El vector d, puede recalcularse para diferentes valores del vector b, que es la situación que se
produce en un barrido DC. Debido a que en el método de Gauss se ocupa, desde el inicio de las
operaciones, los valores de b; el efectuar cálculos con b variable lo realiza con ventajas el
método de descomposición triangular.
La relación anterior, permite deducir una expresión para calcular los d i, en una matriz de orden
N.
i l
di (bi lij d j ) / lii
j 1
Para: i 1, 2, ,N
En la descomposición de Doolittle, los lii son unos.
El algoritmo para la substitución hacia atrás se obtiene de manera similar a las anteriores.
Para la triangular superior:
U x d
Se tiene:
u11 u12 u13 u14 x1 d1
0 u22 u23 u24 x2 d2
0 0 u33 u34 x3 d3
0 0 0 u44 x4 d4
Que entrega la solución del sistema de ecuaciones. Nótese que primero se obtiene x4; y luego x3,
que se calcula en términos de x4; y así sucesivamente. Por esta razón a este algoritmo se lo
denomina substitución hacia atrás (back).
En general:
xN d N / u NN
N
di uij x j
j i 1
xi
uii
Ejemplo de uso.
//Resuelve el sistema de ecuaciones lineales a·X = b.
ludcmp(a, n);
lufwbksb(a, n, b);
Para deducir expresiones generales que permitan escribir algoritmos iterativos, consideremos el
sistema lineal de tres ecuaciones:
a11 a12 a13 x1 b1
a21 a22 a23 x2 b2
a31 a32 a33 x3 b3
Si consideramos conocidos los valores de las variables del lado derecho, podremos estimar un
nuevo valor para las variables del lado izquierdo de las ecuaciones. Podemos anotar lo anterior,
mediante:
| xi [n 1] xi [n] | tolerancia
Si el error es menor que la exactitud requerida el proceso termina; en caso contrario se realiza
una nueva iteración.
j i 1 j N
xi [n 1] (bi aij x j [n] aij x j [n]) / aii
j 1 j i 1
Si el cálculo de las variables se realiza en orden, desde x1 hasta xN , puede observarse que una
vez obtenido x1 puede usarse este valor para calcular x2 ; y así sucesivamente. Entonces en el
cálculo xi se pueden emplear los nuevos valores de las variables desde x1 hasta xi 1 .
Mejores resultados se logran calculando las variables en orden decreciente de los valores de la
diagonal principal.
xi [n 1] axi [n 1] (1 a ) xi [n]
Con: 0 a 2
Si a es 1, se tiene la fórmula de Gauss Seidel. Con a>1, el nuevo valor, en la iteración (n+1),
tiene mayor importancia. Con a<1, se tiene subrelajación. La elección de este valor, y su
influencia en la convergencia debería aclararse en un curso de análisis numérico.
j i 1 j N
xi [n 1] (1 a) xi [n] a (bi aij x j [n 1] aij x j [n]) / aii
j 1 j i 1
En lugar de emplear errores absolutos: (fabs(y[j]-x[j]) < tol), es preferible utilizar errores
relativos: (fabs(y[j]-x[j]) < tol*(fabs(x[j]) );
Una mejor aproximación se logra sumando trapecios, si se desea mayor precisión se emplea
aproximación por segmentos parabólicos, con la regla de Simpson. Para disminuir la
acumulación de errores se emplea el método de Runge-Kutta.
La formulación de las ecuaciones de una red eléctrica en términos de las variables de estado
permite encontrar la solución de un sistema de ecuaciones diferenciales de primer orden en el
dominio del tiempo. La solución numérica, que veremos a continuación, puede extenderse a
sistemas no lineales.
dx
Ax Bu
dt
El resto de las variables del sistema puede expresarse en términos del estado, según:
y Cx Du
dx(t ) 1 dx 2 (t ) 2
x(t t) x(t ) t t ....
dt 2 dt 2
dxi (tk )
xi [k 1] xi [k ] t
dt
Este procedimiento iterativo se denomina esquema simple de Euler.
Para: i 1, 2, ,n
La siguiente función calcula en la matriz x, los valores de las variables de estado en npuntos
separados en intervalos de tiempo Delta.
No se considera la matriz b, ni el vector u de excitaciones. Esto equivale a resolver un sistema
de ecuaciones diferenciales lineales homogéneas y de primer orden.
void euler(float **a, int N, float **x, float *ic, int npuntos, float Delta)
/*Dados a[1..N][1..N], ic[1..N] calcula x[1..N][1..npuntos]*/
{ int i, j, k;
float sum, t=0.;
for(i=1; i<=N; i++) x[i][1]=ic[i]; //condiciones iniciales.
for (k=1; k<npuntos; k++)
{ t= t+Delta;
for(i=1; i<=N; i++)
{
sum=0; for (j=1; j<=N; j++) sum += a[i][j]*x[j][k];
x[i][k+1]= x[i][k]+sum*Delta;
}
}
}
Una alternativa de diseño es generar un archivo de datos en lugar de almacenar los puntos en
una matriz. Con el archivo de datos se pueden generar formas de ondas.
void genseq(float **a, int N, float **x, int npuntos, float Delta, int i)
/*Dados a[1..N][1..N], ic[1..N] y x[1..N][1..npuntos]
genera seq compatible para gráficos de tipo pointplot en Maple.*/
{ int k;
float t=0.;
printf("Seq:=[");
for (k=1; k<=npuntos; k++, t=t+Delta)
{ printf("[%g,%g]\n", t, x[i][k]);
if (k<npuntos) putchar(',');
}
putchar(']'); putchar('\n');
Ejemplo de uso:
n=2;npuntos=20;
a=matrix(1, n, 1, n); //pide espacio
a[1][1]=0.; a[1][2]=1.;
a[2][1]=-3.; a[2][2]=-2.;
ic=vector(1, n);
ic[1]=1.;ic[2]=0.;
x=matrix(1, n, 1, npuntos); //pide espacio
genseq(a,n,x,npuntos,0.1,1);
yn 1 yn hf (tn , y (tn ))
Si definimos:
k1 hf (tn , yn )
Se realiza la integración mediante:
yn 1 yn k1
Se tiene que f (tn , yn ) es la derivada de la función y(t ) , y k1 es el área del rectángulo bajo la
curva de f (t , y) , y también el incremento de la ordenada. Las relaciones se muestran en la
Figura 1, para la variable y(t ) .
n
k1
yn f(tn,yn)
tn tn+1 tn tn+1
h h
Una mejor aproximación para el área bajo la curva de f (t , y) es mediante el trapecio entre las
paralelas t n y tn 1 .
yn+1 f(tn+1,yn+1)
(k1 + k2)/2
n
yn f(tn,yn)
tn tn+1 tn tn+1
h h
Entonces:
h
yn 1 yn ( f (tn , yn ) f (tn 1 , yn 1 ))
2
Con:
k1 hf (tn , yn )
k2 hf (tn 1 , yn 1 )
Para refinar el cálculo del área, se define un punto dentro del intervalo, de este modo puede
calcularse el área bajo una parábola que pasa por los tres puntos:
tn tn+1 tn tn+1
h h
f a (t ) at 2 bt c
Se tienen:
f0 at0 2 bt0 c
f1 at12 bt1 c
f2 at2 2 bt2 c
t t2
A f a (t )dt
t t0
1
yn 1 yn (k0 4k1 k2 )
6
y(tn h / 2) yn k0 h / 2
y(tn h) yn k0 h
1 1
Area (k0 4k1 k2 ) (k2 4k3 k4 )
6 6
La cual puede simplificarse a:
1
Area (k0 4k1 2k2 4k3 k4 )
6
Con 2n+1 puntos en total, se tiene en general:
h
Area ( f 0 4 f1 2 f 2 4 f3 ...... 2 f 2 n 2 4 f2n 1 f2n )
6
Existen métodos más elaborados para efectuar un paso de integración, En éstos la función se
evalúa en varias etapas entre dos puntos. Los puntos de las etapas sólo se emplean para el
cálculo del nuevo valor.
Consisten en definir una función en la cual se escogen los parámetros de tal modo de minimizar
los errores.
k1 f yn , t n
k2 f yn hk1 , tn h
yn 1 yn h ak1 bk2
Con:
y tn , tn af y (tn ), tn bf y (tn ) hf y (tn ), tn , tn h
Tn y tn h y tn h y tn , tn
Como se tiene:
d2y d f f dy f f
f y t ,t f ft fy f
dt 2 dt t y dt t y
d3y d f f d f d f f df
f ( ) ( )f
dt 3 dt t y dt t dt y y dt
d3y 2
f f 2 2
f 2
f f f f
f f f f
dt 3 t 2
t y y t y 2
y t y
d3y
ftt f yt f ( f yt f yy f ) f f y ( ft fy f )
dt 3
h2 h3
y tn h y tn hf ( ft fy f ) ( ftt 2 fty f f yy f 2 f y ft f y 2 f ) O (h 4 )
2! 3!
Donde se ha empleado: f f y tn , tn
Para el segundo término de Tn, se requiere expandir el término que está multiplicado por b. Para
esto se emplea la expansión de Taylor de dos variables de f yn y, tn t , con:
y hf , t h . Como está multiplicado por h, sólo es necesario considerar hasta los
términos de segundo orden.
Entonces;
f y y, t t f y, t
f ( y, t ) f ( y, t ) 1 f 2 ( y, t ) 2 f 2 ( y, t ) 1 f 2 ( y, t ) 2
y t y y t t
y t 2 y2 y t 2 t2
h h2
Tn h f ft ff y f tt 2 f ty f f yy f 2 f y ft fy f
2 3!
2
f yy f ftt 2 2
h af b f ( fy f f t )h h2 f yt fh 2 h O (h 4 )
2 2
Los parámetros: a, b, , , se escogen del tal modo de minimizar el error. En este caso pueden
hacerse cero los coeficientes de h y h 2 , pero no es posible eliminar la parte que depende de h3
.
De la primera se obtiene: a b 1
1 1
De la segunda, debe cumplirse: ft ( b ) ff y (
b ) 0 , de la que se desprenden:
2 2
1
b b
2
Con a 1, b 0 se tiene el método de Euler. No pueden ajustarse , . Por esta razón puede
decirse que el algoritmo de Euler pertenece a la familia Runge-Kutta de segundo orden.
yn 1 yn hk2
Con:
k1 f yn , t n
k1h h
k2 f yn , tn
2 2
La pendiente de y está dada ahora por el valor de f en el punto medio del intervalo.
yn+1 f(tn+h/2,yn+k1h/2)
f(tn+1,yn+1)
n
hk2
yn f(tn,yn)
tn tn+1 tn tn+1
h h
En los algoritmos del método de Runge-Kutta de segundo orden, debe evaluarse dos veces la
función: f y (t ), t , para obtener el siguiente punto de la función.
Para derivar el algoritmo de cuarto orden de Runge Kutta, se definen los 10 parámetros:
a1 , b1 , a2 , b2 , a3 , b3 , w1 , w2 , w3 , w4 . Los cuales deben elegirse de tal modo de eliminar los errores
proporcionales hasta la cuarta potencia del intervalo temporal h.
k1 hf yn , tn
k2 hf yn b1k1 , tn a1h
k3 hf yn b2 k2 , tn a2 h
k4 hf yn b3k3 , tn a3h
yn 1 yn w1k1 w2 k2 w3k3 w4 k4
Con:
y tn , tn w1 f yn , tn w2 f yn b1k1 , tn a1h
w3 f yn b2 k2 , tn a2 h w4 f yn b3k3 , tn a3h
Antes se obtuvieron las tres primeras derivadas de y. Se requiere calcular la cuarta derivada de
y, se tiene:
d4y d
( ftt 2 fty f f yy f 2 f y ft f y2 f )
dt 4 dt
Derivando, se obtiene:
d4y dy dy d2y dy dy
(( f yyy f yyt ) f yy 2 f yyt f ytt )
dt 4 dt dt dt dt dt
2 3
dy d y d y dy dy d2y dy
2( f yy f yt ) 2 f y 3 ( f yyt f ytt ) f yt 2 f ytt f ttt
dt dt dt dt dt dt dt
d4y d2y
(( f yyy f f yyt ) f f yy f yyt f f ytt ) f
dt 4 dt 2
d2y d3y d2y
2( f yy f f yt ) fy ( f yyt f f ytt ) f f yt f ytt f fttt
dt 2 dt 3 dt 2
d4y
(( f yyy f f yyt ) f f yy ( ft fy f ) f yyt f f ytt ) f
dt 4
d3y
2( f yy f f yt )( ft fy f ) fy ( f yyt f f ytt ) f f yt ( f t fy f ) f ytt f f ttt
dt 3
d4y
(( f yyy f f yyt ) f f yy ( f t fy f ) f yyt f f ytt ) f
dt 4
2( f yy f f yt )( f t f y f ) f y ( f tt f ty f ( f yt f yy f ) f
f y ( ft f y f )) ( f yyt f f ytt ) f f yt ( f t fy f ) f ytt f f ttt
Donde se ha empleado: f f y tn , tn
h y tn , tn w1k1 w2 k2 w3k3 w4 k4
Entonces;
f yn y, tn t f yn , t n
1 1
fy y ft t f yy y 2 f yt y t f tt t 2
2 2
1 1 1 1
f yyy y 3 f yyt y 2 t f ytt y t2 f ttt t 3
6 2 2 6
1 3 3 1 2 2 1 1 3
b1 f fyyy b1 f fyyt a1 b1 f a1 2 fytt a1 fttt h 4
6 2 2 6
k2 = 1 1
fyy b1 2 f 2 b1 f fyt a1 ftt a1 2 h 3 ( fy b1 f ft a1 ) h 2 hf
2 2
k3 =
fyy b1 2 f 2 ftt a1 2
fy b2 b1 f fyt a1 fyy b2 2 f ( fy b1 f ft a1 )
2 2
b2 2 f 2 fyyt a2 b2 f a2 2 fytt b2 3 f 3 fyyy a2 3 fttt
b2 ( fy b1 f ft a1 ) fyt a2
2 2 6 6
2 2 2
ftt a2 fyy b2 f
h4 fy b2 ( fy b1 f ft a1 ) b2 f fyt a2 h 3
2 2
( fy b2 f ft a2 ) h 2 hf
h y tn , tn
b1 3 f 3 fyyy b1 2 f 2 fyyt a1 b1 f a1 2 fytt a1 3 fttt a3 3 fttt
w2 w4
6 2 2 6 6
b3 3 f 3 fyyy b3 f a3 2 fytt
fyy b3 2 f ( fy b2 f ft a2 )
6 2
ftt a2 2 fyy b2 2 f 2
fy b3 fy b2 ( fy b1 f ft a1 ) b2 f fyt a2
2 2
b3 2 f 2 fyyt a3
b3 ( fy b2 f ft a2 ) fyt a3 w3
2
fyy b1 2 f 2 ftt a1 2
fy b2 b1 f fyt a1 fyy b2 2 f ( fy b1 f ft a1 )
2 2
b2 2 f 2 fyyt a2 b2 f a2 2 fytt b2 3 f 3 fyyy a2 3 fttt
b2 ( fy b1 f ft a1 ) fyt a2
2 2 6 6
Deducir los valores de los parámetros para eliminar términos en la expresión para el error de la
aproximación resulta complejo.
La generación del primer término del error, puede generarse automáticamente con:
> restart;
> y1:=taylor(y(t),t=tn,5):y1:=subs({t-tn=h},y1):
Efectuando la comparación de los coeficientes del error, de tal forma de anular hasta la potencia
cuarta de h, se obtienen 19 ecuaciones:
e1 := w1 w2 w3 w4 1
w2 b1 2 w4 b3 2 w3 b2 2 1
e4 :=
2 2 2 6
1
e5 := w4 b3 a3 w2 b1 a1 w3 b2 a2
3
1
e6 := w4 b3 b2 w3 b2 b1
6
1
e7 := w4 b3 a2 w3 b2 a1
6
w2 b1 3 w4 b3 3 w3 b2 3 1
ec9 :=
6 6 6 24
2 2 2
w2 b1 a1 w4 b3 a3 w3 b2 a2 1
ec10 :=
2 2 2 8
1 1 1
ec11 := w4 b3 2 b2 b3 b2 2 b2 b1 2 b2 2 b1
w3
2 2 6
1
ec12 := w4 b3 2 a2 w3 b2 2 a1
8
2 2
w2 b1 a1 w4 b3 a3 w3 b2 a2 2 1
ec13 :=
2 2 2 8
5
ec14 := w4 ( b3 b2 a2 b3 b2 a3 ) w3 ( b2 b1 a1 b2 b1 a2 )
24
1
ec15 := w4 b3 a2 a3 w3 b2 a1 a2
8
1
ec16 := w4 b3 b2 b1
24
1
ec17 := w4 b3 b2 a1
24
w4 b3 a2 2 w3 b2 a1 2 1
ec18 :=
2 2 24
3 3 3
w2 a1 w4 a3 w3 a2 1
ec19 :=
6 6 6 24
Se tienen 19 ecuaciones para 10 incógnitas, sin embargo considerando que:
Las ecuaciones 12 y 15 implican, con a1 = b1, a2 = b2 que a3 = b3. Por lo tanto no se requieren
las ecuaciones 12 y 15.
Las ecuaciones 9, 10, 13 y 19, con a1 = b1, a2 = b2 y a3 = b3 son idénticas. Sólo se requiere
considerar una de ellas.
Las ecuaciones 12 y 11 implican la ecuación 18. Por lo tanto no se requiere emplear la ecuación
11.
Las ecuaciones 12 y 14 implican la ecuación 18. Por lo tanto no se requiere emplear la ecuación
14.
Lo anterior reduce el sistema a 10 ecuaciones en 10 incógnitas (a1, a2, a3, b1, b2, b3, w1, w2,
w3, w4).
> ecc:={e1,e2,e3,e6,e7,ec16,ec17,e4,ec9,ec18}:
> sol:=[solve(ecc)]:sol[1];
1 1 1 1 1 1 1 1
{ b1 , a2 , w1 , a3 1, b3 1, a1 , w3 , w2 , b2 , w4 }
2 2 6 2 3 3 2 6
Puede comprobarse que la solución satisface las 19 ecuaciones, y que el error queda:
Tn O(h5 )
k1 hf yn , tn
k1 h
k2 hf yn , tn
2 2
k2 h
k3 hf yn , tn
2 2
k4 hf yn k3 , t n h
1
yn 1 yn k1 2k2 2k3 k4
6
Existen numerosos algoritmos en los que se varía el intervalo de tiempo entre puntos.
df
f ( xk 1 ) f ( xk ) ( xk )( xk 1 xk )
dx
f ( xk )
xk 1 xk
df
( xk )
dx
xs 0
x2 x1 x0
df f ( x0 )
tg ( 0 ) ( x0 )
dx x0 x1
Nótese que f ( x1 ) no es cero, lo cual implica que x1 es una aproximación de xs . También debe
notarse que para calcular la siguiente aproximación deben calcularse la función y la derivada en
el punto anterior.
Donde el valor de tolerancia debe ser un valor lo suficientemente pequeño, para que la solución
se considere aceptable. Con números reales de precisión simple (float en C), un valor razonable
de tolerancia es 10-6, que es el valor del número real más pequeño representable, en el formato
interno normalizado IEEE754.
Si el valor inicial es adecuado conviene limitar el número máximo de iteraciones, de este modo
si no existe convergencia se asegura que el algoritmo termine.
También puede verificarse que la ordenada en los puntos sucesivos esté dentro de cierto rango:
f ( xk 1 ) tolerancia
Se emplea un intervalo [x1..x2] en el cual se busca la raíz, para evitar la no convergencia debido
a máximos o mínimos dentro del intervalo. Si en una iteración se encuentra un punto con
derivada casi horizontal en el intervalo, el valor para el nuevo x se aleja de la raíz, como se
ilustra en la Figura 23.7. Para prevenir esta situación se verifica que la nueva aproximación de la
raíz permanezca dentro del intervalo.
Se detienen las iteraciones si los dos últimos valores obtenidos difieren en determinada
tolerancia.
if ((x<x1) || (x>x2))
{printf("Salta fuera del intervalo");
exit(1);
}
if (fabs(dx) < tolerancia) return (x); //Converge.
}
printf("No converge en %d iteraciones.\n", nmax);
exit(1);
return (0.0); //Para evitar warning.
}
Figura 23.8.
F ( x) F ( xs ) J ( xs )( x xs )
F ( xk 1 ) F ( xk ) J ( xk )( xk 1 xk )
Una explicación del cambio de la función de dos variables, puede efectuarse considerando el
plano tangente a la superficie, en el punto (x10, x20) que pasa también por el punto (x11, x21).
Donde el punto 0 es el inicial, y el punto 1, se obtiene pasando un plano tangente a la superficie
en el punto 0.
x10
F1x1
x11 F1x2
x1
x21 x2
x20
F1 ( x10 , x 20 ) F1x1
tg ( x1 )
x1 x10 x11
F1 ( x10 , x 20 ) F1x 2
tg ( x2 )
x2 x 20 x 21
El cambio total de la función, resulta:
Aplicando el método de Newton-Raphson, que consiste en asumir que el plano tangente pasa
por el punto que es una aproximación a la solución. Esto equivale a efectuar:
F1 ( x1k 1 , x 2k 1 )
0
F2 ( x1k 1 , x 2k 1 )
F1 ( x1k , x 2 k ) F1 ( x1k , x 2 k )
x1 x2 x1k 1
x1k F1 ( x1k , x 2 k )
F2 ( x1k , x 2 k ) F2 ( x1k , x 2 k ) x 2k 1
x 2k F2 ( x1k , x 2 k )
x1 x2
La que expresada en términos de vectores y la matriz inversa del Jacobiano, resulta en general,
para n variables:
xk 1 xk J ( xk ) 1 F ( xk )
Una mejor visualización de la suma de los incrementos, se logra observando los triángulos
semejantes en la Figura 23.10.
Por el punto inicial (2, 2, 10) se pasa el plano z=2x+3y que también pasa por el punto (0, 0, 0).
Se han dibujado además los planos de z constante, z=4 y z=6.
z z z z
2, 3 x 4, y 6
x y x y
F1 (k ) F2 (k )
( F2 (k ) F1 (k ))
x1k x1k x2 x2
1
F1 (k ) F2 (k ) F1 (k ) F2 (k )
x1 x2 x2 x1
F2 (k ) F1 (k )
( F1 (k ) F2 (k ))
x 2k x 2k x2 x1
1
F1 (k ) F2 (k ) F1 (k ) F2 (k )
x1 x2 x2 x1
J ( xk )( xk 1 xk ) F ( xk )
Referencias.
Numerical Recipes In C: The Art of Scientific Computing. Cambridge University Press. 1992.
CAPÍTULO 23 ............................................................................................................................................1
ALGORITMOS NUMÉRICOS.................................................................................................................1
23.1. SOLUCIÓN DE SISTEMA SIMULTÁNEO DE ECUACIONES LINEALES. .....................................................1
23.1.1. Descomposición LU. ................................................................................................................1
23.1.2. Métodos iterativos. ...................................................................................................................7
23.2. SOLUCIÓN NUMÉRICA DE SISTEMAS DE ECUACIONES DIFERENCIALES. ...........................................10
23.2.1. Formulación de ecuaciones de estado. ..................................................................................11
23.2.2. Método de Euler. ....................................................................................................................11
23.2.3. Algoritmo de Euler. ................................................................................................................13
23.2.4. Algoritmo trapezoidal. ...........................................................................................................14
23.2.5. Algoritmo de Simpson. ...........................................................................................................15
23.4.6. Métodos multietapas. .............................................................................................................17
23.4.6.1. Método de Heun. ............................................................................................................................. 20
23.4.6.2. Método del punto medio. ................................................................................................................ 20
23.4.6.3. Método de Ralston. ......................................................................................................................... 20
23.4.7. Métodos de Runge-Kutta de cuarto orden. ............................................................................21
23.3. SOLUCIÓN DE ECUACIÓN NO LINEAL. ..............................................................................................28
23.3.1. Método de Newton-Raphson. .................................................................................................29
23.3.2. Generalización para sistemas de ecuaciones no lineales. .....................................................32
REFERENCIAS. .........................................................................................................................................35
ÍNDICE GENERAL. ....................................................................................................................................36
ÍNDICE DE FIGURAS. ................................................................................................................................37
Índice de figuras.
Capítulo 24
Es considerado un algoritmo clásico, que permite obtener a partir de una serie de valores
temporales las componentes espectrales en frecuencia, y viceversa con un costo O(n log n).
Como veremos es un caso particular del problema más general de calcular polinomios.
Existen dos formas de cálculo: una es evaluar un polinomio representado por sus coeficientes.
La otra es representar el polinomio por una serie de puntos.
Que es de complejidad O(n), ya que requiere (n-1) multiplicaciones. La siguiente rutina ilustra
el procedimiento de cálculo.
Las operaciones de suma y resta de polinomios expresados por sus coeficientes son de
complejidad O(n), ya que basta sumar o restar los coeficientes.
Sin embargo el producto de polinomios es de complejidad O(n2).
2n 2
C ( x) cjx j
j 0
c0 = a0b0
c1 = a0b1 + a1b0
c2 = a0b2 + a1b1 + a2b0
…
j
cj ak b j k
k 0
Operación que se denomina convolución de los coeficientes.
La evaluación de los coeficientes de C es O(n), y debe repetirse n veces, lo cual implica O(n2),
para el costo de la multiplicación de polinomios.
x
x0 x1 x2 xn-1
Si se conocen los puntos: (xk, yk ), se tienen n ecuaciones con n incógnitas (los coeficientes).
.....
yn 1 a0 xn0 1 a1 x1n 1 a2 xn2 1 .... an 1 xnn 11
Pueden existir diferentes representaciones por puntos, ya que sólo se requiere que los n puntos
xk sean diferentes. Como se verá la elección adecuada de los n puntos permitirá acelerar el
cálculo a O(n log n).
Esta representación puede obtenerse a partir de la representación por coeficientes con un costo
O(n2), ya que debe aplicarse n veces el algoritmo de Horner.
det ( xk x j ) ( x1 x0 )( x2 x1 )...( xn 1 xn 2 )
0 j k n 1
El costo de evaluar la matriz inversa es O(n2) y además se debe realizar n multiplicaciones del
vector de los yk por los renglones de la matriz inversa, con lo cual resulta O(n3).
Sin embargo los coeficientes pueden calcularse con costo O(n2) empleando la fórmula de
interpolación de polinomios de Lagrange:
Que es un polinomio de n coeficientes que pasa por los n puntos (xk, yk).
O bien tal que A(xk) = yk para k=0..n-1, lo cual puede comprobarse en la fórmula anterior.
La ventaja de representación por puntos es que el cálculo del producto (también la suma y resta)
de polinomios es ahora O(n). Pero en caso de multiplicación, los polinomios deben
representarse con 2n puntos, ya que C, debe ser de 2n puntos.
Se tienen las transformadas de Fourier, directa e inversa, en el dominio de f y t, para la señal s(t)
y su densidad espectral S(f), definidas según:
j 2 ft
( s (t )) S( f ) s (t )e dt
1
( S ( f )) s (t ) S ( f )e j 2 ft df
n i 0
Con k=0..n-1
Si se define: wn=e-j2 /n
como la n-ava raíz compleja de la unidad, se tiene que:
e-j2 k/n
= (wn)k
Reemplazando S(k) por Sk, s(k) por sk, y (wn)k por x se tienen dos polinomios.
n 1 1 x0 x02 ... x0n 1
s0 S0
Sk si x i
i 0 1 x1 x12 ... x1n 1
s1 S1
1n 1
i
sk Si x
ni 0
1 xn 1 xn2 1... xnn 11 sn 1 Sn 1
Los polinomios se evalúan en las raíces complejas de la unidad. Si se reemplazan las potencias
de x se obtiene la relación que permite obtener el espectro a partir de las muestras temporales:
1 wn( n 1)*1
wn( n 1)*2
... wn( n 1)*( n 1)
sn 1 Sn 1
t f
t0 t1 t2 tn-1 f0 f1 f2 fn-1
Que permite obtener las muestras temporales a partir del espectro. Notamos que el problema es
similar al anterior, pero cambiando el signo de las n-avas potencias complejas de la unidad, y un
escalamiento dividiendo por n.
Conocidas las muestras temporales, se requieren n multiplicaciones para obtener una muestra
del espectro, y como se requieren n muestras en frecuencia, se obtiene un algoritmo de costo
cuadrático. Además deben calcularse las potencias complejas de las n-avas raíces de la unidad.
S p ( x) s0 s2 x ... sn 2 x ( n / 2) 1
Si ( x ) s1 s3 x ... sn 1 x ( n / 2) 1
Sk S ( x) S p ( x 2 ) xSi ( x 2 )
Los polinomios son de n/2 puntos cada uno. Esto considerando n par y más específicamente una
potencia de dos.
Más adelante veremos cómo los Sk pueden calcularse recursivamente, para obtener esa relación
es preciso conocer algunas propiedades de las raíces complejas de la unidad.
Para n = 2, se tienen
w20=e-j 0 = cos(0) + j sen(0) = +1
w21=e-j 1 = cos(- ) + j sen(- ) = -1
w21 w20
Para n = 4, se tienen:
(w4) = e-j /2 = -j
w40 = +1 w43
w41 = -j
w42 w40
w42 = -1
w43= +j
Debe notarse que w44 = w40
w41
Algunas propiedades:
n 1 n 1
( wn kr / n)wnkc ( wn k ( c r)
/ n) la cual toma valor 1 para r=c, y cero en caso contrario. Lo
k 0 k 0
cual origina una matriz unitaria, comprobándose que la expresión para la inversa es correcta. Se
requiere que (c-r) no sea divisible por n, esto se cumple ya que fuera de la diagonal:
Se tiene para n = 2:
S0 = s0 + x0s1
S1 = s0 + x1s1
Resultando:
S0 = s0 + s1
S1 = s0 - s1
Para n = 4:
Evaluando en las raíces cuartas complejas de la unidad, notando que todos los valores no son
diferentes, debido a las propiedades de las raíces cuartas complejas de la unidad, se obtienen:
S0 w40 s0 w40 s1 w40 s2 w40 s3
S1 w40 s0 w14 s1 w42 s2 w43 s3
S2 w40 s0 w42 s1 w44 s2 w46 s3
S3 w40 s0 w43 s1 w46 s2 w49 s3
Finalmente, se advierte que para cuatro puntos, están incorporados los polinomios asociados a
dos puntos. Donde se ha definido: (S0p, S1p) y (S0i, S1i) como series de dos puntos.
S0 ( s0 s2 ) ( s1 s3 ) S0 p w40 S0i
S2 ( s0 s2 ) ( s1 s3 ) S0 p w40 S0i
S1 ( s0 s2 ) j ( s1 s3 ) S1 p w14 S1i
S3 ( s0 s2 ) j ( s1 s3 ) S1 p w14 S1i
Sk S p ( x 2 ) xSi ( x 2 )
Sk S p ( wn2 k ) wnk Si ( wn2 k )
Sk n/2 S p ( wn2 k n ) wnk n/2
Si ( wn2 k n )
Donde se han definido los valores de la parte par e impar como una evaluación de un polinomio
de n/2 puntos.
S kp S p ( wnk / 2 )
S ki Si ( wnk / 2 )
Finalmente se obtienen:
Sk n/2 S kp wnk S ki
Es usual calcular una sola vez, la expresión común dentro del for.
El costo de calcular una potencia puede extraerse, a través de una multiplicación dentro del lazo:
wn = e-j2 /n ;
w = 1;
for (k=0; k<= (n/2)-1; k++)
{ t = w*Ski ;
Sk = Skp + t ; //Operación denominada mariposa. Butterfly.
Sk+n/2 = Skp - t ;
w * = wn ;
}
La codificación puede modificarse para tratar las variables complejas, por sus partes reales e
imaginarias.
Para un polinomio descrito por 8 puntos temporales. El cálculo puede visualizarse mediante la
descomposición en dos polinomios de 4 puntos, con los puntos pares en uno, y los impares en
otro. Un polinomio de 4 puntos, se puede calcular mediante la composición de dos polinomios
de dos puntos, donde nuevamente se separan los ubicados en posiciones pares e impares. No es
necesario descomponer polinomios de dos puntos, ya que su evaluación es sencilla. El siguiente
árbol ilustra la separación entre partes pares e impares.
s0 s1 s2 s3 s4 s5 s6 s7
s0 s2 s4 s6 s1 s3 s5 s7
s0 s4 s2 s6 s1 s5 s3 s7
Si cada vez se suprime el bit menos significativo que representa al índice en binario, se tendrá
que la división se efectúa entre pares e impares. Por ejemplo el grupo (0, 2, 4, 6) se transforma
al suprimir el bit menos significativo en (0, 1, 2, 3).
Si se observa la siguiente tabla, donde se han colocado los valores de las muestras temporales en
un arreglo, junto a sus índices en binario y el arreglo modificado. Puede notarse que los índices
en binario del arreglo modificado son las imágenes especulares de los índices originales. Puede
también notarse que la última columna se puede visualizar como un contador binario inverso, es
decir los dígitos que primero cambian son los más significativos. En este caso sólo es preciso
intercambiar s1 con s4, y s3 con s6.
Para 16 puntos, colocando los valores de los contadores binarios directo e inverso, en decimal,
puede notarse que bastaría generar las dos secuencias de valores siguientes:
Y sólo intercambiar cuando j> i. Esto evita los cambios en los casos identificados en el tercer
renglón con ig (por igualdad), y los cambios marcados con x (esto volvería a su lugar a los
elementos del arreglo). Además no es preciso revisar los casos con i= 0 e i=15.
Si el número de muestras es una potencia de dos, se tiene que el primer valor de j, de la
secuencia que debe generarse, será: j=n/2 o la expresión equivalente: j=n>>1.
“Se cambian todos los unos más significativos por ceros, hasta encontrar el primer cero, el
cual se setea a uno”.
Si k=8 y j = 12 ( 1100 en binario) se efectúa la resta (queda j=4) y la condición de reinicio deja
k=4 (0100), se vuelve a iterar, quedando j=0 y k=2. Lo cual termina la iteración. Después del for
se setea en uno el bit que marca k, y deja j=4 (0010).
El siguiente ejemplo, busca el primer cero y lo cambia por uno, poniendo en cero el más
significativo.
“Se detectan los unos de i, y se los copia, mediante la variable m, en la posición especular en
j”.
n=2
for (k=0; k<= (n/2)-1; k++) s0 s1
{ Sk = Skp + wnk Ski ;
Sk+n/2 = Skp - wnk Ski ;
} s0+s1 s0-s1
La composición de la serie de 4 puntos a partir de las series de dos puntos, se obtiene con:
S0 S0 p w40 S0i ( s0 s2 ) ( s1 s3 )
S2 S0 p w40 S0i ( s0 s2 ) ( s1 s3 )
S1 S1 p w14 S1i ( s0 s2 ) j ( s1 s3 )
S3 S1 p w14 S1i ( s0 s2 ) j ( s1 s3 )
Con: w40 = 1, w41 = - j = (w21)1/2 w4 = w2
Para una serie de cuatro puntos temporales, se procede al reordenamiento. A éstos se los trata
como series de dos puntos. Se aplica la operación mariposa, con n=2, a todos los pares:
s0 s2 s1 s3
Luego de la operación en los pares:
(0,1)(2,3) s0+s2 s0 -s2 s1 +s3 s1 -s3
Quedan las series en frecuencia de dos puntos
S0p S1p S0i S1i
Se aplica ahora la operación mariposa con n=4. A los pares (0,2) y (1,3).
Lo cual genera los cuatro puntos en frecuencia.
Es decir:
S0 S0 p w80 S0i
S4 S0 p w80 S0 i
S1 S1 p w81 S1i
S5 S1 p w81 S1i
S2 S2 p w82 S 2i
S6 S2 p w82 S 2i
S3 S3 p w83 S3i
S7 S3 p w83 S3i
1
Con: w80 =1, w81 = e-j /4
= (1 j ) w8 = w4
2
En el nivel 0: Se forman series de dos puntos, aplicando la operación mariposa con n=2, a los
pares: (0, 1), (0+2*1, 1+2*1), (0+2*2, 1+2*2), (0+2*3, 1+2*3)
En el nivel uno: Se forman series de 4 puntos. La composición de dos series de 2 puntos se logra
aplicando la operación con n=4, a los pares (0,2) (1,3) y también a los pares (0+4, 2+4) (1+4,
3+4) logrando dos series de cuatro puntos.
En el nivel dos: La composición de dos series de 4 puntos se logra aplicando la operación con
n=8. A los pares (0,4) (1,5) (2,6) (3, 7)
Dos series de 4 puntos S0p S1p S2p S3p S0i S1i S2i S3i
Serie de 8 puntos
S0 S1 S2 S3 S4 S5 S6 S7
Veamos ahora el caso general:
Si n es el número de puntos.
En el nivel 0, se tienen n/2 series de 2 puntos.
En el nivel 1, se tienen n/4 series de 4 puntos.
En el nivel 2, se tienen n/8 series de 8 puntos.
En el nivel j, se tienen n/2j+1 series de 2j+1 puntos.
En último nivel, el m-1, la única serie es de n= 2m puntos.
El algoritmo iterativo debe generar las series asociadas al nivel, y los pares de puntos de cada
serie y luego aplicarles la operación mariposa a cada par.
Es decir, a través de la variable j, se repite para cada grupo formado por nn puntos:
Falta aún una última iteración para procesar cada uno de los niveles.
El siguiente segmento genera los pares a los que debe aplicárseles la operación.
void mariposa(int m)
{ int n, k, j, nn, nivel;
n = 1;
for (k=0; k<m; k++) n *= 2; //n=2^m genera número de puntos
nn=1;
for (nivel=0; nivel<m; nivel++)
{ nn=2*nn; //series de nn puntos. Con nn=2^(nivel+1)
for(j=0; j<n; j=j+nn) //Para todos los n elementos en grupos de nn
{
for (k=0; k< nn/2; k++)//Se aplica mariposa a los pares (k+j, k+j+nn/2)
{ printf("(%d,%d)", k+j, k+j+nn/2); }
putchar('\n');
}
}
}
La siguiente función resume las principales ideas. Sólo falta definir el tratamiento de los
números complejos y de los arreglos.
for (k=0, n=1; k<m; k++) n *= 2; //n=2^m genera n, el número de puntos a partir de m
nn=1;
wn = w2 ; //Al inicio las series son de dos puntos
for (nivel=0; nivel<m; nivel++)
{ nn=2*nn; //series de nn puntos. Con nn=2^(nivel+1)
for(j=0; j<n; j=j+nn) //Para todos los n elementos en grupos de nn
{
w = 1;
for (k=0; k< nn/2; k++) //Se aplica mariposa a los pares (k+j, k+j+nn/2)
{
t = w*Sk+j+nn/2 ; //Con los valores de series de nn/2 puntos
u = Sk+j;
Sk+j = u + t ; //Se calculan con la operación mariposa, los
Sk+j+nn/2 = u - t ; //valores de las series de nn puntos.
w * = wn ;
}
}
wn = wn ; //Se duplica el número de puntos.
}
}
Al inicio, en el nivel cero, las series son de dos puntos; al subir de nivel debe calcularse la nueva
raíz compleja. Se ilustra, en el primer cuadrante la nueva raíz, que tiene la mitad del ángulo.
wn
w2n
n
n /2
Para calcular la raíz compleja principal asociada a una serie de 2n puntos a partir de la raíz
compleja principal asociada a una serie de n puntos, pueden efectuarse las siguientes
definiciones:
wn xn jyn
w2 n x2 n jy2 n
Del diagrama, aplicando la identidad trigonométrica para la tangente del ángulo medio, se
obtiene una relación entre las coordenadas cartesianas de las raíces; la segunda relación se
cumple debido a que el módulo de las raíces es de magnitud unitaria.
sen
tg ( / 2)
1 cos
y2 n yn
x2 n 1 xn
x2 n 2 y2 n 2 1
El código completo de la función, con: s[i]=x[i] + jy[i], wn=wn1 +jwn2, w=w1 +jw2, además
se definen una serie de variables locales, para intercambios y para extraer constantes dentro de
los lazos. Se eliminan los llamados a función, para disminuir el costo de la creación de frames
en el stack. Se agrega el argumento dir, para obtener la transformada directa e inversa con la
misma función.
/* Calcula FFT */
nn=1;
//wn = w2 = -1+j0; En el primer nivel son series de dos puntos
wn1 = -1.0;
wn2 = 0.0;
Para una serie de 16 valores constantes en el tiempo se obtiene un espectro de un solo punto en
el origen.
Para 16 muestras de la señal temporal: cos(2 n t/16), con n=0..15 se tiene la gráfica de la
señal, en incrementos de t. Su FFT, graficada como magnitudes de los valores complejos,
está asociada dos puntos diferentes de cero; uno en f=1 f, y otro en f=15 f.
Donde f = 1/ t.
Para 16 muestras de la señal temporal: cos(2 n t/8), con n=0..15 se tiene la gráfica de la señal,
en incrementos de t. Su FFT está asociada dos puntos diferentes de cero; uno en f=2 f, y otro
en f=14 f. Donde f = 1/ t. Se dice que es la segunda armónica.
El punto espectral en 14 es una frecuencia espejo relativa a la frecuencia de Nyquist.
Se toman ahora 8 muestras por período.
La trasformada rápida muestra el espectro, donde figura la tercera armónica, y su espejo en 13.
Nótese que las muestras temporales difícilmente permiten visualizar la figura continua. Se
toman 16/7 muestras por período.
Al tomar muestras con la frecuencia de Nyquist, se toman dos muestras por período de la octava
armónica, que tiene período 2 t, ya que la fundamental tiene período 16 t.
Frecuencia de la fundamental = 1/16 t = f/16 = ff.
Frecuencia de la octava armónica es = f/2 = 8 ff .
Período de muestreo = t, frecuencia de muestreo =1/ t = f.
Frecuencia de Nyquist = f/2 = 8 ff .
Las muestras de la octava armónica, dos por período, se ilustran junto a una gráfica continua.
Para la novena armónica, cuya frecuencia es mayor que la de Nyquist se obtiene un espectro
idéntico al de la séptima armónica. Lo cual no permite reconstruir la señal temporal a través de
su espectro.
Los casos anteriores daban un espectro de tonos puros. Sin embargo una frecuencia levemente
diferente hace aparecer varias armónicas. Veamos por ejemplo una señal de 1,2 veces la
frecuencia fundamental (cos(2 n t*1,2/16) )
Para una señal con frecuencia 1,5 veces la de primera armónica, aparecen componentes
espectrales en toda la banda (de 0 a 8), siendo más importantes las contribuciones de la primera
y segunda.
Figura 24.19. Espectro para con frecuencia 1,5 veces la de primera armónica.
Figura
24.20. Mezcla de frecuencias múltiples de la de muestreo.
j 2 ft
( s (t )) S( f ) s (t )e dt
1
( S ( f )) s (t ) S ( f )e j 2 ft df
t
t0=0 t1=1 t t2=2 t tn-1
t=intervalo de muestreo
n t=Período fundamental
i n 1
j 2 fi t
S( f ) s (i t )e t
i 0
s (k t ) S ( f )e j 2 fk t
df
Para aproximar la segunda integral, se discretiza la variable f, en los puntos i f. Esto también
considera cero el espectro fuera del intervalo n f. Con i=0..n-1 y k=0..n-1.
i n 1
j 2 ki f t
S (k f ) s(i t )e t
i 0
i n 1
s(k t ) S (i f )e j 2 ki f t
f
i 0
S (k f )
S (k )
t
s(k ) s(k t )
1
f Silva Bijit
Profesor Leopoldo 26-05-2008
n t
Transformada rápida de Fourier 27
i n 1
j 2 ki / n
S (k ) s(i )e
i 0
1i n 1
s(k ) S ( k )e j 2 ki / n
n i0
Referencias.
CAPÍTULO 24 ............................................................................................................................................1
TRANSFORMADA RÁPIDA DE FOURIER. .........................................................................................1
24.1. REPRESENTACIÓN POR N COEFICIENTES. ...........................................................................................1
24.2. REPRESENTACIÓN POR N VALORES. ..................................................................................................2
24.3. TRANSFORMADA DISCRETA DE FOURIER. .........................................................................................3
24.4. DESARROLLO DEL ALGORITMO DE TRANSFORMADA RÁPIDA DE FOURIER. .......................................5
24.5. N-AVAS RAÍCES COMPLEJAS DE LA UNIDAD. .....................................................................................6
24.6. SERIES DISCRETAS PARA DOS Y CUATRO PUNTOS. ............................................................................7
24.7. RELACIONES DE RECURRENCIA. .......................................................................................................8
24.8. REORDENAMIENTO DE LOS PUNTOS. ...............................................................................................10
24.9. OPERACIÓN MARIPOSA. ..................................................................................................................12
24.10. INTERPRETACIÓN DE LA FFT. .......................................................................................................19
24.10.1. Corriente continua. ..............................................................................................................19
24.10.2 Primera armónica. ................................................................................................................19
24.10.3. Segunda armónica................................................................................................................20
24.10.4. Tercera armónica. ................................................................................................................21
24.10.5. Séptima armónica. ...............................................................................................................21
24.10.6 Octava armónica. ..................................................................................................................22
24.10.7 Frecuencias no múltiplos enteros de la frecuencia de muestreo. ..........................................23
24.11. DERIVACIÓN DE LA FFT A PARTIR DE LA TRANSFORMADA DE FOURIER. ......................................25
REFERENCIAS. .........................................................................................................................................27
ÍNDICE GENERAL. ....................................................................................................................................28
ÍNDICE DE FIGURAS. ................................................................................................................................29
Índice de figuras.
Capítulo 25.
Una lista de saltos (skip list) representa una lista ordenada simplemente enlazada, en la cual
cada nodo contiene un número variable de enlaces con otros nodos de la estructura. El n-avo
enlace de un nodo apunta a los nodos de n-enlaces siguiente de la lista, saltando los nodos
intermedios de menos de n enlaces. Como se muestra en la Figura 25.1.
6
10 15
8 3 3 31
3 12 23 43
3
Puede estudiarse como una colección de listas enlazadas en diferentes niveles. Un recorrido por
la lista de menor nivel, el cero, pasa por todas las claves de la lista.
La estructura tiene además un nodo con el máximo número de niveles que sirve de encabezado,
y un solo nodo centinela, en el que se almacena un valor mayor que el máximo valor de clave
que puede almacenarse en la estructura. A ese nodo apuntan los últimos enlaces de cada lista.
La operación de búsqueda parte por la lista de mayor nivel, que tenga nodos con valores; hasta
que se encuentre el nodo con el valor o un nodo con una clave menor que el buscado. En este
último caso, en el nodo con clave menor se repite el procedimiento anterior, pero en la lista de
un nivel menor.
Por ejemplo para buscar el nodo con clave 12, se parte en la lista de nivel 3, se revisa el nodo
con clave 6 y se continúa con el siguiente, pero éste es el centinela que tiene clave mayor que
12. Entonces se desciende un nivel y se recorre ahora la lista del segundo nivel a partir del nodo
con clave 6. Se avanza al 10, saltándose el 8; pero como la clave 15 es mayor que la buscada, se
vuelve a descender un nivel y se repite la búsqueda a partir de la lista del primer nivel que
comienza en 10. Es preciso volver a descender un nivel y repetir la búsqueda, encontrándose
finalmente el 12. Si se hubiera buscado el valor 13, el proceso es similar al anterior, pero la
búsqueda falla, ya que luego del 12 se encuentra un nodo con clave mayor que el buscado.
La Figura 25.2, muestra el mismo conjunto de valores almacenados en la Figura 25.1, pero
ahora cada vez que se ubica un nodo con clave menor que el buscado, se descartan (o saltan) de
12
8 3 23
3 6 10 15 31 43
3
En lugar de intentar generar una lista de saltos balanceada como la de la Figura 25.2, cada vez
que se inserta un nodo se le asigna su nivel de forma aleatoria. Por esto se dice que en un
algoritmo aleatorizado, ya que independiente del valor de las claves de entrada, y mediante un
generador de números aleatorios, se escoge el nivel del nodo que será insertado.
Si se desea incrementar el nivel cada vez que se tenga probabilidad 3/4, se determina el número
binario formado por los dos últimos bits del número aleatorio, si es diferente de cero se
incrementa el nivel, y se repite hasta que el número sea cero. Se tienen los casos: 00, 01, 10 y
11; en tres de los cuatro casos el número será diferente de cero.
Si la probabilidad de aumentar los niveles es alta, aumenta el número de niveles de las listas,
con el consecuente aumento del número de punteros; además aumenta el tiempo de la búsqueda
ya que debe descenderse varios niveles hasta encontrar la clave en el nivel cero. El tener varias
listas aumenta el número de claves que se saltan y disminuye la búsqueda horizontal, en el
mismo nivel, de una de las listas.
Si la probabilidad de aumentar los niveles es baja, el número de niveles será bajo, lo que
disminuye el espacio ocupado por los punteros, y se desciende más rápido al nivel cero; pero se
alargan los recorridos horizontales, en el mismo nivel, hasta encontrar la clave. Si no se forman
niveles, resulta una lista simplemente enlazada, con costo O(n) para las operaciones.
Puede demostrarse que las operaciones de búsqueda y actualización tienen costo esperado
logarítmico respecto al número de nodos almacenados en la estructura, y que la probabilidad de
encontrar un peor caso, por ejemplo todos los nodos del mismo nivel, como puede calcularse, es
muy baja.
Más sorprendente aún es que puede demostrarse que el número esperado de punteros por nodo
no excede 1,3 punteros por clave, lo cual es un espacio menor de almacenamiento que el
requerido para almacenar la estructura en un árbol binario. Además no se requiere almacenar
información adicional de balance o color.
El autor del algoritmo plantea que el diseño de las listas de salto es más sencillo de codificar que
los empleados en algoritmos para árboles binarios balanceados.
Existen desarrollos teóricos más avanzados que justifican las bondades del algoritmo, pero son
más difíciles de seguir con el nivel de conocimientos que se tienen cuando se cursa una
asignatura básica de estructuras de datos. En forma experimental los treaps tienen mejor
comportamiento que las listas de salto, y emplean un número bastante menor de comparaciones
de claves.
25.1. Complejidad.
Si cada uno de los n elementos está presente en un conjunto con probabilidad p, entonces el
tamaño esperado del conjunto es np. Entonces ni , el tamaño esperado de la lista de nivel i es:
ni npi .
n n
Puede plantearse entonces: 1/ p i y despejando i, se obtiene: i log1/ p ( ) .
ni ni
Si se asume un peor caso, la lista de mayor nivel tiene un solo elemento y si la probabilidad p es
1/2, se tiene que el mayor nivel de la lista de saltos, queda dado por:
imax log 2 ( n) .
El largo esperado de las listas, que es una medida del tamaño dedicado al almacenamiento de la
lista de saltos de h niveles, está dado por:
i h
n
L npi (1 p h 1 )
i 0 1 p
Con h=6: Para p=0,5 se tiene L=1,98. Para p=0,25 se tiene L=1,33. Para p=0,75 se tiene L=3,5.
Podemos calcular el número esperado de punteros por nodo, sumando los largos de las listas
ponderados con la probabilidad de que se encuentren presentes:
1 i h
1 p 2( h 1)
P (np i ) p i
n i 0 1 p2
Con h=6: Para p=0,5 se tiene P=1,33. Para p=0,25 se tiene P=1,06. Para p=0,75 se tiene P=2,24.
La Figura 25.5, muestra una situación de búsqueda, a partir del segundo nivel del nodo con
clave 6. Se tiene dos situaciones, una es seguir en el nivel, por ejemplo si se busca el 12; la otra
es descender de nivel, por ejemplo si se busca el 8.
Sea C(k) el costo de buscar en el nivel k. Sea C(0)=0, el costo cuando se está apuntado a la clave
buscada en el nivel cero, y estudiemos la ruta inversa a la de búsqueda.
6
10 15
8 3 3
12
Si consideramos que p es la probabilidad que baje de nivel y siga buscando en el nivel (k-1).
Entonces (1-p) es que permanezca buscando en el nivel k. Hemos asumido p, ya que para subir
el nivel de un nodo se lo efectúa con esa probabilidad. Si consideramos costo unitario el
comparar las claves, puede plantearse la recurrencia:
La que requiere una condición inicial C(0) para ser resuelta. Se tienen:
C(1) C(0) 1/ p 1/ p
C(2) C(1) 1/ p 2/ p
Lo que permite obtener:
C (k ) k / p
log1/ p (n / ni )
C (i )
p
Si empleamos para ni 1 para el máximo nivel, con p = 1/2, se tiene:
Resultado que muestra que el costo esperado de las búsquedas, que comienzan en el mayor
nivel, tendrán costo logarítmico.
Las demostraciones de que estos resultados se cumplen con “alta probabilidad” son complejos.
Los siguientes comandos Maple generan las gráficas de los crecimientos de las funciones en una
lista de saltos, comparadas con las de un árbol generado aleatoriamente (treaps) y un árbol
AVL.
> Cmax:=(ln(n/nm)/ln(1/p))/p;
> plot([1.386*ln(n)/ln(2),subs({p=1/2,nm=1}, Cmax),
1.44*ln(n)/ln(2)],n=10..1000,color=[red,black,blue],thickness=2);
aleatorio
Mediante análisis experimental se tienen los siguientes datos, que muestran que la generación de
muchos niveles, aumenta el espacio de almacenamiento, disminuyendo las comparaciones. Si
son muy pocos niveles, disminuye el número de punteros por nodo, pero aumentan las
comparaciones. El caso en que se aumenta el nivel de un nodo en 3 de 4 casos, denominado 2 en
la tabla, produce el menor tiempo de ejecución, y para éste se diseñará la función aleatoria que
fija los niveles de los nodos al insertarlos.
25.2.1. Inserción.
Si se desea insertar el nodo con clave 13, en la lista de saltos de la Figura 25.1, primero se ubica
la posición en que debe ser insertado. La Figura 25.7 muestra los cambios luego de la inserción,
cuando el generador aleatorio produjo un nivel superior al mayor existente.
b
a
6 13
10 15
8 3 3 31
3 12 23 43
3
Figura 25.7. Inserción de nodo con clave 13 en la lista de saltos de la Figura 25.1.
Si el nivel generado para el nuevo nodo es mayor que el máximo nivel actual de la lista de
saltos, se decide incrementar un nivel, y es preciso registrar en el arreglo auxiliar la dirección
del puntero denominado b, en la Figura 25.7. Y luego se procede de igual forma al caso anterior.
25.2.2. Descarte.
Un ensayo de Bernoulli es un experimento que solamente puede tener dos resultados: un éxito,
el cual ocurre con probabilidad p, o un fallo, el que ocurre con probabilidad (1-p). Los ensayos
deben ser mutuamente independientes, de tal modo que cada uno de ellos tenga la misma
probabilidad p de éxito.
Cuando interesa determinar cuántos ensayos ocurren antes de tener un éxito, puede definirse una
variable aleatoria X, que sea el número de ensayos necesarios para obtener un éxito. X puede
tomar valores desde 1 hacia delante, y sea k un número mayor o igual a uno, entonces la
probabilidad de que X sea igual a k, tiene la siguiente distribución discreta:
Pr[ X k ] (1 p) k 1 p
La Figura 25.8, ilustra cómo disminuye la probabilidad de crear nodos con niveles iguales a k,
en una lista de saltos. Cuando k=1, se introduce el nodo en el nivel 0; con k=2 el nodo es
asignado al nivel 1; y así sucesivamente.
1/2
1/4
1/8
El valor esperado cuando h tiende a infinito es 1/p. Es decir, en promedio se requieren 1/p
ensayos antes de obtener un éxito.
El valor esperado teniendo un nivel máximo h, se obtiene con la suma:
k h
1 (1 ph)(1 p) h
E[ X ] k (1 p ) k 1 p
k 1 p
Con h=16: Para p=1/2, se requieren 1,999 intentos en promedio para un cambio de nivel; si
p=1/4 se requieren 3,8 intentos para cambiar de nivel.
El macro newNodeOfLevel(k) crea un nodo, con espacio para la clave y el valor más el de un
arreglo con un número variable de punteros. Para esto considera el tamaño del nodo, que ya
contiene un arreglo de punteros con un solo nodo, y le suma el espacio de los k punteros a nodo
restantes.
#define false 0
#define true 1
typedef char boolean;
La estructura siguiente agrupa el nivel de la lista con un puntero al header de la lista de saltos.
Variables globales.
int mrandom()
{ return( (int) rand()); } //se usa el generador aleatorio de la biblioteca.
void init(void)
{
NIL = newNodeOfLevel(0);
NIL->clave = INT_MAX; //0x7fffffff; //para enteros de 32 bits
NIL->valor = 666; //cualquier valor. Se inicia pero no se usa.
randomBits = mrandom(); //inicia variables del generador.
randomsLeft = BitsInRandom/2;
srand(1);
}
list newList(void)
{ list sl;
int i;
sl = (list)malloc(sizeof(struct listStructure));
sl->nivel = 0;
return(sl);
}
25.6. Listador.
if (cnt>0)
{ for(acum=0, k=l->nivel;k>=0;k--)
int randomLevel(void)
{register int nivel = 0;
register int b;
do { b = randomBits&Mascara; //inspecciona últimos nbits
if (!b) nivel++; //en (1<<nbits)-1 de (1<<nbits) casos incrementa nivel
randomBits>>=nbits;
randomsLeft--;
if (randomsLeft == 0) // Cuando se acaban, invoca al generador
{ randomBits = mrandom(); //recarga número aleatorio
randomsLeft = BitsInRandom/nbits; //reinicia pasadas
}
}
while (!b);
#ifdef allowDuplicates
void insertar(register list l, register TipoClave clave, register TipoValor valor)
#else
boolean insertar(register list l, register TipoClave clave, register TipoValor valor)
#endif
{ register int k;
register pnodo p, q;
p = l->header;
k = l->nivel;
do { while (q = p->forward[k], q->clave < clave)
{p = q; //operador coma.
}
update[k] = p; //salva enlaces hacia al nuevo multinodo.
#ifndef allowDuplicates
if (q->clave == clave)
{ q->valor = valor; //sobreescribe valor.
return(false);
}
#endif
k = randomLevel();
if (k > l->nivel) //si es mayor sólo aumenta al nivel siguiente
{ k = ++l->nivel;
update[k] = l->header;// hay que actualizar el nuevo nivel
}
q = newNodeOfLevel(k);
q->clave = clave;
q->valor = valor;
do { p = update[k]; //Inserta en todas las listas a partir de la k-ésima
q->forward[k] = p->forward[k]; //pega q con los proximos
p->forward[k] = q; //enlaza q con los anteriores
k--;
}
while(k>=0);
#ifndef allowDuplicates
return(true);
#endif
}
25.9. Buscar.
int main(void)
{
register int i,k;
TipoClave keys[sampleSize]; //arreglo con valores.
TipoValor v;
clock_t start, stop; //tipo definido en time.h
int totaltime = 0;
//printf("max=%d\n",INT_MAX);
printf("Prob=%f\n", ((float)Mascara)/((float)(Mascara+1)));
start = clock();
init();
lista= newList();
for(k=0; k<sampleSize;k++)
{ keys[k]=k; //puede también guardarse en forma descendente, o aleatoria.
insertar(lista, keys[k], keys[k]);
//prtList(lista);putchar('\n');
}
for(k=0; k<sampleSize;k++)
{ if (! descartar(lista, keys[k])) printf("Error en descartar\n");
//prtList(lista); putchar('\n');
}
prtList(lista); putchar('\n'); //debe quedar vacía
for(k=0; k<sampleSize;k++)
{ keys[k]=k; //random();
insertar(lista, keys[k], keys[k]);
//prtList(lista);putchar('\n');
}
for(i=0;i<4;i++)
{ for(k=0; k<sampleSize;k++)
{ if (!buscar(lista,keys[k],&v)) printf("Error en búsqueda #%d,#%d\n", i, k);
if (v != keys[k]) printf("Búsqueda retorna valor errado\n");
};
for(k=0; k<sampleSize;k++)
{ if (! descartar(lista,keys[k])) printf("Error en descartar\n");
keys[k] = k; //random();
insertar(lista,keys[k],keys[k]);
}
}
//quedan los nodos en la lista
freeList(lista);
//debe quedar vacía.
free(NIL);
stop = clock();
totaltime += (stop-start); // Mantiene el tiempo usado por la acción. Aproximadamente.
printf("Tiempo acumulado = %d [ticks]\n", totaltime);
printf("\nFin de test.\n");
return (0);
}
Referencias.
Apéndice 1
DESCRIPCION FORMAL DE
LENGUAJES
Cada lenguaje de programación define reglas que permiten componer el texto de un programa
como una secuencia de símbolos. El conjunto de estas reglas se denomina gramática, o más
usualmente, la sintaxis del lenguaje. Sintaxis significa con orden. Cada regla establece una clase
definida de objetos o categorías sintácticas; como ejemplos pueden darse algunas partes típicas
de un programa: acciones, declaraciones, condiciones, expresiones, etc.
Asociado a cada palabra (símbolo) y a cada frase o sentencia (categoría sintáctica) debe existir
un significado, el cual se traduce en valores de los objetos (constantes y variables) de acuerdo a
sus tipos; o en nombres de objetos o grupos de acciones; o en la especificación de las
operaciones que deben efectuarse sobre esos objetos. Todas las reglas que aportan esta
información se denomina: Semántica del lenguaje.
Si bien las reglas para construir símbolos y frases son finitas, el conjunto de programas es
infinito.
Para describir con rigurosidad los lenguajes de programación se emplea una notación formal
que se denomina Metalenguaje.
El formulismo más conocido, y que emplearemos en la descripción, es el Formalismo
Extendido Backus-Nauer. (BNF, Backus Nauer Formalism).
En este ambiente, los elementos léxicos del lenguaje se denominan símbolos terminales. Las
componentes estructurales del lenguaje que serán reemplazadas, se denominan símbolos no
terminales. La regla que establece el reemplazo de un símbolo no terminal por una secuencia de
símbolos terminales y no terminales se denomina producción.
Las reglas deben permitir verificar, con facilidad, si una secuencia de símbolos es o no una
sentencia correcta del lenguaje.
Un programador debe conocer cómo generar secuencias de símbolos terminales que cumplen
la gramática.
A1.2.1. Producción.
Existe sólo una acción primitiva, es la producción. Es una ecuación sintáctica que permite
definir una categoría sintáctica, S; mediante una expresión E.
En símbolos:
<S> ::= <E>
Gráficamente:
S E
Los paréntesis de ángulo, delimitan los símbolos no terminales. En el gráfico esto se representa
por un rectángulo.
La secuencia ::= , es el metasímbolo para la producción. Y se lee: puede ser reemplazado por.
Lo usual es que S corresponda a una parte o concepto del lenguaje. Por ejemplo: acciones,
condiciones, tipos, etc.
La expresión E, debe especificar una secuencia de símbolos; éstos, a su vez, deben especificar,
en forma precisa, cómo se estructura una parte en términos de sus componentes.
Los lenguajes estructurados suelen disponer tres formas básicas para establecer secuencias: La
alternativa, la concatenación, y la iteración.
A1.2.2.1. Alternativa.
Una expresión puede considerarse como una lista de términos sintácticos alternativos.
En símbolos:
Gráficamente:
T1
T2
E
Ti
Tn
El metasímbolo | se lee como o excluyente. La producción anterior explica que una expresión
puede remplazarse por uno (y sólo uno) de los términos de la lista.
Ejemplo:
<clase de almacenamiento> ::= 'auto ' | 'extern ' | 'register ' | 'static ' | 'typedef '
Nótese que los símbolos terminales, se indican por una secuencia de caracteres entre comillas
simples.
A1.2.2.2. Concatenación.
Cada término puede ser reemplazado por la concatenación (o producto) de factores sintácticos.
En símbolos:
Gráficamente:
T F1 F2 Fi Fn
Ejemplo:
A1.2.2.3. Opción.
a) Opción. Una o ninguna.
Una forma de reemplazar un factor es mediante la opción.
En símbolos:
<F> ::= [ E ]
Gráficamente:
Los paréntesis cuadrados son los metasímbolos empleados para la opción, se indica que el factor
puede ser reemplazado por la expresión o por nada. Se dice que [E] es opcional; puede estar o
no. Y si está, lo hace sólo una vez.
Ejemplo:
<signo> ::= [ '+' | '-' ]
+
Signo
-
Figura A1.5. Sintaxis de Signo
Debe notarse que los símbolos terminales se representan encerrados en ovoides o círculos, en
la descripción gráfica.
La ausencia de símbolo, suele ser tratada como la ocurrencia del símbolo vacío.
b) Repetición.
Un factor también puede ser reemplazado por la repetición, de cero o más veces, de una
expresión.
En símbolos:
<F> ::= { E }
Gráficamente:
Ejemplo:
Para delimitar el número máximo de repeticiones, suele agregarse un valor entero como
superíndice.
En símbolos:
Gráficamente:
F E
d) Lista.
Una construcción de uso frecuente es:
La lista está formada por una entidad a lo menos; en caso de existir varias entidades, éstas
aparecen separadas por el separador. Nótese que después de la última entidad no debe existir un
separador.
A1.2.2.4. Agrupaciones.
En las ocasiones que sean necesarias pueden agruparse términos y factores sintácticos
mediante paréntesis redondos.
Ejemplo:
<término simple> ::= ('A'|'B')('C'|'D')
AC AD BC BD
A1.2.2.5. Sintaxis de factor.
En símbolos:
Los símbolos terminales son secuencias de símbolos tomados del vocabulario del lenguaje. Y
se representan entre comillas simples.
El BNF es un lenguaje formal, y como veremos puede emplearse para describirse a sí mismo.
A1.2.4. Ejemplos.
enteroconsigno
signo entero
A su vez, signo se reemplaza por + (ya es terminal). Y entero por: dígito entero.
enteroconsigno
signo entero
dígito entero
+
Figura A1.9. Dígito entero
Dígito por el terminal 1, y entero por dígito. Finalmente dígito por el terminal 0.
enteroconsigno
signo entero
dígito entero
+
1 dígito
0
Figura A1.10. Entero con signo
<A>::={<B>} (i)
es equivalente a:
<A>::=<vacío>|<A><B> (ii)
En forma gráfica:
A B
Se dice que la definición ii) de A es recursiva. Puede comprobarse que existen otras formas
equivalentes. Son equivalentes porque generan las mismas secuencias; cuestión que puede
verificarse desarrollando árboles de derivación.
A es reemplazado por:
AB; luego A es nuevamente reemplazado por AB:
ABB; luego A por AB, queda:
ABBB; otra vez A por AB, queda:
ABBBB; finalmente A por el símbolo vacío.
Ejemplo: <S>::='z'|'y'<S>
S z
y S
S z
El uso de recursividad en las producciones, permite establecer reglas de asociatividad sin uso
de paréntesis.
Con la derivación:
exp
exp op var
exp op var + y
exp op var * x
var + y
var op exp
x + var op exp
y * var op exp
x + var
Ejemplo:
<expresión> ::=<expresión><operador><expresión>|<variable>
<operador> ::='+'|'*'
<variable> ::='a'|'b'|'c'
Puede comprobarse que la secuencia: a+b*c puede interpretarse como a+(b*c) y también como
(a+b)*c.
El formalismo BNF, permite reflejar el orden o jerarquía de los operadores en una expresión
aritmética o lógica.
Por ejemplo: Si se tiene que el operador * tiene precedencia (o mayor jerarquía) que el
operador +; entonces la secuencia a+b*c se interpreta como:
a+(b*c)
Ejemplos:
La semántica de a/b/c es (a/b)/c de acuerdo a la regla iv) ya que hay secuencia de operadores de
igual precedencia y se asocia desde izquierda a derecha.
Para la expresión: a + b*c, aplicando la regla iii), la multiplicación tiene precedencia sobre la
suma, por lo tanto se ejecuta primero. Se interpreta: a + (b*c)
El texto de un programa está formado por una secuencia de símbolos o elementos léxicos.
Los caracteres están estandarizados y les corresponde un código único (de un código de 7
bits, ISO estándar). Con 7 bits pueden codificarse 128 caracteres diferentes.
Los elementos del código que se representan (visualmente) mediante un símbolo gráfico se
denominan caracteres gráficos. Entre ellos 26 letras mayúsculas, 26 minúsculas, 10 dígitos
decimales, 32 caracteres especiales (signos), y el espacio.
El resto del código (33 símbolos, no gráficos) está formado por caracteres de control, que se
emplean para dar efectos de formato y para controlar la comunicación de caracteres entre un
equipo y otro. Se denominan de formato a: tabulación horizontal y vertical, retorno de carro,
alimentación de nueva línea y alimentación de nuevo formulario.
i) Delimitadores
ii) Identificadores
iii) Números (literal numérico)
iv) Carácter
v) Strings
vi) Comentarios
vii) Separadores
Existen reglas que determinan como puede generarse una frase a partir de los elementos
léxicos. El conjunto de reglas se denomina gramática del lenguaje.
A1.3.3. Separadores
Se consideran separadores al espacio, a los caracteres con efecto de formato y el final de una
línea. Suele no definirse qué causa el fin de línea, en algunos sistemas pueden ser uno o más
caracteres de control.
b) Se permite uno o más separadores entre dos elementos léxicos adyacentes. También se
acepta uno o más separadores, antes del primer elemento léxico del programa y después del
último. (Formato libre)
Los comentarios se emplean para mejorar la legibilidad del programa; y deben ocuparse con
frecuencia para aclarar el significado de variables, o de acciones. Los comentarios pueden
sacarse del texto sin alterar el significado del programa.
A1.3.5. Carácter.
Son secuencias de caracteres gráficos entre comillas dobles. Para incluir una comilla doble, se
la precede con \. ”O/”Higgins”.
El largo del string es el número de caracteres que forman la secuencia. Se emplean para
intercalar texto (legible) en la salida. Pueden usarse para alertar al operador que se requiere una
entrada o una operación de su parte (prompts); o bien para hacer más comprensible la salida
(mensajes).
A1.3.7. Números.
Se habla de literal numérico cuando se quiere enfatizar que se está haciendo referencia a cómo
se escribe un número como una secuencia de caracteres.
Existen enteros y reales. Los reales incluyen un punto (la coma decimal). Entre los reales, se
habla de punto fijo y punto flotante (o formato científico y exponencial; llevan la letra E).
número + +
dígito . dígito E dígito
- -
A1.3.8. Identificadores.
Se usan como nombres y también como palabras reservadas. Comienzan con una letra, que
puede ser seguida de cualquier combinación de letras y números. El espacio no se acepta dentro
de un identificador. Sirven para dar nombre a: constantes, tipos de datos, variables,
procedimientos, funciones.
letra
identificador
letra
dígito
A1.3.9. Delimitadores.
~ + ; ] % , < ^
& - = { ( . > | ) / ? } ! * : [
Algunos delimitadores se usan como operadores. Otros para establecer mecanismos de acceso
o selección de datos estructurados.
A1.3.10. Resumen.
A1.3.11. Ejemplos.
b) sqrt(sqr(3)+11*5)
Se evalúa: sqrt(sqr(3)+(11*5))
c) x =2*y-5.02
Se evalúa: x = ((2*y) - 5.02)
A1.3.11.2. Escribir todas las secuencias que cumplen la sintaxis dada por:
a) {'A'|'B'}'C'
b) 'A'{'BA'}
c) ('A'|'B')('C'|'D')
d) 'A'['B']('C'|'D')
a) {'A'|'B'}'C'
número de secuencias>10
1) C
2) AC C
3) BC
4) ABC A
5) AAC
B
b) 'A'{'BA'}
c) ('A'|'B')('C'|'D')
número de secuencias = 4
1) AC A C
2) AD
3) BC
4) BD B D
d) 'A'['B']('C'|'D')
número de secuencias = 4
1) AC C
2) AD A
3) ABC B D
4) ABD
Índice general.
APÉNDICE 1 .............................................................................................................................................. 1
DESCRIPCION FORMAL DE LENGUAJES ........................................................................................ 1
A1.1. LÉXICO, SINTAXIS, SEMÁNTICA. ..................................................................................................... 1
A1.2. METALENGUAJE BNF. .................................................................................................................... 2
A1.2.1. Producción. ............................................................................................................................. 2
A1.2.2. Secuenciación de símbolos. ..................................................................................................... 3
A1.2.2.1. Alternativa........................................................................................................................................ 3
A1.2.2.2. Concatenación. ................................................................................................................................. 3
A1.2.2.3. Opción. ............................................................................................................................................. 4
a) Opción. Una o ninguna. .......................................................................................................................... 4
b) Repetición. .............................................................................................................................................. 5
c) Repetición de a lo menos una vez. .......................................................................................................... 5
d) Lista. ....................................................................................................................................................... 6
A1.2.2.4. Agrupaciones. .................................................................................................................................. 6
A1.2.2.5. Sintaxis de factor. ............................................................................................................................. 7
A1.2.3. Descripción formal de BNF. ................................................................................................... 7
A1.2.4. Ejemplos.................................................................................................................................. 7
A1.2.5. Árboles de Derivación. ........................................................................................................... 8
A1.2.6. Recursividad en Producciones. ............................................................................................... 9
A1.2.7. Asociatividad en Producciones. ............................................................................................ 11
A1.2.8. Ambigüedad en Producciones. .............................................................................................. 12
A1.2.9. Precedencia de operadores en expresiones aritmético lógicas. ........................................... 12
A1.2.10. Reglas para construir Expresiones. .................................................................................... 13
A1.3. SÍMBOLOS DEL LENGUAJE. LÉXICO. .............................................................................................. 15
A1.3.1. Conjuntos de Caracteres. ...................................................................................................... 15
A1.3.2. Elementos Léxicos (tokens). .................................................................................................. 16
A1.3.3. Separadores .......................................................................................................................... 16
A1.3.4. Comentarios. ......................................................................................................................... 17
A1.3.5. Carácter. ............................................................................................................................... 17
A1.3.6. Strings. ( tira, mensaje, texto, hileras, cadenas) ................................................................... 17
A1.3.7. Números. ............................................................................................................................... 17
A1.3.8. Identificadores. ..................................................................................................................... 18
A1.3.9. Delimitadores. ....................................................................................................................... 19
A1.3.10. Resumen. ............................................................................................................................. 19
A1.3.11. Ejemplos.............................................................................................................................. 19
A1.3.11.1. Indicar mediante paréntesis como se evalúan las expresiones:..................................................... 19
A1.3.11.2. Escribir todas las secuencias que cumplen la sintaxis dada por: .................................................. 20
ÍNDICE GENERAL. ................................................................................................................................... 21
ÍNDICE DE FIGURAS................................................................................................................................. 22
Índice de figuras.
Apéndice 2
Introducción al lenguaje C.
Al inicio se efectúa un breve repaso del lenguaje. A continuación se expone con mayor detalle
los tipos básicos y su manipulación; conceptualizando en el diseño de macros y funciones. Más
adelante se profundiza en la interfaz de entrada salida, y en el diseño de rutinas matemáticas.
1. Funciones.
De este modo si el lenguaje no dispone de una determinada acción que se desee, se la puede
desarrollar como un grupo de las instrucciones que el lenguaje ya posee, e invocar la realización
de esta nueva acción por su nombre.
Luego de esto puede seguir construyéndose nuevas funciones empleando las ya definidas, en el
mismo sentido que ya Euclides desenvolvió para el desarrollo de la geometría a través de sus
Elementos.
Si bien el lenguaje C tiene acciones muy primitivas, tiene una biblioteca (en inglés: library) muy
amplia. Las acciones básicas deben ser simples ya que el objetivo del lenguaje es lograr una
compilación eficiente; es decir, que la traducción a assembler sea siempre posible y eficiente
con los repertorios clásicos de los procesadores.
El nombre de la función recuerda que imprime con formato; es decir con determinado patrón
que se establece mediante el string de formato, que es el primer argumento de la función. En el
ejemplo mueve hacia la salida todos los caracteres del string, hasta encontrar un %. Luego de
acuerdo a la letra, después de cada signo % determina cómo imprimir el resto de los
argumentos. En este caso imprime el valor del entero almacenado en x, en forma decimal (por la
letra d).
Lo que se necesita para emplear una función de la biblioteca es conocer sus argumentos y el tipo
del valor de retorno, y la función que realiza. Esta especificación se conoce como el prototipo
de la función.
La acción de usar la función, se conoce como invocación, y se hace de tal modo que los
argumentos actuales deben estar en correspondencia de número, tipo y orden de ocurrencia con
los parámetros o argumentos formales que tiene la definición de la función.
Tomemos un ejemplo del cálculo. Y calculemos la función: f(x) = 3 x2 +5
Entonces la definición, muestra: un argumento real y el tipo del valor de retorno, que también es
real.
Luego de definida la función, se la puede invocar; es decir, si la invocación está más adelante,
en el texto del programa, que su definición.
float w, y;
y = 4.2;
w = f(y -3.0) + 3.3*y ; /* invocación */
Nótese que el argumento actual debe ser una expresión que tome un valor de tipo float. Y que
el valor de tipo float, retornado por la función puede emplearse, también dentro de una
expresión.
Antes de invocar, se calcula el valor del argumento actual (en este caso: 4.2-3.0) y con este
valor se inicializa la variable x (argumento actual) en el espacio de memoria asociado a la
función. Dentro del cuerpo de acciones de la función se realizan los cálculos y mediante la
Si se desea invocar a la función con un argumento entero, debe especificarse una conversión
explícita de tipo, mediante un cast.
int j=0;
j = 5; w = f( (float) j*2 )
Si la invocación se realiza antes, en el texto del programa, que su definición, es preciso emplear
un prototipo de la función antes de invocarla. Nótese el punto y coma que termina el prototipo.
Cuando se desea diseñar una función que sólo sea una agrupación de acciones, se define su
retorno vacío (void). También si no tiene argumentos, debe especificarse void, en el lugar de
los argumentos.
void asterisco(void)
{ putchar('*'); }
Como esta función usa una función de biblioteca, antes de definirla se debe incluir <stdio.h>
que contiene el prototipo de putchar.
Para invocarla:
asterisco( );
Una función con retorno vacío es una abstracción de procedimiento o acción y no de expresión.
Por otro lado cuando la magnitud del problema es mayor, y deben resolverlo mediante un
equipo de programadores, la especificación de las funciones permite establecer la división del
trabajo. Todos deben conocer los prototipos.
Este modelo de programación considera las funciones como operaciones sobre los datos.
También es preciso que los miembros del equipo conozcan las estructuras de datos que
manipularán las funciones.
La exposición del lenguaje C sigue empleándose como una descripción abstracta de las
capacidades de un procesador.
Entonces, las funciones en C, tienen un diseño muy limitado. Sólo se le pueden pasar los
valores de los argumentos, y sólo se dispone de un valor de retorno.
Esto es muy limitado, ya que por ejemplo si el problema se puede modelar con vectores o
matrices, se requiere un vector o matriz de retorno. La función sólo puede retornar un valor de
un tipo determinado.
En ANSI C, se decidió permitir el retorno de una estructura. Lo cual permite retornar todos los
miembros de ésta.
Deseamos incrementar en uno dos variables, en una sola operación, que identifican contadores.
int cnt1=0, cnt2=0, cnt=0;
La función:
El diseño:
void cnt( int * c1, int *c2)
{ (*c1)++; (*c2)++}
Al invocar se pasan los valores de las direcciones de las variables; en el cuerpo de la función
mediante indirección se escribe en las variables. Se pasa una referencia a las variables.
1.6. Frame.
Otro concepto relevante en el uso de funciones es el espacio de memoria que ésta ocupa para
almacenar sus variables. Este espacio se denomina frame y logra mantener en direcciones
contiguas de memoria a las variables que puede emplear la función mientras se ejecuta su
código.
Lo que se desea es tener localidad espacial. Es decir que las instrucciones que ejecuta la
función estén en direcciones contiguas de memoria y también las variables que ésta emplee.
Esto permite el empleo eficiente de las memorias caché, actualmente presentes en todos los
diseños actuales de procesadores.
Otra consideración de importancia es que sólo es necesario disponer del frame mientras la
función esté en ejecución, lo cual permite reutilizar las celdas ocupadas.
Las variables locales, las definidas dentro del cuerpo de acciones de la función, se guardan en el
frame. Los argumentos también se guardan en el frame. Si almacenamos en el frame la
dirección de la instrucción a la que debe retornarse, luego de ejecutada la función, podremos
invocar a funciones dentro de una función.
Entonces, antes de llamar a una función se introducen en el frame, los valores de los argumentos
actuales en celdas contiguas de memoria; luego se introduce la dirección de retorno, y
finalmente se llama a la función. El código de la función crea el espacio para las variables
locales en el frame, en celdas contiguas. Antes de salir de la función, se retorna el valor; luego
se recupera la dirección de retorno y finalmente se desarma el frame.
Con esta disciplina el compilador lo único que requiere para traducir a código de máquina el
texto de una función es su prototipo. Ya que debe empujar dentro del frame los valores de los
argumentos, a su vez conoce ahora a los argumentos por los desplazamientos de éstos relativos
al stack pointer. Empleando mecanismos de direccionamiento indirecto o relativos a registros
puede leer y escribir en el espacio asignado a los argumentos. Lo mismo puede decirse de la
forma en que puede leer o escribir en las variables locales.
Sin embargo dotar a un lenguaje con la capacidad de invocar a funciones tiene un costo. Se
requieren varias instrucciones para crear el espacio, copiar los valores de los argumentos, iniciar
No es razonable crear funciones cuyo código sea menor o similar al número de instrucciones
requeridas para administrar el frame. En estos casos se emplean macros; los cuales permiten la
abstracción de llamar por un nombre, sin el costo del frame.
Los compiladores actuales para un procesador determinado tienen una política para el uso de los
registros; intentan pasar un número fijo de argumentos y retornar un número fijo de valores en
registros, también intentan emplear registros para las variables locales. Y sólo emplean el frame
para los argumentos que no puedan almacenarse en registros.
Más aún: clasifican los registros en temporales y salvables. Usan indiscriminadamente los
temporales, para desarrollar el cuerpo de la función, y los salvables los emplean para las
variables locales. Y sólo salvan en el stack los valores de los registros salvables que sean
modificados por la función.
Como en cualquier lenguaje existe un aspecto léxico (vocabulario, las palabras que se pueden
escribir); un aspecto sintáctico (reglas gramaticales, para escribir correctamente); y finalmente
un aspecto semántico (que asigna un significado a las construcciones).
Los tipos básicos son: enteros, reales, carácter y strings (secuencia de caracteres).
Enteros con signo.
En C, los enteros se representan en una palabra de la memoria, y sus valores dependen del
ancho de palabra que tenga la memoria (en la actualidad, 16 ó 32 bits)
Para obtener un número en complemento a uno, basta cambiar sus unos por ceros y sus ceros
por unos. Si tenemos: 010 (el decimal 2) su complemento uno es 101 (con equivalente decimal
menos dos).
Para 16 bits, en C-2, el rango de representación de enteros con signo es desde -(2^15) hasta
+(2^15 )-1
Lo cual equivale al intervalo desde –32.768 hasta +32.767
Definiciones de Datos.
a) Para declarar una variable entera, por ejemplo, la variable i como entera, se anota:
int i;
Enteros Largos.
Ocupan el doble de largo que un entero común.
La definición:
char ch = ‟a‟ ; /* define e inicia ch con el valor ASCII del símbolo a.*/
Strings.
Se los define como un arreglo de caracteres. La definición del arreglo, se indica con el valor
entero de las componentes entre paréntesis de tipo corchete.
1.7.2. Acciones.
La manera básica de organizar las acciones es hacerlo en una de las tres siguientes formas:
Secuencia.
Alternativa.
Si la condición es verdadera se realiza la acción1; si es falsa: la acción2.
Se anota:
if ( condición ) accion1; else acción2;
Repetición.
Mientras la condición sea verdadera se repite la acción.
Se anota:
while (condición ) acción;
Se ha comprobado que cualquier diagrama de flujo puede ser representado usando las tres
construcciones básicas anteriores. Una programación que emplee estos principios se denomina
estructurada. Permite leer un programa sin tener que volver hacia atrás.
For.
int i=10;
for (i=10; i>0 ; i--) putchar( ‟*‟);
putchar( ‟\n‟);
La construcción del for(inicio; condición; reinicio) ejecuta la expresión de inicio; luego evalúa
la condición, y si es verdadera(valor diferente de cero) ejecuta el bloque asociado; finalmente
evalúa la expresión de reinicio y vuelve a evaluar la condición y así sucesivamente hasta que
ésta es falsa(valor cero), situación en que termina el for.
Abstracción.
Una acción adicional es poder invocar a una función.
Como se verá una función permite agrupar a un número de instrucciones bajo un nombre.
Dotándola de una interfaz con el resto del programa, pasándole argumentos como valores de
entrada y tomando la función un valor de retorno.
Dado un problema, encontrar las funciones que permitan resolverlo es fundamental en
programación.
Si i es de tipo entero:
printf(“%d”,i); /*escribe el entero en decimal*/
printf(“%o”,i); /*escribe el entero en octal*/
printf(“%x”,i); /*escribe el entero en hexadecimal*/
printf(“%6d”,i); /*escribe el entero en decimal, con largo de campo igual a 6*/
printf(“abcd%defgh”, i); /*escribe string abcd, el entero en decimal, y el string efgh*/
2. Tipo char.
2.1. Valores.
Primero describiremos los valores que pueden tomar los elementos de tipo char.
Es un tipo básico del lenguaje. Las variables y constantes de tipo char ocupan un byte.
El tipo unsigned char tiene el rango 0 a 255.
El tipo char (o signed char, esto es por defecto) tiene el rango –128 a 127.
A las variables de tipo char se las puede tratar como si fueran de tipo entero, ya que son
convertidas automáticamente a ese tipo cuando aparecen en expresiones.
Una constante de tipo carácter se define como un carácter encerrado entre comillas simples. El
valor de una constante de tipo carácter es el valor numérico de ese carácter en la tabla o código
de caracteres. En la actualidad la tabla más empleada es el código ASCII.
ASCII son las iniciales de American Standard Code for Information Interchange.
Internamente, un carácter, se representa por una secuencia binaria de 8 bits. Un valor
perteneciente al código ASCII es la representación numérica de un carácter como '1' o '@' o de
una acción de control.
Alternativamente pueden emplearse dos cifras hexadecimales para representar un carácter, del
siguiente modo: '\xhh' La x indica que uno o los dos dígitos siguientes deben ser reemplazados
por una cifra hexadecimal (dígitos 0 a 9, y las letras A, B, C, D, F). La secuencia binaria de 8
unos seguidos, equivale a FF en hexadecimal.
Todos los valores de la tabla anterior son positivos, si se representan mediante un byte, ya que el
bit más significativo es cero.
Los caracteres que representan los dígitos decimales tienen valores asociados menores que las
letras; y si se les resta 0x30, los cuatro bits menos significativos representan a los dígitos
decimales en BCD (Binary Coded Decimal). Las letras mayúsculas tienen códigos crecientes en
orden alfabético, y son menores en 0x20 que las letras minúsculas.
En español suelen emplearse los siguientes caracteres, que se anteceden por su equivalente
decimal: 130 é, 144 É, 154 Ü, 160 á, 161 í, 162 ó, 163 ú, 164 ñ, 165 Ñ, 168 ¿, 173 ¡.
Los valores de éstos tienen el octavo bit (el más significativo en uno), y forman parte de los 128
caracteres que conforman un código ASCII extendido.
Los caracteres de control han sido designados por tres letras que son las primeras del significado
de la acción que tradicionalmente e históricamente se les ha asociado.
Por ejemplo el carácter FF (Form Feed) con valor 0x0c, se lo emplea para enviar a impresoras, y
que éstas lo interpreten con la acción de avanzar el papel hasta el inicio de una nueva página
(esto en impresoras que son alimentadas por formularios continuos).
Los teclados pueden generar caracteres de control (oprimiendo la tecla control y una letra). Por
ejemplo ctrl-S y ctrl-Q generan DC3 y DC1 (también son conocidos por X-on y Xoff), y han
sido usados para detener y reanudar largas salidas de texto por la pantalla de los terminales).
Varios de los caracteres se han usado en protocolos de comunicación, otros para controlar
modems.
Algunos de los caracteres, debido a su frecuente uso, tienen una representación por secuencias
de escape. Se escriben como dos caracteres, pero representan el valor de uno de control. Los
más usados son:
Dentro de un string, suelen emplearse las siguientes secuencias para representar los caracteres ",
', \. Que no podrían ser usados ya que delimitan strings o caracteres o son parte de la secuencia
de escape.
\\ para representar la diagonal invertida
\" para representar la comilla doble, dentro del string.
\' para representar la comilla simple dentro del string.
Ejemplo:
Char esc = '\\';
"O\'Higgins" en un string.
El siguiente texto,
se representa internamente
según:
45 6C 20 73 69 67 75 69 65 6E 74 65 20 74 65 78 74 6F 2C 20 0D 0A
73 65 20 72 65 70 72 65 73 65 6E 74 61 20 69 6E 74 65 72 6E 61 6D 65 6E 74 65 20 0D 0A
73 65 67 FA 6E 3A 0D 0A
La representación hexadecimal de los caracteres que forman el texto, muestra los dos caracteres
de control que representan el fin de línea (0x0D seguido de 0x0A). Cada carácter gráfico es
representado por su valor numérico hexadecimal. La primera representación (externa) se emplea
para desplegar la información en pantallas e impresoras; la segunda es una representación
interna (se suele decir binaria, pero representada en hexadecimal) y se emplea para almacenar
2.5. Expresiones.
Puede verificarse cómo son tratados los enteros con signo negativo por el compilador que se
está empleando, observando los resultados de: printf(" %c\n",-23); Debe producir la letra
acentuada: é.
Un compilador moderno también debería imprimir las letras acentuadas, por ejemplo:
printf(" %c\n",'é');
El siguiente par de for anidados muestra 8 renglones de 16 caracteres cada uno, con los
caracteres que tienen valores negativos (el bit más significativo del byte es uno).
Observando la salida, la que dependerá del compilador empleado, pueden comprobarse los
caracteres (con valores negativos) que serán representados gráficamente.
Cuando se desea tratar el contenido de un byte (independiente del valor gráfico) debe emplearse
unsigned char.
Es preferible escribir: i = (int) (c – '0'); que destaca que se está efectuando un conversión de
tipos. Pero lo anterior no suele encontrarse en textos escritos por programadores
experimentados.
La expresión ('a' - 'A') toma valor (97 – 65) = 32. Valor que expresado en binario es:
00100000 y en hexadecimal 0x20.
Si con esta máscara se efectúa un or con una variable c de tipo carácter: c | ('a' - 'A')
la expresión resultante queda con el bit en la quinta posición en uno(esto conviniendo, como es
usual, que el bit menos significativo ocupa la posición cero, el más derechista).
La expresión: c |= ('a' - 'A') si c es una letra la convierte en letra minúscula.
La expresión: c & ~ ('a' - 'A') forma un and con la máscara binaria 11011111 y la expresión
resultante deja un cero en el bit en la quinta posición.
La palabra máscara recuerda algo que se pone delante del rostro, y en este caso es una buena
imagen de la operación que realiza. Nótese que con un or con una máscara pueden setearse
determinadas posiciones con unos; y con un and, con una máscara, pueden dejarse determinados
bits en cero.
Las expresiones anteriores pueden emplearse para convertir letras minúsculas a mayúsculas y
viceversa.
La condición:
(c != ' ' && c != '\t' && c!= '\n')
es verdadera(toma valor 1) si c no es un separador.
2.6. Entrada-Salida
Convierte c a unsigned char y lo escribe en la salida estándar (stdout); retorna el carácter escrito,
o EOF, en caso de error.
Lee desde la entrada estándar el siguiente carácter como unsigned char y lo retorna convertido a
entero; si encuentra el fin de la entrada o un error retorna EOF.
El valor de la constante EOF predefinida en <stdio.h> es un valor entero, distinto a los valores
de los caracteres que pueden desplegarse en la salida, suele ser –1. EOF recuerda a end of file
(debió ser fin del stream o flujo de caracteres). Por esta razón getchar, retorna entero, ya que
también debe detectar el EOF.
obtiene un carácter y lo asigna a c (la asignación es una expresión que toma el valor del lado
izquierdo); este valor es comparado con el EOF, si son diferentes, la condición toma valor 1,
que se interpreta como valor verdadero. Los paréntesis son obligatorios debido a que la
precedencia de != es mayor que la del operador =.
Dentro del string de control del printf, una especificación de conversión se inicia con el carácter
% y termina con un carácter. El carácter c indica que una variable de tipo int o char se imprimirá
como un carácter. Nótese que los caracteres que están antes y después de la especificación de
conversión se imprimen de acuerdo a su significado.
Los siguientes valores del argumento actual son imprimibles (isgraph retorna verdadero).
print_char(0x40); imprime en una línea: '@'
print_char(65); imprime en una línea: 'A'
Los siguientes valores del argumento actual se imprimen como tres cifras octales.
print_char(06); imprime en una línea: '\006'
print_char(0x15); imprime en una línea: '\025'
2.7. Funciones.
char tolower(int c)
{
if((char)c <= 'Z' && (char)c >= 'A') c |= ('a' - 'A');
return (char)c;
}
Primero el signo, luego la cifra más significativa. Lo logra reinvocando a la función con un
argumento que trunca, mediante división entera la última cifra. De este modo la primera función
(de las múltiples encarnaciones) que termina, es la que tiene como argumento n a la cifra más
significativa, que es menor que 10, imprimiendo dicho valor, a través de la conversión de entero
a carácter. Es necesario tomar módulo 10, para imprimir las cifras siguientes.
void printd(int n)
{ if (n<0) putchar('-') n= -n;
if(n/10) printd(n/10);
putchar(n % 10 + '0');
}
La función prtstr imprime un string. Igual resultado se logra con printf("%s", s).
void prtstr(char *s)
{ while(*s) putchar(*s++); }
2.8. Macros.
Se escriben:
#define <token> <string>
Todas las ocurrencias del identificador <token> en el texto fuente serán reemplazadas por el
texto definido por <string>. Nótese que no hay signo igual, y que no se termina con punto y
coma.
También las emplea el sistema para representar convenientemente y en forma estándar algunos
valores.
Por ejemplo en limits.h figuran entre otras definiciones, las siguientes:
#define CHAR_BIT 8
#define SCHAR_MAX 127
#define SCHAR_MIN (-128)
#define UCHAR_MAX 255
Si se desean emplear constantes predefinidas por el sistema, debe conocerse en cual de los
archivos del directorio include están definidas. Y antes de que sean usadas debe indicarse en una
línea la orden de inclusión, para que el preprocesador incorpore el texto completo de ese archivo
en el texto fuente previo al proceso de compilación.
Por ejemplo:
#include <ctype.h>
Los paréntesis de ángulo indican que el archivo está ubicado en el subdirectorio include. Si se
desea tener archivos definidos por el usuario, el nombre del archivo que debe incluirse debe
estar encerrado por comillas dobles.
Se suelen emplear para definir macroinstrucciones (de eso deriva su nombre), es decir una
expresión en base a las acciones primitivas previamente definidas por el lenguaje. La macro se
diferencia de una función en que no incurre en el costo de invocar a una función (crear un frame
con espacio para los argumentos y variables locales en el stack, salvar registros y la dirección de
retorno; y luego recuperar el valor de los registros salvados, desarmar el frame y seguir la
ejecución).
Su real efectividad está limitada a situaciones en las que el código assembler que genera es
pequeño en comparación con el costo de la administración de la función equivalente. O también
cuando se requiere mayor velocidad de ejecución, no importando el tamaño del programa
ejecutable.
Los identificadores arg1, ... son tratados como parámetros del macro. Todas las instancias de los
argumentos son reemplazadas por el texto definido para arg1,.. cuando se invoca a la macro,
mediante su nombre. Los argumentos se separan por comas.
Por ejemplo:
#define ISLOWER(c) ('a' <= (c) )&& (c) <= 'z')
#define TOUPPER(c) (ISLOWER(c) ? 'A' + ((c) - 'a') : (c))
Si en el texto fuente aparece ISLOWER( 'A') dentro de una expresión, antes de la compilación
el preprocesador cambia el texto anterior por: ('a' <= ('A') && ('A') <= 'z'). El objetivo de esta
macro es devolver un valor verdadero (valor numérico 1) si el carácter c tiene un valor numérico
entre los valores del código asociados al carácter 'a' y al carácter 'z'; en caso contrario, la
expresión lógica toma valor falso (valor numérico 0).
La definición debe estar contenida en una línea. En caso de que el string sea más largo, se
emplea el carácter \ al final de la línea.
#define ctrl(c) ((c) \
< ' ') /* string continua desde línea anterior */
Lo cual es equivalente a:
El siguiente macro sólo puede aplicarse si se está seguro que el argumento representa un
carácter que es una letra. Entre 0x41 y 0x51. También da resultado correcto si la letra es
minúscula (entre 0x60 y 0x7a).
Nótese que en el string que define el texto que reemplaza al macro, los argumentos se colocan
entre paréntesis.
Prototipos en include/ctype.h
El nombre de cada macro pregunta si el carácter es de cierta clase, por ejemplo si es símbolo
alfanumérico el nombre es isalnum. Cada macro retorna un valor diferente de cero en caso de
ser verdadero y cero en caso de ser falso.
El siguiente arreglo contiene la información de atributos de cada carácter, indexada por su valor
numérico ascii +1.
Si el valor entero del carácter es –1(EOF), al sumarle 1, resulta índice 0 para la tabla de
búsqueda. Si se buscan los atributos en la entrada 0 del arreglo; se advierte que tiene definido
valor cero, resultando con retornos falsos de los macros para EOF.
Se escoge para EOF un valor numérico diferente a los imprimibles.
isascii está definida para valores enteros. El resto de los macros están definidos sólo cuando
isaccii es verdadero o si c es EOF.
Los caracteres considerados gráficos no contemplan la categoría espacio (_S); pero sí están
considerados en la de imprimibles.
En la categoría espacios se consideran los caracteres de control: tab, lf, vt, ff, cr
Las letras de las cifras hexadecimales se consideran en mayúsculas y minúsculas.
Con el macro siguiente, que pone en uno el bit en posición dada por bit:
# define _ISbit(bit) (1 << (bit))
Los códigos de biblioteca suelen ser más complejos que los ilustrados, ya que consideran la
portabilidad. Por ejemplo si el macro que define un bit en determinada posición de una palabra,
se desea usar en diferente tipo de procesadores, se agrega texto alternativo de acuerdo a la
característica.
Las órdenes de compilación condicional seleccionan, de acuerdo a las condiciones, la parte del
texto fuente que será compilada.
Por ejemplo: Si se desea marcar uno de los bits de una palabra de 16 bits, el macro debe
considerar el orden de los bytes dentro de la palabra.
# if __BYTE_ORDER == __BIG_ENDIAN
# define _ISbit(bit) (1 << (bit))
# else /* __BYTE_ORDER == __LITTLE_ENDIAN */
# define _ISbit(bit) ((bit) < 8 ? ((1 << (bit)) << 8) : ((1 << (bit)) >> 8))
# endif
3. Strings.
Se describen las rutinas que manipulan strings, cuyos prototipos se encuentran en string.h
#include <string.h>
typedef unsigned size_t; /* define tipo requerido por sizeof */
char string[6]; /*crea string con espacio para 6 caracteres. Índice varía entre 0 y 5 */
string[5] = NULL;
string
\0
El nombre del string es un puntero constante que apunta al primer carácter del string. Por ser
constante no se le puede asignar nuevos valores o modificar.
3.1.2. Puntero a carácter.
La definición de un string como un puntero a carácter, puede ser inicializada asignándole una
constante de tipo string. La que se define como una secuencia de cero o más caracteres entre
comillas dobles; el compilador agrega el carácter „\0‟ automáticamente al final.
Si dentro del string se desea emplear la comilla doble debe precedérsela por un \.
En caso de escribir, en el texto de un programa, un string de varias líneas, la secuencia de un \ y
el retorno de carro(que es invisible en la pantalla) no se consideran parte del string.
char * str1 = "abcdefghi"; /* tiene 10 caracteres, incluido el NULL que termina el string.*/
Un argumento de tipo puntero a carácter puede ser reemplazado en una lista de parámetros, en
la definición de una función por un arreglo de caracteres sin especificar el tamaño. En el caso
del ejemplo anterior: char str1[ ]. La elección entre estas alternativas suele realizarse según sea
el tratamiento que se realice dentro de la función; es decir, si las expresiones se elaboran en base
a punteros o si se emplea manipulación de arreglos.
3.2. Strcpy.
destino
cp fuente
El diagrama ilustra los punteros fuente y cp, después de haberse realizado la copia del primer
carácter. Se muestra el movimiento de copia y el de los punteros.
Cuando el contenido de *fuente es el carácter NULL, primero lo copia y la expresión resultante
de la asignación toma valor cero, que tiene valor falso para la condición, terminando el lazo
while.
El operador de postincremento opera sobre un left value(que recuerda un valor que puede
colocarse a la izquierda de una asignación). Un lvalue es un identificador o expresión que está
relacionado con un objeto que puede ser accesado y cambiado en la memoria.
Puede evitarse la acción doble relacionada con los operadores de pre y postincremento, usando
éstos en expresiones que sólo contengan dichos operadores. En el caso de la acción de
repetición: while(*cp++ = *fuente++) continue;
Cuando en la lista de parámetros de una función aparece la palabra reservada const precediendo
a una variable de tipo puntero, el compilador advierte un error si la función modifica la variable
a la que el puntero apunta. Además cuando se dispone de diferentes tipos de memorias (RAM,
EEPROM o FLASH) localiza las constantes en ROM o FLASH. Si se desea que quede en un
segmento de RAM, se precede con volatile, en lugar de const.
Ejemplo:
#include <string.h>
#include <stdio.h>
char string[10]; /*crea string con espacio para 10 caracteres */
char * str1 = "abcdefghi"; /* tiene 10 caracteres, incluido el NULL que termina el string.*/
int main(void)
{ strcpy(string, str1); printf("%s\n", string);
return 0;
}
3.3. Strncpy.
strncpy copia n caracteres desde el string fuente hacia el string destino. Si el string fuente tiene
menos de n caracteres rellena con nulos hasta completar la copia de n caracteres.
Si el string fuente tiene n o más caracteres el string destino no queda terminado en un nulo.
char *strncpy(register char * destino, register const char * fuente, register size_t n )
{ register char * cp = destino;
while( n ) { n--; if (! (*cp++ = *fuente++) ) break; }
while( n--) *cp++ = 0;
return destino;
}
Concatena una copia del string fuente luego del último carácter del string destino.
El largo del string resultante es la suma: strlen(dest) + strlen(src).
Retorna un puntero al string destino, que ahora contiene la concatenación.
El primer while deja el puntero cp apuntando al carácter de fin del string destino. Luego el
segundo while efectúa la copia, sobreescribiendo el NULL del string destino, con el primer
carácter del string fuente.
Ejemplo:
#include <string.h>
#include <stdio.h>
char destino[25];
char *espacio = " ", *fin = "Final", *destino = "Inicio";
int main(void)
{ strcat(destino, espacio); strcat(destino, fin);
printf("%s\n", destino);
return 0; destino
}
cp fuente
3.5. Strncat.
strncat concatena al final del string destino a lo más n caracteres del string fuente.
Si fuente tiene menos de n caracteres, el segundo operador del and copia el fin de string. Si el
string fuente tiene n o más caracteres, es el primer operando del and es el que da por terminado
el segundo while; saliendo de éste con un valor cero de n. Es para este último caso que está el if
final que escribe el terminador del string destino.
3.6. Strlen.
Largo de un string.
Retorna el número de caracteres del string s. No cuenta el carácter de terminación.
El while termina cuando *cp tiene valor cero. Pero debido al operador de postincremento, al
salir del while, cp queda apuntado una posición más allá de la posición que ocupa el NULL.
Entonces cp-1, apunta al NULL. Y la resta de punteros, produce un entero como resultado; éste
da el número de caracteres del string. Si a s se le suman 5, se tiene el valor del puntero que
apunta al terminador del string, en el caso del ejemplo que se ilustra a continuación; entonces
(cp-1) – s resulta 5 en este caso.
\0
cp
Figura A2.5. Largo string.
3.7. Strcmp.
Si los caracteres *s1 y *s2 son iguales, r es cero; y el valor lógico del primer operando del and
es verdadero. Si son diferentes, termina el lazo de repetición. Si el valor de *s2 es mayor que el
valor de *s1, r será negativo; implicando que el string s2 es "mayor" que el string s1. Si *s2 es
el carácter NULL, r será positivo si *s1 no es cero, terminando el while; implicando que s1 >
s2. Si *s1 es el carácter NULL, r será negativo si *s2 no es cero, terminando el while;
implicando que s1 < s2.
3.8. Strncmp.
int strncmp(register const char * s1, register const char * s2, size_t n)
{
while( n--) { if(*s1 == 0 || *s1 != *s2) return (*s1 - *s2); s1++; s2++; }
return 0;
}
Para los primeros n caracteres, si los caracteres difieren o se llegó al fin del string s1 se retorna
la resta del valor entero asociado a los caracteres.
Si s2 es más corto que s1, los caracteres difieren ya que *s2 es cero, y el retorno será positivo;
indicando s1 > s2.
3.9. Strstr.
3.10. Strchr.
El tipo char es tratado como entero con signo de 8 bits(-128 a +127) y es promovido
automáticamente a tipo entero. Sin embargo se emplea un conversión explícita de entero a char
mediante el molde o cast: (char) c
Debido a que el parámetro formal se trata como puntero a una constante string(para evitar que la
función modifique a s), se debe efectuar una conversión del tipo de puntero para el retorno, se
pasa a puntero a carácter (char *) el puntero a string constante: const char *.
La búsqueda de c en s es hacia adelante (de izquierda a derecha, o de arriba hacia abajo).
Si s apunta al terminador del string, la expresión *s tiene valor cero, y la condición del while es
falsa, por lo tanto no busca el terminador del string.
El fin de string(terminador nulo) es parte del string. Si se desea que la búsqueda strchr(s, 0)
retorne un puntero al terminador nulo del string s, debe efectuarse la siguiente modificación:
\0 s
El terminador nulo es considerado parte del string. Si se desea buscar el terminador del string
debe modificarse la rutina anterior, o utilizar strchar.
3.12. Strpbrk.
strpbrk busca en el string s1 la primera ocurrencia de cualquier carácter presente en s2; retorna
un puntero al primer encontrado, en caso que ningún carácter de s2 esté presente en s1 retorna
un puntero nulo.
3.13. Strcspn.
strcspn encuentra el segmento inicial del string s1 que no contiene caracteres que se encuentren
presentes en s2. Retorna el largo del segmento encontrado.
A partir del primer carácter de s1 se revisa que éste no esté presente entre los caracteres que
forman s2.
3.14. Strspn.
strspn encuentra el segmento inicial del string s1 que solamente contiene caracteres que se
encuentren presentes en s2. Retorna el largo del segmento encontrado.
3.15. Strtok.
Strtok busca el primer substring de s1 que está antes del string s2 que se considera un
separador.
Se considera que s1 es un string formado por una secuencia de cero o más símbolos(tokens)
separados por el string s2.
La función strtkn permite obtener todos los tokens mediante llamados subsiguientes al primero.
Para esto el primer llamado debe retornar un puntero al primer token, y escribir un carácter
NULL en el string s1, inmediatamente después del último carácter del token que se retorna.
Los siguientes llamados deben realizarse con un puntero nulo en el primer argumento. El
separador s2 puede ser diferente para cada una de las siguientes invocaciones. Si no se
encuentran símbolos la función debe retornar un puntero nulo.
Como es una rutina que puede llamarse varias veces, su diseño debe incluir una variable estática
que permanezca entre llamados. Ya que los argumentos y variables locales sólo existen mientras
la función esté activa, debido a que se mantienen en un frame en el stack.
s1
t1 s2 t2 s2 t2 s2
sp
El primer llamado a strtrk, debe retornar s1, colocando un nulo en el primer carácter de s2, y
fijar la posición de la estática sp inmediatamente después del terminador recién insertado.
Los llamados subsiguientes llevan un NULL en el primer argumento, correspondiente al puntero
al string s1; esto le indica a la función que debe emplear ahora sp para seguir buscando tokens.
Mediante la función strspn se saltan los caracteres pertenecientes al separador s2, y s1 queda
apuntado al primer carácter siguiente al separador s2.
El tercer if, si se llegó al final del string, fija sp en puntero nulo, y retorna un puntero nulo.
No se efectúa la acción del tercer if, si quedan caracteres por escanear.
Mediante la función strcspn se fija sp una posición más allá del último carácter del token
encontrado.
El cuarto if, fija sp en puntero nulo, si se agotó la búsqueda (se efectúa el else); en caso,
contrario si quedan caracteres por escanear, coloca un carácter nulo para finalizar el token
encontrado, y avanza sp una posición más adelante.
El tipo del argumento de s1 no puede ser un puntero constante, ya que se escribe en el string.
Si por ejemplo el string s1 tiene el valor "abc, dd,efg" y si el separador s2 es el string ","; se
encuentra, después del primer llamado el token: abc. Luego del segundo (con primer argumento
NULL) el token: dd. Finalmente el símbolo: efg.
3.16. Strdup.
strdup crea un duplicado del string s, obteniendo el espacio con un llamado a malloc.
Malloc retorna un puntero de tipo void o un puntero genérico. Se emplean cuando el compilador
no puede determinar el tamaño del objeto al cual el puntero apunta. Por esta razón los punteros
de tipo void deben ser desreferenciados mediante casting explícito.
El siguiente ejemplo muestra que a un puntero tipo void se le puede asignar la dirección de un
entero o un real. Pero al indireccionar debe convertirse el tipo void al del objeto que éste
apunta.
int x;
float r;
void *p;
p = &x; * (int *) p = 2;
p = &r; *(float *)p = 1.1;
Bloques de memoria.
El conjunto de funciones cuyos prototipos se encuentran en string.h también incluye un grupo
de funciones que manipulan bloques de memoria.
Los bloques son referenciados por punteros de tipo void, en la lista de argumentos. Luego en las
funciones se definen como variables locales punteros a caracteres, que son iniciados con los
valores de los punteros genéricos amoldados a punteros a carácter.
3.17. Memcpy.
memcpy Copia un bloque de n bytes desde la dirección fuente hacia la dirección destino.
3.18. Memccpy.
Copia no más de n bytes desde el bloque apuntado por fuente hacia el bloque apuntado por
destino, deteniéndose cuando copia el carácter c.
Retorna un puntero al bloque destino, apuntando al byte siguiente donde se copió c. Retorna
NULL si no encuentra a c en los primeros n bytes del bloque fuente.
Se emplean locales de tipo registro para acelerar la copia. Nótese que los punteros de tipo void
de los argumentos son los valores iniciales de los registros con punteros a caracteres.
3.19. Memmove.
Con memmove, si los bloques apuntados por fuente y destino se traslapan, los bytes ubicados en
la zona de traslapo se copian correctamente.
Los dos casos del or se ilustran en las dos figuras de más a la izquierda. En el caso que ilustra la
figura central, no hay traslapo.
En el caso s>=d, si d+n>=s, se produce traslapo y se sobreescriben las primeras posiciones del
bloque s, las que primero fueron copiadas (cuando se ejecuta el else, avanzado los punteros
hacia direcciones cada vez mayores).
n n
s d d
Debido a que el bloque fuente puede ser sobrescrito, no puede ser un puntero vacío constante.
Los punteros a carácter s y d, son iniciados con los valores amoldados (cast) a punteros a
carácter de fuente y destino.
3.20. Memcmp.
memcmp compara los primeros n bytes de los bloques s1 y s2, como unsigned chars.
Rellena n bytes del bloque s con el byte c. Retorna puntero genérico al bloque.
void *memset(void * s, int c, register size_t n)
{ register char * p = (char *)s;
while(n--) *p++ = (char) c;
return s;
}
Efectuar movimientos de bloques orientados al carácter es ineficiente. Por esta razón las
funciones de movimiento tratan de mover palabras.
Primero se mueven los bytes parciales de una palabra, luego se pueden mover palabras
alineadas; para finalmente, copiar los bytes presentes en la última palabra.
Estas funciones, implementadas en base a macros para mejorar la velocidad, son dependientes
del procesador. Se requiere conocer el ancho de la palabra y el ordenamiento de los bytes dentro
de la palabra (little o big endian).
Los strings asociados reflejan el ordenamiento de los bytes dentro de la palabra. Suelen
denominarse:
El siguiente texto ilustra una función de movimiento por bloques, mostrando el grado de
complejidad de estas rutinas. Se invoca a varios macros, de los cuales sólo se da el nombre.
rettype
memmove (a1const void *a1, a2const void *a2, size_t len)
{
unsigned long int dstp = (long int) dest;
unsigned long int srcp = (long int) src;
/* There are just a few bytes to copy. Use byte memory operations. */
BYTE_COPY_FWD (dstp, srcp, len);
}
else
{
/* Copy from the end to the beginning. */
srcp += len;
dstp += len;
/* There are just a few bytes to copy. Use byte memory operations. */
BYTE_COPY_BWD (dstp, srcp, len);
}
RETURN (dest);
}
4. Rutinas de conversión.
La función ocupa un buffer estático de 65 bits, lo cual permite convertir enteros de 64 bits en
secuencias binarias. Se considera en el buffer espacio para el signo y el terminador del string.
Para enteros de 16 bits, el rango de representación es: [-32768.. 32767] el cual requiere
de 5 char para representar mediante dígitos decimales.
Para enteros de 32 bits: [-2147483648 .. +2147483647] se requieren 10 chars para
dígitos.
Para enteros de 64 bits: [-9223372036854775808..+9223372036854775807] se
requieren 19 char para dígitos decimales. Para imprimir en binario se requieren 63
dígitos binarios.
El procedimiento consiste en sacar el módulo base del número, esto genera el último carácter
del número; es decir el menos significativo. Luego se divide en forma entera por la base,
quedando el resto; del cual se siguen extrayendo uno a uno los dígitos.
Por ejemplo para el entero 123 en base decimal, al sacar módulo 10 del número se obtiene el
dígito de las unidades, que es 3. Al dividir, en forma entera por la base, se obtiene el número de
decenas; es decir, 12. Sacando módulo 10 se obtiene 2; y al dividir por la base se obtiene el
número de centenas.
#define INT_DIGITOS 63
static char buf[INT_DIGITOS + 2];
/* Buffer para INT_DIGITS dígitos, signo - y fin de string '\0' */
do { dig=(i%base); if (dig <=9) *--p = '0' + dig; else *--p= '7'+ dig ; i /= base;}
while (i != 0);
Para convertir enteros se emplea la misma rutina anterior, invocando con una conversión
explícita del entero a largo.
#include <stdio.h>
int main(void)
{
int i=-31;
long int l= -2147483647L;
Si bien esta rutina efectúa el trabajo inverso de la anterior, su diseño es más complejo, ya que
debe operar con datos suministrados por un ser humano. La anterior saca algo que está en
formato interno y que está bien especificado.
En la conversión de un string en un long integer, debe asumirse que el usuario provee una
secuencia de caracteres que tiene el siguiente formato:
Los corchetes indican elementos opcionales, el asterisco la repetición de cero o más veces. De
esta forma podrían digitarse caracteres espacios o tabuladores antes de la secuencia; podría
indicarse el signo +, y también declarar números octales si el primer dígito es cero, o
hexadecimales si los primeros dígitos son 0x ó 0X.
En el diseño se decide terminar de leer cuando encuentra un carácter que no cumpla el formato.
Para permitir seguir analizando la secuencia de entrada se decide pasar a la función, además de
un puntero al inicio de la secuencia a analizar, y de la base, la referencia a un puntero a carácter.
Al salir de la función se escribe, en la variable pasada por referencia, el valor del puntero que
apunta al carácter que detuvo el scan, por no cumplir el formato. Esto debido a que en C, sólo se
puede retornar un valor desde la función.
Además la función debe resolver que el valor del número ingresado no exceda el mayor
representable. Como los números se representan con signo en complemento a la base, se tendrán
valores máximos diferentes(en la unidad) para el máximo positivo y el máximo negativo.
Por ejemplo para enteros largos de 32 bits, el rango asimétrico de representación en base
decimal es [-2147483648..2147483647].
Como se lee la secuencia de izquierda a derecha, el núcleo del algoritmo consiste en multiplicar
el número acumulado en acc, por la base y luego sumarle el valor numérico del dígito.
Por ejemplo, la secuencia 123 en decimal, es procesada según:
0*10 + 1 = 1
1*10 + 2 = 12
12*10 + 3 = 123
/*#include <limits.h>*/
#define LONG_MAX 0x7FFFFFFFL
#define LONG_MIN ((long)0x80000000L)
/*#include <ctype.h>*/
#define islower(c) (('a' <= (c) ) && ((c) <= 'z'))
#define isupper(c) (('A' <= (c) ) && ((c) <= 'Z'))
#define isdigit(c) (('0' <= (c) ) && ((c) <= '9'))
#define isspace(c) ((c) == ' ' || (c) == '\t' || (c)== '\n')
#define isalpha(c) ((islower(c))||(isupper(c)))
/*#include <errno.h>*/
#define ERANGE 34 /* Resultado demasiado grande. Rebalse de representación. */
extern int errno;
return (acc);
}
/* strtol ejemplo */
#include <stdio.h>
int main(void)
{
char *string = "87654321guena", *endptr;
long lnumber;
Resulta útil una función que convierta un número en punto flotante en una base b1 en otro
número punto flotante en base b2. Si b1 es 2 y b2 es 10, se convierte un número real en
representación interna en externa.
El algoritmo consiste en alternar las multiplicaciones (o divisiones) de m por b1, con las
divisiones (o multiplicaciones) por b2, de tal forma que m se mantenga en el rango:
Debido a que la función entrega dos valores, se decide pasar por referencia el exponente e2.
Sólo acepta bases positivas menores o iguales que 36; en caso de estar fuera de rango asume
base decimal.
Multiplica la mantisa por la base, quedando de esta forma un número a la izquierda del punto
decimal. Dicho dígito puede obtenerse en v, truncando el doble; esto se logra mediante el cast
explícito a entero. Luego se puede enviar hacia la salida el carácter, considerando una
conversión a carácter, que toma en cuenta bases mayores a la decimal.
Antes de volver a seguir desplegando caracteres, le quita la parte entera al doble; de este modo
al inicio del bloque, u siempre es un número fraccionario puro.
Nótese que no tiene sentido invocar la impresión binaria con más de 52 bits, ya que ese es el
número de bits de un doble. Tampoco tiene sentido invocar la impresión hexadecimal de la
mantisa con más de 7 cifras.
Puede verificarse que tampoco tiene sentido solicitar salidas en base decimal con más de 18
dígitos, ya que ésta es la precisión de un doble.
4.5. Rutinas más eficientes para convertir un número punto flotante binario a punto flotante
decimal.
}
/* con a de tipo float. round(2048/10) = 205. Funciona si e<1029 */
long int i;
for(i=0; i<1030; i++)
/*realiza i/10 mediante multiplicaciones enteras. Para i<1029 */
if ( ((i*205)/2048)!=(i/10)) printf( " %d \n", i);
4.5.3. Redondeo de la mantisa.
double round(double t, unsigned int i)
{ /*no puede redondear a mas de 15 cifras */
if (i<16)
switch (i)
{ case 2: t+=0.5e-2;break;
case 3: t+=0.5e-3;break;
case 4: t+=0.5e-4;break;
case 5: t+=0.5e-5;break;
case 6: t+=0.5e-6;break;
case 7: t+=0.5e-7;break;
case 8: t+=0.5e-8;break;
case 9: t+=0.5e-9;break;
case 10: t+=0.5e-10;break;
case 11: t+=0.5e-11;break;
case 12: t+=0.5e-12;break;
case 13: t+=0.5e-13;break;
case 14: t+=0.5e-14;break;
case 15: t+=0.5e-15;break;
}
return(t);
}
Una función puede tener parámetros fijos y una lista de argumentos variables. El número y tipo
de argumentos no son conocidos en el momento que se diseña la función.
El siguiente prototipo de func indica que tiene un parámetro fijo, un puntero a entero, y los tres
puntos a continuación indican que se puede pasar a esta función un número variable de
argumentos.
En la definición de la función debe existir alguna forma de conocer el número y tipo de los
parámetros variables. En la función printf el argumento fijo es el string de control que permite
determinar el número y tipo de argumentos.
Debe conocerse la estructura del frame de la función en el stack. Si suponemos que previo a la
invocación de una función se empujan los valores de los argumentos empezando por el último
argumento, se tendrá el siguiente esquema del frame, para la invocación a
func(p, a, b, c), después del llamado:
p
a
b
c
Las
direcciones
aumentan
Entonces el primer argumento variable tiene una dirección mayor que el último argumento fijo.
Como además los tamaños de los argumentos pueden ser diferentes, deben guardarse los
parámetros alineados.
* (tipo de a *) (dirección de a)
va_list es un tipo de puntero genérico; es decir puede apuntar a cualquier elemento de memoria,
no importando su tamaño. Más adelante se explicarán detalladamente los macros.
La inicialización del puntero, dentro de la función se logra con va_start, y debe usarse antes de
llamar a va_arg o va_end.
va_arg retorna el valor del argumento y mueve el puntero al inicio del siguiente argumento.
va_end desconecta el puntero, y debe emplearse una vez que va_arg haya leído todos los
argumentos.
int main(void) {
sum("El total de: 1+2+3+4 es igual a %d\n", 1, 2, 3, 4, 0);
return 0;
}
Para efectuar correctamente los movimientos del puntero, y para considerar el alineamiento en
el almacenamiento de los argumentos, se emplea el macro:
Esto considera que un entero se guarda alineado, lo cual es una definición implícita en C. Lo
rebuscado, aparentemente, permite portar el código de estos macros a procesadores con
diferentes tamaños para el entero.
El operador sizeof retorna el número de bytes de la expresión o el tipo que es su argumento.
Para enteros de 16 bits, la máscara que se forma tomando el complemento a uno, resulta ser:
…1111110. Para enteros de 32 bits, la máscara es: ….1111100.
Para 64 bits: ….1111000.
Al tamaño de x en bytes se le suma el tamaño en bytes del entero menos uno, lo cual luego es
truncado por la máscara, obteniendo el tamaño de x en múltiplos del tamaño de un entero.
El siguiente segmento permite verificar en forma enumerativa, la función del macro que entrega
direcciones alineadas:
for(t=2;t<9;t*=2)
{ printf(" t=%d \n",t);
for( i=1; i<16;i++) printf(" i=%d largo=%d\n", i, (i+t-1)&~(t-1));
La dirección del último parámetro fijo se convierte a puntero a char(a un Byte) y se obtiene la
siguiente dirección (en Bytes) donde está almacenado el primer argumento variable, mediante:
( (char *)(&parmN) +__size(parmN) )
Luego esta dirección se convierte en la del tipo de ap, y se la escribe en ap. El primer void
indica que el macro no retorna valores.
Debe notarse que cuando se emplean macros, los argumentos deben colocarse entre paréntesis.
Es un tanto más compleja. Ya que efectúa dos cosas, retornar el valor del argumento y por otro
lado incrementar el puntero ap, de tal modo que apunte al próximo argumento.
Primero incrementa el puntero, luego obtiene el valor.
La expresión *(char **)&ap podría haberse anotado más sencillamente: (char *) ap;
El siguiente es un esquema para printf. Se traen los valores de los argumentos a variables
locales.
va_start(ap, format);
for(p = format; *p ; p++) {
if( *p !='%') {putchar(*p); continue;}
switch( *++p) {
case 'd':
ival= va_arg(ap, int);
/* trae el argumento entero a la local ival */
Esta rutina es dependiente de la forma en que el compilador pasa los argumentos. Es decir si
pasa algunos argumentos en registros o los pasa en el stack. Además depende de la forma en
que el compilador almacena los diferentes tipos. Por ejemplo se ilustra una modificación a los
macros para corregir el valor de ap, en el caso que guarde alineados los dobles en direcciones
que son múltiplos de 8 Bytes. Si el tercer bit es 1, le suma 4 bytes a ap, de tal forma de alinear
correctamente al doble. Este es el caso del compilador lcc.
En la rutina se han empleado variables locales para depositar internamente los valores de los
argumentos, de acuerdo al tipo; y su objetivo es ilustrar el uso de va_arg.
Existen funciones como itoa que transforman un entero en una secuencia de caracteres.
Mediante estas funciones se puede traducir el despliegue de un entero, en determinada base, en
el despliegue de una secuencia de caracteres, lo cual se logra con putchar.
Se traen los valores de los argumentos a punteros locales a la rutina, con el fin de ilustrar el uso
de va_arg. Nótese que en este caso se traen punteros, y que puede cambiarse el valor de la
variable pasada por referencia, mediante indirección.
va_start(ap, format);
for(p = format; *p ; p++) {
if( *p !='%') continue;
switch( *++p) {
case 'd':
pi=(int *) va_arg(ap, int*);
/* trae a pi un puntero a entero */
/*acá debe ingresarse un entero y depositarlo en *pi */
break;
case 'l':
pd=(double *) va_arg(ap, double*);
/* trae a pd un puntero a doble */
/*acá debe ingresarse un doble y depositarlo en *pd */
break;
case 's':
pc=(char *) va_arg(ap, char *);
/* trae a pc un puntero a char */
/*acá debe ingresarse un string y copiarlo desde pc */
break;
}
}
va_end(ap);
}
Esta rutina debe cuidar que los caracteres ingresados correspondan a lo que se desea leer; que el
espacio asociado al string externo no sea excedido por el largo del string ingresado. También
dependerá de cómo se termine de ingresar los datos, o la acción que deberá tomarse si lo
ingresado no corresponde al tipo que se establece en el string de control.
Funciones como atoi y atof, pasan de secuencias de caracteres a enteros o flotantes; y mediante
éstas, puede implementarse scanf como una secuencia de llamados a getchar.
Deseamos dotar a los programas compilados para MIPS, mediante el compilador lcc, de rutinas
de interfaz con los llamados al sistema que SPIM provee.
El siguiente código en C, muestra el diseño de la rutina printf, empleando los macros vistos
antes, ya que printf es una función con un número variable de argumentos:
va_start(ap, format);
for(p = format; *p ; p++) {
if( *p !='%') {putchar(*p); continue;}
switch( *++p) {
case 'd':
syscall(va_arg(ap, int),1);
break;
case 'f':
if ((int)ap&4) ap=(va_list)((int)ap +4); /*align double */
syscall(va_arg(ap, double),3);
break;
case 's':
syscall(va_arg(ap, char *),4);
break;
}
}
va_end(ap);
}
La traducción de syscall se traduce en pasar en $a0 y $a1 los argumentos y luego efectuar un:
jal syscall.
Debido a que el compilador lcc, introduce en el stack los dobles alineados en palabras dobles, se
requiere forzar el alineamiento en los casos que sea necesario.
la $a0,formato
la $a1,5
l.d $f18,doble #carga en registro un valor doble
mfc1.d $a2, $f18 #mueve a $a2 y $a3 el valor
la $t8, stringhola
sw $t8,16($sp) #pasa el puntero en el stack.
jal printf
.data
.align 0
formato: .asciiz " entero= %d doble= %f string=%s \n"
.align 3
doble:
.word 0x2843ebe8
.word 0x40280000
.align 0
stringhola: .asciiz "hola"
.rdata
.align 2
_sss: .word 0 # espacio para almacenar un char terminado en \0.
.globl putchar
.text
.align 2
putchar: la $t8,_sss
sw $a0,0($t8)
.globl printf
#void printf(char * format,...)
.text
.align 2
printf:
.frame $sp,32,$31
addu $sp,$sp,-32
.mask 0xc0800000,-8
sw $s7,16($sp)
sw $s8,20($sp)
sw $ra,24($sp)
sw $a0,32($sp)
sw $a1,36($sp)
sw $a2,40($sp)
sw $a3,44($sp)
#{
la $t8,4+32($sp)
sw $t8,-4+32($sp) # va_start(ap, format);
lw $s8,0+32($sp) # for(p = format; *p ; p++) {
b _tstcnd
_esf:
lw $t8,-4+32($sp) # if ((int)ap&4) ap=(va_list)((int)ap +4);
and $t8,$t8,4
beq $t8,$0,_nosuma
lw $t8,-4+32($sp)
la $t8,4($t8)
sw $t8,-4+32($sp)
_nosuma:lw $t8,-4+32($sp) # syscall(va_arg(ap, double),3);
la $t8,8($t8)
sw $t8,-4+32($sp)
l.d $f12,-8($t8) #pasa eldoble en reg. doble $f12
la $v0,3 # la $a1,3
syscall # jal syscall
b _siga2 # break;
_siga1: # break;
_siga2:
# }
sw $0,-4+32($sp) # va_end(ap);
#}
lw $s7,16($sp)
lw $s8,20($sp)
lw $ra,24($sp)
addu $sp,$sp,32
j $ra
.end printf
li $a1,largo #
li $v0, 8 # system call code for read_str
la $a0, str # buffer of string to read
syscall # read the string
*/
va_start(ap, format);
for(p = format; *p ; p++) {
if( *p !='%') continue;
switch( *++p) {
case 'd':
pi=(int *) va_arg(ap, int*);
*pi=syscall(5);
/*printf(" *pi=%d \n", *pi);*/
Los syscall generan jal syscall, que deben parcharse, y también el paso de los argumento a esos
llamados.
.rdata
.align 2
_sss: .word 0
.globl getchar
.text
.align 2
getchar:
la $a0,_sss # syscall(pc,2,8);
li $a1,2
la $v0,8
syscall
la $t8,sss
lw $v0,0($t8)
j $ra
.globl scanf
.text
#void scanf(char * format,...)
.text
.align 2
scanf:
.frame $sp,56,$31
addu $sp,$sp,-56
.mask 0xc0e00000,-24
lw $s8,0+56($sp)
b _tstscn
_blkscn:lb $t8,($30)
la $t7,37 # '%'
beq $t8,$t7,_swscn
b _cntscn
# }
sw $0,-4+56($sp) # va_end(ap);
#}
lw $s5,16($sp)
lw $s6,20($sp)
lw $s7,24($sp)
lw $s8,28($sp)
Si estas rutinas se agregan al código del trap.handler de SPIM, pueden emplearse llamados a
printf, scanf, getchar y putchar, sin tener que incluir dichos códigos junto a los programas
fuentes.
Puede implementarse printf en términos de putchar. Es decir una función que saca un carácter
hacia el medio de salida. La implementación de putchar suele efectuarse programando la puerta
serial de un microcontrolador, para esto bastan muy pocas instrucciones (no más de 10).
#include <stdarg.h>
int printf(const char *format, ...)
{
va_list ap;
int retval;
va_start(ap, format);
va_end(ap);
return retval;
}
Se ilustra una rutina bajada de la red (de la cual perdí la referencia), con pequeñas
modificaciones que implementa printf. Lo cual muestra que la función printf está formada por
numerosas instrucciones, lo cual debe ser tenido en cuenta al emplearla en microcontroladores.
En estos casos existen versiones recortadas, mediante la no implementación de algunos
formatos.
/*
* This version only supports 32 bit floating point
*/
#define value long
#define NDIG 12 /* máximo número de dígitos ha ser impresos */
#define expon int
const static unsigned value
dpowers[] = {1, 10, 100, 1000, 10000, 100000L, 1000000L,
10000000L,10000000L,100000000L};
const static unsigned value
hexpowers[] = {1, 0x10, 0x100, 0x1000,0x10000L, 0x100000L,0x1000000L, 0x10000000L};
/* this routine returns a value to round to the number of decimal places specified */
double fround(unsigned char prec)
{ /* prec is guaranteed to be less than NDIG */
if(prec > 10) return 0.5 * npowers[prec/10+9] * npowers[prec % 10];
return 0.5 * npowers[prec];
}
/* this routine returns a scaling factor equal to 1 to the decimal power supplied */
static double scale(expon scl)
{ if(scl < 0) { scl = -scl;
if(scl > 10) return npowers[scl/10+9] * npowers[scl%10];
return npowers[scl];
}
if(scl > 10) return powers[scl/10+9] * powers[scl%10];
return powers[scl];
}
struct __prbuf
{ char * ptr;
void (* func)(char);
} pb;
/* mini test */
int main(void)
{ int x=15, y=2678; float f=3.2e-5;
return(0);
}
Al disponer del código fuente, éste puede adaptarse a las necesidades del usuario. En caso de ser
empleado en un microcontrolador, con el objeto de disminuir la memoria ocupada por printf, se
pueden recortar algunos modos que no se requieran.
6.1. Trigonométricas.
plot(sin(x), x=0..2*Pi);
Si efectuamos el cambio de variable, w = x/2*Pi, tendremos:
plot(sin(2*Pi*w),w=0..1); cuya gráfica se ilustra a continuación:
Después de este cambio de variables, los valores del argumento estarán acotados. De esta forma
cuando se calcule con valores reales elevados, éstos se reducen a valores entre 0 y 4, y no se
producirán errores cuando se calculen las potencias del argumento al evaluar la serie.
Si efectuamos: m= 4*(w-floor(w)) las variaciones de m serán en el intervalo entre 0 y 4,
cuando w cambia entre cualquier inicio de un período hasta el final de ese período.
Entonces para todos los reales positivos (representables) de w, se puede calcular en el primer
período, para valores de m entre 0 y 4:
plot( sin(2*Pi*m/4 ), m=0..4);
Puede compararse la aproximación por series de potencia (de dos y tres términos) con el
polinomio de Pade, mediante:
plot([x-x^3/6,x-x^3/6+x^5/120, pade(sin(x),x=0,[9,6])], x=0.7..1.7,
y= 0.65..1,color=[red,blue,black], style=[point,line,point]);
Es preciso calcular polinomios, puede emplearse la función estándar poly, descrita en math.h
Si por ejemplo se desea calcular:
p(x) = d[4]*(x**4)+d[3]*(x**3)+d[2]*(x**2)+d[1]*(x)+d[0]
Con algoritmo: poli=d[n]; i=n; while (i >0) {i--; poli = poli* x + d[ i-1]};
Debido a que los polinomios son de potencias pares en el denominador, se efectúa el reemplazo
x por x*x. Y para obtener potencias impares en el numerador se multiplica el polinomio del
numerador por x.
#include <math.h>
/*Calcula para n=4 el polinomio: d[4]*(x**4)+d[3]*(x**3)+d[2]*(x**2)+d[1]*(x)+d[0] */
double eval_poly(register double x, const double *d, int n)
{ int i;
register double res;
res = d[i = n];
while ( i ) res = x * res + d[--i];
return res;
#define PI 3.14159265358979
#define TWO_PI 6.28318530717958
double seno(double x)
{ static const double coeff_a[] = { 207823.68416961012, -76586.415638846949,
7064.1360814006881, -237.85932457812158, 2.8078274176220686 };
static const double coeff_b[] = { 132304.66650864931, 5651.6867953169177,
108.99981103712905, 1.0 };
register double signo, x2;
signo = 1.0;
if(x < 0.0) { x = -x; signo = -signo; } /*Solo argumentos positivos */
x /= TWO_PI; x = 4.0 * (x - floor(x));
if(x > 2.0) { x -= 2.0; signo = -signo;}
if( x > 1.0) x = 2.0 - x;
x2 = x * x;
return signo * x * eval_poly(x2, coeff_a, 4) / eval_poly(x2, coeff_b, 3);
}
Empleando Mapple puede obtenerse el polinomio de Pade, que aproxima a la función seno.
with(numapprox):
pade(sin(x), x=0, [9,6]);
24391.323500544000+1034.1371819921864*x^2+19.695328959656098*x^4+
.17656643195797582*x^6
evalf(expand(numer(a)/10^11),17); Calcula el numerador, dividido por 10^11, con 17 cifras.
38313.801360320554*x-14131.500385136448*x^3+1306.7304862998132*x^5-
44.226197226558042*x^7+.52731372638787005*x^9
La función floor está basada en el truncamiento de la parte fraccionaria del número real.
Si se tiene: Double d, t ;
Entonces t = (double)(long)(d); es el número truncado, con parte fraccionaria igual a cero.
Primero el molde (long) transforma d a un entero, luego el molde o cast (double) transforma ese
entero a doble.
Si la cantidad de cifras enteras de un double, no pueden ser representadas en un entero largo, la
expresión será errónea. Por ejemplo si el entero largo tiene 32 bits, si las cifras enteras del doble
exceden a 231 -1 se tendrá error.
double fabs(double d)
{ if(d < 0.0) return -d; else return d; }
En ocasiones resulta conveniente tener acceso a las representaciones internas de los números.
Los programas de este tipo deben considerar el ordenamiento de los bytes dentro de la palabra
de memoria; es decir si son de orden big-endian o little endian.
Estudiaremos varias alternativas de tratamiento. Desde la más simple de interpretar los bytes
dentro de la palabra, pasando por interpretar los enteros largos que constituyen una palabra
mayor; a métodos más generales que emplean uniones y campos; esta última no se recomienda
ya que es dependiente de la implementación del compilador.
Dado un número real de doble precisión x, la función frexp calcula la mantisa m (como un real
de doble precisión) y un entero n (exponente) tal que:
Como las funciones, en el lenguaje C, sólo retornan un valor, y si éste es el de la mantisa, debe
pasarse un segundo argumento por referencia: la dirección de un entero; y la función devolverá
el exponente escrito en el entero.
Por esta razón el segundo argumento es un puntero a entero. El valor de la mantisa es el valor
retornado por la función.
Entonces el número real que debe retornar la función, como mantisa mayor que un medio y
menor que uno es:
mantisa = (-1)S 1.M2 2 -1
Para flotantes de simple precisión, que empleen 32 bits, se dedican 8 bits al exponente, el
mayor positivo en complemento a dos es, en decimal, 127; que equivale a 01111111 en binario.
El número más negativo, -127, se representa en complemento a dos como: 10000001,
cumpliéndose que, para este número, la representación interna es: 00000000. La polarización
para tipo float es 127, en decimal.
Para reales de precisión doble, se emplean 64 bits, y 11 para el exponente; en este caso la
polarización es 1023 en decimal, con representación binaria complemento a dos: 01111111111
(0x3FF en hexadecimal).
Los últimos 7 bits del primer byte (*pc & 0x7F) son los primeros siete del exponente (ya que el
primero se emplea para el signo del número).
Los primeros 4 bits del segundo, son los últimos 4 del exponente interno. Para esto es preciso
desplazar en forma lógica, en cuatro bits, esto se logra con: *ps>>4.
Para formar el exponente interno se requiere desplazar, en forma lógica, los primeros siete bits
en cuatro posiciones hacia la izquierda.
Entonces: ei = (*pc & 0x7F)<<4 | (*ps>>4) forma el exponente interno, como una secuencia
binaria de 11 bits. Al depositarlo en un entero sin signo, los primeros bits quedan en cero
(desde el doceavo hasta el largo del entero).
Finalmente, se logra:
exponente = ei –1022;
Para sobrescribir el número 0x3FE, en las posiciones en que va el exponente interno, se requiere
modificar los últimos siete bits del primer byte, para no alterar el signo del número. Esto se
logra haciendo un and con la máscara binaria 10000000(0x80) y luego un or con la máscara
binaria 00111111(0x3F)
Es decir:
*pc = (*pc & 0x80) | 0x3F;
Para el segundo byte, sólo se deben sobrescribir los primeros cuatro. Esto se logra haciendo un
and con la máscara binaria 00001111(0x0F) y luego un or con la máscara binaria
11100000(0xE0)
Es decir:
*ps = (*ps & 0x0F) | 0xE0;
Para apuntar a los primeros dos bytes, debe conocerse el orden de los bytes dentro de las
palabras de la memoria. Esto es dependiente del procesador. En algunos sistemas el primer byte
(el más significativo dentro del double) tiene la dirección menor, en otros es la más alta.
Como casi todos los tipos de datos que maneja un procesador suelen ser múltiplos de bytes,
para obtener la dirección de una variable de cierto tipo (en este caso de un double) en unidades
de direcciones de bytes puede escribirse:
Luego de esto, considerando que un double está formado por 8 bytes se tiene: pc += 7; ps=pc-1;
para sistemas en que el byte más significativo tiene la dirección de memoria más alta.
O bien: ps = pc +1; si el byte más significativo tiene la dirección menor; en este caso, no es
preciso modificar pc.
ee = ei -1023; ei = ee + 1023
El exponente interno se está considerando de 11 bits, sin signo. En la norma IEEE 754 debe
considerarse números con signo. Para los dos últimos casos esto implica ei = -1.
Entonces con las definiciones:
unsigned long int *pm2=(unsigned long int *)&number;
unsigned long int *pm1=pm2+1;
Podemos apuntar con pm1 al entero largo más significativo, donde se almacena el signo, los 11
bits del exponente y 4 bits de la mantisa.
Si se deseara manipular el exponente interno como número con signo, habría que definir:
int ei=(int) ( ( ( long int)((*pm1)<<1)) >>21);
Se corre a la derecha el largo con signo, y luego se convierte a entero.
Las siguientes definiciones, nos permiten extraer la parte más significativa de la mantisa en m1
(20 bits), y la menos significativa en m2:
unsigned long m1=(*pm1)&0x000FFFFFL;
unsigned long m2=*pm2;
Setear el exponente externo en -1, para tener mantisa decimal que cumpla:
0.5 =< m < 1
se logra, como se explico antes, dejando el exponente interno en: 01111111110 (0x3FE).
Lo cual se logra con: ((*pm1)&0x800FFFFFL)| 0x3FE00000L
Nótese que la misma rutina que no trata los casos subnormales y el cero, podría escribirse:
Que equivale al comportamiento de la primera rutina que manipulaba los bytes del double.
En el ejemplo siguiente, la union denominada buffer, puede verse como un double o como una
estructura denominada pbs. Las variables anteriores tienen la misma dirección de memoria, y se
accesan de manera similar a una estructura. Si se escribe en una variable, se modifica la otra.
La estructura pbs, define 64 bits, el mismo tamaño que el double. Y permite identificar los dos
bytes más significativos del double, b0 y b1, en caso de que el byte más significativo esté
ubicado en la dirección menor. Y b6 y b7 si el más significativo del double está asociado a la
dirección mayor.
union buf
{ struct bts
{unsigned char b0;
unsigned char b1;
unsigned char b[4];
unsigned char b6;
unsigned char b7; /*el más significativo con dirección mayor*/
} pbs;
double d;
} buffer;
El valor +2.0 en doble precisión, equivale al valor 0x40000000 en formato IEEE 754. El signo
es cero, la mantisa normalizada es cero. Y el exponente externo es +1.
Y leer los bytes de la unión, accesando por su nombre los bytes de la estructura pbs.
if (buffer.pbs.b7==0x40) printf("el byte más significativo del double tiene la dirección
mayor\n");
if (buffer.pbs.b0==0x40) printf("el byte más significativo del double tiene la dirección
menor\n");
Entonces en el ejemplo siguiente, dentro de la unión buffer, se tiene la estructura pbs, que a su
vez está formada por la estructura campos1, el arreglo de 4 caracteres b, y la estructura
union buf
{ struct bts
{ struct campos1
{ unsigned int signo1 :1;
unsigned int exp1 :11;
unsigned int man1 :4;
} pc1;
unsigned char b[4];
struct campos2
{ unsigned int man2 :4;
unsigned int exp2 :11;
unsigned int signo2 :1; /*el byte más significativo con dirección mayor*/
} pc2;
} pbs;
double d;
} buffer;
Y leer los grupos de bits de la unión, accesando por su nombre los campos de la estructura pbs.
if (buffer.pbs.pc2.exp2==0x400) printf("el byte más significativo del double tiene la
dirección mayor\n");
if (buffer.pbs.pc1.exp1==0x400) printf("el byte más significativo del double tiene la
dirección menor\n");
return i;
}
Referencias.
Índice general.
APÉNDICE 2 .............................................................................................................................................. 1
INTRODUCCIÓN AL LENGUAJE C. ................................................................................................... 1
1. FUNCIONES. .......................................................................................................................................... 1
1.1. Abstracción de acciones y expresiones. ....................................................................................... 1
1.2. Prototipo, definición, invocación. ................................................................................................ 2
1.3. Alcances del lenguaje C. .............................................................................................................. 3
1.4. Paso de argumentos por valor. .................................................................................................... 4
1.5. Paso por referencia. ..................................................................................................................... 4
1.6. Frame. .......................................................................................................................................... 5
1.7. Algunos conceptos básicos ........................................................................................................... 6
1.7.1. Datos. .................................................................................................................................................... 6
Enteros con signo. ....................................................................................................................................... 6
Enteros sin signo. ....................................................................................................................................... 8
Enteros Largos. ........................................................................................................................................... 8
Largos sin signo. ......................................................................................................................................... 8
Números Reales. ( float ) ............................................................................................................................ 8
Carácter. ...................................................................................................................................................... 9
Strings. ........................................................................................................................................................ 9
1.7.2. Acciones. ............................................................................................................................................... 9
Secuencia. ................................................................................................................................................... 9
Alternativa. ............................................................................................................................................... 10
Repetición. ................................................................................................................................................ 10
For............................................................................................................................................................. 10
Abstracción. .............................................................................................................................................. 10
1.7.3. Entrada. Salida. .................................................................................................................................... 10
Ejemplos. .................................................................................................................................................. 11
2. TIPO CHAR. ......................................................................................................................................... 12
2.1. Valores. ...................................................................................................................................... 12
2.1. Definición de variables y constantes de tipo char. ..................................................................... 12
2.2. Caracteres ASCII. ...................................................................................................................... 12
2.3. Secuencias de escape.................................................................................................................. 14
2.4. Archivos de texto y binarios. ...................................................................................................... 14
2.5. Expresiones. ............................................................................................................................... 15
2.6. Entrada-Salida ........................................................................................................................... 16
Entrada y salida con formato. ........................................................................................................................ 17
2.7. Funciones. .................................................................................................................................. 18
2.8. Macros........................................................................................................................................ 19
2.9. Macros con argumentos. ............................................................................................................ 21
2.10. Biblioteca. ctype.c .................................................................................................................. 22
3. STRINGS.............................................................................................................................................. 24
3.1. Definición de string. ................................................................................................................... 24
3.1.1. Arreglo de caracteres. .......................................................................................................................... 24
3.1.2. Puntero a carácter................................................................................................................................. 25
3.2. Strcpy.......................................................................................................................................... 25
3.3. Strncpy........................................................................................................................................ 27
Índice de figuras.
Apéndice 3
Un lenguaje está basado en un vocabulario, o léxico, el que está compuesto por palabras, o
más precisamente por símbolos.
Ciertas secuencias de palabras son reconocidas como sintácticamente bien formadas o correctas.
La gramática o sintaxis o estructura del lenguaje queda descrita por una serie de reglas o
fórmulas que definen si una secuencia de símbolos es una sentencia correcta. La estructura de
las sentencias establece el significado o semántica de ésta.
Ejemplo.
<sentencia> ::= <sujeto><predicado>
<sujeto>::= árboles|arbustos
<predicado>::=grandes|pequeños
Una sentencia bien formada puede ser derivada a partir del símbolo de partida, <sentencia> en
el caso del ejemplo, por la repetida aplicación de reglas de reemplazo o producciones (reglas
sintácticas). Se denominan símbolos no terminales, o categorías sintácticas, a las sentencias
definidas entre paréntesis de ángulo, que figuran a la derecha en las producciones; los símbolos
terminales (vocabulario) figuran a la derecha de las producciones y se representan a sí mismos.
Estas reglas para definir lenguajes se denomina formulismo de Backus-Nauer. Los paréntesis
de ángulo, y los símbolos ::= (que se lee: puede ser reemplazado por) y | (que se lee como: o
excluyente) son denominados metasímbolos.
Las producciones son libres al contexto, si en éstas figura a la izquierda un solo símbolo no
terminal S, que puede ser reemplazado en función de símbolos terminales s, no importando el
contexto en el que ocurra S.
Cada paso del análisis está basado solamente en el siguiente símbolo de la secuencia de
símbolos no terminales que se está analizando.
S::=aA
A::=b|cA
No es necesario volver atrás, y el análisis está basado en la lectura de un símbolo terminal por
adelantado.
Para que esto sea posible, los símbolos iniciales de símbolos no terminales alternativos que
figuran a la derecha en las producciones, deben ser diferentes.
El siguiente ejemplo ilustra reglas que no cumplen el principio anterior. Ya que A y B (símbolos
no terminales alternativos en S), tienen iguales símbolos iniciales, ambos son x.
S::=A|B
A::=xA|y
B::=xB|z
Si se desea analizar la secuencia xxxz, se tendrá la dificultad que no es posible discernir (sólo
leyendo el primer símbolo por adelantado) si S debe ser reemplazado por A o por B. Si se
eligiera al azar, reemplazar S por A, luego de unos pasos el análisis falla, y se debería volver
atrás, e intentar reemplazar por B, en lugar de A, y volver a intentar.
Las reglas:
S::=C|xS
C::=y|z
Para una secuencia A que genera la secuencia nula, los símbolos iniciales que genera A deben
se disjuntos con los símbolos que siguen a cualquier secuencia generada por A.
La repetición de construcciones, que también es muy frecuente en los lenguajes, suele definirse
empleando recursión.
Por ejemplo la repetición de una o más veces del elemento B, puede anotarse:
A::= B|AB
Esta definición sólo simplifica la notación, pero aún es preciso revisar que se cumpla la segunda
regla, para emplear algoritmos basados en leer un símbolo por adelantado y sin volver atrás.
Símbolo terminal.
Donde stream es el descriptor del archivo que contiene el texto que será analizado. La función
lee, trae el siguiente carácter. En este nivel los caracteres individuales del texto se consideran
símbolos terminales. La función de error, debería generar un mensaje asociado a la detección de
una sentencia mal formada.
Símbolo no terminal.
Alternativa.
La producción: A::=B1|B2|…|Bn
Se representa:
B1
A
B2
Bn
switch (ch){
case L1: B1(); break;
case L2: B2(); break;
….
case Ln: Bn(); break;
}
Donde los Li serían los conjuntos de los símbolos iniciales de los Bi.
b1 B1
A
b2 B2
bn Bn
switch (ch){
case „b1‟: {ch=lee(stream); B1(); break;}
case „b2‟: {ch=lee(stream); B2(); break;}
….
case „bn‟: {ch=lee(stream); Bn(); break;}
}
Concatenación.
La producción: A::=B1B2…Bn
Se representa:
A
B1 B2 Bn
Figura A3.6. Concatenación.
Repetición.
La producción: A::={B}
Se representa:
En el reconocedor, se implementa:
while( esta_en(L, ch) ) B( );
B b
La repetición, de a lo menos una vez, puede implementarse con una sentencia while.
Cada uno de los bloques anteriores puede ser reemplazado por alguna de las construcciones
anteriores. Por ejemplo: la repetición puede ser una serie de acciones concatenadas.
Resumen.
Para una gramática dada, pueden construirse grafos sintácticos a partir de las producciones
descritas en BNF, y viceversa.
Los grafos deben cumplir las siguientes dos reglas, para que se puedan recorrer leyendo un
símbolo por adelantado y sin volver atrás.
Los primeros símbolos en las alternativas deben ser diferentes. De tal forma que la
bifurcación solo pueda escogerse observando el siguiente símbolo de esa rama.
Si un grafo reconocedor de una sentencia A, puede generar la secuencia nula, debe
rotularse con todos los símbolos que puedan seguir a A. Ya que ingresar al lazo puede
afectar el reconocimiento de lo que viene a continuación.
Una vez definido el lenguaje a reconocer, mediante sus grafos, debe verificarse el cumplimiento
de las dos reglas anteriores. Un sistema de grafos que cumplan las reglas anteriores se denomina
determinista y puede ser recorrido sin volver atrás y solo leyendo un símbolo por adelantado.
Pueden plantearse los grafos sintácticos para cada una de las producciones. Posteriormente, es
posible reducir los grafos, mediante substituciones. Luego de esto se obtiene:
A
( A )
A +
La producción que genera la secuencia nula tiene intersección vacía del primer elemento de la
repetición y del símbolo que sigue a esa repetición:
{ „+„ } { „)‟ } =
Se emplea una variable global ch, para disminuir el número de argumentos. Se destaca que debe
leerse un símbolo por adelantado, antes de invocar al reconocedor.
Si el descriptor del archivo se deja como variable global, pueden disminuirse aún más los
argumentos de las funciones, simplificándolas.
#include <stdio.h>
void error(int e)
{ printf("Error %d\n", e);}
char ch='\0';
fclose(stream);
return 0;
}
int main(void)
{
parser();
return 0;
}
Los símbolos terminales requeridos por las reglas se han colocado entre comillas simple. Nótese
que cada producción termina en el carácter punto.
La serie de producciones se termina cuando se encuentra un asterisco:
<texto de programa> ::= {producción} „*‟
El parser genera algunos comentarios de error, hacia la salida estándar, indicando la línea y la
posición del carácter que no cumple las reglas.
#include <stdio.h>
#include <stdlib.h> //malloc
#include <string.h>
#include <ctype.h>
char simbolo='\0';
int nl=1; //contador de líneas
int nc=0; //contador de caracteres en la línea.
void getch(void)
{
if(!feof(stream))
{ simbolo = fgetc(stream); nc++;
if(símbolo == '\n') {nl++; nc=0;}
putchar(simbolo); //eco en salida estándard
}
}
void getsimbolo(void)
{
getch();
while(isspace(simbolo)) getch(); //descarta blancos
}
int main(void)
{
bnfparser();
return 0;
}
A = C.
B=x,A.
B=x,A,B,C-
C=x(B,D.
D=(A).
*
C=x(B,D.
(4,8): Esperaba cierre paréntesis
D=(A).
*fin de archivo número de líneas =5
Un identificador es una secuencia de caracteres, donde el primero debe ser letra, y los que
siguen letras o números.
Un identificador puede aparecer entre espacios, tabs, retornos; o estar entre caracteres no
alfanuméricos. Si ab y cd son identificadores, las siguientes líneas muestran posibles instancias
de éstos:
ab
(ab + cd)
ab= cd;
ab = cd+5;
El diagrama de estados de la Figura A3.10, muestra que deben descartarse los espacios (se
simboliza por el círculo 0), y comenzar a almacenar el identificador, cuando se encuentra una
letra; luego se siguen almacenado los caracteres del identificador (círculo 1) hasta que llegue un
carácter no alfanumérico, en que se vuelve a esperar identificadores.
Si es letra
Si es espacio
Si es alfanumérico
0 1
No es letra
Si no es alfanumérico
Asumiendo que se tiene una línea almacenada en buffer, la siguiente función forma en el string
id, el identificador.
La estructura del código está basada en el diagrama anterior, y en las funciones cuyos prototipos
se encuentran en ctype.h
#define LARGOLINEA 80
#define Esperando_letra 0
#define Almacenando_id 1
alfanum
6 5 isspace
!alfanum
alfanum
!alfanum
es alfanumérico id ==”define”
3 4
2
alfanum
Si es letra Si es alfanumérico
Si es espacio
0 1
No es letra ni #
Si no es alfanumérico
int main(void)
{ makenull();
procesa_archivos();
return 0;
}
En la etapa de prueba de los algoritmos es necesario ingresar datos. Si éstos son numerosos es
recomendable efectuar esta operación leyendo los datos desde un archivo.
Es importante saber el número y tipos de datos de los ítems de cada línea, ya que debe
confeccionarse un string de formato con dicha estructura.
Veamos un ejemplo.
Se tiene un entero y un carácter por línea, que configuran un dato con la siguiente estructura:
struct mystruct
{
int i;
char ch;
};
La confección con un editor, podría generar el siguiente texto, donde los espacios previos al
número pueden ser reemplazados por varios espacios o tabs. La separación entre el número y el
carácter debe ser un espacio o un tab. Luego pueden existir varios espacios o tabs, seguidos de
un retorno.
11 a
22 b
333 c
4d
5e
El siguiente segmento abre para escritura el archivo testwr.txt, que debe estar ubicado en el
mismo directorio que el programa ejecutable; en caso de otra ubicación, es preciso preceder el
nombre con el sendero de la ruta.
El modo “w” establece modo escritura, es decir sobreescribe si el archivo existe, y lo crea en
caso contrario.
La siguiente instrucción escribe en el stream 4 caracteres por línea, más el terminador de línea,
eol; que suele ser uno o dos caracteres. La estructura de la línea: dd<sp>c<eol>.
Una vez completada la escritura de todas las líneas, se cierra el archivo, mediante:
fclose(stream);
Se incluye un programa para leer el archivo, ya sea generado con un editor o por un programa,
mediante la función fscanf. Se ha usado la función feof, para determinar si se llegó al final del
archivo. La acción que se realiza con los datos es simplemente desplegar en la pantalla, los
datos formateados.
#include <stdio.h>
//El archivo testwr.txt debe estar estructurado en líneas.
//Cada línea debe estar formateada según: <sep>entero<sep>char<eol>
//Donde <sep> pueden ser espacios o tabs.
//El entero debe ser de dos dígitos, si en el string de formato del fscanf figura como %2d.
FILE *stream;
int main(void)
{ int jj; char cc;
Si se intenta leer más allá del fin de archivo la función feof, retorna verdadero.
#include <stdio.h>
//El archivo testwr.txt debe estar estructurado en lineas.
//Cada linea debe estar formateada según: <sep>entero<sep>char<eol>
//Donde <sep> pueden ser espacios o tabs.
//El entero debe ser de dos dígitos, si en el string de formato del fscanf figura como %2d.
struct mystruct
{
int i;
char ch;
};
#define ITEMS 20
struct mystruct arr[ITEMS];
FILE *stream;
int main(void)
{
int i, jj; char cc;
/* Abre stream para lectura, en modo texto. */
if ((stream = fopen("testwr.txt", "r")) == NULL) {
fprintf(stderr, "No pudo abrir archivo de entrada.\n");
return 1;
}
for(i=0; i< ITEMS; i++)
{
fscanf(stream, "%d %c", &jj, &cc); //lee variables según tipo.
if(feof(stream)) break;
arr[i].i=jj; arr[i].ch=cc; //llena items del arreglo
}
for(i=0;i< ITEMS;i++)
{
printf("%d %c\n", arr[i].i, arr[i].ch);// muestra el arreglo
}
return 0;
}
#include <stdio.h>
struct mystruct
{
int i;
char ch;
};
int main(void)
{
FILE *stream;
struct mystruct s;
int j;
/* sobreescribe y lo abre para escritura o lectura en modo binario */
if ((stream = fopen("TEST.bin", "w+b")) == NULL) {
fprintf(stderr, "No se puede crear archivo de salida.\n");
return 1;
}
for(j=0;j<20;j++)
{
s.i = j;
s.ch = 'A'+j;
fwrite(&s, sizeof(s), 1, stream); /* write struct s to file */
}
El archivo TEST.bin, no puede ser visualizado con un editor de texto. Para su interpretación
debe usarse un editor binario.
Para programas sencillos, como los ilustrados, puede generarse el ejecutable en ambiente UNIX,
mediante el comando: make <nombre de archivo c, sin extensión>
int lee_archivo(void)
{
FILE *stream;
/* Abre stream para lectura, en modo texto. */
if ((stream = fopen("input.txt", "r")) == NULL) {
fprintf(stderr, "No pudo abrir archivo de entrada.\n");
return 1;
}
while(!feof(stream)) /* lee hasta encontrar el final del stream */
{
fgets(buffer, LARGOLINEA, stream); //carga buffer
if(!feof(stream))
{
int escribe_archivo(void)
{
FILE *stream;
Referencias.
APÉNDICE 3 .............................................................................................................................................. 1
INTRODUCCIÓN A LA ESTRUCTURA Y OPERACIÓN DE ANALIZADORES LÉXICOS. ...... 1
A3.1. ESTRUCTURA DE UN LENGUAJE DE PROGRAMACIÓN. ...................................................................... 1
A3.2. ANALIZADOR LÉXICO. (PARSER) ..................................................................................................... 2
A3.3. REGLAS DE ANÁLISIS. ..................................................................................................................... 4
Símbolo terminal. ................................................................................................................................ 5
Símbolo no terminal. ........................................................................................................................... 5
Alternativa. .......................................................................................................................................... 5
Concatenación. .................................................................................................................................... 6
Repetición. ........................................................................................................................................... 7
Resumen. ............................................................................................................................................. 7
EJEMPLO A3.1. RECONOCEDOR SIMPLE. ................................................................................................... 8
EJEMPLO A3.2. PARSER BNF. ............................................................................................................... 10
EJEMPLO A3.3. RECONOCEDOR DE IDENTIFICADOR. .............................................................................. 13
EJEMPLO A3.4. RECONOCEDOR DE UNA DEFINICIÓN. ............................................................................ 15
A3.4. MANIPULACIÓN DE ARCHIVOS EN C. ............................................................................................. 18
Escritura de archivos de texto, con estructura de líneas. .................................................................. 19
Escritura de archivo, desde un programa. ........................................................................................ 19
Lectura de archivos de texto con estructura de líneas. ..................................................................... 20
Llenar un arreglo a partir de un archivo. ......................................................................................... 21
Escritura y lectura de archivos binarios. .......................................................................................... 22
Compilación y ejecución en ambiente UNIX. .................................................................................... 23
Escritura y lectura de archivos por líneas. ....................................................................................... 23
REFERENCIAS. ........................................................................................................................................ 24
ÍNDICE GENERAL. ................................................................................................................................... 25
ÍNDICE DE FIGURAS................................................................................................................................. 25
Índice de figuras.