You are on page 1of 194

                        

RESUMENES 
PROGRAMACIÓN III 
      
 
Curso 2008 ‐ 2009 
 
 

 
 
Resumen de programación 3

Tema 1. Preliminares.

Alumna: Alicia Sánchez


Centro: UNED-Las Rozas (Madrid)
Índice:

1.1. Introducción ……………………………………………………… 3


1.2. ¿Qué es un algoritmo? …………………………………………… 3
1.3. Notación para los programas …………………………………….. 5
1.4. Notación matemática …………………………………………….. 5
1.5. Técnica de demostración 1: Contradicción ……………………… 5
1.6. Técnica de demostración 2: Inducción matemática ……………... 6
1.6.1. El principio de inducción matemática ……………………... 6
1.7. Recordatorio ……………………………………………………... 7

Bibliografía:
Se ha tomado apuntes del libro:

 Fundamentos de algoritmia. G. Brassard y P. Bratley

Resumen tema 1 Curso 2007/08 Página 2 de 7


Previo a ver este resumen decir que conviene leerse este tema, ya que así nos iremos
familiarizando con la algoritmia y determinados conceptos, como la demostración
por inducción. Este resumen es complementario al libro, por lo que conviene
estudiarlo junto.
1.1. Introducción
En los problemas veremos estos pasos a seguir para resolverlos, que será
bastante importante el tenerlo claro:
- Elección del esquema (voraz, vuelta atrás, divide y vencerás).
- Identificación del problema con el esquema.
- Estructura de datos.
- Algoritmo completo (con pseudocódigo).
- Estudio del coste.
A lo largo de nuestro temario seguiremos este planteamiento:
- Empezaremos a ver el estudio del coste (temas 2, 3 y 4).
- Luego veremos la estructura de datos (tema 5).
- Para a continuación ver los distintos esquemas, que los dividiremos en 3,
siguiendo este mismo orden (es recomendado):
1. Esquema voraz (tema 6).
2. Exploración de grafo, que incluyen exploración en anchura,
profundidad, vuelta atrás y ramificación y poda (tema 9).
3. Esquema de divide y vencerás (tema 7).
A continuación, daremos unas nociones básicas previas antes de entrar en nuestra
planificación del temario, de nuevo lo hacemos para que vayan sonando los
conceptos.
En este tema, así como en el tema 2 no tendremos problemas del mismo. Por lo
que se tendrá que estudiar la teoría dada, que aunque es introductoria siempre
conviene hacerlo.

1.2. ¿Qué es un algoritmo?


Un algoritmo es un conjunto de reglas para efectuar algún cálculo, bien sea a
mano o, más frecuentemente, en una máquina. Nos interesan los algoritmos
utilizados en una computadora. La ejecución de un algoritmo no debe de
implicar ninguna decisión subjetiva ni tampoco debe de hacer preciso el uso de
la intuición y de la creatividad.
Es importante decidir cuál de los algoritmos escoger de entre una gama de ellos
para resolver un problema. Dependiendo de nuestras prioridades y de los límites
del equipo que esté disponible para nosotros, quizá necesitemos seleccionar el
algoritmo que requiera menos tiempo, o el que utilice menos espacio, o el que
sea más fácil de programar, y así sucesivamente.
La Algoritmia es la ciencia que nos permite evaluar el efecto de estos diferentes
factores externos sobre los algoritmos disponibles, de tal modo que sea posible
seleccionar el que más se ajuste a nuestras circunstancias particulares; también
es la ciencia que nos indica la forma de diseñar un nuevo algoritmo para una
tarea concreta.

Resumen tema 1 Curso 2007/08 Página 3 de 7


Ejemplo: multiplicación de dos números enteros. Para hacerlo tendremos estos
métodos:
- Algoritmos “clásicos”: Son similares a la multiplicación con lápiz y
papel de los dos números enteros. Para ello, tendremos la multiplicación
inglesa y americana, donde la única diferencia reside en que se multiplica
de izquierda a derecha o viceversa.
- Algoritmo de multiplicación “a la russe”: Para ello seguiremos este
procedimiento:
1. Se hacen dos columnas poniendo el número menor a la izquierda
y el mayor a la derecha. El de la izquierda se divide por 2 hasta
llegar a 1 y el de la derecha se multiplica por dos.
2. Se eliminan las filas en las cuales el número de la columna
izquierda sea par y se suman los números que quedan en la
columna de la derecha. Es la más parecida a la que se emplea en
el hardware de una computadora binaria.
- Algoritmo mediante divide y vencerás: Seguiremos estos pasos por este
orden (separado respecto al libro para que sea mayor su comprensión):
1. Necesitamos tener el multiplicando y multiplicador con el mismo
número de cifras múltiplo de dos, si no se añadirían ceros, si
hiciera falta. Multiplicamos la mitad izquierda del multiplicando
por la mitad izquierda del multiplicador y ponemos el resultado
desplazado hacia la izquierda tantas veces como cifras haya en el
multiplicador: cuatro en nuestro caso.
2. Luego la mitad izquierda del multiplicando con la derecha del
multiplicador y desplazamos dos a la izquierda.
3. Después, multiplicamos la mitad derecha del multiplicando por la
izquierda del multiplicador y desplazamos dos a la izquierda.
4. Por último, ambas mitades derechas y no desplazamos ninguna
posición. Para finalizar, sumamos estos cuatro resultados
intermedios.
Hemos reducido la multiplicación de dos números de cuatro cifras a
cuatro multiplicaciones de números de cuatro cifras, junto con un cierto
número de desplazamientos y una suma final.

De entre los cuatro algoritmos ya vistos, este último reduce la multiplicación de


dos números grandes a tres y no cuatro, por lo que sería el más eficiente. La meta
de nuestro libro es enseñar a tomar este tipo de decisiones.

Resumen tema 1 Curso 2007/08 Página 4 de 7


1.3. Notación para los programas.
Es importante decidir la forma en que vamos a describir nuestros algoritmos.
Para ello no nos limitaremos en ningún lenguaje de programación concreto (lo
que denominaremos pseudocódigo): de esta manera, los aspectos esenciales de
un algoritmo no resultarán oscurecidos por detalles de programación
relativamente poco importantes y no importa cuál sea el lenguaje bien
estructurado que prefiere el lector.
Ejemplo: Tenemos esta función con la notación descrita anteriormente:
funcion rusa (m, n)
resultado ← 0;
repetir
si m es impar entonces resultado ← resultado + n;
← ÷ 2;
← + 1;
hasta que = 1;
devolver resultado;

1.4. Notación matemática.


Se usará la notación matemática para:
- Calculo proposicional.
- Teoría de conjuntos.
- Enteros, reales e intervalos.
- Funciones y relaciones.
- Cuantificadores.
- Sumas y productos.

1.5. Técnica de demostración 1: Contradicción.


La demostración por contradicción, o prueba indirecta, consiste en demostrar
la veracidad de una sentencia demostrando que su negación da lugar a una
contradicción.
Ejemplo: Se nos da un teorema que dice que existen infinitos números reales, el
cual demostraremos por contradicción para ello, realizaremos estos pasos
(usaremos esta demostración en los esquemas voraces, por ello es importante el
concepto):
- Empezaremos suponiendo lo contrario al teorema, en este caso, que existe un
conjunto finito, para así buscar una contradicción.
- Si terminamos en una afirmación evidentemente falsa podemos deducir que
la primera sentencia es verdadera.

Resumen tema 1 Curso 2007/08 Página 5 de 7


1.6. Técnica de demostración 2: Inducción matemática.
Es una de las herramientas básicas útiles en la Algoritmia. Tendremos dos
enfoques básicos opuestos fundamentales:
1. Inducción: Consiste en inferir una ley general a partir de casos
particulares. En general no se puede confiar en el resultado del
razonamiento inductivo. Mientras que haya casos que no hayan sido
considerados, sigue siendo posible que la regla general inducida sea
correcta. Aunque no se puede despreciar este enfoque.
Ejemplo: Consideremos el polinomio ( )= + + 41. Si
computamos valores como ( )
0 , ( )
1 , ( 2 ,…, (10), se va
)
encontrando 41, 43, 47,… y 151. Es fácil verificar que todos estos
enteros son números primos. Por tanto, es natural inferir por inducción
que ( ) es primo para todos los valores enteros de n, pero (40) =
1.681 = 41 es compuesto. Deducimos entonces que es una falsa
inducción.
Hay veces que es necesario usar la inducción porque es el único método,
como, por ejemplo, en el descubrimiento del cometa Halley, hecho a
partir de datos obtenidos en experimentos.
- Deducción: Es una inferencia de lo general a lo particular. Por contraste,
el razonamiento deductivo no está sometido a este tipo de errores.
Siempre y cuando la regla invocada sea correcta, y sea aplicable a la
situación que se estudia, la conclusión que se alcanza es necesariamente
correcta.
Una de las técnicas deductivas más útiles que están disponibles en
matemáticas se llama inducción matemática. No hay que confundir con
el otro enfoque, que aunque se parezca en el nombre no es similar.

1.6.1. El principio de inducción matemática.


Consideremos el siguiente algoritmo:
funcion cuadrado (n)
si = 0 entonces devolver 0
si no devolver 2 ∗ + ( − 1) − 1
Si se hace pruebas en varias entradas pequeñas, observamos que
cuadrado (0) = 0; cuadrado (1) = 1; cuadrado (2) = 4;…
Por inducción, parece evidente que cuadrado( ) = para todos los ≥ 0.

Definición: Este principio dice que la propiedad P es cierto para todo ≥


si:
1. ( ) es cierto.
2. ( ) debe ser cierto siempre que ( − 1) sea válido para todos los
enteros > .
A veces, las demostraciones mediante inducción matemática se pueden
transformar en algoritmos, como puede ser el ejemplo del embaldosado.

Resumen tema 1 Curso 2007/08 Página 6 de 7


1.7. Recordatorio.
Se nos dan recordatorios, tales como limites, sumas de series sencillas,
combinatoria y probabilidad. En ejercicios posteriores los usaremos, por lo que
evitamos dar más detalles.

Resumen tema 1 Curso 2007/08 Página 7 de 7


 
Resumen de programación 3

Tema 2. Algoritmia elemental.

Alumna: Alicia Sánchez


Centro: UNED-Las Rozas (Madrid)
Índice:

2.3. La eficiencia de los algoritmos …………………………………... 3


2.4. Análisis de “caso medio” y “caso peor”………….…………….… 4
2.5. ¿Qué es una operación elemental? ………………………...…....... 7
2.6. Más factores de tiempo …………………………………………... 8

Bibliografía:
Se ha tomado apuntes del libro:

 Fundamentos de algoritmia. G. Brassard y P. Bratley

Resumen tema 2 Curso 2007/08 Página 2 de 8


Nos hemos saltado los apartados 2.1 y 2.2, que corresponden con la introducción y
problemas y ejemplares respectivamente, ya que lo siguiente es más propio del tema.
Por ello, empezaremos por el 2.3. Por otro lado, comentar que este tema ha sido
costoso el resumirlo debido a que el resto de temas serán más complicados de
estudiar y conviene entender bien el concepto básico de la algoritmia, que junto con
el tema 1 no tiene ejercicios.
2.3. La eficiencia de los algoritmos.
Cuando tengamos que resolver un problema es posible que estén disponibles
varios algoritmos adecuados. Deseamos seleccionar el mejor posible, tal y como
vimos en la introducción (tema 1). Para ello tendremos dos enfoques:
- El enfoque empírico (o a posteriori): Consiste en programar las técnicas
comparadoras e ir probándolas en distintos casos con ayuda de una
computadora.
- El enfoque teórico (o a priori): Es el que nosotros propugnamos en este
libro. Consiste en determinar matemáticamente la cantidad de recursos
necesarios para cada uno de los algoritmos como función del tamaño de
los casos considerados. Nos interesarán en especial estos recursos:
1. Tiempo de computación: Es el que usaremos a lo largo del libro,
por tanto, será el más importante a tener en cuenta.
2. Espacio de almacenamiento: Indica el espacio que ocupa un
algoritmo. No lo emplearemos en este tema y posteriores.
Compararemos los algoritmos tomando como base sus tiempos de ejecución.
Cuando hablemos de la eficiencia de un algoritmo querremos decir lo rápido que
se ejecuta y en esto consistirá nuestro análisis del coste (temas 3 y 4).
Utilizaremos la palabra tamaño para indicar cualquier entero que mida de alguna
forma el número de componentes de un ejemplar. Daremos algunas veces la
eficiencia de nuestros algoritmos en términos del valor del ejemplar que estemos
considerando en lugar de considerar su tamaño.
La ventaja de la aproximación teórica es que no depende ni de la computadora
que se esté utilizando ni del lenguaje de programación ni siquiera de las
habilidades del programador.
Si deseamos medir la eficiencia de un algoritmo en términos del tiempo que
necesita para llegar a una respuesta, entonces no existe una opción tan evidente.
Se puede expresar en segundos, al no disponer de una computadora estándar a la
cual referir todas las medidas.

Los factores para determinar el tiempo necesario del algoritmo son:


1. Implementación: Depende de:
 Plataforma.
 Lenguaje.
 Compilador.
2. Tamaño del problema: Un problema grande necesitará más tiempo.
3. Contenido de los datos: Cómo vienen ordenados los datos.

Resumen tema 2 Curso 2007/08 Página 3 de 8


Al no poder referir todas las medidas en una computadora estándar tendremos
este principio:
Principio de invarianza: Corresponde con el primer factor antes citado, que
es la implementación.
Definición: Dos implementaciones distintas de un mismo algoritmo no
diferirán en su eficiencia en más de una constante multiplicativa. Si dos
implementaciones del mismo algoritmo necesitan ( ) y ( ) segundos,
respectivamente, para resolver un cado de tamaño n, entonces siempre existen
constantes positivas c y d, tales que ( ) ≤ ∗ ( ) y ( ) ≤ ∗ ( )
siempre que n sea suficientemente grande:
Implementación 1 ( ) ( )≤ ∗ ( )
Implementación 2 ( ) ( )≤ ∗ ( )
El principio sigue siendo cierto sea cual fuere la computadora utilizada para
implementar el algoritmo. Para un problema más grande va a tardar lo mismo
en la misma proporción, salvo por una constante multiplicativa, denominada
en estos casos constante oculta.

2.4. Análisis de “caso medio” y “caso peor”


Analizaremos el coste de este algoritmo, que corresponde con el de la ordenación
por inserción:
procedimiento insertar ([1. . ])
para ← 2 hasta n hacer
← [ ]; ← − 1;
mientras > 0 y < [ ] hacer
[ + 1] ← [ ] ;
← − 1;
fmientras
[ + 1] ←
fpara
fprocedimiento
La nomenclatura empleada será:
←: Asignaciones.
mientras .. fmientras, para .. fpara,…: Bucles mientras, para,…

Como norma general para analizar el coste realizaremos estos pasos:


1. Análisis del funcionamiento: Simularemos su funcionamiento, usando
para ello ejemplos de distintos valores de vectores, …
2. A continuación, analizaremos el coste propiamente dicho.

Resumen tema 2 Curso 2007/08 Página 4 de 8


En nuestro ejemplo tendremos lo siguiente, aunque lo retomaremos en el
resumen del tema 7, no obstante lo veremos en este apartado:
1. Análisis del funcionamiento:
i

Parte ordenada j Parte desordenada

La idea básica es insertar un elemento dado en el lugar que le


corresponda de una parte ordenada del vector. En un primer paso, se
considera que la parte ordenada está formada por el primer elemento del
vector. Entonces, se elige el elemento de la posición 2 y se inserta en la
parte ordenada, de manera que si debe estar en la posición anterior se
desplaza el elemento de la posición 1 hacia la derecha, haciéndole sitio, y
se inserta en la posición 1. Como puede observarse se requerirá de una
variable temporal que almacene el elemento a insertar. Seguidamente, se
considera la parte del vector [1] … [2] ordenada, se elige el tercer
elemento y se siguen las mismas opciones. Este método se repite hasta
que el último elemento [ ] haya sido tratado.
En resumen, el algoritmo puede describirse de la siguiente forma:
- Tomar un elemento en la posición i.
- Buscar en su lugar en las posiciones anteriores.
- Mover hacia la derecha los restantes.
- Insertarlo.

2. Análisis del coste:


El algoritmo de ordenación por inserción es:
procedimiento insertar ([1. . ])
(1) para ← 2 hasta n hacer
(2) ← [ ]; ← − 1;
(3) mientras > 0 < [ ] hacer
[ + 1] ← [ ]
(4)
← −1
fmientras
(5) [ + 1] ←
fpara
fprocedimiento

Tendremos en cuenta estos tiempos:


: Asignación : Comparación : Resta
: Incremento : Acceso al vector

Resumen tema 2 Curso 2007/08 Página 5 de 8


Las distintas sentencias del algoritmo anterior son:
(1) + ( − 1) ∗ + ∗
siendo:
: Asigna i el valor 2 ( ← 2).
: Incrementa − 1 veces (de 2 a n).
: Compara de 2 a n, incluido n, que sale del bucle.

(2) (2 ∗ + + ) ∗ ( − 1)
siendo:
: Significa que hay dos asignaciones ( ← [ ] ← − 1)
: Indica acceso al vector ( [ ]).

(3) (2 ∗ + )∗ ( , )
(4) (2 ∗ +2∗ + + )∗ ( , )
siendo:
( , ): Número de iteraciones del bucle “mientras” en cada
pasada del bucle “para”. Depende de i y del vector T, por lo que
será variable.

El tiempo total del bucle “mientras” será la suma de (3) y (4):


(3) + (4) (3 ∗ +2∗ +2∗ + + )∗ ( , )

(5) ( + + ) ∗ ( − 1)

Hemos visto los tiempos que emplean. Luego analizaremos los casos
peores y mejores, aunque el caso promedio ya veremos que no es tan fácil
hallarlo, ya que requiere un cono cimiento a priori acerca de la
distribución de los casos que hay que resolver. Esto suele ser un requisito
poco realista.
Normalmente, consideraremos el caso peor del algoritmo, esto es, para
cada tamaño de caso sólo consideraremos aquéllos en los cuales el
algoritmo requiera más tiempo, a no ser que se indique lo contrario. Suele
ser más difícil analizar el comportamiento medio del algoritmo que
hacerlo en el caso peor.

Resumen tema 2 Curso 2007/08 Página 6 de 8


2.5. ¿Qué es una operación elemental?
Corresponderá también como el principio de invarianza con el primer factor
visto previamente, recordemos que era la implementación.
Definición: Una operación elemental es aquélla cuyo tiempo de ejecución
puede ser acotada superiormente por una constante que sólo dependerá de la
implementación particular usada: de la máquina, del lenguaje de programación,
etc.
De esta manera, la constante no depende ni del tamaño ni de los parámetros del
ejemplar que se esté considerando. Pueden ser operaciones tales como sumas,
restas, multiplicaciones, divisiones, accesos a un vector, operaciones booleanas,
comparaciones.
Veremos al igual que el anterior algoritmo esta parte en el tema 7, no obstante
hay que hacer hincapié en la definición, porque se usa mucho y es importante.

Un ejemplo: Supongamos que cuando se analiza algún algoritmo, encontramos


que para resolver un caso de un cierto tamaño se necesita efectuar a adiciones, m
multiplicaciones y s instrucciones de asignación.
Supongamos también que se sabe que una suma nunca requiere más de ms.,
que una multiplicación nunca requiere más de ms. y que una asignación
nunca requiere más de ms., donde , y son constantes que dependen de
la máquina utilizada. El tiempo total T requerido por nuestro algoritmo estará
acotado por:

≤ ∗ + ∗ + ∗ ≤ max ( , , )∗( + + )

siendo:
, , : Variables según la implementación.
, , : Constantes que cambiarán de una implementación a otra.

T estará acotado por un múltiplo constante del número de operaciones


elementales (ejecutadas a coste unitario) que hay que ejecutar.
En conclusión, el primer factor del tiempo de ejecución visto anteriormente, que
es la implementación no afectará mucho al coste del algoritmo.

Resumen tema 2 Curso 2007/08 Página 7 de 8


2.6. Más factores de tiempo
En cuanto a los otros factores del tiempo de algoritmo trataremos los dos que
nos quedan los siguientes (añadido del autor, tomando apuntes de otra persona).
Recordemos que hemos visto previamente la implementación (apartado 2.3):

 El contenido de los datos:


En nuestro ejemplo de la ordenación por inserción tendremos estos casos:

1. Vector ya ordenado:
i

Parte ordenada j Parte desordenada

Nunca se entrará en el bucle “mientras” (mientras > 0 y


< [ ]) por no cumplirse la condición de < [ ]. Por tanto,
( , )= .
Recordemos que ( , ) es el número de iteraciones del bucle
“mientras” en cada pasada del bucle “para”.

2. Vector completamente desordenado (de mayor a menor):


Se recorrerán todos los elementos de la parte desordenada hasta
encontrar su posición. En este caso, ( , ) = .

 Tamaño del problema:


Siguiendo el ejemplo anterior tendremos:
(1) Coste n
(2) Coste n
Coste
- Caso mejor 0
(3) + (4)
- Caso peor ∑

(5) Coste n

Caso mejor: Vector ya ordenado. Tiene coste lineal (n).


Caso peor: Vector completamente desordenado.
∗( )
+∑ ≈ (∑ = ≈ )
Caso promedio: En general haremos el análisis para el caso peor. No es
normal hacerlo para el caso promedio.

Resumen tema 2 Curso 2007/08 Página 8 de 8


Resumen de programación 3

Tema 3. Notación asintótica.

Alumna: Alicia Sánchez


Centro: UNED-Las Rozas (Madrid)
Índice:

3.1. Introducción ……………………………………………………... 3


3.2. Una notación para “el orden de” ………………………………… 3
3.3. Otra notación asintótica.………………………………………….. 7

Bibliografía:
Se ha tomado apuntes del libro:

 Fundamentos de algoritmia. G. Brassard y P. Bratley

Resumen tema 3 Curso 2007/08 Página 2 de 8


Este tema al igual que ha pasado con los anteriores resumirlo cuesta, ya que en
muchas ocasiones se añaden extras para completar el tema que cuesta integrarlos.
Por ello, habrá algunas cosas que no queden del todo claro, para ello se debería
mirar en el libro, aunque de nuevo se ha tratado de poner los conceptos más
importantes.
3.1. Introducción.
Recuérdese que deseamos determinar matemáticamente la cantidad de recursos
que necesita el algoritmo en función del tamaño (o a veces del valor) de los
casos considerados. Dado que no existe una computadora estándar con la cual se
puedan comparar las medidas de tiempo de ejecución, vimos también en el
apartado 2.3 que nos contentaremos con expresar el tiempo requerido por el
algoritmo salvo una constante multiplicativa (denominada constante oculta).
Esta notación se denomina asintótica porque trata acerca del comportamiento de
funciones en el límite, esto es, para valores suficientemente grandes de su
parámetro.

3.2. Una notación para “el orden de”


Tendremos una función arbitraria de los números naturales en los reales no
negativos, tal como ( ) = 27 ∗ + 355/133 ∗ + 12. Se puede pensar que
n representa el tamaño del ejemplar sobre el cual es preciso que se aplique un
algoritmo dado y en ( ) como representante de la cantidad de un recurso dado
que se invierte en ese ejemplar por una representación particular de este
algoritmo.
Podría ser que la implementación invirtiera ( ) ms. o quizá ( ) represente la
cantidad de espacio. Tal como se ha visto, la función ( ) puede muy bien
depender de la implementación más que únicamente del algoritmo. Recuerde el
principio de invarianza que dice que la razón de los tiempos de ejecución de
dos implementaciones diferentes del mismo algoritmo siempre está acotada por
encima y por debajo mediante constantes predeterminadas.
Consideremos una función : ℕ → ℝ como ( ) = . Diremos que ( ) está
en el orden de ( ) si ( ) está acotada superiormente por un múltiplo real
positivo de ( ) para todo n suficientemente grande. Matemáticamente, esto
significa que existe una constante real y positiva c y un umbral , tal que
( ) ≤ ∗ ( ), siempre que ≥ .
Por ejemplo, considérese las funciones ( ) y ( ) definidas anteriormente. Está
claro que tanto ≤ como 1 ≤ , siempre que ≥ 1. Por tanto, siempre y
cuando ≥ 1 tendremos:
( ) = 27 ∗ + ∗ + 12 ≥ 27 ∗ + ∗ + 12 ∗ = 42 ∗ ∗
= 42 ∗ ∗ ( ).
Tomando = 42 ∗ 16/113 = 1. Concluiremos que ( ) es del orden de
( ) por cuanto ( ) ≤ ∗ ( ), siempre que ≥ .

Resumen tema 3 Curso 2007/08 Página 3 de 8


De esta manera, si la implementación de un algoritmo requiere en el caso peor
un tiempo de 27 ∗ + 355/133 ∗ + 12 ms. Para resolver un caso de tamaño
n, podríamos simplificar diciendo que el tiempo está en el orden de . Hay algo
más importante: el orden de no solamente indica el tiempo requerido por una
implementación particular del algoritmo, sino también (por el principio de
invarianza) el requerido por cualquier implementación.
Por tanto, tenemos derecho a afirmar que el algoritmo en sí requiere un tiempo
que está en el orden de n2 o que requiere un tiempo cuadrado (independiente de
la implementación).
Es conveniente disponer de un símbolo matemático para representar el orden de.
Sea : ℕ → ℝ una función arbitraria de los números naturales en los reales no
negativos. Le indicará mediante ( ) el conjunto de todas las funciones
: ℕ → ℝ tales que ( ) ≤ ∗ ( ), para todo ≥ para una constante
positiva c y un umbral entero . En otras palabras:
( ) ≡ { : ℕ → ℝ |∃ ∈ ℝ , ∈ ℕ, ∀ ≥ | ( ) ≤ ∗ ( )}
Gráficamente sería:

( )

( )

(umbral)
siendo:
: Cierto umbral del tamaño del problema.
( ): Acota superiormente a la función ( ).

Deduciendo del grafico anterior, podremos decir que habrá un cierto umbral que
permitirá acotar superiormente el problema.
Ejemplo: Para representar que n2 es del orden de n3 lo haremos así:
∈ ( )

Como en teoría de conjuntos, se usa el símbolo ∈. El umbral de suele resultar


útil para simplificar argumentos, pero nunca es necesario para funciones
estrictamente positivas. Sean , : ℕ → ℝ dos funciones de los números
naturales en los reales estrictamente positivos. La regla del umbral afirma que
( )∈ ( ) si y sólo si existe una constante real positiva, tal que
( )∈ ( ) para cada número natural n.

Resumen tema 3 Curso 2007/08 Página 4 de 8


Una regla útil para demostrar que una función es del orden de otra es la regla del
máximo. Sea , : ℕ → ℝ dos funciones arbitrarias de los números naturales
en los reales no negativos, La regla del máximo dice que
( ), ( ) = máx ( ), ( ) .

Más específicamente, sean , : ℕ → ℝ definidas para todo número natural n


mediante ( ) = ( ) + ( ) y ( ) = máx ( ), ( ) y consideremos una
función arbitraria : ℕ → ℝ , la regla del máximo dice que ( ) ∈ ( ) si y
sólo si (⟺) ( ) ∈ ( ) .
Ejemplo: Consideremos un algoritmo que se realiza en tres pasos: Inicialización,
procesamiento y finalización. Supongamos que estos pasos requieren un tiempo
de ( ), ( ) y ( ∗ log( )), respectivamente. Queda claro que el algoritmo
completo requiere un tiempo ( + + ∗ log( )). Por la regla del
máximo tendríamos:
( + + ∗ log( )) = á ( , , ∗ log( )) = ( )
Aún cuando el tiempo requerido por el algoritmo sea lógicamente la suma de los
tiempos requeridos por sus partes separadas, veremos que lo determinará la parte
que más consuma, siempre y cuando el número de partes sean constantes,
independientemente del tamaño de la entrada.

Demostración: Demostraremos la regla del máximo para el caso de dos


funciones. Observamos que:
( )+ ( )= ( ), ( ) + á ( ), ( )
y
0 ≤ ( ), ( ) ≤ á ( ), ( )

Se sigue entonces que:


á ( ), ( ) ≤ ( ) + ( ) ≤ 2 ∗ á ( ), ( )
De lo que podemos deducir que:
- La cota inferior indica que una de las dos funciones será máxima con
respecto a la otra. En este caso, sería la función máxima ( ) ( ) .
- La cota superior es cuando las dos funciones son máximas.

Considérese ahora cualquier ( ) ∈ ( ) + ( ) . Sea c una constante


adecuada tal que ( ) ≤ ∗ ( ) + ( ) para todo n suficientemente grande.
Se sigue que ( ) ≤ 2 ∗ máx ( ) + ( ) . Por tanto, ( ) está acotado
superiormente por un múltiplo real y positivo (2 ) de máx ( )+ ( ) ,
para todo n suficientemente grande, lo cual demuestra que
( ) ∈ máx ( )+ ( ) .

Resumen tema 3 Curso 2007/08 Página 5 de 8


Mostraremos varios ejemplos de uso incorrecto de la regla del máximo:
1. El siguiente razonamiento es erróneo:
( )= ( + − )= máx(n, n , n ) = ( )
Hemos usado la regla del máximo con alguna de las funciones cuando
son negativas con infinita frecuencia.

2. Tenemos esta función ( ) = 12 ∗ log( ) − 5 + ( ) + 36


Razonando incorrectamente tendremos:
( ) = á (12 ∗ log( ), − 5 , log ( ) + 36) =
∗ log ( ) .
No usamos esta regla correctamente por ser −5 negativo. El
razonamiento correcto sería:
( ) = (11 ∗ log( ) + ∗ log( ) − 5 + log ( ) +
36) = á (11 ∗ log( ) , ∗ log( ) , −5 + log ( ) +
36) = ∗ log( ) .
Por último, falta por decir que no especificamos la base del algoritmo dentro de
la notación asintótica, porque podremos emplear las operaciones de los
logaritmos para cambiar la base sin que afecte al tiempo.

Propiedades de el orden de:


 Reflexiva: ( ) ∈ ( ) para toda función : ℕ → ℝ .
Por ejemplo: ( ) ≤ 2 ∗ ( )

 Transitiva: ( ) ∈ ( ) ( )∈ ℎ( ) ⇒ ( ) ∈ ℎ( ) .
La demostración será:
∃ ∈ℝ , ∈ℕ ∀ > , ( )= ∗ ( ) Por definición.
,
∃ ∈ℝ , ∈ℕ ∀ > , ( )= ∗ ℎ( ) Por definición.
( )≤ ∗ ( )≤ ∗ ∗ ℎ( ) ( )∈ ℎ( )

El primer ≤ se cumple por la expresión ≥ y el segundo por la


siguiente expresión ≥ max ( , , ).
Deducimos que existirá, por tanto, un umbral tal que quede acotado el
valor.

Resumen tema 3 Curso 2007/08 Página 6 de 8


Para demostrar que una función dada no pertenece al orden de otra función ( )
tendremos estas formas:
 Demostración por contradicción: Es la forma más sencilla. Consiste en
demostrar la veracidad de una sentencia demostrando que su negación da
lugar a una contradicción.
 La regla del umbral generalizado: Implica la existencia de una constante
real y positiva tal que ( ) ≤ ∗ ( ) para todos los ≥1
(tomaremos como 1, nos interesa más la definición dada por la regla
del umbral sin generalizar).
 La regla del límite: Lo definiremos completamente tras analizar la cota
superior y el coste exacto.

3.3. Otra notación asintótica.


La notación Omega: Necesitamos tener una notación dual para cotas inferiores.
Esto es la notación . Considérese una vez más dos funciones , : ℕ → ℝ de
los números naturales en los números reales no negativos. Diremos que ( ) está
en Omega ( ) de ( ), lo cual se denota como ( ) ∈ ( ) , si ( ) está
acotada inferiormente por un múltiplo real positivo de ( ) para todo n
suficientemente grande.
Matemáticamente, esto significa que existe una constante real positiva y un
umbral entero tal que ( ) ≥ ∗ ( ) siempre que ≥ .
( ) ≡ { : ℕ → ℝ |∃ ∈ ℝ , ∈ ℕ, ∀ ≥ | ( )≥ ∗ ( )}
Gráficamente sería:

( )≈ ( ) ∈ ( ) : Cota inferior.
( )∈ ( ) : Cota superior.
( )

(umbral)

La regla de la dualidad se definirá así:


( ) ∈ ( ) ⇔ ( )∈ ( )
Demostramos la implicación de la derecha (⇒)
⇒∃ ∈ ℝ , ∈ ℕ, ∀ ∈ . ( )≥ ∗ ( )⇒ ∗ ( ) ≥
( ) ⇒ ( ) ≤ ∗ ( ).

Por la definición, deducimos que ( ) ∈ ( ) .

Resumen tema 3 Curso 2007/08 Página 7 de 8


La notación Theta: Diremos que ( ) está en Theta de ( ), o lo que es igual
que ( ) está en el orden exacto de ( ) y lo denotamos ( ) ∈ ( ) , si
( ) pertenece tanto a ( ) como a Ω ( ) .

La definición formal de es:


( ) = ( ) ∩Ω ( ) .
Por tanto,
( ) ≡ { : ℕ → ℝ |∃ , ∈ ℝ , ∈ ℕ, ∀ ≥ |
∗ ( )≤ ( )≤ ∗ ( )}.
Decimos que el conjunto del orden exacto está acotado tanto inferior como
superiormente por ( ). Podemos probarlo tanto por la definición como por la
regla del límite, aunque preferiremos en los ejercicios hacerlo por este segundo
método.

La regla del límite: Nos permite comparar dos funciones en cuanto a la


notación asintótica se refiere. Tendremos que calcular el siguiente límite:
( )
lim → .
( )

Al resolver el limite se nos darán 3 posibles resultados:


( )
1. lim → ( )
= ∈ ⇒

( )∈ ( ) ( )∈ ( ) ( )∈ ( )
⇒ .
( )∈ ( ) ( )∈ ( ) ( )∈ ( )
Estas funciones se comportan igual, diferenciándose en una constante
multiplicativa.

( )
2. lim → ( )
=∞⇒

( )∉ ( ) ( )∈ ( ) ( )∉ ( )
⇒ .
( )∈ ( ) ( )∉ ( ) ( )∉ ( )
Por muy alta que sea la constante multiplicativa de ( ) nunca superará a
( ).

( )
3. lim → ( )
=0⇒

( )∈ ( ) ( )∉ ( ) ( )∉ ( )
⇒ .
( )∉ ( ) ( )∈ ( ) ( )∉ ( )
( ) crece más exponencialmente que ( ), por lo que sería su cota superior.

Resumen tema 3 Curso 2007/08 Página 8 de 8


Resumen de programación 3

Tema 4. Análisis de algoritmos.

Alumna: Alicia Sánchez


Centro: UNED-Las Rozas (Madrid)
Índice:
4.1. Introducción ……………………………………………………... 3
4.2. Análisis de las estructuras de control ……………………………. 3
4.2.1. Secuencias .………………………………………………… 3
4.2.2. Sentencia condicional (if) ………………………………….. 4
4.2.3. Bucles “para” (desde) ……………………………………… 4
4.2.4. Llamadas recursivas ………………………………………. . 7
4.2.5. Bucles “mientras” (while) y “repetir” (repeat) …………….. 9
4.3. Uso de un barómetro ………………………………………….… 10
4.5. Análisis del caso medio …………………………………………. 11
4.6. Resolución de recurrencias ……………………………………… 12

Bibliografía:
Se ha tomado apuntes de los libros:
 Fundamentos de algoritmia. G. Brassard y P. Bratley
 Estructuras de Datos y Algoritmos. R. Hernández

Resumen tema 4 Curso 2007/08 Página 2 de 12


