Está en la página 1de 25

Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

Capítulo 4
Dividir y Conquistar

4.1 Generalidades
“Dividir y conquistar” (DyC) es una técnica de diseño de algoritmos que consiste en
descomponer la instancia a resolver en un número de subinstancias más pequeñas del
mismo problema, resolver sucesiva e independientemente cada una de estas
subinstancias, y luego combinar las subsoluciones calculadas de ese modo para obtener la
solución a la instancia original. Generalmente resulta en un algoritmo más eficiente que el
original.

Es importante entonces determinar para cada problema:

1. Cuáles son las subinstancias, y cómo se encuentran.

2. Cómo solucionar el problema en las subinstancias.

3. Cómo combinar las soluciones parciales.

Para el punto 2 se puede aplicar nuevamente la técnica DyC, hasta que se llegue a
subinstancias de tamaño suficientemente pequeño para ser resueltas inmediatamente.

4.1.1 Esquema General

Consideremos un problema cualquiera, y supongamos que adhoc es un algoritmo


simple capaz de resolver el problema. Pretendemos que adhoc sea eficiente sobre
instancias pequeñas, pero no nos interesa su desempeño sobre instancias grandes. A este
algoritmo lo llamamos subalgoritmo básico. El esquema general para algoritmos DyC es el
siguiente:

F.C.A.D – U.N.E.R. 46
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

− function DyC(x)
− if x es lo bastante pequeño o simple then
− return adhoc(x)
− else
− Descomponer x en subinstancias x1, x2, …, xl más pequeñas.
− for i ← 1 to l do
− yi ← DyC(xi)
− end for
− Recomponer los yi para obtener una solución y para x.
− return y
− end if
El número de subinstancias, l, por lo general es pequeño e independiente de la
instancia particular a resolver.

Para que un algoritmo DyC sea valioso, se deben cumplir tres condiciones:

• Debe hacerse una cuidadosa elección de cuándo usar el subalgoritmo básico en


lugar de seguir haciendo llamadas recursivas. Es decir, especificar cuándo una
instancia es “suficientemente pequeña”. Este tamaño se denomina umbral.

• Debe ser posible descomponer una instancia en subinstancias y recombinar las


soluciones de manera bastante eficiente.

• Las subinstancias, en la medida de lo posible, deberían ser del mismo tamaño.

4.1.2 Análisis del Tiempo de Ejecución

Si g(n) es el tiempo requerido por DyC en instancias de tamaño n, sin contar el tiempo
necesario para las llamadas recursivas, entonces el tiempo total t(n) utilizado por este
algoritmo DyC es algo de la forma:

 f (n ) si n es lo suficientemente pequeño
t DyC (n ) = 
lt DyC (n ÷ b ) + g (n ) en caso contrario

asumiendo que n es lo suficientemente grande. b es una constante tal que n ÷ b aproxime


el tamaño de las subinstancias. f(n) es el tiempo del algoritmo adhoc.

Si existe un entero k tal que g(n) ∈ Θ(nk), entonces se puede concluir que

( )
Θ n k si l < bk

( )
t (n ) ∈ Θ n k log n si l = bk
 ( )
Θ n
log b l
si l > bk

F.C.A.D – U.N.E.R. 47
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

4.1.3 Determinación del Umbral

Aunque la determinación del umbral no afecta el orden del tiempo de ejecución


requerido por un algoritmo DyC, sí afecta considerablemente a las constantes ocultas.
Usualmente, se denota a este umbral como n0. El subalgoritmo básico se usa para resolver
cualquier instancia cuyo tamaño no excede n0.

La elección del umbral se complica por el hecho de que el valor óptimo en general no
depende sólo del algoritmo en cuestión, sino también de la implementación particular.
Más aún, para una implementación particular no hay un valor preciso para un umbral
óptimo. No podemos hablar entonces de un “umbral óptimo”, sino de un umbral
aproximadamente óptimo.

Nos anticiparemos un poco al problema de la sección siguiente, para mostrar con un


ejemplo cómo la elección del umbral incide directamente en el tiempo requerido por un
algoritmo. Se trata del problema de multiplicar dos enteros grandes, en una
implementación donde el tiempo requerido por el algoritmo básico pertenece al Θ(n2).

La recurrencia tendría la forma

n 2 mseg si n ≤ n0
t DyC (n ) = 
3t DyC (n / 2) + 16n mseg en caso contrario

donde l = 3, b = 2 y k = 1. El algoritmo directo toma un tiempo del Θ(n2) y, como l > bk


deducimos que el algoritmo DyC toma un tiempo en el Θ(nlg3). Pero con diferentes
umbrales se obtiene
n Algoritmo Básico TDyC con n0 = 1 TDyC con n0 = 64
5000 25 seg 41 seg 6 seg
32000 15 min 15 min 2 min

4.1.4 Correctitud

A diferencia de los algoritmos greedy, es fácil probar la correctitud de los algoritmos


DyC suponiendo la correctitud del algoritmo básico. Se hace por inducción sobre el
tamaño de la instancia.

F.C.A.D – U.N.E.R. 48
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

4.2 Multiplicación de Enteros Grandes


Problema: Supongamos que tenemos que multiplicar dos enteros a y b, de n y m
dígitos, cantidades que no se pueden representar directamente en el hardware de la
máquina.

Si se implementa cualquiera de los algoritmos tradicionales para el producto entre dos


números, el resultado es del Θ(mn). Aplicaremos DyC para tratar de mejorar este tiempo.
Supongamos por ahora que n = m.

Ilustraremos el proceso con el ejemplo usado en el primer capítulo: la multiplicación


de 981 por 1234. Primero completamos el menor de los operandos con un cero a la
izquierda, para que quede de la misma longitud del mayor. Luego, partimos cada operando
en dos mitades: 0981 da origen a w = 09 y x = 81, y 1234 genera y = 12 y z = 34. Note que
981 = 102w + x y 1234 = 102y + z. Por lo tanto, el producto requerido puede computarse
como

981 × 1234 = (102w + x) × (102y + z)


= 104wy + 102(wz + xy)+ xz
= 1080000 + 127800 + 2754 = 1210554
Hasta aquí, sólo hemos redefinido el algoritmo anterior, pero todavía necesitamos
cuatro multiplicaciones: wy, wz, xy y xz. Pero hay algo que es clave: no es necesario
calcular wz ni xy; lo que realmente necesitamos es la suma de estos dos términos. Así que
podemos mejorar el tiempo de ejecución tomando

r = (w + x) × (y + z) = wy + (wz +xy) + xz

Luego de esa única multiplicación obtenemos la suma de los tres términos necesarios
para calcular el producto deseado. Así que podemos proceder de la siguiente forma

p = wy = 09 × 12 = 108
q = xz = 81 × 34 = 2754
r = (w +x) × (y +z) = 90 × 46 = 4140
y finalmente

