SlideShare una empresa de Scribd logo
9
Lo más leído
10
Lo más leído
11
Lo más leído
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++
Algoritmos en C++
Robert Sedgewick
Versión en español de
FernandoDavara Rodríguez
Universidad Pontificia de Salamanca
Campus de Madrid, España
Miguel Katrib Mora
Universidad de La Habana, Cuba
Sergio Ríos Aguilar
Consultor informático técnico
Con la colaboración de
Luis Joyanes Aguilar
Universidad Pontijicia de Salamanca
Campus de Madrid, España
México @Argenjjna Brasil * Colodía 0 Costa Rica Chile * E d o r
España Guatemala. P a d 0 Pení0 Puerto Rico Uruguay *Venezuela
Algoritmos en C++.pdf
Versión en español de la obra titulada Algorithmsin C++,
de Robert
Sedgewick, publicada originalmente en inglés por Addison-Wesley
Publishing Company, Inc., Reading, Massachusetts,O 1992.
Esta edición en español es la única autorizada.
PRIMERAED1CIÓN. 1995
O 1995 por Addison Wesley Iberoamericana, S.A.
D.R. P2DOQ por ADDWON WESLEY LONGMAN DE MÉXICO, S.A. M C.V.
Atlamulco No. 500, 50 piso
Colonia IndustrialAtoto, Naucalpan de Juárez
Edo de México, C P.53519
Cámara Nacionalde la Industria Editorial Mexicana, Registro No. 1031.
Reservadostodos los derechos. Ni la totalidad ni parte de esta publicación
pueden reproducirse, registrarse o transmitirse, por un sistema de
recuperación de información, en ninguna form ni por ningún medio, sea
dectrónico, mecánico, fotoquimíco, magneticeoelectroóptico, porfotocopia,
grabación o cualquier otro, sin permiso previopor escrito del editor.
El préstamo, alquiler o cualquier otra forma de cesión de uso de esteejemplar
requerirá también la autorización del editor o de sus representantes.
ISBN968-444-401-X
Impreso en México. Printedin Mexico.
I 2 3 4 5 6 7 8 9 0 0302010099
Algoritmos en C++.pdf
A Adam, Andrew, Brett,Robbie
y especialmentea Linda
I
lndice general
Prólogo .....................................................
Fundamentos
1. Introducción ...............................................
Algoritmos. Resumen de temas.
Ejemplo: Algoritmo de Euclides. Tipos de datos. Entrada/Salida.
Comentariosfinales.
3. Estructuras de datos elementales .............................
Arrays. Listasenlazadas. Asignación de memoria.Pilas. Implemen-
tación de pilas por listas enlazadas. Colas. Tiposde datos abstractos
y concretos.
Glosario. Propiedades. Representación de árboles binarios. Repre-
sentación de bosques. Recorrido de los árboles.
5. Recursión .................................................
Recurrencias. Divide y vencerás. Recorrido recursivo de un árbol.
Eliminación de la recursibn. Perspectiva.
Marco de referencia. Clasificaciónde los algoritmos. Complejidad
del cálculo. Análisis del caso medio. Resultados aproximados y
asintóticos. Recurrencias básicas. Perspectiva.
7. Implementación de algoritmos ...............................
Selección de un algoritmo. Análisis empírico. Optimizaciónde un
programa. Algoritmos y sistemas.
2. c++
(y C) .................................................
4. Árboles ...................................................
6. Análisis de algoritmos ......................................
Algoritmos de ordenación
8. Métodos de ordenación elementales ...........................
Reglas del juego. Ordenación por selección. Ordenación por inser-
ción. Digresión: Ordenación de burbuja. características del rendi-
miento de las ordenaciones elementales. Ordenación de archivos con
registros grandes. Ordenación de Shell. Cuenta de distribuciones.
xv
3
9
17
39
55
73
89
103
IX
X [NOICE GENERAL
9.
10.
11.
12.
13.
Quicksort .................................................
El algoritmo básico. Características de rendimiento del Quicksort.
Eliminación de la recursión. Subarchivos pequeños. Partición por
la mediana de tres. Selección.
Ordenación por residuos ....................................
Bits. Ordenación por intercambio de residuos. Ordenación directa
por residuos. características de rendimiento de la ordenación por
residuos. Una ordenación lineal.
Colas de prioridad ..........................................
Implementacioneselementales. Estructura de datos montículo. Al-
goritmos sobre montículos. Ordenación por montículos. Montícu-
los indirectos. Implementaciones avanzadas.
Ordenación por fusión ......................................
Fusión. Ordenación por fusión. Ordenación por fusión de listas.
Ordenación por fusión ascendente. características de rendimiento.
Implementacionesoptimizadas. Revisión de la recursión.
Ordenación externa .........................................
Ordenación-fusión. Fusión múltiple balanceada. Selección por sus-
titución. Consideraciones prácticas. Fusión polifásica. Un método
más fácil.
Algoritmos de búsqueda
14.
15.
16.
17.
18.
Métodos de búsqueda elementales ............................
Búsqueda secuencial. Búsqueda binaria. Búsqueda por árbol bina-
rio. Eliminación. Arboles binarios de búsqueda indirecta.
Arboles equilibrados ........................................
Árboles descendentes 2-3-4. Árboles rojinegros. Otros algoritmos.
Dispersión ................................................
Funciones de dispersión. Encadenamiento separado. Exploración
lineal. Doble dispersión. Perspectiva.
Búsqueda por residuos ......................................
Árboles de búsqueda digital. Árboles de búsqueda por residuos.
Búsqueda por residuos múltiple. Patricia.
Búsqueda externa ..........................................
Acceso secuencia1indexado. Árboles B. Dispersión extensible. Me-
moria virtual.
127
145
159
179
195
213
237
255
271
287
iNDlCE GENERAL XI
Procesamiento de cadenas
19. Búsqueda de cadenas .......................................
Una breve historia. Algoritmo de fuerza bruta. Algoritmo de Knuth
- Moms - Pratt. Algoritmo de Boyer - Moore. Algoritmo de Rabin
- Karp. Búsquedas múltiples.
20. Reconocimiento de patrones .................................
Descripción de patrones. Máquinas de reconocimiento de pa-
trones. Representación de la máquina. Simulación de la má-
quina.
21. Análisis sintáctico ..........................................
Gramáticas libres de contexto. Análisis descendente. Análisis as-
cendente. Compiladores.Compilador de compiladores.
Codificaciónpor longitud de series. Codificaciónde longitud varia-
ble. Construcción del código de Huffman. Implementación.
Reglas del juego. Métodos elementales. Máquinas de cifrar/desci-
fiar. Sistemas de cripto de claves públicas.
22. Compresión de archivos .....................................
23. Criptología ................................................
Algoritmos geométricos
24.
25.
26.
27.
28.
Métodos geométricos elementales ............................
Puntos, líneas y polígonos. Intersección de segmentos de 1í-
neas. Camino cerrado simple. Inclusión en un polígono. Perspec-
tiva.
Obtención del cerco convexo .................................
R
e
g
l
a
s del juego. Envolventes. La exploración de Graham. Elimi-
nación interior. Rendimiento.
Búsqueda por rango ........................................
Métodos elementales. Método de la rejilla. Árboles bidimensiona-
les. Búsqueda por rango multidimensional.
Interseccióngeométrica .....................................
Segmentos horizontales y verticales. Implementación. Intersección
de segmentos en general.
Problemas del punto más cercano
Problema del par más cercano. Diagramas de Voronoi.
............................
307
325
331
351
365
379
391
407
423
435
XI1 ÍNDICE GENERAL
Algoritmos sobre grafos
29.
30.
31.
32.
33.
34.
Algoritmos sobre grafos elementales ..........................
Glosario. Representación. Búsqueda en profundidad. Búsqueda en
profundidad no recursiva. Búsqueda en amplitud. Laberintos.
Perspectivas.
Conectividad ...............................................
Componentesconexas. Biconectividad. Algoritmos de unión - per-
tenencia.
Grafos ponderados .........................................
Árbol de expansión mínimo. Búsqueda en primera prioridad. Mé-
todo de Kruskal. El camino más corto. Árbol de expansión mí-
nimo y camino más corto en grafos densos. Problemas geométri-
Grafos dirigidos ............................................
Búsqueda en profundidad. Clausura transitiva. Todos los caminos
más cortos. Ordenación topológica. Componentesfuertemente co-
nexas.
Flujo de red ...............................................
El problema del flujo de red. Método de Ford - Fulkerson. Bús-
queda de red.
Concordancia ..............................................
Grafos bipartidos. Problema del matrimonio estable. Algoritmos
avanzados.
cos.
Algoritmos matemáticos
35.
36.
37.
38.
Números aleatorios .........................................
Aplicaciones. Método de congruencia lineal. Método de congruen-
cia aditiva. Comprobación de la aieatoriedad. Notas de implemen-
tación.
Aritmética ................................................
Aritmética polinómica. Evaluación e interpoIación polinómica.
Multiplicación polinómica. Operaciones aritméticas sobre enteros
grandes. Aritmética de matrices.
Eliminación gaussiana ......................................
Un ejemplo simple. Esbozo del método, Variacionesy extensiones.
Ajuste de curvas ...........................................
Interpolación polinómica. Interpolación spline. Método de los mí-
nimos cuadrados.
45 1
475
491
513
529
539
555
569
585
597
íNDICE GENERAL Xlll
39. Integración ................................................
Integración simbólica. Métodos de cuadratura elementales. Méto-
dos compuestos.Cuadratura adaptativa.
Temas avanzados
40. Algoritmos paralelos ........................................
Aproximaciones generales. Mezcla perfecta. Arrays sistólicos. Pers-
pectiva.
Evaluar, multiplicar, interpolar. Raíces complejas de la unidad.
Evaluación de las raíces de la unidad. Interpolaciónen !as raíces de
la unidad. Implementación.
El problema de la mochila. Producto de matrices en cadena. Ár-
boles binanos de búsqueda óptima. Necesidades de espacio y
tiempo.
Programas lineales. Interpretación geométrica. El método símplex.
Implementación.
Búsqueda exhaustiva en grafos. Vuelta atrás. Digresión: Genera-
ción de permutaciones. Algoritmos de aproximación.
45. Problemas NP-completos ...................................
Algoritmos deterministas y no deterministas de tiempo polinó-
mico. Compleción NP. El teorema de Cook. Algunos problemas
NP-completos.
41. La transformada rápida de Fourier ...........................
42. Programación dinámica .....................................
43. Programación lineal ........................................
44. Búsqueda exhaustiva .......................................
Epílogo ........................................................
Vocabulario técnico bilingüe ......................................
Índice de programas ............................................
609
623
637
649
661
677
691
701
705
713
Índice analítico ................................................. 719
Algoritmos en C++.pdf
Prólogo
La finalidad de este libro es dar una idea clara de los algoritmos más importan-
tes empleados hoy día en las computadoras y ensefiar sus técnicas fundamen-
tales a quienes, cada vez en mayor número, tienen necesidad de conocerlos. Se
puede utilizar como libro de texto para segundo, tercero o cuarto curso de in-
formática, una vez que los estudiantes hayan adquirido cierta habilidad en la
programación y se hayan familiarizado con los sistemas informáticos, pero an-
tes de realizar cursos de especialización en áreas avanzadas de la informática o
de sus aplicaciones.
Además, el libro puede ser útil para la autoformacióno como texto de con-
sulta para aquellos que trabajan en el desarrollo de aplicaciones o sistemas para
computadoras, ya que contiene un gran número de algoritmos útiles e infor-
mación detallada sobre sus características de ejecución. La amplia perspectiva
adoptada en el libro lo convierte en una adecuadaintroducción a este campo.
Los algoritmos se expresan en el lenguaje de programación C++ (también se
dispone de versiones del libro en Pascal y C), pero no se necesitan conocimien-
tos de un lenguaje de programación específico -el tratamiento aquí contem-
plado es autónomo, aunque un tanto rápido-. Los lectores que estén familia-
rizados con C++ encontrarán en este lenguaje un vehículo útil para aprender
una serie de métodos de interés práctico. Aquellos que tengan conocimientos de
algoritmos básicos encontrarán en el libro una forma útil de conocer diversas
características del lenguaje C++, y simultáneamenteaprenderán algunos algo-
ritmos nuevos.
Finalidad
El libro contiene 45 capítulos agrupados en ocho partes principales: fundamen-
tos, ordenación, búsqueda, procesamiento de cadenas, algoritmos geométricos,
algoritmos sobre grafos, algoritmos matemáticosy temas avanzados. Un obje-
tivo importante a la hora de escribir este libro ha sido reunir los métodos fun-
damentalesde diversas áreas, con la finalidad de dar a conocer los más emplea-
dos en la resolución de problemas por medio de computadoras.Algunos de los
capítulos son una introducción a materias más avanzadas. Se espera que las
descripciones aquí empleadas permitan al lector comprender las propiedades
básicas de algoritmos fundamentales, que abarcan desde las colas de prioridad
y la dispersión, al símplex y la transformada de Fourier rápida.
xv
XVI PROLOGO
Se aconseja que el lector haya realizado previamente uno o dos cursos de
informática, o disponga de una experiencia equivalente en programación, para
que pueda valorar el contenido de este libro: lo ideal sería un curso de progra-
mación en un lenguaje de alto nivel como C++,C o Pascal, y quizás otro curso
sobre conceptos fundamentalesde sistemasde programación. Este libro está pues
destinado a cualquier persona con experiencia en un lenguaje moderno de pro-
gramación y con las ideasbásicas de los sistemas modernosde computadora.Se
incluyen en el texto algunas referencias que pueden ayudar a subsanar las po-
sibles lagunas del lecior.
En su mayor parte, los conceptos matemáticos que sustentan los resultados
analíticos se explican (o bien se clasifican como «másallá de la finalidad» de
este libro), por lo que para la comprensión general del libro no se requiere una
preparación específica en matemáticas, aunque, en definitiva, es útil una cierta
madurez matemática. Algunos de los últimos capítulos tratan algoritmos rela-
cionados con conceptos matemáticos más avanzados -se han incluido para
situar a los algontmos en el contexto de otros métodos y no para enseñar los
conceptos matemáticos-. Por lo tanto, la presentación de los conceptos mate-
máticos avanzados es breve, general y descriptiva.
Utilización en planes de estudio
La forma en que se puede enseñar esta materia es muy flexible. En gran me-
dida, se pueden leer unos capítulos independientementede otros, aunque en al-
gunos casos los algoritmos de un capítulo utilizan los métodos del capítulo an-
tenor. Se puede adaptar el texto para la realización de diferentes cursos mediante
la posible selección de 25 ó 30 de los 45 capítulos, según las preferencias del
profesor y la preparación de los estudiantes.
El libro comienza con una sección de introducción a las estructuras de datos
y ai diseño y análisis de algoritmos. Esto establece las pautas para el resto de la
obra y proporciona una estructura, dentro de la que se tratan algoritmos más
avanzados. Algunos lectores pueden saltarseu hojear esta sección; otros pueden
aprender aquí las bases.
En un curso elemental sobre «algoritmos y estructuras de datos)) podrían
omitirse algunos de los algoritmos matemáticos y ciertos temas avanzados, ha-
ciendo hincapié en la forma en la que se utilizan las estructuras de datos en las
implementaciones. En un curso intermedio sobre «diseño y análisis de algont-
mosn podrían omitirse algunas de las secciones que están orientadas a la prác-
tica y recalcarse la identificación y el estudio de las condiciones en las que los
algontmos alcanzan rendimientos acintóticos satisfactorios. En un curso sobre
las «herramientas del software))se podrían omitir las matemáticas y el material
algorítmico avanzado, y así poner mayor énfasis en cómo integrar las imple-
mentaciones propuestas en grandes sistemas o programas. Un curso sobre «al-
PRÓLOGO XUll
goritmos)) podría adoptar un enfoque de síntesis e introducir conceptos de to-
das estas áreas.
Algunos profesores, para dar una orientación particular, pueden añadir ma-
terial complementario a los cursos descritos anteriormente. Para las ((estructu-
ras de datos y algoritmos))se godría ampliar el estudio sobre estructuras básicas
de datos; para (diseño y análisisde algoritmos))se podría profundizar en el aná-
lisis matemático, y para las ((herramientas del software))convendría profundi-
zar en las técnicas de ingeniería del software. En este libro se contemplan todas
estas áreas, pero el énfasis se pone en los algoritmos propiamente dichos.
En los últimos años se han empleado versiones anteriores de este libro en
decenas de colegios y universidades norteamericanas, como texto para el se-
gundo o tercer curso de informática y como lectura complementaria para otros
cursos. En Princeton, la experiencia ha demostrado que el amplio espectro que
cubre este libro proporciona a los estudiantes de los últimos cursos una intro-
ducción a la informática, que puede ampliarse con cursosposteriores sobreaná-
lisis de algoritmos, programación de sistemas e informática teórica, a la vez que
proporciona a todos los estudiantes un gran conjunto de técnicas de las que
pueden obtener un provecho inmediato.
Hay 450 ejercicios, diez por capítulo, que generalmente se dividen en dos
grupos. La mayor parte tienen como finaiidad comprobar que los estudiantes
han entendido la materia del libro, y hacer que trabajen en un ejemplo o apli-
quen los conceptos descritos en el texto. Sin embargo, algunos de ellos son para
implementar y agrupar algoritmos, necesitándose en ocasiones estudios empí-
ricos para comparar algoritmos y conocer sus propiedades.
Algoritmos de uso práctico
Este libro está orientado al tratamiento de algoritmos de uso práctico. El obje-
tivo es enseñar a los estudiantes las herramientas que tienen a su alcance, para
que puedan implementar con absoluta confianza algoritmos útiles, ejecutarlos
y depurarlos. Se incluyen en el texto implementaciones completas de los méto-
dos empleados, junto con descripciones del funcionamiento de los programas
en un conjunto coherente de ejemplos. De hecho, como se verá en el epílogo,
se incluyen cientos de figuras que han sido generadas por los propios algorit-
mos. Muchos algoritmos se aclaran desde un punto de vista intuitivo a través
de la dimensión visual que proporcionan las figuras.
Las característicasde los algoritmos y las situaciones en que podrían ser úti-
les se presentan de forma detallada. Aunque no se haga hincapié en ellas, no se
ignoran las relaciones entre el análisis de algoiitmos y la informática teórica.
Cuando se considere apropiado, se presentarán los resultados analíticos y em-
píricos para ilustrar por qué se prefieren ciertos algoritmos. Cuando sea intere-
sante, se describirá la relación entre los algoritmos prácticos presentados y los
resultados puramente teóricos. Se encontrará a lo largo del texto información
XVlll PRÓLOGO
específicasobre las característicasde rendimiento de los algoritmos,bajo la forma
de «propiedades», que resumen los hechos importantes de los algoritmos que
merecen un estudio adicional.
Algunos algoritmos se utilizan en programas relativamente pequeños para
resolver problemas concretos y otros se integran, como parte de un todo, en sis-
temas relativamente grandes. Muchos algoritmos fundamentales encuentran
aplicación en ambos casos. Se indicará cómo adaptar algoritmosespecíficospara
resolver problemas concretos o algoritmos generalespara su integración en pro-
gramas más grandes. Talesconsideracionesson particularmente interesantespara
los algoritmos expresadosen un lenguaje orientado a objetos,tal como C++. En
este libro se proporciona la información apropiada que puede utilizarsepara ha-
cer intercambios inteligentesentre utilidad y rendimiento en implementaciones
de algoritmos muy utilizados.
A pesar de que existe un escasotratamiento directo del empleo específicode
los algoritmos en aplicaciones para la ciencia y la ingeniería, las posibilidades
de tal uso se mencionarán cuando sea conveniente. La experiencia demuestra
que cuando los estudiantesaprenden pronto buenos algoritmos informáticosson
capaces de aplicarlos para resolver problemas a los que se enfrentarán más ade-
lante.
Lenguaje de programación
El lenguaje de programación utilizado a lo largo de este libro es C++ (también
existen versiones en Pascal y C). Cualquier lenguaje particular tiene ventajas e
inconvenientes -la intención aquí es facilitar, al creciente número de personas
que utilizan el C++ como lenguaje originalpara sus aplicaciones,el acceso a los
algoritmos fundamentales que se han ido desarrollando a través de los años-.
Los programas se pueden traducir fácilmente a otros lenguajesde programación
modernos, ya que están escritos en una forma sencilla que los hace relativa-
mente independientes del lenguaje. Desde luego, muchos de los programas han
sido traducidos desde Pascal, C y otros lenguajes, aunque se intenta utilizar el
lenguaje C estándar cuando sea apropiado. Por otra parte, C++ se adapta per-
fectamente a la tarea del libro, dado su soporte básico en la abstracción de datos
y su programación modular que permite expresar claramente las relaciones en-
tre las estructuras de datos y los algoritmos.
Algunos de los programas se pueden simplificar utilizando aspectos más
avanzados del lenguaje, pero esto ocurre menos veces de las que se podría pen-
sar. Aunque las características del lenguaje se presentarán cuando sea apro-
piado, este libro no tiene como objetivo ser un manual de referencia de C++ o
de la programación orientada a objetos. Mientras se utilizan las clases de C++
reiteradamente, no se usan plantillas, herencias, ni funciones virtuales, pero los
algoritmos están codificadosasí para facilitar los procesos de instalación en sis-
temas grandes, donde tales aspectos se pueden utilizar para beneficiarse de la
PR~LOGO XIX
programación orientada a objetos. Cuando se precise hacer una elección será
concentrándose'en los algoritmos, no en los detallesde la implementaciónni en
las características del lenguaje.
Una de las metas de este libro es presentar los algoritmos de la forma más
simple y directa que sea posible. Los programas no están para leerse por sí mis-
mos, sino como parte del texto que los encuadra. Este estilo se ha elegido como
una alternativa a, por ejemplo, la introducción de comentarios entre líneas. El
estilo es coherente, de forma que programas similares parecerán similares.
Agradecimientos
Mucha gente me ha ayudado al comentar las versiones anteriores de este libro.
En particular, los estudiantes de Princeton y Brown han sufrido con las versio-
nes preliminares del material del libro en los ochenta. En especial, doy las gra-
cias a Trina Avery, Tom Freeman y Janet Incerpi por su ayuda en la produc-
ción de la primera edición. En particular a Janet por pasar el libro al formato
TEX,añadir los miles de cambios que hice después del «último borradon) de la
primera edición, guiar los archivos a través de diversos sistemas para imprimir
las páginas e incluso escribir una rutina de revisión para TEXutilizada para ob-
tener manuscritos de prueba, entre otras muchas cosas. Solamente cuando yo
mismo desempeñé estas tareas en posteriores versiones, pude apreciar real-
mente la contribución de Janet. Me gustaría también dar las gracias a muchos
de los lectores que me ayudaron con comentarios detallados sobre la segunda
edición, entre ellos a Guy Almes, Jay Gischer, Kennedy Lemke, Udi Manber,
Dana Richards, John Reif, M. Rosenfeld, Stephen Seidman y Michael Quinn.
Muchos de los diseños de las figuras están basados en el trabajo conjunto
con Marc Brown en el proyecto «aula electrónica» en la Brown University en
1983.Agradezco el apoyo de Marc y su ayuda en la creación de los diseños (sin
mencionar el sistema con el que trabajábamos).También me gustaría agradecer
la ayuda de Sarantos Kapidakis para obtener el texto f
i
n
a
l
.
Esta versión C++ debe su existenciaa lú tenacidad de Keith Wollman, quien
me convenció para realizarla, y a la paciencia de Peter Gordon, que estaba con-
vencido de que la sacaría adelante. La buena voluntad de Dave Hanson para
contestar preguntas acerca de C y C++ fue incalculabIe. También me gustaría
agradecer a Darcy Cotten y a Skip Plank su ayudapara producir el libro.
Mucho de lo que he escrito aquí lo he aprendido gracias a las enseñanzasde
Don Knuth, mi consejero en Stanford. Aunque Don no ha tenido influencia
directa sobre este trabajo se puede sentir su presencia en el libro, porque fue él
quien supo colocar el estudio de algoritmos sobreuna base científica de talforma
que sea posible realizar un trabajo como éste.
Estoy muy agradecido por el apoyo de la Brown University e INRIA donde
realicé la mayor parte del trabajo del libro, y al Institute for Defense Analyses y
al Xerox Palo Alto Research Center, donde hice parte del libro mientras lo vi-
xx PRÓLOGO
sitaba. Muchas partes del libro se deben a la investigación realizada y cedida
generosamente por la National Science Foundation y la Office of Naval Re-
search. Finalmente, quisiera dar las gracias a Bill Bowen, Aaron Lemonick y
Neil Rudenstine de la Princeton University por apoyarme al crear un entorno
académico en el que fui capaz de preparar este libro, a pesar de tener muchas
otras responsabilidades.
ROBERT
SEDGEWICK
Marly-le-Roi,Francia,febrero, 1983
Princeton,New Jersey, enero, I990
Princeton, New Jersey, enero, 1992
Fundamentos
Algoritmos en C++.pdf
1
Introducción
El objetivo de este libro es estudiar una variedad muy extendida de algoritmos
útiles e importantes: los métodos de resolución de problemas adaptados para su
realización por computadora. Se tratarán diferentes áreas de aplicación, po-
niendo siempre especial atención a los algoritmos «fundamentales» cuyo co-
nocimiento es importante e interesante su estudio. Dado el gran número de al-
goritmos y de dominios a cubrir, muchos de los métodos no se estudiarán en
profundidad. Sin embargo, se tratará de emplear en cada algoritmo el tiempo
suficientepara comprender sus característicasesencialesy para respetar sus par-
ticularidades. En resumen, la meta es aprender un gran número de los algorit-
mos más importantes que se utilizan actualmente en computadoras, de forma
que se pueda utilizarlos y apreciarlos.
Para entender bien un algoritmo, hay que realizarlo y ejecutarlo; por consi-
guiente, la estrategiarecomendada para comprender los programas que se pre-
sentan en este libro es implementarlos y probarlos, experimentar con variantes
y tratar de aplicarlos a problemas reales. Se utilizará el lenguaje de programa-
ción C++ para presentar y realizar la mayor parte de los algoritmos; no obs-
tante, al utilizar sólo un subconjunto relativamente pequeño del lenguaje, los
programas pueden traducirse fácilmente a otros lenguajesde programación.
Los lectores de este libro deben poseer al menos un año de experiencia en
lenguajes de programación de alto y bajo nivel. También sena conveniente te-
ner algunos conocimientos sobre los algoritmos elementales relativos a las es-
tructuras de datos simplestales como arrays, pilas, colas y árboles, aunque estos
temas se traten detalladamente en los Capítdos 3 y 4. De igual forma, se su-
ponen unos conocimientos elementales sobre la organización de la máquina,
lenguajes de programación y otros conceptos elementales de informática.
(Cuando corresponda se revisarán brevemente estas materias, pero siempre
dentro del contexto de resolución de problemas particulares.) Algunas de las
áreas de aplicación que se abordarán requieren conocimientos de cálculo ele-
mental. También se utilizarán algunos conceptos básicos de álgebra lineal, geo-
3
4 ALGORITMOS EN C++
metria y matemática discreta, pero no es necesario el conocimiento previo de
estos temas.
Algoritmos
La escritura de un programa de computadora consiste normalmente en imple-
mentar un método de resolución de un problema, que se ha diseñado previa-
mente. Con frecuencia este método es independiente de la computadora utili-
zada: es igualmente válido para muchas de eilas. En cualquier caso es el método,
no el programa, el que debe estudiarse para comprendercómo está siendo abor-
dado el problema. El término algoritmo se utiliza en informática para describir
un método de resolución de un problema que es adecuado pará su implemen-
tación como programa de computadora. Los algoritmos son la «esencia» de la
informática; son uno de los centros de interés de muchas, si no todas, de las
áreas del campo de la informática.
Muchos algoritmos interesantes llevan implícitos complicados métodos de
organización de los datos utilizados en el cálculo. Los objetos creados de esta
manera se denominan estructuras de datos, y también constituyen un tema
principal de estudio en informática. Así, estructurasde datos y algoritmosestán
íntimamente relacionados; en este libro se mantiene el punto de vista de que las
estructuras de datos existen como productos secundarios o finales de los algo-
ritmos, por lo que es necesario estudiarlas con el fin de comprenderlos algorit-
mos. Un algoritmo simple puede dar origen a estructurasde datos complicadas,
y a la inversa, un algoritmo complicado puede utilizar estructurasde datos sim-
ples. En este libro se estudian las propiedades de muchas estructuras de datos,
por lo que bien se podría haber titulado Algoritmos y estructuras de datos en
C++.
Cuando se desarrolla un programa muy grande, una gran parte del esfuerzo
se destina a comprender y definir el prob1ema.a resolver, analizar su compleji-
dad y descomponerh en subprogramas más pequeños que puedan realizarse fá-
cilmente. Con frecuenciasucede que muchos de los algoritmos que se van 8 uti-
lizar son fáciles de implementar una vez que se ha descompuesto el programa.
Sin embargo, en la mayor parte de los casos, existen unos pocos algoritmos cuya
elección es critica porque su ejecución ocupará la mayoría de los recursos del
sistema. En este libro se estudiará una variedad de algoritmos fundamentales
básicos para los grandes programas de muchas áreas de aplicación.
El compartir programas en los sistemasinformáticos es una técnica cada vez
más difundida, de modo que aunque los usuarios serios utilizarán íntegramente
los algoritmos de este libro, quizá necesiten implementar sólo alguna parte de
ellos. Realizando las versiones simples de los algoritmos básicos se podrá com-
prenderlos mejor y también utilizar versiones avanzadas de forma más eficaz.
Algunos de los mecanismos de software compartido de los sistemas de compu-
tadoras dificultan a menudo la adaptación de los programas estándar a la reso-
INTRODUCCIÓN 5
lución eficaz de tareas específicas, de modo que muchas veces surge la necesi-
dad de reimplementar algunos algoritmosbásicos.
Los programasestán frecuentementesobreoptimizados.Puede no ser útil es-
merarse excesivamente para asegurarse de que una realización sea lo más efi-
ciente posible, a menos que se trate de un algoritmo susceptiblede utilizarse en
un2 tarea muy amplia o que se utilice muchas veces. En los otros casos, bastará
una implementación relativamente simple; se puede tener cierta confianza en
que funcionará y en que posiblemente su ejecución sea cinco o diez veces más
lenta que la mejor versión posible, lo que significa unos pocos segundos extra
en la ejecución. Por el contrario la elección del algoritmo inadecuado desde el
primer momento puede producir una diferencia de un factor de unos cientos,o
de unos miles, o más, lo que puede traducirse en minutos, horas, o incluso más
tiempo de ejecución. En este libro se estudiarán implementacionesrazonables y
simples de los mejores algoritmos.
A menudo varios algoritmos diferentes son válidos para resolver el mismo
problema. La elección del mejor algoritmo para una tarea particular puede ser
un proceso muy complicado y con frecuencia conllevará un análisis matemá-
tico sofisticado.La rama de la informática que estudia tales cuestiones se llama
análisis de algoritmos. Se ha demostrado a través de dicho análisis que muchos
de los algoritmos que se estudiarán tienen un rendimiento muy bueno, mien-
tras que de otros se sabe que funcionan bien simplemente a través de la expe-
riencia. No se hará hincapié en comparacionesde resultados de rendimiento: la
meta es aprender algunos algoritmos que resuelvan tareas importantes. Pero,
como no se debe usar un algoritmo sin tener alguna idea de qué recursospodría
consumir, se intentará precisar de qué forma se espera que funcionen los algo-
ritmos de este libro.
Resumen de temas
A continuación se presenta una breve descripción de las partes principales del
libro, que enuncian algunos de los temas específicos, así como también alguna
indicación de la orientación general sobre la materia. Este conjunto de temas
intentará tocar tantos algoritmos fundamentales como sea posible. Algunos de
los temas tratados constituyen el «corazón» de diferentes áreas de la informá-
tica, y se estudiarán en profundidad para comprender los algoritmos básicos de
gran utilidad. Otras áreas son campo de estudios superioresdentro de la infor-
mática y de sectores relacionados con ella, tales como el análisis numérico, la
investigación operativa, la construcción de compiladoresy la teoría de algorit-
mos -en estos casos el tratamiento servirá como una introducción a dichos
campos a través del examen de algunos métodos básicos-.
En el contexto de este libro, los FUNDAMENTOS son las herramientas y
métodos que se utilizarán en los capítulosposteriores. Se incluye una corta dis-
cusión de c++,
seguida por una introducción a las estructuras de datos básicas,
6 ALGORITMOS EN C++
que incluye arrays, listas enlazadas, pilas, colas y árboles. Se presentará la utili-
zación práctica de la recursión, encaminando el enfoque básico hacia el análisis
y la realización de algoritmos.
Los métodos de OIWENACIÓN, para reorganizar archivos en un orden de-
terminado, son de vital importancia y se tratan en profundidad. Se desarrollan,
describen y comparan un gran número de métodos. Se tratan algoritmos para
diversos enunciadosde problemas, como colas de prioridad, selección y fusión.
Algunos de estos algoritmos se utilizan como base de otros algoritmos descritos
posteriormente en el libro.
Los métodos de BÚSQUEDA para encontrar datos en los archivos son tam-
bién de gran importancia. Se presentarán métodos avanzados y básicos de bús-
queda con árboles y transformaciones de clavesdigitales, incluyendo árboles de
búsqueda binaria, árboles equilibrados, dispersión, árboles de búsqueda digital
y tries, así como métodos apropiados para archivos muy grandes. Se presenta-
rán las relaciones entre estos métodos y las similitudes con las técnicas de or-
denación.
Los algoritmos de PROCESAMIENTO DE CADENAS incluyen una gama
de métodos de manipulación de (largas)sucesiones de caracteres. Estos métodos
conducen al reconocimiento de patrones en las cadenas, que a su vez conduce
al análisis sintáctico. También se desarrollan técnicas para comprimir archivos
y para criptografia.Aquí, otra vez, se hace una introducción a temas avanzados
mediante el tratamiento de algunos problemas elementales, importantes por sí
mismos.
Los ALGORITMOS GEOMÉTRICOSson un conjunto de métodos de re-
solución de problemas a base de puntos y rectas (y de otros objetos geométricos
sencillos),que no se han puesto en práctica hasta hace poco tiempo. Se estudian
algoritmos para buscar el cerco convexo de un conjunto de puntos, para encon-
trar intersecciones entre objetos geométricos, para resolver problemas de pro-
ximidad y para la búsqueda multidimensional. Muchos de estos métodos com-
plementan de forma elegante las técnicas más elementales de ordenación y
búsqueda.
Los ALGORITMOS SOBRE GRAFOS son útiles para una variedad de pro-
blemas importantes y dificiles. Se desarrolla una estrategia general para la bús-
queda en grafosy se aplica a los problemas fundamentalesde conectividad, como
el camino más corto, el árbol de expansión mínimo, flujo de red y concordan-
cia. Un tratamiento unificado de estos algoritmos demuestra que todos ellos es-
tán basados en el mismo procedimiento y que éste depende de una estructura
de datos básica desarrollada en una sección anterior.
Los ALGORITMOS MATEMATICOSpresentan métodos fundamentales
que proceden del análisis numérico y de la aritmética. Se estudian métodos de
la aritmética de enteros, polinomios y matrices, así como también algoritmos
para resolver una gama de problemas matemáticos que provienen de muchos
contextos: generación de números aleatorios, resolución de sistemasde ecuacio-
nes, ajuste de datos e integración. Se pone énfasis en los aspectos algontmicos
de estos métodos, no en sus fundamentosmatemáticos.
INTRODUCCIÓN 7
Los TEMAS AVANZADOS se presentan con el objeto de relacionar el con-
tenido del libro con otros camposde estudio más avanzados. Lascomputadoras
de arquitectura específica,la programación dinámica, la programación lineal, la
búsqueda exhaustiva y los problemas NP-completos se examinan desde un punto
de vista elemental para dar al lector alguna idea de los interesafites campos de
estudio avanzados que sugieren los problemas simples que contiene este libro.
El estudio de los algoritmos es interesante porque se trata de un campo nuevo
(casi todos los algoritmos que se presentan en el libro son de hace menos
de 25 años), con una rica tradición (algunos algoritmos se conocen desde hace
miles de años). Se están haciendo constantemente nuevos descubrimientos y
pocos algoritmos se entienden por completo. En este libro se consideran tanto
algoritmos dificiles, complicados y enredados, como algoritmos fáciles, simples
y eiegantes. El desafío consiste en comprender los primeros y apreciar los últi-
mos en el marco de las diferentesaplicacionesposibles. Al hacerlo se descubrirá
una variedad de herramientas eficaces y se desarrollará una forma de ((pensa-
miento algontmico)) que será muy útil para los d e d o s informáticos del por-
venir.
Algoritmos en C++.pdf
2
A lo largo de este libro se va a utilizar el lenguaje de programación C++. Todos
los lenguajes tienen su lado negativo y su lado positivo, y así, la elección de
cualquiera de eiios para un libro como éste tiene ventajas e inconvenientes.Pero,
como muchos de los lenguajes modernos son similares, si no se utilizan más
que algunas instrucciones y se evitan decisiones de realización basadas en las
peculiaridades de C++, los programas que se obtengan se podrán traducir fácil-
mente a otros lenguajes.El objetivo es presentar los algoritmos de la forma más
simple y directa que sea posible; C++ permite hacerlo.
Los algoritmos se describen frecuentemente en los libros de texto y en los
informes científicos por medio de seudolenguaje -por desgracia esto lleva a
menudo a omitir detalles y deja al lector bastante lejos de una implementación
práctica-. En este libro se considera que el mejor camino para comprender un
algoritmoy comprobar su utilidad es experimentarlocon una situación real. Los
lenguajesmodernos son Io suficientemente expresivoscomo para que lasimple-
mentaciones reales puedan ser tan concisas y elegantes como sus homólogas
imaginarias. Se aconseja al lector que se familiarice con el entorno C++ de pro-
gramación local, ya que en el libro las implementaciones son pregramas pen-
sados para ejecutarlos, experimentar con ellos,modificarlos y utilizarlos.
La ventaja de utilizar C++ es que este lenguaje está muy extendido y tiene
todas las características básicas que se necesitan en las diversas implementacio-
nes; el inconveniente es que posee propiedades no disponibles en algunos otros
lenguajes mcdernos, también muy extendidos, por lo que se deberá tener cui-
dado y ser consciente de la dependencia que los programas tengan del lenguaje.
Algunos de los programas se verán simplificadospor las características avanza-
das del lenguaje, pero esto ocurre menos veces de las que se podría pensar.
Cuando sea apropiado, la presentación de los programas cubrirá los puntos re-
levantes del lenguaje. En particular, se aprovechará una de las principales vir-
tudes de C++, su compatibilidad con C: el grueso de los códigos se reconocerfi
fácilmente como C, pero las características importantes de C++ tendrán un pa-
pel destacado en muchas de las realizaciones.
9
i o ALGORITMOS ENC++
Una descripción concisa del lenguaje C++ se encuentra en el libro de
Stroustrup The C++Programming Language (segunda edición) '.El objetivo
de este capítulo no es repetir la información de dicho libro, sino más bien ilus-
trar algunasde lascaracterísticasbásicas del lenguaje,por lo que se utilizará como
ejemplo la realización de un algoritmo simple (pero clásico). Aparte de la en-
trada/salida, el código C++ de este capítulo es también código C; también se
utilizarán otras características de C++ cuando se consideren estructuras de da-
tos y programas más complicados en algunos de los capítulos siguientes.
Ejemplo: Algoritmo de Euclides
Para comenzar, se consideraráun programa en C++ para resolver un problema
clásico elemental: «Reducir una fracción determinada a sus términos más ele-
mentales». Se desea escribir 2/3, no 4/6, 200/300, o 178468/267702. Resolver
este problema es equivalente a encontrar el rnúximo cornUn divisor (mcd) del
numerador y denominador: el mayor entero que divide a ambos. Una fracción
se reduce a sus términos más elementalesdividiendo el numerador y el deno-
minador por su máximo común divisor. Un método eficaz para encontrar el
máximo común divisor fue descubiertopor los antiguosgriegos hace más de dos
mil años: se denomina el algoritmo de Euclides porque aparece escrito detalla-
damente en el famoso tratado Los elementos,de Euclides.
El método de Euclides está basado en el hecho de que si u es mayor que v,
entonces el máximo común divisor de u y v es el mismo que el de v y u - v.
Esta observación permite la siguiente implementación en C++:
#include t i ostream.h>
int mcd(int u , int v)
i n t t ;
while (u > O)
{
{
i f (u < v) { t = u; u = v; v = t; }
u - u - v ;
1
return v;
1
main()
I
I Existe versión en espatiol de Addison-Wesley/Díaz de Santos (1994) con el título El lengzuzje deprogruma-
ción Ci+.(A!del T.)
c++(Y C) 11
i n t x, y;
while (cin >> x && cin << y)
if (x>O && y>O) cout << x << I I << y << I I
<< mcd(x,y) << '  n ' ;
}
Antes de seguir adelante hay que estudiar las propiedades del lenguaje expuesto
en este código. C++ tiene una rigurosa sintaxis de alto nivel que permite iden-
tificar fácilmente las principales característicasdel programa. El programa con-
siste en una lista de funciones, una de las cuales se llama main ( ), y constituye
el cuerpo del programa. Las funciones devuelven un valor con la instrucción
return. C++ incluye una «bibliotecade flujos)) para la entrada/salida. La ins-
trucción incl ude permite hacer referencia a esta biblioteca. El operador <<
significa ((poneren» el «flujo de salida) cout, y, de igual manera, >> significa
«obtener de» el «flujo de entrada) cin. Estos operadores comparan los tipos de
datos que se obtienen con los flujos -en este caso se leen como datos de en-
trada dos enteros, y se obtendrán como salidajunto con su máximo común di-
visor (seguidos por los caracteres n que indican mueva línea»)-. El valor de
cin >> x es O cuando no hay más datos de entrada.
La estructura del programa anterior es trivial: se leen pares de números de
la entrada, y a continuación, si ambos son positivos, se graban en la salidajunto
con su máximo común divisor. (¿Qué sucede cuando se llama a la función mcd
con u o v negativos o con valor cero?)La función mcd implementael algoritmo
de Euclides por sí misma: el programa es un bucle que primero se asegura de
que u >=v intercambiando sus valores, si fuera necesario, y reemplazando a
continuación u por u - v. El máximo común divisor de las variables u y v es
siempre igual al máximo común divisor de los valores originales que entraron
al procedimiento: tarde o temprano el proceso termina cuando u es igual a O y
v es igual al máximo común divisor de los valores originales de u y v (y de todos
los intermedios).
El ejemplo anterior se ha escrito por completo en C++, para que el lector
pueda utilizarlo para familiarizarsecon algunos sistemas de la programación en
C++.
El algoritmo de interés se ha escrito como una subrutina (mcd),y el pro-
grama principal es un «conducton>que utiliza la subrutina. Esta organización
es típica, y se ha incluido aquí el ejemplo completo para resaltar que los algo-
ritmos presentados en este libro se entenderán mejor si se implementan y eje-
cutan con algunos valores de entrada de prueba. Dependiendode la calidad del
entorno de depuración disponible, el lector podría desear llegar más lejos en el
análisisde los programas propuestos. Por ejemplo, puede ser interesante ver los
valores intermedios que toman u y v en el bucle whi 1e del programa anterior.
Aunque el objetivo de esta sección es el lenguaje, no el algoritmo, se debe
hacer justicia ai clásico algoritmo de Euclides: la implementación anterior puede
mejorarse notando que, una vez que u > v, se restarán de u los múltiples va-
lores de v hasta encontrar un número menor que v. Pero este número es exac-
12 ALGORITMOS EN C++
tamente el resto que queda al dividir u entre v, que es lo que el operador mó-
dulo (%)
calcula: el máximo común divisor de u y v es igual ai máximo común
divisor de v y u % v. Por ejemplo,el máximo común divisor de 461952y 116298
es 18, Sil y como muestra la siguiente secuencia
461952, 116298, 113058, 3240,2898, 342, 162, 18.
Cada elemento de esta sucesión es el resto que queda al dividir los dos elemen-
tos anteriores: la sucesión termina porque 18 es divisor de 162,de manera que
18 es el máximo común divisor de todos los números. Quizás el lector desee
modificar la implementación anterior para usar el operador % y comprobar que
esta modificación es mucho más eficaz cuando, por ejemplo, se busca el má-
ximo común divisor de un número muy grande y un número muy pequeño.
Este algoritmo siempre utiliza un número de pasos relativamente pequeño.
Tipos de datos
La mayor parte de los algoritmos presentados en este libro funcionan con tipos
de datos simples: números reales, enteros, caracteres o cadenas de caracteres.
Una de las características más importantes de C++ es su capacidad para cons-
truir tipos de datos más complejos a partir de estos «ladrillos» elementales.Más
adelante se verán muchos ejemplos de esto. Sin embargo, se procurará evitar el
uso excesivo de estas facilidades,para no complicar los ejemplos y centrarse en
la dinámica de los algoritmos más que en las propiedades de los datos. Se pro-
curará hacerlo sin que esto lleve a una pérdida de generalidad: desde luego, las
grande5posibilidadesque tiene C++ para realizar construcciones complejas ha-
cen que sea fácil transformar uca «maqueta» de algoritmo que opera sobre ti-
pos de datos sencillos en una versión de «tamaño natural» que realiza una ope-
ración critica de una cl ase de C++. Cuando los métodos básicos se expliquen
mejor en términos de tipos definidos por el usuario, así se hara. Por ejemplo,
los métodos geométricos de los Capítulos 24-28 están basados en modelos para
puntos, líneas, polígonos, etc.; y los métodos de colas de prioridad del Capítulo
I 1 y los métodos de bíxqueda de los Capítulos 14-18 se expresan mejor como
conjuntos de operaciones asociados a estructuras de datos particulares, utili-
zando el constructor cl ase de C++. Se volverá a este punto en el Capítulo 3, y
se verán otros muchos ejemplos a lo largo del libro.
Algunas veces, la conveniente representación de datos a bajo nivel es la clave
del rendimiento. Teóricamente, la forma de realizar un programa no debería
depender de cómo se representan los números o de cómo se codifican los carac-
teres (por escoger dos ejemplos), pero el precio que hay que pagar para conse-
guir este ideal es a veces demasiado alto. Ante este hecho, en el pasado los pro-
gramadores optaron por la drástica postura de irse al Ienguaje ensamblador o a1
lenguaje máquina, donde hay pocas limitaciones para la representación. Afor-
c++(Y C) 13
tunadamente, los lenguajes modernos de alto nivel ofrecen mecanismos para
crear representaciones razonables sin llegar a tales extremos. Esto permite jus-
tificar algunos algoritmos clásicos importantes. Por supuesto, tales mecanismos
dependen de cada máquina, y no se estudian aquí con mucho detalle, excepto
para indicar cuándo son apropiados. Este punto se tratará más detalladamente
en los Capítulos 10, 17y 22, al examinar los algoritmos basados en representa-
ciones binarias de datos.
También se tratará de evitar el uso de representaciones que dependen de la
máquina, al considerar algoritmos que operan sobre caracteres y cadenas de ca-
racteres. Con frecuencia, se simplifican los ejemplos para trabajar únicamente
con las letras mayúsculas de la A a la Z, utilizando un sencillo código en el que
la i-ésima letra del alfabeto está representada por el entero i. La representación
de caracteres y de cadenas de caracteres es una parte tan fundamental de la in-
terfaz entre el programador, el lenguaje de programación y la máquina, que se
debería estar seguro de que se entiende totalmente antes de implementar algo-
ritmos que procesen tales datos -en este libro se dan métodos basados en re-
presentaciones sencillas que, por lo tanto, son fácilesde adaptar-.
Se utilizarán números enteros (int)siempre que sea posible. Los programas
que utilizan números de coma flotante (f1oat) pertenecen al dominio del
análisis numérico. Por lo regular, su utilización va íntimamente ligada a las pro-
piedades matemáticas de la representación. Se volverá sobre este punto en los
Capítulos 37, 38, 39,41 y 43, donde se presentan algunosalgoritmos numéricos
fundamentales. Mientras tanto, se limitan los ejemplos a la utilización de los
números enteros, incluso cuando los números reales puedan parecer más apro-
piados, para evitar la ineficaciae inexactitud que suelen asociarse a las represen-
taciones mediante números de coma flotante.
Entrada/Salida
Otro dominio en el que la dependencia de la máquina es importante es la inter-
acción entre el programa y sus datos, que normalmente se designa como en-
truda/salidu. En los sistemas operativos este término se refiere al intercambio
de datos entre la computadora y los soportes físicos tales como un disco o una
cinta magnética; se hablará sobre tales materias únicamente en los Capítulos 13
y 18. La mayor parte de las veces se busca un medio sistemático para obtener
datos y enviar los resultados a las implementaciones de algoritmos, tales como
la función mcd anterior.
Cuando se necesite «leen>y «escribin>,se utilizarán las características nor-
males de C++,invocando lo menos posible algunos formatos extra disponibles.
t En realidad, al ejecutar un programa se debe usar el punto decimal en lugar de la coma para evitar errores
durante la ejecución. Asimismo, al escribir cifras,se recomienda no usar puntos para separar los millares o millo-
nes.(N.del E.)
14 ALGORITMOS EN C++
Nuevamente, el objetivo es mantener programas concisos, manejables y fácil-
mente traducibles; una razón por la que el lector podría desear modificar los
programas es para mejorar su interfaz con el programador. Pocos, si existe al-
guno, de los entomos de programación modernos como C++ toman c i n o cout
como referencia al medio externo; en su lugar se refieren a ((dispositivoslógi-
cos» o a «flujos» de datos. Así, la salida de un programa puede usarse como la
entrada de otro, sin ninguna lectura o escritura física. La tendencia a hacer flu-
jos de entrada/salida en las implementaciones de este libro las hace más útiles
en tales entomos.
En realidad, en muchos entomos modernos de programación son apropia-
das y fáciles de utilizar las representaciones gráficas como las utilizadas en las
figuras de este libro. Como se precisa en el epílogo, estas figuras realmente se
generan por los propios programas, lo que ha llevado a una mejora sustancial
de la interfaz.
Muchos de los métodos que se presentan son apropiados para utilizarlos
dentro de grandes sistemas de aplicaciones, de manera que la mejor forma de
suministrar datos es mediante el uso de parámetros. Éste es el método utilizado
por el procedimiento mcd visto anteriormente. También algunas de las imple-
mentaciones de capítulos posteriores del libro usarán programas de capítulos
anteriores. De nuevo, para evitar desviarla atención de los algoritmos en sí mis-
mos, se resistirá a la tentación de «empaquetan>las implementaciones para uti-
lizarlas como programas de utilidad general. Seguramente, muchas de las im-
plementaciones que se estudiarán son bastante apropiadas como punto de
partida para tales utilidades, pero se planteará un gran número de preguntas
acerca de la dependencia del sistema o de la máquina, cuyas respuestas se silen-
ciarán aquí, pero que pueden obtenerse de forma satisfactoria durante el de-
sarrollo de tales paquetes.
Algunas veces, se escribirán programas para operar con datos «(globales»,para
evitar una parametrización excesiva.Por ejemplo, la función mcd podría operar
directamente con x e y, sin necesidad de recurrir a los parámetros u y v. Esto
no estájustificado en este caso porque mcd es una función bien definida en tér-
minos de sus dos entradas, pero cuando varios algoritmos operan sobrelos mis-
mos datos, o cuando se pasa una gran cantidad de datos, se podrían utilizar va-
riables globales para reducir la expresión algorítmica y para evitar mover datos
innecesariamente. Por otra parte, C++ es un lenguaje ideal para eencapsulam
los algoritmos y sus estructuras de datos asociadas para hacer explícitas las in-
terfaces, y se tenderá a usar datos globales muchas menos veces en las imple-
mentaciones en C++ que en los correspondientes programas en C o Pascal.
Comentarios finales
En The C++Programming Language y en los capítulos que siguen se muestran
otros muchos ejemplos parecidos al programa anterior. Se invita al lector a ho-
c++(Y C) 15
jear el manual, implementar y probar algunos programas sencillos y posterior-
mente leer el manual con detenimiento para familiarizarse con las caractensti-
cas básicas de C++.
Los programas en C++ que se muestran en este libro deben servir como des-
cripciones precisas de los algoritmos, como ejemplos de implementaciones
completas y como punto de partida para la realización de programas prácticos.
Como se ha mencionado anteriormente, los lectores experimentados en otros
lenguajesno deben tener dificultadespara leerlos algoritmospresentadosen C++
e implementarlos en otros lenguajes. Por ejemplo, la siguiente es una imple-
mentación en Pascal del algoritmo de Euclides:
~~ ~~
program euclides(input, output);
var x, y: integer,
function mcd(u, v: integer):integer;
var t: integer,
begin
repeat
if u<v then
begin t:=u; u:=v; v:=t end;
u:=u-v
until u=O;
mcd:=v
end;
begin
while not eof do
begin
readln(x, y);
if (x> O) and (y> O) then writeln(x, y, mcd(x, y))
end;
end.
En este algoritmo hay una correspondencia prácticamente exacta entre las sen-
tencias en C++ y Pascal, como era la intención; sin embargo, no es difícil de-
sarrollar implementaciones más precisas en ambos lenguajes. En este caso la
realización en C++ se diferencia de la realizada en C únicamente en la entrada/
salida: el objetivo será mantener esta compatibilidad siempre que sea natural
hacerlo, aunque, por supuesto, la mayoría de los programas de este libro utili-
zan instrucciones C++ que no están disponibles en C.
Ejercicios
1. Implementar la versión clásica del algoritmo de Euclides presentado en el
texto.
16 ALGORITMOS EN C++
2. Comprobar qué valores de u % v calcula el sistema en C++ cuando u y v
no son siempre positivos.
3. Implementar un procedimiento para hacer irreducible una fracción dada,
utilizando una struct fracción { i n t numerador; i n t denominador;
4. Escribir una función in t converti r () que lea un número decimal cifra
a cifra, termine cuando encuentre un espacio en blanco y devuelva el valor
del número.
5. Escribir una función b inario (in t x) que presente el equivalente binario
de un número.
6. Obtener los valores que toman u y v cuando se invoca la función mcd con
la llamada inicial mcd( 12345, 56789).
7. ¿Cuántas instrucciones de C++ se ejecutan exactamente en la llamada del
ejercicio anterior?
8. Escribir un programa que calcule el máximo común divisor de tres enteros
u, v y w.
9. Encontrar el mayor par de números representables como enteros en el sis-
tema C++, cuyo máximo común divisor sea 1.
1.
10. Implementar el algoritmo de Euclides en FORTRAN y BASIC.
3
Estructuras
elementales
de datos
En este capítulo se presentan los métodos básicos de organizar los datos para
procesarlos mediante programas de computadora. En muchas aplicaciones la
decisión más importante en la implementación es elegir la estructura de datos
adecuada: una vez realizada la elección, lo único que se necesitan son algorit-
mos simples. Para los mismos datos, algunasestructuras requieren más o menos
espacio que otras; para las mismas operaciones con datos, algunas estructuras
requieren un número distinto de algoritmos, unos más eficaces que otros. Esto
ocurrirá con frecuencia a lo largo de este libro, porque la elección del algoritmo
y de la estructura de datos está estrechamente relacionada y continuamente se
buscan formas de ahorrar tiempo o espacio mediante una elección adecuada.
Una estructura de datos no es un objeto pasivo: es preciso considerar tam-
bién las operaciones que se ejecutan sobre ella (y los algoritmos empleados en
estas operaciones).Este concepto se formaliza en la noción de tipo de datos abs-
tracto, que se analiza al final del capítulo. Pero como el mayor interés está en
las implementaciones concretas, se fijará la atención en las manipulaciones y
representaciones específicas.
Se trabajará con arrays, listas enlazadas, pilas, colas y otras variantes senci-
llas. Éstas son estructuras de datos clásicascon un gran número de aplicaciones:
junto con los árboles (ver Capítulo 4), forman prácticamente la base de todos
los algoritmos que se consideran en este libro. En este capítulo se verán las re-
presentaciones básicas y los métodos de manipulación de estas estructuras, se
trabajará con algunosejemplos concretos de utilización y se presentarán puntos
específicos, como la administración del almacenamiento.
17
i e ALGORITMOS EN C++
Arrays
Tal vez el array sea la estructura de datos más importante, que se define como
una primitiva tanto en C++ como en otros muchos lenguajesde programación.
Un array es un número fijo de elementos de datos que se almacenan de forma
contigua y a los que se accede por un índice. Se hace referencia al i-ésimo ele-
mento de un array a como a[i1. Es responsabilidad del programador almace-
nar en una posición a [i] de un array un valor coherente antes de llamarlo; des-
cuidar esto es uno de los errores más comunes de la programación.
Un sencillo ejemplo de la utilización de un array es el siguiente programa,
que imprime todos los números primos menores de 1.OOO. El método utilizado,
que data del siglo 111 a.c., se denomina la «criba de Eratóstenesx
const i n t N = 1000;
main()
i n t i,j, a[N+l];
f o r ( a [ l ] = O , i = 2; i <= N; i++) a [ i ] = 1;
f o r ( i = 2; i <= N/2; i++)
f o r ( j = 2; j <= N / i ; j++)
a [ i * j ] = O;
{
f o r ( i = 1; i <= N; i++)
cout << 'n';
i f ( a [ i ] ) cout << i << I ';
1
Este programa emplea un array constituido por el tipo más sencillo de elemen-
tos, los valores booleanos (O- 1). El objetivo del mismo es poner en a[i] el valor
1si ies un número primo, o poner un O si no lo es. Para todo i,se pone a O el
elemento del array que corresponde a cualquier múltiplo de i,ya que cualquier
número que sea múltiplo de cualquier otro número no puede ser primo. A con-
tinuación se recorre el array una vez más, imprimiendo los números primos.
Primero se «inicializa»el array para indicar los números que se sabe que no son
primos: el algoritmo pone a O los elementos del array que corresponden a índi-
ces conocidos como no primos. Se puede mejorar la eficacia del programa,
comprobando a [i] antes del f o r del bucle que involucra a j, ya que si ino es
primo, los elementos del array que corresponden a todos sus múltiplos deben
haberse marcado ya. Podría hacerse un empleo más eficazdel espacio mediante
el uso explícito de un array de bits y no de enteros.
La criba de Eratóstenes es uno de los algoritmos típicos que aprovechan la
posibilidad de acceder directamente a cualquier elemento de un array. El algo-
ritmo accede a los elementos del array secuencialmente, uno detrás de otro. En
muchas aplicaciones, es importante el orden secuenciai;en otras se utiliza por-
ESTRUCTURASDE DATOS ELEMENTALES 19
que es tan bueno como cualquier otro. Pero la característica principal de los
arrays es que si se conoce el índice, se puede acceder a cualquier elemento en
un tiempo constante.
El tamaño de un array debe conocerse de antemano: para ejecutar el pro-
grama anterior para un valor diferente de N, es necesario cambiar la constante
N y después volver a compilar y a ejecutar. En algunos entomos de programa-
ción, es posible declarar el tamaño de un array durante la ejecución (de modo
que se podría conseguir, por ejemplo, que un usuario introduzca el valor de N
para obtener los números primos menores que N sin el desperdicio de memoria
provocado al definirun tamaño del array tan grande como el valor máximo que
se permita teclear al usuario). En C++ es posible lograr este efecto mediante la
apropiada utilización del mecanismo de asignación de la memoria, pero sigue
siendo una propiedad fundamental de los arrays que sus tamaños sean fijos y se
deban conocer antes de utilizarlos.
Los arrays son estructuras de datos fundamentales que tienen una corres-
pondencia directa con los sistemas de administración de memoria, en práctica-
mente todas las computadoras. Para poder recuperar el contenido de una pala-
bra de la memoria es preciso proporcionar una dirección en lenguaje máquina.
Así se podría representar la memoria total de la computadora como si fuera un
array, en el que las direcciones de memoria correspondieran a los índices del
mismo. La mayor parte de los procesadores de lenguaje, al traducir programas
a lenguaje máquina, construyen arrays bastante eficaces que permiten acceder
directamente a la memoria.
Otra forma normal de estructurar la información consisteen utilizar una ta-
bla de números organizada en filas y columnas. Por ejemplo, una tabla de las
notas de los estudiantes de un curso podría tener una fila para cada estudiante,
y una columna para cada asignatura. En una computadora, esta tabla se repre-
sentaría como un array bidimensional con dos índices, uno para las filas y otro
para las columnas. Hay varios algoritmos que son inmediatos para manejar es-
tas estructuras: por ejemplo, para calcular la nota media de una asignatura, se
suman todos los elementos de una columna y se dividen por el número de filas;
para calcular la nota media del curso de un estudiante en particular, se suman
todos los elementos de una fila y se dividen por el número de columnas. Los
arrays bidimensionales se utilizan generalmente en aplicacionesde este tipo. En
una computadora se utilizan a menudo más de dos dimensiones: un profesor
podría utilizar un tercer índice para mantener en tablas las notas de los estu-
diantes de una sene de años.
Los arrays también se corresponden directamente con los vectores, término
matemático utilizado para las listas indexadas de objetos. Análogamente, los
arrays bidimensionales se corresponden con las matrices. Los algoritmos para
procesar estos objetos matemáticos se estudian en los Capítulos 36 y 37.
20 ALGORITMOS EN C++
Listas enlazadas
La segunda estructura de datos elementalesa considerares la lista enlazada, que
se define como una primitiva en algunos lenguajes de programación (concreta-
mente en Lisp) pero no en C++. Sin embargo, C++ proporciona operaciones
básicas que facilitan el uso de listas enlazadas.
La ventaja fundamental de las listas enlazadas sobre los arrays es que su ta-
maño puede aumentar y disminuir a lo largo de su vida. En particular, no se
necesita conocer de antemano su tamaño máximo. En aplicaciones prácticas,
esto hace posible que frecuentemente se tengan vanas estructuras de datos que
comparten el mismo espacio, sin tener que prestar en ningún momento una
atención particular a su tamaño relativo.
Una segunda ventaja de las listas enlazadas es que proporcionan flexibili-
dad, lo que permite que los elementos se reordenen eficazmente. Esta flexibili-
dad se gana en detrimento de la rapidez de acceso a cualquier elemento de la
lista. Esto se verá más adelante, después de que se hayan examinado algunas de
las propiedades básicas de las listas enlazadas y algunas de las operaciones fun-
damentales que se llevan a cabo con ellas.
Una lista enlazada es un conjunto de elementos organizados secuencial-
mente, igual que un array. Pero en un array la organización secuencial se pro-
porciona implícitamente (por la posición en el array), mientras que en una lista
enlazada se utiliza un orden explícito en el que cada elemento es parte de un
«nodo» que contiene además un «enlace» con el nodo siguiente. La Figura 3.1
muestra una lista enlazada, con los elementos representados por letras, los no-
dos por círculos y los enlaces por líneas que conectan los nodos. Más adelante
se verá, de forma detallada, cómo se representan las listas en la computadora;
por ahora se hablará simplemente de nodos y enlaces.
Incluso la sencilla representación de la Figura 3.1 pone en evidencia dos de-
talles que deben considerarse. Primero, todo nodo tiene un enlace, por lo que
el enlace del último nodo de la lista debe designar a algún nodo «siguiente».
Con este fin se adopta el convenio de tener un nodo «ficticio», que se denomina
Z: el último nodo de la lista apuntará a Z y Z se apuntará a sí mismo. En se-
gundo lugar, también por convenio, se tendrá un nodo ficticio en el otro ex-
tremo de la lista, que se denomina cabeza, y que apuntará al primer nodo de
la lista. El principal objetivo de los nodos ficticios es que resulte más cómodo
hacer ciertas manipulaciones con los enlaces, especialmente con aquellos que
están relacionados con el primer y último nodo de la lista. Más adelante, se ve-
Figura 3.1 Una lista enlazada.
ESTRUCTURASDE DATOS ELEMENTALES 21
cabeza
Figura 3.2 Una lista enlazada con sus nodos ficticios.
rán más normas que se toman por convenio. La Figura 3.2 muestra la estruc-
tura de la lista con estos nodos ficticios.
Esta representación explícita de la ordenación permite que ciertas operacio-
nes se ejecuten mucho más eficazmente de lo que sena posible con arrays. Por
ejemplo, suponiendoque se quiere mover la A desde el final de la lista ai pnn-
cipio, en un array se tendría que mover cada elemento para hacer sitio en el
comienzo para el nuevo elemento; en una lista enlazada, simplemente se cam-
bian tres enlaces, como se muestra en la Figura 3.3. Las dos versiones que apa-
recen en la Figura 3.3 son equivalentes; simplemente están dibujadas de ma-
nera diferente. Se hace que el nodo que contiene a A apunte a L, que el nodo
que contiene a T apunte a z, y que cabeza apunte a A. Aun cuando la lista
fuese muy larga, se podría hacer este cambioestructural modificando solamente
tres enlaces.
La operación siguiente, que es antinatural e inconveniente en un array, es
todavía más importante. Se trata de «insertan>un elemento en una lista enla-
zada (lo que hace aumentar su longitud en una unidad). La Figura 3.4 muestra
cómo insertar X en la lista del ejemplo, poniendo X en un nodo que apunte a
T, y a continuación haciendo que el nodo que contiene a S apunte al nuevo
nodo. En esta operación sólo se necesita cambiar dos enlaces, cualquiera que
sea la longitud de la lista.
De igual forma, se puede hablar de «eliminan>un elemento de una lista en-
lazada (lo que hace disminuir su longitud en una unidad). Por ejemplo, la ter-
cera lista de la Figura 3.4 muestra cómo eliminar X de la segunda lista haciendo
simplementeque el nodo que contiene a S apunte a T, saltándose a X. Ahora,
cabeza
Figura 3.3 Reordenaciónde una lista enlazada.
22 ALGORITMOS EN C++
cabeza
Figura 3.4. Insercióny borradoen una lista completa.
el nodo que contiene a X todavía existe (dehecho, todavía apunta a T), y quizás
debería eliminarse de alguna manera; pero el hecho es que X no forma parte de
la lista, y no puede accederse a él mediante enlaces desde cabeza. Se volverá
sobre este punto más adelante.
Por otra parte, hay otras operaciones para las que las listas enlazadas no son
apropiadas. La más obvia de estas operaciones es «encontrar el k-ésimo ele-
mento» (encontrar un elemento dado su índice): en un array esto se hace fácil-
mente accediendo a a [k], pero en una lista hay que moverse a lo largo de k
enlaces. Otra operación que es antinatural en las listas enlazadas es «encontrar
el elemento anterior a uno dadon. Si el único dato de la lista del ejemplo es el
enlace a T, entonces la única forma de poder encontrar el enlace a T es comen-
zar en cabeza y recorrer la lista para encontrar el nodo que apunta a A. En
realidad, esta operación es necesaria si se desea eliminar un nodo concreto de
una lista enlazada, ya que ¿de qué otra manera se encontrará el nodo cuyo en-
lace debe modificarse?En muchas aplicaciones se puede rodear este problema
transformando la operación «eliminan>en la «eliminar el nodo siguiente». En
la inserción se puede evitar un problema similar haciendo que la operación sea
«insertar un elemento dado después de un nodo determinado)) de la lista.
Para ilustrar cómo podría implementarse en C++ una lista enlazada básica,
se comenzará especificando con precisión el formato de los nodos de la lista y
construyendo una lista vacía, como se indica a continuación:
struct nodo
struct nodo *cabeza, *z;
{ int clave; struct nodo *siguiente; };
ESTRUCTURASDE DATOS ELEMENTALES 23
cabeza = new nodo; z = new nodo;
cabeza->siguiente = z; z->siguiente = z;
La declaración struct indica que las listas están compuestas por nodos y que
cada nodo contiene un número entero y un puntero al sigui ente nodo de la
lista. La variable cl ave es un entero, únicamentepara simplificar el programa;
pero podría ser de cualquiertipo -el puntero sigui ente esla clave de la lista-.
El asterisco indica que las variables cabeza y z se declaran como punteros a los
nodos. En realidad éstos se crean únicamente cuando se llama a la función in-
tegrada new. Esto oculta un complejo mecanismo que tiene como finalidad ali-
viar al programador de la carga de asignar «memoria» para los nodos según va
creciendo la lista. Más adelante se estudiará este mecanismo con mayor detalle.
La notación «flecha» (un signo menos seguido de un signo mayor) se utiliza en
C++ para seguir a los punteros a través de las estructuras. Se escribe una refe-
rencia a un enlace seguida por este símbolo para indicar una referencia al nodo
al que apunta ese enlace. Así, el código anterior crea dos nuevos nodos referen-
ciados por cabeza y z y pone a ambos apuntando a z.
Para insertar en una lista enlazada detrás de un nodo dato t un nuevo nodo
con el valor de la clave v, se crea el nodo (x = new nodo) y se pone en el valor
clave (x- >clavbe = v), después se copia en el enlace de t(x- >siguiente =
t - >sigui ente) y se hace que el enlace de t apunte al nuevo nodo (t- >si -
guiente = X).
Para extraer de una lista enlazada el nodo siguiente a un nodo dado t, se
obtiene un puntero a ese nodo (x = t - >s i guiente), se copia el puntero en t
para sacarlo de la lista (t- >si guiente = x- >si guiente) y devolverlo al sis-
tema de asignación de memoria empleando el procedimiento integrado de-
1ete, a menos que la lista estuviese vacía (if (x!=z) delete x).
Se invita al lector a que compare estas implementaciones en C++ con las
presentadas en la Figura 3.4. Es interesante destacar que el nodo cabeza evita
el tener que hacer una comprobación especial en la inserción de un elemento al
principio de la lista, y el nodo z proporciona una forma apropiada de compro-
bar la eliminación de un elementoen una lista vacía. Severá otra utilización de
z en el Capítulo 14.
En capítulos posteriores se verán muchos ejemplos de aplicaciones de este
tipo y otras operaciones básicas sobre listas enlazadas. Como las operaciones sólo
se componen de unas cuentas instrucciones, con frecuencia se manipularán las
listas directamente en lugar de hacerlo mediantelos tipos de datos. Como ejem-
plo, se considera el siguiente programa para resolver el denominado«problema
de Josefa» en la misma línea de la criba de Eratóstenes. Se supone que N per-
sonas han decidido cometer un suicidio masivo, disponiéndose en un círculo y
matando a la M-ésima persona alrededor del círculo, cerrando las filasa medida
que cada persona va abandonando el círculo. El problema consiste en averiguar
qué persona será la última en morir (jaunque quizás al final cambie de idea!),
o, más generalmente, encontrar el orden en que mueren las personas. Por ejem-
24 ALGORITMOS EN C++
plo, si N = 9 y M = 5, las personas morirán en el orden 5 1 7 4 3 6 9 2 8. El
siguiente programa lee N y M y obtiene este orden:
struct nodo
main()
{ int clave; struct nodo *siguiente; };
int i,N, M;
struct nodo *t, *x;
{
cin
t =
for
{
1
>> N >> M;
new nodo; t->clave = 1; x = t;
(i = 2; i <= N; i++)
t->siguiente = new nodo;
t = t->siguiente; t->clave = i;
t->siguiente = x;
while (t != t->siguiente)
for (i = 1; i < M; i++) t = t->siguiente;
cout << t->siguiente; t->siguiente = x->siguiente;
delete x;
1
cout << t->clave << 'n';
1
El programa utiliza una lista enlazada «circulan>para simular directamente la
secuencia de ejecuciones. Primero, se construye la lista para las clavesdesde 1 a
N de forma que la variable x ocupe el principio de la lista en el momento de su
creación, después el puntero del último nodo de la lista se pone en X. El pro-
grama continúa recorriendo la lista, contando hasta el elemento M - 1 y eli-
minando el siguiente, hasta que se deje uno sólo (que entonces se apunta a si
mismo). Se observa que se llama a delete para suprimir elementos, lo que co-
rresponde a una ejecución: éste es el operador opuesto a new, como se men-
cionó anteriormente.
Las listas circularesse emplean a veces como una alternativa a la utilización
de los nodos ficticios cabeza o z, con un nodo ficticio para marcar el principio
(y el final) de la lista y como ayuda en el caso de las listas vacías.
La operación «encontrar el elemento anterior a uno dado» se puede realizar
mediante la utilización de una lista doblemente enlazada, en la que se mantie-
nen dos enlaces para cada nodo, uno para el elemento anterior, y otro para el
elemento posterior. El coste de contar con esta capacidad extra es duplicar el
número de enlaces manipulados por cada operación básica; de manera que no
ESTRUCTURASDE DATOS ELEMENTALES 25
es normal que se utilicen, a menos que se requiera específicamente. Por otro
lado, como se mencionó antes, si se va a eliminar un nodo y sólo se dispone de
un enlace al mismo (que quizás también es parte de alguna otra estructura de
datos), pueden utilizarse enlaces dobles.
Asignación de memoria
Como se mostró anteriormente, los punteros de C++ proporcionan una manera
adecuada de implementar listas; pero existen otras alternativas. En esta sección
se verá cómo se utilizan los arrays para implementar listas enlazadas así como
la relación entre esta técnica y la representación real de las listas en un pro-
grama en C++. Como ya se mencionó, los arrays son una representación bas-
tante directa de la memoria de la computadora, por lo que el análisis de cómo
se implementa una estructura de datos de este tipo proporcionará algún cono-
cimiento sobre cómo podría representarse esta estructura a bajo nivel de la
computadora. En particular, interesa ver cómo podrían representarse al mismo
tiempo varias listas.
Para representar directamente listas enlazadas mediante arrays, se utilizan
índices en lugar de enlaces. Una manera de proceder sería definir un array de
registros parecidos a los anteriores, pero utilizando enteros (i nt) para los índi-
ces del array, en lugar de punteros al campo sigui ente. Una alternativa, que
suele resultar más conveniente, es utilizar «arrays paralelos»: se guardan los
elementos en un array clave y los enlaces en otro array siguiente. Así
clave[ siguienteLcabeza)] se refiere a la información asociada con el pri-
mer elemento de la lista, c1ave [siguiente [s i gui ente [cabeza]]]con el se-
gundo, y así sucesivamente. La ventaja de utilizar arrays paralelos es que la es-
tructura puede construirse «sobre» los datos: el array cl ave contiene datos y
sólo datos; toda la estructura está en el array paralelo siguiente. Por ejemplo,
se puede construir otra lista empleando el mismo array de datos y un paralelo
de «enlace» diferente, o se pueden añadir más datos con más arrays paralelos.
La siguientelínea de código implementa la operación ((insertardespués de»
en una lista enlazada representada por los arrays paralelos cl ave y sigui ente
clave[x] = v; siguiente[x] = siguiente[t]; siguiente[t] = x++;
El «puntero» x sigue la pista de la siguiente posición que está sin ocupar en el
array, de manera que no se necesitallamar a la función de asignaciónde memo-
ria new. Para extraer un nodo se escribe siguiente[t] = si-
guiente[sigui ente[t] ], pero se pierde la posición del array «a la que apunta»
sigui ente [t1. Más adelante, se verá cómo podría recuperarse este espacio
perdido.
La Figura 3.5 muestra cómo se podría representar la lista del ejemplo me-
diante arrays paralelosy cómo se relaciona esta representación con la represen-
26 ALGORITMOS EN C++
tación gráfica que se ha estado utilizando. Los arrays cl ave y sigui ente se
muestran en el primer diagrama de la izquierda, como aparecerían si se inser-
tara T I L S A en una lista inicialmente vacía, con T, I y L insertados después
de cabeza, S después de I y A después de T. La posición O es cabeza y la po-
sición l es z (se ponen al inicializar la lista) de manera que como si -
guiente[0] es 4, el primer elemento de la lista es clave[4] (L); como s i -
guiente[4] es 3, el segundo elemento de la lista es clave[3] (I), etc. En el
segundo diagrama por la izquierda, los índices para el array s i gui ente se
reemplazan por líneas y en lugar de poner un 4 en si guiente[O], se dibuja
una línea desde el nodo O hasta el nodo 4, etc. En el tercer diagrama se desen-
redan los enlaces para ordenar los elementos de la lista, uno a continuación de
otro. Y para concluir, a la derecha aparece la lista en la representación gráfica
habitual.
Lo esencial del caso es considerar cómo podnan implementarse los proce-
dimientos integrados new y del ete. Se supone que el único espacio disponible
para nodos y enlaces son los arrays anteriores; esta presunción lleva a la situa-
ción en la que se encuentra el sistema cuando tiene que permitir que se au-
mente o disminuya el tamaño de una estructura de datos a partir de una estruc-
tura fija (la memoria). Por ejemplo, suponiendo que el nodo que contiene a L
se debe eliminar del ejemplo de la Figura 3.5, es fácil reordenar los enlaces de
manera que el nodo no esté mucho tiempo enganchado a la lista, pero ¿qué ha-
cer con el espacio ocupado por ese nodo?, y jcómo encontrar espacio para un
nodo cuando se llame a la función new y se necesite más espacio?
Reflexionando se ve que la solución está clara: jes suficientecon utilizar otra
lista enlazadapara seguirla pista del espaciolibre!, denominándola como la dista
libre)). Entonces, al eliminar (delete) un nodo de la primera lista, se inserta
en la lista libre y cuando se necesite un nodo new, se obtiene eliminándolo de la
Cabeza
Figura 3.5 Implementaciónde una lista enlazada mediante un array.
ESTRUCTURAS DE DATOS ELEMENTALES 27
hd
Figura 3.6 Dos listas compartiendo el mismo espacio.
lista libre. Este mecanismo permite tener varias listas diferentes ocupando el
mismo array.
En la Figura 3.6 se muestra un ejemplo sencillo con dos listas (pero sin lista
libre). Hay dos nodos cabeza de lista hdl * = O y hd2 = 6, pero ambas listas pue-
den compartir el mismo z. Una implementación típica de C++ que utilice la
construcción c1ass tendría un nodo cabeza y un nodo cola asociado a cada lista.
Ahora, siguiente[O] es 4, y por tanto el primer elemento de la primera lista
es cl ave[4] (N); como siguiente[6] es 7, el primer elemento de la segunda
lista es cl ave[71 (D),etc. Los otros diagramas de la Figura 3.6 muestran el re-
sultado de remplazar los valores si gui ente por líneas, desenredando los nodos
y cambiando la representación gráfica simple, como en la Figura 3.5. Esta misma
técnica podría utilizarse para mantener varias listas en el mismo array, una de
las cuales debería ser una lista libre, como se describió anteriormente.
Cuando el sistema dispone de un gestor de memoria, como ocurre en C++,
no hay razón para suplantarlo de esta manera. La descripciónanterior se realiza
para indicar cómo hace el sistema la gestión de la memoria. (Si se trabaja con
un sistema que no asigna memoria, la descripción anterior proporciona un buen
punto de partida para una implementación.) En la práctica, el problema que se
acaba de ver es mucho más complejo, ya que no todos los nodos son necesana-
mente del mismo tamaño. Además algunos sistemasrelevan al usuario de la ne-
cesidad de eliminar explícitamente los nodos, mediante el uso de algoritmos de
arecolección de basura», que eliminan de forma automática los nodos que no
estén referenciadospor ningún enlace. Para resolver estas dos situaciones se ha
* Abreviaturade head1 (cabezal). (N.del T.)
28 ALGORITMOS EN C++
Figura 3.7 Características dinámicas de una pila.
desarrollado un buen número de complicados algoritmos de asignación de
memoria.
Pilas
Hasta aquí se ha considerado la forma de estructurar los datos con el fin de in-
sertar, eliminar o acceder arbitrariamente a los distintos elementos. En realidad,
resulta que para muchas aplicacioneses suficiente con considerar varias restric-
ciones (másbien fuertes)sobre la forma de acceder a la estructura de datos. Ta-
les restricciones son beneficiosas por dos motivos: primero, porque pueden ali-
viar la necesidad que tiene el programa de utilizar la estructura de datos para
analizar ciertos detalles (por ejemplo, memorizando los enlaces o los índices de
los elementos); segundo, porque permiten implementaciones más simples y fle-
xibles, tendiendo a limitar el número de operaciones.
La estructura de datos de acceso restrictivo más importante es la pila, en la
que sólo existen dos operaciones básicas: se puede meter un elemento en la pila
(insertarlo al principio) y se puede sacar un elemento (eliminarlo del princi-
pio). Una pila funciona de forma parecida a la bandeja de «entradas» de un eje-
cutivo muy ocupado: el trabajo se amontona en la pila y cuando el ejecutivo
está preparado para hacer alguna tarea, coge la de la parte más alta del montón.
Pudiera ser que algún documento se quedara bloqueado en el fondo de la pila
durante algún tiempo, pero un buen ejecutivo se las arregla para conseguir va-
ciar la pila periódicamente. A veces los programas de computadora se organi-
zan naturalmente de esta manera, posponiendo algunas tareas mientras se ha-
cen otras, y por ello las pilas representan una estructura de datos fundaméntal
para muchos algontmos.
La Figura 3.7 muestra un ejemplo de una pila desarrollada a través de una
serie de operaciones meter y sacar, representadas por la secuencia:
E *JE * M *P *L *OD* E ***P *ILA ***
Cada letra de esta lista significa «meten>(la letra) y cada asterisco significa «sa-
cam.
C++ proporciona una forma excelente para definir y utilizar estructuras de
datos fundamentales, tales como las pilas. La siguiente implementación de las
operaciones básicas sobre las pilas es el prototipo de muchas implementaciones
ESTRUCTURASDE DATOS ELEMENTALES 29
que se verán más adelante en el libro. La pila está representada por un array
p i 1a y un puntero p que apunta hacia lo más alto de la pila -las funciones
meter, sacar y vacía son implementaciones directas de las operaciones bási-
cas de la pila-. Este código no comprueba si el usuario trata de meter un ele-
mento en una pila llena, o de sacar un elemento de una vacía; aunque la fun-
ción vacía es una forma de comprobarlo posteriormente.
class P i l a
p r i v a t e :
tipoElemento *pila;
i n t p;
P i 1a( i n t max=100)
{ p i l a = new tipoElemento[max]; p = O; }
- P i 1 a()
{ delete p i l a ; }
i n l i n e void meter(tipoE1emento v)
{ pila[p++] = v; }
i n l i n e tipoElemento sacar()
{ r e t u r n p i l a [ - - p ] ; }
i n l i n e i n t vacia()
{ r e t u r n !p; }
publ ic :
1;
El tipo de elementos contenido en la pila se deja sin especificarcon el nombre
t i poElemento, para tener alguna flexibilidad.Por ejemplo, la sentencia type-
def in t t ipoElemento ; podría emplearse para tener una pila formada por nú-
meros enteros. Al final de este capítulo se presentará una alternativa a esto en
C++.
El código anterior muestra varias construcciones en C++. La implementa-
ción es una c l ass, una de las clases del «tipo definido por el usuario» de C++.
Con esta definición, una P i 1a tiene la misma condición que i n t , char o cual-
quiera de los otros tipos incorporados. La implementación se divide en dos par-
tes, una parte p r i v a t e que especifica cómo están organizados los datos y una
parte publ i c que define las operaciones permitidas sobre los datos. Los progra-
mas que utilizan pilas necesitan referenciar solamente la parte pública sin preo-
cuparse de cómo se implementan las pilas. La función P i 1a es un construe-
tom que crea e inicializa la pila y la funcijn - es un ««destructon>
que elimina
la pila cuando ya no se le necesita. La palabra clave in l ine indica que se reem-
plaza la llamada a la función por la función misma, evitando así la sobrecarga
de llamadas, que es muy apropiada para funciones cortas como éstas.
En los capítulos siguientes se verán muchas aplicacionesde las pilas: un buen
30 ALGORITMOS EN C++
ejemplo de introducción será examinar la utilización de estas estructuras en la
evaluación de expresionesaritméticas. Se supone que se desea encontrar el va-
lor de una expresión aritmética simple formada por multiplicaciones y sumas
de enteros, tal como
5 * ( ( (9 + 8) * (4* 6)) + 7).
Para realizar este cálculo, hay que guardar aparte algunos resultados interme-
dios: por ejemplo, si se calcula primero 9+8, entonces el resultado parcial (17)
debe guardarse en algún lugar mientras se calcula 4 * 6. Una pila constituye el
mecanismo ideal para guardar los resultados intermedios de este cálculo.
Para comenzar, se reordena sistemáticamentela expresión de modo que cada
operador aparezca después de sus dos argumentos, en lugar de aparecer entre
ellos. De manera que al ejemplo anterior le corresponde la expresión
5 9 8 + 4 6 * * 7 + *
Esto se denomina notaciónpolaca inversa (dado que fue introducida por un cé-
lebre lógico polaco), o postfija. La forma normal de escribir expresiones arit-
méticas se denomina infija. Una propiedad interesante de la notación postfija
es que no se necesitan paréntesis, mientras que en la infija éstos son necesarios
para distinguir el orden de las operaciones ya que, por ejemplo, no es lo mismo
5*(((9+8)*(4*6))+7)que ((5*9)+8)*((4*6)+7). Una propiedad todavía más inte-
resante de la notación postfija es que proporciona una manera sencilla de eje-
cutar el cálculo, guardando los resultados intermedios en una pila. El siguiente
programa lee una expresión postfija, interpretando cada operando como una
orden para ((introducirel operando en la pila» y cada operador como una orden
para «recuperar los dos operandos de la pila, ejecutar la operación e introducir
el resultado».
char c; Pila acc(50); int x;
while (cin.get(c))
x = o;
while (c == I I ) cin.get(c);
if (c == ' + I ) x = acc.sacar() + acc.sacar() ;
if (c == ' * I ) x = acc.sacar() * acc.sacar() ;
while jc>='O' && c<='9')
{ x = 1O*x + (c-'oI);
acc.meter(x);
{
cin.get(c); }
}
tout << acc.sacar() << 'n';
La pila guardar se declara y define junto con las otras variables del programa
ESTRUCTURASDE DATOS ELEMENTALES 31
y las operaciones meter y sacar de guardar se invocan exactamente igual que
get para el flujo de entrada cin. Este programa lee cualquier expresiónpostfija
formada por multiplicaciones y sumas de enteros y después obtiene el valor de
la expresión. Los espacios en blanco se ignoran y el bucle whi 1e convierte a for-
mato numérico los enteros que están en formato alfanumérico para poder rea-
lizar los cálculos. En C++ no se especifica el orden en que se ejecutan las dos
operaciones sacar (), por l
o que se necesitará un código algo más complejo
para operadores no conmutativos tales como la resta y la división.
El siguiente programa convierte una expresión infija, de paréntesis total-
mente permitidos, en una expresión postfija:
char c; Pila guardar(50);
while (cin.get(c))
if (c == I ) ' ) cout.put(guardar.sacar()) ;
if (c == I + ' ) guardar.meter(c) ;
if (c == ' * I ) guardar.meter(c) ;
while (c>='O' && c<='9')
{ cout.put(c); cin.get(c); }
if (c != ' ( I ) cout << I I ;
{
1
cout << '  n l ;
Los operadores se meten en la pila y los argumentos simplemente pasan a través
de ella. Así, los argumentos aparecen en la expresiónpostfija en el mismo orden
que en la expresión infija. Entonces cada paréntesis derecho indica que se ob-
tienen los dos argumentos del último operador, por tanto el propio operador
puede retirarse de la pila e imprimirse como salida. Es interesante observarque
como sólo se utilizan operadores con exactamente dos operandos, no se nece-
sitan paréntesis izquierdosen la expresión infija (y este programa los omite).Para
hacerlo más sencillo, el programa no verifica los errores de la entrada y exige la
presencia de espacios entre operadores, paréntesis y operandos.
El paradigma aguardar los resultados intermedios» es fundamental, y así las
pilas aparecen frecuentemente. Muchas máquinas llevan a cabo las operaciones
básicas de pilas en el hardware, al implementar de manera natural mecanismos
de llamada a funciones, como, por ejemplo, guardar el entorno actual en la en-
trada de un procedimiento introduciendo información en una pila, restaurar el
entorno en la salida recuperando la información de la pila, etc. Algunas calcu-
ladoras y lenguajesbasan sus métodos de cálculoen operaciones con pilas: cada
operaciónobtiene sus argumentos de la pila y devuelvesusresultados a la misma.
Como se verá en el Capítulo 5, las pilas aparecen con frecuencia de forma im-
plícita aun cuando no se utilicen explícitamente.
32 ALGORITMOS EN C++
Implementaciónde pilas por listas enlazadas
La Figura 3.7 muestra el caso típico en el que basta con una pila pequeña, in-
cluso aunque haya un gran número de operaciones. Si se está seguro de estar en
este caso, entonces resulta apropiada la representación por array. Si no es así
sólo una lista enlazada permitirá a la pila crecer y decrecer elegantemente, lo
cual resulta especialmente útil si se trabaja con muchas estructuras de datos de
este tipo. Para implementar operaciones básicas de pilas utilizando listas enla-
zadas, se comienza por definir la interfaz:
class P i l a
i
public:
P i l a ( i n t max);
void meter(tipoE1emento v);
tipoElemento sacar();
i n t vacia();
s t r u c t nodo
s t r u c t nodo *cabeza, *z;
- P i 1a() ;
private:
{ tipoElemento clave; s t r u c t nodo * siguiente; }
};
En C++, esta interfaz sirve para dos propósitos: para utilizar una pila, única-
mente se necesita consultar la sección pub1i c de la interfaz para conocer qué
operaciones se pueden realizar, y para irnplementar una rutina de pila se con-
sulta la sección p ri vate para ver cuálesson las estructuras de datos básicas aso-
ciadasa la implementación. Las implementaciones de los procedimientosde pila
se separan de la declaración de las clases, llegándose incluso a incluir en un
archivo aparte. Esta capacidad para separar las implementaciones de las inter-
faces, y por lo tanto para experimentar fácilmente con diferentes implementa-
ciones, es un aspecto muy importante de C++ que se tratará con mayor detalle
al final de este capítulo.
Lo siguiente que se necesita es la función ((constructom para crear la pila
cuando se declare y la función ((destructom para eliminarla cuando ya no se
necesite (está fuera del alcance del libro):
Pi 1a: :Pi1a ( i n t max)
cabeza = new nodo; z = new nodo;
cabeza->siguiente = z; z->siguiente = z;
{
ESTRUCTURAS DE DATOS ELEMENTALES 33
i
Pila::-Pila()
struct nodo *t = cabeza;
while (t != z)
{
{ cabeza = t; t->siguiente; delete cabeza; }
1
Para finalizar se muestra una implementación real de las operaciones de la pila:
void Pila::meter(tipoElemento v)
struct nodo *t = new nodo;
t->clave = v; t->siguiente = cabeza->siguiente;
cabeza->siguiente = t;
tipoElemento Pila::sacar()
{
1
{
tipoElemento x;
struct nodo *t = cabeza->siguiente;
cabeza->siguiente = t->siguiente; x = t->clave;
delete t; return x;
1
int Pila::vacia()
{return cabeza->siguiente== z; }
Se aconseja al lector que estudie este código cuidadosamente para reforzar sus
conocimientos, tanto de las listas enlazadas como de las pilas.
Colas
Otra estructura de datos de acceso restrictivo es la que se conoce como cola. En
ella, una vez más, solamente se encuentran dos operaciones básicas: se puede
insertar un elemento al principio de la cola y se puede eli m i nar un ele-
mento del final. Quizás aquel ejecutivotan ocupado podría organizar su trabajo
como una cola, ya que entonces el trabajo que le llegue primero lo hará pn-
mero. En una pila algún elemento puede quedar sepultado en el fondo, pero en
una cola todo se procesa en el orden en que se recibe.
La Figura 3.8 muestra un ejemplo de una cola que evoluciona mediante una
sucesión de operaciones obtener y poner representadas por la sucesión
34 ALGORITMOS EN C++
Figura 3.8 Característicasdinámicas de una cola.
E * J E * M * P * L O * D * * * E * C O * * L A **.
donde cada letra de la lista significa «ponen>(la letra) y el asterisco significa
«obtenen>.
Aunque en la práctica las pilas aparecen con mayor frecuencia que las colas,
debido a su relación fundamental con la recursión (ver Capítulo 5), se encon-
trarán algoritmos para los que la cola es la estructura de datos natural. En el
Capítulo 20 se encontrará una «cola de doble extremo» (deque), que es una
combinación de pila y cola, y en los Capítulos 4 y 30 se verán ejemplos funda-
mentales que muestran cómo se puede utilizar una cola para realizar un meca-
nismo que permita examinar árboles y grafos. A veces se hará referencia a las
pilas indicando que obedecen a la ley «last in, first out» (LIFO)(último en en-
trar, primero en salir) y ,por su parte, las colas obedecen a la ley «first in, first
out» (FIFO)(primero en entrar, primero en salir).
La implementación de las operaciones de colas por listas enlazadas es di-
recta, y se deja como ejercicio para el lector. AI igual que ocurría con las pilas,
también se puede utilizar un array si puede estimarse su tamaño máximo, como
en la siguiente implementación de las funciones poner, obtener y vaci a (se
omite el código para la interfaz y las funciones constructor y destructor porque
son similaresal código dado anteriormente para la implementación de pilas por
array):
void Cola: :poner(tipoElemento v)
cola[rabo++] = v;
i f (rabo > talla) rabo = O;
{
1
{
tipoElemento Cola: :obtener()
tipoElemento t = cola[cabeza++];
ESTRUCTURAS DE DATOS ELEMENTALES 35
i f (cabeza > t a l l a ) cabeza = O;
return t;
i n t Cola: :vacia()
1
{ return cabeza == rabo; }
Hay tres variables de clase: la t a l 1a (tamaño) de la cola y dos índices, uno en
cabeza y otro en «rabo» de la cola. El contenido de la cola está formado por
todos los elementos del array entre cabeza y rabo, teniendo en cuenta la vuelta
a O cuando se llegue al final del array. Si cabeza y rabo son iguales, entonces
la cola se define como vacía; pero si poner las hiciera iguales,entonces se define
como llena (aunque una vez más no se ha incluido esta comprobación en el có-
digo anterior). Esto requiere que el tamaño del array sea mayor, en una unidad,
que el número máximo de elementos que se desea incluir en la cola: una cola
llena contiene una posición del array vacía.
Tipos de datos abstractos y concretos
En lo anterior se ha visto que a menudo es conveniente describir los algoritmos
y las estructuras de datos en función de las operaciones efectuadas, en lugar de
hacerlo en términos de los detalles de la implementación. Cuando se define de
esta forma una estructura de datos, se denomina tipo de datos abstracto. La idea
es separar el «concepto» de lo que debe hacer la estructura de datos de cualquier
implementación particular.
La característica genérica de un tipo de datos abstracto es que nada que sea
externo a la definición de las estructuras de datos y los algoritmos que operan
sobre ellas debe hacer referencia a cualquier cosa interna, excepto a través de
llamadas a funciones y procedimientos de las operaciones fundamentales. El
motivo principal para el desarrollo de los tipos de datos abstractos ha sido es-
tablecer un mecanismo para la organización de grandes programas. Lostipos de
datos abstractos proporcionan una manera de limitar el tamaño y la compleji-
dad de la interfaz entre algoritmos (potencialmente complicados), las estructu-
ras de datos asociadasy los programas (un número potencialmente grande) que
utilizan los algoritmos y las estructuras de datos. Esto hace más fácil compren-
der los grandes programas, y más conveniente los cambios o mejoras de los al-
goritmos fundamentales.
Las pilas y las colas son ejemplos clásicosde tipos de datos abstractos ya que
muchos programas únicamente necesitan tratar con unas cuantas operaciones
básicas bien definidas, y no con detalles de enlaces e índices.
Tanto los arrays como las listas enlazadas se pueden obtener por medio de
mejoras de un tipo de datos abstracto básico denominado lista lined. En cada
uno de ellos se pueden realizar operaciones tales como insertar, eliminar y ac-
36 ALGORITMOS EN C++
ceder en una estructura subyacente básica de elementos ordenados secuencial-
mente. Estas operaciones bastan para describir los algoritmos y la abstracción
de listas linealespuede ser útil en las etapas inicialesde desarrollo del algoritmo.
Pero, como se ha visto, el interés del programador reside en definir cuidadosa-
mente qué operaciones se utilizarán, ya que puede haber bastantes característi-
cas de rendimiento del algoritmodiferentes para implementacionesdistintas.Por
ejemplo, utilizar una lista enlazada en lugar de un array en la criba de Eratós-
tenes sena costoso porque la eficacia del algoritmo depende de que sea posible
pasar rápidamente desde cualquier posición del array a cualquier otra, y utilizar
un array en lugar de una lista enlazada en el problema de Josephus sería tam-
bién costoso porque la eficacia del algoritmo depende de la desaparición de los
elementos que se eliminan.
Las listas lineales sugieren otras muchas operaciones cuya eficacia requiere
algoritmos y estructuras de datos mucho más sofisticados. Las dos operaciones
más importantes son la ordenación de los elementos en orden creciente de sus
claves(tema de los Capítulos 8-13), y la búsqueda de un elemento con una clave
dada (tema de los Capítulos 14-18).
Un tipo de datos abstracto se puede utilizar para definir a otro. Así, se uti-
lizaron las listas enlazadas y arrays para definir pilas y colas; de hecho se em-
plearon los conceptos de «puntero» y «registro» (proporcionados por C++) para
construir listas enlazadas y el de «array» (proporcionado por C++) para cons-
truir arrays. Además, anteriormente se ha visto que se pueden construir listas
enlazadas con arrays y en el Capítulo 36 se verá que jalgunas veces los arrays se
deben construir con listas enlazadas! El verdadero poder del concepto de tipo
de datos abstracto es que permite construir sin inconvenientes grandes sistemas
en diferentes niveles de abstracción: desde las instrucciones en lenguaje má-
quina proporcionadas por la computadora a las diversas posibilidadesque pro-
porciona el lenguaje de programación, o a la ordenación, la búsqueda y otras
operaciones de mayor nivel proporcionadas por los algoritmos que se presentan
en este libro hasta los niveles aún más altos de abstracción que pueda sugerir la
aplicación.
En este libro se utilizarán programas relativamente pequeños que están muy
relacionados con sus estructuras de datos asociadas. Siempre que sea posible se
hablará en términos abstractos de la interfaz entre los algoritmos y sus estruc-
turas de datos; es más apropiado enfocar el problema con un mayor nivel de
abstracción (resulta más próximo a la aplicación):el concepto de abstracción no
debe distraer de la búsqueda de la solución más eficaz de un problema concreto.
¡El rendimiento es lo que importa! Los programas que se han desarrollado te-
niendo esto en cuenta se pueden utilizar con cierta confianza al desarrollar los
niveles de abstracción superiores de los grandes sistemas.
Las implementaciones de operaciones de colas y pilas de este capítulo son
ejemplos de tipos de datos concretos,que guardanjuntas las estructuras de datos
y los algoritmos que operan sobre ellas. A lo largo de este libro se utilizará fre-
cuentemente este paradigma, ya que es una forma muy conveniente de descri-
bir los algoritmos básicos, a la vez que desarrolla un código útil para emplearlo
ESTRUCTURASDE DATOS ELEMENTALES 37
en las aplicaciones. C++ proporciona un medio de implementar verdaderos ti-
pos de datos abstractos utilizando la ((jerarquíade clases» y «las funciones vir-
tuales», en las que la interfaz consta solamente de funciones (node la represen-
tación de los datos), pero en este libro no se utilizará este recurso porque la meta
es el conocimiento de las características del rendimiento, lo cual es difícil de
mantener cuando se utilizan verdaderos tipos de datos abstractos.
Como se mencionó anteriormente, las estructuras de datos reales rara vez
constan simplementede enteros y enlaces. Con frecuencia, los nodos contienen
una gran cantidad de información y pueden pertenecer a múltiples estructuras
de datos independientes. Por ejemplo, un archivo con los datos del personal,
puede contener registros con nombres, direcciones y otros elementos de infor-
mación sobre los empleados, y cada registro puede pertenecer a una estructura
de datos destinada a la búsqueda de un empleado particular o a otra estructura
de datos destinada al estudio de estadísticas,etc. C++ tiene un mecanismo ge-
neral, denominado templ ate,que proporciona una forma fácil de ampliar los
algoritmossimplespara trabajar en estructurascomplejas. Si en lugar de utilizar
typedef se coloca el código template <class tipoElemento> justo antes
de las definiciones de cl ases de este capítulo, las convierte en definiciones de
estructuras que trabajan con cualquier tipo de dato. Por ejemplo, esto permiti-
ría una declaración como Pi 1 a <f1oat > acc ( ) utilizada para construir una
pila de f 1 oats. En general, en este libro se trabajará con enteros, pero se man-
tendrán sin especificar los tipos, como en este capítulo, en el entendimiento de
que typedef o template se pueden utilizar fácilmente en aquellas aplicaciones
que se crea conveniente.
Ejercicios
1. Escribir un programa que llene un array bidimensional de valores boolea-
nos poniendo a [i ] [ j] a 1 si el máximo común divisor de i y j es 1 y a O
en cualquier otro caso.
2. Implementar una rutina despl azasiguienteacabeza(struct nodo *t)
en una lista enlazada, para desplazar al comienzo de la lista el nodo si-
guiente al nodo que apunta t. (La Figura 3.3 es un ejemplo de esta opera-
ción para el caso especial en que t apunte al nodo siguiente d úitimo de la
lista.)
3. Implementar unarutina intercambio(struct nodo *t, struct nodo *u)
en una lista enlazada, para intercambiar las posicionesde los nodos siguien-
tes a los nodos apuntados por t y u.
4. Escribir un programa para resolver el problema de Josephus, utilizando un
array en lugar de una lista enlazada.
5. Escribir procedimientos para insertar y eliminar en una lista doblemente
enlazada.
38 ALGORITMOS EN C++
6.
7.
8.
9.
10.
Escribir procedimientos para la representación de una pila por una lista en-
lazada, pero utilizando arrays paralelos.
Obtener el contenido de la pila después de cada operación en la sucesión C
U E * S * * T I O * * * N F * * * A * C I * L *. Aquí una letra significa
«meten>(introducir la letra) y un «*» significa «sacan>(sacarla).
Obtener el contenido de la cola después de cada operación en la sucesión C
LJ E * S * * T I O * * * N F * * * A * C I * L *. Aquí una letra significa
«ponen>(poner la letra) y un a*» significa «obtenen>(tomarla).
Obtener una sucesión de llamadas a el iminasiguiente e insertades-
pues que podría haber generado la Figura 3.5 desde una lista inicialmente
vacía.
Implementarlas operaciones básicas de una cola utilizando una lista enla-
zada.
4
Arboles
Las estructuras presentadas en el Capítulo 3 son intrínsecamente unidimensio-
nales: un elemento siguea otro. En este capítulo se considerarán las estructuras
enlazadas de dos dimensiones denominadas árboles, que se encontrarán en el
desarrollode muchos de los algoritmos más importantes que se tratan a lo largo
de este libro. Un estudio sobre árboles podría ocupar un libro entero, ya que se
utilizan en muchas aplicaciones, además de en informática, y se han estudiado
con profusión como objetos matemáticos. Desde luego, podría decirse que este
libro es en sí mismo un tratado sobre árboles, ya que están presentes, de forma
fundamental, en cada una de las seccionesdel libro. En este capítulo se estudia-
rán la terminología y las definiciones básicas asociadas a los árboles, se exami-
narán algunas propiedades importantes y se verá la forma de representar árbo-
les mediante una computadora. En capítulos posteriores, se verán muchos
algoritmos que operan con estas estructuras de datos elementales.
Los árboles se encuentran con frecuencia en la vida cotidiana, y el lector se-
guramente está familiarizadocon el concepto básico. Por ejemplo, mucha gente
hace el seguimientode sus antepasadoso descendientes,o ambas cosas, mediante
un árbol genealógico, y así gran parte de la terminología se deriva de esta apli-
cación. Otro ejemplo es el caso de la organización de competiciones deportivas,
que fue estudiado por Lewis Carroll y se verá en el Capítulo 11. Un tercer ejem-
plo es el organigrama de una gran empresa; este caso sugiere la edescomposi-
ción jerárquica» que aparece en muchas aplicaciones de la informática. Un
cuarto ejemplo es el «árbol de análisissintáctico» que descompone una oración
gramatical en las partes que la forman; este proceso está íntimamente relacic-
nado con el procesamiento de lenguajes de computadora, que se tratará en el
Capítulo 21. A lo largo del libro se verán otros ejemplos.
39
40 ALGORITMOS EN C++
Glosario
Este estudio de los árboles comienza definiéndolos como objetos abstractos e
introduciendo la mayor parte de la terminología básica asociada. Los árboles se
pueden definir de diferentes maneras, ya que hay una serie de propiedades ma-
temáticas que implican esta equivalencia; esto se analizará con más detalle en
la siguiente sección.
Un árbol es un conjunto no vacío de vérticesy aristas que cumple una serie
de requisitos. Un vértice es un objeto simple (también conocido como un nodo)
que puede tener un nombre y puede llevar otra información asociada: una arista
es una conexión entre dos vértices. Un camino en un árbol es una lista de vér-
tices distintos en la que dos consecutivos se enlazan mediante aristas. A uno de
los nodos del árbol se le designa como la raíz. La propiedad que define a un
árbol es que hay exactamente un camino entre la raíz y cada uno de los otros
nodos del árbol. Si hay más de un camino entre la raíz y un nodo, o si no existe
ninguno entre la raíz y algún nodo, entonces se trata de un grafo (ver Capítulo
29) y no de un árbol. La Figura 4.1 muestra un ejemplo de árbol.
Aunque la definición no implica la «dirección» de la arista, normalmente se
representan las aristas apuntando hacia afuera de la raíz (hacia abajo en la Fi-
gura 4.I) o hacia la raíz (hacia amba en la Figura 4.1), dependiendo de la apli-
cación de la que se trate. Por lo regular, los árboles se dibujan con la raíz en la
parte más alta (aunque en principio esto parezca antinatural), y se dice que el
nodo y está debajo del nodo x (y x está por encima de y ) si xestá en el camino
que va desde y a la raíz (es decir, y está debajo de x si existe un camino que lo
conecte con xy que no pase por la raíz). Cada nodo (exceptuando la raíz) tiene
exactamente un nodo inmediatamente encima de él, al que se denomina como
su padre;mientras que a los nodos que tiene directamente por debajo se les de-
nomina sus hijos. Algunas veces, siguiendo esta analogía familiar, se habla del
«abuelo» o del «hermano» de un nodo: en la Figura 4.1, L es elaieto de E y
tiene tres hermanos.
A los nodos sin hijos se les denomina a veces hojas, o nodos terminales. En
correspondencia con su utilización posterior, a los nodos con al menos un hijo
algunas veces se les denominará nodos no terminales. Los nodos terminales son
Figura 4.1 Un ejemplo de árbol.
ÁRBOLES 41
frecuentemente diferentes de los no terminales: por ejemplo, pueden no tener
nombre o información asociada. En tales situaciones, se hará referencia a los
nodos no terminales como nodos internos y a los nodos terminales como nodos
externos.
Cualquier nodo es la raíz de un subárbol constituido por él mismo y por los
nodos situados debajo. En el árbol que se muestra en la Figura 4.1, hay siete
subárboles de un nodo, un subárbol de tres nodos, un subárbol de cinco nodos
y un subárbol de seis nodos. A un conjunto de árboles se le denomina bosque:
por ejemplo, si se suprime la raíz y las aristas que la unen al árbol de la Figura
4.1, se obtiene un bosque formado por tres árboles de raíces B, E y O.
El orden en que se ccloca a los hijos de un nodos es a veces significativo,y
otras veces no. Un árbol ordenado es aquel en el que se ha especificadoel orden
de los hijos de todos los nodos. Por supuesto, los hijos se colocan en un orden
determinado cuando se dibuja un árbol, y hay muchas formas diferentes de di-
bujar un árbol que no esté ordenado. Como se verá más adelante, es importante
hacer esta distinción entre árbolesordenadosy no ordenadosa la hora de repre-
sentar los árbolespor computadora, ya que hay mucha menos flexibilidaden la
representación de árbolesordenados. Naturalmente será la aplicación la que de-
termine el tipo de árbol que se debe utilizar.
Los nodos de un árbol se estructuran en niveles: el nivel de un nodo es el
número de nodos del camino que lleva desde éste hasta la raíz (sin incluirse a sí
mismo). Así, por ejemplo, en la Figura 4.1, E es un nodo de nivel 1 y R es de
nivel 2. La altura de un árbol es el nivel máximo del árbol (o la máxima distan-
cia entre la raíz y cualquier nodo). La longitud del carniro de un árbol es la suma
de los niveles de todos los nodos del árbol (es decir, la suma de las longitudes
de los caminos desde cada nodo a la raíz). El árbol de la Figura 4.1 tiene altura
3, y la longitud del camino es 21. Una vez que se han distinguido los nodos
internos de los nodos externos, se puede hablar de la longitud del camino in-
terno y de la longitud del camino externo.
Si cada nodo debe tener un número específico de hijos colocados en un or-
den determinado, entoncesse tiene un árbol multicamino. En estetipo de árbol
conviene definir nodos externos especiales que no tienen hijos (y normalmente
ni nombre ni ninguna otra información asociada). En este caso los nodos exter-
nos actúan como nodos «ficticios» para referencia de nodos que no tienen el
número de hijos especificado.
En particular, el caso más sencillode árbol multicamino es el árbol binario,
que es un árbol ordenado que está formado por dos tipos de nodos: nodos ex-
ternos, sin hijos, y nodos internos, que tienen exactamente dos hijos. En la Fi-
gura 4.2se muestra un ejemplo de un árbol binario. Como los dos hijos de cada
nodo interno están ordenados, se hará referencia al hijo de la izquierda y al hijo
de la derecha: todos los nodos internos deben tener los dos hijos, uno a la iz-
quierda y otro a la derecha, aunque uno o los dos podrían ser nodos externos.
El objetivo de los árboles binarios es estructurar los nodos internos, ya que
los externos sirven únicamente como ((reservade plaza» y se incluyen en la de-
finición porque las representaciones más usuales de árboles binarios necesitan
42 ALGORITMOS EN C++
ó h
Figura 4.2 Un ejemplo de árbol binario.
conocer a todos sus nodos externos. Un árbol binario puede estar «vacío» si
contiene un nodo externo y ninguno interno.
Un árbol binario lleno es aquel en el que los nodos internos llenan todos los
niveles, con la posible excepción del último. Un árbol binario completo es un
árbol binario lleno en el que los nodos internos del último nivel aparecen todos
a la izquierda de los nodos externos de ese mismo nivel. La Figura 4.3 muestra
un ejemplo de un árbol binario completo. Como se verá más adelante, los ár-
boles binarios aparecen muchas veces en aplicacionesde computadoras, siendo
mejor su rendimiento cuando están llenos (o casi llenos). En el Capítulo 11 se
estudiará una estructura de datos basada en los árboles binanos completos.
El lector debería observar con gran cuidado que, mientras que todo árbol
binario es un árbol, no todo árbol es un árbol binario. Incluso considerando
únicamente árboles ordenados en los que todos los nodos tienen O, 1 o 2 hijos,
cada uno de ellos puede corresponder a muchos árbolesbinarios, porque los no-
dos con 1 hijo pueden estar a la izquierda o a la derecha de un árbol binario.
En el próximo capítulo se verá que los árboles están íntimamente ligados
con la recursión. De hecho, la forma más sencilla de definir árboles es la recur-
siva, como se indica a continuación: «un árbol es o un nodo aislado o un nodo
raíz conectado a un conjunto de árboles» y «un árbol binano es o un nodo ex-
terno o un nodo raíz (interno) conectado a un árbol binano izquierdo y a un
árbol binano derecho».
Figura 4.3 Un árbol binario completo.
ÁRBOLEC 43
Propiedades
Antes de tratar las representaciones es preciso ver el aspecto matemático, con-
siderando una sene de propiedades importantes de los árboles. Una vez más
aparece un gran número de propiedades a examinar, pero aquí con el objeto de
tratar aquellas que son particularmente importantes para los algoritmos que se
verán en el libro.
Propiedad 4.1 Dados dos nodos cualesquierade un árbol, existe exactamente
un camino que los conecta.
Dados dos nodos cualesquiera, se verifica que tienen un antecesor común mi-
nimo, es decir un nodo que está en el camino de ambos nodos hacia la raíz pero
de manera que ninguno de sus hijos tiene esta misma propiedad. Por ejemplo,
C es el antecesor común más pequeño de R y L en el árbol de la Figura 4.3.El
antecesor común mínimo debe existir siempre porque, o bien es la raíz, o bien
ambos nodos están en un subárbol enraizado en uno de los hijos de la raíz; en
este último caso, o bien ese nodo es el antecesor común mínimo, o ambos no-
dos están en el subárbol enraizado en uno de sus hijos, etc. Existe un camino
desde cada uno de los nodos al antecesor común mínimo: componiendo estos
dos caminos se obtiene un camino que conecta a los dos nodos. w
Una consecuencia importante de la propiedad 4.1 es que cualquier nodo
puede ser la raíz, ya que, dado cualquier nodo de un árbol, existe exactamente
un camino que lo conecta con cualquier otro nodo del árbol. Técnicamente la
definición en la que Ia raíz está identificada es la del árbol enraizado u orien-
tado. A un árbol en el que la raíz no está identificada se le denomina árbol libre.
El lector no necesita saber más sobre estas diferencias: o la raíz está identificada,
o no lo está.
Propiedad 4.2
Esta propiedad se deduce directamente del hecho de que cada nodo, excepto
la raíz, tiene un único padre, y toda arista conecta a un nodo con su padre.
También es posible probar este hecho por inducción a partir de la definición
recursiva. a
Las dos siguientes propiedades pertenecen a los árboles binanos. Como se
mencionó anteriormente, estas estructuras se encontrarán muy a menudo a lo
largo del libro, de manera que merece la pena prestar atención a sus caractens-
ticas. Esto servirá como trabajo preparatorio para comprender el comporta-
miento representativo de diversosalgoritmos que se encontrarán más adelante.
Propiedad 4.3 Un árbol binario con N nodos internos tiene N + I nodos exter-
nos.
Esta propiedad se puede demostrar por inducción. Un árbol binario sin nodos
internos tiene un nodo externo, de manera que la propiedad se verifica para N
= O. Para N > O, cualquier árbol binano con N nodos internos tienen k nodos
Un árbol con N nodos tiene N - 1 aristas.
44 ALGORITMOS EN C++
internos en el subárbol de la izquierda y N - I - k nodos internos en el subárbol
de la derecha para k entre O y N - I , ya que la raíz es un nodo interno. Por la
hipótesis de inducción, el subárbol izquierdo tiene k + 1 nodos externos y el
subárbol derecho tiene N - k nodos externos, lo que hace un total de N + 1.
Propiedad 4.4 La longitud del camino externo de cualquier árbol binario con
N nodos internos es 2N mayor que la longitud del camino interno.
Esta propiedad también se puede probar por inducción, pero es igualmente ins-
tructiva una demostración alternativa. Se observa que cualquier árbol binano
puede construirse mediante el siguiente proceso: se comienza con un árbol bi-
nano formado por un nodo externo, a continuación se repite la siguiente ope-
ración N veces: coger un nodo extemo y reemplazarlo por un nuevo nodo in-
terno con dos nodos externos como hijos; si el nodo externo elegido es de nivel
k, la longitud del camino interno aumenta en k,pero la longitud del camino
externo aumenta en k+2 (se ha eliminado un nodo externo de nivel k,pero se
han añadido dos de nivel k + 1). El proceso comienza con un árbol cuya longi-
tud de camino externo e interno es O y que en cada uno de los N pasos aumenta
la longitud del camino externo 2 unidades más que la del camino interno. w
Finalmente, se considera una propiedad elemental de la «mejon>clase de ár-
boles binarios, los árboles llenos. Estos árboles son interesantes porque garanti-
zan que su altura será baja, de manera que no costará mucho trabajo ir de la
raíz a cualquier nodo o viceversa.
Propiedad 4.5 La altura de un árbol binario lleno con N nodos internos es
aproximadamente 10g2N.
Haciendo referencia a la Figura 4.3, si la altura es n se debe tener
.
2n-' < N + 1 d 2n,
ya que hay N + 1 nodos externos. Esto implica la propiedad enunciada. (En rea-
lidad la altura es exactamente igual al resultado de redondear log2N al ente-
ro más próximo, pero, como se verá en el Capítulo 6, no hay que ser tan
preciso.)
Otras propiedades matemáticas de los árboles se irán presentando según se
vayan necesitando en los capítulos posteriores. En este momento ya se puede
comenzar con las consideraciones prácticas de la representación de los árboles
en la computadora y su manipulación eficaz.
Representación de arboles binarios
La representación más frecuente de los árboles binanos consiste en utilizar re-
gistros con dos enlaces por nodo. Normalmente, se llamará a los enlaces izq y
der (izquierda y derecha) para indicar el orden elegido en la representación que
corresponde a la forma en que se ha dibujado el árbol en la página. En algunas
ÁRBOLES 45
aplicaciones puede resultar apropiado tener dos tipos de registrosdiferentes, uno
para los nodos internos y otro para los externos; en otras puede ser más ade-
cuado utilizar sólo un tipo de nodo y emplear los enlaces a los nodos externos
para otros propósitos.
Como modelo de la construcción y utilización de los árboles binarios se
continuará con el ejemplo del capítulo anterior, que trata del procesamiento de
expresionesaritméticas. Como se muestra en la Figura 4.4, hay una correspon-
dencia directa entre las expresiones aritméticas y los árboles.
Se emplearán como identificadores de los argumentos caracteres sencillosen
lugar de números (la razón se explicará más adelante). El árbol de análisis sin-
táctico de una expresión se define por la siguienteregia recursiva: «poner el ope-
rador en la raíz y a continuación construir el árbol de la expresión correspon-
diente al primer operando a la izquierda y el de la expresión correspondiente al
segundo operando a la derecha. La Figura 4.4 es el árbol de análisis sintáctico
de A B C + D E **F + * (la misma expresión en postfija). Obsérvese que infija
y postfija son dos formas de representar expresiones aritméticas, siendo los ár-
boles de análisis sintáctico una tercera.
Figura 4.4 Árbol de análisis sintáctico para A ((( B + C) ( D E ) ) + F).
Como los operadores tienen exactamente dos operandos, lo adecuado para
este tipo de expresioneses un árbol binario, pero otras expresionesmás compli-
cadas podrían necesitar un tipo de árbol diferente. En el Capítulo 21 se revisa-
rán estos resultados con más detalle, aunque por ahora el objetivo es la simple
construcción de un árbol que represente una expresión aritmética.
El siguientecódigo construye el árbol de análisissintáctico de una expresión
aritmética a partir de una representación de entrada postfija. Se trata de una
ligera modificación del programa del capítulo anterior que evaluaba expresio-
nes postfijas utilizando una pila. En lugar de guardar los resultados de los cálcu-
los intermedios en la pila, se guarda la expresión de los árboles, como en la si-
guiente implementación:
struct nodo
struct nodo *x, *z;
char c; Pila pila(50);
{ char info; struct nodo *izq, *der; };
46 ALGORITMOS EN C++
z = new nodo; z->izq = z; z->der = z;
whi 1e (cin.get ( c ) )
i
while (c == I I ) cin.get(c);
x = new nodo;
x->info = c; x->izq = z; x->der =z;
if (c=='+l I( c==l*l)
{ x->der = pila.sacar(); x->izq = pila.sacar(); )
pi 1 a.meter(x) ;
Se utiliza el tipo de pila del Capítulo 3, con un declaración typedef apropiada
de modo que se ponen en la pila punteros en lugar de enteros. Todos los nodos
tienen un carácter y dos enlaces a otros nodos. Cada vez que se encuentra un
nuevo carácter distinto del espacioen blanco, se crea un nodo utilizando el ope-
rador estándar de C++ new que llama a un constructor y asigna espacio en la
memoria. Si se trata de un operador, los subárboles de sus operandos están en
lo más alto de la pila, como en una evaluación postfija. Si es un operando, en-
tonces sus enlaces son nulos. En vez de utilizar enlaces nulos, se utilizará un
nodo ficticio zcuyos enlacesapuntan a él mismo. Esto facilita la representación
de ciertas operaciones mediante árboles (ver Capítulo 14). La Figura 4.5 mues-
tra los pasos intermedios de la construcción del árbol de la Figura 4.4.
o
Figura 4.5 Construccióndel árbol de analicis sintáctico de A 6 C +D E F + *.
Este programa, más bien sencillo,puede modificarsepara tratar expresiones
más complicadas que utilicen operadores con un único argumento, tales como
la exponenciación. El procedimiento es muy general; es exactamente el mismo
que se utiliza, por ejemplo, para analizar y compilar programas en C++. Una
vez creado el árbol de análisis sintáctico se puede utilizar para muchas cuestio-
nes, como para evaluar la expresión o crear programas de computadora para
evaluarla. En el Capítulo 21 se presentarán procedimientos generalespara cons-
truir árboles de análisis; en éste se verá cómo se puede utilizar un árbol para
ÁRBOLES 47
evaluar la expresión, si bien el interés de este capítulo se centra más en el pro-
cedimiento de construcción del árbol.
Al igual que en las listas enlazadas, siempre existe la alternativa de utilizar
arrays paralelos en lugar de punteros y registros para implementar la estructura
de datos de un árbol binario. Como se dijo con anterioridad, esto es especial-
mente útil cuando se conoce de antemano el número de nodos; incluso en el
caso particular en que los nodos necesiten ocupar un array para algún otro pro-
pósito se acudirá a esta alternativa.
La representación por doble enlace de los árboles binarios antes utilizados
permite descender por el árbol, pero no proporciona una forma de ascender por
él. La situación es análoga a la de las listas enlazadas simples frente a las listas
doblemente enlazadas: se puede añadir otro enlace a cada nodo para permitir
más libertad de movimientos, pero con el coste de una implementación más
complicada. Existen otras opciones diferentes entre las estructuras de datos
avanzadas que facilitan el movimiento a lo largo del árbol; pero para los algo-
ritmos de este libro generalmente bastará la representación de doble enlace.
En el programa anterior se utilizó un nodo «ficticio» en lugar de nodos ex-
ternos. Al igual que en las listasenlazadas, este cambio será conveniente en mu-
chas situaciones,pero no siempre será apropiado, pues hay otras dos soluciones
comúnmente utilizadas. Una de ellas consiste en emplear un tipo diferente de
nodo sin enlaces, para los nodos externos. Otra solución consiste en marcar los
enlaces de alguna manera (para distinguirlos de otros enlaces del árbol), y hacer
que apunten a otra parte del árbol (un método de este tipo se expondrá a con-
tinuación). Este tema se revisará en los Capítulos 14 y 17.
Representaciónde bosques
Los árboles binarios tienen dos enlacesdebajo de cada nodo interno, de manera
que la representación de árboles empleada anteriormente para ellos es inme-
diata. Pero ¿qué hacer con los árboles en general, o con los bosques, en los que
cada nodo puede tener un número aleatorio de enlaces hacia su descendencia?
La respuesta es que existen dos maneras relativamente sencillas de salir de este
dilema.
En primer lugar, en muchas aplicacionesno se necesita recorrer el árbol ha-
cia abajo, sino iúnicamente hacia arriba! En tales casos, sólo se necesita un en-
lace para cada nodo; el que lo conecta a su padre. La Figura 4.6 muestra esta
representación para el árbol de la Figura 4.1: el array a contiene la información
asociada a cada registro y el array papa contiene los enlacespadre. Así, la infor-
mación asociada al padre de a [i] está en a [papa [i] 1, etc. Por conveniose hace
que la raíz se apunte a sí misma. Ésta es una representación bastante compacta
y muy recomendable para trabajar con árboles si sólo se recorren hacia amba.
Se verán ejemplos de la utilización de esta representación en los Capítulos 22
y 30.
48 ALGORITMOSEN C++
k 1 2 3 4 5 6 7 8 9 1 0 1 1
papa[k] 3 3 10 8 8 8 8 9 10 10 10
Figura 4.6 Representacióndel enlace padre de un árbol.
Para representar bosques por el procedimiento descendente se necesita una
forma de tratar los hijos de cada nodo sin tener que asignar de antemano un
número determinado de hijos para cada uno. Pero éste es exactamente el tipo
de restricción para la que se diseñaron las listas enlazadas. Claro está, debe uti-
lizarse una lista enlazada para los hijos de cada nodo. Entonces cada nodo con-
tiene dos enlaces, uno para la lista enlazada que lo conecta con sus hermanos y
otro para la lista enlazada de sus hijos. La Figura 4.7 muestra esta representa-
ción para el árbol de la Figura 4.1. En lugar de utilizar un nodo ficticio para
terminar cada lista, es preferible obligar a que el último nodo vuelva a apuntar
hacia su padre; de esta manera es posible moverse a través del árbol, tanto hacia
amba como hacia abajo. (Estos enlaces pueden marcarse para distinguirlos de
los enlaces «hermanos»; de forma alternativa, se pueden explorar completa-
mente los hijos de un nodo marcando o guardandoel nombre del padre de ma-
nera que se pueda interrumpir la exploración cuando se vuelva a encontrar al
padre.)
Pero en esta representación cada nodo tiene exactamente dos enlaces (uno
hacia el hermano de la derecha y otro hacia el hijo que está más a la izquierda).
Se puede preguntar si existe alguna diferencia entre esta estructura de datos y
un árbol binario. La respuesta es que no existe, como se muestra en la Figura
4.8 (el árbol de la Figura 4.1 representado como un árbol binario). Esto es, cual-
quier bosque puede representarse por un árbol binano haciendo que el enlace
de la izquierda de cada nodo apunte al hijo que queda más a la izquierda y que
el enlace de la derecha de cada nodo apunte a su hermano de la derecha. (Este
hecho sorprende muy a menudo al principiante.)
Por tanto, es posible utilizar bosques siempre que sea conveniente para el
diseño del algoritmo. Cuando se quiere ascender por el árbol, la representación
Figura 4.7 Representaciónde un árbol por el hijo más a la izquierda y el hermano de
la derecha.
ÁRBOLES 49
d
Figura 4.8 Representaciónde un árbol medianteun árbol binario.
del enlace padre hace que los bosques sean más fáciles de tratar que cualquier
otra clase de árbol, y cuando se desea descender los bosques son esencialmente
equivalentes a los árboles binarios.
Recorrido de los árboles
Una vez que se ha construido el árbol, lo primero que se necesita es saber cómo
recorrerlo, es decir, cómo visitar sistemáticamente todos los nodos. Esta opera-
ción es trivial para las listaslinealespor su definición, pero para los árboles exis-
ten diferentes formas de hacerlo que difieren sobre todo en el orden en que se
recorren los nodos. Como se verá, el orden más apropiado depende de cada
aplicación concreta.
Por el momento se estudia el recomdo de árbolesbinarios; debido a la equi-
valencia entre bosques y árboles binarios, los métodos descritos son también
útiles para bosques, aunque más adelante también se mencionarán métodos que
se aplican directamente a los bosques.
El primer método a considerar es el recomdo en orden previo (preorden),
que puede utilizarse, por ejemplo, para escribirla expresión representada por el
árbol de la Figura 4.4 en prefijo. Este método se define mediante una simple
regla recursiva: «visitar la raíz, después el subárbol izquierdo y a continuación
el subárbolderecho.))Se mostrará en el próximo capítulo la implementación más
sencilla de este método, la recursiva, que está estrechamente relacionada con la
siguiente implementación basada en una pila:
recorrer(struct nodo *t)
pi 1 a .meter(t ) ;
while (!pila.vacia())
{
t = pila.sacar(); visitar(t);
50 ALGORITMOS EN C++
if (t->der != z) pila.meter(t->der);
if (t->izq != z) pila.meter(t->izq);
1
1
Siguiendo la regla, «se visita un subárbol)) después de visitar primero la raíz.
Como no se pueden visitar los dos subárboles a la vez, se guarda el subárbol
derecho en una pila y se visita el subárbol izquierdo. Cuando termine esta visita
el subárbol derecho estará en lo más alto de la pila y podrá visitarse. La Figura
4.9 muestra este programa aplicado al árbol binario de la Figura 4.2: el orden
en el que se visitan los nodos es L O R A B M O E D O L.
Figura 4.9. Recorrido en orden previo (preorden).
Para demostrar que efectivamente el programa visita los nodos del árbol por
orden previo, se puede emplear el método de inducción, tomando como hipó-
tesis inductiva que los subárboles se visitan en orden previo y que el contenido
de la pila justo antes de visitar un subárbol es el mismo que justo después de
visitarlo.
El segundo método a considerar es el recomdo en orden, que puede utili-
zarse, por ejemplo, para escribir las expresiones aritméticas en infija correspon-
dientes a los árboles de análisis sintáctico (con algún trabajo extra para obtener
51
ARBOLES
Figura 4.10. Recorrido en orden simétrico.
los paréntesis de la derecha), Al igual que en el orden previo, el recorrido en
orden se define con la regla recursiva ((visitarel subárbol izquierdo, a continua-
ción la raíz y después el subárbol derecho». A veces a este método también se
le denomina recomdo en orden simétrico, por razones evidentes. La implemen-
tación de un programa (basado en una pila) para recorrer el árbol por orden
simétrico es casi idéntica al programa anterior; se omite aquí porque constituye
el tema principal del siguiente capítulo. En la Figura 4.10 se ve cómo se visitan
en orden simétrico los nodos del árbol de la Figura 4.2:los nodos se visitan en
el orden A R B O L M O D E L O. Este método de recomdo es probablemente
el más utilizado, ya que, por ejemplo, ejerce un papel fundamental en las apli-
caciones de los Capítulos 14 y 15.
El tercer tipo de recorrido recursivo, llamado orden posterior (postorden),se
define mediante la siguiente regla recursiva: ({visitar el subárbol izquierdo, a
continuación el subárbol derecho y después la raíz». La Figura 4.1 1 muestra
cómo se visitan los nodos del árbol de la Figura 4.2 en orden posterior: A B R
O D L O E O M L. Si se visita el árbol de la Figura 4.4 en orden posterior se
obtiene la expresión A B C +D E * *F + *, como era de esperar. La implemen-
tación de un programa (basado en una pila) para recorrer un árbol en orden
posterior es más complicada que la de los otros dos recorridos, porque el pro-
52 ALGORITMOS EN C++
Figura 4.11. Recorridoen orden posterior (postorden).
grama debe organizarse para guardar la raíz y el subárbol derecho mientras se
visita el subárbol izquierdo, y para guardar la raíz mientras se visita el subárbol
derecho. Los detalles de esta implementación se dejan como ejercicio para el
lector.
La cuarta estrategia de recorrido que se considera no es del todo recursiva,
ya que simplemente se visitan los nodos según van apareciendo en la página,
leyendo de amba abajo y de izquierda a derecha. Este método se denomina re-
corrido en orden de nivel porque todos los nodos de cada nivel aparecenjuntos,
en orden. La Figura 4.12 muestra cómo se visitan los nodos del árbol de la Fi-
gura 4.2 si se recorren por orden de nivel.
Singularmente, el recomdo en orden de nivel se puede conseguir empleando
el programa anterior para orden previo, utilizando una cola en lugar de una pila:
recorrer(struct nodo *t)
col a.poner( t) ;
while (!cola.vacia())
{
i
t = cola.obtener(); visitar(t);
ÁRBOLES 53
if (t->izq != z) cola.poner(t->izq);
if (t->der != z) cola.poner(t->der);
Por una parte este programa es virtualmente idéntico al anterior, ya que la única
diferencia es que éste utiliza una estructura de datos FIFO mientras que el otro
utiliza una estructura de datos LIFO. Por la otra, estos programas procesan los
árboles de forma fundamentalmente diferente. Estos programas merecen un es-
tudio cuidadoso, ya que muestran las diferencias esenciales entre las pilas y las
colas. Se volverá sobre este tema en el Capítulo 30.
Figura 4.12. Recorrido en orden de nivel.
Los recomdos en orden previo, orden posterior y orden de nivel también se
pueden definir para bosques. Para hacer coherentes las definiciones, basta con
representar un bosque como un árbol con una raíz imaginaria. Entonces la re-
gla del orden previo queda como «visitar la raíz y después cada uno de los sub-
árboles));y la regia para el recorrido en orden posterior queda como «visitar cada
uno de los subárboles y después la raíz)). La regla del recomdo por orden de
nivel es la misma que para los árboles binarios. Cabe destacar el hecho de que
el orden previo para un bosque es el mismo que el orden previo para su corres-
54 ALGORITMOS EN C++
pondiente árbol binario, como se vio anteriormente, y que el orden posterior
para un bosque es igual que el recorrido por orden simétrico para el árbol bi-
nario; pero no ocurre lo mismo para el recorrido por orden de nivel. Las imple-
mentaciones directas que utilizan pilas y colas son sencillas generalizacionesde
los programas dados con anterioridad para los árboles binarios.
Ejercicios
1. Indicar el orden en el que se visitarán los nodos del árbol de la Figura 4.3
si se recorre en orden previo, orden simétrico, orden posterior y orden de
nivel.
2. ¿Cuál es la altura de un árbol cuatemario completo con N nodos?
3. Dibujar el árbol de análisis sintáctico de la expresión ( A + B ) * C + ( D +
4. Considerandoel árbol de la Figura 4.2 como un bosque que se ha represen-
5. Indicar el contenido de la pila cada vez que se visita un nodo durante el
6. Indicar el contenido de la cola cada vez que se visita un nodo durante el
7. Dar un ejemplo de un árbol para el que la pila utiliza más espacio al reco-
8. Dar un ejemplo de un árbol para el que la pila utiliza menos espacio al re-
9. Dar una implementaciónbasada en una pila del recorrido en orden poste-
10. Escribir un programa para implementar un recorrido en orelen de nivel de
E )-
tado como un árbol binario; dibujar esa representación.
recorrido en orden previo representado en la Figura 4.9.
recorrido en orden de nivel representado en la Figura 4.12.
rrerlo en orden previo que la cola al recorrerlo en orden de nivel.
correrlo en orden previo que la cola al recorrerlo en orden de nivel.
nor de un árbol binario.
un bosque representado como un árbol binario.
5
Recursión
La recursión es un concepto fundamentalen matemáticas e informática. La de-
finición más sencilla es que un programa recursivo es aquel que se llama a sí
mismo (y una función recursiva es aquella que se define en términos de sí
misma). Sin embargo, como un programa recursivo no puede llamarse a sí
mismo siempre, porque nunca se detendría (y una función recursiva no puede
definirse siempre en términos de sí misma, o la definición sería cíclica), otro
ingrediente esencial de la definición es que debe contener una condición de ter-
minación que autoriza al programa a dejar de llamarse a sí mismo (y a que la
función deje de definirse en términos de sí misma). Todos los cálculos prácticos
pueden expresarse de una forma recursiva.
El primer objetivo de este capítulo será examinarla recursión como una he-
rramienta práctica. En primer lugar, se darán algunos ejemplos en los que la
recursión no es práctica, mientras se muestra la relación entre recurrencias ma-
temáticas simples y programas recursivos simples. Después, se mostrará un
ejemplo prototípico de un programa recursivo de «divide y vencerás)) del tipo
que se utiliza para resolver problemas fundamentales en secciones posteriores
de este libro. Finalmente, se estudiará cómo puede eliminarse la recursión de
cualquier programa recursivo, y se mostrará un ejemplo detallado de cómo eli-
minar la recursión de un algoritmo de recorrido de árbol recursivo simple para
obtener un algoritmo no recursivo simple basado en una pila.
Como se verá más adelante, muchos algoritmos interesantes se pueden ex-
presar fácilmente mediante programas recursivos y muchos diseñadores de al-
gontmos prefieren métodos recursivos. Pero también es muy frecuente el caso
de que un algoritmo tan interesantecomo los anteriores se encuentre escondido
en los detalles de una implementación(necesariamente) no recursiva -en este
capítulo se estudiarán las técnicas que permiten encontrar tales algoritmos-.
55
56 ALGORITMOS EN C++
Recurrencias
Las definicionesrecursivas de funciones son muy frecuentes en matemáticas; el
tipo más simple, en el que intervienen argumentos enteros, se denomina rela-
ciones de recurrencia. Quizás la función más familiar de este tipo sea la función
factorial, definida por la fórmula
h
! = N . (N - l)!, para N 2 1 con O! = 1.
Esta definición se corresponde directamente con el siguiente programa recur-
sivo simple:
i n t f a c t o r i a l (int N )
r i f ( N == O) return 1;
return N * factorial(N-1);
1
Por una parte, este programa ilustra los aspectosbásicos de un programa recur-
sivo: se llama a sí mismo (con un valor más pequeño que su argumento) y tiene
una condición de terminación en la que calcula directamente su resultado. Por
otra parte, no se oculta el hecho de que este programa es tan sólo un bucle f o r
con adornos, por lo que dificilmente puede ser un ejemplo convincente del po-
der de la recursión. También es importante recordar que es un programa y no
una ecuación: por ejemplo, ni la ecuación ni el programa anterior «funcionan»
para un N negativo, pero los efectos negativos de esta omisión son quizá más
perceptibles con el programa que con la ecuación. La llamada a f a c t o -
ri al (-1) se traduce en un bucle infinito recursivo; éste es, de hecho, un fallo
recurrente que puede aparecer de forma más sutil en programas recursivos más
complejos.
Una segunda relación de recurrencia muy conocida es la que define los nú-
meros de Fibonacci:
FN=FN-, +FN-2, para N > 2 con Fo = F, = 1.
Esto define la sucesión
1, 1, 2, 3, 5, 8, 13,21, 34, 55, 89, 144, 233, 377, 610, ...
De nuevo, la recurrencia se corresponde directamente con un programa recur-
sivo simple:
int fibonacci ( i n t N)
RECURSI~N 57
i f (N <= 1) return 1;
return fibonacci (N-1) + fibonacci (N-2);
{
1
Éste es un ejemplo todavía menos convincente de la «potencia»de la recursión;
en efecto, es un ejemplo convincente de que la recursión no debería utilizarse a
ciegas, ya que puede resultar dramáticamente ineficaz. El problema aquí es que
las llamadas recursivas indican que FN-I y FN-2deben calcularse independien-
temente, cuando, de hecho, lo natural sería utilizar FN-2(y FN-3)para calcular
FN-1. En realidad, es fácil calcular cuál es el número exacto de llamadas al pro-
cedimiento f ibonacci anterior que se necesitan para calcular FN:el número de
llamadas necesarias para calcular FNes el número de llamadas necesarias para
calcular FN-I más el número de llamadas necesarias para calcular FN-~,
a me-
nos que N = O o N = 1, en cuyo caso sólo se necesita una llamada. Pero esto
encaja exactamente con la relación de recurrencia que define los números de
Fibonacci: el número de llamadas a f ibonacci para calcular FN.Se sabe que
FNes aproximadamente 8,
donde @ = 1,61803... es la «razón de oro»: la tre-
menda verdad es que jel programa anterior es un algoritmo de tiempo exponen-
cid para calcular los números de Fibonacci!
Por el contrario, es muy fácil calcular FNen tiempo lineal, como se indica a
continuación:
const int rnax = 25;
i n t fibonacci (int N )
J
I
int i , F[max];
for (i = 2; i <= max; i++)
F[O] = 1; F[1] = 1;
F[i] = F[i-l] + F[i-2];
1 return FIN1;
Este programa calcula los primeros max números de Fibonacci, utilizando un
array de tamaño max. (Como los números crecen exponencialmente, max será
pequeño.)
De hecho, esta técnica de utilizar un array para almacenar resultados pre-
- -
vios es el método que suele elegirse para evaluar relaciones de recurrencia, ya
que permite resolver de manera eficaz y uniforme las ecuaciones más comple-
jas. Las relaciones de recurrencia aparecen frecuentemente cuando se trata de
determinar las características de rendimiento de programas recursivos y así se
verán varios ejemplos a lo largo de este libro. Por ejemplo, en el Capítulo 9 apa-
rece la ecuación:
1
C N = N - I + - 2 (Ck-l+CN-& paraN3 IconCo=l.
IQkGN
58 ALGORITMOS EN C+t
La manera más fácil de calcular el valor de CNes utilizar un array, como en el
programa anterior. En el Capítulo 9 se presentará cómo puede resolverse ma-
temáticamente esta fórmula y en el Capítulo 6 se tratarán otras recurrencias dis-
tintas que suelen presentarse en el análisis de algoritmos.
Así, con frecuencia la relación entre programas recursivos y funciones defi-
nidas recursivamentees más filosóficaque práctica. Hablando estrictamente,los
problemas antes indicados no se asocian con el concepto de recursión en sí
mismo, sino con la implementación: un compilador (muy inteligente) puede
descubrir que la función fa c t o r i a l podría en realidad implementarse con un
bucle y que la función Fibonacci se emplea mejor cuando se almacenan todos
los valores precalculados en un array. Posteriormente se examinarán con más
detalle los mecanismos de implementación de programas recursivos.
Divide y vencerás
La mayor parte de los programas recursivos que se consideran en este libro uti-
lizan dos llamadas recursivas,y cada una opera sobre aproximadamente la mi-
tad de los datos de entrada. Éste es el paradigma de diseño de algoritmos de-
nominado «divide y vencerás)), que se emplea a menudo para obtener
importantes mejoras. Los programas del tipo de divide y vencerás normalmente
no se reducen a bucles triviales,como el programa anterior que calculaba el fac-
torial, porque tienen dos llamadas recursivas y por lo regular no obligan a rea-
lizar un número excesivo de cálculos, como en el programa para los números
de Fibonacci, expuesto anteriormente, porque los datos de entrada se dividen
sin recubrimientos.
Como ejemplo, considéresela tarea de trazar las marcas de las pulgadas de
una regla: existe una marca en el punto 1/2”, marcas algo más cortas en los in-
tervalos de 1/4”, marcas todavía más cortas en los intervalos de 1/8”, etc., como
se muestra (de forma ampliada) en la Figura 5.1. Éste es un prototipo de los
cálculos sencillos que realiza el método de divide y vencerás; posteriormente se
verá que hay muchas formas de llevar a cebo esta tarea.
Si la resolución deseada es 1/2’”, se efectúa un cambio de escala de manera
que la tarea sea poner una marca en cada punto entre O y 2”, sin incluir los pun-
tos extremos. Se supone la existenciade un procedimiento marcar (x , h) para
hacer una marca de h unidades de altura en la posición x. L a marca central debe
Figura 5.1 Una regla.
RECURSI~N 59
ser de n unidades de altura, las situadas en medio de las partes izquierda y de-
recha deben ser de n - l unidades de altura, etc. El siguiente programa recur-
sivo de ((dividey vencerás))constituye una forma directa de lograr el objetivo:
regla ( i n t izq, i n t der, int h)
i n t m = (izq+der)/2;
i f (h > O)
{
marcar(m, h);
regl a ( izq, m, h-1) ;
regla(m, d e r , h-1);
1
}
Por ejemplo, la llamada a regl a (O ,64,6) producirá la Figura 5.1, con la escala
apropiada. El método se basa en la siguiente idea: para hacer las marcas de un
intervalo, se comienza por la más grande, justo en el medio. Esto divide el in-
tervalo en dos partes iguales. A continuación se hacen las marcas (más cortas)
de cada mitad, utilizando el mismo procedimiento.
Normalmente conviene prestar especial atención a la condición de termi-
nación de un programa recursivo; ya que de otra manera ipuede que no termine
nunca! En el programa anterior, la instrucción regl a termina (no se llama más
a sí misma) cuando la altura de las marcas que quedan por hacer es O. La Figura
5.2 muestra el proceso con detalle, y proporciona la lista de las llamadas al pro-
cedimiento y las marcas que resultan al llamar a regl a (O ,8,3). Se colocauna
marca en el medio y se llama regl a para la mitad izquierda, repitiendo sucesi-
vamente el proceso hasta que la altura de las marcas sea O. Para concluir se
vuelve a llamar a regl a para hacer las marcas de la mitad derecha de la misma
manera.
En este problema no es particularmente importante el orden en el que se
dibujen las marcas. Se podría haber puesto también la llamada a marcar entre
las dos llamadas recursivas, en cuyo caso los puntos del ejemplo se trazarían
simplemente en el orden de izquierda a derecha, como muestra la Figura 5.3.
El conjunto de marcas dibujadas por estos dos procedimientos es el mismo,
pero el orden es bastante diferente. Esta diferenciapuede explicarse mediante el
árbol del diagrama de la Figura 5.4. Este diagrama tiene un nodo para cada lla-
mada a regla, con los parámetros utilizados en ella etiquetados; los hijos de
cada uno de ellos corresponden a las llamadas (recursivas)a regl a, junto con
sus parámetros correspondientes. Un árbol de este tipo permite ilustrar las ca-
racterísticasdinámicas de cualquier conjunto de procedimientos. La Figura 5.2
corresponde al recomdo de este árbol en preorden (la «visita» a un nodo co-
rresponde a hacer la correspondiente llamada a marcar); la Figura 5.3 corres-
ponde al recomdo en orden simétrico.
60 ALGORITMOS EN C++
regl a(O ,8,3)
marcar(4,3)
regla(0,4,2)
marcar (2,2)
regl a( 0,2,1)
marcar(1,l)
regl a( O ,1 ,O)
regl a( 1,2,0)
marcar(3,l)
regl a( 2,3 ,O)
regl a( 3,4,0)
regl a(2,4,1)
regl a(4,8,2)
marcar (6,2)
regla(4,6,l)
marcar(5,l)
regla(4,5,O)
regl a( 5,6,0)
marcar (7,1)
regl a( 6,7 ,O)
regl a( 6,8,1) ~~
i l l i l l
Figura 5.2 Dibujo de una regla.
En general, los algoritmos del tipo divide y vencerás incluyen algún trata-
miento para dividir los datos de entrada en dos partes, o para mezclar los resul-
tados del proceso de la «resolución» de dos partes independientes de datos de
entrada, o para hacer una rehabilitación después de que se haya procesado la
mitad de los datos de entrada. Es decir, se pueden encontrar instrucciones an-
tes, después o entre las dos llamadas recursivas. Más adelante se verán muchos
ejemplos de este tipo de algoritmos, en especial en los Capítulos 9, 12, 27, 28 y
41. También se encontrarán algoritmos en los que no será posible aplicar com-
pletamente el método de divide y vencerás. Esto ocurre, por ejemplo, cuando
los datos de entrada están divididos en dos partes desiguales, o están divididos
en más de dos partes, o existe algún solapamiento entre las partes.
También es fácil desarrollar algoritmos no recursivospara esta tarea. El mé-
todo más directo consiste simplemente en dibujar las marcas en orden, como
en la Figura 5.3, pero con el bucle directo for (i = 1; i < N; i++) marcar
(i, altura(i));. Senecesitala función altura(i) que noesdifícildecal-
cular: es el número de bits consecutivos al final de la representación binaria de
i. Se deja como ejercicio para el lector la implementación de esta función en
RECURSI~N 61
regla
( 0,8,3)
regla(O,4,2)
regla(O,2,1)
regla
( O,2,l)
regla(0,l ,O)
marcar( 1,l)
regla
( 1,2,O)
marcar(2,2)
regla( 2,4,1)
regla( 2,3,O)
marcar(3,l)
regla( 3,4,0)
marcar (4,3)
regla
( 4,8,2)
regla(4,6 ,1)
regla(4 ,5,O)
marcar(5,1)
regla
( 5,6,O)
marcar (6,2)
regla(6,8
,1)
regla
( 6,7,O)
marcar(7,l)
1
Figura 5.3 Dibujo de una regla (versiónen orden sirnetrico).
C++. De hecho, es posible obtener este método directamente a partir de la ver-
sión recursiva mediante un proceso laborioso de {{eliminaciónde la recursión»,
que se examinará con detalle más adelante, para otro problema.
Otro algoritmo no recursivo, que no corresponde a ninguna implementa-
ción recursiva, consisteen dibujar primero las marcas más cortas, luego las más
Figura 5.4 Árbol de llamada recursiva para dibujar una regla.
62 ALGORITMOS EN C++
cortas de las restantes y así sucesivamente, como lo hace el pequeño programa
siguiente:
regla (int izq, int der, int h)
int i,j, t;
for (i= 1, j = 1; i <=h; i++, j+=j)
for (t = O; t <= (izq+der)/j; t++)
{
marcar(izq+j+t*(j+j) , i);
}
La Figura 5.5 muestra cómo dibuja las marcas este programa. El proceso co-
rresponde a recorrer el árbol de la Figura 5.4 en orden de nivel (de abajo hacia
arriba), pero no es recursivo.
Este proceso corresponde ai método general de diseño de algoritmos en el
que se resuelve un problema tratando primero subproblemas triviales y combi-
nando después las solucionespara resolver subproblemas ligeramente más gran-
des, y así sucesivamente hasta la resolución de todo el problema. Esta manera
I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l
I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I , I I I I I I I
I I l I I I I / / I / I I I l I l I l 1 l l I / I I l l I I I I l l l l I l l l l l l l I I I I I I I I I I I I I I I I
l l l l l I I / I I l I I I I I I i l l l / I I I I I I I I I I / l l I I l l l I I / l l / I I / I I I I I / l / I l I I I
l l I l l ~ l l I 1 l l l / l l l / l l l l l l l l l l l / l l I l l l l l l l 1 l l l l / l l l / l l i l l l l / l l l l l
Figura 5.5 Dibujo no recursivode una regla.
RECURSI~N 63
de trabajar podría denominarse«combinay vencerás)). Mientras que todo pro-
grama recursivo admite una implementación no recursiva equivalente, no
siempre es posible volver a ordenar los cálculos de esta forma -muchos pro-
gramas recursivos dependendel orden específico en el que se resuelven los sub-
problemas-. Ésta es una aproximación ascendente, opuesta al método de di-
vide y vencerás, en el que el orden de resolución es descendente. A lo largo del
libro se encontrarán varios ejemplos de esto: el más importante en el Capítulo
12. En el Capítulo 42 se presenta una generalización del método.
Se ha examinadocon detalle el ejemplo de dibujar una regla porque ilustra
las propiedadesesenciales de los algoritmosprácticos con una estructura similar
a la de aquellos que se encontrarán más adelante. Con la recursión está justifi-
cado el estudio en profundidad de ejemplos simples, porque no es fácil saber
cuándo se ha cruzadola frontera entre lo muy simple y lo muy complicado. La
Figura 5.6 muestra un modelo bidimensional que ilustra cómo una descripción
recursiva simple puede conducir a cálculos bastante complejos. El modelo de la
izquierda tiene una estructura en la que se reconoce fácilmente su carácter re-
cursivo, mientras que el modelo de la derecha parece más complicado si apa-
rece en solitario, sin la compañía del primero. El programa que genera el mo-
delo de la izquierda es, en realidad, una ligera generalización de reg1 a:
estrella(int x, int y, int r)
estre1 1a(x-r ,y+r ,r/2) ;
estrella(x+r,y+r ,r/2);
estrel 1 a(x-r,y-r, r/2) ;
estrella(x+r,y-r,r72);
cuadrado(x ,y,r);
1
La primitiva de dibujo que se utiliza es simplemente un programa que dibuja
un cuadradode tamqño 2r y de.centro (x,y). Así, el modelo de la izquierda de
la Figura 5.6 se genera de manera simple con un programa recursivo -el lector
se puede entretener intentando encontrar un método recursivo para dibujar el
contorno del modelo de la derecha-. El modelo de la izquierda también es fá-
cil de generar con un método ascendente como el que se representa en la Figura
5.5: se dibujan los cuadrados más pequeños, después los más pequeños de los
restantes, etc. También puede ser interesante intentar encontrar un método no
recursivo para dibujar el contorno.
Los modelos geométricosdefinidos recursivamente como los de la Figura 5.6
se denominan a vecesfractales. Si se utilizan primitivas de dibujos más comple-
jos e invocaciones recursivas más complicadas (en especial con funciones defi-
64 ALGORITMOS EN C++
nidas recursivamente en los planos real y complejo), se pueden desarrollar mo-
delos de gran complejidad y diversidad.
Recorrido recursivo de un árbol
Como se indicó en el Capítulo 4,el método más simple para recorrer los nodos
de un árbol es probablemente con una implementación recursiva. Por ejemplo,
el siguiente programa visita los nodos de un árbol binario en orden.
recorrer (struct nodo *t)
i f ( t != z)
{
recorrer ( t - > i z q ) ;
visitar ( t ) ;
recorrer (t->der);
{
}
}
La implementación refleja de forma precisa la definición del orden simétrico:
«si el árbol no está vacío, recorrer primero el subárbol izquierdo, visitar la raíz
y después recorrer el subárbol derecho». Evidentemente, el recorrido en orden
previo puede implementarse poniendo la llamada a vi si tar antes de las dos
llamadas recursivas,y el recomdo en orden posterior se puede implementar po-
niendo la llamada a vi s i tar después de las dos llamadas recursivas.
Figura 5.6 Una estrella fractal, dibujada con cuadrados (izquierda)
y sólo con contornos (derecha).
RECURSI~N 65
Esta implementación recursiva del recorrido del árbol surge de una forma
más natural que una implementación basada en una pila, ya que los árbolesson
estructuras definidas recursivamente y porque los recomdos en orden previo,
en orden y en orden posterior son procesos definidos recursivamente. En con-
traste, se observa que no existe una forma adecuada de implementar un proce-
dimiento recursivo para el recomdo en orden de nivel: la misma naturaleza de
la recursión obliga a que los subárboles se procesen como unidades indepen-
dientes, mientras que el orden de nivel necesita que los nodos de diferentes su-
bárboles se mezclen entre ellos. Se volverá sobre este punto en los Capítulos 29
y 30, cuando se consideren los algoritmos de recomdos de grafos, que son es-
tructuras mucho más complicadas que los árboles.
Unas simples modificacionesal programa recursivo anterior y la implemen-
tación apropiada de vi sit a r pueden dar lugar a programas que calculen diver-
sas propiedades de los árboles binarios de las figuras de este libro. Supóngase
que el registro de los nodos incluye dos campos enteros para las coordenadas x
y y de cada nodo en la página. (Para evitar detalles de escala y traslación, se
supone que son coordenadas relativas: si el árbol tiene N nodos y es de altura h,
la coordenada x va de izquierda a derecha desde 1 a N, y la coordenada y va
desde amba hacia abajo desde 1 a h.) El siguiente programa rellena estos cam-
pos con los valores apropiados para cada nodo:
v i s i t a r ( s t r u c t nodo *t)
recorrer ( s t r u c t nodo *t)
{ t - > x = ++x; t - > y = y; }
Y++;
i f (t != z)
recorrer ( t - > i z q ) ;
v i s i t a r ( t ) ;
recorrer (t->der)
{
1
Y--;
1
El programa utiliza dos variables globales, x y y, que se suponen inicializadas a
O. La vanable x sigue la pista del número de los nodos que se han visitado en
orden; la variable y sigue la altura del árbol. Cada vez que recorrer desciende
por el árbol, esta variable se incrementa en una unidad, y cada vez que asciende
por el árbol, se disminuye también en una unidad.
De forma similar se podrían implementar programas recursivos para calcu-
lar la longitud del camino de un árbol, para encontrar otra forma de dibujar un
árbol o para evaluar una expresión que él representa, etcétera.
66 ALGORITMOS EN C+t
Eliminación de la recursión
Pero, jcuál es la relación entre la implementaciónanterior (recursiva)y la im-
plementación del Capítulo 4 (no recursiva) para el recomdo del árbol? Sin duda
estos dos programas están fuertemente relacionados, ya que, para todo árbol
dado, producen precisamente la misma serie de llamadas a vi s i tar. En esta
sección se estudia esta cuestión de forma detallada mediante la eliminación
«mecánica»de la recursión del programa de recomdo en orden previo dado an-
teriormente para conseguir una implementaciónno recursiva.
Ésta es la misma operación con la que se enfrenta un compilador cuando
tiene que traducir un programa recursivo a lenguaje de máquina. El objetivo
principal de este apartado no es estudiar las técnicas de compilación (aunque se
obtengan algunosconocimientos sobre los problemas con los que se enfrenta un
compilador),sino más bien la relación entre las implementaciones recursivas y
no recursivas de los algoritmos. Este tema se planteará nuevamente a lo largo
del libro.
Para empezar se comienza con una implementaciónrecursiva de recomdo
en orden previo, exactamentecomo la antes descrita:
recorrer (struct nodo *t)
i f (t != z)
{
visitar ( t ) ;
recorrer (t->izq);
recorrer (t->der);
{
1
1
La segunda llamada recursiva no va seguida de ninguna instrucción,por lo que
puede eliminarse fácilmente. Cuando se va a ejecutar la segunda llamada, se in-
voca a recorrer (con el argumento t - >der); al finalizar esta llamada, se ter-
mina también la invocación actual de recorrer. Pero esta misma secuencia de
acontecimientos se puede implementarcon un goto en lugar de utilizar una lla-
mada recursiva, como se indica a continuación:
recorrer (struct nodo *t)
izq: i f (t == z) goto x;
{
visitar ( t ) ;
recorrer (t->izq);
t= t->der;
RECURSIÓN 67
goto izq;
x: ;
Ésta es una técnica muy conocida, denominada eliminación de la recursiónfi-
nal, que se implementaen muchos compiladores. Los programas recursivos son
menos viables en los sistemas que no tienen esta capacidad, porque pueden apa-
recer aberraciones innecesarias y notables, tales como las producidas en las fun-
ciones factorial y fibonacci que se vieron anteriormente. En el Capítulo 9
se estudiará un ejemplo práctico importante.
La eliminación de la otra llamada recursiva requiere más trabajo. En gene-
ral, la mayoría de los compiladores producen un código de instrucciones que
sigue la misma secuencia de acciones en cualquier llamada de procedimiento:
«colocar los valores de las variables locales y la dirección de la siguienteinstruc-
ción en la pila, definir los valores de los parámetros del procedimiento e (ir) goto
al principio del procedimiento.» Entonces, cuando termina el procedimiento, se
debe «sacar de la pila los valores y la dirección de retorno de las variables loca-
les, inicializar las variables e (ir) goto a la dirección de retorno». Por supuesto,
las cosas son más complejas en la mayoría de las situaciones que encuentra un
verdadero compilador; no obstante, siguiendo en esta línea de trabajo, se puede
eliminar la segunda llamada recursiva del programa de la siguiente forma:
recorrer (struct nodo *t)
izq: if (t == z) goto s;
{
vi sitar (t) ;
pila.meter(t); t = t->izq; goto izq;
if (pila.vacia ( ) ) goto x;
t = pila-sacar ( ) ; goto der;
der: t = t->der; goto izq;
s:
x: ;
1
Existe sólo una variable local t,por lo que se le introduce en la pila y se efectúa
un goto al principio. Hay una única dirección de retorno der, que está fija, y
por tanto no se pone en la pila. Al final del procedimiento, se actualiza t a par-
tir del valor de la pila y se hace goto a la dirección de retorno der. Cuando la
pila esté vacía, se retorna desde la primera llamada a recorrer.
Ahora ya se ha eliminado la recursión, pero se ha obtenido un «pantano»de
gotos que integran un programa más bien opaco. Pero éstos se pueden eliminar
«mecánicamente» para obtener un código más estructurado. En primer lugar,
la parte de código comprendidaentre la etiqueta der y el segundo goto x está
rodeada de gotos y se puede mover fácilmente, y así se eliminan la etiqueta der
y el goto asociado. Además, se observa que se asigna el valor t a t- >der
68 ALGORITMOS EN C++
cuando se saca de la pila; sería mejor meter ese valor en la pila. Finalmente, las
instruccionescomprendidas entre la etiqueta x y el primer goto x no son más
que un bucle whi1e.Esto queda asi:
recorrer (struct nodo *t)
izq: while (t != z)
{
i
1
visitar (t);
Pila.meter(t->der); t = t->izq;
if’(pi1a.vacia 0 ) goto x;
t = pila.sacar ( ) ; goto izq;
x: ;
1
Ahora se tiene otro bucle, que se puede transformar en otro whi 1e añadiendo
un apilamiento extra (del argumento inicial t a la entrada de recorrer), que-
dando un programa sin goto:
recorrer (struct nodo *t)
pi 1a.meter(t) ;
while (!pila.vacia())
t = pila.sacar();
while (t != z)
{
{
visitar (t) ;
pi 1a.meter (t->izq) ;
t = t->l
{
Esta versión es el método de recomdo no recursivo «estándan>.Es un ejercicio
instructivo para olvidar por el momento cómo se obtuvo y para convencerse
directamente de que este programa hace el recorrido del árbol en orden previo
como se aconsejó.
En realidad, la estructura de este programa, con un bucle dentro de otro,
puede simplificarse(con el coste de algunas inserciones en la pila):
recorrer (struct nodo * t)
RECURSI~N 69
pi 1 a.meter (t) ;
while (!pila.vacia())
t = pila.sacar();
if (t != z)
{
{
visitar (t) ;
pila.meter (t->der);
pi 1 a.meter (t-> i zq) ;
{
Este programa tiene un parecido notable con el algoritmo recursivo original en
orden previo, aunque en realidad los dos programas son completamentedife-
rentes. Una primera diferencia es que este programa puede ejecutarse en prác-
ticamente cualquier entorno de programación, mientras que, claro está, la im-
plementación recursiva necesita un entorno que dé cabida a la recursión. Incluso
en tal entomo, es probable que este método basado en pila sea algo más eficaz.
Finalmente, se observa que este programa coloca en la pila subárboles va-
cíos, como consecuencia de la decisión tomada en la implementación original
de verificar que el subárbol no esté vacío como primera acción del procedi-
miento recursivo. La implementación recursiva podría hacer la llamada recur-
siva sólo para los subárbolesno vacíos verificando t-> i zq y t-> der.Reflejar
este cambio en el programa anterior conduce al algoritmo basado en pila para
el recorrido en orden previo del Capítulo 4.
recorrer (struct nodo *t)
pi 1 a.meter (t) ;
while (!pila.vacia ( ) ) 
,
{
t = pila.sacar(); visitar (t);
if (t->der != z) pi 1 a.meter (t->der) ;
if (t->izq != z) pila.meter (t->izq);
1
Cualquieralgoritmorecursivo puede manipularse de la manera precedente para
eliminar la recursión; desde luego, ésta es la tarea principal del compilador. La
eliminación «manual» de la recursión que se acaba de describir, aunque es
70 ALGORITMOS EN C++
complicada, conducefrecuentementea una implementaciónno recursiva eficaz
y a un mejor entendimiento de la naturaleza de la operación.
Perspectiva
Seguramente es imposible hacer justicia a un tema tan fundamental como la
recursión en una exposición tan breve. A lo largo del libro aparecen muchos de
los mejores ejemplos de programas recursivos -se han ideado algoritmos del
tipo de divide y vencerás para una amplia variedad de problemas-. En muchas
aplicaciones no hay razón para ir más allá de una implementaciónrecursiva,
directa y simple; en otras, en cambio, se considerará la posibilidad de eliminar
la recursión como se describió en este capítulo o se intentará obtener alguna al-
ternativa de implementacionesno recursivas directamente.
La recursión está en el corazón de los primeros estudios teóricos sobre la
verdadera naturaleza del cálculo informático.Los programas y funciones recur-
sivos desempeñan un papel central en los estudios matemáticos que intentan
separar los problemas que se pueden resolver mediante una computadorade los
que no se pueden.
En el Capítulo 44 se estudiará la utilización de programas recursivos (y otras
técnicas) para resolver problemas dificiles, en los que debe examinarse un gran
número de posibles soluciones. Como se verá, la programación recursiva puede
ser un medio bastante eficaz para organizar una búsqueda compleja en el con-
junto de soluciones posibles.
Ejercicios
1. Escribir un programa recursivo para dibujar un árbol binario de manera que
la raíz aparezca en el centro de la página, la raíz del subárbol izquierdo esté
en el centro de la mitad izquierda de la página, etcétera.
2. Escribir un programa recursivo para calcular la longitud del camino ex-
terno de un árbol binano.
3. Escribir un programa recursivo para calcular la longitud del camino ex-
terno de un árbol representado como un árbol binario.
4. Obtener las coordenadas generadas cuando se aplica el procedimiento re-
cursivo de dibujo del árbol dado en el texto al árbol binario de la Figura
4.2.
5. Eliminar mecánicamente la recursión del programa f ibonacci dado en el
texto, para obtener una implementaciónno recursiva.
6. Eliminar mecánicamentela recursión del algoritmo recursivo de recorrido
del árbol en orden,para obtener una implementaciónno recursiva.
RECURSICIN 71
7.
8.
9.
10.
Eliminar mecánicamentela recursión del algoritmo recursivo de recorrido
del árbol en ordenposteriorpara obtener una implementaciónno recursiva.
Escribir un programa recursivo del tipo «divide y vencerás)) para dibujar
una aproximación del segmento que conecta dos puntos (XI, VI) y (x2, y2),
dibujando sólo los puntos que utilizan coordenadas enteras. (Pista: dibujar
primero un punto próximo a la mitad.)
Escribir un programa recursivo para resolver el problema de Josefo (verCa-
pítulo 3).
Escribir una implementaciónrecursiva del algoritmo de Euclides (ver Ca-
pítulo 2).
Algoritmos en C++.pdf
6
Análisis de algoritmos
Para la mayoría de los problemas existen varios algoritmos diferentes. ¿Cómo
elegir uno que conduzca a la mejor implementación? Esta cuestión constituye
actualmente un área de estudio muy desarrollada de la informática. Con fre-
cuencia se tendrá la oportuiiidad de investigar los resultados que describen el
comportamiento de algoritmos fundamentales. De cualquier forma, la compa-
ración de algoritmos puede ser un desafío, por lo que serán útiles ciertas pautas
generales.
Normalmente los problemas a resolver tienen un <ctamaño»natural (en ge-
neral, la cantidad de datos a procesar), al que se denominará N y en función del
cual se tratará de describir los recursos utilizados (con frecuencia, la cantidad
de tiempo empleado). El punto de interés es el estudio del caso medio, es decir,
el tiempo de ejecución de un conjunto «tipo» de datos de entrada, y el del peor
caso, el tiempo de ejecución para la configuración de datos de entrada más des-
favorable.
Algunos de los algoritmos de este libro se entienden muy bien, hasta el punto
de que se conocen las fórmulas matemáticas precisas para averiguar el tiempo
de ejecución medio y el tiempo de ejecución del peor caso. Estas fórmulas se
obtienen estudiando cuidadosamente el programa, para encontrar el tiempo de
ejecuciónen términos de expresionesmatemáticas fundamentales y a continua-
ción hacer un análisis matemático de las cantidades implicadas. Por otra parte,
las propiedades del rendimiento de otros algoritmos de este libro son totalmente
desconocidas -quizás porque su análisis conduce a cuestiones matemáticas no
resueltas, o porque se sabe que las implementaciones son demasiado complejas
para analizarlas al detalle de una manera razonable, o (la mayoría de las veces)
porque los tipos de entrada que se encuentran no pueden caracterizarseadecua-
damente-. L
a mayoría de los algoritmoscaen entre estos extremos: se conocen
algunos hechos sobre su rendimiento, pero en realidad no se han llegado a ana-
lizar por completo.
Varios de los factoresimportantes que entran en este análisishabitualmente
no son competencia del programador. En primer lugar, los programas en C++
73
74 ALGORITMOS ENC++
-
se traducen a código de máquina para una computadora dada, y puede ser una
tarea dificil el averiguar exactamentecuánto se tarda en tratar incluso una sola
sentencia de C++ (especialmenteen un entorno donde se comparten los recur-
sos de modo que el mismo programa pueda tener distintas características de
rendimiento). En segundo lugar, muchos programas son excesivamente sensi-
bles a sus datos de entrada, y su rendimiento podría fluctuar enormemente se-
gún sean éstos. El caso medio podría ser una ficción matemática que no es re-
presentativa de los datos reales que utiliza cada programa, y el peor caso podría
ser una construcción rara que nunca ocumría en la práctica. En tercer lugar,
muchos programas de interés no se entienden bien, y puede que no proporcio-
nen los resultados matemáticos específicos. Por último, es frecuente el caso en
el que los programas no sean comparables en absoluto: uno es mucho más rá-
pido que otro para un tipo particular de entrada, y el otro lo es bajo otras cir-
cunstancias.
A pesar de los comentarios anteriores, a menudo es posible predecir con
exactitud el tiempo de ejecución de un programa particular o saber que un pro-
grama funcionará mejor que otro en situaciones concretas. El objetivo del ana-
lista de algoritmos es descubrir tanta información como sea posible sobre el de-
sarrollo de los algoritmos; la tarea del programador es aplicar esta información
para seleccionar los algoritmos para cada aplicación en particular. En este ca-
pítulo, el centro de atención será el mundo más bien idealizado del analista; en
el siguiente se estudiarán consideraciones prácticas de implementación.
Marco de referencia
El primer paso del análisis de un algoritmo es establecer las características de
los datos de entrada que utilizará y decidir cuál es el tipo de análisis más apro-
piado. Idealmente, sena deseable poder obtener, para cualquier distribución de
probabilidad de las posibles entradas, la correspondiente distribución de los
tiempos empleados en la ejecución del algoritmo. Desgraciadamente no es po-
sible alcanzar este ideal para un algoritmo que no sea trivial, de manera que,
por lo regular, se limita el desarrollo estadísticointentando probar que el tiempo
de ejecución es siempre menor que algún «límite superion) sea cual sea la en-
trada, e intentando obtener el tiempo de ejecución medio para su entrada «alea-
torim.
El segundo paso del análisis de un algoritmo es identificar las operaciones
abstractas en las que se basa, con el fin de separar el análisis de la implementa-
ción. Así, por ejemplo, se separa el estudio del número de comparaciones que
realiza un algoritmo de ordenación del estudio para determinar cuántos micro-
segundos tarda una computadora concreta en ejecutar un código de máquina
cualquiera producido por un compiladordeterminado para el fragmento de có-
digo if (a[i] < v) ....Ambos casos se necesitan para determinar cuál es el
tiempo de ejecución real del programa en una computadora en particular. El
ANÁLISIS DE ALGORITMOS 75
primero dependerá de las propiedades del algoritmo, mientras que el segundo
dependerá de las propiedades de la computadora.Esta separación permite a me-
nudo comparar algoritmos, independientementede las implementaciones par-
ticulares o de las computadorasque se puedan utilizar.
Mientras que el número de operaciones abstractas implicadas puede ser, en
principio, grande, normalmente se da el caso de que el desarrollo de los algont-
mos que se consideran depende sólo de unas cuantas cantidades. En general, es
fácil identificar las cantidades significativas para un programa en particular
-una forma de hacerlo consiste en utilizar una opción de «detección de perfi-
les» (disponible en muchas implementacionesde C++)
para realizar estadísticas
de la frecuencia de llamada de una instrucción en algún ejemplo de ejecu-
ción-. En este libro, el interés se centra en las cantidades de ese tipo que sean
importantes para cada programa.
El tercer paso del análisis de un algoritmo es analizarlo matemáticamente,
con el fin de encontrar los valores del caso medio y del peor caso para cada una
de las cantidades fundamentales. No es dificil encontrar un límite superior del
tiempo de ejecución de un programa -el reto es encontrar el mejor límite su-
perior, aquel que se encontraría si se diera la peor entrada posible-. Esto pro-
duce el peor caso: el caso medio normalmente requiere un análisis matemático
más sofisticado. Una vez desarrollados con éxito tales análisis para las cantida-
des fundamentales, se puede determinar el tiempo asociado a cada cantidad y
obtener expresiones para el tiempo total de ejecución.
En principio, el rendimiento de un algoritmo se puede analizar a menudo
con un nivel de precisión detallado, limitado sólo por la incertidumbre sobre el
rendimiento de la computadora o por la dificultad de determinar las propieda-
des matemáticasde algunas de las cantidades abstractas. Sin embargo, rara vez
será útil hacer un análisis detallado completo, de manera que siempre será pre-
ferible una estimaciónmejor que un cálculo preciso. (En realidad, las estimacio-
nes que aparentemente son sólo aproximadas, a menudo resultan ser bastante
precisas.) Tales estimacionesaproximadas se obtienen fácilmente por medio del
viejo refrán del programador: «el 90 Yo del tiempo se emplea en el 10Yo de la
codificación.» (En el pasado ya se decía esto pero con otros valores diferentes
del a90 %».)
El análisis de un algoritmo es un proceso cíclico, estimándolo y refinándolo
hasta que se alcanza una respuesta al nivel de precisión deseado. Realmente,
como se estudiará en el siguiente capítulo, el proceso también debería incluir
mejoras en la implementacióny desde luego el análisis sugiere a menudo tales
mejoras.
Teniendo en cuenta estas advertencias, el modo de proceder será buscar es-
timaciones aproximadas del tiempo de ejecución de los programas con el fin de
clasificarlos, sabiendo que se puede hacer un análisis más completo para pro-
gramas importantes cuando sea necesario.
76 ALGORITMOS EN C++
Clasificaciónde los algoritmos
Como se mencionó antes, la mayoría de los algoritmos tienen un parámetro
primario N, normalmente el número de elementos de datos a procesar, que
afecta muy significativamente al tiempo de ejecución. El parámetro N podría
ser el grado de un polinomio, el tamaño de un archivo a ordenar o en el que se
va a realizar una búsqueda, el número de nodos de un grafo, etc. Prácticamente
todos los algoritmos de este libro tienen un tiempo de ejecución proporcional a
una de las siguientesfunciones:
I La mayor parte de las instrucciones de la mayoría de los programas
se ejecutan una vez o muy pocas veces. Si todas las instrucciones de
un programa tienen esta propiedad, se dice que su tiempo de ejecu-
ción es constante. Obviamente, esto es lo que se persigue en el diseño
de algoritmos.
logN Cuando el tiempo de ejecución de un programa es Zogaritrnico, éste
será ligeramente más lento a medida que crezca N. Este tiempo de
ejecución es normal en programas que resuelven un problema de gran
tamaño transformándolo en uno más pequeño, dividiéndolo me-
diante alguna fracción constante. Para lo que aquí interesa, el tiempo
de ejecución puede considerarsemenor que una «gran»constante. La
base del logaritmo cambia la constante, pero no mucho: cuando N
vale mil, si la base es 10, logN es 3 y si la base es 2 es aproximada-
mente 10; cuando N vale un millón, ZogN se multiplica por dos.
Cuando se dobla N, logN crece de forma constante, pero no se du-
plica hasta que N llegue a N2.
N Cuando el tiempo de ejecución de un programa es lineal, eso significa
generalmente que para cada elemento de entrada se realiza una pe-
queña cantidad de procesos. Cuando N vale un millón, este valor es
también el del tiempo de ejecución, que se duplica ai hacerlo N. Ésta
es la situación ideal para un algoritmo que debe procesar N entradas
(u obtener N salidas).
N logN Este tiempo de ejecución es el de los algoritmos que resuelven un
problema dividiéndolo en pequeños subproblemas, resolviéndolosin-
dependientemente, y combinando después las soluciones.Ante la falta
de un adjetivo mejor (¿lineal-aritrnético.~,
se dice que el tiempo de
ejecución de tal algoritmo es «MogN». Cuando N vale un millón
MogN es aproximadamente veinte millones. Cuando se duplica N, el
tiempo de ejecución es más del doble (aunque no mucho más).
N2 Cuando el tiempo de ejecución de un algoritmo es cuadrútico, sólo es
práctico para problemas relativamente pequeños. El tiempo de eje-
cución cuadrático normalmente aparece en algoritmos que procesan
ANÁLISIS DE ALGORITMOS 77
pares de elementos de datos (por ejemplo, en un bucle anidado do-
ble). Cuando N vale mil, el tiempo de ejecución es un millón. Cuando
N se dobla, el tiempo de ejecución se multiplica por cuatro.
N3 De igual manera, un algoritmo que procesa trios de elementos de da-
tos (por ejemplo, en un bucle anidado triple) tiene un tiempo de eje-
cución cúbico y no es útil más que en problemas pequeños. Cuando
N vale cien, el tiempo de ejecución es un millón. Cuando N se du-
plica, el tiempo de ejecución se multiplica por ocho.
2N Pocos algoritmos con un tiempo de ejecución exponencial son sus-
ceptiblesde poder ser útiles en la práctica, aunque aparecen de forma
natural al aplicar el método de la «fuerza bruta)) en la resolución de
problemas. Cuando N vale veinte, el tiempo de ejecución es un mi-
llón. Cuando N dobla su valor, jel tiempo de ejecución se eleva al
cuadrado!
El tiempo de ejecución de un programa particular es probablemente igual a
alguna constante multiplicada por uno de sus términos (el «término principal)))
más algunos términos más pequeños. Los valores del coeficienteconstante y de
los términos incluidos dependen de los resultados del análisis y de los detalles
de la implementación. De forma esquemática, el coeficiente del término prin-
cipal está condicionado por el número de instrucciones que hay en el bucle in-
terno: en cualquier nivel del diseño del algoritmo, es prudente limitar el nú-
mero de estas instrucciones. Para grandes valores de N se impone el efecto del
término principal; para pequeños valores de N o para algoritmos diseñados mi-
nuciosamente pueden contribuir más términos, y la comparación de algoritmos
es más difícil.En la mayoría de los casos, simplemente se dice que el tiempo de
ejecución de los programas es «lineal», (dvlogN»,«cúbico», etc., entendiéndose
implícitamente que en los casos donde sea muy importante la eficacia, debe rea-
lizarse un análisis más detallado o un estudio empírico.
A veces surgen otras funciones. Por ejemplo, un algoritmo con N2 entradas
que tiene un tiempo de ejecución cúbico en N es más adecuado clasificarlo como
un algoritmo de tiempo de ejecución N3/2.También, algunos algoritmos tienen
dos etapas de descomposición en subproblemas, lo que se traduce en un tiempo
de ejecución proporcional a Mog2N.Ambas funcionesse aproximan mucho más
a M o o que a N2,para valores grandes de N.
A continuación se ampliará lo antes dicho sobre la función dog». Como se
mencionó, la base del logaritmo hace cambiar los cálculos de forma constante.
Como frecuentemente se trata sólo con resultados analíticos con un factor cons-
tante, no importa mucho cuál es la base, por lo que se dice simplemente dogNn,
etc. Por otro lado, algunas veces se dará el caso de que los conceptos se expli-
carán más claramente si se utiliza alguna base específica. En matemáticas, el lo-
garitmo natural (o neperiano en base e = 2.7 18281828...) aparece con tanta fre-
cuencia que lo normal es utilizar una abreviatura especial: lo&N 1nN. En
informática, el logaritmo binario (base 2) aparece tan a menudo que se utiliza
70 ALGORITMOS EN C++
1
g
N lg2N fi N MgN Mg2N p i 2 N2
3 9 3 10 30 90 30 100
6 36 10 1O
0 600 3.600 1.oao 1o.Ooo
9 81 31 1.o00 9.000 81.o00 31.000 1.000.000
13 169 100 10.000 130.000 1.690.000 1.000.000 1 ~ . ~ . 0 0 0
16 256 316 100.000 1.600.000 25.600.000 31.600.000 10mil d o n e s
19 361 1.O00 1.OOO.OOO 19.000.000 361.OOO.OOO mil millones un bilión
Figura6.1 Valores relativos aproximados de funciones.
la notación abreviada logzNrlgN.Por ejemplo, el mayor entero inferior a 1gN
indica el número de bits necesario para representar N en escritura binaria.
La Figura 6.1 indica el tamaño relativo de algunas de estas funciones: la tabla
proporcionalos valoresaproximadosde lgN,l8N, p,
N, MgN, Mg’N, lV3I2,
N2
para diferentes valores de N. La función cuadrática domina claramente, en es-
pecial para N grande, pero las diferencias entre las funciones más pequeñas no
son las que podría esperarse para un N pequeño. Por ejemplo, N3I2debería ser
mayor que Mg2Npara N muy grande, pero no para los valores más pequeños
que son 30s que podrían darse en la práctica. Se sobreentiende que no se da esta
tabla para que se haga una comparación lineal de las funciones para todos los
valores de N -números, tablas y grafos relativos a los algoritmos específicos
pueden hacer mejor esta tarea-, pero proporciona una primera aproximación
bastante realista.
Complejidaddel cálculo
Una técnica de aproximación al estudio del rendimiento de los algoritmos con-
siste en examinar el peor CQSO, sin tener en cuenta los factores constantes, con
el fin de determinar la dependencia funcional del tiempo de ejecución (o alguna
otra medida) del número de datos de entrada (o de alguna otra variable). Este
enfoque es interesanteporque permite demostrar propiedades matemáticas pre-
cisas sobre el tiempo de ejecución de los programas: por ejemplo, se puede afir-
mar que el tiempo de ejecución de una ordenación por mezcla (ver Capítulo 11)
esforzosamente proporcional a MogN.
El primer paso del proceso consiste en precisar matemáticamentelo que se
entiende por «proporcional am, lo que al mismo tiempo separará el análisis de
un algoritmo de cualquier implementación particular. La idea es ignorar los
factores constantes en el análisis: en la mayor parte de los casos, si se desea co-
nocer si el tiempo de ejecución de un algoritmo es proporcional a N o a 100,
no tiene importancia si el algoritmo se ejecutará en una microcomputadorao
en una supercomputadoray tampoco si el bucle interno se ha implementado
cuidadosamente, con sólo unas pocas instrucciones, o si por el contraria se ha
ANÁLISIS DE ALGORITMOS 79
hecho mal, con muchas instrucciones. Desde un punto de vista matemático, es-
tos dos factores son equivalentes.
El artificio matemáticoque permite precisar esta idea se denomina notación
O, o anotación O mayúscula), definida de la siguiente forma:
Notación. Se dice que unafunción g(N)pertenece a Of(N)) si existen las cons-
tantes coy NOtales que g(N) es menor que c&(N) para todo N > NO.
Informalmente, esto engloba la idea de «es proporcional a)y libera al analista
de considerar los detalles de las característicasparticulares de la máquina. Ade-
más, la afirmación de que el tiempo de ejecución de un algoritmo pertenece a
O(AN))es independiente de los datos de entrada del algoritmo. Como el interés
está en el estudio del algoritmo, no en sus datos de entrada o en la implemen-
tación, la notación O es una forma útil de encontrar un límite superior del
tiempo de ejecución, independientementede los detalles de la implementación
y de los datos de entrada.
La notación O ha sido de gran utilidad para los analistas, al ayudar a clasi-
ficar algoritmos por su tiempo de ejecución, y también para los diseñadores, al
orientar la búsqueda de los «mejores» algoritmos adecuados para problemas
importantes. La meta del estudio del cálculo de la complejidadde un algoritmo
es demostrar que su tiempo de ejecución pertenece a O(f(N))
para alguna
función f; y que no puede haber ningún algoritmo con tiempo de ejecución
en O(g(N))para cualquier función g(N) «inferion> (una función tal que
limN,, g(N)/ f(N)=O). Se trata de proporcionar un «límite superion) y un <dí-
mite infenom para el tiempo de ejecución del peor caso. El cálculo de los 1í-
mites superiores consiste normalmente en analizar y determinar la frecuencia
de las operaciones(se verán muchosejemplos en los capítulos siguientes),mien-
tras que el de los límites inferiores implica la dificil tarea de construir cuidado-
samente un modelo de máquina y determinar qué operaciones fundamentales
debe ejecutar cualquier algoritmo para resolver un problema (rara vez se tratará
este punto). Cuando los cálculos teóricos indiquen que el límite superior de un
algoritmo coincide con su límite inferior, entonces se tendrá la seguridad de que
es inútil tratar de disefiar un algoritmo fundamentalmente más rápido y, por
tanto, se puede dedicar toda la atención a la implementación. Este punto de vista
ha resultado ser muy útil en el diseno de algoritmos en los últimos años.
Sin embargo, al utilizar la notación O hay que ser extremadamentecuida-
doso al interpretar los resultados, al menos por cuatro razones: primera, es un
<&mitesuperion) y la cantidad en cuestión podría ser mucho menor; segunda,
podría ocumr que la entrada que provoca el peor caso no se dé nunca en la
práctica; tercera, no se conoce la constante co y no tiene por qué ser pequeña; y
cuarta, también se desconoce la constante NOy tampoco tiene por qué ser pe-
queña. A continuación se consideraránestas razones, una a continuación de otra.
La afirmación de que el tiempo de ejecución de un algoritmo pertenece a
o(f(N)) no implica que el algoritmo siempre tarde tanto: sólo dice que el ana-
80 ALGORITMOS EN C++
N ’I4Mg’N ‘/zMg2N Mg2N ~ 3 1 2
~~
10 22 45 90 30
1O0 900 1.800 3.600 1.000
I.o00 20.250 40.500 81.O00 31.o00
10.000 422.500 845.000 1.690.000 31.600.000
1.ooo.ooo 90.250.000 180.500.000 361.OOO.OOO 1.ooo.ooo.ooo
Figura 6.2 Importanciade los factores constantes en la comparaciónde funciones.
lista ha podido comprobar que nunca tardará más. El tiempo real de ejecución
podría ser siempre muy inferior. Se ha desarrollado la mejor notación para cu-
brir la situación en la que se sabeque existe alguna entrada para la cual el tiempo
de ejecución pertenece a O(AN)),pero hay muchos algoritmos para los que re-
sulta bastante complicado construir los datos de entrada del peor caso.
Aun cuando se conozca la entrada del peor caso, puede darse la situación en
que los datos de entrada que se encuentren realmente en la práctica tengan
tiempos de ejecución mucho más pequeños. Muchos algoritmos sumamente
útiles tienen un mal comportamiento en el peor caso. Por ejemplo, el que pro-
bablemente sea el algoritmo de ordenación más extendido, el Quicksort, tiene
un tiempo de ejecución en O(N2),pero es posible organizar los datos de forma
que el tiempo de ejecución para las entradas que se encuentran en la práctica
sea proporcional a MogN.
Las constantes co y No implícitas en la notación O a menudo ocultan deta-
lles de la implementación que son importantes en la práctica. Evidentemente,
decir que un algoritmo tiene un tiempo de ejecución en O(f(N))
no propor-
ciona ningún dato sobre el tiempo de ejecución si N es menor que NOy copu-
diera estar ocultando una gran cantidad de «valores superiores))diseñados para
evitar un peor caso malo. Es preferible un algoritmo que utilice N2 nanosegun-
dos a otro que utilice logN siglos, pero no se puede hacer esta elección basán-
dose en la notación O. La Figura 6.2 muestra la situación para dos funciones
típicas, con valores de las constantes más realistas, en el intervalo O 6 N 6
1.OOO.OOO. La función N3’2,que se podría haber consideradoerróneamente como
la mayor de las cuatro, ya que es asintóticamente la más grande, en realidad es
de las más pequeñas para pequeños valores de N, y es menor que Mg2Nhasta
que N valga unas decenas de miles. Los programas en los que los tiempos de
ejecución dependen de funciones de este tipo no se pueden comparar de forma
eficaz sin prestar una atención cuidadosa a los factores constantes y a los deta-
lles de la implementación.
Es conveniente pensar detenidamente dos veces antes de utilizar, por ejem-
plo, un algoritmo con tiempo de ejecución en O(N2)en lugar de uno en O(N),
pero tampoco se deben seguir ciegamente los resultados del cálculo de la com-
plejidad expresadosen notación O. En las implementaciones prácticas de los al-
goritmos considerados en este libro, la complejidad del cálculo es a veces de-
masiado general y la notación O demasiado imprecisa para ser útil. La
complejidad del cálculo debe considerarse como el primer paso de un proceso
ANÁLICIC DE ALGORITMOS 81
progresivo de refinamiento del análisis de un algoritmo, para dar a conocer más
detalles sobre sus propiedades. En este libro se centra el interés en los pasos si-
guientes, más próximos a las implementaciones reales.
Análisis del caso medio
Otra forma de estudiar el rendimiento de los algoritmosconsisteen examinar el
caso medio. En la situación más simple, es posible caracterizar con precisión los
datos de entrada del algoritmo: por ejemplo, un algoritmo de ordenación puede
operar sobre un array de N enteros aleatorios o un algoritmo geométrico puede
procesar un conjunto de N puntos aleatorios del plano con coordenadas entre O
y I. Entonces se calcula el número medio de veces que se ejecuta cada instruc-
ción y se obtiene el tiempo medio de ejecución del programa multiplicando la
frecuencia de cada instrucción por el tiempo que se necesita para dicha instruc-
ción y sumando todas estas cantidades. Sin embargo, al hacer esto existen al
menos tres dificultades, que se van a examinar una a una.
La primera es que en algunas computadoras puede resultar difícil determi-
nar con precisión el tiempo que se necesita para cada instrucción. Peor aún, di-
cho tiempo está sujeto a cambios, y una gran parte de los análisisrealizadosen
una computadora puede no tener valor para los tiempos de ejecución del mismo
algoritmo en otra computadora. Éste es exactamente el tipo de problemas que
trata de evitar el estudio de la complejidad del cálculo.
Segunda:a menudo el análisis del caso medio es en sí mismo un desafío ma-
temático dificil que requiere argumentos detallados y complejos. Por su natu-
raleza, los cálculos matemáticos necesarios para comprobar los límites superio-
res son normalmente menos complejos porque no necesitan ser tan precisos.
Todavía se desconoce el rendimiento del caso medio de muchos algoritmos.
Tercera, y la más importante: en el análisis del caso medio puede ser que el
modelo de datos de entrada no caracterice con precisión a los que aparecen en
la práctica, o puede ser que no exista ningún modelo de entrada natural. ¿Cómo
se deberían caracterizar los datos de entrada de un programa de tratamiento de
textos en inglés? Pero, al contrario, existen pocos argumentos contra la utiliza-
ción de modelos de entrada tales como «un archivo ordenado aleatonamente))
para un algoritmo de ordenación, o «un conjunto de puntos aleatorios))para un
algoritmo geométrico. Para tales modelos es posible obtener resultados mate-
máticos que permitan predecir con precisión el rendimiento de los programas
que operan en aplicacionesreales. Aunque la obtención de estos resultados nor-
malmente rebasa los objetivos de este libro, se presentan algunos ejemplos (ver
Capítulo 9), y se mencionarán algunos resultados significativos cuando sea ne-
cesario.
82 ALGORITMOS EN C++
Resultados aproximados y asintóticos
A menudo, los resultados de un análisis matemático no son exactos sino apro-
ximados en un sentido técnico preciso: el resultado podría ser una expresión
compuesta por una sucesión de términos decrecientes. De igual forma que se
presta más atención al bucle interno de un programa, se está más interesado en
el término más significativo (el término más grande) de una expresión mate-
mática. La notación-O se desarrolló originalmente para este tipo de aplicación,
y, usada adecuadamente,permite realizar estimaciones concisas que dan bue-
nas aproximaciones a resultados matemáticos.
Supóngase, por ejemplo (después de algunos análisis matemáticos), que se
determina que un algoritmo particular tiene un bucle interno que está repetido
MgN veces de media, que una sección externa lo está N veces y que aigún có-
digo de inicialización se ejecuta una sola vez. Supóngase además que se descu-
bre (después de un minucioso examen de la implementación) que cada itera-
ción del bucle interior requiere microsegundos, las de la sección externa, al
microsegundos, y que la inicialización se hace en a2 microsegundos. Entonces
el tiempo medio de ejecución del programa (en microsegundos) es
@N 1gN+ a,N + a2.
Pero también es cierto que el tiempo de ejecución es
(El lector puede verificar esta afirmación a partir de la definición de O(N).) Esto
es importante porque, si se está interesado en una respuesta aproximada, se sabe
que, para N grande, puede no necesitarse encontrar los valores de al o a2. Más
importante aún, puede que otros términos de la expresión exacta del tiempo de
ejecución sean difíciles de analizar: la notación O proporciona una forma de
obtener una respuesta aproximada para valores de N suficientemente grandes
sin preocuparse de tales términos.
Técnicamente, no se tiene una seguridad real de que se puedan ignorar los
términos pequeños de esta manera, porque la definición de la notación O no
dice nada sobre el tamaño de la constante co que podría ser muy grande. Pero
(aunque, por lo regular, no sea una preocupación) en tales casos hay formas para
acotar las constantes que son pequeñas en comparación con N, y así normal-
mente está justificado ignorar las cantidades representadas por la notación O
cuando existe un término principal (mayor)bien definido. Al hacer esto se tiene
la seguridad de que se poseen los conocimientos necesarios para efectuar tal
simplificación si fuera necesario, amque raramente se hará así.
De hecho, cuando una funciónf(N) sea asintóticamente grande comparada
con otra función g(N),se utilizará en este libro la terminología (decididamente
ANÁLISIS DE ALGORITMOS a
3
no técnica) «del orden def(N)» para significarf(N) + O(g(N)).De esta manera,
lo que se pierde en precisión matemática se gana en claridad, ya que el interés
radica más en el rendimiento de los algoritmos que en los detalles matemáticos.
En tales casos, el lector puede estar seguro de que, para valores de N suficien-
temente grandes (si no es para todo N),la cantidad en cuestión estará muy pró-
xima af(N). Por ejemplo, incluso si se sabeque una cantidad es N(N - 1)/2, se
puede hacer referencia a ella como «delorden de» N2/2.Esto se percibe más
rápidamentey, por ejemplo, el error cometidoes de un 10% cuando N = 1000.
La precisión perdida en tales casos es despreciable comparada con la que se
pierde al utilizar O(f( N)). El objetivo es ser a la vez precisos y concisos en la
descripción del rendimiento de los algoritmos.
Recurrencias básicas
Como se verá en los siguientes capítulos, un gran número de algoritmos se ba-
san en el principio de descomponer recursivamente un problema grande en otros
más pequeños, utilizando las soluciones de los subproblemas para resolver el
problema original. El tiempo de ejecución de estos algoritmos viene determi-
nado por el tamaño y el número de los subproblemas, así como por el coste de
la descomposición. En esta sección se verán métodos básicos para analizar tales
algoritmos y obtener soluciones de unas cuantas fórmulas estándar que apare-
cen en el análisis de muchos de los algoritmos que se estudiarán más adelante.
La comprensiónde las propiedades matematicasde las fórmulas de esta sección
permitirá delimitar las propiedades del rendimiento de los algoritmos de este li-
bro.
La propia naturaleza de un programa recursivo impone que su tiempo de
ejecución para una entrada de tamaño N dependa de su tiempo de ejecución
para entradas más pequeiias: esto conduce de nuevo a las relacbnes de recu-
rrencia, que se vieron al principio del capítulo anterior. Tales fórmulas descri-
ben de manera precisa el rendimiento de los algoritmos que les corresponden:
para obtener el tiempo de ejecución, se resuelven las recurrencias. Posterior-
mente se darán argumentosmás rigurosos al estudiar algunos algoritmos con-
cretos: por ahora lo que interesa son las fórmulas, no los algoritmos.
Fórmula 1. Esta recurrencia aparece en un programa recursivo que efectúa
bucles en los datos de entrada para eliminar un elemento:
CN= CNp1
+ N, para N 2 2 con CI = 1.
Solución:CNes del orden de N2/2.Para resolver esta recurrencia, se aplica sobre
sí misma, «en cascada), de la siguiente forma:
a4 ALGORITMOS EN C+-t~
N = CN-1 + N
= CN-2 + ( N - l ) N
=CN-,+(N-2)+(N- 1 ) + N
= CI +2 + ... + (N - 2)+ (N- 1) -I-N
= 1 +2+...+(N--2)+(N- 1)+N
- N(N+ 1)
-
2 -
La evaluación de la suma 1 + 2 + ...+ (N - 2) + (N - 1) + N es elemental: el
resultado obtenido anteriormente puede establecerseañadiendo la misma suma,
pero en orden inverso, término a término. Este resultado, que es dos veces el
valor buscado, contiene N términos, cada uno de los cuales vale N + 1.
Fórmula 2. Esta recurrencia aparece en un programa recursivo que divide los
datos de entrada en un solo paso:
c N = c N l 2 4- 1, para N 2 2 con CI= O.
Solución: CNes del orden de 1 0 . Escrita de esta forma, esta ecuación carece de
sentido a menos que N sea par o que se suponga que N/2 es una división entera:
por ahora, se supone que N = 2", o lo que es lo mismo n=lgN, de modo que la
recurrencia siempre esté bien definida. Pero entonces la recurrencia en cascada
llega incluso a ser aún más fácil que la anterior:
C2" = C p l + 1
= Cp-2 + 1 + 1
= C2n-3 + 3
= C ~ O
+ n
= n.
ANÁLISIS DE ALGORITMOS 85
Resulta que la solución exacta para cualquier N depende de las propiedades de
la represeíitación binaria de N, pero CNes del orden de 1gNpara todo N.
Fórmula 3. Esta recurrencia aparece en un programa recursivo que divide los
datos de entrada en dos, pero que debe examinar cada elemento de ellos.
CN = c N f 2 + N, para N b 2 con C
1 = O.
Solución: CNes del orden de 2N. Reduciendo como antes se obtiene la suma N
+N/2 +N/4 +N/8 + ...(como en el caso anterior, esta serie sólo tendrá sentido
cuando N sea una potencia de dos). Si la sucesión fuera infinita, sena una sene
geométrka simple que convergeríaa 2N. Para cualquier valor de N, la solución
exacta implica otra vez la representación binaria de N.
Fórmula 4. Esta recurrencia aparece en un programa recursivo que tiene que
hacer un recomdo lineal de los datos de entrada, antes, durante o después de
dividirla en dos partes:
CN= 2cN/2 + N, para N 2 2 con CI = O.
Solución: CNes del orden de MgN. Ésta es la solución que será la más citada,
porque es el prototipo de muchos algoritmos del tipo de divide y vencerás.
C2n = 2Cy-1 + 2"
C2n-2
2"-
=-+ 1 + 1
= n.
La solución se obtiene de la misma forma que la de la fórmula 2, pero con el
truco adicional de dividir los dos miembros de la recurrencia por 2" en el se-
gundo paso, para hacer la recurrencia en cascada.
Fórmula 5. Esta recurrencia aparece en un programa recursivo que divide los
datos de entrada en dos partes en un solo paso, como en el programa de dibujar
una regla del Capítulo 5.
86 ALGORITMOS EN C++
Solución: CNes del orden de 2N. Esto se obtiene de la misma forma que en la
fórmula 4.
Se pueden tratar variantes secundarias de estas fórmulas, con diferentes
coridicionesiniciales o ligeras diferencias en los términos añadidos, utilizando
las mismas técnicas de resolución, aunque el lector debe tener en cuenta que
algunas recurrencias que parecen similares a las anteriores,en realidad, pueden
ser bastdnte difíciles de resolver. (Existe una variedad de técnicas generales
avanzadas para tratar estas ecuaciones con rigor matemático.) Se encontrarán
algunasrecurrenciasmás complicadasen capítulos posteriores, dejándose el es-
tudio de su solución hasta el momento en que aparezcan.
Perspectiva
Muchos de los algoritmosde este libro han sido objeto de análisis matemáticos
detalladosy de estudiosde rendimientodemasiadocomplejospara tratarlos aquí.
De hecho, basándose en tales estudios es posible recomendar la utilización de
muchos de los algoritmos que se presentarán.
No todos los algoritmosse han sometido a análisistan detallados; en efecto,
durante el proceso de diseño es preferible trabajar con indicadores de rendi-
miento aproximadospara poder guiar el proceso sin detalles extraños. Según se
vaya refinando el diseño, se debe avanzar más en el análisis y se necesitará uti-
lizar herramientas matemáticas más sofisticadas. A menudo, el proceso de di-
seño conduce a estudios detallados de la complejidad, que a su vez llevan a al-
goritmos ateóricow muy alejadosde cualquier aplicación particular. Es un error
común suponer que el análisis aproximado de estudios de complejidad se tra-
ducirá inmediatamente en algoritmos prácticamente eficaces: esto suele con-
ducir a sorpresasdesagradables.Por otra parte, la complejidadde cálculo es una
herramienta poderosa para obtener las condicionesde partida del diseño sobre
las que pueden basarse nuevos métodos importantes.
No se debería utilizar un algoritmo sin tener una indicación de cómo lle-
varlo a cabo: las aproximacionesdescritas en este capítulo ayudarán a propor-
cionar alguna indicación del rendimiento de una gran variedad de algoritmos,
como los que se verán en los capítulos siguientes. En el próximo se presentarán
otros factores importantesque influyen a la hora de elegir un algoritmo.
Ejercicios
1. Suponiendo que se sabe que el tiempo de ejecución de un algoritmo perte-
nece a O(Nlog2V)y que el de otro pertenece a U(N3),¿qué se puede decir
sobre el rendimiento relativo de estos algoritmos?
ANÁLISIS DE ALGORITMOS 87
2. Suponiendo que se sabe que el tiempo de ejecución de un algoritmo es
siempre del orden de MogN y que el de otro pertenece a O(N3),¿qué se
puede decir sobre el rendimiento relativo de estos algoritmos?
3. Suponiendo que se sabe que el tiempo de ejecución de un algoritmo es
siempre del orden de MogN y que el de otro es siempre del orden de N3,
¿qué se puede decir sobre el rendimiento relativo de estos algoritmos?
4. Explicar la diferencia entre O(1) y O(2).
5. Resolver la recurrencia
C
, = C N ~
+ N2, para N 2 2 con CI = O,
cuando N es una potencia de dos.
6. ¿Para qué valores de N se verifica 10MgN > 2N2?
7. Escribir un programa para calcular el valor exacto de CNen la fórmula 2,
8. Demostrar que la solución exacta de la fórmula 2 es 1gN-tO(1).
9. Escribir un programa recursivo para calcular el mayor de los enteros infe-
riores a log2N. (Ayudapara N > 1, el valor de esta función para N / 2 es una
unidad mayor que N.)
10. Escribir un programa iterativo para resolver el problema del ejercicio an-
terior. Después escribir un programa que efectúe el cálculo utilizando sub-
rutinas de la biblioteca de C++.
Si la computadora lo permite, comparar el
rendimiento de estos tres programas.
como se presentó en el Capítulo 5. Comparar los resultados con 1 0 .
Algoritmos en C++.pdf
7
Implementación
de algoritmos
Como se mencionó en el Capítulo 1, el principal objetivo de este libro son los
algoritmos en sí mismos, de forma que, cuando se presente alguno de ellos, se
tratará como si su rendimiento fuera el factor crucial para la realización co-
rrecta de tareas mayores, Este punto de vista sejustifica porque estas situaciones
aparecen con todos los algoritmos, y porque la búsqueda cuidadosa de solucio-
nes eficaces para un problema conduce frecuentemente a otros algoritmos más
elegantes (y más eficaces). Por supuesto, este planteamiento restrictivo es muy
poco realista, ya que cuando se resuelve un problema complicado con una
computadora deben tenerse en cuenta otros muchos factores. En este capítulo
se presentarán cuestiones referentes a la forma de hacer Útiles, en aplicaciones
prácticas, los algoritmos algo idealizados que se describen en el libro.
Las propiedades del algoritmo, después de todo, son sólo una cara de la mo-
neda, ya que una computadora puede utilizarse para resolver un problema de
forma eficaz sólo si está suficientemente comprendido. El considerar cuidado-
samente las propiedades de las aplicaciones está más allá del alcance de este li-
bro. La intención es proporcionar suficiente información sobre los algoritmos
básicos, de manera que cualquiera pueda tomar decisionesinteligentessobre su
empleo. La mayoría de los algoritmos que se tratarán aquí se utilizan en la prác-
tica en muchas aplicaciones.La extensión de los algoritmos disponibIes para re-
solver diferentes problemas depende de la extensión de las necesidades de las
diversas aplicaciones. No existe «el mejom algoritmo de búsqueda (por poner
un ejemplo), pero un método puede ser idóneo en un sistema de reservas de
unas líneas aéreas y otro podrá ser mejor para utilizarlo en el bucle interno de
un programa de descifrado.
Los algoritmos rara vez existen en condiciones ideales, excepto posible-
mente en la mente de sus diseñadores teóricos que inventan métodos sin pensar
en ninguna implementación definitiva, o en la mente de los programadores de
89
90 ALGORITMOS EN C++
sistemas de aplicaciones, que «amañan» métodos ad hoc para resolver proble-
mas que por otra parte están bien delimitados. El diseño adecuado de un algo-
ritmo supone el tener en cuenta el posible impacto que éste tendrá en las im-
plementaciones posteriores, y la programación adecuada de las aplicaciones
implica el tener en cuenta Pas característicasde rendimiento de los métodos bá-
sicos empleados.
Selección de un algoritmo
Como se verá en los capítulos siguientes, normalmente se dispondrá de varios
algoritmos para resolver cada problema, todos con diferentes características de
rendimiento, variando desde la simple solución de ((fuerzabruta>(aunque pro-
bablemente ineficaz)hasta una solución compleja (bien afinada» (e incluso óp-
tima). (En general, no es cierto que el algoritmo más eficiente sea el que tiene
la implementaciónmás complicada, ya que algunos de los mejores algoritmos
son bastante elegantesy concisos. Pero para los fines de este estudio se supondrá
que esta regla es cierta.) Como se argumentaba anteriormente, no se puede de-
cidir qué algoritmo utilizar para un problema sin analizar las necesidades del
mismo: ¿Con qué frecuencia se utilizará el programa?, jcuáles son las caracte-
rísticas generales del sistema de computación que se va a utilizar?, jes el algo-
ritmo una parte pequeña de una gran aplicación, O viceversa?
La primera regla de la implementaciónes que se debe implementar primero
el algoritmo más simple que resuelva un problema dado. Si el problema parii-
cular con el que se tropieza se resuelve fácilmente, entonces el algoritmo senci-
llo podria resolver el problema y no sería necesario hacer nada más; pero si s
e
requiere un algoritmo más sofisticado, entoncesla implementaciónsencilla pro-
porciona una forma de comprobación para casos puntuales y usa línea básica
para evaluar las característicasdel rendimiento.
Si sólo se va a ejecutar un algoritmo pocas veces, en casos que no son de-
masiado grandes, entonces seguramente es preferible que la computadora tarde
un poco de más de tiempo en la ejecución de un algoritmo un poco menos efi-
caz, en lugar de que el programadoremplee excesivo tiempo desarrollando una
implementaciónsofisticada. Por supuesto, existe el peligro de que se pueda ter-
minar utilizando el programa más de lo que se suponía originalmente, y por
tanto conviene estar siempre preparado para volver a empezar e implementar
un algoritmo mejor.
Si el algoritmo se va a integrar en un gran sistema, la implementacióndel
método de la ((fuerzabruta» proporciona la funcionalidad que se requiere de
una manera fiable, y posteriormente podrá mejorarse el rendimiento (de una
manera controlada), sustituyendo el algoritmo por otro más refinado. Por su-
puesto, cuando se estudie el comportamiento completo del sistema, se deberia
tener cuidado para no excluir opciones al implementarel algoritmo, de t
a
l ma-
nera que sea difícil mejorarlo más adelante, y se debería tener un especial cui-
IMPLEMENTACI6NDE ALGORITMOS 91
dado con aquellos algoritmos que dan lugar a cuellos de botella en la ejecución.
En grandes sistemas, es frecuente que las especificacionesde diseño del sistema
dicten desde el comienzo cuál es el mejor algoritmo. Por ejemplo, puede que
una estructura de datos compartida por el sistema sea una lista enlazada o un
árbol, por lo que son preferibles los algoritmos basados en estas estructuraspar-
ticulares. Por otro lado, cuando se tomen decisiones a la escala del sistema se
debe prestar mucha atención al elegir los algoritmos a utilizar porque al final es
muy frecuente que el comportamientode todo el sistema dependa de algún al-
goritmo básico, como los que se tratarán en este libro.
Si el algoritmo sólo se va a ejecutar unas cuantas veces, pero sobre proble-
mas muy grandes, entonces se deseará asegurarseque se obtendrá una salida co-
herente, y tener una estimación de cuánto tiempo tardará. Aquí otra vez, una
implementación sencilla puede ser a veces bastante útil para la resolución de
una tarea larga, incluyendo el desarrollo de todo lo necesario para la compro-
bación de los resultados.
El error más común a la hora de seleccionar un algoritmo es ign0ra.rlas ca-
racterísticas de rendimiento. Los algoritmos más rápidos suelen ser los más
complicados, y también es frecuente que las personas que los desarrollan estén
dispuestas a aceptar un algoritmo más lento para evitar trabajar con una com-
plejidad añadida. Pero, a menudo, un algoritmo más rápido no tiene por qué
ser mucho más complicado, y trabajar con una pequeña complicación añadida
es un pequeño precio a pagar para evitar manejar un algoritmo lento. Un nú-
mero sorprendente de usuarios de sistemas de información pierden un tiempo
considerable esperando que terminen de ejecutarse simples algoritmos cuadrá-
ticos, cuando existen algontmos cuya complejidad en tiempo está próximo a
M o o , y que pueden ejecutarse en una fracción de dicho tiempo.
El segundo error más común que se comete, a la hora de seleccionar un al-
goritmo, es dar excesiva importancia a las características de rendimiento. Un
algoritmo en MogN podría ser ligeramente más complicado que un algoritmo
cuadrático para resolver el mismo problema; pero un algoritmo en M o o , más
eficaz, podría dar lugar a un incremento sustancial de la complejidad (y en rea-
lidad podría ser más rápido sólo para valores de N muy grandes). También ocu-
rre que muchosprogramas de hecho sólo se ejecutan unas pocas veces: el tiempo
necesario para implemeiitar y depurar un algoritmo optimizadopodría ser con-
siderablemente mayor que el tiempo que se necesita para ejecutar uno sencillo
que es un tanto más lento.
Análisis empírico
Como se mencionóen el Capítulo 6, por desgracia frecuentemente se da el caso
de que el análisis matemático aporte muy poca luz sobre cómo es el compor-
tamiento esperado de un algoritmo particular en una situación concreta. En ta-
les casos, es necesario realizar un análisis empírico, en el que se implementa
92 ALGORITMOS EN C++
cuidadosamenteun algoritmo y se controla su ejecución con una entrada «tí-
pica). De hecho, esto debería realizarse incluso cuando se disponga de los re-
sultados matemáticos completos, con el fin de comprobar su validez.
Dados dos algoritmos que resuelvan el mismo problema, el método es muy
claro: jse ejecutan los dos para ver cuál de ellos lleva más tiempo! Decir esto
podría parecer demasiado obvio, pero probablemente sea la omisión más co-
mún en el estudio comparativode algoritmos. El hecho de que un algoritmo sea
diez veces más rápido que otro es muy improbable que se le escape a alguien
que espera que uno de ellos acabe en tres segundos y que el otro acabe en treinta,
pero es muy fácil que se le pase por alto algo como un pequeño factor constante
en un análisis matemático.
Sin embargo, también es fácil cometer errores cuando se comparan imple-
mentaciones, en especial si intervienen diferentes máquinas, compiladores o sis-
temas, o si están comparando programas muy grandes con entradas mal espe-
cificadas. Desde luego, uno de los factoresque condujo al desarrollodel análisis
matemático de los algoritmos fue la tendencia a confiar en los «modelos están-
dam cuyo comportamiento probablemente se entienda mejor a través de un
análisis cuidadoso.
El principal peligro que se corre al comparar empíricamenteprogramas es
que una implementación puede ser más «optimizada» que otra. Es probable que
el inventor de un nuevo algoritmo preste una atención muy cuidadosa a cada
aspecto de su implementación, pero no a los detalles de la implementacióndel
algoritmo clásico rival del suyo. Para confiar en la precisión de un estudio de
comparación empírico hay que estar seguro de que se ha prestado la misma
atención a ambas implementaciones. Afortunadamente,el caso más frecuente
es el siguiente: muchos algoritmos excelentes se han obtenido haciendo modi-
ficaciones relativamente pequeñas de otros algoritmos creados para resolver el
mismo problema, siendo válidos los estudios comparativos.
Un caso particular importante surge cuando se compara un algoritmo con
otra versión de simismo o cuando se comparan implementaciones ligeramente
diferentes. Una buena manera de comprobarla eficacia de una modificación en
particular, o de otra idea de la misma implementación, es ejecutar ambas ver-
siones con alguna entrada «tipo» y en consecuencia seleccionar la más rápida.
De nuevo, parece obvio mencionarlo, pero jel usuario debe tener cuidado!, por-
que hay un sorprendente número de investigadores dedicados al diseño de al-
goritmos que nunca implementan sus diseños.
Como antes se esbozó, y también al comienzo del Capítulo 6, el criterio
adoptado aquí es que diseño, implementación, análisis matemático y análisis
empírico (todos ellos conjuntamente)contribuyen de forma crucial al desarro-
llo de unas buenas implementacionesde algoritmos. Se utilizan todas las herra-
mientas disponibles para obtener toda la información sobre las propiedades de
los programas, y después se los modifica o se desarrollan programas nuevos, a
partir de dicha información. Por otro lado, no siempre estájustificado hacer un
gran número de cambios pequeños con la esperanza de mejorar ligeramente la
ejecución. A continuación se tratará esta cuestión con más detalle.
IMPLEMENTACIÓN DE ALGORITMOS 93
Optimización de un programa
El procedimiento general para modificar un programa, con la finalidad de con-
seguir otra versión más rápida en su ejecución, se denomina optimización del
programa. Éste no es el término adecuado, porque es poco probable encontrar
una implementación que sea «la mejom, pero, aunque no se pueda optimizar
el programa, se puede esperar mejorarlo. Normalmente, la optimización del
programa se realiza de forma automática, como parte del proceso de compila-
ción, para mejorar el rendimiento del código compilado. Aquí se utiliza el tér-
mino para referirsea las mejoras especzjicasdel algoritmo. Por supuesto, el pro-
ceso también depende bastante del entorno de programación y de la máquina
utilizada; por tanto aquí sólo se consideran cuestiones generales y no técnicas
específicas.
Este tipo de actividad está justificada sólo si se está seguro de que el pro-
grama se empleará muchas veces o sobre grandes conjuntos de datos y si la
experimentación demuestra que el esfuerzo dedicado a mejorar la implemen-
tación será recompensado con una mejor ejecución. La mejor forma de per-
feccionar el rendimiento de un algoritmo es mediante un proceso gradual de
transformación en mejores programas y mejores implementaciones. La eli-
minación de la recursión del Capítulo 5 es un ejemplo de un proceso de este
tipo, aunque la forma de mejorar el rendimiento no había sido el objetivo en
ese momento.
El primer paso en la implementación de un algoritmo es desarrollar una ver-
sión inicial en su forma más simple. Esto proporciona una línea básica para
posterioresrefinamientosy mejoras y, como se mencionó anteriormente, es muy
frecuente que haya que realizar todo esto. Se deben contrastar los resultados
matemáticos disponibles con los resultados de la implementación; por ejemplo,
si el análisis indica que el tiempo de ejecución es O(logN), pero el tiempo de
ejecución real es de varios segundos, entonces algo falla, o bien la implementa-
ción, o bien el análisis, y ambos deben estudiarse con más cuidado.
El siguiente paso es identificar el «bucle interno» y tratar de minimizar el
número de instrucciones que lo componen. Quizás la manera más fácil de en-
contrar este bucle sea ejecutar el programa y comprobar qué instrucciones se
ejecutan más a menudo. Por lo regular, esto da una buena indicación de dónde
se puede mejorar el programa. Cada instrucción del bucle interno debería so-
meterse a un cuidadoso examen: ¿Es realmente necesaria?,¿existe una manera
más eficaz de llevar a cabo la misma tarea? Por ejemplo, por lo general merece
la pena codificar algo más, para eliminar llamadas a procedimiento desde el bu-
cle interno. Existen otras técnicas «automáticas» para hacer esto, muchas de las
cuales están implementadas en compiladores estándar. A final de cuentas, el
mejor rendimiento se logra traduciendo el bucle interno a lenguaje de máquina
o lenguaje ensamblador;pero esto suele ser un último recurso.
En realidad no todas las «mejoras» producen ganancias en el rendimiento,
de modo que es sumamente importante comprobar el alcance del ahorro obte-
94 ALGORITMOS EN C++
nido en cada paso. Además, a medida que la implementación se perfecciona más
y más, es aconsejable volver a comprobar si estájustificada esta profundización
en los detallesdel código. En el pasado, el tiempo de cálculo era tan costoso que
estaba casi siemprejustificado emplear más tiempo de programación para aho-
rrar ciclos de cálculo, pero las cosas han cambiadoen los últimos años.
Por ejemplo, considerandoel algoritmo del recomdo del árbol en orden pre-
vio presentado en el Capítulo 5, la eliminación de la recursión es en realidad el
primer paso para «optimizan>este algoritmo, porque su acción se centra en el
bucle interno. La versión no recursiva dada es probablemente más lenta que la
versión recursiva en muchos sistemas (puede comprobarloel lector) porque el
bucle interno es más grande e incluye cuatro llamadas (aunque no recursivas) a
procedimientos (sacar,meter, meter y vaci a), en vez de dos. Si se reempla-
zan las llamadas a los procedimientos de la pila por otro código, para acceder
directamente a la pila (utilizando,por ejemplo, una implementaciónpor array),
es probable que este programa será significativamentem
á
srápido que la versión
recursiva. (Una de las operaciones push del algoritmo es provisional, por lo
que el programa estándar de un bucle dentro de otro constituye probablemente
la base de la versión optimizada.) Es evidente que el bucle interno implica in-
crementar el puntero de la pila, almacenarun puntero (t-> der) en el array de
la pila, reinicializar el puntero t (a t- >izq) y compararlo con z. En muchas
máquinas, esto se podría implementar con cuatro instrucciones de lenguaje de
máquina, mientras que un compiladortípico es probable que genere el doble o
más. Es posible hacer sin demasiado trabajo que el programa se ejecute cuatro
o cinco veces más rápido que la implementaciónrecursiva directa.
Obviamente, los problemas que se están estudiando aquí dependen en gran
medida del sistema y de la máquina. No es conveniente embarcarse en un in-
tento seno de acelerar un programa, sin tener un conocimientobastante deta-
llado del sistema operativo y del entorno de programación. La versión óptima
de un programa se puede volver bastante frágily dificil de modificar, y un com-
pilador o un sistema operativo nuevos (por no mencionar una computadora
nueva) podría arruinar por completo una implementacióncuidadosamente op-
timizada. Se debe tener una precaución especial cuando se utiliza un lenguaje
evolucionado como el C++, en el que hay que esperar frecuentes modificacio-
nes y mejoras.
Por otra parte, el libro se enfoca a la eficaciade las implementacionespres-
tando especial atención al bucle interno y asegurándose de que el tiempo de
ejecución del algoritmo está minimizado en su mayor parte. Los programas
están codificados de forma escueta y flexible con el fin de poder añadir algu-
nas mejoras, de una manera directa y en cualquier entorno de programación
concreto.
La implementación de un algoritmo es un proceso cíclico del desarrollo de
un programa: se concibe, se depura, se estudian sus características y después
se refina la implementación hasta que se alcanza el nivel de rendimiento de-
seado. Como se vio en el Capítulo 6, en general, el análisis matemático puede
ayudar en el proceso: primero, para sugerir qué algoritmos son susceptiblesde
IMPLEMENTACIÓNDE ALGORITMOS 95
llevar a cabo una cuidadosa implementación;segundo, para ayudar a compro-
bar que la implementación se desarrolla como se esperaba. En algunos casos,
este proceso puede conducir al descubrimiento de ciertas propiedades, que ha-
cen posible un nuevo algoritmo o mejoras sustanciales de una versión más
antigua.
Algoritmos y sistemas
Las implementaciones de los algoritmos de este libro se pueden encontrar en
una amplia variedad de programas, sistemas operativos y sistemas de aplicacio-
nes. La intención es describir los algoritmos y animar al lector a que centre su
atención en sus propiedades dinámicas experimentadas con las implementacio-
nes dadas. En algunas aplicaciones, las implementaciones pueden ser útiles tal
y como vienen dadas, pero para otras puede necesitarse más trabajo.
Como se mencionó en el Capítulo 2, los programas de este libro sólo utili-
zan aspectos básicos del C++, sin aprovechar las posibilidades más potentes,
disponibles en C++ y en otros entomos de programación. La finalidad es el es-
tudio de los algoritmos, no la programación de sistemas o aspectos avanzados
de los lenguajes de programación. Se supone que los aspectos esenciales de los
algoritmos se exponen mejor a través de implementaciones sencillas y directas
en un lenguaje casi universal.
El estilo de programación que se utiliza es conciso, con nombres de varia-
bles cortos y con pocos comentarios,de manera que se destaquen las estructuras
de control. La «documentación» de los algoritmos es el texto que los acom-
paña. Se espera que los lectores que utilicen estos programas en aplicaciones
reales los desarrollen adaptándolos para su uso particular. Al construir sistemas
reales está justificado un estilo de programación más «defensivo»: los progra-
mas deben implementarse de modo que puedan modificarse fácilmente, leerse
con rapidez y entenderse por otros programadores, y que además realicen una
buena interfz con otras partes del sistema.
En particular, aunque los algoritmos que se tratan sean apropiados para es-
tructuras de datos más complejas, las estructurasde datos que se necesitan para
las aplicaciones normalmente contienen bastante más información de la que se
utiliza en el libro. Por ejemplo, se habla de búsqueda en archivos que contienen
números enteros o cadenas de caracteres cortas, mientras que, por lo regular,
una aplicación necesita operar sobre largas cadenas de caracteres que forman
parte de grandes registros. Pero los métodos básicos disponiblesen ambos casos
son los mismos. En tales casos se tratarán aspectos destacados de cada algo-
ritmo y se verá cómo se podnan relacionar con diversas características o nece-
sidades de la aplicación.
Muchos de los comentarios anteriores tienen que ver con la mejora del ren-
dimiento de un algoritmo particular y también se aplican para mejorar el ren-
dimiento de un sistema grande. Sin embargo, a gran escala, una de las técnicas
96 ALGORITMOS EN C++
para mejorar el rendimiento de un sistema podría ser reemplazar un módulo en
el que se implementa un algoritmopor un módulo en el que se implementa otro.
Un principio básico para construir grandes sistemases que deberían ser posibles
tales cambios. Normalmente, cuando un sistema evoluciona se obtienen cono-
cimientos más precisos sobre las necesidades específicasde los módulos en par-
ticular. Este conocimiento específico hace posible una selección más cuidadosa
del mejor algoritmo a utilizar entre los que satisfacen esas necesidades; después
se puede concentrar el esfuerzo en mejorar el rendimiento de ese algoritmo,
como se dijo anteriormente. Es cierto que la mayor parte del códigodel sistema
se ejecuta sólo unas pocas veces (o ninguna) y que el principal interés del cons-
tructor del sistema es crear un todo coherente. Por otra parte, también es muy
probable que, cuando el sistema entre en funcionamiento, muchos de sus re-
cursos se dedicarán a resolver problemas fundamentales del mismo tipo que los
presentados en este libro, de modo que es conveniente que el constructor del
sistema conozca los algoritmos básicos que aquí se describen.
Ejercicios
1. ¿Cuánto tiempo se tarda en contar hasta 100.000?Dar una estimación del
tiempo que tardaría el programa j = O; for (i= 1; i < 100000; i++)
j++;en el entorno de programación del lector. Después ejecutar el pro-
grama para comprobar dicha estimación.
2. Responder a la pregunta anterior utilizando repeat y whi 1e.
3. Ejecutándolo para valores pequeños, estimar el tiempo que llevaría la im-
plementación de la criba de Eratóstenes del Capítulo 3 para un valor de N
= 1.OOO.OOO (si se dispone de memoria suficiente).
4. «Optimizan>la implementación de la criba de Eratóstenes del Capítulo 3
para encontrar el número primo más grande que se pueda obtener en 10
segundosde cálculo.
5. Demostrar la afirmación del texto según la cual, al eliminar la recursión del
algoritmo de recorrido del árbol en orden previo del Capítulo 5 (con lla-
madas a procedimientos para operaciones con pilas), el programa se hace
más lento.
6. Demostrar la afirmación del texto según la cual, al eliminar la recursión del
algoritmo de recorrido del árbol en orden previo del Capítulo 5 (e imple-
mentando directamente operaciones de pila), el programa se hace más rá-
pido.
7. Examinar el programa en lenguaje ensamblador producido por el compi-
lador C++ del entorno local de programación del lector para el algoritmo
recursivo de recorrido de árbol en orden previo del Capítulo 5.
8. Diseñar un experimento para comprobar cuál de las dos implementaciones
de una pila, lista enlazada o array es más eficaz en el entorno de progra-
mación del lector.
IMPLEMENTACIÓNDE ALGORITMOS 97
9. Determinar cuál es el método más eficaz para representar la regia dada en
el Capítulo 5: jel método recursivo o el no recursivo?
10. En la implementación no recursiva dada en el Capítulo 5, al recorrer un
árbol completo de 2" - 1 nodos en orden previo, jcuántas inserciones se
utilizan exactamente en la pila?
98 ALGORITMOS ENC++
REFERENCIASpara Fundamentos
Existe un gran número de libros de texto de introducción a la programación y
a las estructuras de datos elementales. La referencia estándar para C++ es el li-
bro de Stroustrup, y la mejor fuente para cuestiones específicas y ejemplos de
programas en Cycon el mismo espíritu que el encontrado en este libro, es el
libro de Kernighan y Ritchie sobre este lenguaje. La colecciónmás completa de
información sobre las propiedadesde estructurasde datos elementales y árboles
es el Volumen 1 de Knuth: los Capítulos 3 y 4 no contemplan más que una
pequeña parte de la información que se proporciona en el libro de Knuth.
La referencia clásica para el análisis de algoritmosbasado en las medidas del
comportamientoasintóticodel peor caso es el libro de Aho, Hopcroft y Ullman.
Los libros de Knuth cubren, con mayor amplitud, el análisis del caso medio y
son la fuente autorizada sobre las propiedades específicas de varios algoritmos
(por ejemplo, casi 50 páginas del Volumen 2 se dedican al algoritmo de Eucli-
des.) El libro de Gonnet trata tanto el análisis del peor caso como el del caso
medio, así como muchos algoritmosde reciente desarrollo.
El libro de Graham, Knuth y Patashnik comprende los aspectos matemá-
ticos que normalmente se utilizan en el análisis de algoritmos. Por ejemplo,
dicho libro describe muchas técnicas para resolver ecuaciones de recurrencia
como las dadas en el Capítulo 6 y otras mucho más difíciles que se encontra-
rán más adelante.Tal mzteria también se trata con gran amplitud en los libros
de Knuth.
El libro de Roberts abarca la materia relativa al Capítulo 6, y los libros de
Bentley plantean el mismo punto de vista que el Capítulo 7 y secciones poste-
riores de este libro. Bentley describe de forma detallada un gran número de es-
tudios completos de casos sobre la evaluación de varias propuestas para desa-
rrollar algoritmos e implementaciones para resolver algunos problemas
interesantes.
A. V. AhoyJ. E. Hopcroft y J. D. Ullman, The Design and Analysis o
f Algo-
rithms, Addison-Wesley, Reading, MA, 1975.
J. L. Bentley, ProgrammingPearls, Addison-Wesley, Reading, MA, 1985;More
ProgrammingPearls, Addison-Wesley, Reading, MA, 1988.
G. H. Gonnet, Handbook o
f Algorithms and Data Structures, Addison-Wesley,
Reading, MA, 1984.
R. L. Graham, D. E. Knuth y O. Patashnik, Concrete Mathematics, Addison-
Wesley, Reading, MA, 1988.
B.W. Kernighan y D. M. Ritchie, The CProgrammingLanguage, segunda edi-
ción, Prentice Hall, Englewood Cliffs, NJ, 1988.
D. E. Knuth, TheArt o
f ComputerProgramming. Volume I : FundamentalAl-
gorithms,segunda edición,Addison-Wesley, Reading, MA, 1973; Volume2:
Seminumerical Algorithms, segunda edición, Addison-Wesley, Reading, MA,
1981; Volume3: Sorting and Searching, segunda impresión, Addison-Wes-
ley, Reading, MA, 1975.
IMPLEMENTACIÓN DE ALGORITMOS 99
E. Roberts, ThinkingRecursively, John Wiley & Sons, Nueva York, 1986.
B. Stroustrup, The C++Programming Language, segunda edición, Addison-
Wesley, Reading, MA, 1991. (Existe versión en español por Addison-Wesley
Iberoamericana y Ediciones Díaz de Santos,N. del E.)
Algoritmos en C++.pdf
Algoritmos
de ordenación
Algoritmos en C++.pdf
8
Métodos de ordenación
elementales
Durante esta primera excursión por el área de los algoritmos de ordenación, se
verán algunos métodos «elementales» que son apropiadospara archivos peque-
ños o con una estructura particular. Existen varias razones para estudiar con
detalle estos algoritmosde ordenación sencillos:la primera es que proporcionan
una forma relativamente fácil de aprender la terminología y los mecanismosbá-
sicos de los algoritmos de ordenación, con el fin de obtener una información
previa adecuada para el estudio de los algoritmos más sofisticados. La segunda
razón es que hay un gran número de aplicaciones de ordenación en las que es
mejor utilizar estos métodos sencillos en lugar de otros más potentes pero con
fines más generales. Por último, algunos de los métodos sencillos se pueden ex-
tender a otros métodos de carácter general o pueden utilizarse para mejorar la
eficacia de métodos más potentes.
Como se acaba de mencionar, existen varias aplicaciones de ordenación en
las que el método elegido puede ser un algoritmo relativamente sencillo. Con
frecuencia, los programas de ordenación se usan una única vez (o muy pocas
veces). Si el número de elementos a ordenar no es demasiado grande (por ejem-
plo, menos de 500), puede ser más eficaz utilizar un método sencillo en lugar
de implementar y depurar uno complicado. Los métodos elementales siempre
son apropiadospara archivospequeños (menosde 50 elementos),y es poco pro-
bable que se pueda justificar el uso de un algoritmo sofisticado para ordenar un
archivopequeño, salvo que vaya a clasificarseun gran número de archivos.Otros
tipos de archivos que son relativamente fáciles de ordenar son aquellos que ya
están casi ordenados(o ya están ordenados), o aquellos que contienen un gran
número de clavesiguales. Para estos archivos «bienestructurados» puede resul-
tar mucho mejor utilizar métodos sencillos en lugar de métodos más generales.
Por regla general, los métodos elementalesque se verán en el libro necesitan
aproximadamente N2 pasos para ordenar N elementos organizados al azar.
103
104 ALGORITMOS EN C++
Cuando N es suficientemente pequeño esto no presenta ningún problema, y, si
los elementos no están organizados aleatoriamente, alguno de estos métodos
puede resultar mucho mejor que otros más sofisticados. Sin embargo, debe ha-
cerse hincapié en que estos métodos no deberían utilizarse para ordenar archi-
vos grandes, ni para ordenar archivos clasificados aleatoriamente, con la nota-
ble excepción de la ordenación de Shell, que es, en realidad, el método de
ordenación elegido para muchas aplicaciones.
Reglas del juego
Antes de considerar algunos algoritmosespecíficosresultará útil presentar cierta
terminología general y algunos supuestos básicos de los algoritmos de ordena-
ción. Se hará el estudio de métodos para ordenar archivos de registrosque con-
tienen claves, que no son más que una parte de los registros (a menudo una pe-
queña parte), pero que se utilizan para controlar la ordenación. El objetivo de
un método de ordenación es volver a organizar los registros para que sus claves
estén ordenadas de acuerdo con alguna regla bien definida (por lo regular, en
orden numérico o alfabético).
Si el archivo a ordenar se encuentra en la memoria (o, en el contexto del
libro, en un array de C++), entonces el método de ordenación se denomina in-
terno. Cuando se realiza la ordenación de archivos situados en cinta o en disco
se denomina ordenación externa. La diferenciaprincipal entre las dos es que en
una ordenación interna se puede acceder fácilmente a cualquier registro, mien-
tras que en una externa debe accederse a los registros de un modo secuencial,o
al menos en grandes bloques. En el Capítulo 13 se verán algunas ordenaciones
externas, pero la mayoría de los algoritmos que se tratarán en el libro serán or-
denaciones internas.
Como de costumbre, el principal parámetro de rendimiento que interesará
es el tiempo de ejecución de los algoritmos de ordenación. El primero de los
cuatro métodos que se presentan en este capítulo necesita un tiempo proporcio-
nal a N2,siendo N el número de elementos a ordenar, mientras que otros mé-
todos más avanzados pueden ordenar N elementos en un tiempo proporcional
a MogN. (Puede demostrarse que ningún algoritmo de ordenación puede utili-
zar menos de NogN comparaciones entre claves.) Después de examinar estos
métodos sencillos se estudiarán otros más avanzados que pueden ejecutarse en
un tiempo proporcional o menor a N3’*y se verá que existen métodos que uti-
lizan la propiedades digitalesde las claves para obtener un tiempo de ejecución
total proporcional a N.
El segundo factor importante a considerar es la cantidad de memoria extra
necesaria para cada algoritmo de ordenación. Básicamente, se distinguirán tres
tipos de métodos: aquellosque ordenan in situ y no utilizan memoria extra, salvo
una eventual pila o tabla de pequeño tamaño; aquellos que utilizan una repre-
sentación por lista enlazada y necesitan por tanto N palabras de memoria suple-
MÉTODOSDE ORDENACIÓNELEMENTALES 105
mentaria para los punteros de la lista, y aquellos que necesitan bastante me-
mona extra para almacenar una copia del array que se desea ordenar.
Una característica de los métodos de ordenación, que a veces resulta impor-
tante en la práctica, es la estabilidad. Se dice que un método de ordenación es
estable si, cuando se encuentra con dos registros que tienen la misma clave, con-
serva su orden relativo en el archivo. Por ejemplo, si se toma una lista con los
estudiantes de una clase ordenados alfabéticamente y se ordena por notas, un
método estable dará una lista en la que los estudiantesque tienen las mismas no-
t
a
s permanecen ordenados alfabéticamente;en cambio, un método inestable es
probable que dé una lista sin que quede ningún rastro del orden alfabético origi-
nal. La mayoría de los métodos sencillos son estables, pero la mayor parte de los
algontmos sofisticadosconocidos no lo son. Si la estabilidad es vital, es posible
imponerla añadiendo un pequeño índice a cada clave antes de la ordenación, o
prolongando el alcance de la clave de alguna otra forma. La estabilidad pare-
ce que se adquiere fácilmente y a menudo se reacciona con desconfianza ante
los efectos desagradables de la inestabilidad. De hecho, pocos métodos logran
estabilidad sin utilizar grandes cantidades de espacio o tiempo suplemen-
tarios.
El siguiente programa tiene por objeto ilustrar los convenios generales que
se utilizarán en este capítulo. Está formado por un programa principal que lee
N números y a continuación llama a una subrutina para ordenarlos. En este
ejemplo, solamente se ordenan los tres primeros números leídos: lo importante
es que este programa «piloto» podría llamar a cualquier programa de ordena-
ción en lugar de ordenar3.
inline void intercambio(tipoE1emento a[], int i, int j )
{ tipoElemento t = a[i]; a[i] = a[j]; a[j] = t; }
ordenar3(tipoElemento a[] ,int N)
if (a[l] > a[2]) intercambio(a, 1, 2);
i f (a[i] > a[3]) intercambio(a, 1, 3);
if (a[2] > a[3]) intercambio(a, 2, 3);
{
1
{
const i n t maxN = 100;
main()
int N, i; tipoElemento v, a[maxN+l];
N = O; while (cin >> v) a[++N] = v;
a[O] = O;
ordenar3(a,N) ;
for (i = 1; i<= N; i++) cout << a[i] << I I ;
cout << 'n';
106 ALGORITMOS EN C++
Como en el Capítulo 3, se mantiene la atención en los detallesde los algoritmos
dejando sin especificar el tipo de los elementos a ordenar (tipoElemento), y se
emplean aquellos algoritmos que permiten ordenaciones sencillas de arrays de
númerosenterosen orden numérico, o de caracteresen orden alfabético.En C++
es fácil utilizar typedef o plantillas para adaptar tales algoritmos de forma que
se puedan utilizar en aplicaciones prácticas que puedan implicar grandes claves
o registros.
Por lo regular, los programas de ordenación acceden a los registros de una
de estas dos formas: o acceden a las claves para compararlas o acceden a los
registros completos para intercambiarlos. La mayoría de los algoritmos que se
estudiarán pueden describirse por medio de estas dos operaciones sobre regis-
tros arbitrarios. Si se van a ordenar registros grandes, será prudente hacer una
((ordenaciónindirecta» para evitar desplazarlosdurante el proceso: no se reor-
denan los propios registros sino un array de punteros (o índices), de forma que
el primer puntero apunte al registro más pequeño, etc. Las claves se pueden
guardar ya sea con los registros (si son grandes) o bien con los punteros (si son
pequeñas). Si es necesario, se pueden reorganizar los registros después de la or-
denación, como se describirá más adelante en este capítulo.
El procedimiento intercambio lleva a cabo una operación de ((intercam-
bio» y es inl ine porque los intercambiosson fundamentalespara muchos pro-
gramas de ordenación y normalmente forman parte del bucle interno. En rea-
lidad, el programa utiliza un acceso al archivo todavía más restringido: hay tres
instrucciones de la forma ((comparar dos registros y, si es necesario, intercam-
biarlos poniendoen primer lugar el de clave más pequeña». Los programas que
se reducen a estas instrucciones son interesantes porque favorecen su imple-
mentación en cualquier máquina. Este punto se estudiará con más detalle en el
Capítulo 40.
Mientras se pueda dar alguna indicación de cómo explotar las facilidades que
ofrece C++ para construir diversos algoritmos que se consideran útiles para
ciertas aplicaciones, se evitará insistir en el problema general de cómo se debe-
rían ((empaquetan)las ordenaciones. Por ejemplo, ya se ha rocado el tema de la
utilización de plantillas o de typedef para hacer que los algoritmos puedan ser
útiles para más tipos de claves. Otro ejemplo: es razonable pasar el an-ay a or-
denar como un parámetro de la rutina de ordenación en C++, pero esto no tiene
por qué ser así en otros lenguajes de programación. En lugar de ello ¿debería
trabajar el programa sobre un array global?, ¿debería la rutina de ordenación ser
parte de una cl ase que generalice las operaciones que se pueden llevar a cabo
a cualquier array susceptibie de ordenación? En algunos sistemas operativos es
bastante fáciljuntar programas sencillos, como por ejemplo el anterior, para que
sirvan de «filtros» entre su entrada y su salida. Al contrario, muchas aplicacio-
nes no necesitan realmente mecanismostales como clases y filtros, siendo pre-
ferible introducir en la aplicación un pequeñocódigo de ordenación.Claro esa,
estos comentarios se pueden aplicar a otros muchos algoritmos que se exami-
narán en este libro, pero el estudio de los algoritmos de ordenación descubrirá
gran parte de los puntos más interesantes.
MÉTODOS DE ORDENACIÓN ELEMENTALES 107
Tampoco se incluyen en los programas muchas instrucciones de «verifica-
ción de errores», aunque suele ser prudente hacerlo en las aplicaciones. Por
ejemplo, la rutina piloto debería comprobar que N no tome un valor superior a
maxN (y ordenar3 debería verificar que N=3). Otra comprobación útil sería que
el programa piloto se asegurara de que el array está ordenadodespués de llamar
a ordenar3. Esto no garantiza que el programa de ordenación funcione (¿por
qué?), pero puede ayudar a mostrar los errores.
Algunos programas utilizan otras variables globales, en cuyo caso las decla-
raciones que no son obvias se incluirán en el código del programa. Además, a
menudo se reservará a[O] (y algunas veces a[N+1] ) para almacenarlas claves
especialesutilizadas por algunos de los algoritmos. En los ejemplos se utilizarán
con frecuencia las letras del alfabeto en lugar de los números: aquéllas se pue-
den emplear de manera evidente utilizando las funciones estándar de C++ que
convierten enteros a caracteres, y viceversa.
Ordenación por selección
Uno de los algoritmos de ordenación más sencillos funciona de la siguiente
forma: primero se busca el elemento más pequeño del array y se intercambia
con el que está en la primera posición; después se busca el segundo elemento
más pequeño y se intercambia con el que está en la segunda posicion, conti-
nuándose de esta forma hasta que todo el array esté ordenado. Este método se
denomina ordenación por selección porque funciona «seleccionando» repetiti-
vamente el elemento más pequeño de los que quedan por ordenar, como mues-
tra la Figura 8.1. En el primer paso, la A de la octava posición es el elemento
más pequeño, por lo que se cambia por la E dei principio. En el segundo paso,
la segunda A es el elemento más pequeño de los que quedan, de forma que se
intercambia con la J de la segunda posición. Después la D se intercambia con
la E de la tercera posición, y a continuación, en el cuarto paso, la primera E se
intercambia con la M de la cuarta posición, y así sucesivamente.
El siguiente programa es una implementaciónde este proceso. Para todo i
entre 1y N - 1,se intercambia a[i ] con el elemento más peqdeño de la sucesión
a[i], ...,a[N]:
void selection (tipoElemento a[], int N)
r
I
i n t i , j , min;
for ( i = 1; i > N; i++)
min = i ;
{
108 ALGORITMOS EN C++
Figura 8.1 Ordenación por selección.
MÉTODOSDE ORDENACIÓN ELEMENTALES 1o9
f o r ( j = i+l;
j <= N; j
+
+
)
intercambio (a, min, i);
i f ( a [ j ] < a[min]) min = j;
A medida que el índice irecorre el archivo de izquierda a derecha, los elemen-
tos que quedan a su izquierda están ya en su posición definitivadentro del array
(y no se desplazarán otra vez), de manera que el array está completamente or-
denado cuando el índice llega al extremo de la derecha.
Éste es uno de los métodos de ordenación más sencillos y funcionará muy
bien con archivos pequeños. El {bucle interno)) está formado por la compara-
ción a[ j] <a[mi n] (más el código necesario para incrementar j y comprobar
que no es mayor que N), y prácticamente no puede ser más simple. Más ade-
lante se examinará el número de veces que es probable que se ejecuten estas
instrucciones.
Además, a pesar de su enfoque evidente de «fuerza bruta», la ordenación
por selección tiene de hecho una aplicación bastante importante: como la ma-
yoría de los elementos se mueven como máximo una vez, este tipo de ordena-
ción es el método que debe elegirse para ordenar archivos que tienen registros
muy grandes y claves muy pequeñas. Esto se verá con detalle más adelante.
Ordenación por inserción
La ordenaciónpor inserción es un algoritmo casi tan sencillo como la ordena-
ción por selección, pero probablemente más flexible.Es el método que se utiliza
a menudo para ordenar las cartas cuando sejuegan unas manos de bridge: con-
sidérense los elementos uno tras otro, insertando cada uno en su lugar apro-
piado entre los que ya se han considerado (manteniéndolos ordenados). Como
se muestra en la Figura 8.2, el elemento considerado se inserta simplemente
moviendo una posición a la derecha a todos los elementos mayores que él e in-
sertando a continuación el elemento en la posición vacante. La J de la segunda
posición es mayor que la E de la primera, por lo que no hay que desplazarla.Al
encontrar la E en la tercera posición se cambia con la J para poner E E J en el
orden deseado, y así sucesivamente.
Este proceso se implementa en el programa siguiente. Para cada i,con va-
lores entre 2 y N, se ordenan los elementos a[l]
,...,a [i ] insertando a[i] en
su lugar en la lista ordenada de elementos a [11,...,a[i- 11:
void insercion(tipoE1emento a[] , i n t N)
i n t i, j; tipoElemento v;
{
110 ALGORITMOS EN C++
~ ~~ ~
Figura 8.2 Ordenación por inserción.
MÉTODOSDE ORDENACIÓNELEMENTALES 111
for (i= 2; i <= N; i t + )
while (a[j-1] > v)
a [ j ] = v;
{
{ a [ j ] = a[j-11; j--; }
Como en una ordenación por selección, los elementos situados a la izquierda
del índice iestán ordenados entre sí durante la ordenación, pero no están en su
posición definitiva, ya que puede ocurrir que tengan que moverse para hacer
sitio a elementos más pequeños que se encuentren posteriormente. Sin ern-
bargo, el array está ordenado por completo cuando el índice alcanza el extremo
derecho.
Hay que considerar otro detalle más importante: jel procedimiento de inser-
ción no trabaja con la mayor parte de los datos de entrada! Esto es así porque
cuando v sea el elemento más pequeño del array, el bucle whi 1e seguirá funcio-
nando cuando se llegue al final del array por la izquierda. Para arreglar esto, se
coloca una clave «centinela» en a[O], haciendo que tome un valor inferior o
igual al del elemento más pequeño del array. Los centinelas normalmente se
utilizan en situaciones como éstas para evitar tener que incluir una comproba-
ción (en este caso sena comprobar que j >1), que casi siempre se produce en el
bucle interno.
Si por alguna razón no es conveniente utilizar un centinela (por ejemplo, si
no se puede definir fácilmente cuál es la clave más pequeña), entonces se podría
utilizar la comprobación whi 1e j >1 && a [j-11 >v. Esta solución no es atrac-
tiva, porque el caso de j=lsólo se da en raras ocasiones, de manera que ¿por
qué se comprueba con tanta frecuenciaesta situación en el bucle interno? Es de
destacar que cuando j es igual a 1, la comprobación anterior no accederá a
a[j-1 ] porque ésta es la forma como se evalúan las expresioneslógicasen C++
-en estos casos otros lenguajes podrían hacer accesos ilegales al array-. Otra
forma de tratar esta situación en C+t es utilizar un break o un goto a la salida
del bucle whi 1e. (Algunosprogramadores prefieren evitar las instrucciones goto
y para ello son capaces de cualquier cosa, como por ejemplo llevar a cabo una
acción dentro del bucle para asegurarse de que éste termina bien. En este caso,
esta solución no parece que esté justificada, ya que no clarifica el programa y
añade sobrecargascada vez que se recorre el bucle como protección contra un
caso raro.)
Digresión: Ordenación de burbuja
Un método de ordenación elemental que se enseña a menudo en los cursos de
introducción a la informática es la ordenacibn de burbuja: se efectúan tantos
112 ALGORITMOS EN C++
pasos a través del archivo como sean necesarios, intercambiando elementos ad-
yacentes; cuando en algún paso no se necesiten intercambios, el archivo estará
ordenado. A continuación se da una implementaciónde este método.
void burbuja(tipoE1emento a[], int N)
int i,j;
for (i = N; i >= 1; i--)
{
for (j = 2; j >= i ; j++)
if (a[j-11 > a[j] intercambio,ii, j-
}
Es preciso un momento de reflexión antes de convencerse de que el programa
hace lo que debe hacer: siempre que se encuentra el elemento de mayor valor
durante el primer paso, se intercambia con cada elemento que queda a su de-
recha hasta que obtiene su posición en el extremo derecho del array. Después,
en el segundo paso, se colocará en su posición definitiva el elemento con el se-
gundo mayor valor, etc. Así, la ordenación de burbuja funciona como un tipo
de ordenación por selección pero se debe trabajar mucho más para colocar a
cada elemento en su posición definitiva.
Características del rendimiento de las ordenaciones
elementales
Las Figuras 8.3, 8.4 y 8.5 proporcionan ilustraciones directas de las caracterís-
ticas operativas de las ordenaciones por selección, inserción y de burbuja. Estos
diagramas muestran el contenido del array a para cada uno de los algoritmos
después de que el bucle exterior se ha repetido N/4, N / 2 y 3N/4 veces (comen-
zando con una entrada formada por una permutación aleatona de los números
enterosdel 1 al N). En los diagramas se coloca un cuadrado en la posición (i ,j)
cuando a[i ]=j. Así, un array desordenado está representado por conjunto de
cuadrados colocados aleatoriamente, mientras que en un array ordenado cada
cuadrado aparecerá encima de aquel que está a su izquierda. Para mayor clari-
dad en los diagramas, se representan las perrnutaciones (reordenaciones de los
enteros del 1 al N), las que, ai ordenarse, tienen todos los cuadrados alineados
a lo largo de la diagonal principal. Los diagramas muestran cómo los diferentes
métodos van avanzandohacia este objetivo.
La Figura 8.3 muestra cómo la ordenación por selección se mueve de iz-
quierda a derecha, colocando los elementos en su posición definitiva sin tener
que volver atrás. Lo que no es evidente a partir de este diagrama es el hecho de
que la ordenación por selección emplea la mayoría de su tiempo en intentar en-
contrar el elemento mínimo en la parte «desordenada»del array.
MÉTODOSDE ORDENACIÓN ELEMENTALES 113
. = .
. . .. ..
. .
. . . .
.. . - .
= .
. . . .
. . . .
. .
. - -
. .
..
I .
/-
. -
.:======
... ..
.=I
. I.
- ..q
..
Figura 8.3 Ordenación por selección de una permutaciónaleatoria.
La Figura 8.4 muestra cómo la ordenación por inserción también se mueve
de izquierda a derecha, insertando en su posición relativa los elementos que va
encontrando, sin buscar más lejos. La parte izquierda del array está cambiando
continuamente.
La Figura 8.5 muestra la similitud entre las ordenaciones por selección y de
burbuja. Esta última «selecciona»el elemento máximo que queda en cada etapa,
pero pierde algún tiempo poniendo orden en la parte «desordenada» del array.
Todos los métodos son cuadráticos, tanto en el peor caso como en el caso
medio, y no necesitan memoria extra. Así, las comparaciones entre ellos depen-
den de la longitud de los bucles internos o de las característicasespecialesde los
datos de entrada.
Propiedad 8.1
comparacionesy N intercambios.
La ordenación por selección utiliza aproximadamente N2/2
Esta propiedad es fácil de ver examinando la Figura 8.1, que es una tabla de
dimensión N*N en la que a cada comparación le corresponde una letra. Pero
esto representa aproximadamente la mitad de los elementos, precisamente los
que están por encima de la diagonal. Cada uno de los N - 1 elementos que es-
. .. .
= . . -
. .
.-.
... = .
. . =
- . .
. .
. . .
. .
. .
. .
9 .
. ...
. .. . =
. . .
. .
i m
: .
I
. . 1 .
. . .
!
Figura 8.4 Ordenación por inserciónde una permutación aleatoria.
114 ALGORITMOS EN C++
. =
1..
.Im
..
Figura 8.5 Ordenación de burbujade una permutaciónaleatoria.
tán en la diagonal (sin contar el último) corresponde a un intercambio. Con más
precisión: para cada i desde 1 hasta N - 1, hay un intercambio y N - i compa-
raciones, de forma que en total hay N - 1 intercambiosy (N- I) + (N- 2) + ...
+ 2 + 1 = N(N - 1)/2 comparaciones. Estas observaciones son verdaderas para
cualquier conjunto de datos de entrada; la única parte de la ordenación por se-
lección que depende de dichos datos es el número de veces que se actualiza m in.
En el peor caso, esta cantidad podría ser también cuadrática, pero en el caso
medio solamente pertenece a O(MogN), por lo que se puede afirmar que el
tiempo de ejecución de una ordenación por selección es bastante insensible a
los datos de entrada.i
Propiedad 8.2 La ordenación por inserción utiliza aproximadamente N2/4
comparacionesy N2/8 intercambios en el caso medio y dos veces más en elpeor
caso.
En la implementaciónanterior, el número de comparaciones y de «medios-in-
tercambios))(desplazamientos)es el mismo. Como se acaba de exponer, esto se
puede ver fácilmente en la Figura 8.2, que representa el diagrama de N*N con
los detalles de las operaciones del algoritmo. Aquí se cuentan los elementos que
quedan por debajo de la diagonal, todos ellos en el peor caso. Para una entrada
aleatoria, es de esperar que cada elemento tenga que recorrer hacia atrás apro-
ximadamentela mitad de las posiciones, por término medio, por lo que debe-
rían contarse la mitad de los elementos que están por debajo de la diagonal. (No
es dificil hacer que estos argumentossean algo más rigurosos.)i
Propiedad 8.3 Tanto en el caso medio como en el peor caso, la ordenaciónde
burbuja utiliza aproximadamente N2/2comparacionesy N2/2 intercambios.
En el peor caso (archivo en orden inverso), está claro que en el i-ésimo paso de
la ordenación de burbuja se necesitan N-i comparaciones e intercambios, de
forma que la demostración es como la de la ordenación por selección. Pero el
tiempo de ejecución de la ordenación de burbuja depende de cómo estén orde-
MÉTODOSDE ORDENACIÓNELEMENTALES 115
nados los datos de entrada. Por ejemplo, se observa que si el archivo ya está
ordenado sólo se necesita un paso (la ordenación por inserción también es rá-
pida en este caso). En cambio, el rendimiento en el caso medio no es significa-
tivamente mejor que en el peor caso, pero en estas condiciones este análisis es
bastante más difici1.i
Propiedad 8.4 La ordenaciónpor inserción es lineal para los archivos «casi or-
denados)).
Aunque el concepto de archivo «casi ordenado))es necesariamente bastante im-
preciso, la ordenación por inserción funciona bien con algunos tipos de archi-
vos no aleatorios que aparecen con frecuencia en la práctica. Normalmente se
abusa de las ordenaciones de aplicación general al utilizarlas en estas situacio-
nes; en realidad, la ordenación por inserción puede aprovechar el orden que
presente el archivo.
Por ejemplo, considérese la operación de ordenar por inserción un archivo
que ya está ordenado. De formainmediata se determina que cada elemento está
en el lugar que le corresponde en el archivo y el tiempo de ejecución total es
lineal. Lo mismo ocurre con la ordenación de burbuja, pero la ordenación por
seleccióntodavía es cuadrática. Incluso si el archivo no está completamenteor-
denado, la ordenación por inserción puede resultar bastante ú
t
i
iporque el tiempo
de ejecución tiene una dependencia bastante fuerte del orden que tenga ya el
archivo. El tiempo de ejecución depende del número de inversiones:para cada
elemento se cuenta el número de elementos superiores a él, de los que quedan
a su izquierda. Ésta es precisamente la distancia que tienen que recorrer los ele-
mentos cuando se insertan en el archivo durante la ordenación por inserción.
Un archivo que esté algo ordenadotendrá menos inversiones que otro que esté
arbitrariamente desordenado.
Si se desea añadir algunos elementos a un archivo ordenadopara obtener un
archivo ordenadomás grande, una forma de hacerlo consiste en añadir los nue-
vos elementos al final del archivo y a continuación llamar a un algoritmo de
ordenación. Claro está, el número de inversionesserá bajo: un archivo que tiene
únicamenteun número constante de elementos sin ordenar tendrá un número
lineal de inversiones. Otro ejemplo es el de un archivo en el que cada elemento
está solamente a una cierta distancia constante de su posición definitiva. Tales
archivos aparecen a veces en las etapas iniciales de algunos métodos de orde-
nación avanzados: en un determinado momento merece la pena cambiar a la
ordenación por inserción.
Para estos archivos, la ordenación por inserción superará incluso a los mé-
todos sofisticados que se verán en los siguientes capítu1os.i
Para comparar los métodos con más profundidad, se necesita analizar el coste
de las comparaciones y de los intercambios,factores que dependen tanto del ta-
maño de los registros como de las claves. Por ejemplo, si los registros tienen cla-
ves de una palabra, como en las implementaciones anteriores, entonces un in-
tercambio (dos accesos al array) cuesta aproximadamente el doble que una
116 ALGORITMOS EN C++
comparación. En estas circunstancias, el tiempo de ejecución de una ordena-
ción por selección y el de una por inserción son prácticamente iguales, pero la
ordenación de burbuja es dos veces más lenta. (De hecho, ila ordenación de
burbuja es dos veces más lenta que la de inserción, casi bajo cualquier circuns-
tancia!) Pero si los registros son grandes en comparación con las claves, enton-
ces será mejor la ordenación por selección.
Propiedad8.5 La ordenaciónpor selección es linealpara archivos con registros
grandes y clavespequeñas.
Supóngase que los costes de una comparación y de un intercambio son de 1 y
de M unidades de tiempo, respectivamente (éstepuede ser el caso, por ejemplo,
de registros de M palabras y claves de una), entonces, la ordenación por selec-
ción de un archivo de tamaño NM lleva aproximadamente Nz unidades de
tiempo para las comparaciones y alrededor de NM para los intercambios. Si N
= O(M),esto es un tiempo lineal en el tamaño de los dat0s.i
Ordenación de archivos con grandes registros
En realidad, es posible (y deseable)arreglarlas cosas para que cualquier método
de ordenación utilice sólo N «intercambios» de registros completos, haciendo
que el algoritmo opere indirectamente sobre el archivo (utilizando un array de
índices) y reordenándolo después.
Específicamente, si el array a [11y ...y a [ N I contiene registros grandes, es
preferible manipular un «array de índices))p [13 y ...y p [NI que accede al array
original sólo para las comparaciones. Si inicialmente se define p [i3=i
,sólo se
necesitará modificar los algoritmos anteriores (y todos los de los capítulos si-
guientes) para que hagan referencia a a [p[i] ] en lugar de a a [i] al utilizar
a [i] en una comparación, y para hacer referencia a p en lugar de a a cuando
se hagan desplazamientos de datos. Esto produce un algoritmo que «ordenará»
el array de índices de manera que p [11 es el índice del elemento más pequeño
de a, p [21 es el índice del segundo elemento más pequeño de a, etc., y así se
evitará el coste de mover excesivamente grandes registros. El programa si-
guiente muestra cómo puede modificarse la ordenación por inserción para que
funcione de esta manera.
void insercion(tipoE1ernento a[] y i n t p[] y i n t N)
i n t i, j ; tipoElernento v;
for ( i = O; i <= N; i++) p [ i ] = i ;
for ( i = 2; i <= N; i++)
{
MÉTODOSDE ORDENACIÓN ELEMENTALES 117
v = p [ i ] ; j = i;
while (a[p[j-i]] > a[v] )
~ [ j l
= v;
{ ~ [ j l
= P Ij-11; j--; }
En este programa se accede al array a solamente para comparar las claves de
dos registros. Así, podría modificarse fácilmente para tratar archivos con regis-
tros muy grandes cambiando la comparación para que acceda sólo a un campo
pequeño de un gran registro, o haciendo el proceso de comparación algo más
complicado. La Figura 8.6 muestra cómo este procedimiento produce una per-
mutación que especifica el orden en que podría accederse a los elementos del
array para definir una lista ordenada. Para muchas aplicaciones, esto será sufi-
ciente (no se necesitará desplazartotalmente los datos). Por ejemplo, se podrían
imprimir los datos ordenadoshaciendo referenciaa cada uno por medio del array
de índices, como en la propia ordenación.
Antes de la
ordenación
k 1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 1 4 1 5
p[k] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
a[kI ljl rn rn 0 [o] L
i
J rn Ki
Después de la
ordenación
k 1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 1 4 1 5
a[k] m M m
~
p[k] 8 14 11 1 3 12 2 6 4 13 7 9 5 10 15
Después de la
permutación
p[k] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Figura 8.6 Reorganizaciónde un array (ordenado)).
Pero ¿qué ocurre si realmente se deben reorganizar los datos, como es el caso
de la parte de abajo de la Figura 8.6? Si se dispone de suficiente memoria extra
para hacer otra copia del array, esto es trivial, pero ¿qué hacer en la situación
más normal, cuando no se dispone de espacio suficiente para hacer otra copia
del archivo?
En el ejemplo, lo primero que se debería hacer es poner el registro que tenga
la clave más pequeña (el que corresponde al índice p[1]) en la primera posición
en el archivo. Pero, antes de hacerlo, se necesita guardar el registro que está en
esa posición, por ejemplo en t.Ahora, después del movimiento, se puede con-
siderar que hay un «hueco» en el archivo en la posición p [1], pero se sabe que
el registro de la posición p [p [11] llenará finalmente este hueco. Continuando
118 ALGORITMOS EN C++
de esta manera, liegará el momento en que llene el hueco el elemento original
de la primera posición, que se había almacenado en t. En el caso del ejemplo
este proceso conduce a la serie de asignaciones t=a[ 11 ; a[ 1]=a[8] ;
a[83=a[6}; a[6]=a[12]; a[12]=a[9]; a[9]=a[4]; a[4]=a[l]; a[i]=t;.
Estas asignaciones colocan a los registros con claves A, E, E, L, M y O en su
lugar adecuado dentro del archivo, lo que puede indicarse poniendo p[11=1,
p[8]=8, p[6]=6, p[12]=12, p[9]=9 y p[4]=4. (Cualquier elemento con
p [i ]=i está en su lugar definitivo y no es necesario volver a tocarlo.)Ahora, se
puede reanudar el proceso para el siguiente elementoque no esté en su lugar, y
así sucesivamente, hasta que por último se reorganice todo el archivo, mo-
viendo cada registro sólo una vez, como se muestra en el siguiente código:
void insitu(tipoE1emento a[], int p[], int N)
I
int i, j, k; tipoElemento t;
for (i = 1; i <= N; i++)
if (p[i] != i)
t = a[i]; k = i;
do
{
j = k; a[jl = a[p[jll;
k = ~ [ j l ;p[jl = j;
{
}
while (k != i);
a[j] = t;
1
1
Por supuesto, la viabilidad de esta técnica para aplicaciones particulares de-
pende del tamaño relativo de los registros y de las claves del archivo a ordenar.
Por cierto, no se debería utilizar con un archivo de registros pequeños, porque
se necesita mucho espacio extra para el array de índices y mucho tiempo extra
para las comparaciones indirectas. Pero para archivos que contienen registros
grandes, casi siempre es preferible utilizar una ordenación indirecta y, en mu-
chas aplicaciones,no es necesario desplazar todos los datos. Por supuesto, como
se vio anteriormente, para archivos que tienen registros muy grandes el método
a utilizar es la ordenación por selección.
La técnica anterior de «array de índices)) funcionará en cualquier lenguaje
de programación que cuente con arrays. En C++ suele ser conveniente desarro-
llar una implementaciónbasada en el mismo principio, utilizando las direccio-
nes de máquina de los elementos del array (los «auténticos punteros)) que se
presentaron brevemente en el Capítulo 3). Por ejemplo, el código siguiente im-
plementa una ordenación por inserción utilizando un array p de punteros:
MÉTODOSDE ORDENACIÓNELEMENTALES 119
~~ ~~ ~ ~~
void insercion(tipoE1emento a[] , tipoElemento *p[], int N)
i
int i,j; tipoElemento *v;
for (i= O; i <= N; i++) p[i] = &a[i];
for (i= 2; i <= N; i++)
v = p[i]; j = i;
while (*p[j-11 > *v)
{
{ ~ [ j i
= pb-11; j--; }
~[jl
= v;
1
}
Una de las característicasfundamentales que C++ha heredado de C es la fuerte
relación existente entre punteros y arrays. En general,los programas implemen-
tados con punteros son más eficaces,pero más dificilesde entender (aunquepara
alguna aplicación concreta no hay mucha diferencia). El lector que esté intere-
sado puede implementarel programa insitu necesario para la ordenación por
punteros anterior.
En las implementaciones de este libro normalmente se accederá de manera
directa a los datos, aun sabiendo que se podrían utilizar punteros o arrays de
índices para evitar realizar un número excesivo de desplazamientos de datos al
hacer las ordenaciones. Debido a la existencia de esta ordenación indirecta, las
conclusiones que se muestran en este capítulo y en los que siguen, cuando se
comparan los métodos para ordenar archivos de enteros, se pueden aplicar a si-
tuaciones más generales.
Ordenación de Shell
La ordenación por inserción es lenta pcrque únicamente se realizan intercam-
bios entre elementos adyacentes. Por ejemplo, si el elementomás pequeño está
al final del array, se necesitan N pasos para situarlo en su lugar correspondiente.
La ordenación de Shell (Shellsort) es una simple generalización de la ordena-
ción por inserción en la que se gana rapidez al permitir el intercambio entre
elementos que están muy alejados.
La idea es reorganizar el archivo para que tenga la propiedad de que, to-
mando todos los elementos h-ésimos (comenzandopor cualquier sitio), se ob-
tenga un archivo ordenado, Tal archivo se dice que está h-ordenado. Diciéndolo
de otra forma, un archivo h-ordenado está constituidopor h archivosordenados
independientes, entrelazados entre sí. H-ordenando el archivo para algunos va-
lores grandes de h se pueden intercambiar elementos muy distantes en el array,
120 ALGORITMOS EN C++
El ~
Figura 8.7 Ordenación de Shell.
y así se facilita la h-ordenación para pequeños valores de h. Utilizando este pro-
cedimiento para cualquier serie decreciente de valores de h que termine en 1 se
genera un archivo ordenado: éste es el principio de la ordenación de Shell.
La Figura 8.7 muestra la operación de la ordenación de Shell para el archivo
ejemplo con los incrementos decrecientes ..., 13, 4, 1. En el primer paso, la E
está en la posición 1, y se compara (y se intercambia) con la A de la posición
14,y después la J de la posición 2 se compara con la R de la posición 15. En el
segundo paso se reordenan las letras A P O N de las posiciones 1, 5, 9 y 13po-
niendo en esas posiciones A N O P, después las letras J L R E de las posiciones
2, 6, 10 y 14, poniendo en ellas E J L R, y así sucesivamente. El último paso es
precisamente la ordenación por inserción; pero ningún elemento tiene que des-
plazarse muy lejos.
Una forma de implementar la ordenación de Shell podría ser utilizar, para
cada h, la ordenación por inserción de forma independiente en cada uno de los
h subarchivos. (No deberían utilizarse centinelas porque se necesitaría un gran
número de ellos para los mayores valoresde h.) Pero, en cambio, se puede hacer
MÉTODOSDE ORDENACIÓNELEMENTALES 121
más fácil todavía: Si se reemplaza cada aparición de (c 1)) por ((h» (y de ((2)) por
«h+l») en la ordenación por inserción, el programa resultante h-ordena el
archivo y se obtiene una implementación más compacta de la ordenación de
Shell, como se indica a continuación:
void ordensheil (tipoElemento a[] ,int N)
int i, j, h; tipoElemento v;
for (h = 1; h <= N/9; h = 3*h+l) ;
{
h /= 3)
<= N; i += 1)
for ( ; h > O;
for (i= h+l;
v = a[i]
while (j
{
{ aiji
j = i;
> h && a[j-h]>v)
- a[j-h]; j -= h; }
-
a[j] = v;
}
1
Este programa utiliza la sene de incrementos decrecientes ..., 1093, 364, 121,
40, 13, 4, 1. En la práctica, otra sene podría resultar tan buena como ésta pero,
como se indica a continuación, hay que tener un poco de cuidado al elegirla.La
Figura 8.8 muestra cómo actúa este programa ante una permutación aleatoria,
mostrando el contenido del array a después de cada h-ordenación.
La sucesión de incrementosdecrecientesde este programa es fácil de utilizar
y conduce a una ordenación eficaz. Hay otras muchas sucesiones que conducen
a ordenaciones mejores (el lector puede entretenerse intentando descubrir una),
pero es difícil mejorar el programa anterior en más de un 20%, incluso para N
relativamente grande. (Sin embargo, la posibilidad de que existan sucesiones
mucho mejores sigue siendo bastante real.) Por el contrario, existen algunas su-
cesiones más desfavorables:por ejemplo, ...,64, 32, 16, 8, 4, 2, 1 conduce a un
mal rendimiento porque los elementos que están en las posiciones impares no
se comparan con los elementos que están en las posiciones pares hasta el final.
De forma similar, algunas veces la ordenación de Shell se implementa comen-
zando por h=N (en lugar de inicializarse de manera que siempre se utilice la
misma sucesión de antes). Virtualmente, esto asegura que surgirá una sucesión
desfavorable para algún N.
La anterior descripción de la eficacia de la ordenación de Shell es imprecisa
por necesidad, porque nadie ha sido capaz de analizar el algoritmo. Esto hace
que no sólo sea difícil evaluar las diferentes seriesde incrementos, sino también
comparar anaiíticamentela ordenación de Shell con otros métodos. Ni siquiera
se conocela forma funcionaldel tiempo de ejecuciónpara esta ordenación (como
mucho se sabe que depende de la sucesión de incrementos). Para el programa
anterior podrían darse dos conjeturas: N(10gh')~y El tiempo de ejecución
122 ALGORITMOS EN C+t
I I
Figura 8.8. Ordenación de Shell para una permutación aleatoria.
no es particularmente sensible al orden inicial del archivo, en especial en con-
traste con, por ejemplo, la ordenación por inserción, en la que el tiempo de eje-
cución es lineal para un archivo ya ordenado y cuadrático para un archivo en
orden inverso. La Figura 8.9 muestra las operaciones de la ordenación de Shell
en un archivo de este tipo.
Propiedad 8.6 La ordenación de Shell nunca hace más de N3/2comparaciones
(para los incrementos I , 4, 13, 40, 121,...
).
La demostración de esta propiedad está más allá del aícance de este libro; pero,
además de apreciar su dificultad, el lector también puede convencersede que la
ordenación de Shell se comporta bien en la práctica intentando construir un
archivo en el que la ordenación de Shell se ejecute lentamente. Como se men-
cionó antes, existen algunas sucesiones de incrementos desfavorables para las
que la ordenación de Shell puede necesitar un número cuadrático de compara-
ciones, pero se ha demostrado que la cota N3/2es válida para una amplia vane-
dad de sucesiones,como la que se ha utilizado con anterioridad. Incluso se co-
nocen mejores cotas para el peor caso de algunas sucesionesespecia1es.i
La Figura 8.10 muestra una visión diferente de las operaciones que realiza
la ordenación de Shell, comparable a la de las Figuras 8.3, 8.4 y 8.5. Esta figura
presenta el contenido del array después de cada h-ordenación (excepto la ú1-
tima, que es la que completa la ordenación). En estos diagramas podría imagi-
MÉTODOSDE ORDENACIÓNELEMENTALES 123
Figura 8.9. Ordenación de Shell para una perrnutaciónen orden inverso.
narse una goma elástica fija en las esquinas inferior izquierda y superior dere-
cha, que se estira y ajusta para llevar todos los puntos hacia la diagonal. Cada
uno de los tres diagramas de las Figuras 8.3, 8.4 y 8.5 representa el hecho de
que cada algoritmo que se muestra debe realizar una cantidad de trabajo signi-
ficativa;por el contrario, cada uno de los diagramas de la Figura 8.10 representa
sólo un paso de h-ordenación.
La ordenación de Shell es el método elegido en muchas aplicaciones,ya que
su tiempo de ejecución es aceptable, incluso para archivos moderadamente
grandes (por ejemplo, con menos de 5.000 elementos) y únicamente se necesita
Figura 8.10. Ordenaciónde Shell de una permutaciónaleatoria.
124 ALGORITMOS EN C++
un pequeño código, fácil de ejecutar. En los siguientes capítulos se verán mé-
todos que son más eficaces, pero que sólo son el doble de rápidos (cuando mu-
cho), excepto para grandes valores de N, y son bastante más complicados. En
resumen, si se tiene un problema de ordenación, lo mejor es utilizar elpro-
grama anterior, y determinar después si vale la pena el esfuerzo extra que se
necesita para cambiarlo por un método más sofisticado.
Cuenta de distribuciones
Hay una situación muy especial para la que existe un sencillo algoritmo de or-
denación: «ordenar un archivo de N registros cuyas claves son distintos núme-
ros enteros entre 1 y N.» Este problema puede resolverse utilizando un array
temporal b con la sentencia for (i = 1; i <= N; i++) b[a[i]] = a[i]. (O,
como se vio anteriormente, es posible, aunque bastante difícil, resolver este pro-
blema sin un array auxiliar.)
Un problema más realista, pero con el mismo espíritu, consiste en «ordenar
un archivo de N registros cuyas claves son números enteros entre O y M- ID. Si
M no es demasiado grande, se puede utilizar para resolver este problema un al-
goritma denominado cuenta de distribuciones. La idea es contar el número de
clavesde cada valor y después utilizar los números que se han contado para des-
plazar los registros hacia su posición durante un segundo recomdo a través del
archivo, como se indica en el código siguiente:
for (j = O; j <M; j++) contador[j] = O;
for (i = 1; i <= N; i++) contador[a[i]]++;
for (j = 1; j <M; j++) contador[j] += contador[j-11;
for (i = N; i >= 1; i--) b[contador[a[i]]--1 = a[i];
for (i = 1; i <= N; it+) a[i] = b[i];
Para ver cómo funciona este código, se considera el archivo de ejemplo for-
mado por los números enteros de la fila superior de la Figura 8.11. El primer
bucle for inicializa el contador a O; el segundo pone contador[l]=6, conta-
dor[2]=4, contador[3]=1, y contador[4]=3 ya que hay seis letras A, cuatro
B, etc. A continuación el tercer bucle for acumula estos números y se obtiene
contador[l]=6, contador[2]=10, contador[3]=ll, y contador[4]=15. Esto
es, hay seis claves inferiores o iguales que A, diez claves menores o iguales que
B, etcétera.
Ahora, los contadores pueden servir de índices para ordenar el array, como
se muestra en la figura. El array original a se muestra en la línea superior; el
resto de la figura muestra el array temporal que se está rellenando. Por ejemplo,
cuando se encuentre la A al final del archivo, se colocará en la posición 6, ya
que contador[11 indica que hay seis claves menores o iguales que A. Después
MÉTODOSDE ORDENACION ELEMENTALES 125
Figura 8.11. Cuentade distribuciones.
126 ALGORITMOS EN C++
contador [11 se reduce en una unidad, de manera que ahora hay una clave me-
nos entre las menores o iguales que A. A continuación, la D de la penúltima
posición del archivo se coloca en la posición 14 y contador[4] se reduce en
una unidad, etc. El bucle interno se realiza desde N hasta 1, de modo que la
ordenación será estable. (El lector puede intentar comprobar esta afirmación.)
Este método funcionará muy bien para los tipos de archivos descritos ante-
riormente. Además, se puede utilizar para obtener métodos mucho más poten-
tes que se examinarán en el Capítulo 10.
Ejercicios
1. Dar una sucesión de operaciones comparar-intercambiam para ordenar
cuatro registros.
2. ¿Cuál de los tres métodos elementales (ordenación por selección,por inser-
ción, de burbuja) es más rápido en un archivo que ya está ordenado?
3. ¿Cuál de los tres métodos elementales es más rápido para un archivo que
está en orden inverso?
4. Comprobar la hipótesis de que la ordenación por selecciónes el más rápido
de los métodos elementales (para ordenar números enteros), seguida por la
inserción y después por la ordenación de burbuja.
5. Dar una buena razón de por qué puede no ser convenienteutilizar una clave
centinela en la ordenacicn por inserción (aparte de la que se dio en la im-
plementación de !a ordenaciófi de Shell).
6. ¿Cuántas comparaciones utilizará la ordenación de Shell para hacer uaa 7-
ordenación, y después una 3-ordenación de las claves C U E S T I O N F
A C I L ?
7. Dar un ejemplo para mostrar por qué 8, 4,2, I no sería una buena forma
de finalizar una sucesiónde incrementospara hacer una ordenaciónde Shell.
8. ¿Esestablela ordenación por selección?¿Yla ordenación por inserción?¿Y
la ordenación de burbuja?
9. Dar una versión especializada de la cuenta de distribuciones para ordenar
archivos donde los elementos sólo pueden tomar dos valores (o bien X o
bien y).
10. Experimentar con diferentes sucesionesde incrementos para la ordenación
de Shell: encontrar una que sea más rápida que la que se dio para un archivo
aleatorio de 1.O00 elementos.
9
Quicksort
En este capítulo se estudiará el algoritmo de ordenación que es probablemente
el más utilizado de todos: la ordenación rápida (Quicksorl).El algoritmo básico
fue inventado en 1960 por C.A.R. Hoare, y desde entonces ha sido objeto de
numerosos estudios. El Quicksort es popular porque no es difícil de implemen-
tar, proporciona unos buenos resultados generales (funciona bien en una am-
plia diversidad de situaciones)y en muchos casos consume menos recursos que
cualquier otro método de ordenación.
Entre las ventajas del algoritmo de ordenación rápida destacan: trabaja in
situ (utiliza sólo una pequeña pila auxiliar), necesita solamente del orden de
MogN operaciones en promedio para ordenar N elementos y tiene un bucle in-
terno extremadamente corto. Los inconvenientes son que es recursivo (si no se
puede utilizar la recursión la implementación es complicada), que en el peor
caso necesita aproximadamente N2operacionesy que es frágil: si durante la im-
plementación pasa inadvertido un simple error, puede causar un mal compor-
tamiento en ciertos archivos.
El rendimiento del Quicksort se entiende muy bien. Ha sido objeto de mi-
nuciosos análisis matemáticos y se puede describir con precisión. El análisis ha
sido comprobado por una extensa experiencia empírica y el algoritmo se ha re-
finado hasta el punto de convertirse en el método elegido en una gran variedad
de aplicaciones prácticas de ordenación. Esto hace que merezca la pena estu-
diarlo con más cuidridoque otros algoritmos, con el fin de implementar de ma-
nera eficaz el Quicksort. Existen técnicas de implementación similares que son
apropiadas para otros algoritmos y que pueden utilizarse con la ordenación rá-
pida porque así se comprende mejor su rendimiento.
Es muy tentador tratar de desarrollar formas de mejorar el Quicksort: en-
contrar un algoritmo de ordenación más rápido es una ds las utopías de la in-
formática. Casi desde el momento en que Hoare hizo público el algoritmo han
ido apareciendo en los libros versiones «mejoradas» del mismo. Se han inten-
tado y analizado muchas ideas, pero es fácil decepcionarse porque este algo-
ritmo está tan bien equilibrado que los efectos de las mejoras en una parte del
127
128 ALGORITMOS EN C++
programa pueden estar más que compensadospor las consecuencias de un mal
rendimiento en otra. En este capítulo se examinarán con algún detalle tres mo-
dificacionesque mejoran sustancialmenteel Quicksort.
Una versión afinada con cuidado del Quicksort es probable que se ejecute
más rápidamente en la mayoría de las computadoras que cualquier otro mé-
todo de ordenación. De cualquier forma, debe tenerse en cuenta que la opti-
mización de cualquier algoritmo puede hacerlo más frágil, conduciendoa efec-
tos indeseables e inesperados para ciertos datos de entrada. Una vez que se ha
desarrollado una versión que parezca estar libre de tales efectos, es probable-
mente ésta la que se debería tener como una de las utilidades de ordenación de
una biblioteca o en una aplicación de ordenación seria. Pero si no se está dis-
puesto a realizar un esfuerzo adicional para poner a punto una implementación
del Quicksort que resulte correcta, la ordenación de Shell podría resultar una
elección segura que funcionará bastante bien con un menor esfuerzo de imple-
mentación.
El algoritmo básico
El Quicksort es un método de ordenación de «divide y vencerás». Funciona di-
vidiendo un archivo en dos partes, y ordenando independientementecada una
de ellas. Como se verá, el punto exacto de la partición depende del archivo, y
así el algoritmo presenta la siguiente estructura recursiva:
void ordenrapido(tipoE1emento a[], int izq, int der)
i
int i;
if (r > izq)
I
I
i = particion (a, izq, der);
ordenrapido(a, izq, i-1);
ordenrapido (a, i+l , der) ;
1
1
Los parámetros izq y der delimitan el subarchivo del archivo original que se
va a ordenar; la llamada a ordenrapido (a, 1, N) ordena el archivo en su to-
talidad.
Lo esencial del método es el procedimiento parti cion,que debe reordenar
el array para que se verifiquen las siguientescondiciones:
(i) el elemento a[i ] está en su lugar definitivo en el array para algún i,
(ii) ninguno de los elementos de a[izq],..., a [i - 11, son mayores que
aril,
QUICKSORT 129
(iii) ninguno de los elementos de a [ i + l ] ,.., a[der] son menores que
Esto se puede implementar de una manera muy fácil y sencilla mediante la
siguiente estrategia general. En primer lugar, elegir a [der] de manera arbitraria
como el elemento que irá en su posición definitiva. Después, explorar el array
de izquierda a derecha hasta encontrar un elemento mayor que a [der], y vol-
verlo a explorar de derecha a izquierda hasta encontrar un elemento menor que
a[der]. Los dos elementos en los que se detiene el proceso están obviamente
mal situados en el array dividido resultante y por tanto se intercambian. (En
realidad, por razones que se darán posteriormente, es mejor detener también las
exploracionesen los elementos iguales a a [der],aunque parezca que al hacerlo
así se van a realizar algunos intercambios innecesarios). Continuando de esta
forma se tiene la seguridad de que todos los elementos del array situados a la
izquierda del puntero izquierdo son menores que a [der] y de que todos los si-
tuados a la derecha del puntero derecho son mayores que a[der]. Cuando los
punteros de exploración se cruzan, el proceso de partición está casi acabado: todo
lo que queda por hacer es intercambiar a [der] con el elemento que está más a
la izquierda del subarchivo derecho (el elemento apuntado por el puntero iz-
quierdo).
La Figura 9.1 muestra cómo se divide con este método el archivo ejemplo
de claves. Se elige como elemento de partición al elemento que está más a la
derecha, R. Al principio la exploración desde la izquierda se para en la R y a
continuación la exploración desde la derecha se para en la A (como se muestra
en la segunda línea de la tabla) y entonces se intercambian estas dos letras. A
continuación, como los punteros se cruzan, la exploración desde la izquierda se
para en la R, mientras que desde la derecha se detiene en la N. El movimiento
apropiado en este caso consiste en intercambiar la R de la derecha con la otra
R, dejando el archivo dividido tal y como se muestra en la última línea de la
Figura 9.1.
a[i].
Figura 9.1 Operación de partición.
130 ALGORiTMOS EN C++
Figura 9.2 Particiónde un archivo mayor.
El proceso de partición no es estable puesto que durante cualquier intercam-
bio toda clave podna desplazarse detrás de un gran número de claves iguales a
ella (que aún no se han examinado).
La Figura 9.2 muestra el resultado de dividir un archivo más grande: con los
elementos pequeños a la izquierda y los grandes a la derecha, el archivo divi-
dido presenta considerablemente más «orden» que el archivo aleatorio. La or-
denaciónse termina ordenando los dos subarchivosque quedan a cada lado del
elemento de partición (recursivamente). El siguienteprograma proporciona una
implementacióncompleta del método.
void ordenrapido(tipoE1emento a[], int izq, int der)
int i,j ; tipoE7emento v;
if (der > izq)
{
v = a[der]; i = izq-1; j = der;
{
for (;;I
while (a[++i] < v) ;
while (a[--j] > v) ;
if (i >= j) break;
intercambio(a, i,j ) ;
{
}
intercambio(a, i,der);
ordenrapido(a, izq, i-1);
ordenrapido (a, i+l, der) ;
}
1
En esta implementación, la variable v contiene el valor actual del ((elementode
partición» a[der], con i y j como los punteros izquierdo y derecho respecti-
vamente. El bucle de la partición se implementa como un bucle infinito, con
QUICKSORT 131
un break de salida cuando se cruzan los punteros. Este método es realmente la
demostración típica de por qué se utiliza la capacidad break: el lector podría
entretenerse considerando cómo se puede implementar el método de partición
sin utilizar break.
Como en la ordenación por inserción, se necesita una (clave centinela) para
detener la exploración cuando el elemento de partición sea el más pequeño del
archivo. En esta implementación no se necesita ningún centinela para detener
la exploración cuando el elemento de partición sea el más grande del archivo,
porque es él mismo quien la detiene en el lado derecho del archivo. Pronto se
verá una forma sencilla de eliminar ambas claves centinela.
El ((bucleinterno)) de la ordenación rápida implica simplemente incremen-
tar un puntero y comparar un elemento del array con un valor fijo. Esto es lo
que realmente hace rápido a este método de ordenación: es difícil imaginar un
bucle interno más sencillo. También aquí se encuentra una prueba del efecto
beneficioso de las claves centinela, puesto que añadir una comprobación super-
flua al bucle interno ticne un gran efecto en el rendimiento.
La ordenación termina ordenando recursivamente los dos subarchivos. La
Figura 9.3 muestra estasllamadas recursivas.Cada línea representa el resultado
de dividir el subarchivo, así como el elemento de partición elegido (sombreado
en el diagrama). Si la primera comprobación del programa fuera der >= i zq
en lugar de der > izq, cada elemento acabana por utilizarse como elemento
de partición para poderse colocar en su posición final; en la implementación
dada, los archivos de tamaño 1 no se dividen, como se puede ver en la Figura
9.3. Más adelante se estudiará una generalizaciónde esta mejora.
La característica más negativa del programa anterior es que para archivos
sencilloses muy ineficaz. Por ejemplo, si se le llama para un archivo que ya está
ordenado, las particionessenan degeneradasy el programa se llamm’a a sí mismo
N veces, quitando sólo un elemento en cada llamada. Esto significa no sólo que
el tiempo requerido será del orden de N2/2,sino que además la memoria nece-
saria para solventar la recursión será del orden de N (como se verá más ade-
lante), lo cual es inaceptable. Por fortuna hay formas relativamente sencillas de
evitar que este caso tan negativo pueda ocumr en las implementaciones reales
del programa.
Cuando hay clavesigualesen el archivo,hay dos detallesaparentemente poco
importantes, pero que en realidad sí lo son. En primer lugar se plantea la pre-
gunta de si ambos punteros se han de detener en las claves iguales al elemento
de partición o si uno debe parar y el otro continuar la exploración o si ambos
deben continuar. Esta cuestión se ha estudiado matemáticamente en detalle y
los resultados muestran que es mejor que se detengan los dos punteros. Esto
tiende a equilibrar las particiones cuando hay muchas claves iguales. Segundo,
se plantea la cuestión de solucionar adecuadamente el cruce de punteros cuando
hay clavesiguales. De hecho, el programa anterior puede mejorarse ligeramente
terminando la exploración cuando j < i y utilizar ordenrapido (a, izq, j)
para la primera llamada recursiva. Esto es una mejora porque cuando j=ise
pueden poner dos elementos en su posición dejando que el bucle se ejecute una
132 ALGORITMOS EN C++
Figura 9
.
3 Subarchivosen el Quicksort.
vez más. (Este caso ocumría, por ejemplo, si R fuera E en el ejemplo anterior.)
Probablemente merezca la pena hacer este cambio porque el programa, tal y
como se ha dado, deja un registro con una clave igual a la clave de partición en
a [der], y esto hace una primera partición degenerada en la llamada ordenra-
pi do (a, i+l, der) porque la clave situada más a la derecha es también la
más pequeña. La implementacióndel método de partición dado anteriormente
es algo más fácil de comprender,por lo que será la que se trate en la siguiente
presentación. Sin embargo, hay que ser consciente de que esta modificación de-
bería realizarse cuando haya un gran nUmero de claves iguales.
QUICKSORT 133
Característicasde rendimiento del Quicksort
Lo mejor que podría ocurrir en la ordenación rápida sena que en cada etapa de
partición se dividierael archivo exactamentepor la mitad. Esto haría que el nú-
mero de comparaciones a utilizar por la ordenación rápida satisficierala recu-
rrencia del método divide y vencerás
El término 2 c N / 2 cubre el coste de ordenación de los dos subarchivos; el tér-
mino N es el coste de examinar cada elemento, utilizando un puntero de parti-
ción o el otro. Desde el Capítulo 6 se sabe que esta recurrencia admite la solu-
ción
Aunque las cosas no siempre van tan bien, lo que sí que es cierto es que los
elementos de partición caen en el centro,por término medio. El tener en cuenta
la probabilidad exacta de cada posición del elemento de partición complica la
relación de recurrencia y la hace más difícil de resolver, pero el resultado final
es similar.
'
Propiedad 9.1 El Quicksort utiliza del orden de 2NlnN comparacionespor tér-
mino medio.
La fórmula exacta de recurrencia para el número de comparaciones utilizadas
por la ordenación rápida para una permutación aleatoria de N elementos es
El término N + 1 cubre el coste de comparar el elemento de partición con cada
uno de los otros (más dos extra para el cruce de punteros); el resto viene de la
observaciónde que cada elemento k tiene la probabilidad l/k de ser el elemento
de partición, tras lo que quedan archivos aleatoriosde tamaño k - 1 y N - k.
Aunque parece algo complicada, esta recurrencia es realmente fácil de re-
solver en tres pasos. Primero, Co + CI+ ... + CNp1
es lo mismo que C+]+ C N - ~
+ ... + Co,por lo que se tiene
Segundo,se puede eliminar el sumatorio multiplicando ambos miembros por N
y restando la misma fórmula para N - 1:
134 ALGORITMOS EN C++
Esto se simplificaa la recurrencia
Tercero, dividiendo ambos miembros por N(N + 1) se obtiene una simplifica-
ción en cadena de la recurrencia:
CN-2 2 2 2
-- -- - +-+--
+---
2
C
N CN-I
N + 1 N N+1 N-1 N N + 1
Esta solución exacta es casi igual a un sumatorio, que se puede aproximar fácil-
mente por una integral:
lo que conduce ai resultado esperado. Se observa que 2MnN = 1,38 MgN, por
lo que el número medio de comparaciones es sólo un 38% más alto que el caso
más favorab1e.i
Por tanto, la implementación anterior tiene un comportamiento muy bueno
para archivos aleatonos, lo que hace que este método de ordenación sea muy
adecuado para muchas aplicaciones. Sin embargo, si se va a utilizar la ordena-
ción un gran número de veces o si se va a aplicar para ordenar un gran archivo,
sena útil implementar algunas de las mejoras descritasposteriormente y que ha-
cen menos probable que ocurra un caso negativo, reduciendo el tiempo medio
de ejecución en un 20 % y eliminando fácilmente la necesidad de utilizar una
clave centinela.
Eliminaciónde la secursión
Al igual que se hizo en el Capítulo 5, se puede eliminar la recursión del pro-
grama del Quicksort utilizando explícitamente una pila donde se imagina que
se coloca el «trabajoque queda por hacen) en forma de subarchivos a ordenar.
Siempre que se necesite procesar un subarchivo, se sacará de la pila. Al hacer la
partición, los dos subarchivos que se crean para procesar se pueden colocar en
la pila. Esto conduce a la siguienteimplementación no recursiva:
void ordenrapido(tipoE1emento a[], int izq, int der)
int i ; Pila<int> sa(50);
{
QUICKSORT 135
for (;;I
{
{
while (der > izq)
i = particion(a, izq, der);
i f (i-izq > der-i)
el se
{ sa.meter(i); sa.meter(i-1); izq=i+i; }
{ sa.meter(i+l); sa.meter(der); der=i-1; }
1
if (sa.vaci a() ) break;
der = sa.sacar(); izq = sa.sacar();
Este programa se diferencia del descrito con anterioridad en dos cuestionesfun-
damentales. Primero, los dos subarchivos no se colocan en la pila de forma ar-
bitraria, sino que previamente se comprueban sus tamaños y se coloca primero
en la pila el más grande de los dos. Segundo, el más pequeño de los dos subar-
chivos no se coloca en la pila; simplementese inicializan los valores de los pa-
rámetros. Ésta es la técnica de «eliminación de la recursión final» tratada en el
Capítulo 5. Para el Quicksort, la combinación de la {(eliminaciónde la recur-
sión final» y la política de procesar primero el más pequeño de los dos subar-
chivos asegura que la pila necesite espacio solamente para unos l o oelementos,
porque cada elemento de la pila, diferente de la cabeza, debe representar a un
subarchivo de menos de la mitad de tamaño que el elemento que está debajo
de él.
Esto representa un fuerte contraste con el tamaño de la pila en el peor caso
de la implementaciónrecursiva, que podría ser tan grande como N (por ejem-
plo, cuando el archivo ya está ordenado). Ésta es una dificultad sutil pero real
de la implementaciónrecursiva del Quicksort: siempre hay una pila subyacente,
y un caso degenerado en un gran archivo podria causar una terminación anor-
mal del programa por falta de memoria, comportamientoobviamente indesea-
ble en una rutina de biblioteca de ordenación. Más adelante se verá cómo con-
seguir que estos casos degeneradossean muy improbables, pero es difícil eliminar
este problema en una representación recursiva sin la técnica «eliminaciónde la
recursión final». (Ni siquiera ayuda el invertir el orden en que se procesan los
subarchivos.)Por otro lado, algunos compiladores de C++ eliminan automáti-
camente la recursión y algunas máquinas ofrecen directamente la recursión en
el hardware, de manera que en tales entomos el programa anterior podria ser
más lento que la implementaciónrecursiva.
La simple utilización de una pila explícita en el programa anterior conduce
a programas más segurosy quizásmás eficacesque la implementaciónrecursiva
directa. Si los dos subarchivos tienen sólo un elemento, se coloca en la pila un
136 ALGORITMOS EN C++
_____ ~ ~ ~ ~ ~ _ _ _ ~
Figura 9.4 Subarchivosen el Quicksort (no recursivo).
subarchivocon der = izq únicamente para ser descartadoinmediatamente. Es
fácil cambiar el programa para que no coloque tales archivos en la pila. Este
cambio es aún más efectivocuando se incluye la mejora que se describe a con-
tinuación, ya que implica ignorar de la misma forma a los subarchivos peque-
ños, y así serán mucho mayores las posibilidadesde que ambos subarchivosno
se tengan en cuenta.
Por supuesto, el método no recursivo procesa los mismos subarchivosque
el recursivo, para cualquier archivo;simplementelo hace en orden diferente.La
Figura 9.4 muestra las particiones en el caso del ejemplo: las dos primeras par-
ticiones son las mismas, pero después el método no recursivo divide primero el
subarchivo de la derecha de N porque es más pequeño que el de la izquierda,
etcétera.
Si «se unen» las Figuras 9.3 y 9.4 y se conecta cada elemento de partición a
QUICKSORT 137
Figura 9.5 Árbol del procesode particióndel Quicksort.
su homólogo de los dos subarchivos, se obtendrá la representación estática del
proceso de partición mostrado en la Figura 9.5. En este árbol binario, cada sub-
archivo se representa por su elemento de partición (o por su único elemento, si
es de tamaño uno), y los subárboles de cada nodo son los árboles que represen-
tan los subarchivos después de la partición. Los nodos del árbol representados
por un cuadradoson los subarchivos nulos. (Para mayor claridad, la segunda A,
la D, la M, la O, la P, las dos E finales y la R tienen dos subarchivos nulos: tal
y como se vio anteriormente, las variantes del algoritmo tratan de distinta forma
los subarchivos nulos.) La implementación recursiva de la ordenación rápida
consiste en recorrer los nodos de este árbol por orden previo; la implementación
no recursiva se corresponde con la regla de «visitar primero el subárbol más pe-
queño». En el Capítulo 14 se verá cómo este árbol conduce a una relación di-
recta entre el Quicksort y un método fundamental de búsqueda.
Subarchivos pequeños
La segunda mejora de la ordenación rápida surge al observar que un programa
recursivo necesariamente se llama a sí mismo para muchos subarchivos peque-
ños, por lo que se debería utilizar el mejor método posible cuando se encuen-
tren subarchivos de este tipo. Una forma evidente de hacer esto consiste en mo-
dificar la comprobaciónal principio de la rutina recursiva ((i f (der > izq)))
para poder hacer una llamada a la ordenación por inserción (modificada para
aceptar los parámetros que definan ai subarchivoa ordenar),es decir, (( if (der-
izq <=M) insercion(izq, der) .»Aquí M es algún parámetro cuyo valor
exacto depende de la implementación. El valor elegido para M no necesita ser el
mejor posible: el algoritmo trabaja casi lo mismo para cualquier M con valores
entre 5 y 25. La reducción del tiempo de ejecución es del orden de un 20%para
la mayoría de las aplicaciones.
Una forma ligeramente más fácil de ordenar subarchivos pequeños, que
además es algo más eficaz, consiste tan sólo en cambiar la comprobación del
138 ALGORITMOS EN C++
1 .
..
. . . .=:
. .
- . m .
. . . .
..
. . . . .
. 9
. . .
= . .-..
u .
=.
.....-
..-=. -=
..$d
.
:
.. .
......
....
.....
..
:.-.-
.....
- m -
.= = .=.
"
1
..
..
- .
It. 1 It.
Figura 9.6 Quicksort (recursivo, ignorandoI05 SI
.....
..
I
.
:
;
=
'
+
.
..
..
....
..
r:
.- =
..
Y
..-=y.
9 . . C.
...
p
.
..=
I
It.
Jbarchivos pequeños).
principio por «if (der-izq > M) D: es decir, simplemente ignorar los sub-
archivos pequeños durante la partición. En la implementación no recursiva se
haría esto evitando poner en la pila los archivos menores que M. Tras la parti-
ción, lo que se obtiene es un archivoque está casi ordenado. Sin embargo, como
se mencionó en el capítulo anterior, !
a ordenación por inserción es el mStodo a
elegir para ordenar tales archivos. Es decir, la ordenación por inserción es tan
eficaz para este tipo de archivos como para el conjunto de archivos pequeños
que se obtendría si se utilizara directamente. Este método debe emplearse con
precaución porque probablemente la ordenación por inserción ordene siempre,
incluso si Quicksort tiene un error que impide que funcione. La única evidencia
de que algo va mal puede ser el coste excesivo.
La Figura 9.6 da una visión de este proceso en un array grande, ordenado al
azar. Estos diagramas representan gráficamente cómo cada partición divide un
subarray en dos subproblemas independientes que deberian abordarse por se-
parado. En estas figuras se muestra un subarray en cada uno de los cuadrados,
que contiene cuadros de puntos aleatoriamente rcordenados; el proceso de par-
tición divide el cuadrado en otros dos más pequeños, con un elemento (el de
partición) sobre la diagonal. Los elementos que no están implicados en la par-
tición acabarán bastante cerca de la diagonal y el array resultante se opera fá-
cilmente mediante la ordenación por inserción. Tal y como se indicó antes, el
diagrama correspondiente a una implementación no recursiva de Quicksort es
similar, pero las particiones se hacen en un orden diferente.
QUICKSORT 139
Partición por la mediana de tres
La tercera mejora de la ordenación rápida consiste en utilizar un elemento de
partición mejor. Aqcí hay varias posibilidades.Para evitar el peor caso, la elec-
ción más segura sena utilizar como elemento de partición un elemento aleato-
rio del array. Entonces, la probabilidad del peor caso será muy baja. Éste es un
sencillo ejemplo de un «algoritmo probabilists), que utiliza números pseudoa-
leatorios para tener casi siempre un buen rendimiento, independientemente del
orden de los datos de entrada. Los números pseudoaleatorios pueden ser una
herramienta útil en el diseño de algoritmos, en especial si se sospecha alguna
tendencia en los datos de entrada. En el caso del Quicksort probablemente es
excesivoutilizar un generador de números aleatonos completo sólo con este fin:
será suficiente con un número arbitrario (ver Capítulo 35).
Una mejora más útil consiste en tomar tres elementos del archivo y después
utilizar la mediana de los tres como elemento de partición. Si los tres elementos
elegidosprovienen de la izquierda, del centro y de la derecha del array, se puede
evitar el uso de centinelas de la siguiente manera: ordenar los tres elementos
(utilizando el método de los tres intercambios del capítulo anterior), despuésin-
tercambiar el del medio con a [der-1 ], y a continuación, ejecutar el algoritmo
de partición sobre a [izq+l],..., a [der-21. A esta mejora se le conoce como el
método de la partición por la mediana de tres.
Este método mejora el Quicksort de tres formas. En primer lugar, hace rnu-
cho más improbable que ocurra el peor caso en cualquier ordenación real. Esto
es así porque para que la ordenación dure un tiempo N2,dos de !os tres elemen-
tos examinados deberían estar entre los más grandes o los más pequeños de los
elementos del archivo, y esto debería ocurrir en la mayoría de las particiones.
En segundo lugar, elimina la necesidadde la clave centinela al hacer la partición
porque esta función la realizan los tres elementos examinados antes de la par-
tición. En tercer lugar, de hecho reduce el total del tiempo medio de ejecución
del algoritmo en un 5 % aproximadamente.
La combinación de una implementación no recursiva, del método de la par-
tición por la mediana de tres y de un tratamiento aislado de subarchivospeque-
ños puede mejorar el tiempo de ejecución de la ordenación rápida alrededor de
un 25 a 30%con respecto a la implementación recursiva directa. Son posibles
otras mejoras algorítmicas (por ejemplo, podría utilizarse la mediana de cinco
o más elementos), pero la cantidad de tiempo que se gana es despreciable. Po-
drían lograrse ahorros de tiempo más significativos (con menos esfuerzo) codi-
ficando los bucles internos (o el programa completo) en lenguaje ensamblador
o de máquina. Este camino no se recomienda, excepto posiblemente para ex-
pertos en aplicacionesde ordenación importantes.
140 ALGORITMOS EN C+t
Selección
Una aplicación relacionada con ordenaciones, en las que no siempre es nece-
saria una ordenación total, es la operación de encontrar la mediana de un con-
junto de números. Éste es un cálculo usual en estadística y en diversas aplica-
ciones de proceso de datos. Una forma de proceder sería ordenar los números y
seleccionar el del medio, pero se puede hacer mejor utilizando el proceso de
partición de la ordenación rápida.
La operación de encontrar la mediana es un caso particular de la operación
de selección: encontrar el k-ésimo elemento más pequeño de un conjunto de
números. Puesto que ningún algoritmo puede garantizar que un elemento sea
el k-ésimo más pequeño sin haber examinadoe identificadolos k - 1 elementos
que son menores que él y los N - k elementos que son mayores, la mayoría de
los algoritmos de selección pueden devolver todos los k elementos más peque-
ños de un archivo sin una gran cantidad de cálculos extra.
La selección tiene muchas aplicacionesen el proceso de datos expenmenta-
les y de otro tipo. Es muy común el uso de la mediana y de otras estadísticas de
urden para dividir un archivo en grupos más pequeños. A menudo sólo ha de
guardarse para procesos posteriores una pequeña parte de un gran archivo; en
tales casos, podría ser más apropiado un programa que pueda seleccionar, por
ejemplo, el 109’0más significativo de los elementos del archivo, en lugar de otro
que hiciera una ordenación total.
Ya se ha visto un algoritmo que puede adaptarse directamente a la selec-
ción. Si k es muy pequeño, entonces la ordenaciónpur selección será muy efi-
caz, necesitando un tiempo proporcional a N k primero encuentra el elemento
más pequeño, después el segundo más pequeño, buscando el más pequeño de
los que quedan, etc. Para un k algo más grande, se presentarán en el Capítulo
11 métodos que pueden adaptarse para que se ejecuten en un tiempo propor-
cional a Mogk. Puede formularse un método interesante a partir del procedi-
miento de partición empleado en el Quicksort, que se ejecute en un tiempo li-
neal sobre la media de todos los valores de k. Recuérdese que el método de
partición de la ordenación rápida reordena un array a [13 ,...,a [NI y de-
vuelve un entero i tal que a [l ],...,a [i-11 son menores o iguales que a [i ] y
a [i t1] ,...,a [NI son mayores o iguales que a [i 1. Si se busca el k-ésimo ele-
mento del archivo y se tiene que k==i,entonces ya está hecho. Por el contrario,
si k < i se tendrá que buscar el k-ésimo elemento más pequeño en el subar-
chivo izquierdo y si k > i entonces se tendrá que buscar el (k- i ) -esimo ele-
mento más pequeño del subarchivo derecho, Ajustando esto para encontrar el
k-ésimo elemento más pequeño de un array a [izq],...a[der] se llega al si-
guiente programa:
void selecc(tipoE1emento a[], int izq, int der, int k)
int i;
{
QUICKSORT 141
if (der > izq)
i = particion(a, izq, der);
i f (i > izq+k-1) selecc(a, izq, i-1, k ) ;
i f ( i < izq+k-1) selecc(a, i+l, der, k-i);
{
Este procedimiento reordena el array de forma que a[izq] ,... a[1-11 sean
menores o iguales que a [k] y a [k+l] ,...,a [der] sean mayores o iguales que
a [k].Por ejemplo, la llamada a se1ecc (1, N I (N+1)/2) divide al array sobre
su mediana. Para las claves del ejemplo de ordenación, este programa utiliza
sólo cinco llamadas recursivas para encontrar la mediana, como se muestra en
la Figura 9.7. Se reordena el archivo para que la mediana esté en un lugar tal
que a la izquierda están los elementos más pequeños que ella y los más grandes
están a la derecha (lo elementos iguales podrían estar en cualquier lado), pero
no está totalmente ordenado.
I
Figura 9.7 Partición para encontrar la mediana.
Puesto que el procedimiento se1ecc siempre termina con una llamada a sí
mismo, cuando llegue el momento de la llamada recursiva se podrá simple-
mente reinicializar los parámetros y volver al principio (no se necesita una pila
para eliminar la recursion). También es posible eliminar los cálculos simplesque
afectan a k, como en la siguiente implementación:
void selecc(tipoE1emento a[], int N, int k)
{
142 ALGORITMOS EN C++
int i, j, izq, der; tipoElemento v;
izq = 1; der = N;
while (der > izq)
v = a[der]; i = izq-1; j = der;
{
for (;;)
while (a[++i] < v) ;
while (a[--j] > v) ;
if ( i >= j) break;
intercambio(a, i , j);
{
1
intercambio (a, i , der) ;
if (i >= k) der = i-1;
if (i <= k) izq = i+l;
Se emplea un procedimiento de partición idéntico al que se utilizó en la orde-
nación rápida y, como éste, se podría modificar ligeramente si se esperan mu-
chas claves iguales.
La Figura 9.8 muestra el proceso de selección en un archivo (aleatorio)más
grande. Como en el Quicksort se puede afirmar (muy a grandes rasgos) que en
un archivo muy grande cada partición debería dividirlo en dos mitades y por
tanto todo el proceso necesitaría aproximadamente N + N/2 + N/4 + N/8 + ...
= 2N comparaciones. Al igual que en la ordenación rápida, este argumento
aproximado no está muy lejos de la realidad.
Propiedad 9.2 La selección basada en el Quicksort es de tiempo lineal por tér-
mino medio.
Un análisis similar, pero significativamente más complejo, que el dado ante-
riormente para el Quicksort conduce al resultado de que el número medio de
comparaciones es del orden de 2N + 2kln(N/k) + 2(N-k)ln(N/(N- k), que es
lineal para cualquier valor permitido de k. Para k = N/2 (búsqueda de la me-
diana), resulta aproximadamente (2 + 21n2)Ncomparaciones..
El peor caso es muy similar al que se da en la ordenación rápida: utilizar este
método para encontrar el elemento más pequeño de un archivo ya ordenado
daría como resultado un tiempo de ejecución cuadrático. Podría utilizarse un
elemento de partición arbitrario o aleatorio, pero con mucho cuidado: por
ejemplo, si se busca el elemento más pequeño, probablemente no se quiera di-
vidir el archivo por la mitad. Es posible modificar el procedimiento de selección
basado en la ordenación rápida para garantizar que el tiempo de ejecución sea
QUICKSORT 143
Figura 9.8 Búsquedade la mediana.
lineal. Estas modificaciones, aunque importantes en teoría, son extremada-
mente complejas y no del todo prácticas.
Ejercicios
1. Implementar una versión recursiva del Quicksort que ordene por inserción
los subarchivos con menos de M elementos, y determinar empíricamente
el valor de M para que el método, aplicado a un archivo aleatorio de 1.O00
elementos, iucione más rápido.
2. Resolver el problema anterior para una implementación no recursiva.
3. Resolverel problema anterior incorporando la mejora de la mediana de tres.
4. ¿Cuánto tiempo tardará el Quicksort en ordenar un archivo de N elementos
5. ¿Cuál es el número máximo de veces, durante la ejecución de Quicksort,
iguales?
que puede desplazarseel elemento mas grande?
144 ALGORITMOS EN C++
6. Mostrar cómo se divide el archivo A B A B A B A, utilizando los dos mé-
todos sugeridos en el texto.
7. ¿Cuántas comparaciones utiliza el Quicksort para ordenar las letras C U E
S T I O N F A C I L?
8. ¿Cuántas claves centinela) se necesitan si la ordenación por inserción se
llama directamente desde el Quicksort?
9. ¿Sería razonable utilizar una cola en lugar de una pila para una implemen-
tación no recursiva del Quicksort? ¿Por qué sí o por qué no?
10. Escribir un programa para reordenar un archivo de forma que todos los ele-
mentos con clavesiguales a la mediana estén en su lugar, con los elementos
más pequeños a la izquierda y los más grandes a la derecha.
10
Ordenación por residuos
En muchas aplicacionesde ordenación,las «claves» utilizadas para definir el or-
den de los registrosde los archivos pueden ser muy complicadas. (Considérese,
por ejemplo, el orden utilizado en una guía de teléfonos o en el catálogo de una
biblioteca.) Debido a esto, es preferible definir los métodos de ordenación en
términos de las operaciones básicas de «comparan>dos claves e ((intercambian)
dos registros. La mayoría de los métodos que se han estudiado pueden descri-
birse por medio de estas dos operaciones fundamentales. Sin embargo, en mu-
chas aplicaciones se aprovecha el hecho de que las claves pueden considerarse
como números de algún intervalo finito. Los métodos de ordenación que apro-
vechan las propiedades numéricas de estos números se denominan ordenacio-
nespor residuos. Estos métodos no sólo comparan las claves: además procesan
y comparan fragmentos de ellas.
Los algoritmos de ordenación por residuos tratan las claves como números
representados en un sistema de numeración en base M, para diferentes valores
de M (el residuo),y trabajan con las cifras que forman los números. Por ejem-
plo, considerando un oficinista que tiene que ordenar un conjunto de tarjetas
que tienen impresos números de tres dígitos,una manera razonable de proceder
sena hacer diez montones: uno para los números menores de 100,otro para los
números que están entre 100y 199, etc., poner las tarjetas en los montones y, a
continuación, tratarlos de forma individual, bien utilizando el mismo método
con las cifras siguientes o bien, si sólo quedan unas pocas tarjetas, utilizando
algún método más sencillo. Éste es un simple ejemplo de una ordenación por
residuos para izi(= 10. En este capítulo se estudiará con detalle éste y algún otro
método. Por supuesto, para la mayoría de las computadoras es más apropiado
trabajar con A
4 = 2 (o alguna potencia de 2) que con M = 10.
Todo lo que esté representado dentro de una computadoradigital puede tra-
tarse como un número binario, por lo que muchas aplicaciones de ordenación
pueden volverse a escribirpara hacer factiblela utilización de la ordenación por
residuos que operan con claves que sean números binarios. Por fortuna, C++
proporciona operadores de bajo nivel que hacen posible implementar tales ope-
145
146 ALGORITMOS EN C++
raciones de una manera directa y eficaz. Esto es importante porque hay otros
muchos lenguajes (como por ejemplo Pascal) que intencionadamente hacen que
sea difícil escribir un programa que dependa de la representación binaria de los
números.
Dado (una clave representada como) un número binario, la operación funda-
mental necesaria para la ordenación por residuos es la extracciónde un conjunto
contiguo de bits del número. Si, por ejemplo, se van a procesar claves que son
números enteros comprendidos entre O y 1.000, se puede suponer que éstos va-
lores están representados por números binarios de 10bits. En lenguaje de má-
quina, los bits se extraen de los números binarios utilizando operacionesde ma-
nipulación de bits, tales como la «y»y los desplazamientos.Por ejemplo, los dos
bits más significativosde un número de 10bits se extraen desplazándolos ocho
posiciones a la derecha y haciendo a continuación una operación binaria «y»
con la máscara 0000000011. En C++, estas operaciones se realizan directa-
mente con los operadores de manipulación de bits >> y &. Por ejemplo, los
dos bits más significativosde un número x de 10bits se obtienen por (x >>8)
& 03. En general, «poner a cero todos los bits de x excepto losj que están más
a la derecha» se puede obtener con x & (-0 < <j ) porque - (-0 < <j ) es una
máscara con unos en lasj posiciones de bits de la derechay con ceros en el resto.
Hasta el momento, en las implementaciones de los algoritmos de ordena-
ción se ha dejado sin especificar el tipo de las claves de los elementos que se van
a ordenar ( t ipoEl emento), con la suposición implícita de que los operadores
de comparación <,==, y > estarán disponibles para claves usuales tales como
números enteros, números de coma flotante, cadenas de caracteres.En los al-
gontmos de ordenación por residuos no se utilizan los operadores de compara-
ción, pero C++ permite ser explícitosrespecto a los operadores que sedesee uti-
lizar, tal y como se muestra en la siguiente definición para claves de enteros:
class cl avebi t s
private:
int x;
pub1 i c :
clavebits& operator=(int i )
{ x = i ; return *this; }
inline unsigned bits (int k, int j )
{ return (x >>k) & -(-O<< j ) ; }
{
1;
typedef cl avebi t s t i poEl emento;
ORDENACIÓN POR RESIDUOS 147
Esto significa que sólo se utilizarán dos operaciones en las claves tiPO-
El emento: asignación de un valor entero y bi tS. El código de asignación es un
estándar de C++. El operador bits utiliza las instrucciones de tratamiento de
bits descritas anteriormente para devolver los j bits de x que están a la derecha
de k bits. Por ejemplo, si t es del tipo tipoEl emento entonces la declaración t
= 1000 asigna simplemente 1.O00 (en binario 1111101000)al campo de datos
de t, entonces t.b its(2, 4) es 2 (10 en binario, los bits quinto y sexto de la
derecha de t). Para utilizar los algoritmos de ordenación por residuos se debe
tener un operador bits, de manera que las claves a ordenar puedan aparecer
de forma lógica como cadenas de bits. Se pueden incluir otras cosas en el tipo,
tales como el número máximo de bits de una clave o una forma de permitir que
cadenas de bits de longitud variable puedan ser claves. Como siempre, se omi-
tirán tales detalles para poder centrarse en las propiedades esenciales de los
algoritmos.
Armados con esta herramienta básica, se estudiarán dos tipos de ordenación
por residuos que se diferencian en el orden en el que se examinan los bits de las
claves. Se supone que las claves no son cortas, de manera que merece la pena
hacer el esfuerzo de extraer sus bits. Si las claves son cortas, entonces se puede
utilizar el método de cuenta de distribuciones del Capítulo 8. Recuérdese que
este método puede ordenar N claves enteras entre O y M - I en un tiempo li-
neal, utilizando una tabla auxiliar de tamaño M para los contadores y otra de
tamaño N para los registros reordenados. De este modo, si se puede disponer
de una tabla de tamaño 2', se puede ordenar fácilmente clavesde una longitudde
b bits en un tiempo lineal. La ordenación por residuos es útil si las claves son
suficientemente grandes (a partir de b = 32), donde no es posible esta ordena-
ción por cuenta de distribuciones.
El primer método básico de ordenación por residuos que se estudiará se de-
nomina ordenaciónpor intercambio de residuos y examina los bits de las claves
de izquierda a derecha, manipulando los registros de una forma similar a la or-
denación rápida. El segundo método, denominado ordenación directa por resi-
duos, examina los bits de las claves de derecha a izquierda y se puede ejecutar
en tiempo lineal en una serie razonable de circunstancias.
Ordenación por intercambio de residuos
Supóngaseque se pueden reordenar los registros de un archivo de manera que
todas aquellas claves que comiencen con el bit O se coloquen delante de todas
las que comiencen con el bit 1. Esto define un método de ordenación recursivo
como el de la ordenación rápida: si se ordenan los dos subarchivos indepen-
dientemente, todo el archivo estará ordenado. Para reorganizar el archivo se
examina éste empezando por la izquierda para encontrar una clave que em-
piece con el bit 1 y empezando por la derecha para encontrar una clave que
148 ALGORITMOS EN C++
empiece con el bit O, intercambiándolas y continuando así hasta que se crucen
los punteros:
void cambioresiduos(tipoE1emento a[] , int izq, int der, int b)
int i , j ; tipoElemento t ;
i f ( d e n i z q , && b>=O)
{
i = izq; j = der;
while ( j != i )
{
while (!a[i].bits(b, 1) && i < j ) i++;
while ( a [ j ] . b i t s ( b , 1) && j > i ) j--;
intercambio(a, i , j )
i f (!a[der] . b i t s ( b , 1)) j++;
cambioresiduos(a, izq, j-1, b-1);
cambioresiduos(a, j , der, b-1);
{
1
1
1
Lallamadaacambioresiduos(1, N , 30) ordenaráelarraysia[l], ...,a[N]
son enteros positivos menores que 232 (de manera que se puedan representar
como números binanos de 3 1 bits). La variable b ((guardala pista» del bit que
se está examinando, variando entre 30 (el que está más a la izquierda) y O (el
que está más a la derecha). El número exacto de bits a utilizar depende de una
manera directa de la aplicación, del número de bits por palabra de la máquina
y de la representación de los números enteros y de los negativos.
Evidentemente esta implementación es bastante similar a la implementa-
ción recursiva del método de ordenación rApida del Capítulo 9. En esencia, ha-
cer una partición en el método de ordenación por intercambio de residuos es
como hacerlo en el de ordenación rápida, excepto que en lugar de utilizar como
elemento de partición un número del archivoaquí se utiliza el número 2'. Como
se podría dar el caso de que 2hno pertenezca al archivo, no está garantizado que
durante la partición se coloque todo elemento en su lugar definitivo. Además,
como sólo se examina un bit, no se puede contar con centinelas para detener al
puntero durante la exploración, por lo que se incluyen las comprobaciones ( i
< j ) en los bucles de exploración.Esto se traduce en un intercambio extra para
el caso ( i == j ) , que podría evitarse con una instrucción break, como en la
implementación de la ordenación rápida; aunque en este caso el «intercambio»
de a[i ] consigo mismo no tiene consecuencias. El proceso de partición se de-
tendrá cuando j sea igual a i y todos los elementos a la derecha de a [i ] tengan
bits 1 en la posición b-ésima y todos los elementos a la izquierda de a [i ] ten-
ORDENAC!ÓN POR RESIDUOS 149
gan bits O en la posición b-ésima. El mismo elemento a [i ] tendrá un bit 1 a
menosque todas las claves del archivo tengan un O en la posición b. La imple-
mentación anterior tiene una comprobación extra justo después del bucle de
partición, para cubrir este caso.
La Figura 10.1 muestra cómo el ejemplo de archivo de claves se divide y
ordena por este método. Se puede comparar con la Figura 9.2 de la ordenación
rápida, aunque la operación del método de partición es completamente opaca
sin la representación binaria de las claves. La Figura 10.2 muestra la partición
en términos de la representación binaria de las claves. Se utiliza un código sen-
cillo de cinco bits, presentando a la letra i-ésimadel alfabeto mediante la repre-
sentación binaria del número i. Esto es una versión simplificada de la codifica-
ción real de caracteres, que utiliza más bits (siete u ocho) y representa a más
caracteres (mayúsculas, minúsculas, números, símbolos especiales). Tradu-
ciendo las claves de la Figura 10.1a estecódigo de caracteresde cinco bits, com-
primiendo la tabla de manera que el subarchivo dividido se represente «en pa-
ralelo)),y no uno por línea, y transponiendo después filas y columnas, se puede
mostrar en la Figura 10.2cómo los bits más significativosde las claves contro-
lan la partición. En esta figura cada partición se indica en el siguiente diagrama
a la derecha por un subarchivo «O» blanco seguidopor un subarchivo ((1)) gris,
excepción hecha de los subarchivos de tamaño 1 que desaparecen del proceso
de partición en cuanto se los encuentra.
Un serio problema potencial de la ordenación por residuos es que con fre-
cuencia se pueden dar particiones degeneradas (particiones en las que todas las
claves tienen el mismo valor que el bit que se está utilizando). Esta situación
surgefrecuentemente en archivos reales al ordenar números pequeños (con mu-
chos ceros en cabeza).Esto también puede ocumr para caracteres:por ejemplo,
suponiendo que claves de 32 bits están formadas por grupos de cuatro caracte-
res, codificadosen el código estándar de ocho bits, entonces probablemente se
den particiones degeneradas en las primeras posicionesde cada carácter, ya que,
por ejemplo, todas las minúsculas comienzan con los mismos bits en la mayoría
de los códigos de Caracteres. Hay otros muchos efectos similares que deben te-
nerse en cuenta al ordenar datos codificados.
En la Figura 10.2 se puede ver que una vez que se distingue una clave del
resto de ellas por sus bits izquierdos, no se vuelve a examinar ningún otro bit.
Esto es una ventaja en algunas ocasiones y un inconveniente en otras. La ven-
taja se da cuando los bits de las claves son verdaderamente aleatonos, ya que en
este caso cada clave difiere de las otras en unos 1gNbits, lo que podría ser mu-
cho menor que el número de bits de las claves. Esto es así porque, en una situa-
ción aleatoria, se espera que cada partición divida el subarchivo por la mitad.
Por ejemplo, ordenar un archivo con 1.O00 registros podria traer consigo el exa-
minar aproximadamente 10 u 11 bits de cada clave (incluso cuando las claves
son de 32 bits). Por el contrario, hay que destacar que se examinan todos los
bits de las claves iguales; la ordenación por residuos no funciona bien en archi-
vos que contienen muchas claves iguales. La ordenación por intercambio de re-
siduos es de hecho algo más rápida que la ordenación rápida si las claves que se
150 ALGORITMOS EN C++
~~~~
Figura 10.1 Subarchivosen la ordenación por intercambiode residuos.
ORDENACIÓN POR RESIDUOS 151
A O
O
O
a
l
A O0001
D O 0 1 0 0
E 00101
E 0 0 1 0 1
E 0 0 1 0 1
L O 1 1 0 0
M 0 1 1 0 1
N O 1 1 1 0
o o 1 1 1 1
o o 1 1 1 1
R 1 0 0 1 0
R 1 0 0 1 0
E O0101
J O 1 0 1 0
E O0101
M 01101
P 1 0 0 0 0
L O 1 1 0 0
o o 1 1 1 1
A O0001
o o 1 1 1 1
R 1 0 0 1 0
D O 0 1 0 0
E O0101
N O 1 1 1 0
A 0 0 0 0 1
R 1 0 0 1 0
L
E
E
E
D
A
A
o
L
o
N
M
J
R
P
R
O0101
O0101
O0101
O 0 1 0 0
O0001
O0001
o 1 1 1 1
O 1 1 0 0
o 1 1 1 1
0 1 1 1 0
O1101
O 1 0 1 0
1 0 0 1 0
1 0 0 0 0
l Q 0 1 0
A 00031
A O0001
E O0101
D O 0 1 0 0
E O0101
E O0101
L O 1 1 0 0
M 0 1 1 0 1
N 0 1 1 1 0
o o 1 1 1 1
o o 1 1 1 1
P 1 0 0 0 0
R 1 0 0 1 0
R 1 0 0 1 0
E
J
E
M
A
L
o
A
o
N
D
E
R
P
R
5 0 1 0 1
0 1 0 1 0
O0101
01101
O0001
O 1 1 0 0
o 1 1 1 1
0 0 0 0 1
o 1 1 1 1
0 1 1 1 0
0 0 1 0 0
0 0 1 0 1
1 0 0 1 0
1 0 0 0 0
~ O O i O
A
A
E
D
E
E
J
L
o
N
M
o
R
P
R
Figura 10.2 Ordenación por intercambio de residuos (ordenaciónpor residuos <deiz-
quierda a derecha))).
0 0 0 0 1
0 0 0 0 1
0 0 1 0 1
O 0 1 0 0
0 0 1 0 1
0 0 1 0 1
0 1 0 1 0
O 1 1 0 0
o 1 1 1 1
0 1 1 1 0
0 1 1 0 1
o 1 1 1 1
1 0 0 1 0
1 0 0 0 0
10fiiO
quiere ordenar están formadas por bits verdaderamente aleatonos, mientras que
la ordenación rápida se adapta mejor a las situaciones menos aleatonas.
En la Figura 10.3 se puede ver el árbol que representa el proceso de parti-
ción de la ordenación por intercambio de residuos, que se puede comparar con
la Figura 9.5. En este árbol binano, los nodos internos representan a los puntos
de partición, y los nodos externos son las clavesdel archivo que terminan todas
en subarchivos de tamaño 1. En el Capítulo 17 se verá cómo este árbol sugiere
una relación directa entre la ordenación por intercambio de residuos y un mé-
todo fundamental de búsqueda.
La implementación recursiva anterior se puede mejorar eliminando la re-
cursión y tratando de forma diferente a los subarchivospequeños, tal y como se
hizo en la ordenación rápida.
Figura 10.3 Diagrama de árbol del proceso de partición en una ordenación por inter-
cambio de residuos.
152 ALGORITMOS EN C++
E O 0 1 0 1
J 0 1 0 1 0
E 0 0 1 0 1
M o l l 0 1
P 1 0 0 0 0
L 0 1 1 0 0
O 0 1 1 1 1
A 0 0 0 0 1
O 0 1 1 1 1
R 1 0 0 1 0
D 0 0 1 0 0
E 0 0 1 0 1
N 0 1 1 1 0
A O 0 0 0 1
R 1 0 0 1 0
-
J O 1 0 1 0
P 1 0 0 0 0
L O 1 1 0 0
R 1 0 0 1 0
D O 0 1 0 0
N 0 1 1 1 0
R 1 0 0 1 0
E 0 0 1 0 1
E 0 0 1 0 1
M 0 1 1 0 1
O 0 1 1 1 1
A O 0 0 0 1
O O 1 1 1 1
E O 0 1 0 1
A O O O O J
P
A
A
J
R
R
L
D
E
E
M
E
v
o
Q
-
1 0 0 0 0
O 0 0 0 1
O 0 0 0 1
O 1 0 1 0
1 0 0 1 0
1 0 0 1 0
O 1 1 0 0
O 0 1 0 0
O 0 1 0 1
O 0 1 0 1
O i l 0 1
O 0 1 0 1
O 1 1 1 0
o 1 1 1 1
O l J l l
P
A
A
R
R
D
E
E
E
J
L
M
N
o
o
A O 0 0 0 1
A O 0 0 0 1
D O 0 1 0 0
E O 0 1 0 1
E O 0 1 0 1
E O 0 1 0 1
J O 1 0 1 0
L O 1 1 0 0
M O 1 1 0 1
N O 1 1 1 0
o o 1 1 1 1
o o 1 1 1 1
o 1 0 0 0 0
R 1 0 0 1 0
R 1 0 0 1 0
-
1 0 0 0 0
O 0 0 0 1
O 0 0 0 1
1 0 0 1 0
1 0 0 1 0
O 0 1 0 0
O 0 1 0 1
O 0 1 0 1
O 0 1 0 1
O 1 0 1 0
O 1 1 0 0
0 1 1 0 1
0 1 1 1 0
o 1 1 1 1
0 1 1 1 1
Figura 10.4 Ordenación directa por residuos (ordenación por residuos (<dederecha a
izquierda))).
P 1 0 0 0 0
L O 1 1 0 0
D O 0 1 0 0
E O 0 1 0 1
E O 0 1 0 1
M O 1 1 0 1
A O 0 0 0 1
E 0 0 1 0 1
A O 0 0 0 1
J O 1 0 1 0
R 1 0 0 1 0
N 0 1 1 1 0
R 1 0 0 1 0
o o 1 1 1 1
o O l l J l
Ordenación directa por residuos
-
Un método alternativo de la ordenación por residuos consiste en examinar los
bits de derecha a izquierda. Éste era el método utilizado por las antiguas má-
quinas encargadas de ordenar las tarjetas perforadas de ias computadoras: un
mazo de tarjetas se procesaba 80 veces, una por cada columna, procediendo de
derecha a izquierda. La Figura 10.4muestra cómo funciona una ordenación por
residuos de derecha a izquierda, bit a bit, sobre el ejemplo de archivo de claves.
En los diagramas, la i-ésima columna se ordena mediante el rastreo de los bits
que están en la posición i de la clave y se deduce de la columna anterior ((i -
1)-ésima)extrayendo todas las clavesque tienen un O en el bit i-ésimoy después
todas las que tienen un 1 en el i-ésimo bit.
No es fácil convencerse de que el método funciona; de hecho no lo hace a
menos que el proceso de partición sobre un bit sea estable. Una vez que se ha
identificado la importancia de la estabilidad, se puede encontrar una prueba tri-
vial de que sí funciona: después de poner las claves que tienen un O en el i-ésimo
bit, delante de las que tienen un 1 en el i-ésimo bit (de una manera estable), se
sabe que dadas dos claves cualesquiera están en el orden adecuado (conforme a
los bits ya examinados) en el archivo, bien porque sus i-ésimos bits son diferen-
tes, en cuyo caso la partición los coloca en el orden adecuado, o bien porque
son iguales, en cuyo caso ya están en el orden correcto debido a la estabilidad.
El requisito de estabilidad significa, por ejemplo, que el método de partición
utilizado en la ordenación por intercambio de residuos no se puede utilizar en
esta ordenación de derecha a izquierda.
El proceso de una partición es parecido a ordenar un archivo con sólo dos
ORDENACIÓN POR RESIDUOS 153
valoresy que la ordenación por cuenta de distribuciones es muy apropiada para
esto. Si se supone que M = 2 en el programa de la cuenta de distribuciones y se
reemplaza a[i ]por bi ts(a [i ],k ,1) ,entonces el programa se convierte en un
método para ordenar los elementos del array a tomando los bits que están en la
posición k empezando por la derecha y colocando el resultado en el array tem-
poral b. Pero no existe ninguna razón para utilizar M = 2; de hecho, se debería
hacer M tan grande como sea posible, puesto que se necesita una tabla de A
4
contadores. Esto corresponde a utilizar rn bits a la vez durante la ordenación,
con M = 2'". Así, la ordenación directa por residuos se convierte en poco más
que en una generalización de la ordenación por cuenta de distribuciones, como
puede verse en la siguiente implenientación que permite ordenar a[11 ,...,
a[NI para los w bits más a la derecha:
void directaresiduos(tipoE1emento a[] , tipoElemento b[] , int N)
int i, j, pasar, contador[M-11;
for (pasar = O; pasar < w/m; pasar++)
{
for
for
for
for
for
1
1
}
(j = O; j < M; j++) contador[j] = O;
( i = 1; i <= N; i++)
contador[a[i].bits(pasar*m, m)]++;
(j = 1; j < M; j++)
contador[j] += contador[j-11;
(i = N; i >= 1; i--)
b[contador[a[i] .bits(pasar*m, m)]--1 = a[i];
(i = 1; i <= N; i++) a[i] = b[i];
Esta implementación supone que el procedimiento de llamada pasa el array au-
xiliar como un parámetro de entrada al mismo tiempo que el array a ordenar.
La correspondencia M = 2'" se ha preservado en los nombres de las variables,
pero los lectores deben tener en cuenta que en algunos entornos de programa-
ción no podrán establecerla diferencia entre m y M.
El procedimiento anterior funciona adecuadamente sólo si w es múltiplo de
m. Por lo regular, esto no será una restricción que haya que asumir para la or-
denación por residuos: tan sólo corresponde a dividir las claves a ordenar en un
número entero de partes del mismo tamaño. Cuando m==w se obtiene la orde-
nación por cuenta de distribuciones; cuando m==l se obtiene la ordenación di-
recta por residuos, la ordenación por residuos de derecha a izquierda y bit a bit,
descrita en el ejemplo anterior.
La implementación anterior mueve el archivo de a hasta b durante cada fase
de la cuenta de distribuciones, despu6s vuelve a a con un sencillo bucle. Este
154 ALGORITMOS EN C++
bucle de ««copia
de array)podría eliminarse, si se desea, haciendo dos copias del
código de dicha cuenta, una para ordenar de a hacia b y la otra para ordenar de
b hacia a.
Característicasde rendimiento de la ordenación por residuos
Los tiempos de ejecución de las dos ordenaciones por residuos básicas,para or-
denar N registros con claves de b bits, son esencialmente Nb. Por un lado se
puede pensar que este tiempo de ejecución es equivalente a MogN, ya que si los
números son todos diferentes, b debe ser al menos lo@. Por otro lado, los dos
métodos realizan normalmente muchas menos de Nb operaciones: el método
de izquierda a derecha, porque se puede detener en cuanto se hayan encontrado
las diferencias entre claves, y el método de derecha a izquierda, porque puede
procesar muchos bits a la vez.
Propiedad 10.1
mino medio, aproximadamente NlgN bits.
La ordenaciónpor intercambio de residuos examina, por tér-
Si el tamaño del archivo es una potencia de dos y los bits son deatonos, se puede
esperar que la mitad de los bits más significativos sean O y la otra mitad sean 1,
por lo que la recurrencia CN= 2CN,2+ N podría describir el rendimiento, como
en el caso de la ordenación rápida del Capítulo 9. De nuevo, esta descripción
de la situación no es exacta, porque el elemento de partición solamente coin-
cide con el centro en el caso medio (y porque las claves tienen un número finito
de bits). Sin embargo, en este modelo, es mucho más probable que dicho ele-
mento esté en el centro que en la ordenación rápida, de manera que la propie-
dad resulta ser cierta. (Para probar esto se necesitaun análisis detallado que está
más allá del alcance de este libro.).
Propiedad 10.2 Las dos ordenaciones por residuos examinan menos de Nb bits
para ordenar N claves de b bits.
En otras palabras, la ordenación por residuos es lineal en el sentido de que el
tiempo que se necesita es proporcional al número de bits de la entrada. Esto se
deduce directamente estudiando los programas: ningún bit se examina más de
una vez..
Para archivos aleatonos grandes, la ordenación por intercambio de residuos
tiene un comportamiento parecido a la ordenación rápida, como se muestra en
la Figura 9.6; pero la ordenación directa por residuos se comporta de forma muy
diferente. La Figura 10.5 muestra las etapas de la ordenación directa por resi-
duos de un archivo con claves de cinco bits. En estos diagramas aparece clara-
mente la organización progresiva del archivo durante la ordenación. Por ejem-
plo, después de la tercera etapa (abajo a la izquierda), el archivo está formado
ORDENACIÓN POR RESIDUOS 155
..
.. .
..
.. ....
.. : .
.
:- .
. . ..
:
...
- .
. .-..
- .-
9.
. ..
- .
. '
. .. . - .
. .
. -
. -
. .
. .
. .
= .
. . =.
.. m ,
....
. . .
Figura 10.5 Etapas de una ordenación directa por residuos.
por cuatro subarchivos entremezclados: las claves que comienzan por O
0 (banda
inferior), las claves que comienzan por O1, etcétera.
Propiedad 10.3 La ordenación directa por residuos puede ordenar N registros
con claves de b bits en b/m pasos, utilizando un espacio extra de 2" contadores
(y una memoria intermedia para reorganizar el archivo).
La demostración de esta propiedad se deduce directamente de la implementa-
ción. En particular, si se puede tomar m = b/4 sin utilizar demasiada memona
extra, se obtiene una ordenación lineal. Las consecuenciasprácticas de esta pro-
piedad se tratarán con más detalle a continuación.m
Una ordenación lineal
La implementación de la ordenación directa por residuos, dada en la sección
anterior, efectúa b/rn pasos a través del archivo. Haciendo m muy grande se ob-
tiene un método de ordenación muy eficaz, al menos mientras se disponga de
M = 2" palabras de memoria disponible. Una elección razonable consiste en
tomar m con un valor aproximado a la cuarta parte del tamaño de una palabra
156 ALGORITMOS EN C++
(b/4), ya que de esta manera la ordenación por residuos se hará en cuatro pa-
sadas de la cuenta de distribuciones. Se tratan las claves como números en base
A
4 y se examina cada dígito (en base M) de cada clave, aunque sólo hay cuatro
dígitos por clave. (Esto se corresponde directamente con la organización de la
arquitectura de muchas computadoras: una organización representativa tiene
palabras de 32 bits, cada una de ellas formada por cuatro bytes de ocho bits. En
este caso, el procedimiento bi t s acaba extrayendo ciertos bytes de las palabras,
lo que se puede realizar fácilmente en tales computadoras.)Ahora, cada pasada
de la cuenta de distribuciones es lineal y, como sólo hay cuatro, toda la orde-
nación es lineal. Por consiguiente, éste es el mejor rendimiento que se podría
esperar de una ordenación.
De hecho, incluso podría bastar con sólo dos pasadas de la cuenta de distri-
buciones. (A estas alturas es probable que incluso un lector cuidadoso tenga di-
ficultades para distinguir derecha de izquierda, por lo que puede ser necesario
hacer un pequeño esfuerzo para comprender este método.) Se realiza aprove-
chando el hecho de que si sólo se utilizan los b/2 bits más significativos de las
claves de b bits, el archivo está casi ordenado. Al igual que se hizo en la orde-
nación rápida, se puede completar eficazmente la ordenación, aplicando más
tarde la ordenación por inserción sobre la totalidad del archivo. Este método es
una modificación trivial de la implementación anterior: para hacer una orde-
nación de derecha a izquierda utilizando la mitad delantera de las claves, sim-
plemente se comienza el bucle exterior con pasar = b/ (2* m) en lugar de em-
pezar con pasar = O. A continuación se puede aplicar una ordenación por
inserción convencional al archivo casi ordenado que se obtiene. Para conven-
cerse de que un archivo ordenado en sus bits más significativos está bastante
bien ordenado, el lector puede examinar las primeras columnas de la Figura 10.2.
Por ejemplo, aplicar la ordenación por inserción sobre un archivo ya ordenado
en sus tres primeros bits necesitaría solamente seis intercambios.
Utilizando dos pasadas de la cuenta de distribuciones (siendo rn aproxima-
damente igual a la cuarta parte del tamaño de una palabra) y utilizando después
una ordenación por inserción para terminar el trabajo, se obtiene un método de
ordenación que probablemente se ejecutará más rápido que cualquiera de los
que se han visto para grandes archivos cuyas claves son bits aleatonos. El pin-
cipal inconveniente es que se necesita un array auxiliar del mismo tamaño que
el que se está ordenando. Este array extra se puede eliminar utilizando técnicas
de listas enlazadas; pero aun así todavía se necesita un espacio extra proporcio-
nal a N (para los enlaces).
Es obvio que en muchas aplicaciones lo deseable es una ordenación lineal,
pero existen razones por las que no es la panacea que se podría imaginar. En
primer lugar, su eficacia depende mucho de que las claves estén formadas por
bits aleatorios y que estén ordenadas aleatoriamente. Si no se cumplen estas
condicioneses de esperar un rendimiento muy degradado. En segundo lugar, se
necesita un espacio extra proporcional al tamaño del array que se está orde-
nando. En tercer lugar, el ((bucleinterno» del programa contiene bastantes ins-
trucciones, así que, aun siendo lineal, no será mucho más rápido que, por ejem-
ORDENACIÓN POR RESIDUOS 157
plo, el de ordenación rápida, como cabría esperar,excepto para archivosbastante
grandes (pero en éstos el array extra se convierte en un verdadero obstáculo).
La ordenación por residuos podría caracterizarsecomo una aproximación a
una ordenación de ((utilidadespecial»,porque su viabilidad depende de propie-
dades especiales de las claves, en contraste con la «utilidad general)) de algont-
mos tales como el de ordenación rápida, que se utilizan mucho más porque se
adaptan a una gran variedad de aplicaciones. La elección entre la ordenación
rápida y la ordenación por residuos depende no solamente de las características
de la aplicación (como la clave, el registro y el tamaño del archivo), sino tam-
bién de las características del entorno de programación y de la máquina, que
están íntimamente relacionadas con la eficacia de acceso y la manipulación in-
dividual de los bits. En aplicaciones adecuadas, la ordenación por residuos se
puede ejecutar dos veces más rápidamente que la ordenación rápida, o incluso
más; pero podría no merecer la pena si el espacioes un problema potencial o si
las claves son de tamaño variable o no son necesariamente aleatonas, o ambas
cosas.
Ejercicios
1.
2.
3.
4.
5.
6.
7.
8.
Comparar el número de intercambios efectuados por la ordenación por in-
tercambio de residuos y la de ordenación rápida para el archivo 001, O11,
¿Por qué no es tan importante eliminar la recursión de la ordenación por
intercambio de residuos como lo era para la ordenación rápida?
Modificar el programa de ordenación por intercambio de residuos para ig-
norar los bits más significativosque son idénticos en todas las claves. ¿En
qué situaciones sería ventajosa esta técnica?
Verdadero o falso: el tiempo de ejecución de la ordenación directa por re-
siduos no depende del orden de las claves en el archivo de entrada. Razonar
la respuesta.
¿Qué método es probablemente más rápido para un archivo con todas las
claves iguales:el de ordenación por intercambio de residuos o el de orde-
nación directa por residuos?
Verdadero o falso: tanto la ordenación por intercambio de residuos como
la ordenación directa por residuos examinan todos los bits de todas las cla-
ves del archivo. Razonar la respuesta.
Aparte del requisito de memoria extra, jcuál es el mayor inconveniente
de la estrategia de realizar la ordenación directa por residuos sobre los bits
más significativos de las claves y terminar después con una ordenación por
inserción?
¿Cuánta memoria se necesita exactamente para hacer una ordenación di-
recta por residuos, en cuatro pasadas, de N claves de b bits?
101, 110,000,001, 001,010, 111, 110, 010.
158 ALGORITMOS EN C++
9. ¿Qué tipo de archivo de entrada hará que la ordenación por intercambio de
residuos se ejecute lo más lentamente posible (para un N muy grande)?
10. Comparar empíricamente la ordenación directa por residuos con la orde-
nación por intercambio de residuos para u11 archivo aleatorio de 10.000
claves de 32 bits.
I 1
Colas de prioridad
En muchas aplicacioneslos registros con claves se deben procesar en orden, pero
no necesariamente en orden completo, ni todos a la vez. A veces se forma un
conjunto de registros y se procesa el mayor; a continuación posiblemente se in-
cluyan otros elementos y luego se procesa el nuevo registro máximo y así suce-
sivamente. Una estructura de datos apropiada para un entorno como éste es
aquella que permita insertar un nuevo elemento y eliminar el mayor. Esta es-
tructura, que se puede contrastar con las colas (donde se elimina el más anti-
guo) o con las pilas (donde se elimina el más reciente), se denomina cola de
prioridad. De hecho, una cola de prioridad se puede considerar como una ge-
neralización de las pilas y de las colas (y de otras estructuras de datos simples),
puesto que estas estructuras se pueden implementar con colas de prioridad, ha-
ciendo las asignacionesde prioridad adecuadas.
Las aplicaciones de las colas de prioridad incluyen sistemas de simulación
(donde las claves pueden corresponder a (cronologíasde sucesos»que se deben
procesar en orden), planificación de tareas en los sistemas informáticos (donde
las claves pueden corresponder a «prioridades» que indican qué usuarios se de-
ben procesar en primer lugar) y cálculosnuméricos (dondelas clavespueden ser
errores de cálculo y por tanto se puede tratar en primer lugar el más grande).
Más adelante, en este libro, se verá cómo se pueden utilizar las colas de prio-
ridad como bloques básicos para la consiiucción de algoritmos más avanzados.
En el Capítulo 22 se desarrollará un algoritmo de compresión de ficheros utili-
zando las rutinas de este capítulo, y en los Capítulos 31 y 33 se verá cómo las
colas de prioridad pueden servir de base a muchos algoritmos fundamentales de
búsqueda en grafos. Éstos son sólo unos pocos ejemplos del importante papel
que desempeñan las colas de prioridad como herramienta básica en el diseño de
algoritmos.
Por razones de utilidad se debe precisar algo más sobre la forma de tratar las
colas de prioridad, puesto que existen varias operaciones que puede ser necesa-
rio llevar a cabo sobre ellas, para preservarlasy poderlas utilizar con eficacia en
aplicaciones como las mencionadas anteriormente. En verdad, la razón pnnci-
159
160 ALGORITMOS EN C++
pal por la que las colas de prioridad son tan útiles es la flexibilidad con que per-
miten llevar a cabo eficazmente una gran variedad de operaciones sobre con-
juntos de registros con claves. Lo que se desea es construir y mantener una
estructura de datos que contenga registros con claves numéricas (prioridades) y
que cuente con algunas de las operaciones siguientes:
Construir una cola de prioridad a partir de N elementos.
Insertar un nuevo elemento.
Suprimir el elemento más grande.
Sustituir el elemento más grande por un nuevo elemento (a menos que éste
sea mayor).
Cambiar la prioridad de un elemento.
Eliminar un elemento arbitrario determinado.
Unirdos colas de prioridad en una más grande.
(Si los registros pueden tener claves iguales, se considera que el «más gra.ide»
significa«uno cualquierade los registros que tiene el valor de clave más grande».)
La operación sustituir es casi equivalente a insertar seguida de suprimir (la
diferenciaes que insertar/suprimir requiere que la cola de prioridad crezca tem-
poralmente en un elemento); obsérvese que esta operación es diferente de su-
primir seguido de insertar. Ésta se incluye como una operación por separado
porque, como se verá, algunas implementaciones de las colas de prioridad pue-
den realizar eficazmente la operación sustituir. Del mismo modo, la operación
cambiar podría implementarse como eliminar seguido de insertar y la opera-
ción construir con el uso repetido de insertar, pero estas operaciones se pueden
implementar más eficazmente y de manera directa por medio de ciertas estruc-
turas de datos. La operación unión necesita estructuras de datos avanzadas; en
su lugar se estudiará una estructura de datos «clásica», denominada montículo,
con la que es posible lograr implementaciones eficaces de las cinso primeras
operaciones.
La cola de prioridad, tal como se ha descrito anteriormente, es un excelente
ejemplo de la estructura de datos abstracta, descrita en el Capítulo 3: está muy
bien definida en términos de las operaciones que se llevan a cabo sobre ella, in-
dependientemente de cómo se organizan los datos y se procesan en una imple-
mentación particular.
Cada implementación diferente de las colas de prioridad se acompaña de di-
ferentes características de rendimiento para las diversas operaciones que se lle-
van a cabo, lo que conduce a comparaciones de costes. En efecto, las diferencias
de rendimiento son realmente las únicas que pueden aparecer en el concepto de
estructura de datos abstracta. Se ilustrará este punto presentando algunas es-
tructuras de datos elementales que permiten implementar colas de prioridad.
Después se examinará una estructura de datos más avanzada y se mostrará cómo
se pueden implementar algunas de las operaciones anteriores utilizando esta es-
tructura. Para concluir se verá un importante algoritmo de ordenación que se
obtiene de forma natural a partir de estas implementaciones.
COLAS DE PRIORIDAD 161
Implementacioneselementales
Una forma de organizar una cola de prioridad es como una lista no ordenada,
colocando simplemente los elementos en un array a[11, ..., a[NI sin prestar
atención a los valores de las claves. (Como es habitual, se reserva a[O] y a[N+1]
para valores centinelas, en el caso de que se necesiten.) El array a y su tamaño
N se utilizan solamente por las funciones de la cola de prioridad y se suponen
«ocultos» de las rutinas de invocación. Si se usa un array para implementar una
lista no ordenada, la c l ase cola de prioridad se obtiene fácilmente como sigue:
class CP
private:
tipoElemento *a;
i n t N;
CP (in t max)
{
public:
{ a = new tipoElemento[max]; N = O; }
{delete a; }
{ a[++N] = v; }
-cp (1
voi d inserta r ( t ipoElement o v )
tipoElemento suprimir()
i n t j, max = 1;
f o r ( j = 2; j <= N; j++)
intercambio(a, max, N);
r e t u r n a[N--1;
{
i f ( a [ j ] > a[max]) max = j;
1
>;
Para insertar,se incrementa N y se coloca el nuevo elemento en a[NI, una ope-
ración en tiempo constante. Pero suprimir requiere recorrer el array para en-
contrar el elemento con la clave más grande, lo que lleva un tiempo lineal (se
deben examinar todos los elementos del array ), y después intercambia a[NI con
dicho elemento y decrementa N. La implementación de sustituir es muy similar
y por tanto se omite.
Para implementar la operación cambiar (cambiar la prioridad del elemento
a[k] ), es suficiente con almacenar el nuevo valor, y para eliminar el elemento
a[k] ,se puede cambiar por a[ N I y áecrementar N, como en la última línea de
suprimir. Tales operaciones, que hacen referencia a elementos específicos, sólo
162 ALGORITMOS EN C+t
tienen sentido en una implementación (andirecia) o con (puntero)),donde cada
elemento mantiene una referencia a su lugar en la estructura de datos. Una im-
plementación de este tipo se presenta al final de este capítulo.
Otra organización elemental a utilizar es una lista ordenada, empleando otra
vez un array a [11,...,a[NI, pero manteniendo los elementos en orden cre-
ciente de sus claves. Ahora suprimir implica simplementedevolver a [NI y de-
crementar N (operación de tiempo constante), pero insertar necesita desplazar
todos los elementos superiores una posición a la derecha, lo que podría llevar
un tiempo lineal, y construir implicaría una ordenación.
Cualquieralgoritmo de cola de prioridad se puede convertir en un algoritmo
de ordenación utilizando repetidamente insertar para construir una cola de
prioridad que contenga todos los elementos a ordenar, y utilizar después repe-
tidamente suprimir para vaciar esta última cola y obtener elementos en orden
inverso. La utilización de una lista no ordenada para representar de esta forma
a una cola de prioridad corresponde a una ordenación por selección; la de una
lista ordenada corresponde a una ordenación por inserción.
También se pueden utilizar listas enlazadas como listas no ordenadas o lis-
tas ordenadas en lugar de la implementaciónpor array anterior. Esto no cambia
las características fundamentales de rendimiento de insertar, suprimir o susti-
tuir, pero permite ejecutar eliminar y unir en un tiempo constante. Aquí se
omiten estas operaciones porque son similares a la lista de operaciones básicas
del Capítulo 3 y porque en el Capítulo 14 se dan implementaciones de métodos
similares para el problema de búsqueda (encontrar un registro con una clave
dada).
Como siempre, es conveniente no olvidarse de estas implementaciones por-
que a menudo, en muchas situaciones prácticas, superan el rendimiento de mé-
todos más complicados. Por ejemplo, la implementaciónpor lista no ordenada
puede ser apropiada en una aplicación donde sólo se llevan a cabo algunas ope-
raciones de ((suprimirel más grande)) frente a un gran número de inserciones,
mientras que una lista ordenadapodría ser lo apropiado si los elementos que se
insertan en la cola de prioridad siempre tienden a estar próximos al elemento
mayor.
Estructura de datos montículo
La estructura de datos que se utilizará para implementar las colas de prioridad
implica almacenarlos registros en un array de tal manera que se garantice que
cada clave es mayor que otras dos que están situadas en posiciones específicas.
A su vez, cada una de esas claves debe ser mayor que otras dos y así sucesiva-
mente. Este ordenamientoes muy fácil de representar si se dibuja el array como
una estructura de árbol bidimensional, con líneas que descienden desde cada
clave hacia las dos que se sabe que son inferiores, como en la Figura 11.1.
Se vio en el Capítulo 4que esta estructura se denomina (árbol binario com-
COLAS DE PRIORIDAD 163
Figura 11.1 Representaciónde un montículopor un árbol completo.
pleto»: se puede construir colocando un nodo (llamado ruiz)y luego actuando
hacia abajo de la página y de izquierda a derecha, conectando cada par de no-
dos al del nivel superior bajo el que se encuentran, y así sucesivamente hasta
que se hayan colocado N nodos. Los dos nodos debajo de cada nodo se deno-
minan sus hijos;el nodo superior de cada nodo se denomina el pudre. Las cla-
ves del árbol deben satisfacer la condición del montículo: la clave de cada nodo
debe ser superior (o igual) a las claves de sus hijos (si tiene alguno). Esto implica
que la clave más grande está en la raíz.
Se pueden representar árboles binarios secuencialmenteen un array con sólo
poner la raíz en la posición 1, sus hijos en las posiciones 2 y 3, los nodos del
nivel inferior en las posiciones 4, 5, 6 y 7, etc., como indican los números de la
Figura 11.1. Por ejemplo, la representación por array del árbol anterior se
muestra en la Figura 11.2.
Esta representación natural es útil porque es muy fácil ir de un nodo a su
padre o a un hijo. El padre del nodo de la posiciónj está en la posiciónj/2 (re-
dondeado al entero más cercano sij es impar) e, inversamente, los dos hijos del
nodoj están en las posiciones 2 j y 2 j + 1. Esto hace que recorrer este árbol sea
más fácil que si estuviera implementado por la representación enlazada están-
dar (en la que cada elemento contiene punteros a su padre y a sus hijos). La
estructura rígida de árboles binanos completos representados por arrays limita
su utilidad como estructura de datos, pero tiene la flexibilidad suficiente para
permitir la implementación de los algoritmos de cola de prioridad. Un montí-
culo es un árbol binario completo representado por un array, en el cual cada
nodo satisface la condición del montículo. En particular, la clave más grande
está siempre en la primera posición del array.
Todos los algoritmos operan a lo largo de algún camino desde la raíz hasta
Figura 11.2 Representaciónde un montículopor un array.
164 ALGORITMOS EN C++
el fondo del montículo (moviéndose del padre a un hijo o de un hijo al padre).
Es fácil obserVar que en un montículo de N nodos, todos los caminos tienen 1gN
nodos. (Hay alrededor de N/2 hijos en el fondo, N/4 nodos cuyos hijos son los
del fondo, N/8 nodos con nietos en el fondo, etc. Cada «generación» tiene al-
rededor de la mitad de nodos que la siguiente, lo que implica que puede haber
como máximo 1gNgeneraciones.) Por tanto, todas las operaciones de colas de
prioridad (excepto unir)se pueden hacer en tiempos logaritmicos,utilizando un
montículo.
Algoritmossobre montículos
Todos los algoritmos de colas de prioridad que trabajan con montículos co-
mienzan haciendo una simple modificación estructural, que podría violar la
condición del montículo, y luego lo recorren, modificándolo, para asegurar que
dicha condición se satisfaga en todos los nodos. Algunos algoritmos recorren el
montículo de abajo hacia arriba, otros lo hacen desde arriba hacia abajo. En to-
dos los algoritmos se supondrá que los registros son claves de enteros de una
palabra almacenados en un array a de un cierto tamaño máximo y que el en-
tero N indica el tamaño actual del montículo. Como antes, se supone que el array
y su tamaño son accesibles solamente para las rutinas de las colas de prioridad:
los datos se pasan entre el usuario y la cola únicamente a través de las llamadas
a las rutinas.
Para poder construir un montículo, primero es necesario implementar la
operación insertar. Puesto que esta operación incrementa el tamaño del
montículo en uno, se debe incrementar N. A continuación se coloca el registro
a insertar en a[NI,lo que puede violar la condición del montículo, en cuyo caso
(e1 nuevo nodo es mayor que el padre) se intercambia el nuevo nodo con su
padre. Esto puede, a su vez, causar una violación que se debe arreglar de la
misma forma. Por ejemplo, si se va a insertar R en el montículo de la Figura
11.1, primero se almacena en a [NI como el hijo derecho de E. Luego, puesto
que es más grande que E, se intercambia con este nodo y, puesto que es más
grande que N, se intercambia con él, y el proceso termina dado que es igual que
R. Se obtiene como resultado el montículo que se muestra en la Figura 11.3.
El código para este método es directo: la implementación siguiente utiliza
subirmonticulo(N) para eliminar la violación de la condición, después de in-
sertar un nuevo elemento en N:
v o i d CP: :subirmonticulo(int k)
tipoElemento v;
v = a[k]; a[O] = elementoMAX;
while (a[k/2] <= v)
COLAS DEPRIORIDAD 165
{ = a[k] = a[k/s]; k = k/2; }
a[k] = v;
1
void CP: :insertar(tipoElemento v)
{a[++N] = v; subirmonticulo(N); }
Si se reemplazase k/2 por k-1en todo el programa anterior, se tendría, en esen-
cia, un paso de la ordenación por inserción (implementando una cola de prio-
ridad por medio de una lista ordenada);en lugar de ello, aquí se está insertando
la nueva clave a lo largo del camino desde N a la raíz. Al igual que con la orde-
nación por inserción, no es necesario hacer un intercambio completo en el bu-
cle, porque v siempre está implicado en estos intercambios. Se debe poner una
clave centinela en a [O] para detener el bucle en el caso en que v sea mayor que
todas las claves del montículo. Más adelante se verá otro empleo de a [O].
Figura 11.3 Inserciónde un nuevo elemento (R) en un montículo.
La operación su5titui r consiste en poner una nueva clave en la raíz y luego
moverse hacia abajo por el montículo, desde la cima hacia el fondo, para res-
taurar la condición del mismo. Por ejemplo, si la R del montículo anterior se
reemplaza por C, el primer paso es poner a C en la raíz. Esto viola la condición
del montículo, pero se puede arreglarintercambiando C con R, el mayor de los
dos hijos de la raíz. Esto provoca otra violación en el nivel siguiente,la cual se
puede de nuevo arreglarintercambiando C con el mayor de los dos hijos (O en
este caso).El proceso continúa hasta que no se viole la condición del montículo
en el nodo ocupado por C. En el ejemplo, C desciende hasta el penúltimo nivel
quedando el montículo que se muestra en la Figura 11.4.
La operación «supri m i r el mayom implica casi el mismo proceso. Puesto
que el montículo tendrá un elemento menos después de la operación, es nece-
sano decrementar N, desalojando el elemento que estaba almacenado en la ú1-
tima posición. Pero, como se va a suprimir el elemento más grande (que está en
a[1
3), !
a operación suprimir consiste en sustituir, utilizando el elemento que
estaba en a [NI. El montículo de la Figura 11.5 es el resultado de suprimir R del
montículo de la Figura 11.4 reemplazándolo por E y moviéndose a continua-
166 ALGORITMOS EN C++
Figura 11.4 Sustitución (por C) de la clave mas grande del montículo.
ción hacia abajo promocionando al mayor de los dos hijos, hasta que se alcance
un nodo con sus dos hijos inferiores a E, lo que en este caso lleva al fondo del
montículo.
La implementación de ambas operaciones se basa en la técnica de ir restau-
rando hacia amba un montículo para ir satisfaciendo la condición del montí-
culo en todos los lugares, excepto posiblemente en la raíz. Si la clave de la raíz
es muy pequeña, se debe mover hacia abajo por el montículo sin violar la con-
dición en ninguno de los nodos que se tocan. Esto indica que se puede utilizar
la misma operación para restaurar el montículo después de disminuir el valor
de una posición cualquiera. Esto se puede implementar como sigue:
void CP: :bajarmonticulo(int k)
int j; tipoElemento v;
v = a[k];
while (k <= N/2)
{
j = k+k;
if (j<N && a[j]<a[j+l]) j++;
if (v >= a[j]) break;
a[kI = a[jl;
k = j;
{
>
a[k] = v;
,
Este procedimiento recorre hacia abajo el rnonticulo, intercambiando el nodo
de la posición k con el mayor de sus dos hijos, si es necesario,y parando cuando
el nodo k sea mayor que sus dos hijos o se haya alcanzado el fondo. (Como es
posible que el nodo k tenga un solo hijo, jeste caso se debe tratar de manera
adecuada!) Como antes, no se necesita efectuar un intercambio completo por-
que v siempre está implicado en cada uno de ellos. El bucle interno de este pro-
COLAS DE PRIORIDAD 167
grama es un ejemplo de un bucle con dos salidas distintas: una para el caso en
que se alcance el fondo del montículo (como en el primer ejemplo antenor), y
otra para el caso en que la condición del montículo se satisfaga en algún lugar
del interior del mismo. Éste es el prototipo de las situaciones que necesitan una
instrucción break.
Figura 11.5 Supresióndel elementomayor de un montículo.
Ahora la operación suprimir es una aplicación directa de este procedi-
miento:
tipoElemento CP: :suprimir()
i
tipoElemento v = a[i];
a[l] = a[N--1;
bajarmonticulo(1) ;
return v;
}
El valor devuelto es el que se encuentra inicialmente en a[11. Después el ele-
mento en a[NI se pone en a [11 y se decrementa el tamaño del montículo, que-
dando solamente por hacer una llamada a bajarmonticulo para restaurar la
condición del montículo en todas partes. La operación sustituir es un tanto más
complicada:
tipoElemento CP:
a[O] = v;
bajarmonticu
return a[O];
{
1
sust
o(0)
tui r(tipoEl emento v)
Este código utiliza a[O] de una forma artificial: sus hijos son el propio O y 1,
por tanto si v es mayor que el elemento más grande del montículo, el montículo
no se toca; en caso contrario, se coloca v en el montículo y se devuelve a[11.
168 ALGORITMOS EN C++
La operación extraer un elemento arbitrario del montículo y la operación
cambiar también se pueden implementar utilizando una combinación sencilla
de los métodos anteriores. Por ejemplo, si se aumenta la prioridad del elemento
de la posición k, entonces se debe llamar a suhirmonticul o, y si se disminuye
entonces la tarea la hace bajarmonticulo.
Propiedad 11.1 Todas las operaciones básicas -insertar, suprimir, sustituir,
(bajarmontículo,subirmontículo), eliminar y cambiar- necesitan menos de 21gN
comparaciones cuando se llevan a cabo sobre un montículo de N elementos.
Todas estas operacionesimplican recorrer un camino entre la raíz y el fondo del
montículo, que no puede incluir más de 1gNelementos en un montículo de ta-
maño N. El factor dos proviene de bajarmonticulo, que hace dos comparaciones
en su bucle interno; las otras operaciones necesitan sólo IgN comparaciones.i
Es importante señalar que la operación unir no se ha incluido en esta lista.
Realizar eficazmente esta operación necesita una estructura de datos mucho más
sofisticada. A pesar de todo, en muchas aplicaciones, se podría esperar que esta
operación se solicite con mucha menos frecuencia que las otras.
Ordenación por montículos
Las operaciones básicas sobre montículos estudiadas con anterioridad permiten
definir un método elegantey eficaz de ordenación. Dicho método, denominado
ordenaciónpor montículos, no utiliza memoria extra y garantiza ordenar N ele-
mentos en alrededor de MgN pasos, sin importar cuál sea la entrada. Por des-
gracia, su bucle interno es algo más largo que el del Quicksort, y, como pro-
medio, es dos veces más lento.
La idea es simplemente construir un montículo que contenga los elementos
a ordenar y después suprimirlos todos en orden. Una forma de ordenar consiste
en insertar los elementos en un montículo vacío, como en las dos primeras 1í-
neas del código siguiente (que de hecho sólo implementa construir (a, N)), y
después efectuar N operaciones suprimir, colocando el elemento que se ha su-
primido en el lugar que ha quedado vacante en el montículo que se está com-
primiendo:
void ordenmonticulo(tipoE1emento a[], int N)
int i; CP mont!culo(N);
for (i = 1; i <= N; i++) monticulo.insertar(a[i]);
for (i = N; i >= 1; i--) a[:] =monticulo.suprimir();
{
1
Los procedimientos de implementación de colas de prioridad se han utilizado
COLAS DE PRIORIDAD 169
Figura 11.6 Construcción descedente(de arriba hacia abajo)del montículo
sólo con propósitos descriptivos:en una implementación real de la ordenación,
sena más simple utilizar el código de los procedimientos para evitar llamadas
innecesarias a los mismos. Y lo que es más importante, el programa se puede
arreglar para que la ordenación se lleve a cabo in situ (sin utilizar memoria ex-
tra para el montículo), permitiendo que ordenmonti cul o tenga acceso directo
al array y dejando que la cola de prioridad resida en a[11, ... , a[k - 11.
La Figura 11.6 muestra la construccióndel montículo cuando se insertan las
claves E J E M P L O A O R D E N A R, en este orden, en un montículo ini-
cialmente vacío, y la Figura 11.7 muestra cómo se ordenan dichas claves qui-
tando la R, luego la otra R, etcétera.
En realidad es mejor construir el montículo retrocediendo a través de él e ir
creando pequeños submontículosdesde el fondo hacia amba, como se muestra
en la Figura 11.8. Este método considera cada posición del array como la raíz
de un pequeño submontículo y se aprovecha del hecho de que bajarmonti -
170 ALGORITMOS EN C++
Figura 11.7 Ordenacióna partir de un montículo.
culo puede aplicarse en esos submontículos tan bien como en el montículo
grande. Trabajando hacia atrás a través del montículo, cada nodo es la raíz de
un submontículo que responde a la condición del montículo, excepto posible-
mente en su raíz; el método bajarmonticulo termina el trabajo. El recorrido
comienza en la mitad del camino del array porque los submontículos de ta-
maño l se pueden saltar.
Ya se ha señalado que la operación suprimir se puede implementar inter-
cambiando los elementos primero y último, decrementando N, y llamando a ba-
jarmonti cul o(1). Esto conduce a la siguiente implementación de la ordena-
ción por montículos:
void ordenmonticulo(tipoE1emento a[], int N)
{
COLAS DE PRIORIDAD 171
int k;
for (k = N/2; k >= 1; k--)
while (N > 1)
bajarmonticulo(a, N, k);
{ intercambio(a, 1, N); bajarmonticulo(a, --N, 1); }
}
Aquí otra vez se abandona cualquier idea de ocultar la representación del mon-
tículo, y se supone que se ha modificado bajarmonticulo para que sus dos pri-
meros argumentos sean el array y el tamaño del montículo. El primer bucle for
podría utilizarse para implementar un constructor que ordene en tiempo lineal
un array en forma de montículo. A continuación, el bucle whi 1 e intercambia
el elemento mayor con el último y restaura el montículo, al igual que antes. Es
interesante notar que, aunque los bucles de este programa parecen hacer cosas
muy diferentes, están construidos tomando en cuenta el mismo procedimiento
fundamental.
Figura 11.8 Construcción ascendente(de abajo hacia arriba) del montículo.
La Figura 1119ilustra el movimiento de los datus en la ordenación por mon-
tículos al mostrar el contenido de cada montículo operado por bajarmonti -
culo en el ejemplo de ordenación, justamente después de que bajarmonti -
cu1 O haya hecho que la condición del montículo se cumpla en todas partes.
Propiedad 11.2 La construcción ascendente (de abajo hacia arriba) del montí-
culo es lineal.
Esto se deduce del hecho de que la mayoría de los montículos procesados son
pequeños. Por ejemplo, para construir un montículo de 127 elementos, el mé-
172 ALGORITMOS EN C++
1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 1 4 1 5
Figura 11.9 Movimientode datos en la ordenación por montículos.
todo !lama a bajarmonticul o para 64 montículos de tamaño 1, 32 de tamaño
3, 16 de tamaño 7, 8 de tamaño 15,4 de tamaño 31,2 de tamaño 63 y uno de
tamaño 127, por tanto se necesitan en el peor caso 64 . O + 32 . 1 + 16 . 2 + 8 .
. 3 + 4 . 4 + 2 . 5 + 1 . 6 = 120 «promociones» (dos veces más que compara-
ciones). Para N = 2",.unacota superior del número de comparaciones es
COLAS DE PRIORIDAD 173
y una demostración similar es válida cuando N no es una potencia de dos..
Esta propiedad no es de particular importancia en la ordenación por mon-
tículos, puesto que su tiempo está dominado fundamentalmente por el tiempo
MogN de la ordenación, pero es importante en otras aplicaciones de colas de
prioridad, en las que un tiempo lineal de construi r puede conducir a un al-
goritmo lineal. Se observa que construir un montículo con N insertar sucesi-
vos necesita MogN pasos en el peor caso (aunque, por término medio, tiende a
ser lineal).
Propiedad 11.3 La ordenación por montículos utiliza menos de 2NlgN com-
paraciones para ordenar N elementos.
Una cota ligeramente superior, por ejemplo 3MgN, es inmediata a partir de la
propiedad 11.1.La cota que se da aquí se obtiene de un cálculo más cuidadoso
basado en la propiedad 11.2.~
Como se mencionó anteriormente, la propiedad 11.3 es la razón principal
por la que la ordenación por montículos tiene un interés práctico: el número de
pasos necesarios para ordenar N elementos es obligatoriamente proporcional a
M o m , sin importar cuál sea la entrada. A diferencia de los restantes métodos
de ordenación que se han visto, no hay ningún apeor caso)) que pueda hacer
que la ordenación por montículos se ejecute con más lentitud.
Las Figuras 11.10 y 11.11 muestran cómo la ordenación por montículos
opera sobre un fichero ordenado aleatoriamente. En la Figura 11.10, el proceso
parece cualquier cosa menos una ordenación, puesto que los elementos grandes
se desplazan hacia el comienzo del archivo. Pero la Figura 11.11 muestra esta
estructura a medida que se ordena el fichero al ir seleccionando los elementos
más grandes.
Montículos indirectos
En muchas aplicaciones de las colas de prioridad, no se desea que los registros
se estén desplazando continuamente. En lugar de ello, se quiere que las rutinas
de las colas no devuelvan valores sino que indiquen cuál de los registros es el
más grande, etc. Esto es semejante a la ((ordenaciónindirecta) o a la «ordena-
ción por punteros)) descrita en el Capítulo 8. Puede ser útil examinar esta téc-
nica con detalle, ya que es muy práctico utilizar los montículos de esta forma.
Una aproximación consiste, como en el Capítulo 8, en hacer de modo que
en lugar de estar reorganizando las claves en un array a las rutinas de las colas
de prioridad trabajen con un array de índices o de punteros dentro del array,
refiriéndose a las claves indirectamente. Más aún, para implementar las opera-
ciones cambiar y eliminar es necesario conservar la posición de cada elemento
del montículo. Esto se puede llevar a cabo con modificaciones cuidadosas del
código anterior, de forma similar a la presentada en el Capítulo 8.
174 ALGORITMOS EN C++
.....
.
...=.
.
-
I
:
/
-.
+s, .% .,= ..
9 .
I
..
.:
: .-
.....
...
= .
-=.
. . . . . .
- .
. 8 .
.... '
:
= :.:" .
:
,:
,
.
I
*
- . .
..
...
=v
+. ,-
. . . . ..
.:: .
= . . m
$...
.
.
= .
..=--
. - ..
. m .
. .
... ..b.
........
... 1
:
..:" .
.l a:,.' :
c . . . .
. . .
-. ..
.
:
m
2[/&.
.-..
..
+
i
,
-3 t m
.:: .
..........
b
....lJ:
.
.
m ;
.
=
. . . .
..
...
. ....
. ... '
:
...:-, .
.n- .
. . . . . .
. .
..
%
<
:
;
.
.
,
.
J.. ..
= =
.
+ :
.
l
.
. -
.: ..... I
.
.
A
. .t .:
:
. .-
1 :
: :
.=
. . . .
.- .
.... I
.
.
.
:
. . . .
..=
: ..
-9. ....'
s
?$<&
.,.
f... s
.
= + :;=.
. *
. = ...
;
a
.L.= a ..=.
.-
..
..=
:
-
-
.- 9 ..
......+. .
. . . .
.....
. . . .
.'-:',
-..=.3-
Figura 11.10. Ordenación por montículosde una permutaciónaleatoria: fase de cons-
trucción.
Se adoptará una aproximación, toscamente equivalente, pero un tanto más
general, que proporcionará rutinas de colas de prioridad que serán útiles más
adelante, en particular en los Capítulos 22 y 31. Se generaliza la interfaz para
incluir otro argumento para insertar y cambiar: un entero entre 1 y t a l 1a
que se asocia con la clave que se inserta en la cola de prioridad. En particular,
la operación suprimir es para devolver el entero asociado con la clave más pe-
/
Figura 11.11. Ordenación por montículosde una permutaciónaleatoria: fase de orde-
nación.
COLAS DE PRIORIDAD 175
queña de la cola, no la propia clave. Por ejemplo, si las claves están almacena-
das en un array (o si son parte de los registros almacenados en un array), se uti-
lizarán los índicesde array con este objetivo. Luego se puede recuperar del array
la clave y cualquier otra información asociada.
Para ilustrar cómo se puede implementar esto, considéreseuna versión mo-
dificada de la implementación de ((array no ordenado)) dada al comienzo del
capítulo. Se anade un array info para «seguir el rastro)) de Ia información aso-
ciada y un array p para hacerlo con las posiciones de las claves en la cola. Hay
que asegurarsede que estos arrayssatisfagan que p [info [x] ] = i n f o [p [XI
] = x
y a[p[X] ] = v para cada entero x que se haya asociado con alguna clave v de
la cola:
class CP
{
private:
i n t *a, *p, *info;
i n t N;
CP( i n t t a l 1a)
public:
{ a = new tipoElemento[talla];
p = new tipoElemento[talla];
i n f o = new i n t [ t a l l a ] ; N = O;
1
- c p o
{ delete a; delete p; delete i n f o ; }
{ a[++N] = v; p[x] = N; info[N] = x; }
void i n s e r t a r ( i n t x, tipoElemento v)
void cambiar(int x, tipoElemento v)
i n t suprimir() //suprimir e l menor
b [ P [ X I l = v; 1
i n t j, min = 1;
f o r ( j = 2; j <= N; j++)
intercambio(a, min, N);
intercambio(info,min, N) ;
p[info[min]] = min;
r e t u r n info[N--1 ;
i f ( a [ j ] ia[min]) min = j;
1
in t vaci o()
{ r e t u r n (N <= O); }
1;
176 ALGORITMOS EN C++
Esta implementaciónsupone que las claves menores tienen las prioridades más
altas, lo cual es por lo menos tan común en las aplicaciones como el esquema
utilizado para la ordenación por montículos. La clave para valorar esta imple-
mentación es la operación cambi ar. Como es habitual, no se hace comproba-
ción de errores y se supone (por ejemplo) que x siempre está en el margen co-
rrecto y que el usuario no trata de insertar en una cola llena o suprimir en una
cola vacía. La adición de código en C++ para tales controles se obtiene de forma
directa.
Si la cola de prioridad es muy grande o se quiere hacer un gran número de
operaciones de suprim i r,o ambas cosas, el array a se debe mantener en el or-
den del montículo para asegurar que el tiempo de ejecución de todas las ope-
raciones esté acotado por un factor logarítmico. Las rutinas del montículo da-
das anteriormente se pueden modificar de forma directa para mantener los arrays
info y p como se necesite: las comparaciones se refieren directamentea a, pero
los desplazamientos se deben reflejar directamente en info e indirectamente en
p, como en la implementaciónanterior. Todo esto se puede hacer sin cambiar
la interfaz, por lo que más adelante se hará referencia a las operaciones sobre
colas de prioridad como se definieron en esta clase, suponiendoque se pueden
implementar eficazmente utilizando montículos tal y como se acaba de descri-
bir.
En algunas aplicacionespodrían ser apropiadas otras implementaciones, de-
pendiendo de la naturaleza de las claves y de la mezcla de operaciones sobre
colas de prioridad a llevar a cabo. Por ejemplo, si la cola de prioridad es más
bien pequeña (por ejemplo, 20 elementos), la implementación anterior puede
servir bastante bien. También pueden ser apropiados ligeros cambios en la in-
terfaz. Por ejemplo, se puede desear que una función devuelva el valor de la
clave de prioridad más alta de la cola y no precisamente el índice de la infor-
mación asociada. O podría ser conveniente poder suprimir la clave más grande
o la más pequeña de la cola. Es fácil añadir tales procedimientos a la clase an-
terior, pero es un desafio el desarrollar una clase donde se garantice un funcio-
namiento logarítmico en todas las operaciones.
Implementaciones avanzadas
Si se debe hacer la operación unir eficazmente, las implementacionesque se han
hecho son insuficientes y se necesitan técnicas más avanzadas. Aunque no se
dispone aquí de espacio para entrar en los detalles de tales métodos, se pueden
presentar algunas consideracionesválidas para su diseño.
Por «eficazmente» se entiende que una unión se debe hacer al mismo tiempo
que las otras operaciones. Esto excluye inmediatamente la representación sin
enlaces que se ha venido utilizando para los montículos, puesto que dos de ellos
sólo se pueden unir desplazando todos los elementos de al menos uno de ellos
a un array mayor. Es fácil transformar los algoritmos estudiados para utilizar
COLAS DE PRIORIDAD 177
representaciones enlazadas; de hecho, algunas veces existen razones para hacer
esto (por ejemplo, puede no ser conveniente tener un array contiguo muy
grande). En una representación directa por lista enlazada se tendría que man-
tener cada nodo apuntando a su padre y a sus dos hijos.
Esto revela que la propia condición del montículo parece ser demasiado
fuerte para permitir implementaciones eficaces de la operación unir. Las estruc-
turas de datos avanzadas que se han diseñado para resolver este problema de-
bilitan o bien la condición del montículo o la del balance, en busca de obtener
la flexibilidad que se necesita para la unión. Estas estructuras permiten que to-
das las operaciones se puedan hacer en tiempo logarítmico.
Ejercicios
1. Dibujar el montículo que se obtiene cuando se llevan a cabo las siguientes
operaciones en un montículo inicialmente vacío: insertar(1),i nser-
tar (5), insertar(Z), insertar(6), susti tui r(4), insertar(8), su-
primi r, insertar(7), insertar (3).
2. ;,Es un montículo un archivo ordenado en orden inverso?
3. Decir cuál es el montículo que se obtiene cuando, comenzando con un
montículo vacío, se llama sucesivamentea i nsertar para las clavesC U E
S T I O N F A C I L.
4. ¿Qué posiciones podrían estar ocupadas por la tercera clave más grande de
un montículo de tamaño 32? ¿Qué posiciones no podrían estar ocupadas
por la tercera clave más pequeña de un montículo de tamaño 32?
5. ¿Por qué no se utiliza un centinela para evitar la comprobación j < N en
bajarmont i cu1o?
6. Mostrar cómo se obtienen las funciones normales de pilas y colas en los ca-
sos particulares de colas de prioridad.
7. ¿Cuál es el número mínimo de claves que se deben desplazar en un mon-
tículo durante una operación de ({suprimir el mayom? Dibujar un mon-
tículo de tamaño 15 para el que se alcanza el mínimo.
8. Escribir un programa para eliminar el elemento de la posición d de un
montículo.
9. Comparar empíncamerite la construcciónascendente (de abajo hacia arriba)
de un montículo con la descendente (de arriba hacia abajo), construyendo
montículos con 1.O00 claves aleatorias.
10. Dar el contenido de los arrays p e info después de insertar las claves C U
E S T I O N F A C I L (siendo i la i-ésima letra de la serie)en un montículo
inicialmente vacío.
Algoritmos en C++.pdf
12
Ordenación por fusión
En el Capítulo 9 se estudió la operación de selección, que permite encontrar el
k-ésimo elemento más pequeño de un archivo, viéndose que es semejante a di-
vidir el archivo en dos partes, los k elementos más pequeños y los N-k más
grandes. En este capítulo se examinará un proceso más o menos complemen-
tario, lafusión, que permite combinar dos archivos ordenados en otro más
grande, también ordenado. Como se verá, la fusión es la base de un algoritmo
de ordenación recursivo directo.
La selección y la fusión son operaciones complementarias en el sentido de
que la primera divide el archivo en dos archivos independientes y la fusión une
dos archivos independientes para hacer uno. La relación entre estas dos opera-
ciones se hace evidente si se trata de aplicar el paradigma de «dividey vencerás»
para crear un método de ordenación. El archivopuede estar distribuidode modo
que cuando las dos partes estén ordenadas el archivo completo esté ordenado,
o bien puede separarse en dos partes para ordenarlas y luego combinarlas para
dejar ordenado el archivo completo. Ya se ha visto lo que sucede en el primer
caso: es decir en el Quicksort, que consiste básicamente en un procedimiento
de selección seguido de dos llamadas recursivas.A continuación se verá la or-
denación por fusión, el complemento del Quicksort, que básicamente consiste
en dos llamadas recursivasseguidasde un procedimiento de fusión.
La ordenación por fusión, al igual que la ordenación por montículos, tiene
la ventaja de que ordena un archivo de N elementos en un tiempo proporcional
a MogIV aun en el peor caso. Su principal inconveniente es que parece difícil
evitar la utilización de un espacio extra proporcional a N, a menos que se de-
dique un gran esfuerzo para superar este obstáculo. La longitud del bucle in-
terno está entre la del Quicksort y la ordenación por montículos, por lo que la
ordenación por fusión es una buena elección si la velocidad es lo esencialy hay
espacio disponible. Más aún, la ordenación por fusión se puede implementar de
forma que se pueda acceder secuencialmente a los datos (un elemento después
de otro), lo que a veces es una cierta ventaja. Por ejemplo, la ordenación por
fusión es el método ideal para ordenar una lista enlazada, en la que el acceso
.
179
180 ALGORITMOS EN C++
secuencia1es la única forma posible de acceso. Igualmente, como se verá en el
Capítulo 13, la fusión es la base de la ordenación en dispositivosde acceso se-
cuencial, aunque los métodos utilizados en ese contexto son algo diferentes de
los empleados por la ordenación por fusión.
En muchos entornos de procesamiento de datos se mantiene un gran archivo
(ordenado) de datos al que regularmente se le añaden nuevas entradas. Por lo
regular, estas entradas nuevas se van colocando en lotes» y concatenando al
archivo principal (que es mucho más grande), reordenando luego el archivo
completo. Esta situación está hecha a la medida de la fusión:una estrategiamu-
cho mejor consiste en ordenar los lotes pequeños con las entradas nuevas y luego
fusionarlos con el gran archivo principal. La fusión tiene muchas otras aplica-
ciones similares que hacen que su estudio merezca la pena. Se examinará tam-
bién un método de ordenación basado en la fusión.
En este capítulo se concentrará el interés en los programas parafusiones de
dos vías:programas que combinan dos archivosde entrada ordenados para pro-
ducir un archivo ordenado de salida. En el próximo capítulo se verá con más
detalle lafusión muZtivíu,que implica más de dos archivos. (La aplicación más
importante de la fusión multivía es la ordenación externa, el tema del presente
capítulo.)
Para comenzar, se supone que se tienen dos arrays ordenados a [11,...y
a[MI y b [11,...y b [NI de enteros que se quieren fusionar en un tercer array
c [11y ...y c [M+N] .El código siguiente es una implementación de la estrategia
obvia que consiste en ir tomando sucesivamente para c el elemento más pe-
queño de los que van quedando en los arrays a y b:
i = 1; j = 1;
a[M+l] = elementoMAX; b[N+l] = elementoMAX;
for ( k = 1; k <= M+N; k++)
c[k] = ( a [ i ] < b [ j ] ) ? a[i++] :b[j++];
La implementación se simplificareservando espacio en los arrays a y b para las
claves centinelascon valores mayores que cualquiera de las otras claves. Cuando
se termine con el array a(b) el bucle simplemente desplaza el resto de los ele-
mentos del array b (a) al array c. Este método utiliza obviamente M +N com-
paraciones. Si a [M+1] y b [N+l] no pudieran utilizarse por las clavescentinelas,
entonces habría que añadir comprobaciones para estar seguros de que i es
siempre menor que M y que j es menor que N. Otra forma de evitar esta difi-
cultad es la que se utiliza posteriormente en la implementación de la ordena-
ción por fusión.
ORDENACIÓN POR FUSIÓN 181
En lugar de utilizar un espacio extra proporcional al tamaño del archivo fu-
sionado, sería preferible tener un método in situ que utilice c [1
1,..., c [MI
para una entrada y c [M+1],.., , c [M+N] para la otra. A primera vista parece
fácil de hacer, pero no es así: tales métodos existen pero son tan complicados
que incluso una ordenación in situ probablemente sea más eficaz, a menos que
se les dedique un gran cuidado. Se volverá sobre este punto más adelante.
Puesto que en implementaciones prácticas se necesita espacio extra, se po-
drían considerar implementaciones con listas enlazadas. De hecho, este método
es ideal para estas estructuras. A continuación se da una implementación com-
pleta que ilustra todos los convenios a utilizar; obsérvese que el código para la
fusión es casi tan sencillo como el anterior:
struct nodo
struct nodo *z;
struct nodo *fusion(struct nodo *a, struct nodo *b)
{ TipoElernento clave; struct nodo *siguiente; };
r
i
struct nodo *c;
c = z;
do
if (a->cl ave <= b->cl ave)
el se
{ c->siguiente = a; c = a; a = a->siguiente; }
{ c->siguiente = b; c = b; b = b->siguiente; }
while (c != z);
c = z->siguiente; z->sfguiente = z;
return c;
Este programa fusiona las listas a las que apuntan a y b con la ayuda de un pun-
tero auxiliar c.
En este capítulo se tratarán directamente enlaces sobre las listas en lugar de
utilizar la clase Li sta del Capítulo 3 para economizar a la hora de expresar los
algoritmos de ordenación por fusión, como resultará evidente más abajo. Se su-
pone que las listas tienen un nodo ficticio «cola», como en el Capítulo 3: todas
las listas terminan con el nodo ficticio z,el cual normalmente apunta a sí mismo
y también sirve como centinela, con z->cl ave == el emento MAX. Durante la
fusión, z se utiliza para contener el primer elemento de la nueva lista fusionada
(esto es, utilizándolo como nodo cabecera cuyo campo sigui ente apunta al
principio de la lista).Después de construir la lista fusionada, el puntero í!su pri-
mer nodo está dado por z->siguiente y z se reinicializa para que se apunte a
sí mismo.
La comparacion de clave de fusión incluye la igualdad, de forma que la
182 ALGORITMOS EN C++
fusión será estable si se considera que la lista b sigue a la lista a. Más adelante
se verá cómo esta estabilidad puede transmitirse a los programas de ordenación
que utilizan la fusión.
Ordenación por fusión
Una vez que se tiene un procedimiento de fusión, no es difícil utilizarlo como
base de un procedimiento recursivo de ordenación. Para ordenar un archivo
dado, se divide en dos, se ordenan las dos mitades (recursivamente) y se fusio-
nan entre sí. La implementación siguiente de este proceso ordena un array
a [i zq] ,...,a [der] (utilizando un array auxiliar b [i zq] ,...,b[der] ):
void ordenfusion( TipoElemento a[], int izq, int der)
int i , j, k, m;
if (der > izq)
{
m = (der+izq)/2;
ordenfusion(a, izq, m);
ordenfusion(a, m+l, der);
for (i = m+l; i > izq; i--) b[i-l] = a[i-11;
for (j = m; j < der; j++) b[der+m-j] = a[j+l];
for (k = izq; k <= der; k++)
{
a[k] = (b[i]<b[j]) ? b[i++] : b[j--1;
}
1
Este programa efectúa la fusión sin utilizar centinelascopiando el segundo array
de forma simétrica al primero, pero en orden inverso. Así cada array sirvecomo
«centinela» para el otro: el elemento más grande (que se encuentra en un array
o en el otro) garantiza desarrollos adecuados una vez agotado el otro array al
hacer la fusión. El «bucle interno» de este programa es bastante corto (mover
hacia b,mover de nuevo hacia a,incrementar i o j e incrementar y comprobar
k), y podría acortarse aún más teniendo dos copias del código (una para fusio-
nar a en b y otra para fusionar b en a), aunque esto requeriría volver a utilizar
de nuevo los centinelas.
El archivo ejemplo de claves se procesa como se muestra en la Figura 12.1.
Cada línea muestra el resultado de una llamada a fusion. Primero se fusionan
E y J para obtener E J, luego E y M para obtener E M y éstas con E J para
obtener E E J M. Luego se fusiona L P con A O para obtener A L O P, que
fusionado con E E J M da A E E J L M O P, etc. Así pues, este método cons-
ORDENACIÓN POR FUSIÓN 183
Figura 12.1 Ordenación por fusión recursiva.
184 ALGORITMOS EN C++
truye recursivamente archivos ordenados a partir de archivos ordenados más
pequeños.
Ordenación por fusión de listas
Este proceso implica un movimiento tal de datos que también se debe consi-
derar una lista enlazada. El programa que sigue es una implementación recur-
siva directa de una función que toma como entrada un puntero a una lista no
ordenada y devuelve un puntero a la versión ordenada de la lista. El programa
hace esto reorganizando los nodos de la lista sin necesidad de asignar espacio
para nodos temporales ni listas. (Es conveniente pasar como parámetro al pro-
grama recursivo la longitud de la lista; también se puede almacenar este valor
con la lista o dejar que el programa recorra la lista para averiguar tal longitud.)
struct nodo *ordenfusion(struct nodo *c)
struct nodo *a, *b;
if (c->siguiente != z)
{
a = c; b = c->siguiente->siguiente->siguiente;
while (b != z)
{ c = c->siguiente; b = b->siguiente->siguiente;}
b = c->siguiente; c->siguiente = z;
return fusion(ordenfusion(a) , ordenfusion(b));
{
1
return c;
1
Este programa ordena dividiendo la lista sobre la que apunta c en dos mitades,
a las que apuntan a y b, ordenando después las dos mitades recursivamente y
utilizando a continuación fusion para producir el resultado final. Una vez más,
este programa se adhiere al convenio de considerar que todas las listas terminan
con z: la lista de entrada termina con z (y por lo tanto esto hace que la lista b
también), y la instrucción explícita c-> sigui ente = z pone z al final de la lista
a. Este programa es bastante fácil de comprender en su formulación recursiva,
aun cuando realmente es un algoritmo sofisticado.
Ordenación por fusión ascendente
Como se presentó en el Capítulo 5, todo programa recursivo tiene un análogo
no recursivo, que, aunque equivalente, puede ejecutar las operaciones en un or-
ORDENACIÓN POR FUCIÓN 185
den diferente. En realidad, la ordenación por fusión es un prototipo de la estra-
tegia de «combina y vencerás» que caracteriza a muchos cálculos de este tipo,
por lo que merece la pena estudiar detalladamente sus implementaciones no re-
cursivas.
La versión más simple de la ordenación por fusión no recursiva procesa un
conjunto de archivos ligeramente diferentes en un orden diferente: primero re-
corre la lista llevando a cabo una fusión 1 por 1 para producir sublistas de ta-
maño 2, luego recorre la lista llevando a cabo fusiones 2 por 2 para producir
sublistasordenadas de tamaño 4,luego hace fusiones4por 4para producir sub-
listas de tamaño 8, etc., hasta que se ordene la lista completa.
La Figura 12.2 muestra cómo este método lleva a cabo esencialmente las
mismas fusiones que en la Figura 12.1 para el archivo ejemplo (puesto que su
tamaño está próximo a una potencia de dos), pero en un orden diferente. En
general, se necesitan lo@ pasadas para ordenar un archivo de N elementos,pues
en cada pasada se duplica el tamaño de los subarchivos ordenados.
Es importante notar que la fusiones reales efectuadas por este método «as-
cendente» no son las mismas que las realizadaspor la implementación anterior.
Considérese la ordenación de 95 elementos que se muestra en la Figura 12.3.
La última fusión es una 64 por 3 1, mientras que en la ordenación recursiva se-
na un 47 por 47. Es posible, sin embargo, organizar las cosas de forma que la
secuencia de fusiones hecha por los dos métodos sea la misma, aunque no hay
ninguna razón particular para hacer esto.
A continuación se da una implementación detallada de esta aproximación
ascendente, utilizando listas enlazadas.
struct nodo *ordenfusion(struct nodo *c)
r
I
int i, N;
struct nodo *a, *by *cabeza, *resto, *t;
cabeza = new nodo;
cabeza->siguiente = c; a = z;
for (N = 1; a != cabeza->siguiente; N = N+N)
resto = cabeza->siguiente; c = cabeza;
while (resto != z)
t = resto; a = t;
for (i = 1; i < N; i++) t = t->siguiente;
b = t->siguiente; t->siguiente = z; t = b;
for (i = 1; i < N; i++) t = t->siguiente;
resto = t->siguiente;
c->siguiente = fusion(a, b);
for ( i = 1; i <= N+N; i++) c = c->siguiente;
{
{
t->siguiente = z;
186 ALGORITMOS EN C++
Figura 12.2 Ordenación por fusión no recursiva.
ORDENACIÓN POR FUSIÓN 187
i
return cabeza->si gui ente;
}
Figura 12.3 Ordenación por fusión de una permutaciónaleatoria.
Este programa utiliza un nodo (cabecera de lista» (al que apunta cabeza) cuyo
campo enlace apunta a la lista enlazada que se está ordenando. Cada iteración
del bucle externo (for)recorre el archivo, produciendo una lista enlazada com-
puesta de subarchivos ordenados, dos veces más grandes que los de la pasada
anterior. Esto se hace manteniendo dos punteros, uno a la parte de la lista que
188 ALGORITMOS EN C++
aún no se ha visto (resto) y otro al final de la parte de la lista en la que ya se
han fusionado los subarchivos (c). El bucle interno (whi 1e) fusiona los dos su-
barchivos de longitud N comenzando con el nodo al que apunta resto y pro-
duce un subarchivo de longitud N+N, el cual se enlaza con la lista c del resul-
tado.
La fusión real se lleva a cabo almacenando un enlace al primer subarchivo
a fusionar en a, saltando luego N nodos (utilizando el enlace temporal t),y en-
lazando z con el final de la lista de a, haciendo después lo mismo para tener
otra lista de N nodos a la que apunta b (actualizando resto con el enlace al
último nodo visitado) y llamando después a fusion. (Entonces se actualiza c
siguiendo hacia abajo hasta el final de la lista que se acaba de fusionar. Éste es
un método más simple, pero algo menos eficaz,que las diversasalternativas dis-
ponibles, tales como hacer que fusion devuelva punteros al principio y al final
o mantener múltiples punteros sobre cada nodo de la lista.)
La ordenación por fusión ascendentees también un método interesante para
utilizar en una implementación con arrays; esto se deja al lector como un ejer-
cicio instructivo.
Características de rendimiento
La ordenación por fusión es importante porque es un método de ordenación
«óptimo» bastante directo que se puede implementar de forma estable. Estos
hechos son relativamente fáciles de demostrar.
Propiedad 12.1 La ordenaciónpor fusión necesita alrededor de MghT compa-
racionespara ordenar un archivo de N elementos.
En la implementación anterior, cada fusión M por N necesitará M + N com-
paraciones (esto podría variar en una o dos unidades, dependiendo de cómo se
utilizan los centinelas). En una ordenación por fusión ascendente, se utilizan
1gNpasadas y cada una necesita alrededor de N comparaciones. Para la versión
recursiva, el número de comparaciones se describe por la recurrencia estándar
de «divide y vencerás))MN= 2MN/2 + N, con M , = O. Se sabe del Capítulo 6
que esta ecuación admite la solución MN = MU. Precisamente estos argumen-
tos son los dos verdaderos si N es una potencia de dos; se deja como ejercicio
demostrar que esto ocurre también para cualquier N. Más aún, esto también es
válido en el caso medi0.i
Propiedad 12.2 La ordenaciónporfusión utiliza un espacio extra proporcional
a N.
Esto se deduce de las implementaciones, pero se pueden dar algunospasos para
disminuir el impacto de este problema. Por supuesto, si el «archivo» a ordenar
ORDENACIÓN POR FUSIÓN 189
es una lista enlazada, el problema no aparece, dado que el «espacio extra) (para
los enlaces)está por otros motivos.
Para arrays, es fácil hacer una fusión M por N utilizando espacio extra so-
lamente para el más pequeño de los dos arrays (ver Ejercicio 2). Esto reduce a
la mitad las necesidades de espacio de la ordenación por fusión. En realidad es
posible hacer esto mucho mejor y hacer fusiones in situ, aunque en la práctica
es poco probable que merezca la pena..
Propiedad 12.3 La ordenaciónporfusión es estable.
Puesto que todas las implementaciones realmente sólo mueven las claves du-
rante las fusiones,es suficienteverificar que las fusionesen sí son estables. Pero
esto es evidente:la posición relativa de las clavesiguales no se altera por el pro-
ceso de fusión..
Propiedad 12.4 La ordenaciónpor fusión es insensible al orden inicial de la
entrada.
En las impleinentaciones, la entrada determina sólo el orden en el que se pro-
cesan los elementos en las fusiones, por lo tanto esta sentencia es literalmente
exacta (excepto para alguna variación que depende de cómo se compila y eje-
cuta la instrucción if, lo que debería ser insignificante).Otras implementacio-
nes de fusión, que implican comprobaciones relativas al primer archivo que se
recorra completamente, pueden conducir a algunas variaciones más grandes,
según sea la entrada, pero no mucho mayores. El número de pasadas que se ne-
cesita depende sólo del tamaño del archivo, no de su contenido, y cada pasada
necesita alrededor de N comparaciones (realmente N-O(1) como media, como
se explicará más adelante). Pero el peor caso es más o menos el mismo que el
caso medi0.m
La Figura 12.4muestra una ordenación por fusión ascendente que opera so-
bre un archivo que está inicialmente en orden inverso. Es interesante comparar
esta figura con la Figura 8.9, que muestra a la ordenación de Shell haciendo las
mismas operaciones.
La Figura 12.5presenta otro aspecto de la ordenación por fusión que opera
sobre una permutación aleatona, para compararla con diagramas similares de
los primeros capítulos. En particular la Figura 12.5 muestra una sorprendente
semejanza con la Figura 10.5: en este sentido, la ordenación por fusión es ila
«transpuesta» del método de ordenación por residuos!
Implementaciones optimizadas
En la presentación de los centinelas ya se ha prestado alguna atención al bucle
interno de la ordenación por fusión basado en arrays, y se ha visto que las com-
190 ALGORITMOS EN C++
Figura 12.4 Ordenación por fusión de una permutaciónen orden inverso.
probaciones de los límites del array en el bLic!e interno se pueden evitar invir-
tiendo el orden de uno de los arrays. Esto llama la atención sobre una de las
mayores deficiencias de la implementación anterior: el desplazamiento de a ha-
cia b. Como se vio para el método de ordenación por residuos del Capítulo 10,
este desplazamiento se puede evitar utilizando dos copias del código, una para
fiisionar de a a b y otra de b a a.
Para llevar a cabo una combinación de estas dos mejoras, es necesario cam-
biar las cosas de modo que fusion pueda dar como salida los arrays, en orden
creciente o decreciente. En la versión no recursiva, esto se lleva a cabo alter-
nando entre una salida creciente y una decreciente; en la versión recursiva hay
ORDENACIÓN POR FUSIÓN 191
~~~
..
.. .
..
. -
..
. . - m = - i - . .
=. . 9 ... . '
. ..-=.
. : 9 :
. .
. :
.... .
. .
. . .
- .
...._.. .
I ' . I
. O
. * *
e * . . ' O
. * o
* *
' 0
* b
. . * . * b
.
* e * e * '
0 .
b
. . =
. .
. . .
. . I
. .. =.
.. 1 .
..
. .
Figura 12.5 Ordenación por fusión de una perrnutación aleatoria.
que tener cuatro rutinas recursivaspara fusionar a (b) en b (a) con el resultado
en orden decreciente o creciente. Cualquiera de ellos reducirá el bucle interno
de la ordenación por fusión a una comparación, un almacenamiento, dos incre-
mentos de punteros (i
o j,y k) y una comprobación del puntero. Esto compite
favorablemente con una comparación, un incremento y una comprobación y
un intercambio (parcial) del Quicksort, y el bucle interno del Quicksort se eje-
cuta 2 1 0 = 1,38lgNveces,alrededor de un 38%más frecuentemente que el de
la ordenación por fusión.
Revisión de la recursión
Los programas de este capítulo,junto con el del Quicksort, son implementacio-
nes típicas de los algoritmos de divide y vencerás. En capítulos posteriores se
verán vanos algoritmos con estructuras similares, por lo que merece la pena
echar un vistazo más detallzdo a algunas de las características básicas de estas
implementaciones.
El Quicksort es realmente un algoritmo de «vence y dividirás)):en una im-
plementación recursiva, la mayor parte del trabajo se hace antes de las llamadas
recursivas. Por el contrario, la ordenación por fusión recursiva está más en el
espíritu de «divide y vencerás)): cada archivo se divide primero en dos partes y
192 ALGORITMOS EN C++
luego se «vence» a cada una individualmente. El primer problema para el que
la ordenación por fusión hace el procesamiento real es el más pequeño; el sub-
archivo mayor se procesa al final. En el Quicksort el procesamiento comienza
sobre el subarchivo mayor y finaliza con los más pequeños.
Esta diferencia se manifiesta en las implementaciones no recursivas de los
dos métodos. El Quicksort debe mantener una pila, puesto que tiene que me-
morizar grandes subproblemas, que se dividen en función de los datos. La or-
denación por fusión admite una versión no recursiva simple porque la forma
en que divide el archivo es independiente de los datos, por lo que el orden en el
que procesa los subproblemas puede reorganizarse de alguna forma para hacer
el programa más simple.
Otra diferenciapráctica que se manifiesta por sí misma es que la ordenación
por fusión es estable (está implementada adecuadamente) y el Quicksort no lo
es (sin tener que recurrir a complicaciones extra). Para la ordenación por fu-
sión, si se supone (por inducción) que los subarchivos han sido ordenados es-
tablemente, es suficiente con asegurar que la fusión se hace de una manera es-
table,lo que puede hacersecon facilidad.Pero para el Quicksortno parece existir,
por sí misma, ninguna forma fácil de hacer la partición de una manera estable,
lo que impide la posibilidad de estabilidad, incluso antes de que entre en juego
la recursión.
Una nota final: como el Quicksort o cualquier otro programa recursivo, la
ordenación por fusión se puede mejorar tratando los subarchivos pequeños de
forma diferente. En las versiones recursivas del programa esto se puede imple-
mentar exactamente como para el Quicksort,bien haciendo sobre la marcha una
ordenación por inserción de los subarchivos pequeños, bien haciendo una pa-
sada final de limpieza. En las versiones no recursivas,los pequeños subarchivos
ordenados se pueden construir en una pasada inicial utilizando una versión mo-
dificada de la ordenación por inserción o por selección. Otra idea que se ha su-
gerido para la ordenación por fusión es aprovecharse del orden «natural» del
archivo utilizando un método ascendente para fusionar las dos primeras se-
cuencias ordenadas del archivo (sin importar lo largas que puedan ser), después
las dos secuencias siguientes, etc., repitiendo el proceso hasta que el archivo
quede ordenado. A pesar de lo atractiva que pueda parecer esta idea, no se puede
comparar con el método estándar que se ha presentado, porque el costede iden-
tificar las secuencias,que debe imputarse al bucle interno, es mayor que las ga-
nancias alcanzadas, excepto para ciertos casos degenerados (tales como un
archivo ya ordenado).
Ejercicios
1. Implementar una oráenación por fusión recursiva, que procese por medio
de una ordenación por inserción los subarchivos con menos de M elemen-
ORDENACIÓN POR FUSIÓN 193
2.
3.
4.
5.
6.
7.
8.
9.
10.
tos; determinar empíricamente el valor de A
4para el que se ejecuta más rá-
pidamente sobre un archivo aleatorio de 1.O00 elementos.
Comparar empíricamente la ordenación por fusión recursirva y la no re-
cursiva para listas enlazadas y N = 1.OOO.
Implementar la ordenación por fusión recursiva para un array de N ente-
ros, utilizando un array auxiliar de tamaño menor que N/2.
Verdadero o falso: el tiempo de ejecución de la ordenación por fusión no
depende del valor de las clavesdel archivo de entrada. Explicarla respuesta.
¿Cuál es el número mínimo de pasos de la ordenación por fusión (dentro
de un factor constante)?
Implementar una ordenación por fusión ascendente no recursiva que uti-
lice dos arrays en lugar de listas enlazadas.
Mostrar las fusiones efectuadas al utilizar la ordenación por fusión recur-
siva para ordenar las claves C U E S T I O N F A C I L.
Mostrar el contenido de las listas enlazadas de cada iteración al utilizar la
ordenación por fusión no recursiva para ordenar las claves C U E S T I O
N F A C I L .
Intentar escribir una ordenación por fusión recursiva, utilizando arrays,
partiendo de la idea de hacer fusionesde tres vías en lugar de dos vías.
Comprobar empíricamente, para archivos aleatorios de tamaño 1.OOO, la
afirmación hecha en el texto de que no compensa la idea de aprovecharse
del orden «natural» en el archivo.
Algoritmos en C++.pdf
13
Ordenación externa
Muchas importantes aplicaciones de ordenación deben procesar archivos muy
grandes, demasiado como para tenerlos en la memoria principal de cualquier
computadora. Los métodos adaptados a estas aplicaciones se denominan mé-
todos externos, puesto que implican un gran volumen de procesamiento ex-
terno a la unidad central de proceso (en contraste con los métodos internos que
se han visto anteriormente).
Hay dos factores determinantes que hacen que los algoritmos externos sean
diferentesde los que se han visto hasta ahora. El primero es que el coste de ac-
ceso a un elemento es infinitamente más grande que el de cualquier actuaiiza-
ción o cálculo. El segundo, y todavía más costosoque el anterior, es que existen
severas restriccionesde acceso, dependiendo del medio de almacenamiento ex-
terno utilizado: por cjemplo, no se puede acceder a los elementos de una cinta
magnética más que de forma secuencial.
La gran variedad de dispositivosde almacenamiento externo y de costes ha-
cen que el desarrollo de los métodos de ordenación externos sea muy depen-
diente de la tecnología actual. Estos métodos pueden ser muy complicados y
son numerosos los parámetros que afectan su rendimiento: un método muy in-
genioso puede que no sea apreciado o utilizado por un simple cambio en la tec-
nología. Por esta razón este capítulo se centra más en los métodos generales que
en el desarrollo de implementaciones especificas.
En síntesis, tratándose de la ordenación externa, los aspectos del problema
relativos al «sistema» son tan importantes como los aspectos «algontmicos».
Ambas áreas se deben considerar cuidadosamente si se quiere desarrollar una
ordenación externa eficaz. El coste principal de la ordenación externa se debe a
la entrada-salida. Un buen ejerciciopara alguien que planea hacer un programa
para ordenar un archivo muy grande es implementar antes un programa para
copiar un gran archivo y luego (si esto ha sido demasiado fácil)implementar un
programa eficaz para invertir el orden de los elementos de un archivo de este
tipo. Los problemas de sistema que aparecen ai tratar de resolver estos proble-
mas eficazmente son similares a los que aparecen en las ordenaciones externas.
195
196 ALGORITMOS EN C++
Permutar un gran archivo externo de forma no trivial es tan difícil como orde-
narlo, aun cuando no se necesiten comparaciones entre claves, etc. En la orde-
nación externa, se desea principalmente limitar el número de veces que cada
elemento de datos se desplaza entre el medio de almacenamientoexterno y la
memoria principal, y estar seguro de que tales transferencias se hacen tan efi-
cazmentecomo lo permita el material del que se dispone.
Se han desarrollado métodos de ordenación externa que se adaptan a las tar-
jetas perforadas y cintas de papel del pasado, a las cintas magnéticas y discos del
presente y a las nuevas tecnologías como las memorias de burbuja y los video-
discos. La diferencia esencial entre los múltiples dispositivos son el tamaño del
almacenamientodisponible y la velocidad y los tipos de restricción de acceso a
los datos. En este libro se estudian los principios básicos de ordenación en las
cintas magnéticas y los discos, porque estos dispositivos son posiblemente los
que continuarán siendo muy utilizados e ilustran los dos modos fundamentales
de acceso que caracterizan a muchos sistemas de almacenamientoexterno. Fre-
cuentementelos sistemasmodernostienen una «jerarquía de almacenamiento»
de varias memorias cada vez más lentas, baratas y voluminosas. Aunque mu-
chos de los algoritmos que se van a considerar pueden transformarse en algont-
mos eficaces en tales entornos, aquí se tratarán exclusivamentelas memorias de
«dosniveles» de jerarquía, que comprenden una memoria principal y una de
disco o cinta.
Ordenación-fusión
La mayoría de los métodos de ordenación externa utilizan la siguiente estrategia
general: primero hacen una pasada a lo largo del archivo a ordenar, dividiendo
a éste en bloques del tamaño de la memoria interna, y ordenando estos bloques.
Luegofusionan entre sí los bloques ordenados haciendo varias pasadas a través
del archivo, creando sucesivamente archivos ordenados más grandes hasta que
el archivo completo esté ordenado. El acceso a los datos es en su mayoría de
forma secuencial, lo que hace apropiado este método para la mayor parte de los
dispositivosexternos. Los algoritmos de ordenación externa intentan reducir el
número de pasadas sobre el archivo y aproximar lo máximo posible el coste de
una pasada sencilla al coste de una copia.
Puesto que la mayor parte del coste de un método de ordenación externa se
debe a la entrada-salida, es posible tener una medida aproximada del coste de
una ordenación-fusión contando el número de veces que se lee o escribe una
palabra del archivo (el número de pasadas sobre todos los datos). En muchas
aplicacioneslos métodos que se van a considerar implican unas diez pasadas o
menos, lo que supone que cualquier método que pueda eliminar aunque sólo
sea una simple pasada es digno de interés. Además, el tiempo de ejecución de
la ordenación externa global puede estimarse fácilmente a partir del tiempo de
ORDENACIÓNEXTERNA 197
Figura 13.1 Fusiónequilibradade tres vías: resultado de la primerapasada.
ejecución de la acción de ((invertirel archivo de copia), ejercicio sugerido an-
teriormente.
Fusión múltiple equilibrada
Para empezar, se seguiránlos diferentespasos del procedimiento más simplede
ordenación-fusión sobre un ejemplo pequeño. Se supone que los registros con
las claves E J E M P L O D E O R D E N A C I O N F U S I O N en una cinta
de entrada se deben ordenar y colocar sobre una cinta de salida. Utilizar una
«cinta» significa simplemente la obligación de leer los registros secuencial-
mente: el segundo registro no puede leerse hasta que no se haya leído el pri-
mero, y así sucesivamente. Se supone además que sólo hay espacio en la me-
moria para tres registros, pero que se dispone de todas las cintas que se desee.
El primer paso consisteen leer del archivo tres registros cada vez, ordenarlos
en bloques de tres registros y dar como salida los bloques ordenados. Así, pri-
mero se lee E J E y se obtiene el bloque E E J, luego se lee M P L, dando como
salida al bloque L M P, y así sucesivamente.Ahora bien, para que estos bloques
se puedan fusionar entre sí, deben estar en cintas diferentes. Si se desea hacer
una fusión de tres vías, entonces se deben utilizar tres cintas, finalizando la or-
denación anterior con la configuración que se muestra en la Figura 13.1.
Ahora ya se pueden fusionar los bloques ordenados de tamaño tres. Se lee
el primer registro de cada cinta de entrada (hay espaciojusto para ello en la me-
moria) y se extrae el de menor clave. A continuación se lee el siguiente registro
de la misma cinta de la que se leyó el que se acaba de extraer y, de nuevo, se da
salida al registro de la memoria que tenga la menor clave. Cuando se alcance el
final de un bloque de tres palabras de una de las entradas, entonces se ignora
esa cinta hasta que se hayan procesado los respectivos bloques de las otras dos
y se haya dado salida a nueve registros. Luego se repite el proceso para fusionar
los segundosbloquesde tres palabrasde cada cinta en un nuevo bloque de nueve
palabras (que se escribe en una cinta diferente, para que esté listo para la pró-
198 ALGORITMOS EN C++
Cinta 1 E
Cinta 2 E
Cinta 3 H
Figura 13.2 Fusión equilibradade tres vías: resultadode la segunda pasada.
xima fusión). Continuando de esta forma, se llega a los tres grandes bloques
configurados como muestra la Figura 13.2.
Ahora una última fusión de tres vías completa la ordenación. Si se tiene un
archivo mucho maym con múltiples bloques de tamaño 9 en cada cinta, enton-
ces se finaliza la segunda pasada con bloques de tamafio 27 en las cintas 1, 2 y
3, y una tercera pasada produciría bloques de tamaño 81 en las cintas 4,5 y 6,
y así sucesivamente. Se necesitan seis cintas para ordenar un archivo arbitraria-
mente grande: tres para la entrada y tres para la salida de cada fusión de tres
vías. (Realmente, se puede hacer con sólo cuatro cintas: la salida se puede co-
locar sólo en una cinta y después distribuir los bloques de ella sobre las tres cin-
tas de entrada entre cada pasada de fusión.)
Este método se denomina fusion múltiple equilibrada: constituye un algo-
ritmo razonable para hacer ordenaciones externas y es un buen punto de par-
tida para una implementación de ordenación externa. Los algoritmos más so-
fisticados que se describen después pueden hacer que la ordenación se ejecute
algo más rápidamente, pero no mucho más. (Sin embargo, cuando los tiempos
de ejecución se miden en horas, lo que no es raro en la ordenación externa, in-
cluso un pequeño tanto por ciento de disminución del tiempo de ejecución puede
ser importante.)
Suponiendo que la ordenación debe emplear N palabras y que se dispone de
una memoria interna de tamaño M, cada pasada de «ordenación» praduce al-
rededor de N/M bloques ordenados. (Esta estimación supone registros du
p una
palabra: para registros más grandes, el número de tloques ordenados se calcula
multiplicando el resultado anterior por el tamaño del registro.) Si se hacen fu-
siones de P-vías en cada paso posterior, el número de pasadas es alrededor de
log,(N/M), puesto que cada paso reduce el número de bloques ordenados en un
factor P.
Aunque los pequeños ejemplos pueden ayudar a comprender los detallesdel
algoritmo, es mejor razonar en términos de archivos muy grandes cuando se
trabaja con ordenaciones externas. Por ejemplo, la fórmula anterior indica que
si se utiliza una fusión de cuatro vías para ordenar un archivo de 200 millones
de palzbras en una computadora de un millón de palabras de memoria, el nú-
ORDENACIÓN EXTERNA 199
mero de pasadas podría ser aproximadamente cinco. Se puede obtener una es-
timación muy grosera del tiempo de ejecución multiplicando por cinco el CO-
rrespondiente a una implementación de ordenación en orden inverso sugerida
con anterioridad.
Selecciónpor sustitución
La implementación del método anterior se puede desarrollar de una forma muy
elegante y eficaz utilizando colas de prioridad. En primer lugar se verá que las
colas de prioridad ofrecen una forma natural de implementar una fusión múl-
tiple. Más importante aún es que se pueden utilizar las colas de prioridad para
la pasada inicial de ordenación de forma tal que produzcan bloques ordenados
mucho más grandes que los que puede contener la memoria interna.
La operación básica que se necesita para hacer una fusión de P-vías es dar
salida al más pequeño de los elementos más pequeños todavía presentes en cada
uno de los P bloques a fusionar. Ese elemento más pequeño debería reempla-
zarse por el siguiente elemento del bloque del que proviene. La operación sus-
tituir en una cola de prioridad de tamaño P es exactamente lo que se necesita.
(En realidad, las versiones indirectas de las rutinas de colas de prioridad descri-
tas en el Capítulo 11 son las más apropiadas para esta aplicación.) Específica-
mente, para hacer una fusión de P-vías se comienza llenando una cola de prio-
ridad de tamaño P con el eiemento más pequeño de cada una de las Pentradas
utilizando el procedimiento CP: :insertar del Capítulo 11 (adecuadamente
modificado para que la raíz del montículo contenga al elemento más pequeño
en lugar del más grande). Después, utilizando el procedimiento CP ::sustitui r
del Capítulo 11 (modificado de la misma manera) se da salida al elemento más
pequeño y se reemplaza en la cola de prioridad por el siguiente elemento de su
bloque.
El procesode fusionar E E J con L M P y D E O (la primera fusión del ejem-
plo anterior) utilizando un montículo de tamaño tres se muestra en la Figura
13.3. Las «claves» de estos montículos son las más pequeñas (las primeras) de
las clavesde cada nodo. Por claridad, se muestran bloques enteros en los nodos
del montículo; por supuesto, una implementación real consistiría en un mon-
tículo indirecto de punteros dentro de los bloques. Primero, se da salida a D,
por lo que la E (la clave siguiente de su bloque) se convierte en la «clave» de la
raíz. Como esto no viola la condición del montículo se da salida a la E, convir-
tiéndose O en la clave de la raíz. Esto sí viola la condición del montículo y por
ello se intercambia el nodo con el que contiene E, E y J. A continuación se ex-
trae la E y se sustituye por la siguiente clave de su bloque, la E. Esto no viola la
condición del montículo, por lo que no es necesario ningún cambio más. Con-
tinuando de esta manera, se obtiene el archivo ordenado (leyendo la clave más
pequeña del nodo raíz de los árboles de la Figura 13.3, para ver las claves en el
orden en el que aparecen en la primera posición de1 montículo y en el que se
200 ALGORITMOS EN C++
Figura 13.3 Selección por sustituciónpara la fusión sobre un montículode tamaño tres.
obtienen en la salida). Cuando se agota un bloque, se pone un centinela en el
montículo al que se considera mayor que todas las otras claves.Cuando el mon-
tículo no contiene más que centinelas, se ha terminado la fusión. A esta forma
de utilizar las colas de prioridad se la denomina algunas veces selección por sus-
titución.
Así pues, para hacer una fusión de P-vías, se puede utilizar una selección
por sustitución sobre una cola de prioridad de tamaño P para encontrar cada
elemento a dar como salida en lo@ pasos. Esta diferencia de rendimiento no
tiene ninguna repercusión práctica en particular, puesto que una implementa-
cion de fuerza bruta puede encontrar, en P pasos, cada elemento a dar como
salida y P es normalmente tan pequeño que este coste es minúsculo en com-
paración con el de estar realmente dando salida al elemento. La importancia
real de la selección por sustitución reside en la forma en que se puede utilizar
en la primera parte del proceso de ordenación-fusión: formar los bloques inicia-
les ordenados que constituirán la base de las pasadas de la fusión.
La idea es pasar la entrada (desordenada) a través de una gran cola de prio-
ridad, escribiendo siempreen la salida el elemento más pequrño de la cola, como
antes, y sustituyéndolo por el siguiente elemento de la entrada, con un requisito
adicional:si el nuevo elemento es menor que el último en salir, entonces,puesto
que probablemente no podría formar parte del bloque que se está ordenando,
se debería marcar como miembro del bloque siguientey considerarse como su-
perior a todos los elementos del bloque actual. Cuando un elemento marcado
alcanza la cabeza de la cola de prioridad, se abandona el antiguo bloque y se
comienza con uno nuevo. Una vez más, esto se implementa fácilmente con
CP: :insertar y CP: :sustituir del Capítulo l i , apropiadamente modifica-
dos de modo que el elemento más pequeño esté en la raíz del montículo y cam-
biando CP : :susti t ui r para que considere que los elementos marcados son
siempre mayores que los no marcados.
El archivo ejemplo demuestra claramente el valor de la selección por susti-
ORDENACIÓN EXTERNA 201
Figura 13.4 Creación de las secuencias inicialesde la selección por sustitución.
tución. Con una memoria interna capaz de contener sólo tres registros, se pue-
den producir bloques ordenados de tamaño 7, 4,3, 5, 5 y 1, como se ilustra en
la Figura 13.4. Como antes, el orden en que las claves ocupan las primeras po-
sicionesdel montículo es en el que se obtendrán en la salida. El sombreado in-
dica a qué bloque pertenece cada clave del montículo: un elemento marcado de
la misma forma que el de la raíz pertenece al bloque que está siendo ordenado
y los otros pertenecen al bloque siguiente. La condición del montículo (la pri-
mera clave es menor que la segunda y la tercera) se mantiene en todas partes;
los elementos del bloque siguiente se consideran como más grandes que los ele-
mentos del bloque que se está ordenando. La primera secuencia termina con D
E O en el montículo, puesto que al llegar cada una de estas tres claves son más
grandes que la raíz (por tanto no se pceden incluir en el primer bloque), la se-
gunda termina con D E N, etcétera.
Propiedad 13.1 Para claves aleatorias, las secuencias creadaspor la selección
por sustitución son aproximadamente del doble del tamaño del nzontículo utili-
zado.
La demostración de esta propiedad necesita de hecho un análisis un poco .más
sofisticado, pero es fácil de verificar experimenta1mente.i
El efecto práctico de esta propiedad es ganar una pasada de fusión: en vez
de comenzar con secuencias ordenadas de aproximadamente el tamaño de la
202 ALGORITMOS EN C++
memoria interna y después hacer una pasada de fusión para producir secuen-
cias de alrededor del doble del tamaño de dicha memoria, se puede comenzar
directamente con secuencias cuyo tamaño sea de unas dos veces el de la me-
moria interna, utilizando la selección por sustitución con una cola de prioridad
de tamaño M. Si hay algún orden en las claves, entonces las secuencias serán
mucho, mucho más largas. Por ejemplo, si ninguna clave tiene delante de ella
en el archivo más de M claves superiores, jel archivo estará completamente or-
denado después de la pasada de la selección por sustitución, y no será necesaria
ninguna fusión! Ésta es la razón práctica más importante para utilizar el mé-
todo.
En resumen, la técnica de selección por sustitución puede utilizarse a la vez
para los pasos de {(ordenación))y de «fusión» de una fusión múltiple equili-
brada.
Propiedad 13.2 Un archivo de N registrossepuede ordenar utilizando una me-
moria interna capaz de contener M registrosy con P + 1 cintas en alrededor de
1 + 10gP(N/2hf)pasadas.
Como se presentó anteriormente, se utiliza primero una selección por sustitu-
ción con una cola de prioridad de tamaño M, para producir secuenciasiniciales
de tamaño próximo a 2M (en una situación aleatoria) o más (si el archivo está
parcialmente ordenado), y luego se utiliza la selección pcr sustitución con una
cola de prioridad de tamaño P,para alrededor de logp(N/2M)(o menos) pasadas
de fusión.m
Consideraciones prácticas
Para terminar de implementar el método antes esbozado, es necesario hacerlo
con las funciones de entrada-salida que realmente transfieren los datos entre el
procesador y losdispositivosexternos. Estas funcionesson evidentementela clave
del buen rendimiento de una ordenación externa, y necesitan que se consideren
cuidadosamente (al contrario que los algoritmos)algunos aspectos del sistema .
(Los lectores que no tengan ninguna relación con las computadoras a nivel de
«sistema» pueden saltarse los próximos párrafos.)
Uno de los objetivos principales de la implementación debería ser el per-
mitir un recubrimiento de la lectura, la escritura y los cálculos, tanto como sea
posible. La mayoría de los grandes sistemas informáticos tienen unidades de
procesamiento independientes para el control de los dispositivos de entrada/sa-
lida (E/S) en gran escala, lo que hace posible este recubrimiento. La eficacia que
se puede alcanzar con un método de ordenación externa depende del número
de dispositivosde este tipo.
Para cada archivo que se está leyendo o escribiendo, se puede utilizar la téc-
nica de programación de sistemasdenominada doble bufer para hacer máximo
ORDENACIÓN EXTERNA 203
el recubrimiento de E/S con el cálculo. La idea e5 mantener dos «buffers», uno
reservado para el procesador principal y el otro para el dispositivo de EJS (o del
procesador que controla al dispositivo de E/S). Para la entrada, el procesador
utiliza un buffer mientras el dispositivo de entrada está llenando el otro. Cuando
el procesador termina de utilizar su buffer, espera hasta que el dispositivo de
entrada llene el suyo, y entonces los buffers intercambian sus papeles: el proce-
sador utiliza los datos del buffer que se acaba de llenar mientras que el disposi-
tivo de entrada vuelve a llenar el buffer que tenía los datos que el procesador
acaba de utilizar. La misma técnica se utiliza para la salida, canibiando los pa-
peles del procesador y el dispositivo.Habitualmente el tiempo de E/S es mucho
más grande que el de procesamiento y, por lo tanto, el efecto del doble buffer
es recubrir totalmente el tiempo de cálculo; por consiguiente los buffers deben
ser tan grandes como sea posible.
Una dificultad del doble buffer es que realmente utiliza sólo la mitad del es-
pacio de memoria disponible. Esto puede conducir a una falta de eficacia si
existen muchos buffers, como es el caso de la fusión de P-vías cuando P no es
pequeño. Este problema se puede soslayar utilizando una técnica denominada
previsión, que necesita sólo un buffer extra (y no P)durante el proceso de fu-
sión. La previsión funciona de la siguiente forma: ciertamente la mejor forma
de recubrir la entrada con los cálculos durante el proceso de selección por sus-
titución es recubrir la entrada del siguiente buffer que se necesita llenar con la
parte de procesamiento del algoritmo. Y es fácil determinar qué buffer es éste:
el siguiente buffer de entrada a vaciar es aquel cuyo ultimo elemento es el más
pequeño. Por ejemplo, cuando se fusiona E E J con L M P y I9 E O se sabe que
el primer buffer será el pRmero a vaciar, luego es el primero. Una forma simple
de recubrir el procesamiento con la entrada en una fusión múltiple consiste,por
lo tanto, en conservar un buffer extra que se llenará por el dispositivo de en-
trada de acuerdo con esta regla. Cuando el procesador encuentra un buffer va-
cío, espera hasta que el buffer de entrada esté lleno (si no se ha llenado ya), y
luego cambia, para comenzar a utilizar este buffer, en lugar del vacío, al que
dirige al dispositivo de entrada para que lo llene de nuevo de acuerdo cofi la
regia de previsión.
La decisión más importante a tomar en la implementación de la fusión múl-
tiple es la elección del valor de P, el «orden» de la fusión. Para ordenación en
cinta, donde sólo se permite acceso secuencial,esta elección es fácil: P debe ser
igual ai número de unidades de cinta disponibles menos uno, puesto que la fu-
sión múltiple utiliza P cintas de entrada y una de salida. Evidentemente, se de-
ber, tener al menos dos cintas de entrada, por tanto no tiene sentido tratar de
ordenar en cintas si se dispone de menos de tres de ellas.
Para crdenaciór, en disco, donde se permite el acceso a una posición arbitra-
ria pero con un coste algo más caro que el acceso secuencial, es razonable es-
coger P igual al número de discos disponibles menos urio, para evitar el coste
más elevado del acceso no secuencial que se produciría, por ejemplo, si dos
archivos de entrada diferentes estuvieran en el mismo disco. Otra alternativa
comúnmente utilizada es escoger P lo suficientemente grande para que la or-
204 ALGORITMOS EN C++
denación se complete en dos fases de fusión: normalmente no es razonable tra-
tar de hacer la ordenación en una pasada, pero a veces se puede hacer en dos,
con un P razonablemente pequeño. Puesto que la selección por sustitución pro-
duce alrededor de N/2M secuencias y cada paso de fusión divide el número de
secuencias por P, esto significa que el valor de P debe ser el menor entero tal
que > N/2M. Para el ejemplo de ordenación de un archivo de 200 millones
de palabras en una computadora con un millón de palabras de memoria, esto
significa que P = 11 sena una buena elección para una ordenación de dos pa-
sadas. (El valor exacto de P podría calcularse después de completar la fase de
ordenación.) La mejor elección entre estasdos alternativas, del valor razonable-
mente más bajo de P y el valor razonablemente más alto de P, depende fuerte-
mente de muchos parámetros del sistema: se deben considerar ambas alterna-
tivas (e incluso algunas intermedias).
Fusión pdifásica
Uno de los problemas de la fusión múltiple equilibradaen la ordenación en cinta
es que necesita o un número excesivo de unidades de cinta o una cantidad ex-
cesiva de copias. Para una fusión de P-vías o se utilizan 2P cintas (P para la
entrada y P para la salida) o se debe copiar casi todo el archivo desde una cinta
de salida a P cintas de entrada entre pasadas de fusión, lo que efectivamente
dobla el número de pasadas, es decir, alrededor de 21ogp(N/2M). Se han inven-
tado varios algoritmos ingeniosos de ordenación en cintas, que eliminan vir-
tualmente todas estas copias cambiando la forma en la que todos estas peque-
ños bloques ordenadm se fusionan entre sí. El más extendido de estos métodos
es el denominadofusión polifásica.
La idea básica que subyace en la fusión polifásica es distribuir los bloques
ordenados, mediante una selección por sustitución, de forma irregular entre las
unidades de cinta disponibles (dejando una vacía) y aplicando posteriormente
una estrategia de «fusión hasta el vaciado», después de la cual las cintas de sa-
lida y entrada intercambian sus papeles.
Por ejemplo, se supone que se tienen exactamente tres cintas, y se parte de
la configuración inicial de bloques ordenados en las cintas que se muestran en
la parte superior de la Figura 13.5. (Esto se obtiene al aplicar la selección por
sustitución al archivo ejemplo con una memoria interna que sólo puede conte-
ner dos registros.) La cinta 3 que está inicialmente vacía es la cinta de salida
para las primeras fusiones. Después de tres fusionesde dos vías desde las cintas
1 y 2 hacia la cinta 3, la segunda cinta se vacía, como se muestra en la mitad de
la Figura 13.5. A continuación, después de dos fusiones de dos vías desde las
cintas I y 3 hacia la cinta 2, la primera cinta se vacía, como se muestra en la
parte inferior de la Figura 13.5. La ordenación se completa en dos pasos más.
Primero, una fusión de dos vías desde las cintas 2 y 3 hacia la cinta 1 deja un
archivo en la cinta 2 y un archivo en la cinta 1, y despuésuna fusión de dos vías
ORDENACIÓN EXTERNA 205
Cinta2
Cinta3
Cinta 3
A C D E E I IJINlOlOlR1i[DIEIEIFIMINIPISIU]i
I L N O O W
Figura 13.5 Etapas inicialesde una fusión polifásicacon tres cintas.
desde las cintas 1 y 2 hacia la cinta 3 deja el archivo totalmente ordenado en la
cinta 3.
Esta estrategiade afusión hasta el vaciado))se puede generalizar para traba-
jar con un número arbitrario de cintas. La Figura 13.6muestra cómo se pueden
utilizar seis cintas para ordenar 497 datos iniciales. Si se comienza como se in-
dica en la primera columna de la Figura 13.6, con la cinta 2 como cinta de sa-
lida, la cinta l con 61 datos, la cinta 3 con 120,etc., entonces, después de eje-
cutar una «fusión hasta el vaciado))de cinco vías, se tendrá la cinta 1 vacía, la
cinta 2 con 61 datos, la cinta 3 con 59, etc., como se muestra en la segunda
columna de la Figura 13.6. En este momento se puede rebobinar la cinta 2 y
convertirla en una cinta de entrada, y rebobinar la cinta 1 y convertirla en la
cinta de salida. Continuando de esta forma, se llega a tener el archivo total-
mente ordenado en la cinta 1. La fusión se corta en muchasfuses que no im-
plican a todos los datos, pero que no implican ninguna copia directa.
Cinta 1 61 O 3 1 1 5 7 3 1 O 1
Cinta 2 O 6 1 3 0 1 4 6 2 O 1 O
Cinta 3 120 59 28 12 4 O 2 1 O
Cinta 4 116 55 24 8 O 4 2 1 O
Cinta 5 108 47 16 O 8 4 2 1 O
Cinta 6 9 2 3 1 O 1 6 8 4 2 1 O
Figura 13.6 Distribución de secuencias para una fusión polifacica de seis cintas.
206 ALGORITMOS EN C++
La principal dificultad en la implementación de una fusión polifásica es la
de determinar cómo distribuir los datos iniciales. No es difícil ver cómo cons-
truir la tabla a la inversa: se toma el mayor número de cada columna, se con-
vierte a cero, y se añade a cada uno de los otros números para obtener la co-
lumna anterior. Esto conduce a definir la fusión de mayor orden, para la
columna anterior, que podría generar la columna actual. Esta técnica funciona
para un número cualquiera de cintas (al menos tres): los números que aparecen
son ((númerosde Fibonnaci generalizados»que poseen muchas propiedades in-
teresantes. Por supuesto, el número de secuenciasinicialespuede no conocerse
por adelantado, y es probable que no sea exactamente un número generalizado
de Fibonnaci. Por tanto se puede añadir un cierto número de secuencias((ficti-
cias» para hacer que el número de secuencias iniciales sea exactamente el que
se necesita para la tabla.
El análisisde la fusión polifásica es complicado e interesante, y proporciona
resultados sorprendentes. Por ejemplo, revela que el mejor método para distri-
buir las secuencias ficticias entre las cintas implica utilizar más fases y más se-
cuencias de lo que parecería necesario. La razón de esto es que algunas secuen-
cias se utilizan en la fusiones con más frecuencia que otras.
Para implementar un método más eficaz de ordenación en cinta se deben
considerar otros muchos factores. Uno de los más importantes, que no se ha
considerado en el capítulo, es el tiempo que se tarda en rebobinar la cinta. Este
punto ha sido objeto de estudios detallados y se han definido muchos métodos
fascinantes. Sin embargo, como se mencionó con anterioridad, las gananciasque
se obtienen con respecto al método de la fusión múltiple equilibrada son bas-
tante limitadas. Incluso la fusión polifásica sólo es más eficaz que la fusión
equilibrada para un P pequeño, y no sustancialmente. Para P > 8, la fusión
equilibrada posiblemente se ejecute con más rapidez que la polifásica,y para un
P más pequeño el efecto de la polifásica es prácticamente el reducir en dos el
número de cintas (una fusión equilibrada con dos cintas extra se ejecutaría más
rápidamente).
Un método más fácil
Muchos sistemas de computadoras modernos incluyen dispositivos de me-
moria virtual de gran capacidad que no deben pasarse por alto al implementar
un método para ordenar archivos muy grandes. En un buen sistema de me-
moria virtual, el programador puede acceder a cantidades muy grandes de da-
tos, dejando al sistema la responsabilidad de transferir los datos desde el so-
porte de almacenamiento externo al interno, cuando sea necesario. Esta
estrategia descansa en el hecho de que muchos programas presentan una lo-
calización relativamente pequeña de sus referencias: cada referencia a la me-
moria está en un área relativamente próxima a otra referenciada reciente-
ORDENACIÓN EXTERNA 207
mente. Esto implica que las transferencias desde la memoria externa a la
interna son raramente frecuentes. Un método de ordenación interna con una
pequeña localización de las referencias puede ser muy eficaz en un sistema de
memoria virtual. (Por ejemplo, el Quicksort tiene dos «localizaciones»: la ma-
yoría de las referencias están cerca de uno de los dos punteros de partición.)
Pero es mejor recabar información de un programador de sistemas antes que
estar esperando ganancias significativas:un método como el de ordenación por
residuos, que no tiene ninguna localización de referencias, e incluso el Quick-
sort, podría provocar serios desastres en un sistema de memoria virtual, de-
pendiendo de la forma de implementar el sistema de memoria virtual dispo-
nible. Por el contrario, la estrategia de utilizar un método simple de ordenación
interna para ordenar archivos en disco merece una seria reflexión cuando se
dispone de un buen sistema de memoria virtual.
Ejercicios
1.
2.
3.
4.
5.
6.
7.
8.
9.
Describir cómo efectuaría el lector una selección externa: encontrar el K-
ésimo elemento más grande de un archivo de N elementos, donde N es
demasiado grande para que el archivo se pueda tener en la memoria in-
terna.
Implementar el algoritmo de selección por sustitución y utilizarlo después
para verificar la afirmación de que las secuenciasgeneradas son aproxima-
damente del doble del tamaño de la memoria interna.
¿Qué es lo peor que puede pasar cuando se utiliza la selección por sustitu-
ción para generar las secuenciasiniciales en un archivo de N registros,uti-
lizando una cola de prioridad de tamaño M, con M < N?
¿Cómo se ordenaría el contenido de un disco si no existe otro medio de al-
macenamiento disponible que el de la memoria principal?
¿Cómo se ordenaría el contenido de un disco si sólo hay disponible una
sola cinta (y la memoria principal)?
Comparar la fusión múltiple equilibrada de cuatro y seis cintas con la fu-
sión polifásica con el mismo número de cintas y 3 1 secuencias iniciales.
¿Cuántas fases utiliza la fusión polifásica de cinco cintas cuando co-
mienza con cuatro cintas que contienen inicialmente 26, 15, 22 y 28 se-
cuencias?
Suponiendo que las 3 1 secuenciasinicialesde una fusión polifásica de cua-
tro cintas tienen cada una la longitud de un registro (con la distribución
inicial O, 13, 11, 7), jcuántos registroshay en cada uno de los archivos im-
plicados en la última fusión de tres vías?
jCómo se deberían tratar los archivos pequeños en una implementación
del Quicksort destinada a aplicarse en archivos muy grandes en un entorno
de memoria virtual?
208 ALGORITMOS EN C++
10. ¿Cómo se organizaría una cola de prioridad externa? (Concretamente, di-
señar una forma de soportar las operaciones insertar y suprimir del Capí-
tulo 11, cuando el número de elementos de la cola de prioridad podría cre-
cer de modo tal que fuera demasiado grande para mantenerla en la
memoria principal.)
ORDENACIÓN EXTERNA 209
REFERENCIAS para la Ordenación
La referencia principal para esta sección es el Volumen 3 de la obra de D. E.
Knuth, sobre ordenación y búsqueda. En este libro se puede encontrar infor-
mación adicional sobre prácticamente todos los temas presentados con anterio-
ridad. En particular, los resultados presentados aquí sobre las caractensticas del
rendimiento de los diferentes algoritmos están respaldados por análisis mate-
máticos completos.
Existe una vasta literatura sobre ordenación. La bibliografía de Knuth y Ri-
vest de 1973contiene cientos de citas, pero no incluye el tratamiento de la or-
denación que figura en innumerables libros y artículos sobre otros temas. El li-
bro de Gonnet es una referencia más actualizada, que contiene una extensa
bibliografía que cubre los trabajos hasta 1984.
Para el Quicksort, la mejor referencia es el artículo original de Hoare de
1962,que describelas variantes más importantes, incluyendo la utilización para
el problema de la selección presentado en el Capítulo 9. Muchos más detalles
sobre el análisis matemático y los efectos prácticos de diversas modificacionesy
mejoras propuestas a lo largo de los años se pueden encontrar en el libro publi-
cado en 1978por el autor de esta obra.
Un buen ejemplo de una estructura avanzada de cola de prioridad es la
cola binomial)) de J. Vuillemin, implementada y analizada por M. R. Brown.
Esta estructura de datos permite todas las operaciones de cola de prioridad de
una forma elegante y eficaz. El tipo de estructura de datos más avanzado para
implementaciones prácticas es el «montículo pareado», descrito por Fredman,
Sedgewick, Sleator y Tarjan.
Para tener una impresión sobre la infinidad de detalies relativos a la trans-
posición de algoritmos como los que se han presentado en implementaciones
prácticas de uso general, se sugiere al lector que estudie los manuales de referen-
cia de los sistemasde ordenación de su computadora. Estos manuales hacen ne-
cesariamente una revisión de los formatos de las claves, registros y archivos, así
como de otros detalles, y a menudo es interesante constatar cómo entran en
juego los propios algoritmos.
M. R. Brown, «Implementation and analysis of binomial queue algorithms»,
M. L. Fredman, R. Sedgewick,D. D. Sleatory R. E. Tarjan, «The pairing heap:
G. H. Gonnet, Handbook o
f Algorithms and Data Structures, Addison-Wesley,
C. A. R.Hoare, ((Quicksorb, Computer Journal, 5, I (1962).
D. E. Knuth, TheArt o
f Computer Programming, Volume3: Sorting and Sear-
R. L. Rivest y D. E. Knuth, «Bibliography 26: Computing Sorting), Computing
R.Sedgewick, Quicksort, Garland, New York, 1978. (Aparece también como
SIAM Journal of Computing, 7, 3 (agosto, 1978).
a new form of self-adjustingheap)),Algorithmica, 1, I (1986).
Reading, MA, 1984.
ching, segunda impresión, Addison-Wesley, Reading, MA, 1975.
Reviews, 13, 6 Cjunio, 1972).
tesis de Ph.D., Universidad de Stanford, 1975.)
Algoritmos en C++.pdf
Algoritmos
de búsqueda
Algoritmos en C++.pdf
14
Métodos de búsqueda
elementales
La búsqueda es una operación fundamental, intrínseca a una gran cantidad de
tareas de las computadoras, que consiste en recuperar uno o varios elementos
particulares de un gran volumen de información previamente almacenada.
Normalmente se considera que la información está dividida en registros, cada
uno de los cuales posee una clave para utilizar en la búsqueda. El objetivo de
esta operación es encontrar todos los registros cuyas claves coincidan con una
cierta clave de búsqueda, con el propósito de acceder a la información (y no so-
lamente a la clave) para su procesamiento.
Las aplicaciones de la búsqueda están muy difundidas y abarcan una va-
riada gama de operaciones diferentes. Por ejemplo, un banco necesita hacer un
seguimiento de las cuentas de todos sus clientes y buscar en ellas para verificar
diversostipos de transacciones. De igual forma un sistema de reservas de unas
líneas aéreas tiene necesidadessimilares,aunque la mayor parte de los datos sean
de vida corta.
Dos términos comunes que se utilizan a menudo para describir las estruc-
turas de datos relativas a las búsquedas son los diccionarios y las tablas de sírn-
bolos. Por ejemplo, en un diccionario de inglés las «claves» son las palabras y
los «registros» las entradas asociadas con ellas, que contienen la definición, la
pronunciación y otras informaciones. Se puede aprender UJ método de bús-
queda y apreciar su acción pensando cómo se implementaría un sistema para
buscar en un diccionario de inglés. Una tabla de símbolos es el diccionario de
un programa: las «claves» son los nombres simbólicosutilizados en el programa
y los «registros» contienen la información que describe al objeto designado.
En la búsqueda (como en la ordenación) existen programas que están muy
difundidos y se utilizan frecuentemente, de modo que merece la pena estudiar
con cierto detalle un cierto número de métodos. Al igual que en la ordenación,
se comenzará por estudiar algunos métodos elementales, que son muy útiles en
213
214 ALGORITMOS EN C++
pequeñas tablas y en otras situacionesespeciales,y después se mostrarán las téc-
nicas fundamentales a explotar por los métodos más avanzados. Se verán mé-
todos que almacenan los registros en arrays, en los que se busca por compara-
ción entre claves o que están indexadospor el valor de la clave, y posteriormente
se verá un método fundamental que construye estructuras definidas por los va-
lores de las claves.
Al igual que en las colas de prioridad, es preferible considerar que los algo-
ritmos de búsqueda pertenecen a conjuntos de rutinas empaquetadas que rea-
lizan una serie de operaciones genéricas y que se pueden disociar de las imple-
mentaciones particulares, de tal forma que permiten pasar fácilmente de una
implementación a otra. Entre las operaciones que interesan se cuentan:
Inicializar la estructura de datos.
Buscar un registro (o varios) con una clave dada.
Insertar un nuevo registro.
Eliminar un registro específico.
Unir dos diccionarios en uno solo (de mayor tamaño).
Ordenar el diccionario; dar como salida todos los registros ordenados.
Al igual que en las colas de prioridad, a veces es conveniente combinar al-
gunas de estas operaciones. Por ejemplo, la operación buscar e insertar se in-
cluye a menudo, por razones de eficacia, en situaciones en las que la estructura
de datos no debe contener registros con clavesiguales. En muchos métodos, una
vez que se ha determinado que una clave no pertenece a la estructura de datos,
el propio estado interno del procedimiento de búsqueda contiene la informa-
ción necesaria para insertar un nuevo registro con la clave dada.
Los registros con claves iguales se pueden tratar de varias formas, según la
aplicación. Primero, se puede insistir para que la estructura de datos primaria
contenga sólo registros con claves distintas. Entonces cada ((registro))de esta es-
tructura de datos puede contener, por ejemplo, una lista enlazada de todos los
registros que tienen la misma clave. Esto es conveniente en algunas aplicacio-
nes, puesto que todos los registros con la misma clave se obtendrán en una sola
búsqueda. Una segundaposibilidad es colocar a todos los registros con la misma
clave en la estructura de datos primaria y devolver,en una búsqueda, cualquier
registro que contenga la clave. Esto es más simple en aplicacionesque procesan
registro a registro, donde no es importante el orden en el que se procesan los
registros que tienen claves iguales. Esta solución no es satisfactoria para el di-
seño de un algoritmo porque se debe proporcionar un mecanismo para recu-
perar otro registro o todos los registros con la misma clave. Una tercera posibi-
lidad consiste en suponer que cada registro tiene un identificador único (aparte
de la clave) y entonces la búsqueda ha de encontrar el registro que tiene el iden-
tificador dado, conociendo la clave. Una cuarta posibilidad es que el programa
de búsqueda llame a una función específicapara cada registro que tenga la clave
dada. También podrían ser necesarios otros mecanismosmás complejos. En este
libro, al describir los algoritmos de búsqueda, se menciona informalmente cómo
MÉTODOS DE BÚSQUEDA ELEMENTALES 215
se pueden encontrar registros con claves iguales, sin precisar qué mecanismo hay
que utilizar. Los ejemplos del capítulo contendrán normalmente clavesiguales.
Cada una de las operaciones fundamentales antes enunciadas tiene aplica-
ciones importantes y se han sugerido un gran número de organizacionesbásicas
que permiten el uso eficaz de diversas combinaciones de ellas. En éste y en los
próximos capítulos, se centrará la atención en las implementaciones de las fun-
ciones fundamentales de buscar e insertar (y por supuesto inicializar),con al-
gunos comentarios sobre eliminar y ordenar cuando sea conveniente. Al igual
que en las colas de prioridad, la operación unión necesita normalmente técnicas
que se salen del marco de este tratamiento.
Búsqueda secuencia1
El método de búsqueda más simple consisteen almacenar todos los registros en
un array. Cuando se inserta un nuevo registro, se pone al final del array; cuando
se lleva a cabo una búsqueda, se recorre secuencialmente el array. El siguiente
programa muestra una implementación de las funciones básicas que utiliza esta
sencilla organización e ilustra a su vez algunos de los convenios que se utiliza-
rán en la implementación de los métodos de búsqueda.
class Dicc
private:
s tr u c t nodo
s t r u c t nodo *a;
i n t N;
Dicc ( i n t max)
{ tipoElemento clave; t i p o I n f o i n f o ; };
pub1ic :
{ a = new nodo[max] ; N = O; }
{ delete a; }
- D i cc()
tipoInfo buscar(t ipoElemento v) ;
void insertar(tipoE1emento v, t i p o I n f o i n f o ) ;
t i p o I n f o Dicc: :buscar(tipoElemento v) / / Secuencia1
1;
{
i n t x = N+1;
a[O].clave = v; a[O].info = infoNIL;
while (v != a[--x].clave) ;
r e t u r n a[x] . i n f o ;
216 ALGORITMOS EN C++
1
void Dicc: :insertar(tipoElemento v, tipoInfo info)
{ a[++N].clave = v; a[N].info = info; }
Ésta es una implementación de un tipo de datos de diccionario donde las claves
(cl ave)se utilizan para almacenar y recuperar la «informaciónasociada) (i
nfo).
Al igual que en la ordenación, a veces será necesario ampliar los programas para
manipular registros y claves más complicadas, pero esto no implica cambios
fundamentales en los algoritmos. Por ejemplo, si tipoElemento fuera char* y
se sobrecargara al operador != para hacer strcmp convertiría al programa an-
terior en un paquete que utilizaría como claves a cadenas de caracteresen lugar
de enteros. O info podría ser un puntero a una estructura de registro más com-
pleja. Así, este campo puede servir como identificador único del registro para
distinguir entre registros con claves iguales.
Aquí, buscar devuelve el campo info del primer registro encontrado que
tenga la clave en cuestión (i
nfoNIL si no existe tal registro).
Se utiliza un registro centinela en el que su campo cl ave se inicializa con el
valor a buscar para garantizar que la búsqueda siempre terminará y por lo tanto
el bucle interno se podrá escribir con solamente una comprobación de termi-
nación. Al campo info de este registro centinela se le asigna el valor infoN IL
de manera que sea éste el valor devuelto cuando ningún registro tenga la clave
dada. Esta técnica es análoga a la del registro centinela que contiene el valor
máximo o mínimo de una clave, que se utiliza para simplificar la escritura de
vanos algoritmos de ordenación.
Propiedad 14.1 La búsqueda secuencial (implernentaciónpor array) utiliza
(siempre) N + 1 comparacionespara una búsqueda sin éxito y alrededorde N/2
comparaciones (por término medio)para una búsqueda con éxito.
Para una búsqueda sin éxito, esta propiedad se deduce directamente del pro-
grama: se debe examinar cada registro para decidir si una clave en particular
está ausente. Para una búsqueda con éxito, si se supone que todos los registros
tienen la misma probabilidad de ser el buscado, el número medio de compara-
ciones es (1 + 2 + ... + N)/N = (N+ 1)/2, exactamente la mitad del coste de una
búsqueda infructuosa.i
Es obvio que la búsqueda secuencialse puede adaptar de manera natural para
utilizar una representación de los registros mediante una lista enlazada:
class Dicc
private:
struct nodo
{
{ tipoElemento clave; tipoInfo info;
struct nodo *siguiente;
MÉTODOSDE BÚSQUEDA ELEMENTALES 217
nodo(tipoE1emento k, tipoInfo i, struct nodo *n)
{ clave = k; info = i; siguiente = n; };
j ;
struct nodo *cabeza, *z;
Dicc(i nt max)
public:
z = new nodo(elementoMAX, infoNIL, O);
cabeza = new nodo(0, O, z);
{
1
-Dice() ;
tipoInfo buscar(tipoE1emento v) ;
void insertar(tipoE1emento v, tipoInfo info);
Como es habitual en las listas enlazadas, un nodo cabecera ficticio cabeza y un
nodo cola z permiten simplificar el código. Se ha pasado al estilo de utilizar un
constructor para hacer más conveniente la operación de llenar los campos de
los nodos a la vez que se van creando. La búsqueda implica un trabajo más
creativo que el desarrollado en los primeros capítulos.
Una razón para utilizar una lista enlazada es que es fácil mantener la lista
ordenada (severá posteriormente).Esto hace la búsqueda más eficaz: puesto que
la lista está ordenada, cada búsqueda puede terminar cuando se encuentre un
registro con una clave no menor que la clave de búsqueda.
tipoInfo D
struct
while
return
{
1
cc::buscar(tipoElemento v) //Lista ordenada
nodo *t = cabeza;
v > t->clave) t = t->siguiente;
(v = t->clave) ? t->info : z->info;
Es fácil mantener el orden insertando cada nuevo registro en el lugar donde ter-
mina la búsqueda sin éxito:
void Dicc::insertar(tipoElemento v, tipoInfo info)
struct nodo *x, ft = cabeza;
while (v > t->siguiente->clave) t = t->siguiente;
x = new nodo(v, info, t->siguiente);
t->siguiente = x;
{
}
218 ALGORITMOS EN C++
Este programa es una implementación alternativa del mismo tipo de datos abs-
tracto de la implementación por array anterior. Las dos versiones permiten la
inserción, la búsqueda y la inicialización. Se continuará la programación de al-
gontmos de búsqueda de esta manera, añadiendo otras funciones cuando sea
apropiado. Por otra parte, las implementaciones podrán utilizarse de forma
equivalente en las aplicaciones,diferenciándosesolamente (es de esperar)en las
necesidades de tiempo y espacio. Por ejemplo, sería trivial añadir una función
ordenar a esta implementación por lista enlazada, pero para añadir ordenar a
la implementación por array anterior habría que reprogramar alguno de los mé-
todos de los capítulos 8 al 12.
Propiedad 14.2 Una búsqueda secuencia1(en una implementaciónpor lista or-
denada) utiliza alrededor de N/2 comparaciones (por término medio) para las
dos búsquedas (con éxito o sin éo.
Para la búsqueda con éxito, la situación es la misma que antes. Para la bús-
queda sin éxito, si se supone que existe la misma probabilidad de que la bús-
queda acabe en el nodo terminal z o en cualquiera de los elementos de la lista
(que es el caso de un cierto número de modelos de búsqueda «aleatona)), en-
tonces el número medio de comparaciones es el mismo que el de una búsqueda
con éxito en una tabla de tamaño N + I, o sea (N+ 2)/2.0
Se podría también desarrollar fácilmente una implementación por dista des-
ordenada) para la búsqueda secuencial,con característicassimilaresa la de la im-
plementación por array. Por ejemplo, si las búsquedas son relativamente poco
frecuentes, entonces el tiempo constante de la inserción puede ser una ventaja.
Si se conoce algo sobre la frecuencia relativa de acceso de diferentes regis-
tros, se pueden lograr mejoras sustancialessimplemente ordenando los registros
inteligentemente. La ubicación «óptima» consisteen poner el registro de acceso
más frecuente en el comienzo, el segundo de acceso más frecuente en la se-
gunda posición, etc. Esta técnica puede ser muy eficaz, en especial si sólo se
consulta frecuentemente un pequeño conjunto de registros.
Si no hay información disponible sobre la frecuencia de acceso, entonces se
puede lograr una aproximación a la ubicación óptima con una búsqueda «au-
toorganizada)):cada vez que se acceda a un registro se le coloca al principio de
la lista. Este método es más conveniente de implementar cuando se utiliza una
lista enlazada. Por supuesto el tiempo de ejecución depende de las distribucio-
nes de acceso a los registros; así pues, es dificil predecir el comportamiento ge-
neral del método. Pero esto es eficaz en la situación usual en la que se accede
de manera repetitiva a muchos registros que están próximos entre sí.
Búsqueda binaria
Si el conjunto de registros es grande, entonces el tiempo total de búsqueda se
puede reducir significativamenteutilizando un procedimiento de búsqueda ba-
MÉTODOSDE BÚSQUEDA ELEMENTALES 219
sado en la aplicación del paradigma de «divide y vencerás»: se divide el con-
junto de registros en dos partes, se determina a cuál de las dos partes debe per-
tenecer la clave buscada, y a continuación se repite el proceso en esa parte. Una
forma razonable de dividir en partes el conjunto de registros consiste en man-
tener los registros ordenados y después utilizar los índices del array ordenado
para delimitar la parte del array sobre la que se va a trabajar:
t i p o I n f o Dicc: :buscar(tipoElemento v) //Búsqueda b i n a r i a
i n t i z q = 1; i n t der = N; i n t x;
while (der >= izq)
{
x = ( i z q + der)/2;
i f (v == a[x] .clave) r e t u r n a[x] .info;
i f (v < a[x]clave) der = x-izq; else i z q = x+izq;
{
1;
r e t u r n infoNIL;
1
Para averiguar si una clave dada v está en la tabla, primero se le compara con
el elemento de la posición intermedia de la tabla. Si v es menor, entonces debe
estar en la primera mitad de la tabla; si v es mayor, entonces debe estar en la
segunda mitad de la tabla. A continuación se aplica esta técnica recursiva-
mente. Puesto que sólo interviene una llamada recursiva, es más simple expre-
sar el método iterativamente.
Al igual que en el Quicksort y la ordenación por intercambio de residuos,
este método utiliza los punteros izq y der para delimitar el subarchivo sobre el
que se está trabajando. Si este subarchivo llega a estar vacío, entonces la bús-
queda resultará infructuosa. En otro caso la variable x se fija con el valor del
punto medio del intervalo, existiendo tres posibilidades: o se encuentra un re-
gistro con la clave dada, o bien el puntero izquierdo se cambia a x+i zq, o bien
el puntero derecho se cambia a x - izq, según que el valor v buscado sea igual,
menor o mayor que el valor de la clave del registro almacenado en a[XI.
La Figura 14.1 muestra los subarchivos examinadospor este método cuando
se busca O en una tabla construida insertando las claves E J E M P L O D E E
U S Q U E D A. El tamaño del intervalo se reduce a la mitad en cada paso, de
tal modo que sólo se utilizan cuatro comparaciones en la búsqueda. La Figura
14.2 muestra un ejemplo mayor, con 95 registros; aquí a lo sumo se requieren
siete comparaciones para cualquier búsqueda.
Propiedad 14.3 La búsqueda binaria nunca utiliza más de lgN+1 comparacio-
nes para cada búsqueda (con éxito o sin ér).
220 ALGORITMOS EN C++
Figura 14.1 Búsqueda binaria.
Esto se deduce del hecho de que el tamaño del subarchivo se reduce al menos a
la mitad en cada paso: una cota superior del número de comparaciones satisface
la recurrencia C, = C,,, + 1 con C, = 1, lo que implica el resultado indicado
(fórmula 2 del Capítulo 6).=
Es importante notar que en el caso de búsqueda binaria el tiempo que se
necesita para insertar nuevos registros es elevado: el array debe mantenerse or-
denado, de modo que algunos registros deberán moverse para dejar sitio a uno
nuevo. Si un nuevo registro tiene una clave inferior a la de cualquier registro de
la tabla, entonces cada uno de ellos debe moverse una posición. Una inserción
aleatona requiere que se muevan N/2 registros por término medio. Por ello este
método no debe utilizarse en aplicaciones que impliquen muchas inserciones.
Este método constituye la mejor elección en situaciones en las que la tabla se
puede «construin>de una vez desde el principio, quizás por medio de un mé-
todo de ordenación como el de Shell o el Quicksort, y utilizarse después para
an gran número de búsquedas (muy eficaces).
La búsqueda con éxito para el info asociado con una clave v, presente múl-
tiples veces, terminará en algún lugar dentro de un bloque contiguo de registros
que tienen la clave v. Si la aplicación necesita acceder a todos estos registros, se
pueden encontrar recorriendo ambas direcciones a partir del punto en el que se
terminó la búsqueda. Una técnica similar se puede utilizar para resolver el pro-
blema más general de encontrar todos los registros cuyas claves están dentro de
un determinado intervalo.
La secuencia de comparaciones realizadas por el algoritmo de búsqueda bí-
nana es predeterminada: la secuencia específica depende del valor de la clave
que se está buscando y del valor de N. La estructura de comparación se puede
describir de forma sencilla mediante una estructura de árbol binario. La Figura
14.3 muestra 1%estructura de las comparaciones para el ejemplo anterior del
conjunto de claves. Por ejemplo, al buscar un registro con la clave O, primero
METODOS DE BÚSQUEDA ELEMENTALES 221
IIIIIuIIIII
Figura 14.2 Búsquedabinaria en un archivo muy grande.
Figura 14.3 Árbol de comparacionespara la búsquedabinaria.
222 ALGORITMOS EN C++
Figura 14.4 Búsqueda por interpolación.
se compara con J. Puesto que O es mayor, a continuación se compara con P (en
el caso contrario se habría comparado con D), luego se compara con M y el al-
gontmo termina, con éxito, en la cuarta comparación. Más adelante se verán
algoritmos que utilizan un árbol binario construido explícitamente para guiar
la búsqueda.
Una posible mejora de la búsqueda binaria consiste en tratar de acertar en
qué parte del intervalo está la clave que se está buscando (mejor que utilizar
ciegamente la mitad del intervalo en cada paso). Esta técnica imita la forma
como se busca un número en una guía telefónica. Por ejemplo: si el nombre
comienza con B se mira cerca del principio, pero si comienza con Y , se mira
cerca del final. Este método, denominado búsqueda por interpolación, re-
quiere sólo una simple modificación del programa precedente. En él, el nuevo
punto de partida de Ia búsqueda (el punto medio del intervalo) se calcula por
medio de la sentencia x = ( izq + der)/2,que se deduce de la expresión
1
2
x = izq +-(der - izq).
El punto medio del intervalose calcula añadiendo la mitad de su tamaño al punto
izquierdodel mismo. En la búsqueda por interpolación simplemente se sustituye
la fracción 1/2 de esta fórmula por una estimación de donde puede encontrarse
la clave, sobre la base de los valores disponibles: el 1/2 sería apropiado si v estu-
viera en la mitad del intervalo entre a [izq] .cl ave y a [der] .cl ave, pero
x=izq+ (v-aiizq] .clave)* (der-izq)/(a[der] . clave-a[izq] . clave)
podría ser una estimación mejor (si las claves son numéricas y están uniforme-
mente distribuidas).
Suponiendo en el ejemplo que la i-ésimaletra del alfabeto se representa por
el número i.Entonces en la búsqueda de O, la primera posición a examinar en
la tabla sena la 12,puesto que 1 + (15 - 1)*(17 - 1)/(21 - 1) = 12,2 ... La bús-
queda se completa en un solo paso (12 es la posición de O en la clave). Incluso
tornando para la primera posición a examinar el valor encontrado por exceso
(13: que corresponde a P), la búsqueda se completaría en dos pasos. El primer
y el último elementos se localizan en el primer paso. La Figura 14.5 muestra la
búsqueda por interpolación en el archivo de 95 elementos de la Figura 14.2;di-
cha búsqueda utiliza solamente cuatro comparaciones, cuando la binaria nece-
sita siete.
MÉTODOSDE EÚSQUEDA ELEMENTALES 223
I
Figura 14.5 Búsqueda por interpolaciónen un gran archivo.
La búsqueda por interpolación utiliza menos de 1glgN+ 1 comparaciones, lo
mismo para una búsqueda con éxito que para una infructuosa, en archivos con
clavesaleatorias.
La demostración de este hecho rebasa el alcance de este libro. Esta función tiene
un crecimiento muy lento que se puede considerar como una constante para
propósitos prácticos: si iV es mil millones, entonces lglgN < 5. De este modo se
puede encontrar cualquier registro utilizando sólo unos pocos accesos (por tér-
mino medio), lo que representa una mejora sustancial con relación a la bús-
queda binaria.i
Sin embargo, la búsqueda por interpolación depende fuertemente de la su-
posición de que las claves están bien distribuidas en el intervalo, por lo que a
esta técnica la puede «enganan>una distribución poco uniforme de las claves,
lo que es frecuente en la práctica. Además este método requiere algunos cálcu-
los: para un N pequefio, el coste de IgN de la búsqueda binaria directa es bas-
tante próximo a lglgN, por lo que no merece la pena pagar tanto por la inter-
polación. Por el contrario, la búsqueda por interpolación debe tenerse en cuenta
para el caso de archivos grandes, en aplicaciones donde las comparaciones son
muy costosas o en métodos externos que implican costes muy a1tos.i
Búsqueda por árbol binario
La búsqueda por árbol binario es un método simple y eficaz de búsqueda di-
námica, que está calificadocomo uno de los aigoritmos fundamentales de la in-
224 ALGORITMOS EN C++
Figura 14.6 Un árbol binario de búsqueda.
formática. Se presenta entre los métodos «elementales» por lo simple que es;
pero de hecho es el método elegido en muchas situaciones.
En el Capítulo 4 se estudiaron con detalle los árboles, y de él se recuerda
ahora la terminología: la propiedad característica de un árbol es que sobre cada
nodo apunta otro único nodo denominado su padre, y la propiedad caracterís-
tica de un árbol binario es que cada nodo tiene dos enlaces (apunta a dos no-
dos), uno izquierdo y otro derecho. Para su empleo en la búsqueda, cada nodo
tiene también un registro con un valor clave. En un árbol binario de búsqueda
se impone que todos los registros con las claves más pequeñas están en el sub-
árbol izquierdo y que todos los registros del subárbol derecho tienen valores de
clave mayores o iguales. Pronto se verá que es muy fácil garantizar que los ár-
boles binarios de búsqueda construidos por inserciones sucesivasde nuevos no-
dos satisfagantambién esta propiedad de definición. En la Figura 14.6 se mues-
tra un ejemplo de árbol binano de búsqueda; como es ya usual, los subárboles
vacíos se representan por pequeños nodos cuadrados.
De esta estructura se desprende inmediatamente un procedimiento de bús-
queda binaria. Para encontrar un registro con una clave dada v, primero se
compara ésta con la correspondiente a la raíz. Si es más pequeña, se va al sub-
árbol de la izquierda; si es igual, se detiene la búsqueda; si es mayor, se va al
subárbol de la derecha. Se aplica este método recursivamente. En cada paso, se
tiene la garantía de que ninguna otra parte del árbol que no sea la del subárbol
en el que se está situado puede contener registros con la clave v, y, al igual que
disminuye el tamaño del intervalo en la búsqueda binana, el ((subárbolactual»
es cada vez más pequeño. El procedimiento termina cuando se encuentra un
registro con clave v o, si no hay tal registro, cuando el «subárbol actual» llega a
estar vacío. Llegados a este punto hay que admitir que las palabras «binaria»,
«búsqueda» y «árbol» están sobreutilizadas, y el lector debe estar seguro de
comprender la diferencia entre la función de búsqueda binaria presentada an-
teriormente en este capítulo y los árboles binarios de búsqueda descritos aquí.
En una búsqueda binaria, se utiliza un árbol binano para describir la secuencia
de comparaciones llevada a cabo por una fuiición que busca en un array; aquí
realmente se construye una estructura de datos en forma de árbol, con registros
conectados por enlaces, y se utiliza para la búsqueda.
MÉTODOS DE BÚSQUEDA ELEMENTALES 225
class Dicc
private:
{
struct nodo
{ tipoElemento clave; tipoInfo info:
struct nodo *izq, *der;
nodo(tipoE1emento k, tipoInfo i ,
struct nodo *izqizq, struct nodo *derder)
{ clave=k; info=i; izq= izqizq; der=derder; };
1;
struct nodo *cabeza, *z;
public:
Dicc(int max)
{z = new nodo(@, infoNIL, O, O);
cabeza = new nodo(elementoMIN, O, O, z); }
Dicc() ;
tipoInfo buscar(tipoE1emento v);
void insertar(tipoE1emento v, tipoInfo info);
tipoInfo Dicc::buscar(tipoElemento v)
struct nodo *X = cabeza->der;
z->clave = v;
while (v != x->clave)
return x->info;
{
x = (v < x->clave) ? x->izq : x->der;
Es conveniente utilizar un nodo cabeza que sea la cabecera del árbol cuyo en-
lace derecho apunte al nodo raíz real del árbol y cuya clave sea inferior a todas
las otras. El enlace izquierdo de cabeza no se utiliza. La utilidad de cabeza se
verá más clara posteriormente, cuando se presente la inserción. Si un nodo no
tiene subárbolizquierdo(derecho)entonces su enlaceizquierdo(derecho)se pone
a apuntar al nodo «final» z.Al igual que en la búsqueda secuencial, se coloca
en zel valor que se busca, para detener las búsquedas infructuosas. Así, el «sub-
árbol actual» sobre el que apunta x nunca estará vacío y todas las búsquedas
tendrán «éxito»: la inicialización de z->info a infoNI L servirápara indicar, al
devolver este indicador, que una búsqueda no ha tenido éxito de acuerdo con
el convenio que se ha venido utilizando. Los programas de este capítulo nunca
acceden a los enlacesde z,pero para los programas más avanzados que se verán
más adelante es conveniente inicializar los enlaces de z para que apunten al
propio z.
226 ALGORITMOS EN C++
cabeza
Figura 14.7 Un árbol binario de búsqueda(con nodos ficticios).
Como se mostró anteriormente, en la Figura 14.6,es conveniente represen-
tar los enlaces que apuntan a zcomo si apuntaran a unos nodos externos ima-
ginarios, y que todas las búsquedas sin éxito terminan en nodos externos. Los
nodos normales que contienen las claves se denominan nodos internos. Al in-
troducir nodos externos se puede afirmar que todo nodo interno apunta a otros
dos nodos del árbol, aun cuando en esta implementación todos los nodos exter-
nos estén representados por el único nodo z. La Figura 14.7 muestra explícita-
mente estos enlacesy los nodos ficticios.
La Figura 14.8 muestra lo que sucede cuando se busca D en el árbol ejem-
plo, utilizando buscar. Primero, se compara con E, la clave de la raíz. Puesto
que D es menor, se va hacia la izquierda y, como el enlace izquierdo del nodo
que contiene a E es un puntero a z, la bílsqueda termina: D se compara consigo
mismo en zy la búsqueda resulta infructuosa.
Figura 14.8 Búsqueda(de D) en un árbol Sinario de búsqueda
MÉTODOSDE BÚSQUEDA ELEMENTALES 227
Figura 14.9 Inserción(de D) en un árbol binario de búsqueda.
Para insertar un nodo en el árbol, se efectúa una búsqueda infructuosa de
su clave y a continuación se agrega el nuevo nodo en lugar de z en el punto
donde se terminó la búsqueda. Para hacer la inserción, el siguiente programa
sigue la pista del padre p de x a medida que se desciende por el árbol.
Cuando se alcanza el fondo del árbol (x == z),p apunta al nodo cuyo enlace
debe cambiarse para apuntar al nuevo nodo insertado.
void Dicc: :insertar(tipoElemento v , tipoInfo info)
i
struct nodo *p, *x;
p = cabeza; x = cabeza->der;
while ( x != z)
x = new nodo(v, info, z, z);
if (v < p->clave) p->izq = x; else p->der = x;
{ p = x; x = (v < x->clave) ? x->izq : x->der; >;
1
En esta implementación, cuando se inserta un nuevo nodo cuya clave es igual
a alguna de las que ya existen en el árbol, se insertará a la derecha del nodo que
ya estaba en el árbol. Esto significa que se pueden encontrar los nodos con cla-
ves iguales si simplemente se continúa la búsqueda a partir del punto en eI que
buscar terminó, hasta que se encuentre z.
El árbol de la Figura 14.9 se obtiene al insertar las claves E J E M P L O D
en un árbol que inicialmente está vacío. La Figura 14.10 muestra el proceso
completo del ejemplo cuando se añaden E B U S Q U E I
)A. El lector debe
prestar particular atención a la posición de las claves iguales de este árbol: por
ejemplo, aun cuando las E parecen muy alejadas en el árbol, no hay claves «en-
tre» ellas.
La función ordenar se obtiene prácticamente de forma gratuita cuando se
utiliza un árbol binario de búsqueda?puesto que esta estructura representa a un
archivo ordenado, si se mira en el sentido correcto. En las figuras: las claves
228 ALGORITMOS EN C++
Figure 14.10 Construcción de un árbol binario de búsqueda.
aparecen en orden si se leen de izquierda a derecha de la página (ignorando la
altura y los enlaces).Un programa tiene solamentelos enlacespara operar, pero
el método de ordenación se obtiene directamente de las propiedades que defi-
nen a los árboles binarios de búsqueda. Así se define un método de ordenación
que es notablemente parecido al Quicksort, con el nodo raíz del árbol desem-
MÉTODOC DE BÚSQUEDA ELEMENTALES 229
peñando un papel similar al del elemento de partición de la ordenación rápida
(no hay claves mayores a la izquierda, no hay claves menores a la derecha).
Concretamente, la tarea la llevará a cabo el recomdo recursivo en orden simé-
trico del Capítulo 5. En este caso se añadiría la operación de clase
D icc : :recorrer ( ) que simplemente llama a D icc ::enorden(cabeza->der)
donde enorden(struct nodo *x) es la rutina básica del Capítulo 5 (cambiada
de nombre) tal que si x no es z, se llama a sí misma con el argumento x->i zq,
luego llama a v i sitar (x),luego se llama a si misma con argumento x->der.
Así, por ejemplo, si se implementaüi cc ::v i s i tar (struct nodo *x) para im-
primir el campo clave de x, una llamada a recorrer imprimiría el árbol entero
de forma ordenada. O, como se verá en el Capítulo 27, un v i sitar más intrin-
cado puede llevar a un algoritmo más complicado.
Los tiempos de ejecución de los algoritmos de árbolesbinarios de búsqueda
dependen mucho de la forma de los árboles. En el mejor de los casos, el árbol
puede aparecer como el de la Figura 14.3,con aproximadamente1gNnodos en-
tre la raíz y cada nodo externo. Se puede aspirar a tiempos de búsqueda de or-
den logarítmico como promedio porque el primer elemento insertado se con-
vierte en la raíz del árbol; si se insertan aleatoriamente N claves, entonces este
elemento deberá dividir a las claves en dos partes iguales (de media), y esto pro-
duciría tiemposde búsqueda logarítmicos(utilizandoel mismo argumentopara
cada subárbol). En efecto, haciendo abstracción de las claves iguales, podría
ocumr que se produjera un árbol como el dado anteriormente para describir la
estructura de comparación de una búsqueda binaria. Éste sería el mejor caso
para el algoritmo, que garantizaría tiemposde ejecuciónlogarítmicospara todas
las búsquedas. De hecho, en una situación verdaderamente aleatoria, la raíz tiene
las mismas posibilidades de ser cualquier clave, así que un árbol perfectamente
equilibrado es muy raro. Pero si se insertan claves aleatorias se pueden obtener
árbolesbastante bien equilibrados.
Propiedad 14.5 Una búsqueda o inserción en un árbol binario de búsqueda re-
quiere alrededor de 21nN comparaciones,por término medio, en un árbol cons-
truido a partir de N claves aleatorias.
Para cada nodo del árbol, el número de comparaciones realizadaspara una bús-
queda con éxito de dicho nodo es su distancia a la raíz. La suma de estas dis-
tancias para todos los nodos se denomina la longitud del camino interno del ár-
bol. Dividiendo la longitud del camino interno por N se obtiene el número medio
de comparaciones de una búsqueda con éxito. Pero si CNrepresenta la longitud
media del camino interno de un árbol binario de búsqueda de N nodos, se tiene
la relación de recurrencia
con CI= 1. (El término N - 1 tiene en cuenta el hecho de que la raíz contribuye
230 ALGORITMOS EN C++
Figura 14.11 Un gran árbol binario de búsqueda.
con 1 a la longitud del camino para cada uno de los restantes N - 1 nodos del
árbol; el resto de la expresión proviene de observar que la clave de la raíz (la
primera insertada) es como si fuera la k-ésima más grande, dejando subárboles
aleatorios de tamaño k-1 y N-k.) Pero esto está muy próximo a la relación de
recurrencia que se resolvió en el Capítulo 9 para el Quicksort y se puede resol-
ver de la misma manera para obtener el resultado esperado. El razonamiento
para la búsqueda sin éxito es similar, aunque un tanto más comp1icado.i
La Figura 14.1I muestra un gran árbol binario de búsqueda construido a
partir de una permutación aleatoria de 95 elementos. Aun cuando tiene algunos
caminos cortos y algunos largos, se puede decir que está bastante bien equili-
brado: cualquier búsqueda necesitará menos de doce comparaciones, y el nú-
mero «medio» de ellas para encontrar cualquier clave del árbol es 7,00,contra
5,74 de la búsqueda binaria. (El número medio de comparaciones para una
búsqueda aleatoria sin éxito es uno más que para la búsqueda con él.)Más aún,
se puede insertar una nueva clave por el mismo coste, flexibilidadde la que no
se dispone en la búsqueda binaria. Sin embargo, si las claves no están aíeatoria-
mente ordenadas el algoritmo puede tener un mal comportamiento.
Propiedad 14.6 En el peor caso, una búsqueda en un árbol binario de bús-
queda con N claves puede necesitar N Comparaciones.
Por ejemplo, cuando las clavesse insertan en orden (o en orden inverso), el mé-
todo de búsqueda por árbol binario no es mejor que el de búsqueda secuencia1
descrito al principio de este capítulo. Más aún, hay muchos otros tipos de ár-
boles degenerados que pueden conducir al mismo peor caso (considéresepor
ejemplo el árbol formado cuando las claves A Z B Y C X ... se insertan en este
orden en un árbol inicialmente vacío). En el próximo capítulo se examinará una
técnica para eliminar este peor caso y hacer que todos los árboles se parezcan al
del caso mej0r.m
MÉTODOS DE BÚSQUEDA ELEMENTALES 231
Figura 14.12 Eliminado(de E) de un árbol binario de búsqueda.
Eliminación
Las implementaciones precedentes que utilizan estructuras de árboles binarios
para las funciones fundamentales buscar, insertar y ordenar son bastante direc-
tas. No obstante, los árboles binarios son un buen ejemplo de un tema recu-
rrente en los algontmos de búsqueda: la función eliminar, que a menudo es bas-
tante incómoda de implementar.
Considérese el árbol que se muestra a la izquierda de la Figura 14.12:eli-
minar un nodo es fácil si no tiene hijos, como L o P (se «podan» haciendo nulo
el correspondiente enlace con su padre); si tiene solamente un hijo, como A, H
o R (se desplaza el enlace del hijo al enlace apropiado del padre); o incluso si
uno de sus dos hijos no tiene hijos, como N (se utiliza el nodo hijo para reem-
plazar ai padre); pero ¿qué hacer con los nodos más altos del árbol, tales como
E?
La Figura 14.12muestra una forma de eliminar E: se reemplaza por el nodo
que tenga la clave superior más próxima (H en este caso). Este nodo forzosa-
mente tiene como mucho un hijo (puesto que no hay nodos entre él y el nodo
eliminado, su enlace izquierdo debe ser nulo), y se puede suprimir fácilmente.
Así, para quitar E del árbol de la izquierda de la Figura 14.12. se hace apuntar
el enlace izquierdo de R al enlace derecho (N) de H, se copian los enlaces del
nodo que contiene a E en el que contiene a H, y se hace apuntar cabeza-> der
a H. Esto proporciona el árbol de la derecha de la figura.
El programa que permite tratar todos estos casos es bastante más complejo
que las simplesrutinas de búsqueda e inserción, pero merece la pena estudiarlo
cuidadosamente para preparar las manipulaciones más complejas que se reali-
zarán en el próximo capítulo. El siguiente procedimiento elimina el primer nodo
de clave v que encuentre en el árbol. (Otra posibilidad es utilizar in f o para
identificar el nodo a eliminar.) La variable p se utiliza para seguir la pista del
padre de x en el árbol y la variable c se utiliza para encontrar al sucesor del
nodo que se va a eliminar. Después de la operación de eliminación, x es el hijo
de p.
232 ALGORITMOS EN C++
void Dicc: :suprimir(tipoElemento v)
struct nodo *c, *p, *x, *t;
z->clave = v;
p = cabeza; x = cabeza->der;
while (v != x->clave)
t = x;
if (t->der == z) x = x->izq;
else if (t->der->izq == z) { x=x->der; x->izq=t->izq;}
el se
{
{p = x; x = (v < x->clave) ? x->izq : x->der; }
c = x->der; while (c->izq->izq != z) c = c->izq;
x = c->izq; c->izq = x->der;
x->izq = t->izq; x->der = t->der;
{
{
delete t;
if (v < p->clave) p->izq = x; else p-der = x;
}
En primer lugar el programa hace una búsqueda en el árbol de forma normal
para encontrar el emplazamiento de t en el mismo. (Realmente, el objetivo
principal de esta búsqueda es enlazar p con otro nodo una vez que se haya eli-
minado t.) A continuación el programa verifica tres casos: si t no tiene hijo
derecho, entonces el hijo de p después de la eliminación será el hijo izquierdo
de t (éste sena el caso de C, L, M, P, y R en la Figura 14.12); si t tiene un hijo
derecho que no tiene hijo izquierdo, entonces ese hijo derecho será el hijo de p
después de la supresión, con su enlace izquierdo copiado de t (éste sena el caso
de A y N en la Figura 14.12); en caso contrario, se pone a x a apuntar al nodo
con la clave más pequeña del subárbol de la derecha de t;el enlace derecho de
este nodo se copia en el enlace izquierdo de su padre y sus dos enlaces se ponen
a partir de t (éste sena el caso de H y E en la Figura 14.12). Para limitar el nú-
mero de casos el programa siempre elimina mirando hacia la derecha, aunque
en algunos casos podría ser más fácil en algunos casos mirar a la izquierda (por
ejemplo, para eliminar a H en la Figura 14.12).
Esta solución puede parecer asimétrica y bastante ad hoc:por ejemplo, ¿por
qué no utilizar la clave inmediatamente anterior a la que se va a eliminar, en
lugarde una posterior?Se han sugerido varias modificacionessimilares,pero las
diferencias no son tan notables como para que puedan apreciarse en aplicacio-
nes prácticas, aunque se ha demostrado que el algoritmo anterior tiende a dejar
el árbol ligeramente desequilibrado (con altura media proporcional a p)
si se
le somete a un gran número de pares aleatorios de eliminar-insertar.
Es bastante comente que los algoritmos de búsqueda necesiten implemen-
MÉTODOS DE BÚSQUEDA ELEMENTALES 233
taciones de la operación de eliminado significativamentecomplejas: las claves
tienden por sí mismas a formar parte integral de la estructura por lo que elimi-
nar una de ellas puede implicar reparaciones complicadas. Una alternativa, a
menudo satisfactoria,es la denominada eliminaciónperezosa, en la que el nodo
a eliminar se deja en la estructura, pero se marca como «eliminado» para la
búsqueda. Esto se obtiene en el programa anterior añadiendo una verificación
adicional para detectar tales nodos antes de terminar la búsqueda. Se debe estar
segurode que grandescantidadesde nodos «eliminados»no conducen a un gasto
excesivode tiempo o espacio,pero esto es un problema menor en muchas apli-
caciones. De modo alternativo, se puede reconstruir periódicamente toda la es-
tructura de datos, excluyendo los nodos «eliminados»'.
Árboles binarios de búsqueda indirecta
Como se vio en el Capítulo 11, en muchas aplicaciones se desea obtener una
estructura de búsqueda que permita encontrar los registros sin tenerles que des-
plazar. Por ejemplo, se puede tener un array de registros con claves y se puede
desear que la rutina buscar dé el índice en el array del registro que corresponde
con una cierta clave. O se pudiera desear suprimir de la estructura de búsqueda
un registro con un índice dado, pero manteniéndolo dentro del array para algún
otro uso.
Para adaptar los árboles binarios de búsqueda a tales situaciones, sirnple-
mente se transforma el campo i nfo de los nodos en el índice del array. Enton-
ces es posible eliminar el campo cl ave haciendo que las rutinas accedan a las
claves de los registros directamente, por ejemplo por medio de una instruccidn
como i f (v < a [x- >i nf o] ) ... Sin embargo, a menudo es mejor hacer una
copia extra de las claves y utilizar el código anterior tal cual. Esto implica utili-
zar una copia suplementaria de las claves (una en el array, otra en el árbol), pero
permite que se utilice la misma función para más de un array o, como se verá
en el Capítulo 27, para más de un campo clave en el mismo array. (Existenotras
vías de lograr esto: por ejemplo, podría asociarse un procedimiento a cada árbol
para extraer las claves de los registros.)
Otra forma directa de lograr la «indirección» para los árboles binarios de
búsqueda consiste simplemente en suprimir la implementación enlazada y uti-
lizar una representación por array directo, como la que se presentó en el Capí-
tulo 3. Todos los enlaces se convierten en índices dentro de un array a [O] ,
., .,a [N+l] de registros que contienen un campo cl ave y campos índices i zq
y der. Entonces las referencias a enlaces como x->cl ave y x = x->i zq pasan
a ser referencias a array tales como a [XI.cl ave y x = a[x] .izq. No se utilizan
llamadas a new, puesto que el árbol existe dentro del array de registros: los no-
' De aquí el que el término también signifique borrado retardado, porque la acción física 1
: borrar se posterga
(sedeja para después) o no se hace nunca. (N.del T.)
234 ALGORITMOS EN C++
dos ficticios se asignan colocando cabeza = O y z = 1 y el constructor incre-
menta simplementeun puntero al próximo espacio libre dentro del array de re-
gistros y rellena los campos.
Esta forma de implementar árboles binarios de búsqueda para facilitar las
búsquedas en grandes arrays de registros es preferible en muchas aplicaciones,
puesto que evita el gasto extra de copiar las clavesdescrito en el párrafo antenor
y evita la sobrecarga del mecanismo de asignación de memona que implica new.
Su inconveniente es que los enlaces no utilizados pueden gastar espacio en el
array de registros.
Una tercera alternativa es utilizar arrays paralelos, como se hizo en el Ca-
pítulo 3 para las listas enlazadas. La implementación correspondiente es muy
parecida a la descrita en el párrafo anterior, excepto que se utilizan tres arrays,
uno para las claves, otro para los enlaces izquierdos y otro para los enlaces de-
rechos. La ventaja de este método es la flexibilidad. Se pueden añadir fácil-
mente nuevos arrays (para la información extra asociada con cada nodo), sin
modificar en nada el código de manipulación de los árboles, y cuando una ru-
tina de búsqueda proporciona un índice de un nodo, está dando también una
forma inmediata de acceder a todos los arrays.
Ejercicios
1. Implementar un algoritmo de búsqueda secuencia1 con una media de N/2
pasos para una búsqueda cualquiera, con éxito o sin él, mantenkmlo los
registrosen un array ordenado.
2. Indicar el orden en que quedan las claves después de insertar los registros
con las claves C U E S T I O N F A C I L en una tabla (inicialmente vacía),
mediante las operaciones de buscar e insertar y utilizando una heurística de
búsqueda autoorganizada.
3. Dar una implementación recursiva de la búsqueda binaria.
4. Suponiendo que a [i] == 2*i, para 1 <= i <= N. ;Cuántas entradas de
la tabla se examinarán por la búsqueda por interpoiación durante una bús-
queda sin éxito de 2k - I ?
5. Dibujar el árbol binario de búsqueda que resulta de insertar en un árbol
inicialmente vacío los registrosde claves C U E S T I O N F A C I L.
6. Escribir un programa recursivo para calcular la altura de un árbol binario:
la distancia más larga entre la raíz y un nodo externo.
7. Suponiendo que se dispone de una estimación provisional de la frecuencia
con la que las claves de búsqueda acceden a un árbol binano. ¿Deberán in-
sertarselas claves en orden creciente o decrecientede dicha frecuencia? ¿Por
qué?
8. Modificar un árbol binario de búsqueda de modo que se mantengan juntas
en el árbol las claves iguales. (Sivarios nodos del árbol tienen la misma clave
MÉTODOS DE BÚSQUEDA ELEMENTALES 235
que un nodo dado, entonces o su padre o alguno de sus hijos debe tener la
misma clave que éste.)
9. Escribir un programa no recursivo para imprimir en orden las claves de un
árbol binario de búsqueda.
10. Dibujar el árbol binano de búsqueda que resulte de insertar en un árbol,
inicialmente vacío, registros con las claves C U E S T I O N F A C I L,
suprimiendo a continuación T.
Algoritmos en C++.pdf
15
Árboles equilibrados
Los algoritmos de árboles binarios del capítulo anterior son muy útiles en un
gran número de aplicaciones, pero tienen el problema de dar un mal rendi-
miento en el peor caso. Al igual que con el Quicksort,desgraciadamentees cierto
que estos casos tienen tendencia a ocurrir en la práctica si el usuario del algo-
ritmo no se preocupa de ello. El algoritmo de búsqueda de un árbol binario
puede comportarse muy mal en archivos ya ordenados, o en archivos en orden
inverso, o en archivos que contienen alternativamente claves grandes y peque-
ñas, o en archivos de gran sección que tengan una estructura simple.
Con el Quicksort el único remedio para mejorar esta situación fue recurrir
a la aleatonedad: escogiendo un elemento que provoque una partición aleato-
ria, se podna confiar en la ley de las probabilidades para eliminar el peor caso.
Afortunadamente, en la búsqueda en árbolesbinarios es posible hacerlo mucho
mejor, pues hay una técnica general que permite garantizar que el peor caso no
ocumrá. Esta técnica, a la que se le llama equilibrar,ha sido utilizada como base
de varios algoritmos diferentes de «árboles equilibrados». A continuación se es-
tudiará con detalle uno de estos algoritmos,presentándose brevemente la forma
como se relaciona con los otros métodos que se han utilizado.
Como se verá, la implementación de algoritmos de «árboles equilibrados))
es seguramente un caso de «más fácil de decir que de hacen). A menudo es fácil
escribir el concepto general que hay detrás del algoritmo, pero la implementa-
ción es un conglomerado de casos particulares y simétricos. El programa que se
desarrolla en este capítulo no es solamente un método importante de búsqueda
sino que ilustra con precisión la relación entre una descripción de «alto nivel»
y un programa de «bajo nivel» en C++, que implementa el algoritmo.
Árboles descendentes 2-3-4
Para eliminar el peor caso en los árboles binarios de búsqueda, se necesitará al-
guna flexibilidad en la estructura de datos que se va a utilizar. Para obtener esta
237
238 ALGORITMOS EN C++
Figura 15.1 Un árbol 2-3-4.
flexibilidad, se supone que los nodos pueden contener más de una clave. Espe-
cíficamente se permitirán 3-nodos y I-nodos, que pueden contener dos y tres
claves, respectivamente. Un 3-nodo tiene tres enlaces saliendo de él, uno para
todos los registros con claves más pequeñas que las suyas, otro para todos los
registros con claves que están entre las dos suyas, y otro para todos los registros
con claves mayores que las suyas. De forma similar un 4-nodo tiene cuatro en-
laces que salen de él, uno para cada uno de los intervalos definidos por sus tres
claves. (Los nodos de un árbol binano de búsqueda estándar podrían denomi-
narse 2-nodos: una clave, dos enlaces). Más adelante se verin algunas formas
eficaces de definir e implementar las operaciones básicas de estos nodos exten-
didos; por ahora, supóngase que se pueden manipular convenientemente y ob-
sérvese cómo pueden combinarse para formar árboles.
Por ejemplo, la Figura 15.1 muestra un árbol 2-3-4 que contiene las claves
E J E M P L O D E B U. Es fácil ver cómo se efectúa una búsqueda en este tipo
de árbol. Por ejemplo, para buscar S se seguiría el enlace derecho que parte de
la raíz, puesto que S es mayor que EM, terminando con una búsqueda sin éxito
en el segundo enlace por la derecha del nodo que contiene a O, P y U.
Para insertar un nuevo nodo en un árbol 2-3-4, se desearía, como antes, ha-
cer una búsqueda infructuosa y después enganchar el nodo. Es fácil ver lo que
hay que hacer si el nodo en el que termina la búsqueda es un 2-nodo: simple-
mente, transformarlo en un %nodo, añadiéndole el nuevo nodo (y otro enlace).
De forma similar, un h o d 0 se puede convertir fácilmente en un 4-nodo. Pero,
¿qué se debe hacer si se desea insertar un nuevo nodo en un 4-nOdO? Por ejem-
plo, jcómo se insertaría S en el árbol de la Figura 15.l? Una posibilidad sería
engancharlo como un nuevo hijo, el segundo por la derecha del 4-nodo que
contiene a O, P y U; pero existe una solución mejor, la que se muestra en la
Figura 15.2: se divide el 4-nodo en dos 2-nodos y se pasa una de sus claves a su
padre. Primero se divide el 4-nOdO que contiene a O, P y U, en dos 2-nodos
(uno que contiene a O y el otro a U) y la clave ((intermedia) P se pasa hacia
amba, al nodo que contiene a E y M, convirtiéndolo en un 4-nodo. Así hay
espacio para S en el 2-nodo que contiene a U.
Pero ¿qué pasa si se divide un 4-nodo cuyo padre es también un 4-nOdO?Un
método sena dividir también al padre, pero el abuelo también podría ser un 4-
nodo y también el bisabuelo, etc.: se tendría que estar haciendo divisiones de
nodos hasta la raíz. Una solución más fácil consiste en asegurarse de que el pa-
dre de cualquier nodo que se encuentre no sea un 4-nOd0, lo que se logra divi-
ÁRBOLEC EQUILIBRADOS 239
Figura 15.2 Inserción(de S) en un árbol 2-3-4.
Figura 15.3 Construcción de un árbol 2-3-4.
240 ALGORITMOS EN C++
Figura 15.4 Divisiónde 4-nodos.
diendo todos los 4-nodos del camino de descenso por el árbol. La Figura 15.3
completa la construcción de un árbol 2-3-4 correspondiente al conjunto com-
pleto de claves E J E M P L O D E B U S Q U E D A. En la pnmera línea se
ve que el nodo raíz se divide durante la inserción de la Q; se producen otras
divisionesal insertar la segunda U, la última E y la segunda D.
El ejemplo anterior muestra cómo se pueden insertar fácilmente nuevos no-
dos en árboles 2-3-4 haciendo una búsqueda y dividiendo los 4-nodos que se
encuentran en el descenso por el árbol. De forma más precisa, como se muestra
rn la Figura 15.4, cada vez que se encuentre un 2-nodo conectado con un 4-
nodo, se debe transformar en un 3-nOdO conectado con dos 2-nodos, y cada vez
que se encuentre un h o d 0 conectado con un 4-nOd0, se debe transformar en
un h o d 0 conectado a dos 2-nodos.
Esta operación de «división» funciona porque se pueden mover no sólo las
clavessino también lospunteros. Dos 2-nodos tienen el mismo número de pun-
teros (cuatro) que un 4-nod0, así que se puede efectuar la división sin tener que
transformar los elementos que están debajo del nodo dividido. Un h o d 0 no
puede transformarse en un h o d 0 añadiendo solamente otra clave: se necesita
también otro puntero (en este caso el puntero extra liberado por la división). El
punto crucial es que estastransformaciones son puramente «locales»:las únicas
partes del árbol que se necesita examinar o modificar son las que se muestran
en la Figura 15.4.Cada una de las transformaciones transmite hacia arriba una
de las claves, desde un 4-nOdO hacia su padre y reestructura los enlaces de
acuerdo con ello.
Hay que destacar que no es necesario preocuparse por saber si el padre de
un nodo es un 4-nod0, puesto que las transformaciones aseguran que cuando
se pasa a través de un nodo durante el descenso del árbol, se acaba desembo-
cando en un nodo que no es un 4-nOdO. En particular, cuando se alcanza el
fondo del árbol no se está en un 4-nOd0, y se puede insertar un nuevo nodo
directamente transformando un 2-nodo en un 3-nOdO o un h o d 0 en un 4-
nodo. En realidad. es conveniente tratar la inserción como una división de un
ÁRBOLES EQUILIBRADOS 241
Figura 15.5 Un árbol 2-3-4 grande.
4-nodo imaginario del fondo del árbol, el cual transmite hacia amba la nueva
clave a insertar.
Un último detalle: siempre que la raíz del árbol pase a ser un 4-nodo, se le
transforma en tres 2-nodos, como se hizo en el ejemplo anterior. Esta técnica es
más simple que la alternativa de estar esperandohasta la próxima inserción para
hacer la división, porque no es necesario preocuparse por el padre de la raíz. La
división de la raíz (y sólo esta operación) hace que el árbol crezca un nivel «más
alto».
El algoritmo esquematizado anteriormente proporciona un camino para ha-
cer búsquedas e inserciones en árboles 2-3-4; puesto que los 4-nodos se dividen
en el camino de descenso, los árbolesse denominan árboles 2-3-4 descendentes.
Lo interesante es que, aun cuando no ha habido que preocuparse por el equili-
brio, los árboles resultantes jestán perfectamente equilibrados!
Propiedad 15.1 Las búsquedas en un árbol 2-3-4 de N nodos nunca exploran
más de 1gN+ 1 nodos.
La distancia de la raíz a cualquier nodo externo es la misma: las transformacio-
nes que se llevan a cabo no tienen influencia sobre la distancia entre cualquier
nodo y la raíz, excepto cuando se divide la raíz y, en este caso, la distancia entre
todos los nodos y la raíz se aumenta en una unidad. Si todos los nodos son 2-
nodos, el resultado anunciado se cumple puesto que el árbol es similar a un ár-
bol binario completo; si existen 3-nodos o 4-nodos, entonces la. altura del arb01
sólo puede ser infenor..
Propiedad 15.2 Las inserciones en árboles 2-3-4 de N nodos necesitan menos
de lg N + 1 divisiones de nodos en el peor caso y parecen necesitar menos de una
división por término medio.
Lo peor que puede pasar es que todos los nodos en el camino hacia el punto de
inserción sean 4-nodos, que sería preciso dividir. Pero en un árbol construido a
partir de permutaciones aleatorias de N elementos, no sólo es improbable que
suceda el peor caso, sino que también se necesitan pocas divisiones por término
medio, porque no hay muchos 4-nodos. La Figura 15.5 muestra un árbol cons-
truido a partir de una permutación aleatoria de 95 elementos: hay nueve 4-no-
dos y sólo uno de éstos no está situado en ei fondo. Los expertos no han podido
242 ALGORITMOS EN C++
Figura 15.6 Representación rojinegrade 3-nodosy 4-nodos.
establecer todavía resultados analíticos del rendimiento medio de los árboles 2-
3-4, pero los estudios empíricos muestran de forma insistente que se necesitan
pocas divisiones..
La descripción precedente es suficiente para definir un algoritmo de bús-
queda utilizando árboles 2-3-4 que garanticen un buen rendimiento en el peor
caso. Sin embargo, sólo se está a mitad de camino de una implementación real.
Aunque sea posible escribir algoritmos que realmente lleven a cabo transfor-
maciones sobre distintos tipos de datos destinados a representar 2-, 3-, y 4-110-
dos, la mayor parte de las acciones a tomar son poco prácticas en esta represen-
tación directa. (Para convencerse de esto es suficiente con tratar de implementar
incluso la más simple de las transformaciones de dos nodos.) Además, el gasto
extra en que se incurre por la manipulación de estructuras de nodos más com-
plejas es muy probable que haga a los algoritmos más lentos que la búsqueda
estándar por árbol binario. El objetivo principal la acción de equilibrar es ofre-
cer un «seguro» contra el peor caso, pero sería penoso tener que pagar el coste
adicional de este seguro en cada ejecución del algoritmo. Por fortuna, como se
verá más adelante, existe una representación relativamente simple de los 2-, 3-
y 4-nodos que permite que las transformaciones se hagan de manera uniforme
con un pequeño aumento de coste respecto al de una búsqueda estándar por
árbol binario.
Árboles rojinegros
Curiosamente, es posible representar los árboles 2-3-4como árbolesbinanos es-
tándar (2-nodos solamente) utilizando sólo un bit extra por nodo. La idea es
representar los 3-nodos y los 4-nodos como pequeños árboles binarios unidos
por enlaces «rojos» que contrasten con los enlaces «negros» que ligan a los ár-
boles 2-3-4. La representación es simple: como se muestra en la Figura 15.6, los
4-nodos se representan por 2-nodos conectados por un enlace rojo (los enlaces
rojos se dibujarán con líneas gruesas). (Cualquier orientación es legal para un 3-
nodo.)
La Figura 15.7 muestra una forma de representar el último árbol de la Fi-
ÁRBOLES EQUILIBRADOS 243
6 h 6 0 6 0
Figura 15.7 Un árbol rojinegro.
gura 15.3. Si se eliminan los enlaces rojos y se reúnen los nodos que conectan,
el resultado será el árbol 2-3-4 de la Figura 15.3. El bit extra por nodo se utiliza
para almacenar el color del enlace que apunta a ese nodo: se hará referencia a
los árboles 2-3-4 representados de esta forma con el nombre de árboles rojine-
gros.
La «inclinación» de cada 3-nodo se determina por la dinámica del algo-
ritmo que se describe posteriormente. Existen muchos árboles rojinegros para
cada árbol 2-3-4. Sena posible hacer cumplir una regla para que los 3-nodos
tengan todos la misma inclinación, pero no hay razón para hacerlo así.
Estos árbolestienen muchas propiedades que se obtienen directamente de la
forma en la que se han definido. Por ejemplo, nunca hay dos enlaces rojos se-
guidos a lo largo de cualquier camino entre la raíz y un nodo terminal, y todos
los caminos de este tipo tienen el mismo número de enlaces negros. Es de des-
tacar que es posible que un camino (alternando negro-rojo)puede ser dos veces
más largo que otro (todo negro), pero las longitudes de los caminos son siempre
proporcionales a 100.
Una característica sorprendente de la Figura 15.7 es la posición relativa de
las claves iguales. Con un poco de reflexión quedará claro que cualquier algo-
ritmo de árbol equilibrado debe permitir que los registros con claves iguales a
la de un nodo dado se encuentren a ambos lados del nodo: de lo contrario se
podría producir un grave desequilibrioque provocaría la inserción de largas ca-
denas de claves duplicadas. Esto implica que no es posible encontrar todos los
nodos que tengan una clave dada continuando con el procedimiento de bús-
queda, como en la búsqueda estándar por árboles binanos. En su lugar, se debe
utilizar un procedimiento como el de imprimir un árbol del Capítulo 14,o bien
eliminar la posibilidad de que haya claves duplicadas, como se presentó al co-
mienzo del Capítulo 14.
Una propiedad muy agradable de los árboles rojinegros es que el procedi-
miento buscar para la búsqueda estándar por árbolesbinanos se aplica en ellos
sin modificaciones (excepto en el caso de las claves duplicadas que se presentó
en el párrafo anterior). Se implementan los colores de los enlaces añadiendo un
campo b de un bit a cada nodo. Este campo será 1 si el enlace que apunta al
244 ALGORITMOS EN C++
nodo es rojo y O si es negro; el procedimiento buscar nunca examina este
campo. De esta manera, el mecanismo de equilibrado no añade ninguna «SO-
brecarga» al tiempo empleado por el procedimiento fundamental de búsqueda.
Puestoque en una aplicacióntípica cada clave se inserta una sola vez, pero puede
buscarse muchas veces, el resultado final será que se ha mejorado el tiempo de
bíisqueda (porque los árboles están equilibrados) 2 un coste relativamente
pequeño (porque no se ha hecho ninguna acción de equilibrar durante las bús-
quedas).
Más aún, el sobrecoste de la inserción es muy pequeño: solamente hay que
hacer algo diferente al alcanzar un 4-nodo, y no hay muchos 4-nodos en el ár-
bol porque siempre se están dividiendo. El lazo interno necesita sólo una com-
probación extra (si un nodo tiene dos hijos rojos, es parte de un 4-nOdO), tal y
coma se muestra en la siguiente implementación del procedimiento insertar:
void Dicc: :insertar(tipoElemento v, tipoInfo info)
x = cabeza; p = cabeza; a = cabeza;
while (x != z)
{
ba = a; a = p; p = x;
x = (v < x->clave) ? x->izq : x->der;
{
if (x->izq->b && x->der->b) dividir(v);
1
1
x = new nodo(v, info, 1, z, z);
if (v < p->clave) p->izq = x; else p->der = x;
dividir(v); cabeza->der->b = negro;
1
Por claridad, se utilizan las constantes rojo = 1 y negro = O, en éste y en los
siguientes códigos; por brevedad se comprueba el 1 comprobando un no cero,
sin hacer referencia al rojo. En este programa x se mueve hacia abajo por el
árbol al igual que antes, y ba, a y p se mantienen como punteros al bisabuelo,
al abuelo y al padre de x en eí árbol. Para comprender por qué se necesilan to-
dos estos enlaces,se considera la adición de Y al árbol de la Figura 15.7. Cuando
se alcanza el nodo externo de la derecha del 3-nOdO que contiene a UU, ba es
S,a es IJ y p es U.A-horase debe añadir Y para hacer un h o d 0 que contenga
U, U y Y ,resultando el árbol que se muestra en la Figura 15.8.
Se necesita un puntero a S (ba)porque su enlace derecho debe cambiar para
que apunte a la segunda U y no a la primera. Para ver exactamente cómo su-
cede esto. se necesita examinar la operación del procedimiento di vi di r. Con-
sidérese la representación rojinegra de las dos transformaciones que se deben
llevar a cabo: si se tiene un 2-nodo conectado con un 4-nOd0, entonces hay que
convertirlos en un h o d 0 conectado con dos 2-nodos; si se tiene un h o d 0 co-
nectddo a un h o d 0 hay que convertirlos en un 4-nOdO conectado a dos 2-no-
ÁRBOLES EQUILIBRADOS 245
Figura 15.8 Inserción(de Y) en un árbol rojinegro.
dos. Cuando se añade un nuevo nodo en la parte inferior del árbol, se le consi-
dera como el nodo intermedio de un 4-nodo imaginario (esto es, imaginando
que z es tojo, aunque por ello nunca se le compruebe explícitamente).
La transformación necesaria cuando se encuentra un 2-nodo conectado a un
4-nOdO es fácil y se aplica también si se tiene un %nodo conectado a un 4-nOdO
de forma «correcta», como se muestra en la Figura 15.9. Así, d i v i d i r co-
mienza marcando a x como rojo y a sus hijos como negros.
No queda más que considerar las otras dos situaciones que pueden ocumr
si se encuentra un h o d 0 conectado a un 4-nodo, como se muestra en la Figura
15.10 (realmente, hay cuatro situaciones, dado que se pueden dar también las
imágenes simétricas de estasdos en la otra orientación de los 3-nodos).En estos
casos la división del 4-nOdO deja dos enlacesrojos seguidos, una situación ilegal
que debe corregirse. Esto se puede comprobar fácilmente dentro del propio pro-
grama: como se acaba de marcar a x como rojo, sólo se deberá actuar si el padre
p de x es también rojo. La situación no es tan grave porque se tienen tres nodos
conectados por enlaces rojos: todo lo que hay que hacer es transformar el árbol
de modo que los enlaces rojos apunten hacia abajo desde el propio nodo.
Por fortuna, existe una operacih simple que logra el efecto deseado. Se co-
Figura 15.9 Divisiónde 4-nodos con un cambio de colores.
246 ALGORITMOS EN C++
Figura 15.10 Divisiónde 4-nodoc con un cambio de colores: se necesita una rotación.
mienza por la más fácil de las dos situaciones, el primer caso (parte supe-
rior) de la Figura 15.10,en el que los enlacesrojos están orientados en la misma
dirección. El problema es que el h o d 0 se orientó en la dirección equivocada:
en consecuencia se reestructurará el árbol para cambiar la orientación del 3-nOdO
y así reducir este caso al segundo de la Figura 15.9, en el que la marca de color
de x y sus hijos fue suficiente. Al reestructurar el árbol para reorientar un 3-
nodo se cambian tres enlaces, como se muestra en la Figura 15.11;en esta fi-
gura el árbol de la izquierda es el mismo que el que se obtuvo en la Figura 15.8,
pero en el de la derecha está girado el 3-nodo que contiene a P y a S. El enlace
izquierdo de S se cambió para apuntar a Q, el enlace derecho de P se cambió
para apuntar a S y el enlace derecho de M se cambió para apuntar a P. Se ob-
serva que los colores de los dos nodos también se cambiaron.
Esta operación de rotación simple se define sobre cualquier árbol binario de
búsqueda (con la excepción de las operacionesque afectan a colores)y es la base
de varios algoritmos de árboles equilibrados, porque preserva el carácter esen-
cial del árbol de búsqueda y es una modificación local que implica sólo tres
cambios de enlaces. Sin embargo, es importante observar que la aplicación de
una rotación simple no mejora necesariamente el equilibrio de un árbol. En la
Figura 15.11 Rotación de un 3-nodo de la Figura 15.8.
ÁRBOLES EQUILIBRADOS 247
Figura 15.11, la rotación hace subir un nivel hacia la raíz a todos los nodos a la
izquierda de P, pero todos los nodos a la derecha de S han bajado un nivel: en
este caso la rotación hace al árbol menos, no más, equilibrado. Los árboles 2-3-
4 descendentes se pueden considerar simplemente como una forma conve-
niente de identificar rotaciones simples que son susceptiblesde mejurar el equi-
librio.
Toda rotación simple implica modificar la estructura del árbol, algo que se
debe hacer con precaución. Como se vio al considerar el algoritmo de elimina-
ción del Capítulo 14, el código es más complicado de lo que pudiera parecer
necesario a causa del gran número de casos similares con simetrías izquierda-
derecha. Por ejemplo, suponiendo que los enlaces y, h, y n apuntan a M,S y P
respectivamente, en la Figura 15.8, entonces la transformación para pasar a la
Figura 15.11 se efectúa por los cambios de enlace h->i zq = n->der; n->der =
h;y->der = n. Existen otros tres casos análogos: el h o d 0 podría estar orien-
tad0 en el otro sentido, o podría estar en el lado izquierdo de y (orientado en
ambos sentidos). Una forma práctica de tratar estos cuatro casos es utilizar la
clave de búsqueda v para «redescubrin>el hijo (h) y el nieto (n)del nodo y. (Se
sabe que solamente se reorientará un 3-nOdOsi la búsqueda llevó al último nivel
del árbol.) Esto conduce a un programa más simple que la alternativa de estar
recordando durante la búsqueda no sólo los dos enlaces correspondientes a h y
n, sino también si son derechos o izquierdos. La siguiente función permite re-
orientar un 3-nOdO a lo largo del camino de búsqueda de v, cuyo padre es y:
struct nodo *rotar(tipoElemento v, struct nodo *y)
struct nodo *h, *n;
h = ( v < y->clave) ? y->izq : y->der;
if (v < h->clave)
{ n = h->izq; h->izq = n->der; n->der = h; }
el se
{ n = h->der; h->der = n->izq; n->izq = h; }
if (v < y->clave) y->izq = n; else y->der = n;
return n;
1
Si y apunta a la raíz, h es el enlace derecho de y y n es el enlace izquierdo de h,
el programa realiza exactamente las trasformaciones de enlaces necesarias para
producir el árbol de la Figura 15.11 a partir del de la Figura 15.8. El lector puede
verificar por sí mismo los otros casos. Esta función devuelve el enlace del <dope»
del 3-nodo, pero no hace el cambio de color.
Así, para tratar el tercer caso de di vi di r (ver Figura 15.1O), se puede hacer
a rojo, luego asignar a x rotar ( v , ba), y después poner x en negro. Esto re-
orienta el 3-nodo constituido por los dos nodos a los que apuntan a y p y hace
que este caso sea el mismo que el segundo, cuando se orientó el 3-nodo.
248 ALGORITMOS EN C++
Figura 15.12 División de un nodo en un árbol rojinegro.
Por último, para tratar el caso en el que los dos enlaces rojos están orienta-
dos en direcciones diferentes (ver Figura 15.10), simplementese pone en p el
valor de rotar(v, a). Esto reorienta el h o d 0 «ilegal»constituido por los dos
nodos a los que apuntan p y x. Estos nodos son del mismo color, así que no es
necesario ningún cambio de color, lo que lleva directamente ai tercer caso. Por
razones evidentes, la combinaciónde lo que se acaba de presentar y de la rota-
ción relativa al tercer caso se denomina rotación doble.
La Figura 15.12 muestra la acción de dividir en el ejemplo cuando se añade
S. En primer lugar existe un cambio de color para dividir el h o d 0 que con-
tiene a O, P y U. A continuación es preciso hacer una rotación doble: la primera
alrededor de la arista entre P y M y la segunda alrededor de la arista entre M y
E. Como los dos enlaces rojos se hallan en la misma dirección se está en el ter-
cer caso. Después de las modificaciones, se puede insertar S a la izquierda de U,
como se muestra en el primer árbol de la Figura 15.13. Si la raíz es un h o d 0
(inserción en el primer árbol de la Figura 15.13)entonces el procedimiento di -
vi dir pone la raíz en rojo: esto corresponde a transformarla,junto con el nodo
ficticio que está encima de ella, en un 3-nOdO. Evidentemente, no hay razón
para hacer esto, por lo que se incluye una sentencia al final del código de inser-
ción, que permite mantener a la raíz en negro.
Esto completa la descripción de las operaciones que debe llevar a cabo di -
vi dir. Este procedimiento debe cambiar el color de x y de su hijo, efectuar la
parte inferior de una rotación doble, si es necesario, y a continuación efectuar
una rotación simple, también si es necesario, de la siguiente forma:
void dividir(tipoE1emento v)
x->b = rojo; x-> izq->b = negro; x->der->b = negro;
{
ÁRBOLES EQUILIBRADOS 249
i f (p->b)
a->b = rojo;
if (v < a->clave !=v < p->clave) p = rotar(v,a);
x = rotar(v, ba);
x->b = negro;
{
1
1
Este procedimiento fija los coloresdespués de la rotación y también reinicializa
a x lo suficientementealto en el árbol para asegurarque la búsqueda no se pierda
debido a todos los cambios de los enlaces.
Los códigos de divi di r y rotar se han incluido en procedimientos sepa-
rados por razones de claridad, utilizando las variables ba, etc., de i nsertar;en
C++son posibles diversas alternativas menos atractivas, que van desde hacerlas
globales hasta declararlas explícitamente como funciones friends de D iCC.
Figura 15.13 Construcción de un árbol rojinegro.
250 ALGORITMOS EN C++
La declaración de clase para los árboles rojinegros es la misma que la que se
dio en el capítulo anterior para los árboles binarios de búsqueda normales, con
la adición del campo indicador binario b en nodo y la inclusión de un argu-
mento en el constructor de nodo para su inicialización. Si hay espacio disponi-
ble, se podría declarar b como un entero, pero normalmente se intenta utilizar
solamente un bit, quizás el de signo de una c l ave entera o el de alguna parte
del registro que se ha denominado info. A continuación se deben inicializar
cuidadosamente los nodos ficticios del constructor de D iCC, como sigue:
D icc ( in t rnax)
z = new nodo(0, infoNIL, negro, O, O);
z->izq = z; z->der = z;
cabeza = new nodo(elernentoMIN, O, negro, O, z);
{
1
Los enlaces de z se ponen apuntando a z. Aunque no es común que se necesi-
ten obligatoriamente asignaciones tales como un centinela, pueden simplificar
bastante la codificación. Por ejemplo, estas asignacionespermiten evitar el uso
de un break en el lazo whi 1e de insertar.
Al ensamblar los distintos fragmentos de código anteriores se obtiene un al-
goritmo muy eficaz y relativamente simple para la inserción utilizando una es-
tructura de árbol binario que garantiza toda inserción o búsqueda en un nú-
mero de pasos iogarítmico. Éste es uno de los pocos algoritmosde búsqueda con
esta propiedad, y su utilización está justificada siempre que no se pueda tolerar
un mal rendimiento en el peor caso.
La Figura 15.13 muestra cómo este algoritmo construye el resto del árbol
rojinegro del conjunto de clavesdel ejemplo. Por el coste de solamente unas po-
cas rotaciones se obtiene un árbol bastante bien equilibrado.
Propiedad 15.3 Una búsqueda en un árbol rojinegro de N nodos construido a
partir de claves aleatorias parece necesitar I@ comparaciones y una inserción
parece necesitar, como media, menos de una rotación.
A pesar de que todavía está por hacer un análisispreciso del caso medio de este
algoritmo, existen resultados convincentes de análisis parciales y de simulacio-
nes. La Figura 15.14muestra el gran árbol construido para el ejemplo que se ha
venido utilizando: el número medio de nodos explorados en este árbol durante
la búsqueda de una clave aleatoria es apenas 5,8 1, en comparación con 7,OO del
árbol construido para las mismas claves en el Capítulo 14, y con 5,74, el valor
óptimo para un árbol perfectamente equi1ibrado.m
Pero el significado real de los árboles rojinegros se encuentra en su rendi-
miento en el peor caso y en el hecho de que este rendimiento se alcanza con
muy poco coste. La Figura 15.15 muestra el árbol construido si se insertan los
ÁRBOLES EQUILIBRADOS 251
Figura 15.14 Un gran árbol rojirsgro.
números del 1 al 95, en orden, en un árbol inicialmente vacío;incluso este árbol
está bastante bien equilibrado. El coste de la búsqueda para cada nodo del árbol
es tan bajo como si se hubiera construido por el algoritmo elemental y la inser-
ción solamente implica un bit extra de comprobación y alguna llamada ocasio-
nal al procedimiento d i vi di r.
Propiedad 15.4 Una búsqueda en un árbol rojinegrocon N nodos necesita me-
nos de 2lgN + 2 comparaciones y una inserción necesita menos de una cuarta
parte de rotaciones que de comparaciones.
Sólo las «divisiones» que corresponden a un 3-nOdO conectado a un 4-nodo en
un árbol 2-3-4 necesitan una rotación en el árbol rojinegro correspondiente, por
lo que esta propiedad se deduce de la propiedad 15.2. El peor caso se obtiene
cuando el camino al punto de inserción consiste en alternar 3- y 4-no dos.^
En resumen: utilizando este método se puede encontrar una clave de un
archivo de, por ejemplo, medio miilón de registros al compararloscon sólo otras
veinte claves. En el peor caso puede ser que sean necesanas dos veces más com-
paraciones, pero no más. Además, las comparaciones se acompañan de un so-
brecoste pequeño, lo que asegura una búsqueda muy rápida.
Figura 15.15 Un árbol rojinegro en un caso degenerado.
252 ALGORITMOS EN C++
Qtros algoritmos
La implementación del «árbol 2-3-4 descendente», utilizando el esquema roji-
negro presentado en la sección anterior, es una de las diversas estrategias simi-
lares que se han propuesto para implementar árboles binarios equilibrados.
Como se vio anteriormente, de hecho es la operación «rotan>
la que equilibra
los árboles: se han examinadolos árboles desde un enfoque particular que per-
mite decidir fácilmente cuándo efectuar la rotación. Otros enfoques de los ár-
boles conducen a otros algoritmos, algunos de los cuales se examinaránbreve-
mente.
La estructura de datos más antigua y la mejor conocida para los árboles
equilibrados es el árbol A VL.Estos árboles tienen la propiedad de que la altura
de los dos subárboles de cada nodo difieren a lo sumo en una unidad. Si esta
condición se viola por una inserción, se puede restaurar utilizando rotaciones.
Pero esto requiere un bucle extra: el algoritmo básico consiste en buscar el valor
a insertar y después seguir hacia arriba por el árbol, a lo largo del camino que
se está recorriendo, ajiistando las alturas de los nodos utilizando rotaciones.
También es necesario saber si cada nodo tiene una altura superior o inferior en
una unidad a la de su hermano, o es la misma. Esto iiecesita dos bits si se re-
curre a una implementacióndirecta, aunque exista una forma de lograrlo con
un solo bit por nodo, utilizando el modelo rojinegro.
Una segunda, y muy conocida, estructura de árbol equilibrado es el árbol 2-
3, en el que sólo se permiten 2-nodos y 3-nodos. Es posible implementar inser-
ción utilizando un «bucle extra»que incluya rotaciones, como para los árboles
AVL, pero estas estructuras no tienen la suficienteflexibilidad para que se pueda
dar una versión descendente válida del algoritmo. Una vez niás, el modelo ro-
jinegro puede simplificar la implementación, pero es preferible utilizar árboles
2-3-4ascendentes, en los que se busca descendiendo hasta el fondo del árbol para
hacer allí la inserción, y entonces (si el nodo del fondo es un 4-nOdO) se vuelve
hacia atrás ascendiendo por el camino de búsqueda, dividiendo los 4-nodos e
insertando el nodo intermedio en el padre, hasta encontrar un 2-nodo o un 3-
nodo como padre. En este punto puede ser necesaria una rotación para tratar
casos como los de la Figura 15.10. Este método tiene la ventaja de utilizar a lo
sumo una rotación por inserción, lo que puede ser muy importante en algunas
aplicaciones. La implementación es algo más complicada que la del método
descendente descrito anteriormente.
En el Capítulo 18,se estudiará el tipo más iniportante de árbol equilibrado,
una generalización de los árboles 2-3-4 denominada árboles B. Esta estructura
permite hasta M claves por nodo, siendo M grande. Estos árboles se utilizan
ampliamenteen aplicacionesque trabajan con archivos muy grandes.
ÁRBOLES EQUILIBRADOS 253
Ejercicios
1. Dibujar el árbol 2-3-4 descendente construido por inserción de las claves C
U E S T I O N F A C 1 L (en este orden) en un árbol inicialmente vacío.
2. Dibujar una representación rojinegra del árbol de la pregunta anterior.
3. ¿Qué enlaces se modifican exactamente por los procedimientos dividir y ro-
tar cuando se inserta Z (despuésde Y) en el árbol ejemplo de este capítulo?
4. Dibujar el árbol rojinegro que resulte de la inserción de las letras A hasta la
K (en este orden), describiendo lo que pasa en general cuando las claves se
insertan en los árboles en orden ascendente.
5. ¿Cuántos enlaces deben modificarse en una rotación doble y cuántos se
modificaron en la implementación dada?
6. Generar aleatoriamente dos árboles rojinegrosde 32 nodos, dibujándolos (a
mano o por un programa), y compararlos con los árboles binarios de bús-
queda no equilibrados construidos con las mismas claves.
7. Generar aleatoriamente diez árboles rojinegros de 1.O00 nodos. Calcular el
número de rotaciones necesarias para construir los árboles y !a longitud
media del camino entre la raíz y un nodo externo. Interpretar los resulta-
dos.
8. Con un bit por nodo para el «colon>se pueden representar 2-nodos, 3-11s-
dos y 4-nodos. ¿Cuántos tipos de nodos diferentes se podrían representar si
se utilizan dos bits por nodo para el color?
9. En los árboles rojinegros se necesitan las rotaciones cuando los 3-nodos se
convierten en 4-nodos de una forma «no equilibrada). ¿Por qué no se eli-
minan las rotaciones permitiendo que !os 4-nodos se representen como tres
nodos cualquiera conectados por dos errlaces rGjos (perfectamente equili-
brados o no)?
10. Determinar una secuencia de inserciones que construya el árbol rojinegro
que se muestra en la Figura 15.11.
Algoritmos en C++.pdf
16
Dispersión
Una técnica de búsqueda completamente diferente de lasbasadas en estructuras
de árboles de comparación de los capítulos anteriores es la dispersión: un mé-
todo que permite hacer directamente referencia a los registrosde una tabla por
medio de transformaciones aritméticas sobre las clavespara obtener direcciones
de la tabla. Si se sabe que las claves son enteros distintos, entre 1 y N, entonces
se puede almacenar un registro con clave i en la posición i de la tabla, prepa-
rado para que se acceda a él de forma inmediata con el valor de la clave. La
dispersión es una generalización de este método trivial en aplicaciones de bús-
queda típicas donde no se tiene ningún conocimiento concreto sobre los valores
de las claves.
El primer paso en una búsqueda por dispersión consiste en evaluar unafun-
ción de dispersión que transforma la clave de búsqueda en direcciones de la ta-
bla. Idealmente, diferentes claves deben dar diferentes direcciones, pero nin-
guna función de dispersión es perfecta,y dos o más claves diferentespueden dar
la misma dirección de la tabla. La segunda parte de una búsqueda por disper-
sión es pues un proceso de resolución de colisiones, que permite tratar este tipo
de claves. Uno de los métodos de resolución de colisionesque se estudiarán uti-
liza las listas eplazadas y es apropiado en situaciones muy dinámicas en las que
el número de claves de búsqueda no se puede predecir. Los otros dos métodos
de resolución de colisiones que se examinarán alcanzan tiempos de búsqueda
muy bajos para registrosalmacenadosen un array fijo.
La dispersión es un buen ejemplo del compromiso espacio-tiempo. Si no hu-
biera limitación de memoria, se podría hacer cualquier búsqueda con un solo
acceso a la memoria, utilizando simplemente la clave como una dirección de
memoria. Si no hubiera limitaciones de tiempo, se podría hacer con un mínimo
de memoria utilizando un método secuencia1 de búsqueda. La dispersión pro-
porciona una forma de utilizar razonablemente la memoria y el tiempo para
obtener un equilibrio entre estos dos extremos. El empleo eficaz de la memoria
disponible y un rápido acceso a la memoria son los objetivos básicos de cual-
quier método de dispersión.
255
256 ALGORITMOS EN C++
La dispersión es un problema «clásico» en informática en el sentido de que
los diferentes algontmos conocidos se han estudiado con cierta profundidad y
son ampliamente utilizados. Existe un gran número dejustificaciones de orden
empírico y analíticoque apoyan la utilidad de la dispersión en una variada gama
de aplicaciones,
Funciones de dispersión
El primer problema que hay que resolver es el de la realización de la función de
dispersión transformando las claves en direcciones de la tabla. Éste es un pro-
blema aritmético con propiedades similares a los generadoresde números alea-
tonos que se estudia en el Capítulo 33. Lo que se necesita es una fxnción que
transforme la claves (habitualmente enteros o cadenas cortas de caracteres)en
enteros del intervalo [O, M- 13, donde M es el número de registros que se puede
colocar en el total de memoria disponible. Una función de dispersiónideal debe
ser fácil de calcular y debe ser además una aproximación a una función «alea-
toria)): para cada entrada, toda salida debe ser, en cierto sentido, igualmente
probable.
Como los métodos que se utilizan son aritméticos, el primer paso consiste
en transformar las claves en números sobre los que se realicen las operaciones
aritméticas. Para claves pequeñas, esto puede no significar trabajo alguno en
ciertos entornos de programación, si se pueden utilizar como números las re-
presentaciones binarias de las claves (véase la presentación del comienzo del
Capítulo 10).Para claves mayores, se puede intentar extraer bits de las cadenas
de caracteres y empaquetarlos en una palabra en lenguaje de máquina; después
se verá un método para manipular uniformemente clavesde cualquier longitud.
Supóngase en primer lugar que se dispone de un gran entero que corres-
ponde directamente a una clave, El método más comúnmente utilizado en la
dispersión consiste en escoger un M primo y, para cualquier clave k, calcular
h(k)= k mod M. Éste es un método directo fácil de calcular en muchos entor-
nos de programación y dispersa las claves bastante bien.
Por ejemplo, supóngase que el tamaño de la tabla es 101 y que hay que cal-
cular un índice para la clave de cinco caracteres C L A V E: si está codificada
con el código de cinco bits utilizado en el Capítulo 10(en el que la i-ésimaletra
del alfabeto se expresa por la representación binaria del número i), entonces
puede verse como el número binario
0001101100000011011000101,
que es equivalente al 3540677 en base 10. Además, 3540677 = 21 (mod lOl),
así que a la clave C I, A V E le corresponde (<(sedispersa a»)la posición 21 de
la tabla. Hay muchas claves posibles y relativamente pocas posiciones de la ta-
bla, por lo que a muchas otras claves le corresponderá la misma posición (por
DISPERSI~N 257
ejemplo, la clave A C L también tiene la dirección de dispersión 21en el código
anterior).
¿Por qué el tamaño de la tabla debe ser primo? La respuesta a esta pregunta
depende de las propiedades aritméticas de la función mod. En esencia, se trata
la clave como un número en base 32, a razón de un dígito por cada carácter de
la clave. Se ha visto que a la clave C L A V E le correspondeel número 3540677,
que también puede escribirse como
3 . 324+ 12 . 323+ 1 ' 322+ 22 . 32' + 5 . 32'
puesto que C es la tercera letra del alfabeto, etc. Ahora, suponiendo que por
desgracia se escoge M = 32: como el valor de k mod 32 no cambia al añadir
múltiplos de 32, la función de dispersión de cualquier clave será simplemente
jel valor de su último carácter! Parece natural asegurarse de que una buena fun-
ción de dispersión tenga en cuenta todos los caracteres de la clave, y la forma
más simple de hacerlo es eligiendo un M primo.
Pero la situación más típica es cuando las claves no son ni números ni ne-
cesariamente cortas, sino simplemente cadenas alfanuméricas (posiblemente
muy largas). ¿ Cómo calcular la función de dispersión de una cadena como G
R A N C L A V E? En el código utilizado, a ésta le correspondería la cadena de
45 bits
001111001000001011100001101100000011011000101,
o el número
7.328+18.32'+ 1.326+14.325+3.324+
12.323+1.322+22.321+5,
que es demasiado larga para representarla con funciones aritméticas normales
en la mayoría de las computadoras (habría que estar preparados para emplear
clavesmucho más largas). En una tal situación no se puede seguir calculando la
función de dispersión como se hizo antes, transformando la clave pieza por pieza.
Una vez más habrá que aprovecharse de las ventajas de las propiedades arit-
méticas de la función mod y de un sencillo truco de cálculo denominado el mé-
todo de Horner (ver Capítulo 36), que se basa en escribir de otra forma el nú-
mero que correspondea la clave. En el ejemplo,se obtiene la expresión siguiente:
(((((((7.32+18)32+1)32+14)32+3)32+12)32+1)32+22)32+5.
Esto conduce a un método aritmético directo de cálculo de la función de dis-
persión. La implantación de este capítulo utiliza como claves cadenas y no en-
teros. Se supone que t ipoEl emento es un tipo cl avecadena que permite la
asignación y las operaciones de comparación por desigualdad(y por supuesto la
función d i spersion). Ésta es la situación más natural para describir la disper-
sión, aunque, por coherencia con otros capítulos, se utilicen como claves,en los
258 ALGORITMOS EN C++
ejemplos, cadenas de un solo carácter. Esto es similar a la situación que se en-
cuentra en los Capítulos 10y 17 con cadenas de bits como claves:C++ permite
ser explícito sobre qué operaciones se llevarán a cabo en las claves. Para la dis-
persión, cada tipo de clave necesita tener una función de dispersión. En las ca-
denas, una función de dispersión basada en el método de Homer es simple-
mente una forma de tratar los caracteres como dígitos.
unsigned clavecadena: :dispersion(int M)
I
I
int h; char *t = v;
for (h = O; *t; t++)
return h;
h = (64*h + *t) % M;
1
Aquí h es el valor de dispersión calculado y la constante 64 es, estrictamente
hablando, una constante que depende de la implantación y del tamaño del al-
fabeto. El valor exacto de esta constante no es particularmente importante. Un
inconveniente de este método es que necesita un cierto número de operaciones
aritméticas para cada carácter de la clave lo cual podría ser costoso. Esto puede
mejorarse procesando la clave en partes más grandes. Sin el operador %, este
programa calcularía el número correspondiente a la clave, como en la ecuación
anterior, pero con claves muy largas el cálculo podría desbordarse. Sin em-
bargo, con el operador % se puede calcular la función de dispersión graciasa las
propiedades aditivas y multiplicativas de la operación módulo y se evita el des-
bordamiento porque % proporciona siempre un resultado inferior a M. La di-
rección calculada por el programa para G R A N C L A V E con M = 1O1 es
21.
Encadenamiento separado
Las funciones de dispersión anteriores convierten las claves en direccionesde la
tabla: queda todavía por explicar cómo resolver los casos en los que dos claves
dan la misma dirección. El método más directo consiste simplemente en cons-
truir, para cada dirección de la tabla, una lista enlazada con todos los registros
cuyas claves se transforman en esta dirección. Puesto que las claves que tienen
la misma posición en la tabla se ponen en una lista enlazada, es fácil conservar-
las en orden. Esto conduce a una generalización de los métodos de búsqueda
elementales que se presentaron en el Capítulo 14. Mejor que estar manteniendo
una lista única con un sencillo nodo cabecera cabeza, como se sugirió enton-
ces, es preciso mantener M listas con M nodos cabecera, inicializadas como se
describe a continuación:
DISPERSION 259
Dicc: :Dicc(int t m )
M = tm;
z = new nodo; z->siguiente = z; z->info = infoNIL;
cabezas = new nodo*[M];
for (int i = O; i < M; i++)
{
{ cabezas[i] = new nodo; cabezas[i]->siguiente = z; }
1
c h e E
lTJ1ElI
M
IP
ImlolIDIIEllelIUII
s
1
(QI l
ü
lElIDIIAl
dispersión: 5 10 5 2 5 1 4 4 5 2 10 8 6 10 5 4 1
Figura 16.1 Una función de dispersión(M = 11).
Ahora se pueden utilizar los procedimientos del Capítulo 14 de búsqueda e in-
serción en una lista, modificados de tal forma que se utilice la función de dis-
persión para escoger entre las listasreemplazando simplementelas referenciasa
cabeza por cabezas[dispersiÓn(v)].
Por ejemplo, si las claves del ejemplo se insertan sucesivamente en una tabla
inicialmente vacía utilizando la función de dispersión de la Figura 16.1, resulta
entonces el conjunto de listas que se muestra en la Figura 16.2. Este método se
denomina tradicionalmente encadenamiento separado porque los registros en
colisión se «encadenan» juntos en listas enlazadas independientes. Las listas se
pueden mantener ordenadas, pero esto no es tan importante en esta aplicación
como lo fue para la búsqueda secuencia1elemental porque las listas son bas-
tante cortas. Evidentemente, el total de tiempo que se necesita para una bús-
queda depende de la longitud de las listas (y de la posición relativa de las claves
en ellas).
Figura 16.2 Encadenamientoseparado.
260 ALGORITMOS EN C++
Para una «búsqueda sin éxito» (la búsqueda de un registro con una clave
que no está en la tabla), se puede suponer que la función de dispersióndificulta
las cosas lo suficiente como para que cada una de las M listas esté en igualdad
de condiciones para buscar en ella y que, al igual que en la búsqueda secuencial
en una lista, cada lista eIi_la que se busca se recorre sólo hasta la mitad (por
término medio). La longitud media de la lista examinada (no contando a Z)en
una búsqueda sin éxito es en el ejemplo (0+2+2+0+3+5+1+0+1+0+3)/11 =
134. Manteniendo las listas ordenadas se podría reducir este tiempo a la mitad.
Para una «búsqueda con éxito» (la búsqueda de alguno de los registros de la
tabla), se supone que cada registro tiene la misma posibilidad de ser examinado:
se encontrarán siete claves en primera posición de la lista, cinco en segun-
da, etc., por lo que la media es (7 . 1 + 5 . 3 + 3 . 3 + 1 . 4 + 1 . 5)/17 = 2,05.
(Esteanálisis supone que las claves iguales se distinguen por medio de un iden-
tificador único o de algún otro mecanismo y que la rutina de búsqueda se mo-
difica apropiadamente para que sea capaz de buscar cualquier clave en par-
ticular.)
Propiedad 16.1 El encadenamiento separado reduce el número de comparacio-
nes de la búsqueda secuencia1en unfactor de M (por término medio), utilizando
espacio extra para los M enlaces.
Si N, el número de claves de la tabla, es mucho mayor que M, entonces una
buena aproximación de la longitud media de las listas es N/M, puesto que cada
uno de los M valores de dispersión es «igualmente probable» gracias al diseño
de la función de dispersión. AI igual que en el Capítulo 14, las búsquedas sin
éxito llegan hasta el final de una determinada lista y las búsquedas con éxito la
recorren aproximadamente a la mitad.i
La implantación anterior utiliza una tabla de dispersión de enlaces a las ca-
beceras de las listas que contienen realmente las claves. Si no se desea mantener
M nodos cabeceras de lista, una alternativa consiste en eliminarlos y hacer que
cabezas sea una tabla de enlaces a las primeras claves de las listas. Esto pro-
voca algunas complicacionesen el algoritmo. Por ejemplo, añadir un nuevo re-
gistro al comienzo de una lista se convierte en una operación diferente de la de
añadir un nuevo registro en cualquier otra parte de ella, porque implica modi-
ficar una entrada de la tabla de enlaces, no un campo de un registro. Otra im-
plantación consisteen colocar la primera clave dentro de la tabla. Aunque estas
alternativas utilizan menos espacio en algunas situaciones,M es habitualmente
muy pequeño en comparación con N, de modo que la comodidad añadida al
utilizar nodos cabecerasde lista está casi siemprejustificada.
En una implantación de encadenamiento separado, normalmente se escoge
M lo Suficientemente pequeño para no utilizar una gran zona de memoria con-
tigua. Pero es probable que lo mejor sea escoger un M tal que las listas sean lo
suficientemente cortas como para hacer que la búsqueda secuencial sea lo más
eficaz posible: los métodos «híbridos» (como la utilización de árboles binarios
DISPERSI~N 261
en lugar de listas enlazadas) no merecen la pena, dada su complicación. En una
primera aproximación, se puede escoger un M que sea alrededor de la décima
parte del número de claves que se espera que haya en la tabla, de modo que
cada lista cuente con tener alrededor de diez claves. Una de las ventajas del en-
cadenamiento separado es que este valor no es crítico: si aparecen más claves
que las esperadas, entonces las búsquedas se demorarán un poco más: si hay
pocas claves en la tabla, entonces puede ser que se haya utilizado un poco más
dv memoria de la necesaria. Si la memoria es realmente un recurso crítico, la
elección de un M tan grande como se pueda permitirá aportar una ganancia de
rendimiento proporcional a M.
Exploración lineal
Si el número de elementos a poner en la tabla de dispersión se puede estimar
por adelantado y hay suficiente memoria contigua disponible como para con-
tener a todas las claves y contar además con algún espacio de reserva, entonces
probablemente no merezca la pena utilizar enlaces en la tabla de dispersión. Se
han desarrollado varios métodos para almacenar N registros en una tabla de ta-
maño M > N, utilizando los lugares vacíos de la tabla como ayuda en la reso-
lución de las colisiones. Tales técnicas se denominan métodos de dispersión de
direccionamiento abierto.
El método más simple de direccionamiento abierto es la llamada explora-
ción lineal: cuando hay una colisión (cuando la función de dispersión envía so-
bre un lugar de la tabla que ya está ocupado, cuya clave no es igual que la clave
de búsqueda), se explora la siguiente posición de la tabla, comparando la clave
del registro con la clave de búsqueda. Existen tres posibilidades en esta explo-
ración: si las claves concuerdan, entonces la búsqueda termina con éxito; si allí
no hay ningún registro, entonces la búsqueda termina infmctuosamente; en caso
contrario se explora la siguiente posición, continuando hasta que se encuentre
la clave de búsqueda o una posición vacía. Si se debe insertar un registro que
contiene la clave de búsqueda despuésde una búsqueda sin éxito, entonces sim-
plemente se le pone en el espacio vacío de la tabla donde se terminó la bús-
queda. Este método se implanta fácilmente de la forma siguiente:
class Dicc
private:
{
s t r u c t nodo
{ tipoElemento clave; t i p o I n f o i n f o ;
nodo() { clave = 'I ' I ; i n f o = infoNIL; }
1;
s t r u c t nodo *a;
262 ALGORITMOS EN C++
int M;
Dicc( int tm)
int buscar(tipoE1emento v);
void insertar(tipoEl emento v, tipoInfo info)
public:
{ M = tm; a = new nodo[M] }
int x = v.dispersion(M);
while (a[x].info != infoNIL) x = (x+l) % M;
a[x].clave = v; a[x].info = info;
{
1
La exploración lineal necesita la existencia de una clave de valor especial para
señalar las posicionesvacías de la tabla; este programa utiliza un simpleespacio
en blanco para este fin. El cálculo x = (x+l) % M corresponde al examen de la
siguiente posición (que se pone en el comienzo cuando se alcanza el final de la
tabla). Es preciso destacar que este programa no comprueba cuándo está la ta-
bla completamente llena. (¿Qué podría pasar en este caso?) La implantación de
buscar es similar a la de insertar:simplemente se añade la condición (( (v !=
a[x] .cl ave))) al bucle whi 1e y se cambia la línea siguiente, la que almacena
el registro, por return a[x] .info.
clave : (JI( ~ l
MIIPIIiJlOlIDIIEllelIUIElIQIIUIElE
IIAl
dispersión: 5 10 5 13 16 12 15 4 5 2 2 O 17 2 5 4 1
Figura 16.3 Unafunción de dispersión(M = 19).
Para el conjunto de claves del ejemplo, con M = 19, se tienen los valores de
dispersión que se muestran en la Figura 16.3. Si estas claves se insertan en el
orden dado y en una tabla inicialmente vacía, se obtiene la secuencia que se
muestra en la Figura 16.4. Se observa que las claves duplicadas aparecen entre
la posición inicial de la exploración y la siguienteposición vacía de la tabla, pero
no necesitan ser contiguas.
El tamaño de la tabla de la exploración lineal es mayor que la del encade-
namiento separado, puesto que se tiene M > N, pero la cantidad total de me-
moria que se utiliza es menor, ya que no se necesitan enlaces. El número medio
de elementos que se deben examinar para una búsqueda con éxito en este ejem-
plo es 38/17 = 2,23.
Propiedad 16.2 Una exploración lineal utiliza menos de cinco exploraciones,
por término medio, en una tabla dispersión que esté llena, al menos, en sus dos
terceras partes.
DISPERSI~N 263
La fórmula exacta para el número medio de exploraciones necesarias, expre-
sado en función del «factor de carga»a = N/M de la tabla de dispersión, es 1/2
+ 1/2(1 - para una búsqueda infructuosa y 1/2 + 1/2 (1 - a) para una bús-
queda con éxito. Así pues, si se toma a = 2/3, se obtienen cinco exploraciones
en una búsqueda infructuosa y dos en una con éxito. Las búsquedas sin éxito
son siempre las más costosas de las dos: una búsqueda con éxito necesitará me-
nos de cinco exploraciones hasta que la tabla esté llena en un 90 %. A medida
que la tabla se va llenando (y a se acerca a 1), estos números se van haciendo
más grandes; esto no debe permitirse en la práctica, como se confirmará poste-
ri0rmente.i
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
Figura 16.4 Exploraciónlineal.
264 ALGORITMOS EN C++
............... ..............~.....
................................
................ l
l
l
l
l
l 8 .
l
l
l
l
l
l
l
l
l
l
l
l
ll
. l
l
l
l
l I
l
l l
l
l
l
l
l
l
l
l I l
l
l
l
l
l
l
l I I
l
l
................ I
I
I
I
I
I I
I
I
I
I
I
I
I
I
I
I
~
I
I
1
.
0 I
I
I
I
I I..I
I
I
I
I
I
I
~
~
I I
I
I
I
I
I
U I ni
................ .......................................................
l
. l l l l l l ~ l l l l l l l m m l l l l l l l l l m l l l l l m l l l I
l
l l
l
l
l
l l l l l l l l l l l ~ l 8 I l l l m l l l l I I
l
l
l
. ~
l
l
~
l
~
l
l
l
l
l
l
l
l
l
l
l
l
l
.
l I 8 i e i i i i i i i i i i i ..IO l
l
l
l
l~ l P l l l l l l ~ l l l
I l
l
l
l
l
l
l
l I I
l
l
..........................................................................
...........................................................................
Figura 16.5 Exploraciónlineal en una tabla grande.
Doble dispersión
La exploración lineal (o en su lugar cualquier método de dispersión) es válida
porque garantiza que, cuando se está buscando una clave en particular, se exa-
minan todas las claves que dan la misma dirección de tabla al aplicarlesla fun-
ción de dispersión (en particular la de la propia clave si está en la tabla). Des-
graciadamente, en la exploración lineal se examinan también otras claves,sobre
todo cuando la tabla comienza a estar muy llena: en el ejemplo anterior, la bús-
queda de la segunda D implica el examen de E, U y J, ninguna de las cuales
tiene el mismo valor de dispzrsión. Y lo que es peor, la inserción de una clave
con un valor de dispersión dado puede aumentar drásticamente el tiempo de
búsqueda de claves que tienen otros valores de dispersión: en el ejemplo, una
inserción en la posición 18 provocaría un gran aumento del tiempo de bús-
queda para la posición 17. Este fenómeno, denominado agrupamiento, puede
hacer que la exploración lineal actúe muy lentamente en tablas casi llenas. La
Figura 16.5 muestra los agrupamientos que se forman en un ejemplo de ta-
maño mayor.
Por fortuna, existe una forma fácil de eliminar prácticamente este problema
de agrupamiento: la doble dispersión. La estrategiabásica es la misma; la única
diferencia es que, en lugar de examinar sucesivamente todas las entradas si-
guientes a la posición donde se ha producido la colisión, se utiliza un? segunda
función de dispersión para obtener un incremento fijo a utilizar en la secuencia
de ((exploración)).Esto se implanta fácilmente insertando u = h2 (v) al princi-
pi0 de la función y cambiando x = (x+l) % M por x = (x+u) % M dentro del
bucle whi 1e.
La segunda función de dispersión se debe escoger con cuidado, ya que de
otro modo el programa podría'no ser válido. En primer lugar, evidentemente
no se desea tener u = O, puesto que conduciría a un bucle infinito en caso de
colisión. En segundo lugar, es importante que M y u sean primos entre sí, para
evitar que algunas de las secuencias de exploración sean muy cortas (considé-
rese el caso M = 2u). Esto se garantiza fácilmente haciendo a M primo y a U <
M. En tercer lugar, la segunda función de dispersión debe ser «diferente» de la
primera, pues de lo contrario puede ocumr un agrupamiento ligeramente más
complicado. Una función como h2(k)= M - 2 - k mod (M - 2) producirá un
DISPERSIÓN 265
clave IDJE
ll
e
1D
I
E
1
IQJIujE
lmJAJ
lJ1I-q IMlrp1)I
dispersiónl: 5 10 5 13 16 12 15 4 5 2 1 O 17 1 5 4 1
dispersión2: 3 6 3 3 8 4 1 4 3 6 3 5 7 3 3 4 7
Figura 16.6 Función de doble dispersión (M = 19).
buen surtido de «segundos» valores de dispersión, pero quizás esto sea ir de-
masiado lejos, ya que, especialmente para claves grandes, el coste de calcular la
segunda función de dispersiónprácticamente dobla el de la búsqueda, para evi-
tar solamente algunas exploraciones para eliminar el agrupamiento. En la prác-
tica puede ser suficienteuna segunda función de dispersión mucho más simple,
como por ejemplo h2(k)= 8 - (kmod 8). Esta función sólo utiliza los últimos
tres bits de k; puede ser apropiado utilizar un mayor número de bits para una
tabla más grande, aunque el efecto,incluso si es notorio, no es probable que sea
significativo en la práctica.
Para las claves del ejemplo, estas funciones producen los valores de disper-
sión que se muestran en la Figura 16.6. La Figura 16.7 muestra la tabla que se
obtiene por la inserción sucesiva de estas clavesen una tabla inicialmente vacía
y utilizando la doble dispersión con estos valores.
El número medio de elementos examinados en una búsqueda con éxito es
ligeramente superior al de !
a exploración lineal para el mismo ejemplo: 34/ 17 =
2. Pero en una tabla más esparcida, hay muchos menos agrupamientos,tal como
se muestra en la Figura 16.8. En este ejemplo, hay dos veces menos agrupa-
mientos que en la exploración lineal (Figura 16.5), o, de forma equivalente, el
agrupamiento medio es dos veces más pequeño.
Propiedad 16.3 La doble dispersión utiliza menos exploraciones, por tkrinirio
medio, que la exploración lineal.
La fórmula exacta para el número medio de exploraciones que se hacen en la
técnica de doble dispersión con una función de doble dispersión ({indepen-
diente» es l/( 1 - a) para una búsqueda sin éxito y -in( 1 - a)/apara una bús-
queda con éxito. (Estas fórmulas son el resultado de un análisis matemático
profundo y aún no han sido verificadas para un a muy grande.) La segunda (y
más sencilla) de las dos funciones de dispersión de las antes recomendadas no
cumple con esto exactamente, pero puede ser válida, en especial si se utilizan
los suficientesbits para hacer que el número de valores posibles esté próximo a
M. En la práctica, esto significa que con la doble dispersión se puede utilizar
una tabla más pequeña para lograr los mismos tiempos de búsqueda que con la
exploración lineal: el número medio de exploracioneses inferior a cinco, en una
búsqueda sin éxito, si la tabla está llena a menos del 80 %, y para una búsqueda
con éxito si lo está a menos del 99 Yo..
Los métodos de direccionamientoabierto pueden no ser convenientesen una
266 ALGORITMOS EN C++
O 1 2 3 4 5 6 3 8 9 10 11 12 13 14 15 16 13 18
Figura 16.7 Doble dispersión.
DISPERSI~N 267
Figura 16.8 Doble dispersión en una tabla mas grande.
situación dinámica cuando se tiene que procesar un número imprevisiblede in-
sercionesy eliminaciones. En primer lugar jcuál debe ser el tamaño de la tabla?
De una forma u otra se deben hacer estimaciones de cuántas inserciones se es-
peran, pero el rendimiento se degrada rápidamente a medida que la tabla co-
mienza a llenarse. Una solución común para este problema es hacer una redis-
persicn en una tabla más grande, de la forma menos frecuente que sea posible.
En segundo lugar hay que tener precaución con la eliminación: un registro no
se puede eliminar tranquilamente de una tabla construida por medio de una ex-
ploración lineal o de doble dispersión. La razón es que las últimas inserciones
en la tabla puede que hayan saltado la posición de este registro y, una vez eli-
minado, las búsquedas terminarán en el hueco dejado por el registro eliminado.
Un medio de resolver este problema es tener otra clave especial que pueda ser-
vir de comodín para las búsquedas, pero que pueda ser identificada y recordada
como una posición vacía para las inserciones. Se observa que ni el tamaño de
la tabla ni la supresiónde elementospresentan problemas particulares en el caso
del encadenamiento separado.
Perspectiva
Los métodos presentados en lo anterior han sido analizados completamente y
es posible comparar su rendimiento con algún detalle. Las fórmulas proporcio-
nadas en el texto son la síntesis de los análisis detallados descritos por D. E.
Knuth en su libro sobre ordenación y búsqueda. Dichas fórmulas indican cómo
se degrada el rendimiento en el direccionamiento abierto cuando a tiende a 1.
Para M y N grandes, con una tabla llena en un 90 %, la exploración lineal ne-
cesitará alrededor de 50 exploraciones para una búsqueda sin éxito, en compa-
ración con las 10 de la doble dispersión. Pero en la práctica, no se debe dejar
jamás que una tabla de dispersión illegue a llenarse en un 90%! Para pequeños
valores del factor de carga, sólo serán necesariasalgunas exploraciones;si no es
posible lograr factores de carga pequeños, no se debe utilizar la técnica de dis-
persión.
La comparación de la exploración lineal y la doble dispersión con el enca-
denamiento separado es algo más complicada, ya que se dispone de menos me-
268 ALGORITMOS EN C++
mona en el método del direccionamiento abierto (pLzsto que no hay enlaces).
El valor de a debe modificarse, para tener esto en cuenta, en función del ta-
maño relativo de las claves y los enlaces. Esto significa que normalmente no será
justificable la elección del encadenamiento separado en lugar de la doble dis-
persión, por criterios de rendimiento.
La selección del mejor método de dispersión para una aplicación determi-
nada puede resultar muy dificil. Sin embargo, en una situación dada raramente
se necesita el mejor método y las distintas técnicas suelen tener características
de comportamiento similares mientras que los recursos de memoria no se fuer-
cen demasiado. En general, la mejor elección consiste en utilizar el método de
encadenamiento separado para reducir drásticamente el tiempo de búsqueda
cuando no se conoce por adelantado el número de registros a procesar (y se dis-
pone de un buen administrador de memoria) y utilizar la doble dispersión para
buscar en conjuntos de claves cuyo tamaño aproximado se puede predecir.
Se han desarrollado muchos otros métodos de dispersión que tienen aplica-
ción en situaciones especiales. Aunque no se puede entrar en detalles, se expo-
nen brevemente dos ejemplos para ilustrar la naturaleza de los métodos de dis-
persión especializados. Éstos, y muchos otros métodos, se describen
completamente en los libros de Knuth y Gonnet.
El primero de ellos, denominado dispersion ordenada, explota el orden de
una tabla de direccionamiento abierto. En la exploración lineal estándar, se de-
tiene la búsqueda cuando se encuentra una posición vacía de la tabla o un re-
gistro con una clave igual a la de búsqueda; en la dispersión ordenada, se de-
tiene la búsqueda cuando se encuentra un registro con una clave mayor o igual
que la clave de búsqueda (la tabla se debe haber construido hábilmente para ha-
cer este trabajo). Este método reduce el tiempo de una búsqueda infmctuosa
aproximadamente al mismo de una con éxito. (Éste es el mismo tipo de mejora
que se hace en el encadenamiento separado.) Este método es útil para aplicacio-
nes donde la búsqueda infructuosa sucede frecuentemente. Por ejemplo, un sis-
tema de tratamiento de texto puede tener un algoritmo para separar las palabras
que funcione bien para la mayona de las palabras, pero no para algunos casos
excepcionales (como «excepción»). Esta situación podría arreglarse buscando
todas las palabras en un diccionario de excepciones relativamente pequeño, que
contenga aquellas que deben utilizarse de forma especial, y así la mayor parte
de las búsquedas serán infructuosas.
De forma similar, existen métodos para desplazar algunos registros durante
una búsqueda sin éxito con el fin de hacer que las búsquedas con éxito sean más
eficaces. De hecho, R. P. Brent desarrolló un método en el que el tiempo medio
de búsqueda puede ser acotado por una constante, que es muy útil en aplicacio-
nes que implican frecuentes búsquedas con éxito en tablas muy grandes, tales
como los diccionarios.
Éstos son sólo dos ejemplos de un gran número de mejoras que se han pro-
puesto para la dispersión. Muchas de estasmejoras son interesantes y tienen im-
portantes aplicaciones. Sin embargo, se debe tener mucha precaución en la uti-
lización prematura de métodos avanzados, excepto por expertos que tengan
DICPERSI~N 269
serios problemas en aplicaciones de búsqueda, porque, en definitiva, el enca-
denamiento separado y la doble dispersión son simples, eficaces y bastante
aceptables en la mayoría de las aplicaciones.
En muchas aplicacioneses preferible la dispersión a las estructuras de irbo-
les binarios, porque es más simple y ofrece tiempos de búsqueda muy rápidos
(constantes), si hay espacio disponible para tablas lo suficientemente grandes.
Las estructuras de árbolesbinanos tienen la ventaja de ser dinámicas (no se ne-
cesita información previa sobre el número de inserciones), pueden garantizar el
rendimiento en el peor caso (todoslos elementos se pueden colocar en el mismo
lugar al igual que lo podría hacer el mejor método de dispersión) y permiten
una gran variedad de operaciones (la más importante, la función ordenar).
Cuando estos factores no son importantes, la dispersión es ciertamente el mé-
todo de búsqueda ideal.
Ejercicios
1. Describir cómo podría implementarse una función de dispersión haciendo
uso de un buen generador de números aleatonos. ¿Tendría sentido imple-
mentar un generador de números aleatonos utilizando una función de dis-
persión?
2. ¿Cuánto tiempo haría falta en el peor caso para insertar N claves en una
tabla inicialmente vacía, utilizando el método de encadenamientoseparado
con listas desordenadas?Responder a la misma pregunta pero con listas or-
denadas.
3. Dar el contenido de la tabla de dispersión que resulta cuando se insertan
las claves C U E S T 1 O N F A C I L en una tabla inicialmente vacía de
tamaño 13utilizando la exploración lineal. (Utilizarhi(k)= k mod 13para
la función de dispersión de la k-ésima letra del alfabeto.)
4. Dar el contenido de la tabla de dispersión que resulta cuando se insertan
las claves C U E S T I O N F A C I L en una tabla inicialmente vacía de
tamaño 13 utilizando la doble dispersión. (Utilizar el hi(& de la pregunta
anterior y h2(k)= 1 + (kmod 1I) para la segunda función de dispersión.)
5. Aproximadamente, ¿cuántas exploracionesdeben hacerse cuando se utiliza
la doble dispersión para construir una tabla de N claves iguales?
6. ¿Qué método de dispersión utilizaría para una aplicación en la que es po-
sible que estén presentes muchas claves iguales?
7
. Suponiendoque el número de elementos a insertar en una tabla de disper-
sión se conoce por adelantado. ¿Bajo que condiciones es preferible el mé-
todo del encadenamientoseparado a la doble dispersión?
8. Suponiendo que un programador tiene un error en un programa de doble
dispersión de modo que una de las dos funciones devuelve siempre el mismo
valor (distinto de O), describir lo que sucede en cada situación (cuando es
la primera función la que está mal y cuando lo es la segunda).
270 ALGORITMOS EN C++
9. ¿Qué función de dispersión debe utilizarse si se conoce por adelantado que
los valores de las claves pertenecen a un intervalo relativamente pequeño?
10. Hacer una crítica del algoritmo siguiente para suprimir en una tabla de dis-
persión construida por el método de exploración lineal. Explorar hacia la
derecha, desde el elemento que se va a eliminar (dando la vuelta si es ne-
cesario) hasta encontrar una posición vacía, después explorar hacia la iz-
quierda hasta encontrar un elemento con el mismo valor de dispersión. Fi-
nalmente reemplazar el elemento a suprimir por este último dejando vacía
su posición en la tabla.
17
Búsqueda por residuos
Varios métodos de búsqueda producen examinando las claves de búsqueda a
razón de un bit cada vez, en lugar de hacer comparaciones completas entre cla-
ves en cada paso. Estos métodos, denominados métodos de búsqueda por resi-
duos,trabajan con los bits de las propias claves, y no con las versiones transfor-
madas de las claves utilizadas en la dispersión. Al igual que los de ordenación
por residuos (ver el Capítulo lo), estos métodos pueden ser muy útiles cuando
los bits de las claves de búsqueda son fácilmente manipulables y los valores de
las claves están bien distribuidos.
La ventaja principal de los métodos de búsqueda por residuos es que pro-
porcionan un rendimiento razonable en el peor caso, sin las complicacionesde
los árboles equilibrados; también proporcionan un método fácil para utilizar
claves de longitud variable; algunos permiten incluso ganar espacio almace-
nando parte de la clave dentro de la estructura de búsqueda; y, finalmente, per-
miten un acceso muy rápido a los datos, compitiendo tanto con los árbolesbi-
narios de búsqueda como con la dispersión. Sus inconvenientes son que toda
inclinación en los datos puede provocar un mal rendimiento por degeneración
de los árboles (y todo dato compuesto por caracteres está inclinado) y que al-
gunos de estos métodos pueden malgastar inútilmente el espacio de la memoria.
Al igual que con la ordenación por residuos, estos métodos se diseñan para
aprovechar las características particulares de las arquitecturas de las computa-
doras: puesto que utilizan las propiedades digitales de las claves, es difícil, o im-
posible, hacer implementaciones eficaces en algunos lenguajes de alto nivel.
En este capítulo se examinarán una serie de métodos, de los que cada uno
comge un defecto inherente al anterior, y se terminará con un método impor-
tante que es bastante útil en aplicacionesde búsqueda en las que se trabaja con
claves de gran longitud. Además se estudiará el análogo de la «ordenación en
tiempo lineal» del Capítulo 10,una busqueda en «tiempoconstante» basada en
el mismo principio.
271
272 ALGORITMOS EN C++
Árboles de búsqueda digital
El método de búsqueda por residuos más simple es el de búsqueda digital: el
algoritmo es precisamente el mismo que el de búsqueda por árbol binario, ex-
cepto que el movimiento por las ramas del árbol no se hace de acuerdo con el
resultado de una comparación entre claves, sino con los bits de la clave. En el
primer nivel se utiliza el primer bit, en el segundo nivel se utiliza el segundobit,
y así hasta encontrar un nodo externo. El código es virtualmente el mismo que
el de la búsqueda por árbol binario. La única diferencia es que las claves son del
tipo c l avebi ts utilizado en la ordenación por residuos y se utiliza la función
b i t s , para tener acceso a los bits individuales, en lugar de las comparaciones
entre claves. (Se recuerda del Capítulo 1O que v. bits (k,j) son los j bits que
aparecen a k bits de distancia del extremo derecho de la representación binaria
de Y; esto se puede implementar eficazmente en lenguaje de máquina despla-
zando k bits hacia la derecha, y poniendo después a O todos los bits menos los
j más a la derecha.)
TipoInfo Dicc: :buscar(tipoEemento v)
s t r u c t nodo *x = cabeza;
i n t b = tipoElemento: :rnaxb;
z->clave = v;
while (v != x->clave)
{
x = (v.bits(b--, 1 ) ) ? x->der
r e t u r n x->info;
1
//Arb01 d i g i t a l
x->t izq ;
Las estructuras de datos de este programa son las mismas que las que se utili-
zaron en los árboles binarios de búsqueda elementales. La constante maxb es el
número de bits de las claves que se van a ordenar. El programa supone que el
primer bit de cada clave (el que está a (maxb+l) de la derecha) es O (tal vez la
clave sea el resultado de utiIizar b i t s con maxb como segundo argumento),.así
que la búsqueda comienza en cabeza, un enlace a un nodo de cabecera del ár-
bol con clave O, que posee un enlace izquierdo que apunta al árbol de búsqueda.
Así el procedimiento de inicialización para este programa es el mismo que para
el de búsqueda por árbol binario, excepto que se empieza con cabeza->i zq =
z en lugar de cabeza->der = z.
En el Capítulo 10 se vio que las claves iguales son un anatema en la orde-
nación por residuos: lo mismo sucede en la búsqueda por residuos, no en este
algoritmo en particular, pero sí en los que se examinarán más adelante. Así pues
en este capítulo se supone que todas las claves que aparecen en la estructura de
datos son distintas: si es necesario, se puede mantener una lista enlazada, para
cada valor de clave, de todos los registros cuyas claves tienen ese valor. Como
BÚSQUEDA POR RESIDUOS 273
E o 07-
o 1
J o 1 0 1 0
P o 1 1 0 1
L 1 0 0 0 0
M o i i o o
0 0 1 1 1 1
D o o i o o
B o o o i o
u 1 0 1 0 1
s i 0 0 1 1
Q i o o o i
Figura 17.1 Un árbol de búsqueda digital.
en los capítulos precedentes, se supone que la i-ésima letra del alfabeto se repre-
senta por medio de la representación binaria de cinco bits de i. Las claves del
ejemplo que se utilizarán en este capítulo se muestran en la Figura 17.1. Para
ser consistentes con bits, se considera que los bits están numerados del O al 4
y de derecha a izquierda. Así por ejemplo, el bit 1 es el único bit 1 (no cero) de
B y el bit 4 es el único bit 1 (no cero) de P.
El procedimiento de inserción para árboles de búsqueda digital se obtiene
directamente del procedimiento correspondiente para los árboles binarios de
búsqueda:
void Dicc: :insertar(tipoElemento v, tipoInfo info)
i
struct nodo *p, *x = cabeza;
int b = tipoElemento::maxb;
while (x != z)
p = x;
x = (v.bits(b--, 1)) ? x->der : x->izq;
{
1
x = new nodo;
x->clave = v; x->info = info; x->izq = z; x->der = z;
i f (v.bits(b+l, 1)) p->der = x; else p->izq = x;
1
El árbol construido por este programa, cuando las clavesdel ejemplo se insertan
en un árbol inicialmente vacío, se muestra en la Figura 17.1. La Figura 17.2
muestra lo que sucede al añadir una nueva clave Z = 11O1O al árbol de la Figu-
ra 17.1. Se debe ir por la derecha dos veces, porque los dos primeros bits de Z
274 ALGORITMOS EN C++
Figura 17.2 Inserción (de 2)en un árbol de búsqueda digital.
son 1, hasta donde se encuentra el nodo externo a la derecha de P, que es donde
se insertará Z .
El peor caso para árboles construidos con búsqueda digital es mucho mejor
que el de los árboles binarios de búsqueda, si el número de claves es grande y
no son largas.La longitud del camino más largo en un árbol de búsqueda digital
es el mayor número de bits sucesivos iguales de dos claves cualesquiera del ár-
bol, a partir del bit más a la izquierda, y esta cantidad es relativamente pequeña
en muchas aplicaciones (por ejemplo, si las claves están compuestas de bits
aleatorios).
Propiedad 17.1 Una búsqueda o inserción en un árbol de búsqueda digital,
construido sobre N claves de b bits aleatorios, necesita alrededor de 1gNcom-
paracionespor término medio y b comparaciones en el peor caso.
Es evidente que ningún camino será nunca más largo que el número de bits de
las claves:por ejemplo, un árbol de búsqueda digital construido a partir de cla-
ves de ocho caracteres, con seisbits por carácter, no tendrá ningún camino ma-
yor que 48,incluso si hay cientos de miles de claves. Demostrar que los árboles
de búsqueda digital están casi perfectamente equilibrados necesita un análisis
que va más allá del alcance de este libro, aunque este hecho confirma la noción
intuitiva de que el «siguiente» bit de una clave aleatona tiene la misma proba-
bilidad de comenzar por O que por 1, así que la mitad de las claves deben en-
contrarse a cada lado de un nodo dado. La Figura 17.3 muestra un árbol de
búsqueda digital construido a partir de 95 claves aleatonas de 7 bits. Este árbol
está bastante bien equi1ibrado.i
Así, los árboles de búsqueda digital ofrecen una alternativa atractiva a los de
búsqueda binana estándar, siempre y cuando la extracción de bits sea tan fácil
de hacer como la comparación entre claves (consideración que es dependiente
de la máquina).
BÚSQUEDA POR RESIDUOS 275
Figura 17.3 Un gran arb! de búsqueda digital.
Árboles de búsqueda por residuos
Es bastante frecuente que las claves de búsqueda sean muy largas y estén cons-
tituidas, quizás, por veinte caracteres o más. En tal situación, el costede la com-
paración de la igualdad entre la clave de búsqueda y una clave de la estructura
de datos puede ser preponderante y no debe ignorarse. La búsqueda por árbol
digital efectúa una tal comparación en cada nodo del Arbol; en esta sección se
verá que en la mayor parte de los casos es posible lograrlo con una sola com-
paración por búsqueda.
La idea es no almacenar claves en los nodos internos del árbol, sino poner
todas en los nodos externos. Esto es, en lugar de utilizar z para los nodos ter-
minales de la estructura, se ponen nodos que contienen las claves de búsqueda.
Así pues, se tienen dos tipos de nodos en la estructura: nodos internos, que sólo
contienen enlaces a otros nodos, y nodos terminales que contienen claves y no
enlaces.(Fredkin denominó a este método trie porque es útil para la extracción
(«retrieval»),palabra que suele pronunciarse (drai-b)o simplemente (drab). Para
buscar una clave en una estructura como ésta, es preciso moverse por las ramas
de acuerdo con sus bits, al igual que anteriormente, pero sin comparar la clave
con nada hasta que no se alcance un nodo externo. Cada clave del árbol se al-
macena en un nodo terminal del camino descrito por el conjunto de los pri-
meros bits de la clave y, como cada clave de búsqueda termina en un nodo ter-
minal, se necesita una comparación completa de las claves para terminar la
búsqueda.
La Figura 17.4 muestra el método trie de búsqueda por residuospara las cla-
ves E J M P L. Por ejemplo, para llegar a E, se parte de la raíz yendo primero a
la izquierda y luego otra vez a la izquierda, ya que los dos primeros bits de E
son 00; pero al contrario, como ninguna de las claves del método trie comienza
con los bits 11, al moverse en la dirección derecha-derecha se llega a un nodo
terminal. Antes de pensar en una inserción, el lector debe reflexionar sobre la
sorprendente propiedad de que la estructura trie es independiente del orden en
el que se insertan las claves: hay un único trie para cualquier conjunto dado de
claves distintas.
Como es habitual, después de una búsqueda sin éxito, se puede insertar la
276 ALGORITMOS EN C++
Figura 17.4 Un trie de árbol de búsqueda por residuos.
clave reemplazando el nodo terminal en el que terminó la búsqueda, a condi-
ción de que no contenga una clave. Éste es el caso cuando se inserta O en el trie
de la Figura 17.4, como se muestra en el primer trie de la Figura 17.5. Si el nodo
externo en el que termina la búsqueda contiene una clave, debe reemplazarse
por un nodo interno que contenga en los nodos externos por debajo de él, la
clave en cuestión y la clave en la que termina la búsqueda. Por desgracia, si es-
tas claves coinciden en más posiciones de bits, es necesario añadir algunos no-
dos terminales que no corresponden a clavesdel árbol (es decir, nodos internos
con un nodo terminal vacío como hijo). Esto es lo que sucede cuando se inserta
D, como se muestra en el segundo trie de la Figura 17.5. El resto de la Figura
17.5 muestra cómo se completa el ejemplo cuando se añaden las claves B U S
Q A.
La implementación de este método en C++ requiere algunos trucos por la
necesidad de mantener dos tipos de nodos sobre cada uno de los cuales podrían
apuntar los enlaces de los nodos internos. Éste es un ejemplo de un algoritmo
para el que una implementación de bajo nivel puede resultar más simple que
una representación de alto nivel. Se omite el código para este caso porque pos-
teriormente se verá una mejora que evita este problema.
El subárbol izquierdo de un trie de búsqueda por residuos contiene todas las
claves que tienen un O como bit más significativo y el subárboi derecho con-
tiene todas las claves que tienen un 1 como bit más significativo. Esto conduce
a una correspondencia inmediata con la ordenación por residuos: la búsqueda
por tne binano particiona el archivo exactamente de la misma forma que la or-
denación por intercambio de residuos. (Se puede comparar el trie anterior con
la Figura 10.1, el diagrama de partición de la ordenación por intercambio de
residuos, teniendo en cuenta que las claves son ligeramente diferentes.)Esta co-
rrespondencia es análoga a la que existe entre la búsqueda por árbol binario y
el Quicksort.
Propiedad 17.2 Una búsqueda o una inserción en un trie de búsqueda por re-
siduos necesita alrededor de 1gNcomparaciones de bits por Iérmino medio y b
comparaciones de bits en el peor caso, en un árbol construido a partir de N cla-
ves aleatorias de b bits.
BÚSQUEDA POR RESIDUOS 277
Figura 17.5 Construcción de un trie de búsquedapor residuos.
Como se hizo anteriormente, el resultado del peor caso se obtiene directamente
del algoritmo y para el caso medio se requiere un análisis matemático que so-
brepasa el alcance de este libro, aunque esta propiedad da validez a la noción
intuitiva de que cada bit examinado puede ser lo mismo un O que un 1, así que
aproximadamente la mitad de las claves deben encontrarse en cada lado de un
nodo del trie.i
Una característica molesta de los tries por residuos, que los distingue de los
otros tipos de árboles de búsqueda que se han visto, es la ramificación «unidi-
reccional» que se necesita para las claves con un gran número de bits iguales.
Por ejemplo, las claves que difieren solamente en el último bit necesitan un ca-
mino cuya longitud sea igual a la de la clave, independientemente del número
278 ALGORITMOS EN C++
Figura 17.6 Un gran trie de búsqueda por residuos.
de clavesque haya en el árbol. El número de nodos internos puede ser algo ma-
yor que el de claves.
Propiedad 17.3
ves aleatorias de b bits contiene alrededor de NAn2 = 1.44Nnodos de media.
Un trie de búsqueda por residuos construido a partir de N cla-
Una vez más, la demostración de esta afirmación va más allá del alcancede este
libro, aunque se puede verificar empíricamente. La Figura 17.6 muestra un tne
construido a partir de 95 claves aleatorias de 10bits, que tiene 131 nodos..
La altura de los tries se mantiene limitada por el número de bits de las cla-
ves, pero se podría considerarla posibilidad de procesar registros con claves muy
largas (1,000 bits o más) que quizás presenten cierta uniformidad, como puede
ser el caso de datos codificadospor caracteres. Una forma de acortar los cami-
nos de los árboles es utilizar mucho más de dos enlaces por nodo (aunque esto
puede agudizar el problema de «espacio» al utilizar demasiados nodos); otra
forma es ((colapsam los caminos que contienen ramas unidireccionales en en-
laces sencillos. Estos métodos se presentarán en las dos secciones siguientes.
Búsqueda por residuos multiple
En la ordenación por residuos se vio que se pueden obtener importantes mejo-
ras en la velocidad considerando varios bits a la vez. Esto es también cierto en
la búsqueda por residuos: examinando w1 bits a la vez, se puede aumentar la
velocidad en un factor 2". Sin embargo,hay una situación que impone algo más
de cuidado al aplicar esta idea, lo que no fue necesario en la ordenación por
residuos. El problema es que considerar m bits a la vez implica utilizar nodos
con M = 2" enlaces, lo que puede conducir a derrochar un volumen conside-
rable de espacio por los enlaces no utilizados.
Por ejemplo, si M = 4 el trie que se obtiene para las claves del ejemplo es el
que se muestra en la Figura 17.7.Para buscar en este trie, se consideran los bits
SÚSQUEDA POR RESIDUOS 279
Figura 17.7 Un trie por residuos de 4 vias.
de dos en dos: si los dos primeros bits son 00, entonces se sigue el enlace iz-
quierdo del primer nodo; si son O1, se sigueel segundo enlace;si son 1O, se sigue
el tercer enlace, y si son 11, el enlace derecho. La dirección para moverse por
las ramas hacia el siguiente nivel se obtiene de acuerdo con el tercero y cuarto
bits, etc. Por ejemplo, para buscar V = 10.110 en el trie de la Figura 17.7 se
sigue el tercer enlace de la raíz y luego el cuarto enlace del tercer hijo de la raíz,
para acceder a un nodo terminal, de modo que la búsqueda no tiene éxito. Para
insertar V, se debe reemplazar dicho nodo por uno nuevo que contenga a V (y
cuatro enlaces externos).
Es de destacar que hay un cierto despilfarro de espacio en este árbol por el
gran número de enlaces a nodos terminales que no se utilizan. A medida que
M crece este efecto se acentúa: esto conduce a que el número de enlaces utili-
zados sea de alrededor de MN/lnM para claves aleatorias. Por otra parte, éste es
un método de búsqueda muy eficaz: el tiempo de ejecución es de alrededor de
logMN.Se puede establecerun compromiso razonableentre la eficacia en tiempo
de los tries múltiples y la economía de espacio de otros métodos, utilizando un
método «híbrido» con un valor muy grande de M en lo alto (por ejemplo en los
dos primeros niveles) y un valor pequeño de M (oalgún método elemental) en
los niveles inferiores.Aquí, otra vez, las implementaciones eficaces de tales mé-
todos pueden ser muy complicadas, debido a la presencia de múltiples tipos de
nodos.
Por ejemplo, un árbol de dos niveles y 32 vías divide a las claves en 1.024
categorías, cada una accesible por medio de un descenso de dos niveles del ár-
boi. Esto sería bastante útil en archivos de miles de claves, porque posiblemente
existen (sólo)unas pocas claves por categoría. Por otro lado, un M pequeño se-
ría apropiado para archivos de cientos de claves, porque de lo contrario la ma-
yoría de las categorías estarían vacías y se gastaría demasiado espacio, y un M
grande sería aprcpiado para archivos con millones de claves, porque de lo con-
trario la mayoría de las categoríastendrían muchas claves y se perdería mucho
tiempo en las búsquedas.
Es asombroso comprobar que la búsqueda «híbrida» corresponde muy de
cerca a la forma en que los humanos buscan las cosas, por ejemplo, los nombres
en una guía de teléfonos. El primer paso es una decisión múltiple («Vamos a
ver, comienza con ‘A’»), seguida probablemente de alguna decisión de dos vías
(«Está antes de ‘Andrés’,pero después de ‘Aivar’)))y después de una búsqueda
280 ALGORITMOS EN C++
secuencia1 («‘Alfonso’... ‘Algora’...‘Algrano’... iNo, ‘Algoritmos’ no está en la
lista!))).Por supuesto, las computadorasestán posiblemente mejor dotadas que
los humanos para las búsquedas múltiples, así que son suficientes dos niveles.
’También las ramificaciones de 26 vías (incluso con más niveles) son una alter-
nativa bastante razonable a considerar para claves que estén compuestas sola-
mente por letras (por ejemplo, en un diccicnario).
En el próximo capítulo, se verá un método sistemático para adaptar la es-
tructura con el fin de obtener provecho de la búsqueda por residuos múltiples
en el caso de archivos de tamaño arbitrario.
Patricia
Como se ha señalado, el método de búsqueda trie por residuos tiene dos defec-
tos molestos: la «ramificación de una sola vía», que provoca la creación de no-
dos extra en el árbol, y la existencia de dos tipos diferentes de nodos, lo que
complica en cierta forma el código (en especial el de una inserción). D.R. Mo-
rrison descubrió una forma de evitar ambos problemas en un método que de-
nominó Patricia («PracticalAlgorithm ToRetrieve Information Coded In Alp-
hanumerim o (Algoritmo Práctico para Recuperar Información Codificada en
Alfanumérico))).El algoritmo que se da a continuación no es exactamente de la
misma forma que la presentada por Morrison, porque él estaba interesado en
aplicaciones de «búsqueda de cadenas» del tipo de las que se verán en el Capí-
tulo 19. En el contexto presente, Patricia permite la búsqueda de N claves de
longitud arbitraria en un árbol que tiene exactamenteN nodos, necesitando sólo
una comparación completa por búsqueda.
La ramificación cnidireccional se evita gracias a un simple recurso: cada nodo
contiene el índice del bit que se deberá comprobar para decidir qué camino to-
mar cuando se salga de este nodo. Se evitan los nodos terminales reemplazando
los enlaces hacia los nodos externos por enlaces que apuntan hacia niveles su-
periores del árbol, lo que lleva a la noción habitual de los nodos normales del
árbol, con una clave y dos enlaces. Pero en Patricia, las claves de los nodos no
se utilizan para controlar la búsqueda en el recorrido hacia abajo del árbol; sim-
plemente se almacenancomo referencia para cuando se alcance el fondo del ár-
bol. Para ver cómo funciona Patricia, se comienza por observar cómo opera en
un árbol tipo y luego examinarcómo se construye el árbol. El árbol Patricia que
se muestra en la Figura 17.8 se constmyó insertando sucesivamente las claves
del ejemplo.
Para buscar en este árbol, se comienza por la raíz y se desciende utilizando
el índice de bit de cada nodo para saber qué bit examinar en la clave de bús-
queda. Se sigue hacia la derecha si ese bit es 1 y hacia la izquierda si es O. Las
claves de los nodos no se examinan del todo en ese descenso. Tarde o temprano
se encuentra un enlace ascendente: cada uno de ellos apunta hacia la única clave
del árbol que contiene los bits que causarían una búsqueda que alcance tal en-
BÚSQUEDA POR RESIDUOS 281
4
3
Figura 17.8 Un árbol Patricia.
lace. Por ejemplo, S es la única clave del árbol que concuerdacon el patrón de
bits 1O* 11. Así que si la clave del nodo apuntado por el primer enlace ascen-
dente que se encuentre es igual a la clave buscada, la búsqueda tiene éxito, y en
caso contrario será infructuosa. En los tries, todas las búsquedas terminan en
nodos externos, así que se necesita una comparacióncompleta de la clave para
determinar cuándo una búsqueda tuvo éxito o no; para Patricia todas las bús-
quedas terminan en enlaces ascendentes, así que también se necesita una com-
paración completa de la clave para determinar si la búsqueda tuvo éxito o no.
Además, es fácil verificar si un enlace es ascendente o no, porque los índices de
bits de los nodos (por definición) decrecen a medida que se desciende por el ár-
bol. Esto conduce al siguiente código de búsqueda para Patricia, que es tan sim-
ple como el del árbol por residuos o el de la búsquedapor trie:
tipoInfo Dicc: :buscar(tipoElemento v) // Arbol Patricia
struct nodo *p, *x;
p = cabeza; x = cabeza->izq;
while (p->b ->b)
{
<
I
p = x;
x = (bits(v, x->b, 1 ) ) ? x->der : x->izq;
1
if (v != x->clave) return infoNIL;
return x->info;
Esta función encuentra el único nodo que podría contener el registro de clave
v, y después verifica si la búsqueda ha tenido éxito o no. Así, para buscar
Z=11O10en el árbol anterior, se sigue hacia la derecha, luego hacia la izquierda
y después otra vez a la derecha, encontrando el enlace ascendente que lleva a S;
así que la búsqueda no tiene éxito.
282 ALGORITMOS EN C++
Figura 17.9 Inserciónexterna en un árbol Patricia.
La Figura 17.9 muestra el resultado de insertar Z = 11010 en el árbol Patri-
cia de la Figura 17.8. Como se describió anteriormente, la búsqueda de Z ter-
mina en el nodo que contiene a S = 10011. Por la propiedad de definición del
árbol, S es la única clave del árbol para la que una búsqueda terminaría en ese
nodo. Si se inserta Z, habría dos nodos así que el enlace ascendente que se di-
rigía al nodo que contiene a S debe ponerse ahora a apuntar al nuevo nodo que
contiene a Z, con un bit de índice que corresponde ai punto más a la izquierda
en el que S y Z difieren y con dos enlaces ascendentes: uno apuntando a S y el
otro apuntando a Z. Esto corresponde precisamente a reemplazar en la inser-
ción tne por residuos el nodo terminal que contiene a S por un nuevo nodo
interno con S y Z como hijos, eliminando una ramificación de una vía por la
inclusión del bit de índice.
La inserción de G = O0111 ilustra un caso más complicado, como se mues-
tra en la Figura 17.1O. La búsqueda de G termina en E = O
0101, indicando que
E es la única clave del árbol con el patrón 001*1. Ahora, G y E difieren en el
bit 1, una posición que se saltó durante la búsqueda. El requisito de que el bit
de índice decrezcaa medida que se desciendepor el árbol exige que G se inserte
entre D y E, con un autoapuntador hacia arriba que corresponde a su propio
Figura 17.10 Insercióninterna en un árbol Patricia.
BÚSQUEDA POR RESIDUOS 283
bit 1. En este árbol destaca el hecho de que el haberse saltado el bit 3 en el sub-
árbol derecho implicase U, S y Q tienen el mismo valor en el bit 3.
Estos ejemplos ilustran los dos únicos casos que se pueden presentar en una
inserción en Patricia. La implementación siguiente da los detalles del proceso:
void Dicc: :insertar(tipoElemento v, t i p o I n f o i n f o )
s t r u c t nodo *p, *t, *x;
i n t i = maxb;
p = cabeza; t = cabeza->izq;
while (p->b > t->b)
i f ( v == t->clave) return;
w h i l e (bits(t->clave, i,1) == b i t s ( v , i , 1)) i--;
p = cabeza; x = cabeza->izq;
while (p->b > x->b && x->b > i )
t = new nodo;
t->clave = v; t->info = i n f o ; t->b = i ;
t->izq = ( b i t s ( v , t->b, 1)) ? x : t;
t->der = ( b i t s ( v , t->b, 1)) ? t : x;
if ( b i t s ( v , p->b, 1 ) ) p->der = t ; else p->izq = t;
{
{ p = t; t = ( b i t s ( v , t->b, 1 ) ) ? t->der : t->izq; }
{ p = x; x = ( b i t s ( v , x->by 1)) ? x->der : x->izq; }
1
(Este código supone que cabeza se inicializa con un campo clave igual a O, un
índice de bit en maxb y dos enlaces autoapuntando a cabeza.) Primero se hace
una búsqueda hasta encontrar la clave que se debe distinguir de v. Las condi-
ciones x-> b <= iy p->b <= x-> b caracterizan las situaciones que se muestran
en las Figuras 17.10 y 17.9, respectivamente. A continuación se determina la
posición del bit más a la izquierda a partir del cual difieren las claves, descen-
diendo por el árbol hasta ese punto e insertando un nuevo nodo que contenga
a v.
Patricia es la quintaesencia de los métodos de búsqueda por residuos: per-
mite la identificación de los bits que distinguen las claves de búsqueda y los or-
ganiza dentro de una estructura de datos (sin nodos sobrantes) que conduce rá-
pidamente, a partir de cualquier clave de búsqueda, a la única clave de la
estructura de datos que pueda ser igual a aquélla. Evidentemente, la misma téc-
nica utilizada en Patricia puede utilizarse en una búsqueda trie por residuos bi-
nana para eliminar las ramificaciones de una sola vía, pero esto no hace más
que aumentar el problema de los múltiples tipos de nodos. La Figura 17.11
muestra el árbol Patricia para las mismas claves utilizadas para construir el trie
de la Figura 17.6. Este árbol no sólo contiene un 44% de nodos menos, sino que
está mejor equilibrado.
284 ALGORITMOS EN C++
Figura 17.11 Un gran árbol Patricia.
A diferencia de la búsqueda estándar por árbol binario, los métodos por re-
siduos no son sensibles al orden en el que se insertan las claves: dependen so-
lamente de la estructura de las claves en sí mismas. Para Patricia la colocación
de los enlaces ascendentes depende del orden de inserción, pero la estructura
del árbol depende sólo de los bits de las claves, como en los otros métodos. Así
que incluso Patricia podría tener problemas con un conjunto de claves como
O01, O001, O0001, O00001, etc., pero para conjuntos normales de claves, el ár-
bol debe estar relativamente bien equilibrado, por tanto, el número de inspec-
ciones de bits, aun para claves muy grandes, será aproximadamenteproporcio-
nal a 1gNcuando hay N nodos en el árbol.
Propiedad 17.4 Un trie Patricia construido a partir de N claves aleatorias de b
bits tiene N nodos y necesita 1
g
Ncomparaciones de bits para una búsqueda me-
dia.
Al igual que para los otros métodos de este capítulo, el análisis del caso medio
es bastante difícil: resdta que Patricia implica una comparación menos, como
media, que en el caso de una búsqueda en un trie estándar.=
La caracteristica más útil de la búsqueda trie por residuos es que se puede
realizar eficazmente con claves de longitud variable. En todos los otros métodos
de búsqueda que se han visto, la longitud de la clave está de alguna forma «in-
tegrada) en el procedimiento de búsqueda, por lo que el tiempo de ejecución
depende de la longitud de las claves, así como de su número. Las posibles ga-
nancias que se puedan lograr dependen del método de acceso a los bits que se
utilice. Por ejemplo, suponiendo que se dispone de una computadora que puede
acceder eficazmente a datos en «octetos» de 8 bits, y que se necesita buscar en-
tre cientos de claves de 1.000 bits, entonces Patricia necesitaría acceder sola-
mente a alrededor de 9 o 10 octetos de la clave de la búsqueda, más una com-
paración de igualdad de 125 octetos, mientras que la dispersión necesitaría
acceder a los 125 octetos de la clave para calcular la función de dispersión, más
algunas comparaciones de igualdad, y los métodos basados en comparaciones
necesitarian vanas comparaciones de gran longitud. Esta propiedad convierte a ~
Patricia (o a la búsqueda por residuos trie sin ramificacionesde una sola vía) en
el método de búsqueda a escoger cuando las claves sean muy largas.
BÚSQUEDA POR RESIDUOS 285
Ejercicios
1. Dibujar el árbol de búsqueda digital que se obtiene al insertar las claves C
U E S T I O N F A C I L, en este orden, en un árbol inicialmente vacío.
2. Generar un árbol de búsqueda digital de 1.O00 nodos y comparar su altura
y el número de nodos de cada nivel con los de un árbol de búsqueda bina-
rio estándar y con los de un árbol rojinegro (Capítulo 15) construidos sobre
las mismas claves.
3. Encontrar un conjunto de 12 claves que generen un árbol de búsqueda di-
gital particularmente mal equilibrado.
4. Dibujar el árbol de búsqueda por residuos que se obtiene al insertar las cla-
ves C U E S T I O N F A C I L, en este orden, en un árbol inicialmente
vacío.
5. Un inconveniente de una búsqueda por residuos múltiple de 26 vías es que
algunas letras del alfabeto se utilizan muy poco. Sugerir una forma de re-
solver este problema.
6. Describir una forma de suprimir un elemento de un árbol de búsqueda por
residuos múltiple.
7. Dibujar el árbol Patricia que se obtiene al insertar las claves C U E S T I O
N F A C I L, en este orden, en un árbol inicialmente vacío.
8. Encontrar un conjunto de 12 claves que generen un árbol Patricia particu-
lamente mal equilibrado.
9. Escribir un programa que imprima todas las clavesde un árbol Patricia que
tengan los mismos t bits iniciales que la clave de búsqueda.
18. ¿Para cuál de los métodos por residuos es razonable escribir un programa
que imprima las claves ordenadas? ¿Qué métodos no son aconsejablespara
esta operación?
Algoritmos en C++.pdf
18
Búsqueda externa
Los algoritmos de búsqueda adaptados para acceder a los elementos de archivos
muy grandes tienen una inmensa importancia práctica. La búsqueda es la ope-
ración fundamental en los grandes archivos de datos, que consume una parte
muy significativa de los recursos utilizados en muchos sistemas informáticos.
En este capítulo se centrará el interés sobre todo en los métodos de bús-
queda en grandes archivos en disco, puesto que la búsqueda en disco es la de
mayor interés práctico. Con dispositivos secuenciales como las cintas, la bús-
queda se transforma rápidamente en un método trivial y lento: para buscar un
elemento en una cinta no se puede hacer otra cosa que instalarla y leerla hasta
encontrar el elemento. Notablemente, los métodos que se estudiarán aquí pue-
den encontrar un elemento en un disco de una capacidad de hasta un millón de
palabras con sólo dos o tres accesos al disco.
Al igual que en la ordenación externa, el aspecto «sistema»,unido a la uti-
lización de materiales complejos de E/S, es un factor decisivo en el rendimiento
de los métodos de búsqueda externa, pero no será posible estudiarlo con gran
detalle. Sin embargo, a diferencia de la ordenación, donde los métodos externos
son realmente muy diferentes de los internos, se verá que los métodos de bús-
queda externa son prolongaciones lógicas de los métodos que se han estudiado
para la búsqueda interna.
La búsqueda es una operación fundamental en los dispositivosde disco. Los
archivos se organizan por lo general de forma que se aprovechen las caracterís-
ticas particulares de los dispositivos para permitir un acceso a la información
tan eficaz como sea posible. Como se hizo con la ordenación, se trabajará con
un modelo algo simple e impreciso de dispositivosde «discos» con el objeto de
exponer las características principales de los métodos fundamentales. La deter-
minación de cuál es el mejor método de búsqueda externa para una aplicación
en particular es extremadamente complicada y depende mucho de las caracte-
rísticas del material (y del software de los sistemas),y por lo tanto está fuera del
alcance de este libro. Sin embargo, es posible sugerir algunas concepciones ge-
nerales a utilizar.
287
288 ALGORITMOS EN C++
En muchas aplicaciones,con frecuencia se desea poder cambiar, añadir, eli-
minar o (lo más importante) acceder rápidamente a algunos bits de informa-
ción de archivos muy grandes. En este capítulo se examinaránalgunos métodos
para tales situaciones dinámicas, que ofrecen sobre los métodos directos el
mismo tipo de ventajas que la búsqueda por árbol binario y la dispersión ofre-
cen sobre la búsqueda binaria y la secuencial.
Todo gran conjunto de información que se ha de procesar por medio de una
computadora se denomina base de datos. Se ha realizado gran cantidad de es-
tudios para construir, mantener y utilizar bases de datos. Sin embargo, las gran-
des bases de datos tienen una inercia muy elevada: una vez que se ha construido
una de ellas alrededor de una determinada estrategia de búsqueda resulta muy
costoso reconstruirla para otra. Por esta razón, los antiguos métodos estáticosse
utilizan ampliamentey quizá se mantengan mucho tiempo, aunque se están co-
menzando a utilizar nuevos métodos dinámicosen las bases de datos más mo-
dernas.
Por lo regular, las aplicaciones de gestión de bases de datos permiten ope-
raciones mucho más complicadas que la simple búsqueda de un elemento por
medio de una clave. A menudo las búsquedas se apoyan en criterios que impli-
can más de una clave y que devuelven un gran número de registros. En los ú1-
timos capítulos se verán algunosejemplos de algoritmos adaptados a las peticio-
nes de búsqueda de este tipo, pero las peticiones de búsqueda suelen ser lo
suficientemente complejas como para que sea normal hacer una búsqueda se-
cuencial sobre toda la base de datos, evaluando cada registro para ver si satis-
face los criterios.
Los métodos que se presentan en este capítulo son de importancia práctica
en la implementaciónde sistemas de grandes archivos en los que cada uno tiene
un identificador único, con el objeto de permitir el acceso, las insercionesy eli-
minaciones, basados en dicho identificador. En el modelo que se va a tratar se
considerará que el espacio de almacenamientoen disco está dividido en púgi-
nas, bloques contiguos de información a las que los mecanismosdel disco pue-
den acceder eficazmente. Cada página contendrá muchos registros que se deben
organizar dentro de ellas de tal forma que se pueda llegar a cualquier registro
leyendo solamente algunas páginas. Se supone que el tiempo de E/S necesario
para leer una página domina totalmente al tiempo de procesamiento que se re-
quiere para hacer cualquier cálculo sobre la información que contiene la pá-
gina. Como se mencionó con anterioridad, este modelo está muy simplificado
en ciertos aspectos, pero refleja bastante las característicasde los dispositivosac-
tuales de almacenamiento externo como para permitir valorar alguno de los
metodos fundamentales utilizados.
BÚSQUEDA EXTERNA 289
Figura 18.1 Acceso secuencial.
Acceso secuencial indexado
La búsqueda secuencial en disco es la extensión natural de los métodos de bús-
queda secuencial elementales que se estudiaron en el Capítulo 14: Íos registros
se almacenan en orden creciente de sus claves y las búsquedas se efectúan sim-
plemente leyendo los registros uno tras otro hasta encontrar uno que tenga una
clave mayor o igual que la buscada. Por ejemplo, si las claves de búsqueda son
E J E M P L O D E B U S Q U E D A E X T E R N A y se dispone de discos
capaces de contener tres páginas de cuatro registros cada una, entonces se ob-
tiene la configuración que se muestra en la Figura 18.1. (Al igual que para la
ordenación en memoria externa, se deben considerar pequeños ejemplos para
entender los algoritmos y ejemplos muy grandes para apreciar su rendimiento.)
Evidentemente, la búsqueda secuencialpura no es atractiva porque, por ejem-
plo, buscar W en la Figura 18.1 requeriría leer todas las páginas.
Para mejorar la velocidad de las búsquedas, se puede mantener para cada
disco un dndice» que establezcaqué clavespertenecen a las páginas de ese disco,
como en la Figura 18.2.La primera página de cada disco es su índice: las letras
pequeñas indican que sólo se almacena el valor de la clave, no el registro com-
pleto, y los números pequeños son los índices de páginas (O indica la primera
página del disco, 1 la siguiente, etc.). En el índice, cada número de página apa-
rece debajo del valor de la última clave de la página anterior. (El espacio en
blanco es una clave centinela, menor que todas las otras, y el a+» significa
(consultar el disco siguiente».)Así que, por ejemplo, el índice del disco 2 indica
que su primera página contiene los registros con claves entre E y J, inclusive,y
su segunda página los de claves entre J y O, inclusive. Por lo regular, es posible
mantener muchas más claves e índices de páginas en una página de índices que
registros en una página de «datos»; de hecho, el índice de un disco completo
necesita sólo algunas páginas.
Figura 18.2 Acceso secuencial indexado.
290 ALGORITMOS EN C++
Para acelerar aún más la búsqueda, estosíndices pueden estar acopladoscon
un ((índicemaestro» que establezca qué claves están en qué discos. En el ejem-
plo, el índice maestro diría que el disco 1 contiene las claves menores o iguales
que E, el disco 2 las claves menores o iguales que O (pero no menores que E) y
el disco 3 contiene las claves menores o iguales que X (pero no menores que P).
El índice maestro es posiblemente tan pequeño como para tenerlo fijo en me-
moria, de modo que la mayoría de los registros se pueden encontrar accediend?
sólo a dos páginas, una para el índice del disco apropiado y una para la página
que contiene el registro apropiado. Por ejemplo, una búsqueda de W implicaría
primero la lectura de la página de índices del disco 3 y luego la lectura de la
segunda página de datos del disco 3, que es la única que podría contener a W.
Las búsquedas de las clavesque aparecen en el índice necesitan la lectura de tres
páginas: la del índice más las dos páginas que flanquean al valor de la clave del
índice. Si no hay claves duplicadas en el archivo se puede evitar el acceso a la
página extra. Por otro lado, si hay muchas claves iguales en el archivo, pueden
ser necesarios varios accesos a las páginas (registros con claves iguales pueden
llenar varias páginas).
Puesto que esto combina una organización secuencial de las claves con un
acceso indexado, esta técnica se denomina acceso secuencial indexado. Éste es
el método a escoger para aplicacionesen las que los cambios en la base de datos
son poco frecuentes.
El inconveniente de utilizar el acceso secuencialindexado es que resulta muy
rígido. Por ejemplo, para añadir C a la configuración anterior se necesita que la
base de datos se reconstruya prácticamente, con nuevas posiciones para la ma-
yor parte de las claves y nuevos valores para los índices.
Propiedad 18.1 Una búsqueda en un archivo secuencial indexado necesita sólo
un número constante de accesos al disco, pero una inserción puede implicar
reorganizar el archivo completo.
De hecho, la ((constante))en cuestión depende del número de discos y del ta-
maño relativo de los registros, los índices y las páginas. Por ejemplo, un gran
archivo de claves de una sola palabra no podría estar almacenado en un solo
disco de modo que permitiera la búsqueda con un número constante de acce-
sos. O, para tomar otro ejemplo absurdo en el extremo opuesto, un gran nú-
mero de discos muy pequeños, capaces de contener cada uno un solo registro,
harían también difícil la búsqueda.=
Árboles B
Una forma mejor de efectuar la búsqueda en situaciones dinámicas es utilizar
árboles equilibrados. Con objeto de reducir el número de los accesos al disco
(que son relativamente caros), es razonable permitir un gran número de claves
BÚSQUEDA EXTERNA 291
Figura 13.3 Un árbol B.
por nodo, lo que provoca que los nodos tengan un alto grado de ramificación.
Tales árboles fueron denominados árboles B por R. Bayer y E. McCreight, que
fueron los primeros en considerar el uso de árboles equilibrados múltiples para
las búsquedas en memoria externa. (Mucha gente reservael término árbol B para
describirla estructura de datos construida por el algoritmo que sugirieronBayer
y McCreight; aquí se utilizará este nombre como un término genérico que sig-
nifica ((árbolesequilibrados externos)).)
El algoritmo descendente que se utilizó para los árboles 2-3-4 (ver el Capí-
tulo 15) se generaliza fácilmente para manipular más claves por nodo: supón-
gase que existe un valor cualquiera entre 1 y M-1 de claves por nodo (y por
tanto de 2 a M enlacespor nodo). La búsqueda se lleva a cabo en forma análoga
a la de los árboles 2-3-4: para desplazarse de un nodo al siguiente, primero se
debe encontrar el intervalo apropiado de la clave de búsqueda en el nodo en
curso y entonces salir a través del enlace correspondientehacia el siguientenodo.
Se continúa de esta manera hasta qde se alcance un nodo terminal, insertando
la nueva clave en el último nodo interno alcanzado. Al igual que en los árboles
2-3-4 descendentes, es necesario adividim los nodos que están «llenos» que se
encuentran al descender por el árbol: cada vez que se encuentre un k-nodo aso-
ciado con un M-nodo, se reemplaza por un (k+l)-nodo asociado a dos (M/2)-
nodos (se supone que M es par). Esto garantiza que cuando se alcance el fondo
habrá espacio para insertar el nuevo nodo.
El árbol B construido con M = 4 para el ejemplo del conjunto de claves se
muestra en la Figura 18.3. Este árbol tiene 13 nodos, que corresponden cada
uno a una página de disco. Cada nodo puede contener enlaces y registros. El
escoger M = 4,aun cui )do conduce a los familiaresárboles 2-3-4, se hace para
resaltar este punto: anteriormente se podían fijar cuatro registros por página;
ahora sólo se fijarán tres para dejar espacio a los enlaces. La cantidad total de
espacio utilizado depende del tamaño relativo de los registros y los enlaces. Pos-
teriormente se verá un método qUe evita esta mezcla de registros y enlaces.
Al igual que se mantiene en memoria el índice maestro en la búsqueda se-
cuencial indexada, también es razonable mantener en memoria el nodo raíz del
árbol B. Para el árbol B de la Figura 18.3, esto permitirá saber que la raíz del
subárbol que contiene los registros con claves menores que E están en la página
O del disco 1, la raíz del subárbol con claves menores que M (pero no menores
que E) está en la página 1 del disco 1, y la raíz del subárbol con claves mayores
292 ALGORITMOS EN C++
Figura 18.4 Acceso al árbol B.
o iguales que M está en la página 2 del disco 1. Los otros nodos del ejemplo
están almacenados como se muestra en la Figura 18.4.
En este ejemplo los nodos se asignan a las páginas del disco recomendo el
árbol de arriba hacia abajo y de derecha a izquierda en cada nivel, asignando
nodos al disco 1, luego al disco 2, etc. Se evita almacenar enlaces nulos si-
guiendo la pista del lugar donde se alcanza el nivel del fondo: en este caso todos
los nodos de los discos 2, 3 y 4 tienen todos los enlaces nulos (los cuales no ne-
cesitan almacenarse).En una aplicación real entran enjuego otras consideracio-
nes. Por ejemplo, pudiera ser mejor evitar que todas las búsquedas tengan que
ir a través del disco 1, comenzandola asignación por la página O de cada disco,
etc. De hecho se necesitan estrategias más sofisticadasdebido a la dinámica de
la construcción de los árboles (considéresela dificultad de implementaruna m-
tina de dividir que respete cualquiera de las estrategias anteriores).
Propiedad 18.2 Una búsqueda o una inserción en un árbol B de orden M con
N registros no necesita más de logMI2Naccesos al disco, lo que representa una
constante en situaciones prácticas (mientras que M no sea demasiadopequeño).
Esta propiedad se deduce de la observación de que todos los nodos del intenor
de un árbol B (nodos diferentes de la raíz o las hojas) tienen entre M/2 y M
claves, puesto que se han formadoa partir de una división de un nodo completo
con M claves, y cuyo tamaño sólo puede crecer (cuando se divide un nodo in-
ferior). En el peor caso, estos nodos forman un árbol completo de grado M/2,
que conduce directamente a la cota establecida..
Propiedad 18.3
torios contiene aproximadamente 1,44N/Mnodos.
UnárbolB de ordenM construido a partir de N registros alea-
La demostración de esta afirmación sobrepasa el marco de este libro, pero se
puede denotar que la cantidad de espacio perdido llega hasta N, en el peor caso,
cuando todos los nodos están medio 1lenos.i
En el ejemplo anterior ha sido forzosa la elección de M = 4por la necesidad
de guardar espacio para los enlaces en los nodos. Pero se acaba no utilizando
BÚSQUEDA EXTERNA 293
Figura 18.5 Un árbol B con registros sólo en los nodos externos.
enlaces en la mayoría de los nodos, ya que la mayor parte de los nodos de un
árbol B son terminales y la mayor parte de los enlaces son nulos. Además, se
puede utilizar un valor mucho mayor de M en los niveles más altos del árbol si
se almacenan sólo las claves (no los registros completos) en los nodos internos,
como en el acceso secuencia1 indexado. Para comprender cómo sacar partido
de estas observaciones en el ejemplo, se supone que se pueden fijar hasta siete
claves y ocho enlaces por página, de modo que se puede utilizar A
4 = 8 para los
nodos internos y M = 5 para los nodos del nivel del fondo (noA
4 = 4porque en
el fondo no se necesita reservar espacio para los enlaces).Un nodo del fondo se
divide cuando se le añade un quinto registro (en un nodo con dos registros y un
nodo con tres registros); la división termina al <unsertan>
la clave del registro
intermedio en el nodo del nivel superior, donde hay espacio porque el árbol su-
perior ha operado como un árbol B normal con A
4 = 8 (sobre las claves alma-
cenadas, no sobre los registros).Esto conduce al árbol que se muestra en la Fi-
gura 18.5.
El efecto en una aplicación típica es posiblemente mucho más notorio, puesto
que el factor de ramificación del árbol crece aproximadamente en la relación
del tamaño del registro con el tamaño de la clave, lo que es susceptible de ser
grande. También, con este tipo de organización, el <&dice» (que contiene cla-
ves y enlaces) puede separarse de los registros reales, como en la búsqueda se-
cuencial indexada. La Figura 18.6 muestra cómo se puede almacenar el árbol
de la Figura 18.5: el nodo raíz está en la página O del disco 1 (hay espacio para
ello, puesto que el árbol de la Figura 18.5 tiene un nodo menos que el árbol de
la Figura 18.3), aunque en la mayoría de las aplicaciones probablemente se
guarde en memona, como se hizo antes. Todos los comentarios previos que tie-
Figura 18.6 Acceso a un árbol B con registros sólo en los nodos externos.
294 ALGORITMOS EN C++
nen que ver con la ubicación de los nodos en los discos son también de aplica-
ción aquí.
Ahora se tienen dos valores de M, uno para los nodos internos, que deter-
mina el factor de ramificación del árbol (MJ,y otro para los nodos del fondo
del árbol, que determina la asignación de registros a las páginas (MB).Para mi-
nimizar el número de acceso al disco, es preciso hacer que M iy MBsean tan
grandescomo se pueda, aunque esto suponga cálculosadicionales.Por otra parte,
no se desea hacer a M idemasiado grande, porque la mayoría de los nodos del
árbol estarían muy vacíos y se malgastaría el espacio, y no se desea hacer a MB
demasiado grande, porque esto conduciría a una búsqueda secuencia1de los no-
dos del fondo. Habitualmente, lo mejor es hacer a M I y a MBdel tamaño de
una página. La elección obvia de MBes el número de registros que puede con-
tener una página (más uno): el objetivo de la búsqueda es encontrar la página
que contiene el registro deseado. Si se toma M I como el número de claves que
se pueden poner entre dos y cuatro páginas, entonces el árbol B posiblemente
tenga sólo tres niveles de profundidad, incluso en archivos muy grandes (un ár-
bol de tres niveles con M , = 2.048 puede resolver hasta 1.0243,o más de mil
millones, de entradas,) Pero es preciso recordar que el nodo raíz del árbol, al
que se accede para toda operación sobre el árbol, se guarda en memoria, lo que
significa que sólo se necesitan dos accesos al disco para encontrar cualquier ele-
mento del archivo.
Como se mencionó brevemente al final del Capítulo 15, con frecuencia se
utiliza un método más complicado de inserción «ascendente» para los árboles
B (aunque la discusión entre los métodos descendentes y los ascendentespierde
importancia cuando se trata de árboles de tres niveles). En términos técnicos,
los árboles descritos aquí deben calificarse como árboles B «descendentes»para
distinguirlosde los utilizados comúnmente en la literatura especializada. Se han
descrito muchas otras variantes para la búsqueda externa, algunas de ellas muy
importantes. Por ejemplo, cuando se llena un nodo, la división (y los nodos se-
mivacíos resultantes) puede anticiparse desplazando una parte de las claves del
nodo hacia su nodo «hermano» (si no está demasiado lleno). Esto conduce a
una mejor utilización del espacio interior de los nodos, lo que es probablemente
uno de los temas más importantes en aplicacionesde búsqueda en disco a gran
escala.
Dispersión extensible
Una alternativa a los árboles €3, que prolonga los algoritmos de búsqueda digital
para aplicarlos en la búsqueda externa, se desarrolló en 1978 por R. Fagin, J.
Nievergelt, N. Pippenger y R. Strong. Este método, denominado dispersión ex-
tensible,implica dos accesos al disco en cada búsqueda en aplicaciones típicas,
mientras que al mismo tiempo permite una inserción eficaz.Al igual que en los
árboles B, los registros se almacenan en páginas que, cuando se llenan, se divi-
BÚSQUEDA EXTERNA 295
Disco 1
Disco 2 m m
Figura 18.7 Dispersión extensible:primera pagina.
den en dos partes; como en el acceso secuencial indexado, se mantiene un ín-
dice al que se accede para encontrar la página que contiene los registros que
concuerdan con la clave de búsqueda. La dispersión extensible combina estas
ideas mediante la utilización de las propiedades digitales de las claves de bús-
queda.
Para ver cómo funciona la dispersión extensible,considéresela forma en que
trata las inserciones sucesivas de las claves del conjunto D I S P E R S I O N E
X T E N S I B L E, utilizando páginas con capacidad de hasta cuatro registros.
Se comienza con un «índice» con una sola entrada, un puntero a la página que
va a contener los registros. Los cuatro primeros registros caben en la página,
creando la estructura trivial que se muestra en la Figura 18.7.
El directorio del disco 1 indica que todos los registros están en la página O
del disco 2, donde se mantienen ordenados por sus claves. Se muestra el valor
binario de las claves, utilizando la codificación estándar de cinco bits que con-
siste en la representación binaria de i con la i-ésima letra del alfabeto. Ahora la
página está llena y se debe dividir para poder añadir la clave E = O
01O1. La es-
trategia es simple: se ponen los registros cuyas claves comienzan por O en una
página y aquellos cuyas claves comienzan por 1 en otra. Esto requiere duplicar
el tamaño del directorio y colocar parte de las claves de la página O del disco 2
en la nueva página, formando la estructura que se muestra en la Figura 18.8.
Ahora se pueden añadir R = 10010,S= 10011 e I = 01001, pero la primera
página sigue llena, como se muestra en la Figura 18.9. Se necesita otra división
antes de añadir O = O1111, y a continuación se procede de la misma forma que
en la primera división, dividiendo la primera página en dos partes, una para las
claves qiie comienzan por O
0 y otra para las que comienzan por OI . Lo que no
Figura 18.8 Dispersión extensible:división del directorio.
296 ALGORITMOS EN C++
Disco I
n
Disco2
Figura 18.9 Dispersiónextensible: primera paginaotra vez llena.
queda claro de inmediato es qué hacer con el directorio. Una alternativapodría
ser simplemente añadir otra entrada, un puntero a cada página. Esto no es muy
interesante porque esencialmente conduce a la busqueda secuencia1 indexada:
el directorio tiene que recorrerse secuencialmente durante cada búsqueda para
encontrar la página apropiada. Alternativamente, se puede duplicar otra vez el
tamaño del directorio, para obtener la estructura que se muestra en la Figura
18.10. Una nueva página (la página 2 del disco 2) contiene las claves que co-
mienzan por O1 (I y O),la página a dividir (página O del disco 2) contiene ahora
las claves que comienzan por O
0 (X, D y E), y la página que contiene las claves
que comienzan por 1 (P, R, y S) no se ha transformado, aunque ahora hay dos
punteros dirigidoshacia ella, uno para indicar que las claves que comienzanpor
10están almacenadas allí, el otro para indicar que las clavesque comienzan por
11 están almacenadas 211í también. Ahora es posible acceder a cualquier registro
utilizando los dos primeros bits de su clave para consultar directamente la en-
trada del directorio que proporciona la dirección de la página que contiene al
registro.
Mantener los registros ordenados dentro de la página puede parecer una
simplificación por fuerza bruta, pero se recuerda que la hipótesis inicial es que
[ Disco 1
Disco2 -
1 m l
Figura 18.10 Dispersiónextensible: segunda división.
B~SQUEDAEXTERNA 297
Disco 1 -Irn
Disco 2 l
m IpIR[s7sl (ilrlNlol
Disco 3 m I
Figura 18.11 Dispersiónextensible: tercera división.
se hace la E/S de disco en unidades de páginas y que el tiempo de procesa-
miento es despreciablecomparado con el tiempo de entrada o de salida de una
página. De modo que mantener los registros ordenados por sus claves no es un
verdadero gasto: para añadir un registro a una página se debe leer la página de
la memoria, modificarla y escribirla de nuevo en el disco, El tiempo extra que
se necesita para mantener el orden posiblemente no se note en el caso típico en
el que las páginas no son muy grandes.
Continuando un poco más con el ejemplo, es necesaria otra división para
añadir X = 11000. Esta división también requiere duplicar el directorio para
producir la estructura que se muestra en la Figura 18.11. El proceso de duplicar
el directorio es simple: se lee el directorio antiguo, y después se crea el nuevo
escribiendo dos veces cada entrada del antiguo. Esto crea un espacio para el
puntero a la nueva página que acaba de crearse por la división.
En general la estructura creada por un dispersión extensibleestá compuesta
por un directorio de 2d palabras (una para cada serie de d bits) y un conjunto
de púginas hojas, que contienen todos los registros con claves que comienzan
por una sene de bits específica (con d bits o menos). Una búsqueda implica uti-
lizar los primeros d bits de la clave como índice dentro del directorio, el cual
contiene punteros a las páginas hojas. Después se accede a la página hoja así
referenciaday se busca el registro (utilizando cualquier estrategia).Varias entra-
das de directorio pueden apuntar a la misma página hoja: con mayor precisión,
si una página hoja contiene todos los registros cuyas claves comienzan por una
serie específicade k bits (los que no están sombreados en las figuras), entonces
tendrá 2d-kentradas del directorio apuntando hacia ella. En la Figura 18.11 se
298 ALGORITMOS EN C++
Figura 18.12 Dispersiónextensible: cuarta división.
tiene d = 3, y la página 1 del disco 2 contiene todos los registros cuyas claves
comienzan por los bits 10; por tanto hay dos entradas de directoric apuntando
hacia ella.
Hasta ahora, en el ejemplo, cada división de página necesitó una división de
directorio, pero en circunstancias normales se puede esperar que el directorio
sólo se divida raras veces. Ésta es la esencia del algoritmo: los punteros extra del
directorio permiten a la estructura adaptarse armoniosamente a un crecimiento
dinámico. Por ejempio, cuando se inserta T en la estructura de la Figura 18.11,
la página 1 del disco 2 se debe dividir para acomodar las cinco claves que co-
mienzan con 10, pero el directorio no necesita crecer, como lo muestra la Fi-
gura 18.12.El único cambio en el directorio es que el último de sus dos punte-
ros se cambia para apuntar a la página 1 del disco 3, la nueva página que ha
sido creada en la división para acomodar a todas las claves en la estructura de
datos que comienzan con 1O1 (la T).
El directono sólo contiene punteros a páginas. Éstos son probablemente más
pequeiios que las claves o los registros; así que cabrán más entradas de directo-
rio en una página. En el ejemplo, se supone que se pueden poner en una página
dos veces más entradas a directorios que registros, aunque esta relación posible-
mente es mucho más alta en la práctica. Cuando el directorio se expande en
más de una página, se mantiene en memoria un modo raíz» que indica dónde
BÚCQUEDA EXTERNA 299
0100
O101
O110
o111
Figura 18.13 Accesos de la dispersión extensible.
están las páginas del directorio, utilizando el mismo esquema de indexación.Por
ejemplo, si el directorio se expande en dos páginas, el nodo raíz pudiera indicar
que el directono para todos los registros con claves que comienzan por O está
en la página O del disco 1, y que el directorio para todas las claves que comien-
zan por 1 está en la página 1 del disco 1. Continuando con el ejemplo, se inser-
tan las claves E N S I B y L, llegandoa la estructura que se muestra en la Figura
18.13. (Por claridad, se ha reservado el disco 1 para el directorio, aunque en la
práctica pudiera estar mezclado con las otras páginas, o estar reservada la pá-
gina O de cada disco, o bien utilizar alguna otra estrategia.) Así pues, la inser-
ción en una estructura de dispersión extensiblepuede implicar alguna de las si-
guientes operaciones,una vez que se acceda a la página hoja que podría contener
la clave de búsqueda. Si hay espacio en la página hoja, se inserta simplemente
el nuevo registro, o en caso contrario se divide en dos la página hoja (parte de
los registros se desplazan hacia una nueva página). Si el directorio tiene más de
300 ALGORITMOS EN C++
una entrada apuntando a la página hoja, entonces las entradas se pueden dividir
por igual en la página. Si no, se debe doblar el tamaño del directorio.
Como se ha descrito hasta aquí, este algoritmo es muy sensible a una mala
distribución de las claves de entrada: el valor de d es el mayor número de bits
que se necesitan para separar las claves en conjuntos lo suficientemente peque-
ños como para que quepan en las páginas hojas, y así, si un gran número de
claves coincidm en gran parte de sus bits iniciales, el directorio se hace inacep-
tablemente grande. Para aplicaciones reales a gran escala se puede evitar este
problema efectuando una dispersión de las claves para hacer los primeros bits
(pseudo) aleatorios. Para buscar un registro, se hace una dispersión de su clave
para obtener una serie de bits que se utilizarán para acceder al directorio; este
último indica en que página hay que buscar un registro con la misma clave.
Desde el punto de vista de la dispersión se puede presentar el algoritmo como
una división de nodos para resolver el problemas de las colisiones: de aquí el
nombre de «dispersión extensible». Este método ofrece una alternativa atrac-
tiva a los árbolesB y al acceso secuencialindexado, porque utiliza siempre exac-
tamente dos accesos al disco en cada búsqueda (como el acceso secuencia1in-
dexado), mientras que mantiene la capacidad para hacer inserciones eficaces
(como los árboles B) sin malgastar mucho espacio.
Propiedad 18.4 Conpáginas que pueden contener M registros, se puede espe-
rar que la dispersión necesite alrededor de 1,44(N/M)páginas para un archivo
de N registros,El directoriocontendrá alrededor de N1+l/M/M
entradas.
Este análisis es una extensión compleja del análisis de los tries a los que se hizo
referencia en el capítulo anterior. Cuando M es grande, el volumen de espacio
malgastado es aproximadamente el mismo que para los árboles B, pero para un
M pequeño el directorio puede hacerse demasiado grande..
Aun con la dispersión, se deben dar algunos pasos extra si existe un gran
número de clavesiguales. Éstas pueden hacer al directono artificialmentegrande,
y el algoritmo se distorsiona por completo si hay más claves iguales que las que
pueden caber en una página hoja. (Esto ocurre realmente en el ejemplo, puesto
que se tienen cuatro E.) Si existen muchas clavesiguales entonces se podría, por
ejemplo, prohibir la presencia de las mismas en la estructura de datos, y poner
en las páginas hojas punteros a listas enlazadas de registros que contengan las
claves repetidas. Para ver la complicación que esto implica, considérese lo que
pasaría si la última E del ejemplo (que parecía haberse olvidado) se insertara en
la estructura de la Figura 18.13.
Una situación menos catastróficade resolver es que la inserción de una nueva
clave pueda causar que el directorio se divida más de una vez. Esto ocurre
cuando un bit más no es suficiente para distinguir las claves en una página so-
brecargada. Por ejemplo, si se insertaran dos claves con el valor D = O0100 en
la estructura de dispersión extensiblede la Figura 18.12, se necesitarían dos di-
visiones del directorio porque se necesitan cinco bits para distinguir D de E (el
BUSQUEDA EXTERNA 301
cuarto bit no ayuda). Esto es fácil de afrontar en una implementación, pero no
se debe tolerar.
Memoria virtual
El «método más fácil)) que se presentó al final del Capítulo 13 para la ordena-
ción externa se puede aplicar directa y trivialmente al problema de la búsqueda
externa. En realidad, una memoria virtual no es más que un método de bús-
queda externa de amplio espectro:dada una dirección (clave),devolverla infor-
mación asociada con esa dirección. Sin embargo, la utilización directa de la me-
moria virtual no se recomienda como método fácil de búsqueda. Como se
mencionó en el Capítulo 13, las memorias virtuales se comportan mejor cuando
la mayoría de los accesos están relativamente próximos a los accesos anteriores.
Los algontmos de ordenación se pueden adaptar a esto, pero la verdadera na-
turaleza de la búsqueda es que las peticiones traten sobre informaciones de las
partes arbitrarias de la base de dat0s.i
Ejercicios
1. Dar el contenido del árbol B que se obtiene de la inserción de las claves C
U E S T I O N F A C I L en un árbol inicialmente vacío y con M = 5.
2. Dar el contenido del árbol B que se obtiene de la inserción de las claves C
U E S T I O N F A C I L en un árbol inicialmente vacío y con M = 6. Uti-
lizar la vanante del método en el que todos los registros se conserven en
nodos externos.
3. Dibujar el árbol B que se obtiege al insertar dieciséis claves iguales en un
árbol inicialmente vacío, con M = 5.
4. Suponiendo que se destruyeuna página de una base de datos, describir cómo
se resolvería este problema para cada una de las estructuras de árboles B
descritas en el texto.
5. Dar el contenido de la tabla de dispersión extensibleque se obtiene cuando
se insertan las claves C U E S T I O N F A C I L en una tabla inicialmente
vacía, con capacidad de página para cuatro registros. (Siguiendoel ejemplo
del texto, no se debe utilizar la dispersión sino la representación binaria de
5 bits de i como clave para la i-ésima letra.)
6. Obtener una secuencia de tantas claves distintas como sea posible que ha-
gan crecer un directorio desde una tabla inicialmente vacía hasta un ta-
maño 16, con una capacidad de página de tres registros.
extensible.
7. Esbozar un método para suprimir un elemento de una tabla de dispersión’
302 ALGORITMOS EN C++
8. ¿Por qué los árboles B adescendentes))son mejores que los «ascendentes»
para el acceso concurrente a los datos? (Supóngase, por ejemplo, que dos
programas están tratando de insertar un nuevo nodo al mismo tiempo.)
9. Implementar buscar e insertar para una búsqueda interna utilizando el mé-
todo de dispersión extensible.
10. Comparar el programa del ejercicio anterior con la doble dispersión y la
búsqueda trie por residuos, en aplicacionesde búsqueda interna.
BÚSQUEDA EXTERNA 303
REFERENCIAS para la Búsqueda
Las referencias principales para esta sección son el Volumen 3 de Knuth, el li-
bro de Gonnet y el libro de Mehlhorn. La mayoría de los algoritmos que se han
estudiado se tratan detalladamente en estos libros, con análisis matemáticos y
sugerencias para aplicaciones prácticas. Los métodos clásicos son tratados por
Knuth y los más recientes por Gonnet y Mehlhorn, con muchas referenciasbi-
bliográficas. Estas tres fuentes describen los análisis de casi todos los afuera del
alcance de este libro» a los que se ha hecho referencia en esta sección.
El material del Capítulo 15 proviene del artículo de 1978 de Guibas y Sed-
gewick, que muestra cómo adaptar muchos algoritmos clásicos de árboles equi-
librados al esquema crojinegro)),a la vez que ofrece otras implementaciones.
En realidad, hay una literatura muy amplia sobre árboles equilibrados: el lector
que desee ampliar sus conocimientos puede comenzar con este artículo. €1libro
de Mehlhorn da pruebas detalladas de las propiedades de los árboles rojinegros
y de estructuras similares, así como referencias a trabajos más recientes. El es-
tudio realizado por Comer en 1979 presenta los árboles B desde un punto de
vista más práctico.
El algoritmo de la dispersión extensible que se presentó en el Capítulo 18
proviene del artículo de Fagin, Nievergelt,Pippenger y Strong de 1979. Este ar-
tículo es obligatorio para todo aquel que desee más información sobre los mé-
todos de búsqueda externa:relacionael contenido de los Capítulos 16 y 17 hasta
ofrecer el algoritmo del Capítulo 18. El artículo contiene también un análisis
detallado y una presentación de las consecuenciasprácticas.
Muchas aplicaciones prácticas de los métodos expuestos, especialmente en
el Capítulo 18, provienen del coniexto de los sistemas de bases de datos. El es-
tudio de las bases de datos es un campo amplio y en crecimiento, en el que los
algoritmos básicos de búsqueda continúan desempeñando un papel fundamen-
tal en la mayona de los sistemas.El libro de Ullman es una introducción a este
campo.
D. Comer. «The ubiquitous B-tree», Computing Surveys, 11 (1979).
R. Fagin, J. Nievergelt,N. Pippenger y H. R. Strong, ((Extendibledispersion-a
fast access method for dynamic files)),ACM Transactions on Database Sys-
tems, 4, 3 (septiembre 1979).
G. H. Gonnet, Handbook c
f Algorithms and Data Structures, Addison-Wesley,
Reading, MA, 1984.
L. Guibas y R. Sedgewick, «A dichromatic framework for balanced trees», en
19th Annual Symposium on Foundations of Computer Science, IEEE, 1978.
También en A Decade o
f Progress 1970-1980,Xerox PARC, Palo Alto, CA.
D. E. Knuth, TheArt o
f Computer Programming, Volume 3: Sorting and Sear-
ching, Addison-Wesley, Reading, MA, 1975.
K. Mehlhorn, Data Structures and Algorithms 1:Sorting and Searching, Spnn-
ger-Verlag, Berlín, 1984.
J. D. Ullman, Principle o
f Database Systems, Computer Science Press, Rock-
ville, MD, 1982.
Algoritmos en C++.pdf
Procesamiento
de cadenas
Algoritmos en C++.pdf
Búsqueda de cadenas
A memdo sucede que los datos a procesar no se descomponen lógicamente en
registros independientesque representen pequeñas partes identificables.Este tipo
de datos se caracterizafkilmente por el hecho de que se pueden escribir en forma
de cadenas: series lineales (por io regular muy largas) de caracteres. Por su-
puesto que ya se han visto antes las cadenas, por ejemplo en los Capítulos 3 y
16, ya que constituyen estructurzs básicas en C++.
Las cadenas son evidentemente el centro de los sistemas de tratamiento de
texto, que proporcionan una gran variedad de posibilidades para la manipula-
ción de textos. Tales sistemas procesan cadenas alfanuméricas, que pueden de-
finirse en primera aproximación como seriesde letras, números y caracteres es-
peciales. Estos objetos pueden ser bastante grandes (por ejemplo, este libro
contiene más de un millón de caracteres),por lo que es importante disponer de
algoritmos eficaces para su manipulación.
Otro tipo de cadena es la cadena birlaria,que es una simple serie de valores
O y 1. Ésta es, en cierto sentido, un tipo especial de cadena alfanumérica, pero
es útil hacer la distinción porque existen diferentes algoritmos específicos para
este tipo de cadenas y porque las cadenas binarias se utilizan en muchas apli-
caciones. Por ejemplo, algunos sistemas gráficos de computadoras representan
las imágenes como cadenas binarias. (Este libro fue impreso con un sistema de
este tipo: esta misma página se representó en su momento como una cadena
binaria de millones de bits.)
En un sentido, las cadenas alfanuméricas son objetos bastante diferentes de
las cadenasbinarias, porque están constituidaspor caracterestomados de un gran
alfabeto. Pero, en otro sentido, los dos tipos de cadenas son equivalentes,puesto
que cada carácter alfanumérko se puede representar (por ejemplo) con ocho ci-
fras binarias, y una cadena binaria puede considerarse como una alfanuménca,
al tratar cada paquete de ocho bits como un carácter. Se verá que el tamaño del
alfabeto que se toma para formar una cadena es un factor importante en el di-
seño de los algoritmos de procesamiento de cadenas.
Una operación fundamental sobrelas cadenas es el reconocimientodepatro-
307
308 ALGORITMOS EN C++
nes: dada una cadena alfanumérica de longitud N y un patrón de longitud M,
encontrar una ocurrencia del patrón dentro del texto. (Seutiliza aquí el término
«texto» aun cuando se haga referencia a una secuencia de valores O y 1 o a al-
gún otro tipo especial de cadena.) La mayoría de los algoritmos para este pro-
blema se pueden modificar fácilmente para encontrar todas las ocurrencias del
patrón en el texto, puesto que recorren el texto en secuencia y se pueden reini-
cializar en la posición situada inmediatamente después del comienzo de una
concordancia, para encontrar la concordancia siguiente.
El problema del reconocimiento de patrones se puede caracterizar como un
problema de búsqueda en el que el patrón sena la clave, pero los algoritmos de
búsqueda que se han estudiado no se pueden aplicar directamente porque el pa-
trón puede ser largo y porque se «alinea»en el texto de forma desconocida.Éste
es un problema interesante: hace poco tiempo que se ha descubierto que algu-
nos algoritmos muy diferentes (y sorprendentes) no solamente ofrecen un aba-
nico de métodos prácticos, sino que también ilustran algunas de las técnicas
fundamentalesdel diseño de algoritmos.
Una breve historia
Los algoritmos que se van a estudiar tienen una historia interesante que se re-
sume aquí para ayudar a situar a los diferentes métodos en su contexto.
Existe un algoritmo evidente de fuerza bruta para el procesamiento de ca-
denas que se utiliza ampliamente. Mientras que en el peor caso se ejecuta en un
tiempo proporcional a MAT, las cadenas con las que se trabaja en muchas apli-
caciones conducen a un tiempo de ejecución que es virtualmente proporcional
a M + N. Además, como el método se puede beneficiar de los recursos de las
arquitecturas de la mayona de los sistemas de computadoras, la versión opti-
mizada del algoritmo constituye un «estándan>que es difícil de batir por un al-
goritmo más fino.
En 1970, S.A. Cook demostró un resultado teórico sobre un tipo particular
de máquina abstracta que implicaba la existenciade un algoritmo para resolver
el problema del reconocimiento de patrones en un tiempo proporcional a k
í+N
en el peor caso. D. E. Knuth y V. R. Pratt siguieron laboriosamente el razona-
miento que Cook había hecho para probar su teorema (cuya intención no era
la de ser práctico) y obtuvieron un algoritmo que pudieron afinar en un método
relativamente simple y práctico. Esto es un ejemplo raro y satisfactorio de un
resultado teórico con una solución práctica inmediata (e inesperada). Pero re-
sulta que J.H. Morns descubrió prácticamente el mismo algoritmo como solu-
ción de un molesto problema práctico que había encontrado cuando imple-
mentaba un editor de texto (no deseaba «retroceder» nunca en la cadena de
texto). Sin embargo, el hecho de que el mismo algoritmo surgiera de dos apro-
ximaciones diferentesaumenta su credibilidad como una solución fundamental
al problema.
B~SQUEDADECADENAS 309
Knuth, Moms y Pratt no publicaron su algoritmo hasta 1976, y mientras
tanto R.S. Boyer y J. S. Moore (e independientemente, R. W. Gosper) habían
descubiertoun algoritmomucho más rápido en muchas aplicaciones, puesto que
sólo examina una parte reducida de los caracteres de la cadena de texto. Mu-
chos editores de texto utilizan este algoritmo para obtener una reducción nota-
ble del tiempo de respuesta en las búsquedas de cadenas.
Tanto el algoritmo de Knuth-Morris-Pratt como el de Boyer-Moore requie-
ren cierto preprocesamiento algo complicado del patrón, que es difícilde enten-
der, por lo que se ha limitado su utilización. (De hecho, la historia dice que un
programador desconocido encontró el algoritmo de Morris tan difícil de enten-
der que lo reemplazó por una implementación del algoritmo de fuerza bruta.)
En 1980, R. M. Karp y M. O. Rabin observaron que el problema no es tan
diferente como parece del problema de una búsqueda estándar,y obtuvieron un
algoritmo casi tan simple como el de la fuerza bruta, que realmente siempre se
ejecuta en un tiempo proporcional a M + N. Además, su algoritmo se adapta
fácilmentea patrones y textos bidimensionales,lo que lo hace más útil que otros
para el procesamiento de imágenes.
Esta historia ilustra el hecho de que la búsqueda de un ({algoritmomejom
muy a menudo esjustificada; incluso se puede pensar que existen aún otras so-
luciones para el desarrollo de este problema.
Algoritmo de fuerza bruta
El método en el que se piensa de inmediato para el reconocimiento de patrones
consiste simplementeen verificar,para cada posición posible del texto en la que
el patrón puede concordar, si efectivamentelo hace. El programa siguienteefec-
túa de esta manera una búsqueda de la primera ocurrencia de la cadena patrón
p en una cadena texto a:
int busquedabruta(char *p, char *a)
i
int i , j , M = strlen(p), N = strlen(a);
for ( i = O , j = O; j < M && i < N; i++,
j++)
i f ( j == M) return i-M; else return i;
if (a[i] != p [ j ] > { i -= j-1; j = -1; }
1
El programa conserva un puntero (i)
en el texto y otro (j)en el patrón. Mien-
tras que apunten a caracteres que concuerden, ambos se incrementan. Si i y j
apuntan a caracteres incompatibles, entonces j se pone de nuevo a apuntar al
principio del patrón e i se reinicializa de forma que se haga avanzar al patrón
a la siguienteposición a la derecha, para una nueva comparación.En particular,
310 ALGORITMOS EN C++
1 0 0 1 1 1 0 1 0 0 1 0 1 0 0 0 1 0 1 0 0 1 1 1 0 0 0 1 1 1
1
1 o o 1 1 1 o 1 o o 1 o 1 o o ol~IammmmBiil0
o o 1 1 1
Figura 19.1 Búsqueda por fuerza brutade una cadena en un texto binario.
siempre que la sentencia i f ponga j a -1, las iteraciones siguientes del bucle
for van incrementando i hasta que se encuentre un carácter del texto que con-
cuerde con el primer carácter del patrón.
Si se alcanza el final del patrón (j== M) entonces hubo concordancia a partir
de a [i-MI . Si por el contrario se alcanza el final del texto ( i == N) antes que el
del patrón, entonces no hay concordancia: el patrón no aparece en el texto, en
cuyo caso se devuelve el valor «centinela» N.
En una aplicación de edición de texto, el bucle interno de este programa rara
vez se reitera, y el tiempo de ejecución es prácticamente proporcional al nú-
mero de caracteresexaminados en el texto. Por ejemplo, suponiendo que se está
buscando el patrón DENAS en la cadena de texto
EJEMPLO DE BÚSQUEDA DE CADENAS...
entonces la Fentencia j++ se ejecuta sólo cinco veces (dos para cada DE y una
para la D de DA) antes de que se encuentre la verdadera concordancia.
Por otra parte, la fuerza bruta puede ser muy lenta para algunos patrones,
por ejemplo, si el texto es binario (doscaracteres), como sucede en aplicaciones
de procesamiento de imágenes y de programación de sistemas. La Figura 19.1
muestra lo que sucede cuando se utiliza este algoritmo para buscar el patrón
IO1O
0111 en una gran cadena binaria. Cada línea (exceptola última, que mues-
tra la concordancia) contiene la lista de los cero o más caracteres que concuer-
dan con el patrón, seguida de una discordaiicia.Estas líneas son los «falsosprin-
cipios))que ocurren cuando se trata de encontrar el patrón; un objetivo evidente
del diseño de un algoritmo es tratar de limitar el número y la longitud de dichas
líneas. En este ejemplo se examinan por término medio dos caracteres por cada
posición de texto, aunque la situación puede ser mucho peor.
B~SQUEDADECADENAS 311
La búsqueda de cadenas porftierza bruta puede necesitar al-
Propiedad 19.1
rededor de NM comparaciones de caracteres.
El peor caso se produce cuando patrón y texto están formados por uno o varios
O seguidos por un 1. Entonces para cada una de las N - M + I posiciones de
concordancia se comparan con el texto todos los caracteres del patrón, con un
costetotal de M(N - M + 1).Normalmente Mes muy pequeño comparado con
N, por lo que el total es alrededor de NM..
Por supuesto que tales cadenasdegeneradas son poco probables en un texto
normal (o en C++), pero pueden aparecer cuando se procesan textos binarios,
de modo que hay que buscar mejores algoritmos.
Algoritmo de Knuth-Morris-Pratt
La idea básica de este algoritmo descubierto por Knuth, Moms y Pratt es la si-
guiente: cuando se detecta una discordancia (no concordancia),el ((falso prin-
cipio» se compone de los caracteresque se conocen por adelantado (puestoque
están en el patrón). De algún modo hay que ser capacesde aprovecharse de esta
información en lugar de retroceder el puntero i más allá de todos estos carac-
teres conocidos.
Como un ejemplo sencillode esto, se supone que el primer carácter del pa-
trón sólo aparece una vez (sea, por ejemplo, el patrón = 10000000).Supóngase
ahora que se tiene un falso principio de j caracteres de longitud en alguna po-
sición del texto. Cuando se detecta la no concordancia, se sabe, en virtud del
hecho de que concuerdan j caracteres, que no se necesita «retroceden>el pun-
tero i del texto, puesto que ninguno de los j - 1 caracteres del texto pueden
concordar con el primer carácter del patrón. Este cambio se podría implemen-
tar reemplazando la instrucción i -= j - i del programa anterior por i++. El
efecto práctico de este ejemplo es limitado, porque es poco probable que se pre-
senten patrones tan específicos, pero merece la pena pensar en esta idea, y el
algoritmo de Knuth-Morris-Pratt es una generalización de la mima. Sorpren-
dentemente siempre es posible arreglar las cosas de modo tal que el puntero i
nunca se decremente.
Saltar todos los caracteres del patrón cuando se detecta una discordancia,
como la descrita en el párrafo anterior, sena un error en el caso en el que el
patrón se repita en el propio punto de la no concordancia. Por ejemplo, cuando
se está buscando 1O1O
0111 en 1O 1O1O
0111, se comienza por detectar la discor-
dancia en el quinto carácter, pero se debe retroceder al tercero para continuar
la búsqueda, puesto que de lo contrario se perdena la concordancia. En todo
caso se puede prever la acción a tomar, adelantándoseen el tiempo, porque de-
pende sólo del patrón, como se muestra en la Figura 19.2.
Se utilizará el array prox [MI para determinar cuánto se debe retroceder
312 ALGORITMOS EN C++
1
1 0
1 0
2 0
3 1
4 2
5 0
6 1
1 0 1 0 0 1 1 2
7 1 1 0 1 0 0 1 1 ~
Figura 19.2 Posicionesde reinicialización en una btjsqueda de Knuth-Morris-Pratt.
cuando se detecte que no hay concordancia. Imagínese que se hace deslizar una
copia de los primeros j caracteres del patrón, de izquierda a derecha, comen-
zando por colocar el primer carácter de la copia sobre el segundo carácter del
patrón y parando cuando todos los caracteres que se superpongan concuerden
(o no haya ninguno). Estos caracteres que se superponen definen la siguiente
posición posible en la que el patrón podría concordar, si se detecta que no hay
concordanciaen p [j 1.La distancia a retroceder en el patrón (prox [j1)es exac-
tamente el número de caracteresque se superponen. Especificamente,para j >
O, el valor de prox[ j ] es el mayor valor de k < j para el que los primeros k
caracteresdel patrón concuerdan con los últimos k caracteresde los j primeros
caracteres del patrón. Como pronto se verá, es conveniente definir prox[O]
como -1.
Este array prox proporciona de inmediato una forma de limitar (y de he-
cho, como se verá, de eliminar) el «retroceso»del puntero i del texto, como se
presentó anteriormente. Cuando i y j apuntan a caracteres que no concuerdan
(la comprobación de la concordancia comenzó en la posición i - j + 1dentro
de la cadena de texto), entoncesla próxima posición posible para que haya una
concordancia con el patrón es i - prox[j]. Pero por definición de la tabla
prox, los primeros prox [j ] caracteres después de esa posición concuerdan con
los primeros prox [j ] caracteres del patrón, por lo tanto no hay necesidad de
hacer retroceder al puntero itan lejos: simplemente se puede dejar al puntero
i sin cambios y darle al puntero j el valor prox[j], como se hace en el pro-
grama siguiente:
i n t busquedaKMP(char *p, char *a)
i n t í , j , M = s t r l e n ( p ) , N = s t r l e n ( a ) ;
{
B~SQUEDADECADENAS 313
i n i c p r o x (p) ;
for ( i = O, j = O; j <M && i < N; i++,
j++)
if ( j == M) r e t u r n i-M; else r e t u r n i;
while ( ( j >= O) && ( a [ i ] != p [ j ] ) ) j = p r o x [ j ] ;
Cuando j = O y a[i] no concuerdan con p [O], no hay superposición, por lo
que se desea incrementar i y mantener j apuntando al comienzo del patrón.
Esto se logra definiendo prox [O] en -1, lo que provoca que a j se le asigne -1
en el bucle whi 1e; entonces se incrementa iy j se pone a O cuando se itera el
bucle for. Funcionalmente este programa es el mismo que el de busqueda-
bruta, pero es probable que se ejecute con más rapidez en patrones que sean
altamente repetitivos.
Queda por calcular la tabla prox. El programa correspondiente necesitaalgo
más de astucia; básicamente es el mismo anterior, pero haciendo concordar al
patrón consigomismo.
inicprox(char *p)
I
I
i n t i,j , M = strlen(p);
prox[O] = -1;
for (i= O, j = -1; i < M; i++, j++, p r o x [ i ] = j )
while ( ( j>= O) && ( p [ i ] != p [ j ] ) ) j = p r o x [ j ] ;
1
Justo después de que se hayan incrementado i y j, se ha determinado que los
j primeros caracteres del patrón concuerden con los caracteresde las posiciones
p [i-j-1 ] ,..., p [i-11, los últimos j caracteres de los i primeros caracteres
del patrón. Y éste es el mayor j con esta propiedad, puesto que, si no, se habría
olvidado una ((posible concordancia» del patrón consigo mismo. Así que j es
exactamente el valor que se debe asignar a prox [j].
Una forma interesante de representar este algoritmo es considerar al patrón
como si estuviera fijo, de forma que la tabla prox pueda «volcarse en» el pro-
grama. Por ejemplo, el programa siguiente es exactamente equivalente al pro-
grama anterior para el patrón que se está considerando, pero es posible que sea
mucho más eficaz.
in t busquedaKMP(char *a)
i n t i = -1;
{
sm: i++;
SO: i f ( a [ i ] != ' 1 ' ) goto sm; i++;
s l : if ( a [ i ] != ' O ' ) goto SO; i++;
314 ALGORITMOS EN C++
s2: i f ( a [ i ] != ' 1 ' ) goto SO; i++;
s3: i f ( a [ i ] != ' O ' ) goto s1; i++;
s4: if ( a [ i ] != 'GI) goto s2; i++;
s5: if ( a [ i ] != ' 1 ' ) goto SO; i++;
s6: i f ( a [ i ] != ' 1 ' ) goto s l ; i++;
s7: i f ( a [ i ] != ' 1 ' ) goto sl; i++;
return i-8;
1
Las etiquetas goto corresponden precisamente a la tablz prox. De hecho, el
programa i ni cprox anterior que construye la tabla prox se puede modificar
fácilmente para ¡dar como salida este programa! Para evitar tener que verificar
si i == N cada vez que se incrementa i,se supone que el patrón se almacena al
final del texto como un centinela, es decir en a [NI y ... y a[N+M- 11.(Esta me-
jora se puede hacer incluso en la inplementación estándar.)Éste es un ejemplo
de un cornpilador de búsqueda de cadenas»:dado un patrón, se puede generar
un programa muy eficazpara buscar ese patrón en una cadena texto arbitraria-
mente larga. Se verá la generalización de este concepto en los dos capítulos que
siguen.
El programa anterior utiliza solamente algunas operaciones muy básicas para
resolver el problema de la búsqueda de cadenas. Esto significaque se puede des-
cribir en términos de un modelo muy simple de máquina denominada máquina
de estadosfinitos. La Figura 19.3 muestra la máquina de estados finitos para el
problema anterior.
. __.-._
. . .
..... ..
.- '._
__..
--.__
..'
. .
'. I'
,.- -.
*
.
:
._.-._
*
...........
'.c.:'
....... .......
s.,..
,.;._
.... ......
......................
.._ .....
..................
Figura 19.3 Máquinade estados finitos para el algoritmo de Knuth-Morris-Pratt.
La máquina consiste en estados (indicados por los números encerrados en
círculos) y transiciones (indicadas por líneas). Cada estado tiene dos transicio-
nes que salen de él: una transición de concordancia (expresada en la figura por
las líneas gruesas que van hacia la derecha) y una transición de no concordancia
(expresadapor las líneas de puntos que van hacia la izquierda). Los estados son
los lugares donde la máquina ejecuta las instrucciones; las transiciones son las
instrucciones goto. Cuando la máquina está en el estado etiquetado <cx)> puede
llevar a cabo una sola instrucción: «si el carácter en curso es x pasa al siguiente
B~SQUEDADECADENAS 315
Figura 19.4 Máquina de estados finitos (mejorada)para el algoritmo de Knuth-Morris-
Pratt.
y toma la transición de concordancia; en caso contrario toma la transición de
no concordancia). Pasar al siguiente significa tomar el próximo carácter de la
cadena como carácter actual));la máquina va pasando al carácter siguiente a
medida que va concordando con el carácter actual. Hay dos excepcionesa esto:
el primer estado siempre toma una transición de concordancia y pasa al si-
guiente carácter (esencialmenteesto correspondea buscar la primera ocurrencia
del primer carácter del patrón), y el último estado es un estado de «parada»que
indica que se ha encontrado una concordancia del patrón. En el próximo capí-
tulo se verá cómo utilizar una máquina similar (pero más poderosa) para ayu-
dar al desarrollo de un algoritmo mucho más eficaz de reconocimiento de pa-
trones.
El lector atento puede haber notado que es posible mejorar este algoritmo,
porque no tiene en cuenta al carácter que causa que no haya concordancia. Por
ejemplo, supóngase que el texto comienza por 1011 y que se está buscando el
patrón 1O1O
0111. Después de la concordancia de 101 se encuentra una no con-
cordancia en el cuarto carácter; en este punto la tabla prox dice que hay que
comparar el segundo carácter del patrón con el cuarto carácter del texto, puesto
que, en virtud de la concordancia de 101, el primer carácter del patrón se puede
alinear con el tercer carácter del texto (pero no hay que compararlos, ya que se
sabe que los dos son 1). Sin embargo, sería imposible tener aquí una concor-
dancia: al constatar esta no concordancia se sabe que el próximo carácter del
texto no es O, como lo exige el patrón. Otra forma de ver las cosas es observar
la versión del programa que tiene dentro la tabla prox: en la etiqueta 4 se va a
2 si a [i]no es O, pero en la etiqueta 2 se va a 1 si a[ i]
no es O. ¿Por qué no ir
directamente a l? La Figura 19.4 muestra la versión mejorada de la máquina
de estados finitos para el ejemplo.
Afortunadamente, es fácil introducir este cambio en el algoritmo, Sólo se
necesita reemplazar la sentencia prox [i ] = j en el programa i n i cprox por
p r o x [ i ] = ( p [ i ] == p [ j ] ) ? p r o x [ j ] : j
Puesto que se procede de izquierda a derecha, el valor que se necesita para prox
ya ha sido calculado, por lo que simplemente se utiliza.
316 ALGORITMOS EN C++
1 0 0 1 1 1 0 1 0 0 1 0 1 0 0 0 1 0 1 0 0 1 1 1 0 0 0 1 1 1
O 0
1 0 0 1 1 1 0 1 0 0 1 0 1 0 0 0 ~ ~ ~ ~ m m m m 0 0 0 1 1 1
Figura 19.5 Búsquedade cadenas Knuth-Morris-Pratten un texto binario.
Propiedad 19.2 La búsqueda de cadenasKnuth-Morris-Prattnunca efectzia más
de N+M comparaciones de caracteres.
Esta propiedad se ilustra en la Figura 19.5, y también se deduce del código: o se
incrementa j o se reinicializa a partir de la tabla prox, una vez para cada i.’
La Figura 19.5 muestra que este método verdaderamente efectúa menos
comparaciones que el de la fuerza bruta para el ejemplo binario. Sin embargo,
es posible que el algoritmo de Knuth-Morris-Pratt no sea mucho más rápido
que el método de la fuerza bruta en muchas aplicaciones reales, porque de he-
cho pocas aplicacionesimplican búsquedas de patrones altamente repetitivosen
textos también altamente repetitivos. Sin embargo, este método tiene una ven-
taja práctica fundamental: procede secuencialmente a través del texto de en-
trada y nunca retrocede. Esto lo hace conveniente para su utilización en gran-
des archivos que se estén leyendo de algún dispositivo externo. (En estos casos
los algoritmos que necesiten retroceder se acompañan de algún complejo sis-
tema de almacenamiento intermedio.)
Algoritmo de Boyer-Moore
Si no es dificil «retroceden>se puede desarrollar un método de búsqueda de ca-
denas bastante más rápido recorriendo el patrón de derecha a izquierda mien-
tras se está tratando de hacer concordar éste con el texto. Si al buscar el patrón
de ejemplo 10100111, se encuentra concordancia en los caracteres octavo, sép-
timo y sexto, pero no el quinto, se puede deslizar el patrón siete posiciones a la
derecha, y volver a comprobar a partir del carácter decimoquinto, porque la
concordancia parcial encontró 111, que podría aparecer en cualquier parte del
patrón. Por supuesto que al final el patrón suele aparecer en cualquier lugar y,
por tanto, se necesita una tabla prox como la anterior.
La Figura 19.6 muestra una versión de derecha a izquierda de la tabla prox
para el patrón 10110101:en este caso prox[j ] es el número de posiciones de
caracteres que se puede desplazar el patrón hacia la derecha, dado que en una
B~SQUEDADECADENAS 317
2 4
1 0 1 1 0 1 0 1
3 7
4 2
$>&%&O 1 o 1
1 0 1 1 0 1 0 1
5 5
S&-;fl
o 1 o 1
6 5 1 0 1 1 0 1 0 1
7 5
K@11 o 1 o 1
1 0 1 1 0 1 0 1
3 0 1 1 o 1 o 1
8 5 1 0 1 1 0 1 0 1
Figura 19.6 Posiciones de reinicializaciónpara la búsquedade Boyer-Moore.
exploración de derecha a izquierda se constató una no concordanciaen elj-ésimo
caricter desde la derecha del patrón. Este valor se encuentra igual que antes,
deslizando una copia del patrón sobre los últimos j caracteres del mismo, de
izquierda a derecha, comenzando por alinear el penúltimo carácter de la copia
con el último carácter del patrón y parándose cuando todos los caracteres que
se superponen concuerden (teniendo en cuenta también el carácter que provocó
el fallo de la concordancia). Por ejemplo, prox [21 es 7 porque, si hay una con-
cordancia de los dos últimos caracteresy luego una no concordancia en una ex-
ploración de derecha a izquierda, entonces se debe haber encontrado O01 en el
texto; esta sene no aparece en el patrón, excepto posiblemente si el I se alinea
con el primer carácter del patrón, por lo que se puede deslizarlo 7 posiciones a
la derecha.
Esto lleva directamente a un programa que es muy similar a la implemen-
tación anterior del método de Knuth-Moms-Pratt. No se estudiará con más de-
talle porque existe un método completamente diferente de saltar caracteres en
una exploración del patrón de izquierda a derecha, que es mejor en muchos ca-
La idea es decidir lo que se debe hacer en función del carácter que provocó
la discordancia tanto en el texto como en el patrón. La etapa del preprocesa-
miento consiste en decidir, para cada carácter que podría figurar en el texto, lo
que se haría si dicho carácter hubiese provocado la no concordancia. La reali-
zación más simple de esto conduce inmediatamente a un programa bastante útil.
La Figura 19.7 muestra este método para el primer ejemplo de texto. Pro-
cediendo de derecha a izquierda para hacer concordar al patrón, se comprueba
primero la S del patrón con la P (el quinto carácter) del texto. No sólo no con-
cuerdan, sino que se constata que la P no aparece en ninguna parte del patrón,
por lo que se puede deslizarlo más allá de P. La siguiente comparación es la S
sos.
318 ALGORITMOS EN C++
E J E M P L O D E B U S Q U E D A D E C A D E N A S
E J E M P L O D E B U S Q U E D A D E C
A
-
1
Figura 19.7 Búsqueda de cadenas de Boyer-Moore utilizando la heurística de la no
concordancia.
del patrón con el quinto carácter después de P (la E de DE). Esta vez se puede
deslizar el patrón hacia la derecha hasta que la D concuerde con la D del texto.
Después se compara la S del patrón con la U de BÚSQUEDA, y como ésta no
aparece en el patrón, se le puede deslizar cinco lugaresmás a la derecha. El pro-
ceso continúa hqsta llegar a la D de CADENAS, punto en el que se alinea el
patrón de modo que su D concuerda con la del texto y se obtiene la concordan-
cia total. Este método conduce directamente a la posición de concordancia exa-
minando jsólo siete caracteres (y cinco más para comprobar la concordancia)!
Este algoritmo del «carácter no concordante))es bastante fácil de implemen-
tar. Es una versión mejorada del método de fuerza bruta y de d-erechaa iz-
quierda utilizada para inicializar un array saltar que indica, para cada carác-
ter del alfabeto, cuántas posiciones se debe saltar en el texto si este carácter
provoca una no concordancia durante la búsqueda de la cadena. Debe haber
una entrada en saltar para cada carácter que pueda aparecer en el texto: para
simplificar,se supone que se tiene una función índice que toma un char como
argumento y devuelve O para los espacios en blanco e i para la i-ésima letra del
alfabeto;se supone también que se dispone de una subrutina inicsaltar() que
inicializa el array saltar para M caracteres que no están en el patrón y luego,
paraj deOaM-1, asignaa saltar[indice(p[j])] elvalor M-j-l. Laimple-
mentación es directa:
i n t buscar-caracter-de-noconcordancia(char *p, char *a)
int i , j, t, M = strlen(p), N = strlen(a);
i nicsaltar(p);
for ( i = M-1, j = M-1; j > O; i--, j--)
while (a[i] != p[j])
t = saltar[i ndice(a [i ] ) } ;
i += (M-j > t) ? M-j : t ;
if (i> = N) return N;
{
B~SQUEDADECADENAS 319
j = M-1;
1
return i;
}
Si la tabla sal tar fuera toda O (loque nunca es), esto correspondería a una ver-
sión de derecha a izquierda del método de fuerza bruta, porque la sentencia i
+= M-j cambia el valor de ipara la nueva posición de la cadena de texto (como
si el patrón se moviese de izquierda a derecha a lo largo de sí mismo); entonces
j = M- 1 reasigna al puntero del patrón para prepararlo para una concordancia
de derecha a izquierda, carácter a carácter. Como se acaba de presentar, la tabla
sal tar permite que se pueda mover el patrón a lo largo del texto tan lejos como
sea posible, la mayoría de las veces M caracteres a la vez (cuando se encuentren
caracteresdel texto que no estén en el patrón). Para el patrón DENAS, el valor de
sal tar para S sería O, para A sería 1, para N sería 2, para E sería 3, para D sena
4, y para todas las otras letras sería 5. Así, por ejemplo, cuando se encuentra
una D durante una exploración de derecha a izquierda, el puntero i se incre-
menta en 4 de forma que el final del patrón se alinea cuatro posiciones a la de-
recha de D (y en consecuencia la D del patrón se alinea con la D del texto). Si
hubiese más de una D en el patrón, se desearía utilizar para este cálculo la que
está más a la derecha: de aquí que el array sal tar se constmya explorando de
izquierda a derecha.
Boyer y Moore sugirieron combinar los dos métodos que se han esbozado
para la exploración de derecha a izquierda, escogiendo el más grande de los dos
valores de salto.
Propiedad 19.3 La búsqueda de cadenas de Boyer-Moore nunca utiliza más de
M+N comparaciones de caracteres, y necesita alrededor de N/M pasos si el al-
fabeto no espequeño y el patrón no es largo.
El algoritmo es lineal en el peor caso de la misma manera que el método de
Knuth-Mooris-Pratt (la implementación anterior, que sólo utiliza una de las dos
heurísticas de Boyer-Moore, no es lineal). El «caso medio» N/M se puede pro-
bar para varios modelos de cadenas aleatorias, pero éstos tienden a ser irreales,
por lo que no se entrará en los detalles. En muchas situacionesprácticases cierto
que solamente unos pocos caracteres del alfabeto aparecen en el patrón, así que
cada comparación conduce a un desplazamiento de M caracteres, lo que da el
resultado senalado..
Claro está, el algoritmo del (carácter no concordante» no ayuda mucho en
el caso de cadenas binanas, porque sólo hay dos clases de caracteresque puedan
causar la no concordancia (y posiblemente estén ambas en el patrón). Sin em-
bargo, los bits se pueden agrupar para formar «caracteres»que se pueden utili-
zar exactamente como se vio antes. Si se toman b bits a la vez, entonces se ne-
cesita una tabla sal tar con 2' entradas. El valor de b se debe escoger lo
320 ALGORITMOS EN C++
suficientemente pequeño para que esta tabla no sea demasiado grande, pero
también lo suficientemente grande como para que la mayoría de las series de b
bits del texto no estén en el patrón. Específicamenie, hay M - b + 1 secciones
diferentes de b bits en el patrón (comenzando cada una en una posición de bit
desde 1 hasta M - b + I), y se desea que M - b + 1sea significativamentemenor
que 2'. Por ejemplo, si se toma baproximadamente igual a ig(4A4), la tabla sal -
tar estará llena en más de sus tres cuartas partes con M entradas. También b
debe ser menor que M/2, puesto que de lo contrario se podría ocultar por com-
pleto el patrón si se dividiera en dos series de texto de b bits.
Algoritmo de Rabin-Karp
Una aproximación de fuerza-bruta al algoritmo de búsqueda de cadenas que no
se ha considerado anteriormente sería explotar una gran memona tratando cada
posible serie de M caracteres del texto como si fuera una clave de una tabla de
dispersión estándar. Pero no es necesario mantener una tabla de dispersión
completa, puesto que el problema se plantea de forma que s610 se busque una
clave; todo lo que se necesita hacer es evaluar la función dispersión para cada
una de las posibles series de M caracteres del texto y verificar si es igual a la
función de dispersión del patrón. El problema con este método es que parece
tan difícil calcular la función de dispersión para M caracteres del texto como
verificar si éstos son iguales al patrón. Rabin y Karp encontraron una forma
fácil de evitar esta dificultad para la función de dispersión que se utilizo en el
Capítulo 16:h(k)= k mod q, donde q (el tamaño de la tabla) es un gran entero
primo. En este caso no se almacena nada en la tabla, por lo que se puede tomar
un q muy grande.
El método se basa en calcular la función de dispersión para la posición i del
texto conociendo su valor para la posición i - 1, de donde se desprende direc-
tamente una formulación matemática. Se supone que se transforman los M ca-
racteres en números agrupándolos en una palabra en lenguaje de máquina, que
podna tratarse como un entero. Esto equivale a escribirlos caracterescomo nú-
meros en un sistema de base d, donde des el número de caracteresposibles. El
número que corresponde a a[i]...a[i+M - 11es entonces
x = a[i]dM-' + a[i+ i p - 2 + ... + a[i+ M -11
y se puede suponer quc se conoce el valor de h(x)= x mod q. Pero un despla-
zamiento de una posición a la derecha en el texto corresponde a reemplazar x
Por
(x - a[i]&-')d + a[i+ M].
Una propiedad fundamental de la operación mod es que si se toma el resto al
BUSQUEDADECADENAS 321
dividir por q despuésde cada operación aritmética (para mantener pequeños los
números con los que se está tratando), se obtiene la misma respuesta que si se
hubieran realizado todas las operaciones aritméticas; luego se toma el resto al
dividir por q.
Esto conducea un algoritmomuy simple de reconocimientode patronescuya
implementación se presenta a continuación. Este programa supone la misma
función indice anterior, pero se utiliza d=32 por razones de eficacia (las mul-
tiplicaciones se pueden implementar como desplazamientos).
const int q = 33554393;
const int d = 32;
int busquedaRK(char *p, char *a)
int i, dM = 1, hl = O, h2 = O;
int M = strlen(p), N = strlen(a);
for (i = 1; i < M; i++) dM = (d*dM) % q;
for (i= O; i < M; i++)
{
hl = (hl*d+indice(p[i])) % q;
h2 = (h2*d+indice(a[i])) % q;
{
1
{
for (i = O; hl != h2; i++)
h2 = (h2+d*q-i ndice(a[i]
)*dM) % q;
h2 = (h2*d+indice(a[i+M])) % q;
if (i > N-M) return N;
1
return i;
1
El programa calcula primer6 el valor de dispersión hl para el patrón y el valor
h2 para los primeros M caracteres del texto. (También calcula el valor de dM-1
mod q en la variable dM.)A continuación recorre la cadena de texto, utilizando
la técnica anterior de calcular para cada i la función de dispersión para los M
caracteres que comienzan en la posición i y comparar cada nuevo valor de dis-
persión con hl.El número primo q se escoge tan grande como se pueda, pero
lo suficientemente pequeño como para que (d+l)*q no provoque un desbor-
damiento: esto requiere menos operaciones % que si se utiliza el mayor número
primo representable. (Se añade un d*q extra durante el cálculo de h2 para ase-
gurarse de que todo queda positivo y por tanto el operador % funciona como es
debido.)
Propiedad 19.4 Es muy probable que el método de Rabin-Karp sea lineal.
322 ALGORITMOS EN C++
Evidentemente este algoritmo emplea un tiempo proporcional a N +M, pero es
preciso señalar que en realidad sólo encuentra una posición del texto que tenga
el mismo valor de dispersión que el patrón. Para asegurarse de que se ha encon-
trado una concordancia real, se debe hacer una comparación directa de ese texto
con el patrón. Sin embargo, la utilización de un valor de q muy grande, que es
posible por los cálculos de % y por el hecho de que no se necesita conservar la
tabla de dispersión, hace muy poco probable que se produzca una colisión. En
teoría, este algoritmo podría necesitar U(NM)pasos en el (casi imposible) peor
caso. pero en la práctica se puede confiar en que tomará alrededor de N f M
pasos..
Búsquedas múltiples
Los algoritníosque se han presentado están todos orientados hacia un problema
específicode búsqueda de cadenas:encontrar una ocurrencia de un patrón dado
en una cadena de texto dada. Si la misma cadena de texto va a ser objeto de
muchas búsquedas de patrones, entonces merecería la pena hacer algún proce-
samiento sobre la cadena para hacer más eficaces las búsquedas posteriores.
Si hay un gran número de búsquedas, el problema de la búsqueda de cade-
nas puede considerarse como un caso particular del problema general de bús-
queda que se estudió en la sección anterior. Se trata simplemente la cadena de
texto como N «claves» superpuestas, la i-ésima clave definida como
a [i ] ,...,a [NI, es decir la cadena de texto completa que comienza en la po-
sición i . Por supuesto, no se manipularán las propias claves, sino los punteros
sobre ellas: cuando se necesite comparar las claves iy j se hacen comparacio-
nes carácter a caráctercomenzando por las posiciones i y j de la cadena de texto.
(Si se utiliza un carácter (centinelm final, mayor que todos los otros caracteres,
una de las claves siempre es mayor que la otra.) Entonces 12dispersión, el árbol
binario y los otros algontmos de la sección anterior se pueden utilizar directa-
mente. Primero, se construye una estructura completa a partir de la cadena de
texto, y luego se pueden llevar a cabo búsquedas eficaces para patrones parti-
culares.
Es preciso realizar muchos detalles cuando se aplican de esta forma los al-
goritmos de búsqueda a la búsqueda de cadenas: la intención es señalar que se
trata de una opción viable para algunas aplicaciones de búsqueda de cadenas.
Cada situación se acompañará de métodos diferentes más o menos apropiados
para diferentes situaciones. Por ejemplo, si las búsquedas son siempre de patro-
nes de la misma longitud, una tabla de dispersión construida con una sencilla
exploración, como en el método de Rabin-Karp, dará como media tiempos de
búsqueda constantes. Por el contrario, si los patrones son de longitud variable,
entonces alguno de los métodos basados en árboles podría ser más apropiado.
(Patricia se adapta especialmente a este tipo de aplicación.)
Otras variantes del problema pueden hacerlo bastante más difícil y conducir
B~CQUEDADECADENAS 323
a métodos drásticamente diferentes, como podrá verse en los dos próximos ca-
pítulos.
Ejercicios
1. Implementar un algoritmo de reconocimiento de patrones de fuerza bruta
que explore el patrón de derecha a izquierda.
2. Obtener la tabla prox para el algoritmo de Knuth-Moons-Pratt para el pa-
trón AAAAAAAAA.
3. Obtener la tabla prox para el algoritmo de Knutli-Moons-Pratt para el pa-
trón ABRACADABRA.
4. Dibujar una máquina de estados finitos capaz de encontrar el patrón
ABRACADABRA.
5. ¿Cómo se efectuaría una búsqueda en un archivo de texto de una cadena
de 50 espaciosen blanco consecutivos?
6. Obtener la tabla saltar de derecha a izquierda para la exploración de de-
recha a izquierda del patrón ABRACADABRA.
7. Construir un ejemplo para el que la exploración del patrón de derecha a
izquierda (aplicando solamente la heurística de la no concordancia) tenga
un mal rendimiento.
8. ¿Cómo se modificaría el algoritmo de Rabin-Karp para buscar un deter-
minado patrón con la condición adicional de que el carácter central sea un
«comodín» (es decir, que pueda concordar con cualquier carácter)?
9. Implementar una versión del algoritmo de Rabin-Karp para buscar patro-
nes en un texto de dos dimensiones. Se supondrá que tanto el patrón como
el texto son rectángulos de caracteres.
10. Escribirprogramas para generar una cadena de texto aleatoria de 1.O00bits
y después encontrar todas las ocurrencias de los últimos k bits en cualquier
lugar de la cadena, para k = 5. 10, 15. (Para diferentes valores de k pueden
ser apropiados métodos diferentes.)
Algoritmos en C++.pdf
20
Reconocimiento de patrones
A menudo es deseable efectuar búsquedas de cadenas sobre la base de una pe-
queña información sobre el patrón a buscar. Por ejemplo, los usuarios de un
editor de texto quizás quisieran especificar sólo una parte del patrón, o especi-
ficar un patrón que permita una concordancia con varias palabras diferentes,o
especificar que se debe ignorar cualquier número de ocurrencias de determina-
dos caracteres. En este capítulo se estudiará cómo se puede hacer eficazmente
el reconocimiento depatrones de este tipo.
Los algoritmos del capítulo anterior tienen tal vez una dependencia funda-
mental de la especificación completa del patrón, por lo que hay que considerar
otros métodos. Los mecanismosbásicos que se verán permiten disponer de he-
rramientas muy poderosas de búsqueda de cadenas, capaces de reconocer com-
plicados patrones de M caracteres en cadenas de texto de N caracteres en un
tiempo proporcional a MN2en el peor caso, y mucho más rápidamenteen apli-
caciones típicas.
En primer lugar hay que desarrollar una forma de describir los patrones: un
«lenguaje» que pueda utilizarse para especificar, de forma rigurosa, los tipos de
problemas de búsqueda parcial de cadenas que se han sugerido anteriormente.
Este lenguaje implicará operaciones primitivas más poderosas que la simple
operación de ((verificarsi el i-ésimo carácter de la cadena de texto concuerda
con elj-ésimo carácter del patrón» utilizada en el capítulo anterior. En este
capítulo se considerarán tres operaciones básicas en términos de un tipo ima-
ginario de máquina capaz de buscar patrones en una cadena de texto. El algo-
ritmo de reconocimiento de patrones será una forma de simular el funciona-
miento de este tipo de máquina. En el próximo capítulo se verá cómo pasar de
una especificación de patrón, que el usuario emplea para describir su tarea de
búsqueda de cadenas, a la especificación de máquina que realmente emplea el
algoritmo para llevar a cabo la búsqueda.
Como se verá, la solución que se desarrolla para resolver el problema del
reconocimiento de patrones está íntimamente relacionada con ciertos procesos
fundamentales de la informática. Por ejemplo, el método que se utilizará en el
325
326 ALGORITMOS EN C++
programa para llevar a cabo la tarea de búsqueda de una cadena subtendida por
la descripción de un determinado patrón es análogo al método utilizado por el
sistema C++ para efectuar una operación de cálculo de un determinado pro-
grama en C++.
Descripción de patrones
En este capítulo se considerarán las descripcionesde patrones constituidos por
símbolos relacionados por las tres operaciones fundamentales siguientes:
(i) Concatenación.Ésta es la operación utilizada en el capítulo anterior. Si
dos caracteres son adyacentes en el patrón, entonces hay concordancia
si y sólo si los dos mismos caracteres son adyacentes en el texto. Por
ejemplo, AB significaA seguido de B.
(ii) Unión (Or).Ésta es la operación que permite especificar alternativas en
el patrón. Si se tiene un or entre dos caracteres, hay concordancia si y
sólo si uno de los dos caracteres figura en el texto. Se representa esta
operación con el símbolo + y utilizando los paréntesis para combinarlo
con la concatenación en situaciones arbitrariamente complejas. Por
ejemplo A+B significa «A o B»; C(AC+B)Dsignifica «CACD o CBD»;
y (A+C)((B+C)D)significa d B D o CBD o ACD o CCD».
(iii) Clausura. Esta operación permite que algunas partes del patrón se pue-
dan repetir arbitrariamente. Si se aplica la clausura a un símbolo, en-
tonces hay concordancia si y sólo si el símbolo aparece cualquier nú-
mero de 'veces(incluyendo O). La clausura se representará poniendo un
* después del carácter o grupo entre paréntesis que se quiere repetir. Por
ejemplo, AB* concuerda con las cadenas que consisten en una A se-
guida de cualquier número de B, mientras que (AB)*concuerda con las
cadenas que consisten en repeticiones de la seiie AB.
Una cadena de símbolos construida por medio de estas tres operaciones se
denomina una expresión regular. Cada expresión regular describe muchos pa-
trones de texto. El objetivo es desarrollar un algoritmo que determine si alguno
de los patrones descrito por una expresión regular aparece dentro de una deter-
minada cadena de texto.
Se concentrará la atención en la concatenación, la unión y la clausura con
vistas a mostrar los principios básicos del desarrollo de algoritmos para el reco-
nocimiento de patrones descritos por expresiones regulares. Por conveniencia,
en los sistemas reales normalmente se hacen varias adiciones. Por ejemplo, -A
puede significar concuerda con cualquier carácter excepto con A». Esta ope-
ración not es la misma que un or de todos los caracteres diferentes de A, pero
es mucho más fácil de utilizar. De modo similar, "?" significa concuerda con
cualquier letra). De nuevo, esto es evidentemente mucho más compacto que
un gran or. Entre los otros ejemplos de símbolosadicionalesque facilitan la es-
RECONOCIMIENTO DE PATRONES 327
pecificación de grandes patrones figuran los símbolos de concordancia con el
comienzo o el final de una línea, con una letra o un número cualquiera, etcé-
tera.
Estas operaciones pueden ser marcadamente descriptivas. Por ejemplo, el
patrón descrito por ?*(ie+ ei)?*concuerda con todas las cadenas que tienen un
ie o un ez en ellas (¡posiblemente a causa de una falta de ortografía!);el patrón
(1+01)*(0+1) describe todas las cadenas de O y 1 que no tienen dos O consecu-
tivos. Evidentemente hay muchas descripcionesdiferentes de patrones para des-
cribir las mismas cadenas: se debe intentar especificardescripcionesde patrones
sucintas, al igual que se intenta escribir algoritmos eficaces.
El algoritmode reconocimientode patrones qiie se va a examinar puede verse
como una generalización del método de búsqueda de cadenas por fuerza bruta
de izquierda a derecha (el primer método que se vio en el Capítulo 19).El al-
goritmo busca la primera subcadena, empezando por la izquierda de la cadena
de texto, que concuerde con la descripción del patrón. Esto lo hace explorando
la cadena de texto de izquierda a derecha, comprobando la existencia, en cada
posición, de una subcadena que comienza en esa posición y que concuerda con
la descripción del patrón.
Máquinas de reconocimientode patrones
Recuérdese que se puede considerar al algoritmo de Knuth-Monis-Pratt como
una máquina de estados finitos, construida a partir del patrón de búsqueda que
explora el texto. El método que se va a utilizar para el reconocimiento de patro-
nes con expresionesregulares es una generalizaciónde este proceso.
La máquina de estados finitos del algoritmo de Knuth-Monis-Pratt pasa de
un estado a otro, examinando un carácter de la cadena de texto, y cambiando
a un estado si hay concordancia y a otro si no la hay. Una discordancia en algún
punto significa que el patrón no puede figurar en el texto precisamente en ese
punto. El propio algoritmo puede representarse como una simulación de la má-
quina. La característica de la máquina que hace fácil su simulación es que es
determinista: cada transición de estado se determina totalmente por el próximo
carácter de entrada.
Para tratar expresiones regulares, será necesario considerar una máquina
abstracta más poderosa. Debido a la operación or, la máquina no puede deter-
minar cuándo aparece (o no) el patrón en un punto determinado examinando
solamente un carácter. De hecho, debido a la clausura, no puede ni siquiera de-
terminar cuántos caracteres será preciso examinar antes que se descubra una
discordancia. La forma más natural de evitar estos problemas es dotar a la má-
quina del poder del nu determinismo:cuando se enfrente con más de una forma
de tratar de concordar con el patrón, la máquina debe padivinan) la correcta!
Esta operación parece imposible de admitir, pero se verá que es fácil de escribir
un programa que simule las acciones de una tal máquina.
328 ALGORITMOS EN C++
Figura 20.1 Una máquina no determinista de reconocimientodel patrón (A*B+AC)D.
La Figura 20.1 muestra una máquina de estados finitos no determinista que
podría utilizarse para buscar en una cadena de texto el patrón descrito por
(A*B+AC)D. (Los estados están enumerados según una regla que se explicará
posteriormente.) Al igual que la máquina determinista del capítulo anterior, la
máquina puede pasar de un estado actual etiquetado por un carácter, al estado
«apuntado» por el estado actual, si puede concordar (y superar) ese carácter en
la cadena de texto. Lo que hace a la máquina no determinista es que hay ciertos
estados(denominados estados nulos) que no sólo no están etiquetados, sino que
pueden ((apuntar a» dos estados sucesores diferentes. (Algunos estados nulos,
tal como el estado 4 del diagrama, son estados que «no operan» con una sola
salida,que no afectan a la operación de la máquina, pero facilitan la implemen-
tación del programa que construye la máquina, como se verá. El estado 9 es un
estado nulo sin salidas, que permite la parada de la máquina.) Cuando se en-
cuentra en un estado nulo, la máquina puede dirigirse hacia uno cualquiera de
los estados sucesores,independientemente de la entrada (sin superar al próximo
carácter). La máquina tiene el poder de adivinar qué transición conducirá a una
concordancia en la cadena de texto dada (si es que existe alguna). Se observa
que no hay transiciones de «no concordancia» como en el capítulo anterior: la
máquina falla en su intento de encontrar una concordancia sólo si no hay forma
de adivinar una serie de transiciones que conduzcan a una concordancia.
La máquina tiene un único estado inicial (indicado por la línea de la iz-
quierda que no sale de ningún círculo) y un único estadofinal (el pequeño cua-
drado de la derecha). Cuando se parte de un estado inicial, la máquina drbe ser
capaz de «reconocen>cualquier cadena descritapor el patrón leyendocaracteres
y cambiando de estado de acuerdo con las reglas, hasta llegar al ((estadofinal».
Como la máquina tiene el poder del no determinismo, puede adivinar la sene
de cambios de estadosque pueden conducir a la solución. (Pero cuando se trata
de simular esta máquina en una computadora estándar, se debe probar con to-
das las posibilidades.)Por ejemplo, para determinar si la descripción de patrón
(A*B+AC)Dpuede figurar en la cadena de texto
CDAABCAAABDDACDAAC
la máquina indicaría inmediatamente un fallo si se comenzara por el primer o
RECONOCIMIENTODE PATRONES 329
e
Figura 20.2 Reconocimiento de AAABD.
segundo carácter; trabajaría algo más para informar de un fallo si comenzara
por los dos caracteressiguientes;indicaría inmediatamente un fallo al comenzar
por el quinto o sexto carácter; y acertaría la serie de transiciones de estados que
se muestra en la Figura 20.2 para reconocer AAABD, si se comienza por el sép-
timo carácter.
Se puede construir la maquina para una expresión regular construyendo
máquinas parciales para partes de la expresión y definiendo las reglas de uni6n
de dos máquinas parciales para formar una más grande para cada una de las
tres operaciones: concatenación, or y clauswa.
Se comienza por construir la máquina trivial para reconocer un carácter es-
pecífico. Es práctico escribir esto como una máquina de dos estados, con un es-
tado inicial (que deberá reconocer el carácter) y un estado final, como se mues-
tra en la Figura 20.3.
Para construir la máquina para la concatenación de dos expresionesa partir
de las máquinas de sus expresionesindividuales, es suficientecon mezclar e!. es-
tado final de la primera con el estado inicial de la segunda, como se muestra en
ia Figura 20.4.
De forma similar, la máquina para la operación or se construye añadiendo
330 ALGORITMOS EN C++
U
Figura 20.3 Máquina de dos estados para reconocer un carácter.
un nuevo estado nulo que apunte a los dos estados inicialesy haciendo que uno
de los estados finales apunte al otro, el cual pasa a ser el estado final de la má-
quina unión, como se muestra en la Figura 20.5.
Finalmente, la máquina para la operación de clausura se construyc convir-
tiendo el estado final en estado inicial y haciéndolo apuntar hacia el antiguo es-
tado inicial, así como sobre el nuevo estado final. Esto se muestra en la Figura
20.6.
Aplicando sucesivamente estas reglas se puede construir la máquina corres-
pondiente a cualquier expresión regular. Los estados de la máquina de los ejem-
plos anteriores se han numerado en el orden de construcción, explorandoel pa-
trón de izquierda a derecha, por lo que se puede seguir fácilmente el proceso de
construcción de la máquina. Nótese que se tiene una máquina elemental de dos
estados por cada letra de la expresión regular y que cada + y * entraíian !a crea-
U
Figura 20.4 Construcción de una máquina de estados: concatenación.
Figura 20.5 Construcción de una máquina de estados: or.
RECONOCIMIENTODEPATRCINES 33i
Figura 20.6 Construcción de una máquinade estados: clausura.
ción de un estado (la concatenaciónprovoca la desaparición de uno), por lo que
el número de estados es inferior a dos veces el número de caracteres de la ex-
presión regular.
Representación de la máquina
Las mencionadas máquinas no deterministas se construirán utilizando sólo las
reglas de composición esbozadas anteriormente, y se podrá aprovechar su es-
tructura tan simple para manipularlas de forma directa. Por ejemplo, de un es-
tado cualquiera sale un máximo de dos líneas. De hecho, hay solamente dos ti-
pos de estados: los etiquetados por un carácter del alfabeto de entrada (de los
que sale una sola línea) y 10s no etiquetados(nulos) (de los que salen dos o me-
nos líneas). Esto significa que estas máquinas se pueden representar con muy
poca información por nodo. Puesto que a menudo se desea acceder a los esta-
dos por su número, la forma más conveniente de organización para la máquina
es una representaciónpor array. Se utilizarán tres arrays paralelos carac, proxl,
y prox2, indexados por estado, para representar y tener acceso a la máquina.
Sería posible lograrlo con dos tercios de este espacio de memoria, puesto que
cada estado realmente sólo utiliza dos partes significativasde información, pero
no se hará uso de estas mejoras para ganar en claridad y también porque en
definitiva es probable que las descripciones de los patrones no sean muy largas.
La máquina anterior se puede representar como indica la Figura 20.7. Las
entradas indexadas por estado pueden interpretarse como instncciones para la
máquina no determinista de la forma «Si está usted en estado y ve ca-
O 1 2 3 4 5 6 7 8 9
carac A B A C D
proxl 5 2 3 4 8 6 7 8 9 0
prox2 5 2 1 4 8 2 7 8 9 0
Figura 20.7 Representaciónpor array de la máquinade la Figura 20.1.
332 ALGORITMOSEN C++
rac[estado], entonces lea el carácter y vaya al estado proxl [estado] (o al
prox2 [estado]))). En este ejemplo, el Estado 9 es el estado final y el estado O
es un estado pseudo-inicial cuyos valores en los arrays prox son los números de
los estados iniciales reales. Obsérvese la representación especial utilizada para
los estados nulos con un sucesor (las entradas en lcs dos arrays prox son igua-
les) y para el estado final (con cero en ambas entradas de los arrays prox).
Se ha visto la forma de construir máquinas para patrones descritos por ex-
presiones regulares y cómo representar tales máquinas con arrays. Sin embargo,
escribir un programa que haga pasar de una expresión regular a la representa-
ción correspondiente por una máquina no determinista es otra cosa. En efecto,
incluso escribir un programa para determinar si una expresión regular es legal
es un reto para los principiantes. En el próximo capítulo se estudiará detalla-
damente esta operación, denominada análisis sintáctico. Por el momento, se
supondrá que se ha hecho dicho paso, por lo que se dispone de los arrays carac,
proxl, y prox2 que representan a una máquina no determinista determinada,
que corresponde a la expresión regular que describe al patrón objeto de interés.
Simulación de la máquina
El último paso en el desarrollo de un algoritmo general para el reconocimiento
de patrones descritospor expresionesregulares consisteen escribirun programa
que de alguna forma simule la operación de una máquina no determinista de
reconocimiento de patrones. La idea de escribir un programa que pueda «adi-
vinan) la respuesta correcta parece ridícula. Sin embargo, en este caso se puede
ir «memorizando» sistemáticamente todas lasposibles concordancias, de modo
que siempre se acabe por encontrar la correcta.
Una posibilidad sería desarrollar un programa recursivo que imite a la má-
quina no determinista (pero que trate todas las posibilidades en lugar de estar
adivinando la correcta). En lugar de utilizar esta variante, se va a examinar una
implementación no recursiva que expondrá los principios básicos de operación
del método, guardando los estados que se están considerando en una estructura
de datos algo peculiar denominada deqtle (cola de doble extremo).
La idea es conservar todos los estados que se podrían encontrar mientras la
máquina está «mirando» el carácter de entrada. Cada uno de estos estados se
procesa en su momento: los estados nulos conducen a dos estados (o menos),
los estados para caracteres que no concuerdan con el carácter de entrada se eli-
minan y los estados para caracteres que concuerdan con el carácter de entrada
conducen a nuevos estados a utilizar cuando la máquina esté examinando el
próximo carácter de entrada. Así pues, se desea mantener una lista de todos los
estados en los que la máquina no determinista podría encontrarse en un punto
particular del texto. El problema es diseñar una estructura de datos apropiada
para esta lista.
El procesamiento de los estados nulos parece necesitar una pila, puesto que
RECONOCIMIENTODE PATRONES 333
esencialmente se está posponiendo una de las dos accionesa tomar, al igual que
al eliminar una recursión (por tanto, el nuevo estado se debe poner al comienzo
de la lista para que no quede pospuesto indefinidamente). El procesamiento de
los otros estados parece necesitar una cola, dado que no se desea examinar los
estados correspondientes al próximo carácter de entrada hasta que no se ter-
mine con el carácter en curso (por tanto el nuevo estado se debería poner al
j k a l de la lista). En lugar de escoger entre estas dos estructuras, jse utilizarán las
dos! Las deques combinan las características de las pilas y de las colas: una de-
que es una lista en la que los elementos se pueden añadir por los dos extremos.
(En realidad, se utiliza una «deque de salida restringida)),puesto que se quitan
los elementos del comienzo, no del final.)
Una propiedad primordial de la maquina es que no tiene ««bucles»
que estén
constituidos únicamente por estados nulos, puesto que de otra manera se po-
dría caer, en una forma no determinista, en un bucle infinito. Esto implica que
el número de estados de la deque, en cualquier momento, es menor que el nú-
mero de caracteres de 12descripción del patrón.
El programa que se presenta postericrmente utiliza una deque para simular
las acciones de una máquina de reconocimiento de patrones no determinista
como la que se describiócon anterioridad. Mientras está examinando un carác-
ter particular en la entrada, la máquina no determinista puede encontrarse en
un número limitado de estados posibles: el programa conserva la pista de estos
estados en una deque, utilizando los procedimientos meter, poner y sacar,pa-
recidos a los del Capítulo 3. Se podría utilizar también una representación por
array (como en la implementación de cola del Capítulo 3) o una representación
por lista enlazada (como en la implementación de pila del Capítulo 3). Se omi-
ten los detalles de la implementación.
El bucle principal del programa retira un estado de la deque y lleva a cabo
la acción requerida. Si se va a hacer la concordancia con un carácter, se com-
prueba si éste existe en la entrada: si hay concordancia se efectúa el cambio de
estado poniendo el nuevo estado alfinal de la deque (por tanto, todos los esta-
dos que implican el carácter en curso se procesan antes que los que implican el
carácter siguiente). Si el estado es nulo, los dos estados posibles que se deben
simular se ponen al comienzo de la deque. Los estados que implican el carácter
en curso se guardan separadamente de aquellos que implican el próximo carác-
ter mediante una marca avanza=-1 en la deque: cuando se encuentra esta marca
«avanza», se avanza el puntero en la cadena de entrada. El bucle termina
cuando se llega al finalde la entrada (no se encontró una concordancia),cuando
se llega al estado O (se encontró una concordancia legal), o cuando sólo está la
marca avanza en la deque (no se encontró ninguna concordancia). Esto con-
duce directamente a la siguiente implementación:
const int avanza = -1;
int concordar(char *a)
i
int nl, n2; Deque dq(100);
334 ALGORITMOS EN C++
i n t j = O, N = s t r l e n ( a ) , estado = proxl[O];
dq. poner (avanza) ;
while (estado)
i f (estado == avanza) { j++; dq. poner (avanza) ; }
{
else i f (carac[estado] == a [ j ] )
else i f (carac[estado] == ' ' )
dq.poner(proxl[estado]) ;
p l = proxl[estado]; n2 = proxZ[estado];
dq .meter ( p i ) ;
i f ( p i != p2) dq.rneter(p2);
{
1
1
i f (dq.vacio() 11 j==N) return O;
estado = dq.sacar();
I
r e t u r n j ;
Esta función toma como argumento el puntero a la cadena de texto a en la que
se intenta encontrar una concordancia, utilizando la máquina no determinista
que representa al patrón a través de los arrays carac, proxl y prox2 descritos
anteriormente. Esta función devuelve la longitud de la subcadena inicial más
corta que concuerda con el patrón (y O si no hay concordancia). Por convenien-
cia, se supone que el último carácter de la cadena de texto a es un carácter cen-
tinela único que no se repite en ningún otro lugar del array carac que representa
al patrón.
La Figura 20.8 muestra el contenido de la deque cada vez que se elimina un
Figura 20.8 Contenido de la deque durante el reconocimientode AAABD.
RECONOCIMIENTO DE PATRONES 335
estado cuando la máquina ejemplo se pone a trabajar con la cadena de texto
AAABD. Este diagrama supone una representación por array, como la utilizada
para las colas del Capítulo 3: se utiliza un signo de suma para representar
avanza. Cada vez que la marca avanza alcanza el frente de la deque (parte in-
ferior del diagrama), el puntero j avanza hacia el siguiente carácter del texto.
Así pues, se comienza con el estado 5 mientras se explora el primer carácter del
texto (la primera A). El estado 5 conduce a los estados 2 y 6; después el estado
2 conduce a los estados 1 y 3, los cuales necesitan leer el mismo carácter y se
encuentran al comienzo de la deque. Después el estado 1 conduce al estado 2,
pero al final de la deque (para el próximo carácter de entrada). El estado 3 con-
duce a otro estado sólo mientras se está explorando una B; por lo tanto se ig-
nora mientras se está explorando una A. Cuando finalmente el centinela
«avanza» alcanza el frente de la deque, se ve que la máquina puede estar en el
estado 2 o en el estado 7 después de leer una A. El programa trata entonces los
estados 2, 1, 3 y 7 mientras «está mirando» a la segunda A, para descubrir, la
segunda vez que avanza llega al comienzo de la deque, que el estado 2 es la
única posibilidad después de la exploración de AA. Ahora, mientras se está exa-
minando la tercera A, las únicas posibilidades son los estados 2, 1 y 3 (la posi-
bilidad AC ahora está excluida). Estos tres estados se tratan nuevamente, para
conducir por último al estado 4 después de la exploración de AAAB. Conti-
nuando, el programa va al estado 8, pasa la D y termina en el estado final. Se
ha encontrado una concordancia, pero, lo que es más importante, se han con-
siderado todas las transiciones coherentes con la cadena de texto.
Propiedad 20.1 La simulación del funcionamiento de una máquina de M es-
tados para buscar patrones en un texto de N caracteres se puede hacer con me-
nos de NM transiciones de estados en el peor caso.
Por supuesto, el tiempo de ejecución de concordar depende muy fuertemente
del patrón que se está reconociendo. Sin embargo, para cada uno de los N ca-
racteres de entrada, parece que se procesan a lo sumo M estados de la máquina;
por tanto, el tiempo de ejecución del peor caso debe ser proporcional a MN (para
cada posición de comienzo en el texto). Por desgracia, esto no es cierto para
concordar, tal como antes se ha implementado, porque cuando se pone un es-
tado en la deque el programa no verifica si ya estaba allí, por lo que la deque
puede contener copias duplicadas de un mismo estado. Esto puede no tener
mucho efecto en aplicacionesprácticas, pero sí provocar una ejecución excesiva
en algunos casos patológicossi se deja sin verificar. Por ejemplo, este problema
tarde o temprano conduce a una deque con 2N-' estados cuando se concuerda
el patrón (A*A)*Bcon una cadena de N A seguida de una B. Para evitar esto,
las rutinas de la deque utilizadas por concordar deben estar implementadas de
forma tal que se eviten las duplicaciones de estados en la deque (con el fin de
garantizar que a lo sumo se procesarán M estadospor cada carácter de entrada).
Esto se puede hacer manteniendo un array indexado por estado, que indica
qué estados están en la deque. Con este cambio, el número total de operaciones
336 ALGORITMOS EN C++
necesarias para determinar si una porción de la cadena de texto está descritapor
el patrón se encuentra en o ( M N ~ ) . ~
No todas las máquinas no deterministas se pueden simular tan eficazmente,
como se verá con más detalle en el Capítulo 40, pero la utilización de una hí-
potética máquina simple para el reconocimiento de patrones conduce a un al-
goritmo bastante razonable para un problema bastante dificil. Sin embargo, para
completar el algoritmo se necesita un programa que permita pasar de expresio-
nes regulares arbitrarias a «máquinas» que se puedan interpretar con el código
anterior. En el próximo capítulo se verá la implementación de un programa tal
en el contexto de una presentación más generalde técnicas de compilación y de
análisis sintáctico.
Ejercicios
1. Obtener una expresiónregular para el reconocimiento de todas las ocurren-
cias de una serie de cuatro (o menos) 1 consecutivosen una cadena binaria.
2. Dibujar la máquina no determinista de reconocimiento de patrones para la
descripción del patrón (A+B)*+C.
3. Plantear las transmisiones de estados que haría la máquina del ejercicio an-
terior para reconocer ABBAC.
4. Explicar cómo se modificaría la máquina no determinista para manipular
la función not.
5. Explicar cómo se modificaría la máquina no determinista para manipular
caracteres del tipo «sin importancia».
6. ¿Cuántos patrones diferentes se pueden describir por una expresión regular
con M operadores or y ningún operador de clausur?i?
7. Modificar concordar para que manipule expresionesregulares con la fun-
ción not y caracteresdel tipo «sin importancia».
8. Mostrar cómo construir una descripción de un patrón de longitud M y una
cadena de texto de longitud N para los que el tiempo de ejecución de con -
cordar sea tan grande como sea posible.
9. Implementar una versión de concordar que evite el problema descrito en
la demostración de la propiedad 20.1.
10. Mostrar el contenido de la deque cada vez que se suprime un estado al uti-
lizar concordar para simular la máquina del ejemplo que se ha utilizado
en el capítulo, con la cadena de texto ACD.
21
Análisis sintáctico
Se han desarrollado diversos algoritmos fundamentales para reconocer si los
programas de computadora son válidos y desconiponerlosde forma propicia para
su posterior procesamiento.Esta operación,denominada anúlisis sintáctico,tiene
aplicaciones más allá de la informática, dado que está relacionada con el estu-
dio de la estructura del lenguaje en general. Por ejemplo, el análisis sintáctico
tiene un papel fundamental en los sistemas que tratan de centendem los len-
guajes naturales (humanos) y en los de traducción de una lengua a otra. Un caso
particular de interés es la transformación de un lenguaje de computadora «de
alto nivel» como C++ (conveniente para el uso humano) a un lenguajede ((bajo
nivel» como un ensamblador o uno de máquina (conveniente para ejecutar por
computadora). Un programa que hace tales transformaciones se denomina un
compilador. De hecho, ya se ha visto un método de análisis sintáctico, en el Ca-
pítulo 4, cuando se construyó un árbol para representar una expresión aritmé-
tica.
En el análisis sintáctico se utilizan dos metodologíasgenerales. Los métodos
descendentestratan de probar si un programa es válido buscando en primer lu-
gar las partes del programa que son válidas, y después las partes de esas partes,
etc., hasta que las piezas sean lo suficientemente pequeñas coma para que co-
rrespondan directamente con la cadena de entrada. Los métodos ascendentes
van juntando piezas de la entrada de una manera estructurada formando piezas
cada vez mayores hasta que se obtenga un programa válido. En general, los mé-
todos descendentes son recursivos y los ascendentes iterativos. En general se
piensa que los método? descendentes son más fáciles de implementar y los as-
cendentes más eficaces. El método del Capítulo 4 era ascendente; en este capí-
tulo se estudiará con detalle un método descendente.
El tratamiento completo de los temas referentes a los analizadores sintácti-
cos y a la construcción de compiladores está claramente fuera del alcance de
este libro. Sin embargo, mediante la construcción de un ((sencillocompiladom
para completar el algoritmo de reconocimiento de patrones del capítulo ante-
rior, se estará también considerando algunos de los conceptos fundamentaies
337
338 ALGORITMOS EN C++
subyacentes. Primero se construirá un andizador sintácticodescendentepara un
lenguaje simple de descripción de expresionesregulares. Luego se modificará el
analizador sintáctico para hacer un programa que traduzca expresionesreguIa-
res en máquinas de reconocimiento de patrones que puedan utilizarse por el
procedimiento concordar del capítulo anterior.
La intención del capítulo es proporcionar una aproximación a los principios
básicos del análisis sintáctico y la compilación, a la vez que se desarrolla un al-
goritmo útil de reconocimiento de patrones. Ciertamente no se podrá abordar
todo lo que se trate aquí con la profundidad que se merece. El lector debe saber
que pueden aparecer dificultades sutiles cuando se aplica la misma estrategia a
problemas similares, y que la construcción de compiladores es un campo bas-
tante rico con una gran variedad de métodos avanzados de aplicación en situa-
ciones senas.
Gramáticas libres de contexto
Antes de que se pueda escribir un programa que determine si es válido un pro-
grama escrito en un cierto lenguaje, se necesita una descripciónde lo que carac-
teriza exactamente a un programa válido. Esta descripción se denomina gra-
mática:para apreciar esta terminología basta con pensar en una lengua como la
del lector y reemplazar en la oración anterior «oración» por «programa»(jex-
cepto la primera vez que aparece programa!). Los lenguajesde programación se
describen a menudo con un tipo particular de gramática denominada gramá-
tica libre de contexto. Por ejemplo, las siguientes reglas caracterizan a la gra-
mática libre de contexto definida por el conjunto de todas las expresiones re-
gulares válidas (como las descritas en el capítulo anterior).
<expresión>::= <término>I <término>+ <expresión>
<factor>::= (<expresión>)Iv I(<expresión>)*IY*
<término>::= <factor>I<factor><t&mino>
Esta gramática describe expresiones regulares como las que se utilizaron en el
capítulo anterior, tales como (1+O 1)*(0+1) o (A*B+AC)D. Cada línea de la gra-
mática se denomina una producción o regla de reemplazo. Las producciones se
componen de símbolos terminales (, ), + y *, que son los símbolos utilizados en
el lenguaje que se está describiendo (y «w, un símbolo especial, representa a
cualquier letra o dígito); de símbolos no terminales <expresión>,<término>y
<factor>,que son internos a la gramática; y de metasímbolos ::= y 1, que sirven
para describir el significadode las producciones. El símbolo ::=, que puede leerse
como «es un», define la parte izquierda de la producción (la cadena a la iz-
quierda del ::= ) en función de los términos de la parte derecha; y el símbolo 1,
que puede leerse como un «o» (or),que indica alternativas de selección. Las
ANÁLISIS SINTÁCTICO 339
expresión
/
término
/ ' .
y factor término
'
1 factor
I
I
expresión
término
' 1 1 expresión D
/
término
/ 
 I I
/ 
factor término
I
/  factor factor término
A *
A factor
I
C
B
Figura 21.l Un árbol de análisis sintáctico para (A'B+AC)D.
producciones expresadas con esta concisa notación simbólica corresponden de
forma simple a una descripción intuitiva de la gramática. Por ejemplo, la se-
gunda producción de la gramática del ejemplo puede leerse como «un <tér-
mino>es un <factono es un <factor>seguido de un <término>».Un símbolo no
terminal, en este caso <expresión>,se distingue en el sentido de que una cadena
de símbolos terminales pertenece al lenguaje descrito por la gramática si y sólo
si hay alguna forma de utilizar las reglas de producciones para derivar esa ca-
dena del no terminal distinguido reemplazando (en cualquier número de pasos)
un símbolo no terminal por alguna de las cláusulas or de la parte derecha de la
producción de ese símbolo no terminal.
Una forma natural de describir el resultado de este proceso de derivación es
un árbol de análisis sintáctico: un diagrama de la estructura gramatical com-
pleta de la cadena que se está analizando. Por ejemplo, el árbol de análisis sin-
táctico de la Figura 21.1 muestra que la cadena (A*B+AC)Dpertenece al len-
guaje descrito por la gramática anterior. A veces se utilizan árboles de análisih
sintáctico como éstos para descomponer una oración de un lenguaje natural, en
sujeto, verbo, objeto, etcétera.
La función principal de una analizador sintáctico es aceptar las cadenas que
se puedan derivar y rechazar las que no se puedan, intentando construir un ár-
bol de análisis sintáctico para una cadena dada. Esto es, el analizador sintáctico
puede reconocer si una cadena pertenece al lenguaje descrito por la gramática
determinando si existe o no un árbol de análisissintáctico para esa cadena. Los
analizadores sintácticos descendentes hacen esto construyendo el árbol comen-
zando por el símbolo no terminal distinguido en la raíz y descendiendo hacia la
cadena que se debe reconocer situada en el fondo del árbol. Los analizadores
sintácticos ascendentes lo hacen comenzando por la cadena del fondo del árbol
y ascendiendo hasta llegar al no terminal distinguido de la raíz. Como se verá,
340 ALGORITMOS EN C++
si la semántica de las cadenas a reconocer implica un procesamiento posterior,
entonces el analizador sintáctico puede convertir las cadenas en una repr-
usen-
tación interna que facilitetal procesamiento.
Otro ejemplo de una gramática libre de contexto se puede encontrar en el
apéndice del libro The C++Programming Language que describe los progra-
mas que son válidos en C++. Los principios considerados en esta sección para
el reconocimiento y empleo de expresioneslícitas se aplican directamente a la
compleja tarea de compilar y ejecutar programas en C++. Por ejemplo, la gra-
mática siguiente describe un subconjunto muy pequeño de C++, las expresio-
nes aritméticasque contienen sumas y multiplicaciones:
<expresión>::= <término>I <término>+ <expresión>
<término>::= <factor>I <factor>*<término>
<factor>::= (<expresión>)j v
Estas regias describen de una manera formal lo que se aceptó como válido en el
Capítulo 4: son reglas que especificanlas expresiones aritméticas «válidas».De
nuevo, v es un símbolo especialque representa cualquier letra, pero en esta gra-
mática las letras pueden representar variables con valores numéricos. A+(B*C)
y A*(((B+C)*(D*E))+F) son ejemplos de cadenas válidas para esta gramática.
Ya se vio en el Capítulo 4 un árbol de análisis sintáctico para la segunda de es-
tas cadenas, pero dicho árbol no correspondía a la gramática anterior; por ejem-
plo, no incluía explícitamente los paréntesis.
Tal como se han definido ¡as cosas hasta aquí, algunas cadenas son perfec-
tamente válidas como expresiones aritméticas y como expresiones regulares. Por
ejemplo, A*(B+C) puede significar ((sumarB a C y multiplicar el resultado por
ADo «tomar un número cualquiera de A seguido de una B o de una C». Este
punto evidencia el hecho obvio de que verificar si una cadena es válida es una
cosa, pero comprender su significado es otra diferente.Se volverá sobre este tema
una vez que se haya visto cómo analizar una cadena para verificar si está des-
crita o no por una cierta gramática.
Cada expresión regular es por sí misma un ejemplo de gramática libre de
contexto: cualquier lenguje que se puede describir con una expresión regular
también se puede describir con una gramática libre de contexto. La recíproca
no es cierta: por ejemplo, el concepto de paréntesis «equilibrados» no se puede
reproducir por medio de una expresión regular. Otros tipos de gramáticas pue-
den describir lenguajes que las gramáticas libres de contexto no pueden. Por
ejemplo, las gramáticas dependientes del contexto o sensibles al contexto son
idénticas a las anteriores excepto que la parte izquierda de las producciones no
tiene que ser de sólo un no terminal. Las diferenciasentre clases de lengiiajesy
la jerarquía de las gramáticas que los describen se han estudiado muy cuidado-
samente constituyendo una magnífica teoría que se ubica en el corazón de la
ciencia informática.
ANÁLISIS CINTÁCTICO 341
Aniilisis descendente
Algunos métodos de análisis sintáctico utilizan la recursión para reconocer las
cadenas del lenguaje descritas exactamente como se especificaron por la gra-
mática. De forma más simple, la gramática es una especificacióntan completa
del lenguaje ¡que se puede poner directamente en forma de programa!
Cada producción correspondea un procedimiento con el mismo nombre que
el no terminal de la parte izquierda. Los no terminales de ia parte derecha co-
rresponden a llamadas (posiblemente recursivas) a procedimientos; los termi-
nales corresponden a la exploración de la cadena de entrada. Por ejemplo, el
procedimiento siguiente es parte de un analizador sintáctico descendente para
la gramática de expresiones regulares:
expresion ( )
termi no ( ) ;
if (p[j] == I + ' )
{
{ j++; expresiono; }
1
Una cadena p contiene la expresión regular que se está analizando, con un ín-
dice j que apunta al carácter que se está examinando actualmente. Para anali-
zar una expresión regular dada p, se pone j en O y se llama a expresion. Si
resulta que j llega a ser M, entonces la expresión regular pertenece al lenguaje
descrito por la gramática. Si no, se verá a continuación cómo tratar las distintas
situaciones de error. La primera acción de expresi OR es llamar a termi no, cuya
implementación es algo más complicada:
termi no ( )
factor();
if ((p[j] == ' ( I ) /I ~ e t r a ( p [ j l ) )
termino();
{
}
Una implementación directa de la gramática haría que termi no llamara pri-
mero a factor y luego a termino. Esto está evidentemente condenado al fra-
caso porque fio hay forma de salir de termi no: este programa caería en un bu-
cle infinito de llamada? recursivas.(Talesbucles tienen efectos muy molestos en
muchos sistemas.)La implementación anterior evita esto comprobando en pri-
mer lugar la cadena de entrada para decidir si se debe o no llamar a termi no.
Lo primero que termino hace es llamar a factor, que es el ú?iico de los pro-
cedimientos que puede detectar una no concordancia con !a cadena de entrada.
Por la gramática se sabe que cuando se llama a factor, el carácter actual de la
342 ALGORITMOS EN C++
entrada debe ser o un o una letra (representada por v). Este proceso de ve-
rificar el próximo carácter de la entrada, sin incrementar j ,para decidir qué ha-
cer, se denomina examen por anticipado (anticipación).En algunas gramáticas
esto no es necesario, pero en otras puede ser el caso que se necesite más de una
anticipación.
La implementación de factor se obtiene ahora directamente de la gramática.
Si el carácter que se está explorando no es «(» o una letra, se llama a un proce-
dimiento error para manipular la condición de error:
factor ( )
if (p[j] == ' ( I )
j++; expresi on () ;
if (p[j] == I ) ' ) j++; else error();
{
1
else if (letra(p[j])) j++;else error();
if (p[j] == ' * I ) j++;
1
Otra condición de error se produce cuando falta un a))).
Las funciones expresion, termi no y factor son evidentemente recursi-
vas; de hecho están tan interrelacionadas que no hay forma de escribirlas de
forma tal que se pueda declarar cada función antes de llamarla (esto representa
una dificultad en ciertos lenguajesde programación).
El árbol de análisis sintáctico de una cadena dada proporciona la estructura
de las llamadas recursivasdurante el análisis sintáctico. La Figura 21.2 muestra
sucesivamente las tres operaciones anteriores cuando p contiene la cadena
(A*B+AC)D y se llama a expresion con j=l.Excepto para el signo +, toda la
«exploración» se efectúa en factor. Para mayor legibilidad, los caracteres que
recorre factor, excepto los paréntesis, se inscriben en la misma línea que la
llamada a factor.
Se anima al lector a relacionar este proceso con la gramática y el árbol de la
Figura 21.1. Este proceso corresponde a recorrer el árbol en orden previo, aun-
que la correspondencia no es exacta porque la estrategia de anticipación busca
esencialmente cambiar la gramática. Puesto que se parte de la raíz del árbol y
se trabaja hacia abajo, es evidente el origen del nombre «descendente». Tales
analizadores sintácticos también se denominan analizadores sintácticos recur-
sivo descendentes porque recorren el árbol recursivamente.
Esta estrategia descendente no funciona con todas las gramáticas libres de
contexto posibles. Por ejemplo, si la producción <expresión>::=
v I <expresión>
+ <término>se llevara mecánicamente a C++, se obtendría el siguiente resul-
tado indeseable:
ANÁLISIS SINTÁCTICO 343
mal a-expresi on ( )
i f (letra(p[j]))j++;
el se
mal a-expresi on ( ) ;
i f (p[j] = ' + I ) { j++; termino(); }
el se error () ;
{
1
}
Si este procedimiento se llamara cuando en p [j ] hubiera un valor diferente de
una letra (como en el ejemplo, para j=l)caería en un bucle recursivo infinito.
Evitar tales bucles es una de las principales dificultades en la implementación
de analizadores recursivos descendentes. En termi no, se utiliza una anticipa-
ción para evitar un bucle de esta naturaleza; en este caso una buena solución
consiste en invertir la gramática y decir <termino>+ <expresión> en lugar de
<expresión>+ <término>.
La ocurrencia de un no terminal como primer ele-
mento de la parte derecha de una producción que tiene en la parte izquierda el
mismo no terminal se denomina recursividad izquierda. De hecho, el problema
expresion
termino
factor
(
expresi on
termino
factor A *
termi no
factor B
+
expresi on
termi no
factor A
termino
factor C
)
termi no
factor D
Figura 21.2 Análisis sintáctico de (A'B+AC)D.
344 ALGORITMOS EN C++
es más sutil, porque la recursión izquierda puede aparecer indirectamente, por
ejemplo en las producciones <expresión>::=<término>
y <término>::=v
1 cexpre-
sión>+<término>.
Los analizadores sintácticos recursivos descendentes no fun-
cionan con tales gramáticas; es preciso transformarlos en gramáticas equivalen-
tes sin la recursión izquierda, o bien utilizar algún otro método de análisis
sintáctico. En general, hay una conexión muy íntima y ampliamente estudiada
entre los analizadores sintácticos y las gramáticas que éstos reconocen. La elcc-
ción de la técnica de análisis sintáctico depende a menudo de las características
de la gramatica que se va a analizar.
Análisis sintáctico ascendente
Aunque hay varias llamadas recursivas en los programas anteriores, es un ejer-
cicio instructivo eliminar sistemáticamente la recursión. Se vio en el Capítulo 5
que cada llamada a procedimiento se puede reemplazar por meter (push)en una
pila y cada retorno de procedimiento por saca-(pop)de la pila (reproduciendo
lo que hace el sistema C++ para implementar la recursión). Hay que recorúar
que una razón para hacer esto es que muchas llamadas que parecen recursivas
en realidad no lo son. Cuando una llamada a procedimiento es la última acci6n
de un procedimiento, entonces se puede utilizar un simple goto. Esto convierte
a expresi on y a ternii no en simplesbucles que se pueden fusionar y combinar
con f a c t o r para producir un procedimiento único con una sola llamada ver-
daderamente recursiva (la llamada a expresion dentro de factor).
Este punto de vista conduce directamente a una forma bastante simple de
comprobar si una expresión regular es válida. Una vez que se han eliminado
todas las llamadas a procedimientos, se ve que cada símbolo terminal se recorre
sólo cuando se encuentra. El único procesamiento real consiste en comprobar
cuándo existe un paréntesis derecho que concuerde con cada paréntesis iz-
quierdo, si cada a+» está seguido por una letra o un «(», y si cada a*» sigue a
una letra o a un «)D.
Esto es, comprobar si una expresión regular es válida es en esencia equiva-
lente a la comprobación de paréntesis equilibrados. Esto se puede implementar
sencillamente con un contador, inicializado a O, que se incrementa cuando se
encuentra un paréntesis izquierdo y se decrementa cuando se encuentra un pa-
réntesis derecho. Si el contador es cero al finalizar la expresión, y los «+» y los
«*»dentro de la misma cumplen con las condiciones que se acaban de mencio-
nar, entonces la expresión es válida.
Por supuesto, el análisis sintáctico es algo más que comprobar si la cadena
de entrada es válida: el objetivo principal es coiistruir el árbol de análisis sintác-
tico (incluso de una forma implícita, como en ei analizador sintáctico descen-
dente) para llevar a cabo otros procesamientos. Parece posible hacer lo mismQ
en pIogramas que tengan esencialmente la misma estructura que el verificador
de paréntesis que se acaba de describir. Un tipo de analizador que funciona de
ANÁLISIS SINTÁCTICO 345
esta manera es el denominado analizador sintáctico desplazamiento-reducción.
La idea es utilizar una pila que almacene los símbolos terminales y no termi-
nales. Cada paso del análisis sintáctico es o bien un paso desplazamiento, en el
que se pone en !a pila el siguiente carácter de entrada, o un paso reducción, en
el que se concuerdan los caracteres de la cima de la pila con la parte derecha de
alguna regía de producción de la gramática y se ((reducena» (se reemplazan por)
el no terminal de la parte izquierda de la misma producción. (La dificultad
principal al construir un analizador sintáctico desplazamiento-reducción es de-
cidir cuándo se desplaza y cuando se reduce. Esta puede ser una decisión com-
pleja, dependiendo de la gramática.) Tarde o temprano todos los caracteres de
entrada tienen que haber pasado a la pila y, también al fin y al cabo, la pila se
reduce a un único símbolo no terminal. Los programas de los Capítulos 3 y 4,
que construyen un árbol de análisis sintáctico a partir de una expresión infíja,
transformándola primero en una expresión postfija, son un ejemplo de un ana-
lizador sintáctico de este tipo.
En general el análisis sintáctico ascendente se considera como el método a
elegir para los lenguajes de programación. Hay una extensa literatura sobre el
desarrollo de analizadores sintácticos para grandes gramAticas, del tipo que se
necesita para describir un lenguaje de programación. Esta breve descripción sólo
roza la superficie de los temas de este campo.
Compiladores
Un compilador puede considerarse como un programa que traduce de un len-
guaje a otro. Por ejemplo, un compilador C++ traúuce programas escritos en
lenguaje C++ al lenguaje de máquina de una computadora determinada. Se
ilustrará la forma de efectuar esta traducción continuando con el ejemplo de
reconocimiento de patrones descritos por expresiones regulares. Sin embargo,
ahora se desea traducir del lenguaje de las expresiones regulares a las máquinas
de reconocimiento de patrones, es decir los arrays carac, proxl y prox2 del
programa concordar del capítulo anterior.
El proceso de traducción es esencialmente «uno a uno»: para cada carácter
del patrón (con la excepción de los paréntesis) se desea generar un estado de la
máquina de reconocimiento de patrones (una entrada de cada array). La clave
está en conservar la información necesaria para llenar los arrays proxl y prox2.
Para hacer esto, se convierte cada uno de los procedimientos del analizador sin-
táctico recursivo descendente en funciones generadoras de máquinas de reco-
nocimiento de patrones. Cada función añadirá al final de los mays carac, proxl
y prox2 tantos nuevos estados como sea necesario, y devolverá el índice del es-
tado inicial de la máquina generada (el estado final será siempre la última en-
trada de los arrays). Así es que, por ejemplo, la siguiente función para la pro-
ducción de <expresion>genera los estados or para la máquina de reconocimiento
de patrones.
346 ALGORITMOS EN C++
int expresion ()
i
int tl, t2, r;
tl = termino(); r = tl;
if (p[j] == ' + I )
j++; estado++;
t2 = estado; r = t2; estado++;
actuaiiza-estado(t2, I I , expresion0, tl);
actualiza-estado(t2-1, I I , estado, estado);
{
I
return r;
Esta función utiliza un procedimiento actual i za-estado que asigna a las en-
tradas de los arrays carac,proxl y prox2, indexadas por el primer argumento,
los valores proporcionados por el segundo,tercero y cuarto argumentos, respec-
tivamente. El estado índice conserva el estado «actual» de la máquina que se
está construyendo: cada vez que se crea un nuevo estado se incrementa estado.
Así pues, los índices de estados de la máquina que corresponden a una llamada
a procedimiento particular varían entre el valor que tiene estado a la entrada
del procedimiento y su valor a la salida. El índice del estado final es el valor de
estado a la salida. (Realmente no se «crea» el estado final incrementando es-
tado antes de la salida, puesto que esto facilita la «fusión» del estado final con
posteriores estados iniciales, como se podrá comprobar.)
Con este convenio, es fácil comprobar (jcuidado con la llamada recursiva!)
que el programa anterior implementa la regla de composición de dos máquinas
con la operación or según el diagrama del capítulo anterior. Primero se cons-
truye (recursivamente) la máquina para la primera parte de la expresión; luego
se añaden dos nuevos estados nulos y se construye la segunda parte de la expre-
sión. El primer estado nulo (de índice t2-1) es el estado final de la máquina de
la primera parte de la expresión, el cual se pone en un estado «no operativo))
para pasar al estado final de la máquina de la segunda parte de la expresión,
como es de desear. El segundo estado nulo (de índice t2) es el estado inicial,
por lo que su índice es el valor devuelto por expresión y sus entradas proxl y
prox2 apuntan a los estados iniciales de las dos expresiones. Obsérvese que és-
tas se construyen en orden inverso al que se podría esperar, porque el valor de
estado para el estado no operativo no se conoce hasta que se haya hecho la
llamada recursiva de expresi on.
La función para <término>construye primero la máquina para un <factor>
y luego, si es necesario, fusiona el estado final de esta máquina con el estado
inicial de la máquina para otro <término>.Esto es más fácil de hacer que decir,
puesto que estado es el índice del estado final de la llamada a factor:
ANÁLISIS CINTÁCTICO 347
int termino( )
int t, r;
r = factor();
if ( ( p[j] == I ) ' ) 11 ietra(p[j])) t = termino();
return r;
{
1
Se ignora simplemente el índice del estado inicial devuelto por la llamada a
termino:C++ exige ponerlo en alguna parte, por lo que se coloca en una varia-
ble temporal t.
La función para <factor>utiliza técnicas similares para manejar sus tres ca-
sos: un paréntesis implica una llamadarecursiva a expresion;una v llama a la
simple concatenación de un nuevo estado; y un * implica operaciones similares
a las de expresion,de acuerdo con el diagrama de clausura que se vio en la
sección anterior:
int factor( )
int tl, t2, r;
tl = estado;
i.f (p[j] = ' ( I )
{
c
j++; t2 = expresiono;
i f (p[j] == I ) ' ) j++;
else error( ) ;
else if (letra(p[ j]) )
1
{
actualiza-estado(estad0, p[j], estado-tl, estado+l);
t2 = estado; j++; estado++;
else error( ) ;
if (p[j] != ' * I ) r = t2;
else
actualiza-estado(estad0, I I , estado+l, t2);
r = estado; proxl[tl-l] = estado;
j++; estado++;
{
1
return r;
348 ALGORITMOS EN C++
Figura 21.3 Construcción de una máquina de reconocimiento de patrones para
(A'B+AC)D.
La Figura 21.3 muestra cómo se construyen los estados para el patrón
(A*B+AC)D, del ejemplo del capítulo anterior. Primero se construye el estado
1 para la A. Luego se construye el estado 2 para el operando clausura y se agrega
el estado 3 para la B. A continuación se encuentra el «+» y los estados 4 y 5 son
construidos por expresion,pero no pueden llenarse sus campos hasta después
de una llamada recursiva a expresi on,lo que se traduce en la construcción de
los estados 6 y 7. Por último, el estado 8 trata la concatenación de D, quedando
el estado 9 como estado final.
El paso final del desarrollo de un algoritmo general de reconocimiento de
patrones descritos por expresiones regulares consiste en poner estos procedi-
mientos junto con el procedimiento concordar:
void concordar-todos(char *a)
j = O; estado = 1;
proxl[O] = expresiono;
actualiza-estado(0, ' I , proxl[O], proxl[O]);
actualiza-estado(estad0, I I , O, O);
while (*a) cout << concordar(a++) << I I ;
cout <
i
'nu;
{
1
Este programa imprime, para la posición de cada carácterde una cadena de texto
a, la longitud de la subcadena más corta que, comenzando en esa posición, con-
cuerda con un patrón p (O si no hay concordancia).
ANÁLISIS SINTÁCTICO 349
Compilador de compiladores
El programa que se ha desarrollado en este capítulo y en el anterior para el re-
conocimiento de patrones descritos por expresiones regulares es eficaz y bas-
tante útil. Una versión ligeramente mejorada de este programa (capaz de ma-
nejar caracteres «sin importancia)), etc.) tiene todas las posibilidades de
encontrarse entre las herramientas más utilizadas en numerosos sistemas de in-
formación.
Es interesante (algunos pudieran decir confuso) reflexionar sobre este algo-
ritmo desde un puntc de vista más filosófico. En este capítulo se han utilizado
analizadores sintácticos para explicar la estructura de las expresionesregulares,
sobre la base de su descripción formal utilizando una gramática libre de con-
texto. Se ha utilizado una gramlitica libre de contexto para especificar un «pa-
trón» particular: una serie de caracteres con paréntesis correctamente equilibra-
dos. El analizador sintáctico comprueba si el patrón aparece en la cadena de
entrada (pero considera que la concordancia es válida sólo si cubre la cadena
completa de entrada). Así pues, analizar si una cadena de entrada pertenece al
conjunto de cadenas definidas por una gramática libre de contexto, y hacer un
reconocimiento de patrones para comprobar si la cadena de entrada pertenece
al conjunto de cadenas definidas por una expresión regular, jes esencialmente
la misma función! La diferencia fundamental es que las gramáticas libres de
contexto son capaces de describir una clase mucho más amplia de cadenas. Por
ejemplo, las expresionesregulares no pueden describir el conjunto de todas las
expresionesregulares.
Otra diferencia en los programas es que la gramática libre de contexto está
«integrada»en el analizador sintáctico, mientras que el procedimiento concor -
dar está ({dirigidopor tablaw: el mismo programa funciona para toda expre-
sión regular, una vez que se haya puesto en un formato apropiado. Parece po-
sible construir analizadores sintácticos «dirigidos por tablas)),de modo que el
mismo programa pueda servir para analizar todos los lenguajes que se puedan
describir con gramáticas libres de contexto. Un generador de analizadores sin-
tácticos es un programa que recibe como entrada una gramáticay produce como
salida un analizador sintáctico para el lenguaje descrito por esa gramática. Esto
se puede llevar más lejos: se pueden construir compiladores dirigidos por tablas
en términos de los lenguajes de entrada y de salida. Un compilador de compi-
ladores es un programa que recibe como entrada dos gramáticas (así como una
especificación de las relaciones entre ellas) y produce, como salida, un compi-
lador capaz de traducir las cadenas de uno de los lenguajesal otro.
Existen generadores de analizadores sintácticos y compiladores de compila-
dores para uso general en la mayoz-ía de los entornos informáticos. Son herra-
mientas muy útiles que se pueden utilizar con relativamente poco esfuerzo para
producir analizadoressintácticosy compiladoreseficacesy fiables. Por otra parte,
los analizadores sintácticosrecursivosdescendentes del tipo considerado en este
capítulo son bastante útiles para gramáticas simplescomo las que se encuentran
350 ALGORITMOS EN C++
en muchas aplicaciones. Así pues, al igual que con muchos de los algoritmos
que se han considerado, se dispone de un método directo apropiado para apli-
caciones donde no sejustifica un gran esfuerzo de implementación. Además, se
dispone de vanos métodos avanzados que permiten significativas mejoras del
rendimiento en aplicacionesa gran escala. Pero, como ya se dijo, sólo se ha ara-
ñado la superficiede un campo que ha sido objeto de una extensa investigación.
Ejercicios
1. ¿Cómo encontraría un analizador sintáctico recursivo descendente un error
en una expresión regular incompleta, como (A+B)*BC+?
2. Obtener el árbol de análisis sintáctico para la expresión regular
((A+B)+(C+D)*)*.
3. Ampliar la gramática de las expresiones aritméticas para que incluya los
operadores de exponenciación, división y módulo.
4. Obtener una gramática libre de contexto que describatodas las cadenas que
no tienen más de dos 1 consecutivos.
5. ¿Cuántas llamadas a procedimientos se utilizan por el analizador sintáctico
recursivo descendentepara reconocer una expresión regular en términos del
número de operaciones de concatenación, or y de clausura y del número de
paréntesis?
6. Obtener los arrays carac, proxl y prox2 que resultan al construir la má-
quina de reconocimiento de patrones para el patrón ((A+B)+(C+D)*)*.
7. Modificar la gramática de las expresionesregularespara manejar la función
not y los caracteres «sin importancia).
8. Construir un programa general de reconocimiento de patrones descritos por
expresiones regulares basado en la gramática mejorada obtenida en la pre-
gunta anterior.
9. Eliminar la recursión de un compilador recursivo descendente y simplificar
el código obtenido tanto como sea posible. Comparar los tiempos de eje-
cución de ambos métodos (recursivo y no recursivo).
10. Escribir un compilador para las expresionesaritméticas simplesdescritas por
la gramática del texto. Este compilador debe generar una lista de «instruc-
ciones))para una máquina hipotética que sea capaz de ejecutar las siguien-
tes operaciones:poner el valor de una variable en la pila; sumar los dos va-
lores superiores de la pila, eliminándolos de ella y poniendo en su lugar el
resultado; y multiplicar los dos valores superiores de la pila, de forma aná-
loga.
22
Compresión de archivos
Los algoritmos que se han estudiado hasta ahora han sido diseñados, en su ma-
yor parte, para que utilicen el menor tiempo posible, quedando la economía de
espacio en un segundoplano. En esta sección se examinarán algunosalgoritmos
concebidos a la inversa, es decir, para utilizar el menor espacio posible sin con-
sumir demasiado tiempo. Irónicamente, las técnicas a examinar para econo-
mizar espacio se basan en métodos de «codificación»que provienen de la teoría
de la información que se desarrolló para disminuir el volumen de información
necesaria en los sistemas de comunicación con la intención, en su origen, de
ganar tiempo (no espacio).
En general, la mayor parte de los archivos tienen un gran nivel de redun-
dancia. Los métodos que se examinarán reducen el espacio aprovechando el he-
cho de que muchos archivos tienen un (contenido de información)) relativa-
mente bajo. Las técnicas de compresión de archivos sirven a menudo para
archivos de texto (en los que ciertos caracteres aparecen con mucha más fre-
cuencia que otros), para archivos de «exploración» de imágenes codificadas(que
presentan grandes zonas homogéneas) y para archivos de representación digital
de sonido y de otras señales analógicas (que pueden presentar gran número de
patrones repetidos).
En este capítulo se va a considerar un algoritmo elemental bastante útil para
resolver este problema y también un método avanzado «óptimo». La cantidad
de espacio que se gana con estos métodos varía según las caractensticas de los
archivos. Ganancias del 20 al 50 % de espacio son típicas en archivos de texto,
y es posible alcanzar entre un 50 y un 90 9'0 en archivos binarios. Para ciertos
tipos de archivos, como por ejemplo los constituidospor bits aleatonos, se puede
ahorrar muy poco. De hecho, es interesante comprobar que cualquier método
de compresión de propósito general puede aumentar el tamaño de algunos
archivos (si no fuera así, sena posible, aplicando continuamente el método, ob-
tener archivos arbitrariamente pequeños).
Por una parte, se puede argumentar que las técnicas de compresión de archi-
vos son ahora menos importantes que lo que fueron hace tiempo porque el coste
351
352 ALGORITMOS EN C++
de los dispositivosde almacenamiento ha caído drásticamente y un usuario me-
dio puede tener a su alcance mayor capacidad de almacenamiento que la que
tenía en el pasado. Pero, al contrario, también se puede argumentar que las téc-
nicas de compresión de archivos son ahora más importantes que nunca, porque
al poner enjuego un gran volumen de almacenamiento, los ahorros que se pue-
den lograr son muy grandes. Las técnicas de compresión son también apropia-
das püra los dispositivosde almacenamiento que permiten accesos muy rápidos
y que, por naturaleza, son relativamente caros (y por consiguiente pequeños).
Codificación por longitud de series
El tipo más simple de redundancia que se puede encontrar en un archivo son
las largas series de caracteres repetidos. Por ejemplo, considérese la cadena si-
guiente:
AAAABBBAABBBBBCCCCCCCCDABCBAAABBBBCCCD
Esta cadena se puede codificar de forma más compacta reemplazando cada re-
petición de caracterespor un solo ejemplar del carácter repetido seguido del nú-
mero de veces que se repite. Sena mejor decir que esta cadena consiste en 4 le-
tras A, seguidas de 3 B, seguidas de 2 A, seguidas de 5 B, etc. Esta forma de
comprimir una cadena se denomina codificación por longitud de series. En el
caso de largas series, los ahorros pueden ser espectaculares. Existen varias for-
mas de realizar esta idea, dependiendo de las Característicasde la aplicación.
(¿Las series tienden a ser relativamente largas? ¿Cuántos bits se necesitan para
codificar los caracteres?) A continuación se verá un método particular, para
presentar después otras opciones.
Si se sabe que las cadenas contienen sólo letras, entonces es posible codifi-
carlas introduciendo los dígitos entre las letras. La cadena anterior se podría co-
dificar como:
Aquí «4A» significa «cuatro letras A», y así sucesivamente. Obsérvese que no
merece la pena codificar las series de longitud uno o dos, puesto que para la
codificación se necesitan dos caracteres.
En archivos binarios se utiliza una versión mejorada de este método con la
que se obtienen grandes ahorros de espacio. La idea consiste simplemente en
almacenar las longitudes de las series, aprovechando el hecho de que se com-
ponen de O o 1, para evitar almacena estos carzcteres.Esto supone que hay po-
cas series cortas (sólo se gana espacio si la longitud de la serie es mayor que el
número de bits que se necesitan para representardicha longitud en binario), pero
COMPRESIÓN DE ARCHIVOS 353
0000000000000000000000000000111111111111110000000c0
000000000000000000000000001111111111111111110000000
000000000000000000000001111111111111111111111110000
000000000000000000000011111111111111111111111111000
000000000000000000001111111111111111111111111111110
000000000000000000011111110000000000000000001111111
000000000000000000011111000000000000000000000011111
000000000000000000011100000000000000000000000000111
000000000000000000011l00000000000000000000000000111
000000000000000000011100000000000000000000000000111
000000000000000000011100000000000000000000000000111
00000000000000000000111100000000000000000000000111o
900000000000000000000011100000000000000000000111O00
011111111!11111111111111111111l1111111111~111111111
O l l i l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l
O l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l i l l l l l l l l l l
011111111111111111111111111111!111111~1111111111111
01111111111111111111111111111111111111111111111~111
011000000000000000000000000000000000000000000000011
28 14 9
26 18 7
2324 4
2226 3
20 30 1
19 7 187
19 5 2 2 5
19 3 2 6 3
19 3 2 6 3
19 3 2 6 3
19 3 2 6 3
20 4 2 4 3 1
22 3 2 0 3 3
1 50
1 50
1 50
1 50
1 50
1 2 4 6 2
Figura 22.1 Una matriz de puntostípica, con información de la codificación por longi-
tud de series.
ningún método de longitud de series es eficaz a menos que la mayor parte de
las series sean largas.
La Figura 22.1 es una representación «porpixels» de la letra «q (apaisada))).
Esta figura es representativa del tipo de información que se puede procesar por
un sistemade fonnateo de texto (como el que se ha utilizado para imprimir este
libro). A la derecha figura una lista de los números que se podrían utilizar para
almacenar la letra en forma comprimida. Así, la primera línea consiste en 28
«O» seguidos de 14 ««I»,seguidosde otros 9 «O» más, etc. Las 63 informaciones
de esta tabla más el número de bits por línea (51) contienen suficiente infor-
mación para reconstruir el array de bits (en particular destaca que no se necesita
ningún carácter de «fin de línea»). Si se utilizan 6 bits para representar cada
longitud, entonces el archivo completo se representa con 384 bits, un ahorro
sustancial comparado con los 975 bits que se necesitan para almacenarlo en
forma explícita.
La codificación por longitud de series necesita representaciones separadas
para los elementosdel archivoy los de su versión codificada, por lo que no puede
aplicarse en todcs los archivos. Esto puede ser un inconveniente: por ejemplo,
el método de compresión de archivos de caracteres sugerido anteriormente no
funciona con cadenas que contienen dígitos. Si se utilizan otros caracteres para
codificarlas longitudes de las series, no podría aplicarseel método a las cadenas
que contengan esos caracteres. Para ilustrar una forma de codificar cualquier
cadena escrita por medio de un alfabeto fijo de caracteres, utilizando sólo ca-
354 ALGORITMOS EN C++
racteres de ese alfabeto, supóngase que se dispone sólo de las 26 letras del alfa-
beto (y de espacios en blanco).
¿Cómo se puede lograr que algunas letras representen dígitos y otras formen
parte de la cadena que se va a codificar? Una solución consiste en utilizar un
carácter con pocas probabilidades de aparecer en el texto, al que se denomina
carácter de escape. Cada aparición de dicho carácter indica que las dos letras
siguientes forman un par (longitud, carácter), en el que la i-ésima letra del al-
fabeto represenra una longitud igual a i. De esta manera, tomando Q como ca-
rácter de escape, la cadena del ejemplo se representaría por:
QDABBBAAQEBQHCDABCBAAAQDBCCCD
La combinación del carácter de escape, de la longitud de la serie y de una copia
del carácter repetido se denomina secuencia de escape. Obsérvese que no me-
rece la pena codificar series de menos de cuatro caracteres, ya que se necesitan
al menos tres para codificar cualquier serie.
Pero ¿qué pasa si el carácter de escape aparece también en la serie de en-
trada? No se puede ignorar esta posibilidad, porque es difícil asegurar que no
puede aparecer un carácter en particular. (Por ejemplo, alguien puede tratar de
codificar una cadena que ya se ha codificado.) Una solución a este problema
consiste en utilizar para representar al carácter de escape una secuencia de es-
cape con una longitud de sene cero. De esta manera, en el ejemplo, el carácter
espacio en blanco podría representar cero, y la secuenciade escape «Q<espacio>»
representaría a cualquier aparición de Q en la entrada. Es interesantenotar que
los únicos archivos que se «alargan» por este método de compresión son aque-
llos que contienen a Q. Si un archivo que ya ha sido comprimido se comprime
otra vez, aumenta su longitud en un número de caracteres igual al número de
secuencias de escape utilizadas.
Las series muy largas se pueden codificar con múltiples secuencias de es-
cape. Por ejemplo, una serie de 51 A debería codificarse como QZAQYA, de
acuerdo con las convenciones anteriores. Si se espera encontrar series muy lar-
gas, merecería la pena utilizar más de un carácter para codificar las longitudes.
En la práctica es aconsejable hacer que los programas de compresión y de
descompresión sean algo más sensiblesa los errores. Esto se puede lograr inclu-
yendo una ligera redundancia en el archivo comprimido,de modo que el pro-
grama de descompresión pueda tolerar cualquier pequeño cambio accidental
sufrido por el archivo entre la compresión y la descompresión. Por ejemplo,
probablemente merece la pena insertar caracteresde «fin de línea)en la versión
comprimidade letra «q» que se vio con anterioridad, de modo que el programa
de descompresión pueda el mismo resincronizarse en caso de error.
La codificación por longitud de series no es particularmente eficaz en archi-
vos de texto donde posiblemente el único carácter que tenga series repetidas es
el espacio en blanco, ya que existen métodos más simples para codificar series
de espacios en blanco repetidos. (Este método se utilizó con provecho en el pa-
sado para comprimir archivos de texto creados a partir de lecturas de tarjetas
COMPRESIÓN DE ARCHIVOS 355
perforadas, que contenían necesariamente muchos espacios en blanco.) En los
sistemasmodernos nunca entran ni se almacenan seriesrepetidas de espaciosen
blanco: las que aparecen al principio de una línea se codifican como dabulacio-
new, y las que existen al final de las líneas se pueden ignorar utilizando los in-
dicadores de «fin de línea). Una implementación de la codificación por longi-
tud de series como la anterior (modificada para aceptar todos los caracteres
representables) economiza solamente alrededor de un 4 % cuando se utiliza en
un archivo de texto como el de este capítulo (jy toda la economía proviene del
ejemplo de la letra «q»!).
Codificación de longitud variable
En esta sección se examinará una técnica de compresión que permite ganar una
cantidad considerable de espacio en archivos de texto (y en muchos otros tipos
de archivos).La idea es abandonar la forma como se almacenan habitualmente
los archivos de texto: en lugar de emplear siete u ocho bits por carácter, se uti-
lizarán solamente unos pocos bits para los caracteres más frecuentes y algunos
más para los que aparecen más raramente.
Será conveniente examinar, en un pequeño ejemplo, cómo se utiliza el có-
digo antes de considerar cómo se creó. Suponiendo que se desea codificarla ca-
dena ((ABRACADABRA)),su codificación en el código binario compacto es-
tándar, en el que la xepresentación con cinco bits de i reproduce a la i-ésima
letra del alfabeto (O para los espacios en blanco), proporcionaría la siguiente se-
ne de bits:
0000100010100100000100011000010010000001000101001000001
Para ((decodifican)este mensaje se leen grupos de cinco bits y se convierten de
acuerdo con la codificaciónbinaria anterior. En este código estándar, la D, que
aparece sólo una vez, necesita el mismo número de bits que la A, que aparece
cinco veces. Con un código de longitud variable se puede alcanzar ahorros de
espacio codificando los caracteres más frecuentemente utilizados con el menor
número de bits posible, de forma que se minimice el número total de bits.
Se podna tratar de asignar la cadena más corta de bits a las letras más fre-
cuentemente utilizadas, codificando A por O, B por 1, R por O1, C por 1O y D
por 11, y así ABRACADABRA se codificaría como
o 1 0 1 0 1 0 0 1 1 0 1 0 1 0
Esta cadena utiliza sólo 15 bits en lugar de los 55 anteriores, pero esto no es
realmente un código porque depende de los ((espaciosen blanco))para delimitar
los caracteres. Sin ellos, la cadena O1O1O1O011O1O1O se podría decodificarcomo
RRRARBRRA o como otras diferentes cadenas. A pesar de todo, 15 bits más
356 ALGORITMOS EN C++
Figura 22.2 Dos tries de codificaciónpara A, B, C,D y R.
10 delimitadores forman un código mucho más compacto que el código están-
dar, principalmente porque no se utilizan bits para codificar letras que no apa-
recen en el mensaje. Para ser objetivos, es preciso incluir también los bits del
propio código, puesto que el mensaje no puede decodificarsesin él, y el código
depende del mensaje (otros mensajes tendrán diferentes frecuencias de apan-
cijn de las letras). Más adelante se volverá a considerar este aspecto:por el mo-
mento solamente interesa ver hasta qué punto se puede comprimir el mensaje.
Los delimitadores no son necesarios si el código de un carácter no es el pre-
fijo de otro. Por ejemplo, si se codificaA por 11,B por 00, C por 010, D por 10
y R por O11, no hay más que una sola forma de decodificarla cadena de 25 bits
1100011110101110110001111
Una forma fácil de representar el código es con un trie (ver Capítulo 17).En
efecto, cualquier trie con M nodos externos se puede utilizar para codificar
cualquier mensaje con M caracteres diferentes. Por ejemplo, la Figura 22.2
muestra dos códigos que se podnan utilizar para ABRACADABRA.El código
de cada carácter se determina por el camino desde la raíz al carácter, con O para
«ir a la izquierda) y 1 para «ir a la derecha», como es habitual en un tne. Así
pues, el trie de la izquierda corresponde al código anterior; el trie de la derecha
corresponde con el código que genera la cadena
o1101001111011100110100
que es dos bits más corta. La representación por trie garantiza que el código de
ningún carácter es el prefijo de otro, de modo que la cadena es unívocamente
decodificable a partir del tne. Comenzando en la raíz, se desciende por el tne
de acuerdo con los bits del mensaje: cada vez que se encuentre un nodo ex-
temo, se da salida al carácter del nodo y se comienza de nuevo en la raíz.
Pero ¿qué trie es el mejor para utilizar? Existe una forma elegante de cons-
truir un tne que proporcione una cadena de bits de longitud mínima para cual-
quier mensaie. El método general para encontrar el código fue descubierto por
D. Huffman en 1952 y se denomina código de Huffman. (La implementación
que se verá utiliza algunas metodologías algorítmicas más modernas.)
COMPRESIÓN DE ARCHIVOS 357
A B C D E F I L M N O R C T U
k O 1 2 3 4 5 6 3 12 13 1
4 15 18 19 20 21
cuenta[k] 11 6 1 5 3 4 1 6 2 3 7 4 2 2 1 3
Figura 22.3 Cuentas diferentes de cero para UNA CADENA SENCILLA A...
Construcción del código de Huffman
El primer paso en la construcción del código de Huffman es contar el número
de veces que aparece (frecuencia de aparición) cada carácter en el mensaje que
se va a codificar. Las siguientes instrucciones permiten llenar un array
cuenta [261 con la cuenta de las frecuenciasde aparición de un mensaje en una
cadena a. (Este programa utiliza el procedimiento indice descrito en el Capí-
tulo 19 para almacenar la cuenta de frecuencias de la i-ésima letra del alfabeto
en cuenta[i 1, utilizando cuenta [O] para los espacios en blanco.)
for (i=O; i<=26; i++)
cuenta[i] = O ;
for (i=O; i < M; i++) cuenta[indice(a[i])]++;
Por ejemplo, supóngase que se desea codificar la cadena «UNA CADENA
SENCILLA A CODIFICAR CON UN NÚMERO MÍNIMO DE BITS». La ta-
bla de cuentas que se obtiene se muestra en la Figura 22.3: hay once espacios
en blanco, seis A, una B, etcétera.
El siguiente paso es construir el trie de codificaciónde abajo hacia arriba de
acuerdo con las frecuencias. Al construir el trie, se considerará como un árbol
binario con las frecuencias aimacenadas en los nodos: después de su construc-
ción se considerará como un trie de codificación, al igual que antes. Primero se
crea un nodo del árbol para cada frecuencia distinta de cero, como se muestra
en el primer diagrama en la parte superior izquierda de la Figura 22.4 (el orden
en el que aparecen los nodos se determina por la dinámica del algoritmo des-
crito a continuación, pero no es relevante para esta presentación). A continua-
ción se toman los dos nodos con las frecuenciasmás pequeñas y se crea un nuevo
nodo con estos dos como hijos y con un valor de frecuencia igual a la suma de
los valores de los hijos, como se muestra en el segundo diagrama de la Figura
22.4. (Si existen más de dos nodos con el mismo valor mínimo de frecuencia se
eligen dos cualesquiera.) Luego se busca entre !os nodos restantes los dos con
menor frecuencia y se vuelve a crear un nuevo nodo de la misma forma, tal y
como se muestra en el tercer diagrama de la Figura 22.4. Continuando de esta
manera, se van construyendo subárboles cada vez más grandes, a la vez que se
reduce en cada paso el número de subárboles del bosque (se eliminan dos y se
añade uno). Finalmente, todos los nodos se combinan en un solo árbol.
358 ALGORITMOS EN C++
Figura 22.4 Construcción de un árbol de Huffman.
COMPRESIÓN DE ARCHIVOS 359
Obsérvese que los nodos con las frecuencias mis bajas se encuentran bas-
tante abajo en el árbol, y los nodos con frecuencias altas se sitúan cerca de la
raíz. El número de la etiqueta de los nodos externos (cuadrados) de este árbol
es la cuenta de la frecuencia, mientras que el número de cada nodo interno (re-
dondos) es la suma de las etiquetas de sus dos hijos.
El código de Huffman se obtiene ahora fácilmente sustituyendo las frecuen-
cias de los nodos del fondo por los caracteres asociadosy considerando al árbol
como un trie de codificación: cada bifurcación hacia la «izquierda» corres-
ponde al bit de código O y hacia la «derecha»al de código 1.
Evidentemente, las letras con frecuencias altas están cerca de la raíz del ár-
bol y se codifican con pocos bits, por lo que éste es un buen código; pero, ¿por
qué es el mejor?
Propiedad 22.1 La longitud del mensaje codijkado es igual a la longitudpon-
derada del camino externo del árbol defrecuencias de Hufman.
La «longitud ponderada del camino externo» de un árbol es la suma, para todos
los nodos externos, del producto del «peso» (la cuenta de frecuencias)por la dis-
tancia a la raíz. Evidentemente ésta es una forma de calcular la longitud del
mensaje: es equivalente a la suma, para todos los caracteres, del producto del
número de aparicionesde cada carácter por el número de bits asociado con cada
aparición.
Propiedad 22.2 Ningún árbol con la mismasfrecuencias en los nodos externos
puede tener una longitud ponderada del camino externo inferior a la del árbol
de Hufman.
Cualquier árbol se puede reconstruir con el mismo procedimiento que se utilizó
para construir el árbol de Huffman, pero no seleccionando necesariamente en
cada paso los dos nodos de menor peso. Se puede demostrar por inducción que
no hay mejor estrategia que la de tomar primero los dos pesos men0res.i
Siempre que se escoja un nodo, puede ocumr que haya varios nodos con el
mismo peso. El método de Huffman no especificalo que hay que hacer en este
caso. Las diferentes opciones conducirán a códigos diferentes, pero todos ellos
codificarán el mensaje con el mismo número de bits. Por ejemplo, en la Figura
22.5 se muestra otro trie para el ejemplo. El código de A es 000, el de B 110110,
el de C 1100, etc. Este árbol es estructuralmente algo diferente del construido
en la Figura 22.4. Puede que el lector quiera comprobar que tienen la misma
longitud ponderada del camino externo. (El número más pequeño encima de
cada nodo del árbol es un índice que sirve como referencia al tratar la imple-
mentación que se proporciona más adelante.)
La descripción anterior muestra un esbozogeneral de la construcción de un
código de Huffman en función de las operaciones algorítmicas que se han es-
tudiado. Como es habitual, el paso de esta descripción a una implementación
360 ALGORITMOS EN C++
Figura 22.5 Un trie del código de Huffman para UNA CADENA SENCILLA...
concreta es bastante instructivo, por lo que a continuación se examiharán los
detalles de la implementación.
Implementación
La construcción del Arbol de frecuencias implica un proceso general de extraer
el elemento más pequeño de un conjunto de elementos desordenados, por lo
que se utiliza la clase CP del Capítulo 11 para construir y mantener una cola de
prioridad de los valores de las frecuencias.La utilización de esta cola para cons-
truir el árbol descrito anteriormente es directa:
for (i = O; i <= 26; i++)
for (; !cp.vacia(); i++)
if (cuenta[i]) cp.insertar(cuenta[i], i);
tl = cp.suprimir(); t2 = cp.suprimir();
padre [i ]=O; padre [tl]=i;padre[tZ]=-i ;
cuenta[i]= cuenta[tl] + cuenta[t2] ;
if ( !cp.vaci a( ) ) cp.insertar(cuenta[ i] , i ) ;
{
1
Primero, se insertan en la cola de prioridad las cuentas diferentesde cero. Luego
se extraen los dos elementos más pequeños, se suman sus frecuencias y se in-
serta el resultado en la cola, continuando con el mismo procedimiento hasta va-
ciar la cola. En cada paso se crea una nueva cuenta y se disminuye el tamaño
de la cola. Este proceso crea N- 1 nuevas cuentas, una para cada uno de los no-
dos internos que se están creando en el árbol. El índice i continúa refiriéndose
al array cuenta, por lo que el primer nodo interno tiene índice 27, etc. Se
«crean» nuevos nodos internos a través de i++. El propio árbol está represen-
COMPRESIÓNDE ARCHIVOS 361
k O 1 2 3 4 5 6 9 12 13 14 15 18 19 20 21
cuenta[k] 11 6 1 5 3 4 1 6 2 3 7 4 2 2 1 3
padre[k] -38 35 27 34 30 33 -27 35 29 31 36 -33 -29 28 -28 -31
k 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
cuenta[k] 2 3 4 5 6 7 8 10 12 13 15 21 25 36 61
padre[k] -30 -32 32 -34 -36 -37 37 38 39 -39 40 -40 41 -41 O
Figura 22.6 Representaciónde enlaces del trie de Huffman de la Figura22.5.
tad0 por un array de enlaces«padres»: padre [t]es el índice del padre del nodo
cuyo peso está en cuenta[t].El signo de padre [t] indica si el nodo es un hijo
derecho o izquierdo del padre. La Figura 22.6 muestra el contenido completo
de las estructuras de datos del árbol de la Figura 22.5. Por ejemplo, se tiene
cuenta[40]=36, padre [40]=-41 y cuenta [C1]=61 (lo que indica que el nodo
de peso 36 tiene índice 40 y es el hijo derecho de un padre que tiene índice 41
y peso 61).
El trie es suficientepara describir el propio código; para aclarar este punto a
continuación se considerará cómo construir explícitamente el código. La Figura
22.7 muestra el código completo del ejemplo representado por dos arrays: los
1ong [k] bits más a la derecha en la representación binaria del entero co-
digo[k] forman el código de la k-ésima letra. Por ejemplo, I e5 la novena letra
y tiene como código 001, por lo que código[9]=1, y long[9]=3, lo que indica
que el código es los tres bits más a la derecha de la representación binaria del
número 1, o sea O
0I. El siguiente segmento de programa convierte a la repre-
sentación t i e del código (el array padre, que se muestra en la Figura 22.6) en el
propio código de Huffman.
for ( k = O; k <= 26; k++)
i = 0; x = o; j = 1;
{
if (cuenta[k])
for (t=padre[k]; t; t=padre[t], j+=j, i++)
if (t < O) { x +=j; t = -t; }
cod.igo[k! = x; long[k] = i;
1
Los arrays código y 1 ong se calculan de forma directa utilizando el array pa-
dre para ascender por el árbol.
El meiishje podría codificarserecorriendo el trie de esta forma para cada ca-
rácter del mensaje. Como alternativa, se pueden utilizar directamente los arrays
codigo y 1ong para codificar el mensaje:
362 ALGORITMOS EN C++
for ( j = O; j < M; j++)
for ( i = l o n g [ i n d i c e ( a [ j ] ) ] ; i } O; i--)
cout << ((codigo[indice(a[j])] >> i-1) & 1);
El mensaje del ejemplo se codifica con sólo 228 bits en lugar de los 300 que se
utilizarían en la codificacióndirecta, lo que supone un 24 % de ahorro:
01110100001111100000110101000010000111101101000010110000110
1001010000011100011111001001110100011f011100111000001010111
11100100101011101110101110100111011010001010110011110110001
010001011010011111101010001111101100011011110110111
Ahora, como ya se mencionó, se debe almacenar el árbol o bien enviarlo
junto con el mensaje para decodificarlo. Afortunadamente, esto no presenta
ninguna dificultad real. No se necesita más que almacenar el array código, por-
que el trie de búsqueda por residuos que resulta de insertar las entradas del array
en un árbol inicialmente vacío es el árbol de decodificación.
A B C D E F I L M N O R S T U
k O 1 2 3 4 5 6 9 12 13 14 15 18 19 20 21
codigo[k] 7 O 54 12 26 8 55 3 20 6 2 9 21 22 23 7
long[k] 3 3 6 4 5 4 6 3 5 4 3 4 5 5 5 4
111 o00 IIOIIO IIOO 110101000 IIOIII mi iaiw om o10 1001 io101 10110 io111 0111
Figura 22.7 Código de Huffman para UNA CADENA SENCILLA A...
Así, el ahorro de espacio antes mencionado no estotalmente exacto, porque
el mensaje no se puede decodificar sin el trie y se debe tener en cuenta el coste
que significa almacenar el árbol (esto es, el array código)junto con el mensaje.
Por lo tanto, la codificación de Huffman sólo es efectiva para archivos largos
donde el ahorro de espacio en el mensaje es suficiente como para compensar el
coste, o en situaciones donde el trie de codificación puede calcularse previa-
mente y reutilizarse para un gran número de mensajes. Por ejemplo, un tne ba-
sado en las frecuenciasde aparición de las letras de un idioma determinado po-
dría utilizarse en documentos de texto. De la misma forma, un trie basado en
las frecuenciasde aparición de caracteres en programas en C++ se podría utili-
zar para la codificación de programas (por ejemplo, «;» estaría cerca de la raíz
de un tal trie). Un algoritmo de codificación de Huffman permite unas ganan-
cias de espacio de alrededor de un 23% en el texto de este capítulo.
Como siempre, para archivos verdaderamente aleatonos, incluso este inge-
COMPRESIÓNDE ARCHIVOS 363
nioso esquema de codificación no funcionará bien porque cada carácter apare-
cerá aproximadamente el mismo número de veces, lo que conduce a un árbol
de codificación completamente equilibrado y a un número igual de bits por le-
tra del código.
Ejercicios
1. Implementar los procedimientos de compresión y descompresión descritos
en el texto para el método de codificación por longitud de series, conside-
rando un alfabeto fijo y utilizando la letra Q como carácter de escape.
2. ¿Podría aparecer la serie «QQ» en un archivo comprimido por el método
descrito en el texto? ¿Podría aparecer «QQQ»?
3. Implementar los procedimientos de compresión y descompresión descritos
en el texto para el método de codificaciónde archivos binarios.
4. El dibujo de la letra «q» del texto se puede procesar como una sene de ca-
racteres de cinco bits. Analizar las ventajas e inconvenientes de recumr a
esta técnica en el método de codificación por longitud de series basado en
caracteres.
5. Mostrar el proceso de construcción del árbol de codificación de Huffman
cuando se aplica el método de este capítulo a la cadena ((ABRACADA-
BRA». ¿Cuántos bits necesita el mensaje codificado?
6. ¿Cuál es el código de Huffman de un archivo binario? Dar un ejemplo que
muestre el máximo número de bits que se podría utilizar en un código de
Huffman de un archivo ternario (tres valores) de N caracteres.
7. Suponiendo que las frecuencias de aparición de todos los caracteres a co-
dificar son diferentes. ¿Es único el árbol de codificación de Huffman?
8. La codificación de Huffman podría generalizarsede forma directa para co-
dificar en caracteresde dos bits (utilizando árbolesde 4vías).¿Cuáles serían
las principales ventaja e inconveniente de una técnica como ésta?
9. ¿Cuál sería el resultado de dividir una cadena codificada por Huffman en
caracteresde cinco bits y aplicar el código de Huffman a esa nueva cadena?
10. Implementar un procedimiento para decodificar una cadena codificadapor
Huffman, conociendo los arrays codi go y 1ong.
Algoritmos en C++.pdf
23
Criptología
En el capítulo anterior se vieron métodos para codificar cadenas de caracteres
con objeto de ahorrar espacio. Por supuesto, existe otra razón muy importante
para codificar cadenas de caracteres:mantenerlas secretas.
La criptología, el estudio de los sistemas de comunicaciones secretas, está
constituida por dos campos de estudio complementarios: la criptugrafia, o el di-
seño de sistemas de comunicaciones secretas, y el criptoanálisis, o el estudio de
las formas de transgredir los sistemas de comunicaciones secretas. La criptolo-
gía se aplicó inicialmente a los sistemas de comunicaciones militares y diplo-
máticos, pero en la actualidad están apareciendo otras aplicaciones importan-
tes. Dos de los principales ejemplos son los sistemas de administración de
archivos de las computadoras (en los que cada usuario desea que sus archivos
se mantengan como privados)y los sistemasde transferencia electrónica de fon-
dos (en los que se tratan grandes cantidades de dinero). Un usuario de compu-
tadora desea mantener sus archivostan secretos como lo están sus papeles en su
archivador y un banco desea que las transferencias electrónicas de fondos sean
tan seguras como las que se hacen en un coche blindado.
Excepto en las aplicaciones militares, se supone que los criptógrafosson los
«chicos buenos))y los cnptoanalistas los «chicosmalos)): el objetivo es proteger
los archivos informáticosy las cuentas de un banco de los ladrones. Si este punto
de vista parece poco amistoso, se debe recordar (sin tratar de filosofar mucho)
que al utilizar la criptografía se supone ila existencia de la enemistad! Por su-
puesto, los chicos buenos)) deben saber algo de criptoanálisis, puesto que la
mejor forma de saber si un sistema es seguro es tratar de violarlo uno mismo.
(Además hay muchos ejemplos documentados sobre guerras que han finali-
zado, salvándose así muchas vidas, gracias a éxitos del criptoanálisis.)
La criptolo@atiene muchos lazos con la informática y los algontmos, espe-
cialmente con los algoritmos aritméticos y de procesamiento de cadenas que se
acaban de estudiar. En efecto,esta relación entre el arte (¿la ciencia?)de la cnp-
tología con las computadoras y la informática está ahora empezando a com-
prenderse. Al igual que los algoritmos, los sistemas de cripto aparecieron mu-
365
366 ALGORITMOS ENC++
Y
Figura 23.1 Un sistemade cripto típico.
cho antes que las computadoras. El diseño de sistemas secretos y el de los
algoritmos tienen una herencia común, y la misma gente se siente atraída por
ambos.
No es fácil decir qué rama de la criptología ha sido la más afectada por la
aparición de las computadoras. Los criptógrafos tienen ahora a su disposición
máquinas mucho más poderosas, pero también es más fácil que cometan erro-
res. Los criptoanalistas cuentan con herramientas mucho más eficaces para
«romper» los códigos, pero éstos son ahora mucho más complejos. El cripto-
análisispuede suponeruna gran carga de trabajo para las máquinas; no sólo fue
una de las primeras áreas de aplicación de las computadoras, sino que se man-
tiene como uno de los dominios principales de aplicación de las modernas su-
percomputadoras.
Más recientemente, la amplia difusión de las computadorasha generado la
aparición de una gran variedad de nuevas aplicaciones importantes de la crip-
tología, como se mencionó anteriormente. Se han desarrollado nuevos métodos
de criptografía para responder a las necesidades de tales aplicaciones, y esto ha
llevado ai descubrimiento de una relación fundamental entre la criptografía y
un área importante de la teoría informática que se examinarábrevemente en el
Capítulo 45.
En este capítulo se verán algunas de las característicasbásicas de los algorit-
mos criptográficos. No se entrará en el detalle de las implantaciones: la cripto-
grafía es realmente un campo para confiárselo a los expertos. Mientras que no
es difícil «protegerse» cifrando la información con un sencillo algoritmo, es pe-
ligroso confiar en un método implantado por un profano.
Reglas del juego
El conjunto de elementos que permiten la comunicación segura entre dos per-
sonas se denominacolectivamente un sistema de cripto. La Figura 23.1 muestra
la estructura canónica de un sistema de cripto típico.
El emisor envía un mensaje (denominado el texto en claro) al receptor,
transformándoloen una forma secreta propicia para la trasmisión (denominada
CRlPTOLOGíA 367
el texto cifrado) por medio de un algoritmo de criptografía (el método de ci-
frado)y algunosparámetros (c2ave.s).Para leer el mensaje, el receptor debe tener
un algoritmo criptográfico equivalente (el método de descifrado)y los mismos
parámetros clave que transformarán el texto cifrado en el texto original. Habi-
tualmente se supone que el texto cifrado se envía por líneas de comunicación
insegurasy que puede estar al alcance del criptoanalista. También se supone que
el método de cifrado y el de descifrado son conocidos por el criptoanalista: su
objetivo es recuperar el texto en claro a partir del texto cifrado, pero sin conocer
las claves. Es de destacar que todo el sistema depende de algún método preli-
minar de comunicación entre el emisor y el receptor para ponerse de acuerdo
sobre los parámetros claves. Por regla general, cuantas más claves haya, más se-
guro será el sistema, pero más incómodo de utilizar. Esta situación es análoga a
la de los sistemasde seguridad más convencionales: la combinación de una caja
fuerte es más segura cuantos más números tenga, pero es más difícil de recor-
dar. La analogía con los sistemas convencionales también sirve para recordar
que cualquier sistema de seguridad es tan fiable como lo sean las personas que
tengan la clave.
Es importante recordar que las cuestiones económicas representan un papel
importante en los sistemas de cripto. Serán razones económicas las que lleven a
construir dispositivosde cifrado y descifrado simples (porque puede ser que se
necesiten muchos y los dispositivos complicados cuestan más), y también habrá
una motivación económica para reducir el número de informaciones claves a
distribuir (porque pueden necesitar un método de comunicación muy seguro y,
por ello, caro). En el equilibrio entre el coste de implantación de un sistema
criptográficoseguro y el coste de distribución de las informaciones claves, se en-
cuentra el precio que los criptoanalistas están dispuestos a pagar para romper el
sistema. En la mayoría de las aplicaciones, el objetivo del criptógrafoes desarro-
llar sistemas de bajo coste con la característica de que el criptoanalista debe in-
vertir para leer los mensajes mucho más de lo que está dispuesto a pagar. En un
pequeño número de aplicaciones, puede que se necesite un sistema de cripto
(ciertamente seguro», que pueda garantizar que el criptoanalista nunca podrá
leer los mensajes, sin importar lo que esté dispuesto a pagar por ello. (Losgastos
muy altos en ciertas aplicaciones de criptología implican naturalmente que se
invierten grandes cantidades de dinero para el criptoanálisis.) En el diseño de
algoritmos se intenta seguir la pista a los costes para seleccionar el mejor algo-
ritmo; en criptología, los costes desempeñan un papel fundamental en el pro-
ceso de diseño.
Métodos elementales
Entre los métodos de cifrado más simples (y de los más antiguos) se encuentra
la cifra de César:si una letra del texto en claro es la N-ésima del alfabeto se
368 ALGORITMOS EN C++
reemplaza por la (N +K)-ésima letra del alfabeto, siendo K un cierto entero fijo
(César utilizaba K = 3). La siguiente tabla muestra un mensaje cifrado utili-
zando este método con K = 1:
Textoenclaro: A T A Q U E A L A M A N E C E R
Texto cifrado: B U B R V F A BMA B N B O F D F S
El método es débil porque el criptoanalista sólo tiene que adivinar el valor de
K. intentando con cada una de las 26 opciones, podrá estar seguro de leer el
mensaje.
Un método mucho mejor consiste en utilizar una tabla general para definir
la sustitución a efectuar: para cada letra del texto en claro la tabla dice qué letra
poner en el texto cifrado. Por ejemplo, si la tabla ofrece la correspondencia
A B C D E F G H I J K L M N O P Q R S T U W X Y Z
N U E V O S B R I L Y H A M T W Z Y G Q P F J V K C
entonces el mensaje quedará cifrado de la siguiente forma:
Textoenclaro: A T A Q U E A L A M A N E C E R
Textocifrado: U Q U Z P S N U H N U A U M S V S Y
Esta técnica es mucho más poderosa que la simple cifra de César porque el crip-
toanalista tendría que ensayar con muchas tablas (alrededor de 27! > lo2*)para
estar seguro de leer el mensaje. Sin embargo, las cifras basadas en sustituciones
simples como éstas son fáciles de romper debido a las frecuencias de letras in-
herentes a un lenguaje. Por ejemplo, puesto que A es la letra más frecuente en
los textos en español, el criptoanalista ya tendría parte del trabajo hecho si co-
menzara buscando en el texto cifrado cuál es la letra más frecuente y la reem-
plazara por A. Aunque ésta puede no ser la selección correcta, con seguridad es
mejor que ensayar las 26 letras ciegamente. La situación se hace más fácil (para
el cnptoanalista) cuando se tienen en cuenta combinaciones de dos letras: cier-
tas combinaciones (tal como QJ) nunca aparecen en un texto en español mien-
tras que otras (como LA) son frecuentes. Examinando las frecuencias de las le-
tras y de las combinacionesde letras, un cnptoanalista puede romper fácilmente
un cifrado por sustitución.
Se puede complicarlo más utilizando vanas tablas. Un ejemplo simple de
esto es una extensión de la cifra de César denominada la cifra de Vigenere:se
utiliza una pequeña clave repetida para determinar el valor de K para cada le-
tra. En cada paso, el índice de la letra de la clave se añade al de la letra del texto
en claro para determinar el índice de la letra del texto cifrado. El ejemplo de
texto en claro, con la clave ABC queda cifrado de la siguiente forma:
Clave: A B C A B C A B C A B C A B C A B C
Textoenclaro: A T A Q U E A L A M A N E C E R
CRlPTOLOGíA 369
Textocifrado: B V D R W H A C O A C P B P H D G U
Por ejemplo, la última letra del texto cifrado es U, la vigésimo primera del al-
fabeto, porque la letra correspondiente en el texto en claro es R (la decimoctava
letra) y la letra correspondiente en la clave es C (la tercera letra).
Evidentemente, la cifra de Vigenere se puede hacer más complicada utili-
zando tablas generalesdiferentes para cada letra del texto en claro (en lugar de
simples desplazamientos). También es obvio que cuanto más larga es la clave
más eficaz será la cifra. En efecto, si la clave es tan larga como el texto en claro
se tiene la cifra de Vernam,comúnmente denominada clavepara una vez. Éste
es probablemente el único sistema de cripto seguro que se conoce, y se dice de
él que se ha utilizado en la línea directa Washington-Moscú y en otras aplica-
ciones vitales. Puesto que cada letra clave se utiliza una sola vez, el criptoana-
lista no puede hacer nada mejor que ensayar todas las claves posibles para cada
letra del mensaje, una situación desesperante, ya que esto es tan difícil como
ensayar todos los mensajes posibles. Sin embargo, el utilizar cada letra clave una
sola vez genera un problema de distribución de clavesbastante serio, por lo que
esta clavepara una vez es útil para mensajes relativamente cortos que se deben
enviar con poca frecuencia.
Si el mensaje y la clave están codificadosen binano, una técnica más común
de cifrado letra a letra consiste en utilizar la función «o-exclusivo)):para cifrar
el texto en claro se aplica un «o-exclusivo))(bit a bit) con la clave. Una carac-
terística interesante de este método es que la operación de descifrares la misma
que la de cifrar: el texto cifrado es el o-exclusivodel texto en claro y de la clave,
pero la aplicación de otro o-exclusivo al texto cifrado y a la clave lleva de nuevo
al texto en claro. Se observa también que el o-exclusivo de los textos cifrado y
en claro da la clave. Esto puede sorprender a primera vista, pero realmente mu-
chos sistemas criptográficostienen la propiedad de que el criptoanalista puede
descubrir la clave si conoce el texto en claro.
Máquinas de cifrar/descifrar
Muchas aplicaciones criptográficas (por ejemplo, sistemas de voz en comuni-
caciones militares) implican la trasmisión de grandes volúmenes de datos, lo que
hace imposible la utilización de la clavepara una vez. Lo que se necesita es una
aproximación a esta clave en la que se pueda generar un gran volumen de
«pseudo claves))a partir de una pequeña fracción, muy distribuida, de la ver-
dadera clave.
Lo usual en tales situaciones es lo siguiente: el emisor alimenta una má-
quina de cifrar con algunas variables de cifrado (claves verdaderas), para gene-
rar una larga secuencia de bits de clave (pseudo claves). El o-exclusivo de estos
bits y del texto en claro forman el texto cifrado. El receptor, con una máquina
similar y las mismas variables de cifrado, genera la misma secuencia de bits de
370 ALGORITMOS EN C++
clave para aplicarle el o-exclusivo con el texto cifrado y recuperar el texto en
claro.
En este contexto, la generación de claves es similar a la dispersión y a la ge-
neración de números aleatorios, por lo que los métodos de los Capítulos 16 y
35 son apropiados para la generación de claves. En efecto, aigunos de los me-
canismos presentados en el Capítulo 35 se desarrollaron en principio para uti-
lizarlos en máquinas de cifrar/descifi-artales como las que se describen aquí. Sin
embargo, los generadores de claves deben ser más complejos que los generado-
res de números aleatorios, porque existen técnicas para atacar a las máquinas
simples. El problema es que el criptoanalista puede llegar fácilmente a obtener
alguna parte del texto en claro (por ejemplo, los tiempos de silencio en un sis-
tema de voz), y, por Io tanto, una parte de Ia clave. Si el criptoanalista dispone
de suficienteinformación sobre la máquina, entonces lo que conozca de la clave
le puede proporcionar suficientes pistas para permitir que en aigún momento
pueda deducir los valores de Ias variables de cifrado. Entonces puede simular el
funcionamiento de la máquina y calcular todas las claves a partir de ese mo-
mento.
Los cnptógrafos disponen de varias formas de evitar estos problemas. Una
de ellas consiste en definir una parte de la arquitectura de la máquina en sí
misma como una variable de cifrado. Por lo regular se supone que el cripto-
analista conoce todo sobre la estructura de la máquina (quizás robaron alguna),
excepto las variables de cifrado, pero si se utilizan algunas de éstas para con-
figuran) la máquina, entonces puede ser dificil encontrar sus valores. Otro mé-
todo comúnmente utilizado para confundir al criptoanalista es la cifra pro-
ducto, en el que se combinan dos máquinas diferentespara generar una compleja
secuencia de claves (o para controlarse mutuamente). Otro método es la susti-
tucidn no lineal; aquí el paso del texto en claro al cifrado se hace por grandes
segmentos, no bit a bit. El problema de estos métodos es que pueden ser de-
masiado complicados, incluso para el cnptógrafo, y siempre puede existir la po-
sibilidad de que se produzcan comportamientosdegenerados para alguna selec-
ción de las criptovariables.
Sistemas de cripto de claves públicas
En aplicacionescomercialestales como la transferencia electrónica de fondos y
el (verdadero)correo electrónico, elproblema de la distribución de claves es aún
más crucial que en las aplicaciones tradicionales de la criptografia. L
a perspec-
tiva de ofrecer a cada ciudadano grandes claves que deben cambiarse con fre-
cuencia, en beneficio de la seguridad y la eficacia, inhibe ciertamenteel desarro-
llo de tales sistemas. Sin embargo, en fechas recientes se han desarrollado
métodos que prometen eliminar por completo el problema de la distribución de
claves. Tales sistemas,denominadossistemas de cripto de clavespúblicas, serán
posiblemente muy utilizados en un futuro próximo. Uno de los más conocidos
CRIPTOLOG~A 371
se basa en algunos de los algoritmos aritméticos que se han estudiado, por lo
que se va a ver un poco más de cerca su modo de funcionamiento.
La idea de los sistemas de cripto de claves públicas es utilizar una «guía te-
lefónica) de claves de cifra. La clave de cifra de cada uno (P)es de dominio
público: la clave de una persona puede figurar, por ejemplo, en la guía, junto a
su número de teléfono. Cada uno tiene también una clave secreta para desci-
frar: esta clave secreta (S)no la conoce nadie más. Para trasmitir un mensaje
M, el emisor utiliza para cifrarlo la clave pública del receptor y luego lo trans-
mite. El mensaje cifrado (texto cifrado) será C = P(M). El receptor utiliza su
clave privada para descifrar y leer el mensaje. Para que este sistema funcione,
deben satisfacerseal menos las siguientes condiciones:
(i) S(P(M))= M para todo mensaje M.
(ii) Todos los pares (S,P)son diferentes.
(iii) Obtener S a partir de P es tan difícil como leer M.
(iv) Tanto S como P son fáciles de calcular.
La primera es una propiedad fundamental de la cnptografia, la segunday la ter-
cera son para dar seguridad y la cuarta hace que los sistemas sean factibles de
utilizar.
Este esquema general fue esbozado por W. Diffie y M. Hellman en 1976,
pero sin proponer ningún método que cumpliera todas estas condiciones. Un
método tal fue descubierto rápidamente por R. Rivest, A. Shamir y L. Adle-
man. Su esquema, que se conoce como el sistema de cripto de claves públicas
RSA, está basado en la aplicación de algoritmos aritméticos sobre enteros muy
grandes. La clave de cifrar P es el par de enteros (N,p)y la clave de descifrar S
es el par de enteros (NJ),donde s se mantiene secreto. Estos números deben ser
muy grandes (típicamente, N puede tener 200 cifras y p y s 100).Los métodos
de cifrar y descifrar son simples: primero se divide el mensaje en números me-
nores que N (por ejemplo, tomando cada vez IgN bits de la cadena binana co-
rrespondiente a la codificaciónpor caracteres del mensaje). Luego se elevan es-
tos números, de forma independiente, a una potencia módulo It para curar un
mensaje A
4 (una parte del mensaje), se calcula C = P(M) = Mpmod N, y para
descifrar un texto cifrado C, se calcula M = S(C)= Cs mod N. En el Capítulo
36 se estudiará cómo llevar a cabo este cálculo; aunque los cálculos con núme-
ros de 200 cifras pueden ser engorrosos,el hecho de que sólo se necesite el resto
de la división por N permite controlar el tamaño de los números, a pesar de que
Mpy sean prácticamente inconcebibles.
Propiedad 23.1
tiempo lineal.
En el sistema de cripto RSA, un mensaje se puede clfrar en un
Para mensajes largos, la longitud de los números utilizados para las claves se
puede considerar constante (un detalle de implementación). De forma similar,
la exponenciación se efectúa en tiempo constante, puesto que no se permite que
372 ALGORITMOS EN C++
los números sean mayores que esa longitud «constante». Es cierto que este ar-
gumento oculta muchas consideraciones de implementación relacionada.., con
las operaciones sobre grandes números; el coste de estas operaciones es, de he-
cho, un factor limitativo para la generalizaciónde la aplicabilidad del métod0.i
Por tanto, se satisface la condición (iv) anterior y la condición (ii) es fácil de
asegurar. Sólo queda estar seguros de que las variables de cifrar N, p y s se pue-
den escoger para que satisfagan las condiciones (i) y (iii). La demostración de
esto necesita una presentación de la teoría de números que está fuera del al-
cance de este libro, pero es posible esbozar las ideas principales.En primer lugar
hay que generar tres números «aleatorios» primos muy grandes (de 100 cifras
aproximadamente): el mayor será s, denominándose a los otros dos x y y. Des-
pués se escoge N igual al producto de x y y, y se elige p de modo que ps mod
(x - l)(y - 1)=1.Es posible demostrar que, eligiendoN, p y s de esta manera, se
tiene que Mps mod N = M para todo mensaje M.
Por ejemplo, con la codificación estándar, el mensaje ATAQUE AL AMA-
NECER corresponde al número de 36 cifras
012001172105000112000113011405030518
puesto que A es la primera letra (01) del alfabeto, T es la (20), etc. Para man-
tener el ejemplo dentro de unos límites razonables se consideran números pri-
mos de 2 cifras (y no de 100 como sería necesario): se toma x = 47, y = 79 y
s = 97. Estos valores dan un N = 3713 (el producto de x y y) y p = 37 (el único
entero que multiplicado por 97 da de resto 1 al dividirlo por 3588). Para cifrar
el mensaje, se divide en paquetes de 4 cifras que se elevan a la potencia p (mó-
dulo N). Esto da la versión codificada
140403340803000108231215181505271657
Esto es, 012037= 1404, O11737E 0334, 210537= 0803 (mod 3713), etc. El
proceso de descifrado es el mismo, pero utilizando s en lugar de p. Así, retro-
cediendo, se encuentra el mensaje original porque 140497= 0120, 033497=
O117 (mod 3713), etcétera.
La parte más importante de los cálculos es la codificación del mensaje, de
acuerdo con la propiedad 23.1 anterior. Pero no hay sistema de cripto si no es
posible calcular las variables clave. Aunque esto implica una teoría de números
sofisticada y programas relativamente complejos para manipular números muy
grandes, el tiempo de cálculo de las claves es normalmente inferior al cuadrado
de su longitud (y no proporcional a su valor, lo que sería inaceptable).
Propiedad 23.2 Las claves de un sistema de cripto RSA se pueden crear sin ex-
cesivos cálculos.
Aquí se necesitan otra vez métodos que están fuera del alcance de este libro. Se
CRlPTOLOGíA 373
entiende que cada gran número primo se puede generar determinando primero
un gran número aleatorio, y verificando después sucesivos números, comen-
zando a partir de aquél, hasta que se encuentre un primo. Un método simple
permite efectuar un cálculo sobre un número aleatorio que, con probabilidad
1/2, «probará»que el número a comprobar no es primo. (Un número que no
sea primo sobrevivirá a 20 aplicaciones de esta comprobación menos de una vez
en un millón, y a 30 aplicacionesmenos de una vez en mil millones.) El último
paso consiste en calcularp: esto indica que una variante del algoritmo de Eucli-
des (ver Capítulo 1) responde exactamente a las necesidades del prob1ema.i
Recuérdese que la clave de descifrado s (y los factoresx y y de A') se deben
mantener en secreto,y que el éxito del método depende de que el criptoanalista
no sea capaz de encontrar el valor de s, conociendo N y p . Para el ejemplo, es
fácil encontrar que 3713 = 47 * 79, pero si N es un número de 200 cifras, hay
poca esperanza de encontrar sus factores. Esto es, parece difícil poder calcular s
a partir del conocimiento de p (y N), aunque nadie ha sido capaz de probar que
esto es así. Aparentemente encontrar p a partir de s necesita el conocimiento de
x y de y, y parece inevitable descomponer a N en factores primos para calcular
x y y. Pero esta descomposición de N es un problema muy difícil: el mejor al-
goritmo de descomposición conocido llevaría millones de años para descom-
poner un número de 200 cifras, utilizando la tecnología actual.
Una característica atractiva de los sistemas RSA es que los complicados
cálculos que implican a N, p y s se llevan a cabo una sola vez por cada usuario
que se suscribeal sistema, mientras que las operaciones cifrar y descifrarno im-
plican más que dividir el mensaje y aplicar el simple procedimiento de expo-
nenciación. Esta simplicidad de cálculo, combinada con las apropiadas carac-
terísticas de los sistemasde cripto de clavespúblicas, hace a este sistema bastante
conveniente para la comunicación del tipo confidencial,especialmenteen redes
y sistemas de computadoras.
El método RSA tiene sus inconvenientes: el procedimiento de exponencia-
ción es bastante caro en los estándares de Criptografía, y, lo que es peor, no es
posible eliminar la eventualidad de que se puedan leer los mensajescifradosuti-
lizando este método. Esto es cierto en muchos sistemas de cripto: un método
criptográfico debe resistir a un gran número de intentos de violación por parte
de los criptoanalistas antes de que se pueda utilizar con total confianza.
Se han sugeridovarios métodos para la implementación de sistemasde cripto
de claves públicas. Los más interesantes están relacionados con una clase im-
portante de problemasque generalmente se considerancomo muy difícilesy que
se estudiarán en el Capítulo 45. Estos sistemas de cripto poseen la interesante
propiedad de que un ataque que tenga éxito puede proporcionar ideas sobre
cómo resolver algunos de los difícilese insolublesproblemas (como el de la des-
composición en factores primos en el método RSA). Esta relación entre la crip-
tología y algunos dominios fundamentales de la investigación en la informática,
junto con el potencial que significa la difusión de la criptografía de claves pú-
blicas, hacen de ella un campo muy activo de la investigación actual.
374 ALGORITMOS EN C++
Ejercicios
1. Descifrar el siguiente mensaje, que se cifró con la cifra Vigenere utilizando
como clave el patrón CAB (repetido tantas veces como sea necesario; alfa-
beto de 27 letras, con el espacio en blanco precediendo a la A):
XOCCQTHHWQUCCGCFJN
2. ¿Qué tabla se debe utilizar para descifrar mensajes que han sido cifrados
utilizando el método de sustitución?
3. Suponiendo que se utiliza la cifra Vigenerecon claves de dos caracterespara
cifrar un mensaje relativamente largo, escribir un programa para adivinar
la clave, partiendo de la hipótesis de que la frecuencia de aparición de los
caracteres situados en posiciones impares debe ser aproximadamente igual
a la de los caracteres de las posicionespares.
4. Escribir procedimientos de cifrar y descifrar que utilicen la operación «O
exclusivo))entre la versión binaria del mensaje y una secuencia binaria de
uno de los generadores de números aleatorios de congruencia lineal del Ca-
pítulo 35.
5. Escribir un programa para romper el método del ejercicio anterior, supo-
niendo que se sabe que los primeros 10caracteres del mensaje son espacios
en blanco.
6. ¿Se podría cifrar un texto en claro mediante conjunciones «y»bit a bit en-
tre mensaje y clave? Explicar por qué (o por qué no).
7. ¿Verdadero o falso? La criptografía de claves públicas facilita el envío del
mismo mensaje a varios destinatarios. Explicar la respuesta.
8. ¿A qué es igual P(S(M))en el método RSA de la criptografía de claves pú-
blicas?
9. El cifrado del tipo RSA puede implicar el cálculo de M", donde M puede
ser un número de k cifras representado, por ejemplo, por un array de k en-
teros. ¿Cuántas operaciones se necesitan en este cálculo?
10. Implementar los procedimientos de cifrar/descifrar para el método RSA
(suponiendo que s, p y N se representan por arrays de enteros de tamaño
25).
CRIPTOLOG~A 375
REFERENCIAS para el Procesamiento de cadenas
Las mejores fuentes para obtener más información sobre muchos de los temas
que se han tratado en los capítulos de esta sección son las referenciasoriginales.
El artículo de Knuth, Moms y Pratt de 1977, los de Boyer y Moore de 1977 y
de Karp y Rabin de 1981 constituyen la base de la mayor parte del material del
Capítulo 19. El trabajo de Thompson de 1968 es la base del método de reco-
nocimiento de patrones descritos por expresionesregulares de los Capítulos 20
y 21. El artículo de Huffman de 1952 es anterior a muchas de las consideracio-
nes algorítmicashechas aquí, pero todavía es una lectura de interés. Rivest, Sha-
mir y Adleman describen completamente la implementación y la aplicación de
su sistema de cripto de claves públicas en su trabajo de 1978.
El libro de Standish es una buena referencia para muchos de los temas cu-
biertos por esta sección, especialmente en los Capítulos 19, 22 y 23. Ese libro
trata también algunas representacionesy algoritmos prácticos no descritosaquí.
El análisis sintáctico y la compilación son para muchos el corazón de la infor-
mática: se ha investigado su relación con los algoritmos, pero su relación con
los lenguajes de programación, la teoría de la información y otras áreas es mu-
cho más importante. Gran parte de los aspectos algorítmicos se ha estudiado
con gran detalle. La referencia estándar sobre este tema es el libro de Aho, Sethi
y Ullman.
Como es obvio, la literatura pública sobre criptografía es bastante escasa. Sin
embargo, se puede encontrar mucha información general sobre el tema en los
libros de Kahn y Konheim.
A. V. Aho, R. Sethi y J. D. Ullman, Compilers: Principles, Techniques, Tools,
Addison-Wesley, Reading, MA, 1986.(Existeversión en español por Addi-
son-Wesley Iberoamericana N. del E.)
R. S. Boyer y J. S. Moore, «Afast string searchingalgorithm», Communications
o
f the ACM, 20, 10 (octubre, 1977).
D. A. Huffman, method for the construction of minimum-redundancy co-
des», Proceedings o
f the IRE, 40 (1952).
D. Kahn, The Codebreakers,Macmillan, New York, 1967.
R. M. Karp y M. O. Rabin, «Efficient Randomized Pattern-Matching Algo-
rithms», Technical Report TR-31-8l, Aiken Comput, Lab., Harvard U.,
Cambridge, MA 198i.
D. E. Knuth, J. H. Morris y V. R. Pratt, «Fast pattern matching in strings»,
SIAM Journal on Computing,6, 2 Cjunio, 1977).
A. G. Konheim, Cryptography: A Primer, John Wiley & Sons, New York, I981.
R. L. Rivest, A. Shamir y L. Aldeman, KA method for obtaining digital signa-
tures and public-key cryptosystems», Communications o
f the ACM, 21, 2
(febrero, 1978).
T. A. Standish, Data Structure Techniques, Addison-Wesley, Reading, MA,
1980.
K. Thompson, «Regular expression search algorithm», Communications ofthe
ACM, 11, 6 Gunio, 1968).
Algoritmos en C++.pdf
Algoritmos
geométricos
Algoritmos en C++.pdf
24
Métodos geométricos
elementales
Las computadoras se están utilizando cada día más para resolver problemas a
gran escala que son inherentemente geométricos. Los objetos geométricos, tales
como puntos, líneas y polígonos constituyen la base de una gran variedad de
aplicacionesimportantes y conducen a un interesante conjunto de problemas y
algoritmos.
Los algoritmos geométricosson importantes en sistemas de diseño y análisis
de modelos de objetos fisicos,que pueden ser desde edificios y automóvileshasta
circuitos integrados a escala muy grande. Un diseñador que trabaja con un ob-
jeto fisico posee una intuición geométrica que resulta dificil de aplicar en una
representación por computadora. Otras muchas aplicaciones procesan datos
geométricos de forma directa. Por ejemplo, un esquema político de «manipu-
lación del censo electoral)), que sirva para dividir un distrito en áreas de igual
población (y que satisfaga otros criterios, como colocar a todos los miembros
del otro partido en una misma zona), es un sofisticado algoritmo geométrico.
Otras aplicacionesson de tipo matemáticoo estadístico,campos en los que mu-
chos tipos de problemas pueden ser naturalmente puestos en una representa-
ción geométrica.
La mayoría de los algoritmosque se han estudiado utilizan texto y números,
que se representan y se procesan de forma natural en la mayoría de los entornos
de programación. De hecho, las operaciones primitivas necesarias se implantan
en el hardware de la mayoría de los sistemas de computadoras. Se verá que la
situación es diferente en el caso de los problemas geométricos: incluso las ope-
raciones más elementales con puntos y líneas pueden ser un reto en términos
informáticos.
Los problemas geométricos son muy fáciles de visualizar, pero eso puede ser
un inconveniente. Muchos problemas, que una persona puede resolver instan-
táneamente mirando un papel (por ejemplo: jestá un punto dentro de un polí-
379
380 ALGORITMOS EN C++
gono?), requieren programas de computadora que no son triviales. En el caso
de problemas más complicados, como en muchas otras aplicaciones, el método
de resolución apropiado para su implantación en una computadora puede ser
bastante diferente del método de resolución adecuadopara una persona.
Se podría pensar que los algoritmos geométricos deben tener una larga his-
toria, debido a la naturaleza constructiva de la antigua geometría y porque las
aplicaciones útiles están muy difundidas, pero, en realidad, la mayor parte de
los avances en este campo han sido bastante recientes. Sin embargo, el trabajo
de los antiguos matemáticos resulta a menudo útil para el desarrollo de algont-
mos para las modernas computadoras. El campo de los algoritmos geométricos
es interesante de estudiar debido a su fuerte contexto histórico, porque aún se
están desarrollando nuevos algoritmos fundamentalesy porque numerosas apli-
caciones importantes a gran escala necesitan estos algoritmos.
Puntos, líneas y polígonos
La mayoría de los programas que se estudiarán operan sobre objetos geométri-
cos simples definidos en un espacio bidimensional, si bien se tendrá en cuenta
algunosalgoritmos para más dimensiones. El objeto fundamentales elpunto, al
que se considera como un par de enteros -las «coordenadas» del punto en el
sistema cartesiano habitual-. Una línea es un par de puntos, que se supone
que están unidos por un segmento de línea recta. Un polígono es una lista de
puntos: se supone que puntos sucesivosestán unidos por líneas y que el primer
punto está conectado al último, para formar una figura cerrada.
Para poder trabajar con estos objetos geométricos se necesita decidir cómo
representarlos. Normalmente se utiliza un array para los polígonos, aunque
también se puede usar una lista enlazada o alguna otra representación cuando
sea apropiado. La mayoría de los programas utilizarán las siguientes represen-
taciones:
s t r u c t punto { i n t x, y; char c; };
s t r u c t l i n e a { s t r u c t punto p i , p2; };
s t r u c t punto pol igono[Nmax] ;
Hay que destacar que los puntos sólo pueden tener coordenadas enteras. Tam-
bién se podría utilizar una representación en coma flotante. El uso de coorde-
nadas enteras hace que los algoritmos sean algo más sencillos y más eficaces, y
no es una restricción tan estricta como podría parecer. Como ya se mencionó
en el Capítulo 2, la utilización de enteros siempre que sea posible puede ahorrar
bastante tiempo en muchos entomos de computación,ya que los cálculos con
enteros son mucho más eficientes que las operaciones en coma flotante. Por
tanto, cuando se pueda conseguir un propósito utilizando solamente enteros, sin
introducir demasiadas complicaciones extra, éste será el camino a elegir.
MÉTODOSGEOMÉTRICOSELEMENTALES 381
Figura 24.1 Conjuntos de puntos para algoritmos geométflcoc.
Se representarán los objetos geométncos más complicados en función de es-
tos componentes básicos. Por ejemplo, los polígonos se representarán como
arrays de puntos. Se puede advertir que el uso de arrays de 1ineas supondría
que cada punto del polígono estaría incluido dos veces (aunque ésta podría ser
la representación natural para determinados algoritmos). Además, en algunas
aplicaciones resulta útil incluir información adicional asociada a cada punto o
línea; se puede hacer esto añadiendo un campo info en los registros.
Se utilizará el conjunto de puntos mostrado en la Figura 24.1 para ilustrar
las operaciones de vanos algoritmos geométncos. Los 16 puntos de la izquierda
están etiquetados con letras que servirán de referencia en las explicaciones de
los ejemplos, y poseen las coordenadas enteras que aparecen en la Figura 24.2.
(Las letras de las etiquetas se han asignado en el orden en el que se supone se
introducen los puntos.) Por lo regular, los programas no tienen motivos para
hacer referencia a los puntos por su «nombre»; éstos simplemente se almacenan
en un array y se referencian usando un índice. El orden en el que se almacenan
los puntos dentro del array puede ser importante en algunos programas: de he-
cho, el objetivo de algunos algoritmos geométricos consiste en «ordenam los
puntos de una forma determinada. En la parte derecha de la Figura 24.1 hay
A B C D E F G H I J K L M N O P
x 3 11 6 4 5 8 1 7 9 1 4 1 0 1 6 1 5 1 3 3 12
y 9 1 8 3 1 5 1 1 6 4 7 5 1 3 1 4 2 1 6 1 2 1 0
Figura 24.2 Coordenadas de los puntos del pequeño conjunto de ejemplo
(ver Figura 24.1).
382 ALGORITMOS EN C++
Figura 24.3 Comprobación de la intersecciónde segmentos: cuatro casos.
128 puntos generados aleatoriamente, con coordenadas enteras que varían en-
tre O y 1000.
Un programa típico gestiona un array de p puntos y simplemente lee N pares
de enteros, asignando el primer par a las coordenadasx e y de p [11,el segundo
par a p [21, etc. Cuando p representa a un polígono, a veces es conveniente
mantener valores «centinelas» p [O] =p [NI y p[N+1] =p [11.
Intersecciónde segmentos de líneas
Como primer problema geométrico elemental, se considerará si dos segmentos
determinados se cortan o no. La Figura 24.3 ilustra algunas de Ias situaciones
que se pueden dar. En el primer caso, los segmentos se cortan. En el segundo,
el extremode un segmento está situado en el otro segmento. Se considerará que
esto es una intersección, suponiendoque los segmentos son «cerrados» (los ex-
tremos forman parte de los segmentos);por tanto, los segmentos que poseen un
extremoen común se cortan. En los dos últimos casos de la Figura 24.3 los seg-
mentos no se cortan, pero los casos difieren si se considera el punto de intersec-
ción de las líneas definidas por los segmentos. En el cuarto caso, este punto de
intersección se encuentra en uno de los segmentos;en el tercer caso no es así. O
también, las líneas podrían ser paralelas (un caso especial, que aparece con fre-
cuencia, ocurre cuando uno de los segmentos, o ambos, son puntos).
La forma más directa de solucionar este problema consiste en encontrar el
punto de intersección de las líneas definidas por los segmentos y comprobar
después si este punto de intersección está situado entre los extremos de ambos
segmentos. Otro método sencillo se basa en una herramienta que será de utili-
dad más adelante, por lo que se estudiará con más detalle. Dados tres puntos,
se quiere saber si, al ir del primero al segundo y de éste al tercero, el movi-
miento es en el sentido contrario al de las manecillas del reloj. Por ejemplo: para
los puntos A, B y C de la Figura 24.1, la respuesta es afirmativa, pero para los
puntos A, B y D, la respuesta es negativa. Esta función es sencilla de calcular a
partir de las ecuaciones de las líneas, como se muestra a continuación:
int ccw(struct punto PO,
MÉTODOSGEOMÉTRICOS ELEMENTALES 303
struct punto ply
struct punto p2 )
int dxl, dx2, dyl, dy2;
{
dxl p1.X - p0.x; dyl = p1.y - p0.y;
dx2 = p2.x - p0.x; dy2 = p2.y - p0.y;
if (dxl"dy2 > dyl*dx2) return +l;
if (dxl*dy2 > dyl*dx2) return -1;
if ((dxl*dx2 < O) (dyl*dy2 < O)) return -1;
if ( (dxl*dxl+dyl*dyl) < (dx2*dx2+dy2*dy2) )
return O;
return +l;
1
Para entender cómo funciona el programa, se supone en primer lugar que las
cantidades dxl, dx2, dyl y dy2 son positivas. A continuación se observa que la
pendiente de la línea que une PO y pl es dyl/dxl y la pendiente de la línea que
une PO y p2 es dy2/dx2. Ahora, si la pendiente de la segunda línea es mayor
que la pendiente de la primera, se necesita un desplazamiento hacia la «&-
quierda) (en sentido contrario al de las manecillas del reloj) para ir de PO a pl
y a p2;si es menor, se necesita un desplazamiento a la ««derecha»
(en el sentido
de las manecillas del reloj). La comparación de pendientes en el programa es
algo inconveniente, puesto que las lineas podrían ser verticales (dxl o dx2 po-
drían ser O): para evitarlo, se multiplica dxl*dx2. Se puede ver que no es nece-
sario que las pendientes sean positivas para que esta prueba funcione correcta-
mente -la demostración se deja como un instructivo ejercicio-.
Pero existe una omisión crucial en la descripción anterior: se ignoran los ca-
sos en los que las pendientes son iguales (los tres puntos son colineales).En es-
tos casos, se puede pensar en varias formas de definir la función ccw.La opción
que se ha elegido consiste en hacer que la función tenga tres valores: en vez de
utilizar la notación estándar, en la que el valor devuelto es cero o un valor dis-
tinto de cero, se utilizan los valores 1 y -1, reservando el valor O para el caso en
el que p2 está sobre el segmento que une PO y pl. Si los puntos son colineales,
y PO está situado entre p2 y pl, se hace que ccw devuelva -1; si p2 está situado
entre PO y pl, se hace que ccw devuelva O; y si pl está situado entre PO y p2, se
hace que ccw devuelva 1. Se verá que este convenio simplifica la codificación
de las funciones que utilizan ccw, en este capítulo y en el siguiente.
Con estas definiciones se puede implantar rápidamente la función intersec.
Si los dos extremos de cada línea están en diferentes dados)) (tienen distintos
valores de CCW) respecto a la otra, entonces las líneas deben cortarse:
int intersec(struct linea 11, struct linea 12)
{
384 ALGORITMOS EN C++
return ((ccw(ll.pl, 11.~2,12.~1)
&& ((ccw(12.ply 12.p2, 1i.pi)
*ccw(ll.pl, ll.p2, 12.p2)) <= O)
*ccw(12.ply 12.p2, 1l.pl)) <= O);
1
Esta solución parece que supone la realización de una gran cantidad de cálculos
para un problema tan sencillo. Se anima al lector a que intente encontrar una
solución más simple, asegurándose de que funciona en todos los casos. Por
ejemplo, si los cuatro puntos son colineales,existen seiscasos distintos (sin con-
tar las situaciones en las que hay puntos coincidentes), de los cuales sólo cuatro
son intersecciones. Los casos especialescomo éstos son el problema de los al-
goritmos geométricos:no se pueden evitar, pero se puede minimizar su impacto
utilizando primitivas como ccw.
Si hay implicadas muchas líneas, la situación pasa a ser mucho más compli-
cada. En el Capítulo 27 se verá un sofisticadoalgoritmo que determina si se cor-
tan dos líneas cualesquiera de un conjunto de N líneas.
Camino cerrado simple
Para poder saborear los problemas que se refieren a conjuntos de puntos, con-
sidérese el problema de encontrar, a partir de un conjunto de N puntos, un ca-
mino que no se corte a sí mismo, que recorra todos los puntos y que vuelva al
punto inicial. Tal camino se denomina camino cerrado simple. Es posible ima-
ginar muchas aplicaciones para esto: los puntos podrían representar casas, y el
camino puede ser la ruta que seguiría un cartero para visitar todas las casas sin
cruzar su propio trayecto. O, simplemente, se podna buscar una forma razo-
nable de dibujar los puntos usando un plotter mecánico. Este problema es ele-
mental, porque sólo busca cualquier camino cerrado que conecte los puntos. El
problema de buscar el mejor de los caminos, conocido como el problema del
vendedor ambulante,es mucho, muchísimo más dificil, y se abordará con cierto
detalle en los últimos capítulos de este libro. En el siguiente capítulo se consi-
derará un problema relacionado con él, pero mucho más sencillo: encontrar el
camino más corto que envuelve a un determinado conjunto de N puntos. En el
Capítulo 31 se verá cómo encontrar la mejor forma de «conectan) un conjunto
de puntos.
Una forma sencilla de resolver este problema elemental es la siguiente: se
selecciona uno de los puntos, que servirá como «pivote». Después se calcula el
ángulo de las líneas que unen el pivote con cada uno de los puntos del conjunto
según la dirección horizontal positiva (esto es parte de las coordenadas polares
de cada punto del conjunto, con el pivote como origen). A continuación se or-
denan los puntos según el ángulo calculado. Por último se conectan los puntos
MÉTODOS GEOMÉTRICOS ELEMENTALES 385
L
I I I
Figura 24.4 Caminos cerrados simples.
adyacentes. El resultado es un camino cerrado simple que conecta todos los
pun?os,como se muestra en la Figura 24.4 para los puntos de la Figura 24.1. En
el pequeño conjunto de puntos, se utiliza B como pivote: si los puntos se reco-
rren en el orden
B M J L N P K F I E C O A H G D B
se dibujará un polígono cerrado simple.
Si dx y dy son las distancias en los ejes x e y ,desde el punto pivote a cual-
quier otro punto, el ángulo buscado en este algoritmo es tan-' &/&. Aunque
la arcotangente es una función incorporada en C++ (y en algunos otros entor-
nos de programación), es probable que sea lenta y que calcule además al menos
dos condiciones molestas para el cálculo: si dx es cero y en qué cuadrante está
el punto. Puesto que en este algoritmo el ángulo sólo se utiliza para la ordena-
ción, tiene sentido utilizar una función que sea mucho más sencilla de calcular,
pero que tenga las mismas propiedades de ordenación que la arcotangente (de
forma que, al ordenar, se obtengan los mismos resultados). Una buena candi-
data para esta función es simplemente dy/(dy+dx). Aún sigue siendo necesario
comprobar las condiciones excepcionales, pero resulta más sencillo. El siguiente
programa devuelve un número entre O y 360 que no es el ángulo formado por
p l y p2 con la horizontal, pero que tiene las mismas propiedades de ordena-
ción:
f l o a t t h e t a ( struct punto p l , struct punto p2)
i n t dx, dy, ax, ay;
f l o a t t;
{
386 ALGORITMOS EN C++
dx = p2.x - p1.x; ax = abs(dx);
dy = p2.y - p1.y; ay = abs(dy);
t = (ax+ay = O) ? O : (float) dy/(ax+ay);
if(dx < O) t = 2-t; else i f (dy < O) t = 4+t;
return t*90.0;
}
En algunos entomos de programación puede que no merezca la pena utilizar
este programa en sustitución de las funciones trigonométricasestándar, en otros,
puede que se consiga un ahorro significativo.(En determinados casos,puede que
merezca la pena hacer que theta tenga un valor entero, para evitar completa-
mente el empleo de números en coma flotante.)
Figura 24.5 Casos a tener en cuenta en el algoritmo del punto en el polígono.
Inclusión en un polígono
El siguiente problema que se va a considerar es muy natural: dados un punto y
un polígono representado como un array de puntos, determinar si el punto está
dentro o fuera del polígono. De inmediato se puede ver una solución directa a
este problema: se dibuja una línea larga desde el punto, en cualquier dirección
(lo suficientemente larga como para garantizar que el otro extremo esté situado
fuera del polígono),y se cuenta el número de líneas del polígono a las que corta.
Si el número es impar, el punto debe estar en el interior; si es par, el punto es
exterior. Esto se comprueba fácilmente viendo lo que sucede ai acercarse desde
el extremoexterior: tras la primera intersección, se está dentro; tras la segunda,
de nuevo se está fuera, etc. Si se hace esto un número par de veces, el punto al
que se llega (el punto original) debe estar en el exterior.
No obstante, la situación no es tan simple, ya que se pueden producir aigu-
nas interseccionesjusto en los vértices del polígono dado. La Figura 24.5 mues-
tra algunas de las situaciones que se deben tener en cuenta. La primera es un
caso directo de un punto exterior al polígono; la segunda es un caso directo de
punto interior; en el tercer caso, la línea de prueba sale del polígono por un vér-
tice (tras tocar otros dos vértices); y en el cuarto caso, la línea de prueba coin-
MÉTODOS GEOMÉTRICOS ELEMENTALES 387
cide con uno de los lados del polígono antes de salir. En alguno de los casos en
los que la línea de prueba corta a un vértice, debería contar como una intersec-
ción con el polígono; en otros casos, no debería contar (o debería contar como
dos intersecciones).El lector se puede entretener intentando encontrar una sen-
cilla prueba para distinguir estos casos antes de continuar leyendo.
La necesidad de tener en cuenta los casos en los que las líneas de prueba
pasan por los vértices obliga a hacer algo más que contar los lados del polígono
que se cortan con la línea de prueba. En esencia, se desea desplazarse alrededor
del polígono, incrementando el contador de intersecciones siempre que ;e pase
de un lado de la línea de prueba a otro. Una forma de implantar esto consiste
simplemente en ignorar los puntos por los que pasa la línea de prueba, como en
el siguiente programa:
int i n t e r i o r ( s t r u c t punto t, s t r u c t punto[], i n t N)
i n t i , cont = O, j = O;
s t r u c t linea I t , l p ;
p[O] = P P I ; p[N+!l = ~ [ l l ;
for ( i = l ; ic-N; i t t )
{
1 t . p l = t; l t . p 2 = t; l t . p 2 . ~= I N T J A X ;
l p . p l = p[i]; lp.p2= p[i];
if ( !i ntersec( 1p ,1t ) )
{
t
lp.p2= p[j]; j-i;
i f (i
ntersec( 1p, 1t ) ) cont++;
1
1
return cont & 1;
Este programa hace uso de una línea horizontal de prueba para simplificar los
cálculos(pueden imaginarse los diagramas de la Figura 24.5 rotados 45 grados).
La variable j se utiliza como índice del último punto del polígono que se sabe
que no va a estar sobre la línea de prueba. El programa supone que p [11 es el
punto que tiene la menor coordenada x de entre todos los puntos con la menor
coordenaday, de modo que si p [1] está en la línea de prueba, p[O] no lo puede
estar también. El mismo polígono se puede representar mediante N diferentes
arrays p, pero como se puede comprobar, a veces resulta conveniente fijar una
regia estándar para p [11.(Por ejemplo, esta misma regia es útil para usar p[11
como pivote en el procedimiento sugerido anteriormente pua el cálculo del ca-
mino cerrado simple.) Si el siguiente punto del polígono que no está en la línea
de prueba está en el mismo lado de la línea de prueba que el punto j-ésimo, no
3aa ALGORITMOS EN C++
se necesita incrementar el contador de intersecciones(cont); en caso contrario,
se tiene una intersección. Si se desea, el lector puede comprobar que este algo-
ritmo funciona adecuadamenteen los casos de la Figura 24.5.
Si el polígono sólo tiene tres o cuatro caras, como sucede en muchas apli-
caciones, no es apropiado usar un programa tan complejo: un procedimiento
más simple basado en llamadas a ccw será más adecuado. Otro caso especial
importante es el polígono convexo, que se estudiará en el siguiente capítulo, y
que tiene la propiedad de que ninguna línea de prueba puede tener más de dos
interseccionescon el polígono. En este caso se puede utilizar un procedimiento
como el de la búsqueda binaria para determinar en O(1ogN)pasos si el punto
está o no dentro del polígono.
Perspectiva
De los pocos ejemplos anteriores, debería estar claro que resulta fácil subesti-
mar la dificultad de la resolución de un determinadoproblema geométrico uti-
lizando una computadora. Existen otros muchos cálculos geométricos elemen-
tales que no se han estudiado. Por ejemplo, un ejercicio interesante podría ser
la realización de un programa que calcule el área de un polígono. Los proble-
mas vistos hasta el momento constituyen unas herramientas básicas que serán
útiles para solucionar algunos problemas más complicados. No obstante, la va-
riedad de problemas y algoritmos a tener en cuenta es tan grande que se debe
restringir el estudio a algunos ejemplos seleccionados que resuelvan problemas
fundamentales y que estén relacionados con los algoritmos que se han visto.
Algunos de los algoritmos que se estudiarán implican la construcción de es-
tructuras geométricas a partir de un determinado conjunto de puntos. El <qo-
lígono cerrado simple» es un ejemplo elemental de esto. Se necesitará decidir
las representaciones apropiadaspara tales estructuras, desarrollar los algoritmos
para construirlas y estudiar su uso en aplicacionesconcretas. Como siempre, es-
tas consideracionesestán interrelacionadas. Por ejemplo, el algoritmo usado en
el procedimiento interi or de este capítulo dependetotalmente de la represen-
tación del polígono cerrado simple como conjunto ordenado de puntos (en lu-
gar de, por ejemplo, un conjunto desordenado de líneas).
Como es habitual, la característica de abstracción de datos de C++ propor-
ciona una forma conveniente de ofrecer las diversas opciones de representación
de las aplicaciones. Por otra parte, las aplicaciones geométricas pueden benefi-
ciarse de la estructura jerárquica de clases que ofrece c++
para organizar de
forma apropiada todos los tipos de objetos y las operaciones sobre los mismos
que se deben implementar. De hecho, los textos sobre C++ a menudo utilizan
objetos geométricos para ilustrar las ventajas de este enfoque. En este libro no
se verán estos temas con mucho mayor detenimiento, ya que, desde un punto
de vista algorítmico,las operaciones necesarias suelen ser demasiado elementa-
les (ejemplo: dibujar o rotar una figura), o bien demasiado complicadas (ejem-
MÉTODOS GEOMÉTRICOSELEMENTALES 389
plo: calcular la intersección de dos polígonos). La realización de un paquete de
software apropiado que soporte búsquedas, intersecciones y otras operaciones
aplicablesa un conjunto dinámico de puntos, líneas y polígonos excede los pro-
pósitos de este libro, si bien se intentará considerar de qué forma se debería im-
plantar eficazmente las operaciones más importantes.
Muchos de los algoritmos que se estudian utilizan una búsqueda geomé-
trica: se desea conocer qué puntos de un determinado conjunto están cerca de
un punto dado, o qué puntos están dentro de un rectángulo dado, o cuáles son
los puntos que están situados más cerca entre sí. La mayoría de los algoritmos
apropiados para tales problemas de búsqueda están íntimamente relacionados
con los algoritmos de búsqueda estudiados en los Capítulos 14 a 17. El parale-
lismo será bastante evidente.
Se han analizado pocos algoritmos geométricos para que se puedan for-
mular valoraciones precisas sobre sus características de rendimiento relativo.
Como se ha visto hasta ahora, el tiempo de ejecución de un algoritmo geomé-
trico puede depender de muchos factores. La distribución de los propios pun-
tos, el orden en que se introducen y la utilización de funciones trigonométricas
pueden, en su conjunto, afectar de forma significativa al tiempo de ejecución de
los algoritmos geométncos. No obstante, como viene siendo habitual en tales
situaciones, se poseen datos empíricos que indican cuáles son los buenos algo-
ritmos para determinadas aplicaciones. Además, muchos de los algoritmos se
derivan de estudios complejos y han sido disefiados para tener buenos rendi-
mientos en el peor caso.
Ejercicios
1. Indicar el valor de ccw para los tres casos en los que dos de los puntos son
idénticos (y el tercero es distinto), y para el caso en el que los tres puntos
son idénticos.
2. Encontrar un algoritmo rápido que determine si dos segmentos son para-
lelos, sin utilizar ninguna división.
3. Encontrar un algoritmo rápido que determine si cuatro segmentos forman
un cuadrado, sin utilizar ninguna división.
4. Dado un array de 1ineas, ¿cómo se comprobaría si forman un polígono
cerrado simple?
5. Dibujar los polígonos cerrados simples que resultan de utilizar los puntos
A, C y D de la Figura 24.1 como «pivotes» según el método descrito en el
texto.
6. Suponiendo que se utiliza un punto arbitrario como «pivote» en el método
descrito en el texto para calcular un polígono cerrado simple, indicar las
condiciones que debe satisfacer dicho punto para que el método funcione
correctamente.
390 ALGORITMOS EN C++
7
. ¿Qué valor devuelve la función i ntersec cuando se la llama utilizando dos
copias del mismo segmento?
8. ¿Considera la función interior que un vértice del polígono es interior, o,
por el contrario,lo toma como exterior?
9. ¿Cuál es el máximo valor que puede tomar la variable cont cuando se eje-
cuta interior con un polígono de N vértices? Mostrar un ejemplo que
apoye la respuesta.
10. Escribir un programa eficaz que determine si un punto dado está en el in-
terior de un determinado cuadrilátero.
25
Obtención del cerco
convexo
A veces, cuando hay que procesar un elevado número de puntos, lo que interesa
es conocer los límites del conjunto de dichos puntos. Al observar en un dia-
grama un conjunto de puntos dibujados en el plano, normalmente no hay pro-
blema en distinguir los puntos que están «dentro» del conjunto de los que se
encuentran en los bordes. Esta distinción es una característica fundamental de
los conjuntos de puntos; en este capítulo se verá cómo se pueden caracterizar
de forma precisa, examinando algoritmos que distinguen los puntos que con-
forman el <&mitenatural».
El método matemáticoutilizado para la descripción del límite natural de un
conjunto de puntos depende de una propiedad geornétrica denominada conve-
xidad. Se trata de un concepto sencillo que posiblemente ya conozca el lector:
un polígono convexo posee la propiedad de que cualquier línea que una dos
puntos cualesquiera del interior del polígono estará dentro del mismo. Por
ejemplo, el «polígonocerrado simple» que se calculó en el capítulo anterior es,
decididamente,no convexo; por su parte, todos los triángulos y rectángulosson
convexos.
El nombre matemáticodel límite natural de un conjunto de puntos es cerco
convexo. Se define el cerco convexo de un conjunto de puntos del plano como
el polígono convexo más pequeño que los contiene a todos. El cerco convexo es
el camino más pequeño que envuelve los puntos. Una propiedad obvia y fácil
de probar del cerco convexo es que los vértices del polígono convexo que defi-
nen el cerco son puntos pertenecientes al conjunto original de puntos. Dados N
puntos, algunos de ellos forman un polígono convexo, dentro del cual están
contenidos todos los demás. El problema consiste en encontrar esos puntos. Se
han desarrollado numerosos algoritmos para encontrar el cerco convexo: en este
capítulo se examinarán algunos de los más importantes.
La Figura 25.1 muestra los conjuntos de puntos de ejemplo de la Figura 24.1
391
392 ALGORITMOS EN C+t
I
a
a
a
a
I
* a .=
a *
..
. *
0 .
. a
..
y sus cercos convexos. Hay 8 puntos en el cerco del conjunto pequeño y 15 en
el cerco del conjunto grande. En general, el cerco convexopuede contener, como
mínimo, desde tres puntos (si los tres puntos forman un triángulo que contiene
a los demás), hasta, como máximo, todos los puntos (si todos ellos están situa-
dos en el cerco convexo, en cuyo caso los puntos constituyen su propio cerco
convexo). El número de puntos del cerco convexo de un conjunto de puntos
«aleatorio» está comprendido entre esos extremos, como se verá a continua-
ción. Algunos algoritmos funcionan bien cuando hay muchos puntos en el cerco
convexo; otros funcionan mejor cuando sólo hay unos pocos.
Una propiedad fundamental del cerco convexo es que cualquier línea exte-
nor al cerco, al desplazarla hacia él en cualquier dirección, tocará al menos uno
de los puntos vértice. (Ésta es una forma alternativa de definir el cerco: es el
OBTENCIÓN DEL CERCO CONVEXO 393
subconjunto del conjunto de puntos que puede ser alcanzado por alguna línea
que se mueva con algún ángulo desde el infinito.) En particular, es fácil encon-
trar algunos pocos puntos con garantías de que estén en el cerco aplicando esta
regla con líneas horizontales y verticales:los puntos que tengan las coordenadas
x e y más pequeñas y más grandes pertenecen al cerco. Este hecho se utiliza como
punto de partida de los algoritmos a estudiar.
Reglas del juego
La entrada de un algoritmo de búsqueda del cerco convexo es, por supuesto, un
array de puntos; se puede utilizar el tipo punto definido en el capítulo anterior.
La salida es un polígono, también representado como un array de puntos, que
tiene la particularidad de que, al ir uniendo los puntos en el orden en que apa-
recen en el array, se dibuja el polígono. Pensándolo bien, esto puede parecer
que requiere una condición adicional de ordenación en el cálculo del cerco con-
vexo (¿por qué no devolver los puntos del cerco en cualquier orden?),pero, ob-
viamente, la salida en forma ordenada resulta más útil, y ya se ha visto que los
cálculos sin orden no son más fáciles de realizar. En todos los algoritmos que se
estudiarán es conveniente realizar los cálculos in situ: el array utilizado para el
conjunto de puntos original también se utiliza para guardar el resultado. Los
algoritmos simplemente reordenan los puntos del array original de modo que el
cerco convexo aparezca, ordenado, en las M primeras posiciones.
A la vista de la descripción anterior, queda claro que el cálculo del cerco
convexo está íntimamente relacionado con la ordenación. De hecho, es posible
utilizar un algoritmo de cerco convexo para realizar una ordenación de la si-
guiente forma. Dados N números a ordenar, se convierten en puntos (en coor-
denadas polares), considerando los números como ángulos (adecuadamente
normalizados) con un radio fijo para todos los puntos. El cerco convexo de este
conjunto de puntos es un polígono de N lados que contiene todos los puntos.
Puesto que la salida debe estar ordenadasegún el orden de aparición de los pun-
tos en el polígono, se puede utilizar para hallar el orden adecuado de los valores
originales (recordando que los datos introducidos estaban desordenados). Esto
no constituye una prueba formal de que el cálculo de un cerco convexo no es
más sencillo de realizar que una ordenación, porque, por ejemplo, se debe tener
en cuenta el coste que ocasiona el uso de las funciones trigonométricas necesa-
rias para la conversión de los números en puntos del polígono. El comparar al-
goritmos de cerco convexo (que implican la utilización de operaciones trigo-
nométricas) con algoritmos de ordenación (que implican comparaciones entre
claves) es casi como compararmanzanas con naranjas, pese a lo cual se ha visto
que cualquier algoritmo de cerco convexo requiere unas NlogN operaciones, lo
mismo que las ordenaciones (incluso siendo probable que las operaciones per-
mitidas sean muy diferentes).Resulta útil considerar el cálculo de un cerco con-
394 ALGORITMOS EN C++
vex0 como una especie de ((ordenaciónbidimensionah, ya que en el estudio de
algoritmosde cálculo de cercos convexos surgen frecuentesparalelismoscon los
algoritmos de ordenación.
De hecho, los algoritmos que se estudiarán muestran que haiiar un cerco
convexo no es másarduo que realizar una ordenación: existen varios algoritmos
que, en el peor caso, se ejecutan en un tiempo proporcional a NlogN. Muchos
de los algoritmos tienden a utilizar incluso menos tiempo en conjuntos de pun-
tos reales, debido a que su tiempo de ejecución depende de la distribución de
tales puntos, así como del número de puntos que forman el cerco.
Como con todos los algoritmos geométricos, hay que prestar alguna aten-
ción a los casos degenerados que probablemente aparezcan en la entrada. Por
ejemplo, jcuál es el cerco convexo de un conjunto de puntos que están alinea-
dos? Dependiendode la aplicación, podrían ser todos los puntos, o sólo los dos
extremos, o quizás también valdría cualquier conjunto que incluya los dos pun-
tos extremos. Aunque éste puede parecer un ejemplo extremo, no sería inusual
que más de dos puntos se encuentren situados en uno de los segmentos que de-
finen el cerco de un conjunto de puntos. En los siguientes algoritmos no se in-
sistirá en incluir los puntos que estén situados en uno de los lados del cerco, ya
que, en gelled, esto supone más trabajo (si bien, cuando proceda, se indicará
cómo se podría hacer). Por otro lado, tampoco se insistirá en que se deben omi-
tir estos puntos, ya que, si se desea, esta condición se podría comprobar con
posterioridad.
Envolventes
El algoritmo más natural de cerco convexo, que se asemeja al método que uti-
lizaría una persona para dibujar el cerco convexo de un conjunto de puntos, es
una forma sistemática de «envolven>el conjunto de puntos. Empezando por ai-
gún punto que pertenezca con seguridad al cerco convexo (por ejemplo, el que
tenga la coordenada y más pequeña), se traza una línea recta y se gira, haciendo
un «barrido» hacia arriba, hasta que toque algún punto; este punto debe perte-
necer al cerco convexo. A continuación, tomando como pivote este punto, se
continúa «barriendo» hasta encontrar el siguiente punto, y así sucesivamente
hasta que el conjunto quede «envuelto» por completo (se vuelva al punto ini-
cial). La Figura 25.2 muestra cómo se descubre el cerco del conjunto de puntos
del ejemplo siguiendo este método. E
! punto B posee la menor coordenada y y
se toma como punto inicial. A continuación, M es el primer punto alcanzado
por la línea de barrido, luego se alcanza L, etcétera.
Por supuesto, realmente no es necesario hacer un barrido para todos los án-
guios posibles; solamente se realiza un cálculo estándar para conocer el menor
ángulo necesario para encontrar el punto que se alcanzará a continuación. Para
cada punto que se incluye en el cerco, se necesita examinar todos los puntos
que aún no pertenecen a dicho cerco. Por tanto, este método es bastante pare-
OBTENCIÓN DEL CERCO CONVEXO 395
* H
O D
- M
* B * B É P
M
B
(
I
I
B
Figura 25.2 Envolventes.
cido al de la ordenación por selección -se elige el arnejom de los puntos aún
no seleccionados, utilizando una búsqueda exhaustiva del mínimo-. En la Fi-
gura 25.3 se muestra el movimientoreal de datos que se lleva a cabo: la línea M-
ésima de la tabla muestra la situación tras incluir el punto M-ésimo en el cerco.
El siguiente programa busca el cerco convexo de un array p de N puntos,
representado según la descripción del principio del Capítulo 24. La base de esta
implantación es la función theta, desarrollada en el capitulo anterior, que toma
dos puntos p l y p2 como argumentos y que se puede considerar que devuelve
el ángulo que forma el segmento que une dichos puntos con la horizontal (aun-
que en realidad devuelve un número más fácil de calcular y que posee las mis-
mas propiedades de ordenación). Por lo demás, la implantación sigue directa-
mente el método antes explicado. Se necesita un centinela para el cálculo del
396 ALGORITMOS EN C++
Figura 25.3 Movimiento de datos en envolventes.
ángulo mínimo: aunque normalmente se intentaría disponer las cosas de forma
que se utilizase p[O], en este caso es más conveniente utilizar p [N+1] .
i n t envolver(punto p [ l ] , i n t N)
i n t i , min, M;
f l o a t t h y v;
f o r (min=O, i=l;
i<N; i++)
p[N]= p[min]; th= 0.0;
f o r (M=O; M<N; M++)
{
i f ( p [ i ] .y < p[min] .y) min = i;
intercambio(p, M y min);
min= N; v= th; th= 360.0;
f o r (i=M+l; i<=N; i++)
{
if (theta(p[M], p [ i ] ) > v)
i f (theta(p[M], p[i]) < t h )
{ min= i ; th= theta(p[M] y ~ [ m i n ] ) ; }
i f (min N) r e t u r n M;
1
}
En primer lugar, se busca el punto que tiene la menor coordenada y, y se copia
en p[N+l] con el fin de detener el bucle, tal como se describe a continuación.
OBTENCIÓNDEL CERCO CONVEXO 397
La variable M guarda el número de puntos que se han incluido en el cerco hasta
el momento, y v es el valor actual del ángulo de «barrido» (el ángulo que for-
man la horizontal con la línea que une p[M-1] y p[MI). El bucle for incluye el
último punto encontrado en el cerco intercambiándolo con el punto M-ésimo,y
utiliza la función t h e t a del capítulo anterior para calcular el ángulo que for-
man la horizontal y la línea que une dicho punto con cada uno de los puntos
que aún no pertenecen al cerco, buscando el punto cuyo ángulo sea el menor
de todos los calculados y que al mismo tiempo supere el valor de v. El bucle
finaliza cuando se encuentra de nuevo el primer punto (en realidad se trata de
la copia del primer punto que se guarda en p[N+1] ).
Este programa puede o no devolver puntos que se encuentren en un lado del
cerco convexo. Esta situacibn se produce cuando más de un punto tiene el
mismo valor de t h e t a con p [MI durante la ejecución del algoritmo. Esta im-
plantación devuelve el primer punto que se encuentra, incluso aunque pueda
haber otros puntos más próximos a p[MI.Cuando sea importante encontrar los
puntos situados en los lados de los cercos convexos, se puede modificar t h e t a
de modo que tenga en cuenta la distancia entre los puntos ofrecidos como ar-
gumentos y que, cuando dos puntos tengan el mismo ángulo, asigne un valor
menor al punto más próximo.
La principal desventaja de las envolventes es que, en el peor caso, cuando
todos los puntos están en el cerco convexo, el tiempo de ejecución es proporcio-
nal a N2(como en la ordenación por selección). Por otra parte, este método PO:
see la atractiva propiedad de que se puede generalizar a tres (o más) dimensio-
nes. El cerco convexo de un conjunto de puntos de un espacio de dimensión k
es el menor polígono convexo que los contiene a todos, quedando definido un
polígono convexo por una propiedad, según la cual todas las líneas que unen
dos puntos interiores deben estar situadas también en el interior. Por ejemplo,
el cerco convexo de un conjunto de puntos en el espacio tridimensional es un
objeto tridimensional convexo con caras planas. Se puede calcular «barriendo»
el espacio con un plano hasta alcanzar el cerco, y después, «plegando» el plano
por las aristas del cerco, tomando como pivotes los diferentes bordes del cerco,
hasta que el «paquete» quede «envuelto» (como sucede con numerosos algorit-
mos geométncos, jresulta bastante más sencillo explicar esta generalización que
implantarla!).
La exploración de Graham
El siguiente método que se va a examinar, inventado por R.L. Graham en 1972,
es interesante porque la mayor parte de los cálculos necesarios se realizan para
ordenar: el algoritmo incluye una ordenación, seguida por cálculos relativa-
mente poco costosos (aunque tampoco son fáciles). Utilizando el método del
capítulo anterior, el algoritmo comienza construyendo un polígono cerrado
simple con los puntos: ordena los puntos utilizando como claves los valores dr
398 ALGORITMOS EN C++
L
Figura 25.4 Comienzo de la exploración de Graham.
la función theta, correspondientes al ángulo que forman la horizontal y cada
una de las líneas que unen los puntos con el pivote p [11 (el punto que tiene la
menor coordenaday), de forma que al unir p [13, p[21, p[31,..., p [NI, p[11se
obtiene un polígono cerrado. En el caso del conjunto de puntos del ejemplo, el
resultado es el polígono cerrado simple obtenido en el capítulo anterior. Puede
notarse que p[NI, p [1] y p [21 son puntos consecutivosdel cerco; al ordenar-
los, esencialmente se está ejecutando la primera iteración del procedimiento de
envolventes (en ambas direcciones).
El cálculo del cerco convexo se completa intentando situar cada punto en el
cerco, y eliminando los puntos ya situados que posiblemente no puedan estar
en el cerco. En el ejemplo se considera que los puntos tienen el orden B M J L
N P K F I E C O A H G D; los primeros pasos se muestran en la Figura 25.4.
Al principio, gracias a la ordenación, se sabe que B y M pertenecen al cerco.
Cuando se encuentra el punto J, el algoritmo lo incluye en el cerco de prueba
de los tres primeros puntos. Después, cuando se encuentra el punto L, el algo-
ritmo determina que J no puede estar en el cerco (ya que, por ejemplo, está
dentro del triángulo BML).
En general, no es dificil comprobar qué puntos se deben eliminar. Después
de incluir cada punto, se supone que se han eliminado suficientes puntos, de
modo que lo trazado hasta el momento podría ser parte del cerco convexo con-
siderando los puntos ya vistos. Conforme se va trazando el cerco, se espera
girar a la izquierda de cada vértice del cerco. Si un nuevo punto hace que se gire
hacia la derecha, entonces el punto recién incluido debe ser eliminado, puesto
que existe un polígono convexo que lo contiene. Específicamente, la prueba para
eliminar un punto utiliza el procedimiento ccw del capítulo anterior, de la si-
guiente forma. Suponiendo que se ha determinado que p [l] ,...,p[MI están
en el cerco parcial calculado a partir de los puntos p[11,...,p [i-11 ,cuando se
tiene que examinar un nuevo punto p [i1, se elimina p [MI del cerco si
ccw(p[M] ,p[M-l] ,p[i]) no es negativo. En caso contrario, p[M] aún podría
pertenecer al cerco, por lo que no se elimina.
La Figura 25.5 muestra la realización de este proceso utilizando el conjunto
de puntos del ejemplo. Conforme se encuentra cada nuevo punto, la situación
OBTENCION DEL CERCO CONVEXO 399
L
n
i
aL
QL
QL
L
I
QL
0'
O O
L
QL
Figura 25.5 Conclusiónde la exploración de Graham.
400 ALGORITMOS EN C++
es, en resumen, la siguiente: cada nuevo punto se incluye en el cerco parcial
construido hasta el momento, y se utiliza como «testigo»para la eliminación de
(cero o más) puntos previamente considerados. Después de incluir L, N y P en
el cerco, se elimina P al tener en cuenta el punto K (ya que NPK es un giro a
la derecha); después se incluyen F e 1, llegando a la consideración del punto E.
Llegados a este punto, se debe eliminar I porque FIE es un giro hacia la derecha;
F y K se deben eliminar, ya que KFE y NKE son también giros hacia la dere-
cha. Por tanto, se puede eliminar más de un punto en el proceso de «vuelta
atrás», quizá varios. Continuando de esta forma, el algoritmo vuelve finalmente
al punto B.
La ordenación inicial garantiza que cada punto, llegado su turno, se consi-
dera como punto del cerco, ya que todos los puntos antes considerados tienen
un valor más pequeño de theta. Cada línea que sobrevive a las «eliminacio-
nes» posee la propiedad de que todos los puntos considerados hasta el mo-
mento están en el mismo lado, de forma que cuando se vuelve a p [NI, que tam-
bién pertenece al cerco debido a la ordenación, se habrá completado el cerco
convexo de todos los puntos.
Como en el método de las envolventes, se pueden incluir o no los puntos
situados en un lado del cerco, aunque cuando haya puntos colineales se pueden
presentar dos situaciones distintas. En primer lugar, si existen dos puntos coli-
neales con p [11, entonces, como antes, la ordenación que hace uso de t h e t a
puede ordenarlas o no a lo largo de la línea común. En esta situación, los pun-
tos desordenados serán eliminados durante la exploración. En segundo lugar, se
pueden presentar puntos colineales a lo largo del cerco de prueba (que no se
eliminan).
Una vez entendido el método básico, su implantación es sencilla, aunque se
debe prestar atención a unos cuantos detalles. En primer lugar, el punto que
tenga el mayor valor de x de entre todos los puntos que tengan el mínimo valor
de y se intercambia con p [11.A continuación, se utiliza ordenshell para reor-
denar los puntos (también valdría cualquier rutina de ordenación basada en
comparaciones), estando implantada la estructura punto como una clase que
posee una operación de comparación que compara dos puntos utilizando sus
valores de t h e t a con p[11.Tras la ordenación, se copia p[NI en p[O] para ser-
vir como centinela en caso de que p [31 no esté en el cerco. Finalmente, se rea-
liza la exploración antes descrita. El siguiente programa halla el cerco convexo
delconjuntodepuntosp[l], ..., p[N]:
i n t explgraham(punt0 p[], i n t N)
i
i n t i, min, M;
for (min= 1, i=
2; i<=
N; i++)
f o r (i=
1; i<=
N; i++
)
if (p[i].y < p[min].y) min= i;
i f (p[i] .y = p[min] .y)
OBTENCIÓN DEL CERCO CONVEXO 401
if (p[i].x > p[min].x) min= i;
intercambio(p, 1, min);
ordenshell (p, N) ;
for (M= 3, i= 4; ic= N; i++)
P[OI = P P I ;'
while (ccw(p[M], p[M-11, p[i]) >= O) M--;
M++; intercambio(p, i, M);
{
1
return M;
}
El bucle mantiene un cerco parcial en p [11 ,...,p [MI, como se vio anterior-
mente. Para cada nuevo valor de i considerado, se decrernenta M si es necesario,
con el fin de eliminar puntos del cerco parcial, y después se intercambia p [i]
con p[M+1] para incluirlo (provisionalmente) en el cerco. La Figura 25.6 mues-
Figura 25.6 Movimientode datos en la exploración de Graham.
402 ALGORITMOS EN C+-t
tra el contenido del array p cada vez que se analiza un nuevo punto de este
ejemplo.
El lector, si lo desea, puede comprobar por qué para calcular el valor de m in
es necesario hallar el punto que tiene la menor coordenada xde entre todos los
que tienen la menor coordenada y. Como ya se ha mencionado, otro aspecto
sutil a considerar es el hecho de que los puntos colinealesposeen el mismo valor
de theta, y que es posible que no estén ordenados en el orden en que aparecen
en la línea, como se podría esperar.
Una razón por la que resulta interesante estudiar este método es porque se
trata de una forma sencilla del método de retroceso,una técnica de diseño de
algoritmos que se puede resumir como: ((intentaalgo, y, si no funciona, intenta
otra cosa», y que verá de nuevo en el Capítulo 44.
Eliminación interior
Casi todos los métodos de cerco convexo se pueden mejorar enormemente uti-
lizando una sencilla técnica que desecha rápidamente la mayoría de los pun-
tos. La idea general es simple: se cogen cuatro puntos que se sepa que perte-
necen al cerco, y se eliminan todos íos puntos situados en el interior del
cuadrilátero formado por esos cuatro puntos. Esto deja muchos menos puntos
a tener en cuenta en, por ejemplo, la exploración de Graham o en la técnica de
las envolventes.
Los cuatro puntos que se sabe que pertenecen al cerco se deberían elegir te-
niendo en cuenta cualquier información disponible acerca de los puntos de en-
trada. En general, es mejor adaptar la elección de los puntos a la distribución
de la entrada. Por ejemplo, si todos los valores de x e y, dentro de determinados
límites, son igualmente probables (una distribución rectangular), al elegir cua-
tro puntos de las esquinas (loscuatro puntos que tienen la mayor y menor suma
y diferencia de sus coordenadas) se eliminan casi todos los puntos. La Figura
25.7 muestra que esta técnica elimina la mayoría de los puntos que no están en
el cerco de los dos conjuntos de puntos del ejemplo.
En una implantación del método de la eliminación interior, el «bucle inte-
r i o r ~
para los conjuntos de puntos aleatorios es el que comprueba si un punto
está situado dentro del cuadrilátero de prueba. Esto se puede acelerar algo uti-
lizando un rectángulo cuyos lados sean paralelos a los ejes xe y. A partir de las
cuatro coordenadas que definen el Cuadrilátero, es fácil calcular el rectángulo
más grande que cabe en dicho cuadrilátero. Si se utiliza este rectángulo, se eli-
minarán menos puntos del interior, pero la velocidad de la comprobación com-
pensa con creces esta pérdida.
OBTENCIÓN DEL CERCO CONVEXO 403
e
e
e
e
e
Figura 25.7 Eliminación interior.
Rendimiento
Como se dijo en el capítulo anterior, los algontmos geométricos son algo más
dificiles de analizar que los algoritmos de algunas otras áreas que se han estu-
diado, ya que la entrada (y la salida) es más dificil de caracterizar. A menudo
no tiene sentido hablar de conjuntos de puntos «aleatonos»: por ejemplo, con-
forme crece N, el cerco convexo de los puntos de una distribución rectangular
es muy probable que esté muy próximo al rectángulo que define dicha distri-
bución. Los algoritmos que se han visto dependen de diferentespropiedades de
la distribución del conjunto de puntos, y, por ello, en la práctica no son com-
parables, pues para compararlos analíticamente se necesitaría entender unas in-
teraccionesmuy complicadas entre las propiedades (poco conocidas)de los con-
juntos de puntos. Por otra parte, se pueden decir algunas cosas sobre el
rendimiento de los algoritmos que pueden ayudar a la hora de elegir uno de ellos
para una aplicación concreta.
Propiedad 25.1 Después de la ordenación, la exploración de Graham es un
proceso lineal en el tiempo.
Es necesarioreflexionar un momento para convencerse uno mismo de que esto
es cierto, ya que en el programa hay un ((bucledentro de otro bucle». Sin em-
bargo, es fácil ver que ningún punto se «elimina» más de una vez, por Io que
dentro del doble bucle, el código itera menos de N veces. Utilizando este mé-
todo, el tiempo total necesario para hallar el cerco convexo está en OfNlogN),
pero el ((bucleinterion) del método es la propia ordenación, que se puede hacer
más eficaz utilizando las técnicas de los Capítulos 8 a 12.i
404 ALGORITMOS EN C++
Propiedad 25.2 Si hay M vértices en el cerco, la técnica de las envolventes ne-
cesita unos MN pasos
En primer lugar, hay que calcular N-I ángulos para hallar el mínimo, después
se calcula N-2 para hallar el siguiente, después N-3, etc., de modo que el nú-
mero total de cdculos de ángulos es ( N - 1) + ( N - 2) + ... + ( N - M + l), que
es exactamenteigual a MN - M(M - 1)/2. Para comparar analíticamente esto
con la exploración de Graham, se necesitaría una fórmula de M en función de
N, un problema dificil en la geometría estocástica. Para una distribución circu-
lar (y algunas otras), la respuesta es que M está en 0(N1’3),
y para valores de N
que no son grandes, es comparable a logN (que es el valor esperado para
una distribución rectangular), de forma que este método competiría muy favo-
rablemente con la exploración de Graham. Por supuesto, siempre se debe tener
en cuenta el peor caso N*..
Propiedad 25.3 El método de la eliminación interior es, por término medio,
lineal.
El análisis matemático completo de este método requeriría una geometría es-
tocástica incluso más sofisticada que antes, pero el resultado general es el que
indica la intuición: casi todos los puntos están dentro del cuadrilátero y se des-
cartan -el número de puntos que quedan está en O(@)-. Esto es cierto in-
cluso si se utiliza el rectángulo, como se mencionó anteriormente. Esto hace que
el tiempo medio de ejecución de todo el algoritmo de cerco convexo sea pro-
porcional a N, ya que la mayoría de los puntos sólo se examinan una vez (cuando
se descartan). Por regla general, no importa mucho qué método se utiliza des-
pués, ya que es probable que queden muy pocos puntos. No obstante, para de-
fenderse ante el peor caso (cuando todos los puntos están en el cerco), es pru-
dente utilizar la exploración de Graham. Con esto se consigue un algoritmo que
es casi seguro que se ejecutará en la práctica de forma lineal en el tiempo, y que
se garantiza que se ejecutará en un tiempo proporcional a NL0gN.i
El resultado del caso medio de la propiedad 25.3 sólo es válido para puntos
distribuidos aleatoriamente en un rectángulo, y en el peor caso, el método de
eliminación intenor no elimina nada. No obstante, para otras distribuciones u
otros conjuntos de puntos de propiedades desconocidas,aún se recomienda uti-
lizar este método porque su coste es bajo (una exploración lineal de los puntos,
con unas pocas comprobaciones), y el ahorro posible es alto (la mayoría de los
puntos se pueden eliminar fácilmente). El método también se puede ampliar a
dimensiones mayores.
Es posible concebir una versión recursiva del método de eliminación inte-
rior: se hallan los puntos extremos, y se eliminan los puntos situados en el in-
terior del cuadrilátero definido, como antes, pero considerando después que los
puntos restantes se dividen en subproblemas que se pueden resolver de forma
independiente, utilizando el mismo método. Esta técnica recursiva es similar al
OBTENCIÓN DEL CERCO CONVEXO 405
procedimiento de selección se1ecc de tipo Quicksort que se vio en el Capítu-
lo 9. Como aquel procedimiento, es vulnerable a un tiempo de ejecución N2en
el peor caso. Por ejemplo, si todos los puntos originales están en el cerco con-
vexo, no se desecha ningún punto en la etapa recursiva. Como en selecc, el
tiempo de ejecución, por término medio, es lineal (aunque no resulta fácil de-
mostrarlo). Pero debido a que se eliminan tantos puntos en la primera etapa,
no es probable que merezca la pena preocuparse por realizar una posterior des-
composición recursiva en ninguna aplicación práctica.
Ejercicios
1. Suponiendoque se conoce de antemano que el cerco convexo de un con-
junto de puntos es un triángulo, obtener un algoritmo sencillo que encuen-
tre dicho triángulo. Responder a la misma cuestión en el caso de que el cerco
sea un cuadrilátero.
2. Indicar un método eficaz para determinar si un punto está situado en el in-
terior de un polígono convexo.
3. Implantar un algoritmo de cerco convexo parecido a la inserción ordenada,
utilizando el método del ejercicio anterior.
4. En la exploración de Graham jes estrictamente necesario empezar con un
punto que con seguridad pertenece al cerco? Explicar las.razones.
5. En el método de la envolvente ¿es estrictamente necesario empezar con un
punto que con seguridad pertenece al cerco? Explicar las razones.
6. Dibujar un conjunto de puntos que haga que la exploración de Graham del
cerco convexo sea particularmente ineficaz.
7. ¿Es capaz la exploración de Graham de encontrar el cerco convexo de los
puntos que constituyen los vértices de cualquier polígono sencillo?Explicar
por qué, o buscar un contraejemplo que demuestre lo contrario.
8. ¿Qué cuatro puntos se deberían utilizar en el método de la eliminación in-
terior si se supone que la entrada está distribuida aleatoriamente en el in-
terior de una circunferencia (utilizando coordenadas polares aleatorias)?
9. Comparar empíricamentela exploración de Graham y el método de la en-
volvente para conjuntos de puntos grandes en los que los valores de x e y
son equiprobables dentro del intervalo O a 1.OOO.
10. Implantar el método de la eliminación interior y determinar empírica-
mente cómo debería ser el valor de N antes de que se pueda esperar que
queden 50 puntos tras utilizar el método en conjuntos de puntos en los que
los valores de x e y son equiprobables dentro del intervalo O a 1.OOO.
Algoritmos en C++.pdf
26
Búsqueda por rango
Dado un conjunto de puntos del plano, es natural preguntar cuáles de ellos se
encuentran dentro de una zona específica. «Listar todas las ciudades que estén
a menos de 50 millas de Princeton)) es una pregunta que lógicamente podría
hacerse si se dispusiera del conjunto de puntos correspondientes a las ciudades
de los Estados Unidos. Cuando se limitan las figuras geométricas a los rectán-
gulos, el problema se extiende fácilmente a dominios no geométricos.Por ejem-
plo, distar todas las personas entre 21 y 25 años con ingresos entre 60.000 y
100.000dólares» es lo mismo que preguntar qué «puntos» de un archivo de da-
tos con nombres de personas, edades e ingresos, están dentro de un cierto rec-
tángulo del plano edad-ingresos.
La generalización a más de dos dimensiones es inmediata. Si se desea listar
todas las estrellas a menos de 50 años luz del sol, se tiene un problema tridi-
mensional, y si se desea conocer del conjunto de personas jóvenes y bien paga-
das del párrafo anterior las que son altas y mujeres, se tiene un problema de
cuatro dimensiones. De hecho, la dimensión de tales problemas puede llegar a
ser muy grande.
En general, se supone la existencia de un conjunto de registros con ciertos
atributos que toman valores en un conjunto ordenado. (Esto a veces se deno-
mina una base de datos, aunque para este término se han desarrollado defini-
ciones más específicasy completas.) A la acción de encontrar todos los registros
de una base de datos de los que un conjunto específico de atributos satisfacen
determinadas restricciones de rango, se la denomina búsqueda por rango, y es
un problema difícil e importante en ciertas aplicaciones prácticas. En este ca-
pítulo se centrará la atención en el problema geométrico bidimensional en el
que los registros son puntos y los atributos sus coordenadas, para posterior-
mente presentar otras posibles generalizaciones.
Los métodos que se estudiarán son generalizacionesdirectas de las técnicas
que ya se han visto en la búsqueda sobre claves simples (en una dimensión). Se
supone que gran parte de las consultas se hacen sobre el mismo conjunto de
puntos, lo que permite dividir el problema en dos partes: un algoritmo de pre-
407
408 ALGORITMOS EN C++
procesamiento, que estructurelos puntos dadospara permitir una búsqueda por
rango eficaz, y un algoritmo de búsquedapor rango que utilice dicha estructura
para devolver los puntos situados dentro de cualquier rango (multidimensio-
nal). Esta separación hace dificil la comparación de métodos diferentes, puesto
que el coste total depende no sólo de la distribución de los puntos implicados
sino también del número y la naturaleza de las peticiones.
El problema de la búsqueda por rango en una dimensiónconsiste en devol-
ver todos los puntos que están dentro de un intervalo específico. Esto se puede
hacer ordenando los puntos en preprocesamiento y haciendo luego una bús-
queda binaria sobre los puntos extremos del intervalo para devolver todos los
puntos que estén entre ellos. Otra solución consiste en construir un árbol bina-
no de búsqueda y después hacer un simple recorrido recursivo del mismo, de-
volviendo los puntos del intervalo e ignorando las partes del árbol situadas fuera
de él. El programa que se necesita es un simple recomdo recursivo del árbol
(ver los Capítulos 4 y 14). Si el punto del extremoizquierdo del intervalo está a
la izquierda del punto de la raíz, se busca (recursivamente) en el subárbol iz-
quierdo y de forma similar para el derecho, verificando en cada nodo que se
encuentre si los puntos asociados a cada uno de ellos están o no dentro del in-
tervalo:
int rango(int vl, int v2)
{ return rangol(cabeza->der, vl, v2); }
int rangol(struct nodo *t, int vl, int v2)
int txl, tx2, contador = O;
if (t == z) return O;
txl = (t->clave >= vl);
tx2 = (t->clave <= v2);
if (txl) contador += rangol(t->izq, vl, v2);
if (txl && tx2) contador++;
if (tx2) contador += rangol(t->der, vl, v2);
return contador;
{
1
Dependiendodel contexto, estas operaciones pudieran ser mejoras de la imple-
mentación del diccionario del árbol binario de búsqueda del Capitulo 14. La
Figura 26.1 muestra los puntos que se encuentran cuando este programa se eje-
cuta sobre un árbol de prueba. Se observa que los puntos devueltos no necesitan
estar enlazados al árbol.
Propiedad 26.1 Una búsqueda por rango unidimensional se puede hacer con
un número de pasos en O(MogN) para el preprocesamiento y en O(R + lo@)
para la búsqueda por rango, donde R es el número de puntos que están real-
mente en el intervalo.
BÚSQUEDA POR RANGO 409
Figura 26.1 Búsqueda por rango íunidirnensional)con un árbol de búsqueda binaria.
Esto se obtiene directamente de las propiedades elementales de las estructuras
de búsqueda (ver Capítulos 14 y 15). Se podna utilizar, si se desea, un árbol
equilibrado.
El objetivo de este capítulo será alcanzar los mismos tiempos de ejecución
para la búsqueda por rango multidimensional. El parámetro R puede ser bas-
tante significativo: dada la facilidad para hacer consultas de rango, un usuario
podría fácilmente formular peticiones que impliquen a todos o casi todos los
puntos. Este tipo de petición podría ocurrir razonablemente en muchas aplica-
ciones, pero no se necesitan algoritmos sofisticados si todas las peticiones son
de este tipo. Los algoritmos que se van a considerar se han diseñado para ser
eficaces en peticiones donde no se espera que se devuelva un gran número de
puntos.
Métodos elementales
En dos dimensiones, el «rango» es una zona del plano. Por simplicidad, se con-
siderará el problema de encontrar todos los puntos cuyas coordenadas x estén
dentro de un intervalo en x dado y cuyas coordenadas y estén dentro de un in-
tervalo en y esto es, se buscan todos los puntos que están dentro de un rectán-
gulo dado. Así pues, se supone que existe un tipo rect que es un registro de
cuatro enteros, los puntos extremos de los intervalos horizontal y vertical. La
operación básica consisteen comprobar si un punto dado está dentro de un rec-
tángulo dado, por lo que se supone una función dentro-rect (struct punto
p , struct rect r) que compruebaesto de formadirecta,devolviendoun valor
distinto de cero si p está dentro de r. El objetivo es encontrar todos los puntos
situados en el interior de un rectángulo dado, utilizando la menor cantidad po-
sible de llamadas a dentro-rec t.
La forma más simple de resolver este problema es la búsquedasecuencial: se
recorren todos los puntos, comprobando si cada uno está dentro del rango es-
pecificado (llamando a dentro-rect para cada uno de ellos). Este método se
41O ALGORITMOS EN C++
* O
* A
* C
* I
* N I
* G * J
* H
* D
* M
* B
* * *. **
*
.
:
* T I . .
* .
0 .
0.
* * 0 . .
**;
I**
e
:
:
{ **
0 .
* s a f
. * . e
* .
a*
**
* *
0.
* a
* * @
8
Figura 26.2 Búsquedapor rango bidimensional.
utiliza de hecho en muchas aplicacionesde bases de datos porque se puede me-
jorar fácilmente ((empaquetando))las peticiones, y así se comprueban muchas
de ellas durante el propio recomdo a lo largo de los puntos. En una base de
datos muy grande, donde éstos se encuentran en dispositivosexternos y el tiempo
de lectura es el factor de coste dominante, éste puede ser un método muy ra-
zonable: reagrupar tantas preguntas como sea posible tener en la memoria in-
tema y buscar la respuesta a todas ellas en una sola pasada a través del gran
archivo de datos externo. Sin embargo, si este tipo de empaquetado no es con-
veniente o si la base de datos es, en cierto sentido, pequeña, existen métodos
mucho mejores.
No obstante, en este problema geométrico la búsqueda secuencia1 parece
implicar demasiado trabajo, como se muestra en la Figura 26.2. El rectángulo
de búsqueda posiblemente contenga sólo unos pocos puntos, de modo que jes
necesario buscar a través de todos los puntos para encontrar sólo unos pocos?
Una mejora simple del método de búsqueda secuencial consiste en la aplicación
directa de un método unidimensional conocido a lo largo de una o más de las
dimensiones de la búsqueda. Por ejemplo, se buscan los puntos cuyas coorde-
nadas x estén dentro del intervalo x especificado por el rectángulo, y luego se
verifican las coordenadas y de esos puntos para determinar si pueden estar (o
no) en el interior del rectángulo. Así pues, los puntos que no pueden estar den-
tro del rectángulo porque sus coordenadasx están fuera de rango no se exami-
narán nunca. Esta técnica se denominaproyección; por supuesto, también sería
posible proyectar sobre y. En el ejemplo, se comprobaríanlos puntos E C H F
e I para la proyección x, y los O E F K P N y L para la proyección y. Se observa
que el conjunto de puntos buscados (E y F)son precisamente aquellos que apa-
recen en ambas proyecciones.
Si los puntos están uniformemente distribuidos en una región rectangular,
BÚSQUEDA POR RANGO 411
entonces es fácil calcular el número medio de puntos a verificar. La proporción
de puntos que se podría esperar encontrar en un rectángulo dado es simple-
mente la relación entre su área y la de toda la región; la proporción de puntos
que se podría esperar verificar para una proyección x es la relación entre el an-
cho del rectángulo y el de la región, y lo mismo para una proyección y. En el
ejemplo, utilizando un rectángulo de 4por 6 en una región de 16 por 16 se po-
dría esperar encontrar 3/32 puntos en el rectángulo, 1/4 de ellos en una proyec-
ción x y 3/8 en una y. Evidentemente, en tales circunstancias, es mejor proyec-
tar sobre el eje correspondiente a la más pequeña de las dos dimensiones del
rectángulo. Por otro lado, es fácil construir casos donde la técnica de proyección
podría fallar miserablemente: por ejemplo, si el conjunto de puntos forma una
figura en forma de «L» y la búsqueda se efectúa en un rango que engloba sólo
la esquina derecha de la «L», entonces la proyección sobre los ejes elimina sólo
la mitad de los puntos.
A primera vista, parece que la técnica de proyección podría mejorarse <un-
tersecandon los puntos que están dentro del rango x y los que están dentro del
y. Pero intentar hacer esto sin examinartodos los puntos del rango x o todos los
del rango y, en el peor caso, es tan difícil que sirve principalmente para que se
aprecien mejor los métodos más sofisticados que se van a estudiar.
Método de la rejilla
Una técnica simple pero eficaz para mantener relaciones de proximidad entre
los puntos del plano consiste en construir una rejilla imaginaria que divida la
zona de búsqueda en pequeñas celdas y en mantener listas de pequeño tamaño
de los puntos que están dentro de cada celda. (Ésta es una técnica que se utiliza,
por ejemplo, en arqueología.)Así, cuando se buscan los puntos incluidos en un
rectángulo dado, sólo se necesita buscar en las listas que corresponden a las cel-
das del rectángulo. En el ejemplo, sólo se examinan E, C, F y K, como se mues-
tra en la Figura 26.3.
Queda todavía por determinar el tamaño de la rejilla: si es muy grosera, cada
celda contendrá demasiados puntos, y, si es muy fina, habrá muchas celdas en
las que buscar (aunque la mayoría estén vacías). Una forma de alcanzar el equi-
librio entre estos dos extremos es escoger el tamaño de la rejilla de modo que el
número de celdas sea una fracción constante del número total de puntos, lo que
da un número medio de puntos en cada celda aproximadamente igual a una
pequeña constante. Para el pequeño conjunto de puntos del ejemplo, la utili-
zación de una rejilla de 4por 4, con 16puntos, significaque cada celda conten-
drá por término medio un punto.
A continuación se presenta una implementación directa de una clase que
soporta la búsqueda por rango en un espacio bidimensional utilizando el mé-
todo de la rejilla. Las variables maxR y tal 1a se utilizan para controlar la reso-
lución de la rejilla (el número y tamaño de las celdas). Se supone que las coor-
412 ALGORITMOS EN C++
a
a .
. * a
* a * a .
a a
- a a a - . a a a
Figura 26.3 Método de la rejilla para la búsquedapor rango.
denadas son enteras, como es habitual, y tal 1a se toma como el ancho de la
celda. Existen maxR por maxR celdas en la rejilla de modo que el rango de las
coordenadas de los puntos varía entre O y tal 1 a*maxR. Para encontrar la celda
a la que pertenece un punto dado, se dividen sus coordenadas por tal 1a:
class Rango
private:
{
struct nodo
struct nodo *rejilla[maxR] [maxR];
struct nodo *z;
{ struct punto p; struct nodo *siguiente; };
public:
Rango( ;
void insertar(struct punto p);
int buscar(rect rango);
1;
{
Rango::Rango()
int i,j;
z = new nodo;
for (i= O; i <= maxR; i++)
for (j = O; j <= maxR; j++)
rejillari] [j];
1
BÚSQUEDA POR RANGO 413
void Rango: :insertar(struct punto p)
struct nodo *t = new nodo;
t->p = p; t->siguiente = rejilla [p.x/talla] [p.y/talla];
reji 1 1 a[p.x/tall a] [p.y/tall a] = t;
{
1
I
Este programa utiliza la representaciónestándar por listas enlazadas, con el nodo
cola ficticio z y la lista de los encabezamientos en el array. Las listas están de-
sordenadas, con inserción por el frente, como en el Capítulo 3.
Los valores apropiados de las variables tal 1 a y maxR dependen del número
de puntos, de la cantidad de memoria disponible y del rango de los valores de
las coordenadas. En primera aproximación, si hay N puntos y se desea M pun-
tos por celda, entonces se necesita alrededor de N M celdas, por lo que se debe
escoger maxR como el entero más próximo a e
N/M y tal 1 a debe ser aproxi-
madamente el máximo de coordenadas de punto dividido p o r d m . Esto
proporciona alrededor de N/M celdas. Estas estimaciones son falsaspara peque-
ños valores de los parámetros, pero son válidas en la mayoría de los casos, pu-
diéndose adaptar fácilmente para aplicaciones específicas. No es necesario un
cálculopreciso. Típicamente se podría utilizar una potencia de dos para el valor
de tal 1 a para hacer la multiplicación y la división por tal 1 a mucho más efi-
caz, doblando solamente el número de puntos por celda (de 1 a 2 en el ejemplo
anterior).
La implementación precedente utiliza M = 1, una opción que se elige con
mucha frecuencia. Si no hay problemas de espacio en la memoria, pueden ser
apropiados valores más grandes, pero los más pequeños probablemente no se-
rán útiles salvo en situaciones muy específicas.
Ahora, la mayor parte del lrabajo de la búsqueda por rango se efectúa sim-
plemente indexando dentro del array rej i1 1 a, como sigue:
int Rango: :buscar(struct rect rango) //Método de rejilla
struct nodo *t;
int i,j , contador = O;
for (i= rango.xl/talla; i <= rango.x2/talla; i++)
{
for (j = rango.yl/talla; j <= rango.y2/talla; j++)
if (dentro-rect (t->p, rango)) contador++;
for (t = rejilla[i][j]; t != z; t = t->siguiente)
return contador;
Este programa cuenta simplemente el número de puntos del rango, utilizando
una variable global contador. Modificarlo para que imprima o devuelva todos
los puntos del rango es un proceso directo.
414 ALGORITMOS EN C++
Propiedad 26.2 El método de la rejilla para la búsqueda por rango es lineal en
el número de puntos del rango, por término medio, y lineal en el número total
de puntos en el peor caso.
Esto depende de la elección de los parámetros de modo que el número esperado
de puntos en cada celda sea constante, como se describió con anterioridad. Si el
número de puntos del rectángulo a buscar es R,entonces el número de celdas
de la rejilla a examinar es proporcional a R.El número de celdas examinadas
que no están completamente dentro del rectángulo es ciertamente menor que
una pequeña constante multiplicada por R,por lo que el tiempo total de eje-
cución (por término medio) es lineal en R.Para un R grande, el número de
puntos examinados no incluidos en el rectángulo buscado es bastante pequeño:
todos los puntos de este tipo están en las celdas que intersecan al lado del rec-
tán u10 de búsqueda, y el número de celdas con esta propiedad es proporcional
a &,para un R grande. Esta observaciónes falsa si las celdas son muy peque-
ñas (muchos celdas vacías dentro del rectángulo) o demasiado grandes (muchos
puntos en las celdas del perímetro del rectángulo)o si el rectángulo de búsqueda
es más estrecho que una celda (podría intersecar muchas celdas pero tener po-
cos puntos dentro).i
Ei método de la rejilla es eficaz si los puntos están bien distribuidos sobre el
rango, pero no lo es si están agrupados. (Por ejemplo, todos los puntos podrían
estar en una celda, lo que significaría que la estrategiade la rejilla no habría ser-
vido para nada.) El método que se examinará a continuación hace que sea muy
poco probable este peor caso subdividiendo el espacio de manera no uniforme,
adaptándolo a cada conjunto de puntos.
Arboles bidimensionales
Los árboles bidimensionales (20)son estructuras dinámicas y adaptables muy
similares a los árboles binarios, pero que dividen el espacio geométrico de una
manera apropiada para su utilización en la búsqueda por rango y en otros pro-
blemas. La idea es construir árboles binarios de búsqueda con puntos en los no-
dos, utilizando, en una secuencia estrictamente alternada, las coordenadas x y
y de esos puntos como claves.
El mismo algoritmo de inserción en árboles binarios de búsqueda normales
se utiliza para insertar puntos en árboles 2D, pero situando en la raíz la coor-
denada y (si el punto a insertar tiene una y menor que la de la raíz, se va hacia
la izquierda; si no a la derecha), despuésla x en el siguientenivel, en el siguiente
la y, y así sucesivamente, alternando hasta que se encuentre un nodo externo.
La Figura 26.4 muestra el árbol 2D correspondiente al pequeño ejemplo del
conjunto de puntos.
La importancia de esta técnica es que corresponde a dividir el plano de una
BÚSQUEDA POR RANGO 415
Figura 26.4 Un árbol bidimensional(2D).
forma simple: todos los puntos por debajo del de la raíz van al subárbol iz-
quierdo y todos los que están por encima van al subárbol derecho; después to-
dos los puntos por encima del de la raíz y a la izquierda del punto del subárbol
derecho van al subárbol izquierdo del subárbol derecho de la raíz, etcétera.
Las Figuras 26.5 y 26.6 muestran cómo se subdivide el plano en correspon-
dencia con la construcción del árbol de la Figura 26.4. Primero se dibuja una
línea horizontal que pase por A, el primer nodo insertado. Después, como B
está por debajo de A, va a la izquierda de A en el árbol y se divide el semiplano
que queda por debajo de A por medio de una línea vertical que pasa por la
coordenadax de B (segundodiagrama de la Figura 26.5). A continuación, puesto
que C está por debajo de A, se va a la izquierda de la raíz, y como está a la
izquierda de B se va a la izquierda de B, dividiendo la porción del plano por
debajo de A y a la izquierda de B por medio de una línea horizontal que pase
por la coordenada y de C (tercer diagrama de la Figura 26.5). La inserción de D
es similar, después E va a la derecha de A, puesto que está por encima (primer
diagrama de la Figura 26.6), etcétera.
Cada nodo externo del árbol corresponde a un rectángulo del plano. Cada
región corresponde a un nodo externo del árbol; cada punto pertenece a un seg-
Figura 26.5 Subdivisióndel plano con un árbol 2D: etapas iniciales.
416 ALGORITMOS EN C++
OE
* /
t *
I
-
*
1
.
7
t *
t I AM
Figura 26.6 Subdivisióndel plano con un árbol 2D: continuación.
BÚSQUEDA POR RANGO 417
mento horizontal o vertical que define la división efectuada en el árbol en ese
punto.
El código para la construcción de árboles 2D es una modificación directa de
la búsqueda-inserción estándar en un árbol binario que permita esta- cam-
biando entre las coordenadas x e y en cada nivel:
class Rango
private:
i
struct nodo
struct nodo *z, *cabeza;
struct punto ficticio;
int buscar-rect(struct nodo *t,
{ struct punto p; struct nodo *izq, *der; };
struct rect rango, int d);
public:
Rango( ;
void insertar(struct punto p);
int buscar( rect rango);
1;
{
void Rango::insertar(struct punto p) //Arb01 2D
struct nodo *f, *t; int d, td;
for (d = O, t = cabeza; t != z; d =! d)+
c
1
td = d ? (P.X < t->p.x) : (p.y < t->p.y);
f = t; t = td ? t->izq : t->der;
t = new nodo; t->p = p; t->izq = z; t->der = z;
if (td) f->izq = t; else f->der = t;
1
La interfaz pública para esta clase es la inisma que para el método de la rejilla
anterior, pero la implementación utiliza árboles binarios de búsqueda.
Propiedad 26.3 La construcción de un árbol 2 0 para N puntos aleatorios ne-
cesita por término medio 2NlogN comparaciones.
En efecto, para puntos aleatoriamente distribuidos, los árboles 2D tienen las
En el original !=. Pensamos que d debe ser una variable lógica o booleana,ya que, en función de SU valor
(verdaderoo falso),se irá alternativamente al subárbol izquierdo o al subárbol derecho. En otros libros del autor,
Algorifhrnsy Algorithms in C, figura expresamente d como variable lógica. (N.de los T.)
418 ALGORITMOS EN C++
Figura 26.7 Búsqueda por rango con un árbol 2D.
mismas característicasde rendimiento que los árboles binarios de búsqueda. Las
dos coordenadas actúan como «claves»a1eatorias.i
Para efectuar una búsqueda por rango utilizando árboles 2D, primero se
construye el árbol 2D insertando N puntos en un árbol inicialmente vacío. El
código de inicialización debe estar cuidadosamente coordinado con las condi-
ciones iniciales del procedimiento de descenso por el árbol, pues si no podría
introducirse un error inoportuno, y el algoritmo buscaría coordenadas x donde
el árbol tiene las y, y viceversa.
A continuación, para efectuar la búsqueda por rango, se compara el punto
de cada nodo con el rango de la dimensión utilizada para dividir el plano en ese
nodo. Para el ejemplo, se comienza por ir a la derecha de la raíz y a la derecha
del nodo E, puesto que el rectángulo está enteramente por encima de A y a la
derecha de E. Después, en el nodo F, se debe descender por ambos subárboles,
puesto que F está dentro del rango x definido por el rectángulo (lo que no equi-
vale a decir que F está dentro del rectángulo). Luego se comprueban los subár-
boles izquierdos de P y K, lo que corresponde a verificar que regiones del plano
se solapan con el rectángulo de búsqueda. (Ver las Figuras 26.7 y 26.8.)
Este proceso se implementa fácilmente con una generalización directa del
procedimiento rango de una dimensión(1D) que se estudió al principio de este
capítulo:
int Rango: :buscar(struct rect rango) / / árbol 2D
{ return buscar-rect (cabeza->der, rango, 1) ; }
int Rango: :buscar-rect(struct nodo *t, struct rect rango, int d)
int tl, t2, txl, tx2, tyl, ty2, contador = O;
if (t = z) return O;
txl = rango.xl < t->p.x; tx2 = t->p.x <= rango.x2;
tyl = rango.yl < t->p.y; ty2 = t->p.y <= rango.y2;
{
tl = d ? txl : tyl; t2 = d ? tx2 : ty2;
B~SQUEDA
POR RANGO 419
if (ti) contador += buscar-rect(t->izq, rango, !d);
if (dentro-rect (t->p, rango)) contador++;
if (t2) contador += buscar-rect (t->der, rango, !d) ;
return contador;
1
Este procedimiento desciende por los dos subárboles sólo cuando la línea de di-
visión corta al rectángulo, lo que no debería pasar frecuentemente para rectán-
gulos relativamente pequeños. La Figura 26.8 muestra la subdivisión del plano
y los puntos examinadosen los dos ejemplos.
I I
Figura 26.8 Búsqueda por rango con un árbol 2D (subdivisióndel plano).
Propiedad 26.4 La búsqueda por rango con un árbol 2 0 parece utilizar alre-
dedor de R+logNpasos para encontrarR puntos que están en rangos de tamaño
razonable en una región que contiene N puntos.
El análisis de este método está todavía por hacer y la propiedad planteada es
una conjetura basada en la evidencia empírica. Por supuesto, el rendimiento (y
el análisis)depende mucho del tipo de rango utilizado. Pero el método es com-
parable en su rendimiento con el de la rejilla y, en cierto modo, depende menos
de la «aleatonedad» del conjunto de puntos. La Figura 26.9 muestra el árbol
2D del ejemplo mayor..
Búsqueda por rango multidimensional
El método de la rejilia y el de los árboles 2D se generalizan de forma directa
para más de dos dimensiones: las extensiones simples y directas de los algorit-
420 ALGORITMOS EN C++
Figura 26.9 Búsqueda por rango con un árbol 2D grande.
mos anteriores proporcionan métodos de búsqueda por rango para más de dos
dimensiones. Sin embargo, la naturaleza del espacio multidimensional impone
cierta precaución y sugiere que las característicasde rendimiento de los algorit-
mos pueden ser dificiles de predecir para una aplicación particular.
Para implementar el método de la rejilla para búsquedas k-dimensionales,
se toma simplemente un array reji11a k-dimensional con un índice para cada
dimensión. El problema principal consisteen elegir un valor razonable para ta-
11a. Este problema resulta bastante obvio cuando se consideraun k grande: ¿qué
tipo de rejilla se debe utilizar para una búsqueda 10-dimensional?Incluso si se
utilizan sólo tres divisiones por dimensión, se necesitan 3" celdas en la rejilla,
de las que la mayor parte estarían vacías, para valores razonables de N.
La generalización de los árboles 2D a los árboles kD es también directa:
simplemente se pasa en «ciclos»a través de las dimensiones (como se hizo para
dos dimensiones alternando entre x e y) mientras se desciende por el árbol. AI
igual que antes, en una situación aleatoria, los árboles resultantes tienen las mis-
mas caractensticas que los árboles binarios de búsqueda. También como antes,
hay una correspondencia natural entre estos árboles y el simple proceso geo-
métrico. En tres dimensiones, la ramificación en cada nodo corresponde a cor-
tar con un plano la región tridimensional de interés; en el caso general, se corta
la región k-dimensional de interés con un hiperplano (k - 1)-dimensional.
Si k es muy grande, probablemente haya fuertes desequilibriosen los árboles
kD, una vez más porque los conjuntos naturales de puntos no pueden ser lo
suficientemente densos como para mostrar aleatoriedad sobre un gran número
de dimensiones. Por lo regular, todos los puntos de un subárbol tendrán los
mismos valores para varias dimensiones, lo que conduce a muchas ramas de
una sola vía. Una forma de limitar este problema es, en lugar de hacer ciclos
sistemáticamente a través de las dimensiones, utilizar siempre la dimensión que
mejor divida al conjunto de puntos. Esta técnica se puede aplicar también a los
árboles 2D. Esto necesita que se almacene información extra (relativa a la di-
mensión de discriminación) en cada nodo, pero remedia el desequilibrio, en es-
pecial en árboles de dimensiones muy grandes.
En resumen, aunque es fácil ver la forma de generalizar los programas para
BÚSQUEDA POR RANGO 421
la búsqueda por rango de forma que puedan tratar problemas multidimensio-
nales, no debe darse este paso a la ligera en aplicacionesmuy grandes. Las gran-
des bases de datos con muchos atributos por registro pueden ser objetos verda-
deramente complejos. A menudo es necesario tener una buena comprensión de
las características de una determinada base de datos para poder desarrollar un
método de búsqueda por rango que sea eficaz en una aplicación en particular.
Éste es un problema de bastante importancia que todavía se está estudiando
activamente.
Ejercicios
1. Escribir una versión no recursiva del programa rango de 1Ddado en el texto.
2. Escribir un programa para listar todos 10s puntos de un árbol binario que
no están en un intervalo dado.
3. Expresar el máximo y el mínimo número de celdas en las que podría bus-
carse en el método de la rejilla en función de las dimensiones de las celdas
y del rectángulo de búsqueda.
4. Analizar la idea de evitar la búsqueda en celdas vacías utilizando listas en-
lazadas: cada celda podría estar enlazada con la siguientecelda no vacía de
la misma fila y con la próxima celda no vacía de la misma columna. ¿De
qué forma afectaría esta técnica al tamaño de la celda a utilizar?
5. Dibujar el árbol y la subdivisión del plano resultante de construir un árbol
2D para el ejemplo de puntos comenzando por una línea de división ver-
tical.
4. Obtener un conjunto de puntos que conduzca al peor caso del árbol 2D que
no tiene nodos con dos hijos; obtener la correspondiente subdivisión del
plano.
7. Describir la forma de modificar cada uno de los métodos para que devuelva
todos los puntos del interior de un círculo dado.
8. De todos los rectángulos de búsqueda de igual área, ¿qué figura es la que
posiblemente haga que cada uno de los métodos se comporte peor?
9. ¿Qué método seríapreferiblepara la búsqueda por rango cuando los puntos
están agrupados en grandes conjuntos alejados entre sí?
10. Dibujar el árbol 3D que se obtiene al insertar los puntos (3,1,5), (4,8,3),
(8,3,9), (6,2,7), (1,6,3), (1,3,5), (6,4,2) en un árbol inicialmente vacío.
Algoritmos en C++.pdf
27
Intersección geométrica
Un problema natural que aparece con frecuencia en aplicaciones que implican
datos geométricoses el siguiente: «Dado un conjunto de N objetos,json disjun-
tos dos a dos?» Los «objetos» en cuestión pueden ser segmentos, rectángulos,
círculos,poiígonos, o cualquier otro tipo de objetos geométricos.Cuando se trata
de objetos físicos, se sabe que dos de ellos no pueden ocupar el mismo lugar al
mismo tiempo, pero es bastante complejo escribir un programa de computa-
dora que contemple este hecho. Por ejemplo, en un sistema para el diseño y rea-
lización de circuitos integrados o de tarjetas de circuitos impresos, es impor-
tante saber que dos cables no se cruzan para evitar un cortocircuito. En un
sistema industrial para el diseño de plantillas por una máquina de corte por
control numérico, es importante saber que no hay dos partes de la plantilla que
se solapen. En el diseño gráfico por computadora, el problema de determinar
qué partes de un conjunto de objetos están ocultas para un punto de vista par-
ticular, se puede formular como un problema de intersección geométrica de las
proyecciones de los objetos sobre el plano de visión. Incluso en ausencia de ob-
jetos físicos, existen muchos ejemplos en los que la formulación matemáticadel
problema conducea un problema de intersección geométrica. En el Capítulo 43
se encontrará un ejemplo particularmente importante de esto.
La solución evidente al problema de la intersección consiste en comprobar
si cada par de objetos se intersecan entre sí. Puesto que hay alrededor de N2/2
pares de objetos, el tiempo de ejecución de este algoritmo es proporcional a N2.
En algunas aplicaciones esto puede no ser un problema porque otros factores
limitan el número de objetos a procesar. Sin embargo, en la mayor parte de los
casos, es frecuente tener que considerar cientos de miles e incluso millones de
objetos y el algoritmo de fuerza bruta N2es evidentemente inadecuado. En esta
sección, se estudiará un método para determinar en un tiempo proporcional a
MogN si dos objetos de un conjunto de N de ellos intersecan; este método se
basa en los algoritmos presentados por M. Shamos y D. Hoey en un artículo
fundamentalde 1976.
En pnmer lugar se considerará un algoritmo para contar el número de in-
423
424 ALGORITMOS EN C++
terseccisnes de un conjunto de segmentos horizontales o verticales. Esto sim-
plifica el problema en un sentido (los segmentos horizontales y verticales son
objetos geométricos relativamente sencillos), pero lo complica en otro (contar
todos los pares que se intersecan es más difícil que determinar la existencia de
uno de ellos). Al igual que el capítulo anterior, se considerará el problema de
contar (en lugar de estar obteniendo todas las respuestas) sólo para que el có-
digo sea menos engorroso -la generalización del método para obtener todos
los pares que se intersecan es directa-. La implementación que se va a desarro-
llar combina los árboles de binarios de búsqueda y el programa de búsqueda
por rango del capítulo anterior, en un programa doblemente recursivo.
Posteriormente se examinará el problema de determinar si dos segmentosde
un conjunto de N de ellos se intersecan,sin restriccionesen los mismos. Se puede
aplicar la misma estrategiageneral que la utilizada para el caso horizontal-ver-
tical. De hecho, la misma idea básica es válida para la detección de interseccio-
nes entre muchos otros tipos de objetos geométricos. Sin embargo, para seg-
mentos y otros objetos, la generalizaciónpara determinar todos los pares que se
intersecan es algo más complicada que para el caso horizontal-vertical.
Segmentos horizontales y verticales
Para comenzar, se supondrá que todos los segmentos son horizontales o verti-
cales: los dos puntos que definen cada segmento tienen igual coordenada x o
igual coordenada y, como en los ejemplos de segmentos que se muestran en la
Figura 27.1. (A esta restricción se la denomina a veces geometría Manhattan
porque, al contrario de Broadway, el plano de las calles de Manhattan está for-
mado casi exclusivamentepor horizontales y verticales.)La restricción a los seg-
mentos horizontales y verticales es ciertamente estricta, pero no por ello el pro-
blema se convierte en un amodelo reducido». Al contrario, esta restracción se
impone a menudo en las aplicacionesparticulares: por ejemplo, los circuitos in-
tegrados a gran escala se diseñan normalmente con esta restricción. En la figura
de la derecha, los segmentos son relativamente cortos, como es normal en mu-
chas aplicaciones, aunque por lo regular pueden encontrarse unos pocos seg-
mentos muy largos.
El plan general del algoritmo para encontrar una intersección en tales con-
juntos de segmentos consiste en imaginar el recorrido de una línea horizontal
barriendo desde abajo hacia arriba. Las proyeccionessobre esta línea de bamdo
de los segmentos verticales son puntos y las de los segmentos horizontales son
intervalos: a medida que la línea de barrido progresa hacia amba, los puntos
(que representan a los segmentos verticales) aparecen y desaparecen, y los seg-
mentos horizontales aparecen periódicamente. Se encuentra una intersección
cuando aparece un segmento horizontal, representado por un intervalo de la lí-
nea de bamdo, que contiene a un punto que representa a un segmento vertical.
INTERCECCI~N
GEOMÉTRICA 425
t
-1,_
t, t
Figura 27.1 Problemasde intersección de dos segmentos(Manhattan).
Esta aparición significa que en este punto el segmento vertical interseca la línea
de barrido, y que el segmento horizontal pertenece a esta línea, por lo que los
dos segmentos,horizontal y vertical, deben cortarse. De esta forma, el problema
bidimensional de encontrar un par de segmentosque se corten se reduce al uni-
dimensional de búsqueda por rango del capítulo anterior.
Por supuesto, no es realmente necesario «barren>todo el camino a lo largo
del conjunto de segmentos; puesto que se necesita actuar sólo cuando se en-
cuentren los puntos extremos de los segmentos, se puede comenzar ordenando
los Segmentosde acuerdo con su coordenada y, y procesar los segmentosen ese
orden. Si se encuentra el punto del extremo inferior de un segmento vertical, se
añade la coordenada x de ese segmento al árbol binano de búsqueda (denomi-
nado aquí el árbol x); si se encuentra el extremo superior de un segmento ver-
tical, se suprime ese segmento (el x) del árbol; y si se encuentra un segmento
horizontal, se hace una búsqueda de rango en el intervalo definido por sus dos
coordenadas x. Como se verá, es preciso tener cierto cuidado al manejar coor-
denadas iguales en los puntos extremos de los segmentos (aunque el lector de-
bena estar acostumbrado a encontrar dificultades como éstas en los algoritmos
geométricos).
La Figura 27.2 muestra los primeros pasos del recorrido para encontrar las
intersecciones del ejemplo de la izquierda de la Figura 27. I. El recorrido co-
mienza en el punto con menor coordenada y, el extremo inferior de C. A con-
tinuación se encuentra E y luego D. El resto del proceso se muestra en la Figura
27.3; el próximo segmento que se encuentra es G, comprobándose si se inter-
seca con C, D y E (los segmentos verticales que se intersecaron con la línea de
barrido).
Para implementar el barrido, se necesita solamente ordenar los puntos ex-
426 ALGORITMOS EN C++
I . a
Figura 27.2 Búsquedade interseccionespor barrido: pasos iniciales.
tremos de los segmentos por sus coordenadas y. Para el ejemplo, se obtiene la
lista
C E D G I B F C H B A I E D H F
Cada segmento vertical aparece dos veces y cada segmento horizontal aparece
una sola. Por las necesidades del algoritmo de intersección, esta lista ordenada
se puede considerar como una serie de instrucciones de insertar(segmentosver-
ticales cuando se encuentre el extremo inferior), suprimir (cuando se encuentre
el extremo superior)y órdenes de rango (para los extremos de los segmentosho-
rizontales). Todas estas «órdenes» son simplemente llamadas a las rutinas es-
tándar de los árboles binarios de los Capítulos 14 y 26, utilizando las coorde-
nadas x como claves.
La Figura 27.4 muestra el proceso de construcción del árbol x durante el ba-
mdo. Cada nodo del árbol corresponde a un segmento vertical -la clave utili-
zada para el árbol es la coordenada x
-
. Puesto que E está a la derecha, se en-
cuentra en el subárbol derecho de C, etc. La primera línea de la Figura 27.4
corresponde a la Figura 27.2; el resto a la Figura 27.3.
AI encontrar un segmento horizontal, se utiliza para hacer una búsqueda por
rango en el árbol: todos los segmentosverticales en el rango asociado a este seg-
mento horizontal corresponden a intersecciones. En el ejemplo, se descubre la
intersección entre E y G; después se insertan I, B y F. Luego se suprime C, se
inserta H y se suprime B. Posteriormente se encuentra A, y se lleva a cabo la
búsqueda por rango del intervalo definido por A, lo que descubre las intersec-
ciones de A con D, E y H. A continuación se suprimen los extremos supenores
de I, E, D, H y F, quedando el árbol vacío.
Implementación
El primer paso en la implementación es ordenar los puntos extremos de los seg-
mentos por sus coordenadas y. Pero como se utilizan árboles binarios para
INTERSECCI~N
GEOMÉTRICA 427
I I s .
e e
Figura 27.3 Búsquedade interseccionespor barrido: final del proceso.
428 ALGORITMOS EN C++
Figura 27.4 Estructura de datos durante el barrido: construcción del arbol x.
mantener la situación de los segmentos verticales con respecto a la línea hori-
zontal de bamdo, jse pueden utilizar también para la ordenación inicial de los
y! Más concretamente, se utilizarán dos árboles binarios Xarbol e Yarbol de la
clase diccionario de árbol binario del Capítulo 14. El árbol y contendrá los ex-
tremos de los segmentos, que se procesarán uno a uno, en orden; el árbol x con-
tendrá los segmentos que intersecan la línea horizontal de bamdo.
El programa siguientelee primero grupos de cuatro números que definen los
segmentos a partir de la entrada estándar y construye después el y árbol inser-
tando las coordenadas y de los segmentos verticales y de los horizontales. Ahora
el barrido en sí es efectivamente un recorrido en orden del Yarbol:
D icc Xarbol (Nmax) , Y arbol (Nmax);
struct segmento segmentos[Nmax];
int cuenta = O;
INTERSECCI~N
GEOMÉTRICA 429
int intersecciones()
int xl, yl, x2, y2, N;
for (N = 1; cin >> xl >> y1 >>x2 >>y2; N++)
segmentos[N].pl.x = xl; segmentos[N].pl.y = yl;
segmentos[N].p2.x = x2; segmentos[N].p2.y = y2;
Yarbol .insertar(y1 , N) ;
if (y2 != yl) Yarbol .insertar(y2, N);
{
1
Yarbol .recorrer( ) ;
return cuenta;
Para el conjunto de segmentos del ejemplo, se construye el árbol que se
muestra en la Figura 27.5. El «ordenar según y» que necesita el algoritmo se
efectúa con recorrer,que (Capítulo 14) llama al procedimiento vi sitar para
cada uno de los nodos, en orden creciente de y. Todo el trabajo de encontrar
las intersecciones (utilizando un árbol binario diferente sobre las coordenadas
x) se realiza en el procedimiento visitar,que se especifica después.
.
d b
Figura 27.5' Ordenación para el barrido utilizando el árbol y.
A partir de la descripción del algoritmo, es fácil poner el código en el punto
donde se «visita»cada nodo:
void Dicc::visitar(tipoElemento v, tipoInfo info)
int t, xl, x2, yl, y2;
xl = segrnentos[info].pl.x; y1 = segmentos[info].pl.y;
x2 = segmentos[info] .p2.x; y2 = segmentos[info] .p2.y;
if (x2 < xi) { t = x2; x2 = xi; xi = t; }
if (y2 < yi) { t = y2; y2 = yl; y1 = t; }
{
430 ALGORITMOS EN C++
i f (v == y l )
i f (v == y2)
Xarbol .i n s e r t a r (xl , info) ;
Xarbol .suprimir(xl, i n f o ) ;
cuenta += Xarbol .rango(xl, x2) ;
{
1
1
En primer lugar, se extraen las coordenadas de los extremos del segmento co-
rrespondiente del array segmentos, indexado por el campo i nfo del nodo.
Luego se compara el campo cl ave del nodo con estas coordenadas para deter-
minar cuándo este nodo corresponde a una extremidad superior o inferior del
segmento: si es el extremo inferior, se inserta en el árbol x,y, si es el superior,
se suprime del árbol xy se lleva a cabo una búsqueda por rango. La implemen-
tación anterior difiere ligeramente de esta descripción en que los segmentosho-
rizontales se insertan realmente en el árbol x,y se suprimen luego inmediata-
mente, y se efectúa, para los segmentos verticales, una búsqueda por rango en
un intervalo reducido a un punto. Esto hace que el código trate adecuadamente
el caso de segmentos verticales que se solapan, que se consideran que se van a
((intersecam.
Esta aproximación de la aplicación combinada de procedimientos recursi-
vos que opera sobre las coordenadas x e y es muy importante en los algoritmos
geométncos. Otro ejemplo de ella es el algoritmo de árbol 2D del capítulo an-
terior, y además se verá otro en el próximo capítulo.
Propiedad 27.1 Todas las intersecciones entre N segmentos horizontales y ver-
ticales se pueden encontrar en un tiempo proporcional a MogN+I, siendo I el
número de intersecciones.
Las operaciones de manipulación de árbol tardan un tiempo proporcional a
1 0 0 , por término medio (si se utilizan árbolesequilibrados, podría garantizarse
un peor caso en logN), pero el tiempo que se emplea en la búsqueda por rango
también depende del número total de intersecciones. En general, este número
puede ser muy grande. Por ejemplo, si se tienen N/2 segmentos horizontales y
N/2 segmentos verticales distribuidos en un modelo entrecruzado, entonces el
número de interseccioneses proporcional a N 2 . ~
Como en la búsqueda por rango, si se conoce por adelantado que el número
de intersecciones es muy grande, debería utilizarse alguna variante de fuerza
bruta. Por lo regular, las aplicacionespresentan situacionesdel tipo «buscar una
aguja en un pajam, donde se dete examinar un gran conjunto de segmentospara
no encontrar más que unas pocas intersecciones.
INTERSECCI~N
GEOMÉTRICA 431
Figura 27.6 Problemasde intersección de dos segmentoscualesquiera.
Intersección de segmentos en general
Cuando se permiten segmentos de inclinación arbitraria, la situación se vuelve
más complicada, como se ilustra en la Figura 27.6. Primero, las distintas orien-
taciones de los segmentos posibles hacen necesario preguntar explícitamente
cuándo se intersecan ciertos pares de segmentos, lo que no se puede resolver
con una simple verificación de búsqueda por rango. Segundo,la relación de or-
den entre segmentospara el árbol binario es más complicada que antes, puesto
que depende del intervalo actual de y. Tercero, cualquier intersección que se
produzca añadirá nuevos valores «interesantes» de y, que posiblemente serán
diferentes del conjunto de valores de y correspondientes a los extremos de los
segmentos.
Resulta que estos problemas se pueden manejar en un algoritmo con la
misma estructura básica que la dada anteriormente. Para simplificarla presen-
tación, se considerará un algoritmo para detectar cuándo existe o no un par de
segmentos que se intersecan en un conjunto de N segmentos,y posteriormente
se presentará cómo generalizarlopara detectar todas las intersecciones.
Como antes, primero se ordena sobre y para dividir el espacio en franjas
dentro de las que no aparece ningún punto extremo de segmento.Exactamente
como antes, se procede a lo largo de la lista ordenada de puntos, añadiendo cada
segmento a un árbol binorio de búsqueda cuando se encuentre su extremo in-
ferior y suprimiéndolo cuando se encuentre su extremo superior.También como
antes, el árbol binario da el orden en el que aparecen los segmentosen la «franja»
horizontal entre dos valores y consecutivos. Por ejemplo, en la franja entre el
extremo inferior de D y el superior de B de la Figura 27.6, los segmentosdeben
aparecer en el orden F B D H G. Se supone que no hay interseccionesdentro
432 ALGORITMOS EN C++
Figura 27.7 Estructura de datos (árbol x) para el problemageneral.
de la franja horizontal actual: el objetivo es mantener esta estructura de árbol y
utilizarla para encontrar la primera intersección.
Para construir el árbol, no se puede utilizar solamente como claves a las
coordenadas x de los extremos de los segmentos (por ejemplo, si se hace esto en
el ejemplo anterior se invertiría el orden real de B y D). En su lugar se utilizará
una relación de orden más general: se dice que un segmento xestá a la derecha
de un segmento y si los dos extremos de x están del mismo lado de y que un
punto del infinito situado a la derecha, o bien si y está a la izquierda de x,de-
finiendo «izquierda» de forma similar. Así, en el diagrama anterior, B está a la
derecha de A y B está a la derecha de C (puesto que C está a la izquierda de B).
Si x no está ni a la derecha ni a la izquierda de y, entonces los dos segmentos
deben intersectarse. Esta operación generalizada de (comparación de segmen-
tos» se puede implementar utilizando el procedimiento ccw del Capítulo 24.
Excepto al utilizar esta función siempre que se necesite una comparacióii, se
pueden utilizar sin modificación los procedimientos estándar de árbol binario
de búsqueda (incluso si se desea árboles equilibrados). La Figura 27.7 muestra
la evolución del árbol del ejemplo entre el momento en el que se encontró el
segmento C y en el que se encontró el D. Cada «comparación» que se lleva a
cabo durante los procedimientos de manipulación de árboles es realmente una
comprobación de intersección de segmentos: si el procedimiento de búsqueda
en árbol binario no puede decidir si hay que ir a la derecha o a la izquierda, los
dos segmentos en curso se deben intersecar, y ya está todo hecho.
Pero ésta no es la historia completa, porque esta operación de comparación
generalizada no es transitiva. En el ejemplo anterior, F está a la izquierda de B
(porque B está a la derecha de F) y B está a la izquierda de D, pero F no está a
la izquierda de D. Es esencial destacar este problema, porque el procedimiento
de suprimir en el árbol binario supone que la operación de comparación es
transitiva: cuando se suprime B del último árbol de la serie anterior, se obtiene
el árbol de la Figura 27.7 sin ninguna comparación explícita de F y D. Para que
el algoritmo de comprobación de intersección sea correcto, se debe verificar ex-
INTERSECCI~N
GEOMETRICA 433
plícitamente que las comgaraciones son válidas cada vez que se cambia la es-
tructura del árbol. En concreto, cada vez que se hace que el enlace izquierdo del
nodo x apunte al nodo y, se comprueba explícitamente si el segmento corres-
pondiente a x está a la izquierda del segmento correspondiente a y, de acuerdo
con la definición anterior, y lo mismo para la derecha. Por supuesto, esta com-
paración podría entrañar la detección de una intersección, como es el caso del
ejemplo.
En resumen, para detectar una intersección en un conjunto de N segmentos,
se utiliza el programa anterior, pero se suprime la llamada a rango y se gene-
ralizan las rutinas de árbol binario para que permitan comparaciones generali-
zadas como las descritasanteriormente. Si no hay intersección, se comienza con
un árbol vacío y se finaliza con el mismo árbol vacío sin encontrar segmentos
no comparables. Si hay una intersección, entonces se deben comparar entre sí
los dos segmentos que se intersecan en algún momento del proceso de barrido
y se descubrirá la intersección.
Sin embargo, una vez que se ha encontrado una intersección no se debe sim-
plemente continuar y esperara encontrar al resto, porque los dos segmentos que
se intersecan deben intercambiar su lugar en el orden, inmediatamente después
del punto de intersección. Una forma de realizar esta operación sería utilizar
una cola de prioridad en lugar de un árbol binario para la ordenación de y: ini-
cialmente se ponen los segmentos en la cola de prioridad de acuerdo con las
coordenadas y de sus extremos, y luego se efectúa un barrido ascendente to-
mando sucesivamente la coordenada y más pequeña de la cola de prioridad y
haciendo una inserción o supresión en el árbol binario, como se hizo antes.
Cuando se encuentra una intersección, se añaden ncevas entradas en la cola de
prioridad, una por cada segmento, utilizando para cada una el punto de inter-
sección como extremo inferior.
Otra forma de encontrar todas las intersecciones,que es apropiada si no se
espera que haya muchas: es simplementeeliminar uno de los segmentos cuando
se detecta una intersección. Una vez efectuado el barrido, se sabe que todos los
pares que se intersequen deben englobar a uno de esos segmentos, y se puede
utilizar un método de fuerza bruta para enumerar todas las intersecciones.
Propiedad 27.2 Todas las intersecciones entre N segmentos se pueden encon-
trar en un tiempo proporcional a (iV+~logAr,
donde I es el ntímero de intersec-
ciones.
Esto es consecuencia directa de la descripción anteri0r.i
Una característica interesante del procedimiento anterior es que se puede
adaptar, cambiando el procedimiento general de comparación, para detectar la
existencia de un par de interseccionesen un conjunto de figuras geométricas más
generales.Por ejemplo, si se implementa un procedimiento para comparar dos
rectánguloscuyos lados son paralelos horizontal y verticalmente,de acuerdo con
la iegla trivial de que un rectángulo x está a la izquierda de un rectángulo y si
434 ALGORITMOS EN C++
el lado derechode x está a la izquierda del lado izquierdode y, entoncesse puede
utilizar el método anterior para detectar las intersecciones en un conjunto de
rectángulosde este tipo, Para círculos, se puede utilizar las coordenadas x de los
centros para la ordenación y efectuar explícitamente comprobaciones de inter-
sección (por ejemplo, comparar la distancia entre los centros con la suma de los
radios). Una vez más, si esta comparación se utiliza en el método anterior, se
tiene un algoritmo de comprobación de intersecciones en un conjunto de cír-
culos. El problema de detectar todas las intersecciones en estos casos es mucho
más complicado, aunque el método de fuerza bruta ya mencionado en el pá-
rrafo anterior es válido siempre que se esperen pocas intersecciones.Otra apro-
ximación que es suficiente en muchas aplicaciones consiste en considerar los
objetos complejos como conjuntos de segmentos y utilizar el procedimiento de
intersección de segmentos.
Ejercicios
1. jC6mo se determinaría si se intersecan dos triángulos? ¿Y dos cuadrados?
¿Y dos polígonos regulares de n lados, con y1 > 4?
2. En el algoritmo de intersección de segmentos horizontales y verticales,
jcuántos pares de segmentos se deben comprobar en un conjunto de seg-
mentos sin intersección, en el peor caso? Mostrar un diagrama que apoye
la respuesta.
3. ¿Qué sucede cuando se utiliza el procedimiento de intersección de segmen-
tos horizontales y verticales en un conjunto de segmentoscon inclinaciones
arbitrarias?
4. Escribir un programa para encontrar el número de pares de intersecciones
en un conjunto de N segmentos aleatorios horizontales y verticales, si cada
segmento se genera por dos coordenadas enteras aleatonas entre O y 1.O00
y un bit aleatorio para distinguir si es vertical u horizontal.
5. Dar un mCtodo para comprobar si un polígono es simple (no se interseca
consigo mismo).
6. Dar un método para averiguar si un polígono está contenido totalmente
dentro de otro.
7. Describir cómo se resolvería el problema general de intersección de seg-
mentos dado el hecho adicional de que la separación mínima entre dos seg-
mentos es mayor que la longitud máxima de los segmentos.
8. Obtener las estructuras de árbol binario que existen cuando el algoritmo de
intersección de segmentosdetecta la intersección en los segmentosde la Fi-
gura 27.6, si se ha hecho una rotación de 90 grados.
9. ¿Son transitivos los procedimientos de comparación de círculos y rectán-
gulos Manhattan descritos en el texto?
10. Escribir un programa para encontrar el número de pares que se intersecan
en un conjunto de N segmentos aleatorios, si cada segmento está generado
por coordenadas enteras aleatonas entre O y 1.OOO.
28
Problemas del punto
más cercano
Por lo regular, en los problemas geométricos relativos a puntos del plano inter-
viene el cálculo implícito o explícito de las distancias entre los puntos. Por
ejemplo, un problema muy natural que se presenta en muchas aplicaciones es
el del vecino más próximo: encontrar, entre los puntos de un conjunto dado, el
más cercano a un nuevo punto también dado. Parece necesario comparar las
distancias entre el punto dado y cada punto del conjunto, pero existen solucio-
nes mucho mejores. En esta sección se verán otros problemas de distancia, un
prototipo de algoritmo y una estructura geométnca fundamental denominada
diagrama de Voronoi, que se puede utilizar con efectividad en una gran vane-
dad de problemas de este tipo en el plano. Se hará una aproximación que con-
sistirá en describir un método general de resolución de problemasdel punto más
cercano por medio de un análisis cuidadoso del prototipo de implementación
de un problema sencillo.
Algunos de los problemas que se considerarán en este capítulo son similares
a los de búsqueda por rango del Capítulo 26, y los métodos de la rejilla y de los
árboles 2D ya estudiados son también adecuados para resolver el problema del
vecino más próximo o de otros varios. Sin embargo, el defecto fundamental de
tales métodos es que se apoyan en la aleatonedad del conjunto de puntos: tie-
nen un mal rendimiento en el peor caso. El objetivo de este capítulo es exami-
nar otra aproximación generalque garantice un buen rendimiento para muchos
problemas, sin importar cuál sea la entrada. Algunos de los métodos son de-
masiado complicados para que se pueda examinar aquí la implementación
completa, e implican un sobrecoste tan grande que incluso los métodos más
simples pueden ser más eficacescuando el conjunto de puntos no es muy grande
o está bastante bien distribuido. Sin embargo, el estudio de los métodos que
presentan un buen rendimiento en el peor caso revelará algunas de las propie-
dades fundamentales de los conjuntos de puntos que deben ser comprendidas,
435
436 ALGORITMOS EN C++
incluso aunque los métodos más simplesparezcan más convenientes en algunas
situaciones específicas.
La aproximación general que se examinará proporciona otro ejemplo de la
utilización de procedimientos doblemente recursivos para entrelazar el proce-
samiento entre las dos direcciones de coordenadas.Los dos métodos de este tipo
que se han visto anteriormente (árboles kD e intersección de segmentos)se fun-
dan en árboles binarios de búsqueda; aquí el método es del tipo ((combina y
vencerás»basado en la ordenación por fusión.
Problema del par mis cercano
El problema del par más cercana consiste en encontrar los dos puntos más cer-
canos entre sí de un conjunto de puntos dado. Este problema está relacionado
con el del vecino más próximo; aunque no sea de aplicación general, servirá
como prototipo de los problemas del punto más cercano en el sentido de que se
puede resolver con un algoritmo cuya estructura recursiva general se adapte a
otros problemas de este tipo.
Para encontrar la distancia mínima entre dos puntos, parecería necesario
examinar las distancias entre todos los pares de puntos: para N puntos esto po-
dría significar un tiempo de ejecución proporcional a N2. Sin embargo, es po-
sible utilizar una ordenación para examinar sólo alrededor de MogN distancias
entre puntos en el peor caso (algunos menos por término medio) y obtener un
peor caso proporcional a MogN (mucho menos en el caso medio). En esta sec-
ción se examinará con detalle un algoritmo como éste.
El algoritmo que se utilizará está basado en una estrategiadirecta de ((divide
y vencerás>>.
La idea es ordenar los puntos según una coordenada, por ejemplo
la x,y utilizar después esa ordenación para dividir los puntos en dos mitades.
El par más cercano del conjunto completo es o bien el de una de las dos mitades
o el formado por un elemento de cada mitad. El caso interesante, por supuesto,
es cuando el par más cercano cruza la línea divisoria: el par más cercano de cada
mitad se puede encontrar fácilmenteutilizando llamadas recursivas, pero ¿cómo
se pueden comprobar eficazmente todos los pares cuyos elementos que están
uno a cada lado de la línea divisoria?
Puesto que la única información que se bcsca es el par más cercano al con-
junto de puntos, solamente se necesita examinar los puntos que están dentro de
la distancia m in de la línea divisoria, siendo m i n la menor de las distancias entre
los pares más cercanos encontrados en las dos mitades. Sin embargo, esta ob-
servaciónno es por sí misma ayuda suficienteen el peor caso, puesto que puede
haber muchos pares de puntos muy cercanos a la línea divisoria; por ejemplo,
todos los puntos de cada mitad podrían estar situados en la proximidad de la
línea divisoria.
Para tratar tales situaciones, parece necesario ordenar los puntos según y.
Después se puede limitar el número de cálculos de distancias que implican a
PROBLEMASDEL PUNTOMÁS CERCANO 437
* O
* A
* G
8
N
* L
* M
8 8 . 8 8
Figura 28.1 Aproximación de divide y vencerás para encontrar el par mas cercano.
cada punto de la siguiente forma: recomendo los puntos en orden creciente de
y, se comprueba si cada uno está dentro de la franja vertical que contiene a to-
dos los puntos del plano situados a menos de la distancia m i n de la línea divi-
soria. Para cada punto que pase la comprobación, se calcula la distancia entre
él y otro cualquiera situado también dentro de la franja cuya coordenada y sea
menor que la y del punto en cuestión, pero en una cantidad que no sea mayor
que mi n. El hecho de que la distancia entre todos los pares de puntos de cada
mitad sea inferior a min significa que posiblemente se comprueben sólo unos
pocos puntos.
En el pequeño conjunto de piintos de la izquierda de la Figura 28.1, la línea
vertical divisoria imaginaria inmediatamente a la derecha de F tiene ocho pun-
tos a la izquierda y ocho a la derecha. El par más cercano de la mitad izquierda
es AC (o AO) y el de la derecha es JM. Si se ordenan los puntos según y, enton-
ces el par más cercano dividido por la línea se encuentra comparando los pares
HI, CI, FK (el par más cercano del conjunto completo de puntos), y finalmente
EK. Para conjuntos de puntos más grandes. la banda que podría contener un
par más cercano sobre la línea divisoria es más estrecha, como se muestra a la
derecha de la Figura 28.1.
Aunque este algoritmo es siinple, se debe tener cierto widado para imple-
mentarlo eficazmente:por ejemplo, sena muy costoso ordenar los puntos según
y en el interior de la rutina recursiva. Se han visto varios algoritmos con tiem-
pos de ejecución descritos por la recurrencia C,v= 2CN,2 + N, lo que implica
que CNes proporcional a MogN; si se hubiera hecho la ordenación completa
sobre y, entonces la recurrencia devendría C, = 2C,,r,2+ McgN, lo que implica
que C,Ves proporcional a hlog'N (ver el Capítulo 6). Para eludir esto se necesita
evitar la ordenación según y.
La solución a este problema es simple, pero sutil. El método ordenfusion
438 ALGORITMOS EN C++
del Capítulo 12se basa en dividir los elementos a ordenar exactamente como se
dividieron los puntos anteriormente. Hay dos problemas a resolver y el mismo
método general de resolución, jasí que se pueden resolver al mismo tiempo! Más
concretamente, se escribirá una rutina recursiva que ordene según y y encuentre
el par más cercano. Esto lo hará dividiendo al conjunto de puntos por la mitad,
llamándose a sí misma recursivamente para ordenar las dos mitades según y y
encontrar el par más cercano de cada mitad, fusionando después para comple-
tar la ordenación sobre y y aplicando el procedimiento anterior para completar
el cálculo del par más cercano. De esta forma, se evita el coste de hacer una
ordenación extra de y al entremezclar los movimientos de datos que se requie-
ren para la ordenación con los que se requieren para el cálculo del par más cer-
cano.
Para la ordenación y, la división en dos mitades se podría hacer de cualquier
manera, pero, para el cálculo del par más cercano, se necesita que los puntos de
una mitad tengan todos coordenadas x más pequeñas que los puntos de la otra.
Esto se lleva a cabo fácilmente ordenando según x antes de hacer la división.
De hecho, jse puede utilizar la misma rutina para ordenar según x! Una vez que
se acepta este plan general, la implementación no es dificil de entender.
Como se mencionó anteriormente, la implementación utilizará los proce-
dimientos recursivos ordenar y fusion del Capítulo 12. El primer paso con-
siste en modificar las estructuras de lista para que contengan puntos en lugar de
claves, y modificar fusion de forma que compruebe una variable global pa-
sada para decidir qué tipo de comparación hacer. Si pasada vale l se debenan
comparar las coordenadas x de los dos puntos; si pasada vale 2 se comparan
las coordenadas y. La implementación es directa:
i n t comp(struct nodo * t )
s t r u c t nodo *fusion(struct nodo *a, s t r u c t nodo *b)
{return (pasada == 1) ? t-}p.x : t->p.y; }
s t r u c t nodo *c;
c = z;
do
{
i f (comp(a) < comp(b))
e l se
{ c->siguiente = a; c = a; a = a->s
{ c->siguiente = b; c = b; b = b->s
while (c != z);
c = z->siguiente; z->siguiente = z;
r e t u r n c;
1
guiente; }
guiente; }
El nodo ficticio z que aparece al final de cada lista se inicializa para contener
un punto «centinela»con coordenadas x y y artificialmente grandes.
PROBLEMASDEL PUNTO MÁS CERCANO 439
Para evaluar las distancias, se utiliza otro procedimiento simple que verifica
si la distancia entre los dos puntos pasados como argumentos es inferior a la
variable global mi n. Si lo es, se asigna esta distancia a m i n y se guardan los pun-
tos en las variables globales p c l y pc2:
comprobar(struct punto p l y s t r u c t punto p2)
i
f l o a t d i s t ;
i f ((p1.y != z->p.y) && (p2.y != z->p.y))
d i s t = sqrt((pl.x-p2.x)*(pl.x-p2.x) +
i f ( d i s t < min)
{
( P i .Y-P2. Y) *( P i . Y-P2 * Y ) ) ;
{ min = d i s t ; p c l = p l ; pc2 = p2; };
1
1
Así, la variable global m i n contiene siempre la distancia entre p c l y pc2, el par
más cercano encontrado hasta ahora.
El siguientepaso es modificar la función recursiva ordenar del Capítulo 12
para hacer también el cálculo del punto más cercano cuando pasada es 2, de la
siguiente forma:
s t r u c t nodo *ordenar(struct nodo *cy i n t N)
i n t i;
s t r u c t nodo *a, *b;
f l o a t medio;
s t r u c t punto p l , p2, p3, p4;
i f (c->siguiente == z) r e t u r n c;
a = c;
f o r (i= 2; i <= N/2; i++)
c = c->siguiente;
b = c->siguiente; c->siguiente = z;
i f (pasada == 2) medio = b->p.x;
c = fusion(ordenar(a, N/2) , ordenar(b, N-(Nl2)));
i f (pasada == 2)
{
p l = z->p; p2 = z->p; p3 = z->p; p4 = z->p;
for (a = c; a != z; a = a->siguiente)
{
i f (fabs(a->p.x - medio) < min)
comprobar( a-->p, p i ) ;
{
440 ALGORITMOS EN C++
comprobar(a->p, p2);
comprobar (a->p, p3) ;
comprobar (a->p, p4) ;
p l = p2; p2 = p3; p3 = p4; p4 = a->p;
1
1
r e t u r n c;
Si pasada vale 1, ésta es exactamente la rutina recursiva de ordenación por fu-
sión del Capítulo 12: devuelve una lista enlazada que contiene los puntos or-
denados por sus coordenadas x (porque fusion ha sido modificado como se
describióantes para comparar las coordenadas x en la primera pasada). La ma-
gia de esta implementación se manifiesta cuando pasada vale '2. El programa
no sólo ordena según y (porque fusion ha sido modificado como se describió
anteriormente para comparar las coordenadas y en la segunda pasada), sino que
también efectúa el cálculo del punto más cercano. Antes de las llamadas recur-
sivas, los puntos están ordenados según x:esta ordenación se utiliza para dividir
los puntos en dos mitades y encontrar la coordenada x de la línea divisoria.
Después de las llamadas recursivasse ordenan los puntos según y, y se sabe que
la distancia entre todo par de puntos de cada mitad es mayor que ms' n. La or-
denación sobrey se utiliza para recorrer los puntos cercanos a la línea divisoria;
el valor de m i n se utiliza para limitar el número de puntos que se deben com-
probar. Cada punto situado a menos de una distancia min de la línea divisoria
se compara con cada uno de los cuatro puntos encontrados previamente dentro
de una distancia min de la línea divisoria. Esta comparación garantiza encon-
trar todos los pares de puntos que están a una distancia inferior a m i n a cada
lado de la línea divisoria.
¿Por qué se comparan los cuatro últimos puntos y no los dos, tres o cinco?
Esto es una particularidad geometrica sorprendente que quizás el lector desee
comprobar: se sabe que los puntos situados en un mismo lado de la línea divi-
soria están separados por al menos m i n, por lo que el número de puntos inclui-
dos en cualquier círculo de radio m i n es limitado. Se podrían comprobar más
de cuatro puntos, pero no es difícil convencerse de que cuatro es suficiente.
El código siguiente llama a ordenar dos veces para efectuar el cálculo del
par más cercano. Primero, se ordena según x (con pasada igual a 1); después se
ordena según y, y se encuentra el par más cercano (con pasada igual a 2):
z = new nodo;
z->p.x = max; z->p.y = max; z->siguiente = z;
h = new nodo; h->siguiente = l e e r l i s t a ( ) ;
min = max;
pasada = i; h->siguiente = ordenar(h->siguiente, N);
pasada = 2; h->siguiente = ordenar(h->siguiente, N);
PROBLEMAS DEL PUNTO MÁS CERCANO 441
Figura 28.2 Árbol de llamadas recursivas parael caiculo del par más cercano.
Después de estas llamadas, el par de puntos más cercanos se encuentra en las
variablesglobales pcl y pc2, que controla el procedimiento comprobar «de en-
contrar el mínimo».
La Figura 28.2 muestra el árbol de llamadas recursivas que describe el fun-
cionamiento de este algoritmo en el pequeño ejemplo del conjunto de puntos.
Un nodo interno de este árbol representa una línea vertical que divide a los
puntos del subárbol izquierdo y del derecho. Los nodos estár, numerados en el
orden en el que se examinan las líneas verticaiesen el algoritmo. Esta numera-
ción corresponde a un recorrido en orden posterior del árbol porque el cálculo
que implica a la línea divisoria tiene lugar después de las llamadas recursivas,y
es simplemente otra forma de ver el orden en el que se hace la fusión durante
una ordenación por fusión recursiva (ver el Capítulo 12).
De este modo, primero se trata la línea entre G y O y se retiene el par GO
como el más cercano, por ahora. Luego se trata la línea entre A y D, pero A y
D están demasiado alejados como para modificar el valor de mi n. A continua-
ción se trata la línea entre O y A y GD; GA y OA son los pares más cercanos
sucesivos. En este ejemplo se comprueba que no se encuentran pares más cer-
canos hasta FK, que es el último par comprobado en la última línea divisoria
tratada.
El lector que siga cuidadosamente el desarrollo puede notar que no se ha
implementado el algoritmo puro de divide y vencerás descrito con anterioridad
-en realidad no se calcula el par más cercano de cada una de las dos mitades,
tomando luego el mejor de los dos-. En lugar de esto, se obtiene el más cer-
cano de los dos pares más próximos utilizando simplemente la variable global
mi n durante el cálculo recursivo. Cada vez que se encuentra un par más cer-
cano, se considera de hecho una franja vertical más estrecha alrededor de la lí-
nea divisoria actual, sin tener en cuenta en qué punto se encuentra el cálculo
recursivo.
La Figura 28.3 muestra este proceso detalladamente.La coordenadax de es-
tos diagramas se ha ampliado para resaltar la orientación x dei proceso y poner
en evidencia el paralelismo con la ordenación por fusión (ver Capítulo 12). Se
comienza haciendo una ordenación y sobre los cuatro puntos más a la iz-
quierda, G O A D, ordenando G O, luego A D y fusionando después los resul-
442 ALGORITMOS EN C++
O 0
0 0 0 0 0 o o
0
O 0 0 00
Figura 28.3 Calculo del par mas cercano (coordenadax ampliada).
PROBLEMAS DEL PUNTO MÁC CERCANO 443
tados. Después de la fusión, se completa la ordenación y encontrándose el par
más cercano AO. A continuación se hace lo mismo con E C H F, etcétera.
Propiedad 28.1 El par más cercano de un conjunto de N puntos se puede en-
contrar con un número de pasos en O(MogN).
Esencialmente, el cálculo se realiza en el tiempo de hacer dos ordenaciones por
fusión (una sobre las coordenadas x, y otra sobre las y ) más el coste del reco-
mdo a lo largo de la línea divisoria. Este coste está también gobernado por la
recurrencia TN= 2TNi2+ N.i
La estrategiageneral que se ha utilizado aquí para el problema del par más
cercano puede servir para resolver otros problemas geométncos. Por ejemplo,
otro caso de interés es el de todos los vecinos máspróximos: para cada punto se
desea encontrar el punto más próximo a él. Este problema se puede resolver uti-
lizando un programa como el anterior y añadiendo un procesamiento extra a lo
largo de la línea divisoria para determinar, para cada punto, si existe un punto
homólogo situado en la otra mitad, más cercano que el más cercano de los de
su propia mitad. De nuevo la dibre» ordenación en y es útil para este cálculo.
Diagramas de Voronoi
El conjunto de todos los puntos más cercanos a un punto dado que todos los
otros puntos en un conjunto de puntos es una interesante estructura geométrica
denominada pollgono de Voronoidel punto. La unión de todos los polígonosde
Voronoi de un conjunto de puntos se denomina diagrama de Voronoi.Esto es
lo máximo en el cálculo del punto más cercano: se verá que la mayoría de los
problemas tratados que implican distancias entre puntos admiten soluciones
naturalese interesantesbasadas en los diagramas de Voronoi. Los diagramaspara
el ejemplo del conjunto de puntos se muestran en la Figura 28.4.
El polígono de Voronoi de un punto está formado por las mediatnces de los
segmentos que enlazan al punto con los que le son más cercanos. Su definición
real se hace de otra forma: el polígono de Voronoi se define como el perímetro
del conjunto de todos los puntos del plano más cercanos al punto dado que a
cualquier otro punto del conjunto de puntos, y cada lado del polígono de Vo-
ronoi separa al punto en cuestión de cada uno de los puntos más acercanos a
él».
El dual del diagrama de Voronoi, que se muestra en la Figura 28.5, hace
explícita esta correspondencia: en el dual, se dibuja un segmento entre cada
punto y todos los puntos «cercanos» a él. Esta estructura se denomina también
trianguIación de Delaunay. Los puntos x y y se enlazan en el dual de Voronoi
solamente si sus polígonos de Voronoi tienen un lado en común.
El diagrama de Voronoi y la tnangulación de Delaunay tienen muchas pro-
444 ALGORITMOS EN C++
Figura 28.4 Diagramade Voronoi.
piedades que conducen a algontmos eficaces para los problemas del punto más
cercano. La propiedad que hace eficaces a estos algoritmos es que el número de
segmentos de ambos diagramas es proporcional a una pequeña constante mul-
tiplicada por N. Por ejemplo, el segmento que conecta los pares de puntos más
cercanos debe estar en el dual, lo que significa que se puede resolver el pro-
blema de la sección anterior calculando el dual y encontrando simplemente la
longitud mínima entre los segmentos del mismo. De forma similar, el segmento
que conecta cada punto con su vecino más cercano debe estar en el dual, Io que
significa que el problema de todos los vecinos próximos se reduce directamente
a encontrar el dual. El cerco convexo del conjunto de puntos es parte del dual,
Figura 28.5 Triangulaciónde Delaunay.
PROBLEMAS DEL PUNTO MÁS CERCANO 445
por lo que el cálculo del dual de Voronoi lleva a otro algoritmo de cerco con-
vexo más. En el Capítulo 3 1 se verá otro ejemplo de un problema que se puede
resolver eficazmente encontrando primero el dual de Voronoi.
La propiedad que define al diagrama de Voronoi significa que se puede uti-
lizar para resolver el problema del vecino más próximo: para identificar, en un
conjunto de puntos, el vecino más próximo de un punto dado, sólo se necesita
encontrar en qué polígono de Voronoi se encuentra el punto. Es posible orga-
nizar los polígonos de Voronoi en una estructura como un árbol 2D para per-
mitir que esta búsqueda se haga eficazmente.
El diagrama de Voronoi se puede calcular utilizando un algoritmo con la
misma estructura general que el algoritmo anterior del punto más cercano. Pri-
mero se ordenan los puntos según su coordenada x. Después se utiliza la orde-
nación para dividir a los puntos en dos mitades, dejando dos llamadas recursi-
vas para encontrar el diagrama de Voronoi del conjunto de puntos de cada una
de las dos mitades. Al mismo tiempo, se ordenan los puntos según y; final-
mente: los diagramas de Voronoi de las dos mitades se fusionan entre sí. Como
antes, esta fusión (efectuada cuando pasada vale 2) puede explotar el hecho de
que los puntos se ordenan según x antes de las llamadas recursivas y que, des-
pués de ellas, se ordenan según y y se construyen los diagramas de Voronoi de
las dos mitades. Sin embargo, aun con estas ayudas, la fusión es una tarea bas-
tante complicada y la presentación de una implementación completa rebasa el
zkance de este libro.
El diagrama de Voronoi es la estrrictura natural de los problemas del punto
más cercano, y la comprensión de las características de un problema en térmi-
nos del diagrama de Voronoi o de su dual es sin duda un ejercicioque merece
la pena. Sin embargo, para muchos problemas particulares, puede ser conve-
niente una implementación directa basada en el esquema general dado en este
capítulo. Este esquema es lo suficientemente potente para poder calcular el dia-
grama de Voronoi, por lo que es lo suficientemente potente para algoritmos ba-
sados en el diagrama de Voronoi, y puede conducir a programas más simplesy
eficaces, como se vio para el caso del problema del par más cercano.
Ejercicios
1. Escribir programas para resolver el problema del vecino más próximo, uti-
lizando primero el método de la rejilla y posteriormente árboles 2D.
2. Describir lo que sucede cuando el procedimiento del par más cercano se
utiliza en un conjunto de puntos alineados sobre la misma línea horizontal
e igualmente espaciados.
3. Describir lo que sucede cuando el procedimiento del par más cercano se
utiliza en un conjunto de puntos alineados sobre la misma línea vertical e
igualmente espaciados.
4. Dado un conjunto de 2N puntos, la mitad con coordenadas positivas de x
446 ALGORITMOS EN C++
y la otra mitad con coordenadas negativas de x, obtener un algoritmo que
encuentre el par más cercano constituido por un elemento del mismo en
cada mitad.
5. Obtener los pares sucesivosde puntos asignadosa p c l y pc2 cuando el pro-
grama del texto se aplica a los puntos del ejemplo, del que se ha suprimido
A.
6. Comprobar la eficacia de atribuir a min el carácter global comparando el
rendimiento de la implementación dada con una implementación pura-
mente recursiva en algún gran conjunto de puntos aleatonos.
7. Obtener un algoritmo para encontrar el par más cercano de un conjunto de
segmentos.
8. Dibujar el diagrama de Voronoi y su dual para los puntos A B C D E F del
conjunto de puntos del ejemplo.
9. Obtener un método de «fuerzabruta) (que pueda necesitar un tiempo pro-
porcional a N2)para construir el diagrama de Voronoi.
10. Escribir un programa que utilice la misma estructura recursiva que la im-
plementación del par más cercano dada en el texto para encontrar la super-
ficie convexa de un conjunto de puntos.
PROBLEMASDEL PUNTO MÁS CERCANO 447
REFERENCIAS para los Algoritmos geométricos
En realidad, gran parte del material descrito en esta sección se ha desarrollado
hace poco tiempo. Muchos de los problemas y solucionesque se han presentado
fueron introducidos por M. Shamos en 1975.La tesis de Ph.D. de Shamos trata
un gran número de algoritmos geométricos,que han estimulado muchas de las
investigacionesrecientes y que finalmente fueron desarrollados en la referencia
más autorizada en este campo, el libro de Preparata y Shamos. Esta materia está
en rápida expansión: el libro de Edelsbrunner describe muchos de los resultados
más recientes.
En su mayor parte, cada algoritmo que se ha presentado está descrito en su
propia referencia original. Los algoritmos de cerco convexo del Capítulo 25 se
pueden encontrar en los artículos de Jarvis, Graham, y Golin y Sedgewick. Los
métodos de búsqueda por rango del Capítulo 26 provienen del artículo de in-
vestigación de Bentley y Friedman, que contiene muchas referencias a fuentes
originales (de particular interés resulta el artículo original de Bentley sobre los
kD árboles, escrito cuando era estudiante). El tratamiento del problema del
punto más cercano del Capítulo 28 está basado en el artículo de Shamos y Hoey
de 1976, y los algoritmos de intersección geométnca del Capítulo 27 son de su
trabajo de 1975y de un artículo de Bentley y Ottmann.
Pero la mejor vía a seguir por alguien interesado en aprender más sobre al-
goritmos geométricos consiste en implementar algunos programas y ejecutarlos
para aprender sus propiedades y las de los objetos que manipulan.
J. L. Bentley, «Multidimensional binary searchtrees used for associative search-
ing», Communications of the ACM, 18, 9 (septiembre, 1975).
J. L. Bentley y J. H. Friedman, «Data structures for range searching), Comput-
ing Surveys, 11, 4 (diciembre, 1979).
J. L. Bentley y T. Ottmann, «Algorithmsfor reporting and counting geometric
intersections)),IEEE Transactionson Computing,C-28, 9 (septiembre,1979).
H. Edelsbrunner,Algorithms in Combinatorial Geometry,Springer-Verlag,1987.
M. Golin y R. Sedgewick, ((Analysisof a simple yet efficient convex hull algo-
R. A. Jarvis, «On the identification of the convex hull of a finite set of points in
F. P. Preparata y M. LShamos, Computacional Geometry: An Introduction,
M. I. Shamos y D.Hoey, «Closest-pointproblems)) en 16th Annual Symposium
M. I. Shamos y D. Hoey, ((Geometricintersections problems», en 17th Annual
rithm», Information Processing Letters, 1 (1972).
the plane», Information Processing Letters, 2 (1973).
Springer-Verlag, 1985.
on Foundations o
f Computer Science, IEEE, 1975.
Symposium on Foundations of Computer Science, IEEE, 1976.
Algoritmos en C++.pdf
Algoritmos
sobre grafos
Algoritmos en C++.pdf
29
Algoritmos sobre grafos
elementales
Muchos problemas se formulan de manera natural por medio de objetos y de
las conexiones entre ellos. Por ejemplo, si se dispone de un mapa de enlaces de
líneas aéreas del Este de los Estados Unidos, pueden ser de interés preguntas
como: «¿Cuál es el camino más rápido para ir de Providence a Princeton?)).O
bien puede tener más importancia el precio que el tiempo, y por ello se busca
la forma más económica de ir de Providence a Princeton. Para contestar a esta
clase de preguntas solamente se necesita tener información sobrelas conexiones
(líneas aéreas) entre objetos (ciudades).
Los circuitos eléctricos son otro claro ejemplo en el que las conexionesentre
objetos tienen un papel principal. Los elementos del circuito, transistores, resis-
tencias y condensadores,están conectados entre sí de forma compleja. Talescir-
cuitos pueden representarse y procesarse por medio de computadoras para po-
der contestar a preguntas sencillas como «¿Están conectados todos los
componentes?», así como a cuestionesmás complicadas como «¿,Sise construye
el circuito, funcionará?)).La respuesta a la primera pregunta depende solamente
de las propiedades de las conexiones (cables),mientras que la respuesta a la se-
gunda necesita una información detallada sobre las conexionesy los objetos que
conectan.
Un tercer ejemplo es la «ordenación de tareas», en el que los objetos son las
tareas que se van a realizar, como es el caso de un proceso de fabricación, y las
conexiones entre ellosindican qué tareas deben hacerse antes que otras. Aquí el
interés se centra en responder a preguntas tales como qCuándo se debe realizar
cada tarea?».
Un grufo es un objeto matemático que modela fielmente situaciones de este
tipo. En este capítulo se examinarán algunas de las propiedades básicas de los
grafos, y en los siguientes se estudiará una sene de algontmos que permitirán
responder a preguntas como las propuestas anteriormente.
451
452 ALGORITMOS EN C++
De hecho, ya se han visto algunos grafos en los capítulos precedentes. Las
estructuras de datos enlazadas son realmente representaciones de grafos y algu-
nos de los algoritmos que se verán para el procesamiento de grafos son similares
a los que se han visto ya en el tratamiento de árboles y de otras estructuras. Por
ejemplo, las máquinas de estados finitos de los Capítulos 19y 20 se representan
por medio de estructuras de grafos.
La teoría de grafos es una-rama fundamental de la matemática combinato-
ria que se ha estudiado en profundidad desde hace cientos de años. Gran parte
de las propiedades útiles e importantes de los grafos se han demostrado ya, pero
todavía están sin resolver muchos problemas dificiles. En este libro solamente
se puede arañar la superficiede lo que se conoce sobre los grafos, abarcando lo
suficientepara poder ser capaces de comprender los algoritmos fundamentales.
Como muchos de los tenlas que se han estudiado en este libro, los grafos no
se han examinado desde un punto de vista algorítmico hasta hace poco tiempo.
A pesar de que algunos de los algoritmos fundamentales son bastante antiguos,
muchos de los más interesantes se han descubierto en los últimos diez años. In-
cluso los algoritmos triviales conducen a interesantes programas de computa-
dora, y los otros más dificiles que se examinarán posteriormente se encuentran
entre los más elegantes e interesantes de los algoritmos conocidos (a pesar de
que sean difíciles de comprender).
Glosario
Para el estudio de los grafos hay una cuantiosa cantidad de nomenclatura. La
mayor parte de los términos tienen definiciones sencillas, por lo que es conve-
niente presentarlos todos juntos, aun cuando no se vaya a utilizar algunos de
ellos sino hasta más tarde.
Un grafo es una colección de vértices y de aristas. Los vértices son objetos
simples que pueden tener un nombre y otras propiedades; una arista es una co-
nexión entre dos vértices. Se puede dibujar un grafo representando los vértices
por puntos y las aristas por líneas que los conecten entre sí, pero no hay que
olvidar jamás que la definición de un grafo es independiente de la representa-
ción. Por ejemplo, los dos dibujos de la Figura 29.1 representan el mismo grafo,
que se define diciendo que consiste en el conjunto de vértices A B C D E F G
H I J K L M y en el de aristas entre dichos vértices AG AB AC LM JM JL JK
ED F D HI FE AF GE.
En algunas aplicaciones, tales como las líneas aéreas del ejemplo anterior,
puede que no tenga sentido una reorganización de vértices como la de la Figura
29.1. Pero en otras, como en los mencionados circuitos eléctricos, io mejor es
concentrarse soiamente en las aristas y vértices, independientemente de su si-
tuación geométrica particular. Y para otras aplicaciones, tales como las máqui-
nas de estados finitos de los Capítulos 19 y 20, no se necesita ninguna disposi-
ción geométrica de los nodos. La relación entre los algoritmos sobre grafosy los
ALGORITMOS SOBRE GRAFOS ELEMENTALES 453
Figura 29.1 Dos representacionesdel mismo grafo.
problemas geométricos se presentará con mayor detalle en el Capítulo 31. Por
ahora hay que concentrarse en los aigoritmos «puros», que tratan colecciones
sencillas de aristas y nodos.
Un camino entre los vértices x e y de un grafo es una lista de vértices en la
que dos elementos sucesivosestán conectados por aristas del grafo. Por ejemplo,
BAFEG es un camino desde B a G de la Figura 29.1. Un grafo es conexo si hay
un camino desde cada nodo hacia otro nodo del grafo. De forma intuitiva, si
los vértices son objetos fisicos y las aristas son cadenasque los conectan, un grafo
conexo permanecería en una sola pieza si se le levantara por uno cualquiera de
sus vértices. Un grafo que no es conexo está constituido por componentes co-
nexas; por ejemplo, el grafo de la Figura 29.l tiene tres componentes conexas.
Un camino simple es un camino en el que no se repite ningún vértice (por ejem-
plo BAFEGAC no es un camino simple). Un ciclo es un camino simple con la
característica de que el primero y el último vértices son el mismo (un camino
desde un punto a sí mismo): el camino AFEGA es un ciclo.
Un grafo sin ciclos se denomina un árbol (ver el Capítulo 4). Un grupo de
árboles sin conectar se denomina un bosque. Un árbol de expansión de iin grafo
es un subgrafo que contiene todos los vértices, pero solamente las aristas nece-
sanas para formar un árbol. Por ejemplo, las aristas AB AC AF FD EG ED for-
man un árbol de expansión de la componente mayor del grafo de la Figura 29.1,
y la Figura 29.2 muestra un grafo más grande, así como uno de sus árboles de
expansión.
Hay que subrayar que si se añade una arista cualquiera a un árbol, se debe
formar un ciclo (dado que ya existe un camino entre los dos vértices que ella
conecta). Además, como se vio en el Capítulo 4,un árbol con V-vértices tiene
exactamente V- 1 aristas, Si un grafo con Vvérticestiene menos de V- 1 aristas,
no puede ser conexo. Si tiene más de V- 1 aristas, debe contener un ciclo. (Pero
si tiene exactamente V- 1 aristas no es necesariamente un árbol.)
En este libro se denominará V al número de vértices que tiene un grafo y A
al de aristas. Es de destacar que A puede estar comprendido entre O y Iíz V(V - 1).
Los grafos con todas las aristas posibles se denominan grafos completos;los que
454 ALGORITMOS EN C++
Figura 29.2 Un grafo muy grande y uno de sus arboles de expansión.
tienen relativamente pocas (menos de Vlogy) se denominan dispersos y a los
que les faltan muy pocas de todas las posibles se les denomina densos.
El hecho de que la topología de los grafos dependa fundamentalmente de
dos parámetros hace que el estudio comparativo de los algoritmos sobre grafos
sea algo más complicado que el de muchos de los algoritmos que se han estu-
diado, ya que aparecen más posibilidades.Por ejemplo, un algoritmo puede ne-
cesitar V2pasos, mientras que otro, para el mismo problema, puede necesitar
(A + v>logA pasos. El segundo algoritmo sería preferible para grafos dispersos y
el primero para grafos densos.
Los grafos que se han descrito hasta ahora son del tipo más sencillo, el de-
nominado de grafos no dirigidos. También se consideran en este libro otros ti-
pos de grafos más complicados, en los que se asocia más información con los
nodos y las aristas. En los grafos ponderados se asignan enteros (pesos) a cada
arista para representar, por ejemplo, distancias o costes. En los grafos dirigidos,
las aristas son de mentido único)):una arista puede ir de x a y pero no de y a x.
Los grafosdirigidosponderados se denominan a veces redes. Como se verá pos-
teriormente, la información extra que contienen los grafos ponderados y din-
gidos hace que éstos sean algo más difíciles de manipular que los grafos no di-
rigidos sencillos.
Representación
Con el fin de procesar grafos por medio de un programa de computadora se ne-
cesita decidir cómo representarlos en la máquina. Aquí se estudiarán dos de las
representacionesmás usuales;la elección entre ellasdependerá normalmente de
ALGORITMOS SOBRE GRAFOS ELEMENTALES 455
A B C D E F G H I J K L M
A l l l O O l l O O O O O O
B l l O O O O O O O O O O O
c 1 0 1 0 0 0 0 0 0 0 0 0 0
D O O O l l l O O O O O O O
E O O O l l l l O O O O O O
F l O O l l l O O O O O O O
G l O O O l O l O O O O O O
H O O O O O O O l l O O O O
1 0 0 0 0 0 0 0 1 1 0 0 0 0
J O O O O O O O O O l l l l
K O O O O O O O O O l l O O
L 0 0 0 0 0 0 0 0 0 1 0 1 1
M O O O O O O O O O l O l l
Figura 29.3 Representaciónpor matriz de adyacencia.
si el grafo es denso o disperso, aunque, como siempre, la naturaleza de la ope-
ración a realizar también tendrá un papel importante.
El primer paso para representar un grafo es hacer corresponder los nombres
de los vértices con los enteros entre 1 y V.La principal razón para hacer esto es
facilitar un rápido acceso a la información que corresponde a cada vértice, uti-
lizando un array indexado. Para este objetivo puede utilizarse cualquier es-
quema estándar de búsqueda; por ejemplo, se pueden transformar los nombres
de los vértices en enteros entre el 1 y el V por medio de una tabla de dispersión
o de un árbol binario donde se pueda buscar el entero correspondiente al nom-
bre de un vértice cualquiera. Como ya se han estudiado estas técnicas, se su-
pone que existe una función i ndi ce para convertir nombres de vértices en en-
teros entre 1 y V,y una función nombre para convertir enteros en nombres de
vértices. Para simplificarlos algoritmos se utilizarán nombres de vértices de una
sola letra, correspondiendo la i-ésima letra del alfabeto al entero i. Así,aunque
nombre e indi ce son de fácil implementación en los ejemplos, su utilización
hará más sencilla la extensión de los algoritmos a la manipulación de grafoscon
nombres de vértices reales, utilizando las técnicas de los Capítulos 14-17.
La representación más directa de los grafoses la denominada representación
por matriz de adyacencia. Se construye un array de P V valores booleanos en
el que a [x] [y] es igual a 1si existe una arista desde el vértice x al y y a O en el
caso contrario. La matriz de adyacencia del grafo de la Figura 29.1 se muestra
en la Figura 29.3.
Es de destacar que en realidad cada arista se representa con dos bits: una
arista que enlace x e y se representa con valores verdaderos tanto en a [x] [y]
como en a [y] [x] .Aunque sea posible ahorrar espacio almacenando solamente
456 ALGORITMOS EN C++
la mitad de esta matriz simétrica, en C++ no es conveniente hacer esto, y los
algoritmos son algo más simples con la matriz completa. Además, normal-
mente se supone que existe una «arista»desde cada vértice a sí mismo, por lo
que a[x] [x] es igual a 1 para los valores de x desde 1 hasta V. (En algunos
casos es más conveniente poner a O los elementos de la diagonal; en el libro se
hará esto libremente cuando se considere apropiado.)
Un grafo se define por un conjunto de nodos y otro de aristas que los co-
nectan. Para aceptar un grafo como entrada se necesita establecer un formato
de lectura de estos dos conjuntos. Una posibilidad consiste en utilizar para ello
la propia matriz de adyacencia, pero, como se verá, esto no es adecuado para
los grafos densos. Por ello se utilizará un formato más directo: primero se leen
los nombres de los vértices, después los pares de nombres de vértices (lo que
define las aristas). Como se mencionó anteriormente, una sencilla forma de ac-
tuar es leer los nombres de los vértices en una tabla de dispersión o en un árbol
binario de búsqueda, y asignar un entero a cada nombre de vértice, que servirá
para poder indexar por vértices a los arrays de igual forma que en la matriz de
adyacencia. El i-ésimovértice leído puede asgnarse al entero i. Para simplificar
más los programas, se leen primero V y A, a continuación los vértices y después
las aristas. Alternativamente, se podría distribuir la entrada por medio de un
delimitador que separe los vértices de las aristas, y el programa podria deter-
minar Y y A a partir de los datos de entrada. (En los ejemplos anteriores se uti-
lizan las V primeras letras del alfabeto como nombres de vértices, lo que per-
mite simplificarel esquema leyendo Y y A, y despuéslosA pares de letras de las
primeras V letras del alfabeto.) El orden en el que aparecen las aristas no tiene
ninguna importancia dado que todas las permutaciones de las aristas represen-
tan el mismo grafo y generan la misma matriz de adyacencia, como muestra el
siguiente programa:
i n t V, A;
i n t a [maxV] [maxV] ;
void matrizady()
i n t j , x, y;
tin >> V >> A;
for ( x = 1; x <= V; x++)
for (x = 1; x <= V; x++) a[x][x] = 1;
f o r ( j = 1; j <= A; j++)
c i n >> v l >> v2;
x = indice(v1); y = indice(v2);
{
f o r ( y = 1; y <= V; y++) a[x][y] = O;
{
ALGORITMOS SOBRE GRAFOS ELEMENTALES 457
En este programa se omiten tanto el tipo de v l y v2 como el código de i ndi ce.
Estos detalles se pueden añadir de manera sencilla,dependiendo de la represen-
tación que se desee para el grafo de entrada. Para los ejemplos del libro, v l y v2
pueden ser del tipo char e i ndi ce podría ser una simple función que devuelva
c - ' A ' + 1 o algo similar.
Se puede desarrollar fácilmente una cl ase de C++ para grafos que permita
ocultar la representación detrás de una interfaz que consista en funciones bási-
cas para aplicar a los grafos. Aunque se ha demostrado esta metodología para
estructuras ampliamente utilizadas, como diccionarios y colas de prioridad, se
debe evitar hacerlo en los algoritmos sobre grafos, dado que las implementacio-
nes que utilizan variables globales son algo más compactas, y porque las imple-
mentaciones que dependen de la aplicación tienden a ser necesarias en una
buena implementación de clase. Inicialmente el objetivo de los algoritmos sobre
grafos es exponer las diferenciasde las representaciones, no ocultarlas. Por ello
el lector más experto puede elegir una representación adecuada y utilizar las ca-
pacidades de abstracción de datos de C++ como ayuda para integrarla en una
aplicación particular.
La representación por matriz de adyacencia sólo es satisfactoriasi los grafos
a procesar son densos: la matriz necesita V2bits de almacenamiento y V2pasos
de inicialización. Si el número de aristas (el nirnero de bits 1 de la matriz) es
proporcional a V2,
se puede aceptar esta representación porque en cualquier caso
se necesitan aproximadamente V2pasos para leer las aristas. Sin embargo, si el
grafo es disperso, la simple inicialización de la matriz podría ser el factor du-
minante en el tiempo de ejecución del algoritmo. Ésta podría ser también la
mejor representación para algunos algoritmos cuya ejecución necesita más de
v2pasos.
A continuación se estudia una representación mejor adaptada a los casos de
grafos que no son densos. En la representación por estructura de adyacencia to-
dos los vértices conectados con uno dado se relacionan en una lista de adyacen-
cia de dicho vértice. Esto se puede realizar fácilmente por medio de listas enla-
zadas, como se muestra en el siguienteprograma que construye la estructura de
adyacencia para el grafo del ejemplo. Las listas enlazadas se construyen de la
forma habitual, con un nodo ficticio z en cola (apuntando sobre sí mismo). Los
nodos ficticios del encabezamiento de las listas se conservan en un array ady
indexado por vértices. Para añadir una arista que conecte xa y en esta represen-
tación del grafo, se agregará x a la lista de adyacencia de y e y a la lista de ad-
yacencia de x:
s t r u c t nodo
i n t V, A;
s t r u c t nodo *ady[maxV], *z;
void l i s t a a d y o
{ i n t v; struct nodo "siguiente; };
458 ALGORITMOS EN C+t
int j, x, y; struct nodo *t;
cin >> V >>A;
z = new nodo; z->siguiente = z;
for (j = 1; j <= V; j++) ady[j] = z;
for (j = 1; j <= A; j++)
cin >> vl >> v2;
x = indice(v1); y = indice(v2);
t = new nodo;
t = new nodo;
{
t->v =x; t->siguiente = ady[y]; ady[y]=t;
t->v =y; t->siguiente = ady[x]; ady[x]=t;
1
1
La representación por lista de adyacencia es la mejor para los grafos dispersos,
dado que el espacio que se necesita está en O(V +A), en contraste con el espa-
cio en O(V2)necesario para la representación por matriz de adyacencia.
A B C D E F G H I J K L M
D
r
3
Figura 29.4 Una representación por estructura de adyacencia.
Si las aristas aparecen en el orden AG AB AC LM JM JL JK ED FD HI FE
AF GE, el programa anterior construye la estructura de listasde adyacencia que
se muestra en la Figura 29.4. Se observa otra vez que cada arista se representa
dos veces: una arista que conecte x e y se representa como un nodo que con-
tiene a x en la lista de adyacencia de y, y como un nodo que contiene a y en la
lista de adyacencia de x. Es importante incluir ambos nodos, pues en caso con-
trario una pregunta tan simple como «¿Qué nodos están conectados directa-
mente al nodo x?»no podría contestarse de forma eficaz.
En esta representación el orden en el que aparecen las aristas en la entrada
es muy importante: él determina (junto con el método de inserción utilizado) el
orden en el que aparecerán los vértices en las listas de adyacencia. Por ello el
ALGORITMOS SOBRE GRAFOS ELEMENTALES 459
mismo gafo se puede representar de muchas formas diferentes en una estruc-
tura de listas de adyacencia. De hecho, es dificil predecir que las listas de adya-
cencia serán semejantesal examinar solamente la secuenciade aristas, dado que
cada arista implica la inserción en dos listas de adyacencia.
El orden de aparición de las aristas en la lista de adyacencia afecta, a su vez,
el orden en el que serán procesadas por los algoritmos. Esto es, la estructura de
la lista de adyacenciadetermina la forma en la que diversosalgoritmos «verán»
al grafo. Mientras que un algoritmo debe dar una respuesta correcta, sin que
tenga importancia cómo están ordenadas las aristas en las listas de adyacencia,
podría ser que obtuviera esta respuesta por muchas secuenciasde cálculo distin-
tas en órdenes diferentes.Y si existe más de una «respuesta correcta), diferentes
órdenes de entrada podrían llevar a resultados de salida diferentes.
En esta representación no se contemplan algunas operaciones simples. Por
ejemplo, se podría desear suprimir un vértice, x,y todas las aristas que inciden
en él. No es suficientecon suprimir nodos de una lista de adyacencia: cada nodo
de la lista especifica otro vértice, cuya lista de adyacencia debe buscarse para
eliminar un nodo correspondiente a x.Este problema puede corregirse enla-
zando los dos nodos de las listas que corresponden a una arista determinada y
haciendo las listas de adyacencia doblemente enlazadas. Así, si se suprime una
arista, los dos nodos de las listas que se corresponden con ella pueden elimi-
narse rápidamente. Por supuesto, estos lazos extra son incómodos para el pro-
ceso y no se les debería incluir salvo que se necesitaran operaciones como la de
eliminación,
Estas consideraciones también justifican por qué no se utilizan representa-
ciones «directas» en los grafos: una estructura de datos que modela el grafo
exactamente, con los vértices representados por registros asignados y listas de
aristas que contienen enlaces a los vértices en lugar de nombres de vérticcs. Sin
acceso directo a los vértices, las operacionesmás simplespodrían convertirse en
verdaderos desafíos. Por ejemplo, para añadir una arista a un grafo represen-
tado de esta manera se tendría que buscar a través del grafo alguna forma de
encontrar los vértices.
Los grafos dirigidos y los grafos ponderados se representan por medio de es-
tructuras similares.En el caso de los grafos dirigidos todo lo expuesto es válido
excepto que cada arista se representa una sola vez: una arista de x a y se repre-
senta con 1 en a [x] [y] de la matriz de adyacencia o por la aparición de y en
la lista de adyacencia de x de la estructura de adyacencia. Así se puede repre-
sentar un grafo no dirigido como un grafo dirigido en el que toda arista que co-
necta dos vértices es una arista dirigida en los dos sentidos. Para los grafos pon-
derados se procede exactamente igual excepto que se completa la matriz de
adyacencia con pesos en lugar de valores booleanos (utilizando algún peso que
no exista para representar la ausencia de una arista), o incluyendo un campo
para el peso en los registros de la lista de la estructura de adyacencia.
A veces es necesario asociar otras informaciones a los vértices o nodos de un
grafo para permitir el modelado de objetosmás complicados o para ahorrar tra-
bajo en la actualización de la información de los algoritmos complicados. Para
460 ALGORITMOS EN C++
disponer de esta información extra asociada con cada vértice se pueden utilizar
arrays auxiliaresindexados por los números de los vértices o transformando ady
en un array de registros en la representación de la estructura de adyacencia. La
información suplementaria asociada con cada arista puede colocarse en los no-
dos de la lista de adyacencia (o en un array a de registros en la representación
por matriz de adyacencia), o en arrays auxiliares indexados por el número de
arista (lo que requiere numerarlas).
Búsqueda en profundidad
AI comienzo de este capítulo, se han visto varias cuestiones que aparecen de
forma inmediata cuando se procesa un grafo. ¿El grafo es conexo? Si no lo es,
¿cuáles son sus componentes conexas? ¿Contiene un ciclo? Estos problemas y
otros muchos pueden solucionarse fácilmente por medio de una técnica deno-
minada búsqueda en profundidad, que es un medio natural de «exploran>cada
nodo y de comprobar cada arista del grafo de forma sistemática. En los capítu-
los siguientes se verá que es posible utilizar sencillas variaciones de una gene-
ralización de este método para resolver una gran variedad de problemas sobre
grafos.
Por ahora hay que concentrarse en los mecanismos que examinan metódi-
camente cada elemento del grafo. Se utiliza un array val [ V I para registrar el
orden en el que se exploran los vértices. Cada zntrada del array se inicializa con
el valor novisto para indicar qué vértices no se han inspeccionado todavía. El
objetivo es visitar sistemáticamente todos los vérticesdel grafo, colocando el or-
den del vértice explarado, id, en la id-ésima entrada de val, para los valores
de id= 1, 2, ..., V.El siguiente programa utiliza un procedimiento v i s i t a r que
inspecciona todos los vértices de la misma componente conexa del vértice pa-
sado como argumento.
i n t
f o r
f o r
{
1
void buscar ( )
k;
(k=l ;
(k=l;
k<=V; k++) v a l l k ] = novisto;
k<=V ; k++)
f (va [k] == novisto); v i s i t a r ( k ) ;
El primer bucle f o r inicializa el array val. A continuación se invoca v i s it a r
para el primer vértice, con el resultado de atribuir valores en val a todos los
vértices conectados a él. A continuación buscar explora el array val buscando
vértices que todavía no hayan sido vistos y llama a v i s i ta r para estos vértices,
continuando de esta forma hasta que se hayan inspeccionado todos los vértices.
ALGORITMOS SOBRE GRAFOS ELEMENTALES 461
Es de destacar que este método no depende de la forma como se represente el
grafo o de como se implemente v i s i t a r .
En primer lugar se considera una implementación recursiva de v i s it a r para
la representación por listas de adyacencia: para v i s i t a r un vértice, se com-
prueban todas sus aristas para ver si conducen a vértices que todavía no se hani
visto; si los hay, se invoca v i s i t a r para ellos.
v o i d v i s i t a r ( i n t k) // E?, l i s t a s de adyacencia
i
s t r u c t nodo *t;
val [k] = ++id;
for ( t = ady[k]; t !=z; t = t->siguiente)
if (val [t->VI == novisto) v i s i t a r ( t - > v ) ;
}
La Figura 29.5 traza el recorrido de la operación de búsqueda en profundidad
de la componente mayor del grafo del ejemplo y muestra cómo se toca cada
arista de esta componente como resultado de la llamada v i s i t a r (1) (después
de que se hayan construido las listas de adyacencia de la Figura 29.4). Real-
mente se «contacts» dos veces con cada arista, dado que todas están represen-
tadas en las dos listas de adyacencia de los vértices que conectan. En la Figura
29.5 hay un diagrama por cada arista recomda (cadavez que el enlace t se pone
a apuntar a algún nodo de alguna lista de adyacencia).En cada diagrama la arista
«actual» aparece sombreada y el nodo cuya lista de adyacencia contiene a esta
arista está etiquetado con un cuadrado. Además, cada vez que se inspecciona
un nodo por primera vez (lo que se corresponde con una nueva llamada a v i -
s i tar), se representa en negro la arista que conduce a dicho nodo. Los nodos
que no han sido tocados todavía están sombreados, pero no etiquetados, y
aquellos para los que v i s i t a r ha terminado están sombreados y etiquetados.
La primera arista recorrida es AF, el primer nodo de la primera lista de ad-
yacencia. A continuación se invoca v i s i t a r para el nodo F y se recorre la arista
FA' dado que A es el primer nodo de la lista de adyacencia de F. Pero el nodo
A es en este momento novi sto, por lo que se coge la arista FE, la siguienteen-
trada de la lista de adyacencia de F. A continuación se recorre EG y después
GE, dado que G y E son los primeros de cada una de las otras listas. Luego se
recorre GA y con esto se termina v i s it a r G, por lo que el algoritmo continua
con v i sita r E y recorre EF y después ED. Después v i sit a r D consiste en re-
correr DE y DF, ninguna de las cuales conduce a un nuevo nodo. Dado que D
es el último nodo de la lista de adyacencia de E, v i sit a r este nodo ha termi-
nado y la visita de F se completará recomendo FD. Finalmente se vuelve a A y
se recorre AC, CA, AB, BA y AG.
Otra forma de seguir la operación de búsqueda en profundidad es volver a
dibujar el grafo en el orden indicado por las llamadas recursivas del procedi-
miento v i s i t a r , como se muestra en la Figura 29.6. Cada componente conexa
462 ALGORITMOS EN C++
Figura 29.5 Búsquedaen profundidad(recursiva)de la componentemayor del grafo.
p,
F ....., C B .
.
'
,
.
:
_..
__...
__...
__..
.
.._____.__..
...
Figura 29.6 Bosque de búsquedaen profundidad.
ALGORITMOS SOBRE GRAFOS ELEMENTALES 463
conduce a un árbol, denominado árbol de búsqueda en profundidad de la com-
ponente. Recomendo este árbol en orden previo se obtienen los vértices del grafo
en el orden en el que estaban la primera vez que se encontraron en la búsqueda;
recoméndolo en orden posterior se obtienen los vértices en el orden que esta-
ban al terminar v i sit a r . Es importante comprender que este bosque de árbo-
les de búsqueda en profundidad es simplemente otra forma de dibujar el grafo:
el algoritmo examina todos los vértices y aristas del grafo.
Las líneas de trazo grueso de la Figura 29.6 indican que el algoritmo ha en-
contrado al vértice inferior en la lista de aristas del vértice superior y no había
sido inspeccionado hasta ahora, por lo que se hizo una llamada recursiva. Las
líneas de puntos corresponden a las aristas dirigidas hacia vértices que ya han
sido visitados,por lo que la comprobación if de v i s i t a r ha fallado y la arista
no ha «provocado» una llamada recursiva. Estos comentarios se aplican la pri-
mera vez que se encuentra a cada arista; la comprobación if de v i s i t a r per-
mite también evitar que se recorra la arista la segunda vez que se la encuentre,
como se vio en la Figura 29.5.
Una propiedad crucial de estos árboles de búsqueda en profundidad para
grafos no dirigidos es que los enlaces de puntos siempre van desde un nodo a
algún antecesor (otronodo del mismo árbol situado más alto en el camino hacia
la raíz). En cualquier momento de la ejecución del algoritmo los vértices se di-
viden en tres clases: aquellospara los que v i sit a r ha terminado, aquellos para
los que ha terminado sólo parcialmente y aquellos que todavía no han sido en-
contrados. Por la definición de v i s i t a r no se podrá encontrarjamás una arista
apuntando hacia un vértice de la primera clase y si se encuentra una dirigida
hacia un vértice de la tercera clase, se efectuará una llamada recursiva (por lo
que la arista se representará con una línea gruesa en el árbol de búsqueda en
profundidad). Los únicos vértices que quedan son los de la segunda clase, que
son precisamente los situados en el camino desde el vértice actual al de la raíz
del mismo árbol, y toda arista dirigida hacia uno cualquiera de ellos correspon-
derá a un enlace de puntos del árbol de búsqueda en profundidad.
Propiedad 29.1 La búsqueda en profundidad de un grafo representado con lis-
tas de adyacencia necesita un tiempoproporcional a V +A.
Se actualiza cada uno de los Vvalores de val (de ahí el término V) y se examina
cada arista dos veces (de ahí el término A). Se podría encontrar un gafo (extre-
madamente) denso con A < V,pero si no están permitidos los vértices aislados
(se podría, por ejemplo, haberlos suprimido en una fase de preprocesamiento),
es preferible pensar que el tiempo de ejecución de la búsqueda en profundidad
es lineal en el número de aristav
El mismo método básico se puede aplicar a los grafosrepresentadoscon ma-
trices de adyacencia, utilizando el procedimiento v i s i t a r siguiente:
v o i d v i s i t a r ( i n t k) // BP, m a t r i z de adyacencia
464 ALGORITMOS EN C++
{
i n t t;
val[k] = ++id;
f o r ( t = 1; t <= V; t++)
i f (a[k] [t] != O)
i f (val [t] == novisto) v i s i t a r ( t ) ;
1
El recorrido a través de una lista de adyacencia se traduce en una exploración
de las filas de la matriz de adyacencia, buscando valores iguales a 1(que corres-
ponden a las aristas). Como anteriormente, cualquier arista dirigida hacia un
vértice que todavía no ha sido visto se «recorre» por medio de una llamada re-
cursiva. Ahora, las aristas conectadas a cada vértice se examinarán en un orden
diferente, lo que proporciona un bosque de búsqueda en profundidad diferente,
representado en la Figura 29.7. Esto confirma el hecho de que un bosque de
búsqueda en profundidad no es más que otra representación del grafo, cuya es-
tructura particular depende n la vez del algoritmo de búsqueda y de la represen-
tación interna utilizada.
Propiedad 29.2 La búsqueda enprofundidad de un grafo representado con una
matriz de adyacencia necesita un tiempo proporcional a V2.
La demostración de esta propiedad es trivial: se comprueba todo bit de la ma-
triz de adyacencia.i
Figura 29.7 Bosque de búsquedaen profundidad (representacióndel grafo por
matriz).
La búsqueda en profundidad resuelve directamente algunos problemas ele-
mentales de procesamiento de grafos. Por ejemplo, el procedimiento se basa en
encontrar las sucesivas componentes conexas: el número de componentes co-
nexas es igual al número de veces que se llama a v i sit a r en la última línea del
programa. El comprobar si un grafo tiene un ciclo es también una modificación
trivial del programa anterior. Un grafo tiene un ciclo si, y sólo si, se descubre
un nodo no novi Sto en v i s i t a r . Esto es, si se encuentra una arista que apunta
a un vértice que ya se ha inspeccionado, entonces se tiene un ciclo. De forma
ALGORITMOS SOERE GRAFOS ELEMENTALES 465
equivalente, todos los enlaces en línea punteada de los árboles de búsqueda en
profundidad pertenecen a ciclos.
Búsqueda en profundidad no recursiva
La búsqueda en profundidad de un grafo es una generalizacióndel recorrido de
árboles. Si el grafo es un árbol, es exactamente equivalente al recorrido del
mismo; para los grafos, corresponde al recorrido del árbol que recubre al grafo
y que se «descubre» durante el proceso de búsqueda. Como se ha visto, el árbol
a recorrer depende de la forma como se representa el grafo.
Se puede eliminar la recursión en la búsqueda en profundidad utilizando una
pila, de la misma forma que se hizo para el recomdo del árbol del Capítulo 5.
Para los árboles se encontró que la supresión de la recursión conducía a una
implementación equivalente alternativa (relativamente simple) y también se
descubrió un algoritmo de recorrido no recursivo (por niveles). Para los grafos
se encontrará una evolución similar, que finalmente conducirá (en el Capítulo
3 1) a un algoritmo de recomdo de grafos de uso general.
Sirviéndose de la experiencia del Capítulo 5, se puede dar directamente una
imp!ementación basada en una pila:
Pi1 a pi 1 a(maxV) ;
void visitar(int k) //BP no recursiva, listas de adyacencia
c
i
struct nodo *t;
p i 1 a.meter(k) ;
while (!pila.vacia())
k = pila.sacar(); val[k] = ++id;
for (t = ady[k]; t != z; t = t->siguiente)
{ pila.meter(t->v); val[t->v] = -1; }
{
if (val [t-]VI ==novisto)
Los vértices que han sido tocados, pero que todavía no han sido inspecciona-
dos, se colocan en una pila. Para visitar a un vértice se recorren sus aristas y se
mete en la pila cualquier vértice que no haya sido inspeccionado todavía y que
no esté todavía en la pila. En la implementación recursiva, la «contabilidad» de
los vértices «parcialmente inspeccionados»está oculta por la variable local t del
procedimiento recursivo. Habría sido posible implementar esto directamente
guardando los punteros (correspondientes a t) en los elementos de las listas de
adyacencia, y así sucesivamente. En su lugar se ha extendido simplemente el
466 ALGORITMOS EN C++
Figura 29.8 Comienzo de la búsqueda basada en una pila.
significado de las entradas de val para englobar a los vértices que ya están en
la pila: los vértices con entradas de val iguales a novi sto no se han encontrado
todavía (como antes), aquellos con entradas negativas de val están en la pila y
aquellos con entradas de val entre 1y V ya han sido explorados (todaslas ans-
tas de sus listas de adyacencia se han puesto en la pila).
La Figura 29.8 representa la operación de este procedimiento de búsqueda
en profundidad basado en pilas cuando se han inspeccionado los cuatro pri-
meros nodos del grafo del ejemplo. Cada diagrama de esta figura corresponde a
la inspección de un nodo: el nodo visitado se dibuja como un cuadrado y todas
las aristas de sus listas de adyacencia están sombreadas. Como antes, los nodos
que no se han encontrado todavía no tienen etiqueta y están sombreados, los
nodos para los que la exploración ha terminado están etiquetados y no som-
breados, y cada nodo está conectado por una arista en línea negra gruesa al nodo
que ha provocado que se coloque en la pila. Los nodos que están todavía en la
pila están dibujados con cuadrados.
El primer nodo explorado es A: se recorren las aristas AF, AB, AC y AG y
se colocan en la pila F, B, C y G. A continuación se saca de la pila a G (es el
último nodo de la lista de adyacencia de A) y se recorren las aristas GA y GE,
lo que se traduce en colocar E en la pila (A no está todavía en ella). Después se
recorren EG, EF y ED y se mete a D en la pila, etc. La Figura 29.9 muestra el
ALGORITMOS SOBRE GRAFOS ELEMENTALES 467
Figura 29.9 Contenido de la pila durante la búsquedabasadaen una pila.
contenido de la pila durante la búsqueda y la Figura 29.10 muestra cómo con-
tinúa el proceso de la Figura 29.8.
El lector seguramente habrá notado que el programa precedente no inspec-
ciona las aristas y nodos en el mismo orden que en la implementación recur-
siva. Esto podría hacerse colocando las anstas en la pila en orden inverso y ma-
nipulando de forma diferente el caso en el que se encuentra de nuevo a un nodo
que ya está en la pila. En primer lugar, si se colocan en la pila las aristas de la
lista de adyacencia de cada nodo en orden inverso al que aparecen en la lista,
Figura 29.10 Fin de la búsqueda basadaen una pila.
468 ALGORITMOSEN C++
entonces se sacarán de la pila y se visitarán los nodos correspondientes en el
mismo orden que en la implementación recursiva. (Éste es el mismo efecto que
en el recorrido del árbol del ejemplo del Capítulo 5, en el que el subárbol dere-
cho se coloca en la pila antes que el izquierdo en la implementación no recur-
siva.) Una diferencia más importante es que el método basado en la pila técni-
camente no es del todo un procedimiento de (búsqueda en profundidad)),dado
que inspecciona el último nodo que se ha colocado en la pila, no el último nodo
que se ha encontrado, como es el caso de la búsqueda en profundidad recursiva.
Esto se puede restablecer moviendo hacia la parte superior de la pila los nodos
que están en ella, según se van redescubriendo,pero esta operación necesita una
estructi;ra de datos más sofisticada que una pila. En el Capítulo 31 se exami-
nará una forma más simple de implementar esto.
Búsqueda en amplitud
De igual forma que en el recorrido del árbol (ver Capítulo 4
)
,se puede utilizar
una cola, en vez de una pila, como estructura de datos para almacenar vértices.
Esto conduce a un segundo algoritmo clásico de recorrido de grafos, denomi-
nado búsqueda en amplitud. Para implementar la búsqueda en amplitud, se
cambian las operaciones de pila por operaciones de cola en el programa de bús-
queda anterior:
Cola cola(maxV);
void visitar(int k) / / BA, listas de adyacencia
{
struct nodo *t;
col a,poner(k) ;
while (!cola.vacia())
i
k = cola.obtener()ccl[k] = ++id;
for (t = ady[k]; t != z; t = t->siguiente)
if (val [t->VI == novisto)
{ cola.poner(t->v) ; val [t->v] = -1; }
1
1
El hecho de cambiar la estructura de datos de esta fama afecta el orden de ins-
pección de los nodos. En el grafo pequeño del ejemplo las aristas se visitan en
el orden AF AC AB A G FA FE FD CA BA GE GA DF DE EG EF ED HI IH
JK JL JM KJ LJ LM MJ ML. El contenido de la cola durante el recomdo se
muestra en la Figura 29.1 1.
ALGORITMOS SOBRE GRAFOS ELEMENTALES 469
1HI
@I
I
m
E
l
El El
1AJ IJI
Figura 29.11 Contenido de la cola durante la búsquedaen amplitud.
Como en la búsqueda en profundidad, se puede definir un bosque a partir
de las aristas que conducen por primera vez a cada nodo, como se muestra en
la Figura 29.12. La búsqueda en amplitud corresponde a recorrer los árboles de
este bosque por orden de niveles.
En los dos algoritmos se puede imaginar que los vértices están divididos en
tres clases: vértices del árbol (o visitados), aquellos que se han retirado de la es-
tructura de datos; vértices del margen, que son adyacentes a los vértices del ár-
bol, pero que no se han inspeccionado todavía, y vértices no vistos, que no se
han encontrado todavía. Si cada vértice del árbol está conectado a la arista que
ha provocado su inserción en la estructura de datos (las aristas de trazo negro
grueso de las Figuras 29.8 y 29.lo), entonces estas aristas forman un árbol.
Para buscar de forma sistemática una componente conexa de un gafo (im-
plementando un procedimiento visitar), se comienza por un vértice del margen,
siendo todos los otros no vistos, y se lleva a cabo la acción siguiente hasta que
se hayan inspeccionado todos los vértices: «se transporta un vértice x desde el
margen al árbol, y se colocan en el margen todos los vértices no vistos adyacen-
tes a m.
Los métodos de recorrer los grafos difieren en la forma como deciden
qué vértice debe pasar desde el margen hacia el árbol. En la búsqueda en pro-
Figura 29.12 Bosque de búsquedaen amplitud.
470 ALGORITMOS EN C++
Figura 29.13 Búsqueda en profundidaden un gran grafo.
fundidad se desea elegir el vértice del margen que se ha encontrado más recien-
temente; esto corresponde al empleo de una pila para almacenar los vértices del
margen. En la búsqueda en amplitud se desea elegir el vértice del margen que
se ha encontrado menos recientemente; esto corresponde al empleo de una cola
para almacenar los vértices del margen. En el Capítulo 31 se verá el efecto de
utilizar una cola deprioridad para el margen.
El contraste entre las búsquedas en profundidad y en amplitud se hace más
evidente cuando se considera un gran grafo. La Figura 29.13 muestra el proceso
de búsqueda en profundidad en un gran grafo, al tercio y a los dos tercios de su
realización; la Figura 29.14 es la descripción correspondiente a la búsqueda en
amplitud. En estos diagramas, los vértices y aristas del árbol están en negro, los
vértices no vistos están sombreados y los vértices del margen están en blanco.
Figura 29.14 Búsqueda en amplitud en un gran grafo.
ALGORITMOS SOBRE GRAFOS ELEMENTALES 471
Figura 29.15 Un laberinto y el grafo asociado.
En ambos casos el recomdo comienza en el nodo inferior izquierdo. La bús-
queda en profundidad «se sumerge» en el grafo, almacenando en la pila los
puntos de los que emergen otros caminos; la búsqueda en amplitud «barre»el
grafo, utilizando una cola para memorizar la frontera de los lugares ya visita-
dos. La búsqueda en profundidad «explora»el grafo buscando nuevos vértices
lejos del punto de partida, tomando los vértices próximos solamente cuando se
encuentre un callejón sin salida;la búsqueda en amplitud cubre completamente
la zona cercana al punto de partida, alejándose solamente cuando todos los ve-
cinos han sido vistos. Una vez más, el orden en el que se visitan los nodos de-
pende fuertemente del orden en el que aparecen las aristas en la entrada y de
los efectos de esta ordenación en la forma como aparecen los vértices en las lis-
tas de adyacencia.
Más allá de estas diferenciasoperativas, es interesante reflejar las diferencias
fundamentales que existen entre las implementaciones de estos métodos. La
búsqueda en profundidad se expresa simplemente de forma recursiva (dado que
la estructura de datos subyacente es una pila) y la búsqueda en amplitud admite
una implementación no recursiva (dado que la estructura de datos subyacente
es una cola).En el Capítulo 3 1 se verá que la estructura de datos subyacente de
los algoritmos sobre grafos es realmente una cola de prioridad, lo que implica
una abundancia de interesantes propiedades y algoritmos.
Laberintos
Esta forma sistemática de visitar cada vértice y arista de un gafo tiene una his-
toria muy particular: la búsqueda en profundidad fue expuesta formalmentehace
472 ALGORITMOS EN C++
centenares de años como un método para recorrer laberintos. Por ejemplo, en
la parte izquierda de la Figura 29.15 se representa un popular laberinto, y a la
derecha el grafo construido al colocar un vértice en cada punto en el que existe
más de un camino a tomar y al conectar a continuación los vértices dc acuerdo
con esos caminos. Este laberinto es claramente más complicado que los de los
antiguosjardines ingleses, que estaban construidos como caminos rodeados de
grandes setos. En estos laberintos todos los muros estaban conectados a otros
muros, por lo que damas y caballerospodían pasear por ellos, y los más inteli-
gentes de ellos podían encontrar la salida siguiendo simplemente el muro de su
mano derecha (los ratones de laboratorio han aprendido estos trucos). Cuando
pueden existir paredes interiores independientes se necesita una estrategia más
sofisticada,lo que conduce a una búsqueda en profundidad.
Para desplazarse de un lugar a otro del laberinto por medio de una bús-
queda en profundidad se utiliza v i sitar partiendo del vértice del grafo que co-
rresponde al punto de partida. Cada vez que v i s itar «sigue» una arista por
medio de una llamada recursiva, se marcha a lo largo del camino correspon-
diente del laberinto. El truco consisteen retroceder por el camino que se ha uti-
lizado para llegar a cada vértice una vez que v i s itar ha terminado con dicho
vértice. Así se vuelve al vértice situado justo un nivel por encima en el árbol de
búsqueda en profundidad, y se está preparado para seguir por la arista adya-
cente. (Este proceso reproduce fielmente el recorrido de la búsqueda en profun-
didad de un grafo.) La búsqueda en profundidad es apropiada para la búsqueda
de un elemento del laberinto por una sola persona, dado que el «siguiente lugar
a visitan) está siempre próximo; la búsqueda en amplitud es más apropiada para
un grupo de personas que buscan el mismo elemento desplazándose en todas
las direcciones a la vez.
Perspectivas
En los capítulos siguientes se verá una serie de algoritmos sobre grafos enfoca-
dos principalmente a la determinación de las propiedades de la conectividad re-
lativas a los grafos, dirigidos o no. Estos algoritmos son fundamentales para el
tratamiento de grafos, pero son solamente una introducción al tema de los al-
goritmos sobre grafos. Se han desarrollado muchos algoritmos útiles e intere-
santes que están fuera del alcance de este libro y se han estudiado muchos pro-
blemas interesantes para los que no se ha conocido ningún algoritmo eficaz.
Algunos de los algoritmosque se han desarrollado son demasiado complejos
para presentarlos aquí. Por ejemplo, es posible determinar de forma eficaz si un
grafo puede representarse (o no) en un plano sin que haya ninguna intersección
entre sus líneas. Este problema, denominado de planaridad, ha debido esperar
hasta 1974 para cmocer un algoritmo que lo resuelva. Fue R.E. Tarjan quien
en ese ano desarrolló un ingenioso algoritmo (aunque muy complejo) para re-
solver el problema en tiempo lineal, utilizando la búsqueda en profundidad.
ALGORITMOS SOBRE GRAFOS ELEMENTALES 473
Algunos problemas sobre grafos que se encuentran de forma natural y son
fáciles de formular, parecen de dificil resolución y no existen algoritmos cono-
cidos para resolverlos. Por ejemplo, no se conoce ningún algoritmo eficaz para
encontrar el camino de coste mínimo que explora todos los vértices de un grafo
ponderado. Este problema, denominado el problema del vendedor ambulante,
pertenece a una amplia clase de problemas difíciles que se presentarán con más
detalle en el Capítulo 45.La mayor parte de los expertos están convencidos de
que no existe ningún algoritmo eficaz para estos problemas.
Otros problemas sobre grafos pueden contar con algoritmos eficaces, aun-
que no se haya encontrado todavía ninguno. Un ejemplo de ellos es el problema
del isomorfismo de grafos en el que se desea conocer si es posible identificar a
dos grafos renombrando simplemente sus vértices. Se conocen algoritmos efi-
caces para este problema en muchos tipos particulares de grafos, pero el pro-
blema general permanece abierto.
En resumen, existe un amplio espectro de problemas y algontmos para tra-
tar a los grafos. Ciertamente no se puede esperar resolver todos los problemas
que se encuentren y, al contrario, algunos problemas que parecen simples to-
davía causan confusión en los expertos. Pero io normal es que aparezcan con
frecuenciaun cierto número de problemas relativamente simples, y, además, los
algontmos sobre grafos que se estudiarán en este libro serán muy útiles en una
gran variedad de aplicaciones.
.
Ejercicios
1. ¿Qué representación de grafo no dirigido es más apropiada para determinar
rápidamente si un vértice está aislado (no conectado a ningún otro vértice)
o no lo está?
2. Supóngase que se utiliza una búsqueda en profundidad sobre un árbol bi-
nailo de búsqueda y que la arista derecha se toma antes que la izquierda, al
salir de cada nodo. ¿En qué orden se visitarán los nodos?
3. iCuántos bits de almacenamiento se necesitan para representar la matriz de
adyacencia de iin grafo no dirigido de V nodos y A aristas?¿Cuántosbits se
necesitan para la representación con listas de adyacencia?
4. Dibujar un grafo que no pueda representarse en una hoja de papel sin que
dos de sus aristas se crucen.
5. Escribir un programa para suprimir una arista de un grafo representadocon
listas de adyacencia.
6. Escribir una versión de 1istaady que guarde las listas de adyacenciaen una
ordenación según los índices de los vértices. Analizar las ventajas de esta
estrategia.
7. Dibujar el bosque de búsqueda en profundidad que se obtiene del ejemplo
del texto cuando el procedimiento buscar explora los vértices en orden in-
verso (de V a 1) para las dos representaciones.
474 ALGORITMOS EN C++
8. ¿Cuántas veces se debe invocar exactamente a v i s i tar en una búsqueda
en profundidad en un grafo no dirigido?Determinar lo anterior en función
del número de vértices V,del número de aristas A y del número de com-
ponentes conexas C.
9. Presentar las listas de adyacencia obtenidas si las aristas del grafo del ejem-
plo se leen en orden inverso al que se utilizó para hacer la estructura de la
Figura 29.4.
10. Presentar el bosque de búsqueda en profundidad para el gafo del ejemplo
del texto cuando la rutina recursiva se utiliza en las listas de adyacencia del
ejercicioanterior.
30
Conectividad
El procedimiento fundamental de búsqueda en profundidad del capítulo ante-
rior encuentra las componentes conexas de un grafo dado; en este capítulo se
examinarán los algoritmos relacionados y los problemas que conciernen a otras
propiedades de la conectividad de los grafos.
Después de haber visto algunas aplicacionesdirectas de la búsqueda en pro-
fundidad para obtener información de conectividad, se examinará una genera-
lización de la conectividad denominada biconectividad, cuyo interés reside en
conocer si hay más de un medio de pasar de un vértice de un grafo a otro. Un
gafo es biconexo si, y sólo si, existen al menos dos caminos diferentes que co-
necten cada par de vértices. De esta forma, si se suprime un vértice y todas las
aristas que inciden en él, el grafo permanece conexo. Si para algunas aplicacio-
nes es importante que un grafo sea conexo, es también importante que perma-
nezca conexo. La solución a este problema es un algoritmoverdaderamente más
complejo que los algoritmos de recorrido del capítulo anterior, aunque se base
también en la búsqueda en profundidad.
Una versión particular del problema de la conectividad, que con frecuencia
concierne a la situación dinámica en la que las aristas se añaden al grafo una a
una, intercalando preguntas sobre si dos vértices determinados pertenecen (o no)
a la misma componente conexa. Este problema se ha estudiado profundamente,
y en este libro se examinarán con detalle dos algoritmos «clásicos»relacionados
con él. Estos métodos no solamente son sencillos y de aplicación general, sino
que también muestran la gran dificultad que puede existiral analizar algoritmos
simples. El problema se denomina a veces como ((unión-pertenencia),una no-
menclatura que se deriva de la aplicación de los algoritmos al tratamiento de
operaciones simples en conjuntos de elementos.
475
476 ALGORITMOS EN C++
Componentes conexas
Cualquier método de recorrido de grafos del capítulo anterior puede utilizarse
para encontrar las componentes conexas de un grafo, dado que todos se basan
en la misma estrategia general de visitar todos los nodos de una componente
conexa antes de trasladarse a la siguiente. Una forma sencilla de listar las com-
ponentes conexas es modificar uno de los programas de búsqueda recursiva en
profundidad para que vi si tar enumere el vértice que se acaba de visitar (es
decir, imprimiendo nombre(k) justo antes de acabar), y dando a continuación
alguna indicación del comienzo de una nueva componente conexa justo antes
de la llamada (no recursiva) a vi s itar en buscar. Esta técnica produciría la
siguiente salida cuando se utiliza la búsqueda en profundidad (buscar y la ver-
sión de lista de adyacenci a de vi sitar del Capítulo 29) en el grafo del ejem-
plo (Figura 29.1):
G D E F C B A
I H
K M L J
Otras variantes, como la versión de matriz de adyacencia de vi s i tar, la bús-
queda en profundidad basada en una pila y la búsqueda en amplitud, pueden
calcular las mismas componentes conexas (por supuesto), pero los vértices se
imprimirán en un orden diferente.
Es fácil obtener extensiones para hacer más complejo el procesamiento de
componentes conexas. Por ejemplo, insertando simplemente inval [i d]=k des-
pués de la instrucción val [k]=id se obtiene la «inversa» del array val, cuya
i d-ésima entrada es el índice del i d-ésimo vértice explorado. Los vértices de la
misma componente conexaestán contiguos en este array;el índice de cada nueva
componente conexa está dado por el valor de i d cada vez que se llama a vi -
si tar en buscar. Estos valores pueden almacenarse o utilizarse como delimi-
tadores en i nval (por ejemplo, la primera entrada de cada componente conexa
podría hacerse negativa).
k 1 2 3 4 5 6 7 8 9 10 11 12 13
nombre[k] A B C D E F G H I J K L M
val [k] 1 7 6 5 3 2 4 8 9 10 11 12 13
inval[k] -1 6 5 7 4 3 2 -8 9 -10 11 12 13
Figura 30.1 Estructurasde datos para componentesconexas.
La Figura 30.1 muestra los valores tomados por estos arrays del ejemplo si
la versión lista de adyacencia de buscar se modificara de esta manera. Nor-
malmente merece la pena utilizar tales técnicas para dividir un grafo en sus
componentes conexas y más adelante procesarlo por medio de algontmos más
CONECTIVIDAD 477
Figura 30.2 Un grafo que no es biconexo.
sofisticados,de forma que se les liberede los detallesde tratar con componentes
no conexas.
Biconectividad
A veces es útil diseñar más de una ruta entre puntos de un grafo, aunque s6lo
sea para identificar posibles fallos en los puntos de conexión (vértices)..4sí se
puede volar desde Providence a Princeton, aunque New York esté cerrado por
nieve, sin tener que ir por Philadelphia. Las principales líneas de conexión de
un circuito integrado son a menudo biconexas, por lo que el resto de1 circuito
puede continuar funcionando si falla uno de los componentes. Otra aplicación
(no muy realista, pero que da una ilustración natural del concepto)es la de ima-
ginar una situación de guerra en la que se obliga al enemigo a bombardear al
menos dos estacionespara poder cortar las líneas de ferrocarril.
Un punto de articulación en un grafo conexo es un vértice que si se sUprime
romperá el grafo en dos o más piezas. De un grafo que no tiene puntos de arti-
culación se dice que es biconexo. En un grafo biconexo, cada par de vértices
están conectados por dos caminos distintos. Un grafo que es no biconexo se di-
vide en componentes biconexas, conjuntos de nodos accesibles mutuamente por
medio de dos caminos distintos.
En la Figura 30.2 se muestra un grafo que es conexo pero no biconexo. (Este
grafo se ha obtenido del correspondiente al del capítulo anterior ariadiendo las
aristas GC, GH, JG y LG. En los ejemplos se supone que estas cuatro aristas se
han añadido al final de los datos de entrada y en el orden anterior, de tal forma,
por ejemplo, que las listas de adyaceirciasean similares a las de la Figura 29.4
con ocho nuevas entradas correspondientes a ias cuatro nuevas aristas.) Los
puntos de articulación de este grafo son A (dado que conecta a B con el resto
del grafo), H (al conectar a I con el resto del grafo), J (que conecta a K con el
478 ALGORITMOS EN C++
Figura 30.3 Búsqueda en profundidaden la biconectividad.
resto del grafo) y G (dado que el gafo se rompena en tres piezas si se borrara
G). Existen seis componentes biconexas: {A C G D E F}, {GJ L M},y los nodos
individuales B, H, I y K.
La determinación de los puntos de articulación resulta ser una simpleexten-
sión de la búsqueda en profundidad. Para comprobarlo, considérese el árbol de
búsqueda en profundidad de este grafo, mostrado en la Figura 30.3. Borrando
el nodo E, no se desconecta el grafo, dado que G y D tienen ambos enlaces de
puntos por encima de E, que proporcionan caminos alternativos para ir de di-
chos vértices a F (el padre de E en el árbol). Por el contrario, borrando G se
desconecta el grafo porque no existen caminos alternativos para ir de L o H a E
(que es el padre de G).
Un vértice x no es un punto de articulación si cada uno de sus hijos y tiene
algún nodo descendiente conectado (por medio de un enlace de puntos) a un
nodo más alto que x en el árbol, que proporciona una conexión alternativa en-
tre x e y. Esta prueba no es válida al trabajar en la raíz del árbol de búsqueda
en profundidad, porque este nodo no tiene {(ascendientesen el árbol».
La raíz es un punto de articulaciónsi tiene dos o más hijos, dado que el único
camino para conectar a hijos de la raíz pasa por ella misma. Estas pruebas se
incorporan fácilmente a la búsqueda en profundidad transformando el proce-
dimiento de exploración de nodos en una función que devuelva el punto más
alto del árbol (valor inferior de val) que se haya encontrado durante la bús-
queda, de la siguiente forma:
int v i s i t a r ( i n t k) // BP para encontrar puntos de articulación
struct nodo *t;
i n t m, min;
val [k] = ++id;
min = id;
f o r (t = ady[k]; t != z; t = t->siguiente)
{
CONECTIVIDAD 479
~~ ~ ~~ ~
i f (val [t->v] = novi sto)
m = visitar(t->v);
i f (m < min) min = m;
i f (m >= val[k]) cout <<nombre(k);
{
1
else i f (val [t->VI< min) min = val [t->VI
;
return min;
1
Este procedimiento determina recursivamente el punto más alto de árbol acce-
sible (por medio de un enlace de puntos) desde cualquiera de los descendientes
del vértice k, y utiliza esta información para determinar si k es un punto de arti-
culación. Normalmente este cálculo no implica más que comprobar si el valor
mínimo accesible desde un hijo está más alto en el árbol, o no lo está. Sin em-
bargo, se necesita una prueba extra para determinar si k es la raíz de un árbol
de búsqueda en profundidad (o, de forma equivalente, si se trata de la primera
llamada a vi s it a r para la componente conexa que contiene a k),ya que se uti-
liza en ambos casos el mismo programa recursivo. Es conveniente llevar a cabo
esta prueba fuera del vi s itar recursivo y así no aparecerá en el código que se
acaba de mostrar.
Propiedad 30.1 Las componentes biconexas de un grafo pueden determinarse
en tiempo lineal.
Aunque el programa anterior no hace más que imprimir los puntos de articu-
lación, es fácil ampliarlo, como en el caso de las componentes conexas, para
que efectúe un tratamiento adicional de los puntos de articulación y de las com-
ponentes conexas. Al ser un procedimiento de búsqueda en profundidad, el
tiempo de ejecución es proporcional a V +A .(Un programa similar, basado en
una matriz de adyacencia, se ejecutaría en O(V2)pasos.)i
Además de los tipos de aplicacionesmencionados anteriormente, en las que
las biconectividades se han utilizado para mejorar la fiabilidad, pueden ser de
una gran ayuda en la descomposición de grafos muy grandes en varias partes
más manejables. Es obvio que en muchas aplicacionespuede procesarse un grafo
muy grande, haciéndolo componente conexa a componente conexa, pero a ve-
ces es más práctico, aunque sea menos evidente, el poder procesar un grafo
componente biconexa a componente biconexa.
Algoritmos de unión-pertenencia
En algunas aplicaciones se desea simplemente conocer si un vértice x está o no
conectado a un vértice y de un grafo, sin que sea importante el camino que los
480 ALGORITMOS EN C++
conecta de hecho. Este problema se ha estudiado cuidadosamente en los últi-
mos años: los eficaces algoritmos que se han desarrollado son interesantes por
sí mismos dado que también pueden utilizarsepara el procesamiento de conjun-
tos (coleccionesde objetos). Los grafos se corresponden de forma natural con
estos conjuntos: los vértices representan a los objetos y las aristas significan «está
en el mismo conjunto que...». Así, el grafo ejemplo del capítulo anterior corres-
ponde a los conjuntos {A B C D E F G},{H I}y {JK L M}. Otro término para
definir estos conjuntos es el de clases de equivalencia. Cada componente conexa
corresponde a una clase de equivalencia diferente. El añadir una arista se co-
rresponde con la combinación de las clases de equivalencia representadas por
los vértices a conectar. El interés se centra en la pregunta fundamental «¿es x
equivalente a y?» o «¿está xen el mismo conjunto que y?». Esto se corresponde
claramente con la pregunta fundamental de los grafos «¿está el vértice x conec-
tado al vértice y?».
Dado un conjunto de aristas,se puede construir una representación por lista
de adyacencias que corresponda al gafo y utilizar la búsqueda en profundidad
para asignar a cada vértice el índice de su componente conexa, y así preguntas
tales como «¿estáx conectada a y?» pueden responderse con dos accesosa arrays
y una comparación. La característica suplementaria de los métodos que se con-
siderarán aquí es que son dinámicos: pueden aceptar nuevas aristas mezcladas
arbitrariamente con preguntas y contestar correctamente a las preguntas utili-
zando la información recibida. Por correspondencia con el problema de los
conjuntos, la adición de una nueva arista se denomina una operación de unión,
y las preguntas se denominan operaciones de pertenencia.
El objetivo es escribir una función que pueda verificar si dos vértices x e y
pertenecen al mismo conjunto (o, en representación de grafos, a la misma com-
ponente conexa) y, en caso de que sea así, que pueda unirlos en el mismo con-
junto (colocando una arista entre ellos y el grafo). En lugar de construir una
lista de adyacencia directa o cualquier otra representación de los grafos, es más
eficaz utilizar una estructura interna orientada específicamentea la realización
de las operaciones union y pertenencia. Esta estructura interna es un bosque
de árboles, uno por cada componente conexa. Se necesita poder encontrar si
dos vértices pertenecen al mismo árbol y combinar dos árboles en uno. Por for-
tuna ambas operaciones pueden implementarse eficazmente.
Para ilustrar el funcionamiento del algoritmo, se examina el bosque que se
obtiene cuando las aristas del grafo del ejemplo de la Figura 30.1 se procesan en
el orden AG AB AC LM JM JL JK ED FD HI FE AF GE GC GH JG LG. En
la Figura 30.4 se muestran los siete primeros pasos. Inicialmente, todos los no-
dos están en árbolesseparados.A continuación la arista AG provoca la creación
de un Arbol dos-nodos de raíz A. (La elección es arbitraria -igualmente se po-
dría haber tomado como raíz a G-.) Las aristas AB y AC añaden a B y C al
árbol de la misma I'orma. A continuación las aristas LM, JM, JL y JK constru-
yen un árbol que contiene a J, K, L y M, que tiene una estructura ligeramente
diferente (es de destacar que JL no contribuye con nada, dado que LM y JM
colocan a L y J en el mismo componente).
CONECTIVIDAD 481
Figura 30.4 Etapasiniciales de unión-pertenencia.
La Figura 30.5 muestra el fin del proceso. Las aristas ED, FD y HI generan
dos árboles más, quedando un bosque con cuatro árboles. Este bosque indica
que las aristas procesadas hasta este momento describen un grafo con cuatro
componentes conexas, o, de forma equivalente, que las operaciones de unión de
conjuntos efectuadas hasta el momento conducen a cuatro conjuntos {A €3 C
G}, {J K L M}, {DE F}y {H I}. Ahora la arista FE no contribuye con nada a la
estructura, dado que F y E están en la misma componente, pero la arista AF
combina los dos primeros árboles; a continuación GE y GC tampoco contri-
buyen con nada, pero GH y JG reúnen todo el bosque en un solo árbol.
Se debe enfatizar que, a diferencia de los árboles de búsqueda en profundi-
dad, la única relación entre estos árboles de unión-pertenencia y el grafo aso-
ciado con las aristas dadas es que aquéllos dividen de forma análoga a los vér-
tices en conjuntos. Por ejemplo, no hay correspondencia entre los caminos que
conectan los nodos de los árboles y los que conectan los nodos de los grafos.
¿Qué estructura de datos debe elegirse para mantener estos bosques? Aquí sólo
se recorren los árboles hacia arriba, nunca hacia abajo, por lo que es apropiada
la representación de «enlace padre» (ver el Capítulo 4). Específicamente, se
mantiene un array padre que contiene, para cada vértice, el índice de su padre
(O si es la raíz de algún árbol), como se especifica en la siguiente declaración
clase:
clase EQ
{
482 ALGORITMOS EN C++
Figura 30.5 Final de unión-pertenencia.
private:
public:
int *padre;
EQ(int t a l l a ) ;
int pertenencia(int x, int y, int union);
1;
El constructor asigna espaciopara el array padre e inicialmente coloca todos
sus valores a cero (aquí se omite el código).Se utiliza un sencillo procedimiento
pertenenci a para implementarlas dos operaciones, unión y pertenencia. Si el
tercer argumentoes O se tiene solamente una pertenencia;si es distinto de cero,
se tiene una unión. Para encontrar el padre de un vértice j, simplemente se es-
tablece j = padre [j ] y para encontrar la raíz del árbol que contiene a j se re-
CONECTIVIDAD 483
pite esta operación hasta obtener O. Esto conduce a una implementación integra
del procedimiento pertenencia:
i n t EQ::pertenencia(int x, int y, int unión)
int i = x, j = y;
while (padre[i] > O) i = padre[i];
while (padre[j] > O) j = padre[j];
if (union && (i != j ) ) padre[j] = i;
return (i != j ) ;
{
1
La función pertenenci a devuelve O si los dos vértices dados son de la misma
componente. Si no es así y el indicador uni on está activado, se colocarán dichos
vérticesen la misma componente. El método es simple: se utiliza un array pa-
dre para obtener la raíz del árbol que contiene a cada vértice, y a continuación
se comprueba que las dos raíces son idénticas. Para fusionar el árbol de raíz j
con el de raíz i, se pone simplemente padre [j ] = i .
La Figura 30.6 muestra el contenido de la estructura de datos durante el
proceso. Como de costumbre, se supone que las funciones i ndice y nombre
permiten la traducción de los nombres de los vértices en enteros entre 1 y V:
cada entrada de la tabla es el nombre de la correspondiente entrada del array
padre. Por ejemplo, después de declarar una instancia de la clase EQ (con la
declaración EQ eq (max),se comprobará si un vértice denominado x está en la
misma componente que un vértice denominado y (sin la introducción de una
arista entre ellos) llamando a la función eq .pertenenci a (indi ce(x) , in-
dice(y), O).
Esta clase está codificadade forma que puede usarse en cualquier aplicación
en la que las clases de equivalencia tengan un papel importante, no sólo en la
conectividad de grafos. El único requisito es que los elementos del conjunto
tengan nombres enteros que sirvan como índices (desde 1 hasta V). Como ya se
dijo, se pueden usar las implementaciones de diccionario anteriores para que así
sea.
El algoritmo descrito anteriormente tiene un mal comportamiento en el peor
caso porque los árboles construidos pueden estar degenerados. Por ejemplo, si
se toman las aristas en el orden AB BC CD DE EF FG GH Hi IJ ... YZ se ob-
tiene una gran cadena en la que Z apunta a Y ,Y apunta a X, etc. Para construir
este tipo de estructura se necesita un tiempo que es proporcional a V2,y para
una comprobación de equivalencia, un tiempo proporcional a V.
Para resolver este problema se han propuesto diversastécnicas. Un método
natural, que posiblemente ya se le haya ocumdo al lector, es intentar hacer lo
más «razonable» cuando se mezclsn dos árboles, en lugar de poner arbitraria-
mente padre[j] = i. Cuando se va a mezclar un árbol de raíz i con otro de
raíz j, uno de los nodos debe permanecer como raíz y el otro (y todos sus des-
484 ALGORITMOS EN C++
A B C D E F G H C J K L M
Figura 30.6 Estructurade datos unión-pertenencia.
cendientes) debe bajar un nivel en el árbol. Para hacer mínima la distancia en-
tre la raíz y la mayor parte de los nodos, parece lógico elegir como raíz el nodo
que tenga el mayor número de descendientes. Esta idea, denominada equili-
brado depeso, se implementa fácilmente manteniendo el tamaño de cada árbol
(número de descendientes de la raíz) en la entrada del array padre, para todos
los nodos raíz, codificando como números negativos a todos los nodos raíz que
puedan detectarse ascendiendo por el árbol en pertenencia.
Lo ideal sería arreglárselaspara que todas los nodos apuntaran directamente
a la raíz de su árbol. Sin embargo, independientemente de la estrategia a utili-
zar, para lograr este ideal habría que examinar al menos todos los nodos de uno
de los dos árboles a fusionar, y esto podría representaruna cantidad muy grande
comparada con los relativamente pocos nodos situados en el camino hacia la
raíz que suele examinar pertenenci a. Pero se puede hacer una aproximación
al ideal haciendo que todos los nodos que se examinan japunten hacia la raíz!
A primera vista esta operación parece ser muy drástica, pero es muy fácil de
CONECTIVIDAD 485
hacer, y no hay nada sagrado en lo que respecta a la estructura de estos árboles:
si es factible modificarlos para hacer que el algoritmo sea más eficaz, debe ha-
cerse. Este método, denominado compresión de caminos, es fácil de implemen-
tar haciendo otra pasada a través de cada árbol, después de haber encontrado la
raíz, y fijando la entrada padre de cada vértice que se ha encontrado a lo largo
del camino que apunta a la raíz.
La combinación de los métodos de equilibrado de peso y compresión de ca-
minos asegura que los algoritmos se ejecuten más rápidamente. La siguiente
implementación muestra que el código extra es un precio a pagar muy pequeño
para prevenirse de los casos degenerados.
i n t EQ::pertenencia(int x, i n t y , i n t union)
int i = x, j = y;
while (padre[i] > O) i = padre[i];
while (padre[j] > O) j = padre[j];
while (padre[x] > O)
while (padre[y] > O)
i f (unión && ( i != j ) )
{
{ t = x; x = padre[x]; padre[t] = i ; }
{ t = y; y = padre[y]; padre[t] = j ; }
i f (padre[j] < padre[i])
el se
{ padre[j] += padre[i] - 1; padre[i] = j ; }
{ padre[i] += padre[j] - 1; padre[j] = i ; }
return (i != j ) ;
1
La Figura 30.7 muestra los primeros ocho pasos de la aplicación del método a
los datos del ejemplo y la Figura 30.8 muestra el final del proceso. La longitud
media de camino del árbol resultante es 31/13 = 2,38, a comparar con el valor
de 38/13 = 2,92 de la Figura 30.5. Para las cinco primeras aristas el bosque
resultante es el mismo que el de la Figura 30.4; sin embargo, las tres últimas
aristas producen un árbol «plano» que contiene a J, K, L y M a causa de la regla
del equilibrado de peso. Los bosques de este ejemplo son tan planos que todos
los vértices implicados en las operaciones de unión están en la raíz o justo de-
bajo -no se utiliza la compresión de caminos (ya que podría hacer que los ár-
boles fueran todavía más planos)-. Por ejemplo, si la última unión fue FJ y no
GL, entonces al final F acabaría siendo un hijo de A.
La Figura 30.9 proporciona el contenido del array padre según se va cons-
truyendo el bosque. Para mayor claridad de la tabla, cada entrada positiva i se
ha reemplazado por la i-ésima letra del alfabeto (el nombre del padre) y a cada
entrada negativa se ha aplicado el complemento para obtener un entero posi-
tivo (el peso del árbol).
486 ALGORITMOS EN C++
Figura 30.7 Primeros pasos de unión-pertenencia(equilibrado,con compresión de
caminos).
Se han desarrollado otras muchas técnicas para evitar las estructuras dege-
neradas. Por ejemplo, la compresión de caminos tiene el inconveniente de ne-
cesitar una segunda pasada a través del árbol. Otra técnica, denominada divi-
sión, hace que cada nodo apunte a su abuelo en el árbol. Otra más, la escisión,
es como la anterior, pero se aplica solamente a uno de cada dos nodos del ca-
mino de búsqueda. Cualquiera de estas técnicas puede utilizarseconjuntamente
con el equilibrado de peso o con el equilibrado de altura, que es similar pero
utiliza la altura del árbol para decidir cómo fusionar los árboles.
¿Qué método se debe elegir entre todos éstos? y ¿hasta que punto son «pla-
nos» los árbolesgenerados? El análisisde este problema es muy difícil dado que
el rendimiento depende no solamente de los parámetros V y A, sino también
del número de operaciones de pertenencia y, lo que es peor, del orden en que se
efectúan las operaciones de unión y pertenencia. A diferencia de la ordenación,
en la que los archivos reales que aparecen en la práctica son a menudo casi
«aleatonos»,es difícil ver los modelos de grafos y consultas que pueden apare-
cer en la práctica. Por esta razón los algoritmos que tienen un buen comporta-
miento en el peor caso se prefieren a los de unión-pertenencia (y a otros algo-
ritmos sobre grafos), aunque esto puede ser un enfoque superconservador.
Incluso si sólo se considera el peor caso, el análisisde los algoritmos de unión-
pertenencia es extremadamentecomplejo e intrincado. Esto puede deducirse de
la naturaleza de los resultados, que sin embargodan una idea clara del compor-
tamiento de los algoritmos en una situación práctica.
CONECTIVIDAD 487
El
Figura 30.8 Final de unión-pertenencia(equilibrado,con compresión de caminos).
Propiedad 30.2 Si se utiliza un equilibrado depeso o de altura conjuntamente
con la compresión, división o escisión, el número total de operaciones necesarias
para la construcción de una estructura utilizandoA aristas es casi (perono exac-
tamente) lineal.
Con más precisión, el número de operacionesnecesariases proporcionala Aa(A),
donde a(A)es una función que crece tan lentamente que a(A)<4,a menos que
A sea inferior a un valor tan grande que cuando se tome lgA, a continuación se
tome el Ig del resultado, y así otra y otra vez, repitiéndolo 16 veces, todavía se
obtiene un número imayor que l! Este número es increíblemente grande: es
bastante seguro suponer que la media de tiempo de ejecución de cada operación
de unión y pertenencia es constante. Este resultado se debe a R. E. Tajan, quien
además demostró que ningún algoritmo para este problema (dentro de una clase
48% ALGORITMOS EN C++
A B C D E F G H I J K L M
Figura 30.9 Estructurade datos de unión-pertenencia(equilibrada,con compresiónde
caminos).
general de ellos) puede hacerlo mejor que Aa(A),por lo que esta función es in-
trínseca al prob1ema.i
Una importante aplicación práctica de los algoritmos de unión-pertenencia
es determinar si un grafo de V vértices y A aristas es conexo en tiempo propor-
cional a V (y casi en tiempo lineal). Ésta es una ventaja sobre la búsqueda en
profundidad en algunoscasos: aquí no se necesita almacenar las aristas. Por ello
la conectividad de un grafo con miles de vértices y millones de aristas puede
determinarse por medio de uEa pasada rápida a través de las aristas.
CONECTIVIDAD 489
Ejercicios
1. Encontrar los puntos de articulación y las componentes biconexas del grafo
2. Dibujar el árbol de búsqueda en profundidad del grafo descrito en el ejer-
3. ¿Cuál es el níimero mínimo de aristas que se necesitan para construir un
4. Escribir un programa para imprimir las componentesbiconexas de un grafo.
5. Dibujar e
¡ bosque de unión-pertenencia construidopara el ejemplo del texto,
pero suponiendo que pertenencia se cambia para poner a[i]=j en lugar
de a[j]=i.
6. Resolver el ejercicio anterior, suponiendo además que se utiliza la compre-
sión de caminos.
7. Dibujar el bosqur de unión-pertenencia construido por las aristas AB BC
CD DE EF ... YZ, suponiendo en primer lugar que se utiliza el método de
equilibrado de peso sin compresión de caminos y después este último sin
equilibrado de peso.
8. Resolver el ejercicio anterior suponiendo que se utilizan a la vez los dos
métodos de compresión de caminos y de equilibrado de peso.
9. Implementar las variantes de unión-pertenencia descritas en el texto y de-
terminar empíricamente la comparación de sus rendimientos para l .O00
operaciones unión con argumentos aleatonos enteros comprendidos entre
10. Escribir un programa para generar un grafo aleatorio conexo con V vértices
por generación de pares de enteros aleatonos entre 1 y V.Estimar en fun-
ción de V qué número de aristas se necesitan para producir un grafo co-
nexo.
que se obtiene al suprimir GJ y añadir IK al gafo de ejemplo.
cicio 1.
grafo biconexo con V vértices?
1 y 100.
Algoritmos en C++.pdf
31
Grafos ponderados
Con frecuencia se desea modelar problemas prácticos utilizando grafos en los
que se asocia a las aristas unos pesos o costes. En un mapa de líneas aéreas, en
el que las aristas representan rutas de vuelo, los pesos pueden representar dis-
tancias o tarifas. En un circuito eléctrico, en el que las aristas representan las
conexiones, los pesos naturales a utilizar son la longitud o el precio de los ca-
bles. En el diagrama de un proyecto los pesos pueden representar el tiempo o el
coste de realización de las tareas o bien el retraso en llevarlas a cabo.
Estas situaciones hacen aparecer de forma natural cuestiones como el mi-
nimizar costes. En este capítulo se examinarán detalladamente los algoritmos
de dos de estos problemas: ((encontrarla forma de conectar todos los puntos al
menor coste» y ((encontrarel camino de menor coste entre dos puntos dados)).
El primero, que evidentemente se utiliza para los grafos que representan a cosas
similaresa circuitos eléctricos, se denomina el problema del árbol de expansión
mínimo; el segundo, que evidentemente se utiliza para los grafos que represen-
tan cosas parecidas a los mapas de líneas aéreas, se denomina el problema del
camino más corto. Estos problemas representan a toda una variedad de los que
se suelen encontrar en los grafos ponderados.
Los algoritmos de este capítulo implican búsquedas a través de los grafos y
a veces se hacen pensando intuitivamente en los pesos como si fueran distan-
cias: se habla de «el vértice más próximo a XB, etc. De hecho, esta predisposi-
ción forma parte de la propia nomenclatura del problema del camino más corto.
A pesar de ello, es importante recordar que los pesos no son necesariamente
proporcionales a la distancia, y podrían representar tiempos o costes o alguna
cosa completamentediferente. Cuando los pesos realmente representen las dis-
tancias, puede que sean más apropiados otros tipos de algoritmos. Este asunto
se presentará con mayor detalle al final del capítulo.
La Figura 31.1 muestra un ejemplo de grafo no dirigido ponderado. La forma
de representar a los grafos ponderados es obvia: en la representación por matriz
de adyacencia, la matnz puede contener pesos de aristas en lugar de valores
booleanos y en la representación por estructurasde adyacencia se puede añadir
491
492 ALGORITMOS EN C++
Figura 31.1 Un grafo no dirigido ponderado.
un campo a cada elemento de la lista (que representa una arista), a manera de
peso. Se supone que todos los pesos son positivos. Algunos algoritmos se pue-
den adaptar para manipular pesos negativos, pero se hacen significativamente
más complejos. En otros casos, los pesos negativos cambian la naturaleza del
problema de forma esencial y se necesita recurrir a algoritmos mucho más so-
flsticados que los que se consideran aquí. Un ejemplo del tipo de dificultades
que pueden encontrarse aparece si se considera la situación en la que la suma
de los pesos de las aristas de un ciclo es negativa: un camino infinitamente corto
podría engendrarse simplemente dando vueltas alrededor del ciclo.
Se han desarrollado varios algoritmos «clásicos»para resolver los problemas
del árbol de expansión mínimo y del camino más corto. Estos métodos se en-
cuentran entre los más célebres y los más utilizados de los algontmos de este
libro. Como se vio anteriormente al estudiar antiguos algoritmos, los métodos
clásicosproporcionan un enfoque general, pero las modernas estructuras de da-
tos ayudan a proporcionar implementaciones compactas y eficaces. En este ca-
pítulo se verá cómo utilizar colas de prioridad en la generalización de los mé-
todos de recorrido de grafos del Capítulo 29 para resolver aabos problemas de
forma eficaz en los grafos dispersos;más adelante se verá la relación entre este
método y los clásicos de los grafos densos, y por último se verá un método para
resolver el problema del árbol de expansión mínimo que utiliza un enfoque to-
talmente diferente.
Árbol de expansión mínimo
Un árbol de expansión mínimo de un grafo ponderado es una colección de aris-
tas que conectan todos los vértices y en el que la suma de los pesos de las aristas
es al menos inferior a la suma de los pesos de cualquier otra colección de aristas
que conecten todos los vértices. El árbol de expansión mínimo no es necesaria-
GRAFOS PONDERADOS 493
Figura 31.2 Árboles de expansión mínimos.
mente único: la Figura 3 1.2muestra los árbolesde expansión mínimos del grafo
del ejemplo. Es fácil demostrar que la «colección de aristas))de la anterior de-
finición deben formar un árbol de expansión: si existe algún ciclo, se puede su-
primir alguna arista del mismo para dar una colección de aristas que todavía
conectan a los vértices, pero con un peso inferior.
Se vio en el Capítulo 29 que muchos de los procedimientos de recorrido de
grafos construyen un árbol de expansión de dichos grafos. ¿Qué podría hacerse
para que, en un grafo ponderado, el árbol construido sea el de peso total más
bajo? Existen varias formas de hacerlo, todas basadas en la siguiente propiedad
general de los árboles de expansión mínimos.
Propiedad31.1 Duda una división cualquierade los vértices de un grafo en dos
conjuntos, el árbol de expansión mínimo contiene la menor de las aristas que
conectan un vértice de uno de los conjuntos con un vértice del otro.
Por ejemplo, dividir los vértices del grafo del ejemplo en dos conjuntos {A B C
D} y {EF G H I J K L M}, implica que DF debe estar en el árbol de expansión
mínimo. Esta propiedad es fácil de demostrar por reducción al absurdo. Se de-
nomina a a la menor de las aristas que conectan a los dos conjuntos y se supone
que a no está en el árbol de expansión mínimo. A continuación se considera el
grafo formado al añadir a al árbol de expansión mínimo. Este grafo tiene un
ciclo, que debe contener alguna otra arista diferente de a que conecte a los dos
conjuntos. Suprirniendo esta arista y añadiendo a, se obtiene un árbol de ex-
pansión de menor peso, lo que contradice el supuesto de que a no se encuentra
en el árbol de expansión mínim0.i
Por lo tanto se puede construir el árbol de expansión mínimo comenzando
en cualquier vértice y tomando siempre el vértice «más próximo))de todos los
que ya se han elegido. En otras palabras, se busca la arista de menor peso entre
todas las que conectan vértices que ya están en el árbol con vertices que no lo
están todavía, y después se añade al árbol la arista y el vértice a los que conduce
la anterior. (En caso de empate, se puede elegir cualquiera de las anstas «em-
patadas)).)La propiedad 3 1.1 garantiza que cada arista añadida forma parte del
árbol de expansión mínimo.
494 ALGORITMOS EN C+t
Figura 31.3 Pasos inicialesde la construcción de un árbol de expansión mínimo.
La Figura 31.3 muestra los cuatro primeros pasos cuando esta estrategia se
utiliza en el grafo ejemplo, comenzando con el nodo A. El vértice más «pró-
ximo» a A (conectado con una arista de peso mínimo) es B, por lo que AB es
el árbol de expansión mínimo. De todas las aristas adyacentes a AB, la BC es la
de menor peso, así que se añade ai árbol, y el vértice C es el siguiente a explorar.
Entonces, el vértice más próximo a A, B o C es ahora D, por lo que BD se añade
al árbol. La continuación de este proceso se muestra en la Figura 31.5, después
de presentar la implementación.
¿Cómo implementar realmente esta estrategia? Hasta ahora el lector habrá
reconocido seguramente la estructura básica de: vértices del árbol, del margen y
no vistos que caracterizan a las estrategiasde búsqueda en profundidad y en an-
chura del Capítulo 29. Resulta que el mismo método sirve, utilizando una cola
de prioridad (en lugar de una pila o de una cola), para almacenar los vértices
del margen.
Búsqueda en primera prioridad
Recuérdese del Capítulo 29 que la búsqueda en grafos puede describirse en tér-
minos de la división de los vértices en tres conjuntos: vértices del árbol, cuyas
aristas ya han sido examinadas, vértices del margen, que están en la estructura
de datos esperando su tratamiento, y vértices no vistos, a los que todavía no se
ha llegado. El método fundamental de búsqueda en grafos que se utiliza aquí
está basado en el paso «mover un vértice (denominadox desde el margen hacia
GRAFOS PONDERADOS 495
Figura 31.4 Contenido de la cola de prioridad durante la construcción del árbol de
expansión mínimo.
el árbol, y colocar después en el margen a cualquiera de los vértices no vistos
adyacentes a XP. Se utiliza el término de búsqueda en primera prioridad para
referirse a la estrategia general de la utilización de una cola de prioridad para
decidir qué vértice retirar del margen. Esto permite una gran flexibilidad. Como
se verá, varios de los algoritmos clásicos (incluyendolas búsquedas en anchura
y profundidad) se diferencian solamente en la elección de la prioridad.
Para la construcción del árbol de expansión mínimo, la prioridad de cada
vértice del margen debe ser igual a la longitud de la arista más pequeña que lo
conecta al árbol. La Figura 31.4muestra el contenido de una cola de prioridad
durante el proceso de construcción presentado en las Figuras 31.3 y 31.5. Para
mayor claridad, los elementos de la cola se muestran en orden creciente. Esta
implementaciónpor dista ordenada»de las colas de prioridad podría ser apro-
piada para grafos pequeños, pero para los grandes deben utilizarse montículos
para asegurar que todas las operaciones se puedan finalizar en O(logni? pasos
(véase el Capítulo 11).
En primer lugar, se consideran grafos dispersos con representación por lista
de adyacencia. Como se ha mencionado anteriormente, se añade un campo de
peso w al registro de a ristas (modificando el código de entrada para poder leer
pesos). Entonces, si se utiliza una cola de prioridad para el margen, se tiene la
siguiente implementación:
CP cp(maxV);
v i s i t a r ( i n t k) / / BPP, l i s t a s de adyacencia
s t r u c t nodo *t;
if (cp.actualizar(k, -novisto) != O) padre[k] = O;
whi 1e ( !cp. vacío())
{
id++; k = cp.suprimir(); val[k] = -val[k];
if(val [k] == -novisto) val [k] = O;
for (t = ady[k]; t != z; t =t->siguiente)
if (val [t->VI < O)
496 ALGORITMOS EN C++
i f (cp. actual i z a r (t->v, prioridad))
val [t->VI
= -(prioridad);
padre[t->VI = k;
{
1
Para calcular el árbol de expansión mínimo, hay que reemplazar las dos ocu-
rrencias de p r i o r i dad por t-> w. Se utiliza la cl ase diccionario cola de prio-
ridad descrita en el Capítulo 11: la función actual izar es una primitiva que se
implementa fácilmente y cuyo objetivo es asegurar que el vértice pasado como
parámetro aparezca en la cola al menos con la prioridad dada: si el vértice no
está en la cola, se aplica una inserci on, y si el vértice está, pero tiene un gran
valor de prioridad, entonces se utiliza cambi a r para cambiar la prioridad. Si se
ha hecho cualquier cambio (o inserción o cambio de prioridad), la función ac-
tual izar devuelveun valor distinto de cero. Esto permite que el programa an-
terior mantenga actualizados los arrays val y padre. El array val podría con-
tener él mismo realmente la cola de prioridad de una forma «indirecta»;en el
programa anterior se han separado las operaciones sobre la cola con prioridad,
para una mayor claridad.
Aparte del cambio de la estructura de datos por una cola de prioridad, este
programa es prácticamente el mismo que se ha utilizado en las búsquedas en
anchura y profundidad, con dos excepciones. Primero, se necesita una acción
suplementaria cuando se encuentra una arista que ya está en el margen: en las
búsquedas en anchura y profundidad se ignoran tales aristas, pero en el pro-
grama anterior se necesita comprobar si la nueva arista rebaja la prioridad. Se
garantiza así, como es de desear, que siempre se explore el siguiente vértice del
margen que es el más próximo al árbol. Segundo, este programa sigue explíci-
tamente la pista del árbol actualizando el array padre que almacena al padre de
cada nodo en el árbol de búsqueda en primera prioridad (el nombre del nodo
que ha provocado su desplazamiento desde el margen hasta el árbol). También,
para cada nodo k del árbol, val [k] es el peso de la arista entre k y padre [k].
Los vértices del margen se marcan como antes con valores negativos en val ;el
centinela novi Sto toma valores negativos grandes (la razón de esto se hará evi-
dente más adelante).
La Figura 31.5 muestra el final de la construcción del árbol de expansión
mínimo del ejemplo. Como es habitual, los vértices del margen se dibujan como
cuadrados, los vértices del árbol como círculosy los vértices no vistos como cír-
culos sin etiqueta. Las aristas de los árboles se representan por medio de líneas
gruesas en negro, y la arista más pequeña que conecta a cada vértice del margen
con el árbol está sombreada. Se anima al lector a seguir la construcción del ár-
bol utilizando las Figuras 31.4 y 31.5. En particular hay que destacar cómo se
añade al irbol el vértice G después de haber estado en el margen durante varias
GRAFOC PONDERADOS 497
Figura 31.5 Finalde la construcción del árbol de expansión mínimo.
etapas. Inicialmente, la distancia desde G al árbol es 6 (por causa de la arista
GA). Después de añadir L al árbol, GL disminuye esta distancia hasta 5, y a
continuación, después de añadir E, la distancia finalmente baja hasta 1 y G se
añade al árbol antes de J. La Figura 3 1.6 muestra la construcción de un árbol
de expansión mínimo para el gran grafo «laberinto» anterior, ponderado por la
longitud de sus aristas.
Propiedad 31.2 La búsqueda en primera prioridad en grafos dispersos cons-
truye el árbol de expansión mínimo en O((A+v)logv) etapas.
Se aplica la propiedad 31.1: los dos conjuntos de nodos en cuestión son los no-
dos explorados y los sin explorar. En cada etapa se elige la arista más pequeña
que conecta un nodo explorado con un nodo del margen (no existen aristas que
conecten nodos exploradoscon nodos no vistos).Asi, por la propiedad 31.1, cada
arista que se elige está en el árbol de expansión mínimo. La cola de prioridad
contiene solamente vértices; si se implementa como un montículo (véaseel Ca-
498 ALGORITMOS EN C++
Figura 31.6 Construcción de un granárbol de expansión minimo.
pítulo 11), entonces cada operación necesita O(1ogV) pasos. Cada vértice con-
duce a una inserción y cada arista a una operación de cambio de pri0ridad.i
Se verá posteriormente que este método también resuelve el problema del
camino más corto, si se hace una elección apropiada de la prioridad. También
se verá que una implementación diferente de la cola de prioridad puede dar un
algoritmo en V2,que es apropiado para los grafos densos. Esto es equivalente a
un antiguo algoritmo que data al menos de 1957: para el árbol de expansión
mínimo se atribuye normalmente a R. Prim, y para el camino más corto suele
atribuirse a E. Dijkstra. Por coherencia, aquí se hará referencia a estas solucio-
nes (para los grafos densos) como el «algoritmo de Prim» y el «algoritmo de
Dijkstra) respectivamente, y se hará referencia al método anterior (para grafos
dispersos)como la «solución de la búsqueda en primera prioridad)).
La búsqueda en primera prioridad es una buena generalización de las bús-
quedas en anchura y en profundidad, porque estos métodos pueden deducirse
por medio de una adecuada elección de la prioridad. Como i d aumenta desde
1 hasta Vdurante la ejecución del algoritmo, esto se puede utilizar para asignar
prioridades únicas a los vértices. Si se cambian las dos ocurrencias de pr i ori -
dad en 1i stasbpp por V - id, se obtiene una búsqueda en profundidad, dado
que los nodos que se han encontrado más recientemente tienen la prioridad más
alta. Si se utiliza i d por pri oridad se obtiene una búsqueda en anchura, por-
que los nodos más antiguos tienen la prioridad más alta. Estas asignacionesde
prioridad hacen que las colas de prioridad operen como si fueran pilas y colas.
GRAFOS PONDERADOS 499
Figura 31.7 Pasosiniciales del algoritmo de Kruskal.
Método de Kruskal
Una aproximación del todo diferente para encontrar el árbol de expansión mí-
nimo consiste simplementeen añadir aristas, una a una, utilizando en cada paso
la arista más pequeña que no forme un ciclo. Dicho de otra manera, el algo-
ritmo comienza con un bosque de árboles A
! en N pasos combina dos árboles
(utilizando la arista más pequeña posible)hasta que no exista mas que un árbol
izquierdo. Este algoritmo data por lo menos de 1956 y se suele atribuir a J.
Kruskal.
Las Figuras 31.7 y 31.8 muestran la operación de este algoritmo en el grafo
del ejemplo. Las primeras aristas que se eligen son aquellas cuya longitud es la
más pequeña (1) en el grafo. Después se ensayan las aristas de longitud 2; se
observa, en particular, que FE se examina, pero no se incluye, dado que forma
un ciclo con las aristas que ya se sabía que están en el árbol. Las componentes
no conexas evolucionan progresivamente hacia un árbol, en contraste con la
búsqueda en primera prioridad, en la que el árbol «engorda» arista a arista.
La implementación del algoritmode Kruskal se puede ir componiendo a base
de programas que ya se han estudiado. Primero se necesita considerar las aris-
tas, una a una, por orden creciente de sus pesos. Una posibilidad sería la de or-
denarlas simplemente, pero parece más conveniente utilizar una cola de prio-
ridad, sobre todo porque no se necesita examinar todas las aristas. Esto se
presenta con más detalle posteriormente. A continuación se necesita poder
comprobar si una arista dada, al añadirse a las otras que ya se han cogido, en-
500 ALGORITMOS EN C++
Figura 31.8 Finaldel algoritmo de Kruskal.
gendra un ciclo. Las estructuras de unión-pertenencia que se presentaron en el
capítulo anterior están diseñadas precisamente para esta tarea.
Ahora, la estructura de datos apropiada para el grafo es tan sólo un array e
con una entrada por cada arista. Éste se puede construir fácilmente a partir de
la representación por listas de adyacencia o por matriz de adyacencia del grafo
con una búsqueda en profundidad o algún procedimiento más simple. Sin em-
bargo, en el programa siguiente se llena el array directamente it partir de los da-
tos. Se utiliza la cl ase diccionario cola de prioridad CP del Capítulo 11, con las
prioridades de los campos de ponderación del array e y una clase de equivalen-
cia EQ basada en el procedimiento pertenencia del Capítulo 30, para verificar
los ciclos. El programa llama simplemente al procedimiento ari s taencon-
trada para cada arista del árbol de expansión; con un poco más de trabajo se
podrían construir un array padre u otra representación.
void kruskal()
int i , m, V, A;
{
GRAFOS PONDERADOS 501
struct arista
struct arista e[maxA] ;
CP cp(maxA); EQ eq(maxA);
cin >> V >> A;
for (i = 1; i >= A; i++)
for (i= 1; i >= A; i++)
for (i= O; i < V-1; )
{ char vl, v2; int w; };
cin >> e[i].vl >> e[i].v2 >> e[i].w;
cp.insertar(i, e[i] .w);
i
if (cp.vacio()) break; else m = cp.eliminar();
if (cp.encontrar( i ndice( e[m] .vi) , i ndice( e[m] .v2,1))
{ cout << e[m] .vi << e[m] .v2 << I I ; i++;
};
I
El proceso puede terminar de dos formas. Si se encuentran V- 1 aristas, enton-
ces se tiene un árbol y se puede parar. Si en primer lugar se vacía la cola de
prioridad, entonces hay que examinar todas las aristas sin encontrar e
! árbol de
expansión: esto sucederá si el grafo es no conexo. El tiempo de ejecuciónde este
programa está dominado por el consumo de tiempo al procesar las aristas de la
cola de prioridad.
Propiedad 31.3 El algoritmo de Kruskal construye el árbol de expansión mí-
nimo de un grafo en O(A1ogA) pasos.
La exactitud de este algoritmo se deriva también de la propiedad 31.1. Los dos
conjuntos de vértices en cuestión son los conectados a las aristas elegidas para
el árbol y los que todavía no se han tocado. Cada arista que se añade es la más
pequeña de las que enlaza vérticesde los dos conjuntos. El peor caso es un grafo
que es no conexo, en el que se debe examinar todas las aristas. Incluso en un
grafo conexo el peor caso seguirá siendo el mismo, porque el grafo puede estar
compuesto de dos agrupamientos de vértices, todos ellos conectados por aristas
muy pequeñas, y con una única arista muy grande que conecta a los dos agru-
pamientos. Entonces la arista más grande del grafo está en el árbol de expansión
mínimo, pero será la última en salir de la cola de prioridad. Para grafos repre-
sentativos, se debe esperar a que el árbol de expansión esté completo (tiene so-
lamente V-l vértices) antes de obtener la arista más grande del grafo, pero la
construcción inicial de la cola de prioridad llevará siempre un tiempo propor-
cional a A (véase la propiedad 11 . 2 ) ~
La Figura 31.9 muestra la construcción de un gran árbol de expansión mí-
502 ALGORITMOSEN C++
Figura 31.9 Construcción de un gran árbol de expansión mínimo con el algoritmo de
Kruskal.
nimo con el algoritmo de Kniskal. Este diagrama muestra claramente cómo el
método selecciona en primer lugar a todas las aristas pequeñas: el método aña-
dirá las aristas más largas (diagonales)en último lugar.
En vez de utilizar colas de prioridad se podría simplemente ordenar las aris-
tas por las ponderaciones inicialesy después tratarlas en orden. También,la ve-
rificación de ciclos puede hacerse en un tiempo proporcional a AlogA con una
estrategia mucho más simple que la de unión-pertenencia, lo que proporciona
un algoritmo de árbol de expansión mínimo que siempre lleva AlogA pasos. Éste
es el método propuesto por Kniskal, pero en este libro se hace referencia a la
versión modernizada anterior, que utiliza colas de prioridad y estructuras de
unión-pertenencia,denominándola ((algoritmode Kniskal».
Ell camino más corto
El problema del camino más corto consiste en encontrar entre todos los cami-
nos que conectan a dos vértices x e y dados de un grafo ponderado el que cum-
pla con la propiedad de que la suma de las ponderaciones de todas las aristas
sea mínima.
Si las ponderaciones son todas igual a 1, entonces el problema sigue siendo
interesante: ahora consiste en encontrar el camino que contenga al mínimo nú-
mero de aristas que conecten a x e y. Además, ya se ha tratado un algoritmo
que resuelve este problema: la búsqueda en anchura. Es fácil de demostrar por
inducción que dicha búsqueda, si comienza en x, explorará en pnmer lugar to-
dos los vértices que se puedan alcanzar desde x con una arista, después todos
GRAFOS PONDERADOS 503
Figura 31.10 Árboles de expansióndel camino más corto.
los vértices que se puedan alcanzar con dos aristas, etc., explorando todos los
vértices que se puedan alcanzar con k aristas antes de encontrar alguno que ne-
cesite k + I aristas. Así, cuando se alcance y por primera vez, se ha encontrado
el camino más corto desde x porque ningún otro camino más corto puede al-
canzar y (véase la Figura 29.14).
En general,el caminodesde x a y puede tocar a todos los vértices, por lo que
normalmente se considera el poblema de encontrar el más corto de los cami-
nos que conectan a un vértice dado x con todos los otros vértices del grafo. Una
vez más sucede que el problema es fácil de resolver con el algoritmo de reco-
mdo de grafo en primera prioridad dado anteriormente.
Si se dibuja el camino más corto desde x a todos los otros vértices del gafo,
entonces se obtiene claramente que no hay ciclos, y se tiene un árbol de expan-
sión. Cada vértice conduce a un árbol de expansión diferente; por ejemplo, la
Figura 31.10 muestra los árboles de expansión de los caminos más cortos para
los vértices A, G y M del grafo de ejemplo que se ha estado utilizando.
La solución de la búsqueda en primera prioridad para este problema es vir-
tualmente idéntica a la solución del árbol de expansión mínimo: se construye el
árbol por el vértice x añadiendoa cada paso el vértice del margen que sea el más
próximo a x (antes, se añade el más próximo al árboi). Para encontrar cuál de
los vértices del margen es el más próximo a x,se utiliza el array val :para cada
vértice k del árbol, val [k] es la distancia entre ese vértice y x,utilizando el ca-
mino más corto (que debe estar formado por los nodos del árbol). Cuando se
añade k al árbol, se actualiza el margen recomendo la lista de adyacencia de k.
Para cada nodo t de la lista, la distancia más corta de x a t->v a través de k es
val [k] +t->v. Así, el algoritmo se implementade forma trivial, utilizando esta
cantidad en pri ori dad en el programa de recorrido de grafos en primera pno-
ridad.
La Figura 3 1.1 1 muestra los primeros cuatro pasos de la construcción del
árbol de expansión del camino más corto para el vértice A del ejemplo. Primero
se explora el vértice más próximo a A, que es B. C y F están a la distancia 2 de
A, por lo que se exploran a continuación (en el orden que la cola de prioridad
devuelve para ellos, en este caso C y después F).El final del proceso se muestra
en la Figura 31.12 y el contenido de la cola de prioridad durante las búsqueda
se muestra en la Figura 31.13.
504 ALGORITMOS EN C++
Figura 31.11 Pasos inicialesde la construcción de un árbol de camino más corto.
A continuación, D puede conectarse a F o a B para obtener un camino de
distancia 3 a A. (El algoritmo conecta D a B porque B se puso en el árbol antes
que F; así, D estaba ya en el margen cuando F se puso en el árbol, y F no pro-
porciona un camino más corto hacia A.) Después se añaden al árbol L E M G
J K I, en orden creciente, de su distancia mínima a A. Así, por ejemplo, H es el
nodo más lejano desde A: el camino AFEGH tiene un peso total de 8. No existe
el camino más corto hacia H, y el camino más corto desde A a todos los otros
nodos no es el más largo.
La Figura 31.14 muestra los valores finales de los arrays padre y val para
el ejemplo. Así el camino más corto desde A a H tiene un peso total de 8 (en-
contrado en val [8], la entrada para H) y va desde A a F a E a G y a H (encon-
trado leyendo al revés en el array padre, comenzando en H). Obsérvese que este
programa depende de que la entrada de val para la raíz sea cero, la convención
que se adoptó para 1istasbpp.
Propiedad 31.4 La búsqueda en primera prioridad en un grafo disperso cal-
cula el árbol de camino más corto en O((A+ V)logv) pasos.
La demostración de este algoritmo se puede hacer de forma similar a la propie-
dad 31.1. Con una cola de prioridad implementada, utilizando montículos como
en el Capítulo 12, la búsqueda en primera prioridad puede siempre asegurarse
que funcionará en el número máximo de pasos mencionado, sin importar qué
regia de prioridad se uti1ice.i
GRAFOS PONDERADOS 505
Figura 31.12 Final de la construcción de un árbol de camino más corto.
Más adelante se verá cómo una implementacióndiferente de la cola de prio-
ridad puede dar un algoritmo en V
' que es apropiado para los grafos densos.
Para el problema del camino más corto, esto se reduce a un método que data al
menos de 1959 y que suele atribuirse a E. Dijkstra.
La Figura 3 1.15 muestra un árbol de camino más corto de gran tamaño.
Como antes, las longitudes de las aristas se utilizan en este grafo como pesos,
por lo que la solución llega a encontrar el camino de longitud mínima desde el
E l 2
m
4
a
4 a
5 pJ5
2 @ 5 @ 5 l j g 4 a
4 5 a
5 m
6 0 7 m S
m*
Figura 31.13 Contenido de la cola de prioridad durante la construcción del árbol de
camino más corto.
506 ALGORITMOS EN C++
k 1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3
nombre(padre[k]) A B B F A E G K G I F L
val [k] O 1 2 3 4 2 5 8 8 6 7 4 5
Figura 31.14 Representacióndel árbol de expansión del camino más corto.
nodo inferior izquierdo a todos los demás. Posteriormente, se presentará una
mejora que puede ser apropiada para tales grafos. Pero incluso en este grafo po-
dría ser apropiado utilizar otros valores para los pesos: por ejemplo, si este grafo
representa un laberinto (véase el Capítulo 29), el peso de una arista puede re-
presentarse por la distancia del propio laberinto, no por los «atajos» dibujados
en el grafo.
Árbol de expansión mínimo y camino más corto
en grafos densos
Para un gafo representado por una matriz de adyacencia, lo mejor es utilizar
una representación por array no ordenadopara la cola de prioridad, de manera
que se obtenga un tiempo de ejecución V2para cualquier algoritmo de recomdo
del grafo en primera prioridad, Esto se hace combinando el bucle de actualiza-
ción de las prioridades y el de encontrar el mínimo: cada vez que se mueve un
vértice del margen, se pasa a través de todos los vértices, actualizando sus prio-
ridades si es necesario y siguiendo la pista del valor mínimo encontrado. Esto
proporciona un algoritmo lineal para la búsqueda en primera prioridad en gra-
Figura 31.15 Construcción de un gran árbol del camino mas corto.
GRAFOS PONDERADOS 507
fos densos (y también para los problemas del árbol de expansión mínimo y el
camino más corto).
Específicamente, se gestiona la cola de prioridad en el array val (esto tam-
bién se puede hacer en 1istasbpp, como se presentó antes),pero se implemen-
tan las operaciones de cola de prioridad directamente en vez de utilizar montí-
culos. Como antes, el signo de las entradas de v a l indica si el vértice
correspondiente está en el árbol o en la cola de prioridad. Todos los vértices co-
mienzan en la cola de prioridad y tienen la prioridad del centinela novisto.
Para cambiar la prioridad de un vértice, simplemente se asigna la nueva pno-
ridad en la entrada de val de este vértice. Para eliminar el vértice de prioridad
más alta, se explora a través del array val para encontrar el vértice con el ma-
yor valor negativo (el más próximo a O), y entonces se toma su complemento
en val. Después de hacer estas modificacionesmecánicas en el programa 1is-
tasbpp que se ha estado utilizando, se obtiene el programa compactosiguiente:
void buscar() // BPP, matriz de adyacencia
i
i n t k, t, min = O;
f o r ( k = 1; kn<= V; ktt)
{ val[k] = novisto; padre[k] = O; }
val[O] = novisto-1;
f o r ( k = 1; k != O; k = min, min = O)
f o r ( t
i f (va
i f
i f
{
val [k] = -val [k] ;
if (val [k] == -novisto) val [k] = O;
= 1; t <= v; t++)
[tl < 0)
(a[k] [t] && (val [t] < -prioridad))
( v a l [ t ] > val[min]) min = t;
v a l [ t ] = -prioridad; padre[t] = k; }
Es preciso señalar que se ha utilizado un valor superior en una unidad a -no-
v i sto, como un centinela para encontrar el mínimo, y que el opuesto de dicho
valor debe ser representable.
Si se almacenan los pesos en la matriz de adyacencia y se utiliza a[k] [t]
para p r io r i dad en este programa, se obtiene el algoritmo de Prim para la cons-
trucción del árbol de expansión mínimo; si se utiliza val [k]+a [k ] [t] para
p r io r idad se obtiene el algoritmo de Dijkstra para el problema del camino más
corto. Como antes, si se incluye el código para que i d indique el número de
508 ALGORITMOS EN C++
vértices explorados y se utiliza V - id para priori dad, se obtiene la búsqueda en
profundidad. Este programa sólo se diferencia del de la búsqueda en primera
prioridad, con el que se ha estado trabajando para los grafos dispersos, en la re-
presentación del grafo que se utiliza (matriz de adyacencia en vez de listas de
adyacencia)y en la implementación de la cola de prioridad (array no ordenado
en vez de montículo indirecto).
Propiedad 31.5 Los problemas del árbol de expansión mínimo y del camino
más cortopueden resolverseen tiempo linealpara grafos densos.
De la inspección del programa se deduce inmediatamente que el tiempo de eje-
cución del peor caso es proporcional a V2.Cada vez que se explora un vértice,
un recomdo de las V entradas de una fila de la matriz de adyacencia permite
cumplir el doble objetivo de verificar todas las aristas adyacentes, actualizar la
cola de prioridad y encontrar el valor mínimo que contiene. Así, el tiempo de
ejecución es lineal cuando A es proporcional a V2..
Se han presentado tres programas para el problema del árbol de expansión
mínimo con diferentescaracterísticasde rendimiento: el método de la búsqueda
en primera prioridad, el algoritmo de Kruskal y el algoritmo de Prim. El algo-
ritmo de Prim es posiblemente el más rápido de los tres para algunos grafos, el
de Kruskal para otros y el método de búsqueda en primera prioridad para otros.
Como se dijo anteriormente, el peor caso para el método de búsqueda en pri-
mera prioridad es (A + V)logV,mientras que el peor caso para el de Prim es V2
y para el de Kruskal es AlogA. Pero sería poco prudente elegir entre los algorit-
mos sobre la base de estas fórmulas porque es improbable que «el peor caso»
suceda en la práctica. De hecho, el método de la búsqueda en prioridad y el de
Kruskal son ambos susceptjbles de ejecutarse en tiempo proporcional a A para
los grafos que aparecen normalmente en la práctica: el primero, porque la ma-
yoría de las aristas no necesitan una actualización de la cola de prioridad, que
lleva logV pasos, y el segundo porque es probable que la arista de mayor longi-
tud del árbol de expansión mínimo sea lo suficientemente pequeña como para
que no se extraigan muchas aristas de la cola de prioridad. Por supuesto, el mé-
todo de Prim también se ejecuta en un tiempo proporcional a aproximada-
mente A en grafos densos (pero no debe utilizarse en grafos dispersos).
Problemas geométricos
Supóngaseque se tienen N puntos dados de un plano y que se desea encontrar
el conjunto de segmentos de longitud más pequeña de los que conectan a todos
los puntos. Éste es un problema geométrico denominado el problema del árbol
de expansión minimo euclidiano, que se puede resolver utilizando el algoritmo
GRAFOS PONDERADOS 509
para grafos dado anteriormente, pero parece claro que la geometría del mismo
proporciona suficienteestructura extra como para que se puedan desarrollar al-
goritmos mucho más eficaces.
El medio para resolver el problema euclidiano utilizando el algoritmo ante-
rior consiste en construir un gafo completo con N vértices y N(N - 1)/2aristas,
cada una de las cuales conecta cada par de vértices ponderados por la distancia
enire los puntos correspondientes. Entonces se puede construir el árbol de ex-
pansión mínimo por medio del algoritmo de Prim en un tiempo proporcional
a N2.
Se ha demostrado que es posible hacerlo mejor. En efecto, la estructura geo-
métrica del problema hace irrelevantes a la mayor parte de las aristas, y se pue-
den eliminar una mayoría de ellas antes de comenzar a construir el árbol de
expansión mínimo. De hecho, también se ha demostrado que el árbol de expan-
sión mínimo es un subconjunto del grafo que se obtiene al elegir solamente las
aristas a partir del dual del diagrama de Voronoi (véase el Capítulo 28). Se sabe
que este grafo tiene un número de aristas proporcional a N y que tanto el algo-
ritmo de Kruskal como el método de búsqueda en primera prioridad funcionan
eficazmente en tales grafos dispersos. En principio, podría calcularse el dual del
diagrama de Voronoi (lo que lleva un tiempo proporcional a Mom, y a con-
tinuación ejecutar o bien el algoritmo de Kruskal o bien el método de búsqueda
eii primera prioridad para obtener el algoritmo del árbol de expansión mínimo
euclidiano que se ejecuta en un tiempo proporcional a Moo. Pero la escritura
de un programa para calcular el dual de Voronoi es más bien UE desafío, in-
cluso para programadores experimentados, y por ello esta aproximación al pro-
blema es probablemente impracticable.
Otro enfoque, que se puede utilizar en conjuntos de puntos aleatorios, con-
siste en aprovecharse de la distribución de puntos para limitar el número de
aristas incluidas en el grafo, como en el método de la rejilla que se utilizó en el
Capítulo 26 para la búsqueda por rango. Si se divide el plano en cuadrados de
tal forma que cada uno de ellos contenga aproximadamente l@/2 puntos, y en-
tonces se incluyen en el grafo sólo aquellas aristas que conecten cada punto de
los situados en cuadrados vecinos, entonces es muy probable (pero no garanti-
zado) obtener todas las aristas del árbol de expansión mínimo, lo que significa-
ría que el algoritmo de Kruskal o el método del árbol de búsqueda en primera
prioridad acabarían el trabajo eficazmente.
Es interesante hacer una reflexión sobre las relaciones entre los grafos y los
algoritmos geométricos, que se han dado a conocer por el problema expuesto
en los párrafos anteriores. Es cierto que muchos problemas pueden formularse
bien como problemas geométricos,bien como problemas de grafos. Si el empla-
zamiento físico real de los objetos es una característica dominante, entonces
pueden ser apropiados los algoritmos geométricos de los Capítulos 24-28; pero
si las interconexiones entre objetos tienen una importancia fundamental, en-
tonces podrán ser mejores los algoritmos sobre grafos de esta sección. El árbol
de expansión mínimo euclidiano parece ser la interfaz entre estos dos enfoques
(los datos de entrada afectan la geometría y los de salida, las interconexiones),
510 ALGORITMOS EN C++
y el desarrollo de métodos simples y directos para éste y otros problemas rela-
cionados con él continúa siendo una meta elusiva.
Otro lugar donde los algoritmos grafos y geométricos interactúan es en el
problema de encontrar el camino más corto entre x e y en un grafo cuyos vér-
tices sean puntos del plano y cuyas aristas sean líneas que los conectan. El grafo
laberinto que se ha utilizado puede considerarse como un grafo de este tipo. La
solución a este problema es simple: utilizar la búsqueda en primera prioridad,
dando como prioridad de cada vértice del margen que se encuentre la distancia,
en el árbol, entre x y el vértice del margen, más la distancia euclidiana entre
dicho vértice del margen e y. A continuación se para cuando y se ha añadido al
árbol. Este método encontrará rápidamente el camino más corto desde x a y
yendo siempre en la dirección de y, mientras que el algoritmo sobre el grafo es-
tándar tiene que «buscan>y. Desplazarse de un extremoa otro de un gran grafo
laberinto puede necesitar que se examine un número de nodos proporcional a
p,
mientras que el algoritmo estándar debe examinarprácticamente todos los
nodos.
Ejercicios
1. Encontrar otro árbol de expansión mínimo para el grafo ejemplo del prin-
cipio del capítulo.
2. Presentar un algoritmo para encontrar el bosque de expansión mínimo de
un grafo conexo (cada vértice debe ser alcanzado por alguna arista, pero no
es necesario que el gafo resultante sea conexo).
3. ¿Existe un grafo con V vértices y A aristas para el que la solución en pri-
mera prioridad del problema del árbol de expansión mínimo pueda nece-
sitar un tiempo proporcional a (A + V)logV?Dar un ejemplo o explicar la
respuesta.
4. Supóngase que se mantiene la cola de prioridad por medio de una lista or-
denada en las implementaciones de recomdo de grafos en general. ¿Cuál
podrá ser el tiempo de ejecución del peor caso para un factor constante?
¿Cuándo podría ser apropiado el método, si lo es alguna vez?
5. Presentar contraejemplos que muestren por qué la siguiente estrategia «in-
saciable» no resuelve ni el problema del camino más corto ni el del árbol
de expansión mínimo: «en cada paso se exploran los vértices inexplorados
que sean los más próximos al que se acaba de exploran>.
6. Presentar los árboles del camino más corto para los otros nodos del grafo
del ejemplo.
7. Describir cómo se podría encontrar el árbol de expansión mínimo de un
grafo extremadamente grande (demasiado grande como para poderse al-
macenar en la memoria principal).
8. Escribir un programa para generar grafos aleatorios conexos con Vvértices,
y después encontrar el árbol de expansión mínimo y el del camino más corto
GRAFOS PONDERADOS 511
para algunos vértices. Utilizar pesos aleatorios entre 1 y V. ¿Qué relación
existe entre los pesos de los árboles y los diferentes valores de V?
9. Escribir un programa para generar grafos aleatonos completos y pondera-
dos con V vértices tan sólo llenando una matriz de adyacencia con núme-
ros aleatonos entre 1 y V.Determinar empíricamentequé método encuen-
tra el árbol de expansión mínimo de forma más rápida para V = 10, 25,
100:el de Prim o el de Kruskal.
10. Presentar un contraejemplo para mostrar por qué el método siguiente no
sirve para encontrar el árbol de expansión mínimo euclidiano: «ordenar los
puntos por sus coordenadas x, a continuación encontrar los árboles de ex-
pansión mínimos de la primera mitad, de la segunda mitad, y finalmente
encontrar la arista más corta que los conecta».
Algoritmos en C++.pdf
32
Grafos dirigidos
Los grafos dirigidos son aquellos en los que las aristas que
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf
Algoritmos en C++.pdf

Más contenido relacionado

PDF
Hyper study 10.0
PDF
ANALISIS
PDF
Ejercicios programacion prolog
PDF
Grafi3
PDF
PDF
Introducción al r
PDF
Análisis Espacial con R
PDF
Manual abreviado de_analisis_multivarian
Hyper study 10.0
ANALISIS
Ejercicios programacion prolog
Grafi3
Introducción al r
Análisis Espacial con R
Manual abreviado de_analisis_multivarian

Similar a Algoritmos en C++.pdf (20)

PDF
Álgebra lineal y geometría ( PDFDrive ).pdf
PDF
2011 minitab-15
PDF
Apuntes De Matematicas Discretas
PDF
Apuntes De Matematicas Discretas
PDF
Apuntes De Matematicas Discretas
PDF
Apuntes De Matematicas Discretas
PDF
Apunts dintel ligencia_artificial
PDF
EstadisticaIngenieros.pdf
PDF
Calculo diferencial integral_func_una_var (1)
PDF
HUGO_ALBERTO_RINCON_MEJIA_ALGEBRA_LINEAL.pdf
PDF
2011 minitab-15
PDF
El Arte de Programar en R
PDF
El arte de programar en r
PDF
Santana el arte_de_programar_en_r
PDF
Matematicas en ingenieria_con_matlab_y_o
PDF
DataMining_lastfm
PDF
Curso basico de R
PDF
Números irracionales
PDF
y estructura_de_datos
PDF
Algoritmos y estructura_de_datos
Álgebra lineal y geometría ( PDFDrive ).pdf
2011 minitab-15
Apuntes De Matematicas Discretas
Apuntes De Matematicas Discretas
Apuntes De Matematicas Discretas
Apuntes De Matematicas Discretas
Apunts dintel ligencia_artificial
EstadisticaIngenieros.pdf
Calculo diferencial integral_func_una_var (1)
HUGO_ALBERTO_RINCON_MEJIA_ALGEBRA_LINEAL.pdf
2011 minitab-15
El Arte de Programar en R
El arte de programar en r
Santana el arte_de_programar_en_r
Matematicas en ingenieria_con_matlab_y_o
DataMining_lastfm
Curso basico de R
Números irracionales
y estructura_de_datos
Algoritmos y estructura_de_datos
Publicidad

Más de bilgrado01 (7)

PDF
BD-Tema-5werweweywteweasdawfsdrseehsd.pdf
PDF
SQL_Oraclegwretfghgsdgfgsdgfdfsdfssdrfds.pdf
PDF
SQL-36434databasesistemaunicosprogsf.pdf
PDF
Clase11-LenguajeSQL-I-1.pdfrftyrtyrtyrtyrty
PDF
03-El lenguaje SQLdatabasesrfasdasref.pdf
PDF
ApuntesC++.pdf
PDF
C++ Como Programar - Deitel 6edi.pdf
BD-Tema-5werweweywteweasdawfsdrseehsd.pdf
SQL_Oraclegwretfghgsdgfgsdgfdfsdfssdrfds.pdf
SQL-36434databasesistemaunicosprogsf.pdf
Clase11-LenguajeSQL-I-1.pdfrftyrtyrtyrtyrty
03-El lenguaje SQLdatabasesrfasdasref.pdf
ApuntesC++.pdf
C++ Como Programar - Deitel 6edi.pdf
Publicidad

Último (20)

PDF
TESTAMENTO DE DESCRIPTIVA ..............
PDF
Sugerencias Didacticas 2023_Diseño de Estructuras Metalicas_digital.pdf
PPTX
Manual ISO9001_2015_IATF_16949_2016.pptx
PDF
FIJA NUEVO TEXTO DE LA ORDENANZA GENERAL DE LA LEY GENERAL DE URBANISMO Y CON...
PPTX
Seminario de telecomunicaciones para ingeniería
PDF
Oficio SEC 293416 Comision Investigadora
PDF
Módulo-de Alcance-proyectos - Definición.pdf
PDF
fulguracion-medicina-legal-418035-downloable-2634665.pdf lesiones por descarg...
PPTX
GEOLOGIA, principios , fundamentos y conceptos
PPTX
Presentación - Taller interpretación iso 9001-Solutions consulting learning.pptx
PPTX
leyes de los gases Ideales. combustible refinación
PDF
Copia de Presentación Propuesta de Marketing Corporativo Blanco y Negro.pdf
PPTX
Logging While Drilling Ingenieria Petrolera.pptx
PDF
GUÍA PARA LA IMPLEMENTACIÓN DEL PLAN PARA LA REDUCCIÓN DEL RIESGO DE DESASTRES
PPT
Sustancias Peligrosas de empresas para su correcto manejo
PDF
Sustitucion_del_maiz_por_harina_integral_de_zapall.pdf
PDF
Durabilidad del concreto en zonas costeras
PPT
PRIMEROS AUXILIOS EN EL SECTOR EMPRESARIAL
PDF
HISTORIA DE LA GRÚAA LO LARGO DE LOS TIEMPOSpdf
PPTX
Curso Corto de PLANTA CONCENTRADORA FREEPORT
TESTAMENTO DE DESCRIPTIVA ..............
Sugerencias Didacticas 2023_Diseño de Estructuras Metalicas_digital.pdf
Manual ISO9001_2015_IATF_16949_2016.pptx
FIJA NUEVO TEXTO DE LA ORDENANZA GENERAL DE LA LEY GENERAL DE URBANISMO Y CON...
Seminario de telecomunicaciones para ingeniería
Oficio SEC 293416 Comision Investigadora
Módulo-de Alcance-proyectos - Definición.pdf
fulguracion-medicina-legal-418035-downloable-2634665.pdf lesiones por descarg...
GEOLOGIA, principios , fundamentos y conceptos
Presentación - Taller interpretación iso 9001-Solutions consulting learning.pptx
leyes de los gases Ideales. combustible refinación
Copia de Presentación Propuesta de Marketing Corporativo Blanco y Negro.pdf
Logging While Drilling Ingenieria Petrolera.pptx
GUÍA PARA LA IMPLEMENTACIÓN DEL PLAN PARA LA REDUCCIÓN DEL RIESGO DE DESASTRES
Sustancias Peligrosas de empresas para su correcto manejo
Sustitucion_del_maiz_por_harina_integral_de_zapall.pdf
Durabilidad del concreto en zonas costeras
PRIMEROS AUXILIOS EN EL SECTOR EMPRESARIAL
HISTORIA DE LA GRÚAA LO LARGO DE LOS TIEMPOSpdf
Curso Corto de PLANTA CONCENTRADORA FREEPORT

Algoritmos en C++.pdf

  • 4. Algoritmos en C++ Robert Sedgewick Versión en español de FernandoDavara Rodríguez Universidad Pontificia de Salamanca Campus de Madrid, España Miguel Katrib Mora Universidad de La Habana, Cuba Sergio Ríos Aguilar Consultor informático técnico Con la colaboración de Luis Joyanes Aguilar Universidad Pontijicia de Salamanca Campus de Madrid, España México @Argenjjna Brasil * Colodía 0 Costa Rica Chile * E d o r España Guatemala. P a d 0 Pení0 Puerto Rico Uruguay *Venezuela
  • 6. Versión en español de la obra titulada Algorithmsin C++, de Robert Sedgewick, publicada originalmente en inglés por Addison-Wesley Publishing Company, Inc., Reading, Massachusetts,O 1992. Esta edición en español es la única autorizada. PRIMERAED1CIÓN. 1995 O 1995 por Addison Wesley Iberoamericana, S.A. D.R. P2DOQ por ADDWON WESLEY LONGMAN DE MÉXICO, S.A. M C.V. Atlamulco No. 500, 50 piso Colonia IndustrialAtoto, Naucalpan de Juárez Edo de México, C P.53519 Cámara Nacionalde la Industria Editorial Mexicana, Registro No. 1031. Reservadostodos los derechos. Ni la totalidad ni parte de esta publicación pueden reproducirse, registrarse o transmitirse, por un sistema de recuperación de información, en ninguna form ni por ningún medio, sea dectrónico, mecánico, fotoquimíco, magneticeoelectroóptico, porfotocopia, grabación o cualquier otro, sin permiso previopor escrito del editor. El préstamo, alquiler o cualquier otra forma de cesión de uso de esteejemplar requerirá también la autorización del editor o de sus representantes. ISBN968-444-401-X Impreso en México. Printedin Mexico. I 2 3 4 5 6 7 8 9 0 0302010099
  • 8. A Adam, Andrew, Brett,Robbie y especialmentea Linda
  • 9. I lndice general Prólogo ..................................................... Fundamentos 1. Introducción ............................................... Algoritmos. Resumen de temas. Ejemplo: Algoritmo de Euclides. Tipos de datos. Entrada/Salida. Comentariosfinales. 3. Estructuras de datos elementales ............................. Arrays. Listasenlazadas. Asignación de memoria.Pilas. Implemen- tación de pilas por listas enlazadas. Colas. Tiposde datos abstractos y concretos. Glosario. Propiedades. Representación de árboles binarios. Repre- sentación de bosques. Recorrido de los árboles. 5. Recursión ................................................. Recurrencias. Divide y vencerás. Recorrido recursivo de un árbol. Eliminación de la recursibn. Perspectiva. Marco de referencia. Clasificaciónde los algoritmos. Complejidad del cálculo. Análisis del caso medio. Resultados aproximados y asintóticos. Recurrencias básicas. Perspectiva. 7. Implementación de algoritmos ............................... Selección de un algoritmo. Análisis empírico. Optimizaciónde un programa. Algoritmos y sistemas. 2. c++ (y C) ................................................. 4. Árboles ................................................... 6. Análisis de algoritmos ...................................... Algoritmos de ordenación 8. Métodos de ordenación elementales ........................... Reglas del juego. Ordenación por selección. Ordenación por inser- ción. Digresión: Ordenación de burbuja. características del rendi- miento de las ordenaciones elementales. Ordenación de archivos con registros grandes. Ordenación de Shell. Cuenta de distribuciones. xv 3 9 17 39 55 73 89 103 IX
  • 10. X [NOICE GENERAL 9. 10. 11. 12. 13. Quicksort ................................................. El algoritmo básico. Características de rendimiento del Quicksort. Eliminación de la recursión. Subarchivos pequeños. Partición por la mediana de tres. Selección. Ordenación por residuos .................................... Bits. Ordenación por intercambio de residuos. Ordenación directa por residuos. características de rendimiento de la ordenación por residuos. Una ordenación lineal. Colas de prioridad .......................................... Implementacioneselementales. Estructura de datos montículo. Al- goritmos sobre montículos. Ordenación por montículos. Montícu- los indirectos. Implementaciones avanzadas. Ordenación por fusión ...................................... Fusión. Ordenación por fusión. Ordenación por fusión de listas. Ordenación por fusión ascendente. características de rendimiento. Implementacionesoptimizadas. Revisión de la recursión. Ordenación externa ......................................... Ordenación-fusión. Fusión múltiple balanceada. Selección por sus- titución. Consideraciones prácticas. Fusión polifásica. Un método más fácil. Algoritmos de búsqueda 14. 15. 16. 17. 18. Métodos de búsqueda elementales ............................ Búsqueda secuencial. Búsqueda binaria. Búsqueda por árbol bina- rio. Eliminación. Arboles binarios de búsqueda indirecta. Arboles equilibrados ........................................ Árboles descendentes 2-3-4. Árboles rojinegros. Otros algoritmos. Dispersión ................................................ Funciones de dispersión. Encadenamiento separado. Exploración lineal. Doble dispersión. Perspectiva. Búsqueda por residuos ...................................... Árboles de búsqueda digital. Árboles de búsqueda por residuos. Búsqueda por residuos múltiple. Patricia. Búsqueda externa .......................................... Acceso secuencia1indexado. Árboles B. Dispersión extensible. Me- moria virtual. 127 145 159 179 195 213 237 255 271 287
  • 11. iNDlCE GENERAL XI Procesamiento de cadenas 19. Búsqueda de cadenas ....................................... Una breve historia. Algoritmo de fuerza bruta. Algoritmo de Knuth - Moms - Pratt. Algoritmo de Boyer - Moore. Algoritmo de Rabin - Karp. Búsquedas múltiples. 20. Reconocimiento de patrones ................................. Descripción de patrones. Máquinas de reconocimiento de pa- trones. Representación de la máquina. Simulación de la má- quina. 21. Análisis sintáctico .......................................... Gramáticas libres de contexto. Análisis descendente. Análisis as- cendente. Compiladores.Compilador de compiladores. Codificaciónpor longitud de series. Codificaciónde longitud varia- ble. Construcción del código de Huffman. Implementación. Reglas del juego. Métodos elementales. Máquinas de cifrar/desci- fiar. Sistemas de cripto de claves públicas. 22. Compresión de archivos ..................................... 23. Criptología ................................................ Algoritmos geométricos 24. 25. 26. 27. 28. Métodos geométricos elementales ............................ Puntos, líneas y polígonos. Intersección de segmentos de 1í- neas. Camino cerrado simple. Inclusión en un polígono. Perspec- tiva. Obtención del cerco convexo ................................. R e g l a s del juego. Envolventes. La exploración de Graham. Elimi- nación interior. Rendimiento. Búsqueda por rango ........................................ Métodos elementales. Método de la rejilla. Árboles bidimensiona- les. Búsqueda por rango multidimensional. Interseccióngeométrica ..................................... Segmentos horizontales y verticales. Implementación. Intersección de segmentos en general. Problemas del punto más cercano Problema del par más cercano. Diagramas de Voronoi. ............................ 307 325 331 351 365 379 391 407 423 435
  • 12. XI1 ÍNDICE GENERAL Algoritmos sobre grafos 29. 30. 31. 32. 33. 34. Algoritmos sobre grafos elementales .......................... Glosario. Representación. Búsqueda en profundidad. Búsqueda en profundidad no recursiva. Búsqueda en amplitud. Laberintos. Perspectivas. Conectividad ............................................... Componentesconexas. Biconectividad. Algoritmos de unión - per- tenencia. Grafos ponderados ......................................... Árbol de expansión mínimo. Búsqueda en primera prioridad. Mé- todo de Kruskal. El camino más corto. Árbol de expansión mí- nimo y camino más corto en grafos densos. Problemas geométri- Grafos dirigidos ............................................ Búsqueda en profundidad. Clausura transitiva. Todos los caminos más cortos. Ordenación topológica. Componentesfuertemente co- nexas. Flujo de red ............................................... El problema del flujo de red. Método de Ford - Fulkerson. Bús- queda de red. Concordancia .............................................. Grafos bipartidos. Problema del matrimonio estable. Algoritmos avanzados. cos. Algoritmos matemáticos 35. 36. 37. 38. Números aleatorios ......................................... Aplicaciones. Método de congruencia lineal. Método de congruen- cia aditiva. Comprobación de la aieatoriedad. Notas de implemen- tación. Aritmética ................................................ Aritmética polinómica. Evaluación e interpoIación polinómica. Multiplicación polinómica. Operaciones aritméticas sobre enteros grandes. Aritmética de matrices. Eliminación gaussiana ...................................... Un ejemplo simple. Esbozo del método, Variacionesy extensiones. Ajuste de curvas ........................................... Interpolación polinómica. Interpolación spline. Método de los mí- nimos cuadrados. 45 1 475 491 513 529 539 555 569 585 597
  • 13. íNDICE GENERAL Xlll 39. Integración ................................................ Integración simbólica. Métodos de cuadratura elementales. Méto- dos compuestos.Cuadratura adaptativa. Temas avanzados 40. Algoritmos paralelos ........................................ Aproximaciones generales. Mezcla perfecta. Arrays sistólicos. Pers- pectiva. Evaluar, multiplicar, interpolar. Raíces complejas de la unidad. Evaluación de las raíces de la unidad. Interpolaciónen !as raíces de la unidad. Implementación. El problema de la mochila. Producto de matrices en cadena. Ár- boles binanos de búsqueda óptima. Necesidades de espacio y tiempo. Programas lineales. Interpretación geométrica. El método símplex. Implementación. Búsqueda exhaustiva en grafos. Vuelta atrás. Digresión: Genera- ción de permutaciones. Algoritmos de aproximación. 45. Problemas NP-completos ................................... Algoritmos deterministas y no deterministas de tiempo polinó- mico. Compleción NP. El teorema de Cook. Algunos problemas NP-completos. 41. La transformada rápida de Fourier ........................... 42. Programación dinámica ..................................... 43. Programación lineal ........................................ 44. Búsqueda exhaustiva ....................................... Epílogo ........................................................ Vocabulario técnico bilingüe ...................................... Índice de programas ............................................ 609 623 637 649 661 677 691 701 705 713 Índice analítico ................................................. 719
  • 15. Prólogo La finalidad de este libro es dar una idea clara de los algoritmos más importan- tes empleados hoy día en las computadoras y ensefiar sus técnicas fundamen- tales a quienes, cada vez en mayor número, tienen necesidad de conocerlos. Se puede utilizar como libro de texto para segundo, tercero o cuarto curso de in- formática, una vez que los estudiantes hayan adquirido cierta habilidad en la programación y se hayan familiarizado con los sistemas informáticos, pero an- tes de realizar cursos de especialización en áreas avanzadas de la informática o de sus aplicaciones. Además, el libro puede ser útil para la autoformacióno como texto de con- sulta para aquellos que trabajan en el desarrollo de aplicaciones o sistemas para computadoras, ya que contiene un gran número de algoritmos útiles e infor- mación detallada sobre sus características de ejecución. La amplia perspectiva adoptada en el libro lo convierte en una adecuadaintroducción a este campo. Los algoritmos se expresan en el lenguaje de programación C++ (también se dispone de versiones del libro en Pascal y C), pero no se necesitan conocimien- tos de un lenguaje de programación específico -el tratamiento aquí contem- plado es autónomo, aunque un tanto rápido-. Los lectores que estén familia- rizados con C++ encontrarán en este lenguaje un vehículo útil para aprender una serie de métodos de interés práctico. Aquellos que tengan conocimientos de algoritmos básicos encontrarán en el libro una forma útil de conocer diversas características del lenguaje C++, y simultáneamenteaprenderán algunos algo- ritmos nuevos. Finalidad El libro contiene 45 capítulos agrupados en ocho partes principales: fundamen- tos, ordenación, búsqueda, procesamiento de cadenas, algoritmos geométricos, algoritmos sobre grafos, algoritmos matemáticosy temas avanzados. Un obje- tivo importante a la hora de escribir este libro ha sido reunir los métodos fun- damentalesde diversas áreas, con la finalidad de dar a conocer los más emplea- dos en la resolución de problemas por medio de computadoras.Algunos de los capítulos son una introducción a materias más avanzadas. Se espera que las descripciones aquí empleadas permitan al lector comprender las propiedades básicas de algoritmos fundamentales, que abarcan desde las colas de prioridad y la dispersión, al símplex y la transformada de Fourier rápida. xv
  • 16. XVI PROLOGO Se aconseja que el lector haya realizado previamente uno o dos cursos de informática, o disponga de una experiencia equivalente en programación, para que pueda valorar el contenido de este libro: lo ideal sería un curso de progra- mación en un lenguaje de alto nivel como C++,C o Pascal, y quizás otro curso sobre conceptos fundamentalesde sistemasde programación. Este libro está pues destinado a cualquier persona con experiencia en un lenguaje moderno de pro- gramación y con las ideasbásicas de los sistemas modernosde computadora.Se incluyen en el texto algunas referencias que pueden ayudar a subsanar las po- sibles lagunas del lecior. En su mayor parte, los conceptos matemáticos que sustentan los resultados analíticos se explican (o bien se clasifican como «másallá de la finalidad» de este libro), por lo que para la comprensión general del libro no se requiere una preparación específica en matemáticas, aunque, en definitiva, es útil una cierta madurez matemática. Algunos de los últimos capítulos tratan algoritmos rela- cionados con conceptos matemáticos más avanzados -se han incluido para situar a los algontmos en el contexto de otros métodos y no para enseñar los conceptos matemáticos-. Por lo tanto, la presentación de los conceptos mate- máticos avanzados es breve, general y descriptiva. Utilización en planes de estudio La forma en que se puede enseñar esta materia es muy flexible. En gran me- dida, se pueden leer unos capítulos independientementede otros, aunque en al- gunos casos los algoritmos de un capítulo utilizan los métodos del capítulo an- tenor. Se puede adaptar el texto para la realización de diferentes cursos mediante la posible selección de 25 ó 30 de los 45 capítulos, según las preferencias del profesor y la preparación de los estudiantes. El libro comienza con una sección de introducción a las estructuras de datos y ai diseño y análisis de algoritmos. Esto establece las pautas para el resto de la obra y proporciona una estructura, dentro de la que se tratan algoritmos más avanzados. Algunos lectores pueden saltarseu hojear esta sección; otros pueden aprender aquí las bases. En un curso elemental sobre «algoritmos y estructuras de datos)) podrían omitirse algunos de los algoritmos matemáticos y ciertos temas avanzados, ha- ciendo hincapié en la forma en la que se utilizan las estructuras de datos en las implementaciones. En un curso intermedio sobre «diseño y análisis de algont- mosn podrían omitirse algunas de las secciones que están orientadas a la prác- tica y recalcarse la identificación y el estudio de las condiciones en las que los algontmos alcanzan rendimientos acintóticos satisfactorios. En un curso sobre las «herramientas del software))se podrían omitir las matemáticas y el material algorítmico avanzado, y así poner mayor énfasis en cómo integrar las imple- mentaciones propuestas en grandes sistemas o programas. Un curso sobre «al-
  • 17. PRÓLOGO XUll goritmos)) podría adoptar un enfoque de síntesis e introducir conceptos de to- das estas áreas. Algunos profesores, para dar una orientación particular, pueden añadir ma- terial complementario a los cursos descritos anteriormente. Para las ((estructu- ras de datos y algoritmos))se godría ampliar el estudio sobre estructuras básicas de datos; para (diseño y análisisde algoritmos))se podría profundizar en el aná- lisis matemático, y para las ((herramientas del software))convendría profundi- zar en las técnicas de ingeniería del software. En este libro se contemplan todas estas áreas, pero el énfasis se pone en los algoritmos propiamente dichos. En los últimos años se han empleado versiones anteriores de este libro en decenas de colegios y universidades norteamericanas, como texto para el se- gundo o tercer curso de informática y como lectura complementaria para otros cursos. En Princeton, la experiencia ha demostrado que el amplio espectro que cubre este libro proporciona a los estudiantes de los últimos cursos una intro- ducción a la informática, que puede ampliarse con cursosposteriores sobreaná- lisis de algoritmos, programación de sistemas e informática teórica, a la vez que proporciona a todos los estudiantes un gran conjunto de técnicas de las que pueden obtener un provecho inmediato. Hay 450 ejercicios, diez por capítulo, que generalmente se dividen en dos grupos. La mayor parte tienen como finaiidad comprobar que los estudiantes han entendido la materia del libro, y hacer que trabajen en un ejemplo o apli- quen los conceptos descritos en el texto. Sin embargo, algunos de ellos son para implementar y agrupar algoritmos, necesitándose en ocasiones estudios empí- ricos para comparar algoritmos y conocer sus propiedades. Algoritmos de uso práctico Este libro está orientado al tratamiento de algoritmos de uso práctico. El obje- tivo es enseñar a los estudiantes las herramientas que tienen a su alcance, para que puedan implementar con absoluta confianza algoritmos útiles, ejecutarlos y depurarlos. Se incluyen en el texto implementaciones completas de los méto- dos empleados, junto con descripciones del funcionamiento de los programas en un conjunto coherente de ejemplos. De hecho, como se verá en el epílogo, se incluyen cientos de figuras que han sido generadas por los propios algorit- mos. Muchos algoritmos se aclaran desde un punto de vista intuitivo a través de la dimensión visual que proporcionan las figuras. Las característicasde los algoritmos y las situaciones en que podrían ser úti- les se presentan de forma detallada. Aunque no se haga hincapié en ellas, no se ignoran las relaciones entre el análisis de algoiitmos y la informática teórica. Cuando se considere apropiado, se presentarán los resultados analíticos y em- píricos para ilustrar por qué se prefieren ciertos algoritmos. Cuando sea intere- sante, se describirá la relación entre los algoritmos prácticos presentados y los resultados puramente teóricos. Se encontrará a lo largo del texto información
  • 18. XVlll PRÓLOGO específicasobre las característicasde rendimiento de los algoritmos,bajo la forma de «propiedades», que resumen los hechos importantes de los algoritmos que merecen un estudio adicional. Algunos algoritmos se utilizan en programas relativamente pequeños para resolver problemas concretos y otros se integran, como parte de un todo, en sis- temas relativamente grandes. Muchos algoritmos fundamentales encuentran aplicación en ambos casos. Se indicará cómo adaptar algoritmosespecíficospara resolver problemas concretos o algoritmos generalespara su integración en pro- gramas más grandes. Talesconsideracionesson particularmente interesantespara los algoritmos expresadosen un lenguaje orientado a objetos,tal como C++. En este libro se proporciona la información apropiada que puede utilizarsepara ha- cer intercambios inteligentesentre utilidad y rendimiento en implementaciones de algoritmos muy utilizados. A pesar de que existe un escasotratamiento directo del empleo específicode los algoritmos en aplicaciones para la ciencia y la ingeniería, las posibilidades de tal uso se mencionarán cuando sea conveniente. La experiencia demuestra que cuando los estudiantesaprenden pronto buenos algoritmos informáticosson capaces de aplicarlos para resolver problemas a los que se enfrentarán más ade- lante. Lenguaje de programación El lenguaje de programación utilizado a lo largo de este libro es C++ (también existen versiones en Pascal y C). Cualquier lenguaje particular tiene ventajas e inconvenientes -la intención aquí es facilitar, al creciente número de personas que utilizan el C++ como lenguaje originalpara sus aplicaciones,el acceso a los algoritmos fundamentales que se han ido desarrollando a través de los años-. Los programas se pueden traducir fácilmente a otros lenguajesde programación modernos, ya que están escritos en una forma sencilla que los hace relativa- mente independientes del lenguaje. Desde luego, muchos de los programas han sido traducidos desde Pascal, C y otros lenguajes, aunque se intenta utilizar el lenguaje C estándar cuando sea apropiado. Por otra parte, C++ se adapta per- fectamente a la tarea del libro, dado su soporte básico en la abstracción de datos y su programación modular que permite expresar claramente las relaciones en- tre las estructuras de datos y los algoritmos. Algunos de los programas se pueden simplificar utilizando aspectos más avanzados del lenguaje, pero esto ocurre menos veces de las que se podría pen- sar. Aunque las características del lenguaje se presentarán cuando sea apro- piado, este libro no tiene como objetivo ser un manual de referencia de C++ o de la programación orientada a objetos. Mientras se utilizan las clases de C++ reiteradamente, no se usan plantillas, herencias, ni funciones virtuales, pero los algoritmos están codificadosasí para facilitar los procesos de instalación en sis- temas grandes, donde tales aspectos se pueden utilizar para beneficiarse de la
  • 19. PR~LOGO XIX programación orientada a objetos. Cuando se precise hacer una elección será concentrándose'en los algoritmos, no en los detallesde la implementaciónni en las características del lenguaje. Una de las metas de este libro es presentar los algoritmos de la forma más simple y directa que sea posible. Los programas no están para leerse por sí mis- mos, sino como parte del texto que los encuadra. Este estilo se ha elegido como una alternativa a, por ejemplo, la introducción de comentarios entre líneas. El estilo es coherente, de forma que programas similares parecerán similares. Agradecimientos Mucha gente me ha ayudado al comentar las versiones anteriores de este libro. En particular, los estudiantes de Princeton y Brown han sufrido con las versio- nes preliminares del material del libro en los ochenta. En especial, doy las gra- cias a Trina Avery, Tom Freeman y Janet Incerpi por su ayuda en la produc- ción de la primera edición. En particular a Janet por pasar el libro al formato TEX,añadir los miles de cambios que hice después del «último borradon) de la primera edición, guiar los archivos a través de diversos sistemas para imprimir las páginas e incluso escribir una rutina de revisión para TEXutilizada para ob- tener manuscritos de prueba, entre otras muchas cosas. Solamente cuando yo mismo desempeñé estas tareas en posteriores versiones, pude apreciar real- mente la contribución de Janet. Me gustaría también dar las gracias a muchos de los lectores que me ayudaron con comentarios detallados sobre la segunda edición, entre ellos a Guy Almes, Jay Gischer, Kennedy Lemke, Udi Manber, Dana Richards, John Reif, M. Rosenfeld, Stephen Seidman y Michael Quinn. Muchos de los diseños de las figuras están basados en el trabajo conjunto con Marc Brown en el proyecto «aula electrónica» en la Brown University en 1983.Agradezco el apoyo de Marc y su ayuda en la creación de los diseños (sin mencionar el sistema con el que trabajábamos).También me gustaría agradecer la ayuda de Sarantos Kapidakis para obtener el texto f i n a l . Esta versión C++ debe su existenciaa lú tenacidad de Keith Wollman, quien me convenció para realizarla, y a la paciencia de Peter Gordon, que estaba con- vencido de que la sacaría adelante. La buena voluntad de Dave Hanson para contestar preguntas acerca de C y C++ fue incalculabIe. También me gustaría agradecer a Darcy Cotten y a Skip Plank su ayudapara producir el libro. Mucho de lo que he escrito aquí lo he aprendido gracias a las enseñanzasde Don Knuth, mi consejero en Stanford. Aunque Don no ha tenido influencia directa sobre este trabajo se puede sentir su presencia en el libro, porque fue él quien supo colocar el estudio de algoritmos sobreuna base científica de talforma que sea posible realizar un trabajo como éste. Estoy muy agradecido por el apoyo de la Brown University e INRIA donde realicé la mayor parte del trabajo del libro, y al Institute for Defense Analyses y al Xerox Palo Alto Research Center, donde hice parte del libro mientras lo vi-
  • 20. xx PRÓLOGO sitaba. Muchas partes del libro se deben a la investigación realizada y cedida generosamente por la National Science Foundation y la Office of Naval Re- search. Finalmente, quisiera dar las gracias a Bill Bowen, Aaron Lemonick y Neil Rudenstine de la Princeton University por apoyarme al crear un entorno académico en el que fui capaz de preparar este libro, a pesar de tener muchas otras responsabilidades. ROBERT SEDGEWICK Marly-le-Roi,Francia,febrero, 1983 Princeton,New Jersey, enero, I990 Princeton, New Jersey, enero, 1992
  • 23. 1 Introducción El objetivo de este libro es estudiar una variedad muy extendida de algoritmos útiles e importantes: los métodos de resolución de problemas adaptados para su realización por computadora. Se tratarán diferentes áreas de aplicación, po- niendo siempre especial atención a los algoritmos «fundamentales» cuyo co- nocimiento es importante e interesante su estudio. Dado el gran número de al- goritmos y de dominios a cubrir, muchos de los métodos no se estudiarán en profundidad. Sin embargo, se tratará de emplear en cada algoritmo el tiempo suficientepara comprender sus característicasesencialesy para respetar sus par- ticularidades. En resumen, la meta es aprender un gran número de los algorit- mos más importantes que se utilizan actualmente en computadoras, de forma que se pueda utilizarlos y apreciarlos. Para entender bien un algoritmo, hay que realizarlo y ejecutarlo; por consi- guiente, la estrategiarecomendada para comprender los programas que se pre- sentan en este libro es implementarlos y probarlos, experimentar con variantes y tratar de aplicarlos a problemas reales. Se utilizará el lenguaje de programa- ción C++ para presentar y realizar la mayor parte de los algoritmos; no obs- tante, al utilizar sólo un subconjunto relativamente pequeño del lenguaje, los programas pueden traducirse fácilmente a otros lenguajesde programación. Los lectores de este libro deben poseer al menos un año de experiencia en lenguajes de programación de alto y bajo nivel. También sena conveniente te- ner algunos conocimientos sobre los algoritmos elementales relativos a las es- tructuras de datos simplestales como arrays, pilas, colas y árboles, aunque estos temas se traten detalladamente en los Capítdos 3 y 4. De igual forma, se su- ponen unos conocimientos elementales sobre la organización de la máquina, lenguajes de programación y otros conceptos elementales de informática. (Cuando corresponda se revisarán brevemente estas materias, pero siempre dentro del contexto de resolución de problemas particulares.) Algunas de las áreas de aplicación que se abordarán requieren conocimientos de cálculo ele- mental. También se utilizarán algunos conceptos básicos de álgebra lineal, geo- 3
  • 24. 4 ALGORITMOS EN C++ metria y matemática discreta, pero no es necesario el conocimiento previo de estos temas. Algoritmos La escritura de un programa de computadora consiste normalmente en imple- mentar un método de resolución de un problema, que se ha diseñado previa- mente. Con frecuencia este método es independiente de la computadora utili- zada: es igualmente válido para muchas de eilas. En cualquier caso es el método, no el programa, el que debe estudiarse para comprendercómo está siendo abor- dado el problema. El término algoritmo se utiliza en informática para describir un método de resolución de un problema que es adecuado pará su implemen- tación como programa de computadora. Los algoritmos son la «esencia» de la informática; son uno de los centros de interés de muchas, si no todas, de las áreas del campo de la informática. Muchos algoritmos interesantes llevan implícitos complicados métodos de organización de los datos utilizados en el cálculo. Los objetos creados de esta manera se denominan estructuras de datos, y también constituyen un tema principal de estudio en informática. Así, estructurasde datos y algoritmosestán íntimamente relacionados; en este libro se mantiene el punto de vista de que las estructuras de datos existen como productos secundarios o finales de los algo- ritmos, por lo que es necesario estudiarlas con el fin de comprenderlos algorit- mos. Un algoritmo simple puede dar origen a estructurasde datos complicadas, y a la inversa, un algoritmo complicado puede utilizar estructurasde datos sim- ples. En este libro se estudian las propiedades de muchas estructuras de datos, por lo que bien se podría haber titulado Algoritmos y estructuras de datos en C++. Cuando se desarrolla un programa muy grande, una gran parte del esfuerzo se destina a comprender y definir el prob1ema.a resolver, analizar su compleji- dad y descomponerh en subprogramas más pequeños que puedan realizarse fá- cilmente. Con frecuenciasucede que muchos de los algoritmos que se van 8 uti- lizar son fáciles de implementar una vez que se ha descompuesto el programa. Sin embargo, en la mayor parte de los casos, existen unos pocos algoritmos cuya elección es critica porque su ejecución ocupará la mayoría de los recursos del sistema. En este libro se estudiará una variedad de algoritmos fundamentales básicos para los grandes programas de muchas áreas de aplicación. El compartir programas en los sistemasinformáticos es una técnica cada vez más difundida, de modo que aunque los usuarios serios utilizarán íntegramente los algoritmos de este libro, quizá necesiten implementar sólo alguna parte de ellos. Realizando las versiones simples de los algoritmos básicos se podrá com- prenderlos mejor y también utilizar versiones avanzadas de forma más eficaz. Algunos de los mecanismos de software compartido de los sistemas de compu- tadoras dificultan a menudo la adaptación de los programas estándar a la reso-
  • 25. INTRODUCCIÓN 5 lución eficaz de tareas específicas, de modo que muchas veces surge la necesi- dad de reimplementar algunos algoritmosbásicos. Los programasestán frecuentementesobreoptimizados.Puede no ser útil es- merarse excesivamente para asegurarse de que una realización sea lo más efi- ciente posible, a menos que se trate de un algoritmo susceptiblede utilizarse en un2 tarea muy amplia o que se utilice muchas veces. En los otros casos, bastará una implementación relativamente simple; se puede tener cierta confianza en que funcionará y en que posiblemente su ejecución sea cinco o diez veces más lenta que la mejor versión posible, lo que significa unos pocos segundos extra en la ejecución. Por el contrario la elección del algoritmo inadecuado desde el primer momento puede producir una diferencia de un factor de unos cientos,o de unos miles, o más, lo que puede traducirse en minutos, horas, o incluso más tiempo de ejecución. En este libro se estudiarán implementacionesrazonables y simples de los mejores algoritmos. A menudo varios algoritmos diferentes son válidos para resolver el mismo problema. La elección del mejor algoritmo para una tarea particular puede ser un proceso muy complicado y con frecuencia conllevará un análisis matemá- tico sofisticado.La rama de la informática que estudia tales cuestiones se llama análisis de algoritmos. Se ha demostrado a través de dicho análisis que muchos de los algoritmos que se estudiarán tienen un rendimiento muy bueno, mien- tras que de otros se sabe que funcionan bien simplemente a través de la expe- riencia. No se hará hincapié en comparacionesde resultados de rendimiento: la meta es aprender algunos algoritmos que resuelvan tareas importantes. Pero, como no se debe usar un algoritmo sin tener alguna idea de qué recursospodría consumir, se intentará precisar de qué forma se espera que funcionen los algo- ritmos de este libro. Resumen de temas A continuación se presenta una breve descripción de las partes principales del libro, que enuncian algunos de los temas específicos, así como también alguna indicación de la orientación general sobre la materia. Este conjunto de temas intentará tocar tantos algoritmos fundamentales como sea posible. Algunos de los temas tratados constituyen el «corazón» de diferentes áreas de la informá- tica, y se estudiarán en profundidad para comprender los algoritmos básicos de gran utilidad. Otras áreas son campo de estudios superioresdentro de la infor- mática y de sectores relacionados con ella, tales como el análisis numérico, la investigación operativa, la construcción de compiladoresy la teoría de algorit- mos -en estos casos el tratamiento servirá como una introducción a dichos campos a través del examen de algunos métodos básicos-. En el contexto de este libro, los FUNDAMENTOS son las herramientas y métodos que se utilizarán en los capítulosposteriores. Se incluye una corta dis- cusión de c++, seguida por una introducción a las estructuras de datos básicas,
  • 26. 6 ALGORITMOS EN C++ que incluye arrays, listas enlazadas, pilas, colas y árboles. Se presentará la utili- zación práctica de la recursión, encaminando el enfoque básico hacia el análisis y la realización de algoritmos. Los métodos de OIWENACIÓN, para reorganizar archivos en un orden de- terminado, son de vital importancia y se tratan en profundidad. Se desarrollan, describen y comparan un gran número de métodos. Se tratan algoritmos para diversos enunciadosde problemas, como colas de prioridad, selección y fusión. Algunos de estos algoritmos se utilizan como base de otros algoritmos descritos posteriormente en el libro. Los métodos de BÚSQUEDA para encontrar datos en los archivos son tam- bién de gran importancia. Se presentarán métodos avanzados y básicos de bús- queda con árboles y transformaciones de clavesdigitales, incluyendo árboles de búsqueda binaria, árboles equilibrados, dispersión, árboles de búsqueda digital y tries, así como métodos apropiados para archivos muy grandes. Se presenta- rán las relaciones entre estos métodos y las similitudes con las técnicas de or- denación. Los algoritmos de PROCESAMIENTO DE CADENAS incluyen una gama de métodos de manipulación de (largas)sucesiones de caracteres. Estos métodos conducen al reconocimiento de patrones en las cadenas, que a su vez conduce al análisis sintáctico. También se desarrollan técnicas para comprimir archivos y para criptografia.Aquí, otra vez, se hace una introducción a temas avanzados mediante el tratamiento de algunos problemas elementales, importantes por sí mismos. Los ALGORITMOS GEOMÉTRICOSson un conjunto de métodos de re- solución de problemas a base de puntos y rectas (y de otros objetos geométricos sencillos),que no se han puesto en práctica hasta hace poco tiempo. Se estudian algoritmos para buscar el cerco convexo de un conjunto de puntos, para encon- trar intersecciones entre objetos geométricos, para resolver problemas de pro- ximidad y para la búsqueda multidimensional. Muchos de estos métodos com- plementan de forma elegante las técnicas más elementales de ordenación y búsqueda. Los ALGORITMOS SOBRE GRAFOS son útiles para una variedad de pro- blemas importantes y dificiles. Se desarrolla una estrategia general para la bús- queda en grafosy se aplica a los problemas fundamentalesde conectividad, como el camino más corto, el árbol de expansión mínimo, flujo de red y concordan- cia. Un tratamiento unificado de estos algoritmos demuestra que todos ellos es- tán basados en el mismo procedimiento y que éste depende de una estructura de datos básica desarrollada en una sección anterior. Los ALGORITMOS MATEMATICOSpresentan métodos fundamentales que proceden del análisis numérico y de la aritmética. Se estudian métodos de la aritmética de enteros, polinomios y matrices, así como también algoritmos para resolver una gama de problemas matemáticos que provienen de muchos contextos: generación de números aleatorios, resolución de sistemasde ecuacio- nes, ajuste de datos e integración. Se pone énfasis en los aspectos algontmicos de estos métodos, no en sus fundamentosmatemáticos.
  • 27. INTRODUCCIÓN 7 Los TEMAS AVANZADOS se presentan con el objeto de relacionar el con- tenido del libro con otros camposde estudio más avanzados. Lascomputadoras de arquitectura específica,la programación dinámica, la programación lineal, la búsqueda exhaustiva y los problemas NP-completos se examinan desde un punto de vista elemental para dar al lector alguna idea de los interesafites campos de estudio avanzados que sugieren los problemas simples que contiene este libro. El estudio de los algoritmos es interesante porque se trata de un campo nuevo (casi todos los algoritmos que se presentan en el libro son de hace menos de 25 años), con una rica tradición (algunos algoritmos se conocen desde hace miles de años). Se están haciendo constantemente nuevos descubrimientos y pocos algoritmos se entienden por completo. En este libro se consideran tanto algoritmos dificiles, complicados y enredados, como algoritmos fáciles, simples y eiegantes. El desafío consiste en comprender los primeros y apreciar los últi- mos en el marco de las diferentesaplicacionesposibles. Al hacerlo se descubrirá una variedad de herramientas eficaces y se desarrollará una forma de ((pensa- miento algontmico)) que será muy útil para los d e d o s informáticos del por- venir.
  • 29. 2 A lo largo de este libro se va a utilizar el lenguaje de programación C++. Todos los lenguajes tienen su lado negativo y su lado positivo, y así, la elección de cualquiera de eiios para un libro como éste tiene ventajas e inconvenientes.Pero, como muchos de los lenguajes modernos son similares, si no se utilizan más que algunas instrucciones y se evitan decisiones de realización basadas en las peculiaridades de C++, los programas que se obtengan se podrán traducir fácil- mente a otros lenguajes.El objetivo es presentar los algoritmos de la forma más simple y directa que sea posible; C++ permite hacerlo. Los algoritmos se describen frecuentemente en los libros de texto y en los informes científicos por medio de seudolenguaje -por desgracia esto lleva a menudo a omitir detalles y deja al lector bastante lejos de una implementación práctica-. En este libro se considera que el mejor camino para comprender un algoritmoy comprobar su utilidad es experimentarlocon una situación real. Los lenguajesmodernos son Io suficientemente expresivoscomo para que lasimple- mentaciones reales puedan ser tan concisas y elegantes como sus homólogas imaginarias. Se aconseja al lector que se familiarice con el entorno C++ de pro- gramación local, ya que en el libro las implementaciones son pregramas pen- sados para ejecutarlos, experimentar con ellos,modificarlos y utilizarlos. La ventaja de utilizar C++ es que este lenguaje está muy extendido y tiene todas las características básicas que se necesitan en las diversas implementacio- nes; el inconveniente es que posee propiedades no disponibles en algunos otros lenguajes mcdernos, también muy extendidos, por lo que se deberá tener cui- dado y ser consciente de la dependencia que los programas tengan del lenguaje. Algunos de los programas se verán simplificadospor las características avanza- das del lenguaje, pero esto ocurre menos veces de las que se podría pensar. Cuando sea apropiado, la presentación de los programas cubrirá los puntos re- levantes del lenguaje. En particular, se aprovechará una de las principales vir- tudes de C++, su compatibilidad con C: el grueso de los códigos se reconocerfi fácilmente como C, pero las características importantes de C++ tendrán un pa- pel destacado en muchas de las realizaciones. 9
  • 30. i o ALGORITMOS ENC++ Una descripción concisa del lenguaje C++ se encuentra en el libro de Stroustrup The C++Programming Language (segunda edición) '.El objetivo de este capítulo no es repetir la información de dicho libro, sino más bien ilus- trar algunasde lascaracterísticasbásicas del lenguaje,por lo que se utilizará como ejemplo la realización de un algoritmo simple (pero clásico). Aparte de la en- trada/salida, el código C++ de este capítulo es también código C; también se utilizarán otras características de C++ cuando se consideren estructuras de da- tos y programas más complicados en algunos de los capítulos siguientes. Ejemplo: Algoritmo de Euclides Para comenzar, se consideraráun programa en C++ para resolver un problema clásico elemental: «Reducir una fracción determinada a sus términos más ele- mentales». Se desea escribir 2/3, no 4/6, 200/300, o 178468/267702. Resolver este problema es equivalente a encontrar el rnúximo cornUn divisor (mcd) del numerador y denominador: el mayor entero que divide a ambos. Una fracción se reduce a sus términos más elementalesdividiendo el numerador y el deno- minador por su máximo común divisor. Un método eficaz para encontrar el máximo común divisor fue descubiertopor los antiguosgriegos hace más de dos mil años: se denomina el algoritmo de Euclides porque aparece escrito detalla- damente en el famoso tratado Los elementos,de Euclides. El método de Euclides está basado en el hecho de que si u es mayor que v, entonces el máximo común divisor de u y v es el mismo que el de v y u - v. Esta observación permite la siguiente implementación en C++: #include t i ostream.h> int mcd(int u , int v) i n t t ; while (u > O) { { i f (u < v) { t = u; u = v; v = t; } u - u - v ; 1 return v; 1 main() I I Existe versión en espatiol de Addison-Wesley/Díaz de Santos (1994) con el título El lengzuzje deprogruma- ción Ci+.(A!del T.)
  • 31. c++(Y C) 11 i n t x, y; while (cin >> x && cin << y) if (x>O && y>O) cout << x << I I << y << I I << mcd(x,y) << ' n ' ; } Antes de seguir adelante hay que estudiar las propiedades del lenguaje expuesto en este código. C++ tiene una rigurosa sintaxis de alto nivel que permite iden- tificar fácilmente las principales característicasdel programa. El programa con- siste en una lista de funciones, una de las cuales se llama main ( ), y constituye el cuerpo del programa. Las funciones devuelven un valor con la instrucción return. C++ incluye una «bibliotecade flujos)) para la entrada/salida. La ins- trucción incl ude permite hacer referencia a esta biblioteca. El operador << significa ((poneren» el «flujo de salida) cout, y, de igual manera, >> significa «obtener de» el «flujo de entrada) cin. Estos operadores comparan los tipos de datos que se obtienen con los flujos -en este caso se leen como datos de en- trada dos enteros, y se obtendrán como salidajunto con su máximo común di- visor (seguidos por los caracteres n que indican mueva línea»)-. El valor de cin >> x es O cuando no hay más datos de entrada. La estructura del programa anterior es trivial: se leen pares de números de la entrada, y a continuación, si ambos son positivos, se graban en la salidajunto con su máximo común divisor. (¿Qué sucede cuando se llama a la función mcd con u o v negativos o con valor cero?)La función mcd implementael algoritmo de Euclides por sí misma: el programa es un bucle que primero se asegura de que u >=v intercambiando sus valores, si fuera necesario, y reemplazando a continuación u por u - v. El máximo común divisor de las variables u y v es siempre igual al máximo común divisor de los valores originales que entraron al procedimiento: tarde o temprano el proceso termina cuando u es igual a O y v es igual al máximo común divisor de los valores originales de u y v (y de todos los intermedios). El ejemplo anterior se ha escrito por completo en C++, para que el lector pueda utilizarlo para familiarizarsecon algunos sistemas de la programación en C++. El algoritmo de interés se ha escrito como una subrutina (mcd),y el pro- grama principal es un «conducton>que utiliza la subrutina. Esta organización es típica, y se ha incluido aquí el ejemplo completo para resaltar que los algo- ritmos presentados en este libro se entenderán mejor si se implementan y eje- cutan con algunos valores de entrada de prueba. Dependiendode la calidad del entorno de depuración disponible, el lector podría desear llegar más lejos en el análisisde los programas propuestos. Por ejemplo, puede ser interesante ver los valores intermedios que toman u y v en el bucle whi 1e del programa anterior. Aunque el objetivo de esta sección es el lenguaje, no el algoritmo, se debe hacer justicia ai clásico algoritmo de Euclides: la implementación anterior puede mejorarse notando que, una vez que u > v, se restarán de u los múltiples va- lores de v hasta encontrar un número menor que v. Pero este número es exac-
  • 32. 12 ALGORITMOS EN C++ tamente el resto que queda al dividir u entre v, que es lo que el operador mó- dulo (%) calcula: el máximo común divisor de u y v es igual ai máximo común divisor de v y u % v. Por ejemplo,el máximo común divisor de 461952y 116298 es 18, Sil y como muestra la siguiente secuencia 461952, 116298, 113058, 3240,2898, 342, 162, 18. Cada elemento de esta sucesión es el resto que queda al dividir los dos elemen- tos anteriores: la sucesión termina porque 18 es divisor de 162,de manera que 18 es el máximo común divisor de todos los números. Quizás el lector desee modificar la implementación anterior para usar el operador % y comprobar que esta modificación es mucho más eficaz cuando, por ejemplo, se busca el má- ximo común divisor de un número muy grande y un número muy pequeño. Este algoritmo siempre utiliza un número de pasos relativamente pequeño. Tipos de datos La mayor parte de los algoritmos presentados en este libro funcionan con tipos de datos simples: números reales, enteros, caracteres o cadenas de caracteres. Una de las características más importantes de C++ es su capacidad para cons- truir tipos de datos más complejos a partir de estos «ladrillos» elementales.Más adelante se verán muchos ejemplos de esto. Sin embargo, se procurará evitar el uso excesivo de estas facilidades,para no complicar los ejemplos y centrarse en la dinámica de los algoritmos más que en las propiedades de los datos. Se pro- curará hacerlo sin que esto lleve a una pérdida de generalidad: desde luego, las grande5posibilidadesque tiene C++ para realizar construcciones complejas ha- cen que sea fácil transformar uca «maqueta» de algoritmo que opera sobre ti- pos de datos sencillos en una versión de «tamaño natural» que realiza una ope- ración critica de una cl ase de C++. Cuando los métodos básicos se expliquen mejor en términos de tipos definidos por el usuario, así se hara. Por ejemplo, los métodos geométricos de los Capítulos 24-28 están basados en modelos para puntos, líneas, polígonos, etc.; y los métodos de colas de prioridad del Capítulo I 1 y los métodos de bíxqueda de los Capítulos 14-18 se expresan mejor como conjuntos de operaciones asociados a estructuras de datos particulares, utili- zando el constructor cl ase de C++. Se volverá a este punto en el Capítulo 3, y se verán otros muchos ejemplos a lo largo del libro. Algunas veces, la conveniente representación de datos a bajo nivel es la clave del rendimiento. Teóricamente, la forma de realizar un programa no debería depender de cómo se representan los números o de cómo se codifican los carac- teres (por escoger dos ejemplos), pero el precio que hay que pagar para conse- guir este ideal es a veces demasiado alto. Ante este hecho, en el pasado los pro- gramadores optaron por la drástica postura de irse al Ienguaje ensamblador o a1 lenguaje máquina, donde hay pocas limitaciones para la representación. Afor-
  • 33. c++(Y C) 13 tunadamente, los lenguajes modernos de alto nivel ofrecen mecanismos para crear representaciones razonables sin llegar a tales extremos. Esto permite jus- tificar algunos algoritmos clásicos importantes. Por supuesto, tales mecanismos dependen de cada máquina, y no se estudian aquí con mucho detalle, excepto para indicar cuándo son apropiados. Este punto se tratará más detalladamente en los Capítulos 10, 17y 22, al examinar los algoritmos basados en representa- ciones binarias de datos. También se tratará de evitar el uso de representaciones que dependen de la máquina, al considerar algoritmos que operan sobre caracteres y cadenas de ca- racteres. Con frecuencia, se simplifican los ejemplos para trabajar únicamente con las letras mayúsculas de la A a la Z, utilizando un sencillo código en el que la i-ésima letra del alfabeto está representada por el entero i. La representación de caracteres y de cadenas de caracteres es una parte tan fundamental de la in- terfaz entre el programador, el lenguaje de programación y la máquina, que se debería estar seguro de que se entiende totalmente antes de implementar algo- ritmos que procesen tales datos -en este libro se dan métodos basados en re- presentaciones sencillas que, por lo tanto, son fácilesde adaptar-. Se utilizarán números enteros (int)siempre que sea posible. Los programas que utilizan números de coma flotante (f1oat) pertenecen al dominio del análisis numérico. Por lo regular, su utilización va íntimamente ligada a las pro- piedades matemáticas de la representación. Se volverá sobre este punto en los Capítulos 37, 38, 39,41 y 43, donde se presentan algunosalgoritmos numéricos fundamentales. Mientras tanto, se limitan los ejemplos a la utilización de los números enteros, incluso cuando los números reales puedan parecer más apro- piados, para evitar la ineficaciae inexactitud que suelen asociarse a las represen- taciones mediante números de coma flotante. Entrada/Salida Otro dominio en el que la dependencia de la máquina es importante es la inter- acción entre el programa y sus datos, que normalmente se designa como en- truda/salidu. En los sistemas operativos este término se refiere al intercambio de datos entre la computadora y los soportes físicos tales como un disco o una cinta magnética; se hablará sobre tales materias únicamente en los Capítulos 13 y 18. La mayor parte de las veces se busca un medio sistemático para obtener datos y enviar los resultados a las implementaciones de algoritmos, tales como la función mcd anterior. Cuando se necesite «leen>y «escribin>,se utilizarán las características nor- males de C++,invocando lo menos posible algunos formatos extra disponibles. t En realidad, al ejecutar un programa se debe usar el punto decimal en lugar de la coma para evitar errores durante la ejecución. Asimismo, al escribir cifras,se recomienda no usar puntos para separar los millares o millo- nes.(N.del E.)
  • 34. 14 ALGORITMOS EN C++ Nuevamente, el objetivo es mantener programas concisos, manejables y fácil- mente traducibles; una razón por la que el lector podría desear modificar los programas es para mejorar su interfaz con el programador. Pocos, si existe al- guno, de los entomos de programación modernos como C++ toman c i n o cout como referencia al medio externo; en su lugar se refieren a ((dispositivoslógi- cos» o a «flujos» de datos. Así, la salida de un programa puede usarse como la entrada de otro, sin ninguna lectura o escritura física. La tendencia a hacer flu- jos de entrada/salida en las implementaciones de este libro las hace más útiles en tales entomos. En realidad, en muchos entomos modernos de programación son apropia- das y fáciles de utilizar las representaciones gráficas como las utilizadas en las figuras de este libro. Como se precisa en el epílogo, estas figuras realmente se generan por los propios programas, lo que ha llevado a una mejora sustancial de la interfaz. Muchos de los métodos que se presentan son apropiados para utilizarlos dentro de grandes sistemas de aplicaciones, de manera que la mejor forma de suministrar datos es mediante el uso de parámetros. Éste es el método utilizado por el procedimiento mcd visto anteriormente. También algunas de las imple- mentaciones de capítulos posteriores del libro usarán programas de capítulos anteriores. De nuevo, para evitar desviarla atención de los algoritmos en sí mis- mos, se resistirá a la tentación de «empaquetan>las implementaciones para uti- lizarlas como programas de utilidad general. Seguramente, muchas de las im- plementaciones que se estudiarán son bastante apropiadas como punto de partida para tales utilidades, pero se planteará un gran número de preguntas acerca de la dependencia del sistema o de la máquina, cuyas respuestas se silen- ciarán aquí, pero que pueden obtenerse de forma satisfactoria durante el de- sarrollo de tales paquetes. Algunas veces, se escribirán programas para operar con datos «(globales»,para evitar una parametrización excesiva.Por ejemplo, la función mcd podría operar directamente con x e y, sin necesidad de recurrir a los parámetros u y v. Esto no estájustificado en este caso porque mcd es una función bien definida en tér- minos de sus dos entradas, pero cuando varios algoritmos operan sobrelos mis- mos datos, o cuando se pasa una gran cantidad de datos, se podrían utilizar va- riables globales para reducir la expresión algorítmica y para evitar mover datos innecesariamente. Por otra parte, C++ es un lenguaje ideal para eencapsulam los algoritmos y sus estructuras de datos asociadas para hacer explícitas las in- terfaces, y se tenderá a usar datos globales muchas menos veces en las imple- mentaciones en C++ que en los correspondientes programas en C o Pascal. Comentarios finales En The C++Programming Language y en los capítulos que siguen se muestran otros muchos ejemplos parecidos al programa anterior. Se invita al lector a ho-
  • 35. c++(Y C) 15 jear el manual, implementar y probar algunos programas sencillos y posterior- mente leer el manual con detenimiento para familiarizarse con las caractensti- cas básicas de C++. Los programas en C++ que se muestran en este libro deben servir como des- cripciones precisas de los algoritmos, como ejemplos de implementaciones completas y como punto de partida para la realización de programas prácticos. Como se ha mencionado anteriormente, los lectores experimentados en otros lenguajesno deben tener dificultadespara leerlos algoritmospresentadosen C++ e implementarlos en otros lenguajes. Por ejemplo, la siguiente es una imple- mentación en Pascal del algoritmo de Euclides: ~~ ~~ program euclides(input, output); var x, y: integer, function mcd(u, v: integer):integer; var t: integer, begin repeat if u<v then begin t:=u; u:=v; v:=t end; u:=u-v until u=O; mcd:=v end; begin while not eof do begin readln(x, y); if (x> O) and (y> O) then writeln(x, y, mcd(x, y)) end; end. En este algoritmo hay una correspondencia prácticamente exacta entre las sen- tencias en C++ y Pascal, como era la intención; sin embargo, no es difícil de- sarrollar implementaciones más precisas en ambos lenguajes. En este caso la realización en C++ se diferencia de la realizada en C únicamente en la entrada/ salida: el objetivo será mantener esta compatibilidad siempre que sea natural hacerlo, aunque, por supuesto, la mayoría de los programas de este libro utili- zan instrucciones C++ que no están disponibles en C. Ejercicios 1. Implementar la versión clásica del algoritmo de Euclides presentado en el texto.
  • 36. 16 ALGORITMOS EN C++ 2. Comprobar qué valores de u % v calcula el sistema en C++ cuando u y v no son siempre positivos. 3. Implementar un procedimiento para hacer irreducible una fracción dada, utilizando una struct fracción { i n t numerador; i n t denominador; 4. Escribir una función in t converti r () que lea un número decimal cifra a cifra, termine cuando encuentre un espacio en blanco y devuelva el valor del número. 5. Escribir una función b inario (in t x) que presente el equivalente binario de un número. 6. Obtener los valores que toman u y v cuando se invoca la función mcd con la llamada inicial mcd( 12345, 56789). 7. ¿Cuántas instrucciones de C++ se ejecutan exactamente en la llamada del ejercicio anterior? 8. Escribir un programa que calcule el máximo común divisor de tres enteros u, v y w. 9. Encontrar el mayor par de números representables como enteros en el sis- tema C++, cuyo máximo común divisor sea 1. 1. 10. Implementar el algoritmo de Euclides en FORTRAN y BASIC.
  • 37. 3 Estructuras elementales de datos En este capítulo se presentan los métodos básicos de organizar los datos para procesarlos mediante programas de computadora. En muchas aplicaciones la decisión más importante en la implementación es elegir la estructura de datos adecuada: una vez realizada la elección, lo único que se necesitan son algorit- mos simples. Para los mismos datos, algunasestructuras requieren más o menos espacio que otras; para las mismas operaciones con datos, algunas estructuras requieren un número distinto de algoritmos, unos más eficaces que otros. Esto ocurrirá con frecuencia a lo largo de este libro, porque la elección del algoritmo y de la estructura de datos está estrechamente relacionada y continuamente se buscan formas de ahorrar tiempo o espacio mediante una elección adecuada. Una estructura de datos no es un objeto pasivo: es preciso considerar tam- bién las operaciones que se ejecutan sobre ella (y los algoritmos empleados en estas operaciones).Este concepto se formaliza en la noción de tipo de datos abs- tracto, que se analiza al final del capítulo. Pero como el mayor interés está en las implementaciones concretas, se fijará la atención en las manipulaciones y representaciones específicas. Se trabajará con arrays, listas enlazadas, pilas, colas y otras variantes senci- llas. Éstas son estructuras de datos clásicascon un gran número de aplicaciones: junto con los árboles (ver Capítulo 4), forman prácticamente la base de todos los algoritmos que se consideran en este libro. En este capítulo se verán las re- presentaciones básicas y los métodos de manipulación de estas estructuras, se trabajará con algunosejemplos concretos de utilización y se presentarán puntos específicos, como la administración del almacenamiento. 17
  • 38. i e ALGORITMOS EN C++ Arrays Tal vez el array sea la estructura de datos más importante, que se define como una primitiva tanto en C++ como en otros muchos lenguajesde programación. Un array es un número fijo de elementos de datos que se almacenan de forma contigua y a los que se accede por un índice. Se hace referencia al i-ésimo ele- mento de un array a como a[i1. Es responsabilidad del programador almace- nar en una posición a [i] de un array un valor coherente antes de llamarlo; des- cuidar esto es uno de los errores más comunes de la programación. Un sencillo ejemplo de la utilización de un array es el siguiente programa, que imprime todos los números primos menores de 1.OOO. El método utilizado, que data del siglo 111 a.c., se denomina la «criba de Eratóstenesx const i n t N = 1000; main() i n t i,j, a[N+l]; f o r ( a [ l ] = O , i = 2; i <= N; i++) a [ i ] = 1; f o r ( i = 2; i <= N/2; i++) f o r ( j = 2; j <= N / i ; j++) a [ i * j ] = O; { f o r ( i = 1; i <= N; i++) cout << 'n'; i f ( a [ i ] ) cout << i << I '; 1 Este programa emplea un array constituido por el tipo más sencillo de elemen- tos, los valores booleanos (O- 1). El objetivo del mismo es poner en a[i] el valor 1si ies un número primo, o poner un O si no lo es. Para todo i,se pone a O el elemento del array que corresponde a cualquier múltiplo de i,ya que cualquier número que sea múltiplo de cualquier otro número no puede ser primo. A con- tinuación se recorre el array una vez más, imprimiendo los números primos. Primero se «inicializa»el array para indicar los números que se sabe que no son primos: el algoritmo pone a O los elementos del array que corresponden a índi- ces conocidos como no primos. Se puede mejorar la eficacia del programa, comprobando a [i] antes del f o r del bucle que involucra a j, ya que si ino es primo, los elementos del array que corresponden a todos sus múltiplos deben haberse marcado ya. Podría hacerse un empleo más eficazdel espacio mediante el uso explícito de un array de bits y no de enteros. La criba de Eratóstenes es uno de los algoritmos típicos que aprovechan la posibilidad de acceder directamente a cualquier elemento de un array. El algo- ritmo accede a los elementos del array secuencialmente, uno detrás de otro. En muchas aplicaciones, es importante el orden secuenciai;en otras se utiliza por-
  • 39. ESTRUCTURASDE DATOS ELEMENTALES 19 que es tan bueno como cualquier otro. Pero la característica principal de los arrays es que si se conoce el índice, se puede acceder a cualquier elemento en un tiempo constante. El tamaño de un array debe conocerse de antemano: para ejecutar el pro- grama anterior para un valor diferente de N, es necesario cambiar la constante N y después volver a compilar y a ejecutar. En algunos entomos de programa- ción, es posible declarar el tamaño de un array durante la ejecución (de modo que se podría conseguir, por ejemplo, que un usuario introduzca el valor de N para obtener los números primos menores que N sin el desperdicio de memoria provocado al definirun tamaño del array tan grande como el valor máximo que se permita teclear al usuario). En C++ es posible lograr este efecto mediante la apropiada utilización del mecanismo de asignación de la memoria, pero sigue siendo una propiedad fundamental de los arrays que sus tamaños sean fijos y se deban conocer antes de utilizarlos. Los arrays son estructuras de datos fundamentales que tienen una corres- pondencia directa con los sistemas de administración de memoria, en práctica- mente todas las computadoras. Para poder recuperar el contenido de una pala- bra de la memoria es preciso proporcionar una dirección en lenguaje máquina. Así se podría representar la memoria total de la computadora como si fuera un array, en el que las direcciones de memoria correspondieran a los índices del mismo. La mayor parte de los procesadores de lenguaje, al traducir programas a lenguaje máquina, construyen arrays bastante eficaces que permiten acceder directamente a la memoria. Otra forma normal de estructurar la información consisteen utilizar una ta- bla de números organizada en filas y columnas. Por ejemplo, una tabla de las notas de los estudiantes de un curso podría tener una fila para cada estudiante, y una columna para cada asignatura. En una computadora, esta tabla se repre- sentaría como un array bidimensional con dos índices, uno para las filas y otro para las columnas. Hay varios algoritmos que son inmediatos para manejar es- tas estructuras: por ejemplo, para calcular la nota media de una asignatura, se suman todos los elementos de una columna y se dividen por el número de filas; para calcular la nota media del curso de un estudiante en particular, se suman todos los elementos de una fila y se dividen por el número de columnas. Los arrays bidimensionales se utilizan generalmente en aplicacionesde este tipo. En una computadora se utilizan a menudo más de dos dimensiones: un profesor podría utilizar un tercer índice para mantener en tablas las notas de los estu- diantes de una sene de años. Los arrays también se corresponden directamente con los vectores, término matemático utilizado para las listas indexadas de objetos. Análogamente, los arrays bidimensionales se corresponden con las matrices. Los algoritmos para procesar estos objetos matemáticos se estudian en los Capítulos 36 y 37.
  • 40. 20 ALGORITMOS EN C++ Listas enlazadas La segunda estructura de datos elementalesa considerares la lista enlazada, que se define como una primitiva en algunos lenguajes de programación (concreta- mente en Lisp) pero no en C++. Sin embargo, C++ proporciona operaciones básicas que facilitan el uso de listas enlazadas. La ventaja fundamental de las listas enlazadas sobre los arrays es que su ta- maño puede aumentar y disminuir a lo largo de su vida. En particular, no se necesita conocer de antemano su tamaño máximo. En aplicaciones prácticas, esto hace posible que frecuentemente se tengan vanas estructuras de datos que comparten el mismo espacio, sin tener que prestar en ningún momento una atención particular a su tamaño relativo. Una segunda ventaja de las listas enlazadas es que proporcionan flexibili- dad, lo que permite que los elementos se reordenen eficazmente. Esta flexibili- dad se gana en detrimento de la rapidez de acceso a cualquier elemento de la lista. Esto se verá más adelante, después de que se hayan examinado algunas de las propiedades básicas de las listas enlazadas y algunas de las operaciones fun- damentales que se llevan a cabo con ellas. Una lista enlazada es un conjunto de elementos organizados secuencial- mente, igual que un array. Pero en un array la organización secuencial se pro- porciona implícitamente (por la posición en el array), mientras que en una lista enlazada se utiliza un orden explícito en el que cada elemento es parte de un «nodo» que contiene además un «enlace» con el nodo siguiente. La Figura 3.1 muestra una lista enlazada, con los elementos representados por letras, los no- dos por círculos y los enlaces por líneas que conectan los nodos. Más adelante se verá, de forma detallada, cómo se representan las listas en la computadora; por ahora se hablará simplemente de nodos y enlaces. Incluso la sencilla representación de la Figura 3.1 pone en evidencia dos de- talles que deben considerarse. Primero, todo nodo tiene un enlace, por lo que el enlace del último nodo de la lista debe designar a algún nodo «siguiente». Con este fin se adopta el convenio de tener un nodo «ficticio», que se denomina Z: el último nodo de la lista apuntará a Z y Z se apuntará a sí mismo. En se- gundo lugar, también por convenio, se tendrá un nodo ficticio en el otro ex- tremo de la lista, que se denomina cabeza, y que apuntará al primer nodo de la lista. El principal objetivo de los nodos ficticios es que resulte más cómodo hacer ciertas manipulaciones con los enlaces, especialmente con aquellos que están relacionados con el primer y último nodo de la lista. Más adelante, se ve- Figura 3.1 Una lista enlazada.
  • 41. ESTRUCTURASDE DATOS ELEMENTALES 21 cabeza Figura 3.2 Una lista enlazada con sus nodos ficticios. rán más normas que se toman por convenio. La Figura 3.2 muestra la estruc- tura de la lista con estos nodos ficticios. Esta representación explícita de la ordenación permite que ciertas operacio- nes se ejecuten mucho más eficazmente de lo que sena posible con arrays. Por ejemplo, suponiendoque se quiere mover la A desde el final de la lista ai pnn- cipio, en un array se tendría que mover cada elemento para hacer sitio en el comienzo para el nuevo elemento; en una lista enlazada, simplemente se cam- bian tres enlaces, como se muestra en la Figura 3.3. Las dos versiones que apa- recen en la Figura 3.3 son equivalentes; simplemente están dibujadas de ma- nera diferente. Se hace que el nodo que contiene a A apunte a L, que el nodo que contiene a T apunte a z, y que cabeza apunte a A. Aun cuando la lista fuese muy larga, se podría hacer este cambioestructural modificando solamente tres enlaces. La operación siguiente, que es antinatural e inconveniente en un array, es todavía más importante. Se trata de «insertan>un elemento en una lista enla- zada (lo que hace aumentar su longitud en una unidad). La Figura 3.4 muestra cómo insertar X en la lista del ejemplo, poniendo X en un nodo que apunte a T, y a continuación haciendo que el nodo que contiene a S apunte al nuevo nodo. En esta operación sólo se necesita cambiar dos enlaces, cualquiera que sea la longitud de la lista. De igual forma, se puede hablar de «eliminan>un elemento de una lista en- lazada (lo que hace disminuir su longitud en una unidad). Por ejemplo, la ter- cera lista de la Figura 3.4 muestra cómo eliminar X de la segunda lista haciendo simplementeque el nodo que contiene a S apunte a T, saltándose a X. Ahora, cabeza Figura 3.3 Reordenaciónde una lista enlazada.
  • 42. 22 ALGORITMOS EN C++ cabeza Figura 3.4. Insercióny borradoen una lista completa. el nodo que contiene a X todavía existe (dehecho, todavía apunta a T), y quizás debería eliminarse de alguna manera; pero el hecho es que X no forma parte de la lista, y no puede accederse a él mediante enlaces desde cabeza. Se volverá sobre este punto más adelante. Por otra parte, hay otras operaciones para las que las listas enlazadas no son apropiadas. La más obvia de estas operaciones es «encontrar el k-ésimo ele- mento» (encontrar un elemento dado su índice): en un array esto se hace fácil- mente accediendo a a [k], pero en una lista hay que moverse a lo largo de k enlaces. Otra operación que es antinatural en las listas enlazadas es «encontrar el elemento anterior a uno dadon. Si el único dato de la lista del ejemplo es el enlace a T, entonces la única forma de poder encontrar el enlace a T es comen- zar en cabeza y recorrer la lista para encontrar el nodo que apunta a A. En realidad, esta operación es necesaria si se desea eliminar un nodo concreto de una lista enlazada, ya que ¿de qué otra manera se encontrará el nodo cuyo en- lace debe modificarse?En muchas aplicaciones se puede rodear este problema transformando la operación «eliminan>en la «eliminar el nodo siguiente». En la inserción se puede evitar un problema similar haciendo que la operación sea «insertar un elemento dado después de un nodo determinado)) de la lista. Para ilustrar cómo podría implementarse en C++ una lista enlazada básica, se comenzará especificando con precisión el formato de los nodos de la lista y construyendo una lista vacía, como se indica a continuación: struct nodo struct nodo *cabeza, *z; { int clave; struct nodo *siguiente; };
  • 43. ESTRUCTURASDE DATOS ELEMENTALES 23 cabeza = new nodo; z = new nodo; cabeza->siguiente = z; z->siguiente = z; La declaración struct indica que las listas están compuestas por nodos y que cada nodo contiene un número entero y un puntero al sigui ente nodo de la lista. La variable cl ave es un entero, únicamentepara simplificar el programa; pero podría ser de cualquiertipo -el puntero sigui ente esla clave de la lista-. El asterisco indica que las variables cabeza y z se declaran como punteros a los nodos. En realidad éstos se crean únicamente cuando se llama a la función in- tegrada new. Esto oculta un complejo mecanismo que tiene como finalidad ali- viar al programador de la carga de asignar «memoria» para los nodos según va creciendo la lista. Más adelante se estudiará este mecanismo con mayor detalle. La notación «flecha» (un signo menos seguido de un signo mayor) se utiliza en C++ para seguir a los punteros a través de las estructuras. Se escribe una refe- rencia a un enlace seguida por este símbolo para indicar una referencia al nodo al que apunta ese enlace. Así, el código anterior crea dos nuevos nodos referen- ciados por cabeza y z y pone a ambos apuntando a z. Para insertar en una lista enlazada detrás de un nodo dato t un nuevo nodo con el valor de la clave v, se crea el nodo (x = new nodo) y se pone en el valor clave (x- >clavbe = v), después se copia en el enlace de t(x- >siguiente = t - >sigui ente) y se hace que el enlace de t apunte al nuevo nodo (t- >si - guiente = X). Para extraer de una lista enlazada el nodo siguiente a un nodo dado t, se obtiene un puntero a ese nodo (x = t - >s i guiente), se copia el puntero en t para sacarlo de la lista (t- >si guiente = x- >si guiente) y devolverlo al sis- tema de asignación de memoria empleando el procedimiento integrado de- 1ete, a menos que la lista estuviese vacía (if (x!=z) delete x). Se invita al lector a que compare estas implementaciones en C++ con las presentadas en la Figura 3.4. Es interesante destacar que el nodo cabeza evita el tener que hacer una comprobación especial en la inserción de un elemento al principio de la lista, y el nodo z proporciona una forma apropiada de compro- bar la eliminación de un elementoen una lista vacía. Severá otra utilización de z en el Capítulo 14. En capítulos posteriores se verán muchos ejemplos de aplicaciones de este tipo y otras operaciones básicas sobre listas enlazadas. Como las operaciones sólo se componen de unas cuentas instrucciones, con frecuencia se manipularán las listas directamente en lugar de hacerlo mediantelos tipos de datos. Como ejem- plo, se considera el siguiente programa para resolver el denominado«problema de Josefa» en la misma línea de la criba de Eratóstenes. Se supone que N per- sonas han decidido cometer un suicidio masivo, disponiéndose en un círculo y matando a la M-ésima persona alrededor del círculo, cerrando las filasa medida que cada persona va abandonando el círculo. El problema consiste en averiguar qué persona será la última en morir (jaunque quizás al final cambie de idea!), o, más generalmente, encontrar el orden en que mueren las personas. Por ejem-
  • 44. 24 ALGORITMOS EN C++ plo, si N = 9 y M = 5, las personas morirán en el orden 5 1 7 4 3 6 9 2 8. El siguiente programa lee N y M y obtiene este orden: struct nodo main() { int clave; struct nodo *siguiente; }; int i,N, M; struct nodo *t, *x; { cin t = for { 1 >> N >> M; new nodo; t->clave = 1; x = t; (i = 2; i <= N; i++) t->siguiente = new nodo; t = t->siguiente; t->clave = i; t->siguiente = x; while (t != t->siguiente) for (i = 1; i < M; i++) t = t->siguiente; cout << t->siguiente; t->siguiente = x->siguiente; delete x; 1 cout << t->clave << 'n'; 1 El programa utiliza una lista enlazada «circulan>para simular directamente la secuencia de ejecuciones. Primero, se construye la lista para las clavesdesde 1 a N de forma que la variable x ocupe el principio de la lista en el momento de su creación, después el puntero del último nodo de la lista se pone en X. El pro- grama continúa recorriendo la lista, contando hasta el elemento M - 1 y eli- minando el siguiente, hasta que se deje uno sólo (que entonces se apunta a si mismo). Se observa que se llama a delete para suprimir elementos, lo que co- rresponde a una ejecución: éste es el operador opuesto a new, como se men- cionó anteriormente. Las listas circularesse emplean a veces como una alternativa a la utilización de los nodos ficticios cabeza o z, con un nodo ficticio para marcar el principio (y el final) de la lista y como ayuda en el caso de las listas vacías. La operación «encontrar el elemento anterior a uno dado» se puede realizar mediante la utilización de una lista doblemente enlazada, en la que se mantie- nen dos enlaces para cada nodo, uno para el elemento anterior, y otro para el elemento posterior. El coste de contar con esta capacidad extra es duplicar el número de enlaces manipulados por cada operación básica; de manera que no
  • 45. ESTRUCTURASDE DATOS ELEMENTALES 25 es normal que se utilicen, a menos que se requiera específicamente. Por otro lado, como se mencionó antes, si se va a eliminar un nodo y sólo se dispone de un enlace al mismo (que quizás también es parte de alguna otra estructura de datos), pueden utilizarse enlaces dobles. Asignación de memoria Como se mostró anteriormente, los punteros de C++ proporcionan una manera adecuada de implementar listas; pero existen otras alternativas. En esta sección se verá cómo se utilizan los arrays para implementar listas enlazadas así como la relación entre esta técnica y la representación real de las listas en un pro- grama en C++. Como ya se mencionó, los arrays son una representación bas- tante directa de la memoria de la computadora, por lo que el análisis de cómo se implementa una estructura de datos de este tipo proporcionará algún cono- cimiento sobre cómo podría representarse esta estructura a bajo nivel de la computadora. En particular, interesa ver cómo podrían representarse al mismo tiempo varias listas. Para representar directamente listas enlazadas mediante arrays, se utilizan índices en lugar de enlaces. Una manera de proceder sería definir un array de registros parecidos a los anteriores, pero utilizando enteros (i nt) para los índi- ces del array, en lugar de punteros al campo sigui ente. Una alternativa, que suele resultar más conveniente, es utilizar «arrays paralelos»: se guardan los elementos en un array clave y los enlaces en otro array siguiente. Así clave[ siguienteLcabeza)] se refiere a la información asociada con el pri- mer elemento de la lista, c1ave [siguiente [s i gui ente [cabeza]]]con el se- gundo, y así sucesivamente. La ventaja de utilizar arrays paralelos es que la es- tructura puede construirse «sobre» los datos: el array cl ave contiene datos y sólo datos; toda la estructura está en el array paralelo siguiente. Por ejemplo, se puede construir otra lista empleando el mismo array de datos y un paralelo de «enlace» diferente, o se pueden añadir más datos con más arrays paralelos. La siguientelínea de código implementa la operación ((insertardespués de» en una lista enlazada representada por los arrays paralelos cl ave y sigui ente clave[x] = v; siguiente[x] = siguiente[t]; siguiente[t] = x++; El «puntero» x sigue la pista de la siguiente posición que está sin ocupar en el array, de manera que no se necesitallamar a la función de asignaciónde memo- ria new. Para extraer un nodo se escribe siguiente[t] = si- guiente[sigui ente[t] ], pero se pierde la posición del array «a la que apunta» sigui ente [t1. Más adelante, se verá cómo podría recuperarse este espacio perdido. La Figura 3.5 muestra cómo se podría representar la lista del ejemplo me- diante arrays paralelosy cómo se relaciona esta representación con la represen-
  • 46. 26 ALGORITMOS EN C++ tación gráfica que se ha estado utilizando. Los arrays cl ave y sigui ente se muestran en el primer diagrama de la izquierda, como aparecerían si se inser- tara T I L S A en una lista inicialmente vacía, con T, I y L insertados después de cabeza, S después de I y A después de T. La posición O es cabeza y la po- sición l es z (se ponen al inicializar la lista) de manera que como si - guiente[0] es 4, el primer elemento de la lista es clave[4] (L); como s i - guiente[4] es 3, el segundo elemento de la lista es clave[3] (I), etc. En el segundo diagrama por la izquierda, los índices para el array s i gui ente se reemplazan por líneas y en lugar de poner un 4 en si guiente[O], se dibuja una línea desde el nodo O hasta el nodo 4, etc. En el tercer diagrama se desen- redan los enlaces para ordenar los elementos de la lista, uno a continuación de otro. Y para concluir, a la derecha aparece la lista en la representación gráfica habitual. Lo esencial del caso es considerar cómo podnan implementarse los proce- dimientos integrados new y del ete. Se supone que el único espacio disponible para nodos y enlaces son los arrays anteriores; esta presunción lleva a la situa- ción en la que se encuentra el sistema cuando tiene que permitir que se au- mente o disminuya el tamaño de una estructura de datos a partir de una estruc- tura fija (la memoria). Por ejemplo, suponiendo que el nodo que contiene a L se debe eliminar del ejemplo de la Figura 3.5, es fácil reordenar los enlaces de manera que el nodo no esté mucho tiempo enganchado a la lista, pero ¿qué ha- cer con el espacio ocupado por ese nodo?, y jcómo encontrar espacio para un nodo cuando se llame a la función new y se necesite más espacio? Reflexionando se ve que la solución está clara: jes suficientecon utilizar otra lista enlazadapara seguirla pista del espaciolibre!, denominándola como la dista libre)). Entonces, al eliminar (delete) un nodo de la primera lista, se inserta en la lista libre y cuando se necesite un nodo new, se obtiene eliminándolo de la Cabeza Figura 3.5 Implementaciónde una lista enlazada mediante un array.
  • 47. ESTRUCTURAS DE DATOS ELEMENTALES 27 hd Figura 3.6 Dos listas compartiendo el mismo espacio. lista libre. Este mecanismo permite tener varias listas diferentes ocupando el mismo array. En la Figura 3.6 se muestra un ejemplo sencillo con dos listas (pero sin lista libre). Hay dos nodos cabeza de lista hdl * = O y hd2 = 6, pero ambas listas pue- den compartir el mismo z. Una implementación típica de C++ que utilice la construcción c1ass tendría un nodo cabeza y un nodo cola asociado a cada lista. Ahora, siguiente[O] es 4, y por tanto el primer elemento de la primera lista es cl ave[4] (N); como siguiente[6] es 7, el primer elemento de la segunda lista es cl ave[71 (D),etc. Los otros diagramas de la Figura 3.6 muestran el re- sultado de remplazar los valores si gui ente por líneas, desenredando los nodos y cambiando la representación gráfica simple, como en la Figura 3.5. Esta misma técnica podría utilizarse para mantener varias listas en el mismo array, una de las cuales debería ser una lista libre, como se describió anteriormente. Cuando el sistema dispone de un gestor de memoria, como ocurre en C++, no hay razón para suplantarlo de esta manera. La descripciónanterior se realiza para indicar cómo hace el sistema la gestión de la memoria. (Si se trabaja con un sistema que no asigna memoria, la descripción anterior proporciona un buen punto de partida para una implementación.) En la práctica, el problema que se acaba de ver es mucho más complejo, ya que no todos los nodos son necesana- mente del mismo tamaño. Además algunos sistemasrelevan al usuario de la ne- cesidad de eliminar explícitamente los nodos, mediante el uso de algoritmos de arecolección de basura», que eliminan de forma automática los nodos que no estén referenciadospor ningún enlace. Para resolver estas dos situaciones se ha * Abreviaturade head1 (cabezal). (N.del T.)
  • 48. 28 ALGORITMOS EN C++ Figura 3.7 Características dinámicas de una pila. desarrollado un buen número de complicados algoritmos de asignación de memoria. Pilas Hasta aquí se ha considerado la forma de estructurar los datos con el fin de in- sertar, eliminar o acceder arbitrariamente a los distintos elementos. En realidad, resulta que para muchas aplicacioneses suficiente con considerar varias restric- ciones (másbien fuertes)sobre la forma de acceder a la estructura de datos. Ta- les restricciones son beneficiosas por dos motivos: primero, porque pueden ali- viar la necesidad que tiene el programa de utilizar la estructura de datos para analizar ciertos detalles (por ejemplo, memorizando los enlaces o los índices de los elementos); segundo, porque permiten implementaciones más simples y fle- xibles, tendiendo a limitar el número de operaciones. La estructura de datos de acceso restrictivo más importante es la pila, en la que sólo existen dos operaciones básicas: se puede meter un elemento en la pila (insertarlo al principio) y se puede sacar un elemento (eliminarlo del princi- pio). Una pila funciona de forma parecida a la bandeja de «entradas» de un eje- cutivo muy ocupado: el trabajo se amontona en la pila y cuando el ejecutivo está preparado para hacer alguna tarea, coge la de la parte más alta del montón. Pudiera ser que algún documento se quedara bloqueado en el fondo de la pila durante algún tiempo, pero un buen ejecutivo se las arregla para conseguir va- ciar la pila periódicamente. A veces los programas de computadora se organi- zan naturalmente de esta manera, posponiendo algunas tareas mientras se ha- cen otras, y por ello las pilas representan una estructura de datos fundaméntal para muchos algontmos. La Figura 3.7 muestra un ejemplo de una pila desarrollada a través de una serie de operaciones meter y sacar, representadas por la secuencia: E *JE * M *P *L *OD* E ***P *ILA *** Cada letra de esta lista significa «meten>(la letra) y cada asterisco significa «sa- cam. C++ proporciona una forma excelente para definir y utilizar estructuras de datos fundamentales, tales como las pilas. La siguiente implementación de las operaciones básicas sobre las pilas es el prototipo de muchas implementaciones
  • 49. ESTRUCTURASDE DATOS ELEMENTALES 29 que se verán más adelante en el libro. La pila está representada por un array p i 1a y un puntero p que apunta hacia lo más alto de la pila -las funciones meter, sacar y vacía son implementaciones directas de las operaciones bási- cas de la pila-. Este código no comprueba si el usuario trata de meter un ele- mento en una pila llena, o de sacar un elemento de una vacía; aunque la fun- ción vacía es una forma de comprobarlo posteriormente. class P i l a p r i v a t e : tipoElemento *pila; i n t p; P i 1a( i n t max=100) { p i l a = new tipoElemento[max]; p = O; } - P i 1 a() { delete p i l a ; } i n l i n e void meter(tipoE1emento v) { pila[p++] = v; } i n l i n e tipoElemento sacar() { r e t u r n p i l a [ - - p ] ; } i n l i n e i n t vacia() { r e t u r n !p; } publ ic : 1; El tipo de elementos contenido en la pila se deja sin especificarcon el nombre t i poElemento, para tener alguna flexibilidad.Por ejemplo, la sentencia type- def in t t ipoElemento ; podría emplearse para tener una pila formada por nú- meros enteros. Al final de este capítulo se presentará una alternativa a esto en C++. El código anterior muestra varias construcciones en C++. La implementa- ción es una c l ass, una de las clases del «tipo definido por el usuario» de C++. Con esta definición, una P i 1a tiene la misma condición que i n t , char o cual- quiera de los otros tipos incorporados. La implementación se divide en dos par- tes, una parte p r i v a t e que especifica cómo están organizados los datos y una parte publ i c que define las operaciones permitidas sobre los datos. Los progra- mas que utilizan pilas necesitan referenciar solamente la parte pública sin preo- cuparse de cómo se implementan las pilas. La función P i 1a es un construe- tom que crea e inicializa la pila y la funcijn - es un ««destructon> que elimina la pila cuando ya no se le necesita. La palabra clave in l ine indica que se reem- plaza la llamada a la función por la función misma, evitando así la sobrecarga de llamadas, que es muy apropiada para funciones cortas como éstas. En los capítulos siguientes se verán muchas aplicacionesde las pilas: un buen
  • 50. 30 ALGORITMOS EN C++ ejemplo de introducción será examinar la utilización de estas estructuras en la evaluación de expresionesaritméticas. Se supone que se desea encontrar el va- lor de una expresión aritmética simple formada por multiplicaciones y sumas de enteros, tal como 5 * ( ( (9 + 8) * (4* 6)) + 7). Para realizar este cálculo, hay que guardar aparte algunos resultados interme- dios: por ejemplo, si se calcula primero 9+8, entonces el resultado parcial (17) debe guardarse en algún lugar mientras se calcula 4 * 6. Una pila constituye el mecanismo ideal para guardar los resultados intermedios de este cálculo. Para comenzar, se reordena sistemáticamentela expresión de modo que cada operador aparezca después de sus dos argumentos, en lugar de aparecer entre ellos. De manera que al ejemplo anterior le corresponde la expresión 5 9 8 + 4 6 * * 7 + * Esto se denomina notaciónpolaca inversa (dado que fue introducida por un cé- lebre lógico polaco), o postfija. La forma normal de escribir expresiones arit- méticas se denomina infija. Una propiedad interesante de la notación postfija es que no se necesitan paréntesis, mientras que en la infija éstos son necesarios para distinguir el orden de las operaciones ya que, por ejemplo, no es lo mismo 5*(((9+8)*(4*6))+7)que ((5*9)+8)*((4*6)+7). Una propiedad todavía más inte- resante de la notación postfija es que proporciona una manera sencilla de eje- cutar el cálculo, guardando los resultados intermedios en una pila. El siguiente programa lee una expresión postfija, interpretando cada operando como una orden para ((introducirel operando en la pila» y cada operador como una orden para «recuperar los dos operandos de la pila, ejecutar la operación e introducir el resultado». char c; Pila acc(50); int x; while (cin.get(c)) x = o; while (c == I I ) cin.get(c); if (c == ' + I ) x = acc.sacar() + acc.sacar() ; if (c == ' * I ) x = acc.sacar() * acc.sacar() ; while jc>='O' && c<='9') { x = 1O*x + (c-'oI); acc.meter(x); { cin.get(c); } } tout << acc.sacar() << 'n'; La pila guardar se declara y define junto con las otras variables del programa
  • 51. ESTRUCTURASDE DATOS ELEMENTALES 31 y las operaciones meter y sacar de guardar se invocan exactamente igual que get para el flujo de entrada cin. Este programa lee cualquier expresiónpostfija formada por multiplicaciones y sumas de enteros y después obtiene el valor de la expresión. Los espacios en blanco se ignoran y el bucle whi 1e convierte a for- mato numérico los enteros que están en formato alfanumérico para poder rea- lizar los cálculos. En C++ no se especifica el orden en que se ejecutan las dos operaciones sacar (), por l o que se necesitará un código algo más complejo para operadores no conmutativos tales como la resta y la división. El siguiente programa convierte una expresión infija, de paréntesis total- mente permitidos, en una expresión postfija: char c; Pila guardar(50); while (cin.get(c)) if (c == I ) ' ) cout.put(guardar.sacar()) ; if (c == I + ' ) guardar.meter(c) ; if (c == ' * I ) guardar.meter(c) ; while (c>='O' && c<='9') { cout.put(c); cin.get(c); } if (c != ' ( I ) cout << I I ; { 1 cout << ' n l ; Los operadores se meten en la pila y los argumentos simplemente pasan a través de ella. Así, los argumentos aparecen en la expresiónpostfija en el mismo orden que en la expresión infija. Entonces cada paréntesis derecho indica que se ob- tienen los dos argumentos del último operador, por tanto el propio operador puede retirarse de la pila e imprimirse como salida. Es interesante observarque como sólo se utilizan operadores con exactamente dos operandos, no se nece- sitan paréntesis izquierdosen la expresión infija (y este programa los omite).Para hacerlo más sencillo, el programa no verifica los errores de la entrada y exige la presencia de espacios entre operadores, paréntesis y operandos. El paradigma aguardar los resultados intermedios» es fundamental, y así las pilas aparecen frecuentemente. Muchas máquinas llevan a cabo las operaciones básicas de pilas en el hardware, al implementar de manera natural mecanismos de llamada a funciones, como, por ejemplo, guardar el entorno actual en la en- trada de un procedimiento introduciendo información en una pila, restaurar el entorno en la salida recuperando la información de la pila, etc. Algunas calcu- ladoras y lenguajesbasan sus métodos de cálculoen operaciones con pilas: cada operaciónobtiene sus argumentos de la pila y devuelvesusresultados a la misma. Como se verá en el Capítulo 5, las pilas aparecen con frecuencia de forma im- plícita aun cuando no se utilicen explícitamente.
  • 52. 32 ALGORITMOS EN C++ Implementaciónde pilas por listas enlazadas La Figura 3.7 muestra el caso típico en el que basta con una pila pequeña, in- cluso aunque haya un gran número de operaciones. Si se está seguro de estar en este caso, entonces resulta apropiada la representación por array. Si no es así sólo una lista enlazada permitirá a la pila crecer y decrecer elegantemente, lo cual resulta especialmente útil si se trabaja con muchas estructuras de datos de este tipo. Para implementar operaciones básicas de pilas utilizando listas enla- zadas, se comienza por definir la interfaz: class P i l a i public: P i l a ( i n t max); void meter(tipoE1emento v); tipoElemento sacar(); i n t vacia(); s t r u c t nodo s t r u c t nodo *cabeza, *z; - P i 1a() ; private: { tipoElemento clave; s t r u c t nodo * siguiente; } }; En C++, esta interfaz sirve para dos propósitos: para utilizar una pila, única- mente se necesita consultar la sección pub1i c de la interfaz para conocer qué operaciones se pueden realizar, y para irnplementar una rutina de pila se con- sulta la sección p ri vate para ver cuálesson las estructuras de datos básicas aso- ciadasa la implementación. Las implementaciones de los procedimientosde pila se separan de la declaración de las clases, llegándose incluso a incluir en un archivo aparte. Esta capacidad para separar las implementaciones de las inter- faces, y por lo tanto para experimentar fácilmente con diferentes implementa- ciones, es un aspecto muy importante de C++ que se tratará con mayor detalle al final de este capítulo. Lo siguiente que se necesita es la función ((constructom para crear la pila cuando se declare y la función ((destructom para eliminarla cuando ya no se necesite (está fuera del alcance del libro): Pi 1a: :Pi1a ( i n t max) cabeza = new nodo; z = new nodo; cabeza->siguiente = z; z->siguiente = z; {
  • 53. ESTRUCTURAS DE DATOS ELEMENTALES 33 i Pila::-Pila() struct nodo *t = cabeza; while (t != z) { { cabeza = t; t->siguiente; delete cabeza; } 1 Para finalizar se muestra una implementación real de las operaciones de la pila: void Pila::meter(tipoElemento v) struct nodo *t = new nodo; t->clave = v; t->siguiente = cabeza->siguiente; cabeza->siguiente = t; tipoElemento Pila::sacar() { 1 { tipoElemento x; struct nodo *t = cabeza->siguiente; cabeza->siguiente = t->siguiente; x = t->clave; delete t; return x; 1 int Pila::vacia() {return cabeza->siguiente== z; } Se aconseja al lector que estudie este código cuidadosamente para reforzar sus conocimientos, tanto de las listas enlazadas como de las pilas. Colas Otra estructura de datos de acceso restrictivo es la que se conoce como cola. En ella, una vez más, solamente se encuentran dos operaciones básicas: se puede insertar un elemento al principio de la cola y se puede eli m i nar un ele- mento del final. Quizás aquel ejecutivotan ocupado podría organizar su trabajo como una cola, ya que entonces el trabajo que le llegue primero lo hará pn- mero. En una pila algún elemento puede quedar sepultado en el fondo, pero en una cola todo se procesa en el orden en que se recibe. La Figura 3.8 muestra un ejemplo de una cola que evoluciona mediante una sucesión de operaciones obtener y poner representadas por la sucesión
  • 54. 34 ALGORITMOS EN C++ Figura 3.8 Característicasdinámicas de una cola. E * J E * M * P * L O * D * * * E * C O * * L A **. donde cada letra de la lista significa «ponen>(la letra) y el asterisco significa «obtenen>. Aunque en la práctica las pilas aparecen con mayor frecuencia que las colas, debido a su relación fundamental con la recursión (ver Capítulo 5), se encon- trarán algoritmos para los que la cola es la estructura de datos natural. En el Capítulo 20 se encontrará una «cola de doble extremo» (deque), que es una combinación de pila y cola, y en los Capítulos 4 y 30 se verán ejemplos funda- mentales que muestran cómo se puede utilizar una cola para realizar un meca- nismo que permita examinar árboles y grafos. A veces se hará referencia a las pilas indicando que obedecen a la ley «last in, first out» (LIFO)(último en en- trar, primero en salir) y ,por su parte, las colas obedecen a la ley «first in, first out» (FIFO)(primero en entrar, primero en salir). La implementación de las operaciones de colas por listas enlazadas es di- recta, y se deja como ejercicio para el lector. AI igual que ocurría con las pilas, también se puede utilizar un array si puede estimarse su tamaño máximo, como en la siguiente implementación de las funciones poner, obtener y vaci a (se omite el código para la interfaz y las funciones constructor y destructor porque son similaresal código dado anteriormente para la implementación de pilas por array): void Cola: :poner(tipoElemento v) cola[rabo++] = v; i f (rabo > talla) rabo = O; { 1 { tipoElemento Cola: :obtener() tipoElemento t = cola[cabeza++];
  • 55. ESTRUCTURAS DE DATOS ELEMENTALES 35 i f (cabeza > t a l l a ) cabeza = O; return t; i n t Cola: :vacia() 1 { return cabeza == rabo; } Hay tres variables de clase: la t a l 1a (tamaño) de la cola y dos índices, uno en cabeza y otro en «rabo» de la cola. El contenido de la cola está formado por todos los elementos del array entre cabeza y rabo, teniendo en cuenta la vuelta a O cuando se llegue al final del array. Si cabeza y rabo son iguales, entonces la cola se define como vacía; pero si poner las hiciera iguales,entonces se define como llena (aunque una vez más no se ha incluido esta comprobación en el có- digo anterior). Esto requiere que el tamaño del array sea mayor, en una unidad, que el número máximo de elementos que se desea incluir en la cola: una cola llena contiene una posición del array vacía. Tipos de datos abstractos y concretos En lo anterior se ha visto que a menudo es conveniente describir los algoritmos y las estructuras de datos en función de las operaciones efectuadas, en lugar de hacerlo en términos de los detalles de la implementación. Cuando se define de esta forma una estructura de datos, se denomina tipo de datos abstracto. La idea es separar el «concepto» de lo que debe hacer la estructura de datos de cualquier implementación particular. La característica genérica de un tipo de datos abstracto es que nada que sea externo a la definición de las estructuras de datos y los algoritmos que operan sobre ellas debe hacer referencia a cualquier cosa interna, excepto a través de llamadas a funciones y procedimientos de las operaciones fundamentales. El motivo principal para el desarrollo de los tipos de datos abstractos ha sido es- tablecer un mecanismo para la organización de grandes programas. Lostipos de datos abstractos proporcionan una manera de limitar el tamaño y la compleji- dad de la interfaz entre algoritmos (potencialmente complicados), las estructu- ras de datos asociadasy los programas (un número potencialmente grande) que utilizan los algoritmos y las estructuras de datos. Esto hace más fácil compren- der los grandes programas, y más conveniente los cambios o mejoras de los al- goritmos fundamentales. Las pilas y las colas son ejemplos clásicosde tipos de datos abstractos ya que muchos programas únicamente necesitan tratar con unas cuantas operaciones básicas bien definidas, y no con detalles de enlaces e índices. Tanto los arrays como las listas enlazadas se pueden obtener por medio de mejoras de un tipo de datos abstracto básico denominado lista lined. En cada uno de ellos se pueden realizar operaciones tales como insertar, eliminar y ac-
  • 56. 36 ALGORITMOS EN C++ ceder en una estructura subyacente básica de elementos ordenados secuencial- mente. Estas operaciones bastan para describir los algoritmos y la abstracción de listas linealespuede ser útil en las etapas inicialesde desarrollo del algoritmo. Pero, como se ha visto, el interés del programador reside en definir cuidadosa- mente qué operaciones se utilizarán, ya que puede haber bastantes característi- cas de rendimiento del algoritmodiferentes para implementacionesdistintas.Por ejemplo, utilizar una lista enlazada en lugar de un array en la criba de Eratós- tenes sena costoso porque la eficacia del algoritmo depende de que sea posible pasar rápidamente desde cualquier posición del array a cualquier otra, y utilizar un array en lugar de una lista enlazada en el problema de Josephus sería tam- bién costoso porque la eficacia del algoritmo depende de la desaparición de los elementos que se eliminan. Las listas lineales sugieren otras muchas operaciones cuya eficacia requiere algoritmos y estructuras de datos mucho más sofisticados. Las dos operaciones más importantes son la ordenación de los elementos en orden creciente de sus claves(tema de los Capítulos 8-13), y la búsqueda de un elemento con una clave dada (tema de los Capítulos 14-18). Un tipo de datos abstracto se puede utilizar para definir a otro. Así, se uti- lizaron las listas enlazadas y arrays para definir pilas y colas; de hecho se em- plearon los conceptos de «puntero» y «registro» (proporcionados por C++) para construir listas enlazadas y el de «array» (proporcionado por C++) para cons- truir arrays. Además, anteriormente se ha visto que se pueden construir listas enlazadas con arrays y en el Capítulo 36 se verá que jalgunas veces los arrays se deben construir con listas enlazadas! El verdadero poder del concepto de tipo de datos abstracto es que permite construir sin inconvenientes grandes sistemas en diferentes niveles de abstracción: desde las instrucciones en lenguaje má- quina proporcionadas por la computadora a las diversas posibilidadesque pro- porciona el lenguaje de programación, o a la ordenación, la búsqueda y otras operaciones de mayor nivel proporcionadas por los algoritmos que se presentan en este libro hasta los niveles aún más altos de abstracción que pueda sugerir la aplicación. En este libro se utilizarán programas relativamente pequeños que están muy relacionados con sus estructuras de datos asociadas. Siempre que sea posible se hablará en términos abstractos de la interfaz entre los algoritmos y sus estruc- turas de datos; es más apropiado enfocar el problema con un mayor nivel de abstracción (resulta más próximo a la aplicación):el concepto de abstracción no debe distraer de la búsqueda de la solución más eficaz de un problema concreto. ¡El rendimiento es lo que importa! Los programas que se han desarrollado te- niendo esto en cuenta se pueden utilizar con cierta confianza al desarrollar los niveles de abstracción superiores de los grandes sistemas. Las implementaciones de operaciones de colas y pilas de este capítulo son ejemplos de tipos de datos concretos,que guardanjuntas las estructuras de datos y los algoritmos que operan sobre ellas. A lo largo de este libro se utilizará fre- cuentemente este paradigma, ya que es una forma muy conveniente de descri- bir los algoritmos básicos, a la vez que desarrolla un código útil para emplearlo
  • 57. ESTRUCTURASDE DATOS ELEMENTALES 37 en las aplicaciones. C++ proporciona un medio de implementar verdaderos ti- pos de datos abstractos utilizando la ((jerarquíade clases» y «las funciones vir- tuales», en las que la interfaz consta solamente de funciones (node la represen- tación de los datos), pero en este libro no se utilizará este recurso porque la meta es el conocimiento de las características del rendimiento, lo cual es difícil de mantener cuando se utilizan verdaderos tipos de datos abstractos. Como se mencionó anteriormente, las estructuras de datos reales rara vez constan simplementede enteros y enlaces. Con frecuencia, los nodos contienen una gran cantidad de información y pueden pertenecer a múltiples estructuras de datos independientes. Por ejemplo, un archivo con los datos del personal, puede contener registros con nombres, direcciones y otros elementos de infor- mación sobre los empleados, y cada registro puede pertenecer a una estructura de datos destinada a la búsqueda de un empleado particular o a otra estructura de datos destinada al estudio de estadísticas,etc. C++ tiene un mecanismo ge- neral, denominado templ ate,que proporciona una forma fácil de ampliar los algoritmossimplespara trabajar en estructurascomplejas. Si en lugar de utilizar typedef se coloca el código template <class tipoElemento> justo antes de las definiciones de cl ases de este capítulo, las convierte en definiciones de estructuras que trabajan con cualquier tipo de dato. Por ejemplo, esto permiti- ría una declaración como Pi 1 a <f1oat > acc ( ) utilizada para construir una pila de f 1 oats. En general, en este libro se trabajará con enteros, pero se man- tendrán sin especificar los tipos, como en este capítulo, en el entendimiento de que typedef o template se pueden utilizar fácilmente en aquellas aplicaciones que se crea conveniente. Ejercicios 1. Escribir un programa que llene un array bidimensional de valores boolea- nos poniendo a [i ] [ j] a 1 si el máximo común divisor de i y j es 1 y a O en cualquier otro caso. 2. Implementar una rutina despl azasiguienteacabeza(struct nodo *t) en una lista enlazada, para desplazar al comienzo de la lista el nodo si- guiente al nodo que apunta t. (La Figura 3.3 es un ejemplo de esta opera- ción para el caso especial en que t apunte al nodo siguiente d úitimo de la lista.) 3. Implementar unarutina intercambio(struct nodo *t, struct nodo *u) en una lista enlazada, para intercambiar las posicionesde los nodos siguien- tes a los nodos apuntados por t y u. 4. Escribir un programa para resolver el problema de Josephus, utilizando un array en lugar de una lista enlazada. 5. Escribir procedimientos para insertar y eliminar en una lista doblemente enlazada.
  • 58. 38 ALGORITMOS EN C++ 6. 7. 8. 9. 10. Escribir procedimientos para la representación de una pila por una lista en- lazada, pero utilizando arrays paralelos. Obtener el contenido de la pila después de cada operación en la sucesión C U E * S * * T I O * * * N F * * * A * C I * L *. Aquí una letra significa «meten>(introducir la letra) y un «*» significa «sacan>(sacarla). Obtener el contenido de la cola después de cada operación en la sucesión C LJ E * S * * T I O * * * N F * * * A * C I * L *. Aquí una letra significa «ponen>(poner la letra) y un a*» significa «obtenen>(tomarla). Obtener una sucesión de llamadas a el iminasiguiente e insertades- pues que podría haber generado la Figura 3.5 desde una lista inicialmente vacía. Implementarlas operaciones básicas de una cola utilizando una lista enla- zada.
  • 59. 4 Arboles Las estructuras presentadas en el Capítulo 3 son intrínsecamente unidimensio- nales: un elemento siguea otro. En este capítulo se considerarán las estructuras enlazadas de dos dimensiones denominadas árboles, que se encontrarán en el desarrollode muchos de los algoritmos más importantes que se tratan a lo largo de este libro. Un estudio sobre árboles podría ocupar un libro entero, ya que se utilizan en muchas aplicaciones, además de en informática, y se han estudiado con profusión como objetos matemáticos. Desde luego, podría decirse que este libro es en sí mismo un tratado sobre árboles, ya que están presentes, de forma fundamental, en cada una de las seccionesdel libro. En este capítulo se estudia- rán la terminología y las definiciones básicas asociadas a los árboles, se exami- narán algunas propiedades importantes y se verá la forma de representar árbo- les mediante una computadora. En capítulos posteriores, se verán muchos algoritmos que operan con estas estructuras de datos elementales. Los árboles se encuentran con frecuencia en la vida cotidiana, y el lector se- guramente está familiarizadocon el concepto básico. Por ejemplo, mucha gente hace el seguimientode sus antepasadoso descendientes,o ambas cosas, mediante un árbol genealógico, y así gran parte de la terminología se deriva de esta apli- cación. Otro ejemplo es el caso de la organización de competiciones deportivas, que fue estudiado por Lewis Carroll y se verá en el Capítulo 11. Un tercer ejem- plo es el organigrama de una gran empresa; este caso sugiere la edescomposi- ción jerárquica» que aparece en muchas aplicaciones de la informática. Un cuarto ejemplo es el «árbol de análisissintáctico» que descompone una oración gramatical en las partes que la forman; este proceso está íntimamente relacic- nado con el procesamiento de lenguajes de computadora, que se tratará en el Capítulo 21. A lo largo del libro se verán otros ejemplos. 39
  • 60. 40 ALGORITMOS EN C++ Glosario Este estudio de los árboles comienza definiéndolos como objetos abstractos e introduciendo la mayor parte de la terminología básica asociada. Los árboles se pueden definir de diferentes maneras, ya que hay una serie de propiedades ma- temáticas que implican esta equivalencia; esto se analizará con más detalle en la siguiente sección. Un árbol es un conjunto no vacío de vérticesy aristas que cumple una serie de requisitos. Un vértice es un objeto simple (también conocido como un nodo) que puede tener un nombre y puede llevar otra información asociada: una arista es una conexión entre dos vértices. Un camino en un árbol es una lista de vér- tices distintos en la que dos consecutivos se enlazan mediante aristas. A uno de los nodos del árbol se le designa como la raíz. La propiedad que define a un árbol es que hay exactamente un camino entre la raíz y cada uno de los otros nodos del árbol. Si hay más de un camino entre la raíz y un nodo, o si no existe ninguno entre la raíz y algún nodo, entonces se trata de un grafo (ver Capítulo 29) y no de un árbol. La Figura 4.1 muestra un ejemplo de árbol. Aunque la definición no implica la «dirección» de la arista, normalmente se representan las aristas apuntando hacia afuera de la raíz (hacia abajo en la Fi- gura 4.I) o hacia la raíz (hacia amba en la Figura 4.1), dependiendo de la apli- cación de la que se trate. Por lo regular, los árboles se dibujan con la raíz en la parte más alta (aunque en principio esto parezca antinatural), y se dice que el nodo y está debajo del nodo x (y x está por encima de y ) si xestá en el camino que va desde y a la raíz (es decir, y está debajo de x si existe un camino que lo conecte con xy que no pase por la raíz). Cada nodo (exceptuando la raíz) tiene exactamente un nodo inmediatamente encima de él, al que se denomina como su padre;mientras que a los nodos que tiene directamente por debajo se les de- nomina sus hijos. Algunas veces, siguiendo esta analogía familiar, se habla del «abuelo» o del «hermano» de un nodo: en la Figura 4.1, L es elaieto de E y tiene tres hermanos. A los nodos sin hijos se les denomina a veces hojas, o nodos terminales. En correspondencia con su utilización posterior, a los nodos con al menos un hijo algunas veces se les denominará nodos no terminales. Los nodos terminales son Figura 4.1 Un ejemplo de árbol.
  • 61. ÁRBOLES 41 frecuentemente diferentes de los no terminales: por ejemplo, pueden no tener nombre o información asociada. En tales situaciones, se hará referencia a los nodos no terminales como nodos internos y a los nodos terminales como nodos externos. Cualquier nodo es la raíz de un subárbol constituido por él mismo y por los nodos situados debajo. En el árbol que se muestra en la Figura 4.1, hay siete subárboles de un nodo, un subárbol de tres nodos, un subárbol de cinco nodos y un subárbol de seis nodos. A un conjunto de árboles se le denomina bosque: por ejemplo, si se suprime la raíz y las aristas que la unen al árbol de la Figura 4.1, se obtiene un bosque formado por tres árboles de raíces B, E y O. El orden en que se ccloca a los hijos de un nodos es a veces significativo,y otras veces no. Un árbol ordenado es aquel en el que se ha especificadoel orden de los hijos de todos los nodos. Por supuesto, los hijos se colocan en un orden determinado cuando se dibuja un árbol, y hay muchas formas diferentes de di- bujar un árbol que no esté ordenado. Como se verá más adelante, es importante hacer esta distinción entre árbolesordenadosy no ordenadosa la hora de repre- sentar los árbolespor computadora, ya que hay mucha menos flexibilidaden la representación de árbolesordenados. Naturalmente será la aplicación la que de- termine el tipo de árbol que se debe utilizar. Los nodos de un árbol se estructuran en niveles: el nivel de un nodo es el número de nodos del camino que lleva desde éste hasta la raíz (sin incluirse a sí mismo). Así, por ejemplo, en la Figura 4.1, E es un nodo de nivel 1 y R es de nivel 2. La altura de un árbol es el nivel máximo del árbol (o la máxima distan- cia entre la raíz y cualquier nodo). La longitud del carniro de un árbol es la suma de los niveles de todos los nodos del árbol (es decir, la suma de las longitudes de los caminos desde cada nodo a la raíz). El árbol de la Figura 4.1 tiene altura 3, y la longitud del camino es 21. Una vez que se han distinguido los nodos internos de los nodos externos, se puede hablar de la longitud del camino in- terno y de la longitud del camino externo. Si cada nodo debe tener un número específico de hijos colocados en un or- den determinado, entoncesse tiene un árbol multicamino. En estetipo de árbol conviene definir nodos externos especiales que no tienen hijos (y normalmente ni nombre ni ninguna otra información asociada). En este caso los nodos exter- nos actúan como nodos «ficticios» para referencia de nodos que no tienen el número de hijos especificado. En particular, el caso más sencillode árbol multicamino es el árbol binario, que es un árbol ordenado que está formado por dos tipos de nodos: nodos ex- ternos, sin hijos, y nodos internos, que tienen exactamente dos hijos. En la Fi- gura 4.2se muestra un ejemplo de un árbol binario. Como los dos hijos de cada nodo interno están ordenados, se hará referencia al hijo de la izquierda y al hijo de la derecha: todos los nodos internos deben tener los dos hijos, uno a la iz- quierda y otro a la derecha, aunque uno o los dos podrían ser nodos externos. El objetivo de los árboles binarios es estructurar los nodos internos, ya que los externos sirven únicamente como ((reservade plaza» y se incluyen en la de- finición porque las representaciones más usuales de árboles binarios necesitan
  • 62. 42 ALGORITMOS EN C++ ó h Figura 4.2 Un ejemplo de árbol binario. conocer a todos sus nodos externos. Un árbol binario puede estar «vacío» si contiene un nodo externo y ninguno interno. Un árbol binario lleno es aquel en el que los nodos internos llenan todos los niveles, con la posible excepción del último. Un árbol binario completo es un árbol binario lleno en el que los nodos internos del último nivel aparecen todos a la izquierda de los nodos externos de ese mismo nivel. La Figura 4.3 muestra un ejemplo de un árbol binario completo. Como se verá más adelante, los ár- boles binarios aparecen muchas veces en aplicacionesde computadoras, siendo mejor su rendimiento cuando están llenos (o casi llenos). En el Capítulo 11 se estudiará una estructura de datos basada en los árboles binanos completos. El lector debería observar con gran cuidado que, mientras que todo árbol binario es un árbol, no todo árbol es un árbol binario. Incluso considerando únicamente árboles ordenados en los que todos los nodos tienen O, 1 o 2 hijos, cada uno de ellos puede corresponder a muchos árbolesbinarios, porque los no- dos con 1 hijo pueden estar a la izquierda o a la derecha de un árbol binario. En el próximo capítulo se verá que los árboles están íntimamente ligados con la recursión. De hecho, la forma más sencilla de definir árboles es la recur- siva, como se indica a continuación: «un árbol es o un nodo aislado o un nodo raíz conectado a un conjunto de árboles» y «un árbol binano es o un nodo ex- terno o un nodo raíz (interno) conectado a un árbol binano izquierdo y a un árbol binano derecho». Figura 4.3 Un árbol binario completo.
  • 63. ÁRBOLEC 43 Propiedades Antes de tratar las representaciones es preciso ver el aspecto matemático, con- siderando una sene de propiedades importantes de los árboles. Una vez más aparece un gran número de propiedades a examinar, pero aquí con el objeto de tratar aquellas que son particularmente importantes para los algoritmos que se verán en el libro. Propiedad 4.1 Dados dos nodos cualesquierade un árbol, existe exactamente un camino que los conecta. Dados dos nodos cualesquiera, se verifica que tienen un antecesor común mi- nimo, es decir un nodo que está en el camino de ambos nodos hacia la raíz pero de manera que ninguno de sus hijos tiene esta misma propiedad. Por ejemplo, C es el antecesor común más pequeño de R y L en el árbol de la Figura 4.3.El antecesor común mínimo debe existir siempre porque, o bien es la raíz, o bien ambos nodos están en un subárbol enraizado en uno de los hijos de la raíz; en este último caso, o bien ese nodo es el antecesor común mínimo, o ambos no- dos están en el subárbol enraizado en uno de sus hijos, etc. Existe un camino desde cada uno de los nodos al antecesor común mínimo: componiendo estos dos caminos se obtiene un camino que conecta a los dos nodos. w Una consecuencia importante de la propiedad 4.1 es que cualquier nodo puede ser la raíz, ya que, dado cualquier nodo de un árbol, existe exactamente un camino que lo conecta con cualquier otro nodo del árbol. Técnicamente la definición en la que Ia raíz está identificada es la del árbol enraizado u orien- tado. A un árbol en el que la raíz no está identificada se le denomina árbol libre. El lector no necesita saber más sobre estas diferencias: o la raíz está identificada, o no lo está. Propiedad 4.2 Esta propiedad se deduce directamente del hecho de que cada nodo, excepto la raíz, tiene un único padre, y toda arista conecta a un nodo con su padre. También es posible probar este hecho por inducción a partir de la definición recursiva. a Las dos siguientes propiedades pertenecen a los árboles binanos. Como se mencionó anteriormente, estas estructuras se encontrarán muy a menudo a lo largo del libro, de manera que merece la pena prestar atención a sus caractens- ticas. Esto servirá como trabajo preparatorio para comprender el comporta- miento representativo de diversosalgoritmos que se encontrarán más adelante. Propiedad 4.3 Un árbol binario con N nodos internos tiene N + I nodos exter- nos. Esta propiedad se puede demostrar por inducción. Un árbol binario sin nodos internos tiene un nodo externo, de manera que la propiedad se verifica para N = O. Para N > O, cualquier árbol binano con N nodos internos tienen k nodos Un árbol con N nodos tiene N - 1 aristas.
  • 64. 44 ALGORITMOS EN C++ internos en el subárbol de la izquierda y N - I - k nodos internos en el subárbol de la derecha para k entre O y N - I , ya que la raíz es un nodo interno. Por la hipótesis de inducción, el subárbol izquierdo tiene k + 1 nodos externos y el subárbol derecho tiene N - k nodos externos, lo que hace un total de N + 1. Propiedad 4.4 La longitud del camino externo de cualquier árbol binario con N nodos internos es 2N mayor que la longitud del camino interno. Esta propiedad también se puede probar por inducción, pero es igualmente ins- tructiva una demostración alternativa. Se observa que cualquier árbol binano puede construirse mediante el siguiente proceso: se comienza con un árbol bi- nano formado por un nodo externo, a continuación se repite la siguiente ope- ración N veces: coger un nodo extemo y reemplazarlo por un nuevo nodo in- terno con dos nodos externos como hijos; si el nodo externo elegido es de nivel k, la longitud del camino interno aumenta en k,pero la longitud del camino externo aumenta en k+2 (se ha eliminado un nodo externo de nivel k,pero se han añadido dos de nivel k + 1). El proceso comienza con un árbol cuya longi- tud de camino externo e interno es O y que en cada uno de los N pasos aumenta la longitud del camino externo 2 unidades más que la del camino interno. w Finalmente, se considera una propiedad elemental de la «mejon>clase de ár- boles binarios, los árboles llenos. Estos árboles son interesantes porque garanti- zan que su altura será baja, de manera que no costará mucho trabajo ir de la raíz a cualquier nodo o viceversa. Propiedad 4.5 La altura de un árbol binario lleno con N nodos internos es aproximadamente 10g2N. Haciendo referencia a la Figura 4.3, si la altura es n se debe tener . 2n-' < N + 1 d 2n, ya que hay N + 1 nodos externos. Esto implica la propiedad enunciada. (En rea- lidad la altura es exactamente igual al resultado de redondear log2N al ente- ro más próximo, pero, como se verá en el Capítulo 6, no hay que ser tan preciso.) Otras propiedades matemáticas de los árboles se irán presentando según se vayan necesitando en los capítulos posteriores. En este momento ya se puede comenzar con las consideraciones prácticas de la representación de los árboles en la computadora y su manipulación eficaz. Representación de arboles binarios La representación más frecuente de los árboles binanos consiste en utilizar re- gistros con dos enlaces por nodo. Normalmente, se llamará a los enlaces izq y der (izquierda y derecha) para indicar el orden elegido en la representación que corresponde a la forma en que se ha dibujado el árbol en la página. En algunas
  • 65. ÁRBOLES 45 aplicaciones puede resultar apropiado tener dos tipos de registrosdiferentes, uno para los nodos internos y otro para los externos; en otras puede ser más ade- cuado utilizar sólo un tipo de nodo y emplear los enlaces a los nodos externos para otros propósitos. Como modelo de la construcción y utilización de los árboles binarios se continuará con el ejemplo del capítulo anterior, que trata del procesamiento de expresionesaritméticas. Como se muestra en la Figura 4.4, hay una correspon- dencia directa entre las expresiones aritméticas y los árboles. Se emplearán como identificadores de los argumentos caracteres sencillosen lugar de números (la razón se explicará más adelante). El árbol de análisis sin- táctico de una expresión se define por la siguienteregia recursiva: «poner el ope- rador en la raíz y a continuación construir el árbol de la expresión correspon- diente al primer operando a la izquierda y el de la expresión correspondiente al segundo operando a la derecha. La Figura 4.4 es el árbol de análisis sintáctico de A B C + D E **F + * (la misma expresión en postfija). Obsérvese que infija y postfija son dos formas de representar expresiones aritméticas, siendo los ár- boles de análisis sintáctico una tercera. Figura 4.4 Árbol de análisis sintáctico para A ((( B + C) ( D E ) ) + F). Como los operadores tienen exactamente dos operandos, lo adecuado para este tipo de expresioneses un árbol binario, pero otras expresionesmás compli- cadas podrían necesitar un tipo de árbol diferente. En el Capítulo 21 se revisa- rán estos resultados con más detalle, aunque por ahora el objetivo es la simple construcción de un árbol que represente una expresión aritmética. El siguientecódigo construye el árbol de análisissintáctico de una expresión aritmética a partir de una representación de entrada postfija. Se trata de una ligera modificación del programa del capítulo anterior que evaluaba expresio- nes postfijas utilizando una pila. En lugar de guardar los resultados de los cálcu- los intermedios en la pila, se guarda la expresión de los árboles, como en la si- guiente implementación: struct nodo struct nodo *x, *z; char c; Pila pila(50); { char info; struct nodo *izq, *der; };
  • 66. 46 ALGORITMOS EN C++ z = new nodo; z->izq = z; z->der = z; whi 1e (cin.get ( c ) ) i while (c == I I ) cin.get(c); x = new nodo; x->info = c; x->izq = z; x->der =z; if (c=='+l I( c==l*l) { x->der = pila.sacar(); x->izq = pila.sacar(); ) pi 1 a.meter(x) ; Se utiliza el tipo de pila del Capítulo 3, con un declaración typedef apropiada de modo que se ponen en la pila punteros en lugar de enteros. Todos los nodos tienen un carácter y dos enlaces a otros nodos. Cada vez que se encuentra un nuevo carácter distinto del espacioen blanco, se crea un nodo utilizando el ope- rador estándar de C++ new que llama a un constructor y asigna espacio en la memoria. Si se trata de un operador, los subárboles de sus operandos están en lo más alto de la pila, como en una evaluación postfija. Si es un operando, en- tonces sus enlaces son nulos. En vez de utilizar enlaces nulos, se utilizará un nodo ficticio zcuyos enlacesapuntan a él mismo. Esto facilita la representación de ciertas operaciones mediante árboles (ver Capítulo 14). La Figura 4.5 mues- tra los pasos intermedios de la construcción del árbol de la Figura 4.4. o Figura 4.5 Construccióndel árbol de analicis sintáctico de A 6 C +D E F + *. Este programa, más bien sencillo,puede modificarsepara tratar expresiones más complicadas que utilicen operadores con un único argumento, tales como la exponenciación. El procedimiento es muy general; es exactamente el mismo que se utiliza, por ejemplo, para analizar y compilar programas en C++. Una vez creado el árbol de análisis sintáctico se puede utilizar para muchas cuestio- nes, como para evaluar la expresión o crear programas de computadora para evaluarla. En el Capítulo 21 se presentarán procedimientos generalespara cons- truir árboles de análisis; en éste se verá cómo se puede utilizar un árbol para
  • 67. ÁRBOLES 47 evaluar la expresión, si bien el interés de este capítulo se centra más en el pro- cedimiento de construcción del árbol. Al igual que en las listas enlazadas, siempre existe la alternativa de utilizar arrays paralelos en lugar de punteros y registros para implementar la estructura de datos de un árbol binario. Como se dijo con anterioridad, esto es especial- mente útil cuando se conoce de antemano el número de nodos; incluso en el caso particular en que los nodos necesiten ocupar un array para algún otro pro- pósito se acudirá a esta alternativa. La representación por doble enlace de los árboles binarios antes utilizados permite descender por el árbol, pero no proporciona una forma de ascender por él. La situación es análoga a la de las listas enlazadas simples frente a las listas doblemente enlazadas: se puede añadir otro enlace a cada nodo para permitir más libertad de movimientos, pero con el coste de una implementación más complicada. Existen otras opciones diferentes entre las estructuras de datos avanzadas que facilitan el movimiento a lo largo del árbol; pero para los algo- ritmos de este libro generalmente bastará la representación de doble enlace. En el programa anterior se utilizó un nodo «ficticio» en lugar de nodos ex- ternos. Al igual que en las listasenlazadas, este cambio será conveniente en mu- chas situaciones,pero no siempre será apropiado, pues hay otras dos soluciones comúnmente utilizadas. Una de ellas consiste en emplear un tipo diferente de nodo sin enlaces, para los nodos externos. Otra solución consiste en marcar los enlaces de alguna manera (para distinguirlos de otros enlaces del árbol), y hacer que apunten a otra parte del árbol (un método de este tipo se expondrá a con- tinuación). Este tema se revisará en los Capítulos 14 y 17. Representaciónde bosques Los árboles binarios tienen dos enlacesdebajo de cada nodo interno, de manera que la representación de árboles empleada anteriormente para ellos es inme- diata. Pero ¿qué hacer con los árboles en general, o con los bosques, en los que cada nodo puede tener un número aleatorio de enlaces hacia su descendencia? La respuesta es que existen dos maneras relativamente sencillas de salir de este dilema. En primer lugar, en muchas aplicacionesno se necesita recorrer el árbol ha- cia abajo, sino iúnicamente hacia arriba! En tales casos, sólo se necesita un en- lace para cada nodo; el que lo conecta a su padre. La Figura 4.6 muestra esta representación para el árbol de la Figura 4.1: el array a contiene la información asociada a cada registro y el array papa contiene los enlacespadre. Así, la infor- mación asociada al padre de a [i] está en a [papa [i] 1, etc. Por conveniose hace que la raíz se apunte a sí misma. Ésta es una representación bastante compacta y muy recomendable para trabajar con árboles si sólo se recorren hacia amba. Se verán ejemplos de la utilización de esta representación en los Capítulos 22 y 30.
  • 68. 48 ALGORITMOSEN C++ k 1 2 3 4 5 6 7 8 9 1 0 1 1 papa[k] 3 3 10 8 8 8 8 9 10 10 10 Figura 4.6 Representacióndel enlace padre de un árbol. Para representar bosques por el procedimiento descendente se necesita una forma de tratar los hijos de cada nodo sin tener que asignar de antemano un número determinado de hijos para cada uno. Pero éste es exactamente el tipo de restricción para la que se diseñaron las listas enlazadas. Claro está, debe uti- lizarse una lista enlazada para los hijos de cada nodo. Entonces cada nodo con- tiene dos enlaces, uno para la lista enlazada que lo conecta con sus hermanos y otro para la lista enlazada de sus hijos. La Figura 4.7 muestra esta representa- ción para el árbol de la Figura 4.1. En lugar de utilizar un nodo ficticio para terminar cada lista, es preferible obligar a que el último nodo vuelva a apuntar hacia su padre; de esta manera es posible moverse a través del árbol, tanto hacia amba como hacia abajo. (Estos enlaces pueden marcarse para distinguirlos de los enlaces «hermanos»; de forma alternativa, se pueden explorar completa- mente los hijos de un nodo marcando o guardandoel nombre del padre de ma- nera que se pueda interrumpir la exploración cuando se vuelva a encontrar al padre.) Pero en esta representación cada nodo tiene exactamente dos enlaces (uno hacia el hermano de la derecha y otro hacia el hijo que está más a la izquierda). Se puede preguntar si existe alguna diferencia entre esta estructura de datos y un árbol binario. La respuesta es que no existe, como se muestra en la Figura 4.8 (el árbol de la Figura 4.1 representado como un árbol binario). Esto es, cual- quier bosque puede representarse por un árbol binano haciendo que el enlace de la izquierda de cada nodo apunte al hijo que queda más a la izquierda y que el enlace de la derecha de cada nodo apunte a su hermano de la derecha. (Este hecho sorprende muy a menudo al principiante.) Por tanto, es posible utilizar bosques siempre que sea conveniente para el diseño del algoritmo. Cuando se quiere ascender por el árbol, la representación Figura 4.7 Representaciónde un árbol por el hijo más a la izquierda y el hermano de la derecha.
  • 69. ÁRBOLES 49 d Figura 4.8 Representaciónde un árbol medianteun árbol binario. del enlace padre hace que los bosques sean más fáciles de tratar que cualquier otra clase de árbol, y cuando se desea descender los bosques son esencialmente equivalentes a los árboles binarios. Recorrido de los árboles Una vez que se ha construido el árbol, lo primero que se necesita es saber cómo recorrerlo, es decir, cómo visitar sistemáticamente todos los nodos. Esta opera- ción es trivial para las listaslinealespor su definición, pero para los árboles exis- ten diferentes formas de hacerlo que difieren sobre todo en el orden en que se recorren los nodos. Como se verá, el orden más apropiado depende de cada aplicación concreta. Por el momento se estudia el recomdo de árbolesbinarios; debido a la equi- valencia entre bosques y árboles binarios, los métodos descritos son también útiles para bosques, aunque más adelante también se mencionarán métodos que se aplican directamente a los bosques. El primer método a considerar es el recomdo en orden previo (preorden), que puede utilizarse, por ejemplo, para escribirla expresión representada por el árbol de la Figura 4.4 en prefijo. Este método se define mediante una simple regla recursiva: «visitar la raíz, después el subárbol izquierdo y a continuación el subárbolderecho.))Se mostrará en el próximo capítulo la implementación más sencilla de este método, la recursiva, que está estrechamente relacionada con la siguiente implementación basada en una pila: recorrer(struct nodo *t) pi 1 a .meter(t ) ; while (!pila.vacia()) { t = pila.sacar(); visitar(t);
  • 70. 50 ALGORITMOS EN C++ if (t->der != z) pila.meter(t->der); if (t->izq != z) pila.meter(t->izq); 1 1 Siguiendo la regla, «se visita un subárbol)) después de visitar primero la raíz. Como no se pueden visitar los dos subárboles a la vez, se guarda el subárbol derecho en una pila y se visita el subárbol izquierdo. Cuando termine esta visita el subárbol derecho estará en lo más alto de la pila y podrá visitarse. La Figura 4.9 muestra este programa aplicado al árbol binario de la Figura 4.2: el orden en el que se visitan los nodos es L O R A B M O E D O L. Figura 4.9. Recorrido en orden previo (preorden). Para demostrar que efectivamente el programa visita los nodos del árbol por orden previo, se puede emplear el método de inducción, tomando como hipó- tesis inductiva que los subárboles se visitan en orden previo y que el contenido de la pila justo antes de visitar un subárbol es el mismo que justo después de visitarlo. El segundo método a considerar es el recomdo en orden, que puede utili- zarse, por ejemplo, para escribir las expresiones aritméticas en infija correspon- dientes a los árboles de análisis sintáctico (con algún trabajo extra para obtener
  • 71. 51 ARBOLES Figura 4.10. Recorrido en orden simétrico. los paréntesis de la derecha), Al igual que en el orden previo, el recorrido en orden se define con la regla recursiva ((visitarel subárbol izquierdo, a continua- ción la raíz y después el subárbol derecho». A veces a este método también se le denomina recomdo en orden simétrico, por razones evidentes. La implemen- tación de un programa (basado en una pila) para recorrer el árbol por orden simétrico es casi idéntica al programa anterior; se omite aquí porque constituye el tema principal del siguiente capítulo. En la Figura 4.10 se ve cómo se visitan en orden simétrico los nodos del árbol de la Figura 4.2:los nodos se visitan en el orden A R B O L M O D E L O. Este método de recomdo es probablemente el más utilizado, ya que, por ejemplo, ejerce un papel fundamental en las apli- caciones de los Capítulos 14 y 15. El tercer tipo de recorrido recursivo, llamado orden posterior (postorden),se define mediante la siguiente regla recursiva: ({visitar el subárbol izquierdo, a continuación el subárbol derecho y después la raíz». La Figura 4.1 1 muestra cómo se visitan los nodos del árbol de la Figura 4.2 en orden posterior: A B R O D L O E O M L. Si se visita el árbol de la Figura 4.4 en orden posterior se obtiene la expresión A B C +D E * *F + *, como era de esperar. La implemen- tación de un programa (basado en una pila) para recorrer un árbol en orden posterior es más complicada que la de los otros dos recorridos, porque el pro-
  • 72. 52 ALGORITMOS EN C++ Figura 4.11. Recorridoen orden posterior (postorden). grama debe organizarse para guardar la raíz y el subárbol derecho mientras se visita el subárbol izquierdo, y para guardar la raíz mientras se visita el subárbol derecho. Los detalles de esta implementación se dejan como ejercicio para el lector. La cuarta estrategia de recorrido que se considera no es del todo recursiva, ya que simplemente se visitan los nodos según van apareciendo en la página, leyendo de amba abajo y de izquierda a derecha. Este método se denomina re- corrido en orden de nivel porque todos los nodos de cada nivel aparecenjuntos, en orden. La Figura 4.12 muestra cómo se visitan los nodos del árbol de la Fi- gura 4.2 si se recorren por orden de nivel. Singularmente, el recomdo en orden de nivel se puede conseguir empleando el programa anterior para orden previo, utilizando una cola en lugar de una pila: recorrer(struct nodo *t) col a.poner( t) ; while (!cola.vacia()) { i t = cola.obtener(); visitar(t);
  • 73. ÁRBOLES 53 if (t->izq != z) cola.poner(t->izq); if (t->der != z) cola.poner(t->der); Por una parte este programa es virtualmente idéntico al anterior, ya que la única diferencia es que éste utiliza una estructura de datos FIFO mientras que el otro utiliza una estructura de datos LIFO. Por la otra, estos programas procesan los árboles de forma fundamentalmente diferente. Estos programas merecen un es- tudio cuidadoso, ya que muestran las diferencias esenciales entre las pilas y las colas. Se volverá sobre este tema en el Capítulo 30. Figura 4.12. Recorrido en orden de nivel. Los recomdos en orden previo, orden posterior y orden de nivel también se pueden definir para bosques. Para hacer coherentes las definiciones, basta con representar un bosque como un árbol con una raíz imaginaria. Entonces la re- gla del orden previo queda como «visitar la raíz y después cada uno de los sub- árboles));y la regia para el recorrido en orden posterior queda como «visitar cada uno de los subárboles y después la raíz)). La regla del recomdo por orden de nivel es la misma que para los árboles binarios. Cabe destacar el hecho de que el orden previo para un bosque es el mismo que el orden previo para su corres-
  • 74. 54 ALGORITMOS EN C++ pondiente árbol binario, como se vio anteriormente, y que el orden posterior para un bosque es igual que el recorrido por orden simétrico para el árbol bi- nario; pero no ocurre lo mismo para el recorrido por orden de nivel. Las imple- mentaciones directas que utilizan pilas y colas son sencillas generalizacionesde los programas dados con anterioridad para los árboles binarios. Ejercicios 1. Indicar el orden en el que se visitarán los nodos del árbol de la Figura 4.3 si se recorre en orden previo, orden simétrico, orden posterior y orden de nivel. 2. ¿Cuál es la altura de un árbol cuatemario completo con N nodos? 3. Dibujar el árbol de análisis sintáctico de la expresión ( A + B ) * C + ( D + 4. Considerandoel árbol de la Figura 4.2 como un bosque que se ha represen- 5. Indicar el contenido de la pila cada vez que se visita un nodo durante el 6. Indicar el contenido de la cola cada vez que se visita un nodo durante el 7. Dar un ejemplo de un árbol para el que la pila utiliza más espacio al reco- 8. Dar un ejemplo de un árbol para el que la pila utiliza menos espacio al re- 9. Dar una implementaciónbasada en una pila del recorrido en orden poste- 10. Escribir un programa para implementar un recorrido en orelen de nivel de E )- tado como un árbol binario; dibujar esa representación. recorrido en orden previo representado en la Figura 4.9. recorrido en orden de nivel representado en la Figura 4.12. rrerlo en orden previo que la cola al recorrerlo en orden de nivel. correrlo en orden previo que la cola al recorrerlo en orden de nivel. nor de un árbol binario. un bosque representado como un árbol binario.
  • 75. 5 Recursión La recursión es un concepto fundamentalen matemáticas e informática. La de- finición más sencilla es que un programa recursivo es aquel que se llama a sí mismo (y una función recursiva es aquella que se define en términos de sí misma). Sin embargo, como un programa recursivo no puede llamarse a sí mismo siempre, porque nunca se detendría (y una función recursiva no puede definirse siempre en términos de sí misma, o la definición sería cíclica), otro ingrediente esencial de la definición es que debe contener una condición de ter- minación que autoriza al programa a dejar de llamarse a sí mismo (y a que la función deje de definirse en términos de sí misma). Todos los cálculos prácticos pueden expresarse de una forma recursiva. El primer objetivo de este capítulo será examinarla recursión como una he- rramienta práctica. En primer lugar, se darán algunos ejemplos en los que la recursión no es práctica, mientras se muestra la relación entre recurrencias ma- temáticas simples y programas recursivos simples. Después, se mostrará un ejemplo prototípico de un programa recursivo de «divide y vencerás)) del tipo que se utiliza para resolver problemas fundamentales en secciones posteriores de este libro. Finalmente, se estudiará cómo puede eliminarse la recursión de cualquier programa recursivo, y se mostrará un ejemplo detallado de cómo eli- minar la recursión de un algoritmo de recorrido de árbol recursivo simple para obtener un algoritmo no recursivo simple basado en una pila. Como se verá más adelante, muchos algoritmos interesantes se pueden ex- presar fácilmente mediante programas recursivos y muchos diseñadores de al- gontmos prefieren métodos recursivos. Pero también es muy frecuente el caso de que un algoritmo tan interesantecomo los anteriores se encuentre escondido en los detalles de una implementación(necesariamente) no recursiva -en este capítulo se estudiarán las técnicas que permiten encontrar tales algoritmos-. 55
  • 76. 56 ALGORITMOS EN C++ Recurrencias Las definicionesrecursivas de funciones son muy frecuentes en matemáticas; el tipo más simple, en el que intervienen argumentos enteros, se denomina rela- ciones de recurrencia. Quizás la función más familiar de este tipo sea la función factorial, definida por la fórmula h ! = N . (N - l)!, para N 2 1 con O! = 1. Esta definición se corresponde directamente con el siguiente programa recur- sivo simple: i n t f a c t o r i a l (int N ) r i f ( N == O) return 1; return N * factorial(N-1); 1 Por una parte, este programa ilustra los aspectosbásicos de un programa recur- sivo: se llama a sí mismo (con un valor más pequeño que su argumento) y tiene una condición de terminación en la que calcula directamente su resultado. Por otra parte, no se oculta el hecho de que este programa es tan sólo un bucle f o r con adornos, por lo que dificilmente puede ser un ejemplo convincente del po- der de la recursión. También es importante recordar que es un programa y no una ecuación: por ejemplo, ni la ecuación ni el programa anterior «funcionan» para un N negativo, pero los efectos negativos de esta omisión son quizá más perceptibles con el programa que con la ecuación. La llamada a f a c t o - ri al (-1) se traduce en un bucle infinito recursivo; éste es, de hecho, un fallo recurrente que puede aparecer de forma más sutil en programas recursivos más complejos. Una segunda relación de recurrencia muy conocida es la que define los nú- meros de Fibonacci: FN=FN-, +FN-2, para N > 2 con Fo = F, = 1. Esto define la sucesión 1, 1, 2, 3, 5, 8, 13,21, 34, 55, 89, 144, 233, 377, 610, ... De nuevo, la recurrencia se corresponde directamente con un programa recur- sivo simple: int fibonacci ( i n t N)
  • 77. RECURSI~N 57 i f (N <= 1) return 1; return fibonacci (N-1) + fibonacci (N-2); { 1 Éste es un ejemplo todavía menos convincente de la «potencia»de la recursión; en efecto, es un ejemplo convincente de que la recursión no debería utilizarse a ciegas, ya que puede resultar dramáticamente ineficaz. El problema aquí es que las llamadas recursivas indican que FN-I y FN-2deben calcularse independien- temente, cuando, de hecho, lo natural sería utilizar FN-2(y FN-3)para calcular FN-1. En realidad, es fácil calcular cuál es el número exacto de llamadas al pro- cedimiento f ibonacci anterior que se necesitan para calcular FN:el número de llamadas necesarias para calcular FNes el número de llamadas necesarias para calcular FN-I más el número de llamadas necesarias para calcular FN-~, a me- nos que N = O o N = 1, en cuyo caso sólo se necesita una llamada. Pero esto encaja exactamente con la relación de recurrencia que define los números de Fibonacci: el número de llamadas a f ibonacci para calcular FN.Se sabe que FNes aproximadamente 8, donde @ = 1,61803... es la «razón de oro»: la tre- menda verdad es que jel programa anterior es un algoritmo de tiempo exponen- cid para calcular los números de Fibonacci! Por el contrario, es muy fácil calcular FNen tiempo lineal, como se indica a continuación: const int rnax = 25; i n t fibonacci (int N ) J I int i , F[max]; for (i = 2; i <= max; i++) F[O] = 1; F[1] = 1; F[i] = F[i-l] + F[i-2]; 1 return FIN1; Este programa calcula los primeros max números de Fibonacci, utilizando un array de tamaño max. (Como los números crecen exponencialmente, max será pequeño.) De hecho, esta técnica de utilizar un array para almacenar resultados pre- - - vios es el método que suele elegirse para evaluar relaciones de recurrencia, ya que permite resolver de manera eficaz y uniforme las ecuaciones más comple- jas. Las relaciones de recurrencia aparecen frecuentemente cuando se trata de determinar las características de rendimiento de programas recursivos y así se verán varios ejemplos a lo largo de este libro. Por ejemplo, en el Capítulo 9 apa- rece la ecuación: 1 C N = N - I + - 2 (Ck-l+CN-& paraN3 IconCo=l. IQkGN
  • 78. 58 ALGORITMOS EN C+t La manera más fácil de calcular el valor de CNes utilizar un array, como en el programa anterior. En el Capítulo 9 se presentará cómo puede resolverse ma- temáticamente esta fórmula y en el Capítulo 6 se tratarán otras recurrencias dis- tintas que suelen presentarse en el análisis de algoritmos. Así, con frecuencia la relación entre programas recursivos y funciones defi- nidas recursivamentees más filosóficaque práctica. Hablando estrictamente,los problemas antes indicados no se asocian con el concepto de recursión en sí mismo, sino con la implementación: un compilador (muy inteligente) puede descubrir que la función fa c t o r i a l podría en realidad implementarse con un bucle y que la función Fibonacci se emplea mejor cuando se almacenan todos los valores precalculados en un array. Posteriormente se examinarán con más detalle los mecanismos de implementación de programas recursivos. Divide y vencerás La mayor parte de los programas recursivos que se consideran en este libro uti- lizan dos llamadas recursivas,y cada una opera sobre aproximadamente la mi- tad de los datos de entrada. Éste es el paradigma de diseño de algoritmos de- nominado «divide y vencerás)), que se emplea a menudo para obtener importantes mejoras. Los programas del tipo de divide y vencerás normalmente no se reducen a bucles triviales,como el programa anterior que calculaba el fac- torial, porque tienen dos llamadas recursivas y por lo regular no obligan a rea- lizar un número excesivo de cálculos, como en el programa para los números de Fibonacci, expuesto anteriormente, porque los datos de entrada se dividen sin recubrimientos. Como ejemplo, considéresela tarea de trazar las marcas de las pulgadas de una regla: existe una marca en el punto 1/2”, marcas algo más cortas en los in- tervalos de 1/4”, marcas todavía más cortas en los intervalos de 1/8”, etc., como se muestra (de forma ampliada) en la Figura 5.1. Éste es un prototipo de los cálculos sencillos que realiza el método de divide y vencerás; posteriormente se verá que hay muchas formas de llevar a cebo esta tarea. Si la resolución deseada es 1/2’”, se efectúa un cambio de escala de manera que la tarea sea poner una marca en cada punto entre O y 2”, sin incluir los pun- tos extremos. Se supone la existenciade un procedimiento marcar (x , h) para hacer una marca de h unidades de altura en la posición x. L a marca central debe Figura 5.1 Una regla.
  • 79. RECURSI~N 59 ser de n unidades de altura, las situadas en medio de las partes izquierda y de- recha deben ser de n - l unidades de altura, etc. El siguiente programa recur- sivo de ((dividey vencerás))constituye una forma directa de lograr el objetivo: regla ( i n t izq, i n t der, int h) i n t m = (izq+der)/2; i f (h > O) { marcar(m, h); regl a ( izq, m, h-1) ; regla(m, d e r , h-1); 1 } Por ejemplo, la llamada a regl a (O ,64,6) producirá la Figura 5.1, con la escala apropiada. El método se basa en la siguiente idea: para hacer las marcas de un intervalo, se comienza por la más grande, justo en el medio. Esto divide el in- tervalo en dos partes iguales. A continuación se hacen las marcas (más cortas) de cada mitad, utilizando el mismo procedimiento. Normalmente conviene prestar especial atención a la condición de termi- nación de un programa recursivo; ya que de otra manera ipuede que no termine nunca! En el programa anterior, la instrucción regl a termina (no se llama más a sí misma) cuando la altura de las marcas que quedan por hacer es O. La Figura 5.2 muestra el proceso con detalle, y proporciona la lista de las llamadas al pro- cedimiento y las marcas que resultan al llamar a regl a (O ,8,3). Se colocauna marca en el medio y se llama regl a para la mitad izquierda, repitiendo sucesi- vamente el proceso hasta que la altura de las marcas sea O. Para concluir se vuelve a llamar a regl a para hacer las marcas de la mitad derecha de la misma manera. En este problema no es particularmente importante el orden en el que se dibujen las marcas. Se podría haber puesto también la llamada a marcar entre las dos llamadas recursivas, en cuyo caso los puntos del ejemplo se trazarían simplemente en el orden de izquierda a derecha, como muestra la Figura 5.3. El conjunto de marcas dibujadas por estos dos procedimientos es el mismo, pero el orden es bastante diferente. Esta diferenciapuede explicarse mediante el árbol del diagrama de la Figura 5.4. Este diagrama tiene un nodo para cada lla- mada a regla, con los parámetros utilizados en ella etiquetados; los hijos de cada uno de ellos corresponden a las llamadas (recursivas)a regl a, junto con sus parámetros correspondientes. Un árbol de este tipo permite ilustrar las ca- racterísticasdinámicas de cualquier conjunto de procedimientos. La Figura 5.2 corresponde al recomdo de este árbol en preorden (la «visita» a un nodo co- rresponde a hacer la correspondiente llamada a marcar); la Figura 5.3 corres- ponde al recomdo en orden simétrico.
  • 80. 60 ALGORITMOS EN C++ regl a(O ,8,3) marcar(4,3) regla(0,4,2) marcar (2,2) regl a( 0,2,1) marcar(1,l) regl a( O ,1 ,O) regl a( 1,2,0) marcar(3,l) regl a( 2,3 ,O) regl a( 3,4,0) regl a(2,4,1) regl a(4,8,2) marcar (6,2) regla(4,6,l) marcar(5,l) regla(4,5,O) regl a( 5,6,0) marcar (7,1) regl a( 6,7 ,O) regl a( 6,8,1) ~~ i l l i l l Figura 5.2 Dibujo de una regla. En general, los algoritmos del tipo divide y vencerás incluyen algún trata- miento para dividir los datos de entrada en dos partes, o para mezclar los resul- tados del proceso de la «resolución» de dos partes independientes de datos de entrada, o para hacer una rehabilitación después de que se haya procesado la mitad de los datos de entrada. Es decir, se pueden encontrar instrucciones an- tes, después o entre las dos llamadas recursivas. Más adelante se verán muchos ejemplos de este tipo de algoritmos, en especial en los Capítulos 9, 12, 27, 28 y 41. También se encontrarán algoritmos en los que no será posible aplicar com- pletamente el método de divide y vencerás. Esto ocurre, por ejemplo, cuando los datos de entrada están divididos en dos partes desiguales, o están divididos en más de dos partes, o existe algún solapamiento entre las partes. También es fácil desarrollar algoritmos no recursivospara esta tarea. El mé- todo más directo consiste simplemente en dibujar las marcas en orden, como en la Figura 5.3, pero con el bucle directo for (i = 1; i < N; i++) marcar (i, altura(i));. Senecesitala función altura(i) que noesdifícildecal- cular: es el número de bits consecutivos al final de la representación binaria de i. Se deja como ejercicio para el lector la implementación de esta función en
  • 81. RECURSI~N 61 regla ( 0,8,3) regla(O,4,2) regla(O,2,1) regla ( O,2,l) regla(0,l ,O) marcar( 1,l) regla ( 1,2,O) marcar(2,2) regla( 2,4,1) regla( 2,3,O) marcar(3,l) regla( 3,4,0) marcar (4,3) regla ( 4,8,2) regla(4,6 ,1) regla(4 ,5,O) marcar(5,1) regla ( 5,6,O) marcar (6,2) regla(6,8 ,1) regla ( 6,7,O) marcar(7,l) 1 Figura 5.3 Dibujo de una regla (versiónen orden sirnetrico). C++. De hecho, es posible obtener este método directamente a partir de la ver- sión recursiva mediante un proceso laborioso de {{eliminaciónde la recursión», que se examinará con detalle más adelante, para otro problema. Otro algoritmo no recursivo, que no corresponde a ninguna implementa- ción recursiva, consisteen dibujar primero las marcas más cortas, luego las más Figura 5.4 Árbol de llamada recursiva para dibujar una regla.
  • 82. 62 ALGORITMOS EN C++ cortas de las restantes y así sucesivamente, como lo hace el pequeño programa siguiente: regla (int izq, int der, int h) int i,j, t; for (i= 1, j = 1; i <=h; i++, j+=j) for (t = O; t <= (izq+der)/j; t++) { marcar(izq+j+t*(j+j) , i); } La Figura 5.5 muestra cómo dibuja las marcas este programa. El proceso co- rresponde a recorrer el árbol de la Figura 5.4 en orden de nivel (de abajo hacia arriba), pero no es recursivo. Este proceso corresponde ai método general de diseño de algoritmos en el que se resuelve un problema tratando primero subproblemas triviales y combi- nando después las solucionespara resolver subproblemas ligeramente más gran- des, y así sucesivamente hasta la resolución de todo el problema. Esta manera I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I l l I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I I , I I I I I I I I I l I I I I / / I / I I I l I l I l 1 l l I / I I l l I I I I l l l l I l l l l l l l I I I I I I I I I I I I I I I I l l l l l I I / I I l I I I I I I i l l l / I I I I I I I I I I / l l I I l l l I I / l l / I I / I I I I I / l / I l I I I l l I l l ~ l l I 1 l l l / l l l / l l l l l l l l l l l / l l I l l l l l l l 1 l l l l / l l l / l l i l l l l / l l l l l Figura 5.5 Dibujo no recursivode una regla.
  • 83. RECURSI~N 63 de trabajar podría denominarse«combinay vencerás)). Mientras que todo pro- grama recursivo admite una implementación no recursiva equivalente, no siempre es posible volver a ordenar los cálculos de esta forma -muchos pro- gramas recursivos dependendel orden específico en el que se resuelven los sub- problemas-. Ésta es una aproximación ascendente, opuesta al método de di- vide y vencerás, en el que el orden de resolución es descendente. A lo largo del libro se encontrarán varios ejemplos de esto: el más importante en el Capítulo 12. En el Capítulo 42 se presenta una generalización del método. Se ha examinadocon detalle el ejemplo de dibujar una regla porque ilustra las propiedadesesenciales de los algoritmosprácticos con una estructura similar a la de aquellos que se encontrarán más adelante. Con la recursión está justifi- cado el estudio en profundidad de ejemplos simples, porque no es fácil saber cuándo se ha cruzadola frontera entre lo muy simple y lo muy complicado. La Figura 5.6 muestra un modelo bidimensional que ilustra cómo una descripción recursiva simple puede conducir a cálculos bastante complejos. El modelo de la izquierda tiene una estructura en la que se reconoce fácilmente su carácter re- cursivo, mientras que el modelo de la derecha parece más complicado si apa- rece en solitario, sin la compañía del primero. El programa que genera el mo- delo de la izquierda es, en realidad, una ligera generalización de reg1 a: estrella(int x, int y, int r) estre1 1a(x-r ,y+r ,r/2) ; estrella(x+r,y+r ,r/2); estrel 1 a(x-r,y-r, r/2) ; estrella(x+r,y-r,r72); cuadrado(x ,y,r); 1 La primitiva de dibujo que se utiliza es simplemente un programa que dibuja un cuadradode tamqño 2r y de.centro (x,y). Así, el modelo de la izquierda de la Figura 5.6 se genera de manera simple con un programa recursivo -el lector se puede entretener intentando encontrar un método recursivo para dibujar el contorno del modelo de la derecha-. El modelo de la izquierda también es fá- cil de generar con un método ascendente como el que se representa en la Figura 5.5: se dibujan los cuadrados más pequeños, después los más pequeños de los restantes, etc. También puede ser interesante intentar encontrar un método no recursivo para dibujar el contorno. Los modelos geométricosdefinidos recursivamente como los de la Figura 5.6 se denominan a vecesfractales. Si se utilizan primitivas de dibujos más comple- jos e invocaciones recursivas más complicadas (en especial con funciones defi-
  • 84. 64 ALGORITMOS EN C++ nidas recursivamente en los planos real y complejo), se pueden desarrollar mo- delos de gran complejidad y diversidad. Recorrido recursivo de un árbol Como se indicó en el Capítulo 4,el método más simple para recorrer los nodos de un árbol es probablemente con una implementación recursiva. Por ejemplo, el siguiente programa visita los nodos de un árbol binario en orden. recorrer (struct nodo *t) i f ( t != z) { recorrer ( t - > i z q ) ; visitar ( t ) ; recorrer (t->der); { } } La implementación refleja de forma precisa la definición del orden simétrico: «si el árbol no está vacío, recorrer primero el subárbol izquierdo, visitar la raíz y después recorrer el subárbol derecho». Evidentemente, el recorrido en orden previo puede implementarse poniendo la llamada a vi si tar antes de las dos llamadas recursivas,y el recomdo en orden posterior se puede implementar po- niendo la llamada a vi s i tar después de las dos llamadas recursivas. Figura 5.6 Una estrella fractal, dibujada con cuadrados (izquierda) y sólo con contornos (derecha).
  • 85. RECURSI~N 65 Esta implementación recursiva del recorrido del árbol surge de una forma más natural que una implementación basada en una pila, ya que los árbolesson estructuras definidas recursivamente y porque los recomdos en orden previo, en orden y en orden posterior son procesos definidos recursivamente. En con- traste, se observa que no existe una forma adecuada de implementar un proce- dimiento recursivo para el recomdo en orden de nivel: la misma naturaleza de la recursión obliga a que los subárboles se procesen como unidades indepen- dientes, mientras que el orden de nivel necesita que los nodos de diferentes su- bárboles se mezclen entre ellos. Se volverá sobre este punto en los Capítulos 29 y 30, cuando se consideren los algoritmos de recomdos de grafos, que son es- tructuras mucho más complicadas que los árboles. Unas simples modificacionesal programa recursivo anterior y la implemen- tación apropiada de vi sit a r pueden dar lugar a programas que calculen diver- sas propiedades de los árboles binarios de las figuras de este libro. Supóngase que el registro de los nodos incluye dos campos enteros para las coordenadas x y y de cada nodo en la página. (Para evitar detalles de escala y traslación, se supone que son coordenadas relativas: si el árbol tiene N nodos y es de altura h, la coordenada x va de izquierda a derecha desde 1 a N, y la coordenada y va desde amba hacia abajo desde 1 a h.) El siguiente programa rellena estos cam- pos con los valores apropiados para cada nodo: v i s i t a r ( s t r u c t nodo *t) recorrer ( s t r u c t nodo *t) { t - > x = ++x; t - > y = y; } Y++; i f (t != z) recorrer ( t - > i z q ) ; v i s i t a r ( t ) ; recorrer (t->der) { 1 Y--; 1 El programa utiliza dos variables globales, x y y, que se suponen inicializadas a O. La vanable x sigue la pista del número de los nodos que se han visitado en orden; la variable y sigue la altura del árbol. Cada vez que recorrer desciende por el árbol, esta variable se incrementa en una unidad, y cada vez que asciende por el árbol, se disminuye también en una unidad. De forma similar se podrían implementar programas recursivos para calcu- lar la longitud del camino de un árbol, para encontrar otra forma de dibujar un árbol o para evaluar una expresión que él representa, etcétera.
  • 86. 66 ALGORITMOS EN C+t Eliminación de la recursión Pero, jcuál es la relación entre la implementaciónanterior (recursiva)y la im- plementación del Capítulo 4 (no recursiva) para el recomdo del árbol? Sin duda estos dos programas están fuertemente relacionados, ya que, para todo árbol dado, producen precisamente la misma serie de llamadas a vi s i tar. En esta sección se estudia esta cuestión de forma detallada mediante la eliminación «mecánica»de la recursión del programa de recomdo en orden previo dado an- teriormente para conseguir una implementaciónno recursiva. Ésta es la misma operación con la que se enfrenta un compilador cuando tiene que traducir un programa recursivo a lenguaje de máquina. El objetivo principal de este apartado no es estudiar las técnicas de compilación (aunque se obtengan algunosconocimientos sobre los problemas con los que se enfrenta un compilador),sino más bien la relación entre las implementaciones recursivas y no recursivas de los algoritmos. Este tema se planteará nuevamente a lo largo del libro. Para empezar se comienza con una implementaciónrecursiva de recomdo en orden previo, exactamentecomo la antes descrita: recorrer (struct nodo *t) i f (t != z) { visitar ( t ) ; recorrer (t->izq); recorrer (t->der); { 1 1 La segunda llamada recursiva no va seguida de ninguna instrucción,por lo que puede eliminarse fácilmente. Cuando se va a ejecutar la segunda llamada, se in- voca a recorrer (con el argumento t - >der); al finalizar esta llamada, se ter- mina también la invocación actual de recorrer. Pero esta misma secuencia de acontecimientos se puede implementarcon un goto en lugar de utilizar una lla- mada recursiva, como se indica a continuación: recorrer (struct nodo *t) izq: i f (t == z) goto x; { visitar ( t ) ; recorrer (t->izq); t= t->der;
  • 87. RECURSIÓN 67 goto izq; x: ; Ésta es una técnica muy conocida, denominada eliminación de la recursiónfi- nal, que se implementaen muchos compiladores. Los programas recursivos son menos viables en los sistemas que no tienen esta capacidad, porque pueden apa- recer aberraciones innecesarias y notables, tales como las producidas en las fun- ciones factorial y fibonacci que se vieron anteriormente. En el Capítulo 9 se estudiará un ejemplo práctico importante. La eliminación de la otra llamada recursiva requiere más trabajo. En gene- ral, la mayoría de los compiladores producen un código de instrucciones que sigue la misma secuencia de acciones en cualquier llamada de procedimiento: «colocar los valores de las variables locales y la dirección de la siguienteinstruc- ción en la pila, definir los valores de los parámetros del procedimiento e (ir) goto al principio del procedimiento.» Entonces, cuando termina el procedimiento, se debe «sacar de la pila los valores y la dirección de retorno de las variables loca- les, inicializar las variables e (ir) goto a la dirección de retorno». Por supuesto, las cosas son más complejas en la mayoría de las situaciones que encuentra un verdadero compilador; no obstante, siguiendo en esta línea de trabajo, se puede eliminar la segunda llamada recursiva del programa de la siguiente forma: recorrer (struct nodo *t) izq: if (t == z) goto s; { vi sitar (t) ; pila.meter(t); t = t->izq; goto izq; if (pila.vacia ( ) ) goto x; t = pila-sacar ( ) ; goto der; der: t = t->der; goto izq; s: x: ; 1 Existe sólo una variable local t,por lo que se le introduce en la pila y se efectúa un goto al principio. Hay una única dirección de retorno der, que está fija, y por tanto no se pone en la pila. Al final del procedimiento, se actualiza t a par- tir del valor de la pila y se hace goto a la dirección de retorno der. Cuando la pila esté vacía, se retorna desde la primera llamada a recorrer. Ahora ya se ha eliminado la recursión, pero se ha obtenido un «pantano»de gotos que integran un programa más bien opaco. Pero éstos se pueden eliminar «mecánicamente» para obtener un código más estructurado. En primer lugar, la parte de código comprendidaentre la etiqueta der y el segundo goto x está rodeada de gotos y se puede mover fácilmente, y así se eliminan la etiqueta der y el goto asociado. Además, se observa que se asigna el valor t a t- >der
  • 88. 68 ALGORITMOS EN C++ cuando se saca de la pila; sería mejor meter ese valor en la pila. Finalmente, las instruccionescomprendidas entre la etiqueta x y el primer goto x no son más que un bucle whi1e.Esto queda asi: recorrer (struct nodo *t) izq: while (t != z) { i 1 visitar (t); Pila.meter(t->der); t = t->izq; if’(pi1a.vacia 0 ) goto x; t = pila.sacar ( ) ; goto izq; x: ; 1 Ahora se tiene otro bucle, que se puede transformar en otro whi 1e añadiendo un apilamiento extra (del argumento inicial t a la entrada de recorrer), que- dando un programa sin goto: recorrer (struct nodo *t) pi 1a.meter(t) ; while (!pila.vacia()) t = pila.sacar(); while (t != z) { { visitar (t) ; pi 1a.meter (t->izq) ; t = t->l { Esta versión es el método de recomdo no recursivo «estándan>.Es un ejercicio instructivo para olvidar por el momento cómo se obtuvo y para convencerse directamente de que este programa hace el recorrido del árbol en orden previo como se aconsejó. En realidad, la estructura de este programa, con un bucle dentro de otro, puede simplificarse(con el coste de algunas inserciones en la pila): recorrer (struct nodo * t)
  • 89. RECURSI~N 69 pi 1 a.meter (t) ; while (!pila.vacia()) t = pila.sacar(); if (t != z) { { visitar (t) ; pila.meter (t->der); pi 1 a.meter (t-> i zq) ; { Este programa tiene un parecido notable con el algoritmo recursivo original en orden previo, aunque en realidad los dos programas son completamentedife- rentes. Una primera diferencia es que este programa puede ejecutarse en prác- ticamente cualquier entorno de programación, mientras que, claro está, la im- plementación recursiva necesita un entorno que dé cabida a la recursión. Incluso en tal entomo, es probable que este método basado en pila sea algo más eficaz. Finalmente, se observa que este programa coloca en la pila subárboles va- cíos, como consecuencia de la decisión tomada en la implementación original de verificar que el subárbol no esté vacío como primera acción del procedi- miento recursivo. La implementación recursiva podría hacer la llamada recur- siva sólo para los subárbolesno vacíos verificando t-> i zq y t-> der.Reflejar este cambio en el programa anterior conduce al algoritmo basado en pila para el recorrido en orden previo del Capítulo 4. recorrer (struct nodo *t) pi 1 a.meter (t) ; while (!pila.vacia ( ) ) , { t = pila.sacar(); visitar (t); if (t->der != z) pi 1 a.meter (t->der) ; if (t->izq != z) pila.meter (t->izq); 1 Cualquieralgoritmorecursivo puede manipularse de la manera precedente para eliminar la recursión; desde luego, ésta es la tarea principal del compilador. La eliminación «manual» de la recursión que se acaba de describir, aunque es
  • 90. 70 ALGORITMOS EN C++ complicada, conducefrecuentementea una implementaciónno recursiva eficaz y a un mejor entendimiento de la naturaleza de la operación. Perspectiva Seguramente es imposible hacer justicia a un tema tan fundamental como la recursión en una exposición tan breve. A lo largo del libro aparecen muchos de los mejores ejemplos de programas recursivos -se han ideado algoritmos del tipo de divide y vencerás para una amplia variedad de problemas-. En muchas aplicaciones no hay razón para ir más allá de una implementaciónrecursiva, directa y simple; en otras, en cambio, se considerará la posibilidad de eliminar la recursión como se describió en este capítulo o se intentará obtener alguna al- ternativa de implementacionesno recursivas directamente. La recursión está en el corazón de los primeros estudios teóricos sobre la verdadera naturaleza del cálculo informático.Los programas y funciones recur- sivos desempeñan un papel central en los estudios matemáticos que intentan separar los problemas que se pueden resolver mediante una computadorade los que no se pueden. En el Capítulo 44 se estudiará la utilización de programas recursivos (y otras técnicas) para resolver problemas dificiles, en los que debe examinarse un gran número de posibles soluciones. Como se verá, la programación recursiva puede ser un medio bastante eficaz para organizar una búsqueda compleja en el con- junto de soluciones posibles. Ejercicios 1. Escribir un programa recursivo para dibujar un árbol binario de manera que la raíz aparezca en el centro de la página, la raíz del subárbol izquierdo esté en el centro de la mitad izquierda de la página, etcétera. 2. Escribir un programa recursivo para calcular la longitud del camino ex- terno de un árbol binano. 3. Escribir un programa recursivo para calcular la longitud del camino ex- terno de un árbol representado como un árbol binario. 4. Obtener las coordenadas generadas cuando se aplica el procedimiento re- cursivo de dibujo del árbol dado en el texto al árbol binario de la Figura 4.2. 5. Eliminar mecánicamente la recursión del programa f ibonacci dado en el texto, para obtener una implementaciónno recursiva. 6. Eliminar mecánicamentela recursión del algoritmo recursivo de recorrido del árbol en orden,para obtener una implementaciónno recursiva.
  • 91. RECURSICIN 71 7. 8. 9. 10. Eliminar mecánicamentela recursión del algoritmo recursivo de recorrido del árbol en ordenposteriorpara obtener una implementaciónno recursiva. Escribir un programa recursivo del tipo «divide y vencerás)) para dibujar una aproximación del segmento que conecta dos puntos (XI, VI) y (x2, y2), dibujando sólo los puntos que utilizan coordenadas enteras. (Pista: dibujar primero un punto próximo a la mitad.) Escribir un programa recursivo para resolver el problema de Josefo (verCa- pítulo 3). Escribir una implementaciónrecursiva del algoritmo de Euclides (ver Ca- pítulo 2).
  • 93. 6 Análisis de algoritmos Para la mayoría de los problemas existen varios algoritmos diferentes. ¿Cómo elegir uno que conduzca a la mejor implementación? Esta cuestión constituye actualmente un área de estudio muy desarrollada de la informática. Con fre- cuencia se tendrá la oportuiiidad de investigar los resultados que describen el comportamiento de algoritmos fundamentales. De cualquier forma, la compa- ración de algoritmos puede ser un desafío, por lo que serán útiles ciertas pautas generales. Normalmente los problemas a resolver tienen un <ctamaño»natural (en ge- neral, la cantidad de datos a procesar), al que se denominará N y en función del cual se tratará de describir los recursos utilizados (con frecuencia, la cantidad de tiempo empleado). El punto de interés es el estudio del caso medio, es decir, el tiempo de ejecución de un conjunto «tipo» de datos de entrada, y el del peor caso, el tiempo de ejecución para la configuración de datos de entrada más des- favorable. Algunos de los algoritmos de este libro se entienden muy bien, hasta el punto de que se conocen las fórmulas matemáticas precisas para averiguar el tiempo de ejecución medio y el tiempo de ejecución del peor caso. Estas fórmulas se obtienen estudiando cuidadosamente el programa, para encontrar el tiempo de ejecuciónen términos de expresionesmatemáticas fundamentales y a continua- ción hacer un análisis matemático de las cantidades implicadas. Por otra parte, las propiedades del rendimiento de otros algoritmos de este libro son totalmente desconocidas -quizás porque su análisis conduce a cuestiones matemáticas no resueltas, o porque se sabe que las implementaciones son demasiado complejas para analizarlas al detalle de una manera razonable, o (la mayoría de las veces) porque los tipos de entrada que se encuentran no pueden caracterizarseadecua- damente-. L a mayoría de los algoritmoscaen entre estos extremos: se conocen algunos hechos sobre su rendimiento, pero en realidad no se han llegado a ana- lizar por completo. Varios de los factoresimportantes que entran en este análisishabitualmente no son competencia del programador. En primer lugar, los programas en C++ 73
  • 94. 74 ALGORITMOS ENC++ - se traducen a código de máquina para una computadora dada, y puede ser una tarea dificil el averiguar exactamentecuánto se tarda en tratar incluso una sola sentencia de C++ (especialmenteen un entorno donde se comparten los recur- sos de modo que el mismo programa pueda tener distintas características de rendimiento). En segundo lugar, muchos programas son excesivamente sensi- bles a sus datos de entrada, y su rendimiento podría fluctuar enormemente se- gún sean éstos. El caso medio podría ser una ficción matemática que no es re- presentativa de los datos reales que utiliza cada programa, y el peor caso podría ser una construcción rara que nunca ocumría en la práctica. En tercer lugar, muchos programas de interés no se entienden bien, y puede que no proporcio- nen los resultados matemáticos específicos. Por último, es frecuente el caso en el que los programas no sean comparables en absoluto: uno es mucho más rá- pido que otro para un tipo particular de entrada, y el otro lo es bajo otras cir- cunstancias. A pesar de los comentarios anteriores, a menudo es posible predecir con exactitud el tiempo de ejecución de un programa particular o saber que un pro- grama funcionará mejor que otro en situaciones concretas. El objetivo del ana- lista de algoritmos es descubrir tanta información como sea posible sobre el de- sarrollo de los algoritmos; la tarea del programador es aplicar esta información para seleccionar los algoritmos para cada aplicación en particular. En este ca- pítulo, el centro de atención será el mundo más bien idealizado del analista; en el siguiente se estudiarán consideraciones prácticas de implementación. Marco de referencia El primer paso del análisis de un algoritmo es establecer las características de los datos de entrada que utilizará y decidir cuál es el tipo de análisis más apro- piado. Idealmente, sena deseable poder obtener, para cualquier distribución de probabilidad de las posibles entradas, la correspondiente distribución de los tiempos empleados en la ejecución del algoritmo. Desgraciadamente no es po- sible alcanzar este ideal para un algoritmo que no sea trivial, de manera que, por lo regular, se limita el desarrollo estadísticointentando probar que el tiempo de ejecución es siempre menor que algún «límite superion) sea cual sea la en- trada, e intentando obtener el tiempo de ejecución medio para su entrada «alea- torim. El segundo paso del análisis de un algoritmo es identificar las operaciones abstractas en las que se basa, con el fin de separar el análisis de la implementa- ción. Así, por ejemplo, se separa el estudio del número de comparaciones que realiza un algoritmo de ordenación del estudio para determinar cuántos micro- segundos tarda una computadora concreta en ejecutar un código de máquina cualquiera producido por un compiladordeterminado para el fragmento de có- digo if (a[i] < v) ....Ambos casos se necesitan para determinar cuál es el tiempo de ejecución real del programa en una computadora en particular. El
  • 95. ANÁLISIS DE ALGORITMOS 75 primero dependerá de las propiedades del algoritmo, mientras que el segundo dependerá de las propiedades de la computadora.Esta separación permite a me- nudo comparar algoritmos, independientementede las implementaciones par- ticulares o de las computadorasque se puedan utilizar. Mientras que el número de operaciones abstractas implicadas puede ser, en principio, grande, normalmente se da el caso de que el desarrollo de los algont- mos que se consideran depende sólo de unas cuantas cantidades. En general, es fácil identificar las cantidades significativas para un programa en particular -una forma de hacerlo consiste en utilizar una opción de «detección de perfi- les» (disponible en muchas implementacionesde C++) para realizar estadísticas de la frecuencia de llamada de una instrucción en algún ejemplo de ejecu- ción-. En este libro, el interés se centra en las cantidades de ese tipo que sean importantes para cada programa. El tercer paso del análisis de un algoritmo es analizarlo matemáticamente, con el fin de encontrar los valores del caso medio y del peor caso para cada una de las cantidades fundamentales. No es dificil encontrar un límite superior del tiempo de ejecución de un programa -el reto es encontrar el mejor límite su- perior, aquel que se encontraría si se diera la peor entrada posible-. Esto pro- duce el peor caso: el caso medio normalmente requiere un análisis matemático más sofisticado. Una vez desarrollados con éxito tales análisis para las cantida- des fundamentales, se puede determinar el tiempo asociado a cada cantidad y obtener expresiones para el tiempo total de ejecución. En principio, el rendimiento de un algoritmo se puede analizar a menudo con un nivel de precisión detallado, limitado sólo por la incertidumbre sobre el rendimiento de la computadora o por la dificultad de determinar las propieda- des matemáticasde algunas de las cantidades abstractas. Sin embargo, rara vez será útil hacer un análisis detallado completo, de manera que siempre será pre- ferible una estimaciónmejor que un cálculo preciso. (En realidad, las estimacio- nes que aparentemente son sólo aproximadas, a menudo resultan ser bastante precisas.) Tales estimacionesaproximadas se obtienen fácilmente por medio del viejo refrán del programador: «el 90 Yo del tiempo se emplea en el 10Yo de la codificación.» (En el pasado ya se decía esto pero con otros valores diferentes del a90 %».) El análisis de un algoritmo es un proceso cíclico, estimándolo y refinándolo hasta que se alcanza una respuesta al nivel de precisión deseado. Realmente, como se estudiará en el siguiente capítulo, el proceso también debería incluir mejoras en la implementacióny desde luego el análisis sugiere a menudo tales mejoras. Teniendo en cuenta estas advertencias, el modo de proceder será buscar es- timaciones aproximadas del tiempo de ejecución de los programas con el fin de clasificarlos, sabiendo que se puede hacer un análisis más completo para pro- gramas importantes cuando sea necesario.
  • 96. 76 ALGORITMOS EN C++ Clasificaciónde los algoritmos Como se mencionó antes, la mayoría de los algoritmos tienen un parámetro primario N, normalmente el número de elementos de datos a procesar, que afecta muy significativamente al tiempo de ejecución. El parámetro N podría ser el grado de un polinomio, el tamaño de un archivo a ordenar o en el que se va a realizar una búsqueda, el número de nodos de un grafo, etc. Prácticamente todos los algoritmos de este libro tienen un tiempo de ejecución proporcional a una de las siguientesfunciones: I La mayor parte de las instrucciones de la mayoría de los programas se ejecutan una vez o muy pocas veces. Si todas las instrucciones de un programa tienen esta propiedad, se dice que su tiempo de ejecu- ción es constante. Obviamente, esto es lo que se persigue en el diseño de algoritmos. logN Cuando el tiempo de ejecución de un programa es Zogaritrnico, éste será ligeramente más lento a medida que crezca N. Este tiempo de ejecución es normal en programas que resuelven un problema de gran tamaño transformándolo en uno más pequeño, dividiéndolo me- diante alguna fracción constante. Para lo que aquí interesa, el tiempo de ejecución puede considerarsemenor que una «gran»constante. La base del logaritmo cambia la constante, pero no mucho: cuando N vale mil, si la base es 10, logN es 3 y si la base es 2 es aproximada- mente 10; cuando N vale un millón, ZogN se multiplica por dos. Cuando se dobla N, logN crece de forma constante, pero no se du- plica hasta que N llegue a N2. N Cuando el tiempo de ejecución de un programa es lineal, eso significa generalmente que para cada elemento de entrada se realiza una pe- queña cantidad de procesos. Cuando N vale un millón, este valor es también el del tiempo de ejecución, que se duplica ai hacerlo N. Ésta es la situación ideal para un algoritmo que debe procesar N entradas (u obtener N salidas). N logN Este tiempo de ejecución es el de los algoritmos que resuelven un problema dividiéndolo en pequeños subproblemas, resolviéndolosin- dependientemente, y combinando después las soluciones.Ante la falta de un adjetivo mejor (¿lineal-aritrnético.~, se dice que el tiempo de ejecución de tal algoritmo es «MogN». Cuando N vale un millón MogN es aproximadamente veinte millones. Cuando se duplica N, el tiempo de ejecución es más del doble (aunque no mucho más). N2 Cuando el tiempo de ejecución de un algoritmo es cuadrútico, sólo es práctico para problemas relativamente pequeños. El tiempo de eje- cución cuadrático normalmente aparece en algoritmos que procesan
  • 97. ANÁLISIS DE ALGORITMOS 77 pares de elementos de datos (por ejemplo, en un bucle anidado do- ble). Cuando N vale mil, el tiempo de ejecución es un millón. Cuando N se dobla, el tiempo de ejecución se multiplica por cuatro. N3 De igual manera, un algoritmo que procesa trios de elementos de da- tos (por ejemplo, en un bucle anidado triple) tiene un tiempo de eje- cución cúbico y no es útil más que en problemas pequeños. Cuando N vale cien, el tiempo de ejecución es un millón. Cuando N se du- plica, el tiempo de ejecución se multiplica por ocho. 2N Pocos algoritmos con un tiempo de ejecución exponencial son sus- ceptiblesde poder ser útiles en la práctica, aunque aparecen de forma natural al aplicar el método de la «fuerza bruta)) en la resolución de problemas. Cuando N vale veinte, el tiempo de ejecución es un mi- llón. Cuando N dobla su valor, jel tiempo de ejecución se eleva al cuadrado! El tiempo de ejecución de un programa particular es probablemente igual a alguna constante multiplicada por uno de sus términos (el «término principal))) más algunos términos más pequeños. Los valores del coeficienteconstante y de los términos incluidos dependen de los resultados del análisis y de los detalles de la implementación. De forma esquemática, el coeficiente del término prin- cipal está condicionado por el número de instrucciones que hay en el bucle in- terno: en cualquier nivel del diseño del algoritmo, es prudente limitar el nú- mero de estas instrucciones. Para grandes valores de N se impone el efecto del término principal; para pequeños valores de N o para algoritmos diseñados mi- nuciosamente pueden contribuir más términos, y la comparación de algoritmos es más difícil.En la mayoría de los casos, simplemente se dice que el tiempo de ejecución de los programas es «lineal», (dvlogN»,«cúbico», etc., entendiéndose implícitamente que en los casos donde sea muy importante la eficacia, debe rea- lizarse un análisis más detallado o un estudio empírico. A veces surgen otras funciones. Por ejemplo, un algoritmo con N2 entradas que tiene un tiempo de ejecución cúbico en N es más adecuado clasificarlo como un algoritmo de tiempo de ejecución N3/2.También, algunos algoritmos tienen dos etapas de descomposición en subproblemas, lo que se traduce en un tiempo de ejecución proporcional a Mog2N.Ambas funcionesse aproximan mucho más a M o o que a N2,para valores grandes de N. A continuación se ampliará lo antes dicho sobre la función dog». Como se mencionó, la base del logaritmo hace cambiar los cálculos de forma constante. Como frecuentemente se trata sólo con resultados analíticos con un factor cons- tante, no importa mucho cuál es la base, por lo que se dice simplemente dogNn, etc. Por otro lado, algunas veces se dará el caso de que los conceptos se expli- carán más claramente si se utiliza alguna base específica. En matemáticas, el lo- garitmo natural (o neperiano en base e = 2.7 18281828...) aparece con tanta fre- cuencia que lo normal es utilizar una abreviatura especial: lo&N 1nN. En informática, el logaritmo binario (base 2) aparece tan a menudo que se utiliza
  • 98. 70 ALGORITMOS EN C++ 1 g N lg2N fi N MgN Mg2N p i 2 N2 3 9 3 10 30 90 30 100 6 36 10 1O 0 600 3.600 1.oao 1o.Ooo 9 81 31 1.o00 9.000 81.o00 31.000 1.000.000 13 169 100 10.000 130.000 1.690.000 1.000.000 1 ~ . ~ . 0 0 0 16 256 316 100.000 1.600.000 25.600.000 31.600.000 10mil d o n e s 19 361 1.O00 1.OOO.OOO 19.000.000 361.OOO.OOO mil millones un bilión Figura6.1 Valores relativos aproximados de funciones. la notación abreviada logzNrlgN.Por ejemplo, el mayor entero inferior a 1gN indica el número de bits necesario para representar N en escritura binaria. La Figura 6.1 indica el tamaño relativo de algunas de estas funciones: la tabla proporcionalos valoresaproximadosde lgN,l8N, p, N, MgN, Mg’N, lV3I2, N2 para diferentes valores de N. La función cuadrática domina claramente, en es- pecial para N grande, pero las diferencias entre las funciones más pequeñas no son las que podría esperarse para un N pequeño. Por ejemplo, N3I2debería ser mayor que Mg2Npara N muy grande, pero no para los valores más pequeños que son 30s que podrían darse en la práctica. Se sobreentiende que no se da esta tabla para que se haga una comparación lineal de las funciones para todos los valores de N -números, tablas y grafos relativos a los algoritmos específicos pueden hacer mejor esta tarea-, pero proporciona una primera aproximación bastante realista. Complejidaddel cálculo Una técnica de aproximación al estudio del rendimiento de los algoritmos con- siste en examinar el peor CQSO, sin tener en cuenta los factores constantes, con el fin de determinar la dependencia funcional del tiempo de ejecución (o alguna otra medida) del número de datos de entrada (o de alguna otra variable). Este enfoque es interesanteporque permite demostrar propiedades matemáticas pre- cisas sobre el tiempo de ejecución de los programas: por ejemplo, se puede afir- mar que el tiempo de ejecución de una ordenación por mezcla (ver Capítulo 11) esforzosamente proporcional a MogN. El primer paso del proceso consiste en precisar matemáticamentelo que se entiende por «proporcional am, lo que al mismo tiempo separará el análisis de un algoritmo de cualquier implementación particular. La idea es ignorar los factores constantes en el análisis: en la mayor parte de los casos, si se desea co- nocer si el tiempo de ejecución de un algoritmo es proporcional a N o a 100, no tiene importancia si el algoritmo se ejecutará en una microcomputadorao en una supercomputadoray tampoco si el bucle interno se ha implementado cuidadosamente, con sólo unas pocas instrucciones, o si por el contraria se ha
  • 99. ANÁLISIS DE ALGORITMOS 79 hecho mal, con muchas instrucciones. Desde un punto de vista matemático, es- tos dos factores son equivalentes. El artificio matemáticoque permite precisar esta idea se denomina notación O, o anotación O mayúscula), definida de la siguiente forma: Notación. Se dice que unafunción g(N)pertenece a Of(N)) si existen las cons- tantes coy NOtales que g(N) es menor que c&(N) para todo N > NO. Informalmente, esto engloba la idea de «es proporcional a)y libera al analista de considerar los detalles de las característicasparticulares de la máquina. Ade- más, la afirmación de que el tiempo de ejecución de un algoritmo pertenece a O(AN))es independiente de los datos de entrada del algoritmo. Como el interés está en el estudio del algoritmo, no en sus datos de entrada o en la implemen- tación, la notación O es una forma útil de encontrar un límite superior del tiempo de ejecución, independientementede los detalles de la implementación y de los datos de entrada. La notación O ha sido de gran utilidad para los analistas, al ayudar a clasi- ficar algoritmos por su tiempo de ejecución, y también para los diseñadores, al orientar la búsqueda de los «mejores» algoritmos adecuados para problemas importantes. La meta del estudio del cálculo de la complejidadde un algoritmo es demostrar que su tiempo de ejecución pertenece a O(f(N)) para alguna función f; y que no puede haber ningún algoritmo con tiempo de ejecución en O(g(N))para cualquier función g(N) «inferion> (una función tal que limN,, g(N)/ f(N)=O). Se trata de proporcionar un «límite superion) y un <dí- mite infenom para el tiempo de ejecución del peor caso. El cálculo de los 1í- mites superiores consiste normalmente en analizar y determinar la frecuencia de las operaciones(se verán muchosejemplos en los capítulos siguientes),mien- tras que el de los límites inferiores implica la dificil tarea de construir cuidado- samente un modelo de máquina y determinar qué operaciones fundamentales debe ejecutar cualquier algoritmo para resolver un problema (rara vez se tratará este punto). Cuando los cálculos teóricos indiquen que el límite superior de un algoritmo coincide con su límite inferior, entonces se tendrá la seguridad de que es inútil tratar de disefiar un algoritmo fundamentalmente más rápido y, por tanto, se puede dedicar toda la atención a la implementación. Este punto de vista ha resultado ser muy útil en el diseno de algoritmos en los últimos años. Sin embargo, al utilizar la notación O hay que ser extremadamentecuida- doso al interpretar los resultados, al menos por cuatro razones: primera, es un <&mitesuperion) y la cantidad en cuestión podría ser mucho menor; segunda, podría ocumr que la entrada que provoca el peor caso no se dé nunca en la práctica; tercera, no se conoce la constante co y no tiene por qué ser pequeña; y cuarta, también se desconoce la constante NOy tampoco tiene por qué ser pe- queña. A continuación se consideraránestas razones, una a continuación de otra. La afirmación de que el tiempo de ejecución de un algoritmo pertenece a o(f(N)) no implica que el algoritmo siempre tarde tanto: sólo dice que el ana-
  • 100. 80 ALGORITMOS EN C++ N ’I4Mg’N ‘/zMg2N Mg2N ~ 3 1 2 ~~ 10 22 45 90 30 1O0 900 1.800 3.600 1.000 I.o00 20.250 40.500 81.O00 31.o00 10.000 422.500 845.000 1.690.000 31.600.000 1.ooo.ooo 90.250.000 180.500.000 361.OOO.OOO 1.ooo.ooo.ooo Figura 6.2 Importanciade los factores constantes en la comparaciónde funciones. lista ha podido comprobar que nunca tardará más. El tiempo real de ejecución podría ser siempre muy inferior. Se ha desarrollado la mejor notación para cu- brir la situación en la que se sabeque existe alguna entrada para la cual el tiempo de ejecución pertenece a O(AN)),pero hay muchos algoritmos para los que re- sulta bastante complicado construir los datos de entrada del peor caso. Aun cuando se conozca la entrada del peor caso, puede darse la situación en que los datos de entrada que se encuentren realmente en la práctica tengan tiempos de ejecución mucho más pequeños. Muchos algoritmos sumamente útiles tienen un mal comportamiento en el peor caso. Por ejemplo, el que pro- bablemente sea el algoritmo de ordenación más extendido, el Quicksort, tiene un tiempo de ejecución en O(N2),pero es posible organizar los datos de forma que el tiempo de ejecución para las entradas que se encuentran en la práctica sea proporcional a MogN. Las constantes co y No implícitas en la notación O a menudo ocultan deta- lles de la implementación que son importantes en la práctica. Evidentemente, decir que un algoritmo tiene un tiempo de ejecución en O(f(N)) no propor- ciona ningún dato sobre el tiempo de ejecución si N es menor que NOy copu- diera estar ocultando una gran cantidad de «valores superiores))diseñados para evitar un peor caso malo. Es preferible un algoritmo que utilice N2 nanosegun- dos a otro que utilice logN siglos, pero no se puede hacer esta elección basán- dose en la notación O. La Figura 6.2 muestra la situación para dos funciones típicas, con valores de las constantes más realistas, en el intervalo O 6 N 6 1.OOO.OOO. La función N3’2,que se podría haber consideradoerróneamente como la mayor de las cuatro, ya que es asintóticamente la más grande, en realidad es de las más pequeñas para pequeños valores de N, y es menor que Mg2Nhasta que N valga unas decenas de miles. Los programas en los que los tiempos de ejecución dependen de funciones de este tipo no se pueden comparar de forma eficaz sin prestar una atención cuidadosa a los factores constantes y a los deta- lles de la implementación. Es conveniente pensar detenidamente dos veces antes de utilizar, por ejem- plo, un algoritmo con tiempo de ejecución en O(N2)en lugar de uno en O(N), pero tampoco se deben seguir ciegamente los resultados del cálculo de la com- plejidad expresadosen notación O. En las implementaciones prácticas de los al- goritmos considerados en este libro, la complejidad del cálculo es a veces de- masiado general y la notación O demasiado imprecisa para ser útil. La complejidad del cálculo debe considerarse como el primer paso de un proceso
  • 101. ANÁLICIC DE ALGORITMOS 81 progresivo de refinamiento del análisis de un algoritmo, para dar a conocer más detalles sobre sus propiedades. En este libro se centra el interés en los pasos si- guientes, más próximos a las implementaciones reales. Análisis del caso medio Otra forma de estudiar el rendimiento de los algoritmosconsisteen examinar el caso medio. En la situación más simple, es posible caracterizar con precisión los datos de entrada del algoritmo: por ejemplo, un algoritmo de ordenación puede operar sobre un array de N enteros aleatorios o un algoritmo geométrico puede procesar un conjunto de N puntos aleatorios del plano con coordenadas entre O y I. Entonces se calcula el número medio de veces que se ejecuta cada instruc- ción y se obtiene el tiempo medio de ejecución del programa multiplicando la frecuencia de cada instrucción por el tiempo que se necesita para dicha instruc- ción y sumando todas estas cantidades. Sin embargo, al hacer esto existen al menos tres dificultades, que se van a examinar una a una. La primera es que en algunas computadoras puede resultar difícil determi- nar con precisión el tiempo que se necesita para cada instrucción. Peor aún, di- cho tiempo está sujeto a cambios, y una gran parte de los análisisrealizadosen una computadora puede no tener valor para los tiempos de ejecución del mismo algoritmo en otra computadora. Éste es exactamente el tipo de problemas que trata de evitar el estudio de la complejidad del cálculo. Segunda:a menudo el análisis del caso medio es en sí mismo un desafío ma- temático dificil que requiere argumentos detallados y complejos. Por su natu- raleza, los cálculos matemáticos necesarios para comprobar los límites superio- res son normalmente menos complejos porque no necesitan ser tan precisos. Todavía se desconoce el rendimiento del caso medio de muchos algoritmos. Tercera, y la más importante: en el análisis del caso medio puede ser que el modelo de datos de entrada no caracterice con precisión a los que aparecen en la práctica, o puede ser que no exista ningún modelo de entrada natural. ¿Cómo se deberían caracterizar los datos de entrada de un programa de tratamiento de textos en inglés? Pero, al contrario, existen pocos argumentos contra la utiliza- ción de modelos de entrada tales como «un archivo ordenado aleatonamente)) para un algoritmo de ordenación, o «un conjunto de puntos aleatorios))para un algoritmo geométrico. Para tales modelos es posible obtener resultados mate- máticos que permitan predecir con precisión el rendimiento de los programas que operan en aplicacionesreales. Aunque la obtención de estos resultados nor- malmente rebasa los objetivos de este libro, se presentan algunos ejemplos (ver Capítulo 9), y se mencionarán algunos resultados significativos cuando sea ne- cesario.
  • 102. 82 ALGORITMOS EN C++ Resultados aproximados y asintóticos A menudo, los resultados de un análisis matemático no son exactos sino apro- ximados en un sentido técnico preciso: el resultado podría ser una expresión compuesta por una sucesión de términos decrecientes. De igual forma que se presta más atención al bucle interno de un programa, se está más interesado en el término más significativo (el término más grande) de una expresión mate- mática. La notación-O se desarrolló originalmente para este tipo de aplicación, y, usada adecuadamente,permite realizar estimaciones concisas que dan bue- nas aproximaciones a resultados matemáticos. Supóngase, por ejemplo (después de algunos análisis matemáticos), que se determina que un algoritmo particular tiene un bucle interno que está repetido MgN veces de media, que una sección externa lo está N veces y que aigún có- digo de inicialización se ejecuta una sola vez. Supóngase además que se descu- bre (después de un minucioso examen de la implementación) que cada itera- ción del bucle interior requiere microsegundos, las de la sección externa, al microsegundos, y que la inicialización se hace en a2 microsegundos. Entonces el tiempo medio de ejecución del programa (en microsegundos) es @N 1gN+ a,N + a2. Pero también es cierto que el tiempo de ejecución es (El lector puede verificar esta afirmación a partir de la definición de O(N).) Esto es importante porque, si se está interesado en una respuesta aproximada, se sabe que, para N grande, puede no necesitarse encontrar los valores de al o a2. Más importante aún, puede que otros términos de la expresión exacta del tiempo de ejecución sean difíciles de analizar: la notación O proporciona una forma de obtener una respuesta aproximada para valores de N suficientemente grandes sin preocuparse de tales términos. Técnicamente, no se tiene una seguridad real de que se puedan ignorar los términos pequeños de esta manera, porque la definición de la notación O no dice nada sobre el tamaño de la constante co que podría ser muy grande. Pero (aunque, por lo regular, no sea una preocupación) en tales casos hay formas para acotar las constantes que son pequeñas en comparación con N, y así normal- mente está justificado ignorar las cantidades representadas por la notación O cuando existe un término principal (mayor)bien definido. Al hacer esto se tiene la seguridad de que se poseen los conocimientos necesarios para efectuar tal simplificación si fuera necesario, amque raramente se hará así. De hecho, cuando una funciónf(N) sea asintóticamente grande comparada con otra función g(N),se utilizará en este libro la terminología (decididamente
  • 103. ANÁLISIS DE ALGORITMOS a 3 no técnica) «del orden def(N)» para significarf(N) + O(g(N)).De esta manera, lo que se pierde en precisión matemática se gana en claridad, ya que el interés radica más en el rendimiento de los algoritmos que en los detalles matemáticos. En tales casos, el lector puede estar seguro de que, para valores de N suficien- temente grandes (si no es para todo N),la cantidad en cuestión estará muy pró- xima af(N). Por ejemplo, incluso si se sabeque una cantidad es N(N - 1)/2, se puede hacer referencia a ella como «delorden de» N2/2.Esto se percibe más rápidamentey, por ejemplo, el error cometidoes de un 10% cuando N = 1000. La precisión perdida en tales casos es despreciable comparada con la que se pierde al utilizar O(f( N)). El objetivo es ser a la vez precisos y concisos en la descripción del rendimiento de los algoritmos. Recurrencias básicas Como se verá en los siguientes capítulos, un gran número de algoritmos se ba- san en el principio de descomponer recursivamente un problema grande en otros más pequeños, utilizando las soluciones de los subproblemas para resolver el problema original. El tiempo de ejecución de estos algoritmos viene determi- nado por el tamaño y el número de los subproblemas, así como por el coste de la descomposición. En esta sección se verán métodos básicos para analizar tales algoritmos y obtener soluciones de unas cuantas fórmulas estándar que apare- cen en el análisis de muchos de los algoritmos que se estudiarán más adelante. La comprensiónde las propiedades matematicasde las fórmulas de esta sección permitirá delimitar las propiedades del rendimiento de los algoritmos de este li- bro. La propia naturaleza de un programa recursivo impone que su tiempo de ejecución para una entrada de tamaño N dependa de su tiempo de ejecución para entradas más pequeiias: esto conduce de nuevo a las relacbnes de recu- rrencia, que se vieron al principio del capítulo anterior. Tales fórmulas descri- ben de manera precisa el rendimiento de los algoritmos que les corresponden: para obtener el tiempo de ejecución, se resuelven las recurrencias. Posterior- mente se darán argumentosmás rigurosos al estudiar algunos algoritmos con- cretos: por ahora lo que interesa son las fórmulas, no los algoritmos. Fórmula 1. Esta recurrencia aparece en un programa recursivo que efectúa bucles en los datos de entrada para eliminar un elemento: CN= CNp1 + N, para N 2 2 con CI = 1. Solución:CNes del orden de N2/2.Para resolver esta recurrencia, se aplica sobre sí misma, «en cascada), de la siguiente forma:
  • 104. a4 ALGORITMOS EN C+-t~ N = CN-1 + N = CN-2 + ( N - l ) N =CN-,+(N-2)+(N- 1 ) + N = CI +2 + ... + (N - 2)+ (N- 1) -I-N = 1 +2+...+(N--2)+(N- 1)+N - N(N+ 1) - 2 - La evaluación de la suma 1 + 2 + ...+ (N - 2) + (N - 1) + N es elemental: el resultado obtenido anteriormente puede establecerseañadiendo la misma suma, pero en orden inverso, término a término. Este resultado, que es dos veces el valor buscado, contiene N términos, cada uno de los cuales vale N + 1. Fórmula 2. Esta recurrencia aparece en un programa recursivo que divide los datos de entrada en un solo paso: c N = c N l 2 4- 1, para N 2 2 con CI= O. Solución: CNes del orden de 1 0 . Escrita de esta forma, esta ecuación carece de sentido a menos que N sea par o que se suponga que N/2 es una división entera: por ahora, se supone que N = 2", o lo que es lo mismo n=lgN, de modo que la recurrencia siempre esté bien definida. Pero entonces la recurrencia en cascada llega incluso a ser aún más fácil que la anterior: C2" = C p l + 1 = Cp-2 + 1 + 1 = C2n-3 + 3 = C ~ O + n = n.
  • 105. ANÁLISIS DE ALGORITMOS 85 Resulta que la solución exacta para cualquier N depende de las propiedades de la represeíitación binaria de N, pero CNes del orden de 1gNpara todo N. Fórmula 3. Esta recurrencia aparece en un programa recursivo que divide los datos de entrada en dos, pero que debe examinar cada elemento de ellos. CN = c N f 2 + N, para N b 2 con C 1 = O. Solución: CNes del orden de 2N. Reduciendo como antes se obtiene la suma N +N/2 +N/4 +N/8 + ...(como en el caso anterior, esta serie sólo tendrá sentido cuando N sea una potencia de dos). Si la sucesión fuera infinita, sena una sene geométrka simple que convergeríaa 2N. Para cualquier valor de N, la solución exacta implica otra vez la representación binaria de N. Fórmula 4. Esta recurrencia aparece en un programa recursivo que tiene que hacer un recomdo lineal de los datos de entrada, antes, durante o después de dividirla en dos partes: CN= 2cN/2 + N, para N 2 2 con CI = O. Solución: CNes del orden de MgN. Ésta es la solución que será la más citada, porque es el prototipo de muchos algoritmos del tipo de divide y vencerás. C2n = 2Cy-1 + 2" C2n-2 2"- =-+ 1 + 1 = n. La solución se obtiene de la misma forma que la de la fórmula 2, pero con el truco adicional de dividir los dos miembros de la recurrencia por 2" en el se- gundo paso, para hacer la recurrencia en cascada. Fórmula 5. Esta recurrencia aparece en un programa recursivo que divide los datos de entrada en dos partes en un solo paso, como en el programa de dibujar una regla del Capítulo 5.
  • 106. 86 ALGORITMOS EN C++ Solución: CNes del orden de 2N. Esto se obtiene de la misma forma que en la fórmula 4. Se pueden tratar variantes secundarias de estas fórmulas, con diferentes coridicionesiniciales o ligeras diferencias en los términos añadidos, utilizando las mismas técnicas de resolución, aunque el lector debe tener en cuenta que algunas recurrencias que parecen similares a las anteriores,en realidad, pueden ser bastdnte difíciles de resolver. (Existe una variedad de técnicas generales avanzadas para tratar estas ecuaciones con rigor matemático.) Se encontrarán algunasrecurrenciasmás complicadasen capítulos posteriores, dejándose el es- tudio de su solución hasta el momento en que aparezcan. Perspectiva Muchos de los algoritmosde este libro han sido objeto de análisis matemáticos detalladosy de estudiosde rendimientodemasiadocomplejospara tratarlos aquí. De hecho, basándose en tales estudios es posible recomendar la utilización de muchos de los algoritmos que se presentarán. No todos los algoritmosse han sometido a análisistan detallados; en efecto, durante el proceso de diseño es preferible trabajar con indicadores de rendi- miento aproximadospara poder guiar el proceso sin detalles extraños. Según se vaya refinando el diseño, se debe avanzar más en el análisis y se necesitará uti- lizar herramientas matemáticas más sofisticadas. A menudo, el proceso de di- seño conduce a estudios detallados de la complejidad, que a su vez llevan a al- goritmos ateóricow muy alejadosde cualquier aplicación particular. Es un error común suponer que el análisis aproximado de estudios de complejidad se tra- ducirá inmediatamente en algoritmos prácticamente eficaces: esto suele con- ducir a sorpresasdesagradables.Por otra parte, la complejidadde cálculo es una herramienta poderosa para obtener las condicionesde partida del diseño sobre las que pueden basarse nuevos métodos importantes. No se debería utilizar un algoritmo sin tener una indicación de cómo lle- varlo a cabo: las aproximacionesdescritas en este capítulo ayudarán a propor- cionar alguna indicación del rendimiento de una gran variedad de algoritmos, como los que se verán en los capítulos siguientes. En el próximo se presentarán otros factores importantesque influyen a la hora de elegir un algoritmo. Ejercicios 1. Suponiendo que se sabe que el tiempo de ejecución de un algoritmo perte- nece a O(Nlog2V)y que el de otro pertenece a U(N3),¿qué se puede decir sobre el rendimiento relativo de estos algoritmos?
  • 107. ANÁLISIS DE ALGORITMOS 87 2. Suponiendo que se sabe que el tiempo de ejecución de un algoritmo es siempre del orden de MogN y que el de otro pertenece a O(N3),¿qué se puede decir sobre el rendimiento relativo de estos algoritmos? 3. Suponiendo que se sabe que el tiempo de ejecución de un algoritmo es siempre del orden de MogN y que el de otro es siempre del orden de N3, ¿qué se puede decir sobre el rendimiento relativo de estos algoritmos? 4. Explicar la diferencia entre O(1) y O(2). 5. Resolver la recurrencia C , = C N ~ + N2, para N 2 2 con CI = O, cuando N es una potencia de dos. 6. ¿Para qué valores de N se verifica 10MgN > 2N2? 7. Escribir un programa para calcular el valor exacto de CNen la fórmula 2, 8. Demostrar que la solución exacta de la fórmula 2 es 1gN-tO(1). 9. Escribir un programa recursivo para calcular el mayor de los enteros infe- riores a log2N. (Ayudapara N > 1, el valor de esta función para N / 2 es una unidad mayor que N.) 10. Escribir un programa iterativo para resolver el problema del ejercicio an- terior. Después escribir un programa que efectúe el cálculo utilizando sub- rutinas de la biblioteca de C++. Si la computadora lo permite, comparar el rendimiento de estos tres programas. como se presentó en el Capítulo 5. Comparar los resultados con 1 0 .
  • 109. 7 Implementación de algoritmos Como se mencionó en el Capítulo 1, el principal objetivo de este libro son los algoritmos en sí mismos, de forma que, cuando se presente alguno de ellos, se tratará como si su rendimiento fuera el factor crucial para la realización co- rrecta de tareas mayores, Este punto de vista sejustifica porque estas situaciones aparecen con todos los algoritmos, y porque la búsqueda cuidadosa de solucio- nes eficaces para un problema conduce frecuentemente a otros algoritmos más elegantes (y más eficaces). Por supuesto, este planteamiento restrictivo es muy poco realista, ya que cuando se resuelve un problema complicado con una computadora deben tenerse en cuenta otros muchos factores. En este capítulo se presentarán cuestiones referentes a la forma de hacer Útiles, en aplicaciones prácticas, los algoritmos algo idealizados que se describen en el libro. Las propiedades del algoritmo, después de todo, son sólo una cara de la mo- neda, ya que una computadora puede utilizarse para resolver un problema de forma eficaz sólo si está suficientemente comprendido. El considerar cuidado- samente las propiedades de las aplicaciones está más allá del alcance de este li- bro. La intención es proporcionar suficiente información sobre los algoritmos básicos, de manera que cualquiera pueda tomar decisionesinteligentessobre su empleo. La mayoría de los algoritmos que se tratarán aquí se utilizan en la prác- tica en muchas aplicaciones.La extensión de los algoritmos disponibIes para re- solver diferentes problemas depende de la extensión de las necesidades de las diversas aplicaciones. No existe «el mejom algoritmo de búsqueda (por poner un ejemplo), pero un método puede ser idóneo en un sistema de reservas de unas líneas aéreas y otro podrá ser mejor para utilizarlo en el bucle interno de un programa de descifrado. Los algoritmos rara vez existen en condiciones ideales, excepto posible- mente en la mente de sus diseñadores teóricos que inventan métodos sin pensar en ninguna implementación definitiva, o en la mente de los programadores de 89
  • 110. 90 ALGORITMOS EN C++ sistemas de aplicaciones, que «amañan» métodos ad hoc para resolver proble- mas que por otra parte están bien delimitados. El diseño adecuado de un algo- ritmo supone el tener en cuenta el posible impacto que éste tendrá en las im- plementaciones posteriores, y la programación adecuada de las aplicaciones implica el tener en cuenta Pas característicasde rendimiento de los métodos bá- sicos empleados. Selección de un algoritmo Como se verá en los capítulos siguientes, normalmente se dispondrá de varios algoritmos para resolver cada problema, todos con diferentes características de rendimiento, variando desde la simple solución de ((fuerzabruta>(aunque pro- bablemente ineficaz)hasta una solución compleja (bien afinada» (e incluso óp- tima). (En general, no es cierto que el algoritmo más eficiente sea el que tiene la implementaciónmás complicada, ya que algunos de los mejores algoritmos son bastante elegantesy concisos. Pero para los fines de este estudio se supondrá que esta regla es cierta.) Como se argumentaba anteriormente, no se puede de- cidir qué algoritmo utilizar para un problema sin analizar las necesidades del mismo: ¿Con qué frecuencia se utilizará el programa?, jcuáles son las caracte- rísticas generales del sistema de computación que se va a utilizar?, jes el algo- ritmo una parte pequeña de una gran aplicación, O viceversa? La primera regla de la implementaciónes que se debe implementar primero el algoritmo más simple que resuelva un problema dado. Si el problema parii- cular con el que se tropieza se resuelve fácilmente, entonces el algoritmo senci- llo podria resolver el problema y no sería necesario hacer nada más; pero si s e requiere un algoritmo más sofisticado, entoncesla implementaciónsencilla pro- porciona una forma de comprobación para casos puntuales y usa línea básica para evaluar las característicasdel rendimiento. Si sólo se va a ejecutar un algoritmo pocas veces, en casos que no son de- masiado grandes, entonces seguramente es preferible que la computadora tarde un poco de más de tiempo en la ejecución de un algoritmo un poco menos efi- caz, en lugar de que el programadoremplee excesivo tiempo desarrollando una implementaciónsofisticada. Por supuesto, existe el peligro de que se pueda ter- minar utilizando el programa más de lo que se suponía originalmente, y por tanto conviene estar siempre preparado para volver a empezar e implementar un algoritmo mejor. Si el algoritmo se va a integrar en un gran sistema, la implementacióndel método de la ((fuerzabruta» proporciona la funcionalidad que se requiere de una manera fiable, y posteriormente podrá mejorarse el rendimiento (de una manera controlada), sustituyendo el algoritmo por otro más refinado. Por su- puesto, cuando se estudie el comportamiento completo del sistema, se deberia tener cuidado para no excluir opciones al implementarel algoritmo, de t a l ma- nera que sea difícil mejorarlo más adelante, y se debería tener un especial cui-
  • 111. IMPLEMENTACI6NDE ALGORITMOS 91 dado con aquellos algoritmos que dan lugar a cuellos de botella en la ejecución. En grandes sistemas, es frecuente que las especificacionesde diseño del sistema dicten desde el comienzo cuál es el mejor algoritmo. Por ejemplo, puede que una estructura de datos compartida por el sistema sea una lista enlazada o un árbol, por lo que son preferibles los algoritmos basados en estas estructuraspar- ticulares. Por otro lado, cuando se tomen decisiones a la escala del sistema se debe prestar mucha atención al elegir los algoritmos a utilizar porque al final es muy frecuente que el comportamientode todo el sistema dependa de algún al- goritmo básico, como los que se tratarán en este libro. Si el algoritmo sólo se va a ejecutar unas cuantas veces, pero sobre proble- mas muy grandes, entonces se deseará asegurarseque se obtendrá una salida co- herente, y tener una estimación de cuánto tiempo tardará. Aquí otra vez, una implementación sencilla puede ser a veces bastante útil para la resolución de una tarea larga, incluyendo el desarrollo de todo lo necesario para la compro- bación de los resultados. El error más común a la hora de seleccionar un algoritmo es ign0ra.rlas ca- racterísticas de rendimiento. Los algoritmos más rápidos suelen ser los más complicados, y también es frecuente que las personas que los desarrollan estén dispuestas a aceptar un algoritmo más lento para evitar trabajar con una com- plejidad añadida. Pero, a menudo, un algoritmo más rápido no tiene por qué ser mucho más complicado, y trabajar con una pequeña complicación añadida es un pequeño precio a pagar para evitar manejar un algoritmo lento. Un nú- mero sorprendente de usuarios de sistemas de información pierden un tiempo considerable esperando que terminen de ejecutarse simples algoritmos cuadrá- ticos, cuando existen algontmos cuya complejidad en tiempo está próximo a M o o , y que pueden ejecutarse en una fracción de dicho tiempo. El segundo error más común que se comete, a la hora de seleccionar un al- goritmo, es dar excesiva importancia a las características de rendimiento. Un algoritmo en MogN podría ser ligeramente más complicado que un algoritmo cuadrático para resolver el mismo problema; pero un algoritmo en M o o , más eficaz, podría dar lugar a un incremento sustancial de la complejidad (y en rea- lidad podría ser más rápido sólo para valores de N muy grandes). También ocu- rre que muchosprogramas de hecho sólo se ejecutan unas pocas veces: el tiempo necesario para implemeiitar y depurar un algoritmo optimizadopodría ser con- siderablemente mayor que el tiempo que se necesita para ejecutar uno sencillo que es un tanto más lento. Análisis empírico Como se mencionóen el Capítulo 6, por desgracia frecuentemente se da el caso de que el análisis matemático aporte muy poca luz sobre cómo es el compor- tamiento esperado de un algoritmo particular en una situación concreta. En ta- les casos, es necesario realizar un análisis empírico, en el que se implementa
  • 112. 92 ALGORITMOS EN C++ cuidadosamenteun algoritmo y se controla su ejecución con una entrada «tí- pica). De hecho, esto debería realizarse incluso cuando se disponga de los re- sultados matemáticos completos, con el fin de comprobar su validez. Dados dos algoritmos que resuelvan el mismo problema, el método es muy claro: jse ejecutan los dos para ver cuál de ellos lleva más tiempo! Decir esto podría parecer demasiado obvio, pero probablemente sea la omisión más co- mún en el estudio comparativode algoritmos. El hecho de que un algoritmo sea diez veces más rápido que otro es muy improbable que se le escape a alguien que espera que uno de ellos acabe en tres segundos y que el otro acabe en treinta, pero es muy fácil que se le pase por alto algo como un pequeño factor constante en un análisis matemático. Sin embargo, también es fácil cometer errores cuando se comparan imple- mentaciones, en especial si intervienen diferentes máquinas, compiladores o sis- temas, o si están comparando programas muy grandes con entradas mal espe- cificadas. Desde luego, uno de los factoresque condujo al desarrollodel análisis matemático de los algoritmos fue la tendencia a confiar en los «modelos están- dam cuyo comportamiento probablemente se entienda mejor a través de un análisis cuidadoso. El principal peligro que se corre al comparar empíricamenteprogramas es que una implementación puede ser más «optimizada» que otra. Es probable que el inventor de un nuevo algoritmo preste una atención muy cuidadosa a cada aspecto de su implementación, pero no a los detalles de la implementacióndel algoritmo clásico rival del suyo. Para confiar en la precisión de un estudio de comparación empírico hay que estar seguro de que se ha prestado la misma atención a ambas implementaciones. Afortunadamente,el caso más frecuente es el siguiente: muchos algoritmos excelentes se han obtenido haciendo modi- ficaciones relativamente pequeñas de otros algoritmos creados para resolver el mismo problema, siendo válidos los estudios comparativos. Un caso particular importante surge cuando se compara un algoritmo con otra versión de simismo o cuando se comparan implementaciones ligeramente diferentes. Una buena manera de comprobarla eficacia de una modificación en particular, o de otra idea de la misma implementación, es ejecutar ambas ver- siones con alguna entrada «tipo» y en consecuencia seleccionar la más rápida. De nuevo, parece obvio mencionarlo, pero jel usuario debe tener cuidado!, por- que hay un sorprendente número de investigadores dedicados al diseño de al- goritmos que nunca implementan sus diseños. Como antes se esbozó, y también al comienzo del Capítulo 6, el criterio adoptado aquí es que diseño, implementación, análisis matemático y análisis empírico (todos ellos conjuntamente)contribuyen de forma crucial al desarro- llo de unas buenas implementacionesde algoritmos. Se utilizan todas las herra- mientas disponibles para obtener toda la información sobre las propiedades de los programas, y después se los modifica o se desarrollan programas nuevos, a partir de dicha información. Por otro lado, no siempre estájustificado hacer un gran número de cambios pequeños con la esperanza de mejorar ligeramente la ejecución. A continuación se tratará esta cuestión con más detalle.
  • 113. IMPLEMENTACIÓN DE ALGORITMOS 93 Optimización de un programa El procedimiento general para modificar un programa, con la finalidad de con- seguir otra versión más rápida en su ejecución, se denomina optimización del programa. Éste no es el término adecuado, porque es poco probable encontrar una implementación que sea «la mejom, pero, aunque no se pueda optimizar el programa, se puede esperar mejorarlo. Normalmente, la optimización del programa se realiza de forma automática, como parte del proceso de compila- ción, para mejorar el rendimiento del código compilado. Aquí se utiliza el tér- mino para referirsea las mejoras especzjicasdel algoritmo. Por supuesto, el pro- ceso también depende bastante del entorno de programación y de la máquina utilizada; por tanto aquí sólo se consideran cuestiones generales y no técnicas específicas. Este tipo de actividad está justificada sólo si se está seguro de que el pro- grama se empleará muchas veces o sobre grandes conjuntos de datos y si la experimentación demuestra que el esfuerzo dedicado a mejorar la implemen- tación será recompensado con una mejor ejecución. La mejor forma de per- feccionar el rendimiento de un algoritmo es mediante un proceso gradual de transformación en mejores programas y mejores implementaciones. La eli- minación de la recursión del Capítulo 5 es un ejemplo de un proceso de este tipo, aunque la forma de mejorar el rendimiento no había sido el objetivo en ese momento. El primer paso en la implementación de un algoritmo es desarrollar una ver- sión inicial en su forma más simple. Esto proporciona una línea básica para posterioresrefinamientosy mejoras y, como se mencionó anteriormente, es muy frecuente que haya que realizar todo esto. Se deben contrastar los resultados matemáticos disponibles con los resultados de la implementación; por ejemplo, si el análisis indica que el tiempo de ejecución es O(logN), pero el tiempo de ejecución real es de varios segundos, entonces algo falla, o bien la implementa- ción, o bien el análisis, y ambos deben estudiarse con más cuidado. El siguiente paso es identificar el «bucle interno» y tratar de minimizar el número de instrucciones que lo componen. Quizás la manera más fácil de en- contrar este bucle sea ejecutar el programa y comprobar qué instrucciones se ejecutan más a menudo. Por lo regular, esto da una buena indicación de dónde se puede mejorar el programa. Cada instrucción del bucle interno debería so- meterse a un cuidadoso examen: ¿Es realmente necesaria?,¿existe una manera más eficaz de llevar a cabo la misma tarea? Por ejemplo, por lo general merece la pena codificar algo más, para eliminar llamadas a procedimiento desde el bu- cle interno. Existen otras técnicas «automáticas» para hacer esto, muchas de las cuales están implementadas en compiladores estándar. A final de cuentas, el mejor rendimiento se logra traduciendo el bucle interno a lenguaje de máquina o lenguaje ensamblador;pero esto suele ser un último recurso. En realidad no todas las «mejoras» producen ganancias en el rendimiento, de modo que es sumamente importante comprobar el alcance del ahorro obte-
  • 114. 94 ALGORITMOS EN C++ nido en cada paso. Además, a medida que la implementación se perfecciona más y más, es aconsejable volver a comprobar si estájustificada esta profundización en los detallesdel código. En el pasado, el tiempo de cálculo era tan costoso que estaba casi siemprejustificado emplear más tiempo de programación para aho- rrar ciclos de cálculo, pero las cosas han cambiadoen los últimos años. Por ejemplo, considerandoel algoritmo del recomdo del árbol en orden pre- vio presentado en el Capítulo 5, la eliminación de la recursión es en realidad el primer paso para «optimizan>este algoritmo, porque su acción se centra en el bucle interno. La versión no recursiva dada es probablemente más lenta que la versión recursiva en muchos sistemas (puede comprobarloel lector) porque el bucle interno es más grande e incluye cuatro llamadas (aunque no recursivas) a procedimientos (sacar,meter, meter y vaci a), en vez de dos. Si se reempla- zan las llamadas a los procedimientos de la pila por otro código, para acceder directamente a la pila (utilizando,por ejemplo, una implementaciónpor array), es probable que este programa será significativamentem á srápido que la versión recursiva. (Una de las operaciones push del algoritmo es provisional, por lo que el programa estándar de un bucle dentro de otro constituye probablemente la base de la versión optimizada.) Es evidente que el bucle interno implica in- crementar el puntero de la pila, almacenarun puntero (t-> der) en el array de la pila, reinicializar el puntero t (a t- >izq) y compararlo con z. En muchas máquinas, esto se podría implementar con cuatro instrucciones de lenguaje de máquina, mientras que un compiladortípico es probable que genere el doble o más. Es posible hacer sin demasiado trabajo que el programa se ejecute cuatro o cinco veces más rápido que la implementaciónrecursiva directa. Obviamente, los problemas que se están estudiando aquí dependen en gran medida del sistema y de la máquina. No es conveniente embarcarse en un in- tento seno de acelerar un programa, sin tener un conocimientobastante deta- llado del sistema operativo y del entorno de programación. La versión óptima de un programa se puede volver bastante frágily dificil de modificar, y un com- pilador o un sistema operativo nuevos (por no mencionar una computadora nueva) podría arruinar por completo una implementacióncuidadosamente op- timizada. Se debe tener una precaución especial cuando se utiliza un lenguaje evolucionado como el C++, en el que hay que esperar frecuentes modificacio- nes y mejoras. Por otra parte, el libro se enfoca a la eficaciade las implementacionespres- tando especial atención al bucle interno y asegurándose de que el tiempo de ejecución del algoritmo está minimizado en su mayor parte. Los programas están codificados de forma escueta y flexible con el fin de poder añadir algu- nas mejoras, de una manera directa y en cualquier entorno de programación concreto. La implementación de un algoritmo es un proceso cíclico del desarrollo de un programa: se concibe, se depura, se estudian sus características y después se refina la implementación hasta que se alcanza el nivel de rendimiento de- seado. Como se vio en el Capítulo 6, en general, el análisis matemático puede ayudar en el proceso: primero, para sugerir qué algoritmos son susceptiblesde
  • 115. IMPLEMENTACIÓNDE ALGORITMOS 95 llevar a cabo una cuidadosa implementación;segundo, para ayudar a compro- bar que la implementación se desarrolla como se esperaba. En algunos casos, este proceso puede conducir al descubrimiento de ciertas propiedades, que ha- cen posible un nuevo algoritmo o mejoras sustanciales de una versión más antigua. Algoritmos y sistemas Las implementaciones de los algoritmos de este libro se pueden encontrar en una amplia variedad de programas, sistemas operativos y sistemas de aplicacio- nes. La intención es describir los algoritmos y animar al lector a que centre su atención en sus propiedades dinámicas experimentadas con las implementacio- nes dadas. En algunas aplicaciones, las implementaciones pueden ser útiles tal y como vienen dadas, pero para otras puede necesitarse más trabajo. Como se mencionó en el Capítulo 2, los programas de este libro sólo utili- zan aspectos básicos del C++, sin aprovechar las posibilidades más potentes, disponibles en C++ y en otros entomos de programación. La finalidad es el es- tudio de los algoritmos, no la programación de sistemas o aspectos avanzados de los lenguajes de programación. Se supone que los aspectos esenciales de los algoritmos se exponen mejor a través de implementaciones sencillas y directas en un lenguaje casi universal. El estilo de programación que se utiliza es conciso, con nombres de varia- bles cortos y con pocos comentarios,de manera que se destaquen las estructuras de control. La «documentación» de los algoritmos es el texto que los acom- paña. Se espera que los lectores que utilicen estos programas en aplicaciones reales los desarrollen adaptándolos para su uso particular. Al construir sistemas reales está justificado un estilo de programación más «defensivo»: los progra- mas deben implementarse de modo que puedan modificarse fácilmente, leerse con rapidez y entenderse por otros programadores, y que además realicen una buena interfz con otras partes del sistema. En particular, aunque los algoritmos que se tratan sean apropiados para es- tructuras de datos más complejas, las estructurasde datos que se necesitan para las aplicaciones normalmente contienen bastante más información de la que se utiliza en el libro. Por ejemplo, se habla de búsqueda en archivos que contienen números enteros o cadenas de caracteres cortas, mientras que, por lo regular, una aplicación necesita operar sobre largas cadenas de caracteres que forman parte de grandes registros. Pero los métodos básicos disponiblesen ambos casos son los mismos. En tales casos se tratarán aspectos destacados de cada algo- ritmo y se verá cómo se podnan relacionar con diversas características o nece- sidades de la aplicación. Muchos de los comentarios anteriores tienen que ver con la mejora del ren- dimiento de un algoritmo particular y también se aplican para mejorar el ren- dimiento de un sistema grande. Sin embargo, a gran escala, una de las técnicas
  • 116. 96 ALGORITMOS EN C++ para mejorar el rendimiento de un sistema podría ser reemplazar un módulo en el que se implementa un algoritmopor un módulo en el que se implementa otro. Un principio básico para construir grandes sistemases que deberían ser posibles tales cambios. Normalmente, cuando un sistema evoluciona se obtienen cono- cimientos más precisos sobre las necesidades específicasde los módulos en par- ticular. Este conocimiento específico hace posible una selección más cuidadosa del mejor algoritmo a utilizar entre los que satisfacen esas necesidades; después se puede concentrar el esfuerzo en mejorar el rendimiento de ese algoritmo, como se dijo anteriormente. Es cierto que la mayor parte del códigodel sistema se ejecuta sólo unas pocas veces (o ninguna) y que el principal interés del cons- tructor del sistema es crear un todo coherente. Por otra parte, también es muy probable que, cuando el sistema entre en funcionamiento, muchos de sus re- cursos se dedicarán a resolver problemas fundamentales del mismo tipo que los presentados en este libro, de modo que es conveniente que el constructor del sistema conozca los algoritmos básicos que aquí se describen. Ejercicios 1. ¿Cuánto tiempo se tarda en contar hasta 100.000?Dar una estimación del tiempo que tardaría el programa j = O; for (i= 1; i < 100000; i++) j++;en el entorno de programación del lector. Después ejecutar el pro- grama para comprobar dicha estimación. 2. Responder a la pregunta anterior utilizando repeat y whi 1e. 3. Ejecutándolo para valores pequeños, estimar el tiempo que llevaría la im- plementación de la criba de Eratóstenes del Capítulo 3 para un valor de N = 1.OOO.OOO (si se dispone de memoria suficiente). 4. «Optimizan>la implementación de la criba de Eratóstenes del Capítulo 3 para encontrar el número primo más grande que se pueda obtener en 10 segundosde cálculo. 5. Demostrar la afirmación del texto según la cual, al eliminar la recursión del algoritmo de recorrido del árbol en orden previo del Capítulo 5 (con lla- madas a procedimientos para operaciones con pilas), el programa se hace más lento. 6. Demostrar la afirmación del texto según la cual, al eliminar la recursión del algoritmo de recorrido del árbol en orden previo del Capítulo 5 (e imple- mentando directamente operaciones de pila), el programa se hace más rá- pido. 7. Examinar el programa en lenguaje ensamblador producido por el compi- lador C++ del entorno local de programación del lector para el algoritmo recursivo de recorrido de árbol en orden previo del Capítulo 5. 8. Diseñar un experimento para comprobar cuál de las dos implementaciones de una pila, lista enlazada o array es más eficaz en el entorno de progra- mación del lector.
  • 117. IMPLEMENTACIÓNDE ALGORITMOS 97 9. Determinar cuál es el método más eficaz para representar la regia dada en el Capítulo 5: jel método recursivo o el no recursivo? 10. En la implementación no recursiva dada en el Capítulo 5, al recorrer un árbol completo de 2" - 1 nodos en orden previo, jcuántas inserciones se utilizan exactamente en la pila?
  • 118. 98 ALGORITMOS ENC++ REFERENCIASpara Fundamentos Existe un gran número de libros de texto de introducción a la programación y a las estructuras de datos elementales. La referencia estándar para C++ es el li- bro de Stroustrup, y la mejor fuente para cuestiones específicas y ejemplos de programas en Cycon el mismo espíritu que el encontrado en este libro, es el libro de Kernighan y Ritchie sobre este lenguaje. La colecciónmás completa de información sobre las propiedadesde estructurasde datos elementales y árboles es el Volumen 1 de Knuth: los Capítulos 3 y 4 no contemplan más que una pequeña parte de la información que se proporciona en el libro de Knuth. La referencia clásica para el análisis de algoritmosbasado en las medidas del comportamientoasintóticodel peor caso es el libro de Aho, Hopcroft y Ullman. Los libros de Knuth cubren, con mayor amplitud, el análisis del caso medio y son la fuente autorizada sobre las propiedades específicas de varios algoritmos (por ejemplo, casi 50 páginas del Volumen 2 se dedican al algoritmo de Eucli- des.) El libro de Gonnet trata tanto el análisis del peor caso como el del caso medio, así como muchos algoritmosde reciente desarrollo. El libro de Graham, Knuth y Patashnik comprende los aspectos matemá- ticos que normalmente se utilizan en el análisis de algoritmos. Por ejemplo, dicho libro describe muchas técnicas para resolver ecuaciones de recurrencia como las dadas en el Capítulo 6 y otras mucho más difíciles que se encontra- rán más adelante.Tal mzteria también se trata con gran amplitud en los libros de Knuth. El libro de Roberts abarca la materia relativa al Capítulo 6, y los libros de Bentley plantean el mismo punto de vista que el Capítulo 7 y secciones poste- riores de este libro. Bentley describe de forma detallada un gran número de es- tudios completos de casos sobre la evaluación de varias propuestas para desa- rrollar algoritmos e implementaciones para resolver algunos problemas interesantes. A. V. AhoyJ. E. Hopcroft y J. D. Ullman, The Design and Analysis o f Algo- rithms, Addison-Wesley, Reading, MA, 1975. J. L. Bentley, ProgrammingPearls, Addison-Wesley, Reading, MA, 1985;More ProgrammingPearls, Addison-Wesley, Reading, MA, 1988. G. H. Gonnet, Handbook o f Algorithms and Data Structures, Addison-Wesley, Reading, MA, 1984. R. L. Graham, D. E. Knuth y O. Patashnik, Concrete Mathematics, Addison- Wesley, Reading, MA, 1988. B.W. Kernighan y D. M. Ritchie, The CProgrammingLanguage, segunda edi- ción, Prentice Hall, Englewood Cliffs, NJ, 1988. D. E. Knuth, TheArt o f ComputerProgramming. Volume I : FundamentalAl- gorithms,segunda edición,Addison-Wesley, Reading, MA, 1973; Volume2: Seminumerical Algorithms, segunda edición, Addison-Wesley, Reading, MA, 1981; Volume3: Sorting and Searching, segunda impresión, Addison-Wes- ley, Reading, MA, 1975.
  • 119. IMPLEMENTACIÓN DE ALGORITMOS 99 E. Roberts, ThinkingRecursively, John Wiley & Sons, Nueva York, 1986. B. Stroustrup, The C++Programming Language, segunda edición, Addison- Wesley, Reading, MA, 1991. (Existe versión en español por Addison-Wesley Iberoamericana y Ediciones Díaz de Santos,N. del E.)
  • 123. 8 Métodos de ordenación elementales Durante esta primera excursión por el área de los algoritmos de ordenación, se verán algunos métodos «elementales» que son apropiadospara archivos peque- ños o con una estructura particular. Existen varias razones para estudiar con detalle estos algoritmosde ordenación sencillos:la primera es que proporcionan una forma relativamente fácil de aprender la terminología y los mecanismosbá- sicos de los algoritmos de ordenación, con el fin de obtener una información previa adecuada para el estudio de los algoritmos más sofisticados. La segunda razón es que hay un gran número de aplicaciones de ordenación en las que es mejor utilizar estos métodos sencillos en lugar de otros más potentes pero con fines más generales. Por último, algunos de los métodos sencillos se pueden ex- tender a otros métodos de carácter general o pueden utilizarse para mejorar la eficacia de métodos más potentes. Como se acaba de mencionar, existen varias aplicaciones de ordenación en las que el método elegido puede ser un algoritmo relativamente sencillo. Con frecuencia, los programas de ordenación se usan una única vez (o muy pocas veces). Si el número de elementos a ordenar no es demasiado grande (por ejem- plo, menos de 500), puede ser más eficaz utilizar un método sencillo en lugar de implementar y depurar uno complicado. Los métodos elementales siempre son apropiadospara archivospequeños (menosde 50 elementos),y es poco pro- bable que se pueda justificar el uso de un algoritmo sofisticado para ordenar un archivopequeño, salvo que vaya a clasificarseun gran número de archivos.Otros tipos de archivos que son relativamente fáciles de ordenar son aquellos que ya están casi ordenados(o ya están ordenados), o aquellos que contienen un gran número de clavesiguales. Para estos archivos «bienestructurados» puede resul- tar mucho mejor utilizar métodos sencillos en lugar de métodos más generales. Por regla general, los métodos elementalesque se verán en el libro necesitan aproximadamente N2 pasos para ordenar N elementos organizados al azar. 103
  • 124. 104 ALGORITMOS EN C++ Cuando N es suficientemente pequeño esto no presenta ningún problema, y, si los elementos no están organizados aleatoriamente, alguno de estos métodos puede resultar mucho mejor que otros más sofisticados. Sin embargo, debe ha- cerse hincapié en que estos métodos no deberían utilizarse para ordenar archi- vos grandes, ni para ordenar archivos clasificados aleatoriamente, con la nota- ble excepción de la ordenación de Shell, que es, en realidad, el método de ordenación elegido para muchas aplicaciones. Reglas del juego Antes de considerar algunos algoritmosespecíficosresultará útil presentar cierta terminología general y algunos supuestos básicos de los algoritmos de ordena- ción. Se hará el estudio de métodos para ordenar archivos de registrosque con- tienen claves, que no son más que una parte de los registros (a menudo una pe- queña parte), pero que se utilizan para controlar la ordenación. El objetivo de un método de ordenación es volver a organizar los registros para que sus claves estén ordenadas de acuerdo con alguna regla bien definida (por lo regular, en orden numérico o alfabético). Si el archivo a ordenar se encuentra en la memoria (o, en el contexto del libro, en un array de C++), entonces el método de ordenación se denomina in- terno. Cuando se realiza la ordenación de archivos situados en cinta o en disco se denomina ordenación externa. La diferenciaprincipal entre las dos es que en una ordenación interna se puede acceder fácilmente a cualquier registro, mien- tras que en una externa debe accederse a los registros de un modo secuencial,o al menos en grandes bloques. En el Capítulo 13 se verán algunas ordenaciones externas, pero la mayoría de los algoritmos que se tratarán en el libro serán or- denaciones internas. Como de costumbre, el principal parámetro de rendimiento que interesará es el tiempo de ejecución de los algoritmos de ordenación. El primero de los cuatro métodos que se presentan en este capítulo necesita un tiempo proporcio- nal a N2,siendo N el número de elementos a ordenar, mientras que otros mé- todos más avanzados pueden ordenar N elementos en un tiempo proporcional a MogN. (Puede demostrarse que ningún algoritmo de ordenación puede utili- zar menos de NogN comparaciones entre claves.) Después de examinar estos métodos sencillos se estudiarán otros más avanzados que pueden ejecutarse en un tiempo proporcional o menor a N3’*y se verá que existen métodos que uti- lizan la propiedades digitalesde las claves para obtener un tiempo de ejecución total proporcional a N. El segundo factor importante a considerar es la cantidad de memoria extra necesaria para cada algoritmo de ordenación. Básicamente, se distinguirán tres tipos de métodos: aquellosque ordenan in situ y no utilizan memoria extra, salvo una eventual pila o tabla de pequeño tamaño; aquellos que utilizan una repre- sentación por lista enlazada y necesitan por tanto N palabras de memoria suple-
  • 125. MÉTODOSDE ORDENACIÓNELEMENTALES 105 mentaria para los punteros de la lista, y aquellos que necesitan bastante me- mona extra para almacenar una copia del array que se desea ordenar. Una característica de los métodos de ordenación, que a veces resulta impor- tante en la práctica, es la estabilidad. Se dice que un método de ordenación es estable si, cuando se encuentra con dos registros que tienen la misma clave, con- serva su orden relativo en el archivo. Por ejemplo, si se toma una lista con los estudiantes de una clase ordenados alfabéticamente y se ordena por notas, un método estable dará una lista en la que los estudiantesque tienen las mismas no- t a s permanecen ordenados alfabéticamente;en cambio, un método inestable es probable que dé una lista sin que quede ningún rastro del orden alfabético origi- nal. La mayoría de los métodos sencillos son estables, pero la mayor parte de los algontmos sofisticadosconocidos no lo son. Si la estabilidad es vital, es posible imponerla añadiendo un pequeño índice a cada clave antes de la ordenación, o prolongando el alcance de la clave de alguna otra forma. La estabilidad pare- ce que se adquiere fácilmente y a menudo se reacciona con desconfianza ante los efectos desagradables de la inestabilidad. De hecho, pocos métodos logran estabilidad sin utilizar grandes cantidades de espacio o tiempo suplemen- tarios. El siguiente programa tiene por objeto ilustrar los convenios generales que se utilizarán en este capítulo. Está formado por un programa principal que lee N números y a continuación llama a una subrutina para ordenarlos. En este ejemplo, solamente se ordenan los tres primeros números leídos: lo importante es que este programa «piloto» podría llamar a cualquier programa de ordena- ción en lugar de ordenar3. inline void intercambio(tipoE1emento a[], int i, int j ) { tipoElemento t = a[i]; a[i] = a[j]; a[j] = t; } ordenar3(tipoElemento a[] ,int N) if (a[l] > a[2]) intercambio(a, 1, 2); i f (a[i] > a[3]) intercambio(a, 1, 3); if (a[2] > a[3]) intercambio(a, 2, 3); { 1 { const i n t maxN = 100; main() int N, i; tipoElemento v, a[maxN+l]; N = O; while (cin >> v) a[++N] = v; a[O] = O; ordenar3(a,N) ; for (i = 1; i<= N; i++) cout << a[i] << I I ; cout << 'n';
  • 126. 106 ALGORITMOS EN C++ Como en el Capítulo 3, se mantiene la atención en los detallesde los algoritmos dejando sin especificar el tipo de los elementos a ordenar (tipoElemento), y se emplean aquellos algoritmos que permiten ordenaciones sencillas de arrays de númerosenterosen orden numérico, o de caracteresen orden alfabético.En C++ es fácil utilizar typedef o plantillas para adaptar tales algoritmos de forma que se puedan utilizar en aplicaciones prácticas que puedan implicar grandes claves o registros. Por lo regular, los programas de ordenación acceden a los registros de una de estas dos formas: o acceden a las claves para compararlas o acceden a los registros completos para intercambiarlos. La mayoría de los algoritmos que se estudiarán pueden describirse por medio de estas dos operaciones sobre regis- tros arbitrarios. Si se van a ordenar registros grandes, será prudente hacer una ((ordenaciónindirecta» para evitar desplazarlosdurante el proceso: no se reor- denan los propios registros sino un array de punteros (o índices), de forma que el primer puntero apunte al registro más pequeño, etc. Las claves se pueden guardar ya sea con los registros (si son grandes) o bien con los punteros (si son pequeñas). Si es necesario, se pueden reorganizar los registros después de la or- denación, como se describirá más adelante en este capítulo. El procedimiento intercambio lleva a cabo una operación de ((intercam- bio» y es inl ine porque los intercambiosson fundamentalespara muchos pro- gramas de ordenación y normalmente forman parte del bucle interno. En rea- lidad, el programa utiliza un acceso al archivo todavía más restringido: hay tres instrucciones de la forma ((comparar dos registros y, si es necesario, intercam- biarlos poniendoen primer lugar el de clave más pequeña». Los programas que se reducen a estas instrucciones son interesantes porque favorecen su imple- mentación en cualquier máquina. Este punto se estudiará con más detalle en el Capítulo 40. Mientras se pueda dar alguna indicación de cómo explotar las facilidades que ofrece C++ para construir diversos algoritmos que se consideran útiles para ciertas aplicaciones, se evitará insistir en el problema general de cómo se debe- rían ((empaquetan)las ordenaciones. Por ejemplo, ya se ha rocado el tema de la utilización de plantillas o de typedef para hacer que los algoritmos puedan ser útiles para más tipos de claves. Otro ejemplo: es razonable pasar el an-ay a or- denar como un parámetro de la rutina de ordenación en C++, pero esto no tiene por qué ser así en otros lenguajes de programación. En lugar de ello ¿debería trabajar el programa sobre un array global?, ¿debería la rutina de ordenación ser parte de una cl ase que generalice las operaciones que se pueden llevar a cabo a cualquier array susceptibie de ordenación? En algunos sistemas operativos es bastante fáciljuntar programas sencillos, como por ejemplo el anterior, para que sirvan de «filtros» entre su entrada y su salida. Al contrario, muchas aplicacio- nes no necesitan realmente mecanismostales como clases y filtros, siendo pre- ferible introducir en la aplicación un pequeñocódigo de ordenación.Claro esa, estos comentarios se pueden aplicar a otros muchos algoritmos que se exami- narán en este libro, pero el estudio de los algoritmos de ordenación descubrirá gran parte de los puntos más interesantes.
  • 127. MÉTODOS DE ORDENACIÓN ELEMENTALES 107 Tampoco se incluyen en los programas muchas instrucciones de «verifica- ción de errores», aunque suele ser prudente hacerlo en las aplicaciones. Por ejemplo, la rutina piloto debería comprobar que N no tome un valor superior a maxN (y ordenar3 debería verificar que N=3). Otra comprobación útil sería que el programa piloto se asegurara de que el array está ordenadodespués de llamar a ordenar3. Esto no garantiza que el programa de ordenación funcione (¿por qué?), pero puede ayudar a mostrar los errores. Algunos programas utilizan otras variables globales, en cuyo caso las decla- raciones que no son obvias se incluirán en el código del programa. Además, a menudo se reservará a[O] (y algunas veces a[N+1] ) para almacenarlas claves especialesutilizadas por algunos de los algoritmos. En los ejemplos se utilizarán con frecuencia las letras del alfabeto en lugar de los números: aquéllas se pue- den emplear de manera evidente utilizando las funciones estándar de C++ que convierten enteros a caracteres, y viceversa. Ordenación por selección Uno de los algoritmos de ordenación más sencillos funciona de la siguiente forma: primero se busca el elemento más pequeño del array y se intercambia con el que está en la primera posición; después se busca el segundo elemento más pequeño y se intercambia con el que está en la segunda posicion, conti- nuándose de esta forma hasta que todo el array esté ordenado. Este método se denomina ordenación por selección porque funciona «seleccionando» repetiti- vamente el elemento más pequeño de los que quedan por ordenar, como mues- tra la Figura 8.1. En el primer paso, la A de la octava posición es el elemento más pequeño, por lo que se cambia por la E dei principio. En el segundo paso, la segunda A es el elemento más pequeño de los que quedan, de forma que se intercambia con la J de la segunda posición. Después la D se intercambia con la E de la tercera posición, y a continuación, en el cuarto paso, la primera E se intercambia con la M de la cuarta posición, y así sucesivamente. El siguiente programa es una implementaciónde este proceso. Para todo i entre 1y N - 1,se intercambia a[i ] con el elemento más peqdeño de la sucesión a[i], ...,a[N]: void selection (tipoElemento a[], int N) r I i n t i , j , min; for ( i = 1; i > N; i++) min = i ; {
  • 128. 108 ALGORITMOS EN C++ Figura 8.1 Ordenación por selección.
  • 129. MÉTODOSDE ORDENACIÓN ELEMENTALES 1o9 f o r ( j = i+l; j <= N; j + + ) intercambio (a, min, i); i f ( a [ j ] < a[min]) min = j; A medida que el índice irecorre el archivo de izquierda a derecha, los elemen- tos que quedan a su izquierda están ya en su posición definitivadentro del array (y no se desplazarán otra vez), de manera que el array está completamente or- denado cuando el índice llega al extremo de la derecha. Éste es uno de los métodos de ordenación más sencillos y funcionará muy bien con archivos pequeños. El {bucle interno)) está formado por la compara- ción a[ j] <a[mi n] (más el código necesario para incrementar j y comprobar que no es mayor que N), y prácticamente no puede ser más simple. Más ade- lante se examinará el número de veces que es probable que se ejecuten estas instrucciones. Además, a pesar de su enfoque evidente de «fuerza bruta», la ordenación por selección tiene de hecho una aplicación bastante importante: como la ma- yoría de los elementos se mueven como máximo una vez, este tipo de ordena- ción es el método que debe elegirse para ordenar archivos que tienen registros muy grandes y claves muy pequeñas. Esto se verá con detalle más adelante. Ordenación por inserción La ordenaciónpor inserción es un algoritmo casi tan sencillo como la ordena- ción por selección, pero probablemente más flexible.Es el método que se utiliza a menudo para ordenar las cartas cuando sejuegan unas manos de bridge: con- sidérense los elementos uno tras otro, insertando cada uno en su lugar apro- piado entre los que ya se han considerado (manteniéndolos ordenados). Como se muestra en la Figura 8.2, el elemento considerado se inserta simplemente moviendo una posición a la derecha a todos los elementos mayores que él e in- sertando a continuación el elemento en la posición vacante. La J de la segunda posición es mayor que la E de la primera, por lo que no hay que desplazarla.Al encontrar la E en la tercera posición se cambia con la J para poner E E J en el orden deseado, y así sucesivamente. Este proceso se implementa en el programa siguiente. Para cada i,con va- lores entre 2 y N, se ordenan los elementos a[l] ,...,a [i ] insertando a[i] en su lugar en la lista ordenada de elementos a [11,...,a[i- 11: void insercion(tipoE1emento a[] , i n t N) i n t i, j; tipoElemento v; {
  • 130. 110 ALGORITMOS EN C++ ~ ~~ ~ Figura 8.2 Ordenación por inserción.
  • 131. MÉTODOSDE ORDENACIÓNELEMENTALES 111 for (i= 2; i <= N; i t + ) while (a[j-1] > v) a [ j ] = v; { { a [ j ] = a[j-11; j--; } Como en una ordenación por selección, los elementos situados a la izquierda del índice iestán ordenados entre sí durante la ordenación, pero no están en su posición definitiva, ya que puede ocurrir que tengan que moverse para hacer sitio a elementos más pequeños que se encuentren posteriormente. Sin ern- bargo, el array está ordenado por completo cuando el índice alcanza el extremo derecho. Hay que considerar otro detalle más importante: jel procedimiento de inser- ción no trabaja con la mayor parte de los datos de entrada! Esto es así porque cuando v sea el elemento más pequeño del array, el bucle whi 1e seguirá funcio- nando cuando se llegue al final del array por la izquierda. Para arreglar esto, se coloca una clave «centinela» en a[O], haciendo que tome un valor inferior o igual al del elemento más pequeño del array. Los centinelas normalmente se utilizan en situaciones como éstas para evitar tener que incluir una comproba- ción (en este caso sena comprobar que j >1), que casi siempre se produce en el bucle interno. Si por alguna razón no es conveniente utilizar un centinela (por ejemplo, si no se puede definir fácilmente cuál es la clave más pequeña), entonces se podría utilizar la comprobación whi 1e j >1 && a [j-11 >v. Esta solución no es atrac- tiva, porque el caso de j=lsólo se da en raras ocasiones, de manera que ¿por qué se comprueba con tanta frecuenciaesta situación en el bucle interno? Es de destacar que cuando j es igual a 1, la comprobación anterior no accederá a a[j-1 ] porque ésta es la forma como se evalúan las expresioneslógicasen C++ -en estos casos otros lenguajes podrían hacer accesos ilegales al array-. Otra forma de tratar esta situación en C+t es utilizar un break o un goto a la salida del bucle whi 1e. (Algunosprogramadores prefieren evitar las instrucciones goto y para ello son capaces de cualquier cosa, como por ejemplo llevar a cabo una acción dentro del bucle para asegurarse de que éste termina bien. En este caso, esta solución no parece que esté justificada, ya que no clarifica el programa y añade sobrecargascada vez que se recorre el bucle como protección contra un caso raro.) Digresión: Ordenación de burbuja Un método de ordenación elemental que se enseña a menudo en los cursos de introducción a la informática es la ordenacibn de burbuja: se efectúan tantos
  • 132. 112 ALGORITMOS EN C++ pasos a través del archivo como sean necesarios, intercambiando elementos ad- yacentes; cuando en algún paso no se necesiten intercambios, el archivo estará ordenado. A continuación se da una implementaciónde este método. void burbuja(tipoE1emento a[], int N) int i,j; for (i = N; i >= 1; i--) { for (j = 2; j >= i ; j++) if (a[j-11 > a[j] intercambio,ii, j- } Es preciso un momento de reflexión antes de convencerse de que el programa hace lo que debe hacer: siempre que se encuentra el elemento de mayor valor durante el primer paso, se intercambia con cada elemento que queda a su de- recha hasta que obtiene su posición en el extremo derecho del array. Después, en el segundo paso, se colocará en su posición definitiva el elemento con el se- gundo mayor valor, etc. Así, la ordenación de burbuja funciona como un tipo de ordenación por selección pero se debe trabajar mucho más para colocar a cada elemento en su posición definitiva. Características del rendimiento de las ordenaciones elementales Las Figuras 8.3, 8.4 y 8.5 proporcionan ilustraciones directas de las caracterís- ticas operativas de las ordenaciones por selección, inserción y de burbuja. Estos diagramas muestran el contenido del array a para cada uno de los algoritmos después de que el bucle exterior se ha repetido N/4, N / 2 y 3N/4 veces (comen- zando con una entrada formada por una permutación aleatona de los números enterosdel 1 al N). En los diagramas se coloca un cuadrado en la posición (i ,j) cuando a[i ]=j. Así, un array desordenado está representado por conjunto de cuadrados colocados aleatoriamente, mientras que en un array ordenado cada cuadrado aparecerá encima de aquel que está a su izquierda. Para mayor clari- dad en los diagramas, se representan las perrnutaciones (reordenaciones de los enteros del 1 al N), las que, ai ordenarse, tienen todos los cuadrados alineados a lo largo de la diagonal principal. Los diagramas muestran cómo los diferentes métodos van avanzandohacia este objetivo. La Figura 8.3 muestra cómo la ordenación por selección se mueve de iz- quierda a derecha, colocando los elementos en su posición definitiva sin tener que volver atrás. Lo que no es evidente a partir de este diagrama es el hecho de que la ordenación por selección emplea la mayoría de su tiempo en intentar en- contrar el elemento mínimo en la parte «desordenada»del array.
  • 133. MÉTODOSDE ORDENACIÓN ELEMENTALES 113 . = . . . .. .. . . . . . . .. . - . = . . . . . . . . . . . . - - . . .. I . /- . - .:====== ... .. .=I . I. - ..q .. Figura 8.3 Ordenación por selección de una permutaciónaleatoria. La Figura 8.4 muestra cómo la ordenación por inserción también se mueve de izquierda a derecha, insertando en su posición relativa los elementos que va encontrando, sin buscar más lejos. La parte izquierda del array está cambiando continuamente. La Figura 8.5 muestra la similitud entre las ordenaciones por selección y de burbuja. Esta última «selecciona»el elemento máximo que queda en cada etapa, pero pierde algún tiempo poniendo orden en la parte «desordenada» del array. Todos los métodos son cuadráticos, tanto en el peor caso como en el caso medio, y no necesitan memoria extra. Así, las comparaciones entre ellos depen- den de la longitud de los bucles internos o de las característicasespecialesde los datos de entrada. Propiedad 8.1 comparacionesy N intercambios. La ordenación por selección utiliza aproximadamente N2/2 Esta propiedad es fácil de ver examinando la Figura 8.1, que es una tabla de dimensión N*N en la que a cada comparación le corresponde una letra. Pero esto representa aproximadamente la mitad de los elementos, precisamente los que están por encima de la diagonal. Cada uno de los N - 1 elementos que es- . .. . = . . - . . .-. ... = . . . = - . . . . . . . . . . . . . 9 . . ... . .. . = . . . . . i m : . I . . 1 . . . . ! Figura 8.4 Ordenación por inserciónde una permutación aleatoria.
  • 134. 114 ALGORITMOS EN C++ . = 1.. .Im .. Figura 8.5 Ordenación de burbujade una permutaciónaleatoria. tán en la diagonal (sin contar el último) corresponde a un intercambio. Con más precisión: para cada i desde 1 hasta N - 1, hay un intercambio y N - i compa- raciones, de forma que en total hay N - 1 intercambiosy (N- I) + (N- 2) + ... + 2 + 1 = N(N - 1)/2 comparaciones. Estas observaciones son verdaderas para cualquier conjunto de datos de entrada; la única parte de la ordenación por se- lección que depende de dichos datos es el número de veces que se actualiza m in. En el peor caso, esta cantidad podría ser también cuadrática, pero en el caso medio solamente pertenece a O(MogN), por lo que se puede afirmar que el tiempo de ejecución de una ordenación por selección es bastante insensible a los datos de entrada.i Propiedad 8.2 La ordenación por inserción utiliza aproximadamente N2/4 comparacionesy N2/8 intercambios en el caso medio y dos veces más en elpeor caso. En la implementaciónanterior, el número de comparaciones y de «medios-in- tercambios))(desplazamientos)es el mismo. Como se acaba de exponer, esto se puede ver fácilmente en la Figura 8.2, que representa el diagrama de N*N con los detalles de las operaciones del algoritmo. Aquí se cuentan los elementos que quedan por debajo de la diagonal, todos ellos en el peor caso. Para una entrada aleatoria, es de esperar que cada elemento tenga que recorrer hacia atrás apro- ximadamentela mitad de las posiciones, por término medio, por lo que debe- rían contarse la mitad de los elementos que están por debajo de la diagonal. (No es dificil hacer que estos argumentossean algo más rigurosos.)i Propiedad 8.3 Tanto en el caso medio como en el peor caso, la ordenaciónde burbuja utiliza aproximadamente N2/2comparacionesy N2/2 intercambios. En el peor caso (archivo en orden inverso), está claro que en el i-ésimo paso de la ordenación de burbuja se necesitan N-i comparaciones e intercambios, de forma que la demostración es como la de la ordenación por selección. Pero el tiempo de ejecución de la ordenación de burbuja depende de cómo estén orde-
  • 135. MÉTODOSDE ORDENACIÓNELEMENTALES 115 nados los datos de entrada. Por ejemplo, se observa que si el archivo ya está ordenado sólo se necesita un paso (la ordenación por inserción también es rá- pida en este caso). En cambio, el rendimiento en el caso medio no es significa- tivamente mejor que en el peor caso, pero en estas condiciones este análisis es bastante más difici1.i Propiedad 8.4 La ordenaciónpor inserción es lineal para los archivos «casi or- denados)). Aunque el concepto de archivo «casi ordenado))es necesariamente bastante im- preciso, la ordenación por inserción funciona bien con algunos tipos de archi- vos no aleatorios que aparecen con frecuencia en la práctica. Normalmente se abusa de las ordenaciones de aplicación general al utilizarlas en estas situacio- nes; en realidad, la ordenación por inserción puede aprovechar el orden que presente el archivo. Por ejemplo, considérese la operación de ordenar por inserción un archivo que ya está ordenado. De formainmediata se determina que cada elemento está en el lugar que le corresponde en el archivo y el tiempo de ejecución total es lineal. Lo mismo ocurre con la ordenación de burbuja, pero la ordenación por seleccióntodavía es cuadrática. Incluso si el archivo no está completamenteor- denado, la ordenación por inserción puede resultar bastante ú t i iporque el tiempo de ejecución tiene una dependencia bastante fuerte del orden que tenga ya el archivo. El tiempo de ejecución depende del número de inversiones:para cada elemento se cuenta el número de elementos superiores a él, de los que quedan a su izquierda. Ésta es precisamente la distancia que tienen que recorrer los ele- mentos cuando se insertan en el archivo durante la ordenación por inserción. Un archivo que esté algo ordenadotendrá menos inversiones que otro que esté arbitrariamente desordenado. Si se desea añadir algunos elementos a un archivo ordenadopara obtener un archivo ordenadomás grande, una forma de hacerlo consiste en añadir los nue- vos elementos al final del archivo y a continuación llamar a un algoritmo de ordenación. Claro está, el número de inversionesserá bajo: un archivo que tiene únicamenteun número constante de elementos sin ordenar tendrá un número lineal de inversiones. Otro ejemplo es el de un archivo en el que cada elemento está solamente a una cierta distancia constante de su posición definitiva. Tales archivos aparecen a veces en las etapas iniciales de algunos métodos de orde- nación avanzados: en un determinado momento merece la pena cambiar a la ordenación por inserción. Para estos archivos, la ordenación por inserción superará incluso a los mé- todos sofisticados que se verán en los siguientes capítu1os.i Para comparar los métodos con más profundidad, se necesita analizar el coste de las comparaciones y de los intercambios,factores que dependen tanto del ta- maño de los registros como de las claves. Por ejemplo, si los registros tienen cla- ves de una palabra, como en las implementaciones anteriores, entonces un in- tercambio (dos accesos al array) cuesta aproximadamente el doble que una
  • 136. 116 ALGORITMOS EN C++ comparación. En estas circunstancias, el tiempo de ejecución de una ordena- ción por selección y el de una por inserción son prácticamente iguales, pero la ordenación de burbuja es dos veces más lenta. (De hecho, ila ordenación de burbuja es dos veces más lenta que la de inserción, casi bajo cualquier circuns- tancia!) Pero si los registros son grandes en comparación con las claves, enton- ces será mejor la ordenación por selección. Propiedad8.5 La ordenaciónpor selección es linealpara archivos con registros grandes y clavespequeñas. Supóngase que los costes de una comparación y de un intercambio son de 1 y de M unidades de tiempo, respectivamente (éstepuede ser el caso, por ejemplo, de registros de M palabras y claves de una), entonces, la ordenación por selec- ción de un archivo de tamaño NM lleva aproximadamente Nz unidades de tiempo para las comparaciones y alrededor de NM para los intercambios. Si N = O(M),esto es un tiempo lineal en el tamaño de los dat0s.i Ordenación de archivos con grandes registros En realidad, es posible (y deseable)arreglarlas cosas para que cualquier método de ordenación utilice sólo N «intercambios» de registros completos, haciendo que el algoritmo opere indirectamente sobre el archivo (utilizando un array de índices) y reordenándolo después. Específicamente, si el array a [11y ...y a [ N I contiene registros grandes, es preferible manipular un «array de índices))p [13 y ...y p [NI que accede al array original sólo para las comparaciones. Si inicialmente se define p [i3=i ,sólo se necesitará modificar los algoritmos anteriores (y todos los de los capítulos si- guientes) para que hagan referencia a a [p[i] ] en lugar de a a [i] al utilizar a [i] en una comparación, y para hacer referencia a p en lugar de a a cuando se hagan desplazamientos de datos. Esto produce un algoritmo que «ordenará» el array de índices de manera que p [11 es el índice del elemento más pequeño de a, p [21 es el índice del segundo elemento más pequeño de a, etc., y así se evitará el coste de mover excesivamente grandes registros. El programa si- guiente muestra cómo puede modificarse la ordenación por inserción para que funcione de esta manera. void insercion(tipoE1ernento a[] y i n t p[] y i n t N) i n t i, j ; tipoElernento v; for ( i = O; i <= N; i++) p [ i ] = i ; for ( i = 2; i <= N; i++) {
  • 137. MÉTODOSDE ORDENACIÓN ELEMENTALES 117 v = p [ i ] ; j = i; while (a[p[j-i]] > a[v] ) ~ [ j l = v; { ~ [ j l = P Ij-11; j--; } En este programa se accede al array a solamente para comparar las claves de dos registros. Así, podría modificarse fácilmente para tratar archivos con regis- tros muy grandes cambiando la comparación para que acceda sólo a un campo pequeño de un gran registro, o haciendo el proceso de comparación algo más complicado. La Figura 8.6 muestra cómo este procedimiento produce una per- mutación que especifica el orden en que podría accederse a los elementos del array para definir una lista ordenada. Para muchas aplicaciones, esto será sufi- ciente (no se necesitará desplazartotalmente los datos). Por ejemplo, se podrían imprimir los datos ordenadoshaciendo referenciaa cada uno por medio del array de índices, como en la propia ordenación. Antes de la ordenación k 1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 1 4 1 5 p[k] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 a[kI ljl rn rn 0 [o] L i J rn Ki Después de la ordenación k 1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 1 4 1 5 a[k] m M m ~ p[k] 8 14 11 1 3 12 2 6 4 13 7 9 5 10 15 Después de la permutación p[k] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Figura 8.6 Reorganizaciónde un array (ordenado)). Pero ¿qué ocurre si realmente se deben reorganizar los datos, como es el caso de la parte de abajo de la Figura 8.6? Si se dispone de suficiente memoria extra para hacer otra copia del array, esto es trivial, pero ¿qué hacer en la situación más normal, cuando no se dispone de espacio suficiente para hacer otra copia del archivo? En el ejemplo, lo primero que se debería hacer es poner el registro que tenga la clave más pequeña (el que corresponde al índice p[1]) en la primera posición en el archivo. Pero, antes de hacerlo, se necesita guardar el registro que está en esa posición, por ejemplo en t.Ahora, después del movimiento, se puede con- siderar que hay un «hueco» en el archivo en la posición p [1], pero se sabe que el registro de la posición p [p [11] llenará finalmente este hueco. Continuando
  • 138. 118 ALGORITMOS EN C++ de esta manera, liegará el momento en que llene el hueco el elemento original de la primera posición, que se había almacenado en t. En el caso del ejemplo este proceso conduce a la serie de asignaciones t=a[ 11 ; a[ 1]=a[8] ; a[83=a[6}; a[6]=a[12]; a[12]=a[9]; a[9]=a[4]; a[4]=a[l]; a[i]=t;. Estas asignaciones colocan a los registros con claves A, E, E, L, M y O en su lugar adecuado dentro del archivo, lo que puede indicarse poniendo p[11=1, p[8]=8, p[6]=6, p[12]=12, p[9]=9 y p[4]=4. (Cualquier elemento con p [i ]=i está en su lugar definitivo y no es necesario volver a tocarlo.)Ahora, se puede reanudar el proceso para el siguiente elementoque no esté en su lugar, y así sucesivamente, hasta que por último se reorganice todo el archivo, mo- viendo cada registro sólo una vez, como se muestra en el siguiente código: void insitu(tipoE1emento a[], int p[], int N) I int i, j, k; tipoElemento t; for (i = 1; i <= N; i++) if (p[i] != i) t = a[i]; k = i; do { j = k; a[jl = a[p[jll; k = ~ [ j l ;p[jl = j; { } while (k != i); a[j] = t; 1 1 Por supuesto, la viabilidad de esta técnica para aplicaciones particulares de- pende del tamaño relativo de los registros y de las claves del archivo a ordenar. Por cierto, no se debería utilizar con un archivo de registros pequeños, porque se necesita mucho espacio extra para el array de índices y mucho tiempo extra para las comparaciones indirectas. Pero para archivos que contienen registros grandes, casi siempre es preferible utilizar una ordenación indirecta y, en mu- chas aplicaciones,no es necesario desplazar todos los datos. Por supuesto, como se vio anteriormente, para archivos que tienen registros muy grandes el método a utilizar es la ordenación por selección. La técnica anterior de «array de índices)) funcionará en cualquier lenguaje de programación que cuente con arrays. En C++ suele ser conveniente desarro- llar una implementaciónbasada en el mismo principio, utilizando las direccio- nes de máquina de los elementos del array (los «auténticos punteros)) que se presentaron brevemente en el Capítulo 3). Por ejemplo, el código siguiente im- plementa una ordenación por inserción utilizando un array p de punteros:
  • 139. MÉTODOSDE ORDENACIÓNELEMENTALES 119 ~~ ~~ ~ ~~ void insercion(tipoE1emento a[] , tipoElemento *p[], int N) i int i,j; tipoElemento *v; for (i= O; i <= N; i++) p[i] = &a[i]; for (i= 2; i <= N; i++) v = p[i]; j = i; while (*p[j-11 > *v) { { ~ [ j i = pb-11; j--; } ~[jl = v; 1 } Una de las característicasfundamentales que C++ha heredado de C es la fuerte relación existente entre punteros y arrays. En general,los programas implemen- tados con punteros son más eficaces,pero más dificilesde entender (aunquepara alguna aplicación concreta no hay mucha diferencia). El lector que esté intere- sado puede implementarel programa insitu necesario para la ordenación por punteros anterior. En las implementaciones de este libro normalmente se accederá de manera directa a los datos, aun sabiendo que se podrían utilizar punteros o arrays de índices para evitar realizar un número excesivo de desplazamientos de datos al hacer las ordenaciones. Debido a la existencia de esta ordenación indirecta, las conclusiones que se muestran en este capítulo y en los que siguen, cuando se comparan los métodos para ordenar archivos de enteros, se pueden aplicar a si- tuaciones más generales. Ordenación de Shell La ordenación por inserción es lenta pcrque únicamente se realizan intercam- bios entre elementos adyacentes. Por ejemplo, si el elementomás pequeño está al final del array, se necesitan N pasos para situarlo en su lugar correspondiente. La ordenación de Shell (Shellsort) es una simple generalización de la ordena- ción por inserción en la que se gana rapidez al permitir el intercambio entre elementos que están muy alejados. La idea es reorganizar el archivo para que tenga la propiedad de que, to- mando todos los elementos h-ésimos (comenzandopor cualquier sitio), se ob- tenga un archivo ordenado, Tal archivo se dice que está h-ordenado. Diciéndolo de otra forma, un archivo h-ordenado está constituidopor h archivosordenados independientes, entrelazados entre sí. H-ordenando el archivo para algunos va- lores grandes de h se pueden intercambiar elementos muy distantes en el array,
  • 140. 120 ALGORITMOS EN C++ El ~ Figura 8.7 Ordenación de Shell. y así se facilita la h-ordenación para pequeños valores de h. Utilizando este pro- cedimiento para cualquier serie decreciente de valores de h que termine en 1 se genera un archivo ordenado: éste es el principio de la ordenación de Shell. La Figura 8.7 muestra la operación de la ordenación de Shell para el archivo ejemplo con los incrementos decrecientes ..., 13, 4, 1. En el primer paso, la E está en la posición 1, y se compara (y se intercambia) con la A de la posición 14,y después la J de la posición 2 se compara con la R de la posición 15. En el segundo paso se reordenan las letras A P O N de las posiciones 1, 5, 9 y 13po- niendo en esas posiciones A N O P, después las letras J L R E de las posiciones 2, 6, 10 y 14, poniendo en ellas E J L R, y así sucesivamente. El último paso es precisamente la ordenación por inserción; pero ningún elemento tiene que des- plazarse muy lejos. Una forma de implementar la ordenación de Shell podría ser utilizar, para cada h, la ordenación por inserción de forma independiente en cada uno de los h subarchivos. (No deberían utilizarse centinelas porque se necesitaría un gran número de ellos para los mayores valoresde h.) Pero, en cambio, se puede hacer
  • 141. MÉTODOSDE ORDENACIÓNELEMENTALES 121 más fácil todavía: Si se reemplaza cada aparición de (c 1)) por ((h» (y de ((2)) por «h+l») en la ordenación por inserción, el programa resultante h-ordena el archivo y se obtiene una implementación más compacta de la ordenación de Shell, como se indica a continuación: void ordensheil (tipoElemento a[] ,int N) int i, j, h; tipoElemento v; for (h = 1; h <= N/9; h = 3*h+l) ; { h /= 3) <= N; i += 1) for ( ; h > O; for (i= h+l; v = a[i] while (j { { aiji j = i; > h && a[j-h]>v) - a[j-h]; j -= h; } - a[j] = v; } 1 Este programa utiliza la sene de incrementos decrecientes ..., 1093, 364, 121, 40, 13, 4, 1. En la práctica, otra sene podría resultar tan buena como ésta pero, como se indica a continuación, hay que tener un poco de cuidado al elegirla.La Figura 8.8 muestra cómo actúa este programa ante una permutación aleatoria, mostrando el contenido del array a después de cada h-ordenación. La sucesión de incrementosdecrecientesde este programa es fácil de utilizar y conduce a una ordenación eficaz. Hay otras muchas sucesiones que conducen a ordenaciones mejores (el lector puede entretenerse intentando descubrir una), pero es difícil mejorar el programa anterior en más de un 20%, incluso para N relativamente grande. (Sin embargo, la posibilidad de que existan sucesiones mucho mejores sigue siendo bastante real.) Por el contrario, existen algunas su- cesiones más desfavorables:por ejemplo, ...,64, 32, 16, 8, 4, 2, 1 conduce a un mal rendimiento porque los elementos que están en las posiciones impares no se comparan con los elementos que están en las posiciones pares hasta el final. De forma similar, algunas veces la ordenación de Shell se implementa comen- zando por h=N (en lugar de inicializarse de manera que siempre se utilice la misma sucesión de antes). Virtualmente, esto asegura que surgirá una sucesión desfavorable para algún N. La anterior descripción de la eficacia de la ordenación de Shell es imprecisa por necesidad, porque nadie ha sido capaz de analizar el algoritmo. Esto hace que no sólo sea difícil evaluar las diferentes seriesde incrementos, sino también comparar anaiíticamentela ordenación de Shell con otros métodos. Ni siquiera se conocela forma funcionaldel tiempo de ejecuciónpara esta ordenación (como mucho se sabe que depende de la sucesión de incrementos). Para el programa anterior podrían darse dos conjeturas: N(10gh')~y El tiempo de ejecución
  • 142. 122 ALGORITMOS EN C+t I I Figura 8.8. Ordenación de Shell para una permutación aleatoria. no es particularmente sensible al orden inicial del archivo, en especial en con- traste con, por ejemplo, la ordenación por inserción, en la que el tiempo de eje- cución es lineal para un archivo ya ordenado y cuadrático para un archivo en orden inverso. La Figura 8.9 muestra las operaciones de la ordenación de Shell en un archivo de este tipo. Propiedad 8.6 La ordenación de Shell nunca hace más de N3/2comparaciones (para los incrementos I , 4, 13, 40, 121,... ). La demostración de esta propiedad está más allá del aícance de este libro; pero, además de apreciar su dificultad, el lector también puede convencersede que la ordenación de Shell se comporta bien en la práctica intentando construir un archivo en el que la ordenación de Shell se ejecute lentamente. Como se men- cionó antes, existen algunas sucesiones de incrementos desfavorables para las que la ordenación de Shell puede necesitar un número cuadrático de compara- ciones, pero se ha demostrado que la cota N3/2es válida para una amplia vane- dad de sucesiones,como la que se ha utilizado con anterioridad. Incluso se co- nocen mejores cotas para el peor caso de algunas sucesionesespecia1es.i La Figura 8.10 muestra una visión diferente de las operaciones que realiza la ordenación de Shell, comparable a la de las Figuras 8.3, 8.4 y 8.5. Esta figura presenta el contenido del array después de cada h-ordenación (excepto la ú1- tima, que es la que completa la ordenación). En estos diagramas podría imagi-
  • 143. MÉTODOSDE ORDENACIÓNELEMENTALES 123 Figura 8.9. Ordenación de Shell para una perrnutaciónen orden inverso. narse una goma elástica fija en las esquinas inferior izquierda y superior dere- cha, que se estira y ajusta para llevar todos los puntos hacia la diagonal. Cada uno de los tres diagramas de las Figuras 8.3, 8.4 y 8.5 representa el hecho de que cada algoritmo que se muestra debe realizar una cantidad de trabajo signi- ficativa;por el contrario, cada uno de los diagramas de la Figura 8.10 representa sólo un paso de h-ordenación. La ordenación de Shell es el método elegido en muchas aplicaciones,ya que su tiempo de ejecución es aceptable, incluso para archivos moderadamente grandes (por ejemplo, con menos de 5.000 elementos) y únicamente se necesita Figura 8.10. Ordenaciónde Shell de una permutaciónaleatoria.
  • 144. 124 ALGORITMOS EN C++ un pequeño código, fácil de ejecutar. En los siguientes capítulos se verán mé- todos que son más eficaces, pero que sólo son el doble de rápidos (cuando mu- cho), excepto para grandes valores de N, y son bastante más complicados. En resumen, si se tiene un problema de ordenación, lo mejor es utilizar elpro- grama anterior, y determinar después si vale la pena el esfuerzo extra que se necesita para cambiarlo por un método más sofisticado. Cuenta de distribuciones Hay una situación muy especial para la que existe un sencillo algoritmo de or- denación: «ordenar un archivo de N registros cuyas claves son distintos núme- ros enteros entre 1 y N.» Este problema puede resolverse utilizando un array temporal b con la sentencia for (i = 1; i <= N; i++) b[a[i]] = a[i]. (O, como se vio anteriormente, es posible, aunque bastante difícil, resolver este pro- blema sin un array auxiliar.) Un problema más realista, pero con el mismo espíritu, consiste en «ordenar un archivo de N registros cuyas claves son números enteros entre O y M- ID. Si M no es demasiado grande, se puede utilizar para resolver este problema un al- goritma denominado cuenta de distribuciones. La idea es contar el número de clavesde cada valor y después utilizar los números que se han contado para des- plazar los registros hacia su posición durante un segundo recomdo a través del archivo, como se indica en el código siguiente: for (j = O; j <M; j++) contador[j] = O; for (i = 1; i <= N; i++) contador[a[i]]++; for (j = 1; j <M; j++) contador[j] += contador[j-11; for (i = N; i >= 1; i--) b[contador[a[i]]--1 = a[i]; for (i = 1; i <= N; it+) a[i] = b[i]; Para ver cómo funciona este código, se considera el archivo de ejemplo for- mado por los números enteros de la fila superior de la Figura 8.11. El primer bucle for inicializa el contador a O; el segundo pone contador[l]=6, conta- dor[2]=4, contador[3]=1, y contador[4]=3 ya que hay seis letras A, cuatro B, etc. A continuación el tercer bucle for acumula estos números y se obtiene contador[l]=6, contador[2]=10, contador[3]=ll, y contador[4]=15. Esto es, hay seis claves inferiores o iguales que A, diez claves menores o iguales que B, etcétera. Ahora, los contadores pueden servir de índices para ordenar el array, como se muestra en la figura. El array original a se muestra en la línea superior; el resto de la figura muestra el array temporal que se está rellenando. Por ejemplo, cuando se encuentre la A al final del archivo, se colocará en la posición 6, ya que contador[11 indica que hay seis claves menores o iguales que A. Después
  • 145. MÉTODOSDE ORDENACION ELEMENTALES 125 Figura 8.11. Cuentade distribuciones.
  • 146. 126 ALGORITMOS EN C++ contador [11 se reduce en una unidad, de manera que ahora hay una clave me- nos entre las menores o iguales que A. A continuación, la D de la penúltima posición del archivo se coloca en la posición 14 y contador[4] se reduce en una unidad, etc. El bucle interno se realiza desde N hasta 1, de modo que la ordenación será estable. (El lector puede intentar comprobar esta afirmación.) Este método funcionará muy bien para los tipos de archivos descritos ante- riormente. Además, se puede utilizar para obtener métodos mucho más poten- tes que se examinarán en el Capítulo 10. Ejercicios 1. Dar una sucesión de operaciones comparar-intercambiam para ordenar cuatro registros. 2. ¿Cuál de los tres métodos elementales (ordenación por selección,por inser- ción, de burbuja) es más rápido en un archivo que ya está ordenado? 3. ¿Cuál de los tres métodos elementales es más rápido para un archivo que está en orden inverso? 4. Comprobar la hipótesis de que la ordenación por selecciónes el más rápido de los métodos elementales (para ordenar números enteros), seguida por la inserción y después por la ordenación de burbuja. 5. Dar una buena razón de por qué puede no ser convenienteutilizar una clave centinela en la ordenacicn por inserción (aparte de la que se dio en la im- plementación de !a ordenaciófi de Shell). 6. ¿Cuántas comparaciones utilizará la ordenación de Shell para hacer uaa 7- ordenación, y después una 3-ordenación de las claves C U E S T I O N F A C I L ? 7. Dar un ejemplo para mostrar por qué 8, 4,2, I no sería una buena forma de finalizar una sucesiónde incrementospara hacer una ordenaciónde Shell. 8. ¿Esestablela ordenación por selección?¿Yla ordenación por inserción?¿Y la ordenación de burbuja? 9. Dar una versión especializada de la cuenta de distribuciones para ordenar archivos donde los elementos sólo pueden tomar dos valores (o bien X o bien y). 10. Experimentar con diferentes sucesionesde incrementos para la ordenación de Shell: encontrar una que sea más rápida que la que se dio para un archivo aleatorio de 1.O00 elementos.
  • 147. 9 Quicksort En este capítulo se estudiará el algoritmo de ordenación que es probablemente el más utilizado de todos: la ordenación rápida (Quicksorl).El algoritmo básico fue inventado en 1960 por C.A.R. Hoare, y desde entonces ha sido objeto de numerosos estudios. El Quicksort es popular porque no es difícil de implemen- tar, proporciona unos buenos resultados generales (funciona bien en una am- plia diversidad de situaciones)y en muchos casos consume menos recursos que cualquier otro método de ordenación. Entre las ventajas del algoritmo de ordenación rápida destacan: trabaja in situ (utiliza sólo una pequeña pila auxiliar), necesita solamente del orden de MogN operaciones en promedio para ordenar N elementos y tiene un bucle in- terno extremadamente corto. Los inconvenientes son que es recursivo (si no se puede utilizar la recursión la implementación es complicada), que en el peor caso necesita aproximadamente N2operacionesy que es frágil: si durante la im- plementación pasa inadvertido un simple error, puede causar un mal compor- tamiento en ciertos archivos. El rendimiento del Quicksort se entiende muy bien. Ha sido objeto de mi- nuciosos análisis matemáticos y se puede describir con precisión. El análisis ha sido comprobado por una extensa experiencia empírica y el algoritmo se ha re- finado hasta el punto de convertirse en el método elegido en una gran variedad de aplicaciones prácticas de ordenación. Esto hace que merezca la pena estu- diarlo con más cuidridoque otros algoritmos, con el fin de implementar de ma- nera eficaz el Quicksort. Existen técnicas de implementación similares que son apropiadas para otros algoritmos y que pueden utilizarse con la ordenación rá- pida porque así se comprende mejor su rendimiento. Es muy tentador tratar de desarrollar formas de mejorar el Quicksort: en- contrar un algoritmo de ordenación más rápido es una ds las utopías de la in- formática. Casi desde el momento en que Hoare hizo público el algoritmo han ido apareciendo en los libros versiones «mejoradas» del mismo. Se han inten- tado y analizado muchas ideas, pero es fácil decepcionarse porque este algo- ritmo está tan bien equilibrado que los efectos de las mejoras en una parte del 127
  • 148. 128 ALGORITMOS EN C++ programa pueden estar más que compensadospor las consecuencias de un mal rendimiento en otra. En este capítulo se examinarán con algún detalle tres mo- dificacionesque mejoran sustancialmenteel Quicksort. Una versión afinada con cuidado del Quicksort es probable que se ejecute más rápidamente en la mayoría de las computadoras que cualquier otro mé- todo de ordenación. De cualquier forma, debe tenerse en cuenta que la opti- mización de cualquier algoritmo puede hacerlo más frágil, conduciendoa efec- tos indeseables e inesperados para ciertos datos de entrada. Una vez que se ha desarrollado una versión que parezca estar libre de tales efectos, es probable- mente ésta la que se debería tener como una de las utilidades de ordenación de una biblioteca o en una aplicación de ordenación seria. Pero si no se está dis- puesto a realizar un esfuerzo adicional para poner a punto una implementación del Quicksort que resulte correcta, la ordenación de Shell podría resultar una elección segura que funcionará bastante bien con un menor esfuerzo de imple- mentación. El algoritmo básico El Quicksort es un método de ordenación de «divide y vencerás». Funciona di- vidiendo un archivo en dos partes, y ordenando independientementecada una de ellas. Como se verá, el punto exacto de la partición depende del archivo, y así el algoritmo presenta la siguiente estructura recursiva: void ordenrapido(tipoE1emento a[], int izq, int der) i int i; if (r > izq) I I i = particion (a, izq, der); ordenrapido(a, izq, i-1); ordenrapido (a, i+l , der) ; 1 1 Los parámetros izq y der delimitan el subarchivo del archivo original que se va a ordenar; la llamada a ordenrapido (a, 1, N) ordena el archivo en su to- talidad. Lo esencial del método es el procedimiento parti cion,que debe reordenar el array para que se verifiquen las siguientescondiciones: (i) el elemento a[i ] está en su lugar definitivo en el array para algún i, (ii) ninguno de los elementos de a[izq],..., a [i - 11, son mayores que aril,
  • 149. QUICKSORT 129 (iii) ninguno de los elementos de a [ i + l ] ,.., a[der] son menores que Esto se puede implementar de una manera muy fácil y sencilla mediante la siguiente estrategia general. En primer lugar, elegir a [der] de manera arbitraria como el elemento que irá en su posición definitiva. Después, explorar el array de izquierda a derecha hasta encontrar un elemento mayor que a [der], y vol- verlo a explorar de derecha a izquierda hasta encontrar un elemento menor que a[der]. Los dos elementos en los que se detiene el proceso están obviamente mal situados en el array dividido resultante y por tanto se intercambian. (En realidad, por razones que se darán posteriormente, es mejor detener también las exploracionesen los elementos iguales a a [der],aunque parezca que al hacerlo así se van a realizar algunos intercambios innecesarios). Continuando de esta forma se tiene la seguridad de que todos los elementos del array situados a la izquierda del puntero izquierdo son menores que a [der] y de que todos los si- tuados a la derecha del puntero derecho son mayores que a[der]. Cuando los punteros de exploración se cruzan, el proceso de partición está casi acabado: todo lo que queda por hacer es intercambiar a [der] con el elemento que está más a la izquierda del subarchivo derecho (el elemento apuntado por el puntero iz- quierdo). La Figura 9.1 muestra cómo se divide con este método el archivo ejemplo de claves. Se elige como elemento de partición al elemento que está más a la derecha, R. Al principio la exploración desde la izquierda se para en la R y a continuación la exploración desde la derecha se para en la A (como se muestra en la segunda línea de la tabla) y entonces se intercambian estas dos letras. A continuación, como los punteros se cruzan, la exploración desde la izquierda se para en la R, mientras que desde la derecha se detiene en la N. El movimiento apropiado en este caso consiste en intercambiar la R de la derecha con la otra R, dejando el archivo dividido tal y como se muestra en la última línea de la Figura 9.1. a[i]. Figura 9.1 Operación de partición.
  • 150. 130 ALGORiTMOS EN C++ Figura 9.2 Particiónde un archivo mayor. El proceso de partición no es estable puesto que durante cualquier intercam- bio toda clave podna desplazarse detrás de un gran número de claves iguales a ella (que aún no se han examinado). La Figura 9.2 muestra el resultado de dividir un archivo más grande: con los elementos pequeños a la izquierda y los grandes a la derecha, el archivo divi- dido presenta considerablemente más «orden» que el archivo aleatorio. La or- denaciónse termina ordenando los dos subarchivosque quedan a cada lado del elemento de partición (recursivamente). El siguienteprograma proporciona una implementacióncompleta del método. void ordenrapido(tipoE1emento a[], int izq, int der) int i,j ; tipoE7emento v; if (der > izq) { v = a[der]; i = izq-1; j = der; { for (;;I while (a[++i] < v) ; while (a[--j] > v) ; if (i >= j) break; intercambio(a, i,j ) ; { } intercambio(a, i,der); ordenrapido(a, izq, i-1); ordenrapido (a, i+l, der) ; } 1 En esta implementación, la variable v contiene el valor actual del ((elementode partición» a[der], con i y j como los punteros izquierdo y derecho respecti- vamente. El bucle de la partición se implementa como un bucle infinito, con
  • 151. QUICKSORT 131 un break de salida cuando se cruzan los punteros. Este método es realmente la demostración típica de por qué se utiliza la capacidad break: el lector podría entretenerse considerando cómo se puede implementar el método de partición sin utilizar break. Como en la ordenación por inserción, se necesita una (clave centinela) para detener la exploración cuando el elemento de partición sea el más pequeño del archivo. En esta implementación no se necesita ningún centinela para detener la exploración cuando el elemento de partición sea el más grande del archivo, porque es él mismo quien la detiene en el lado derecho del archivo. Pronto se verá una forma sencilla de eliminar ambas claves centinela. El ((bucleinterno)) de la ordenación rápida implica simplemente incremen- tar un puntero y comparar un elemento del array con un valor fijo. Esto es lo que realmente hace rápido a este método de ordenación: es difícil imaginar un bucle interno más sencillo. También aquí se encuentra una prueba del efecto beneficioso de las claves centinela, puesto que añadir una comprobación super- flua al bucle interno ticne un gran efecto en el rendimiento. La ordenación termina ordenando recursivamente los dos subarchivos. La Figura 9.3 muestra estasllamadas recursivas.Cada línea representa el resultado de dividir el subarchivo, así como el elemento de partición elegido (sombreado en el diagrama). Si la primera comprobación del programa fuera der >= i zq en lugar de der > izq, cada elemento acabana por utilizarse como elemento de partición para poderse colocar en su posición final; en la implementación dada, los archivos de tamaño 1 no se dividen, como se puede ver en la Figura 9.3. Más adelante se estudiará una generalizaciónde esta mejora. La característica más negativa del programa anterior es que para archivos sencilloses muy ineficaz. Por ejemplo, si se le llama para un archivo que ya está ordenado, las particionessenan degeneradasy el programa se llamm’a a sí mismo N veces, quitando sólo un elemento en cada llamada. Esto significa no sólo que el tiempo requerido será del orden de N2/2,sino que además la memoria nece- saria para solventar la recursión será del orden de N (como se verá más ade- lante), lo cual es inaceptable. Por fortuna hay formas relativamente sencillas de evitar que este caso tan negativo pueda ocumr en las implementaciones reales del programa. Cuando hay clavesigualesen el archivo,hay dos detallesaparentemente poco importantes, pero que en realidad sí lo son. En primer lugar se plantea la pre- gunta de si ambos punteros se han de detener en las claves iguales al elemento de partición o si uno debe parar y el otro continuar la exploración o si ambos deben continuar. Esta cuestión se ha estudiado matemáticamente en detalle y los resultados muestran que es mejor que se detengan los dos punteros. Esto tiende a equilibrar las particiones cuando hay muchas claves iguales. Segundo, se plantea la cuestión de solucionar adecuadamente el cruce de punteros cuando hay clavesiguales. De hecho, el programa anterior puede mejorarse ligeramente terminando la exploración cuando j < i y utilizar ordenrapido (a, izq, j) para la primera llamada recursiva. Esto es una mejora porque cuando j=ise pueden poner dos elementos en su posición dejando que el bucle se ejecute una
  • 152. 132 ALGORITMOS EN C++ Figura 9 . 3 Subarchivosen el Quicksort. vez más. (Este caso ocumría, por ejemplo, si R fuera E en el ejemplo anterior.) Probablemente merezca la pena hacer este cambio porque el programa, tal y como se ha dado, deja un registro con una clave igual a la clave de partición en a [der], y esto hace una primera partición degenerada en la llamada ordenra- pi do (a, i+l, der) porque la clave situada más a la derecha es también la más pequeña. La implementacióndel método de partición dado anteriormente es algo más fácil de comprender,por lo que será la que se trate en la siguiente presentación. Sin embargo, hay que ser consciente de que esta modificación de- bería realizarse cuando haya un gran nUmero de claves iguales.
  • 153. QUICKSORT 133 Característicasde rendimiento del Quicksort Lo mejor que podría ocurrir en la ordenación rápida sena que en cada etapa de partición se dividierael archivo exactamentepor la mitad. Esto haría que el nú- mero de comparaciones a utilizar por la ordenación rápida satisficierala recu- rrencia del método divide y vencerás El término 2 c N / 2 cubre el coste de ordenación de los dos subarchivos; el tér- mino N es el coste de examinar cada elemento, utilizando un puntero de parti- ción o el otro. Desde el Capítulo 6 se sabe que esta recurrencia admite la solu- ción Aunque las cosas no siempre van tan bien, lo que sí que es cierto es que los elementos de partición caen en el centro,por término medio. El tener en cuenta la probabilidad exacta de cada posición del elemento de partición complica la relación de recurrencia y la hace más difícil de resolver, pero el resultado final es similar. ' Propiedad 9.1 El Quicksort utiliza del orden de 2NlnN comparacionespor tér- mino medio. La fórmula exacta de recurrencia para el número de comparaciones utilizadas por la ordenación rápida para una permutación aleatoria de N elementos es El término N + 1 cubre el coste de comparar el elemento de partición con cada uno de los otros (más dos extra para el cruce de punteros); el resto viene de la observaciónde que cada elemento k tiene la probabilidad l/k de ser el elemento de partición, tras lo que quedan archivos aleatoriosde tamaño k - 1 y N - k. Aunque parece algo complicada, esta recurrencia es realmente fácil de re- solver en tres pasos. Primero, Co + CI+ ... + CNp1 es lo mismo que C+]+ C N - ~ + ... + Co,por lo que se tiene Segundo,se puede eliminar el sumatorio multiplicando ambos miembros por N y restando la misma fórmula para N - 1:
  • 154. 134 ALGORITMOS EN C++ Esto se simplificaa la recurrencia Tercero, dividiendo ambos miembros por N(N + 1) se obtiene una simplifica- ción en cadena de la recurrencia: CN-2 2 2 2 -- -- - +-+-- +--- 2 C N CN-I N + 1 N N+1 N-1 N N + 1 Esta solución exacta es casi igual a un sumatorio, que se puede aproximar fácil- mente por una integral: lo que conduce ai resultado esperado. Se observa que 2MnN = 1,38 MgN, por lo que el número medio de comparaciones es sólo un 38% más alto que el caso más favorab1e.i Por tanto, la implementación anterior tiene un comportamiento muy bueno para archivos aleatonos, lo que hace que este método de ordenación sea muy adecuado para muchas aplicaciones. Sin embargo, si se va a utilizar la ordena- ción un gran número de veces o si se va a aplicar para ordenar un gran archivo, sena útil implementar algunas de las mejoras descritasposteriormente y que ha- cen menos probable que ocurra un caso negativo, reduciendo el tiempo medio de ejecución en un 20 % y eliminando fácilmente la necesidad de utilizar una clave centinela. Eliminaciónde la secursión Al igual que se hizo en el Capítulo 5, se puede eliminar la recursión del pro- grama del Quicksort utilizando explícitamente una pila donde se imagina que se coloca el «trabajoque queda por hacen) en forma de subarchivos a ordenar. Siempre que se necesite procesar un subarchivo, se sacará de la pila. Al hacer la partición, los dos subarchivos que se crean para procesar se pueden colocar en la pila. Esto conduce a la siguienteimplementación no recursiva: void ordenrapido(tipoE1emento a[], int izq, int der) int i ; Pila<int> sa(50); {
  • 155. QUICKSORT 135 for (;;I { { while (der > izq) i = particion(a, izq, der); i f (i-izq > der-i) el se { sa.meter(i); sa.meter(i-1); izq=i+i; } { sa.meter(i+l); sa.meter(der); der=i-1; } 1 if (sa.vaci a() ) break; der = sa.sacar(); izq = sa.sacar(); Este programa se diferencia del descrito con anterioridad en dos cuestionesfun- damentales. Primero, los dos subarchivos no se colocan en la pila de forma ar- bitraria, sino que previamente se comprueban sus tamaños y se coloca primero en la pila el más grande de los dos. Segundo, el más pequeño de los dos subar- chivos no se coloca en la pila; simplementese inicializan los valores de los pa- rámetros. Ésta es la técnica de «eliminación de la recursión final» tratada en el Capítulo 5. Para el Quicksort, la combinación de la {(eliminaciónde la recur- sión final» y la política de procesar primero el más pequeño de los dos subar- chivos asegura que la pila necesite espacio solamente para unos l o oelementos, porque cada elemento de la pila, diferente de la cabeza, debe representar a un subarchivo de menos de la mitad de tamaño que el elemento que está debajo de él. Esto representa un fuerte contraste con el tamaño de la pila en el peor caso de la implementaciónrecursiva, que podría ser tan grande como N (por ejem- plo, cuando el archivo ya está ordenado). Ésta es una dificultad sutil pero real de la implementaciónrecursiva del Quicksort: siempre hay una pila subyacente, y un caso degenerado en un gran archivo podria causar una terminación anor- mal del programa por falta de memoria, comportamientoobviamente indesea- ble en una rutina de biblioteca de ordenación. Más adelante se verá cómo con- seguir que estos casos degeneradossean muy improbables, pero es difícil eliminar este problema en una representación recursiva sin la técnica «eliminaciónde la recursión final». (Ni siquiera ayuda el invertir el orden en que se procesan los subarchivos.)Por otro lado, algunos compiladores de C++ eliminan automáti- camente la recursión y algunas máquinas ofrecen directamente la recursión en el hardware, de manera que en tales entomos el programa anterior podria ser más lento que la implementaciónrecursiva. La simple utilización de una pila explícita en el programa anterior conduce a programas más segurosy quizásmás eficacesque la implementaciónrecursiva directa. Si los dos subarchivos tienen sólo un elemento, se coloca en la pila un
  • 156. 136 ALGORITMOS EN C++ _____ ~ ~ ~ ~ ~ _ _ _ ~ Figura 9.4 Subarchivosen el Quicksort (no recursivo). subarchivocon der = izq únicamente para ser descartadoinmediatamente. Es fácil cambiar el programa para que no coloque tales archivos en la pila. Este cambio es aún más efectivocuando se incluye la mejora que se describe a con- tinuación, ya que implica ignorar de la misma forma a los subarchivos peque- ños, y así serán mucho mayores las posibilidadesde que ambos subarchivosno se tengan en cuenta. Por supuesto, el método no recursivo procesa los mismos subarchivosque el recursivo, para cualquier archivo;simplementelo hace en orden diferente.La Figura 9.4 muestra las particiones en el caso del ejemplo: las dos primeras par- ticiones son las mismas, pero después el método no recursivo divide primero el subarchivo de la derecha de N porque es más pequeño que el de la izquierda, etcétera. Si «se unen» las Figuras 9.3 y 9.4 y se conecta cada elemento de partición a
  • 157. QUICKSORT 137 Figura 9.5 Árbol del procesode particióndel Quicksort. su homólogo de los dos subarchivos, se obtendrá la representación estática del proceso de partición mostrado en la Figura 9.5. En este árbol binario, cada sub- archivo se representa por su elemento de partición (o por su único elemento, si es de tamaño uno), y los subárboles de cada nodo son los árboles que represen- tan los subarchivos después de la partición. Los nodos del árbol representados por un cuadradoson los subarchivos nulos. (Para mayor claridad, la segunda A, la D, la M, la O, la P, las dos E finales y la R tienen dos subarchivos nulos: tal y como se vio anteriormente, las variantes del algoritmo tratan de distinta forma los subarchivos nulos.) La implementación recursiva de la ordenación rápida consiste en recorrer los nodos de este árbol por orden previo; la implementación no recursiva se corresponde con la regla de «visitar primero el subárbol más pe- queño». En el Capítulo 14 se verá cómo este árbol conduce a una relación di- recta entre el Quicksort y un método fundamental de búsqueda. Subarchivos pequeños La segunda mejora de la ordenación rápida surge al observar que un programa recursivo necesariamente se llama a sí mismo para muchos subarchivos peque- ños, por lo que se debería utilizar el mejor método posible cuando se encuen- tren subarchivos de este tipo. Una forma evidente de hacer esto consiste en mo- dificar la comprobaciónal principio de la rutina recursiva ((i f (der > izq))) para poder hacer una llamada a la ordenación por inserción (modificada para aceptar los parámetros que definan ai subarchivoa ordenar),es decir, (( if (der- izq <=M) insercion(izq, der) .»Aquí M es algún parámetro cuyo valor exacto depende de la implementación. El valor elegido para M no necesita ser el mejor posible: el algoritmo trabaja casi lo mismo para cualquier M con valores entre 5 y 25. La reducción del tiempo de ejecución es del orden de un 20%para la mayoría de las aplicaciones. Una forma ligeramente más fácil de ordenar subarchivos pequeños, que además es algo más eficaz, consiste tan sólo en cambiar la comprobación del
  • 158. 138 ALGORITMOS EN C++ 1 . .. . . . .=: . . - . m . . . . . .. . . . . . . 9 . . . = . .-.. u . =. .....- ..-=. -= ..$d . : .. . ...... .... ..... .. :.-.- ..... - m - .= = .=. " 1 .. .. - . It. 1 It. Figura 9.6 Quicksort (recursivo, ignorandoI05 SI ..... .. I . : ; = ' + . .. .. .... .. r: .- = .. Y ..-=y. 9 . . C. ... p . ..= I It. Jbarchivos pequeños). principio por «if (der-izq > M) D: es decir, simplemente ignorar los sub- archivos pequeños durante la partición. En la implementación no recursiva se haría esto evitando poner en la pila los archivos menores que M. Tras la parti- ción, lo que se obtiene es un archivoque está casi ordenado. Sin embargo, como se mencionó en el capítulo anterior, ! a ordenación por inserción es el mStodo a elegir para ordenar tales archivos. Es decir, la ordenación por inserción es tan eficaz para este tipo de archivos como para el conjunto de archivos pequeños que se obtendría si se utilizara directamente. Este método debe emplearse con precaución porque probablemente la ordenación por inserción ordene siempre, incluso si Quicksort tiene un error que impide que funcione. La única evidencia de que algo va mal puede ser el coste excesivo. La Figura 9.6 da una visión de este proceso en un array grande, ordenado al azar. Estos diagramas representan gráficamente cómo cada partición divide un subarray en dos subproblemas independientes que deberian abordarse por se- parado. En estas figuras se muestra un subarray en cada uno de los cuadrados, que contiene cuadros de puntos aleatoriamente rcordenados; el proceso de par- tición divide el cuadrado en otros dos más pequeños, con un elemento (el de partición) sobre la diagonal. Los elementos que no están implicados en la par- tición acabarán bastante cerca de la diagonal y el array resultante se opera fá- cilmente mediante la ordenación por inserción. Tal y como se indicó antes, el diagrama correspondiente a una implementación no recursiva de Quicksort es similar, pero las particiones se hacen en un orden diferente.
  • 159. QUICKSORT 139 Partición por la mediana de tres La tercera mejora de la ordenación rápida consiste en utilizar un elemento de partición mejor. Aqcí hay varias posibilidades.Para evitar el peor caso, la elec- ción más segura sena utilizar como elemento de partición un elemento aleato- rio del array. Entonces, la probabilidad del peor caso será muy baja. Éste es un sencillo ejemplo de un «algoritmo probabilists), que utiliza números pseudoa- leatorios para tener casi siempre un buen rendimiento, independientemente del orden de los datos de entrada. Los números pseudoaleatorios pueden ser una herramienta útil en el diseño de algoritmos, en especial si se sospecha alguna tendencia en los datos de entrada. En el caso del Quicksort probablemente es excesivoutilizar un generador de números aleatonos completo sólo con este fin: será suficiente con un número arbitrario (ver Capítulo 35). Una mejora más útil consiste en tomar tres elementos del archivo y después utilizar la mediana de los tres como elemento de partición. Si los tres elementos elegidosprovienen de la izquierda, del centro y de la derecha del array, se puede evitar el uso de centinelas de la siguiente manera: ordenar los tres elementos (utilizando el método de los tres intercambios del capítulo anterior), despuésin- tercambiar el del medio con a [der-1 ], y a continuación, ejecutar el algoritmo de partición sobre a [izq+l],..., a [der-21. A esta mejora se le conoce como el método de la partición por la mediana de tres. Este método mejora el Quicksort de tres formas. En primer lugar, hace rnu- cho más improbable que ocurra el peor caso en cualquier ordenación real. Esto es así porque para que la ordenación dure un tiempo N2,dos de !os tres elemen- tos examinados deberían estar entre los más grandes o los más pequeños de los elementos del archivo, y esto debería ocurrir en la mayoría de las particiones. En segundo lugar, elimina la necesidadde la clave centinela al hacer la partición porque esta función la realizan los tres elementos examinados antes de la par- tición. En tercer lugar, de hecho reduce el total del tiempo medio de ejecución del algoritmo en un 5 % aproximadamente. La combinación de una implementación no recursiva, del método de la par- tición por la mediana de tres y de un tratamiento aislado de subarchivospeque- ños puede mejorar el tiempo de ejecución de la ordenación rápida alrededor de un 25 a 30%con respecto a la implementación recursiva directa. Son posibles otras mejoras algorítmicas (por ejemplo, podría utilizarse la mediana de cinco o más elementos), pero la cantidad de tiempo que se gana es despreciable. Po- drían lograrse ahorros de tiempo más significativos (con menos esfuerzo) codi- ficando los bucles internos (o el programa completo) en lenguaje ensamblador o de máquina. Este camino no se recomienda, excepto posiblemente para ex- pertos en aplicacionesde ordenación importantes.
  • 160. 140 ALGORITMOS EN C+t Selección Una aplicación relacionada con ordenaciones, en las que no siempre es nece- saria una ordenación total, es la operación de encontrar la mediana de un con- junto de números. Éste es un cálculo usual en estadística y en diversas aplica- ciones de proceso de datos. Una forma de proceder sería ordenar los números y seleccionar el del medio, pero se puede hacer mejor utilizando el proceso de partición de la ordenación rápida. La operación de encontrar la mediana es un caso particular de la operación de selección: encontrar el k-ésimo elemento más pequeño de un conjunto de números. Puesto que ningún algoritmo puede garantizar que un elemento sea el k-ésimo más pequeño sin haber examinadoe identificadolos k - 1 elementos que son menores que él y los N - k elementos que son mayores, la mayoría de los algoritmos de selección pueden devolver todos los k elementos más peque- ños de un archivo sin una gran cantidad de cálculos extra. La selección tiene muchas aplicacionesen el proceso de datos expenmenta- les y de otro tipo. Es muy común el uso de la mediana y de otras estadísticas de urden para dividir un archivo en grupos más pequeños. A menudo sólo ha de guardarse para procesos posteriores una pequeña parte de un gran archivo; en tales casos, podría ser más apropiado un programa que pueda seleccionar, por ejemplo, el 109’0más significativo de los elementos del archivo, en lugar de otro que hiciera una ordenación total. Ya se ha visto un algoritmo que puede adaptarse directamente a la selec- ción. Si k es muy pequeño, entonces la ordenaciónpur selección será muy efi- caz, necesitando un tiempo proporcional a N k primero encuentra el elemento más pequeño, después el segundo más pequeño, buscando el más pequeño de los que quedan, etc. Para un k algo más grande, se presentarán en el Capítulo 11 métodos que pueden adaptarse para que se ejecuten en un tiempo propor- cional a Mogk. Puede formularse un método interesante a partir del procedi- miento de partición empleado en el Quicksort, que se ejecute en un tiempo li- neal sobre la media de todos los valores de k. Recuérdese que el método de partición de la ordenación rápida reordena un array a [13 ,...,a [NI y de- vuelve un entero i tal que a [l ],...,a [i-11 son menores o iguales que a [i ] y a [i t1] ,...,a [NI son mayores o iguales que a [i 1. Si se busca el k-ésimo ele- mento del archivo y se tiene que k==i,entonces ya está hecho. Por el contrario, si k < i se tendrá que buscar el k-ésimo elemento más pequeño en el subar- chivo izquierdo y si k > i entonces se tendrá que buscar el (k- i ) -esimo ele- mento más pequeño del subarchivo derecho, Ajustando esto para encontrar el k-ésimo elemento más pequeño de un array a [izq],...a[der] se llega al si- guiente programa: void selecc(tipoE1emento a[], int izq, int der, int k) int i; {
  • 161. QUICKSORT 141 if (der > izq) i = particion(a, izq, der); i f (i > izq+k-1) selecc(a, izq, i-1, k ) ; i f ( i < izq+k-1) selecc(a, i+l, der, k-i); { Este procedimiento reordena el array de forma que a[izq] ,... a[1-11 sean menores o iguales que a [k] y a [k+l] ,...,a [der] sean mayores o iguales que a [k].Por ejemplo, la llamada a se1ecc (1, N I (N+1)/2) divide al array sobre su mediana. Para las claves del ejemplo de ordenación, este programa utiliza sólo cinco llamadas recursivas para encontrar la mediana, como se muestra en la Figura 9.7. Se reordena el archivo para que la mediana esté en un lugar tal que a la izquierda están los elementos más pequeños que ella y los más grandes están a la derecha (lo elementos iguales podrían estar en cualquier lado), pero no está totalmente ordenado. I Figura 9.7 Partición para encontrar la mediana. Puesto que el procedimiento se1ecc siempre termina con una llamada a sí mismo, cuando llegue el momento de la llamada recursiva se podrá simple- mente reinicializar los parámetros y volver al principio (no se necesita una pila para eliminar la recursion). También es posible eliminar los cálculos simplesque afectan a k, como en la siguiente implementación: void selecc(tipoE1emento a[], int N, int k) {
  • 162. 142 ALGORITMOS EN C++ int i, j, izq, der; tipoElemento v; izq = 1; der = N; while (der > izq) v = a[der]; i = izq-1; j = der; { for (;;) while (a[++i] < v) ; while (a[--j] > v) ; if ( i >= j) break; intercambio(a, i , j); { 1 intercambio (a, i , der) ; if (i >= k) der = i-1; if (i <= k) izq = i+l; Se emplea un procedimiento de partición idéntico al que se utilizó en la orde- nación rápida y, como éste, se podría modificar ligeramente si se esperan mu- chas claves iguales. La Figura 9.8 muestra el proceso de selección en un archivo (aleatorio)más grande. Como en el Quicksort se puede afirmar (muy a grandes rasgos) que en un archivo muy grande cada partición debería dividirlo en dos mitades y por tanto todo el proceso necesitaría aproximadamente N + N/2 + N/4 + N/8 + ... = 2N comparaciones. Al igual que en la ordenación rápida, este argumento aproximado no está muy lejos de la realidad. Propiedad 9.2 La selección basada en el Quicksort es de tiempo lineal por tér- mino medio. Un análisis similar, pero significativamente más complejo, que el dado ante- riormente para el Quicksort conduce al resultado de que el número medio de comparaciones es del orden de 2N + 2kln(N/k) + 2(N-k)ln(N/(N- k), que es lineal para cualquier valor permitido de k. Para k = N/2 (búsqueda de la me- diana), resulta aproximadamente (2 + 21n2)Ncomparaciones.. El peor caso es muy similar al que se da en la ordenación rápida: utilizar este método para encontrar el elemento más pequeño de un archivo ya ordenado daría como resultado un tiempo de ejecución cuadrático. Podría utilizarse un elemento de partición arbitrario o aleatorio, pero con mucho cuidado: por ejemplo, si se busca el elemento más pequeño, probablemente no se quiera di- vidir el archivo por la mitad. Es posible modificar el procedimiento de selección basado en la ordenación rápida para garantizar que el tiempo de ejecución sea
  • 163. QUICKSORT 143 Figura 9.8 Búsquedade la mediana. lineal. Estas modificaciones, aunque importantes en teoría, son extremada- mente complejas y no del todo prácticas. Ejercicios 1. Implementar una versión recursiva del Quicksort que ordene por inserción los subarchivos con menos de M elementos, y determinar empíricamente el valor de M para que el método, aplicado a un archivo aleatorio de 1.O00 elementos, iucione más rápido. 2. Resolver el problema anterior para una implementación no recursiva. 3. Resolverel problema anterior incorporando la mejora de la mediana de tres. 4. ¿Cuánto tiempo tardará el Quicksort en ordenar un archivo de N elementos 5. ¿Cuál es el número máximo de veces, durante la ejecución de Quicksort, iguales? que puede desplazarseel elemento mas grande?
  • 164. 144 ALGORITMOS EN C++ 6. Mostrar cómo se divide el archivo A B A B A B A, utilizando los dos mé- todos sugeridos en el texto. 7. ¿Cuántas comparaciones utiliza el Quicksort para ordenar las letras C U E S T I O N F A C I L? 8. ¿Cuántas claves centinela) se necesitan si la ordenación por inserción se llama directamente desde el Quicksort? 9. ¿Sería razonable utilizar una cola en lugar de una pila para una implemen- tación no recursiva del Quicksort? ¿Por qué sí o por qué no? 10. Escribir un programa para reordenar un archivo de forma que todos los ele- mentos con clavesiguales a la mediana estén en su lugar, con los elementos más pequeños a la izquierda y los más grandes a la derecha.
  • 165. 10 Ordenación por residuos En muchas aplicacionesde ordenación,las «claves» utilizadas para definir el or- den de los registrosde los archivos pueden ser muy complicadas. (Considérese, por ejemplo, el orden utilizado en una guía de teléfonos o en el catálogo de una biblioteca.) Debido a esto, es preferible definir los métodos de ordenación en términos de las operaciones básicas de «comparan>dos claves e ((intercambian) dos registros. La mayoría de los métodos que se han estudiado pueden descri- birse por medio de estas dos operaciones fundamentales. Sin embargo, en mu- chas aplicaciones se aprovecha el hecho de que las claves pueden considerarse como números de algún intervalo finito. Los métodos de ordenación que apro- vechan las propiedades numéricas de estos números se denominan ordenacio- nespor residuos. Estos métodos no sólo comparan las claves: además procesan y comparan fragmentos de ellas. Los algoritmos de ordenación por residuos tratan las claves como números representados en un sistema de numeración en base M, para diferentes valores de M (el residuo),y trabajan con las cifras que forman los números. Por ejem- plo, considerando un oficinista que tiene que ordenar un conjunto de tarjetas que tienen impresos números de tres dígitos,una manera razonable de proceder sena hacer diez montones: uno para los números menores de 100,otro para los números que están entre 100y 199, etc., poner las tarjetas en los montones y, a continuación, tratarlos de forma individual, bien utilizando el mismo método con las cifras siguientes o bien, si sólo quedan unas pocas tarjetas, utilizando algún método más sencillo. Éste es un simple ejemplo de una ordenación por residuos para izi(= 10. En este capítulo se estudiará con detalle éste y algún otro método. Por supuesto, para la mayoría de las computadoras es más apropiado trabajar con A 4 = 2 (o alguna potencia de 2) que con M = 10. Todo lo que esté representado dentro de una computadoradigital puede tra- tarse como un número binario, por lo que muchas aplicaciones de ordenación pueden volverse a escribirpara hacer factiblela utilización de la ordenación por residuos que operan con claves que sean números binarios. Por fortuna, C++ proporciona operadores de bajo nivel que hacen posible implementar tales ope- 145
  • 166. 146 ALGORITMOS EN C++ raciones de una manera directa y eficaz. Esto es importante porque hay otros muchos lenguajes (como por ejemplo Pascal) que intencionadamente hacen que sea difícil escribir un programa que dependa de la representación binaria de los números. Dado (una clave representada como) un número binario, la operación funda- mental necesaria para la ordenación por residuos es la extracciónde un conjunto contiguo de bits del número. Si, por ejemplo, se van a procesar claves que son números enteros comprendidos entre O y 1.000, se puede suponer que éstos va- lores están representados por números binarios de 10bits. En lenguaje de má- quina, los bits se extraen de los números binarios utilizando operacionesde ma- nipulación de bits, tales como la «y»y los desplazamientos.Por ejemplo, los dos bits más significativosde un número de 10bits se extraen desplazándolos ocho posiciones a la derecha y haciendo a continuación una operación binaria «y» con la máscara 0000000011. En C++, estas operaciones se realizan directa- mente con los operadores de manipulación de bits >> y &. Por ejemplo, los dos bits más significativosde un número x de 10bits se obtienen por (x >>8) & 03. En general, «poner a cero todos los bits de x excepto losj que están más a la derecha» se puede obtener con x & (-0 < <j ) porque - (-0 < <j ) es una máscara con unos en lasj posiciones de bits de la derechay con ceros en el resto. Hasta el momento, en las implementaciones de los algoritmos de ordena- ción se ha dejado sin especificar el tipo de las claves de los elementos que se van a ordenar ( t ipoEl emento), con la suposición implícita de que los operadores de comparación <,==, y > estarán disponibles para claves usuales tales como números enteros, números de coma flotante, cadenas de caracteres.En los al- gontmos de ordenación por residuos no se utilizan los operadores de compara- ción, pero C++ permite ser explícitosrespecto a los operadores que sedesee uti- lizar, tal y como se muestra en la siguiente definición para claves de enteros: class cl avebi t s private: int x; pub1 i c : clavebits& operator=(int i ) { x = i ; return *this; } inline unsigned bits (int k, int j ) { return (x >>k) & -(-O<< j ) ; } { 1; typedef cl avebi t s t i poEl emento;
  • 167. ORDENACIÓN POR RESIDUOS 147 Esto significa que sólo se utilizarán dos operaciones en las claves tiPO- El emento: asignación de un valor entero y bi tS. El código de asignación es un estándar de C++. El operador bits utiliza las instrucciones de tratamiento de bits descritas anteriormente para devolver los j bits de x que están a la derecha de k bits. Por ejemplo, si t es del tipo tipoEl emento entonces la declaración t = 1000 asigna simplemente 1.O00 (en binario 1111101000)al campo de datos de t, entonces t.b its(2, 4) es 2 (10 en binario, los bits quinto y sexto de la derecha de t). Para utilizar los algoritmos de ordenación por residuos se debe tener un operador bits, de manera que las claves a ordenar puedan aparecer de forma lógica como cadenas de bits. Se pueden incluir otras cosas en el tipo, tales como el número máximo de bits de una clave o una forma de permitir que cadenas de bits de longitud variable puedan ser claves. Como siempre, se omi- tirán tales detalles para poder centrarse en las propiedades esenciales de los algoritmos. Armados con esta herramienta básica, se estudiarán dos tipos de ordenación por residuos que se diferencian en el orden en el que se examinan los bits de las claves. Se supone que las claves no son cortas, de manera que merece la pena hacer el esfuerzo de extraer sus bits. Si las claves son cortas, entonces se puede utilizar el método de cuenta de distribuciones del Capítulo 8. Recuérdese que este método puede ordenar N claves enteras entre O y M - I en un tiempo li- neal, utilizando una tabla auxiliar de tamaño M para los contadores y otra de tamaño N para los registros reordenados. De este modo, si se puede disponer de una tabla de tamaño 2', se puede ordenar fácilmente clavesde una longitudde b bits en un tiempo lineal. La ordenación por residuos es útil si las claves son suficientemente grandes (a partir de b = 32), donde no es posible esta ordena- ción por cuenta de distribuciones. El primer método básico de ordenación por residuos que se estudiará se de- nomina ordenaciónpor intercambio de residuos y examina los bits de las claves de izquierda a derecha, manipulando los registros de una forma similar a la or- denación rápida. El segundo método, denominado ordenación directa por resi- duos, examina los bits de las claves de derecha a izquierda y se puede ejecutar en tiempo lineal en una serie razonable de circunstancias. Ordenación por intercambio de residuos Supóngaseque se pueden reordenar los registros de un archivo de manera que todas aquellas claves que comiencen con el bit O se coloquen delante de todas las que comiencen con el bit 1. Esto define un método de ordenación recursivo como el de la ordenación rápida: si se ordenan los dos subarchivos indepen- dientemente, todo el archivo estará ordenado. Para reorganizar el archivo se examina éste empezando por la izquierda para encontrar una clave que em- piece con el bit 1 y empezando por la derecha para encontrar una clave que
  • 168. 148 ALGORITMOS EN C++ empiece con el bit O, intercambiándolas y continuando así hasta que se crucen los punteros: void cambioresiduos(tipoE1emento a[] , int izq, int der, int b) int i , j ; tipoElemento t ; i f ( d e n i z q , && b>=O) { i = izq; j = der; while ( j != i ) { while (!a[i].bits(b, 1) && i < j ) i++; while ( a [ j ] . b i t s ( b , 1) && j > i ) j--; intercambio(a, i , j ) i f (!a[der] . b i t s ( b , 1)) j++; cambioresiduos(a, izq, j-1, b-1); cambioresiduos(a, j , der, b-1); { 1 1 1 Lallamadaacambioresiduos(1, N , 30) ordenaráelarraysia[l], ...,a[N] son enteros positivos menores que 232 (de manera que se puedan representar como números binanos de 3 1 bits). La variable b ((guardala pista» del bit que se está examinando, variando entre 30 (el que está más a la izquierda) y O (el que está más a la derecha). El número exacto de bits a utilizar depende de una manera directa de la aplicación, del número de bits por palabra de la máquina y de la representación de los números enteros y de los negativos. Evidentemente esta implementación es bastante similar a la implementa- ción recursiva del método de ordenación rApida del Capítulo 9. En esencia, ha- cer una partición en el método de ordenación por intercambio de residuos es como hacerlo en el de ordenación rápida, excepto que en lugar de utilizar como elemento de partición un número del archivoaquí se utiliza el número 2'. Como se podría dar el caso de que 2hno pertenezca al archivo, no está garantizado que durante la partición se coloque todo elemento en su lugar definitivo. Además, como sólo se examina un bit, no se puede contar con centinelas para detener al puntero durante la exploración, por lo que se incluyen las comprobaciones ( i < j ) en los bucles de exploración.Esto se traduce en un intercambio extra para el caso ( i == j ) , que podría evitarse con una instrucción break, como en la implementación de la ordenación rápida; aunque en este caso el «intercambio» de a[i ] consigo mismo no tiene consecuencias. El proceso de partición se de- tendrá cuando j sea igual a i y todos los elementos a la derecha de a [i ] tengan bits 1 en la posición b-ésima y todos los elementos a la izquierda de a [i ] ten-
  • 169. ORDENAC!ÓN POR RESIDUOS 149 gan bits O en la posición b-ésima. El mismo elemento a [i ] tendrá un bit 1 a menosque todas las claves del archivo tengan un O en la posición b. La imple- mentación anterior tiene una comprobación extra justo después del bucle de partición, para cubrir este caso. La Figura 10.1 muestra cómo el ejemplo de archivo de claves se divide y ordena por este método. Se puede comparar con la Figura 9.2 de la ordenación rápida, aunque la operación del método de partición es completamente opaca sin la representación binaria de las claves. La Figura 10.2 muestra la partición en términos de la representación binaria de las claves. Se utiliza un código sen- cillo de cinco bits, presentando a la letra i-ésimadel alfabeto mediante la repre- sentación binaria del número i. Esto es una versión simplificada de la codifica- ción real de caracteres, que utiliza más bits (siete u ocho) y representa a más caracteres (mayúsculas, minúsculas, números, símbolos especiales). Tradu- ciendo las claves de la Figura 10.1a estecódigo de caracteresde cinco bits, com- primiendo la tabla de manera que el subarchivo dividido se represente «en pa- ralelo)),y no uno por línea, y transponiendo después filas y columnas, se puede mostrar en la Figura 10.2cómo los bits más significativosde las claves contro- lan la partición. En esta figura cada partición se indica en el siguiente diagrama a la derecha por un subarchivo «O» blanco seguidopor un subarchivo ((1)) gris, excepción hecha de los subarchivos de tamaño 1 que desaparecen del proceso de partición en cuanto se los encuentra. Un serio problema potencial de la ordenación por residuos es que con fre- cuencia se pueden dar particiones degeneradas (particiones en las que todas las claves tienen el mismo valor que el bit que se está utilizando). Esta situación surgefrecuentemente en archivos reales al ordenar números pequeños (con mu- chos ceros en cabeza).Esto también puede ocumr para caracteres:por ejemplo, suponiendo que claves de 32 bits están formadas por grupos de cuatro caracte- res, codificadosen el código estándar de ocho bits, entonces probablemente se den particiones degeneradas en las primeras posicionesde cada carácter, ya que, por ejemplo, todas las minúsculas comienzan con los mismos bits en la mayoría de los códigos de Caracteres. Hay otros muchos efectos similares que deben te- nerse en cuenta al ordenar datos codificados. En la Figura 10.2 se puede ver que una vez que se distingue una clave del resto de ellas por sus bits izquierdos, no se vuelve a examinar ningún otro bit. Esto es una ventaja en algunas ocasiones y un inconveniente en otras. La ven- taja se da cuando los bits de las claves son verdaderamente aleatonos, ya que en este caso cada clave difiere de las otras en unos 1gNbits, lo que podría ser mu- cho menor que el número de bits de las claves. Esto es así porque, en una situa- ción aleatoria, se espera que cada partición divida el subarchivo por la mitad. Por ejemplo, ordenar un archivo con 1.O00 registros podria traer consigo el exa- minar aproximadamente 10 u 11 bits de cada clave (incluso cuando las claves son de 32 bits). Por el contrario, hay que destacar que se examinan todos los bits de las claves iguales; la ordenación por residuos no funciona bien en archi- vos que contienen muchas claves iguales. La ordenación por intercambio de re- siduos es de hecho algo más rápida que la ordenación rápida si las claves que se
  • 170. 150 ALGORITMOS EN C++ ~~~~ Figura 10.1 Subarchivosen la ordenación por intercambiode residuos.
  • 171. ORDENACIÓN POR RESIDUOS 151 A O O O a l A O0001 D O 0 1 0 0 E 00101 E 0 0 1 0 1 E 0 0 1 0 1 L O 1 1 0 0 M 0 1 1 0 1 N O 1 1 1 0 o o 1 1 1 1 o o 1 1 1 1 R 1 0 0 1 0 R 1 0 0 1 0 E O0101 J O 1 0 1 0 E O0101 M 01101 P 1 0 0 0 0 L O 1 1 0 0 o o 1 1 1 1 A O0001 o o 1 1 1 1 R 1 0 0 1 0 D O 0 1 0 0 E O0101 N O 1 1 1 0 A 0 0 0 0 1 R 1 0 0 1 0 L E E E D A A o L o N M J R P R O0101 O0101 O0101 O 0 1 0 0 O0001 O0001 o 1 1 1 1 O 1 1 0 0 o 1 1 1 1 0 1 1 1 0 O1101 O 1 0 1 0 1 0 0 1 0 1 0 0 0 0 l Q 0 1 0 A 00031 A O0001 E O0101 D O 0 1 0 0 E O0101 E O0101 L O 1 1 0 0 M 0 1 1 0 1 N 0 1 1 1 0 o o 1 1 1 1 o o 1 1 1 1 P 1 0 0 0 0 R 1 0 0 1 0 R 1 0 0 1 0 E J E M A L o A o N D E R P R 5 0 1 0 1 0 1 0 1 0 O0101 01101 O0001 O 1 1 0 0 o 1 1 1 1 0 0 0 0 1 o 1 1 1 1 0 1 1 1 0 0 0 1 0 0 0 0 1 0 1 1 0 0 1 0 1 0 0 0 0 ~ O O i O A A E D E E J L o N M o R P R Figura 10.2 Ordenación por intercambio de residuos (ordenaciónpor residuos <deiz- quierda a derecha))). 0 0 0 0 1 0 0 0 0 1 0 0 1 0 1 O 0 1 0 0 0 0 1 0 1 0 0 1 0 1 0 1 0 1 0 O 1 1 0 0 o 1 1 1 1 0 1 1 1 0 0 1 1 0 1 o 1 1 1 1 1 0 0 1 0 1 0 0 0 0 10fiiO quiere ordenar están formadas por bits verdaderamente aleatonos, mientras que la ordenación rápida se adapta mejor a las situaciones menos aleatonas. En la Figura 10.3 se puede ver el árbol que representa el proceso de parti- ción de la ordenación por intercambio de residuos, que se puede comparar con la Figura 9.5. En este árbol binano, los nodos internos representan a los puntos de partición, y los nodos externos son las clavesdel archivo que terminan todas en subarchivos de tamaño 1. En el Capítulo 17 se verá cómo este árbol sugiere una relación directa entre la ordenación por intercambio de residuos y un mé- todo fundamental de búsqueda. La implementación recursiva anterior se puede mejorar eliminando la re- cursión y tratando de forma diferente a los subarchivospequeños, tal y como se hizo en la ordenación rápida. Figura 10.3 Diagrama de árbol del proceso de partición en una ordenación por inter- cambio de residuos.
  • 172. 152 ALGORITMOS EN C++ E O 0 1 0 1 J 0 1 0 1 0 E 0 0 1 0 1 M o l l 0 1 P 1 0 0 0 0 L 0 1 1 0 0 O 0 1 1 1 1 A 0 0 0 0 1 O 0 1 1 1 1 R 1 0 0 1 0 D 0 0 1 0 0 E 0 0 1 0 1 N 0 1 1 1 0 A O 0 0 0 1 R 1 0 0 1 0 - J O 1 0 1 0 P 1 0 0 0 0 L O 1 1 0 0 R 1 0 0 1 0 D O 0 1 0 0 N 0 1 1 1 0 R 1 0 0 1 0 E 0 0 1 0 1 E 0 0 1 0 1 M 0 1 1 0 1 O 0 1 1 1 1 A O 0 0 0 1 O O 1 1 1 1 E O 0 1 0 1 A O O O O J P A A J R R L D E E M E v o Q - 1 0 0 0 0 O 0 0 0 1 O 0 0 0 1 O 1 0 1 0 1 0 0 1 0 1 0 0 1 0 O 1 1 0 0 O 0 1 0 0 O 0 1 0 1 O 0 1 0 1 O i l 0 1 O 0 1 0 1 O 1 1 1 0 o 1 1 1 1 O l J l l P A A R R D E E E J L M N o o A O 0 0 0 1 A O 0 0 0 1 D O 0 1 0 0 E O 0 1 0 1 E O 0 1 0 1 E O 0 1 0 1 J O 1 0 1 0 L O 1 1 0 0 M O 1 1 0 1 N O 1 1 1 0 o o 1 1 1 1 o o 1 1 1 1 o 1 0 0 0 0 R 1 0 0 1 0 R 1 0 0 1 0 - 1 0 0 0 0 O 0 0 0 1 O 0 0 0 1 1 0 0 1 0 1 0 0 1 0 O 0 1 0 0 O 0 1 0 1 O 0 1 0 1 O 0 1 0 1 O 1 0 1 0 O 1 1 0 0 0 1 1 0 1 0 1 1 1 0 o 1 1 1 1 0 1 1 1 1 Figura 10.4 Ordenación directa por residuos (ordenación por residuos (<dederecha a izquierda))). P 1 0 0 0 0 L O 1 1 0 0 D O 0 1 0 0 E O 0 1 0 1 E O 0 1 0 1 M O 1 1 0 1 A O 0 0 0 1 E 0 0 1 0 1 A O 0 0 0 1 J O 1 0 1 0 R 1 0 0 1 0 N 0 1 1 1 0 R 1 0 0 1 0 o o 1 1 1 1 o O l l J l Ordenación directa por residuos - Un método alternativo de la ordenación por residuos consiste en examinar los bits de derecha a izquierda. Éste era el método utilizado por las antiguas má- quinas encargadas de ordenar las tarjetas perforadas de ias computadoras: un mazo de tarjetas se procesaba 80 veces, una por cada columna, procediendo de derecha a izquierda. La Figura 10.4muestra cómo funciona una ordenación por residuos de derecha a izquierda, bit a bit, sobre el ejemplo de archivo de claves. En los diagramas, la i-ésima columna se ordena mediante el rastreo de los bits que están en la posición i de la clave y se deduce de la columna anterior ((i - 1)-ésima)extrayendo todas las clavesque tienen un O en el bit i-ésimoy después todas las que tienen un 1 en el i-ésimo bit. No es fácil convencerse de que el método funciona; de hecho no lo hace a menos que el proceso de partición sobre un bit sea estable. Una vez que se ha identificado la importancia de la estabilidad, se puede encontrar una prueba tri- vial de que sí funciona: después de poner las claves que tienen un O en el i-ésimo bit, delante de las que tienen un 1 en el i-ésimo bit (de una manera estable), se sabe que dadas dos claves cualesquiera están en el orden adecuado (conforme a los bits ya examinados) en el archivo, bien porque sus i-ésimos bits son diferen- tes, en cuyo caso la partición los coloca en el orden adecuado, o bien porque son iguales, en cuyo caso ya están en el orden correcto debido a la estabilidad. El requisito de estabilidad significa, por ejemplo, que el método de partición utilizado en la ordenación por intercambio de residuos no se puede utilizar en esta ordenación de derecha a izquierda. El proceso de una partición es parecido a ordenar un archivo con sólo dos
  • 173. ORDENACIÓN POR RESIDUOS 153 valoresy que la ordenación por cuenta de distribuciones es muy apropiada para esto. Si se supone que M = 2 en el programa de la cuenta de distribuciones y se reemplaza a[i ]por bi ts(a [i ],k ,1) ,entonces el programa se convierte en un método para ordenar los elementos del array a tomando los bits que están en la posición k empezando por la derecha y colocando el resultado en el array tem- poral b. Pero no existe ninguna razón para utilizar M = 2; de hecho, se debería hacer M tan grande como sea posible, puesto que se necesita una tabla de A 4 contadores. Esto corresponde a utilizar rn bits a la vez durante la ordenación, con M = 2'". Así, la ordenación directa por residuos se convierte en poco más que en una generalización de la ordenación por cuenta de distribuciones, como puede verse en la siguiente implenientación que permite ordenar a[11 ,..., a[NI para los w bits más a la derecha: void directaresiduos(tipoE1emento a[] , tipoElemento b[] , int N) int i, j, pasar, contador[M-11; for (pasar = O; pasar < w/m; pasar++) { for for for for for 1 1 } (j = O; j < M; j++) contador[j] = O; ( i = 1; i <= N; i++) contador[a[i].bits(pasar*m, m)]++; (j = 1; j < M; j++) contador[j] += contador[j-11; (i = N; i >= 1; i--) b[contador[a[i] .bits(pasar*m, m)]--1 = a[i]; (i = 1; i <= N; i++) a[i] = b[i]; Esta implementación supone que el procedimiento de llamada pasa el array au- xiliar como un parámetro de entrada al mismo tiempo que el array a ordenar. La correspondencia M = 2'" se ha preservado en los nombres de las variables, pero los lectores deben tener en cuenta que en algunos entornos de programa- ción no podrán establecerla diferencia entre m y M. El procedimiento anterior funciona adecuadamente sólo si w es múltiplo de m. Por lo regular, esto no será una restricción que haya que asumir para la or- denación por residuos: tan sólo corresponde a dividir las claves a ordenar en un número entero de partes del mismo tamaño. Cuando m==w se obtiene la orde- nación por cuenta de distribuciones; cuando m==l se obtiene la ordenación di- recta por residuos, la ordenación por residuos de derecha a izquierda y bit a bit, descrita en el ejemplo anterior. La implementación anterior mueve el archivo de a hasta b durante cada fase de la cuenta de distribuciones, despu6s vuelve a a con un sencillo bucle. Este
  • 174. 154 ALGORITMOS EN C++ bucle de ««copia de array)podría eliminarse, si se desea, haciendo dos copias del código de dicha cuenta, una para ordenar de a hacia b y la otra para ordenar de b hacia a. Característicasde rendimiento de la ordenación por residuos Los tiempos de ejecución de las dos ordenaciones por residuos básicas,para or- denar N registros con claves de b bits, son esencialmente Nb. Por un lado se puede pensar que este tiempo de ejecución es equivalente a MogN, ya que si los números son todos diferentes, b debe ser al menos lo@. Por otro lado, los dos métodos realizan normalmente muchas menos de Nb operaciones: el método de izquierda a derecha, porque se puede detener en cuanto se hayan encontrado las diferencias entre claves, y el método de derecha a izquierda, porque puede procesar muchos bits a la vez. Propiedad 10.1 mino medio, aproximadamente NlgN bits. La ordenaciónpor intercambio de residuos examina, por tér- Si el tamaño del archivo es una potencia de dos y los bits son deatonos, se puede esperar que la mitad de los bits más significativos sean O y la otra mitad sean 1, por lo que la recurrencia CN= 2CN,2+ N podría describir el rendimiento, como en el caso de la ordenación rápida del Capítulo 9. De nuevo, esta descripción de la situación no es exacta, porque el elemento de partición solamente coin- cide con el centro en el caso medio (y porque las claves tienen un número finito de bits). Sin embargo, en este modelo, es mucho más probable que dicho ele- mento esté en el centro que en la ordenación rápida, de manera que la propie- dad resulta ser cierta. (Para probar esto se necesitaun análisis detallado que está más allá del alcance de este libro.). Propiedad 10.2 Las dos ordenaciones por residuos examinan menos de Nb bits para ordenar N claves de b bits. En otras palabras, la ordenación por residuos es lineal en el sentido de que el tiempo que se necesita es proporcional al número de bits de la entrada. Esto se deduce directamente estudiando los programas: ningún bit se examina más de una vez.. Para archivos aleatonos grandes, la ordenación por intercambio de residuos tiene un comportamiento parecido a la ordenación rápida, como se muestra en la Figura 9.6; pero la ordenación directa por residuos se comporta de forma muy diferente. La Figura 10.5 muestra las etapas de la ordenación directa por resi- duos de un archivo con claves de cinco bits. En estos diagramas aparece clara- mente la organización progresiva del archivo durante la ordenación. Por ejem- plo, después de la tercera etapa (abajo a la izquierda), el archivo está formado
  • 175. ORDENACIÓN POR RESIDUOS 155 .. .. . .. .. .... .. : . . :- . . . .. : ... - . . .-.. - .- 9. . .. - . . ' . .. . - . . . . - . - . . . . . . = . . . =. .. m , .... . . . Figura 10.5 Etapas de una ordenación directa por residuos. por cuatro subarchivos entremezclados: las claves que comienzan por O 0 (banda inferior), las claves que comienzan por O1, etcétera. Propiedad 10.3 La ordenación directa por residuos puede ordenar N registros con claves de b bits en b/m pasos, utilizando un espacio extra de 2" contadores (y una memoria intermedia para reorganizar el archivo). La demostración de esta propiedad se deduce directamente de la implementa- ción. En particular, si se puede tomar m = b/4 sin utilizar demasiada memona extra, se obtiene una ordenación lineal. Las consecuenciasprácticas de esta pro- piedad se tratarán con más detalle a continuación.m Una ordenación lineal La implementación de la ordenación directa por residuos, dada en la sección anterior, efectúa b/rn pasos a través del archivo. Haciendo m muy grande se ob- tiene un método de ordenación muy eficaz, al menos mientras se disponga de M = 2" palabras de memoria disponible. Una elección razonable consiste en tomar m con un valor aproximado a la cuarta parte del tamaño de una palabra
  • 176. 156 ALGORITMOS EN C++ (b/4), ya que de esta manera la ordenación por residuos se hará en cuatro pa- sadas de la cuenta de distribuciones. Se tratan las claves como números en base A 4 y se examina cada dígito (en base M) de cada clave, aunque sólo hay cuatro dígitos por clave. (Esto se corresponde directamente con la organización de la arquitectura de muchas computadoras: una organización representativa tiene palabras de 32 bits, cada una de ellas formada por cuatro bytes de ocho bits. En este caso, el procedimiento bi t s acaba extrayendo ciertos bytes de las palabras, lo que se puede realizar fácilmente en tales computadoras.)Ahora, cada pasada de la cuenta de distribuciones es lineal y, como sólo hay cuatro, toda la orde- nación es lineal. Por consiguiente, éste es el mejor rendimiento que se podría esperar de una ordenación. De hecho, incluso podría bastar con sólo dos pasadas de la cuenta de distri- buciones. (A estas alturas es probable que incluso un lector cuidadoso tenga di- ficultades para distinguir derecha de izquierda, por lo que puede ser necesario hacer un pequeño esfuerzo para comprender este método.) Se realiza aprove- chando el hecho de que si sólo se utilizan los b/2 bits más significativos de las claves de b bits, el archivo está casi ordenado. Al igual que se hizo en la orde- nación rápida, se puede completar eficazmente la ordenación, aplicando más tarde la ordenación por inserción sobre la totalidad del archivo. Este método es una modificación trivial de la implementación anterior: para hacer una orde- nación de derecha a izquierda utilizando la mitad delantera de las claves, sim- plemente se comienza el bucle exterior con pasar = b/ (2* m) en lugar de em- pezar con pasar = O. A continuación se puede aplicar una ordenación por inserción convencional al archivo casi ordenado que se obtiene. Para conven- cerse de que un archivo ordenado en sus bits más significativos está bastante bien ordenado, el lector puede examinar las primeras columnas de la Figura 10.2. Por ejemplo, aplicar la ordenación por inserción sobre un archivo ya ordenado en sus tres primeros bits necesitaría solamente seis intercambios. Utilizando dos pasadas de la cuenta de distribuciones (siendo rn aproxima- damente igual a la cuarta parte del tamaño de una palabra) y utilizando después una ordenación por inserción para terminar el trabajo, se obtiene un método de ordenación que probablemente se ejecutará más rápido que cualquiera de los que se han visto para grandes archivos cuyas claves son bits aleatonos. El pin- cipal inconveniente es que se necesita un array auxiliar del mismo tamaño que el que se está ordenando. Este array extra se puede eliminar utilizando técnicas de listas enlazadas; pero aun así todavía se necesita un espacio extra proporcio- nal a N (para los enlaces). Es obvio que en muchas aplicaciones lo deseable es una ordenación lineal, pero existen razones por las que no es la panacea que se podría imaginar. En primer lugar, su eficacia depende mucho de que las claves estén formadas por bits aleatorios y que estén ordenadas aleatoriamente. Si no se cumplen estas condicioneses de esperar un rendimiento muy degradado. En segundo lugar, se necesita un espacio extra proporcional al tamaño del array que se está orde- nando. En tercer lugar, el ((bucleinterno» del programa contiene bastantes ins- trucciones, así que, aun siendo lineal, no será mucho más rápido que, por ejem-
  • 177. ORDENACIÓN POR RESIDUOS 157 plo, el de ordenación rápida, como cabría esperar,excepto para archivosbastante grandes (pero en éstos el array extra se convierte en un verdadero obstáculo). La ordenación por residuos podría caracterizarsecomo una aproximación a una ordenación de ((utilidadespecial»,porque su viabilidad depende de propie- dades especiales de las claves, en contraste con la «utilidad general)) de algont- mos tales como el de ordenación rápida, que se utilizan mucho más porque se adaptan a una gran variedad de aplicaciones. La elección entre la ordenación rápida y la ordenación por residuos depende no solamente de las características de la aplicación (como la clave, el registro y el tamaño del archivo), sino tam- bién de las características del entorno de programación y de la máquina, que están íntimamente relacionadas con la eficacia de acceso y la manipulación in- dividual de los bits. En aplicaciones adecuadas, la ordenación por residuos se puede ejecutar dos veces más rápidamente que la ordenación rápida, o incluso más; pero podría no merecer la pena si el espacioes un problema potencial o si las claves son de tamaño variable o no son necesariamente aleatonas, o ambas cosas. Ejercicios 1. 2. 3. 4. 5. 6. 7. 8. Comparar el número de intercambios efectuados por la ordenación por in- tercambio de residuos y la de ordenación rápida para el archivo 001, O11, ¿Por qué no es tan importante eliminar la recursión de la ordenación por intercambio de residuos como lo era para la ordenación rápida? Modificar el programa de ordenación por intercambio de residuos para ig- norar los bits más significativosque son idénticos en todas las claves. ¿En qué situaciones sería ventajosa esta técnica? Verdadero o falso: el tiempo de ejecución de la ordenación directa por re- siduos no depende del orden de las claves en el archivo de entrada. Razonar la respuesta. ¿Qué método es probablemente más rápido para un archivo con todas las claves iguales:el de ordenación por intercambio de residuos o el de orde- nación directa por residuos? Verdadero o falso: tanto la ordenación por intercambio de residuos como la ordenación directa por residuos examinan todos los bits de todas las cla- ves del archivo. Razonar la respuesta. Aparte del requisito de memoria extra, jcuál es el mayor inconveniente de la estrategia de realizar la ordenación directa por residuos sobre los bits más significativos de las claves y terminar después con una ordenación por inserción? ¿Cuánta memoria se necesita exactamente para hacer una ordenación di- recta por residuos, en cuatro pasadas, de N claves de b bits? 101, 110,000,001, 001,010, 111, 110, 010.
  • 178. 158 ALGORITMOS EN C++ 9. ¿Qué tipo de archivo de entrada hará que la ordenación por intercambio de residuos se ejecute lo más lentamente posible (para un N muy grande)? 10. Comparar empíricamente la ordenación directa por residuos con la orde- nación por intercambio de residuos para u11 archivo aleatorio de 10.000 claves de 32 bits.
  • 179. I 1 Colas de prioridad En muchas aplicacioneslos registros con claves se deben procesar en orden, pero no necesariamente en orden completo, ni todos a la vez. A veces se forma un conjunto de registros y se procesa el mayor; a continuación posiblemente se in- cluyan otros elementos y luego se procesa el nuevo registro máximo y así suce- sivamente. Una estructura de datos apropiada para un entorno como éste es aquella que permita insertar un nuevo elemento y eliminar el mayor. Esta es- tructura, que se puede contrastar con las colas (donde se elimina el más anti- guo) o con las pilas (donde se elimina el más reciente), se denomina cola de prioridad. De hecho, una cola de prioridad se puede considerar como una ge- neralización de las pilas y de las colas (y de otras estructuras de datos simples), puesto que estas estructuras se pueden implementar con colas de prioridad, ha- ciendo las asignacionesde prioridad adecuadas. Las aplicaciones de las colas de prioridad incluyen sistemas de simulación (donde las claves pueden corresponder a (cronologíasde sucesos»que se deben procesar en orden), planificación de tareas en los sistemas informáticos (donde las claves pueden corresponder a «prioridades» que indican qué usuarios se de- ben procesar en primer lugar) y cálculosnuméricos (dondelas clavespueden ser errores de cálculo y por tanto se puede tratar en primer lugar el más grande). Más adelante, en este libro, se verá cómo se pueden utilizar las colas de prio- ridad como bloques básicos para la consiiucción de algoritmos más avanzados. En el Capítulo 22 se desarrollará un algoritmo de compresión de ficheros utili- zando las rutinas de este capítulo, y en los Capítulos 31 y 33 se verá cómo las colas de prioridad pueden servir de base a muchos algoritmos fundamentales de búsqueda en grafos. Éstos son sólo unos pocos ejemplos del importante papel que desempeñan las colas de prioridad como herramienta básica en el diseño de algoritmos. Por razones de utilidad se debe precisar algo más sobre la forma de tratar las colas de prioridad, puesto que existen varias operaciones que puede ser necesa- rio llevar a cabo sobre ellas, para preservarlasy poderlas utilizar con eficacia en aplicaciones como las mencionadas anteriormente. En verdad, la razón pnnci- 159
  • 180. 160 ALGORITMOS EN C++ pal por la que las colas de prioridad son tan útiles es la flexibilidad con que per- miten llevar a cabo eficazmente una gran variedad de operaciones sobre con- juntos de registros con claves. Lo que se desea es construir y mantener una estructura de datos que contenga registros con claves numéricas (prioridades) y que cuente con algunas de las operaciones siguientes: Construir una cola de prioridad a partir de N elementos. Insertar un nuevo elemento. Suprimir el elemento más grande. Sustituir el elemento más grande por un nuevo elemento (a menos que éste sea mayor). Cambiar la prioridad de un elemento. Eliminar un elemento arbitrario determinado. Unirdos colas de prioridad en una más grande. (Si los registros pueden tener claves iguales, se considera que el «más gra.ide» significa«uno cualquierade los registros que tiene el valor de clave más grande».) La operación sustituir es casi equivalente a insertar seguida de suprimir (la diferenciaes que insertar/suprimir requiere que la cola de prioridad crezca tem- poralmente en un elemento); obsérvese que esta operación es diferente de su- primir seguido de insertar. Ésta se incluye como una operación por separado porque, como se verá, algunas implementaciones de las colas de prioridad pue- den realizar eficazmente la operación sustituir. Del mismo modo, la operación cambiar podría implementarse como eliminar seguido de insertar y la opera- ción construir con el uso repetido de insertar, pero estas operaciones se pueden implementar más eficazmente y de manera directa por medio de ciertas estruc- turas de datos. La operación unión necesita estructuras de datos avanzadas; en su lugar se estudiará una estructura de datos «clásica», denominada montículo, con la que es posible lograr implementaciones eficaces de las cinso primeras operaciones. La cola de prioridad, tal como se ha descrito anteriormente, es un excelente ejemplo de la estructura de datos abstracta, descrita en el Capítulo 3: está muy bien definida en términos de las operaciones que se llevan a cabo sobre ella, in- dependientemente de cómo se organizan los datos y se procesan en una imple- mentación particular. Cada implementación diferente de las colas de prioridad se acompaña de di- ferentes características de rendimiento para las diversas operaciones que se lle- van a cabo, lo que conduce a comparaciones de costes. En efecto, las diferencias de rendimiento son realmente las únicas que pueden aparecer en el concepto de estructura de datos abstracta. Se ilustrará este punto presentando algunas es- tructuras de datos elementales que permiten implementar colas de prioridad. Después se examinará una estructura de datos más avanzada y se mostrará cómo se pueden implementar algunas de las operaciones anteriores utilizando esta es- tructura. Para concluir se verá un importante algoritmo de ordenación que se obtiene de forma natural a partir de estas implementaciones.
  • 181. COLAS DE PRIORIDAD 161 Implementacioneselementales Una forma de organizar una cola de prioridad es como una lista no ordenada, colocando simplemente los elementos en un array a[11, ..., a[NI sin prestar atención a los valores de las claves. (Como es habitual, se reserva a[O] y a[N+1] para valores centinelas, en el caso de que se necesiten.) El array a y su tamaño N se utilizan solamente por las funciones de la cola de prioridad y se suponen «ocultos» de las rutinas de invocación. Si se usa un array para implementar una lista no ordenada, la c l ase cola de prioridad se obtiene fácilmente como sigue: class CP private: tipoElemento *a; i n t N; CP (in t max) { public: { a = new tipoElemento[max]; N = O; } {delete a; } { a[++N] = v; } -cp (1 voi d inserta r ( t ipoElement o v ) tipoElemento suprimir() i n t j, max = 1; f o r ( j = 2; j <= N; j++) intercambio(a, max, N); r e t u r n a[N--1; { i f ( a [ j ] > a[max]) max = j; 1 >; Para insertar,se incrementa N y se coloca el nuevo elemento en a[NI, una ope- ración en tiempo constante. Pero suprimir requiere recorrer el array para en- contrar el elemento con la clave más grande, lo que lleva un tiempo lineal (se deben examinar todos los elementos del array ), y después intercambia a[NI con dicho elemento y decrementa N. La implementación de sustituir es muy similar y por tanto se omite. Para implementar la operación cambiar (cambiar la prioridad del elemento a[k] ), es suficiente con almacenar el nuevo valor, y para eliminar el elemento a[k] ,se puede cambiar por a[ N I y áecrementar N, como en la última línea de suprimir. Tales operaciones, que hacen referencia a elementos específicos, sólo
  • 182. 162 ALGORITMOS EN C+t tienen sentido en una implementación (andirecia) o con (puntero)),donde cada elemento mantiene una referencia a su lugar en la estructura de datos. Una im- plementación de este tipo se presenta al final de este capítulo. Otra organización elemental a utilizar es una lista ordenada, empleando otra vez un array a [11,...,a[NI, pero manteniendo los elementos en orden cre- ciente de sus claves. Ahora suprimir implica simplementedevolver a [NI y de- crementar N (operación de tiempo constante), pero insertar necesita desplazar todos los elementos superiores una posición a la derecha, lo que podría llevar un tiempo lineal, y construir implicaría una ordenación. Cualquieralgoritmo de cola de prioridad se puede convertir en un algoritmo de ordenación utilizando repetidamente insertar para construir una cola de prioridad que contenga todos los elementos a ordenar, y utilizar después repe- tidamente suprimir para vaciar esta última cola y obtener elementos en orden inverso. La utilización de una lista no ordenada para representar de esta forma a una cola de prioridad corresponde a una ordenación por selección; la de una lista ordenada corresponde a una ordenación por inserción. También se pueden utilizar listas enlazadas como listas no ordenadas o lis- tas ordenadas en lugar de la implementaciónpor array anterior. Esto no cambia las características fundamentales de rendimiento de insertar, suprimir o susti- tuir, pero permite ejecutar eliminar y unir en un tiempo constante. Aquí se omiten estas operaciones porque son similares a la lista de operaciones básicas del Capítulo 3 y porque en el Capítulo 14 se dan implementaciones de métodos similares para el problema de búsqueda (encontrar un registro con una clave dada). Como siempre, es conveniente no olvidarse de estas implementaciones por- que a menudo, en muchas situaciones prácticas, superan el rendimiento de mé- todos más complicados. Por ejemplo, la implementaciónpor lista no ordenada puede ser apropiada en una aplicación donde sólo se llevan a cabo algunas ope- raciones de ((suprimirel más grande)) frente a un gran número de inserciones, mientras que una lista ordenadapodría ser lo apropiado si los elementos que se insertan en la cola de prioridad siempre tienden a estar próximos al elemento mayor. Estructura de datos montículo La estructura de datos que se utilizará para implementar las colas de prioridad implica almacenarlos registros en un array de tal manera que se garantice que cada clave es mayor que otras dos que están situadas en posiciones específicas. A su vez, cada una de esas claves debe ser mayor que otras dos y así sucesiva- mente. Este ordenamientoes muy fácil de representar si se dibuja el array como una estructura de árbol bidimensional, con líneas que descienden desde cada clave hacia las dos que se sabe que son inferiores, como en la Figura 11.1. Se vio en el Capítulo 4que esta estructura se denomina (árbol binario com-
  • 183. COLAS DE PRIORIDAD 163 Figura 11.1 Representaciónde un montículopor un árbol completo. pleto»: se puede construir colocando un nodo (llamado ruiz)y luego actuando hacia abajo de la página y de izquierda a derecha, conectando cada par de no- dos al del nivel superior bajo el que se encuentran, y así sucesivamente hasta que se hayan colocado N nodos. Los dos nodos debajo de cada nodo se deno- minan sus hijos;el nodo superior de cada nodo se denomina el pudre. Las cla- ves del árbol deben satisfacer la condición del montículo: la clave de cada nodo debe ser superior (o igual) a las claves de sus hijos (si tiene alguno). Esto implica que la clave más grande está en la raíz. Se pueden representar árboles binarios secuencialmenteen un array con sólo poner la raíz en la posición 1, sus hijos en las posiciones 2 y 3, los nodos del nivel inferior en las posiciones 4, 5, 6 y 7, etc., como indican los números de la Figura 11.1. Por ejemplo, la representación por array del árbol anterior se muestra en la Figura 11.2. Esta representación natural es útil porque es muy fácil ir de un nodo a su padre o a un hijo. El padre del nodo de la posiciónj está en la posiciónj/2 (re- dondeado al entero más cercano sij es impar) e, inversamente, los dos hijos del nodoj están en las posiciones 2 j y 2 j + 1. Esto hace que recorrer este árbol sea más fácil que si estuviera implementado por la representación enlazada están- dar (en la que cada elemento contiene punteros a su padre y a sus hijos). La estructura rígida de árboles binanos completos representados por arrays limita su utilidad como estructura de datos, pero tiene la flexibilidad suficiente para permitir la implementación de los algoritmos de cola de prioridad. Un montí- culo es un árbol binario completo representado por un array, en el cual cada nodo satisface la condición del montículo. En particular, la clave más grande está siempre en la primera posición del array. Todos los algoritmos operan a lo largo de algún camino desde la raíz hasta Figura 11.2 Representaciónde un montículopor un array.
  • 184. 164 ALGORITMOS EN C++ el fondo del montículo (moviéndose del padre a un hijo o de un hijo al padre). Es fácil obserVar que en un montículo de N nodos, todos los caminos tienen 1gN nodos. (Hay alrededor de N/2 hijos en el fondo, N/4 nodos cuyos hijos son los del fondo, N/8 nodos con nietos en el fondo, etc. Cada «generación» tiene al- rededor de la mitad de nodos que la siguiente, lo que implica que puede haber como máximo 1gNgeneraciones.) Por tanto, todas las operaciones de colas de prioridad (excepto unir)se pueden hacer en tiempos logaritmicos,utilizando un montículo. Algoritmossobre montículos Todos los algoritmos de colas de prioridad que trabajan con montículos co- mienzan haciendo una simple modificación estructural, que podría violar la condición del montículo, y luego lo recorren, modificándolo, para asegurar que dicha condición se satisfaga en todos los nodos. Algunos algoritmos recorren el montículo de abajo hacia arriba, otros lo hacen desde arriba hacia abajo. En to- dos los algoritmos se supondrá que los registros son claves de enteros de una palabra almacenados en un array a de un cierto tamaño máximo y que el en- tero N indica el tamaño actual del montículo. Como antes, se supone que el array y su tamaño son accesibles solamente para las rutinas de las colas de prioridad: los datos se pasan entre el usuario y la cola únicamente a través de las llamadas a las rutinas. Para poder construir un montículo, primero es necesario implementar la operación insertar. Puesto que esta operación incrementa el tamaño del montículo en uno, se debe incrementar N. A continuación se coloca el registro a insertar en a[NI,lo que puede violar la condición del montículo, en cuyo caso (e1 nuevo nodo es mayor que el padre) se intercambia el nuevo nodo con su padre. Esto puede, a su vez, causar una violación que se debe arreglar de la misma forma. Por ejemplo, si se va a insertar R en el montículo de la Figura 11.1, primero se almacena en a [NI como el hijo derecho de E. Luego, puesto que es más grande que E, se intercambia con este nodo y, puesto que es más grande que N, se intercambia con él, y el proceso termina dado que es igual que R. Se obtiene como resultado el montículo que se muestra en la Figura 11.3. El código para este método es directo: la implementación siguiente utiliza subirmonticulo(N) para eliminar la violación de la condición, después de in- sertar un nuevo elemento en N: v o i d CP: :subirmonticulo(int k) tipoElemento v; v = a[k]; a[O] = elementoMAX; while (a[k/2] <= v)
  • 185. COLAS DEPRIORIDAD 165 { = a[k] = a[k/s]; k = k/2; } a[k] = v; 1 void CP: :insertar(tipoElemento v) {a[++N] = v; subirmonticulo(N); } Si se reemplazase k/2 por k-1en todo el programa anterior, se tendría, en esen- cia, un paso de la ordenación por inserción (implementando una cola de prio- ridad por medio de una lista ordenada);en lugar de ello, aquí se está insertando la nueva clave a lo largo del camino desde N a la raíz. Al igual que con la orde- nación por inserción, no es necesario hacer un intercambio completo en el bu- cle, porque v siempre está implicado en estos intercambios. Se debe poner una clave centinela en a [O] para detener el bucle en el caso en que v sea mayor que todas las claves del montículo. Más adelante se verá otro empleo de a [O]. Figura 11.3 Inserciónde un nuevo elemento (R) en un montículo. La operación su5titui r consiste en poner una nueva clave en la raíz y luego moverse hacia abajo por el montículo, desde la cima hacia el fondo, para res- taurar la condición del mismo. Por ejemplo, si la R del montículo anterior se reemplaza por C, el primer paso es poner a C en la raíz. Esto viola la condición del montículo, pero se puede arreglarintercambiando C con R, el mayor de los dos hijos de la raíz. Esto provoca otra violación en el nivel siguiente,la cual se puede de nuevo arreglarintercambiando C con el mayor de los dos hijos (O en este caso).El proceso continúa hasta que no se viole la condición del montículo en el nodo ocupado por C. En el ejemplo, C desciende hasta el penúltimo nivel quedando el montículo que se muestra en la Figura 11.4. La operación «supri m i r el mayom implica casi el mismo proceso. Puesto que el montículo tendrá un elemento menos después de la operación, es nece- sano decrementar N, desalojando el elemento que estaba almacenado en la ú1- tima posición. Pero, como se va a suprimir el elemento más grande (que está en a[1 3), ! a operación suprimir consiste en sustituir, utilizando el elemento que estaba en a [NI. El montículo de la Figura 11.5 es el resultado de suprimir R del montículo de la Figura 11.4 reemplazándolo por E y moviéndose a continua-
  • 186. 166 ALGORITMOS EN C++ Figura 11.4 Sustitución (por C) de la clave mas grande del montículo. ción hacia abajo promocionando al mayor de los dos hijos, hasta que se alcance un nodo con sus dos hijos inferiores a E, lo que en este caso lleva al fondo del montículo. La implementación de ambas operaciones se basa en la técnica de ir restau- rando hacia amba un montículo para ir satisfaciendo la condición del montí- culo en todos los lugares, excepto posiblemente en la raíz. Si la clave de la raíz es muy pequeña, se debe mover hacia abajo por el montículo sin violar la con- dición en ninguno de los nodos que se tocan. Esto indica que se puede utilizar la misma operación para restaurar el montículo después de disminuir el valor de una posición cualquiera. Esto se puede implementar como sigue: void CP: :bajarmonticulo(int k) int j; tipoElemento v; v = a[k]; while (k <= N/2) { j = k+k; if (j<N && a[j]<a[j+l]) j++; if (v >= a[j]) break; a[kI = a[jl; k = j; { > a[k] = v; , Este procedimiento recorre hacia abajo el rnonticulo, intercambiando el nodo de la posición k con el mayor de sus dos hijos, si es necesario,y parando cuando el nodo k sea mayor que sus dos hijos o se haya alcanzado el fondo. (Como es posible que el nodo k tenga un solo hijo, jeste caso se debe tratar de manera adecuada!) Como antes, no se necesita efectuar un intercambio completo por- que v siempre está implicado en cada uno de ellos. El bucle interno de este pro-
  • 187. COLAS DE PRIORIDAD 167 grama es un ejemplo de un bucle con dos salidas distintas: una para el caso en que se alcance el fondo del montículo (como en el primer ejemplo antenor), y otra para el caso en que la condición del montículo se satisfaga en algún lugar del interior del mismo. Éste es el prototipo de las situaciones que necesitan una instrucción break. Figura 11.5 Supresióndel elementomayor de un montículo. Ahora la operación suprimir es una aplicación directa de este procedi- miento: tipoElemento CP: :suprimir() i tipoElemento v = a[i]; a[l] = a[N--1; bajarmonticulo(1) ; return v; } El valor devuelto es el que se encuentra inicialmente en a[11. Después el ele- mento en a[NI se pone en a [11 y se decrementa el tamaño del montículo, que- dando solamente por hacer una llamada a bajarmonticulo para restaurar la condición del montículo en todas partes. La operación sustituir es un tanto más complicada: tipoElemento CP: a[O] = v; bajarmonticu return a[O]; { 1 sust o(0) tui r(tipoEl emento v) Este código utiliza a[O] de una forma artificial: sus hijos son el propio O y 1, por tanto si v es mayor que el elemento más grande del montículo, el montículo no se toca; en caso contrario, se coloca v en el montículo y se devuelve a[11.
  • 188. 168 ALGORITMOS EN C++ La operación extraer un elemento arbitrario del montículo y la operación cambiar también se pueden implementar utilizando una combinación sencilla de los métodos anteriores. Por ejemplo, si se aumenta la prioridad del elemento de la posición k, entonces se debe llamar a suhirmonticul o, y si se disminuye entonces la tarea la hace bajarmonticulo. Propiedad 11.1 Todas las operaciones básicas -insertar, suprimir, sustituir, (bajarmontículo,subirmontículo), eliminar y cambiar- necesitan menos de 21gN comparaciones cuando se llevan a cabo sobre un montículo de N elementos. Todas estas operacionesimplican recorrer un camino entre la raíz y el fondo del montículo, que no puede incluir más de 1gNelementos en un montículo de ta- maño N. El factor dos proviene de bajarmonticulo, que hace dos comparaciones en su bucle interno; las otras operaciones necesitan sólo IgN comparaciones.i Es importante señalar que la operación unir no se ha incluido en esta lista. Realizar eficazmente esta operación necesita una estructura de datos mucho más sofisticada. A pesar de todo, en muchas aplicaciones, se podría esperar que esta operación se solicite con mucha menos frecuencia que las otras. Ordenación por montículos Las operaciones básicas sobre montículos estudiadas con anterioridad permiten definir un método elegantey eficaz de ordenación. Dicho método, denominado ordenaciónpor montículos, no utiliza memoria extra y garantiza ordenar N ele- mentos en alrededor de MgN pasos, sin importar cuál sea la entrada. Por des- gracia, su bucle interno es algo más largo que el del Quicksort, y, como pro- medio, es dos veces más lento. La idea es simplemente construir un montículo que contenga los elementos a ordenar y después suprimirlos todos en orden. Una forma de ordenar consiste en insertar los elementos en un montículo vacío, como en las dos primeras 1í- neas del código siguiente (que de hecho sólo implementa construir (a, N)), y después efectuar N operaciones suprimir, colocando el elemento que se ha su- primido en el lugar que ha quedado vacante en el montículo que se está com- primiendo: void ordenmonticulo(tipoE1emento a[], int N) int i; CP mont!culo(N); for (i = 1; i <= N; i++) monticulo.insertar(a[i]); for (i = N; i >= 1; i--) a[:] =monticulo.suprimir(); { 1 Los procedimientos de implementación de colas de prioridad se han utilizado
  • 189. COLAS DE PRIORIDAD 169 Figura 11.6 Construcción descedente(de arriba hacia abajo)del montículo sólo con propósitos descriptivos:en una implementación real de la ordenación, sena más simple utilizar el código de los procedimientos para evitar llamadas innecesarias a los mismos. Y lo que es más importante, el programa se puede arreglar para que la ordenación se lleve a cabo in situ (sin utilizar memoria ex- tra para el montículo), permitiendo que ordenmonti cul o tenga acceso directo al array y dejando que la cola de prioridad resida en a[11, ... , a[k - 11. La Figura 11.6 muestra la construccióndel montículo cuando se insertan las claves E J E M P L O A O R D E N A R, en este orden, en un montículo ini- cialmente vacío, y la Figura 11.7 muestra cómo se ordenan dichas claves qui- tando la R, luego la otra R, etcétera. En realidad es mejor construir el montículo retrocediendo a través de él e ir creando pequeños submontículosdesde el fondo hacia amba, como se muestra en la Figura 11.8. Este método considera cada posición del array como la raíz de un pequeño submontículo y se aprovecha del hecho de que bajarmonti -
  • 190. 170 ALGORITMOS EN C++ Figura 11.7 Ordenacióna partir de un montículo. culo puede aplicarse en esos submontículos tan bien como en el montículo grande. Trabajando hacia atrás a través del montículo, cada nodo es la raíz de un submontículo que responde a la condición del montículo, excepto posible- mente en su raíz; el método bajarmonticulo termina el trabajo. El recorrido comienza en la mitad del camino del array porque los submontículos de ta- maño l se pueden saltar. Ya se ha señalado que la operación suprimir se puede implementar inter- cambiando los elementos primero y último, decrementando N, y llamando a ba- jarmonti cul o(1). Esto conduce a la siguiente implementación de la ordena- ción por montículos: void ordenmonticulo(tipoE1emento a[], int N) {
  • 191. COLAS DE PRIORIDAD 171 int k; for (k = N/2; k >= 1; k--) while (N > 1) bajarmonticulo(a, N, k); { intercambio(a, 1, N); bajarmonticulo(a, --N, 1); } } Aquí otra vez se abandona cualquier idea de ocultar la representación del mon- tículo, y se supone que se ha modificado bajarmonticulo para que sus dos pri- meros argumentos sean el array y el tamaño del montículo. El primer bucle for podría utilizarse para implementar un constructor que ordene en tiempo lineal un array en forma de montículo. A continuación, el bucle whi 1 e intercambia el elemento mayor con el último y restaura el montículo, al igual que antes. Es interesante notar que, aunque los bucles de este programa parecen hacer cosas muy diferentes, están construidos tomando en cuenta el mismo procedimiento fundamental. Figura 11.8 Construcción ascendente(de abajo hacia arriba) del montículo. La Figura 1119ilustra el movimiento de los datus en la ordenación por mon- tículos al mostrar el contenido de cada montículo operado por bajarmonti - culo en el ejemplo de ordenación, justamente después de que bajarmonti - cu1 O haya hecho que la condición del montículo se cumpla en todas partes. Propiedad 11.2 La construcción ascendente (de abajo hacia arriba) del montí- culo es lineal. Esto se deduce del hecho de que la mayoría de los montículos procesados son pequeños. Por ejemplo, para construir un montículo de 127 elementos, el mé-
  • 192. 172 ALGORITMOS EN C++ 1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 1 4 1 5 Figura 11.9 Movimientode datos en la ordenación por montículos. todo !lama a bajarmonticul o para 64 montículos de tamaño 1, 32 de tamaño 3, 16 de tamaño 7, 8 de tamaño 15,4 de tamaño 31,2 de tamaño 63 y uno de tamaño 127, por tanto se necesitan en el peor caso 64 . O + 32 . 1 + 16 . 2 + 8 . . 3 + 4 . 4 + 2 . 5 + 1 . 6 = 120 «promociones» (dos veces más que compara- ciones). Para N = 2",.unacota superior del número de comparaciones es
  • 193. COLAS DE PRIORIDAD 173 y una demostración similar es válida cuando N no es una potencia de dos.. Esta propiedad no es de particular importancia en la ordenación por mon- tículos, puesto que su tiempo está dominado fundamentalmente por el tiempo MogN de la ordenación, pero es importante en otras aplicaciones de colas de prioridad, en las que un tiempo lineal de construi r puede conducir a un al- goritmo lineal. Se observa que construir un montículo con N insertar sucesi- vos necesita MogN pasos en el peor caso (aunque, por término medio, tiende a ser lineal). Propiedad 11.3 La ordenación por montículos utiliza menos de 2NlgN com- paraciones para ordenar N elementos. Una cota ligeramente superior, por ejemplo 3MgN, es inmediata a partir de la propiedad 11.1.La cota que se da aquí se obtiene de un cálculo más cuidadoso basado en la propiedad 11.2.~ Como se mencionó anteriormente, la propiedad 11.3 es la razón principal por la que la ordenación por montículos tiene un interés práctico: el número de pasos necesarios para ordenar N elementos es obligatoriamente proporcional a M o m , sin importar cuál sea la entrada. A diferencia de los restantes métodos de ordenación que se han visto, no hay ningún apeor caso)) que pueda hacer que la ordenación por montículos se ejecute con más lentitud. Las Figuras 11.10 y 11.11 muestran cómo la ordenación por montículos opera sobre un fichero ordenado aleatoriamente. En la Figura 11.10, el proceso parece cualquier cosa menos una ordenación, puesto que los elementos grandes se desplazan hacia el comienzo del archivo. Pero la Figura 11.11 muestra esta estructura a medida que se ordena el fichero al ir seleccionando los elementos más grandes. Montículos indirectos En muchas aplicaciones de las colas de prioridad, no se desea que los registros se estén desplazando continuamente. En lugar de ello, se quiere que las rutinas de las colas no devuelvan valores sino que indiquen cuál de los registros es el más grande, etc. Esto es semejante a la ((ordenaciónindirecta) o a la «ordena- ción por punteros)) descrita en el Capítulo 8. Puede ser útil examinar esta téc- nica con detalle, ya que es muy práctico utilizar los montículos de esta forma. Una aproximación consiste, como en el Capítulo 8, en hacer de modo que en lugar de estar reorganizando las claves en un array a las rutinas de las colas de prioridad trabajen con un array de índices o de punteros dentro del array, refiriéndose a las claves indirectamente. Más aún, para implementar las opera- ciones cambiar y eliminar es necesario conservar la posición de cada elemento del montículo. Esto se puede llevar a cabo con modificaciones cuidadosas del código anterior, de forma similar a la presentada en el Capítulo 8.
  • 194. 174 ALGORITMOS EN C++ ..... . ...=. . - I : / -. +s, .% .,= .. 9 . I .. .: : .- ..... ... = . -=. . . . . . . - . . 8 . .... ' : = :.:" . : ,: , . I * - . . .. ... =v +. ,- . . . . .. .:: . = . . m $... . . = . ..=-- . - .. . m . . . ... ..b. ........ ... 1 : ..:" . .l a:,.' : c . . . . . . . -. .. . : m 2[/&. .-.. .. + i , -3 t m .:: . .......... b ....lJ: . . m ; . = . . . . .. ... . .... . ... ' : ...:-, . .n- . . . . . . . . . .. % < : ; . . , . J.. .. = = . + : . l . . - .: ..... I . . A . .t .: : . .- 1 : : : .= . . . . .- . .... I . . . : . . . . ..= : .. -9. ....' s ?$<& .,. f... s . = + :;=. . * . = ... ; a .L.= a ..=. .- .. ..= : - - .- 9 .. ......+. . . . . . ..... . . . . .'-:', -..=.3- Figura 11.10. Ordenación por montículosde una permutaciónaleatoria: fase de cons- trucción. Se adoptará una aproximación, toscamente equivalente, pero un tanto más general, que proporcionará rutinas de colas de prioridad que serán útiles más adelante, en particular en los Capítulos 22 y 31. Se generaliza la interfaz para incluir otro argumento para insertar y cambiar: un entero entre 1 y t a l 1a que se asocia con la clave que se inserta en la cola de prioridad. En particular, la operación suprimir es para devolver el entero asociado con la clave más pe- / Figura 11.11. Ordenación por montículosde una permutaciónaleatoria: fase de orde- nación.
  • 195. COLAS DE PRIORIDAD 175 queña de la cola, no la propia clave. Por ejemplo, si las claves están almacena- das en un array (o si son parte de los registros almacenados en un array), se uti- lizarán los índicesde array con este objetivo. Luego se puede recuperar del array la clave y cualquier otra información asociada. Para ilustrar cómo se puede implementar esto, considéreseuna versión mo- dificada de la implementación de ((array no ordenado)) dada al comienzo del capítulo. Se anade un array info para «seguir el rastro)) de Ia información aso- ciada y un array p para hacerlo con las posiciones de las claves en la cola. Hay que asegurarsede que estos arrayssatisfagan que p [info [x] ] = i n f o [p [XI ] = x y a[p[X] ] = v para cada entero x que se haya asociado con alguna clave v de la cola: class CP { private: i n t *a, *p, *info; i n t N; CP( i n t t a l 1a) public: { a = new tipoElemento[talla]; p = new tipoElemento[talla]; i n f o = new i n t [ t a l l a ] ; N = O; 1 - c p o { delete a; delete p; delete i n f o ; } { a[++N] = v; p[x] = N; info[N] = x; } void i n s e r t a r ( i n t x, tipoElemento v) void cambiar(int x, tipoElemento v) i n t suprimir() //suprimir e l menor b [ P [ X I l = v; 1 i n t j, min = 1; f o r ( j = 2; j <= N; j++) intercambio(a, min, N); intercambio(info,min, N) ; p[info[min]] = min; r e t u r n info[N--1 ; i f ( a [ j ] ia[min]) min = j; 1 in t vaci o() { r e t u r n (N <= O); } 1;
  • 196. 176 ALGORITMOS EN C++ Esta implementaciónsupone que las claves menores tienen las prioridades más altas, lo cual es por lo menos tan común en las aplicaciones como el esquema utilizado para la ordenación por montículos. La clave para valorar esta imple- mentación es la operación cambi ar. Como es habitual, no se hace comproba- ción de errores y se supone (por ejemplo) que x siempre está en el margen co- rrecto y que el usuario no trata de insertar en una cola llena o suprimir en una cola vacía. La adición de código en C++ para tales controles se obtiene de forma directa. Si la cola de prioridad es muy grande o se quiere hacer un gran número de operaciones de suprim i r,o ambas cosas, el array a se debe mantener en el or- den del montículo para asegurar que el tiempo de ejecución de todas las ope- raciones esté acotado por un factor logarítmico. Las rutinas del montículo da- das anteriormente se pueden modificar de forma directa para mantener los arrays info y p como se necesite: las comparaciones se refieren directamentea a, pero los desplazamientos se deben reflejar directamente en info e indirectamente en p, como en la implementaciónanterior. Todo esto se puede hacer sin cambiar la interfaz, por lo que más adelante se hará referencia a las operaciones sobre colas de prioridad como se definieron en esta clase, suponiendoque se pueden implementar eficazmente utilizando montículos tal y como se acaba de descri- bir. En algunas aplicacionespodrían ser apropiadas otras implementaciones, de- pendiendo de la naturaleza de las claves y de la mezcla de operaciones sobre colas de prioridad a llevar a cabo. Por ejemplo, si la cola de prioridad es más bien pequeña (por ejemplo, 20 elementos), la implementación anterior puede servir bastante bien. También pueden ser apropiados ligeros cambios en la in- terfaz. Por ejemplo, se puede desear que una función devuelva el valor de la clave de prioridad más alta de la cola y no precisamente el índice de la infor- mación asociada. O podría ser conveniente poder suprimir la clave más grande o la más pequeña de la cola. Es fácil añadir tales procedimientos a la clase an- terior, pero es un desafio el desarrollar una clase donde se garantice un funcio- namiento logarítmico en todas las operaciones. Implementaciones avanzadas Si se debe hacer la operación unir eficazmente, las implementacionesque se han hecho son insuficientes y se necesitan técnicas más avanzadas. Aunque no se dispone aquí de espacio para entrar en los detalles de tales métodos, se pueden presentar algunas consideracionesválidas para su diseño. Por «eficazmente» se entiende que una unión se debe hacer al mismo tiempo que las otras operaciones. Esto excluye inmediatamente la representación sin enlaces que se ha venido utilizando para los montículos, puesto que dos de ellos sólo se pueden unir desplazando todos los elementos de al menos uno de ellos a un array mayor. Es fácil transformar los algoritmos estudiados para utilizar
  • 197. COLAS DE PRIORIDAD 177 representaciones enlazadas; de hecho, algunas veces existen razones para hacer esto (por ejemplo, puede no ser conveniente tener un array contiguo muy grande). En una representación directa por lista enlazada se tendría que man- tener cada nodo apuntando a su padre y a sus dos hijos. Esto revela que la propia condición del montículo parece ser demasiado fuerte para permitir implementaciones eficaces de la operación unir. Las estruc- turas de datos avanzadas que se han diseñado para resolver este problema de- bilitan o bien la condición del montículo o la del balance, en busca de obtener la flexibilidad que se necesita para la unión. Estas estructuras permiten que to- das las operaciones se puedan hacer en tiempo logarítmico. Ejercicios 1. Dibujar el montículo que se obtiene cuando se llevan a cabo las siguientes operaciones en un montículo inicialmente vacío: insertar(1),i nser- tar (5), insertar(Z), insertar(6), susti tui r(4), insertar(8), su- primi r, insertar(7), insertar (3). 2. ;,Es un montículo un archivo ordenado en orden inverso? 3. Decir cuál es el montículo que se obtiene cuando, comenzando con un montículo vacío, se llama sucesivamentea i nsertar para las clavesC U E S T I O N F A C I L. 4. ¿Qué posiciones podrían estar ocupadas por la tercera clave más grande de un montículo de tamaño 32? ¿Qué posiciones no podrían estar ocupadas por la tercera clave más pequeña de un montículo de tamaño 32? 5. ¿Por qué no se utiliza un centinela para evitar la comprobación j < N en bajarmont i cu1o? 6. Mostrar cómo se obtienen las funciones normales de pilas y colas en los ca- sos particulares de colas de prioridad. 7. ¿Cuál es el número mínimo de claves que se deben desplazar en un mon- tículo durante una operación de ({suprimir el mayom? Dibujar un mon- tículo de tamaño 15 para el que se alcanza el mínimo. 8. Escribir un programa para eliminar el elemento de la posición d de un montículo. 9. Comparar empíncamerite la construcciónascendente (de abajo hacia arriba) de un montículo con la descendente (de arriba hacia abajo), construyendo montículos con 1.O00 claves aleatorias. 10. Dar el contenido de los arrays p e info después de insertar las claves C U E S T I O N F A C I L (siendo i la i-ésima letra de la serie)en un montículo inicialmente vacío.
  • 199. 12 Ordenación por fusión En el Capítulo 9 se estudió la operación de selección, que permite encontrar el k-ésimo elemento más pequeño de un archivo, viéndose que es semejante a di- vidir el archivo en dos partes, los k elementos más pequeños y los N-k más grandes. En este capítulo se examinará un proceso más o menos complemen- tario, lafusión, que permite combinar dos archivos ordenados en otro más grande, también ordenado. Como se verá, la fusión es la base de un algoritmo de ordenación recursivo directo. La selección y la fusión son operaciones complementarias en el sentido de que la primera divide el archivo en dos archivos independientes y la fusión une dos archivos independientes para hacer uno. La relación entre estas dos opera- ciones se hace evidente si se trata de aplicar el paradigma de «dividey vencerás» para crear un método de ordenación. El archivopuede estar distribuidode modo que cuando las dos partes estén ordenadas el archivo completo esté ordenado, o bien puede separarse en dos partes para ordenarlas y luego combinarlas para dejar ordenado el archivo completo. Ya se ha visto lo que sucede en el primer caso: es decir en el Quicksort, que consiste básicamente en un procedimiento de selección seguido de dos llamadas recursivas.A continuación se verá la or- denación por fusión, el complemento del Quicksort, que básicamente consiste en dos llamadas recursivasseguidasde un procedimiento de fusión. La ordenación por fusión, al igual que la ordenación por montículos, tiene la ventaja de que ordena un archivo de N elementos en un tiempo proporcional a MogIV aun en el peor caso. Su principal inconveniente es que parece difícil evitar la utilización de un espacio extra proporcional a N, a menos que se de- dique un gran esfuerzo para superar este obstáculo. La longitud del bucle in- terno está entre la del Quicksort y la ordenación por montículos, por lo que la ordenación por fusión es una buena elección si la velocidad es lo esencialy hay espacio disponible. Más aún, la ordenación por fusión se puede implementar de forma que se pueda acceder secuencialmente a los datos (un elemento después de otro), lo que a veces es una cierta ventaja. Por ejemplo, la ordenación por fusión es el método ideal para ordenar una lista enlazada, en la que el acceso . 179
  • 200. 180 ALGORITMOS EN C++ secuencia1es la única forma posible de acceso. Igualmente, como se verá en el Capítulo 13, la fusión es la base de la ordenación en dispositivosde acceso se- cuencial, aunque los métodos utilizados en ese contexto son algo diferentes de los empleados por la ordenación por fusión. En muchos entornos de procesamiento de datos se mantiene un gran archivo (ordenado) de datos al que regularmente se le añaden nuevas entradas. Por lo regular, estas entradas nuevas se van colocando en lotes» y concatenando al archivo principal (que es mucho más grande), reordenando luego el archivo completo. Esta situación está hecha a la medida de la fusión:una estrategiamu- cho mejor consiste en ordenar los lotes pequeños con las entradas nuevas y luego fusionarlos con el gran archivo principal. La fusión tiene muchas otras aplica- ciones similares que hacen que su estudio merezca la pena. Se examinará tam- bién un método de ordenación basado en la fusión. En este capítulo se concentrará el interés en los programas parafusiones de dos vías:programas que combinan dos archivosde entrada ordenados para pro- ducir un archivo ordenado de salida. En el próximo capítulo se verá con más detalle lafusión muZtivíu,que implica más de dos archivos. (La aplicación más importante de la fusión multivía es la ordenación externa, el tema del presente capítulo.) Para comenzar, se supone que se tienen dos arrays ordenados a [11,...y a[MI y b [11,...y b [NI de enteros que se quieren fusionar en un tercer array c [11y ...y c [M+N] .El código siguiente es una implementación de la estrategia obvia que consiste en ir tomando sucesivamente para c el elemento más pe- queño de los que van quedando en los arrays a y b: i = 1; j = 1; a[M+l] = elementoMAX; b[N+l] = elementoMAX; for ( k = 1; k <= M+N; k++) c[k] = ( a [ i ] < b [ j ] ) ? a[i++] :b[j++]; La implementación se simplificareservando espacio en los arrays a y b para las claves centinelascon valores mayores que cualquiera de las otras claves. Cuando se termine con el array a(b) el bucle simplemente desplaza el resto de los ele- mentos del array b (a) al array c. Este método utiliza obviamente M +N com- paraciones. Si a [M+1] y b [N+l] no pudieran utilizarse por las clavescentinelas, entonces habría que añadir comprobaciones para estar seguros de que i es siempre menor que M y que j es menor que N. Otra forma de evitar esta difi- cultad es la que se utiliza posteriormente en la implementación de la ordena- ción por fusión.
  • 201. ORDENACIÓN POR FUSIÓN 181 En lugar de utilizar un espacio extra proporcional al tamaño del archivo fu- sionado, sería preferible tener un método in situ que utilice c [1 1,..., c [MI para una entrada y c [M+1],.., , c [M+N] para la otra. A primera vista parece fácil de hacer, pero no es así: tales métodos existen pero son tan complicados que incluso una ordenación in situ probablemente sea más eficaz, a menos que se les dedique un gran cuidado. Se volverá sobre este punto más adelante. Puesto que en implementaciones prácticas se necesita espacio extra, se po- drían considerar implementaciones con listas enlazadas. De hecho, este método es ideal para estas estructuras. A continuación se da una implementación com- pleta que ilustra todos los convenios a utilizar; obsérvese que el código para la fusión es casi tan sencillo como el anterior: struct nodo struct nodo *z; struct nodo *fusion(struct nodo *a, struct nodo *b) { TipoElernento clave; struct nodo *siguiente; }; r i struct nodo *c; c = z; do if (a->cl ave <= b->cl ave) el se { c->siguiente = a; c = a; a = a->siguiente; } { c->siguiente = b; c = b; b = b->siguiente; } while (c != z); c = z->siguiente; z->sfguiente = z; return c; Este programa fusiona las listas a las que apuntan a y b con la ayuda de un pun- tero auxiliar c. En este capítulo se tratarán directamente enlaces sobre las listas en lugar de utilizar la clase Li sta del Capítulo 3 para economizar a la hora de expresar los algoritmos de ordenación por fusión, como resultará evidente más abajo. Se su- pone que las listas tienen un nodo ficticio «cola», como en el Capítulo 3: todas las listas terminan con el nodo ficticio z,el cual normalmente apunta a sí mismo y también sirve como centinela, con z->cl ave == el emento MAX. Durante la fusión, z se utiliza para contener el primer elemento de la nueva lista fusionada (esto es, utilizándolo como nodo cabecera cuyo campo sigui ente apunta al principio de la lista).Después de construir la lista fusionada, el puntero í!su pri- mer nodo está dado por z->siguiente y z se reinicializa para que se apunte a sí mismo. La comparacion de clave de fusión incluye la igualdad, de forma que la
  • 202. 182 ALGORITMOS EN C++ fusión será estable si se considera que la lista b sigue a la lista a. Más adelante se verá cómo esta estabilidad puede transmitirse a los programas de ordenación que utilizan la fusión. Ordenación por fusión Una vez que se tiene un procedimiento de fusión, no es difícil utilizarlo como base de un procedimiento recursivo de ordenación. Para ordenar un archivo dado, se divide en dos, se ordenan las dos mitades (recursivamente) y se fusio- nan entre sí. La implementación siguiente de este proceso ordena un array a [i zq] ,...,a [der] (utilizando un array auxiliar b [i zq] ,...,b[der] ): void ordenfusion( TipoElemento a[], int izq, int der) int i , j, k, m; if (der > izq) { m = (der+izq)/2; ordenfusion(a, izq, m); ordenfusion(a, m+l, der); for (i = m+l; i > izq; i--) b[i-l] = a[i-11; for (j = m; j < der; j++) b[der+m-j] = a[j+l]; for (k = izq; k <= der; k++) { a[k] = (b[i]<b[j]) ? b[i++] : b[j--1; } 1 Este programa efectúa la fusión sin utilizar centinelascopiando el segundo array de forma simétrica al primero, pero en orden inverso. Así cada array sirvecomo «centinela» para el otro: el elemento más grande (que se encuentra en un array o en el otro) garantiza desarrollos adecuados una vez agotado el otro array al hacer la fusión. El «bucle interno» de este programa es bastante corto (mover hacia b,mover de nuevo hacia a,incrementar i o j e incrementar y comprobar k), y podría acortarse aún más teniendo dos copias del código (una para fusio- nar a en b y otra para fusionar b en a), aunque esto requeriría volver a utilizar de nuevo los centinelas. El archivo ejemplo de claves se procesa como se muestra en la Figura 12.1. Cada línea muestra el resultado de una llamada a fusion. Primero se fusionan E y J para obtener E J, luego E y M para obtener E M y éstas con E J para obtener E E J M. Luego se fusiona L P con A O para obtener A L O P, que fusionado con E E J M da A E E J L M O P, etc. Así pues, este método cons-
  • 203. ORDENACIÓN POR FUSIÓN 183 Figura 12.1 Ordenación por fusión recursiva.
  • 204. 184 ALGORITMOS EN C++ truye recursivamente archivos ordenados a partir de archivos ordenados más pequeños. Ordenación por fusión de listas Este proceso implica un movimiento tal de datos que también se debe consi- derar una lista enlazada. El programa que sigue es una implementación recur- siva directa de una función que toma como entrada un puntero a una lista no ordenada y devuelve un puntero a la versión ordenada de la lista. El programa hace esto reorganizando los nodos de la lista sin necesidad de asignar espacio para nodos temporales ni listas. (Es conveniente pasar como parámetro al pro- grama recursivo la longitud de la lista; también se puede almacenar este valor con la lista o dejar que el programa recorra la lista para averiguar tal longitud.) struct nodo *ordenfusion(struct nodo *c) struct nodo *a, *b; if (c->siguiente != z) { a = c; b = c->siguiente->siguiente->siguiente; while (b != z) { c = c->siguiente; b = b->siguiente->siguiente;} b = c->siguiente; c->siguiente = z; return fusion(ordenfusion(a) , ordenfusion(b)); { 1 return c; 1 Este programa ordena dividiendo la lista sobre la que apunta c en dos mitades, a las que apuntan a y b, ordenando después las dos mitades recursivamente y utilizando a continuación fusion para producir el resultado final. Una vez más, este programa se adhiere al convenio de considerar que todas las listas terminan con z: la lista de entrada termina con z (y por lo tanto esto hace que la lista b también), y la instrucción explícita c-> sigui ente = z pone z al final de la lista a. Este programa es bastante fácil de comprender en su formulación recursiva, aun cuando realmente es un algoritmo sofisticado. Ordenación por fusión ascendente Como se presentó en el Capítulo 5, todo programa recursivo tiene un análogo no recursivo, que, aunque equivalente, puede ejecutar las operaciones en un or-
  • 205. ORDENACIÓN POR FUCIÓN 185 den diferente. En realidad, la ordenación por fusión es un prototipo de la estra- tegia de «combina y vencerás» que caracteriza a muchos cálculos de este tipo, por lo que merece la pena estudiar detalladamente sus implementaciones no re- cursivas. La versión más simple de la ordenación por fusión no recursiva procesa un conjunto de archivos ligeramente diferentes en un orden diferente: primero re- corre la lista llevando a cabo una fusión 1 por 1 para producir sublistas de ta- maño 2, luego recorre la lista llevando a cabo fusiones 2 por 2 para producir sublistasordenadas de tamaño 4,luego hace fusiones4por 4para producir sub- listas de tamaño 8, etc., hasta que se ordene la lista completa. La Figura 12.2 muestra cómo este método lleva a cabo esencialmente las mismas fusiones que en la Figura 12.1 para el archivo ejemplo (puesto que su tamaño está próximo a una potencia de dos), pero en un orden diferente. En general, se necesitan lo@ pasadas para ordenar un archivo de N elementos,pues en cada pasada se duplica el tamaño de los subarchivos ordenados. Es importante notar que la fusiones reales efectuadas por este método «as- cendente» no son las mismas que las realizadaspor la implementación anterior. Considérese la ordenación de 95 elementos que se muestra en la Figura 12.3. La última fusión es una 64 por 3 1, mientras que en la ordenación recursiva se- na un 47 por 47. Es posible, sin embargo, organizar las cosas de forma que la secuencia de fusiones hecha por los dos métodos sea la misma, aunque no hay ninguna razón particular para hacer esto. A continuación se da una implementación detallada de esta aproximación ascendente, utilizando listas enlazadas. struct nodo *ordenfusion(struct nodo *c) r I int i, N; struct nodo *a, *by *cabeza, *resto, *t; cabeza = new nodo; cabeza->siguiente = c; a = z; for (N = 1; a != cabeza->siguiente; N = N+N) resto = cabeza->siguiente; c = cabeza; while (resto != z) t = resto; a = t; for (i = 1; i < N; i++) t = t->siguiente; b = t->siguiente; t->siguiente = z; t = b; for (i = 1; i < N; i++) t = t->siguiente; resto = t->siguiente; c->siguiente = fusion(a, b); for ( i = 1; i <= N+N; i++) c = c->siguiente; { { t->siguiente = z;
  • 206. 186 ALGORITMOS EN C++ Figura 12.2 Ordenación por fusión no recursiva.
  • 207. ORDENACIÓN POR FUSIÓN 187 i return cabeza->si gui ente; } Figura 12.3 Ordenación por fusión de una permutaciónaleatoria. Este programa utiliza un nodo (cabecera de lista» (al que apunta cabeza) cuyo campo enlace apunta a la lista enlazada que se está ordenando. Cada iteración del bucle externo (for)recorre el archivo, produciendo una lista enlazada com- puesta de subarchivos ordenados, dos veces más grandes que los de la pasada anterior. Esto se hace manteniendo dos punteros, uno a la parte de la lista que
  • 208. 188 ALGORITMOS EN C++ aún no se ha visto (resto) y otro al final de la parte de la lista en la que ya se han fusionado los subarchivos (c). El bucle interno (whi 1e) fusiona los dos su- barchivos de longitud N comenzando con el nodo al que apunta resto y pro- duce un subarchivo de longitud N+N, el cual se enlaza con la lista c del resul- tado. La fusión real se lleva a cabo almacenando un enlace al primer subarchivo a fusionar en a, saltando luego N nodos (utilizando el enlace temporal t),y en- lazando z con el final de la lista de a, haciendo después lo mismo para tener otra lista de N nodos a la que apunta b (actualizando resto con el enlace al último nodo visitado) y llamando después a fusion. (Entonces se actualiza c siguiendo hacia abajo hasta el final de la lista que se acaba de fusionar. Éste es un método más simple, pero algo menos eficaz,que las diversasalternativas dis- ponibles, tales como hacer que fusion devuelva punteros al principio y al final o mantener múltiples punteros sobre cada nodo de la lista.) La ordenación por fusión ascendentees también un método interesante para utilizar en una implementación con arrays; esto se deja al lector como un ejer- cicio instructivo. Características de rendimiento La ordenación por fusión es importante porque es un método de ordenación «óptimo» bastante directo que se puede implementar de forma estable. Estos hechos son relativamente fáciles de demostrar. Propiedad 12.1 La ordenaciónpor fusión necesita alrededor de MghT compa- racionespara ordenar un archivo de N elementos. En la implementación anterior, cada fusión M por N necesitará M + N com- paraciones (esto podría variar en una o dos unidades, dependiendo de cómo se utilizan los centinelas). En una ordenación por fusión ascendente, se utilizan 1gNpasadas y cada una necesita alrededor de N comparaciones. Para la versión recursiva, el número de comparaciones se describe por la recurrencia estándar de «divide y vencerás))MN= 2MN/2 + N, con M , = O. Se sabe del Capítulo 6 que esta ecuación admite la solución MN = MU. Precisamente estos argumen- tos son los dos verdaderos si N es una potencia de dos; se deja como ejercicio demostrar que esto ocurre también para cualquier N. Más aún, esto también es válido en el caso medi0.i Propiedad 12.2 La ordenaciónporfusión utiliza un espacio extra proporcional a N. Esto se deduce de las implementaciones, pero se pueden dar algunospasos para disminuir el impacto de este problema. Por supuesto, si el «archivo» a ordenar
  • 209. ORDENACIÓN POR FUSIÓN 189 es una lista enlazada, el problema no aparece, dado que el «espacio extra) (para los enlaces)está por otros motivos. Para arrays, es fácil hacer una fusión M por N utilizando espacio extra so- lamente para el más pequeño de los dos arrays (ver Ejercicio 2). Esto reduce a la mitad las necesidades de espacio de la ordenación por fusión. En realidad es posible hacer esto mucho mejor y hacer fusiones in situ, aunque en la práctica es poco probable que merezca la pena.. Propiedad 12.3 La ordenaciónporfusión es estable. Puesto que todas las implementaciones realmente sólo mueven las claves du- rante las fusiones,es suficienteverificar que las fusionesen sí son estables. Pero esto es evidente:la posición relativa de las clavesiguales no se altera por el pro- ceso de fusión.. Propiedad 12.4 La ordenaciónpor fusión es insensible al orden inicial de la entrada. En las impleinentaciones, la entrada determina sólo el orden en el que se pro- cesan los elementos en las fusiones, por lo tanto esta sentencia es literalmente exacta (excepto para alguna variación que depende de cómo se compila y eje- cuta la instrucción if, lo que debería ser insignificante).Otras implementacio- nes de fusión, que implican comprobaciones relativas al primer archivo que se recorra completamente, pueden conducir a algunas variaciones más grandes, según sea la entrada, pero no mucho mayores. El número de pasadas que se ne- cesita depende sólo del tamaño del archivo, no de su contenido, y cada pasada necesita alrededor de N comparaciones (realmente N-O(1) como media, como se explicará más adelante). Pero el peor caso es más o menos el mismo que el caso medi0.m La Figura 12.4muestra una ordenación por fusión ascendente que opera so- bre un archivo que está inicialmente en orden inverso. Es interesante comparar esta figura con la Figura 8.9, que muestra a la ordenación de Shell haciendo las mismas operaciones. La Figura 12.5presenta otro aspecto de la ordenación por fusión que opera sobre una permutación aleatona, para compararla con diagramas similares de los primeros capítulos. En particular la Figura 12.5 muestra una sorprendente semejanza con la Figura 10.5: en este sentido, la ordenación por fusión es ila «transpuesta» del método de ordenación por residuos! Implementaciones optimizadas En la presentación de los centinelas ya se ha prestado alguna atención al bucle interno de la ordenación por fusión basado en arrays, y se ha visto que las com-
  • 210. 190 ALGORITMOS EN C++ Figura 12.4 Ordenación por fusión de una permutaciónen orden inverso. probaciones de los límites del array en el bLic!e interno se pueden evitar invir- tiendo el orden de uno de los arrays. Esto llama la atención sobre una de las mayores deficiencias de la implementación anterior: el desplazamiento de a ha- cia b. Como se vio para el método de ordenación por residuos del Capítulo 10, este desplazamiento se puede evitar utilizando dos copias del código, una para fiisionar de a a b y otra de b a a. Para llevar a cabo una combinación de estas dos mejoras, es necesario cam- biar las cosas de modo que fusion pueda dar como salida los arrays, en orden creciente o decreciente. En la versión no recursiva, esto se lleva a cabo alter- nando entre una salida creciente y una decreciente; en la versión recursiva hay
  • 211. ORDENACIÓN POR FUSIÓN 191 ~~~ .. .. . .. . - .. . . - m = - i - . . =. . 9 ... . ' . ..-=. . : 9 : . . . : .... . . . . . . - . ...._.. . I ' . I . O . * * e * . . ' O . * o * * ' 0 * b . . * . * b . * e * e * ' 0 . b . . = . . . . . . . I . .. =. .. 1 . .. . . Figura 12.5 Ordenación por fusión de una perrnutación aleatoria. que tener cuatro rutinas recursivaspara fusionar a (b) en b (a) con el resultado en orden decreciente o creciente. Cualquiera de ellos reducirá el bucle interno de la ordenación por fusión a una comparación, un almacenamiento, dos incre- mentos de punteros (i o j,y k) y una comprobación del puntero. Esto compite favorablemente con una comparación, un incremento y una comprobación y un intercambio (parcial) del Quicksort, y el bucle interno del Quicksort se eje- cuta 2 1 0 = 1,38lgNveces,alrededor de un 38%más frecuentemente que el de la ordenación por fusión. Revisión de la recursión Los programas de este capítulo,junto con el del Quicksort, son implementacio- nes típicas de los algoritmos de divide y vencerás. En capítulos posteriores se verán vanos algoritmos con estructuras similares, por lo que merece la pena echar un vistazo más detallzdo a algunas de las características básicas de estas implementaciones. El Quicksort es realmente un algoritmo de «vence y dividirás)):en una im- plementación recursiva, la mayor parte del trabajo se hace antes de las llamadas recursivas. Por el contrario, la ordenación por fusión recursiva está más en el espíritu de «divide y vencerás)): cada archivo se divide primero en dos partes y
  • 212. 192 ALGORITMOS EN C++ luego se «vence» a cada una individualmente. El primer problema para el que la ordenación por fusión hace el procesamiento real es el más pequeño; el sub- archivo mayor se procesa al final. En el Quicksort el procesamiento comienza sobre el subarchivo mayor y finaliza con los más pequeños. Esta diferencia se manifiesta en las implementaciones no recursivas de los dos métodos. El Quicksort debe mantener una pila, puesto que tiene que me- morizar grandes subproblemas, que se dividen en función de los datos. La or- denación por fusión admite una versión no recursiva simple porque la forma en que divide el archivo es independiente de los datos, por lo que el orden en el que procesa los subproblemas puede reorganizarse de alguna forma para hacer el programa más simple. Otra diferenciapráctica que se manifiesta por sí misma es que la ordenación por fusión es estable (está implementada adecuadamente) y el Quicksort no lo es (sin tener que recurrir a complicaciones extra). Para la ordenación por fu- sión, si se supone (por inducción) que los subarchivos han sido ordenados es- tablemente, es suficiente con asegurar que la fusión se hace de una manera es- table,lo que puede hacersecon facilidad.Pero para el Quicksortno parece existir, por sí misma, ninguna forma fácil de hacer la partición de una manera estable, lo que impide la posibilidad de estabilidad, incluso antes de que entre en juego la recursión. Una nota final: como el Quicksort o cualquier otro programa recursivo, la ordenación por fusión se puede mejorar tratando los subarchivos pequeños de forma diferente. En las versiones recursivas del programa esto se puede imple- mentar exactamente como para el Quicksort,bien haciendo sobre la marcha una ordenación por inserción de los subarchivos pequeños, bien haciendo una pa- sada final de limpieza. En las versiones no recursivas,los pequeños subarchivos ordenados se pueden construir en una pasada inicial utilizando una versión mo- dificada de la ordenación por inserción o por selección. Otra idea que se ha su- gerido para la ordenación por fusión es aprovecharse del orden «natural» del archivo utilizando un método ascendente para fusionar las dos primeras se- cuencias ordenadas del archivo (sin importar lo largas que puedan ser), después las dos secuencias siguientes, etc., repitiendo el proceso hasta que el archivo quede ordenado. A pesar de lo atractiva que pueda parecer esta idea, no se puede comparar con el método estándar que se ha presentado, porque el costede iden- tificar las secuencias,que debe imputarse al bucle interno, es mayor que las ga- nancias alcanzadas, excepto para ciertos casos degenerados (tales como un archivo ya ordenado). Ejercicios 1. Implementar una oráenación por fusión recursiva, que procese por medio de una ordenación por inserción los subarchivos con menos de M elemen-
  • 213. ORDENACIÓN POR FUSIÓN 193 2. 3. 4. 5. 6. 7. 8. 9. 10. tos; determinar empíricamente el valor de A 4para el que se ejecuta más rá- pidamente sobre un archivo aleatorio de 1.O00 elementos. Comparar empíricamente la ordenación por fusión recursirva y la no re- cursiva para listas enlazadas y N = 1.OOO. Implementar la ordenación por fusión recursiva para un array de N ente- ros, utilizando un array auxiliar de tamaño menor que N/2. Verdadero o falso: el tiempo de ejecución de la ordenación por fusión no depende del valor de las clavesdel archivo de entrada. Explicarla respuesta. ¿Cuál es el número mínimo de pasos de la ordenación por fusión (dentro de un factor constante)? Implementar una ordenación por fusión ascendente no recursiva que uti- lice dos arrays en lugar de listas enlazadas. Mostrar las fusiones efectuadas al utilizar la ordenación por fusión recur- siva para ordenar las claves C U E S T I O N F A C I L. Mostrar el contenido de las listas enlazadas de cada iteración al utilizar la ordenación por fusión no recursiva para ordenar las claves C U E S T I O N F A C I L . Intentar escribir una ordenación por fusión recursiva, utilizando arrays, partiendo de la idea de hacer fusionesde tres vías en lugar de dos vías. Comprobar empíricamente, para archivos aleatorios de tamaño 1.OOO, la afirmación hecha en el texto de que no compensa la idea de aprovecharse del orden «natural» en el archivo.
  • 215. 13 Ordenación externa Muchas importantes aplicaciones de ordenación deben procesar archivos muy grandes, demasiado como para tenerlos en la memoria principal de cualquier computadora. Los métodos adaptados a estas aplicaciones se denominan mé- todos externos, puesto que implican un gran volumen de procesamiento ex- terno a la unidad central de proceso (en contraste con los métodos internos que se han visto anteriormente). Hay dos factores determinantes que hacen que los algoritmos externos sean diferentesde los que se han visto hasta ahora. El primero es que el coste de ac- ceso a un elemento es infinitamente más grande que el de cualquier actuaiiza- ción o cálculo. El segundo, y todavía más costosoque el anterior, es que existen severas restriccionesde acceso, dependiendo del medio de almacenamiento ex- terno utilizado: por cjemplo, no se puede acceder a los elementos de una cinta magnética más que de forma secuencial. La gran variedad de dispositivosde almacenamiento externo y de costes ha- cen que el desarrollo de los métodos de ordenación externos sea muy depen- diente de la tecnología actual. Estos métodos pueden ser muy complicados y son numerosos los parámetros que afectan su rendimiento: un método muy in- genioso puede que no sea apreciado o utilizado por un simple cambio en la tec- nología. Por esta razón este capítulo se centra más en los métodos generales que en el desarrollo de implementaciones especificas. En síntesis, tratándose de la ordenación externa, los aspectos del problema relativos al «sistema» son tan importantes como los aspectos «algontmicos». Ambas áreas se deben considerar cuidadosamente si se quiere desarrollar una ordenación externa eficaz. El coste principal de la ordenación externa se debe a la entrada-salida. Un buen ejerciciopara alguien que planea hacer un programa para ordenar un archivo muy grande es implementar antes un programa para copiar un gran archivo y luego (si esto ha sido demasiado fácil)implementar un programa eficaz para invertir el orden de los elementos de un archivo de este tipo. Los problemas de sistema que aparecen ai tratar de resolver estos proble- mas eficazmente son similares a los que aparecen en las ordenaciones externas. 195
  • 216. 196 ALGORITMOS EN C++ Permutar un gran archivo externo de forma no trivial es tan difícil como orde- narlo, aun cuando no se necesiten comparaciones entre claves, etc. En la orde- nación externa, se desea principalmente limitar el número de veces que cada elemento de datos se desplaza entre el medio de almacenamientoexterno y la memoria principal, y estar seguro de que tales transferencias se hacen tan efi- cazmentecomo lo permita el material del que se dispone. Se han desarrollado métodos de ordenación externa que se adaptan a las tar- jetas perforadas y cintas de papel del pasado, a las cintas magnéticas y discos del presente y a las nuevas tecnologías como las memorias de burbuja y los video- discos. La diferencia esencial entre los múltiples dispositivos son el tamaño del almacenamientodisponible y la velocidad y los tipos de restricción de acceso a los datos. En este libro se estudian los principios básicos de ordenación en las cintas magnéticas y los discos, porque estos dispositivos son posiblemente los que continuarán siendo muy utilizados e ilustran los dos modos fundamentales de acceso que caracterizan a muchos sistemas de almacenamientoexterno. Fre- cuentementelos sistemasmodernostienen una «jerarquía de almacenamiento» de varias memorias cada vez más lentas, baratas y voluminosas. Aunque mu- chos de los algoritmos que se van a considerar pueden transformarse en algont- mos eficaces en tales entornos, aquí se tratarán exclusivamentelas memorias de «dosniveles» de jerarquía, que comprenden una memoria principal y una de disco o cinta. Ordenación-fusión La mayoría de los métodos de ordenación externa utilizan la siguiente estrategia general: primero hacen una pasada a lo largo del archivo a ordenar, dividiendo a éste en bloques del tamaño de la memoria interna, y ordenando estos bloques. Luegofusionan entre sí los bloques ordenados haciendo varias pasadas a través del archivo, creando sucesivamente archivos ordenados más grandes hasta que el archivo completo esté ordenado. El acceso a los datos es en su mayoría de forma secuencial, lo que hace apropiado este método para la mayor parte de los dispositivosexternos. Los algoritmos de ordenación externa intentan reducir el número de pasadas sobre el archivo y aproximar lo máximo posible el coste de una pasada sencilla al coste de una copia. Puesto que la mayor parte del coste de un método de ordenación externa se debe a la entrada-salida, es posible tener una medida aproximada del coste de una ordenación-fusión contando el número de veces que se lee o escribe una palabra del archivo (el número de pasadas sobre todos los datos). En muchas aplicacioneslos métodos que se van a considerar implican unas diez pasadas o menos, lo que supone que cualquier método que pueda eliminar aunque sólo sea una simple pasada es digno de interés. Además, el tiempo de ejecución de la ordenación externa global puede estimarse fácilmente a partir del tiempo de
  • 217. ORDENACIÓNEXTERNA 197 Figura 13.1 Fusiónequilibradade tres vías: resultado de la primerapasada. ejecución de la acción de ((invertirel archivo de copia), ejercicio sugerido an- teriormente. Fusión múltiple equilibrada Para empezar, se seguiránlos diferentespasos del procedimiento más simplede ordenación-fusión sobre un ejemplo pequeño. Se supone que los registros con las claves E J E M P L O D E O R D E N A C I O N F U S I O N en una cinta de entrada se deben ordenar y colocar sobre una cinta de salida. Utilizar una «cinta» significa simplemente la obligación de leer los registros secuencial- mente: el segundo registro no puede leerse hasta que no se haya leído el pri- mero, y así sucesivamente. Se supone además que sólo hay espacio en la me- moria para tres registros, pero que se dispone de todas las cintas que se desee. El primer paso consisteen leer del archivo tres registros cada vez, ordenarlos en bloques de tres registros y dar como salida los bloques ordenados. Así, pri- mero se lee E J E y se obtiene el bloque E E J, luego se lee M P L, dando como salida al bloque L M P, y así sucesivamente.Ahora bien, para que estos bloques se puedan fusionar entre sí, deben estar en cintas diferentes. Si se desea hacer una fusión de tres vías, entonces se deben utilizar tres cintas, finalizando la or- denación anterior con la configuración que se muestra en la Figura 13.1. Ahora ya se pueden fusionar los bloques ordenados de tamaño tres. Se lee el primer registro de cada cinta de entrada (hay espaciojusto para ello en la me- moria) y se extrae el de menor clave. A continuación se lee el siguiente registro de la misma cinta de la que se leyó el que se acaba de extraer y, de nuevo, se da salida al registro de la memoria que tenga la menor clave. Cuando se alcance el final de un bloque de tres palabras de una de las entradas, entonces se ignora esa cinta hasta que se hayan procesado los respectivos bloques de las otras dos y se haya dado salida a nueve registros. Luego se repite el proceso para fusionar los segundosbloquesde tres palabrasde cada cinta en un nuevo bloque de nueve palabras (que se escribe en una cinta diferente, para que esté listo para la pró-
  • 218. 198 ALGORITMOS EN C++ Cinta 1 E Cinta 2 E Cinta 3 H Figura 13.2 Fusión equilibradade tres vías: resultadode la segunda pasada. xima fusión). Continuando de esta forma, se llega a los tres grandes bloques configurados como muestra la Figura 13.2. Ahora una última fusión de tres vías completa la ordenación. Si se tiene un archivo mucho maym con múltiples bloques de tamaño 9 en cada cinta, enton- ces se finaliza la segunda pasada con bloques de tamafio 27 en las cintas 1, 2 y 3, y una tercera pasada produciría bloques de tamaño 81 en las cintas 4,5 y 6, y así sucesivamente. Se necesitan seis cintas para ordenar un archivo arbitraria- mente grande: tres para la entrada y tres para la salida de cada fusión de tres vías. (Realmente, se puede hacer con sólo cuatro cintas: la salida se puede co- locar sólo en una cinta y después distribuir los bloques de ella sobre las tres cin- tas de entrada entre cada pasada de fusión.) Este método se denomina fusion múltiple equilibrada: constituye un algo- ritmo razonable para hacer ordenaciones externas y es un buen punto de par- tida para una implementación de ordenación externa. Los algoritmos más so- fisticados que se describen después pueden hacer que la ordenación se ejecute algo más rápidamente, pero no mucho más. (Sin embargo, cuando los tiempos de ejecución se miden en horas, lo que no es raro en la ordenación externa, in- cluso un pequeño tanto por ciento de disminución del tiempo de ejecución puede ser importante.) Suponiendo que la ordenación debe emplear N palabras y que se dispone de una memoria interna de tamaño M, cada pasada de «ordenación» praduce al- rededor de N/M bloques ordenados. (Esta estimación supone registros du p una palabra: para registros más grandes, el número de tloques ordenados se calcula multiplicando el resultado anterior por el tamaño del registro.) Si se hacen fu- siones de P-vías en cada paso posterior, el número de pasadas es alrededor de log,(N/M), puesto que cada paso reduce el número de bloques ordenados en un factor P. Aunque los pequeños ejemplos pueden ayudar a comprender los detallesdel algoritmo, es mejor razonar en términos de archivos muy grandes cuando se trabaja con ordenaciones externas. Por ejemplo, la fórmula anterior indica que si se utiliza una fusión de cuatro vías para ordenar un archivo de 200 millones de palzbras en una computadora de un millón de palabras de memoria, el nú-
  • 219. ORDENACIÓN EXTERNA 199 mero de pasadas podría ser aproximadamente cinco. Se puede obtener una es- timación muy grosera del tiempo de ejecución multiplicando por cinco el CO- rrespondiente a una implementación de ordenación en orden inverso sugerida con anterioridad. Selecciónpor sustitución La implementación del método anterior se puede desarrollar de una forma muy elegante y eficaz utilizando colas de prioridad. En primer lugar se verá que las colas de prioridad ofrecen una forma natural de implementar una fusión múl- tiple. Más importante aún es que se pueden utilizar las colas de prioridad para la pasada inicial de ordenación de forma tal que produzcan bloques ordenados mucho más grandes que los que puede contener la memoria interna. La operación básica que se necesita para hacer una fusión de P-vías es dar salida al más pequeño de los elementos más pequeños todavía presentes en cada uno de los P bloques a fusionar. Ese elemento más pequeño debería reempla- zarse por el siguiente elemento del bloque del que proviene. La operación sus- tituir en una cola de prioridad de tamaño P es exactamente lo que se necesita. (En realidad, las versiones indirectas de las rutinas de colas de prioridad descri- tas en el Capítulo 11 son las más apropiadas para esta aplicación.) Específica- mente, para hacer una fusión de P-vías se comienza llenando una cola de prio- ridad de tamaño P con el eiemento más pequeño de cada una de las Pentradas utilizando el procedimiento CP: :insertar del Capítulo 11 (adecuadamente modificado para que la raíz del montículo contenga al elemento más pequeño en lugar del más grande). Después, utilizando el procedimiento CP ::sustitui r del Capítulo 11 (modificado de la misma manera) se da salida al elemento más pequeño y se reemplaza en la cola de prioridad por el siguiente elemento de su bloque. El procesode fusionar E E J con L M P y D E O (la primera fusión del ejem- plo anterior) utilizando un montículo de tamaño tres se muestra en la Figura 13.3. Las «claves» de estos montículos son las más pequeñas (las primeras) de las clavesde cada nodo. Por claridad, se muestran bloques enteros en los nodos del montículo; por supuesto, una implementación real consistiría en un mon- tículo indirecto de punteros dentro de los bloques. Primero, se da salida a D, por lo que la E (la clave siguiente de su bloque) se convierte en la «clave» de la raíz. Como esto no viola la condición del montículo se da salida a la E, convir- tiéndose O en la clave de la raíz. Esto sí viola la condición del montículo y por ello se intercambia el nodo con el que contiene E, E y J. A continuación se ex- trae la E y se sustituye por la siguiente clave de su bloque, la E. Esto no viola la condición del montículo, por lo que no es necesario ningún cambio más. Con- tinuando de esta manera, se obtiene el archivo ordenado (leyendo la clave más pequeña del nodo raíz de los árboles de la Figura 13.3, para ver las claves en el orden en el que aparecen en la primera posición de1 montículo y en el que se
  • 220. 200 ALGORITMOS EN C++ Figura 13.3 Selección por sustituciónpara la fusión sobre un montículode tamaño tres. obtienen en la salida). Cuando se agota un bloque, se pone un centinela en el montículo al que se considera mayor que todas las otras claves.Cuando el mon- tículo no contiene más que centinelas, se ha terminado la fusión. A esta forma de utilizar las colas de prioridad se la denomina algunas veces selección por sus- titución. Así pues, para hacer una fusión de P-vías, se puede utilizar una selección por sustitución sobre una cola de prioridad de tamaño P para encontrar cada elemento a dar como salida en lo@ pasos. Esta diferencia de rendimiento no tiene ninguna repercusión práctica en particular, puesto que una implementa- cion de fuerza bruta puede encontrar, en P pasos, cada elemento a dar como salida y P es normalmente tan pequeño que este coste es minúsculo en com- paración con el de estar realmente dando salida al elemento. La importancia real de la selección por sustitución reside en la forma en que se puede utilizar en la primera parte del proceso de ordenación-fusión: formar los bloques inicia- les ordenados que constituirán la base de las pasadas de la fusión. La idea es pasar la entrada (desordenada) a través de una gran cola de prio- ridad, escribiendo siempreen la salida el elemento más pequrño de la cola, como antes, y sustituyéndolo por el siguiente elemento de la entrada, con un requisito adicional:si el nuevo elemento es menor que el último en salir, entonces,puesto que probablemente no podría formar parte del bloque que se está ordenando, se debería marcar como miembro del bloque siguientey considerarse como su- perior a todos los elementos del bloque actual. Cuando un elemento marcado alcanza la cabeza de la cola de prioridad, se abandona el antiguo bloque y se comienza con uno nuevo. Una vez más, esto se implementa fácilmente con CP: :insertar y CP: :sustituir del Capítulo l i , apropiadamente modifica- dos de modo que el elemento más pequeño esté en la raíz del montículo y cam- biando CP : :susti t ui r para que considere que los elementos marcados son siempre mayores que los no marcados. El archivo ejemplo demuestra claramente el valor de la selección por susti-
  • 221. ORDENACIÓN EXTERNA 201 Figura 13.4 Creación de las secuencias inicialesde la selección por sustitución. tución. Con una memoria interna capaz de contener sólo tres registros, se pue- den producir bloques ordenados de tamaño 7, 4,3, 5, 5 y 1, como se ilustra en la Figura 13.4. Como antes, el orden en que las claves ocupan las primeras po- sicionesdel montículo es en el que se obtendrán en la salida. El sombreado in- dica a qué bloque pertenece cada clave del montículo: un elemento marcado de la misma forma que el de la raíz pertenece al bloque que está siendo ordenado y los otros pertenecen al bloque siguiente. La condición del montículo (la pri- mera clave es menor que la segunda y la tercera) se mantiene en todas partes; los elementos del bloque siguiente se consideran como más grandes que los ele- mentos del bloque que se está ordenando. La primera secuencia termina con D E O en el montículo, puesto que al llegar cada una de estas tres claves son más grandes que la raíz (por tanto no se pceden incluir en el primer bloque), la se- gunda termina con D E N, etcétera. Propiedad 13.1 Para claves aleatorias, las secuencias creadaspor la selección por sustitución son aproximadamente del doble del tamaño del nzontículo utili- zado. La demostración de esta propiedad necesita de hecho un análisis un poco .más sofisticado, pero es fácil de verificar experimenta1mente.i El efecto práctico de esta propiedad es ganar una pasada de fusión: en vez de comenzar con secuencias ordenadas de aproximadamente el tamaño de la
  • 222. 202 ALGORITMOS EN C++ memoria interna y después hacer una pasada de fusión para producir secuen- cias de alrededor del doble del tamaño de dicha memoria, se puede comenzar directamente con secuencias cuyo tamaño sea de unas dos veces el de la me- moria interna, utilizando la selección por sustitución con una cola de prioridad de tamaño M. Si hay algún orden en las claves, entonces las secuencias serán mucho, mucho más largas. Por ejemplo, si ninguna clave tiene delante de ella en el archivo más de M claves superiores, jel archivo estará completamente or- denado después de la pasada de la selección por sustitución, y no será necesaria ninguna fusión! Ésta es la razón práctica más importante para utilizar el mé- todo. En resumen, la técnica de selección por sustitución puede utilizarse a la vez para los pasos de {(ordenación))y de «fusión» de una fusión múltiple equili- brada. Propiedad 13.2 Un archivo de N registrossepuede ordenar utilizando una me- moria interna capaz de contener M registrosy con P + 1 cintas en alrededor de 1 + 10gP(N/2hf)pasadas. Como se presentó anteriormente, se utiliza primero una selección por sustitu- ción con una cola de prioridad de tamaño M, para producir secuenciasiniciales de tamaño próximo a 2M (en una situación aleatoria) o más (si el archivo está parcialmente ordenado), y luego se utiliza la selección pcr sustitución con una cola de prioridad de tamaño P,para alrededor de logp(N/2M)(o menos) pasadas de fusión.m Consideraciones prácticas Para terminar de implementar el método antes esbozado, es necesario hacerlo con las funciones de entrada-salida que realmente transfieren los datos entre el procesador y losdispositivosexternos. Estas funcionesson evidentementela clave del buen rendimiento de una ordenación externa, y necesitan que se consideren cuidadosamente (al contrario que los algoritmos)algunos aspectos del sistema . (Los lectores que no tengan ninguna relación con las computadoras a nivel de «sistema» pueden saltarse los próximos párrafos.) Uno de los objetivos principales de la implementación debería ser el per- mitir un recubrimiento de la lectura, la escritura y los cálculos, tanto como sea posible. La mayoría de los grandes sistemas informáticos tienen unidades de procesamiento independientes para el control de los dispositivos de entrada/sa- lida (E/S) en gran escala, lo que hace posible este recubrimiento. La eficacia que se puede alcanzar con un método de ordenación externa depende del número de dispositivosde este tipo. Para cada archivo que se está leyendo o escribiendo, se puede utilizar la téc- nica de programación de sistemasdenominada doble bufer para hacer máximo
  • 223. ORDENACIÓN EXTERNA 203 el recubrimiento de E/S con el cálculo. La idea e5 mantener dos «buffers», uno reservado para el procesador principal y el otro para el dispositivo de EJS (o del procesador que controla al dispositivo de E/S). Para la entrada, el procesador utiliza un buffer mientras el dispositivo de entrada está llenando el otro. Cuando el procesador termina de utilizar su buffer, espera hasta que el dispositivo de entrada llene el suyo, y entonces los buffers intercambian sus papeles: el proce- sador utiliza los datos del buffer que se acaba de llenar mientras que el disposi- tivo de entrada vuelve a llenar el buffer que tenía los datos que el procesador acaba de utilizar. La misma técnica se utiliza para la salida, canibiando los pa- peles del procesador y el dispositivo.Habitualmente el tiempo de E/S es mucho más grande que el de procesamiento y, por lo tanto, el efecto del doble buffer es recubrir totalmente el tiempo de cálculo; por consiguiente los buffers deben ser tan grandes como sea posible. Una dificultad del doble buffer es que realmente utiliza sólo la mitad del es- pacio de memoria disponible. Esto puede conducir a una falta de eficacia si existen muchos buffers, como es el caso de la fusión de P-vías cuando P no es pequeño. Este problema se puede soslayar utilizando una técnica denominada previsión, que necesita sólo un buffer extra (y no P)durante el proceso de fu- sión. La previsión funciona de la siguiente forma: ciertamente la mejor forma de recubrir la entrada con los cálculos durante el proceso de selección por sus- titución es recubrir la entrada del siguiente buffer que se necesita llenar con la parte de procesamiento del algoritmo. Y es fácil determinar qué buffer es éste: el siguiente buffer de entrada a vaciar es aquel cuyo ultimo elemento es el más pequeño. Por ejemplo, cuando se fusiona E E J con L M P y I9 E O se sabe que el primer buffer será el pRmero a vaciar, luego es el primero. Una forma simple de recubrir el procesamiento con la entrada en una fusión múltiple consiste,por lo tanto, en conservar un buffer extra que se llenará por el dispositivo de en- trada de acuerdo con esta regla. Cuando el procesador encuentra un buffer va- cío, espera hasta que el buffer de entrada esté lleno (si no se ha llenado ya), y luego cambia, para comenzar a utilizar este buffer, en lugar del vacío, al que dirige al dispositivo de entrada para que lo llene de nuevo de acuerdo cofi la regia de previsión. La decisión más importante a tomar en la implementación de la fusión múl- tiple es la elección del valor de P, el «orden» de la fusión. Para ordenación en cinta, donde sólo se permite acceso secuencial,esta elección es fácil: P debe ser igual ai número de unidades de cinta disponibles menos uno, puesto que la fu- sión múltiple utiliza P cintas de entrada y una de salida. Evidentemente, se de- ber, tener al menos dos cintas de entrada, por tanto no tiene sentido tratar de ordenar en cintas si se dispone de menos de tres de ellas. Para crdenaciór, en disco, donde se permite el acceso a una posición arbitra- ria pero con un coste algo más caro que el acceso secuencial, es razonable es- coger P igual al número de discos disponibles menos urio, para evitar el coste más elevado del acceso no secuencial que se produciría, por ejemplo, si dos archivos de entrada diferentes estuvieran en el mismo disco. Otra alternativa comúnmente utilizada es escoger P lo suficientemente grande para que la or-
  • 224. 204 ALGORITMOS EN C++ denación se complete en dos fases de fusión: normalmente no es razonable tra- tar de hacer la ordenación en una pasada, pero a veces se puede hacer en dos, con un P razonablemente pequeño. Puesto que la selección por sustitución pro- duce alrededor de N/2M secuencias y cada paso de fusión divide el número de secuencias por P, esto significa que el valor de P debe ser el menor entero tal que > N/2M. Para el ejemplo de ordenación de un archivo de 200 millones de palabras en una computadora con un millón de palabras de memoria, esto significa que P = 11 sena una buena elección para una ordenación de dos pa- sadas. (El valor exacto de P podría calcularse después de completar la fase de ordenación.) La mejor elección entre estasdos alternativas, del valor razonable- mente más bajo de P y el valor razonablemente más alto de P, depende fuerte- mente de muchos parámetros del sistema: se deben considerar ambas alterna- tivas (e incluso algunas intermedias). Fusión pdifásica Uno de los problemas de la fusión múltiple equilibradaen la ordenación en cinta es que necesita o un número excesivo de unidades de cinta o una cantidad ex- cesiva de copias. Para una fusión de P-vías o se utilizan 2P cintas (P para la entrada y P para la salida) o se debe copiar casi todo el archivo desde una cinta de salida a P cintas de entrada entre pasadas de fusión, lo que efectivamente dobla el número de pasadas, es decir, alrededor de 21ogp(N/2M). Se han inven- tado varios algoritmos ingeniosos de ordenación en cintas, que eliminan vir- tualmente todas estas copias cambiando la forma en la que todos estas peque- ños bloques ordenadm se fusionan entre sí. El más extendido de estos métodos es el denominadofusión polifásica. La idea básica que subyace en la fusión polifásica es distribuir los bloques ordenados, mediante una selección por sustitución, de forma irregular entre las unidades de cinta disponibles (dejando una vacía) y aplicando posteriormente una estrategia de «fusión hasta el vaciado», después de la cual las cintas de sa- lida y entrada intercambian sus papeles. Por ejemplo, se supone que se tienen exactamente tres cintas, y se parte de la configuración inicial de bloques ordenados en las cintas que se muestran en la parte superior de la Figura 13.5. (Esto se obtiene al aplicar la selección por sustitución al archivo ejemplo con una memoria interna que sólo puede conte- ner dos registros.) La cinta 3 que está inicialmente vacía es la cinta de salida para las primeras fusiones. Después de tres fusionesde dos vías desde las cintas 1 y 2 hacia la cinta 3, la segunda cinta se vacía, como se muestra en la mitad de la Figura 13.5. A continuación, después de dos fusiones de dos vías desde las cintas I y 3 hacia la cinta 2, la primera cinta se vacía, como se muestra en la parte inferior de la Figura 13.5. La ordenación se completa en dos pasos más. Primero, una fusión de dos vías desde las cintas 2 y 3 hacia la cinta 1 deja un archivo en la cinta 2 y un archivo en la cinta 1, y despuésuna fusión de dos vías
  • 225. ORDENACIÓN EXTERNA 205 Cinta2 Cinta3 Cinta 3 A C D E E I IJINlOlOlR1i[DIEIEIFIMINIPISIU]i I L N O O W Figura 13.5 Etapas inicialesde una fusión polifásicacon tres cintas. desde las cintas 1 y 2 hacia la cinta 3 deja el archivo totalmente ordenado en la cinta 3. Esta estrategiade afusión hasta el vaciado))se puede generalizar para traba- jar con un número arbitrario de cintas. La Figura 13.6muestra cómo se pueden utilizar seis cintas para ordenar 497 datos iniciales. Si se comienza como se in- dica en la primera columna de la Figura 13.6, con la cinta 2 como cinta de sa- lida, la cinta l con 61 datos, la cinta 3 con 120,etc., entonces, después de eje- cutar una «fusión hasta el vaciado))de cinco vías, se tendrá la cinta 1 vacía, la cinta 2 con 61 datos, la cinta 3 con 59, etc., como se muestra en la segunda columna de la Figura 13.6. En este momento se puede rebobinar la cinta 2 y convertirla en una cinta de entrada, y rebobinar la cinta 1 y convertirla en la cinta de salida. Continuando de esta forma, se llega a tener el archivo total- mente ordenado en la cinta 1. La fusión se corta en muchasfuses que no im- plican a todos los datos, pero que no implican ninguna copia directa. Cinta 1 61 O 3 1 1 5 7 3 1 O 1 Cinta 2 O 6 1 3 0 1 4 6 2 O 1 O Cinta 3 120 59 28 12 4 O 2 1 O Cinta 4 116 55 24 8 O 4 2 1 O Cinta 5 108 47 16 O 8 4 2 1 O Cinta 6 9 2 3 1 O 1 6 8 4 2 1 O Figura 13.6 Distribución de secuencias para una fusión polifacica de seis cintas.
  • 226. 206 ALGORITMOS EN C++ La principal dificultad en la implementación de una fusión polifásica es la de determinar cómo distribuir los datos iniciales. No es difícil ver cómo cons- truir la tabla a la inversa: se toma el mayor número de cada columna, se con- vierte a cero, y se añade a cada uno de los otros números para obtener la co- lumna anterior. Esto conduce a definir la fusión de mayor orden, para la columna anterior, que podría generar la columna actual. Esta técnica funciona para un número cualquiera de cintas (al menos tres): los números que aparecen son ((númerosde Fibonnaci generalizados»que poseen muchas propiedades in- teresantes. Por supuesto, el número de secuenciasinicialespuede no conocerse por adelantado, y es probable que no sea exactamente un número generalizado de Fibonnaci. Por tanto se puede añadir un cierto número de secuencias((ficti- cias» para hacer que el número de secuencias iniciales sea exactamente el que se necesita para la tabla. El análisisde la fusión polifásica es complicado e interesante, y proporciona resultados sorprendentes. Por ejemplo, revela que el mejor método para distri- buir las secuencias ficticias entre las cintas implica utilizar más fases y más se- cuencias de lo que parecería necesario. La razón de esto es que algunas secuen- cias se utilizan en la fusiones con más frecuencia que otras. Para implementar un método más eficaz de ordenación en cinta se deben considerar otros muchos factores. Uno de los más importantes, que no se ha considerado en el capítulo, es el tiempo que se tarda en rebobinar la cinta. Este punto ha sido objeto de estudios detallados y se han definido muchos métodos fascinantes. Sin embargo, como se mencionó con anterioridad, las gananciasque se obtienen con respecto al método de la fusión múltiple equilibrada son bas- tante limitadas. Incluso la fusión polifásica sólo es más eficaz que la fusión equilibrada para un P pequeño, y no sustancialmente. Para P > 8, la fusión equilibrada posiblemente se ejecute con más rapidez que la polifásica,y para un P más pequeño el efecto de la polifásica es prácticamente el reducir en dos el número de cintas (una fusión equilibrada con dos cintas extra se ejecutaría más rápidamente). Un método más fácil Muchos sistemas de computadoras modernos incluyen dispositivos de me- moria virtual de gran capacidad que no deben pasarse por alto al implementar un método para ordenar archivos muy grandes. En un buen sistema de me- moria virtual, el programador puede acceder a cantidades muy grandes de da- tos, dejando al sistema la responsabilidad de transferir los datos desde el so- porte de almacenamiento externo al interno, cuando sea necesario. Esta estrategia descansa en el hecho de que muchos programas presentan una lo- calización relativamente pequeña de sus referencias: cada referencia a la me- moria está en un área relativamente próxima a otra referenciada reciente-
  • 227. ORDENACIÓN EXTERNA 207 mente. Esto implica que las transferencias desde la memoria externa a la interna son raramente frecuentes. Un método de ordenación interna con una pequeña localización de las referencias puede ser muy eficaz en un sistema de memoria virtual. (Por ejemplo, el Quicksort tiene dos «localizaciones»: la ma- yoría de las referencias están cerca de uno de los dos punteros de partición.) Pero es mejor recabar información de un programador de sistemas antes que estar esperando ganancias significativas:un método como el de ordenación por residuos, que no tiene ninguna localización de referencias, e incluso el Quick- sort, podría provocar serios desastres en un sistema de memoria virtual, de- pendiendo de la forma de implementar el sistema de memoria virtual dispo- nible. Por el contrario, la estrategia de utilizar un método simple de ordenación interna para ordenar archivos en disco merece una seria reflexión cuando se dispone de un buen sistema de memoria virtual. Ejercicios 1. 2. 3. 4. 5. 6. 7. 8. 9. Describir cómo efectuaría el lector una selección externa: encontrar el K- ésimo elemento más grande de un archivo de N elementos, donde N es demasiado grande para que el archivo se pueda tener en la memoria in- terna. Implementar el algoritmo de selección por sustitución y utilizarlo después para verificar la afirmación de que las secuenciasgeneradas son aproxima- damente del doble del tamaño de la memoria interna. ¿Qué es lo peor que puede pasar cuando se utiliza la selección por sustitu- ción para generar las secuenciasiniciales en un archivo de N registros,uti- lizando una cola de prioridad de tamaño M, con M < N? ¿Cómo se ordenaría el contenido de un disco si no existe otro medio de al- macenamiento disponible que el de la memoria principal? ¿Cómo se ordenaría el contenido de un disco si sólo hay disponible una sola cinta (y la memoria principal)? Comparar la fusión múltiple equilibrada de cuatro y seis cintas con la fu- sión polifásica con el mismo número de cintas y 3 1 secuencias iniciales. ¿Cuántas fases utiliza la fusión polifásica de cinco cintas cuando co- mienza con cuatro cintas que contienen inicialmente 26, 15, 22 y 28 se- cuencias? Suponiendo que las 3 1 secuenciasinicialesde una fusión polifásica de cua- tro cintas tienen cada una la longitud de un registro (con la distribución inicial O, 13, 11, 7), jcuántos registroshay en cada uno de los archivos im- plicados en la última fusión de tres vías? jCómo se deberían tratar los archivos pequeños en una implementación del Quicksort destinada a aplicarse en archivos muy grandes en un entorno de memoria virtual?
  • 228. 208 ALGORITMOS EN C++ 10. ¿Cómo se organizaría una cola de prioridad externa? (Concretamente, di- señar una forma de soportar las operaciones insertar y suprimir del Capí- tulo 11, cuando el número de elementos de la cola de prioridad podría cre- cer de modo tal que fuera demasiado grande para mantenerla en la memoria principal.)
  • 229. ORDENACIÓN EXTERNA 209 REFERENCIAS para la Ordenación La referencia principal para esta sección es el Volumen 3 de la obra de D. E. Knuth, sobre ordenación y búsqueda. En este libro se puede encontrar infor- mación adicional sobre prácticamente todos los temas presentados con anterio- ridad. En particular, los resultados presentados aquí sobre las caractensticas del rendimiento de los diferentes algoritmos están respaldados por análisis mate- máticos completos. Existe una vasta literatura sobre ordenación. La bibliografía de Knuth y Ri- vest de 1973contiene cientos de citas, pero no incluye el tratamiento de la or- denación que figura en innumerables libros y artículos sobre otros temas. El li- bro de Gonnet es una referencia más actualizada, que contiene una extensa bibliografía que cubre los trabajos hasta 1984. Para el Quicksort, la mejor referencia es el artículo original de Hoare de 1962,que describelas variantes más importantes, incluyendo la utilización para el problema de la selección presentado en el Capítulo 9. Muchos más detalles sobre el análisis matemático y los efectos prácticos de diversas modificacionesy mejoras propuestas a lo largo de los años se pueden encontrar en el libro publi- cado en 1978por el autor de esta obra. Un buen ejemplo de una estructura avanzada de cola de prioridad es la cola binomial)) de J. Vuillemin, implementada y analizada por M. R. Brown. Esta estructura de datos permite todas las operaciones de cola de prioridad de una forma elegante y eficaz. El tipo de estructura de datos más avanzado para implementaciones prácticas es el «montículo pareado», descrito por Fredman, Sedgewick, Sleator y Tarjan. Para tener una impresión sobre la infinidad de detalies relativos a la trans- posición de algoritmos como los que se han presentado en implementaciones prácticas de uso general, se sugiere al lector que estudie los manuales de referen- cia de los sistemasde ordenación de su computadora. Estos manuales hacen ne- cesariamente una revisión de los formatos de las claves, registros y archivos, así como de otros detalles, y a menudo es interesante constatar cómo entran en juego los propios algoritmos. M. R. Brown, «Implementation and analysis of binomial queue algorithms», M. L. Fredman, R. Sedgewick,D. D. Sleatory R. E. Tarjan, «The pairing heap: G. H. Gonnet, Handbook o f Algorithms and Data Structures, Addison-Wesley, C. A. R.Hoare, ((Quicksorb, Computer Journal, 5, I (1962). D. E. Knuth, TheArt o f Computer Programming, Volume3: Sorting and Sear- R. L. Rivest y D. E. Knuth, «Bibliography 26: Computing Sorting), Computing R.Sedgewick, Quicksort, Garland, New York, 1978. (Aparece también como SIAM Journal of Computing, 7, 3 (agosto, 1978). a new form of self-adjustingheap)),Algorithmica, 1, I (1986). Reading, MA, 1984. ching, segunda impresión, Addison-Wesley, Reading, MA, 1975. Reviews, 13, 6 Cjunio, 1972). tesis de Ph.D., Universidad de Stanford, 1975.)
  • 233. 14 Métodos de búsqueda elementales La búsqueda es una operación fundamental, intrínseca a una gran cantidad de tareas de las computadoras, que consiste en recuperar uno o varios elementos particulares de un gran volumen de información previamente almacenada. Normalmente se considera que la información está dividida en registros, cada uno de los cuales posee una clave para utilizar en la búsqueda. El objetivo de esta operación es encontrar todos los registros cuyas claves coincidan con una cierta clave de búsqueda, con el propósito de acceder a la información (y no so- lamente a la clave) para su procesamiento. Las aplicaciones de la búsqueda están muy difundidas y abarcan una va- riada gama de operaciones diferentes. Por ejemplo, un banco necesita hacer un seguimiento de las cuentas de todos sus clientes y buscar en ellas para verificar diversostipos de transacciones. De igual forma un sistema de reservas de unas líneas aéreas tiene necesidadessimilares,aunque la mayor parte de los datos sean de vida corta. Dos términos comunes que se utilizan a menudo para describir las estruc- turas de datos relativas a las búsquedas son los diccionarios y las tablas de sírn- bolos. Por ejemplo, en un diccionario de inglés las «claves» son las palabras y los «registros» las entradas asociadas con ellas, que contienen la definición, la pronunciación y otras informaciones. Se puede aprender UJ método de bús- queda y apreciar su acción pensando cómo se implementaría un sistema para buscar en un diccionario de inglés. Una tabla de símbolos es el diccionario de un programa: las «claves» son los nombres simbólicosutilizados en el programa y los «registros» contienen la información que describe al objeto designado. En la búsqueda (como en la ordenación) existen programas que están muy difundidos y se utilizan frecuentemente, de modo que merece la pena estudiar con cierto detalle un cierto número de métodos. Al igual que en la ordenación, se comenzará por estudiar algunos métodos elementales, que son muy útiles en 213
  • 234. 214 ALGORITMOS EN C++ pequeñas tablas y en otras situacionesespeciales,y después se mostrarán las téc- nicas fundamentales a explotar por los métodos más avanzados. Se verán mé- todos que almacenan los registros en arrays, en los que se busca por compara- ción entre claves o que están indexadospor el valor de la clave, y posteriormente se verá un método fundamental que construye estructuras definidas por los va- lores de las claves. Al igual que en las colas de prioridad, es preferible considerar que los algo- ritmos de búsqueda pertenecen a conjuntos de rutinas empaquetadas que rea- lizan una serie de operaciones genéricas y que se pueden disociar de las imple- mentaciones particulares, de tal forma que permiten pasar fácilmente de una implementación a otra. Entre las operaciones que interesan se cuentan: Inicializar la estructura de datos. Buscar un registro (o varios) con una clave dada. Insertar un nuevo registro. Eliminar un registro específico. Unir dos diccionarios en uno solo (de mayor tamaño). Ordenar el diccionario; dar como salida todos los registros ordenados. Al igual que en las colas de prioridad, a veces es conveniente combinar al- gunas de estas operaciones. Por ejemplo, la operación buscar e insertar se in- cluye a menudo, por razones de eficacia, en situaciones en las que la estructura de datos no debe contener registros con clavesiguales. En muchos métodos, una vez que se ha determinado que una clave no pertenece a la estructura de datos, el propio estado interno del procedimiento de búsqueda contiene la informa- ción necesaria para insertar un nuevo registro con la clave dada. Los registros con claves iguales se pueden tratar de varias formas, según la aplicación. Primero, se puede insistir para que la estructura de datos primaria contenga sólo registros con claves distintas. Entonces cada ((registro))de esta es- tructura de datos puede contener, por ejemplo, una lista enlazada de todos los registros que tienen la misma clave. Esto es conveniente en algunas aplicacio- nes, puesto que todos los registros con la misma clave se obtendrán en una sola búsqueda. Una segundaposibilidad es colocar a todos los registros con la misma clave en la estructura de datos primaria y devolver,en una búsqueda, cualquier registro que contenga la clave. Esto es más simple en aplicacionesque procesan registro a registro, donde no es importante el orden en el que se procesan los registros que tienen claves iguales. Esta solución no es satisfactoria para el di- seño de un algoritmo porque se debe proporcionar un mecanismo para recu- perar otro registro o todos los registros con la misma clave. Una tercera posibi- lidad consiste en suponer que cada registro tiene un identificador único (aparte de la clave) y entonces la búsqueda ha de encontrar el registro que tiene el iden- tificador dado, conociendo la clave. Una cuarta posibilidad es que el programa de búsqueda llame a una función específicapara cada registro que tenga la clave dada. También podrían ser necesarios otros mecanismosmás complejos. En este libro, al describir los algoritmos de búsqueda, se menciona informalmente cómo
  • 235. MÉTODOS DE BÚSQUEDA ELEMENTALES 215 se pueden encontrar registros con claves iguales, sin precisar qué mecanismo hay que utilizar. Los ejemplos del capítulo contendrán normalmente clavesiguales. Cada una de las operaciones fundamentales antes enunciadas tiene aplica- ciones importantes y se han sugerido un gran número de organizacionesbásicas que permiten el uso eficaz de diversas combinaciones de ellas. En éste y en los próximos capítulos, se centrará la atención en las implementaciones de las fun- ciones fundamentales de buscar e insertar (y por supuesto inicializar),con al- gunos comentarios sobre eliminar y ordenar cuando sea conveniente. Al igual que en las colas de prioridad, la operación unión necesita normalmente técnicas que se salen del marco de este tratamiento. Búsqueda secuencia1 El método de búsqueda más simple consisteen almacenar todos los registros en un array. Cuando se inserta un nuevo registro, se pone al final del array; cuando se lleva a cabo una búsqueda, se recorre secuencialmente el array. El siguiente programa muestra una implementación de las funciones básicas que utiliza esta sencilla organización e ilustra a su vez algunos de los convenios que se utiliza- rán en la implementación de los métodos de búsqueda. class Dicc private: s tr u c t nodo s t r u c t nodo *a; i n t N; Dicc ( i n t max) { tipoElemento clave; t i p o I n f o i n f o ; }; pub1ic : { a = new nodo[max] ; N = O; } { delete a; } - D i cc() tipoInfo buscar(t ipoElemento v) ; void insertar(tipoE1emento v, t i p o I n f o i n f o ) ; t i p o I n f o Dicc: :buscar(tipoElemento v) / / Secuencia1 1; { i n t x = N+1; a[O].clave = v; a[O].info = infoNIL; while (v != a[--x].clave) ; r e t u r n a[x] . i n f o ;
  • 236. 216 ALGORITMOS EN C++ 1 void Dicc: :insertar(tipoElemento v, tipoInfo info) { a[++N].clave = v; a[N].info = info; } Ésta es una implementación de un tipo de datos de diccionario donde las claves (cl ave)se utilizan para almacenar y recuperar la «informaciónasociada) (i nfo). Al igual que en la ordenación, a veces será necesario ampliar los programas para manipular registros y claves más complicadas, pero esto no implica cambios fundamentales en los algoritmos. Por ejemplo, si tipoElemento fuera char* y se sobrecargara al operador != para hacer strcmp convertiría al programa an- terior en un paquete que utilizaría como claves a cadenas de caracteresen lugar de enteros. O info podría ser un puntero a una estructura de registro más com- pleja. Así, este campo puede servir como identificador único del registro para distinguir entre registros con claves iguales. Aquí, buscar devuelve el campo info del primer registro encontrado que tenga la clave en cuestión (i nfoNIL si no existe tal registro). Se utiliza un registro centinela en el que su campo cl ave se inicializa con el valor a buscar para garantizar que la búsqueda siempre terminará y por lo tanto el bucle interno se podrá escribir con solamente una comprobación de termi- nación. Al campo info de este registro centinela se le asigna el valor infoN IL de manera que sea éste el valor devuelto cuando ningún registro tenga la clave dada. Esta técnica es análoga a la del registro centinela que contiene el valor máximo o mínimo de una clave, que se utiliza para simplificar la escritura de vanos algoritmos de ordenación. Propiedad 14.1 La búsqueda secuencial (implernentaciónpor array) utiliza (siempre) N + 1 comparacionespara una búsqueda sin éxito y alrededorde N/2 comparaciones (por término medio)para una búsqueda con éxito. Para una búsqueda sin éxito, esta propiedad se deduce directamente del pro- grama: se debe examinar cada registro para decidir si una clave en particular está ausente. Para una búsqueda con éxito, si se supone que todos los registros tienen la misma probabilidad de ser el buscado, el número medio de compara- ciones es (1 + 2 + ... + N)/N = (N+ 1)/2, exactamente la mitad del coste de una búsqueda infructuosa.i Es obvio que la búsqueda secuencialse puede adaptar de manera natural para utilizar una representación de los registros mediante una lista enlazada: class Dicc private: struct nodo { { tipoElemento clave; tipoInfo info; struct nodo *siguiente;
  • 237. MÉTODOSDE BÚSQUEDA ELEMENTALES 217 nodo(tipoE1emento k, tipoInfo i, struct nodo *n) { clave = k; info = i; siguiente = n; }; j ; struct nodo *cabeza, *z; Dicc(i nt max) public: z = new nodo(elementoMAX, infoNIL, O); cabeza = new nodo(0, O, z); { 1 -Dice() ; tipoInfo buscar(tipoE1emento v) ; void insertar(tipoE1emento v, tipoInfo info); Como es habitual en las listas enlazadas, un nodo cabecera ficticio cabeza y un nodo cola z permiten simplificar el código. Se ha pasado al estilo de utilizar un constructor para hacer más conveniente la operación de llenar los campos de los nodos a la vez que se van creando. La búsqueda implica un trabajo más creativo que el desarrollado en los primeros capítulos. Una razón para utilizar una lista enlazada es que es fácil mantener la lista ordenada (severá posteriormente).Esto hace la búsqueda más eficaz: puesto que la lista está ordenada, cada búsqueda puede terminar cuando se encuentre un registro con una clave no menor que la clave de búsqueda. tipoInfo D struct while return { 1 cc::buscar(tipoElemento v) //Lista ordenada nodo *t = cabeza; v > t->clave) t = t->siguiente; (v = t->clave) ? t->info : z->info; Es fácil mantener el orden insertando cada nuevo registro en el lugar donde ter- mina la búsqueda sin éxito: void Dicc::insertar(tipoElemento v, tipoInfo info) struct nodo *x, ft = cabeza; while (v > t->siguiente->clave) t = t->siguiente; x = new nodo(v, info, t->siguiente); t->siguiente = x; { }
  • 238. 218 ALGORITMOS EN C++ Este programa es una implementación alternativa del mismo tipo de datos abs- tracto de la implementación por array anterior. Las dos versiones permiten la inserción, la búsqueda y la inicialización. Se continuará la programación de al- gontmos de búsqueda de esta manera, añadiendo otras funciones cuando sea apropiado. Por otra parte, las implementaciones podrán utilizarse de forma equivalente en las aplicaciones,diferenciándosesolamente (es de esperar)en las necesidades de tiempo y espacio. Por ejemplo, sería trivial añadir una función ordenar a esta implementación por lista enlazada, pero para añadir ordenar a la implementación por array anterior habría que reprogramar alguno de los mé- todos de los capítulos 8 al 12. Propiedad 14.2 Una búsqueda secuencia1(en una implementaciónpor lista or- denada) utiliza alrededor de N/2 comparaciones (por término medio) para las dos búsquedas (con éxito o sin éo. Para la búsqueda con éxito, la situación es la misma que antes. Para la bús- queda sin éxito, si se supone que existe la misma probabilidad de que la bús- queda acabe en el nodo terminal z o en cualquiera de los elementos de la lista (que es el caso de un cierto número de modelos de búsqueda «aleatona)), en- tonces el número medio de comparaciones es el mismo que el de una búsqueda con éxito en una tabla de tamaño N + I, o sea (N+ 2)/2.0 Se podría también desarrollar fácilmente una implementación por dista des- ordenada) para la búsqueda secuencial,con característicassimilaresa la de la im- plementación por array. Por ejemplo, si las búsquedas son relativamente poco frecuentes, entonces el tiempo constante de la inserción puede ser una ventaja. Si se conoce algo sobre la frecuencia relativa de acceso de diferentes regis- tros, se pueden lograr mejoras sustancialessimplemente ordenando los registros inteligentemente. La ubicación «óptima» consisteen poner el registro de acceso más frecuente en el comienzo, el segundo de acceso más frecuente en la se- gunda posición, etc. Esta técnica puede ser muy eficaz, en especial si sólo se consulta frecuentemente un pequeño conjunto de registros. Si no hay información disponible sobre la frecuencia de acceso, entonces se puede lograr una aproximación a la ubicación óptima con una búsqueda «au- toorganizada)):cada vez que se acceda a un registro se le coloca al principio de la lista. Este método es más conveniente de implementar cuando se utiliza una lista enlazada. Por supuesto el tiempo de ejecución depende de las distribucio- nes de acceso a los registros; así pues, es dificil predecir el comportamiento ge- neral del método. Pero esto es eficaz en la situación usual en la que se accede de manera repetitiva a muchos registros que están próximos entre sí. Búsqueda binaria Si el conjunto de registros es grande, entonces el tiempo total de búsqueda se puede reducir significativamenteutilizando un procedimiento de búsqueda ba-
  • 239. MÉTODOSDE BÚSQUEDA ELEMENTALES 219 sado en la aplicación del paradigma de «divide y vencerás»: se divide el con- junto de registros en dos partes, se determina a cuál de las dos partes debe per- tenecer la clave buscada, y a continuación se repite el proceso en esa parte. Una forma razonable de dividir en partes el conjunto de registros consiste en man- tener los registros ordenados y después utilizar los índices del array ordenado para delimitar la parte del array sobre la que se va a trabajar: t i p o I n f o Dicc: :buscar(tipoElemento v) //Búsqueda b i n a r i a i n t i z q = 1; i n t der = N; i n t x; while (der >= izq) { x = ( i z q + der)/2; i f (v == a[x] .clave) r e t u r n a[x] .info; i f (v < a[x]clave) der = x-izq; else i z q = x+izq; { 1; r e t u r n infoNIL; 1 Para averiguar si una clave dada v está en la tabla, primero se le compara con el elemento de la posición intermedia de la tabla. Si v es menor, entonces debe estar en la primera mitad de la tabla; si v es mayor, entonces debe estar en la segunda mitad de la tabla. A continuación se aplica esta técnica recursiva- mente. Puesto que sólo interviene una llamada recursiva, es más simple expre- sar el método iterativamente. Al igual que en el Quicksort y la ordenación por intercambio de residuos, este método utiliza los punteros izq y der para delimitar el subarchivo sobre el que se está trabajando. Si este subarchivo llega a estar vacío, entonces la bús- queda resultará infructuosa. En otro caso la variable x se fija con el valor del punto medio del intervalo, existiendo tres posibilidades: o se encuentra un re- gistro con la clave dada, o bien el puntero izquierdo se cambia a x+i zq, o bien el puntero derecho se cambia a x - izq, según que el valor v buscado sea igual, menor o mayor que el valor de la clave del registro almacenado en a[XI. La Figura 14.1 muestra los subarchivos examinadospor este método cuando se busca O en una tabla construida insertando las claves E J E M P L O D E E U S Q U E D A. El tamaño del intervalo se reduce a la mitad en cada paso, de tal modo que sólo se utilizan cuatro comparaciones en la búsqueda. La Figura 14.2 muestra un ejemplo mayor, con 95 registros; aquí a lo sumo se requieren siete comparaciones para cualquier búsqueda. Propiedad 14.3 La búsqueda binaria nunca utiliza más de lgN+1 comparacio- nes para cada búsqueda (con éxito o sin ér).
  • 240. 220 ALGORITMOS EN C++ Figura 14.1 Búsqueda binaria. Esto se deduce del hecho de que el tamaño del subarchivo se reduce al menos a la mitad en cada paso: una cota superior del número de comparaciones satisface la recurrencia C, = C,,, + 1 con C, = 1, lo que implica el resultado indicado (fórmula 2 del Capítulo 6).= Es importante notar que en el caso de búsqueda binaria el tiempo que se necesita para insertar nuevos registros es elevado: el array debe mantenerse or- denado, de modo que algunos registros deberán moverse para dejar sitio a uno nuevo. Si un nuevo registro tiene una clave inferior a la de cualquier registro de la tabla, entonces cada uno de ellos debe moverse una posición. Una inserción aleatona requiere que se muevan N/2 registros por término medio. Por ello este método no debe utilizarse en aplicaciones que impliquen muchas inserciones. Este método constituye la mejor elección en situaciones en las que la tabla se puede «construin>de una vez desde el principio, quizás por medio de un mé- todo de ordenación como el de Shell o el Quicksort, y utilizarse después para an gran número de búsquedas (muy eficaces). La búsqueda con éxito para el info asociado con una clave v, presente múl- tiples veces, terminará en algún lugar dentro de un bloque contiguo de registros que tienen la clave v. Si la aplicación necesita acceder a todos estos registros, se pueden encontrar recorriendo ambas direcciones a partir del punto en el que se terminó la búsqueda. Una técnica similar se puede utilizar para resolver el pro- blema más general de encontrar todos los registros cuyas claves están dentro de un determinado intervalo. La secuencia de comparaciones realizadas por el algoritmo de búsqueda bí- nana es predeterminada: la secuencia específica depende del valor de la clave que se está buscando y del valor de N. La estructura de comparación se puede describir de forma sencilla mediante una estructura de árbol binario. La Figura 14.3 muestra 1%estructura de las comparaciones para el ejemplo anterior del conjunto de claves. Por ejemplo, al buscar un registro con la clave O, primero
  • 241. METODOS DE BÚSQUEDA ELEMENTALES 221 IIIIIuIIIII Figura 14.2 Búsquedabinaria en un archivo muy grande. Figura 14.3 Árbol de comparacionespara la búsquedabinaria.
  • 242. 222 ALGORITMOS EN C++ Figura 14.4 Búsqueda por interpolación. se compara con J. Puesto que O es mayor, a continuación se compara con P (en el caso contrario se habría comparado con D), luego se compara con M y el al- gontmo termina, con éxito, en la cuarta comparación. Más adelante se verán algoritmos que utilizan un árbol binario construido explícitamente para guiar la búsqueda. Una posible mejora de la búsqueda binaria consiste en tratar de acertar en qué parte del intervalo está la clave que se está buscando (mejor que utilizar ciegamente la mitad del intervalo en cada paso). Esta técnica imita la forma como se busca un número en una guía telefónica. Por ejemplo: si el nombre comienza con B se mira cerca del principio, pero si comienza con Y , se mira cerca del final. Este método, denominado búsqueda por interpolación, re- quiere sólo una simple modificación del programa precedente. En él, el nuevo punto de partida de Ia búsqueda (el punto medio del intervalo) se calcula por medio de la sentencia x = ( izq + der)/2,que se deduce de la expresión 1 2 x = izq +-(der - izq). El punto medio del intervalose calcula añadiendo la mitad de su tamaño al punto izquierdodel mismo. En la búsqueda por interpolación simplemente se sustituye la fracción 1/2 de esta fórmula por una estimación de donde puede encontrarse la clave, sobre la base de los valores disponibles: el 1/2 sería apropiado si v estu- viera en la mitad del intervalo entre a [izq] .cl ave y a [der] .cl ave, pero x=izq+ (v-aiizq] .clave)* (der-izq)/(a[der] . clave-a[izq] . clave) podría ser una estimación mejor (si las claves son numéricas y están uniforme- mente distribuidas). Suponiendo en el ejemplo que la i-ésimaletra del alfabeto se representa por el número i.Entonces en la búsqueda de O, la primera posición a examinar en la tabla sena la 12,puesto que 1 + (15 - 1)*(17 - 1)/(21 - 1) = 12,2 ... La bús- queda se completa en un solo paso (12 es la posición de O en la clave). Incluso tornando para la primera posición a examinar el valor encontrado por exceso (13: que corresponde a P), la búsqueda se completaría en dos pasos. El primer y el último elementos se localizan en el primer paso. La Figura 14.5 muestra la búsqueda por interpolación en el archivo de 95 elementos de la Figura 14.2;di- cha búsqueda utiliza solamente cuatro comparaciones, cuando la binaria nece- sita siete.
  • 243. MÉTODOSDE EÚSQUEDA ELEMENTALES 223 I Figura 14.5 Búsqueda por interpolaciónen un gran archivo. La búsqueda por interpolación utiliza menos de 1glgN+ 1 comparaciones, lo mismo para una búsqueda con éxito que para una infructuosa, en archivos con clavesaleatorias. La demostración de este hecho rebasa el alcance de este libro. Esta función tiene un crecimiento muy lento que se puede considerar como una constante para propósitos prácticos: si iV es mil millones, entonces lglgN < 5. De este modo se puede encontrar cualquier registro utilizando sólo unos pocos accesos (por tér- mino medio), lo que representa una mejora sustancial con relación a la bús- queda binaria.i Sin embargo, la búsqueda por interpolación depende fuertemente de la su- posición de que las claves están bien distribuidas en el intervalo, por lo que a esta técnica la puede «enganan>una distribución poco uniforme de las claves, lo que es frecuente en la práctica. Además este método requiere algunos cálcu- los: para un N pequefio, el coste de IgN de la búsqueda binaria directa es bas- tante próximo a lglgN, por lo que no merece la pena pagar tanto por la inter- polación. Por el contrario, la búsqueda por interpolación debe tenerse en cuenta para el caso de archivos grandes, en aplicaciones donde las comparaciones son muy costosas o en métodos externos que implican costes muy a1tos.i Búsqueda por árbol binario La búsqueda por árbol binario es un método simple y eficaz de búsqueda di- námica, que está calificadocomo uno de los aigoritmos fundamentales de la in-
  • 244. 224 ALGORITMOS EN C++ Figura 14.6 Un árbol binario de búsqueda. formática. Se presenta entre los métodos «elementales» por lo simple que es; pero de hecho es el método elegido en muchas situaciones. En el Capítulo 4 se estudiaron con detalle los árboles, y de él se recuerda ahora la terminología: la propiedad característica de un árbol es que sobre cada nodo apunta otro único nodo denominado su padre, y la propiedad caracterís- tica de un árbol binario es que cada nodo tiene dos enlaces (apunta a dos no- dos), uno izquierdo y otro derecho. Para su empleo en la búsqueda, cada nodo tiene también un registro con un valor clave. En un árbol binario de búsqueda se impone que todos los registros con las claves más pequeñas están en el sub- árbol izquierdo y que todos los registros del subárbol derecho tienen valores de clave mayores o iguales. Pronto se verá que es muy fácil garantizar que los ár- boles binarios de búsqueda construidos por inserciones sucesivasde nuevos no- dos satisfagantambién esta propiedad de definición. En la Figura 14.6 se mues- tra un ejemplo de árbol binano de búsqueda; como es ya usual, los subárboles vacíos se representan por pequeños nodos cuadrados. De esta estructura se desprende inmediatamente un procedimiento de bús- queda binaria. Para encontrar un registro con una clave dada v, primero se compara ésta con la correspondiente a la raíz. Si es más pequeña, se va al sub- árbol de la izquierda; si es igual, se detiene la búsqueda; si es mayor, se va al subárbol de la derecha. Se aplica este método recursivamente. En cada paso, se tiene la garantía de que ninguna otra parte del árbol que no sea la del subárbol en el que se está situado puede contener registros con la clave v, y, al igual que disminuye el tamaño del intervalo en la búsqueda binana, el ((subárbolactual» es cada vez más pequeño. El procedimiento termina cuando se encuentra un registro con clave v o, si no hay tal registro, cuando el «subárbol actual» llega a estar vacío. Llegados a este punto hay que admitir que las palabras «binaria», «búsqueda» y «árbol» están sobreutilizadas, y el lector debe estar seguro de comprender la diferencia entre la función de búsqueda binaria presentada an- teriormente en este capítulo y los árboles binarios de búsqueda descritos aquí. En una búsqueda binaria, se utiliza un árbol binano para describir la secuencia de comparaciones llevada a cabo por una fuiición que busca en un array; aquí realmente se construye una estructura de datos en forma de árbol, con registros conectados por enlaces, y se utiliza para la búsqueda.
  • 245. MÉTODOS DE BÚSQUEDA ELEMENTALES 225 class Dicc private: { struct nodo { tipoElemento clave; tipoInfo info: struct nodo *izq, *der; nodo(tipoE1emento k, tipoInfo i , struct nodo *izqizq, struct nodo *derder) { clave=k; info=i; izq= izqizq; der=derder; }; 1; struct nodo *cabeza, *z; public: Dicc(int max) {z = new nodo(@, infoNIL, O, O); cabeza = new nodo(elementoMIN, O, O, z); } Dicc() ; tipoInfo buscar(tipoE1emento v); void insertar(tipoE1emento v, tipoInfo info); tipoInfo Dicc::buscar(tipoElemento v) struct nodo *X = cabeza->der; z->clave = v; while (v != x->clave) return x->info; { x = (v < x->clave) ? x->izq : x->der; Es conveniente utilizar un nodo cabeza que sea la cabecera del árbol cuyo en- lace derecho apunte al nodo raíz real del árbol y cuya clave sea inferior a todas las otras. El enlace izquierdo de cabeza no se utiliza. La utilidad de cabeza se verá más clara posteriormente, cuando se presente la inserción. Si un nodo no tiene subárbolizquierdo(derecho)entonces su enlaceizquierdo(derecho)se pone a apuntar al nodo «final» z.Al igual que en la búsqueda secuencial, se coloca en zel valor que se busca, para detener las búsquedas infructuosas. Así, el «sub- árbol actual» sobre el que apunta x nunca estará vacío y todas las búsquedas tendrán «éxito»: la inicialización de z->info a infoNI L servirápara indicar, al devolver este indicador, que una búsqueda no ha tenido éxito de acuerdo con el convenio que se ha venido utilizando. Los programas de este capítulo nunca acceden a los enlacesde z,pero para los programas más avanzados que se verán más adelante es conveniente inicializar los enlaces de z para que apunten al propio z.
  • 246. 226 ALGORITMOS EN C++ cabeza Figura 14.7 Un árbol binario de búsqueda(con nodos ficticios). Como se mostró anteriormente, en la Figura 14.6,es conveniente represen- tar los enlaces que apuntan a zcomo si apuntaran a unos nodos externos ima- ginarios, y que todas las búsquedas sin éxito terminan en nodos externos. Los nodos normales que contienen las claves se denominan nodos internos. Al in- troducir nodos externos se puede afirmar que todo nodo interno apunta a otros dos nodos del árbol, aun cuando en esta implementación todos los nodos exter- nos estén representados por el único nodo z. La Figura 14.7 muestra explícita- mente estos enlacesy los nodos ficticios. La Figura 14.8 muestra lo que sucede cuando se busca D en el árbol ejem- plo, utilizando buscar. Primero, se compara con E, la clave de la raíz. Puesto que D es menor, se va hacia la izquierda y, como el enlace izquierdo del nodo que contiene a E es un puntero a z, la bílsqueda termina: D se compara consigo mismo en zy la búsqueda resulta infructuosa. Figura 14.8 Búsqueda(de D) en un árbol Sinario de búsqueda
  • 247. MÉTODOSDE BÚSQUEDA ELEMENTALES 227 Figura 14.9 Inserción(de D) en un árbol binario de búsqueda. Para insertar un nodo en el árbol, se efectúa una búsqueda infructuosa de su clave y a continuación se agrega el nuevo nodo en lugar de z en el punto donde se terminó la búsqueda. Para hacer la inserción, el siguiente programa sigue la pista del padre p de x a medida que se desciende por el árbol. Cuando se alcanza el fondo del árbol (x == z),p apunta al nodo cuyo enlace debe cambiarse para apuntar al nuevo nodo insertado. void Dicc: :insertar(tipoElemento v , tipoInfo info) i struct nodo *p, *x; p = cabeza; x = cabeza->der; while ( x != z) x = new nodo(v, info, z, z); if (v < p->clave) p->izq = x; else p->der = x; { p = x; x = (v < x->clave) ? x->izq : x->der; >; 1 En esta implementación, cuando se inserta un nuevo nodo cuya clave es igual a alguna de las que ya existen en el árbol, se insertará a la derecha del nodo que ya estaba en el árbol. Esto significa que se pueden encontrar los nodos con cla- ves iguales si simplemente se continúa la búsqueda a partir del punto en eI que buscar terminó, hasta que se encuentre z. El árbol de la Figura 14.9 se obtiene al insertar las claves E J E M P L O D en un árbol que inicialmente está vacío. La Figura 14.10 muestra el proceso completo del ejemplo cuando se añaden E B U S Q U E I )A. El lector debe prestar particular atención a la posición de las claves iguales de este árbol: por ejemplo, aun cuando las E parecen muy alejadas en el árbol, no hay claves «en- tre» ellas. La función ordenar se obtiene prácticamente de forma gratuita cuando se utiliza un árbol binario de búsqueda?puesto que esta estructura representa a un archivo ordenado, si se mira en el sentido correcto. En las figuras: las claves
  • 248. 228 ALGORITMOS EN C++ Figure 14.10 Construcción de un árbol binario de búsqueda. aparecen en orden si se leen de izquierda a derecha de la página (ignorando la altura y los enlaces).Un programa tiene solamentelos enlacespara operar, pero el método de ordenación se obtiene directamente de las propiedades que defi- nen a los árboles binarios de búsqueda. Así se define un método de ordenación que es notablemente parecido al Quicksort, con el nodo raíz del árbol desem-
  • 249. MÉTODOC DE BÚSQUEDA ELEMENTALES 229 peñando un papel similar al del elemento de partición de la ordenación rápida (no hay claves mayores a la izquierda, no hay claves menores a la derecha). Concretamente, la tarea la llevará a cabo el recomdo recursivo en orden simé- trico del Capítulo 5. En este caso se añadiría la operación de clase D icc : :recorrer ( ) que simplemente llama a D icc ::enorden(cabeza->der) donde enorden(struct nodo *x) es la rutina básica del Capítulo 5 (cambiada de nombre) tal que si x no es z, se llama a sí misma con el argumento x->i zq, luego llama a v i sitar (x),luego se llama a si misma con argumento x->der. Así, por ejemplo, si se implementaüi cc ::v i s i tar (struct nodo *x) para im- primir el campo clave de x, una llamada a recorrer imprimiría el árbol entero de forma ordenada. O, como se verá en el Capítulo 27, un v i sitar más intrin- cado puede llevar a un algoritmo más complicado. Los tiempos de ejecución de los algoritmos de árbolesbinarios de búsqueda dependen mucho de la forma de los árboles. En el mejor de los casos, el árbol puede aparecer como el de la Figura 14.3,con aproximadamente1gNnodos en- tre la raíz y cada nodo externo. Se puede aspirar a tiempos de búsqueda de or- den logarítmico como promedio porque el primer elemento insertado se con- vierte en la raíz del árbol; si se insertan aleatoriamente N claves, entonces este elemento deberá dividir a las claves en dos partes iguales (de media), y esto pro- duciría tiemposde búsqueda logarítmicos(utilizandoel mismo argumentopara cada subárbol). En efecto, haciendo abstracción de las claves iguales, podría ocumr que se produjera un árbol como el dado anteriormente para describir la estructura de comparación de una búsqueda binaria. Éste sería el mejor caso para el algoritmo, que garantizaría tiemposde ejecuciónlogarítmicospara todas las búsquedas. De hecho, en una situación verdaderamente aleatoria, la raíz tiene las mismas posibilidades de ser cualquier clave, así que un árbol perfectamente equilibrado es muy raro. Pero si se insertan claves aleatorias se pueden obtener árbolesbastante bien equilibrados. Propiedad 14.5 Una búsqueda o inserción en un árbol binario de búsqueda re- quiere alrededor de 21nN comparaciones,por término medio, en un árbol cons- truido a partir de N claves aleatorias. Para cada nodo del árbol, el número de comparaciones realizadaspara una bús- queda con éxito de dicho nodo es su distancia a la raíz. La suma de estas dis- tancias para todos los nodos se denomina la longitud del camino interno del ár- bol. Dividiendo la longitud del camino interno por N se obtiene el número medio de comparaciones de una búsqueda con éxito. Pero si CNrepresenta la longitud media del camino interno de un árbol binario de búsqueda de N nodos, se tiene la relación de recurrencia con CI= 1. (El término N - 1 tiene en cuenta el hecho de que la raíz contribuye
  • 250. 230 ALGORITMOS EN C++ Figura 14.11 Un gran árbol binario de búsqueda. con 1 a la longitud del camino para cada uno de los restantes N - 1 nodos del árbol; el resto de la expresión proviene de observar que la clave de la raíz (la primera insertada) es como si fuera la k-ésima más grande, dejando subárboles aleatorios de tamaño k-1 y N-k.) Pero esto está muy próximo a la relación de recurrencia que se resolvió en el Capítulo 9 para el Quicksort y se puede resol- ver de la misma manera para obtener el resultado esperado. El razonamiento para la búsqueda sin éxito es similar, aunque un tanto más comp1icado.i La Figura 14.1I muestra un gran árbol binario de búsqueda construido a partir de una permutación aleatoria de 95 elementos. Aun cuando tiene algunos caminos cortos y algunos largos, se puede decir que está bastante bien equili- brado: cualquier búsqueda necesitará menos de doce comparaciones, y el nú- mero «medio» de ellas para encontrar cualquier clave del árbol es 7,00,contra 5,74 de la búsqueda binaria. (El número medio de comparaciones para una búsqueda aleatoria sin éxito es uno más que para la búsqueda con él.)Más aún, se puede insertar una nueva clave por el mismo coste, flexibilidadde la que no se dispone en la búsqueda binaria. Sin embargo, si las claves no están aíeatoria- mente ordenadas el algoritmo puede tener un mal comportamiento. Propiedad 14.6 En el peor caso, una búsqueda en un árbol binario de bús- queda con N claves puede necesitar N Comparaciones. Por ejemplo, cuando las clavesse insertan en orden (o en orden inverso), el mé- todo de búsqueda por árbol binario no es mejor que el de búsqueda secuencia1 descrito al principio de este capítulo. Más aún, hay muchos otros tipos de ár- boles degenerados que pueden conducir al mismo peor caso (considéresepor ejemplo el árbol formado cuando las claves A Z B Y C X ... se insertan en este orden en un árbol inicialmente vacío). En el próximo capítulo se examinará una técnica para eliminar este peor caso y hacer que todos los árboles se parezcan al del caso mej0r.m
  • 251. MÉTODOS DE BÚSQUEDA ELEMENTALES 231 Figura 14.12 Eliminado(de E) de un árbol binario de búsqueda. Eliminación Las implementaciones precedentes que utilizan estructuras de árboles binarios para las funciones fundamentales buscar, insertar y ordenar son bastante direc- tas. No obstante, los árboles binarios son un buen ejemplo de un tema recu- rrente en los algontmos de búsqueda: la función eliminar, que a menudo es bas- tante incómoda de implementar. Considérese el árbol que se muestra a la izquierda de la Figura 14.12:eli- minar un nodo es fácil si no tiene hijos, como L o P (se «podan» haciendo nulo el correspondiente enlace con su padre); si tiene solamente un hijo, como A, H o R (se desplaza el enlace del hijo al enlace apropiado del padre); o incluso si uno de sus dos hijos no tiene hijos, como N (se utiliza el nodo hijo para reem- plazar ai padre); pero ¿qué hacer con los nodos más altos del árbol, tales como E? La Figura 14.12muestra una forma de eliminar E: se reemplaza por el nodo que tenga la clave superior más próxima (H en este caso). Este nodo forzosa- mente tiene como mucho un hijo (puesto que no hay nodos entre él y el nodo eliminado, su enlace izquierdo debe ser nulo), y se puede suprimir fácilmente. Así, para quitar E del árbol de la izquierda de la Figura 14.12. se hace apuntar el enlace izquierdo de R al enlace derecho (N) de H, se copian los enlaces del nodo que contiene a E en el que contiene a H, y se hace apuntar cabeza-> der a H. Esto proporciona el árbol de la derecha de la figura. El programa que permite tratar todos estos casos es bastante más complejo que las simplesrutinas de búsqueda e inserción, pero merece la pena estudiarlo cuidadosamente para preparar las manipulaciones más complejas que se reali- zarán en el próximo capítulo. El siguiente procedimiento elimina el primer nodo de clave v que encuentre en el árbol. (Otra posibilidad es utilizar in f o para identificar el nodo a eliminar.) La variable p se utiliza para seguir la pista del padre de x en el árbol y la variable c se utiliza para encontrar al sucesor del nodo que se va a eliminar. Después de la operación de eliminación, x es el hijo de p.
  • 252. 232 ALGORITMOS EN C++ void Dicc: :suprimir(tipoElemento v) struct nodo *c, *p, *x, *t; z->clave = v; p = cabeza; x = cabeza->der; while (v != x->clave) t = x; if (t->der == z) x = x->izq; else if (t->der->izq == z) { x=x->der; x->izq=t->izq;} el se { {p = x; x = (v < x->clave) ? x->izq : x->der; } c = x->der; while (c->izq->izq != z) c = c->izq; x = c->izq; c->izq = x->der; x->izq = t->izq; x->der = t->der; { { delete t; if (v < p->clave) p->izq = x; else p-der = x; } En primer lugar el programa hace una búsqueda en el árbol de forma normal para encontrar el emplazamiento de t en el mismo. (Realmente, el objetivo principal de esta búsqueda es enlazar p con otro nodo una vez que se haya eli- minado t.) A continuación el programa verifica tres casos: si t no tiene hijo derecho, entonces el hijo de p después de la eliminación será el hijo izquierdo de t (éste sena el caso de C, L, M, P, y R en la Figura 14.12); si t tiene un hijo derecho que no tiene hijo izquierdo, entonces ese hijo derecho será el hijo de p después de la supresión, con su enlace izquierdo copiado de t (éste sena el caso de A y N en la Figura 14.12); en caso contrario, se pone a x a apuntar al nodo con la clave más pequeña del subárbol de la derecha de t;el enlace derecho de este nodo se copia en el enlace izquierdo de su padre y sus dos enlaces se ponen a partir de t (éste sena el caso de H y E en la Figura 14.12). Para limitar el nú- mero de casos el programa siempre elimina mirando hacia la derecha, aunque en algunos casos podría ser más fácil en algunos casos mirar a la izquierda (por ejemplo, para eliminar a H en la Figura 14.12). Esta solución puede parecer asimétrica y bastante ad hoc:por ejemplo, ¿por qué no utilizar la clave inmediatamente anterior a la que se va a eliminar, en lugarde una posterior?Se han sugerido varias modificacionessimilares,pero las diferencias no son tan notables como para que puedan apreciarse en aplicacio- nes prácticas, aunque se ha demostrado que el algoritmo anterior tiende a dejar el árbol ligeramente desequilibrado (con altura media proporcional a p) si se le somete a un gran número de pares aleatorios de eliminar-insertar. Es bastante comente que los algoritmos de búsqueda necesiten implemen-
  • 253. MÉTODOS DE BÚSQUEDA ELEMENTALES 233 taciones de la operación de eliminado significativamentecomplejas: las claves tienden por sí mismas a formar parte integral de la estructura por lo que elimi- nar una de ellas puede implicar reparaciones complicadas. Una alternativa, a menudo satisfactoria,es la denominada eliminaciónperezosa, en la que el nodo a eliminar se deja en la estructura, pero se marca como «eliminado» para la búsqueda. Esto se obtiene en el programa anterior añadiendo una verificación adicional para detectar tales nodos antes de terminar la búsqueda. Se debe estar segurode que grandescantidadesde nodos «eliminados»no conducen a un gasto excesivode tiempo o espacio,pero esto es un problema menor en muchas apli- caciones. De modo alternativo, se puede reconstruir periódicamente toda la es- tructura de datos, excluyendo los nodos «eliminados»'. Árboles binarios de búsqueda indirecta Como se vio en el Capítulo 11, en muchas aplicaciones se desea obtener una estructura de búsqueda que permita encontrar los registros sin tenerles que des- plazar. Por ejemplo, se puede tener un array de registros con claves y se puede desear que la rutina buscar dé el índice en el array del registro que corresponde con una cierta clave. O se pudiera desear suprimir de la estructura de búsqueda un registro con un índice dado, pero manteniéndolo dentro del array para algún otro uso. Para adaptar los árboles binarios de búsqueda a tales situaciones, sirnple- mente se transforma el campo i nfo de los nodos en el índice del array. Enton- ces es posible eliminar el campo cl ave haciendo que las rutinas accedan a las claves de los registros directamente, por ejemplo por medio de una instruccidn como i f (v < a [x- >i nf o] ) ... Sin embargo, a menudo es mejor hacer una copia extra de las claves y utilizar el código anterior tal cual. Esto implica utili- zar una copia suplementaria de las claves (una en el array, otra en el árbol), pero permite que se utilice la misma función para más de un array o, como se verá en el Capítulo 27, para más de un campo clave en el mismo array. (Existenotras vías de lograr esto: por ejemplo, podría asociarse un procedimiento a cada árbol para extraer las claves de los registros.) Otra forma directa de lograr la «indirección» para los árboles binarios de búsqueda consiste simplemente en suprimir la implementación enlazada y uti- lizar una representación por array directo, como la que se presentó en el Capí- tulo 3. Todos los enlaces se convierten en índices dentro de un array a [O] , ., .,a [N+l] de registros que contienen un campo cl ave y campos índices i zq y der. Entonces las referencias a enlaces como x->cl ave y x = x->i zq pasan a ser referencias a array tales como a [XI.cl ave y x = a[x] .izq. No se utilizan llamadas a new, puesto que el árbol existe dentro del array de registros: los no- ' De aquí el que el término también signifique borrado retardado, porque la acción física 1 : borrar se posterga (sedeja para después) o no se hace nunca. (N.del T.)
  • 254. 234 ALGORITMOS EN C++ dos ficticios se asignan colocando cabeza = O y z = 1 y el constructor incre- menta simplementeun puntero al próximo espacio libre dentro del array de re- gistros y rellena los campos. Esta forma de implementar árboles binarios de búsqueda para facilitar las búsquedas en grandes arrays de registros es preferible en muchas aplicaciones, puesto que evita el gasto extra de copiar las clavesdescrito en el párrafo antenor y evita la sobrecarga del mecanismo de asignación de memona que implica new. Su inconveniente es que los enlaces no utilizados pueden gastar espacio en el array de registros. Una tercera alternativa es utilizar arrays paralelos, como se hizo en el Ca- pítulo 3 para las listas enlazadas. La implementación correspondiente es muy parecida a la descrita en el párrafo anterior, excepto que se utilizan tres arrays, uno para las claves, otro para los enlaces izquierdos y otro para los enlaces de- rechos. La ventaja de este método es la flexibilidad. Se pueden añadir fácil- mente nuevos arrays (para la información extra asociada con cada nodo), sin modificar en nada el código de manipulación de los árboles, y cuando una ru- tina de búsqueda proporciona un índice de un nodo, está dando también una forma inmediata de acceder a todos los arrays. Ejercicios 1. Implementar un algoritmo de búsqueda secuencia1 con una media de N/2 pasos para una búsqueda cualquiera, con éxito o sin él, mantenkmlo los registrosen un array ordenado. 2. Indicar el orden en que quedan las claves después de insertar los registros con las claves C U E S T I O N F A C I L en una tabla (inicialmente vacía), mediante las operaciones de buscar e insertar y utilizando una heurística de búsqueda autoorganizada. 3. Dar una implementación recursiva de la búsqueda binaria. 4. Suponiendo que a [i] == 2*i, para 1 <= i <= N. ;Cuántas entradas de la tabla se examinarán por la búsqueda por interpoiación durante una bús- queda sin éxito de 2k - I ? 5. Dibujar el árbol binario de búsqueda que resulta de insertar en un árbol inicialmente vacío los registrosde claves C U E S T I O N F A C I L. 6. Escribir un programa recursivo para calcular la altura de un árbol binario: la distancia más larga entre la raíz y un nodo externo. 7. Suponiendo que se dispone de una estimación provisional de la frecuencia con la que las claves de búsqueda acceden a un árbol binano. ¿Deberán in- sertarselas claves en orden creciente o decrecientede dicha frecuencia? ¿Por qué? 8. Modificar un árbol binario de búsqueda de modo que se mantengan juntas en el árbol las claves iguales. (Sivarios nodos del árbol tienen la misma clave
  • 255. MÉTODOS DE BÚSQUEDA ELEMENTALES 235 que un nodo dado, entonces o su padre o alguno de sus hijos debe tener la misma clave que éste.) 9. Escribir un programa no recursivo para imprimir en orden las claves de un árbol binario de búsqueda. 10. Dibujar el árbol binano de búsqueda que resulte de insertar en un árbol, inicialmente vacío, registros con las claves C U E S T I O N F A C I L, suprimiendo a continuación T.
  • 257. 15 Árboles equilibrados Los algoritmos de árboles binarios del capítulo anterior son muy útiles en un gran número de aplicaciones, pero tienen el problema de dar un mal rendi- miento en el peor caso. Al igual que con el Quicksort,desgraciadamentees cierto que estos casos tienen tendencia a ocurrir en la práctica si el usuario del algo- ritmo no se preocupa de ello. El algoritmo de búsqueda de un árbol binario puede comportarse muy mal en archivos ya ordenados, o en archivos en orden inverso, o en archivos que contienen alternativamente claves grandes y peque- ñas, o en archivos de gran sección que tengan una estructura simple. Con el Quicksort el único remedio para mejorar esta situación fue recurrir a la aleatonedad: escogiendo un elemento que provoque una partición aleato- ria, se podna confiar en la ley de las probabilidades para eliminar el peor caso. Afortunadamente, en la búsqueda en árbolesbinarios es posible hacerlo mucho mejor, pues hay una técnica general que permite garantizar que el peor caso no ocumrá. Esta técnica, a la que se le llama equilibrar,ha sido utilizada como base de varios algoritmos diferentes de «árboles equilibrados». A continuación se es- tudiará con detalle uno de estos algoritmos,presentándose brevemente la forma como se relaciona con los otros métodos que se han utilizado. Como se verá, la implementación de algoritmos de «árboles equilibrados)) es seguramente un caso de «más fácil de decir que de hacen). A menudo es fácil escribir el concepto general que hay detrás del algoritmo, pero la implementa- ción es un conglomerado de casos particulares y simétricos. El programa que se desarrolla en este capítulo no es solamente un método importante de búsqueda sino que ilustra con precisión la relación entre una descripción de «alto nivel» y un programa de «bajo nivel» en C++, que implementa el algoritmo. Árboles descendentes 2-3-4 Para eliminar el peor caso en los árboles binarios de búsqueda, se necesitará al- guna flexibilidad en la estructura de datos que se va a utilizar. Para obtener esta 237
  • 258. 238 ALGORITMOS EN C++ Figura 15.1 Un árbol 2-3-4. flexibilidad, se supone que los nodos pueden contener más de una clave. Espe- cíficamente se permitirán 3-nodos y I-nodos, que pueden contener dos y tres claves, respectivamente. Un 3-nodo tiene tres enlaces saliendo de él, uno para todos los registros con claves más pequeñas que las suyas, otro para todos los registros con claves que están entre las dos suyas, y otro para todos los registros con claves mayores que las suyas. De forma similar un 4-nodo tiene cuatro en- laces que salen de él, uno para cada uno de los intervalos definidos por sus tres claves. (Los nodos de un árbol binano de búsqueda estándar podrían denomi- narse 2-nodos: una clave, dos enlaces). Más adelante se verin algunas formas eficaces de definir e implementar las operaciones básicas de estos nodos exten- didos; por ahora, supóngase que se pueden manipular convenientemente y ob- sérvese cómo pueden combinarse para formar árboles. Por ejemplo, la Figura 15.1 muestra un árbol 2-3-4 que contiene las claves E J E M P L O D E B U. Es fácil ver cómo se efectúa una búsqueda en este tipo de árbol. Por ejemplo, para buscar S se seguiría el enlace derecho que parte de la raíz, puesto que S es mayor que EM, terminando con una búsqueda sin éxito en el segundo enlace por la derecha del nodo que contiene a O, P y U. Para insertar un nuevo nodo en un árbol 2-3-4, se desearía, como antes, ha- cer una búsqueda infructuosa y después enganchar el nodo. Es fácil ver lo que hay que hacer si el nodo en el que termina la búsqueda es un 2-nodo: simple- mente, transformarlo en un %nodo, añadiéndole el nuevo nodo (y otro enlace). De forma similar, un h o d 0 se puede convertir fácilmente en un 4-nodo. Pero, ¿qué se debe hacer si se desea insertar un nuevo nodo en un 4-nOdO? Por ejem- plo, jcómo se insertaría S en el árbol de la Figura 15.l? Una posibilidad sería engancharlo como un nuevo hijo, el segundo por la derecha del 4-nodo que contiene a O, P y U; pero existe una solución mejor, la que se muestra en la Figura 15.2: se divide el 4-nodo en dos 2-nodos y se pasa una de sus claves a su padre. Primero se divide el 4-nOdO que contiene a O, P y U, en dos 2-nodos (uno que contiene a O y el otro a U) y la clave ((intermedia) P se pasa hacia amba, al nodo que contiene a E y M, convirtiéndolo en un 4-nodo. Así hay espacio para S en el 2-nodo que contiene a U. Pero ¿qué pasa si se divide un 4-nodo cuyo padre es también un 4-nOdO?Un método sena dividir también al padre, pero el abuelo también podría ser un 4- nodo y también el bisabuelo, etc.: se tendría que estar haciendo divisiones de nodos hasta la raíz. Una solución más fácil consiste en asegurarse de que el pa- dre de cualquier nodo que se encuentre no sea un 4-nOd0, lo que se logra divi-
  • 259. ÁRBOLEC EQUILIBRADOS 239 Figura 15.2 Inserción(de S) en un árbol 2-3-4. Figura 15.3 Construcción de un árbol 2-3-4.
  • 260. 240 ALGORITMOS EN C++ Figura 15.4 Divisiónde 4-nodos. diendo todos los 4-nodos del camino de descenso por el árbol. La Figura 15.3 completa la construcción de un árbol 2-3-4 correspondiente al conjunto com- pleto de claves E J E M P L O D E B U S Q U E D A. En la pnmera línea se ve que el nodo raíz se divide durante la inserción de la Q; se producen otras divisionesal insertar la segunda U, la última E y la segunda D. El ejemplo anterior muestra cómo se pueden insertar fácilmente nuevos no- dos en árboles 2-3-4 haciendo una búsqueda y dividiendo los 4-nodos que se encuentran en el descenso por el árbol. De forma más precisa, como se muestra rn la Figura 15.4, cada vez que se encuentre un 2-nodo conectado con un 4- nodo, se debe transformar en un 3-nOdO conectado con dos 2-nodos, y cada vez que se encuentre un h o d 0 conectado con un 4-nOd0, se debe transformar en un h o d 0 conectado a dos 2-nodos. Esta operación de «división» funciona porque se pueden mover no sólo las clavessino también lospunteros. Dos 2-nodos tienen el mismo número de pun- teros (cuatro) que un 4-nod0, así que se puede efectuar la división sin tener que transformar los elementos que están debajo del nodo dividido. Un h o d 0 no puede transformarse en un h o d 0 añadiendo solamente otra clave: se necesita también otro puntero (en este caso el puntero extra liberado por la división). El punto crucial es que estastransformaciones son puramente «locales»:las únicas partes del árbol que se necesita examinar o modificar son las que se muestran en la Figura 15.4.Cada una de las transformaciones transmite hacia arriba una de las claves, desde un 4-nOdO hacia su padre y reestructura los enlaces de acuerdo con ello. Hay que destacar que no es necesario preocuparse por saber si el padre de un nodo es un 4-nod0, puesto que las transformaciones aseguran que cuando se pasa a través de un nodo durante el descenso del árbol, se acaba desembo- cando en un nodo que no es un 4-nOdO. En particular, cuando se alcanza el fondo del árbol no se está en un 4-nOd0, y se puede insertar un nuevo nodo directamente transformando un 2-nodo en un 3-nOdO o un h o d 0 en un 4- nodo. En realidad. es conveniente tratar la inserción como una división de un
  • 261. ÁRBOLES EQUILIBRADOS 241 Figura 15.5 Un árbol 2-3-4 grande. 4-nodo imaginario del fondo del árbol, el cual transmite hacia amba la nueva clave a insertar. Un último detalle: siempre que la raíz del árbol pase a ser un 4-nodo, se le transforma en tres 2-nodos, como se hizo en el ejemplo anterior. Esta técnica es más simple que la alternativa de estar esperandohasta la próxima inserción para hacer la división, porque no es necesario preocuparse por el padre de la raíz. La división de la raíz (y sólo esta operación) hace que el árbol crezca un nivel «más alto». El algoritmo esquematizado anteriormente proporciona un camino para ha- cer búsquedas e inserciones en árboles 2-3-4; puesto que los 4-nodos se dividen en el camino de descenso, los árbolesse denominan árboles 2-3-4 descendentes. Lo interesante es que, aun cuando no ha habido que preocuparse por el equili- brio, los árboles resultantes jestán perfectamente equilibrados! Propiedad 15.1 Las búsquedas en un árbol 2-3-4 de N nodos nunca exploran más de 1gN+ 1 nodos. La distancia de la raíz a cualquier nodo externo es la misma: las transformacio- nes que se llevan a cabo no tienen influencia sobre la distancia entre cualquier nodo y la raíz, excepto cuando se divide la raíz y, en este caso, la distancia entre todos los nodos y la raíz se aumenta en una unidad. Si todos los nodos son 2- nodos, el resultado anunciado se cumple puesto que el árbol es similar a un ár- bol binario completo; si existen 3-nodos o 4-nodos, entonces la. altura del arb01 sólo puede ser infenor.. Propiedad 15.2 Las inserciones en árboles 2-3-4 de N nodos necesitan menos de lg N + 1 divisiones de nodos en el peor caso y parecen necesitar menos de una división por término medio. Lo peor que puede pasar es que todos los nodos en el camino hacia el punto de inserción sean 4-nodos, que sería preciso dividir. Pero en un árbol construido a partir de permutaciones aleatorias de N elementos, no sólo es improbable que suceda el peor caso, sino que también se necesitan pocas divisiones por término medio, porque no hay muchos 4-nodos. La Figura 15.5 muestra un árbol cons- truido a partir de una permutación aleatoria de 95 elementos: hay nueve 4-no- dos y sólo uno de éstos no está situado en ei fondo. Los expertos no han podido
  • 262. 242 ALGORITMOS EN C++ Figura 15.6 Representación rojinegrade 3-nodosy 4-nodos. establecer todavía resultados analíticos del rendimiento medio de los árboles 2- 3-4, pero los estudios empíricos muestran de forma insistente que se necesitan pocas divisiones.. La descripción precedente es suficiente para definir un algoritmo de bús- queda utilizando árboles 2-3-4 que garanticen un buen rendimiento en el peor caso. Sin embargo, sólo se está a mitad de camino de una implementación real. Aunque sea posible escribir algoritmos que realmente lleven a cabo transfor- maciones sobre distintos tipos de datos destinados a representar 2-, 3-, y 4-110- dos, la mayor parte de las acciones a tomar son poco prácticas en esta represen- tación directa. (Para convencerse de esto es suficiente con tratar de implementar incluso la más simple de las transformaciones de dos nodos.) Además, el gasto extra en que se incurre por la manipulación de estructuras de nodos más com- plejas es muy probable que haga a los algoritmos más lentos que la búsqueda estándar por árbol binario. El objetivo principal la acción de equilibrar es ofre- cer un «seguro» contra el peor caso, pero sería penoso tener que pagar el coste adicional de este seguro en cada ejecución del algoritmo. Por fortuna, como se verá más adelante, existe una representación relativamente simple de los 2-, 3- y 4-nodos que permite que las transformaciones se hagan de manera uniforme con un pequeño aumento de coste respecto al de una búsqueda estándar por árbol binario. Árboles rojinegros Curiosamente, es posible representar los árboles 2-3-4como árbolesbinanos es- tándar (2-nodos solamente) utilizando sólo un bit extra por nodo. La idea es representar los 3-nodos y los 4-nodos como pequeños árboles binarios unidos por enlaces «rojos» que contrasten con los enlaces «negros» que ligan a los ár- boles 2-3-4. La representación es simple: como se muestra en la Figura 15.6, los 4-nodos se representan por 2-nodos conectados por un enlace rojo (los enlaces rojos se dibujarán con líneas gruesas). (Cualquier orientación es legal para un 3- nodo.) La Figura 15.7 muestra una forma de representar el último árbol de la Fi-
  • 263. ÁRBOLES EQUILIBRADOS 243 6 h 6 0 6 0 Figura 15.7 Un árbol rojinegro. gura 15.3. Si se eliminan los enlaces rojos y se reúnen los nodos que conectan, el resultado será el árbol 2-3-4 de la Figura 15.3. El bit extra por nodo se utiliza para almacenar el color del enlace que apunta a ese nodo: se hará referencia a los árboles 2-3-4 representados de esta forma con el nombre de árboles rojine- gros. La «inclinación» de cada 3-nodo se determina por la dinámica del algo- ritmo que se describe posteriormente. Existen muchos árboles rojinegros para cada árbol 2-3-4. Sena posible hacer cumplir una regla para que los 3-nodos tengan todos la misma inclinación, pero no hay razón para hacerlo así. Estos árbolestienen muchas propiedades que se obtienen directamente de la forma en la que se han definido. Por ejemplo, nunca hay dos enlaces rojos se- guidos a lo largo de cualquier camino entre la raíz y un nodo terminal, y todos los caminos de este tipo tienen el mismo número de enlaces negros. Es de des- tacar que es posible que un camino (alternando negro-rojo)puede ser dos veces más largo que otro (todo negro), pero las longitudes de los caminos son siempre proporcionales a 100. Una característica sorprendente de la Figura 15.7 es la posición relativa de las claves iguales. Con un poco de reflexión quedará claro que cualquier algo- ritmo de árbol equilibrado debe permitir que los registros con claves iguales a la de un nodo dado se encuentren a ambos lados del nodo: de lo contrario se podría producir un grave desequilibrioque provocaría la inserción de largas ca- denas de claves duplicadas. Esto implica que no es posible encontrar todos los nodos que tengan una clave dada continuando con el procedimiento de bús- queda, como en la búsqueda estándar por árboles binanos. En su lugar, se debe utilizar un procedimiento como el de imprimir un árbol del Capítulo 14,o bien eliminar la posibilidad de que haya claves duplicadas, como se presentó al co- mienzo del Capítulo 14. Una propiedad muy agradable de los árboles rojinegros es que el procedi- miento buscar para la búsqueda estándar por árbolesbinanos se aplica en ellos sin modificaciones (excepto en el caso de las claves duplicadas que se presentó en el párrafo anterior). Se implementan los colores de los enlaces añadiendo un campo b de un bit a cada nodo. Este campo será 1 si el enlace que apunta al
  • 264. 244 ALGORITMOS EN C++ nodo es rojo y O si es negro; el procedimiento buscar nunca examina este campo. De esta manera, el mecanismo de equilibrado no añade ninguna «SO- brecarga» al tiempo empleado por el procedimiento fundamental de búsqueda. Puestoque en una aplicacióntípica cada clave se inserta una sola vez, pero puede buscarse muchas veces, el resultado final será que se ha mejorado el tiempo de bíisqueda (porque los árboles están equilibrados) 2 un coste relativamente pequeño (porque no se ha hecho ninguna acción de equilibrar durante las bús- quedas). Más aún, el sobrecoste de la inserción es muy pequeño: solamente hay que hacer algo diferente al alcanzar un 4-nodo, y no hay muchos 4-nodos en el ár- bol porque siempre se están dividiendo. El lazo interno necesita sólo una com- probación extra (si un nodo tiene dos hijos rojos, es parte de un 4-nOdO), tal y coma se muestra en la siguiente implementación del procedimiento insertar: void Dicc: :insertar(tipoElemento v, tipoInfo info) x = cabeza; p = cabeza; a = cabeza; while (x != z) { ba = a; a = p; p = x; x = (v < x->clave) ? x->izq : x->der; { if (x->izq->b && x->der->b) dividir(v); 1 1 x = new nodo(v, info, 1, z, z); if (v < p->clave) p->izq = x; else p->der = x; dividir(v); cabeza->der->b = negro; 1 Por claridad, se utilizan las constantes rojo = 1 y negro = O, en éste y en los siguientes códigos; por brevedad se comprueba el 1 comprobando un no cero, sin hacer referencia al rojo. En este programa x se mueve hacia abajo por el árbol al igual que antes, y ba, a y p se mantienen como punteros al bisabuelo, al abuelo y al padre de x en eí árbol. Para comprender por qué se necesilan to- dos estos enlaces,se considera la adición de Y al árbol de la Figura 15.7. Cuando se alcanza el nodo externo de la derecha del 3-nOdO que contiene a UU, ba es S,a es IJ y p es U.A-horase debe añadir Y para hacer un h o d 0 que contenga U, U y Y ,resultando el árbol que se muestra en la Figura 15.8. Se necesita un puntero a S (ba)porque su enlace derecho debe cambiar para que apunte a la segunda U y no a la primera. Para ver exactamente cómo su- cede esto. se necesita examinar la operación del procedimiento di vi di r. Con- sidérese la representación rojinegra de las dos transformaciones que se deben llevar a cabo: si se tiene un 2-nodo conectado con un 4-nOd0, entonces hay que convertirlos en un h o d 0 conectado con dos 2-nodos; si se tiene un h o d 0 co- nectddo a un h o d 0 hay que convertirlos en un 4-nOdO conectado a dos 2-no-
  • 265. ÁRBOLES EQUILIBRADOS 245 Figura 15.8 Inserción(de Y) en un árbol rojinegro. dos. Cuando se añade un nuevo nodo en la parte inferior del árbol, se le consi- dera como el nodo intermedio de un 4-nodo imaginario (esto es, imaginando que z es tojo, aunque por ello nunca se le compruebe explícitamente). La transformación necesaria cuando se encuentra un 2-nodo conectado a un 4-nOdO es fácil y se aplica también si se tiene un %nodo conectado a un 4-nOdO de forma «correcta», como se muestra en la Figura 15.9. Así, d i v i d i r co- mienza marcando a x como rojo y a sus hijos como negros. No queda más que considerar las otras dos situaciones que pueden ocumr si se encuentra un h o d 0 conectado a un 4-nodo, como se muestra en la Figura 15.10 (realmente, hay cuatro situaciones, dado que se pueden dar también las imágenes simétricas de estasdos en la otra orientación de los 3-nodos).En estos casos la división del 4-nOdO deja dos enlacesrojos seguidos, una situación ilegal que debe corregirse. Esto se puede comprobar fácilmente dentro del propio pro- grama: como se acaba de marcar a x como rojo, sólo se deberá actuar si el padre p de x es también rojo. La situación no es tan grave porque se tienen tres nodos conectados por enlaces rojos: todo lo que hay que hacer es transformar el árbol de modo que los enlaces rojos apunten hacia abajo desde el propio nodo. Por fortuna, existe una operacih simple que logra el efecto deseado. Se co- Figura 15.9 Divisiónde 4-nodos con un cambio de colores.
  • 266. 246 ALGORITMOS EN C++ Figura 15.10 Divisiónde 4-nodoc con un cambio de colores: se necesita una rotación. mienza por la más fácil de las dos situaciones, el primer caso (parte supe- rior) de la Figura 15.10,en el que los enlacesrojos están orientados en la misma dirección. El problema es que el h o d 0 se orientó en la dirección equivocada: en consecuencia se reestructurará el árbol para cambiar la orientación del 3-nOdO y así reducir este caso al segundo de la Figura 15.9, en el que la marca de color de x y sus hijos fue suficiente. Al reestructurar el árbol para reorientar un 3- nodo se cambian tres enlaces, como se muestra en la Figura 15.11;en esta fi- gura el árbol de la izquierda es el mismo que el que se obtuvo en la Figura 15.8, pero en el de la derecha está girado el 3-nodo que contiene a P y a S. El enlace izquierdo de S se cambió para apuntar a Q, el enlace derecho de P se cambió para apuntar a S y el enlace derecho de M se cambió para apuntar a P. Se ob- serva que los colores de los dos nodos también se cambiaron. Esta operación de rotación simple se define sobre cualquier árbol binario de búsqueda (con la excepción de las operacionesque afectan a colores)y es la base de varios algoritmos de árboles equilibrados, porque preserva el carácter esen- cial del árbol de búsqueda y es una modificación local que implica sólo tres cambios de enlaces. Sin embargo, es importante observar que la aplicación de una rotación simple no mejora necesariamente el equilibrio de un árbol. En la Figura 15.11 Rotación de un 3-nodo de la Figura 15.8.
  • 267. ÁRBOLES EQUILIBRADOS 247 Figura 15.11, la rotación hace subir un nivel hacia la raíz a todos los nodos a la izquierda de P, pero todos los nodos a la derecha de S han bajado un nivel: en este caso la rotación hace al árbol menos, no más, equilibrado. Los árboles 2-3- 4 descendentes se pueden considerar simplemente como una forma conve- niente de identificar rotaciones simples que son susceptiblesde mejurar el equi- librio. Toda rotación simple implica modificar la estructura del árbol, algo que se debe hacer con precaución. Como se vio al considerar el algoritmo de elimina- ción del Capítulo 14, el código es más complicado de lo que pudiera parecer necesario a causa del gran número de casos similares con simetrías izquierda- derecha. Por ejemplo, suponiendo que los enlaces y, h, y n apuntan a M,S y P respectivamente, en la Figura 15.8, entonces la transformación para pasar a la Figura 15.11 se efectúa por los cambios de enlace h->i zq = n->der; n->der = h;y->der = n. Existen otros tres casos análogos: el h o d 0 podría estar orien- tad0 en el otro sentido, o podría estar en el lado izquierdo de y (orientado en ambos sentidos). Una forma práctica de tratar estos cuatro casos es utilizar la clave de búsqueda v para «redescubrin>el hijo (h) y el nieto (n)del nodo y. (Se sabe que solamente se reorientará un 3-nOdOsi la búsqueda llevó al último nivel del árbol.) Esto conduce a un programa más simple que la alternativa de estar recordando durante la búsqueda no sólo los dos enlaces correspondientes a h y n, sino también si son derechos o izquierdos. La siguiente función permite re- orientar un 3-nOdO a lo largo del camino de búsqueda de v, cuyo padre es y: struct nodo *rotar(tipoElemento v, struct nodo *y) struct nodo *h, *n; h = ( v < y->clave) ? y->izq : y->der; if (v < h->clave) { n = h->izq; h->izq = n->der; n->der = h; } el se { n = h->der; h->der = n->izq; n->izq = h; } if (v < y->clave) y->izq = n; else y->der = n; return n; 1 Si y apunta a la raíz, h es el enlace derecho de y y n es el enlace izquierdo de h, el programa realiza exactamente las trasformaciones de enlaces necesarias para producir el árbol de la Figura 15.11 a partir del de la Figura 15.8. El lector puede verificar por sí mismo los otros casos. Esta función devuelve el enlace del <dope» del 3-nodo, pero no hace el cambio de color. Así, para tratar el tercer caso de di vi di r (ver Figura 15.1O), se puede hacer a rojo, luego asignar a x rotar ( v , ba), y después poner x en negro. Esto re- orienta el 3-nodo constituido por los dos nodos a los que apuntan a y p y hace que este caso sea el mismo que el segundo, cuando se orientó el 3-nodo.
  • 268. 248 ALGORITMOS EN C++ Figura 15.12 División de un nodo en un árbol rojinegro. Por último, para tratar el caso en el que los dos enlaces rojos están orienta- dos en direcciones diferentes (ver Figura 15.10), simplementese pone en p el valor de rotar(v, a). Esto reorienta el h o d 0 «ilegal»constituido por los dos nodos a los que apuntan p y x. Estos nodos son del mismo color, así que no es necesario ningún cambio de color, lo que lleva directamente ai tercer caso. Por razones evidentes, la combinaciónde lo que se acaba de presentar y de la rota- ción relativa al tercer caso se denomina rotación doble. La Figura 15.12 muestra la acción de dividir en el ejemplo cuando se añade S. En primer lugar existe un cambio de color para dividir el h o d 0 que con- tiene a O, P y U. A continuación es preciso hacer una rotación doble: la primera alrededor de la arista entre P y M y la segunda alrededor de la arista entre M y E. Como los dos enlaces rojos se hallan en la misma dirección se está en el ter- cer caso. Después de las modificaciones, se puede insertar S a la izquierda de U, como se muestra en el primer árbol de la Figura 15.13. Si la raíz es un h o d 0 (inserción en el primer árbol de la Figura 15.13)entonces el procedimiento di - vi dir pone la raíz en rojo: esto corresponde a transformarla,junto con el nodo ficticio que está encima de ella, en un 3-nOdO. Evidentemente, no hay razón para hacer esto, por lo que se incluye una sentencia al final del código de inser- ción, que permite mantener a la raíz en negro. Esto completa la descripción de las operaciones que debe llevar a cabo di - vi dir. Este procedimiento debe cambiar el color de x y de su hijo, efectuar la parte inferior de una rotación doble, si es necesario, y a continuación efectuar una rotación simple, también si es necesario, de la siguiente forma: void dividir(tipoE1emento v) x->b = rojo; x-> izq->b = negro; x->der->b = negro; {
  • 269. ÁRBOLES EQUILIBRADOS 249 i f (p->b) a->b = rojo; if (v < a->clave !=v < p->clave) p = rotar(v,a); x = rotar(v, ba); x->b = negro; { 1 1 Este procedimiento fija los coloresdespués de la rotación y también reinicializa a x lo suficientementealto en el árbol para asegurarque la búsqueda no se pierda debido a todos los cambios de los enlaces. Los códigos de divi di r y rotar se han incluido en procedimientos sepa- rados por razones de claridad, utilizando las variables ba, etc., de i nsertar;en C++son posibles diversas alternativas menos atractivas, que van desde hacerlas globales hasta declararlas explícitamente como funciones friends de D iCC. Figura 15.13 Construcción de un árbol rojinegro.
  • 270. 250 ALGORITMOS EN C++ La declaración de clase para los árboles rojinegros es la misma que la que se dio en el capítulo anterior para los árboles binarios de búsqueda normales, con la adición del campo indicador binario b en nodo y la inclusión de un argu- mento en el constructor de nodo para su inicialización. Si hay espacio disponi- ble, se podría declarar b como un entero, pero normalmente se intenta utilizar solamente un bit, quizás el de signo de una c l ave entera o el de alguna parte del registro que se ha denominado info. A continuación se deben inicializar cuidadosamente los nodos ficticios del constructor de D iCC, como sigue: D icc ( in t rnax) z = new nodo(0, infoNIL, negro, O, O); z->izq = z; z->der = z; cabeza = new nodo(elernentoMIN, O, negro, O, z); { 1 Los enlaces de z se ponen apuntando a z. Aunque no es común que se necesi- ten obligatoriamente asignaciones tales como un centinela, pueden simplificar bastante la codificación. Por ejemplo, estas asignacionespermiten evitar el uso de un break en el lazo whi 1e de insertar. Al ensamblar los distintos fragmentos de código anteriores se obtiene un al- goritmo muy eficaz y relativamente simple para la inserción utilizando una es- tructura de árbol binario que garantiza toda inserción o búsqueda en un nú- mero de pasos iogarítmico. Éste es uno de los pocos algoritmosde búsqueda con esta propiedad, y su utilización está justificada siempre que no se pueda tolerar un mal rendimiento en el peor caso. La Figura 15.13 muestra cómo este algoritmo construye el resto del árbol rojinegro del conjunto de clavesdel ejemplo. Por el coste de solamente unas po- cas rotaciones se obtiene un árbol bastante bien equilibrado. Propiedad 15.3 Una búsqueda en un árbol rojinegro de N nodos construido a partir de claves aleatorias parece necesitar I@ comparaciones y una inserción parece necesitar, como media, menos de una rotación. A pesar de que todavía está por hacer un análisispreciso del caso medio de este algoritmo, existen resultados convincentes de análisis parciales y de simulacio- nes. La Figura 15.14muestra el gran árbol construido para el ejemplo que se ha venido utilizando: el número medio de nodos explorados en este árbol durante la búsqueda de una clave aleatoria es apenas 5,8 1, en comparación con 7,OO del árbol construido para las mismas claves en el Capítulo 14, y con 5,74, el valor óptimo para un árbol perfectamente equi1ibrado.m Pero el significado real de los árboles rojinegros se encuentra en su rendi- miento en el peor caso y en el hecho de que este rendimiento se alcanza con muy poco coste. La Figura 15.15 muestra el árbol construido si se insertan los
  • 271. ÁRBOLES EQUILIBRADOS 251 Figura 15.14 Un gran árbol rojirsgro. números del 1 al 95, en orden, en un árbol inicialmente vacío;incluso este árbol está bastante bien equilibrado. El coste de la búsqueda para cada nodo del árbol es tan bajo como si se hubiera construido por el algoritmo elemental y la inser- ción solamente implica un bit extra de comprobación y alguna llamada ocasio- nal al procedimiento d i vi di r. Propiedad 15.4 Una búsqueda en un árbol rojinegrocon N nodos necesita me- nos de 2lgN + 2 comparaciones y una inserción necesita menos de una cuarta parte de rotaciones que de comparaciones. Sólo las «divisiones» que corresponden a un 3-nOdO conectado a un 4-nodo en un árbol 2-3-4 necesitan una rotación en el árbol rojinegro correspondiente, por lo que esta propiedad se deduce de la propiedad 15.2. El peor caso se obtiene cuando el camino al punto de inserción consiste en alternar 3- y 4-no dos.^ En resumen: utilizando este método se puede encontrar una clave de un archivo de, por ejemplo, medio miilón de registros al compararloscon sólo otras veinte claves. En el peor caso puede ser que sean necesanas dos veces más com- paraciones, pero no más. Además, las comparaciones se acompañan de un so- brecoste pequeño, lo que asegura una búsqueda muy rápida. Figura 15.15 Un árbol rojinegro en un caso degenerado.
  • 272. 252 ALGORITMOS EN C++ Qtros algoritmos La implementación del «árbol 2-3-4 descendente», utilizando el esquema roji- negro presentado en la sección anterior, es una de las diversas estrategias simi- lares que se han propuesto para implementar árboles binarios equilibrados. Como se vio anteriormente, de hecho es la operación «rotan> la que equilibra los árboles: se han examinadolos árboles desde un enfoque particular que per- mite decidir fácilmente cuándo efectuar la rotación. Otros enfoques de los ár- boles conducen a otros algoritmos, algunos de los cuales se examinaránbreve- mente. La estructura de datos más antigua y la mejor conocida para los árboles equilibrados es el árbol A VL.Estos árboles tienen la propiedad de que la altura de los dos subárboles de cada nodo difieren a lo sumo en una unidad. Si esta condición se viola por una inserción, se puede restaurar utilizando rotaciones. Pero esto requiere un bucle extra: el algoritmo básico consiste en buscar el valor a insertar y después seguir hacia arriba por el árbol, a lo largo del camino que se está recorriendo, ajiistando las alturas de los nodos utilizando rotaciones. También es necesario saber si cada nodo tiene una altura superior o inferior en una unidad a la de su hermano, o es la misma. Esto iiecesita dos bits si se re- curre a una implementacióndirecta, aunque exista una forma de lograrlo con un solo bit por nodo, utilizando el modelo rojinegro. Una segunda, y muy conocida, estructura de árbol equilibrado es el árbol 2- 3, en el que sólo se permiten 2-nodos y 3-nodos. Es posible implementar inser- ción utilizando un «bucle extra»que incluya rotaciones, como para los árboles AVL, pero estas estructuras no tienen la suficienteflexibilidad para que se pueda dar una versión descendente válida del algoritmo. Una vez niás, el modelo ro- jinegro puede simplificar la implementación, pero es preferible utilizar árboles 2-3-4ascendentes, en los que se busca descendiendo hasta el fondo del árbol para hacer allí la inserción, y entonces (si el nodo del fondo es un 4-nOdO) se vuelve hacia atrás ascendiendo por el camino de búsqueda, dividiendo los 4-nodos e insertando el nodo intermedio en el padre, hasta encontrar un 2-nodo o un 3- nodo como padre. En este punto puede ser necesaria una rotación para tratar casos como los de la Figura 15.10. Este método tiene la ventaja de utilizar a lo sumo una rotación por inserción, lo que puede ser muy importante en algunas aplicaciones. La implementación es algo más complicada que la del método descendente descrito anteriormente. En el Capítulo 18,se estudiará el tipo más iniportante de árbol equilibrado, una generalización de los árboles 2-3-4 denominada árboles B. Esta estructura permite hasta M claves por nodo, siendo M grande. Estos árboles se utilizan ampliamenteen aplicacionesque trabajan con archivos muy grandes.
  • 273. ÁRBOLES EQUILIBRADOS 253 Ejercicios 1. Dibujar el árbol 2-3-4 descendente construido por inserción de las claves C U E S T I O N F A C 1 L (en este orden) en un árbol inicialmente vacío. 2. Dibujar una representación rojinegra del árbol de la pregunta anterior. 3. ¿Qué enlaces se modifican exactamente por los procedimientos dividir y ro- tar cuando se inserta Z (despuésde Y) en el árbol ejemplo de este capítulo? 4. Dibujar el árbol rojinegro que resulte de la inserción de las letras A hasta la K (en este orden), describiendo lo que pasa en general cuando las claves se insertan en los árboles en orden ascendente. 5. ¿Cuántos enlaces deben modificarse en una rotación doble y cuántos se modificaron en la implementación dada? 6. Generar aleatoriamente dos árboles rojinegrosde 32 nodos, dibujándolos (a mano o por un programa), y compararlos con los árboles binarios de bús- queda no equilibrados construidos con las mismas claves. 7. Generar aleatoriamente diez árboles rojinegros de 1.O00 nodos. Calcular el número de rotaciones necesarias para construir los árboles y !a longitud media del camino entre la raíz y un nodo externo. Interpretar los resulta- dos. 8. Con un bit por nodo para el «colon>se pueden representar 2-nodos, 3-11s- dos y 4-nodos. ¿Cuántos tipos de nodos diferentes se podrían representar si se utilizan dos bits por nodo para el color? 9. En los árboles rojinegros se necesitan las rotaciones cuando los 3-nodos se convierten en 4-nodos de una forma «no equilibrada). ¿Por qué no se eli- minan las rotaciones permitiendo que !os 4-nodos se representen como tres nodos cualquiera conectados por dos errlaces rGjos (perfectamente equili- brados o no)? 10. Determinar una secuencia de inserciones que construya el árbol rojinegro que se muestra en la Figura 15.11.
  • 275. 16 Dispersión Una técnica de búsqueda completamente diferente de lasbasadas en estructuras de árboles de comparación de los capítulos anteriores es la dispersión: un mé- todo que permite hacer directamente referencia a los registrosde una tabla por medio de transformaciones aritméticas sobre las clavespara obtener direcciones de la tabla. Si se sabe que las claves son enteros distintos, entre 1 y N, entonces se puede almacenar un registro con clave i en la posición i de la tabla, prepa- rado para que se acceda a él de forma inmediata con el valor de la clave. La dispersión es una generalización de este método trivial en aplicaciones de bús- queda típicas donde no se tiene ningún conocimiento concreto sobre los valores de las claves. El primer paso en una búsqueda por dispersión consiste en evaluar unafun- ción de dispersión que transforma la clave de búsqueda en direcciones de la ta- bla. Idealmente, diferentes claves deben dar diferentes direcciones, pero nin- guna función de dispersión es perfecta,y dos o más claves diferentespueden dar la misma dirección de la tabla. La segunda parte de una búsqueda por disper- sión es pues un proceso de resolución de colisiones, que permite tratar este tipo de claves. Uno de los métodos de resolución de colisionesque se estudiarán uti- liza las listas eplazadas y es apropiado en situaciones muy dinámicas en las que el número de claves de búsqueda no se puede predecir. Los otros dos métodos de resolución de colisiones que se examinarán alcanzan tiempos de búsqueda muy bajos para registrosalmacenadosen un array fijo. La dispersión es un buen ejemplo del compromiso espacio-tiempo. Si no hu- biera limitación de memoria, se podría hacer cualquier búsqueda con un solo acceso a la memoria, utilizando simplemente la clave como una dirección de memoria. Si no hubiera limitaciones de tiempo, se podría hacer con un mínimo de memoria utilizando un método secuencia1 de búsqueda. La dispersión pro- porciona una forma de utilizar razonablemente la memoria y el tiempo para obtener un equilibrio entre estos dos extremos. El empleo eficaz de la memoria disponible y un rápido acceso a la memoria son los objetivos básicos de cual- quier método de dispersión. 255
  • 276. 256 ALGORITMOS EN C++ La dispersión es un problema «clásico» en informática en el sentido de que los diferentes algontmos conocidos se han estudiado con cierta profundidad y son ampliamente utilizados. Existe un gran número dejustificaciones de orden empírico y analíticoque apoyan la utilidad de la dispersión en una variada gama de aplicaciones, Funciones de dispersión El primer problema que hay que resolver es el de la realización de la función de dispersión transformando las claves en direcciones de la tabla. Éste es un pro- blema aritmético con propiedades similares a los generadoresde números alea- tonos que se estudia en el Capítulo 33. Lo que se necesita es una fxnción que transforme la claves (habitualmente enteros o cadenas cortas de caracteres)en enteros del intervalo [O, M- 13, donde M es el número de registros que se puede colocar en el total de memoria disponible. Una función de dispersiónideal debe ser fácil de calcular y debe ser además una aproximación a una función «alea- toria)): para cada entrada, toda salida debe ser, en cierto sentido, igualmente probable. Como los métodos que se utilizan son aritméticos, el primer paso consiste en transformar las claves en números sobre los que se realicen las operaciones aritméticas. Para claves pequeñas, esto puede no significar trabajo alguno en ciertos entornos de programación, si se pueden utilizar como números las re- presentaciones binarias de las claves (véase la presentación del comienzo del Capítulo 10).Para claves mayores, se puede intentar extraer bits de las cadenas de caracteres y empaquetarlos en una palabra en lenguaje de máquina; después se verá un método para manipular uniformemente clavesde cualquier longitud. Supóngase en primer lugar que se dispone de un gran entero que corres- ponde directamente a una clave, El método más comúnmente utilizado en la dispersión consiste en escoger un M primo y, para cualquier clave k, calcular h(k)= k mod M. Éste es un método directo fácil de calcular en muchos entor- nos de programación y dispersa las claves bastante bien. Por ejemplo, supóngase que el tamaño de la tabla es 101 y que hay que cal- cular un índice para la clave de cinco caracteres C L A V E: si está codificada con el código de cinco bits utilizado en el Capítulo 10(en el que la i-ésimaletra del alfabeto se expresa por la representación binaria del número i), entonces puede verse como el número binario 0001101100000011011000101, que es equivalente al 3540677 en base 10. Además, 3540677 = 21 (mod lOl), así que a la clave C I, A V E le corresponde (<(sedispersa a»)la posición 21 de la tabla. Hay muchas claves posibles y relativamente pocas posiciones de la ta- bla, por lo que a muchas otras claves le corresponderá la misma posición (por
  • 277. DISPERSI~N 257 ejemplo, la clave A C L también tiene la dirección de dispersión 21en el código anterior). ¿Por qué el tamaño de la tabla debe ser primo? La respuesta a esta pregunta depende de las propiedades aritméticas de la función mod. En esencia, se trata la clave como un número en base 32, a razón de un dígito por cada carácter de la clave. Se ha visto que a la clave C L A V E le correspondeel número 3540677, que también puede escribirse como 3 . 324+ 12 . 323+ 1 ' 322+ 22 . 32' + 5 . 32' puesto que C es la tercera letra del alfabeto, etc. Ahora, suponiendo que por desgracia se escoge M = 32: como el valor de k mod 32 no cambia al añadir múltiplos de 32, la función de dispersión de cualquier clave será simplemente jel valor de su último carácter! Parece natural asegurarse de que una buena fun- ción de dispersión tenga en cuenta todos los caracteres de la clave, y la forma más simple de hacerlo es eligiendo un M primo. Pero la situación más típica es cuando las claves no son ni números ni ne- cesariamente cortas, sino simplemente cadenas alfanuméricas (posiblemente muy largas). ¿ Cómo calcular la función de dispersión de una cadena como G R A N C L A V E? En el código utilizado, a ésta le correspondería la cadena de 45 bits 001111001000001011100001101100000011011000101, o el número 7.328+18.32'+ 1.326+14.325+3.324+ 12.323+1.322+22.321+5, que es demasiado larga para representarla con funciones aritméticas normales en la mayoría de las computadoras (habría que estar preparados para emplear clavesmucho más largas). En una tal situación no se puede seguir calculando la función de dispersión como se hizo antes, transformando la clave pieza por pieza. Una vez más habrá que aprovecharse de las ventajas de las propiedades arit- méticas de la función mod y de un sencillo truco de cálculo denominado el mé- todo de Horner (ver Capítulo 36), que se basa en escribir de otra forma el nú- mero que correspondea la clave. En el ejemplo,se obtiene la expresión siguiente: (((((((7.32+18)32+1)32+14)32+3)32+12)32+1)32+22)32+5. Esto conduce a un método aritmético directo de cálculo de la función de dis- persión. La implantación de este capítulo utiliza como claves cadenas y no en- teros. Se supone que t ipoEl emento es un tipo cl avecadena que permite la asignación y las operaciones de comparación por desigualdad(y por supuesto la función d i spersion). Ésta es la situación más natural para describir la disper- sión, aunque, por coherencia con otros capítulos, se utilicen como claves,en los
  • 278. 258 ALGORITMOS EN C++ ejemplos, cadenas de un solo carácter. Esto es similar a la situación que se en- cuentra en los Capítulos 10y 17 con cadenas de bits como claves:C++ permite ser explícito sobre qué operaciones se llevarán a cabo en las claves. Para la dis- persión, cada tipo de clave necesita tener una función de dispersión. En las ca- denas, una función de dispersión basada en el método de Homer es simple- mente una forma de tratar los caracteres como dígitos. unsigned clavecadena: :dispersion(int M) I I int h; char *t = v; for (h = O; *t; t++) return h; h = (64*h + *t) % M; 1 Aquí h es el valor de dispersión calculado y la constante 64 es, estrictamente hablando, una constante que depende de la implantación y del tamaño del al- fabeto. El valor exacto de esta constante no es particularmente importante. Un inconveniente de este método es que necesita un cierto número de operaciones aritméticas para cada carácter de la clave lo cual podría ser costoso. Esto puede mejorarse procesando la clave en partes más grandes. Sin el operador %, este programa calcularía el número correspondiente a la clave, como en la ecuación anterior, pero con claves muy largas el cálculo podría desbordarse. Sin em- bargo, con el operador % se puede calcular la función de dispersión graciasa las propiedades aditivas y multiplicativas de la operación módulo y se evita el des- bordamiento porque % proporciona siempre un resultado inferior a M. La di- rección calculada por el programa para G R A N C L A V E con M = 1O1 es 21. Encadenamiento separado Las funciones de dispersión anteriores convierten las claves en direccionesde la tabla: queda todavía por explicar cómo resolver los casos en los que dos claves dan la misma dirección. El método más directo consiste simplemente en cons- truir, para cada dirección de la tabla, una lista enlazada con todos los registros cuyas claves se transforman en esta dirección. Puesto que las claves que tienen la misma posición en la tabla se ponen en una lista enlazada, es fácil conservar- las en orden. Esto conduce a una generalización de los métodos de búsqueda elementales que se presentaron en el Capítulo 14. Mejor que estar manteniendo una lista única con un sencillo nodo cabecera cabeza, como se sugirió enton- ces, es preciso mantener M listas con M nodos cabecera, inicializadas como se describe a continuación:
  • 279. DISPERSION 259 Dicc: :Dicc(int t m ) M = tm; z = new nodo; z->siguiente = z; z->info = infoNIL; cabezas = new nodo*[M]; for (int i = O; i < M; i++) { { cabezas[i] = new nodo; cabezas[i]->siguiente = z; } 1 c h e E lTJ1ElI M IP ImlolIDIIEllelIUII s 1 (QI l ü lElIDIIAl dispersión: 5 10 5 2 5 1 4 4 5 2 10 8 6 10 5 4 1 Figura 16.1 Una función de dispersión(M = 11). Ahora se pueden utilizar los procedimientos del Capítulo 14 de búsqueda e in- serción en una lista, modificados de tal forma que se utilice la función de dis- persión para escoger entre las listasreemplazando simplementelas referenciasa cabeza por cabezas[dispersiÓn(v)]. Por ejemplo, si las claves del ejemplo se insertan sucesivamente en una tabla inicialmente vacía utilizando la función de dispersión de la Figura 16.1, resulta entonces el conjunto de listas que se muestra en la Figura 16.2. Este método se denomina tradicionalmente encadenamiento separado porque los registros en colisión se «encadenan» juntos en listas enlazadas independientes. Las listas se pueden mantener ordenadas, pero esto no es tan importante en esta aplicación como lo fue para la búsqueda secuencia1elemental porque las listas son bas- tante cortas. Evidentemente, el total de tiempo que se necesita para una bús- queda depende de la longitud de las listas (y de la posición relativa de las claves en ellas). Figura 16.2 Encadenamientoseparado.
  • 280. 260 ALGORITMOS EN C++ Para una «búsqueda sin éxito» (la búsqueda de un registro con una clave que no está en la tabla), se puede suponer que la función de dispersióndificulta las cosas lo suficiente como para que cada una de las M listas esté en igualdad de condiciones para buscar en ella y que, al igual que en la búsqueda secuencial en una lista, cada lista eIi_la que se busca se recorre sólo hasta la mitad (por término medio). La longitud media de la lista examinada (no contando a Z)en una búsqueda sin éxito es en el ejemplo (0+2+2+0+3+5+1+0+1+0+3)/11 = 134. Manteniendo las listas ordenadas se podría reducir este tiempo a la mitad. Para una «búsqueda con éxito» (la búsqueda de alguno de los registros de la tabla), se supone que cada registro tiene la misma posibilidad de ser examinado: se encontrarán siete claves en primera posición de la lista, cinco en segun- da, etc., por lo que la media es (7 . 1 + 5 . 3 + 3 . 3 + 1 . 4 + 1 . 5)/17 = 2,05. (Esteanálisis supone que las claves iguales se distinguen por medio de un iden- tificador único o de algún otro mecanismo y que la rutina de búsqueda se mo- difica apropiadamente para que sea capaz de buscar cualquier clave en par- ticular.) Propiedad 16.1 El encadenamiento separado reduce el número de comparacio- nes de la búsqueda secuencia1en unfactor de M (por término medio), utilizando espacio extra para los M enlaces. Si N, el número de claves de la tabla, es mucho mayor que M, entonces una buena aproximación de la longitud media de las listas es N/M, puesto que cada uno de los M valores de dispersión es «igualmente probable» gracias al diseño de la función de dispersión. AI igual que en el Capítulo 14, las búsquedas sin éxito llegan hasta el final de una determinada lista y las búsquedas con éxito la recorren aproximadamente a la mitad.i La implantación anterior utiliza una tabla de dispersión de enlaces a las ca- beceras de las listas que contienen realmente las claves. Si no se desea mantener M nodos cabeceras de lista, una alternativa consiste en eliminarlos y hacer que cabezas sea una tabla de enlaces a las primeras claves de las listas. Esto pro- voca algunas complicacionesen el algoritmo. Por ejemplo, añadir un nuevo re- gistro al comienzo de una lista se convierte en una operación diferente de la de añadir un nuevo registro en cualquier otra parte de ella, porque implica modi- ficar una entrada de la tabla de enlaces, no un campo de un registro. Otra im- plantación consisteen colocar la primera clave dentro de la tabla. Aunque estas alternativas utilizan menos espacio en algunas situaciones,M es habitualmente muy pequeño en comparación con N, de modo que la comodidad añadida al utilizar nodos cabecerasde lista está casi siemprejustificada. En una implantación de encadenamiento separado, normalmente se escoge M lo Suficientemente pequeño para no utilizar una gran zona de memoria con- tigua. Pero es probable que lo mejor sea escoger un M tal que las listas sean lo suficientemente cortas como para hacer que la búsqueda secuencial sea lo más eficaz posible: los métodos «híbridos» (como la utilización de árboles binarios
  • 281. DISPERSI~N 261 en lugar de listas enlazadas) no merecen la pena, dada su complicación. En una primera aproximación, se puede escoger un M que sea alrededor de la décima parte del número de claves que se espera que haya en la tabla, de modo que cada lista cuente con tener alrededor de diez claves. Una de las ventajas del en- cadenamiento separado es que este valor no es crítico: si aparecen más claves que las esperadas, entonces las búsquedas se demorarán un poco más: si hay pocas claves en la tabla, entonces puede ser que se haya utilizado un poco más dv memoria de la necesaria. Si la memoria es realmente un recurso crítico, la elección de un M tan grande como se pueda permitirá aportar una ganancia de rendimiento proporcional a M. Exploración lineal Si el número de elementos a poner en la tabla de dispersión se puede estimar por adelantado y hay suficiente memoria contigua disponible como para con- tener a todas las claves y contar además con algún espacio de reserva, entonces probablemente no merezca la pena utilizar enlaces en la tabla de dispersión. Se han desarrollado varios métodos para almacenar N registros en una tabla de ta- maño M > N, utilizando los lugares vacíos de la tabla como ayuda en la reso- lución de las colisiones. Tales técnicas se denominan métodos de dispersión de direccionamiento abierto. El método más simple de direccionamiento abierto es la llamada explora- ción lineal: cuando hay una colisión (cuando la función de dispersión envía so- bre un lugar de la tabla que ya está ocupado, cuya clave no es igual que la clave de búsqueda), se explora la siguiente posición de la tabla, comparando la clave del registro con la clave de búsqueda. Existen tres posibilidades en esta explo- ración: si las claves concuerdan, entonces la búsqueda termina con éxito; si allí no hay ningún registro, entonces la búsqueda termina infmctuosamente; en caso contrario se explora la siguiente posición, continuando hasta que se encuentre la clave de búsqueda o una posición vacía. Si se debe insertar un registro que contiene la clave de búsqueda despuésde una búsqueda sin éxito, entonces sim- plemente se le pone en el espacio vacío de la tabla donde se terminó la bús- queda. Este método se implanta fácilmente de la forma siguiente: class Dicc private: { s t r u c t nodo { tipoElemento clave; t i p o I n f o i n f o ; nodo() { clave = 'I ' I ; i n f o = infoNIL; } 1; s t r u c t nodo *a;
  • 282. 262 ALGORITMOS EN C++ int M; Dicc( int tm) int buscar(tipoE1emento v); void insertar(tipoEl emento v, tipoInfo info) public: { M = tm; a = new nodo[M] } int x = v.dispersion(M); while (a[x].info != infoNIL) x = (x+l) % M; a[x].clave = v; a[x].info = info; { 1 La exploración lineal necesita la existencia de una clave de valor especial para señalar las posicionesvacías de la tabla; este programa utiliza un simpleespacio en blanco para este fin. El cálculo x = (x+l) % M corresponde al examen de la siguiente posición (que se pone en el comienzo cuando se alcanza el final de la tabla). Es preciso destacar que este programa no comprueba cuándo está la ta- bla completamente llena. (¿Qué podría pasar en este caso?) La implantación de buscar es similar a la de insertar:simplemente se añade la condición (( (v != a[x] .cl ave))) al bucle whi 1e y se cambia la línea siguiente, la que almacena el registro, por return a[x] .info. clave : (JI( ~ l MIIPIIiJlOlIDIIEllelIUIElIQIIUIElE IIAl dispersión: 5 10 5 13 16 12 15 4 5 2 2 O 17 2 5 4 1 Figura 16.3 Unafunción de dispersión(M = 19). Para el conjunto de claves del ejemplo, con M = 19, se tienen los valores de dispersión que se muestran en la Figura 16.3. Si estas claves se insertan en el orden dado y en una tabla inicialmente vacía, se obtiene la secuencia que se muestra en la Figura 16.4. Se observa que las claves duplicadas aparecen entre la posición inicial de la exploración y la siguienteposición vacía de la tabla, pero no necesitan ser contiguas. El tamaño de la tabla de la exploración lineal es mayor que la del encade- namiento separado, puesto que se tiene M > N, pero la cantidad total de me- moria que se utiliza es menor, ya que no se necesitan enlaces. El número medio de elementos que se deben examinar para una búsqueda con éxito en este ejem- plo es 38/17 = 2,23. Propiedad 16.2 Una exploración lineal utiliza menos de cinco exploraciones, por término medio, en una tabla dispersión que esté llena, al menos, en sus dos terceras partes.
  • 283. DISPERSI~N 263 La fórmula exacta para el número medio de exploraciones necesarias, expre- sado en función del «factor de carga»a = N/M de la tabla de dispersión, es 1/2 + 1/2(1 - para una búsqueda infructuosa y 1/2 + 1/2 (1 - a) para una bús- queda con éxito. Así pues, si se toma a = 2/3, se obtienen cinco exploraciones en una búsqueda infructuosa y dos en una con éxito. Las búsquedas sin éxito son siempre las más costosas de las dos: una búsqueda con éxito necesitará me- nos de cinco exploraciones hasta que la tabla esté llena en un 90 %. A medida que la tabla se va llenando (y a se acerca a 1), estos números se van haciendo más grandes; esto no debe permitirse en la práctica, como se confirmará poste- ri0rmente.i 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 Figura 16.4 Exploraciónlineal.
  • 284. 264 ALGORITMOS EN C++ ............... ..............~..... ................................ ................ l l l l l l 8 . l l l l l l l l l l l l ll . l l l l l I l l l l l l l l l l l I l l l l l l l l I I l l ................ I I I I I I I I I I I I I I I I I ~ I I 1 . 0 I I I I I I..I I I I I I I ~ ~ I I I I I I I U I ni ................ ....................................................... l . l l l l l l ~ l l l l l l l m m l l l l l l l l l m l l l l l m l l l I l l l l l l l l l l l l l l l l l ~ l 8 I l l l m l l l l I I l l l . ~ l l ~ l ~ l l l l l l l l l l l l l . l I 8 i e i i i i i i i i i i i ..IO l l l l l~ l P l l l l l l ~ l l l I l l l l l l l l I I l l .......................................................................... ........................................................................... Figura 16.5 Exploraciónlineal en una tabla grande. Doble dispersión La exploración lineal (o en su lugar cualquier método de dispersión) es válida porque garantiza que, cuando se está buscando una clave en particular, se exa- minan todas las claves que dan la misma dirección de tabla al aplicarlesla fun- ción de dispersión (en particular la de la propia clave si está en la tabla). Des- graciadamente, en la exploración lineal se examinan también otras claves,sobre todo cuando la tabla comienza a estar muy llena: en el ejemplo anterior, la bús- queda de la segunda D implica el examen de E, U y J, ninguna de las cuales tiene el mismo valor de dispzrsión. Y lo que es peor, la inserción de una clave con un valor de dispersión dado puede aumentar drásticamente el tiempo de búsqueda de claves que tienen otros valores de dispersión: en el ejemplo, una inserción en la posición 18 provocaría un gran aumento del tiempo de bús- queda para la posición 17. Este fenómeno, denominado agrupamiento, puede hacer que la exploración lineal actúe muy lentamente en tablas casi llenas. La Figura 16.5 muestra los agrupamientos que se forman en un ejemplo de ta- maño mayor. Por fortuna, existe una forma fácil de eliminar prácticamente este problema de agrupamiento: la doble dispersión. La estrategiabásica es la misma; la única diferencia es que, en lugar de examinar sucesivamente todas las entradas si- guientes a la posición donde se ha producido la colisión, se utiliza un? segunda función de dispersión para obtener un incremento fijo a utilizar en la secuencia de ((exploración)).Esto se implanta fácilmente insertando u = h2 (v) al princi- pi0 de la función y cambiando x = (x+l) % M por x = (x+u) % M dentro del bucle whi 1e. La segunda función de dispersión se debe escoger con cuidado, ya que de otro modo el programa podría'no ser válido. En primer lugar, evidentemente no se desea tener u = O, puesto que conduciría a un bucle infinito en caso de colisión. En segundo lugar, es importante que M y u sean primos entre sí, para evitar que algunas de las secuencias de exploración sean muy cortas (considé- rese el caso M = 2u). Esto se garantiza fácilmente haciendo a M primo y a U < M. En tercer lugar, la segunda función de dispersión debe ser «diferente» de la primera, pues de lo contrario puede ocumr un agrupamiento ligeramente más complicado. Una función como h2(k)= M - 2 - k mod (M - 2) producirá un
  • 285. DISPERSIÓN 265 clave IDJE ll e 1D I E 1 IQJIujE lmJAJ lJ1I-q IMlrp1)I dispersiónl: 5 10 5 13 16 12 15 4 5 2 1 O 17 1 5 4 1 dispersión2: 3 6 3 3 8 4 1 4 3 6 3 5 7 3 3 4 7 Figura 16.6 Función de doble dispersión (M = 19). buen surtido de «segundos» valores de dispersión, pero quizás esto sea ir de- masiado lejos, ya que, especialmente para claves grandes, el coste de calcular la segunda función de dispersiónprácticamente dobla el de la búsqueda, para evi- tar solamente algunas exploraciones para eliminar el agrupamiento. En la prác- tica puede ser suficienteuna segunda función de dispersión mucho más simple, como por ejemplo h2(k)= 8 - (kmod 8). Esta función sólo utiliza los últimos tres bits de k; puede ser apropiado utilizar un mayor número de bits para una tabla más grande, aunque el efecto,incluso si es notorio, no es probable que sea significativo en la práctica. Para las claves del ejemplo, estas funciones producen los valores de disper- sión que se muestran en la Figura 16.6. La Figura 16.7 muestra la tabla que se obtiene por la inserción sucesiva de estas clavesen una tabla inicialmente vacía y utilizando la doble dispersión con estos valores. El número medio de elementos examinados en una búsqueda con éxito es ligeramente superior al de ! a exploración lineal para el mismo ejemplo: 34/ 17 = 2. Pero en una tabla más esparcida, hay muchos menos agrupamientos,tal como se muestra en la Figura 16.8. En este ejemplo, hay dos veces menos agrupa- mientos que en la exploración lineal (Figura 16.5), o, de forma equivalente, el agrupamiento medio es dos veces más pequeño. Propiedad 16.3 La doble dispersión utiliza menos exploraciones, por tkrinirio medio, que la exploración lineal. La fórmula exacta para el número medio de exploraciones que se hacen en la técnica de doble dispersión con una función de doble dispersión ({indepen- diente» es l/( 1 - a) para una búsqueda sin éxito y -in( 1 - a)/apara una bús- queda con éxito. (Estas fórmulas son el resultado de un análisis matemático profundo y aún no han sido verificadas para un a muy grande.) La segunda (y más sencilla) de las dos funciones de dispersión de las antes recomendadas no cumple con esto exactamente, pero puede ser válida, en especial si se utilizan los suficientesbits para hacer que el número de valores posibles esté próximo a M. En la práctica, esto significa que con la doble dispersión se puede utilizar una tabla más pequeña para lograr los mismos tiempos de búsqueda que con la exploración lineal: el número medio de exploracioneses inferior a cinco, en una búsqueda sin éxito, si la tabla está llena a menos del 80 %, y para una búsqueda con éxito si lo está a menos del 99 Yo.. Los métodos de direccionamientoabierto pueden no ser convenientesen una
  • 286. 266 ALGORITMOS EN C++ O 1 2 3 4 5 6 3 8 9 10 11 12 13 14 15 16 13 18 Figura 16.7 Doble dispersión.
  • 287. DISPERSI~N 267 Figura 16.8 Doble dispersión en una tabla mas grande. situación dinámica cuando se tiene que procesar un número imprevisiblede in- sercionesy eliminaciones. En primer lugar jcuál debe ser el tamaño de la tabla? De una forma u otra se deben hacer estimaciones de cuántas inserciones se es- peran, pero el rendimiento se degrada rápidamente a medida que la tabla co- mienza a llenarse. Una solución común para este problema es hacer una redis- persicn en una tabla más grande, de la forma menos frecuente que sea posible. En segundo lugar hay que tener precaución con la eliminación: un registro no se puede eliminar tranquilamente de una tabla construida por medio de una ex- ploración lineal o de doble dispersión. La razón es que las últimas inserciones en la tabla puede que hayan saltado la posición de este registro y, una vez eli- minado, las búsquedas terminarán en el hueco dejado por el registro eliminado. Un medio de resolver este problema es tener otra clave especial que pueda ser- vir de comodín para las búsquedas, pero que pueda ser identificada y recordada como una posición vacía para las inserciones. Se observa que ni el tamaño de la tabla ni la supresiónde elementospresentan problemas particulares en el caso del encadenamiento separado. Perspectiva Los métodos presentados en lo anterior han sido analizados completamente y es posible comparar su rendimiento con algún detalle. Las fórmulas proporcio- nadas en el texto son la síntesis de los análisis detallados descritos por D. E. Knuth en su libro sobre ordenación y búsqueda. Dichas fórmulas indican cómo se degrada el rendimiento en el direccionamiento abierto cuando a tiende a 1. Para M y N grandes, con una tabla llena en un 90 %, la exploración lineal ne- cesitará alrededor de 50 exploraciones para una búsqueda sin éxito, en compa- ración con las 10 de la doble dispersión. Pero en la práctica, no se debe dejar jamás que una tabla de dispersión illegue a llenarse en un 90%! Para pequeños valores del factor de carga, sólo serán necesariasalgunas exploraciones;si no es posible lograr factores de carga pequeños, no se debe utilizar la técnica de dis- persión. La comparación de la exploración lineal y la doble dispersión con el enca- denamiento separado es algo más complicada, ya que se dispone de menos me-
  • 288. 268 ALGORITMOS EN C++ mona en el método del direccionamiento abierto (pLzsto que no hay enlaces). El valor de a debe modificarse, para tener esto en cuenta, en función del ta- maño relativo de las claves y los enlaces. Esto significa que normalmente no será justificable la elección del encadenamiento separado en lugar de la doble dis- persión, por criterios de rendimiento. La selección del mejor método de dispersión para una aplicación determi- nada puede resultar muy dificil. Sin embargo, en una situación dada raramente se necesita el mejor método y las distintas técnicas suelen tener características de comportamiento similares mientras que los recursos de memoria no se fuer- cen demasiado. En general, la mejor elección consiste en utilizar el método de encadenamiento separado para reducir drásticamente el tiempo de búsqueda cuando no se conoce por adelantado el número de registros a procesar (y se dis- pone de un buen administrador de memoria) y utilizar la doble dispersión para buscar en conjuntos de claves cuyo tamaño aproximado se puede predecir. Se han desarrollado muchos otros métodos de dispersión que tienen aplica- ción en situaciones especiales. Aunque no se puede entrar en detalles, se expo- nen brevemente dos ejemplos para ilustrar la naturaleza de los métodos de dis- persión especializados. Éstos, y muchos otros métodos, se describen completamente en los libros de Knuth y Gonnet. El primero de ellos, denominado dispersion ordenada, explota el orden de una tabla de direccionamiento abierto. En la exploración lineal estándar, se de- tiene la búsqueda cuando se encuentra una posición vacía de la tabla o un re- gistro con una clave igual a la de búsqueda; en la dispersión ordenada, se de- tiene la búsqueda cuando se encuentra un registro con una clave mayor o igual que la clave de búsqueda (la tabla se debe haber construido hábilmente para ha- cer este trabajo). Este método reduce el tiempo de una búsqueda infmctuosa aproximadamente al mismo de una con éxito. (Éste es el mismo tipo de mejora que se hace en el encadenamiento separado.) Este método es útil para aplicacio- nes donde la búsqueda infructuosa sucede frecuentemente. Por ejemplo, un sis- tema de tratamiento de texto puede tener un algoritmo para separar las palabras que funcione bien para la mayona de las palabras, pero no para algunos casos excepcionales (como «excepción»). Esta situación podría arreglarse buscando todas las palabras en un diccionario de excepciones relativamente pequeño, que contenga aquellas que deben utilizarse de forma especial, y así la mayor parte de las búsquedas serán infructuosas. De forma similar, existen métodos para desplazar algunos registros durante una búsqueda sin éxito con el fin de hacer que las búsquedas con éxito sean más eficaces. De hecho, R. P. Brent desarrolló un método en el que el tiempo medio de búsqueda puede ser acotado por una constante, que es muy útil en aplicacio- nes que implican frecuentes búsquedas con éxito en tablas muy grandes, tales como los diccionarios. Éstos son sólo dos ejemplos de un gran número de mejoras que se han pro- puesto para la dispersión. Muchas de estasmejoras son interesantes y tienen im- portantes aplicaciones. Sin embargo, se debe tener mucha precaución en la uti- lización prematura de métodos avanzados, excepto por expertos que tengan
  • 289. DICPERSI~N 269 serios problemas en aplicaciones de búsqueda, porque, en definitiva, el enca- denamiento separado y la doble dispersión son simples, eficaces y bastante aceptables en la mayoría de las aplicaciones. En muchas aplicacioneses preferible la dispersión a las estructuras de irbo- les binarios, porque es más simple y ofrece tiempos de búsqueda muy rápidos (constantes), si hay espacio disponible para tablas lo suficientemente grandes. Las estructuras de árbolesbinanos tienen la ventaja de ser dinámicas (no se ne- cesita información previa sobre el número de inserciones), pueden garantizar el rendimiento en el peor caso (todoslos elementos se pueden colocar en el mismo lugar al igual que lo podría hacer el mejor método de dispersión) y permiten una gran variedad de operaciones (la más importante, la función ordenar). Cuando estos factores no son importantes, la dispersión es ciertamente el mé- todo de búsqueda ideal. Ejercicios 1. Describir cómo podría implementarse una función de dispersión haciendo uso de un buen generador de números aleatonos. ¿Tendría sentido imple- mentar un generador de números aleatonos utilizando una función de dis- persión? 2. ¿Cuánto tiempo haría falta en el peor caso para insertar N claves en una tabla inicialmente vacía, utilizando el método de encadenamientoseparado con listas desordenadas?Responder a la misma pregunta pero con listas or- denadas. 3. Dar el contenido de la tabla de dispersión que resulta cuando se insertan las claves C U E S T 1 O N F A C I L en una tabla inicialmente vacía de tamaño 13utilizando la exploración lineal. (Utilizarhi(k)= k mod 13para la función de dispersión de la k-ésima letra del alfabeto.) 4. Dar el contenido de la tabla de dispersión que resulta cuando se insertan las claves C U E S T I O N F A C I L en una tabla inicialmente vacía de tamaño 13 utilizando la doble dispersión. (Utilizar el hi(& de la pregunta anterior y h2(k)= 1 + (kmod 1I) para la segunda función de dispersión.) 5. Aproximadamente, ¿cuántas exploracionesdeben hacerse cuando se utiliza la doble dispersión para construir una tabla de N claves iguales? 6. ¿Qué método de dispersión utilizaría para una aplicación en la que es po- sible que estén presentes muchas claves iguales? 7 . Suponiendoque el número de elementos a insertar en una tabla de disper- sión se conoce por adelantado. ¿Bajo que condiciones es preferible el mé- todo del encadenamientoseparado a la doble dispersión? 8. Suponiendo que un programador tiene un error en un programa de doble dispersión de modo que una de las dos funciones devuelve siempre el mismo valor (distinto de O), describir lo que sucede en cada situación (cuando es la primera función la que está mal y cuando lo es la segunda).
  • 290. 270 ALGORITMOS EN C++ 9. ¿Qué función de dispersión debe utilizarse si se conoce por adelantado que los valores de las claves pertenecen a un intervalo relativamente pequeño? 10. Hacer una crítica del algoritmo siguiente para suprimir en una tabla de dis- persión construida por el método de exploración lineal. Explorar hacia la derecha, desde el elemento que se va a eliminar (dando la vuelta si es ne- cesario) hasta encontrar una posición vacía, después explorar hacia la iz- quierda hasta encontrar un elemento con el mismo valor de dispersión. Fi- nalmente reemplazar el elemento a suprimir por este último dejando vacía su posición en la tabla.
  • 291. 17 Búsqueda por residuos Varios métodos de búsqueda producen examinando las claves de búsqueda a razón de un bit cada vez, en lugar de hacer comparaciones completas entre cla- ves en cada paso. Estos métodos, denominados métodos de búsqueda por resi- duos,trabajan con los bits de las propias claves, y no con las versiones transfor- madas de las claves utilizadas en la dispersión. Al igual que los de ordenación por residuos (ver el Capítulo lo), estos métodos pueden ser muy útiles cuando los bits de las claves de búsqueda son fácilmente manipulables y los valores de las claves están bien distribuidos. La ventaja principal de los métodos de búsqueda por residuos es que pro- porcionan un rendimiento razonable en el peor caso, sin las complicacionesde los árboles equilibrados; también proporcionan un método fácil para utilizar claves de longitud variable; algunos permiten incluso ganar espacio almace- nando parte de la clave dentro de la estructura de búsqueda; y, finalmente, per- miten un acceso muy rápido a los datos, compitiendo tanto con los árbolesbi- narios de búsqueda como con la dispersión. Sus inconvenientes son que toda inclinación en los datos puede provocar un mal rendimiento por degeneración de los árboles (y todo dato compuesto por caracteres está inclinado) y que al- gunos de estos métodos pueden malgastar inútilmente el espacio de la memoria. Al igual que con la ordenación por residuos, estos métodos se diseñan para aprovechar las características particulares de las arquitecturas de las computa- doras: puesto que utilizan las propiedades digitales de las claves, es difícil, o im- posible, hacer implementaciones eficaces en algunos lenguajes de alto nivel. En este capítulo se examinarán una serie de métodos, de los que cada uno comge un defecto inherente al anterior, y se terminará con un método impor- tante que es bastante útil en aplicacionesde búsqueda en las que se trabaja con claves de gran longitud. Además se estudiará el análogo de la «ordenación en tiempo lineal» del Capítulo 10,una busqueda en «tiempoconstante» basada en el mismo principio. 271
  • 292. 272 ALGORITMOS EN C++ Árboles de búsqueda digital El método de búsqueda por residuos más simple es el de búsqueda digital: el algoritmo es precisamente el mismo que el de búsqueda por árbol binario, ex- cepto que el movimiento por las ramas del árbol no se hace de acuerdo con el resultado de una comparación entre claves, sino con los bits de la clave. En el primer nivel se utiliza el primer bit, en el segundo nivel se utiliza el segundobit, y así hasta encontrar un nodo externo. El código es virtualmente el mismo que el de la búsqueda por árbol binario. La única diferencia es que las claves son del tipo c l avebi ts utilizado en la ordenación por residuos y se utiliza la función b i t s , para tener acceso a los bits individuales, en lugar de las comparaciones entre claves. (Se recuerda del Capítulo 1O que v. bits (k,j) son los j bits que aparecen a k bits de distancia del extremo derecho de la representación binaria de Y; esto se puede implementar eficazmente en lenguaje de máquina despla- zando k bits hacia la derecha, y poniendo después a O todos los bits menos los j más a la derecha.) TipoInfo Dicc: :buscar(tipoEemento v) s t r u c t nodo *x = cabeza; i n t b = tipoElemento: :rnaxb; z->clave = v; while (v != x->clave) { x = (v.bits(b--, 1 ) ) ? x->der r e t u r n x->info; 1 //Arb01 d i g i t a l x->t izq ; Las estructuras de datos de este programa son las mismas que las que se utili- zaron en los árboles binarios de búsqueda elementales. La constante maxb es el número de bits de las claves que se van a ordenar. El programa supone que el primer bit de cada clave (el que está a (maxb+l) de la derecha) es O (tal vez la clave sea el resultado de utiIizar b i t s con maxb como segundo argumento),.así que la búsqueda comienza en cabeza, un enlace a un nodo de cabecera del ár- bol con clave O, que posee un enlace izquierdo que apunta al árbol de búsqueda. Así el procedimiento de inicialización para este programa es el mismo que para el de búsqueda por árbol binario, excepto que se empieza con cabeza->i zq = z en lugar de cabeza->der = z. En el Capítulo 10 se vio que las claves iguales son un anatema en la orde- nación por residuos: lo mismo sucede en la búsqueda por residuos, no en este algoritmo en particular, pero sí en los que se examinarán más adelante. Así pues en este capítulo se supone que todas las claves que aparecen en la estructura de datos son distintas: si es necesario, se puede mantener una lista enlazada, para cada valor de clave, de todos los registros cuyas claves tienen ese valor. Como
  • 293. BÚSQUEDA POR RESIDUOS 273 E o 07- o 1 J o 1 0 1 0 P o 1 1 0 1 L 1 0 0 0 0 M o i i o o 0 0 1 1 1 1 D o o i o o B o o o i o u 1 0 1 0 1 s i 0 0 1 1 Q i o o o i Figura 17.1 Un árbol de búsqueda digital. en los capítulos precedentes, se supone que la i-ésima letra del alfabeto se repre- senta por medio de la representación binaria de cinco bits de i. Las claves del ejemplo que se utilizarán en este capítulo se muestran en la Figura 17.1. Para ser consistentes con bits, se considera que los bits están numerados del O al 4 y de derecha a izquierda. Así por ejemplo, el bit 1 es el único bit 1 (no cero) de B y el bit 4 es el único bit 1 (no cero) de P. El procedimiento de inserción para árboles de búsqueda digital se obtiene directamente del procedimiento correspondiente para los árboles binarios de búsqueda: void Dicc: :insertar(tipoElemento v, tipoInfo info) i struct nodo *p, *x = cabeza; int b = tipoElemento::maxb; while (x != z) p = x; x = (v.bits(b--, 1)) ? x->der : x->izq; { 1 x = new nodo; x->clave = v; x->info = info; x->izq = z; x->der = z; i f (v.bits(b+l, 1)) p->der = x; else p->izq = x; 1 El árbol construido por este programa, cuando las clavesdel ejemplo se insertan en un árbol inicialmente vacío, se muestra en la Figura 17.1. La Figura 17.2 muestra lo que sucede al añadir una nueva clave Z = 11O1O al árbol de la Figu- ra 17.1. Se debe ir por la derecha dos veces, porque los dos primeros bits de Z
  • 294. 274 ALGORITMOS EN C++ Figura 17.2 Inserción (de 2)en un árbol de búsqueda digital. son 1, hasta donde se encuentra el nodo externo a la derecha de P, que es donde se insertará Z . El peor caso para árboles construidos con búsqueda digital es mucho mejor que el de los árboles binarios de búsqueda, si el número de claves es grande y no son largas.La longitud del camino más largo en un árbol de búsqueda digital es el mayor número de bits sucesivos iguales de dos claves cualesquiera del ár- bol, a partir del bit más a la izquierda, y esta cantidad es relativamente pequeña en muchas aplicaciones (por ejemplo, si las claves están compuestas de bits aleatorios). Propiedad 17.1 Una búsqueda o inserción en un árbol de búsqueda digital, construido sobre N claves de b bits aleatorios, necesita alrededor de 1gNcom- paracionespor término medio y b comparaciones en el peor caso. Es evidente que ningún camino será nunca más largo que el número de bits de las claves:por ejemplo, un árbol de búsqueda digital construido a partir de cla- ves de ocho caracteres, con seisbits por carácter, no tendrá ningún camino ma- yor que 48,incluso si hay cientos de miles de claves. Demostrar que los árboles de búsqueda digital están casi perfectamente equilibrados necesita un análisis que va más allá del alcance de este libro, aunque este hecho confirma la noción intuitiva de que el «siguiente» bit de una clave aleatona tiene la misma proba- bilidad de comenzar por O que por 1, así que la mitad de las claves deben en- contrarse a cada lado de un nodo dado. La Figura 17.3 muestra un árbol de búsqueda digital construido a partir de 95 claves aleatonas de 7 bits. Este árbol está bastante bien equi1ibrado.i Así, los árboles de búsqueda digital ofrecen una alternativa atractiva a los de búsqueda binana estándar, siempre y cuando la extracción de bits sea tan fácil de hacer como la comparación entre claves (consideración que es dependiente de la máquina).
  • 295. BÚSQUEDA POR RESIDUOS 275 Figura 17.3 Un gran arb! de búsqueda digital. Árboles de búsqueda por residuos Es bastante frecuente que las claves de búsqueda sean muy largas y estén cons- tituidas, quizás, por veinte caracteres o más. En tal situación, el costede la com- paración de la igualdad entre la clave de búsqueda y una clave de la estructura de datos puede ser preponderante y no debe ignorarse. La búsqueda por árbol digital efectúa una tal comparación en cada nodo del Arbol; en esta sección se verá que en la mayor parte de los casos es posible lograrlo con una sola com- paración por búsqueda. La idea es no almacenar claves en los nodos internos del árbol, sino poner todas en los nodos externos. Esto es, en lugar de utilizar z para los nodos ter- minales de la estructura, se ponen nodos que contienen las claves de búsqueda. Así pues, se tienen dos tipos de nodos en la estructura: nodos internos, que sólo contienen enlaces a otros nodos, y nodos terminales que contienen claves y no enlaces.(Fredkin denominó a este método trie porque es útil para la extracción («retrieval»),palabra que suele pronunciarse (drai-b)o simplemente (drab). Para buscar una clave en una estructura como ésta, es preciso moverse por las ramas de acuerdo con sus bits, al igual que anteriormente, pero sin comparar la clave con nada hasta que no se alcance un nodo externo. Cada clave del árbol se al- macena en un nodo terminal del camino descrito por el conjunto de los pri- meros bits de la clave y, como cada clave de búsqueda termina en un nodo ter- minal, se necesita una comparación completa de las claves para terminar la búsqueda. La Figura 17.4 muestra el método trie de búsqueda por residuospara las cla- ves E J M P L. Por ejemplo, para llegar a E, se parte de la raíz yendo primero a la izquierda y luego otra vez a la izquierda, ya que los dos primeros bits de E son 00; pero al contrario, como ninguna de las claves del método trie comienza con los bits 11, al moverse en la dirección derecha-derecha se llega a un nodo terminal. Antes de pensar en una inserción, el lector debe reflexionar sobre la sorprendente propiedad de que la estructura trie es independiente del orden en el que se insertan las claves: hay un único trie para cualquier conjunto dado de claves distintas. Como es habitual, después de una búsqueda sin éxito, se puede insertar la
  • 296. 276 ALGORITMOS EN C++ Figura 17.4 Un trie de árbol de búsqueda por residuos. clave reemplazando el nodo terminal en el que terminó la búsqueda, a condi- ción de que no contenga una clave. Éste es el caso cuando se inserta O en el trie de la Figura 17.4, como se muestra en el primer trie de la Figura 17.5. Si el nodo externo en el que termina la búsqueda contiene una clave, debe reemplazarse por un nodo interno que contenga en los nodos externos por debajo de él, la clave en cuestión y la clave en la que termina la búsqueda. Por desgracia, si es- tas claves coinciden en más posiciones de bits, es necesario añadir algunos no- dos terminales que no corresponden a clavesdel árbol (es decir, nodos internos con un nodo terminal vacío como hijo). Esto es lo que sucede cuando se inserta D, como se muestra en el segundo trie de la Figura 17.5. El resto de la Figura 17.5 muestra cómo se completa el ejemplo cuando se añaden las claves B U S Q A. La implementación de este método en C++ requiere algunos trucos por la necesidad de mantener dos tipos de nodos sobre cada uno de los cuales podrían apuntar los enlaces de los nodos internos. Éste es un ejemplo de un algoritmo para el que una implementación de bajo nivel puede resultar más simple que una representación de alto nivel. Se omite el código para este caso porque pos- teriormente se verá una mejora que evita este problema. El subárbol izquierdo de un trie de búsqueda por residuos contiene todas las claves que tienen un O como bit más significativo y el subárboi derecho con- tiene todas las claves que tienen un 1 como bit más significativo. Esto conduce a una correspondencia inmediata con la ordenación por residuos: la búsqueda por tne binano particiona el archivo exactamente de la misma forma que la or- denación por intercambio de residuos. (Se puede comparar el trie anterior con la Figura 10.1, el diagrama de partición de la ordenación por intercambio de residuos, teniendo en cuenta que las claves son ligeramente diferentes.)Esta co- rrespondencia es análoga a la que existe entre la búsqueda por árbol binario y el Quicksort. Propiedad 17.2 Una búsqueda o una inserción en un trie de búsqueda por re- siduos necesita alrededor de 1gNcomparaciones de bits por Iérmino medio y b comparaciones de bits en el peor caso, en un árbol construido a partir de N cla- ves aleatorias de b bits.
  • 297. BÚSQUEDA POR RESIDUOS 277 Figura 17.5 Construcción de un trie de búsquedapor residuos. Como se hizo anteriormente, el resultado del peor caso se obtiene directamente del algoritmo y para el caso medio se requiere un análisis matemático que so- brepasa el alcance de este libro, aunque esta propiedad da validez a la noción intuitiva de que cada bit examinado puede ser lo mismo un O que un 1, así que aproximadamente la mitad de las claves deben encontrarse en cada lado de un nodo del trie.i Una característica molesta de los tries por residuos, que los distingue de los otros tipos de árboles de búsqueda que se han visto, es la ramificación «unidi- reccional» que se necesita para las claves con un gran número de bits iguales. Por ejemplo, las claves que difieren solamente en el último bit necesitan un ca- mino cuya longitud sea igual a la de la clave, independientemente del número
  • 298. 278 ALGORITMOS EN C++ Figura 17.6 Un gran trie de búsqueda por residuos. de clavesque haya en el árbol. El número de nodos internos puede ser algo ma- yor que el de claves. Propiedad 17.3 ves aleatorias de b bits contiene alrededor de NAn2 = 1.44Nnodos de media. Un trie de búsqueda por residuos construido a partir de N cla- Una vez más, la demostración de esta afirmación va más allá del alcancede este libro, aunque se puede verificar empíricamente. La Figura 17.6 muestra un tne construido a partir de 95 claves aleatorias de 10bits, que tiene 131 nodos.. La altura de los tries se mantiene limitada por el número de bits de las cla- ves, pero se podría considerarla posibilidad de procesar registros con claves muy largas (1,000 bits o más) que quizás presenten cierta uniformidad, como puede ser el caso de datos codificadospor caracteres. Una forma de acortar los cami- nos de los árboles es utilizar mucho más de dos enlaces por nodo (aunque esto puede agudizar el problema de «espacio» al utilizar demasiados nodos); otra forma es ((colapsam los caminos que contienen ramas unidireccionales en en- laces sencillos. Estos métodos se presentarán en las dos secciones siguientes. Búsqueda por residuos multiple En la ordenación por residuos se vio que se pueden obtener importantes mejo- ras en la velocidad considerando varios bits a la vez. Esto es también cierto en la búsqueda por residuos: examinando w1 bits a la vez, se puede aumentar la velocidad en un factor 2". Sin embargo,hay una situación que impone algo más de cuidado al aplicar esta idea, lo que no fue necesario en la ordenación por residuos. El problema es que considerar m bits a la vez implica utilizar nodos con M = 2" enlaces, lo que puede conducir a derrochar un volumen conside- rable de espacio por los enlaces no utilizados. Por ejemplo, si M = 4 el trie que se obtiene para las claves del ejemplo es el que se muestra en la Figura 17.7.Para buscar en este trie, se consideran los bits
  • 299. SÚSQUEDA POR RESIDUOS 279 Figura 17.7 Un trie por residuos de 4 vias. de dos en dos: si los dos primeros bits son 00, entonces se sigue el enlace iz- quierdo del primer nodo; si son O1, se sigueel segundo enlace;si son 1O, se sigue el tercer enlace, y si son 11, el enlace derecho. La dirección para moverse por las ramas hacia el siguiente nivel se obtiene de acuerdo con el tercero y cuarto bits, etc. Por ejemplo, para buscar V = 10.110 en el trie de la Figura 17.7 se sigue el tercer enlace de la raíz y luego el cuarto enlace del tercer hijo de la raíz, para acceder a un nodo terminal, de modo que la búsqueda no tiene éxito. Para insertar V, se debe reemplazar dicho nodo por uno nuevo que contenga a V (y cuatro enlaces externos). Es de destacar que hay un cierto despilfarro de espacio en este árbol por el gran número de enlaces a nodos terminales que no se utilizan. A medida que M crece este efecto se acentúa: esto conduce a que el número de enlaces utili- zados sea de alrededor de MN/lnM para claves aleatorias. Por otra parte, éste es un método de búsqueda muy eficaz: el tiempo de ejecución es de alrededor de logMN.Se puede establecerun compromiso razonableentre la eficacia en tiempo de los tries múltiples y la economía de espacio de otros métodos, utilizando un método «híbrido» con un valor muy grande de M en lo alto (por ejemplo en los dos primeros niveles) y un valor pequeño de M (oalgún método elemental) en los niveles inferiores.Aquí, otra vez, las implementaciones eficaces de tales mé- todos pueden ser muy complicadas, debido a la presencia de múltiples tipos de nodos. Por ejemplo, un árbol de dos niveles y 32 vías divide a las claves en 1.024 categorías, cada una accesible por medio de un descenso de dos niveles del ár- boi. Esto sería bastante útil en archivos de miles de claves, porque posiblemente existen (sólo)unas pocas claves por categoría. Por otro lado, un M pequeño se- ría apropiado para archivos de cientos de claves, porque de lo contrario la ma- yoría de las categorías estarían vacías y se gastaría demasiado espacio, y un M grande sería aprcpiado para archivos con millones de claves, porque de lo con- trario la mayoría de las categoríastendrían muchas claves y se perdería mucho tiempo en las búsquedas. Es asombroso comprobar que la búsqueda «híbrida» corresponde muy de cerca a la forma en que los humanos buscan las cosas, por ejemplo, los nombres en una guía de teléfonos. El primer paso es una decisión múltiple («Vamos a ver, comienza con ‘A’»), seguida probablemente de alguna decisión de dos vías («Está antes de ‘Andrés’,pero después de ‘Aivar’)))y después de una búsqueda
  • 300. 280 ALGORITMOS EN C++ secuencia1 («‘Alfonso’... ‘Algora’...‘Algrano’... iNo, ‘Algoritmos’ no está en la lista!))).Por supuesto, las computadorasestán posiblemente mejor dotadas que los humanos para las búsquedas múltiples, así que son suficientes dos niveles. ’También las ramificaciones de 26 vías (incluso con más niveles) son una alter- nativa bastante razonable a considerar para claves que estén compuestas sola- mente por letras (por ejemplo, en un diccicnario). En el próximo capítulo, se verá un método sistemático para adaptar la es- tructura con el fin de obtener provecho de la búsqueda por residuos múltiples en el caso de archivos de tamaño arbitrario. Patricia Como se ha señalado, el método de búsqueda trie por residuos tiene dos defec- tos molestos: la «ramificación de una sola vía», que provoca la creación de no- dos extra en el árbol, y la existencia de dos tipos diferentes de nodos, lo que complica en cierta forma el código (en especial el de una inserción). D.R. Mo- rrison descubrió una forma de evitar ambos problemas en un método que de- nominó Patricia («PracticalAlgorithm ToRetrieve Information Coded In Alp- hanumerim o (Algoritmo Práctico para Recuperar Información Codificada en Alfanumérico))).El algoritmo que se da a continuación no es exactamente de la misma forma que la presentada por Morrison, porque él estaba interesado en aplicaciones de «búsqueda de cadenas» del tipo de las que se verán en el Capí- tulo 19. En el contexto presente, Patricia permite la búsqueda de N claves de longitud arbitraria en un árbol que tiene exactamenteN nodos, necesitando sólo una comparación completa por búsqueda. La ramificación cnidireccional se evita gracias a un simple recurso: cada nodo contiene el índice del bit que se deberá comprobar para decidir qué camino to- mar cuando se salga de este nodo. Se evitan los nodos terminales reemplazando los enlaces hacia los nodos externos por enlaces que apuntan hacia niveles su- periores del árbol, lo que lleva a la noción habitual de los nodos normales del árbol, con una clave y dos enlaces. Pero en Patricia, las claves de los nodos no se utilizan para controlar la búsqueda en el recorrido hacia abajo del árbol; sim- plemente se almacenancomo referencia para cuando se alcance el fondo del ár- bol. Para ver cómo funciona Patricia, se comienza por observar cómo opera en un árbol tipo y luego examinarcómo se construye el árbol. El árbol Patricia que se muestra en la Figura 17.8 se constmyó insertando sucesivamente las claves del ejemplo. Para buscar en este árbol, se comienza por la raíz y se desciende utilizando el índice de bit de cada nodo para saber qué bit examinar en la clave de bús- queda. Se sigue hacia la derecha si ese bit es 1 y hacia la izquierda si es O. Las claves de los nodos no se examinan del todo en ese descenso. Tarde o temprano se encuentra un enlace ascendente: cada uno de ellos apunta hacia la única clave del árbol que contiene los bits que causarían una búsqueda que alcance tal en-
  • 301. BÚSQUEDA POR RESIDUOS 281 4 3 Figura 17.8 Un árbol Patricia. lace. Por ejemplo, S es la única clave del árbol que concuerdacon el patrón de bits 1O* 11. Así que si la clave del nodo apuntado por el primer enlace ascen- dente que se encuentre es igual a la clave buscada, la búsqueda tiene éxito, y en caso contrario será infructuosa. En los tries, todas las búsquedas terminan en nodos externos, así que se necesita una comparacióncompleta de la clave para determinar cuándo una búsqueda tuvo éxito o no; para Patricia todas las bús- quedas terminan en enlaces ascendentes, así que también se necesita una com- paración completa de la clave para determinar si la búsqueda tuvo éxito o no. Además, es fácil verificar si un enlace es ascendente o no, porque los índices de bits de los nodos (por definición) decrecen a medida que se desciende por el ár- bol. Esto conduce al siguiente código de búsqueda para Patricia, que es tan sim- ple como el del árbol por residuos o el de la búsquedapor trie: tipoInfo Dicc: :buscar(tipoElemento v) // Arbol Patricia struct nodo *p, *x; p = cabeza; x = cabeza->izq; while (p->b ->b) { < I p = x; x = (bits(v, x->b, 1 ) ) ? x->der : x->izq; 1 if (v != x->clave) return infoNIL; return x->info; Esta función encuentra el único nodo que podría contener el registro de clave v, y después verifica si la búsqueda ha tenido éxito o no. Así, para buscar Z=11O10en el árbol anterior, se sigue hacia la derecha, luego hacia la izquierda y después otra vez a la derecha, encontrando el enlace ascendente que lleva a S; así que la búsqueda no tiene éxito.
  • 302. 282 ALGORITMOS EN C++ Figura 17.9 Inserciónexterna en un árbol Patricia. La Figura 17.9 muestra el resultado de insertar Z = 11010 en el árbol Patri- cia de la Figura 17.8. Como se describió anteriormente, la búsqueda de Z ter- mina en el nodo que contiene a S = 10011. Por la propiedad de definición del árbol, S es la única clave del árbol para la que una búsqueda terminaría en ese nodo. Si se inserta Z, habría dos nodos así que el enlace ascendente que se di- rigía al nodo que contiene a S debe ponerse ahora a apuntar al nuevo nodo que contiene a Z, con un bit de índice que corresponde ai punto más a la izquierda en el que S y Z difieren y con dos enlaces ascendentes: uno apuntando a S y el otro apuntando a Z. Esto corresponde precisamente a reemplazar en la inser- ción tne por residuos el nodo terminal que contiene a S por un nuevo nodo interno con S y Z como hijos, eliminando una ramificación de una vía por la inclusión del bit de índice. La inserción de G = O0111 ilustra un caso más complicado, como se mues- tra en la Figura 17.1O. La búsqueda de G termina en E = O 0101, indicando que E es la única clave del árbol con el patrón 001*1. Ahora, G y E difieren en el bit 1, una posición que se saltó durante la búsqueda. El requisito de que el bit de índice decrezcaa medida que se desciendepor el árbol exige que G se inserte entre D y E, con un autoapuntador hacia arriba que corresponde a su propio Figura 17.10 Insercióninterna en un árbol Patricia.
  • 303. BÚSQUEDA POR RESIDUOS 283 bit 1. En este árbol destaca el hecho de que el haberse saltado el bit 3 en el sub- árbol derecho implicase U, S y Q tienen el mismo valor en el bit 3. Estos ejemplos ilustran los dos únicos casos que se pueden presentar en una inserción en Patricia. La implementación siguiente da los detalles del proceso: void Dicc: :insertar(tipoElemento v, t i p o I n f o i n f o ) s t r u c t nodo *p, *t, *x; i n t i = maxb; p = cabeza; t = cabeza->izq; while (p->b > t->b) i f ( v == t->clave) return; w h i l e (bits(t->clave, i,1) == b i t s ( v , i , 1)) i--; p = cabeza; x = cabeza->izq; while (p->b > x->b && x->b > i ) t = new nodo; t->clave = v; t->info = i n f o ; t->b = i ; t->izq = ( b i t s ( v , t->b, 1)) ? x : t; t->der = ( b i t s ( v , t->b, 1)) ? t : x; if ( b i t s ( v , p->b, 1 ) ) p->der = t ; else p->izq = t; { { p = t; t = ( b i t s ( v , t->b, 1 ) ) ? t->der : t->izq; } { p = x; x = ( b i t s ( v , x->by 1)) ? x->der : x->izq; } 1 (Este código supone que cabeza se inicializa con un campo clave igual a O, un índice de bit en maxb y dos enlaces autoapuntando a cabeza.) Primero se hace una búsqueda hasta encontrar la clave que se debe distinguir de v. Las condi- ciones x-> b <= iy p->b <= x-> b caracterizan las situaciones que se muestran en las Figuras 17.10 y 17.9, respectivamente. A continuación se determina la posición del bit más a la izquierda a partir del cual difieren las claves, descen- diendo por el árbol hasta ese punto e insertando un nuevo nodo que contenga a v. Patricia es la quintaesencia de los métodos de búsqueda por residuos: per- mite la identificación de los bits que distinguen las claves de búsqueda y los or- ganiza dentro de una estructura de datos (sin nodos sobrantes) que conduce rá- pidamente, a partir de cualquier clave de búsqueda, a la única clave de la estructura de datos que pueda ser igual a aquélla. Evidentemente, la misma téc- nica utilizada en Patricia puede utilizarse en una búsqueda trie por residuos bi- nana para eliminar las ramificaciones de una sola vía, pero esto no hace más que aumentar el problema de los múltiples tipos de nodos. La Figura 17.11 muestra el árbol Patricia para las mismas claves utilizadas para construir el trie de la Figura 17.6. Este árbol no sólo contiene un 44% de nodos menos, sino que está mejor equilibrado.
  • 304. 284 ALGORITMOS EN C++ Figura 17.11 Un gran árbol Patricia. A diferencia de la búsqueda estándar por árbol binario, los métodos por re- siduos no son sensibles al orden en el que se insertan las claves: dependen so- lamente de la estructura de las claves en sí mismas. Para Patricia la colocación de los enlaces ascendentes depende del orden de inserción, pero la estructura del árbol depende sólo de los bits de las claves, como en los otros métodos. Así que incluso Patricia podría tener problemas con un conjunto de claves como O01, O001, O0001, O00001, etc., pero para conjuntos normales de claves, el ár- bol debe estar relativamente bien equilibrado, por tanto, el número de inspec- ciones de bits, aun para claves muy grandes, será aproximadamenteproporcio- nal a 1gNcuando hay N nodos en el árbol. Propiedad 17.4 Un trie Patricia construido a partir de N claves aleatorias de b bits tiene N nodos y necesita 1 g Ncomparaciones de bits para una búsqueda me- dia. Al igual que para los otros métodos de este capítulo, el análisis del caso medio es bastante difícil: resdta que Patricia implica una comparación menos, como media, que en el caso de una búsqueda en un trie estándar.= La caracteristica más útil de la búsqueda trie por residuos es que se puede realizar eficazmente con claves de longitud variable. En todos los otros métodos de búsqueda que se han visto, la longitud de la clave está de alguna forma «in- tegrada) en el procedimiento de búsqueda, por lo que el tiempo de ejecución depende de la longitud de las claves, así como de su número. Las posibles ga- nancias que se puedan lograr dependen del método de acceso a los bits que se utilice. Por ejemplo, suponiendo que se dispone de una computadora que puede acceder eficazmente a datos en «octetos» de 8 bits, y que se necesita buscar en- tre cientos de claves de 1.000 bits, entonces Patricia necesitaría acceder sola- mente a alrededor de 9 o 10 octetos de la clave de la búsqueda, más una com- paración de igualdad de 125 octetos, mientras que la dispersión necesitaría acceder a los 125 octetos de la clave para calcular la función de dispersión, más algunas comparaciones de igualdad, y los métodos basados en comparaciones necesitarian vanas comparaciones de gran longitud. Esta propiedad convierte a ~ Patricia (o a la búsqueda por residuos trie sin ramificacionesde una sola vía) en el método de búsqueda a escoger cuando las claves sean muy largas.
  • 305. BÚSQUEDA POR RESIDUOS 285 Ejercicios 1. Dibujar el árbol de búsqueda digital que se obtiene al insertar las claves C U E S T I O N F A C I L, en este orden, en un árbol inicialmente vacío. 2. Generar un árbol de búsqueda digital de 1.O00 nodos y comparar su altura y el número de nodos de cada nivel con los de un árbol de búsqueda bina- rio estándar y con los de un árbol rojinegro (Capítulo 15) construidos sobre las mismas claves. 3. Encontrar un conjunto de 12 claves que generen un árbol de búsqueda di- gital particularmente mal equilibrado. 4. Dibujar el árbol de búsqueda por residuos que se obtiene al insertar las cla- ves C U E S T I O N F A C I L, en este orden, en un árbol inicialmente vacío. 5. Un inconveniente de una búsqueda por residuos múltiple de 26 vías es que algunas letras del alfabeto se utilizan muy poco. Sugerir una forma de re- solver este problema. 6. Describir una forma de suprimir un elemento de un árbol de búsqueda por residuos múltiple. 7. Dibujar el árbol Patricia que se obtiene al insertar las claves C U E S T I O N F A C I L, en este orden, en un árbol inicialmente vacío. 8. Encontrar un conjunto de 12 claves que generen un árbol Patricia particu- lamente mal equilibrado. 9. Escribir un programa que imprima todas las clavesde un árbol Patricia que tengan los mismos t bits iniciales que la clave de búsqueda. 18. ¿Para cuál de los métodos por residuos es razonable escribir un programa que imprima las claves ordenadas? ¿Qué métodos no son aconsejablespara esta operación?
  • 307. 18 Búsqueda externa Los algoritmos de búsqueda adaptados para acceder a los elementos de archivos muy grandes tienen una inmensa importancia práctica. La búsqueda es la ope- ración fundamental en los grandes archivos de datos, que consume una parte muy significativa de los recursos utilizados en muchos sistemas informáticos. En este capítulo se centrará el interés sobre todo en los métodos de bús- queda en grandes archivos en disco, puesto que la búsqueda en disco es la de mayor interés práctico. Con dispositivos secuenciales como las cintas, la bús- queda se transforma rápidamente en un método trivial y lento: para buscar un elemento en una cinta no se puede hacer otra cosa que instalarla y leerla hasta encontrar el elemento. Notablemente, los métodos que se estudiarán aquí pue- den encontrar un elemento en un disco de una capacidad de hasta un millón de palabras con sólo dos o tres accesos al disco. Al igual que en la ordenación externa, el aspecto «sistema»,unido a la uti- lización de materiales complejos de E/S, es un factor decisivo en el rendimiento de los métodos de búsqueda externa, pero no será posible estudiarlo con gran detalle. Sin embargo, a diferencia de la ordenación, donde los métodos externos son realmente muy diferentes de los internos, se verá que los métodos de bús- queda externa son prolongaciones lógicas de los métodos que se han estudiado para la búsqueda interna. La búsqueda es una operación fundamental en los dispositivosde disco. Los archivos se organizan por lo general de forma que se aprovechen las caracterís- ticas particulares de los dispositivos para permitir un acceso a la información tan eficaz como sea posible. Como se hizo con la ordenación, se trabajará con un modelo algo simple e impreciso de dispositivosde «discos» con el objeto de exponer las características principales de los métodos fundamentales. La deter- minación de cuál es el mejor método de búsqueda externa para una aplicación en particular es extremadamente complicada y depende mucho de las caracte- rísticas del material (y del software de los sistemas),y por lo tanto está fuera del alcance de este libro. Sin embargo, es posible sugerir algunas concepciones ge- nerales a utilizar. 287
  • 308. 288 ALGORITMOS EN C++ En muchas aplicaciones,con frecuencia se desea poder cambiar, añadir, eli- minar o (lo más importante) acceder rápidamente a algunos bits de informa- ción de archivos muy grandes. En este capítulo se examinaránalgunos métodos para tales situaciones dinámicas, que ofrecen sobre los métodos directos el mismo tipo de ventajas que la búsqueda por árbol binario y la dispersión ofre- cen sobre la búsqueda binaria y la secuencial. Todo gran conjunto de información que se ha de procesar por medio de una computadora se denomina base de datos. Se ha realizado gran cantidad de es- tudios para construir, mantener y utilizar bases de datos. Sin embargo, las gran- des bases de datos tienen una inercia muy elevada: una vez que se ha construido una de ellas alrededor de una determinada estrategia de búsqueda resulta muy costoso reconstruirla para otra. Por esta razón, los antiguos métodos estáticosse utilizan ampliamentey quizá se mantengan mucho tiempo, aunque se están co- menzando a utilizar nuevos métodos dinámicosen las bases de datos más mo- dernas. Por lo regular, las aplicaciones de gestión de bases de datos permiten ope- raciones mucho más complicadas que la simple búsqueda de un elemento por medio de una clave. A menudo las búsquedas se apoyan en criterios que impli- can más de una clave y que devuelven un gran número de registros. En los ú1- timos capítulos se verán algunosejemplos de algoritmos adaptados a las peticio- nes de búsqueda de este tipo, pero las peticiones de búsqueda suelen ser lo suficientemente complejas como para que sea normal hacer una búsqueda se- cuencial sobre toda la base de datos, evaluando cada registro para ver si satis- face los criterios. Los métodos que se presentan en este capítulo son de importancia práctica en la implementaciónde sistemas de grandes archivos en los que cada uno tiene un identificador único, con el objeto de permitir el acceso, las insercionesy eli- minaciones, basados en dicho identificador. En el modelo que se va a tratar se considerará que el espacio de almacenamientoen disco está dividido en púgi- nas, bloques contiguos de información a las que los mecanismosdel disco pue- den acceder eficazmente. Cada página contendrá muchos registros que se deben organizar dentro de ellas de tal forma que se pueda llegar a cualquier registro leyendo solamente algunas páginas. Se supone que el tiempo de E/S necesario para leer una página domina totalmente al tiempo de procesamiento que se re- quiere para hacer cualquier cálculo sobre la información que contiene la pá- gina. Como se mencionó con anterioridad, este modelo está muy simplificado en ciertos aspectos, pero refleja bastante las característicasde los dispositivosac- tuales de almacenamiento externo como para permitir valorar alguno de los metodos fundamentales utilizados.
  • 309. BÚSQUEDA EXTERNA 289 Figura 18.1 Acceso secuencial. Acceso secuencial indexado La búsqueda secuencial en disco es la extensión natural de los métodos de bús- queda secuencial elementales que se estudiaron en el Capítulo 14: Íos registros se almacenan en orden creciente de sus claves y las búsquedas se efectúan sim- plemente leyendo los registros uno tras otro hasta encontrar uno que tenga una clave mayor o igual que la buscada. Por ejemplo, si las claves de búsqueda son E J E M P L O D E B U S Q U E D A E X T E R N A y se dispone de discos capaces de contener tres páginas de cuatro registros cada una, entonces se ob- tiene la configuración que se muestra en la Figura 18.1. (Al igual que para la ordenación en memoria externa, se deben considerar pequeños ejemplos para entender los algoritmos y ejemplos muy grandes para apreciar su rendimiento.) Evidentemente, la búsqueda secuencialpura no es atractiva porque, por ejem- plo, buscar W en la Figura 18.1 requeriría leer todas las páginas. Para mejorar la velocidad de las búsquedas, se puede mantener para cada disco un dndice» que establezcaqué clavespertenecen a las páginas de ese disco, como en la Figura 18.2.La primera página de cada disco es su índice: las letras pequeñas indican que sólo se almacena el valor de la clave, no el registro com- pleto, y los números pequeños son los índices de páginas (O indica la primera página del disco, 1 la siguiente, etc.). En el índice, cada número de página apa- rece debajo del valor de la última clave de la página anterior. (El espacio en blanco es una clave centinela, menor que todas las otras, y el a+» significa (consultar el disco siguiente».)Así que, por ejemplo, el índice del disco 2 indica que su primera página contiene los registros con claves entre E y J, inclusive,y su segunda página los de claves entre J y O, inclusive. Por lo regular, es posible mantener muchas más claves e índices de páginas en una página de índices que registros en una página de «datos»; de hecho, el índice de un disco completo necesita sólo algunas páginas. Figura 18.2 Acceso secuencial indexado.
  • 310. 290 ALGORITMOS EN C++ Para acelerar aún más la búsqueda, estosíndices pueden estar acopladoscon un ((índicemaestro» que establezca qué claves están en qué discos. En el ejem- plo, el índice maestro diría que el disco 1 contiene las claves menores o iguales que E, el disco 2 las claves menores o iguales que O (pero no menores que E) y el disco 3 contiene las claves menores o iguales que X (pero no menores que P). El índice maestro es posiblemente tan pequeño como para tenerlo fijo en me- moria, de modo que la mayoría de los registros se pueden encontrar accediend? sólo a dos páginas, una para el índice del disco apropiado y una para la página que contiene el registro apropiado. Por ejemplo, una búsqueda de W implicaría primero la lectura de la página de índices del disco 3 y luego la lectura de la segunda página de datos del disco 3, que es la única que podría contener a W. Las búsquedas de las clavesque aparecen en el índice necesitan la lectura de tres páginas: la del índice más las dos páginas que flanquean al valor de la clave del índice. Si no hay claves duplicadas en el archivo se puede evitar el acceso a la página extra. Por otro lado, si hay muchas claves iguales en el archivo, pueden ser necesarios varios accesos a las páginas (registros con claves iguales pueden llenar varias páginas). Puesto que esto combina una organización secuencial de las claves con un acceso indexado, esta técnica se denomina acceso secuencial indexado. Éste es el método a escoger para aplicacionesen las que los cambios en la base de datos son poco frecuentes. El inconveniente de utilizar el acceso secuencialindexado es que resulta muy rígido. Por ejemplo, para añadir C a la configuración anterior se necesita que la base de datos se reconstruya prácticamente, con nuevas posiciones para la ma- yor parte de las claves y nuevos valores para los índices. Propiedad 18.1 Una búsqueda en un archivo secuencial indexado necesita sólo un número constante de accesos al disco, pero una inserción puede implicar reorganizar el archivo completo. De hecho, la ((constante))en cuestión depende del número de discos y del ta- maño relativo de los registros, los índices y las páginas. Por ejemplo, un gran archivo de claves de una sola palabra no podría estar almacenado en un solo disco de modo que permitiera la búsqueda con un número constante de acce- sos. O, para tomar otro ejemplo absurdo en el extremo opuesto, un gran nú- mero de discos muy pequeños, capaces de contener cada uno un solo registro, harían también difícil la búsqueda.= Árboles B Una forma mejor de efectuar la búsqueda en situaciones dinámicas es utilizar árboles equilibrados. Con objeto de reducir el número de los accesos al disco (que son relativamente caros), es razonable permitir un gran número de claves
  • 311. BÚSQUEDA EXTERNA 291 Figura 13.3 Un árbol B. por nodo, lo que provoca que los nodos tengan un alto grado de ramificación. Tales árboles fueron denominados árboles B por R. Bayer y E. McCreight, que fueron los primeros en considerar el uso de árboles equilibrados múltiples para las búsquedas en memoria externa. (Mucha gente reservael término árbol B para describirla estructura de datos construida por el algoritmo que sugirieronBayer y McCreight; aquí se utilizará este nombre como un término genérico que sig- nifica ((árbolesequilibrados externos)).) El algoritmo descendente que se utilizó para los árboles 2-3-4 (ver el Capí- tulo 15) se generaliza fácilmente para manipular más claves por nodo: supón- gase que existe un valor cualquiera entre 1 y M-1 de claves por nodo (y por tanto de 2 a M enlacespor nodo). La búsqueda se lleva a cabo en forma análoga a la de los árboles 2-3-4: para desplazarse de un nodo al siguiente, primero se debe encontrar el intervalo apropiado de la clave de búsqueda en el nodo en curso y entonces salir a través del enlace correspondientehacia el siguientenodo. Se continúa de esta manera hasta qde se alcance un nodo terminal, insertando la nueva clave en el último nodo interno alcanzado. Al igual que en los árboles 2-3-4 descendentes, es necesario adividim los nodos que están «llenos» que se encuentran al descender por el árbol: cada vez que se encuentre un k-nodo aso- ciado con un M-nodo, se reemplaza por un (k+l)-nodo asociado a dos (M/2)- nodos (se supone que M es par). Esto garantiza que cuando se alcance el fondo habrá espacio para insertar el nuevo nodo. El árbol B construido con M = 4 para el ejemplo del conjunto de claves se muestra en la Figura 18.3. Este árbol tiene 13 nodos, que corresponden cada uno a una página de disco. Cada nodo puede contener enlaces y registros. El escoger M = 4,aun cui )do conduce a los familiaresárboles 2-3-4, se hace para resaltar este punto: anteriormente se podían fijar cuatro registros por página; ahora sólo se fijarán tres para dejar espacio a los enlaces. La cantidad total de espacio utilizado depende del tamaño relativo de los registros y los enlaces. Pos- teriormente se verá un método qUe evita esta mezcla de registros y enlaces. Al igual que se mantiene en memoria el índice maestro en la búsqueda se- cuencial indexada, también es razonable mantener en memoria el nodo raíz del árbol B. Para el árbol B de la Figura 18.3, esto permitirá saber que la raíz del subárbol que contiene los registros con claves menores que E están en la página O del disco 1, la raíz del subárbol con claves menores que M (pero no menores que E) está en la página 1 del disco 1, y la raíz del subárbol con claves mayores
  • 312. 292 ALGORITMOS EN C++ Figura 18.4 Acceso al árbol B. o iguales que M está en la página 2 del disco 1. Los otros nodos del ejemplo están almacenados como se muestra en la Figura 18.4. En este ejemplo los nodos se asignan a las páginas del disco recomendo el árbol de arriba hacia abajo y de derecha a izquierda en cada nivel, asignando nodos al disco 1, luego al disco 2, etc. Se evita almacenar enlaces nulos si- guiendo la pista del lugar donde se alcanza el nivel del fondo: en este caso todos los nodos de los discos 2, 3 y 4 tienen todos los enlaces nulos (los cuales no ne- cesitan almacenarse).En una aplicación real entran enjuego otras consideracio- nes. Por ejemplo, pudiera ser mejor evitar que todas las búsquedas tengan que ir a través del disco 1, comenzandola asignación por la página O de cada disco, etc. De hecho se necesitan estrategias más sofisticadasdebido a la dinámica de la construcción de los árboles (considéresela dificultad de implementaruna m- tina de dividir que respete cualquiera de las estrategias anteriores). Propiedad 18.2 Una búsqueda o una inserción en un árbol B de orden M con N registros no necesita más de logMI2Naccesos al disco, lo que representa una constante en situaciones prácticas (mientras que M no sea demasiadopequeño). Esta propiedad se deduce de la observación de que todos los nodos del intenor de un árbol B (nodos diferentes de la raíz o las hojas) tienen entre M/2 y M claves, puesto que se han formadoa partir de una división de un nodo completo con M claves, y cuyo tamaño sólo puede crecer (cuando se divide un nodo in- ferior). En el peor caso, estos nodos forman un árbol completo de grado M/2, que conduce directamente a la cota establecida.. Propiedad 18.3 torios contiene aproximadamente 1,44N/Mnodos. UnárbolB de ordenM construido a partir de N registros alea- La demostración de esta afirmación sobrepasa el marco de este libro, pero se puede denotar que la cantidad de espacio perdido llega hasta N, en el peor caso, cuando todos los nodos están medio 1lenos.i En el ejemplo anterior ha sido forzosa la elección de M = 4por la necesidad de guardar espacio para los enlaces en los nodos. Pero se acaba no utilizando
  • 313. BÚSQUEDA EXTERNA 293 Figura 18.5 Un árbol B con registros sólo en los nodos externos. enlaces en la mayoría de los nodos, ya que la mayor parte de los nodos de un árbol B son terminales y la mayor parte de los enlaces son nulos. Además, se puede utilizar un valor mucho mayor de M en los niveles más altos del árbol si se almacenan sólo las claves (no los registros completos) en los nodos internos, como en el acceso secuencia1 indexado. Para comprender cómo sacar partido de estas observaciones en el ejemplo, se supone que se pueden fijar hasta siete claves y ocho enlaces por página, de modo que se puede utilizar A 4 = 8 para los nodos internos y M = 5 para los nodos del nivel del fondo (noA 4 = 4porque en el fondo no se necesita reservar espacio para los enlaces).Un nodo del fondo se divide cuando se le añade un quinto registro (en un nodo con dos registros y un nodo con tres registros); la división termina al <unsertan> la clave del registro intermedio en el nodo del nivel superior, donde hay espacio porque el árbol su- perior ha operado como un árbol B normal con A 4 = 8 (sobre las claves alma- cenadas, no sobre los registros).Esto conduce al árbol que se muestra en la Fi- gura 18.5. El efecto en una aplicación típica es posiblemente mucho más notorio, puesto que el factor de ramificación del árbol crece aproximadamente en la relación del tamaño del registro con el tamaño de la clave, lo que es susceptible de ser grande. También, con este tipo de organización, el <&dice» (que contiene cla- ves y enlaces) puede separarse de los registros reales, como en la búsqueda se- cuencial indexada. La Figura 18.6 muestra cómo se puede almacenar el árbol de la Figura 18.5: el nodo raíz está en la página O del disco 1 (hay espacio para ello, puesto que el árbol de la Figura 18.5 tiene un nodo menos que el árbol de la Figura 18.3), aunque en la mayoría de las aplicaciones probablemente se guarde en memona, como se hizo antes. Todos los comentarios previos que tie- Figura 18.6 Acceso a un árbol B con registros sólo en los nodos externos.
  • 314. 294 ALGORITMOS EN C++ nen que ver con la ubicación de los nodos en los discos son también de aplica- ción aquí. Ahora se tienen dos valores de M, uno para los nodos internos, que deter- mina el factor de ramificación del árbol (MJ,y otro para los nodos del fondo del árbol, que determina la asignación de registros a las páginas (MB).Para mi- nimizar el número de acceso al disco, es preciso hacer que M iy MBsean tan grandescomo se pueda, aunque esto suponga cálculosadicionales.Por otra parte, no se desea hacer a M idemasiado grande, porque la mayoría de los nodos del árbol estarían muy vacíos y se malgastaría el espacio, y no se desea hacer a MB demasiado grande, porque esto conduciría a una búsqueda secuencia1de los no- dos del fondo. Habitualmente, lo mejor es hacer a M I y a MBdel tamaño de una página. La elección obvia de MBes el número de registros que puede con- tener una página (más uno): el objetivo de la búsqueda es encontrar la página que contiene el registro deseado. Si se toma M I como el número de claves que se pueden poner entre dos y cuatro páginas, entonces el árbol B posiblemente tenga sólo tres niveles de profundidad, incluso en archivos muy grandes (un ár- bol de tres niveles con M , = 2.048 puede resolver hasta 1.0243,o más de mil millones, de entradas,) Pero es preciso recordar que el nodo raíz del árbol, al que se accede para toda operación sobre el árbol, se guarda en memoria, lo que significa que sólo se necesitan dos accesos al disco para encontrar cualquier ele- mento del archivo. Como se mencionó brevemente al final del Capítulo 15, con frecuencia se utiliza un método más complicado de inserción «ascendente» para los árboles B (aunque la discusión entre los métodos descendentes y los ascendentespierde importancia cuando se trata de árboles de tres niveles). En términos técnicos, los árboles descritos aquí deben calificarse como árboles B «descendentes»para distinguirlosde los utilizados comúnmente en la literatura especializada. Se han descrito muchas otras variantes para la búsqueda externa, algunas de ellas muy importantes. Por ejemplo, cuando se llena un nodo, la división (y los nodos se- mivacíos resultantes) puede anticiparse desplazando una parte de las claves del nodo hacia su nodo «hermano» (si no está demasiado lleno). Esto conduce a una mejor utilización del espacio interior de los nodos, lo que es probablemente uno de los temas más importantes en aplicacionesde búsqueda en disco a gran escala. Dispersión extensible Una alternativa a los árboles €3, que prolonga los algoritmos de búsqueda digital para aplicarlos en la búsqueda externa, se desarrolló en 1978 por R. Fagin, J. Nievergelt, N. Pippenger y R. Strong. Este método, denominado dispersión ex- tensible,implica dos accesos al disco en cada búsqueda en aplicaciones típicas, mientras que al mismo tiempo permite una inserción eficaz.Al igual que en los árboles B, los registros se almacenan en páginas que, cuando se llenan, se divi-
  • 315. BÚSQUEDA EXTERNA 295 Disco 1 Disco 2 m m Figura 18.7 Dispersión extensible:primera pagina. den en dos partes; como en el acceso secuencial indexado, se mantiene un ín- dice al que se accede para encontrar la página que contiene los registros que concuerdan con la clave de búsqueda. La dispersión extensible combina estas ideas mediante la utilización de las propiedades digitales de las claves de bús- queda. Para ver cómo funciona la dispersión extensible,considéresela forma en que trata las inserciones sucesivas de las claves del conjunto D I S P E R S I O N E X T E N S I B L E, utilizando páginas con capacidad de hasta cuatro registros. Se comienza con un «índice» con una sola entrada, un puntero a la página que va a contener los registros. Los cuatro primeros registros caben en la página, creando la estructura trivial que se muestra en la Figura 18.7. El directorio del disco 1 indica que todos los registros están en la página O del disco 2, donde se mantienen ordenados por sus claves. Se muestra el valor binario de las claves, utilizando la codificación estándar de cinco bits que con- siste en la representación binaria de i con la i-ésima letra del alfabeto. Ahora la página está llena y se debe dividir para poder añadir la clave E = O 01O1. La es- trategia es simple: se ponen los registros cuyas claves comienzan por O en una página y aquellos cuyas claves comienzan por 1 en otra. Esto requiere duplicar el tamaño del directorio y colocar parte de las claves de la página O del disco 2 en la nueva página, formando la estructura que se muestra en la Figura 18.8. Ahora se pueden añadir R = 10010,S= 10011 e I = 01001, pero la primera página sigue llena, como se muestra en la Figura 18.9. Se necesita otra división antes de añadir O = O1111, y a continuación se procede de la misma forma que en la primera división, dividiendo la primera página en dos partes, una para las claves qiie comienzan por O 0 y otra para las que comienzan por OI . Lo que no Figura 18.8 Dispersión extensible:división del directorio.
  • 316. 296 ALGORITMOS EN C++ Disco I n Disco2 Figura 18.9 Dispersiónextensible: primera paginaotra vez llena. queda claro de inmediato es qué hacer con el directorio. Una alternativapodría ser simplemente añadir otra entrada, un puntero a cada página. Esto no es muy interesante porque esencialmente conduce a la busqueda secuencia1 indexada: el directorio tiene que recorrerse secuencialmente durante cada búsqueda para encontrar la página apropiada. Alternativamente, se puede duplicar otra vez el tamaño del directorio, para obtener la estructura que se muestra en la Figura 18.10. Una nueva página (la página 2 del disco 2) contiene las claves que co- mienzan por O1 (I y O),la página a dividir (página O del disco 2) contiene ahora las claves que comienzan por O 0 (X, D y E), y la página que contiene las claves que comienzan por 1 (P, R, y S) no se ha transformado, aunque ahora hay dos punteros dirigidoshacia ella, uno para indicar que las claves que comienzanpor 10están almacenadas allí, el otro para indicar que las clavesque comienzan por 11 están almacenadas 211í también. Ahora es posible acceder a cualquier registro utilizando los dos primeros bits de su clave para consultar directamente la en- trada del directorio que proporciona la dirección de la página que contiene al registro. Mantener los registros ordenados dentro de la página puede parecer una simplificación por fuerza bruta, pero se recuerda que la hipótesis inicial es que [ Disco 1 Disco2 - 1 m l Figura 18.10 Dispersiónextensible: segunda división.
  • 317. B~SQUEDAEXTERNA 297 Disco 1 -Irn Disco 2 l m IpIR[s7sl (ilrlNlol Disco 3 m I Figura 18.11 Dispersiónextensible: tercera división. se hace la E/S de disco en unidades de páginas y que el tiempo de procesa- miento es despreciablecomparado con el tiempo de entrada o de salida de una página. De modo que mantener los registros ordenados por sus claves no es un verdadero gasto: para añadir un registro a una página se debe leer la página de la memoria, modificarla y escribirla de nuevo en el disco, El tiempo extra que se necesita para mantener el orden posiblemente no se note en el caso típico en el que las páginas no son muy grandes. Continuando un poco más con el ejemplo, es necesaria otra división para añadir X = 11000. Esta división también requiere duplicar el directorio para producir la estructura que se muestra en la Figura 18.11. El proceso de duplicar el directorio es simple: se lee el directorio antiguo, y después se crea el nuevo escribiendo dos veces cada entrada del antiguo. Esto crea un espacio para el puntero a la nueva página que acaba de crearse por la división. En general la estructura creada por un dispersión extensibleestá compuesta por un directorio de 2d palabras (una para cada serie de d bits) y un conjunto de púginas hojas, que contienen todos los registros con claves que comienzan por una sene de bits específica (con d bits o menos). Una búsqueda implica uti- lizar los primeros d bits de la clave como índice dentro del directorio, el cual contiene punteros a las páginas hojas. Después se accede a la página hoja así referenciaday se busca el registro (utilizando cualquier estrategia).Varias entra- das de directorio pueden apuntar a la misma página hoja: con mayor precisión, si una página hoja contiene todos los registros cuyas claves comienzan por una serie específicade k bits (los que no están sombreados en las figuras), entonces tendrá 2d-kentradas del directorio apuntando hacia ella. En la Figura 18.11 se
  • 318. 298 ALGORITMOS EN C++ Figura 18.12 Dispersiónextensible: cuarta división. tiene d = 3, y la página 1 del disco 2 contiene todos los registros cuyas claves comienzan por los bits 10; por tanto hay dos entradas de directoric apuntando hacia ella. Hasta ahora, en el ejemplo, cada división de página necesitó una división de directorio, pero en circunstancias normales se puede esperar que el directorio sólo se divida raras veces. Ésta es la esencia del algoritmo: los punteros extra del directorio permiten a la estructura adaptarse armoniosamente a un crecimiento dinámico. Por ejempio, cuando se inserta T en la estructura de la Figura 18.11, la página 1 del disco 2 se debe dividir para acomodar las cinco claves que co- mienzan con 10, pero el directorio no necesita crecer, como lo muestra la Fi- gura 18.12.El único cambio en el directorio es que el último de sus dos punte- ros se cambia para apuntar a la página 1 del disco 3, la nueva página que ha sido creada en la división para acomodar a todas las claves en la estructura de datos que comienzan con 1O1 (la T). El directono sólo contiene punteros a páginas. Éstos son probablemente más pequeiios que las claves o los registros; así que cabrán más entradas de directo- rio en una página. En el ejemplo, se supone que se pueden poner en una página dos veces más entradas a directorios que registros, aunque esta relación posible- mente es mucho más alta en la práctica. Cuando el directorio se expande en más de una página, se mantiene en memoria un modo raíz» que indica dónde
  • 319. BÚCQUEDA EXTERNA 299 0100 O101 O110 o111 Figura 18.13 Accesos de la dispersión extensible. están las páginas del directorio, utilizando el mismo esquema de indexación.Por ejemplo, si el directorio se expande en dos páginas, el nodo raíz pudiera indicar que el directono para todos los registros con claves que comienzan por O está en la página O del disco 1, y que el directorio para todas las claves que comien- zan por 1 está en la página 1 del disco 1. Continuando con el ejemplo, se inser- tan las claves E N S I B y L, llegandoa la estructura que se muestra en la Figura 18.13. (Por claridad, se ha reservado el disco 1 para el directorio, aunque en la práctica pudiera estar mezclado con las otras páginas, o estar reservada la pá- gina O de cada disco, o bien utilizar alguna otra estrategia.) Así pues, la inser- ción en una estructura de dispersión extensiblepuede implicar alguna de las si- guientes operaciones,una vez que se acceda a la página hoja que podría contener la clave de búsqueda. Si hay espacio en la página hoja, se inserta simplemente el nuevo registro, o en caso contrario se divide en dos la página hoja (parte de los registros se desplazan hacia una nueva página). Si el directorio tiene más de
  • 320. 300 ALGORITMOS EN C++ una entrada apuntando a la página hoja, entonces las entradas se pueden dividir por igual en la página. Si no, se debe doblar el tamaño del directorio. Como se ha descrito hasta aquí, este algoritmo es muy sensible a una mala distribución de las claves de entrada: el valor de d es el mayor número de bits que se necesitan para separar las claves en conjuntos lo suficientemente peque- ños como para que quepan en las páginas hojas, y así, si un gran número de claves coincidm en gran parte de sus bits iniciales, el directorio se hace inacep- tablemente grande. Para aplicaciones reales a gran escala se puede evitar este problema efectuando una dispersión de las claves para hacer los primeros bits (pseudo) aleatorios. Para buscar un registro, se hace una dispersión de su clave para obtener una serie de bits que se utilizarán para acceder al directorio; este último indica en que página hay que buscar un registro con la misma clave. Desde el punto de vista de la dispersión se puede presentar el algoritmo como una división de nodos para resolver el problemas de las colisiones: de aquí el nombre de «dispersión extensible». Este método ofrece una alternativa atrac- tiva a los árbolesB y al acceso secuencialindexado, porque utiliza siempre exac- tamente dos accesos al disco en cada búsqueda (como el acceso secuencia1in- dexado), mientras que mantiene la capacidad para hacer inserciones eficaces (como los árboles B) sin malgastar mucho espacio. Propiedad 18.4 Conpáginas que pueden contener M registros, se puede espe- rar que la dispersión necesite alrededor de 1,44(N/M)páginas para un archivo de N registros,El directoriocontendrá alrededor de N1+l/M/M entradas. Este análisis es una extensión compleja del análisis de los tries a los que se hizo referencia en el capítulo anterior. Cuando M es grande, el volumen de espacio malgastado es aproximadamente el mismo que para los árboles B, pero para un M pequeño el directorio puede hacerse demasiado grande.. Aun con la dispersión, se deben dar algunos pasos extra si existe un gran número de clavesiguales. Éstas pueden hacer al directono artificialmentegrande, y el algoritmo se distorsiona por completo si hay más claves iguales que las que pueden caber en una página hoja. (Esto ocurre realmente en el ejemplo, puesto que se tienen cuatro E.) Si existen muchas clavesiguales entonces se podría, por ejemplo, prohibir la presencia de las mismas en la estructura de datos, y poner en las páginas hojas punteros a listas enlazadas de registros que contengan las claves repetidas. Para ver la complicación que esto implica, considérese lo que pasaría si la última E del ejemplo (que parecía haberse olvidado) se insertara en la estructura de la Figura 18.13. Una situación menos catastróficade resolver es que la inserción de una nueva clave pueda causar que el directorio se divida más de una vez. Esto ocurre cuando un bit más no es suficiente para distinguir las claves en una página so- brecargada. Por ejemplo, si se insertaran dos claves con el valor D = O0100 en la estructura de dispersión extensiblede la Figura 18.12, se necesitarían dos di- visiones del directorio porque se necesitan cinco bits para distinguir D de E (el
  • 321. BUSQUEDA EXTERNA 301 cuarto bit no ayuda). Esto es fácil de afrontar en una implementación, pero no se debe tolerar. Memoria virtual El «método más fácil)) que se presentó al final del Capítulo 13 para la ordena- ción externa se puede aplicar directa y trivialmente al problema de la búsqueda externa. En realidad, una memoria virtual no es más que un método de bús- queda externa de amplio espectro:dada una dirección (clave),devolverla infor- mación asociada con esa dirección. Sin embargo, la utilización directa de la me- moria virtual no se recomienda como método fácil de búsqueda. Como se mencionó en el Capítulo 13, las memorias virtuales se comportan mejor cuando la mayoría de los accesos están relativamente próximos a los accesos anteriores. Los algontmos de ordenación se pueden adaptar a esto, pero la verdadera na- turaleza de la búsqueda es que las peticiones traten sobre informaciones de las partes arbitrarias de la base de dat0s.i Ejercicios 1. Dar el contenido del árbol B que se obtiene de la inserción de las claves C U E S T I O N F A C I L en un árbol inicialmente vacío y con M = 5. 2. Dar el contenido del árbol B que se obtiene de la inserción de las claves C U E S T I O N F A C I L en un árbol inicialmente vacío y con M = 6. Uti- lizar la vanante del método en el que todos los registros se conserven en nodos externos. 3. Dibujar el árbol B que se obtiege al insertar dieciséis claves iguales en un árbol inicialmente vacío, con M = 5. 4. Suponiendo que se destruyeuna página de una base de datos, describir cómo se resolvería este problema para cada una de las estructuras de árboles B descritas en el texto. 5. Dar el contenido de la tabla de dispersión extensibleque se obtiene cuando se insertan las claves C U E S T I O N F A C I L en una tabla inicialmente vacía, con capacidad de página para cuatro registros. (Siguiendoel ejemplo del texto, no se debe utilizar la dispersión sino la representación binaria de 5 bits de i como clave para la i-ésima letra.) 6. Obtener una secuencia de tantas claves distintas como sea posible que ha- gan crecer un directorio desde una tabla inicialmente vacía hasta un ta- maño 16, con una capacidad de página de tres registros. extensible. 7. Esbozar un método para suprimir un elemento de una tabla de dispersión’
  • 322. 302 ALGORITMOS EN C++ 8. ¿Por qué los árboles B adescendentes))son mejores que los «ascendentes» para el acceso concurrente a los datos? (Supóngase, por ejemplo, que dos programas están tratando de insertar un nuevo nodo al mismo tiempo.) 9. Implementar buscar e insertar para una búsqueda interna utilizando el mé- todo de dispersión extensible. 10. Comparar el programa del ejercicio anterior con la doble dispersión y la búsqueda trie por residuos, en aplicacionesde búsqueda interna.
  • 323. BÚSQUEDA EXTERNA 303 REFERENCIAS para la Búsqueda Las referencias principales para esta sección son el Volumen 3 de Knuth, el li- bro de Gonnet y el libro de Mehlhorn. La mayoría de los algoritmos que se han estudiado se tratan detalladamente en estos libros, con análisis matemáticos y sugerencias para aplicaciones prácticas. Los métodos clásicos son tratados por Knuth y los más recientes por Gonnet y Mehlhorn, con muchas referenciasbi- bliográficas. Estas tres fuentes describen los análisis de casi todos los afuera del alcance de este libro» a los que se ha hecho referencia en esta sección. El material del Capítulo 15 proviene del artículo de 1978 de Guibas y Sed- gewick, que muestra cómo adaptar muchos algoritmos clásicos de árboles equi- librados al esquema crojinegro)),a la vez que ofrece otras implementaciones. En realidad, hay una literatura muy amplia sobre árboles equilibrados: el lector que desee ampliar sus conocimientos puede comenzar con este artículo. €1libro de Mehlhorn da pruebas detalladas de las propiedades de los árboles rojinegros y de estructuras similares, así como referencias a trabajos más recientes. El es- tudio realizado por Comer en 1979 presenta los árboles B desde un punto de vista más práctico. El algoritmo de la dispersión extensible que se presentó en el Capítulo 18 proviene del artículo de Fagin, Nievergelt,Pippenger y Strong de 1979. Este ar- tículo es obligatorio para todo aquel que desee más información sobre los mé- todos de búsqueda externa:relacionael contenido de los Capítulos 16 y 17 hasta ofrecer el algoritmo del Capítulo 18. El artículo contiene también un análisis detallado y una presentación de las consecuenciasprácticas. Muchas aplicaciones prácticas de los métodos expuestos, especialmente en el Capítulo 18, provienen del coniexto de los sistemas de bases de datos. El es- tudio de las bases de datos es un campo amplio y en crecimiento, en el que los algoritmos básicos de búsqueda continúan desempeñando un papel fundamen- tal en la mayona de los sistemas.El libro de Ullman es una introducción a este campo. D. Comer. «The ubiquitous B-tree», Computing Surveys, 11 (1979). R. Fagin, J. Nievergelt,N. Pippenger y H. R. Strong, ((Extendibledispersion-a fast access method for dynamic files)),ACM Transactions on Database Sys- tems, 4, 3 (septiembre 1979). G. H. Gonnet, Handbook c f Algorithms and Data Structures, Addison-Wesley, Reading, MA, 1984. L. Guibas y R. Sedgewick, «A dichromatic framework for balanced trees», en 19th Annual Symposium on Foundations of Computer Science, IEEE, 1978. También en A Decade o f Progress 1970-1980,Xerox PARC, Palo Alto, CA. D. E. Knuth, TheArt o f Computer Programming, Volume 3: Sorting and Sear- ching, Addison-Wesley, Reading, MA, 1975. K. Mehlhorn, Data Structures and Algorithms 1:Sorting and Searching, Spnn- ger-Verlag, Berlín, 1984. J. D. Ullman, Principle o f Database Systems, Computer Science Press, Rock- ville, MD, 1982.
  • 327. Búsqueda de cadenas A memdo sucede que los datos a procesar no se descomponen lógicamente en registros independientesque representen pequeñas partes identificables.Este tipo de datos se caracterizafkilmente por el hecho de que se pueden escribir en forma de cadenas: series lineales (por io regular muy largas) de caracteres. Por su- puesto que ya se han visto antes las cadenas, por ejemplo en los Capítulos 3 y 16, ya que constituyen estructurzs básicas en C++. Las cadenas son evidentemente el centro de los sistemas de tratamiento de texto, que proporcionan una gran variedad de posibilidades para la manipula- ción de textos. Tales sistemas procesan cadenas alfanuméricas, que pueden de- finirse en primera aproximación como seriesde letras, números y caracteres es- peciales. Estos objetos pueden ser bastante grandes (por ejemplo, este libro contiene más de un millón de caracteres),por lo que es importante disponer de algoritmos eficaces para su manipulación. Otro tipo de cadena es la cadena birlaria,que es una simple serie de valores O y 1. Ésta es, en cierto sentido, un tipo especial de cadena alfanumérica, pero es útil hacer la distinción porque existen diferentes algoritmos específicos para este tipo de cadenas y porque las cadenas binarias se utilizan en muchas apli- caciones. Por ejemplo, algunos sistemas gráficos de computadoras representan las imágenes como cadenas binarias. (Este libro fue impreso con un sistema de este tipo: esta misma página se representó en su momento como una cadena binaria de millones de bits.) En un sentido, las cadenas alfanuméricas son objetos bastante diferentes de las cadenasbinarias, porque están constituidaspor caracterestomados de un gran alfabeto. Pero, en otro sentido, los dos tipos de cadenas son equivalentes,puesto que cada carácter alfanumérko se puede representar (por ejemplo) con ocho ci- fras binarias, y una cadena binaria puede considerarse como una alfanuménca, al tratar cada paquete de ocho bits como un carácter. Se verá que el tamaño del alfabeto que se toma para formar una cadena es un factor importante en el di- seño de los algoritmos de procesamiento de cadenas. Una operación fundamental sobrelas cadenas es el reconocimientodepatro- 307
  • 328. 308 ALGORITMOS EN C++ nes: dada una cadena alfanumérica de longitud N y un patrón de longitud M, encontrar una ocurrencia del patrón dentro del texto. (Seutiliza aquí el término «texto» aun cuando se haga referencia a una secuencia de valores O y 1 o a al- gún otro tipo especial de cadena.) La mayoría de los algoritmos para este pro- blema se pueden modificar fácilmente para encontrar todas las ocurrencias del patrón en el texto, puesto que recorren el texto en secuencia y se pueden reini- cializar en la posición situada inmediatamente después del comienzo de una concordancia, para encontrar la concordancia siguiente. El problema del reconocimiento de patrones se puede caracterizar como un problema de búsqueda en el que el patrón sena la clave, pero los algoritmos de búsqueda que se han estudiado no se pueden aplicar directamente porque el pa- trón puede ser largo y porque se «alinea»en el texto de forma desconocida.Éste es un problema interesante: hace poco tiempo que se ha descubierto que algu- nos algoritmos muy diferentes (y sorprendentes) no solamente ofrecen un aba- nico de métodos prácticos, sino que también ilustran algunas de las técnicas fundamentalesdel diseño de algoritmos. Una breve historia Los algoritmos que se van a estudiar tienen una historia interesante que se re- sume aquí para ayudar a situar a los diferentes métodos en su contexto. Existe un algoritmo evidente de fuerza bruta para el procesamiento de ca- denas que se utiliza ampliamente. Mientras que en el peor caso se ejecuta en un tiempo proporcional a MAT, las cadenas con las que se trabaja en muchas apli- caciones conducen a un tiempo de ejecución que es virtualmente proporcional a M + N. Además, como el método se puede beneficiar de los recursos de las arquitecturas de la mayona de los sistemas de computadoras, la versión opti- mizada del algoritmo constituye un «estándan>que es difícil de batir por un al- goritmo más fino. En 1970, S.A. Cook demostró un resultado teórico sobre un tipo particular de máquina abstracta que implicaba la existenciade un algoritmo para resolver el problema del reconocimiento de patrones en un tiempo proporcional a k í+N en el peor caso. D. E. Knuth y V. R. Pratt siguieron laboriosamente el razona- miento que Cook había hecho para probar su teorema (cuya intención no era la de ser práctico) y obtuvieron un algoritmo que pudieron afinar en un método relativamente simple y práctico. Esto es un ejemplo raro y satisfactorio de un resultado teórico con una solución práctica inmediata (e inesperada). Pero re- sulta que J.H. Morns descubrió prácticamente el mismo algoritmo como solu- ción de un molesto problema práctico que había encontrado cuando imple- mentaba un editor de texto (no deseaba «retroceder» nunca en la cadena de texto). Sin embargo, el hecho de que el mismo algoritmo surgiera de dos apro- ximaciones diferentesaumenta su credibilidad como una solución fundamental al problema.
  • 329. B~SQUEDADECADENAS 309 Knuth, Moms y Pratt no publicaron su algoritmo hasta 1976, y mientras tanto R.S. Boyer y J. S. Moore (e independientemente, R. W. Gosper) habían descubiertoun algoritmomucho más rápido en muchas aplicaciones, puesto que sólo examina una parte reducida de los caracteres de la cadena de texto. Mu- chos editores de texto utilizan este algoritmo para obtener una reducción nota- ble del tiempo de respuesta en las búsquedas de cadenas. Tanto el algoritmo de Knuth-Morris-Pratt como el de Boyer-Moore requie- ren cierto preprocesamiento algo complicado del patrón, que es difícilde enten- der, por lo que se ha limitado su utilización. (De hecho, la historia dice que un programador desconocido encontró el algoritmo de Morris tan difícil de enten- der que lo reemplazó por una implementación del algoritmo de fuerza bruta.) En 1980, R. M. Karp y M. O. Rabin observaron que el problema no es tan diferente como parece del problema de una búsqueda estándar,y obtuvieron un algoritmo casi tan simple como el de la fuerza bruta, que realmente siempre se ejecuta en un tiempo proporcional a M + N. Además, su algoritmo se adapta fácilmentea patrones y textos bidimensionales,lo que lo hace más útil que otros para el procesamiento de imágenes. Esta historia ilustra el hecho de que la búsqueda de un ({algoritmomejom muy a menudo esjustificada; incluso se puede pensar que existen aún otras so- luciones para el desarrollo de este problema. Algoritmo de fuerza bruta El método en el que se piensa de inmediato para el reconocimiento de patrones consiste simplementeen verificar,para cada posición posible del texto en la que el patrón puede concordar, si efectivamentelo hace. El programa siguienteefec- túa de esta manera una búsqueda de la primera ocurrencia de la cadena patrón p en una cadena texto a: int busquedabruta(char *p, char *a) i int i , j , M = strlen(p), N = strlen(a); for ( i = O , j = O; j < M && i < N; i++, j++) i f ( j == M) return i-M; else return i; if (a[i] != p [ j ] > { i -= j-1; j = -1; } 1 El programa conserva un puntero (i) en el texto y otro (j)en el patrón. Mien- tras que apunten a caracteres que concuerden, ambos se incrementan. Si i y j apuntan a caracteres incompatibles, entonces j se pone de nuevo a apuntar al principio del patrón e i se reinicializa de forma que se haga avanzar al patrón a la siguienteposición a la derecha, para una nueva comparación.En particular,
  • 330. 310 ALGORITMOS EN C++ 1 0 0 1 1 1 0 1 0 0 1 0 1 0 0 0 1 0 1 0 0 1 1 1 0 0 0 1 1 1 1 1 o o 1 1 1 o 1 o o 1 o 1 o o ol~IammmmBiil0 o o 1 1 1 Figura 19.1 Búsqueda por fuerza brutade una cadena en un texto binario. siempre que la sentencia i f ponga j a -1, las iteraciones siguientes del bucle for van incrementando i hasta que se encuentre un carácter del texto que con- cuerde con el primer carácter del patrón. Si se alcanza el final del patrón (j== M) entonces hubo concordancia a partir de a [i-MI . Si por el contrario se alcanza el final del texto ( i == N) antes que el del patrón, entonces no hay concordancia: el patrón no aparece en el texto, en cuyo caso se devuelve el valor «centinela» N. En una aplicación de edición de texto, el bucle interno de este programa rara vez se reitera, y el tiempo de ejecución es prácticamente proporcional al nú- mero de caracteresexaminados en el texto. Por ejemplo, suponiendo que se está buscando el patrón DENAS en la cadena de texto EJEMPLO DE BÚSQUEDA DE CADENAS... entonces la Fentencia j++ se ejecuta sólo cinco veces (dos para cada DE y una para la D de DA) antes de que se encuentre la verdadera concordancia. Por otra parte, la fuerza bruta puede ser muy lenta para algunos patrones, por ejemplo, si el texto es binario (doscaracteres), como sucede en aplicaciones de procesamiento de imágenes y de programación de sistemas. La Figura 19.1 muestra lo que sucede cuando se utiliza este algoritmo para buscar el patrón IO1O 0111 en una gran cadena binaria. Cada línea (exceptola última, que mues- tra la concordancia) contiene la lista de los cero o más caracteres que concuer- dan con el patrón, seguida de una discordaiicia.Estas líneas son los «falsosprin- cipios))que ocurren cuando se trata de encontrar el patrón; un objetivo evidente del diseño de un algoritmo es tratar de limitar el número y la longitud de dichas líneas. En este ejemplo se examinan por término medio dos caracteres por cada posición de texto, aunque la situación puede ser mucho peor.
  • 331. B~SQUEDADECADENAS 311 La búsqueda de cadenas porftierza bruta puede necesitar al- Propiedad 19.1 rededor de NM comparaciones de caracteres. El peor caso se produce cuando patrón y texto están formados por uno o varios O seguidos por un 1. Entonces para cada una de las N - M + I posiciones de concordancia se comparan con el texto todos los caracteres del patrón, con un costetotal de M(N - M + 1).Normalmente Mes muy pequeño comparado con N, por lo que el total es alrededor de NM.. Por supuesto que tales cadenasdegeneradas son poco probables en un texto normal (o en C++), pero pueden aparecer cuando se procesan textos binarios, de modo que hay que buscar mejores algoritmos. Algoritmo de Knuth-Morris-Pratt La idea básica de este algoritmo descubierto por Knuth, Moms y Pratt es la si- guiente: cuando se detecta una discordancia (no concordancia),el ((falso prin- cipio» se compone de los caracteresque se conocen por adelantado (puestoque están en el patrón). De algún modo hay que ser capacesde aprovecharse de esta información en lugar de retroceder el puntero i más allá de todos estos carac- teres conocidos. Como un ejemplo sencillode esto, se supone que el primer carácter del pa- trón sólo aparece una vez (sea, por ejemplo, el patrón = 10000000).Supóngase ahora que se tiene un falso principio de j caracteres de longitud en alguna po- sición del texto. Cuando se detecta la no concordancia, se sabe, en virtud del hecho de que concuerdan j caracteres, que no se necesita «retroceden>el pun- tero i del texto, puesto que ninguno de los j - 1 caracteres del texto pueden concordar con el primer carácter del patrón. Este cambio se podría implemen- tar reemplazando la instrucción i -= j - i del programa anterior por i++. El efecto práctico de este ejemplo es limitado, porque es poco probable que se pre- senten patrones tan específicos, pero merece la pena pensar en esta idea, y el algoritmo de Knuth-Morris-Pratt es una generalización de la mima. Sorpren- dentemente siempre es posible arreglar las cosas de modo tal que el puntero i nunca se decremente. Saltar todos los caracteres del patrón cuando se detecta una discordancia, como la descrita en el párrafo anterior, sena un error en el caso en el que el patrón se repita en el propio punto de la no concordancia. Por ejemplo, cuando se está buscando 1O1O 0111 en 1O 1O1O 0111, se comienza por detectar la discor- dancia en el quinto carácter, pero se debe retroceder al tercero para continuar la búsqueda, puesto que de lo contrario se perdena la concordancia. En todo caso se puede prever la acción a tomar, adelantándoseen el tiempo, porque de- pende sólo del patrón, como se muestra en la Figura 19.2. Se utilizará el array prox [MI para determinar cuánto se debe retroceder
  • 332. 312 ALGORITMOS EN C++ 1 1 0 1 0 2 0 3 1 4 2 5 0 6 1 1 0 1 0 0 1 1 2 7 1 1 0 1 0 0 1 1 ~ Figura 19.2 Posicionesde reinicialización en una btjsqueda de Knuth-Morris-Pratt. cuando se detecte que no hay concordancia. Imagínese que se hace deslizar una copia de los primeros j caracteres del patrón, de izquierda a derecha, comen- zando por colocar el primer carácter de la copia sobre el segundo carácter del patrón y parando cuando todos los caracteres que se superpongan concuerden (o no haya ninguno). Estos caracteres que se superponen definen la siguiente posición posible en la que el patrón podría concordar, si se detecta que no hay concordanciaen p [j 1.La distancia a retroceder en el patrón (prox [j1)es exac- tamente el número de caracteresque se superponen. Especificamente,para j > O, el valor de prox[ j ] es el mayor valor de k < j para el que los primeros k caracteresdel patrón concuerdan con los últimos k caracteresde los j primeros caracteres del patrón. Como pronto se verá, es conveniente definir prox[O] como -1. Este array prox proporciona de inmediato una forma de limitar (y de he- cho, como se verá, de eliminar) el «retroceso»del puntero i del texto, como se presentó anteriormente. Cuando i y j apuntan a caracteres que no concuerdan (la comprobación de la concordancia comenzó en la posición i - j + 1dentro de la cadena de texto), entoncesla próxima posición posible para que haya una concordancia con el patrón es i - prox[j]. Pero por definición de la tabla prox, los primeros prox [j ] caracteres después de esa posición concuerdan con los primeros prox [j ] caracteres del patrón, por lo tanto no hay necesidad de hacer retroceder al puntero itan lejos: simplemente se puede dejar al puntero i sin cambios y darle al puntero j el valor prox[j], como se hace en el pro- grama siguiente: i n t busquedaKMP(char *p, char *a) i n t í , j , M = s t r l e n ( p ) , N = s t r l e n ( a ) ; {
  • 333. B~SQUEDADECADENAS 313 i n i c p r o x (p) ; for ( i = O, j = O; j <M && i < N; i++, j++) if ( j == M) r e t u r n i-M; else r e t u r n i; while ( ( j >= O) && ( a [ i ] != p [ j ] ) ) j = p r o x [ j ] ; Cuando j = O y a[i] no concuerdan con p [O], no hay superposición, por lo que se desea incrementar i y mantener j apuntando al comienzo del patrón. Esto se logra definiendo prox [O] en -1, lo que provoca que a j se le asigne -1 en el bucle whi 1e; entonces se incrementa iy j se pone a O cuando se itera el bucle for. Funcionalmente este programa es el mismo que el de busqueda- bruta, pero es probable que se ejecute con más rapidez en patrones que sean altamente repetitivos. Queda por calcular la tabla prox. El programa correspondiente necesitaalgo más de astucia; básicamente es el mismo anterior, pero haciendo concordar al patrón consigomismo. inicprox(char *p) I I i n t i,j , M = strlen(p); prox[O] = -1; for (i= O, j = -1; i < M; i++, j++, p r o x [ i ] = j ) while ( ( j>= O) && ( p [ i ] != p [ j ] ) ) j = p r o x [ j ] ; 1 Justo después de que se hayan incrementado i y j, se ha determinado que los j primeros caracteres del patrón concuerden con los caracteresde las posiciones p [i-j-1 ] ,..., p [i-11, los últimos j caracteres de los i primeros caracteres del patrón. Y éste es el mayor j con esta propiedad, puesto que, si no, se habría olvidado una ((posible concordancia» del patrón consigo mismo. Así que j es exactamente el valor que se debe asignar a prox [j]. Una forma interesante de representar este algoritmo es considerar al patrón como si estuviera fijo, de forma que la tabla prox pueda «volcarse en» el pro- grama. Por ejemplo, el programa siguiente es exactamente equivalente al pro- grama anterior para el patrón que se está considerando, pero es posible que sea mucho más eficaz. in t busquedaKMP(char *a) i n t i = -1; { sm: i++; SO: i f ( a [ i ] != ' 1 ' ) goto sm; i++; s l : if ( a [ i ] != ' O ' ) goto SO; i++;
  • 334. 314 ALGORITMOS EN C++ s2: i f ( a [ i ] != ' 1 ' ) goto SO; i++; s3: i f ( a [ i ] != ' O ' ) goto s1; i++; s4: if ( a [ i ] != 'GI) goto s2; i++; s5: if ( a [ i ] != ' 1 ' ) goto SO; i++; s6: i f ( a [ i ] != ' 1 ' ) goto s l ; i++; s7: i f ( a [ i ] != ' 1 ' ) goto sl; i++; return i-8; 1 Las etiquetas goto corresponden precisamente a la tablz prox. De hecho, el programa i ni cprox anterior que construye la tabla prox se puede modificar fácilmente para ¡dar como salida este programa! Para evitar tener que verificar si i == N cada vez que se incrementa i,se supone que el patrón se almacena al final del texto como un centinela, es decir en a [NI y ... y a[N+M- 11.(Esta me- jora se puede hacer incluso en la inplementación estándar.)Éste es un ejemplo de un cornpilador de búsqueda de cadenas»:dado un patrón, se puede generar un programa muy eficazpara buscar ese patrón en una cadena texto arbitraria- mente larga. Se verá la generalización de este concepto en los dos capítulos que siguen. El programa anterior utiliza solamente algunas operaciones muy básicas para resolver el problema de la búsqueda de cadenas. Esto significaque se puede des- cribir en términos de un modelo muy simple de máquina denominada máquina de estadosfinitos. La Figura 19.3 muestra la máquina de estados finitos para el problema anterior. . __.-._ . . . ..... .. .- '._ __.. --.__ ..' . . '. I' ,.- -. * . : ._.-._ * ........... '.c.:' ....... ....... s.,.. ,.;._ .... ...... ...................... .._ ..... .................. Figura 19.3 Máquinade estados finitos para el algoritmo de Knuth-Morris-Pratt. La máquina consiste en estados (indicados por los números encerrados en círculos) y transiciones (indicadas por líneas). Cada estado tiene dos transicio- nes que salen de él: una transición de concordancia (expresada en la figura por las líneas gruesas que van hacia la derecha) y una transición de no concordancia (expresadapor las líneas de puntos que van hacia la izquierda). Los estados son los lugares donde la máquina ejecuta las instrucciones; las transiciones son las instrucciones goto. Cuando la máquina está en el estado etiquetado <cx)> puede llevar a cabo una sola instrucción: «si el carácter en curso es x pasa al siguiente
  • 335. B~SQUEDADECADENAS 315 Figura 19.4 Máquina de estados finitos (mejorada)para el algoritmo de Knuth-Morris- Pratt. y toma la transición de concordancia; en caso contrario toma la transición de no concordancia). Pasar al siguiente significa tomar el próximo carácter de la cadena como carácter actual));la máquina va pasando al carácter siguiente a medida que va concordando con el carácter actual. Hay dos excepcionesa esto: el primer estado siempre toma una transición de concordancia y pasa al si- guiente carácter (esencialmenteesto correspondea buscar la primera ocurrencia del primer carácter del patrón), y el último estado es un estado de «parada»que indica que se ha encontrado una concordancia del patrón. En el próximo capí- tulo se verá cómo utilizar una máquina similar (pero más poderosa) para ayu- dar al desarrollo de un algoritmo mucho más eficaz de reconocimiento de pa- trones. El lector atento puede haber notado que es posible mejorar este algoritmo, porque no tiene en cuenta al carácter que causa que no haya concordancia. Por ejemplo, supóngase que el texto comienza por 1011 y que se está buscando el patrón 1O1O 0111. Después de la concordancia de 101 se encuentra una no con- cordancia en el cuarto carácter; en este punto la tabla prox dice que hay que comparar el segundo carácter del patrón con el cuarto carácter del texto, puesto que, en virtud de la concordancia de 101, el primer carácter del patrón se puede alinear con el tercer carácter del texto (pero no hay que compararlos, ya que se sabe que los dos son 1). Sin embargo, sería imposible tener aquí una concor- dancia: al constatar esta no concordancia se sabe que el próximo carácter del texto no es O, como lo exige el patrón. Otra forma de ver las cosas es observar la versión del programa que tiene dentro la tabla prox: en la etiqueta 4 se va a 2 si a [i]no es O, pero en la etiqueta 2 se va a 1 si a[ i] no es O. ¿Por qué no ir directamente a l? La Figura 19.4 muestra la versión mejorada de la máquina de estados finitos para el ejemplo. Afortunadamente, es fácil introducir este cambio en el algoritmo, Sólo se necesita reemplazar la sentencia prox [i ] = j en el programa i n i cprox por p r o x [ i ] = ( p [ i ] == p [ j ] ) ? p r o x [ j ] : j Puesto que se procede de izquierda a derecha, el valor que se necesita para prox ya ha sido calculado, por lo que simplemente se utiliza.
  • 336. 316 ALGORITMOS EN C++ 1 0 0 1 1 1 0 1 0 0 1 0 1 0 0 0 1 0 1 0 0 1 1 1 0 0 0 1 1 1 O 0 1 0 0 1 1 1 0 1 0 0 1 0 1 0 0 0 ~ ~ ~ ~ m m m m 0 0 0 1 1 1 Figura 19.5 Búsquedade cadenas Knuth-Morris-Pratten un texto binario. Propiedad 19.2 La búsqueda de cadenasKnuth-Morris-Prattnunca efectzia más de N+M comparaciones de caracteres. Esta propiedad se ilustra en la Figura 19.5, y también se deduce del código: o se incrementa j o se reinicializa a partir de la tabla prox, una vez para cada i.’ La Figura 19.5 muestra que este método verdaderamente efectúa menos comparaciones que el de la fuerza bruta para el ejemplo binario. Sin embargo, es posible que el algoritmo de Knuth-Morris-Pratt no sea mucho más rápido que el método de la fuerza bruta en muchas aplicaciones reales, porque de he- cho pocas aplicacionesimplican búsquedas de patrones altamente repetitivosen textos también altamente repetitivos. Sin embargo, este método tiene una ven- taja práctica fundamental: procede secuencialmente a través del texto de en- trada y nunca retrocede. Esto lo hace conveniente para su utilización en gran- des archivos que se estén leyendo de algún dispositivo externo. (En estos casos los algoritmos que necesiten retroceder se acompañan de algún complejo sis- tema de almacenamiento intermedio.) Algoritmo de Boyer-Moore Si no es dificil «retroceden>se puede desarrollar un método de búsqueda de ca- denas bastante más rápido recorriendo el patrón de derecha a izquierda mien- tras se está tratando de hacer concordar éste con el texto. Si al buscar el patrón de ejemplo 10100111, se encuentra concordancia en los caracteres octavo, sép- timo y sexto, pero no el quinto, se puede deslizar el patrón siete posiciones a la derecha, y volver a comprobar a partir del carácter decimoquinto, porque la concordancia parcial encontró 111, que podría aparecer en cualquier parte del patrón. Por supuesto que al final el patrón suele aparecer en cualquier lugar y, por tanto, se necesita una tabla prox como la anterior. La Figura 19.6 muestra una versión de derecha a izquierda de la tabla prox para el patrón 10110101:en este caso prox[j ] es el número de posiciones de caracteres que se puede desplazar el patrón hacia la derecha, dado que en una
  • 337. B~SQUEDADECADENAS 317 2 4 1 0 1 1 0 1 0 1 3 7 4 2 $>&%&O 1 o 1 1 0 1 1 0 1 0 1 5 5 S&-;fl o 1 o 1 6 5 1 0 1 1 0 1 0 1 7 5 K@11 o 1 o 1 1 0 1 1 0 1 0 1 3 0 1 1 o 1 o 1 8 5 1 0 1 1 0 1 0 1 Figura 19.6 Posiciones de reinicializaciónpara la búsquedade Boyer-Moore. exploración de derecha a izquierda se constató una no concordanciaen elj-ésimo caricter desde la derecha del patrón. Este valor se encuentra igual que antes, deslizando una copia del patrón sobre los últimos j caracteres del mismo, de izquierda a derecha, comenzando por alinear el penúltimo carácter de la copia con el último carácter del patrón y parándose cuando todos los caracteres que se superponen concuerden (teniendo en cuenta también el carácter que provocó el fallo de la concordancia). Por ejemplo, prox [21 es 7 porque, si hay una con- cordancia de los dos últimos caracteresy luego una no concordancia en una ex- ploración de derecha a izquierda, entonces se debe haber encontrado O01 en el texto; esta sene no aparece en el patrón, excepto posiblemente si el I se alinea con el primer carácter del patrón, por lo que se puede deslizarlo 7 posiciones a la derecha. Esto lleva directamente a un programa que es muy similar a la implemen- tación anterior del método de Knuth-Moms-Pratt. No se estudiará con más de- talle porque existe un método completamente diferente de saltar caracteres en una exploración del patrón de izquierda a derecha, que es mejor en muchos ca- La idea es decidir lo que se debe hacer en función del carácter que provocó la discordancia tanto en el texto como en el patrón. La etapa del preprocesa- miento consiste en decidir, para cada carácter que podría figurar en el texto, lo que se haría si dicho carácter hubiese provocado la no concordancia. La reali- zación más simple de esto conduce inmediatamente a un programa bastante útil. La Figura 19.7 muestra este método para el primer ejemplo de texto. Pro- cediendo de derecha a izquierda para hacer concordar al patrón, se comprueba primero la S del patrón con la P (el quinto carácter) del texto. No sólo no con- cuerdan, sino que se constata que la P no aparece en ninguna parte del patrón, por lo que se puede deslizarlo más allá de P. La siguiente comparación es la S sos.
  • 338. 318 ALGORITMOS EN C++ E J E M P L O D E B U S Q U E D A D E C A D E N A S E J E M P L O D E B U S Q U E D A D E C A - 1 Figura 19.7 Búsqueda de cadenas de Boyer-Moore utilizando la heurística de la no concordancia. del patrón con el quinto carácter después de P (la E de DE). Esta vez se puede deslizar el patrón hacia la derecha hasta que la D concuerde con la D del texto. Después se compara la S del patrón con la U de BÚSQUEDA, y como ésta no aparece en el patrón, se le puede deslizar cinco lugaresmás a la derecha. El pro- ceso continúa hqsta llegar a la D de CADENAS, punto en el que se alinea el patrón de modo que su D concuerda con la del texto y se obtiene la concordan- cia total. Este método conduce directamente a la posición de concordancia exa- minando jsólo siete caracteres (y cinco más para comprobar la concordancia)! Este algoritmo del «carácter no concordante))es bastante fácil de implemen- tar. Es una versión mejorada del método de fuerza bruta y de d-erechaa iz- quierda utilizada para inicializar un array saltar que indica, para cada carác- ter del alfabeto, cuántas posiciones se debe saltar en el texto si este carácter provoca una no concordancia durante la búsqueda de la cadena. Debe haber una entrada en saltar para cada carácter que pueda aparecer en el texto: para simplificar,se supone que se tiene una función índice que toma un char como argumento y devuelve O para los espacios en blanco e i para la i-ésima letra del alfabeto;se supone también que se dispone de una subrutina inicsaltar() que inicializa el array saltar para M caracteres que no están en el patrón y luego, paraj deOaM-1, asignaa saltar[indice(p[j])] elvalor M-j-l. Laimple- mentación es directa: i n t buscar-caracter-de-noconcordancia(char *p, char *a) int i , j, t, M = strlen(p), N = strlen(a); i nicsaltar(p); for ( i = M-1, j = M-1; j > O; i--, j--) while (a[i] != p[j]) t = saltar[i ndice(a [i ] ) } ; i += (M-j > t) ? M-j : t ; if (i> = N) return N; {
  • 339. B~SQUEDADECADENAS 319 j = M-1; 1 return i; } Si la tabla sal tar fuera toda O (loque nunca es), esto correspondería a una ver- sión de derecha a izquierda del método de fuerza bruta, porque la sentencia i += M-j cambia el valor de ipara la nueva posición de la cadena de texto (como si el patrón se moviese de izquierda a derecha a lo largo de sí mismo); entonces j = M- 1 reasigna al puntero del patrón para prepararlo para una concordancia de derecha a izquierda, carácter a carácter. Como se acaba de presentar, la tabla sal tar permite que se pueda mover el patrón a lo largo del texto tan lejos como sea posible, la mayoría de las veces M caracteres a la vez (cuando se encuentren caracteresdel texto que no estén en el patrón). Para el patrón DENAS, el valor de sal tar para S sería O, para A sería 1, para N sería 2, para E sería 3, para D sena 4, y para todas las otras letras sería 5. Así, por ejemplo, cuando se encuentra una D durante una exploración de derecha a izquierda, el puntero i se incre- menta en 4 de forma que el final del patrón se alinea cuatro posiciones a la de- recha de D (y en consecuencia la D del patrón se alinea con la D del texto). Si hubiese más de una D en el patrón, se desearía utilizar para este cálculo la que está más a la derecha: de aquí que el array sal tar se constmya explorando de izquierda a derecha. Boyer y Moore sugirieron combinar los dos métodos que se han esbozado para la exploración de derecha a izquierda, escogiendo el más grande de los dos valores de salto. Propiedad 19.3 La búsqueda de cadenas de Boyer-Moore nunca utiliza más de M+N comparaciones de caracteres, y necesita alrededor de N/M pasos si el al- fabeto no espequeño y el patrón no es largo. El algoritmo es lineal en el peor caso de la misma manera que el método de Knuth-Mooris-Pratt (la implementación anterior, que sólo utiliza una de las dos heurísticas de Boyer-Moore, no es lineal). El «caso medio» N/M se puede pro- bar para varios modelos de cadenas aleatorias, pero éstos tienden a ser irreales, por lo que no se entrará en los detalles. En muchas situacionesprácticases cierto que solamente unos pocos caracteres del alfabeto aparecen en el patrón, así que cada comparación conduce a un desplazamiento de M caracteres, lo que da el resultado senalado.. Claro está, el algoritmo del (carácter no concordante» no ayuda mucho en el caso de cadenas binanas, porque sólo hay dos clases de caracteresque puedan causar la no concordancia (y posiblemente estén ambas en el patrón). Sin em- bargo, los bits se pueden agrupar para formar «caracteres»que se pueden utili- zar exactamente como se vio antes. Si se toman b bits a la vez, entonces se ne- cesita una tabla sal tar con 2' entradas. El valor de b se debe escoger lo
  • 340. 320 ALGORITMOS EN C++ suficientemente pequeño para que esta tabla no sea demasiado grande, pero también lo suficientemente grande como para que la mayoría de las series de b bits del texto no estén en el patrón. Específicamenie, hay M - b + 1 secciones diferentes de b bits en el patrón (comenzando cada una en una posición de bit desde 1 hasta M - b + I), y se desea que M - b + 1sea significativamentemenor que 2'. Por ejemplo, si se toma baproximadamente igual a ig(4A4), la tabla sal - tar estará llena en más de sus tres cuartas partes con M entradas. También b debe ser menor que M/2, puesto que de lo contrario se podría ocultar por com- pleto el patrón si se dividiera en dos series de texto de b bits. Algoritmo de Rabin-Karp Una aproximación de fuerza-bruta al algoritmo de búsqueda de cadenas que no se ha considerado anteriormente sería explotar una gran memona tratando cada posible serie de M caracteres del texto como si fuera una clave de una tabla de dispersión estándar. Pero no es necesario mantener una tabla de dispersión completa, puesto que el problema se plantea de forma que s610 se busque una clave; todo lo que se necesita hacer es evaluar la función dispersión para cada una de las posibles series de M caracteres del texto y verificar si es igual a la función de dispersión del patrón. El problema con este método es que parece tan difícil calcular la función de dispersión para M caracteres del texto como verificar si éstos son iguales al patrón. Rabin y Karp encontraron una forma fácil de evitar esta dificultad para la función de dispersión que se utilizo en el Capítulo 16:h(k)= k mod q, donde q (el tamaño de la tabla) es un gran entero primo. En este caso no se almacena nada en la tabla, por lo que se puede tomar un q muy grande. El método se basa en calcular la función de dispersión para la posición i del texto conociendo su valor para la posición i - 1, de donde se desprende direc- tamente una formulación matemática. Se supone que se transforman los M ca- racteres en números agrupándolos en una palabra en lenguaje de máquina, que podna tratarse como un entero. Esto equivale a escribirlos caracterescomo nú- meros en un sistema de base d, donde des el número de caracteresposibles. El número que corresponde a a[i]...a[i+M - 11es entonces x = a[i]dM-' + a[i+ i p - 2 + ... + a[i+ M -11 y se puede suponer quc se conoce el valor de h(x)= x mod q. Pero un despla- zamiento de una posición a la derecha en el texto corresponde a reemplazar x Por (x - a[i]&-')d + a[i+ M]. Una propiedad fundamental de la operación mod es que si se toma el resto al
  • 341. BUSQUEDADECADENAS 321 dividir por q despuésde cada operación aritmética (para mantener pequeños los números con los que se está tratando), se obtiene la misma respuesta que si se hubieran realizado todas las operaciones aritméticas; luego se toma el resto al dividir por q. Esto conducea un algoritmomuy simple de reconocimientode patronescuya implementación se presenta a continuación. Este programa supone la misma función indice anterior, pero se utiliza d=32 por razones de eficacia (las mul- tiplicaciones se pueden implementar como desplazamientos). const int q = 33554393; const int d = 32; int busquedaRK(char *p, char *a) int i, dM = 1, hl = O, h2 = O; int M = strlen(p), N = strlen(a); for (i = 1; i < M; i++) dM = (d*dM) % q; for (i= O; i < M; i++) { hl = (hl*d+indice(p[i])) % q; h2 = (h2*d+indice(a[i])) % q; { 1 { for (i = O; hl != h2; i++) h2 = (h2+d*q-i ndice(a[i] )*dM) % q; h2 = (h2*d+indice(a[i+M])) % q; if (i > N-M) return N; 1 return i; 1 El programa calcula primer6 el valor de dispersión hl para el patrón y el valor h2 para los primeros M caracteres del texto. (También calcula el valor de dM-1 mod q en la variable dM.)A continuación recorre la cadena de texto, utilizando la técnica anterior de calcular para cada i la función de dispersión para los M caracteres que comienzan en la posición i y comparar cada nuevo valor de dis- persión con hl.El número primo q se escoge tan grande como se pueda, pero lo suficientemente pequeño como para que (d+l)*q no provoque un desbor- damiento: esto requiere menos operaciones % que si se utiliza el mayor número primo representable. (Se añade un d*q extra durante el cálculo de h2 para ase- gurarse de que todo queda positivo y por tanto el operador % funciona como es debido.) Propiedad 19.4 Es muy probable que el método de Rabin-Karp sea lineal.
  • 342. 322 ALGORITMOS EN C++ Evidentemente este algoritmo emplea un tiempo proporcional a N +M, pero es preciso señalar que en realidad sólo encuentra una posición del texto que tenga el mismo valor de dispersión que el patrón. Para asegurarse de que se ha encon- trado una concordancia real, se debe hacer una comparación directa de ese texto con el patrón. Sin embargo, la utilización de un valor de q muy grande, que es posible por los cálculos de % y por el hecho de que no se necesita conservar la tabla de dispersión, hace muy poco probable que se produzca una colisión. En teoría, este algoritmo podría necesitar U(NM)pasos en el (casi imposible) peor caso. pero en la práctica se puede confiar en que tomará alrededor de N f M pasos.. Búsquedas múltiples Los algoritníosque se han presentado están todos orientados hacia un problema específicode búsqueda de cadenas:encontrar una ocurrencia de un patrón dado en una cadena de texto dada. Si la misma cadena de texto va a ser objeto de muchas búsquedas de patrones, entonces merecería la pena hacer algún proce- samiento sobre la cadena para hacer más eficaces las búsquedas posteriores. Si hay un gran número de búsquedas, el problema de la búsqueda de cade- nas puede considerarse como un caso particular del problema general de bús- queda que se estudió en la sección anterior. Se trata simplemente la cadena de texto como N «claves» superpuestas, la i-ésima clave definida como a [i ] ,...,a [NI, es decir la cadena de texto completa que comienza en la po- sición i . Por supuesto, no se manipularán las propias claves, sino los punteros sobre ellas: cuando se necesite comparar las claves iy j se hacen comparacio- nes carácter a caráctercomenzando por las posiciones i y j de la cadena de texto. (Si se utiliza un carácter (centinelm final, mayor que todos los otros caracteres, una de las claves siempre es mayor que la otra.) Entonces 12dispersión, el árbol binario y los otros algontmos de la sección anterior se pueden utilizar directa- mente. Primero, se construye una estructura completa a partir de la cadena de texto, y luego se pueden llevar a cabo búsquedas eficaces para patrones parti- culares. Es preciso realizar muchos detalles cuando se aplican de esta forma los al- goritmos de búsqueda a la búsqueda de cadenas: la intención es señalar que se trata de una opción viable para algunas aplicaciones de búsqueda de cadenas. Cada situación se acompañará de métodos diferentes más o menos apropiados para diferentes situaciones. Por ejemplo, si las búsquedas son siempre de patro- nes de la misma longitud, una tabla de dispersión construida con una sencilla exploración, como en el método de Rabin-Karp, dará como media tiempos de búsqueda constantes. Por el contrario, si los patrones son de longitud variable, entonces alguno de los métodos basados en árboles podría ser más apropiado. (Patricia se adapta especialmente a este tipo de aplicación.) Otras variantes del problema pueden hacerlo bastante más difícil y conducir
  • 343. B~CQUEDADECADENAS 323 a métodos drásticamente diferentes, como podrá verse en los dos próximos ca- pítulos. Ejercicios 1. Implementar un algoritmo de reconocimiento de patrones de fuerza bruta que explore el patrón de derecha a izquierda. 2. Obtener la tabla prox para el algoritmo de Knuth-Moons-Pratt para el pa- trón AAAAAAAAA. 3. Obtener la tabla prox para el algoritmo de Knutli-Moons-Pratt para el pa- trón ABRACADABRA. 4. Dibujar una máquina de estados finitos capaz de encontrar el patrón ABRACADABRA. 5. ¿Cómo se efectuaría una búsqueda en un archivo de texto de una cadena de 50 espaciosen blanco consecutivos? 6. Obtener la tabla saltar de derecha a izquierda para la exploración de de- recha a izquierda del patrón ABRACADABRA. 7. Construir un ejemplo para el que la exploración del patrón de derecha a izquierda (aplicando solamente la heurística de la no concordancia) tenga un mal rendimiento. 8. ¿Cómo se modificaría el algoritmo de Rabin-Karp para buscar un deter- minado patrón con la condición adicional de que el carácter central sea un «comodín» (es decir, que pueda concordar con cualquier carácter)? 9. Implementar una versión del algoritmo de Rabin-Karp para buscar patro- nes en un texto de dos dimensiones. Se supondrá que tanto el patrón como el texto son rectángulos de caracteres. 10. Escribirprogramas para generar una cadena de texto aleatoria de 1.O00bits y después encontrar todas las ocurrencias de los últimos k bits en cualquier lugar de la cadena, para k = 5. 10, 15. (Para diferentes valores de k pueden ser apropiados métodos diferentes.)
  • 345. 20 Reconocimiento de patrones A menudo es deseable efectuar búsquedas de cadenas sobre la base de una pe- queña información sobre el patrón a buscar. Por ejemplo, los usuarios de un editor de texto quizás quisieran especificar sólo una parte del patrón, o especi- ficar un patrón que permita una concordancia con varias palabras diferentes,o especificar que se debe ignorar cualquier número de ocurrencias de determina- dos caracteres. En este capítulo se estudiará cómo se puede hacer eficazmente el reconocimiento depatrones de este tipo. Los algoritmos del capítulo anterior tienen tal vez una dependencia funda- mental de la especificación completa del patrón, por lo que hay que considerar otros métodos. Los mecanismosbásicos que se verán permiten disponer de he- rramientas muy poderosas de búsqueda de cadenas, capaces de reconocer com- plicados patrones de M caracteres en cadenas de texto de N caracteres en un tiempo proporcional a MN2en el peor caso, y mucho más rápidamenteen apli- caciones típicas. En primer lugar hay que desarrollar una forma de describir los patrones: un «lenguaje» que pueda utilizarse para especificar, de forma rigurosa, los tipos de problemas de búsqueda parcial de cadenas que se han sugerido anteriormente. Este lenguaje implicará operaciones primitivas más poderosas que la simple operación de ((verificarsi el i-ésimo carácter de la cadena de texto concuerda con elj-ésimo carácter del patrón» utilizada en el capítulo anterior. En este capítulo se considerarán tres operaciones básicas en términos de un tipo ima- ginario de máquina capaz de buscar patrones en una cadena de texto. El algo- ritmo de reconocimiento de patrones será una forma de simular el funciona- miento de este tipo de máquina. En el próximo capítulo se verá cómo pasar de una especificación de patrón, que el usuario emplea para describir su tarea de búsqueda de cadenas, a la especificación de máquina que realmente emplea el algoritmo para llevar a cabo la búsqueda. Como se verá, la solución que se desarrolla para resolver el problema del reconocimiento de patrones está íntimamente relacionada con ciertos procesos fundamentales de la informática. Por ejemplo, el método que se utilizará en el 325
  • 346. 326 ALGORITMOS EN C++ programa para llevar a cabo la tarea de búsqueda de una cadena subtendida por la descripción de un determinado patrón es análogo al método utilizado por el sistema C++ para efectuar una operación de cálculo de un determinado pro- grama en C++. Descripción de patrones En este capítulo se considerarán las descripcionesde patrones constituidos por símbolos relacionados por las tres operaciones fundamentales siguientes: (i) Concatenación.Ésta es la operación utilizada en el capítulo anterior. Si dos caracteres son adyacentes en el patrón, entonces hay concordancia si y sólo si los dos mismos caracteres son adyacentes en el texto. Por ejemplo, AB significaA seguido de B. (ii) Unión (Or).Ésta es la operación que permite especificar alternativas en el patrón. Si se tiene un or entre dos caracteres, hay concordancia si y sólo si uno de los dos caracteres figura en el texto. Se representa esta operación con el símbolo + y utilizando los paréntesis para combinarlo con la concatenación en situaciones arbitrariamente complejas. Por ejemplo A+B significa «A o B»; C(AC+B)Dsignifica «CACD o CBD»; y (A+C)((B+C)D)significa d B D o CBD o ACD o CCD». (iii) Clausura. Esta operación permite que algunas partes del patrón se pue- dan repetir arbitrariamente. Si se aplica la clausura a un símbolo, en- tonces hay concordancia si y sólo si el símbolo aparece cualquier nú- mero de 'veces(incluyendo O). La clausura se representará poniendo un * después del carácter o grupo entre paréntesis que se quiere repetir. Por ejemplo, AB* concuerda con las cadenas que consisten en una A se- guida de cualquier número de B, mientras que (AB)*concuerda con las cadenas que consisten en repeticiones de la seiie AB. Una cadena de símbolos construida por medio de estas tres operaciones se denomina una expresión regular. Cada expresión regular describe muchos pa- trones de texto. El objetivo es desarrollar un algoritmo que determine si alguno de los patrones descrito por una expresión regular aparece dentro de una deter- minada cadena de texto. Se concentrará la atención en la concatenación, la unión y la clausura con vistas a mostrar los principios básicos del desarrollo de algoritmos para el reco- nocimiento de patrones descritos por expresiones regulares. Por conveniencia, en los sistemas reales normalmente se hacen varias adiciones. Por ejemplo, -A puede significar concuerda con cualquier carácter excepto con A». Esta ope- ración not es la misma que un or de todos los caracteres diferentes de A, pero es mucho más fácil de utilizar. De modo similar, "?" significa concuerda con cualquier letra). De nuevo, esto es evidentemente mucho más compacto que un gran or. Entre los otros ejemplos de símbolosadicionalesque facilitan la es-
  • 347. RECONOCIMIENTO DE PATRONES 327 pecificación de grandes patrones figuran los símbolos de concordancia con el comienzo o el final de una línea, con una letra o un número cualquiera, etcé- tera. Estas operaciones pueden ser marcadamente descriptivas. Por ejemplo, el patrón descrito por ?*(ie+ ei)?*concuerda con todas las cadenas que tienen un ie o un ez en ellas (¡posiblemente a causa de una falta de ortografía!);el patrón (1+01)*(0+1) describe todas las cadenas de O y 1 que no tienen dos O consecu- tivos. Evidentemente hay muchas descripcionesdiferentes de patrones para des- cribir las mismas cadenas: se debe intentar especificardescripcionesde patrones sucintas, al igual que se intenta escribir algoritmos eficaces. El algoritmode reconocimientode patrones qiie se va a examinar puede verse como una generalización del método de búsqueda de cadenas por fuerza bruta de izquierda a derecha (el primer método que se vio en el Capítulo 19).El al- goritmo busca la primera subcadena, empezando por la izquierda de la cadena de texto, que concuerde con la descripción del patrón. Esto lo hace explorando la cadena de texto de izquierda a derecha, comprobando la existencia, en cada posición, de una subcadena que comienza en esa posición y que concuerda con la descripción del patrón. Máquinas de reconocimientode patrones Recuérdese que se puede considerar al algoritmo de Knuth-Monis-Pratt como una máquina de estados finitos, construida a partir del patrón de búsqueda que explora el texto. El método que se va a utilizar para el reconocimiento de patro- nes con expresionesregulares es una generalizaciónde este proceso. La máquina de estados finitos del algoritmo de Knuth-Monis-Pratt pasa de un estado a otro, examinando un carácter de la cadena de texto, y cambiando a un estado si hay concordancia y a otro si no la hay. Una discordancia en algún punto significa que el patrón no puede figurar en el texto precisamente en ese punto. El propio algoritmo puede representarse como una simulación de la má- quina. La característica de la máquina que hace fácil su simulación es que es determinista: cada transición de estado se determina totalmente por el próximo carácter de entrada. Para tratar expresiones regulares, será necesario considerar una máquina abstracta más poderosa. Debido a la operación or, la máquina no puede deter- minar cuándo aparece (o no) el patrón en un punto determinado examinando solamente un carácter. De hecho, debido a la clausura, no puede ni siquiera de- terminar cuántos caracteres será preciso examinar antes que se descubra una discordancia. La forma más natural de evitar estos problemas es dotar a la má- quina del poder del nu determinismo:cuando se enfrente con más de una forma de tratar de concordar con el patrón, la máquina debe padivinan) la correcta! Esta operación parece imposible de admitir, pero se verá que es fácil de escribir un programa que simule las acciones de una tal máquina.
  • 348. 328 ALGORITMOS EN C++ Figura 20.1 Una máquina no determinista de reconocimientodel patrón (A*B+AC)D. La Figura 20.1 muestra una máquina de estados finitos no determinista que podría utilizarse para buscar en una cadena de texto el patrón descrito por (A*B+AC)D. (Los estados están enumerados según una regla que se explicará posteriormente.) Al igual que la máquina determinista del capítulo anterior, la máquina puede pasar de un estado actual etiquetado por un carácter, al estado «apuntado» por el estado actual, si puede concordar (y superar) ese carácter en la cadena de texto. Lo que hace a la máquina no determinista es que hay ciertos estados(denominados estados nulos) que no sólo no están etiquetados, sino que pueden ((apuntar a» dos estados sucesores diferentes. (Algunos estados nulos, tal como el estado 4 del diagrama, son estados que «no operan» con una sola salida,que no afectan a la operación de la máquina, pero facilitan la implemen- tación del programa que construye la máquina, como se verá. El estado 9 es un estado nulo sin salidas, que permite la parada de la máquina.) Cuando se en- cuentra en un estado nulo, la máquina puede dirigirse hacia uno cualquiera de los estados sucesores,independientemente de la entrada (sin superar al próximo carácter). La máquina tiene el poder de adivinar qué transición conducirá a una concordancia en la cadena de texto dada (si es que existe alguna). Se observa que no hay transiciones de «no concordancia» como en el capítulo anterior: la máquina falla en su intento de encontrar una concordancia sólo si no hay forma de adivinar una serie de transiciones que conduzcan a una concordancia. La máquina tiene un único estado inicial (indicado por la línea de la iz- quierda que no sale de ningún círculo) y un único estadofinal (el pequeño cua- drado de la derecha). Cuando se parte de un estado inicial, la máquina drbe ser capaz de «reconocen>cualquier cadena descritapor el patrón leyendocaracteres y cambiando de estado de acuerdo con las reglas, hasta llegar al ((estadofinal». Como la máquina tiene el poder del no determinismo, puede adivinar la sene de cambios de estadosque pueden conducir a la solución. (Pero cuando se trata de simular esta máquina en una computadora estándar, se debe probar con to- das las posibilidades.)Por ejemplo, para determinar si la descripción de patrón (A*B+AC)Dpuede figurar en la cadena de texto CDAABCAAABDDACDAAC la máquina indicaría inmediatamente un fallo si se comenzara por el primer o
  • 349. RECONOCIMIENTODE PATRONES 329 e Figura 20.2 Reconocimiento de AAABD. segundo carácter; trabajaría algo más para informar de un fallo si comenzara por los dos caracteressiguientes;indicaría inmediatamente un fallo al comenzar por el quinto o sexto carácter; y acertaría la serie de transiciones de estados que se muestra en la Figura 20.2 para reconocer AAABD, si se comienza por el sép- timo carácter. Se puede construir la maquina para una expresión regular construyendo máquinas parciales para partes de la expresión y definiendo las reglas de uni6n de dos máquinas parciales para formar una más grande para cada una de las tres operaciones: concatenación, or y clauswa. Se comienza por construir la máquina trivial para reconocer un carácter es- pecífico. Es práctico escribir esto como una máquina de dos estados, con un es- tado inicial (que deberá reconocer el carácter) y un estado final, como se mues- tra en la Figura 20.3. Para construir la máquina para la concatenación de dos expresionesa partir de las máquinas de sus expresionesindividuales, es suficientecon mezclar e!. es- tado final de la primera con el estado inicial de la segunda, como se muestra en ia Figura 20.4. De forma similar, la máquina para la operación or se construye añadiendo
  • 350. 330 ALGORITMOS EN C++ U Figura 20.3 Máquina de dos estados para reconocer un carácter. un nuevo estado nulo que apunte a los dos estados inicialesy haciendo que uno de los estados finales apunte al otro, el cual pasa a ser el estado final de la má- quina unión, como se muestra en la Figura 20.5. Finalmente, la máquina para la operación de clausura se construyc convir- tiendo el estado final en estado inicial y haciéndolo apuntar hacia el antiguo es- tado inicial, así como sobre el nuevo estado final. Esto se muestra en la Figura 20.6. Aplicando sucesivamente estas reglas se puede construir la máquina corres- pondiente a cualquier expresión regular. Los estados de la máquina de los ejem- plos anteriores se han numerado en el orden de construcción, explorandoel pa- trón de izquierda a derecha, por lo que se puede seguir fácilmente el proceso de construcción de la máquina. Nótese que se tiene una máquina elemental de dos estados por cada letra de la expresión regular y que cada + y * entraíian !a crea- U Figura 20.4 Construcción de una máquina de estados: concatenación. Figura 20.5 Construcción de una máquina de estados: or.
  • 351. RECONOCIMIENTODEPATRCINES 33i Figura 20.6 Construcción de una máquinade estados: clausura. ción de un estado (la concatenaciónprovoca la desaparición de uno), por lo que el número de estados es inferior a dos veces el número de caracteres de la ex- presión regular. Representación de la máquina Las mencionadas máquinas no deterministas se construirán utilizando sólo las reglas de composición esbozadas anteriormente, y se podrá aprovechar su es- tructura tan simple para manipularlas de forma directa. Por ejemplo, de un es- tado cualquiera sale un máximo de dos líneas. De hecho, hay solamente dos ti- pos de estados: los etiquetados por un carácter del alfabeto de entrada (de los que sale una sola línea) y 10s no etiquetados(nulos) (de los que salen dos o me- nos líneas). Esto significa que estas máquinas se pueden representar con muy poca información por nodo. Puesto que a menudo se desea acceder a los esta- dos por su número, la forma más conveniente de organización para la máquina es una representaciónpor array. Se utilizarán tres arrays paralelos carac, proxl, y prox2, indexados por estado, para representar y tener acceso a la máquina. Sería posible lograrlo con dos tercios de este espacio de memoria, puesto que cada estado realmente sólo utiliza dos partes significativasde información, pero no se hará uso de estas mejoras para ganar en claridad y también porque en definitiva es probable que las descripciones de los patrones no sean muy largas. La máquina anterior se puede representar como indica la Figura 20.7. Las entradas indexadas por estado pueden interpretarse como instncciones para la máquina no determinista de la forma «Si está usted en estado y ve ca- O 1 2 3 4 5 6 7 8 9 carac A B A C D proxl 5 2 3 4 8 6 7 8 9 0 prox2 5 2 1 4 8 2 7 8 9 0 Figura 20.7 Representaciónpor array de la máquinade la Figura 20.1.
  • 352. 332 ALGORITMOSEN C++ rac[estado], entonces lea el carácter y vaya al estado proxl [estado] (o al prox2 [estado]))). En este ejemplo, el Estado 9 es el estado final y el estado O es un estado pseudo-inicial cuyos valores en los arrays prox son los números de los estados iniciales reales. Obsérvese la representación especial utilizada para los estados nulos con un sucesor (las entradas en lcs dos arrays prox son igua- les) y para el estado final (con cero en ambas entradas de los arrays prox). Se ha visto la forma de construir máquinas para patrones descritos por ex- presiones regulares y cómo representar tales máquinas con arrays. Sin embargo, escribir un programa que haga pasar de una expresión regular a la representa- ción correspondiente por una máquina no determinista es otra cosa. En efecto, incluso escribir un programa para determinar si una expresión regular es legal es un reto para los principiantes. En el próximo capítulo se estudiará detalla- damente esta operación, denominada análisis sintáctico. Por el momento, se supondrá que se ha hecho dicho paso, por lo que se dispone de los arrays carac, proxl, y prox2 que representan a una máquina no determinista determinada, que corresponde a la expresión regular que describe al patrón objeto de interés. Simulación de la máquina El último paso en el desarrollo de un algoritmo general para el reconocimiento de patrones descritospor expresionesregulares consisteen escribirun programa que de alguna forma simule la operación de una máquina no determinista de reconocimiento de patrones. La idea de escribir un programa que pueda «adi- vinan) la respuesta correcta parece ridícula. Sin embargo, en este caso se puede ir «memorizando» sistemáticamente todas lasposibles concordancias, de modo que siempre se acabe por encontrar la correcta. Una posibilidad sería desarrollar un programa recursivo que imite a la má- quina no determinista (pero que trate todas las posibilidades en lugar de estar adivinando la correcta). En lugar de utilizar esta variante, se va a examinar una implementación no recursiva que expondrá los principios básicos de operación del método, guardando los estados que se están considerando en una estructura de datos algo peculiar denominada deqtle (cola de doble extremo). La idea es conservar todos los estados que se podrían encontrar mientras la máquina está «mirando» el carácter de entrada. Cada uno de estos estados se procesa en su momento: los estados nulos conducen a dos estados (o menos), los estados para caracteres que no concuerdan con el carácter de entrada se eli- minan y los estados para caracteres que concuerdan con el carácter de entrada conducen a nuevos estados a utilizar cuando la máquina esté examinando el próximo carácter de entrada. Así pues, se desea mantener una lista de todos los estados en los que la máquina no determinista podría encontrarse en un punto particular del texto. El problema es diseñar una estructura de datos apropiada para esta lista. El procesamiento de los estados nulos parece necesitar una pila, puesto que
  • 353. RECONOCIMIENTODE PATRONES 333 esencialmente se está posponiendo una de las dos accionesa tomar, al igual que al eliminar una recursión (por tanto, el nuevo estado se debe poner al comienzo de la lista para que no quede pospuesto indefinidamente). El procesamiento de los otros estados parece necesitar una cola, dado que no se desea examinar los estados correspondientes al próximo carácter de entrada hasta que no se ter- mine con el carácter en curso (por tanto el nuevo estado se debería poner al j k a l de la lista). En lugar de escoger entre estas dos estructuras, jse utilizarán las dos! Las deques combinan las características de las pilas y de las colas: una de- que es una lista en la que los elementos se pueden añadir por los dos extremos. (En realidad, se utiliza una «deque de salida restringida)),puesto que se quitan los elementos del comienzo, no del final.) Una propiedad primordial de la maquina es que no tiene ««bucles» que estén constituidos únicamente por estados nulos, puesto que de otra manera se po- dría caer, en una forma no determinista, en un bucle infinito. Esto implica que el número de estados de la deque, en cualquier momento, es menor que el nú- mero de caracteres de 12descripción del patrón. El programa que se presenta postericrmente utiliza una deque para simular las acciones de una máquina de reconocimiento de patrones no determinista como la que se describiócon anterioridad. Mientras está examinando un carác- ter particular en la entrada, la máquina no determinista puede encontrarse en un número limitado de estados posibles: el programa conserva la pista de estos estados en una deque, utilizando los procedimientos meter, poner y sacar,pa- recidos a los del Capítulo 3. Se podría utilizar también una representación por array (como en la implementación de cola del Capítulo 3) o una representación por lista enlazada (como en la implementación de pila del Capítulo 3). Se omi- ten los detalles de la implementación. El bucle principal del programa retira un estado de la deque y lleva a cabo la acción requerida. Si se va a hacer la concordancia con un carácter, se com- prueba si éste existe en la entrada: si hay concordancia se efectúa el cambio de estado poniendo el nuevo estado alfinal de la deque (por tanto, todos los esta- dos que implican el carácter en curso se procesan antes que los que implican el carácter siguiente). Si el estado es nulo, los dos estados posibles que se deben simular se ponen al comienzo de la deque. Los estados que implican el carácter en curso se guardan separadamente de aquellos que implican el próximo carác- ter mediante una marca avanza=-1 en la deque: cuando se encuentra esta marca «avanza», se avanza el puntero en la cadena de entrada. El bucle termina cuando se llega al finalde la entrada (no se encontró una concordancia),cuando se llega al estado O (se encontró una concordancia legal), o cuando sólo está la marca avanza en la deque (no se encontró ninguna concordancia). Esto con- duce directamente a la siguiente implementación: const int avanza = -1; int concordar(char *a) i int nl, n2; Deque dq(100);
  • 354. 334 ALGORITMOS EN C++ i n t j = O, N = s t r l e n ( a ) , estado = proxl[O]; dq. poner (avanza) ; while (estado) i f (estado == avanza) { j++; dq. poner (avanza) ; } { else i f (carac[estado] == a [ j ] ) else i f (carac[estado] == ' ' ) dq.poner(proxl[estado]) ; p l = proxl[estado]; n2 = proxZ[estado]; dq .meter ( p i ) ; i f ( p i != p2) dq.rneter(p2); { 1 1 i f (dq.vacio() 11 j==N) return O; estado = dq.sacar(); I r e t u r n j ; Esta función toma como argumento el puntero a la cadena de texto a en la que se intenta encontrar una concordancia, utilizando la máquina no determinista que representa al patrón a través de los arrays carac, proxl y prox2 descritos anteriormente. Esta función devuelve la longitud de la subcadena inicial más corta que concuerda con el patrón (y O si no hay concordancia). Por convenien- cia, se supone que el último carácter de la cadena de texto a es un carácter cen- tinela único que no se repite en ningún otro lugar del array carac que representa al patrón. La Figura 20.8 muestra el contenido de la deque cada vez que se elimina un Figura 20.8 Contenido de la deque durante el reconocimientode AAABD.
  • 355. RECONOCIMIENTO DE PATRONES 335 estado cuando la máquina ejemplo se pone a trabajar con la cadena de texto AAABD. Este diagrama supone una representación por array, como la utilizada para las colas del Capítulo 3: se utiliza un signo de suma para representar avanza. Cada vez que la marca avanza alcanza el frente de la deque (parte in- ferior del diagrama), el puntero j avanza hacia el siguiente carácter del texto. Así pues, se comienza con el estado 5 mientras se explora el primer carácter del texto (la primera A). El estado 5 conduce a los estados 2 y 6; después el estado 2 conduce a los estados 1 y 3, los cuales necesitan leer el mismo carácter y se encuentran al comienzo de la deque. Después el estado 1 conduce al estado 2, pero al final de la deque (para el próximo carácter de entrada). El estado 3 con- duce a otro estado sólo mientras se está explorando una B; por lo tanto se ig- nora mientras se está explorando una A. Cuando finalmente el centinela «avanza» alcanza el frente de la deque, se ve que la máquina puede estar en el estado 2 o en el estado 7 después de leer una A. El programa trata entonces los estados 2, 1, 3 y 7 mientras «está mirando» a la segunda A, para descubrir, la segunda vez que avanza llega al comienzo de la deque, que el estado 2 es la única posibilidad después de la exploración de AA. Ahora, mientras se está exa- minando la tercera A, las únicas posibilidades son los estados 2, 1 y 3 (la posi- bilidad AC ahora está excluida). Estos tres estados se tratan nuevamente, para conducir por último al estado 4 después de la exploración de AAAB. Conti- nuando, el programa va al estado 8, pasa la D y termina en el estado final. Se ha encontrado una concordancia, pero, lo que es más importante, se han con- siderado todas las transiciones coherentes con la cadena de texto. Propiedad 20.1 La simulación del funcionamiento de una máquina de M es- tados para buscar patrones en un texto de N caracteres se puede hacer con me- nos de NM transiciones de estados en el peor caso. Por supuesto, el tiempo de ejecución de concordar depende muy fuertemente del patrón que se está reconociendo. Sin embargo, para cada uno de los N ca- racteres de entrada, parece que se procesan a lo sumo M estados de la máquina; por tanto, el tiempo de ejecución del peor caso debe ser proporcional a MN (para cada posición de comienzo en el texto). Por desgracia, esto no es cierto para concordar, tal como antes se ha implementado, porque cuando se pone un es- tado en la deque el programa no verifica si ya estaba allí, por lo que la deque puede contener copias duplicadas de un mismo estado. Esto puede no tener mucho efecto en aplicacionesprácticas, pero sí provocar una ejecución excesiva en algunos casos patológicossi se deja sin verificar. Por ejemplo, este problema tarde o temprano conduce a una deque con 2N-' estados cuando se concuerda el patrón (A*A)*Bcon una cadena de N A seguida de una B. Para evitar esto, las rutinas de la deque utilizadas por concordar deben estar implementadas de forma tal que se eviten las duplicaciones de estados en la deque (con el fin de garantizar que a lo sumo se procesarán M estadospor cada carácter de entrada). Esto se puede hacer manteniendo un array indexado por estado, que indica qué estados están en la deque. Con este cambio, el número total de operaciones
  • 356. 336 ALGORITMOS EN C++ necesarias para determinar si una porción de la cadena de texto está descritapor el patrón se encuentra en o ( M N ~ ) . ~ No todas las máquinas no deterministas se pueden simular tan eficazmente, como se verá con más detalle en el Capítulo 40, pero la utilización de una hí- potética máquina simple para el reconocimiento de patrones conduce a un al- goritmo bastante razonable para un problema bastante dificil. Sin embargo, para completar el algoritmo se necesita un programa que permita pasar de expresio- nes regulares arbitrarias a «máquinas» que se puedan interpretar con el código anterior. En el próximo capítulo se verá la implementación de un programa tal en el contexto de una presentación más generalde técnicas de compilación y de análisis sintáctico. Ejercicios 1. Obtener una expresiónregular para el reconocimiento de todas las ocurren- cias de una serie de cuatro (o menos) 1 consecutivosen una cadena binaria. 2. Dibujar la máquina no determinista de reconocimiento de patrones para la descripción del patrón (A+B)*+C. 3. Plantear las transmisiones de estados que haría la máquina del ejercicio an- terior para reconocer ABBAC. 4. Explicar cómo se modificaría la máquina no determinista para manipular la función not. 5. Explicar cómo se modificaría la máquina no determinista para manipular caracteres del tipo «sin importancia». 6. ¿Cuántos patrones diferentes se pueden describir por una expresión regular con M operadores or y ningún operador de clausur?i? 7. Modificar concordar para que manipule expresionesregulares con la fun- ción not y caracteresdel tipo «sin importancia». 8. Mostrar cómo construir una descripción de un patrón de longitud M y una cadena de texto de longitud N para los que el tiempo de ejecución de con - cordar sea tan grande como sea posible. 9. Implementar una versión de concordar que evite el problema descrito en la demostración de la propiedad 20.1. 10. Mostrar el contenido de la deque cada vez que se suprime un estado al uti- lizar concordar para simular la máquina del ejemplo que se ha utilizado en el capítulo, con la cadena de texto ACD.
  • 357. 21 Análisis sintáctico Se han desarrollado diversos algoritmos fundamentales para reconocer si los programas de computadora son válidos y desconiponerlosde forma propicia para su posterior procesamiento.Esta operación,denominada anúlisis sintáctico,tiene aplicaciones más allá de la informática, dado que está relacionada con el estu- dio de la estructura del lenguaje en general. Por ejemplo, el análisis sintáctico tiene un papel fundamental en los sistemas que tratan de centendem los len- guajes naturales (humanos) y en los de traducción de una lengua a otra. Un caso particular de interés es la transformación de un lenguaje de computadora «de alto nivel» como C++ (conveniente para el uso humano) a un lenguajede ((bajo nivel» como un ensamblador o uno de máquina (conveniente para ejecutar por computadora). Un programa que hace tales transformaciones se denomina un compilador. De hecho, ya se ha visto un método de análisis sintáctico, en el Ca- pítulo 4, cuando se construyó un árbol para representar una expresión aritmé- tica. En el análisis sintáctico se utilizan dos metodologíasgenerales. Los métodos descendentestratan de probar si un programa es válido buscando en primer lu- gar las partes del programa que son válidas, y después las partes de esas partes, etc., hasta que las piezas sean lo suficientemente pequeñas coma para que co- rrespondan directamente con la cadena de entrada. Los métodos ascendentes van juntando piezas de la entrada de una manera estructurada formando piezas cada vez mayores hasta que se obtenga un programa válido. En general, los mé- todos descendentes son recursivos y los ascendentes iterativos. En general se piensa que los método? descendentes son más fáciles de implementar y los as- cendentes más eficaces. El método del Capítulo 4 era ascendente; en este capí- tulo se estudiará con detalle un método descendente. El tratamiento completo de los temas referentes a los analizadores sintácti- cos y a la construcción de compiladores está claramente fuera del alcance de este libro. Sin embargo, mediante la construcción de un ((sencillocompiladom para completar el algoritmo de reconocimiento de patrones del capítulo ante- rior, se estará también considerando algunos de los conceptos fundamentaies 337
  • 358. 338 ALGORITMOS EN C++ subyacentes. Primero se construirá un andizador sintácticodescendentepara un lenguaje simple de descripción de expresionesregulares. Luego se modificará el analizador sintáctico para hacer un programa que traduzca expresionesreguIa- res en máquinas de reconocimiento de patrones que puedan utilizarse por el procedimiento concordar del capítulo anterior. La intención del capítulo es proporcionar una aproximación a los principios básicos del análisis sintáctico y la compilación, a la vez que se desarrolla un al- goritmo útil de reconocimiento de patrones. Ciertamente no se podrá abordar todo lo que se trate aquí con la profundidad que se merece. El lector debe saber que pueden aparecer dificultades sutiles cuando se aplica la misma estrategia a problemas similares, y que la construcción de compiladores es un campo bas- tante rico con una gran variedad de métodos avanzados de aplicación en situa- ciones senas. Gramáticas libres de contexto Antes de que se pueda escribir un programa que determine si es válido un pro- grama escrito en un cierto lenguaje, se necesita una descripciónde lo que carac- teriza exactamente a un programa válido. Esta descripción se denomina gra- mática:para apreciar esta terminología basta con pensar en una lengua como la del lector y reemplazar en la oración anterior «oración» por «programa»(jex- cepto la primera vez que aparece programa!). Los lenguajesde programación se describen a menudo con un tipo particular de gramática denominada gramá- tica libre de contexto. Por ejemplo, las siguientes reglas caracterizan a la gra- mática libre de contexto definida por el conjunto de todas las expresiones re- gulares válidas (como las descritas en el capítulo anterior). <expresión>::= <término>I <término>+ <expresión> <factor>::= (<expresión>)Iv I(<expresión>)*IY* <término>::= <factor>I<factor><t&mino> Esta gramática describe expresiones regulares como las que se utilizaron en el capítulo anterior, tales como (1+O 1)*(0+1) o (A*B+AC)D. Cada línea de la gra- mática se denomina una producción o regla de reemplazo. Las producciones se componen de símbolos terminales (, ), + y *, que son los símbolos utilizados en el lenguaje que se está describiendo (y «w, un símbolo especial, representa a cualquier letra o dígito); de símbolos no terminales <expresión>,<término>y <factor>,que son internos a la gramática; y de metasímbolos ::= y 1, que sirven para describir el significadode las producciones. El símbolo ::=, que puede leerse como «es un», define la parte izquierda de la producción (la cadena a la iz- quierda del ::= ) en función de los términos de la parte derecha; y el símbolo 1, que puede leerse como un «o» (or),que indica alternativas de selección. Las
  • 359. ANÁLISIS SINTÁCTICO 339 expresión / término / ' . y factor término ' 1 factor I I expresión término ' 1 1 expresión D / término / I I / factor término I / factor factor término A * A factor I C B Figura 21.l Un árbol de análisis sintáctico para (A'B+AC)D. producciones expresadas con esta concisa notación simbólica corresponden de forma simple a una descripción intuitiva de la gramática. Por ejemplo, la se- gunda producción de la gramática del ejemplo puede leerse como «un <tér- mino>es un <factono es un <factor>seguido de un <término>».Un símbolo no terminal, en este caso <expresión>,se distingue en el sentido de que una cadena de símbolos terminales pertenece al lenguaje descrito por la gramática si y sólo si hay alguna forma de utilizar las reglas de producciones para derivar esa ca- dena del no terminal distinguido reemplazando (en cualquier número de pasos) un símbolo no terminal por alguna de las cláusulas or de la parte derecha de la producción de ese símbolo no terminal. Una forma natural de describir el resultado de este proceso de derivación es un árbol de análisis sintáctico: un diagrama de la estructura gramatical com- pleta de la cadena que se está analizando. Por ejemplo, el árbol de análisis sin- táctico de la Figura 21.1 muestra que la cadena (A*B+AC)Dpertenece al len- guaje descrito por la gramática anterior. A veces se utilizan árboles de análisih sintáctico como éstos para descomponer una oración de un lenguaje natural, en sujeto, verbo, objeto, etcétera. La función principal de una analizador sintáctico es aceptar las cadenas que se puedan derivar y rechazar las que no se puedan, intentando construir un ár- bol de análisis sintáctico para una cadena dada. Esto es, el analizador sintáctico puede reconocer si una cadena pertenece al lenguaje descrito por la gramática determinando si existe o no un árbol de análisissintáctico para esa cadena. Los analizadores sintácticos descendentes hacen esto construyendo el árbol comen- zando por el símbolo no terminal distinguido en la raíz y descendiendo hacia la cadena que se debe reconocer situada en el fondo del árbol. Los analizadores sintácticos ascendentes lo hacen comenzando por la cadena del fondo del árbol y ascendiendo hasta llegar al no terminal distinguido de la raíz. Como se verá,
  • 360. 340 ALGORITMOS EN C++ si la semántica de las cadenas a reconocer implica un procesamiento posterior, entonces el analizador sintáctico puede convertir las cadenas en una repr- usen- tación interna que facilitetal procesamiento. Otro ejemplo de una gramática libre de contexto se puede encontrar en el apéndice del libro The C++Programming Language que describe los progra- mas que son válidos en C++. Los principios considerados en esta sección para el reconocimiento y empleo de expresioneslícitas se aplican directamente a la compleja tarea de compilar y ejecutar programas en C++. Por ejemplo, la gra- mática siguiente describe un subconjunto muy pequeño de C++, las expresio- nes aritméticasque contienen sumas y multiplicaciones: <expresión>::= <término>I <término>+ <expresión> <término>::= <factor>I <factor>*<término> <factor>::= (<expresión>)j v Estas regias describen de una manera formal lo que se aceptó como válido en el Capítulo 4: son reglas que especificanlas expresiones aritméticas «válidas».De nuevo, v es un símbolo especialque representa cualquier letra, pero en esta gra- mática las letras pueden representar variables con valores numéricos. A+(B*C) y A*(((B+C)*(D*E))+F) son ejemplos de cadenas válidas para esta gramática. Ya se vio en el Capítulo 4 un árbol de análisis sintáctico para la segunda de es- tas cadenas, pero dicho árbol no correspondía a la gramática anterior; por ejem- plo, no incluía explícitamente los paréntesis. Tal como se han definido ¡as cosas hasta aquí, algunas cadenas son perfec- tamente válidas como expresiones aritméticas y como expresiones regulares. Por ejemplo, A*(B+C) puede significar ((sumarB a C y multiplicar el resultado por ADo «tomar un número cualquiera de A seguido de una B o de una C». Este punto evidencia el hecho obvio de que verificar si una cadena es válida es una cosa, pero comprender su significado es otra diferente.Se volverá sobre este tema una vez que se haya visto cómo analizar una cadena para verificar si está des- crita o no por una cierta gramática. Cada expresión regular es por sí misma un ejemplo de gramática libre de contexto: cualquier lenguje que se puede describir con una expresión regular también se puede describir con una gramática libre de contexto. La recíproca no es cierta: por ejemplo, el concepto de paréntesis «equilibrados» no se puede reproducir por medio de una expresión regular. Otros tipos de gramáticas pue- den describir lenguajes que las gramáticas libres de contexto no pueden. Por ejemplo, las gramáticas dependientes del contexto o sensibles al contexto son idénticas a las anteriores excepto que la parte izquierda de las producciones no tiene que ser de sólo un no terminal. Las diferenciasentre clases de lengiiajesy la jerarquía de las gramáticas que los describen se han estudiado muy cuidado- samente constituyendo una magnífica teoría que se ubica en el corazón de la ciencia informática.
  • 361. ANÁLISIS CINTÁCTICO 341 Aniilisis descendente Algunos métodos de análisis sintáctico utilizan la recursión para reconocer las cadenas del lenguaje descritas exactamente como se especificaron por la gra- mática. De forma más simple, la gramática es una especificacióntan completa del lenguaje ¡que se puede poner directamente en forma de programa! Cada producción correspondea un procedimiento con el mismo nombre que el no terminal de la parte izquierda. Los no terminales de ia parte derecha co- rresponden a llamadas (posiblemente recursivas) a procedimientos; los termi- nales corresponden a la exploración de la cadena de entrada. Por ejemplo, el procedimiento siguiente es parte de un analizador sintáctico descendente para la gramática de expresiones regulares: expresion ( ) termi no ( ) ; if (p[j] == I + ' ) { { j++; expresiono; } 1 Una cadena p contiene la expresión regular que se está analizando, con un ín- dice j que apunta al carácter que se está examinando actualmente. Para anali- zar una expresión regular dada p, se pone j en O y se llama a expresion. Si resulta que j llega a ser M, entonces la expresión regular pertenece al lenguaje descrito por la gramática. Si no, se verá a continuación cómo tratar las distintas situaciones de error. La primera acción de expresi OR es llamar a termi no, cuya implementación es algo más complicada: termi no ( ) factor(); if ((p[j] == ' ( I ) /I ~ e t r a ( p [ j l ) ) termino(); { } Una implementación directa de la gramática haría que termi no llamara pri- mero a factor y luego a termino. Esto está evidentemente condenado al fra- caso porque fio hay forma de salir de termi no: este programa caería en un bu- cle infinito de llamada? recursivas.(Talesbucles tienen efectos muy molestos en muchos sistemas.)La implementación anterior evita esto comprobando en pri- mer lugar la cadena de entrada para decidir si se debe o no llamar a termi no. Lo primero que termino hace es llamar a factor, que es el ú?iico de los pro- cedimientos que puede detectar una no concordancia con !a cadena de entrada. Por la gramática se sabe que cuando se llama a factor, el carácter actual de la
  • 362. 342 ALGORITMOS EN C++ entrada debe ser o un o una letra (representada por v). Este proceso de ve- rificar el próximo carácter de la entrada, sin incrementar j ,para decidir qué ha- cer, se denomina examen por anticipado (anticipación).En algunas gramáticas esto no es necesario, pero en otras puede ser el caso que se necesite más de una anticipación. La implementación de factor se obtiene ahora directamente de la gramática. Si el carácter que se está explorando no es «(» o una letra, se llama a un proce- dimiento error para manipular la condición de error: factor ( ) if (p[j] == ' ( I ) j++; expresi on () ; if (p[j] == I ) ' ) j++; else error(); { 1 else if (letra(p[j])) j++;else error(); if (p[j] == ' * I ) j++; 1 Otra condición de error se produce cuando falta un a))). Las funciones expresion, termi no y factor son evidentemente recursi- vas; de hecho están tan interrelacionadas que no hay forma de escribirlas de forma tal que se pueda declarar cada función antes de llamarla (esto representa una dificultad en ciertos lenguajesde programación). El árbol de análisis sintáctico de una cadena dada proporciona la estructura de las llamadas recursivasdurante el análisis sintáctico. La Figura 21.2 muestra sucesivamente las tres operaciones anteriores cuando p contiene la cadena (A*B+AC)D y se llama a expresion con j=l.Excepto para el signo +, toda la «exploración» se efectúa en factor. Para mayor legibilidad, los caracteres que recorre factor, excepto los paréntesis, se inscriben en la misma línea que la llamada a factor. Se anima al lector a relacionar este proceso con la gramática y el árbol de la Figura 21.1. Este proceso corresponde a recorrer el árbol en orden previo, aun- que la correspondencia no es exacta porque la estrategia de anticipación busca esencialmente cambiar la gramática. Puesto que se parte de la raíz del árbol y se trabaja hacia abajo, es evidente el origen del nombre «descendente». Tales analizadores sintácticos también se denominan analizadores sintácticos recur- sivo descendentes porque recorren el árbol recursivamente. Esta estrategia descendente no funciona con todas las gramáticas libres de contexto posibles. Por ejemplo, si la producción <expresión>::= v I <expresión> + <término>se llevara mecánicamente a C++, se obtendría el siguiente resul- tado indeseable:
  • 363. ANÁLISIS SINTÁCTICO 343 mal a-expresi on ( ) i f (letra(p[j]))j++; el se mal a-expresi on ( ) ; i f (p[j] = ' + I ) { j++; termino(); } el se error () ; { 1 } Si este procedimiento se llamara cuando en p [j ] hubiera un valor diferente de una letra (como en el ejemplo, para j=l)caería en un bucle recursivo infinito. Evitar tales bucles es una de las principales dificultades en la implementación de analizadores recursivos descendentes. En termi no, se utiliza una anticipa- ción para evitar un bucle de esta naturaleza; en este caso una buena solución consiste en invertir la gramática y decir <termino>+ <expresión> en lugar de <expresión>+ <término>. La ocurrencia de un no terminal como primer ele- mento de la parte derecha de una producción que tiene en la parte izquierda el mismo no terminal se denomina recursividad izquierda. De hecho, el problema expresion termino factor ( expresi on termino factor A * termi no factor B + expresi on termi no factor A termino factor C ) termi no factor D Figura 21.2 Análisis sintáctico de (A'B+AC)D.
  • 364. 344 ALGORITMOS EN C++ es más sutil, porque la recursión izquierda puede aparecer indirectamente, por ejemplo en las producciones <expresión>::=<término> y <término>::=v 1 cexpre- sión>+<término>. Los analizadores sintácticos recursivos descendentes no fun- cionan con tales gramáticas; es preciso transformarlos en gramáticas equivalen- tes sin la recursión izquierda, o bien utilizar algún otro método de análisis sintáctico. En general, hay una conexión muy íntima y ampliamente estudiada entre los analizadores sintácticos y las gramáticas que éstos reconocen. La elcc- ción de la técnica de análisis sintáctico depende a menudo de las características de la gramatica que se va a analizar. Análisis sintáctico ascendente Aunque hay varias llamadas recursivas en los programas anteriores, es un ejer- cicio instructivo eliminar sistemáticamente la recursión. Se vio en el Capítulo 5 que cada llamada a procedimiento se puede reemplazar por meter (push)en una pila y cada retorno de procedimiento por saca-(pop)de la pila (reproduciendo lo que hace el sistema C++ para implementar la recursión). Hay que recorúar que una razón para hacer esto es que muchas llamadas que parecen recursivas en realidad no lo son. Cuando una llamada a procedimiento es la última acci6n de un procedimiento, entonces se puede utilizar un simple goto. Esto convierte a expresi on y a ternii no en simplesbucles que se pueden fusionar y combinar con f a c t o r para producir un procedimiento único con una sola llamada ver- daderamente recursiva (la llamada a expresion dentro de factor). Este punto de vista conduce directamente a una forma bastante simple de comprobar si una expresión regular es válida. Una vez que se han eliminado todas las llamadas a procedimientos, se ve que cada símbolo terminal se recorre sólo cuando se encuentra. El único procesamiento real consiste en comprobar cuándo existe un paréntesis derecho que concuerde con cada paréntesis iz- quierdo, si cada a+» está seguido por una letra o un «(», y si cada a*» sigue a una letra o a un «)D. Esto es, comprobar si una expresión regular es válida es en esencia equiva- lente a la comprobación de paréntesis equilibrados. Esto se puede implementar sencillamente con un contador, inicializado a O, que se incrementa cuando se encuentra un paréntesis izquierdo y se decrementa cuando se encuentra un pa- réntesis derecho. Si el contador es cero al finalizar la expresión, y los «+» y los «*»dentro de la misma cumplen con las condiciones que se acaban de mencio- nar, entonces la expresión es válida. Por supuesto, el análisis sintáctico es algo más que comprobar si la cadena de entrada es válida: el objetivo principal es coiistruir el árbol de análisis sintác- tico (incluso de una forma implícita, como en ei analizador sintáctico descen- dente) para llevar a cabo otros procesamientos. Parece posible hacer lo mismQ en pIogramas que tengan esencialmente la misma estructura que el verificador de paréntesis que se acaba de describir. Un tipo de analizador que funciona de
  • 365. ANÁLISIS SINTÁCTICO 345 esta manera es el denominado analizador sintáctico desplazamiento-reducción. La idea es utilizar una pila que almacene los símbolos terminales y no termi- nales. Cada paso del análisis sintáctico es o bien un paso desplazamiento, en el que se pone en !a pila el siguiente carácter de entrada, o un paso reducción, en el que se concuerdan los caracteres de la cima de la pila con la parte derecha de alguna regía de producción de la gramática y se ((reducena» (se reemplazan por) el no terminal de la parte izquierda de la misma producción. (La dificultad principal al construir un analizador sintáctico desplazamiento-reducción es de- cidir cuándo se desplaza y cuando se reduce. Esta puede ser una decisión com- pleja, dependiendo de la gramática.) Tarde o temprano todos los caracteres de entrada tienen que haber pasado a la pila y, también al fin y al cabo, la pila se reduce a un único símbolo no terminal. Los programas de los Capítulos 3 y 4, que construyen un árbol de análisis sintáctico a partir de una expresión infíja, transformándola primero en una expresión postfija, son un ejemplo de un ana- lizador sintáctico de este tipo. En general el análisis sintáctico ascendente se considera como el método a elegir para los lenguajes de programación. Hay una extensa literatura sobre el desarrollo de analizadores sintácticos para grandes gramAticas, del tipo que se necesita para describir un lenguaje de programación. Esta breve descripción sólo roza la superficie de los temas de este campo. Compiladores Un compilador puede considerarse como un programa que traduce de un len- guaje a otro. Por ejemplo, un compilador C++ traúuce programas escritos en lenguaje C++ al lenguaje de máquina de una computadora determinada. Se ilustrará la forma de efectuar esta traducción continuando con el ejemplo de reconocimiento de patrones descritos por expresiones regulares. Sin embargo, ahora se desea traducir del lenguaje de las expresiones regulares a las máquinas de reconocimiento de patrones, es decir los arrays carac, proxl y prox2 del programa concordar del capítulo anterior. El proceso de traducción es esencialmente «uno a uno»: para cada carácter del patrón (con la excepción de los paréntesis) se desea generar un estado de la máquina de reconocimiento de patrones (una entrada de cada array). La clave está en conservar la información necesaria para llenar los arrays proxl y prox2. Para hacer esto, se convierte cada uno de los procedimientos del analizador sin- táctico recursivo descendente en funciones generadoras de máquinas de reco- nocimiento de patrones. Cada función añadirá al final de los mays carac, proxl y prox2 tantos nuevos estados como sea necesario, y devolverá el índice del es- tado inicial de la máquina generada (el estado final será siempre la última en- trada de los arrays). Así es que, por ejemplo, la siguiente función para la pro- ducción de <expresion>genera los estados or para la máquina de reconocimiento de patrones.
  • 366. 346 ALGORITMOS EN C++ int expresion () i int tl, t2, r; tl = termino(); r = tl; if (p[j] == ' + I ) j++; estado++; t2 = estado; r = t2; estado++; actuaiiza-estado(t2, I I , expresion0, tl); actualiza-estado(t2-1, I I , estado, estado); { I return r; Esta función utiliza un procedimiento actual i za-estado que asigna a las en- tradas de los arrays carac,proxl y prox2, indexadas por el primer argumento, los valores proporcionados por el segundo,tercero y cuarto argumentos, respec- tivamente. El estado índice conserva el estado «actual» de la máquina que se está construyendo: cada vez que se crea un nuevo estado se incrementa estado. Así pues, los índices de estados de la máquina que corresponden a una llamada a procedimiento particular varían entre el valor que tiene estado a la entrada del procedimiento y su valor a la salida. El índice del estado final es el valor de estado a la salida. (Realmente no se «crea» el estado final incrementando es- tado antes de la salida, puesto que esto facilita la «fusión» del estado final con posteriores estados iniciales, como se podrá comprobar.) Con este convenio, es fácil comprobar (jcuidado con la llamada recursiva!) que el programa anterior implementa la regla de composición de dos máquinas con la operación or según el diagrama del capítulo anterior. Primero se cons- truye (recursivamente) la máquina para la primera parte de la expresión; luego se añaden dos nuevos estados nulos y se construye la segunda parte de la expre- sión. El primer estado nulo (de índice t2-1) es el estado final de la máquina de la primera parte de la expresión, el cual se pone en un estado «no operativo)) para pasar al estado final de la máquina de la segunda parte de la expresión, como es de desear. El segundo estado nulo (de índice t2) es el estado inicial, por lo que su índice es el valor devuelto por expresión y sus entradas proxl y prox2 apuntan a los estados iniciales de las dos expresiones. Obsérvese que és- tas se construyen en orden inverso al que se podría esperar, porque el valor de estado para el estado no operativo no se conoce hasta que se haya hecho la llamada recursiva de expresi on. La función para <término>construye primero la máquina para un <factor> y luego, si es necesario, fusiona el estado final de esta máquina con el estado inicial de la máquina para otro <término>.Esto es más fácil de hacer que decir, puesto que estado es el índice del estado final de la llamada a factor:
  • 367. ANÁLISIS CINTÁCTICO 347 int termino( ) int t, r; r = factor(); if ( ( p[j] == I ) ' ) 11 ietra(p[j])) t = termino(); return r; { 1 Se ignora simplemente el índice del estado inicial devuelto por la llamada a termino:C++ exige ponerlo en alguna parte, por lo que se coloca en una varia- ble temporal t. La función para <factor>utiliza técnicas similares para manejar sus tres ca- sos: un paréntesis implica una llamadarecursiva a expresion;una v llama a la simple concatenación de un nuevo estado; y un * implica operaciones similares a las de expresion,de acuerdo con el diagrama de clausura que se vio en la sección anterior: int factor( ) int tl, t2, r; tl = estado; i.f (p[j] = ' ( I ) { c j++; t2 = expresiono; i f (p[j] == I ) ' ) j++; else error( ) ; else if (letra(p[ j]) ) 1 { actualiza-estado(estad0, p[j], estado-tl, estado+l); t2 = estado; j++; estado++; else error( ) ; if (p[j] != ' * I ) r = t2; else actualiza-estado(estad0, I I , estado+l, t2); r = estado; proxl[tl-l] = estado; j++; estado++; { 1 return r;
  • 368. 348 ALGORITMOS EN C++ Figura 21.3 Construcción de una máquina de reconocimiento de patrones para (A'B+AC)D. La Figura 21.3 muestra cómo se construyen los estados para el patrón (A*B+AC)D, del ejemplo del capítulo anterior. Primero se construye el estado 1 para la A. Luego se construye el estado 2 para el operando clausura y se agrega el estado 3 para la B. A continuación se encuentra el «+» y los estados 4 y 5 son construidos por expresion,pero no pueden llenarse sus campos hasta después de una llamada recursiva a expresi on,lo que se traduce en la construcción de los estados 6 y 7. Por último, el estado 8 trata la concatenación de D, quedando el estado 9 como estado final. El paso final del desarrollo de un algoritmo general de reconocimiento de patrones descritos por expresiones regulares consiste en poner estos procedi- mientos junto con el procedimiento concordar: void concordar-todos(char *a) j = O; estado = 1; proxl[O] = expresiono; actualiza-estado(0, ' I , proxl[O], proxl[O]); actualiza-estado(estad0, I I , O, O); while (*a) cout << concordar(a++) << I I ; cout < i 'nu; { 1 Este programa imprime, para la posición de cada carácterde una cadena de texto a, la longitud de la subcadena más corta que, comenzando en esa posición, con- cuerda con un patrón p (O si no hay concordancia).
  • 369. ANÁLISIS SINTÁCTICO 349 Compilador de compiladores El programa que se ha desarrollado en este capítulo y en el anterior para el re- conocimiento de patrones descritos por expresiones regulares es eficaz y bas- tante útil. Una versión ligeramente mejorada de este programa (capaz de ma- nejar caracteres «sin importancia)), etc.) tiene todas las posibilidades de encontrarse entre las herramientas más utilizadas en numerosos sistemas de in- formación. Es interesante (algunos pudieran decir confuso) reflexionar sobre este algo- ritmo desde un puntc de vista más filosófico. En este capítulo se han utilizado analizadores sintácticos para explicar la estructura de las expresionesregulares, sobre la base de su descripción formal utilizando una gramática libre de con- texto. Se ha utilizado una gramlitica libre de contexto para especificar un «pa- trón» particular: una serie de caracteres con paréntesis correctamente equilibra- dos. El analizador sintáctico comprueba si el patrón aparece en la cadena de entrada (pero considera que la concordancia es válida sólo si cubre la cadena completa de entrada). Así pues, analizar si una cadena de entrada pertenece al conjunto de cadenas definidas por una gramática libre de contexto, y hacer un reconocimiento de patrones para comprobar si la cadena de entrada pertenece al conjunto de cadenas definidas por una expresión regular, jes esencialmente la misma función! La diferencia fundamental es que las gramáticas libres de contexto son capaces de describir una clase mucho más amplia de cadenas. Por ejemplo, las expresionesregulares no pueden describir el conjunto de todas las expresionesregulares. Otra diferencia en los programas es que la gramática libre de contexto está «integrada»en el analizador sintáctico, mientras que el procedimiento concor - dar está ({dirigidopor tablaw: el mismo programa funciona para toda expre- sión regular, una vez que se haya puesto en un formato apropiado. Parece po- sible construir analizadores sintácticos «dirigidos por tablas)),de modo que el mismo programa pueda servir para analizar todos los lenguajes que se puedan describir con gramáticas libres de contexto. Un generador de analizadores sin- tácticos es un programa que recibe como entrada una gramáticay produce como salida un analizador sintáctico para el lenguaje descrito por esa gramática. Esto se puede llevar más lejos: se pueden construir compiladores dirigidos por tablas en términos de los lenguajes de entrada y de salida. Un compilador de compi- ladores es un programa que recibe como entrada dos gramáticas (así como una especificación de las relaciones entre ellas) y produce, como salida, un compi- lador capaz de traducir las cadenas de uno de los lenguajesal otro. Existen generadores de analizadores sintácticos y compiladores de compila- dores para uso general en la mayoz-ía de los entornos informáticos. Son herra- mientas muy útiles que se pueden utilizar con relativamente poco esfuerzo para producir analizadoressintácticosy compiladoreseficacesy fiables. Por otra parte, los analizadores sintácticosrecursivosdescendentes del tipo considerado en este capítulo son bastante útiles para gramáticas simplescomo las que se encuentran
  • 370. 350 ALGORITMOS EN C++ en muchas aplicaciones. Así pues, al igual que con muchos de los algoritmos que se han considerado, se dispone de un método directo apropiado para apli- caciones donde no sejustifica un gran esfuerzo de implementación. Además, se dispone de vanos métodos avanzados que permiten significativas mejoras del rendimiento en aplicacionesa gran escala. Pero, como ya se dijo, sólo se ha ara- ñado la superficiede un campo que ha sido objeto de una extensa investigación. Ejercicios 1. ¿Cómo encontraría un analizador sintáctico recursivo descendente un error en una expresión regular incompleta, como (A+B)*BC+? 2. Obtener el árbol de análisis sintáctico para la expresión regular ((A+B)+(C+D)*)*. 3. Ampliar la gramática de las expresiones aritméticas para que incluya los operadores de exponenciación, división y módulo. 4. Obtener una gramática libre de contexto que describatodas las cadenas que no tienen más de dos 1 consecutivos. 5. ¿Cuántas llamadas a procedimientos se utilizan por el analizador sintáctico recursivo descendentepara reconocer una expresión regular en términos del número de operaciones de concatenación, or y de clausura y del número de paréntesis? 6. Obtener los arrays carac, proxl y prox2 que resultan al construir la má- quina de reconocimiento de patrones para el patrón ((A+B)+(C+D)*)*. 7. Modificar la gramática de las expresionesregularespara manejar la función not y los caracteres «sin importancia). 8. Construir un programa general de reconocimiento de patrones descritos por expresiones regulares basado en la gramática mejorada obtenida en la pre- gunta anterior. 9. Eliminar la recursión de un compilador recursivo descendente y simplificar el código obtenido tanto como sea posible. Comparar los tiempos de eje- cución de ambos métodos (recursivo y no recursivo). 10. Escribir un compilador para las expresionesaritméticas simplesdescritas por la gramática del texto. Este compilador debe generar una lista de «instruc- ciones))para una máquina hipotética que sea capaz de ejecutar las siguien- tes operaciones:poner el valor de una variable en la pila; sumar los dos va- lores superiores de la pila, eliminándolos de ella y poniendo en su lugar el resultado; y multiplicar los dos valores superiores de la pila, de forma aná- loga.
  • 371. 22 Compresión de archivos Los algoritmos que se han estudiado hasta ahora han sido diseñados, en su ma- yor parte, para que utilicen el menor tiempo posible, quedando la economía de espacio en un segundoplano. En esta sección se examinarán algunosalgoritmos concebidos a la inversa, es decir, para utilizar el menor espacio posible sin con- sumir demasiado tiempo. Irónicamente, las técnicas a examinar para econo- mizar espacio se basan en métodos de «codificación»que provienen de la teoría de la información que se desarrolló para disminuir el volumen de información necesaria en los sistemas de comunicación con la intención, en su origen, de ganar tiempo (no espacio). En general, la mayor parte de los archivos tienen un gran nivel de redun- dancia. Los métodos que se examinarán reducen el espacio aprovechando el he- cho de que muchos archivos tienen un (contenido de información)) relativa- mente bajo. Las técnicas de compresión de archivos sirven a menudo para archivos de texto (en los que ciertos caracteres aparecen con mucha más fre- cuencia que otros), para archivos de «exploración» de imágenes codificadas(que presentan grandes zonas homogéneas) y para archivos de representación digital de sonido y de otras señales analógicas (que pueden presentar gran número de patrones repetidos). En este capítulo se va a considerar un algoritmo elemental bastante útil para resolver este problema y también un método avanzado «óptimo». La cantidad de espacio que se gana con estos métodos varía según las caractensticas de los archivos. Ganancias del 20 al 50 % de espacio son típicas en archivos de texto, y es posible alcanzar entre un 50 y un 90 9'0 en archivos binarios. Para ciertos tipos de archivos, como por ejemplo los constituidospor bits aleatonos, se puede ahorrar muy poco. De hecho, es interesante comprobar que cualquier método de compresión de propósito general puede aumentar el tamaño de algunos archivos (si no fuera así, sena posible, aplicando continuamente el método, ob- tener archivos arbitrariamente pequeños). Por una parte, se puede argumentar que las técnicas de compresión de archi- vos son ahora menos importantes que lo que fueron hace tiempo porque el coste 351
  • 372. 352 ALGORITMOS EN C++ de los dispositivosde almacenamiento ha caído drásticamente y un usuario me- dio puede tener a su alcance mayor capacidad de almacenamiento que la que tenía en el pasado. Pero, al contrario, también se puede argumentar que las téc- nicas de compresión de archivos son ahora más importantes que nunca, porque al poner enjuego un gran volumen de almacenamiento, los ahorros que se pue- den lograr son muy grandes. Las técnicas de compresión son también apropia- das püra los dispositivosde almacenamiento que permiten accesos muy rápidos y que, por naturaleza, son relativamente caros (y por consiguiente pequeños). Codificación por longitud de series El tipo más simple de redundancia que se puede encontrar en un archivo son las largas series de caracteres repetidos. Por ejemplo, considérese la cadena si- guiente: AAAABBBAABBBBBCCCCCCCCDABCBAAABBBBCCCD Esta cadena se puede codificar de forma más compacta reemplazando cada re- petición de caracterespor un solo ejemplar del carácter repetido seguido del nú- mero de veces que se repite. Sena mejor decir que esta cadena consiste en 4 le- tras A, seguidas de 3 B, seguidas de 2 A, seguidas de 5 B, etc. Esta forma de comprimir una cadena se denomina codificación por longitud de series. En el caso de largas series, los ahorros pueden ser espectaculares. Existen varias for- mas de realizar esta idea, dependiendo de las Característicasde la aplicación. (¿Las series tienden a ser relativamente largas? ¿Cuántos bits se necesitan para codificar los caracteres?) A continuación se verá un método particular, para presentar después otras opciones. Si se sabe que las cadenas contienen sólo letras, entonces es posible codifi- carlas introduciendo los dígitos entre las letras. La cadena anterior se podría co- dificar como: Aquí «4A» significa «cuatro letras A», y así sucesivamente. Obsérvese que no merece la pena codificar las series de longitud uno o dos, puesto que para la codificación se necesitan dos caracteres. En archivos binarios se utiliza una versión mejorada de este método con la que se obtienen grandes ahorros de espacio. La idea consiste simplemente en almacenar las longitudes de las series, aprovechando el hecho de que se com- ponen de O o 1, para evitar almacena estos carzcteres.Esto supone que hay po- cas series cortas (sólo se gana espacio si la longitud de la serie es mayor que el número de bits que se necesitan para representardicha longitud en binario), pero
  • 373. COMPRESIÓN DE ARCHIVOS 353 0000000000000000000000000000111111111111110000000c0 000000000000000000000000001111111111111111110000000 000000000000000000000001111111111111111111111110000 000000000000000000000011111111111111111111111111000 000000000000000000001111111111111111111111111111110 000000000000000000011111110000000000000000001111111 000000000000000000011111000000000000000000000011111 000000000000000000011100000000000000000000000000111 000000000000000000011l00000000000000000000000000111 000000000000000000011100000000000000000000000000111 000000000000000000011100000000000000000000000000111 00000000000000000000111100000000000000000000000111o 900000000000000000000011100000000000000000000111O00 011111111!11111111111111111111l1111111111~111111111 O l l i l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l O l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l i l l l l l l l l l l 011111111111111111111111111111!111111~1111111111111 01111111111111111111111111111111111111111111111~111 011000000000000000000000000000000000000000000000011 28 14 9 26 18 7 2324 4 2226 3 20 30 1 19 7 187 19 5 2 2 5 19 3 2 6 3 19 3 2 6 3 19 3 2 6 3 19 3 2 6 3 20 4 2 4 3 1 22 3 2 0 3 3 1 50 1 50 1 50 1 50 1 50 1 2 4 6 2 Figura 22.1 Una matriz de puntostípica, con información de la codificación por longi- tud de series. ningún método de longitud de series es eficaz a menos que la mayor parte de las series sean largas. La Figura 22.1 es una representación «porpixels» de la letra «q (apaisada))). Esta figura es representativa del tipo de información que se puede procesar por un sistemade fonnateo de texto (como el que se ha utilizado para imprimir este libro). A la derecha figura una lista de los números que se podrían utilizar para almacenar la letra en forma comprimida. Así, la primera línea consiste en 28 «O» seguidos de 14 ««I»,seguidosde otros 9 «O» más, etc. Las 63 informaciones de esta tabla más el número de bits por línea (51) contienen suficiente infor- mación para reconstruir el array de bits (en particular destaca que no se necesita ningún carácter de «fin de línea»). Si se utilizan 6 bits para representar cada longitud, entonces el archivo completo se representa con 384 bits, un ahorro sustancial comparado con los 975 bits que se necesitan para almacenarlo en forma explícita. La codificación por longitud de series necesita representaciones separadas para los elementosdel archivoy los de su versión codificada, por lo que no puede aplicarse en todcs los archivos. Esto puede ser un inconveniente: por ejemplo, el método de compresión de archivos de caracteres sugerido anteriormente no funciona con cadenas que contienen dígitos. Si se utilizan otros caracteres para codificarlas longitudes de las series, no podría aplicarseel método a las cadenas que contengan esos caracteres. Para ilustrar una forma de codificar cualquier cadena escrita por medio de un alfabeto fijo de caracteres, utilizando sólo ca-
  • 374. 354 ALGORITMOS EN C++ racteres de ese alfabeto, supóngase que se dispone sólo de las 26 letras del alfa- beto (y de espacios en blanco). ¿Cómo se puede lograr que algunas letras representen dígitos y otras formen parte de la cadena que se va a codificar? Una solución consiste en utilizar un carácter con pocas probabilidades de aparecer en el texto, al que se denomina carácter de escape. Cada aparición de dicho carácter indica que las dos letras siguientes forman un par (longitud, carácter), en el que la i-ésima letra del al- fabeto represenra una longitud igual a i. De esta manera, tomando Q como ca- rácter de escape, la cadena del ejemplo se representaría por: QDABBBAAQEBQHCDABCBAAAQDBCCCD La combinación del carácter de escape, de la longitud de la serie y de una copia del carácter repetido se denomina secuencia de escape. Obsérvese que no me- rece la pena codificar series de menos de cuatro caracteres, ya que se necesitan al menos tres para codificar cualquier serie. Pero ¿qué pasa si el carácter de escape aparece también en la serie de en- trada? No se puede ignorar esta posibilidad, porque es difícil asegurar que no puede aparecer un carácter en particular. (Por ejemplo, alguien puede tratar de codificar una cadena que ya se ha codificado.) Una solución a este problema consiste en utilizar para representar al carácter de escape una secuencia de es- cape con una longitud de sene cero. De esta manera, en el ejemplo, el carácter espacio en blanco podría representar cero, y la secuenciade escape «Q<espacio>» representaría a cualquier aparición de Q en la entrada. Es interesantenotar que los únicos archivos que se «alargan» por este método de compresión son aque- llos que contienen a Q. Si un archivo que ya ha sido comprimido se comprime otra vez, aumenta su longitud en un número de caracteres igual al número de secuencias de escape utilizadas. Las series muy largas se pueden codificar con múltiples secuencias de es- cape. Por ejemplo, una serie de 51 A debería codificarse como QZAQYA, de acuerdo con las convenciones anteriores. Si se espera encontrar series muy lar- gas, merecería la pena utilizar más de un carácter para codificar las longitudes. En la práctica es aconsejable hacer que los programas de compresión y de descompresión sean algo más sensiblesa los errores. Esto se puede lograr inclu- yendo una ligera redundancia en el archivo comprimido,de modo que el pro- grama de descompresión pueda tolerar cualquier pequeño cambio accidental sufrido por el archivo entre la compresión y la descompresión. Por ejemplo, probablemente merece la pena insertar caracteresde «fin de línea)en la versión comprimidade letra «q» que se vio con anterioridad, de modo que el programa de descompresión pueda el mismo resincronizarse en caso de error. La codificación por longitud de series no es particularmente eficaz en archi- vos de texto donde posiblemente el único carácter que tenga series repetidas es el espacio en blanco, ya que existen métodos más simples para codificar series de espacios en blanco repetidos. (Este método se utilizó con provecho en el pa- sado para comprimir archivos de texto creados a partir de lecturas de tarjetas
  • 375. COMPRESIÓN DE ARCHIVOS 355 perforadas, que contenían necesariamente muchos espacios en blanco.) En los sistemasmodernos nunca entran ni se almacenan seriesrepetidas de espaciosen blanco: las que aparecen al principio de una línea se codifican como dabulacio- new, y las que existen al final de las líneas se pueden ignorar utilizando los in- dicadores de «fin de línea). Una implementación de la codificación por longi- tud de series como la anterior (modificada para aceptar todos los caracteres representables) economiza solamente alrededor de un 4 % cuando se utiliza en un archivo de texto como el de este capítulo (jy toda la economía proviene del ejemplo de la letra «q»!). Codificación de longitud variable En esta sección se examinará una técnica de compresión que permite ganar una cantidad considerable de espacio en archivos de texto (y en muchos otros tipos de archivos).La idea es abandonar la forma como se almacenan habitualmente los archivos de texto: en lugar de emplear siete u ocho bits por carácter, se uti- lizarán solamente unos pocos bits para los caracteres más frecuentes y algunos más para los que aparecen más raramente. Será conveniente examinar, en un pequeño ejemplo, cómo se utiliza el có- digo antes de considerar cómo se creó. Suponiendo que se desea codificarla ca- dena ((ABRACADABRA)),su codificación en el código binario compacto es- tándar, en el que la xepresentación con cinco bits de i reproduce a la i-ésima letra del alfabeto (O para los espacios en blanco), proporcionaría la siguiente se- ne de bits: 0000100010100100000100011000010010000001000101001000001 Para ((decodifican)este mensaje se leen grupos de cinco bits y se convierten de acuerdo con la codificaciónbinaria anterior. En este código estándar, la D, que aparece sólo una vez, necesita el mismo número de bits que la A, que aparece cinco veces. Con un código de longitud variable se puede alcanzar ahorros de espacio codificando los caracteres más frecuentemente utilizados con el menor número de bits posible, de forma que se minimice el número total de bits. Se podna tratar de asignar la cadena más corta de bits a las letras más fre- cuentemente utilizadas, codificando A por O, B por 1, R por O1, C por 1O y D por 11, y así ABRACADABRA se codificaría como o 1 0 1 0 1 0 0 1 1 0 1 0 1 0 Esta cadena utiliza sólo 15 bits en lugar de los 55 anteriores, pero esto no es realmente un código porque depende de los ((espaciosen blanco))para delimitar los caracteres. Sin ellos, la cadena O1O1O1O011O1O1O se podría decodificarcomo RRRARBRRA o como otras diferentes cadenas. A pesar de todo, 15 bits más
  • 376. 356 ALGORITMOS EN C++ Figura 22.2 Dos tries de codificaciónpara A, B, C,D y R. 10 delimitadores forman un código mucho más compacto que el código están- dar, principalmente porque no se utilizan bits para codificar letras que no apa- recen en el mensaje. Para ser objetivos, es preciso incluir también los bits del propio código, puesto que el mensaje no puede decodificarsesin él, y el código depende del mensaje (otros mensajes tendrán diferentes frecuencias de apan- cijn de las letras). Más adelante se volverá a considerar este aspecto:por el mo- mento solamente interesa ver hasta qué punto se puede comprimir el mensaje. Los delimitadores no son necesarios si el código de un carácter no es el pre- fijo de otro. Por ejemplo, si se codificaA por 11,B por 00, C por 010, D por 10 y R por O11, no hay más que una sola forma de decodificarla cadena de 25 bits 1100011110101110110001111 Una forma fácil de representar el código es con un trie (ver Capítulo 17).En efecto, cualquier trie con M nodos externos se puede utilizar para codificar cualquier mensaje con M caracteres diferentes. Por ejemplo, la Figura 22.2 muestra dos códigos que se podnan utilizar para ABRACADABRA.El código de cada carácter se determina por el camino desde la raíz al carácter, con O para «ir a la izquierda) y 1 para «ir a la derecha», como es habitual en un tne. Así pues, el trie de la izquierda corresponde al código anterior; el trie de la derecha corresponde con el código que genera la cadena o1101001111011100110100 que es dos bits más corta. La representación por trie garantiza que el código de ningún carácter es el prefijo de otro, de modo que la cadena es unívocamente decodificable a partir del tne. Comenzando en la raíz, se desciende por el tne de acuerdo con los bits del mensaje: cada vez que se encuentre un nodo ex- temo, se da salida al carácter del nodo y se comienza de nuevo en la raíz. Pero ¿qué trie es el mejor para utilizar? Existe una forma elegante de cons- truir un tne que proporcione una cadena de bits de longitud mínima para cual- quier mensaie. El método general para encontrar el código fue descubierto por D. Huffman en 1952 y se denomina código de Huffman. (La implementación que se verá utiliza algunas metodologías algorítmicas más modernas.)
  • 377. COMPRESIÓN DE ARCHIVOS 357 A B C D E F I L M N O R C T U k O 1 2 3 4 5 6 3 12 13 1 4 15 18 19 20 21 cuenta[k] 11 6 1 5 3 4 1 6 2 3 7 4 2 2 1 3 Figura 22.3 Cuentas diferentes de cero para UNA CADENA SENCILLA A... Construcción del código de Huffman El primer paso en la construcción del código de Huffman es contar el número de veces que aparece (frecuencia de aparición) cada carácter en el mensaje que se va a codificar. Las siguientes instrucciones permiten llenar un array cuenta [261 con la cuenta de las frecuenciasde aparición de un mensaje en una cadena a. (Este programa utiliza el procedimiento indice descrito en el Capí- tulo 19 para almacenar la cuenta de frecuencias de la i-ésima letra del alfabeto en cuenta[i 1, utilizando cuenta [O] para los espacios en blanco.) for (i=O; i<=26; i++) cuenta[i] = O ; for (i=O; i < M; i++) cuenta[indice(a[i])]++; Por ejemplo, supóngase que se desea codificar la cadena «UNA CADENA SENCILLA A CODIFICAR CON UN NÚMERO MÍNIMO DE BITS». La ta- bla de cuentas que se obtiene se muestra en la Figura 22.3: hay once espacios en blanco, seis A, una B, etcétera. El siguiente paso es construir el trie de codificaciónde abajo hacia arriba de acuerdo con las frecuencias. Al construir el trie, se considerará como un árbol binario con las frecuencias aimacenadas en los nodos: después de su construc- ción se considerará como un trie de codificación, al igual que antes. Primero se crea un nodo del árbol para cada frecuencia distinta de cero, como se muestra en el primer diagrama en la parte superior izquierda de la Figura 22.4 (el orden en el que aparecen los nodos se determina por la dinámica del algoritmo des- crito a continuación, pero no es relevante para esta presentación). A continua- ción se toman los dos nodos con las frecuenciasmás pequeñas y se crea un nuevo nodo con estos dos como hijos y con un valor de frecuencia igual a la suma de los valores de los hijos, como se muestra en el segundo diagrama de la Figura 22.4. (Si existen más de dos nodos con el mismo valor mínimo de frecuencia se eligen dos cualesquiera.) Luego se busca entre !os nodos restantes los dos con menor frecuencia y se vuelve a crear un nuevo nodo de la misma forma, tal y como se muestra en el tercer diagrama de la Figura 22.4. Continuando de esta manera, se van construyendo subárboles cada vez más grandes, a la vez que se reduce en cada paso el número de subárboles del bosque (se eliminan dos y se añade uno). Finalmente, todos los nodos se combinan en un solo árbol.
  • 378. 358 ALGORITMOS EN C++ Figura 22.4 Construcción de un árbol de Huffman.
  • 379. COMPRESIÓN DE ARCHIVOS 359 Obsérvese que los nodos con las frecuencias mis bajas se encuentran bas- tante abajo en el árbol, y los nodos con frecuencias altas se sitúan cerca de la raíz. El número de la etiqueta de los nodos externos (cuadrados) de este árbol es la cuenta de la frecuencia, mientras que el número de cada nodo interno (re- dondos) es la suma de las etiquetas de sus dos hijos. El código de Huffman se obtiene ahora fácilmente sustituyendo las frecuen- cias de los nodos del fondo por los caracteres asociadosy considerando al árbol como un trie de codificación: cada bifurcación hacia la «izquierda» corres- ponde al bit de código O y hacia la «derecha»al de código 1. Evidentemente, las letras con frecuencias altas están cerca de la raíz del ár- bol y se codifican con pocos bits, por lo que éste es un buen código; pero, ¿por qué es el mejor? Propiedad 22.1 La longitud del mensaje codijkado es igual a la longitudpon- derada del camino externo del árbol defrecuencias de Hufman. La «longitud ponderada del camino externo» de un árbol es la suma, para todos los nodos externos, del producto del «peso» (la cuenta de frecuencias)por la dis- tancia a la raíz. Evidentemente ésta es una forma de calcular la longitud del mensaje: es equivalente a la suma, para todos los caracteres, del producto del número de aparicionesde cada carácter por el número de bits asociado con cada aparición. Propiedad 22.2 Ningún árbol con la mismasfrecuencias en los nodos externos puede tener una longitud ponderada del camino externo inferior a la del árbol de Hufman. Cualquier árbol se puede reconstruir con el mismo procedimiento que se utilizó para construir el árbol de Huffman, pero no seleccionando necesariamente en cada paso los dos nodos de menor peso. Se puede demostrar por inducción que no hay mejor estrategia que la de tomar primero los dos pesos men0res.i Siempre que se escoja un nodo, puede ocumr que haya varios nodos con el mismo peso. El método de Huffman no especificalo que hay que hacer en este caso. Las diferentes opciones conducirán a códigos diferentes, pero todos ellos codificarán el mensaje con el mismo número de bits. Por ejemplo, en la Figura 22.5 se muestra otro trie para el ejemplo. El código de A es 000, el de B 110110, el de C 1100, etc. Este árbol es estructuralmente algo diferente del construido en la Figura 22.4. Puede que el lector quiera comprobar que tienen la misma longitud ponderada del camino externo. (El número más pequeño encima de cada nodo del árbol es un índice que sirve como referencia al tratar la imple- mentación que se proporciona más adelante.) La descripción anterior muestra un esbozogeneral de la construcción de un código de Huffman en función de las operaciones algorítmicas que se han es- tudiado. Como es habitual, el paso de esta descripción a una implementación
  • 380. 360 ALGORITMOS EN C++ Figura 22.5 Un trie del código de Huffman para UNA CADENA SENCILLA... concreta es bastante instructivo, por lo que a continuación se examiharán los detalles de la implementación. Implementación La construcción del Arbol de frecuencias implica un proceso general de extraer el elemento más pequeño de un conjunto de elementos desordenados, por lo que se utiliza la clase CP del Capítulo 11 para construir y mantener una cola de prioridad de los valores de las frecuencias.La utilización de esta cola para cons- truir el árbol descrito anteriormente es directa: for (i = O; i <= 26; i++) for (; !cp.vacia(); i++) if (cuenta[i]) cp.insertar(cuenta[i], i); tl = cp.suprimir(); t2 = cp.suprimir(); padre [i ]=O; padre [tl]=i;padre[tZ]=-i ; cuenta[i]= cuenta[tl] + cuenta[t2] ; if ( !cp.vaci a( ) ) cp.insertar(cuenta[ i] , i ) ; { 1 Primero, se insertan en la cola de prioridad las cuentas diferentesde cero. Luego se extraen los dos elementos más pequeños, se suman sus frecuencias y se in- serta el resultado en la cola, continuando con el mismo procedimiento hasta va- ciar la cola. En cada paso se crea una nueva cuenta y se disminuye el tamaño de la cola. Este proceso crea N- 1 nuevas cuentas, una para cada uno de los no- dos internos que se están creando en el árbol. El índice i continúa refiriéndose al array cuenta, por lo que el primer nodo interno tiene índice 27, etc. Se «crean» nuevos nodos internos a través de i++. El propio árbol está represen-
  • 381. COMPRESIÓNDE ARCHIVOS 361 k O 1 2 3 4 5 6 9 12 13 14 15 18 19 20 21 cuenta[k] 11 6 1 5 3 4 1 6 2 3 7 4 2 2 1 3 padre[k] -38 35 27 34 30 33 -27 35 29 31 36 -33 -29 28 -28 -31 k 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 cuenta[k] 2 3 4 5 6 7 8 10 12 13 15 21 25 36 61 padre[k] -30 -32 32 -34 -36 -37 37 38 39 -39 40 -40 41 -41 O Figura 22.6 Representaciónde enlaces del trie de Huffman de la Figura22.5. tad0 por un array de enlaces«padres»: padre [t]es el índice del padre del nodo cuyo peso está en cuenta[t].El signo de padre [t] indica si el nodo es un hijo derecho o izquierdo del padre. La Figura 22.6 muestra el contenido completo de las estructuras de datos del árbol de la Figura 22.5. Por ejemplo, se tiene cuenta[40]=36, padre [40]=-41 y cuenta [C1]=61 (lo que indica que el nodo de peso 36 tiene índice 40 y es el hijo derecho de un padre que tiene índice 41 y peso 61). El trie es suficientepara describir el propio código; para aclarar este punto a continuación se considerará cómo construir explícitamente el código. La Figura 22.7 muestra el código completo del ejemplo representado por dos arrays: los 1ong [k] bits más a la derecha en la representación binaria del entero co- digo[k] forman el código de la k-ésima letra. Por ejemplo, I e5 la novena letra y tiene como código 001, por lo que código[9]=1, y long[9]=3, lo que indica que el código es los tres bits más a la derecha de la representación binaria del número 1, o sea O 0I. El siguiente segmento de programa convierte a la repre- sentación t i e del código (el array padre, que se muestra en la Figura 22.6) en el propio código de Huffman. for ( k = O; k <= 26; k++) i = 0; x = o; j = 1; { if (cuenta[k]) for (t=padre[k]; t; t=padre[t], j+=j, i++) if (t < O) { x +=j; t = -t; } cod.igo[k! = x; long[k] = i; 1 Los arrays código y 1 ong se calculan de forma directa utilizando el array pa- dre para ascender por el árbol. El meiishje podría codificarserecorriendo el trie de esta forma para cada ca- rácter del mensaje. Como alternativa, se pueden utilizar directamente los arrays codigo y 1ong para codificar el mensaje:
  • 382. 362 ALGORITMOS EN C++ for ( j = O; j < M; j++) for ( i = l o n g [ i n d i c e ( a [ j ] ) ] ; i } O; i--) cout << ((codigo[indice(a[j])] >> i-1) & 1); El mensaje del ejemplo se codifica con sólo 228 bits en lugar de los 300 que se utilizarían en la codificacióndirecta, lo que supone un 24 % de ahorro: 01110100001111100000110101000010000111101101000010110000110 1001010000011100011111001001110100011f011100111000001010111 11100100101011101110101110100111011010001010110011110110001 010001011010011111101010001111101100011011110110111 Ahora, como ya se mencionó, se debe almacenar el árbol o bien enviarlo junto con el mensaje para decodificarlo. Afortunadamente, esto no presenta ninguna dificultad real. No se necesita más que almacenar el array código, por- que el trie de búsqueda por residuos que resulta de insertar las entradas del array en un árbol inicialmente vacío es el árbol de decodificación. A B C D E F I L M N O R S T U k O 1 2 3 4 5 6 9 12 13 14 15 18 19 20 21 codigo[k] 7 O 54 12 26 8 55 3 20 6 2 9 21 22 23 7 long[k] 3 3 6 4 5 4 6 3 5 4 3 4 5 5 5 4 111 o00 IIOIIO IIOO 110101000 IIOIII mi iaiw om o10 1001 io101 10110 io111 0111 Figura 22.7 Código de Huffman para UNA CADENA SENCILLA A... Así, el ahorro de espacio antes mencionado no estotalmente exacto, porque el mensaje no se puede decodificar sin el trie y se debe tener en cuenta el coste que significa almacenar el árbol (esto es, el array código)junto con el mensaje. Por lo tanto, la codificación de Huffman sólo es efectiva para archivos largos donde el ahorro de espacio en el mensaje es suficiente como para compensar el coste, o en situaciones donde el trie de codificación puede calcularse previa- mente y reutilizarse para un gran número de mensajes. Por ejemplo, un tne ba- sado en las frecuenciasde aparición de las letras de un idioma determinado po- dría utilizarse en documentos de texto. De la misma forma, un trie basado en las frecuenciasde aparición de caracteres en programas en C++ se podría utili- zar para la codificación de programas (por ejemplo, «;» estaría cerca de la raíz de un tal trie). Un algoritmo de codificación de Huffman permite unas ganan- cias de espacio de alrededor de un 23% en el texto de este capítulo. Como siempre, para archivos verdaderamente aleatonos, incluso este inge-
  • 383. COMPRESIÓNDE ARCHIVOS 363 nioso esquema de codificación no funcionará bien porque cada carácter apare- cerá aproximadamente el mismo número de veces, lo que conduce a un árbol de codificación completamente equilibrado y a un número igual de bits por le- tra del código. Ejercicios 1. Implementar los procedimientos de compresión y descompresión descritos en el texto para el método de codificación por longitud de series, conside- rando un alfabeto fijo y utilizando la letra Q como carácter de escape. 2. ¿Podría aparecer la serie «QQ» en un archivo comprimido por el método descrito en el texto? ¿Podría aparecer «QQQ»? 3. Implementar los procedimientos de compresión y descompresión descritos en el texto para el método de codificaciónde archivos binarios. 4. El dibujo de la letra «q» del texto se puede procesar como una sene de ca- racteres de cinco bits. Analizar las ventajas e inconvenientes de recumr a esta técnica en el método de codificación por longitud de series basado en caracteres. 5. Mostrar el proceso de construcción del árbol de codificación de Huffman cuando se aplica el método de este capítulo a la cadena ((ABRACADA- BRA». ¿Cuántos bits necesita el mensaje codificado? 6. ¿Cuál es el código de Huffman de un archivo binario? Dar un ejemplo que muestre el máximo número de bits que se podría utilizar en un código de Huffman de un archivo ternario (tres valores) de N caracteres. 7. Suponiendo que las frecuencias de aparición de todos los caracteres a co- dificar son diferentes. ¿Es único el árbol de codificación de Huffman? 8. La codificación de Huffman podría generalizarsede forma directa para co- dificar en caracteresde dos bits (utilizando árbolesde 4vías).¿Cuáles serían las principales ventaja e inconveniente de una técnica como ésta? 9. ¿Cuál sería el resultado de dividir una cadena codificada por Huffman en caracteresde cinco bits y aplicar el código de Huffman a esa nueva cadena? 10. Implementar un procedimiento para decodificar una cadena codificadapor Huffman, conociendo los arrays codi go y 1ong.
  • 385. 23 Criptología En el capítulo anterior se vieron métodos para codificar cadenas de caracteres con objeto de ahorrar espacio. Por supuesto, existe otra razón muy importante para codificar cadenas de caracteres:mantenerlas secretas. La criptología, el estudio de los sistemas de comunicaciones secretas, está constituida por dos campos de estudio complementarios: la criptugrafia, o el di- seño de sistemas de comunicaciones secretas, y el criptoanálisis, o el estudio de las formas de transgredir los sistemas de comunicaciones secretas. La criptolo- gía se aplicó inicialmente a los sistemas de comunicaciones militares y diplo- máticos, pero en la actualidad están apareciendo otras aplicaciones importan- tes. Dos de los principales ejemplos son los sistemas de administración de archivos de las computadoras (en los que cada usuario desea que sus archivos se mantengan como privados)y los sistemasde transferencia electrónica de fon- dos (en los que se tratan grandes cantidades de dinero). Un usuario de compu- tadora desea mantener sus archivostan secretos como lo están sus papeles en su archivador y un banco desea que las transferencias electrónicas de fondos sean tan seguras como las que se hacen en un coche blindado. Excepto en las aplicaciones militares, se supone que los criptógrafosson los «chicos buenos))y los cnptoanalistas los «chicosmalos)): el objetivo es proteger los archivos informáticosy las cuentas de un banco de los ladrones. Si este punto de vista parece poco amistoso, se debe recordar (sin tratar de filosofar mucho) que al utilizar la criptografía se supone ila existencia de la enemistad! Por su- puesto, los chicos buenos)) deben saber algo de criptoanálisis, puesto que la mejor forma de saber si un sistema es seguro es tratar de violarlo uno mismo. (Además hay muchos ejemplos documentados sobre guerras que han finali- zado, salvándose así muchas vidas, gracias a éxitos del criptoanálisis.) La criptolo@atiene muchos lazos con la informática y los algontmos, espe- cialmente con los algoritmos aritméticos y de procesamiento de cadenas que se acaban de estudiar. En efecto,esta relación entre el arte (¿la ciencia?)de la cnp- tología con las computadoras y la informática está ahora empezando a com- prenderse. Al igual que los algoritmos, los sistemas de cripto aparecieron mu- 365
  • 386. 366 ALGORITMOS ENC++ Y Figura 23.1 Un sistemade cripto típico. cho antes que las computadoras. El diseño de sistemas secretos y el de los algoritmos tienen una herencia común, y la misma gente se siente atraída por ambos. No es fácil decir qué rama de la criptología ha sido la más afectada por la aparición de las computadoras. Los criptógrafos tienen ahora a su disposición máquinas mucho más poderosas, pero también es más fácil que cometan erro- res. Los criptoanalistas cuentan con herramientas mucho más eficaces para «romper» los códigos, pero éstos son ahora mucho más complejos. El cripto- análisispuede suponeruna gran carga de trabajo para las máquinas; no sólo fue una de las primeras áreas de aplicación de las computadoras, sino que se man- tiene como uno de los dominios principales de aplicación de las modernas su- percomputadoras. Más recientemente, la amplia difusión de las computadorasha generado la aparición de una gran variedad de nuevas aplicaciones importantes de la crip- tología, como se mencionó anteriormente. Se han desarrollado nuevos métodos de criptografía para responder a las necesidades de tales aplicaciones, y esto ha llevado ai descubrimiento de una relación fundamental entre la criptografía y un área importante de la teoría informática que se examinarábrevemente en el Capítulo 45. En este capítulo se verán algunas de las característicasbásicas de los algorit- mos criptográficos. No se entrará en el detalle de las implantaciones: la cripto- grafía es realmente un campo para confiárselo a los expertos. Mientras que no es difícil «protegerse» cifrando la información con un sencillo algoritmo, es pe- ligroso confiar en un método implantado por un profano. Reglas del juego El conjunto de elementos que permiten la comunicación segura entre dos per- sonas se denominacolectivamente un sistema de cripto. La Figura 23.1 muestra la estructura canónica de un sistema de cripto típico. El emisor envía un mensaje (denominado el texto en claro) al receptor, transformándoloen una forma secreta propicia para la trasmisión (denominada
  • 387. CRlPTOLOGíA 367 el texto cifrado) por medio de un algoritmo de criptografía (el método de ci- frado)y algunosparámetros (c2ave.s).Para leer el mensaje, el receptor debe tener un algoritmo criptográfico equivalente (el método de descifrado)y los mismos parámetros clave que transformarán el texto cifrado en el texto original. Habi- tualmente se supone que el texto cifrado se envía por líneas de comunicación insegurasy que puede estar al alcance del criptoanalista. También se supone que el método de cifrado y el de descifrado son conocidos por el criptoanalista: su objetivo es recuperar el texto en claro a partir del texto cifrado, pero sin conocer las claves. Es de destacar que todo el sistema depende de algún método preli- minar de comunicación entre el emisor y el receptor para ponerse de acuerdo sobre los parámetros claves. Por regla general, cuantas más claves haya, más se- guro será el sistema, pero más incómodo de utilizar. Esta situación es análoga a la de los sistemasde seguridad más convencionales: la combinación de una caja fuerte es más segura cuantos más números tenga, pero es más difícil de recor- dar. La analogía con los sistemas convencionales también sirve para recordar que cualquier sistema de seguridad es tan fiable como lo sean las personas que tengan la clave. Es importante recordar que las cuestiones económicas representan un papel importante en los sistemas de cripto. Serán razones económicas las que lleven a construir dispositivosde cifrado y descifrado simples (porque puede ser que se necesiten muchos y los dispositivos complicados cuestan más), y también habrá una motivación económica para reducir el número de informaciones claves a distribuir (porque pueden necesitar un método de comunicación muy seguro y, por ello, caro). En el equilibrio entre el coste de implantación de un sistema criptográficoseguro y el coste de distribución de las informaciones claves, se en- cuentra el precio que los criptoanalistas están dispuestos a pagar para romper el sistema. En la mayoría de las aplicaciones, el objetivo del criptógrafoes desarro- llar sistemas de bajo coste con la característica de que el criptoanalista debe in- vertir para leer los mensajes mucho más de lo que está dispuesto a pagar. En un pequeño número de aplicaciones, puede que se necesite un sistema de cripto (ciertamente seguro», que pueda garantizar que el criptoanalista nunca podrá leer los mensajes, sin importar lo que esté dispuesto a pagar por ello. (Losgastos muy altos en ciertas aplicaciones de criptología implican naturalmente que se invierten grandes cantidades de dinero para el criptoanálisis.) En el diseño de algoritmos se intenta seguir la pista a los costes para seleccionar el mejor algo- ritmo; en criptología, los costes desempeñan un papel fundamental en el pro- ceso de diseño. Métodos elementales Entre los métodos de cifrado más simples (y de los más antiguos) se encuentra la cifra de César:si una letra del texto en claro es la N-ésima del alfabeto se
  • 388. 368 ALGORITMOS EN C++ reemplaza por la (N +K)-ésima letra del alfabeto, siendo K un cierto entero fijo (César utilizaba K = 3). La siguiente tabla muestra un mensaje cifrado utili- zando este método con K = 1: Textoenclaro: A T A Q U E A L A M A N E C E R Texto cifrado: B U B R V F A BMA B N B O F D F S El método es débil porque el criptoanalista sólo tiene que adivinar el valor de K. intentando con cada una de las 26 opciones, podrá estar seguro de leer el mensaje. Un método mucho mejor consiste en utilizar una tabla general para definir la sustitución a efectuar: para cada letra del texto en claro la tabla dice qué letra poner en el texto cifrado. Por ejemplo, si la tabla ofrece la correspondencia A B C D E F G H I J K L M N O P Q R S T U W X Y Z N U E V O S B R I L Y H A M T W Z Y G Q P F J V K C entonces el mensaje quedará cifrado de la siguiente forma: Textoenclaro: A T A Q U E A L A M A N E C E R Textocifrado: U Q U Z P S N U H N U A U M S V S Y Esta técnica es mucho más poderosa que la simple cifra de César porque el crip- toanalista tendría que ensayar con muchas tablas (alrededor de 27! > lo2*)para estar seguro de leer el mensaje. Sin embargo, las cifras basadas en sustituciones simples como éstas son fáciles de romper debido a las frecuencias de letras in- herentes a un lenguaje. Por ejemplo, puesto que A es la letra más frecuente en los textos en español, el criptoanalista ya tendría parte del trabajo hecho si co- menzara buscando en el texto cifrado cuál es la letra más frecuente y la reem- plazara por A. Aunque ésta puede no ser la selección correcta, con seguridad es mejor que ensayar las 26 letras ciegamente. La situación se hace más fácil (para el cnptoanalista) cuando se tienen en cuenta combinaciones de dos letras: cier- tas combinaciones (tal como QJ) nunca aparecen en un texto en español mien- tras que otras (como LA) son frecuentes. Examinando las frecuencias de las le- tras y de las combinacionesde letras, un cnptoanalista puede romper fácilmente un cifrado por sustitución. Se puede complicarlo más utilizando vanas tablas. Un ejemplo simple de esto es una extensión de la cifra de César denominada la cifra de Vigenere:se utiliza una pequeña clave repetida para determinar el valor de K para cada le- tra. En cada paso, el índice de la letra de la clave se añade al de la letra del texto en claro para determinar el índice de la letra del texto cifrado. El ejemplo de texto en claro, con la clave ABC queda cifrado de la siguiente forma: Clave: A B C A B C A B C A B C A B C A B C Textoenclaro: A T A Q U E A L A M A N E C E R
  • 389. CRlPTOLOGíA 369 Textocifrado: B V D R W H A C O A C P B P H D G U Por ejemplo, la última letra del texto cifrado es U, la vigésimo primera del al- fabeto, porque la letra correspondiente en el texto en claro es R (la decimoctava letra) y la letra correspondiente en la clave es C (la tercera letra). Evidentemente, la cifra de Vigenere se puede hacer más complicada utili- zando tablas generalesdiferentes para cada letra del texto en claro (en lugar de simples desplazamientos). También es obvio que cuanto más larga es la clave más eficaz será la cifra. En efecto, si la clave es tan larga como el texto en claro se tiene la cifra de Vernam,comúnmente denominada clavepara una vez. Éste es probablemente el único sistema de cripto seguro que se conoce, y se dice de él que se ha utilizado en la línea directa Washington-Moscú y en otras aplica- ciones vitales. Puesto que cada letra clave se utiliza una sola vez, el criptoana- lista no puede hacer nada mejor que ensayar todas las claves posibles para cada letra del mensaje, una situación desesperante, ya que esto es tan difícil como ensayar todos los mensajes posibles. Sin embargo, el utilizar cada letra clave una sola vez genera un problema de distribución de clavesbastante serio, por lo que esta clavepara una vez es útil para mensajes relativamente cortos que se deben enviar con poca frecuencia. Si el mensaje y la clave están codificadosen binano, una técnica más común de cifrado letra a letra consiste en utilizar la función «o-exclusivo)):para cifrar el texto en claro se aplica un «o-exclusivo))(bit a bit) con la clave. Una carac- terística interesante de este método es que la operación de descifrares la misma que la de cifrar: el texto cifrado es el o-exclusivodel texto en claro y de la clave, pero la aplicación de otro o-exclusivo al texto cifrado y a la clave lleva de nuevo al texto en claro. Se observa también que el o-exclusivo de los textos cifrado y en claro da la clave. Esto puede sorprender a primera vista, pero realmente mu- chos sistemas criptográficostienen la propiedad de que el criptoanalista puede descubrir la clave si conoce el texto en claro. Máquinas de cifrar/descifrar Muchas aplicaciones criptográficas (por ejemplo, sistemas de voz en comuni- caciones militares) implican la trasmisión de grandes volúmenes de datos, lo que hace imposible la utilización de la clavepara una vez. Lo que se necesita es una aproximación a esta clave en la que se pueda generar un gran volumen de «pseudo claves))a partir de una pequeña fracción, muy distribuida, de la ver- dadera clave. Lo usual en tales situaciones es lo siguiente: el emisor alimenta una má- quina de cifrar con algunas variables de cifrado (claves verdaderas), para gene- rar una larga secuencia de bits de clave (pseudo claves). El o-exclusivo de estos bits y del texto en claro forman el texto cifrado. El receptor, con una máquina similar y las mismas variables de cifrado, genera la misma secuencia de bits de
  • 390. 370 ALGORITMOS EN C++ clave para aplicarle el o-exclusivo con el texto cifrado y recuperar el texto en claro. En este contexto, la generación de claves es similar a la dispersión y a la ge- neración de números aleatorios, por lo que los métodos de los Capítulos 16 y 35 son apropiados para la generación de claves. En efecto, aigunos de los me- canismos presentados en el Capítulo 35 se desarrollaron en principio para uti- lizarlos en máquinas de cifrar/descifi-artales como las que se describen aquí. Sin embargo, los generadores de claves deben ser más complejos que los generado- res de números aleatorios, porque existen técnicas para atacar a las máquinas simples. El problema es que el criptoanalista puede llegar fácilmente a obtener alguna parte del texto en claro (por ejemplo, los tiempos de silencio en un sis- tema de voz), y, por Io tanto, una parte de Ia clave. Si el criptoanalista dispone de suficienteinformación sobre la máquina, entonces lo que conozca de la clave le puede proporcionar suficientes pistas para permitir que en aigún momento pueda deducir los valores de Ias variables de cifrado. Entonces puede simular el funcionamiento de la máquina y calcular todas las claves a partir de ese mo- mento. Los cnptógrafos disponen de varias formas de evitar estos problemas. Una de ellas consiste en definir una parte de la arquitectura de la máquina en sí misma como una variable de cifrado. Por lo regular se supone que el cripto- analista conoce todo sobre la estructura de la máquina (quizás robaron alguna), excepto las variables de cifrado, pero si se utilizan algunas de éstas para con- figuran) la máquina, entonces puede ser dificil encontrar sus valores. Otro mé- todo comúnmente utilizado para confundir al criptoanalista es la cifra pro- ducto, en el que se combinan dos máquinas diferentespara generar una compleja secuencia de claves (o para controlarse mutuamente). Otro método es la susti- tucidn no lineal; aquí el paso del texto en claro al cifrado se hace por grandes segmentos, no bit a bit. El problema de estos métodos es que pueden ser de- masiado complicados, incluso para el cnptógrafo, y siempre puede existir la po- sibilidad de que se produzcan comportamientosdegenerados para alguna selec- ción de las criptovariables. Sistemas de cripto de claves públicas En aplicacionescomercialestales como la transferencia electrónica de fondos y el (verdadero)correo electrónico, elproblema de la distribución de claves es aún más crucial que en las aplicaciones tradicionales de la criptografia. L a perspec- tiva de ofrecer a cada ciudadano grandes claves que deben cambiarse con fre- cuencia, en beneficio de la seguridad y la eficacia, inhibe ciertamenteel desarro- llo de tales sistemas. Sin embargo, en fechas recientes se han desarrollado métodos que prometen eliminar por completo el problema de la distribución de claves. Tales sistemas,denominadossistemas de cripto de clavespúblicas, serán posiblemente muy utilizados en un futuro próximo. Uno de los más conocidos
  • 391. CRIPTOLOG~A 371 se basa en algunos de los algoritmos aritméticos que se han estudiado, por lo que se va a ver un poco más de cerca su modo de funcionamiento. La idea de los sistemas de cripto de claves públicas es utilizar una «guía te- lefónica) de claves de cifra. La clave de cifra de cada uno (P)es de dominio público: la clave de una persona puede figurar, por ejemplo, en la guía, junto a su número de teléfono. Cada uno tiene también una clave secreta para desci- frar: esta clave secreta (S)no la conoce nadie más. Para trasmitir un mensaje M, el emisor utiliza para cifrarlo la clave pública del receptor y luego lo trans- mite. El mensaje cifrado (texto cifrado) será C = P(M). El receptor utiliza su clave privada para descifrar y leer el mensaje. Para que este sistema funcione, deben satisfacerseal menos las siguientes condiciones: (i) S(P(M))= M para todo mensaje M. (ii) Todos los pares (S,P)son diferentes. (iii) Obtener S a partir de P es tan difícil como leer M. (iv) Tanto S como P son fáciles de calcular. La primera es una propiedad fundamental de la cnptografia, la segunday la ter- cera son para dar seguridad y la cuarta hace que los sistemas sean factibles de utilizar. Este esquema general fue esbozado por W. Diffie y M. Hellman en 1976, pero sin proponer ningún método que cumpliera todas estas condiciones. Un método tal fue descubierto rápidamente por R. Rivest, A. Shamir y L. Adle- man. Su esquema, que se conoce como el sistema de cripto de claves públicas RSA, está basado en la aplicación de algoritmos aritméticos sobre enteros muy grandes. La clave de cifrar P es el par de enteros (N,p)y la clave de descifrar S es el par de enteros (NJ),donde s se mantiene secreto. Estos números deben ser muy grandes (típicamente, N puede tener 200 cifras y p y s 100).Los métodos de cifrar y descifrar son simples: primero se divide el mensaje en números me- nores que N (por ejemplo, tomando cada vez IgN bits de la cadena binana co- rrespondiente a la codificaciónpor caracteres del mensaje). Luego se elevan es- tos números, de forma independiente, a una potencia módulo It para curar un mensaje A 4 (una parte del mensaje), se calcula C = P(M) = Mpmod N, y para descifrar un texto cifrado C, se calcula M = S(C)= Cs mod N. En el Capítulo 36 se estudiará cómo llevar a cabo este cálculo; aunque los cálculos con núme- ros de 200 cifras pueden ser engorrosos,el hecho de que sólo se necesite el resto de la división por N permite controlar el tamaño de los números, a pesar de que Mpy sean prácticamente inconcebibles. Propiedad 23.1 tiempo lineal. En el sistema de cripto RSA, un mensaje se puede clfrar en un Para mensajes largos, la longitud de los números utilizados para las claves se puede considerar constante (un detalle de implementación). De forma similar, la exponenciación se efectúa en tiempo constante, puesto que no se permite que
  • 392. 372 ALGORITMOS EN C++ los números sean mayores que esa longitud «constante». Es cierto que este ar- gumento oculta muchas consideraciones de implementación relacionada.., con las operaciones sobre grandes números; el coste de estas operaciones es, de he- cho, un factor limitativo para la generalizaciónde la aplicabilidad del métod0.i Por tanto, se satisface la condición (iv) anterior y la condición (ii) es fácil de asegurar. Sólo queda estar seguros de que las variables de cifrar N, p y s se pue- den escoger para que satisfagan las condiciones (i) y (iii). La demostración de esto necesita una presentación de la teoría de números que está fuera del al- cance de este libro, pero es posible esbozar las ideas principales.En primer lugar hay que generar tres números «aleatorios» primos muy grandes (de 100 cifras aproximadamente): el mayor será s, denominándose a los otros dos x y y. Des- pués se escoge N igual al producto de x y y, y se elige p de modo que ps mod (x - l)(y - 1)=1.Es posible demostrar que, eligiendoN, p y s de esta manera, se tiene que Mps mod N = M para todo mensaje M. Por ejemplo, con la codificación estándar, el mensaje ATAQUE AL AMA- NECER corresponde al número de 36 cifras 012001172105000112000113011405030518 puesto que A es la primera letra (01) del alfabeto, T es la (20), etc. Para man- tener el ejemplo dentro de unos límites razonables se consideran números pri- mos de 2 cifras (y no de 100 como sería necesario): se toma x = 47, y = 79 y s = 97. Estos valores dan un N = 3713 (el producto de x y y) y p = 37 (el único entero que multiplicado por 97 da de resto 1 al dividirlo por 3588). Para cifrar el mensaje, se divide en paquetes de 4 cifras que se elevan a la potencia p (mó- dulo N). Esto da la versión codificada 140403340803000108231215181505271657 Esto es, 012037= 1404, O11737E 0334, 210537= 0803 (mod 3713), etc. El proceso de descifrado es el mismo, pero utilizando s en lugar de p. Así, retro- cediendo, se encuentra el mensaje original porque 140497= 0120, 033497= O117 (mod 3713), etcétera. La parte más importante de los cálculos es la codificación del mensaje, de acuerdo con la propiedad 23.1 anterior. Pero no hay sistema de cripto si no es posible calcular las variables clave. Aunque esto implica una teoría de números sofisticada y programas relativamente complejos para manipular números muy grandes, el tiempo de cálculo de las claves es normalmente inferior al cuadrado de su longitud (y no proporcional a su valor, lo que sería inaceptable). Propiedad 23.2 Las claves de un sistema de cripto RSA se pueden crear sin ex- cesivos cálculos. Aquí se necesitan otra vez métodos que están fuera del alcance de este libro. Se
  • 393. CRlPTOLOGíA 373 entiende que cada gran número primo se puede generar determinando primero un gran número aleatorio, y verificando después sucesivos números, comen- zando a partir de aquél, hasta que se encuentre un primo. Un método simple permite efectuar un cálculo sobre un número aleatorio que, con probabilidad 1/2, «probará»que el número a comprobar no es primo. (Un número que no sea primo sobrevivirá a 20 aplicaciones de esta comprobación menos de una vez en un millón, y a 30 aplicacionesmenos de una vez en mil millones.) El último paso consiste en calcularp: esto indica que una variante del algoritmo de Eucli- des (ver Capítulo 1) responde exactamente a las necesidades del prob1ema.i Recuérdese que la clave de descifrado s (y los factoresx y y de A') se deben mantener en secreto,y que el éxito del método depende de que el criptoanalista no sea capaz de encontrar el valor de s, conociendo N y p . Para el ejemplo, es fácil encontrar que 3713 = 47 * 79, pero si N es un número de 200 cifras, hay poca esperanza de encontrar sus factores. Esto es, parece difícil poder calcular s a partir del conocimiento de p (y N), aunque nadie ha sido capaz de probar que esto es así. Aparentemente encontrar p a partir de s necesita el conocimiento de x y de y, y parece inevitable descomponer a N en factores primos para calcular x y y. Pero esta descomposición de N es un problema muy difícil: el mejor al- goritmo de descomposición conocido llevaría millones de años para descom- poner un número de 200 cifras, utilizando la tecnología actual. Una característica atractiva de los sistemas RSA es que los complicados cálculos que implican a N, p y s se llevan a cabo una sola vez por cada usuario que se suscribeal sistema, mientras que las operaciones cifrar y descifrarno im- plican más que dividir el mensaje y aplicar el simple procedimiento de expo- nenciación. Esta simplicidad de cálculo, combinada con las apropiadas carac- terísticas de los sistemasde cripto de clavespúblicas, hace a este sistema bastante conveniente para la comunicación del tipo confidencial,especialmenteen redes y sistemas de computadoras. El método RSA tiene sus inconvenientes: el procedimiento de exponencia- ción es bastante caro en los estándares de Criptografía, y, lo que es peor, no es posible eliminar la eventualidad de que se puedan leer los mensajescifradosuti- lizando este método. Esto es cierto en muchos sistemas de cripto: un método criptográfico debe resistir a un gran número de intentos de violación por parte de los criptoanalistas antes de que se pueda utilizar con total confianza. Se han sugeridovarios métodos para la implementación de sistemasde cripto de claves públicas. Los más interesantes están relacionados con una clase im- portante de problemasque generalmente se considerancomo muy difícilesy que se estudiarán en el Capítulo 45. Estos sistemas de cripto poseen la interesante propiedad de que un ataque que tenga éxito puede proporcionar ideas sobre cómo resolver algunos de los difícilese insolublesproblemas (como el de la des- composición en factores primos en el método RSA). Esta relación entre la crip- tología y algunos dominios fundamentales de la investigación en la informática, junto con el potencial que significa la difusión de la criptografía de claves pú- blicas, hacen de ella un campo muy activo de la investigación actual.
  • 394. 374 ALGORITMOS EN C++ Ejercicios 1. Descifrar el siguiente mensaje, que se cifró con la cifra Vigenere utilizando como clave el patrón CAB (repetido tantas veces como sea necesario; alfa- beto de 27 letras, con el espacio en blanco precediendo a la A): XOCCQTHHWQUCCGCFJN 2. ¿Qué tabla se debe utilizar para descifrar mensajes que han sido cifrados utilizando el método de sustitución? 3. Suponiendo que se utiliza la cifra Vigenerecon claves de dos caracterespara cifrar un mensaje relativamente largo, escribir un programa para adivinar la clave, partiendo de la hipótesis de que la frecuencia de aparición de los caracteres situados en posiciones impares debe ser aproximadamente igual a la de los caracteres de las posicionespares. 4. Escribir procedimientos de cifrar y descifrar que utilicen la operación «O exclusivo))entre la versión binaria del mensaje y una secuencia binaria de uno de los generadores de números aleatorios de congruencia lineal del Ca- pítulo 35. 5. Escribir un programa para romper el método del ejercicio anterior, supo- niendo que se sabe que los primeros 10caracteres del mensaje son espacios en blanco. 6. ¿Se podría cifrar un texto en claro mediante conjunciones «y»bit a bit en- tre mensaje y clave? Explicar por qué (o por qué no). 7. ¿Verdadero o falso? La criptografía de claves públicas facilita el envío del mismo mensaje a varios destinatarios. Explicar la respuesta. 8. ¿A qué es igual P(S(M))en el método RSA de la criptografía de claves pú- blicas? 9. El cifrado del tipo RSA puede implicar el cálculo de M", donde M puede ser un número de k cifras representado, por ejemplo, por un array de k en- teros. ¿Cuántas operaciones se necesitan en este cálculo? 10. Implementar los procedimientos de cifrar/descifrar para el método RSA (suponiendo que s, p y N se representan por arrays de enteros de tamaño 25).
  • 395. CRIPTOLOG~A 375 REFERENCIAS para el Procesamiento de cadenas Las mejores fuentes para obtener más información sobre muchos de los temas que se han tratado en los capítulos de esta sección son las referenciasoriginales. El artículo de Knuth, Moms y Pratt de 1977, los de Boyer y Moore de 1977 y de Karp y Rabin de 1981 constituyen la base de la mayor parte del material del Capítulo 19. El trabajo de Thompson de 1968 es la base del método de reco- nocimiento de patrones descritos por expresionesregulares de los Capítulos 20 y 21. El artículo de Huffman de 1952 es anterior a muchas de las consideracio- nes algorítmicashechas aquí, pero todavía es una lectura de interés. Rivest, Sha- mir y Adleman describen completamente la implementación y la aplicación de su sistema de cripto de claves públicas en su trabajo de 1978. El libro de Standish es una buena referencia para muchos de los temas cu- biertos por esta sección, especialmente en los Capítulos 19, 22 y 23. Ese libro trata también algunas representacionesy algoritmos prácticos no descritosaquí. El análisis sintáctico y la compilación son para muchos el corazón de la infor- mática: se ha investigado su relación con los algoritmos, pero su relación con los lenguajes de programación, la teoría de la información y otras áreas es mu- cho más importante. Gran parte de los aspectos algorítmicos se ha estudiado con gran detalle. La referencia estándar sobre este tema es el libro de Aho, Sethi y Ullman. Como es obvio, la literatura pública sobre criptografía es bastante escasa. Sin embargo, se puede encontrar mucha información general sobre el tema en los libros de Kahn y Konheim. A. V. Aho, R. Sethi y J. D. Ullman, Compilers: Principles, Techniques, Tools, Addison-Wesley, Reading, MA, 1986.(Existeversión en español por Addi- son-Wesley Iberoamericana N. del E.) R. S. Boyer y J. S. Moore, «Afast string searchingalgorithm», Communications o f the ACM, 20, 10 (octubre, 1977). D. A. Huffman, method for the construction of minimum-redundancy co- des», Proceedings o f the IRE, 40 (1952). D. Kahn, The Codebreakers,Macmillan, New York, 1967. R. M. Karp y M. O. Rabin, «Efficient Randomized Pattern-Matching Algo- rithms», Technical Report TR-31-8l, Aiken Comput, Lab., Harvard U., Cambridge, MA 198i. D. E. Knuth, J. H. Morris y V. R. Pratt, «Fast pattern matching in strings», SIAM Journal on Computing,6, 2 Cjunio, 1977). A. G. Konheim, Cryptography: A Primer, John Wiley & Sons, New York, I981. R. L. Rivest, A. Shamir y L. Aldeman, KA method for obtaining digital signa- tures and public-key cryptosystems», Communications o f the ACM, 21, 2 (febrero, 1978). T. A. Standish, Data Structure Techniques, Addison-Wesley, Reading, MA, 1980. K. Thompson, «Regular expression search algorithm», Communications ofthe ACM, 11, 6 Gunio, 1968).
  • 399. 24 Métodos geométricos elementales Las computadoras se están utilizando cada día más para resolver problemas a gran escala que son inherentemente geométricos. Los objetos geométricos, tales como puntos, líneas y polígonos constituyen la base de una gran variedad de aplicacionesimportantes y conducen a un interesante conjunto de problemas y algoritmos. Los algoritmos geométricosson importantes en sistemas de diseño y análisis de modelos de objetos fisicos,que pueden ser desde edificios y automóvileshasta circuitos integrados a escala muy grande. Un diseñador que trabaja con un ob- jeto fisico posee una intuición geométrica que resulta dificil de aplicar en una representación por computadora. Otras muchas aplicaciones procesan datos geométricos de forma directa. Por ejemplo, un esquema político de «manipu- lación del censo electoral)), que sirva para dividir un distrito en áreas de igual población (y que satisfaga otros criterios, como colocar a todos los miembros del otro partido en una misma zona), es un sofisticado algoritmo geométrico. Otras aplicacionesson de tipo matemáticoo estadístico,campos en los que mu- chos tipos de problemas pueden ser naturalmente puestos en una representa- ción geométrica. La mayoría de los algoritmosque se han estudiado utilizan texto y números, que se representan y se procesan de forma natural en la mayoría de los entornos de programación. De hecho, las operaciones primitivas necesarias se implantan en el hardware de la mayoría de los sistemas de computadoras. Se verá que la situación es diferente en el caso de los problemas geométricos: incluso las ope- raciones más elementales con puntos y líneas pueden ser un reto en términos informáticos. Los problemas geométricos son muy fáciles de visualizar, pero eso puede ser un inconveniente. Muchos problemas, que una persona puede resolver instan- táneamente mirando un papel (por ejemplo: jestá un punto dentro de un polí- 379
  • 400. 380 ALGORITMOS EN C++ gono?), requieren programas de computadora que no son triviales. En el caso de problemas más complicados, como en muchas otras aplicaciones, el método de resolución apropiado para su implantación en una computadora puede ser bastante diferente del método de resolución adecuadopara una persona. Se podría pensar que los algoritmos geométricos deben tener una larga his- toria, debido a la naturaleza constructiva de la antigua geometría y porque las aplicaciones útiles están muy difundidas, pero, en realidad, la mayor parte de los avances en este campo han sido bastante recientes. Sin embargo, el trabajo de los antiguos matemáticos resulta a menudo útil para el desarrollo de algont- mos para las modernas computadoras. El campo de los algoritmos geométricos es interesante de estudiar debido a su fuerte contexto histórico, porque aún se están desarrollando nuevos algoritmos fundamentalesy porque numerosas apli- caciones importantes a gran escala necesitan estos algoritmos. Puntos, líneas y polígonos La mayoría de los programas que se estudiarán operan sobre objetos geométri- cos simples definidos en un espacio bidimensional, si bien se tendrá en cuenta algunosalgoritmos para más dimensiones. El objeto fundamentales elpunto, al que se considera como un par de enteros -las «coordenadas» del punto en el sistema cartesiano habitual-. Una línea es un par de puntos, que se supone que están unidos por un segmento de línea recta. Un polígono es una lista de puntos: se supone que puntos sucesivosestán unidos por líneas y que el primer punto está conectado al último, para formar una figura cerrada. Para poder trabajar con estos objetos geométricos se necesita decidir cómo representarlos. Normalmente se utiliza un array para los polígonos, aunque también se puede usar una lista enlazada o alguna otra representación cuando sea apropiado. La mayoría de los programas utilizarán las siguientes represen- taciones: s t r u c t punto { i n t x, y; char c; }; s t r u c t l i n e a { s t r u c t punto p i , p2; }; s t r u c t punto pol igono[Nmax] ; Hay que destacar que los puntos sólo pueden tener coordenadas enteras. Tam- bién se podría utilizar una representación en coma flotante. El uso de coorde- nadas enteras hace que los algoritmos sean algo más sencillos y más eficaces, y no es una restricción tan estricta como podría parecer. Como ya se mencionó en el Capítulo 2, la utilización de enteros siempre que sea posible puede ahorrar bastante tiempo en muchos entomos de computación,ya que los cálculos con enteros son mucho más eficientes que las operaciones en coma flotante. Por tanto, cuando se pueda conseguir un propósito utilizando solamente enteros, sin introducir demasiadas complicaciones extra, éste será el camino a elegir.
  • 401. MÉTODOSGEOMÉTRICOSELEMENTALES 381 Figura 24.1 Conjuntos de puntos para algoritmos geométflcoc. Se representarán los objetos geométncos más complicados en función de es- tos componentes básicos. Por ejemplo, los polígonos se representarán como arrays de puntos. Se puede advertir que el uso de arrays de 1ineas supondría que cada punto del polígono estaría incluido dos veces (aunque ésta podría ser la representación natural para determinados algoritmos). Además, en algunas aplicaciones resulta útil incluir información adicional asociada a cada punto o línea; se puede hacer esto añadiendo un campo info en los registros. Se utilizará el conjunto de puntos mostrado en la Figura 24.1 para ilustrar las operaciones de vanos algoritmos geométncos. Los 16 puntos de la izquierda están etiquetados con letras que servirán de referencia en las explicaciones de los ejemplos, y poseen las coordenadas enteras que aparecen en la Figura 24.2. (Las letras de las etiquetas se han asignado en el orden en el que se supone se introducen los puntos.) Por lo regular, los programas no tienen motivos para hacer referencia a los puntos por su «nombre»; éstos simplemente se almacenan en un array y se referencian usando un índice. El orden en el que se almacenan los puntos dentro del array puede ser importante en algunos programas: de he- cho, el objetivo de algunos algoritmos geométricos consiste en «ordenam los puntos de una forma determinada. En la parte derecha de la Figura 24.1 hay A B C D E F G H I J K L M N O P x 3 11 6 4 5 8 1 7 9 1 4 1 0 1 6 1 5 1 3 3 12 y 9 1 8 3 1 5 1 1 6 4 7 5 1 3 1 4 2 1 6 1 2 1 0 Figura 24.2 Coordenadas de los puntos del pequeño conjunto de ejemplo (ver Figura 24.1).
  • 402. 382 ALGORITMOS EN C++ Figura 24.3 Comprobación de la intersecciónde segmentos: cuatro casos. 128 puntos generados aleatoriamente, con coordenadas enteras que varían en- tre O y 1000. Un programa típico gestiona un array de p puntos y simplemente lee N pares de enteros, asignando el primer par a las coordenadasx e y de p [11,el segundo par a p [21, etc. Cuando p representa a un polígono, a veces es conveniente mantener valores «centinelas» p [O] =p [NI y p[N+1] =p [11. Intersecciónde segmentos de líneas Como primer problema geométrico elemental, se considerará si dos segmentos determinados se cortan o no. La Figura 24.3 ilustra algunas de Ias situaciones que se pueden dar. En el primer caso, los segmentos se cortan. En el segundo, el extremode un segmento está situado en el otro segmento. Se considerará que esto es una intersección, suponiendoque los segmentos son «cerrados» (los ex- tremos forman parte de los segmentos);por tanto, los segmentos que poseen un extremoen común se cortan. En los dos últimos casos de la Figura 24.3 los seg- mentos no se cortan, pero los casos difieren si se considera el punto de intersec- ción de las líneas definidas por los segmentos. En el cuarto caso, este punto de intersección se encuentra en uno de los segmentos;en el tercer caso no es así. O también, las líneas podrían ser paralelas (un caso especial, que aparece con fre- cuencia, ocurre cuando uno de los segmentos, o ambos, son puntos). La forma más directa de solucionar este problema consiste en encontrar el punto de intersección de las líneas definidas por los segmentos y comprobar después si este punto de intersección está situado entre los extremos de ambos segmentos. Otro método sencillo se basa en una herramienta que será de utili- dad más adelante, por lo que se estudiará con más detalle. Dados tres puntos, se quiere saber si, al ir del primero al segundo y de éste al tercero, el movi- miento es en el sentido contrario al de las manecillas del reloj. Por ejemplo: para los puntos A, B y C de la Figura 24.1, la respuesta es afirmativa, pero para los puntos A, B y D, la respuesta es negativa. Esta función es sencilla de calcular a partir de las ecuaciones de las líneas, como se muestra a continuación: int ccw(struct punto PO,
  • 403. MÉTODOSGEOMÉTRICOS ELEMENTALES 303 struct punto ply struct punto p2 ) int dxl, dx2, dyl, dy2; { dxl p1.X - p0.x; dyl = p1.y - p0.y; dx2 = p2.x - p0.x; dy2 = p2.y - p0.y; if (dxl"dy2 > dyl*dx2) return +l; if (dxl*dy2 > dyl*dx2) return -1; if ((dxl*dx2 < O) (dyl*dy2 < O)) return -1; if ( (dxl*dxl+dyl*dyl) < (dx2*dx2+dy2*dy2) ) return O; return +l; 1 Para entender cómo funciona el programa, se supone en primer lugar que las cantidades dxl, dx2, dyl y dy2 son positivas. A continuación se observa que la pendiente de la línea que une PO y pl es dyl/dxl y la pendiente de la línea que une PO y p2 es dy2/dx2. Ahora, si la pendiente de la segunda línea es mayor que la pendiente de la primera, se necesita un desplazamiento hacia la «&- quierda) (en sentido contrario al de las manecillas del reloj) para ir de PO a pl y a p2;si es menor, se necesita un desplazamiento a la ««derecha» (en el sentido de las manecillas del reloj). La comparación de pendientes en el programa es algo inconveniente, puesto que las lineas podrían ser verticales (dxl o dx2 po- drían ser O): para evitarlo, se multiplica dxl*dx2. Se puede ver que no es nece- sario que las pendientes sean positivas para que esta prueba funcione correcta- mente -la demostración se deja como un instructivo ejercicio-. Pero existe una omisión crucial en la descripción anterior: se ignoran los ca- sos en los que las pendientes son iguales (los tres puntos son colineales).En es- tos casos, se puede pensar en varias formas de definir la función ccw.La opción que se ha elegido consiste en hacer que la función tenga tres valores: en vez de utilizar la notación estándar, en la que el valor devuelto es cero o un valor dis- tinto de cero, se utilizan los valores 1 y -1, reservando el valor O para el caso en el que p2 está sobre el segmento que une PO y pl. Si los puntos son colineales, y PO está situado entre p2 y pl, se hace que ccw devuelva -1; si p2 está situado entre PO y pl, se hace que ccw devuelva O; y si pl está situado entre PO y p2, se hace que ccw devuelva 1. Se verá que este convenio simplifica la codificación de las funciones que utilizan ccw, en este capítulo y en el siguiente. Con estas definiciones se puede implantar rápidamente la función intersec. Si los dos extremos de cada línea están en diferentes dados)) (tienen distintos valores de CCW) respecto a la otra, entonces las líneas deben cortarse: int intersec(struct linea 11, struct linea 12) {
  • 404. 384 ALGORITMOS EN C++ return ((ccw(ll.pl, 11.~2,12.~1) && ((ccw(12.ply 12.p2, 1i.pi) *ccw(ll.pl, ll.p2, 12.p2)) <= O) *ccw(12.ply 12.p2, 1l.pl)) <= O); 1 Esta solución parece que supone la realización de una gran cantidad de cálculos para un problema tan sencillo. Se anima al lector a que intente encontrar una solución más simple, asegurándose de que funciona en todos los casos. Por ejemplo, si los cuatro puntos son colineales,existen seiscasos distintos (sin con- tar las situaciones en las que hay puntos coincidentes), de los cuales sólo cuatro son intersecciones. Los casos especialescomo éstos son el problema de los al- goritmos geométricos:no se pueden evitar, pero se puede minimizar su impacto utilizando primitivas como ccw. Si hay implicadas muchas líneas, la situación pasa a ser mucho más compli- cada. En el Capítulo 27 se verá un sofisticadoalgoritmo que determina si se cor- tan dos líneas cualesquiera de un conjunto de N líneas. Camino cerrado simple Para poder saborear los problemas que se refieren a conjuntos de puntos, con- sidérese el problema de encontrar, a partir de un conjunto de N puntos, un ca- mino que no se corte a sí mismo, que recorra todos los puntos y que vuelva al punto inicial. Tal camino se denomina camino cerrado simple. Es posible ima- ginar muchas aplicaciones para esto: los puntos podrían representar casas, y el camino puede ser la ruta que seguiría un cartero para visitar todas las casas sin cruzar su propio trayecto. O, simplemente, se podna buscar una forma razo- nable de dibujar los puntos usando un plotter mecánico. Este problema es ele- mental, porque sólo busca cualquier camino cerrado que conecte los puntos. El problema de buscar el mejor de los caminos, conocido como el problema del vendedor ambulante,es mucho, muchísimo más dificil, y se abordará con cierto detalle en los últimos capítulos de este libro. En el siguiente capítulo se consi- derará un problema relacionado con él, pero mucho más sencillo: encontrar el camino más corto que envuelve a un determinado conjunto de N puntos. En el Capítulo 31 se verá cómo encontrar la mejor forma de «conectan) un conjunto de puntos. Una forma sencilla de resolver este problema elemental es la siguiente: se selecciona uno de los puntos, que servirá como «pivote». Después se calcula el ángulo de las líneas que unen el pivote con cada uno de los puntos del conjunto según la dirección horizontal positiva (esto es parte de las coordenadas polares de cada punto del conjunto, con el pivote como origen). A continuación se or- denan los puntos según el ángulo calculado. Por último se conectan los puntos
  • 405. MÉTODOS GEOMÉTRICOS ELEMENTALES 385 L I I I Figura 24.4 Caminos cerrados simples. adyacentes. El resultado es un camino cerrado simple que conecta todos los pun?os,como se muestra en la Figura 24.4 para los puntos de la Figura 24.1. En el pequeño conjunto de puntos, se utiliza B como pivote: si los puntos se reco- rren en el orden B M J L N P K F I E C O A H G D B se dibujará un polígono cerrado simple. Si dx y dy son las distancias en los ejes x e y ,desde el punto pivote a cual- quier otro punto, el ángulo buscado en este algoritmo es tan-' &/&. Aunque la arcotangente es una función incorporada en C++ (y en algunos otros entor- nos de programación), es probable que sea lenta y que calcule además al menos dos condiciones molestas para el cálculo: si dx es cero y en qué cuadrante está el punto. Puesto que en este algoritmo el ángulo sólo se utiliza para la ordena- ción, tiene sentido utilizar una función que sea mucho más sencilla de calcular, pero que tenga las mismas propiedades de ordenación que la arcotangente (de forma que, al ordenar, se obtengan los mismos resultados). Una buena candi- data para esta función es simplemente dy/(dy+dx). Aún sigue siendo necesario comprobar las condiciones excepcionales, pero resulta más sencillo. El siguiente programa devuelve un número entre O y 360 que no es el ángulo formado por p l y p2 con la horizontal, pero que tiene las mismas propiedades de ordena- ción: f l o a t t h e t a ( struct punto p l , struct punto p2) i n t dx, dy, ax, ay; f l o a t t; {
  • 406. 386 ALGORITMOS EN C++ dx = p2.x - p1.x; ax = abs(dx); dy = p2.y - p1.y; ay = abs(dy); t = (ax+ay = O) ? O : (float) dy/(ax+ay); if(dx < O) t = 2-t; else i f (dy < O) t = 4+t; return t*90.0; } En algunos entomos de programación puede que no merezca la pena utilizar este programa en sustitución de las funciones trigonométricasestándar, en otros, puede que se consiga un ahorro significativo.(En determinados casos,puede que merezca la pena hacer que theta tenga un valor entero, para evitar completa- mente el empleo de números en coma flotante.) Figura 24.5 Casos a tener en cuenta en el algoritmo del punto en el polígono. Inclusión en un polígono El siguiente problema que se va a considerar es muy natural: dados un punto y un polígono representado como un array de puntos, determinar si el punto está dentro o fuera del polígono. De inmediato se puede ver una solución directa a este problema: se dibuja una línea larga desde el punto, en cualquier dirección (lo suficientemente larga como para garantizar que el otro extremo esté situado fuera del polígono),y se cuenta el número de líneas del polígono a las que corta. Si el número es impar, el punto debe estar en el interior; si es par, el punto es exterior. Esto se comprueba fácilmente viendo lo que sucede ai acercarse desde el extremoexterior: tras la primera intersección, se está dentro; tras la segunda, de nuevo se está fuera, etc. Si se hace esto un número par de veces, el punto al que se llega (el punto original) debe estar en el exterior. No obstante, la situación no es tan simple, ya que se pueden producir aigu- nas interseccionesjusto en los vértices del polígono dado. La Figura 24.5 mues- tra algunas de las situaciones que se deben tener en cuenta. La primera es un caso directo de un punto exterior al polígono; la segunda es un caso directo de punto interior; en el tercer caso, la línea de prueba sale del polígono por un vér- tice (tras tocar otros dos vértices); y en el cuarto caso, la línea de prueba coin-
  • 407. MÉTODOS GEOMÉTRICOS ELEMENTALES 387 cide con uno de los lados del polígono antes de salir. En alguno de los casos en los que la línea de prueba corta a un vértice, debería contar como una intersec- ción con el polígono; en otros casos, no debería contar (o debería contar como dos intersecciones).El lector se puede entretener intentando encontrar una sen- cilla prueba para distinguir estos casos antes de continuar leyendo. La necesidad de tener en cuenta los casos en los que las líneas de prueba pasan por los vértices obliga a hacer algo más que contar los lados del polígono que se cortan con la línea de prueba. En esencia, se desea desplazarse alrededor del polígono, incrementando el contador de intersecciones siempre que ;e pase de un lado de la línea de prueba a otro. Una forma de implantar esto consiste simplemente en ignorar los puntos por los que pasa la línea de prueba, como en el siguiente programa: int i n t e r i o r ( s t r u c t punto t, s t r u c t punto[], i n t N) i n t i , cont = O, j = O; s t r u c t linea I t , l p ; p[O] = P P I ; p[N+!l = ~ [ l l ; for ( i = l ; ic-N; i t t ) { 1 t . p l = t; l t . p 2 = t; l t . p 2 . ~= I N T J A X ; l p . p l = p[i]; lp.p2= p[i]; if ( !i ntersec( 1p ,1t ) ) { t lp.p2= p[j]; j-i; i f (i ntersec( 1p, 1t ) ) cont++; 1 1 return cont & 1; Este programa hace uso de una línea horizontal de prueba para simplificar los cálculos(pueden imaginarse los diagramas de la Figura 24.5 rotados 45 grados). La variable j se utiliza como índice del último punto del polígono que se sabe que no va a estar sobre la línea de prueba. El programa supone que p [11 es el punto que tiene la menor coordenada x de entre todos los puntos con la menor coordenaday, de modo que si p [1] está en la línea de prueba, p[O] no lo puede estar también. El mismo polígono se puede representar mediante N diferentes arrays p, pero como se puede comprobar, a veces resulta conveniente fijar una regia estándar para p [11.(Por ejemplo, esta misma regia es útil para usar p[11 como pivote en el procedimiento sugerido anteriormente pua el cálculo del ca- mino cerrado simple.) Si el siguiente punto del polígono que no está en la línea de prueba está en el mismo lado de la línea de prueba que el punto j-ésimo, no
  • 408. 3aa ALGORITMOS EN C++ se necesita incrementar el contador de intersecciones(cont); en caso contrario, se tiene una intersección. Si se desea, el lector puede comprobar que este algo- ritmo funciona adecuadamenteen los casos de la Figura 24.5. Si el polígono sólo tiene tres o cuatro caras, como sucede en muchas apli- caciones, no es apropiado usar un programa tan complejo: un procedimiento más simple basado en llamadas a ccw será más adecuado. Otro caso especial importante es el polígono convexo, que se estudiará en el siguiente capítulo, y que tiene la propiedad de que ninguna línea de prueba puede tener más de dos interseccionescon el polígono. En este caso se puede utilizar un procedimiento como el de la búsqueda binaria para determinar en O(1ogN)pasos si el punto está o no dentro del polígono. Perspectiva De los pocos ejemplos anteriores, debería estar claro que resulta fácil subesti- mar la dificultad de la resolución de un determinadoproblema geométrico uti- lizando una computadora. Existen otros muchos cálculos geométricos elemen- tales que no se han estudiado. Por ejemplo, un ejercicio interesante podría ser la realización de un programa que calcule el área de un polígono. Los proble- mas vistos hasta el momento constituyen unas herramientas básicas que serán útiles para solucionar algunos problemas más complicados. No obstante, la va- riedad de problemas y algoritmos a tener en cuenta es tan grande que se debe restringir el estudio a algunos ejemplos seleccionados que resuelvan problemas fundamentales y que estén relacionados con los algoritmos que se han visto. Algunos de los algoritmos que se estudiarán implican la construcción de es- tructuras geométricas a partir de un determinado conjunto de puntos. El <qo- lígono cerrado simple» es un ejemplo elemental de esto. Se necesitará decidir las representaciones apropiadaspara tales estructuras, desarrollar los algoritmos para construirlas y estudiar su uso en aplicacionesconcretas. Como siempre, es- tas consideracionesestán interrelacionadas. Por ejemplo, el algoritmo usado en el procedimiento interi or de este capítulo dependetotalmente de la represen- tación del polígono cerrado simple como conjunto ordenado de puntos (en lu- gar de, por ejemplo, un conjunto desordenado de líneas). Como es habitual, la característica de abstracción de datos de C++ propor- ciona una forma conveniente de ofrecer las diversas opciones de representación de las aplicaciones. Por otra parte, las aplicaciones geométricas pueden benefi- ciarse de la estructura jerárquica de clases que ofrece c++ para organizar de forma apropiada todos los tipos de objetos y las operaciones sobre los mismos que se deben implementar. De hecho, los textos sobre C++ a menudo utilizan objetos geométricos para ilustrar las ventajas de este enfoque. En este libro no se verán estos temas con mucho mayor detenimiento, ya que, desde un punto de vista algorítmico,las operaciones necesarias suelen ser demasiado elementa- les (ejemplo: dibujar o rotar una figura), o bien demasiado complicadas (ejem-
  • 409. MÉTODOS GEOMÉTRICOSELEMENTALES 389 plo: calcular la intersección de dos polígonos). La realización de un paquete de software apropiado que soporte búsquedas, intersecciones y otras operaciones aplicablesa un conjunto dinámico de puntos, líneas y polígonos excede los pro- pósitos de este libro, si bien se intentará considerar de qué forma se debería im- plantar eficazmente las operaciones más importantes. Muchos de los algoritmos que se estudian utilizan una búsqueda geomé- trica: se desea conocer qué puntos de un determinado conjunto están cerca de un punto dado, o qué puntos están dentro de un rectángulo dado, o cuáles son los puntos que están situados más cerca entre sí. La mayoría de los algoritmos apropiados para tales problemas de búsqueda están íntimamente relacionados con los algoritmos de búsqueda estudiados en los Capítulos 14 a 17. El parale- lismo será bastante evidente. Se han analizado pocos algoritmos geométricos para que se puedan for- mular valoraciones precisas sobre sus características de rendimiento relativo. Como se ha visto hasta ahora, el tiempo de ejecución de un algoritmo geomé- trico puede depender de muchos factores. La distribución de los propios pun- tos, el orden en que se introducen y la utilización de funciones trigonométricas pueden, en su conjunto, afectar de forma significativa al tiempo de ejecución de los algoritmos geométncos. No obstante, como viene siendo habitual en tales situaciones, se poseen datos empíricos que indican cuáles son los buenos algo- ritmos para determinadas aplicaciones. Además, muchos de los algoritmos se derivan de estudios complejos y han sido disefiados para tener buenos rendi- mientos en el peor caso. Ejercicios 1. Indicar el valor de ccw para los tres casos en los que dos de los puntos son idénticos (y el tercero es distinto), y para el caso en el que los tres puntos son idénticos. 2. Encontrar un algoritmo rápido que determine si dos segmentos son para- lelos, sin utilizar ninguna división. 3. Encontrar un algoritmo rápido que determine si cuatro segmentos forman un cuadrado, sin utilizar ninguna división. 4. Dado un array de 1ineas, ¿cómo se comprobaría si forman un polígono cerrado simple? 5. Dibujar los polígonos cerrados simples que resultan de utilizar los puntos A, C y D de la Figura 24.1 como «pivotes» según el método descrito en el texto. 6. Suponiendo que se utiliza un punto arbitrario como «pivote» en el método descrito en el texto para calcular un polígono cerrado simple, indicar las condiciones que debe satisfacer dicho punto para que el método funcione correctamente.
  • 410. 390 ALGORITMOS EN C++ 7 . ¿Qué valor devuelve la función i ntersec cuando se la llama utilizando dos copias del mismo segmento? 8. ¿Considera la función interior que un vértice del polígono es interior, o, por el contrario,lo toma como exterior? 9. ¿Cuál es el máximo valor que puede tomar la variable cont cuando se eje- cuta interior con un polígono de N vértices? Mostrar un ejemplo que apoye la respuesta. 10. Escribir un programa eficaz que determine si un punto dado está en el in- terior de un determinado cuadrilátero.
  • 411. 25 Obtención del cerco convexo A veces, cuando hay que procesar un elevado número de puntos, lo que interesa es conocer los límites del conjunto de dichos puntos. Al observar en un dia- grama un conjunto de puntos dibujados en el plano, normalmente no hay pro- blema en distinguir los puntos que están «dentro» del conjunto de los que se encuentran en los bordes. Esta distinción es una característica fundamental de los conjuntos de puntos; en este capítulo se verá cómo se pueden caracterizar de forma precisa, examinando algoritmos que distinguen los puntos que con- forman el <&mitenatural». El método matemáticoutilizado para la descripción del límite natural de un conjunto de puntos depende de una propiedad geornétrica denominada conve- xidad. Se trata de un concepto sencillo que posiblemente ya conozca el lector: un polígono convexo posee la propiedad de que cualquier línea que una dos puntos cualesquiera del interior del polígono estará dentro del mismo. Por ejemplo, el «polígonocerrado simple» que se calculó en el capítulo anterior es, decididamente,no convexo; por su parte, todos los triángulos y rectángulosson convexos. El nombre matemáticodel límite natural de un conjunto de puntos es cerco convexo. Se define el cerco convexo de un conjunto de puntos del plano como el polígono convexo más pequeño que los contiene a todos. El cerco convexo es el camino más pequeño que envuelve los puntos. Una propiedad obvia y fácil de probar del cerco convexo es que los vértices del polígono convexo que defi- nen el cerco son puntos pertenecientes al conjunto original de puntos. Dados N puntos, algunos de ellos forman un polígono convexo, dentro del cual están contenidos todos los demás. El problema consiste en encontrar esos puntos. Se han desarrollado numerosos algoritmos para encontrar el cerco convexo: en este capítulo se examinarán algunos de los más importantes. La Figura 25.1 muestra los conjuntos de puntos de ejemplo de la Figura 24.1 391
  • 412. 392 ALGORITMOS EN C+t I a a a a I * a .= a * .. . * 0 . . a .. y sus cercos convexos. Hay 8 puntos en el cerco del conjunto pequeño y 15 en el cerco del conjunto grande. En general, el cerco convexopuede contener, como mínimo, desde tres puntos (si los tres puntos forman un triángulo que contiene a los demás), hasta, como máximo, todos los puntos (si todos ellos están situa- dos en el cerco convexo, en cuyo caso los puntos constituyen su propio cerco convexo). El número de puntos del cerco convexo de un conjunto de puntos «aleatorio» está comprendido entre esos extremos, como se verá a continua- ción. Algunos algoritmos funcionan bien cuando hay muchos puntos en el cerco convexo; otros funcionan mejor cuando sólo hay unos pocos. Una propiedad fundamental del cerco convexo es que cualquier línea exte- nor al cerco, al desplazarla hacia él en cualquier dirección, tocará al menos uno de los puntos vértice. (Ésta es una forma alternativa de definir el cerco: es el
  • 413. OBTENCIÓN DEL CERCO CONVEXO 393 subconjunto del conjunto de puntos que puede ser alcanzado por alguna línea que se mueva con algún ángulo desde el infinito.) En particular, es fácil encon- trar algunos pocos puntos con garantías de que estén en el cerco aplicando esta regla con líneas horizontales y verticales:los puntos que tengan las coordenadas x e y más pequeñas y más grandes pertenecen al cerco. Este hecho se utiliza como punto de partida de los algoritmos a estudiar. Reglas del juego La entrada de un algoritmo de búsqueda del cerco convexo es, por supuesto, un array de puntos; se puede utilizar el tipo punto definido en el capítulo anterior. La salida es un polígono, también representado como un array de puntos, que tiene la particularidad de que, al ir uniendo los puntos en el orden en que apa- recen en el array, se dibuja el polígono. Pensándolo bien, esto puede parecer que requiere una condición adicional de ordenación en el cálculo del cerco con- vexo (¿por qué no devolver los puntos del cerco en cualquier orden?),pero, ob- viamente, la salida en forma ordenada resulta más útil, y ya se ha visto que los cálculos sin orden no son más fáciles de realizar. En todos los algoritmos que se estudiarán es conveniente realizar los cálculos in situ: el array utilizado para el conjunto de puntos original también se utiliza para guardar el resultado. Los algoritmos simplemente reordenan los puntos del array original de modo que el cerco convexo aparezca, ordenado, en las M primeras posiciones. A la vista de la descripción anterior, queda claro que el cálculo del cerco convexo está íntimamente relacionado con la ordenación. De hecho, es posible utilizar un algoritmo de cerco convexo para realizar una ordenación de la si- guiente forma. Dados N números a ordenar, se convierten en puntos (en coor- denadas polares), considerando los números como ángulos (adecuadamente normalizados) con un radio fijo para todos los puntos. El cerco convexo de este conjunto de puntos es un polígono de N lados que contiene todos los puntos. Puesto que la salida debe estar ordenadasegún el orden de aparición de los pun- tos en el polígono, se puede utilizar para hallar el orden adecuado de los valores originales (recordando que los datos introducidos estaban desordenados). Esto no constituye una prueba formal de que el cálculo de un cerco convexo no es más sencillo de realizar que una ordenación, porque, por ejemplo, se debe tener en cuenta el coste que ocasiona el uso de las funciones trigonométricas necesa- rias para la conversión de los números en puntos del polígono. El comparar al- goritmos de cerco convexo (que implican la utilización de operaciones trigo- nométricas) con algoritmos de ordenación (que implican comparaciones entre claves) es casi como compararmanzanas con naranjas, pese a lo cual se ha visto que cualquier algoritmo de cerco convexo requiere unas NlogN operaciones, lo mismo que las ordenaciones (incluso siendo probable que las operaciones per- mitidas sean muy diferentes).Resulta útil considerar el cálculo de un cerco con-
  • 414. 394 ALGORITMOS EN C++ vex0 como una especie de ((ordenaciónbidimensionah, ya que en el estudio de algoritmosde cálculo de cercos convexos surgen frecuentesparalelismoscon los algoritmos de ordenación. De hecho, los algoritmos que se estudiarán muestran que haiiar un cerco convexo no es másarduo que realizar una ordenación: existen varios algoritmos que, en el peor caso, se ejecutan en un tiempo proporcional a NlogN. Muchos de los algoritmos tienden a utilizar incluso menos tiempo en conjuntos de pun- tos reales, debido a que su tiempo de ejecución depende de la distribución de tales puntos, así como del número de puntos que forman el cerco. Como con todos los algoritmos geométricos, hay que prestar alguna aten- ción a los casos degenerados que probablemente aparezcan en la entrada. Por ejemplo, jcuál es el cerco convexo de un conjunto de puntos que están alinea- dos? Dependiendode la aplicación, podrían ser todos los puntos, o sólo los dos extremos, o quizás también valdría cualquier conjunto que incluya los dos pun- tos extremos. Aunque éste puede parecer un ejemplo extremo, no sería inusual que más de dos puntos se encuentren situados en uno de los segmentos que de- finen el cerco de un conjunto de puntos. En los siguientes algoritmos no se in- sistirá en incluir los puntos que estén situados en uno de los lados del cerco, ya que, en gelled, esto supone más trabajo (si bien, cuando proceda, se indicará cómo se podría hacer). Por otro lado, tampoco se insistirá en que se deben omi- tir estos puntos, ya que, si se desea, esta condición se podría comprobar con posterioridad. Envolventes El algoritmo más natural de cerco convexo, que se asemeja al método que uti- lizaría una persona para dibujar el cerco convexo de un conjunto de puntos, es una forma sistemática de «envolven>el conjunto de puntos. Empezando por ai- gún punto que pertenezca con seguridad al cerco convexo (por ejemplo, el que tenga la coordenada y más pequeña), se traza una línea recta y se gira, haciendo un «barrido» hacia arriba, hasta que toque algún punto; este punto debe perte- necer al cerco convexo. A continuación, tomando como pivote este punto, se continúa «barriendo» hasta encontrar el siguiente punto, y así sucesivamente hasta que el conjunto quede «envuelto» por completo (se vuelva al punto ini- cial). La Figura 25.2 muestra cómo se descubre el cerco del conjunto de puntos del ejemplo siguiendo este método. E ! punto B posee la menor coordenada y y se toma como punto inicial. A continuación, M es el primer punto alcanzado por la línea de barrido, luego se alcanza L, etcétera. Por supuesto, realmente no es necesario hacer un barrido para todos los án- guios posibles; solamente se realiza un cálculo estándar para conocer el menor ángulo necesario para encontrar el punto que se alcanzará a continuación. Para cada punto que se incluye en el cerco, se necesita examinar todos los puntos que aún no pertenecen a dicho cerco. Por tanto, este método es bastante pare-
  • 415. OBTENCIÓN DEL CERCO CONVEXO 395 * H O D - M * B * B É P M B ( I I B Figura 25.2 Envolventes. cido al de la ordenación por selección -se elige el arnejom de los puntos aún no seleccionados, utilizando una búsqueda exhaustiva del mínimo-. En la Fi- gura 25.3 se muestra el movimientoreal de datos que se lleva a cabo: la línea M- ésima de la tabla muestra la situación tras incluir el punto M-ésimo en el cerco. El siguiente programa busca el cerco convexo de un array p de N puntos, representado según la descripción del principio del Capítulo 24. La base de esta implantación es la función theta, desarrollada en el capitulo anterior, que toma dos puntos p l y p2 como argumentos y que se puede considerar que devuelve el ángulo que forma el segmento que une dichos puntos con la horizontal (aun- que en realidad devuelve un número más fácil de calcular y que posee las mis- mas propiedades de ordenación). Por lo demás, la implantación sigue directa- mente el método antes explicado. Se necesita un centinela para el cálculo del
  • 416. 396 ALGORITMOS EN C++ Figura 25.3 Movimiento de datos en envolventes. ángulo mínimo: aunque normalmente se intentaría disponer las cosas de forma que se utilizase p[O], en este caso es más conveniente utilizar p [N+1] . i n t envolver(punto p [ l ] , i n t N) i n t i , min, M; f l o a t t h y v; f o r (min=O, i=l; i<N; i++) p[N]= p[min]; th= 0.0; f o r (M=O; M<N; M++) { i f ( p [ i ] .y < p[min] .y) min = i; intercambio(p, M y min); min= N; v= th; th= 360.0; f o r (i=M+l; i<=N; i++) { if (theta(p[M], p [ i ] ) > v) i f (theta(p[M], p[i]) < t h ) { min= i ; th= theta(p[M] y ~ [ m i n ] ) ; } i f (min N) r e t u r n M; 1 } En primer lugar, se busca el punto que tiene la menor coordenada y, y se copia en p[N+l] con el fin de detener el bucle, tal como se describe a continuación.
  • 417. OBTENCIÓNDEL CERCO CONVEXO 397 La variable M guarda el número de puntos que se han incluido en el cerco hasta el momento, y v es el valor actual del ángulo de «barrido» (el ángulo que for- man la horizontal con la línea que une p[M-1] y p[MI). El bucle for incluye el último punto encontrado en el cerco intercambiándolo con el punto M-ésimo,y utiliza la función t h e t a del capítulo anterior para calcular el ángulo que for- man la horizontal y la línea que une dicho punto con cada uno de los puntos que aún no pertenecen al cerco, buscando el punto cuyo ángulo sea el menor de todos los calculados y que al mismo tiempo supere el valor de v. El bucle finaliza cuando se encuentra de nuevo el primer punto (en realidad se trata de la copia del primer punto que se guarda en p[N+1] ). Este programa puede o no devolver puntos que se encuentren en un lado del cerco convexo. Esta situacibn se produce cuando más de un punto tiene el mismo valor de t h e t a con p [MI durante la ejecución del algoritmo. Esta im- plantación devuelve el primer punto que se encuentra, incluso aunque pueda haber otros puntos más próximos a p[MI.Cuando sea importante encontrar los puntos situados en los lados de los cercos convexos, se puede modificar t h e t a de modo que tenga en cuenta la distancia entre los puntos ofrecidos como ar- gumentos y que, cuando dos puntos tengan el mismo ángulo, asigne un valor menor al punto más próximo. La principal desventaja de las envolventes es que, en el peor caso, cuando todos los puntos están en el cerco convexo, el tiempo de ejecución es proporcio- nal a N2(como en la ordenación por selección). Por otra parte, este método PO: see la atractiva propiedad de que se puede generalizar a tres (o más) dimensio- nes. El cerco convexo de un conjunto de puntos de un espacio de dimensión k es el menor polígono convexo que los contiene a todos, quedando definido un polígono convexo por una propiedad, según la cual todas las líneas que unen dos puntos interiores deben estar situadas también en el interior. Por ejemplo, el cerco convexo de un conjunto de puntos en el espacio tridimensional es un objeto tridimensional convexo con caras planas. Se puede calcular «barriendo» el espacio con un plano hasta alcanzar el cerco, y después, «plegando» el plano por las aristas del cerco, tomando como pivotes los diferentes bordes del cerco, hasta que el «paquete» quede «envuelto» (como sucede con numerosos algorit- mos geométncos, jresulta bastante más sencillo explicar esta generalización que implantarla!). La exploración de Graham El siguiente método que se va a examinar, inventado por R.L. Graham en 1972, es interesante porque la mayor parte de los cálculos necesarios se realizan para ordenar: el algoritmo incluye una ordenación, seguida por cálculos relativa- mente poco costosos (aunque tampoco son fáciles). Utilizando el método del capítulo anterior, el algoritmo comienza construyendo un polígono cerrado simple con los puntos: ordena los puntos utilizando como claves los valores dr
  • 418. 398 ALGORITMOS EN C++ L Figura 25.4 Comienzo de la exploración de Graham. la función theta, correspondientes al ángulo que forman la horizontal y cada una de las líneas que unen los puntos con el pivote p [11 (el punto que tiene la menor coordenaday), de forma que al unir p [13, p[21, p[31,..., p [NI, p[11se obtiene un polígono cerrado. En el caso del conjunto de puntos del ejemplo, el resultado es el polígono cerrado simple obtenido en el capítulo anterior. Puede notarse que p[NI, p [1] y p [21 son puntos consecutivosdel cerco; al ordenar- los, esencialmente se está ejecutando la primera iteración del procedimiento de envolventes (en ambas direcciones). El cálculo del cerco convexo se completa intentando situar cada punto en el cerco, y eliminando los puntos ya situados que posiblemente no puedan estar en el cerco. En el ejemplo se considera que los puntos tienen el orden B M J L N P K F I E C O A H G D; los primeros pasos se muestran en la Figura 25.4. Al principio, gracias a la ordenación, se sabe que B y M pertenecen al cerco. Cuando se encuentra el punto J, el algoritmo lo incluye en el cerco de prueba de los tres primeros puntos. Después, cuando se encuentra el punto L, el algo- ritmo determina que J no puede estar en el cerco (ya que, por ejemplo, está dentro del triángulo BML). En general, no es dificil comprobar qué puntos se deben eliminar. Después de incluir cada punto, se supone que se han eliminado suficientes puntos, de modo que lo trazado hasta el momento podría ser parte del cerco convexo con- siderando los puntos ya vistos. Conforme se va trazando el cerco, se espera girar a la izquierda de cada vértice del cerco. Si un nuevo punto hace que se gire hacia la derecha, entonces el punto recién incluido debe ser eliminado, puesto que existe un polígono convexo que lo contiene. Específicamente, la prueba para eliminar un punto utiliza el procedimiento ccw del capítulo anterior, de la si- guiente forma. Suponiendo que se ha determinado que p [l] ,...,p[MI están en el cerco parcial calculado a partir de los puntos p[11,...,p [i-11 ,cuando se tiene que examinar un nuevo punto p [i1, se elimina p [MI del cerco si ccw(p[M] ,p[M-l] ,p[i]) no es negativo. En caso contrario, p[M] aún podría pertenecer al cerco, por lo que no se elimina. La Figura 25.5 muestra la realización de este proceso utilizando el conjunto de puntos del ejemplo. Conforme se encuentra cada nuevo punto, la situación
  • 419. OBTENCION DEL CERCO CONVEXO 399 L n i aL QL QL L I QL 0' O O L QL Figura 25.5 Conclusiónde la exploración de Graham.
  • 420. 400 ALGORITMOS EN C++ es, en resumen, la siguiente: cada nuevo punto se incluye en el cerco parcial construido hasta el momento, y se utiliza como «testigo»para la eliminación de (cero o más) puntos previamente considerados. Después de incluir L, N y P en el cerco, se elimina P al tener en cuenta el punto K (ya que NPK es un giro a la derecha); después se incluyen F e 1, llegando a la consideración del punto E. Llegados a este punto, se debe eliminar I porque FIE es un giro hacia la derecha; F y K se deben eliminar, ya que KFE y NKE son también giros hacia la dere- cha. Por tanto, se puede eliminar más de un punto en el proceso de «vuelta atrás», quizá varios. Continuando de esta forma, el algoritmo vuelve finalmente al punto B. La ordenación inicial garantiza que cada punto, llegado su turno, se consi- dera como punto del cerco, ya que todos los puntos antes considerados tienen un valor más pequeño de theta. Cada línea que sobrevive a las «eliminacio- nes» posee la propiedad de que todos los puntos considerados hasta el mo- mento están en el mismo lado, de forma que cuando se vuelve a p [NI, que tam- bién pertenece al cerco debido a la ordenación, se habrá completado el cerco convexo de todos los puntos. Como en el método de las envolventes, se pueden incluir o no los puntos situados en un lado del cerco, aunque cuando haya puntos colineales se pueden presentar dos situaciones distintas. En primer lugar, si existen dos puntos coli- neales con p [11, entonces, como antes, la ordenación que hace uso de t h e t a puede ordenarlas o no a lo largo de la línea común. En esta situación, los pun- tos desordenados serán eliminados durante la exploración. En segundo lugar, se pueden presentar puntos colineales a lo largo del cerco de prueba (que no se eliminan). Una vez entendido el método básico, su implantación es sencilla, aunque se debe prestar atención a unos cuantos detalles. En primer lugar, el punto que tenga el mayor valor de x de entre todos los puntos que tengan el mínimo valor de y se intercambia con p [11.A continuación, se utiliza ordenshell para reor- denar los puntos (también valdría cualquier rutina de ordenación basada en comparaciones), estando implantada la estructura punto como una clase que posee una operación de comparación que compara dos puntos utilizando sus valores de t h e t a con p[11.Tras la ordenación, se copia p[NI en p[O] para ser- vir como centinela en caso de que p [31 no esté en el cerco. Finalmente, se rea- liza la exploración antes descrita. El siguiente programa halla el cerco convexo delconjuntodepuntosp[l], ..., p[N]: i n t explgraham(punt0 p[], i n t N) i i n t i, min, M; for (min= 1, i= 2; i<= N; i++) f o r (i= 1; i<= N; i++ ) if (p[i].y < p[min].y) min= i; i f (p[i] .y = p[min] .y)
  • 421. OBTENCIÓN DEL CERCO CONVEXO 401 if (p[i].x > p[min].x) min= i; intercambio(p, 1, min); ordenshell (p, N) ; for (M= 3, i= 4; ic= N; i++) P[OI = P P I ;' while (ccw(p[M], p[M-11, p[i]) >= O) M--; M++; intercambio(p, i, M); { 1 return M; } El bucle mantiene un cerco parcial en p [11 ,...,p [MI, como se vio anterior- mente. Para cada nuevo valor de i considerado, se decrernenta M si es necesario, con el fin de eliminar puntos del cerco parcial, y después se intercambia p [i] con p[M+1] para incluirlo (provisionalmente) en el cerco. La Figura 25.6 mues- Figura 25.6 Movimientode datos en la exploración de Graham.
  • 422. 402 ALGORITMOS EN C+-t tra el contenido del array p cada vez que se analiza un nuevo punto de este ejemplo. El lector, si lo desea, puede comprobar por qué para calcular el valor de m in es necesario hallar el punto que tiene la menor coordenada xde entre todos los que tienen la menor coordenada y. Como ya se ha mencionado, otro aspecto sutil a considerar es el hecho de que los puntos colinealesposeen el mismo valor de theta, y que es posible que no estén ordenados en el orden en que aparecen en la línea, como se podría esperar. Una razón por la que resulta interesante estudiar este método es porque se trata de una forma sencilla del método de retroceso,una técnica de diseño de algoritmos que se puede resumir como: ((intentaalgo, y, si no funciona, intenta otra cosa», y que verá de nuevo en el Capítulo 44. Eliminación interior Casi todos los métodos de cerco convexo se pueden mejorar enormemente uti- lizando una sencilla técnica que desecha rápidamente la mayoría de los pun- tos. La idea general es simple: se cogen cuatro puntos que se sepa que perte- necen al cerco, y se eliminan todos íos puntos situados en el interior del cuadrilátero formado por esos cuatro puntos. Esto deja muchos menos puntos a tener en cuenta en, por ejemplo, la exploración de Graham o en la técnica de las envolventes. Los cuatro puntos que se sabe que pertenecen al cerco se deberían elegir te- niendo en cuenta cualquier información disponible acerca de los puntos de en- trada. En general, es mejor adaptar la elección de los puntos a la distribución de la entrada. Por ejemplo, si todos los valores de x e y, dentro de determinados límites, son igualmente probables (una distribución rectangular), al elegir cua- tro puntos de las esquinas (loscuatro puntos que tienen la mayor y menor suma y diferencia de sus coordenadas) se eliminan casi todos los puntos. La Figura 25.7 muestra que esta técnica elimina la mayoría de los puntos que no están en el cerco de los dos conjuntos de puntos del ejemplo. En una implantación del método de la eliminación interior, el «bucle inte- r i o r ~ para los conjuntos de puntos aleatorios es el que comprueba si un punto está situado dentro del cuadrilátero de prueba. Esto se puede acelerar algo uti- lizando un rectángulo cuyos lados sean paralelos a los ejes xe y. A partir de las cuatro coordenadas que definen el Cuadrilátero, es fácil calcular el rectángulo más grande que cabe en dicho cuadrilátero. Si se utiliza este rectángulo, se eli- minarán menos puntos del interior, pero la velocidad de la comprobación com- pensa con creces esta pérdida.
  • 423. OBTENCIÓN DEL CERCO CONVEXO 403 e e e e e Figura 25.7 Eliminación interior. Rendimiento Como se dijo en el capítulo anterior, los algontmos geométricos son algo más dificiles de analizar que los algoritmos de algunas otras áreas que se han estu- diado, ya que la entrada (y la salida) es más dificil de caracterizar. A menudo no tiene sentido hablar de conjuntos de puntos «aleatonos»: por ejemplo, con- forme crece N, el cerco convexo de los puntos de una distribución rectangular es muy probable que esté muy próximo al rectángulo que define dicha distri- bución. Los algoritmos que se han visto dependen de diferentespropiedades de la distribución del conjunto de puntos, y, por ello, en la práctica no son com- parables, pues para compararlos analíticamente se necesitaría entender unas in- teraccionesmuy complicadas entre las propiedades (poco conocidas)de los con- juntos de puntos. Por otra parte, se pueden decir algunas cosas sobre el rendimiento de los algoritmos que pueden ayudar a la hora de elegir uno de ellos para una aplicación concreta. Propiedad 25.1 Después de la ordenación, la exploración de Graham es un proceso lineal en el tiempo. Es necesarioreflexionar un momento para convencerse uno mismo de que esto es cierto, ya que en el programa hay un ((bucledentro de otro bucle». Sin em- bargo, es fácil ver que ningún punto se «elimina» más de una vez, por Io que dentro del doble bucle, el código itera menos de N veces. Utilizando este mé- todo, el tiempo total necesario para hallar el cerco convexo está en OfNlogN), pero el ((bucleinterion) del método es la propia ordenación, que se puede hacer más eficaz utilizando las técnicas de los Capítulos 8 a 12.i
  • 424. 404 ALGORITMOS EN C++ Propiedad 25.2 Si hay M vértices en el cerco, la técnica de las envolventes ne- cesita unos MN pasos En primer lugar, hay que calcular N-I ángulos para hallar el mínimo, después se calcula N-2 para hallar el siguiente, después N-3, etc., de modo que el nú- mero total de cdculos de ángulos es ( N - 1) + ( N - 2) + ... + ( N - M + l), que es exactamenteigual a MN - M(M - 1)/2. Para comparar analíticamente esto con la exploración de Graham, se necesitaría una fórmula de M en función de N, un problema dificil en la geometría estocástica. Para una distribución circu- lar (y algunas otras), la respuesta es que M está en 0(N1’3), y para valores de N que no son grandes, es comparable a logN (que es el valor esperado para una distribución rectangular), de forma que este método competiría muy favo- rablemente con la exploración de Graham. Por supuesto, siempre se debe tener en cuenta el peor caso N*.. Propiedad 25.3 El método de la eliminación interior es, por término medio, lineal. El análisis matemático completo de este método requeriría una geometría es- tocástica incluso más sofisticada que antes, pero el resultado general es el que indica la intuición: casi todos los puntos están dentro del cuadrilátero y se des- cartan -el número de puntos que quedan está en O(@)-. Esto es cierto in- cluso si se utiliza el rectángulo, como se mencionó anteriormente. Esto hace que el tiempo medio de ejecución de todo el algoritmo de cerco convexo sea pro- porcional a N, ya que la mayoría de los puntos sólo se examinan una vez (cuando se descartan). Por regla general, no importa mucho qué método se utiliza des- pués, ya que es probable que queden muy pocos puntos. No obstante, para de- fenderse ante el peor caso (cuando todos los puntos están en el cerco), es pru- dente utilizar la exploración de Graham. Con esto se consigue un algoritmo que es casi seguro que se ejecutará en la práctica de forma lineal en el tiempo, y que se garantiza que se ejecutará en un tiempo proporcional a NL0gN.i El resultado del caso medio de la propiedad 25.3 sólo es válido para puntos distribuidos aleatoriamente en un rectángulo, y en el peor caso, el método de eliminación intenor no elimina nada. No obstante, para otras distribuciones u otros conjuntos de puntos de propiedades desconocidas,aún se recomienda uti- lizar este método porque su coste es bajo (una exploración lineal de los puntos, con unas pocas comprobaciones), y el ahorro posible es alto (la mayoría de los puntos se pueden eliminar fácilmente). El método también se puede ampliar a dimensiones mayores. Es posible concebir una versión recursiva del método de eliminación inte- rior: se hallan los puntos extremos, y se eliminan los puntos situados en el in- terior del cuadrilátero definido, como antes, pero considerando después que los puntos restantes se dividen en subproblemas que se pueden resolver de forma independiente, utilizando el mismo método. Esta técnica recursiva es similar al
  • 425. OBTENCIÓN DEL CERCO CONVEXO 405 procedimiento de selección se1ecc de tipo Quicksort que se vio en el Capítu- lo 9. Como aquel procedimiento, es vulnerable a un tiempo de ejecución N2en el peor caso. Por ejemplo, si todos los puntos originales están en el cerco con- vexo, no se desecha ningún punto en la etapa recursiva. Como en selecc, el tiempo de ejecución, por término medio, es lineal (aunque no resulta fácil de- mostrarlo). Pero debido a que se eliminan tantos puntos en la primera etapa, no es probable que merezca la pena preocuparse por realizar una posterior des- composición recursiva en ninguna aplicación práctica. Ejercicios 1. Suponiendoque se conoce de antemano que el cerco convexo de un con- junto de puntos es un triángulo, obtener un algoritmo sencillo que encuen- tre dicho triángulo. Responder a la misma cuestión en el caso de que el cerco sea un cuadrilátero. 2. Indicar un método eficaz para determinar si un punto está situado en el in- terior de un polígono convexo. 3. Implantar un algoritmo de cerco convexo parecido a la inserción ordenada, utilizando el método del ejercicio anterior. 4. En la exploración de Graham jes estrictamente necesario empezar con un punto que con seguridad pertenece al cerco? Explicar las.razones. 5. En el método de la envolvente ¿es estrictamente necesario empezar con un punto que con seguridad pertenece al cerco? Explicar las razones. 6. Dibujar un conjunto de puntos que haga que la exploración de Graham del cerco convexo sea particularmente ineficaz. 7. ¿Es capaz la exploración de Graham de encontrar el cerco convexo de los puntos que constituyen los vértices de cualquier polígono sencillo?Explicar por qué, o buscar un contraejemplo que demuestre lo contrario. 8. ¿Qué cuatro puntos se deberían utilizar en el método de la eliminación in- terior si se supone que la entrada está distribuida aleatoriamente en el in- terior de una circunferencia (utilizando coordenadas polares aleatorias)? 9. Comparar empíricamentela exploración de Graham y el método de la en- volvente para conjuntos de puntos grandes en los que los valores de x e y son equiprobables dentro del intervalo O a 1.OOO. 10. Implantar el método de la eliminación interior y determinar empírica- mente cómo debería ser el valor de N antes de que se pueda esperar que queden 50 puntos tras utilizar el método en conjuntos de puntos en los que los valores de x e y son equiprobables dentro del intervalo O a 1.OOO.
  • 427. 26 Búsqueda por rango Dado un conjunto de puntos del plano, es natural preguntar cuáles de ellos se encuentran dentro de una zona específica. «Listar todas las ciudades que estén a menos de 50 millas de Princeton)) es una pregunta que lógicamente podría hacerse si se dispusiera del conjunto de puntos correspondientes a las ciudades de los Estados Unidos. Cuando se limitan las figuras geométricas a los rectán- gulos, el problema se extiende fácilmente a dominios no geométricos.Por ejem- plo, distar todas las personas entre 21 y 25 años con ingresos entre 60.000 y 100.000dólares» es lo mismo que preguntar qué «puntos» de un archivo de da- tos con nombres de personas, edades e ingresos, están dentro de un cierto rec- tángulo del plano edad-ingresos. La generalización a más de dos dimensiones es inmediata. Si se desea listar todas las estrellas a menos de 50 años luz del sol, se tiene un problema tridi- mensional, y si se desea conocer del conjunto de personas jóvenes y bien paga- das del párrafo anterior las que son altas y mujeres, se tiene un problema de cuatro dimensiones. De hecho, la dimensión de tales problemas puede llegar a ser muy grande. En general, se supone la existencia de un conjunto de registros con ciertos atributos que toman valores en un conjunto ordenado. (Esto a veces se deno- mina una base de datos, aunque para este término se han desarrollado defini- ciones más específicasy completas.) A la acción de encontrar todos los registros de una base de datos de los que un conjunto específico de atributos satisfacen determinadas restricciones de rango, se la denomina búsqueda por rango, y es un problema difícil e importante en ciertas aplicaciones prácticas. En este ca- pítulo se centrará la atención en el problema geométrico bidimensional en el que los registros son puntos y los atributos sus coordenadas, para posterior- mente presentar otras posibles generalizaciones. Los métodos que se estudiarán son generalizacionesdirectas de las técnicas que ya se han visto en la búsqueda sobre claves simples (en una dimensión). Se supone que gran parte de las consultas se hacen sobre el mismo conjunto de puntos, lo que permite dividir el problema en dos partes: un algoritmo de pre- 407
  • 428. 408 ALGORITMOS EN C++ procesamiento, que estructurelos puntos dadospara permitir una búsqueda por rango eficaz, y un algoritmo de búsquedapor rango que utilice dicha estructura para devolver los puntos situados dentro de cualquier rango (multidimensio- nal). Esta separación hace dificil la comparación de métodos diferentes, puesto que el coste total depende no sólo de la distribución de los puntos implicados sino también del número y la naturaleza de las peticiones. El problema de la búsqueda por rango en una dimensiónconsiste en devol- ver todos los puntos que están dentro de un intervalo específico. Esto se puede hacer ordenando los puntos en preprocesamiento y haciendo luego una bús- queda binaria sobre los puntos extremos del intervalo para devolver todos los puntos que estén entre ellos. Otra solución consiste en construir un árbol bina- no de búsqueda y después hacer un simple recorrido recursivo del mismo, de- volviendo los puntos del intervalo e ignorando las partes del árbol situadas fuera de él. El programa que se necesita es un simple recomdo recursivo del árbol (ver los Capítulos 4 y 14). Si el punto del extremoizquierdo del intervalo está a la izquierda del punto de la raíz, se busca (recursivamente) en el subárbol iz- quierdo y de forma similar para el derecho, verificando en cada nodo que se encuentre si los puntos asociados a cada uno de ellos están o no dentro del in- tervalo: int rango(int vl, int v2) { return rangol(cabeza->der, vl, v2); } int rangol(struct nodo *t, int vl, int v2) int txl, tx2, contador = O; if (t == z) return O; txl = (t->clave >= vl); tx2 = (t->clave <= v2); if (txl) contador += rangol(t->izq, vl, v2); if (txl && tx2) contador++; if (tx2) contador += rangol(t->der, vl, v2); return contador; { 1 Dependiendodel contexto, estas operaciones pudieran ser mejoras de la imple- mentación del diccionario del árbol binario de búsqueda del Capitulo 14. La Figura 26.1 muestra los puntos que se encuentran cuando este programa se eje- cuta sobre un árbol de prueba. Se observa que los puntos devueltos no necesitan estar enlazados al árbol. Propiedad 26.1 Una búsqueda por rango unidimensional se puede hacer con un número de pasos en O(MogN) para el preprocesamiento y en O(R + lo@) para la búsqueda por rango, donde R es el número de puntos que están real- mente en el intervalo.
  • 429. BÚSQUEDA POR RANGO 409 Figura 26.1 Búsqueda por rango íunidirnensional)con un árbol de búsqueda binaria. Esto se obtiene directamente de las propiedades elementales de las estructuras de búsqueda (ver Capítulos 14 y 15). Se podna utilizar, si se desea, un árbol equilibrado. El objetivo de este capítulo será alcanzar los mismos tiempos de ejecución para la búsqueda por rango multidimensional. El parámetro R puede ser bas- tante significativo: dada la facilidad para hacer consultas de rango, un usuario podría fácilmente formular peticiones que impliquen a todos o casi todos los puntos. Este tipo de petición podría ocurrir razonablemente en muchas aplica- ciones, pero no se necesitan algoritmos sofisticados si todas las peticiones son de este tipo. Los algoritmos que se van a considerar se han diseñado para ser eficaces en peticiones donde no se espera que se devuelva un gran número de puntos. Métodos elementales En dos dimensiones, el «rango» es una zona del plano. Por simplicidad, se con- siderará el problema de encontrar todos los puntos cuyas coordenadas x estén dentro de un intervalo en x dado y cuyas coordenadas y estén dentro de un in- tervalo en y esto es, se buscan todos los puntos que están dentro de un rectán- gulo dado. Así pues, se supone que existe un tipo rect que es un registro de cuatro enteros, los puntos extremos de los intervalos horizontal y vertical. La operación básica consisteen comprobar si un punto dado está dentro de un rec- tángulo dado, por lo que se supone una función dentro-rect (struct punto p , struct rect r) que compruebaesto de formadirecta,devolviendoun valor distinto de cero si p está dentro de r. El objetivo es encontrar todos los puntos situados en el interior de un rectángulo dado, utilizando la menor cantidad po- sible de llamadas a dentro-rec t. La forma más simple de resolver este problema es la búsquedasecuencial: se recorren todos los puntos, comprobando si cada uno está dentro del rango es- pecificado (llamando a dentro-rect para cada uno de ellos). Este método se
  • 430. 41O ALGORITMOS EN C++ * O * A * C * I * N I * G * J * H * D * M * B * * *. ** * . : * T I . . * . 0 . 0. * * 0 . . **; I** e : : { ** 0 . * s a f . * . e * . a* ** * * 0. * a * * @ 8 Figura 26.2 Búsquedapor rango bidimensional. utiliza de hecho en muchas aplicacionesde bases de datos porque se puede me- jorar fácilmente ((empaquetando))las peticiones, y así se comprueban muchas de ellas durante el propio recomdo a lo largo de los puntos. En una base de datos muy grande, donde éstos se encuentran en dispositivosexternos y el tiempo de lectura es el factor de coste dominante, éste puede ser un método muy ra- zonable: reagrupar tantas preguntas como sea posible tener en la memoria in- tema y buscar la respuesta a todas ellas en una sola pasada a través del gran archivo de datos externo. Sin embargo, si este tipo de empaquetado no es con- veniente o si la base de datos es, en cierto sentido, pequeña, existen métodos mucho mejores. No obstante, en este problema geométrico la búsqueda secuencia1 parece implicar demasiado trabajo, como se muestra en la Figura 26.2. El rectángulo de búsqueda posiblemente contenga sólo unos pocos puntos, de modo que jes necesario buscar a través de todos los puntos para encontrar sólo unos pocos? Una mejora simple del método de búsqueda secuencial consiste en la aplicación directa de un método unidimensional conocido a lo largo de una o más de las dimensiones de la búsqueda. Por ejemplo, se buscan los puntos cuyas coorde- nadas x estén dentro del intervalo x especificado por el rectángulo, y luego se verifican las coordenadas y de esos puntos para determinar si pueden estar (o no) en el interior del rectángulo. Así pues, los puntos que no pueden estar den- tro del rectángulo porque sus coordenadasx están fuera de rango no se exami- narán nunca. Esta técnica se denominaproyección; por supuesto, también sería posible proyectar sobre y. En el ejemplo, se comprobaríanlos puntos E C H F e I para la proyección x, y los O E F K P N y L para la proyección y. Se observa que el conjunto de puntos buscados (E y F)son precisamente aquellos que apa- recen en ambas proyecciones. Si los puntos están uniformemente distribuidos en una región rectangular,
  • 431. BÚSQUEDA POR RANGO 411 entonces es fácil calcular el número medio de puntos a verificar. La proporción de puntos que se podría esperar encontrar en un rectángulo dado es simple- mente la relación entre su área y la de toda la región; la proporción de puntos que se podría esperar verificar para una proyección x es la relación entre el an- cho del rectángulo y el de la región, y lo mismo para una proyección y. En el ejemplo, utilizando un rectángulo de 4por 6 en una región de 16 por 16 se po- dría esperar encontrar 3/32 puntos en el rectángulo, 1/4 de ellos en una proyec- ción x y 3/8 en una y. Evidentemente, en tales circunstancias, es mejor proyec- tar sobre el eje correspondiente a la más pequeña de las dos dimensiones del rectángulo. Por otro lado, es fácil construir casos donde la técnica de proyección podría fallar miserablemente: por ejemplo, si el conjunto de puntos forma una figura en forma de «L» y la búsqueda se efectúa en un rango que engloba sólo la esquina derecha de la «L», entonces la proyección sobre los ejes elimina sólo la mitad de los puntos. A primera vista, parece que la técnica de proyección podría mejorarse <un- tersecandon los puntos que están dentro del rango x y los que están dentro del y. Pero intentar hacer esto sin examinartodos los puntos del rango x o todos los del rango y, en el peor caso, es tan difícil que sirve principalmente para que se aprecien mejor los métodos más sofisticados que se van a estudiar. Método de la rejilla Una técnica simple pero eficaz para mantener relaciones de proximidad entre los puntos del plano consiste en construir una rejilla imaginaria que divida la zona de búsqueda en pequeñas celdas y en mantener listas de pequeño tamaño de los puntos que están dentro de cada celda. (Ésta es una técnica que se utiliza, por ejemplo, en arqueología.)Así, cuando se buscan los puntos incluidos en un rectángulo dado, sólo se necesita buscar en las listas que corresponden a las cel- das del rectángulo. En el ejemplo, sólo se examinan E, C, F y K, como se mues- tra en la Figura 26.3. Queda todavía por determinar el tamaño de la rejilla: si es muy grosera, cada celda contendrá demasiados puntos, y, si es muy fina, habrá muchas celdas en las que buscar (aunque la mayoría estén vacías). Una forma de alcanzar el equi- librio entre estos dos extremos es escoger el tamaño de la rejilla de modo que el número de celdas sea una fracción constante del número total de puntos, lo que da un número medio de puntos en cada celda aproximadamente igual a una pequeña constante. Para el pequeño conjunto de puntos del ejemplo, la utili- zación de una rejilla de 4por 4, con 16puntos, significaque cada celda conten- drá por término medio un punto. A continuación se presenta una implementación directa de una clase que soporta la búsqueda por rango en un espacio bidimensional utilizando el mé- todo de la rejilla. Las variables maxR y tal 1a se utilizan para controlar la reso- lución de la rejilla (el número y tamaño de las celdas). Se supone que las coor-
  • 432. 412 ALGORITMOS EN C++ a a . . * a * a * a . a a - a a a - . a a a Figura 26.3 Método de la rejilla para la búsquedapor rango. denadas son enteras, como es habitual, y tal 1a se toma como el ancho de la celda. Existen maxR por maxR celdas en la rejilla de modo que el rango de las coordenadas de los puntos varía entre O y tal 1 a*maxR. Para encontrar la celda a la que pertenece un punto dado, se dividen sus coordenadas por tal 1a: class Rango private: { struct nodo struct nodo *rejilla[maxR] [maxR]; struct nodo *z; { struct punto p; struct nodo *siguiente; }; public: Rango( ; void insertar(struct punto p); int buscar(rect rango); 1; { Rango::Rango() int i,j; z = new nodo; for (i= O; i <= maxR; i++) for (j = O; j <= maxR; j++) rejillari] [j]; 1
  • 433. BÚSQUEDA POR RANGO 413 void Rango: :insertar(struct punto p) struct nodo *t = new nodo; t->p = p; t->siguiente = rejilla [p.x/talla] [p.y/talla]; reji 1 1 a[p.x/tall a] [p.y/tall a] = t; { 1 I Este programa utiliza la representaciónestándar por listas enlazadas, con el nodo cola ficticio z y la lista de los encabezamientos en el array. Las listas están de- sordenadas, con inserción por el frente, como en el Capítulo 3. Los valores apropiados de las variables tal 1 a y maxR dependen del número de puntos, de la cantidad de memoria disponible y del rango de los valores de las coordenadas. En primera aproximación, si hay N puntos y se desea M pun- tos por celda, entonces se necesita alrededor de N M celdas, por lo que se debe escoger maxR como el entero más próximo a e N/M y tal 1 a debe ser aproxi- madamente el máximo de coordenadas de punto dividido p o r d m . Esto proporciona alrededor de N/M celdas. Estas estimaciones son falsaspara peque- ños valores de los parámetros, pero son válidas en la mayoría de los casos, pu- diéndose adaptar fácilmente para aplicaciones específicas. No es necesario un cálculopreciso. Típicamente se podría utilizar una potencia de dos para el valor de tal 1 a para hacer la multiplicación y la división por tal 1 a mucho más efi- caz, doblando solamente el número de puntos por celda (de 1 a 2 en el ejemplo anterior). La implementación precedente utiliza M = 1, una opción que se elige con mucha frecuencia. Si no hay problemas de espacio en la memoria, pueden ser apropiados valores más grandes, pero los más pequeños probablemente no se- rán útiles salvo en situaciones muy específicas. Ahora, la mayor parte del lrabajo de la búsqueda por rango se efectúa sim- plemente indexando dentro del array rej i1 1 a, como sigue: int Rango: :buscar(struct rect rango) //Método de rejilla struct nodo *t; int i,j , contador = O; for (i= rango.xl/talla; i <= rango.x2/talla; i++) { for (j = rango.yl/talla; j <= rango.y2/talla; j++) if (dentro-rect (t->p, rango)) contador++; for (t = rejilla[i][j]; t != z; t = t->siguiente) return contador; Este programa cuenta simplemente el número de puntos del rango, utilizando una variable global contador. Modificarlo para que imprima o devuelva todos los puntos del rango es un proceso directo.
  • 434. 414 ALGORITMOS EN C++ Propiedad 26.2 El método de la rejilla para la búsqueda por rango es lineal en el número de puntos del rango, por término medio, y lineal en el número total de puntos en el peor caso. Esto depende de la elección de los parámetros de modo que el número esperado de puntos en cada celda sea constante, como se describió con anterioridad. Si el número de puntos del rectángulo a buscar es R,entonces el número de celdas de la rejilla a examinar es proporcional a R.El número de celdas examinadas que no están completamente dentro del rectángulo es ciertamente menor que una pequeña constante multiplicada por R,por lo que el tiempo total de eje- cución (por término medio) es lineal en R.Para un R grande, el número de puntos examinados no incluidos en el rectángulo buscado es bastante pequeño: todos los puntos de este tipo están en las celdas que intersecan al lado del rec- tán u10 de búsqueda, y el número de celdas con esta propiedad es proporcional a &,para un R grande. Esta observaciónes falsa si las celdas son muy peque- ñas (muchos celdas vacías dentro del rectángulo) o demasiado grandes (muchos puntos en las celdas del perímetro del rectángulo)o si el rectángulo de búsqueda es más estrecho que una celda (podría intersecar muchas celdas pero tener po- cos puntos dentro).i Ei método de la rejilla es eficaz si los puntos están bien distribuidos sobre el rango, pero no lo es si están agrupados. (Por ejemplo, todos los puntos podrían estar en una celda, lo que significaría que la estrategiade la rejilla no habría ser- vido para nada.) El método que se examinará a continuación hace que sea muy poco probable este peor caso subdividiendo el espacio de manera no uniforme, adaptándolo a cada conjunto de puntos. Arboles bidimensionales Los árboles bidimensionales (20)son estructuras dinámicas y adaptables muy similares a los árboles binarios, pero que dividen el espacio geométrico de una manera apropiada para su utilización en la búsqueda por rango y en otros pro- blemas. La idea es construir árboles binarios de búsqueda con puntos en los no- dos, utilizando, en una secuencia estrictamente alternada, las coordenadas x y y de esos puntos como claves. El mismo algoritmo de inserción en árboles binarios de búsqueda normales se utiliza para insertar puntos en árboles 2D, pero situando en la raíz la coor- denada y (si el punto a insertar tiene una y menor que la de la raíz, se va hacia la izquierda; si no a la derecha), despuésla x en el siguientenivel, en el siguiente la y, y así sucesivamente, alternando hasta que se encuentre un nodo externo. La Figura 26.4 muestra el árbol 2D correspondiente al pequeño ejemplo del conjunto de puntos. La importancia de esta técnica es que corresponde a dividir el plano de una
  • 435. BÚSQUEDA POR RANGO 415 Figura 26.4 Un árbol bidimensional(2D). forma simple: todos los puntos por debajo del de la raíz van al subárbol iz- quierdo y todos los que están por encima van al subárbol derecho; después to- dos los puntos por encima del de la raíz y a la izquierda del punto del subárbol derecho van al subárbol izquierdo del subárbol derecho de la raíz, etcétera. Las Figuras 26.5 y 26.6 muestran cómo se subdivide el plano en correspon- dencia con la construcción del árbol de la Figura 26.4. Primero se dibuja una línea horizontal que pase por A, el primer nodo insertado. Después, como B está por debajo de A, va a la izquierda de A en el árbol y se divide el semiplano que queda por debajo de A por medio de una línea vertical que pasa por la coordenadax de B (segundodiagrama de la Figura 26.5). A continuación, puesto que C está por debajo de A, se va a la izquierda de la raíz, y como está a la izquierda de B se va a la izquierda de B, dividiendo la porción del plano por debajo de A y a la izquierda de B por medio de una línea horizontal que pase por la coordenada y de C (tercer diagrama de la Figura 26.5). La inserción de D es similar, después E va a la derecha de A, puesto que está por encima (primer diagrama de la Figura 26.6), etcétera. Cada nodo externo del árbol corresponde a un rectángulo del plano. Cada región corresponde a un nodo externo del árbol; cada punto pertenece a un seg- Figura 26.5 Subdivisióndel plano con un árbol 2D: etapas iniciales.
  • 436. 416 ALGORITMOS EN C++ OE * / t * I - * 1 . 7 t * t I AM Figura 26.6 Subdivisióndel plano con un árbol 2D: continuación.
  • 437. BÚSQUEDA POR RANGO 417 mento horizontal o vertical que define la división efectuada en el árbol en ese punto. El código para la construcción de árboles 2D es una modificación directa de la búsqueda-inserción estándar en un árbol binario que permita esta- cam- biando entre las coordenadas x e y en cada nivel: class Rango private: i struct nodo struct nodo *z, *cabeza; struct punto ficticio; int buscar-rect(struct nodo *t, { struct punto p; struct nodo *izq, *der; }; struct rect rango, int d); public: Rango( ; void insertar(struct punto p); int buscar( rect rango); 1; { void Rango::insertar(struct punto p) //Arb01 2D struct nodo *f, *t; int d, td; for (d = O, t = cabeza; t != z; d =! d)+ c 1 td = d ? (P.X < t->p.x) : (p.y < t->p.y); f = t; t = td ? t->izq : t->der; t = new nodo; t->p = p; t->izq = z; t->der = z; if (td) f->izq = t; else f->der = t; 1 La interfaz pública para esta clase es la inisma que para el método de la rejilla anterior, pero la implementación utiliza árboles binarios de búsqueda. Propiedad 26.3 La construcción de un árbol 2 0 para N puntos aleatorios ne- cesita por término medio 2NlogN comparaciones. En efecto, para puntos aleatoriamente distribuidos, los árboles 2D tienen las En el original !=. Pensamos que d debe ser una variable lógica o booleana,ya que, en función de SU valor (verdaderoo falso),se irá alternativamente al subárbol izquierdo o al subárbol derecho. En otros libros del autor, Algorifhrnsy Algorithms in C, figura expresamente d como variable lógica. (N.de los T.)
  • 438. 418 ALGORITMOS EN C++ Figura 26.7 Búsqueda por rango con un árbol 2D. mismas característicasde rendimiento que los árboles binarios de búsqueda. Las dos coordenadas actúan como «claves»a1eatorias.i Para efectuar una búsqueda por rango utilizando árboles 2D, primero se construye el árbol 2D insertando N puntos en un árbol inicialmente vacío. El código de inicialización debe estar cuidadosamente coordinado con las condi- ciones iniciales del procedimiento de descenso por el árbol, pues si no podría introducirse un error inoportuno, y el algoritmo buscaría coordenadas x donde el árbol tiene las y, y viceversa. A continuación, para efectuar la búsqueda por rango, se compara el punto de cada nodo con el rango de la dimensión utilizada para dividir el plano en ese nodo. Para el ejemplo, se comienza por ir a la derecha de la raíz y a la derecha del nodo E, puesto que el rectángulo está enteramente por encima de A y a la derecha de E. Después, en el nodo F, se debe descender por ambos subárboles, puesto que F está dentro del rango x definido por el rectángulo (lo que no equi- vale a decir que F está dentro del rectángulo). Luego se comprueban los subár- boles izquierdos de P y K, lo que corresponde a verificar que regiones del plano se solapan con el rectángulo de búsqueda. (Ver las Figuras 26.7 y 26.8.) Este proceso se implementa fácilmente con una generalización directa del procedimiento rango de una dimensión(1D) que se estudió al principio de este capítulo: int Rango: :buscar(struct rect rango) / / árbol 2D { return buscar-rect (cabeza->der, rango, 1) ; } int Rango: :buscar-rect(struct nodo *t, struct rect rango, int d) int tl, t2, txl, tx2, tyl, ty2, contador = O; if (t = z) return O; txl = rango.xl < t->p.x; tx2 = t->p.x <= rango.x2; tyl = rango.yl < t->p.y; ty2 = t->p.y <= rango.y2; { tl = d ? txl : tyl; t2 = d ? tx2 : ty2;
  • 439. B~SQUEDA POR RANGO 419 if (ti) contador += buscar-rect(t->izq, rango, !d); if (dentro-rect (t->p, rango)) contador++; if (t2) contador += buscar-rect (t->der, rango, !d) ; return contador; 1 Este procedimiento desciende por los dos subárboles sólo cuando la línea de di- visión corta al rectángulo, lo que no debería pasar frecuentemente para rectán- gulos relativamente pequeños. La Figura 26.8 muestra la subdivisión del plano y los puntos examinadosen los dos ejemplos. I I Figura 26.8 Búsqueda por rango con un árbol 2D (subdivisióndel plano). Propiedad 26.4 La búsqueda por rango con un árbol 2 0 parece utilizar alre- dedor de R+logNpasos para encontrarR puntos que están en rangos de tamaño razonable en una región que contiene N puntos. El análisis de este método está todavía por hacer y la propiedad planteada es una conjetura basada en la evidencia empírica. Por supuesto, el rendimiento (y el análisis)depende mucho del tipo de rango utilizado. Pero el método es com- parable en su rendimiento con el de la rejilla y, en cierto modo, depende menos de la «aleatonedad» del conjunto de puntos. La Figura 26.9 muestra el árbol 2D del ejemplo mayor.. Búsqueda por rango multidimensional El método de la rejilia y el de los árboles 2D se generalizan de forma directa para más de dos dimensiones: las extensiones simples y directas de los algorit-
  • 440. 420 ALGORITMOS EN C++ Figura 26.9 Búsqueda por rango con un árbol 2D grande. mos anteriores proporcionan métodos de búsqueda por rango para más de dos dimensiones. Sin embargo, la naturaleza del espacio multidimensional impone cierta precaución y sugiere que las característicasde rendimiento de los algorit- mos pueden ser dificiles de predecir para una aplicación particular. Para implementar el método de la rejilla para búsquedas k-dimensionales, se toma simplemente un array reji11a k-dimensional con un índice para cada dimensión. El problema principal consisteen elegir un valor razonable para ta- 11a. Este problema resulta bastante obvio cuando se consideraun k grande: ¿qué tipo de rejilla se debe utilizar para una búsqueda 10-dimensional?Incluso si se utilizan sólo tres divisiones por dimensión, se necesitan 3" celdas en la rejilla, de las que la mayor parte estarían vacías, para valores razonables de N. La generalización de los árboles 2D a los árboles kD es también directa: simplemente se pasa en «ciclos»a través de las dimensiones (como se hizo para dos dimensiones alternando entre x e y) mientras se desciende por el árbol. AI igual que antes, en una situación aleatoria, los árboles resultantes tienen las mis- mas caractensticas que los árboles binarios de búsqueda. También como antes, hay una correspondencia natural entre estos árboles y el simple proceso geo- métrico. En tres dimensiones, la ramificación en cada nodo corresponde a cor- tar con un plano la región tridimensional de interés; en el caso general, se corta la región k-dimensional de interés con un hiperplano (k - 1)-dimensional. Si k es muy grande, probablemente haya fuertes desequilibriosen los árboles kD, una vez más porque los conjuntos naturales de puntos no pueden ser lo suficientemente densos como para mostrar aleatoriedad sobre un gran número de dimensiones. Por lo regular, todos los puntos de un subárbol tendrán los mismos valores para varias dimensiones, lo que conduce a muchas ramas de una sola vía. Una forma de limitar este problema es, en lugar de hacer ciclos sistemáticamente a través de las dimensiones, utilizar siempre la dimensión que mejor divida al conjunto de puntos. Esta técnica se puede aplicar también a los árboles 2D. Esto necesita que se almacene información extra (relativa a la di- mensión de discriminación) en cada nodo, pero remedia el desequilibrio, en es- pecial en árboles de dimensiones muy grandes. En resumen, aunque es fácil ver la forma de generalizar los programas para
  • 441. BÚSQUEDA POR RANGO 421 la búsqueda por rango de forma que puedan tratar problemas multidimensio- nales, no debe darse este paso a la ligera en aplicacionesmuy grandes. Las gran- des bases de datos con muchos atributos por registro pueden ser objetos verda- deramente complejos. A menudo es necesario tener una buena comprensión de las características de una determinada base de datos para poder desarrollar un método de búsqueda por rango que sea eficaz en una aplicación en particular. Éste es un problema de bastante importancia que todavía se está estudiando activamente. Ejercicios 1. Escribir una versión no recursiva del programa rango de 1Ddado en el texto. 2. Escribir un programa para listar todos 10s puntos de un árbol binario que no están en un intervalo dado. 3. Expresar el máximo y el mínimo número de celdas en las que podría bus- carse en el método de la rejilla en función de las dimensiones de las celdas y del rectángulo de búsqueda. 4. Analizar la idea de evitar la búsqueda en celdas vacías utilizando listas en- lazadas: cada celda podría estar enlazada con la siguientecelda no vacía de la misma fila y con la próxima celda no vacía de la misma columna. ¿De qué forma afectaría esta técnica al tamaño de la celda a utilizar? 5. Dibujar el árbol y la subdivisión del plano resultante de construir un árbol 2D para el ejemplo de puntos comenzando por una línea de división ver- tical. 4. Obtener un conjunto de puntos que conduzca al peor caso del árbol 2D que no tiene nodos con dos hijos; obtener la correspondiente subdivisión del plano. 7. Describir la forma de modificar cada uno de los métodos para que devuelva todos los puntos del interior de un círculo dado. 8. De todos los rectángulos de búsqueda de igual área, ¿qué figura es la que posiblemente haga que cada uno de los métodos se comporte peor? 9. ¿Qué método seríapreferiblepara la búsqueda por rango cuando los puntos están agrupados en grandes conjuntos alejados entre sí? 10. Dibujar el árbol 3D que se obtiene al insertar los puntos (3,1,5), (4,8,3), (8,3,9), (6,2,7), (1,6,3), (1,3,5), (6,4,2) en un árbol inicialmente vacío.
  • 443. 27 Intersección geométrica Un problema natural que aparece con frecuencia en aplicaciones que implican datos geométricoses el siguiente: «Dado un conjunto de N objetos,json disjun- tos dos a dos?» Los «objetos» en cuestión pueden ser segmentos, rectángulos, círculos,poiígonos, o cualquier otro tipo de objetos geométricos.Cuando se trata de objetos físicos, se sabe que dos de ellos no pueden ocupar el mismo lugar al mismo tiempo, pero es bastante complejo escribir un programa de computa- dora que contemple este hecho. Por ejemplo, en un sistema para el diseño y rea- lización de circuitos integrados o de tarjetas de circuitos impresos, es impor- tante saber que dos cables no se cruzan para evitar un cortocircuito. En un sistema industrial para el diseño de plantillas por una máquina de corte por control numérico, es importante saber que no hay dos partes de la plantilla que se solapen. En el diseño gráfico por computadora, el problema de determinar qué partes de un conjunto de objetos están ocultas para un punto de vista par- ticular, se puede formular como un problema de intersección geométrica de las proyecciones de los objetos sobre el plano de visión. Incluso en ausencia de ob- jetos físicos, existen muchos ejemplos en los que la formulación matemáticadel problema conducea un problema de intersección geométrica. En el Capítulo 43 se encontrará un ejemplo particularmente importante de esto. La solución evidente al problema de la intersección consiste en comprobar si cada par de objetos se intersecan entre sí. Puesto que hay alrededor de N2/2 pares de objetos, el tiempo de ejecución de este algoritmo es proporcional a N2. En algunas aplicaciones esto puede no ser un problema porque otros factores limitan el número de objetos a procesar. Sin embargo, en la mayor parte de los casos, es frecuente tener que considerar cientos de miles e incluso millones de objetos y el algoritmo de fuerza bruta N2es evidentemente inadecuado. En esta sección, se estudiará un método para determinar en un tiempo proporcional a MogN si dos objetos de un conjunto de N de ellos intersecan; este método se basa en los algoritmos presentados por M. Shamos y D. Hoey en un artículo fundamentalde 1976. En pnmer lugar se considerará un algoritmo para contar el número de in- 423
  • 444. 424 ALGORITMOS EN C++ terseccisnes de un conjunto de segmentos horizontales o verticales. Esto sim- plifica el problema en un sentido (los segmentos horizontales y verticales son objetos geométricos relativamente sencillos), pero lo complica en otro (contar todos los pares que se intersecan es más difícil que determinar la existencia de uno de ellos). Al igual que el capítulo anterior, se considerará el problema de contar (en lugar de estar obteniendo todas las respuestas) sólo para que el có- digo sea menos engorroso -la generalización del método para obtener todos los pares que se intersecan es directa-. La implementación que se va a desarro- llar combina los árboles de binarios de búsqueda y el programa de búsqueda por rango del capítulo anterior, en un programa doblemente recursivo. Posteriormente se examinará el problema de determinar si dos segmentosde un conjunto de N de ellos se intersecan,sin restriccionesen los mismos. Se puede aplicar la misma estrategiageneral que la utilizada para el caso horizontal-ver- tical. De hecho, la misma idea básica es válida para la detección de interseccio- nes entre muchos otros tipos de objetos geométricos. Sin embargo, para seg- mentos y otros objetos, la generalizaciónpara determinar todos los pares que se intersecan es algo más complicada que para el caso horizontal-vertical. Segmentos horizontales y verticales Para comenzar, se supondrá que todos los segmentos son horizontales o verti- cales: los dos puntos que definen cada segmento tienen igual coordenada x o igual coordenada y, como en los ejemplos de segmentos que se muestran en la Figura 27.1. (A esta restricción se la denomina a veces geometría Manhattan porque, al contrario de Broadway, el plano de las calles de Manhattan está for- mado casi exclusivamentepor horizontales y verticales.)La restricción a los seg- mentos horizontales y verticales es ciertamente estricta, pero no por ello el pro- blema se convierte en un amodelo reducido». Al contrario, esta restracción se impone a menudo en las aplicacionesparticulares: por ejemplo, los circuitos in- tegrados a gran escala se diseñan normalmente con esta restricción. En la figura de la derecha, los segmentos son relativamente cortos, como es normal en mu- chas aplicaciones, aunque por lo regular pueden encontrarse unos pocos seg- mentos muy largos. El plan general del algoritmo para encontrar una intersección en tales con- juntos de segmentos consiste en imaginar el recorrido de una línea horizontal barriendo desde abajo hacia arriba. Las proyeccionessobre esta línea de bamdo de los segmentos verticales son puntos y las de los segmentos horizontales son intervalos: a medida que la línea de barrido progresa hacia amba, los puntos (que representan a los segmentos verticales) aparecen y desaparecen, y los seg- mentos horizontales aparecen periódicamente. Se encuentra una intersección cuando aparece un segmento horizontal, representado por un intervalo de la lí- nea de bamdo, que contiene a un punto que representa a un segmento vertical.
  • 445. INTERCECCI~N GEOMÉTRICA 425 t -1,_ t, t Figura 27.1 Problemasde intersección de dos segmentos(Manhattan). Esta aparición significa que en este punto el segmento vertical interseca la línea de barrido, y que el segmento horizontal pertenece a esta línea, por lo que los dos segmentos,horizontal y vertical, deben cortarse. De esta forma, el problema bidimensional de encontrar un par de segmentosque se corten se reduce al uni- dimensional de búsqueda por rango del capítulo anterior. Por supuesto, no es realmente necesario «barren>todo el camino a lo largo del conjunto de segmentos; puesto que se necesita actuar sólo cuando se en- cuentren los puntos extremos de los segmentos, se puede comenzar ordenando los Segmentosde acuerdo con su coordenada y, y procesar los segmentosen ese orden. Si se encuentra el punto del extremo inferior de un segmento vertical, se añade la coordenada x de ese segmento al árbol binano de búsqueda (denomi- nado aquí el árbol x); si se encuentra el extremo superior de un segmento ver- tical, se suprime ese segmento (el x) del árbol; y si se encuentra un segmento horizontal, se hace una búsqueda de rango en el intervalo definido por sus dos coordenadas x. Como se verá, es preciso tener cierto cuidado al manejar coor- denadas iguales en los puntos extremos de los segmentos (aunque el lector de- bena estar acostumbrado a encontrar dificultades como éstas en los algoritmos geométricos). La Figura 27.2 muestra los primeros pasos del recorrido para encontrar las intersecciones del ejemplo de la izquierda de la Figura 27. I. El recorrido co- mienza en el punto con menor coordenada y, el extremo inferior de C. A con- tinuación se encuentra E y luego D. El resto del proceso se muestra en la Figura 27.3; el próximo segmento que se encuentra es G, comprobándose si se inter- seca con C, D y E (los segmentos verticales que se intersecaron con la línea de barrido). Para implementar el barrido, se necesita solamente ordenar los puntos ex-
  • 446. 426 ALGORITMOS EN C++ I . a Figura 27.2 Búsquedade interseccionespor barrido: pasos iniciales. tremos de los segmentos por sus coordenadas y. Para el ejemplo, se obtiene la lista C E D G I B F C H B A I E D H F Cada segmento vertical aparece dos veces y cada segmento horizontal aparece una sola. Por las necesidades del algoritmo de intersección, esta lista ordenada se puede considerar como una serie de instrucciones de insertar(segmentosver- ticales cuando se encuentre el extremo inferior), suprimir (cuando se encuentre el extremo superior)y órdenes de rango (para los extremos de los segmentosho- rizontales). Todas estas «órdenes» son simplemente llamadas a las rutinas es- tándar de los árboles binarios de los Capítulos 14 y 26, utilizando las coorde- nadas x como claves. La Figura 27.4 muestra el proceso de construcción del árbol x durante el ba- mdo. Cada nodo del árbol corresponde a un segmento vertical -la clave utili- zada para el árbol es la coordenada x - . Puesto que E está a la derecha, se en- cuentra en el subárbol derecho de C, etc. La primera línea de la Figura 27.4 corresponde a la Figura 27.2; el resto a la Figura 27.3. AI encontrar un segmento horizontal, se utiliza para hacer una búsqueda por rango en el árbol: todos los segmentosverticales en el rango asociado a este seg- mento horizontal corresponden a intersecciones. En el ejemplo, se descubre la intersección entre E y G; después se insertan I, B y F. Luego se suprime C, se inserta H y se suprime B. Posteriormente se encuentra A, y se lleva a cabo la búsqueda por rango del intervalo definido por A, lo que descubre las intersec- ciones de A con D, E y H. A continuación se suprimen los extremos supenores de I, E, D, H y F, quedando el árbol vacío. Implementación El primer paso en la implementación es ordenar los puntos extremos de los seg- mentos por sus coordenadas y. Pero como se utilizan árboles binarios para
  • 447. INTERSECCI~N GEOMÉTRICA 427 I I s . e e Figura 27.3 Búsquedade interseccionespor barrido: final del proceso.
  • 448. 428 ALGORITMOS EN C++ Figura 27.4 Estructura de datos durante el barrido: construcción del arbol x. mantener la situación de los segmentos verticales con respecto a la línea hori- zontal de bamdo, jse pueden utilizar también para la ordenación inicial de los y! Más concretamente, se utilizarán dos árboles binarios Xarbol e Yarbol de la clase diccionario de árbol binario del Capítulo 14. El árbol y contendrá los ex- tremos de los segmentos, que se procesarán uno a uno, en orden; el árbol x con- tendrá los segmentos que intersecan la línea horizontal de bamdo. El programa siguientelee primero grupos de cuatro números que definen los segmentos a partir de la entrada estándar y construye después el y árbol inser- tando las coordenadas y de los segmentos verticales y de los horizontales. Ahora el barrido en sí es efectivamente un recorrido en orden del Yarbol: D icc Xarbol (Nmax) , Y arbol (Nmax); struct segmento segmentos[Nmax]; int cuenta = O;
  • 449. INTERSECCI~N GEOMÉTRICA 429 int intersecciones() int xl, yl, x2, y2, N; for (N = 1; cin >> xl >> y1 >>x2 >>y2; N++) segmentos[N].pl.x = xl; segmentos[N].pl.y = yl; segmentos[N].p2.x = x2; segmentos[N].p2.y = y2; Yarbol .insertar(y1 , N) ; if (y2 != yl) Yarbol .insertar(y2, N); { 1 Yarbol .recorrer( ) ; return cuenta; Para el conjunto de segmentos del ejemplo, se construye el árbol que se muestra en la Figura 27.5. El «ordenar según y» que necesita el algoritmo se efectúa con recorrer,que (Capítulo 14) llama al procedimiento vi sitar para cada uno de los nodos, en orden creciente de y. Todo el trabajo de encontrar las intersecciones (utilizando un árbol binario diferente sobre las coordenadas x) se realiza en el procedimiento visitar,que se especifica después. . d b Figura 27.5' Ordenación para el barrido utilizando el árbol y. A partir de la descripción del algoritmo, es fácil poner el código en el punto donde se «visita»cada nodo: void Dicc::visitar(tipoElemento v, tipoInfo info) int t, xl, x2, yl, y2; xl = segrnentos[info].pl.x; y1 = segmentos[info].pl.y; x2 = segmentos[info] .p2.x; y2 = segmentos[info] .p2.y; if (x2 < xi) { t = x2; x2 = xi; xi = t; } if (y2 < yi) { t = y2; y2 = yl; y1 = t; } {
  • 450. 430 ALGORITMOS EN C++ i f (v == y l ) i f (v == y2) Xarbol .i n s e r t a r (xl , info) ; Xarbol .suprimir(xl, i n f o ) ; cuenta += Xarbol .rango(xl, x2) ; { 1 1 En primer lugar, se extraen las coordenadas de los extremos del segmento co- rrespondiente del array segmentos, indexado por el campo i nfo del nodo. Luego se compara el campo cl ave del nodo con estas coordenadas para deter- minar cuándo este nodo corresponde a una extremidad superior o inferior del segmento: si es el extremo inferior, se inserta en el árbol x,y, si es el superior, se suprime del árbol xy se lleva a cabo una búsqueda por rango. La implemen- tación anterior difiere ligeramente de esta descripción en que los segmentosho- rizontales se insertan realmente en el árbol x,y se suprimen luego inmediata- mente, y se efectúa, para los segmentos verticales, una búsqueda por rango en un intervalo reducido a un punto. Esto hace que el código trate adecuadamente el caso de segmentos verticales que se solapan, que se consideran que se van a ((intersecam. Esta aproximación de la aplicación combinada de procedimientos recursi- vos que opera sobre las coordenadas x e y es muy importante en los algoritmos geométncos. Otro ejemplo de ella es el algoritmo de árbol 2D del capítulo an- terior, y además se verá otro en el próximo capítulo. Propiedad 27.1 Todas las intersecciones entre N segmentos horizontales y ver- ticales se pueden encontrar en un tiempo proporcional a MogN+I, siendo I el número de intersecciones. Las operaciones de manipulación de árbol tardan un tiempo proporcional a 1 0 0 , por término medio (si se utilizan árbolesequilibrados, podría garantizarse un peor caso en logN), pero el tiempo que se emplea en la búsqueda por rango también depende del número total de intersecciones. En general, este número puede ser muy grande. Por ejemplo, si se tienen N/2 segmentos horizontales y N/2 segmentos verticales distribuidos en un modelo entrecruzado, entonces el número de interseccioneses proporcional a N 2 . ~ Como en la búsqueda por rango, si se conoce por adelantado que el número de intersecciones es muy grande, debería utilizarse alguna variante de fuerza bruta. Por lo regular, las aplicacionespresentan situacionesdel tipo «buscar una aguja en un pajam, donde se dete examinar un gran conjunto de segmentospara no encontrar más que unas pocas intersecciones.
  • 451. INTERSECCI~N GEOMÉTRICA 431 Figura 27.6 Problemasde intersección de dos segmentoscualesquiera. Intersección de segmentos en general Cuando se permiten segmentos de inclinación arbitraria, la situación se vuelve más complicada, como se ilustra en la Figura 27.6. Primero, las distintas orien- taciones de los segmentos posibles hacen necesario preguntar explícitamente cuándo se intersecan ciertos pares de segmentos, lo que no se puede resolver con una simple verificación de búsqueda por rango. Segundo,la relación de or- den entre segmentospara el árbol binario es más complicada que antes, puesto que depende del intervalo actual de y. Tercero, cualquier intersección que se produzca añadirá nuevos valores «interesantes» de y, que posiblemente serán diferentes del conjunto de valores de y correspondientes a los extremos de los segmentos. Resulta que estos problemas se pueden manejar en un algoritmo con la misma estructura básica que la dada anteriormente. Para simplificarla presen- tación, se considerará un algoritmo para detectar cuándo existe o no un par de segmentos que se intersecan en un conjunto de N segmentos,y posteriormente se presentará cómo generalizarlopara detectar todas las intersecciones. Como antes, primero se ordena sobre y para dividir el espacio en franjas dentro de las que no aparece ningún punto extremo de segmento.Exactamente como antes, se procede a lo largo de la lista ordenada de puntos, añadiendo cada segmento a un árbol binorio de búsqueda cuando se encuentre su extremo in- ferior y suprimiéndolo cuando se encuentre su extremo superior.También como antes, el árbol binario da el orden en el que aparecen los segmentosen la «franja» horizontal entre dos valores y consecutivos. Por ejemplo, en la franja entre el extremo inferior de D y el superior de B de la Figura 27.6, los segmentosdeben aparecer en el orden F B D H G. Se supone que no hay interseccionesdentro
  • 452. 432 ALGORITMOS EN C++ Figura 27.7 Estructura de datos (árbol x) para el problemageneral. de la franja horizontal actual: el objetivo es mantener esta estructura de árbol y utilizarla para encontrar la primera intersección. Para construir el árbol, no se puede utilizar solamente como claves a las coordenadas x de los extremos de los segmentos (por ejemplo, si se hace esto en el ejemplo anterior se invertiría el orden real de B y D). En su lugar se utilizará una relación de orden más general: se dice que un segmento xestá a la derecha de un segmento y si los dos extremos de x están del mismo lado de y que un punto del infinito situado a la derecha, o bien si y está a la izquierda de x,de- finiendo «izquierda» de forma similar. Así, en el diagrama anterior, B está a la derecha de A y B está a la derecha de C (puesto que C está a la izquierda de B). Si x no está ni a la derecha ni a la izquierda de y, entonces los dos segmentos deben intersectarse. Esta operación generalizada de (comparación de segmen- tos» se puede implementar utilizando el procedimiento ccw del Capítulo 24. Excepto al utilizar esta función siempre que se necesite una comparacióii, se pueden utilizar sin modificación los procedimientos estándar de árbol binario de búsqueda (incluso si se desea árboles equilibrados). La Figura 27.7 muestra la evolución del árbol del ejemplo entre el momento en el que se encontró el segmento C y en el que se encontró el D. Cada «comparación» que se lleva a cabo durante los procedimientos de manipulación de árboles es realmente una comprobación de intersección de segmentos: si el procedimiento de búsqueda en árbol binario no puede decidir si hay que ir a la derecha o a la izquierda, los dos segmentos en curso se deben intersecar, y ya está todo hecho. Pero ésta no es la historia completa, porque esta operación de comparación generalizada no es transitiva. En el ejemplo anterior, F está a la izquierda de B (porque B está a la derecha de F) y B está a la izquierda de D, pero F no está a la izquierda de D. Es esencial destacar este problema, porque el procedimiento de suprimir en el árbol binario supone que la operación de comparación es transitiva: cuando se suprime B del último árbol de la serie anterior, se obtiene el árbol de la Figura 27.7 sin ninguna comparación explícita de F y D. Para que el algoritmo de comprobación de intersección sea correcto, se debe verificar ex-
  • 453. INTERSECCI~N GEOMETRICA 433 plícitamente que las comgaraciones son válidas cada vez que se cambia la es- tructura del árbol. En concreto, cada vez que se hace que el enlace izquierdo del nodo x apunte al nodo y, se comprueba explícitamente si el segmento corres- pondiente a x está a la izquierda del segmento correspondiente a y, de acuerdo con la definición anterior, y lo mismo para la derecha. Por supuesto, esta com- paración podría entrañar la detección de una intersección, como es el caso del ejemplo. En resumen, para detectar una intersección en un conjunto de N segmentos, se utiliza el programa anterior, pero se suprime la llamada a rango y se gene- ralizan las rutinas de árbol binario para que permitan comparaciones generali- zadas como las descritasanteriormente. Si no hay intersección, se comienza con un árbol vacío y se finaliza con el mismo árbol vacío sin encontrar segmentos no comparables. Si hay una intersección, entonces se deben comparar entre sí los dos segmentos que se intersecan en algún momento del proceso de barrido y se descubrirá la intersección. Sin embargo, una vez que se ha encontrado una intersección no se debe sim- plemente continuar y esperara encontrar al resto, porque los dos segmentos que se intersecan deben intercambiar su lugar en el orden, inmediatamente después del punto de intersección. Una forma de realizar esta operación sería utilizar una cola de prioridad en lugar de un árbol binario para la ordenación de y: ini- cialmente se ponen los segmentos en la cola de prioridad de acuerdo con las coordenadas y de sus extremos, y luego se efectúa un barrido ascendente to- mando sucesivamente la coordenada y más pequeña de la cola de prioridad y haciendo una inserción o supresión en el árbol binario, como se hizo antes. Cuando se encuentra una intersección, se añaden ncevas entradas en la cola de prioridad, una por cada segmento, utilizando para cada una el punto de inter- sección como extremo inferior. Otra forma de encontrar todas las intersecciones,que es apropiada si no se espera que haya muchas: es simplementeeliminar uno de los segmentos cuando se detecta una intersección. Una vez efectuado el barrido, se sabe que todos los pares que se intersequen deben englobar a uno de esos segmentos, y se puede utilizar un método de fuerza bruta para enumerar todas las intersecciones. Propiedad 27.2 Todas las intersecciones entre N segmentos se pueden encon- trar en un tiempo proporcional a (iV+~logAr, donde I es el ntímero de intersec- ciones. Esto es consecuencia directa de la descripción anteri0r.i Una característica interesante del procedimiento anterior es que se puede adaptar, cambiando el procedimiento general de comparación, para detectar la existencia de un par de interseccionesen un conjunto de figuras geométricas más generales.Por ejemplo, si se implementa un procedimiento para comparar dos rectánguloscuyos lados son paralelos horizontal y verticalmente,de acuerdo con la iegla trivial de que un rectángulo x está a la izquierda de un rectángulo y si
  • 454. 434 ALGORITMOS EN C++ el lado derechode x está a la izquierda del lado izquierdode y, entoncesse puede utilizar el método anterior para detectar las intersecciones en un conjunto de rectángulosde este tipo, Para círculos, se puede utilizar las coordenadas x de los centros para la ordenación y efectuar explícitamente comprobaciones de inter- sección (por ejemplo, comparar la distancia entre los centros con la suma de los radios). Una vez más, si esta comparación se utiliza en el método anterior, se tiene un algoritmo de comprobación de intersecciones en un conjunto de cír- culos. El problema de detectar todas las intersecciones en estos casos es mucho más complicado, aunque el método de fuerza bruta ya mencionado en el pá- rrafo anterior es válido siempre que se esperen pocas intersecciones.Otra apro- ximación que es suficiente en muchas aplicaciones consiste en considerar los objetos complejos como conjuntos de segmentos y utilizar el procedimiento de intersección de segmentos. Ejercicios 1. jC6mo se determinaría si se intersecan dos triángulos? ¿Y dos cuadrados? ¿Y dos polígonos regulares de n lados, con y1 > 4? 2. En el algoritmo de intersección de segmentos horizontales y verticales, jcuántos pares de segmentos se deben comprobar en un conjunto de seg- mentos sin intersección, en el peor caso? Mostrar un diagrama que apoye la respuesta. 3. ¿Qué sucede cuando se utiliza el procedimiento de intersección de segmen- tos horizontales y verticales en un conjunto de segmentoscon inclinaciones arbitrarias? 4. Escribir un programa para encontrar el número de pares de intersecciones en un conjunto de N segmentos aleatorios horizontales y verticales, si cada segmento se genera por dos coordenadas enteras aleatonas entre O y 1.O00 y un bit aleatorio para distinguir si es vertical u horizontal. 5. Dar un mCtodo para comprobar si un polígono es simple (no se interseca consigo mismo). 6. Dar un método para averiguar si un polígono está contenido totalmente dentro de otro. 7. Describir cómo se resolvería el problema general de intersección de seg- mentos dado el hecho adicional de que la separación mínima entre dos seg- mentos es mayor que la longitud máxima de los segmentos. 8. Obtener las estructuras de árbol binario que existen cuando el algoritmo de intersección de segmentosdetecta la intersección en los segmentosde la Fi- gura 27.6, si se ha hecho una rotación de 90 grados. 9. ¿Son transitivos los procedimientos de comparación de círculos y rectán- gulos Manhattan descritos en el texto? 10. Escribir un programa para encontrar el número de pares que se intersecan en un conjunto de N segmentos aleatorios, si cada segmento está generado por coordenadas enteras aleatonas entre O y 1.OOO.
  • 455. 28 Problemas del punto más cercano Por lo regular, en los problemas geométricos relativos a puntos del plano inter- viene el cálculo implícito o explícito de las distancias entre los puntos. Por ejemplo, un problema muy natural que se presenta en muchas aplicaciones es el del vecino más próximo: encontrar, entre los puntos de un conjunto dado, el más cercano a un nuevo punto también dado. Parece necesario comparar las distancias entre el punto dado y cada punto del conjunto, pero existen solucio- nes mucho mejores. En esta sección se verán otros problemas de distancia, un prototipo de algoritmo y una estructura geométnca fundamental denominada diagrama de Voronoi, que se puede utilizar con efectividad en una gran vane- dad de problemas de este tipo en el plano. Se hará una aproximación que con- sistirá en describir un método general de resolución de problemasdel punto más cercano por medio de un análisis cuidadoso del prototipo de implementación de un problema sencillo. Algunos de los problemas que se considerarán en este capítulo son similares a los de búsqueda por rango del Capítulo 26, y los métodos de la rejilla y de los árboles 2D ya estudiados son también adecuados para resolver el problema del vecino más próximo o de otros varios. Sin embargo, el defecto fundamental de tales métodos es que se apoyan en la aleatonedad del conjunto de puntos: tie- nen un mal rendimiento en el peor caso. El objetivo de este capítulo es exami- nar otra aproximación generalque garantice un buen rendimiento para muchos problemas, sin importar cuál sea la entrada. Algunos de los métodos son de- masiado complicados para que se pueda examinar aquí la implementación completa, e implican un sobrecoste tan grande que incluso los métodos más simples pueden ser más eficacescuando el conjunto de puntos no es muy grande o está bastante bien distribuido. Sin embargo, el estudio de los métodos que presentan un buen rendimiento en el peor caso revelará algunas de las propie- dades fundamentales de los conjuntos de puntos que deben ser comprendidas, 435
  • 456. 436 ALGORITMOS EN C++ incluso aunque los métodos más simplesparezcan más convenientes en algunas situaciones específicas. La aproximación general que se examinará proporciona otro ejemplo de la utilización de procedimientos doblemente recursivos para entrelazar el proce- samiento entre las dos direcciones de coordenadas.Los dos métodos de este tipo que se han visto anteriormente (árboles kD e intersección de segmentos)se fun- dan en árboles binarios de búsqueda; aquí el método es del tipo ((combina y vencerás»basado en la ordenación por fusión. Problema del par mis cercano El problema del par más cercana consiste en encontrar los dos puntos más cer- canos entre sí de un conjunto de puntos dado. Este problema está relacionado con el del vecino más próximo; aunque no sea de aplicación general, servirá como prototipo de los problemas del punto más cercano en el sentido de que se puede resolver con un algoritmo cuya estructura recursiva general se adapte a otros problemas de este tipo. Para encontrar la distancia mínima entre dos puntos, parecería necesario examinar las distancias entre todos los pares de puntos: para N puntos esto po- dría significar un tiempo de ejecución proporcional a N2. Sin embargo, es po- sible utilizar una ordenación para examinar sólo alrededor de MogN distancias entre puntos en el peor caso (algunos menos por término medio) y obtener un peor caso proporcional a MogN (mucho menos en el caso medio). En esta sec- ción se examinará con detalle un algoritmo como éste. El algoritmo que se utilizará está basado en una estrategiadirecta de ((divide y vencerás>>. La idea es ordenar los puntos según una coordenada, por ejemplo la x,y utilizar después esa ordenación para dividir los puntos en dos mitades. El par más cercano del conjunto completo es o bien el de una de las dos mitades o el formado por un elemento de cada mitad. El caso interesante, por supuesto, es cuando el par más cercano cruza la línea divisoria: el par más cercano de cada mitad se puede encontrar fácilmenteutilizando llamadas recursivas, pero ¿cómo se pueden comprobar eficazmente todos los pares cuyos elementos que están uno a cada lado de la línea divisoria? Puesto que la única información que se bcsca es el par más cercano al con- junto de puntos, solamente se necesita examinar los puntos que están dentro de la distancia m in de la línea divisoria, siendo m i n la menor de las distancias entre los pares más cercanos encontrados en las dos mitades. Sin embargo, esta ob- servaciónno es por sí misma ayuda suficienteen el peor caso, puesto que puede haber muchos pares de puntos muy cercanos a la línea divisoria; por ejemplo, todos los puntos de cada mitad podrían estar situados en la proximidad de la línea divisoria. Para tratar tales situaciones, parece necesario ordenar los puntos según y. Después se puede limitar el número de cálculos de distancias que implican a
  • 457. PROBLEMASDEL PUNTOMÁS CERCANO 437 * O * A * G 8 N * L * M 8 8 . 8 8 Figura 28.1 Aproximación de divide y vencerás para encontrar el par mas cercano. cada punto de la siguiente forma: recomendo los puntos en orden creciente de y, se comprueba si cada uno está dentro de la franja vertical que contiene a to- dos los puntos del plano situados a menos de la distancia m i n de la línea divi- soria. Para cada punto que pase la comprobación, se calcula la distancia entre él y otro cualquiera situado también dentro de la franja cuya coordenada y sea menor que la y del punto en cuestión, pero en una cantidad que no sea mayor que mi n. El hecho de que la distancia entre todos los pares de puntos de cada mitad sea inferior a min significa que posiblemente se comprueben sólo unos pocos puntos. En el pequeño conjunto de piintos de la izquierda de la Figura 28.1, la línea vertical divisoria imaginaria inmediatamente a la derecha de F tiene ocho pun- tos a la izquierda y ocho a la derecha. El par más cercano de la mitad izquierda es AC (o AO) y el de la derecha es JM. Si se ordenan los puntos según y, enton- ces el par más cercano dividido por la línea se encuentra comparando los pares HI, CI, FK (el par más cercano del conjunto completo de puntos), y finalmente EK. Para conjuntos de puntos más grandes. la banda que podría contener un par más cercano sobre la línea divisoria es más estrecha, como se muestra a la derecha de la Figura 28.1. Aunque este algoritmo es siinple, se debe tener cierto widado para imple- mentarlo eficazmente:por ejemplo, sena muy costoso ordenar los puntos según y en el interior de la rutina recursiva. Se han visto varios algoritmos con tiem- pos de ejecución descritos por la recurrencia C,v= 2CN,2 + N, lo que implica que CNes proporcional a MogN; si se hubiera hecho la ordenación completa sobre y, entonces la recurrencia devendría C, = 2C,,r,2+ McgN, lo que implica que C,Ves proporcional a hlog'N (ver el Capítulo 6). Para eludir esto se necesita evitar la ordenación según y. La solución a este problema es simple, pero sutil. El método ordenfusion
  • 458. 438 ALGORITMOS EN C++ del Capítulo 12se basa en dividir los elementos a ordenar exactamente como se dividieron los puntos anteriormente. Hay dos problemas a resolver y el mismo método general de resolución, jasí que se pueden resolver al mismo tiempo! Más concretamente, se escribirá una rutina recursiva que ordene según y y encuentre el par más cercano. Esto lo hará dividiendo al conjunto de puntos por la mitad, llamándose a sí misma recursivamente para ordenar las dos mitades según y y encontrar el par más cercano de cada mitad, fusionando después para comple- tar la ordenación sobre y y aplicando el procedimiento anterior para completar el cálculo del par más cercano. De esta forma, se evita el coste de hacer una ordenación extra de y al entremezclar los movimientos de datos que se requie- ren para la ordenación con los que se requieren para el cálculo del par más cer- cano. Para la ordenación y, la división en dos mitades se podría hacer de cualquier manera, pero, para el cálculo del par más cercano, se necesita que los puntos de una mitad tengan todos coordenadas x más pequeñas que los puntos de la otra. Esto se lleva a cabo fácilmente ordenando según x antes de hacer la división. De hecho, jse puede utilizar la misma rutina para ordenar según x! Una vez que se acepta este plan general, la implementación no es dificil de entender. Como se mencionó anteriormente, la implementación utilizará los proce- dimientos recursivos ordenar y fusion del Capítulo 12. El primer paso con- siste en modificar las estructuras de lista para que contengan puntos en lugar de claves, y modificar fusion de forma que compruebe una variable global pa- sada para decidir qué tipo de comparación hacer. Si pasada vale l se debenan comparar las coordenadas x de los dos puntos; si pasada vale 2 se comparan las coordenadas y. La implementación es directa: i n t comp(struct nodo * t ) s t r u c t nodo *fusion(struct nodo *a, s t r u c t nodo *b) {return (pasada == 1) ? t-}p.x : t->p.y; } s t r u c t nodo *c; c = z; do { i f (comp(a) < comp(b)) e l se { c->siguiente = a; c = a; a = a->s { c->siguiente = b; c = b; b = b->s while (c != z); c = z->siguiente; z->siguiente = z; r e t u r n c; 1 guiente; } guiente; } El nodo ficticio z que aparece al final de cada lista se inicializa para contener un punto «centinela»con coordenadas x y y artificialmente grandes.
  • 459. PROBLEMASDEL PUNTO MÁS CERCANO 439 Para evaluar las distancias, se utiliza otro procedimiento simple que verifica si la distancia entre los dos puntos pasados como argumentos es inferior a la variable global mi n. Si lo es, se asigna esta distancia a m i n y se guardan los pun- tos en las variables globales p c l y pc2: comprobar(struct punto p l y s t r u c t punto p2) i f l o a t d i s t ; i f ((p1.y != z->p.y) && (p2.y != z->p.y)) d i s t = sqrt((pl.x-p2.x)*(pl.x-p2.x) + i f ( d i s t < min) { ( P i .Y-P2. Y) *( P i . Y-P2 * Y ) ) ; { min = d i s t ; p c l = p l ; pc2 = p2; }; 1 1 Así, la variable global m i n contiene siempre la distancia entre p c l y pc2, el par más cercano encontrado hasta ahora. El siguientepaso es modificar la función recursiva ordenar del Capítulo 12 para hacer también el cálculo del punto más cercano cuando pasada es 2, de la siguiente forma: s t r u c t nodo *ordenar(struct nodo *cy i n t N) i n t i; s t r u c t nodo *a, *b; f l o a t medio; s t r u c t punto p l , p2, p3, p4; i f (c->siguiente == z) r e t u r n c; a = c; f o r (i= 2; i <= N/2; i++) c = c->siguiente; b = c->siguiente; c->siguiente = z; i f (pasada == 2) medio = b->p.x; c = fusion(ordenar(a, N/2) , ordenar(b, N-(Nl2))); i f (pasada == 2) { p l = z->p; p2 = z->p; p3 = z->p; p4 = z->p; for (a = c; a != z; a = a->siguiente) { i f (fabs(a->p.x - medio) < min) comprobar( a-->p, p i ) ; {
  • 460. 440 ALGORITMOS EN C++ comprobar(a->p, p2); comprobar (a->p, p3) ; comprobar (a->p, p4) ; p l = p2; p2 = p3; p3 = p4; p4 = a->p; 1 1 r e t u r n c; Si pasada vale 1, ésta es exactamente la rutina recursiva de ordenación por fu- sión del Capítulo 12: devuelve una lista enlazada que contiene los puntos or- denados por sus coordenadas x (porque fusion ha sido modificado como se describióantes para comparar las coordenadas x en la primera pasada). La ma- gia de esta implementación se manifiesta cuando pasada vale '2. El programa no sólo ordena según y (porque fusion ha sido modificado como se describió anteriormente para comparar las coordenadas y en la segunda pasada), sino que también efectúa el cálculo del punto más cercano. Antes de las llamadas recur- sivas, los puntos están ordenados según x:esta ordenación se utiliza para dividir los puntos en dos mitades y encontrar la coordenada x de la línea divisoria. Después de las llamadas recursivasse ordenan los puntos según y, y se sabe que la distancia entre todo par de puntos de cada mitad es mayor que ms' n. La or- denación sobrey se utiliza para recorrer los puntos cercanos a la línea divisoria; el valor de m i n se utiliza para limitar el número de puntos que se deben com- probar. Cada punto situado a menos de una distancia min de la línea divisoria se compara con cada uno de los cuatro puntos encontrados previamente dentro de una distancia min de la línea divisoria. Esta comparación garantiza encon- trar todos los pares de puntos que están a una distancia inferior a m i n a cada lado de la línea divisoria. ¿Por qué se comparan los cuatro últimos puntos y no los dos, tres o cinco? Esto es una particularidad geometrica sorprendente que quizás el lector desee comprobar: se sabe que los puntos situados en un mismo lado de la línea divi- soria están separados por al menos m i n, por lo que el número de puntos inclui- dos en cualquier círculo de radio m i n es limitado. Se podrían comprobar más de cuatro puntos, pero no es difícil convencerse de que cuatro es suficiente. El código siguiente llama a ordenar dos veces para efectuar el cálculo del par más cercano. Primero, se ordena según x (con pasada igual a 1); después se ordena según y, y se encuentra el par más cercano (con pasada igual a 2): z = new nodo; z->p.x = max; z->p.y = max; z->siguiente = z; h = new nodo; h->siguiente = l e e r l i s t a ( ) ; min = max; pasada = i; h->siguiente = ordenar(h->siguiente, N); pasada = 2; h->siguiente = ordenar(h->siguiente, N);
  • 461. PROBLEMAS DEL PUNTO MÁS CERCANO 441 Figura 28.2 Árbol de llamadas recursivas parael caiculo del par más cercano. Después de estas llamadas, el par de puntos más cercanos se encuentra en las variablesglobales pcl y pc2, que controla el procedimiento comprobar «de en- contrar el mínimo». La Figura 28.2 muestra el árbol de llamadas recursivas que describe el fun- cionamiento de este algoritmo en el pequeño ejemplo del conjunto de puntos. Un nodo interno de este árbol representa una línea vertical que divide a los puntos del subárbol izquierdo y del derecho. Los nodos estár, numerados en el orden en el que se examinan las líneas verticaiesen el algoritmo. Esta numera- ción corresponde a un recorrido en orden posterior del árbol porque el cálculo que implica a la línea divisoria tiene lugar después de las llamadas recursivas,y es simplemente otra forma de ver el orden en el que se hace la fusión durante una ordenación por fusión recursiva (ver el Capítulo 12). De este modo, primero se trata la línea entre G y O y se retiene el par GO como el más cercano, por ahora. Luego se trata la línea entre A y D, pero A y D están demasiado alejados como para modificar el valor de mi n. A continua- ción se trata la línea entre O y A y GD; GA y OA son los pares más cercanos sucesivos. En este ejemplo se comprueba que no se encuentran pares más cer- canos hasta FK, que es el último par comprobado en la última línea divisoria tratada. El lector que siga cuidadosamente el desarrollo puede notar que no se ha implementado el algoritmo puro de divide y vencerás descrito con anterioridad -en realidad no se calcula el par más cercano de cada una de las dos mitades, tomando luego el mejor de los dos-. En lugar de esto, se obtiene el más cer- cano de los dos pares más próximos utilizando simplemente la variable global mi n durante el cálculo recursivo. Cada vez que se encuentra un par más cer- cano, se considera de hecho una franja vertical más estrecha alrededor de la lí- nea divisoria actual, sin tener en cuenta en qué punto se encuentra el cálculo recursivo. La Figura 28.3 muestra este proceso detalladamente.La coordenadax de es- tos diagramas se ha ampliado para resaltar la orientación x dei proceso y poner en evidencia el paralelismo con la ordenación por fusión (ver Capítulo 12). Se comienza haciendo una ordenación y sobre los cuatro puntos más a la iz- quierda, G O A D, ordenando G O, luego A D y fusionando después los resul-
  • 462. 442 ALGORITMOS EN C++ O 0 0 0 0 0 0 o o 0 O 0 0 00 Figura 28.3 Calculo del par mas cercano (coordenadax ampliada).
  • 463. PROBLEMAS DEL PUNTO MÁC CERCANO 443 tados. Después de la fusión, se completa la ordenación y encontrándose el par más cercano AO. A continuación se hace lo mismo con E C H F, etcétera. Propiedad 28.1 El par más cercano de un conjunto de N puntos se puede en- contrar con un número de pasos en O(MogN). Esencialmente, el cálculo se realiza en el tiempo de hacer dos ordenaciones por fusión (una sobre las coordenadas x, y otra sobre las y ) más el coste del reco- mdo a lo largo de la línea divisoria. Este coste está también gobernado por la recurrencia TN= 2TNi2+ N.i La estrategiageneral que se ha utilizado aquí para el problema del par más cercano puede servir para resolver otros problemas geométncos. Por ejemplo, otro caso de interés es el de todos los vecinos máspróximos: para cada punto se desea encontrar el punto más próximo a él. Este problema se puede resolver uti- lizando un programa como el anterior y añadiendo un procesamiento extra a lo largo de la línea divisoria para determinar, para cada punto, si existe un punto homólogo situado en la otra mitad, más cercano que el más cercano de los de su propia mitad. De nuevo la dibre» ordenación en y es útil para este cálculo. Diagramas de Voronoi El conjunto de todos los puntos más cercanos a un punto dado que todos los otros puntos en un conjunto de puntos es una interesante estructura geométrica denominada pollgono de Voronoidel punto. La unión de todos los polígonosde Voronoi de un conjunto de puntos se denomina diagrama de Voronoi.Esto es lo máximo en el cálculo del punto más cercano: se verá que la mayoría de los problemas tratados que implican distancias entre puntos admiten soluciones naturalese interesantesbasadas en los diagramas de Voronoi. Los diagramaspara el ejemplo del conjunto de puntos se muestran en la Figura 28.4. El polígono de Voronoi de un punto está formado por las mediatnces de los segmentos que enlazan al punto con los que le son más cercanos. Su definición real se hace de otra forma: el polígono de Voronoi se define como el perímetro del conjunto de todos los puntos del plano más cercanos al punto dado que a cualquier otro punto del conjunto de puntos, y cada lado del polígono de Vo- ronoi separa al punto en cuestión de cada uno de los puntos más acercanos a él». El dual del diagrama de Voronoi, que se muestra en la Figura 28.5, hace explícita esta correspondencia: en el dual, se dibuja un segmento entre cada punto y todos los puntos «cercanos» a él. Esta estructura se denomina también trianguIación de Delaunay. Los puntos x y y se enlazan en el dual de Voronoi solamente si sus polígonos de Voronoi tienen un lado en común. El diagrama de Voronoi y la tnangulación de Delaunay tienen muchas pro-
  • 464. 444 ALGORITMOS EN C++ Figura 28.4 Diagramade Voronoi. piedades que conducen a algontmos eficaces para los problemas del punto más cercano. La propiedad que hace eficaces a estos algoritmos es que el número de segmentos de ambos diagramas es proporcional a una pequeña constante mul- tiplicada por N. Por ejemplo, el segmento que conecta los pares de puntos más cercanos debe estar en el dual, lo que significa que se puede resolver el pro- blema de la sección anterior calculando el dual y encontrando simplemente la longitud mínima entre los segmentos del mismo. De forma similar, el segmento que conecta cada punto con su vecino más cercano debe estar en el dual, Io que significa que el problema de todos los vecinos próximos se reduce directamente a encontrar el dual. El cerco convexo del conjunto de puntos es parte del dual, Figura 28.5 Triangulaciónde Delaunay.
  • 465. PROBLEMAS DEL PUNTO MÁS CERCANO 445 por lo que el cálculo del dual de Voronoi lleva a otro algoritmo de cerco con- vexo más. En el Capítulo 3 1 se verá otro ejemplo de un problema que se puede resolver eficazmente encontrando primero el dual de Voronoi. La propiedad que define al diagrama de Voronoi significa que se puede uti- lizar para resolver el problema del vecino más próximo: para identificar, en un conjunto de puntos, el vecino más próximo de un punto dado, sólo se necesita encontrar en qué polígono de Voronoi se encuentra el punto. Es posible orga- nizar los polígonos de Voronoi en una estructura como un árbol 2D para per- mitir que esta búsqueda se haga eficazmente. El diagrama de Voronoi se puede calcular utilizando un algoritmo con la misma estructura general que el algoritmo anterior del punto más cercano. Pri- mero se ordenan los puntos según su coordenada x. Después se utiliza la orde- nación para dividir a los puntos en dos mitades, dejando dos llamadas recursi- vas para encontrar el diagrama de Voronoi del conjunto de puntos de cada una de las dos mitades. Al mismo tiempo, se ordenan los puntos según y; final- mente: los diagramas de Voronoi de las dos mitades se fusionan entre sí. Como antes, esta fusión (efectuada cuando pasada vale 2) puede explotar el hecho de que los puntos se ordenan según x antes de las llamadas recursivas y que, des- pués de ellas, se ordenan según y y se construyen los diagramas de Voronoi de las dos mitades. Sin embargo, aun con estas ayudas, la fusión es una tarea bas- tante complicada y la presentación de una implementación completa rebasa el zkance de este libro. El diagrama de Voronoi es la estrrictura natural de los problemas del punto más cercano, y la comprensión de las características de un problema en térmi- nos del diagrama de Voronoi o de su dual es sin duda un ejercicioque merece la pena. Sin embargo, para muchos problemas particulares, puede ser conve- niente una implementación directa basada en el esquema general dado en este capítulo. Este esquema es lo suficientemente potente para poder calcular el dia- grama de Voronoi, por lo que es lo suficientemente potente para algoritmos ba- sados en el diagrama de Voronoi, y puede conducir a programas más simplesy eficaces, como se vio para el caso del problema del par más cercano. Ejercicios 1. Escribir programas para resolver el problema del vecino más próximo, uti- lizando primero el método de la rejilla y posteriormente árboles 2D. 2. Describir lo que sucede cuando el procedimiento del par más cercano se utiliza en un conjunto de puntos alineados sobre la misma línea horizontal e igualmente espaciados. 3. Describir lo que sucede cuando el procedimiento del par más cercano se utiliza en un conjunto de puntos alineados sobre la misma línea vertical e igualmente espaciados. 4. Dado un conjunto de 2N puntos, la mitad con coordenadas positivas de x
  • 466. 446 ALGORITMOS EN C++ y la otra mitad con coordenadas negativas de x, obtener un algoritmo que encuentre el par más cercano constituido por un elemento del mismo en cada mitad. 5. Obtener los pares sucesivosde puntos asignadosa p c l y pc2 cuando el pro- grama del texto se aplica a los puntos del ejemplo, del que se ha suprimido A. 6. Comprobar la eficacia de atribuir a min el carácter global comparando el rendimiento de la implementación dada con una implementación pura- mente recursiva en algún gran conjunto de puntos aleatonos. 7. Obtener un algoritmo para encontrar el par más cercano de un conjunto de segmentos. 8. Dibujar el diagrama de Voronoi y su dual para los puntos A B C D E F del conjunto de puntos del ejemplo. 9. Obtener un método de «fuerzabruta) (que pueda necesitar un tiempo pro- porcional a N2)para construir el diagrama de Voronoi. 10. Escribir un programa que utilice la misma estructura recursiva que la im- plementación del par más cercano dada en el texto para encontrar la super- ficie convexa de un conjunto de puntos.
  • 467. PROBLEMASDEL PUNTO MÁS CERCANO 447 REFERENCIAS para los Algoritmos geométricos En realidad, gran parte del material descrito en esta sección se ha desarrollado hace poco tiempo. Muchos de los problemas y solucionesque se han presentado fueron introducidos por M. Shamos en 1975.La tesis de Ph.D. de Shamos trata un gran número de algoritmos geométricos,que han estimulado muchas de las investigacionesrecientes y que finalmente fueron desarrollados en la referencia más autorizada en este campo, el libro de Preparata y Shamos. Esta materia está en rápida expansión: el libro de Edelsbrunner describe muchos de los resultados más recientes. En su mayor parte, cada algoritmo que se ha presentado está descrito en su propia referencia original. Los algoritmos de cerco convexo del Capítulo 25 se pueden encontrar en los artículos de Jarvis, Graham, y Golin y Sedgewick. Los métodos de búsqueda por rango del Capítulo 26 provienen del artículo de in- vestigación de Bentley y Friedman, que contiene muchas referencias a fuentes originales (de particular interés resulta el artículo original de Bentley sobre los kD árboles, escrito cuando era estudiante). El tratamiento del problema del punto más cercano del Capítulo 28 está basado en el artículo de Shamos y Hoey de 1976, y los algoritmos de intersección geométnca del Capítulo 27 son de su trabajo de 1975y de un artículo de Bentley y Ottmann. Pero la mejor vía a seguir por alguien interesado en aprender más sobre al- goritmos geométricos consiste en implementar algunos programas y ejecutarlos para aprender sus propiedades y las de los objetos que manipulan. J. L. Bentley, «Multidimensional binary searchtrees used for associative search- ing», Communications of the ACM, 18, 9 (septiembre, 1975). J. L. Bentley y J. H. Friedman, «Data structures for range searching), Comput- ing Surveys, 11, 4 (diciembre, 1979). J. L. Bentley y T. Ottmann, «Algorithmsfor reporting and counting geometric intersections)),IEEE Transactionson Computing,C-28, 9 (septiembre,1979). H. Edelsbrunner,Algorithms in Combinatorial Geometry,Springer-Verlag,1987. M. Golin y R. Sedgewick, ((Analysisof a simple yet efficient convex hull algo- R. A. Jarvis, «On the identification of the convex hull of a finite set of points in F. P. Preparata y M. LShamos, Computacional Geometry: An Introduction, M. I. Shamos y D.Hoey, «Closest-pointproblems)) en 16th Annual Symposium M. I. Shamos y D. Hoey, ((Geometricintersections problems», en 17th Annual rithm», Information Processing Letters, 1 (1972). the plane», Information Processing Letters, 2 (1973). Springer-Verlag, 1985. on Foundations o f Computer Science, IEEE, 1975. Symposium on Foundations of Computer Science, IEEE, 1976.
  • 471. 29 Algoritmos sobre grafos elementales Muchos problemas se formulan de manera natural por medio de objetos y de las conexiones entre ellos. Por ejemplo, si se dispone de un mapa de enlaces de líneas aéreas del Este de los Estados Unidos, pueden ser de interés preguntas como: «¿Cuál es el camino más rápido para ir de Providence a Princeton?)).O bien puede tener más importancia el precio que el tiempo, y por ello se busca la forma más económica de ir de Providence a Princeton. Para contestar a esta clase de preguntas solamente se necesita tener información sobrelas conexiones (líneas aéreas) entre objetos (ciudades). Los circuitos eléctricos son otro claro ejemplo en el que las conexionesentre objetos tienen un papel principal. Los elementos del circuito, transistores, resis- tencias y condensadores,están conectados entre sí de forma compleja. Talescir- cuitos pueden representarse y procesarse por medio de computadoras para po- der contestar a preguntas sencillas como «¿Están conectados todos los componentes?», así como a cuestionesmás complicadas como «¿,Sise construye el circuito, funcionará?)).La respuesta a la primera pregunta depende solamente de las propiedades de las conexiones (cables),mientras que la respuesta a la se- gunda necesita una información detallada sobre las conexionesy los objetos que conectan. Un tercer ejemplo es la «ordenación de tareas», en el que los objetos son las tareas que se van a realizar, como es el caso de un proceso de fabricación, y las conexiones entre ellosindican qué tareas deben hacerse antes que otras. Aquí el interés se centra en responder a preguntas tales como qCuándo se debe realizar cada tarea?». Un grufo es un objeto matemático que modela fielmente situaciones de este tipo. En este capítulo se examinarán algunas de las propiedades básicas de los grafos, y en los siguientes se estudiará una sene de algontmos que permitirán responder a preguntas como las propuestas anteriormente. 451
  • 472. 452 ALGORITMOS EN C++ De hecho, ya se han visto algunos grafos en los capítulos precedentes. Las estructuras de datos enlazadas son realmente representaciones de grafos y algu- nos de los algoritmos que se verán para el procesamiento de grafos son similares a los que se han visto ya en el tratamiento de árboles y de otras estructuras. Por ejemplo, las máquinas de estados finitos de los Capítulos 19y 20 se representan por medio de estructuras de grafos. La teoría de grafos es una-rama fundamental de la matemática combinato- ria que se ha estudiado en profundidad desde hace cientos de años. Gran parte de las propiedades útiles e importantes de los grafos se han demostrado ya, pero todavía están sin resolver muchos problemas dificiles. En este libro solamente se puede arañar la superficiede lo que se conoce sobre los grafos, abarcando lo suficientepara poder ser capaces de comprender los algoritmos fundamentales. Como muchos de los tenlas que se han estudiado en este libro, los grafos no se han examinado desde un punto de vista algorítmico hasta hace poco tiempo. A pesar de que algunos de los algoritmos fundamentales son bastante antiguos, muchos de los más interesantes se han descubierto en los últimos diez años. In- cluso los algoritmos triviales conducen a interesantes programas de computa- dora, y los otros más dificiles que se examinarán posteriormente se encuentran entre los más elegantes e interesantes de los algoritmos conocidos (a pesar de que sean difíciles de comprender). Glosario Para el estudio de los grafos hay una cuantiosa cantidad de nomenclatura. La mayor parte de los términos tienen definiciones sencillas, por lo que es conve- niente presentarlos todos juntos, aun cuando no se vaya a utilizar algunos de ellos sino hasta más tarde. Un grafo es una colección de vértices y de aristas. Los vértices son objetos simples que pueden tener un nombre y otras propiedades; una arista es una co- nexión entre dos vértices. Se puede dibujar un grafo representando los vértices por puntos y las aristas por líneas que los conecten entre sí, pero no hay que olvidar jamás que la definición de un grafo es independiente de la representa- ción. Por ejemplo, los dos dibujos de la Figura 29.1 representan el mismo grafo, que se define diciendo que consiste en el conjunto de vértices A B C D E F G H I J K L M y en el de aristas entre dichos vértices AG AB AC LM JM JL JK ED F D HI FE AF GE. En algunas aplicaciones, tales como las líneas aéreas del ejemplo anterior, puede que no tenga sentido una reorganización de vértices como la de la Figura 29.1. Pero en otras, como en los mencionados circuitos eléctricos, io mejor es concentrarse soiamente en las aristas y vértices, independientemente de su si- tuación geométrica particular. Y para otras aplicaciones, tales como las máqui- nas de estados finitos de los Capítulos 19 y 20, no se necesita ninguna disposi- ción geométrica de los nodos. La relación entre los algoritmos sobre grafosy los
  • 473. ALGORITMOS SOBRE GRAFOS ELEMENTALES 453 Figura 29.1 Dos representacionesdel mismo grafo. problemas geométricos se presentará con mayor detalle en el Capítulo 31. Por ahora hay que concentrarse en los aigoritmos «puros», que tratan colecciones sencillas de aristas y nodos. Un camino entre los vértices x e y de un grafo es una lista de vértices en la que dos elementos sucesivosestán conectados por aristas del grafo. Por ejemplo, BAFEG es un camino desde B a G de la Figura 29.1. Un grafo es conexo si hay un camino desde cada nodo hacia otro nodo del grafo. De forma intuitiva, si los vértices son objetos fisicos y las aristas son cadenasque los conectan, un grafo conexo permanecería en una sola pieza si se le levantara por uno cualquiera de sus vértices. Un grafo que no es conexo está constituido por componentes co- nexas; por ejemplo, el grafo de la Figura 29.l tiene tres componentes conexas. Un camino simple es un camino en el que no se repite ningún vértice (por ejem- plo BAFEGAC no es un camino simple). Un ciclo es un camino simple con la característica de que el primero y el último vértices son el mismo (un camino desde un punto a sí mismo): el camino AFEGA es un ciclo. Un grafo sin ciclos se denomina un árbol (ver el Capítulo 4). Un grupo de árboles sin conectar se denomina un bosque. Un árbol de expansión de iin grafo es un subgrafo que contiene todos los vértices, pero solamente las aristas nece- sanas para formar un árbol. Por ejemplo, las aristas AB AC AF FD EG ED for- man un árbol de expansión de la componente mayor del grafo de la Figura 29.1, y la Figura 29.2 muestra un grafo más grande, así como uno de sus árboles de expansión. Hay que subrayar que si se añade una arista cualquiera a un árbol, se debe formar un ciclo (dado que ya existe un camino entre los dos vértices que ella conecta). Además, como se vio en el Capítulo 4,un árbol con V-vértices tiene exactamente V- 1 aristas, Si un grafo con Vvérticestiene menos de V- 1 aristas, no puede ser conexo. Si tiene más de V- 1 aristas, debe contener un ciclo. (Pero si tiene exactamente V- 1 aristas no es necesariamente un árbol.) En este libro se denominará V al número de vértices que tiene un grafo y A al de aristas. Es de destacar que A puede estar comprendido entre O y Iíz V(V - 1). Los grafos con todas las aristas posibles se denominan grafos completos;los que
  • 474. 454 ALGORITMOS EN C++ Figura 29.2 Un grafo muy grande y uno de sus arboles de expansión. tienen relativamente pocas (menos de Vlogy) se denominan dispersos y a los que les faltan muy pocas de todas las posibles se les denomina densos. El hecho de que la topología de los grafos dependa fundamentalmente de dos parámetros hace que el estudio comparativo de los algoritmos sobre grafos sea algo más complicado que el de muchos de los algoritmos que se han estu- diado, ya que aparecen más posibilidades.Por ejemplo, un algoritmo puede ne- cesitar V2pasos, mientras que otro, para el mismo problema, puede necesitar (A + v>logA pasos. El segundo algoritmo sería preferible para grafos dispersos y el primero para grafos densos. Los grafos que se han descrito hasta ahora son del tipo más sencillo, el de- nominado de grafos no dirigidos. También se consideran en este libro otros ti- pos de grafos más complicados, en los que se asocia más información con los nodos y las aristas. En los grafos ponderados se asignan enteros (pesos) a cada arista para representar, por ejemplo, distancias o costes. En los grafos dirigidos, las aristas son de mentido único)):una arista puede ir de x a y pero no de y a x. Los grafosdirigidosponderados se denominan a veces redes. Como se verá pos- teriormente, la información extra que contienen los grafos ponderados y din- gidos hace que éstos sean algo más difíciles de manipular que los grafos no di- rigidos sencillos. Representación Con el fin de procesar grafos por medio de un programa de computadora se ne- cesita decidir cómo representarlos en la máquina. Aquí se estudiarán dos de las representacionesmás usuales;la elección entre ellasdependerá normalmente de
  • 475. ALGORITMOS SOBRE GRAFOS ELEMENTALES 455 A B C D E F G H I J K L M A l l l O O l l O O O O O O B l l O O O O O O O O O O O c 1 0 1 0 0 0 0 0 0 0 0 0 0 D O O O l l l O O O O O O O E O O O l l l l O O O O O O F l O O l l l O O O O O O O G l O O O l O l O O O O O O H O O O O O O O l l O O O O 1 0 0 0 0 0 0 0 1 1 0 0 0 0 J O O O O O O O O O l l l l K O O O O O O O O O l l O O L 0 0 0 0 0 0 0 0 0 1 0 1 1 M O O O O O O O O O l O l l Figura 29.3 Representaciónpor matriz de adyacencia. si el grafo es denso o disperso, aunque, como siempre, la naturaleza de la ope- ración a realizar también tendrá un papel importante. El primer paso para representar un grafo es hacer corresponder los nombres de los vértices con los enteros entre 1 y V.La principal razón para hacer esto es facilitar un rápido acceso a la información que corresponde a cada vértice, uti- lizando un array indexado. Para este objetivo puede utilizarse cualquier es- quema estándar de búsqueda; por ejemplo, se pueden transformar los nombres de los vértices en enteros entre el 1 y el V por medio de una tabla de dispersión o de un árbol binario donde se pueda buscar el entero correspondiente al nom- bre de un vértice cualquiera. Como ya se han estudiado estas técnicas, se su- pone que existe una función i ndi ce para convertir nombres de vértices en en- teros entre 1 y V,y una función nombre para convertir enteros en nombres de vértices. Para simplificarlos algoritmos se utilizarán nombres de vértices de una sola letra, correspondiendo la i-ésima letra del alfabeto al entero i. Así,aunque nombre e indi ce son de fácil implementación en los ejemplos, su utilización hará más sencilla la extensión de los algoritmos a la manipulación de grafoscon nombres de vértices reales, utilizando las técnicas de los Capítulos 14-17. La representación más directa de los grafoses la denominada representación por matriz de adyacencia. Se construye un array de P V valores booleanos en el que a [x] [y] es igual a 1si existe una arista desde el vértice x al y y a O en el caso contrario. La matriz de adyacencia del grafo de la Figura 29.1 se muestra en la Figura 29.3. Es de destacar que en realidad cada arista se representa con dos bits: una arista que enlace x e y se representa con valores verdaderos tanto en a [x] [y] como en a [y] [x] .Aunque sea posible ahorrar espacio almacenando solamente
  • 476. 456 ALGORITMOS EN C++ la mitad de esta matriz simétrica, en C++ no es conveniente hacer esto, y los algoritmos son algo más simples con la matriz completa. Además, normal- mente se supone que existe una «arista»desde cada vértice a sí mismo, por lo que a[x] [x] es igual a 1 para los valores de x desde 1 hasta V. (En algunos casos es más conveniente poner a O los elementos de la diagonal; en el libro se hará esto libremente cuando se considere apropiado.) Un grafo se define por un conjunto de nodos y otro de aristas que los co- nectan. Para aceptar un grafo como entrada se necesita establecer un formato de lectura de estos dos conjuntos. Una posibilidad consiste en utilizar para ello la propia matriz de adyacencia, pero, como se verá, esto no es adecuado para los grafos densos. Por ello se utilizará un formato más directo: primero se leen los nombres de los vértices, después los pares de nombres de vértices (lo que define las aristas). Como se mencionó anteriormente, una sencilla forma de ac- tuar es leer los nombres de los vértices en una tabla de dispersión o en un árbol binario de búsqueda, y asignar un entero a cada nombre de vértice, que servirá para poder indexar por vértices a los arrays de igual forma que en la matriz de adyacencia. El i-ésimovértice leído puede asgnarse al entero i. Para simplificar más los programas, se leen primero V y A, a continuación los vértices y después las aristas. Alternativamente, se podría distribuir la entrada por medio de un delimitador que separe los vértices de las aristas, y el programa podria deter- minar Y y A a partir de los datos de entrada. (En los ejemplos anteriores se uti- lizan las V primeras letras del alfabeto como nombres de vértices, lo que per- mite simplificarel esquema leyendo Y y A, y despuéslosA pares de letras de las primeras V letras del alfabeto.) El orden en el que aparecen las aristas no tiene ninguna importancia dado que todas las permutaciones de las aristas represen- tan el mismo grafo y generan la misma matriz de adyacencia, como muestra el siguiente programa: i n t V, A; i n t a [maxV] [maxV] ; void matrizady() i n t j , x, y; tin >> V >> A; for ( x = 1; x <= V; x++) for (x = 1; x <= V; x++) a[x][x] = 1; f o r ( j = 1; j <= A; j++) c i n >> v l >> v2; x = indice(v1); y = indice(v2); { f o r ( y = 1; y <= V; y++) a[x][y] = O; {
  • 477. ALGORITMOS SOBRE GRAFOS ELEMENTALES 457 En este programa se omiten tanto el tipo de v l y v2 como el código de i ndi ce. Estos detalles se pueden añadir de manera sencilla,dependiendo de la represen- tación que se desee para el grafo de entrada. Para los ejemplos del libro, v l y v2 pueden ser del tipo char e i ndi ce podría ser una simple función que devuelva c - ' A ' + 1 o algo similar. Se puede desarrollar fácilmente una cl ase de C++ para grafos que permita ocultar la representación detrás de una interfaz que consista en funciones bási- cas para aplicar a los grafos. Aunque se ha demostrado esta metodología para estructuras ampliamente utilizadas, como diccionarios y colas de prioridad, se debe evitar hacerlo en los algoritmos sobre grafos, dado que las implementacio- nes que utilizan variables globales son algo más compactas, y porque las imple- mentaciones que dependen de la aplicación tienden a ser necesarias en una buena implementación de clase. Inicialmente el objetivo de los algoritmos sobre grafos es exponer las diferenciasde las representaciones, no ocultarlas. Por ello el lector más experto puede elegir una representación adecuada y utilizar las ca- pacidades de abstracción de datos de C++ como ayuda para integrarla en una aplicación particular. La representación por matriz de adyacencia sólo es satisfactoriasi los grafos a procesar son densos: la matriz necesita V2bits de almacenamiento y V2pasos de inicialización. Si el número de aristas (el nirnero de bits 1 de la matriz) es proporcional a V2, se puede aceptar esta representación porque en cualquier caso se necesitan aproximadamente V2pasos para leer las aristas. Sin embargo, si el grafo es disperso, la simple inicialización de la matriz podría ser el factor du- minante en el tiempo de ejecución del algoritmo. Ésta podría ser también la mejor representación para algunos algoritmos cuya ejecución necesita más de v2pasos. A continuación se estudia una representación mejor adaptada a los casos de grafos que no son densos. En la representación por estructura de adyacencia to- dos los vértices conectados con uno dado se relacionan en una lista de adyacen- cia de dicho vértice. Esto se puede realizar fácilmente por medio de listas enla- zadas, como se muestra en el siguienteprograma que construye la estructura de adyacencia para el grafo del ejemplo. Las listas enlazadas se construyen de la forma habitual, con un nodo ficticio z en cola (apuntando sobre sí mismo). Los nodos ficticios del encabezamiento de las listas se conservan en un array ady indexado por vértices. Para añadir una arista que conecte xa y en esta represen- tación del grafo, se agregará x a la lista de adyacencia de y e y a la lista de ad- yacencia de x: s t r u c t nodo i n t V, A; s t r u c t nodo *ady[maxV], *z; void l i s t a a d y o { i n t v; struct nodo "siguiente; };
  • 478. 458 ALGORITMOS EN C+t int j, x, y; struct nodo *t; cin >> V >>A; z = new nodo; z->siguiente = z; for (j = 1; j <= V; j++) ady[j] = z; for (j = 1; j <= A; j++) cin >> vl >> v2; x = indice(v1); y = indice(v2); t = new nodo; t = new nodo; { t->v =x; t->siguiente = ady[y]; ady[y]=t; t->v =y; t->siguiente = ady[x]; ady[x]=t; 1 1 La representación por lista de adyacencia es la mejor para los grafos dispersos, dado que el espacio que se necesita está en O(V +A), en contraste con el espa- cio en O(V2)necesario para la representación por matriz de adyacencia. A B C D E F G H I J K L M D r 3 Figura 29.4 Una representación por estructura de adyacencia. Si las aristas aparecen en el orden AG AB AC LM JM JL JK ED FD HI FE AF GE, el programa anterior construye la estructura de listasde adyacencia que se muestra en la Figura 29.4. Se observa otra vez que cada arista se representa dos veces: una arista que conecte x e y se representa como un nodo que con- tiene a x en la lista de adyacencia de y, y como un nodo que contiene a y en la lista de adyacencia de x. Es importante incluir ambos nodos, pues en caso con- trario una pregunta tan simple como «¿Qué nodos están conectados directa- mente al nodo x?»no podría contestarse de forma eficaz. En esta representación el orden en el que aparecen las aristas en la entrada es muy importante: él determina (junto con el método de inserción utilizado) el orden en el que aparecerán los vértices en las listas de adyacencia. Por ello el
  • 479. ALGORITMOS SOBRE GRAFOS ELEMENTALES 459 mismo gafo se puede representar de muchas formas diferentes en una estruc- tura de listas de adyacencia. De hecho, es dificil predecir que las listas de adya- cencia serán semejantesal examinar solamente la secuenciade aristas, dado que cada arista implica la inserción en dos listas de adyacencia. El orden de aparición de las aristas en la lista de adyacencia afecta, a su vez, el orden en el que serán procesadas por los algoritmos. Esto es, la estructura de la lista de adyacenciadetermina la forma en la que diversosalgoritmos «verán» al grafo. Mientras que un algoritmo debe dar una respuesta correcta, sin que tenga importancia cómo están ordenadas las aristas en las listas de adyacencia, podría ser que obtuviera esta respuesta por muchas secuenciasde cálculo distin- tas en órdenes diferentes.Y si existe más de una «respuesta correcta), diferentes órdenes de entrada podrían llevar a resultados de salida diferentes. En esta representación no se contemplan algunas operaciones simples. Por ejemplo, se podría desear suprimir un vértice, x,y todas las aristas que inciden en él. No es suficientecon suprimir nodos de una lista de adyacencia: cada nodo de la lista especifica otro vértice, cuya lista de adyacencia debe buscarse para eliminar un nodo correspondiente a x.Este problema puede corregirse enla- zando los dos nodos de las listas que corresponden a una arista determinada y haciendo las listas de adyacencia doblemente enlazadas. Así, si se suprime una arista, los dos nodos de las listas que se corresponden con ella pueden elimi- narse rápidamente. Por supuesto, estos lazos extra son incómodos para el pro- ceso y no se les debería incluir salvo que se necesitaran operaciones como la de eliminación, Estas consideraciones también justifican por qué no se utilizan representa- ciones «directas» en los grafos: una estructura de datos que modela el grafo exactamente, con los vértices representados por registros asignados y listas de aristas que contienen enlaces a los vértices en lugar de nombres de vérticcs. Sin acceso directo a los vértices, las operacionesmás simplespodrían convertirse en verdaderos desafíos. Por ejemplo, para añadir una arista a un grafo represen- tado de esta manera se tendría que buscar a través del grafo alguna forma de encontrar los vértices. Los grafos dirigidos y los grafos ponderados se representan por medio de es- tructuras similares.En el caso de los grafos dirigidos todo lo expuesto es válido excepto que cada arista se representa una sola vez: una arista de x a y se repre- senta con 1 en a [x] [y] de la matriz de adyacencia o por la aparición de y en la lista de adyacencia de x de la estructura de adyacencia. Así se puede repre- sentar un grafo no dirigido como un grafo dirigido en el que toda arista que co- necta dos vértices es una arista dirigida en los dos sentidos. Para los grafos pon- derados se procede exactamente igual excepto que se completa la matriz de adyacencia con pesos en lugar de valores booleanos (utilizando algún peso que no exista para representar la ausencia de una arista), o incluyendo un campo para el peso en los registros de la lista de la estructura de adyacencia. A veces es necesario asociar otras informaciones a los vértices o nodos de un grafo para permitir el modelado de objetosmás complicados o para ahorrar tra- bajo en la actualización de la información de los algoritmos complicados. Para
  • 480. 460 ALGORITMOS EN C++ disponer de esta información extra asociada con cada vértice se pueden utilizar arrays auxiliaresindexados por los números de los vértices o transformando ady en un array de registros en la representación de la estructura de adyacencia. La información suplementaria asociada con cada arista puede colocarse en los no- dos de la lista de adyacencia (o en un array a de registros en la representación por matriz de adyacencia), o en arrays auxiliares indexados por el número de arista (lo que requiere numerarlas). Búsqueda en profundidad AI comienzo de este capítulo, se han visto varias cuestiones que aparecen de forma inmediata cuando se procesa un grafo. ¿El grafo es conexo? Si no lo es, ¿cuáles son sus componentes conexas? ¿Contiene un ciclo? Estos problemas y otros muchos pueden solucionarse fácilmente por medio de una técnica deno- minada búsqueda en profundidad, que es un medio natural de «exploran>cada nodo y de comprobar cada arista del grafo de forma sistemática. En los capítu- los siguientes se verá que es posible utilizar sencillas variaciones de una gene- ralización de este método para resolver una gran variedad de problemas sobre grafos. Por ahora hay que concentrarse en los mecanismos que examinan metódi- camente cada elemento del grafo. Se utiliza un array val [ V I para registrar el orden en el que se exploran los vértices. Cada zntrada del array se inicializa con el valor novisto para indicar qué vértices no se han inspeccionado todavía. El objetivo es visitar sistemáticamente todos los vérticesdel grafo, colocando el or- den del vértice explarado, id, en la id-ésima entrada de val, para los valores de id= 1, 2, ..., V.El siguiente programa utiliza un procedimiento v i s i t a r que inspecciona todos los vértices de la misma componente conexa del vértice pa- sado como argumento. i n t f o r f o r { 1 void buscar ( ) k; (k=l ; (k=l; k<=V; k++) v a l l k ] = novisto; k<=V ; k++) f (va [k] == novisto); v i s i t a r ( k ) ; El primer bucle f o r inicializa el array val. A continuación se invoca v i s it a r para el primer vértice, con el resultado de atribuir valores en val a todos los vértices conectados a él. A continuación buscar explora el array val buscando vértices que todavía no hayan sido vistos y llama a v i s i ta r para estos vértices, continuando de esta forma hasta que se hayan inspeccionado todos los vértices.
  • 481. ALGORITMOS SOBRE GRAFOS ELEMENTALES 461 Es de destacar que este método no depende de la forma como se represente el grafo o de como se implemente v i s i t a r . En primer lugar se considera una implementación recursiva de v i s it a r para la representación por listas de adyacencia: para v i s i t a r un vértice, se com- prueban todas sus aristas para ver si conducen a vértices que todavía no se hani visto; si los hay, se invoca v i s i t a r para ellos. v o i d v i s i t a r ( i n t k) // E?, l i s t a s de adyacencia i s t r u c t nodo *t; val [k] = ++id; for ( t = ady[k]; t !=z; t = t->siguiente) if (val [t->VI == novisto) v i s i t a r ( t - > v ) ; } La Figura 29.5 traza el recorrido de la operación de búsqueda en profundidad de la componente mayor del grafo del ejemplo y muestra cómo se toca cada arista de esta componente como resultado de la llamada v i s i t a r (1) (después de que se hayan construido las listas de adyacencia de la Figura 29.4). Real- mente se «contacts» dos veces con cada arista, dado que todas están represen- tadas en las dos listas de adyacencia de los vértices que conectan. En la Figura 29.5 hay un diagrama por cada arista recomda (cadavez que el enlace t se pone a apuntar a algún nodo de alguna lista de adyacencia).En cada diagrama la arista «actual» aparece sombreada y el nodo cuya lista de adyacencia contiene a esta arista está etiquetado con un cuadrado. Además, cada vez que se inspecciona un nodo por primera vez (lo que se corresponde con una nueva llamada a v i - s i tar), se representa en negro la arista que conduce a dicho nodo. Los nodos que no han sido tocados todavía están sombreados, pero no etiquetados, y aquellos para los que v i s i t a r ha terminado están sombreados y etiquetados. La primera arista recorrida es AF, el primer nodo de la primera lista de ad- yacencia. A continuación se invoca v i s i t a r para el nodo F y se recorre la arista FA' dado que A es el primer nodo de la lista de adyacencia de F. Pero el nodo A es en este momento novi sto, por lo que se coge la arista FE, la siguienteen- trada de la lista de adyacencia de F. A continuación se recorre EG y después GE, dado que G y E son los primeros de cada una de las otras listas. Luego se recorre GA y con esto se termina v i s it a r G, por lo que el algoritmo continua con v i sita r E y recorre EF y después ED. Después v i sit a r D consiste en re- correr DE y DF, ninguna de las cuales conduce a un nuevo nodo. Dado que D es el último nodo de la lista de adyacencia de E, v i sit a r este nodo ha termi- nado y la visita de F se completará recomendo FD. Finalmente se vuelve a A y se recorre AC, CA, AB, BA y AG. Otra forma de seguir la operación de búsqueda en profundidad es volver a dibujar el grafo en el orden indicado por las llamadas recursivas del procedi- miento v i s i t a r , como se muestra en la Figura 29.6. Cada componente conexa
  • 482. 462 ALGORITMOS EN C++ Figura 29.5 Búsquedaen profundidad(recursiva)de la componentemayor del grafo. p, F ....., C B . . ' , . : _.. __... __... __.. . .._____.__.. ... Figura 29.6 Bosque de búsquedaen profundidad.
  • 483. ALGORITMOS SOBRE GRAFOS ELEMENTALES 463 conduce a un árbol, denominado árbol de búsqueda en profundidad de la com- ponente. Recomendo este árbol en orden previo se obtienen los vértices del grafo en el orden en el que estaban la primera vez que se encontraron en la búsqueda; recoméndolo en orden posterior se obtienen los vértices en el orden que esta- ban al terminar v i sit a r . Es importante comprender que este bosque de árbo- les de búsqueda en profundidad es simplemente otra forma de dibujar el grafo: el algoritmo examina todos los vértices y aristas del grafo. Las líneas de trazo grueso de la Figura 29.6 indican que el algoritmo ha en- contrado al vértice inferior en la lista de aristas del vértice superior y no había sido inspeccionado hasta ahora, por lo que se hizo una llamada recursiva. Las líneas de puntos corresponden a las aristas dirigidas hacia vértices que ya han sido visitados,por lo que la comprobación if de v i s i t a r ha fallado y la arista no ha «provocado» una llamada recursiva. Estos comentarios se aplican la pri- mera vez que se encuentra a cada arista; la comprobación if de v i s i t a r per- mite también evitar que se recorra la arista la segunda vez que se la encuentre, como se vio en la Figura 29.5. Una propiedad crucial de estos árboles de búsqueda en profundidad para grafos no dirigidos es que los enlaces de puntos siempre van desde un nodo a algún antecesor (otronodo del mismo árbol situado más alto en el camino hacia la raíz). En cualquier momento de la ejecución del algoritmo los vértices se di- viden en tres clases: aquellospara los que v i sit a r ha terminado, aquellos para los que ha terminado sólo parcialmente y aquellos que todavía no han sido en- contrados. Por la definición de v i s i t a r no se podrá encontrarjamás una arista apuntando hacia un vértice de la primera clase y si se encuentra una dirigida hacia un vértice de la tercera clase, se efectuará una llamada recursiva (por lo que la arista se representará con una línea gruesa en el árbol de búsqueda en profundidad). Los únicos vértices que quedan son los de la segunda clase, que son precisamente los situados en el camino desde el vértice actual al de la raíz del mismo árbol, y toda arista dirigida hacia uno cualquiera de ellos correspon- derá a un enlace de puntos del árbol de búsqueda en profundidad. Propiedad 29.1 La búsqueda en profundidad de un grafo representado con lis- tas de adyacencia necesita un tiempoproporcional a V +A. Se actualiza cada uno de los Vvalores de val (de ahí el término V) y se examina cada arista dos veces (de ahí el término A). Se podría encontrar un gafo (extre- madamente) denso con A < V,pero si no están permitidos los vértices aislados (se podría, por ejemplo, haberlos suprimido en una fase de preprocesamiento), es preferible pensar que el tiempo de ejecución de la búsqueda en profundidad es lineal en el número de aristav El mismo método básico se puede aplicar a los grafosrepresentadoscon ma- trices de adyacencia, utilizando el procedimiento v i s i t a r siguiente: v o i d v i s i t a r ( i n t k) // BP, m a t r i z de adyacencia
  • 484. 464 ALGORITMOS EN C++ { i n t t; val[k] = ++id; f o r ( t = 1; t <= V; t++) i f (a[k] [t] != O) i f (val [t] == novisto) v i s i t a r ( t ) ; 1 El recorrido a través de una lista de adyacencia se traduce en una exploración de las filas de la matriz de adyacencia, buscando valores iguales a 1(que corres- ponden a las aristas). Como anteriormente, cualquier arista dirigida hacia un vértice que todavía no ha sido visto se «recorre» por medio de una llamada re- cursiva. Ahora, las aristas conectadas a cada vértice se examinarán en un orden diferente, lo que proporciona un bosque de búsqueda en profundidad diferente, representado en la Figura 29.7. Esto confirma el hecho de que un bosque de búsqueda en profundidad no es más que otra representación del grafo, cuya es- tructura particular depende n la vez del algoritmo de búsqueda y de la represen- tación interna utilizada. Propiedad 29.2 La búsqueda enprofundidad de un grafo representado con una matriz de adyacencia necesita un tiempo proporcional a V2. La demostración de esta propiedad es trivial: se comprueba todo bit de la ma- triz de adyacencia.i Figura 29.7 Bosque de búsquedaen profundidad (representacióndel grafo por matriz). La búsqueda en profundidad resuelve directamente algunos problemas ele- mentales de procesamiento de grafos. Por ejemplo, el procedimiento se basa en encontrar las sucesivas componentes conexas: el número de componentes co- nexas es igual al número de veces que se llama a v i sit a r en la última línea del programa. El comprobar si un grafo tiene un ciclo es también una modificación trivial del programa anterior. Un grafo tiene un ciclo si, y sólo si, se descubre un nodo no novi Sto en v i s i t a r . Esto es, si se encuentra una arista que apunta a un vértice que ya se ha inspeccionado, entonces se tiene un ciclo. De forma
  • 485. ALGORITMOS SOERE GRAFOS ELEMENTALES 465 equivalente, todos los enlaces en línea punteada de los árboles de búsqueda en profundidad pertenecen a ciclos. Búsqueda en profundidad no recursiva La búsqueda en profundidad de un grafo es una generalizacióndel recorrido de árboles. Si el grafo es un árbol, es exactamente equivalente al recorrido del mismo; para los grafos, corresponde al recorrido del árbol que recubre al grafo y que se «descubre» durante el proceso de búsqueda. Como se ha visto, el árbol a recorrer depende de la forma como se representa el grafo. Se puede eliminar la recursión en la búsqueda en profundidad utilizando una pila, de la misma forma que se hizo para el recomdo del árbol del Capítulo 5. Para los árboles se encontró que la supresión de la recursión conducía a una implementación equivalente alternativa (relativamente simple) y también se descubrió un algoritmo de recorrido no recursivo (por niveles). Para los grafos se encontrará una evolución similar, que finalmente conducirá (en el Capítulo 3 1) a un algoritmo de recomdo de grafos de uso general. Sirviéndose de la experiencia del Capítulo 5, se puede dar directamente una imp!ementación basada en una pila: Pi1 a pi 1 a(maxV) ; void visitar(int k) //BP no recursiva, listas de adyacencia c i struct nodo *t; p i 1 a.meter(k) ; while (!pila.vacia()) k = pila.sacar(); val[k] = ++id; for (t = ady[k]; t != z; t = t->siguiente) { pila.meter(t->v); val[t->v] = -1; } { if (val [t-]VI ==novisto) Los vértices que han sido tocados, pero que todavía no han sido inspecciona- dos, se colocan en una pila. Para visitar a un vértice se recorren sus aristas y se mete en la pila cualquier vértice que no haya sido inspeccionado todavía y que no esté todavía en la pila. En la implementación recursiva, la «contabilidad» de los vértices «parcialmente inspeccionados»está oculta por la variable local t del procedimiento recursivo. Habría sido posible implementar esto directamente guardando los punteros (correspondientes a t) en los elementos de las listas de adyacencia, y así sucesivamente. En su lugar se ha extendido simplemente el
  • 486. 466 ALGORITMOS EN C++ Figura 29.8 Comienzo de la búsqueda basada en una pila. significado de las entradas de val para englobar a los vértices que ya están en la pila: los vértices con entradas de val iguales a novi sto no se han encontrado todavía (como antes), aquellos con entradas negativas de val están en la pila y aquellos con entradas de val entre 1y V ya han sido explorados (todaslas ans- tas de sus listas de adyacencia se han puesto en la pila). La Figura 29.8 representa la operación de este procedimiento de búsqueda en profundidad basado en pilas cuando se han inspeccionado los cuatro pri- meros nodos del grafo del ejemplo. Cada diagrama de esta figura corresponde a la inspección de un nodo: el nodo visitado se dibuja como un cuadrado y todas las aristas de sus listas de adyacencia están sombreadas. Como antes, los nodos que no se han encontrado todavía no tienen etiqueta y están sombreados, los nodos para los que la exploración ha terminado están etiquetados y no som- breados, y cada nodo está conectado por una arista en línea negra gruesa al nodo que ha provocado que se coloque en la pila. Los nodos que están todavía en la pila están dibujados con cuadrados. El primer nodo explorado es A: se recorren las aristas AF, AB, AC y AG y se colocan en la pila F, B, C y G. A continuación se saca de la pila a G (es el último nodo de la lista de adyacencia de A) y se recorren las aristas GA y GE, lo que se traduce en colocar E en la pila (A no está todavía en ella). Después se recorren EG, EF y ED y se mete a D en la pila, etc. La Figura 29.9 muestra el
  • 487. ALGORITMOS SOBRE GRAFOS ELEMENTALES 467 Figura 29.9 Contenido de la pila durante la búsquedabasadaen una pila. contenido de la pila durante la búsqueda y la Figura 29.10 muestra cómo con- tinúa el proceso de la Figura 29.8. El lector seguramente habrá notado que el programa precedente no inspec- ciona las aristas y nodos en el mismo orden que en la implementación recur- siva. Esto podría hacerse colocando las anstas en la pila en orden inverso y ma- nipulando de forma diferente el caso en el que se encuentra de nuevo a un nodo que ya está en la pila. En primer lugar, si se colocan en la pila las aristas de la lista de adyacencia de cada nodo en orden inverso al que aparecen en la lista, Figura 29.10 Fin de la búsqueda basadaen una pila.
  • 488. 468 ALGORITMOSEN C++ entonces se sacarán de la pila y se visitarán los nodos correspondientes en el mismo orden que en la implementación recursiva. (Éste es el mismo efecto que en el recorrido del árbol del ejemplo del Capítulo 5, en el que el subárbol dere- cho se coloca en la pila antes que el izquierdo en la implementación no recur- siva.) Una diferencia más importante es que el método basado en la pila técni- camente no es del todo un procedimiento de (búsqueda en profundidad)),dado que inspecciona el último nodo que se ha colocado en la pila, no el último nodo que se ha encontrado, como es el caso de la búsqueda en profundidad recursiva. Esto se puede restablecer moviendo hacia la parte superior de la pila los nodos que están en ella, según se van redescubriendo,pero esta operación necesita una estructi;ra de datos más sofisticada que una pila. En el Capítulo 31 se exami- nará una forma más simple de implementar esto. Búsqueda en amplitud De igual forma que en el recorrido del árbol (ver Capítulo 4 ) ,se puede utilizar una cola, en vez de una pila, como estructura de datos para almacenar vértices. Esto conduce a un segundo algoritmo clásico de recorrido de grafos, denomi- nado búsqueda en amplitud. Para implementar la búsqueda en amplitud, se cambian las operaciones de pila por operaciones de cola en el programa de bús- queda anterior: Cola cola(maxV); void visitar(int k) / / BA, listas de adyacencia { struct nodo *t; col a,poner(k) ; while (!cola.vacia()) i k = cola.obtener()ccl[k] = ++id; for (t = ady[k]; t != z; t = t->siguiente) if (val [t->VI == novisto) { cola.poner(t->v) ; val [t->v] = -1; } 1 1 El hecho de cambiar la estructura de datos de esta fama afecta el orden de ins- pección de los nodos. En el grafo pequeño del ejemplo las aristas se visitan en el orden AF AC AB A G FA FE FD CA BA GE GA DF DE EG EF ED HI IH JK JL JM KJ LJ LM MJ ML. El contenido de la cola durante el recomdo se muestra en la Figura 29.1 1.
  • 489. ALGORITMOS SOBRE GRAFOS ELEMENTALES 469 1HI @I I m E l El El 1AJ IJI Figura 29.11 Contenido de la cola durante la búsquedaen amplitud. Como en la búsqueda en profundidad, se puede definir un bosque a partir de las aristas que conducen por primera vez a cada nodo, como se muestra en la Figura 29.12. La búsqueda en amplitud corresponde a recorrer los árboles de este bosque por orden de niveles. En los dos algoritmos se puede imaginar que los vértices están divididos en tres clases: vértices del árbol (o visitados), aquellos que se han retirado de la es- tructura de datos; vértices del margen, que son adyacentes a los vértices del ár- bol, pero que no se han inspeccionado todavía, y vértices no vistos, que no se han encontrado todavía. Si cada vértice del árbol está conectado a la arista que ha provocado su inserción en la estructura de datos (las aristas de trazo negro grueso de las Figuras 29.8 y 29.lo), entonces estas aristas forman un árbol. Para buscar de forma sistemática una componente conexa de un gafo (im- plementando un procedimiento visitar), se comienza por un vértice del margen, siendo todos los otros no vistos, y se lleva a cabo la acción siguiente hasta que se hayan inspeccionado todos los vértices: «se transporta un vértice x desde el margen al árbol, y se colocan en el margen todos los vértices no vistos adyacen- tes a m. Los métodos de recorrer los grafos difieren en la forma como deciden qué vértice debe pasar desde el margen hacia el árbol. En la búsqueda en pro- Figura 29.12 Bosque de búsquedaen amplitud.
  • 490. 470 ALGORITMOS EN C++ Figura 29.13 Búsqueda en profundidaden un gran grafo. fundidad se desea elegir el vértice del margen que se ha encontrado más recien- temente; esto corresponde al empleo de una pila para almacenar los vértices del margen. En la búsqueda en amplitud se desea elegir el vértice del margen que se ha encontrado menos recientemente; esto corresponde al empleo de una cola para almacenar los vértices del margen. En el Capítulo 31 se verá el efecto de utilizar una cola deprioridad para el margen. El contraste entre las búsquedas en profundidad y en amplitud se hace más evidente cuando se considera un gran grafo. La Figura 29.13 muestra el proceso de búsqueda en profundidad en un gran grafo, al tercio y a los dos tercios de su realización; la Figura 29.14 es la descripción correspondiente a la búsqueda en amplitud. En estos diagramas, los vértices y aristas del árbol están en negro, los vértices no vistos están sombreados y los vértices del margen están en blanco. Figura 29.14 Búsqueda en amplitud en un gran grafo.
  • 491. ALGORITMOS SOBRE GRAFOS ELEMENTALES 471 Figura 29.15 Un laberinto y el grafo asociado. En ambos casos el recomdo comienza en el nodo inferior izquierdo. La bús- queda en profundidad «se sumerge» en el grafo, almacenando en la pila los puntos de los que emergen otros caminos; la búsqueda en amplitud «barre»el grafo, utilizando una cola para memorizar la frontera de los lugares ya visita- dos. La búsqueda en profundidad «explora»el grafo buscando nuevos vértices lejos del punto de partida, tomando los vértices próximos solamente cuando se encuentre un callejón sin salida;la búsqueda en amplitud cubre completamente la zona cercana al punto de partida, alejándose solamente cuando todos los ve- cinos han sido vistos. Una vez más, el orden en el que se visitan los nodos de- pende fuertemente del orden en el que aparecen las aristas en la entrada y de los efectos de esta ordenación en la forma como aparecen los vértices en las lis- tas de adyacencia. Más allá de estas diferenciasoperativas, es interesante reflejar las diferencias fundamentales que existen entre las implementaciones de estos métodos. La búsqueda en profundidad se expresa simplemente de forma recursiva (dado que la estructura de datos subyacente es una pila) y la búsqueda en amplitud admite una implementación no recursiva (dado que la estructura de datos subyacente es una cola).En el Capítulo 3 1 se verá que la estructura de datos subyacente de los algoritmos sobre grafos es realmente una cola de prioridad, lo que implica una abundancia de interesantes propiedades y algoritmos. Laberintos Esta forma sistemática de visitar cada vértice y arista de un gafo tiene una his- toria muy particular: la búsqueda en profundidad fue expuesta formalmentehace
  • 492. 472 ALGORITMOS EN C++ centenares de años como un método para recorrer laberintos. Por ejemplo, en la parte izquierda de la Figura 29.15 se representa un popular laberinto, y a la derecha el grafo construido al colocar un vértice en cada punto en el que existe más de un camino a tomar y al conectar a continuación los vértices dc acuerdo con esos caminos. Este laberinto es claramente más complicado que los de los antiguosjardines ingleses, que estaban construidos como caminos rodeados de grandes setos. En estos laberintos todos los muros estaban conectados a otros muros, por lo que damas y caballerospodían pasear por ellos, y los más inteli- gentes de ellos podían encontrar la salida siguiendo simplemente el muro de su mano derecha (los ratones de laboratorio han aprendido estos trucos). Cuando pueden existir paredes interiores independientes se necesita una estrategia más sofisticada,lo que conduce a una búsqueda en profundidad. Para desplazarse de un lugar a otro del laberinto por medio de una bús- queda en profundidad se utiliza v i sitar partiendo del vértice del grafo que co- rresponde al punto de partida. Cada vez que v i s itar «sigue» una arista por medio de una llamada recursiva, se marcha a lo largo del camino correspon- diente del laberinto. El truco consisteen retroceder por el camino que se ha uti- lizado para llegar a cada vértice una vez que v i s itar ha terminado con dicho vértice. Así se vuelve al vértice situado justo un nivel por encima en el árbol de búsqueda en profundidad, y se está preparado para seguir por la arista adya- cente. (Este proceso reproduce fielmente el recorrido de la búsqueda en profun- didad de un grafo.) La búsqueda en profundidad es apropiada para la búsqueda de un elemento del laberinto por una sola persona, dado que el «siguiente lugar a visitan) está siempre próximo; la búsqueda en amplitud es más apropiada para un grupo de personas que buscan el mismo elemento desplazándose en todas las direcciones a la vez. Perspectivas En los capítulos siguientes se verá una serie de algoritmos sobre grafos enfoca- dos principalmente a la determinación de las propiedades de la conectividad re- lativas a los grafos, dirigidos o no. Estos algoritmos son fundamentales para el tratamiento de grafos, pero son solamente una introducción al tema de los al- goritmos sobre grafos. Se han desarrollado muchos algoritmos útiles e intere- santes que están fuera del alcance de este libro y se han estudiado muchos pro- blemas interesantes para los que no se ha conocido ningún algoritmo eficaz. Algunos de los algoritmosque se han desarrollado son demasiado complejos para presentarlos aquí. Por ejemplo, es posible determinar de forma eficaz si un grafo puede representarse (o no) en un plano sin que haya ninguna intersección entre sus líneas. Este problema, denominado de planaridad, ha debido esperar hasta 1974 para cmocer un algoritmo que lo resuelva. Fue R.E. Tarjan quien en ese ano desarrolló un ingenioso algoritmo (aunque muy complejo) para re- solver el problema en tiempo lineal, utilizando la búsqueda en profundidad.
  • 493. ALGORITMOS SOBRE GRAFOS ELEMENTALES 473 Algunos problemas sobre grafos que se encuentran de forma natural y son fáciles de formular, parecen de dificil resolución y no existen algoritmos cono- cidos para resolverlos. Por ejemplo, no se conoce ningún algoritmo eficaz para encontrar el camino de coste mínimo que explora todos los vértices de un grafo ponderado. Este problema, denominado el problema del vendedor ambulante, pertenece a una amplia clase de problemas difíciles que se presentarán con más detalle en el Capítulo 45.La mayor parte de los expertos están convencidos de que no existe ningún algoritmo eficaz para estos problemas. Otros problemas sobre grafos pueden contar con algoritmos eficaces, aun- que no se haya encontrado todavía ninguno. Un ejemplo de ellos es el problema del isomorfismo de grafos en el que se desea conocer si es posible identificar a dos grafos renombrando simplemente sus vértices. Se conocen algoritmos efi- caces para este problema en muchos tipos particulares de grafos, pero el pro- blema general permanece abierto. En resumen, existe un amplio espectro de problemas y algontmos para tra- tar a los grafos. Ciertamente no se puede esperar resolver todos los problemas que se encuentren y, al contrario, algunos problemas que parecen simples to- davía causan confusión en los expertos. Pero io normal es que aparezcan con frecuenciaun cierto número de problemas relativamente simples, y, además, los algontmos sobre grafos que se estudiarán en este libro serán muy útiles en una gran variedad de aplicaciones. . Ejercicios 1. ¿Qué representación de grafo no dirigido es más apropiada para determinar rápidamente si un vértice está aislado (no conectado a ningún otro vértice) o no lo está? 2. Supóngase que se utiliza una búsqueda en profundidad sobre un árbol bi- nailo de búsqueda y que la arista derecha se toma antes que la izquierda, al salir de cada nodo. ¿En qué orden se visitarán los nodos? 3. iCuántos bits de almacenamiento se necesitan para representar la matriz de adyacencia de iin grafo no dirigido de V nodos y A aristas?¿Cuántosbits se necesitan para la representación con listas de adyacencia? 4. Dibujar un grafo que no pueda representarse en una hoja de papel sin que dos de sus aristas se crucen. 5. Escribir un programa para suprimir una arista de un grafo representadocon listas de adyacencia. 6. Escribir una versión de 1istaady que guarde las listas de adyacenciaen una ordenación según los índices de los vértices. Analizar las ventajas de esta estrategia. 7. Dibujar el bosque de búsqueda en profundidad que se obtiene del ejemplo del texto cuando el procedimiento buscar explora los vértices en orden in- verso (de V a 1) para las dos representaciones.
  • 494. 474 ALGORITMOS EN C++ 8. ¿Cuántas veces se debe invocar exactamente a v i s i tar en una búsqueda en profundidad en un grafo no dirigido?Determinar lo anterior en función del número de vértices V,del número de aristas A y del número de com- ponentes conexas C. 9. Presentar las listas de adyacencia obtenidas si las aristas del grafo del ejem- plo se leen en orden inverso al que se utilizó para hacer la estructura de la Figura 29.4. 10. Presentar el bosque de búsqueda en profundidad para el gafo del ejemplo del texto cuando la rutina recursiva se utiliza en las listas de adyacencia del ejercicioanterior.
  • 495. 30 Conectividad El procedimiento fundamental de búsqueda en profundidad del capítulo ante- rior encuentra las componentes conexas de un grafo dado; en este capítulo se examinarán los algoritmos relacionados y los problemas que conciernen a otras propiedades de la conectividad de los grafos. Después de haber visto algunas aplicacionesdirectas de la búsqueda en pro- fundidad para obtener información de conectividad, se examinará una genera- lización de la conectividad denominada biconectividad, cuyo interés reside en conocer si hay más de un medio de pasar de un vértice de un grafo a otro. Un gafo es biconexo si, y sólo si, existen al menos dos caminos diferentes que co- necten cada par de vértices. De esta forma, si se suprime un vértice y todas las aristas que inciden en él, el grafo permanece conexo. Si para algunas aplicacio- nes es importante que un grafo sea conexo, es también importante que perma- nezca conexo. La solución a este problema es un algoritmoverdaderamente más complejo que los algoritmos de recorrido del capítulo anterior, aunque se base también en la búsqueda en profundidad. Una versión particular del problema de la conectividad, que con frecuencia concierne a la situación dinámica en la que las aristas se añaden al grafo una a una, intercalando preguntas sobre si dos vértices determinados pertenecen (o no) a la misma componente conexa. Este problema se ha estudiado profundamente, y en este libro se examinarán con detalle dos algoritmos «clásicos»relacionados con él. Estos métodos no solamente son sencillos y de aplicación general, sino que también muestran la gran dificultad que puede existiral analizar algoritmos simples. El problema se denomina a veces como ((unión-pertenencia),una no- menclatura que se deriva de la aplicación de los algoritmos al tratamiento de operaciones simples en conjuntos de elementos. 475
  • 496. 476 ALGORITMOS EN C++ Componentes conexas Cualquier método de recorrido de grafos del capítulo anterior puede utilizarse para encontrar las componentes conexas de un grafo, dado que todos se basan en la misma estrategia general de visitar todos los nodos de una componente conexa antes de trasladarse a la siguiente. Una forma sencilla de listar las com- ponentes conexas es modificar uno de los programas de búsqueda recursiva en profundidad para que vi si tar enumere el vértice que se acaba de visitar (es decir, imprimiendo nombre(k) justo antes de acabar), y dando a continuación alguna indicación del comienzo de una nueva componente conexa justo antes de la llamada (no recursiva) a vi s itar en buscar. Esta técnica produciría la siguiente salida cuando se utiliza la búsqueda en profundidad (buscar y la ver- sión de lista de adyacenci a de vi sitar del Capítulo 29) en el grafo del ejem- plo (Figura 29.1): G D E F C B A I H K M L J Otras variantes, como la versión de matriz de adyacencia de vi s i tar, la bús- queda en profundidad basada en una pila y la búsqueda en amplitud, pueden calcular las mismas componentes conexas (por supuesto), pero los vértices se imprimirán en un orden diferente. Es fácil obtener extensiones para hacer más complejo el procesamiento de componentes conexas. Por ejemplo, insertando simplemente inval [i d]=k des- pués de la instrucción val [k]=id se obtiene la «inversa» del array val, cuya i d-ésima entrada es el índice del i d-ésimo vértice explorado. Los vértices de la misma componente conexaestán contiguos en este array;el índice de cada nueva componente conexa está dado por el valor de i d cada vez que se llama a vi - si tar en buscar. Estos valores pueden almacenarse o utilizarse como delimi- tadores en i nval (por ejemplo, la primera entrada de cada componente conexa podría hacerse negativa). k 1 2 3 4 5 6 7 8 9 10 11 12 13 nombre[k] A B C D E F G H I J K L M val [k] 1 7 6 5 3 2 4 8 9 10 11 12 13 inval[k] -1 6 5 7 4 3 2 -8 9 -10 11 12 13 Figura 30.1 Estructurasde datos para componentesconexas. La Figura 30.1 muestra los valores tomados por estos arrays del ejemplo si la versión lista de adyacencia de buscar se modificara de esta manera. Nor- malmente merece la pena utilizar tales técnicas para dividir un grafo en sus componentes conexas y más adelante procesarlo por medio de algontmos más
  • 497. CONECTIVIDAD 477 Figura 30.2 Un grafo que no es biconexo. sofisticados,de forma que se les liberede los detallesde tratar con componentes no conexas. Biconectividad A veces es útil diseñar más de una ruta entre puntos de un grafo, aunque s6lo sea para identificar posibles fallos en los puntos de conexión (vértices)..4sí se puede volar desde Providence a Princeton, aunque New York esté cerrado por nieve, sin tener que ir por Philadelphia. Las principales líneas de conexión de un circuito integrado son a menudo biconexas, por lo que el resto de1 circuito puede continuar funcionando si falla uno de los componentes. Otra aplicación (no muy realista, pero que da una ilustración natural del concepto)es la de ima- ginar una situación de guerra en la que se obliga al enemigo a bombardear al menos dos estacionespara poder cortar las líneas de ferrocarril. Un punto de articulación en un grafo conexo es un vértice que si se sUprime romperá el grafo en dos o más piezas. De un grafo que no tiene puntos de arti- culación se dice que es biconexo. En un grafo biconexo, cada par de vértices están conectados por dos caminos distintos. Un grafo que es no biconexo se di- vide en componentes biconexas, conjuntos de nodos accesibles mutuamente por medio de dos caminos distintos. En la Figura 30.2 se muestra un grafo que es conexo pero no biconexo. (Este grafo se ha obtenido del correspondiente al del capítulo anterior ariadiendo las aristas GC, GH, JG y LG. En los ejemplos se supone que estas cuatro aristas se han añadido al final de los datos de entrada y en el orden anterior, de tal forma, por ejemplo, que las listas de adyaceirciasean similares a las de la Figura 29.4 con ocho nuevas entradas correspondientes a ias cuatro nuevas aristas.) Los puntos de articulación de este grafo son A (dado que conecta a B con el resto del grafo), H (al conectar a I con el resto del grafo), J (que conecta a K con el
  • 498. 478 ALGORITMOS EN C++ Figura 30.3 Búsqueda en profundidaden la biconectividad. resto del grafo) y G (dado que el gafo se rompena en tres piezas si se borrara G). Existen seis componentes biconexas: {A C G D E F}, {GJ L M},y los nodos individuales B, H, I y K. La determinación de los puntos de articulación resulta ser una simpleexten- sión de la búsqueda en profundidad. Para comprobarlo, considérese el árbol de búsqueda en profundidad de este grafo, mostrado en la Figura 30.3. Borrando el nodo E, no se desconecta el grafo, dado que G y D tienen ambos enlaces de puntos por encima de E, que proporcionan caminos alternativos para ir de di- chos vértices a F (el padre de E en el árbol). Por el contrario, borrando G se desconecta el grafo porque no existen caminos alternativos para ir de L o H a E (que es el padre de G). Un vértice x no es un punto de articulación si cada uno de sus hijos y tiene algún nodo descendiente conectado (por medio de un enlace de puntos) a un nodo más alto que x en el árbol, que proporciona una conexión alternativa en- tre x e y. Esta prueba no es válida al trabajar en la raíz del árbol de búsqueda en profundidad, porque este nodo no tiene {(ascendientesen el árbol». La raíz es un punto de articulaciónsi tiene dos o más hijos, dado que el único camino para conectar a hijos de la raíz pasa por ella misma. Estas pruebas se incorporan fácilmente a la búsqueda en profundidad transformando el proce- dimiento de exploración de nodos en una función que devuelva el punto más alto del árbol (valor inferior de val) que se haya encontrado durante la bús- queda, de la siguiente forma: int v i s i t a r ( i n t k) // BP para encontrar puntos de articulación struct nodo *t; i n t m, min; val [k] = ++id; min = id; f o r (t = ady[k]; t != z; t = t->siguiente) {
  • 499. CONECTIVIDAD 479 ~~ ~ ~~ ~ i f (val [t->v] = novi sto) m = visitar(t->v); i f (m < min) min = m; i f (m >= val[k]) cout <<nombre(k); { 1 else i f (val [t->VI< min) min = val [t->VI ; return min; 1 Este procedimiento determina recursivamente el punto más alto de árbol acce- sible (por medio de un enlace de puntos) desde cualquiera de los descendientes del vértice k, y utiliza esta información para determinar si k es un punto de arti- culación. Normalmente este cálculo no implica más que comprobar si el valor mínimo accesible desde un hijo está más alto en el árbol, o no lo está. Sin em- bargo, se necesita una prueba extra para determinar si k es la raíz de un árbol de búsqueda en profundidad (o, de forma equivalente, si se trata de la primera llamada a vi s it a r para la componente conexa que contiene a k),ya que se uti- liza en ambos casos el mismo programa recursivo. Es conveniente llevar a cabo esta prueba fuera del vi s itar recursivo y así no aparecerá en el código que se acaba de mostrar. Propiedad 30.1 Las componentes biconexas de un grafo pueden determinarse en tiempo lineal. Aunque el programa anterior no hace más que imprimir los puntos de articu- lación, es fácil ampliarlo, como en el caso de las componentes conexas, para que efectúe un tratamiento adicional de los puntos de articulación y de las com- ponentes conexas. Al ser un procedimiento de búsqueda en profundidad, el tiempo de ejecución es proporcional a V +A .(Un programa similar, basado en una matriz de adyacencia, se ejecutaría en O(V2)pasos.)i Además de los tipos de aplicacionesmencionados anteriormente, en las que las biconectividades se han utilizado para mejorar la fiabilidad, pueden ser de una gran ayuda en la descomposición de grafos muy grandes en varias partes más manejables. Es obvio que en muchas aplicacionespuede procesarse un grafo muy grande, haciéndolo componente conexa a componente conexa, pero a ve- ces es más práctico, aunque sea menos evidente, el poder procesar un grafo componente biconexa a componente biconexa. Algoritmos de unión-pertenencia En algunas aplicaciones se desea simplemente conocer si un vértice x está o no conectado a un vértice y de un grafo, sin que sea importante el camino que los
  • 500. 480 ALGORITMOS EN C++ conecta de hecho. Este problema se ha estudiado cuidadosamente en los últi- mos años: los eficaces algoritmos que se han desarrollado son interesantes por sí mismos dado que también pueden utilizarsepara el procesamiento de conjun- tos (coleccionesde objetos). Los grafos se corresponden de forma natural con estos conjuntos: los vértices representan a los objetos y las aristas significan «está en el mismo conjunto que...». Así, el grafo ejemplo del capítulo anterior corres- ponde a los conjuntos {A B C D E F G},{H I}y {JK L M}. Otro término para definir estos conjuntos es el de clases de equivalencia. Cada componente conexa corresponde a una clase de equivalencia diferente. El añadir una arista se co- rresponde con la combinación de las clases de equivalencia representadas por los vértices a conectar. El interés se centra en la pregunta fundamental «¿es x equivalente a y?» o «¿está xen el mismo conjunto que y?». Esto se corresponde claramente con la pregunta fundamental de los grafos «¿está el vértice x conec- tado al vértice y?». Dado un conjunto de aristas,se puede construir una representación por lista de adyacencias que corresponda al gafo y utilizar la búsqueda en profundidad para asignar a cada vértice el índice de su componente conexa, y así preguntas tales como «¿estáx conectada a y?» pueden responderse con dos accesosa arrays y una comparación. La característica suplementaria de los métodos que se con- siderarán aquí es que son dinámicos: pueden aceptar nuevas aristas mezcladas arbitrariamente con preguntas y contestar correctamente a las preguntas utili- zando la información recibida. Por correspondencia con el problema de los conjuntos, la adición de una nueva arista se denomina una operación de unión, y las preguntas se denominan operaciones de pertenencia. El objetivo es escribir una función que pueda verificar si dos vértices x e y pertenecen al mismo conjunto (o, en representación de grafos, a la misma com- ponente conexa) y, en caso de que sea así, que pueda unirlos en el mismo con- junto (colocando una arista entre ellos y el grafo). En lugar de construir una lista de adyacencia directa o cualquier otra representación de los grafos, es más eficaz utilizar una estructura interna orientada específicamentea la realización de las operaciones union y pertenencia. Esta estructura interna es un bosque de árboles, uno por cada componente conexa. Se necesita poder encontrar si dos vértices pertenecen al mismo árbol y combinar dos árboles en uno. Por for- tuna ambas operaciones pueden implementarse eficazmente. Para ilustrar el funcionamiento del algoritmo, se examina el bosque que se obtiene cuando las aristas del grafo del ejemplo de la Figura 30.1 se procesan en el orden AG AB AC LM JM JL JK ED FD HI FE AF GE GC GH JG LG. En la Figura 30.4 se muestran los siete primeros pasos. Inicialmente, todos los no- dos están en árbolesseparados.A continuación la arista AG provoca la creación de un Arbol dos-nodos de raíz A. (La elección es arbitraria -igualmente se po- dría haber tomado como raíz a G-.) Las aristas AB y AC añaden a B y C al árbol de la misma I'orma. A continuación las aristas LM, JM, JL y JK constru- yen un árbol que contiene a J, K, L y M, que tiene una estructura ligeramente diferente (es de destacar que JL no contribuye con nada, dado que LM y JM colocan a L y J en el mismo componente).
  • 501. CONECTIVIDAD 481 Figura 30.4 Etapasiniciales de unión-pertenencia. La Figura 30.5 muestra el fin del proceso. Las aristas ED, FD y HI generan dos árboles más, quedando un bosque con cuatro árboles. Este bosque indica que las aristas procesadas hasta este momento describen un grafo con cuatro componentes conexas, o, de forma equivalente, que las operaciones de unión de conjuntos efectuadas hasta el momento conducen a cuatro conjuntos {A €3 C G}, {J K L M}, {DE F}y {H I}. Ahora la arista FE no contribuye con nada a la estructura, dado que F y E están en la misma componente, pero la arista AF combina los dos primeros árboles; a continuación GE y GC tampoco contri- buyen con nada, pero GH y JG reúnen todo el bosque en un solo árbol. Se debe enfatizar que, a diferencia de los árboles de búsqueda en profundi- dad, la única relación entre estos árboles de unión-pertenencia y el grafo aso- ciado con las aristas dadas es que aquéllos dividen de forma análoga a los vér- tices en conjuntos. Por ejemplo, no hay correspondencia entre los caminos que conectan los nodos de los árboles y los que conectan los nodos de los grafos. ¿Qué estructura de datos debe elegirse para mantener estos bosques? Aquí sólo se recorren los árboles hacia arriba, nunca hacia abajo, por lo que es apropiada la representación de «enlace padre» (ver el Capítulo 4). Específicamente, se mantiene un array padre que contiene, para cada vértice, el índice de su padre (O si es la raíz de algún árbol), como se especifica en la siguiente declaración clase: clase EQ {
  • 502. 482 ALGORITMOS EN C++ Figura 30.5 Final de unión-pertenencia. private: public: int *padre; EQ(int t a l l a ) ; int pertenencia(int x, int y, int union); 1; El constructor asigna espaciopara el array padre e inicialmente coloca todos sus valores a cero (aquí se omite el código).Se utiliza un sencillo procedimiento pertenenci a para implementarlas dos operaciones, unión y pertenencia. Si el tercer argumentoes O se tiene solamente una pertenencia;si es distinto de cero, se tiene una unión. Para encontrar el padre de un vértice j, simplemente se es- tablece j = padre [j ] y para encontrar la raíz del árbol que contiene a j se re-
  • 503. CONECTIVIDAD 483 pite esta operación hasta obtener O. Esto conduce a una implementación integra del procedimiento pertenencia: i n t EQ::pertenencia(int x, int y, int unión) int i = x, j = y; while (padre[i] > O) i = padre[i]; while (padre[j] > O) j = padre[j]; if (union && (i != j ) ) padre[j] = i; return (i != j ) ; { 1 La función pertenenci a devuelve O si los dos vértices dados son de la misma componente. Si no es así y el indicador uni on está activado, se colocarán dichos vérticesen la misma componente. El método es simple: se utiliza un array pa- dre para obtener la raíz del árbol que contiene a cada vértice, y a continuación se comprueba que las dos raíces son idénticas. Para fusionar el árbol de raíz j con el de raíz i, se pone simplemente padre [j ] = i . La Figura 30.6 muestra el contenido de la estructura de datos durante el proceso. Como de costumbre, se supone que las funciones i ndice y nombre permiten la traducción de los nombres de los vértices en enteros entre 1 y V: cada entrada de la tabla es el nombre de la correspondiente entrada del array padre. Por ejemplo, después de declarar una instancia de la clase EQ (con la declaración EQ eq (max),se comprobará si un vértice denominado x está en la misma componente que un vértice denominado y (sin la introducción de una arista entre ellos) llamando a la función eq .pertenenci a (indi ce(x) , in- dice(y), O). Esta clase está codificadade forma que puede usarse en cualquier aplicación en la que las clases de equivalencia tengan un papel importante, no sólo en la conectividad de grafos. El único requisito es que los elementos del conjunto tengan nombres enteros que sirvan como índices (desde 1 hasta V). Como ya se dijo, se pueden usar las implementaciones de diccionario anteriores para que así sea. El algoritmo descrito anteriormente tiene un mal comportamiento en el peor caso porque los árboles construidos pueden estar degenerados. Por ejemplo, si se toman las aristas en el orden AB BC CD DE EF FG GH Hi IJ ... YZ se ob- tiene una gran cadena en la que Z apunta a Y ,Y apunta a X, etc. Para construir este tipo de estructura se necesita un tiempo que es proporcional a V2,y para una comprobación de equivalencia, un tiempo proporcional a V. Para resolver este problema se han propuesto diversastécnicas. Un método natural, que posiblemente ya se le haya ocumdo al lector, es intentar hacer lo más «razonable» cuando se mezclsn dos árboles, en lugar de poner arbitraria- mente padre[j] = i. Cuando se va a mezclar un árbol de raíz i con otro de raíz j, uno de los nodos debe permanecer como raíz y el otro (y todos sus des-
  • 504. 484 ALGORITMOS EN C++ A B C D E F G H C J K L M Figura 30.6 Estructurade datos unión-pertenencia. cendientes) debe bajar un nivel en el árbol. Para hacer mínima la distancia en- tre la raíz y la mayor parte de los nodos, parece lógico elegir como raíz el nodo que tenga el mayor número de descendientes. Esta idea, denominada equili- brado depeso, se implementa fácilmente manteniendo el tamaño de cada árbol (número de descendientes de la raíz) en la entrada del array padre, para todos los nodos raíz, codificando como números negativos a todos los nodos raíz que puedan detectarse ascendiendo por el árbol en pertenencia. Lo ideal sería arreglárselaspara que todas los nodos apuntaran directamente a la raíz de su árbol. Sin embargo, independientemente de la estrategia a utili- zar, para lograr este ideal habría que examinar al menos todos los nodos de uno de los dos árboles a fusionar, y esto podría representaruna cantidad muy grande comparada con los relativamente pocos nodos situados en el camino hacia la raíz que suele examinar pertenenci a. Pero se puede hacer una aproximación al ideal haciendo que todos los nodos que se examinan japunten hacia la raíz! A primera vista esta operación parece ser muy drástica, pero es muy fácil de
  • 505. CONECTIVIDAD 485 hacer, y no hay nada sagrado en lo que respecta a la estructura de estos árboles: si es factible modificarlos para hacer que el algoritmo sea más eficaz, debe ha- cerse. Este método, denominado compresión de caminos, es fácil de implemen- tar haciendo otra pasada a través de cada árbol, después de haber encontrado la raíz, y fijando la entrada padre de cada vértice que se ha encontrado a lo largo del camino que apunta a la raíz. La combinación de los métodos de equilibrado de peso y compresión de ca- minos asegura que los algoritmos se ejecuten más rápidamente. La siguiente implementación muestra que el código extra es un precio a pagar muy pequeño para prevenirse de los casos degenerados. i n t EQ::pertenencia(int x, i n t y , i n t union) int i = x, j = y; while (padre[i] > O) i = padre[i]; while (padre[j] > O) j = padre[j]; while (padre[x] > O) while (padre[y] > O) i f (unión && ( i != j ) ) { { t = x; x = padre[x]; padre[t] = i ; } { t = y; y = padre[y]; padre[t] = j ; } i f (padre[j] < padre[i]) el se { padre[j] += padre[i] - 1; padre[i] = j ; } { padre[i] += padre[j] - 1; padre[j] = i ; } return (i != j ) ; 1 La Figura 30.7 muestra los primeros ocho pasos de la aplicación del método a los datos del ejemplo y la Figura 30.8 muestra el final del proceso. La longitud media de camino del árbol resultante es 31/13 = 2,38, a comparar con el valor de 38/13 = 2,92 de la Figura 30.5. Para las cinco primeras aristas el bosque resultante es el mismo que el de la Figura 30.4; sin embargo, las tres últimas aristas producen un árbol «plano» que contiene a J, K, L y M a causa de la regla del equilibrado de peso. Los bosques de este ejemplo son tan planos que todos los vértices implicados en las operaciones de unión están en la raíz o justo de- bajo -no se utiliza la compresión de caminos (ya que podría hacer que los ár- boles fueran todavía más planos)-. Por ejemplo, si la última unión fue FJ y no GL, entonces al final F acabaría siendo un hijo de A. La Figura 30.9 proporciona el contenido del array padre según se va cons- truyendo el bosque. Para mayor claridad de la tabla, cada entrada positiva i se ha reemplazado por la i-ésima letra del alfabeto (el nombre del padre) y a cada entrada negativa se ha aplicado el complemento para obtener un entero posi- tivo (el peso del árbol).
  • 506. 486 ALGORITMOS EN C++ Figura 30.7 Primeros pasos de unión-pertenencia(equilibrado,con compresión de caminos). Se han desarrollado otras muchas técnicas para evitar las estructuras dege- neradas. Por ejemplo, la compresión de caminos tiene el inconveniente de ne- cesitar una segunda pasada a través del árbol. Otra técnica, denominada divi- sión, hace que cada nodo apunte a su abuelo en el árbol. Otra más, la escisión, es como la anterior, pero se aplica solamente a uno de cada dos nodos del ca- mino de búsqueda. Cualquiera de estas técnicas puede utilizarseconjuntamente con el equilibrado de peso o con el equilibrado de altura, que es similar pero utiliza la altura del árbol para decidir cómo fusionar los árboles. ¿Qué método se debe elegir entre todos éstos? y ¿hasta que punto son «pla- nos» los árbolesgenerados? El análisisde este problema es muy difícil dado que el rendimiento depende no solamente de los parámetros V y A, sino también del número de operaciones de pertenencia y, lo que es peor, del orden en que se efectúan las operaciones de unión y pertenencia. A diferencia de la ordenación, en la que los archivos reales que aparecen en la práctica son a menudo casi «aleatonos»,es difícil ver los modelos de grafos y consultas que pueden apare- cer en la práctica. Por esta razón los algoritmos que tienen un buen comporta- miento en el peor caso se prefieren a los de unión-pertenencia (y a otros algo- ritmos sobre grafos), aunque esto puede ser un enfoque superconservador. Incluso si sólo se considera el peor caso, el análisisde los algoritmos de unión- pertenencia es extremadamentecomplejo e intrincado. Esto puede deducirse de la naturaleza de los resultados, que sin embargodan una idea clara del compor- tamiento de los algoritmos en una situación práctica.
  • 507. CONECTIVIDAD 487 El Figura 30.8 Final de unión-pertenencia(equilibrado,con compresión de caminos). Propiedad 30.2 Si se utiliza un equilibrado depeso o de altura conjuntamente con la compresión, división o escisión, el número total de operaciones necesarias para la construcción de una estructura utilizandoA aristas es casi (perono exac- tamente) lineal. Con más precisión, el número de operacionesnecesariases proporcionala Aa(A), donde a(A)es una función que crece tan lentamente que a(A)<4,a menos que A sea inferior a un valor tan grande que cuando se tome lgA, a continuación se tome el Ig del resultado, y así otra y otra vez, repitiéndolo 16 veces, todavía se obtiene un número imayor que l! Este número es increíblemente grande: es bastante seguro suponer que la media de tiempo de ejecución de cada operación de unión y pertenencia es constante. Este resultado se debe a R. E. Tajan, quien además demostró que ningún algoritmo para este problema (dentro de una clase
  • 508. 48% ALGORITMOS EN C++ A B C D E F G H I J K L M Figura 30.9 Estructurade datos de unión-pertenencia(equilibrada,con compresiónde caminos). general de ellos) puede hacerlo mejor que Aa(A),por lo que esta función es in- trínseca al prob1ema.i Una importante aplicación práctica de los algoritmos de unión-pertenencia es determinar si un grafo de V vértices y A aristas es conexo en tiempo propor- cional a V (y casi en tiempo lineal). Ésta es una ventaja sobre la búsqueda en profundidad en algunoscasos: aquí no se necesita almacenar las aristas. Por ello la conectividad de un grafo con miles de vértices y millones de aristas puede determinarse por medio de uEa pasada rápida a través de las aristas.
  • 509. CONECTIVIDAD 489 Ejercicios 1. Encontrar los puntos de articulación y las componentes biconexas del grafo 2. Dibujar el árbol de búsqueda en profundidad del grafo descrito en el ejer- 3. ¿Cuál es el níimero mínimo de aristas que se necesitan para construir un 4. Escribir un programa para imprimir las componentesbiconexas de un grafo. 5. Dibujar e ¡ bosque de unión-pertenencia construidopara el ejemplo del texto, pero suponiendo que pertenencia se cambia para poner a[i]=j en lugar de a[j]=i. 6. Resolver el ejercicio anterior, suponiendo además que se utiliza la compre- sión de caminos. 7. Dibujar el bosqur de unión-pertenencia construido por las aristas AB BC CD DE EF ... YZ, suponiendo en primer lugar que se utiliza el método de equilibrado de peso sin compresión de caminos y después este último sin equilibrado de peso. 8. Resolver el ejercicio anterior suponiendo que se utilizan a la vez los dos métodos de compresión de caminos y de equilibrado de peso. 9. Implementar las variantes de unión-pertenencia descritas en el texto y de- terminar empíricamente la comparación de sus rendimientos para l .O00 operaciones unión con argumentos aleatonos enteros comprendidos entre 10. Escribir un programa para generar un grafo aleatorio conexo con V vértices por generación de pares de enteros aleatonos entre 1 y V.Estimar en fun- ción de V qué número de aristas se necesitan para producir un grafo co- nexo. que se obtiene al suprimir GJ y añadir IK al gafo de ejemplo. cicio 1. grafo biconexo con V vértices? 1 y 100.
  • 511. 31 Grafos ponderados Con frecuencia se desea modelar problemas prácticos utilizando grafos en los que se asocia a las aristas unos pesos o costes. En un mapa de líneas aéreas, en el que las aristas representan rutas de vuelo, los pesos pueden representar dis- tancias o tarifas. En un circuito eléctrico, en el que las aristas representan las conexiones, los pesos naturales a utilizar son la longitud o el precio de los ca- bles. En el diagrama de un proyecto los pesos pueden representar el tiempo o el coste de realización de las tareas o bien el retraso en llevarlas a cabo. Estas situaciones hacen aparecer de forma natural cuestiones como el mi- nimizar costes. En este capítulo se examinarán detalladamente los algoritmos de dos de estos problemas: ((encontrarla forma de conectar todos los puntos al menor coste» y ((encontrarel camino de menor coste entre dos puntos dados)). El primero, que evidentemente se utiliza para los grafos que representan a cosas similaresa circuitos eléctricos, se denomina el problema del árbol de expansión mínimo; el segundo, que evidentemente se utiliza para los grafos que represen- tan cosas parecidas a los mapas de líneas aéreas, se denomina el problema del camino más corto. Estos problemas representan a toda una variedad de los que se suelen encontrar en los grafos ponderados. Los algoritmos de este capítulo implican búsquedas a través de los grafos y a veces se hacen pensando intuitivamente en los pesos como si fueran distan- cias: se habla de «el vértice más próximo a XB, etc. De hecho, esta predisposi- ción forma parte de la propia nomenclatura del problema del camino más corto. A pesar de ello, es importante recordar que los pesos no son necesariamente proporcionales a la distancia, y podrían representar tiempos o costes o alguna cosa completamentediferente. Cuando los pesos realmente representen las dis- tancias, puede que sean más apropiados otros tipos de algoritmos. Este asunto se presentará con mayor detalle al final del capítulo. La Figura 31.1 muestra un ejemplo de grafo no dirigido ponderado. La forma de representar a los grafos ponderados es obvia: en la representación por matriz de adyacencia, la matnz puede contener pesos de aristas en lugar de valores booleanos y en la representación por estructurasde adyacencia se puede añadir 491
  • 512. 492 ALGORITMOS EN C++ Figura 31.1 Un grafo no dirigido ponderado. un campo a cada elemento de la lista (que representa una arista), a manera de peso. Se supone que todos los pesos son positivos. Algunos algoritmos se pue- den adaptar para manipular pesos negativos, pero se hacen significativamente más complejos. En otros casos, los pesos negativos cambian la naturaleza del problema de forma esencial y se necesita recurrir a algoritmos mucho más so- flsticados que los que se consideran aquí. Un ejemplo del tipo de dificultades que pueden encontrarse aparece si se considera la situación en la que la suma de los pesos de las aristas de un ciclo es negativa: un camino infinitamente corto podría engendrarse simplemente dando vueltas alrededor del ciclo. Se han desarrollado varios algoritmos «clásicos»para resolver los problemas del árbol de expansión mínimo y del camino más corto. Estos métodos se en- cuentran entre los más célebres y los más utilizados de los algontmos de este libro. Como se vio anteriormente al estudiar antiguos algoritmos, los métodos clásicosproporcionan un enfoque general, pero las modernas estructuras de da- tos ayudan a proporcionar implementaciones compactas y eficaces. En este ca- pítulo se verá cómo utilizar colas de prioridad en la generalización de los mé- todos de recorrido de grafos del Capítulo 29 para resolver aabos problemas de forma eficaz en los grafos dispersos;más adelante se verá la relación entre este método y los clásicos de los grafos densos, y por último se verá un método para resolver el problema del árbol de expansión mínimo que utiliza un enfoque to- talmente diferente. Árbol de expansión mínimo Un árbol de expansión mínimo de un grafo ponderado es una colección de aris- tas que conectan todos los vértices y en el que la suma de los pesos de las aristas es al menos inferior a la suma de los pesos de cualquier otra colección de aristas que conecten todos los vértices. El árbol de expansión mínimo no es necesaria-
  • 513. GRAFOS PONDERADOS 493 Figura 31.2 Árboles de expansión mínimos. mente único: la Figura 3 1.2muestra los árbolesde expansión mínimos del grafo del ejemplo. Es fácil demostrar que la «colección de aristas))de la anterior de- finición deben formar un árbol de expansión: si existe algún ciclo, se puede su- primir alguna arista del mismo para dar una colección de aristas que todavía conectan a los vértices, pero con un peso inferior. Se vio en el Capítulo 29 que muchos de los procedimientos de recorrido de grafos construyen un árbol de expansión de dichos grafos. ¿Qué podría hacerse para que, en un grafo ponderado, el árbol construido sea el de peso total más bajo? Existen varias formas de hacerlo, todas basadas en la siguiente propiedad general de los árboles de expansión mínimos. Propiedad31.1 Duda una división cualquierade los vértices de un grafo en dos conjuntos, el árbol de expansión mínimo contiene la menor de las aristas que conectan un vértice de uno de los conjuntos con un vértice del otro. Por ejemplo, dividir los vértices del grafo del ejemplo en dos conjuntos {A B C D} y {EF G H I J K L M}, implica que DF debe estar en el árbol de expansión mínimo. Esta propiedad es fácil de demostrar por reducción al absurdo. Se de- nomina a a la menor de las aristas que conectan a los dos conjuntos y se supone que a no está en el árbol de expansión mínimo. A continuación se considera el grafo formado al añadir a al árbol de expansión mínimo. Este grafo tiene un ciclo, que debe contener alguna otra arista diferente de a que conecte a los dos conjuntos. Suprirniendo esta arista y añadiendo a, se obtiene un árbol de ex- pansión de menor peso, lo que contradice el supuesto de que a no se encuentra en el árbol de expansión mínim0.i Por lo tanto se puede construir el árbol de expansión mínimo comenzando en cualquier vértice y tomando siempre el vértice «más próximo))de todos los que ya se han elegido. En otras palabras, se busca la arista de menor peso entre todas las que conectan vértices que ya están en el árbol con vertices que no lo están todavía, y después se añade al árbol la arista y el vértice a los que conduce la anterior. (En caso de empate, se puede elegir cualquiera de las anstas «em- patadas)).)La propiedad 3 1.1 garantiza que cada arista añadida forma parte del árbol de expansión mínimo.
  • 514. 494 ALGORITMOS EN C+t Figura 31.3 Pasos inicialesde la construcción de un árbol de expansión mínimo. La Figura 31.3 muestra los cuatro primeros pasos cuando esta estrategia se utiliza en el grafo ejemplo, comenzando con el nodo A. El vértice más «pró- ximo» a A (conectado con una arista de peso mínimo) es B, por lo que AB es el árbol de expansión mínimo. De todas las aristas adyacentes a AB, la BC es la de menor peso, así que se añade ai árbol, y el vértice C es el siguiente a explorar. Entonces, el vértice más próximo a A, B o C es ahora D, por lo que BD se añade al árbol. La continuación de este proceso se muestra en la Figura 31.5, después de presentar la implementación. ¿Cómo implementar realmente esta estrategia? Hasta ahora el lector habrá reconocido seguramente la estructura básica de: vértices del árbol, del margen y no vistos que caracterizan a las estrategiasde búsqueda en profundidad y en an- chura del Capítulo 29. Resulta que el mismo método sirve, utilizando una cola de prioridad (en lugar de una pila o de una cola), para almacenar los vértices del margen. Búsqueda en primera prioridad Recuérdese del Capítulo 29 que la búsqueda en grafos puede describirse en tér- minos de la división de los vértices en tres conjuntos: vértices del árbol, cuyas aristas ya han sido examinadas, vértices del margen, que están en la estructura de datos esperando su tratamiento, y vértices no vistos, a los que todavía no se ha llegado. El método fundamental de búsqueda en grafos que se utiliza aquí está basado en el paso «mover un vértice (denominadox desde el margen hacia
  • 515. GRAFOS PONDERADOS 495 Figura 31.4 Contenido de la cola de prioridad durante la construcción del árbol de expansión mínimo. el árbol, y colocar después en el margen a cualquiera de los vértices no vistos adyacentes a XP. Se utiliza el término de búsqueda en primera prioridad para referirse a la estrategia general de la utilización de una cola de prioridad para decidir qué vértice retirar del margen. Esto permite una gran flexibilidad. Como se verá, varios de los algoritmos clásicos (incluyendolas búsquedas en anchura y profundidad) se diferencian solamente en la elección de la prioridad. Para la construcción del árbol de expansión mínimo, la prioridad de cada vértice del margen debe ser igual a la longitud de la arista más pequeña que lo conecta al árbol. La Figura 31.4muestra el contenido de una cola de prioridad durante el proceso de construcción presentado en las Figuras 31.3 y 31.5. Para mayor claridad, los elementos de la cola se muestran en orden creciente. Esta implementaciónpor dista ordenada»de las colas de prioridad podría ser apro- piada para grafos pequeños, pero para los grandes deben utilizarse montículos para asegurar que todas las operaciones se puedan finalizar en O(logni? pasos (véase el Capítulo 11). En primer lugar, se consideran grafos dispersos con representación por lista de adyacencia. Como se ha mencionado anteriormente, se añade un campo de peso w al registro de a ristas (modificando el código de entrada para poder leer pesos). Entonces, si se utiliza una cola de prioridad para el margen, se tiene la siguiente implementación: CP cp(maxV); v i s i t a r ( i n t k) / / BPP, l i s t a s de adyacencia s t r u c t nodo *t; if (cp.actualizar(k, -novisto) != O) padre[k] = O; whi 1e ( !cp. vacío()) { id++; k = cp.suprimir(); val[k] = -val[k]; if(val [k] == -novisto) val [k] = O; for (t = ady[k]; t != z; t =t->siguiente) if (val [t->VI < O)
  • 516. 496 ALGORITMOS EN C++ i f (cp. actual i z a r (t->v, prioridad)) val [t->VI = -(prioridad); padre[t->VI = k; { 1 Para calcular el árbol de expansión mínimo, hay que reemplazar las dos ocu- rrencias de p r i o r i dad por t-> w. Se utiliza la cl ase diccionario cola de prio- ridad descrita en el Capítulo 11: la función actual izar es una primitiva que se implementa fácilmente y cuyo objetivo es asegurar que el vértice pasado como parámetro aparezca en la cola al menos con la prioridad dada: si el vértice no está en la cola, se aplica una inserci on, y si el vértice está, pero tiene un gran valor de prioridad, entonces se utiliza cambi a r para cambiar la prioridad. Si se ha hecho cualquier cambio (o inserción o cambio de prioridad), la función ac- tual izar devuelveun valor distinto de cero. Esto permite que el programa an- terior mantenga actualizados los arrays val y padre. El array val podría con- tener él mismo realmente la cola de prioridad de una forma «indirecta»;en el programa anterior se han separado las operaciones sobre la cola con prioridad, para una mayor claridad. Aparte del cambio de la estructura de datos por una cola de prioridad, este programa es prácticamente el mismo que se ha utilizado en las búsquedas en anchura y profundidad, con dos excepciones. Primero, se necesita una acción suplementaria cuando se encuentra una arista que ya está en el margen: en las búsquedas en anchura y profundidad se ignoran tales aristas, pero en el pro- grama anterior se necesita comprobar si la nueva arista rebaja la prioridad. Se garantiza así, como es de desear, que siempre se explore el siguiente vértice del margen que es el más próximo al árbol. Segundo, este programa sigue explíci- tamente la pista del árbol actualizando el array padre que almacena al padre de cada nodo en el árbol de búsqueda en primera prioridad (el nombre del nodo que ha provocado su desplazamiento desde el margen hasta el árbol). También, para cada nodo k del árbol, val [k] es el peso de la arista entre k y padre [k]. Los vértices del margen se marcan como antes con valores negativos en val ;el centinela novi Sto toma valores negativos grandes (la razón de esto se hará evi- dente más adelante). La Figura 31.5 muestra el final de la construcción del árbol de expansión mínimo del ejemplo. Como es habitual, los vértices del margen se dibujan como cuadrados, los vértices del árbol como círculosy los vértices no vistos como cír- culos sin etiqueta. Las aristas de los árboles se representan por medio de líneas gruesas en negro, y la arista más pequeña que conecta a cada vértice del margen con el árbol está sombreada. Se anima al lector a seguir la construcción del ár- bol utilizando las Figuras 31.4 y 31.5. En particular hay que destacar cómo se añade al irbol el vértice G después de haber estado en el margen durante varias
  • 517. GRAFOC PONDERADOS 497 Figura 31.5 Finalde la construcción del árbol de expansión mínimo. etapas. Inicialmente, la distancia desde G al árbol es 6 (por causa de la arista GA). Después de añadir L al árbol, GL disminuye esta distancia hasta 5, y a continuación, después de añadir E, la distancia finalmente baja hasta 1 y G se añade al árbol antes de J. La Figura 3 1.6 muestra la construcción de un árbol de expansión mínimo para el gran grafo «laberinto» anterior, ponderado por la longitud de sus aristas. Propiedad 31.2 La búsqueda en primera prioridad en grafos dispersos cons- truye el árbol de expansión mínimo en O((A+v)logv) etapas. Se aplica la propiedad 31.1: los dos conjuntos de nodos en cuestión son los no- dos explorados y los sin explorar. En cada etapa se elige la arista más pequeña que conecta un nodo explorado con un nodo del margen (no existen aristas que conecten nodos exploradoscon nodos no vistos).Asi, por la propiedad 31.1, cada arista que se elige está en el árbol de expansión mínimo. La cola de prioridad contiene solamente vértices; si se implementa como un montículo (véaseel Ca-
  • 518. 498 ALGORITMOS EN C++ Figura 31.6 Construcción de un granárbol de expansión minimo. pítulo 11), entonces cada operación necesita O(1ogV) pasos. Cada vértice con- duce a una inserción y cada arista a una operación de cambio de pri0ridad.i Se verá posteriormente que este método también resuelve el problema del camino más corto, si se hace una elección apropiada de la prioridad. También se verá que una implementación diferente de la cola de prioridad puede dar un algoritmo en V2,que es apropiado para los grafos densos. Esto es equivalente a un antiguo algoritmo que data al menos de 1957: para el árbol de expansión mínimo se atribuye normalmente a R. Prim, y para el camino más corto suele atribuirse a E. Dijkstra. Por coherencia, aquí se hará referencia a estas solucio- nes (para los grafos densos) como el «algoritmo de Prim» y el «algoritmo de Dijkstra) respectivamente, y se hará referencia al método anterior (para grafos dispersos)como la «solución de la búsqueda en primera prioridad)). La búsqueda en primera prioridad es una buena generalización de las bús- quedas en anchura y en profundidad, porque estos métodos pueden deducirse por medio de una adecuada elección de la prioridad. Como i d aumenta desde 1 hasta Vdurante la ejecución del algoritmo, esto se puede utilizar para asignar prioridades únicas a los vértices. Si se cambian las dos ocurrencias de pr i ori - dad en 1i stasbpp por V - id, se obtiene una búsqueda en profundidad, dado que los nodos que se han encontrado más recientemente tienen la prioridad más alta. Si se utiliza i d por pri oridad se obtiene una búsqueda en anchura, por- que los nodos más antiguos tienen la prioridad más alta. Estas asignacionesde prioridad hacen que las colas de prioridad operen como si fueran pilas y colas.
  • 519. GRAFOS PONDERADOS 499 Figura 31.7 Pasosiniciales del algoritmo de Kruskal. Método de Kruskal Una aproximación del todo diferente para encontrar el árbol de expansión mí- nimo consiste simplementeen añadir aristas, una a una, utilizando en cada paso la arista más pequeña que no forme un ciclo. Dicho de otra manera, el algo- ritmo comienza con un bosque de árboles A ! en N pasos combina dos árboles (utilizando la arista más pequeña posible)hasta que no exista mas que un árbol izquierdo. Este algoritmo data por lo menos de 1956 y se suele atribuir a J. Kruskal. Las Figuras 31.7 y 31.8 muestran la operación de este algoritmo en el grafo del ejemplo. Las primeras aristas que se eligen son aquellas cuya longitud es la más pequeña (1) en el grafo. Después se ensayan las aristas de longitud 2; se observa, en particular, que FE se examina, pero no se incluye, dado que forma un ciclo con las aristas que ya se sabía que están en el árbol. Las componentes no conexas evolucionan progresivamente hacia un árbol, en contraste con la búsqueda en primera prioridad, en la que el árbol «engorda» arista a arista. La implementación del algoritmode Kruskal se puede ir componiendo a base de programas que ya se han estudiado. Primero se necesita considerar las aris- tas, una a una, por orden creciente de sus pesos. Una posibilidad sería la de or- denarlas simplemente, pero parece más conveniente utilizar una cola de prio- ridad, sobre todo porque no se necesita examinar todas las aristas. Esto se presenta con más detalle posteriormente. A continuación se necesita poder comprobar si una arista dada, al añadirse a las otras que ya se han cogido, en-
  • 520. 500 ALGORITMOS EN C++ Figura 31.8 Finaldel algoritmo de Kruskal. gendra un ciclo. Las estructuras de unión-pertenencia que se presentaron en el capítulo anterior están diseñadas precisamente para esta tarea. Ahora, la estructura de datos apropiada para el grafo es tan sólo un array e con una entrada por cada arista. Éste se puede construir fácilmente a partir de la representación por listas de adyacencia o por matriz de adyacencia del grafo con una búsqueda en profundidad o algún procedimiento más simple. Sin em- bargo, en el programa siguiente se llena el array directamente it partir de los da- tos. Se utiliza la cl ase diccionario cola de prioridad CP del Capítulo 11, con las prioridades de los campos de ponderación del array e y una clase de equivalen- cia EQ basada en el procedimiento pertenencia del Capítulo 30, para verificar los ciclos. El programa llama simplemente al procedimiento ari s taencon- trada para cada arista del árbol de expansión; con un poco más de trabajo se podrían construir un array padre u otra representación. void kruskal() int i , m, V, A; {
  • 521. GRAFOS PONDERADOS 501 struct arista struct arista e[maxA] ; CP cp(maxA); EQ eq(maxA); cin >> V >> A; for (i = 1; i >= A; i++) for (i= 1; i >= A; i++) for (i= O; i < V-1; ) { char vl, v2; int w; }; cin >> e[i].vl >> e[i].v2 >> e[i].w; cp.insertar(i, e[i] .w); i if (cp.vacio()) break; else m = cp.eliminar(); if (cp.encontrar( i ndice( e[m] .vi) , i ndice( e[m] .v2,1)) { cout << e[m] .vi << e[m] .v2 << I I ; i++; }; I El proceso puede terminar de dos formas. Si se encuentran V- 1 aristas, enton- ces se tiene un árbol y se puede parar. Si en primer lugar se vacía la cola de prioridad, entonces hay que examinar todas las aristas sin encontrar e ! árbol de expansión: esto sucederá si el grafo es no conexo. El tiempo de ejecuciónde este programa está dominado por el consumo de tiempo al procesar las aristas de la cola de prioridad. Propiedad 31.3 El algoritmo de Kruskal construye el árbol de expansión mí- nimo de un grafo en O(A1ogA) pasos. La exactitud de este algoritmo se deriva también de la propiedad 31.1. Los dos conjuntos de vértices en cuestión son los conectados a las aristas elegidas para el árbol y los que todavía no se han tocado. Cada arista que se añade es la más pequeña de las que enlaza vérticesde los dos conjuntos. El peor caso es un grafo que es no conexo, en el que se debe examinar todas las aristas. Incluso en un grafo conexo el peor caso seguirá siendo el mismo, porque el grafo puede estar compuesto de dos agrupamientos de vértices, todos ellos conectados por aristas muy pequeñas, y con una única arista muy grande que conecta a los dos agru- pamientos. Entonces la arista más grande del grafo está en el árbol de expansión mínimo, pero será la última en salir de la cola de prioridad. Para grafos repre- sentativos, se debe esperar a que el árbol de expansión esté completo (tiene so- lamente V-l vértices) antes de obtener la arista más grande del grafo, pero la construcción inicial de la cola de prioridad llevará siempre un tiempo propor- cional a A (véase la propiedad 11 . 2 ) ~ La Figura 31.9 muestra la construcción de un gran árbol de expansión mí-
  • 522. 502 ALGORITMOSEN C++ Figura 31.9 Construcción de un gran árbol de expansión mínimo con el algoritmo de Kruskal. nimo con el algoritmo de Kniskal. Este diagrama muestra claramente cómo el método selecciona en primer lugar a todas las aristas pequeñas: el método aña- dirá las aristas más largas (diagonales)en último lugar. En vez de utilizar colas de prioridad se podría simplemente ordenar las aris- tas por las ponderaciones inicialesy después tratarlas en orden. También,la ve- rificación de ciclos puede hacerse en un tiempo proporcional a AlogA con una estrategia mucho más simple que la de unión-pertenencia, lo que proporciona un algoritmo de árbol de expansión mínimo que siempre lleva AlogA pasos. Éste es el método propuesto por Kniskal, pero en este libro se hace referencia a la versión modernizada anterior, que utiliza colas de prioridad y estructuras de unión-pertenencia,denominándola ((algoritmode Kniskal». Ell camino más corto El problema del camino más corto consiste en encontrar entre todos los cami- nos que conectan a dos vértices x e y dados de un grafo ponderado el que cum- pla con la propiedad de que la suma de las ponderaciones de todas las aristas sea mínima. Si las ponderaciones son todas igual a 1, entonces el problema sigue siendo interesante: ahora consiste en encontrar el camino que contenga al mínimo nú- mero de aristas que conecten a x e y. Además, ya se ha tratado un algoritmo que resuelve este problema: la búsqueda en anchura. Es fácil de demostrar por inducción que dicha búsqueda, si comienza en x, explorará en pnmer lugar to- dos los vértices que se puedan alcanzar desde x con una arista, después todos
  • 523. GRAFOS PONDERADOS 503 Figura 31.10 Árboles de expansióndel camino más corto. los vértices que se puedan alcanzar con dos aristas, etc., explorando todos los vértices que se puedan alcanzar con k aristas antes de encontrar alguno que ne- cesite k + I aristas. Así, cuando se alcance y por primera vez, se ha encontrado el camino más corto desde x porque ningún otro camino más corto puede al- canzar y (véase la Figura 29.14). En general,el caminodesde x a y puede tocar a todos los vértices, por lo que normalmente se considera el poblema de encontrar el más corto de los cami- nos que conectan a un vértice dado x con todos los otros vértices del grafo. Una vez más sucede que el problema es fácil de resolver con el algoritmo de reco- mdo de grafo en primera prioridad dado anteriormente. Si se dibuja el camino más corto desde x a todos los otros vértices del gafo, entonces se obtiene claramente que no hay ciclos, y se tiene un árbol de expan- sión. Cada vértice conduce a un árbol de expansión diferente; por ejemplo, la Figura 31.10 muestra los árboles de expansión de los caminos más cortos para los vértices A, G y M del grafo de ejemplo que se ha estado utilizando. La solución de la búsqueda en primera prioridad para este problema es vir- tualmente idéntica a la solución del árbol de expansión mínimo: se construye el árbol por el vértice x añadiendoa cada paso el vértice del margen que sea el más próximo a x (antes, se añade el más próximo al árboi). Para encontrar cuál de los vértices del margen es el más próximo a x,se utiliza el array val :para cada vértice k del árbol, val [k] es la distancia entre ese vértice y x,utilizando el ca- mino más corto (que debe estar formado por los nodos del árbol). Cuando se añade k al árbol, se actualiza el margen recomendo la lista de adyacencia de k. Para cada nodo t de la lista, la distancia más corta de x a t->v a través de k es val [k] +t->v. Así, el algoritmo se implementade forma trivial, utilizando esta cantidad en pri ori dad en el programa de recorrido de grafos en primera pno- ridad. La Figura 3 1.1 1 muestra los primeros cuatro pasos de la construcción del árbol de expansión del camino más corto para el vértice A del ejemplo. Primero se explora el vértice más próximo a A, que es B. C y F están a la distancia 2 de A, por lo que se exploran a continuación (en el orden que la cola de prioridad devuelve para ellos, en este caso C y después F).El final del proceso se muestra en la Figura 31.12 y el contenido de la cola de prioridad durante las búsqueda se muestra en la Figura 31.13.
  • 524. 504 ALGORITMOS EN C++ Figura 31.11 Pasos inicialesde la construcción de un árbol de camino más corto. A continuación, D puede conectarse a F o a B para obtener un camino de distancia 3 a A. (El algoritmo conecta D a B porque B se puso en el árbol antes que F; así, D estaba ya en el margen cuando F se puso en el árbol, y F no pro- porciona un camino más corto hacia A.) Después se añaden al árbol L E M G J K I, en orden creciente, de su distancia mínima a A. Así, por ejemplo, H es el nodo más lejano desde A: el camino AFEGH tiene un peso total de 8. No existe el camino más corto hacia H, y el camino más corto desde A a todos los otros nodos no es el más largo. La Figura 31.14 muestra los valores finales de los arrays padre y val para el ejemplo. Así el camino más corto desde A a H tiene un peso total de 8 (en- contrado en val [8], la entrada para H) y va desde A a F a E a G y a H (encon- trado leyendo al revés en el array padre, comenzando en H). Obsérvese que este programa depende de que la entrada de val para la raíz sea cero, la convención que se adoptó para 1istasbpp. Propiedad 31.4 La búsqueda en primera prioridad en un grafo disperso cal- cula el árbol de camino más corto en O((A+ V)logv) pasos. La demostración de este algoritmo se puede hacer de forma similar a la propie- dad 31.1. Con una cola de prioridad implementada, utilizando montículos como en el Capítulo 12, la búsqueda en primera prioridad puede siempre asegurarse que funcionará en el número máximo de pasos mencionado, sin importar qué regia de prioridad se uti1ice.i
  • 525. GRAFOS PONDERADOS 505 Figura 31.12 Final de la construcción de un árbol de camino más corto. Más adelante se verá cómo una implementacióndiferente de la cola de prio- ridad puede dar un algoritmo en V ' que es apropiado para los grafos densos. Para el problema del camino más corto, esto se reduce a un método que data al menos de 1959 y que suele atribuirse a E. Dijkstra. La Figura 3 1.15 muestra un árbol de camino más corto de gran tamaño. Como antes, las longitudes de las aristas se utilizan en este grafo como pesos, por lo que la solución llega a encontrar el camino de longitud mínima desde el E l 2 m 4 a 4 a 5 pJ5 2 @ 5 @ 5 l j g 4 a 4 5 a 5 m 6 0 7 m S m* Figura 31.13 Contenido de la cola de prioridad durante la construcción del árbol de camino más corto.
  • 526. 506 ALGORITMOS EN C++ k 1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 nombre(padre[k]) A B B F A E G K G I F L val [k] O 1 2 3 4 2 5 8 8 6 7 4 5 Figura 31.14 Representacióndel árbol de expansión del camino más corto. nodo inferior izquierdo a todos los demás. Posteriormente, se presentará una mejora que puede ser apropiada para tales grafos. Pero incluso en este grafo po- dría ser apropiado utilizar otros valores para los pesos: por ejemplo, si este grafo representa un laberinto (véase el Capítulo 29), el peso de una arista puede re- presentarse por la distancia del propio laberinto, no por los «atajos» dibujados en el grafo. Árbol de expansión mínimo y camino más corto en grafos densos Para un gafo representado por una matriz de adyacencia, lo mejor es utilizar una representación por array no ordenadopara la cola de prioridad, de manera que se obtenga un tiempo de ejecución V2para cualquier algoritmo de recomdo del grafo en primera prioridad, Esto se hace combinando el bucle de actualiza- ción de las prioridades y el de encontrar el mínimo: cada vez que se mueve un vértice del margen, se pasa a través de todos los vértices, actualizando sus prio- ridades si es necesario y siguiendo la pista del valor mínimo encontrado. Esto proporciona un algoritmo lineal para la búsqueda en primera prioridad en gra- Figura 31.15 Construcción de un gran árbol del camino mas corto.
  • 527. GRAFOS PONDERADOS 507 fos densos (y también para los problemas del árbol de expansión mínimo y el camino más corto). Específicamente, se gestiona la cola de prioridad en el array val (esto tam- bién se puede hacer en 1istasbpp, como se presentó antes),pero se implemen- tan las operaciones de cola de prioridad directamente en vez de utilizar montí- culos. Como antes, el signo de las entradas de v a l indica si el vértice correspondiente está en el árbol o en la cola de prioridad. Todos los vértices co- mienzan en la cola de prioridad y tienen la prioridad del centinela novisto. Para cambiar la prioridad de un vértice, simplemente se asigna la nueva pno- ridad en la entrada de val de este vértice. Para eliminar el vértice de prioridad más alta, se explora a través del array val para encontrar el vértice con el ma- yor valor negativo (el más próximo a O), y entonces se toma su complemento en val. Después de hacer estas modificacionesmecánicas en el programa 1is- tasbpp que se ha estado utilizando, se obtiene el programa compactosiguiente: void buscar() // BPP, matriz de adyacencia i i n t k, t, min = O; f o r ( k = 1; kn<= V; ktt) { val[k] = novisto; padre[k] = O; } val[O] = novisto-1; f o r ( k = 1; k != O; k = min, min = O) f o r ( t i f (va i f i f { val [k] = -val [k] ; if (val [k] == -novisto) val [k] = O; = 1; t <= v; t++) [tl < 0) (a[k] [t] && (val [t] < -prioridad)) ( v a l [ t ] > val[min]) min = t; v a l [ t ] = -prioridad; padre[t] = k; } Es preciso señalar que se ha utilizado un valor superior en una unidad a -no- v i sto, como un centinela para encontrar el mínimo, y que el opuesto de dicho valor debe ser representable. Si se almacenan los pesos en la matriz de adyacencia y se utiliza a[k] [t] para p r io r i dad en este programa, se obtiene el algoritmo de Prim para la cons- trucción del árbol de expansión mínimo; si se utiliza val [k]+a [k ] [t] para p r io r idad se obtiene el algoritmo de Dijkstra para el problema del camino más corto. Como antes, si se incluye el código para que i d indique el número de
  • 528. 508 ALGORITMOS EN C++ vértices explorados y se utiliza V - id para priori dad, se obtiene la búsqueda en profundidad. Este programa sólo se diferencia del de la búsqueda en primera prioridad, con el que se ha estado trabajando para los grafos dispersos, en la re- presentación del grafo que se utiliza (matriz de adyacencia en vez de listas de adyacencia)y en la implementación de la cola de prioridad (array no ordenado en vez de montículo indirecto). Propiedad 31.5 Los problemas del árbol de expansión mínimo y del camino más cortopueden resolverseen tiempo linealpara grafos densos. De la inspección del programa se deduce inmediatamente que el tiempo de eje- cución del peor caso es proporcional a V2.Cada vez que se explora un vértice, un recomdo de las V entradas de una fila de la matriz de adyacencia permite cumplir el doble objetivo de verificar todas las aristas adyacentes, actualizar la cola de prioridad y encontrar el valor mínimo que contiene. Así, el tiempo de ejecución es lineal cuando A es proporcional a V2.. Se han presentado tres programas para el problema del árbol de expansión mínimo con diferentescaracterísticasde rendimiento: el método de la búsqueda en primera prioridad, el algoritmo de Kruskal y el algoritmo de Prim. El algo- ritmo de Prim es posiblemente el más rápido de los tres para algunos grafos, el de Kruskal para otros y el método de búsqueda en primera prioridad para otros. Como se dijo anteriormente, el peor caso para el método de búsqueda en pri- mera prioridad es (A + V)logV,mientras que el peor caso para el de Prim es V2 y para el de Kruskal es AlogA. Pero sería poco prudente elegir entre los algorit- mos sobre la base de estas fórmulas porque es improbable que «el peor caso» suceda en la práctica. De hecho, el método de la búsqueda en prioridad y el de Kruskal son ambos susceptjbles de ejecutarse en tiempo proporcional a A para los grafos que aparecen normalmente en la práctica: el primero, porque la ma- yoría de las aristas no necesitan una actualización de la cola de prioridad, que lleva logV pasos, y el segundo porque es probable que la arista de mayor longi- tud del árbol de expansión mínimo sea lo suficientemente pequeña como para que no se extraigan muchas aristas de la cola de prioridad. Por supuesto, el mé- todo de Prim también se ejecuta en un tiempo proporcional a aproximada- mente A en grafos densos (pero no debe utilizarse en grafos dispersos). Problemas geométricos Supóngaseque se tienen N puntos dados de un plano y que se desea encontrar el conjunto de segmentos de longitud más pequeña de los que conectan a todos los puntos. Éste es un problema geométrico denominado el problema del árbol de expansión minimo euclidiano, que se puede resolver utilizando el algoritmo
  • 529. GRAFOS PONDERADOS 509 para grafos dado anteriormente, pero parece claro que la geometría del mismo proporciona suficienteestructura extra como para que se puedan desarrollar al- goritmos mucho más eficaces. El medio para resolver el problema euclidiano utilizando el algoritmo ante- rior consiste en construir un gafo completo con N vértices y N(N - 1)/2aristas, cada una de las cuales conecta cada par de vértices ponderados por la distancia enire los puntos correspondientes. Entonces se puede construir el árbol de ex- pansión mínimo por medio del algoritmo de Prim en un tiempo proporcional a N2. Se ha demostrado que es posible hacerlo mejor. En efecto, la estructura geo- métrica del problema hace irrelevantes a la mayor parte de las aristas, y se pue- den eliminar una mayoría de ellas antes de comenzar a construir el árbol de expansión mínimo. De hecho, también se ha demostrado que el árbol de expan- sión mínimo es un subconjunto del grafo que se obtiene al elegir solamente las aristas a partir del dual del diagrama de Voronoi (véase el Capítulo 28). Se sabe que este grafo tiene un número de aristas proporcional a N y que tanto el algo- ritmo de Kruskal como el método de búsqueda en primera prioridad funcionan eficazmente en tales grafos dispersos. En principio, podría calcularse el dual del diagrama de Voronoi (lo que lleva un tiempo proporcional a Mom, y a con- tinuación ejecutar o bien el algoritmo de Kruskal o bien el método de búsqueda eii primera prioridad para obtener el algoritmo del árbol de expansión mínimo euclidiano que se ejecuta en un tiempo proporcional a Moo. Pero la escritura de un programa para calcular el dual de Voronoi es más bien UE desafío, in- cluso para programadores experimentados, y por ello esta aproximación al pro- blema es probablemente impracticable. Otro enfoque, que se puede utilizar en conjuntos de puntos aleatorios, con- siste en aprovecharse de la distribución de puntos para limitar el número de aristas incluidas en el grafo, como en el método de la rejilla que se utilizó en el Capítulo 26 para la búsqueda por rango. Si se divide el plano en cuadrados de tal forma que cada uno de ellos contenga aproximadamente l@/2 puntos, y en- tonces se incluyen en el grafo sólo aquellas aristas que conecten cada punto de los situados en cuadrados vecinos, entonces es muy probable (pero no garanti- zado) obtener todas las aristas del árbol de expansión mínimo, lo que significa- ría que el algoritmo de Kruskal o el método del árbol de búsqueda en primera prioridad acabarían el trabajo eficazmente. Es interesante hacer una reflexión sobre las relaciones entre los grafos y los algoritmos geométricos, que se han dado a conocer por el problema expuesto en los párrafos anteriores. Es cierto que muchos problemas pueden formularse bien como problemas geométricos,bien como problemas de grafos. Si el empla- zamiento físico real de los objetos es una característica dominante, entonces pueden ser apropiados los algoritmos geométricos de los Capítulos 24-28; pero si las interconexiones entre objetos tienen una importancia fundamental, en- tonces podrán ser mejores los algoritmos sobre grafos de esta sección. El árbol de expansión mínimo euclidiano parece ser la interfaz entre estos dos enfoques (los datos de entrada afectan la geometría y los de salida, las interconexiones),
  • 530. 510 ALGORITMOS EN C++ y el desarrollo de métodos simples y directos para éste y otros problemas rela- cionados con él continúa siendo una meta elusiva. Otro lugar donde los algoritmos grafos y geométricos interactúan es en el problema de encontrar el camino más corto entre x e y en un grafo cuyos vér- tices sean puntos del plano y cuyas aristas sean líneas que los conectan. El grafo laberinto que se ha utilizado puede considerarse como un grafo de este tipo. La solución a este problema es simple: utilizar la búsqueda en primera prioridad, dando como prioridad de cada vértice del margen que se encuentre la distancia, en el árbol, entre x y el vértice del margen, más la distancia euclidiana entre dicho vértice del margen e y. A continuación se para cuando y se ha añadido al árbol. Este método encontrará rápidamente el camino más corto desde x a y yendo siempre en la dirección de y, mientras que el algoritmo sobre el grafo es- tándar tiene que «buscan>y. Desplazarse de un extremoa otro de un gran grafo laberinto puede necesitar que se examine un número de nodos proporcional a p, mientras que el algoritmo estándar debe examinarprácticamente todos los nodos. Ejercicios 1. Encontrar otro árbol de expansión mínimo para el grafo ejemplo del prin- cipio del capítulo. 2. Presentar un algoritmo para encontrar el bosque de expansión mínimo de un grafo conexo (cada vértice debe ser alcanzado por alguna arista, pero no es necesario que el gafo resultante sea conexo). 3. ¿Existe un grafo con V vértices y A aristas para el que la solución en pri- mera prioridad del problema del árbol de expansión mínimo pueda nece- sitar un tiempo proporcional a (A + V)logV?Dar un ejemplo o explicar la respuesta. 4. Supóngase que se mantiene la cola de prioridad por medio de una lista or- denada en las implementaciones de recomdo de grafos en general. ¿Cuál podrá ser el tiempo de ejecución del peor caso para un factor constante? ¿Cuándo podría ser apropiado el método, si lo es alguna vez? 5. Presentar contraejemplos que muestren por qué la siguiente estrategia «in- saciable» no resuelve ni el problema del camino más corto ni el del árbol de expansión mínimo: «en cada paso se exploran los vértices inexplorados que sean los más próximos al que se acaba de exploran>. 6. Presentar los árboles del camino más corto para los otros nodos del grafo del ejemplo. 7. Describir cómo se podría encontrar el árbol de expansión mínimo de un grafo extremadamente grande (demasiado grande como para poderse al- macenar en la memoria principal). 8. Escribir un programa para generar grafos aleatorios conexos con Vvértices, y después encontrar el árbol de expansión mínimo y el del camino más corto
  • 531. GRAFOS PONDERADOS 511 para algunos vértices. Utilizar pesos aleatorios entre 1 y V. ¿Qué relación existe entre los pesos de los árboles y los diferentes valores de V? 9. Escribir un programa para generar grafos aleatonos completos y pondera- dos con V vértices tan sólo llenando una matriz de adyacencia con núme- ros aleatonos entre 1 y V.Determinar empíricamentequé método encuen- tra el árbol de expansión mínimo de forma más rápida para V = 10, 25, 100:el de Prim o el de Kruskal. 10. Presentar un contraejemplo para mostrar por qué el método siguiente no sirve para encontrar el árbol de expansión mínimo euclidiano: «ordenar los puntos por sus coordenadas x, a continuación encontrar los árboles de ex- pansión mínimos de la primera mitad, de la segunda mitad, y finalmente encontrar la arista más corta que los conecta».
  • 533. 32 Grafos dirigidos Los grafos dirigidos son aquellos en los que las aristas que