Este tema es el último de los pertenecientes a costes y de los que más ejercicios
cortos han entrado en exámenes. Por ello, merece la pena pararse sobre todo en las
fórmulas para hallar el coste de la recursividad. Nos saltamos el apartado 4.4, que
trata de ejemplos adicionales, ya que los iremos incluyendo en el resto del tema.
4.1. Introducción.
El objetivo principal de este libro es enseñar a diseñar algoritmos eficientes.
Para resolver el mismo problema con varios algoritmos es preciso decidir cuál de
ellos es el más adecuado para la aplicación considerada, recordemos que eso lo
hemos visto en el tema 1. Una herramienta esencial para este propósito es el
análisis de los algoritmos, aunque no tendremos una fórmula mágica para hallar
la eficiencia de los algoritmos.
Existen técnicas básicas que suelen resultar útiles, tales como saber la forma de
enfrentarse a estructuras de control y a ecuaciones de recurrencia, que será lo que
tratemos en este capítulo.
Añadiremos en este capítulo el análisis del bucle “if” para completarlo aun más.

4.2. Análisis de las estructuras de control.


El análisis de los algoritmos suele efectuarse desde dentro hacia fuera.
Seguiremos estos pasos:
- En primer lugar, se determina el tiempo requerido por las instrucciones
individuales (suele estar acotado por una constante).
- Después se combinan estos tiempos de acuerdo con las estructuras del
programa.
En esta sección ofreceremos unos principios generales que resultan útiles en
aquellos análisis relacionados con las estructuras de control de uso más
frecuente, así como ejemplos de la aplicación de estos principios (para
mientras,…).

4.2.1. Secuencias.
Sean y dos fragmentos de un algoritmo (instrucciones o subalgoritmos).
Sean y los tiempos requeridos por y , respectivamente. Estos
tiempos pueden depender de distintos parámetros tales como el tamaño del
caso.
La regla de la composición secuencial dice que el tiempo necesario para
calcular " ; ", esto es, primero y después , es simplemente + .
Por la regla del máximo este tiempo está en á ( , ) , es decir, como
vimos previamente el coste del algoritmo lo determinará el más ineficiente.
Ejemplo:
→ ( )
( )= ( )+ ( )
→ ( )
siendo:
( ): Lo que cuesta la primera función.
( ): Lo que cuesta la segunda función.
Resumen tema 4 Curso 2007/08 Página 3 de 12
El coste del algoritmo, siguiendo la regla del máximo será:
( )∈ á ( ), ( )

4.2.2. Sentencia condicional (if).


Este añadido es del autor. Tenemos la siguiente sentencia condicional:
si B entonces
si no
fsi

Tenemos estos costes:


si → ( )
si → ( )
si → ( )

La cota superior será: á ( ), ( ), ( ) (camino máximo).


La cota inferior será: ( ), ( ), ( ) (camino mínimo).

4.2.3. Bucles “para” (desde).


Los bucles (lazos) “para” (desde) son los más fáciles de analizar.
Considérese el bucle siguiente:
para ← 1 hasta m hacer ( )
Se nos dan varios casos:
a. El tiempo ( ) requerido por ( ) no depende realmente de i,
aún cuando pudiera depender del tamaño del ejemplar o del
ejemplar en sí. Es el caso más sencillo.
En este caso, el tiempo total requerido por el bucle es simplemente
= ∗ o bien ( ) = ∗ ( ). Se harían n iteraciones y cada
uno con el mismo coste.
Sería algo así como este bucle “mientras”:
i ← 1;
mientras < hacer
( );
← + 1;
Asignaremos costes unitarios (operaciones elementales) a la
comprobación < , a las instrucciones i ← 1 e ← + 1 y a las
instrucciones de salto implícitas en el bucle “mientras”.
Se demuestra que este tiempo al hacer las operaciones está acotado
superiormente por ∗ , tal como habíamos escrito antes.

Resumen tema 4 Curso 2007/08 Página 4 de 12


b. El tiempo ( ) por ( ) varía como función de i. Si
despreciamos el tiempo requerido por el bucle de control, para
≥ 1, entonces ese mismo bucle “para” requiere un tiempo que
está dado por una suma ∑ ( ). Sería entonces
( )=∑ ( , ) el coste del bucle.

Ejemplo: Analizamos el coste de este bucle “para”:


funcion fibiter(n)
← 1; ← 0;
para ← 1 hasta hacer
← + ;
← − ;
fpara
devolver j
ffuncion
Se nos darán dos casos en función de los costes de las operaciones
aritméticas:
1. Las operaciones aritméticas se consideran como de coste
unitario. Las instrucciones de dentro del bucle “para” requieren
un tiempo constante. Suponemos que el tiempo requerido por estas
instrucciones está acotado superiormente por alguna constante c.
El tiempo requerido por el bucle “para” está acotado
superiormente por n veces esta constante: n*c.
El algoritmo tiene coste ( ).
2. Las operaciones aritméticas no se consideran como de coste
unitario. Podemos llegar a ver como al paso del bucle se vuelve
costosa una instrucción tal como ← + , por haber operandos
muy grandes.
Sea c una constante tal que este tiempo está acotado superiormente
por ∗ para todo ≥ 1. Si despreciamos el tiempo requerido
por el control del bucle y las instrucciones que preceden al bucle
concluiremos que el tiempo requerido por el algoritmo está
acotado superiormente por:
∗( )
∑ ∗ = ∗∑ = ∗ ∈ ( )

Un razonamiento similar indica que este tiempo se encuentra en


Ω( ) y se deduce, por tanto, que está en ( ).

El análisis del bucle “para” que empiezan en un valor que no sea 1 o


que avanzan con pasos mayores, debería resultar evidente.
Ejemplo: Se nos da el siguiente bucle:
para ← 5 hasta m paso 2 hacer ( )
Aquí, ( ) se ejecuta ( − 5) ÷ 2 + 1 veces siempre que ≥ 3.

Resumen tema 4 Curso 2007/08 Página 5 de 12


Ejemplo completo de análisis de un algoritmo con bucle “para”. Para
ello, analizaremos la ordenación por selección:
procedimiento seleccionar (1. . )
para ← 1 hasta − 1 hacer
← 1; ← [ ];
para ← + 1 hasta hacer
si [ ] < entonces
← ;
← [ ];
fsi
fpara
[ ] ← [ ]; [ ] ← ;
fpara
fprocedimiento
Seguiremos estos pasos:
1. Análisis de su funcionamiento:
Dentro del bucle para “exterior” tendremos uno “interior” que
nunca sabremos si realiza las mismas instrucciones con el
mismo coste. Estaremos en el caso b) de los vistos
anteriormente.
i

Parte ordenada Parte desordenada

La idea básica es seleccionar el menor elemento de una parte


desordenada del vector y colocarlo en la posición del primer
elemento no ordenado. En un primer paso, se recorre el vector
hasta encontrar el elemento menor. Para ello se coloca el
primer elemento en una variable temporal y se va comparando
con los demás elementos del vector tal que si se encuentra uno
menor se asigna a la variable temporal. Recorrido todo el
vector, el elemento de la variable temporal (que será el menor)
se intercambia con el de la primera posición (el primero
escogido de la parte desordenada). Seguidamente, se considera
únicamente la parte del vector no ordenado y se repite el
proceso de búsqueda del menor, y así sucesivamente.
Se puede describir de la siguiente manera:
 Seleccionar el elemento menor de la parte del vector
no ordenada.
 Colocarlo en la primera posición de la parte no
ordenada del vector.
El coste es independiente de cómo vienen ordenados los datos,
es decir, del contenido de los datos.

Resumen tema 4 Curso 2007/08 Página 6 de 12


2. Análisis del coste:
Veremos el número de instrucciones y analizaremos el coste
del algoritmo en el caso peor, como es habitual:
procedimiento seleccionar (1. . )
(1) para ← 1 hasta − 1 hacer
← 1; ← [ ];
(2) para ← + 1 hasta hacer
si [ ] < entonces
(3) ← ;
← [ ];
fsi
fpara
[ ] ← [ ]; [ ] ← ;
fpara
fprocedimiento

Suponemos que las operaciones de suma, asignación,… son


elementales, por tanto, no las consideraremos en el coste total
del algoritmo. Pasamos a ver el número de instrucciones en
cada paso:
(1) n iteraciones ≈ coste n
(2) ( − ) iteraciones
Tendremos que el bucle “para” interior y el “si” tendrán el
siguiente número de instrucciones:
(2) + (3) ( )≈∑ ( − ).
Resolviendo la sucesión, tendremos que
( )≈∑ ( − ) = ( − 1) ∗ ( − 2) ∗ … ∗ 1 ≈ .
Como conclusión, el coste en todos los casos es ( ).

4.2.4. Llamadas recursivas.


El análisis de algoritmos recursivos suele ser sencillo. Una inspección
sencilla del algoritmo suele dar lugar a una ecuación de recurrencia que
imita el flujo de control dentro del algoritmo. Una vez que se ha obtenido
la ecuación de recurrencia, se pueden aplicar las técnicas generales para
resolverlas.

Resumen tema 4 Curso 2007/08 Página 7 de 12


Ejemplo: Considérese el algoritmo recursivo siguiente:
funcion fibrec (n)
si < 2 entonces devolver n
si no devolver fibrec ( − 1) + fibrec ( − 2)

Sea ( ) el tiempo requerido por una llamada a fibrec ( ):


- Si < 2, el algoritmo devuelve simplemente n, lo cual requiere
un tiempo constante a.
- En caso contrario, la mayor parte del trabajo se invierte en dos
llamadas recursivas, que requieren un tiempo ( − 1) y
( − 2), respectivamente.

Sea ℎ( ) el trabajo implicado en esta suma y en este control, es decir, el


tiempo requerido por una llamada a fibrec( ) ignorando los tiempos
invertidos dentro de las dos llamadas recursivas. Por definición de ( )
y de ℎ( ), obtenemos la siguiente recurrencia:

Si =0ó
= 1 ( < 2)
( )= ( − 1) + ( − 2) + ℎ ( ) En caso contrario

Trataremos, por tanto, estos casos:


- Si contamos las sumas con coste unitario, ℎ( ) está acotado por
una constante y la ecuación de recurrencia es similar a la ya
encontrada antes. Tenemos que ( ) ∈ ( ) , razonando de
manera similar para la cota inferior. Concluimos, entonces, que
fibrec ( ) requiere un tiempo exponencial en n.
- Si no se cuentan las adiciones con un coste unitario, ℎ( ) ya no
queda acotado por una constante. ℎ( ) está dominado por la
adición de y para n suficientemente grande. La adición
requiere un tiempo ℎ ( ) ∈ ( ) .

Deducimos que el resultado es el mismo independientemente de si ℎ( )


es constante o lineal: ( ) ∈ ( ) . La única diferencia es la
constante multiplicativa oculta en la notación .

Resumen tema 4 Curso 2007/08 Página 8 de 12


4.2.5. Bucles “mientras” (while) y “repetir” (repeat).
Para analizar estos bucles no existe una forma evidente a priori de saber
cuántas veces tendremos que pasar por el bucle. Podremos emplear dos
técnicas:
- Es la técnica estándar. Es hallar una función de las variables
implicadas cuyo valor se decremente en cada pasada.
- Para determinar el número de veces que se repite el bucle
necesitamos conocer mejor la forma en que disminuye el valor de
esta función. Para analizar el bucle “mientras” de manera
alternativa consiste en tratarlo como un algoritmo recursivo.

Ejemplo: Estudiaremos con detalle el algoritmo de búsqueda binaria.


funcion busq_binaria( [1. . ], )
← 1; ← ;
mientras < hacer
← ( + ) ÷ 2;
caso_de < [ ]: ← − 1;
= [ ]: , ← ; { }
> [ ]: ← + 1;
fcaso
fmientras
dev i;
ffuncion

Analizaremos el algoritmo siguiendo los pasos anteriores:


1. Análisis del funcionamiento:

j i

La idea básica que subyace a la búsqueda binaria es comparar x


con el elemento k que está en la posición media de T. El objetivo
de la búsqueda binaria es hallar un elemento x de un vector
[1. . ] que está ordenado de modo no decreciente (sólo podremos
aplicar la búsqueda binaria si este vector está ordenado así).
Supongamos por sencillez que está garantizado que x aparece al
menos una vez en T. Se nos pide buscar un entero i tal que
1≤ ≤ y [ ]= .
Como el elemento k está en el centro del vector se dan tres casos:
- Elemento x está a la izquierda de k.
- Elemento x coincide con k.
- Elemento x está a la derecha de k.
Resumen tema 4 Curso 2007/08 Página 9 de 12
Mediante sucesivas divisiones por 2 llegaremos hasta que = .
En el primer caso, en el que x sea menor que la mitad moveremos
el puntero j a la mitad izquierda. En el último caso, moveremos el
puntero i. El caso intermedio, sería ya la solución, encontrando el
elemento x.
Para resolverlo definiremos d como el número de posiciones donde
podrá ir el elemento:
= − +1
En el paso inicial, consideramos las n posiciones:

= −1 − +1= ≈ .

Hemos sustituido j por su mitad, es decir, = − 1 = para el


caso < [ ] (elemento en la mitad izquierda).
El significado de las variables hasta el momento es el siguiente:
d: Representa los valores de − + 1 antes del bucle
“mientras”.
: Representa el número de iteraciones tras finalizar el bucle,
antes de la siguiente iteración.
Para el caso > [ ] (elemento en la mitad derecha), tenemos
que = . Sustituyendo, como vimos previamente:

= − −1 +1 = ≈ .

El coste será ( ) , porque siempre se divide por 2 para


buscar el elemento.

4.3. Uso de un barómetro.


Definición: Una instrucción barómetro es aquélla que se ejecuta por lo menos
con tanta frecuencia como cualquier otra instrucción del algoritmo.
Siempre que el tiempo requerido por cada instrucción esté acotado por una
constante, el tiempo requerido por el algoritmo completo es del orden exacto del
número de veces que se ejecuta la instrucción barómetro.

Resumen tema 4 Curso 2007/08 Página 10 de 12


Ejemplo: analizaremos el siguiente algoritmo:
funcion fibiter(n)
← 1; ← 0;
para ← 1 hasta hacer
← + ;
← − ;
fpara
devolver j
ffuncion
Podremos considerar que la instrucción “ ← + ” se puede tomar como un
barómetro, por ser ejecutada un número de veces igual a n. Se ve entonces que el
algoritmo requiere un tiempo que está en ( ).
Cuando un algoritmo contiene varios bucles anidados, toda instrucción del bucle
más interno puede utilizarse en general como barómetro. Sin embargo, hay que
hacer esto con cuidado, porque hay casos en los que es preciso tener en
consideración el control implícito del bucle. Esto sucede cuando algunos de los
bucles se ejecutan cero veces.

4.5. Análisis del caso medio.


Requiere suponer a priori una distribución de probabilidad para los casos en que
se pedirá que resuelva nuestro algoritmo.

Resumen tema 4 Curso 2007/08 Página 11 de 12


4.6. Resolución de recurrencias.
Tendremos dos tipos:
- Reducción por sustracción:
La ecuación de la recurrencia es la siguiente:

∗ si 0 ≤ <
( )=
∗ ( − )+ ∗ si ≥

La resolución de la ecuación de recurrencia es:

( ) si <1
( )= ( ) si =1
si >1

- Reducción por división:


La ecuación de la recurrencia es la siguiente:

∗ si 1 ≤ <
( )=
∗ ( / )+ ∗ si ≥

La resolución de la ecuación de recurrencia es:

( ) si <
( )= ∗ ( ) si =
si >

siendo:
a: Número de llamadas recursivas.
b: Reducción del problema en cada llamada.
∗ : Todas aquellas operaciones que hacen falta además de las de
recursividad.

Resumen tema 4 Curso 2007/08 Página 12 de 12


Resumen de programación 3

Tema 5. Estructuras de datos.

Alumna: Alicia Sánchez


Centro: UNED-Las Rozas (Madrid)
Índice:

5.1. Matrices (arrays), pilas y colas. …………………………………... 3


5.2. Registros y punteros (apuntadores) .……………………………… 7
5.3. Listas ………………………………………….………………….. 8
5.4. Grafos …………………………………………………………… 11
5.5. Árboles …………………………………………………………... 16
5.6. Tablas asociativas ……………………………………………….. 19
5.7. Montículos (heaps) ………………………………………………. 20
5.8. Montículos binomiales …………………………………………... 33
5.9. Particiones ……………………………………………………….. 34

Bibliografía:
Se han tomado apuntes de los libros:

 Fundamentos de algoritmia. G. Brassard y P. Bratley


 Estructuras de Datos y Algoritmos. R. Hernández
 Esquemas algorítmicos: Enfoque metodológico y problemas resueltos. J. González y M.
Rodríguez

Resumen tema 5 Curso 2007/08 Página 2 de 41


El uso de las estructuras de datos suele ser un factor crucial para el diseño de
algoritmos eficientes. Por ello veremos los más usados más adelante.
De modo amplio escribiremos las distintas operaciones de las estructuras de datos,
así como el análisis de costes de las mismas. Lo ponemos aunque no haya que
estudiarlos con detenimiento.
5.1. Matrices (arrays), pilas y colas.
Definición: Una matriz es una estructura de datos que consta de un número fijo
de ítems (o elementos) del mismo tipo.
En una matriz monodimensional, el acceso a todo ítem particular se efectúa
especificando un solo índice. A lo largo de todo este resumen (y en temas
anteriores también) escribiremos indistintamente tanto matriz como vector,
fijándonos que los índices sean uno en el caso de poner el primero.
Ejemplo:
tab: matriz [1. .50] de enteros
Aquí, tab es una matriz de 50 enteros indexados desde 1 hasta 50. Por tanto,
tab[1] es el primer elemento de la matriz y tab[50] alude al último.

La propiedad esencial de esta matriz es que podemos calcular la dirección de


cualquier elemento dado en un tiempo constante.
Las operaciones de las matrices son:
 El tiempo necesario para leer el valor de un solo elemento o para cambiar
ese valor se encuentra en (1) (operaciones elementales).
 Toda operación que implique a todos los elementos de una matriz tendera
a requerir más tiempo a medida que crezca el tamaño de la matriz. Una
operación como dar valor inicial a todos los elementos o buscar el mayor
elemento tendrá coste ( ) o bien O(n) .
Las matrices monodimensionales permiten implementar eficientemente la
estructura de datos llamada pila. Veremos está estructura con más detenimiento,
aunque insisto que no hay que sabérselo todo de memoria.
Vemos la estructura de datos pila:
Definición: Una pila es una lista dinámica LIFO. La forma de insertar y
recuperar elementos hace que el primero en entrar sea el último en salir.
Utilización: Es útil cuando sea importante conservar el orden de inserción y
extracción según la propiedad LIFO.
No es útil si el acceso a los elementos es indiscriminado o si debe existir
algún tipo de orden en ella dependiendo del valor de los elementos.

Resumen tema 5 Curso 2007/08 Página 3 de 41


Implementaciones:
1. Lista enlazada: Colección de registros que contiene el elemento de
información y un apuntador al siguiente.
2. Vectores: Un vector contiene los elementos de información. Se utiliza
si podemos estimar el número máximo de elementos que albergamos.
Operaciones:
 Creación:
1. fun pila-vacía () dev p:pila: Devuelve una pila vacía.
 Modificación:
1. fun apila (e:elemento, p:pila) dev p:pila: Añade el elemento a la
pila. Se denomina “push”.
2. fun desapila (p:pila) dev e:elemento: Devuelve el elemento
situado en la cima de la pila y lo borra de ésta. Se denomina
“pop”.
 Consulta:
1. fun vacía (p:pila) dev b:booleano: Comprueba si en la pila hay
algún elemento.
2. fun llena (p:pila) dev b:booleano: Comprueba si en la pila está
llena, es decir, no caben más elementos.
3. fun altura (p:pila) dev n:natural: Número de elemento en la pila.

El coste asociado según la implementación es:

Vectores Punteros
pila-vacía cte cte
apila cte cte
desapila cte cte
vacía cte cte
llena cte cte
altura cte cte

La estructura de datos llamada cola también se puede implementar de forma


bastante eficiente en una matriz monodimensional. Pasamos a verlo con más
detalle como hicimos con la pila:
Definición: Una cola es una lista con dinámica FIFO. Los elementos se
insertan en la cola y la recuperación se efectúa en el mismo orden de
inserción. El primero en entrar es el primero en salir.
Utilización: Una cola es útil cuando resulta relevante que el orden de
inserción y extracción en la estructura se realice según la propiedad FIFO.
Análogamente al caso de las pilas, no es de utilidad si el acceso a los
elementos es indiscrimado.
Implementaciones: Son las mismas que en el caso de las pilas.

Resumen tema 5 Curso 2007/08 Página 4 de 41


Operaciones: Tiene las suyas propias aunque podríamos usar las de las listas.
 Creación:
1. fun cola-vacía () dev p:pila: Devuelve una cola vacía.
 Modificación:
1. fun encolar (e:elemento, c:cola) dev c:cola: Inserta el elemento
en la cola.
2. fun desencolar (c:cola) dev e:elemento: Devuelve el elemento
situado al comienzo de la cola y lo borra de ésta.
 Consulta:
1. fun vacía (c:cola) dev b:booleano: Comprueba si en la cola hay
algún elemento.
2. fun llena (c:cola) dev b:booleano: Comprueba si en la cola está
llena, es decir, no caben más elementos.

El coste asociado según la implementación es:

Vectores Punteros
cola-vacía cte cte
borrar cte ( )
encolar cte cte
desencolar cte cte
vacía cte cte
llena cte cte

Tanto para las pilas como para las colas hay una desventaja del uso de las
matrices para la implementación y es que normalmente hay que reservar espacio
para el máximo número de elementos que se prevea. Si reservamos mucho
espacio es un desperdicio y si el espacio no es suficiente es difícil reservar más.
Los elementos de una matriz pueden ser de cualquier tipo de longitud fija:
entero, booleano, etc.
Ejemplo:
lettab: matriz [′ . . ′ ′] de valor
No se permite indexar una matriz empleando números reales, ni tampoco
estructuras tales como las cadenas o conjuntos.

Podemos declarar matrices con dos o más índices de forma similar a las
unidimensionales, las cuales denominaremos bidimensionales o matrices,
indistintamente, que será las que tratemos en la asignatura.
Ejemplo:
matriz: matriz [1. .20,1. .20] de complejo
Una referencia a cualquier elemento requiere ahora dos índices. Ej. matriz [5,7].

Resumen tema 5 Curso 2007/08 Página 5 de 41


Las operaciones de matrices bidimensionales:
 Lectura o modificación del valor de la matriz tiene coste (1), como las
operaciones elementales.
 Dar valor inicial a todos los elementos de la matriz o buscar el elemento
mayor requieren un tiempo ( ) por tener dos dimensiones.
Dijimos que el tiempo necesario para dar un valor inicial a todos los elemento de
un vector de tamaño n es ( ). Si suponemos que no queremos dar valor inicial
a todos los valores, sino que lo único que se precisa saber es si se le ha dado o no
valor inicial y obtenemos su valor en tal caso.
Si estamos dispuestos a emplear más espacio, la técnica denominada
inicialización virtual nos permite evitar el tiempo de dar valores a todas las
entradas del vector. Tendremos estos componentes:
 [1. . ]: Vector que hay que inicializar virtualmente.
 [1. . ] y [1. . ]: Vector auxiliares.
 : Contador.
Ejemplo: veremos un ejemplo paso a paso para que se vea más claramente estas
componentes:
Al comenzar, damos simplemente a el valor 0 y dejamos los vectores a, b y
T con aquellos valores que pudieran contener, o lo que es igual no inicializamos
ningún valor en ninguno de estos vectores.
En el paso primero, inicializamos el valor en la posición 4 en T, supongamos 20:
4
20
1
4

siendo:
T: El primer vector, que por el diseño no podemos poner el nombre.
Indica que se ha inicializado un valor en la posición 4.
= 1: El contador se pone a 1, por ser el primer valor.
[1] = 4: Es la segunda matriz de nuestro dibujo. Indica la posición
inicializada en primer lugar.
[4] = 1: Es la última matriz. Nos dice qué elemento de T está
inicializado en qué posición. En este caso, el 4ª elemento de T es el
primero en inicializarse.

Resumen tema 5 Curso 2007/08 Página 6 de 41


En el paso segundo, inicializamos el valor 15, por ejemplo, en la posición 1 en T:
1 4
15 20
1
4 1

2 1

siendo:
T: El primer vector, que por el diseño no podemos poner el nombre.
Indica que se ha inicializado otro valor en la posición 1.
= 2.
[1] = 2: Es la segunda matriz de nuestro dibujo. Indica la posición
inicializada en segundo lugar.
[2] = 1: Es la última matriz. Nos dice qué elemento de T está
inicializado en qué posición. En este caso, el 1 elemento de T es el
segundo en inicializarse.
Para determinar si se ha asignado un valor a [ ], comprobamos primero si
1≤ [ ]≤ . Si no se cumple, no ha sido inicializado. Si se cumple,
verificamos si realmente ha sido asignado si [ ] = , siendo afirmativo
cuando [ ] ha sido iniciado y si no, no.

5.2. Registros y punteros (apuntadores).


Definición: Un registro es una estructura de datos que consta de un número fijo
de elementos, que suelen llamarse campos en este contexto y que son de tipos
posiblemente distintos.
Ejemplo:
tipo persona = registro
nombre: cadena
edad: entero
peso: real
varon: booleano
Podremos referenciar una variable usando la notación de punto, como puede ser
Juan.edad. Las matrices pueden aparecer como elementos de un registro y los
registros se pueden almacenar en matrices.
Ejemplo:
clase: matriz [1. .50] de persona

Resumen tema 5 Curso 2007/08 Página 7 de 41


Las operaciones de registro son:
 La dirección de todo elemento particular se puede calcular en un tiempo
constante, así que las consultas o modificaciones del valor de un campo
se pueden considerar como operaciones elementales.
Se pueden utilizar los registros en conjunción con los punteros.
Ejemplo:
tipo jefe = ^ persona
donde jefe es un puntero de un registro cuyo tipo es persona. Para hacer
alusiones a los campos de este registro utilizaremos la siguiente notación
jefe^.nombre, fijándonos en que la flecha se pone después del nombre del tipo.
Por último, decir que si un puntero tiene el valor especial nulo “nil” entonces no
apunta a ningún registro, esto es importante para verlo después en otras
estructuras de datos.

5.3. Listas.
Definición: Una lista es una colección de elementos de información dispuestos
en un cierto orden.
A diferencia de las matrices y registros, el número de elementos de la lista no
suele estar fijado ni suele estar limitado por anticipado (recordemos que era el
inconveniente de estas estructuras). Podemos determinar cuál es el primer
elemento, cuál es el último, cuál el predecesor y sucesor de esta estructura. En
una máquina, el espacio correspondiente a cualquier elemento dado suele
denominarse un nodo. La información asociada a cada nodo se muestra dentro
del cuadro correspondiente y las flechas muestran los enlaces que van desde el
nodo a su sucesor.

alpha beta gamma delta

Utilización: El uso más general es el de almacenar elementos de información sin


poder estimar el número máximo de elementos de que disponemos. Si la forma
de acceso está bien definida es posible que podamos optar por una pila o una
cola.
Implementaciones:
1. Mediante vectores: Un vector contiene los elementos de información. Se
utiliza si podemos estimar el número máximo de elementos que
albergamos. Usaremos esta declaración:
tipo lista = registro
contador: 0..longmax
valor: matriz [1. . ] de información
Los ítems (o elementos) de una lista ocupan las posiciones desde valor[1]
hasta valor[ ] y el orden de sus elementos es el mismo que el
orden de sus índices dentro de la matriz.

Resumen tema 5 Curso 2007/08 Página 8 de 41


La lista no tiene tamaño definido pero se limita según la memoria
disponible en el equipo.
Características de esta implementación:
- Asignación estática.
- Inserción costosa: Hay que insertar el elemento y luego desplazar
el resto a la derecha. En el caso peor, hay que mover todos los
elementos. Sería coste lineal.
- Las búsquedas son eficientes.
Operaciones de esta implementación:
a) Se puede hallar rápidamente los elementos primero y último de la
lista, así que como también el predecesor y sucesor de cualquier
elemento dado. El coste es constante (1) .
b) Insertar un nuevo elemento o borrar uno requiere un número de
operaciones que, en el caso peor, está en el orden del tamaño
actual de la lista. Tendría coste lineal ( ) .
Desventaja:
- Todo el almacenamiento precisado está reservado a lo largo de
toda la vida del programa.

2. Mediante punteros: Empleamos esta declaración:


tipo lista = ^ nodo
tipo nodo = registro
valor: información
siguiente: ^ nodo
Todos los nodos salvo el último incluyen un puntero explicito a su
sucesor. El puntero del último nodo tiene el valor especial nulo (“nil”
o NIL) para indicar que no apunta a ningún nodo.

NIL

Resumen tema 5 Curso 2007/08 Página 9 de 41


Características de esta implementación:
- Asignación dinámica: Según el número de elementos que hagan
falta.
- Inserciones eficientes: Sólo hace falta cambiar dos punteros.
- Búsqueda costosa: Pasa al revés que antes.
Operaciones de esta implementación:
a) Examinar el k-ésimo elemento, para un k arbitrario, se necesita
seguir k punteros. Coste ( ).
b) Inserción de un nuevo nodo o el borrado de un nodo. Coste
constante.

Operaciones de las listas en general:


 Creación:
1. fun lista-vacía () dev l:lista: Devuelve una lista vacía.
 Modificación:
1. fun añadir (e:elemento, l:lista) dev l:lista: Añade el elemento a la
lista.
2. fun resto (l:lista) dev l:lista: Elimina el primer elemento y devuelve
el resto de la lista.
 Consulta:
1. fun vacía (l:lista) dev b:booleano: Comprueba si en la lista hay algún
elemento.
2. fun miembro (e:elemento, l:lista) dev b:booleano: Comprueba si el
elemento forma parte de la lista.
3. fun elemento (p:posición, l:lista) dev e:elemento: Consulta el
elemento de la posición p de la lista, sin modificarlo.
4. fun primero (l:lista) dev e:elemento: Extrae el primer elemento de la
lista sin modificarla.

Vectores Punteros
lista-vacía cte cte
añadir cte cte
resto cte cte
vacía cte cte
miembro ( ) ( )
elemento cte ( )
primero cte cte

Resumen tema 5 Curso 2007/08 Página 10 de 41


5.4. Grafos.
Daremos una breve introducción de los grafos, donde se nos darán nociones
básicas del mismo, antes de meternos con la definición formal. Este apartado es
de los más importantes, sin descartar lo anterior.
Intuitivamente, un grafo es un conjunto de aristas unidas por un conjunto de
líneas o flechas (llamadas aristas).
Ejemplo:

alpha beta

gamma delta

Conceptos básicos de grafos:


 Camino: Sucesión de vértices y aristas que comunican un vértice con
otro.
 Peso: Valor asociado a una arista. Indica el coste o el valor de uso de
dicha arista.
 Ciclo: Camino propio que empieza y termina en el mismo vértice.
Tendremos estos tipos de grafos, igualmente pasamos a definirlos:
1. Dirigidos/no dirigidos:
Grafos no dirigidos: Un grafo es no dirigido si la unión entre
cualesquiera dos vértices adyacentes es simétrica. Se ve claramente al
unirse los nodos mediante líneas sin flechas.
Ejemplo:

b c

Veremos el conjunto de nodos y de aristas del grafo:


={ , , }.
= { , }, { , } .
Dirigidos: Los nodos se unen mediante líneas con flecha asociada. En el
ejemplo anterior, la única diferencia es que el conjunto de aristas lo
denotaremos por ( , ).
Se pueden formar caminos y ciclos con estos tipos de grafos.

Resumen tema 5 Curso 2007/08 Página 11 de 41


2. Conexo/no conexo:
Grafo conexo: Un grafo es conexo cuando siempre hay al menos un
camino entre cualesquiera dos vértices, es decir, si todos los nodos están
conectados por alguna arista.
Ejemplo:

a b

d e

Grafo no conexo: No todos los nodos están conectados.


Ejemplo:

a b

d e

Un grafo dirigido es fuertemente conexo si se puede pasar desde


cualquier nodo hasta cualquier otro siguiendo una secuencia de aristas,
pero respetando esta vez el sentido de las flechas.

Resumen tema 5 Curso 2007/08 Página 12 de 41


3. Cíclico/acíclico:
Cíclico: Puede crear un ciclo desde un nodo al otro. En este ejemplo será
un grafo no dirigido, pero puede ser el dirigido según el sentido de las
flechas.
Ejemplo:

a b

Acíclico: Es justo lo contrario, no se crearía ningún ciclo.

Definición formal de grafo: Un grafo es una pareja = 〈 , 〉 en donde N es


un conjunto de nodos y A es un conjunto de aristas. Pondremos un ejemplo que
nos servirá a lo largo del tema:

alpha beta

gamma delta

={ ℎ , , , }.
( ℎ , ), ( ℎ , ), ( , ), ( , ℎ ),
= .
( , ), ( , )

Utilización: Se utiliza para la representación de los elementos de información y


de la relación entre ellos. Un ejemplo puede ser la representación de las ciudades
(vértices) y las carreteras (aristas) así como las distancias (peso) que hay entre
ellas.

Resumen tema 5 Curso 2007/08 Página 13 de 41


Implementaciones: Tendremos dos tipos:
1. Matriz de adyacencia: Un registro contiene, por un lado, un vector con
los elementos de información contenido en los vértices y, por el otro lado,
una matriz que puede tener valores lógicos indicando existencia o no de
aristas, o bien un valor que indique el peso o coste de dicha arista.
tipo grafoadya = registro
valor: matriz [1. . ] de información
adyacente: matriz [1. . , 1. . ] de boolean
Si existe arista de i a j entonces adyacente [ , ] = verdadero, en caso
contrario adyacente [ , ] = falso.
En un grafo dirigido, tal y como dijimos en su definición anteriormente,
adyacente [ , ] = adyacente [ , ], es decir, la matriz de adyacencia es
simétrica (una mitad es igual a la otra).
Operaciones de esta implementación:
- Para saber si existe arista entre i y j hay que buscar un valor de la
matriz. Coste constante, (1).
- Si deseamos examinar todos los nodos que están conectados con
algún nodo dado hay que recorrer toda una fila completa en el
caso de un grafo no dirigido o bien tanto una fila completa como
una columna completa en el caso de un grafo dirigido. Tiene coste
( ).
- El espacio requerido para representar un grafo es cuadrático.
Coste ( ).

2. Array de registros (lista de adyacencia): Una matriz de vértices


contiene el valor de estos y una lista de sus vértices sucesores.
tipo grafolista = matriz [1. . ] de
registro
valor: información
adyacente: lista
Aquí se asocia a cada nodo i una lista formada por sus vecinos, esto es,
una lista formada por aquellos nodos j, tales que exista una arista de i a j
(para grafo dirigido) o entre i y j (para grafo no dirigido).
Ejemplo:
1 n

3 1 5

7 4

Resumen tema 5 Curso 2007/08 Página 14 de 41


Operaciones de esta implementación:
- Puede ser que sea posible examinar todos los vecinos de un nodo
dado en menos de numnodos operaciones. ( ).
- Determinar si existe o no una conexión directa entre dos nodos
dados i y j nos obliga a recorrer la lista de vecinos del nodo i (y
también del j, si fuera un grafo dirigido), lo cual es menos
eficiente que buscar un valor booleano en una matriz. Equivale a
buscar el nodo. El coste es ( ), que es peor que antes.
Para pocas aristas ocupa menos espacio que la implementación anterior.

Operaciones de grafos en general:


 Creación:
1. fun grafo-vacío () dev g:grafo: Devuelve un grafo vacío.
 Modificación:
1. fun sucesores (v:vértice, g:grafo) dev l:lista: Devuelve una lista con
los vértices adyacentes a v.
2. fun peso (v1,v2:vértice, g:grafo) dev p:peso: Peso asociado a la arista
que une los vértices dados.
3. fun añadir-arista (v1,v2:vértice, p:peso, g:grafo) dev g:grafo: Añade
una arista entre los vértices dados y le asigna el peso p.
4. fun añadir-vértice (v:vértice, g:grafo) dev g:grafo: Añade el vértice
v al grafo g.
5. fun borrar-arista (v1,v2:vértice, g:grafo) dev g:grafo: Elimina la
arista que une los vértices dados.
6. fun borrar-vértice (v:vértice, g:grafo) dev g:grafo: Borra el vértice
del grafo y todas las aristas que partan o lleguen de él.
 Consulta:
1. fun adyacente (v1,v2:vértice, g:grafo) dev b:boolean: Comprueba si
los vértices v1 y v2 son adyacentes.

Matriz de adyacencia Lista de adyacencia


grafo-vacío cte cte
sucesores ( ) cte
peso cte ( )
añadir-arista cte cte
borrar-arista cte ( )
añadir-vértice cte cte
borrar-vértice ( ) ( )
adyacente cte ( )

Resumen tema 5 Curso 2007/08 Página 15 de 41


5.5. Arboles.
Un árbol es un grafo acíclico, conexo y no dirigido. Se puede definir como un
grafo no dirigido en el cual existe exactamente un camino entre todo par de
nodos dado.
Se emplean las mismas implementaciones que un grafo.
Propiedades de los árboles:
 Un árbol con n nodos contiene exactamente − 1 aristas.
 Si se añade una única arista a un árbol, entonces el grafo resultante
contiene un único ciclo.
 Si se elimina una única arista de un árbol, entonces el grafo resultante ya
no es un conexo.
Nos interesamos por los árboles con raíz, en los cuales hay un nodo llamado
raíz, que es especial. La raíz la dibujaremos en la parte superior y luego su
descendencia, como un árbol genealógico. Usaremos el término árbol en todas
las ocasiones y en todo el resto de asignatura.
Ejemplo:
Padre

Hijo Hijo

Una de las propiedades del árbol la pasamos a ver con más detenimiento:
 Un árbol con n nodos contiene exactamente − 1 aristas.
Para comprobarlo usaremos la demostración por inducción, que dimos
brevemente en el tema 1, aunque aquí lo trataremos con más detenimiento:
Hipótesis de inducción: Suponemos que los nodos están conectados por las
aristas.

− 1 aristas

Resumen tema 5 Curso 2007/08 Página 16 de 41


Si se añade un nodo más, entonces se añade una sola arista (u), ya que si
añadimos otra más (v) crea un ciclo. Se pasaría a + 1 nodos y n aristas.

Implementaciones:
1. Con punteros al hijo mayor y al hermano siguiente. Se usan nodos
del tipo:
tipo nodoarbol1 = registro
valor: información
hijo_mayor, hermano_siguiente: ^ nodoarbol1
Ejemplo:

alpha

beta gamma

delta epsilon zeta

La ventaja es que todos los nodos se pueden representar utilizando la


misma estructura registro, independientemente del número de hijos y
hermanos que posean.

2. Con punteros al padre. Su representación es esta:


tipo nodoarbol2 = registro
valor: información
padre: ^ nodoarbol2
Cada nodo contiene un único puntero que lleva a su padre. Esta
representación es la más económica en términos de espacio, pero es
poco eficiente a no ser que todas las operaciones del árbol impliquen
comenzar de un nodo y subir, sin descender nunca.
Si queremos acelerar operaciones que queremos efectuar a base de
añadir punteros suplementarios. Por el contrario, se incrementa el
espacio.

Resumen tema 5 Curso 2007/08 Página 17 de 41


Tendremos ocasión de usar árboles binarios, de 0, 1 y 2 hijos,
distinguiendo entre hijo izquierdo y derecho.

3. Árbol k-ario. Generalizando si en cada nodo del árbol no puede


haber más de k hijos, se trata de un árbol k-ario. Esta representación
emplea estos nodos:
tipo nodo-k-ario = registro
valor: información
hijo: matriz [1. . ] de ^ nodo-k-ario
En el caso de un árbol binario (de 0 a 2 hijos) tenemos:
tipo nodo-binario = registro
valor: información
hijo-izquierdo, hijo-derecho: ^ nodo-binario

Un árbol binario es un árbol de búsqueda si el valor contenido en


todos los nodos internos es mayor o igual que los valores contenidos
en su hijo izquierdo o en cualquiera de los descendientes de ese hijo y
menor o igual que los valores contenidos en su hijo derecho o en
cualquiera de los descendientes de ese hijo. Sólo mostraremos
brevemente los conceptos del árbol de búsqueda sin entrar en más
detalle.
Operaciones del árbol de búsqueda:
- Borrar un nodo o añadir un nuevo valor es fácil pero luego hay
que recuperar la propiedad del montículo. Se vuelve un árbol
desequilibrado con ramas largas y delgadas, cuya búsqueda
será entonces lineal.
- Para equilibrar el árbol tendremos coste (log ) en el caso
peor, siendo n el número de nodos que hay en el árbol.

Conceptos sobre árboles:


 Altura de un nodo: Es el número de aristas que hay en el camino más
largo que vaya desde el nodo en cuestión hasta una hoja.
 Profundidad de un nodo: Es el número de aristas que hay en el
camino que va desde el nodo raíz hasta el nodo en cuestión.
 Nivel de un nodo: Es igual a la altura de la raíz del árbol menos la
profundidad del nodo estudiado.

Resumen tema 5 Curso 2007/08 Página 18 de 41


Una aplicación de estos conceptos puede ser el siguiente ejemplo:

alpha

beta gamma

delta epsilon zeta

Nodo Altura Profundidad Nivel


alpha 2 0 2
beta 1 1 1
gamma 0 1 1
delta 0 2 0
épsilon 0 2 0
zeta 0 2 0

Los valores que se nos dan son todos sacados de la definición, aunque nos
pararemos en deducir el nivel, en este caso veremos el de alpha y beta con más
calma.
Nivel de alpha = altura de alpha – profundidad de alpha = 2 – 0 = 2.
Nivel de beta = altura de alpha – profundidad de beta = 2 – 1 = 1.

5.6. Tablas asociativas.


Brevemente daremos las nociones básicas en este apartado.
Definición: Una tabla asociativa es igual a una matriz, salvo que su índice no
está restringido a encontrarse entre dos cotas predeterminadas.
Para implementarlo usaremos una lista:
tipo lista_tabla = ^ nodo_tabla
tipo nodo_tabla = registro
indice: tipo_indice
valor: información
siguiente: ^ nodo_tabla

Esta implementación es ineficiente en el caso peor, requiriendo un tiempo que se


encuentra en Ω( ).
Todos los compiladores utilizan una tabla asociativa para implementar la tabla
de símbolos, que contiene los identificadores que se utilizan en el programa que
hay que compilar.

Resumen tema 5 Curso 2007/08 Página 19 de 41


Veremos varios conceptos:
- Función de dispersión: Es una función ℎ: → {0,1,2, . . , − 1} que
debe dispersar todos los índices probables: ℎ( ).
- Colisión: ocurre cuando ≠ pero ℎ( ) = ℎ( ).
- Factor de carga: Es m/N, donde m es el número de índices distintos que
se han almacenado en la tabla y N es el tamaño de la matriz que se
emplea para implementarla.
- Redispersión: Lo usaremos para mantener valores pequeños del factor
de carga. Consiste en volver a hacer la dispersión con otra función
distinta.

5.7. Montículos (heaps).


Esta es una estructura de las más importantes que daremos a lo largo del curso,
por lo que nos centraremos en las distintas propiedades y operaciones.
Definición: Un montículo es un tipo especial de árbol que tiene la propiedad
particular que se puede implementar en una matriz sin punteros explícitos.
Tiene estas características:
- Es binario, significa que tiene de 0 a 2 hijos cada nodo.
- Cada uno de los nodos incluye un elemento de información llamado valor
del nodo, siendo éste mayor o igual que los valores de sus hijos.
- Es esencialmente completo: Todo nodo interno, con la posible excepción
de un nodo especial, tiene exactamente dos hijos.
Pasamos esta última característica con más detenimiento:
Árbol binario esencialmente completo: Es un árbol binario en el que todos los
nodos finales hasta el penúltimo están completos (con mayor número de nodos
posibles) y el último está incompleto, aunque ordenado. Se irá rellenando de
arriba a abajo y de izquierda a derecha.
Ejemplo:

Resumen tema 5 Curso 2007/08 Página 20 de 41


Calculamos el número de nodos que tendrá un árbol binario, como sigue:

Nivel 1 nodo 2
Nivel −1 2 nodos 2
Nivel −2 4 nodos 2

Nivel 1
Nivel 0 1≤ ≤2
Se van rellenando los nodos hasta completar el nivel 0.
Si contamos todos los nodos hasta el penúltimo nivel:

∑ 2 = = 2 − 1 nodos.
Para resolver la serie tendremos que seguir esta fórmula, que ya hemos
aplicado previamente:

∑ = .

Por tanto, para el árbol contiene estos nodos 2 ≤ ≤2 .


La altura del árbol que contiene n nodos es = ⌊log ⌋, lo que significa que
es un redondeo por abajo, quedándonos con la parte entera.
Ejemplo: si tenemos tres nodos, la altura sería ⌊log 3⌋ = 1.

Es importantísimo tener bien claro la propiedad del montículo:


2∗
El nodo i es el padre de .
2∗ +1
El nodo i es el hijo del nodo i div 2 (o i ÷ 2).
Ejemplo: tenemos este montículo:

2
3

4 5 6 7

8 9 10 11 12 13 14

Resumen tema 5 Curso 2007/08 Página 21 de 41


Queremos verificar si cumple estas condiciones, para ello lo representamos
en forma de vector:

1 2 3 4 5 6 7 8 9 10 11 12 13 14

Las flechas indican cuál es el padre y cuál es el hijo, es decir, los hijos del
nodo 1 son las posiciones 2 y 3.
El número del nodo indica el orden de inserción en el árbol.
[2 ∗ ]
Por tanto, el nodo en la posición [ ] es el padre de , que son
[ 2 ∗ + 1]
posiciones en el vector.
El nodo en la posición [ ] es el hijo del nodo [ 2] o [ ÷ 2] .

Estas son la propiedad del montículo para los vectores. No haría falta
representarlo con punteros, ya que lo haríamos con un vector en la que cada
posición indica cual es el padre e hijo.
En resumen y para los vectores sería:
[ ] ≥ [2 ∗ ]
Hijos con respecto a padres.
[ ] ≥ [ 2 ∗ + 1]
[ ]≤ [ 2]. Padres con respecto a hijos.
Consideramos montículos de máximos (padre es mayor o igual que el hijo),
aunque hay también de mínimos, que es justo lo contrario.

Utilización: La principal utilidad de un montículo es la de estar


permanentemente organizado de forma que nos proporcione de manera
eficiente el elemento de mayor (o menor) valor, que en este caso es la raíz del
árbol binario. A este estado se le conoce como propiedad del montículo y
debe restaurarse después de cualquier modificación del montículo.
Un ejemplo típico de uso de montículos es la implementación de las colas de
prioridad.

Resumen tema 5 Curso 2007/08 Página 22 de 41


Insistimos en el asunto de las propiedades del montículo, ya que es básico su
dominio. Pondremos unos ejemplos para verificar si el vector dado es
montículo o no. Seguiremos este procedimiento:

1. Imaginemos que nos dan este vector:


10 7 9 4 7 5 2 2 1
El árbol del mismo sería:

10

7
9

4 7 5 2

2 1

Vemos que cada padre es mayor o igual que sus hijos. Por tanto, es
montículo.

2. Ahora nos da este otro:


3 7 9 4 7 5 2 2 1

7
9

4 7 5 2

2 1

Observamos que el nodo 1 es menor que sus hijos, por lo que no es


montículo. Para arreglarlo lo hundimos (de arriba abajo), para ello lo
intercambiamos con el hijo mayor, que es 9, en este caso.

Resumen tema 5 Curso 2007/08 Página 23 de 41


9

7
3

4 7 5 2

2 1

Sigue sin cumplirse la propiedad del montículo, por lo que hundimos


de nuevo el nodo 3, intercambiándolo con el hijo mayor, quedando
así:

7
5

4 7 3 2

2 1

Con este último intercambio ya cumple la propiedad del montículo.

Agregamos nodo nuevo en el vector siguiente, nos fijamos que es la


última posición y el valor es un 8:

7
5

4 7 3 2

2 8

Resumen tema 5 Curso 2007/08 Página 24 de 41


Flotamos el nodo último para que cumpla la propiedad del montículo
y lo intercambiamos con el padre:

7
5

8 7 3 2

2 4

A continuación, hay que hacer otro cambio para que cumpla de nuevo
la propiedad del montículo:

8
5

7 7 3 2

2 4

Para hacer los dos cambios hay que hacer una única operación de
hundir o flotar. Ya con estos cambios se cumple la propiedad del
montículo.

Operaciones del montículo:


Hemos visto anteriormente una aplicación de flotar y hundir en esos
ejemplos. Pasamos a escribir las operaciones, con los algoritmos y a
explicarlos. Las más importantes y en las que se basaran el resto de
algoritmos es el de hundir y flotar. Empezamos por la modificación de
montículo, que usa ambos, que más abajo lo describiremos. Es
IMPORTANTE el saber estos algoritmos (razonando y no de memoria),
teniendo en cuenta la propiedad del montículo.

Resumen tema 5 Curso 2007/08 Página 25 de 41


procedimiento modificar-montículo ( [1. . ], , )
{ [1. . ] es un montículo. [ ] recibe el valor v y se vuelve a
establecer la propiedad del montículo. Suponemos 1 ≤ ≤ }
← [ ];
[ ]← ;
si < entonces hundir ( , );
si no flotar ( , );

Este procedimiento restaura la propiedad del montículo, o bien hundiendo o


bien flotando el nodo. El coste de este algoritmo es el de hundir o flotar, que
son (log ).
procedimiento hundir ( [1. . ], )
{ Este procedimiento hunde el nodo i para establecer la propiedad del
montículo en [1. . ]. suponemos que T seria un montículo si [ ]
fuera lo suficientemente grande. También suponemos que 1 ≤ ≤ }
← ;
repetir
← ;
{ Buscar el hijo mayor del nodo j }
si 2 ∗ ≤ y [2 ∗ ] > [ ] entonces ← 2 ∗
si 2 ∗ ≤ y [2 ∗ + 1] > [ ] entonces ← 2 ∗ + 1
intercambiar [ ] y [ ]
{ si = , entonces el nodo ha llegado a su posición final }
hasta que =
El coste es de (log )

procedimiento flotar ( [1. . ], )


{ Este procedimiento flota el nodo i para establecer la propiedad del
montículo en [1. . ]. suponemos que T seria un montículo si [ ]
fuera lo suficientemente grande. También suponemos que 1 ≤ ≤ }
← ;
repetir
← ;
si > 1 y [ ÷ 2] < [ ] entonces ← ÷ 2
intercambiar [ ] y [ ]
{ si = , entonces el nodo ha llegado a su posición final }
hasta que =
El coste es de (log ) , como pasa con el algoritmo de hundir un nodo.

Debido a su importancia recordamos de nuevo la utilización del montículo, ya


que lo usaremos en muchas aplicaciones prácticas. Un montículo es una
estructura ideal para hallar el mayor de un conjunto, para eliminarlo, para añadir
un nodo nuevo o para modificar un nodo.
Una aplicación muy utilizada es la lista de prioridad dinámica (como dijimos
antes cola de prioridad), en la que el valor del nodo de la prioridad del suceso

Resumen tema 5 Curso 2007/08 Página 26 de 41


correspondiente, el suceso de prioridad más alta se encuentra siempre en la raíz
del montículo y la prioridad de un suceso se puede modificar dinámicamente en
cualquier momento.
Seguimos con más operaciones del montículo, no menos importantes que las
anteriores:
funcion buscar-max ( [1. . ])
{ Proporciona el mayor elemento del montículo [1. . ] }
devolver [1];
Esta función devuelve un valor de un elemento en una matriz, que recordemos es
una interpretación muy utilizada de los montículos. En este caso, al ser
montículo de máximo, en la raíz estará el mayor elemento. Recapitulando
teníamos que el coste de acceso al vector es constante, (1).
funcion borrar-max ( [1. . ])
{ Elimina el mayor elemento del montículo [1. . ] y restaura la
propiedad del montículo en [1. . − 1] }
[1] ← [ ];
hundir ( [1. . − 1], 1);
Si queremos borrar el máximo (el primer elemento) del montículo tendremos que
restaurar la propiedad del mismo, para eso hundimos la raíz hasta que se haga
eso. Veremos paso a paso este algoritmo, para que así quede más claro:
1er paso. Intercambio con el último elemento. Siguiendo con el ejemplo anterior
tendremos:

8
5

7 7 3 2

2 4

Resumen tema 5 Curso 2007/08 Página 27 de 41


Quitamos el primer elemento e intercambiamos el último elemento con el
primero, el que previamente hemos quitado. Por tanto, nos quedaría así:

8
5

7 7 3 2

2º paso. Hundimos (de arriba abajo, recuérdese como regla nemotécnica el del
buzo y el agua) el primer elemento, que como vimos en los ejemplos anteriores
no cumple la condición de montículo (de máximos). Nos evitamos hacer los
pasos, aunque los describiremos, para saber porque el montículo será ese:
1. Intercambiamos 4 con 8, que es su hijo mayor ( [1] con [2]).
2. Intercambiamos 4 con 7 ( [2] con [4]).
En este segundo intercambio ya es montículo, por lo que ponemos como
quedaría:

7
5

4 7 3 2

Un asunto importante es que estos intercambios (es decir, hundir dos veces dos
nodos) se hace en una misma llamada hasta que acabe de recorrerse todo el
montículo.
procedimiento añadir-nodo ( [1. . ], )
{ Añade un elemento cuyo valor es v y restaura la propiedad del
montículo en [1. . − 1] }
[ + 1] ← ;
flotar ( [1. . + 1], + 1)

Resumen tema 5 Curso 2007/08 Página 28 de 41


Si quisiéramos añadir un nodo habría que flotar, como resaltamos de nuevo
aunque de tres saltos se hace en una llamada. El coste de nuevo lo determina
flotar, que es (log ).
procedimiento crear-montículo-lento ( [1. . ])
{ Este procedimiento transforma la matriz [1. . ] en un montículo,
aunque de forma más bien ineficiente }
para ← 2 hasta n hacer flotar ( [1. . ], )
Iríamos recorriendo el montículo desde la posición 2 hasta flotar (de arriba
abajo) todos los elementos y que se restaure la propiedad del montículo.
Al ser un procedimiento lento, veremos otro más rápido, que es más eficiente. A
continuación los compararemos:
procedimiento crear-montículo ( [1. . ])
{ Este procedimiento transforma la matriz [1. . ] en un montículo }
para ← ⌊ /2⌋ bajando hasta 1 hundir ( , )
Observando ambos algoritmos vemos que mientras el primero flota haciendo un
bucle mayor (de 2 a n) el segundo hunde de ⌊ /2⌋ bajando a 1, por lo que se
reduce a priori el número de operaciones. Veremos este segundo y
comprenderemos porque es más eficiente.
Ejemplo del segundo algoritmo, más eficiente:
- Inicialmente nos dan la situación siguiente:
1 2 3 4 5 6 7 8 9 10
1 6 9 2 7 5 2 7 4 10

Pasamos este vector a un árbol binario (montículo):

6
9

2 7 5 2

7 4 10

- Primer paso: Convertimos en montículos aquellos subárboles cuyas


raíces se encuentran en el nivel 1. Recordamos que se intercambiarán con
su hijo mayor.

2 7 5 2

7 4 10

Resumen tema 5 Curso 2007/08 Página 29 de 41


- Segundo paso: Los subárboles de nivel inmediatamente superior se
transforman en montículos, hundiendo (de arriba abajo) una vez más sus
raíces. Se cuentan con las modificaciones del paso anterior. En todos los
niveles salvo el último, hundimos la raíz de dicho subárbol.

7 10

2 4 7

Como antes vemos los intercambios en el subárbol izquierdo que se han


realizado:
1. Intercambiamos el nodo con valor 6 con el 10 (su hijo mayor).
2. Intercambiamos el nuevo nodo con valor 6 con el 7.
Quedará así:

10

7 7

2 4 6

Observamos que el subárbol derecho ya está ordenado, por lo que no lo


modificamos. Si no fuera así, lo haríamos igual.
- Último paso: Nos queda hundir la raíz, por lo que una vez hecho lo
anterior, ya estamos dispuestos a hacerlo. Ya cumple la propiedad del
montículo.

10
9

7 7 5 2

2 4 6

Resumen tema 5 Curso 2007/08 Página 30 de 41


De nuevo, no pintaremos los grafos, pero diremos que hemos hecho
(recordemos de nuevo que eso se hace en una misma llamada a hundir):
1. Intercambiamos nodo posición 1 (valor 1) con el posición 2 (valor
10).
2. Intercambiamos nuevo nodo valor 1 con valor 7 (posición 4).
3. Intercambiamos nodo 1 (posición 4) con valor 4 (posición 9).
Nuestro resultado en forma de matriz será:
1 2 3 4 5 6 7 8 9 10
10 7 9 4 7 5 2 2 1 6

Para finalizar las operaciones del montículo analizaremos el coste del algoritmo
de crear montículo. Queda decir que ambos dos tendrán coste lineal, lo único que
les diferenciaran es que la constante multiplicativa del “lento” es mucho mayor
(hace más operaciones) que la del rápido.
Para analizar el coste tendremos esta afirmación: El algoritmo construye un
montículo en tiempo lineal.
La demostración es:
Sea ( ) el tiempo requerido para construir un montículo de altura k como
máximo. Para construir el montículo, el algoritmo transforma primero los dos
subárboles asociados a la raíz en árboles de altura − 1 como máximo:

Entonces, el algoritmo hunde la raíz por una ruta cuya longitud es k como
máximo, lo cual requiere un tiempo ( ) ∈ ( ) en el caso peor.

Resumen tema 5 Curso 2007/08 Página 31 de 41


Tenemos, por tanto, la recurrencia siguiente:
( ) ≤ 2 ∗ ( − 1) + ( ).
siendo:
a: Número de llamadas recursivas = 2
b: Reducción del subproblema en cada llamada = 1
∗ : Coste de llamadas externas a la recursividad, que sería:
∗ = 1 ⇒ = 0.

Recordemos cuales son los casos de la resolución de la recurrencia por


sustracción:

( ) si <1
( )= ( ) si =1
si >1

Estamos en el tercer caso, porque a = 2, por lo que el coste es =


(2 ).
Necesitamos saber el coste para el número de nodos, para ello teníamos
= ⌊log ⌋, sustituyendo tendremos 2 = ( ). Se verá la
demostración de este coste en los ejercicios del tema 7, el esquema de divide y
vencerás.
Con esto queda demostrado que el coste de crear montículo es lineal, y como
dijimos antes tiene más constante multiplicativa el algoritmo lento que el que
hemos visto paso a paso.

Por último, para acabar el apartado de montículos veremos el algoritmo de


ordenación por montículo (heapsort), cuyo algoritmo es el siguiente:
procedimiento ordenación por montículo ( [1. . ])
{ T es la matriz que hay que ordenar }
crear-montículo (T);
para ← bajando hasta 2 hacer
intercambiar [1] y [ ]
hundir ( [1. . − 1], 1)

Para analizar el coste realizaremos estos pasos:


1. Análisis del funcionamiento.
2. Análisis del coste propiamente dicho.

Resumen tema 5 Curso 2007/08 Página 32 de 41


Analizaremos el funcionamiento como sigue:
a) Pasamos el último elemento al primero, es algo así como quitar la raíz
que veíamos antes.
b) Hundimos la raíz hasta que se cumpla la propiedad del montículo. Esto
quiere decir que ese último elemento ya estaría ordenado, por ser el
mayor de todos los elementos.

desordenado 1ª raíz

c) De nuevo pasamos el penúltimo elemento al primero (a la raíz). Luego


hundimos estos elementos. Ya estarían las dos últimas posiciones
ordenadas en el vector (montículo).
d) Sucesivamente haremos esto, hasta ordenarlos. Nos fijamos que acaba en
el segundo elemento, ya que no haría falta continuar más.

Una vez visto el funcionamiento, pasamos a analizar el coste, que será lo


siguiente:
- Crear montículo (T): ( ).
- Hacer el bucle “para”: realiza n veces hundir la raíz a lo largo de un
camino una longitud log( ), que es el coste en el caso peor. Por tanto, el
coste es ( ∗ log( )).
Por lo visto anteriormente, el coste del algoritmo de ordenar montículo es
( ∗ log( )).

Recordemos que en algunas ocasiones usaremos un montículo invertido (o de


mínimos, o la raíz es el mínimo) en el que el nodo interno es menor o igual que
los valores de sus hijos. Nuestro montículo habitual es el montículo de máximos.
Al haber operaciones que no resultan adecuadas para manejar listas dinámicas de
prioridad usaremos montículos binomiales, que los daremos en el siguiente
apartado.

5.8. Montículos binomiales.


En un montículo ordinario que contenga n elementos, buscar el mayor de ellos
requiere un tiempo que está en (1). Borrar el mayor elemento o insertar uno
nuevo requiere un tiempo que está en (log( )). Sin embargo, fusionar dos
montículos que tengan entre los dos n elementos, requiere un tiempo que está en
( ).
En un montículo binomial, la búsqueda del mayor elemento sigue necesitando
un tiempo que está en (1) y el borrado del mayor elemento (log( )), igual
que antes. Sin embargo, la fusión de dos de estos montículos sólo requiere un
tiempo en (log( )) y la inserción de un nuevo elemento sólo requiere un
tiempo en (1).

Resumen tema 5 Curso 2007/08 Página 33 de 41


Definición de árbol binomial: El i-ésimo árbol binomial Bi con ≥ 0, se define
recursivamente como aquél que consta de un nodo raíz con i hijos, en donde el j-
ésimo hijo, 1 ≤ ≤ , es a su vez la raíz de un árbol binomial Bj-1.

Ejemplo de árbol binomial:

B0 B1 B2 B3

5.9. Particiones.
Supongamos que se tienen N objetos numerados de 1 a N. deseamos agrupar
estos objetos en conjuntos disjuntos, de tal manera que en todo momento cada
objeto se encuentre exactamente en un conjunto.
Ejemplo de particiones:

Cada conjunto lleva su rótulo asociado.

Resumen tema 5 Curso 2007/08 Página 34 de 41


Operaciones:
- Buscar: Busca en qué conjunto está contenido un elemento. Devolvemos
el rótulo de este conjunto.
- Fusionar: Se le pasan dos rótulos y construye un único conjunto con un
rótulo nuevo. Ej. Fusionar (rótulo1, rótulo2).

rótulo3

rótulo1

rótulo2

Se nos da que inicialmente los N objetos se encuentran en N conjuntos


diferentes, cada uno contiene exactamente un objeto:

Pretendemos que queden todos los objetos en un único conjunto mediante


operaciones de búsquedas y fusiones, para ello necesitamos N búsquedas y
− fusiones. Esto es importante, ya que de ello depende lo que a continuación
daremos en este apartado.

Resumen tema 5 Curso 2007/08 Página 35 de 41


La partición resultante quedaría así:

Tendremos distintas implementaciones, que en ocasiones serán mejoras de la


anterior. Es decir, aunque asumimos que son implementaciones distintas en el
fondo son mejoras de las anteriores.
NOTA: Tomaremos la cota superior de todas las funciones al analizar el coste,
ya que en principio tendremos el mismo resultado que al poner el coste exacto
(recordemos que es la cota superior y la cota inferior).
Las implementaciones serán:
1. La primera implementación: El rótulo de un conjunto es el menor de sus
elementos.
La notación que usaremos será conjunto[ ], que es el rótulo que contiene
al elemento i.
Operaciones:
funcion buscar1 (x)
{ Busca el rótulo del conjunto que contiene x }
devolver conjunto[ ];
Esta función tiene coste (1), al devolver una posición en un array.
procedimiento fusionar1 (a,b)
{ Fusiona los conjuntos rotulados como a y b, suponemos que
≠ }
← ( , );
← á ( , );
para ← 1 hasta N hacer
si conjunto[ ] = entonces conjunto[ ] ←

Resumen tema 5 Curso 2007/08 Página 36 de 41


Al fusionar los dos conjuntos tendremos que saber cuál de los dos rótulos
a ó b es el menor y le asignamos el valor al nuevo rótulo después de
fusionarlo. Gráficamente tendríamos algo así tras fusionar:

El coste máximo será ( ), determinado por el bucle “para”.


Según las conclusiones anteriores el coste de 1 búsqueda es (1) y el de
1 fusión es ( ), por lo que para nuestro problema de N búsquedas y
− 1 fusiones tendremos:
N búsquedas (1) ( )
− 1 fusiones ( ) ( )
Aplicando la regla del máximo, visto en temas anteriores, tendremos que
el coste del problema de esta implementación es ( ).

2. La segunda implementación: Cada conjunto es un árbol, en el cual cada


nodo contiene un puntero a su padre, como nodoarbol2. Tendremos este
convenio:
Si conjunto[ ] = ⇒ es rótulo de un conjunto y raíz.
Si conjunto[ ] = ≠ ⇒ es el padre de i en algún árbol.

Ejemplo:
1 2 3 4 5 6 7 8 9 10
1 2 3 2 1 3 4 3 3 4

Buscamos conjunto 1: Conjunto 1 es una raíz de un conjunto, es su rótulo


por ser conjunto[ ] = .
Buscamos conjunto 2: Ocurre igual que el 1, es su rótulo.
Buscamos conjunto 3: Igual que los anteriores.
Buscamos conjunto 4: Observamos que conjunto[4] = 2, por lo que el
padre de 4 es el 2.
Y así sucesivamente, hasta finalizar el recorrido.

Resumen tema 5 Curso 2007/08 Página 37 de 41


En forma de árbol, el resultado nos quedaría así:

1 2 3

5 4 6 8 10

7 10

Las operaciones de buscar y fusionar serán:


funcion buscar2 (x)
{ Busca el rótulo del conjunto que contiene x }
← ;
mientras conjunto[ ] ≠ hacer ← conjunto[ ]
devolver r
Iremos buscando en todos los conjuntos hasta encontrar el padre para
llegar a la raíz.
En el caso peor, tiene coste lineal ( ) , porque llegaría a ser un árbol
de altura n, buscando el último elemento del árbol.

procedimiento fusionar2 (a,b)


{ Fusiona los conjuntos rotulados como a y b, suponemos que
≠ }
si < entonces conjunto[ ] ←
si no conjunto [ ] ←
Bastaría con cambiar un conjunto y su rótulo. Entonces, el coste sería
constante, (1).

Resumen tema 5 Curso 2007/08 Página 38 de 41


Ejemplo:

Hijo de 1
1 2

5 4

7 10

Con estas modificaciones quedaría así el nuevo vector:


1 2 3 4 5 6 7 8 9 10
1 1 3 2 1 3 4 3 3 4

Según las conclusiones anteriores el coste de 1 búsqueda es ( ) y el de


1 fusión es (1), por lo que para nuestro problema de búsquedas y
− 1 fusiones tendremos:
búsquedas ( ) ( )
− 1 fusiones (1) ( )
Como hemos visto antes, el coste según la regla del máximo es ( ).

3. La tercera implementación: Se mejora, ya que en vez de tener como


rótulo el menor de los elementos en cada paso. Tenemos el de menor
altura, que sería el hijo del de mayor altura. No se varía buscar2, pero sí
fusionar2. Ambas funciones serían:
funcion buscar2 (x)
{ Busca el rótulo del conjunto que contiene x }
← ;
mientras conjunto[ ] ≠ hacer ← conjunto[ ]
devolver r

procedimiento fusionar3 (a, b)


{ Fusiona los conjuntos rotulados como a y b, suponemos que
≠ }
si altura[ ] = altura[ ] entonces
altura[ ] ← altura[ ]+1
conjunto[ ] ←
si no
si altura[ ] > altura[ ] entonces
conjunto[ ] ←
si no conjunto[ ] ← ;
Resumen tema 5 Curso 2007/08 Página 39 de 41
Al cabo de una secuencia arbitraria de búsquedas y fusiones, con árbol de
k nodos tiene una altura máxima ⌊log ⌋.
Para ejecutar una secuencia arbitraria de n operaciones buscar2 y N-1
operaciones fusionar3 comenzando a partir de la situación inicial es
( ∗ log ). En el libro nos comentan como coste exacto, como pusimos
anteriormente tomaremos cota superior, porque es lo mismo.

4. La cuarta implementación: Añadimos compresión de caminos (cuidado


que es de comprimir, no de comprender).
Cuando se está intentando determinar el conjunto que contiene un cierto
elemento x; primero se recorren las aristas del árbol que suben desde x
hasta la raíz. Una vez que se conoce la raíz, podemos recorrer una vez
más las mismas aristas, modificando esta vez cada nodo encontrado por el
camino de tal manera que su puntero señale ahora directamente a la raíz.
Modificaremos buscar2 como sigue:
funcion buscar3 (x)
{ Busca el rótulo del conjunto que contiene el elemento x }
← ;
mientras conjunto[ ] ≠ hacer ← conjunto[ ]
{ r es la raíz del árbol }
Mientras ≠ hacer
← conjunto[ ]
conjunto[ ] ←
← ;
devolver r

Explicamos brevemente la compresión de caminos:

Encontramos ese nodo y vemos que el nodo padre sería el del nivel
superior y el padre de este es el nodo raíz.

Resumen tema 5 Curso 2007/08 Página 40 de 41


Se cambiaría el puntero de este nodo:

Si hiciéramos muchas compresiones de caminos, o lo que es igual,


muchas búsquedas el árbol llegaría a algo así:

Llegaría la búsqueda casi a coste lineal ( ), en vez de (log ).

Resumen tema 5 Curso 2007/08 Página 41 de 41


 
Resumen de programación 3

Tema 6. Algoritmos voraces.

Alumna: Alicia Sánchez


Centro: UNED-Las Rozas (Madrid)
Índice:
6.1. Dar la vuelta (1) …………………………………………………... 3
6.2. Características generales .………………………………………… 4
6.3. Grafos: árboles de recubrimiento mínimo ………………………... 6
6.3.1. Algoritmo de Kruskal …………………………………….. 9
6.3.2. El algoritmo de Prim ……………………………………... 14
6.4. Grafos: caminos mínimos ……………………………………….. 18
6.5. El problema de la mochila (1) …………………………………… 24
6.6. Planificación ……………………………………………………... 28
6.6.1. Minimización del tiempo en el sistema …………………... 28
6.6.2. Planificación con plazo fijo ………………………………. 30

Bibliografía:
Se han tomado apuntes de los libros:

Resumen tema 6 Curso 2007/08 Página 2 de 39


 Fundamentos de algoritmia. G. Brassard y P. Bratley