981 × 1234 = 104p + 102(r – p – q)+ q


= 1080000 + 127800 + 2754 = 1210554
Así, el producto de 981 por 1234 puede reducirse a tres multiplicaciones de números
de dos cifras (09×12, 81×34 y 90×46), junto con un cierto número de sumas, restas, y
desplazamientos (multiplicaciones por potencias de 10).

F.C.A.D – U.N.E.R. 49
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

En el ejemplo aplicamos DyC una sola vez, pero en general es necesario aplicar
recursivamente la estrategia hasta llegar a operandos lo bastante pequeños. Por lo tanto,
para el tiempo del algoritmo se genera una recurrencia de la forma

t DyC (n ) = 
( )
Θ n 2 si n es pequeño
3t DyC (n ÷ 2 ) + Θ(n ) si n es grande

La resolución de esta recurrencia da un tiempo t(n) ∈ Θ(nlg 3). Como lg 3 ≈ 1,585 es


menor que 2, este algoritmo puede multiplicar dos enteros grandes mucho más rápido que
el algoritmo de multiplicación clásico, y cuanto mayor sea n, más valiosa se torna la
ganancia.

4.3 Búsqueda Binaria


Problema: Dados un arreglo de enteros ordenados en forma no decreciente y un entero
x, se quiere encontrar el índice i tal que T[i – 1] < x ≤ T[i], con la convención lógica de que
T[0] = –∞ y T [n +1] = ∞ (con convención lógica se quiere dar a entender de que esos
valores en realidad no están presentes en el arreglo). Es decir, lo que se quiere es encontrar
el entero x en el arreglo, y si no existe, queremos saber en qué posición del arreglo debería
ser insertado.

El enfoque obvio de recorrer secuencialmente el arreglo hasta encontrar un elemento


no menor que x o hasta llegar al final del arreglo, toma un tiempo en el Θ(r), donde r es el
índice devuelto. Esto es, en el peor caso, como así también en el caso promedio, el tiempo
será del Θ(n), y del Θ(1) en el mejor caso.

Para mejorar este tiempo se puede utilizar el clásico algoritmo de búsqueda binaria,
que probablemente sea la aplicación más simple de DyC. Estrictamente hablando, no se
trata de un algoritmo DyC, ya que el número de subinstancias siempre es 1. A esta suerte
de degeneración de los algoritmos DyC se los denomina simplificación.

En la búsqueda binaria se busca x bien en la primera mitad o bien en la segunda. Para


averiguar cuál de las búsquedas es la apropiada, se compara x con un elemento en el medio
del arreglo. Sea k = n/2. Si x ≤ T[k], entonces la búsqueda de x se puede limitar a T[1..k];
sino, es suficiente con buscar en T[k+1..n]. Para evitar pruebas repetidas en cada llamada

F.C.A.D – U.N.E.R. 50
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

recursiva, es mejor verificar al principio si la respuesta es n+1, es decir si x cae a la


derecha de T. Se obtiene el siguiente algoritmo, ilustrado en la Figura 4.1.
− function busqBin(T[1..n], x)
− if x = 0 o x > T[n] then
− return n + 1
− else
− return recBin(T[1..n], x)
− end if

− function recBin(T[i..j], x)
− if i = j then
− return i
− end if
− k ← (i + j) ÷ 2
− if x ≤ T[k] then
− return recBin(T[i..k], x)
− else
− return recBin(T[k+1..j], x)
− end if

1 2 3 4 5 6 7 8 9 10 11
−5 −2 0 3 8 9 12 12 12 26 31 ¿ x ≤ T[k] ?
i k j no
i k j si
i k j si
i-k j no
i-j i = j : stop

Figura 4.1: Búsqueda binaria para x = 12 en T[1..11]

Sea m = j – i + 1. El tiempo de ejecución genera la siguiente recurrencia:

a si m = 1
t (m ) = 
b + t (m 2) si no

Resolviendo, se obtiene que t(m) ∈ Θ(lg m) en el peor caso, y el mismo resultado aún
en el mejor caso.

El algoritmo recursivo puede transformarse en uno iterativo, ya que las llamadas


recursivas se encuentran al final del algoritmo.

− function iterBin(T[1..n], x)
− if x > T[n] then
− return n + 1
− end if
− i ← 1; j ← n

F.C.A.D – U.N.E.R. 51
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

− while i < j do
− k ← (i + j) ÷ 2
− if x ≤ T[k] then
− j←k
− else
− i←k+1
− end if
− end while
− return i

4.4 Ordenamiento
Sea T [1..n] un arreglo de n elementos. Nuestro problema es ordenar estos elementos
en orden ascendente. Ya vimos que este problema puede ser resuelto con los algoritmos de
ordenamiento por selección o inserción, o mediante heapsort. Recordemos que este último
método toma un tiempo en el Θ(n log n), tanto para el peor caso como para el caso
promedio, mientras que los dos primeros métodos toman tiempo cuadrático. Existen varios
algoritmos clásicos de ordenamiento que siguen el modelo Dividir y Conquistar.
Estudiaremos ahora dos de ellos: mergesort y quicksort.

4.4.1 Ordenamiento por mergesort

Mergesort es el algoritmo obvio para resolver el problema mediante DyC. Consiste en


dividir el arreglo en dos partes de tamaños aproximadamente iguales, ordenar estas partes
mediante llamadas recursivas, y luego combinar las soluciones de cada parte, cuidando de
preservar el orden. Para hacerlo necesitamos un algoritmo eficiente para mezclar dos
arreglos ordenados U y V en un único arreglo T cuya longitud sea la suma de las
longitudes de U y V.

A continuación se ilustra el algoritmo de ordenamiento, en el que se usa el método de


inserción como subalgoritmo básico.
− procedure mergesort(T[1..n])
− if n es lo suficientemente pequeño then insercion(T)
− else
− array U[1.. n/2 + 1], V[1.. n/2 + 1]
− U[1 .. n/2] ← T[1 .. n/2]
− V[1 .. n/2] ← T[n/2 + 1 .. n]
− mergesort(U[1 .. n/2])
− mergesort(V[1 .. n/2])

F.C.A.D – U.N.E.R. 52
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

− mezcla(U, V, T)
− end if

Nótese que tanto U como V tienen un elemento más que el necesario. Esto se debe a
que el algoritmo de mezcla utiliza esta posición adicional al final del arreglo como
centinela, para que los subíndices dentro del ciclo for no caigan fuera de rango. La técnica
funciona si se puede asignar al centinela un valor mayor a cualquier elemento de los
arreglos U y V , lo que en el algoritmo siguiente aparece como “∞”.

Entonces, el algoritmo siguiente mezcla los arreglos ordenados U[1..m] y V[1..n] en el


