20.03.2015 Views

Tabla de Contenidos

Tabla de Contenidos

Tabla de Contenidos

SHOW MORE
SHOW LESS

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

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

Saved successfully!

Ooh no, something went wrong!