Empezaremos a ver los algoritmos voraces, ya que son los más fáciles de ver.
Resultan fáciles de inventar e implementar y cuando funcionan son muy
eficientes. Sin embargo, hay muchos problemas que no se pueden resolver usando el
enfoque voraz.
Los algoritmos voraces se utilizan típicamente para resolver problemas de
optimización. Por ejemplo, la búsqueda de la recta más corta para ir desde un nodo a
otro a través de una red de trabajo o la búsqueda del mejor orden para ejecutar un
conjunto de tareas en una computadora.
Un algoritmo voraz nunca reconsidera su decisión, sea cual fuere la situación que
pudiera surgir más adelante.
Veremos en el siguiente apartado un ejemplo cotidiano para la que esta táctica
funciona bien. En los siguientes apartados, se seguirán los apartados del temario, en
los que ha sido imposible resumir las demostraciones, sobre todo, por lo que
realmente este tema es una copia casi exacta del libro. Nuestro convenio es ver
primero el funcionamiento del algoritmo, luego ejemplos (uno o varios),
demostración de optimalidad y, por último, costes del algoritmo.
6.1. Dar la vuelta (1)
Se nos dan estas monedas: 100, 25, 10, 5 y 1 pts. Nuestro problema consiste en
diseñar un algoritmo para pagar una cierta cantidad a un cliente, utilizando el
menor número posible de monedas. Por ejemplo, si tenemos que pagar 289 pts.,
habría que usar estas monedas: 2 de 100, 3 de 25, 1 de 10 y 4 de 1 pts.
Usamos de modo inconsciente un algoritmo voraz: empezaremos por nada y en
cada fase vamos añadiendo a las monedas que ya están seleccionadas una
moneda de la mayor denominación posible, pero que no deben llevarnos más allá
de la cantidad que haya que pagar.
El algoritmo formalizado es:
funcion devolver cambio (n): conjunto de monedas
{ Da el cambio de n unidades utilizando el menor número posible de
monedas. La constante C especifica las monedas disponibles }
const = {100,25,10,5,1}
←∅ { S es un conjunto que contendrá la solución }
←0 { s es la suma de los elementos de S }
mientras ≠ hacer
x ← el elemento de C tal que + ≤
si no existe ese elemento entonces
Devolver “no encuentro la solución”
← ∪{ };
← + ;
devolver S
Es fácil convencerse (aunque difícil de probar formalmente) que este algoritmo
siempre produce una solución óptima para nuestro problema. En algunos casos,
puede seleccionar un conjunto de monedas que no sea óptimo (más monedas que
las necesarias), mientras que en otros casos no llegue a encontrar solución aún
cuando exista, por lo que el algoritmo voraz no funciona adecuadamente.

Resumen tema 6 Curso 2007/08 Página 3 de 39


El algoritmo es “voraz”, porque en cada paso selecciona la mayor de las
monedas que puede encontrar, sin preocuparse por lo correcto de la decisión.
Además, nunca cambia de opinión. Una vez que una moneda se ha incluido en la
solución, la moneda se queda allí para siempre.

6.2. Características generales


Generalmente, los algoritmos voraces y los problemas que éstos resuelven se
caracterizan por la mayoría de propiedades siguientes:
 Son adecuadas para problemas de optimización, tal y como vimos en el
ejemplo anterior.
 Para construir la solución de nuestro problema disponemos de un
conjunto (o lista) de candidatos. Por ejemplo, para el caso de las
monedas, los candidatos son las monedas disponibles, para construir una
ruta los candidatos son las aristas de un grafo, etc. A medida que avanza
el algoritmo tendremos estos conjuntos:
- Candidatos considerados y seleccionados.
- Candidatos considerados y rechazados.
Las funciones empleadas más destacadas de este esquema son:
1. Función de solución: Comprueba si un cierto conjunto de candidatos
constituye una solución de nuestro problema, ignorando si es o no óptima
por el momento. Puede que exista o no solución.
2. Función factible: Comprueba si el candidato es compatible con la
solución parcial construida hasta el momento; esto es, si existe una
solución incluyendo dicha solución parcial y el citado candidato.
3. Función de selección: Indica en cualquier momento cuál es el más
prometedor de los candidatos restantes, que no han sido seleccionados ni
rechazados. Es la más importante de todas.
4. Función objetivo: Da el valor de la solución que hemos hallado: el
número de monedas utilizadas para dar la vuelta, la longitud de la ruta
calculada, etc. Está función no aparece explícitamente en el algoritmo
voraz.
Para resolver nuestro problema, buscamos un conjunto de candidatos que
constituyan una solución y que optimice (maximice o minimice, según los casos)
el valor de la función objetivo. Los algoritmos voraces avanzan paso a paso:
 Inicialmente, el conjunto de elementos seleccionados está vacío y el de
solución también lo está.
 En cada paso, se considera añadir a este conjunto el mejor candidato sin
considerar los restantes, estando guiada nuestra selección por la función
de selección. Se nos darán estos casos:
1. Si el conjunto ampliado de candidatos seleccionados ya no fuera
factible (no podemos completar el conjunto de solución parcial
dado por el momento), rechazamos el candidato considerado por
el momento y no lo volvemos a considerar.
2. Si el conjunto aumentado sigue siendo factible, entonces
añadimos el candidato actual al conjunto de candidatos
seleccionados. Cada vez que se amplía el conjunto de candidatos
Resumen tema 6 Curso 2007/08 Página 4 de 39
seleccionados comprobamos si este constituye una solución para
nuestro problema. Se quedará en ese conjunto para siempre.
Cuando el algoritmo voraz funciona correctamente, la primera solución que se
encuentra es la óptima.
El esquema voraz es el siguiente:
funcion voraz (C: Conjunto): conjunto
{ C es el conjunto de candidatos }
←∅ { Construimos la solución en el conjunto S }
mientras ≠ 0 y ¬solución ( ) hacer
← ( )
← \{ };
si factible ( ∪ { }) entonces ← ∪ { }
si solución ( ) entonces devolver S
si no devolver “no hay solución”
La función de selección suele estar relacionada con la función objetivo. Por
ejemplo, si estamos intentando maximizar nuestros beneficios, es probable que
seleccionemos aquel candidato restante que posea mayor valor individual. En
ocasiones, puede haber varias funciones de selección plausibles.

Veremos una forma de adecuar las características generales de los algoritmos


voraces a las particulares del problema de devolver cambio, aunque previamente
hemos visto el esquema de este problema en particular:
 Los candidatos son un conjunto de monedas. Por ejemplo, 100, 25, 10, 5
y 1 pts.
 La función de solución comprueba si el valor de las monedas
seleccionadas es exactamente el valor que hay que pagar.
 Un conjunto de monedas será factible si su valor no sobrepasa la
cantidad que haya que pagar.
 La función de selección toma la moneda de valor más alto que quede en
el conjunto de candidatos.
 La función objetivo cuenta el número de monedas utilizadas en la
solución.
Está claro que es más eficiente rechazar todas las monedas restantes de 100 pts.
(por ejemplo) cuando el valor restante que haya que pagar caiga por debajo de
ese valor. El uso de la división entera para calcular cuántas monedas de un cierto
valor hay que tomar también es más eficiente que actuar por sustracciones
sucesivas.

Resumen tema 6 Curso 2007/08 Página 5 de 39


6.3. Grafos: árboles de recubrimiento mínimo
Es el primer tipo de problemas que veremos que se pueden resolver con un
algoritmo voraz.
Sea = 〈 , 〉 un grafo conexo no dirigido en donde N es el conjunto de nodos
y A el de aristas. Cada arista posee una longitud negativa. El problema consiste
en hallar un subconjunto T de las aristas de G, tal que utilizando sólo las aristas
de T, todos los nodos deben quedar conectados y además la suma de las
longitudes de las aristas de T debe ser tan pequeña como sea posible (forman
árbol de recubrimiento mínimo). Existirá, al menos, una solución y si hubiera
dos soluciones de igual longitud total, escogemos la de menor número de aristas.
En lugar de hablar de longitudes, podemos asociar un coste a cada arista.
Entonces, el problema consistirá en hallar un subconjunto T de las aristas cuyo
coste total sea el menos posible.
Sea ′ = 〈 , 〉 el grafo parcial formado por todos los nodos de G y las aristas de
T ( ⊆ : Contenido en A o incluso igual a A) y supongamos que en N hay n
nodos. Un grafo conexo con n nodos debe tener, al menos − 1 aristas como
mínimo.
Por tanto, si G’ es conexo y T tiene más de − 1 aristas (al menos tiene un
ciclo), se puede eliminar al menos una arista sin desconectar G’ siempre y
cuando seleccionemos una arista que forme parte de un ciclo, dándose estos
casos:
1. Disminuye la longitud total de las aristas de T.
2. La longitud total queda intacta a la vez que disminuye el número de
aristas en T.
El grafo G’ se denomina árbol de recubrimiento mínimo para el grafo G si tiene
− 1 aristas tales que G’ es conexo y el coste global de las aristas que quedan es
el mínimo posible.
Ejemplo: Supongamos que los nodos de G representan unidades y sea el coste
de una arista { , } el de tender una línea telefónica desde a hasta b. Entonces, un
árbol de recubrimiento mínimo de G se corresponde con la red más barata
posible para dar servicio a todas las ciudades en cuestión, siempre y cuando sólo
se pueda utilizar conexiones directas entre ciudades.

Esto es un añadido del autor. Puede ser que el algoritmo voraz no de una única
solución, puede ser que haya más, como ejemplos podremos poner los
siguientes. Es importante, ya que suele caer preguntas similares en exámenes:

1
a b

2 3
c
Resumen tema 6 Curso 2007/08 Página 6 de 39
El coste de llegar de b a c es similar en ambos caminos (b-a y a-c) y directo. Se
quitaría la arista de coste 3, por ser la mayor de todas. Con esto además, se
demuestra que puede haber dos árboles de recubrimiento mínimo distintos en el
mismo grafo, porque forman un ciclo.
El segundo ejemplo podría ser:

1
a b

1 1
c

En este caso, podremos quitar cualquier arista y llegar a todos los nodos con el
mismo coste. Igualmente, hay varios posibles árboles de recubrimiento mínimo.

Podremos tener dos tácticas para resolver el problema que posteriormente


veremos con más detalle:
1ª táctica: Consiste en comenzar por un conjunto vacio T y seleccionar en
cada etapa la arista más corta que todavía no haya sido seleccionada o
rechazada, independientemente de donde se encuentra esa arista en G.
2ª táctica: Implica seleccionar un nodo y construir un árbol a partir de él,
seleccionando en cada etapa la arista más corta posible que pueda extender el
árbol hasta un nodo adicional.

Las componentes para el problema del recubrimiento mínimo serán:


 Los candidatos son las aristas de G.
 Un conjunto de aristas es una solución si constituye un árbol de
recubrimiento para los nodos de N (no tiene que ser recubrimiento
mínimo).
 Un conjunto de aristas es factible si no contiene ningún ciclo.
 La función de selección que utilizamos varía con el algoritmo (según las
tácticas antes expuestas).
 La función objetivo que hay que minimizar es la longitud total de las
aristas de la solución.

El lema siguiente es crucial para demostrar la corrección de los próximos


algoritmos:
6.3.1 Sea = 〈 , 〉 un grafo conexo no dirigido en el cual está dado la
longitud de todas las aristas. Sea ⊂ un subconjunto estricto de los nodos de
G. Sea ⊆ un conjunto prometedor de aristas, tal que no haya ninguna arista

Resumen tema 6 Curso 2007/08 Página 7 de 39


de T que salga de B. Sea v la arista más corta que sale de B (o una de las más
cortas si hay empates). Entonces ∪ { } es prometedor.

Demostración: Sea U un árbol de recubrimiento mínimo de G tal que ⊆ .


Este U tiene que existir puesto que T es prometedor por hipótesis. Se nos darán
estos casos:
1. Si ∈ , entonces no hay nada que probar (es ya árbol de recubrimiento
mínimo).
2. Si no, cuando añadimos v a U creamos exactamente un ciclo, en el que
debe existir otra arista llamada u, ya que sería quien cerrara dicho ciclo.

La situación hasta el momento es la siguiente (es una interpretación de un


alumno):
u de U
N/B B

V de longitud mínima

Si ahora eliminamos u, el ciclo desaparece y obtenemos un nuevo árbol V que


abarca G (recordemos que era un grafo conexo no dirigido en el cual está dada la
longitud de todas las aristas). Sin embargo, la longitud de v, por definición, no es
mayor que la de u (v tiene longitud mínima) y, por tanto, la longitud total de las
aristas de U. Por tanto, V es también un árbol de recubrimiento mínimo de G y
contiene a v.

De nuevo, la situación sería la siguiente:

N/B B

Resumen tema 6 Curso 2007/08 Página 8 de 39


V de longitud mínima

Para completar la demostración sólo hay que comentar que ⊆ porque la


arista u que se ha eliminado sale de B y, por tanto, no podría haber sido una
arista de T (recordemos que es el conjunto prometedor de aristas, tal que no haya
arista de T que sale de B).

6.3.1. Algoritmo de Kruskal.


Para este tipo de problema se nos darán estos conjuntos:
- T: Conjunto de aristas seleccionadas.
Este algoritmo hará lo siguiente:
- El conjunto de aristas T está inicialmente vacio.
- A medida que progresa el algoritmo, se añaden aristas a T, que
forman componentes conexas separadas.
- Para construir componentes conexas más y más grandes,
examinaremos las aristas de G por orden creciente de longitud. Se nos
dan dos casos:
1. Si una arista une a dos componentes distintas, se la añadimos a
T. Consiguientemente, las dos componentes conexas forman
ahora una única componente.
2. En caso contrario, se rechaza la arista: une a dos nodos de la
misma componente conexa y, por tanto, no se puede añadir a T
sin formar un ciclo.
- El algoritmo se detiene cuando sólo queda una componente conexa.

Ejemplo del algoritmo de Kruskal:

1 2

1 2 3

4 6 4 5 6

4 3 5 8 6

4 7 3

Resumen tema 6 Curso 2007/08 Página 9 de 39


Seguiremos estos pasos para resolverlo:
1er paso. Ordenamos aristas por orden creciente de costes:
Nodo Coste
{1,2} 1
{2,3} 2
{4,5} 3
{6,7} 3
{1,4} 4
{2,5} 4
{4,7} 4
{3,5} 5
{2,4} 6
{3,6} 6
{5,7} 7
{5,6} 8

2º paso. Seleccionamos aristas de la lista por orden. Vemos si unen


componentes conexas distintas, si es así fusionamos esas componentes
conexas para unirlas en una misma partición.
Paso Arista seleccionada Componentes conexas
Inicialización - {1}, {2}, {3}, {4}, {5}, {6}, {7}
1 {1,2} {1,2}, {3}, {4}, {5}, {6}, {7}
2 {2,3} {1,2,3}, {4}, {5}, {6}, {7}
3 {4,5} {1,2,3}, {4,5}, {6}, {7}
4 {6,7} {1,2,3}, {4,5}, {6,7}
5 {1,4} {1,2,3,4,5}, {6,7}
6 {2,5} Rechazada, por formar ciclo.
Están en el mismo conjunto.
7 {4,7} {1,2,3,4,5,6,7}

El coste del árbol de recubrimiento mínimo es 17.


En cada paso, el algoritmo de Kruskal va cogiendo aristas prometedoras
(aquéllas que se extienda la solución a la óptima) hasta llegar a − 1
prometedor como este ejemplo.

Resumen tema 6 Curso 2007/08 Página 10 de 39


6.3.2 El algoritmo de Kruskal halla un árbol de recubrimiento
mínimo

Demostración: se hace por inducción matemática sobre el número de aristas


que hay en el conjunto T. Mostraremos que si T es prometedor, entonces
sigue siendo prometedor en cualquier fase del algoritmo cuando se le añade
una arista adicional. Cuando se detiene el algoritmo, T da una solución de
nuestro problema; puesto que también es prometedora, la solución es óptima.
 Base: El conjunto vacio es prometedor porque G es conexo y, por
tanto, tiene que existir una solución.
 Paso de inducción: Supongamos que T (recordemos que es el conjunto
de aristas seleccionadas) es prometedor antes de que el algoritmo
añada una nueva arista = { , }. Las aristas de T dividen a los nodos
de G en dos o más componentes conexas; el nodo u se encuentra en
una de estas componentes y v está en otra componente conexa. Sea B
el conjunto de nodos de esa componente que contiene a u. Ahora:
o El conjunto de B es un subconjunto estricto de los nodos de G,
puesto que no incluye a v, por ejemplo. Gráficamente, quedaría
algo así (es una interpretación de un alumno), siendo estos
conjuntos subconjuntos de G, que por problemas con el dibujo
es imposible hacerlo. Por tanto, sería:

o T es un conjunto prometedor de aristas tal que ninguna arista


de T sale de B, porque una arista de T tiene o bien ambas
aristas en B, o ninguna arista en B, así que por definición, no
sale de B.
o e es una de las aristas más cortas que salen de B, porque todas
las aristas estrictamente más cortas ya se han examinado o bien
se han añadido a T (conjunto de aristas seleccionadas) o bien
se han rechazado porque tenían los dos extremos en la misma
componente conexa.

Resumen tema 6 Curso 2007/08 Página 11 de 39


Otra apreciación del alumno sería que en este punto, quedaría
algo así:
e
Arista más corta v
B aun no considerada

Recordemos que por ser Kruskal cogería la arista con el coste


menor, que sería e.

Concluimos que el conjunto ∪ { } también es prometedor.

Para implementar el algoritmo, es preciso efectuar rápidamente las


operaciones buscar (x), que nos dice en qué componente conexa se encuentra
el nodo x y fusionar (A, B) para fusionar dos componentes conexos. Por eso,
utilizamos la estructura de partición.
Para el algoritmo es necesario representar el grafo como un vector de aristas
con sus longitudes asociadas. El algoritmo es:
funcion Kruskal ( = 〈 , 〉: grafo, longitud: → ): conj. aristas
{ Iniciación }
Ordenar A por longitudes crecientes
← el número de nodos que hay en N
←∅ { Contendrá las aristas del árbol de recubrim. minimo }
Iniciar n conjuntos cada uno de los cuales contiene un elemento
distinto de N
{ Bucle voraz }
repetir
← { , } ← arista más corta aun no considerada
compu ← buscar (u)
compv ← buscar (v)
si compu ≠ compv entonces
fusionar (compu, compv)
← ∪ { };
hasta que T contenga − 1 aristas
devolver T

Resumen tema 6 Curso 2007/08 Página 12 de 39


Coste del algoritmo: Calculamos el tiempo de ejecución del algoritmo en
la forma siguiente. El número de operaciones es:
- ( ∗ log( )) : Coste para ordenar las aristas.
- ( ): Para iniciar los n conjuntos disjuntos.
- 2 ∗ ∗ (2 ∗ , ) : Para las operaciones de fusionar (tendrá
como máximo 2 ∗ operaciones buscar y − 1 operaciones
fusionar).
- ( ): Para el caso peor de las operaciones restantes.

Vamos a detallar más el coste de ordenar las aristas, que es ( ∗ log( )).
Veremos la parte logarítmica, teniendo en cuenta que el número de
∗( )
aristas seguirá está formula: − 1 ≤ ≤ , por eso se nos darán
estos casos:
- El grafo es disperso, es decir, ≈ , que corresponde con la parte
izquierda de la formula. Por tanto,
log( ) ≈ log( )
- El grafo es denso, por lo que ≈ , que será más cercano a la
parte derecha de la formula. Por tanto,
log( ) ≈ log( ) ≈ log( ).
Por la propiedad de los logaritmos, log( ) = 2 ∗ log( ).
En conclusión, asintóticamente, tanto si el grafo es disperso como denso
es ( ∗ log( )).

En cuanto a la parte multiplicativa, razonaremos de la misma manera:


- El grafo es disperso, por lo que el coste es ( ∗ log( )).
- El grafo es denso, por lo que el coste es ( ∗ log( )).

Una mejora del algoritmo, que es bastante importante el saberla es usar


un montículo invertido, en el que la raíz es el mínimo elemento. Se nos
darán estos costes:
o Crear un montículo: ( )
o Restaurar la condición del montículo (hundir la raíz como vimos
en el tema de estructura de datos): (log( )) ≈ (log( )).
Al repetirse un número de a veces la restauración de la condición del
montículo, en el caso peor será: (a ∗ log( )).
En el caso mejor, será coste lineal ( ), ya que tendríamos ordenadas las
aristas de menor a mayor (es otra apreciación del alumno, no aparece en
el libro, ni en otros libros que he consultado).

Resumen tema 6 Curso 2007/08 Página 13 de 39


6.3.2. El algoritmo de Prim.
Recordemos que en el algoritmo de Kruskal se toman las aristas por orden
creciente sin preocuparse por su conexión y teniendo cuidado de no crear
ciclos. El bosque crecerá al azar hasta formar un árbol de recubrimiento
mínimo con todos los componentes conexos.
En el algoritmo de Prim, por otra parte, el árbol de recubrimiento mínimo
crece de forma natural, comenzando por una raíz arbitraria. En cada fase, se
añade una nueva rama al árbol ya construido. El algoritmo se detiene cuando
se han alcanzado todos los nodos.
Veremos con más detalle el algoritmo. Se nos dan estos conjuntos:
B: Conjunto de nodos que contiene la solución (inicialmente contiene al
nodo de partida).
T: Conjunto de aristas solución (inicialmente vacio).
El algoritmo hará lo siguiente:
- Inicialmente, B contiene un único nodo arbitrario y T está vacío.
- En cada paso, el algoritmo de Prim busca la arista más corta posible
{ , }, tal que ∈ y ∈ / . Entonces añade v a B y { , } a T.
De esta manera, las aristas de T forman en todo momento un árbol de
recubrimiento mínimo para los nodos de B. Continuamos mientras
≠ .

Un enunciado informal del algoritmo es:


funcion prim ( = 〈 , 〉: grafo, longitud: → ): conj. aristas
{ Iniciación }
← ∅;
← { un miembro arbitrario de N }
mientras ≠ hacer
buscar = { , } de longitud mínima tal que
∈ y ∈ /
← ∪ { };
← ∪ { };
devolver T

Resumen tema 6 Curso 2007/08 Página 14 de 39


Ejemplo de algoritmo de Prim:
1 2

1 2 3

4 6 4 5 6

4 3 5 8 6

4 7 3

Lo resolveremos realizando estos pasos:


1er paso: seleccionamos un nodo como raíz arbitraria. En este caso, es el
nodo 1.
1 2

1 2 3

4 6 4 5 6

4 3 5 8 6

4 7 3

2o paso: En cada paso, el algoritmo de Prim busca la arista más corta posible
{ , } tal que ∈ y ∈ / . Entonces añade v a B y { , } a T.
En nuestro ejemplo tenemos:
Paso Arista seleccionada B
Inicialización - {1}
1 {1,2} {1,2}
2 {2,3} {1,2,3}
3 {1,4} {1,2,3,4}
4 {4,5} {1,2,3,4,5}
5 {4,7} {1,2,3,4,5,7}
6 {6,7} {1,2,3,4,5,6,7}

La longitud (o el coste) total es 17, como en el caso de Kruskal.

Resumen tema 6 Curso 2007/08 Página 15 de 39


Cuando haya soluciones distintas, ante empate de costes podemos escoger el
que queramos.
No es habitual escoger los nodos en orden, pero como en este caso se puede
dar. Vemos que hasta el paso 4 se añaden en orden, dependerá en todo caso
de los costes de las aristas.

Veremos la demostración del algoritmo de Prim aunque es parecida a la de


Kruskal.
6.3.3 El algoritmo de Prim halla un árbol de recubrimiento
mínimo.

Demostración: Es por inducción matemática sobre el número de aristas que


hay en el conjunto T (conjunto de aristas). Demostraremos que si T es
prometedor en alguna fase del algoritmo entonces al añadir una arista
adicional sigue siendo prometedor. Cuando se detiene el algoritmo, T da una
solución para nuestro problema, puesto que también es prometedor la
solución es óptima.
 Base: El conjunto vacio es prometedor.
 Paso de inducción: Suponga que T es prometedor inmediatamente
antes de que el algoritmo añada una nueva arista = { , }. Ahora:
o B es un subconjunto estricto de N.
o T es un conjunto de aristas prometedor, por hipótesis de
inducción.
o e es por definición una de las aristas más cortas que salen de
B.

La situación será la siguiente. Hay que destacar que aunque sea el dibujo algo
distinto al que vimos en Kruskal, resaltamos que la idea es similar y la
demostración igualmente. Quedaría:

B N/B
e

siendo:
T: Contiene las aristas seleccionadas.
G: Grafo completo (conjunto de nodos)
B: Conjunto de esos componentes que contiene a u (conjunto de nodos).
Resumen tema 6 Curso 2007/08 Página 16 de 39
La segunda implementación, que es más sencilla será:
funcion prim (L[1..n,1..n]): conj. aristas
{ Iniciación: solo el nodo 1 se encuentra en B }
← ∅; { Contendrá las aristas del árbol de recubrim. mínimo }
para ← 2 hasta n hacer
mas próximo [ ] ← 1
distmin [ ] ← [ , 1]
{ Bucle voraz }
repetir − 1 veces
min ← ∞
para ← 2 hasta n hacer
si 0 ≤ distmin[ ] < min entonces min←distmin[ ]

← ∪ { más próximo [ ], k }
distmin [ ] ← −1
para ← 2 hasta n hacer
si [ , ] ≤distmin[ ] entonces distmin[ ] ← [ , ]
más próximo [ ] ←
devolver T

Supongamos que los nodos de G que están numeradas de 1 a n, así que


= {1,2, . . , }, siendo:
L: Matriz simétrica que da la longitud de todas las aristas, con [ , ] =
∞, si no existe la arista correspondiente.
mas próximo [i]: Proporciona el nodo de B que está más próximo a i.
distmin [i]: Da la distancia desde i hasta el nodo más próximo. Si
distmin[ ] = −1, sabremos si un nodo está o no en B.
mas próximo[1] y distmin[1] no se utilizan, por ser el nodo inicial el 1.

Análisis del coste del algoritmo:


 El bucle “para”, que es interno, requiere un tiempo ( ).
 El bucle “repetir”, que es exterior, se repite − 1 veces, por lo que
requiere ( ).
El coste del algoritmo de Prim requiere un tiempo que está en ( ).

Comparación de costes de Prim y Kruskal:

Prim Kruskal
General ( ) ( ∗ log( ))

Grafo denso ( ≈ ) ( ) ( ∗ log( ))

Grafo disperso ( ≈ ) ( ) ( ∗ log( ))

Resumen tema 6 Curso 2007/08 Página 17 de 39


∗( )
Como hemos visto anteriormente, para un grafo denso, a tiende a , el
algoritmo de Kruskal requiere un tiempo de ( ∗ log( )), por lo que es
más eficiente el algoritmo de Prim.
Para un grafo disperso, a tiende a n, por lo que el algoritmo de Kruskal
mejora.

Mejora del algoritmo: Si usamos montículos invertidos, tendremos que al


igual que pasaba antes el coste es ( ∗ log( )). Si el grafo es denso o
disperso se ve como antes.

6.4. Grafos: caminos mínimos


Es el segundo tipo de problemas que veremos.
Considérese ahora un grafo dirigido = 〈 , 〉 en donde N es el conjunto de
nodos de G y A es el de aristas dirigidas. Podría ser el caso de aristas no
dirigidas considerándose el grafo siguiente:

Cada arista posee una longitud no negativa. Se toma uno de los nodos como
nodo origen. El problema consiste en determinar la longitud del camino
mínimo que va desde el origen hasta cada uno de los demás nodos del grafo.
Este problema se puede resolver mediante un algoritmo voraz que recibe el
nombre de algoritmo de Dijkstra. Emplea estos conjuntos:
 S: Contiene aquellos nodos que ya han sido seleccionados, cuya distancia
es conocida para todos los nodos de este conjunto.
 C: Contiene todos los demás nodos, cuya distancia mínima desde el
origen todavía no es conocida y que son candidatos a ser seleccionados
posteriormente.
La unión de ambos conjuntos sería = ∪ , o lo que es igual, ambos
conjuntos formarán el conjunto total.

El funcionamiento del algoritmo es el siguiente:


 En un primer momento, S contiene nada más que el origen; cuando se
detiene el algoritmo, S contiene todos los nodos del grafo y el problema
está resuelto.
 En cada paso, seleccionamos aquel nodo de C cuya distancia al origen sea
mínima y se lo añadimos a S.

Resumen tema 6 Curso 2007/08 Página 18 de 39


Camino especial: Diremos que un camino desde el origen a algún otro nodo es
especial si todos los nodos intermedios a lo largo del camino pertenecen a S.
Gráficamente, sería:

Origen

En cada fase del algoritmo, hay una matriz D que contiene la longitud del
camino especial más corto que va hasta cada nodo del grafo. En el momento en
que se deseé añadir un nuevo nodo v a S, el camino especial más corto hasta v es
también el más corto de los caminos posibles hasta v (minimiza D). Al acabar el
algoritmo, todos los caminos desde el origen hasta algún nodo son especiales.
Consiguientemente, los valores que hay en D dan la solución del problema de
caminos mínimos.
Suponemos una vez más que los nodos de G están numerados de 1 a n, por tanto,
= {1,2, . . , }. Podemos suponer que el nodo uno es el origen. Supongamos
que la matriz L da la longitud de todas las aristas dirigidas: [ , ] ≥ 0 si la arista
( , ) ∈ y [ , ] = ∞, en caso contrario.
Con estos datos, tendremos este algoritmo:
funcion Dijkstra (L[1..n, 1..n]): matriz [2..n]
matriz D[2..n]
{ Iniciación }
← {2, 3,.., n} { = / sólo existe implícitamente }
para ← 2 hasta n hacer [ ] ← [1, ]
{ Bucle voraz }
repetir n-2 veces
← algún elemento de C que minimiza [ ]
← \{ } { e implícitamente ← ∪ { } }
para cada ∈ hacer
[ ]← ( [ ], [ ] + [ , ]);
devolver D

siendo:
D[i]: Vector de distancias, que indica la distancia hasta el nodo i.
L[i,j]: Matriz de longitud, que indica longitud del nodo y al j.
w: Arista del conjunto C, que contiene todos los demás nodos, cuya distancia
mínima desde el origen todavía no es conocida y que son candidatos a ser
seleccionados posteriormente.

Resumen tema 6 Curso 2007/08 Página 19 de 39


El bucle principal se repite − 2 veces, ya que D no cambiaria si hiciéramos una
iteración más para eliminar el último elemento de C. Añadimos una segunda
matriz P[2. . ] llamada matriz de precedencias o vector de predecesores, en
donde P[ ] contiene el número del nodo que precede a v dentro del camino más
corto. Para hallarlo se tendría que seguir los punteros hacia atrás desde el destino
hacia el origen.
Por tanto, el algoritmo definitivo incluyendo esta matriz de precedencia o vector
de predecesores (según se guste más, en algunos exámenes he encontrado la
segunda acepción, pero es igual) quedaría así:
funcion Dijkstra (L[1..n, 1..n]): matriz [2..n]
matriz D[2..n]
matriz P[2..n]
{ Iniciación }
← {2, 3,.., n} { = / sólo existe implícitamente }
para ← 2 hasta n hacer [ ] ← [1, ]
para ← 2 hasta n hacer [ ] ← 1
{ Bucle voraz }
repetir − 2 veces
← algún elemento de C que minimiza [ ]
← \{ } { e implícitamente ← ∪ { } }
para cada ∈ hacer
si [ ] > [ ] + [ , ] entonces
[ ] ← [ ] + [ , ];
[ ]← ;
devolver D

Un ejemplo del algoritmo de Dijkstra será el siguiente:

1
10 50
100 30
5 2

10 20 5

4 50 3

El nodo de partida es el 1. Será importante identificarlo bien, ya que para


resolverlo no es lo mismo empezar por el nodo 3 que por el 1 o cualquier otro
nodo distinto. No saldrá la misma solución.

Resumen tema 6 Curso 2007/08 Página 20 de 39


Tendremos estos pasos para resolverlo este ejemplo. Añadimos el vector de
precedencias y a continuación los explicamos:

Paso v C D P
2 3 4 5
Inicialización - {2,3,4,5} [50,30,100,10] [1,1,1,1]
1 5 {2,3,4} [50,30,20,10] [1,1,5,1]
2 4 {2,3} [40,30,20,10] [4,1,5,1]
3 3 {2} [35,30,20,10] [3,1,5,1]

Inicialización: El conjunto de candidatos C es de 4 nodos, sin incluir el origen,


que es el nodo 1. Ponemos todos los costes del nodo 1 al resto de nodos. Si no
hubiera conexión directa la distancia será ∞.
1er paso: Se modifica el valor para llegar a 4 a través de 5.
2o paso: Modificamos el valor de 2 a través de 4.
3er paso y último: Modificamos el valor de 2, ahora a través de 3.

NOTA: Es fundamental decir que se parará en este último paso, aunque quede un
candidato por escoger, ya que tenemos todos los caminos mínimos posibles.

La demostración que el algoritmo funciona es por inducción matemática.


6.4.1 El algoritmo de Dijkstra halla los caminos mínimos desde un
único origen hasta los demás nodos del grafo.

Demostración: Demostraremos por inducción matemática que:


a) Si un nodo ≠ 1 está en S, entonces D[ ] da la longitud del camino más
corto desde el origen hasta i, y
b) Si un nodo y no está en S, entonces D[ ] da la longitud del camino
especial más corto desde el origen hasta i.
 Base: Inicialmente, sólo el nodo 1, que es el origen, se encuentra en S, así
que la situación a) es cierta sin más demostración. Para los demás nodos,
el único camino especial desde el origen es el camino directo y D recibe
valores iniciales en consecuencia. Por tanto, la situación b) es también
cierta cuando comienza el algoritmo.
 Hipótesis de inducción: La hipótesis de inducción es que tanto la
situación a) como la b) son válidas inmediatamente antes de añadir un
nodo v a S (conjunto de nodos seleccionados). Detallamos los pasos de
inducción por separado para ambas situaciones.
 Paso de inducción para la situación a): Para todo nodo que ya esté en S
antes de añadir v no cambia nada, así que la situación a) sigue siendo
válida. En cuanto al nodo v, ahora pertenecerá a S. Antes de añadirlo a S,
es preciso comprobar que D[ ] proporcione la longitud del camino más
corto que va desde el origen hasta v. Por hipótesis de inducción, nos da
ciertamente la longitud del camino más corto. Por tanto, hay que verificar

Resumen tema 6 Curso 2007/08 Página 21 de 39


que el camino más corto desde el origen hasta v no pase por ninguno de
los nodos que no pertenecen a S.
Supongamos lo contrario; supongamos que cuando se sigue el camino
más corto desde el origen hasta v, se encuentran uno o más nodos (sin
contar el propio v) que no pertenecen a S. Sea x el primer nodo
encontrado con estas características. Ahora el segmento inicial de esa
ruta, hasta llegar a x, es una ruta especial, así que la distancia hasta x es
D[ ], por la parte b) de la hipótesis de inducción. Claramente, la distancia
total hasta v a través de x no es más corta que este valor, porque las
longitudes de las aristas son no negativas. Finalmente, D[ ] no es menor
que D[v], porque el algoritmo ha seleccionado a v antes que a x. Por
tanto, la distancia total hasta v a través de x es como mínimo D[ ] y el
camino a través de x no puede ser más corto que el camino especial que
lleva hasta v.
Gráficamente, sería:
x El camino más corto

Origen

S
v
El camino especial más corto

 Paso de inducción para la situación b): Considérese ahora un nodo w,


distinto de v, que no se encuentre en S. Cuando v se añade a S, hay dos
posibilidades para el camino especial más corto desde el origen hasta w:
1. O bien no cambia.
2. O bien ahora pasa a través de v.
En el segundo caso, sea x el último nodo de S visitado antes de llegar a
w. La longitud de este camino es [ ] + [ , ]. Parece a primera vista
que para calcular el nuevo valor de [ ] deberíamos comparar el valor
anterior de [ ] con [ ] + [ , ] para todo nodo x de S (incluyendo a
v). Sin embargo, para todos los nodos x de S salvo v, esta comparación se
ha hecho cuando se añadió x a S y [ ] no ha variado desde entonces.
Por tanto, el nuevo valor de [ ] se puede calcular sencillamente
comparando el valor anterior con [ ] + [ , ].
Puesto que el algoritmo hace esto explícitamente, asegura que la parte b)
de la inducción siga siendo cierta también cuando se añade a S un nuevo
nodo v.
Para completar la demostración de que el algoritmo funciona, obsérvese que
cuando se detenga el algoritmo, todos los nodos menos uno estarán en S. En
ese momento queda claro que el camino más costo desde el origen hasta el
nodo restante es un camino especial.

Resumen tema 6 Curso 2007/08 Página 22 de 39


