0% encontró este documento útil (0 votos)
14 vistas174 páginas

220726-Ada-Notes 1 5

Este documento presenta una introducción a los conceptos fundamentales de diseño y análisis de algoritmos. En el capítulo 0 se definen arreglos y problemas algorítmicos, y se introducen convenciones para su especificación. El capítulo 1 trata sobre el análisis asintótico de algoritmos para medir su complejidad. Los capítulos subsiguientes cubren técnicas como divide y vencerás, programación dinámica y algoritmos voraces para diseñar soluciones eficientes a problemas clásicos.

Cargado por

Juan José Marin
Derechos de autor
© © All Rights Reserved
Nos tomamos en serio los derechos de los contenidos. Si sospechas que se trata de tu contenido, reclámalo aquí.
Formatos disponibles
Descarga como PDF, TXT o lee en línea desde Scribd
0% encontró este documento útil (0 votos)
14 vistas174 páginas

220726-Ada-Notes 1 5

Este documento presenta una introducción a los conceptos fundamentales de diseño y análisis de algoritmos. En el capítulo 0 se definen arreglos y problemas algorítmicos, y se introducen convenciones para su especificación. El capítulo 1 trata sobre el análisis asintótico de algoritmos para medir su complejidad. Los capítulos subsiguientes cubren técnicas como divide y vencerás, programación dinámica y algoritmos voraces para diseñar soluciones eficientes a problemas clásicos.

Cargado por

Juan José Marin
Derechos de autor
© © All Rights Reserved
Nos tomamos en serio los derechos de los contenidos. Si sospechas que se trata de tu contenido, reclámalo aquí.
Formatos disponibles
Descarga como PDF, TXT o lee en línea desde Scribd
Está en la página 1/ 174

Diseño y Análisis de Algoritmos

Camilo Rocha
Email address: [email protected]
Para Laura.
© Derechos de autor 2019-2022 Camilo Rocha.
Última actualización 30 de octubre de 2022.
Versión 0.0

cbna
Esta obra está bajo una licencia de Creative Commons
Reconocimiento-NoComercial-CompartirIgual 4.0 Internacional.
Este trabajo puede ser copiado y distribuı́do libremente, como copia electrónica o en
papel. No puede ser vendido por un valor mayor a su costo actual de reproducción,
almacenamiento o transmisión.
Índice general

Capı́tulo 0. Preliminares 1
0.1. Arreglos: notación y convenciones 1
0.2. Problemas algorı́tmicos y su especificación 4
0.3. El concepto de algoritmo 11
0.4. Especificación de algoritmos 16
Notas del capı́tulo y referencias 18

Capı́tulo 1. Análisis asintótico 21


1.1. Medidas para problemas y algoritmos 22
1.1.1. El tamaño de la entrada 22
1.1.2. Asociando medidas a un algoritmo 23
1.2. Notación asintótica 25
1.3. Propiedades de la notación asintótica 31
1.4. El Teorema Maestro 35
Notas del capı́tulo y referencias 41

Capı́tulo 2. Dividir, conquistar y combinar 43


2.1. Teselación de tableros de ajedrez 43
2.1.1. Especificación del problema 44
2.1.2. Diseño de una solución 45
2.2. Ordenamiento de arreglos 49
2.3. Una versión iterativa del ordenamiento 55
2.4. Mergesort: un ordenamiento de arreglos más eficiente 60
2.5. Búsqueda binaria 67

7
8 Índice general

Notas del capı́tulo y referencias 74

Capı́tulo 3. Programación dinámica 75


3.1. Ideas clave 76
3.2. Cálculo de los números de Fibonacci 79
3.2.1. Memorización 82
3.2.2. Tabulación 86
3.3. Una metodologı́a 91
3.4. Suma máxima de un subarreglo 93
3.5. El problema del morral 102
3.5.1. Primera optimización 111
3.5.2. Segunda optimización 114
3.6. El problema del agente viajero 117
3.6.1. Diseño de una función objetivo 119
3.6.2. Especificación de conjuntos con máscaras de bits 121
3.6.3. Diseño de un algoritmo con memorización 123
Notas del capı́tulo y referencias 128

Capı́tulo 4. Algoritmos voraces 131


4.1. Agendamiento de actividades 132
4.1.1. Abstracción y estrategia voraz 133
4.1.2. Una implementación eficiente 137
4.2. Árboles de cubrimiento mı́nimo 142

Capı́tulo 5. Decidibilidad y completitud 147


5.1. Problemas de decisión 149
5.2. Un marco universal basado en lenguajes 152
5.3. Aceptación y decisión 155

Bibliografı́a 161

Índice alfabético 163


Capı́tulo 0

Preliminares

El propósito de un algoritmo es resolver mecánicamente un problema –o, equi-


valentemente, todas las instancias de un problema. Contar con una noción de al-
goritmo para este propósito requiere antes precisar qué se entiende por y cómo se
define un problema algorı́tmico. La finalidad de este capútulo es presentar un marco
general para la definición de problemas algorı́tmicos, e introducir convenciones para
su posterior diseño y análisis.

0.1. Arreglos: notación y convenciones

Los problemas algorı́tmicos que se estudian en este texto, en su gran mayorı́a,


son definidos y estudiados con base en arreglos. Un arreglo pueden contener núme-
ros, caracteres, valores Booleanos, entre otros. También se pueden componer entre
sı́ para formar matrices, hipermatrices como cubos, etc.

Definición 0.1.1

Un arreglo de tamaño N es una estructura de datos que almacena una colec-


ción de elementos (o valores), cada uno identificado por un ı́ndice correspon-
diente a un número natural en el rango 0 .. N − 1.

No es de interés en este texto estudiar detalles de implementación de arreglos en


lenguajes de programación en general o en un lenguaje de programación particular.
Más bien, se supondrá que dado un arreglo, el acceso a cada uno de sus elementos se
hace de manera eficiente, como sucede realmente en la práctica de la programación.

1
2 0. Preliminares

Nota 0.1.1

Se usa una notación similar a la de rangos en los números reales, con paréntesis
circulares ( ) y llaves cuadradas [ ], para especificar arreglos y definir opera-
ciones (de indexación y proyección) sobre ellos.
La expresión A[0..N ) indica que A es un arreglo de N elementos con
ı́ndices en el rango 0..N − 1.
Con A[0..N ) y n un número natural, la indexación A[n] denota el valor
de A en la posición n siempre y cuando 0 ≤ n < N . Si n ≥ N , entonces
la expresión es un error.
Por ejemplo, con A = [1, 0, 25, −1, 8] se tiene A[3] = −1 y A[6] es un
error.
Un arreglo sin ı́ndices se llama vacı́o.
Con A[0..N ) y a, b números naturales, la expresión A[a..b) denota la sec-
ción (o el subarreglo) de A, la cual es también un arreglo que:
• cuando 0 ≤ a < b ≤ N , re-indexa desde 0 y hasta b − (a + 1),
respetando su orden, los elementos de A entre a y b − 1 (i.e., b no se
incluye), y
• de lo contrario, el arreglo vacı́o.
Por ejemplo, A[1..3) = [0, 25] con A = [1, 0, 25, −1, 8]; A[2..2) denota
el arreglo vacı́o.
El tamaño (o, equivalentemente, la cantidad de ı́ndices) de un arreglo o
una sección de un arreglo se denota como | |.

Adoptando la notación introducida para arreglos en la Nota 0.1.1, una matriz


A de tamaño N × M se especifica como A[0..N )[0..M ), y ası́ sucesivamente. Una
cadena de tamaño N puede representarse como un arreglo de tamaño N . Abusando
un poco de la notación, las expresiones A[0..N ) y A[0..N − 1] denotan el mismo
arreglo. Es importante tener en cuenta que cualquier operación de indexación en el
arreglo vacı́o resulta en un error.
Los arreglos también se pueden concebir como objetos matemáticos. De esta
forma es posible referirse a ellos de manera abstracta y formal (e.g., no atada a su
implementación en un lenguaje de programación particular), posibilitando ası́ un
diseño y análisis algorı́tmico más enfocado en las ideas que una implementación en
particular, cuando sea conveniente.

Nota 0.1.2
0.1. Arreglos: notación y convenciones 3

Matemáticamente, un arreglo A[0..N ) es una función A con dominio


{0, 1, . . . , N − 1}
y co-dominio
{A[0], A[1], . . . , A[N − 1]}.
El conjunto vacı́o es el dominio y co-dominio del arreglo vacı́o.

Note que no se menciona el tipo de elementos que almacena un arreglo. Esta


información deberá entenderse del contexto dentro del cual se usan los arreglos en
cada caso o se suministrará cuando sea necesario.

Ejercicios

1. Considere dos arreglos A[0..M ) y B[0..N ).


a) Describa e implemente un algoritmo que permita determinar si los dos
arreglos son iguales.
b) Describa e implemente un algoritmo que permita determinar si B es sub-
arreglo de A.

2. Investigue sobre árboles binarios casi llenos (e.g., aquellos que se utilizan en
la implementación de HeapSort como implementación de colas de prioridad).
Explique brévemente cómo representar un árbol binario casi lleno (e.g., de
números) en un arreglo. ¿Qué relación hay entre los ı́ndices del arreglo y la
profundidad de los elementos del árbol?

3. En el lenguaje de programación Python, los arreglos son en realidad listas


indexadas. Investigue sobre cuál es la principal diferencia entre los arreglos de
Python y los arreglos de C/C++/Java. ¿Qué ventajas tienen los arreglos de
Python sobre los arreglos en estos otros lenguajes? ¿Tienen alguna desventaja?

4. Cuando están disponibles en un lenguaje de programación imperativo, los arre-


glos se pueden pasar como parámetros de funciones o procedimientos. Hay
diferentes formas de pasar un arreglo como parámetro: por ejemplo, por va-
lor o por referencia. Considere las siguientes preguntas para los lenguajes de
programación Python, C, C++, Java:
a) ¿Cuál es el mecanismo por defecto al pasar un arreglo como parámetro?
b) ¿Se puede modificar el comportamiento por defecto? De ser posible, ¿cómo?
c) ¿Cuáles son las principales ventajas y desventajas de cada uno de los me-
canismos identificados anteriormente?
4 0. Preliminares

0.2. Problemas algorı́tmicos y su especificación

Un computador puede ser utilizado para asistir en la mecanización de un


sinnúmero de tareas, de diferentes envergaduras y con propósitos muy diversos.
Por ejemplo, un computador puede ser utilizado para encontrar la ruta más rápida
(o corta) para conducir de un lugar a otro en una ciudad. También puede ser utili-
zado para almacenar y analizar grandes volúmenes de datos como los asociados al
genoma humano. Hoy en dı́a, los computadores son la herramienta de trabajo en
muchas profesiones y se requiere para una gran cantidad de actividades.
Generalmente, es necesario diseñar piezas algorı́tmicas pequeñas y con propósi-
tos muy especı́ficos que, al componerse con otras piezas, permitan abordar tareas
de mayor dificultad, como las mencionadas anteriormente. En algunos lenguajes de
programación estas piezas reciben el nombre de funciones, procedimientos o módu-
los.

Nota 0.2.1

El propósito de este manuscrito es presentar técnicas de diseño y análisis de al-


goritmos para construir satisfactoriamente piezas algorı́tmicas fundamentales,
que junto con otras piezas pueden conformar grandes sistemas de cómputo,
información o procesamiento.

Conceptualmente, se puede aceptar que un problema es una pregunta formulada


y a la cual se desea encontrar respuesta. De esta forma, se puede concebir un
problema algorı́tmico como un problema cuya respuesta puede ser encontrada con
la asistencia de un computador que ejecute macánicamente unas instrucciones.

Definición 0.2.1

La especificación de un problema algorı́tmico está conformada por dos partes:


la descripción de la entrada y la descripción de la salida.

En un problema algorı́tmico, la descripción de la entrada identifica cuáles son los


datos relevantes que hacen parte del planteamiento del problema. La descripción
de la salida identifica la pregunta que se desea responder con base en los datos
disponibles, previamente identificados en la descripción de la entrada.

Nota 0.2.2

Se usa el siguiente formato para presentar problemas algorı́tmicos:


0.2. Problemas algorı́tmicos y su especificación 5

Entrada: ...
Salida: ...

en donde a la derecha de “Entrada:” se describe la entrada del problema y a


la derecha de “Salida:” la salida del problema.

El Ejemplo 0.2.1 presenta la especificación del problema de ordenamiento de


un arreglo de números.

Ejemplo 0.2.1

Considere el problema de ordenar un arreglo de números. El problema al-


gorı́tmico asociado puede definirse de la siguiente manera:

Entrada: Un arreglo A[0..N ), con N ≥ 0, de números enteros.


Salida: El arreglo A[0..N ) ordenado ascendentemente.

Observe que en el proceso de especificar el problema algorı́tmico de ordenar


ascendentemente un arreglo de números en el Ejemplo 0.2.1 se tomaron algunas
decisiones. Primero, se indica concretamente la estructura de datos que representa
dicha lista y cómo se llama: en este caso, la lista está dada como un arreglo llamado
A y que contiene N números enteros, indexados desde 0. Segundo, se identifican
qué casos de arreglos de números enteros son considerados: la entrada indica que
se considera cualquier arreglo de números enteros, incluyendo el arreglo vacı́o (¿por
qué?). Tercero, se establece unı́vocamente que el objetivo es ordenar el arreglo dado
y no una copia de este, es decir, se plantea hacer un ordenamiento in situ del arreglo
dado.
Un problema algorı́tmico puede contener “variables” a las cuales no han sido
asignados valores concretos en su especificación. En el Ejemplo 0.2.1, este es el caso
de A y N : indican que la entrada del problema puede ser cualquier arreglo (llamado
A por conveniencia o por falta de imaginación) de cualquier tamaño. La intención
de emplear variables en la especificación de un problema algorı́tmico, como A y N
en el Ejemplo 0.2.1, es transmitir el mensaje de que su formulación es de carácter
general y no particular.

Definición 0.2.2

Un parámetro es una variable que aparece en la especificación de un problema


algorı́tmico y no tiene un valor concreto.
6 0. Preliminares

Dado que la especificación de un problema puede tener parámetros, un proble-


ma algorı́tmico en realidad representa una colección de problemas concretos: uno
por cada asignación de valores concretos a sus parámetros y que cumple con las
condiciones dadas. Es decir, un problema algorı́tmico con parámetros es una expre-
sión simbólica que representa una colección de problemas concretos (o instancias),
aquellos que especı́ficamente se desea resolver en la práctica.

Definición 0.2.3

Una instancia de un problema algorı́tmico es una asignación de valores con-


cretos a los parámetros de su especificación (y que cumplen con las condiciones
dadas).

Un problema algorı́tmico, en general, tiene tantas instancias como asignaciones


tengan sus parámetros. De esta forma, hay problemas algorı́tmicos que representan
una cantidad finita de instancias y otros una cantidad infinita de ellas.

Ejemplo 0.2.2

Considere el problema algorı́tmico presentado en el Ejemplo 0.2.1. Las siguien-


tes son algunas de sus instancias:
A = [] y N = 0.
A = [1, 2, 3] y N = 3.
A = [2, 7, 1, 9, 8] y N = 5.

Ejemplo 0.2.3

La noción de instancia ha sido útil desde la formación básica en matemáticas.


Por ejemplo, al establecer la ecuación (a + b)2 = a2 + 2ab + b2 , con a y b
números reales, lo que realmente se está indicando es que para cualquier par
de números reales (i.e., instancias de a y b) se puede calcular su suma al
cuadrado calculando independientemente sus cuadrados y sumando el doble
de su producto.

Aún no se ha definido claramente qué se entiende por responder una pregunta


algorı́tmica. ¿Acaso se refiere a resolver una instancia de un problema algorı́tmico?
¿O a resolver algunas instancias? ¿O más bien se refiere a resolver todas las instan-
cias? A continuación se propone una noción precisa de qué significa responder una
pregunta algorı́tmica con base en las nociones presentadas anteriormente.
0.2. Problemas algorı́tmicos y su especificación 7

Definición 0.2.4

Una solución de una instancia de un problema algorı́tmico es la respuesta a


la pregunta representada por dicha instancia. Una solución de un problema
algorı́tmico es la respuesta a todas las instancias del problema.

En un problema algorı́tmico, puede haber una distancia abismal entre la difi-


cultad que presenta resolver una de sus instancias especı́ficas y resolver todas sus
instancias. Existe el caso en el cual el tamaño de una instancia es pequeño y, como
tal, podrı́a ser fácil resolverla sin ingenio alguno. Por ejemplo, ordenar un arreglo
con cinco números es fácil, mientras que ordenar otro con un millón puede ser más
demandante en recursos. También está el caso en el cual ciertas instancias pueden
estar sesgadas y “escondan”, de alguna manera, la complejidad real de muchas otras
instancias del problema. Por ejemplo, ordenar un arreglo que ya está ordenado –sin
importar su tamaño– es trivial, mientras que resolver el problema de ordenamiento
no es tan directo en el caso más general. En una situación más dramática, puede
suceder que ciertas instancias de un problema algorı́tmico no tengan solución.
El estudio de algoritmos es interesante cuando su propósito es resolver una
gran cantidad de instancias de un problema algorı́tmico, si no todas. Es decir, el
reto de diseño algorı́tmico está en definir un algoritmo (e.g., unas instrucciones que
un computador pueda ejecutar) que resuelva (todas las instancias de) un problema
algorı́tmico dado.

Ejemplo 0.2.4

Para el problema de ordenamiento presentando en el Ejemplo 0.2.1, las si-


guientes son soluciones a las instancias en el Ejemplo 0.2.2:
A = [].
A = [1, 2, 3].
A = [1, 2, 7, 8, 9].
En los dos primeros casos, el arreglo A estaba originalmente ordenado ascen-
dentemente.

Vale la pena llamar la atención del lector acerca de un detalle que puede ser
importante en la Definición 0.2.4. Allı́ se hace explı́cito el hecho de que pueden
existir varias soluciones diferentes para una misma instancia de un problema al-
gorı́tmico dado y, por ende, para un problema algorı́tmico dado. A continuación se
presenta un problema algorı́tmico en el cual su única instancia puede tener más de
una solución.
8 0. Preliminares

Ejemplo 0.2.5

Considere la siguiente especificación:

Entrada: Un tablero de ajedrez de 8 × 8 vacı́o.


Salida: Ocho celdas del tablero dado en las cuales se pueden ubicar
reinas sin que se ataquen entre ellas.

Se propone como ejercicio al lector mostrar que la única instancia de este


problema algorı́tmico, conocido como el “problema de las 8 reinas”, tiene más
de una solución.

Hay una diferencia en los niveles de abstracción empleados en las especificacio-


nes de los problemas en los ejemplos 0.2.1 y 0.2.5. Para el ordenamiento, la entrada
es un arreglo que puede ser considerado como un elemento primitivo en un len-
guaje de programación. Sin embargo, la entrada en el problema de las 8 reinas es
un tablero de ajedrez, que difı́cilmente es parte de los elementos primitivos en un
lenguaje de programación. Esta diferencia no indica que alguno de los dos estilos
de especificación esté mal. Lo que sugiere esta diferencia es, más bien, que en algu-
nas ocasiones es conveniente especificar sin detallar tan finamente la forma de los
parámetros porque se hacerlo ası́ se considera algo sin importancia en ese momento
o porque hay una razón de fondo para mantenerlo a ese nivel de abstracción alto.
En el caso del problema de las 8 reinas, el tablero de entrada puede representarse
fácilmente con una matriz de números enteros con dimensiones 8 × 8, llena de ceros
indicando que el tablero está vacı́o. Sin embargo, la especificación dada asume que
no hay necesidad de entrar en este tipo de detalles para entender la escencia del
problema y pensar en una forma de resolverlo.
Especificar problemas algorı́tmicos requiere experiencia que se construye con
la práctica. Puede ser retador adquirir esta disciplina, pero merece la pena: un
problema bien especificado, es un problema que está más cerca de ser resuelto. En
la Nota 0.2.3 se presentan algunas sugerencias para la especificación de problemas
algorı́tmicos.

Nota 0.2.3: Regla de la mano derecha para especificar problemas

Un problema bien especificado formula cómo determinar si una salida dada


es correcta para una entrada dada. Para ello, es recomendable:
usar notación y términos claros y concisos, evitando argot especializado
ajeno a la computación;
0.2. Problemas algorı́tmicos y su especificación 9

si es necesario incluir argot especializado, este debe ser definido formal-


mente (y preferiblemente antes de ser usado);
la especificación no debe mencionar
• ni detalles de la fuente de datos de la cual proviene la entrada (e.g.,
base de datos, archivo de texto o un microservicio en la nube),
• ni una estrategia de solución o detalles de su implementación,
si la especificación puede ser simplificada, entonces debe ser simplificada.
Toda especificación debe ser precisa, corta y elegante.

El problema especificado en el Ejemplo 0.2.1 está bien especificado de acuerdo


con la regla de la mano derecha en la Nota 0.2.3.
Esta sección concluye con ejemplos de buenas y malas especificaciones de pro-
blemas algorı́tmicos.

Ejemplo 0.2.6

A continuación se presentan buenas especificaciones de problemas algorı́tmi-


cos.

Entrada: un grafo G dirigido.


Salida: un orden topológico de G, si existe; de lo contrario, una lista
vacı́a.

Entrada: un grafo G = (V, E) y un conjunto C ⊆ V .


Salida: ¿el subgrafo de G inducido por C es un clique (i.e., un grafo
en donde cualquier par de vértices está conectado)?

Entrada: una colección P de coordendas en el plano Cartesiano de dos


dimensiones.
Salida: mı́nima distancia entre cualquier par de puntos (distintos) en
P.

Ejemplo 0.2.7

A continuación se presentan malas especificaciones de problemas algorı́tmicos;


las razones por las cuales son consideradas malas especificaciones, en cada
caso, se proponen como ejercicio para el lector.
10 0. Preliminares

Entrada: una secuencia de lı́neas de texto; en la primera un número x


y en la segunda un arreglo A.
Salida: ¿está x en A?

Entrada: un grafo G con peso en los arcos.


Salida: el mejor camino para ir de u a v.

Entrada: una colección de cadenas.


Salida: la supercadena maximal de todas las cadenas de la entrada.

Ejercicios

1. Considere el problema de encontrar un número en un arreglo de números. Espe-


cifique los siguientes problemas asociados e identifique cuáles son sus paráme-
tros:
a) Determinar si el número está en el arreglo.
b) Suponiendo que el número está en el arreglo, determinar un ı́ndice en
donde está el número en el arreglo.
c) Si el número está en el arreglo, determinar un ı́ndice en el arreglo en donde
está el número; de lo contrario, identificar que no aparece.
d ) Suponiendo que el número está en el arreglo, determinar el ı́ndice más
pequeño en donde está el número en el arreglo.
e) Suponiendo que el número está en el arreglo, determinar el ı́ndice más
grande en donde está el número en el arreglo.
2. Investigue y especifique el problema de buscar un valor numérico en un arreglo
de números ordenados ascendentemente. ¿Qué diferencia fundamental hay entre
este problema y los especificados en el Ejercicio 1?
3. Especifique el problema de calcular en A[0..N ) el valor de los primeros N núme-
ros factoriales.
4. Especifique el problema de multiplicar dos matrices de números. ¿Qué restric-
ciones deben cumplir las dimensiones de las matrices?
5. ¿Cuántas instancias tiene el problema algorı́tmico en el Ejemplo 0.2.1? Justifi-
que su respuesta.
6. Investigue acerca de cada uno de los siguientes problemas y especifı́quelos como
problemas algorı́tmicos:
a) El problema de primalidad de números.
0.3. El concepto de algoritmo 11

b) El Teorema de Fermat.
c) La Conjetura de Goldbach.
d) El problema de satsifacibilidad proposicional.
e) El problema de la parada.
7. Clasifique los problemas indicados en el Ejercicio 1 de acuerdo a si tienen ins-
tancias con solución única o no. Ilustre su respuesta con ejemplos.
8. Clasifique los problemas indicados en el Ejercicio 6 de acuerdo a si tienen ins-
tancias con solución única o no. Ilustre su respuesta con ejemplos.
9. ¿Cuántas soluciones tiene el problema de las 8 reinas en el Ejemplo 0.2.5?
Investigue y formule tres soluciones.
10. Considere la siguiente problema:

Entrada: Un tablero de Sudoku parcialmente lleno.


Salida: Una solución del tablero de Sudoku dado.

a) ¿Cuántas instancias tiene el problema? Detalle los cálculos hechos.


b) Especifique una instancia del problema que no tiene solución.
c) ¿Es un problema para el cual cada instancia tiene a lo sumo una solución?
Justifique su respuesta.
11. Investigue y especifique tres problemas algorı́tmicos que tengan una cantidad
finita de instancias.
12. Investigue y especifique tres problemas algorı́tmicos que tengan una cantidad
infinita de instancias.
13. ¿Existe un problema algorı́tmico con múltiples instancias, pero para el cual hay
una única solución (i.e., independientemente de la entrada, la salida siempre es
la misma)? Justifique su respuesta.
14. Explique brevemente por qué las especificaciones en el Ejemplo 0.2.7 no son
consideradas como buenas especificaciones de problemas algorı́tmicos. En cada
caso justifique su respuesta.

0.3. El concepto de algoritmo

De acuerdo con Donald Knuth, uno de los cientı́ficos más destacados y prolı́ficos
de la informática, los algoritmos son los hilos que permiten relacionar y asociar
diferentes disciplinas de las ciencias de la computación. Dada su importancia, definir
qué se entiende por algoritmo ha sido una de las primeras tareas abordadas desde
las matemáticas y desde las ciencias de la computación. Esta sección presenta una
definición de algoritmo, los asocia a problemas algorı́tmicos e identifica algunas de
sus propiedades principales.
12 0. Preliminares

Nota 0.3.1

La palabra algoritmo es de origen árabe y se asocia al nombre del matemático


Mohamed ibn Musa, cuyo apodo era Al-Khwarismi y quien vivió entre los años
770 y 840 A.D. Se cree que, inicialmente, se usaban las palabras alguarismo
y guarismo, y que estas palabras evolucionaron con influencia de la palabra
griega aritmo (i.e., número) a algoritmo. Sin embargo, la Real Academia Es-
pañola indica que esta palabra proviene del latı́n algobarismus, para abreviar
en árabe la acción de calcular mediante cifras arábigas. De cualquier forma,
Al-Khwarismi –además de desarrollar algoritmos para resolver ecuaciones de
primer y segundo grado– explicó cómo usar ábacos en lugar de las manos
para calcular a partir de una especificación clara y concisa. Esta es conside-
rada una de las primeras expresiones de cálculo asistido mecánicamente en
la humanidad. Ası́ parece que surge la popularidad de la palabra ‘algoritmo’
como término para referirse a una secuencia de reglas para manipular núme-
ros arábigos. Posteriormente, esta palabra fue generalizada para dar cuenta
de cálculos con otros objetos matemáticos, no necesariamente numéricos. El
primer caso de un algoritmo escrito para una máquina se encontró en las notas
de Ada Lovelace hacia 1840, quien propuso cómo calcular los números de Ber-
nulli en la Máquina Analı́tica de Charles Babbage. A pesar de que la Máquina
Analı́tica nunca fue terminada y, en consecuencia, el algoritmo propuesto por
Ada Lovelace no pudo ser ejecutado mecánicamente en dicha máquina, a ella
se atribuye el tı́tulo de primer programador de la humanidad.

El propósito intuitivo de un algoritmo es resolver un problema algorı́tmico a


partir de una secuencia de pasos. Antes de que las ciencias de la computación
existieran, los matemáticos se preocuparon por definir formalmente la noción de
“computar”, la cual está estrechamente ligada a la de algoritmo. Varias nociones
fueron propuestas para finalmente llegar a un concenso.

Definición 0.3.1

Un algoritmo es un conjunto de instrucciones que pueden ser ejecutadas por


una máquina de Turing.

Posiblemente, esta sea la definición más formal de lo que signifique computar,


en cuanto a que en este contexto computar se refiere a las operaciones que puede
realizar una máquina ideal, dada una secuencia de instrucciones (i.e., un algoritmo).
0.3. El concepto de algoritmo 13

Nota 0.3.2

Es importante resaltar que la noción de algoritmo relativa a una máquina de


Turing puede no solo ser la más formal, sino también la más general posible
de acuerdo con la tesis de Church-Turing (en inglés, Church-Turing thesis).
Este postulado afirma que la formalización de algoritmo en una máquina de
Turing es posiblemente la noción más poderosa posible. Note que este postu-
lado, como tal, es más una creencia que un hecho demostrado a causa de su
formulación coloquial (i.e., no matemática). A pesar de ello, hay consenso en
las comunidades académica y cientı́fica para aceptar esta afirmación.

Los computadores que se usan hoy en dı́a están inspirados en el modelo de


computación asociado a una máquina de Turing y, por ende, los cálculos que hacen
están basados en lo que formalmente se entiende por algoritmo. Para el propósito
de diseñar y analizar algoritmos, convenientemente se puede abstraer el concepto de
“conjunto de instrucciones” de una máquina de Turing para obtener una definición
de más alto nivel (pero sin perder de vista su definición formal) de lo que se entiende
por algoritmo.

Definición 0.3.2

Un algoritmo es una colección finita y ordenada de pasos sin ambigüeadad


que produce un resultado y termina en una cantidad finita de pasos (y de
tiempo).

La descripción de un algoritmo debe ser finita por naturaleza, al igual que


sucede con la descripción y la entrada de una máquina de Turing. El orden y
la inexistencia de ambigüedad en los pasos que definen un algoritmo se refiere a
que debe ser claro qué paso sigue en una “ejecución” de un algoritmo a partir de
un estado parcial de su ejecución. Por ejemplo, si un paso es sumar dos números
enteros, ambos números enteros deben estar definidos y la operación de adición
estar identificada. No se puede confundir el operador de suma con otro operador,
ni la acción de sumar con otro paso como almacenar un valor en una base de datos.
El objetivo de un algoritmo es calcular algo como resultado; de lo contrario serı́a
inoficioso seguir sus pasos. El resultado de un algoritmo se entiende como la salida
de un proceso mecánico que obtiene un valor o modifica una cantidad. Finalmente,
un algoritmo debe terminar después de una cantidad finita de tiempo o de pasos.
De lo contrario, aquello definido no serı́a un algoritmo (esta restricción puede ser
polémica en general, pero es conveniente y suficiente para el propósito de estudio
en este texto).
14 0. Preliminares

Adicionalmente a las propiedades asociadas a un algoritmo y enunciadas en la


Definición 0.3.2, un algoritmo puede tener cero, una o más entradas, dependiendo de
lo que se desea calcular, y también producir uno o más resultados, cada uno de ellos
asociados unı́vocamente con la entrada dada. Las operaciones usadas en los pasos
de un algoritmo para producir los valores de retorno a partir de la entrada deben
ser suficientemente básicas de tal forma que cada una de ellas se pueda procesar en
tiempo finito.
La noción de algoritmo en la Definición 0.3.2 no está relacionada de ninguna
manera con problemas algorı́tmicos, a pesar de que la finalidad de un algoritmo
(al menos en este manuscrito) es resolverlos. De acuerdo con la Definición 0.2.4,
una solución a un problema algorı́tmico necesariamente debe resolver todas sus
instancias.

Definición 0.3.3

Sean P un problema algorı́tmico y A un algoritmo. Se dice que A resuelve (o


es una solución de) P si y solo si A calcula una respuesta correcta para cada
una de las instancias de P .

El concepto de solución algorı́tmica planteado en la Definición 0.3.3 indica que


un algoritmo puede ser visto como solución a un problema algorı́tmico. Note que no
hay excepción en cuanto a que algunas instancias del problema dado pueden quedar
sin respuesta. En este sentido, considerar un algoritmo como solución a un problema
algorı́tmico es una propiedad categórica de los problemas: debe resolver todas sus
instancias. Note también que esta noción es agnóstica del lenguaje de especificación
con que se describe el algoritmo y del lenguaje de programación con que se vaya
a implementar. Como se verá a lo largo del texto, garantizar cuándo un algoritmo
resuelve un problema algorı́tmico es una de las principales preocupaciones (si no la
más importante) asociada a su diseño.

Ejemplo 0.3.1

Considere el problema de ordenamiento de un arreglo A[0..N ) de números


enteros, especificado en el Ejemplo 0.2.1. A continuación se presenta un algo-
ritmo especificado en lenguaje natural que “permite” resolver el problema.
1. Si N 6= 0, entonces repita, para n = 0, 1, . . . , N − 1, la siguiente secuencia
de pasos:
a) sea i el ı́ndice del mı́nimo valor en A[n..N )
b) intercambie A[n] y A[i]
2. Retorne A[0..N ).
0.3. El concepto de algoritmo 15

Aparentemente, el algoritmo propuesto en el Ejemplo 0.3.1 ordena un arreglo


de números. Sin embargo, dado que no hay una certeza matemática de ello, se usa
la palabra permite en comillas: no es suficiente con creer, es necesario demostrar
que en realidad funciona. Este será uno de los temas de énfasis en el texto.

Ejercicios

1. Elabore un pequeño resumen de las principales contribuciones de Donald Knuth


a la informática y las matemáticas, identificando al menos cinco de sus princi-
pales manuscritos (e.g., libros y artı́culos).
2. Investigue sobre la vida de Mohamed ibn Musa y explique por qué (se cree)
que lo apodaron Al-Khwarismi. Además, liste al menos cinco de sus principales
aportes a la ciencia.
3. Investigue sobre el algoritmo diseñado por Ada Lovelace para Máquina Analı́ti-
ca de Charles Babbage y explique, a un alto nivel, su diseño.
4. Explique cuáles son las partes de una máquina de Turing y cómo se ejecu-
tan algoritmos allı́. ¿Puede proponer un corto algoritmo para una máquina de
Turing? Justifique su respuesta.
5. Considere el algoritmo propuesto en el Ejemplo 0.3.1 para ordernar ascenden-
temente un arreglo de números. Identifique al menos una fuente de ambigüedad
en su descripción y proponga cómo corregir este defecto.
6. Considere el problema de ordenamiento de un arreglo A[0..N ) de números en-
teros, especificado en el Ejemplo 0.2.1. A continuación se presenta un algoritmo
especificado en lenguaje natural que “permite” resolver el problema.
a) Si N ≥ 2, entonces repita, para n = 0, 1, . . . , N − 1, la siguiente secuencia
de pasos:
1) sea i el ı́ndice del mı́nimo valor en A[n..N )
2) intercambie A[n] y A[i]
b) Retorne A[0..N ).
Aparentemente, este algoritmo es una alternativa al presentado en el Ejem-
plo 0.3.1 en cuanto ordena el arreglo A[0..N ). Proponga una forma de demostrar
que las dos soluciones presentadas al problema algorı́tmico de ordenamiento son
equivalentes, es decir, las dos solucionan correctamente todas las instancias del
problema.
7. Considere el problema de ordenamiento de un arreglo A[0..N ) de números en-
teros, especificado en el Ejemplo 0.2.1. Suponga que hay un algoritmo que dado
A[0..N ) ordena su contenido en un arreglo B[0..N ), el cual retorna como re-
sultado. ¿Resuelve dicho algoritmo el problema formulado en el Ejemplo 0.2.1?
Justifique su respuesta.
16 0. Preliminares

0.4. Especificación de algoritmos

En este texto se presentan técnicas de solución de problemas que resultan en


algoritmos, recurrentes o iterativos. Independientemente de la forma que tome una
solución, se debe contar con un lenguaje suficientemente expresivo para escribirla
y conciso para entenderla. Para ello existen numerosas opciones, desde lenguaje
natural (e.g., el que usamos para comunicarnos verbalmente) hasta lenguajes ma-
temáticos o de programación con una semántica muy precisa y clara. Cada opción
viene con beneficios e inconvenientes. Por ejemplo, una ventaja del lenguaje na-
tural es su fácil descripción; el precio que se paga es, como tal, la posibilidad de
involuntariamente llegar a una descripción ambigua dada la inherente ambiguedad
del lenguaje natural. Una opción que contrarrestarı́a esa posible ambiguedad serı́a
optar por un lenguaje de programación en lugar del lenguaje natural. Sin embargo,
cada lenguaje de programación tiene su propia semántica (e.g., el comando for en
Python y en Java puede ser interpretado de forma diferente) y esto obliga al lector a
aprenderlo. Como estos, hay más ejemplos de beneficios e inconvenientes, animados
por un sinfı́n de discusiones. En conclusión, en este texto se reconoce que no hay
una solución perfecta, que convenga a cualquier tipo de lector, para el problema de
cómo especificar algoritmos.
Por las razones expuestas anteriormente, se usarán varios lenguajes de espe-
cificación de algoritmos. Dependiendo del contexto, se podrá usar uno u otro. El
compromiso del autor con los lectores es el siguiente: en ningún caso habrán am-
bigüedades ni especificaciones incompletas, sin importar el lenguaje que se use.
Eso sı́, el espectro de posibilidades se restringe a tres opciones como se anuncia en
la Nota 0.4.1.

Nota 0.4.1

En orden de preferencia, en este texto se usaran los siguientes lenguajes para


especificar un algoritmo:
Enumeraciones estructuradas, como en el Ejemplo 0.3.1.
Pseudo-código/código en el lenguaje de programación Python3, como en
el Ejemplo 2.2.1
Pseudocódigo con notación matemática, similar a lo que se emplea en
otros textos de algoritmos, usualmente.

En las enumeraciones estructuradas se usa lenguaje natural (e.g., Castellano)


en combinación con notación matemática sencilla, si es necesario. En estos casos,
0.4. Especificación de algoritmos 17

los pasos de un algoritmo se describen con frases de palabras, que algunas veces
contienen fórmulas. A pesar de ser una opción muy intuitiva, se ha de propender
por ser preciso con las palabras porque de lo contrario describir un algoritmo podrı́a
fácilmente resultar en pasos ambigüos, como usualmente sucede en las recetas de
cocina. Usar pseudo-código o código puede ayudar a eliminar fuentes de ambigüea-
dad gracias a la sintaxis técnica de los lenguajes de programación, como se explicó
anteriormente. De esta forma, un algoritmo es o está muy cerca de ser un progra-
ma de computador. La relativa desventaja con esta opción es que la utilidad del
algoritmo está supeditada a la semántica del lenguaje de programación elegido y se
puede perder –entre tanto detalle técnico– la idea primordial detrás de la solución
que encarna. Por ello, a lo largo de este texto se usará esta opción simpre y cuando
la especificación del algoritmo no resulte en demasiado detalle técnico o sea extre-
madamente dependiente de la semántica de Pyhton. Finalmente, al usar notación
matemática se elimina de raı́z el problema de la ambigüedad, pero la especificación
puede resultar extraña a los ojos poco entrenados o desconocedores de la notación
elegida.

Ejercicios

1. Investigue sobre el formalismo conocido como diagramas de flujo y úselo para


describir el algoritmo en el Ejemplo 0.3.1.

2. Investigue sobre el formalismo conocido como lenguaje de comandos guardados


(en inglés, guarded command language) y úselo para describir el algoritmo en
el Ejemplo 0.3.1.

3. Recuerde el problema de buscar un número en un arreglo de números ordenado


ascendentemente (Ejercicio 0.2.2). Para este problema se conoce una solución
llamada “búsqueda binaria”. Investigue sobre esta solución y:
a) Especifique el algoritmo de búsqueda binaria en lenguaje natural para
resolver el problema dado.
b) Especifique el algoritmo de búsqueda binaria en diagramas de flujo para
resolver el problema dado (ver Ejercicio 1).
c) Especifique el algoritmo de búsqueda binaria en el lenguaje de comandos
guardados para resolver el problema dado (ver Ejercicio 2).
d ) Especifique el algoritmo de búsqueda binaria en el lenguaje de programa-
ción Python para resolver el problema dado.
e) Especifique el algoritmo de búsqueda binaria en el lenguaje de programa-
ción Ada para resolver el problema dado.
f ) Especifique el algoritmo de búsqueda binaria en el lenguaje de programa-
ción Ruby para resolver el problema dado.
18 0. Preliminares

g) Especifique el algoritmo de búsqueda binaria en el lenguaje de programa-


ción Julia para resolver el problema dado.
h) Especifique el algoritmo de búsqueda binaria en el lenguaje de programa-
ción Scala para resolver el problema dado.
i ) Especifique el algoritmo de búsqueda binaria en el lenguaje de programa-
ción Maude para resolver el problema dado.
j ) Especifique el algoritmo de búsqueda binaria en el lenguaje de programa-
ción Brainfuck para resolver el problema dado.
4. Considere el problema de calcular la descomposición en factores primos de un
número natural. Una aproximación para resolver el problema es la construcción
de la Criba de Eratóstenes, para luego factorizar el número dado.
a) Investigue sobre la Criba de Eratóstenes. ¿Cuáles son las operaciones bási-
cas en su construcción? Explique su respuesta.
b) Especifique el problema de construir la Criba de Eratóstenes en un arreglo
A[0..N ) para los primeros N números naturales.
c) Proponga una algoritmo para resolver el problema dado, utilizando el len-
guaje de especificación de su preferencia.
d ) ¿Cómo puede garantizar que la solución propuesta es un algoritmo y fun-
ciona?

Notas del capı́tulo y referencias

En numerosos textos, los arreglos con considerados como conjuntos de variables


elementales e indexadas consecutivamente. Esta concepción coincide con el trata-
miento presentado en este capı́tulo. Autores como E. Dijkstra [Dij76] proponen
un tratamiento alejado de la tradición y más cercano a la lógica para los arreglos,
introduciendo el concepto de “variable arreglo” y adoptando una notación distinta
para su indexación. Un tratamiento más extenso de la relación entre arreglos y fun-
ciones es propuesta por A. Kaldewaij [Kal90], incluyendo las nociones de sección
e indexación, y notación para operar con ellos.
La especificación de problemas a partir de una relación entre la entrada dada y
la salida esperada, y la concepción de un problema algorı́tmico como una colección
de instancias, son usuales en la literatura; ver, e.g., [CLRS09, Bha15]. Autores
como J. Kleinberg y É. Tardos [KET06] usan enumeraciones estructuradas pa-
ra especificar algoritmos, mientras que autores como T. Cormen et al. [CLRS09]
J. Erickson [Eri19] usan pseudo-código. Autores como H. Bhasin [Bha15] y S.
Skiena [Ski08] usan complementariamente pseudo-código y un lenguaje de progra-
mación para especificar algoritmos. D. Gries [Gri81] usa el lenguaje de comandos
Notas del capı́tulo y referencias 19

guardados (en inglés, guarded command language) para especificar algoritmos ite-
rativos; este puede ser considerado un lenguaje matemático para la especificación
de algoritmos.
Capı́tulo 1

Análisis asintótico

Considere por un momento que cuenta con dos algoritmos, digamos A0 y A1 ,


que resuelven un problema algorı́tmico dado. Al ser soluciones del mismo problema,
se está suponiendo que tanto A0 como A1 resuelven correctamente todas las ins-
tancias del problema. Sin más información y con poca reflexión, cualquiera de las
dos soluciones podrı́a adoptarse como “la” solución del problema algorı́tmico. Sin
embargo, en la práctica es importante entender cómo se comportarı́an A0 y A1 para
decir objetivamente cuál de las dos soluciones es “mejor” o “más conveniente”. Por
ello, entender cuáles son las caracterı́sticas que hacen que un algoritmo sea mejor
que el otro es clave para diseñar algoritmos.
El análisis asintótico es una herramienta matemática que permite comparar al-
goritmos con base en algunas de sus medidas. En el caso puntual de este manuscrito,
el interés es las medidas que permiten identificar cuántas instrucciones, o cuántas
unidades de tiempo o memoria –haciendo suposiciones mı́nimas sobre el lenguaje
de programación en el cual se implemente y la arquitectura de la máquina en donde
se despliegue dicha implementación– se requieren para que un algoritmo resuelva
las instancias del problema para el cual fue diseñado. Al uso de estos recursos (i.e.,
tiempo y espacio) que hace un algorimto se le denomina eficiencia algorı́tmica: es
mejor usar menos instrucciones, o menos unidades de tiempo o memoria cuando
sea posible.
Un reto fundamental al cual se enfrenta el análisis asintótico tiene que ver con
el hecho de que un problema algorı́tmico puede contar con múltiples parámetros
e infinitas instancias. Es decir, el análisis asintótico debe ser capaz de clasificar la
eficiencia algorı́tmica de manera tal que refleje lo que en realidad sucede con cada
instancia, pero de manera suficientemente general como para no entrar a distinguir
casos muy especı́ficos para cada una de ellas.

21
22 1. Análisis asintótico

Al final de este capı́tulo, el lector estará familiarizado con notación asintótica,


complejidad temporal y espacial asociada a un algoritmo, y con nociones funda-
mentales para poder comparar algoritmos con base en esta notación.

1.1. Medidas para problemas y algoritmos

Como se aprenderá más adelante, la cantidad de problemas algorı́tmicos es


inmensa. Por ello, es prácticamente imposible construir un marco matemático lo
suficientemente general como para albergar un análisis asintótico de cualquier algo-
ritmo. Por ejemplo, ¿cómo analizar soluciones a un problema algorı́tmico que tiene
diez parámetros de la misma forma como se analiza para otro par de un problema
algorı́tmico con un solo parámetro? Ası́ como hay heterogeneidad en la cantidad
de parámetros, también puede haber heterogeneidad en el tipo de operaciones que
los algoritmos realizan. En algunos casos, por ejemplo, estas operaciones pueden
tener naturaleza aritmética (e.g., multiplicar dos números enteros o calcular la raı́z
cuadrada de un número real no negativo), o –en otros casos– permitir manipular y
construir cadenas de caracteres. En general, es necesario asumir algunas suposicio-
nes acerca de la forma de los problemas algorı́tmicos y sobre el tipo de instrucciones
que un algoritmo puede utilizar como instrucciones básicas.

1.1.1. El tamaño de la entrada. Para muchos problemas algorı́tmicos, es fácil


identificar una medida razonable del tamaño de la entrada. En la especificación
del Ejemplo 0.2.1, en la cual el problema consiste en ordenar ascendentemente
un arreglo A[0..N ) dado, la cantidad N de elementos del arreglo parece ser una
buena elección para identificar el tamaño de la entrada (tampoco hay muchas más
opciones). En el caso del tablero de ajedrez en el Ejemplo 0.2.5, el tamaño de la
entrada está dado por las dimensiones del tablero; en particular, hay 8 × 8 filas y
columnas o, de manera equivalente, 64 casillas.

Nota 1.1.1

Dado un problema algorı́tmico P , se denomina tamaño de la entrada de P a


alguna medida razonable sobre los parámetros de P .

La noción de tamaño de la entrada presentada en la Nota 1.1.1 está lejos de


ser un concepto matemáticamente preciso, además que no indica las unidades con
las cuales se medirá. Algunas veces se usará la cantidad de elementos de un arreglo
como unidad de medida y otras veces la cantidad de bits que conforman un número;
sin embargo, no hay una sola unidad preferida para medir el tamaño de las entradas
de los problemas algorı́tmicos. Otra “mala” noticia es que, en la práctica, no existe
una forma automática (i.e., algorı́tmica) de asignar el tamaño de la entrada para
cualquier problema algorı́tmico. Por ello, es necesario desarrollar rápidamente la
1.1. Medidas para problemas y algoritmos 23

destreza de especificar claramente los problemas algorı́tmicos, para luego identificar


cuáles son los parámetros clave que permiten definir el tamaño de su entrada.

Ejemplo 1.1.1

Considere el problema de sumar los elementos de una matriz de números:

Entrada: Matriz A[0..M )[0..N ), con M, N ≥ 0, de números enteros.


Salida: Suma de los elementos en A[0..M )[0..N ).

En este caso, el tamaño de la entrada es la cantidad de filas y columnas de la


matriz dada, es decir, el tamaño del problema es una función de M y N .

Aún en los problemas sencillos especificados en los ejemplos 0.2.1 y 1.1.1, se


está ignorando el tamaño de los números que se están ordenando o sumando. El
tamaño de los números puede llegar a ser un factor importante a la hora de entender
cómo se comporta un algoritmo: no cuesta lo mismo comparar un par de números
de pocos dı́gitos o comparar otro par con miles de dı́gitos. Sin embargo, este nivel
de detalle resulta innecesario en la mayorı́a de los casos dado que se opera con datos
cuyos tamaños básicos no son exageradamente grandes.

Nota 1.1.2

Para evitar un detalle excesivo al definir el tamaño de la entrada de un pro-


blema, y en la medida de las posibilidades, se supondrá que el tamaño de los
números que conforman la entrada es despreciable.

El acuerdo en la Nota 1.1.2 será utilizado en la mayorı́a de los problemas


abordados en este manuscrito. Este acuerdo tiene una justificación en la prácti-
ca de implementar algoritmos. Por ejemplo, es común emplear procesadores de 64
bits para ejecutar algoritmos implementados en un lenguaje de programación. Esto
último quiere decir que las operaciones aritméticas de comparación, suma o multi-
plicación entre números de 64 bits se pueden relizar directamente con operaciones
del procesador, sin incurrir en “costos” adicionales para realizarlas externamente.
En general, y aunque no siempre es posible, el acuerdo establecido en la Nota 1.1.2
eliminará dificultades técnicas y facilitará la tarea de analizar asintóticamente un
algoritmo.

1.1.2. Asociando medidas a un algoritmo. Es indispensable modelar ma-


temáticamente la cantidad de instrucciones, o de unidades de tiempo o memoria
que un algoritmo emplea para resolver las instancias de un problema algorı́tmico.
24 1. Análisis asintótico

La intuición es que dicha cantidad de operaciones o de unidades de tiempo/memoria


pueden depender del tamaño de cada instancia. Matemáticamente, esto se puede
modelar directamente con el concepto de función.

Nota 1.1.3

La cantidad de operaciones, tiempo o memoria requeridos por un algoritmo


A con k ∈ N parámetros, se pueden representar por medio de una función
TA : Nk → R≥0 .
De esta forma, si A tiene un único parámetro, la expresión TA (n) representarı́a
la cantidad de operaciones, tiempo o memoria empleadas por el algoritmo A
al resolver una instancia de tamaño n.

Note que las funciones TA presentadas en la Nota 1.1.3 tienen como dominio
los números naturales y rango los números reales no negativos. Esto obedece a
que el tamaño de una instancia se mide, generalmente, en unidades enteras (e.g.,
cantidad de elementos, cantidad de bits), y a que la cantidad de operaciones, tiempo
o memoria nunca es negativa (por cuestiones técnicas, se prefiere que sea un número
real y no uno natural).
Abstraer la cantidad de recursos empleados por un algoritmo con una función
matemática tiene varios beneficios. Primero, la noción de función es un concepto
básico no solo en matemáticas sino en cualquier ciencia. Segundo, hay herramientas
disponibles desde las matemáticas para analizar funciones, i.e., para clasificar al-
goritmos en función de la cantidad de recursos que requieren. Tercero, esta brinda
un nivel de abstracción conveniente para comparar algoritmos, dejando un poco de
lado la velocidad del procesador o de la memoria de una máquina concreta en la
cual se implanten.

Ejercicios

1. Investigue sobre el problema de determinar la ruta más corta de un vértice a


otro en un grafo dirigido sin pesos en los arcos. Especifique este problema y
determine el tamaño de la entrada.
2. Investigue sobre el problema de determinar la ruta más corta de un vértice a
otro en un grafo dirigido con pesos en los arcos. Especifique este problema y
determine el tamaño de la entrada.
3. Investigue sobre el problema del morral (en inglés, knapsack ). Especifique este
problema y determine el tamaño de la entrada. ¿Se puede expresar el tamaño
de la entrada en función de solo una de sus variables?
1.2. Notación asintótica 25

1.2. Notación asintótica

Esta sección presenta la definición formal de la notación O (en inglés, big Oh


notation), y algunas notaciones relacionadas. También se incluyen ejemplos de cómo
utilizarlas para clasificar y comparar funciones (matemáticas).

Definición 1.2.1

Sea f : N → R≥0 . El orden de f , denotado O(f ), es el conjunto definido de la


siguiente manera:
O(f ) = {g : N → R≥0 | (∃n0 ∈ N)(∃c ∈ R>0 )(∀n ∈ N)n ≥ n0 ⇒ g(n) ≤ cf (n)}.

El orden O(f ) de una función f es el conjunto de todas las funciones que en el


lı́mite están acotadas (por encima) por un factor constante de f . En Castellano, se
dice que una función g está en O(f ) (i.e., g es del orden de f ) si eventualmente g
está acotada por encima por un múltiplo constante de f . En este caso, se escribe
g ∈ O(f ). Las variables n0 y c cuantificadas existencialmente en la Definición 1.2.1
son usadas para denotar “eventualmente” y “factor constante”, respectivamente.
La notación O puede también definirse sobre funciones con otro tipo. Por ejem-
plo, se pueden considerar funciones con dominio en los reales o de tuplas de números
naturales. Para el propósito de este manuscrito, basta con enfocarse en aquellas fun-
ciones que tienen dominio en los naturales y codominio en los reales no negativos.
La definición de O, tal y como se presenta con dominio en los naturales y rango en
los reales no negativos, es suficiente para clasificar y comparar una gran cantidad
de funciones asociadas a algoritmos.

Nota 1.2.1

Por conveniencia, en algunas ocasiones se abusa la notación de orden permi-


tiendo funciones más generales. Por ejemplo, de vez en cuando, se indica que
g ∈ O(f ) aún si g(n) es negativo o indefinido para algunos valores n < n0 .
También se permite que f sea negativa o indefinida para alguna cantidad fi-
nita de valores en su dominio. En estos casos, es necesario escoger un n0 lo
suficientemente grande para que estos comportamientos sean excluı́dos para
cualquier n ≥ n0 .
26 1. Análisis asintótico

La Figura 1 ilustra cuándo una función g está en O(f ). La constante c indica


cuál es el múltiplo de f con el que se acota a g y la constante n0 indica a partir de
qué punto cf permanece estable como acotamiento de g.

Figura 1. La función g está acotada por un factor constante positivo c de f


en el lı́mite (i.e., g ∈ O(f )).

La necesidad de introducir una definición formal de la notación O es para


establecer observaciones precisas sobre el comportamiento asintótico de algoritmos.
En estos casos, se requieren demostraciones rigurosas que permitan clasificarlos.
Para establecer g ∈ O(f ) se debe demostrar la existencia de dos valores n0 ∈ N
y c ∈ R>0 de tal forma que g(n) ≤ cf (n) sea cierto para cualquier n ≥ n0 . Una
forma alternativa de ver este tipo de demostraciones es a modo de juego contra
un oponente que siempre juega de forma perfecta (i.e., si hay forma de ganar, el
oponente escoge una opción que le garantiza ganar).

Nota 1.2.2

El jugador con el primer turno es quien tiene el objetivo de establecer g ∈


O(f ). En el primer turno, dicho jugador escoge valores para n0 y c. En el
segundo turno, el oponente “todopoderoso” escoge un valor n ≥ n0 . El juego
lo gana el primer jugador si g(n) ≤ cf (n); de lo contrario, gana el oponente.

La justificación de ver una posible demostración de g ∈ O(f ) a modo de juego,


como lo propone la Nota 1.2.2, es la siguiente. Si g ∈ O(f ), entonces existen cons-
tantes n0 y c tal que g(n) ≤ cf (n) para n ≥ n0 , y de esta forma el primer jugador
tiene una estrategia ganadora. De lo contrario, sin importar cómo el primer jugador
escoja n0 y c, el oponente encontrará (dado que siempre juega de forma perfecta) un
n ≥ n0 que falsifique la desigualdad y ası́ ganará el juego. Es importante entender
que en ocasiones se dice que las variables n0 y c son constantes. En realidad, este
abuso del lenguaje tiene el propósito de dejar claro que estos valores no dependen
de n.
1.2. Notación asintótica 27

Ejemplo 1.2.1

Se desea demostrar que 5n + 10 está en O(n). Para ello, es necesario esgoger


valores n0 ∈ N y c ∈ R>0 tales que para cualquier n ≥ n0 se dé 5n + 10 < cn.
El primer jugador puede tomar n0 = 3 y c = 10, y se observa que 5n+10 <
10n siempre y cuando 2 < n. Como n0 = 3, la desigualdad 5n + 10 < 10n es
cierta cuando n ≥ n0 . Es decir, no importa qué valor escoja el oponente en su
turno, aquel que siempre juega perfecto, pues cualquier escogencia hace que
la desigualdad sea cierta. En conclusión,
(5n + 10) ∈ O(n).

Un ejercicio común en la comparación asintótica de funciones es el de establecer


cuándo una función no está en el orden de otra. Para este propósito es indispensable
entender muy bien la definición de O y, en particular, la alternancia de cuantificado-
res en ella. Intuitivamente, g ∈/ O(f ) cuando sin importar con qué factor constante
se amplifique a f es imposible que dicho factor constante de f domine a g en el
lı́mite. Estas demostraciones tienden a ser posiblemente más retadoras que las de
pertenencia dado que se debe demostrar que las constantes n0 y c no existen. Di-
cho de otra forma, que sin importar cómo se escojan n0 y c, al menos un valor de
n ≥ n0 satisface g(n) > cf (n). En estos casos, resulta conveniente usar la técnica
de demostración por contradicción: suponer que dichos valores existen para luego
llegar a un sinsentido o absurdo lógico.

Ejemplo 1.2.2

Se desea demostrar que n2 no está en O(10000n). Para ello, es necesario


garantizar que es imposible escoger valores n0 ∈ N y c ∈ R>0 tales que si
n ≥ n0 se dé n2 ≤ 10000cn.
Hacia una contradicción, suponga que dichos valores existen. Sin pérdida
de generalidad, también suponga que n0 ≥ 1. Entonces, para n ≥ n0 se tiene
n
n2 < 10000cn sii < c.
10000
n
Es decir, se debe dar que la constante c acote la función 10000 , lo cual es
imposible (¿por qué?). En conclusión,
n2 ∈
/ O(10000n).

La notación asintótica es suficientemente robusta y de alto nivel como para


obviar detalles que se desean ignorar y que dependen –por ejemplo– de la arqui-
tectura de máquina o del lenguaje de programación en el cual se implementa un
28 1. Análisis asintótico

algoritmo. También, es precisa como para poder establecer comparaciones entre


diferentes alternativas de alto nivel que resuelvan un mismo problema algorı́tmico,
especialmente cuando el tamaño de la entrada es grande y se requiere ingenio para
plantear un algoritmo que sea práctico.
La notación O es útil para estimar una cota superior del uso de recursos que
un algoritmo requiere para reolver un problema. De forma dual, también puede ser
interesante estimar una cota inferior del uso de estos recursos. A continuación se
introduce nueva notación para tal fin.

Definición 1.2.2

Sea f : N → R≥0 . El conjunto Ω(f ) se define para cualquier g : N → R≥0


como:
g ∈ Ω(f ) sii f ∈ O(g).

El conjunto Ω(f ) (leı́do, ‘ómega de f ’) es el dual de O(f ), pues es el conjunto


de todas aquellas funciones que están acotadas por debajo (en el lı́mite) por un
múltiplo positivo de f .

Nota 1.2.3

La definición de Ω en la Definición 1.2.2 no es la habitual. En este texto se


opta por esta formulación dado que directamente depende de O. De este modo,
basta con recordar solamente la definición de O, y una equivalencia sencilla
para utilizar y razonar sobre Ω. El Ejercicio 14 de esta sección presenta la
definición usual de Ω (para cualquier f : N → R≥0 )
Ω(f ) = {g : N → R≥0 | (∃n0 ∈ N)(∃c ∈ R>0 )(∀n ∈ N)n ≥ n0 ⇒ g(n) ≥ cf (n)}.
y plantea la equivalencia entre las dos definiciones.

En adición a O y Ω, existe otra noción asintótica para clasificar funciones


que cuentan con una función que, simultánteamente en el lı́mite, sirve como cota
superior e inferior con factores que pueden ser distintos en cada caso.

Definición 1.2.3

Sea f : N → R≥0 . El conjunto Θ(f ) se define como:


Θ(f ) = O(f ) ∩ Ω(f ).
1.2. Notación asintótica 29

El conjunto Θ(f ) se denomina el exacto orden de f y, como se puede deducir de


su definición, es más poderoso (en relación a que brinda más información) que O(f )
o Ω(f ) individualmente. Al igual que con la definición de Ω, hay una definición
distinta que se usa habitualmente (Ejercicio 15 de esta sección). Por los mismos
motivos explicados anteriormente para Ω, se prefiere la caracterización de Θ en la
1.2.3.

Ejemplo 1.2.3

Se desea demostrar, para n ∈ N:


5n ∈ Θ(n + log n2 ).
Considere el siguiente razonamiento:
5n ∈ Θ(n + log n2 )
sii 5n ∈ Θ(n + 2 log n) (aritmética)
sii 5n ∈ O(n + 2 log n) ∩ Ω(n + 2 log n) (def. de Θ)
sii 5n ∈ O(n + 2 log n) ∧ 5n ∈ Ω(n + 2 log n) (def. de ∩)
sii 5n ∈ O(n + 2 log n) ∧ (n + 2 log n) ∈ O(5n) (O(5n) = O(n + 2 log n))
sii (n + 2 log n) ∈ O(n + 2 log n) ∧ 5n ∈ O(5n) (f ∈ O(f ))
sii true.
La demostración de la justificación dada en el quinto paso del razonamiento se
propone como ejercicio al lector.

Ejercicios

1. Justificando su respuesta, clasifique ascendentemente por orden asintótico las


siguientes funciones:
n √
22 log n n2 log n log(n!) n! n2,5

2. Justificando su respuesta, clasifique ascendentemente por orden asintótico las


siguientes funciones:
 n
√ 3 √
2n (log n)2 2 2 log n n log n log(n2 )
2
3. En la Nota 1.2.1 se indica que en ocasiones se pueden considerar funciones nega-
tivas o indefinidas en algunos puntos. Explique por qué los abusos identificados
en la Definición 1.2.1 no afectan el uso formal de la notación O.
4. Considere la demostración del Ejemplo 1.2.1. Escoja valores diferentes para n0
y c de tal forma que se obtenga una nueva demostración.
30 1. Análisis asintótico

5. En la demostración por contradicción en el Ejemplo 1.2.1 se indica que sin pérdi-


da de generalidad se puede suponer n0 ≥ 1. Explique por qué esta afirmación
es correcta y no limita la generalidad de la demostración.
6. Suponga que un algoritmo A realiza TA : N → R>0 operaciones básicas para
ordenar un arreglo (i.e., TA (n) es la cantidad de operaciones que realiza A para
ordenar un arreglo de tamaño n). Como parte de una tarea, Pedrito demostró
que TA ∈ O(n2 ), Juanita que TA ∈ O(n3/2 ) y el profesor publicó una solución
con una demostración de TA ∈ O(n log n).
a) ¿Es posible que las tres demostraciones sean correctas? Justifique su res-
puesta.
b) Si las tres demostraciones fueran correctas, ¿cuál de las tres afirmacio-
nes brinda “más” información sobre el comportamiento de A? Explique
brevemente su respuesta.
n
7. En el Ejemplo 1.2.2 se usa el argumento de que la función 100000 no puede
ser acotada por la constante c. Demuestre formalmente que esta afirmación es
cierta.
8. Para n ∈ N, demuestre que O(5n) = O(n + 2 log n).
9. Para n ∈ N, demuestre que 2n2 ∈ O(n3 ) y que n3 ∈
/ 2n2 .
10. Para n ∈ N, demuestre o refute: 2n+1 ∈ O(2n ).
11. Para n ∈ N, demuestre o refute: (n + 1)! ∈ O(n!), en donde ! es la función
factorial.
12. Para n ∈ N y f : N → R>0 , demuestre o refute:
a) Si f ∈ O(n), entonces f 2 ∈ O(n2 ).
b) Si f ∈ O(n), entonces 2f ∈ O(2n ).
13. Considere la siguiente definición para una función f : N → R≥0 :

O∗ (f ) = {g : N → R≥0 | (∃c ∈ R>0 )(∀n ∈ N)g(n) ≤ cf (n)}.

Demuestre: si f : N → R>0 , entonces O(f ) = O∗ (f ).


14. Sea f : N → R≥0 . El conjunto Ω∗ (f ) se define de la siguiente manera:

Ω∗ (f ) = {g : N → R≥0 | (∃n0 ∈ N)(∃c ∈ R>0 )(∀n ∈ N)n ≥ n0 ⇒ g(n) ≥ cf (n)}.

Demuestre que Ω(f ) = Ω∗ (f ).


15. Sea f : N → R≥0 . El conjunto Θ∗ (f ) se define de la siguiente manera:

Θ∗ (f ) = {g : N → R≥0 | (∃n0 ∈ N)(∃c, d ∈ R>0 )(∀n ∈ N)


n ≥ n0 ⇒ cf (n) ≤ g(n) ≤ df (n)}.

Demuestre que Θ(f ) = Θ∗ (f ).


1.3. Propiedades de la notación asintótica 31

1.3. Propiedades de la notación asintótica

El razonamiento para clasificar y comparar funciones con las notaciones O, Ω, Θ


se hace más práctico explotando sus propiedades matemáticas. Esta sección pre-
senta algunas de estas propiedades, enfocándose principalmente en O, y las ilustra
con ejemplos. Los ejercicios de la sección incluyen más propiedades de la notación
asintótica.
Los factores constantes en una expresión que determina la cantidad de recursos
requeridos para resolver un problema tienden a ser extremadamente dependientes
del sistema en donde se desplieguen los algoritmos. Por ello, si no se desean cálculos
que estén comprometidos con un lenguaje de programación especı́fico o un entorno
de ejecución particular, tiene todo el sentido ignorar los términos constantes al
clasificar y comprar asintóticamente las funciones asociadas a los algoritmos.

Teorema 1.3.1: Regla de constantes

Sea f : N → R≥0 . Si c ∈ R>0 , entonces O(f ) = O(cf ).

Demostración

Se supone c ∈ R>0 y se procede por doble inclusión:


El objetivo es demostrar O(f ) ⊆ O(cf ). Si g : N → R≥0 es tal que
g ∈ O(f ), basta con demostrar g ∈ O(cf ). Si g ∈ O(f ), entonces hay
n0 ∈ N y c0 ∈ R>0 tales que g(n) ≤ c0 f para n ≥ n0 . Tome n1 = n0 y
c1 = cc0 , y note que para n ≥ n1 se tiene:
g(n) ≤ c0 f (n) (por suposición)
= c1 cf (n) (por definición de c1 ).
Luego, g ∈ O(cf ) con testigos n1 y c1 .
El objetivo es demostrar O(cf ) ⊆ O(f ). Si g : N → R≥0 es tal que
g ∈ O(cf ), basta con demostrar g ∈ O(f ). Si g ∈ O(cf ), entonces hay
n0 ∈ N y c0 ∈ R>0 tales que g(n) ≤ c0 cf para n ≥ n0 . Tome n1 = n0 y
c1 = c0 c, y note que para n ≥ n1 se tiene:
g(n) ≤ c0 cf (n) (por suposición)
= c1 f (n) (por definición de c1 ).
Luego, g ∈ O(f ) con testigos n1 y c1 .

Es importante aclarar que los factores constantes pueden ser determinantes en


el diseño de un algoritmo. Puede pensarse que la notación O es una herramienta
32 1. Análisis asintótico

que permite comparar algoritmos con enfoques fundamentalmente distintos que


resuelven un problema dado y determinar, entre ellos, cuál tiene el mejor potencial
para escalar a entradas de gran tamaño. Una vez el algoritmo de alto nivel esté claro,
es importante esforzarse por reducir el factor constante asociado al algoritmo. De
cualquier forma, si el éxito de la solución algorı́tmica depende de qué tan rápido
pueda resolver un problema, claramente este debe funcionar tan rápido como sea
posible.
Los términos que tienen orden bajo, en relación con los demás términos que
definen una función, tienden a ser irrelevantes a medida que el tamaño del problema
a resolver aumenta.

Teorema 1.3.2: Subsunción por suma

Sea f, g : N → R≥0 . Si g ∈ O(f ), entonces O(f + g) = O(f ), en donde f + g


denota la suma punto a punto de f y g (i.e., (f + g)(n) = f (n) + g(n)).

Demostración

Se supone g ∈ O(f ) y se procede por doble inclusión.


El objetivo es demostrar O(f + g) ⊆ O(f ). Si h : N → R≥0 es tal que
h ∈ O(f + g), basta con demostrar h ∈ O(f ). Primero, por la suposición
g ∈ O(f ), hay n0 ∈ N y c0 ∈ R>0 tales que g(n) ≤ c0 f (n) para n ≥ n0 .
Segundo, por la suposición h ∈ O(f + g), hay n1 ∈ N y c1 ∈ R>0 tales
que h(n) ≤ c1 (f + g)(n) para n ≥ n1 . Observe que:
h(n) ≤ c1 (f + g)(n) (por suposición si n ≥ n1 )
= c1 f (n) + c1 g(n) (por aritmética)
≤ c1 f (n) + c1 c0 f (n) (por suposición si n ≥ n0 )
≤ c1 (c0 + 1)f (n) + c1 (c0 + 1)f (n) (por aritmética)
= (c1 (c0 + 1))2f (n) (por aritmética).
Luego, con testigos n2 = n0 ↑ n1 y c2 = c1 (c0 + 1), se concluye h ∈ O(f ).
El caso O(f ) ⊆ O(f + g) se propone como ejercicio al lector.

El Ejemplo 1.3.1 presenta un caso en el cual se pueden emplear los teore-


mas 1.3.1 y 1.3.2 para simplificar la clasificación asintótica de funciones. En con-
junto, estas dos propiedades son de gran utilidad práctica pues permiten eliminar
factores constantes e ignorar términos irrelevantes de una función.
1.3. Propiedades de la notación asintótica 33

Ejemplo 1.3.1

Una cota superior para la cantidad de operaciónes que toma el algoritmo


MergeSort es aproximadamente
6n log n + 6n,
en donde n es la cantidad de elementos a ordenar. Entonces, la complejidad
temporal de este algoritmo puede abstraerse asintóticamente ası́:
O(6n log n + 6n) = O(6n log n) (por el Teorema 1.3.2)
= O(n log n) (por el Teorema 1.3.1).

Los conjuntos construı́dos a partir de la notación O satisfacen una especie de


transitividad.

Teorema 1.3.3: Transitividad

Sean f, g, h : N → R≥0 . Si h ∈ O(g) y g ∈ O(f ), entonces h ∈ O(f ).

Demostración

Se propone como ejercicio al lector.

También es posible usar la notación O para establecer una relación de orden


parcial entre funciones y, consecuentemente, comparar indirectamente la eficiencia
relativa de diferentes algoritmos para resolver un problema dado.

Teorema 1.3.4

Sean f, g : N → R≥0 :
1. O(f ) = O(g) sii f ∈ O(g) y g ∈ O(f ).
2. O(f ) ⊂ O(g) sii f ∈ O(g) y g ∈
/ O(f ).

Demostración

Se proponen como ejercicio al lector.


34 1. Análisis asintótico

Finalmente, se presentan propiedades de la notación O que son especialmente


útiles para analizar la complejidad temporal de algoritmos secuenciales e iterativos.

Teorema 1.3.5: Reglas de suma y producto

Sean f, f1 , g, g1 : N → R≥0 :
1. O(f + g) = O(f ↑ g), en donde la suma y el máximo se interpretan punto
a punto.
2. Si f1 ∈ O(f ) y g1 ∈ O(g), entonces f1 g1 ∈ O(f g), en donde el producto
se interpreta punto a punto.

La Propiedad 1 en el Teorema 1.3.5 se conoce como la regla de la suma, mientras


que la Propiedad 2 como la regla del producto.

Ejercicios

1. Sea f : N → R≥0 . Demuestre que, si c ∈ R>0 , entonces:


a) Ω(f ) = Ω(cf ).
b) Θ(f ) = Θ(cf ).
2. Complete la demostracı́on del Teorema 1.3.2 con el caso O(f ) ⊆ O(f + g).
3. Sean f, g : N → R≥0 tales que g ∈ O(f ). Demuestre o refute:
a) Ω(f + g) = Ω(f ).
b) Θ(f + g) = Θ(f ).
4. Demuestre el Teorema 1.3.3.
5. Sean f, g, h : N → R≥0 tales que g ∈ O(f ). Demuestre o refute:
a) Si h ∈ Ω(g) y g ∈ Ω(f ), entonces h ∈ Ω(f ).
b) Si h ∈ Θ(g) y g ∈ Θ(f ), entonces h ∈ Θ(f ).
6. Demuestre la Propiedad 1 del Teorema 1.3.4.
7. Demuestre la Propiedad 2 del Teorema 1.3.4.
8. Sean f, g : N → R≥0 . Demuestre o refute:
a) Ω(f ) = Ω(g) sii f ∈ Ω(g) y g ∈ Ω(f ).
b) Ω(f ) ⊂ Ω(g) sii f ∈ Ω(g) y g ∈/ Ω(f ).
c) Θ(f ) = Θ(g) sii f ∈ Θ(g) y g ∈ Θ(f ).
d ) Θ(f ) ⊂ Θ(g) sii f ∈ Θ(g) y g ∈
/ Θ(f ).
9. Demuestre la Propiedad 1 del Teorema 1.3.5.
10. Demuestre la Propiedad 2 del Teorema 1.3.5.
1.4. El Teorema Maestro 35

11. Formule duales para Ω y Θ de las propiedades de suma y producto en el 1.3.5.


Justifique su respuesta con demostraciones.
12. Proponga funciones f, g : N → R≥0 tales que f ∈
/ O(g) y g ∈
/ O(f ). Justifique
su respuesta.
13. Sean f, g : N → R≥0 . Demuestre que las siguientes propiedades son equivalentes:
a) O(f ) = O(g).
b) Θ(f ) = Θ(g).
c) f ∈ O(g).
14. Considere la siguiente argumentación:

O(n2 ) = O(n3 + (n2 − n3 )) (por aritmética)


3
= O(n ) (por Teorema 1.3.2 con n2 − n3 ∈ O(n3 )).

Claramente O(n2 ) 6= O(n3 ). Entonces, ¿cuál es el error en la argumentación?


15. La noción de lı́mite es una herramienta versátil y útil para comparar funciones.
Para f, g : N → R≥0 , demuestre:
a) Si lı́mn→∞ fg(n)
(n)
∈ R>0 , entonces O(f ) = O(g).
f (n)
b) Si lı́mn→∞ g(n) = 0, entonces O(f ) ⊂ O(g).
f (n)
c) Si lı́mn→∞ g(n) ∈ R>0 , entonces f ∈ Θ(g).
d ) Si lı́mn→∞ fg(n)
(n)
= 0, entonces f ∈ O(g) ∧ f ∈
/ Θ(g).
16. Encuentre funciones f, g : N → R≥0 tales que O(f ) = O(g), pero para las cuales
lı́mn→∞ fg(n)
(n)
no exista.

1.4. El Teorema Maestro

El Teorema Maestro es un resultado del análisis de algoritmos que permite


estimar una cota asintótica para una clase de funciones recurrentes. Esta sección
presenta una versión del Teorema Maestro que es útil a lo largo del texto, carac-
teriza el tipo de función recurrente que puede ser resuelto con su aplicación y lo
ilustra con ejemplos. Como tal, la presentación del Teorema Maestro se hace sin
acompañamiento de una demostración formal de su validez, pues esta está fuera del
alcance del texto.
El Teorema Maestro es de gran utilidad para analizar la eficiencia de algoritmos
recurrentes como los que resultan de aplicar técnicas como, e.g., dividir y conquistar.
Básicamente, este teorema brinda un método que permite tomar como entrada una
función definida recurrentemente (e.g., determinando la cantidad de operaciones o
de recursos que un algoritmo necesita para resolver un problema) para calcular una
cota superior (asintóticamente definida) para dicha función. Por ejemplo, si se tiene
una función T ( ) que define recurrentemente la cantidad de operaciones que efectúa
36 1. Análisis asintótico

MergeSort para ordenar un arreglo en función de su tamaño, entonces usando el


Teorema Maestro se puede obtener una función f ( ) que depende de dicho tamaño
y tal que T ∈ O(f ). Como se verá en el desarrollo de esta sección, este proceso
requiere únicamente de pocos cálculos artiméticos.
Dado que el Teorema Maestro no puede ser usado con cualquier función recu-
rrente, es importante caracterizar el tipo de función que puede ser analizado con el
teorema.

Definición 1.4.1: Función simple

Se dice que la función T : N → R≥0 es simple si satisface las siguientes


condiciones para n ∈ N:
Caso base: para n suficientemente pequeño,
T (n) ∈ O(1).
Caso recurrente: para los demás valores de n y constantes a, b, k ∈ R≥0 ,
T (n) ≤ a · T (n/b) + O(nk )
tales que:
• a ≥ 1 representa la cantidad de llamados recurrentes hechos por el
algortitmo asociado a T y
• b > 1 representa el factor por el cual se reduce el espacio en cada
llamado recurrente de T .

El caso base en una función simple indica que una vez el tamaño de la entra-
da es suficientemente pequeño como para no hacer llamados recurrentes, entonces
el problema se puede resolver en tiempo/espacio constante (i.e., O(1)). El caso
recurrente asume que se recurre sobre a instancias similares a la instancia dada,
pero de tamaño reducido en una fración b, y que estos llamados requieren O(nk )
tiempo/espacio para ser consolidados como respuesta de la instancia inicial.

Ejemplo 1.4.1

Sea T (n) la función que determina al cantidad de operaciones realizadas por


MergeSort para ordenar un arreglo de tamaño n. Como los casos base, que
corresponden a ordenar un arreglo vacı́o o de un solo elemento, toman tiempo
constante, se tiene:
T (n) ∈ O(1), para n ≤ 1.
En el algoritmo hay dos llamados recurrentes (i.e., a = 2), cada uno sobre
una mitad del arreglo dado (i.e., b = 2), y se requiere un cálculo lineal en el
1.4. El Teorema Maestro 37

tamaño de la entrada (i.e., k = 1) para consolidar los resultados obtenidos de


los llamados recurrentes. Es decir, para el caso recurrente, se tiene:
T (n) ≤ 2 · T (n/2) + O(n), para n > 1.
Luego, T ( ) es una función simple.

En la práctica se puede trabajar con aproximaciones de a, b, k, que son los


parámetros asociados a una función simple. También es importante entender que
cuando una función ha sido definida/declarada como simple, no es necesario fijar-
se en el valor exacto de su(s) caso(s) base (¿por qué?). Estas funciones, a su vez,
cuentan con algunas restricciones. Por ejemplo, cada llamado recurrente debe ha-
cerse sobre subproblemas del mismo tamaño. Aunque este no siempre es el caso,
los algoritmos que se diseñan en este texto usando dividir y conquistar permiten
ser asociados a funciones simples, similares a la asociada a MergeSort.
Se puede precisar ahora el enunciado del Teorema Maestro con base en la noción
de función simple.

Apuntes de carlos Teorema 1.4.1: Teorema Maestro


hay ejemplos de
desarrollo Si T : N → R≥0 es una función simple con parámetros a ≥ 1, b > 1 y k ≥ 0,
Si queremos aplicar el
entonces para n ∈ N: teorema maestro en el
Segun as variables llegar a o

O(nk log n) , si a = bk
 merge sort, comparamos
 grande con el teorema maestro las variables de llegada
T (n) ∈ O(nk ) , si a < bk y vemos que

O(nlogb a ) , si a > bk . T(MS) = O(n log n)

El Teorema Maestro identifica tres casos que provienen de la comparación de


dos cantidades: a y bk . Intuitivamente, a representa la tasa con la que se recurre
sobre subproblemas de un problema dado, mientras que bk representa la tasa con
la que se reduce “la complejidad” para resolver estos subproblemas. En este con-
texto, hay menos operaciones/recursos requeridos por un algoritmo cuando a < bk ;
lo contrario sucede cuando a > bk porque hay demasiada proliferación de subpro-
blemas. Cuando a = bk , la intuición indica que el algoritmo lleva a cabo la misma
cantidad de trabajo en cada uno de los niveles de la recurrencia: O(nk ) operaciones
en cada uno de los O(log n) niveles de la recurrencia, es decir, O(nk log n) opera-
ciones en total. Cuando a < bk , la cantidad de trabajo va decreciendo a medida
que se profundiza la recurrencia: es decir, la cantidad de trabajo O(nk ) al tope de
la recurrencia domina la cantidad de operaciones totales. Cuando a > bk , la can-
tidad de operaciones en cada nivel de la recurrencia aumenta con su profunidad:
la cantidad total de operaciones es dominada por la cantidad de operaciones en el
38 1. Análisis asintótico

último nivel de la recurrencia, es decir, está acotada por O(nlogb a ) (sabiendo que
nlogb a = alogb n , lo cual se propone como ejercicio para el lector).
A continuación se presentan ejemplos del uso del Teorema Maestro.

Ejemplo 1.4.2: Complejidad temporal de MergeSort

La cantidad de operaciones básicas que lleva a cabo el algortimo MergeSort


está definida por la función simple T cuyo caso recurrente corresponde a:
T (n) = 2 · T (n/2) + O(n).
Luego, a = 2, b = 2 y k = 1. Note que:
a = 2 = 2 1 = bk ,
lo cual corresponde al primer caso del Teorema Maestro. En consecuencia,
T (n) ∈ O(n log n).

Ejemplo 1.4.3: Complejidad temporal de búsqueda binaria

La cantidad de operaciones básicas que lleva a cabo el algoritmo de búsqueda


binaria está definida por la función simple T cuyo caso recurrente corresponde
a:
T (n) = T (n/2) + O(1).
Luego, a = 1, b = 2 y k = 0. Note que:
a = 1 = 2 0 = bk ,
lo cual corresponde al primer caso del Teorema Maestro. En consecuencia,
T (n) ∈ O(log n).

Ejemplo 1.4.4

Considere una función simple cuyo caso recurrente está dado por:
T (n) = 2 · T (n/3) + O(n).
Luego, a = 2, b = 3 y k = 1. Note que:
a = 2 < 3 = 31 = bk ,
lo cual corresponde al segundo caso del Teorema Maestro. En consecuencia,
T (n) ∈ O(n).
1.4. El Teorema Maestro 39

Ejemplo 1.4.5: Complejidad temporal de Strassen

El algoritmo de Strassen permite multiplicar dos matrices cuadradas de ta-


maño n. De una forma ingeniosa, este algoritmo hace 7 llamados recurrentes
sobre subproblemas reduciendo en mitades los tamaños de los suproblemas.
Las respuestas obtenidas de los llamados recurrentes se consolidan con O(n2 )
operaciones:
T (n) = 7 · T (n/2) + O(n2 ).
Luego, a = 7, b = 2 y k = 2. Note que:
a = 7 > 4 = 22 = bk
lo cual corresponde al tercer caso del Teorema Maestro. En consecuencia,
como log2 7 = 2,81, se tiene
T (n) ∈ O(22,81 ).

Existen herramientas que permiten analizar el comportamiento de funciones


recurrentes más generales que las caracterizadas en esta sección como funciones
simples. Este es el caso de los árboles de recurrencia, los cuales permiten analizar
visualmente funciones recurrentes muy generales. Por ejemplo, un árbol de recurren-
cia se pueden utilizar para analizar una función que resulte de dividir y conquistar
en la cual los llamados recurrentes no se hacen sobre subproblemas del mismo ta-
maño. Algunos de los ejercicios de esta sección están propuestos para familiarizarse
con esta herramienta.
40 1. Análisis asintótico

Ejercicios

1. Use el Teorema Maestro para calcular T (n) en cada uno de los siguientes casos:
a) T (n) = T (n/2) + 1
b) T (n) = 3T (n/3) + 8n
c) T (n) = 2T (4n/5) + n3
2. Use el Teorema Maestro para calcular T (n) en cada uno de los siguientes casos:
a) T (n) = 2T (n/3) + n3
b) T (n) = 3T (n/3) + 5n
c) T (n) = 6T (4n/5) + 4n2
3. Justifique por qué, para analizar con ayuda del Teorema Maestro el orden de
la cantidad de recursos que emplea un algoritmo, no es necesario contar con los
valores exactos de los casos base de la función simple asociada.
4. Sean a, b ∈ R tales que a ≥ 1 y b > 1. Demuestre, para n ∈ N, que nlogb a =
alogb n .
5. Investigue acerca del Algoritmo de Strassen para la multiplicación de matrices.
Explique brevemente en qué consiste, cómo reduce la cantidad de llamados
recurrentes de 8 a 7 e ilustre su funcionamiento con un ejemplo.
6. Existe una versión más general del Teorema Maestro en la cual se pueden
analizar funciones recurrentes T : N → R≥0 cuyos casos bases son O(1) y cuyos
casos recurrentes son de la forma
T (n) ≤ a · T (n/b) + Θ(nk ),
para a, b, k ∈ R≥0 tales que a ≥ 1 y b > 1. Investigue acerca de esta versión del
Teorema Maestro y enúncielo.
7. Los árboles de recurrencia son una herramienta visual para resolver recurrencias
más generales que las admitidas para funciones simples. Investigue acerca de los
árboles de recurrencia, elabore una breve descripción y presente un ejemplo de
cómo usarlos para analizar funciones recurrentes más generales que las simples.
8. Use árboles de recurrencia (ver Ejercicio 7) para resolver cada una de las si-
guientes recurrencias:

a) T (n) = 2T (n/4) + n
b) T (n) = 2T (n/4) + n
c) T (n) = 2T (n/4) + n2
9. Use árboles de recurrencia (ver Ejercicio 7) para resolver cada una de las si-
guientes recurrencias:
a) T (n) = T (n/2) + T (n/3) + T (n/6) + n
b) T (n) = T (n/2) + 2T (n/3) + 3T (n/4) + n2
c) T (n) = 2T (n/2) + O(n log n)
Notas del capı́tulo y referencias 41

d) T (n) = 2T (n/2) + O(n/ log n)


√ √
e) T (n) = nT ( n) + n
10. Use árboles de recurrencia (ver Ejercicio 7) para resolver cada una de las si-
guientes recurrencias:
a) T (n) = T (n − 1) + n
b) T (n) = 2T (n − 1) + n log n
c) T (n) = 2T (n − 1) + n2

Notas del capı́tulo y referencias

Asociar funciones que miden diferentes aspectos de un algoritmo es una práctica


común en el análisis de algoritmos. Las notaciones O, Ω y Θ son también muy
comunes. Sin embargo, la presentación en este texto de Ω y Θ no es la habitual.
Presentaciones convencionales de estas nociones se encuentran en, e.g., [CLRS09,
KET06]. Los ejercicios de la Sección 1.2 que establecen las respectivas equivalencias
entre las definiciones habituales de Ω y Θ con las presentadas en este texto, están
inspirados por la presentación de la notación asintótica de G. Brassard y P. Bratley
en [BB88]. Cormen et al. en [CLRS09] hacen una presentación sucinta de los
análogos o, ω y θ de las notaciones presentadas en la Sección 1.2.
Las propiedades para el análisis asintótico en la Sección 1.3 son bien conocidas
y se pueden consultar en varios de los textos de referencia citados aquı́. Algunos
ejercicios de esta sección están inspirados o aparacen en [BB88], especialmente
los relacionados con lı́mites que fueron transcritos literalmente en algunos casos.
Los textos [CLRS09, KET06, BB88, Eri19] son una fuente extensa de más
propiedades de O, Ω y Θ, y ejemplos de su uso.
El tratamiento del Teorema Maestro en la Sección 1.4 sigue la propuesta de T.
Roughgarden [Rou17]; en este texto se encuentra una demostración del teorema,
al igual que en [CLRS09]. Los ejercicios de la sección relacionados con árboles
de recurrencia están inspirados en el tratamiento del tema y algunos ejercicios
en [Eri19]. Cormen et al. en [CLRS09] incluyen técnicas para resolver recurrencias,
complementarias al Teorema Maestro y a los árboles de recurrencia.
El texto [SF13] de R. Sedgewick y P. Flajolet es una fuente de profundización
y temas especializados en el área de análisis de algoritmos; se recomienda como
lectura obligatoria (y selectiva) al lector decididamente interesado en el tema.
Capı́tulo 2

Dividir, conquistar y combinar

Dividir y conquistar (acá llamada, dividir, conquistar y combinar ) es la técni-


ca por excelencia para resolver problemas algorı́tmicos. La idea está basada en un
principio que la humanidad conoce y usa desde hace muchos años para construir
demostraciones: el principio de inducción matemática. Como su nombre lo indica,
la técnica permite resolver un problema tratando de dividirlo en subcasos y siendo
oportunista para resolverlos directamente cuando no es necesario/posible reducirlos
más. Técnicamente, una instancia de un problema se divide en subcasos que a su
vez son instancias del mismo problema, pero más pequeñas (i.e., dividir), y estas
soluciones se combinan para resolver la instancia dada (i.e., combinar); si la subdi-
visión no es necesaria, entonces la instancia del problema se resuelve directamente
(i.e., conquistar).
Este capı́tulo hace una presentación de esta técnica de diseño algorı́tmico, la
relaciona con el principio de inducción matemática y presenta varios ejemplos de
cómo se utiliza en algoritmos sobre arreglos que han sido ampliamente estudiados.
Algunos problemas y demostraciones se proponen como ejercicios para el lector.

2.1. Teselación de tableros de ajedrez

Esta sección tiene dos propósitos. Uno es mostrar cómo la técnica de dividir,
conquistar y combinar permite resolver el problema de la teselación de tableros de
ajedrez. El otro es establecer (de manera informal) la relación que existe entre esta
técnica algorı́tmica y la demostración por inducción matemática.

Nota 2.1.1

43
44 2. Dividir, conquistar y combinar

A continuación se indican las tres partes que forman parte de una demostra-
ción por inducción matemática:
Casos base: son aquellos casos que no dependen de otros casos y que se
pueden resolver directamente.
Hipótesis inductiva: son aquellos casos que se pueden suponer resueltos
(con base en un ordenamiento de las instancias del problema).
Casos inductivos: son aquellos casos que se apoyan en la hipótesis in-
ductiva para poder llegar al objetivo de la demostración.
Esta es una sobresimplificación (un poco abusiva) del principio de inducción
matemática. Sin embargo, es útil para el propósito de explicar la técnica de
dividir, conquistar y combinar.

2.1.1. Especificación del problema. A continuación se especifica el problema


de teselación de los tableros de ajedrez generalizados.

Problema 2.1.1: Teselación de un tablero generalizado

Entrada: un tablero cuadrado de lado 2N , N ≥ 1, con una celda fal-


tante.
Salida: ¿es posible teselar el tablero con fichas en forma de L que
comprenden exactamente tres celdas del tablero?

A continuación se muestran: (a) un tablero generalizado de lado 4; (b) fichas


con las cuales se teselan los tableros (de las cuales siempre hay suficientes); y (c)
una posible teselación de un tablero de lado 4.

22

(a) (b) (c)

El problema se plantea de manera general, i.e., para cualquier tablero que cum-
pla con las indicaciones de la entrada. Como N no está acotado, esto significa que
la cantidad de tableros es infinita y que no es posible tratar de construir tesela-
ciones enumerando explı́citamente todos los tableros. Si se formulase el problema
directamente a modo de una fórmula matemática (e.g., ∀N.N ≥ 1 · · · ), serı́a claro
que una opción es proceder por inducción matemática.
2.1. Teselación de tableros de ajedrez 45

2.1.2. Diseño de una solución. Se propone un algoritmo recurrente que está


basado en análisis de casos, siguiendo las ideas de una demostración por inducción.
Los tableros que pueden ser teselados directamente se tratan como casos base, mien-
tras que los tableros que no son lo suficientemente pequeños como para ser teselados
directamente se teselan al componer las teselaciones de tableros generalizados más
pequeños.

Algoritmo 2.1.1

Los tableros más pequeños se pueden enumerar; estos corresponden a


N = 1 y son 4:

Cada uno de estos tableros se puede teselar con una ficha en forma
de L. Esto concluye el caso base o, más bien, los casos base.
Los demás tableros tienen lado 2N , con N ≥ 2. Se supondrá, a modo
de hipótesis inductiva, que todo tablero de lado 2n , con n ≥ 1, se puede
teselar (tal cual como se procede por inducción matemática sobre N ).
Tenga en cuenta que cada uno de estos tableros tiene exactamente un
hueco. La idea es entonces encontrar una forma de teselar un tablero
de lado 2n+1 que tiene exactamente un hueco. Note también que si un
tablero de lado 2n+1 se divide en 4 partes iguales, resultan 4 tableros de
lado 2n (enumerados de 1 a 4 en la gráfica a continuación) y se acerca
a algo parecido a la hipótesis inductiva. Estos dos hechos se representan
gráficamente a continuación:
2n+1 2n 2n
(1) (2)

2n

2n+1

2n

(4) (3)

Como el subtablero 1 tiene lado 2n y un hueco, se puede teselar por


la hipótesis inductiva. Sin embargo, los otros 3 subtableros no tiene hueco
y por ello no se puede usar la hipótesis inductiva directamente. Pero, el
hecho de que no tengan hueco no quiere decir que no se pueda suponer
46 2. Dividir, conquistar y combinar

convenientemente que tienen un hueco: uno que resulta de ubicar una de


las fichas en forma de L en sus tres esquinas concéntricas:
2n 2n
(1) (2)

2n (HI) (HI)

2n (HI) (HI)

(4) (3)

Ahora, los subtableros 2-4 se pueden tratar como si tuvieran un hueco


en una de sus esquinas y consequentemente cumplen con la condición de
la hipótesis inductiva (HI). Es decir, cada uno de ellos se puede teselar al
tener lado 2n y exactamente un hueco. Por consiguiente, todo el tablero
de lado 2n+1 se puede teselar.

La solución dada en el problema anterior muestra cómo un tablero de ajedrez


generalizado y con un hueco puede ser teselado. En realidad, brinda más que eso,
pues demuestra constructivamente la posibilidad de encontrar una teselación con
ayuda de un algoritmo. En los casos base, el problema se resuelve directametne
teselando con una ficha (i.e., conquistar). Mientras que en los casos inductivos: (i)
se “recurre” sobre el subtablero en donde está el hueco dado que es una subins-
tancia del problema inicial (i.e., dividir); y (ii) en los otros tres subtableros se
marcan las esquinas concéntricas como si tuvieran un hueco, se recurre en cada
subtablero (i.e., dividir) y finalmente se ubica una ficha en forma de L sobre las
tres esquinas concéntricas de los subtableros teselados para completar la teselación
(i.e., combinar). Este algoritmo puede ser usado en cualquier tablero por el estilo:
está garantizada su correctitud por una demostración por inducción matemática.
La siguiente tabla hace explı́cita la relación que hay entre el principio de in-
ducción matemática, la técnica dividir, conquistar y combinar, y un algoritmo re-
currente.
Inducción mat. DCC Alg. recurrente
Caso base Conquistar Solución directa
Hipótesis inductiva Dividir Recurrencia
Caso inductivo Combinar Consolidación de soluciones a subproblemas
2.1. Teselación de tableros de ajedrez 47

Ejercicios

1. Con base en la demostración que prueba la existencia de una teselación de un


tablero de ajedrez generalizado:
a) Diseñe un algoritmo en el lenguaje de programación Python3 que tesele
un tablero dado.
b) Invesigue sobre el lenguaje de programación ditaa. Intente traducir el
algoritmo diseñado en Python para el numeral a ditta.
2. Un tablero de ajedrez doblemente generalizado es un tablero cuadrado de lado
2N , N ≥ 1, al cual le hacen falta dos celdas de colores opuestos (asumiendo
que está coloreado como un tablero de ajedrez convencional).
a) ¿Es posible teselar dicho tablero con (suficientes) fichas de dominó, en
donde cada ficha cubre exactamente dos celdas adyacentes del tablero?
Si su respuesta es negativa, suministre un contra-ejemeplo. De lo contra-
rio, construya una demostración siguiendo la estructura propuesta en esta
sección.
b) Considere la misma pregunta del numeral anterior para tableros de lado
N ≥ 1, a los cuales le hacen falta dos celdas de colores opuestos. Tenga en
cuenta que esta es una generalización del problema inicial.
3. Considere un tablero de ajedrez de 8 × 8 celdas que ha sido “mutilado” remo-
viendo exactamente dos celdas de esquinas opuestas.
a) Especifique el problema de determinar si dichos tableros se pueden teselar
con (suficientes) fichas de dominó, en donde cada ficha de dominó cubre
exactamente dos celdas adyacentes.
b) Resuelva el problema especificado en el numeral anterior.
4. Considere tableros de 2 × N celdas y fichas de dominó que cubren exactamente
dos celdas adyacentes.
tablero fichas

...

...

0 N

Proponga una función recurrente que determine la cantidad de formas de teselar


el tablero con (suficientes) fichas de dominó. Justifique su respuesta con una
demostración.
5. De acuerdo con Wikipedia,

Las Torres de Hanói es un rompecabezas o juego matemático inven-


tado en 1883 por el matemático francés Édouard Lucas. Este juego de
48 2. Dividir, conquistar y combinar

mesa individual consiste en un número de discos perforados de radio


creciente que se apilan intertándose en uno de los tres postes fijados
a un tablero (...) Para realizar este objetivo, es necesario seguir tres
simples reglas:
Solo se puede mover un disco cada vez y para mover otro los
demás tienen que estar en postes.
Un disco de mayor tamaño no puede estar sobre uno más pequeño
que él mismo.
Solo se puede desplazar el disco que se encuentre arriba en cada
poste.
En este ejercicio tiene dos objetivos: demostrar que el juego tiene solución y
que hay una algoritmo que lo resuelve con una cantidad de movimientos dado.
a) Especifique el problema de las Torres de Hanói para N ≥ 1 discos perfo-
rados.
b) Demuestre que el problema especificado es soluble para cualquier N .
c) Proponga un algoritmo que lleve a cabo exactamente 2N − 1 movimientos
de discos para resolver el problema. Demuestre que su algoritmo resuelve
el problema dado.
6. Sean k y l números naturales. Se dice que k es divisible por l si y solo si existe
un p ∈ N tal que k = p·l (en este caso, p se llama el testigo de la divisibilidad de
k entre l). Por ejemplo, 15 es divisible por 3 con testigo 5 porque 15 = 5 · 3. Use
el principio de inducción matemática para demostrar que 11n − 4n es divisible
por 7 para todo n ∈ N.
7. Demuestre que xn − 1 es divisible por x − 1 para todos n, x ∈ N.
8. La función F : N → N de Fibonacci se define inductivamente de la siguiente
manera:

F (0) = 0 F (1) = 1 F (n) = F (n − 2) + F (n − 1), para n ≥ 2.

Demuestre que la función F de Fibonacci satisface las siguientes igualdades:


a) F (1) + F (3) + · · · + F (2(n − 1) + 1) = F (2n), para n ≥ 1.
b) F (0) + F (2) + · · · + F (2n) = F (2n + 1) − 1, para n ≥ 0.
9. Demuestre que la función F de Fibonacci satisface, para n ∈ N, la siguiente
igualdad:

F (0)2 + F (1)2 + · · · + F (n)2 = F (n)F (n + 1).

10. Demuestre que la función F de Fibonacci satisface, para n ∈ N, la siguiente


igualdad:

F (n)2 − F (n + 1)F (n − 1) = (−1)n+1 .


2.2. Ordenamiento de arreglos 49

11. Demuestre que la función F de Fibonacci, para n ≥ 1, satisface


 n−2
3
F (n) ≥ .
2
12. El producto entre dos matrices de dimensión 2 × 2 se define como:
! ! !
a00 a01 b00 b01 a00 b00 + a01 b10 a00 b01 + a01 b11
× = .
a10 a11 b10 b11 a10 b00 + a11 b10 a10 b01 + a11 b11
Dada una matriz A de 2 × 2, se define An para n ≥ 1 de la siguiente manera:
A1 = A
An+1 = A × An−1 , n ≥ 2.
Usando el principio de inducción matemática demuestre, para n ≥ 1, la siguien-
te igualdad relacionada con la función F de Fibonacci:
!n !
1 1 F (n + 1) F (n)
= .
1 0 F (n) F (n − 1)

2.2. Ordenamiento de arreglos

Ordenar un arreglo de, por ejemplo, números es un problema fundamental en


computación.

Problema 2.2.1: Ordenamiento de un arreglo de números

Entrada: Un arreglo A[0..N ), con N ≥ 0, de números.


Salida: A[0..N ) ordenado ascendentemente.

A pesar de la simplicidad de su planteamiento y de contar con soluciones muy


intuitivas, es un problema que ha sido estudiado extensivamente. La principal razón,
pareciera, es la dificultad para resolverlo eficientemente por medio de un algoritmo
cuando no se cuenta con información adicional sobre el arreglo dado.
El objetivo ahora es derivar un algoritmo que ordene un arreglo de números a
partir de la técnica dividir, conquistar y combinar. Para ello, es deseable identificar
rápidamente los subcasos que generalmente surgen al tratar de dividir el problema
en suproblemas similares. En este sentido, es importante fijarse en qué casos se pue-
den resolver directamente sin necesidad de un ordenamiento explı́cito y los demás
casos que parece no se puede resolver directamente. Los primeros casos correspon-
derán, muy seguramente, a los casos base de una demostración por inducción y
al conquistar de la técnica algorı́tmica. Los segundos, serán los casos inductivos
50 2. Dividir, conquistar y combinar

(i.e., combinar ), que a su vez nos permitirán identificar cuál deberı́a ser la hipótesis
inductiva (i.e., dividir ). Por supuesto, dependiendo de cómo se haga este análisis,
diferentes algoritmos resultarán.

Algoritmo 2.2.1

1. Si n = N , entonces no se hace nada.


2. Si n 6= N , entonces hay al menos un elemento en A[n..N ):
a) identificar el ı́ndice n ≤ m < N con el mı́nimo valor en A[n..N ),
b) intercambiar A[n] y A[m], y
c) recurrir sobre A[n + 1..N ).

Note que el caso n = N , identifica la situación en la cual A[n..N ) es vacı́o (y


está ordenado). En el caso n 6= N es posible que el arreglo no esté ordenado, por
lo cual se ubica en A[n] el elemento más pequeño en A[n..N ); de esta forma y se
recurre ordenando A[n + 1..N ). La intuición es que en este último caso, dado que
A[n] es el mı́nimo valor en A[n..N ) y A[n + 1..N ) está ordenado ascendentemente,
necesariamente A[n..N ) queda ordenado ascendentemente.

Implementación 2.2.1: Ordenamiento basado en el Algoritmo 2.2.1

1 def sort(A, n, N):


2 if n==N: pass
3 else:
4 m = n
5 for i in range(n+1, N):
6 if A[i] < A[m]: m = i
7 A[n],A[m] = A[m],A[n]
8 sort(A, n+1, N)

Ejemplo 2.2.1

La función sort se usa a continuación con algunos ejemplos:


1 A = [8, -10, -2, 3, -50, 2, 10, 8, 4 ]
2 sort(A, 0, len(A))
3 print(A)
4

5 A = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
6 sort(A, 0, len(A))
2.2. Ordenamiento de arreglos 51

7 print(A)
8

9 A = [ ]
10 sort(A, 0, len(A))
11 print(A)
12

13 A = [ 5 ]
14 sort(A, 0, len(A))
15 print(A)
El resultado de esta ejecución es el siguiente:
1 [-50, -10, -2, 2, 3, 4, 8, 8, 10]
2 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
3 []
4 [5]

Se han mostrado ejemplos que sugieren indicar que funciona bien para algunas
instancias del problema, mas no una demostración de que el algoritmo funciona
correctamente (i.e., resuelve adecuadamente todas las instancias del problema).
Para ello es necesario demostrar que en realidad el código hace lo que debe hacer, es
decir, ordenar un arreglo ascendentemente. Dicho de otra forma: se desea demostrar
que sort ordena ascendentemente cualquier arreglo de números.

Teorema 2.2.1: Correctitud de sort

Para N = len(A) y 0 ≤ n ≤ N , el llamado sort(A, n, N) ordena ascenden-


temente el arreglo A[n..N ). En particular, sort(A, 0, N) ordena ascendente-
mente a A[0..N ).

Demostración

Se procede por inducción sobre n:


Caso base (n = N ): en este caso, el algoritmo no hace ningúna operación.
Note que A[n..N ) es el arreglo vacı́o, el cual por definición está ordenado
(pues no tiene un par de elementos en desorden).
Caso inductivo (n 6= N ): en este caso, n es un ı́ndice dentro del arreglo y
A[n] representa el valor en esa posición. El ciclo for y la asignación pos-
terior al ciclo ubican en A[n] el mı́nimo valor en A[n..N ). Por la hipótesis
52 2. Dividir, conquistar y combinar

inductiva, A[n + 1..N ) está ordenado ascendentemente (este es el llama-


do recurrente de sort). Como ningún elemento en A[n + 1..N ) es menor
que A[n] y A[n + 1..N ) está ordenado ascendentemente, necesariamente
A[n..N ) está ordenado ascendentemente.
Finalmente, como el resultado es cierto para cualquier n que satisface
0 ≤ n ≤ N , en particular vale para n = 0 y de esa forma se tiene que
sort(A, 0, N) ordena ascendentemente a A[0..N ).

La complejidad temporal del algoritmo sort se establece asintóticamente a


continuación.

Teorema 2.2.2: Complejidad temporal de sort

Para N = len(A) y 0 ≤ n ≤ N , el llamado sort(A, n, N) toma tiempo


O((N − n)2 ). En particular, el llamado sort(A, 0, N) toma tiempo O(N 2 ).

Demostración

Sin pérdida de generalidad, se supondrá que las operaciones aritméticas, de


asignación y de control toman exactamente una unidad de tiempo. Además, se
define k = N − n para simplificar la manipulación aritmética en los cálculos.
Note que en cada llamado recurrente se realizan N − n = k iteraciones del
ciclo for y cada una de ellas toma tiempo constante. Entonces, la cantidad
de operaciones está dada por:
(N − n) + (N − (n + 1)) + · · · + (N − (N − 1)) + (N − N )
=k + (k − 1) + · · · + 1 + 0
k(k + 1)
=
2
(N − n)(N − n + 1)
= ∈ O((N − n)2 ).
2
En particular, para n = 0, se tiene que la cantidad de operaciones está
dada por O(N 2 ).

La complejidad espacial se debe calcular con cuidado, en especial bajo la au-


sencia de información acerca de detalles de implementación de la recurrencia. A
continuación, se presenta un estimado que supone que la creación del ambiente de
ejecución de cada uno de los llamados recurrentes es de orden constante.
2.2. Ordenamiento de arreglos 53

Teorema 2.2.3: Complejidad espacial de sort

Para N = len(A) y 0 ≤ n ≤ N , el llamado sort(A, n, N) usa espacio


O(N − n). En particular, el llamado sort(A, 0, N) usa espacio O(N ).

Demostración

En cada llamado recurrente de sort se crean a lo sumo dos variables, llamadas


m e i. Como hay a lo sumo N − n llamados recurrentes en donde se crean estas
dos variables (únicamete se evita su creación en el caso base), la complejidad
espacial de sort(A, n, N) es O(N − n). Luego, la complejidad espacial de
sort(A, 0, N) es O(N ).

Es importante aclarar que en el Teorema 2.2.3 se está suponiendo que las varia-
bles que se crean en cada llamado recurrente son independientes entre los llamados.
Sin embargo, si se pudieran reutilizar de tal forma que fuera una cantidad constante
para todos los llamados recurrentes, la complejidad espacial del algoritmo serı́a de
orden constante.

Ejercicios

1. El algoritmo de ordenamiento diseñado e implementado en esta sección es muy


conocido y tiene un nombre. ¿Cuál es?
2. Demuestre la siguiente versión, más general, del teorema de corrección de la
función sort
Teorema. Para N = len(A) y 0 ≤ n ≤ m ≤ N , el llamado sort(A, n, m)
ordena ascendentemente el arreglo A[n..m).
3. Se propone el siguiente algoritmo para ordenar un arreglo A[0..N ) de números:
mientras que A[0..N ) no esté ordenado, permutarlo.
a) ¿Puede estimar la complejidad temporal de este algoritmo?
b) ¿Puede demostrar que funciona?
Si es necesario contar con algunos supuestos, indique cuáles son. Justifique sus
respuestas.
4. Considere la siguiente variación del Algoritmo 2.2.1:
Si n = N , entonces no se hace nada.
Si n = N − 1, entonces no se hace nada.
Si n 6= N , entonces hay al menos un elemento en A[n..N ):
• identificar el ı́ndice n ≤ m < N con el mı́nimo valor en A[n..N ),
54 2. Dividir, conquistar y combinar

• intercambiar A[n] y A[m], y


• recurrir sobre A[n + 1..N ).
Con base en este algoritmo:
a) Modifique la función sort en la Implementación 2.2.1 para que considere
el segundo caso base planteado anteriormente.
b) Formule el teorema de corrección y demuestre que es correcto para esta
nueva versión del algoritmo.
c) ¿Cuál es la complejidad temporal y espacial del algoritmo? Justifique su
respuesta.
5. En el caso recurrente del Algoritmo 2.2.1, para ordenar el arreglo A[n..N ) se
usa la estrategia de identificar el ı́ndice n ≤ m < N con el mı́nimo valor e
intercambiarlo con A[n] antes de recurrir sobre A[n + 1..N ).
Otra estrategia para resolver el Problema 2.2.1 resulta de identificar valores
máximos y no mı́nimos.
a) Diseñe un algoritmo recurrente que, usando dividir, conquistar y combinar,
aplique esta estrategia.
b) Implemente su diseño en una función del lenguaje de programación Python.
c) Enuncie y demuestre el teorema de corrección de su algoritmo.
d ) ¿Cuál es la complejidad temporal del algoritmo?
6. Considere el problema de ordernar descendentemente un arreglo de números.
a) Especifique el problema dado.
b) Usando dividir, conquistar y combinar, diseñe un algoritmo recurrente que
resuelva el problema especificado anteriormente.
c) Demuestre que el algoritmo es correcto con respecto a la especificación
dada.
d ) Calcule la complejidad temporal del algoritmo diseñado.
7. Considere la siguiente colección de problemas algorı́tmicos sobre un arreglo de
números:
a) Calcular la suma de los elementos del arreglo.
b) Determinar el mı́nimo valor en el arreglo.
c) Determinar el máximo valor en el arreglo.
d ) Calcular la cantidad de apariciones de un número dado en el arreglo.
Para cada uno de estos problemas:
Especifique el problema dado.
Usando dividir, conquistar y combinar, diseñe un algoritmo recurrente que
resuelva el problema especificado.
Demuestre que el algoritmo es correcto con respecto a la especificación.
Calcule la complejidad temporal del algoritmo diseñado.
Implemente el algoritmo en el lenguaje de programación Python.
2.3. Una versión iterativa del ordenamiento 55

2.3. Una versión iterativa del ordenamiento

En la Sección 2.2 se diseñó un algoritmo recurrente que ordena ascendente-


mente un arreglo de números. Su diseño se basó en la técnica dividir, conquistar
y combinar, con la cual se obtuvo la implementación de la función sort en el len-
guaje de programación Python. También es posible obtener una versión iterativa
del Algoritmo 2.2.1 de ordenamiento. Para ello es necesario usar la noción de in-
variante como guı́a para diseñar un ciclo iterativo. Esta sección presenta la noción
de invariante asociada a un ciclo iterativo y la ilustra con el ejemplo de una versión
iterativa del algoritmo de ordenamiento ascendente de un arreglo de números.

Nota 2.3.1

Un invariante (de ciclo) es una fórmula lógica asociada a un ciclo iterativo


que cumple las siguientes dos condiciones:
ser cierta antes de que inicien las iteraciones del ciclo
ser cierta después de cualquier iteración del ciclo.

El diseño de algoritmos con invariantes requiere de disciplina, práctica y un


entendimiento claro de la estrategia de solución que materializa el ciclo al cual
está asociado. Cuando los invariantes están bien planteados y son suficientemente
completos, es relativamente fácil obtener un ciclo iterativo correcto. La estrategia
en estas notas es diseñar ciclos iterativos a partir de invariantes.
Se utilizarán los siguientes invariantes para representar el avance del algoritmo
hacia el ordenamiento del arreglo dado. Para evitar confusiones porque el ordena-
miento se hará en el mismo arreglo (i.e., es un ordenamiento in-situ) se identificará
con A al estado inicial del arreglo, antes de que el algoritmo a ser diseñado lo mani-
pule para lograr su ordenamiento. Es decir, el estado inicial del arreglo A se llama
A.

P0 : A[0..N ) es una permutación de A[0..N ).


P1 : A[0..n) tiene los n elementos de A[0..N ) más pequeños y ordenados
ascendentemente.
P2 : 0 ≤ n ≤ N.

Hay tres invariantes, llamados P0 , P1 , P2 . El invariante P0 indica que los elementos


de A son los mismos que los de A, posiblemente en diferente orden. El invariante
P1 indica no solo que A[0..N ) está parcialmente ordenado en las posiciones 0..n − 1,
sino que los elementos allı́ corresponden a los más pequeños en A. El invariante P2
indica que el valor de n varı́a entre 0 y N , inclusive.
56 2. Dividir, conquistar y combinar

Antes de continuar con el diseño de los casos del algoritmo, es clave pensar en
cuál serı́a la situación ideal que –con base en los invariantes dados– garantizará que
el arreglo A está ordenado. En este sentido, el invariante P1 es clave, pues cuando n
sea N (lo cual es permitido por el invariante P2 ), se tendrı́a la siguiente situación:
A[0..N ) tiene los n elementos de A[0..N ) más pequeños y ordenados ascen-
dentemente.
Es decir, A[0..N ) tendrı́a sus elementos ordenados ascendentemente; no faltarı́a ni
sobrarı́a elemento alguno gracias al invariante P0 . De esta forma, se sugiere que
la estrategia es ir avanzando n, que puede iniciar desde 0, hasta que su valor sea
N , mientras se mantienen los invariantes formulados. Un representación gráfica
de los invariantes P1 y P2 , y de esta idea de solución algorı́tmica, se muestra a
continuación:

ordenado por ordenar

A
0 n N
Con base en los invariantes y en la idea gráfica de diseño, se propone el Algo-
ritmo 2.3.1 para ordenar iterativa y ascendentemente un arreglo de números.

Algoritmo 2.3.1

Para n = 0, 1, . . . , N − 1:
1. identificar el ı́ndice n ≤ m < N con el mı́nimo valor en A[n..N ) e
2. intercambiar A[n] y A[m].

La propuesta del Algoritmo 2.3.1 es similar a la del Algoritmo 2.2.1 recurrente,


diseñado en la Sección 2.2. La gran diferencia es que después del paso (2) no se
recurre sino que se continúa con las iteraciones del ciclo. Este diseño resulta en la
siguiente implementación.

Implementación 2.3.1: Ordenamiento basado en el Algoritmo 2.3.1

1 def sortiter(A):
2 N = len(A)
3 # P0 ∧ P1 ∧ P2
4 for n in range(0, N):
5 m = n
2.3. Una versión iterativa del ordenamiento 57

6 for i in range(n+1, N):


7 if A[i] < A[m]: m = i
8 A[n],A[m] = A[m],A[n]

Note que el código que selecciona el valor mı́nimo en A[n..N ) es el mismo de la


versión recurrente del algoritmo.
La demostración de que un algoritmo iterativo de correcto está directamente
ligada a la noción de invariante.

Nota 2.3.2

Para demostrar que un ciclo iterativo C con condición de terminación B es


correcto con respecto a un invariante P es necesario demostrar que:
1. [Iniciación] P es cierto antes de la primera iteración de C y
2. [Estabilidad] Si P es cierto antes de una iteración de C, entonces P es
cierto después de una iteración de C (i.e., que P ∧ B implica P después
de la terminación de una iteración de C).
Adicionalmente, una vez terminen las iteraciones de C, el invariante P debe
suministrar información importante acerca del objetivo de C (i.e., P ∧ ¬B
deben proveer información sobre la finalidad de C).

A manera de ejemplo de cómo establecer la correctitud del ciclo principal de


Algoritmo 2.3.1 con respecto sus invariantes, se presentan demostraciones de inicia-
ción y estabilidad. Además, se muestra cómo los invariantes ayudan a determinar
qué logra la ejecución del algoritmo. Dado que hay un ciclo anidado dentro del ciclo
principal, se supondrá que dicho ciclo cumple su objetivo: calcular en la variable m
el ı́ndice del valor mı́nimo en A[n..N ).

Teorema 2.3.1

Los invariantes P0 , P1 , P2 son ciertos antes de la ejecución del ciclo (principal)


de la Implementación 2.3.1.

Demostración

Antes de la primera iteración del ciclo, las variables n y N tienen los valores
0 y len(A), respectivamente. Note que:
Dado que el arreglo A no ha sido modificado, claramente P0 es cierto.
58 2. Dividir, conquistar y combinar

Como n = 0, el arreglo A[0..n) es vacı́o y por tanto no tiene elementos.


Esto concuerda con el invariante P1 .
Con n = 0 y N = len(A) se establecen trivialmente las desigualdades
0 ≤ n ≤ N.

Teorema 2.3.2

Si los invariantes P0 , P1 , P2 son ciertos y se itera una vez el ciclo (principal)


de la Implementación 2.3.1, entonces P0 , P1 , P2 siguen siendo ciertos.

Demostración

Lo que cambia en una iteración del ciclo son la variable n y (posiblemente) el


arreglo A[n..N ). Para proceder, se supone que P0 , P1 , P2 son ciertos antes de
que estos camibios surtan efecto. Además, como es necesario que la condición
0 ≤ n < N (esta es la guarda del ciclo) se cumpla para que dicha iteración se
dé, se supondrá también que es cierta. Observe:
Como A[0..N ) es una permutación de A[0..N ) (suposición de que P0 es
cierto antes de la iteración) y como lo único que afecta a A en una itera-
ción es el intercambio A[n], A[m] = A[m], A[n], el invariante P0 es cierto
después de una iteración del ciclo.
Por P1 , se tiene que A[0..n) está ordenado ascendentemente con los n ele-
mentos más pequeños de A[0..N ). En una iteración, A[n] es actualizado
con el elemento más pequeño de A[n..N ). Luego, A[0..n + 1) está orde-
nado ascendentemente con los n + 1 elementos más pequeños de A[0..N )
(este es el invariante P1 después de una iteración del ciclo).
Como 0 ≤ n < N y n aumenta en uno con una iteración del ciclo, entonces
claramente 0 ≤ n + 1 ≤ N , lo cual corresponde al invariante P2 después
de incrementar n.

Finalmente, note que el ciclo termina cuando n = N . Como el ciclo es correcto


con respecto a los invariantes P0 , P1 , P2 , se tiene que A[0..N ) tiene los N elementos
de A[0..N ) más pequeños y ordenados. Es decir, sortiter(A) ordena ascendente-
mente el arreglo A. El análisis de las complejidades temporal y espacial, al igual que
la corrección del ciclo interno de Implementación 2.3.1 se proponen como ejercicio
al lector.
2.3. Una versión iterativa del ordenamiento 59

Ejercicios

1. Demuestre que el ciclo interno en Implementación 2.3.1 termina.


2. Proponga invariantes para el ciclo interno en Implementación 2.3.1 y demuestre
que el diseño del ciclo es correcto.
3. Considere el problema del ordenamiento de un arreglo de números. Suponga
que únicamente se condieran arreglos cuyos valores están entre 0 y 10000.
a) Especifique el problema.
b) Diseñe un algoritmo que resuelva el problema dado, y cuyas complejidades
temporal y espacial sean O(N ), en donde N es el tamaño del arreglo.
4. Considere la siguiente colección de problemas algorı́tmicos sobre un arreglo de
números:
a) Calcular la suma de los elementos del arreglo.
b) Determinar el mı́nimo valor en el arreglo.
c) Determinar el máximo valor en el arreglo.
Para cada uno de estos problemas:
Especifique el problema dado.
Diseñe un algoritmo iterativo, incluyendo los invariantes correspondientes,
que resuelva el problema especificado.
Demuestre que el algoritmo es correcto con respecto a la especificación.
Calcule la complejidad temporal del algoritmo diseñado.
Implemente el algoritmo en el lenguaje de programación Python.
5. Considere la siguiente función sortitergen:
1 def sortitergen(A, low, hi):

2 for n in range(low, hi):


3 m = n
4 for i in range(n+1, hi):
5 if A[i] < A[m]: m = i
6 A[n],A[m] = A[m],A[n]
Esta es una versión generalizada de la función sortiter en la Implementa-
ción 2.3.1 que –dados A[0..N ), low, hi, con 0 ≤ low ≤ hi ≤ N – ordena ascen-
dentemente el subarreglo A[low..hi).
Proponga invariantes para el ciclo externo y demuestre la correctitud de la
función (suponiendo que el ciclo interno es correcto).
6. La siguiente es una enumeración de algoritmos de ordenamiento iterativos am-
pliamente conocidos:
a) BubbleSort
b) InsertionSort
c) ShellSort
60 2. Dividir, conquistar y combinar

Para cada uno de estos algoritmos en el caso de ordenamiento de un arreglo de


números:
a) Plasme el diseño del algoritmo al estilo del Algoritmo 2.3.1.
b) Proponga invariantes para los ciclos que materializan los algoritmos itera-
tivos.
c) Demuestre que son correctos con respecto a la especificación propuesta.
d ) Determine la complejidad temporal y espacial del algoritmo.

2.4. Mergesort: un ordenamiento de arreglos más eficiente

La idea principal detrás del algoritmo MergeSort no es muy distinta a la de los


algoritmos de ordenamiento presentado en las secciones secciones 2.2 y 2.3, pues
su diseño se puede también explicar con base en dividir, conquistar y combinar.
La principal caracterı́stica de MergeSort es que usa la hipótesis inductiva dos veces
en cada paso del “combinar”: el subarreglo se divide en dos “mitades” que, siendo
problemas más pequeños que el original, se puede suponer que están ordenadas
ascendentemente (i.e., se hace un llamado recurrente para cada una de ellas), para
posteriormente combinarlas a modo de cremallera, manteniendo el orden de los
elementos al ser consolidados en el arreglo original.

ordenado ordenado
A
0 low mid hi N

merge

A
0 low ordenado hi N

A cada momento, el algoritmo mantiene un “cerco” para el ordenamiento de un


subarreglo de A con base en las varibles low y hi: el objetivo es ordenar A[low..hi).
Si esto es posible para cualesquiera low y hi, en particular se tiene resuelto el
ordenamiento de todo el arreglo A[0..N ).
Antes de continuar explicando la imagen anterior que resume la idea de Mer-
geSort, se introducen las siguientes condiciones:
C0 : A[0..N ) es una permutación de A[0..N ).
C1 : 0 ≤ low ≤ hi ≤ N.
2.4. Mergesort: un ordenamiento de arreglos más eficiente 61

Note que la idea de ordenar el arreglo dado /in-situ/ se captura con la condición
C0 . La condición C1 brinda pistas de cómo se puede avanzar con low y hi ordenando
por partes:

Si A[low..hi) tiene a lo sumo un elemento, entonces no hay mucho que hacer


pues el arreglo vacı́o y cualquier arreglo unitario están ordenados por definición.
Estos casos suceden cuando low + 1 ≥ hi.

Si A[low..hi) tiene al menos dos elementos, entonces se puede “partir” en dos


mitades (aproximadas) A[low..mid) y A[mid..hi) ordenando cada una de ellas
recurrentemente y posteriormente combinándolas. Estos casos suceden cuando
low + 1 < hi.

La idea principal detrás de la función mergesort en el Algoritmo 2.4.1 deberı́a


estar clara en este punto. Por ende, el foco estará en entender cómo la función
merge combina las dos partes del arreglo que están ordenadas garantizando el or-
denamiento general del arreglo A[low..hi).

Implementación 2.4.1: MergeSort en Python

1 def mergesort(A, low, hi):


2 if low+1<hi:
3 mid = low+((hi-low)>>1) # mid = (low+hi)//2
4 mergesort(A, low, mid) # induction hypothesis on the first half
5 mergesort(A, mid, hi) # induction hypothesis on the second half
6 merge(A, low, mid, hi) # combine the two halves preserving the order
7

8 def merge(A, low, mid, hi):


9 global tmp # a global array at least the size of A
10 for i in range(low, hi): tmp[i] = A[i] # copy A[low..hi) to tmp[low..hi)
11 l,r = low,mid
12 for n in range(low, hi):
13 if l==mid: A[n],r = tmp[r],r+1 # only process the right half
14 elif r==hi: A[n],l = tmp[l],l+1 # only process the left half
15 else:
16 # the first pending element of each half needs to be compared
17 if tmp[l]<=tmp[r]: A[n],l = tmp[l],l+1 # choose the one on the left
18 else: A[n],r = tmp[r],r+1 # choose the one on the right
62 2. Dividir, conquistar y combinar

Para entender cómo encajan las piezas del algoritmo, se proponen los siguientes
invariantes para el segundo ciclo de la función merge:
P0 : A[low..n) es un ordenamiento de tmp[low..l) y tmp[mid..r).
P1 : low ≤ l ≤ mid ≤ r ≤ hi.
P2 : low ≤ n ≤ hi.

El invariante P0 indica que en A[low..n) se van almacenando, en orden, los elementos


de tmp[low..l) y tmp[mid..r) que han sido procesados. Los invariantes P1 y P2 indican
entre qué rangos pueden tomar valores las variables l, r y n. Intuitivamente, las
variables l y r pueden entenderse como dos (dedos) ı́ndices que señalan el siguiente
elemento de la mitad izquierda y de la derecha, respectivamente, que debe ser
procesado. Cuando l = mid se tiene que la mitad izquierda ha sido agotada; de la
misma forma, cuando r = hi se tiene que la mitad derecha ha sido agotada. En
cualquiera de estos dos casos no hay que comparar nada y basta con copiar a A los
elementos pendientes en la mitad correspondiente. Si hay al menos un elemento en
cada una de las mitades, entonces es necesario compararlos para determinar cuál
de los dos se copiará a A[n]: el menor de los dos o, en caso de igualdad, el de la
mitad izquierda. El siguiente diagrama resume visualmente el rol de cada una de
las variables y de los arreglos involucrados en merge:
ordenado ordenado

tmp
0 low l mid r hi len(tmp)

¿?
ordenado

A
0 low n hi N

Antes de proceder a demostrar que merge y mergesort son correctos con respec-
to a su especificación, se presentan ejemplos de cómo el algoritmo ordena algunos
arreglos de números.

Ejemplo 2.4.1

La función mergesort se usa a continuación con algunos ejemplos:


1 tmp = [ None for _ in range(10) ]
2 A = [8, -10, -2, 3, -50, 2, 10, 8, 4 ]
3 mergesort(A, 0, len(A))
4 print(A)
5

6 A = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
2.4. Mergesort: un ordenamiento de arreglos más eficiente 63

7 mergesort(A, 0, len(A))
8 print(A)
9

10 A = [ ]
11 mergesort(A, 0, len(A))
12 print(A)
13

14 A = [ 5 ]
15 mergesort(A, 0, len(A))
16 print(A)
El resultado de esta ejecución es el siguiente:
1 [-50, -10, -2, 2, 3, 4, 8, 8, 10]
2 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
3 []
4 [5]

A continuación se presenta y demuestra el teorema de correctitud de mergesort.

Teorema 2.4.1

Si A[low..mid) y A[mid..hi) están ordenados ascendentemente, entonces el


llamado merge(A, low, mid, hi) garantiza el ordenamiento ascendente de
tmp[low..hi) en A[low..hi).

Demostración

Se procede por inducción sobre n.


Caso base (n = hi): en este caso no debe hacerse nada pues las dos
mitades tmp han sido procesadas. Para observar esto con mayor claridad,
a partir del código se puede deducir la siguiente igualdad (esto hay que
demostrarlo y se propone como ejercicio al lector):
n − low = (l − low) + (r − mid)
Con n = hi y con base en el invariante P1 , se tiene que las igualdades l =
mid y r = hi son ciertas (esto también se propone como ejercicio al lector);
es decir, no hacen falta elementos en tmp[low..hi) por ser procesados.
Caso inductivo (n 6= hi): esto indica que hay al menos un valor por ser
procesado, y que al menos uno de los arreglos tmp[l..mid) y tmp[r..hi) no
es vacı́o. Se procede por casos:
64 2. Dividir, conquistar y combinar

• Si l = mid, entonces la mitad izquierda no tiene elementos pendientes


por ser procesados y el siguiente a ser almacenado en A[n] es tmp[r];
se “recurre” ordenando tmp[r + 1..hi) en A[n + 1..hi).
• Si r = hi, el caso es similar al anterior, pero se procesa el siguiente
valor en la mitad izquierda pues la derecha no tiene elementos por ser
procesados; se “recurre” ordenando tmp[l + 1..mid) en A[n + 1..hi).
• De lo contrario, l 6= mid y r 6= hi y se debe decidir entre tmp[l] y
tmp[r] para actualizar A[n]. Si tmp[l] ≤ tmp[r] entonces se actualiza
A[n] con tmp[l], y se “recurre” ordenando tmp[l + 1..mid) y tmp[r..hi)
en A[n + 1..hi). De lo contrario (i.e., tmp[l] > tmp[r]) se actualiza A[n]
con tmp[r], y se “recurre” ordenando tmp[l..mid) y tmp[r + 1..hi) en
A[n + 1..hi).
Por la hipótesis inductiva, dado que hay menos elementos que ordenar en
los casos recurrentes, se tiene que A[low..hi) es un ordenamiento ascen-
dente de tmp[low..hi).
En consecuencia, como tmp[low..hi) es una copia de la versión inicial de
A[low..hi), se concluye que merge(A, low, mid, hi) ordena ascendemente a
A[low..hi).

Con base en la correctitud (o corrección) de merge, se puede establecer la co-


rrectitud de mergesort.

Teorema 2.4.2

Para N = len(A) y 0 ≤ low ≤ hi ≤ N , el llamado mergesort(A, low, hi)


ordena ascendentemente A[low..hi).

Demostración

Se procede por inducción sobre el tamaño de A[low..hi):


Caso base (low + 1 ≥ hi): en este caso, mergesort no modifica a A. Note
que cuando low + 1 ≥ hi, A[low..hi) es vacı́o o tiene exactamente un
elemento, por lo cual está ordenado ascendentemente.
Caso inductivo (low + 1 < hi): en este caso, el problema se divide en
dos subproblemas: ordenar A[low..mid) y A[mid..hi). Bajo la condición
dada, el código garantiza que la desigualdad low < mid < hi (esto se
debe demostrar y se propone como ejercicio para el lector). Esto ga-
rantiza que cada uno de los sub-arreglos tiene al menos un elemento y
ambos tienen menor tamaño que A[low..mid): como son más pequeños,
2.4. Mergesort: un ordenamiento de arreglos más eficiente 65

por hipótesis inductiva, se puede suponer que después de los llamados


recurrentes mergesort(A, low, mid) y mergesort(A, mid, hi) ordenan
A[low..mid) y A[mid..hi), respectivamente. Dado que merge es correcto
(Teorema 2.4.1), el llamado merge(A, low, mid, hi) ordena ascendente-
mente a A[low..hi).
En cualquiera de los dos casos, A[low..hi) resulta ordenado ascendentemente
por mergesort(A, low, hi).

La ventaja que tiene el algoritmo mergesort con respecto al primer algoritmo


de ordenamiento estudiado, es que su comportamiento asintótico en función de la
cantidad de operaciones que lleva a cabo para ordenar un arreglo es mejor (i.e.,
crece menos rápido).

Teorema 2.4.3

Para N = len(A) y 0 ≤ low ≤ hi ≤ N , el llamado mergesort(A, low, hi) to-


ma tiempo O((hi − low) log(hi − low)). En consecuencia, mergesort(A, 0, N)
toma tiempo O(N log N ).

Demostración

Sea n = hi − low (note que n ≥ 0). A modo de ejercicio para el lector, se


puede establecer que merge(A, low, mid, hi) toma tiempo O(hi − low) =
O(n) (esto es lo que cuesta combinar las soluciones a los subproblemas de
mergesort). Entonces, la ecuación de recurrencia que define la cantidad de
operaciones básicas está dada por
n
T (n) = 2T + O(n)
2
pues se recurre una vez en cada una de las dos mitades y cuesta O(n) combinar
estas soluciones. De acuerdo con el Teorema Maestro, se tiene que
T (n) ∈ O(n log n).
Note que con low = 0 y hi = N , que corresponden al llamado mergesort(A, 0, N),
se tiene n = N y consecuentemente T (N ) ∈ O(N log N ).

Finalmente, se analiza la complejidad espacial de mergesort.

Teorema 2.4.4
66 2. Dividir, conquistar y combinar

Para N = len(A) y suponiendo que la variable tmp es suministrada para


merge, el llamado mergesort(A, 0, N) usa O(dlog N e) espacio, en donde d e
es la función techo.

Demostración

Se propone como ejercicio para el lector.

Ejercicios

1. En la demostración del Teorema 2.4.1 se usa la siguiente igualdad:

n − low = (l − low) + (r − mid)

Demuestre que esta igualdad es cierta. Concluya que, cuando n = hi y con base
en el invariante P1 , l = mid y r = hi.
2. Demuestre que la desigualdad low < mid < hi es cierta en mergesort.
3. Demuestre el Teorema 2.4.3.
4. Demuestre el Teorema 2.4.4.
5. Considere la función merge que es llamada desde la función mergesort en la
Implementación 2.4.1. Allı́ se usa la variable global tmp para almacenar una
copia de partes de A en cada llamado. Suponiendo que dicha variable no es global
y que en cada llamado recurrente se crea un arreglo de tamaño hi − low para la
copia temporal, demuestre que mergesort(A, 0, N) usa espacio O(N log N ).
Justifique su respuesta.
6. La cantidad de inversiones de un arreglo es un indicador de qué tan desordenado
está: si este valor es 0, entonces el arreglo está completamente ordenado. Si
el arreglo está ordenado descendentemente, entonces este valor es el máximo.
Formalmente, una inversión en en un arreglo A[0..N ), N ≥ 0, es una pareja de
ı́ndices 0 ≤ i < j < N tales que A[i] > A[j]. Por ejemplo, en el arreglo [3, 1, 2]
hay dos inversiones: (3, 1) y (3, 2).
a) Diseñe un algoritmo que calcule la cantidad de inversiones en A[0..N ) en
tiempo O(N log N ).
b) Demuestre que el algoritmo propuesto es correcto con respecto a la espe-
cificación.
2.5. Búsqueda binaria 67

2.5. Búsqueda binaria

El problema de búsqueda binaria consiste en encontrar un valor en un arreglo


ordenado.

Problema 2.5.1: Búsqueda binaria

Entrada: Un arreglo A[0..N ) de números ordenado ascendentemente,


N ≥ 0, y un número x.
Salida: ¿Está x en A[0..N )?

Esta versión del problema corresponde al problema de decisión, es decir, el que


requiere responder sı́ o no. Hay otras versiones en las cuales se desea averiguar más
información como, por ejemplo, un ı́ndice en el arreglo en el cual aparece el valor
dado (si aparece) o la cantidad de ocurrencias de este. También hay variantes en las
cuales el arreglo dado no está ordenado ascendentemente sino descendentemente.
Cualquiera de estas variantes se puede resolver con modificaciones (en su mayorı́a
sencillas) del algoritmo que se presenta en esta sección.
La idea general es explotar la información que se tiene sobre el arreglo A[0..N ).
En particular, el hecho de que esté ordenado (ascendentemente, aunque –como se
explica anteriormente– puede estar ordenado de otra forma) brinda la posibilidad
de evitar hacer comparaciones y operaciones innecesarias. La idea general es la
siguiente: suponga que se compara el valor dado x con el valor A[n] del arreglo
(0 ≤ n < N ). Allı́ hay tres opciones:

Si A[n] = x, entonces x está en A[0..N ) y se responde afirmativamente.


Si A[n] < x, entonces x necesariamente NO está en A[0..n) dado que el arreglo
está ordenado; en este caso, solo tiene sentido buscar a partir del ı́ndice n + 1.
Es decir, se puede recurrir con un problema de tamaño menor.
Si A[n] > x, entonces x necesariamente NO está en A[n..N ) dado que el arreglo
está ordenado; en este caso, solo tiene sentido buscar antes del ı́ndice n. Es decir,
se puede recurrir con un problema de tamaño menor.

Cualquier caso, bajo las suposiciones hechas en la especificación del problema,


cae en una de las tres opciones enumeradas anteriormente. Ası́, se puede explicar
de manera más precisa la idea central detrás del algoritmo de búsqueda: descartar
rápidamente porciones del arreglo de entrada durante el proceso de búsqueda te-
niendo la certeza de que la porción del espacio de búsqueda que se preserva tiene
suficiente información para responder la pregunta. La razón por la cual el algoritmo
tiene la palabra “binaria” en su nombre es por la forma en cual el espacio restante
68 2. Dividir, conquistar y combinar

para la búsqueda se divide en dos “mitades”, de las cuales una se mantiene y la


otra se descarta.
El diseño general del algoritmo se presenta a continuación:
descartado pendiente por explorar descartado

A
0 low hi N

El algoritmo mantiene dos centinelas low y hi que definen las fronteras del espa-
cio de búsqueda activo A[low..hi) para el valor x. Las otras dos porciones A[0..low)
y A[hi..N ) han sido descartadas y se tiene certeza de que no es necesario seguir
buscando allı́. Este diseño usa las siguientes condiciones:

C0 : (∃i | 0 ≤ i < N : A[i] = x) ≡ (∃i | low ≤ i < hi : A[i] = x).


C1 : 0 ≤ low ≤ hi ≤ N.

La lectura de la condición C0 se puede facilitar si la equivalencia lógica se


interpreta como una doble implicación: si el valor está en el arreglo, entonces está en
el espacio de búsqueda pendiente por explorar (implicación de izquierda a derecha);
si el valor está en el espacio pendiente por explorar, entonces (claramente) está en el
arreglo (derecha a izquierda). De manera alternativa y apelando a la contraposición
proposicional, esta segunda implicación se puede interpretar de la siguiente forma:
si el valor NO está en el arreglo, entonces NO está en el espacio de búsqueda
pendiente por explorar. La condición C1 es auxiliar y limita los valores que las
variables low y hi pueden tomar. Es importante hacer dos observaciones sobre estas
variables. Primero, low nunca debe sobrepasar a hi. Segundo, bajo estas restricciones
necesariamente low es un ı́ndice de A[0..N ) cuando el arreglo no es vacı́o (i.e.,
cuando N 6= 0), pero este no es el caso con hi (¿por qué?). Estas propiedades
son importantes pues la corrección y terminación del algoritmo dependen, en gran
medida, de que sean ciertas.
La idea del algoritmo de búsqueda binaria es estrechar incrementalmente el es-
pacio pendiente por explorar. Es decir, la idea es que las variables low y hi se acer-
quen de tal manera que la cantidad de valores pendientes por explorar A[low..hi))
sea cada vez más pequeña. ¿En qué casos indican estas variables que se ha estra-
chado suficientemente el cerco? De acuerdo con la condición C1 , estos casos están
dados por low = hi y low + 1 = hi: o no hay espacio por explorar o hay exactamente
una posición de A por ser explorada. En el primer caso, claramente la respuesta es
negativa pues un arreglo vacı́o no contiene valor alguno. Como se verá en los algo-
ritmos que se diseñarán, este caso base solo es posible cuando el arreglo A[0..N )
es vacı́o. En el segundo caso, esto quiere decir que la máxima reducción que se
puede hacer del espacio de búsqueda, de acuerdo con los invariantes propuestos, es
a A[low..low + 1) que corresponde a A[low]. Si se combina esta observación con la
2.5. Búsqueda binaria 69

condición C0 , entonces se habrı́a logrado reducir la búsqueda en todo el arreglo a


solo un punto: para determinar si x está en A[0..N ), basta con determinar si x es
el valor en A[low].
La siguiente pregunta a responder es la siguiente: ¿en qué casos (y cómo) se
debe estrechar el cerco establecido por low y hi sobre A[0..N )? Estos son en realidad
los casos inductivos y están caracterizados por la desigualdad low+1 < hi. Note que
bajo esta suposición, existe un número entero mid que satisface low < mid < hi.
Esto en combinación con la condición C1 , cuando N 6= 0, garantiza que mid es
un ı́ndice del arreglo A[0..N ) y por ende, se puede consultar el valor almacenado
allı́: es precisamente contra A[mid] que se compara x para decidir cuál de las dos
partes A[low..mid) o A[mid..hi) descartar directamente. El cómo estrechar el cerco
se responde de manera oportunista: como se ha de descartar una de dos partes,
es conveniente que (sin saber a priori cuál) las dos partes sean lo suficientemente
grandes como para estrechar el cerco lo más ajustado posible. Es de esta forma
como se llega a la conclusión de que es conveniente que mid sea un punto medio
(puede haber más de uno, ¿por qué?) entre low y hi.
descartado pendiente por explorar descartado

A
0 low mid hi N

Algoritmo 2.5.1

1. si low = hi, entonces responder negativamente;


2. si low + 1 = hi, entonces responder afirmativamente si A[low] = x y
negativamente de lo contrario; y
3. si low + 1 < hi, entonces sea mid un punto medio entre low y hi y:
a) si A[mid] = x, entonces responder afirmativamente;
b) si A[mid] < x, entonces recurrir sobre A[mid..hi); y
c) si A[mid] > x, entonces recurrir sobre A[low..mid).

Con base en este diseño se propone la siguiente implementación:

Implementación 2.5.1: Búsqueda binaria basada en el Algoritmo 2.5.1

1 def binsearch(A, x, low, hi):


2 ans = None
3 if low==hi: ans = False
4 elif: low+1==hi: ans = A[low]==x
5 else:
70 2. Dividir, conquistar y combinar

6 mid = low+((hi-low)>>1) # mid = (low+hi)//2


7 if A[mid]==x: ans = True
8 elif A[mid]<x: ans = binsearch(A, x, mid, hi)
9 else: ans = binsearch(A, x, low, mid)
10 return ans

La función binsearch identifica tres casos. Si el espacio de búsqueda es vacı́o,


entonces responde negativamente. Si el espacio de búsqueda ha sido reducido a
una sola posición, entonces se responde con base en lo que hay en esa posición: si
x está allı́, entonces se responde afirmativamente; de lo contrario, negativamente.
Si el espacio de búsqueda tiene al menos dos elementos, entonces se calcula la
“mitad” mid del subarreglo y se recurre con base en la relación existente entre x y
el elemento A[mid], cuando el elemento no se encuentra allı́. Esta es una traducción
literal Algoritmo 2.5.1 al lenguaje de programación Python.
A continuación se presenta la demostración de que la función binsearch es
correcta con respecto a su especificación.

Teorema 2.5.1

Para N = len(A) y 0 ≤ low ≤ hi ≤ N , el llamado binsearch(A, x, low, hi)


determina si x está en A[low..hi). En consecuencia, binsearch(A, x, 0, N)
determina si x está en A[0..N ).

Demostración

Se procede por inducción sobre la distancia entre low y hi:


Caso base (low = hi): el espacio de búsqueda es vacı́o, luego se responde
negativamente
Caso base (low + 1 = hi): el espacio de búsqueda tiene una sola posición
A[low], entonces se determina directamente si x corresponde a es valor.
Caso inductivo (low + 1 < hi): el espacio de búsqueda tiene al menos dos
posiciones. Por la forma en que se calcula mid, se puede demostrar que:
(i) low < mid < hi y (ii) mid es un ı́ndice del arreglo A (estas demos-
traciones se proponen como ejercicio para el lector). Con base en estas
observaciones, se tiene que la búsqueda de x en A[low..mid) y A[mid..hi),
respectivamente, son subproblemas de la búsqueda en A[low..hi). Note
que si A[mid] < x, es imposible que x esté en A[low..mid) (¿por qué?). De
manera similar, si A[mid] > x, es imposible que x esté en A[mid..hi) (¿por
2.5. Búsqueda binaria 71

qué?). En cada uno de los casos se recurre sobre la “mitad” que tiene el
potencial de contener a x.
En consecuencia, el llamado binsearch(A, x, 0, N) resuelve el problema de
búsqueda de x en el arreglo A ordenado ascendentemente.

La demostración de que la función binsearch termina se propone como ejercicio


para el lector.

Teorema 2.5.2

Para N = len(A) y 0 ≤ low ≤ hi ≤ N , el llamado binsearch(A, x, low, hi)


termina.

Demostración

Se propone como ejercicio para el lector.

Teorema 2.5.3

Para N = len(A), el llamado binsearch(A, x, 0, N) toma O(dlog N e) tiem-


po, en donde d e es la función techo.

Demostración

Ver el Ejemplo 1.4.3.

Una versión iterativa del algoritmo de búsqueda binaria se presenta a conti-


nuación. Inicialmente identifica dos casos. Si el arreglo dado es vacı́o, la respuesta
necesariamente es negativa. De lo contrario, iterativamente se estrecha el cerco de
la búsqueda tratando de acercar low y hi hasta que sean números consecutivos.
En esta segunda parte, en la cual en realidad se lleva a cabo la búsqueda, note
que la inicialización low,hi = 0,N hace que las condiciones C0 y C1 , vistas como
invariantes del ciclo iterativo, sean ciertas trivialmente: inicialmente el espacio de
búsqueda es todo el arreglo.

Implementación 2.5.2: Búsqueda binaria iterativa


72 2. Dividir, conquistar y combinar

1 def binsearch(A, x):


2 N,ans = len(A),False
3 if N!=0:
4 low,hi = 0,N
5 # C0 ∧ C1
6 while low+1!=hi:
7 mid = low+((hi-low)>>1) # mid = (low+hi)//2
8 if A[mid]<=x: low = mid
9 else: hi = mid
10 ans = A[low]==x
11 return ans

La correctitud y el análisis asintótico de la versión iterativa de binsearch se


proponen como ejercicio para el lector.

Ejercicios

1. Simule la ejecución del llamado binsearch(A, 0, len(A)) para


A = [ -4, 2, 2, 7, 11, 14, 18, 23, 100] y x = 23.
Explique gráficamente qué sucede en cada llamado recurrente en relación con
las variables low, hi y mid.
2. En la demostración del Teorema 2.5.1, caso inductivo, se hacen las afirmaciones
(i) y (ii). Proponga demostraciones para cada una de ellas.
3. Demuestre el Teorema 2.5.2.
4. Demuestre el Teorema 2.5.3.
5. Proponga y demuestre el teorema de corrección del algoritmo de búsqueda bi-
naria iterativa en la Implementación 2.5.2. Demuestre que el algoritmo termina.
6. Especifique el problema de búsqueda binaria en un arreglo ordenado descen-
dentemente.
7. La siguiente lista de problemas tienen como entrada un arreglo de números
A[0..N ), N ≥ 0, y un número x.
a) Identificar el primer ı́ndice en el que aparece x en A[0..N ).
b) Identificar el último ı́ndice en el que aparece x en A[0..N ).
c) Determinar la cantidad de veces que aparece x en A[0..N ).
En cada uno de los problemas anteriores, la respuesta debe ser, e.g., −1 cuan-
do la búsqueda falla. Con base en esta información y para cada uno de los
problemas:
a) Diseñe un algoritmo que resuelva el problema en tiempo O(log N ).
b) Demuestre que el algoritmo es correcto.
2.5. Búsqueda binaria 73

c) Justifique por qué el algoritmo propuesto termina.


8. En un arreglo A[0..N ) de números, un número x es mayoritario si aparece, al
menos, N/2 veces. Suponiendo que A[0..N ) está ordenado ascendentemente:
a) Diseñe un algoritmo que determine si x es mayoritario en tiempo O(log N ).
b) Demuestre que el algoritmo es correcto.
c) Demuestre que el algoritmo termina.
d ) ¿Cómo puede resolver el problema si el arreglo no necesariamente está
ordenado?
9. Un punto fijo en un arreglo es un ı́ndice que almacena su mismo valor. Por
ejemplo, 3 es un punto fijo en [−2, 5, 1, 3, 0, 0].
a) Especifique el problema de encontrar un punto fijo en un arreglo A[0..N )
ordenado ascendentemente.
b) Diseñe un algoritmo que resuelva el problema dado en tiempo O(log N ).
c) Demuestre que el algoritmo es correcto y termina.
10. En el ámbito de la estadı́stica, la mediana representa el valor de la variable
de posición central en un conjunto de datos ordenados. Por ejemplo, en el
conjunto {7, 8, 9, 10, 11, 12} la mediana es 9,5. Dado un arreglo de números
ordenado ascendentemente, la mediana se puede calcular en tiempo O(1). Un
problema interesante, más bien, es determinar la mediana de un conjunto de
datos cuando estos están divididos en dos grupos de igual tamaño.

Entrada: Arreglos A[0..N ) y B[0..N ), N ≥ 0, de números y ordenados


ascendentemente.
Salida: Mediana del conjunto de datos representado por los elementos
de A[0..N ) y B[0..N ).

Diseñe y analice un algoritmo que en tiempo O(log N ) resuelva el problema


dado. Justifique su respuesta.
11. Un arreglo de números no repetidos es bitónico si está conformado por una
secuencia ascendente con al menos dos elementos seguida de una secuencia
descendente con al menos dos elementos, o si puede ser rotado circularmente
para que cumpla esta condición. Por ejemplo, [1, 2, 3, 0, −1] y [5, 4, 3, 2] son
bitónicos, mientras que [0, 2], [2, 4, 3, 5] ni [1, 2, 1] lo son. Suponiendo que los
números son distintos, diseñe y analice un algoritmo que determine si una
arreglo dado A[0..N ) es bitónico en tiempo O(log N ).
12. Considere un arreglo A[0..N ) con la propiedad especial de que A[0] ≥ A[1]
y A[N − 2] ≤ A[N ]. Se dice que un ı́ndice n de A[0..N ) es un mı́nimo local
cuando A[n − 1] ≥ A[n] ≤ A[n + 1] (necesariamente 0 < n < N − 1). Con
las condiciones dadas, el arreglo debe tener al menos un mı́nimo local. Por
ejemplo, en [7, 2, 2, 4, −1, 5, 6] los mı́nimos locales son 1, 2 y 4. Diseñe y analice
un algoritmo que en tiempo O(log N ) encuentre un mı́nimo local en A[0..N ).
74 2. Dividir, conquistar y combinar

13. Considere un arreglo A[0..N ) que ha sido rotado circularmente k pasos, con
1 ≤ k < N . Por ejemplo, el arreglo [3, 7, 10, 1, 2] está rotado 3 pasos.
a) Diseñe y analice un algoritmo que en tiempo O(log N ) calcule la cantidad
de pasos que ha sido rotado A[0..N ).
b) Diseñe y analice un algoritmo que en tiempo O(log N ) determine si un
número x está en A[0..N ).

Notas del capı́tulo y referencias

El planteamiento en este capı́tulo de dividir, conquistar y combinar es similar al


propuesto por T. Cormen et al. [CLRS09]. J. Kleinberg y É. Tardos [KET06], al
igual que en este texto, introducen la técnica con MergeSort, y diseñan algoritmos
para resolver problemas como los de encontrar pares de puntos más cercanos y
la transformada rápida de Fourier. Estas dos referencias son fuentes variadas de
ejercicios y problemas, sugeridas para profundizar en el tema.
Otros autores como H. Bhasin [Bha15] y A. Levitin [Lev12] distinguen di-
ferentes versiones de dividir y conquistar. Por ejemplo, incluyen decrementar y
conquistar, y transformar y conquistar. Por decrementar se entiende un caso de
dividir y conquistar en el cual cada subproblema resulta de reducir el problema ini-
cial en una cantidad constante y no en un factor constante (e.g., como el algoritmo
de ordenamiento en la Sección 2.2 en donde cada subproblema tiene una posición
menos que ordenar). Por transformar se entiende que las instancias iniciales son
pre-procesadas antes de aplicar dividir y conquistar. Por ejemplo, si se desea deter-
minar si un elemento en un arreglo es único, se puede ordenar el arreglo para luego
determinar la unicidad del elemento. Estos textos también son una buena fuente
de ejemplos y problemas algorı́tmicos.
El diseño de ciclos iterativos basado en invariantes es común en la literatura;
ver, e.g., [CLRS09]. La disciplina de plantear invariantes a modo de predicados
formales proviene de enfoques como los de E. Dijkstra [Dij76], D. Gries [Gri81],
E. Cohen [Coh90] y A. Kaldewaij [Kal90]. En Castellano, el uso de predica-
dos para la derivación de algoritmos iterativos y recurrentes es propuesto por J.
Bohórquez [Boh06].
La inmensa mayorı́a de problemas propuestos como ejercicios en esta sección
hacen parte del folclor de computación y algoritmos, y como tal se pueden encontrar
en varias fuentes como libros, artı́culos cientı́ficos y divulgativos, e internet. El
Ejercicio 2b de la Sección 2.1 es un resultado conocido como el Teorema de Gomory
y cuya demostración usa ciclos Hamiltonianos. Los ejercicios 11, 12 y 13 de la
Sección 2.5 están inspirados por sendos problemas en [Eri19].
Capı́tulo 3

Programación dinámica

La programación dinámica es una técnica de solución de problemas algorı́tmi-


cos y de programación. Dada una función recurrente (matemáticamente hablando)
que resuelve un problema algorı́tmico (e.g., decisión, conteo u optimización), la
programación dinámica permite diseñar un algoritmo para computar dicha función
evitando recalcular subinstancias iguales que aparecen en diferentes subproblemas.
Esta situación es común en soluciones que resultan al aplicar, e.g., dividir, conquis-
tar y combinar cuando en varias recurrencias resulta una misma instancia.
Al usar programación dinámica el objetivo es garantizar que no se repita el
cálculo de ningún subproblema. La apuesta que hace la programación dinámica
es entonces permitir que el cálculo de la función de interés use memoria a modo
de “registro”, de forma tal que los resultados intermedios (o los potencialmente
intermedios) de la función se escriban una vez allı́ y se puedan consultar tantas
veces como sea necesario.

Nota 3.0.1

Richard E. Bellman (1920–1984) fue un matemático aplicado que acuñó por


primera vez, circa 1950, el término programación dinámica. El siguiente es
un apartado traducido de su autobiografı́a (ver notas y referencias al final de
capı́tulo) en el cual relata la ‘curiosa’ historia detrás de la intención de este
nombre.
Una pregunta interesante es, “¿de dónde viene el nombre ‘programa-
ción dinámica’ ?” Los años 1950s no eran buenos para la investigación
en matemáticas. Tuvimos un personaje muy interesante en Washing-
ton, llamado Wilson. Él era el Secretario de Defensa, y realmente

75
76 3. Programación dinámica

tenı́a miedo y odio patológicos de la palabra ‘investigación’. No estoy


usando estos adjetivos ligeramente; los estoy usando precisamente. Su
cara se descomponı́a, se ponı́a roja, y se tornaba violento si alguien
usaba el término ‘investigación’ en su presencia. Se podrán imaginar
cómo se sentı́a, entonces, acerca del término ‘matemático’. La Corpo-
ración RAND estaba empleada por la Fuerza Aérea y la Fuerza Aérea
tenı́a, escencialmente, a Wilson como jefe. Por esto, sentı́ la necesi-
dad de hacer algo para proteger de Wilson y de la Fuerza Aérea el
hecho de que en realidad estaba haciendo matemáticas al interior de
la Corporación RAND. ¿Qué tı́tulo, qué nombre, podı́a escoger? En
primer lugar, estaba interesado en planeación (en inglés, planning),
en toma de decisiones, en pensar. Pero la palabra ‘planeación’ no era
una buena elección por varias razones. Por ello decidı́ usar el término
‘programación’. Querı́a lograr la idea de que esto era dinámico, con
múltiples escenarios, que dependı́a del tiempo; entonces se me ocurrió
matar dos pájaros con una sola piedra. Usemos una palabra con un
significado preciso y absoluto, es decir ‘dinámica’, en el sentido fı́sico
clásico. Esta palabra también tiene propiedades interesantes como ad-
jetivo dado que es imposible usarla en un sentido peyorativo. Trate de
pensar en una combinación que le haga tomar un significado peyora-
tivo. Es imposible. Luego, pensé que ‘programación dinámica’ era un
buen nombre. Era algo que ni siquiera un congresista podrı́a objetar.
Y ese fue el término que usé como sombrilla para mis actividades.

Este capı́tulo presenta las ideas principales que respaldan la programación


dinámica, incluyendo técnicas de implementación, una metodologı́a para su uso
y varios ejemplos.

3.1. Ideas clave

La programación dinámica es una técnica que puede ser útil para resolver pro-
blemas algorı́tmicos. Sin embargo, no en todos los problemas algorı́tmicos tiene sen-
tido usarla. Aquellos problemas que son susceptibles de abordar usando la técnica
exhiben ciertas propiedades en común que relacionan la solución de instancias con
subinstancias.

Nota 3.1.1

A continuación se enumeran dos propiedades de un problema algorı́tmico ne-


cesarias para resolverlo con programación dinámica:
3.1. Ideas clave 77

Propiedad de subestructura: debe ser fácil relacionar la solución del pro-


blema dado con las soluciones de subproblemas relacionados.
Propiedad de solapamiento: al plantear una solución recurrente, repetiti-
vamente resultan instancias del mismo subproblema.

Un ejemplo conocido de la propiedad de subestructura es el de ordenamiento


de un arreglo de números (Problema 2.2.1): ordenar un arreglo A[0..N ) se puede
relacionar fácilmente con ordenar el subarreglo A[1..N ). Sin embargo, este problema
no cuenta con la propiedad de solapamiento: e.g., ordenar A[0..n) y A[n..N ), con
0 < n < N , son subproblemas de ordenar A[0..N ) que no dependen el uno del otro.
Es importante aclarar que la noción de ‘subproblema’ debe estar asociada a una
medida concreta y bien definida como, e.g., el tamaño del arreglo o subarreglo a
ser ordenado; esto dependerá del problema mismo.
La propiedad de subestructura se puede especializar dependiendo de la natura-
leza del problema que se desea resolver (e.g., decisión, conteo u optimización). En
el caso de un problema de optimización, la propiedad de subestructura es denomi-
nada ‘propiedad de subestructura óptima’ (o ‘subestructura óptima’). El principio
de subestructura óptima indica que la soluciones óptimas de un problema incor-
poran soluciones óptimas de sus subproblemas relacionados. El término original se
puede especializar para problemas de decisión o conteo, resultando en principios
particulares.

Ejemplo 3.1.1

Considere el problema de determinar la ruta más corta de un punto A a un


punto B en una ciudad. Note que si una ruta óptima (porque puede haber
más de una) de A a B pasa por un punto C, entonces necesariamente en esa
ruta la subruta de A a C también debe ser óptima. De lo contrario, la ruta
de A a C no serı́a óptima: se podrı́a llegar a C “más rápido” de otra forma
y, en consecuencia, a B más rápido usando otra ruta. Pero esto riñe con la
suposición de que la ruta de A a B que pasa por C es óptima; i.e., no puede
haber una ruta mejor. Para este problema, ¿pueden existir solapamientos entre
subproblemas?

A simple vista pareciera que la propiedad de subestructura es innocua por


su sencillez y obviedad; sin embargo, no todos los problemas cuentan con esta
propiedad.

Ejemplo 3.1.2
78 3. Programación dinámica

Considere el problema de determinar la ruta simple más larga de un punto


A a un punto B en una ciudad. Simple se refiere a que no se visita un sitio
más de una vez (i.e., no hay ciclos, porque de lo contrario el problema estarı́a
indefinido). En este problema, el principio de optimalidad no aplica. Considere
la siguiente instancia en donde hay 4 sitios en la ciudad llamados A, B, C y
D, relacionados de la siguiente manera:
1 C 1

A 1 B
1
D 1

El camino simple más largo de A a B cuesta/toma/demora 3 unidades. Si el


principio de optimalidad fuera cierto en este problema, el tamaño del camino
más largo de A a B se podrı́a obtener de los caminos más largos de A a puntos
intermedios y de estos puntos intermedios a B. Pero esto falla, por ejemplo,
cuando se toma D como punto “pivote” pues los caminos simples más largos
no se pueden componer para obtener un camino simple más largo: el camino
simple más largo de A a C es A → D → B → C y el camino simple más largo
de C a B es C → A → D → B. Sin embargo, la composición de estos dos
caminos
A→D→B→C→A→D→B
no es simple.
3.2. Cálculo de los números de Fibonacci 79

Ejercicios

1. Justificando su respuesta, responda la pregunta formulada al final del Ejem-


plo 3.1.1.
2. Considere el problema de determinar si dada una colección de números A[0..N )
y un número x es posible escoger algunos elementos de A cuya suma es x. Deter-
mine si este problema exhibe las propiedades de subestructura y solapamiento.
Justifique e ilustre su respuesta con un ejemplo.
3. Justifique por qué el problema de buscar un valor en un arreglo dado no cumple
las propiedades de subestructura y solapamiento.
4. Suponga que se cuenta con el registro de precios de una acción a lo largo de
N dı́as, un precio por dı́a. Se desea determinar la longitud de la mejor “corri-
da” de la acción durante los N dı́as: es decir, la mayor cantidad de dı́as (no
necesariamente consecutivos) en los cuales la acción aumentó de precio. ¿Exhi-
be este problema alguna de las propiedades de subestructura o solapamiento?
Justifique e ilustre su respuesta con un ejemplo.
5. Un empresario desea hacer una gira por varias ciudades conduciendo su vehı́culo
(debe iniciar y terminar en la ciudad de origen, visitando cada una de las demás
ciudades exactamente una vez). Para cada trayecto cuenta con la cantidad de
dinero que cuesta el desplazamiento. Suponiendo que existe un camino directo
entre cualquier par de ciudades, indique si determinar el menor costo posible
de la gira exhibe alguna de las propiedades de subestructura o solapamiento.
Justifique e ilustre su respuesta con un ejemplo.

3.2. Cálculo de los números de Fibonacci

Se deja de lado, por un momento, el proceso de plantear una función recurrente


que resuelva un problema con las propiedades del subproblema y solapamiento. El
objetivo de esta sección es presentar las dos principales formas, memorización y
tabulación, de instrumentar la programación dinámica. Para ilustrarlas, se parte de
una recurrencia bien conocida: la función que calcula los números de Fibonacci.

Definición 3.2.1
80 3. Programación dinámica

Para n ∈ N, la definición de la función fib es la siguiente:



0 , si n = 0,


fib(n) = 1 , si n = 1,


fib(n − 2) + fib(n − 1) , si n ≥ 2.

El problema que se desea resolver es el siguiente.

Problema 3.2.1: Cálculo de la función de Fibonacci

Entrada: Un número entero N , N ≥ 0.


Salida: fib(N ).

Calcular fib es en el fondo un problema de conteo. Hay dos casos bases: para
N = 0 o N = 1, se tiene que fib(N ) = N . El caso inductivo se plantea para
cualquier otro valor de N (recuerde que es un número natural) recurriendo sobre
los dos valores inmediatamente anteriores (por eso son necesarios los dos casos
base). Note que el Problema 3.2.1 cuenta con las propiedades de subestructura y
solapamiento. Por un lado, el cálculo recurrente de la función depende de sı́ misma,
con valores más pequeños (en el orden usual de los números). Por otro lado, dos
llamados recurrentes distintos dependen de un mismo suproblema. Por ejemplo, en
el cálculo de fib(4) se recalcula fib(2):

fib(4)

fib(2) fib(3)

fib(0) fib(1) fib(1) fib(2)

0 1 1 fib(0) fib(1)

0 1

Una implementación directa de esta función en el lenguaje de programación


Python 3 se presenta a continuación.
3.2. Cálculo de los números de Fibonacci 81

Implementación 3.2.1: Cálculo de la función fib

1 def fib(n):
2 ans = None
3 if n<=1: ans = n
4 else: ans = fib(n-2)+fib(n-1)
5 return ans

La función fib se usa a continuación con algunos ejemplos:


1 print('fib(0):', fib(0))
2 print('fib(1):', fib(1))
3 print('fib(2):', fib(2))
4 print('fib(10):', fib(10))
5 print('fib(15):', fib(15))
El resultado de esta ejecución es el siguiente:
1 fib(0): 0
2 fib(1): 1
3 fib(2): 1
4 fib(10): 55
5 fib(15): 610
Si la implementación de la función fib es tan sencilla y básicamente resulta de
transcribir la definición matemática como código, ¿por qué hay que preocuparse
por usar programación dinámica? Para responder esa pregunta directamente (pero
no necesariamente rápidamente) vale la pena tratar de calcular fib(100). Posible-
mente en un par de dı́as aún no habrá terminado el cálculo, pero se entenderá por
qué es necesario usar programación dinámica: los llamados recurrentes se solapan
y, en consecuencia, muchos llamados de fib se repiten; hay valores que se calcu-
lanuna y otra vez. En realidad, la complejidad temporal de la función fib(n) es
√ n
1+ 5
O 2 , lo cual resulta de resolver la ecuación de diferencia X 2 − X − 1 = 0
con variable X (los detalles de una demostración que soporte esta afirmación se
proponen como ejercicio para el lector). Es decir, la complejidad temporal de fib
es exponencial, lo cual no es una buena noticia si el objetivo es contar con un
algoritmo eficiente para calcular fib.
La forma en que resulta útil la programación dinámica es precisamente alma-
cenando el resultado de los cálculos, ahorrando tiempo de cómputo cuando sea
necesario visitar una y otra vez dichas instancias en cada uno de los subproblemas.
82 3. Programación dinámica

Ejemplo 3.2.1

Hoy dos formas usualmente empleadas para diseñar un algoritmo con pro-
gramación dinámica y que permiten implementar eficientemente la función
recurrente asociada a su solución:
Memorización: se calcula la función objetivo por demanda de modo tal
que los valores intermedios, resultado de los llamados recurrentes, se van
calculando en la medida que sea necesario. Generalmente, resultan algo-
ritmos recurrentes muy parecidos a la función objetivo. Hay una memoria
compartida que se usa: para una instancia especı́fica del problema se con-
sulta si ha sido resuelta antes (i.e., el valor está en la memoria compar-
tida), entonces se usa el valor registrado en la memoria; de lo contrario,
se calcula el valor correspondiente a dicha instancia, ya bien sea directa
o recurrentemente, y se almacena en la memoria compartida para que
pueda ser usado posteriormente de ser necesario.
Tabulación: se calcula la función objetivo exhaustivamente considerando
todos los casos posibles para implementar la función dada con base en
los parámetros de interés. La memoria compartida se va “llenando” incre-
mentalmente con todos estos casos hasta obtener el valor deseado. Gene-
ralmente, resultan algoritmos iterativos.

En lo que resta de la sección se ilustra cómo usar memorización y tabulación


para obtener una implementación eficiente de la función fib.

3.2.1. Memorización. La programación dinámica con memorización es cono-


cida en inglés como programación dinámica top-down. Básicamente, se extiende la
función a implementar con una memoria compartida que puede ser leı́da y modi-
ficada en los llamados recurrentes. Esto, especı́ficamente, puede hacerse con una
variable global o aumentando la lista de parámetros de la función con una variable
que es pasada por referencia. Una de las ventajas de las implementaciones con me-
morización es que la estructura del código resultante es básicamente la misma que
la de una transcripción de la función objetivo.
La idea general es la siguiente:

Si el valor que se desea calcular está en la memoria compartida (i.e., ha sido


calculado previamente), este valor se usa.
De lo contrario, se calcula directa o recurrentemente el valor deseado y se
almacena en la memoria compartida. En las invocaciones recurrentes para los
subproblemas también se hace uso de la memoria compartida de modo tal que
los cálculos intermedios queden almacenados en ella.
3.2. Cálculo de los números de Fibonacci 83

La implementación de fib con memorización se presenta en el Algoritmo 3.2.2


con la función fib_memo; un diccionario representa la memoria compartida y es
identificado como mem.

Implementación 3.2.2: Cálculo de fib con memorización

1 def fib_memo(n, mem):


2 ans = None
3 if n in mem: ans = mem[n] # if the value is available, use it!
4 else: # otherwise, it needs to be computed
5 if n<=1: ans = n
6 else: ans = fib_memo(n-2, mem)+fib_memo(n-1, mem)
7 mem[n] = ans # store the value in the shared memory
8 return ans

Cuando el valor que se quiere calcular está disponible en mem, el algoritmo


lo retorna. De lo contrario, el valor se calcula (incluyendo los casos base) y, una
vez se cuenta con este valor, se almacena antes de retornarlo. Note que los llamos
recurrentes también hacen uso de la memoria compartida: dado que mem se pasa
por referencia, todos los cálculos que se hagan en los llamados recurrentes quedan
almacenados en mem.
Es importante observar que la función fib_meo se comporta correctamente siem-
pre y cuando se respeten algunas condiciones sobre sus parámetros. En particular,
se debe garantizar que mem no contiene “basura” en el sentido de que los valo-
res almacenados allı́ son correctos. De lo contrario, no habrı́a garantı́a acerca de
los cálculos realizados ni del valor retornado. Por ello, es importante contar con
condiciones de representación que apoyen el diseño del algoritmo.
C0 : (∀k | k ∈ N : k ∈ mem ⇒ mem[k] = fib(k)).
C1 : n ≥ 0.
La condición C0 indica que si k es una de las llaves del diccionario, necesariamente
dicha llave está asociada al valor fib(k); es decir, lo que está almacenado en el
diccionario (suponiendo que solamente hay números naturales como llaves) son
valores correctos de Fibonacci. La condición C1 es auxiliar e indinca que el valor
de n siempre es un número natural.

Teorema 3.2.1

Sea n ∈ N y mem un diccionario. Si n y mem satisfacen las condiciones C0 y C1 ,


entonces el llamado fib_memo(n, mem) es tal que:
84 3. Programación dinámica

1. al terminar su ejecución C0 y C1 son ciertos; y


2. calcula fib(n).

Demostración

Se procede por análisis de casos sobre fib_memo (usando inducción):


Caso n ∈ mem: no se modifica mem, por lo cual C0 es cierto dada la supo-
sición. Como C0 se supone y n ∈ mem, entonces mem(n) = fib(n). Luego,
fib_memo(n, mem) calcula fib(n).
Caso n ∈ / mem: si n = 0 o n = 1, entonces ans tiene el valor de fib(n). Si
n ≥ 2, por las hipótesis inductivas se tiene que ans tiene el valor fib(n). Por
la misma razón se puede suponer que la variable mem satisface la condición
C0 después de los dos llamados recurrentes. Luego, aumentar mem con
mem[n] = ans garantiza que C0 sigue siendo cierto una vez termine la
ejecución de fib_memo(n, mem).
Se propone como ejercicio al lector demostrar que la condición C1 es cierta
bajo las suposiciones del enunciado del teorema.

Resta por hacer explı́cito cómo fib_memo resuelve el problema planteado ini-
cialmente.

Teorema 3.2.2

El llamado fib_memo(N, {}), en donde {} representa el diccionario vacı́o,


calcula fib(N ).

Demostración

La demostración es un corolario del Teorema 3.2.1 y se propone como ejercicio


al lector.

A continuación se muestra el resultado de ejecución de fib_memo, incluyendo


el cálculo para n = 100, y el contenido de la memoria compartida después de este
llamado. Note que todos los valores hasta 100 han sido calculados y almacenados en
la memoria compartida. Una ventaja práctica de la memorización es que la memoria
compartida puede ser calculada incrementalmente: por ejemplo, hacer el llamado
para calular fib_memo(15, mem) y posteriormente hacer el llamado para calcular
3.2. Cálculo de los números de Fibonacci 85

fib_memo(100, mem) de tal forma que en el segundo llamado se cuente con algunos
valores previamente (y correctamente) calculados en mem.

1 print('fib(0):', fib_memo(0, dict()))


2 print('fib(1):', fib_memo(1, dict()))
3 print('fib(2):', fib_memo(2, dict()))
4 print('fib(10):', fib_memo(10, dict()))
5 print('fib(15):', fib_memo(15, dict()))
6

7 mem = dict()
8 print('fib(100):', fib_memo(100, mem))
9 print(mem)

El resultado de esta ejecución es el siguiente:

1 fib(0): 0
2 fib(1): 1
3 fib(2): 1
4 fib(10): 55
5 fib(15): 610
6 fib(100): 354224848179261915075
7 {0: 0, 1: 1, 2: 1, 3: 2, 4: 3, 5: 5, 6: 8, 7: 13, 8: 21, 9: 34, 10: 55,
8 11: 89, 12: 144, 13: 233, 14: 377, 15: 610, 16: 987, 17: 1597,
9 18: 2584, 19: 4181, 20: 6765, 21: 10946, 22: 17711, 23: 28657,
10 24: 46368, 25: 75025, 26: 121393, 27: 196418, 28: 317811, 29: 514229,
11 30: 832040, 31: 1346269, 32: 2178309, 33: 3524578, 34: 5702887,
12 35: 9227465, 36: 14930352, 37: 24157817, 38: 39088169, 39: 63245986,
13 40: 102334155, 41: 165580141, 42: 267914296, 43: 433494437,
14 44: 701408733, 45: 1134903170, 46: 1836311903, 47: 2971215073,
15 48: 4807526976, 49: 7778742049, 50: 12586269025, 51: 20365011074,
16 52: 32951280099, 53: 53316291173, 54: 86267571272, 55: 139583862445,
17 56: 225851433717, 57: 365435296162, 58: 591286729879, 59: 956722026041,
18 60: 1548008755920, 61: 2504730781961, 62: 4052739537881,
19 63: 6557470319842, 64: 10610209857723, 65: 17167680177565,
20 66: 27777890035288, 67: 44945570212853, 68: 72723460248141,
21 69: 117669030460994, 70: 190392490709135, 71: 308061521170129,
22 72: 498454011879264, 73: 806515533049393, 74: 1304969544928657,
23 75: 2111485077978050, 76: 3416454622906707, 77: 5527939700884757,
24 78: 8944394323791464, 79: 14472334024676221, 80: 23416728348467685,
25 81: 37889062373143906, 82: 61305790721611591, 83: 99194853094755497,
26 84: 160500643816367088, 85: 259695496911122585, 86: 420196140727489673,
27 87: 679891637638612258, 88: 1100087778366101931,
28 89: 1779979416004714189, 90: 2880067194370816120, 91: 4660046610375530309,
86 3. Programación dinámica

29 92: 7540113804746346429, 93: 12200160415121876738,


30 94: 19740274219868223167, 95: 31940434634990099905,
31 96: 51680708854858323072, 97: 83621143489848422977,
32 98: 135301852344706746049, 99: 218922995834555169026,
33 100: 354224848179261915075}

Finalmente, se establece el orden de la complejidad temporal de fib_memo.

Teorema 3.2.3

Sea n ∈ N. Si el acceso y modificación de mem con mem[n] toma tiempo


O(ψ(n)), entonces la complejidad temporal del llamado fib_memo(n, mem)
toma tiempo O(nψ(n)).

Demostración

Se propone como ejercicio al lector.

Si el acceso y modificación de diccionarios es de orden constante, entonces


la complejidad de fib_memo es de orden lineal en n. Es importante resaltar que
se está suponiendo algo que en la práctica no es necesariamente cierto: operar
números grandes es de orden constante. En realidad, como la función fib crece
rápidamente, es fácil encontrar valores de n no muy grandes para los cuales fib(n)
sobrepasa la cota de 263 para números enteros en un procesador de 64 bits. En
el caso particular de fib_memo, Python “esconde” del usuario esta complicación
dado que sus enteros básicos son de precisión arbitraria. Este no necesariamente
es el caso en otros lenguajes de programación, y deben usarse librerı́as espciales o
implementar las operaciones de enteros grandes partiendo de cero. Con cualquiera
de estas opciones, las operaciones aritméticas dejan de ser de orden constante en el
peor de los casos.

3.2.2. Tabulación. Al igual que la memorización, la tabulación es una alter-


nativa para diseñar algoritmos con programación dinámica. Esta técnica es conocida
en inglés como bottom-up. Aunque sirven el mismo propósito, los algoritmos con
tabulación son intrı́nsecamente distintos a los que resultan con memorización. Pri-
mero, los algoritmos con tabulación son iterativos por naturaleza. Segundo, con
memorización los valores de la función objetivo se calculan por demanda, mien-
tras que con tabulación se pueden calcular más valores intermedios de los que
son necesarios para calcular la función para el objetivo final. Tercero, en algunos
casos las tabulaciones pueden ser optimizadas para ahorrar espacio. Esta última
3.2. Cálculo de los números de Fibonacci 87

caracterı́stica hace que la tabulación sea preferida, en muchos situaciones, sobre la


memorización.
A modo de ejemplo, se diseñará un algoritmo por tabulación que permita cal-
cular la función fib. Esto se hará con la ayuda de una variable que almacene el
cálculo parcial de los valores de la función hasta obtener el valor deseado.
P0 : (∀i | 0 ≤ i < n : tab[i] = fib(i)).
P1 : 0 ≤ n ≤ N + 1.
La variable de almacenamiento es el arreglo tab[0..N + 1) que permite guardar
valores de fib. En particular, tab[0..n) almacena los primeros n valores de fib. La
variable n indica qué valores se han calculado; note que cuando n = N + 1, el valor
deseado está en tab[N ]. Visualmente, estos invariantes se pueden representar como
se muestra en el siguiente diagrama de necesidades:
tab[i] = fib(i) objetivo

tab 0 1 ?
0 n-2 n-1 n N

calculado pendiente por calcular

En una tabulación, el diagrama de necesidades indica cómo se inicializa la


memoria adicional (en este caso tab[0] = 0 y tab[1] = 1) y cómo los valores que
se calculan recurrentemente (i.e., fib[n] para n ≥ 2) se pueden determinar a partir
de valores previamente calculados en la memoria (en este caso, fib[n − 2] y fib[n −
1]). Note que de acuerdo con el invariante P0 , los valores de fib están calculados
correctamente en tab[0..n), y n varı́a entre 0 y N + 1, inclusive.
Con base en estos invariantes y en el diagrama de necesidades, se propone la
función fib_iter en el Algoritmo 3.2.3 para calcular iterativamente el valor de la
función fib.

Implementación 3.2.3: Cálculo de fib con tabulación

1 def fib_iter(N):
2 ans = None
3 if N<=1: ans = N
4 else:
5 tab,n = [ None for _ in range(N+1) ],2
6 tab[0],tab[1] = 0,1
7 # P0 ∧ P1
88 3. Programación dinámica

8 while n!=N+1:
9 tab[n],n = tab[n-2]+tab[n-1],n+1
10 ans = tab[N]
11 return ans

La función fib_iter determina el valor de retorno distinguiendo dos casos. Si


N ≤ 1, que corresponde a los casos base, el valor se calcula directamente. De lo con-
trario, se crea la memoria auxiliar en tab[0..N] inicializada con valores indefinidos;
excepto por tab[0] y tab[1] que son 0 y 1, respectivamente, dado que fib(0) = 0 y
fib(1) = 1. Para cualquier valor a partir de 2, el valor tab[n] se calcula sumando los
dos valores inmediatamente anteriores en tab: por P0 , estos valores coinciden con
fib(n − 2) y fib(n − 1), luego se esperarı́a que tab[n] corresponda a fib(n) cuando
se actualice la tabulación.
Para demostrar que el algoritmo iterativo de tabulación fib_iter funciona, es
necesario demostrar que el ciclo satisface los invariantes P0 y P1 .

Teorema 3.2.4

El ciclo en fib_iter satisface los invariantes P0 y P1 . Es decir,


1. P0 y P1 son ciertos antes de la (posible) primera iteración del ciclo.
2. P0 y P1 son ciertos antes de una iteración del ciclo, entonces son ciertos
después de una iteración del ciclo.

Demostración

Las propiedades se demostrarán para P0 ; las demostraciones para P1 se pro-


ponen como ejercicios para el lector.
1. Note que antes de la primera iteración del ciclo, se tiene n = 2, tab[0] = 0
y tab[1] = 1. Por su parte, P0 indica que en tab[0..n) están almacenados
los primeros n valores de fib. Luego, P0 es cierto antes de la primera
iteración del ciclo.
2. Suponga que P0 es cierto; se desea demostrar que P0 es cierto después
de una iteración del ciclo (es decir, se supone que P0 es cierto para n y
se demuestra que es cierto para n + 1). Note que al suponer que P0 es
cierto antes de una iteración del ciclo, se está suponiendo que tab[0..n)
tiene los primeros n valores de fib. En el cuerpo del ciclo se modifican
tab[n] y n con los valores tab[n − 2] + tabn − 1 y n + 1, respectivamente.
Por la suposición, se tiene que tab[n] = fib(n − 2) + fib(n − 1): es decir,
3.2. Cálculo de los números de Fibonacci 89

tab[n] = fib(n). Como n incrementa en una unidad, es cierto que:


(∀i | 0 ≤ i < n + 1 : tab[i] = fib(i)).

Con base en el Teorema 3.2.4 es fácil demostrar que la función fib_iter es


correcta con respecto a la especificación del Problema 3.2.1.

Teorema 3.2.5

El llamado fib_iter(N) calcula fib(N ).

Demostración

Recuerde que N ∈ N, como se indica en la formulación del problema. La


demostración de terminación del algoritmo se propone como ejercicio al lector.
Si N = 0 o N = 1, entonces el algoritmo responde 0 o 1, respectivamente.
De lo contrario (i.e., N ≥ 2), suponga que la ejecución del ciclo termina para
cualquier N ≥ 2. Note que esto sucede cuando n = N + 1. En este caso, el
invariante P0 es equivalente a:
(∀i | 0 ≤ i < N + 1 : tab[i] = fib(i)).
En particular, tab[N ] = fib(N ). Es decir, en el caso N ≥ 2, fib_iter(N)
responde con el valor de fib(N ).
En cualquiera de los casos fib_iter(N) calcula el valor de fib(N ).

Los cálculos de las complejidades temporal y espacial de fib_iter se proponen


como ejercicios al lector (¿cuáles son estas complejidades?).
Hay una versión iterativa para calcular fib con tabulación que toma tiempo
O(N ) y espacio O(1). Diseñar este algoritmo se propone como ejercicio al lector
(note que en el caso recurrente solo depende de dos valores).

Ejercicios

1. Al estudiar la función fib, se afirmó que su complejidad temporal es


√ !n !
1+ 5
O ,
2

en donde n es valor que se desea calcular. También se indicó que esta expresión
resulta de resolver la ecuación de diferencia X 2 − X − 1 = 0 con variable X.
90 3. Programación dinámica

a) Investigue sobre ecuaciones de recurrencia y explique de dónde resulta la


ecuación dada.
b) Investigue sobre técnicas para resolver ecuaciones de recurrencia y aplique
una n para demostrar que la complejidad temporal de fib(n) es
de √ellas
1+ 5
O 2 .
2. Complete la demostración del Teorema 3.2.1 con el análisis de la condición C1 .
3. Demuestre el Teorema 3.2.2.
4. Demuestre el Teorema 3.2.3.
5. En el Teorema 3.2.2 se concluye que el llamado fib_memo(N, {}), en donde
{} representa el diccionario vacı́o, calcula fib(N ). Proponga (justificando su
respuesta) otra forma de invocar fib_memo para que calcule fib(N ).
6. Demuestre que el ciclo en fib_iter satisface el invariante P1 .
7. Demuestre que fib_iter termina.
8. Para N ∈ N, demuestre:
a) La complejidad temporal de fib_iter(N) es O(N ).
b) La complejidad espacial de fib_iter(N) es O(N ).
9. Diseñe un algoritmo iterativo que use la técnica de programación dinámica
con tabulación que permita calcular la función fib en tiempo O(N ) y espacio
O(1). Formule invariantes y demuestre que el algoritmo es correcto. ¿Cuál es
el diagrama de necesidades?
10. Un coeficiente binomial es un número entero positivo que corresponde a la
expansión polinomial de la potencia de un binomio. Para n, k ∈ N,
 
n
k

es el coeficiente del término xk en la expansión de (1 + x)n . El valor numérico


de la expresión nk , leı́da “de n se toman k”, corresponde a la cantidad de


subconjuntos de k elementos de un conjunto de n elementos (si k > n, este


valor es 0). Los coeficientes binomiales satisfacen la siguiente recurrencia:
     
n n−1 n−1
= + .
k k k−1

Con base en esta definición recurrente:


a) Explique por qué el problema de calcular coeficientes binomiales requiere
programación dinámica.
b) Diseñe un algoritmo recurrente que calcule nk usando memorización.


c) Diseñe un algoritmo iterativo que calcule nk usando tabulación.



3.3. Una metodologı́a 91

3.3. Una metodologı́a

Se propone una metodologı́a para aplicar la técnica de programación dinámica


a partir de la especificación de un problema. Esta sección detalla los pasos de la
metodologı́a que, en las secciones posteriores, se usa para resolver cada uno de los
problemas formulados a modo de casos de estudio.

Nota 3.3.1

Dada la especificación de un problema que cuenta con las propiedades de sub-


estructura y solapamiento, se propone la siguiente metodologı́a para diseñar
un algoritmo con programación dinámica que resuelva el problema.
Función objetivo: se formula una función objetivo que generalice la salida
del problema dado o que, en su defecto, permita resolver el problema dado.
Es importante que dicha función, en este punto, cuente con una descrip-
ción clara mas no con una definición formal. Comúnmente esta función
se obtiene al generalizar los parámetros del problema que hacen parte
de la salida de la especificación; puede involucrar uno o más parámetros,
dependiendo del caso.
Reformulación de la especificación: se debe validar que la especifica-
ción del problema se puede reformular con ayuda, directa o indirecta, de
la función objetivo. Debe ser posible reescribir la salida del problema en
términos de la función que generaliza el problema. Si esto no es posible,
será necesario reformular la función planteada en el paso anterior.
Planteamiento recurrente: se presenta una definición recurrente de la
función objetivo. Esta definición será la base de la programación dinámica
y, como tal, debe ser completa, cubriendo todas las instancias del proble-
ma, y no ser ambigüa.
¿Memorización o tabulación?: partiendo de la definición recurrente de
la función objetivo, se decide si se usa memorización o tabulación. Depen-
diendo de la elección:
Si se prefiere memorización, es clave determinar qué estructura de da-
tos se usará para representar la memoria compartida (e.g., en fib_memo
fue un diccionario). Se debe tener cuidado con la profundidad de la
recurrencia; en algunos casos, puede generar errores de ejecución si
hay llamados recurrentes muy profundos.
Si se prefiere tabulación, es deseable diseñar un diagrama de necesi-
dades que muestre cómo se da la recurrencia en términos de depen-
dencias con el objetivo de determinar cómo llevar a cabo el cálculo de
la tabulación.
92 3. Programación dinámica

Reducción del espacio (opcional): si se escogió tabular, explorar la po-


sibilidad de reducir el espacio de la memoria de tabulación.
Implementación: implementar la solución diseñada.

Estos pasos se ilustrarán con cada uno de los ejemplos que se presentan en el
resto del capı́tulo.

Ejercicios

1. El problema de multiplicación de una secuencia de matrices consiste en mini-


mizar la cantidad de operaciones básicas (sumas y multiplicaciones escalares,
básicamente) para multiplicar N ≥ 0 matrices. Este algoritmo es uno de los
ejemplos clásicos de programación dinámica.
a) Investigue sobre este problema y formule su especificación.
b) Investigue sobre su solución con programación dinámica y, paso a paso,
identifique el uso en ella de la metodologı́a propuesta en esta sección.
Indique claramente en qué consiste cada uno de los pasos y, en caso tal de
que alguno de los pasos no esté presente, justifique esta situación.
2. El problema del corte de un lingote de oro (en inglés conocido como rod cutting)
consiste en maximizar la ganancia que se puede obtener de cortar verticalmente
el lingote en varios pedazos (para venderlos individualmente).

Entrada: La longitud N ≥ 0 de un lingote y una tabla T [0..N ] especi-


ficando la ganancia a obtener por cada tamaño vendido.
Salida: Máxima ganancia que se puede obtener vendiendo el lingote
de tamaño N , posiblemente en varios pedazos, en relación con
la tabla T [0..N ].

Considere la siguiente función objetivo, para 0 ≤ n ≤ N :

φ(n) : “Máxima ganancia que se puede obtener vendiendo el lingote


de tamaño n, posiblemente en varios pedazos, en relación con
la tabla T [0..N ]”.

Como definición de φ, se propone la siguiente función recurrente para 0 ≤ n ≤


N:
(
0 , si n = 0,
φ(n) =
(↑ i | 1 ≤ i ≤ n : T [i] + φ(n − i)) , si n 6= 0.

El caso base indica que no hay ganancia alguna cuando no hay nada que vender.
El caso inductivo indica que se escoge aquella opción que resulta de cortar el
3.4. Suma máxima de un subarreglo 93

lingote en un pedazo de tamaño i y lo mejor que se pueda hacer con el tamaño


restante n − i (¿por qué esto cubre todos los casos?).
Con base en este planteamiento, siga la metodologı́a propuesta en esta
sección para diseñar:
a) Un ejemplo que muestre cómo φ tiene las propiedades de subestructura
óptima y solapamiento.
b) Un algoritmo con memorización para φ.
c) Un algoritmo con tabulación para φ (¿se puede hacer reducción de la
tabulación?)
En cada caso debe resultar código en Python ejecutable.

3.4. Suma máxima de un subarreglo

El problema del subarreglo de suma máxima consiste en calcular la máxima


suma posible de un subarreglo en un arreglo de números dado.

Problema 3.4.1: Subarreglo de suma máxima

Entrada: Un arreglo A[0..N ), con N ≥ 0, de números.


Salida: Suma máxima de un subarreglo de A[0..N ).

Este ha sido un problema estudiado exhaustivamente desde su formulación


en 1977 por Ulf Grenander (inicialmente se planteó para una matriz y no para
un arreglo unidimensional). En el procesamiento de imágenes, por ejemplo, este
problema tiene aplicación en la identificación de áreas brillantes en mapas de bits.
También tiene aplicaciones en biologı́a computacional y en finanzas.

Ejemplo 3.4.1

Considere el arreglo A = [−1, 3, −2, 1, 4, −2, 1, 0]. El subarreglo de suma máxi-


ma es A[1..5) = [3, −2, 1, 4] con suma 6.

Este problema se puede resolver trivialmente para algunas instancias. Por ejem-
plo, si el arreglo dado únicamente tiene números no negativos, la respuesta es la
suma de sus elementos. También, si todos sus elementos son números negativos,
la respuesta es 0, pues el arreglo vacı́o es subarreglo de cualquier arreglo y tiene
suma 0. El caso general se puede resolver exhaustiva pero ineficientemente, como
se presenta en el Algoritmo 3.4.1.
94 3. Programación dinámica

Algoritmo 3.4.1

1. Para cada pareja (i, j) que satisface 0 ≤ i ≤ j ≤ N , determinar la suma


de A[i..j).
2. Retornar aquella suma que sea máxima.

La complejidad temporal del Algoritmo 3.4.1 es O(N 3 ) pues calcular todas las
parejas (i, j) toma tiempo O(N 2 ) y para cada una de ellas la suma de A[i..j) toma
tiempo O(N ). Esta complejidad se puede reducir a O(N 2 ) con un pre-proceso que
toma tiempo y espacio O(N ). En particular, se pueden calcular las sumas de los
prefijos (o sufijos) de A[0..N ) de tal forma que la suma de cada subarreglo A[i..j)
se obtiene en tiempo O(1), reduciendo de O(N 3 ) a O(N 2 ) el tiempo que toma la
solución global.
Aplicando la técnica dividir, conquistar y combinar se puede obtener una solu-
ción más eficiente (o menos ineficiente).

Algoritmo 3.4.2

Suponga que low y hi son tales que 0 ≤ low ≤ hi ≤ N . El objetivo de este


algoritmo es calcular la suma máxima de un subarreglo en A[low..hi).
1. Si low = hi, entonces retornar 0.
2. Si low + 1 = hi, entonces retornar A[low].
3. De lo contrario:
a) sea mid un punto medio entre low y hi,
b) recurrir sobre A[low..mid) para determinar la máxima suma de un
subarreglo de A[low..mid),
c) recurrir sobre A[mid..hi) para determinar la máxima suma de un sub-
arreglo de A[mid..hi),
d ) determinar la suma máxima de un subarreglo de A[low..hi) que incluye
el valor en mid (i.e., que cruza de izquierda a derecha), y
e) retornar el máximo valor obtenido en los pasos (3b),(3c),(3d ).
Si se incluye el subarreglo vacı́o como opción, basta con maximizar el resultado
del los pasos (2) y (3) con 0.

Suponiendo que el Algoritmo 3.4.2 es correcto (es un ejercicio para el lector),


su llamado con low = 0 y hi = N resuelve el problema del subarreglo de suma
máxima en A[0..N ). Además, si el paso (3d ) se calcula en tiempo lineal en función
de hi − low, con peor caso O(N ), entonces la cantidad de operaciones básicas que el
algoritmo realiza para resolver el problema está dado por la función simple T (N )
3.4. Suma máxima de un subarreglo 95

definida en su caso recurrente por

T (N ) = 2T (N/2) + O(N ).

Es decir, bajo la suposición hecha, el Algoritmo 3.4.2 toma tiempo O(N log N ) en
resolver el problema de suma máxima de un subarreglo.
Si se cuenta con un algoritmo relativamente eficiente que resuelve el problema
sin usar programación dinámica, ¿qué hace este problema en este capı́tulo? Pues
bien, lo que sucede es que se puede diseñar una solución que toma tiempo O(N ) si
se usa programación dinámica. Ese es el objetivo de lo que resta de esta sección:
derivar dicha solución.

Nota 3.4.1

Se rumora que hacia 1984, en un seminario en Carnegie Melon (una univer-


sidad en EE.UU., prestigiosa en computación) se presentó la versión unidi-
mensional del problema del subarreglo de suma máxima. En ese seminario
estaba Jay Kadane quien, en un par de minutos, diseñó una solución lineal
para el problema. Se cree que esta es, desde el punto de vista asintótico, la
mejor solución posible. Por ello, la solución lineal al problema del subarreglo
de suma máxima recibe el nombre de Algoritmo de Kadane (es el diseño al
cual se llega al final de esta sección).

El caso de la suma máxima de un subarreglo en un arreglo vacı́o es trivial, como


se ha explicado anteriormente. Igualmente, considerar el arreglo vacı́o como uno de
los posibles subarreglos (aún si el arreglo dado no es vacı́o) resulta en restringir la
salida de la solución a números no negativos. Los casos interesantes se presentan, en
realidad, al fijar la atención en aquellos subarreglos que tienen al menos un elemento.
El desarrollo que se presenta a continuación se enfoca, entonces, en determinar la
suma máxima de un subarreglo no vacı́o, dando por descontado el caso en el cual
A[0..N ) tiene tamaño N = 0. Posteriormente, se presentará la solución para el caso
más general en el cual el arreglo puede ser vacı́o.
Un arreglo de suma máxima (no vacı́o) termina en alguno de los ı́ndices de
A[0..N ). Es decir, para algún ı́ndice 0 ≤ n < N , un subarreglo de suma máxima
debe terminar en n (i.e., incluyendo a A[n] como último elemento de la suma de
elementos contı́guos). Si se contara con la información de las mejores sumas que
terminan en cada uno de los ı́ndices 0 ≤ n < N , entonces el problema de determinar
la mejor suma de un subarreglo (no vacı́o) en A[0..N ) estarı́a resuelto: bastarı́a con
maximizar sobre todos estos valores. Esta es la estrategia de diseño que se adopta
para resolver el problema en cuestión. Se sigue la metodologı́a propuesta en la
Nota 3.3.1 para diseñar una solución de programación dinámica con tabulación.
96 3. Programación dinámica

Función objetivo. Para 0 ≤ n < N , se define:


φ(n) : “suma máxima de un subarreglo de A[0..N ) que termina en n.”
La función φ tiene como dominio los ı́ndices de A y co-dominio a todos los números.
Una forma alternativa (y equivalente) de interpretar φ es la siguiente: φ(n) es la
suma máxima de un sufijo de A[0..n).
Reformulación de la especificación. Note que, con base en φ, se puede
especificar la salida del problema (restringido) como la siguiente maximatoria:
(↑ n | 0 ≤ n < N : φ(n)).
Si por convención se adopta que esta maximatoria es 0 cuando N = 0, entonces
esta expresión también abarca el caso en el cual el arreglo dado es vacı́o. Es más, si
también se desea considerar el subarreglo vacı́o como opción entre los subarreglos
de A, basta con extender la maximatoria de la siguiente manera (¿por qué?):
(↑ n | 0 ≤ n < N : φ(n)) ↑ 0.
En cualquier caso, es evidente que la función objetivo permite reformular el proble-
ma de interés.
Planteamiento recurrente. El objetivo es obtener una definición recurrente
para φ. Siguiendo los principios de dividir, conquistar y combinar, el primer paso
es identificar aquellos casos que se pueden resolver directamente sin necesidad de
recurrir (i.e., conquistar). Dada la defición de la función objetivo, ese caso corres-
ponde a φ(0): ¿cuál es la suma máxima de un subarreglo de A[0..N ) que termina en
0? Solo A[0..0] satisface esta condición. Es decir, φ(0) = A[0]. ¿Qué sucede cuando
n 6= 0? Para responder esta pregunta, suponga que A[k..n] es un arreglo de suma
máxima que inicia en algún 0 ≤ k ≤ n y termina en n. Es decir, el objetivo es que
φ(n) tenga como valor la suma de los elementos de este subarreglo. Si k = n, enton-
ces esta suma máxima es A[n]. Si k 6= n (i.e., k < n), entonces esta suma máxima
tiene la forma A[k] + · · · + A[n − 1] + A[n] o, de manera equivalente, φ(n − 1) + A[n]
(¿por qué? Ayuda: pensar en la propiedad de la subestructura óptima).
Con base en el análisis anterior, se propone la siguiente definición para φ, con
0 ≤ n < N y N 6= 0:
(
A[0] , si n = 0,
φ(n) =
A[n] ↑ (A[n] + φ(n − 1)) , si n 6= 0.

El planteamiento recurrente se hace con base en observaciones que, aunque


intuitivas y “obvias”, deben ser respaldadas por un razonamiento formal.

Teorema 3.4.1
3.4. Suma máxima de un subarreglo 97

Sea 0 ≤ n < N . Si N 6= 0, entonces φ(n) es la suma máxima de un subarreglo


de A[0..N ) que termina en n.

Demostración

Se procede por inducción sobre n:


Caso base n = 0: note que solo un subarreglo de A[0..N ) termina en 0 y
tiene suma A[0]. Luego, φ(0) = A[0] es la definición correcta en este caso.
Caso inductivo n 6= 0: sea A[k..n] de suma máxima (inicia en k y termina
en n). Se distinguen dos subcasos:
• Caso k = n: entonces A[n] es de suma máxima entre todos los subarre-
glos que terminan en n. En particular, A[n] no es menor que la suma
del arreglo que resulta de extender el de suma máxima que termina
en n − 1 con A[n], i.e.,
A[n] ≥ φ(n − 1) + A[n].
Note que esto es cierto si y solo si φ(n − 1) ≤ 0. Entonces se tiene:
φ(n) = A[n] (A[0] es de suma máxima)
= A[n] + 0 (aritmética)
= A[n] + (0 ↑ φ(n − 1)) (φ(n − 1) ≤ 0)
= (A[n] + 0) ↑ (A[n] + φ(n − 1)) (distr. de suma sobre máximo)
= A[n] ↑ (A[n] + φ(n − 1)) (arimética).
• Caso k 6= n: entonces k < n y la suma de los elementos de A[k..n] no
es menor que A[n] (i.e., A[k] + · · · + A[n] ≥ A[n]). Como se observó
anteriormente:
A[k] + · · · + A[n − 1] + A[n] = φ(n − 1) + A[n].
Luego, φ(n − 1) ≥ 0. Con base en estas observaciones, se tiene:
φ(n) = A[k] + · · · + A[n − 1] + A[n] (A[k..n] es de suma máxima)
= φ(n − 1) + A[n] (observación anterior)
= (φ(n − 1) ↑ 0) + A[n] (φ(n − 1) ≥ 0)
= (A[n] + φ(n − 1)) ↑ (A[n] + 0) (distr. de suma sobre máximo)
= A[n] ↑ (A[n] + φ(n − 1)) (arimética).
En cualquiera de los dos casos, φ(n) = A[n] ↑ (A[n] + φ(n − 1)).
En conclusión, φ(n) es la suma máxima de un subarreglo de A[0..N ) que
termina en n.
98 3. Programación dinámica

Con base en el planteamiento recurrente de la función objetivo, es fácil ver con


ejemplos que existen solapamientos entre diferentes instancias de la solución. Se
propone al lector, como ejercicio, encontrar instancias para las cuales esto pasa y
explicar en qué consisten los solapamientos con esos casos en particular.
¿Memorización o tabulación? Se prefiere, dado que uno de los objetivos del
diseño de la solución es ahorrar espacio, implementar la técnica de programación
dinámica para la función objetivo con tabulación.
De acuerdo con la definición recurrente de φ, se propone el siguiente diagrama
de necesidades.

tab A[0] ?
0 n N

En el diagrama se usa la memoria de tabulación tab[0..N ) para almacenar los valores


de la función y una variable n para indicar el ı́ndice de la tabla que se debe calcular
en un momento dado. Esta tabla en la posición 0 es inicializada con A[0], como
lo indica la definición de φ. Para calcular el valor para n ≥ 1 (i.e., en el caso
recurrente) en la tabla se depende directamente del valor inmediatamente anterior
(esto sucede porque φ(n) depende de φ(n − 1)). Esto quiere decir que el valor en
tab[n − 1] debe calcularse antes de intentar calcular tab[n]. Estas observaciones son
importantes para diseñar el algoritmo que llena la tabla con los valores de φ.
El diseño del algoritmo iterativo para llenar la memoria de tabulación se basa
en los siguientes invariantes:

P0 : (∀i | 0 ≤ i < n : tab[n] = φ(n)).


P1 : 0 ≤ n ≤ N.

El invariante P0 indica que el subarreglo tab[0..n) tiene, en orden, los primeros


n valores de φ: el valor en la i-ésima posición de tab almacena el valor φ(i). El
segundo invariante indica en qué rango toma valores la variable n. Note que se
pudo restringir a 1 como lı́mite inferior para n, pero no es necesario. Con estos
invariantes la estrategia está clara: llenar tab de izquierda a derecha. Con esta tabla
completamente llena es posible, entonces, resolver el problema de determinar la
suma máxima de los subarreglos de A[0..N ) al calcular el valor máximo en tab. Esto
último se hará con una segunda pasada sobre tab, usando el siguiente invariante
adicional.

P2 : ans = (↑ i | 0 ≤ i ≤ n : φ(i)).

Con base en este diseño, se propone el Algoritmo 3.4.3.


3.4. Suma máxima de un subarreglo 99

Algoritmo 3.4.3

1 def mss(A):
2 ans,N = 0,len(A)
3 if N!=0:
4 tab = [ None for _ in range(N) ]
5 tab[0],ans = A[0],max(ans, A[0])
6 # P0 ∧ P1
7 for n in range(1, N):
8 tab[n] = max(A[n], tab[n-1]+A[n])
9 # P0 ∧ P1 ∧ P2
10 for n in range(N):
11 ans = max(ans, tab[n])
12 return ans

El caso en el cual el arreglo dado es vacı́o, se trata de manera especial con ayuda
de una instrucción condicional. Es necesario ahora demostrar que mss es correcta
con respecto a la especificación dada, y determinar sus complejidades temporal y
espacial.

Teorema 3.4.2

El llamado mss(A):
1. Calcula la suma máxima de un subarreglo de A[0..N ).
2. Toma tiempo O(N ).
3. Toma espacio O(N ).

Demostración

Se propone como ejercicio al lector.

Reducción del espacio. En el diagrama de necesidades se puede observar


que para el cálculo de tab[n] solo es necesario contar con el valor precalculado
en tab[n − 1]. Es decir, de manera más general, para los casos recurrentes solo es
necesario contar con un valor pre-calculado y no con un prefijo completo de tab.
Con base en esta observación, se propone el invariante P3 que introduce la variable
entera prev para reemplazar el arreglo tab:

P3 : prev = φ(n − 1).


100 3. Programación dinámica

La variable prev representa la mejor suma de A[0..N ) que termina en la posición


inmediatamente anterior a n. Por ello, la idea es usar este valor en el cuerpo del
ciclo iterativo e ignorar todos los valores para ı́ndices inferiores a n − 1. Además,
es necesario incluir el cálculo del valor máximo de φ dentro del cuerpo del ciclo
principal puesto que serı́a imposible recuperarlo de otra forma habiendo reducido
el espacio de la tabulación (no hay arreglo que recorrer para calcular el máximo).
Esta estrategia resulta en la función mss_opt que se presenta en el Algoritmo 3.4.4.

Algoritmo 3.4.4

1 def mss_opt(A):
2 ans,N = 0,len(A)
3 if N!=0:
4 prev,ans = A[0],max(ans, A[0])
5 # P1 ∧ P2 ∧ P3
6 for n in range(1, N):
7 prev = max(A[n], prev+A[n])
8 ans = max(ans, prev)
9 return ans

Si se demuestra correcto, el algoritmo optimizado resolverı́a el problema de


interés en tiempo O(N ) y en espacio O(1).

Teorema 3.4.3

El llamado mss_opt(A):
1. Calcula la suma máxima de un subarreglo de A[0..N ).
2. Toma tiempo O(N ).
3. Toma espacio O(1).

Demostración

Se propone como ejercicio al lector.

Finalmente, el Algoritmo de Kadane se puede obtener del Algoritmo 3.4.4 bajo


ciertos supuestos o acuerdos. La observación clave es notar que las asignaciones a
las varibles prev y ans en las lı́neas 4 y 7-8 son básicamente las mismas: prev se
actualiza con el siguiente “mejor” y ans lleva registro del mejor encontrado entre los
primeros n valores de la función. Si se supone que el máximo de una colección vacı́a
3.4. Suma máxima de un subarreglo 101

de números es 0 (en este problema tiene todo el sentido pues se opera sobre números
naturales y el 0 es el elemento neutro del máximo sobre este conjunto), entonces
no hay ningún problema si se decide iniciar las iteraciones del ciclo con n = 0. La
transformación del Algoritmo 3.4.4 bajo las observaciones anteriores resulta en el
Algoritmo de Kadane, el cual se presenta en el Algoritmo 3.4.5.

Algoritmo 3.4.5: Algoritmo de Kadane

1 def kadane(A):
2 N,curr,ans = len(A),0,0
3 for n in range(N):
4 curr = max(A[n], curr+A[n])
5 ans = max(ans, curr)
6 return ans

Al igual que mss_opt, la función kadane resuelve el problema de suma máxima


de un subarreglo en un arreglo usando tiempo lineal y espacio constante. El diseño
de los invariantes para el ciclo en kadane y su corrección se proponen como ejercicio
para el lector.

Ejercicios

1. Diseñe una función mss_bf en Python que implemente el diseño del Algorit-
mo 3.4.1. Corra la implementación con arreglos de números generados aleato-
riamente de tamaños 10, 50, 100 y 250. ¿Cuál es la diferencia en tiempo entre
las ejecuciones?

2. Después de presentar el Algoritmo 3.4.1, se propone una mejora para disminuir


su complejidad de O(N 3 ) a O(N 2 ). Diseñe una función mss_bf_opt en Python
que implemente este diseño optimizado. Corra la implementación con arreglos
de números generados aleatoriamente de tamaños 10, 100, 1000 y 5000. ¿Cuál
es la diferencia en tiempo entre las ejecuciones?

3. Diseñe una función mss_dcc en Python que implemente el diseño del Algorit-
mo 3.4.2. Corra la implementación con arreglos de números generados aleato-
riamente de tamaños 10, 100, 1000, 10000 y 100000. ¿Cuál es la diferencia en
tiempo entre las ejecuciones?

4. En la reformulación de la especificación con base en φ, se afirma que la expresión

(↑ n | 0 ≤ n < N : φ(n)) ↑ 0.
102 3. Programación dinámica

permite considerar el subarreglo vacı́o como opción entre los subarreglos de


A. Proponga un ejemplo de un arreglo A[0..N ) en el cual el resultado de la
maximatoria sobre n sea negativo.
5. En el planteamiento recurrente de φ, se supone que A[k..n] es un arreglo de
suma máxima que inicia en algún 0 ≤ k ≤ n y termina en n. En el caso k < n
se indica que la suma A[k] + · · · + A[n − 1] + A[n] es igual a φ(n − 1) + A[n].
Ilustre esta afirmación con un ejemplo y elabore una justificación de por qué la
igualdad es cierta (no es necesaria una demostración).
6. Elabore un ejemplo en el cual se exhiba la propiedad de solapamiento para φ.
Explique brevemente su respuesta.
7. Diseñe un algoritmo con memorización para la función φ, incluyendo los pre-
dicados correspondientes a las condiciones que debe satisfacer el algoritmo.
Demuestre que el algoritmo es correcto con respecto a la especificación dada, y
estime las complejidades temporal y espacial.
8. Demuestre el Teorema 3.4.2.
9. Demuestre el Teorema 3.4.3.
10. Proponga invariantes para el Algoritmo de Kadane (Algoritmo 3.4.5) y demues-
tre que es correcto. También determine las complejidades temporal y espacial
del algoritmo.
11. Modifique el Algoritmo de Kadane (Algoritmo 3.4.5) para que no se tenga en
cuenta el arreglo vacı́o entre los subarreglos de A al calcular la suma máxima.
Explique brevemente por qué funciona el algoritmo propuesto.
12. Modifique cualquiera de los algoritmos propuestos en esta sección que toman
tiempo lineal para que, además de calcular la suma máxima de un subarreglo
de A[0..N ), retorne k, n tales que el subarreglo A[k..n) es de suma máxima
(la complejidad del algoritmo resultante también debe ser lineal en función del
tamaño del arreglo dado).

3.5. El problema del morral

El problema del morral es un problema de optimización combinatoria.

Nota 3.5.1

El nombre del problema del morral (en inglés, knapsack o KS ) se deriva de


una analogı́a en la cual se desea escoger de una colección de elementos para
cargar en un morral que tiene una restricción de peso (o volumen). El objetivo
3.5. El problema del morral 103

es escoger dicha combinación de elementos maximizando su valor total sin


exceder la capacidad del morral.
Este es un problema que surge naturalmente en situaciones de asignación
de recursos en las cuales quienes toman las decisiones deben escoger proyectos
o tareas indivisibles bajo una restricción presupuestal o de tiempo.

De forma general, dada una colección de elementos, cada uno con un peso y
un valor asociados, el problema consiste en determinar cuál es la suma máxima de
valores que se puede obtener al tomar algunos de los elementos sin que su suma
exceda una restricción de peso global.

Problema 3.5.1: Problema del morral


Especificación del problema

Entrada: Arreglos V [0..N ) y W [0..N ), con N ≥ 0, de valores y pesos


(números no negativos), y un número X ≥ 0. x es valor de restriccion
Salida: Suma máxima de elementos con valores en V [0..N ) cuyos
pesos en W [0..N ) no exceden X.

La condición de salida del problema supone que el valor y el peso del i-ésimo
elemento están dados por V [i] y W [i], respectivamente. La naturaleza combinatoria
del problema del morral se revela al notar que se requiere una exploración sobre
los subconjuntos de ı́ndices de A. Para cualquier S ⊆ {0, . . . , N − 1}, se definen las
funciones value y weight:
value(S) = (+i | i ∈ S : V [i]) y weight(S) = (+i | i ∈ S : W [i]).
La expresión value(S) representa el valor total de los elementos indexados por S,
mientras que weight(S) el peso total de los elementos indexados por S. Ası́, la salida
del problema del morarral corresponde a la expresión:
(↑ S | S ⊆ {0, . . . , N − 1} ∧ weight(S) ≤ X : value(S)).
Como hay 2N subconjuntos S de ı́ndices de A[0..N ) (¿por qué?), se dice que el pro-
blema del morral es de naturaleza combinatoria, pues debe considerar todos aquellos
subconjuntos S para determinar la mejor combinación posible bajo la restricción
dada. Debe ser claro, entonces, que un algoritmo de fuerza bruta para resolver el
problema del morral tomará tiempo exponencial en la cantidad de elementos entre
los cuales se elige.

Ejemplo 3.5.1
104 3. Programación dinámica

Considere 4 elementos con valores y pesos dados por, respectivamente:


V = [4, 6, 5, 1] y W = [3, 5, 5, 2].
Con X = 15, lo mejor opción es llevar todos los elementos, obteniendo
como valor total 16 y peso total 15.
Con X = 11, el valor máximo es 11. Esto resulta de dos configuraciones
cuyo peso total es 10: llevar los elementos 0, 1, 3 o los elementos 1, 2.
Con X = 5, la mejor opción es llevar el elemento 1, con valor total 6 y
peso total 5.
Con X = 1, la mejor (y única) opción es no echar nada al morral.

El objetivo es diseñar un algoritmo de programación dinámica que resuelva


el problema del morral, pues exhibe las propiedades de subestructura óptima y
solapamiento (¿por qué?). Para ello se seguirá la metodologı́a presentada en la
Sección 3.3.
Función objetivo. Hay dos variables que permiten generalizar la salida del
problema: N y X. Por una parte, la cantidad de elementos disponibles se puede
hacer variar para considerar diferentes prefijos de A[0..N ) (también se pueden hacer
variar los sufijos, resultando en un plateamiento similar). Por otra parte, el peso
disponible en el morral cambiarı́a cada vez que se decide escoger un elemento. Con
base en estas observaciones, se propone la siguiente función objetivo para 0 ≤ n ≤ N
y 0 ≤ x ≤ X:
Funcion objetivo
φ(n, x) : “suma máxima de elementos con valores en V [0..n) cuyos pesos
en W [0..n) no exceden x.”

Reformulación de la especificación. El objetivo del problema es calcular


φ(N, X).
Planteamiento recurrente. Se escogerá n como variable “pivote”. Esto quie-
re decir que para razonar por casos, estos primero se estructuran con base en con-
diciones sobre n y, de ser necesario, luego se dividien en subcasos con base en
condiciones sobre la segunda variable x. Es importante tener en cuenta que φ(n, x)
debe ser definida para valores de n y x que satisfacen 0 ≤ n ≤ N y 0 ≤ x ≤ X.
Se procede por casos sobre n y x:

No hay elementos para escoger (i.e., n = 0). Luego, sin importar la capacidad
x del morral, lo máximo (y único) que se puede obtener es 0.
Hay al menos un elemento para escoger (i.e., n > 0). Entonces están las opciones
de que el elemento n−1 (este es el n-ésimo elemento) pese más que la capacidad
x del morral o que no:
3.5. El problema del morral 105

• si W [n − 1] > x, no hay más opción que ignorar ese elemento, y escoger lo


mejor posible con los elementos que restan, sin modificar la capacidad del
morral; es decir, se recurre con n − 1 y x.
• si W [n − 1] ≤ x, entonces estan las opciones de adicionar el elemento al
morral o no. Como el objetivo es tomar la mejor decisión, se apuesta a los
dos caballos. No escogerlo resulta en la misma situación del caso anterior
(i.e., se recurre con n − 1 y x). Escogerlo tiene el efecto de disminuir la
capacidad del morral a x − W [n − 1] y de aumentar el beneficio en V [n − 1]
unidades; es decir, se recurre con n−1 y x−W [n−1], y se acumula V [n−1]
beneficio.
En cada uno de los casos recurrentes se respeta las condiciones 0 ≤ n ≤ N y
0 ≤ x ≤ X (¿por qué?), lo cual es clave para contar con una buena definición de φ.
La naturaleza exponencial de la búsqueda de “la” mejor elección de elementos se
ve en el segundo caso recurrente: allı́ se apuesta a llevar y a no llevar el elemento,
es decir, se consideran todos los subconjuntos de ı́ndices pendientes sin acumular
el n-ésimo elemento y todos los subconjuntos de ı́ndices pendientes acumulando el
n-ésimo elemento.
La definición formal de φ se presenta para 0 ≤ n ≤ N y 0 ≤ x ≤ X:



 0 , si n = 0,

φ(n − 1, x)

, si n 6= 0 ∧ W [n − 1] > x,
φ(n, x) =


 φ(n − 1, x) ↑


 (φ(n − 1, x − W [n − 1]) + V [n − 1]) , si n 6= 0 ∧ W [n − 1] ≤ x.

Es necesario demostrar que la definición de φ permite resolver el problema del


morral.

Teorema 3.5.1

Si 0 ≤ n ≤ N y 0 ≤ x ≤ X, entonces φ(n, x) es la suma máxima de elementos


con valores en V [0..n) cuyos pesos en W [0..n) no exceden x.

Demostración

Sea S ⊆ {0, . . . , n − 1} una solución óptima para el problema del morral con
los primeros n elementos y capacidad x. Se procede por casos sobre S.
Si S = ∅, entonces no hay elementos para escoger (i.e., n = 0) o todos los
elementos disponibles tienen un peso mayor a x (i.e., W [i] > x para 0 ≤ i < n).
En cualquiera de los dos casos φ(n, x) = 0, lo cual coincide con el hecho de
que S = ∅ (¿por qué?).
106 3. Programación dinámica

Si S 6= ∅, suponga que S 0 ⊆ {0, . . . , n − 1} es escogencia “hecha” por


φ(n, x) (i.e., φ(n, x) = value(S 0 )). Hacia una contradicción suponga que
value(S) > value(S 0 ).
Sin pérdida de generalidad también suponga que k (con 0 ≤ k < n) es el
máximo ı́ndice de A[0..n) en el cual S y S 0 no coinciden, y x0 (con 0 ≤ x0 ≤ x)
es la capacidad del morral cuando φ decide si el elemento k se incluye o no.
Por la forma en que se escogió k, se cumple
value(S \ {k + 1, . . . , n − 1}) > value(S 0 \ {k + 1, . . . , n − 1}).
Entonces, para simplificar la escritura de la demostración y sin pérdida de
generalidad, basta con suponer que k = n − 1 y x0 = x. En particular,
S = S \ {k + 1, . . . , n − 1} y S 0 = S 0 \ {k + 1, . . . , n − 1}.

Se consideran los siguientes casos:


si (n − 1) ∈ S 0 , entonces (n − 1) ∈
/Sy
φ(n − 1, x) ≤ φ(n − 1, x − W [n − 1]) + V [n − 1].
Luego
value(S 0 ) = φ(n, x) = φ(n − 1, x − W [n − 1]) + V [n − 1].
Por la hipótesis inductiva, φ(n − 1, x) es la suma máxima de elementos
con valores en V [0..n − 1) cuyos pesos en W [0..n − 1) no exceden x. En
particular,
value(S) ≤ φ(n − 1, x)
dado que φ(n − 1, x) es máximo entre todas las opciones al considerar los
primeros n − 1 elementos y con capacidad x. Observe que los elementos de
S necesariamente están entre los primeros n − 1 elementos, en este caso.
Entonces se tiene:
value(S) ≤ φ(n − 1, x) (optimalidad de φ)
≤ φ(n − 1, x − W [n − 1]) + V [n − 1] ((n − 1) ∈ S 0 )
= φ(n, x) (suposición)
0
= value(S ) (definición de S 0 )
< value(S) (suposición inicial).

Esto es una contradicción.


/ S 0 , entonces (n − 1) ∈ S y
si (n − 1) ∈
φ(n − 1, x) ≥ φ(n − 1, x − W [n − 1]) + V [n − 1].
3.5. El problema del morral 107

Luego,
value(S 0 ) = φ(n, x) = φ(n − 1, x).
Como (n − 1) ∈ S, note que
value(S) = value(S \ {n − 1}) + V [n − 1].
Por la misma observación del caso anterior, la optimalidad de φ(n − 1, x −
W [n − 1]) implica que
value(S \ {n − 1}) ≤ φ(n − 1, x − W [n − 1]).
Entonces se tiene:
value(S) = value(S \ {n − 1}) + V [n − 1] ((n − 1) ∈ S)
≤ φ(n − 1, x − W [n − 1]) + V [n − 1] (optimalidad de φ)
≤ φ(n − 1, x) (suposición)
= φ(n, x) (suposición)
0
= value(S ) (definición de S 0 )
< value(S) (suposición inicial).

Esto es una contradicción.


En cualquiera de los dos casos se llega a una contradicción. Luego, value(S) ≤
value(S 0 ). Como S es óptimo (i.e., de suma máxima bajo la restricción de
capacidad), se concluye que value(S) = value(S 0 ). Es decir, φ(n, x) calcula el
valor óptimo.

La demostración de la correctitud de φ puede parecer difı́cil. Sin embargo, la


estrategia es y será la misma una y otra vez: garantizar que los casos base son
correctos y, posteriormente, demostrar que los casos recurrentes también lo son con
base en la hipótesis inductiva. En la demostración del Teorema 3.5.1 se usa el hecho
de que una solución óptima existe y se concluye que la definición recurrente de la
función objetivo permite calcular ese valor óptimo (no necesariamente con la misma
configuración, pues puede haber más de una solución óptima). Note que en este caso
se usó una estrategia similar a la usada en la demostración del Teorema 3.4.1.
¿Memorización o tabulación? Se diseñará una tabulación para implementar
eficientemente φ con base en el siguiente diagrama de necesidades.
108 3. Programación dinámica

objetivo
tab[0..N][0..X]

X 0
.
.
.
.

x 0 ?
. .
. .
. .
.
.

0 0

0 n N

La tabulación se implementa con una matriz tab[0..N ][0..X] con N + 1 columnas y


X + 1 filas. El objetivo es calcular el valor correspodiente a tab[N ][X] dado que el
problema se resuelve con φ(N, X). Cuando n = 0, sin importar el valor de x, el valor
φ(n, x) es 0. Por ello, la primera columna de la matriz tiene únicamente el valor 0.
Para calcular φ(n, x), en el primer caso recurrente se depende de φ(n − 1, x) y en el
segundo caso recurrente además de φ(n − 1, x − W [n − 1]). Por ello, en la matriz se
identifican dos celdas de la columna n − 1: cada una corresponde a cada uno de los
llamados recurrentes, para los dos posibles valores de la capacidad restante en el
morral. Estas observaciones indican que se puede llenar la tabulación por columnas,
comenzando por la columna n = 1 (esta no es la única forma de hacerlo, ¿cierto?).
Esta decisión se ve reflejada en los siguientes invariantes:

P0 : (∀i, j | 0 ≤ i < n ∧ 0 ≤ j ≤ X : tab[i][j] = φ(i, j)).


P1 : (∀j | 0 ≤ j < x : tab[n][j] = φ(n, j)).
P2 : 0 ≤ n ≤ N + 1.
P3 : 0 ≤ x ≤ X + 1.

El invariante P0 indica que todas las columnas a la izquierda de la columna n han


sido llenadas con los valores correspondientes de φ. El invariante P1 especifica que
los valores en la columna n, por debajo de la fila x, corresponden a los valores de φ.
Los invariantes P2 y P3 son de apoyo: el primero indica que los valores de n varı́an
entre 0 y N + 1, inclusive; el segundo, que los valores de x varı́an entre 0 y X + 1,
inclusive. Los dos invariantes de apoyo sugieren una forma de llenar la tabla: por
3.5. El problema del morral 109

columnas, de izquierda a derecha, hasta llenar la columna N . Visualmente, esta


decisión se puede incluir en el diagrama de necesidades.

objetivo
tab[0..N][0..X]

X 0
.
.
.
.

x 0 ?
. .
. .
. .
.
.

0 0

0 n N

Para implementar el algoritmo de tabulación, basta con seguir los invariantes y tener
clara la imagen mental de ellos en el diagrama de necesidades. La idea es entonces,
una vez creada la tabla e inicializada su primera columna, iniciar a llenarla desde
la columna n = 1. Esto se hará como con las máquinas de escribir: se llena una
columna, se procesa la siguiente. Intencionalmente se permite que la variable x
llegue a X + 1, aunque esta fila no haga parte de la tabla: con x = X + 1 se sabe
que se ha llenado la columna actual y se procede a iniciar la siguiente. Algo similar
sucede con n: se habrá llenado la tabla por completo cuando se tenga n = N + 1.

Algoritmo 3.5.1

1 def ks_tab(V, W, X):


2 N = len(V)
3 tab = [ [ None for _ in range(X+1) ] for _ in range(N+1) ]
4 for x in range(X+1): tab[0][x] = 0
5 n,x = 1,0
6 # P0 ∧ P1 ∧ P2 ∧ P3
7 while n!=N+1:
8 if x==X+1: n,x = n+1,0
9 else:
10 if x<W[n-1]: tab[n][x] = tab[n-1][x]
110 3. Programación dinámica

11 else: tab[n][x] = max(tab[n-1][x], tab[n-1][x-W[n-1]]+V[n-1])


12 x += 1
13 return tab[N][X]

La demostración de la corrección del algoritmo es rutina con base en los inva-


riantes dados y la especificación del problema. La complejidad espacial está domi-
nada por el tamaño de la tabla, y la temporal por su creación y actualización. Antes
de introducir formalmente estos resultados, se presenta un ejemplo de la ejecución
del algoritmo.

1 >>> V = [4, 6, 5, 1]
2 >>> W = [3, 5, 5, 2]
3 >>> print(ks_tab(V, W, 15))
4 16
5 >>> print(ks_tab(V, W, 11))
6 11
7 >>> print(ks_tab(V, W, 5))
8 6
9 >>> print(ks_tab(V, W, 1))
10 0

Teorema 3.5.2

El llamado ks_tab(V, W, X), con len(V ) = len(W ) = N :


1. Calcula la suma máxima de elementos con valores en V [0..N ) cuyos pesos
en W [0..N ) no exceden X.
2. Toma tiempo O(N X).
3. Usa espacio O(N X).

Demostración

Se propone como ejercicio al lector.

La complejidad temporal del algoritmo propuesto es polinomial en función de N


y X. Sin embargo, es importante tener en cuenta que la cota X no es polinomial en
función de N ; a esto se le llama tiempo pseudo-polinomial. Por ejemplo, si X = 232 ,
basta con 32 bits para representar X. Sin embargo, el algoritmo propuesto usa 232
filas para construir y llenar la tabla. Por ello, es usual indicar que la complejidad
temporal (y espacial) de la función ks_tab es O(N · 2bits en X ).
3.5. El problema del morral 111

3.5.1. Primera optimización. El diagrama de necesidades sugiere que el di-


seño de la tabulación se puede optimizar. En realidad, el cálculo de la columna
n ≥ 1 solo depende de valores en la columna n − 1. Además, al iniciar con la co-
lumna n = 1, la columna n = 0 está completamente inicializada. Esta observación
permite hacer una optimización importante en el espacio usado por la tabulación.
El siguiente diagrama de necesidades reduce la tabulación a dos columnas: una
para el presente (que corresponde a la columna n) y otra para el pasado inmedia-
tamente anterior (que corresponde a la columna n − 1).

x ?

prev curr

La noción de “presente” se representa con el arreglo curr y la de “pasado inmediato”


con prev. Un reto surge ante la pregunta de qué hacer cuando curr haya sido llenado.
Note que en el caso en el cual exista la necesidad de seguir procesando la tabulación
(i.e., n 6= N + 1), este arreglo pasará a ser el pasado inmediato y no habrá uso para
los valores almacenados en prev. Por ello, y para evitar la creación de memoria
adicional y copias innecesarias, se puede decidir reutilizar prev para el rol de nuevo
presente. Para facilitar esta decisión en el código final, se prefiere que la tabulación
sea una matriz de dos columnas, cada una de ellas de tamaño X + 1, y usar dos
variables (abusando la notación, prev y curr) que alternen opuestamente con los
112 3. Programación dinámica

valores 0 y 1: cuando prev sea 0, necesariamente curr es 1, y viceversa.

Q0 : (∀j | 0 ≤ j ≤ X : tab[prev][j] = φ(n − 1, j)).


Q1 : (∀j | 0 ≤ j < x : tab[curr][j] = φ(n, j)).
Q2 : 0 ≤ prev ≤ 1.
Q3 : 0 ≤ curr ≤ 1.
Q4 : prev + curr = 1.
P2 : 0 ≤ n ≤ N + 1.
P3 : 0 ≤ x ≤ X + 1.

La matriz para la tabulación es tab[0..2][0..X]. El invariante Q0 indica que tab[prev]


contiene todos los valores del pasado inmediatamente anterior, mientras que el
arreglo tab[curr][0..x) los del presente (aún faltan por procesar valores del presente
en tab[curr][x..X]), de acuerdo con Q1 . Los invariantes Q2 y Q3 especifican que los
valores de prev y curr solo pueden ser 0 o 1, y el invariante Q4 que los valores de
estas variables no debe coincidir. Los invariantes P2 y P3 corresponden a los de la
especificación del algoritmo sin optimizar. Con base en este diseño se presenta la
función ks_tab_opt1 en el Algoritmo 3.5.2.

Algoritmo 3.5.2

1 def ks_tab_opt1(V, W, X):


2 N = len(V)
3 tab = [ [ 0 for _ in range(X+1) ] for _ in range(2) ]
4 n,x,prev,curr = 1,0,0,1
5 # Q0 ∧ Q1 ∧ Q2 ∧ Q3 ∧ Q4 ∧ P2 ∧ P3
6 while n!=N+1:
7 if x==X+1: n,x,prev,curr = n+1,0,1-prev,1-curr
8 else:
9 tab[curr][x] = tab[prev][x]
10 if W[n-1]<=x:
11 tab[curr][x] = max(tab[curr][x], tab[prev][x-W[n-1]]+V[n-1])
12 x += 1
13 return tab[prev][X]

Además de los cambios derivados de la nueva forma de hacer la tabulación, la


función ks_tab_opt1 incluye una actualización en la inicialización de la tabla: en
el momento de su creación, todos los valores en ella son 0. Esto evita tener que
procesar la primera columna después de su creación (es decir, se está ahorrando
una pasada por una columna completa). Inicialmente, prev es asignada el ı́ndice
0 y curr el ı́ndice 1. Es decir, el pasado está registrado en la columna 0 de tab y
3.5. El problema del morral 113

el presente en la columna 1. El cuerpo del ciclo tiene dos partes: una de cambio
de columna (lı́nea 7) y otra de cambio de fila (lı́neas 8-12). El cambio de columna
se hace de manera similar al algoritmo sin optimización, incrementando n en una
unidad y asignando 0 a x. Las variables prev y curr invierten sus valores (¿por
qué la resta funciona?). Se pudo optar por intercambiar directamente los valores de
estas dos variables apelando a la sustitución “simulatánea” ofrecida por Python.
Sin embargo, esto no es posible en lenguajes de programación imperativos como
C, C++ o Java. Por esto, se prefiere hacer el “complemento” con 1, dado que esta
operación aritmética se puede implementar con una asignación individual sin la
necesidad de una variable intermedia/temporal. El cuerpo del ciclo se encarga de
encontrar el mejor valor posible para φ(n, x) de acuerdo con su definición recurrente.
Observe el valor que se retorna está al final de la columna prev y no en la columna
curr (¿por qué?).
Se ilustra el uso de la función ks_tab_opt1 con algunas instancias del problema:
1 >>> V = [4, 6, 5, 1]
2 >>> W = [3, 5, 5, 2]
3 >>> print(ks_tab_opt1(V, W, 15))
4 16
5 >>> print(ks_tab_opt1(V, W, 11))
6 11
7 >>> print(ks_tab_opt1(V, W, 5))
8 6
9 >>> print(ks_tab_opt1(V, W, 1))
10 0
El diseño de la tabulación para ks_tab_opt1 permite reducir el espacio de la
tabulación a O(2X) = O(X), mientras que el tiempo de ejecución se mantiene en
O(N X).

Teorema 3.5.3

El llamado ks_tab_opt1(V, W, X), con len(V ) = len(W ) = N :


1. Calcula la suma máxima de elementos con valores en V [0..N ) cuyos pesos
en W [0..N ) no exceden X.
2. Toma tiempo O(N X).
3. Usa espacio O(X).

Demostración
114 3. Programación dinámica

Se propone como ejercicio al lector.

3.5.2. Segunda optimización. El espacio de la tabulación puede ser reducido


aún más. Note que los valores que se utilizan para actualizar la columna n (i.e.,
la del presente) están en un espacio acotado dentro de la columna n − 1 (i.e., la
del pasado inmediatamente anterior). Si de alguna forma se pudiera consolidar la
información de los dos momentos en el tiempo en una sola columna, esto permitirı́a
reducir el espacio de 2X a X posiciones de memoria. La observación clave es que
para tabular φ(n, x), solo se requieren valores de φ(n − 1, y), con 0 ≤ y ≤ x. Es
decir, en la tabulación deseada se podrı́a “partir” esa columna de tal forma que la
parte inferior al ı́ndice x tuviera solo valores del pasado inmediato y en la parte
superior de x solo valores del presente. El diagrama de necesidades a continuación
resume visualmente estas observaciones.

X
presente

x
pasado

tab

Como la parte inferior de tab[0..X] debe tener disponibles los valores del pasado,
necesariamente la forma de llenarla debe iniciar por su parte superior. Los siguientes
invariantes hacen explı́cita esta y las observaciones anteriores.

R0 : (∀j | 0 ≤ j ≤ x : tab[j] = φ(n − 1, j)).


R1 : (∀j | x < j ≤ X : tab[j] = φ(n, j)).
P2 : 0 ≤ n ≤ N + 1.
P3 : − 1 ≤ x ≤ X.

El invariante R0 especifica que el subarreglo tab[0..x] tiene los valores del pasado
inmediato (i.e., desde φ(n − 1, 0) hasta φ(n − 1, x)). El invariante R1 , a su vez,
especifica que el subarreglo tab[x + 1..X] tiene los valores del presente (i.e., desde
φ(n, x + 1) hasta φ(n, X)). Los invariantes P2 y P3 son los mismos de las dos
versiones anteriores de los algoritmos de tabulación de φ.
3.5. El problema del morral 115

Algoritmo 3.5.3

1 def ks_tab_opt2(V, W, X):


2 N = len(V)
3 tab = [ 0 for _ in range(X+1) ]
4 n,x = 1,X
5 # R0 ∧ R1 ∧ P2 ∧ P3
6 while n!=N+1:
7 if x==-1: n,x = n+1,X
8 else:
9 if W[n-1]<=x:
10 tab[x] = max(tab[x], tab[x-W[n-1]]+V[n-1])
11 x += 1
12 return tab[X]

Inicialmente, tab[0..X] es creado con valores 0. A partir de la columna n = 1


y hasta la columna n = N , se hace una de dos cosas. O bien se detecta que se ha
procesado una nueva columna (i.e., x = −1) y se avanza a la (posible) siguiente
columna para comenzar el proceso desde la fila X. De lo contrario, el valor en tab[x]
es φ(n − 1, x). Si se tiene capacidad disponible en el morral (i.e., W [n − 1] ≤ x) y
hay mayor beneficio en llevar el elemento a no llevarlo, entonces se actualiza tab[x]
con φ(n − 1, x − W [n − 1]) + V [n − 1].

Teorema 3.5.4

El llamado ks_tab_opt2(V, W, X), con len(V ) = len(W ) = N :


1. Calcula la suma máxima de elementos con valores en V [0..N ) cuyos pesos
en W [0..N ) no exceden X.
2. Toma tiempo O(N X).
3. Usa espacio O(X).

Demostración

Se propone como ejercicio al lector.

Asintóticamente, los algoritmos 3.5.2 y 3.5.3 usan la misma cantidad de espacio.


Sin embargo, el espacio se reduce exactamente a la mitad en ks_tab_opt2 y, además,
el código es más sucinto y fácil de seguir. Por estas razones deberı́a preferirse esta
función a ks_tab_opt1.
116 3. Programación dinámica

Ejercicios

1. Demuestre que la cantidad de subconjuntos de un conjunto de N elementos es


2N .
2. Considere los 4 elementos con pesos y valores dados en el Ejemplo 3.5.1. Enu-
mere los 16 conjuntos de ı́ndices correspondientes a esta instancia del problema
del morral, y a cada uno de ellos asocie el peso y valor total. Verifique que la
conclusión es correcta para cada X en dicho ejemplo.
3. Justifique con un ejemplo que el problema del morral exhibe las propiedades
de subestructura óptima y solapamiento.
4. Demuestre que la definición recurrente de φ(n, x), para 0 ≤ n ≤ N y 0 ≤ x ≤ X,
en cualquier llamado recurrente mantiene los valores de n y x en estos rangos.
5. En la demostración del Teorema 3.5.1 se afirma que si S = ∅, entonces φ(n, x) =
0 observando que no hay elementos para escoger (i.e., n = 0) o que todos los
elementos disponibles tienen un peso mayor a x (i.e., W [i] > x para 0 ≤ i < n).
Demuestre en detalle que esta afirmación acerca de φ es correcta.
6. En la tabulación propuesta para φ en ks_opt se opta por llenar tab[0..N ][0..X]
por columnas. Diseñe una tabulación para φ que resulte de llenar tab por filas.
Bajo esta decisión de llenar la matriz, ¿es posible optimizar el espacio de la
tabulación? Justifique su respuesta.
7. Demuestre el Teorema 3.5.2.
8. Diseñe un algoritmo con memorización para φ. Demuestre que es correcto con
respecto a la especificación hecha. ¿Cuáles son sus complejidades temporal y
espacial? Justifique su respuesta.
9. Investigue acerca de tiempo pseudo-polinimial y explique su relación con algo-
ritmos numéricos. En particular, explique su relación con el algoritmo común
para determinar si un número es primo.
10. Demuestre el Teorema 3.5.3.
11. Suponga que a es un número entero que satisface 0 ≤ a ≤ 1. Demuestre que
0 ≤ 1 − a ≤ 1.
12. En la función ks_tab_opt1 se retorna el valor tab[prev][X]. Explique por qué
serı́a incorrecto retornar el valor tab[curr][X].
13. Demuestre el Teorema 3.5.4.
14. En el planteamiento de la función objetivo, la cantidad de elementos disponi-
bles se hizo variar para considerar diferentes prefijos de los ı́ndices de A[0..N ).
Plantee una definición alternativa a φ en la cual se hagan variar los diferentes
prefijos de A[0..N ).
3.6. El problema del agente viajero 117

15. Hay una variante del problema del morral en la cual de cada elemento hay una
cantidad ilimitada de copias (y se permite llevar cuantas copias sean deseadas
de cada elemento).
a) Especifique esta variante del problema del morral.
b) Diseñe una solución con tabulación para el problema especificado usando
la metodologı́a propuesta en la Sección 3.3. Si es posible reducir el espacio
de la tabulación, redúzcalo al máximo.
16. La siguiente especificación corresponde al problema, comúnmente denominado,
suma exacta de un subconjunto (en inglés, Subset Sum):

Entrada: Arreglo A[0..N ), con N ≥ 0, de números y un número X.


Salida: ¿Hay un subarreglo de A[0..N ) cuyos elementos sumen X?

a) En la literatura se indica que el problema de la suma exacta de un sub-


conjunto es una instancia particular del problema del morral. Justifique
por qué esta afirmación es cierta e ilústre su respuesta con ejemplos.
b) Diseñe un algoritmo con tabulación que permita resolver el problema de
suma exacta de un subconjunto.

3.6. El problema del agente viajero

El del agente viajero, es un problema de optimización combinatoria en gra-


fos. Los grafos son un formalismo de las matemáticas utilizado ampliamente como
herramienta para abstraer y modelar relaciones entre objetos.

Nota 3.6.1

Un grafo G es una pareja (V, E) de vértices V y de arcos E ⊆ V × V sobre


los vértices. Si (u, v) ∈ E se dice que hay un arco entre u y v. El grafo puede
ser dirigido o no-dirigido. En un grafo dirigido los arcos tienen dirección, es
decir, un origen y un destino. En un grafo no-dirigido los arcos no tienen
dirección, únicamente conectan dos vértices. En este texto, mientras no se
haga la aclaración, los grafos se suponen no-dirigidos. Se dice que un grafo
es completo cuando hay un arco entre cualquier par de vértices (distintos).
Un circuito en un grafo es un camino que visita exactamente cada uno de sus
vértices y al finalizar regresa al vértice de origen
Las relaciones de amistad o de seguimiento en redes sociales se pueden mo-
delar como grafos, al igual que sistemas biológicos como redes de co-expresión
genética o redes de interacción entre proteinas.
118 3. Programación dinámica

Nota 3.6.2

El problema del agente viajero (en inglés, travelling salesman problem o TSP )
se preocupa de responder la siguiente pregunta: dada una lista de ciudades
y las distancias entre cada par de ellas, ¿cuál es la ruta más corta posible
que visita cada ciudad exactamente una vez y al finalizar regresa a la ciudad
origen?
Este problema fue formulado por primera vez a finales de los años 1920s
y ha sido estudiado exhaustivamente en las áreas de optimización y teoria de
la complejidad.

De forma general, el sistema de ciudades y rutas en el problema del agente


viajero se puede abstraer en un grafo en el cual las ciudades se modelan como
vértices y los caminos entre ciudades como arcos. En este problema se menciona
una noción de medida para las rutas. Para incluir este elemento en el modelado
del problema, se puede optar por tener una función de distancia sobre los arcos del
grafo. Note que en el problema del agente viajero se desea encontrar un circuito de
costo mı́nimo con respecto a una función de distancia.

Problema 3.6.1: El problema del agente viajero

Entrada: Un grafo G = (V, E) completo, con función de peso sobre


los arcos w : E → R≥0 .
Salida: Un circuito de costo mı́nimo (con respecto a w) en G.

La salida el Problema 3.6.1 se puede precisar matemáticamente y exhibir ası́,


entre otras cosas, su estructura combinatoria. La primera observación importante
es que en un circuito no es importante identificar cuál es el vértice de “inicio”, pues
cualquiera de los vértices en dicho circuito puede ser considerado de inicio (¿por
qué?). Entonces, para el modelado del problema es natural volcar el interés en los
arcos del grafo. Suponga que se cuenta con un predicado circuit que indica, para
cualquier C ⊆ E, si C es un circuito en G. Es decir, circuit(G, C) (o, simplemente,
circuit(C) cuando G se entiende del contexto) es cierto únicamente cuando C es un
circuito en G. Además, se define la función cost para cualquier subconjunto C ⊆ E
y función de peso w : E → R≥0 de la siguiente manera:

cost(C) = (+e | e ∈ C : w(e)).

Es decir, cost(C) es la suma de los pesos de los arcos en C. Con base en estas
definiciones, se formula la salida del problema del agente viajero de la siguiente
3.6. El problema del agente viajero 119

manera:
(↓ C | C ⊆ E ∧ circuit(C) : cost(C)).
Esta fórmula expresa que se desea calcular el mı́nimo costo entre todos los circuitos
en G. Note que la cantidad de posibles circuitos está acotada por 2|E| . Más preci-
|E|
samente, por aquellos subconjuntos de |E| de tamaño |V |; es decir, por 2(|V |) , en
donde
 
|E|
|V |
denota la cantidad de subconjuntos de tamaño |V | en un conjunto de tamaño |E|. En
esta observación yace la naturaleza combinatoria del problema del agente viajero.

Ejemplo 3.6.1

A continuación se presenta un grafo completo con 4 vértices a, b, c, d.

8
6
a 7
7

b 8
c

El circuito a → b → c → d → a es óptimo y tiene costo 27:

8
6
a 7
7

b 8
c

3.6.1. Diseño de una función objetivo. Necesariamente hay un circuito de


suma mı́nima cuando G no es vacı́o (i.e., V 6= ∅); suponga que C ⊆ E es un circuito
óptimo de G en este sentido. Sin pérdida de generalidad, también suponga que
C = {(u0 , u1 ), (u1 , u2 ), . . . , (uN −1 , u0 )},
en donde N = |V | y V = {u0 , u1 , u2 , . . . , uN −1 }. Observe que el circuito
u0 , u1 , u2 , . . . , uN −1 , u0
es óptimo, si y solo si el camino de u0 a uN −1 en este circuito es óptimo, entre
todos los caminos posibles de u0 a uN −1 que incluyen los demás vértices de G
(de lo contrario, no serı́a de costo mı́nimo –¿por qué?). Esta es la propiedad de la
120 3. Programación dinámica

subestructura óptima que se “explotará” para formular una función objetivo que
ayude a resolver el problema del agente viajero usando programación dinámica.
Función objetivo. Dado que en un circuito no importa desde dónde se inicie
el recorrido, se fija arbitrariamente un vértice s ∈ V como punto de partida. Se usa
el hecho de que el grafo G no es vacı́o (i.e., V 6= ∅); de lo contrario el problema es
trivial ya que dicho circuito no existe.
Para X ⊆ V y u ∈ X:

φ(u, X) : “mı́nimo costo de un camino (simple) que inicia en s, visita cada


uno de los vértices en X y termina en u.”

Basada en la propiedad de la subestructura óptima, la observación importante


para aceptar la formulación de φ como (potencialmente) útil es que todo circuito
óptimo se puede construir con base en rutas óptimas que visiten todos los vértices
del grafo dado.
Reformulación de la especificación. El objetivo es entonces calcular el valor
de la siguiente expresión:

(↓ u | u ∈ V \ {s} : φ(u, V \ {s}) + w(u, s)).

Entre todos los posibles caminos que inician en s, incluyen todos los demás vértices
del grafo y terminan en u, se desea calcular aquel camino que sumando la ruta de
regreso directa de u a s sea de costo mı́nimo. Note que al tener que w(e) ≥ 0 para
cualquier arco e ∈ E, es natural que el valor asociado a la minimatoria anterior sea
0 cuando V tiene exactamente un vértice (i.e., V = {s}).
Definición recurrente. Una ruta de costo mı́nimo de s a cualquier otro vértice
debe estar conformado por subrutas óptimas. Es decir, cada ruta que inicie en s en
dicho camino, también debe ser de costo mı́nimo.
La definición formal de φ se presenta para X ⊆ V \ {s} y u ∈ V \ {s}:


+∞

 , si x ∈
/ X,
φ(u, X) = w(s, u) , si X = {u},


(↓ v | v ∈ X \ {u} : φ(v, X \ {u}) + w(v, u)) , si u ∈ X ∧ |X| ≥ 2.

Se consideran tres casos para definir φ. Hay dos opciones entre u y X: que u sea
elemento de X o no y, cuando lo es, que sea el único o no. Es imposible que haya un
camino que visite todos los elementos de X y que termine en u cuando u ∈ / X; la
forma de expresar que esto es imposible es con el valor +∞ dado que es la identidad
del mı́nimo. Si u es el único elemento de X, solo hay un camino de s a u: el camino
directo entre s y u, el cual tiene costo w(s, u). Cuando en X hay otros elementos en
adición a u, se apuesta por todos lo caballos: se prefiere aquél vértice v ∈ X \ {u}
3.6. El problema del agente viajero 121

que en dicho camino conecte directamente con u y para el cual el camino desde s
permita construir un camino hasta u de costo mı́nimo.

Teorema 3.6.1

Sean s ∈ V y X ⊆ V \ {s}. Si u ∈ X, entonces φ(u, X) es el costo mı́nimo


de un camino (simple) que inicia en s, visita cada uno de los vértices en X y
termina en u.

Demostración

Se propone como ejercicio al lector.

La función φ es distinta a las que se han usado para resolver los problemas
anteriores. En particular, tiene como parámetro un conjunto. Como tal, esta defini-
ción se puede implementar directamente en un lenguaje de programación (usando
conjuntos disponibles en sus librerı́as o implementando la estructura de datos co-
rrespondiente). No resulta fácil pensar cómo tabular cuando en una de las dos
dimensiones hay un conjunto. Tampoco resulta fácil pensar cómo implementar una
memorización eficiente cuando el acceso a los datos depende de una colección y que
esta colección puede “mutar” entre los distintos llamados recurrentes. En realidad,
estas son noticias parcialmente malas. La noticia parcialmente buena es que bajo
ciertas suposiciones acerca de la cantidad de vértices en el grafo, los conjuntos se
pueden representar con números naturales y las operaciones básicas sobre ellos (al
menos las que se requieren para calcular la función objetivo) se pueden realizar
en tiempo constante. Entonces, antes de proponer una solución con programación
dinámica, se estudia cómo representar conjuntos y algunas de sus operaciones con
números naturales.

3.6.2. Especificación de conjuntos con máscaras de bits. El propósito de


esta sección es mostrar cómo, bajo ciertas suposiciones, se pueden usar máscaras y
operaciones de bits para representar conjuntos y operaciones sobre ellos. En par-
ticular, para un conjunto de N elementos, es deseable contar con las siguientes
operaciones:
Representación del conjunto completo o universal, junto con todos sus subcon-
juntos.
Sustraer un elemento de un conjunto.
Consultar si un elemento hace parte de un conjunto.
Determinar si un conjunto es el conjunto unitario determinado por un elemento
dado.
122 3. Programación dinámica

Estando disponible y de manera eficiente, la funcionalidad enumerada serı́a suficien-


te para implementar un algoritmo con programación dinámica que permita calcular
la función φ eficientemente. Al menos, esta es la intención.
Las máscaras de bits (i.e., secuencias de bits) ofrecen una posibilidad si la
cantidad de elementos N está acotada: un conjunto universal con N elementos
se puede representar con N bits. Es más, cualquier conjunto con a lo sumo N
elementos se puede también representar con una secuencia de bits de tamaño N .
Sin pérdida de generalidad, se puede suponer que hay un orden entre los elementos
del conjunto universal que se quiere representar. Bajo esta suposición, se puede
asumir que el conjunto a codificar es U = {0, 1, . . . , N − 1} de tal forma que 0
identifica el primer elemento de dicho conjunto, 1 el segundo y ası́, con N − 1
representando el último elemento en este orden arbitrario. Una secuencia de bits
de tamaño N se puede, entonces, usar como función caracterı́stica para representar
exactamente un subconjunto de U : el n-ésimo bit, yendo del menos significativo al
más significativo, indica si el n-ésimo elemento de U hace parte del subconjunto,
para 0 ≤ n < N .

Ejemplo 3.6.2

Considere el conjunto {a, b, c, d} de etiquetas. Suponga que las etiquetas tienen


el siguiente orden:
a < b < c < d.
Entonces:
La secuencia 1111 representa el conjunto {a, b, c, d}.
La secuencia 1010 representa el conjunto {b, d}.
La secuencia 0100 representa el conjunto unitario {c}.
La secuencia 0000 representa el conjunto vacio ∅.

Entonces, en el problema del agente viajero se puede suponer que el conjunto


de vértices V = {v0 , v1 , . . . , vN −1 } y sus subconjuntos, se pueden representar con
máscaras de bits de tamaño N (fijando algún orden sobre V ).
En un lenguaje de programación, como Python, se puede construir rápidamente
un conjunto universo para cualquier N ∈ N haciendo corrimiento de bits y una
resta (ojo, no es la única manera de hacerlo). A continuación se presenta la función
universe que dado dicho N , retorna el número entero cuya representación en base
2 es una secuencia de exactamente N unos:

1 def universe(N): return (1<<N)-1


3.6. El problema del agente viajero 123

Por ejemplo, universe(3) es 7 dado que su representación en base 2 es 111. La


expresión 1<<N calcula el número natural 2N . Al restarle 1, se “apaga” el único bit
existente y todos los 0 a su derecha de “encienden”, resultando en una secuencia
de N unos.
El siguiente reto es determinar si un bit especı́fico está encendido o apagado en
una máscara de bits. Suponiendo que se cuenta con una máscara de N bits, para
cualquier 0 ≤ n < N , se puede identificar si el n-ésimo bit está encendido con una
disyunción bit a bit.

1 def is_elt(n, X): return (X|(1<<n))==X

El llamado is_elt(n, X) es cierto cuando el n-ésimo bit del código binario del
número natural X es 1, suponiendo que n es una posición en la máscara de bits
correspondiente a X.
De una manera muy similar, se puede apagar el n-ésimo bit en una máscara de
bits.

1 def remove_elt(n, X): return X-(1<<n) if is_elt(n, X) else X

Si ese bit está encendido, entonces se apaga; de lo contrario, la máscara no se


modifica.
Finalmente, determinar si una máscara representa un conjunto unitario dado es
fácil de calcular: en la máscara correspondiente al conjunto unitario {n} únicamente
hay un bit encendido, exactamente el n-ésimo.

1 def singleton(n, X): return X==(1<<n)

Estas cuatro operaciones, basadas en comparación y aritmética de bits, son efi-


cientes en la práctica cuando N no supera el tamaño de la palabra en un procesador.
Por ejemplo, en procesadores de 32 bits estas operaciones se pueden implementar
directa y eficientemente siempre y cuando N ≤ 32. Lo mismo sucede con procesado-
res de 64 bits, etc. De lo contrario, se deberá incurrir en procedimientos adicionales
que difı́cilmente serán de orden constante.

3.6.3. Diseño de un algoritmo con memorización. Bajo la suposición de que


se pueden representar los subconjuntos de V con máscaras de bits, se obtiene casi
directamente un algoritmo para implementar φ con memorización. Concretamente,
se supondrá que G tiene N vértices V = {0, 1, . . . , N − 1} y que su representación
es una matriz de ayacencia w[0..N )[0..N ) tal que para cualquier u, v ∈ V , se tiene
w[u][v] = w[v][u] = w(u, v) y w[u][u] = 0. Es decir, los vértices se identifican con
los primeros N números naturales y la función de peso entre vértices se especifica
con una matriz cuadrada y simétrica en donde cada entrada indica el peso del arco
entre los vértices correspondientes; esta matriz es 0 en su diagonal.
124 3. Programación dinámica

El Algoritmo 3.6.1 presenta las funciones phi_memo y tsp que resuelven el pro-
blema del agente viajero para un grafo de N vértices y función w de peso en los
arcos. La función principal es tsp, la cual recibe estos dos parámetros (se pudo
también optar por tener estos dos parámetros como variables globales, simplifican-
do –pero oscureciendo en este caso en particular– el código). La función phi_memo
es la solución por memorización que implementa la función φ. Note que el vértice
0 juega el papel del vértice s en la especificación de la función φ.

Algoritmo 3.6.1

1 INF = float('inf')
2

3 def phi_memo(N, w, u, X, mem):


4 ans,key = None,(u,X)
5 if key in mem: ans = mem[key]
6 else:
7 if not(is_elt(u, X)): ans = INF
8 elif singleton(u, X): ans = w[0][u]
9 else:
10 ans,Y = INF,remove_elt(u, X)
11 for v in range(1, N):
12 if is_elt(v, Y):
13 ans = min(ans, phi_memo(N, w, v, Y, mem)+w[v][u])
14 mem[key] = ans
15 return ans
16

17 def tsp(N, w):


18 ans = INF
19 X = remove_elt(0, universe(N))
20 mem = dict()
21 for u in range(1, N):
22 ans = min(ans, phi_memo(N, w, u, X, mem)+w[u][0])
23 return ans

Un llamado tsp(N, w) inicialmente construye en X el conjunto de todos los


vértices excepto el vétice de partida, es decir, el conjunto {1, . . . , N − 1}. Iniciando
con la memoria vacı́a, se itera sobre cada uno de los vértices en este conjunto
tratando de encontrar aquella combinación que permita construir un circuito de
costo mı́nimo que inicie en 0 y termine en 0, agotando todos los demás vértices
en X. Esto corresponde, tal cual, con la reformulación de la especificación en la
Sección 3.6.1.
3.6. El problema del agente viajero 125

El llamado phi_memo(N, w, u, X, mem) calcula la función φ(u, X) para el gra-


fo de N vértices con función de peso w[0..N )[0..N ), usando la memorización en el
diccionario mem. Las llaves del diccionario son parejas en donde el primer argumen-
to corresponde a un identificador de vértice y el segundo a una máscara de bits
(representando un conjunto de vértices). Si el valor asociado a la llave (u, X) está
presente en el diccionario, se usa directamente como valor de retorno. De lo conta-
rio, se calcula el valor correspondiente a esta llave y se almacena en el diccionario
antes de retornarlo. Note que se calcula este valor con base en cada uno de los tres
casos que hacen parte de la definición recurrente de φ.

Ejemplo 3.6.3

A continuación se presenta el grafo completo con 4 vértices a, b, c, d del Ejem-


plo 3.6.1.

8
6
a 7
7

b 8
c

Este es un grafo con N = 4 vértices y función de peso w dada por la siguiente


matriz de adyacencia:
1 w = [ [ 0, 6, 7, 6 ],
2 [ 6, 0, 8, 8 ],
3 [ 7, 8, 0, 7 ],
4 [ 6, 8, 7, 0 ] ]
El valor calculado por la función tsp en este caso es 27:
1 >>> tsp(4, w)
2 27

La correctitud de tsp depende directamente de la correctitud de phi_memo, bajo


las siguientes condiciones:
C0 : (∀u, X | (u, X) ∈ mem : mem[u][X] = φ(u, X)).
C1 : (∀u, X | (u, X) ∈ mem : 1 ≤ u < N ∧ 0 ≤ X < 2N ).
Las condiciones C0 y C1 son condiciones de representación, respectivamente, de la
memorización y de conjuntos con máscaras de bits. La condición C0 indica que los
valores almacenados en la memoria compartida son correctos en relación con φ. La
condición C1 indica que los valores utilizados como ı́ndice de la memoria son los
126 3. Programación dinámica

esperados: un vértice distinto al de inicio y una máscara de bits para el conjunto


de N vértices. La correctitud de phi_memo consiste, básicamente, en garatizar que
estas dos condiciones se mantienen a lo largo de todos los llamados recurrentes.

Teorema 3.6.2: Correctitud de phi_memo

Para V = {0, 1, . . . , N − 1} y función de peso w[0..N )[0..N ), el llamado


phi_memo(N, w, u, X, memo) con u ∈ V \ {0} y 0 ≤ X < 2N :
1. Preserva las condiciones C0 y C1 , y
2. Calcula φ(u, X).

Demostración

Se propone como ejercicio para el lector.

La complejidad temporal de phi_memo, suponiendo que el acceso al diccionario


y las operaciones sobre la máscara de bits son de orden constante, principalmente,
es O(N · 2N ) que resulta principalmente de las lı́neas de código 11-13. Con base en
esta observación (y suposiciones), es fácil concluir que la complejidad temporal del
llamado tsp(N, w) es O(N 2 · 2N ). Los detalles de estas afirmaciones se proponen
como ejercicio para el lector.

Teorema 3.6.3: Correctitud de phi_memo

El llamado tsp(N, w) toma tiempo O(N 2 · 2N ) y espacio O(N · 2N ).

Demostración

Se propone como ejercicio para el lector.


3.6. El problema del agente viajero 127

Ejercicios

1. Encuentre 5 ejemplos cotidianos de grafos, y explique cuáles son los vértices y


arcos en cada uno de ellos. En cada caso, explique si el grafo es dirigido o no-
dirigido, y si es completo. Si hay una noción de peso, distancia o importancia,
¿cuál es?
2. Suponga que se cuenta con un circuito en un grafo G = (V, E). Explique por qué
cada uno de los vértices en dicho circuito puede ser considerado como vértice
inicial del circuito.
3. Proponga una definición formal para el predicado circuit(C) relativa a un grafo
G = (V, E) tal que C ⊆ E. Si prefiere, puede tratar circuit como una función
Booleana circuit : 2E → B, en donde 2E denota la colección de subconjuntos
de E.
4. Explique por qué la cantidad máxima de circuitos en un grafo completo G =
|E|
(V, E) está acotada por 2(|V |) . ¿Son circuitos todos los subconjuntos de E de
tamaño |V |? ¿Por qué?
5. Enumere todos los circuitos del grafo en el Ejemplo 3.6.1 sin distinguir entre
rotaciones (¿cuántos hay?). Justifique que el circuito de costo mı́nimo allı́ es
27.
6. En la definición recurrente de φ se incluye como caso base φ(u, X) = +∞
cuando u ∈ / X. En realidad, este caso se incluye por completitud de φ dado
que X ⊆ V \ {s} y u ∈ V \ {s}. Explique por qué este caso no es necesario
para calcular la salida del problema. En particular, justifique cómo φ se puede
definir, sin introducir inconsistencias matemáticas, únicamente para aquellos
casos en los cuales u ∈ X.
7. Es posible obtener una versión distinta de la función remove_elt usando ex-
clusivamente operaciones sobre bits (sin operaciones aritméticas). Investigue
acerca de esta operación y proponga una versión alternativa de remove_elt.
8. ¿Cómo se puede iterar sobre todos los subconjuntos de un conjunto de tamaño
N con máscaras de bits? Diseñe un algoritmo que itere sobre todas las posibles
máscaras de 2 bits y calcule la tabla de verdad para la disyunción y conjunción.
9. Demuestre el Teorema 3.6.2.
10. Demuestre el Teorema 3.6.3.
11. Extienda el Algoritmo 3.6.1 para que, además de calcular el costo de un circuito
óptimo, también retorne un circuito de costo óptimo.
12. Diseñe un algoritmo de programación dinámica con tabulación para φ y un
algoritmo iterativo para resolver el problema del agente viajero. Estime su
complejidad temporal y espacial.
128 3. Programación dinámica

Notas del capı́tulo y referencias

La noción de programación dinámica cobra un aire más general cuando se


considera más allá de la computación y del estudio de algoritmos eficientes para
implementar cierto tipo de funciones recurrentes. Para el estudio de fenómenos y
problemas económicos, como originalmente nace, es un método recursivo para re-
solver problemas de decisión secuenciales. Se le conoce también como inducción
reversa (en inglés, backward induction), y es empleada para encontrar reglas de
decisión óptimas en juegos, equilibrios perfectos de juegos multi-agente y equili-
brios competitivos en modelos económicos dinámicos. El texto autobiográfico de R.
Bellman [Bel84] incluye apartes de los orı́genes de la programación dinámica en
este contexto más general, al igual que algunos trabajos más recientes en Economı́a
como el de J. Rust [Rus08]. El aparte textual que se presenta en la Nota 3.0.1 es
tomado de [Bel84, p. 159]; la traducción al Castallano es responsabilidad del autor.
Las propiedades de la subestructura y solapamiento son planteadas comúnmen-
te para estudiar problemas cuyas soluciones son candidatas a ser encontradas con
programación dinámica. Lo mismo sucede con las técnicas de implementación por
memorización (en inglés, top-down) y tabulación (en inglés, bottom-up). Estos pares
de temas se encuentran explicados, de manera similar a la seguida en este capı́tulo,
en [CLRS09] y [KET06]. La metodologı́a propuesta en la Sección 3.3 es original,
pero de ninguna forma alejada a otras propuestas como la encontrada en [Eri19].
En el caso de este texto, el enfoque se basa en la definición de una función objetivo
que posteriormente se define formalmente de manera recurrente.
Los problemas escogidos para ilustrar el uso de la programación dinámica tam-
bién son extensamente conocidos en la comunidad de algoritmos. Posiblemente, el
enfoque de anotar los diseños de memorización con condiciones y de tabulación con
invariantes explı́citos no sea tan popular. En este capı́tulo se ha hecho el esfuerzo
de exponer las principales propiedades de los problemas y los algoritmos usando
una notación matemática sencilla. El uso de invariantes à la Dijkstra & Scholten
también es planteado por J. Bohórquez [Boh06]. El algoritmo que implementa la
reducción a una sola columna en la Sección 3.5 está inspirado en observaciones
hechas en [Eri19].
Los textos de T. Cormen et al. [CLRS09], J. Kleinberg y É. Tardos [KET06],
J. Erickson [Eri19], H. Bhasin [Bha15] y A. Levitin [Lev12] son fuentes extensas
de ejemplos de soluciones con programación dinámica y ejercicios para ser resueltos
con esta técnica. Textos de entrenamiento diseñados para programación competi-
tiva como los de S. Skiena [Ski08] y S. Halim et al. [HHE20] contienen ejemplos
complementarios, además de ‘trucos’ de implementación, al igual que páginas de
internet asociados a programación competitiva y algoritmos.
Notas del capı́tulo y referencias 129

Además de las técnicas de reducción de espacio vistas para tabulaciones, exis-


ten técnicas para la reducción del tiempo de cómputo para algunos problemas que
admiten programación dinámica como solución. Entre ellas se encuentran las basa-
das en monotonı́as y desigualdades propuestas por D. Knuth en [Knu88]. Existen
otras basadas en envolvimientos convexos, y en dividir y conquistar; se sugiere al
lector [Bei13] para un recuento más detallado y ejemplos.
Capı́tulo 4

Algoritmos voraces

Los algoritmos voraces (en inglés, greedy algorithms) sirven comúnmente para
resolver problemas de optimización. Están basados en la premisa de que la elección
reiterativa de óptimos locales garantiza, al final del proceso de elección, un óptimo
global. Es decir, para resolver un problema de optimización, un algoritmo voraz
una y otra vez toma una mejor opción local (sin fijarse en sus consecuencias) de
tal forma que la colección de elecciones hecha finalmente constituye una solución
óptima global.
Sin embargo, en la práctica, la optimización local rara vez conduce a un óptimo
global. Entonces, ¿por qué estudiar este tipo de algoritmos? La razón principal
es que cuando los algoritmos voraces funcionan, son muy eficientes, sencillos de
programar y elegantes. Hay una frase anónima que resume muy bien esta situación
que parece paradójica:

Los algoritmos voraces no funcionan, pero cuando sı́, lo hacen muy bien.

Autor desconocido.

El diseño de un algoritmo voraz debe estar siempre acompañado de una de-


mostración de su corrección, i.e., de que logra resolver el problema de optimización
planteado. Generalmente, estas demostraciones siguen un mismo patrón, el cual se
identifica en el desarrollo de este capı́tulo y se ilustrará con ejemplos de diferente
ı́ndole. Es importante estar familiarizado con este tipo de demostraciones porque,
a veces, no es fácil encontrar un argumento formal que justifique la correctitud de
los algoritmos voraces.

131
132 4. Algoritmos voraces

4.1. Agendamiento de actividades

Considere el problema de agendar procedimientos quirúrgicos en la sala de


cirugı́as de un hospital o clı́nica. Cada cirugı́a tiene un horario, con horas de inicio
y finalización. Suponiendo que entre más cirugı́as se hagan, mayor será el beneficio
para los pacientes (acá nada tiene que ver el dinero, ¿cierto?), surge la siguiente
pregunta: ¿cómo maximizar la cantidad de cirugı́as a realizar sin que haya conflicto
de horario entre ellas?
De una forma más general, esta pregunta es formulada en el problema del
agendamiento de actividades.

Problema 4.1.1: Agendamiento de actividades

Entrada: Un arreglo A[0..N ), N ≥ 0, de parejas de números (sn , en )


tales que 0 ≤ sn < en indican el tiempo de inicio y finaliza-
ción de la actividad 0 ≤ n < N , respectivamente.
Salida: Máxima cantidad de actividades en A[0..N ) que se pueden
agendar sin conflicto.

Como convención para cualquier n, con 0 ≤ n < N , las expresiones A[n][0] y


A[n][1] denotan, respectivamente, el tiempo de inicio y finalización de la actividad
n: es decir, sn = A[n][0] y en = A[n][1]. Note que todos los tiempos se expresan
con cantidades no negativas. Es necesario precisar qué significa actividades compa-
tibles y en conflicto para entender completamente la especificación del problema de
agendamiento de actividades.

Nota 4.1.1

Por convención, se entenderá que cada actividad (s, e) representa el interva-


lo cerrado-abierto de tiempo [s..e). De esta forma, dos actividades (si , ei ) y
(sj , ej ) son compatibles sii [si ..ei ) ∩ [sj ..ej ) = ∅; de lo contrario, se dice que
están en conflicto.

Entonces, la salida del problema indica que se desea determinar el tamaño de


un conjunto maximal de actividades en A[0..N ) que sean compatibles mútuamente
(o, de forma equivalente, en el cual ningún par de ellas esté en conflicto).
4.1. Agendamiento de actividades 133

Ejemplo 4.1.1

Se ilustra cada cirugı́a como un rectángulo cuya coordenada izquierda en el


eje horizontal denota el tiempo de inicio y la derecha el tiempo de finalización.

Las actividades que tienen su fondo resaltado hacen parte de un conjunto


maximal de cirugı́as compatibles, sin conflictos entre ellas. Dada esta entrada,
la salida es 6 para el problema de agendamiento.

El problema de agendamiento se puede resolver exhaustivamente enumerando


todos los subconjuntos de actividades y, entre aquellos que no tienen conflictos,
determinar el tamaño máximo posible. Es más, este problema exhibe las propie-
dades de la subestructura óptima y solapamiento (¿por qué?), y por ello es buen
candidato a ser resuelto con programación dinámica (ver Ejercicio 14).

4.1.1. Abstracción y estrategia voraz. El enfoque voraz es distinto al de una


exploracı́on exhaustiva o la de utilizar programación dinámica, pues evita apostarle
a ‘todos los caballos’ y en cambio apuesta solo a uno de ellos, a uno que ganará (o
que no perderá).
La solución voraz que se presenta a continuación está basada en la siguiente
observación/intuición: entre más extenso sea el rango para agendar cirugı́as, más
cirugı́as se podrán agendar. Suponga que se cuenta con un conjunto X de cirugı́as
para agendar. La pregunta clave es, ¿cuál agendar primero? Bajo la intuición an-
terior, se puede elegir una cirugı́a a ∈ X que termine lo más pronto posible dado
que esto permite que, para una posterior elección, haya más tiempo para agendar
otras cirugı́as bajo la misma estrategia. Esto es, habiendo escogido a, el proceso de
agendamiento de las cirugı́as en X se repetirı́a con aquellas actividades que no estén
en conflicto con a, seleccionando aquellas que terminen primero y que no generen
conflicto alguno.
Esta será la apuesta por una estrategia voraz en la cual se optimizan localmente
las decisiones, esperando que al final resulte un agendamiento con la mayor cantidad
de cirugı́as posibles. Para ello, se demuestra formalmente que dicha intuición en
realidad funciona, con la ayuda de dos teoremas: uno que justifica la optimización
local y otro que garantiza que una secuencia de optmizaciones locales resulta en
una selección óptima globalmente.
134 4. Algoritmos voraces

Teorema 4.1.1: Optimización local

Sea X un conjunto finito de actividades. Si a ∈ X es tal que su tiempo de


finalización es mı́nimo en X, entonces a hace parte de un conjunto maximal
y sin conflicto de actividades de X.

Demostración

Suponga que Y ⊆ X es un conjunto maximal y sin conflicto. Como X 6= ∅,


6 ∅ (¿por qué?). Se procede por casos:
necesariamente Y =
Si a ∈ Y , entonces la conclusión del teorema es correcta.
Si a ∈ / Y , entonces sea b aquella actividad en Y que tiene tiempo de
finalización mı́nimo. Necesariamente b 6= a, porque de lo contrario a ∈ Y
(una contradicción). Considere el conjunto Z = (Y \ {b}) ∪ {a}. Como
b ∈ Y y a ∈ / Y , se tiene que |Z| = |Y |. Además note que Z no tiene
conflictos porque el tiempo de finalización de b no puede ser menor que
el de a (a tiene tiempo de finalización mı́nimo entre todas las actividades
de X, luego también de Y ): al remover b de Y , las actividades que están
en Y \ {b} no tienen conflicto y como a no termina después de b, Z no
tiene conflictos (por suposición, en Y no hay conflictos). Luego, Z es un
conjunto maximal y sin conflictos de actividades de X que contiene a a,
como se desea.

El efecto práctico del Teorema 4.1.1 es el siguiente: en un conjunto de activi-


dades, cualquiera de aquellas que termine lo más pronto posible puede ser parte
de un agendamiento óptimo. Luego, la estrategia es aplicar reiterativamente este
principio para obtener, por construcción, un agendamiento óptimo.

Algoritmo 4.1.1

Para un conjunto (finito) de actividades X:


1. Si X = ∅, entonces no hacer nada.
2. De lo contrario, sea a una actividad en X con tiempo de finalización
mı́nimo:
a) seleccionar a como parte del agendamiento y
b) recurrir con las actividades en X que no estén en conflicto con a.
4.1. Agendamiento de actividades 135

El Algoritmo 4.1.1 considera dos casos sobre un conjunto (finito) de actividades


X. Si X es vacı́o, entonces no hay nada que agendar y el algoritmo no hace nada. Si
X no es vacı́o, entonces se identifica una de la actividades en X que termina lo más
pronto posible. Esta actividad es seleccionada como parte de la respuesta, la cual se
sigue construyendo al recurrir con aquellas actividades en X que son compatibles
con a. La corrección de este algoritmo corresponde a la demostración del teorema
de optimización global: el algoritmo basado en la elección localmente óptima logra
un agendamiento óptimo (globalmente).

Teorema 4.1.2: Optimización global

Sea X un conjunto finito de actividades. El Algoritmo 4.1.1 construye un


agendamiento óptimo para X.

Demostración

El Algoritmo 4.1.1 construye un agendamiento sin conflictos (¿por qué?); lue-


go, basta demostrar que la colección de actividades seleccionadas por este
algoritmo es de tamaño máximo. Se procede por inducción (y por casos):
Si X = ∅, el agendamiento óptimo es vacı́o; el algoritmo responde correc-
tamente en este caso pues no hace ningún agendamiento.
Si X 6= ∅, suponga que el algoritmo selecciona n ≥ 1 actividades en X. Por
ejemplo, la siguiente colección ordenada ascendentemente por el tiempo
de inicio:
a0 , . . . , an−1 .
Suponga también que
b0 , . . . , bm−1
es un agendamiento óptimo de actividades en X, también ordenado as-
cendentemente por el tiempo de inicio de las actividades. El objetivo es
demostrar que n = m. Si los dos agendamientos son iguales, entonces la
propiedad es cierta. De lo contrario, sin pérdida de generalidad, se supone
que los dos agendamientos son iguales en las primeras k actividades, con
0 ≤ k < n: su primera diferencia es ak 6= bk (note que esto descarta la
posibilidad de que a0 , . . . , an−1 sea un prefijo de b0 , . . . , bm−1 ; ¿por qué
esta suposición es correcta?). Es decir, el agendamiento óptimo es de la
forma
a0 , . . . , ak−1 , bk , bk+1 , . . . , bm−1 .
136 4. Algoritmos voraces

Note que ak y bk son compatibles con las actividades en a0 , . . . , ak−1 (¿por


qué?). Como ak es una actividad que finaliza lo más temprano posible en-
tre aquellas en X que no tienen conflicto con a0 , . . . , ak−1 , necesariamente
ak no puede terminar después de bk (dado que bk también está en X y no
tiene conflicto con a0 , . . . , ak−1 ). En consecuencia, ak no entra en conflic-
to con ninguna de las actividades en bk+1 , . . . , bm−1 . Luego, el siguiente
agendamiento no tiene conflictos:
a0 , . . . , ak−1 , ak , bk+1 , . . . , bm−1 .
Sea Y ( X el conjunto de actividades que no tiene conflictos con nin-
guna actividad en {a0 , . . . , ak }. Observe que {ak+1 , . . . , an−1 } ⊆ Y y
{bk+1 , . . . , bm−1 } ⊆ Y . Por la hipótesis inductiva, con |Y | < |X|, el agen-
damiento
ak+1 , . . . , an−1
es maximal para las actividades en Y . Luego, n − k ≥ m − k; es decir,
n ≥ m. Como m es la longitud de una agendamiento óptimo para las
actividades en X, necesariamente n ≤ m. En conclusión, n = m.
En cualquiera de los dos casos, el agendamiento hecho por el Algoritmo 4.1.1
es óptimo.

Es importante resaltar que puede haber más de un agendamiento óptimo; esto


ha sido indicado antes, incluyendo el cuerpo de la demostración del Teorema 4.1.2.
El Ejemplo 4.1.2 muestra cuál es el agendamiento hecho por el Algoritmo 4.1.1 para
el conjunto de actividades presentado en el Ejemplo 4.1.1, al inicio de esta sección.

Ejemplo 4.1.2

El siguiente diagrama muestra, paso a paso, cómo el Algoritmo 4.1.2 seleccio-


na un conjunto máximo de actividades compatibles a partir del conjunto de
actividades en el Ejemplo 4.1.1.
x x

0 0 x

x x

x x x

0 x 0 x

1 3 1

x 2 x x 2

x x x x 5

0 x x 0 x x

1 3 x 1 3 x

x 2 x 4 x 2 4
4.1. Agendamiento de actividades 137

Cada elección hecha por el algoritmo se indica con un número consecutivo y


aquellas actividades que van siendo descartadas porque tiene conflictos con al
menos una de las actividades seleccionadas se marcan con una ‘x’.

4.1.2. Una implementación eficiente. Hasta este punto, el diseño y análisis


del algoritmo voraz se ha hecho con base en una abstracción del problema original,
suponiendo que la colección de actividades es un conjunto. De acuerdo con la espe-
cificación del problema, la colección de actividades es un arreglo A[0..N ) de parejas.
Por ello, aún hace falta algo más de trabajo para llegar a tener un algoritmo que
opere sobre arreglos, con la estrategia voraz diseñada.
El reto fundamental es evitar hacer un cálculo costoso, en el caso recurrente,
de aquellas actividades que son compatibles con la actividad seleccionada. Esto se
puede lograr de varias formas. Por ejemplo, bajo el supuesto de que A[0..N ) está
ordenado ascendentemente por el tiempo de finalización de las actividades. Note
que en este caso, si se ha hecho el mejor agendamiento posible para A[0..n), con 0 ≤
n ≤ N , las actividades candidatas para extender este mejor agendamiento parcial
están en A[n..N ). Esto es porque el tiempo de finalización de estas actividades
no es menor que el tiempo de finalización de la actividad n − 1 (y, por ende, de
ninguna actividad antes de ella). Es decir, si se tiene registro de qué actividades
han sido exploradas (e.g., A[0..n)) y el tiempo de finalización de la última tarea
seleccionada allı́, entonces se puede recurrir sobre las demás actividades en A[n..N ),
en orden: se descarta A[n] cuando tiene conflicto con alguna de las ya seleccionadas
en A[0..n) (esto se hace con base en el tiempo de finalizción que la última actividad
seleccionada) o se selecciona porque termina más temprano entre todas aquellas
actividades en A[n..N ) que no generan conflicto con la selección hecha hasta ese
punto.
La siguiente función objetivo, para 0 ≤ n ≤ N y t ∈ R≥0 , ayuda a plasmar
matemáticamente la idea elaborada en el párrafo anterior:

φ(n, t) : “máxima cantidad de actividades compatibles en A[n..N )


que no tienen conflicto con el intervalo (0..t).”

Para resolver el problema de agendamiento de actividades, el objetivo es calcular


φ(0, 0) suponiendo que ninguna actividad tiene tiempos expresados con números
negativos, como está estipulado en la especificación del problema.
La definición recurrente de φ se establece tomando a n como pivote, y seleccio-
nando o descartando actividades en relación con el intervalo (0..t). Formalmente,
para 0 ≤ n ≤ N y t ∈ R≥0 , se propone la siguiente definición recurrente de la
138 4. Algoritmos voraces

función objetivo:

0 , si n = N,


φ(n, t) = φ(n + 1, t) , si n 6= N ∧ A[n][0] < t,


1 + φ(n + 1, A[n][1]) , si n =6 N ∧ A[n][0] ≥ t.
El caso base corresponde a la situación en la cual no hay actividades para agendar;
el agendamiento óptimo tiene tamaño 0. El caso inductivo se divide en dos, siempre
suponiendo que hay al menos una actividad que potencialmente puede ser agendada
(i.e., n 6= N ). Si hay conflicto entre la actividad n y el intervalo “protegido” (i.e.,
A[n][0] < t), entonces esta se ignora y se recurre con el resto de las actividades. De
lo contrario, si no hay conflicto entre la actividad n y el intervalo protegido (i.e.,
A[n][0] ≥ t), entonces se selecciona la actividad y se recurre con las demás que están
pendientes por explorar, actualizando a (0..A[n][1]) el intervalo protegido (i.e., con
el cual se quiere evitar un conflicto).

Teorema 4.1.3

Suponga que las actividades en A[0..N ) están ordenadas ascendentemente por


tiempo de finalización (i.e., A[i][1] ≤ A[j][1], para 0 ≤ i < j < N ). Entonces:
1. Para 0 ≤ n ≤ N y t ∈ R≥0 , el valor de φ(n, t) es la máxima cantidad de
actividades compatibles en A[n..N ) que no tienen conflicto con el intervalo
(0..t).
2. El valor de φ(0, 0) es la máxima cantidad de actividades en A[0..N ) que
se pueden agendar sin conflicto.

Demostración

Se propone como ejercicio al lector.

Con base en la definición de φ, es claro cómo diseñar un algoritmo recurrente


para su implementación. A continuación se presenta el Algoritmo 4.1.2, como la
implementación de la función act que permite calcular φ.

Algoritmo 4.1.2

1 def act(A, N, n, t):


2 ans = None
3 if n==N: ans = 0
4 else:
4.1. Agendamiento de actividades 139

5 if A[n][0]<t: ans = act(A, N, n+1, t)


6 else: ans = 1 + act(A, N, n+1, A[n][1])
7 return ans

A continuación se presenta un ejemplo con un llamado a la función act para un


conjunto de actividades que, a escala, representan a aquellas en el Ejemplo 4.1.1.

Ejemplo 4.1.3

Considere el siguiente arreglo de parejas que especifica las actividades del


Ejemplo 4.1.1:
1 A = [ (0, 14), (16, 24), (29, 31),
2 (2, 6), (9, 17), (22, 30),
3 (6, 10), (15, 20), (24, 32),
4 (2, 9), (11, 15), (16, 23), (24, 28) ]
Después de ordenar el arreglo ascendentemente por la segunda componente
de las parejas, se obtiene:
1 >>> A.sort(key = lambda x: x[1])
2 >>> A
3 [ (2, 6), (2, 9), (6, 10), (0, 14), (11, 15),
4 (9, 17), (15, 20), (16, 23), (16, 24), (24, 28),
5 (22, 30), (29, 31), (24, 32) ]
Con base en la versión ordenada del arreglo, se tiene:
1 >>> act(A, len(A), 0, 0)
2 6

El Teorema 4.1.4 presenta los resultados de correctitud de la función act, junto


con sus complejidades temporal y espacial.

Teorema 4.1.4

Si las actividades en A[0..N ) está ordenadas ascendentemente por tiempo


de finalización, entonces el llamado act(A, N, 0, 0) calcula φ(0, 0), y toma
tiempo y espacio O(N ). Si es necesario ordenar el arreglo, entonces la com-
plejidad temporal es O(N log N ).

Demostración
140 4. Algoritmos voraces

Note que la función act es una transcripción de la función φ, agregando los


parámetros A y N que nunca son modificados. El tiempo de ejecución de act es
linealmente proporcional a la cantidad de elementos en A[0..N ), i.e., es O(N ).
El espacio es O(N ) si no hay optimizaciones que se apliquen a los llamados
recurrentes.

Ejercicios

1. Diseñe un algoritmo de búsqueda exhaustiva que enumere todos los subconjun-


tos de actividades en A[0..N ) para resolver el problema de agendamiento.
2. Enumere todo los agendamientos óptimos del conjunto de actividades presen-
tado en el Ejemplo 4.1.1.
3. Explique por qué el problema de agendamiento de actividades exhibe las pro-
piedades de subestructura óptima y solapamiento. Ilustre la explicación con un
ejemplo.
4. Demuestre que cada una de las siguientes afirmaciones hechas en la demostra-
ción del Teorema 4.1.1 es cierta:
a) Si X 6= ∅, entonces Y 6= ∅.
b) Por suposición, Y no tiene conflictos.
c) El conjunto Z no tiene conflictos.
5. En el caso inductivo de la demostración del Teorema 4.1.2 se afirma que a0 , . . . , an−1
no puede ser un prefijo de b0 , . . . , bm−1 . Demuestre que esta afirmación es co-
rrecta.
6. En el caso inductivo de la demostración del Teorema 4.1.2 se afirma que ak y
bk son compatibles con las actividades en a0 , . . . , ak−1 . También se indica que
el tiempo de finalización de ak no puede ser mayor que el tiempo de finalización
de bk . Justifique por qué estas afirmaciones son ciertas.
7. La argumentación final en la demostración del Teorema 4.1.2 se basa, principal-
mente, en el hecho de que |Y | < |X|. ¿Por qué es necesario que esta desigualdad
sea estricta? Justifique claramente su respuesta.
8. Teniendo como marco la demostración del Teorema 4.1.2, demuestre o refute:
el agendamiento bk+1 , . . . , bm−1 es óptimo para Y . Ayuda: suponga lo contrario
y obtenga una contradicción en relación con la optimalidad del agendamiento
b0 , . . . , bm−1 para X.
9. En el planteamiento recurrente de φ, el caso en el cual n 6= N ∧ A[n][0] ≥ t
indica que la actividad n se considera como parte del agendamiento óptimo que
se está construyendo. ¿Por qué no hay conflicto si A[n][0] puede ser igual a t?
4.1. Agendamiento de actividades 141

10. Justifique con un ejemplo, por qué la suposición de ordenamiento de A[0..N )


es necesaria para que el Teorema 4.1.3 sea cierto.
11. Considere la siguiente función objetivo para el problema de agendamiento de
actividades, con 0 ≤ n ≤ N y t ∈ R≥0 ∪ {+∞}:
φ(n, t) : “máxima cantidad de actividades compatibles en A[0..n)
que no tienen conflicto con el intervalo [t.. + ∞).”
Para resolver el problema de agendamiento de actividades, el objetivo es cal-
cular φ(N, +∞). Proponga un planteamiento recurrente para φ, y diseñe un
algoritmo voraz que la calcule en tiempo O(N ) y espacio O(1).
12. Diseñe un algoritmo voraz que resuelva en tiempo O(N ) el problema de agen-
damiento bajo la suposición de que el arreglo A[0..N ) está ordenado:
a) Ascendentemente por tiempo inicio de las actividades.
b) Descendentemente por tiempo de inicio de las actividades.
13. La estrategia voraz diseñada en esta sección está basada en la idea de escoger
aquella actividad que termine lo más pronto posible entre aquellas que no ge-
neran conflicto, para posteriormente recurrir. Para cada una de las siguientes
estrategias voraces, o bien demuestre que sirve como regla de optimización local
para resolver el problema de agendamiento de actividades (si hay empates, se
deberı́a poder escoger cualquiera de las posibilidades) ó describa un pequeño
contraejemplo que iluestre por qué no sirve:
a) Escoja aquella actividad a que termina lo más tarde posible, descarte las
actividades que están en conflicto con a y recurra.
b) Escoja aquella actividad a que comienza lo más pronto posible, descarte
las actividades que están en conflicto con a y recurra.
c) Escoja aquella actividad a que comienza lo más tarde posible, descarte las
actividades que están en conflicto con a y recurra.
d ) Escoja aquella actividad a que tenga la menor cantidad de conflictos, des-
carte las actividades que están en conflicto con a y recurra.
e) Si no hay conflicto, escoja todas las actividades; de lo contrario, descarte
la actividad con la mayor duración y recurra.
f ) Si no hay conflicto, escoja todas las actividades; de lo contrario, descarte
la actividad con la mayor cantidad de conflictos y recurra.
14. Diseñe un algoritmo con programación dinámica para calcular φ, sin suponer
el principio de optimalidad local, y que resuelva el problema de agendamiento
de actividades, suponiendo que los tiempos de las actividades en A[0..N ) son
números naturales.
15. Diseñe un algoritmo iterativo que, en tiempo O(N ) y espacio O(1), permita
calcular la función φ.
142 4. Algoritmos voraces

4.2. Árboles de cubrimiento mı́nimo

Identificar un árbol de cubrimiento mı́nimo es un problema de optimización


sobre grafos. Dado un grafo no dirigido y conexo, con peso en sus arcos, el proble-
ma consiste en encontrar un subgrafo que cumpla con dos propiedades especı́ficas:
una estructural de ser conexo y sin ciclos, y otra de optimalidad en cuanto a la
menor suma posible de los pesos de sus arcos. Este problema, conocido en inglés
como minimum spanning tree (MST), ha sido ampliamente estudiado y se conocen
soluciones algorı́tmicas voraces eficientes, además relativamente fáciles de imple-
mentar. En esta sección se establecen los principios fundamentales sobre los cuales
están diseñados varios de algoritmos voraces que resuelven el problema de calcular
árboles de cubrimiento mı́nimo.
Antes de especificar el problema algorı́tmico es indispensable introducir for-
malmente algunos conceptos y términos útiles para su definición. En particular, se
precisan las nociones de árbol de cubrimiento y de minimalidad en este contexto a
partir de nociones de grafos que comúnmente se conocen.

Definición 4.2.1

Un árbol es un grafo no dirigido que es conexo y acı́clico. Dado un grafo


no dirigido G = (V, E) y una función de peso w : E → R>0 , se dice que
T = (VT , ET ) es un árbol de cubrimiento de G (con respecto a w) sii:
T es un árbol, VT = V y ET ⊆ E.
Además, se dice que T es un árbol de cubrimiento mı́nimo de G (con respecto
a w) sii ningún otro árbol de cubrimiento de G tiene una suma de pesos de
arcos (con respecto a w) menor que ET .

Intuitivamente, un árbol de cubrimiento mı́nimo de un grafo es un subgrafo que


conecta todos los vértices, sin ciclos y lo menos pesado posible. Un escenario común
en el cual se requieren árboles de cubrimiento mı́nimo es en el diseño de redes de
comunicaciones. Considere diferentes ubicaciones que deben ser conectadas y que
el costo de conectar dos ubicaciones es proporcional a su distancia. El objetivo es
conectar toda las ubicaciones usando el presupuesto más modesto posible cuando.
La solución deberá ser un árbol de cubrimiento mı́nimo.

Problema 4.2.1: Árbol de cubrimiento mı́nimo


4.2. Árboles de cubrimiento mı́nimo 143

Entrada: Un grafo G = (V, E) conexo con función de peso w : E →


R>0 .
Salida: Un árbol T = (V, ET ) de cubrimiento mı́nimo de G (con
respecto a w).

Una aproximación de fuerza bruta para resolver el problema consiste en enu-


merar todos los suconjuntos de E y determinar, entre aquellos que representen un
árbol de cubrimiento de G, uno de suma mı́nima. Como hay un total de Θ(2|E| )
subconjuntos de arcos de E, necesariamente este enfoque no es viable en la pŕacti-
ca. Aún observando que un árbol con vértices V tiene |V | − 1 arcos (¿por qué?), la
enumeración de los subconjuntos de |E| de tamaño |V | − 1 sigue siendo exponencial
en tamaño. Es decir, los enfoques de fuerza bruta no son una opción si se desea
considerar grafos con, al menos, centenares de arcos.

Ejemplo 4.2.1

El MST de un grafo dado no es necesariamente único. Por ejemplo, considere


el grafo de tres vértices que se dibuja a continuación:

1
a b

1 1

Este grafo tiene tres árboles de cubrimiento mı́nimo, dados por los siguientes
conjuntos de arcos, cada uno con suma de pesos 2: {(a, b), (b, c)}, {(a, b), (a, c)}
y {(a, c), (b, c)}.

La idea general para construir un MST de G = (V, E) con función de peso


w : E → R>0 usando un principio de optimización local es la siguiente: mantener
un conjunto de arcos A ⊆ E que cumpla con la siguiente condición:
C0 : A únicamente contiene arcos que hacen parte de un MST de G.
El reto consiste, entonces, en construir incrementalmente a A agregando nuevos
arcos, uno a la vez, mientras se respeta la condición C0 . De esta forma, cuando no
sea posible agregar nuevos arcos, A incluirá únicamente los arcos de un MST de G.
144 4. Algoritmos voraces

El diseño genérico del algoritmo descrito verbalmente hasta ahora se presenta en el


Algoritmo 4.2.1.

Algoritmo 4.2.1: Algoritmo genérico para construir un MST

1. A = ∅.
2. Mientras A no sea un MST de G:
a) Encontrar un arco seguro en E para extender A (e.g., e)
b) extender A con e.
3. Retornar A.

El algoritmo genérico está basado en el concepto de arco seguro. En este con-


texto, un arco e ∈ E es seguro para A sii A ∪ {e} es un subconjunto de arcos de un
MST de G. Es decir, lo que propone el Algoritmo 4.2.1 es aumentar un conjunto de
arcos seguros con nuevos arcos seguros, mientras sea posible, con el propósito que
ese conjunto maximal de arcos seguros en realidad constituya un MST de G.

Nota 4.2.1

Se presentan algunas definiciones que serán útiles en los resultados que se


presentan en el resto de la sección:
Un corte de V es una pareja (S0 , S1 ) tal que {S0 , S1 } es una partición de
V (se permite que alguno entre S0 y S1 sea vacı́o).
Un arco e ∈ E cruza un corte (S0 , S1 ) de V sii uno de los vértices en e
está en S0 y el otro está en S1 .
Un corte (S0 , S1 ) de V respeta un subconjunto de arcos E 0 ⊆ E sii ningún
arco en E 0 cruza (S0 , S1 ).
Un arco e ∈ E es ligero para un corte (S0 , S1 ) de V sii w(e) es mı́nimo
entre todos los arcos que cruzan (S0 , S1 ) (note que puede haber más de
un arco ligero para un corte (S0 , S1 ) de V .)

El resultado que se presenta a continuación es la base matemática sobre la cual


funcionan los algoritmos voraces comúnmente utilizados para calcular el MST de
un grafo. Este no es más que el principio de optimización local que también se
identificó para el problema del agendamiento de actividades en la Sección 4.1.

Teorema 4.2.1: Optimización local


4.2. Árboles de cubrimiento mı́nimo 145

Sea A ⊆ E tal que cada uno de sus arcos hace parte de un MST de G y
(S, V \ S) un corte de V que respeta a A. Si (u, v) ∈ E es un arco ligero que
cruza (S, V \ S), entonces (u, v) es seguro para A.

Demostración

El grafo G tiene un MST dado que es conexo (y no dirigido); sea T = (V, ET )


un MST de G tal que A ⊆ ET . Note que T existe con esta caracterı́stica por
la suposición sobre A. Se procede por casos:
Si (u, v) ∈ ET , entonces A ∪ {(e, v)} ⊆ ET . Por ende, (u, v) es seguro para
A.
Si (u, v) ∈
/ ET , entonces note que (V, ET ∪ {(u, v)}) no es un árbol (¿por
qué?). Como T es un MST de G, hay un camino único (¿por qué?) entre
u y v en T . Dado que u y v están en “lados” opuestos de (S, V \ S), al
menos un arco en el camino de u a v cruza este corte. Sea (x, y) dicho arco:
observe que (x, y) no está en A porque (S, V \ S) respeta a A. Como el
camino simple entre u y v es único en T , remover (x, y) de ET desconecta
T en dos componentes. Estos dos componentes se pueden volver a unir
agregando el arco (u, v). Es decir, T 0 = (V, (ET \ {(x, y)}) ∪ {(u, v)}) es
un árbol de cubrimiento de G.
El objetivo ahora es demostrar que el árbol de cubrimiento T 0 de G
es de costo mı́nimo. Para ello, note lo siguiente:
w(T 0 ) = (+e | e ∈ (ET \ {(x, y)}) ∪ {(u, v)} : w(e))
= (+e | e ∈ ET : w(e)) + w(u, v) − w(x, y)
≤ (+e | e ∈ ET : w(e))
= w(T ).

El paso de la desigualdad es posible porque (u, v) es ligero con respecto


a (S, V \ S). Por otra parte, se sabe que T es un MST de G. Luego
w(T ) ≤ w(T 0 ). De las dos desigualdades se concluye
w(T ) = w(T 0 ).
Es decir, T 0 es un MST de G. Como A∪{(u, v)} está contenido en los arcos
de T 0 , la propiedad deseada es cierta. Es decir, los arcos en A ∪ {(u, v)}
hacen parte de un MST de G.

Finalmente, con ayuda del Teorema 4.2.1 se puede obtener la correctitud del
Algoritmo 4.2.1. Esto quiere decir que, al concretar las nociones de corte y arco
146 4. Algoritmos voraces

ligero resulta un algoritmo para calcular el MST del grafo G con función de peso
w.

Teorema 4.2.2: Optimización global

El Algoritmo 4.2.1 calcula un MST de G.

Demostración

Se propone como ejercicio al lector (ayuda: suponer que no es ası́ y llegar a


una contradicción con base en el Teorema 4.2.1).

Como el Algoritmo 4.2.1 no es lo suficientemente concreto, no es útil aún tratar


de determinar sus complejidades temporal y espacial. Lo que debe ser claro es, de
forma general, que la complejidad es polinomial si la operación para determinar si
un arco es seguro toma tiempo polinimial.

Ejercicios

1. Sea G = (V, E) un grafo no dirigido y conexo, y w : E → R>0 una función de


peso para los arcos. Demuestre que si T = (V, ET ), con ET ⊆ E, es un subgrafo
conexo de G de costo mı́nimo (con respecto a w), entonces T es un árbol (de
cubrimiento mı́nimo de G).
2. Sea T un árbol con N vértices. Demuestre que T tiene N − 1 arcos.
3. Demuestre que la noción de árbol en la Definición 4.2.1 es equivalente a la
siguiente definición:
Un árbol es un grafo en el cual existe exactamente un camino simple
(i.e., sin repeticiones) entre cualquier par de vértices.
4. En la demostración del Teorema 4.2.1 se indica que se puede escoger un MST
T = (V, ET ) de G tal que A ⊆ ET . Demuestre que dicha afirmación es cierta,
i.e., que tal T árbol existe.
5. El el caso cuando (u, v) ∈
/ ET , en la demostración del Teorema 4.2.1, se hacen
las siguientes afirmaciones:
a) El grafo (V, A ∪ {(e, v)}) no es un árbol.
b) En T hay un camino único entre u y v.
6. Demuestre el Teorema 4.2.2.
Capı́tulo 5

Decidibilidad y completitud

Suponga por un momento que alguien recién graduado está en un proceso de


selección con miras a una posición de programador en una red social famosa. Esta
persona se ha preparado árduamente para la siguiente entrevista en la cual se
valorarán sus conocimientos técnicos y prácticos en algoritmos. Por ello, el recién
graduado ha estudiado algoritmos y practicado sus implementaciones, con una y
mil variantes, en varios lenguajes de programación.
En la entrevista, se formula la siguiente pregunta: ¿se comprometerı́a a resolver
en un par de meses el problema de determinar el tamaño máximo de usuarios de
la red social en el cual todos son amigos? La pregunta viene acompañada de un
ofrecimiento con un jugoso salario de varios cientos de miles de dólares anuales.
Aprovechando sus conocimientos en grafos, el sujeto de la entrevista se da cuenta
que el problema computacional, básicamente, es determinar el tamaño máximo de
un subgrafo completo (i.e., la red social es el grafo y el subgrafo es el grupo de
amigos).

Problema 5.0.1: Grupo de amigos

Entrada: Un grafo G con N vértices, N ≥ 0


Salida: El tamaño máximo de un subgrafo completo en G.

Se sabe que hay varios súpercomputadores a disposición de la compañı́a, con ca-


pacidades de cómputo inimaginables, ası́ que hay un panorama prometedor. Todo
parece estar dado para que el recién graduado acepte la prometedora vacante y
para que, en un par de meses, el problema haya sido resuelto. Lamentablemente, el

147
148 5. Decidibilidad y completitud

recién graduado decide no aceptar la propuesta argumentando que serı́a irrespon-


sable comprometerse a resolver el problema formulado. Quien lidera el proceso de
entrevista agradece al recién graduado por su honestidad, y le ofrece la posición
con un salario mayor y sin tener que comprometerse a resolver el problema de los
amigos. Para muchas personas lo usual habrı́a sido que la compañı́a optara por
rechazar al candadito y terminar allı́ el proceso de selección; ¿qué sucedió?
Hace varias décadas se avanzó significativamente en la tarea de identificar pro-
blemas para los cuales no se conocen soluciones eficientes, es decir, para los cuales
no se conocen algoritmos cuya complejidad temporal sea polinomial (o mejor/me-
nor que polinomial). Como parte de este grupo, fueron identificados problemas de
lógica, optimización, combinatoria e inteligencia artificial. Sin embargo, la tarea ha
sudo muy dı́cil y, al dı́a de hoy, el proceso se encuentra en el “peor” de dos mundos.
Por una parte, para los problemas identificados no se conocen soluciones eficien-
tes. Por otra parte, no hay demostraciones que garanticen que dichas soluciones no
existen. Es decir, la tarea de identificación aún se encuentra en una zona gris. Es,
además, un área fértil para la investigación, sobretodo por las implicaciones que
tienen estos problemas en la sociedad actual. Por ejemplo, el hecho de contar con
transacciones seguras en lı́nea depende, básicamente, de no contar con algoritmos
para descomponer números en factores primos rápidamente. Los problemas para
los cuales no se concen soluciones que corren en tiempo polinomial son denomina-
dos intratables, dado que cualquier solución conocida corre en tiempo exponencial
(o peor) en función del tamaño de la entrada. Si una red social tiene millones de
usuarios, cualquier algoritmo exponencial en su tamaño está lejos de terminar de
ejecutarse en decenas de siglos.
En este capı́tulo se presenta una introducción a la teorı́a de la NP-completitud,
un grupo de problemas cuyo estatus –en relación con la existencia de soluciones
eficientes- es desconocido. Alrededor de esta clase de problemas se han obtenido
resultados intersantes como, por ejemplo, la factibilidad de poderlos resolver efi-
cientemente bastando con que uno de ellos tenga una solución eficiente. Además,
esta exploración permite presentar y explicar una de las preguntas abiertas más
interesantes en el campo de la computación (y quizás una de las más importantes
de todas las ciencias exactas): la de determinar si el poder de cómputo de los algo-
ritmos determinı́sticos polinomiales es igual al de los algoritmos no determinı́sticos
polinomiales. Usualmente, esta pregunta se formula como P vs. NP y es tan im-
portante (y famosa) que quien la resuelva tiene derecho a un premio de un millón
de dólares. A pesar de que numerosos intentos han fracasado desde los 1970s en
reclamar este premio, se cree que P 6= NP.
Para abordar el estudio de la NP-completitud se estudian los problemas den-
tro del marco de los lenguajes formales. Se limita, sin pérdida de generalidad, el
estudio a problemas de decisión y se caracterizan las nociones de decidibilidad e
5.1. Problemas de decisión 149

indecidibilidad asociadas a este tipo de problemas. Esta exploración se hace con un


lenguaje matemático básico (e.g., conjuntos, cadenas, funciones) y posteriormente,
para introducir la noción de indecidibilidad, se utilizan cardinales, contables y no
contables. Es decir, además de estudiar las clases P y NP, y la noción de completi-
tud de NP, se estudia la frontera existente entre lo que puede y no puede resolver
mecánicamente un computador. El capı́tulo culmina con demostraciones que permi-
ten concluir que algunos problemas, como el del grupo de amigos en la entrevista,
es en realidad (hasta ahora) intratable.
Desde un punto de vista práctico, encontrarse con un problema NP-completo
es una invitación a limitar las expectativas de encontrar un algoritmo eficiente que
lo resuelva. En realidad, esto serı́a equivalente a pretender diseñar un algoritmo
eficiente para resolver los problemas clásicos que han sido estudiados por años, y
con los cuales han fracasado –hasta ahora- numerosas mentes en el intento. Quizás,
eso fue lo que reconoció inmediatamente el recién graduado en su entrevista, y
también por ello fue el mensaje de agradecimiento de quien le entrevistaba.

5.1. Problemas de decisión

Un problema de decisión es un problema algorı́tmico para el cual el conjunto de


posibles respuestas es ‘sı́’ o ‘no’. Generalmente, siguen un patrón de especificación.

Problema 5.1.1: Especificación tı́pica de un problema de decisión

Entrada: Un conjunto X ⊆ U y un elemento x ∈ U .


Salida: ¿Está x en X (i.e., x ∈ X)?

Se usa U para identificar un conjunto universo que sirve de referencia de X y que


se supone puede ser inferido del contexto del problema. La pregunta de decisión
consiste en determinar si un elemento de interés (en este caso x) hace parte del
conjunto dado (en este caso X).
A modo de ejemplo, se pueden encunciar algunos problemas de decisión conoci-
dos usando el formato de especificación tı́pica del problema de decisión presentado
en el Problema 5.1.1.

Ejemplo 5.1.1

Considere los problemas que se enuncian a continuación, identificando versio-


nes concretas de X, U y x.
150 5. Decidibilidad y completitud

Clique: determinar si un grafo (no dirigido) tiene un subgrafo completo de


un tamaño k ∈ N dado. En este caso, X representa el conjunto de grafos
con un subgrafo completo de tamaño k, U el conjunto de grafos (finitos)
y x el grafo dado.
Primalidad: determinar si un número es primo. En este caso, X representa
el conjunto de números primos, U el conjunto de números naturales y x
el número sobre el cual se desea averiguar primalidad.
SAT: determinar si una proposición es satisfacible. En este caso, X co-
rresponde al conjunto de proposiciones satisfacibles, U al conjunto de
proposiciones y x a una proposición.
2Partition: determinar si un conjunto de números naturales puede ser di-
vido exactamente en dos subconjuntos tal que la suma de sus elementos
coincida. En este caso, X corresponde al conjunto de conjuntos finitos
de números naturales que pueden ser particionados en dos subconjuntos
con igual suma, U el conjunto de todos los conjuntos finitos de números
naturales y x el conjunto dado.

La importancia de los problemas de decisión radica en dos fenómenos funda-


mentales. Primero, los problemas de decisión son, a la vez, suficientemente fáciles de
entender, y suficientemente complejos para plasmar la naturaleza de la mayorı́a de
los problemas computacionales y abordar el estudio de la intratabilidad. Segundo,
problemas de optimización y de conteo pueden ser reformulados y resueltos a partir
de un problema de decisión y una de sus soluciones. La investigación en el área de
teorı́a de complejidad se enfoca tı́picamente en problemas de decisión, la cual ha si-
do ampliamente desarrollada en las últimas décadas, permeando diferentes campos
del conocimiento dentro y fuera de la ingenierı́a y las ciencias.

Ejemplo 5.1.2

Considere el Problema 3.6.1 del agente viajero: dado un grafo G = (V, E)


completo, con función w : E → R≥0 de peso sobre los arcos, determinar el
costo mı́nimo (con respecto a w) de un circuito en G.
Este problema de optimización cuenta con el siguiente problema de deci-
sión asociado:

Entrada: un grafo G = (V, E) completo, con función w : E → R≥0


de peso sobre los arcos y un número real x.
Salida: ¿Existe en G un circuito cuyo costo sea a lo sumo x?
5.1. Problemas de decisión 151

En la práctica, el problema de optimización original se puede resolver usando


como caja negra (e.g., como una función que responde sı́ o no) un algoritmo
que resuelva el problema de decisión asociado aplicando bisección. Note que
para un error de precisión razonable (e.g., 10−9 ), la cantidad de llamados que
se hacen a la caja negra está acotada por un polinomio. Como conesecuencia,
la dificultad computacional intrı́nseca de resolver el problema de optimización
con base en una solución al problema de decisión no es mayor asintóticamente
a una que se pueda dar directamente al problema de optimización, aún si esta
última es polinomial.

Ejercicios

1. Considere una función φ : [0..109 ] → {0, 1} no decreciente y suponga que para


cualquier x ∈ [0..109 ], calcular φ(x) cuesta tiempo polinomial. Demuestre que
un algoritmo de bisección sobre [0..109 ] para determinar el mı́nimo valor x tal
que φ(x) = 1 toma tiempo polinimial.
2. Investigue acerca de la lista de 21 problemas NP-completos publicada por Ri-
chard Karp en el artı́culo “Reducibility Among Combinatorial Problems” de
1972. Escoja 3 de ellos, preferiblemente entre aquellos que no conozca, y espe-
cifı́quelos como problemas de decisión siguiendo el formato propuesto en esta
sección.
3. Considere el Problema 3.5.1 del morral. Siguiendo el desarrollo del Ejemplo 5.1.2,
reformule el problema de optimización original con base en un problema de deci-
sión asociado. Explique claramente cómo una solución de la versión reformulada
sirve para resolver el problema de optimización original.
4. El problema de satsifacibilidad de una proposición en forma normal conjuntiva
(o CNFSAT) se puede formular como un problema de optimización en función
de la cantidad de cláusulas. Explique cómo hacer esta formulación.
5. Suponga que se desea determinar la longitud máxima de un ciclo simple (i.e., sin
repetir vértices) en un grafo dirigido. Defina un problema de decisión asociado y
resuelva el problema original suponiendo que hay una solución para el problema
de decisión. Calcule la complejidad temporal de la solución planteada como
función de la complejidad temporal del algoritmo de decisión.
6. Investigue acerca de las cientı́ficos de la computación Stephen Arthur Cook y
Leonid Levin, y explique brevemente cuál fue su principal aporte al estudio de
problemas intratables.
152 5. Decidibilidad y completitud

5.2. Un marco universal basado en lenguajes

Los conceptos de clases de lenguajes que se trabajan en este capı́tulo se definen


en términos de lenguajes formales y máquinas de Turing. Dado que el requisito de
tiempo polinomial es suficientemente general, el tratamiento en este capı́tulo no
ahonda en los detalles de las máquinas de Turing. En realidad, cualquier algoritmo
ejecutable por una máquina de acceso aleatorio (en inglés, random access machine)
puede ser simulado por una máquina de Turing de una sola cinta y una sola cabeza
con un factor polinimial de costo de cómputo. Por ello, esta sección se centra en los
lenguajes formales.

Definición 5.2.1

Un alfabeto es un conjunto finito de sı́mbolos. Un lenguaje (formal) L sobre


un alfabeto Σ es un conjunto de cadenas formadas con los sı́mbolos de Σ. Se
usan los sı́mbolos λ para denotar la cadena vacı́a, ∅ el lenguaje vacı́o y Σ∗ la
colección de todas las cadenas sobre Σ.

De acuerdo con las convenciones introducidas en la Definición 5.2.1, cualquier


lenguaje L sobre un alfabeto Σ es en realidad un subconjunto de Σ∗ , i.e., L ⊆
Σ∗ . Una ventaja de los lenguajes es que sobre ellos se pueden definir fácilmente
operaciones de interés.

Definición 5.2.2

Sea Σ un alfabeto:
El complemento L de un lenguaje L ⊆ Σ∗ es el conjunto
L = Σ∗ \ L.
La concatenación L1 L2 de dos lenguajes L1 , L2 ⊆ Σ∗ es el conjunto
L1 L2 = {s1 s2 | s1 ∈ L1 ∧ s2 ∈ L2 }.
La concatenación generalizada Lk , con k ≥ 0, de un lenguaje L ⊆ Σ∗ es
el conjunto definido inductivamente, para cualquier n ∈ N, de la siguiente
manera:
L0 = {λ}
Ln+1 = Ln L.
Las operaciones de unión (i.e., ∪ ), intersección (i.e., ∩ ) y potencia (i.e.,
P( )) son las usuales.
5.2. Un marco universal basado en lenguajes 153

El complemento L de un lenguaje L es la colección de las cadenas que no están


en L. La concatenación L1 L2 es el conjunto de cadenas que pueden ser partidas
en exactamente dos partes: la primera parte está en L1 y la segunda en L2 . La
concatenación generalizada Lk es el conjunto de cadenas que se construyen a partir
de la concatenación de k cadenas en L. De acuerdo con la definición de conjunto
potencia heredada de la teorı́a de conjuntos, las expresiones L ⊆ Σ∗ y L ∈ P(Σ∗ )
son equivalentes.

Ejemplo 5.2.1

Considere cada una de las siguientes situaciones.


Si Σ = {0, 1, . . . 9} (i.e., el conjunto de los dı́gitos en base 10), entonces
L1 = {w | w ∈ Σ∗ ∧ w 6= λ}
es el cojunto de los números naturales (algunos repetidos) en representa-
ción decimal y
L2 = {w | w ∈ L1 ∧ “w termina en 0”}
es el conjunto de números naturales múltiplos de 10 en representación
decimal.
Si Σ es el alfabeto, entonces
L1 = {w | w ∈ Σ∗ ∧ w 6= λ ∧ “w está en la base de datos de la RAE”}
es la colección de palabras del Castellano y
L2 = L1
la colección de palabras que no son castizas.

Nota 5.2.1

La inclinación por usar Σ∗ para denotar el conjunto de todas las cadenas sobre
Σ no es caprichosa. Obedece al hecho de que este conjunto corresponde a la
clausura de Kleene de Σ, escrita Σ∗ , y definida como:
[
Σ∗ = Σn ,
n∈N

es decir, el conjunto de todas las cadenas finitas sobre el alfabeto Σ.

El alfabeto de interés en el desarrollo de esta sección y este capı́tulo, sin pérdida


de generalidad, es el binario (i.e., Σ = {0, 1}). Con ello se supone la existencia de
154 5. Decidibilidad y completitud

una función inyectiva que identifica cualquier elemento o tupla de elementos (de un
universo contable) con una secuencia binaria en {0, 1}∗ .

Nota 5.2.2

Se usa la notación h i para denotar una función de codificación con rango


en Σ∗ . Por ejemplo, h5i identifica el código binario que representa a 5, mien-
tras que h“abracadabra”i el código binario que representa la cadena “abra-
cadabra”. Esta notación se puede usar sobre otros tipos de estructuras como
grafos, matrices, etc.

Es importante anotar que existen varias funciones de codificación. Sin embargo,


hacer distinciones no es relevante para el tratamiento que sigue. Además, note que
elegir una función de codificación en particular es coherente con la situación que
se tiene en los computadores que procesan y calculan volúmenes brutales de infor-
mación representada en cadenas de bits: cada sistema de archivos puede usar un
formato distinto. Tampoco es indispensable que los códigos sean binarios; por ejem-
plo, pueden ser ternarios o usar cualquier otro alfabeto que garantice que cualquier
conversión de códigos binarios al alfabeto de elección esté acotada polinomialmente.
A las codificaciones que respetan esta restricción se les denominan codificaciones
razonables. Por ejemplo, el alfabeto {1} (i.e., el unario) no es razonable; ¿por qué?
Finalmente, una bondad de usar lenguajes formales como marco general de
representación de problemas computacionales tiene que ver con la facilidad para
formular problemas de decisión. Note que, de acuerdo con la especificación tı́pica
de un problema de decisión en la Sección 5.1, un problema de decisión es entonces
una pregunta sobre la pertentencia o no de una cadena binaria a un lenguaje.

Problema 5.2.1: Especificación de un problema de decisión con lenguajes

Entrada: Un conjunto L ⊆ {0, 1}∗ y un elemento x ∈ {0, 1}∗ .


Salida: ¿x ∈ L?

Gracias al uso de lenguajes formales, y a la transparencia de contar con las máquinas


de Turing como modelo de computación abstracto, en las próximas secciones es
posible abordar las definiciones y el estudio de las clases P y NP, los conceptos de
decisión, aceptación y completitud, y la técnica de reducción polinomial con algo
más que un poco de teorı́a de conjuntos básica.
5.3. Aceptación y decisión 155

Ejercicios

1. Investigue acerca de máquinas de acceso aleatorio (en inglés, random access


machine) y explique su relación con los computadores modernos.
2. Investigue acerca de máquinas de Turing. Elabore una justificación de la si-
guiente afirmación: cualquier algoritmo ejecutable por una máquina de acceso
aleatorio puede ser simulado por una máquina de Turing de una sola cinta y
una sola cabeza con un factor polinimial de costo de cómputo.
3. Explique con ejemplos y justifique por qué el alfabeto unario no es razonable.

5.3. Aceptación y decisión

El objetivo de esta sección es presentar y estudiar las nociones de aceptación


y decisión asociadas a los lenguajes formales. Esto se hace con base en funciones
totales y parciales, respectivamente. Al igual en el resto del capı́tulo, en esta sección
se fija el alfabeto binario Σ = {0, 1} y, mientras no se precise, una función puede
ser total o parcial.
Haber adoptado el marco de los lenguajes formales permite expresar concisa y
consistentemente la relación entre problemas de decisión y la forma en que estos se
pueden resolver.

Definición 5.3.1: Aceptación y rechazo

Sea A : Σ∗ → Σ una función (total o parcial). Para x ∈ Σ∗ , se dice que:


A acepta x sii A(x) = 1 y
A rechaza x sii A(x) = 0.
El lenguaje aceptado por A, denotado L(A), es el conjunto:
L(A) = {x | x ∈ Σ∗ ∧ A(x) = 1}.

En el marco de los lenguajes formales, la inclinación es por representar una


solución a un problema de decisión como una función que, dada una palabra binaria,
calcula un valor binario. El hecho de permitir que dicha función sea total o parcial
será evidente una vez se introduzcan las nociones de aceptación y decisión para los
lenguajes. Por convención, aceptar una cadena se indica con el valor ‘Booleano’ 1 y
rechazarla con el 0, similar a lo que se tiene en la lógica Booleana implementada en
los computadores de hoy en dı́a. Dada una función A, se asocia a esta el lenguaje
L(A) que corresponde exactamente a las cadenas aceptadas por A.
156 5. Decidibilidad y completitud

Ejemplo 5.3.1

Considere las siguientes afirmaciones para una función A : Σ∗ → {0, 1}:


Si A(x) = 1 para cualquier x ∈ Σ∗ , entonces L(A) = Σ∗ . Hacia una
contradicción, suponga lo contrario: es decir, que L(A) 6= Σ∗ . Entonces,
necesariamente L(A) ( Σ∗ . En consecuencia, hay una cadena s ∈ Σ∗ \
L(A). Por definición, A(s) 6= 1 dado que s no está en L(A) (¿por qué no
se puede afirmar que A(s) = 0?). Pero esto es una contradicción dado que
se ha supuesto que A(x) = 1 para cualquier x ∈ Σ∗ ; en particular, para
s. Luego, necesariamente L(A) = Σ∗ .
Si A(x) = 0 para cualquier x ∈ Σ∗ , entonces L(A) = ∅. La justificación
se propone como ejercicio al lector.

En la justificación de la primera parte del Ejemplo 5.3.1, se concluye que A(s) 6=


1 cuando s ∈ / L(A), pero no que A(s) = 0. Este es un aspecto muy sutil, pero
extremadamente importante que se da al permitir que las funciones como A puedan
ser no totales. Note que es posible que A(s) sea indefinido, es decir, que una cadena
binaria s sea uno de los puntos de indeterminación de A (i.e., para el cual A no
tiene asociado ni 0 ni 1).
Es usual encontrar casos en los cuales se diseña un algoritmo con base en otro
existente (algo ası́ como construir nuevas funciones en lenguaje de programación
con base en funciones existentes). Uno de estos casos corresponde al de algoritmos
que invierten la salida de otro algoritmo que encapsulan.

Definición 5.3.2

Sea A : Σ∗ → {0, 1} una función. La función complementaria de A, denotada


A, se define para cualquier x ∈ Σ∗ como:
A(x) = 1 si A(x) = 0,
A(x) = 0 si A(x) = 1 y
A(x) es indeterminada si A(x) es indeterminada.

Cuando A en la Definición 5.3.2 es total, el complemento del lenguaje determi-


nado por A y el lenguaje determinado por su función complementaria A coinciden.

Teorema 5.3.1
5.3. Aceptación y decisión 157

Si A : Σ∗ → {0, 1} es una función total, entonces:


L(A) = L(A).

Demostración

Sea x ∈ Σ∗ . El objetivo es demostrar:


x ∈ L(A) ⇐⇒ x ∈ L(A).
Se procede por doble implicación.
(=⇒): Se supone x ∈ L(A) con el objetivo de demostrar que x ∈ L(A) o,
equivalentemente, que x ∈ / L(A). Como x ∈ L(A), se tiene que A(x) = 1.
Por definición de función complementaria, se tiene que A(x) = 0. Es decir,
x∈/ L(A).
(⇐=): Se supone x ∈ L(A) con el objetivo de demostrar que x ∈ L(A).
Como x ∈ L(A), entonces x ∈ / L(A). O bien A(x) = 0 o A(x) es inde-
terminado. Por la definición de función complementaria y la suposición
de que A es total, no se puede dar que A(x) sea indeterminado. Es decir,
necesariamente A(x) = 0. Por definición de A, se tiene que A(x) = 1,
como se deseaba.
En conclusión, L(A) = L(A).

El hecho de que una función A determine el lenguaje L(A), no permite concluir


que necesariamente A rechaza cualquier cadena x ∈ / L(A). En la práctica, esto puede
suceder cuando un ciclo no termina o cuando hay un problema en la ejecución
del algoritmo. Con este preámbulo se motiva la introducción de las nociones de
aceptación y decisión asociadas a los lenguajes.

Definición 5.3.3: Aceptación y decisión de un lenguaje

Sea L un lenguaje y A : Σ∗ → {0, 1} una función. Se dice que:


A acepta L sii para cualquier x ∈ L se tiene que A(x) = 1.
A decide L sii para cualquier x ∈ L se tiene que A(x) = 1 y para cualquier
x∈/ L se tiene que A(x) = 0.

La Definición 5.3.3 invita a varias observaciones importantes. Primero, toda


función de decisión es una de aceptación, pero no necesariamente al contrario. Se-
gundo, si una función A acepta L, independientemente de si A es solo de aceptación
o si es de decisión, L = L(A). Tercero, un lenguaje puede ser aceptado por más
158 5. Decidibilidad y completitud

de una función pero decidido solo por una única (¿por qué?). Finalmente, cual-
quier función total necesariamente es una función de decisión; el converso también
es cierto, es decir, cualquier función de decisión es total. Esta última observación,
en el marco de los lenguajes formales, es especialmente importante porque asocia
unı́vocamente la noción de decisión de un lenguaje a la de totalidad de una función.

Teorema 5.3.2

Si A : Σ∗ → {0, 1} una función, entonces:


A es de decisión ⇐⇒ A es total.

Demostración

Se propone como ejercicio al lector.

Ejercicios

1. Proponga condiciones suficientes para que la siguiente igualdad sea cierta para
A : Σ∗ → {0, 1}:

L(A) = {x | x ∈ Σ∗ ∧ L(A) = 0}.

2. Suministre una justificación formal para la segunda afirmación en el Ejem-


plo 5.3.1.
3. Sea A : Σ∗ → {0, 1}. Demuestre o refute:
a) L(A) ∪ L(A) = Σ∗ .
b) L(A) ∪ L(A) = Σ∗ .
4. Presente los detalles de la siguiente afirmación, en el marco de la demostración
del Teorema 5.3.1:
Por la definición de función complementaria y la suposición de que A
es total, no se puede dar que A(x) sea indeterminado.
Sugerencia: procer por contradicción.
5. Formule un ejemplo de un problema que es aceptado exactamente por una única
función de aceptación.
6. Justifique brevemente por qué toda función de decisión es de aceptación, pero
no viceversa.
7. Justifique brevemente por qué si una función A acepta L, independientemente
de si A es solo de aceptación o si es de decisión, L = L(A).
5.3. Aceptación y decisión 159

8. Defina un lenguaje L, y dos funciones distintas A1 y A2 tales que L = L(A1 ) =


L(A2 ).
9. Demuestre el Teorema 5.3.2.
Bibliografı́a

[BB88] Gilles Brassard and Paul Bratley, Algorithms: Theory and practice, Prentice
Hall, 1988.
[Bei13] Wolfgang Bein, Advanced techniques for dynamic programming, pp. 41–92,
Springer, New York, NY, 2013.
[Bel84] Richard Bellman, Eye of the hurricane: an autobiography, World Scientific,
1984.
[Bha15] Harsh Bhasin, Algorithms: Design and analysis, Oxford University Press, 2015.
[Boh06] Jaime Bohórquez, Diseño efectivo de programas correctos, Escuela Colombiana
de Ingenierı́a, 2006.
[CLRS09] Thomas Cormen, Charles Leiserson, Ronald Rivest, and Clifford Stein, Intro-
duction to algorithms, 3rd ed., MIT Press, 2009.
[Coh90] Edward Cohen, Programming in the 1990s: an introduction to the calculation
of programs, Springer-Verlag, 1990.
[Dij76] Edsger Wybe Dijkstra, A discipline of programming, Prentice-Hall, 1976.
[Eri19] Jeff Erickson, Algorithms, 2019.
[Gri81] David Gries, The science of programming, Springer-Verlag, 1981.
[HHE20] Steven Halim, Felix Halim, and Suhendry Effendy, Competitive programming
4: Book I, LuLu, 2020.
[Kal90] Anne Kaldewaij, Programming: the derivation of algorithms, Prentice-Hall,
1990.
[KET06] Jon Kleinberg and Éva Tardos, Algorithm design, Pearson, 2006.
[Knu88] Donald Knuth, The art of computer programming: Volume 3 / sorting and
searching, 2nd ed., Addison-Wesley, 1988.
[Lev12] Anany Levitin, Introduction to the design and analysis of algorithms, 3rd ed.,
Pearson, 2012.

161
162 Bibliografı́a

[Rou17] Tim Roughgarden, Algorithms illuminated I: The basics, 2017.


[Rus08] John Rust, Dynamic programming, pp. 1–26, Palgrave Macmillan, 12 2008.
[SF13] Robert Sedgewick and Philippe Flajolet, An introduction to the analysis of
algorithms, 2nd ed., Addison-Wesley Professional, 2013.
[Ski08] Steven S. Skiena, The algorithm design manual, Springer London, 2008.
Índice alfabético

Al-Khwarismi, 12 código Python iterativo, 71


Musa, Mohamed ibn, 12 diseño, 67, 69
algoritmo, 12–14 especificación del problema, 67
Algoritmos voraces, 131 terminación, 71
análisis asintótico, 21
función simple, 36 ciclo iterativo
regla de constantes, 31 condición de terminación, 57
regla de la suma, 34 coeficiente binomial, 90
regla del producto, 34
subsunción por suma, 32 dividir y conquistar, 43
teorema maestro, 37 y combinar, 43
transitividad, 33
arreglo, 1 eficiencia algorı́tmica, 21
especificación, 4
como función
entrada, 4
co-dominio, 3
lenguajes, 16
dominio, 3
parámetro, 5
indexación, 2
salida, 4
ı́ndice, 1
sección, 2
función
subarreglo, 2
de Fibonacci, 79
tamaño, 1, 2
implementación adhoc, 81
vacı́o, 2
implementación con memorización,
83
Babbage, Charles, 12 implementación con tabulación, 87
Bellman, Richard, 75 simple, 36
búsqueda binaria, 67 función
complejidad temporal, 38, 71 complementaria, 156
correctitud, 70
código Python, 69 grafo, 117

163
164 Índice alfabético

arcos, 117 de agendamiento de actividades, 132


completo, 117 de la ruta más corta, 77
dirigido, 117 de la ruta simple más larga, 77
no-dirigido, 117 de ordenamiento de un arreglo, 49,
vértices, 117 55, 60
de suma exacta de un subconjunto,
inducción matemática, 43, 44 117
caso base, 44 de suma máxima de un subarreglo, 93
caso inductivo, 44 de teselación de un tablero
hipótesis inductiva, 44 generalizado, 44
invariante, 55 de un árbol de cubrimiento mı́nimo,
estabilidad, 57 142
iniciación, 57 decisión, 149
decisión con lenguaje formal, 154
Kadane, Jay, 95
del agente viajero, 117, 118
Knuth, Donald, 11
del morral, 102
lenguaje instancia, 6
aceptación de, 157 solución, 7
decisión de, 157 solución de una instancia, 7
lenguaje formal, 152 tamaño de la entrada, 22
aceptación, 155 problema algorı́tmico
operaciones, 152 solución, 14
rechazo, 155 programación dinámica
Lovelace, Ada, 12 tabulación, 82
programación dinámica, 75
máscara de bits, 121 memorización, 82
conjunto unitario, 123 metodologı́a, 91
conjunto universo, 122 propiedad de solapamiento, 77
eliminación de un elemento, 123 propiedad de subestructura, 76
pertenencia, 123 tabulación, 86
mergesort, 60
código Python, 61 solución, 7, 14
complejidad espacial, 65
Teorema Maestro, 37
complejidad temporal, 38, 65
tiempo pseudo-polinomial, 110, 116
correctitud, 63, 64
Turing, Alan, 13
diseño, 60

notación asintótica
O, 25
Ω, 28
Θ, 28

problema, 4
algorı́tmico, 4
árbol de cubrimiento mı́nimo, 142
cálculo de números de Fibonacci, 80

También podría gustarte