arreglo T[1..m+n]; U[m+1] y V[n+1] se usan como centinelas.

− procedure mezcla(U[1..m+1], V[1..n+1], T[1..m+n])


− i, j ← 1
− U[m+1], V[n+1] ← ∞
− for k ← 1 to m + n do
− if U[i] < V[j] then
− T[k] ← U[i]; i ← i + 1
− else
− T[k] ← V[j]; j ← j + 1
− end if
− end for

Este algoritmo de ordenamiento ilustra bien todas las facetas de DyC. Cuando el
número de elementos a ordenar es pequeño, se usa un algoritmo relativamente simple. Por
otro lado, cuando el número de elementos lo justifica, mergesort separa la instancia en dos
subinstancias aproximadamente iguales, resuelve cada una recursivamente, y luego
combina las dos mitades ordenadas para obtener la solución a la instancia original. La
Figura 4.2 muestra un ejemplo, con un arreglo de 13 elementos.

Sea t(n) el tiempo utilizado por este algoritmo para ordenar un arreglo de n elementos.
La separación de T en U y V toma tiempo lineal. Es fácil ver que mezcla(U, V, T) también
toma tiempo lineal. En consecuencia, t(n) = t(n/2) + t(n/2) + g(n), donde g(n) ∈ Θ(n).
Esta recurrencia, que si n es par se transforma en t(n) = 2t(n/2)+g(n), es un caso especial
de nuestro análisis general para algoritmos DyC. En este caso, l = 2, b = 2 y k = 1. Como
l = bk, se aplica el segundo caso y por lo tanto se obtiene que t(n) ∈ Θ(n log n).

Así, la eficiencia de mergesort es similar a la de heapsort. El desempeño de mergesort


suele ser ligeramente mejor en la práctica, pero requiere más espacio de almacenamiento
para los arreglos intermedios U y V.

F.C.A.D – U.N.E.R. 53
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

Para que el algoritmo sea eficiente, es importante que se creen subinstancias de


tamaños parecidos. Si se crearan subinstancias de tamaños desparejos (el caso extremo
sería crear una instancia de tamaño 1 y otra de tamaño n – 1), el tiempo de ejecución sería
del Θ(n2). De hecho, esta estrategia no sería más que una implementación ineficiente del
ordenamiento por inserción.

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

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

Mergesort Mergesort Mergesort Mergesort


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

Inserción Inserción Inserción Mergesort Mergesort


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

Mezcla Inserción Inserción


1 1 3 4 5 9 3 5 8 9

Mezcla
3 5 8 9

Mezcla
2 3 5 5 6 8 9

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

Figura 4.2: Un ejemplo de ordenamiento por mergesort

4.4.2 Ordenamiento por quicksort

El algoritmo de ordenamiento inventado por Hoare usualmente conocido como


quicksort, también se basa en el principio de dividir y conquistar. A diferencia del
mergesort, la mayor porción de la parte no recursiva del trabajo a realizar se utiliza
construyendo las subinstancias, y no combinando sus soluciones.

Como primer paso, este algoritmo elige como pívot uno de los elementos del arreglo a
ordenar. Luego el arreglo se divide a cada lado del pívot: los elementos se mueven de
forma tal que los que son mayores que el pívot están a su derecha, mientras que los
menores están a su izquierda. Si ahora las secciones del arreglo a cada lado del pívot se

F.C.A.D – U.N.E.R. 54
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

ordenan independientemente mediante llamadas recursivas del algoritmo, el resultado final


es un arreglo completamente ordenado, sin que sea necesario un paso subsiguiente para
mezclar subinstancias.

Para balancear los tamaños de las subinstancias a ordenar, podríamos querer usar el
elemento mediano como pívot. Desafortunadamente, encontrar el elemento mediano lleva
demasiado tiempo. Por esta razón, simplemente usamos un elemento arbitrario del arreglo
como pívot, esperando que sea el mejor.

El diseño de un algoritmo de pivoteo de tiempo lineal no es tan problemático. Sin


embargo, en la práctica es crucial que la constante oculta sea pequeña si que quiere que
quicksort sea competitivo con otras técnicas de ordenamiento como heapsort.

Supongamos que un subarreglo T[i..j] va a ser pivoteado alrededor de p = T[i]. Una


buena forma de pivoteo consiste en recorrer el arreglo una sola vez, pero comenzando por
ambos extremos. Los punteros k y l se inicializan en i y j + 1, respectivamente. El puntero
k es entonces incrementado hasta que T[k] > p, y el puntero l es decrementado hasta que
T[l] ≤ p. Luego se intercambian T[k] y T[l]. Este proceso continúa mientras k < l. Por
último se intercambian T[i] y T[l] para colocar el pívot en su posición correcta.

A continuación se muestra el algoritmo de pivoteo, que permuta los elementos en el


arreglo T[i..j] y devuelve un valor l tal que, al final, todos los elementos a la izquierda de
T[l] son menores o iguales que p = T[l], y todos los elementos a la derecha de T[l] son
mayores que p.

El pívot p es el valor inicial de T[i].


− procedure pivot(T[i..j]; var l)
− p ← T[i]; k ← i; l ← j + 1
− repeat
− k←k+1
− until T[k] > p o k ≤ j
− repeat
− l←l−1
− until T[l] ≤ p
− while k < l do
− intercambiar T[k] y T[l]
− repeat
− k←k+1
− until T[k] > p
− repeat
− l←l−1
− until T[l] ≤ p

F.C.A.D – U.N.E.R. 55
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

− end while
− intercambiar T[i] y T[l]

El algoritmo de ordenamiento para un subarreglo T[i..j] sería el siguiente (para ordenar


el arreglo completo T, simplemente hay que llamar a quicksort(T[1..n])).
− procedure quicksort(T[i..j])
− if j − i es lo suficientemente pequeño then
− insercion(T[i..j])
− else
− pivot(T[i..j], l)
− quicksort(T[i..l – 1])
− quicksort(T[l + 1.. j])
− end if

La Figura 4.3 muestra cómo trabajan pívot y quicksort.

Arreglo a ordenar
3 1 4 1 5 9 2 6 5 3 5 8 9
El arreglo se pivotea por su primer elemento p = 3
3 1 4 1 5 9 2 6 5 3 5 8 9
Se encuentra el primer elemento mayor que el pívot (naranja)
y el último elemento no mayor que el pívot (celeste)
3 1 4 1 5 9 2 6 5 3 5 8 9
Se intercambian esos elementos (negrita)
3 1 3 1 5 9 2 6 5 4 5 8 9
Se busca de nuevo 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 busca de nuevo
3 1 3 1 2 9 5 6 5 4 5 8 9
Los punteros se cruzaron (el naranja está a la derecha del celeste):
se intercambian el pívot con el celeste
2 1 3 1 3 9 5 6 5 4 5 8 9
El pivoteo está completo.
Se ordenan recursivamente los subarreglos a cada lado del pívot
1 1 2 3 3 4 5 5 5 6 8 9 9
Al final, el arreglo está ordenado