Coste del algoritmo: Recordemos que hemos visto estas dos
implementaciones, por el momento:
1. La primera teníamos dos vectores L y D.
2. Añadimos el vector de precedencia o matriz de predecesores P.
El coste de ambas implementaciones lo determinara el bucle “mientras”, en el
que se irán seleccionando una arista cada vez. Tendremos lo siguiente:
( − 1) + ( − 2) + ⋯ + 1 ≈ ∑ ≈
Por tanto, el coste del algoritmo es ( ), ya que aunque se añada un vector
más en la segunda implementación las operaciones son elementales, por lo
que se quedaría igual.

Mejora del algoritmo: Usaremos un montículo invertido, que contiene un


nodo para cada elemento v de C que minimiza D[ ] que se encuentra siempre
en la raíz.
El coste como hemos visto en los algoritmos anteriores corresponderá con:
 Inicialización: ( )
 Flotar o hundir la raíz n veces: ( ∗ log( )).
Igualmente, tendremos dos casos, según el tipo de grafo:
 Si es grafo disperso ( ≈ ): ( ∗ log( )).
 Si es grafo denso ( ≈ ): ( ∗ log( )).
Se concluye, por tanto, que es más conveniente si fuera grafo disperso.

Resumen tema 6 Curso 2007/08 Página 23 de 39


6.5. El problema de la mochila (1).
Nos dan n objetos y una mochila. Para = 1,2, … , , el objeto i tiene un peso
positivo ( > 0)y un valor positivo ( > 0). La mochila puede llevar un
peso que no sobrepase W.
Podremos fraccionar los objetos (es muy importante): 0 ≤ ≤ 1, de manera
que podamos decidir llevar solamente una fracción del objeto .
Nuestro objetivo es llenar la mochila de tal manera que se maximice el valor de
los objetos transportados, respetando la limitación de la capacidad impuesta (sin
sobrepasar W).
El problema, por tanto, se puede enunciar de la siguiente manera:
Maximizar ∑ ∗ con la restricción ∑ ∗ ≤
donde ( > 0), ( > 0) y 0 ≤ ≤ 1 para 1 ≤ ≤ .

Utilizaremos un algoritmo voraz para resolverlo, para lo cual tendremos estas


componentes:
 Candidatos: Son los propios objetos.
 Solución: Es un conjunto ( , , … , ) que indica que fracción de cada
objeto hay que incluir.
 La solución será factible cuando se respeten las restricciones indicadas
antes.
 La función objetivo es el valor total de los objetos que están en la
mochila.
 La función de selección es la que quedaría por ver. Tendremos tres
posibles funciones de selección:
- Seleccionar el objeto más valioso, argumentando que esto
incrementa el valor de la carga más rápido posible.
- Seleccionar el objeto más pequeño restante, basándonos en que de
este modo la capacidad se agota de forma más lenta posible.
- Seleccionar aquel objeto cuyo valor por unidad de peso sea el
mayor posible .

Resumen tema 6 Curso 2007/08 Página 24 de 39


El algoritmo es:
funcion mochila (w[1..n], v[1..n], W): matriz [1..n]
{ Iniciación }
para ← 1 hasta n hacer [ ] ← 0
peso ← 0
{ Bucle voraz }
mientras < hacer
← el mejor objeto restante
{ Si el objeto se puede incluir entero }
si + [ ]≤ entonces [ ] ← 1
← + []
{ Si no se puede incluir entero, se fracciona }
( )
si no [ ] ← []


devolver x

Ejemplo de problema de la mochila: Se nos dan 5 objetos distintos, con estos


pesos y valores. Además, el peso máximo será de 100. La tabla es la siguiente:
= 5, = 100
w (pesos) 10 20 30 40 50
v (valores) 20 30 66 40 60
2.0 1.5 2.2 1.0 1.2

Resolveremos este algoritmo usando las tres funciones de selección antes


puestas. Nos quedará:

Seleccionar: xi Valor

Max. vi 0 0 1 0.5 1 146


Min. wi 1 1 1 1 1 156
Max. 1 1 1 0 0.8 164

Observamos que la solución óptima es la tercera, puesto que es el de mayor


valor, que era lo que nos pedían en el problema. Se ve que las otras funciones de
selección no son óptimas. Si quisiéramos probarlo lo haríamos mediante un
contraejemplo (importante para exámenes, que lo suelen pedir).

Veremos el teorema que corrobora que esta última función de selección es


óptima.

Resumen tema 6 Curso 2007/08 Página 25 de 39


6.5.1 Si se seleccionan los objetos por orden decreciente de
entonces el algoritmo de la mochila encuentra una solución óptima.

Demostración: Supongamos que los objetos disponibles están ordenados por


orden decreciente de coste por unidad de peso:
≥ ≥⋯≥

Nuestro método para averiguarlo lo denominaremos reducción de diferencias.


Sea = ( , ,…, ) la solución hallada por el algoritmo voraz. Se nos darán
estos casos:
 Si todos los son iguales a 1, entonces esta solución es claramente
óptima.
 En caso contrario, supongamos que j denota el menor índice tal que
< 1. Examinando la forma en que funciona el algoritmo, está claro
que:
1. = 1, cuando <
2. = 0, cuando >
y que ∑ ∗ = .

Tendremos que ( ) = ∑ ∗ es el valor de la solución X.

Ahora, sea = ( , , … , ) cualquier solución factible. Como Y es factible,


∑ ∗ ≤ y, por tanto, ∑ ( − ) ∗ ≥ 0. Tendremos que ( ) =
∑ ∗ es el valor de la solución Y. Multiplicando y dividiendo por , nos
queda la resta de ambas soluciones:
( )− ( )=∑ ( − )∗ =∑ ( − )∗ ∗ .

Vemos de nuevo los casos posibles:


1. Cuando < , = 1 y, por tanto, ( − ) es positivo o nulo, mientras
que ≥ .
2. Cuando > , = 1 y, por tanto, ( − ) es negativo o nulo, mientras
que ≤ .
3. Cuando = , = .

Por tanto, en todos los casos se tiene que ( − )∗ ≥ − ∗

Con lo que se deduce que:


( )− ( )≥ ∗∑ ( − )∗ ≥ 0.

Por tanto, hemos demostrado que ninguna solución factible puede tener un valor
mayor que V( ), por lo que la solución X es óptima.

NOTA DEL AUTOR: Es una interpretación escrita de otra manera a la del libro,
pero tomando el texto. Se distinguen casos para que sea más entendible.

Resumen tema 6 Curso 2007/08 Página 26 de 39


Análisis del coste: como en ocasiones anteriores, tendremos estos costes:
 Calcular los tiene coste ( ).
 Ordenar de mayor a menor relación valor-peso : ( ∗ log( )),
pudiendo usar cualquier algoritmo para ello, heapsort, quicksort, etc.
 El esquema voraz tiene coste ( ), en el caso peor.
Por tanto, la ordenación determina el coste del algoritmo, que sería por norma
general ( ∗ ( )).

Mejora del algoritmo: Usaremos montículos de máximos, estando el mayor


valor en la raíz. Las operaciones, como en ocasiones anteriores serán:

 Crear montículo tiene coste ( ).


 Para flotar o hundir requeriremos (log( )), por lo que la propiedad del
montículo debe ser restaurada como máximo n veces (tantas como nodos
haya).
En el caso peor, el coste será de ( ∗ log( )).
En el caso mejor, es más rápido si solo se necesitan unos pocos objetos para
llenar la mochila, será coste casi lineal (estimación del autor, no del libro): ( ).

Resumen tema 6 Curso 2007/08 Página 27 de 39


6.6. Planificación.
Presentaremos dos problemas que conciernen a la forma óptima de planificar
tareas en una sola máquina:
1. El problema consiste en minimizar el tiempo que invierte cada tarea en el
sistema.
2. Las tareas tienen un plazo fijo de ejecución y cada tarea aporta unos
ciertos beneficios sólo si está acabada al llegar al plazo: nuestro objetivo
es maximizar la rentabilidad.
Pasamos a ver estos problemas con más detenimiento.

6.6.1. Minimización del tiempo en el sistema.


Un único servidor, como, por ejemplo, un dentista, un surtidor de gasolina o
un cajero de un banco, tiene que dar servicio a n clientes. El tiempo requerido
por cada cliente se conoce de antemano: el cliente i requerirá un tiempo
para 1 ≤ ≤ . Deseamos minimizar el tiempo medio requerido por cada
cliente en el sistema. A ser el número de clientes predeterminado equivale a
minimizar el tiempo total invertido en el sistema por todos los clientes. Por
tanto, deseamos minimizar
=∑ ( )

Ejemplo de minimización del tiempo en el sistema: Tenemos tres clientes


= 5, = 10 y = 3. Existen 6 órdenes de servicio posibles:
Orden Tiempo total invertido en el sistema
123 5 + (5 + 10) + (5 + 10 + 3) = 38
132 5 + (5 + 3) + (5 + 3 + 10) = 31
213 10 + (10 + 5) + (10 + 5 + 3) = 43
231 10 + (10 + 3) + (10 + 3 + 5) = 41
312 3 + (3 + 5) + (3 + 5 + 10) = 29  Óptimo
321 3 + (3 + 10) + (3 + 10 + 5) = 34
En el primer orden, se sirve al cliente 1, el cliente 2 espera mientras se sirve
al cliente 1 y entonces le llega el turno y el cliente 3 espera mientras se sirve
a los clientes 1 y 2 y se le sirve en el último lugar. El tiempo total invertido
en el sistema por los 3 clientes es de 38.
La planificación óptima se obtiene por orden creciente de tiempos de
servicio: el cliente 3, que es el de menor tiempo de servicio es servido el
primero, mientras que el cliente 2, que es el de mayor tiempo de servicio, es
servido en último orden.

Resumen tema 6 Curso 2007/08 Página 28 de 39


Para ver esta idea de que puede ser óptimo planificar los clientes por orden
creciente de tiempos de servicio, imaginemos un algoritmo voraz que
construya la solución óptima elemento a elemento. Supongamos que después
de planificar el servicio para los clientes , , … , se añade un cliente j. El
incremento de tiempos en esta fase es igual a la suma de los tiempos de
servicio para los clientes desde hasta más , que es el tiempo necesario
para servir al cliente j. Para minimizar esto, debemos minimizar . Nuestro
algoritmo voraz es bastante sencillo: en cada paso se añade al final de la
planificación al cliente que requiere el menor servicio entre los restantes.
6.6.1 El algoritmo voraz es óptimo.
Demostración: Sea = … cualquier permutación de enteros del 1
al n y sea = . Si se sirven clientes en el orden P, entonces el tiempo
requerido por el j-ésimo cliente que haya que servir será y el tiempo
transcurrido en el sistema por todos los clientes es:
( )= +( + )+( + + ) +⋯+ ( + + +⋯+ )=
= ∗ + ( − 1) ∗ +⋯+2∗ + =
= ∑ ( − + 1) ∗ .
Supongamos ahora que P no organiza a los clientes por orden de tiempos
crecientes de servicio. Entonces, se pueden encontrar dos enteros a y b con
< y > . Es decir, se sirven al cliente a-ésimo antes que al b-ésimo,
aun cuando el primero necesite más tiempo de servicio que el segundo. Sería
algo así:
1…a-1 a a+1…b-1 b b+1…n
P

Si intercambiamos la posición de esos dos clientes, obtendremos un nuevo


orden de servicio o permutación P’, que es simplemente el orden P después
de intercambiar y :
1…a-1 a a+1…b-1 b b+1…n
P

P’

El tiempo total transcurrido pasado en el sistema por todos los clientes si se


emplea la planificación P’ es:
( ′) = ( − + 1) ∗ +( − + 1) ∗ +∑ ( − + 1) ∗
,

La nueva planificación es preferible a la vieja, porque:


( )− ( )=( − + 1) ∗ ( − )+( − + 1) ∗ ( − )=
= ( − )∗( − )>0

Resumen tema 6 Curso 2007/08 Página 29 de 39


