20.03.2015 Views

Tabla de Contenidos

Tabla de Contenidos

Tabla de Contenidos

SHOW MORE
SHOW LESS

You also want an ePaper? Increase the reach of your titles

YUMPU automatically turns print PDFs into web optimized ePapers that Google loves.

<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

Hooray! Your file is uploaded and ready to be published.

Saved successfully!

Ooh no, something went wrong!