Figura 4.3: Quicksort

F.C.A.D – U.N.E.R. 56
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

Quicksort es ineficiente si en la mayoría de las llamadas recursivas ocurre que las


subinstancias T[i..l – 1] y T[l + 1.. j] están severamente desbalanceadas. Por ejemplo, en el
peor caso, si T ya está ordenado antes de la llamada a quicksort, obtenemos l = i todas las
veces, lo que significa una llamada recursiva sobre una instancia de tamaño 0 y otra sobre
una instancia cuyo tamaño se ha reducido sólo en 1. El tiempo de ejecución en este caso
(es decir, en el peor caso) es cuadrático.

Por otro lado, si el arreglo a ordenar está inicialmente en orden aleatorio, es probable
que la mayoría de las veces las subinstancias a ordenar estén bastante bien balanceadas.
Para determinar el tiempo promedio requerido por quicksort para ordenar un arreglo de n
elementos, debemos asumir cuál es la distribución de probabilidad de todas las instancias
de tamaño n. Lo más natural es asumir que los elementos de T son distintos y que cada una
de las n! posibles permutaciones iniciales de los elementos es igualmente probable. Es
necesario notar, sin embargo, que esta suposición puede ser inadecuada –o directamente
errónea– para algunas aplicaciones, en cuyo caso no se puede aplicar el análisis siguiente.
Este es el caso, por ejemplo, si la aplicación con frecuencia necesita ordenar arreglos que
ya están casi ordenados.

Sea t(m) el tiempo promedio utilizado por una llamada a quicksort(T[i..j]), donde
m = j – i + 1 es el número de elementos en el subarreglo. En particular, quicksort(T[1..n])
requiere el tiempo t(n) para ordenar todos los n elementos del arreglo. Por nuestra
suposición sobre la distribución de probabilidad de la instancia, el pívot elegido por el
algoritmo cuando se le pide ordenar T[1..n] cae con igual probabilidad en cualquier
posición con respecto a los demás elementos de T . Por lo tanto, el valor de l devuelto por
el algoritmo de pivoteo luego de la llamada inicial pivot(T[1..n], l) puede ser cualquier
entero entre 1 y n, donde cada valor tiene igual probabilidad 1/n. Esta operación de
pivoteo toma tiempo lineal g(n) ∈ Θ(n). Nos queda ordenar recursivamente dos
subarreglos de tamaño l – 1 y n – l, respectivamente. Se puede mostrar que la distribución
de probabilidad en los subarreglos sigue siendo uniforme. Por lo tanto, el tiempo promedio
requerido para ejecutar estas llamadas recursivas es de t(l–1) + t(n–l). En consecuencia,

1 n
t (n ) = ∑ (g (n) + t (l − 1) + t (n − l ))
n l =1

siempre que n sea lo suficientemente grande como para garantizar el enfoque recursivo.

Teorema 6 Quicksort toma un tiempo promedio en O(n log n).

F.C.A.D – U.N.E.R. 57
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

En la práctica, la constante oculta es menor que las involucradas para heapsort o


mergesort. Si se puede tolerar un tiempo de ejecución ocasionalmente largo, este es un
excelente algoritmo de ordenamiento de propósito general. Es posible mejorar el tiempo
de ejecución de quicksort en el peor caso para llevarlo a Θ(n log n), pero eso involucraría:

• una mejor elección del pívot (se puede elegir como pívot el elemento mediano en
tiempo de Θ(n), pero esto no es conveniente salvo para n muy grandes).

• Se puede modificar el pivoteo para que divida el arreglo en tres subarreglos: los
elementos menores que el pívot, los elementos iguales, y los elementos mayores.

Esto último podría hacerse mediante un procedimiento


procedure pivotbis(T[i..j], p; var k, l])
que divide a T en tres secciones usando p como pívot: luego del pivoteo, los elementos
en T[i..k] son menores que p, los elementos en T[k+1..l–1] son iguales a p, y los elementos
en T[l..j] son mayores que p.

Estas “mejoras” involucran constantes ocultas que hacen que el algoritmo resultante
sea prácticamente inviable comparado con la versión original.

4.5 Selección del Elemento Mediano


Sea T[1..n] un arreglo de enteros y s un entero entre 1 y n. El s-ésimo menor elemento
se define como el elemento que estaría en la posición s si T estuviera ordenado en orden
no decreciente. Dados T y s, el problema de encontrar el s-ésimo menor elemento de T se
conoce como el problema de selección. En particular, la mediana de T[1..n] se define
como su n/2-ésimo menor elemento. Cuando n es impar y los elementos de T son
distintos, la mediana es simplemente aquel elemento de T tal que hay tantos elementos
menores como mayores. Por ejemplo, la mediana de [3, 1, 4, 1, 5, 9, 2, 6, 5] es 4, ya que 3,
1, 1 y 2 son menores que 4, mientras que 5, 9, 6 y 5 son mayores.

¿Qué puede ser más fácil que encontrar el menor elemento de T o calcular la media de
todos los elementos? Sin embargo, no es tan obvio que la mediana pueda encontrarse tan
fácilmente. El algoritmo naive para determinar la mediana de T[1..n] consiste en ordenar
el arreglo y luego extraer su entrada n/2-ésima. Si usamos heapsort o mergesort, esto

F.C.A.D – U.N.E.R. 58
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

toma un tiempo en Θ(n log n). ¿Se puede mejorar? Para responder a esta pregunta,
estudiamos la interrelación entre encontrar la mediana y elegir el s-ésimo menor elemento.

Es obvio que cualquier algoritmo para el problema de selección puede usarse para
encontrar la mediana: simplemente se selecciona el n/2-ésimo menor elemento. Lo
inverso también se cumple. Asumamos por ahora la disponibilidad de un algoritmo
mediana(T[1..n]) que devuelve la mediana de T. Dado un arreglo T y un entero s, cómo se
podría usar este algoritmo para determinar el s-ésimo menor elemento de T?. Sea p la
mediana de T. Realicemos un pivoteo de T alrededor de p, muy parecido a lo que
hacíamos en quicksort, pero usando el algoritmo pivotbis introducido al final de la sección
anterior. Recordemos que una llamada a pivotbis(T[i..j], p; var k, l) parte a T[i..j] en tres
secciones: los elementos de T[i..k] son menores que p, los elementos en T[k+1..l–1] son
iguales a p, y los elementos de T[l..j] son mayores que p. Luego de una llamada a
pivotbis(T, p, k, l), si k < s < l el problema está resuelto, ya que el s-ésimo menor elemento
de T es igual a p. Si s ≤ k, el s-ésimo menor elemento de T es ahora el s-ésimo menor
elemento de T[1..k]. Finalmente, si s ≥ l, el s-ésimo menor elemento de T es ahora el
(s – l + 1)-ésimo menor elemento de T[l..n]. En cualquier caso hemos avanzado, ya que, o
resolvimos el problema, o el subarreglo a considerar contiene menos de la mitad de los
elementos, en virtud de que p es la mediana del arreglo original.