Se observa tras el intercambio que los clientes salen en su posición adecuada,
ya que < por nuestra suposición inicial, estando el resto ordenados. Por
tanto, P’ es mejor que P en conjunto.
De esta manera, se puede optimizar toda planificación en la que se sirva a un
cliente antes que requiera menos servicio. Las únicas planificaciones que
quedan son aquellas que se obtienen poniendo a los clientes por orden
creciente de tiempo de servicio. Todas las planificaciones son equivalentes y,
por tanto, todas son óptimas.
La implementación de este algoritmo es sencilla.
Análisis del coste: tendremos este coste del algoritmo:
- Ordenar los elementos por orden de tiempos creciente (no
decreciente, usando cualquier algoritmo de ordenación (montículo,
quicksort, …): ( ∗ log( ))
- Coste del algoritmo voraz: ( ).
Podemos mejorarlo usando montículos invertidos (de mínimos):
 Crear montículo: ( )
 Restaurar la condición del montículo n veces: ( ∗ log( ))
Por tanto, el coste asintóticamente coincidirá en ambas y será ( ∗ ( )).

6.6.2. Planificación con plazo fijo.


Tenemos que ejecutar un conjunto de n tareas, cada una de las cuales requiere
un tiempo unitario. En cualquier instante = 1, 2, … , podemos ejecutar
únicamente una tarea. La tarea i nos produce unos beneficios > 0 sólo en
el caso en que sea ejecutada en un instante anterior a .
En resumen:
n: Número de tareas de tiempo unitario. Por ejemplo, una hora, días,…
= 1, 2, … , En cada instante solo podemos realizar una tarea.
: Beneficio asociado a la tarea i.
: Plazo máximo de la tarea i.
El problema consiste en maximizar el beneficio total.

Un ejemplo de este algoritmo será:


i 1 2 3 4
50 10 15 30
2 1 2 1

Resumen tema 6 Curso 2007/08 Página 30 de 39


Las planificaciones que hay que considerar y los beneficios correspondientes
son:
Secuencia Beneficio Secuencia Beneficio
1 50 2, 1 60
2 10 2, 3 25
3 15 3, 1 65
4 30 4, 1 80  Óptima
1,3 65 4, 3 45

Puede haber tareas que se queden sin realizar. Tendremos, por tanto:
 Conjunto de candidatos: Las tareas.
 Conjunto factible: Se dice que un conjunto de tareas es factible si
existe, al menos, una sucesión de sus tareas que permite que todas
ellas se ejecute dentro de plazo.
 Función de selección: Un algoritmo voraz evidente consiste en
construir la planificación paso a paso, añadiendo en cada paso la
tarea que tenga el mayor valor de (ganancia) y cuando el conjunto
de tareas seleccionadas siga siendo factible.
Nuestra solución óptima es ejecutar las tareas en el orden 4, 1. Queda por
demostrar que este algoritmo siempre encuentra una planificación óptima, y
además hay que buscar una forma eficiente de implementarlo.
Sea J un conjunto de tareas. Necesitamos probar las ! permutaciones de
estas tareas para ver si J es factible. Vemos un lema que nos indicará que esto
no es así:
6.6.2 Sea J un conjunto de k tareas. Supongamos que las tareas están
numeradas de tal forma que ≤ ≤⋯≤ . Entonces el conjunto J es
factible si y sólo si la secuencia 1, 2, … , es factible.

Demostración (por contradicción): El “si” (⇒) es evidente. Para el “sólo


si” (⇐), supongamos que la secuencia 1, 2, … , no es factible. Entonces, al
menos, una de estas tareas se planifica después del plazo. Sea r cualquiera de
estas tareas, de tal manera que ≤ − 1. Dado que las tareas se planifican
por orden no decrecientes (crecientes) de plazos, esto significa que, al menos,
r tareas tienen como fecha final − 1 o anterior. Sea cual fuere la forma en
que se planifiquen, la última siempre llegará tarde, es decir, se saldrá del
plazo.
Esto demuestra que basta comprobar una sola secuencia, en orden no
decreciente, para saber si un conjunto de tareas J es o no factible.
NOTA DEL AUTOR: Lo de la demostración por contradicción, de nuevo, es
un añadido, por el texto de la propia demostración.

Resumen tema 6 Curso 2007/08 Página 31 de 39


6.6.3 El algoritmo voraz esbozado anteriormente siempre
encuentra solución óptima.

Demostración: Supongamos que el algoritmo voraz decide ejecutar un


conjuntos de tareas I, y supongamos que el conjunto J es óptimo. Sean y
secuencias factibles, que posiblemente incluyan huecos, para los dos
conjuntos de tareas en cuestión. Gráficamente, sería esto:

p y q x r Solución del algoritmo voraz

r s t p u v q w Conjunto óptimo

Reorganizando las tareas de y , podemos obtener dos secuencias


factibles ′ y ′ , que también pueden contener huecos tales que toda tarea
común a I y a J se planifique en el mismo instante en ambas secuencias. Tras
reorganizar las tareas quedaría así:
Si esta tarea es a

′ x y p r q

′ u s t p r v q w

Esta será b
Tareas comunes

Para ver esto, imaginemos que alguna tarea a aparece en las dos secuencias
factibles y , en donde queda planificada en los instantes y ,
respectivamente. Se nos darán estos casos:
 Si = no hay nada que hacer, ya que coinciden ambas tareas en
tiempo (apreciación del autor).
 En caso contrario, supongamos que < (es decir, se ejecuta
antes la misma tarea para la secuencia que para la , apreciación
del autor). Dado que la secuencia es factible, se sigue que el
plazo para la tarea a no es anterior a . Se modifica la secuencia
de la siguiente manera:
- Si hay un hueco en la secuencia en el instante , se
atrasa la tarea a del instante del instante al hueco en el
instante .
- Si hay una tarea b planificada en en el instante , se
intercambian las tareas a y b en la secuencia .
La secuencia resultante sigue siendo factible, puesto que, en
cualquier caso, a se ejecutará antes de su plazo y en el segundo
caso el traslado de b a un instante anterior, no puede causar daños.
Ahora, se planifica a en un instante en las dos secuencias
modificadas y .

Resumen tema 6 Curso 2007/08 Página 32 de 39


 Se puede aplicar un argumento similar cuando > salvo que, en
este caso, es quien debe ser modificada.
Una vez que se ha tratado una tarea a de esta manera, está claro que nunca
será preciso volver a trasladarla. Por tanto, si las secuencias y tiene m
tareas en común, después de un máximo de m modificaciones de o de
podemos asegurar que todas las tareas comunes a I y a J estarán planificadas
al mismo tiempo en ambas secuencias. Las secuencias resultantes ′ y ′
pueden no ser iguales si ≠ . Por tanto, supongamos que existe un instante
en el cual la tarea planificada en es distinta de la planificada en ′ .
o Si alguna tarea a está planificada en ′ frente a un hueco de ′ ,
entonces a no pertenece a . El conjunto ∪ { } es factible, porque
podríamos poner a en el hueco y seria más rentable que . Esto es
imposible, puesto que es óptimo por hipótesis.
o Si alguna tarea b está planificada en ′ frente a un hueco ′ , el
conjunto ∪ { } sería factible, así que el algoritmo voraz habría
incluido a b en I. Esto también es imposible, porque no lo hizo.
o La única posibilidad restante es que alguna tarea a esté planificada en
′ al lado de una tarea distinta b en ′ (como las tareas y y s en el
dibujo anterior). En este caso, a no aparece en J y b no aparece en I.
Aparentemente, hay 3 posibilidades:
 Si > (la ganancia de la tarea a es mayor que la del b),
se podría sustituir a por b en J y mejorarla. Esto es imposible,
porque J es óptima.
 Si < , el algoritmo voraz habrá seleccionado a b antes
de considerar a a, puesto que ( \{ }) ∪ { } sería factible. Esto
es imposible, puesto que el algoritmo no incluyó a b en I.
 La única posibilidad restante es que = .

Concluimos que para toda posición temporal las secuencias ′ y ′ , o bien:


 No planifican bien las tareas.
 Planifican la misma tarea.
 Planifican dos tareas distintas que producen idéntico beneficio.
El beneficio total de I es igual al beneficio del conjunto óptimo J, así que J es
óptimo.

Resumen tema 6 Curso 2007/08 Página 33 de 39


Implementaciones: Tendremos dos tipos:
1ª implementación: Suponemos que las tareas están numeradas de tal
manera que ≥ ≥ ⋯ ≥ , además que >0 y > 0, para
1≤ ≤ .
Añadimos una posición en el vector que será nuestro “centinela”, usados
para evitar comprobaciones repetitivas de rangos que consumen mucho
tiempo.
funcion secuencia ( [0. . ]): k, matriz [1. . ]
matriz j[0. . ]
{ La planificación se construye paso a paso en la matriz j.
La variable k dice cuantas tareas están ya en la planificación }
[ 0] ← [ 0] ← 0 { Centinelas }
← [ 1] ← 1 { La tarea 1 siempre se selecciona }
{ Bucle voraz }
para ← 2 hasta n hacer { Orden decreciente de g }

mientras [ ] > ( [ ], ) hacer ← − 1
si [ ] > entonces
para ← paso −1 hasta + 1 hacer
[ + 1] ← [ ];
[ + 1] ←
← + 1;
devolver , [1. . ]
Las k tareas de la matriz j están por orden creciente de plazo. Cuando se
está considerando la tarea i, el algoritmo comprueba si se puede insertar
en j en lugar oportuno sin llevar alguna tarea que ya está en j más allá de
su plazo. De ser así, i se acepta; en caso contrario, i se rechaza.
Las tareas están numeradas por orden decreciente de beneficios.

Un ejemplo de este algoritmo será:


i 1 2 3 4 5 6
20 15 10 7 5 3
3 1 1 3 1 3

Resumen tema 6 Curso 2007/08 Página 34 de 39


Observamos que ya están ordenados por orden decreciente de beneficios,
tal y como dijimos antes (es importante).
Los pasos serán:
1 2 3 4 5 6
Inicialmente: 1

Primer intento: 2 1

Segundo intento: Sin cambios

Tercero intento: 2 1 4

Cuarto intento: Sin cambios

Quinto intento: Sin cambios

La secuencia óptima es la 2, 1, 4 con valor 42

Análisis del coste: tendremos como en ocasiones anteriores estos pasos:


- Ordenación de las tareas: ( ∗ log( )). Recordemos que se
puede emplear cualquier algoritmo, como el de heapsort,
quicksort, etc.
- Algoritmo voraz: En el caso peor, las tareas están clasificadas por
orden decreciente de plazos. En este caso, cuando se está
considerando la tarea i, el algoritmo examina las = − 1 tareas
ya planificadas, para encontrar un lugar para el recién llegado y
luego las desplaza todas un lugar.
El algoritmo requiere, por tanto, un tiempo está en Ω( ). Es, por tanto,
ineficiente, por ver donde “insertamos” la tarea en cuestión.

2ª implementación: Lo veremos mediante un lema, que será:


6.6.4 Un conjunto J de n tareas es factible si y sólo si se puede
construir una secuencia factible que incluya a todas las tareas de J en la
forma siguiente. Se empieza por una planificación vacía, de longitud n.
entonces para cada tarea ∈ sucesivamente, se planifica i en el instante
en donde t es el mayor entero tal que 1 ≤ ≤ ( , ) y la tarea que
se ejecuta en el instante t no está decidida todavía.

En otras palabras, se empieza por una planificación vacía, se considera


cada tarea sucesivamente, y se añade a la planificación que se está
construyendo en el momento más tardío posible (mucho cuidado, que es
básico para está implementación), pero no antes de su fecha final.

Resumen tema 6 Curso 2007/08 Página 35 de 39


Demostración: El “si” es evidente. Para el “sólo si”; obsérvese primero
que si existe una secuencia factible, entonces existe una secuencia factible
de longitud n (número de tareas). Puesto que sólo hay n tareas por
planificar, toda secuencia más larga tendrá que contener huecos y siempre
se puede trasladar una tarea a un hueco anterior sin afectar a la
factibilidad de la secuencia.
Cuando se intenta añadir una nueva tarea, la secuencia que se está
construyendo contiene siempre al menos un hueco. Supongamos que no
se puede añadir una tarea cuyo plazo sea d. Esto puede suceder solamente
si todas las posiciones desde = 1 hasta = están ya reservadas, en
donde = ( , ). Sea > el menor entero tal que la posición =
está vacía. La planificación que ya se ha construido incluye, por tanto,
− 1 tareas, ninguna tarea con plazo exactamente a s y quizá otra más
con plazos posteriores a s. Por tanto, J contiene al menos s tareas cuyos
plazos son − 1 o anteriores. Sea cual fuere la forma en que se
planifiquen, la última llegará tarde con certeza. La situación quedaría así:
s
D D D

Hemos marcado con una D las tareas ya decididas. Pondremos la tarea lo


más tarde posible.
Por ejemplo, si tenemos que la tarea tiene plazo 5, lo pondremos en la
posición 4 (en la flecha) y no en 1.

El lema sugiere que deberíamos considerar un algoritmo que intente


llenar una por una las posiciones de una secuencia de longitud p, donde
= ( , ). Para cualquier posición t, se define:
= á { ≤ | }
Se definen ciertos conjuntos de posiciones en la forma siguiente: Dos
posiciones i y j están en el mismo conjunto si =
Posiciones del mismo conjunto
D D D D
=

Al igual que antes, D indica que la posición está ocupada (la tarea ya
decidida), mientras que la que está en blanco la posición está libre.
A medida que se asignan nuevas tareas a posiciones vacantes, los
conjuntos se fusionan para formar conjuntos más grandes, para ello se
usan estructuras de partición.
Para un conjunto dado K de posiciones, sea ( ) el menor elemento de
K. finalmente, se define una posición ficticia cero, que siempre está libre.

Resumen tema 6 Curso 2007/08 Página 36 de 39


El algoritmo será el siguiente:
i. Iniciación: Toda posición 0,1,2, … , está en un conjunto diferente
y ([ ]) = , 0 ≤ ≤ .

= , ( ) .
Mayor de los plazos
Número de tareas
La posición 0 sirve para ver cuando la planificación está llena.
ii. Adición de una tarea con plazo d; se busca un conjunto que
contenga a d; sea K este conjunto. Si ( ) = 0 se rechaza la
tarea, en caso contrario:
- Se asigna la nueva tarea a la posición ( ).
- Se busca el conjunto que contenga ( ) − 1. Llamemos L
a este conjunto (no puede ser igual a K).
- Se fusionan K y L. El valor de F para este nuevo conjunto
es el valor viejo de ( ).
Tendremos un enunciado más preciso del algoritmo rápido. Para
simplificar la descripción, suponemos que la etiqueta del conjunto
producido por una operación de fusionar es necesariamente la etiqueta de
uno de los conjuntos que hayan sido fusionados. La planificación en
primer lugar puede contener huecos; el algoritmo acaba por trasladar
tareas hacia delante para llenarlos.
funcion secuencia2 ( [1. . ]): k, matriz [1. . ]
matriz j, F[0. . ]
{ Iniciación }
= ( , { [ ]|1 ≤ ≤ });
para ← 0 hasta p hacer [ ] ← 0
[ ]←
Iniciar el conjunto { }
{ Bucle voraz }
para ← 1 hasta n hacer { Orden decreciente de g }
← ( , [ ])
← [ ]
si ≠ 0 entonces
[ ]← ;
← ( − 1)
[ ]← [ ]
{ El conjunto resultante tiene la etiqueta k o l }
fusionar ( , )
{ Sólo queda comprimir la solución }
←0
para ← 1 hasta p hacer
si [ ] > 0 entonces ← + 1
[ ]← [ ]
[
devolver , 1. . ]

Resumen tema 6 Curso 2007/08 Página 37 de 39


Ejemplo de la segunda implementación, siendo el mismo ejemplo
anterior:
i 1 2 3 4 5 6
20 15 10 7 5 3
3 1 1 3 1 3
De nuevo, están ordenados por orden decreciente de ganancias. Los pasos
son los siguientes:
Inicialmente: = , ( ) = (6,3) = 3.
Por tanto, como máximo tendremos una planificación de 3 tareas:

0 1 2 3

Primer intento: = 3. Se asigna la tarea 1 a la posición 3.


( ) = 3, ( ) = ( ) − 1 = 2. Fusionamos K con L

0 1 2

Segundo intento: = 1. Se asigna la tarea 2 a la posición 1.


( ) = 1, ( ) = ( ) − 1 = 0. Fusionamos K con L

0 2

1 3

Tercer intento: = 1. No hay posiciones libres disponibles porque el


valor de F es 0.

Cuarto intento: = 3. Se asigna la tarea 4 a la posición 3.


( ) = 1, ( ) = ( ) − 1 = 0. Fusionamos K con L

1 2

Quinto y sexto intento: No hay posiciones libres disponibles.


La secuencia óptima es la 2, 4, 1 con valor 42.

Resumen tema 6 Curso 2007/08 Página 38 de 39


Análisis del coste: Usaremos operaciones de conjuntos disjuntos, en la
que la hay que ejecutar, como máximo, 2 ∗ operaciones buscar y n
operaciones fusionar, por lo que el tiempo requerido está en ∗
(2 ∗ , ) , por tanto, tiene coste lineal ( ) .
En el caso peor, en el que suponemos que todas las tareas están
desordenadas, habría que ordenarlas, por lo que, de nuevo, el coste es
( ∗ ( )), que determinará el coste del algoritmo (apreciación del
autor).

Resumen tema 6 Curso 2007/08 Página 39 de 39


 
Resumen de programación 3

Tema 7. Divide y vencerás.

Alumna: Alicia Sánchez


Centro: UNED-Las Rozas (Madrid)
Índice:
7.1. Introducción: multiplicación de enteros muy grande ……………. 3
7.2. El caso general …………………………………………………… 8
7.3. Búsqueda binaria …………………………………………………. 9
7.4. Ordenación ……………………………………………………… 12
7.4.1. Ordenación por fusión (mergesort) ………………………... 12
7.4.2. Ordenación rápida (quicksort) …………………………….. 16
7.5. Búsqueda de la mediana ………………………………………… 21
7.6. Multiplicación de matrices ……………………………………… 23
7.7. Exponenciación …………………………………………………. 24

Bibliografía:
Se han tomado apuntes de los libros:
 Fundamentos de algoritmia. G. Brassard y P. Bratley
 Estructuras de Datos y Algoritmos. R. Hernández

Resumen tema 7 Curso 2007-08 Página 2 de 30


Previo a ver el capitulo explicaré una serie de características que tiene este resumen:
1. la primera de ellas es que la multiplicación, a diferencia de otros documentos
pondremos la multiplicación o bien con ∗ o sin ningún signo intermedio. Es
simplemente un apunte, sin ninguna importancia conceptual.
2. La segunda de ellas es que hemos omitido el último punto del temario, el 7.8,
que trata de la encriptación, ya que lo veremos en la sección de ejercicios, por lo
que omitiremos dar más detalles.
3. La tercera de ellas es que debido al evidente cansancio del autor al hacer los
documentos y al intento por resumirlos se ha intentado explicar de modo
adecuado el punto 7.7, el de la exponenciación, aunque no sé si habrá quedado
claro.
4. Por último, decir que la parte de la búsqueda binaria, así como las distintas
ordenaciones son muy importantes, por haber entrado en los últimos exámenes,
de los años 2007-08, sin desatender las otras partes del tema.
Una vez comentado estos puntos pasamos a ver el resumen de este tema. Divide y
vencerás es una técnica para diseñar algoritmos que consiste en:
- Descomponer el caso que haya que resolver en un cierto número de
subcasos más pequeños del mismo problema.
- Resolver sucesiva e independientemente todos estos subcasos.
- Combinar después las soluciones obtenidas de esta manera para obtener la
solución del caso original.
7.1. Introducción: multiplicación de enteros muy grandes
Recordaremos el método clásico de multiplicación visto en el tema 1, la cual era
simplemente la multiplicación lo más sencilla posible. Un ejemplo podrá ser:

a b c d
e f g h
ℎ∗ ℎ∗ ℎ∗ ℎ∗

+
+

Como vemos, hemos hecho la multiplicación de h con el resto de los números de


la primera fila. Iremos desplazando una posición a la izquierda al seguir
multiplicando los demás números.
Por tanto, hacemos ∗ multiplicaciones y cerca de 2 ∗ sumas,
considerándolas operaciones elementales, que es aquella cuyo tiempo de
ejecución puede ser acotada superiormente por una constante que sólo dependerá
de la implementación particular usada: de la máquina, del lenguaje de
programación, etc. (recordatorio del resumen del tema 2).
Por ello, el coste del método clásico es ( ).

Resumen tema 7 Curso 2007-08 Página 3 de 30


Otro algoritmo para la multiplicación de enteros que discutimos en el tema 1 es
que llamábamos técnica de “divide y vencerás”, que consistía en reducir la
multiplicación de dos números de n cifras a 4 multiplicaciones de números de
/2 cifras. Un ejemplo de esto podrá ser la multiplicación de 0981 x 1234, que
veremos paso a paso:
1. Descompondremos ambas cifras en números de longitud mitad, como
sigue:
w = 09 y = 12
x = 81 z = 34
2. Una vez descompuestos los dos valores (nos fijaremos que los
rellenaremos con ceros a la izquierda en caso de ser de cifras impares),
tenemos lo siguiente:
0981 x 1234 = (9 ∗ 10 + 81) (12 ∗ 10 + 34)
3. Sustituiremos la descomposición anterior las variables antes deducidas
(w, y, x y z), por lo que tenemos:
0981 x 1234 = (9 ∗ 10 + 81) (12 ∗ 10 + 34) =
= ( ∗ 10 + ) ( ∗ 10 + )
4. Por último, desarrollaremos los paréntesis de esta manera:
( ∗ 10 + ) ( ∗ 10 + ) =
= ∗ ∗ 10 + ( ∗ + ∗ ) ∗ 10 + ∗ .
Queda por decir que los números elevados a 10 indican desplazamientos
de las cifras, siendo 10 desplazamiento de 4 cifras (a la izquierda) y 10
de 2.

Tendremos, por tanto, la siguiente ecuación de recurrencia que resuelve este


problema:
( ) = 4 ∗ ( /2) + ( )
Está ecuación de recurrencia indica que hay 4 subproblemas mitad (las
correspondientes a x, y, w y z) y además, el coste de las sumas y los
desplazamientos es lineal ( ) . Por ello, las variables (del tema 4) para
resolver la recurrencia, siendo de reducción por división serán:
a: Número de llamadas recursivas = 4
b: Reducción del problema en cada llamada = 2
∗ : Coste de las operaciones extras a las llamadas recursivas. Será, en
este caso, ∗ = ⇒ =1

Nuestra resolución de la reducción por división tiene 3 casos distintos, que son:
( ) si <
( )= ∗ ( ) si =
si >

Resumen tema 7 Curso 2007-08 Página 4 de 30


Sustituimos las variables en la igualdad = , teniendo 4 > 2 , que
equivaldría al tercer caso. Por ello, el coste del algoritmo es ( ) ∈ =
= ( ).

Para mejorar el algoritmo clásico debemos encontrar una forma de reducir la


multiplicación original no a 4 multiplicaciones si no a 3 de números de tamaño
mitad ( /2). Para ello, tenemos:
= ( + )∗( + )
= ∗
= ∗
Desarrollamos r, como sigue:
= ( + )∗( + )= ∗ + ∗ + ∗ + ∗ =
= ∗ +( ∗ + ∗ )+ ∗

Despejamos ( ∗ + ∗ ) y queda:
( ∗ + ∗ )= − ∗ − ∗

Sustituimos las variables anteriores y tendremos lo siguiente:


( ∗ + ∗ )= − −

Para nuestro caso particular, lo veremos mediante un ejemplo:


= ∗ = 09 ∗ 12 = 108
= ∗ = 81 ∗ 34 = 2754
= ( + ) ∗ ( + ) = 90 ∗ 46 = 4140
Y, finalmente, reescribiendo la formula anterior, la multiplicación quedaría:
0981 ∗ 1234 = 10 ∗ + 10 ∗ ( − − )+
Para verificar que efectivamente es correcta la multiplicación sería:
0981 ∗ 1234 = 10 ∗ 108 + 10 ∗ (4140 − 108 − 2754) + 2754 =
= 1080000 + 127800 + 2754 = 1210554
Concluyendo en la misma solución que vimos con la multiplicación empleando
el modo tradicional (el ya visto en las paginas anteriores).
Cuando los operandos son muy grandes, el tiempo requerido para las sumas es
despreciable frente al tiempo que requiere una sola multiplicación. En este caso
simple (el de nuestro ejemplo), nos dará igual quitar una multiplicación y hacer
una suma.

Resumen tema 7 Curso 2007-08 Página 5 de 30


El tiempo para multiplicar 2 números de n cifras usando el algoritmo clásico
empleando esta última técnica (de 3 multiplicaciones) será:
3 ∗ ℎ( /2) + ( ) = 3 ∗ ∗ ( /2) + ( ) = ∗ ∗ + ( )=

= ∗ ℎ( ) + ( ).
siendo:
( ): Tiempo de la implementación dada del algoritmo clásico = ∗
( ): Tiempo necesario para las sumas, desplazamientos y operaciones
adicionales ( ) ∈ ( ).
( ) resultará despreciable frente a ℎ( ), cuando n sea suficientemente grande,
lo que significa que hemos ganado aproximadamente un 25% de velocidad en
comparación con el algoritmo clásico (en el que hacíamos 4 multiplicaciones).
Aun así, el nuevo algoritmo tiene coste cuadrático de nuevo.

Usando el algoritmo de forma recursiva, la ecuación de recurrencia (o el tiempo)


será:
( ) = 3 ∗ ( /2) + ( )
Tendremos los siguientes datos:
a: Número de llamadas recursivas = 3
b: Reducción del problema en cada llamada = 2
∗ : Coste de las operaciones extras a las llamadas recursivas. Como
hemos visto en el método clásico anterior, tendremos que ( ) ∈ ( )
⇒ =1
Al igual que antes y de nuevo insistiremos (hay que dar el latazo con estas
formulas, así sí que se aprenden bien), la ecuación es de reducción por división
y la resolución de dicha recursividad es:
( ) si <
( )= ∗ ( ) si =
si >

Como vimos en el caso de 4 multiplicaciones, tendremos que resolver la


ecuación y luego ver que inecuación es la adecuada = ⇒3 >2
Por ello, de nuevo será el tercer caso y el coste es ( )∈ =
.

NOTA DEL AUTOR: No sé muy bien como separar dichos algoritmos clásicos,
ya que nos guiamos por el libro de Brassard y no lo separan con el orden
“lógico” que se quisiera. Por tanto, tendremos dos algoritmos clásicos, uno, el
básico (el más burdo), de 4 multiplicaciones y otro, una mejora, de 3
multiplicaciones.

Resumen tema 7 Curso 2007-08 Página 6 de 30


Esta mejora merecerá la pena, ya que este algoritmo puede multiplicar dos
enteros muy grandes más deprisa que el algoritmo clásico de multiplicación
(insistimos, el primer algoritmo clásico, el de las 4 multiplicaciones). El
algoritmo de divide y vencerás (el último que hemos visto) puede resultar más
lento que el clásico cuando los enteros son demasiados pequeños. Por tanto, un
algoritmo de divide y vencerás debe de evitar seguir avanzando recursivamente
cuando el tamaño de los casos ya no lo justifique.
Cuando los números tienen longitud impar se pueden multiplicar fácilmente
partiéndolos del modo más equitativo posible: un número de n cifras se parte en
⌊ /2⌋ cifras (redondeo por abajo) y otro de ⌈ /2⌉ cifras.
Si fuera el tamaño de las sumas ( + y + ) impar, éstas no podrán
sobrepasar del tamaño 1 + ⌈ /2⌉. Por ejemplo, si fuera la multiplicación de 5678
y 6789, el valor de = ( + ) ∗ ( + ) = 134 ∗ 156.
Para multiplicar números de distintos tamaños podremos emplear estos
algoritmos posibles:
- Si m y n no difieren en más de un factor 2 (es decir, como en nuestro
caso de 981 y 1234), lo mejor es rellenar el operando más pequeño con
ceros (sería el operando 981) para hacerlo de longitud igual a la del otro
operando. El algoritmo de divide y vencerás utilizado con relleno está en
y el algoritmo clásico requiere un tiempo ( ) para calcular
la multiplicación de 2 enteros. La constante oculta del primero tiene más
posibilidades de ser más grande que la del segundo.
Vemos, por tanto, que el algoritmo de divide y vencerás con relleno es
( / )
más lento que el clásico cuando < .
- Para obtener un algoritmo realmente mejor, la idea es segmentar el
operando más largo, v, en bloques de tamaño m y utilizar entonces el
algoritmo de divide y vencerás para multiplicar u por cada bloque v, de
tal manera que se multiplicarán parejas de operandos de igual tamaño.
El tiempo total de ejecución necesario para multiplicar un número de n
( / )
cifras por un número de m cifras está en .

Resumen tema 7 Curso 2007-08 Página 7 de 30


7.2. El caso general
Tendremos el siguiente esquema, que está sacado del libro de problemas, ya que
creo que es más claro como lo ponen allí que en el libro de teoría (Brassard):
fun divide-y-vencerás (problema)
si suficientemente-simple (problema) entonces
dev solucion-simple (problema)
si no { No es solución suficientemente simple }
{ . . } ← decomposicion (problema)
para cada hacer
← divide-y-vencerás ( )
fpara
dev combinacion ( … )
fsi
ffun
Las funciones que han de particularizarse son:
- suficientemente-simple: Decide si un problema está por debajo del
tamaño umbral o no.
- solucion-simple: Algoritmo para resolver los casos más sencillos, por
debajo del tamaño umbral.
- descomposicion: Descompone el problema en subproblemas en tamaño
menor.
- combinacion: Algoritmo que combina las soluciones a los subproblemas
en solución al problema del que provienen.
Algunos algoritmos de divide y vencerás no siguen exactamente este esquema,
puesto que hay casos en los que no tiene sentido reducir la solución de un caso
muy grande a la de uno más pequeño. Entonces, divide y vencerás recibe el
nombre de reducción (simplificación).
Para que el enfoque de divide y vencerás merezca la pena es necesario que se
cumplan estas tres condiciones:
1. La decisión de utilizar el subalgoritmo básico (suficientemente-simple) en
lugar de hacer llamadas recursivas debe tomarse cuidadosamente.
2. Tiene que ser posible descomponer el ejemplar y en subejemplares y
recomponer las soluciones parciales de forma bastante eficiente.
3. Los subejemplares deben ser en la medida de lo posible aproximadamente
del mismo tamaño.
Tendremos que decidir la forma en la que dividir el caso y hacer llamadas
recursivas o si el caso es tan sencillo que resulte mejor invocar directamente el
subconjunto básico. Esta decisión se basa en un sencillo umbral, llamado . El
subalgoritmo básico se emplea para resolver todos aquellos casos cuyo tamaño
no supere .
La selección del mejor umbral se ve complicada por el hecho de que el valor
óptimo depende en general no sólo del algoritmo en cuestión, sino también de la
implementación particular.

Resumen tema 7 Curso 2007-08 Página 8 de 30


7.3. Búsqueda binaria
Probablemente se trate de la aplicación más sencilla de divide y vencerás, tan
sencilla que hablando con propiedad se trata de una aplicación de reducción
(simplificación) más que de divide y vencerás.
Se nos da un vector [1. . ] ordenado por orden creciente, esto es, [ ] ≤ [ ]
siempre que 1 ≤ ≤ ≤ . Sea x un elemento. El problema consiste en buscar
x en la matriz T, si es que está. Formalmente, deseamos encontrar el índice i tal
que 1 ≤ ≤ + 1 y [ − 1] < < [ ], con la convención lógica consistente
en que [0] = −∞ y [ + 1] = +∞.
La primera aproximación consiste en examinar secuencialmente los elementos de
T, hasta que o bien lleguemos al final de la matriz o bien encontramos un
elemento que no sea menor que x:
fun secuencial ( [1. . ], x)
{ Búsqueda secuencial de x en una matriz }
para ← 1 hasta n hacer
si [ ] ≥ entonces devolver i
devolver + 1;
Este algoritmo requiere un tiempo que está en ( ), donde r es el índice que se
devuelve. En el caso peor y en el promedio, la búsqueda secuencial requiere un
tiempo que está en ( ), porque el número medio de pasadas por el bucle es
.
Para acelerar la búsqueda deberíamos buscar x en la primera mitad de la matriz o
en la segunda. Para averiguar cuál de estas búsquedas es la correcta, comparamos
x con un elemento de la matriz. Sea = ⌈ /2⌉. Si ≤ [ ], entonces se puede
restringir la búsqueda de x a [1. . ] (mitad izquierda); en caso contrario, basta
con buscar en [ + 1. . ] (mitad derecha).
Para evitar comparaciones repetitivas en cada llamada recursiva, es mejor
verificar desde un comienzo si la respuesta es + 1, esto es, si x está a la
derecha de T.
NOTA DEL AUTOR (IMPORTANTE): En exámenes se verá que pidan que se
detecte si el elemento x está fuera de la matriz, esto quiere decir la anterior
expresión “estar a la derecha de T”. Por eso, el siguiente algoritmo es muy
importante el controlarlo (entró en los últimos exámenes de 2007-2008).

Dicho esto, tenemos el siguiente algoritmo:


funcion busquedabin ( [1. . ], x)
si = 0 ó > [ ] entonces devolver + 1
si no { Elemento dentro del vector }
devolver binrec ( [1. . ], x)
Observamos e insistimos por su importancia que la llamada inicial (busquedabin)
verifica que el elemento esté dentro del vector.

Resumen tema 7 Curso 2007-08 Página 9 de 30


Por tanto, la función propiamente de búsqueda binaria será:
funcion binrec ( [ . . ], x)
{ Búsqueda binaria de x en la submatriz [ . . ] con la seguridad de que
[ − 1] < ≤ [ ] }
si = entonces devolver i
← ( + )÷2
si ≤ [ ] entonces devolver binrec ( [ . . ], x) { Mitad izquierda}
si no devolver binrec ( [ + 1. . ], x) { Mitad derecha }

Como añadido del autor, tendremos otra manera para averiguar si el elemento
está en el vector T y es sustituyendo en el primer “si” del algoritmo anterior.
si = entonces
si [ ] = entonces { Elemento encontrado }
dev i
si no { Elemento fuera del vector T }
dev -1
Ambos algoritmos son iguales, es decir, el del busquedabin y esta sustitución del
bucle “si”. Bajo mi punto de vista lo que mejor encuentro es hacerlo tal y como
viene en el libro, es decir, con ambos procedimientos distintos, así es seguro que
esté correcto, aunque ver varias maneras nunca viene de más.
Sea ( ) el tiempo requerido por una llamada a binrec ( [ . . ], x), en donde
= − + 1 (siendo m el tamaño del problema) es el número de elementos que
restan a efectos de búsqueda. El tiempo requerido por una llamada a
busquedabin ( [ . . ], x) es claramente ( ) salvo por una pequeña constante
aditiva.
Analizaremos el coste de este algoritmo, teniendo en cuenta que es una
reducción por división. Por tanto, tendremos la siguiente ecuación de
recurrencia:
( ) = 1 ∗ ( /2) + (1)
Las distintas variables son:
a: Número de llamadas recursivas = 1, siendo este valor por haber una
llamada a un subproblema por cada bucle “si”.
b: Reducción del problema en cada llamada = 2
∗ : Coste de las operaciones extras a las llamadas recursivas. Tendremos
que el valor de = 0, al ser constante (1) .
De nuevo, la resolución de la recurrencia por división es:

( ) si <
( )= ∗ ( ) si =
si >

Resumen tema 7 Curso 2007-08 Página 10 de 30


Resolvemos la ecuación = , en este caso, tenemos que 1 = 2 , por lo que el
caso es el segundo. Sustituyendo, el coste del algoritmo es ∗ ( ) =
( ) .
NOTA DEL AUTOR: Es muy IMPORTANTE tener MUY claro este coste,
porque suele ser un problema bastante fácil y el coste muchas veces se confunde
como puede ser, por ejemplo, con la ordenación por montículo,.., que es
∗ ( ) .

Esta sería la versión recursiva del algoritmo de búsqueda binaria:


funcion binrec ( [ . . ], x)
{ Búsqueda binaria iterativa en x en la matriz T}
si > [ ] entonces devolver + 1
← 1; ←
mientras < hacer
{ [ − 1] < ≤ [ ] }
← ( + )÷2
si ≤ [ ] entonces ←
si no ← + 1
devolver i
Este algoritmo gráficamente haría algo así:
i j

En este caso, no es tan directa la búsqueda del elemento en ambas mitades.


Según parece y es algo que he deducido con el código el puntero i se desplazaría
a la derecha (sumando posiciones), mientras que el j al revés, de tal manera que
hasta que i sea mayor que j seguirá haciendo estos movimientos de puntero
(acabaría el algoritmo cuando i supere a j). Toda la explicación es una estimación
del autor.
Por último, decir que el coste del algoritmo es exactamente igual al que vimos
con la versión recursiva, es decir, ( ) .
De nuevo, es igualmente básico, fundamental el quedarnos con este coste. Se
insistirá numerosas veces.

Resumen tema 7 Curso 2007-08 Página 11 de 30


7.4. Ordenación
Sea [1. . ] una matriz de n elementos. Nuestro problema es ordenar estos
elementos por orden ascendente. Previamente, hemos visto que se puede resolver
mediante ordenación por selección y ordenación por inserción o mediante
ordenación por montículo. Este último algoritmo de ordenación tiene coste en el
caso peor y promedio ∗ ( ) , mientras que los dos anteriores tienen
coste cuadrático ( ). Hay varios algoritmos de ordenación que siguen el
esquema de divide y vencerás. Veremos con más detenimiento dos de ellos:
- Ordenación por fusión (mergesort).
- Ordenación rápida (quicksort).

7.4.1. Ordenación por fusión (mergesort)


El problema consiste en:
- Descomponer la matriz T en dos partes cuyos tamaños sean tan
parecidos como sea posible.
- Ordenar estas partes mediante llamadas recursivas.
- Fusionar las soluciones en cada parte, teniendo buen cuidado de
mantener el orden.
Un ejemplo de este algoritmo podría ser el siguiente:

Tomamos 2 partes de igual tamaño.

Ordenamos las partes

Fusionamos esas dos mitades

Observamos, aunque sólo es un ejemplo a grosso modo, que el vector tras


fusionar ya está ordenado.

Resumen tema 7 Curso 2007-08 Página 12 de 30


El problema es como fusionar esas mitades y hacerlo eficiente. Emplearemos
como centinela (una especie de posición auxiliar, para evitar realizar cálculos
extras) la última posición en las matrices U y V.
procedimiento fusionar ( [1. . + 1], [1. . + 1], [1. . + ])
{ Fusiona las matrices ordenadas [1. . ] y [1. . ] almacenándolas
en [1. . + ], [ + 1] y [ + 1] se utilizan como centinelas }
, ← 1;
[ + 1], [ + 1] ← ∞
para ← 1 hasta + hacer
si [ ] < [ ] entonces
[ ] ← [ ]; ← + 1
si no
[ ] ← [ ]; ← + 1

El algoritmo de ordenación por fusión es como sigue, en donde utilizamos


la ordenación por inserción (insertar) como subalgoritmo básico, que también
añadiremos a continuación (tomándolo del tema 2).
procedimiento ordenarporfusion ( [1. . ])
si n es suficientemente pequeño entonces
insertar (T)
si no
matriz [1. .1 + ⌊ /2⌋], [1. .1 + ⌊ /2⌋]
[1. . ⌊ /2⌋] ← [1. . ⌊ /2⌋]
[1. . ⌊ /2⌋] ← [1 + ⌊ /2⌋. . ]
ordenarporfusion ( [1. . ⌊ /2⌋])
ordenarporfusion ( [1. . ⌊ /2⌋])
fusionar (U, V, T)
Usaremos la función de fusionar anterior. Vamos a ver la función de insertar
tal y como hemos comentado previamente:
procedimiento insertar ([1. . ])
para ← 2 hasta n hacer
← [ ]; ← − 1;
mientras > 0 < [ ] hacer
[ + 1] ← [ ] ;
← − 1;
[ + 1] ←

Resumen tema 7 Curso 2007-08 Página 13 de 30


Gráficamente, empleando centinelas, como hemos explicado antes,
tendremos el funcionamiento de la ordenación por fusión como sigue:
1 m m+1 1 n n+1
U ∞ V ∞
i j

k
Tenemos dos punteros i y j. Comparamos los elementos a los que apuntan
estos dos punteros y vemos cuál es el menor, para luego copiarlo a T. Al
copiarlo, incrementaríamos i y k, para desplazar una posición en ambos
vectores (U y T), en este caso. Dependerá de la comparación, ver cuál es el
puntero menor (si i o j).
Para verificar que hemos llegado al final, al ir incrementándose ambos
punteros (i o j), tendremos un momento en que uno de los dos o ambos
lleguen al valor del centinela. Se nos daría un caso en que el puntero i llegara
a dicho centinela y que el j no lo hiciera, eso querrá decir que los elementos
menores de j ya están en T, por lo que los elementos hasta llegar a n (sin
contar + 1) de V se copiarían directamente a T (creo recordar que era copia
de cabos en estructura de datos). Sería algo así:

Al vector T

Veremos un ejemplo práctico de una matriz a ordenar:


Matriz que hay que ordenar
3 1 4 1 5 9 2 6 5 3 5 8 9

La matriz se parte en dos mitades


3 1 4 1 5 9 2 6 5 3 5 8 9

Una llamada recursiva a ordenar por fusión para cada mitad


1 1 3 4 5 9 2 3 5 3 6 8 9

Una llamada a fusionar


1 1 2 3 3 4 5 5 5 6 8 9 9

Resumen tema 7 Curso 2007-08 Página 14 de 30


El procedimiento de la ordenación por fusión es:
- Cuando el número de elementos que hay que ordenar es pequeño, se
utiliza un algoritmo relativamente sencillo.
- Por otra parte, cuando esté justificado por el número de elementos,
ordenarporfusion separa el ejemplar en dos subejemplares de tamaño
mitad, resuelve las dos recursivamente y entonces combina las dos
medias matrices ya ordenadas para obtener la solución del ejemplar
original.
Analizaremos el coste de la ordenación por fusión separándolo por partes
distintas, es decir, siguiendo el funcionamiento anteriormente dicho:
- La separación de T en U y V requiere un tiempo lineal.
- fusionar (U, V, T) también requiere un tiempo lineal.
- Tendremos la ecuación de recurrencia siguiente ( ) = (⌊ /2⌋) +
(⌈ /2⌉) + ( ), donde ( ) ∈ ( ). Por tanto, esta recurrencia
pasa a ser ( ) = 2 ∗ ( /2) + ( ) cuando n es par.
Resolvemos la recurrencia por reducción por división con los
siguientes valores:
a: Número de llamadas recursivas = 2
b: Reducción del problema en cada llamada = 2
∗ : Coste de las operaciones extras a las llamadas recursivas.
Tendremos que el valor de = 1, al ser el tiempo extra lineal.
De nuevo, el valor de = ⇒ 2 = 2 , siendo el caso el segundo y
resolviéndose así: ( ) ∈ ∗ ( ) = ∗ ( ) .

Por tanto, la eficiencia de ordenar por fusión (mergesort) es similar a la de


ordenación por montículo (heapsort). La ordenación por fusión puede ser
ligeramente más rápida en la práctica, pero requiere una cantidad
significativamente mayor de espacio para las matrices intermedias U y V
(recordemos que la ordenación por montículo puede hacerse in situ).
Cuando se crean subejemplares de distinto tamaño (los ejemplares están mal
distribuidos) nos queda la siguiente variante (muy mala, por cierto) del
algoritmo de ordenación por fusión, en la que tendremos una parte en la que
se tienen todos los elementos menos uno y en la otra parte tendremos un
único elemento. Por ello, será en este caso una reducción por sustracción.
procedimiento ordenarporfusionmala ( [1. . ])
si n es suficientemente pequeño entonces
insertar (T)
si no
matriz [1. .1 + ⌊ /2⌋], [1. .1 + ⌊ /2⌋]
[1. . − 1] ← [1. . − 1]
[ 1] ← [ ]
ordenarporfusion ( [1. . − 1])
ordenarporfusion ( [1. .1])
fusionar (U, V, T)
Por lo anteriormente escrito, el coste es de ( ), considerándose
evidentemente coste ineficiente con respecto al otro algoritmo.

Resumen tema 7 Curso 2007-08 Página 15 de 30


7.4.2. Ordenación rápida (quicksort)
El funcionamiento del algoritmo es el siguiente:
- El algoritmo selecciona como pivote uno de los elementos de la
matriz que haya que ordenar.
- La matriz se parte a ambos lados del pivote: se desplazan los
elementos de tal manera que los que sean mayores que el pivote (p)
queden a la derecha, mientras que los demás queden a su izquierda.
Quedaría algo así, gráficamente:

<p >p

- El pivote quedará ahora en su posición definitiva. Tendremos un


pequeño problema al colocar los elementos iguales al pivote (p),
aunque en este caso nos dará igual donde los coloquemos, si a la
derecha o a la izquierda.
- Si ahora las partes de la matriz que quedan a ambos lados del pivote
se ordenan independientemente mediante llamadas recursivas al
algoritmo, el resultado final es una matriz completamente ordenada.

Para equilibrar los tamaños de los dos subcasos que hay que ordenar, lo
idóneo es utilizar el elemento mediana como pivote. Expondremos el
concepto de mediana brevemente, para así comprenderlo mejor, aunque más
adelante lo veremos con más detenimiento.
Se define a la mediana de T como su ⌈ /2⌉-ésimo elemento. Por tanto, la
mediana es aquel elemento de T, tal que en T hay tantos elementos más
pequeños que él como elementos mayores que él.
Por ejemplo, en este vector:

3 1 4 1 5 9 2 6 5
la mediana es el número 4, porque ordenado es:

1 1 2 3 4 5 5 6 9

Aunque veremos esta definición en este apartado, lo veremos con más


detenimiento en el 7.5, empleando para ello una definición más formal. Aun
así, el ejemplo que pondremos será similar.

Resumen tema 7 Curso 2007-08 Página 16 de 30


Desafortunadamente, encontrar la mediana requiere un tiempo excesivo. Por
esta razón, nos limitaremos a utilizar como pivote un elemento arbitrario de
la matriz y esperemos tener suerte.
Es necesario que la constante multiplicativa sea pequeña, para que quicksort
sea competitivo con otras técnicas de ordenación como la ordenación por
montículo. Podremos tener estos pasos:
- Supongamos que es preciso descomponer la submatriz [ . . ]
empleando como pivote = [ ]. Una buena forma de hacer la
descomposición consiste en explorar la submatriz una sola vez, pero
empezando en los dos extremos. Los punteros k y l se inicializan i y
+ 1, respectivamente.
- A continuación, se incrementa el puntero k hasta que [ ] > y se
decrementa el puntero l hasta que [ ] ≤ . Ahora se intercambian
[ ] y [ ]. Este proceso continúa mientras sea < .
- Finalmente, se intercambian [ ] y [ ] para poner el pivote en la
posición correcta.

Los procedimientos serán estos, empezando por el del pivote:


procedmiento pivote ( [ . . ], var l)
{ Permuta los elementos de la matriz [ . . ] y proporciona un valor l,
tal que, al final, 1 ≤ ≤ ; [ ] ≤ para todo ≤ < , [ ] = , y
[ ] > para todo 1 < ≤ , en donde p es el valor inicial de [ ] }
← [ ];
← ; ← +1
repetir ← + 1 hasta que [ ] > o ≥
repetir ← − 1 hasta que [ ] ≤
mientras < hacer
intercambiar [ ] y [ ]
repetir ← + 1 hasta que [ ] >
repetir ← − 1 hasta que [ ] ≤
intercambiar [ ] y [ ]

El algoritmo siguiente es el propio de la ordenación rápida (quicksort):


procedmiento quicksort ( [ . . ])
{ Ordena la submatriz [ . . ] por orden no decreciente }
si − es suficientemente pequeño entonces
insertar ( [ . . ])
si no
pivote ( [ . . ], l)
quicksort ( [ . . − 1])
quicksort ( [ + 1. . ])

Nos fijamos que usa la función insertar, visto ya en el apartado anterior.


.

Resumen tema 7 Curso 2007-08 Página 17 de 30


Un ejemplo de este mismo algoritmo será el siguiente y así fijar los
conceptos vistos previamente. Tendremos que ordenar este vector, idéntico al
de la ordenación por fusión:
Matriz que hay que ordenar
3 1 4 1 5 9 2 6 5 3 5 8 9
La matriz se particiona tomando como pivote su primer elemento, =3
3 1 4 1 5 9 2 6 5 3 5 8 9
Se busca el primer elemento mayor que el pivote (subrayado, puntero k) y
el último elemento no mayor que el pivote (superrayado, puntero l)
3 1 4 1 5 9 2 6 5 3 5 8 9
Se intercambian esos elementos
3 1 3 1 5 9 2 6 5 4 5 8 9

Se vuelve a explorar en ambas direcciones


3 1 3 1 5 9 2 6 5 4 5 8 9
Se intercambian
3 1 3 1 2 9 5 6 5 4 5 8 9
Se explora
3 1 3 1 2 9 5 6 5 4 5 8 9
Los punteros se han cruzado (el elemento superrayado está a la izquierda
del subrayado, > ): se intercambia el pivote con el elemento superrayado
2 1 3 1 3 9 5 6 5 4 5 8 9
La partición ya está ordenada
Se ordenan recursivamente las submatrices a cada lado del pivote
1 1 2 3 3 4 5 5 5 6 8 9 9

Resumen tema 7 Curso 2007-08 Página 18 de 30


Vemos estos casos de colocación del pivote:
- Cuando el pivote p queda en un extremo (al inicio o al final, da
igual): Tendremos una versión no equilibrada de ordenación rápida,
en la que el tamaño del problema se reduce en una mitad. La
situación tras colocar los elementos menores y mayores es:
−1
p

La ecuación de recurrencia, por tanto, sería:


( ) = ( − 1) + ( )
Recolocación
del pivote
Las distintas variables al igual que hemos visto previamente es:
a: Número de llamadas recursivas = 1
b: Reducción del problema en cada llamada = 1
∗ : Coste de las operaciones extras a las llamadas recursivas.
Tendremos que el valor de = 1, al ser el tiempo extra lineal.
Recordemos que la resolución para la reducción de la recurrencia por
sustracción es la siguiente:

( ) si <1
( )= ( ) si =1
si >1

Por tanto, vemos que = 1, por lo que estaremos en el segundo caso.


Pasamos a resolverlo, siendo el tiempo ( ) ∈ ( ) = ( ).
El algoritmo se comporta muy mal en este caso, siendo éste el caso
peor. Veremos un ejemplo de este caso peor, si T ya está ordenado
antes de la llamada a quicksort obtenemos = en todas las
ocasiones, lo cual implica una llamada recursiva a un caso de tamaño
1 y otra a un caso cuyo tamaño se reduce en una unidad.
Este caso podremos compararlo con el peor de la ordenación por
fusión (donde estaban descompensadas las particiones), en la que
recordemos tenía coste cuadrático.

Resumen tema 7 Curso 2007-08 Página 19 de 30


- Por otra parte, si los elementos de la matriz que hay que ordenar se
encuentran inicialmente en orden aleatorio, tendremos que los
subejemplares para ordenar estarán suficientemente bien equilibrados.
En el caso peor, tendremos:
1 n
p
~ n/2 ~ n/2
El pivote está ya ordenado.
Tendremos esta recurrencia por división:
( ) = 2 ∗ ( /2) + ( )
Recolocación
del pivote
De nuevo, los valores de las variables son:
a: Número de llamadas recursivas = 2
b: Reducción del problema en cada llamada = 2
∗ : Coste de las operaciones extras a las llamadas recursivas.
Tendremos que el valor de = 1, al ser el tiempo extra lineal.
Resolvemos la ecuación = , siendo éste 2 = 2 , que es el
segundo caso:
( )∈ ∗ ( ) = ∗ ( ) .

Este caso correspondería con el mejor caso de esta ordenación.

- Para calcular el tiempo promedio haremos una suposición acerca de


la distribución de probabilidad de los casos de los n elementos. La
suposición más natural es que los elementos de T sean diferentes y
que todas las ! permutaciones iniciales posibles de los elementos son
igualmente equiprobables.
El tiempo medio requerido es:
( )= ∑ ( ) + ( − 1) + ( − )
Realizando diversos cálculos llegamos a la conclusión que el coste
requiere un tiempo en ∗ ( ) para ordenar n elementos en el
caso medio.
Comparación en cuanto a costes de las distintas ordenaciones: podremos
decir que la constante oculta es más pequeña que las asociadas en la
ordenación por montículo o en ordenar por fusión cuando funciona bien (el
caso mejor). Aun cuando seleccionemos la mediana de [ . . ] como pivote,
que se puede hacer en tiempo lineal, la ordenación rápida (quicksort) sigue
requiriendo un tiempo cuadrático en el caso peor, lo cual sucede si son
iguales todos los elementos que hay que ordenar (ojito con esta parte, puede
entrar perfectamente en exámenes).

Resumen tema 7 Curso 2007-08 Página 20 de 30


Para intentar mejorar el tiempo en el caso peor tendremos que descomponer T
en 3 secciones, empleando p como pivote: después de la partición, los
elementos de [ . . ] son más pequeños que p, los de [ + 1. . − 1] son
iguales a p y la de [ . . ] son más grandes que p. Tendremos lo siguiente:
1 k k+1 l-1 l j

<p =p >p

Tendremos este nuevo procedimiento:


procedimiento pivotebis (T[i. . j], p; var k, l).
Después de hacer la partición con una llamada a pivotebis (T[i. . j], T[i], k, l)
hay que llamar a quicksort recursivamente con [ . . ] y [ . . ].
Quicksort requiere ahora un tiempo en el caso peor ∗ ( ) .

7.5. Búsqueda de la mediana


Recordemos otra vez la definición de la mediana dada anteriormente, aunque de
modo breve (en la ordenación rápida o quicksort):
Sea [1. . ] una matriz de enteros y sea s un entero entre 1 y n. Se define el s-
ésimo elemento de T como aquél elemento que se encontraría en la s-ésima
posición si ordenara T en orden no decreciente. Dados T y s, el problema de
encontrar el s-ésimo elemento de T se conoce como el problema de selección. En
particular, se define la mediana de T como su ⌈ /2⌉-ésimo elemento.
Cuando n es impar y los elementos de T son diferentes, la mediana es
simplemente aquel elemento de T, tal que en T hay tantos elementos más
pequeños que él como elementos mayores que él.
Un ejemplo podrá ser este, en el que nos piden que calculemos la mediana de:

3 1 4 1 5 9 2 6 5
es 4, puesto que 3, 1, 1 y 2 son más pequeños que 4, mientras que 5, 9, 6 y 5 son
mayores. Llegaremos a esta conclusión tras ordenar el vector, cosa que habremos
hecho previamente.

Un algoritmo sencillo para determinar la mediana de [1. . ] consiste en


ordenar la matriz y extraer entonces el ⌈ /2⌉-ésimo elemento. Si utilizamos
ordenación por montículo u ordenación por fusión, esto requiere un tiempo que
está en ∗ ( ) (de nuevo hay que saberse de memoria este coste, es
básico).
Es evidente que todo algoritmo para el problema de selección se puede utilizar
para hallar la mediana: basta con seleccionar el ⌈ /2⌉-ésimo elemento más
pequeño.
NOTA DEL AUTOR: Este último párrafo supongo que querrá decir que con
cualquier algoritmo de ordenación ordenaremos el vector y basta con seleccionar
dicho elemento más pequeño. Está copiado literalmente del libro.

Resumen tema 7 Curso 2007-08 Página 21 de 30


Para calcular el s-ésimo elemento más pequeño tendremos este algoritmo,
resolviéndolo de forma parecida a la última mejora de quicksort, para ello
usamos el procedimiento pivotebis. Recordemos que la llamada a pivotebis
( [ . . ], p; var k, l) particiona a [ . . ] en 3 secciones. T se organiza de tal
manera que los elementos de [ . . ] sean más pequeños que p, los de [ +
1. . − 1] sean iguales a p y los de [ . . ] sean mayores que p. Se nos darán
estos casos posibles:
- Tras una llamada a pivotebis (T, P, k, l) hemos terminado si < < ,
puesto que entonces el s-ésimo elemento más pequeño de T es igual a p.
- Si ≤ , entonces el s-ésimo elemento más pequeño de T es ahora el s-
ésimo elemento más pequeño de [1. . ].
- Por último, si ≥ , entonces el s-ésimo elemento más pequeño de T es
ahora el ( − + 1)-ésimo elemento más pequeño de [1. . ].

1 k k+1 l-1 l j

<p =p >p

Tendremos este algoritmo para el cálculo del s-ésimo elemento más pequeño:
funcion selección (T[1. . n], s)
{ Busca el s-ésimo elemento más pequeño de T, 1 ≤ ≤ }
← 1; ←
repetir
{ La respuesta se encuentra en T[i. . j] }
← mediana (T[i. . j])
pivotebis (T[i. . j], p, k, l)
si ≤ entonces ←
si no
si ≥ entonces ←
si no
devolver p
Para hacer más eficiente el algoritmo tendremos que seleccionar el pivote como
← [ ]. Esto da lugar a que el algoritmo invierta un tiempo cuadrático en el
caso peor (como pasaba con quicksort). Sin embargo, en el caso promedio el
algoritmo modificado funciona en un tiempo lineal.
Para mejorar el caso peor de orden cuadrático tendremos que hacer que el
número de pasadas por el bucle siga siendo logarítmico, siempre y cuando
seleccionemos un pivote cercano a la mediana, lo que se denomina
pseudomediana. El tiempo en el caso peor para hallar el s-ésimo elemento más
pequeño usando el método de la pseudomediana es lineal.

La última parte, la de la pseudomediana, debido a su complejidad para resumirla,


prácticamente no la hemos tocado, por tanto, se dejaría como lectura obligada y
comprensiva.

Resumen tema 7 Curso 2007-08 Página 22 de 30


7.6. Multiplicación de matrices
El algoritmo clásico de multiplicación de matrices sigue su definición:
=∑ ∗
El tiempo está en ( ) para calcularse entradas.
Una posible mejora es multiplicar una matriz 2 x 2 en vez de en 8
multiplicaciones en 7 (parecido a la multiplicación de enteros muy grandes). La
reducción de una multiplicación con respecto a más sumas es insignificante
cuando el tamaño del problema es pequeño, pero es importante el ahorro cuando
las matrices son grandes.
La ecuación de recurrencia es:
( ) = 7 ∗ ( /2) + ( )
Deduciendo de esta ecuación, tendremos las siguientes variables para la
reducción por división:
a: Número de llamadas recursivas = 7
b: Reducción del problema en cada llamada = 2
∗ : Coste de las operaciones extras a las llamadas recursivas. El
significado de ( ) es el coste de sumar y restar matrices, que seria ( ).
Por tanto, el valor de k es = 2.
Resolviendo la recurrencia tendremos lo siguiente:
( ) si <
( )= ∗ ( ) si =
si >
Como en ocasiones anteriores sustituyendo en la ecuación = , tendremos la
inecuación 7 > 2 , por lo que estaremos en el caso tercero, con coste ( ) ∈
= . Nos fijamos que mejoramos el coste visto en el
algoritmo que emplea 8 multiplicaciones.

Resumen tema 7 Curso 2007-08 Página 23 de 30


7.7. Exponenciación
Como recordatorio veremos esta comparación de costes entre los métodos hasta
el momento vistos, en la que las multiplicaciones son operaciones elementales,
es decir, que no tiene coste extra hacerlas:
- Método clásico: Tenemos que ( , ) ∈ ( ), demostrando que
ambas cotas (inferior y superior) tienen el mismo coste.
- Divide y vencerás: Empleando una multiplicación de dos números de n
cifras tiene coste . Recordemos, que lo vimos previamente y
que era el coste hacer 3 multiplicaciones en vez de 4, por lo que sería el
algoritmo ya mejorado. Tendremos distintos casos usando el algoritmo
de divide y vencerás:
1. Si multiplicamos dos números grandes, como hemos hecho en
este apartado, empleando para ello un algoritmo de
multiplicación mediante el esquema de divide y vencerás,
tendremos que ( , ) ∈ .
2. Si multiplicamos dos números de n y m cifras
respectivamente, siendo m mucho mayor que n ( ≪ ),
tendríamos lo siguiente:
n n n n
m

Haremos multiplicaciones de dos números de n cifras, por lo


que el coste será:

= = =
( / )
=

Sean a y n dos enteros. Deseamos calcular la exponenciación = . Por


sencillez, supondremos en toda esta sección que > 0. Si n es pequeño, el
algoritmo evidente resulta adecuado:
funcion exposec (a, n)

para ← 1 hasta − 1 hacer ← ∗
devolver r
que equivale a hacer:
= = ∗ ∗ …∗ = ∗

Este algoritmo requiere un tiempo que está en ( ), puesto que la instrucción


← ∗ se ejecuta exactamente − 1 veces, siempre y cuando las
multiplicaciones cuenten como operaciones elementales. El inconveniente para
analizar el coste es que el número que se multiplica puede ser grande.

Resumen tema 7 Curso 2007-08 Página 24 de 30


A continuación y en lo que acaba el apartado, veremos la multiplicación cuando
los operandos son muy grandes, para ello es preciso tener en cuenta el tiempo
necesario para cada multiplicación. Sea ( , ) el tiempo necesario para
multiplicar dos enteros de tamaños q y s. Supongamos que ≤ y ≤
implican que ( , ) ≤ ( , ). Estimemos cuánto tiempo invierte nuestro
algoritmo multiplicando enteros cuando se invoca exposec (a, n):
Como añadido del autor veremos cuánto cambia el tamaño de r en cada pasada
(recordemos que teníamos que calcular ). Aplicaremos esta idea al problema
nuestro.
Haremos un producto de i y j cifras respectivamente, teniendo dos casos:
+ en el peor caso.
+ − 1 en el mejor caso.
Veremos el exponente, el valor y el tamaño de r:
Exponente 1 2 3 ……… i
Valor a ………
2 3
Tamaño m ………
2 −1 3 −2 − +1

El tamaño indica el número de cifras de la exponenciación.


En el nivel i de la exponenciación los casos que se nos dan son los siguientes:
: Es el caso peor y el coste de las multiplicaciones es ( , ).
− + : Es el caso mejor y el coste es ( , − 1 + 1).

Sea m el tamaño del problema a. En primer lugar, observemos que el producto


de dos enteros de tamaños i y j tiene un tamaño que es al menos + − 1 y
como máximo + . Sean y el valor y el tamaño de r al principio de la i-
ésima pasada por el bucle. Claramente, = y, por tanto, = . Dado que
= , el tamaño de es al menos + − 1 y como máximo + .
La demostración por inducción matemática de que − +1 ≤ ≤ para
todo se sigue inmediatamente. Por tanto, la multiplicación efectuada en la i-
ésima pasada por el bucle afecta a un entero de tamaño m y a un entero cuyo
tamaño se encuentre entre − + 1 e , lo cual requiere un tiempo que está
entre ( , − + 1) y ( , ). El tiempo total ( , ) que se invierte en
multiplicaciones cuando se calcula con exposec es:
∑ ( , − + 1) ≤ ( , ) ≤ ∑ ( , )
( ) ( )

donde m es el tamaño de a.

NOTA DEL AUTOR: Hay una pequeña errata en la ecuación anterior en el libro,
en el que la cota superior es ∑ ( , ) cuando anteriormente estaba puesto
( , ). Al escribirlo en nuestro resumen la hemos subsanado y escrito
correctamente.

Resumen tema 7 Curso 2007-08 Página 25 de 30


Distinguiremos dos casos de los algoritmos clásicos, en la que las
multiplicaciones ya no son operaciones elementales, como dijimos previamente:
- Algoritmo sin mejorar (realiza 4 multiplicaciones): ( , ) es una
buena estimación del tiempo total requerido por exposec, puesto que la
mayor parte del trabajo se invierte en realizar estas multiplicaciones. Si
utilizamos el algoritmo clásico de multiplicación, entonces ( , ) ∈
( ). Sea c tal que ( , ) ≤ :
( , )≤∑ (, )≤∑ ∗ ∗ = ∑ <
<
Hemos calculado la cota superior. El cálculo para la cota inferior será
igual, concluyendo que para el algoritmo sin mejorar es:
( , )∈ ( )
Esta demostración está sacada del libro. Observamos que se ha hecho con
el método clásico sin mejorar.

- Algoritmo mejorado (realiza 3 multiplicaciones): Haremos el mismo


cálculo para calcular la cota superior mediante este método mejorado:
( , )≤∑ (, ) =∑ = ∑ ≤

Tendremos mediante esta demostración que:
( , )∈
Calcularíamos la cota inferior, pero es igual, por tanto, concluimos que:
( , )∈

Para mejorar exposec haremos que = ( / ) , cuando n es par. Se puede


calcular ( / ) aproximadamente cuatro veces más deprisa que con exposec y
basta una única elevación al cuadrado para obtener el resultado deseado a partir
de ( / ) . Tendremos lo siguiente:
( / ) ( / )
= ∗ cuando n es par.
= ∗ cuando n es impar. En la siguiente llamada
recursiva la resolveríamos con el algoritmo
de arriba, ya que n sería ya par.

La recurrencia que produce es:


a si =1
( / )
= si n es par
∗ en caso contrario

Resumen tema 7 Curso 2007-08 Página 26 de 30


Un ejemplo que tendremos es:
= ∗ = ∗( ) = ∗ (( ) ) =⋯= ∗ (( ∗ ( ∗ ) ) )
Sólo utilizaría 3 multiplicaciones y 4 elevaciones al cuadrado en lugar de las 28
multiplicaciones que se necesitan con exposec.

La recursión anterior da lugar a este algoritmo:


funcion expoDV (a, n)
si = 1 entonces devolver a
si n es par entonces devolver [ ( , /2)]
devolver ∗ ( , − 1)
El número de multiplicaciones sólo es una función del exponente n, por lo que
denotamos ( ). Veremos los casos distintos:
- Cuando = , no se efectúa operación, así que (1) = 0.
- Cuando n es par, se efectúa una multiplicación (la elevación del
cuadrado de ( , /2), además de las ( /2) multiplicaciones implicadas
en la llamada recursiva a expoDV (a, /2).
- Cuando n es impar, se efectúa una multiplicación (a por ) además
de las ( − 1) multiplicaciones requeridas por la llamada recursiva a
expoDV (a, − 1).

Por tanto, la recurrencia de nuevo en función de ( ) es:


0 si = 1
( )= ( /2) + 1 si n es par
( − 1) + 1 en caso contrario

Acotamos inferior y superiormente la función mediante funciones no


decrecientes. De nuevo, tendremos estos casos:
- Cuando > 1 es impar:
( )
( )= ( − 1) + 1 = +2= (⌊ /2⌋) + 2.

- Cuando > 1 es par:


( )= (⌊ /2⌋) + 1.

Resumen tema 7 Curso 2007-08 Página 27 de 30


Por tanto, tendremos la siguiente recurrencia a partir de los casos anteriores:
(⌊ /2⌋) + 1 ≤ ( )≤ (⌊ /2⌋) + 2

Tendremos las siguientes variables empleando para ello la ecuación anterior:


a: Número de llamadas recursivas = 1, que sería 1 si es impar o par.
b: Reducción del problema en cada llamada = 2
∗ : Coste de las operaciones extras a las llamadas recursivas. Al ser una
constante la operación extra, tendremos que = 0.
Ahora estaremos en el caso segundo siendo ( ) ∈ ( ∗ log( )) = (log( )).
Las funciones anteriores (empleando operaciones elementales) están en orden
de (log( )), por lo que igualmente lo está ( ). En este mismo caso, exposec
usaba un número de multiplicaciones de tiempo ( ) para efectuar la misma
exponenciación, por tanto, podemos concluir que expoDV es más eficiente que
exposec usando este criterio. Añadiremos más adelante una tabla comparativa
incluyendo este coste también, a modo de recordatorio.

Sea ( , ) el tiempo necesario para multiplicar 2 enteros de tamaños q y s y


( , ) el tiempo que invierta en multiplicar una llamada a expoDV (a, n), en
donde m es el tamaño de a. Tendremos, por tanto, esta recurrencia:

0 si = 1
( , )≤ ( , /2) + ( /2, /2) si n es par
( , − 1) + , ( − 1) si n es impar

Se ha hecho otra pequeña modificación en el caso en el que n es impar, por lo


que se varía un parámetro de la multiplicación M, poniendo del modo correcto
bajo mi punto de vista.
Las siguientes multiplicaciones M son:
( /2, /2) = ( ∗ /2) ∗ ( ∗ /2).
, ( − 1) = ∗

Calculamos ( , ) en el caso peor, teniendo esta ecuación para calcular el


tiempo:
( , ) ≤ ( , ⌊ /2⌋) + ( ⌊ /2⌋, ⌊ /2⌋) + , ( − 1)

Recordemos que la expresión ⌊ /2⌋ significa tomar la parte inferior de la mitad


de n.

Resumen tema 7 Curso 2007-08 Página 28 de 30


Dicho esto, veremos la resolución de esta ecuación puesta arriba empleando los
distintos métodos ya conocidos:
1. Utilizando el método clásico, que recordemos que realizaba 4
multiplicaciones. Tendremos lo siguiente:
( ⌊ /2⌋, ⌊ /2⌋) ~
, ( − 1) ~
Sustituyendo en la ecuación anterior nos quedará:
( , )≤ ( , ⌊ /2⌋) +
Podremos decir que la parte de la multiplicación de las dos mitades es la que
impera en el tiempo total del algoritmo, la cual hemos resaltado en negrita.
Pasamos a resolver la recurrencia de reducción por división empleando las
siguientes variables:
a: Número de llamadas recursivas = 1
b: Reducción del problema en cada llamada = 2
∗ : Coste de las operaciones extras a las llamadas recursivas. = 2,
recordemos que había que hacer 4 multiplicaciones, siendo =
log 4 = 2. Previamente, hemos visto cual era ese coste.
Como es habitual la resolución de la recurrencia es:
( ) si <
( )= ∗ ( ) si =
si >
Sustituyendo en = , tendremos que < , por lo que el coste es
( ) ∈ ( ). Por tanto, ( , ) ∈ ( ), y además vimos que imperaba
este coste en el algoritmo.
Posteriormente veremos una tabla comparativa de costes, aunque podemos
decir que como conclusión que el coste del método clásico empleando
exposec y expoDV es el mismo, siendo éste el deducido previamente.

2. Utilizando el método mejorado, que recordemos que consistía en hacer 3


multiplicaciones. Igualmente, tendremos:
( ⌊ /2⌋, ⌊ /2⌋) ~
, ( − 1) ~
La ecuación sería:
( , )≤ ( , ⌊ /2⌋) +
Recordemos que log 3 > 1, por lo que imperará .

Resumen tema 7 Curso 2007-08 Página 29 de 30


Resolveremos, de nuevo, la recurrencia de reducción por división
empleando las siguientes variables:
a: Número de llamadas recursivas = 1
b: Reducción del problema en cada llamada = 2
∗ : Coste de las operaciones extras a las llamadas recursivas, siendo
= log 3, como hemos visto previamente.
Resolviendo = , tendremos de nuevo que < , por lo que el coste es
( )∈ . Por tanto, ( , ) ∈ .
Recordemos que el coste de realizar 3 multiplicaciones empleando exposec
era , mientras que empleando el algoritmo expoDV es
, el cual acabamos de hallar. Veremos una tabla comparativa
a modo de último recordatorio.

Compararemos los distintos métodos vistos hasta el momento:

Multiplicación
Operaciones Clásica DyV
elementales
exposec ( ) ( )
expoDV (log( )) ( )

Por último, tendremos una versión iterativa del algoritmo de exponenciación:


funcion expoiter (a, n)
← ; ← 1; ←
mientras > 0 hacer
si i es impar entonces ←

← ÷2
devolver r

Resumen tema 7 Curso 2007-08 Página 30 de 30


Resumen de programación 3

Tema 9. Exploración de grafos.

Alumna: Alicia Sánchez


Centro: UNED-Las Rozas (Madrid)
Índice:
9.1. Grafos y juegos: introducción .…………………………………… 3
9.2. Recorrido de árboles ……………………………………………... 4
9.2.1. Precondicionamiento ……………………………………… 6
9.3.Recorrido en profundidad: grafos no dirigidos …………………… 7
9.3.1. Puntos de articulación …………………………………….. 8
9.4. Recorrido en profundidad: grafos dirigidos ………………………9
9.5. Recorrido en anchura …………………………………………… 10
9.6. Vuelta atrás ……………………………………………………... 21
9.6.1. El problema de la mochila (3) …………………………… 23
9.6.2. El problema de las 8 reinas ……………………………… 25
9.6.3. El caso general …………………………………………... 29
9.7. Ramificación y poda ……………………………………………. 29
9.7.1. El problema de la asignación ……………………………. 33
9.7.2. El problema de la mochila (4) …………………………... 37
9.7.3. Consideraciones generales ………………………………. 38

Bibliografía:
Se han tomado apuntes de los libros:
 Fundamentos de algoritmia. G. Brassard y P. Bratley
 Estructuras de Datos y Algoritmos. R. Hernández

Resumen tema 9 Curso 2007-08 Página 2 de 38


En este capítulo presentamos algunas técnicas generales que se pueden utilizar
cuando no se requiere ningún orden concreto en nuestro recorrido.
9.1. Grafos y juegos: introducción
Veremos brevemente un juego llamado Nim, que consiste en que dos jugadores
escogen una serie de casillas hasta que gane el jugador que se quede con la
última cerilla. No se permiten empates.
Ambos jugadores cumplen las mismas normas. Por tanto, para ganar la partida
tendremos que imaginarnos mentalmente las jugadas tanto las nuestras como la
del contrincante presentes y futuras. Para formalizar este proceso de
pensamiento anticipatorio, representamos el juego mediante un grafo dirigido.
Cada nodo del grafo corresponde a una situación del juego y cada arista
corresponde a una jugada que nos lleva de una situación a otra.
Los nodos del grafo que corresponden a este juego son, por tanto, parejas de la
forma 〈 , 〉. En general, 〈 , 〉 con 1 ≤ ≤ indica que en la mesa quedan i
cerillas, y que en la jugada siguiente se puede tomar cualquier número de cerillas
entre 1 y j. Las aristas que salen de esta situación, esto es, las jugadas que se
pueden hacer, van a los j nodos 〈 − , min(2 ∗ , − )〉, 1 ≤ ≤ .
Para decidir cuáles son las situaciones de victoria y derrota, partimos de la
situación de derrota 〈0,0〉 y retrocedemos. Este nodo no tiene sucesor y el
jugador que se encuentre en esta situación perderá la partida. Podemos, por tanto,
resumir las reglas vistas antes: una situación será de victoria si al menos uno de
los sucesores es una situación de derrota.
Tendremos el siguiente algoritmo que determina si una situación es de victoria o
de derrota:
funcion ganarec (i, j)
{ Devuelve verdadero si y sólo si la situación 〈 , 〉 es de victoria,
suponemos que 0 ≤ ≤ }
para ← 1 hasta n hacer
si no ganarec − , (2 ∗ , − ) entonces
devolver verdadero
devolver falso
Este algoritmo tiene un inconveniente muy grande, que es que calcula el mismo
valor una y otra vez. Para solucionarlo tendremos dos enfoques:
1. Aplicando programación dinámica, cuyo algoritmo no veremos, por no
estar en nuestro temario (seria el tema 8 que nos falta por estudiar).
2. Utilizando una función con memoria, donde usaremos un vector de
booleanos indicando qué nodos hemos visitado durante el cálculo
recursivo.
Asociamos a cada nodo una etiqueta para indicar si es victoria, derrota o tablas.

Resumen tema 9 Curso 2007-08 Página 3 de 38


A lo largo del capítulo usaremos la palabra grafo de dos maneras distintas:
1. Un grafo puede ser una estructura de datos en la memoria de una
computadora.
2. El grafo solamente existe de forma implícita. Este grafo nunca llega a
existir en la memoria de la máquina. Lo veremos más adelante y será
básico para la vuelta atrás.

9.2. Recorrido de árboles


Tendremos tres técnicas para recorrer árboles, que recordemos es un grafo
acíclico, conexo y no dirigido:
 Preorden: Visitamos primero el nodo, segundo el subárbol izquierdo y,
por último, el subárbol derecho.
 Orden infijo: Visitamos primero el subárbol izquierdo, segundo el nodo
y, por último, el subárbol derecho.
 Postorden: Visitamos primero el subárbol izquierdo, después el subárbol
derecho y, por último, el nodo.
Generalmente, usaremos la primera y tercera técnica (preorden y postorden), por
lo que veremos varios ejemplos de recorrido de árboles. Pondremos el árbol
siguiente:

B C

D E F G H

I J K L M

Resumen tema 9 Curso 2007-08 Página 4 de 38


Empezamos a ver el recorrido en preorden, que será el que usemos normalmente.
El orden de los nodos visitados lo pondremos en el siguiente grafo:

1
A

2 7
B C

3 4 6 8 9
D E F G H

5 10 11 12 13

I J K L M

El recorrido en postorden será:

13
A

5 12
B C

1 3 4 6 11
D E F G H

2 7 8 9 10

I J K L M

Exploramos el árbol de izquierda a derecha en estas técnicas aunque se puede


recorrer de derecha a izquierda igualmente.
Veremos este lema correspondiente con estas técnicas, aunque no lo
demostraremos formalmente:
9.2.1 Para cada una de las seis técnicas mencionadas, el tiempo total
( ) que se necesita para explorar el árbol binario que contiene n nodos se
encuentra en ( ).

Resumen tema 9 Curso 2007-08 Página 5 de 38


9.2.1. Precondicionamiento
Emplearemos el precondicionamiento en dos casos distintos (aunque no están
distinguidos en el libro directamente por lo que lo haremos en el resumen):
1er caso: Realización de cálculos anticipados de información auxiliar.
Puede merecer la pena invertir una cierta cantidad de tiempo en calcular
resultados auxiliares que pueden ser utilizados en el futuro para acelerar
la resolución de cada paso. Esto es el precondicionamiento.
Sea a el tiempo que necesita para resolver un caso típico cuando no se
dispone de información auxiliar, sea b el tiempo si se dispone de
información auxiliar y p el tiempo para calcular esta información auxiliar.
Sin precondicionamiento, para resolver n casos típicos necesitaremos un
tiempo ∗ . Con precondicionamiento, empleamos un tiempo + ∗ .
Siempre que < , resulta ventajoso el precondicionamiento cuando
> ( ).

El empleo de este caso es para aplicaciones en tiempo real, donde se


necesita asegurar una respuesta suficientemente rápida.

2º caso: Usaremos el problema de determinar los antecesores dentro de


un árbol con raíz. Sea T un árbol con raíz, no necesariamente binario,
decimos que un nodo v de T es un antecesor del nodo w si v está en el
camino que va desde w hasta la raíz de T. El problema es dado un par de
nodos ( , ) de T determinar si v es o no antecesor de w.
Para precondicionar el árbol, recorremos en preorden y luego en
postorden, numerando secuencialmente los nodos a medida que los
visitamos. Tendremos, por tanto, que:
[ ] es el número asignado a v cuando se recorre el árbol en
preorden.
[ ] es el número asignado a v cuando se recorre el árbol en
postorden.
En preorden, recordemos que primero numeramos el nodo y después sus
subárboles de izquierda a derecha tendremos:
,
[ ]≤ [ ]⇔
á . á

En postorden, recordemos que primero numeramos sus subárboles de


izquierda a derecha y después numeramos el nodo tendremos:

[ ]≥ [ ]⇔ ,
á . á

Se sigue que:
[ ]≤ [ ] [ ]≥ [ ]⇔

Resumen tema 9 Curso 2007-08 Página 6 de 38


Los valores de premun y postnum se han calculado en un tiempo que está
en ( ), mientras que la condición requerida se puede comprobar en un
tiempo que está en (1). Esta es la importancia realmente de este caso,
que luego tarda poco en comprobar y ahorra mucho tiempo.

9.3. Recorrido en profundidad: grafos no dirigidos


Para el recorrido en profundidad se siguen estos pasos:
 Se selecciona cualquier nodo ∈ como punto de partida.
 Se marca este nodo para mostrar que ya ha sido visitado.
 Si hay un nodo adyacente a v que no haya sido visitado todavía, se toma
este nodo como punto de partida y se invoca recursivamente al
procedimiento en profundidad. Al volver de la llamada recursiva, si hay
otro nodo adyacente a v que no haya sido visitado se toma este nodo
como punto de partida siguiente, se llama recursivamente al
procedimiento y, así sucesivamente.
 Cuando están marcados todos los nodos adyacentes a v el recorrido que
comenzó en v ha finalizado. Si queda algún nodo de G que no haya sido
visitado tomamos cualquiera de ellos como nuevo punto de partida y
(como en los grafos no conexos), volvemos a invocar al procedimiento.
Se sigue así hasta que estén marcados todos los nodos de G.
El procedimiento de inicialización y arranque será:
procedimiento recorridop (G)
para cada ∈ hacer [ ] ← no visitado
para cada ∈ hacer
si [ ] ≠ visitado entonces rp (v)
El algoritmo de recorrido en profundidad siguiendo los pasos anteriores es:
procedimiento rp (v)
{ El nodo v no ha sido visitado anteriormente }
[ ] ← visitado
para cada nodo w adyacente a v hacer
si [ ] ≠ visitado entonces rp (v)

Ejemplo: Se nos da este grafo no dirigido:

2 3 4

5 6 7 8

Resumen tema 9 Curso 2007-08 Página 7 de 38


Suponemos que el nodo de partida es el 1. La exploración del grafo en
profundidad progresa en la forma siguiente:
1. rp(1) Llamada inicial
2. rp(2) Llamada recursiva
3. rp(3) Llamada recursiva
4. rp(6) Llamada recursiva
5. rp(5) Llamada recursiva; no se puede continuar
6. rp(4) No se ha visitado un vecino del nodo 1
7. rp(7) Llamada recursiva
8. rp(8) Llamada recursiva; no se puede continuar
9. No quedan nodos por visitar

Análisis del coste: Si se representa el grafo de tal manera que la lista de nodos
adyacentes tenga un acceso directo, empleando para ello lista de adyacencias
(recordemos grafolista del tema 5), entonces este trabajo es proporcional a a en
total. El algoritmo requiere un tiempo que está en ( ) para las llamadas al
procedimiento y un tiempo en ( ) para inspeccionar las marcas. Por tanto, el
tiempo de ejecución está en ( , ) .

El recorrido en profundidad de un grafo conexo asocia al grafo un árbol de


recubrimiento. Sea T este árbol. Las aristas de T corresponden a las aristas
utilizadas para recorrer el grafo; están dirigidas del primer nodo visitado al
segundo. Las aristas que no se utilizan en el recorrido del grafo no tienen una
arista correspondiente en T. El punto inicial de partida de la exploración pasa a
ser la raíz del árbol.
Resulta fácil mostrar que una arista de G que no tenga una arista correspondiente
en T une necesariamente un nodo v con alguno de sus antecesores en T.
Si el grafo que se está explorando no es conexo, entonces un recorrido en
profundidad le asocia no sólo a un único árbol, sino a un todo un bosque de
árboles, uno por cada componente conexa del árbol. Un recorrido en
profundidad también ofrece una manera de numerar los nodos del grafo que se
está visitando. Los nodos del árbol asociado se numeran en preorden.

9.3.1. Puntos de articulación


Un nodo v de un grafo conexo es un punto de articulación si el subgrafo que
se obtiene borrando v y todas las aristas en v ya no es conexo. Nos
evitaremos más detalles, puesto que no nos interesará saber más de este
apartado.

Resumen tema 9 Curso 2007-08 Página 8 de 38


9.4. Recorrido en profundidad: grafos dirigidos
El algoritmo es esencialmente el mismo que para los grafos no dirigidos; la
diferencia reside en la interpretación de la palabra “adyacente”. En un grafo
dirigido, el nodo w es adyacente al v si existe la arista dirigida ( , ).

Ejemplo: En el siguiente grafo dirigido:

2 3 4

5 6 7 8

Suponemos que el nodo de partida es el 1. La exploración del grafo en


profundidad progresa en la forma siguiente:
1. rp(1) Llamada inicial
2. rp(2) Llamada recursiva
3. rp(3) Llamada recursiva; no se puede continuar
4. rp(4) No se ha visitado un vecino del nodo 1
5. rp(8) Llamada recursiva
6. rp(7) Llamada recursiva¸ no se puede continuar
7. rp(5) Nuevo punto de comienzo
8. rp(6) Llamada recursiva¸ no se puede continuar
9. No quedan nodos por visitar

Análisis del coste: El tiempo que requiere este algoritmo también está en
( , ) . En este caso, las aristas utilizadas para visitar todos los nodos de
un grafo dirigido = 〈 , 〉 pueden formar un bosque de varios árboles aunque
G sea conexo.

Resumen tema 9 Curso 2007-08 Página 9 de 38


9.5. Recorrido en anchura
Cuando un recorrido en profundidad llega a un nodo v, intenta a continuación,
visitar algún vecino de v, después algún vecino del vecino y, así sucesivamente.
Daremos una formulación no recursiva del algoritmo de recorrido en
profundidad:
Sea pila un tipo de datos que admite dos valores apilar y desapilar. Se pretende
que este tipo represente una lista de elementos que hay que manejar por el orden
“primero en llegar, primero en salir”. La función cima denota el elemento que se
encuentra en la parte superior de la pila.
El algoritmo de recorrido en profundidad ya modificado es:
procedimiento rp2 (v)
← pila-vacía
marca[ ] ← visitado
apilar w en P
mientras P no esté vacía hacer
mientras exista un nodo w adyacente a cima (P)
tal que marca[ ] ≠ visitado hacer
marca[ ] ← visitado
apilar w en P { w es la nueva cima (P) }
desapilar P

NOTA DEL AUTOR: Tras ver el código de nuevo se ha encontrado lo que a mi


parecer es una errata, y es que nunca entraría en el bucle “mientras” a no ser que
apiles algún nodo en P, por ello se añade la línea ‘apilar w en P’. Está en la fe de
erratas del libro de problemas, por lo que se escribe correctamente (el código está
en la página 338).

Ejemplo: Tendremos el siguiente ejemplo de profundidad con pila, donde


señalaremos con flechas el sentido de apilar y desapilar, para verlo de modo más
grafico:

A B

D F

Resumen tema 9 Curso 2007-08 Página 10 de 38


Lo veremos paso a paso empezando a visitar el nodo A:
Inicialmente: La pila está vacía:

1er paso: Apilamos el nodo A y lo marcamos como visitado. En la pila: A

2o paso: Apilamos el nodo B y lo marcamos como visitado. En la pila: A, B

B
A

NOTA: Escogemos ese nodo sin ningún criterio en especial, ya que estimo que
sería más ‘lógico’ escoger el nodo C, pero ahí queda. Más abajo se aclarará como
se hace la búsqueda en profundidad.
3er paso: Apilamos el nodo C y lo marcamos como visitado. En la pila: A, B, C

C
B
A

4o paso: Apilamos el nodo D y lo marcamos como visitado. En la pila: A, B, C,


D

D
C
B
A

Resumen tema 9 Curso 2007-08 Página 11 de 38


5o paso: Apilamos el nodo E y lo marcamos como visitado. En la pila: A, B, C,
D, E

E
D
C
B
A

6o paso: Desapilamos el nodo E, porque no tiene más nodos adyacentes. En la


pila: A, B, C, D

D
C
B
A

7o paso: Desapilamos el nodo D, porque no tiene más nodos adyacentes. En la


pila: A, B, C

C
B
A

8o paso: Apilamos el nodo F, por ser un hijo del nodo C y lo marcamos como
visitado. En la pila: A, B, C, F

F
C
B
A

9o paso: Desapilamos el nodo F, porque no tiene más nodos adyacentes. En la


pila: A, B, C

C
B
A

Resumen tema 9 Curso 2007-08 Página 12 de 38


10o paso: Desapilamos el nodo C, porque no tiene más nodos adyacentes. En la
pila: A, B

B
A

11o paso: Desapilamos el nodo B, porque no tiene más nodos adyacentes. En la


pila: A

12o paso y último: Desapilamos el nodo A, porque no hay más nodos que
explorar. La pila está vacía, llegamos al final.

El orden de exploración será: A, B, C, D, E, F


Es una coincidencia el recorrerlos por orden, pero no tiene porque ser así.

Para verlo más gráficamente, tendremos este árbol que iremos marcando con
flechas por donde iríamos recorriendo:

Como se observa el recorrido iría del nodo de arriba hasta el de abajo para luego
cuando no se pueda continuar seguir con los demás nodos, tal y como hemos
visto en el ejemplo anterior.

Resumen tema 9 Curso 2007-08 Página 13 de 38


En cuanto al recorrido en anchura seguiremos estos pasos:
 Se toma cualquier nodo ∈ como punto de partida.
 Se marca este nodo como visitado.
 Después se visita a todos los adyacentes antes de seguir con nodos más
profundos.

El procedimiento de inicialización y arranque será:


procedimiento recorrido (G)
para cada ∈ hacer [ ] ← no visitado
para cada ∈ hacer
si [ ] ≠ visitado entonces { 2 } (v)
Para el algoritmo de recorrido en anchura necesitamos un tipo cola que admite
las dos operaciones poner o quitar. Este tipo representa una lista de elementos
que hay que manejar por el orden “primero en llegar, primero en salir”. La
función primero denota el elemento que ocupa la primera posición en la cola.
El recorrido en anchura no es naturalmente recursivo, por lo que el algoritmo será:
procedimiento ra (v)
← cola-vacía
poner v en Q
mientras Q no esté vacía hacer
← primero (Q)
quitar u de Q
para cada nodo w adyacente a u hacer
si marca [ ] ≠ visitado entonces
marca [ ] ← visitado
poner w en Q

NOTA DEL AUTOR: Al igual que pasaba con el algoritmo rp2, añadiremos una
nueva línea ‘poner v en Q’, de tal manera que al encolar el primer nodo ya la
cola Q no está vacía y entraría en el bucle “mientras”, exactamente como pasaba
antes.

Resumen tema 9 Curso 2007-08 Página 14 de 38


Ejemplos: Veremos un par de ejemplos que hace este tipo de búsqueda en
anchura, aunque con uno de los dos sería suficiente para saber hacerlo. De nuevo
pondremos flechas que indican el sentido de encolar y desencolar.
El primero de ellos es el siguiente, sacado del libro completamente y respetando
el orden de visitas, en el que empezaremos a visitarlo por el nodo 1:

2 3 4

5 6 7 8

Inicialmente: La cola está vacía (se añade este paso a la teoría del libro):

1er paso: Encolamos el nodo de partida, 1 (se añade este paso a la teoría del
libro). En la cola: 1

2o paso: Visitamos (desencolamos) el nodo 1 y encolamos los hijos de él, que


son el 2, 3 y 4. En la cola: 2, 3, 4

4 3 2

3er paso: Visitamos (desencolamos) el nodo 2 y encolamos los hijos,


añadiéndolos a la cola. En la cola: 3, 4, 5, 6

6 5 4 3

Resumen tema 9 Curso 2007-08 Página 15 de 38


4o paso: Visitamos (desencolamos) el nodo 3 y al no tener hijos no encolamos
ningún nodo más. En la cola: 4, 5, 6

6 5 4

5o paso: Visitamos (desencolamos) el nodo 4 y encolamos los hijos del mismo,


de nuevo. En la cola: 5, 6, 7, 8

8 7 6 5

6o paso: Visitamos (desencolamos) el nodo 5 y al tener el resto ya recorridos no


apilamos ninguno más. En la cola: 6, 7, 8

8 7 6

7o paso: Visitamos (desencolamos) el nodo 6, que por los mismos motivos de


antes no apilamos ningún nodo más. En la cola: 7, 8

8 7

8o paso: Visitamos (desencolamos) el nodo 7 y no añadimos ningún nodo más.


En la cola: 8

9o paso y último: Visitamos el último nodo y ya queda la cola vacía.

El orden de exploración será: 1, 2, 3, 4, 5, 6, 7, 8

Resumen tema 9 Curso 2007-08 Página 16 de 38


El segundo ejemplo es idéntico al que vimos anteriormente en el recorrido en
profundidad, en el que veremos todavía más desgranados los pasos, por lo que
puede quedar algo distinto al anterior, pero usando la misma técnica. Es un
ejemplo complementario para comprender más este tipo de estructuras de datos.
Empezamos por el nodo A:

A B

D F

Inicialmente: La cola está vacía:

1er paso: Encolamos el nodo de partida, A. En la cola: A

2o paso: Encolamos uno de los hijos de A, que es el B, marcándolo como


recorrido. En la cola: A, B

BA

3er paso: Encolamos el otro hijo de A, que es el C. En la cola: A, B, C

C B A

Resumen tema 9 Curso 2007-08 Página 17 de 38


4o paso: Desencolamos el nodo A, ya que no hay hijos sin visitar que explorar.
En la cola: B, C

CB

NOTA: Vemos que en el ejemplo anterior estos tres primeros pasos lo hemos
hecho en sólo uno, en los que primero encolamos los hijos, hasta recorrerlos
todos y luego desencolamos el padre.

5o paso: Desencolamos el nodo B, por no tener ningún descendiente no


explorado previamente. En la cola: C

6o paso: Encolamos uno de los hijos de C, que es el D, marcándolo como


recorrido. En la cola: C, D

DC

7o paso: Encolamos el otro hijo de C, que es el F. En la cola: C, D, F

F DC

8o paso: Desencolamos el nodo C, ya que no hay hijos sin visitar que explorar.
Nos fijamos que de nuevo, pasa algo similar al paso 2. En la cola: D, F

FD

9o paso: Encolamos el otro hijo de D, que es el E. En la cola: D, F, E

E F D

Resumen tema 9 Curso 2007-08 Página 18 de 38


10o paso: Desencolamos el nodo D, ya que no hay hijos sin visitar que explorar.
En la cola: F, E

EF

11o paso: Desencolamos el nodo F, por no tener ningún descendiente no


explorado previamente. En la cola: E

12o paso: Desencolamos el nodo E, por no tener ningún descendiente no


explorado previamente, por lo que la cola ya está vacía.

El orden de exploración será: A, B, C, D, F, E

Como hemos hecho en la exploración en profundidad veremos cómo lo haremos


en anchura de modo más grafico:

………

……………………

Al igual que el recorrido en profundidad, podemos asociar un árbol al recorrido


en anchura. Si el grafo G que se está recorriendo es no conexo, el recorrido en
anchura genera un bosque de árboles, uno por cada componente de G.

Análisis del coste: Igual que el recorrido en profundidad tendremos que el coste
es ( , ) . Se puede aplicar el recorrido en anchura tanto en grafos
dirigidos como en no dirigidos.

Resumen tema 9 Curso 2007-08 Página 19 de 38


La comparación entre ambos recorridos será la siguiente:
 El recorrido en anchura se realizará en una de estas situaciones:
1. Cuando haya que efectuar una exploración parcial de un grafo
infinito. En ocasiones puede no terminar si hay niveles con un
número infinito de vecinos (no se suele dar en la práctica), como
pudiera ser el siguiente grafo:

……

2. Para hallar el camino más corto desde un punto de un grafo a otro, es


decir, la solución será el nodo más cercano a la raíz y tiene la certeza
de hallar una solución si existe. Un ejemplo pudiera ser este grafo:

Solución

 El recorrido en profundidad puede no terminar si las ramas son infinitas.


Una rama infinita puede ser la siguiente:

Resumen tema 9 Curso 2007-08 Página 20 de 38


9.6. Vuelta atrás
Hay problemas que son inabordables mediante grafos abstractos (almacenados
en memoria). Si el grafo contiene un número elevado de nodos y es infinito,
puede resultar inútil construirlo implícitamente en memoria. En tales situaciones
emplearemos un grafo implícito, que será aquél para el cual se dispone de una
descripción de sus nodos y aristas, de tal manera que se pueden construir partes
relevantes del grafo a medida que progresa el recorrido.
En su forma básica, la vuelta atrás se asemeja a un recorrido en profundidad
dentro de un grafo dirigido. Esto se consigue construyendo soluciones parciales a
medida que progresa el recorrido; estas soluciones parciales limitan las regiones
en las que se puede encontrar una solución completa. Se nos darán estos casos:
 El recorrido tendrá éxito si se puede definir por completo una solución.
En este caso, el algoritmo puede o detenerse (si sólo necesita una
solución al problema) o bien seguir buscando soluciones alternativas (si
deseamos examinarlas todas).
 Por otra parte, el recorrido no tiene éxito si en alguna etapa de la
solución parcial construida hasta el momento no se puede completar, lo
cual denominaremos condición de poda (no se construye esa parte del
árbol). Cuando vuelve a un nodo que tiene uno o más vecinos sin
explorar, prosigue el recorrido de una solución.
Hemos visto antes que podemos explorar buscando soluciones alternativas, en
ese caso, la vuelta atrás se puede usar para problemas de optimización, lo que
implica que:
 Exige encontrar todas las soluciones y quedarnos con la óptima.
 No es el más adecuado, ya que lo veríamos más adelante en este tema
(usaríamos para ello ramificación y poda).

Este primer esquema de vuelta atrás es el general, en nuestro caso, será el


básico que tengamos que saber para la asignatura:
fun vuelta-atrás (ensayo)
si valido (ensayo) entonces
devolver ensayo
si no
para cada hijo en compleciones (ensayo) hacer
si condiciones-de-poda (hijo) entonces
vuelta-atrás (hijo)
fsi
fpara
fsi
ffun

Resumen tema 9 Curso 2007-08 Página 21 de 38


Necesitaremos especificar lo siguiente:
1. Ensayo: Es el nodo del árbol.
2. Función valido: Determina si un nodo es solución al problema o no.
3. Función compleciones: Genera los hijos de un nodo dado.
4. Función condiciones-de-poda: Verifica si se puede descartar de
antemano una rama del árbol, aplicando los criterios de poda sobre el
nodo origen de esa rama. Adoptaremos el convenio de que la función
condiciones-de-poda devuelve cierto si ha de explorarse el nodo, y falso
si puede abandonarse.

El segundo esquema que veremos y será una variación del visto anteriormente
será aquél en el que finaliza al encontrar la primera solución.
fun vuelta-atrás (ensayo) dev (solución)
si valido (ensayo) entonces
devolver ensayo
si no
lista ← compleciones (ensayo)
solución ← solución_vacia
mientras no vacía (lista) y solución = solución_vacia hacer
hijo ← primero (lista)
lista ← resto (lista)
si condiciones-de-poda (hijo) entonces
solución ← vuelta-atrás (hijo)
fsi
fmientras
devolver solución
fsi
ffun
Nos fijamos que almacena en una estructura de datos lista los nodos que se
pueden seguir explorando, es decir, que no cumple las condiciones de poda,
como hemos explicado previamente.

Resumen tema 9 Curso 2007-08 Página 22 de 38


El tercer esquema y último en el que igualmente finaliza al encontrar la primera
solución será el siguiente:
fun vuelta-atrás (ensayo) dev (es_solucion, solución)
si valido (ensayo) entonces
devolver (verdadero, ensayo)
si no
es_solucion ← false
lista ← compleciones (ensayo)
solución ← solución_vacia
mientras no vacía (lista) y no es_solución hacer
hijo ← primero (lista)
lista ← resto (lista)
si condiciones-de-poda (hijo) entonces
(es_solucion, solución) ← vuelta-atrás (hijo)
fsi
fmientras
devolver (es_solucion, solución)
fsi
ffun
Este último esquema tendrá una leve modificación con respecto al anterior, por
lo que añadiremos una variable booleana, que nos indicará si hay solución o no.
Esta variable nos servirá para, por ejemplo, verificar mediante una llamada
externa a la función si ha encontrado una solución (cuando la encuentre se para
el algoritmo).
NOTA DEL AUTOR: El primer esquema se ha sacado directamente del libro de
problemas, aunque adaptado para cerrar los bucles (como puede ser “si”,
“para”,…) y así quede más didáctico. Los dos siguientes son adaptaciones de
otros de problemas. Se ponen por si entrara en examen.

9.6.1. El problema de la mochila (3)


Se nos da un cierto número de objetos y una mochila. En esta ocasión y a
diferencia de los algoritmos voraces, en lugar de suponer que estén
disponibles n objetos, supondremos que los que tenemos son n tipos de
objetos y que está disponible un número adecuado de objetos de cada tipo.
Cada objeto de un tipo i = 1, 2, … , tiene un peso positivo ( > 0) y
un valor positivo ( > 0). La mochila puede llevar un peso que no exceda
de W (al igual que en los voraces, insistimos de nuevo).
Nuestro objetivo es llenar la mochila de tal manera que se maximice el valor
de los objetos incluidos, respetando la restricción de capacidad. No encuentra
función de selección que garantice que es solución óptima, por eso se
descarta el esquema voraz (aunque el esquema de vuelta atrás no es el más
adecuado por ser un problema de optimización). No podemos aplicar
tampoco búsqueda en profundidad o en anchura porque el árbol puede ser
infinito.

Resumen tema 9 Curso 2007-08 Página 23 de 38


Ejemplo: Se nos dan cuatro tipos de objetos distintos con peso máximo
=8
= 4, =8
w (pesos) 1 2 3 4
v (valores) 3 5 6 10

Desarrollaremos el árbol implícito del problema de la mochila, que será el


siguiente:
w; v
0; 0

2; 3 3; 5 4; 6 5; 10

2,2; 6 2,3; 8 2,4;9 2,5;13 3,3;10 3,4;11 3,5;15 4,4;12

2,2,2;9 2,2,3;11 2,2,4;12 2,3,3;13

2,2,2,2; 12

Hemos marcado las posibles soluciones con un doble cuadrado. Podemos


acordar que cargaremos los objetos en la mochila por orden creciente de
peso, como hemos marcado en nuestro dibujo. Reducimos el tamaño del
árbol a explorar aunque podemos usar otro orden, que no sea el que hemos
hecho antes.
El procedimiento es el siguiente para el ejemplo anterior:
- Inicialmente, la solución parcial está vacía.
- El algoritmo de vuelta atrás explora el árbol como un recorrido en
profundidad, construyendo nodos y soluciones parciales a medida que
avanza. En el ejemplo, el primer nodo que se visita es el (2; 3), el
siguiente el (2,2; 6), el tercero el (2,2,2; 9) y el cuarto el
(2,2,2,2; 12).
- A medida que se visita cada nodo, se extiende la solución parcial.
Después visitar estos cuatro nodos, se bloquea el recorrido en
profundidad: el nodo (2,2,2,2; 12) no posee sucesores por no cumplir
las restricciones del problema. Dado que esta solución parcial, puede
resultar ser la solución óptima, de nuestro caso, la memorizamos.

Resumen tema 9 Curso 2007-08 Página 24 de 38


- El algoritmo de recorrido en profundidad, vuelve atrás ahora en busca
de otras soluciones. En el ejemplo, el recorrido vuelve primero a
(2,2,2; 9), que carece de sucesores no visitados, sin embargo, al
retroceder un paso más por el árbol hasta el nodo (2,2; 6) hay 2
sucesores que quedan por visitar.
- Explorando de esta manera encontramos que (2,3,3; 13) es una
solución mejor que la que ya tenemos y que (3,5; 15) es aun mejor,
siendo ésta la solución óptima, por ser la que tiene mayor valor de
todas.
Programar el algoritmo es sencillo e ilustra la íntima relación existente entre
recursión y recorrido en profundidad. Supongamos que los valores de n y de
W y los valores de las matrices [1. . ] y de [1. . ] correspondientes al
caso que hay que resolver están disponibles como variables globales. La
ordenación de los tipos de elementos es irrelevante. El algoritmo será:
funcion mochilava (i, r)
{ Calcula el valor de la mejor carga que se puede construir empleando
elementos de los tipos 1 a n cuyo peso total no sobrepase r }
← 0;
{ Se prueban por turno las clases de objetos admisibles }
para ← 1 hasta n hacer
si [ ] ≤ entonces
← , [ ]+ ℎ ( , − [ ])
devolver b
La llamada inicial es mochilava (1, W).
Cada llamada recursiva a mochilava se corresponde con extender el recorrido
en profundidad hasta el nivel inmediatamente inferior del árbol, mientras que
el bucle “para” se encarga de examinar todas las posibilidades en un nivel
dado.

9.6.2. El problema de las 8 reinas


Este es el segundo problema que veremos de vuelta atrás. Consiste en situar
ocho reinas en un tablero de ajedrez de tal manera que ninguna de ellas
amenace a ninguna de las demás. Recordemos que una reina amenaza a los
cuadrados de la misma fila, columna o diagonal.
Veremos las distintas maneras de resolver el problema:
La forma más evidente consiste en generar todas las posibilidades de colocar
8 reinas en un tablero. Esto será un enfoque exhaustivo, es decir, recorriendo
todas las posibilidades (por “fuerza bruta”). Nos queda el número de
situaciones siguiente:
= 4.426.165.368 situaciones hasta llegar a solución.

Una primera mejora consiste en no poner nunca más de una reina en una
fila. Reduce la representación del tablero a un vector de ocho elementos, cada
uno de los cuales da la posición de la reina dentro de la fila correspondiente.

Resumen tema 9 Curso 2007-08 Página 25 de 38


Quedaría:
1 8
1

donde:
[ ]: Indica la fila en la que está la reina en la i-ésima columna.
En esta mejora el número de situaciones que hay que considerar será:
8 = 16.277.216

Una segunda mejora consiste en hacer lo mismo que antes sólo que en las
columnas. Ahora representaremos el tablero mediante un vector formado por
ocho números diferentes entre 1 y 8, es decir, mediante una permutación de
los ocho primeros números enteros.
Por lo que el número de situaciones posibles es:
8! = 40.320
Usaremos el siguiente algoritmo:
proc perm (i)
si = entonces usar (T) { T es una nueva permutación }
si no
para = hasta n hacer intercambiar [ ] y [ ]
( + 1)
intercambiar [ ] y [ ]
Si se utiliza el algoritmo anterior para generar las permutaciones sólo se
consideran 2.830 situaciones antes de que encuentre una solución.

Una tercera mejora será aquélla en la que ninguna reina está en la misma
diagonal. Evidentemente, el número de situaciones posibles es aún menor.
Todos estos algoritmos comparten un defecto común: nunca comprueban si
una situación es una solución mientras no se hayan colocado todas las reinas
en el tablero (son exhaustivos).

Resumen tema 9 Curso 2007-08 Página 26 de 38


La vuelta atrás permite mejorar esto, utilizando otro enfoque distinto. Como
primer paso reformulamos el problema de las ocho reinas como un problema
de búsqueda en un árbol. Decimos que un vector [1. . ] de enteros entre 1 y
8 es k-prometedor para 0 ≤ ≤ 8, si ninguna de las k reinas colocadas en las
posiciones (1, [1]), (2, [2]), … , ( , [ ]) amenaza a ninguna de las otras.
Matemáticamente, es k-prometedor si, para todo par de enteros i y j entre 1
y k, con ≠ , tenemos que [ ] − [ ] ∉ { − , 0, − }. Para ≤ 1, todo
vector V es k-prometedor.
Las soluciones del problema de las ocho reinas se corresponden con aquellos
vectores que son 8-prometedores.
Ejemplos:
1 8
1 X
X

[1] − [2] = 1 − 2 = −1. [1,2] No vale, no es k-prometedor

1 8
1 X
X

[1] − [3] = 1 − 3 = −3. [1,3] Vale, ya que es k-prometedor


Sea N el conjunto de vectores k-prometedores, 0 ≤ ≤ 8. Sea = 〈 , 〉 el
grafo dirigido tal que ( , ) ∈ si y sólo si existe un entero k, con 0 ≤ ≤
8, tal que:
o es k-prometedor
o es (k+1)-prometedor
o [ ] = [ ] para todo ∈ [1. . ]
Este grafo es un árbol. Su raíz es el árbol vacio correspondiente a = 0. Sus
hojas son o bien soluciones ( = 8) o posiciones sin salida ( < 8) tales
como [1,4,2,5,8] (o como hemos visto previamente en los ejemplos
anteriores): en tal situación, resulta imposible colocar una reina en la fila
siguiente sin amenazar por lo menos a una de las reinas que ya están en el

Resumen tema 9 Curso 2007-08 Página 27 de 38


tablero. Las soluciones del problema de las ocho reinas se pueden obtener
explorando este árbol. Sin embargo, no generamos explícitamente el árbol
para explorarlo después. Más bien, se generan y abandonan los nodos en el
transcurso de la exploración. El recorrido en profundidad es el método
evidente, sobre todo si sólo necesitamos una solución.
Gráficamente, puede ser algo así:

0-prometedor
1-prometedor

2-prometedor

n-prometedor

Esta técnica tiene dos ventajas con respecto a las anteriores:


1. El número de nodos del árbol es menor que 8! = 40.320. Bastaría con
explorar 114 nodos para obtener la primera solución.
2. Para decidir si un vector es k-prometedor, sabiendo que es una
extensión de un vector (k-1)-prometedor, sólo necesitamos comprobar
la última reina que haya que añadir.

En el procedimiento siguiente, [1. .8] es una matriz global. Tendremos:


procedimiento reinas (k, col, diag45, diag135)
{ [1. . ] es k-prometedor, = { [ ]|1 ≤ ≤ },
45 = { [ ] − + 1|1 ≤ ≤ } y
135 = { [ ] + − 1|1 ≤ ≤ }
si = 8 entonces { un vector 8-prometedor es una solución }
escribir sol
si no
para ← 1 hasta 8 hacer
si ∉ y − ∉ 45 y + ∉ 135 entonces
[ + 1] ←
{ [1. . + 1] es (k+1)-prometedor }
reinas( + 1, ∪ { }, 45 ∪ { − },
135 ∪ + { })
La llamada inicial es (0, ∅, ∅, ∅).
La ventaja obtenida al utilizar la vuelta atrás en lugar de un enfoque
exhaustivo (la forma evidente y sus mejoras) se vuelve más pronunciada a
medida que crece n. Por ejemplo, para = 12 son 479.001.600 las posibles
permutaciones que hay que considerar.

Resumen tema 9 Curso 2007-08 Página 28 de 38


9.6.3. El caso general.
Los algoritmos de vuelta atrás se pueden utilizar aun cuando las soluciones
buscadas no tengan todas necesariamente la misma longitud. Siguiendo con
el planteamiento anterior de los k-prometedores tendremos este cuarto
esquema, que será:
fun vueltaatrás ( [1. . ])
{ v es un vector k-prometedor }
si v es una solución entonces escribir v
si no
para cada vector (k+1)-prometedor w
tal que [1. . ] = [1. . ] hacer
vueltaatrás ( [1. . + 1])

Tanto el problema de la mochila como el de las n reinas se resolvían


empleando una búsqueda en profundidad en el árbol correspondiente.
Algunos problemas pueden llegar a ser un grafo infinito, que como vimos
antes se resolverían empleando un recorrido en anchura.
NOTA DEL AUTOR: Veremos varios ejercicios resueltos que compararán
ambas técnicas, usando vuelta atrás como la conocemos (haciendo más
exhaustiva la búsqueda y el grafo implícito mayor) y vectores k-
prometedores (justo al contrario, el grafo implícito será menor).
IMPORTANTE: Hemos puesto cuatro posibles esquemas, los cuales son
básicos el primero y el cuarto. Los otros dos intermedios son variaciones
sobre el primero de ellos. Se recalca esto, al igual que en una nota anterior.

9.7. Ramificación y poda


Es otra técnica para explorar un grafo dirigido implícito y la última que veremos
en este capítulo, además de ser la más complicada de entender.
Esta vez vamos a buscar la solución óptima de algún problema. En cada nodo
calculamos una cota del posible valor de aquellas soluciones que pudieran
encontrarse más adelante en el grafo. Si la cota muestra que cualquiera de estas
soluciones tiene que ser necesariamente peor que la mejor solución hallada hasta
el momento, entonces no necesitaremos seguir explorando esta parte del grafo.

Resumen tema 9 Curso 2007-08 Página 29 de 38


Tendremos dos posibles implementaciones:
El primer esquema posible que tendremos será aquél en el que el cálculo de las
cotas se combina con un recorrido en anchura, que situará la solución más cerca
de la raíz (nota del autor) y las cotas solamente sirven para podar ciertas ramas
de un árbol.
fun ramificación-y-poda (ensayo)
p ← cola-vacía
cota-superior ← inicializar-cota-superior
solución ← solución-vacía
encolar (ensayo, p)
mientras no vacía (p) hacer
nodo ← desencolar (p)
si valido (nodo) entonces
si coste (nodo) < cota-superior entonces
solución ← nodo
cota-superior ← coste (nodo)
fsi
si no { Nodo no es válido (solución) }
para cada hijo en compleciones (nodo) hacer
si condiciones-de-poda (hijo) y
cota-inferior (hijo) < cota-superior entonces
encolar (hijo, p)
fsi
fpara
fsi
fmientras
ffun

Nos fijamos que usamos estructura de cola, ya que es un recorrido en anchura,


por encontrar la solución más cerca de la raíz (apreciación del autor). Veremos
con algo más de detalle las distintas funciones y variables que son nuevas
correspondientes a dicho esquema, el resto de funciones (compleciones,
condiciones-de-poda) ya las hemos estudiado previamente en el esquema general
de vuelta atrás. Por tanto, tenemos estas nuevas funciones y variables:
- Cota-superior: Será, en cada momento, el coste de la mejor solución
encontrada hasta el momento. En el esquema anterior es una pequeña
modificación respecto al mismo, en la que llaman c a esta variable, pero
conceptualmente es similar.
- Cota-superior-inicial: Será aquella cota que en un primer momento
estimemos. Podrá ser tanto un valor muy alto, que luego haga podar
menos ramas tanto un valor cercano a la solución, en todo caso,
dependerá del tipo del problema en cuestión.
- Función cota-inferior (nodo): Será aquél valor de cota en el nodo que se
estime para alcanzar la solución.
- Función coste (nodo): Será aquel valor del nodo una vez alcanzada la
solución. Podrá mejorar al valor de la cota-superior, ante lo cual se
actualiza esta última.

Resumen tema 9 Curso 2007-08 Página 30 de 38


El segundo esquema será aquél en el que el cálculo de las cotas se utilizan para
seleccionar el camino que, entre los abiertos, parezca más prometedor para
explorarlo primero y además como el esquema anterior para podar ramas. Será el
que más empleemos en los distintos ejercicios que se nos den. Tenemos el
algoritmo:
fun ramificación-y-poda (ensayo)
m ← montículo-vacío
cota-superior ← inicializar-cota-superior
solución ← solución-vacía
añadir-nodo (ensayo, m)
mientras no vacío (m) hacer
nodo ← extraer-raíz (m)
si valido (nodo) entonces
si coste (nodo) < cota-superior entonces
solución ← nodo
cota-superior ← coste (nodo)
fsi
si no { Nodo no es válido (solución) }
si cota-inferior (nodo) ≥ cota-superior entonces
devolver solución
si no { cota-inferior (nodo) < cota-superior }
para cada hijo en compleciones (nodo) hacer
si condiciones-de-poda (hijo) y
cota-inferior (hijo) < cota-superior entonces
añadir-nodo (hijo, m)
fsi
fsi
fpara
fsi
fmientras
ffun

En este caso usaremos una estructura de datos montículo que nos hará escoger
siempre el nodo más prometedor (lo usaremos como una lista con prioridad). Es
el mismo esquema que previamente, sólo que añadimos la selección del camino
que nos lleve antes a solución.
Se añaden un par de líneas en este esquema que son:
si cota-inferior (nodo) ≥ cota-superior entonces
devolver solución
Esto significará que cuando en un nodo tengamos una cota-inferior (recordemos
que es la estimación hasta encontrar la solución) igual o mayor a la cota-superior
(que es el coste de la mejor solución encontrada hasta el momento) entonces no
podremos llegar por ningún otro nodo del montículo a una solución mejor, por lo
que dejamos de explorar el resto del grafo implícito (devolvemos la solución
mejor y salimos del bucle).

Resumen tema 9 Curso 2007-08 Página 31 de 38


Los dos esquemas anteriores para problemas de minimización, ya que iremos
rebajando la cota superior una vez encontrada la solución si la mejora
(podaremos más ramas).
NOTA DEL AUTOR: Estos dos esquemas se han sacado completamente del
libro de problemas, y el siguiente es totalmente personal, a partir del anterior.
Igualmente (y como añadido del autor) podremos emplearlo para problemas de
maximización como sigue (aunque lo veremos en los problemas resueltos). La
única diferencia apreciable será que se actualizará la cota inferior (se
incrementará) al encontrar una mejor solución y las comparaciones serán con
respecto a la cota-superior del nodo, no obstante, la filosofía será similar.
fun ramificación-y-poda (ensayo)
m ← montículo-vacío
cota-inferior ← inicializar-cota-inferior
solución ← solución-vacía
añadir-nodo (ensayo, m)
mientras no vacío (m) hacer
nodo ← extraer-raíz (m)
si valido (nodo) entonces
si coste (nodo) > cota-inferior entonces
solución ← nodo
cota-inferior ← coste (nodo)
fsi
si no { Nodo no es válido (solución) }
si cota-superior (nodo) ≤ cota-inferior entonces
devolver solución
si no { cota-superior (nodo) > cota-inferior }
para cada hijo en compleciones (nodo) hacer
si condiciones-de-poda (hijo) y
cota-superior (hijo) > cota-inferior entonces
añadir-nodo (hijo, m)
fsi
fpara
fsi
fsi
fmientras
ffun

Resumen tema 9 Curso 2007-08 Página 32 de 38


9.7.1. El problema de la asignación
Hay que asignar n tareas a n agentes, de forma que cada agente realice
exactamente una tarea. Si el agente i, con 1 ≤ ≤ , se le asigna la tarea j,
con 1 ≤ ≤ , entonces el coste de realizar esta tarea será . Dada la
matriz de costes completa, el problema consiste en asignar tareas a los
agentes de tal manera que minimice el coste de ejecución de las n tareas.

Veremos un ejemplo de este problema, en la que se nos da esta matriz de


costes:

Tareas
1 2 3
Agentes

a 4 7 3
b 2 6 1
c 3 9 4

Si asignamos la tarea 1 al agente a, la tarea 2 al agente b y la tarea 3 al agente


c, nuestro coste total será: 4 + 6 + 4 = 14. La asignación óptima es →
2, → 3 → 1, cuyo coste es 7 + 3 + 1 = 11.
Este problema tiene numerosas aplicaciones. En general, con n agentes y n
tareas, hay ! posibles asignaciones que considerar, que son demasiadas
incluso para valores moderados de n. Por tanto, recurriremos a la
ramificación y poda.
Veremos otro ejemplo más e iremos paso a paso resolviéndolo. De nuevo, la
matriz de costes será:

Tareas
1 2 3 4
Agentes

a 11 12 18 40
b 14 15 13 22
c 11 17 19 23
d 17 14 20 28

El primer paso es obtener la cota superior inicial del problema:


1ª solución posible: Diagonal principal: 11 + 15 + 19 + 28 = 73.
2ª solución posible: Diagonal secundaria: 40 + 13 + 17 + 17 = 83.
La segunda solución posible no supone mejora respecto a la primera. Por
tanto, tomamos la primera de ellas como cota superior.

Resumen tema 9 Curso 2007-08 Página 33 de 38


El segundo paso es obtener la cota inferior del problema. Para ello sumamos
los elementos más pequeños. En este ejemplo calcularemos dos posibles
cotas inferiores (son estimaciones, no son soluciones reales):
1ª cota inferior: Será aquélla que sale de asignar a cada agente la tarea que
mejor sabe hacer. En este caso, inicialmente tendremos 11 + 12 + 13 +
22 = 58
2ª cota inferior: Será aquélla que sale de asignar a cada tarea el agente que
mejor la realiza. Por lo que tendremos 11 + 13 + 11 + 14 = 49.
En este caso, al ser un problema de minimización, tendremos que la segunda
cota inferior será peor que la primera, ya que la solución óptima no puede ser
mejor que 49, por ser imposible este resultado. Por ello, tendremos la mayor
de las cotas inferiores.
La cota donde, por tanto, estará ubicada la solución será la del intervalo
[58. .73].
NOTA DEL AUTOR: Al ser problema de minimización la cota inferior será
la mayor de las cotas inferiores, a diferencia de los problemas de
maximización que será la menor de las cotas superiores y calcularíamos las
dos posibles cotas superiores.

Realizaremos el cálculo de las cotas inferiores de manera recursiva hasta


encontrar una solución válida, que nos resuelva el problema.
Exploraremos un árbol cuyos nodos conexos corresponden a asignaciones
parciales. En la raíz del árbol no se han hecho asignaciones. En lo sucesivo,
en cada nivel se determina la asignación de un agente más. Para cada nodo,
calculamos una cota de las soluciones que se pueden obtener completando la
asignación parcial correspondiente y utilizar esta cota para cerrar caminos y
guiar la búsqueda. Asignamos la tarea al agente a, por lo que nos queda:

a -> 1

a -> 2

a -> 3

a -> 4

Las cotas inferiores para la asignación → 1 serán:


1ª cota inferior: 11 + 13 + 17 + 14 = 55 (asignar a cada agente la tarea).
2ª cota inferior: 11 + 14 + 13 + 12 = 60 (asignar a cada tarea el agente).

Resumen tema 9 Curso 2007-08 Página 34 de 38


Cualquier solución que se pueda alcanzar por esta rama va a tener coste
máximo de 60. Por tanto, calculando igual para el resto de asignaciones
tendremos el siguiente árbol:

a -> 1
60

a -> 2 58

a -> 3 65

a -> 4 78*

El asterisco (*) indica que la rama se poda por superar el valor máximo del
intervalo antes puesto, por ello, no se seguirá explorando. Lo denominaremos
nodo muerto.
Seguimos por la rama de cota inferior más baja: 58, que es la rama más
prometedora. Las ramas que se pueden seguir explorando las denominaremos
nodo vivo.
Seguimos por la rama → 2, que como hemos puesto es la rama de cota
inferior más baja (la que nos optimice la solución) como sigue:

a -> 1 60 a -> 2, b -> 1

a -> 2 a -> 2, b -> 3

a -> 3 65 a -> 2, b -> 4

a -> 4 78*

Continuamos haciendo el mismo procedimiento de antes y llegamos a


calcularlo para los nodos que salen de → 2. Por ejemplo, → 2, → 1:
1ª cota inferior: → 2, → 1, → 3, → 3: 12 + 14 + 19 + 20 = 65
(asignar a cada agente la tarea).
2ª cota inferior: → 2, → 1, → 3, → 4: 12 + 14 + 19 + 23 = 68
(asignar a cada tarea el agente).

Resumen tema 9 Curso 2007-08 Página 35 de 38


La cota inferior está limitada por el valor 68. Calcularemos de igual manera
las demás cotas. Por lo que tendremos:

a -> 1 60 a -> 2, b -> 1 68

a -> 2 a -> 2, b -> 3 59

a -> 3 65 a -> 2, b -> 4 64

a -> 4 78*

NOTA DEL AUTOR: Hemos visto que el valor 59 es la menor estimación de


cota inferior mejorando a la del intervalo, ya que el coste de la solución
nunca será inferior a 59. Por ello se actualiza el intervalo a [59. .73]. No está
hecho así en el ejercicio, simplemente es para comprenderlo.
De nuevo, seguimos por → 2, → 3, que nos quedará:

a -> 1 60 a -> 2, b -> 1 68

a -> 2, b -> 3, c -> 1, d -> 4 64


a -> 2 a -> 2, b -> 3

a -> 2, b -> 3, c -> 4, d -> 1 65


a -> 3 65 a -> 2, b -> 4 64

a -> 4 78*

Ya encontramos dos posibles soluciones. Como es similar al paso anterior,


nos evitamos hacer otro grafo de nuevo y ponemos las cotas inferiores.
Puesto que la primera solución mejora a la del intervalo será nuestra nueva
cota inferior, quedando [59. .64].
Inmediatamente, podamos las ramas tras la actualización que sean superiores
a 64. Sólo queda una rama menor de 64, que es la de → 1. Por tanto,
desarrollando ese árbol como hemos hecho antes, tendremos lo siguiente:

Resumen tema 9 Curso 2007-08 Página 36 de 38


a -> 1, b -> 2
68*

a -> 1, b -> 3, c -> 2, d -> 4


69*
a -> 1 a -> 1, b -> 3

a -> 1, b -> 3, c -> 4, d -> 2 61


a -> 1, b -> 4
66*

a -> 2, b -> 1 68*

a -> 2, b -> 3, c -> 1, d -> 4 64*


a -> 2 a -> 2, b -> 3

a -> 2, b -> 3, c -> 4, d -> 1 65*


a -> 2, b -> 4
a -> 3 65* 64*

a -> 4 78*

Como no quedan más ramas que explorar la solución → 1, → 3, →


4, → 2 es la óptima. En este algoritmo lo hemos resuelto siguiendo nuestro
segundo esquema (aquél que guiaba la búsqueda).

9.7.2. El problema de la mochila (4)


Se nos pide maximizar ∑ ∗ sometido a la restricción ∑ ∗ ≤
, donde y son estrictamente positivas y los son enteros no
negativos. Resolveremos este problema por ramificación y poda.
Las variables están numeradas de ≥ . Si los valores de
, , … , , 0 ≤ ≤ quedan fijados, con ∑ ∗ ≤ , es fácil ver
que el valor que se puede obtener sumando más elementos de los tipos
+ 1, … , a la mochila no puede sobrepasar el valor:
∑ ∗ + −∑ ∗ ∗

Para resolver el problema de ramificación y poda, exploramos un árbol en


cuya raíz no está fijado el valor de ninguno de los y en cada nivel sucesivo
se va determinando el valor de una variable más, por orden numérico de
variables. En cada nodo que exploremos, sólo generamos aquellos sucesores
que satisfagan la restricción de peso, de tal manera que cada nodo tiene un
número finito de sucesores. Siempre que se genera un nodo, calculamos una
cota superior del valor de la solución que se puede obtener la carga

Resumen tema 9 Curso 2007-08 Página 37 de 38


parcialmente especificada y utilizando estas cotas para cortar ramas inútiles y
guiar la exploración del árbol.
NOTA DEL AUTOR: Hay una errata del libro en la que el símbolo de la
restricción es mayor o igual, cuando debería ser menor o igual, por lo que la
errata se ha subsanado anteriormente.

9.7.3. Consideraciones generales


Usaremos montículos para almacenar una lista de nodos generados pero no
explorados. No se dispone de una formulación recursiva elegante de
ramificación y poda.
Resulta imposible dar una idea precisa de lo bien que se va a comportar esta
técnica en un problema, empleando una cota dada. Hay que llegar a un
compromiso en respuesta a la cota calculada. Si es cota mejor examinaremos
menos nodos y llegaremos a la solución óptima más rápidamente si hay
suerte. Pero podemos pasar mucho tiempo calculando la cota
correspondiente.
En el caso peor, tendremos una cota excelente, pero no podamos ninguna
rama, por lo que desperdiciamos tiempo. En la práctica, casi siempre es
rentable invertir el tiempo necesario para calcular la mejor cota posible.

Resumen tema 9 Curso 2007-08 Página 38 de 38


Tema 10. Resumen de las ecuaciones.
La regla del límite: Nos permite comparar dos funciones en cuanto a la
notación asintótica se refiere. Tendremos que calcular el siguiente límite:
( )
lim → .
( )

Al resolver el limite se nos darán 3 posibles resultados:


( )
1. lim → ( )
= ∈ ⇒

( )∈ ( ) ( )∈ ( ) ( )∈ ( )
⇒ .
( )∈ ( ) ( )∈ ( ) ( )∈ ( )
Estas funciones se comportan igual, diferenciándose en una constante
multiplicativa.

( )
2. lim → ( )
=∞⇒

( )∉ ( ) ( )∈ ( ) ( )∉ ( )
⇒ .
( )∈ ( ) ( )∉ ( ) ( )∉ ( )
Por muy alta que sea la constante multiplicativa de ( ) nunca superará a
( ).

( )
3. lim → ( )
=0⇒

( )∈ ( ) ( )∉ ( ) ( )∉ ( )
⇒ .
( )∉ ( ) ( )∈ ( ) ( )∉ ( )
( ) crece más exponencialmente que ( ), por lo que sería su cota superior.
Tendremos dos tipos:
- Reducción por sustracción:
La ecuación de la recurrencia es la siguiente:

∗ si 0 ≤ <
( )=
∗ ( − )+ ∗ si ≥

La resolución de la ecuación de recurrencia es:

( ) si <1
( )= ( ) si =1
si >1

- Reducción por división:


La ecuación de la recurrencia es la siguiente:

∗ si 1 ≤ <
( )=
∗ ( / )+ ∗ si ≥

La resolución de la ecuación de recurrencia es:

( ) si <
( )= ∗ ( ) si =
si >

siendo:
a: Número de llamadas recursivas.
b: Reducción del problema en cada llamada.
∗ : Todas aquellas operaciones que hacen falta además de las de
recursividad.
El esquema voraz es el siguiente:

funcion voraz (C: Conjunto): conjunto


{ C es el conjunto de candidatos }
←∅ { Construimos la solución en el conjunto S }
mientras ≠ 0 y ¬solución ( ) hacer
← ( )
← \{ };
si factible ( ∪ { }) entonces ← ∪ { }
si solución ( ) entonces devolver S
si no devolver “no hay solución”

Los esquemas de vuelta atrás:

fun vuelta-atrás (ensayo)


si valido (ensayo) entonces
devolver ensayo
si no
para cada hijo en compleciones (ensayo) hacer
si condiciones-de-poda (hijo) entonces
vuelta-atrás (hijo)
fsi
fpara
fsi
ffun

fun vueltaatrás ( [1. . ])


{ v es un vector k-prometedor }
si v es una solución entonces escribir v
si no
para cada vector (k+1)-prometedor w
tal que [1. . ] = [1. . ] hacer
vueltaatrás ( [1. . + 1])
El de ramificación y poda (problemas de maximización):

fun ramificación-y-poda (ensayo)


m ← montículo-vacío
cota-superior ← inicializar-cota-superior
solución ← solución-vacía
añadir-nodo (ensayo, m)
mientras no vacío (m) hacer
nodo ← extraer-raíz (m)
si valido (nodo) entonces
si coste (nodo) < cota-superior entonces
solución ← nodo
cota-superior ← coste (nodo)
fsi
si no { Nodo no es válido (solución) }
si cota-inferior (nodo) ≥ cota-superior entonces
devolver solución
si no { cota-inferior (nodo) < cota-superior }
para cada hijo en compleciones (nodo) hacer
si condiciones-de-poda (hijo) y
cota-inferior (hijo) < cota-superior entonces
añadir-nodo (hijo, m)
fsi
fsi
fpara
fsi
fmientras
ffun

El esquema de divide y vencerás:

fun divide-y-vencerás (problema)


si suficientemente-simple (problema) entonces
dev solucion-simple (problema)
si no { No es solución suficientemente simple }
{ . . } ← decomposicion (problema)
para cada hacer
← divide-y-vencerás ( )
fpara
dev combinacion ( … )
fsi
ffun