Tabla de Contenidos
Tabla de Contenidos
Tabla de Contenidos
Create successful ePaper yourself
Turn your PDF publications into a flip-book with our unique Google optimized e-Paper software.
<strong>Tabla</strong> <strong>de</strong> <strong>Contenidos</strong><br />
1. Estructuras <strong>de</strong> Datos Simples 3<br />
1.1. Stacks y Colas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3<br />
1.2. Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6<br />
1.3. Listas Doblemente Ligadas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14<br />
1.4. Representación <strong>de</strong> Árboles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17<br />
1.5. Implementación <strong>de</strong> Punteros y Objetos . . . . . . . . . . . . . . . . . . . . . . . . . . 20<br />
1.6. Definición Algebráica <strong>de</strong> una Estructura <strong>de</strong> Datos . . . . . . . . . . . . . . . . . . . 20<br />
2. Algoritmos <strong>de</strong> Or<strong>de</strong>nación 21<br />
2.1. Algoritmos Simples <strong>de</strong> Or<strong>de</strong>nación . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22<br />
2.2. Heapsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26<br />
2.3. Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29<br />
2.4. Or<strong>de</strong>nación en Tiempo Esperado Lineal . . . . . . . . . . . . . . . . . . . . . . . . . 34<br />
2.5. Estadísticas <strong>de</strong> Or<strong>de</strong>n . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34<br />
3. Estructuras <strong>de</strong> Datos para Diccionarios 35<br />
3.1. <strong>Tabla</strong>s <strong>de</strong> Hash . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35<br />
3.2. Árboles Binarios <strong>de</strong> Búsqueda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35<br />
3.3. Árboles AVL . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35<br />
3.4. Árboles Rojo–Negro . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35<br />
3.5. SkipLists . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35<br />
3.6. Aumento <strong>de</strong> una Estructura <strong>de</strong> Datos . . . . . . . . . . . . . . . . . . . . . . . . . . 35<br />
4. Estructuras <strong>de</strong> Datos Externas 37<br />
4.1. In<strong>de</strong>xación Secuencial . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37<br />
4.2. Árboles–B . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37<br />
1
2 TABLA DE CONTENIDOS<br />
5. Otras Estructuras <strong>de</strong> Datos 39<br />
5.1. Estructuras para Conjuntos Disjuntos . . . . . . . . . . . . . . . . . . . . . . . . . . 39<br />
5.2. Heaps Binomiales . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39<br />
5.3. Heaps <strong>de</strong> Fibbonacci . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39<br />
6. Técnicas Fundamentales <strong>de</strong> Diseño <strong>de</strong> Algoritmos 41<br />
6.1. Analisis Amortizado . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41<br />
6.2. Algoritmos Codiciosos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41<br />
6.3. Divir para Conquistar . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41<br />
6.4. Programación Dinámica . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41<br />
7. Algoritmos en Grafos 43<br />
7.1. Recorridos en Profundidad y Amplitud . . . . . . . . . . . . . . . . . . . . . . . . . . 43<br />
7.2. Or<strong>de</strong>n Topológico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43<br />
7.3. Componentes Fuertemente Conexas . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43<br />
7.4. Árboles <strong>de</strong> Cobertura <strong>de</strong> Costo Mínimo . . . . . . . . . . . . . . . . . . . . . . . . . 43<br />
7.5. Caminos más Cortos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43<br />
7.6. Flujo Máximo en Re<strong>de</strong>s . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43<br />
8. Algoritmos sobre Strings 45<br />
8.1. Pattern Matching . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45<br />
8.2. Pattern Matching <strong>de</strong> Expresiones Regulares . . . . . . . . . . . . . . . . . . . . . . . 45<br />
8.3. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45<br />
9. Estructuras y Algoritmos para Computación Gráfica 47<br />
9.1. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47<br />
10.Introducción a la Complejidad Computacional 49<br />
10.1. Las clases <strong>de</strong> problemas P y NP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49<br />
10.2. Problemas NP-duros y NP-completos . . . . . . . . . . . . . . . . . . . . . . . . . . 49<br />
10.3. La clase co-NP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49<br />
10.4. Más allá <strong>de</strong> P . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49<br />
10.5. Dentro <strong>de</strong> P . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
Capítulo<br />
1<br />
Estructuras <strong>de</strong> Datos Simples<br />
1.1. Stacks y Colas<br />
Stacks<br />
Un stack es una estructura que sólo implementa una operación <strong>de</strong> consulta o extracción <strong>de</strong> datos.<br />
El elemento retornado está pre<strong>de</strong>terminado por la forma en que los elementos fueron agregados al<br />
stack, siguiendo una política LIFO (Last In First Out) que significa que el elemento retornado por<br />
una operación <strong>de</strong> consulta es siempre el último elemento insertado. La operación <strong>de</strong> extracción sobre<br />
un stack se llama Pop y diremos que retorna siempre el elemento que se encuentra al tope <strong>de</strong>l stack.<br />
La operación <strong>de</strong> inserción en un stack se llama Push y recibe un único argumento. Después <strong>de</strong><br />
una operación Push(x) el elemento al tope <strong>de</strong>l stack es x y <strong>de</strong>biera ser el elemento retornado por<br />
una subsiguiente operación Pop.<br />
Po<strong>de</strong>mos implementar un stack usando un arreglo S para almacenar los elementos y un puntero<br />
top que nos indique la posición <strong>de</strong>l elemento al tope <strong>de</strong>l stack. El valor <strong>de</strong> top es inicialmente −1 y<br />
supondremos que el arreglo para implementar el stack tiene un tamaño fijo, digamos N.<br />
Stack:<br />
S[0..N − 1]<br />
top := −1<br />
Una observación importante es que se <strong>de</strong>be tener cuidado <strong>de</strong> hacer Pop a un stack vacío. Implementamos<br />
entonces un método llamado IsEmpty que retorna true si el stack se encuentra vacío<br />
y false en otro caso. Es fácil darse cuenta que para <strong>de</strong>cidir acerca <strong>de</strong> si el stack se encuentra o no<br />
vacío, sólo <strong>de</strong>bemos mirar el puntero top.<br />
3
4 CAPÍTULO 1. ESTRUCTURAS DE DATOS SIMPLES<br />
Figura 1.1: Ejemplo <strong>de</strong> las operaciones Pop y Push aplicadas a un stack en particular.<br />
Stack.IsEmpty()<br />
if top = −1<br />
then return true<br />
else return false<br />
Con las observaciones anteriores, las implementaciones <strong>de</strong> Push y Pop son:<br />
Stack.Push(x)<br />
top := top +1<br />
S[top] := x<br />
Stack.Pop()<br />
if IsEmpty()<br />
then error “un<strong>de</strong>rflow”<br />
else top := top −1<br />
return S[top +1]<br />
Cada una <strong>de</strong> las operaciones IsEmpty, Push y Pop toman tiempo O(1). La figura 1.1 muestra<br />
los efectos <strong>de</strong> las operaciones Push y Pop sobre un stack particular.<br />
Colas<br />
Al igual que un stack, una cola implementa sólo una operación <strong>de</strong> consulta o extracción <strong>de</strong> datos.<br />
También el elemento retornado por una operación <strong>de</strong> extracción en una cola está pre<strong>de</strong>terminado<br />
por la forma en que los elementos fueron insertados, esta vez obe<strong>de</strong>ciendo una política FIFO (First<br />
In First Out), es <strong>de</strong>cir, el primer elemento insertado en la cola será el primer elemento retornado<br />
por una operación <strong>de</strong> extracción.<br />
En una cola la operación <strong>de</strong> extracción se llama Dequeue y diremos que retorna siempre el<br />
elemento a la cabeza <strong>de</strong> la cola. Este elemento representa al elemento que primero fue insertado<br />
en la cola <strong>de</strong> entre los que aún se encuentran en ella. La operación <strong>de</strong> inserción sobre una cola se<br />
llama Enqueue y recibe un único argumento. Diremos que luego <strong>de</strong> una operación Enqueue(x) el<br />
elemento x queda al final <strong>de</strong> la cola (o a la cola <strong>de</strong> la cola).
1.1. STACKS Y COLAS 5<br />
Figura 1.2: Ejemplo <strong>de</strong> las operaciones sobre una cola.<br />
Po<strong>de</strong>mos entonces implementar una cola usando un arreglo Q para almacenar los elementos y<br />
dos punteros: head, que nos indique la posición <strong>de</strong>l elemento a la cabeza <strong>de</strong> la cola, y tail, que nos<br />
indique la posición siguiente al último elemento <strong>de</strong> la cola. Inicialmente los valores head y tail son<br />
0 y supondremos que el arreglo tiene un tamaño fijo N.<br />
Cola:<br />
Q[0..N − 1]<br />
head := 0<br />
tail := 0<br />
Los valores iniciales nos indican inmediatamente que una cola se encuentra vacía si se cumple<br />
head = tail. Los dos punteros son necesarios para optimizar el uso <strong>de</strong>l espacio en el arreglo. De<br />
hecho, como se verá en la implementación, el uso <strong>de</strong>l arreglo es en forma circular lo que implica que<br />
se supone que la posición que sigue a Q[N − 1] es Q[0].<br />
Las implementaciones <strong>de</strong> Enqueue y Dequeue resultan:<br />
Cola.Enqueue(x)<br />
Q[tail] := x<br />
tail := tail +1 mod N<br />
Cola.Dequeue()<br />
x := Q[head]<br />
head := head +1 mod N<br />
return x<br />
Es importante notar que en los procedimientos Enqueue y Dequeue pue<strong>de</strong>n ocurrir tanto<br />
errores <strong>de</strong> un<strong>de</strong>rflow como <strong>de</strong> overflow. El chequeo <strong>de</strong> estas condiciones se han omitido para mantener<br />
la claridad <strong>de</strong> los procedimientos. Al igual que con el stack, todas las operaciones sobre una cola<br />
toman tiempo O(1).<br />
La figura 1.2 muestra un ejemplo <strong>de</strong> las operaciones <strong>de</strong> extracción e inserción sobre una cola<br />
particular.
6 CAPÍTULO 1. ESTRUCTURAS DE DATOS SIMPLES<br />
Ejercicios<br />
1. Explique como se pue<strong>de</strong>n implementar dos stacks usando sólo un arreglo S[0..N −1] <strong>de</strong> manera<br />
tal que no se produzca un overflow a menos que entre los dos stacks alcancen un número total<br />
<strong>de</strong> elementos insertados igual a N. Las operaciones Pop y Push <strong>de</strong>ben seguir tomando tiempo<br />
O(1).<br />
1.2. Heaps<br />
Un heap es una estructura que permite las operaciones básicas <strong>de</strong> conjuntos más la operación<br />
Max <strong>de</strong> manera muy eficiente. Es por esto que la motivación inicial para esta estructura es el uso<br />
en colas <strong>de</strong> priorida<strong>de</strong>s. A<strong>de</strong>más el heap tendrá aplicaciones directas en el capítulo 2 cuando veamos<br />
el algoritmo Heap-Sort.<br />
Un heap es un árbol binario semi–completo, o sea, que tiene todos sus niveles llenos excepto<br />
posiblemente el último nivel, el que se encuentra lleno <strong>de</strong>s<strong>de</strong> la izquierda hasta algún punto a la<br />
<strong>de</strong>recha. La figura ?? en la sección ?? muestra un ejemplo <strong>de</strong> un árbol binario semi–completo.<br />
Por la regularidad que tiene un árbol <strong>de</strong> las anteriores características, es posible implementar un<br />
heap directamente sobre un arreglo sin la necesidad <strong>de</strong> utilizar punteros usando procedimientos para<br />
plasmar la estructura <strong>de</strong> árbol. Dado un árbol binario semi–completo en un arreglo A[0..N − 1], la<br />
raíz <strong>de</strong>l árbol será A[0] y los siguientes procedimientos permiten obtener los índices <strong>de</strong>l padre, hijo<br />
izquierdo e hijo <strong>de</strong>recho <strong>de</strong>l elemento con índice i:<br />
P(i)<br />
L(i)<br />
R(i)<br />
return ⌊ i−1<br />
2 ⌋<br />
return 2i + 1<br />
return 2i + 2<br />
Se <strong>de</strong>be tener especial cuidado en hacer la diferencia entre el largo total <strong>de</strong>l arreglo y la cantidad<br />
<strong>de</strong> posiciones <strong>de</strong>l arreglo que se están usando como elementos efectivos <strong>de</strong>l heap. Con estas<br />
consi<strong>de</strong>raciones podríamos <strong>de</strong>finir un heap usando un arreglo A <strong>de</strong> largo N como sigue:<br />
Heap<br />
A[0..N − 1]<br />
µ := N<br />
π := 0<br />
don<strong>de</strong> el atributo µ indica el largo total <strong>de</strong>l arreglo usado para almacenar el heap, y π indica la<br />
cantidad <strong>de</strong> elementos efectivamente almacenados en el heap, inicialmente 0. De esta forma, a pesar<br />
<strong>de</strong> que pue<strong>de</strong>n existir elementos válidos en A[0..µ−1], los elementos efectivos <strong>de</strong>l heap se encuentran<br />
en A[0..π − 1].
1.2. HEAPS 7<br />
Figura 1.3: Ejemplo <strong>de</strong> Heap<br />
Hasta ahora nuestra <strong>de</strong>finición <strong>de</strong> heap no se diferencia en nada <strong>de</strong> un árbol binario. Lo que<br />
caracteriza a un heap, es que el arreglo A cumple la propiedad <strong>de</strong> heap:<br />
A[P(i)] ≥ A[i] (1.1)<br />
En la figura 1.3 se muestra un ejemplo <strong>de</strong> un árbol binario semicompleto que cumple la propiedad<br />
<strong>de</strong> heap. La anterior propiedad nos dice que en el árbol que representa al heap, el valor asignado a<br />
un nodo es siempre mayor que sus dos hijos. Esta propiedad nos permite también, inmediatamente<br />
dar un procedimiento para obtener el valor máximo <strong>de</strong>ntro <strong>de</strong>l heap:<br />
Heap.Max()<br />
if π > 0<br />
then return A[0]<br />
else return nil<br />
El ejercicio 1 le pi<strong>de</strong> <strong>de</strong>mostrar que este procedimiento es correcto.<br />
Para implementar cada una <strong>de</strong> las operaciones básicas sobre un heap <strong>de</strong>bemos tener claro que,<br />
tanto antes como <strong>de</strong>spués <strong>de</strong> la aplicación <strong>de</strong> cada una <strong>de</strong> ellas, la propiedad <strong>de</strong> heap <strong>de</strong>be cumplirse.<br />
En lo que sigue estudiaremos cómo implementar las operación <strong>de</strong> inserción, y más a<strong>de</strong>lante cómo<br />
restaurar la propiedad <strong>de</strong> heap en un árbol semi-completo <strong>de</strong>ficiente (como heap) lo que nos permitirá<br />
implementar la operación <strong>de</strong> extracción.<br />
Inserción<br />
La operación <strong>de</strong> inserción en un heap se pue<strong>de</strong> hacer <strong>de</strong> una forma bastante simple. El siguiente<br />
trozo <strong>de</strong> código inserta el valor k en un heap.<br />
Heap.Insert(k)<br />
if π = µ<br />
then error “heap overflow”<br />
A[π] := k<br />
i := π<br />
while i > 0 ∧ A[i] > A[P(i)]<br />
do intercambia(A[i],A[P(i)])<br />
i := P(i)<br />
π := π + 1
8 CAPÍTULO 1. ESTRUCTURAS DE DATOS SIMPLES<br />
Figura 1.4: Ejemplo <strong>de</strong> ejecución <strong>de</strong> la función Insert sobre un heap.<br />
La i<strong>de</strong>a tras el procedimiento <strong>de</strong> inserción es que al intentar agregar un nuevo elemento al final<br />
<strong>de</strong>l arreglo, una posible violación a la propiedad <strong>de</strong> heap se pue<strong>de</strong> dar sólo entre el nuevo valor y su<br />
padre, en el caso <strong>de</strong> que el nuevo valor sea mayor que su padre. Este problema se arregla fácilmente<br />
intercambiando los valores luego <strong>de</strong> lo cuál podría existir una posible nueva violación por lo que el<br />
proceso <strong>de</strong>be repetirse. La complejidad <strong>de</strong> este procedimiento tiene directa relación con la altura <strong>de</strong>l<br />
árbol, esto porque en cada llamada recursiva estamos subiendo un nivel en el árbol. Dado que si<br />
el heap tiene N elementos la altura máxima <strong>de</strong>l árbol es Θ(log N) (es un árbol semi–completo), el<br />
tiempo que toma Insert es Θ(log N) en el peor caso. La figura 1.4 muestra un ejemplo <strong>de</strong> inserción<br />
<strong>de</strong> un nuevo valor en un heap.<br />
Restaurando la propiedad <strong>de</strong> heap<br />
Suponga que se quiere eliminar un elemento <strong>de</strong>s<strong>de</strong> el heap, por ejemplo el que se encuentra en la<br />
posición j. Podríamos hacer simplemente A[j] = nil para representar que el elemento en la posición<br />
j ya no se encuentra en el heap. Existen muchos problemas que este tipo <strong>de</strong> eliminación generaría:<br />
el árbol podría <strong>de</strong>jar <strong>de</strong> ser semi–completo incluso podría <strong>de</strong>jar <strong>de</strong> ser un árbol, podría existir un<br />
problema en cómo actualizar el valor <strong>de</strong> π, etc. El alumno podría preguntarse si existe algún caso<br />
en que hacer la eliminación <strong>de</strong> esta forma no produzca estos problemas.<br />
El siguiente procedimiento recursivo Heapify se usa para restaurar la propiedad <strong>de</strong> heap a un<br />
arreglo que ha <strong>de</strong>jado <strong>de</strong> cumplirla porque uno (y sólo uno) <strong>de</strong> sus elementos la está violando con<br />
respecto a sus hijos (no tiene conflictos con el valor <strong>de</strong>l padre), y tanto el subárbol izquierdo como<br />
el <strong>de</strong>recho <strong>de</strong> este valor siguen cumpliendo la propiedad <strong>de</strong> heap. Esta operación nos servirá más<br />
a<strong>de</strong>lante no sólo para la extracción <strong>de</strong> elementos <strong>de</strong>s<strong>de</strong> el heap, si no también para la construcción <strong>de</strong><br />
un heap a partir <strong>de</strong> valores arbitrarios. Toma como argumento un índice i tal que A[i] posiblemente<br />
viola la propiedad <strong>de</strong> heap, o sea que pue<strong>de</strong> ser menor que alguno <strong>de</strong> sus hijos en el heap.
1.2. HEAPS 9<br />
Figura 1.5: Ejemplo <strong>de</strong> ejecución <strong>de</strong> Heapify sobre un heap <strong>de</strong>ficiente en una <strong>de</strong> sus posiciones.<br />
Heap.Heapify(i)<br />
l := L(i)<br />
r := R(i)<br />
k := i<br />
if l < π ∧ A[l] > A[k]<br />
then k := l<br />
if r < π ∧ A[r] > A[k]<br />
then k := r<br />
✄ k es el índice <strong>de</strong>l mayor elemento entre A[i], A[l] y A[r]<br />
if k ≠ i<br />
then intercambia(A[i],A[k])<br />
Heapify(k)<br />
Hapify hace “<strong>de</strong>scen<strong>de</strong>r” por el heap el valor en A[i], <strong>de</strong> modo que el subárbol con raíz en i se<br />
vuelva un heap. Primero pone en la posición i al mayor <strong>de</strong> los valores entre A[i], A[l] y A[r], y si<br />
el valor más gran<strong>de</strong> no es A[i] hace una llamada recursiva sobre la nueva posición <strong>de</strong> A[i], ya que<br />
la nueva posición podría nuevamente estar violando la propiedad <strong>de</strong> heap. La figura 1.5 muestra un<br />
ejemplo <strong>de</strong> aplicar Heapify a un arreglo en que una <strong>de</strong> sus posiciones viola la propiedad <strong>de</strong> heap.<br />
El tiempo que tarda Heapify(i) correspon<strong>de</strong> a la altura <strong>de</strong> A[i] en el árbol binario, esto porque<br />
en cada llamada recursiva estamos <strong>de</strong>scendiendo un nivel en el árbol. Dado que si el heap tiene N<br />
elementos la altura máxima <strong>de</strong>l árbol es Θ(log N) (es un árbol semi–completo), el tiempo que toma<br />
Heapify es Θ(log N) en el peor caso. En el ejercicio 2 se pi<strong>de</strong> <strong>de</strong>mostrar que Heapify es correcto<br />
y calcular usando relaciones <strong>de</strong> recurrencia su complejidad.<br />
Construyendo un heap<br />
Heapify nos permite “arreglar” un arreglo en el que sólo un valor le está impidiendo ser un<br />
heap, pero qué pasa si tenemos un arreglo cualquiera y queremos convertirlo en un heap. En la<br />
figura 1.6 se muestra un arreglo cualquiera y cómo se vería su estructura si se interpretara como un<br />
árbol completo. En esta situación, sólo las hojas <strong>de</strong>l árbol cumplen con seguridad la propiedad <strong>de</strong><br />
heap. Si por ejemplo aplicáramos Heapify a cada uno <strong>de</strong> los nodos <strong>de</strong> altura 2 obtendríamos varios<br />
subárboles que cumplen la propiedad <strong>de</strong> heap. El resultado <strong>de</strong> esto se muestra en la figura 1.7. Esto<br />
nos da una intuición para crear un procedimiento que a partir <strong>de</strong> un arreglo cualquiera pueda crear<br />
un heap, la i<strong>de</strong>a será aplicar Heapify por niveles partiendo <strong>de</strong> los <strong>de</strong> menor altura.
10 CAPÍTULO 1. ESTRUCTURAS DE DATOS SIMPLES<br />
Figura 1.6: Un arreglo cualquiera visto como un árbol binario semicompleto.<br />
Figura 1.7: Arreglo <strong>de</strong> la figura 1.6 <strong>de</strong>spúes <strong>de</strong> aplicar Heapify a cada uno <strong>de</strong> los nodos <strong>de</strong> altura 2.<br />
El siguiente procedimiento toma un arreglo A <strong>de</strong> tamaño N y entrega un heap con los mismos<br />
valores <strong>de</strong>l arreglo A.<br />
Build-Heap(A)<br />
Heap h<br />
h.A := A<br />
h.π := N<br />
h.µ := N<br />
for i := N − 1 downto 0<br />
do h.Heapify(i)<br />
return h<br />
¿Qué complejidad tiene el procedimiento Build-Heap? Nuestra primera aproximación es apostar<br />
por que toma tiempo O(N log N) en el peor caso para construir un heap a partir <strong>de</strong> un arreglo <strong>de</strong><br />
N elementos, esto porque sabemos que Heapify toma tiempo Θ(log N) en el peor caso y estamos<br />
haciendo N llamadas al procedimiento. ¿Po<strong>de</strong>mos dar una cota más ajustada? La respuesta es sí,<br />
la observación clave es que el peor caso <strong>de</strong> Heapify ocurre cuando el nodo <strong>de</strong>s<strong>de</strong> don<strong>de</strong> se llama<br />
inicialmente al procedimiento está a la mayor altura posible, sin embargo en Build-Heap estamos<br />
haciendo “muchas” llamadas sobre nodos <strong>de</strong> poca altura, <strong>de</strong> hecho, el procedimiento Heapify toma<br />
tiempo Θ(1) si se llama sobre una hoja <strong>de</strong>l árbol.<br />
Teorema 1.2.1. Build-Heap toma tiempo O(N) en el peor caso para un arreglo <strong>de</strong> tamaño N.<br />
Demostración. Antes <strong>de</strong> la <strong>de</strong>mostración, haremos una observación. La cantidad <strong>de</strong> nodos <strong>de</strong> altura<br />
h en un arbol binario completo con N nodos es exactamente ⌈N/2 h+1 ⌉. Esto se pue<strong>de</strong> <strong>de</strong>mostrar
1.2. HEAPS 11<br />
por inducción en h y la clave está en notar que, si a altura h hay m nodos, a altura h + 1 hay m/2<br />
nodos. El ejercicio 3 le pi<strong>de</strong> hacer esta <strong>de</strong>mostración.<br />
Con esta observación po<strong>de</strong>mos seguir con nuestra <strong>de</strong>mostración inicial acerca <strong>de</strong> la complejidad<br />
<strong>de</strong> Build-Heap. En Build-Heap se hace Heapify una vez para cada uno <strong>de</strong> los elementos <strong>de</strong>l árbol<br />
(arreglo) iniciando por los <strong>de</strong> menor altura. Si el nodo i tiene altura h i entonces, para i, Heapify<br />
tarda Θ(h i ). Luego, para calcular el tiempo total que <strong>de</strong>mora el procedimiento, po<strong>de</strong>mos sumar los<br />
tiempos por niveles en el árbol. Supongamos que el árbol tiene altura H, la suma resulta entonces:<br />
h=0<br />
H∑<br />
h=0<br />
⌈ N<br />
2 h+1 ⌉<br />
Θ(h)<br />
como lo que buscamos es una notación O po<strong>de</strong>mos reescribir la suma anterior como<br />
( H<br />
) ( )<br />
∑ N<br />
H∑<br />
O<br />
2 h+1 h h<br />
= O N<br />
2 h<br />
necesitamos entonces solamente acotar la sumatoria interna. Es claro que la sumatoria cumple con<br />
la <strong>de</strong>sigualdad<br />
H∑<br />
h=0<br />
∞<br />
h<br />
2 h < ∑<br />
Usaremos algunos conocimientos <strong>de</strong> series, generalmente aprendidos en los cursos <strong>de</strong> cálculo, para<br />
encontrar un valor explícito para esta última sumatoria. La sucesión geométrica (muy conocida por<br />
los estudiantes <strong>de</strong> ingeniería) es la siguiente<br />
y cuando n tien<strong>de</strong> a infinito obtenemos<br />
1 + x + x 2 + · · · + x n =<br />
1 + x + x 2 + · · · =<br />
h=0<br />
h<br />
2 h .<br />
n∑<br />
h=0<br />
∞∑<br />
h=0<br />
h=0<br />
x h = 1 − xn+1<br />
1 − x<br />
x h = 1<br />
1 − x .<br />
que converge para todos los valores <strong>de</strong> x estrictamente menores que 1. Ahora po<strong>de</strong>mos “<strong>de</strong>rivar” la<br />
anterior sumatoria 1 para obtener<br />
1 + 2x + 3x 2 + 4x 3 + · · · =<br />
∞∑<br />
hx h−1 1<br />
=<br />
(1 − x) 2<br />
que también converge para todos los valores <strong>de</strong> x estrictamente menores que 1. Lo único que nos<br />
falta ahora es multiplicar lo anterior por x para obtener<br />
x + 2x 2 + 3x 3 + 4x 4 + · · · =<br />
h=1<br />
∞∑<br />
hx h x<br />
=<br />
(1 − x) 2<br />
1 Esta <strong>de</strong>rivación es permitida sólo por la convergencia uniforme <strong>de</strong> la serie geométrica, y es válida para los valores<br />
<strong>de</strong> x estrictamente menores que 1.<br />
h=0
12 CAPÍTULO 1. ESTRUCTURAS DE DATOS SIMPLES<br />
que es exactamente la sumatoria que buscamos cuando x = 1/2, luego tenemos que<br />
H∑<br />
h=0<br />
h<br />
∞<br />
2 h < ∑<br />
h=0<br />
Finalmente la complejidad <strong>de</strong> Build-Heap resulta:<br />
O<br />
(<br />
N<br />
H∑<br />
h=0<br />
1<br />
h<br />
2 h = 2<br />
(1 − 1 = 2.<br />
2<br />
)2<br />
)<br />
h<br />
2 h = O(N · 2) = O(N).<br />
Este teorema nos dice que po<strong>de</strong>mos crear un heap a partir <strong>de</strong> un arreglo cualquiera en tiempo<br />
lineal en el largo <strong>de</strong>l arreglo.<br />
El anterior cálculo <strong>de</strong> complejidad se estableció como un teorema principalmente por la cantidad<br />
<strong>de</strong> herramientas matemáticas necesarias, generalmente estos cálculos no serán tan engorrosos y por<br />
esto no los estableceremos como teoremas. Se le recomienda al alumno apren<strong>de</strong>r los resultados <strong>de</strong>l<br />
teorema, principalmente los cálculos <strong>de</strong> las sumatorias ya que estas pue<strong>de</strong>n serle <strong>de</strong> utilidad en otros<br />
cálculos <strong>de</strong> complejidad <strong>de</strong> algoritmos.<br />
Extracción y Colas <strong>de</strong> Priorida<strong>de</strong>s<br />
Una cola <strong>de</strong> priorida<strong>de</strong>s es una estructura <strong>de</strong> datos para mantener elementos <strong>de</strong> manera tal que<br />
la extracción siempre entrega el elemento <strong>de</strong> mayor valor. Similarmente a un stack o una cola, en<br />
una cola <strong>de</strong> priorida<strong>de</strong>s el elemento que se extrae esta pre<strong>de</strong>terminado por los elementos actualmente<br />
en ella. Soporta las operaciones Insert, Max y Extract-Max.<br />
Una típica aplicación <strong>de</strong> las colas <strong>de</strong> priorida<strong>de</strong>s es la organización <strong>de</strong> las tareas que se <strong>de</strong>ben<br />
ejecutar en un servidor. Se ejecutará primero la tarea que tenga la más alta prioridad (claramente<br />
una tarea <strong>de</strong>l <strong>de</strong>cano <strong>de</strong> la facultad tiene prioridad sobre una tarea pedida por un alumno). Cuando<br />
una tarea es terminada (o interrumpida) se elige <strong>de</strong>s<strong>de</strong> la cola la que tenga mayor prioridad <strong>de</strong> las<br />
que se encuentran esperando usando la operación Extract-Max y esta será la próxima en ejecutar<br />
en el servidor.<br />
La implementación más simple <strong>de</strong> una cola <strong>de</strong> priorida<strong>de</strong>s, pero muy ineficiente, es mantener<br />
todos los elementos en un arreglo y cada vez que se pregunte por o se quiera extraer el <strong>de</strong> máximo<br />
valor, este se busca linealmente en todo el arreglo.<br />
Otra implementación simple, pero igual <strong>de</strong> ineficiente, es usar un arreglo or<strong>de</strong>nado en forma<br />
creciente y un puntero (índice) al último elemento <strong>de</strong>l arreglo. Cada vez que se ingresa un nuevo<br />
elemento, este se ubica directamente en la posición que le correspon<strong>de</strong> según el or<strong>de</strong>n y cuando se<br />
pregunta por o se quiere extraer el elemento mayor simplemente se consulta el último elemento <strong>de</strong>l<br />
arreglo.<br />
Nuestra implementación usará un heap principalmente por la eficiencia que tiene para obtener el<br />
valor máximo e insertar nuevos elementos. Un heap ya soporta <strong>de</strong> manera eficiente las operaciones<br />
Insert, en tiempo O(log N), y Max, en tiempo Θ(1), sólo le falta la operación Extrac-Max. El<br />
siguiente código muestra una implementación para este método.<br />
✷
1.2. HEAPS 13<br />
Figura 1.8: Ejemplo <strong>de</strong> la aplicación <strong>de</strong> Extract-Max sobre un heap.<br />
Heap.Extract-Max()<br />
if π = 0<br />
then error “heap un<strong>de</strong>rflow”<br />
m := A[0]<br />
A[0] := A[π − 1]<br />
π := π − 1<br />
Heapify(0)<br />
return m<br />
La i<strong>de</strong>a <strong>de</strong>l algoritmo es intercambiar el último elemento <strong>de</strong>l heap, que es una hoja, con la raíz <strong>de</strong>l<br />
heap. Al hacer esto, posiblemente la propiedad <strong>de</strong> heap es violada por la nueva raiz, pero es tal que<br />
cada uno <strong>de</strong> sus subárboles cumple con ser un heap válido, por lo tanto es factible aplicar Heapify<br />
para arreglar la <strong>de</strong>ficiencia en la raíz. La figura 1.8 muestra un ejemplo <strong>de</strong> este procedimiento aplicado<br />
a un heap particular.<br />
Ejercicios<br />
1. Demuestre que el procedimiento Max <strong>de</strong> un heap efectivamente retorna el valor máximo<br />
almacenado en el heap.<br />
2. Demuestre que el procedimiento Heapify es correcto. Calcule usando relaciones <strong>de</strong> recurrencia<br />
la complejidad <strong>de</strong>l procedimiento.<br />
3. Demuestre que en un árbol binario completo con N nodos, la cantidad <strong>de</strong> nodos <strong>de</strong> altura h<br />
es exactamente ⌈N/2 h+1 ⌉.<br />
4. El procedimiento Build-Heap hace Heapify <strong>de</strong>s<strong>de</strong> las hojas <strong>de</strong>l árbol (arreglo) hasta la raiz.<br />
Dado que las hojas <strong>de</strong> un árbol ya cumplen la propiedad <strong>de</strong> heap no es necesario hacer Heapify<br />
sobre ellas. Mejore el procedimiento Build-Heap para que este no tome en cuenta las hojas.<br />
Discuta si su mejora aumenta la eficiencia asintótica <strong>de</strong>l algoritmo (si se mejora o no el tiempo<br />
O(N) <strong>de</strong>l algoritmo original).
14 CAPÍTULO 1. ESTRUCTURAS DE DATOS SIMPLES<br />
Figura 1.9: Un elemento componente <strong>de</strong> una lista doblemente ligada<br />
1.3. Listas Doblemente Ligadas<br />
Una lista doblemente ligada es una estructura en la que los elementos están dispuestos en un<br />
or<strong>de</strong>n lineal, y que soporta las tres operaciones elementales sobre conjuntos dinámicos (aunque no<br />
siempre <strong>de</strong> manera eficiente). Cada elemento en una lista es un objeto que posee un atributo clave<br />
y punteros para plasmar la estructura <strong>de</strong> la lista. Si x es un objeto en la lista:<br />
x.k representa al atributo clave<br />
x.next representa al objeto siguiente en la lista<br />
x.prev representa al objeto anterior en la lista<br />
La figura 1.9 muestra un objeto con estos atributos. La lista completa se representa entonces<br />
simplemente por un puntero a su primer objeto, que llamaremos head cuyo valor inicial será nil,<br />
así la lista pue<strong>de</strong> implementarse como:<br />
List:<br />
head := nil<br />
Operaciones Básicas<br />
La búsqueda en una lista doblemente ligada se hace en forma lineal. El siguiente es una posible<br />
implementación para buscar un elemento con clave k en una lista, entrega el primer elemento que<br />
tiene clave k, o nil si ningún elemento tiene esa clave. Toma tiempo Θ(N) en el peor caso si la lista<br />
tiene N elementos.<br />
List.Search(k)<br />
x := head<br />
while x ≠ nil ∧ x.k ≠ k<br />
do x := x.next<br />
return x<br />
Inicialmente en una lista, su atributo head apunta a nil lo que significa que la lista se encuentra<br />
vacía. Para po<strong>de</strong>r agregar elementos a una lista se necesita <strong>de</strong> un procedimiento <strong>de</strong> inserción. El<br />
siguiente procedimiento inserta un nuevo elemento a una lista.
1.3. LISTAS DOBLEMENTE LIGADAS 15<br />
Figura 1.10: Listas con centinelas<br />
List.Insert(x)<br />
x.next := head<br />
if head ≠ nil<br />
then head.prev := x<br />
head := x<br />
x.prev := nil<br />
El tiempo <strong>de</strong> ejecución <strong>de</strong> Insert es O(1) en el pero caso.<br />
El siguiente procedimiento elimina un elemento x <strong>de</strong>s<strong>de</strong> una lista. En el se supone que x es un<br />
puntero directo al objeto que se quiere eliminar. Si en vez <strong>de</strong>l puntero, sólo se tiene la clave <strong>de</strong>l<br />
objeto, este <strong>de</strong>be ser primero encontrado en la lista y luego eliminado.<br />
List.Delete(x)<br />
if x.prev ≠ nil<br />
then x.prev.next := x.next<br />
else head := x.next<br />
if x.next ≠ nil<br />
then x.next.prev := x.prev<br />
El tiempo <strong>de</strong> ejecución <strong>de</strong> Delete es O(1) en el peor caso.<br />
Centinelas<br />
Si en el código <strong>de</strong> Delete pudiésemos ignorar las condiciones <strong>de</strong> bor<strong>de</strong>, la implementación<br />
resultaría en algo como<br />
List.Delete(x)<br />
x.prev.next := x.next<br />
x.next.prev := x.prev<br />
que no funcionaría por ejemplo en el caso <strong>de</strong> que la lista se encontrara vacía.<br />
Un centinela es un objeto tonto que nos sirve para simplificar las condiciones <strong>de</strong> bor<strong>de</strong> en una<br />
lista (en general en una estructura ligada por punteros). Supongamos que a nuestra lista le agregamos<br />
un objeto nil que representa a nil pero tiene todos los atributos <strong>de</strong> los otros objetos <strong>de</strong> la lista. La
16 CAPÍTULO 1. ESTRUCTURAS DE DATOS SIMPLES<br />
i<strong>de</strong>a <strong>de</strong> este objeto es que se posicione entre el primer y último elemento <strong>de</strong> la lista, <strong>de</strong> manera tal que<br />
el primer elemento lo apunte mediante su atributo prev y el último lo apunte mediante su atributo<br />
next. El objeto nil será por su parte tal que su atributo next apunte al primer elemento <strong>de</strong> la lista<br />
y su atributo prev apunte al último elemento <strong>de</strong> la lista. La figura 1.10 muestra ejemplos <strong>de</strong> listas<br />
adicionadas con un centinela. Nótese que el objeto nil no representa información alguna, sólo se<br />
usará como reemplazo a una referencia a nil, por ejemplo, anteriormente el puntero prev <strong>de</strong>l primer<br />
elemento <strong>de</strong> la lista apuntaba a nil, ahora lo hace al objeto nil. Dado que ahora nil.next apunta al<br />
primer elemento <strong>de</strong> la lista, po<strong>de</strong>mos incluso prescindir <strong>de</strong>l atributo head. Con estas consi<strong>de</strong>raciones<br />
una lista vacía contendrá sólo al objeto nil y tal que inicialmente tanto los puntero nil.prev como<br />
nil.next apunten el mismo nil. Nuestra nueva estructura lista será:<br />
List:<br />
nil :<br />
k := nil<br />
next := nil<br />
prev := nil<br />
Con la adición <strong>de</strong>l centinela nil el método Delete anterior funciona sin problemas para eliminar<br />
un objeto <strong>de</strong> la lista. Nótese como ya no son necesarias las condiciones <strong>de</strong> bor<strong>de</strong> gracias al centinela.<br />
Para buscar en la lista con centinela usamos el procedimiento Search que funciona como antes<br />
pero se <strong>de</strong>ben cambiar las referencias a head que ya no existe.<br />
List.Search(k)<br />
x := nil.next<br />
while x ≠ nil ∧x.k ≠ k<br />
do x := x.next<br />
return x<br />
Al igual que el nuevo procedimiento Delete, el procedimiento Insert se pue<strong>de</strong> hacer <strong>de</strong> manera<br />
muy simple.<br />
List.Insert(x)<br />
x.next := nil.next<br />
nil.next.prev := x<br />
nil.next := x<br />
x.prev := nil<br />
Los centinelas muy difícilmente disminuyen la eficiencia asintótica <strong>de</strong> un procedimiento, ellos se<br />
usan generalmente para hacer los códigos más simples. No <strong>de</strong>ben ser usados indiscriminadamente<br />
ya que, a pesar <strong>de</strong> no tener un significado como elemento <strong>de</strong> la estructura, necesitan <strong>de</strong> espacio <strong>de</strong><br />
almacenamiento.<br />
Ejercicios<br />
1.
1.4. REPRESENTACIÓN DE ÁRBOLES 17<br />
1.4. Representación <strong>de</strong> Árboles<br />
Para representar un árbol, generalmente usaremos una estructura <strong>de</strong> nodo que contendrá un<br />
valor clave que llamaremos k y uno o más punteros a otros nodos <strong>de</strong>l árbol. Cuántos punteros y a<br />
qué nodos <strong>de</strong>l árbol apunten, variará <strong>de</strong>pendiendo <strong>de</strong>l tipo <strong>de</strong> árbol y su implementación. Un árbol lo<br />
representaremos simplemente a partir <strong>de</strong> un puntero a su raíz, un nodo distinguido que llamaremos<br />
root el que inicialmente apuntará a nil.<br />
Tree:<br />
root := nil<br />
Árboles Binarios<br />
Si x es un nodo en un árbol binario, el contendrá los siguientes atributos a<strong>de</strong>más <strong>de</strong> la clave:<br />
x.left que apuntará al hijo izquierdo <strong>de</strong> x<br />
x.right que apuntará al hijo <strong>de</strong>recho <strong>de</strong> x<br />
x.p que apuntará al padre <strong>de</strong> x<br />
Si para algún objeto x se tiene que x.p = nil entonces x es la raíz <strong>de</strong>l árbol. Si para algún objeto x<br />
tanto x.left como x.right son nil entonces x es una hoja <strong>de</strong>l árbol.<br />
El siguiente es una posible implementación para una búsqueda sobre un árbol binario. El método<br />
Tree-Search es recursivo y recibe como parámetro la clave k <strong>de</strong>l objeto que se busca y un puntero<br />
x a un nodo <strong>de</strong>l árbol. Tree-Search entonces busca el objeto con clave k en el subárbol con raíz<br />
en x, retorna el objeto si es que lo encuentra o nil si el objeto no existe en el subárbol.<br />
Tree-Search(k,x)<br />
if x = nil ∨ x.k = k<br />
then return x<br />
l := Tree-Search(k,x.left)<br />
if l ≠ nil<br />
then return l<br />
r := Tree-Search(k,x.right)<br />
if r ≠ nil<br />
then return r<br />
return nil<br />
La llamada inicial sería entonces Tree-Search(k,T.root) para buscar un nodo con clave k en un<br />
árbol T. El tiempo <strong>de</strong> ejecución para Tree-Search es Θ(N) en el peor caso si el árbol tiene N<br />
nodos.<br />
Los procedimientos <strong>de</strong> inserción y eliminación <strong>de</strong> elementos <strong>de</strong>s<strong>de</strong> árboles binarios <strong>de</strong>pen<strong>de</strong>rán<br />
mucho <strong>de</strong> la aplicación en la que se estén utilizando y <strong>de</strong> las políticas que se usen para <strong>de</strong>terminar,<br />
por ejemplo, don<strong>de</strong> <strong>de</strong>be ir a parar un nuevo nodo en el árbol.
18 CAPÍTULO 1. ESTRUCTURAS DE DATOS SIMPLES<br />
Figura 1.11: Un árbol estructurado usando la represetación <strong>de</strong> hijo–izquierdo, hermano–<strong>de</strong>recho.<br />
Árboles <strong>de</strong> Ramificación Ilimitada<br />
La forma <strong>de</strong> implementación <strong>de</strong> un árbol binario pue<strong>de</strong> exten<strong>de</strong>rse para cualquier clase <strong>de</strong> árbol<br />
en el que se conozca a priori la cantidad máxima <strong>de</strong> hijos que un nodo pue<strong>de</strong> tener, sólo exten<strong>de</strong>mos el<br />
objeto nodo para que tenga tantos punteros como hijos máximos pue<strong>de</strong> tener en el árbol. El problema<br />
surge cuando nos encontramos con árboles en los que no po<strong>de</strong>mos saber a priori la cantidad <strong>de</strong> hijos<br />
que un nodo pue<strong>de</strong> llegar a tener. Incluso si supiéramos la cantidad máxima <strong>de</strong> hijos pero esta es<br />
muy gran<strong>de</strong>, una implementación tipo árbol binario nos haría <strong>de</strong>sperdiciar una importante cantidad<br />
<strong>de</strong> memoria.<br />
Afortunadamente existe una forma inteligente <strong>de</strong> usar una estructura tipo árbol binario (con<br />
sólo tres punteros) que nos soluciona este problema. La representación se llama hijo–izquierdo,<br />
hermano–<strong>de</strong>recho. En nuestro árbol, cada nodo x contendrá a<strong>de</strong>más <strong>de</strong> la clave los siguientes<br />
atributos:<br />
x.p que apunta al nodo padre <strong>de</strong> x en el árbol<br />
x.left-child que apunta al hijo <strong>de</strong> más a la izquierda <strong>de</strong> x<br />
x.right-sibling que apunta al hermano <strong>de</strong> x que está inmediatamente a su <strong>de</strong>recha.<br />
Con esta implementación, x.left-child = nil si x no tiene ningún hijo, y x.right-sibling = nil<br />
si x es el hijo <strong>de</strong> más a la <strong>de</strong>recha <strong>de</strong> su padre. La figura 1.11 muestra un ejemplo <strong>de</strong> un árbol<br />
estructurado <strong>de</strong> la forma mencionada.<br />
El siguiente procedimiento busca <strong>de</strong>ntro <strong>de</strong> un árbol con la representación hijo–izquierdo hermano–<br />
<strong>de</strong>recho un elemento con clave k, en el subárbol con raíz en x.<br />
Tree-Search(k,x)<br />
if x = nil ∨ x.k = k<br />
then return x<br />
y := Tree-Search(k,x.left-child)<br />
if y ≠ nil<br />
then return y<br />
z := Tree-Search(k,x.right-sibling)<br />
if z ≠ nil<br />
then return z<br />
return nil
1.4. REPRESENTACIÓN DE ÁRBOLES 19<br />
La llamada inicial entonces para buscar un elemento con clave k en un árbol T es Tree-Search(k,T.root).<br />
El tiempo <strong>de</strong> ejecución para Tree-Search es Θ(N) en el peor caso si el árbol tiene N nodos.<br />
Ejercicios<br />
1. Escriba procedimientos que muestren todos los valores <strong>de</strong> las claves almacenadas en un árbol<br />
binario siguiendo cada una <strong>de</strong> las siguientes reglas:<br />
a) El valor <strong>de</strong>l padre <strong>de</strong>be imprimirse siempre antes que los dos hijos.<br />
b) El valor <strong>de</strong> los hijos <strong>de</strong>be imprimirse siempre antes que el <strong>de</strong>l padre.<br />
c) El valor <strong>de</strong>l padre <strong>de</strong>be imprimirse <strong>de</strong>spués <strong>de</strong>l hijo izquierdo pero antes que el hijo<br />
<strong>de</strong>recho.<br />
En cada caso, su procedimiento <strong>de</strong>be tardar O(N) don<strong>de</strong> N es la cantidad <strong>de</strong> elementos<br />
almacenados en el árbol.<br />
2. Escriba procedimientos que, dado un árbol almacenado usando la representación hijo–izquierdo<br />
hermano–<strong>de</strong>recho, imprima todos los valores <strong>de</strong> sus claves siguiendo cada una <strong>de</strong> las siguientes<br />
reglas:<br />
a) El valor <strong>de</strong>l padre <strong>de</strong>be imprimirse antes que todos sus hijos los que <strong>de</strong>ben imprimirse <strong>de</strong><br />
izquierda a <strong>de</strong>recha.<br />
b) El valor <strong>de</strong>l padre <strong>de</strong>be imprimirse <strong>de</strong>spués que todos sus hijos los que <strong>de</strong>ben imprimirse<br />
<strong>de</strong> <strong>de</strong>recha a izquierda.<br />
En cada caso, su procedimiento <strong>de</strong>be tardar O(N) don<strong>de</strong> N es la cantidad <strong>de</strong> elementos<br />
almacenados en el árbol.<br />
3. Escriba un procedimiento Search(k,x) para buscar en un árbol almacenado con la representación<br />
<strong>de</strong> hijo–izquierdo hermano–<strong>de</strong>recho, pero que haga sólo una llamada recursiva.
20 CAPÍTULO 1. ESTRUCTURAS DE DATOS SIMPLES<br />
1.5. Implementación <strong>de</strong> Punteros y Objetos<br />
1.6. Definición Algebráica <strong>de</strong> una Estructura <strong>de</strong> Datos
Capítulo<br />
2<br />
Algoritmos <strong>de</strong> Or<strong>de</strong>nación<br />
Es mucho más fácil, y rápido que es lo que realmente nos importa, operar la información cuando<br />
esta se encuentra almacenada siguiendo cierto or<strong>de</strong>n. Por ejemplo, cuando tenemos una lista or<strong>de</strong>nada<br />
<strong>de</strong> n elementos, la búsqueda <strong>de</strong> un elemento particular pue<strong>de</strong> hacerse <strong>de</strong> manera tan eficiente como<br />
O(log n), usando búsqueda binaria. Este capítulo está <strong>de</strong>dicado al problema <strong>de</strong> or<strong>de</strong>nación que se<br />
pue<strong>de</strong> <strong>de</strong>finir <strong>de</strong> la siguiente manera:<br />
Input: Una secuencia <strong>de</strong> n elementos S = 〈s 1 ,s 2 ...,s n 〉 (generalemente supondremos que s i ∈ Z).<br />
Output: Una permutación <strong>de</strong> S (reor<strong>de</strong>namiento), S ′ = 〈s ′ 1,s ′ 2,...,s ′ n〉 tal que s ′ 1 ≤ s ′ 2 ≤ · · · ≤ s ′ n.<br />
Generalmente la secuencia <strong>de</strong> input la representaremos como un arreglo <strong>de</strong> n elementos, sin embargo<br />
en algunas aplicaciones pue<strong>de</strong> estar representada usando cualquier otra estructura lineal, como por<br />
ejemplo una lista ligada.<br />
Es importante notar que en pocas aplicaciones reales estaremos interesados en or<strong>de</strong>nar números,<br />
entonces <strong>de</strong>bemos pensar que lo que or<strong>de</strong>namos son claves <strong>de</strong> objetos con datos satélite, por ejemplo<br />
or<strong>de</strong>nar un grupo <strong>de</strong> personas por su número <strong>de</strong> RUT. Cuando intercambiamos un RUT <strong>de</strong> posición,<br />
lo que realmente estamos haciendo es intercambiar todos los datos <strong>de</strong> la persona. Si en alguna<br />
aplicación los datos satélites son <strong>de</strong>masiados, entonces no los moveremos todos cuando movamos su<br />
clave, sólo moveremos punteros a un objeto que los contenga. Por la discusión anterior, al estudiar el<br />
problema <strong>de</strong> or<strong>de</strong>nación, asumiremos siempre que el input es simplemente una secuencia <strong>de</strong> números.<br />
El traspaso <strong>de</strong>s<strong>de</strong> un algoritmo que or<strong>de</strong>ne números a otro que or<strong>de</strong>ne objetos más gran<strong>de</strong>s (o con<br />
mayor significado) <strong>de</strong>biese no presentar <strong>de</strong>masiado problema.<br />
Como observación final, en general cuando analicemos algoritmos <strong>de</strong> or<strong>de</strong>nación, nos interesará<br />
saber cuántas comparaciones entre elementos y cuantas asignaciones se realizaron en total.
22 CAPÍTULO 2. ALGORITMOS DE ORDENACIÓN<br />
Figura 2.1: Ejemplo <strong>de</strong> ejecución <strong>de</strong> Insert-Sort<br />
2.1. Algoritmos Simples <strong>de</strong> Or<strong>de</strong>nación<br />
Comenzaremos el capítulo estudiando varios algoritmos <strong>de</strong> or<strong>de</strong>nación. En general ellos se asemejan<br />
mucho a los métodos que una persona usa para or<strong>de</strong>nar objetos. Supondremos en cada caso<br />
que lo que se quiere or<strong>de</strong>nar es un arreglo A[0..n − 1] <strong>de</strong> n elementos.<br />
Or<strong>de</strong>nación por Inserción<br />
El algoritmo <strong>de</strong> or<strong>de</strong>nación por inserción utiliza la siguiente estrategia: Mantiene la parte inicial<br />
<strong>de</strong>l arreglo or<strong>de</strong>nada, digamos los valores A[0..j −1] y en la siguiente iteración inserta el valor A[j]<br />
en la posición que le correspon<strong>de</strong>:<br />
Insert-Sort(A)<br />
for j := 1 to n − 1<br />
do k := A[j]<br />
i := j − 1<br />
while i ≥ 0 ∧ A[i] > k<br />
do A[i + 1] := A[i]<br />
i := i − 1<br />
A[i + 1] := k<br />
La figura 2.1 muestra una ejecución <strong>de</strong> ejemplo <strong>de</strong> este algoritmo para un arreglo en particular. No<br />
es difícil <strong>de</strong>mostrar que en el peor caso Insert-Sort toma tiempo O(n 2 ). Un punto interesante es<br />
que el mejor caso <strong>de</strong> Insert-Sort ocurre cuando el arreglo se encuentra or<strong>de</strong>nado, en ese caso el<br />
tiempo es O(n).
2.1. ALGORITMOS SIMPLES DE ORDENACIÓN 23<br />
Figura 2.2: Ejemplo <strong>de</strong> ejecución <strong>de</strong> Select-Sort<br />
Or<strong>de</strong>nación por Selección<br />
El algoritmo <strong>de</strong> or<strong>de</strong>nación por selección utiliza la siguiente estrategia: Mantiene la parte inicial<br />
<strong>de</strong>l arreglo or<strong>de</strong>nada, digamos los valores A[0..j] y en la siguiente iteración selecciona el menor <strong>de</strong><br />
los valores en A[j + 1..n − 1] y lo intercambia con A[j]:<br />
Select-Sort(A)<br />
for j := 0 to n − 2<br />
do k := j<br />
for i := j + 1 to n − 1 ✄ Después <strong>de</strong> este ciclo<br />
do if A[i] < A[k] ✄ k es el índice <strong>de</strong>l menor<br />
then k := i ✄ elemento <strong>de</strong> A[j + 1..n − 1].<br />
intercambia(A[j],A[k])<br />
La figura 2.2 muestra una ejecución <strong>de</strong> ejemplo <strong>de</strong> este algoritmo para un arreglo en particular. Al<br />
igual que Insert-Sort, en el peor caso Select-Sort toma tiempo O(n 2 ). Una diferencia importante<br />
es que Select-Sort no distingue entre casos favorables, para cualquier arreglo la cantidad <strong>de</strong> tiempo<br />
que le toma es O(n 2 ). Ahora, si se quiere obtener un dato exacto, por ejemplo acerca <strong>de</strong>l número <strong>de</strong><br />
comparaciones o asignaciones, Select-Sort sí hace diferencias <strong>de</strong>pendiendo <strong>de</strong> cómo se encuentren<br />
inicialmente los datos.<br />
Or<strong>de</strong>nación por Mezcla<br />
La or<strong>de</strong>nación por mezcla se basa en la estrategia dividir para conquistar. Primero divi<strong>de</strong> el arreglo<br />
A en dos, or<strong>de</strong>na recursivamente cada uno <strong>de</strong> las partes <strong>de</strong> A y luego las mezcla. El procedimiento<br />
<strong>de</strong>be entonces recibir el arreglo y un par <strong>de</strong> índices que <strong>de</strong>limitan los bor<strong>de</strong>s <strong>de</strong> la or<strong>de</strong>nación. Con<br />
estas consi<strong>de</strong>raciones el código resulta:
24 CAPÍTULO 2. ALGORITMOS DE ORDENACIÓN<br />
Merge-Sort(A,p,r)<br />
if p < r<br />
then q := ⌊ ⌋<br />
p+r<br />
2<br />
Merge-Sort(A,p,q)<br />
Merge-Sort(A,q + 1,r)<br />
Merge(A,p,q,r)<br />
El procedimiento Merge toma un arreglo A y tres índices p ≤ q < r, supone que A[p..q] y<br />
A[q + 1..r] están ambos or<strong>de</strong>nados y mezcla sus valores <strong>de</strong> manera tal que al finalizar A[p..r]<br />
está or<strong>de</strong>nado. Esto se pue<strong>de</strong> hacer en tiempo lineal con respecto a r −p (o sea el largo <strong>de</strong> la porción<br />
que se quiere mezclar) y su implementación se <strong>de</strong>ja como ejercicio. La propiedad más importante <strong>de</strong><br />
un procedimiento que mezcle en tiempo proporcional a r−p es que obligatoriamente necesitará <strong>de</strong> un<br />
arreglo auxiliar para po<strong>de</strong>r realizar la mezcla, lo que diferencia este algoritmo <strong>de</strong> los dos anteriores<br />
que sólo utilizaban el mismo arreglo para or<strong>de</strong>nar.<br />
Para calcular la complejidad <strong>de</strong> Merge-Sort usaremos relaciones <strong>de</strong> recurrencia. Supongamos<br />
que <strong>de</strong>signamos por T(n) a la complejidad <strong>de</strong> tiempo (cantidad <strong>de</strong> operaciones) que le toma a<br />
Merge-Sort or<strong>de</strong>nar un arreglo <strong>de</strong> tamaño n. No es difícil notar que T(n) <strong>de</strong>be cumplir la siguiente<br />
ecuación:<br />
( n<br />
)<br />
T(n) = 2T + c · n (2.1)<br />
2<br />
ya que para or<strong>de</strong>nar el arreglo <strong>de</strong> tamaño n primero <strong>de</strong>berá or<strong>de</strong>nar cada una <strong>de</strong> las mita<strong>de</strong>s <strong>de</strong>l<br />
arreglo para finalmente mezclarlas. En or<strong>de</strong>nar cada mitad la cantidad <strong>de</strong> tiempo que tarda es T ( )<br />
n<br />
2<br />
y en mezclarlos toma Θ(n), que hemos representado por el término c·n, con c una constante. El caso<br />
base <strong>de</strong> la relación <strong>de</strong> recurrencia es cuando el arreglo tiene tamaño 1, es este caso ya que T(1) es<br />
Θ(1), diremos que T(1) = c para alguna constante c. Para <strong>de</strong>sarrollar la anterior relación haremos<br />
un cambio <strong>de</strong> variables que nos simplificará los cálculos, diremos que n = 2 k para algún k, entonces<br />
T(n) = T(2 k ) y <strong>de</strong>sarrollando obtenemos:<br />
T(2 k ) = 2T(2 k−1 ) + c · 2 k<br />
= 2(2T(2 k−2 ) + c · 2 k−1 ) + c · 2 k<br />
= 2 2 T(2 k−2 ) + c · 2 k + c · 2 k<br />
= 2 2 (2T(2 k−3 ) + c · 2 k−2 ) + c · 2 k + c · 2 k<br />
= 2 3 T(2 k−3 ) + c · 2 k + c · 2 k + c · 2 k<br />
.<br />
= 2 i T(2 k−i ) + i · c · 2 k<br />
.<br />
= 2 k T(1) + k · c · 2 k<br />
= c · 2 k + c · k2 k<br />
= c · (2 k + k2 k ).<br />
Ahora, dado que habíamos hecho el cambio n = 2 k (y por lo tanto k = log 2 n) obtenemos<br />
T(n) = c · (n + nlog 2 n)<br />
De don<strong>de</strong> concluimos finalmente que T(n) es Θ(nlog n). Una forma alternativa <strong>de</strong> obtener este<br />
resultado <strong>de</strong> complejidad para Merge-Sort es usando la técnica <strong>de</strong> árboles <strong>de</strong> recursión que se<br />
muestra en la figura 2.3.
2.1. ALGORITMOS SIMPLES DE ORDENACIÓN 25<br />
Figura 2.3: Árbol <strong>de</strong> recursión para la complejidad <strong>de</strong> Merge-Sort.<br />
Muchas veces nos encontraremos con la necesidad <strong>de</strong> calcular complejida<strong>de</strong>s <strong>de</strong> algoritmos recursivos,<br />
el siguiente teorema nos ayuda con una fórmula general que servirá en casi todos los casos<br />
que veremos durante el curso.<br />
Teorema 2.1.1. Sea T(n) una función tal que cumple con la siguiente relación <strong>de</strong> recurrencia:<br />
( n<br />
)<br />
T(n) = aT + f(n)<br />
b<br />
don<strong>de</strong> f(n) es una función en Θ(n log b a ) y tal que T(1) es Θ(1), entonces se cumple que T(n) es<br />
Θ(n log b a log n).<br />
Es importante notar que Merge-Sort es el primer algoritmo que estudiamos que tarda menos<br />
<strong>de</strong> Θ(n 2 ) en el peor caso, Merge-Sort tarda Θ(nlog n) en el peor caso, lo que es un gran avance.<br />
El mayor (e insalvable) problema <strong>de</strong> Merge-Sort es que necesita <strong>de</strong> mucha memoria adicional para<br />
po<strong>de</strong>r or<strong>de</strong>nar los números (específicamente en la tarea <strong>de</strong> mezcla), necesita tanto espacio adicional<br />
como números tenga que or<strong>de</strong>nar.<br />
Ejercicios<br />
1. Implemente el procedimiento Rec-Insert-Sort(A,i) que or<strong>de</strong>ne el arreglo A usando la misma<br />
estrategia que Insert-Sort pero cambiando una iteración por una llamada recursiva. El<br />
parámetro i indica que lo que se quiere or<strong>de</strong>nar es la porción A[0..i] <strong>de</strong>l arreglo, así la llamada<br />
inicial <strong>de</strong>biera ser Rec-Insert-Sort(A,n − 1).<br />
2. Implemente el procedimiento Rec-Select-Sort(A,i) que or<strong>de</strong>ne el arreglo A usando la misma<br />
estrategia que Select-Sort pero cambiando una iteración por una llamada recursiva. El<br />
parámetro i indica que lo que falta por or<strong>de</strong>nar es la porción A[i..n − 1] <strong>de</strong>l arreglo, así la<br />
llamada inicial <strong>de</strong>biera ser Rec-Select-Sort(A,0).<br />
✷
26 CAPÍTULO 2. ALGORITMOS DE ORDENACIÓN<br />
3. Implemente el método Merge(A,p,q,r) que mezcla, en tiempo lineal con respecto a r −p, las<br />
porciones A[p..q] y A[q + 1..r] suponiendo que ambas están or<strong>de</strong>nadas y <strong>de</strong>ja el resultado en<br />
A[p..q].<br />
2.2. Heapsort<br />
En esta sección veremos cómo usar un heap para or<strong>de</strong>nar <strong>de</strong> manera eficiente un arreglo mediante<br />
el método Heap-Sort. La i<strong>de</strong>a principal será aprovechar el hecho <strong>de</strong> que en un heap, el elemento<br />
<strong>de</strong> mayor valor se encuentra en la raíz y que dado que tenemos un heap <strong>de</strong>ficiente en una <strong>de</strong><br />
sus posiciones, arreglarlo se pue<strong>de</strong> hacer <strong>de</strong> manera muy eficiente usando Heapify. Inicialmente<br />
Heap-Sort creará un heap a partir <strong>de</strong> un arreglo arbitrario. Ahora, dado que el mayor elemento<br />
<strong>de</strong>l heap se encuentra en la raíz, po<strong>de</strong>mos insertar este elemento directamente don<strong>de</strong> le correspon<strong>de</strong><br />
en el arreglo, o sea, al final. En particular lo que se hará es reemplazar el elemento mayor por el<br />
último en el arreglo y luego arreglar un posible problema que haya quedado en la raíz, suponiendo<br />
ahora que el heap tiene un elemento menos, para posteriormente repetir el proceso. Siguiendo estas<br />
i<strong>de</strong>as, el código <strong>de</strong> Heap-Sort es:<br />
Heap-Sort(A)<br />
H := Build-Heap(A)<br />
N := H.π<br />
for i := N − 1 downto 1<br />
do intercambia(H.A[0],H.A[i])<br />
H.π := H.π − 1<br />
H.Heapify(0)<br />
Finalente el arreglo A <strong>de</strong> los elementos <strong>de</strong>l heap H contiene los valores or<strong>de</strong>nados.<br />
¿Por qué Heap-Sort funciona? Hasta ahora nos hemos preocupado <strong>de</strong> estudiar algoritmos <strong>de</strong><br />
or<strong>de</strong>nación en cuanto a su complejidad y sólo <strong>de</strong> manera intuitiva hemos argumentado por qué efectivamente<br />
or<strong>de</strong>nan el arreglo. A modo <strong>de</strong> ejemplo, presentamos una <strong>de</strong>mostración formal <strong>de</strong> la<br />
correctitud <strong>de</strong> Heap-Sort en el siguiente teorema.<br />
Teorema 2.2.1. Heap-Sort es correcto, es <strong>de</strong>cir, <strong>de</strong>spués <strong>de</strong> la llamada Heap-Sort(A) el arreglo<br />
A se encuentra efectivamente or<strong>de</strong>nado.<br />
Demostración. Como la mayoría <strong>de</strong> las <strong>de</strong>mostraciones <strong>de</strong> corrección <strong>de</strong> programas, la estrategia<br />
a usar será inducción, a<strong>de</strong>más supondremos que tanto Build-Heap como Heapify son correctos.<br />
Primero estableceremos una propiedad que se cumple en todo momento <strong>de</strong> ejecución <strong>de</strong>l algoritmo.<br />
La propiedad es la siguiente: <strong>de</strong>spués <strong>de</strong> cada iteración <strong>de</strong>l loop principal se cumple que:<br />
1. A[i..N − 1] está or<strong>de</strong>nado,<br />
2. A[0..i − 1] cumple la propiedad <strong>de</strong> heap (es un heap), y<br />
3. A[0..i − 1] ≤ A[i..N − 1], es <strong>de</strong>cir cada elemento en A[0..i − 1] es menor que todos los<br />
elementos <strong>de</strong> A[i..N − 1].<br />
Si <strong>de</strong>mostramos que esta propiedad se cumple en toda iteración, entonces cuando el algoritmo termina<br />
tenemos que i = 1 y que por lo tanto, por (1) A[1..N−1] está or<strong>de</strong>nado y por (3) A[0] ≤ A[1..N−1]<br />
por lo que finalmente A[0..N − 1] está or<strong>de</strong>nado.
2.2. HEAPSORT 27<br />
Lo primero es notar que i <strong>de</strong>crece, por lo que no po<strong>de</strong>mos hacer inducción en i. Demostraremos<br />
esta propiedad entonces por inducción en la cantidad <strong>de</strong> iteraciones <strong>de</strong>l loop, que llamaremos n.<br />
Note que la relación directa entre n e i es n = N − i.<br />
B.I. Si n = 0 ⇒ i = N, o sea antes <strong>de</strong> empezar el ciclo, sabemos que A cumple la propiedad <strong>de</strong> heap<br />
ya que se ejecutó Build-Heap, luego se cumple (2). Ahora, tanto (1) como (3) se cumplen ya<br />
que los índices quedan fuera <strong>de</strong>l arreglo (no hay nada que <strong>de</strong>mostrar).<br />
H.I. Supongamos que se cumple para n (o equivalentemente para i = N − n), o sea que luego <strong>de</strong>l<br />
paso n–ésimo <strong>de</strong> la iteración las propieda<strong>de</strong>s (1), (2) y (3) son ciertas, es <strong>de</strong>cir:<br />
1. A[N − n..N − 1] está or<strong>de</strong>nado,<br />
2. A[0..N − n − 1] cumple la propiedad <strong>de</strong> heap, y<br />
3. A[0..N − n − 1] ≤ A[N − n..N − 1].<br />
T.I. Analicemos ahora el siguiente paso <strong>de</strong> la iteración, o sea la iteración n+1, lo que querría <strong>de</strong>cir<br />
que i = N −(n+1). Por la H.I. (2) sabemos que A[0] ≥ A[0..N −n−1] ya que A[0..N −n−1]<br />
cumple la propiedad <strong>de</strong> heap, y a<strong>de</strong>más por (3) sabemos que A[0] ≤ A[N − n..N − 1], por lo<br />
que al intercambiar A[0] con A[N − (n + 1)] = A[N − n − 1] se tiene que A[N − n − 1..N − 1]<br />
está or<strong>de</strong>nado y que A[0..N − n − 2] ≤ A[N − n − 1..N − 1]. Ahora el cambio pudo haber<br />
hecho que A[0..N − n − 2] <strong>de</strong>jara <strong>de</strong> cumplir la propiedad <strong>de</strong> heap. Hay que notar que justo<br />
antes <strong>de</strong> la llamada a Heapify el valor <strong>de</strong> π cumple con π = i = N −n−2, por lo que luego <strong>de</strong><br />
la llamada a Heapify, estaremos seguros que A[0..N − n − 2] cumple la propiedad <strong>de</strong> heap.<br />
Finalmente hemos <strong>de</strong>mostrado que:<br />
- A[N − (n + 1)..N − 1] está or<strong>de</strong>nado,<br />
- A[0..N − (n + 1) − 1] cumple la propiedad <strong>de</strong> heap, y<br />
- A[0..N − (n + 1) − 1] ≤ A[N − (n + 1)..N − 1].<br />
Por inducción se sigue que la propiedad se cumple para toda iteración <strong>de</strong>l loop principal.<br />
Finalmente cuando el loop termina, tenemos que i = 1, o sea que n = N − 1, <strong>de</strong> don<strong>de</strong> se concluye<br />
(reemplazando n = N −1 en (1) y (3)) que <strong>de</strong>spués <strong>de</strong> la última iteración A[0..N −1] está or<strong>de</strong>nado.<br />
Complejidad <strong>de</strong> Heapsort<br />
Para analizar la complejidad <strong>de</strong> Heap-Sort lo primero que notamos es que se ejecuta una vez<br />
el procedimiento Build-Heap y que el procedimiento Heapify se ejecuta Θ(N) veces. Dado que<br />
Build-Heap toma tiempo Θ(N) y que Heapify toma tiempo Θ(log N) cuando se ejecuta <strong>de</strong>s<strong>de</strong> la<br />
raíz <strong>de</strong> un heap con N elementos, po<strong>de</strong>mos asegurar que Heap-Sort toma tiempo O(N+N log N) =<br />
O(N log N) en el peor caso. ¿Po<strong>de</strong>mos dar una cota más ajustada como lo hicimos en el caso <strong>de</strong><br />
Build-Heap? La respuesta esta vez es no. Un argumento a favor para intentar encontrar una cota<br />
más ajustada es el hecho <strong>de</strong> que Heapify no se ejecuta siempre sobre un heap <strong>de</strong> N elementos, <strong>de</strong><br />
hecho, en cada iteración se disminuye el valor <strong>de</strong> π lo que nos dice que en cada iteración Heapify<br />
se ejecuta sobre un heap cada vez más pequeño. Un argumento muy fuerte en contra <strong>de</strong> intentar<br />
✷
28 CAPÍTULO 2. ALGORITMOS DE ORDENACIÓN<br />
encontrar una cota más ajustada lo daremos en la sección 2.4. Por ahora daremos las pautas <strong>de</strong> la<br />
<strong>de</strong>mostración <strong>de</strong> que Heap-Sort toma tiempo Θ(N log N) en el peor caso y que por lo tanto la cota<br />
ya es ajustada.<br />
En un árbol binario completo, la cantidad <strong>de</strong> hojas que hay a profundidad 1 p es exactamente 2 p ,<br />
luego, dado que Heapify toma tiempo proporcional a la altura <strong>de</strong>l heap en el peor caso, tenemos<br />
que el tiempo total <strong>de</strong> Heap-Sort está dado por la sumatoria:<br />
Θ(N) + 2 P Θ(P) + 2 P −1 Θ(P − 1) + 2 P −2 Θ(P − 2) + · · · + 2 1 Θ(1) + 2 0 Θ(0) = Θ(N) +<br />
= Θ<br />
(<br />
N +<br />
)<br />
P∑<br />
2 p p<br />
p=0<br />
P∑<br />
2 p Θ(p)<br />
con P la profundidad total <strong>de</strong>l árbol. Ahora, la sumatoria interna cumple la siguiente <strong>de</strong>sigualdad:<br />
(P − 1)2 P+1 ≤<br />
P∑<br />
2 p p ≤ P2 P+1 .<br />
p=0<br />
La <strong>de</strong>mostración <strong>de</strong> esta <strong>de</strong>sigualdad se pue<strong>de</strong> hacer por inducción en P y se <strong>de</strong>ja como ejercicio<br />
(ejercicio 3). Dado que el heap es un árbol semicompleto, sabemos que P es aproximadamente log 2 N<br />
por lo que el lado izquierdo <strong>de</strong> la <strong>de</strong>sigualdad se acota <strong>de</strong> la siguiente manera<br />
2 · (log 2 N − 1) · 2 log 2 N ≤<br />
P∑<br />
2 p p<br />
p=0<br />
p=0<br />
(2.2)<br />
<strong>de</strong> don<strong>de</strong> obtenemos que<br />
P∑<br />
2N log 2 N − 2N ≤ 2 p p<br />
p=0<br />
<strong>de</strong> don<strong>de</strong> resulta que la sumatoria ∑ P<br />
p=0 2p p es Ω(N log N). Reemplazando este resultado en nuestro<br />
cálculo inicial 2.2 obtenemos que Heap-Sort tiene complejidad<br />
Θ(N + N log N) = Θ(N log N).<br />
Ejercicios<br />
1. Encuentre una notación O lo más ajustada posible, para el tiempo <strong>de</strong> ejecución <strong>de</strong> Heap-Sort<br />
cuando el arreglo A se encuentra ya or<strong>de</strong>nado.<br />
2. Encuentre una notación O lo más ajustada posible, para el tiempo <strong>de</strong> ejecución <strong>de</strong> Heap-Sort<br />
cuando el arreglo A se encuentra or<strong>de</strong>nado al revés, o sea, <strong>de</strong> mayor a menor.<br />
1 ¡No confundir profundidad con altura!
2.3. QUICKSORT 29<br />
3. Demuestre que para todo P mayor o igual a 0 se cumple<br />
2.3. Quicksort<br />
(P − 1)2 P+1 ≤<br />
P∑<br />
2 p p ≤ P2 P+1 .<br />
En esta sección estudiaremos el algoritmo <strong>de</strong> or<strong>de</strong>nación Quick-Sort que es uno <strong>de</strong> los algoritmos<br />
más eficientes para or<strong>de</strong>nar en un caso promedio (real). Al igual que Merge-Sort, Quick-Sort<br />
usa una estrategia <strong>de</strong>l tipo dividir para conquistar, por lo que es natural plantearlo como un procedimiento<br />
recursivo. La estrategia para or<strong>de</strong>nar el arreglo A[p..r] es la siguiente:<br />
p=0<br />
Divi<strong>de</strong> el arreglo A[p..r] usando un índice q, para obtener A[p..q] y A[q + 1..r], tal que se<br />
cumpla la propiedad<br />
A[p..q] ≤ A[q + 1..r]. (2.3)<br />
Or<strong>de</strong>na recursivamente cada uno <strong>de</strong> las partes A[p..q] y A[q + 1..r].<br />
No necesita mezclar ya que luego <strong>de</strong> or<strong>de</strong>nar recursivamente A[p..q] y A[q + 1..r] por la<br />
propiedad (2.3) anterior, el arreglo A[p..r] resulta or<strong>de</strong>nado.<br />
Debiera quedar claro entonces, dado que no se <strong>de</strong>ben mezclar las soluciones a los subproblemas,<br />
que la mayor parte <strong>de</strong>l trabajo se gastará en encontrar el índice q para particionar el arreglo A <strong>de</strong><br />
manera <strong>de</strong> cumplir la propiedad (2.3). Siguiendo esta estrategia, el código para Quick-Sort es:<br />
Quick-Sort(A,p,r)<br />
if p < r<br />
then q := Partition(A,p,r)<br />
Quick-Sort(A,p,q)<br />
Quick-Sort(A,q + 1,r)<br />
La llamada inicial entonces para or<strong>de</strong>nar el arreglo A es Quick-Sort(A,0,n − 1).<br />
El procedimiento Partition(A,p,r) encuentra un índice q, p ≤ q < r, tal que A[p..q] ≤<br />
A[q + 1..r]. Para hacerlo, elige un pivote <strong>de</strong>ntro <strong>de</strong>l arreglo y luego <strong>de</strong>ja en la parte inicial <strong>de</strong><br />
A[p..r] sólo elementos que sean menores o iguales al pivote, y en la parte final <strong>de</strong> A[p..r] sólo<br />
elementos que sean mayores o iguales que el pivote. En la siguiente implementación <strong>de</strong> Partition<br />
se elige como pivote al primer elemento <strong>de</strong> A[p..r].<br />
Partition(A,p,r)<br />
x := A[p] ✄ Elige a A[p] como pivote<br />
i := p − 1<br />
j := r + 1<br />
while true<br />
do repeat j := j − 1 while A[j] > x<br />
repeat i := i + 1 while A[i] < x<br />
if i < j<br />
then intercambia(A[i],A[j])<br />
else return j
30 CAPÍTULO 2. ALGORITMOS DE ORDENACIÓN<br />
Figura 2.4: Ejemplo <strong>de</strong> ejecución <strong>de</strong> Partition.<br />
Lo que hace Partition es usar dos índices i y j para apuntar la parte inicial y final <strong>de</strong>l arreglo<br />
A[p..r], y establecer x = A[p] como el pivote <strong>de</strong> la partición. Dentro <strong>de</strong>l loop principal, el índice j es<br />
<strong>de</strong>crementado mientras A[j] sea mayor que el pivote, y el índice i es incrementado mientras A[i] sea<br />
menor que el pivote. Cuando estas dos propieda<strong>de</strong>s sean violadas, sabemos que A[j] es muy pequeño<br />
para estar en la parte final <strong>de</strong> A[p..r] y que A[i] es muy gran<strong>de</strong> para estar en la parte inicial <strong>de</strong><br />
A[p..r], por lo que ambos valores son intercambiados. El loop termina cuando los índices i y j se<br />
cruzan, entregando j como punto <strong>de</strong> partición. La figura 2.4 muestra un ejemplo <strong>de</strong> ejecución <strong>de</strong>l<br />
procedimiento Partition.<br />
Un punto muy importante es que Partition entrega siempre un valor q ≠ r, si en algún caso<br />
entregara q = r, Quick-Sort haría infinitas llamadas recursivas y nunca terminaría. La elección<br />
<strong>de</strong> A[p] como el pivote es crucial en esta propiedad, <strong>de</strong> hecho, si en vez se hubiese elegido a A[r]<br />
como pivote hubiese sido posible que Partition entregara q = r (¿cuándo?). El ejercicio 1 le pi<strong>de</strong><br />
<strong>de</strong>mostrar que Quick-Sort es correcto a partir <strong>de</strong> suponer que Partition es correcto. El ejercicio 3<br />
le pi<strong>de</strong> <strong>de</strong>mostrar que Partition es correcto.<br />
Complejidad <strong>de</strong> Quicksort<br />
Primero, no es difícil notar que Partition(A,p,r) toma tiempo lineal con respecto a r − p.<br />
Ahora, llamemos T(n) al tiempo que le toma a Quick-Sort or<strong>de</strong>nar un arreglo <strong>de</strong> n elementos.<br />
Dado que Quick-Sort particiona el arreglo en dos y el procedimiento <strong>de</strong> partición tarda tiempo<br />
lineal con respecto al largo total <strong>de</strong>l arreglo, po<strong>de</strong>mos <strong>de</strong>cir que el tiempo que le toma a Quick-Sort<br />
está regido por la relación <strong>de</strong> recurrencia<br />
T(n) = T(p) + T(n − p) + c · n (2.4)<br />
T(1) = c<br />
cuando suponemos que el tamaño <strong>de</strong> una <strong>de</strong> las particiones producidas por Partition es p. La<br />
complejidad <strong>de</strong> Quick-Sort <strong>de</strong>pen<strong>de</strong>rá entonces directamente <strong>de</strong> la calidad <strong>de</strong> la partición.<br />
Supongamos que la partición se lleva a cabo <strong>de</strong> manera tal que siempre se obtiene una división<br />
<strong>de</strong>l arreglo con sólo un elemento y otra con los n−1 elementos restantes, o sea, p = 1. Reemplazando
2.3. QUICKSORT 31<br />
Figura 2.5: Árbol <strong>de</strong> recursión para Quick-Sort en el caso <strong>de</strong> una partición <strong>de</strong>sbalanceada.<br />
en (2.4) obtenemos<br />
T(n) = T(1) + T(n − 1) + c · n<br />
<strong>de</strong> don<strong>de</strong> resolviendo obtenemos<br />
T(n) = c + T(n − 1) + c · n<br />
= 2c + T(n − 2) + c · (n − 1) + c · n<br />
= 3c + T(n − 3) + c · (n − 2) + c · (n − 1) + c · n<br />
.<br />
= c · (n − 1) + T(1) + c · 2 + · · · + c · (n − 1) + c · n<br />
= c · (n − 1) + c · ∑n<br />
i=1 i<br />
por lo que T(n) es Θ(n 2 ), o sea, en el caso <strong>de</strong> que la partición produzca siempre una región <strong>de</strong><br />
largo 1 y otra <strong>de</strong> largo n − 1, Quick-Sort se comporta asintóticamente como el pero caso <strong>de</strong><br />
Insert-Sort y Select-Sort. El árbol <strong>de</strong> recursión para este caso se muestra en la figura 2.5. Se<br />
pue<strong>de</strong> <strong>de</strong>mostrar, no lo haremos aquí, que este es el peor comportamiento posible para Quick-Sort.<br />
Un punto interesante <strong>de</strong> observar es que este patrón <strong>de</strong> particiones se obtienen cuando el arreglo <strong>de</strong><br />
entrada se encuentra ya or<strong>de</strong>nado.<br />
Supongamos ahora que la partición se produce <strong>de</strong> manera tal que siempre <strong>de</strong>ja regiones <strong>de</strong>l mismo<br />
largo, o sea, dos regiones <strong>de</strong> largo n 2<br />
. Reemplazando en (2.4) obtenemos<br />
( n<br />
) ( n<br />
) ( n<br />
)<br />
T(n) = T + T + c · n = 2T + c · n<br />
2 2 2<br />
que es igual a la ecuación (2.1), por lo que T(n) es Θ(nlog n) en este caso. El árbol <strong>de</strong> recursión es<br />
el mismo que aparece en la figura 2.3.<br />
Hemos visto un caso malo y uno bueno (comparativamente con los anteriores algoritmos estudiados).<br />
¿Cómo po<strong>de</strong>mos analizar un caso promedio <strong>de</strong> Quick-Sort? Una posibilidad es pensar que<br />
las particiones se generan siempre con cierta proporción no balanceada que esté entre el caso 1 vs<br />
n − 1 y el caso n 2 vs n 2<br />
. Por ejemplo, supongamos que la partición siempre genera regiones <strong>de</strong> largo
32 CAPÍTULO 2. ALGORITMOS DE ORDENACIÓN<br />
n<br />
c · n<br />
log 10 n<br />
n<br />
10<br />
9n<br />
10<br />
c · n<br />
log 10/9 n<br />
n<br />
100<br />
9n<br />
100<br />
9n<br />
100<br />
81n<br />
100<br />
c · n<br />
1<br />
81n<br />
1000<br />
729n<br />
1000<br />
c · n<br />
≤ c · n<br />
1<br />
≤ c · n<br />
Θ(nlog n)<br />
Figura 2.6: Árbol <strong>de</strong> recursión para Quick-Sort cuando la partición se produce en relación 9 : 1.<br />
en razón 9 : 1, o sea p = n 10<br />
. Reemplazando en (2.4) obtenemos<br />
( ( ) n 9n<br />
T(n) = T + T + c · n.<br />
10)<br />
10<br />
El árbol <strong>de</strong> recursión para este caso se muestra en la figura 2.6. En él po<strong>de</strong>mos notar que cada<br />
nivel <strong>de</strong>l árbol tiene un costo <strong>de</strong> c · n (por la suma <strong>de</strong> la aplicación <strong>de</strong> Partition a cada porción<br />
<strong>de</strong>l arreglo) hasta una condición <strong>de</strong> bor<strong>de</strong> que se alcanza cuando las llamadas recursivas <strong>de</strong> más a<br />
la izquierda <strong>de</strong>jan <strong>de</strong> suce<strong>de</strong>r, o sea, cuando estas se hagan sobre arreglos <strong>de</strong> tamaño 1. Esto pasa<br />
a profundidad log 10 n en la parte izquierda <strong>de</strong>l árbol. Des<strong>de</strong> ese punto en a<strong>de</strong>lante, cada nivel <strong>de</strong>l<br />
árbol tendrá costo total menor o igual a c ·n. La recursión completa termina a profundidad log 10/9 n<br />
por lo que la suma total <strong>de</strong>l trabajo será Θ(nlog 10/9 n) = Θ(nlog n). No es difícil ver que cualquier<br />
proporción que elijamos, por <strong>de</strong>sbalanceada que esta parezca, nos llevará a concluir que la eficiencia<br />
asintótica <strong>de</strong> Quick-Sort resulta Θ(nlog n), la diferencia principal estará dada por la constante<br />
escondida que acompaña a nlog n.<br />
Otra forma, mucho más acertada, <strong>de</strong> obtener una intuición <strong>de</strong>l caso promedio <strong>de</strong> Quick-Sort<br />
es suponiendo que todas las entradas son igualmente probables <strong>de</strong> obtener. Si este es el caso, es<br />
muy poco probable que todas las particiones ocurran <strong>de</strong> la misma forma, todas balanceadas, todas<br />
<strong>de</strong>sbalanceadas, todas en la misma proporción, etc. En un caso promedio Partition generará una<br />
mezcla entre “buenas” y “malas” particiones. Será mucho más acertado suponer entonces que las<br />
buenas y malas particiones se van mezclando en el árbol <strong>de</strong> recursión. Por simplicidad po<strong>de</strong>mos<br />
suponer que vez por medio se obtiene una buena y una mala partición, es <strong>de</strong>cir, que en una llamada<br />
se obtiene una partición <strong>de</strong>l tipo 1 vs n − 1 y en otra se obtiene una partición <strong>de</strong>l tipo n 2 vs n 2 .<br />
Una porción <strong>de</strong> este árbol <strong>de</strong> recursión se muestra en la figura 2.7. En ella también se muestra<br />
como un árbol <strong>de</strong> recursión con malas y buenas particiones intercaladas, se pue<strong>de</strong> ver como un árbol<br />
<strong>de</strong> recursión con sólo buenas particiones. Si se analiza con cuidado este caso se concluye que en él<br />
también el comportamiento asintótico <strong>de</strong> Quick-Sort resulta ser Θ(nlog n).
2.3. QUICKSORT 33<br />
n<br />
Θ(n)<br />
Θ(n)<br />
1 n − 1<br />
n−1<br />
2<br />
n−1<br />
2<br />
⇐⇒<br />
n<br />
n−1<br />
2<br />
+ 1<br />
n−1<br />
2<br />
Figura 2.7: Porción <strong>de</strong>l árbol <strong>de</strong> recursión para Quick-Sort cuando las particiones se producen<br />
intercalando buenas y malas (izquierda), y cómo esta pue<strong>de</strong> representarse por una porción <strong>de</strong> un<br />
árbol con sólo particiones buenas (<strong>de</strong>recha).<br />
Quicksort Aleatorio<br />
Hemos entregado evi<strong>de</strong>ncia acerca <strong>de</strong> que, si todas las posibles entradas son igualmente probables,<br />
entonces Quick-Sort tiene un buen comportamiento esperado, Θ(nlog n). ¿Qué tan razonable es la<br />
suposición <strong>de</strong> que todas las entradas son igualmente probables? Depen<strong>de</strong> <strong>de</strong>masiado <strong>de</strong> la aplicación.<br />
Por ejemplo, suponga una aplicación que or<strong>de</strong>na datos <strong>de</strong> personas por fecha <strong>de</strong> nacimiento, si ocurre<br />
el caso <strong>de</strong> que los datos se encontraban inicialmente or<strong>de</strong>nados por RUT, Quick-Sort tendrá un mal<br />
comportamiento ¿por qué? El caso peor ocurre si quien entrega la entrada es un adversario. Si él sabe<br />
que estamos utilizando Quick-Sort para or<strong>de</strong>nar el arreglo pue<strong>de</strong> forzar un mal comportamiento.<br />
Una alternativa para solucionar estos problemas es, en vez <strong>de</strong> suponer que todas las entradas<br />
son igualmente probables, imponer que todas lo sean. Esto se pue<strong>de</strong> lograr, por ejemplo, haciendo<br />
permutaciones aleatorias <strong>de</strong> los elementos antes <strong>de</strong> comenzar a or<strong>de</strong>narlos. Entonces Quick-Sort<br />
podría obtener un mejor comportamiento esperado si antes <strong>de</strong> comenzar a or<strong>de</strong>nar los valores, se<br />
asegura <strong>de</strong> que estos estén suficientemente “<strong>de</strong>sor<strong>de</strong>nados”, <strong>de</strong>sor<strong>de</strong>nados en el sentidos <strong>de</strong> que no<br />
<strong>de</strong>pendan <strong>de</strong> una aplicación en particular. Importante es notar que esta modificación no mejora el<br />
peor comportamiento <strong>de</strong> Quick-Sort, <strong>de</strong> hecho podría tenerse tan mala suerte que en el proceso<br />
<strong>de</strong> “<strong>de</strong>sor<strong>de</strong>n” el arreglo que<strong>de</strong> ya or<strong>de</strong>nado, en cuyo caso se obtendrá <strong>de</strong> todas maneras un mal<br />
comportamiento. Lo que si estamos seguros que se logra es in<strong>de</strong>pendizarse <strong>de</strong> la fuente <strong>de</strong> datos<br />
<strong>de</strong> entrada, la probabilidad <strong>de</strong> obtener un mal <strong>de</strong>sempeño ya no <strong>de</strong>pen<strong>de</strong>rá <strong>de</strong> la aplicación o <strong>de</strong> si<br />
un adversario es quién provee los datos <strong>de</strong> entrada.<br />
El siguiente algoritmo Rand-Quick-Sort implementa esta i<strong>de</strong>a llamando al procedimiento<br />
Rand-Partition para realizar la partición.<br />
Rand-Quick-Sort(A,p,r)<br />
if p < r<br />
then q := Rand-Partition(A,p,r)<br />
Rand-Quick-Sort(A,p,q)<br />
Rand-Quick-Sort(A,q + 1,r)
34 CAPÍTULO 2. ALGORITMOS DE ORDENACIÓN<br />
Rand-Partition(A,p,r)<br />
i :=rand(p,r)<br />
intercambia(A[p],A[i])<br />
return Partition(A,p,r)<br />
En esta implementación no se está <strong>de</strong>sor<strong>de</strong>nando el arreglo inicialmente, en cambio, se están tomando<br />
<strong>de</strong>cisiones aleatorias <strong>de</strong> vez en cuando. Esta <strong>de</strong>cisión aleatoria tiene que ver con la elección <strong>de</strong>l pivote<br />
para realizar la partición. La función rand(p,r) entrega un número entero aleatorio entre p y r.<br />
Ejercicios<br />
1. Demuestre que Quick-Sort es correcto suponiendo que Partirion es correcto, es <strong>de</strong>cir,<br />
<strong>de</strong>muestre que efectivamente <strong>de</strong>spués <strong>de</strong> la llamada Quick-Sort(A,0,n − 1) sobre un arreglo<br />
<strong>de</strong> tamaño n, el arreglo queda efectivamente or<strong>de</strong>nado, suponiendo que la llamada a<br />
Partition(A,p,r) entrega un índice q tal que p ≤ q < r y A[p..q] ≤ A[q + 1..r] <strong>de</strong>spués <strong>de</strong><br />
la llamada.<br />
2. De una argumentación simple <strong>de</strong> por qué Partition(A,p,r) toma tiempo lineal con respecto<br />
a r − p.<br />
3. Demuestre que Partition es correcto, es <strong>de</strong>cir, <strong>de</strong>muestre que la llamada a Partition(A,p,r)<br />
entrega un índice q tal que p ≤ q < r y A[p..q] ≤ A[q + 1..r] <strong>de</strong>spués <strong>de</strong> la llamada.<br />
2.4. Or<strong>de</strong>nación en Tiempo Esperado Lineal<br />
2.5. Estadísticas <strong>de</strong> Or<strong>de</strong>n
Capítulo<br />
3<br />
Estructuras <strong>de</strong> Datos para Diccionarios<br />
(Aquí una pequeña introducción)<br />
3.1. <strong>Tabla</strong>s <strong>de</strong> Hash<br />
3.2. Árboles Binarios <strong>de</strong> Búsqueda<br />
3.3. Árboles AVL<br />
3.4. Árboles Rojo–Negro<br />
3.5. SkipLists<br />
3.6. Aumento <strong>de</strong> una Estructura <strong>de</strong> Datos<br />
35
36 CAPÍTULO 3. ESTRUCTURAS DE DATOS PARA DICCIONARIOS
Capítulo<br />
4<br />
Estructuras <strong>de</strong> Datos Externas<br />
(Aquí una pequeña introducción)<br />
4.1. In<strong>de</strong>xación Secuencial<br />
4.2. Árboles–B<br />
37
38 CAPÍTULO 4. ESTRUCTURAS DE DATOS EXTERNAS
Capítulo<br />
5<br />
Otras Estructuras <strong>de</strong> Datos<br />
(Aquí una pequeña introducción)<br />
5.1. Estructuras para Conjuntos Disjuntos<br />
5.2. Heaps Binomiales<br />
5.3. Heaps <strong>de</strong> Fibbonacci<br />
39
40 CAPÍTULO 5. OTRAS ESTRUCTURAS DE DATOS
Capítulo<br />
6<br />
Técnicas Fundamentales <strong>de</strong> Diseño <strong>de</strong> Algoritmos<br />
6.1. Analisis Amortizado<br />
6.2. Algoritmos Codiciosos<br />
6.3. Divir para Conquistar<br />
6.4. Programación Dinámica<br />
41
42 CAPÍTULO 6. TÉCNICAS FUNDAMENTALES DE DISEÑO DE ALGORITMOS
Capítulo<br />
7<br />
Algoritmos en Grafos<br />
(Aquí una pequeña introducción)<br />
7.1. Recorridos en Profundidad y Amplitud<br />
7.2. Or<strong>de</strong>n Topológico<br />
7.3. Componentes Fuertemente Conexas<br />
7.4. Árboles <strong>de</strong> Cobertura <strong>de</strong> Costo Mínimo<br />
7.5. Caminos más Cortos<br />
7.6. Flujo Máximo en Re<strong>de</strong>s<br />
43
44 CAPÍTULO 7. ALGORITMOS EN GRAFOS
Capítulo<br />
8<br />
Algoritmos sobre Strings<br />
(Aquí una pequeña introducción)<br />
8.1. Pattern Matching<br />
8.2. Pattern Matching <strong>de</strong> Expresiones Regulares<br />
8.3.<br />
45
46 CAPÍTULO 8. ALGORITMOS SOBRE STRINGS
Capítulo<br />
9<br />
Estructuras y Algoritmos para Computación Gráfica<br />
(Aquí una pequeña introducción)<br />
9.1.<br />
47
48 CAPÍTULO 9. ESTRUCTURAS Y ALGORITMOS PARA COMPUTACIÓN GRÁFICA
Capítulo<br />
10<br />
Introducción a la Complejidad Computacional<br />
(Aquí una pequeña introducción)<br />
10.1. Las clases <strong>de</strong> problemas P y NP<br />
10.2. Problemas NP-duros y NP-completos<br />
10.3. La clase co-NP<br />
10.4. Más allá <strong>de</strong> P<br />
10.5. Dentro <strong>de</strong> P<br />
49
Índice<br />
50