Hay muchas similitudes entre este enfoque y el de la búsqueda binaria, y de hecho el


algoritmo resultante puede programarse iterativamente en lugar de recursivamente. La idea
clave es usar dos variables i y j, inicializadas en 1 y n respectivamente, y asegurar que en
cada momento i ≤ s ≤ j y que los elementos en T[1..i–1] sean menores que los de T[i..j],
los que a su vez deben ser menores a los de T[j+1..n]. La consecuencia inmediata de esto
es que el elemento deseado reside en T[i..j]. Cuando todos los elementos de T[i..j] son
iguales, tenemos el problema solucionado.

La Figura 4.4 ilustra el proceso. Por simplicidad, la ilustración asume que pivotbis está
implementado de una forma intuitivamente simple, aún cuando una implementación
realmente eficiente podría proceder de manera diferente.

El siguiente es el algoritmo de selección, que encuentra el s-ésimo menor elemento en


T, para 1 ≤ s ≤ n.
− function seleccion(T[i..j], s)
− p ← mediana(T[i..j])
− pivotbis(T[i..j], p, k, l)

F.C.A.D – U.N.E.R. 59
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

− case
− s ≤ k: j ← k; p ← seleccion(T[i..j], s);
− k < s < l: ;
− s ≥ l: i ← l; p ← seleccion(T[i..j], s – l + 1);
− end case
− return p

Arreglo en el cual se debe hallar el 4º menor elemento


3 1 4 1 5 9 2 6 5 3 5 8 9
El arreglo se pivotea por su mediana p = 5 usando pivotbis
3 1 4 1 2 3 5 5 5 9 6 8 9
Como 4 ≤ 6, sólo es relevante la parte a la izquierda del pívot
3 1 4 1 2 3 · · · · · · ·
Se pivotea alrededor de su mediana p = 2
1 1 2 3 4 3 · · · · · · ·
Ahora sólo es relevante la parte a la izquierda del pívot, ya que 4 ≥ 4
· · · 3 4 3 · · · · · · ·
Se pivotea alrededor de su mediana p = 3
· · · 3 3 4 · · · · · · ·
La respuesta es 3, porque el pívot está en la 4º posición

Figura 4.4: Selección usando la mediana

Por un análisis similar al de la búsqueda binaria, el algoritmo anterior selecciona el


elemento requerido de T luego de ingresar al ciclo un número logarítmico de veces en el
peor caso. Sin embargo, los ciclos ya no toman tiempo constante, y de hecho este
algoritmo no se puede usar mientras no se tenga una forma eficiente de encontrar la
mediana, que era nuestro problema original. ¿Se puede modificar el algoritmo para evitar
recurrir a la mediana?

Primero, observemos que el algoritmo funciona independientemente de qué elemento


de T se elija como pívot (el valor de p). Es sólo la eficiencia del algoritmo lo que depende
de la elección del pívot: usar la mediana nos asegura que el número de elementos que
todavía están en consideración es reducido al menos a la mitad cada vez que se ejecuta el
ciclo. Si estamos dispuestos a sacrificar velocidad en el peor caso para obtener un
algoritmo razonablemente rápido en promedio, podemos tomar prestada otra idea de
quicksort y simplemente elegir T[i] como pívot. En otras palabras, reemplazar la primera
instrucción del ciclo con

p ← T[i]

F.C.A.D – U.N.E.R. 60
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

Esto hace que el algoritmo tome tiempo cuadrático en el peor caso, por ejemplo si el
arreglo está en orden decreciente y queremos encontrar el menor elemento. Sin embargo,
este algoritmo modificado corre en tiempo lineal en promedio, bajo nuestra suposición
usual de que los elementos de T son distintos y que cada una de las n! posibles
permutaciones iniciales son igualmente probables. Esto es mucho mejor que el tiempo
promedio requerido si procedemos a ordenar el arreglo, pero el comportamiento en el peor
caso es inaceptable para muchas aplicaciones.

Felizmente, este peor caso cuadrático puede evitarse sin sacrificar el comportamiento
lineal en el promedio. La idea es que el número de veces que se ejecuta el ciclo
permanezca logarítmico a condición de que el pívot se elija razonablemente cercano a la
mediana. Una buena aproximación a la mediana puede encontrarse rápidamente con un
pequeño artificio. Consideremos el siguiente algoritmo, que encuentra una aproximación a
la mediana del arreglo T.
− function seudomed(T[1..n])
− if n ≤ 5 then return adhocmed(T)
− z ← n/5
− array Z[1..z]
− for i ← 1 to z do
− Z[i] ← adhocmed(T[5i–4..5i])
− end for
− return seleccion(Z, z/2)

Aquí, adhocmed es un algoritmo especialmente diseñado para encontrar la mediana


entre un máximo de cinco elementos, que puede ejecutarse en un tiempo limitado
superiormente por una constante, y seleccion(Z, z/2) determina la mediana exacta del
arreglo Z. Sea p el valor devuelto por una llamada a seudomed(T). ¿Cuán lejos puede estar
p de la mediana verdadera de T cuando n > 5?

Como en el algoritmo, sea z = n/5, el número de elementos en el arreglo Z creado


mediante la llamada a seudomed(T). Para cada i entre 1 y z, Z[i] es por definición la
mediana de T[5i–4..5i], y por lo tanto al menos tres elementos de cinco en este subarreglo
son menores o iguales que ésta. Además, ya que p es la mediana verdadera de Z, al menos
z/2 elementos de Z son menores o iguales que p. Por transitividad (T[j] ≤ Z[i] ≤ p implica
que T[j] ≤ p), al menos 3z/2 elementos de T son menores o iguales que p. Como z = n/5
≤ (n – 4)/5, concluimos que al menos (3n – 12)/10 elementos de T son menores o iguales a
p, y por lo tanto quedan como máximo (7n +12)/10 elementos de T que son estrictamente

F.C.A.D – U.N.E.R. 61
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

mayores que p. Se puede aplicar un razonamiento similar al número de elementos de T que


son estrictamente menores que p.

Aunque p no sea la mediana exacta de T, concluimos que su rango es


aproximadamente entre 3n/10 y 7n/10. Para ver cómo surgen estos factores, imaginemos
como en la Figura 4.5 que los elementos de T están organizados en cinco filas, con la
posible excepción de hasta cuatro elementos dejados a un lado. Ahora supongamos que
cada una de las n/5 columnas como así también la fila central están ordenadas, es decir
que los elementos menores van hacia arriba y a la izquierda, respectivamente. La fila
central corresponde al arreglo Z del algoritmo, y el elemento en el círculo corresponde a la
mediana de este arreglo, que es el valor de p devuelto por el algoritmo. Es claro que todos
los elementos del rectángulo son menores o iguales a p. La conclusión surge ya que el
recuadro contiene aproximadamente tres quintos de la mitad de los elementos de T.

Figura 4.5: Visualización de la seudomediana

Analicemos ahora la eficiencia del algoritmo de selección presentado al principio de


esta sección, cuando se reemplaza la primera instrucción de su ciclo por

p ← seudomed(T[i..j])

Sea t(n) el tiempo requerido en el peor caso por una llamada a seleccion(T[1..n], s).
Consideremos cualquier i y j tales que 1 ≤ i ≤ j ≤ n. El tiempo requerido para completar el
ciclo con estos valores para i y j es esencialmente t(m), donde m = j – i + 1 es el número de
elementos que todavía están en consideración. Cuando n > 5, el cálculo de seudomed(T)
toma un tiempo en t(n/5) + O(n) porque el arreglo Z puede construirse en tiempo lineal,
ya que cada llamada a adhocmed toma tiempo constante. La llamada a pivotbis también
toma tiempo lineal. En este punto, o hemos terminado, o tenemos que volver a ingresar al
ciclo con un máximo de (7n + 12)/10 elementos, que todavía están bajo consideración. Por
lo tanto, existe una constante d tal que

F.C.A.D – U.N.E.R. 62
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

t(n) ≤ dn + t(n/5)+ max{t(m)|m ≤ (7n + 12)/10}

suponiendo que n > 5.

Teorema 7 El algoritmo de selección usado con seudomediana encuentra el s-ésimo


menor elemento entre n elementos en un tiempo en Θ(n) en el peor caso. En particular, se
puede encontrar la mediana, en el peor caso, en tiempo lineal.

4.6 Multiplicación de Matrices


Sean A y B dos matrices que se quiere multiplicar, y sea C su producto. El algoritmo
clásico de multiplicación de matrices surge directamente de la definición.
n
C ij = ∑ Aik Bkj
k =1

Cada elemento de C se calcula en un tiempo del Θ(n), asumiendo que la multiplicación


y la suma escalar son operaciones elementales. Como hay n2 entradas a computar, el
producto AB puede calcularse en un tiempo en el Θ(n3).

Hacia finales de la década de 1960, Strassen causó un considerable revuelo al mejorar


este algoritmo. Desde un punto de vista algorítmico, este gran adelanto es un hito en la
historia de la filosofía DyC, aún cuando el igualmente sorprendente algoritmo para
multiplicar enteros grandes había sido descubierto casi una década antes. La idea básica
detrás del algoritmo de Strassen es similar a aquella idea anterior. Primero mostramos que
dos matrices de 2×2 pueden multiplicarse usando menos que las ocho multiplicaciones
escalares aparentemente requeridas por su definición. Sean

a a12  b b12 
A =  11  ; B =  11 
 a 21 a 22   b21 b22 

dos matrices a multiplicar. Consideremos las siguientes operaciones, cada una de las
cuales involucra una única multiplicación.
m1 = (a21 + a22 – a11)(b22 – b12 + b11)
m2 = a11b11
m3 = a12b21
m4 = (a11 – a21)(b22 – b12)
m5 = (a21 + a22)(b12 – b11)
m6 = (a12 – a21 + a11 – a22)b22

F.C.A.D – U.N.E.R. 63
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

m7 = a22(b11 + b22 – b12 – b21)


Es fácil verificar que el producto requerido de AB está dado por la siguiente matriz.

 m 2 + m3 m1 + m2 + m5 + m6 
C =  
 m1 + m2 + m4 − m7 m1 + m2 + m4 + m5 

Por lo tanto, es posible multiplicar dos matrices de 2×2 usando sólo siete
multiplicaciones escalares. A primera vista, este algoritmo no parece muy interesante:
utiliza un gran número de sumas y restas comparadas con las cuatro adiciones que son
suficientes para el algoritmo clásico.

Si ahora reemplazamos cada elemento de A y B por una matriz de n×n, obtenemos un


algoritmo que puede multiplicar dos matrices de 2n×2n ejecutando siete multiplicaciones
de matrices de n×n, como así también un número de sumas y restas de matrices de n×n.
Esto es posible porque el algoritmo de 2×2 no se basa en la conmutatividad de las
multiplicaciones escalares. Como las matrices grandes pueden sumarse mucho más rápido
de lo que pueden multiplicarse, el ahorro de una multiplicación compensa muy bien las
sumas adicionales.

Sea t(n) el tiempo necesario par multiplicar dos matrices de n×n mediante el uso
recursivo de las multiplicaciones introducidas anteriormente. Asumamos por simplicidad
que n es una potencia de 2. Como las matrices pueden ser sumadas y restadas en un
tiempo del Θ(n2), t(n) = 7t(n/2) + g(n), donde g(n) ∈ Θ(n2). Esta recurrencia es otra
instancia de nuestro análisis general para los algoritmos DyC. Si tomamos l = 7, b = 2 y k
= 2, y como l > bk, el tercer caso da que t(n) ∈ Θ(nlg7). Las matrices cuadradas cuyo
tamaño no es una potencia de 2 se pueden manejar fácilmente llenándolas de ceros hasta
que alcancen un tamaño apropiado, lo que no afecta el tiempo de corrida asintótico. Como
lg 7 < 2.81, entonces es posible multiplicar dos matrices de n×n en un tiempo en el
O(n2.81), si suponemos que las operaciones escalares son elementales.

Siguiendo el descubrimiento de Strassen, un sinnúmero de investigadores intentaron


mejorar la constante ω tal que es posible multiplicar dos matrices de n×n en un tiempo del
O(nω). Lo primero que se intentó fue lo obvio, es decir intentar multiplicar dos matrices de
2×2 con seis multiplicaciones escalares. Pero en 1971 Hopcroft y Kerr probaron que esto
es imposible cuando no se puede usar la conmutatividad de la multiplicación. Lo próximo
que se intentó fue encontrar una forma de multiplicar matrices de 3×3 con un máximo de

F.C.A.D – U.N.E.R. 64
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

21 multiplicaciones escalares. Esto daría un algoritmo recursivo que multiplicara matrices


( )
de n×n en un tiempo en el Θ n log3 21 , asintóticamente más rápido que el algoritmo de
Strassen, porque log3 21 < log2 7. Desafortunadamente, esto también es imposible.

Pasó casi una década antes de que Pan descubriera una forma de multiplicar dos
matrices de 70×70 con 143640 multiplicaciones escalares –comparemos esto con las
343000 requeridas por el algoritmo clásico– y de hecho log70 143640 es un poquito menor
que lg 7. Este descubrimiento desató la llamada guerra decimal. Sucesivamente se fueron
descubriendo numerosos algoritmos, asintóticamente cada vez más eficientes. Por
ejemplo, a finales de 1979 era conocido que las matrices se podían multiplicar en un
tiempo en el O(n2.521813); imaginemos la emoción en enero de 1980 cuando esto se mejoró
al O(n2.521801). El algoritmo de multiplicación de matrices más rápido hoy conocido data de
1986, cuando Coppersmith y Winograd descubrieron que era posible, al menos en teoría,
multiplicar dos matrices de n×n en un tiempo en el O(n2.376). Sin embargo, debido a las
constantes ocultas involucradas, ninguno de los algoritmos encontrados después del de
Strassen tiene mucho uso práctico.

4.7 Exponenciación
Sean a y n dos números enteros. Queremos calcular la exponenciación x = an.
Asumiremos que n > 0. Si n es pequeño, entonces se puede aplicar el algoritmo obvio.
− function exposec(a, n)
− r←a
− for i ← 1 to n – 1 do
− r←a×r
− end for
− return r
Este algoritmo toma un tiempo en el Θ(n), suponiendo que las multiplicaciones se
consideran como operaciones elementales. Sin embargo, en la mayoría de las
computadoras, incluso valores pequeños de a y n causan que este algoritmo produzca un
desbordamiento. Por ejemplo, 1517 no cabe en un entero de 64 bits.

Si queremos manejar enteros más grandes, debemos tener en cuenta el tiempo


requerido por cada multiplicación. Si m es el tamaño de a, y si se usa el algoritmo de
multiplicación básico, se puede probar que el tiempo que toman las multiplicaciones

F.C.A.D – U.N.E.R. 65
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

cuando se usa el algoritmo exposec es de T(m, n) ∈ Θ(m2n2). Si por otro lado usamos el
algoritmo de multiplicación de enteros grandes descrito en este mismo capítulo, es decir el
que se basa en DyC, se puede probar que T(m, n) ∈ Θ(mlg 3n2).

La observación clave para mejorar exposec es que an = (an/2)2 cuando n es par. Esto es
interesante porque an/2 se puede calcular aproximadamente cuatro veces más rápido que an
con exposec, y un único cálculo del cuadrado (que es una multiplicación) es suficiente
para obtener el resultado deseado partiendo de an/2. Esto da lugar a la siguiente
recurrencia.

a si n = 1
an = (an/2)2 si n es par
a × an–1 en caso contrario

Por ejemplo,

a29 = aa28 = a(a14)2 = a((a7)2)2 = · · · = a((a(aa2)2)2)2,

que involucra sólo tres multiplicaciones y cuatro cuadrados en lugar de las 28


multiplicaciones requeridas por exposec. La recurrencia anterior genera el siguiente
algoritmo.
− function expoDyC(a, n)
− if n = 1 then return a
− if n es par then return [expoDyC(a, n/2)]2
− return a × expoDyC(a, n–1)
Para analizar la eficiencia de este algoritmo, nos concentramos primero en el número
de multiplicaciones (contando el cálculo de los cuadrados como multiplicaciones)
efectuadas por una llamada a expoDyC(a, n). Notemos que el flujo de control de este
algoritmo no depende del valor de a, y por lo tanto el número de multiplicaciones es sólo
función del exponente n; llamémosle a esa función N(n).

Cuando n = 1 no se realiza ninguna multiplicación, entonces N(1) = 0. Cuando n es par


se realiza una multiplicación (el cuadrado de an/2), además de las N(n/2) multiplicaciones
involucradas en la llamada recursiva a expoDyC(a, n/2). Cuando n es impar se ejecuta una
multiplicación (la de a por an–1) más las N(n–1) multiplicaciones implicadas en la llamada
recursiva a expoDyC(a, n–1). Así, tenemos la siguiente recurrencia:
0 si n = 1
N(n) = N(n/2) + 1 si n es par
N(n–1) + 1 en caso contrario

F.C.A.D – U.N.E.R. 66
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

Resolviendo esta recurrencia, se puede probar que N(n) está en el Θ(log n). Por lo
tanto, usando este criterio, concluimos que este algoritmo es más eficiente que exposec,
que utilizaba un número de multiplicaciones en el Θ(n). Queda por ver si expoDyC
también es significativamente mejor cuando se tiene en cuenta el tiempo insumido en esas
multiplicaciones. Nuevamente, el tiempo dependerá de si esas multiplicaciones se ejecutan
utilizando el algoritmo clásico o el algoritmo DyC. En el primer caso, el tiempo insumido
por las multiplicaciones es del Θ(m2n2), mientras que usando el algoritmo DyC ese tiempo
se reduce al Θ(mlg 3nlg 3).

Resumiendo, la siguiente tabla muestra el tiempo requerido para calcular an, donde m
es el tamaño de a, dependiendo de si se usa exposec o expoDyC, y de si se usa el algoritmo
de multiplicación clásico o el algoritmo DyC.
Multiplicación
Clásica DyC
exposec Θ(m2n2) Θ(mlg 3n2)
expoDyc Θ(m2n2) Θ(mlg 3nlg 3)

Es interesante el hecho de que no se gana nada –excepto quizás un factor constante en


velocidad– siendo sólo medio inteligentes: tanto exposec como expoDyC toman un tiempo
en el Θ(m2n2) si se usa el algoritmo de multiplicación clásico. Esto es así aún cuando
expoDyC utiliza un número exponencialmente menor de multiplicaciones que exposec.

Como en el caso de la búsqueda binaria, expoDyC requiere sólo una llamada recursiva
sobre una instancia más pequeña. Por lo tanto es un ejemplo de simplificación más que de
Dividir y Conquistar. Existe una versión iterativa, aunque no muy eficiente, del algoritmo
de exponenciación.

− function expoiter(a, n)
− i ← n; r ← 1; x ← a
− while i > 0 do
− if i es impar then r ← rx
− x ← x2
− i←i÷2
− end while
− return r

F.C.A.D – U.N.E.R. 67
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

4.7.1 Exponenciación Modular: Aplicación en Criptografía

Hay aplicaciones para las cuales es razonable contar todas las multiplicaciones de una
exponenciación con el mismo costo. Este es el caso si estamos interesados en la aritmética
modular, es decir en el cálculo de an módulo algún tercer entero z. Recordemos que
x mod z denota el resto de la división entera de x por z. Por ejemplo, 25 mod 7 = 4 porque
25 = 3 × 7 + 4. Si x e y son dos enteros entre 0 y z – 1, y si z es un entero de tamaño m, la
multiplicación modular xy mod z involucra una multiplicación entera ordinaria de dos
enteros de tamaño máximo m, dando un entero de tamaño máximo 2m, seguida por una
división del producto por z, un entero de tamaño m, para calcular el resto de la división.
Por lo tanto, el tiempo insumido por cada multiplicación modular es más bien insensible a
los dos números realmente involucrados. Se usarán dos propiedades elementales de la
aritmética modular:

xy mod z = [(x mod z) × (y mod z)] mod z

(x mod z)y mod z = xy mod z

Así, exposec, expoDyC y expoiter se pueden adaptar para calcular an mod z en


aritmética modular sin tener nunca que manejar enteros más grandes que max(a, z2). Para
esto, es suficiente con reducir módulo z después de cada multiplicación. Por ejemplo,
expoiter genera el siguiente algoritmo, que calcula an mod z.
− function expomod(a, n, z)
− i ← n; r ← 1; x ← a mod z
− while i > 0 do
− if i es impar then r ← rx mod z
− x ← x2 mod z
− i←i÷2
− end while
− return r

Este algoritmo sólo necesita un número de multiplicaciones modulares en Θ(log n). En


contraste, el algoritmo correspondiente a exposec requiere n – 1 multiplicaciones para todo
n. Concretamente, supongamos que queremos calcular an mod z donde a, n y z son
números de 200 dígitos y que los números de ese tamaño pueden multiplicarse módulo z
en un milisegundo. Mientras que típicamente el algoritmo expomod calcula an mod z en

F.C.A.D – U.N.E.R. 68
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

menos de un segundo, el algoritmo correspondiente a exposec requeriría más o menos


10179 veces la edad del universo para la misma tarea.

De todos modos, nos podríamos preguntar quién necesita calcular exponenciaciones


modulares tan enormes en la vida real. Y la respuesta es que la criptografía moderna, el
arte y la ciencia de la comunicación secreta a través de canales inseguros, depende
crucialmente de eso. Consideremos dos partes, a las que llamaremos A y B, y asumamos
que A quiere enviarle algún mensaje privado m a B a través de una canal susceptible de ser
espiado. Para evitar que otros lean el mensaje, A lo transforma en un texto cifrado c, que
es el que envía a B. Esta transformación es el resultado de un algoritmo de cifrado cuya
salida depende no sólo del mensaje m, sino también de otro parámetro k conocido como la
clave. Clásicamente, esta clave es información secreta que tiene que ser establecida entre
A y B antes de que la comunicación secreta pueda llevarse a cabo. De c y de su
conocimiento de k, B puede reconstruir el mensaje real m que A le envió. Tales sistemas
de confidencialidad confían en la esperanza de que un espía que intercepta c pero no
conoce k no será capaz de determinar m con la información disponible.

Esta forma de encarar la criptografía se ha usado con más o menos éxito a lo largo de
la historia. Su requerimiento de que las partes deben compartir información secreta antes
de la comunicación puede ser aceptable para militares y diplomáticos, pero no para los
ciudadanos comunes. En la era de las comunicaciones electrónicas, es deseable que
cualquier par de personas puedan comunicarse privadamente sin coordinación previa.
¿Pueden comunicarse secretamente A y B, a la vista de una tercera parte, si no comparten
un secreto antes de establecer la comunicación? La era de la criptografía de clave pública
comenzó a mediados de los ‘70, cuando Diffie, Hellman y Merkle vieron que esto podía
ser posible. Aquí se presenta una solución asombrosamente simple descubierta unos pocos
años más tarde por Rivest, Shamir y Adleman, que se hizo conocida como el sistema de
criptografía RSA por los nombres de sus inventores.

Consideremos dos números primos de 100 dígitos p y q elegidos aleatoriamente por B.


Sea z el producto de p y q. B puede calcular z eficientemente partiendo de p y q. Sin
embargo, no se conoce ningún algoritmo que pueda recalcular p y q partiendo de z. Sea φ
el producto de (p – 1)(q – 1), y sea n un entero entre 1 y z – 1 elegido aleatoriamente por
B, que no tiene factores comunes con φ. La teoría numérica elemental nos dice que existe
un único entero s entre 1 y z – 1 tal que ns mod φ = 1. Además, s es fácil de calcular a
partir de n y φ, y su existencia es prueba de que n y φ no tienen factores comunes. Si s no

F.C.A.D – U.N.E.R. 69
Algoritmos y Complejidad Unidad 4: Dividir y Conquistar

existe, B tiene que elegir aleatoriamente otro valor para n; cada intento tiene una buena
probabilidad de éxito. El teorema clave es que ax mod z = a siempre que 0 ≤ a < z y
x mod φ = 1.

Para permitir que A o cualquier otra parte se comunique con él privadamente, B hace
pública su elección de z y n, pero mantiene s en secreto. Sea m un mensaje que A le quiere
transmitir a B. Usando codificación estándar, como ASCII, A transforma su mensaje en
una cadena de bits, que interpreta como un número a. Asumamos por simplicidad que
0 ≤ a ≤ z – 1; en caso contrario, tendrá que dividir su mensaje en porciones del tamaño
apropiado. Luego, A usa el algoritmo expomod para calcular c = an mod z, que es lo que le
envía a B a través del canal inseguro. Usando su conocimiento privado de s, B obtiene a, y
por lo tanto el mensaje m de A, con una llamada a expomod(c, s, z). Esto funciona porque

cs mod z = (an mod z)s mod z = (an)s mod z = ans mod z = a

Ahora consideremos la tarea del espía. Asumiendo que ha interceptado todas las
comunicaciones entre A y B, sabe cuáles son z, n y c. Su propósito es determinar el
mensaje a de A, que es el único número entre 0 y z – 1 tal que c = an mod z. Es decir que
tiene que calcular la raíz enésima de c módulo z. No se conoce ningún algoritmo eficiente
para este cálculo: las exponenciaciones modulares se pueden calcular eficientemente con
expomod, pero resulta que el proceso inverso es inviable. El mejor método conocido hasta
ahora es el obvio: factorear z en p y q, calcular φ como (p – 1)(q – 1), calcular s a partir de
n y φ, y calcular a = cs mod z exactamente como debería haberlo hecho B. Todos los pasos
de este intento son viables, salvo el primero: factorear un número de 200 dígitos está más
allá del alcance de la tecnología actual. Entonces, la ventaja de B para descifrar los
mensajes dirigidos a él está en el hecho de que sólo él conoce los factores de z, que son
necesarios para calcular φ y s. Este conocimiento no proviene de sus habilidades para
factorear, sino más bien porque él eligió esos factores de z primero, y luego calculó z a
partir de ellos.

El sistema de criptografía RSA es ampliamente considerado como uno de los mejores


inventos en la historia de la criptografía.

F.C.A.D – U.N.E.R. 70

También podría gustarte