programacion2

185
PROGRAMACIÓN II Ángel Aguilera García Juan Manuel Fernández Luna Miguel Lastra Leidinger Mari Lina Rivero Cejudo

Upload: luis-castro

Post on 05-Jul-2015

225 views

Category:

Documents


2 download

TRANSCRIPT

Page 1: programacion2

PROGRAMACIÓN II

Ángel Aguilera García

Juan Manuel Fernández Luna

Miguel Lastra Leidinger

Mari Lina Rivero Cejudo

Page 2: programacion2

Prólogo

La asignatura de Programación II aparece en la especialidad de Telemática de la IngenieríaTécnica de Telecomunicaciones como la continuación natural de la asignatura Programación I.

Tras tener los conocimientos básicos necesarios para afrontar la resolución de un problemamediante el diseño de un algoritmo y posteriormente, su implantación en un programa escrito en unlenguaje de programación de alto nivel, al alumnado se le presentan en esta nueva asignatura unconjunto de herramientas que le facilitarán las labores de desarrollo. En ella se sentarán las basesnecesarias para que el futuro ingeniero técnico pueda afrontar con total garantías de éxito eldesarrollo de complejas aplicaciones dentro de su campo de trabajo.

Este libro de apuntes cubre el temario de la asignatura de Programación II, que incluye desdeun estudio de la recursividad a una introducción de la programación dirigida a objetos, pasando porlas técnicas de análisis de eficiencia de algoritmos, los tipos de datos abstractos, algoritmosavanzados de ordenación y los métodos formales de prueba del software.

Los objetivos básicos que intentamos cubrir con este libro son varios:

• Ser una guía de apoyo para seguir la asignatura, de tal forma que se disponga de unmaterial de referencia básico.

• Ofrecer al alumnado una amplia bibliografía organizada por capítulos, donde poderestudiar con más profundidad los temas presentados.

Además, este libro de apuntes puede utilizarse como continuación a cualquier curso deintroducción a la programación.

Linares, septiembre de 2.000

Ángel Aguilera García

Juan Manuel Fernández Luna

Miguel Lastra Leidinger

Mari Lina Rivero Cejudo

Page 3: programacion2

ÍNDICE

CAPÍTULO 1: INTRODUCCIÓN.......................................................................................................................9

1.1 CONSTRUCCIÓN DE SOFTWARE.......................................................................................................................91.2 FUNDAMENTOS DE DISEÑO DE SOFTWARE....................................................................................................10

1.2.1 Abstracción...........................................................................................................................................101.2.2 Refinamiento.........................................................................................................................................101.2.3 Modularidad .........................................................................................................................................101.2.4 Arquitectura del software .....................................................................................................................111.2.5 Jerarquía de control .............................................................................................................................111.2.6 Estructura de datos...............................................................................................................................111.2.7 Procedimientos del software.................................................................................................................111.2.8 Ocultamiento de información ...............................................................................................................11

1.3 BIBLIOGRAFIA ..............................................................................................................................................12

CAPÍTULO 2: RECURSIVIDAD......................................................................................................................13

2.1 INTRODUCCIÓN A LA RECURSIVIDAD. ...........................................................................................................132.2 DISEÑO DE MÓDULOS RECURSIVOS...............................................................................................................152.3 LA PILA DEL ORDENADOR Y LA RECURSIVIDAD. ...........................................................................................202.4 PASO DE PARÁMETROS A LOS MÓDULOS RECURSIVOS. .................................................................................252.5 ¿ RECURSIVIDAD O ITERACIÓN ?...................................................................................................................272.6 ELIMINACIÓN DE LA RECURSIVIDAD.............................................................................................................282.7 ALGUNAS TÉCNICAS DE RESOLUCIÓN DE PROBLEMAS BASADAS EN LA RECURSIVIDAD. ...............................32

2.7.1 Divide y vencerás..................................................................................................................................332.7.2 Backtracking.........................................................................................................................................36

2.8 BIBLIOGRAFÍA. .............................................................................................................................................38

CAPÍTULO 3: ANÁLISIS DE LA EFICIENCIA DE LOS ALGORITMOS................................................39

3.1 INTRODUCCIÓN AL ANÁLISIS DE LA EFICIENCIA............................................................................................393.2 EL TIEMPO DE EJECUCIÓN DE UN ALGORITMO Y SU ORDEN DE EFICIENCIA....................................................413.3 LAS NOTACIONES ASINTÓTICAS....................................................................................................................453.4 CÁLCULO DEL TIEMPO DE EJECUCIÓN DE UN ALGORITMO ITERATIVO Y DE SU ORDEN DE EFICIENCIA...........483.5 CÁLCULO DEL TIEMPO DE EJECUCIÓN DE ALGORITMOS RECURSIVOS Y DE SU ORDEN DE EFICIENCIA............543.6 RESOLUCIÓN DE RECURRENCIAS HOMOGÉNEAS. ..........................................................................................563.7 RESOLUCIÓN DE RECURRENCIAS NO HOMOGÉNEAS. .....................................................................................573.8 RESOLUCIÓN DE RECURRENCIAS MEDIANTE CAMBIO DE VARIABLE..............................................................593.9 BIBLIOGRAFÍA. .............................................................................................................................................59

CAPÍTULO 4: TIPOS DE DATOS ABSTRACTOS........................................................................................61

4.1 INTRODUCCIÓN.............................................................................................................................................614.2 CONCEPTO DE TDA......................................................................................................................................61

4.2.1 Introducción al concepto de TDA.........................................................................................................614.2.2 Uso de los TDA en Programación........................................................................................................654.2.3 Ejemplo de un TDA...............................................................................................................................664.2.4 Consideraciones generales para la elección de primitivas ..................................................................69

4.3 TIPO DE DATOS, ESTRUCTURA DE DATOS Y TIPO DE DATOS ABSTRACTO.......................................................704.4 TIPOS DE DATOS ABSTRACTOS LINEALES......................................................................................................72

4.4.1 El tipo de datos abstracto “Lista”........................................................................................................724.4.2 Pilas......................................................................................................................................................854.4.3 Colas.....................................................................................................................................................89

4.5 EL TDA ÁRBOL ...........................................................................................................................................964.5.1 Introducción y terminología básica......................................................................................................964.5.2 Una aplicación: árboles de expresión ................................................................................................1004.5.3 El TDA Árbol ......................................................................................................................................1014.5.4 El TDA Árbol Binario.........................................................................................................................108

4.6 BIBLIOGRAFÍA ............................................................................................................................................112

Page 4: programacion2

Programación II

6

CAPÍTULO 5: ALGORITMOS AVANZADOS DE ORDENACIÓN Y BÚSQUEDA...............................115

5.1 INTRODUCCIÓN...........................................................................................................................................1155.1.1 Algoritmos simples de clasificación ...................................................................................................1155.1.2 Clasificación por inserción.................................................................................................................1185.1.3 Clasificación por selección.................................................................................................................1195.1.4 Complejidad de tiempos de los algoritmos y cuenta de intercambios ................................................1205.1.5 Limitaciones de los algoritmos simples ..............................................................................................121

5.2 CLASIFICACIÓN RÁPIDA (QUICKSORT) ........................................................................................................1215.2.1 Tiempo de ejecución del quicksort......................................................................................................1255.2.2 Mejoras al quicksort. ..........................................................................................................................126

5.3 CLASIFICACIÓN POR MONTÍCULOS (HEAPSORT)..........................................................................................1275.4 CLASIFICACIÓN POR URNAS (BINSORT).......................................................................................................128

5.4.1 Clasificación general por residuos (radix sort)..................................................................................1305.4.2 Análisis de la clasificación por residuos ............................................................................................131

5.5 TÉCNICAS BÁSICAS DE BÚSQUEDA..............................................................................................................1315.5.1 El diccionario como un tipo de datos abstracto. ................................................................................1315.5.2 Notación algorítmica..........................................................................................................................1325.5.3 Búsqueda secuencial...........................................................................................................................1335.5.4 Eficiencia de la búsqueda secuencial. ................................................................................................1355.5.5 Reordenamiento de una lista para alcanzar máxima eficiencia de búsqueda. ...................................1355.5.6 La búsqueda en una tabla ordenada...................................................................................................1365.5.7 La búsqueda secuencial indexada. .....................................................................................................1365.5.8 Búsqueda binaria................................................................................................................................1405.5.9 Búsqueda por interpolación. ..............................................................................................................141

5.6 ARBOLES BINARIOS DE BÚSQUEDA. ...........................................................................................................1425.7 HASHING. ...................................................................................................................................................146

5.7.1 Introducción. ......................................................................................................................................1465.7.2 Funciones hash. ..................................................................................................................................1465.7.3 Resolución de colisiones.....................................................................................................................1475.7.4 Borrados y rehashing. ........................................................................................................................151

5.8 BIBLIOGRAFÍA. ...........................................................................................................................................151

CAPÍTULO 6: MÉTODOS DE PRUEBA DEL SOFTWARE......................................................................153

6.1 FUNDAMENTOS DE LA PRUEBA DEL SOFTWARE.........................................................................1536.1.1 Objetivos de la prueba........................................................................................................................1536.1.2 Principios de la prueba. .....................................................................................................................1546.1.3 Facilidad de prueba............................................................................................................................154

6.2 DISEÑO DE CASOS DE PRUEBA. ........................................................................................................1556.3 PRUEBA DE CAJA BLANCA.................................................................................................................156

6.3.1 Prueba del camino básico. .................................................................................................................1576.4 PRUEBA DE CAJA NEGRA....................................................................................................................167

6.4.1 Métodos de prueba basados en grafos. ..............................................................................................1686.4.2 Partición equivalente..........................................................................................................................1696.4.3 Análisis de valores límite (AVL) .........................................................................................................1706.4.4 Prueba de comparación......................................................................................................................171

6.5 BIBLIOGRAFÍA. ...........................................................................................................................................172

CAPÍTULO 7: PROGRAMACIÓN ORIENTADA A OBJETOS ................................................................173

7.1 PARADIGMAS DE LA PROGRAMACIÓN.........................................................................................................1737.1.1 Programación estructurada................................................................................................................1737.1.2 Programación orientada a objetos .....................................................................................................1747.1.3 Programación funcional.....................................................................................................................175

Page 5: programacion2

ÍNDICE

7

7.1.4 Programación lógica..........................................................................................................................1767.2 EL ESTILO ORIENTADO A OBJETOS..............................................................................................................177

7.2.1 Características del software ...............................................................................................................1777.2.2 Mecanismos de abstracción................................................................................................................1787.2.3 Descomposición funcional..................................................................................................................1797.2.4 Programación orientada a objetos .....................................................................................................179

7.3 CLASES.......................................................................................................................................................1807.4 OBJETOS.....................................................................................................................................................1817.5 HERENCIA ..................................................................................................................................................1847.6 POLIMORFISMO...........................................................................................................................................1857.7 GENERICIDAD.............................................................................................................................................1867.8 LENGUAJES ORIENTADOS A OBJETOS..........................................................................................................186

7.8.1 Simula .................................................................................................................................................1867.8.2 SmallTalk............................................................................................................................................1867.8.3 C++....................................................................................................................................................1877.8.4 Objective-C.........................................................................................................................................1877.8.5 Java ....................................................................................................................................................187

7.9 BIBLIOGRAFÍA ............................................................................................................................................188

Page 6: programacion2

Capítulo 1: Introducción

1.1 Construcción de softwareEscribir un programa para resolver un problema comprende varios pasos que van desde la

formulación y especificación del problema, el diseño de la solución, su implantación, prueba ydocumentación, hasta la evaluación de la solución. En este capítulo se propone la metodología de laprogramación a seguir para resolver un problema tal como se tratará en este libro. Las fases de estametodología son las siguientes:

1. Formulación y especificación del problema : A veces a la hora de abordar los problemas estosno tienen una especificación simple y precisa. Sin embargo, es posible expresar ciertos aspectosde un problema con un modelo formal y una vez formalizado el problema pueden buscarsesoluciones en función de un modelo preciso y determinar si ya existe un problema que resuelvatal problema. Aún cuando no sea tal el caso será posible averiguar lo que se sabe acerca delmodelo y usar sus propiedades como ayuda para elaborar una buena solución.

2. Diseño de una solución : Partiendo de las especificaciones del modelo formal llegaremos a unaprimera solución en términos generales. Siguiendo una método de diseño de refinamiento porpasos, llegaremos a una solución escrita en seudolenguaje suficientemente detallada para quelas operaciones a realizar con los distintos tipos de datos estén bien determinadas. Entonces secrean los tipos de datos abstractos para cada tipo de datos (con excepción de los datos de tipoelemental como los enteros, los reales o las cadenas de caracteres) dando un nombre deprocedimiento a cada operación y sustituyendo los usos de las operaciones por invocaciones alos procedimientos correspondientes.

3. Implementación de la solución : Seleccionar una implementación para cada tipo de datosabstracto, eligiendo una estructura de datos adecuada para escribir un procedimiento por cadaoperación con ese tipo. El resultado de esta fase será un algoritmo codificado en un lenguajeadecuado que resuelva el problema. A lo largo de este libro el lenguaje que vamos a elegir parala codificación será el lenguaje C.

4. Prueba y documentación de la aplicación : Comprobar que el programa codificado se ajusta alas especificaciones de la primera fase. Se pueden realizar dos tipos de pruebas:

a) Pruebas de verificación : Se centra en la lógica interna del software, se usa sólo parasegmentos cortos del programa.

b) Pruebas de validación : se centra en las funciones externas, realizando pruebas queaseguren que la entrada definida produce realmente los resultados que se requieren, o sea, secomprueba si se está realizando o no el producto adecuado. Para realizar la prueba se debenseleccionar una gran variedad de valores tipos para las variables del programa y prever conanterioridad los resultados que hay que obtener si el programa funciona bien. La prueba sólopuede asegurar la invalidez del programa, no su validez.

Por otro lado, la documentación puede incluirse de forma implícita o explícita:

a) Implícita : Nombres de variables, funciones y procedimientos, que aclaren su papel dentro delprograma.

b) Explícita : Añadir comentarios dentro del código del programa. Pueden ser de dos tipos:

• Prólogo : Aparecen al principio de cada módulo y describen la tarea que realiza.

• Descriptivos : Se incluyen en el cuerpo del código fuente y se usan para describir lasfunciones de procesamiento de determinados bloques de código, añadir comentariossobre los valores que tienen determinadas variables en determinados puntos delprograma, o bien comentar el uso de determinadas variables en el programa.

5. Valoración de la solución : Los criterios de valoración se elaboran en torno a dos requisitos:

a) Legibilidad y claridad del programa.

b) Eficiencia: Se mide desde dos puntos de vista:

Page 7: programacion2

Programación II

10

• Eficiencia en espacio: espacio que ocupan en memoria las estructuras de datos del programa.

• Eficiencia en tiempo: rapidez de ejecución del programa. En la actualidad suele ser másimportante el segundo criterio.

1.2 Fundamentos de diseño de softwareTras el proceso de desarrollo de software se desea obtener software que funcione, y que

además funcione correctamente. Existen unos conceptos fundamentales en la Ingeniería delSoftware que persiguen precisamente este propósito. Estos conceptos se desarrollan a continuacióny son:

• Abstracción.

• Refinamiento.

• Modularidad.

• Arquitectura del software.

• Jerarquía de control.

• Estructuras de datos.

• Procedimientos del software.

• Ocultamiento de la información.

1.2.1 AbstracciónEn el desarrollo de una solución software se consideran normalmente diferentes niveles de

abstracción. Así, existirá una formulación de la solución en el lenguaje, o cercana a este, delproblema. Esta solución se irá refinando hasta obtener una solución implementable. En este procesose va pasando de un nivel de abstracción mas alto hasta el mas bajo a nivel de la implementación.

Pueden considerarse principalmente tres tipos de abstracción:

• Procedimental : una operación puede considerarse como básica a un nivel de abstracción,pero estará compuesta de operaciones mas básicas, que se describen a un nivel deabstracción mas bajo.

• De datos : los datos también pueden representarse a diferentes niveles de detalle

• De control : los detalles internos de determinados mecanismos de abstracción tambiénpueden abstraerse.

1.2.2 RefinamientoComo ya se ha mencionado en el punto anterior, una solución software se va obteniendo

normalmente mediante refinamientos sucesivos de la formulación inicial de la solución. Se varealizando un proceso de refinamiento de los detalles procedimentales, hasta llegar finalmente a lassentencias de un lenguaje de programación.

1.2.3 ModularidadEn el proceso de desarrollo de software este se descompone en componentes, a los cuales

se le asocia normalmente un nombre. Estos componentes se denominan módulos. De esta forma elsoftware se hace mas manejable, ya que es mas sencillo enfrentarse a un problema por partes que aa la totalidad. Se aplica así la técnica de divide y vencerás.

Page 8: programacion2

Introducción

11

Sin embargo, la descomposición del software en un número excesivo de módulos puedeproducir un aumento de la complejidad ya que aumentan las interfaces entre los módulos.

1.2.4 Arquitectura del so ftwareLa arquitectura del software se refiere a la estructura jerárquica de los componentes

procedimentales y las estructuras de datos.

Unos mismos requisitos para el software pueden dar lugar a diferentes estructuras y noresulta de forma general sencillo determinar que estructura de las posibles es mas adecuada.

1.2.5 Jerarquía de contro lLa jerarquía de control se denomina también estructura del programa. La organización de los

módulos de un programa se realiza normalmente de forma jerárquica. De esta forma, existiránmódulos subordinados y otros módulos superiores. Cada módulo superior podrá ser a su vez unmódulo subordinado.

Se definen por tanto los siguientes conceptos:

• Visibilidad : componentes que pueden ser invocados o usados sus datos por otro componente(tanto de forma directa o indirecta).

• Conectividad : módulos a los que invoca o utiliza sus datos otro módulo.

La visibilidad y conectividad de cada módulo son dos características de la jerarquía de control.

1.2.6 Estructura de datosLa estructura de datos es la relación lógica entre los distintos elementos individuales de

datos. Esta relación determina en gran medida el diseño procedimental final.

El número organizaciones de datos esta solo limitado por el ingenio del diseñador, peroexisten sin embargo una serie de estructuras clásicas como son las listas, pilas colas, etc. Estasestructuras de datos se tratarán en detalle en el capítulo siguiente.

1.2.7 Procedimientos de l softwareEl procedimiento del software se centra en los detalles de procesamiento de cada módulo

individual, incluyendo secuencias de sucesos, puntos de decisión, repetición de operaciones, etc.

1.2.8 Ocultamiento de in formaciónUno de los principios en la descomposición modular de una solución es que la información

contenida en un módulo sea inaccesible al resto de los módulos que no necesitan acceder a esainformación.

De esta forma se conseguirán módulos independientes, estableciéndose la comunicaciónentre esos módulos solo mediante la información necesaria para realizar su función.

Page 9: programacion2

Programación II

12

1.3 Bibliografia

• [AU87] A.V. Aho, J.A. Ullman. Data structures and algorithms. Addison-Wesley. 1992.

• [FGG98] J. Fernández, A. Garrido, M. García. Estructuras de datos. Un enfoque prácticousando C. Universidad de Granada. 1998.

• [Pres94] R. S. Pressman. Ingeniería del software.Mc Graw Hill. 1994.

Page 10: programacion2

Capítulo 2: Recursividad

2.1 Introducción a la recursividad.La recursividad es una técnica ampliamente utilizada en matemáticas, y que básicamente

consiste en realizar una definición de un concepto en términos del propio concepto que se estádefiniendo. Veamos algunos ejemplos:

• Los números naturales se pueden definir de la siguiente forma: 0 es un número natural y elsucesor de un número natural es también un número natural.

• El factorial de un número natural n, es 1 si dicho número es 0, o n multiplicado por el factorialdel número n-1, en caso contrario.

• La n-ésima potencia de un número x, es 1 si n es igual a 0, o el producto de x por la potencia(n-1)-ésima de x, cuando n es mayor que 0.

En todos estos ejemplos se utiliza el concepto definido en la propia definición, es decir, unnúmero natural se define en términos de los naturales anteriores a él, o el factorial de un número seestablece en función del factorial de otro número.

La recursividad es una técnica de resolución de problemas muy potente ya que muchosproblemas que a primera vista parecen poseer una solución difícil, son resueltos de manera sencilla yelegante, incluso inmediata de forma recursiva. Este método divide el problema original en varios máspequeños que son del mismo tipo que el inicial, procediendo seguidamente a encontrar la solución deestos subproblemas, soluciones en las que se basará la del problema inicialmente planteado. Paraello, realiza de nuevo las correspondientes divisiones en problemas más pequeños aún, hasta queéstos tengan un tamaño tan pequeño para que su solución se conozca de forma directa. Una vezconocidas estas soluciones, podremos ir resolviendo sucesivamente los problemas más grandeshasta llegar a la solución buscada del problema primeramente planteado. Es de vital importancia queexitan dichas soluciones a los problemas más sencillos, por que nos permitirán, a partir de ellas, irresolviendo los problemas más complejos.

Para ilustrar el funcionamiento de la resolución de problemas mediante la recursividadindicado en el párrafo anterior, supongamos que se pudiera resolver un problema P conociendo lasolución de otro problema Q que es del mismo tipo que P, pero más pequeño. Igualmente,supongamos que pudiéramos resolver Q mediante la búsqueda de la solución de otro nuevoproblema, R, que sigue siendo del mismo tipo que Q y P, pero de un tamaño menor que ambos. Si elproblema R fuera tan simple que su solución es obvia o directa, entonces, dado que sabemos lasolución de R, procederíamos a resolver Q y, una vez resuelto, finalmente se obtendría la solucióndefinitiva al primer problema, P.

El típico problema que clarifica lo descrito en el párrafo anterior es el cálculo del factorial deun número, por ejemplo 5. Calcular 5! se basa en multiplicar 5 por 4!, por tanto, el obtener el valor de5! dependerá de cuánto valga 4! (se ha reducido el tamaño del problema en una unidad). Nuestronuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 * 3!. Así, paracalcular 4! debemos hallar 3!. Si seguimos descomponiendo el problema en otro más pequeño demanera sucesiva tendremos que 3! es 3 * 2!, 2! = 2 * 1! y 1! = 1 * 0!. En la definición recursiva delfactorial se dice que el factorial de 0 es 1, por tanto, hemos encontrado la solución al caso mássencillo. Con esta solución podemos ir hacia atrás construyendo las de los problemas más complejos:

0! = 1

1! = 1 * 0! = 1

2! = 2 * 1! = 2

3! = 3 * 2! = 6

4! = 4 * 3! = 24

5! = 5 * 4! = 120

Page 11: programacion2

Programación II

14

Del ejemplo anterior, deducimos que la descomposición del problema deberá finalizar enalgún momento, ya que si no fuera así, estaríamos siempre aplicando la misma operación de divisiónde los problemas, sin llegar a ningún lado y con peligro de agotar los recursos del ordenador. Portanto, es de vital importancia para aplicar la recursividad determinar aquellos subproblemas cuyasoluciones vengan dadas por soluciones directas o conocidas, pero no recursivas.

Otro ejemplo de la resolución de un problema de forma recursiva, que sin duda algunaaclarará bastante las ideas y que se sale del ámbito de las matemáticas, es la búsqueda de unapalabra en un diccionario: abrimos el diccionario más o menos por la mitad, decidimos en qué mitadestá la palabra buscada. Si está en la primera, buscamos la palabra en dicha mitad utilizando estamisma técnica y si lo está en la segunda, hacemos lo propio con esa segunda parte del diccionario.De esta forma vamos reduciendo el tamaño del diccionario donde buscamos hasta que tenga eltamaño de una página, momento en el cual será fácil buscar la palabra.

En este ejemplo podemos observar cómo el problema se va reduciendo de tamaño: elproblema de buscar una palabra en un diccionario se reduce al de buscarla en una de sus mitades, elcual se convertirá en la búsqueda en su correspondiente mitad, es decir, en un cuarto del diccionario,y así sucesivamente hasta que el proceso finalice cuando el diccionario conste tan sólo de unapágina, situación en la que será fácil localizar la palabra, pues sólo habrá que buscarlasecuencialmente.

De manera general, aquellos problemas que puedan ser resueltos de forma recursiva tendránlas siguientes características:

1. Los problemas pueden ser redefinidos en términos de uno o más subproblemas, idénticosen naturaleza al problema original, pero de alguna forma menores en tamaño.

2. Uno o más subproblemas tienen solución directa o conocida, no recursiva.

3. Aplicando la redefinición del problema en términos de problemas más pequeños, dichoproblema se reduce sucesivamente a los subproblemas cuyas soluciones se conocendirectamente.

4. La solución a los problemas más simples se utiliza para construir la solución al problemainicial.

Cuando plasmamos la solución de un problema de manera recursiva en un algoritmo diremosentonces que ha sido resuelto mediante un algoritmo recursivo. Este algoritmo, o en general cualquiermódulo, tendrá que realizar una o varias llamadas a sí mismo, las cuales descompondrán el problemaen otros subproblemas con un tamaño más reducido, hasta que se lleguen a problemas cuyassoluciones se conozcan, en cuyo caso irán devolviendo el control a los módulos desde donde fueronllamados, solventando así los problemas mayores que los originaron.

Es muy importante que siempre se determine en qué momento se detendrán las llamadasrecursivas del módulo, ya que si no se hace, se corre el riesgo de no llegar nunca a la solución delproblema inicial por estar llamándose el módulo a sí mismo de forma infinita.

Un aspecto que hay que tener en cuenta cuando se está diseñando un algoritmo recursivo esla cuestión de la eficiencia. La recursividad es una alternativa a la resolución de problemas de formaiterativa y, como ya hemos dicho, ofrece soluciones simples y elegantes a algunos problemas,aunque en algunos casos estas soluciones no son mejores que las correspondientes iterativas,fundamentalmente por que son tan ineficientes que son totalmente impracticables. Por tanto, y comoveremos en las secciones siguientes, a la hora de diseñar un algoritmo, habrá que decidir que tipo desolución, recursiva o iterativa, es la más idónea.

Page 12: programacion2

Recursividad

15

Una vez que hemos diseñado el algoritmo recursivo, el siguiente paso dentro del proceso dedesarrollo del software es realizar su implementación en un lenguaje de programación, aunque notodos están preparados para ello. Lenguajes como BASIC, FORTRAN o COBOL no permiten laimplementación de algoritmos recursivos, por lo que se descartará este método de resolución deproblemas en caso de tener que utilizarlos, buscando vías alternativas. Otros como C, C++, Pascal oJava sí incluyen esta característica. En nuestro caso, utilizaremos el leguaje C para estudiar losconceptos básicos de la recursividad, creando para ello, y por analogía con los algoritmos, funcionesrecursivas1.

En el resto del tema nos centraremos en el estudio detallado de algunos aspectos necesariospara implementar funciones recursivas en C, como son las consideraciones básicas para crear dichossubprogramas (sección 2), el funcionamiento de la pila durante la ejecución de una función recursiva(sección 3), el paso de parámetros a funciones de ese tipo (sección 4), las ventajas e inconvenientesde las soluciones recursivas frente a las iterativas (sección 5), la conversión de una resoluciónrecursiva en iterativa (sección 6), y finalmente, el esbozo de algunas técnicas de resolución deproblemas basadas en recursividad (sección 7).

2.2 Diseño de módu los recursivos.Comencemos clasificando a las funciones recursivas. Si una función F contiene una llamada

explícita a sí misma, entonces se dice que es recursiva de forma directa, pero si F posee unareferencia a otra función Q, que a su vez contiene una llamada a F, entonces F es recursiva deforma indirecta.

La primera implementación de una función recursiva que vamos a tratar es el factorial de unnúmero, la cual se obtiene de manera directa a partir de la propia definición del factorial (en este casoes una función recursiva directa):

long factorial (long n) if (n == 0) return 1; else return n * factorial(n-1);

En esta función podemos claramente determinar dos partes principales:

1. la llamada recursiva, que expresa el problema original en términos de otro más pequeño,y

2. el valor para el cual se conoce una solución no recursiva. Esto es lo que se conoce comocaso base: una instancia del problema cuya solución no requiere de llamadas recursivas.

La existencia del caso base cubre fundamentalmente dos objetivos

1. Actuar como condición de finalización de la función recursiva. Sin el caso base la rutinarecursiva se llamaría indefinidamente y no finalizaría nunca.

1Como ya hemos dicho anteriormente, la línea lógica de desarrollo de un programa que implementeuna solución recursiva a un problema, y en general cualquier tipo de solución, sería diseñar elalgoritmo y posteriormente implementarlo, en nuestro caso en C, pero debido a la simplicidad de losproblemas que vamos a tratar inicialmente, éstos se van a implementar directamente sin necesidadde exponer explícitamente su algoritmo, aunque obviamente se comentará como se solucionarán losproblemas tratados.

Page 13: programacion2

Programación II

16

2. Es el cimiento sobre el cual se construirá la solución completa al problema.

Veamos seguidamente, en la figura 1, una traza de las llamadas recursivas a la funciónfactorial originadas para calcular el correspondiente valor de 5 desde una función main:

Figura 1: evolución de las llamadas recursivas del factorial de 5.

A la hora de resolver recursivamente un problema, son cuatro las preguntas que nosdebemos plantear:

[P1] ¿Cómo se puede definir el problema en términos de uno o más problemas máspequeños del mismo tipo que el original?

[P2] ¿Qué instancias del problema harán de caso base?

[P3] Conforme el problema se reduce de tamaño ¿se alcanzará el caso base?

[P4] ¿Cómo se usa la solución del caso base parar construir una solución correcta alproblema original?

Apliquemos esta técnica de preguntas y respuestas para diseñar una función recursiva queobtenga el valor del n-ésimo término de la secuencia de Fibonacci, que fue creada inicialmente paramodelar el crecimiento de una colonia de conejos. La secuencia es la siguiente: 1, 1, 2, 3, 5, 8, 13,21, 34, 55, 89, 144, ...

Se puede observar que el tercer término de la sucesión se obtiene sumando el segundo y elprimero. El cuarto, a partir de la suma del tercero y el segundo. El problema es calcular el valor del n-ésimo término de la solución, que se obtendrá sumando los términos n-1 y n-2.

Page 14: programacion2

Recursividad

17

Las respuestas a la preguntas anteriores serían:

[P1] fibonacci(n) = fibonacci(n-1) + fibonacci(n-2).

[P2] En este caso hay que seleccionar como casos bases fibonacci(1) = 1 y fibonacci(2)=1.

[P3] En cada llamada a la rutina fibonacci se reduce el tamaño del problema en uno o en dos,por lo que siempre se alcanzará uno de los dos casos bases.

[P4] fibonacci(3) = fibonacci(2) + fibonacci(1) = 1 + 1. Se construye la solución del probleman==2 a partir de los dos casos bases.

Teniendo en cuenta estas respuestas estamos preparados para implementar la funciónfibonacci en C:

in t fibonacci(int n) if ((n == 1) || (n == 2)) return 1; else return (fibonacci(n-2) + fibonacci(n-1));

La evolución de las llamadas recursivas para calcular el valor del cuarto término de la seriesería la siguiente:

Figura 2: evolución de las llamadas recursivas para calcular fibonacci(3).

Page 15: programacion2

Programación II

18

Esta función posee dos peculiaridades: tiene más de un caso base y hay más de una llamadarecursiva, denominándose a este tipo recursión no lineal, en contraposición a la recursión linealproducida cuando sólo hay una única llamada recursiva. Generalmente, la recursión no lineal requierela identificación de más de un caso base.

Seguidamente vamos a estudiar tres ejemplos más: el cálculo del máximo común divisor dedos números, contar el número de veces que aparece un valor en un vector y calcular el número dedistintas formas de seleccionar k objetos de entre un total de n. Con ello podremos afianzar la técnicade las cuatro preguntas.

Máximo común divisor de dos números m y n.

En los dos problemas anteriores, la solución obtenida al encontrar el caso base sirve paraconstruir las soluciones a los problemas mayores que han derivado al caso base, pero hay ocasionesdonde esa solución del caso base es la propia solución del problema original. Este problema es unejemplo.

Se pretende implementar una función recursiva en C que calcule el máximo común divisor(MCD) de dos enteros m y n, no negativos, que es el mayor entero que divide a ambos.

El algoritmo de Euclides para encontrar el MCD de m y n es el siguiente: si n divide a m,entonces MCD(m, n) = n, y en otro caso, MCD(m, n) = MCD(n, m mod n), donde m mod n es el restode la división de m por n (en C se representa con el operador %).

Apliquemos la técnica de las cuatro preguntas para ver si esta definición está correctamentehecha:

[P1] Es evidente que MCD(m, n) ha sido definida en términos de un problema del mismo tipo,pero justifiquemos que MCD(m, n mod m) es de tamaño menor que MCD(m, n):

1. El rango de m mod n es 0, ..., n-1, por tanto el resto es siempre menor que n.

2. Si m > n, entonces MCD(n, m mod n) es menor que MCD(m, n).

3. Si n > m, el resto de dividir m entre n es m, por lo que la primera llamada recursiva esMCD(n, m mod n) es equivalente a MCD(n, m), lo que tiene el efecto de intercambiar losargumentos y producir el caso anterior.

[P2] Si n divide a m, si y sólo si, m mod n = 0, en cuyo caso MCD(m, n) = n, por tanto, seconseguirá el caso base cuando el resto sea cero.

[P3] Se alcanzará el caso base ya que n se hace más pequeño en cada llamada y además secumple que 0 ≤ m mod n ≤ n-1.

[P4] En este caso, cuando se llega al caso base es cuando se obtiene la solución final alproblema, por lo que no se construye la solución general en base a soluciones de problemas mássimples.

Teniendo en cuenta las respuestas anteriores, la función en C que implementa el cálculo delmáximo común divisor es la siguiente:

int MCD (int m, int n) if (m % n == 0) return n; else return (MCD(n, m % n));

Page 16: programacion2

Recursividad

19

Contar del número de veces que aparece un valor dado en un vector.

Dado un vector de n enteros, el problema a resolver recursivamente consiste en contar elnúmero de veces que un valor dado aparece en dicho vector.

Comencemos por las respuestas a las preguntas P1, P2, P3 y P4:

[P1] Si el primer elemento del vector es igual que el valor buscado, entonces la solución será1 más el número de veces que aparece dicho valor en el resto del vector. Si no estuviera en laprimera posición, la solución al problema original será sólo el número de veces que el valor seencuentra en las posiciones restantes, por lo que siempre se formula un nuevo problema con untamaño menor del vector en una unidad.

[P2] El caso base será cuando el vector a inspeccionar no tenga elementos, por lo que elnúmero de ocurrencias del valor buscado en dicho array será cero.

[P3] Cada llamada recursiva se reduce en uno el tamaño del vector, por lo que nosaseguramos que en N llamadas habremos alcanzado el caso base.

[P4] Cuando se encuentra una coincidencia se suma uno al valor devuelto por otra llamadarecursiva. El retorno del control de las sucesivas llamadas comenzará inicialmente sumando 0 cuandonos hayamos salido de las dimensiones del vector.

La función en C que implementa la solución recursiva al problema en curso tiene cuatroparámetros: el tamaño del vector (N), el propio vector (Vector), el valor a buscar (Objetivo) y el índicedel primer elemento del vector a procesar (Primero) y su código es el siguiente:

int ContarOcurrencias (int N, int *Vector, int Objetivo, int Primero) if (Primero > N-1) return 0;else if (Vector[Primero] == Objetivo)

return (1 + ContarOcurrencias(N,Vector, Objetivo, Primero+1)); else return (ContarOcurrencias(N,Vector, Objetivo, Primero+1));

Combinaciones sin repetición de n objetos tomados de k en k.

En este problema típico de combinatoria se desea calcular cuántas combinaciones de kelementos se pueden hacer de entre un total de n (combinaciones sin repetición de n elementostomados de k en k). La función recursiva que implementaremos en C se llamará "ncsr" y tendrá dosparámetros: el número total de elementos de que disponemos, n, y el tamaño del conjunto que sedesea generar, k.

Este enunciado nos sirve para ejemplificar cómo los casos bases pueden ser elaborados enfunción de varias condiciones. Las respuestas a las cuatro preguntas son las siguientes:

[P1] El cálculo del número de combinaciones de n elementos tomados de k en k se puededescomponer en dos problemas: calcular el número de combinaciones de n-1 elementos tomados dek en k y el número de combinaciones de n-1 elementos tomados de k-1 en k-1, siendo estos dosnuevos problemas instancias más pequeñas que el original de donde provienen y del mismo tipo. Elresultado del problema inicial se obtendrá, por tanto, sumando ambos resultados: ncsr(n, k) = ncsr(n-1, k-1) + ncsr(n-1, k).

Page 17: programacion2

Programación II

20

Para facilitar el entendimiento del problema, supongamos que tenemos un total de n personasy queremos determinar cuántos grupos de k personas se pueden formar de entre las n personas entotal. La forma de resolución recursiva que se ha elegido se puede interpretar como la suma de losgrupos que se pueden hacer de un tamaño k sin contar a la persona A (ncsr(n-1,k)), más todos losque se puedan realizar incluyendo a esa misma persona, ncsr(n-1, k-1) (contamos todos los que sepueden hacer con una persona menos y luego le añadimos esa persona).

Veamos el siguiente ejemplo: tenemos cuatro personas: A, B, C y D, y queremos determinarcuántos grupos de dos personas podemos formar, o lo que es lo mismo, calcular ncsr(4,2). Este valorse obtiene al aplicar la definición: ncsr(3,1)+ncsr(3,2). Fijada la persona A, calculamos cuántos gruposse pueden realizar con una persona menos, que en este caso es A (ncsr(3,1) será igual a 3 ya quepodemos hacer 3 conjuntos de tamaño 1:B, C y D) y se lo sumamos al número de comités quese pueden hacer, también de tamaño 2, pero sin A (ncsr(3,2) será también 3 ya que podemos generarB,C, B,D y D,C).

[P2] Los casos bases son los siguientes:

Si k > n entonces no hay suficientes elementos para formar combinaciones de tamaño k,en cuyo caso el total de combinaciones en este caso es 0.

Si k = 0, entonces este caso sólo ocurre una vez.

Si k = n, el número total de elementos a tomar coincide con el tamaño del grupo deelementos de donde seleccionarlo, siendo uno el número de combinaciones en estecaso.

Resumiendo: ncsr(n, n) = 1, ncsr(n,0)=0 y ncsr(n, k)=0 con k > n.

[P3] Considerando que en una de las llamadas recursivas restamos uno al número deelementos, y en la otra se resta la unidad tanto al número de elementos como al tamaño de lacombinación, nos aseguramos que en un número finito de pasos se habrá alcanzado uno de loscasos bases.

[P4] Apenas se haya encontrado un caso base, uno de los sumandos tendrá un valor.Cuando el otro sumando alcance a un caso base, se podrá hacer la suma y se devolverá a un valorque irá construyendo la solución final conforme se produzcan las devoluciones de control.

Para concluir, la implementación de la función que acabamos de diseñar es la siguiente:

int ncsr(int n, int k) if (k > n) return 0; else if (n == k || k == 0) return 1; else return ncsr(n-1, k) + ncsr(n-1, k-1);

2.3 La pila del orden ador y la recursividad.En esta sección vamos a estudiar cómo gestiona un ordenador las llamadas recursivas

cuando éste ejecuta un módulo recursivo. Así, comprendiendo este funcionamiento, seremosconscientes de los problemas que se nos pueden plantear si el diseño del módulo recursivo no escorrecto.

La memoria de un ordenador a la hora de ejecutar un programa queda dividida en dos partes:la zona donde se almacena el código del programa y la zona donde se guardan los datos, utilizadaésta última fundamentalmente en las llamadas a rutinas y que se denomina pila (en inglés stack).

Page 18: programacion2

Recursividad

21

Cuando un programa principal llama a una rutina M, se crea en la pila lo que se denomina elregistro de activación o entorno E, asociado al módulo M. El registro de activación almacenainformación como constantes y variables locales del módulo, así como sus parámetros formales.

Figura 3: estado de la pila cuando se llaman tres módulos de manera anidada.

Conforme se van llamando de manera anidada a las rutinas, se van creando sucesivosregistros de activación, almacenándose de forma apilada: si M1 llama a M2 y a su vez, invoca a M3,la pila estará formada por un primer registro de activación correspondiente a M1, E1, sobre el que sereservará espacio para el de M2, E2, y posteriormente para el de M3, E3, conviviendo los tresentornos mientras se esté ejecutando M3. Esta situación queda representada gráficamente en elgráfico de la figura 3.

Cuando finaliza la ejecución del último módulo invocado, se devuelve el control a M2, y elespacio ocupado por su entorno se libera, quedando ahora la pila compuesta sólo por dos registrosde activación. Al acabar M2 se elimina de la pila el entorno E2, devolviéndose el control a M1 yquedando sólo el registro E1 correspondiente a ese último módulo, que desaparecerá, a su vez,cuando acabe su ejecución.

Como se puede observar, este trozo de memoria recibe el nombre de pila por la forma en quese gestiona, ya que en él se encuentran los entornos apilados en el orden en que han sido llamados,de tal manera que el registro activo en cada momento es el que está situado en la cabecera de la pila,ocupando así el lugar más alto de esta hipotética pila. Esta forma de gestionar ese espacio dememoria permite que el lenguaje de programación correspondiente pueda utilizar la recursividad.

En la siguiente figura se introduce una notación gráfica para representar el registro deactivación de un módulo. El módulo se representa mediante una rectángulo con cuatro divisiones: laprimera contendrá el nombre del módulo, la segunda el área de almacenamiento de los argumentosformales y sus valores, la tercera la zona donde aparecerán las variables locales e igualmente susvalores, y por último, una cuarta zona, creada sólo a efectos pedagógicos, donde aparecerán algunassentencias que pueden ser interesantes para entender el funcionamiento del módulo.

Page 19: programacion2

Programación II

22

Figura 4. Notación gráfica para representar el registro de activación.

En la pila existirán tantos entornos de una misma función como llamadas recursivas se hayanefectuado, siendo todos ellos independientes y distintos entre sí. Así, llamamos profundidad derecursión de un módulo recursivo al número de entornos que están presentes en la pila en unmomento dado.

El concepto de profundidad se suele usar como elemento de decisión para optar por unasolución recursiva de un problema, ya que problemas que requieran profundidades muy grandespueden originar que se llene la memoria del ordenador dedicada a la pila (desbordamiento de la pila).Obviamente, que la profundidad de un módulo recursivo sea pequeña no indica que el algoritmo queimplemente sea eficiente. Como regla general, se evitará utilizar una solución recursiva en aquelloscasos en los que la profundidad sea muy grande.

Seguidamente, y mediante un ejemplo, estudiaremos cómo evoluciona la pila durante lasdiferentes llamadas recursivas. El problema que vamos a resolver en este caso es la impresión de npalabras recibidas desde la entrada estándar en orden contrario al de entrada en la salida estándar:desde la última palabra introducida hasta la primera. Por ejemplo, si se introdujera la frase "En unlugar de la Mancha de cuyo nombre no quiero acordarme", la función recursiva imprimiría: "acordarmequiero no nombre cuyo de Mancha la de lugar un En".

Cabe destacar que en este problema, y al igual que ocurrió en el que contaba las ocurrenciasde un valor en un vector, y en general en todos aquellos que impliquen un procesamiento de listas deobjetos, se procesará inicialmente el primer elemento de la lista y posteriormente y de formarecursiva, el resto.

Comencemos a diseñar la función recursiva Imp_OrdenInverso, que recibirá como argumentoel número de palabras que quedan por leer (n). Aplicaremos la técnica de responder a las yaconocidas cuatro preguntas:

[P1] Se lee la primera palabra, y recursivamente se llama a la función para que lea e imprimael resto de palabras, para que finalmente se imprima la primera leída. El problema de leer n palabrase imprimirlas hacia atrás se convierte en realizar el mismo proceso pero sobre n-1 palabras.

[P2] El caso más simple en la impresión en orden inverso de una cadena será cuando sóloquede una palabra (n == 1), en cuyo caso se imprimirá directamente.

[P3] Como en cada llamada recursiva hay que leer una palabra menos del total, en n-1llamadas se habrá alcanzado el caso base.

Page 20: programacion2

Recursividad

23

[P4] Una vez se haya alcanzado el caso base (se haya leído la última palabra y se imprima)al devolver continuamente el control a las funciones invocantes se irán imprimiendo de atrás a delantelas palabras obtenidas desde la entrada estándar.

La implementación en C de la rutina Imp_OrdenInverso es la siguiente:

void Imp_OrdenInverso(int n) char Palabra[MAXTAMPAL];

if (n == 1) scanf("%s", Palabra); printf("%s", Palabra); else scanf("%s", Palabra); Imp_OrdenInverso(n-1); printf("%s", Palabra);

MAXTAMPAL es una constante declarada en algún fichero que albergará el valor del tamañomáximo de una palabra.

Retomemos el objetivo principal de esta sección, para lo cual estudiaremos cómo evolucionagráficamente la pila si llamáramos a la función Imp_OrdenInverso con un valor del parámetro n igual a5, representado gráficamente por las dos siguientes gráficos (figuras 5 y 6).

Al llamar a Imp_OrdenInverso(5), se crea el registro de activación correspondiente, en el cualse reserva memoria para almacenar el valor del parámetro actual n == 5 y la variable palabra. Secomienza a ejecutar el código de dicha función y tras comprobar que no se da el caso base, elusuario introduce una primera cadena de caracteres, por ejemplo, "uno". A continuación se produce lallamada recursiva con una unidad menos en n (n==4). Encima del registro de la llamada inicial secrea un nuevo registro de activación, en este caso para la invocación Imp_OrdenInverso(4) y sereservan las zonas de memoria para los parámetros y las variables locales. Este proceso se repitetres veces más, encontrándose en el tope de la pila el registro correspondiente a la llamadaImp_OrdenInverso(1), momento en el cual se cumple el caso base, n == 1, y se lee la última palabra yseguidamente se imprime por la salida estándar. Este es el momento al que se corresponde la 5.

Page 21: programacion2

Programación II

24

. Figura 5. Evolución de la pila con las llamadas recursivas.

Una vez finalizada la función, se elimina el registro de la pila, quedando en el tope la llamadaanterior, es decir, el caso de n==4. Para finalizar la ejecución de esa función sólo queda imprimir lapalabra leída (palabra == "cuatro") y se devolverá el control a la función que la invocó, tras eliminarsela zona de memoria reservada para alojar a esta función. El mismo proceso se repite para lasfunciones que fueron invocadas con n == 3, 2 y 1 respectivamente. Al llegar el tope de la pila alprimer registro de activación, se imprimirá la palabra "una" y finalizará su ejecución devolviendo elcontrol a la función main. En la salida estándar se habrá escrito "cinco cuatro tres dos uno". Elvaciado de la pila según van devolviendo el control las funciones recursivas corresponde a la figura 6.

Page 22: programacion2

Recursividad

25

Figura 6. Vaciado de la pila con el retorno de las llamadas recursivas.

Para finalizar, hacer hincapié en la corrección de los casos base como forma de evitar unarecursión infinita, y por tanto, que la pila se agote:

1. Cada rutina recursiva requiere como mínimo un caso base, sin el cual se generaránuna secuencia de llamadas infinitas originando el agotamiento de la pila.

2. Se debe identificar todos los casos base ya que en caso contrario se puede producirrecursión infinita.

3. Los casos bases deben ser correctos, es decir, las sentencias ejecutadas cuando sedé un caso base deben originar una solución correcta para una instancia particular delproblema. En caso contrario, se generará una solución incorrecta.

4. Hay que asegurarse que cada subproblema esté más cercano a algún caso base,por lo que en repetidas llamadas se alcanzará alguno de éstos.

Aconsejamos al lector para que llegue a comprender totalmente cómo funciona la pila delordenador, que implemente los ejemplos mostrados en este capítulo y los ejecute paso a pasoutilizando un depurador. De esta forma, podrá observar cómo va evolucionando la pila conforme seproducen las llamadas recursivas.

2.4 Paso de parámet ros a los módulos recursivos.Hay que hacer algunas consideraciones importantes a la hora de determinar los parámetros

formales que tendrá un módulo recursivo y si éstos serán de entrada, salida o de entrada/salida (si sepasarán por copia o por referencia) ya que puede influir en la correcta ejecución del módulo recursivo.Centrémonos en primer lugar en la forma de pasar los parámetros formales.

Debemos tener mucho cuidado a la hora de decidir el tipo de paso de parámetros.Supongamos que modificamos el código de la función factorial y el parámetro formal n quepasábamos por copia, ahora lo pasamos por referencia:

Page 23: programacion2

Programación II

26

long factorial (long * n) if (*n == 0) return 1; else *n = *n -1; return (*n+1) * factorial(n); El desarrollo de las llamadas recursivas sería correcto, de tal forma que n se va reduciendo a

0, momento en el cual se comenzarán a devolver las llamadas recursivas al haberse alcanzado elcaso base. Pero, ¿qué ocurre en ese momento? Que *n siempre será igual a 0, y por tanto, sedevolverá siempre 1, por lo que finalmente el factorial de cualquier número será 1, resultado a todasluces incorrecto. Para aclarar lo anterior, estudiemos la traza correspondiente al siguiente trozo decódigo para calcular el factorial de 3;

long fact, tamanio= 3;

fact= factorial(&tamanio);

fact= factorial(*n == 3)

(*n == 2) * factorial (*n == 2)

(*n == 1) *factorial(*n == 1)

(*n==0)*factorial(*n==0)

return 1.

Ë

return (*n==0) * 1 = 0

Ë

return (*n==0) * 0

Ë

return (*n==0) * 0

Ë

fact= 0

En este caso, n no se puede pasar por referencia porque en la vuelta atrás de la recursividadse va a utilizar el valor antiguo que tenía antes de invocar a la función recursiva. La regla general paradecidir si un parámetro será pasado por copia o por referencia es la siguiente: si se necesita recordarel valor que tenía un parámetro una vez que se ha producido la vuelta atrás de la recursividad,entonces ha de pasarse por valor. En caso contrario, se podrá pasar por referencia.

Obviamente, si convertimos una función en un procedimiento habrá que introducir un nuevoparámetro pasado por referencia para ir guardando el valor solución en cada momento:

void factorial (long n, long *fact) if (n == 0) *fact=1; else factorial(n-1, fact); *fact = *fact * n;

Page 24: programacion2

Recursividad

27

El otro aspecto a tener en cuenta en el diseño de la cabecera de un módulo es el número y eltipo de parámetros que se le pasarán. No es conveniente pasar ni muchos parámetros ni parámetrosque tengan un tamaño muy grande (por ejemplo, vectores o estructuras extensas), ya que en cadallamada recursiva la cantidad de memoria ocupada en la pila que se necesita para almacenar losparámetros actuales es muy grande, y en pocas llamadas recursivas se podría agotar.

Por ejemplo, si en vez de haber diseñado la cabecera de la función ContarOcurrencias comoint ContarOcurrencias (int N, int *Vector, int Objetivo, int Primero), hubiera hecho intContarOcurrencias ( int Vector[MAX], int Objetivo, int Primero), en cada llamada recursiva se hará unacopia de Vector en la pila y si el tamaño MAX es muy grande, se corre el riesgo de agotar la memoriaa las pocas llamadas.

Se podría solucionar el inconveniente del número y tamaño de los parámetros de tres formasdiferentes:

a) Declarar algunas variables como globales, y por tanto, no pasarlas como parámetrosa la función.

b) Hacer pasos por referencia. De esta manera se introduce a la función punteros enlugar de los propios objetos, reduciendo el tamaño requerido (esta es la soluciónrecogida en la función ContarOcurrencias).

c) Crear una función y dentro de ella implementar la función recursiva (la primerarecubrirá a la segunda). Los parámetros se pasarán únicamente a la primera y seránglobales a la segunda, la cual no tendrá parámetros formales o si los tiene, serán muypocos.

Aunque son soluciones posibles, para evitar efectos secundarios y escribir un código lo máslimpio posible, sólo vamos a tomar como única solución válida la b) olvidando completamente las dosrestantes en nuestras implementaciones.

Como resumen, habrá que tener en cuenta que:

1. Generalmente pasar por copia los parámetros necesarios para determinar si se haalcanzado el caso base.

2. Si la rutina acumula valores en alguna variable o vector, éste debe ser pasado porreferencia. Cualquier cambio en dicho vector se repercutirá en el resto de llamadasrecursivas posteriores.

3. Aunque depende del tipo de lenguaje, si existen en él como mínimo el paso porreferencia y el paso por copia, como ocurre en C, los vectores y las estructuras dedatos grandes no se pasarán por copia ya que se requiere una mayor demanda dememoria, si no que se pasarán por referencia y para evitar que se puedan modificarde manera errónea, se utilizará la palabra reservada const si trabajamos en C, ocualquier mecanismo de protección de los parámetros.

2.5 ¿ Recursividad o iteración ?La recursividad es una herramienta muy poderosa para resolver problemas, los cuales

resueltos iterativamente podrían tener una resolución muy compleja, sobre todos aquellos cuya propiadefinición es recursiva, aunque esto no asegura que dichos algoritmos recursivos posean unaeficiencia alta, ya que suelen consumir un mayor tiempo de cálculo.

En general, si un problema se puede describir en términos de versiones más pequeñas de élmismo, entonces la recursividad permite expresarlo, en la mayoría de los casos, y por tantoimplementarlo más fácilmente. De igual forma, cuando la recursividad nos permita solucionarproblemas cuyas soluciones iterativas sean difíciles de implementar, utilizaremos esta primeratécnica.

Page 25: programacion2

Programación II

28

A pesar de estas ideas expresadas en el párrafo anterior, hay dos aspectos que influyen en laineficiencia de algunas soluciones recursivas, y que tras evaluar convenientemente debemos decidirsi son inconvenientes sustanciales como para decidir buscar una solución iterativa al problema:

a) El tiempo asociado con la llamada a las rutinas es una cuestión a tener en cuenta,ya que en cada llamada se aloja en la pila un nuevo entorno, con el consiguiente tiempoadicional consumido en esta operación. En una rutina iterativa la operación se realiza sólouna vez y se puede considerar a este tiempo irrelevante con respecto a la ejecución total dela rutina, pero una simple llamada recursiva inicial puede generar un gran número dellamadas posteriores, penalizando sustancialmente el tiempo final de ejecución con el tiempototal de creación de los entornos.

En el momento de diseñar un algoritmo recursivo hay que decidir si la facilidad deelaboración del algoritmo merece la pena con respecto al costo en tiempo de ejecución queincrementará la gestión de la pila originado por el gran número de posibles llamadasrecursivas.

b) La ineficiencia inherente de algunos algoritmos recursivos. Por ejemplo, fijémonosen el módulo que resuelve la sucesión de Fibonacci. Si se trazan las llamadas recursivas defibonacci(6) podremos apreciar cómo fibonacci(4) se calcula dos veces como problemasseparados, fibonacci(3) se obtiene tres veces; fibonacci(2), cinco veces y fibonacci(1), tres. Esde suponer, según estas evidencias, que conforme aumente n, el número de cálculosrepetidos que se realizarán será mucho mayor, presentando una gran cantidad deprocesamiento duplicado que no es aprovechado para evitar llamadas recursivas posteriores.

Así, es de especial importancia que la rutina recursiva implemente algún tipo detécnica que evite realizar procesamiento duplicado en diferentes partes de la ejecución de larutina. Una solución podría ser la utilización de algún tipo de estructura de datos adicional,como por ejemplo un vector o una lista, que mantuviera toda la información calculada hasta elmomento, para evitar de esta forma, cálculos redundantes.

Adicionalmente, y relacionado con la gestión de la pila en la recursividad, cabe destacar queotro problema puede ser el planteado por la profundidad de la recursión, ya que un gran número dellamadas recursivas almacenadas en la pila y pendientes a ser resueltas puede ocasionar que estazona de memoria se agote, pudiendo bloquearse el ordenador o abortándose la ejecución delprograma. Por tanto, si el tamaño del problema a resolver va a ser considerable, debemosplantearnos el buscar una solución iterativa.

Habiendo decidido que una solución recursiva concreta tiene más inconvenientes queventajas, hay dos alternativas a la hora de diseñar una rutina que resuelva un problema dado: buscarun algoritmo puramente iterativo o, dada la rutina recursiva, intentar eliminar la recursividad,manteniendo la idea básica que sustenta dicha rutina recursiva. En la siguiente sección se hará unbreve estudio de esta segunda alternativa.

2.6 Eliminación de la recursividad.Existen varios lenguajes en los que no se puede utilizar la recursividad como técnica de

resolución de problemas debido a que no están preparados para ello, fundamentalmente por que noestablecen la zona de la pila en la memoria del ordenador. También, y como hemos comentado en lasección anterior, la solución recursiva debe ser evitada cuando implique más inconvenientes queventajas. En estos casos, se deberá estudiar la posibilidad de eliminar la recursión total oparcialmente, utilizando para ello dos técnicas principales:

Page 26: programacion2

Recursividad

29

a) Eliminación de la recursión de cola.

Cuando existe una llamada recursiva en un módulo que es la última instrucción de la rutina, aesta llamada se le denomina llamada de recursión de cola. En algunos casos puede ser suficienteeliminar esta llamada recursiva para obtener alguna ganancia en lo que se refiere a términos deeficiencia y de espacio de pila ocupado, que haga que sea una solución viable la recursiva.

Para eliminar la recursión de cola, se sustituye la llamada recursiva por las sentencias quecambian los valores de los parámetros que se utilizan en la llamada a suprimir por los valores con losque se iba a realizar dicha llamada.

En general, frente a una estructura con una única llamada recursiva de cola ajustándose alsiguiente patrón:

if (condición) Ejecutar una tarea. Actualizar condiciones. Llamada recursiva.

El paso a estructura iterativa es inmediato, ya que es equivalente a un ciclo while:while (condición) Ejecutar una tarea. Actualizar condiciones.

Sirva como ejemplo la función recursiva que implementa la solución al problema de las Torresde Hanoi, problema que se estudiará detalladamente en secciones posteriores, pero que nos serviráseguidamente para ejemplificar la eliminación de la recursión de cola:

La rutina recursiva es:

void TorresHanoi(int n; char de; char hacia; char usando) if (n==1) printf(“Moviendo disco 1 desde el poste %c al %c\n”,de, hacia);else if (n>1) TorresHanoi(n-1, de, usando, hacia);

printf(“Moviendo disco %i desde el poste %c al %c\n”,n, de, hacia);TorresHanoi(n-1, usando, a, de);

Tras eliminar la recursión de cola, sustituyendo la última llamada TorresHanoi(n-1, usando, a,

de) por las asignaciones correspondientes para que los parámetros actuales tengan los valores conlos que se hacía esta llamada, la rutina quedaría como sigue:

void TorresHanoi(int n; char de; char hacia; char usando) char temp; while (n>1)

Page 27: programacion2

Programación II

30

TorresHanoi(n-1, de, usando, hacia); printf(“Moviendo disco %i desde el poste %c al %c\n”,n, de, hacia); n=n-1; temp= de; d= usando; usando= temp; if (n==1) printf(“Moviendo disco 1 desde el poste %c al %c\n”,de, hacia);

Consideremos otro ejemplo: la función recursiva Imp_OrdenInversoVector, la cual imprime deforma inversa un vector de enteros (es una variación de la ya estudiada Imp_OrdenInverso):

void Imp_OrdenInversoVector(int *Vector, int Tamanio) if (Tamanio > 0) printf(“%c”, Vector[Tamanio-1]); Imp_OrdenInversoVector(Vector, Tamanio-1);

Aplicando el cambio propuesto anteriormente, la función iterativa obtenida al eliminar larecursión de cola quedaría como sigue:

void Imp_OrdenInversoVector(int *Vector, int Tamanio) while (Tamanio > 0) printf(“%c”, Vector[Tamanio-1]); --Tamanio; b) Eliminación de la recursión mediante la utilización de pilas.

Cuando se produce una llamada a una rutina, se introducen en la pila los valores de lasvariables locales, la dirección de la siguiente instrucción a ejecutar una vez que finalice la llamada, seasignan los parámetros actuales a los formales y se comienza la ejecución por la primera instrucciónde la rutina llamada. Cuando acaba el procedimiento o función, se saca de la pila la dirección deretorno y los valores de las variables locales, y por último se ejecuta la siguiente instrucción.

En esta técnica de eliminación de recursividad, el programador implementa una estructura dedatos2 con la que simulará la pila del ordenador, introduciendo los valores de las variables locales yparámetros en la pila, en lugar de hacer las llamadas recursivas, y sacándolos para comprobar si secumplen los casos bases. Se añade un bucle, generalmente while, que iterará mientras la pila no estévacía, hecho que significa que se han finalizado las llamadas recursivas.

Al meter en la pila los valores de las variables locales y los parámetros formales, así como enalgunos casos la dirección de retorno (el lugar donde deberá volver el control cuando la invocaciónde la rutina termine), se simula la invocación recursiva. Cuando se saca de la pila, se está simulandoel comienzo de la rutina recursiva, ya que se recuperan los valores de las variables locales yparámetros formales con los que se haría la llamada recursiva.

2 Se recomienda, para la perfecta compresión de esta sección que el lector estudie detenidamente elconcepto de Tipo de Dato Abstracto Pila

Page 28: programacion2

Recursividad

31

Como hemos dicho anteriormente, se va a utilizar el T.D.A. Pila, del cual vamos a utilizar enesta sección las siguientes primitivas (se estudiarán con más detalle en el capítulo 4):

TPila crear() => Crea una pila y la deja lista para ser usada.

void destruir(TPila P) => Libera los recursos ocupados por una pila.

void push(TElemento x, TPila UnaPila) => Introduce en el tope de la pila UnaPila el valor de lavariable x.

TElemento tope(TPila UnaPila) => Devuelve el valor que ocupa el tope de la pila UnaPila.

void pop(Tpila UnaPila) => Elimina el elemento que está en el tope de la pila.

int vacia(TPila UnaPila) => Devuelve 1 si la pila UnaPila no tiene elementos y 0 en caso de queno esté vacía.

A continuación, eliminaremos la recursividad de la ya conocida función ncsr:int ncsr(int n, int k) if (k > n) return 0; else if ((n == k) || (k == 0)) return 1; else return ncsr(n-1, k) + ncsr(n-1, k-1);Obteniendo la siguiente función no recursiva:int ncsr_nr(int n, int k) tPila Pila; int Suma=0; Pila=crear();

push(n, Pila); push(k, Pila); while (!vacia(Pila)) k= tope(Pila); pop(Pila); n= tope(Pila); pop(Pila); if (k >n ) Suma += 0; else if (k == n || k== 0) Suma +=1;

else push(n-1, Pila); push(k-1, Pila); push(n-1, Pila); push(k, Pila);

destruir(Pila); return Suma;

Page 29: programacion2

Programación II

32

Después de crear la pila, las primeras sentencias con las que nos encontramos son las queintroducen en la pila los dos valores de los parámetros formales. Seguidamente hallamos un ciclowhile cuyo cuerpo se ejecutará mientras la pila no esté vacía. ¿Qué se hace dentro del ciclo? Serecuperan los valores de n y k de la pila y se comprueban los casos bases. Si se cumplen seactualiza la variable Suma con los valores que devolvería la rutina recursiva cuando se alcanzaran loscasos bases. En caso contrario, en la rutina recursiva se procedería a llamarse a sí misma condiferentes parámetros. Ahora, se introducen en la pila los valores de dichos parámetros (en nuestroejemplo, son cuatro los valores que se meten, correspondientes con los dos parámetros de las dosllamadas recursivas) para posteriormente, cuando comience de nuevo el bucle, sacarlos yprocesarlos.

De manera general, dada una estructura recursiva básica de la forma:RutinaRecursiva(parámetros)Si se han alcanzado los casos basesEntonces

Realizar las acciones que correspondan a dichos casos bases.Si no

Llamar recursivamente a la rutina actualizando convenientemente los parámetros dedicha llamada.

La estructura básica de la eliminación de la recursividad es la siguiente:

RutinaNoRecursiva(parámetros)Meter los parámetros y variables locales en la pila.Repetir mientras no esté vacía la pila

Sacar los elementos que están en el tope de la pilaSi éstos cumplen los casos basesEntonces

Realizar las acciones que correspondan a dichos casos bases.Si no

Meter en la pila los valores con los que se produciría la(s) llamada(s)recursiva(s).

Hay que tener en cuenta que, si los parámetros y las variables locales son de diferente tipo,habrá que tener tantas pilas como tipos haya para que alberguen los valores de las variables de esostipos.

2.7 Algunas técnicas de resolución de problemas basadasen la recursividad.Son muchas las técnicas de resolución de problemas que utilizan la recursividad como idea

básica. En esta sección serán dos las que presentaremos someramente y de las que ofreceremosalgún ejemplo para comprender sus fundamentos.

Page 30: programacion2

Recursividad

33

2.7.1 Divide y vencerás.Esta técnica soluciona un problema mediante la solución de instancias más pequeñas de

dicho problema, de tal manera que dichos subproblemas tienen una resolución más fácil,construyendo la solución al problema original a partir de dichas "subsoluciones". Así, teniendo encuenta este procedimiento de actuación, si se desea encontrar un problema de tamaño n, si éstetamaño es menor que un umbral dado, entonces se podrá aplicar, en caso de que exista, un algoritmosencillo para resolverlo, ya que el tamaño del problema permite que sea abordado por un algoritmoque encuentre la solución de forma simple y rápida. En caso contrario, se dividirá el problema detamaño n en m subproblemas distintos de menor tamaño y se encontrará la solución a cada uno deellos mediante una llamada recursiva. Finalmente, y una vez solucionados dichos subproblemas, dealguna forma se combinarán para calcular la solución al problema inicial.

Tanto el tamaño umbral en el que se aplicará un algoritmo sencillo, como el número desubproblemas en los que se dividirán el primero y la forma de combinar las soluciones dependeráclaramente del tipo de problema, no existiendo una regla que pueda determinar dichos valores, y serála persona que diseñe el algoritmo quien deberá decidir.

Como se puede apreciar la filosofía de esta técnica se corresponde totalmente con larecursividad, aunque hay que avisar de nuevo que no todos los problemas son aptos para serresueltos por el "divide y vencerás".

Búsqueda binaria.

En primer lugar estudiemos el algoritmo de búsqueda binaria recursiva de un valor en unvector ordenado, que ya utilizamos en la primera sección de este capítulo para ayudarnos a explicarel concepto de recursión.

Básicamente, el problema se resuelve determinando si el elemento buscado está en laposición que ocupa la mitad del vector. Si estuviera, concluiríamos la búsqueda. Si no es así,determinamos en qué mitad debería estar el elemento: si fuera menor o igual que el valor de laposición de la mitad, deberíamos buscar en la mitad izquierda, y si fuera mayor, en la parte derecha.Una vez decidida la mitad donde deberíamos buscar, el problema original (búsqueda de un elementoen el vector completo) lo sustituimos por la búsqueda sólo en una mitad (obviamente, el problemaoriginal se reduce de tamaño), en donde volveríamos a repetir todo el proceso descrito anteriormente.¿Cuándo acabaríamos? En el momento en que encontremos el valor buscado, o cuando el tamañodel vector sea cero, lo que indica que no está el valor buscado.

En este caso, y siguiendo las directrices de la técnica "divide y vencerás", el problema sedivide en dos subproblemas de igual tamaño e independientes entre sí, lo que implica queposteriormente no habrá que combinar las soluciones, pues la solución a un subproblema será lasolución general.

La función recursiva en C que implementará la búsqueda binaria recursiva (BusqBinaria)recibirá como parámetros un vector de números enteros (Vector), y tres parámetros formales enteros:los límites inferior (LimInf) y superior (LimSup) del vector y el valor a buscar (Valor), todos ellospasados por copia. Los límites nos indicarán en cada momento el tamaño y el subvector determinadodonde buscaremos.

El código en C de la función que resuelve el problema es el siguiente:int BusqBinaria(int *Vector, int LimInf, int LimSup, int Valor) int Mitad;

if (LimInf > LimSup) return -1; else

Page 31: programacion2

Programación II

34

Mitad= (LimInf + LimSup)/2; if (Valor == Vector[Mitad]) return Mitad; else if (Valor <= Vector[Mitad])

return BusqBinaria(Vector, LimInf, Mitad-1, Valor); else return BusqBinaria(Vector, Mitad+1, LimSup,Valor); Tras comprobar que los límites pasados como parámetros actuales se han cruzado, en cuyo

caso el inferior es mayor que el superior, lo que implica que el valor buscado no está en el vector,devolviendo la función -1 para indicarlo, y una vez calculada la posición de la mitad, que esa posiciónno contenga dicho valor, se determina en qué mitad está: si lo está en la mitad izquierda (es menor oigual que el valor de la posición central), los nuevos límites del vector donde se buscarán serán LimInfy Mitad -1; si lo está en la mitad superior (el valor es mayor que el central), los límites se convertiránen Mitad + 1 y LimSup.

La invocación inicial para un vector de enteros llamado VectEnt compuesto de 10 elementos,en donde se desea buscar el valor 8 sería: Posicion= BusqBinaria(VectEnt, 0, 9, 8); Dejamos a cargodel lector la comprobación de las cuatro preguntas para validar esta solución recursiva.

Las torres de Hanoi.

Los autores de [CHV88] hacen una descripción bastante ilustrativa del problema que vamos aabordar a continuación. Expresada de manera algo abreviada, es la siguiente:

Cuenta la leyenda que el emperador de un país con capital en la actual ciudad vietnamita deHanoi, debía elegir a un nuevo sabio del reino tras la jubilación del actual. Para ello, el sabio todavíaen ejercicio pensó en un problema y aquella persona que lo solucionara ocuparía su puesto en lacorte.

Utilizando tres postes (A, B y C) y n discos de diferentes tamaños y con un agujero en susmitades para que pudieran meterse en los postes, y considerando que un disco sólo puede situarseen un poste encima de uno de tamaño mayor, el problema consistía en pasarlos, de uno en uno, delposte A, donde estaban inicialmente colocados, al poste B, utilizando como poste auxiliar el restante(C), cumpliendo siempre la restricción de que un disco mayor no se pueda situar encima de otromenor. El estado inicial de los postes y el final es el que queda representado gráficamente en la figura7 mediante las situaciones 1 y 2:

Page 32: programacion2

Recursividad

35

Figura 7. Estados inicial y final y pasos intermedios a la resolución.

El problema parecía fácil y fueron muchos los que lo intentaron, aunque nadie lo conseguía.El emperador estaba ya algo desesperado al no encontrar sucesor para el puesto de sabio, cuandoun monje budista se presentó ante él , diciéndole que el problema era tan fácil , que casi se resolvía así mismo. Esto sorprendió al emperador y éste le dijo que se lo explicara. El monje le dioprimeramente la solución para un sólo disco: mover el disco de A a B, para continuar seguidamentecon la solución para el caso en que hubiera más de un disco:

1. Solventar el problema para n-1 discos, ignorando el último de ellos, pero ahora teniendo encuenta que el poste destino será C y B será el auxiliar (estado 3 de la figura 7).

2. Una vez hecho esto anterior, los n - 1 discos estarán en C y el más grande permanecerá en A.Por tanto, tendríamos el problema cuando n = 1, en cuyo caso se movería ese disco de A a B(estado 4 de la figura 7).

3. Por último, nos queda mover los n-1 discos de C a B, utilizando A como poste auxiliar (estado 5de la figura 7).

Desde el punto de la filosofía de la técnica "divide y vencerás", el problema original se divideen tres subproblemas de tamaño menor, correspondiente a los tres puntos anteriores, dos de loscuales se solventarán utilizando llamadas recursivas, y el que queda será tan simple de resolver queno hace falta una nueva invocación recursiva.

Aplicando de manera directa los tres pasos que el monje indicaba, presentaremos la función

recursiva TorresHanoi que implementa la solución al problema en un total de 2N-1 movimientos y quetiene como parámetros el número de discos con los que se resolverá el problema (NumDiscos) y trescaracteres que representan a cada poste (Origen, Destino y Auxiliar).

void TorresHanoi(int NumDiscos, char Origen, char Destino, char Auxiliar) if (NumDiscos == 1) printf("Mover el disco de arriba del disco %c al %c.\n", Origen, Destino); else TorresHanoi(NumDiscos-1, Origen, Auxiliar, Destino); TorresHanoi(1, Origen, Destino, Auxiliar);

Page 33: programacion2

Programación II

36

TorresHanoi(NumDiscos-1, Auxiliar, Destino, Origen); Para un total de 5 discos, la llamada a la función sería:

TorresHanoi(5, 'A', 'B', 'C');

Por último, comentar a modo de curiosidad, que otra leyenda cuenta que hay un grupo demonjes que tienen la misión de resolver el problema con un total de 40 discos de oro sobre trespostes de diamante y que cuando finalicen, el mundo se acabará. Esperemos por nuestro bien queestos monjes no utilicen un ordenador para solucionarlo ;-)

2.7.2 Backtracking.Para comprender el funcionamiento de esta técnica vamos a comenzar viendo cómo se

soluciona el "problema de las ocho reinas". Partimos de un tablero de ajedrez, el cual tiene un total de64 casillas (8 filas x 8 columnas). El problema consiste en situar ocho reinas en el tablero de talforma que no se den jaque entre ellas. Una reina puede dar jaque a aquellas reinas que se sitúen enla misma fila, columna o diagonal en la que se encuentra dicha reina. En la figura siguiente, podemosobservar una solución al problema:

R

R

R

R

R

R

R

R

Figura 8. Una solución para el problema de las 8 reinas.

La solución a este rompecabezas pasa, por tanto, en situar una reina en cada fila y en cadacolumna. ¿Cómo actuaríamos para situar las ocho reinas? Comenzaríamos por la primera columna ysituaríamos la primera reina. Al hacer esto, eliminamos como posibles casillas donde localizar reinasla fila, la columna y las dos diagonales. Teniendo en cuenta estas restricciones, se sitúa en lasegunda columna la segunda reina y se "tachan" las nuevas casillas prohibidas. Seguidamente seprocede a poner la tercera reina y así sucesivamente.

Supongamos que se han situado las seis primeras reinas. Puede llegar un momento en el queno se pueda situar la séptima sin que ninguna otra le dé jaque. En ese momento, se deshace elmovimiento que ha ubicado la sexta reina y busca otra posición válida para dicha reina. Si no sepudiera, se vuelve deshacer el movimiento de la sexta reina y se intenta buscar otra localización. Enel caso de que no sea posible, se sigue hacia atrás intentando colocar la reina quinta. Si se puedecolocar, entonces se procederá a dejar en el tablero la sexta de nuevo. Si se ha conseguido sin que leden jaque, se intentará emplazar la séptima y, por último, la octava. En general, si hay algúnproblema, se deshace el último movimiento y se prueba a localizar una casilla alternativa. Si no seconsigue, se vuelve hacia atrás y así sucesivamente.

Ésta técnica de prueba-error o avance-retroceso, se conoce como bactracking (vuelta atrás).En ella se van dando pasos hacia delante mientras sea posible y se deshacen cuando se ha llegado auna situación que no conduce a la resolución del problema original.

Page 34: programacion2

Recursividad

37

¿Cómo se puede aplicar la recursividad para solventar este problema? De forma muysencilla: dado que se ha situado una reina en una posición correcta en una columna, hay queconsiderar el problema de situar otra reina en la columna siguiente: resolver el mismo problema conuna columna menos. En este momento realizaríamos una llamada recursiva. ¿El caso base? Cuandoel tablero se haya reducido a uno de tamaño cero, en cuyo caso no se hará nada. Por tanto, se partiráde un problema de tamaño 8, y se considerarán problemas de tamaño menor quitando una columnaen cada llamada recursiva hasta que se llegue a un tablero de tamaño cero, momento en el cualhabremos alcanzado el caso base. Si se puede situar una reina en la columna correspondiente, sehace una llamada recursiva, si no, se vuelve hacia atrás y se prueba otras posiciones.

Para implementar la función recursiva en C que encuentre una solución para el problema delas ocho reinas necesitaremos una estructura de datos para representar el tablero, para lo cualutilizaremos una matriz de tamaño 8 x 8 de enteros, donde un 1 en una posición indicará que haysituada una reina en ella y un 0, que no está ocupada.

int Tablero[8][8];

Inicialmente todos los elementos de la matriz bidimensional serán 0, indicando que no hayninguna reina en el tablero y será global a la función recursiva, con objeto de ahorrar espacio en lapila.

Las funciones auxiliares que nos ayudarán a resolver el problema y que no implementaremosaquí por ser irrelevantes al tema que nos centra nuestra atención, son:

void AsignarReinaA(int Fila, int Columa) => Sitúa un 1 en la casilla (Fila, Columna)indicando que está ocupada por una reina.

void EliminarReinaDe(int Fila, int Columna) => Sitúa un 0 en la casilla (Fila, Columna)indicando que esa casilla está libre (antes estaba ocupada por una reina y ahora deja deestarlo).

int RecibeJaqueEn(int Fila, int Columna) => Devuelve 1 si la casilla (Fila, Columna)recibe jaque de alguna reina y 0 en caso contrario.

La función recursiva que implementaremos será void SituarReina(int Columna, int *Situada).El parámetro formal Col indica en qué columna se quiere situar la reina, y Situada, pasado porreferencia, es un parámetro que tomará el valor 1 cuando se haya logrado ubicar correctamente a lareina correspondiente, y 0 cuando el intento haya sido infructuoso. Columna se pasará por copia paraque, cuando se realice backtracking se pueda trabajar con el valor original de esta variable.

La llamada a esta función se hace con Col == 0 (primera columna de la matriz).void SituarReina(int Columna, int *Situada) int Fila; if (Columna > 7) *Situada=1; else *Situada=0; Fila=1; while (!(*Situada) && (Fila <= 7)) if (RecibeJaqueEn(Fila, Columna)) ++Fila; else

AsignarReinaA(Fila, Columna); SituarReina(Columna+1, *Situada); if ( !(*Situada)) EliminaReinaDe(Fila, Columna);

Page 35: programacion2

Programación II

38

++Fila;

Básicamente, se comprueba si se está en el caso base, en cuyo caso se indica que se hasituado la reina en una casilla libre. En otro caso, se comenzará un bucle que iterará mientras no sehaya situado correctamente la reina dentro de la columna especificada en el parámetro, o noshayamos salido fuera del tablero, en cuanto a las filas se refiere. En el bucle se comprobará si en lacasilla (Fila, Columna) se puede situar la reina. Si no es así, se incrementa la fila para probar en otracasilla de la misma columna. Si no recibiera jaque una reina situada en esa casilla, se asigna a laposición del tablero y se llama recursivamente a la función SituarReina para situar en la siguientecolumna otra reina. Cuando han finalizado las llamadas recursivas, se comprueba el valor de Situadapara ver si ha habido éxito en el resto de intentos. En el caso de que no, se deshace la asignaciónanterior y se incrementa en una la fila para probar con otra casilla.

De forma sencilla se puede modificar esta función para que calcule, no sólo una soluciónposible, si no todas las existentes, tarea que dejamos a cargo del lector.

2.8 Bibliografía.• [AHU88] A. Aho, J.E. Hopcroft, J. Ullman. Estructuras de datos y algoritmos. Addison-Wesley

(1.988).

• [BB86] P. Berlioux, P. Bizard. Algorithms. The construction, proof and analysis of programs.John Wiley and Sons (1.986).

• [BB90] G. Brassad, P. Bratley. Algorítmica. Concepción y análisis. Masson (1.990).

• [CCP93] F.J. Cortijo, J.C. Cubero, O. Pons. Metodología de la programación. Programas yestructuras de datos en Pascal. ISBN. 84-604-7652-9 (1.993).

• [CHV88] F. Canorro, P. Helman, R. Veroff. Data abstraction and problem solving with C++.Walls and Mirrors. 2nd Edition. Addison-Wesley (1.988).

• [FT96] W. Ford, W. Topp. Data structures with C++. Prentice Hall International (1.996).

• [LAT97] Y. Langsom, M. Augenstein, A. Tenenbaum. Estructuras de datos con C y C++. 2ªEdición. Prentice Hall (1.997).

• [Riv99] M. L. Rivero Cejudo. Programación: parte teórica. Colección Apuntes. Universidad deJaén (1.999).

• [Sed88] R. Sedgewick. Algorithms in C. 2ndEdition. Addison-Wesley (1.988).

• [Sed98] R. Sedgewick. Algorithms in C. 3rd Edition. Addison-Wesley (1.998).

• [Wir86] N. Wirth. Algorithms and data structures. Prentice Hall (1986).

Page 36: programacion2

Capítulo 3: Análisis de la eficiencia de los

algoritmos.

3.1 Introducción al análisis de la eficiencia. Cuando se nos propone realizar un programa para resolver un problema puede ser

interesante plantearnos el diseño de varios algoritmos, y de entre éstos escoger el mejor. Pero rápidamente surge la pregunta: ¿cuál es el mejor? ¿con qué criterios se decide cuándo un algoritmo es mejor que otro?

Esta cuestión puede tener varias respuestas alternativas: aquel que consuma menos memoria, o el que finalice la ejecución más velozmente, aquel que funcione adecuadamente (que realice correctamente la misión para la cual ha sido diseñado) o incluso el que sea más fácil para un humano de leer, escribir o entender.

Si nos centramos en las dos primeras, éstas pueden ser cuantificadas más fácilmente, el consumo de memoria viene determinado por el número de variables y el número y el tamaño de las estructuras de datos usadas en el algoritmo. Por otro lado, la velocidad quedará cuantificada determinando el tiempo que tarda en finalizar el programa o, alternativamente, calculando el número de acciones elementales ejecutadas por un procesador.

A pesar de todo lo anteriormente comentado, y teniendo en cuenta los avances tecnológicos actuales podríamos pensar: ¿por qué deberíamos preocuparnos por la calidad de un algoritmo, si lo único que tenemos que hacer para que un programa vaya más rápido es utilizar un ordenador con más potencia, o si éste consume mucha memoria, añadirle algunos módulos de memoria más? De hecho, es evidente que si un programa se ejecuta lentamente en una máquina determinada, si mejoramos las prestaciones, lograremos que finalice más rápidamente. Esta afirmación no es del todo correcta, ya que este aumento de las prestaciones no implica una clara mejora en el tiempo de ejecución.

Supongamos que un ordenador es capaz de ejecutar un programa cuya entrada son n datos en 10-42n segundos. Si n=10, el programa terminará en 0'1 segundos. Si fuera 20, aproximadamente 10 minutos; 30, un día y 40, un año. Si cambiamos el ordenador que estamos usando por uno 100 veces más rápido, resolveríamos el problema con 45 datos en un año, mejora que no es del todo significativa. Sólo se mejoraría de manera importante si se modificara el diseño del programa, sustituyendo este algoritmo anterior por uno más eficiente.

Una vez que conocemos qué criterios se pueden usar para decidirnos por un algoritmo u otro, la pregunta que nos planteamos seguidamente sería: ¿cómo podemos realizar el estudio de la eficiencia de un algoritmo? Son dos las líneas básicas con las que podemos abordar dicho estudio:

• Empíricamente: programar los algoritmos y ejecutarlos varias veces con distintos datos de entrada.

• Teóricamente, lo que equivale a determinar matemáticamente la cantidad de recursos (tiempo de ejecución y memoria) requeridos por la implementación en función del tamaño de la entrada.

El problema que se origina al considerar un estudio empírico radica en la dependencia de los resultados obtenidos del tipo de ordenador con el que se hayan realizado los experimentos, del lenguaje de programación usado y del traductor con el que se obtenga el código ejecutable, e incluso de la pericia del programador (si cambiamos alguno de estos elementos probablemente se obtengan resultados diferentes, con lo cual no podemos establecer una eficiencia empírica absoluta).

Page 37: programacion2

Programación II

40

Otro problema que plantea la alternativa empírica es que no siempre es posible su utilización ya que existen algoritmos que pueden ser comparados con esta técnica sólo cuando el número de datos con los que se ejecutan es relativamente pequeño. Si dicho valor crece, aunque sólo sea un poco, se puede correr el peligro de que el tiempo de ejecución crezca de manera exagerada. Un ejemplo es el problema del viajante de comercio, que cosiste en encontrar una ruta que una un número de ciudades pero con coste mínimo, por ejemplo en kilómetros a recorrer. Cuando el total de poblaciones a unir es bajo, 10 o 20, el resultado lo obtendremos en unos pocos segundos, pero si ese número se elevara a 100, ahora mismo no existiría ningún ordenador capaz de encontrar la solución en un tiempo razonable.

Estos inconvenientes no se encuentran cuando se lleva a cabo un estudio teórico, pues se realiza independientemente de todos los factores anteriormente citados. El resultado de un análisis teórico es genérico para cualquier tamaño de la entrada y depende exclusivamente de las instrucciones que componen el algoritmo y del citado tamaño. La salida de este análisis será una expresión matemática que indique cómo se produce el crecimiento del tiempo que tardaría en ejecutarse el algoritmo conforme aumente el tamaño de la entrada, cuestión que trataremos detalladamente más adelante.

Existe una tercera línea a la que podríamos calificar como híbrida entre empírica y la teórica, que consiste en hacer un estudio teórico del algoritmo en cuestión y, posteriormente determinar los parámetros numéricos de las funciones matemáticas obtenidas, que dependen de la implementación concreta, mediante técnicas estadísticas, como puede ser la regresión.

En este capítulo nos vamos a centrar exclusivamente en el estudio o análisis teórico de la eficiencia de los algoritmos, y más concretamente en su vertiente del tiempo, aunque se podrán realizar las mismas consideraciones con respecto al consumo de memoria e incluso a otros recursos. En general, a todos aquellos cuyo crecimiento, a igual que ocurre con el tiempo, dependa del tamaño del problema o de los datos de entrada, concepto al que ya hemos hecho referencia en párrafos anteriores y que debemos clarificar, aunque sea de manera intuitiva para poder seguir adelante.

Supongamos que tenemos entre manos el estudio de la eficiencia de un algoritmo de ordenación de un vector de n enteros. En este caso, n es el tamaño de los datos de entrada. Si ahora debiéramos estudiar la eficiencia de un algoritmo para el cálculo del factorial de un entero, el valor de ese número entero es el que nos determinaría el tamaño de nuestro problema. Dicho tamaño puede venir expresado no sólo en una dimensión, como ocurre en los ejemplos anteriores, sino puede depender de varias, como es el caso del viajante de comercio, ya que la eficiencia de un algoritmo que lo resuelva vendrá dada en función del número de poblaciones a visitar, y del número de carreteras que unan dichas poblaciones.

De manera general, el tamaño de la entrada de un problema vendrá dado en función de un número entero que nos mide el número de componentes de dicho ejemplo. Es el caso del tamaño de un vector, o por ejemplo, el propio valor que tiene ese ejemplo (el valor del entero al cual se le quiere hallar su factorial). No existe una regla exacta para determinar el tamaño de un problema, sino que tendrá que obtenerse según las características de cada uno.

Tras esta introducción, en la siguiente sección definiremos el tiempo de ejecución de un algoritmo, así como su orden de eficiencia, para pasar a formalizar las notaciones asintóticas en la sección 4. En la quinta sección nos dedicaremos a describir cómo se hace el cálculo del tiempo de ejecución de los algoritmos iterativos y la posterior consecución de su orden de eficiencia, para pasar a continuación, a realizar la misma tarea pero en algoritmos recursivos en la última sección de este capítulo.

Page 38: programacion2

Análisis de la eficiencia de los algoritmos.

41

3.2 El tiempo de ejecución de un algoritmo y su orden de eficiencia. Una vez definido el tamaño de la entrada, resulta conveniente usar una función, T(n), para

representar el número de unidades de tiempo (segundos, milisegundos,...) que un algoritmo tardaría en ejecutarse con unos datos de entrada de tamaño n. Como el tiempo de ejecución de un programa depende claramente del ordenador que se utilice para medirlo y del traductor con el que se haya generado el código objeto, sería preferible que T(n) no represente un tiempo, sino el número de instrucciones simples (asignaciones, comparaciones, operaciones aritméticas,...) que se ejecutan, o de forma equivalente, el tiempo de ejecución del algoritmo en un ordenador idealizado, donde cada una de las instrucciones simples consumen una unidad de tiempo. En general se suele dejar sin especificar las unidades empleadas en T(n)1 y se asume que n≥0 y T(n) es positivo.

Un concepto que nos ayudará a entender por qué podemos evitar el uso de unidades de tiempo en la función T(n) es el que se conoce como el principio de invarianza, válido independientemente tanto del ordenador que estemos utilizando, como del compilador. El principio establece que dos implementaciones distintas de un mismo algoritmo, que toman t1(n) y t2(n) unidades de tiempo, respectivamente, para resolver un problema de tamaño n, no diferirán en eficiencia en más de una constante multiplicativa. Expresado matemáticamente, existe un c>0 tal que t1(n) ≤ ct2(n). Así, se podrá hacer que un programa vaya 10 ó 1000 veces más rápido cambiando de máquina, pero sólo un cambio de algoritmo nos permitirá obtener una mejoría mayor cuanto más crezca el tamaño de los ejemplos, lo que nos llevará a ignorar las constantes multiplicativas a todos los efectos.

Ahora bien, un mismo algoritmo puede ejecutarse con conjuntos de datos diferentes y su tiempo de ejecución puede ser distinto para cada uno de ellos: habrá datos de entrada con los que el algoritmo emplee más tiempo, y otros con los que finalice antes, por lo que es conveniente indicar si la expresión que se obtiene al realizar el análisis de la eficiencia corresponde al caso peor, al caso promedio, o al mejor caso. En el primero de ellos, estamos indicando un límite superior, el máximo valor, del tiempo de ejecución para cualquier entrada al algoritmo, al contrario que ocurre con el mejor caso, donde ofrecemos un límite inferior, indicando que el algoritmo, no se ejecutará por debajo de ese tiempo. El caso promedio será una media ponderada de ambos casos, aunque los abundantes y a veces complicados cálculos para realizar un análisis de eficiencia del caso promedio complican notablemente su uso.

Aunque el peor caso suele ser bastante pesimista, y probablemente el comportamiento del algoritmo sea algo mejor que el obtenido por el análisis, se suele utilizar más a menudo que el mejor caso. En este capítulo, salvo que se diga lo contrario, se calculará la expresión analítica del peor caso.

Veamos un ejemplo: un algoritmo de ordenación creciente de un vector mediante el método de selección cuando se aplica a un vector ya ordenado de forma creciente (mejor caso) tardará más o menos igual que si se aplica sobre el mismo vector ordenado de manera decreciente (peor caso). Este es un ejemplo en el que ambos tiempos, el del peor y del mejor caso, coinciden. Sin embargo, cuando tratamos con el algoritmo de ordenación mediante inserción, el tiempo de ejecución aplicado a un vector ordenado crecientemente es proporcional al número de elementos del vector, n, y si lo aplicamos al vector ordenado decrecientemente, dicho tiempo se incrementará de manera proporcional al cuadrado de n. En este otro algoritmo, los tiempos son diferentes.

1 Hacerlo así no es restrictivo, ya que si T(n) es el tiempo de ejecución en un ordenador ideal, entonces el tiempo de ejecución en un ordenador real será T(n) por una cierta constante que depende del ordenador y que se puede calcular empíricamente.

Page 39: programacion2

Programación II

42

¿Cómo se calcula el tiempo de ejecución de un algoritmo? La respuesta es clara: sobre la base de las instrucciones que componen el algoritmo. Aunque posteriormente lo estudiaremos con detalle, esbozaremos seguidamente algunas ideas. La evaluación de una expresión tendrá como tiempo de ejecución lo que se tarde en realizar las operaciones que contenga. Una asignación a una variable simple de una expresión, al igual que una operación de escritura de una expresión, suele tardar el tiempo de evaluar dicha expresión más un tiempo constante relativo a gestión interna de la asignación o del proceso de escritura. Una lectura de una variable requiere un tiempo constante. Una secuencia de instrucciones, la suma de los tiempos de cada una de las instrucciones. En una sentencia condicional, su tiempo de ejecución será el de evaluar la condición más el máximo de los costes del bloque entonces y del bloque sino. Para un bucle, se evalúa el tiempo del cuerpo del bucle y se multiplica por el número de iteraciones más el tiempo de evaluar la condición del bucle.

Todas aquellas instrucciones cuyo tiempo de ejecución queda limitado superiormente por una constante, que sólo depende de la implementación, se denominarán operaciones elementales. Por tanto, al habernos desprendido de las constantes multiplicativas, para el análisis de la eficiencia de un algoritmo sólo será relevante el número de operaciones primitivas y no su duración. Consideraremos, por tanto, que las operaciones de suma, multiplicación, resta, división, módulo y similares son operaciones elementales y por tanto, tendrán un costo unidad.

Hemos considerado hasta ahora que el análisis de la eficiencia se hace siempre sobre un algoritmo, sin tener en cuenta la implementación en un lenguaje de alto nivel. En nuestro caso, y a partir de este momento, los ejemplos serán implementados en C y a partir de dicha implementación se estudiará su eficiencia. La razón fundamentalmente es la coherencia con respecto al resto de capítulos de este libro ya que todos están basados en C.

Veamos un primer ejemplo para identificar intuitivamente estos conceptos. La siguiente función obtiene la posición donde se encuentra el mínimo valor de un vector de números enteros:

/* 1 */ int BuscarMinimo(int *Vector, int n) /* 2 */ /* 3 */ int j, min; /* 4 */ min= 0; /* 5 */ for (j=1; j<n;j++) /* 6 */ if (Vector[j] < Vector[min]) /* 7 */ min= j; /* 8 */ return min; /* 9 */ En el tiempo de ejecución de este programa no se consideran todas las instrucciones: las

declaraciones de variables y la propia declaración de la función no intervienen, por lo que empezaremos por la línea 4 a hacer el cálculo del tiempo de ejecución. La línea 4 pertenece a una asignación, por lo que contabilizará una constante ca. En la quinta línea se tendrán en cuenta dos constantes: una por el incremento del contador j , ci, y otra por la evaluación de la condición booleana, ce, que incrementarán el tiempo de ejecución tantas veces como se ejecute el bucle: n-1 veces, más dos veces adicionales correspondientes al caso en que j == n+1. El cuerpo del bucle podrá tener como coste ce ó ce+ca, dependiendo de si la condición del if se evalúa verdadera o falsa, y se ejecuta o no el bloque then. Como hemos considerado trabajar siempre con el peor caso, tomaremos la suma del tiempo de evaluación de la condición (línea 6) más el de la asignación de la línea 7. Por último, nos queda la línea 8, que consumirá una constante cr. Agrupando todo lo anterior en una única expresión tendríamos: T(n)= ca + (ci + ce)(ce+ca)(n-1) + cr. Si consideramos que esas constantes son la unidad, por provenir de operaciones elementales, haciendo cálculos obtendremos T(n)= 4(n-1) + 4= 4n.

Page 40: programacion2

Análisis de la eficiencia de los algoritmos.

43

En este momento ya estamos en condiciones de aclarar el concepto de eficiencia, el cual hace referencia a la forma en que el tiempo de ejecución (y en general cualquier recurso) necesario para procesar una entrada de tamaño n crece cuando se incrementa el valor de n. Es por esto, por lo que se especificará mediante una función de crecimiento. Como se puede observar, dicha eficiencia sólo depende del tamaño del problema, dejando a un lado cuestiones como la velocidad del ordenador y la eficiencia del compilador.

Se dice que un algoritmo necesita un tiempo de ejecución del orden de una función cualquiera f(n), cuando existe una constante positiva c y una implementación del algoritmo que resuelve cada instancia del problema en un tiempo acotado superiormente por cf(n), siendo la función f(n) la que marca cómo crecerá dicho tiempo de ejecución cuando aumente n. Un algoritmo será, por tanto, más eficiente que otro si el tiempo de ejecución del peor caso tiene un orden de crecimiento menor que el segundo.

Supongamos que para un cierto problema dos algoritmos ,A y B, lo resuelven mediante las funciones TA(n)=100n y TB(n)=2n2, respectivamente. ¿Cuál deberíamos usar? Si n<50, A es mejor que B, pero en el caso de que n≥50, A es mejor que B, haciéndose mayor la ventaja de A sobre B conforme n crece (si n= 100, A es el doble de rápido que B; si n=1.000, A es 20 veces más rápido que B). Con este ejemplo podemos ver cómo es más importante la forma de las funciones (n y n2), que las constantes (2 y 100). Además, como el tiempo de ejecución de un algoritmo en un ordenador concreto requiere que se multiplique por una constante sólo medible mediante la experimentación, parece lógico que nos olvidemos de calcular los factores multiplicativos al analizar un algoritmo.

En vez de decir que el tiempo de ejecución del algoritmo es T(n)=4n+3, por ejemplo, diremos que T(n) es del orden de n, lo que implica que el tiempo de ejecución es alguna constante multiplicada por n, para tamaños de problemas de dimensión n.

Las funciones que nos marcan más comúnmente el orden de crecimiento de los algoritmos y los nombres por las que se les conocen son las siguientes2:

f(n)= 1 => Constante.

f(n)= log n => Logarítmica.

f(n)= n => Lineal.

f(n)= nlog n => Quasilineal.

f(n)= n2 => Cuadrática.

f(n)= n3 => Cúbica, y en general, f(n)= nk => polinómica.

f(n)= kn => Exponencial.

2 Notaremos lg n como el logaritmo en base dos de n (log2 n), y ln n como el logaritmo en base e de n (loge n). Cuando nos dé igual la base, se indicará simplemente log n.

Page 41: programacion2

Programación II

44

En general, suele admitirse como algoritmo eficiente aquel que alcanza como mucho un coste quasilineal. El mejor orden es el logarítmico, ya que al doblar el tamaño del problema apenas afecta al tiempo de ejecución, mientras que doblar el tiempo disponible permite tratar problemas enormes en relación con el original. Por otro lado, el quasilienal y el lineal presentan como característica que al duplicar el tamaño del problema se duplica aproximadamente el tiempo empleado, y análogamente, al duplicar el tiempo se pueden tratar problemas aproximadamente del doble de tamaño. En el caso de las funciones las polinómicas, al multiplicar el tiempo disponible por un factor c en un orden del tipo nk, multiplica el tamaño del problema que puede tratar por un factor c1/k, con lo cual necesitamos mucho tiempo para resolver un problema que ha crecido relativamente poco en tamaño. A pesar de esto, los algoritmos que resuelven un problema en tiempo polinomial, se consideran manejables. Cualquier coste superior al polinómico es un coste que se califica como intratable, incluso para casos de tamaño moderado, ya que exigen unos recursos prohibitivos. En la siguiente tabla se puede observar el crecimiento de algunas funciones conforme crece el tamaño n del problema.

log n n nlog n n2 n3 2n

10 33 100 1.000 1.024

100 664 10.000 1.000.000 1'267650600228e+30

0 1.000 9.966 1.000.000 1.000.000.000 1'071508607186e+301

3 10.000 132.877 100.000.000 1'0e+12 Enorme

7 100.000 1.660.964 10.000.000.000 1'0e+15 Más enorme

10 1.000.000 19.931.569 1.000.000.000.000 1'0e+18 Sin comentarios Tabla 1. Crecimientos de las funciones más comunes del orden de eficiencia.

Las tasas de crecimiento más frecuentes se pueden comparar en la representación gráfica de la figura 1.

Básicamente, los órdenes de eficiencia son clases de equivalencia de funciones, y cuando se calcula el orden de un algoritmo, lo que se hace es estimar el tiempo de ejecución en función del tamaño de la entrada y seleccionar una de esas clases. Esas clases de equivalencia se forman con funciones que son equivalentes según el principio de invarianza expuesto anteriormente. Por ejemplo, los tiempos de ejecución T1(n)= 2n3 + 4n2 + 5 y T2(n)= n3- 4n, son funciones que pertenecen a la misma clase de equivalencia cuya representante es la función f(n)= n3.

Hasta ahora hemos centrando toda la discusión en la velocidad de crecimiento del tiempo de ejecución como la piedra de toque para evaluar un algoritmo o programa, pero deberíamos tener en cuenta otros criterios en donde dicho tiempo de ejecución del programa podría ser ignorado y que deben ser tenidos en cuenta a la hora de elegir o diseñar un algoritmo. Nosotros destacamos dos principalmente:

• Si un programa se va a ejecutar pocas veces, el costo de escritura es el principal, no influyendo en dicho costo el tiempo de ejecución, debiéndose elegir un algoritmo cuya aplicación sea la más fácil.

Page 42: programacion2

Análisis de la eficiencia de los algoritmos.

45

• Si un algoritmo se va a ejecutar con entradas pequeñas, entonces la velocidad de crecimiento del tiempo de ejecución puede ser menos importante que las constantes multiplicativas, al contrario que lo que dijimos varios párrafos atrás. Por ejemplo, supongamos dos implementaciones de un mismo algoritmo cuyos tiempos de ejecución son 100n2 y 5n3, respectivamente. Se cumple que para n < 20, el segundo programa será más rápido que el primero, por lo que si el programa se va a ejecutar normalmente con entradas pequeñas, en este caso menores que 20, conviene ejecutar el segundo, ya que debido a la constante 5, ya que 5n3 estará siempre por debajo que 100n2. Otra cuestión es que esas entradas sean muy grandes, en cuyo caso, nos decantaremos claramente por la primera implementación, ya que la velocidad de crecimiento es menor.

Figura 1. Representación gráfica de los órdenes de crecimiento más frecuentes.

3.3 Las notaciones asintóticas. En esta sección vamos a introducir las notaciones que serán utilizadas para razonar sobre la

eficiencia de los algoritmos. Dichas notaciones se califican como asintóticas debido a que el estudio de los órdenes de eficiencia se hace en casos límite, es decir, se trata de determinar cómo crece el tiempo de ejecución con respecto al tamaño de la entrada en el límite: cuando el tamaño de la entrada se incrementa sin límite.

Son tres las notaciones asintóticas que vamos a tratar: la O mayúscula, la o minúscula y las notaciones Θ y Ω, aunque nos centraremos algo más en la primera que es la que representa el peor comportamiento, y será la que utilicemos de la siguiente sección en adelante.

Page 43: programacion2

Programación II

46

Las notaciones 0 mayúscula y o minúscula. La notación O mayúscula, O(f(n)), representa el conjunto de funciones g que crecen como

mucho tan rápido como f, o lo que es lo mismo, las funciones g tales que f llega a ser en algún momento una cota superior para g. En definitiva se trata de buscar una función sencilla, f(n), que acote superiormente el crecimiento de otra g(n), en cuyo caso se notará como g(n) ∈ O(f(n)) (g es del orden de f). La definición formal es la siguiente: sea f:N→R+∪0 una función cualquiera, el conjunto de las funciones del orden de f(n), notado como O(f(n)), se define:

O(f(n)) = g ∃c0 ∈ R+ y ∃n0 ∈ N, ∀ n ≥ n0 g(n) ≤ c0f(n)

Esta definición garantiza que si el tiempo de ejecución de una implementación de un algoritmo es g(n), la cual es del orden de f(n), el tiempo g'(n) empleado por cualquier otra implementación que difiera de la primera en el lenguaje y en el compilador utilizado, o la propia máquina, también será del orden de f(n).

Veamos algunos ejemplos:

T(n)= 3n + 2 ∈ O(n), ya que existen dos constantes positivas n0= 2, y c0=4, tal que 3n+2 ≤ 4n.

T(n)= 1.000n2 + 100n - 6 ∈ O(n2), por que existen n0= 100, y c0=1.000 que hacen que se cumpla que 1.000n2 + 100n - 6 ≤ 1.001n2.

T(n) = 6· 2n + n2 ∈ O(2n) debido a que se pueden encontrar dos constantes n0= 4, y c0=7 que hacen que 6· 2n + n2 ≤ 7· 2n.

T(n) = 3n ∉ O(2n). Supongamos que existen dos constantes n0 y c0 tales que para todo n≥n0, se tiene que 3n ≤ c02

n. Entonces c0 ≥ (3/2)n para cualquier valor n≥n0, pero esta desigualdad anterior no se verifica nunca ya que no existe ninguna constante suficientemente grande que (3/2)n para todo n.

Como ya dijimos anteriormente, hay problemas, como son los que tratan con grafos, en los que el tiempo de ejecución depende de más de un parámetro. La notación asintótica del orden generalizada a dos parámetros es la siguiente: sea f:N x N→R+∪0 una función cualquiera, el conjunto de las funciones del orden de f(n, m), notado como O(f(n, m)), se define como sigue:

O(f(n,m)) = g ∃c0 ∈ R+ y ∃n0,m0∈ N, ∀ n ≥ n0 y m ≥ m0, g(n,m) ≤ c0f(n,m)

Algunas propiedades interesantes de la notación O(f), para cualesquiera que sean las funciones f, f', g, h:

• ∀c ∈ R+, g ∈ O(f) si y sólo si c· g ∈ O(f) [Invarianza multiplicativa].

• ∀c ∈ R+, g ∈ O(f) si y sólo si c+g ∈ O(f) [Invarizanza aditiva]

• f ∈ O(f) [Reflexividad]

• Si h ∈ O(g) y g ∈ O(f) entonces h ∈ O(f) [Transitividad.]

• g ∈ O(f) si y sólo si O(g) ⊆ O(f) [Criterio de Caracterización].

Mención especial merecen las conocidas como reglas de la suma y del producto, ya que son básicas para el análisis de la eficiencia de un algoritmo:

• Si g ∈ O(f) y h ∈ O(f'), entonces g + h ∈ O(max(f, f')) [Regla de la suma].

Demostración:

Si g(n) ∈ O(f(n)) entonces ∃c1, n1 / g(n) ≤ c1f(n),∀n ≥ n1.

Si h(n) ∈ O(f'(n)) entonces ∃c2, n2 / h(n) ≤ c2f'(n), ∀n ≥ n2.

∀n ≥ max(n1, n2) se tiene que f(n)+f'(n) ≤ (c1 + c2) max(f(n),g(n)).

Page 44: programacion2

Análisis de la eficiencia de los algoritmos.

47

Esta regla nos asegura que si se dispone de dos trozos de código independientes, uno con eficiencia O(f(n)) y otro con O(f'(n)), la eficiencia del trozo completo será O(max(f(n), f'(n)). Por ejemplo, dados los órdenes de eficiencia de tres trozos consecutivos de un programa. O(n2), O(n3) y O(nlgn), el tiempo de ejecución de los tres será O(max(n2, n3, nlgn))= O(n3).

• Si g ∈ O(f) y h ∈ O(f'), entonces g· h ∈ O(f· f') [Regla del producto].

Demostración:

Si g(n) ∈ O(f(n)) entonces ∃c1, m1 / g(n) ≤ c1f(n), ∀ n ≥ n1.

Si h(n) ∈ O(f'(n)) entonces ∃c2, m2 / h(n) ≤ c2f'(n), ∀ n ≥ n2.

∀ n≥ n1· n2 se tiene que f(n)· f'(n) ≤ (c1· c2) f(n)· g(n).

Esta regla nos asegura que si existen dos trozos de código anidados (no independientes), uno con eficiencia O(f(n)) y otro O(f'(n)), la eficiencia del trozo completo es O(f(n)*g(n)). En este caso, si el trozo de código más interno es O(lgn), y el más externo posee O(n), el del código completo pertenecerá a O(nlgn).

La notación o minúscula es una cota superior, aunque diferente de O(f(n)): si g(n) ∈ o(f(n)), indica que el crecimiento de g(n) es estrictamente más lento que el de f(n), al contrario que ocurría con O(· ), en donde en algún momento una constante por f(n) llega a ser una cota superior para g(n). Su definición es la siguiente:

o(f(n)) = g ∀c0 ∈ R+ y ∃n0 ∈ N, ∀ n ≥ n0 g(n) ≤ c0f(n)

Si comparamos las definiciones de O(· ) y o(· ) podemos observar que la única diferencia es que el cuantificador existencial de la primera se convierte en un cuantificador universal en la segunda, indicando que independientemente del factor que se utilice, co, f siempre estará por encima de g. Así, por ejemplo, 2n ∈ o(n2), pero 2n2∉ o(n2).

También se dice que g(n) ∈ o(f(n)) si y sólo si el límite cuando n tiende al infinito de g(n)/f(n) es cero.

Por último, indicar que se cumple la siguiente cadena de inclusiones:

O(1) ⊂ O(log n) ⊂ O(n) ⊂ O(n2) ⊂ ... ⊂ O(na) ⊂...⊂ O(2n) ⊂ O(n!)

Las notaciones Ω. De igual forma que las notaciones O y o se encargan de establecer cotas superiores, las

notaciones Ω nos dan una cota inferior en el orden de eficiencia (el mejor caso). Existen dos: Ωk y Ω∞, cuyas definiciones respectivas son las siguientes:

Ωk(f(n)) = g ∃c0 ∈ R+ y ∃n0 ∈ N, ∀ n ≥ n0 g(n) ≥ c0f(n)

Ω∞(f(n)) = g ∃c0 ∈ R+ y ∃n0 ∈ N, ∃ n ≥ n0 g(n) ≥ c0f(n)

Si g(n) ∈ Ωk(f(n)) entonces f es una cota inferior de g desde un punto en adelante, o lo que es lo mismo, ofrece un mínimo del cual nunca baja (g crece más deprisa que f). Por otro lado, si g(n) ∈ Ω∞(f(n)), entonces en una cantidad infinita de ocasiones g crece lo suficiente como para alcanzar a f.

En este caso, si el límite cuando n tiende a infinito de g(n)/f(n) es infinito, entonces g(n) ∈ Ω(f(n)).

De igual manera que la notación O(· ) posee la notación o(· ), para Ω(· ), existe la notación ω(· ), con el mismo significado que o, aunque teniendo en cuenta que tratamos ahora cotas inferiores.

Page 45: programacion2

Programación II

48

Las notaciones Θ. Esta notación define las funciones con la misma tasa de crecimiento (crecen al mismo ritmo)

que f, es decir, Θ(f) = O(f) ∩ Ωk(f). Formalmente:

Θ (f(n)) = g ∃c0, c1∈ R+ y ∃n0 ∈ N, ∀ n ≥ n0, 0 ≤ c0f(n) ≤ g(n) ≤ c1f(n)

En definitiva, si existen dos constantes por las que la función g queda embutida entre la función f, entonces g(n) ∈ Θ(f(n)) (leído sería el orden exacto de g es f). En este caso, el límite cuando n tiende a infinito de g(n)/f(n) es igual a k>0, entonces g(n) ∈ Θ(f(n)).

Relaciones entre las diferentes notaciones. En el gráfico de la figura 2, y a modo de aclaración, se pueden observar ejemplos gráficos del

significado de cada notación:

Figura 2. Representaciones gráficas de las diferentes notaciones asintóticas.

3.4 Cálculo del tiempo de ejecución de un algoritmo iterativo y de su orden de eficiencia. En esta sección vamos a establecer un conjunto de reglas simples que nos ayudarán a

estudiar la complejidad algorítmica de un programa. A partir de este momento sólo trabajaremos con la notación O(· ), haciendo referencia, por tanto, al peor de los casos. El objetivo es pues obtener el orden de eficiencia de un programa, para lo cual estudiaremos cómo se calcula el tiempo de ejecución de las diferentes sentencias que nos podemos encontrar en un lenguaje de programación y que son relevantes para dicho cálculo: sentencias simples, condicionales e iterativas.

Page 46: programacion2

Análisis de la eficiencia de los algoritmos.

49

El objetivo final que vamos buscando se puede alcanzar de dos formas diferentes:

• Obtener el tiempo de ejecución del programa, y posteriormente calcular su orden de eficiencia.

• Ir calculando el orden de eficiencia de las diferentes sentencias y bloques existentes en el programa.

Sentencias simples. Cualquier sentencia simple (lectura, escritura, asignación, ...) tardará un tiempo O(1), ya que

el tiempo constante que tarda realmente la ejecución de esa sentencia se puede acotar por una constante que multiplica a la función f(n)=1. Esto podremos hacerlo siempre y cuando no intervengan en dicha sentencia ni variables estructuradas,ni operandos aritméticos que dependan del tamaño del problema.

Veamos un ejemplo de un programa muy sencillo: /*1*/ void main() /*2*/ /*3*/ int a, b, c; /*4*/ printf("Introduzca los dos números a sumar: "); /*5*/ scanf("%i %i", &a, &b); /*6*/ c= a+b; /*7*/ printf("\n La suma de %i y %i es %i.\n", a, b,c); /*8*/ Este programa está compuesto sólo por sentencias sencillas (sólo intervienen en el cálculo

las sentencias que van de la 4 a la 7), por lo que su orden de eficiencia va a ser, utilizando la regla de la suma, el orden constante, es decir, O(1). Veamos por qué:

Hemos dicho anteriormente que las escrituras, salidas y asignaciones tienen todas ellas O(1), por tanto, el orden del programa completo será O(1+1+1+1) = O(max(1,1,1,1))= O(1); La otra forma de llegar al mismo resultado sería la siguiente: T(n)= te+tl+toa+te= c, siendo te el tiempo constante que lleva ejecutar una escritura; tl, el de la lectura y toa el que se tarda en hacer una operación aritmética más una asignación. Es fácilmente comprobable que T(n)=c ∈ O(1).

En este ejemplo anterior, hemos visto que, para calcular el orden de un bloque de sentencias, se aplica la regla de la suma, tomando el máximo de los ordenes de eficiencia de dicho bloque. Esta forma de proceder será independiente de los tipos de sentencias de que conste el bloque.

Sentencias de repetición. Fijémonos primeramente en los bucles controlados por contador. ¿Cuántas veces se repite un

bucle de este tipo? En estas sentencias iterativas es muy fácil de determinarlo, ya que los límites están expresados en el mismo bucle, y sólo queda realizar una resta de ambos límites. Una vez conocido ese valor, bastaría con multiplicarlo por el tiempo que tardaría en ejecutarse una única iteración del cuerpo del bucle para calcular el tiempo que tarda el bucle completo, sumándole posteriormente el tiempo de evaluación de la condición, más el de incremento de la variable contadora.

En el siguiente bucle:

/*1*/ for (i=0; i<n;i++)

/*2*/ printf(" %i ", i);

Page 47: programacion2

Programación II

50

La sentencia printf tiene un tiempo de ejecución te. Como el ciclo se repite n veces, tendríamos que T(n) =tc + nte ∈ O(n), siendo tc la constante que identifica el tiempo de evaluación de la condición más el incremento.

El código que se muestra a continuación, la inicialización de una matriz cuadrada, nos servirá para ejemplificar cómo se debe aplicar la regla del producto para obtener su orden de eficiencia:

/*1*/ for (i=0; i<n;i++) /*2*/ for (j=0; j<n;j++) /*3*/ Matriz[i][j]= 0; La asignación de la línea 3 tiene un orden de ejecución constante; el bucle de la línea 2 se

repite n veces, por lo que posee O(n), igual que ocurre con el ciclo de la primera línea (O(n)). Aplicando en este caso la regla del producto, llegamos a la conclusión que el tiempo de ejecución de estos dos ciclos anidados pertenece a O(n2).

Obteniendo el tiempo de ejecución del bloque completo tendríamos: T2-3(n) = nta + tc, para las sentencias 2 y 3, y T1-3(n)= n(nta+tc) +tc = tan

2 +tcn +tc ∈ O(n2) para las tres juntas.

Si la matriz no hubiera sido cuadrada, es decir, si la dimensión fuera n x m, en este caso, el orden de eficiencia sería O(n· m), siendo un ejemplo del caso en el que se tienen más de dos parámetros como tamaño de la entrada.

Nos centraremos ahora en los bucles controlados por centinela, donde se opera de manera análoga a como se ha hecho con el tipo de bucle anterior: se obtiene el tiempo de ejecución del interior del bucle, y posteriormente se multiplica por el número de iteraciones que se realizan.

En el trozo de código que se muestra a continuación, el cual busca la posición en la que se encuentra un valor dentro de un vector, supondremos el peor caso, es decir, que se tenga que recorrer todo el vector:

/*1*/ i=0; /*2*/ while (i<n && Vector[i] != valor) /*3*/ i++; Así, el bucle while repetirá n veces una sentencia de O(n), por lo que su orden de eficiencia

será n. En este caso también despreciamos el tiempo constante de la evaluación de la expresión del bucle. Si convirtiéramos en un bucle do...while el anterior, la forma de proceder sería la misma.

Sentencias condicionales. En este caso, el tiempo de ejecución será el tiempo de evaluación de la condición, más el

máximo del bloque a ejecutar cuando la condición se evalúe como verdadera y del bloque a ejecutar cuando se evalúe como falsa. Cuando estamos tratando con órdenes, el de la evaluación de la expresión booleana, que es constante, se desprecia. Así, si el bloque then tiene una eficiencia O(f(n)) y el bloque else O(g(n)), el orden de eficiencia de la sentencia if será O(max(f(n), g(n)).

En la siguiente sentencia if:

/*1*/ if (n> m) /*2*/ n= n *m; /*3*/ else /*4*/ for (i=0; i<n;i++) /*5*/ m= m * n;

Page 48: programacion2

Análisis de la eficiencia de los algoritmos.

51

Como hemos visto anteriormente, el bucle for tiene un tiempo de ejecución que pertenece a O(n), que coincide con el bloque de sentencias de la parte else del condicional. Por otro lado, el bloque de la parte then posee O(1), por lo que el orden de la sentencia condicional será el máximo de 1 y n, que claramente es n.

En el caso de hacer el cálculo obteniendo el tiempo de ejecución, éste sería T(n) = te + max(tea, ntea)= te + ntea ∈ O(n), donde te corresponde con el tiempo que se tardaría en hacer la evaluación de la condición del if, tea, el correspondiente a la asignación más la suma. El bucle ejecuta n veces una asignación, por lo que su tiempo de ejecución será ntea (en esta constante también incluimos el tiempo de gestión del bucle). Finalmente, el T(n) de la sentencia condicional será el máximo de ambos tiempos más el tiempo de evaluación.

Llamadas a funciones. Si en un programa se llama a una o varias funciones no recursivas, se deberá calcular el

tiempo de ejecución de cada una de ellas, comenzando por aquellas que no llaman a otras. Una vez hechos estos cálculos iniciales se procede a calcular el tiempo de ejecución de las funciones que sí contienen invocaciones a otras rutinas, utilizando para ello los tiempos de cada una de las funciones que ya han sido calculados.

Supongamos que un programa en C llama en dos lugares distintos a una función HacerAlgo1 y a otra HacerAlgo2. A su vez, la segunda función invoca a una tercera, HacerAlgo3. Para poder calcular el tiempo de ejecución del programa principal debemos calcular el tiempo de cada una de las funciones que son referenciadas. Así, HacerAlgo1 no invoca a ninguna, con lo que procederíamos a obtener su eficiencia directamente. Seguidamente nos disponemos a hacer lo mismo para HacerAlgo2, pero ésta sí realiza una llamada a otra función, por lo que para determinar su orden de eficiencia, debemos previamente encontrar el de HacerAlgo3. Con esta función no hay ningún problema, y también se puede hacer directamente. Una vez realizada esta tarea, procedemos a incorporar el orden de eficiencia de HacerAlgo3 en los cálculos para obtener el de HacerAlgo2, y finalmente, obtenemos el de la función main basado en las llamadas a HacerAlgo1 y HacerAlgo2.

Dependiendo de dónde estén situadas las llamadas a funciones, se deberá incluir su tiempo de ejecución de una u otra manera:

• En una asignación, el tiempo de ejecución de esa asignación básicamente será el de la función. Si hay más de una función invocada en la sentencia, corresponderá a la suma de tiempos. En el caso de órdenes, corresponde al máximo de cada uno.

• En una condición de un bucle (cualquier tipo), se sumará el tiempo de ejecución de la función al tiempo de ejecución de cada iteración, multiplicándose finalmente ese tiempo por el número de iteraciones que realiza el bucle. Además, para bucles controlados por centinela, habrá que sumar el tiempo de ejecución de la función al costo de la evaluación por primera vez de la condición en caso de que no se itere ninguna vez (aunque estudiando el peor de los casos, esto no se daría). Para los controlados por contador, si la llamada está en la inicialización del bucle, se deberá sumar el tiempo de ejecución de la función al costo total del bucle.

• En la condición de una sentencia condicional se suma el tiempo de ejecución de la función al obtenido al evaluar la sentencia condicional.

• En una secuencia de sentencias: simplemente aplicar la regla de la suma, si tratamos con órdenes, o sumar tiempos si son tiempos de ejecución.

Page 49: programacion2

Programación II

52

Ejemplos. En esta sección vamos a calcular el orden de eficiencia de varios programas. Para ello,

aplicaremos las reglas explicadas en los apartados anteriores.

Comencemos por la función Burbuja, la cual ordena un vector de n números enteros de forma creciente mediante el método de la burbuja. Su código es el siguiente:

void Burbuja (int *Vector, int n) int i, j; /*1*/ int aux; /*2*/ for (i = 0; i < n - 1; i++) /*3*/ for (j = n-1; j< i; j--) /*4*/ if (Vector[j-1] > Vector[j]) /*5*/ /*6*/ Aux= Vector[j-1]; /*7*/ Vector[j-1]= Vector[j]; /*8*/ Vector[j]= Aux; /*9*/ Comenzamos el estudio por el bloque de código más interno, para posteriormente ir

ascendiendo. Así, nos vamos al segundo bucle for, y dentro de él nos encontramos una sentencia if con una bloque then, el cual está formado por tres sentencias simples (líneas 6, 7 y 8), y por tanto, cada una ellas llevará O(1). Al aplicar la regla de la suma, se obtiene igualmente O(1). Seguimos avanzando hacia fuera, para obtener seguidamente el orden de eficiencia de la sentencia condicional de la línea 4. En este caso, no sabemos si se llegará a ejecutar el cuerpo then del if, pero como buscamos el peor caso, lo tenemos en cuenta, en cuyo caso el if se ejecutará en O(1). Continuamos hacia fuera, y encontramos el for de la línea 3. Cada iteración tomará un tiempo perteneciente a O(1), y al repetirse el ciclo n-i veces, tendremos que el código de las líneas 3 a 9 tendrá una eficiencia O((n-i) * 1) = O(n-i). Finalmente nos encontramos con el bucle más externo: en este caso, la línea 2 ejecutará n-1 veces, por lo que el tiempo de ejecución del bucle será una constante multiplicada por:

222

)1()(

21

1

nnnnin

n

i

−=−=−∑−

=

que pertenece a O(n2). Por tanto para ejecutar la función Burbuja se necesita un tiempo proporcional al cuadrado del número elementos que se desea ordenar.

El siguiente ejemplo que vamos a estudiar es el problema de la suma de la subsecuencia máxima, el cual consiste en encontrar una secuencia de números consecutivos almacenados en un vector de tamaño n cuya suma sea máxima3. Por ejemplo, dado el vector -2, 11, -4, 13, -5, -2, el valor máximo que se puede conseguir al sumar varios elementos consecutivos del vector es 20 y se alcanza en 11, -4, 13, -5.

Una posible implementación en C es la siguiente:

3 En las implementaciones que vamos a estudiar y para facilitar el entendimiento de las expresiones a la hora de calcular la eficiencia, supondremos que los valores se sitúan a partir de la posición primera del vector. Además, vamos a consierar las constantes igual a 1 para simplificar las operaciones.

Page 50: programacion2

Análisis de la eficiencia de los algoritmos.

53

long SumaSubsecuenciaMaxima(int *Vector, int n) int i, j, k; /*1*/ int SumMax=0, PosInicioSec=0, PosFinSec=0, SumaActual; /*2*/ for (i=0;i<=n; i++) /*3*/ for (j=i;j<=n; j++) /*4*/ /*5*/ SumaActual=0; /*6*/ for (k=i;k<=j; i++) /*7*/ SumaActual+= Vector[k]; /*8*/ If (SumaAcutal > SumaMax) /*9*/ /*10*/ SumaMax= SumaActual; /*11*/ PosInicioSec= i; /*12*/ PosFinSec= j; /*13*/ /*14*/ /*15*/ return SumMax; Entre las líneas 4 y 14 tenemos una asignación, un bucle for y un if. En este caso, aplicando

la regla de la suma, tomaremos el orden que sea mayor de los tres. La asignación, como ya sabemos, es O(1), el condicional podemos considerarlo también como O(1). Por otro lado, el bucle repetirá j-i+1 veces una asignación, lo que implica que pertenecerá a O(j-i+1). Como debemos ponernos en el peor de los casos, el bucle se podría repetir n veces, con lo cual nos debemos quedar con O(n) como el máximo de los tres órdenes. Claramente el tiempo de ejecución de la función vendrá dada por la siguiente suma:

∑∑∑= = =

=n

i

n

ij

j

ik

nT1

1)( , la cual determina el número de veces que se ejecuta la instrucción de la

línea 6. La suma de más a la derecha, que representa el bucle de la línea 6, ofrece como resultado j-i+1. Si resolvemos la siguiente sumatoria, que se corresponde con el bucle de la línea 3, obtendremos:

2

)2)(1()1(

+−+−=+−∑=

ininij

n

ij

, y por último, realizamos los cálculos sobre la primera

sumatoria, bucle de la línea 2, aplicada a la expresión anterior:

=+++

+−=+−+− ∑∑∑∑

====

n

i

n

i

n

i

n

i

nniniinin

1

2

11

2

1

1)33(2

1

2

3

2

1

2

)2)(1(

)(6

23

2

23

2

)1(

2

3

6

)12)(1(

2

1 3232

nOnnn

nnnnn

nnnn ∈++=++++

+−++=

Teniendo en cuenta el orden de complejidad de la función, y observando la implementación, nos damos cuenta que se podría evitar ese orden si se elimina un bucle for, consiguiendo un orden de eficiencia cuadrático:

long SumaSubsecuenciaMaxima(int *Vector, int n) int i, j, k; /*1*/ int SumMax=0, PosInicioSec=0, PosFinSec=0, SumaActual;

Page 51: programacion2

Programación II

54

/*2*/ for (i=0;i<=n; i++) /*3*/ /*4*/ SumaActual=0; /*5*/ for (j=i;j<=n; j++) /*6*/ /*7*/ SumaActual+= Vector[k]; /*8*/ If (SumaAcutal > SumaMax) /*9*/ /*10*/ SumaMax= SumaActual; /*11*/ PosInicioSec= i; /*12*/ PosFinSec= j; /*13*/ /*14*/ /*15*/ /*16*/ return SumMax; Este es un ejemplo de cómo un estudio de la complejidad algorítmica puede hacer que nos

replanteemos el algoritmo, dando lugar finalmente a un diseño mucho más eficiente. De hecho, se puede reducir más su orden de complejidad mediante un algoritmo recursivo a O(nlgn), tarea que le proponemos al lector.

3.5 Cálculo del tiempo de ejecución de algoritmos recursivos y de su orden de eficiencia. Cuando se analiza la eficiencia de un programa recursivo, suele ocurrir habitualmente que las

funciones del tiempo de ejecución que se obtengan sean también recursivas, es decir, expresiones de la forma T(n)=E(n), apareciendo la propia función T en la expresión E. Este tipo de ecuaciones se denominan relaciones recurrentes o recurrencias. Una vez que tenemos una recurrencia, para poder obtener su orden de eficiencia deberíamos encontrar una expresión no recursiva para la función T(n). De nuevo, se ignorarán las constantes multiplicativas.

Veamos el siguiente ejemplo que se corresponde con la implementación recursiva del cálculo del factorial y que nos sirve para introducir el método de expansión de recurrencias:

long factorial (long n) /*1*/ if (n <= 0) return 1; /*2*/ else return n * factorial(n-1); Podemos observar que si n ≤ 1, el tiempo de ejecución de la función recursiva es T(n)=c y

que si n > 1, se cumplirá que T(n) = d + T(n-1).

¿Cómo resolvemos esta recurrencia? Más adelante estableceremos formalmente varios métodos, pero por ahora obtendremos la expresión no recursiva de T(n) mediante la expansión de la misma, es decir, sustituyendo T(n-1) por su valor correspondiente, y así sucesivamente hasta que se elimine la recursividad de la expresión del tiempo de ejecución: hasta que lleguemos al caso donde T(n) no está expresada en función de sí misma.

T(n) = d + T(n-1)= d + d+ T(n-2)= d + d + d + T(n-3). Si repetimos el proceso i pasos, la recurrencia tendría la siguiente forma: T(n)=id+T(n-i), n>i. En particular, para i=n-1, tenemos que T(n)= (n-1)d + T(n-(n-1))= (n-1)d + T(1)= (n-1)d+c, ya que conocemos que T(1) es 1. Al final el tiempo de ejecución pertenece a O(n).

Page 52: programacion2

Análisis de la eficiencia de los algoritmos.

55

Continuemos con el estudio de la eficiencia del un algoritmo de ordenación basado en el método de selección recursivo:

void OrdenarVector (int *Vector, int n) int i, MaxPos; if (n>1) MaxPos=0; for (i=1; i<n;i++) if (Vector[i] > Vector[MaxPos]) MaxPos= i; if (MaxPos != 0) i= Vector[0]; Vector[0]= Vector[MaxPos]; Vector[MaxPos]= i; OrdenarVector(Vector+1, n-1); La ecuación de recurrencia está definida en dos partes: la primera corresponde al caso base,

es decir, cuando el vector a ordenar tenga una longitud menor o igual que uno, en cuyo caso, T(1)=1, ya que sólo tendremos que contar el tiempo que tarda la evaluación de la condición, el cual suponemos 1. En el caso contrario, n ≥ 2, tendremos un bucle con un cuerpo constante, que se repite n veces más el tiempo de la llamada recursiva, pero esta vez aplicada a un vector con un tamaño en una unidad menor que el de la llamada original. Por tanto T(n) = T(n-1) + n.

Una vez planteada la ecuación recurrente procederemos a su resolución mediante la técnica de la expansión, para lo cual realizaremos expansiones sucesivas:

T(n)= T(n-1) + n = T(n-2) + (n-1) + n = T(n-3) + (n-2) + (n-1) + n,

y en general:

1,)()()(1

0

+≥−+−= ∑−

=

injninTnTi

j

.

Si particularizamos para el valor i=n-1, podremos eliminar el término recurrente, obteniendo

∑−

=

−+=2

0

)()1()(n

j

jnTnT

Por último nos queda resolver la sumatoria:

)(2

)1()(...21)( 2

2

0

nOnn

jnnnTn

i

∈+=−=+++= ∑−

=

Hemos visto, por tanto, que un programa recursivo generará una recurrencia que describe su tiempo de ejecución en función de ella misma, y que para obtener su eficiencia se debe resolver. Es en este aspecto en el que nos vamos a centrar a continuación: en los diferentes métodos de resolución de recurrencias, ya que no siempre se pueden expandir los tiempos de ejecución de manera tan sencilla.

Page 53: programacion2

Programación II

56

3.6 Resolución de recurrencias homogéneas. Las recurrencias lineales homogéneas tienen la forma:

a0tn + a1tn-1 + ... + aktn-k = 0 [1]

donde los ti son los valores buscados (aplicamos el adjetivo de lineal por que no hay términos de la forma titi+j ó ti

2, por ejemplo), los coeficientes ai son constantes y la recurrencia es homogénea por que la combinación lineal de los ti es igual a 0.

Las soluciones que buscamos son de la forma tn=xn, donde x es una constante. Si sustituimos esta solución en [1] obtenemos:

a0xn + a1x

n-1 + ... + ak xn-k = 0 [2]

Esta ecuación tiene dos soluciones x=0, que no nos sirve, y a0xk + a1x

k-1 + ... + ak= 0, ecuación que se denomina ecuación característica asociada a la ecuación recurrente inicial. Supongamos que las k raíces de esta ecuación característica son r1, r2, ..., rk, entonces cualquier

combinación lineal ∑=

=k

i

kiin rct

1

de términos rin es solución para la ecuación recurrente lineal

homogénea.

Clarifiquemos esta explicación teórica anterior con la resolución de la siguiente recurrencia:

≥−+−==

=2),2(4)1(3

1,1

1,0

)(

nsinTnT

nsi

nsi

nT

En primer lugar pongamos la expresión recurrente anterior con la forma de la recurrencia lineal homogénea mediante un cambio de notación [1]:

tn= 3tn-1 - 4tn-2 => tn - 3tn-1 - 4tn-2 = 0

El siguiente paso será hacer la sustitución tn=xn, ya que es la forma de la solución que buscamos:

xn - 3xn-1 - 4xn-2 =0.

Posteriormente dividimos cada término por xn-k= xn-2 , con objeto de eliminar la solución trivial y conseguimos la ecuación característica:

x2-3x-4=0.

A continuación, se resuelve la ecuación obteniendo como raíces -1 y 4. La solución general a la expresión recursiva tendrá la forma de la expresión [2], donde los ri son las soluciones de la recurrencia.

tn= c1(-1)n + c24n

Las constantes se obtienen a partir de las condiciones iniciales. Si sustituimos n=0 y n=1 en la ecuación anterior, llegaremos al siguiente sistema de dos ecuaciones con dos incógnitas (en este caso c1 y c2):

c1 + c2 = 0 y -c1 + 4c2 =1

La solución es c1= -1/5 y c2= 1/5, valores que se substituirán para llegar a la ecuación del tiempo de ejecución sin recurrencias: tn= (1/5)(4n - (-1)n) ∈ O(4n).

Para afianzar el cálculo, veamos otro ejemplo sobre la recurrencia que corresponde al tiempo de ejecución de la sucesión de Fibonacci:

tn= tn-1 + tn-2, n≥2, y t0= 0, t1= 1

Page 54: programacion2

Análisis de la eficiencia de los algoritmos.

57

Cambiando de notación, sustituyendo tn=xn y dividiendo por xn-k= xn-2 se obtiene la ecuación característica:

tn-tn-1-tn-2=0 => x2-x-1=0

Si resolvemos la ecuación, sus soluciones son r1= (1+51/2)/2 y r2=(1-51/2)/2. Sustituyendo las condiciones iniciales n=0 y n=1, en la expresión que nos da la solución, tn=c1r1

n +c2r2n,, obtenemos el

sistema de dos ecuaciones con dos incógnitas:

c1 + c2 = 0 y c1r1 + c2r2 = 1.

Al solucionarlo, las incógnitas toman los valores c1=1/(51/2) y c2=-1/(51/2). Finalmente, nos quedaría sustituir c1 y c2 en la expresión de la solución de la recurrencia. Al final T(n) ∈ O((1/(51/2))n)

En los dos ejemplos anteriores, las raíces de la ecuación característica han sido todas distintas, pero puede darse el caso en el que se repitan, como ocurre en el siguiente ejemplo:

Dada la recurrencia:

tn= 5tn-1 - 8tn-2 + 4tn-3, n≥3, t0=0, t1=1, t2=2

La ecuación característica es:

x3 - 5x2+ 8x - 4= 0

Tras resolver la ecuación anterior, las soluciones son 1, con multiplicidad 1, y 2, con multiplicidad 2, es decir, (x-1)(x-2)2 =0. La solución general será, por tanto:

tn= c11n + c22

n + c3n2n

Esta expresión se crea igual que la expresión [2] para las raíces simples, añadiéndole tantos sumandos como multiplicidades tengan las raíces múltiples: si m es la multiplicidad de una raíz r, entonces se añadirán los sumandos rn, nrn, n2rn,..., nm-1rn, multiplicados por sus correspondientes constantes. A partir de este momento, todo se desarrolla igual: dadas las condiciones iniciales, se plantean las ecuaciones de un sistema lineal, que en el ejemplo tendrá tres ecuaciones con tres incógnitas:

c1 + c2= 0, c1 + 2c2 + 2c3= 0 y c1 + 4c2 + 8c3= 0,

Las soluciones son c1= -2, c2= 2 y c3=-1/2, para finalmente encontrar la expresión de la solución a la recurrencia:

tn= 2n+1 - n2n-1 - 2 ∈O(2n).

3.7 Resolución de recurrencias no homogéneas. Las recurrencias que vamos a tratar a continuación son más generales que las anteriores y

tienen el siguiente aspecto:

a0tn + a1tn-1 +...+ aktn-k = bnp(n) [3]

Donde p(n) es un polinomio de grado d, y b es una constante (el resto es igual que [1]). A partir de ahí, se intentará obtener la ecuación característica, que tendrá el siguiente aspecto:

(a0xk + a1x

k-1 +...+ ak)(x-b)d+1=0 [4]

Donde (a0xk + a1x

k-1 +...+ ak) es la aportación de la parte homogénea y (x-b)d+1 de la parte no

homogénea. Una vez en este punto, se concluye el proceso dando los mismos pasos que en la solución de recurrencias homogéneas.

El siguiente ejemplo nos permitirá identificar cada una de las funciones que componen la expresión [3]:

tn= 1 + tn-1 + tn-2 => tn - tn-1 - tn-2 = 1

Page 55: programacion2

Programación II

58

Como 1= 1np(n), siendo p(n)=1 con grado d=0 y b=1. Esto implica que la ecuación característica quedaría:

(x2 -x-1)(x-1)=0

Un segundo ejemplo:

tn - 2tn-1 = 3n

En este caso, b=3, p(n) = 1 y, por tanto, d=0, obteniendo la ecuación característica:

(x-2)(x-3)=0

Si la ecuación recurrente fuera:

tn - 2tn-1= (n+5)3n

En este caso, b=3, p(n)= n+5, con d=1. La ecuación característica sería:

(x-2)(x-3)2=0

Veamos un último ejemplo, esta vez completo. La recurrencia será: T(n) = 2T(n-1) + n, n ≥1 y T(0) = 0. Cambiando la notación tendremos la siguiente ecuación lineal no homogénea:

tn - 2tn-1 = n

Identificamos b=1 y p(n)=n, siendo d=1, con lo que se obtiene la ecuación característica siguiente:

(x-2)(x-1)2 = 0

Por tanto, la solución es:

tn= c12n + c21

n + c3n1n = c12n + c2 +c3n

Con t0, se obtienen t1 y t2, y se sustituyen en la ecuación anterior, obteniendo el siguiente sistema de tres ecuaciones con tres incógnitas:

c1 + c2 = 0, 2c1 + c2 + c3 = 1, 4c1 + c2 + 2c3 = 4

Las soluciones son c1 = 2, c2 = -2 y c3 = -1, con lo que finalmente:

tn =2· 2n - n -2 = 2n+1 - n - 2 ∈ O(2n+1)

De esta misma manera, se pueden resolver recurrencias de la forma:

a0tn + a1tn-1 +...+ aktn-k = b1np1(n) + b2

np2(n) + b3np3(n) +... [5]

Siendo d1 el grado de p1(n), d2 el de p2(n) y así sucesivamente. Las ecuaciones características tendrán el siguiente patrón:

(a0xk + a1x

k-1 + ...+ ak)(x - b1)d1+1(x - b2)

d1+2...= 0 [6]

Resolvamos la siguiente recurrencia: T(n)= 2T(n-1) + n + 2n, n≥1 y T(0)= 0. En primer lugar cambiemos la notación y reorganicemos la expresión:

tn = 2tn-1 + n + 2n => tn - 2tn-1 = n + 2n

La parte derecha de la igualdad debemos expresarla de la forma:

n + 2n = b1np1(n) + b2

np2(n)

Cosa que conseguiremos si identificamos b1= 1, p1(n)= n (grado d=1) y b2= 2 , p2(n)= 1 (d=0). De esta manera, podemos obtener la ecuación característica:

(x-2)(x-1)2(x-2) = 0

Su solución tiene la forma:

tn=c11n + c2n1n + c32

n + c4n2n = c1 + c2n + c32n + c4n2n

Sustituyendo las condiciones iniciales y resolviendo el sistema de cuatro ecuaciones con cuatro incógnitas, concluiremos que T(n) ∈ O(n2n).

Page 56: programacion2

Análisis de la eficiencia de los algoritmos.

59

3.8 Resolución de recurrencias mediante cambio de variable. A menudo se pueden resolver recurrencias más complicadas mediante un cambio de

variable. En esta sección mostramos cómo se llevaría a cabo con dos ejemplos. El primero lo resolveremos cambiando de variable y seguidamente expandiendo. El segundo, tras realizar el cambio de variable, obtenemos una ecuación recurrente no homogénea, que pasamos a resolver como se ha comentado en la sección anterior.

Esta ecuación recurrente corresponde al tiempo de ejecución de la búsqueda binaria recursiva:

=

≥+=1,1

2,)2

()(n

nnn

TnT

Debido a que el tamaño del problema se divide en dos suponemos que n es potencia de dos, por lo que podemos hacer n=2m, quedando:

T(2m)= T(2m-1) + 1, m ≥ 1, siendo el caso base T(20)= 1. Realizamos varias expansiones:

T(2m)= T(2m-1) + 1 =T(2m-2) + 1 + 1 = T(2m-3) + 1 + 1 + 1,

y en general:

T(2m)= T(2m-i)+i, m ≥ i

Particularizando para m=i para así poder eliminar la recurrencia, tendríamos:

T(2m)= T(20) + m = m + 1

Como m= lg n, desaciendo el cambio, finalmente concluimos que T(n)= lgn + 1 ∈ O(lgn).

Un segundo ejemplo donde n es una potencia de 2, corresponde a la resolución de la recurrencia:

T(n)= 4T(n/2) + n2

Al hacer el mismo cambio de variable que en el ejercicio anterior, se llega a

T(2m)= 4T(2m-1) + 4m,

O lo que es lo mismo:

tm= 4tm-1 + 4m

La ecuación característica es:

(x-4)2=0

Y la solución buscada tendrá la forma:

tm= c14m + c2k4m.

Deshaciendo los cambios, el tiempo de ejecución es T(n)= c1n2 + c2n

2lg n ∈ O(n2 lg n).

3.9 Bibliografía.

• [AHU87] A.V. Aho, J.A. Ullman. Data structures and algorithms. Addison-Wesley (1.992).

• [BB90] G. Brassard, P. Bratley. Algoritmica. Concepción y análisis. Masson. (1.990).

• [CLR??] T.H. Cormen, C. E. Leiserson, R. L. Rivest. Introduction to algorithm. MIT Press.

Page 57: programacion2

Programación II

60

• [FGG98] J. Fernández, A. Garrido, M. García. Estructuras de datos. Un enfoque práctico usando C. Universidad de Granada (1.998).

• [HSR97] E. Horowitz, S. Sahni, S. Rajasekaran. Computer algorithms. Computer Science Press (1.997).

• [Har92] D. Harel. Algorithms. The spirit of computing. 2nd Edition. (1.992).

• [MS91] B. M. E. Moret, H.D. Shapiro. Algorithms from P to NP. Volume I. Design and eficiency. Benjamin/Cummings (1.991).

• [PB84] P.W. Purdon, C. A. Brown. The analysis of algorithms. CBS College Publishing (1.984).

• [Peñ93] R. Peña. Diseño de programas. Formalismo y abstracción. Prentice Hall (1.993).

• [Sed98] R. Sedgewick. Algorithms in C++. 3rd Edition. Addison-Wesley (1.998).

• [Smi89] J.D. Smith. Design and analysis of algorithms. PWS-KENT publishing company (1.989).

• [Wei95] M. A. Weiss. Estructuras de datos y algoritmos. Addison-Wesley (1.995).

Page 58: programacion2

Capítulo 4: Tipos de Datos Abstractos

4.1 IntroducciónEn el primer capítulo se ha descrito la metodología de programación a seguir para resolver un

problema. En la figura 4.1 se resume el proceso de programación en tres etapas. En la primera etapase expresan ciertos aspectos de un problema a través de un modelo formal, de ahí que a esta fase lapodamos llamar fase de modelación. Para encontrar un modelo se puede recurrir a cualquier rama delas matemáticas. Una vez se cuente con un modelo matemático adecuado del problema, puedebuscarse una solución en función de ese modelo. En esta etapa, la solución del problema será unalgoritmo expresado de manera muy informal.

Para convertir en programa un algoritmo tan informal, es necesario pasar por varias etapas deformalización (refinamientos por pasos) hasta llegar a un programa cuyos pasos tengan un significadoformalmente definido en un lenguaje de programación. En algún punto de este proceso, el programaen seudolenguaje estará suficientemente detallado para que las operaciones a realizar con losdistintos tipos de datos estén bien determinadas. Entonces se crean los tipos de datos abstractospara cada tipo de datos (con excepción de los datos de tipo elemental como los enteros, los reales olas cadenas de caracteres) dando un nombre de procedimiento a cada operación y sustituyendo losusos de las operaciones por invocaciones a los procedimientos correspondientes. Por tanto, en lasegunda etapa, la solución al problema será un algoritmo en seudolenguaje definido sobre tipos dedatos abstractos y sus operaciones.

Figura 4.1: Proceso de solución de problemas

Para conseguir definitivamente un programa ejecutable es necesaria una última etapa en laque para cada tipo de datos abstracto se elija una representación y se reemplace por sentencias en Ctoda proposición que quede escrita en seudolenguaje. En la representación de cada tipo de datosabstracto se definirá el nombre del tipo de datos abstracto mediante declaraciones en C de acuerdo ala estructura de datos seleccionada y para cada operación del tipo de datos abstracto se escribirá unprocedimiento en C que realice la operación deseada. El resultado será un programa ejecutable.Después de depurarlo será finalmente un programa operativo.

4.2 Concepto de TDA

4.2.1 Introducción al concepto de TDALa mayor parte de los conceptos introducidos en la sección anterior son familiares del primer

curso de programación. La única noción nueva es la de tipo de datos abstracto; por eso antes decontinuar, se analizará el papel de estos tipos durante el proceso general de diseño de programas.Para comenzar es útil comparar este concepto con el más familiar, el de procedimiento. Losprocedimientos generalizan el concepto de operador. Evitan al programador limitarse a losoperadores incorporados en un lenguaje de programación, con el uso de procedimientos, elprogramador es libre de definir sus propios operadores y aplicarlos a operandos que no tienen porqué ser de tipo fundamental. Ej. : multiplicación de matrices.

ModeloMatemático

algoritmoInformal

Tipos de datosabstractos

algoritmoseudolenguaje

Estructuras dedatos

Programa en C

Page 59: programacion2

Programación II

62

Otra ventaja de los procedimientos es que pueden utilizarse para encapsular partes de unalgoritmo, localizando en una sección de un programa todas las sentencias que incumben a unaspecto del programa en concreto. Un ejemplo de encapsulación es el uso de un procedimiento paraleer todas las entradas y verificar su validez. La ventaja de realizar encapsulaciones es que se sabe adonde ir para realizar cambios. Por ejemplo, si se produce un fallo en la entrada de datos se sabecon exactitud donde están las líneas que provocan el fallo.

Podemos ahora definir el concepto de tipo de dato abstracto (TDA) como un modelomatemático con una serie de operaciones definidas en ese modelo. Un ejemplo de TDA son losconjuntos de números enteros con las operaciones de unión, intersección y diferencia. Lasoperaciones de un TDA pueden tener como operandos no solo los del TDA que se define, sinotambién otros tipos de operandos, como enteros o de otros TDA, y el resultado de una operaciónpuede no ser un caso de ese TDA. Sin embargo, se supone que al menos un operando, o elresultado, de alguna operación pertenece al TDA en cuestión.

Las propiedades de los procedimientos mencionadas anteriormente, generalización yencapsulación, son igualmente aplicables a los tipos de datos abstractos. Los TDA songeneralizaciones de los tipos de datos primitivos (enteros, caracteres,...), al igual que losprocedimientos son generalizaciones de operaciones primitivas (suma, resta,...). Un TDA encapsulacierto tipo de datos pues es posible localizar la definición del tipo y todas sus operaciones en unasección del programa. De esta forma, si se desea cambiar la forma de implementar un TDA, se sabehacia dónde dirigirse, y revisando una pequeña sección del programa se puede tener la seguridadque no hay detalles en otras partes que puedan ocasionar errores relacionados con ese tipo de datos.Una vez definido un TDA, éste se puede utilizar como si fuese un tipo de dato primitivo, sinpreocuparse por cual sea su implementación.

Metodología para la definición de un TDA

Para definir un TDA de manera que pueda ser utilizado necesitamos realizar unaespecificación del TDA, teniendo en cuenta:

• Definir el dominio del TDA en donde tomará valores una entidad que pertenezca al modelomatemático del TDA.

• Definir los efectos que producen en el dominio del TDA cada una de las operaciones definidas.Para ello debemos primero conocer como hacer referencia a una operación, esto implicarárealizar una especificación sintáctica de las operaciones, indicando las reglas que hay que seguirpara hacer referencia a una operación. Y en segundo lugar, debemos conocer que significado oconsecuencia tiene cada operación, debemos realizar una especificación semántica.

Dominio de un TDA

Identificar y describir el dominio de un TDA es bastante sencillo. Hay distintas formas dehacerlo:

• Si el dominio es finito y pequeño, éste puede ser enumerado. Por ejemplo, el dominio deltipo booleano es true, false.

• Se puede hacer referencia a un dominio conocido de objetos matemáticos. Por ejemplo,el conjunto de los números negativos.

• Se puede definir constructivamente. Enumerando unos cuantos miembros básicos deldominio y proporcionando reglas para generar o construir los miembros restantes a partirde los enumerados. Por ejemplo, el dominio de las cadenas de caracteres puede definirsecomo sigue:

1. Cualquier letra es una cadena.

Page 60: programacion2

Tipos de Datos Abstractos

63

2. Cualquier cadena seguida de una letra es una cadena.

Este tipo de definiciones se llama también definiciones recursivas, ya que hacenreferencia a los elementos del dominio definido para crear nuevos elementos.

Especificación sintáctica de un TDA

Consiste en determinar como hay que escribir las operaciones de un TDA, dando el tipo deoperandos y el resultado. Por ejemplo, el tipo entero se puede especificar sintácticamente,enumerando las siguientes operaciones:

+ : entero x entero entero

- : entero x entero entero

> : entero x entero boolean

abs: entero entero

Otra forma más relacionada con la forma de escribir en un lenguaje de programación es:

int ‘+’ (int a,b)

int ‘-‘ (int a,b:entero)

unsigned char ‘>’ (int a,b)

int abs (int a)

La interpretación de este tipo de especificaciones es la siguiente: + es una función que seaplica sobre dos enteros y devuelve uno. Cuando el nombre del operador se escribe entre comillas,como es el caso de +, -, >, significa que al aplicar el operador hay que aplicarlo en forma infija:(operando1 operador operando2). La función abs que no está entre comillas se expresa en la formausual de las funciones, abs(n).

A lo largo del libro usaremos la notación correspondiente a la sintaxis del lenguaje C pararealizar esta especificación. La sintaxis de las operaciones vendrá descrita por las cabeceras de lasfunciones correspondientes a cada una de las operaciones.

Especificación semántica de un TDA

Una vez especificada la sintaxis de las operaciones de un TDA hay que dar su significado.Una forma de especificar el significado de las operaciones sería mediante el lenguaje natural. Sinembargo, el uso del lenguaje natural puede dar lugar a ambigüedades. Por ejemplo, si decimos que‘div’ divide un entero entre otro, esto puede dejar lugar a dudas sobre como funciona este operador.Una buena especificación debe de identificar todas las posibles acciones que puedan ocurrir, deforma que cuando se requiera más precisión se debe de utilizar una notación matemática. Este es elcaso de la notación algebraica. Consiste en dar un conjunto de axiomas verificados por lasoperaciones del TDA.

Ejemplo:

Vamos a definir el TDA sucesión de forma algebraica. Una sucesión es un tipo parametrizado,que depende del tipo de sus elementos básicos de un cierto dominio D.

• Sintaxis:

Sucesión nula: sucesion ‘<>’()

Sucesión de un solo elemento: ‘<*>’ (D b)

El significado del asterisco como nombre de una función es que se escribe sustituyendoel asterisco por el valor a que se aplica: <b>

Composición: sucesion ‘ο’ (sucesion a,b)

Page 61: programacion2

Programación II

64

Ultimo: D last (sucesion a)

Cabecera: sucesion leader (sucesion a)

Primero: D first (sucesion a)

Cola: sucesion trailer (sucesion a)

Longitud: int length (sucesion a)

• Semántica

El significado de estas operaciones lo daremos a partir de este conjunto de axiomas:

a) last (x ο <d>) = d

b) leader (x ο <d>) = x

c) x ο (y ο z) = (x ο y) ο z

d) first (<d> ο x) = d

e) trailer (<d> ο x) = x

f) length (<>) = 0

g) length (x ο <d>) = 1 + length (x)

h) <> ο x = x ο <> = x

i) Si x ≠ <>, y ≠ <>, first (x) = first(y), trailer(x) = trailer(y), entonces x = y

j) first (<>) = last (<>) = leader (<>) = trailer(<>) = ERROR

En esta especificación no se ha dicho nada acerca del dominio del tipo sucesión. Esto escaracterístico de las definiciones axiomáticas. Los objetos que pertenecen al dominio son los quese pueden obtener a través de las operaciones, cuyo resultado se determina a partir de losaxiomas. Así un elemento de este dominio tendría la forma:

<d1> ο<d2>ο....ο<dn>

que de forma abreviada escribiremos como

<d1,d2, . . ,dn>

Un tercer método de especificación semántica de un TDA es mediante el uso de modelosabstractos. Este método se basa en el hecho de que podemos describir el dominio de un tipo entérminos de otro tipo y, entonces, usar las operaciones del segundo tipo para describir lasoperaciones del que estamos definiendo. A estas definiciones comparativas se les llama tambiéndefiniciones que usan modelos abstractos ya que modelizan el dominio y las operaciones de un tipo,usando el dominio y las operaciones de algún tipo o tipos previamente definidos. Este tipo deespecificación se conoce también como especificación operacional. Por ejemplo, si queremos definirel tipo string de un lenguaje de programación. Un string no es otra cosa que una sucesión decaracteres con una longitud máxima, que en nuestro caso notaremos como N, por tanto es un casoparticular del tipo anteriormente especificado. Podemos definir el tipo string como:

a| a=<a1, . . , an>, ai es carácter, n ≤ N

A continuación se puede dar la sintaxis y la semántica de las operaciones correspondientes.Vamos a usar una sintaxis similar a la del C, y en la especificación semántica de cada operación seusará un lenguaje matemático (basado en este caso en el conocimiento de las sucesiones finitas), yprecondiciones y postcondiciones. La precondición es la condición previa para poder aplicar unaoperación y la postcondición especifica el resultado de la operación.

string concat (string a,b)

pre a=<a1,a2, .. ,an>, b=<b1,b2, .. ,bm> length(a)+length(b)=n+m ≤ N

Page 62: programacion2

Tipos de Datos Abstractos

65

post concat = <a1,a2, . . ,an,b1,b2, . . ,bm>

string substr (string a,int f,t)

pre a=<a1,a2, . . ,an> f ≥1,t ≤ length(a), f ≤ t

post substr = <af, . . ,at>

El uso de precondiciones y postcondiciones para la semántica de las operaciones es elsiguiente: bajo el cumplimiento de las precondiciones, se podrá ejecutar la operación y tras suejecución se debe cumplir la postcondición. No se afirma nada en el caso de que no se cumplan lasprecondiciones, puede dar un resultado indeterminado o error. Lo ideal es que diese erroradvirtiéndose de esta forma el mal uso de la operación. Vamos a utilizar el nombre de la operaciónpara denotar su resultado en la postcondición.

A lo largo del libro, vamos a hacer uso de modelos abstractos para la especificaciónsemántica de los TDA, explicando su significado a partir de modelos matemáticos conocidos. Sinembargo, y cuando no haya lugar a ambigüedades usaremos también el lenguaje natural.

4.2.2 Uso de los TDA en ProgramaciónHasta ahora hemos considerado los TDA como objetos matemáticos, pero no hemos

comentado cual es su uso en la programación. En primer lugar, la información, los datos con los quese puede trabajar en un lenguaje de programación están organizados por tipos. El diseñador dellenguaje en cuestión, por una parte, comunica estos tipos al usuario de forma abstracta: identificandolos objetos y sus operaciones; y por otra ha diseñado un compilador en el que para cada tipo sedetermina como hay que organizar, interpretar y operar con la información representada en lamemoria, de forma que se imite el comportamiento del tipo correspondiente. Con ello el programadorno tiene que preocuparse de cómo se representa un tipo particular de datos y sólo tiene que trabajarcon ellos en base a las especificaciones sintácticas y semánticas del diseñador.

En una segunda etapa, debemos realizar ante cualquier problema una tarea similar a la quehemos descrito para el constructor del compilador. El proceso sería el siguiente:

1. Determinar, ante un problema, los objetos candidatos a tipos de datos, estén o no esténen el lenguaje de partida.

2. Identificar las operaciones básicas, primitivas entre dichos objetos.

3. Especificar dichas operaciones

4. Construir un programa, que usando estos tipos y estas operaciones resuelva el problemaoriginal.

5. Implementar los tipos y operaciones que no estén en el lenguaje de partida, con loselementos de dicho lenguaje

6. Determinar la eficiencia de la implementación.

Con esta metodología se logra disminuir la complejidad inherente a la tarea de construirprogramas, separando dos problemas, que se resuelven de forma independiente:

Construir un programa a partir de unos objetos adecuados a las característicasparticulares del problema que se quiere resolver.

Implementar estos objetos en base a los elementos del lenguaje y razonar sobre ellos.

Page 63: programacion2

Programación II

66

Para que se obtenga un ahorro efectivo hay que procurar que se lleve a cabo de formaestricta esta separación: construir el programa, no haciendo ninguna referencia a la implementaciónusada para los objetos y diseñar una implementación para estos sin tener que conocer todos losdetalles del problema en el que se vaya a usar. Esta separación debe incluso permitirnos sin ningúnproblema poder especificar las operaciones y construir el programa original sin llegar aimplementarlas hasta el final. Esto nos permite posponer la decisión acerca de la implementación finalhasta una etapa posterior en el desarrollo de la solución de nuestro problema, en la cual puedaresultar más fácil realizar la elección de la estructura de datos más adecuada.

Esta separación en la construcción de los programas se conoce con el nombre deabstracción-realización. La abstracción consiste en la descripción de un determinado sistema,teniendo en cuenta solo las características importantes del mismo, y no considerando los detallesirrelevantes. La realización consiste en completar los detalles que antes se han dejado sin especificar.Para el caso que nos ocupa, la realización de un programa, en base a unos determinados TDA seríauna descripción abstracta del mismo. La implementación de los TDA correspondería a la tarea derealización.

La construcción de software siguiendo esta metodología nos va a permitir:

1. Construir la solución despreciando detalles de implementación. Esto nos permite obtenerla solución en menos tiempo y con un diseño cualitativamente superior que si resolvemosel problema mezclando la solución con detalles de implementación. Para obviar el tipo deimplementación que se ha usado necesitamos realizar ocultamiento de la información.

2. Aunque la forma de construcción de TDA se haya presentado con el objetivo de resolverun único problema, estos tipos se pueden construir de forma independiente, es decir enmódulos software independientes como pueden ser librerías adicionales con las quefinalmente enlazaremos los programas que hacen uso de estos nuevos tipos. De estamanera, al diseñar un TDA disponemos de un nuevo tipo de dato para posiblesproblemas futuros. Para garantizar la reusabilidad de los nuevos tipos las operacionesque definamos sobre ellos deben ser completas, es decir, que nos permitan poder realizarsobre ellos todas las operaciones que en el futuro puedan ser necesarias, pero sinexceder en el número de operaciones que necesitamos programar realmente. A vecesañadir una nueva primitiva puede ser redundante pues tal vez sea posible programarla enbase a las demás. Sin embargo, aunque en ciertas ocasiones una operación pueda serimplementada en base a otras operaciones básicas es posible que sea interesanteañadirla por cuestión de eficiencia, pues tal vez accediendo a la implementación de formadirecta el resultado acabe siendo más eficiente.

Los conceptos de ocultamiento de la información y reusabilidad del software son básicosdentro de la construcción de tipos de datos abstractos.

4.2.3 Ejemplo de un TDAPara ilustrar las ideas básicas desarrolladas a lo largo de esta sección vamos a desarrollar un

TDA, concretamente el TDA polinomio.

Una vez se ha identificado un tipo para la construcción del TDA correspondiente, tal y comose ha explicado en la sección anterior, debemos identificar las operaciones primitivas, paraposteriormente especificar su funcionamiento y realizar su implementación. En el caso que nosocupa, el TDA polinomio, podemos enumerar las siguientes operaciones primitivas:

1. CrearPolinomio: obtiene los recursos necesarios y devuelve el polinomio nulo.

2. Grado: devuelve el grado del polinomio.

3. Coeficiente: devuelve un coeficiente del polinomio.

Page 64: programacion2

Tipos de Datos Abstractos

67

4. AsigCoeficiente: asigna un coeficiente del polinomio

5. DestruirPolinomio: libera los recursos del tipo obtenido.

Estas operaciones son imprescindibles y forman un conjunto mínimo, y con ellas podemosllevar a cabo cualquier aplicación sobre el tipo polinomio sin necesidad de ninguna más, son puestambién un conjunto suficiente. Estos dos hechos nos permiten afirmar que son válidas comoconjunto de funciones primitivas de nuestro tipo.

Especificación del TDA polinomio

Comenzaremos definiendo el dominio del TDA polinomio como sigue:

Una instancia P del tipo de dato abstracto polinomio es un elemento del conjunto depolinomios de cualquier grado en una variable x con coeficiente reales. El polinomio P (x) = 0 esllamado polinomio nulo.

P (x) = a0 + a1 x1 + a2 x

2 + …+ anxn

Donde n es un número natural y ai es un número real.

Una vez definido el dominio del TDA polinomio, pasamos a especificar sus operaciones.Estas operaciones las podemos clasificar como sigue:

• De creación: CrearPolinomio

• De destrucción: DestruirPolinomio

• De acceso y actualización: Grado, Coeficiente, AsigCoeficiente.

Su especificación es la siguiente:

Polinomio CrearPolinomio (int MaxGrado)

pre MaxGrado ≥ 0.

post devuelve el polinomio nulo. El objetivo de esta función es reservar los recursosnecesarios, inicializar con el valor nulo y devolver un tipo polinomio.

int Grado (polinomio p)

pre p está inicializado

post devuelve el grado del polinomio indicado por p.

float Coeficiente (polinomio p, natural n)

pre p está inicializado. n ≤ MaxGrado.

post devuelve el coeficiente correspondiente al monomio de grado n del polinomio p.

void AsigCoeficiente (polinomio p, natural n, float c)

pre p está inicializado. n ≤ MaxGrado.

post asigna al monomio de grado n el coeficiente c

void DestruirPolinomio (polinomio p)

pre p está inicializado

post libera los recursos de p (para volver a utilizar p se debe volver a inicializar).

Los pasos a seguir para hacer uso de una instancia P de este tipo son:

1. Declaración del tipo. Declaramos a P de tipo polinomio (polinomio P).

Page 65: programacion2

Programación II

68

2. Creación de la estructura. Llamamos a la función de creación para crear la estructura dedatos que sustenta el tipo asignado el resultado a P.

3. Uso de las funciones de acceso y actualización sobre P.

4. Destrucción de los recursos usados por P.

Una vez especificado el TDA polinomio se puede construir cualquier aplicación sobre el tipopolinomio. Una posible aplicación sería evaluar un polinomio. La solución a este problema se detallaen la figura 4.2 en ella se aprecia dos aspectos importantes de los TDA:

1. Construir la solución despreciando detalles de implementación, de forma que el tipopolinomio puede representarse en la forma que se desee sin que los programas que loutilicen sufran cambios.

2. Inicialmente se ha diseñado el TDA polinomio independientemente del problema aresolver. De esta manera, disponemos de un nuevo tipo reusable en posibles problemasfuturos.

float EvaluarPolinomio (polinomio p, float valor)/*Función que dado un polinomio p lo evalúa para un valor x */int i,grado;float resultado,coeficiente;

grado=Grado(p);resultado=0.0;for (i=0;i<=grado;i++)

coeficiente= Coeficiente (p,i);resultado=resultado+coeficiente*(pow (valor,i));

return (resultado);

Figura 4.2: función para evaluar un polinomio

Implementación del TDA polinomio

Una vez especificado un TDA este puede ser utilizado como ya hemos visto para resolvercualquier problema. Ahora bien quedaría un tema por resolver, elegir una implementación para elTDA polinomio y realizarla. La implementación de un TDA conlleva elegir una estructura de datospara representar el TDA y diseñar en base a la estructura de datos elegida un procedimiento por cadaoperación del TDA.

Tenemos una amplia gama de posibilidades para la implementación del tipo polinomio, entreotras:

1. Un vector en el que se guarden los distintos coeficientes, de tal forma que el coeficiente i-ésimo se guardará en la posición i del vector.

Esta implementación tiene el inconveniente de que para determinar el grado el polinomioserá necesario recorrer ese vector desde el final hasta el inicio buscando el primercoeficiente distinto de cero, esta operación tendrá un O (n).

Page 66: programacion2

Tipos de Datos Abstractos

69

2. Un registro con dos campos: uno para guardar el grado del polinomio y otrocorrespondiente a un vector en el que se guardan los coeficientes con el mismo criterioque en la implementación anterior.

En este caso la implementación de la operación para determinar el grado será O (1).

3. Un registro con tres campos: los dos de la implementación anterior más un campo(pos_min) en el que se indica la posición del polinomio en la que existe el primercoeficiente, comenzando por el término independiente, distinto de cero. Así, elcoeficiente que aparece en la posición i del vector corresponde al coeficiente i+pos-min-ésimo del polinomio.

Tiene la ventaja de que polinomios del tipo x545 se representen sin desperdiciar el espaciopara guardar los primeros 544 coeficientes cero.

4. Un registro con dos campos: uno para guardar el grado y otro correspondiente a unpuntero del que cuelga una serie de celdas enlazadas una por cada coeficiente distinto decero que posea el polinomio, cada una contiene un par coeficiente-grado.

De esta forma no es necesario limitar a priori el número máximo de coeficientes queposee el polinomio y se utiliza sólo la memoria necesaria para guardar los coeficientesdistintos de cero. Como inconvenientes tenemos que operaciones como AsigCoeficiente,Coeficiente y DestruirPolinomio dejan de tener un orden de eficiencia constante.

Una vez definidas todas las posibles representaciones para el TDA polinomio, debemosdecantarnos por alguna de ellas. En esta decisión suelen prevalecer dos criterios:

a) Cuáles son las operaciones que se van a realizar con más frecuencia. Con el fin de elegiraquella representación en la que estas operaciones sean más eficientes

b) Conocer o no de antemano el tamaño aproximado de los objetos del TDA. Pues lasrepresentaciones basadas en estructuras de datos estáticas (como las representaciones1,2 y 3 del TDA polinomio definidas anteriormente) limitan el tamaño de los objetos delTDA. En aquellos casos en que no se conozca a priori el tamaño de los objetos del TDAcon los que se va a trabajar debemos rechazar este tipo de representaciones.

4.2.4 Consideraciones generales para la elección de primitivasEn el ejemplo anterior mostramos como se debe construir un tipo de datos abstracto, en este

caso el TDA polinomio. Para ello se ha elegido un conjunto de primitivas representativo aunque ésteno es único pues es posible optar por un conjunto distinto teniendo en cuenta los siguientes puntos:

1. El conjunto de primitivas debe ser suficiente pero no obligatoriamente mínimo. Aunque nosea necesario incluir nuevas primitivas, puede ser conveniente añadir nuevas funciones siexisten motivos:

a) La función va a ser probablemente muy usada.

b) La función va a ser usada con cierta asiduidad y su implementación haciendo uso delas demás funciones primitivas empeora considerablemente la eficiencia de laoperación.

2. Puede ser necesario rehacer el conjunto de primitivas atendiendo a razones referentes auna eficiente utilización de los recursos hardware. Este el caso de la funciónCrearPolinomio, la cual en ciertas implementaciones es altamente probable que seatransformada en una función de creación complementada con otra de destrucción.:

a) destruir: cuando un polinomio ya no va a ser utilizada, se usa esta primitiva paraliberar los recursos de memoria que mantienen la estructura.

Page 67: programacion2

Programación II

70

b) crear: antes de usar un nuevo polinomio, se utiliza esta primitiva para reservar yasociar la memoria necesaria para mantener la estructura del polinomio en memoria.

3. Las cabeceras de las funciones pueden necesitar ser modificadas para hacer viable suimplementación. Es el caso por ejemplo de una función que no pueda devolver un tipo dedato o que el tipo de dato sea muy complejo y pasarlo por valor o devolverlo como salidade una función pueda convertirse en algo ineficiente debido a su tamaño. Por tanto, enmuchos casos, será aconsejable no pasar estructuras directamente sino un puntero aellas, que una función no devuelva un valor sino que lo devuelva mediante losparámetros a través del paso por referencia, etc.

4. Un tipo de dato abstracto es un producto software y como tal es algo dinámico y estásujeto a mantenimiento. Por tanto, el conjunto de primitivas de un TDA es algo extensible.En este sentido, el conjunto de funciones que incorporamos a un TDA no debe serdiseñado considerando que debemos de añadir todas y cada una de las primitivas quecreemos que se necesitarán, es más conveniente retrasar la incorporación de ciertasprimitivas cuya necesidad sea dudosa. Pues desde el punto de vista del mantenimientodel software, es mucho menos costoso la adición de nuevas primitivas que la supresiónde algunas ya existentes.

4.3 Tipo de datos, es tructura de datos y tipo de datosabstracto.Aunque los términos “tipo de datos” (o simplemente “tipo”), “estructura de datos” y “tipo de

datos abstracto” parecen semejantes, su significado es diferente. En un lenguaje de programación elconcepto de tipo es de central importancia, el tipo de datos de una variable es el conjunto de valoresque ésta puede tomar, de forma que cada variable, constante o expresión tiene un único tipoasociado. En algunos lenguajes como C, la asociación de un tipo con una variable se realiza en sudeclaración, en otros lenguajes, como el Fortran, ésta se realiza a través de la primera letra, delidentificador asociado a dicha variable. En la implementación del lenguaje, la información referente altipo, determina la forma en que las operaciones aritméticas tienen que interpretarse; y capacita alcompilador a detectar errores en aquellos programas que no las utilicen de forma adecuada. Lascaracterísticas del concepto de tipo pueden resumirse en los siguientes puntos:

1. Un tipo determina la clase de valores que pueden tomar las variables y expresiones.

2. Todo valor pertenece a uno y sólo un tipo.

3. El tipo de un valor denotado por una constante, variable o expresión puede deducirse desu forma o contexto, sin ningún conocimiento de su valor calculado en el momento deejecución.

4. Cada operador está definido para operandos de varios tipos, y calcula el resultadoobteniendo un tipo que esta determinado por el de éstos (usualmente el mismo sí soniguales). Cuando el mismo símbolo se aplica a diferentes tipos (la división,/, para losenteros y los reales), este símbolo puede considerarse como ambiguo, denotandodiferentes operadores según el tipo de operandos a que se aplique. La resolución de laambigüedad puede realizarse siempre en el momento de compilación.

5. Las propiedades de los valores de un tipo y de sus operaciones primitivas se especificanformalmente.

6. La información del tipo en un lenguaje de programación se usa, por una parte paraprevenir errores, y por otra, para determinar el método de representar y manipular losdatos en un ordenador.

Page 68: programacion2

Tipos de Datos Abstractos

71

Un lenguaje de programación proporciona sus propios tipos de datos básicos, pero en lamayoría de los lenguajes suelen aparecer como tipos básicos los proporcionados por el ordenador:enteros, reales, carácter y lógicos. A partir de estos tipos básicos se pueden generar nuevos tipos dedatos, aplicando mecanismos de estructuración del lenguaje de programación. A estos nuevos tiposse les denomina tipos de datos compuestos o estructurados (estructuras de datos), y estos puedenservir de base para diseñar estructuras de datos más complejas, y de esta forma construirverdaderas jerarquías de estructuras, pero los componentes últimos deben ser de tipo básico.

Los mecanismos de estructuración para construir tipos de datos compuestos a partir de losbásicos varían de un lenguaje a otro, pero en la mayor parte de los lenguajes se suelen encontrar lossiguientes métodos de estructuración estáticos: array, cadena de caracteres y registro. Estasestructuras de datos se caracterizan por conocer su tamaño en tiempo de compilación y no variarlodurante la ejecución del programa. Pero hay ocasiones en que esto no es lo más conveniente y esdeseable aprovechar mejor la memoria solicitándola conforme sea necesario y liberándola cuando yano haga falta, sin necesidad de reservar una cantidad fija e invariable. Las estructuras diseñadas deeste modo reciben el nombre de estructuras de datos dinámicas. Es imprescindible para generar estetipo de estructuras disponer de un método para adquirir posiciones adicionales de memoria a medidaque se necesiten durante la ejecución del programa y liberarlas posteriormente cuando ya no seannecesarias, este método se conoce como asignación dinámica de memoria y tiene como elementobase al tipo de dato puntero. En el primer curso de programación se ha definido este tipo de datopuntero y sus operaciones.

Un tipo de datos abstracto es un modelo matemático, junto con varias operaciones definidassobre ese modelo. El diseño de un TDA, tal y como ya se ha comentado, conlleva dos fases: una deespecificación y otra de realización. La fase de realización consistirá en representar los TDA enfunción de los tipos de datos y los operadores manejados por ese lenguaje. Para representar elmodelo matemático básico de un TDA, se emplearán estructuras de datos. Un TDA puedeimplementarse bajo diferentes estructuras de datos, a la hora de elegir una estructura frente a otrasse deben tener en cuenta los siguientes principios:

1. La eficiencia en tiempo de las operaciones que se utilizarán con mayor frecuencia.

2. Las posibles restricciones de espacio en memoria derivadas del uso de la estructura. Enel caso de estructuras estáticas se asigna un tamaño fijo para almacenar los objetos delTDA, con ello se limita el tamaño máximo de los mismos y en muchas ocasiones sedesaprovecha espacio en memoria. En aquellos casos en que no se conozca deantemano el tamaño aproximado de los objetos del TDA con los que se va a trabajar, noes conveniente una estructura de datos estática.

En definitiva, no se deben confundir los conceptos de TDA y estructura de datos. A través deuna estructura de datos y sus operaciones (métodos de acceso, forma de procesamiento, …)podemos definir un tipo de dato abstracto. Lógicamente, en base a la experiencia acumulada de losprogramadores, podemos considerar un conjunto de estructuras de datos que son ampliamenteusadas junto con sus operaciones más frecuentes, es el caso de: listas, árboles binarios, árbolesbinarios de búsqueda (ABB), árboles equilibrados (AVL), árboles binarios parcialmente ordenados(APO), tablas hash, grafos. Algunas de estas estructuras de datos (listas y árboles) serán estudiadasen este libro y presentadas como tipos de datos abstractos. De esta manera cuando en un programanecesitemos manejar objetos estructurados según alguna de las estructuras de datos de quedisponemos, no tendremos más que hacer uso de ellas añadiendo el módulo correspondiente anuestro programa y usando la estructura en base a las especificaciones como tipo de dato abstractosin necesidad de considerar detalles de implementación. Obviamente también será posible quenuevos tipos de datos abstractos sean creados con estructuras de datos específicas, que no esténpresentadas como tipo de dato abstracto, es en estos caso en los que realmente los tipos de datosabstractos para nuestra aplicación serán construidos sobre los tipos ofrecidos por el lenguaje deprogramación.

Page 69: programacion2

Programación II

72

4.4 Tipos de datos a bstractos linealesEn esta sección se estudiarán algunos tipos de datos abstractos lineales. Se consideran las

listas, que son secuencias de elementos, y dos tipos especiales de listas: las pilas donde loselementos se insertan y eliminan solo en un extremo, y las colas donde los elementos se insertan porun extremo y se eliminan por el otro. Para cada uno de estos TDA, se presentarán y analizarán variasrealizaciones.

4.4.1 El tipo de datos abstracto “Lista”Las listas constituyen una estructura flexible en particular, porque pueden crecer y acortarse

según se requiera, los elementos son accesibles y se pueden insertar y suprimir en cualquier posiciónde la lista. Las listas también pueden concatenarse entre sí o dividirse en sublistas. Suelen serbastante utilizadas en gran variedad de aplicaciones; por ejemplo en recuperación de información,traducción de lenguajes de programación y simulación, …

Matemáticamente, una lista es una sucesión de cero o más elementos de un tipo determinado( que por lo general se denominará tipo_elemento). Una lista se suele representar de la forma:

<a1,a2,…, an>

donde n ≥ 0 y cada ai es del tipo tipo_elemento. A n se le llama longitud de la lista. Al suponer que n≥ 1, se dice que a1 es el primer elemento y an el último elemento. Si n=0, se tiene una lista vacía.

Una propiedad importante de una lista es que sus elementos pueden estar ordenados en forma linealde acuerdo con sus posiciones en la misma. Se dice que ai precede a ai+1 para i=1,2,…,n-1, y que ai

sucede a ai-1 para i =2,3,…,n. Se dice que elemento ai ocupa la posición i. Si la lista tiene nelementos, no existe ningún elemento que ocupe la posición n+1. Sin embargo, conviene consideraresta posición, a la que se llama posición que sucede al último elemento de la lista, ya que estaposición indicará el final de la lista. Obsérvese que esta posición, con respecto al principio de la lista,está a una distancia que varía conforme la lista crece o se reduce, mientras que las demás posicionesguardan una distancia fija con respecto al principio de la lista.

Para formar un tipo de datos abstracto a partir de la noción matemática de lista, se debedefinir un conjunto de operaciones. Como sucede con muchos de los TDA, ningún conjunto deoperaciones es adecuado para todas las aplicaciones. Aquí se presentará un conjunto representativode operaciones.

Vamos a notar al conjunto de las listas (es decir, al tipo lista) como TLista, al conjunto de loselementos básicos como TElemento, y al tipo posición como TPosicion. Se define el tipo posición yaque no siempre las posiciones las vamos a representar por enteros, y su implementación cambiarácon aquella que se haya elegido para las listas.

Especificación de las operaciones primitivas del TDA Lista

Las operaciones primitivas propuestas para el tipo de dato abstracto lista son las siguientes:

void anula (TLista * L)

post (*L) = <>

Tposicion primero (TLista L)

pre L está inicializada.

post primero = 1

Si L está vacía, la posición que se devuelve es fin (L)

Page 70: programacion2

Tipos de Datos Abstractos

73

Tposicion fin (TLista L)

pre L está inicializada.

post fin = n+1

posición detrás de la última

void insertar (TElemento x,Tposicion p,TLista L)

pre L = <a1,a2,…,an>

1 ≤ p ≤ n+1

post L = <a1,…,ap-1,x,ap,…,an>

Si (p= fin (L)) entonces

L = <a1,a2,…,an,x>

resulta una lista de longitud n+1, en la que x ocupa la posición p. Si la lista L no tieneposición p, el resultado es indefinido

void borrar (Tposicion p,TLista L)

pre L = <a1,a2,…,an>

1 ≤ p ≤ n

post L = <a1,…,ap-1,ap+1,…,an>

se elimina el elemento que ocupaba la posición p. Ahora la posición p la ocupa el elementoque se encontraba en la posición p+1. El resultado no está definido si L no tiene posición p osi p = fin (L)

TElemento elemento (Tposicion p,TLista L)

pre L = <a1,a2,…,an>

1 ≤ p ≤ n

post elemento = ap

devuelve el elemento que está en la posición p de la lista L. El resultado no está definido sip= fin (L) o si L no tiene posición p

TPosicion siguiente (Tposicion p,TLista L)

pre L = <a1,a2,…,an>

1 ≤ p ≤ n

post siguiente = p+1

devuelve la posición siguiente a p en la lista L. Si p es la última posición de L, siguiente (p,L)= fin (L). El resultado no está definido si p = fin (L), o si la lista L no tiene posición p

Tposicion anterior (Tposicion p,TLista L)

pre L = <a1,a2,…,an>

2 ≤ p ≤ n+1

Page 71: programacion2

Programación II

74

post anterior = p-1

devuelve la posición anterior a p en la lista L. El resultado no está definido si p =1 , o si lalista L no tiene posición p

Tposicion posicion (TElemento x,TLista L)

pre L está inicializada

post Si ∃ j ∈ 1,2,…,n, tal que aj = x, entonces

posicion = i, donde i verifica que:

1. ai = x

2. Si aj = x, entonces j ≥ i.

Si no existe j ∈ 1,2,…,n, tal que aj = x, entonces posicion = n+1

devuelve la posición de la primera aparición de x en la lista L. Si x no figura en la listaentonces se devuelve fin (L)

El conjunto de primitivas presentado es un ejemplo representativo de las primitivas másimportantes que nos permite mostrar la forma en que se debe construir el tipo de datos abstractoLista. Para ilustrar el uso de las operaciones del tipo lista basándonos en la especificación realizada,consideraremos una aplicación típica: eliminar todos los elementos repetidos de una lista. Loselementos de la lista son de tipo TElemento y existe una función lógica, igual (x,y), que nos dicecuando son iguales dos elementos de este tipo. No es suficiente con considerar la igualdad del C(==), pues es posible que no coincida con la igualdad de TElemento. Por ejemplo, en el caso de queTElemento sea un registro.

Con estas consideraciones, el procedimiento para eliminar las repeticiones de una lista sería:void elimina (TLista L) TPosicion p,q; for (p =primero (L);p!=fin (L);p=siguiente (p,L)) q=siguiente(p,L); while (q!=fin (L)) if (igual (elemento (p,L),elemento (q,L))) borrar (q,L); else q=siguiente(q,L); Las variables p y q se usan para representar dos posiciones en la lista. Dado el elemento de

la posición p se eliminan todos los elementos iguales a él que se encuentren a la izquierda de suposición, usaremos la posición q para recorrer los elementos a la izquierda de p. Cuando se elimineel elemento de la posición q los elementos que estaban en las posiciones siguientes (q+1,q+2, …)retroceden una posición en la lista, en estos casos no será necesario pasar a la posición siguiente deq para continuar buscando elementos repetidos, nos quedaremos en la misma posición q.

La función elimina se ha diseñado de forma independiente a la representación que tengan laslistas. Pero para que esta función sea ejecutable debemos representar el TDA Lista bajo algunaestructura de datos.

Page 72: programacion2

Tipos de Datos Abstractos

75

Implementación de las listas

Implementación de las listas mediante vectores

En la realización de una lista mediante un array, los elementos de ésta se almacenan enceldas contiguas de un vector. Como las listas tienen longitud variable y los vectores longitud fija,debemos tomar vectores de tamaño igual a la longitud máxima de la lista y utilizar un entero que nosindique la posición del último elemento de la lista. Como consecuencia definiremos el tipo Lista comoun puntero a un registro con dos campos; el primero un vector que tiene la longitud adecuada paracontener la lista de mayor tamaño que se pueda presentar. El segundo campo es un entero queindica la posición del último elemento de la lista en el vector. El i-ésimo elemento de la lista está en la(i-1)-ésima celda del vector. Las posiciones en la lista se representan mediante enteros; la i_ésimaposición, mediante el entero i-1. Tal y como se ilustra en la figura 4.3. Para esta realización basadaen vectores, el tipo abstracto Lista ser define como sigue:

#define LMAX 100typedef int TElementotypedef struct

TElemento elementos [LMAX];int n;Lista;

typedef Lista *TLista;typedef int TPosicion;

0 Primer Elemento

1 Segundo Elemento . . . . . .

.n

Último Elemento

tamMax-1

Elementos

Figura 4.3: implementación de una lista mediante un vector

La implementación de la mayoría de las operaciones es inmediata. Las operaciones mássimples son:

void anula (TLista * L)

(*L)=(TLista) malloc (sizeof (Lista));if ((*L)==NULL)

printf (“error de memoria”);

.

.

.

.

Page 73: programacion2

Programación II

76

exit (1);(*L)->n =-1;

TPosicion primero (TLista L)

return (0);TPosicion fin (TLista L)

return (L->n+1);TPosicion siguiente (TPosicion p,TLista L)

if ((p<0) || (p > L->n))printf (“La posición no está en la lista”);exit (1);

return (p+1);

Tposicion anterior (TPosicion p, TLista L)if ((p<=0) || (p > L->n+1))

printf (“La posición no está en la lista”);exit (1);

return (p-1);

TElemento elemento (Tposicion p,TLista L)

if ((p < 0) || (p > L->n))printf (“La posición no está en la lista”);exit (1);

return (L->elementos[p]);

Las únicas operaciones que pueden presentar un poco de dificultad son las de insertar, borrar

y posicion. La función posicion tiene que realizar una búsqueda secuencial en un vector. En el casode que el elemento no esté en el vector la función devolverá fin (L).

TPosicion posicion (TElemento x,TLista L)

TPosicion q;int encontrado;q=encontrado=0;while ((q <= L->n)&&(!encontrado))

if (l->elementos[q]==x)encontrado=1;

else q=q+1;return (q);

Page 74: programacion2

Tipos de Datos Abstractos

77

Para insertar un elemento en la posición p de la lista (excepto para p= fin (L)), se debedesplazar una posición dentro del array a todos los elementos que siguen al elemento de la posiciónp, con el fin de hacer previamente un hueco donde realizar la inserción.

void insertar (TElemento x,TPosicion p,TLista L)

TPosicion q;if ((p > L->n) || (p<0))

printf (“La posición no está en la lista”);exit (1);

else if (L->n >= LMAX-1)

printf (“La lista está llena”);exit (1);else

for (q=L->n;q>=p;q—)/*Desplaza los elementos en p,p+1, … una posición hacia abajo*/ L->elementos[q+1] = L->elementos[q];L->n++;L->elementos[p]=x;

De la misma forma la eliminación de un elemento, excepto en el caso del último, requiere

desplazamientos de elementos para llenar el vacío formado.void borrar (TPosicion p,TLista L)

if ((p > L->n) || (p<0))printf (“La posición no está en la lista”);exit (1);

else

L->n--;for (q=p;q<=L->n;q++)/*Desplaza los elementos en p+1,p+2,… una posición hacia arriba*/

L->elementos[q]=L->elementos[q+1];

Estas tres últimas operaciones tienen una eficiencia del orden del tamaño de la lista, pues en

el peor de los casos tendrían que recorrer la lista por completo para realizar la operación en cuestión.

Otro inconveniente de esta implementación es que las listas tienen un tamaño máximo delque no se puede pasar en contra de la especificación en la que no se le impone ningún límite altamaño de las listas. Además, siempre hay una cantidad de espacio reservada para los elementos dela lista desperdiciada al ser el tamaño de la lista, en un momento dado, menor que el tamaño máximo.Este problema se acentúa si las distintas listas que se representan son de un tamaño muy dispar, eneste caso no es conveniente el uso de esta representación.

Otro detalle importante en esta implementación es, cómo hemos mencionado anteriormente,la necesidad de una función de destrucción ya que ahora mismo la memoria que se requiere cada vezque se hace una llamada a la función anula no es recuperada en ningún momento. Podemos paliaralgunos de los problemas que presenta la implementación considerando las posibilidades que nosbrindan el lenguaje C, la versión optimizada sería:

Page 75: programacion2

Programación II

78

typedef int TElemento;typedef struct

TElemento *elementos;int Lmax;int n;Lista;

typedef Lista *TLista;typedef int TPosicion;

TLista crear (int tanMAx)

TLista L;L =(TLista) malloc (sizeof (Lista));if (L==NULL)

printf (“error: no hay memoria”);exit (1);

L->Lmax=tamMax;L->n=-1;L->elementos= (TElemento ) malloc (tamMax*sizeof (TElemento));if (L->elementos==NULL)

printf (“error: no hay memoria”);exit (1);

void destruir (TLista L)

free (L->elementos);free(L);

Las demás primitivas quedarían de la misma forma sustituyendo LMAX por L->Lmax. En esta

nueva implementación conseguimos resolver con éxito dos cosas:

1. Tamaños variables. Ahora la primitiva anula ha sido sustituida por la primitiva crear a laque se pasa un parámetro indicando el tamaño máximo que tendrá la lista. Se mejora conrespecto a la versión anterior en la que el tamaño máximo tenía que ser superior a la másgrande de las listas que se manejan y por consiguiente para pequeñas listas habría unagran cantidad de memoria desperdiciada.

2. Creación y destrucción. En esta versión se ofrece el constructor y el destructor del tipo dedato permitiendo de esta forma recuperar los recursos ocupados por las listas que no sevolverán a usar. El uso de la primitiva crear sobre una lista ya creada provocará unapérdida de los recursos de memoria ocupados por esta lista y la actualización de su valora la lista vacía. Después de la destrucción de una lista, se podrá usar de nuevo la mismavariable en la creación, uso y destrucción de una nueva lista.

Una vez implementado el TDA Lista la función elimina será ejecutable y el uso del TDA Listapara la última versión debe ser:

1. Declaración de la variable de tipo TLista.

2. Creación de la lista mediante la primitiva crear.

3. Uso de la lista mediante primitivas distintas a la de creación y destrucción.

Page 76: programacion2

Tipos de Datos Abstractos

79

4. Destrucción de la lista mediante la primitiva destruir.

Al principio puede parecer tedioso escribir procedimientos que rijan todos los accesos a lasestructuras subyacentes de un programa. Sin embargo, si se logra establecer la disciplina de escribirprogramas en función de operaciones de manipulación de tipos de datos abstractos en lugar de usarciertos detalles de implementación particulares, es posible modificar las estructuras de datos de losprogramas más fácilmente con sólo aplicar de nuevo las operaciones, en lugar de buscar en todos losprogramas aquellos lugares donde se hacen accesos a las estructuras de datos subyacentes. Laflexibilidad que se obtiene con esto, puede ser especialmente importante en proyectos grandes,aunque no sea tan evidente en los ejemplos pequeños que necesariamente se encuentran en estelibro.

Implementación de listas mediante celdas enlazadas por punteros

En esta implementación se utilizan punteros para enlazar elementos consecutivos, con ello seevita el empleo de memoria contigua para almacenar una lista y, por tanto, también elude losdesplazamientos de elementos para hacer inserciones y eliminaciones. No obstante, hay que pagar elprecio de un espacio adicional para los punteros.

En esta representación, una lista está formada por celdas; cada elemento ai de una lista<a1,a2,…,an>, se representa por una celda dividida en dos partes: un primer campo que contiene alelemento ai de la lista y un segundo campo donde se almacena un puntero a la celda que contiene alsiguiente elemento ai+1. La celda que contiene a an posee un puntero a NULL (puntero nulo), paraindicar el final de la lista. Así, la lista quedaría como se muestra en la figura 4.4 (a).

Para realizar más fácilmente las operaciones es conveniente considerar una celda inicial,llamada cabecera donde no se almacena ningún elemento de la lista. De esta forma la listapropiamente dicha vendrá representada por un puntero que indique la dirección de la cabecera y quepermite obtener los distintos elementos de la misma como se muestra finalmente en la figura 4.4 (b).En el caso de una lista vacía, el puntero cabecera será NULL.

(a)

(b)

Figura 4.4: representación de listas mediante celdas enlazadas

Para estas listas es conveniente usar una definición de posición distinta de la que se empleópara las listas representadas por vectores. Aquí, la posición i será un puntero pero no a la celda quecontiene al elemento ai , sino a la celda donde está el elemento anterior ai. Con esto se puedeacceder al elemento y también se facilita el diseño de las operaciones de inserción y borrado. Laposición del primer elemento será un puntero a la celda cabecera, idéntico a la lista L, y la posiciónfin (L) es un puntero a la última celda de L.

En C, la definición del tipo lista correspondiente a la implementación por punteros sería:typedef struct celda

Cabecera a1 an

L

a1 a2 an

Page 77: programacion2

Programación II

80

TElemento elemento;struct celda * sig;celda;

typedef celda *TPosicion;typedef celda *TLista;Como se puede observar el tipo lista es el mismo que el de posición, un puntero a una celda.

Las operaciones se detallan a continuación, entre ellas destacar que se han diseñado funciones decreación y destrucción para evitar que la memoria utilizada por una lista quede sin recuperar cuandoesta ya no es útil:

TLista crear ()

TLista L;L=(TLista) malloc (sizeof (celda));if (L==NULL)

printf (“error: no hay memoria”);exit (1);

L->sig =NULL;return (L);

void destruir (TLista L)

TPosicion p;for (p=L;L!=NULL;p=L)

L=L->sig;free(p);

TPosicion fin (TLista L)

TPosicion p;p=L;while (p->sig!=NULL)

p=p->sig;return (p);

Sobre esta última función es importante señalar su ineficiencia pues se requiere recorrer toda

la lista para devolver el puntero a la última celda de la lista. Su eficiencia es pues del orden de lalongitud de la lista. Si en las aplicaciones que utilizan el tipo lista se va a utilizar con frecuencia estaoperación puede optarse por cualquiera de las opciones siguientes:

1. Sustituir el uso de fin (L) donde sea posible. Por ejemplo, en un ciclo while con unacondición del tipo p !=fin (L) se debería sustituir por:

q= fin(L);while (p!=q) ....

donde q es de tipo posicion y fin (L) no se ve afecta de ninguna forma en el interior delciclo.

2. Considerar listas con un puntero a la cabecera y otro a la posición fin (L), aunque estocomplicaría ligeramente algunas operaciones, pero se ganaría mucha eficiencia en estafunción.

Page 78: programacion2

Tipos de Datos Abstractos

81

void insertar (TElemento x,TPosicion p,TLista L)

TPosicion q;q =(TPosicion) malloc (sizeof (celda));if (q==NULL)

printf (“error: no hay memoria”);exit (1);

q ->elemento=x;q ->sig=p->sig;p->sig=q;

En la figura 4.5 se muestra la mecánica del manejo de punteros en la función inserta. Sobre

esta función cabe señalar varias cosas:

a) Tarda siempre un tiempo constante frente a la implementación vectorial en que setardaba un tiempo proporcional a la longitud de la lista.

b) No se comprueba la precondición pues se perdería mucho tiempo en la comprobación, ydejaría de ser tan eficiente la operación. Es responsabilidad del programador utilizarlasiempre con posiciones de esta lista. Si no se hace así, se puede dar lugar a graveserrores.

c) Gracias al uso de la celda cabecera no es necesario distinguir en que posición se realizala inserción. El procedimiento funciona bien en los casos extremos de la primera posicióny fin (L) posición. En listas sin cabecera estos casos habrían sido necesariosconsiderarlos aparte, complicando la realización de la operación.

( a ) Situación inicial

p

( b ) Asignaciones de q

p

q

( c ) Asignación de p

p

q

Figura 4.5: inserción en una lista enlazadaTPosicion siguiente (TPosicion p,TLista L)

ai ai+1

x

ai ai+1

x

ai ai+1

Page 79: programacion2

Programación II

82

if (p->sig==NULL)

printf (“no hay siguiente del fin de la lista”);exit (1);

return (p->sig);

TPosicion primero (TLista L)

return (L);TPosicion posicion (TElemento x,TLista L)

TPosicion p;int encontrado;

p=primero (L);encontrado=0;while ((p->sig!=NULL)&&(!encontrado))

if (p->sig->elemento==x)encontrado=1;

else p=p->sig;return (p);

Respecto a esta función es conveniente destacar:

a) La función cumple la postcondición en los dos casos posibles: cuando éste y no éste elelemento buscado en la lista.

b) La complejidad es la misma que para la implementación mediante vectores.

c) En la condición del bucle aparece la comparación (p->sig!=NULL). Esta es equivalente a(p!=fin (L)), pero no se utiliza esta última pues aumentaría mucho la complejidad debido ala ineficiencia ya comentada de la función fin (L). Se podría pensar sustituir en cualquierprograma esta última condición por la que hemos usado aquí, pero no se debe de hacerpues iría en contra de todo lo comentado sobre la construcción de programas haciendouso de TDAs. En la función si se ha podido realizar el cambio porque se trata de unaoperación primitiva.

TElemento elemento (TPosicion p,TLista L)

if (p->sig==NULL)printf (“error posición fin de la lista”);exit (1);

return (p->sig->elemento);

void borrar (TPosicion p,TLista L)

TPosicion q;if (p->sig==NULL)

printf (“error posición fin de la lista”);exit (1);

Page 80: programacion2

Tipos de Datos Abstractos

83

q=p->sig;p->sig=q->sig;free(q);

Esta operación es más sencilla que la de inserción. En la figura 4.6 se muestra las

manipulaciones de punteros que se realizan en esta operación. Los punteros antiguos se representanpor medio de líneas de trazo continuo, y los nuevos por medio de líneas punteadas.

p

Figura 4.6: borrado en una lista enlazada

Con esta representación de listas basadas en punteros se consigue solucionar algunos de losproblemas que tenía la representación mediante vectores. Por un lado, ya no se acota el tamañomáximo de las listas y por otro, las operaciones de inserción y borrado que en la anteriorrepresentación tenían un O(n), donde n es la longitud de la lista, en esta representación requierenúnicamente un tiempo constante. Sin embargo, la representación basada en punteros requiere unespacio adicional para el puntero de cada celda, esto significa que si se conocemos el tamañoaproximado de las listas que se van manejar podría ser más conveniente representar el tipo listamediante vectores, con el consiguiente ahorro de espacio en memoria.

Comparación de los métodos

Cabe preguntar si en determinadas circunstancias es mejor usar una implementación de listasbasadas en celdas enlazadas o en vectores. A menudo, la respuesta depende de las operacionesque se deseen realizar, o de las que se realizan más frecuentemente. Otras veces la decisión es enbase a la longitud de la lista. Los puntos principales a considerar son los siguientes:

1. La implementación mediante vectores requiere especificar el tamaño máximo de una listaen el momento de la compilación. Si no es posible acotar la longitud de la lista,posiblemente deberíamos escoger una representación basada en punteros. Esteproblema ha sido parcialmente solucionado con la parametrización del tamaño máximode la lista, pero aún así hay que delimitar el tamaño máximo para cada una de las listas.

2. Ciertas operaciones son más lentas en una realización que en otra. Por ejemplo, insertary borrar realizan un número constante de pasos para una lista enlazada, pero necesitanun tiempo proporcional al número de elementos de la lista para la representación basadaen vectores. Inversamente, la ejecución de anterior y fin requiere un tiempo constantecon la representación mediante vectores, pero un tiempo proporcional a la longitud de lalista si usamos la implementación por punteros simplemente-enlazadas (aunquerecordemos que el problema es solucionable añadiendo un puntero).

Por otro lado, en las listas doblemente-enlazadas se requiere tiempo constante paratodas las operaciones (excepto la de posición que requiere un tiempo proporcional a lalongitud de la lista).

ai ai+1 ai+2

Page 81: programacion2

Programación II

84

3. La realización con vectores puede malgastar espacio, puesto que usa la cantidadmáxima de espacio independientemente del número de elementos presentes en la listaen un momento dado. La implementación por punteros utiliza sólo el espacio necesariopara los elementos que actualmente tienen la lista, pero necesita espacio adicional paralos punteros de cada celda. Por último, las listas doblemente-enlazadas aunque son lasmás eficientes requieren de dos punteros para cada elemento.

4. En las listas enlazadas la posición de un elemento se determina con un puntero a lacelda del elemento anterior por lo que hay que tener cuidado con la operación de borradosi se trabaja con varias posiciones consecutivas. En el caso de la implementaciónmediante vectores, si borramos un elemento, todas las posiciones posteriores a eseelemento apuntarán al siguiente al que apuntaban y si existe alguna posición apuntandoal final de la lista, ésta queda invalida.

Listas Doblemente-Enlazadas

En algunas aplicaciones puede ser deseable recorrer eficientemente una lista, tanto haciadelante como hacia atrás. O, dado un elemento, podría desearse determinar con rapidez el siguientey el anterior. En tales situaciones podríamos desear añadir a cada celda de una lista un puntero a laanterior celda tal y como se muestra en la figura 4.7.

Otra ventaja importante de las listas doblemente enlazadas es que permiten usar un punteroa la celda que contiene el i-ésimo elemento de una lista para representar la posición i, en vez de usarel puntero a la celda anterior, que es menos natural. El único precio que se paga por estascaracterísticas es la presencia de un puntero adicional en cada celda y consecuentementeprocedimientos algo más lentos para alguna de las operaciones básicas de las listas. La declaraciónde las celdas para una lista doblemente enlazada sería:

typedef struct celdaTElemento elemento;struct celda sig,ant; celda;

typedef celda * TPosicion;

Figura 4.7: borrado en una lista doblemente-enlazada

Un procedimiento para borrar un elemento en la posición p de una lista doblemente-enlazadaes:

void borrar (TPosicion p)

if (p->ant!=NULL)p->ant->sig=p->sig;

if (p->sig!=NULL)p->sig->ant=p->ant;

free(p);

Page 82: programacion2

Tipos de Datos Abstractos

85

En la figura 4.7 se expresa de forma gráfica los cambios sufridos por los punteros en esteprocedimiento. Los punteros antiguos se representan con líneas de traza continua, y los nuevos conlíneas punteadas. Para evitar las verificaciones sobre si la celda a suprimir es la primera o la última,es práctica común hacer que la primera celda de la lista doblemente_enlazada sea una celda queefectivamente cierre el círculo, es decir, que el campo ant de esta celda apunte a la última celda y elcampo sig de la última celda apunte a la primera.

4.4.2 PilasUna pila es un tipo especial de lista en la que todas las inserciones y borrados tienen lugar en

un extremo denominado tope. A las pilas se les llama también “listas LIFO” (last in first out) o listas“último en entrar, primero en salir”. El modelo intuitivo de una pila es precisamente un mazo de cartaspuesto sobre una mesa, o de platos en una estantería, situaciones todas en las que sólo esconveniente quitar o agregar un objeto del extremo superior de la pila, al que se denominará en losucesivo tope.

Especificación de las operaciones primitivas del TDA Pila

Vamos a notar al tipo pila como TPila, al conjunto de los elementos básicos como TElemento. Un tipo de dato abstracto pila suele incluir a menudo las siguientes operaciones:

void anula (TPila *P)

post (*P)=<>

Esta operación es la misma que la de las listas generales

int vacia (TPila P)

pre P está inicializada

post Si (P = <>) entonces vacia = true

sino vacia = false

TElemento tope (TPila P)

pre no vacia (P)

post tope = a1

Devuelve el elemento en la cabeza de la pila P. Esta operación puede escribirse en términosde las operaciones de listas como elemento(primero(P),P) , pues la cabeza de una pila seidentifica con la posición 1

void push (TElemento x, TPila P)

pre P=<a1,a2,…,an>

post P = <x,a1,a2,…,an>

inserta el elemento x en el tope de la pila P. En términos de primitivas de listas estaoperación es inserta(x,primero(P),P)

void pop (TPila P)

pre no vacia (P)

P=<a1,a2,…,an>

post P = <a2,…,an>

Page 83: programacion2

Programación II

86

Borra el elemento del topo de la pila P. En términos de primitivas de listas Borra (primero(P),P). Algunas veces es conveniente implementar pop como una función que devuelve elelemento que acaba de borrar

Al igual que se hizo con la operación anula para las listas, en la implementación del tipo pilaesta función también será transformada en una función de creación que se verá completada con otranueva de destrucción.

Implementación de las pilas

Todas las implementaciones de listas que hemos descrito son válidas para las pilas ya queuna pila junto con sus operaciones es un caso especial de una lista con sus operaciones. Aún asíconviene destacar que las operaciones de las pilas son más específicas y que por lo tanto laimplementación puede ser mejorada especialmente en el caso de la implementación basada envectores.

Implementación de pilas basadas en vectores

La implementación basada en vectores para las listas, no es particularmente buena para laspilas, porque cada push o pop requiere mover la lista entera hacia arriba o hacia abajo y por tanto,requiere un tiempo proporcional al número de elementos en la pila. Un mejor criterio para usar unarray es tener en cuenta que las inserciones y las supresiones ocurren sólo en la parte superior. Sepuede anclar la base de la pila a la base del array (el extremo de índice más bajo) y dejar que la pilacrezca hacia el último elemento del array. Un cursor llamado tope indica la posición actual del primerelemento de la pila. Esta idea se ilustra en la figura 4.8.

0 Último Elemento.....

Segundo ElementoTope

Primer Elemento

tamMax-1

Elementos

Figura 4.8: implementación de una pila mediante un vector

Para esta realización basada en vectores, el tipo abstracto pila se define como:typedef int TElemento;typedef struct

TElemento *elementos;int Lmax;int tope;

.

.

.

.

.

Page 84: programacion2

Tipos de Datos Abstractos

87

Pila;typedef Pila *TPila;

Las operaciones primitivas de las pilas serán implementadas de la siguiente forma:TPila crear (int tamMax)

TPila P;P= (TPila) malloc (sizeof (Pila));if (P==NULL)

printf (“error no hay memoria”);exit (1);

P->Lmax=tamMax;P->tope= -1;P->elementos= (TElemento *) malloc (tamMax * sizeof (TElemento));if (P->elementos==NULL)

printf (“error no hay memoria”);exit (1);

return (P);

void destruir (TPila P)

free (P->elementos);free (P);

int vacia (TPila P)

return (P->tope == -1);

TElemento tope (TPila P)

if (vacia (P))printf (“la pila está vacía”);

exit (1);return (P->elementos[P->tope]);

void pop (TPila P)

if (vacia (P))printf (“la pila está vacía”);

exit (1);P->tope--;

Page 85: programacion2

Programación II

88

void push (TElemento x, TPila P)

if (P->tope == P->Lmax-1)printf (“la pila está llena”);exit (1);

P->tope++;P->elementos[P->tope] = x;

Implementación de Pilas mediante celdas enlazadas

La representación por celdas enlazadas de una pila es bastante fácil, pues push y pop operansólo sobre el primer elemento y no existe la noción de posición. Esto último hace que las cabeceraspuedan ser punteros mejor que celdas completas, ya que no necesitamos representar la posición 1de forma análoga a otras posiciones, tal y como muestra la figura 4.9.

Cabecera tope de la pila fondo de la pila

Figura 4.9: pila implementada mediante celdas enlazadas

Lógicamente, el que las funciones sobre pilas sean más específicas que sobre listas implicaque en general se simplificará la implementación. Veamos como sería una posible implementación delas pilas bajo la estructura de la figura 4.9.

typedef struct nodoTElemento elemento;struct nodo * sig;nodo;

typedef nodo **TPila;

TPila crear ()

TPila P;

P=(nodo **) malloc (sizeof (nodo *));if (P==NULL)

printf (“error no hay memoria”);exit (1);

(*P) = NULL;return (P);

int vacia (TPila P)

return ((*P)==NULL);void pop (TPila P)

P a1 an

Page 86: programacion2

Tipos de Datos Abstractos

89

nodo *q;

if (vacia (P))printf (“la pila está vacía”);

exit (1);q=(*P);(*P)=q->sig;free (q);

void destruir (TPila P)

while (!vacia (P))pop (P);

free (P);

TElemento tope (TPila P)

if (vacia (P))printf (“la pila está vacía”);

exit (1);return ((*P)->elemento);

void push (TElemento x,TPila P)

nodo *q;

q=(nodo *)malloc(sizeof (nodo));if (q==NULL)

printf (“error no hay memoria”);exit (1);

q->elemento=x;q->sig=(*P);(*P)=q;

4.4.3 ColasUna cola es otro tipo especial de lista en el cual los elementos se insertan en un extremo (el

posterior) y se suprimen en el otro (el anterior o frente). Las colas se conocen también como “listasFIFO” (first-in first-out) o listas “primero en entrar, primero en salir”. Las operaciones para una colason análogas a las de las pilas; las diferencias sustanciales consisten en que las inserciones sehacen al final de la lista, y no al principio, y en que la terminología tradicional para colas y listas no esla misma.

Page 87: programacion2

Programación II

90

Especificación de las operaciones primitivas para el TDA Cola

Vamos a notar al tipo cola como TCola y al conjunto de los elementos básicos comoTElemento. Las primitivas que vamos a considerar para las colas son las siguientes:

void anula (TCola *C)

post (*C)=<>

Esta operación es la misma que la de las listas generales

int vacia (TCola C)

pre C está inicializada

post Si (C = <>) entonces vacia = true

sino vacia = false

int frente (TCola C)

pre no vacia (C)

post frente = a1

devuelve el valor del primer elemento de la cola C. Se puede escribir en función de lasoperaciones primitivas de las listas como: elemento (primero (C),C)

void poner_en_cola (TElemento x, TCola C)

pre C =<a1,a2,…,an>

post C =<a1,a2,…,an,x>

inserta el elemento x al final de la cola C. En función de las operaciones de las listas sería:inserta(x, fin (C),C)

void quitar_de_cola (TCola C)

pre no vacia ( C)

C =<a1,a2,…,an>

post C =<a2,…,an>

suprime el primer elemento de C. En función de las operaciones de las listas sería: borra(primero ( C),C)

Implementación de las colas basada en celdas enlazadas

Igual que en el caso de las pilas, cualquier realización de listas es lícita para las colas. Noobstante, para aumentar la eficiencia de pone_en_cola es posible aprovechar el hecho de que lasinserciones se realizan sólo en el extremo posterior, de forma que en lugar de recorrer la lista deprincipio a fin cada vez que se desea hacer una inserción, se puede mantener un puntero al últimoelemento. Como en las listas de cualquier clase, también se mantiene un puntero al frente de la lista;en las colas, ese puntero es útil para ejecutar mandatos del tipo frente o quita_de_cola. Al igual quepara las listas, utilizaremos una celda cabecera con el puntero frontal apuntándola. Esta convenciónpermite manejar convenientemente una cola vacía. Gráficamente, la estructura de la cola es tal comose muestra en la figura 4.10.

Page 88: programacion2

Tipos de Datos Abstractos

91

post

ant

Figura 4.10: cola implementada mediante celdas enlazadas

Una cola es pues un puntero a una estructura compuesta por dos punteros, uno al extremoanterior de la cola y otro al extremo posterior. La primera celda es una celda cabecera cuyo campoelemento se ignora. La definición de tipo es la siguiente:

typedef struct celdaTElemento elemento;struct celda *sig;celda;

typedef struct colacelda * ant, *post;cola;

typedef cola * TCola;

Con esta definición del tipo cola, la implementación de las primitivas es la siguiente:TCola crear ()

TCola C;

C= (TCola ) malloc (sizeof (cola));if (C==NULL)

printf (“error no hay memoria”);exit (1);

C->ant=C->post= (celda *) malloc (sizeof (celda));if (C->ant==NULL)

printf (“error no hay memoria”);exit (1);

C->ant->sig= NULL;return ( C);

int vacia (TCola C)

return (C->ant==C->post);

TElemento frente (TCola C)

if (vacia (C))printf (“error cola vacía”);

Cab a1 an

C

Page 89: programacion2

Programación II

92

exit (1);return (C->ant->sig->elemento);

void poner_en_cola (TElemento x, TCola C)

C->post->sig=(celda *) malloc (sizeof (celda));if (C->post->sig==NULL)

printf (“error no hay memoria”);exit (1);

C->post=C->post->sig;C->post->elemento=x;C->post->sig=NULL;

void quitar_de_cola (TCola C)

celda * aux;if (vacia (C))

printf (“error cola vacía”);exit (1);

aux=C->ant;C->ant=C->ant->sig;free (aux);

void destruir (TCola C)

while (!vacia (C))quitar_de_cola (C);

free (C->ant);free (C) ;

El procedimiento quitar_de_cola suprime el primer elemento de la cola C deconectando lacabecera antigua de la cola, de forma que el primer elemento de la cola se convierte en la nuevacabecera. En la figura 4.11 puede verse esquemáticamente el resultado de hacer consecutivamentelas siguientes operaciones: C= crear(); poner_en_cola (x,C); poner_en_cola (y,C); quitar_de_cola(C);. La línea punteada indica la memoria que es liberada.

C (a) C = crear ( )ant post

Page 90: programacion2

Tipos de Datos Abstractos

93

(b) poner_en_cola (x,C)

poner_en_cola (y,C)

C

(c) quitar_de_cola (C)

C

Figura 4.11: sucesión de operaciones sobre una cola implementada mediante celdas enlazadas

Implementación de las colas usando matrices circulares

La implementación de listas por medio de matrices puede usarse para las colas, pero no esmuy eficiente. Es cierto que con un puntero al último elemento es posible ejecutar pone_en_cola enun tiempo constante, pero quita_de_cola, que suprime el primer elemento, requiere que la colacompleta ascienda una posición en la matriz con lo que tiene un orden de eficiencia linealproporcional al tamaño de la cola.

Para evitarlo se puede adoptar un criterio diferente. Imaginemos a la matriz como un círculoen el que la primera posición sigue a la última, en la forma sugerida en la figura 4.12. La cola seencuentra en alguna parte de ese círculo ocupando posiciones consecutivas. Para insertar unelemento en la cola se mueve el puntero post una posición en el sentido de las agujas del reloj y seescribe el elemento en esa posición. Para suprimir un elemento simplemente se mueve ant unaposición en el sentido de las agujas del reloj. De esta forma, la cola se mueve en es e sentidoconforme se insertan y suprimen elementos. Obsérvese que utilizando este modelo losprocedimientos poner_en_cola y quitar_de_cola se pueden implementar de manera que su ejecuciónse realice en tiempo constante.

Existe un problema que aparece en la representación de la figura 4.12 y en cualquiervariación menor de esta estrategia (p.e. que post apunte a la última posición en el sentido de lasagujas del reloj). El problema es que no hay forma de distinguir una cola vacía de una que llene elcírculo completo salvo que mantengamos un bit que sea verdad si y solo si la cola está vacía. Si nodeseamos mantener este bit debemos prevenir que la cola llene alguna vez la matriz.

ant post x y NULL

ant post x y NULL

Page 91: programacion2

Programación II

94

Para ver por qué puede pasar esto, supongamos que la cola de la figura anterior tuvieratamMax-1 elementos. Entonces, post apuntaría a la posición anterior en el sentido de las agujas delreloj de ant. ¿Qué pasa si la cola estuviera vacía?. Para ver como se representa una cola vacía,consideremos primero una cola de un elemento. Entonces ant y post apuntarían a la misma posición.Si extraemos un elemento, ant se mueve una posición en el sentido de las agujas del reloj, formandouna cola vacía. Por tanto una cola vacía tiene post a una posición de ant en el sentido de las agujasdel reloj, que es exactamente la misma posición relativa que cuando la cola tenía tamMax elementos.Por tanto vemos que aún cuando la matriz tenga tamMax casillas, no podemos hacer crecer la colamás allá de tamMax-1 casillas, a menos que introduzcamos un mecanismo para distinguir si la colaestá vacía o llena.

tamMax-1

0 0

1

post

ant

Cola

Figura 4.12: realización circular para colas

Las primitivas de las colas usando esta representación se describen a continuación:typedef struct

TElemento *elementos;int Lmax;int ant,pos;cola;

typedef cola * TCola;

TCola crear (int tamMax)

TCola C;

C= (TCola) malloc (sizeof (cola));if (C==NULL)

printf (“error no hay memoria”);exit (1);

C->Lmax=tamMax+1;C->ant=0;C->post=C->Lmax-1;C->elementos=(TElemento *) malloc ((tamMax+1) *sizeof (TElemento));if (C->elementos==NULL)

printf (“error no hay memoria”);exit (1);

return (C);

Page 92: programacion2

Tipos de Datos Abstractos

95

int vacia (TCola C)

return ((C->post+1)%(C->Lmax)==C->ant));

TElemento frente (TCola C)

if (vacia (C))printf (“error cola vacía”);exit (1);

return (C->elementos[C->ant]);

void poner_en_cola (TElemento x, TCola C)

if ((C->post+2)%(C->Lmax)==C->ant)printf (“error cola llena”);exit (1);

C->post=(C->post+1)%(C->Lmax);C->elementos[C->post]=x;

void quitar_de_cola (TCola C)

if (vacia (C))printf (“error cola vacía”);exit (1);

C->ant= (C->ant+1)%(C->Lmax);

void destruir (TCola C)

free (C->elementos);free (C);

En esta implementación podemos observar que se reserva una posición más que laespecificada en el parámetro de la función crear. La razón de hacerlo es que no se podrán ocupartodos los elementos de la matriz ya que debemos distinguir la cola llena de la cola vacía. Estas dossituaciones por lo tanto vienen representadas tal como se muestra en la figura 4.13. Como se puedeobservar en el caso de la cola llena la posición siguiente a post no es usada y por lo tanto esnecesario crear una matriz de un tamaño n+1 para tener una capacidad de almacenar n elementos enuna cola.

Page 93: programacion2

Programación II

96

post post

ant

ant

Cola Llena Cola vacía

Figura 4.13: cola llena y vacía en matrices circulares

4.5 El TDA Árbol

4.5.1 Introducción y terminología básicaHasta ahora las estructuras de datos que hemos estudiado eran de tipo lineal, o sea, existía

una relación de anterior y siguiente entre los elementos que la componían (cada elemento tendrá unoanterior y otro posterior, salvo los casos de primero y último). En esta sección vamos a consideraruna estructuración de los datos más compleja: los árboles.

Este tipo de estructura es usual incluso fuera del campo de la informática. El lectorseguramente conoce casos como los árboles gramaticales para analizar oraciones, los árboles paraanalizar circuitos eléctricos, los árboles genealógicos, representación de jerarquías, etc … Laestructura en árbol de los elementos es fundamental dentro del campo de la informática aplicándoseen una amplia variedad de problemas como veremos más adelante.

Para tratar esta estructura cambiaremos la notación:

• Las listas tienen posiciones. Los árboles tienen nodos.

• Las listas tienen un elemento en cada posición. Los árboles tienen una etiqueta en cadanodo algunos autores distinguen entre árboles con y sin etiquetas. Un árbol sin etiquetastiene sentido aunque en la inmensa mayoría de los problemas necesitaremos etiquetarlos nodos.

Usando esta notación, un árbol es una colección de elementos llamados nodos, uno de loscuales se distingue como raíz, junto con una relación de paternidad que impone una estructurajerárquica sobre los nodos. A menudo se representa un nodo por medio de una letra, una cadena decaracteres o un círculo con un número en su interior. Formalmente, un árbol se puede definir demanera recursiva como sigue:

1. Un solo nodo es, por sí mismo, un árbol. Ese nodo es también la raíz de dicho árbol.

2. Supóngase que n es un nodo y que A1,A2,…, Ak son árboles con raíces n1,n2, …,nk,respectivamente. Se puede construir un nuevo árbol haciendo que n se constituya en elpadre de los nodos n1,n2,…,nk. En dicho árbol, n es la raíz y A1,A2,…,Ak son lossubárboles de la raíz. Los nodos n1,n2,…,nk reciben el nombre de hijos del nodo n.

Page 94: programacion2

Tipos de Datos Abstractos

97

A veces conviene incluir entre los árboles un árbol especial el árbol nulo, un árbol sin nodosque se representa mediante Λ. Ejemplo: consideremos el ejemplo de la figura 4.14

C

C1 C2 C3

s1.1 s1.2 s2.1 s2.2 s2.3 s4.1

s2.2.1 s2.2.2 s2.2.3

Figura 4.14: un índice general y su representación como árbol

Podemos observar que cada uno de los identificadores representa un nodo y la relaciónpadre-hijo se señala con una línea. Los árboles normalmente se presentan en forma descendente yse interpretan de la siguiente forma:

C es la raíz del árbol.

C1, C2, C3 son los hijos de C

C1, s1.1, s1.2 componen un subárbol de la raíz.

s1.1, s1.2, s2.1, s2.2.1, s2.2.2, s2.2.3, s2.3,s4.1 son las hojas del árbol.

Además de los términos introducidos consideraremos la siguiente terminología:

a) Grado de salida o simplemente grado : se denomina grado de un nodo al número dehijos que tiene. Así el grado de un nodo hoja es cero. En la figura 4.14 el nodo conetiqueta C tiene grado 3.

b) Caminos : si n1,n2,…,nk es una sucesión de nodos en un árbol tal que ni es el padre deni+1 para 1≤ i ≤ k-1, entonces esta sucesión se llama un camino del nodo n1 al nodo nk. Lalongitud de un camino es el número de nodos menos uno, que hay en el mismo. Existe uncamino de longitud cero de cada nodo a sí mismo. Ejemplos sobre la figura 4.14:

• C,C2,s2.2,s2.3 es un camino de C a s2.2.3 ya que C es padre de s2, éste es padre des2.2, etc.

• C1,C,C2 no es un camino de C1 a C2 ya que C1 no es padre de C.

c) Ancestros y descendientes : si existe un camino, del nodo a al nodo b, entonces a es unancestro de b y b es un descendiente de a. En el ejemplo anterior los ancestros de s2.2son s2.2, C2 y C y sus descendientes s2.2.1, s2.2.2, s2.2.4. (cualquier nodo es a la vezancestro y descendiente de sí mismo). Un ancestro o descendiente de un nodo, distintode sí mismo, se llama un ancestro propio o descendiente propio respectivamente.Podemos definir en términos de ancestros y descendientes los conceptos de raíz, hoja ysubárbol:

• En un árbol, la raíz es el único nodo que no tiene ancestros propios.

Page 95: programacion2

Programación II

98

• Una hoja es un nodo sin descendientes propios.

• Un subárbol de un árbol es un nodo, junto con todos sus descendientes.

Algunos autores prescinden de las definiciones de ancestro propio y descendientepropio asumiendo que un nodo no es ancestro ni descendiente de sí mismo.

d) Altura : la altura de un nodo es un árbol es la longitud del mayor de los caminos del nodoa cada hoja. La altura de un árbol es la altura de la raíz. Ejemplo: en la figura 4.14 laaltura de C2 es 2 y la del árbol es 3.

e) Profundidad : la profundidad de un nodo es la longitud del único camino de la raíz a esenodo. Ejemplo: en la figura 4.14 la profundidad de C2 es 1.

f) Niveles : dado un árbol de altura h se definen los niveles 0…h de manera que el nivel iestá compuesto por todos los nodos de profundidad i.

g) Orden de los nodos : los hijos de un nodo usualmente están ordenados de izquierda aderecha. Si deseamos explícitamente ignorar el orden de los hijos, nos referiremos a unárbol como un árbol no-ordenado.

La ordenación izquierda-derecha de hermanos puede ser extendida para compararcualesquiera dos nodos que no están relacionados por la relación ancestro-descendiente.La regla a usar es que si n1 y n2 son hermanos y n1 está a la izquierda de n2, entoncestodos los descendientes de n1 están a la izquierda de todos los descendientes de n2.Ejemplo: en la figura 4.14 el nodo s2.2.1 está a la derecha de los nodos C1, s1.1, s1.2,s2.1 y a la izquierda de los nodos s2.2.2, s2.2.3, s2.3, C3, s4.1.

Recorridos de un árbol

En una estructura lineal resulta trivial establecer un criterio de movimiento por la misma paraacceder a los elementos, pero en un árbol esa tarea no resulta tan simple. No obstante, existendistintos métodos útiles en que podemos sistemáticamente recorrer todos los nodos de un árbol. Lostres recorridos más importantes se denominan preorden, inorden y postorden. Estos ordenamientosse definen recursivamente como sigue:

• Si un árbol A es nulo, entonces la lista vacía es el listado de los nodos de A en losrecorridos preorden, inorden y postorden.

• Si A contiene un solo nodo, entonces ese nodo constituye el listado de los nodos de A enlos recorridos preorden, inorden y postorden.

Si ninguno de los anteriores es el caso, sea A un árbol con raíz n y subárboles A1,A2,…,Ak.Como se representa en la figura 4.15.

n

Figura 4.15: árbol A

A1 A2 Ak

Page 96: programacion2

Tipos de Datos Abstractos

99

1. El listado en preorden de los nodos de A está formado por el nodo raíz de A, seguido delos nodos de A1 listados en preorden, luego por los de A2 en preorden y asísucesivamente hasta los nodos de Ak listados en preorden.

2. El listado en inorden de los nodos de A está formado por los nodos de A1 listados eninorden, seguidos de n (nodo raíz) y luego por los nodos de los subárboles A2,…, Ak

listados en inorden.

3. El listado en postorden de los nodos de A está formado por los nodos de A1 listados enpostorden, luego por los nodos de A2 en postorden y así sucesivamente hasta los nodosde Ak listados en postorden y por último la raíz n.

Como ejemplo de listados veamos el resultado que se obtendría sobre el árbol de la figura4.15. Los resultados de los listados en preorden, postorden e inorden son los siguientes:

1. Listado preorden: 1,2,3,5,8,9,6,10,4,7

2. Listado postorden: 2,8,9,5,10,6,3,7,4,1

3. Listado inorden: 2,1,8,5,9,3,10,6,7,4

Un truco útil para producir los tres recorridos es el siguiente: si caminamos por la periferia delárbol, partiendo de la raíz, y avanzando en el sentido contrario de las agujas del reloj, como estárepresentado en la figura 4.17 para la figura 4.16. Para el recorrido en preorden se lista un nodo laprimera vez que se pasa por él. En el caso del recorrido postorden, se lista un nodo la última vez quese pasa por él, conforme se sube hacia su padre. Para el recorrido inorden, se lista una hoja laprimera vez que se pasa por ella, y un nodo interior, la segunda vez que se pasa por él. A manera deejemplo, en la figura 4.17 se pasa por el nodo 1 al empezar, y otra vez al pasar por la “bahía” entrelos nodos 2 y 4. Obsérvese que el orden de las hojas en los tres recorridos corresponde al mismoordenamiento de izquierda a derecha de las hojas. Sólo el orden de los nodos interiores y su relacióncon las hojas varía entre los tres ordenamientos.

1

2 3 4

5 6 7

8 9 10

Figura 4.16: ejemplo de árbol

Page 97: programacion2

Programación II

100

1

2 3 4

5 6 7

8 9 10

Figura 4.17: recorrido del árbol de la figura 4.16

Finalmente, es interesante conocer que un árbol no puede, en general, recuperarse con unosolo de sus recorridos. Será a partir de los recorridos preorden y postorden cuando se puedadeterminar unívocamente. También a través de estos recorridos podemos determinar los antecesoresde un nodo. Supóngase que ord_post (n) es la posición del nodo n en el listado en postorden de losnodos de un árbol, y que desc (n) es el número de descendientes propios del nodo n. Las posicionesen postorden de los nodos tienen la útil propiedad de que los nodos de un subárbol con raíz n ocupanposiciones consecutivas de ord_post (n) – desc (n) a ord_post(n). Para determinar si un nodo x esdescendiente de un nodo y, basta verificar que se cumple:

ord_post(y)-desc(y) ≤ ord_post(x) ≤ ord_post(y)

Una propiedad similar se cumple para el recorrido en preorden.

4.5.2 Una aplicación: árboles de expresiónUna importante aplicación de los árboles en la informática es la representación de árboles

sintácticos, es decir, árboles que contienen las derivaciones de una gramática necesarias paraobtener una determinada frase de un lenguaje.

Podemos etiquetar los nodos de un árbol con operandos y operadores de manera que unárbol represente una expresión. Por ejemplo, en la figura 4.18 se representa un árbol con la expresiónaritmética (x+z)*(x-y). Las reglas para que un árbol represente a una expresión son:

1. Cada hoja está etiquetada con un operando y sólo consta de ese operando.

2. Cada nodo interior está etiquetado con un operador.

*

+ -

x z x y

Figura 4.18: árbol de expresión

Page 98: programacion2

Tipos de Datos Abstractos

101

Con estas premisas si un nodo interior n está etiquetado por un operador binario θ (como + ó*), el hijo izquierdo representa la expresión E1 y el derecho la E2, entonces el árbol de raíz nrepresenta la expresión (E1) θ (E2), (los paréntesis pueden eliminarse si no son necesarios). En lafigura 4.18 el hijo izquierdo del nodo raíz está etiquetado con el operador + y sus hijos izquierdo yderecho representan las expresiones x e y, respectivamente. Por tanto, este nodo representa laexpresión (x) + (y), o más simple, x+y. De la misma manera la expresión representada por el hijoderecho de la raíz en el árbol de la figura 4.18 será: x-y, así la expresión representada por el nodoraíz será: (x+z) * (x-y).

En general, cuando se recorra un árbol en preorden, inorden o postorden, se preferirá listarlas etiquetas de los nodos, en lugar de sus nombres. En los árboles de expresión, el listado enpreorden de las etiquetas nos da lo que se conoce como la forma prefijo de una expresión, en la queel operador precede a su operando izquierdo y derecho. Formalmente,

• La forma prefija para un único operando x es el mismo.

• La expresión prefija correspondiente a (E1) θ (E2), donde θ es un operador binario, es θP1

P2, donde P1 y P2 son las expresiones prefijas correspondientes a E1 y E2.

Obsérvese que en la expresión prefija no se necesitan paréntesis, dado que es posible revisarla expresión prefija θ P1 P2 e identificar unívocamente a P1 como el prefijo más corto (y único) deP1P2, que es además una expresión prefija válida.

En el ejemplo de la figura 4.18, el preorden de etiquetas del árbol es *+xy-xy. Como puedeverse el prefijo válido más corto de esta expresión es +xy que corresponde al hijo izquierdo del nodoraíz.

De manera similar, el listado en postorden de las etiquetas de un árbol de expresión da lo quese conoce como representación postfija (o polaca). Formalmente,

• La expresión postfijo para un único operando x es el mismo.

• La expresión postfijo para (E1) θ (E2), siendo θ un operador binario es Z1Z2θ , donde Z1 yZ2 son las representaciones postfijo de E1 y E2, respectivamente.

De nuevo, los paréntesis son innecesarios, porque se puede identificar a Z2 buscando el sufijomás corto de Z1Z2 que sea una expresión postfijo válida. Por ejemplo, la expresión postfijacorrespondiente a la figura 4.18 es xy+xy-*, y si escribimos esta expresión como Z1Z2*, entonces Z2

es xy-, el sufijo más corto de xy+xy-*.

Finalmente, el inorden de una expresión en un árbol de expresión da la expresión infijo en símismo, pero sin paréntesis. En ele ejemplo de la figura 4.18, la sucesión inorden del árbol es x+y*x-y.

4.5.3 El TDA ÁrbolLa estructura de árbol puede ser tratada como un tipo de dato abstracto. A continuación

especificaremos este TDA y posteriormente se presentará una posible representación. Notaremos altipo Árbol como TArbol, al tipo nodo como TNodo y al tipo de las etiquetas TEtiqueta. Además, paranotar el árbol vacío usaremos un valor especial ARBOL_VACIO, al igual que en las listas existe elconcepto de lista vacía.

De igual forma, es necesario expresar en algunos casos que un nodo no existe para lo cualtambién usaremos un valor especial NODO_NULO. Un ejemplo de su uso puede ser cuandointentemos extraer el nodo hijo a la izquierda de un nodo hoja.

Page 99: programacion2

Programación II

102

Especificación de las operaciones primitivas del TDA Árbol

No vamos a realizar una especificación operacional, no se va a hacer uso de ningún modeloabstracto, en su lugar utilizaremos el lenguaje natural pues para este caso no hay lugar aambigüedad.

Al igual que con otros TDA, existe una gran variedad de operaciones que pueden llevarse acabo sobre árboles. Aquí se consideran las siguientes:

TArbol crearRaiz (TEtiqueta u)

Construye un nuevo nodo r con etiqueta u y sin hijos. Se devuelve el árbol con raíz, es decir,un árbol con un único nodo

void destruir (TArbol T)

Libera los recursos que mantienen el árbol T de forma que para volver a usarlo se debe deasignar un nuevo valor con la operación de creación

TNodo padre (TNodo n,TArbol T)

Devuelve el padre del nodo n en el árbol T. Si n es la raíz, que no tiene padre, devuelveNODO_NULO. Como precondición n no es NODO_NULO

TNodo hizqda (TNodo n,TArbol T)

Devuelve el descendiente más a la izquierda en el siguiente nivel del nodo n en el árbol T, ydevuelve NODO_NULO si n no tiene hijo a la izquierda. Como precondición n no esNODO_NULO. En la figura 4.19, hijo_izda (n2) = n4, , hijo_izda (n5) = NODO_NULO

TNodo herdrcha (TNodo n,TArbol T)

Devuelve un nodo m con el mismo padre p que n tal que m cae inmediatamente a la derechade n en la ordenación de los hijos de p. Devuelve NODO_NULO si n no tiene hermano a laderecha. Como precondición n no es NODO_NULO. En la figura 4.19, hermano_drcha (n4) =n5

n1

n2 n3

n4 n5 n6 n7

Figura 4.19: árbol ejemplo para las primitivas hizqda y herdrcha

TEtiqueta etiqueta (TNodo n,TArbol T)

Devuelve la etiqueta del nodo n en el árbol T. Como precondición n no es NODO_NULO

void reEtiqueta (TEtiqueta e,TNodo n,TArbol T)

Page 100: programacion2

Tipos de Datos Abstractos

103

Asigna una nueva etiqueta e al nodo n en el árbol T. Como precondición n no esNODO_NULO

TNodo raiz (TArbol T)

Devuelve el nodo que está en la raíz del árbol T o NODO_NULO si T es ARBOL_VACIO

void insertar_hijo_izqda (TNodo n, TArbol Ti,TArbol T)

Inserta el árbol Ti como hijo a la izquierda del nodo n que pertenece al árbol T. Comoprecondición n no es NODO_NULO y Ti no es ARBOL_VACIO

void insertar_hermano_drcha (TNodo n, TArbol Td,TArbol T)

Inserta el árbol Td como hermano a la derecha del nodo n que pertenece al árbol T. Comoprecondición n no es NODO_NULO y Td no es ARBOL_VACIO

TArbol podar_hijo_izqda (TNodo n,TArbol T)

Devuelve el subárbol con raíz hijo a la izquierda de n del árbol T, el cual se ve privado deestos nodos. Como precondición n no es NODO_NULO

TArbol podar_hermano_drcha (TNodo n,TArbol T)

Devuelve el subárbol con raíz hermano a la derecha de n del árbol T, el cual se ve privado deestos nodos. Como precondición n no es NODO_NULO

Implementación de los recorridos de un árbol

A continuación veremos la implementación de los recorridos de un árbol basándonos en lasprimitivas especificadas en la sección anterior

• Preorden: los pasos a seguir son los siguientes:

1. Visitar la raíz.

2. Recorrer el subárbol más a la izquierda en preorden.

3. Recorrer el subárbol de la derecha en preorden.

Vamos a escribir dos procedimientos uno recursivo y otro no recursivo que tomanun árbol y listan las etiquetas de sus nodos en preorden. El procedimiento recursivo que,dado un nodo n, lista las etiquetas en preorden del subárbol con raíz n es el siguiente:

void preordenAr (TNodo n,TArbol T)

Escribir (etiqueta (n,T));for (n=hizqda (n,T);n!=NODO_NULO;n=herdrcha (n,T))

preordenAr (n,T);

En esta función existe una rutina Escribir que tiene como parámetro de entrada unvalor de tipo TEtiqueta que se encarga de imprimir en la salida estándar.

Page 101: programacion2

Programación II

104

Para el procedimiento no recursivo , usaremos una pila para encontrar el caminoalrededor del árbol. El tipo TPila es realmente pila de nodos, es decir, pila de posicionesde nodos. La idea básica subyacente al algoritmo es que cuando estamos en un nodo p, lapila alojará el camino desde la raíz a p, con la raíz en el fondo de la pila y el nodo p en lacabeza. El programa tiene dos modos de operar. En el primer modo desciende por elcamino más a la izquierda en el árbol, escribiendo y apilando los nodos a lo largo delcamino, hasta que encuentre una hoja. A continuación el programa entra en el segundomodo de operación en el cual vuelve hacia atrás por el camino apilado en la pila,extrayendo los nodos de la pila hasta que se encuentra un nodo en el camino con unhermano a la derecha. Entonces el programa vuelve al primer modo de operación,comenzando el descenso desde el inexplorado hermano de la derecha. El programacomienza en modo uno en la raíz y termina cuando la pila está vacía.

void preordenAr (TArbol T)

TPila P; /*Pila de posiciones: TElemento de la pila es el tipo nodo*/TNodo m;

P = crear (); /*Función de creación del TDA Pila*/m=raiz (T);do

if (m!=NODO_NULO))Escribir (etiqueta (n,T));Push (m,P);m = hizqda (m,T);

else if (!vacia (P))

m=herdrcha (tope(P),T);pop (P);

while (!vacia (P));destruir (P);

• Inorden: los pasos a seguir son los siguientes:

1. Recorrer el subárbol más a la izquierda en inorden.

2. Visitar la raíz.

3. Recorrer el subárbol del siguiente hijo a la derecha en inorden.

Vamos a escribir un procedimiento recursivo para listar las etiquetas de sus nodos en inorden.

void inordenAr (TNodo n,TArbol T)

TNodo c;

c=hizqda (n,T);if (c!=NODO_NULO)

inordenAr (c,T);Escribir (etiqueta (n,T));

Page 102: programacion2

Tipos de Datos Abstractos

105

for (c=herdrcha (c,T);c!=NODO_NULO;c=herdrcha (c,T))inordenAr (c,T);

else Escribir (etiqueta (n,T));

• Postorden: los pasos a seguir son:

1. Recorrer el subárbol más a la izquierda en postorden.

2. Recorrer el subárbol de la derecha en postorden.

3. Visitar la raíz.

El procedimiento recursivo para listar las etiquetas de sus nodos en postorden es elsiguiente:

void postordenAr (TNodo n, TArbol T)

TNodo c;

for (c=hizqda (n,T);c!=NODO_NULO;c=herdrcha (c,T))postordenAr (c,T);

Escribir (etiqueta (n,T));

Implementación de árboles basada en celdas enlazadas

Al igual que ocurre en los TDA estudiados (Listas, Pilas o Colas), un nodo puede serdeclarado de forma que la estructura del árbol pueda ir en aumento mediante la obtención dememoria deforma dinámica, haciendo una petición de memoria adicional cada vez que se quierecrear un nuevo nodo.

#define ARBOL_VACIO NULL#define NODO_NULO NULL

typedef int TEtiqueta;typedef struct tipo_celda

TEtiqueta etiqueta;struct tipo_celda * hizqda;struct tipo_celda * herdrcha;struct tipo_celda * padre; tipo_celda;

typedef tipo_celda * TNodo;typedef TNodo TArbol;

Observemos que bajo esta implementación cada nodo de un árbol contiene 3 punteros: padre queapunta al padre, hizqda que apunta al hijo izquierdo y herdrcha que apunta al hermano a la derecha delnodo. La implementación de las operaciones es como sigue:

Page 103: programacion2

Programación II

106

TNodo padre (TNodo n, TArbol T)

return (n->padre);

TNodo hizqda (TNodo n, TArbol T)

return (n->hizqda);

TNodo herdrcha (TNodo n, TArbol T)

return (n-> herdrcha);

TEtiqueta etiqueta (TNodo n, TArbol T)

return (n->etiqueta);

void reEtiqueta (TEtiqueta e,TNodo n,TArbol T)

n->etiqueta = e;

TNodo raiz (TArbol T)

return (T);

TArbol creaRaiz (TEtiqueta et)

TArbol raiz;

raiz = (TArbol) malloc (sizeof (tipo_celda));if (!raiz)

printf (" error no hay memoria");exit (1);

raiz ->padre = NULL;raiz->hizqda = NULL;raiz ->herdrcha = NULL;raiz->etiqueta = et;return (raiz);

void destruir (TArbol T)

if (T) destruir (T->hizqda);destruir (T->herdrcha);free (T);

Page 104: programacion2

Tipos de Datos Abstractos

107

void insertar_hijo_izqda (TNodo n,TArbol Ti,TArbol T)

Ti->herdrcha=n->hizqda;Ti->padre=n;n->hizqda=Ti;

void insertar_hermano_drcha (TNodo n,TArbol Td,TArbol T)

if (n==raiz (T))printf (“error no se puede insertar hermano a la derecha en la raíz”);exit (1);

Td->herdrcha=n->herdrcha;Td->padre=n;n->herdrcha=Td;

TArbol podar_hijo_izqda (TNodo n,TArbol T)

TArbol Taux;

Taux = n->hizqda;if (Taux!=ARBOL_VACIO)

n->hizqda = Taux->herdrcha;Taux->padre = NODO_NULO;Taux->herdrcha = NODO_NULO;

return (Taux);

TArbol podar_hermano_drcha (TNodo n,TArbol T)

TArbol Taux;

Taux = n->herdrcha;if (Taux!=ARBOL_VACIO)

n->herdrcha = Taux->herdrcha;Taux->padre = NODO_NULO;Taux->herdrcha = NODO_NULO;

return (Taux);

Page 105: programacion2

Programación II

108

4.5.4 El TDA Árbol Binar ioUn árbol binario puede definirse como un árbol que en cada nodo puede tener como mucho

grado 2, es decir, a lo más 2 hijos. Los hijos suelen denominarse hijo a la izquierda e hijo a laderecha, estableciéndose de esta forma un orden en el posicionamiento de los mismos.

Todas las definiciones básicas que se dieron para árboles generales permanecen inalteradassin más que hacer las particularizaciones correspondientes. En los árboles binarios hay que tener encuenta el orden izqda-drcha de los hijos. Por ejemplo los árboles binarios (a) y (b) de la figura 4.20son diferentes, puesto que difieren en el nodo 5, y el árbol (c) es el correspondiente árbol general, porconvenio se supone igual al (b) y no al (a), aunque los árboles generales no son directamentecomparables a los árboles binarios.

1 1 1

2 2 2

3 4 3 4 3 4

5 5 5

(a) (b) (c)

Figura 4.20: ejemplo de árboles binarios

Especificación del TDA Árbol Binario

Vamos a notar al tipo árbol binario como TArbolB y al tipo nodo como TNodoB. Lasoperaciones primitivas para el TDA árbol binario son las siguientes:

TArbolB crearB (Tetiqueta e, TArbolB Ti,TArbolB Td)

Devuelve un árbol cuya raíz contiene la etiqueta e, y como subárbol a la izquierda Ti y comosubárbol a la derecha Td

void destruirB (TArbolB T)

Libera los recursos que mantiene el árbol T de forma que para volver a usarlo se debeasignar un nuevo valor con la operación de creación

TNodoB padreB (TNodoB n,TArbolB T)

Devuelve el padre del nodo n en el árbol T. Si n es la raíz, que no tiene padre, devuelveNODO_NULO . Como precondición n no es NODO_NULO

Page 106: programacion2

Tipos de Datos Abstractos

109

TNodoB hizqdaB (TNodoB n,TArbolB T)

Devuelve el hijo a la izquierda del nodo n en el árbol T, y devuelve NODO_NULO si n notiene hijo a la izquierda. Como precondición n no es NODO_NULO

TNodoB hdrchaB (TNodoB n,TArbolB T)

Devuelve el hijo a la derecha del nodo n en el árbol T y devuelve NODO_NULO si n no tienehijo a la derecha. Como precondición n no es NODO_NULO

TEtiqueta etiquetaB (TNodoB n,TArbolB T)

Devuelve la etiqueta del nodo n en el árbol T. Como precondición n no es NODO_NULO

void reEtiquetaB (TEtiqueta e,TNodoB n,TArbolB T)

Asigna una nueva etiqueta e al nodo n en el árbol T. Como precondición n no esNODO_NULO

TNodoB raizB (TArbolB T)

Devuelve el nodo que está en la raíz del árbol T o NODO_NULO si T es BINARIO_VACIO

void insertar_hizqdaB (TNodoB n, TArbolB Ti,TArbolB T)

Inserta el árbol Ti como hijo a la izquierda del nodo n que pertenece al árbol T. En el caso deque existiese ya el hijo a la izquierda, la primitiva se encarga de que sea destruido junto consus descendientes. Como precondiciones n no es NODO_NULO y Ti no es BINARIO_VACIO

void insertar_hidrchaB (TNodoB n, TArbolB Td,TArbolB T)

Inserta el árbol Td como hijo a la derecha del nodo n que pertenece al árbol T. En el caso deque existiese ya el hijo a la derecha, la primitiva se encarga de que sea destruido junto consus descendientes. Como precondiciones n no es NODO_NULO y Td no esBINARIO_VACIO

TArbolB podar_hizqdaB (TNodoB n,TArbolB T)

Devuelve el subárbol con raíz hijo a la izquierda de n del árbol T, el cual se ve privado deestos nodos. Como precondición n no es NODO_NULO

TArbolB podar_herdrcha (TNodoB n,TArbolB T)

Devuelve el subárbol con raíz hermano a la derecha de n del árbol T, el cual se ve privado deestos nodos. Como precondición n no es NODO_NULO

Como podemos observar estas primitivas son las mismas que para los árboles generalespero adaptadas a las características específicas de los árboles binarios. Una vez especificadas lasoperaciones del TDA árbol binario la implementación de los recorridos sería la siguiente:

Page 107: programacion2

Programación II

110

void preordenArB (TNodoB n, TArbolB T)

if (n!= NODO_NULO)Escribir (etiquetaB(n,T));

preordenArB (hizqdaB (n,T),T);preordenArB(hdrchaB (n,T),T);

void postordenArB (TNodoB n, TArbolB T)

if (n!= NODO_NULO)

postordenArB (hizqdaB (n,T),T);postordenArB(hdrchaB (n,T),T);Escribir (etiquetaB(n,T));

void inordenArB (TNodoB n, TArbolB T)

if (n!= NODO_NULO)inordenArB(hizqdaB (n,T),T);

Escribir (etiquetaB(n,T));inordenArB(hdrchaB (n,T),T);

Implementación del TDA Árbol Binario

Vamos a realizar una implementación mediante punteros. La declaración de tipos es comosigue:

#define BINARIO_VACIO NULL#define NODO_NULO NULL

typedef int TEtiqueta;typedef struct tipo_celdaB

TEtiqueta etiqueta;struct tipo_celdaB * hizqda;struct tipo_celdaB * hdrcha;struct tipo_celdaB * padre; tipo_celdaB;

typedef tipo_celda * TNodoB;typedef TNodoB TArbolB;

Una posible implementación para las primitivas de árboles binarios es la siguiente:

TArbolB CrearB (Tetiqueta et, TArbolB Ti,TArbolB Td)

TArbolB raiz;

Page 108: programacion2

Tipos de Datos Abstractos

111

raiz = (TArbolB) malloc (sizeof (tipo_celdaB));if (!raiz)

printf (“error no hay memoria”);exit (1);

raiz->padre=NULL;raiz->hizqda=Ti;raiz->hdrcha=Td;raiz->etiqueta=et;if (Ti!=NULL)

Ti->padre=raiz;if (Td!=NULL)

Td->padre=raiz;

return (raiz);

void destruirB (TArbolB T)

if (T)destruirB(T->hizqda);destruirB(T->hdrcha);free (T);

TNodoB padre (TNodoB n, TArbolB T)

return (n->padre);

TNodoB hizqdaB (TNodoB n, TArbolB T)

return (n->hizqda);

TNodoB hdrcha (TNodoB n, TArbolB T)

return (n-> hdrcha);

TEtiqueta etiquetaB (TNodoB n, TArbolB T)

return (n->etiqueta);

void reEtiquetaB (Tetiqueta e,TNodoB n,TArbolB T)

n->etiqueta = e;

TNodoB raizB (TArbolB T)

Page 109: programacion2

Programación II

112

return (T);

void insertar_hizqdaB (TNodoB n, TArbolB Th, TArbolB T)

destruirB (n->hizqda);n->hizqda = Th;Th -> padre = n;

void insertar_hdrchaB (TNodoB n, TArbolB Th, TArbolB T)

destruirB (n->hdrcha);n->hdrcha = Th;Th -> padre = n;

TArbolB podar_hizqdaB (TNodoB n,TArbolB T)

TArbolB Taux;

Taux=n->hizqda;n->hizqda = BINARIO_VACIO;if (Taux)

Taux ->padre = BINARIO_VACIO;

return (Taux);

TArbolB podar_hdrchaB (TNodoB n,TArbolB T)

TArbolB Taux;

Taux=n->hdrcha;n->hdrcha = BINARIO_VACIO;if (Taux)

Taux ->padre = BINARIO_VACIO;

return (Taux);

4.6 Bibliografía• [AHU88] A.V. Aho, J.E. Hopcroft, J:D. Ullman. Estructuras de datos y algoritmos.2ª Edición.

Addison_Wesley (1988).

• [BOW97] C.F. Bowman. Algorithms and data structures: An approach in C. Oxford UniversityPress (1997).

• [KG88] J.F. Korsh, L.J. Garrett. Data Structures, Algorithms, and Program Style Using C. PWS-KENT Publishing Company (1988).

Page 110: programacion2

Tipos de Datos Abstractos

113

• [FGG98] J. Fdez-Valdivia, A. Garrido, M. García. Estructuras de datos. Un enfoque práctico. 1ªEdición (1998).

• [FT96] W. Ford, W. Topp. Data structures with C++. Prentice Hall International (1.996).

• [LAT97] Y. Langsom, M. Augenstein, A. Tenenbaum. Estructuras de datos con C y C++. 2ªEdición. Prentice Hall (1.997).

• [MEH84] K. Mehlhorn. Data structures and algorithms. Vols. 1-3. Springer Verlag (1984).

• [VAN90] J.C. Van Wick. Data structures and C programs. Addison-Wesley (1.990).

• [WIR86] N. Wirth. Algorithms an Data Structures. Pretince Hall (1986).

Page 111: programacion2
Page 112: programacion2

Capítulo 5: Algoritmos avanzados de

ordenación y búsqueda.

El proceso de clasificación u ordenación de una lista de objetos de acuerdo con algún ordenlineal, como ≤ para números, se realiza con tanta frecuencia que merece la pena pararse a estudiarlo.

La clasificación la podemos dividir en dos tipos: interna y externa, en este libro vamos a vercon gran detalle la interna, ya que la externa se sale de los objetivos de nuestro curso. Laclasificación interna es la que se realiza en la memoria principal del ordenador, aprovechando lacapacidad del acceso aleatorio en sus distintas formas. La clasificación externa se utiliza cuando elnúmero de objetos a ordenar es demasiado grande y no cabe en la memoria principal de ordenador.

5.1 IntroducciónLos algoritmos más simples de ordenación requieren un tiempo O(n2 ) para clasificar n

objetos. Uno de los algoritmos de clasificación más populares es la clasificación rápida (quicksort),que tiene un tiempo promedio de O(n lg n). El quicksort funciona muy bien para la mayor parte de lasaplicaciones; sin embargo, en el peor de los casos tiene un tiempo de O(n2 ). Existen otros algoritmos,como la clasificación por montículos (heapsort) y la clasificación por intercalación (mergesort) quelleva un tiempo O (n lg n) en el peor de sus casos, aunque su comportamiento en el caso promedio espeor que el del quicksort. El mergesort se puede utilizar en la clasificación externa. Existen otrosalgoritmos de clasificación llamados por urnas o por cubetas, los cuales se pueden utilizar solo conclases especiales de datos, requiriendo un tiempo de O(n) en el peor de los casos.

Para clasificar suponemos que vamos a trabajar con un registro, el cual tiene un campollamado clave que será el que vamos a utilizar para ordenarlos. Por ejemplo el que se muestra en lafigura 5.3.

El problema de la clasificación consiste en ordenar una secuencia de registros de tal formaque los valores de sus claves formen una secuencia creciente o decreciente. No es necesario quetodos los registros tengan valores distintos, ni que los registros con claves iguales aparezcan en unorden particular.

Se emplean varios criterios para evaluar el tiempo de ejecución de un algoritmo declasificación interna. La primera medición, y también la más común, es el número de pasosrequeridos por el algoritmo para clasificar n registros. Otra medición frecuente es el número decomparaciones entre claves que debe efectuarse para clasificar n registros, y por ultimo su el tamañodel registro es muy grande, también puede ser conveniente contar las veces que debe moverse.

5.1.1 Algoritmos simples de clasificaciónUno de los algoritmos de clasificación más simples puede ser el de la burbuja (bubblesort). La

idea básica de este es imaginar que los registros a ordenar están almacenados en un vector vertical.Se recorre varia veces el vector de abajo hacia arriba, y al hacer esto si hay dos elementosadyacentes que no están en orden se invierten. El efecto producido por esta operación es que en elprimer recorrido es que el registro con valor clave menor, sube al primer valor del vector. En elsegundo recorrido el valor de la clave menor siguiente, pasa a la segunda posición del vector, y asísucesivamente. Si el vector A es un tipo_registro A[n], n es el número de registros, y el campo clavecontiene el valor de la clave de cada registro, tendríamos el siguiente algoritmo:

for (i=0; i<n-1; i++) for (j=n-1; j>=i+1; j--) if (A[j].clave<A[j-1].clave) intercambia(A[j],A[j-1]);

Figura 5.1. Algoritmo de la burbuja (Bubblesort).

Page 113: programacion2

Programación II

116

El procedimiento de intercambio se utiliza en varios algoritmos de clasificación y se define dela siguiente forma:

void intercambia (registro x, registro y)

/*intercambia cambia los valores de x e y*/registro temp;temp =x;x = y;y = temp;

/*intercambia*/

Figura 5.2. El procedimiento intercambia.

Ejemplo. En la tabla siguiente se muestra una tabla de escritores con el nombre y apellido.

Nombre Apellidos

Federico García Lorca

Arturo Pérez Reverte

Isabel Allende

Pablo Neruda

Dominique Lapierre

Camilo José Cela

Tabla 5.1. Escritores.

Para este ejemplo podemos utilizar la siguiente definición de tipos:

typedef int tipo_clave;typedef struct

tipo_clave clave;char apellidos [80];

registro;typedef registro* tipo_registro;

Figura 5.3. Definición de tipos.

El algoritmo de clasificación de la burbuja aplicado sobre la lista de escritores, los ordenaalfabéticamente, si la relación ≤ en objetos con este tipo de claves es el orden lexicográfico habitual.En la siguiente tabla, se muestra los pasos dados por el algoritmo para ordenar los nombres. Losnombres subrayados indican el punto sobre el cual se sabe que son los primeros en orden alfabéticoy ocupan el lugar correcto.

Page 114: programacion2

Algoritmos avanzados de ordenación y búsqueda.

117

Federico Federico Federico Federico Federico Arturo

Arturo Arturo Arturo Arturo Arturo Federico

Isabel Isabel Isabel Camilo José Camilo José Camilo José

Pablo Pablo Camilo José Isabel Isabel Isabel

Dominique Camilo José Pablo Pablo Pablo Pablo

Camilo José Dominique Dominique Dominique Dominique Dominique

Inicial i=1 para i=1y j=6 para i=1y j=5 para i=1y j=4 para i=1y j=3 para i=1y j=2

Arturo Arturo Arturo Arturo Arturo

Federico Federico Federico Federico Camilo José

Camilo José Camilo José Camilo José Camilo José Federico

Isabel Isabel Dominique Dominique Dominique

Pablo Dominique Isabel Isabel Isabel

Dominique Pablo Pablo Pablo Pablo

Inicial i=2 para i=2 y j=6 para i=2 y j=5 para i=2 y j=4 para i=2 y j=3

Arturo Arturo Arturo Arturo

Camilo José Camilo José Camilo José Camilo José

Federico Federico Federico Dominique

Dominique Dominique Dominique Federico

Isabel Isabel Isabel Isabel

Pablo Pablo Pablo Pablo

Inicial i=3 para i=3 y j=6 para i=3 y j=5 para i=3 y j=4

Arturo Arturo Arturo

Camilo José Camilo José Camilo José

Dominique Dominique Dominique

Federico Federico Federico

Isabel Isabel Isabel

Pablo Pablo Pablo

Inicial i=4 para i=4 y j=6 para i=4 y j=5

Page 115: programacion2

Programación II

118

Arturo Arturo

Camilo José Camilo José

Dominique Dominique

Federico Federico

Isabel Isabel

Pablo Pablo

Inicial i=5 para i=5 y j=6

Como podemos ver el primer recorrido nos intercambia Dominique y Camilo José, subiéndolohasta que se encuentra con Arturo, que es el nombre más pequeño y lo sube hasta arriba. En elsegundo recorrido sube a Camilo José hasta la posición dos. En el tercer recorrido Dominiquesobrepasa a Federico y la lista quedaría ordenada, pero sin embargo por la definición del algoritmo,se hacen dos recorridos más.

5.1.2 Clasificación por inserciónEn este método de clasificación se llama así, porque en el i-ésimo recorrido se inserta el i-

ésimo elemento A[i] en el lugar correcto, entre A[1], A[2], … ,A[i-1], los cuales fueron ordenadospreviamente. Después de hacer esta inserción, se encuentran clasificación los registros colocados enA[1].. A[i]. Esto se resume en:

Repite para i=2.. nmover A[i] hacia la posición j ≤ i tal que

A[i] < A[k] para j ≤ k < i, yA[i] ≥ A[j-1] o j = 1

Fin-repite

Figura 5.4. Clasificación por inserción en algorítmico.

Para facilitar el proceso de mover A[i], es útil introducir un elemento A[0], cuya clave tiene unvalor menor que el de cualquier clave existente entre A[1], …, A[n]. Se crea una constante -∞ de tipotipo_clave que es menor que la clave de cualquier registro que pueda aparecer en la práctica. Elprograma completo seria el siguiente:

A[0].clave = -∞;for (i=2; i<=n; i++) j = i; while (A[j].clave < A[j-1].clave) intercambia(A[j], A[j-1]); j--;

Figura 5.5. Clasificación por inserción en C.

Page 116: programacion2

Algoritmos avanzados de ordenación y búsqueda.

119

Ejemplo. Utilizando la tabla del ejemplo anterior mostramos el resultado de los recorridossucesivos de la clasificación por inserción para i=2, 3, …, 6. Después de cada recorrido, estágarantizado que los elementos por arriba de la línea estarán ordenados entre sí, aunque su orden notenga relación con los registros encontrados bajo la palabra subrayada, los cuales se insertarándespués.

-∞ -∞ -∞ -∞ -∞ -∞

Federico Arturo Arturo Arturo Arturo Arturo

Arturo Federico Federico Federico Dominique Camilo José

Isabel Isabel Isabel Isabel Federico Dominique

Pablo Pablo Pablo Pablo Isabel Federico

Dominique Dominique Dominique Dominique Pablo Isabel

Camilo José Camilo José Camilo José Camilo José Camilo José Pablo

Inicial Después de i=2 Después de i=3 Después de i=4 Después de i=5 Después de i=6

Tabla 5.2. Recorridos de la clasificación por inserción.

5.1.3 Clasificación por selecciónLa idea es que en el i-ésimo recorrido se selecciona el registro con la clave más pequeña,

entre A[i], …, A[n-1], y se intercambia con A[i]. Como resultado, después de i pasadas, los i registrosmenores ocupan A[0], …, A[i], en el orden clasificado. Esta clasificación la podemos describir de lasiguiente forma:

Repite para i=0..n-2Seleccionar el más pequeño entre A[i], …, A[n-1] e

Intercambiarlo con A[i];

El programa que realizaría esto sería:

tipo_clave clave_menor; /*la clave menor encontrada actualmente en un recorrido a través de A[i], …, A[n]*/

int indice_menor; /*la posición de clave_menor*/for (i=0; i<n-1; i++)

/*elegir el menor entre A[i], …, A[n-1] eintercambiarlo con A[i] */indice_menor= i;clave_menor= A[i].clave;for (j=i; j<n; j++)

/*compara cada clave con la actual clave_menor*/if (A[j].clave < clave_menor)

clave_menor = A[j].clave;indice_menor = j;

Page 117: programacion2

Programación II

120

intercambia ( A[i], A[indice_menor]);

Figura 5.6. Clasificación por selección.

Ejemplo. Tomando la tabla de autores de los ejemplos anteriores, vamos a ordenarlosutilizando este algoritmo. En el primer recorrido el valor de indice_menor es 1, la posición de Arturo,que se intercambia con Federico en A[0]. Después de n-2 recorridos, el registro A[n-1], Pablo ennuestra tabla, está en su lugar correcto, ya que es el elemento que se sabe que no está entre los n-2más pequeños. Toda la evolución de los elementos a ordenar la podemos observar en la siguientetabla.

Federico Arturo Arturo Arturo Arturo Arturo

Arturo Federico Camilo José Camilo José Camilo José Camilo José

Isabel Isabel Isabel Dominique Dominique Dominique

Pablo Pablo Pablo Pablo Federico Federico

Dominique Dominique Dominique Isabel Isabel Isabel

Camilo José Camilo José Federico Federico Pablo Pablo

Inicial Después de i=1 Después de i=2 Después de i=3 Después de i=4 Después de i=5

Tabla 5.3. Recorridos de la clasificación por selección.

5.1.4 Complejidad de tiempos de los algoritmos y cuenta deintercambios

Las clasificaciones de burbuja, por inserción y por selección llevan un tiempo O(n2) y llevaránun tiempo de Ω(n2) en buena para de las secuencias de entrada de n elementos, sin importar quétipo_registro sea, y considerando que intercambia lleva un tiempo constante.

Si el tamaño de los registros es grande, el procedimiento intercambia, llevará más tiempo quecualquier otro paso, como la comparación de claves o los cálculos en los índices del vector. Asímientras que los tres algoritmos llevan un tiempo proporcional a n2, se pueden comparar con másdetalle al contar las veces que se usa intercambia.

El número de intercambios efectuados en la clasificación por inserción es, en promedioidéntico al de la clasificación de la burbuja. Sin embargo, en el caso de que intercambia sea unaoperación costosa, es fácil observar que la clasificación por selección es superior a lasclasificaciones de la burbuja y por inserción. A diferencia de las clasificaciones de burbuja y porinserción, la clasificación por selección permite a los elementos saltar sobre grandes cantidades deelementos sin necesidad de intercambiarlos entre sí individualmente.

Cuando los registros sean grandes y los intercambios costosos, una estrategia muy útil esmantener un vector de punteros a registros por medio de cualquier algoritmo. Entonces se puedenintercambiar los punteros en lugar de registros.

Page 118: programacion2

Algoritmos avanzados de ordenación y búsqueda.

121

5.1.5 Limitaciones de los algoritmos simplesSe debe tener presente que los algoritmos mencionados en esta sección tienen un tiempo de

ejecución O(n2), tanto en el peor de los casos como para el caso promedio. Así para una n grande,ninguno de estos algoritmos se compara de modo favorable con los algoritmos O (n lg n) que seanalizarán en las siguientes secciones. Una regla razonable es que a menos que n seaaproximadamente 100, puede ser una perdida de tiempo implantar un algoritmo más complicado quelos estudiados en esta sección. La clasificación de Shell, una generalización de la clasificación de laburbuja, es un algoritmo de clasificación O(n1.5) simple, muy sencillo de ampliar y razonablementeeficiente para valores modestos de n.

El procedimiento Shellsort (clasificación de Shell) de la figura 5.7, algunas veces llamadoclasificación de incremento decreciente, clasifica un vector A[n] de enteros, al clasificar n/2 pares (A[i],A[n/2+i]) para 0 L Q HQ HO SULPHU UHFRUULGR Q FXiGUXSORV $>L@ $>Q L@ $>Q L@ $>QL@para 0 L Q HQ HO VHJXQGR UHFRUULGR Q yFWXSORV HQ HO WHUFHU UHFRUULGR \ DVt VXFHVLYDPHQWH (Qcada recorrido, n/8 óctuplos en el tercer recorrido, y así sucesivamente. En cada recorrido, elordenamiento se realiza con la clasificación por inserción, la cual termina cuando encuentra doselementos en el orden apropiado.

void Shellsort ( int *A )int i,j, incr;incr = n / 2;while (incr > 0)

for (i=incr + 1;i < n; i++) j= i – incr;while (j> 0) if (A[j] > A[j+incr]) intercambia_enteros(A[j], A[j+incr]);

j = j –incr;

incr = incr / 2;

/*Shellsort*/

Figura 5.7. Clasificación de Shell (Shellsort).

El procedimiento intercambia_enteros queda su realización como ejercicio al lector.

5.2 Clasificación ráp ida (quicksort)La esencia del método consiste en clasificar un vector A[0], …, A[n-1] tomando en el vector un

valor clave v como elemento pivote, alrededor del cual reorganizar los elementos del vector. Es deesperar que el pivote esté cercano a la mediana de la clave del vector. Se permutan los elementosdel vector con el fin de que para alguna j, todos los registros con claves menores que v aparezcan enA[0], …, A[j], y todos aquellos con claves mayores aparezcan en A[j+1], …, A[n-1]. Después se aplicarecursivamente el Quicksort a A[0], …, A[j] y a A[j+1], …, A[n-1] para clasificar ambos grupos deelementos. Dado que todas las claves del primer grupo preceden a todas las claves del segundogrupo, todo el vector quedará ordenado.

Page 119: programacion2

Programación II

122

Ejemplo. En la figura siguiente se muestran los pasos recursivos que da el quicksort paraordenar la siguiente secuencia de enteros 3, 1, 4, 1, 5, 9, 2, 6, 5, 3. En cada caso, se ha escogidocomo valor v al mayor de los dos valores distintos que se encuentran más a la izquierda. La recursiónacaba al descubrir que las posiciones del vector tienen claves idénticas. Se puede ver que cada nivelconsta de dos pasos, uno antes de dividir cada subvector, y el segundo, después.

Más concretamente para el ejemplo siguiente el elemento pivote elegido en el primer nivel esel 3, ya que es el mayor de los dos valores claves primeros del vector. A continuación permutamoslos elementos del vector inicial, de tal forma que los elementos primeros de este tienen un valormenor que el pivote y los últimos tienen un valor mayor que el pivote. Con esta clasificación podemosdividir el vector inicial en dos, por un lado los elementos menores que el pivote y por otro lado losmayores, pasando entonces al nivel dos del gráfico, llamando de forma recursiva al quicksort paracada uno de los dos vectores obtenidos anteriormente repitiendo este proceso hasta que lleguemos alos casos base.

3DUWLFLyQ Y

3DUWLFLyQ Y 3DUWLFLyQ Y

&RQFOXLGR &RQFOXLGR

&RQFOXLGR &RQFOXLGR

&RQFOXLGR

&RQFOXLGR

&RQFOXLGR

3DUWLFLyQ Y 3DUWLFLyQ Y

3DUWLFLyQ Y

1LYHO

1LYHO

1LYHO

1LYHO

1LYHO

Figura 5.8. Ejemplo de ordenación con Quicksort.

El procedimiento recursivo quicksort(i,j) que opera en un vector A con elementos A[0], …, A[n-1], ordena desde A[i] hasta A[j] y a grandes rasgos sería:

Page 120: programacion2

Algoritmos avanzados de ordenación y búsqueda.

123

/*1*/ Si de A[i] a A[j] existen al menos dos claves distintas entonces/*2*/ sea v la mayor de las dos claves distintas encontradas;/*3*/ permuta A[i], …, A[j] de manera que para alguna k entre

i+1 y j, A[i], …, A[k-1] tengan claves menores quev y los elementos A[k], …, A[j] tengan claves ≥ v

/*4*/ quicksort(i, k-1);/*5*/ quicksort(k, j);/*6*/ fin-si

Figura 5.9. Algoritmo del quicksort.

Obsérvese que si todos los elementos A[i], …, A[j], tienen la misma clave, el procedimiento noafecta a A.

Lo primero que tenemos que hacer es definir la función encuentra_pivote que obtiene laprueba de la línea /*1*/ del procedimiento anterior, para determinar si todas las claves A[i], …, A[j]tienen el mismo valor. Si encuentra_pivote no encuentra dos claves distintas, devuelve -1. De otromodo devuelve el índice de la mayor de las dos primeras claves diferentes, la cual se convierte en elelemento pivote. Esta función se podría escribir como:

int encuentra_pivote (int i, int j, tipo_registro A) /*devuelve 0 si A[i], …, A[j] tienen claves idénticas; de otra forma, devuelve el índice de la mayor de las dos claves diferentes de más a la izquierda*/

tipo_clave primera_clave; /*valor de la primera clave encontrada es decir, A[i].clave*/

int k; /*va de izquierda a derecha buscando una clave diferente*/

int valor;int terminar;valor = -1; /* si nunca se encontraron dos claves diferentes*/terminar = 0;k = i+1;primera_clave = A[i].clave;while ((k <= j) && (terminar == 0)) /*rastrea en busca de una clave distinta*/

if (A[k].clave > primera_clave) /*selecciona la clave mayor*/teminar = 1;valor = k;

else if (A[k].clave < primer_clave)

terminar = 1;valor = k;

return (valor); /*encuentra_pivote*/

Figura 5.10. Procedimiento encuentra_pivote.

Page 121: programacion2

Programación II

124

Luego, se aplica la línea /*3*/ del procedimiento del quicksort, donde se encuentra elproblema de la permutación de A[i], …, A[j], sobre los mismos registros, de manera que todas lasclaves menores que el valor del pivote aparezcan a la izquierda de las demás. Para realizar esto, seintroducen dos cursores, z y d, en un principio, en el extremo izquierdo y derecho, respectivamente,de la posición de A que es está clasificando. Los elementos a la izquierda de z, esto es, A[i], …, A[z-1], siempre tendrán claves menores que el pivote. Los elementos a la derecha de d, esto es, A[d+1],…, A[j], tendrán claves mayores o iguales al pivote, y los elementos del centro estarán mezclados, dela siguiente forma:

&ODYHV SLYRWH &ODYHV SLYRWH≥

, = ' -

&ODYHV GHVRUGHQDGDV

Figura 5.11. Situación durante el proceso de permutación.

La función partición devuelve z, utilizando las siguientes operaciones:

• Rastrear: mueve z a la derecha en los registros cuyas claves sean menores que elpivote y mueve d a la izquierda en las claves mayores o iguales que pivote.

• Probar: si z > d, entonces se ha dividido A[i], …, A[j] de forma satisfactoria.

• Desviar: si z < d (obsérvese que no se puede para durante el rastreo con z=d porqueuno u otro se moverá más allá de la clave dada), se intercambia A[z] con A[d].Después de hacerlo se sabrá que en la siguiente fase de rastreo z se moverá unaposición a la derecha, en la A[d] anterior y d se moverá al menos una posición a laizquierda.

La función partición implementada que realizaría esto podría ser:

Int particion (int i, int j, tipo_clave pivote, tipo_registro A)/*divide A[i], …, A[j] para que las clavesmenores que pivote estén a la izquierda y lasclaves mayores o iguales que pivote estén a laderecha. Devuelve el lugar donde se inicia elgrupo de la derecha.*/

int z, d; /*cursores*/z = i;d = j;do

intercambia (A[z],A[d]);

Page 122: programacion2

Algoritmos avanzados de ordenación y búsqueda.

125

/*ahora se inicia la fase de rastreo*/while (A[z].clave < pivote)

z++;while (A[d].clave >= pivote)

d --;

while (z > d);return (z);

/*particion*/

Figura 5.12. Función partición.

El programa final del quicksort para ordenar un vector A de tipo tipo_registro [n], será:

void quicksort (int i, int j, tipo_registro A) /*ordena los elementos A[i], …, A[j] del vector A*/tipo_clave pivote; /*el valor del pivote*/int indice_pivote; /*indice de un elemento a

A donde clave es el pivote*/int k; /*indice al inicio del grupo de elementos ≥ pivote*/

/*1*/ indice_pivote = encuentra_pivote(i, j, A);/*2*/ if (indice_pivote != (-1)) /*no hace nada si toda

las claves son iguales*//*3*/ pivote = A[indice_pivote].clave;/*4*/ k = particion (i, j, pivote, A);/*5*/ quicksort (i, k-1, A);/*6*/ quicksort (k, j, A);

Figura 5.13. Procedimiento quicksort.

La llamada inicial a este procedimiento para ordenar el vector A seria quicksort (0, n-1).

5.2.1 Tiempo de ejecución del quicksort.El algoritmo tiene en promedio un tiempo de O(n lg n) para ordenar n elementos, y en el peor

de los casos tiene O(n2). Para demostrar estos valores lo primero que tenemos que hacer es probarqué partición(i, j, pivote, A) tiene un tiempo proporcional al número de elementos que debe clasificar,es decir, un tiempo de O(j – i + 1), dado que existen j – i + 1 elementos en la porción del vector aordenar. Si observamos el código fuente vemos que nunca se pasa dos veces por el mismoelemento, ya que los bucles lo van recorriendo, uno hacia abajo y el otro hacia arriba, intercambiandolos registros si es necesario, pero nunca vuelven sobre uno ya visitado.

Ahora, tenemos que considerar el tiempo de ejecución de quicksort(i, j, A), siendo fácil probarque le tiempo consumido por la llamada encuentra_pivote de la línea /*1*/ del procedimiento anterior,es O(j – i +1), siendo en muchos caso bastante menor. Cada llamada individual a quicksort lleva untiempo como máximo proporcional al número de elementos que se van a ordenar.

Page 123: programacion2

Programación II

126

En otras palabras, el tiempo total consumido por quicksort es la suma, en todos loselementos, de las veces que el elemento forma parte del subvector en el que se hizo la llamada aquicksort.

En el peor caso, podría suceder que en cada llamada a quicksort se seleccione el peor pivoteposible, por ejemplo, el mayor valor de las claves en el subvector que se está clasificando. Entoncesde dividiría el subvector en dos subvectores más pequeños, uno con un solo elemento, y el otro contodos los demás. Esta secuencia de particiones forma un árbol como el de la figura siguiente, donder0, r1 … rn-1 es la secuencia de registro en orden creciente de las claves.

UQ

UQ

U

U

Figura 5.14. Peor secuencia posible de selecciones de pivotes.

La profundidad de ri es n-i+1 para 1≤ i ≤ n-1, y la profundidad de r1es n-1, así la suma de las

profundidades es n-1+∑−

=

+−1

1

1n

i

in = 122

2

−+ nnlo cual es Ω (n2). Viéndose que en el peor de los

casos, el quicksort necesita un tiempo proporcional a n2 para clasificar n elementos.

Para el caso promedio tenemos que suponer que no existen dos elementos con clavesiguales y que cuando se llama a quicksort (i, j, A), todas las clasificaciones para A[i], …, A[j] sonigualmente probables. Partiendo de esto y utilizando la probabilidad para obtener el mejor pivoteobtendremos una serie de fórmulas que tras ser simplificadas nos dan que el quicksort requiere untiempo O (n lg n) en el caso promedio, saliéndose esta demostración del objetivo del curso.

5.2.2 Mejoras al quicksort.Quicksort es muy rápido, en tiempo promedio tiene O( n lg n). Es posible mejorar aún más el

factor constante al tomar pivotes que dividan cada subvector en partes similares ya que de esta formacada elemento tendría una profundidad exactamente igual a lg n, en el árbol de particiones, encomparación la profundidad promedio de un elemento es de cerca de 1,4 lg n. Sobre el ejemplo de lafigura 5.8 el diagrama de niveles que tendríamos sería el siguiente:

Page 124: programacion2

Algoritmos avanzados de ordenación y búsqueda.

127

3DUWLFLyQ Y

3DUWLFLyQ Y 3DUWLFLyQ Y

&RQFOXLGR

&RQFOXLGR &RQFOXLGR&RQFOXLGR&RQFOXLGR

3DUWLFLyQ Y 3DUWLFLyQ Y 3DUWLFLyQ Y

1LYHO

1LYHO

1LYHO

1LYHO

&RQFOXLGR

&RQFOXLGR

Figura 5.15. Ejemplo con elección optima de pivote.

Para mejorar esto en nuestro algoritmo podemos escoger tres elementos de un subvector alazar y tomar el elemento medio como pivote. Se pueden tomar k elementos al azar para cualquier k,clasificarlos por una llamada recursiva al quicksort o por uno de los algoritmos más simples de lasección anterior, y elegir el elemento medio, es decir, el [(k + 1)/2]-ésimo como pivote.

Otra mejora del quicksort está relacionada con lo que sucede cuando se toman subvectorespequeños. Recuérdese que los métodos simples tienen un tiempo de O(n2), siendo mejor que el deeste método que es de O( n lg n), para n pequeñas. Knuth en [Kun87] sugiere 9 como el tamaño delsubvector en el que el quicksort debe llamar a un algoritmo de clasificación más simple.

Existe otra forma de acelerar el quicksort, siempre que se tenga espacio disponible, se creaun vector de punteros a los registros del vector A. Se efectúan las comparaciones entre las claves delos registros y se mueven los punteros a los registros.

5.3 Clasificación por montículos (heapsort)Esta clasificación tiene como tiempo peor y caso promedio O(n lg n). Este algoritmo se puede

expresar en forma abstracta por medio de las cuatro operaciones de conjuntos INSERTA, SUPRIME,VACIA, MIN. Supóngase que L es la lista de elementos que se van a clasificar y S es un conjunto deelementos de tipo tipo_registro que se usará para guardar los elementos conforme se clasifican. Eloperador MIN(S) devuelve el registro en S cuya clave tiene el valor más pequeño. El algoritmo declasificación abstracto se puede transformar en el heapsort, y nos quedaría como:

Page 125: programacion2

Programación II

128

Repite para x en la lista L INSERTA(x, S);Fin-repiteRepite mientras (not VACIA(S)) y <- MIN(S); escribir(y); SUPRIME(y, S)Fin-repite

Figura 5.16. Algoritmo abstracto de clasificación.

Para implementar estas operaciones podemos utilizar varias estructuras, con las cualespodemos implementar cada operación en un tiempo O(lg n), si los conjuntos no crecen más allá de nelementos. Si se supone que la lista L es de longitud n, el número de operaciones realizadas será nveces INSERTA, n veces SUPRIME, n veces MIN, y n+1 VACIA. El tiempo total consumido por elalgoritmo anterior es O(n lg n).

5.4 Clasificación por urnas (binsort)A continuación nos planteamos la siguiente cuestión: ¿Son necesarios tiempos de Ω(n lg n)

para clasificar n elementos? Si, siempre que se suponga que el tipo de las claves se pueden ordenarmediante alguna función que indica si el valor de alguna clave es menor que otro. Pero en muchasocasiones es posible clasificar en tiempos menores a O(n lg n), siempre que se conozca algo especialacerca de las claves que se están clasificando.

Ejemplo. Supóngase que tipo_clave es entero, y que se sabe que los valores de las clavesse encuentran en el intervalo de 0 a n-1, sin duplicados, donde n es el número de elementos.Entonces si A y B son del tipo tipo_registro [n], y los n elementos a clasificar están almacenadosinicialmente en A, es posible colocarlos en orden en el vector B, por medio de

for (i=0; i < n; i++)B[A[i].clave] := A[i];

Este código calcula el lugar que pertenece al registro A[i] y lo coloca en él. El ciclo completolleva un tiempo O(n). Trabaja bien cuando existe un único registro con clave v, para todo valor de ventre 0 y n-1. Si no existe un valor nos crearía un agujero.

Un ejemplo práctico de cómo funciona esto seria:

Para los valores del vector A=[5, 3, 2, 1, 8, 7, 9, 6, 4, 0], como tenemos queA[0]= 5, si sustituimos en la fórmula B[A[0]]=A[0] tendríamos que B[5]=5, quejustamente es la posición que tiene que ocupar tras ser ordenado el vector. Si esto lorepetimos para cada uno de los elementos lo que obtenemos es el vector A ordenadoen B. Para un solo valor entero quizás no tenga mucho sentido, ya que tendríamos lasecuencia completa, pero si esto lo aplicamos a un registro si, ya que nos ordenaríatodos los demás campos.

Page 126: programacion2

Algoritmos avanzados de ordenación y búsqueda.

129

Hay dos formas de clasificar un vector A con claves 0, 1, …, n - 1 en sólo un tiempo O(n). Serecorre A[0], …, A[n - 1] por turno; si el registro a A[i] tiene clave j ≠ i, se intercambia A[i] con A[j]. Sidespués del intercambio el registro con la clave k se encuentra en A[i], y k ≠ i, se intercambia A[i] cona[k], y así sucesivamente. Así , el siguiente algoritmo ordena A en un tiempo O(n).

for (i = 0; i < n; i++) while (A[i].clave != i)

intercambia(A[i], A[A[i].clave]);

Un ejemplo de ordenación con este algoritmo podía ser:

Vector inicial a ordenar.

3 4 2 1 5 6 0

Para el valor de i = 0, entramos en el bucle while y ordenamos en los siguientes pasos:

Intercambiamos el valor de la posición cero con la tres

1 4 2 3 5 6 0

Como no se cumple la condición del bucle volvemos a intercambiar

4 1 2 3 5 6 0

Como tampoco se cumple la condición del bucle volvemos a intercambiar

5 1 2 3 4 6 0

Como sigue sin cumplirse la condición del bucle volvemos a intercambiar

6 1 2 3 4 5 0

Por ultimo como sigue sin cumplirse la condición del bucle volvemos a intercambiar

0 1 2 3 4 5 6

Como ya se cumple la condición del bucle salimos y ya no volvemos a entrar más en él yaque no se vuelve a cumplir la condición de entrada, por estar ya ordenado.

El programa anterior a este es un caso simple de una clasificación por urnas (binsort), unproceso de clasificación donde se crea una urna para contener todos los registros con cierta clave. Seexamina cada registro r a clasificar y se coloca en la urna de acuerdo con el valor de la clave de r. Enéste programa, las urnas son los elementos del vector B[0], …, B[n-1], y B[i] es la urna para la clavecuyo valor es i.

A veces puede ser necesario almacenar más de un registro en una urna teniendo luego queconcatenarlas en el orden apropiado. Si B es un vector de tipo TLista [tipo_clave], entonces B es unvector de urnas, que son listas y esta indexado por tipo_clave, existiendo una urna para cada posiblevalor de clave.

Page 127: programacion2

Programación II

130

5.4.1 Clasificación general por residuos (radix sort).Supóngase que el tipo_clave es una secuencia de campos, como en

tiposTipo_clave = registro

Dia : 1..31;Mes : (ene, …, dic);Año : 1900..2500;

fin-tipo_clave ;

O un vector de elementos del mismo tipo, como en

tiposTipo_clave = cadena (10);

Se supondrá de aquí en adelante que tipo_clave está constituido por k elementos, f1, f2, …, fkde tipos t1, t2,…, tk. Por ejemplo, t1 ∈1, ..,31, t2 ∈ ene, …, dic y t3 ∈ 1900, .., 2500, para el primertipo, y k=10, t1 = t2 = … =tk =char.

Se pueden considerar las claves del tipo antes definido como si los valores de las clavesfueran enteros expresados en alguna notación de residuos extraña. Por ejemplo, en el vector delmismo tipo, donde cada campo es un carácter, puede considerarse como la expresión de enteros enbase 128. La definición del tipo secuencia de campos, puede considerarse como si el lugar de más ala derecha estuviera en base 100 (correspondiente a los valores entre 1900 y 2500), el siguiente lugaren base 12, y el tercero en base 31. Desde este punto de vista la clasificación por urnas generalizadase conoce como clasificación por residuos (radix sorting).

La idea de esta clasificación es ordenar por urnas todos los registros, primero el fk , el dígitomenos significativo, después concatenar las urnas, primero el menor valor, clasificar de nuevo en fk-1 ,y así sucesivamente. Un boceto de cómo se podría escribir este algoritmo sería:

Procedimiento radixsort;clasifica la lista A de n registros con claves que consisten en campos f1, f2, …, fk de tipos t1, t2,…, tk respectivamente. El procedimiento usa los vectores Bi de tipo array [ti] of tipo_lista para 1 ≤ i≤ k, donde tipo_lista es una lista enlazada de registros.inicio

/*1*/ Repite para i = k, k-1, k –2, …, 1/*2*/ Repite para cada valor de tipo ti do limpia las urnas/*3*/ vacia Bi [v]/*4*/ Repite para cada registro r de la lista A do/*5*/ mover r desde A hasta el final de la urna Bi [v], donde

v es el valor del campo fi de la clave de r/*6*/ Repite para cada valor v de tipo ti , desde el menor hasta el mayor do/*7*/ concatena Bi [v] en el extremo de A

fin-repitefin radixsort

Figura 5.17. Clasificación por residuos.

Page 128: programacion2

Algoritmos avanzados de ordenación y búsqueda.

131

5.4.2 Análisis de la clasi ficación por residuosTenemos que escoger la estructura de datos adecuada para hacer que esta clasificación sea

eficiente, por ejemplo la de lista ordenada, no de vector. En la práctica, sólo es necesario agregar uncampo adicional, el campo de enlace, al tipo tipo_registro, para poder enlazar A[i] a A[i+1] para i = 1,2, …, n-1 y así hacer una lista enlazada del vector A en un tiempo O(n). Obsérvese también que si sepresentan en esta forma los elementos a clasificar, nunca se copia un registro. Sólo se cambianregistros de una lista a otra.

5.5 Técnicas básicas de búsquedaDefinamos algunos términos antes de considerar técnicas específicas de búsqueda:

• Una tabla o un archivo es un grupo de elementos, cada uno de los cuales se llamaregistro.

• Hay una clave asociada a cada registro, que se usa para diferenciar unos de otros. Laasociación entre un registro y su clave puede ser simple o compleja.

• La clave interna está contenida dentro del registro en un tramo a una distancia específicadel principio del mismo.

• La clave externa es cuando tenemos una tabla de claves diferentes que incluyeapuntadores a los registros.

• La clave primaria es un conjunto de claves único para todo archivo. Por ejemplo si elarchivo está almacenado como un vector, el índice dentro del vector de un elemento esuna clave externa única para ese elemento. Sin embargo como cualquier campo de unregistro puede servir como la clave en una aplicación particular las claves no siemprenecesitan ser únicas. Por ejemplo, en un archivo de nombres y direcciones si se usa elestado (provincia) como clave para una búsqueda particular, es probable que no seaúnica, dado que puede haber dos registros con el mismo estado dentro del archivo. Unaclave de este tipo se llama clave secundaria.

• Un algoritmo de búsqueda es un algoritmo que afecta un argumento a y trata deencontrar un registro cuya clave sea a. El algoritmo puede dar como resultado el registroentero o un puntero a dicho registro. Si la búsqueda no es exitosa, el algoritmo puede darcomo resultado un registro “nulo” especial o un puntero a nulo.

• La tabla o el archivo puede ser un vector de registro, una lista ligada, un árbol o inclusoun diagrama. Dado que distintas técnicas de búsqueda pueden ser adecuadas paraorganizaciones de tablas diferentes, con frecuencia se diseña una tabla teniendo enmente una técnica de búsqueda específica. La tabla puede estar contenida en sutotalidad en la memoria, en la memoria auxiliar o estar dividida en ambas.

5.5.1 El diccionario como un tipo de datos abstracto.Una tabla de búsqueda o diccionario puede representarse como un TDA. Primero suponemos

dos tipos de declaraciones de los tipo de clave y registros y una función que extrae la clave de unregistro del mismo. También definimos un registro nulo para representar una búsqueda infructuosa.

typedef KEYTYPE ... /* un tipo de clave*/typedef RECTYPE ... /* un tipo de registro */

Page 129: programacion2

Programación II

132

RECTYPE nullrec = ... /* un registro “null” */

KEYTYPE keyfunct (r)RECTYPE r; ...;

Podemos entonces representar el tipo de datos abstracto table como un simple conjunto deregistros. Este es nuestro primer ejemplo de un TDA definido como un conjunto y no como unasecuencia. Usamos la notación [eltype] para denotar un conjunto de objetos de tipo eltype. La funcióninsert (s, elt) da como resultado verdadero si elt está en el conjunto s y falso en el caso contrario. Laoperación de conjuntos x – y denota el conjunto x eliminado de él todos los elementos de y.

abstract typedef [rectype] TABLE (RECTIPE) ;

abstract member (tbl,k)TABLE(RECTYPE) tbl;KEYTYPE k;postcondition if (existe un r en tbl tal que keyfunct(r) == k)

then menber = TRUEelse member = FALSE

abstract RECTYPE search (tbl,k)TABLE (rectype) tbl;KEYTYPE k;postcondition (not member (tbl, k)) && (search == nulrec)

|| (member (tbl,k) && keyfunct (search) == k);abstrac inset (tbl,)TABLE(RECTYPE) tbl;RECTYPE r;precondition member (tbl, keyfunct (r) == FALSEpostcondition inset (tbl, r);

( tbl – [r]) == tbl’ ;abstract delete (tbl, k)TABLE(RECTYPE) tbl;KEYTYPE k;postcondition tb == (tbl’ – [search (tbl,K]);

Como no se presupone que exista relación entre los registros o sus claves asociadas, la tablaque especificamos se llama tabla desordenada. Una vez establecido un orden entre las claves sehace posible la referencia al primer elemento de una tabla, al último y al sucesor de un elementodado. Una tabla que cuente con estas facilidades adicionales se llama una tabla ordenada. El TDApara una tabla ordenada debe especificarse como una secuencia para indicar el orden de losregistros y no como un conjunto. Dejamos la especificación del TDA como ejercicio al lector.

5.5.2 Notación algorítmicaUna tabla organizada como un vector podría declararse como:

#define TABESIZE 1000typedef KEYTYPE ...

Page 130: programacion2

Algoritmos avanzados de ordenación y búsqueda.

133

typedef RECTYPE ...struct

KEYTYPE k;RECTYPE r;

table [TABLESIZE];

o como dos arreglos separados:

KEYTYPE k[TABLESIZE];RECTYPE r[TABLESIZE];

En el primer caso la i-ésima clave sería referida como table[i].k; en el segundo como k[i].

De manera similar, para una tabla organizada como una lista, podría usarse la representacióndinámica de una lista o la representación con vector de la lista. En el primer caso la clave del registroapuntado por un puntero p sería referida como node[p].k; en el último, como p Æ k.

Sin embargo, las técnicas para buscar en estas tablas son muy similares. Así, con el objetode liberarnos de la necesidad de elegir una representación específica adoptamos la convenciónalgorítmica de hacer referencia a la clave i-ésima como k(i) y a la clave del registro apuntado por pcomo k(p). De igual forma, hacemos referencia al registro correspondiente como r(i) o r(p). De estamanera podemos concentrar nuestra atención en los detalles de la técnica en lugar de los de laimplementación.

5.5.3 Búsqueda secuenc ialLa forma más simple de búsqueda es la búsqueda secuencial. Esta búsqueda es aplicable a

una tabla organizada, ya sea como un vector o como una lista ligada. Supongamos que k es unvector de n claves, de k(0) a k(n-1) y r es un vector de registro de r(0) a r(n-1) de tal manera que k(i)es la clave de r(i). Supongamos también que key es un argumento de búsqueda. Queremos obtenerel entero i más pequeño tal que k(i) sea igual a key si existe tal i y –1 en caso contrario. El algoritmopara hacerlo es el siguiente.

for (i = 0; i<n; i++)if (key == k(i))

return (i);return (-1);

El algoritmo examina cada clave en turno; al encontrar una que coincida con el argumento dela búsqueda, da como resultado su índice. Si ninguna coincide el resultado es –1.

Este algoritmo puede modificarse con facilidad para agregar un registro rec con clave key a latabla si key aún no está en la misma. La última instrucción se modifica como sigue:

k(n) = key; /* insertar la nueva clave */r(n) = rec; /* y el registro */n++ ; /* incrementar el tamaño de la tabla */return (n-1);

Page 131: programacion2

Programación II

134

Obsérvese que si se hacen inserciones usando sólo el algoritmo modificado anterior, dosregistros no pueden tener la misma clave. Cuando este algoritmo se implementa, debemosasegurarnos que el incremento de n no haga que su valor exceda el límite superior del vector.

Un método de búsqueda aún más eficiente involucra la inserción de la clave del argumento alfinal del vector antes de comenzar la búsqueda, garantizando así que la clave será encontrada.

k(n) = key;for (i = 0; key ! = k(i); i++)

;if (i<n)

return (i);else

return (-1);

Para una búsqueda e inserción la instrucción if completa se reemplaza por:

if (i == n)r(n++) = rec;

return (i)

La clave extra insertada al final del vector se llama un centinela.

Al almacenar una tabla como una lista ligada tiene la ventaja de que el tamaño de la tablapuede aumentar de manera dinámica cuando sea necesario. Supongamos que la tabla estáorganizada como una lista lineal ligada apuntada por table y ligada mediante un campo puntero next.Entonces, suponiendo k, r, key y rec como antes, la búsqueda secuencial con inserción para una listaligada puede escribirse de la siguiente manera:

q = null;for (p = table; p !=null && k(p) != key; p=next(p))

q = p;if (p != null) /* lo que significa que k(p) == KEY */if (p != null)

return (p);/* inserta un nuevo nodo */s = getnode();k(s) = key;r(s) = rec; next(s) =null;if (q == null)

table = s;else

next(q) = s;return (s)

La eficiencia de la búsqueda puede perfeccionarse mediante la misma técnica que acabamosde sugerir para un vector. Se puede agregar un nodo centinela que contenga la clave del argumentoal final de la lista antes de comenzar la búsqueda de manera que la condición en la iteración for seala condición simple k(p) != key. Sin embargo, el método del centinela requiere de guardar un punteroexterno adicional al último nodo de la lista.

Page 132: programacion2

Algoritmos avanzados de ordenación y búsqueda.

135

La eliminación de un registro de una tabla almacenada como un vector desordenado seimplementa reemplazando el registro a ser eliminado por el último registro del vector y reduciendo eltamaño de la tabla en 1. Si el vector está ordenado de alguna manera este método no puede usarse.

5.5.4 Eficiencia de la búsqueda secuencial.El número de comparaciones depende del lugar de la tabla donde aparece el registro que

tienen la clave del argumento. Si el registro es el primero de la tabla, se realiza una sola comparación;si el registro es el último, se necesitan n comparaciones. Si es igualmente probable que el argumentoaparezca en cualquier posición dada en la tabla, una búsqueda exitosa haría (en el promedio) (n+1)/2comparaciones y, una infructuosa n comparaciones. En cualquier caso el número de comparacioneses o(n).

5.5.5 Reordenamiento de una lista para alcanzar máxima eficienciade búsqueda.

Así, sería de gran ayuda tener un algoritmo que reordenará de manera continua la tabla, detal forma que los registros que se accedan con mayor frecuencia estuvieran al frente y los que seaccedan con menor frecuencia al final.

Hay dos métodos de búsqueda para realizar lo anterior. Uno de ellos se conoce como métodode moverse-al-frente y es eficiente en el caso de una tabla como una lista. En este método, siempreque una búsqueda es exitosa, el registro recuperado se elimina de su localización actual de la lista yse coloca a la cabeza de la misma.

El otro método se llama transposición, en el cual un registro recuperado se intercambia con elregistro que lo precede de manera inmediata. Presentamos un algoritmo para implementar el métodode transposición en una tabla almacenada en forma de lista ligada. El algoritmo da como resultado unpuntero como registro recuperado o el puntero nulo si no se encuentra el registro. Como antes, key esel argumento de búsqueda, k y r son las tablas de claves y registros, table es un puntero al primernodo de la lista.

q = s = null; /* q se encuentra u n paso detrás de p; *//* se está dos pasos detrás de p */

for (p = table; p ! = null && k(p) ! = key; = p = next(p)) s = q;q = p;

/* fin de for */if (p == null)

return (p);/* Se ha encontrado el registro en la posición p. *//* Transponer los registros apuntados por p y q. */if (q == null)

/* la clave está en la primera posición de la tabla. *//* No se requiere la transposición */return (p);

/* transponer node(q) y node(p). */next(q) = next(p);next(p) = q;if (s = null) table = p

else next(s) = p;return (p);

Page 133: programacion2

Programación II

136

Obsérvese que las dos primeras instrucciones if en el algoritmo anterior pueden combinarsedentro de la instrucción simple if (p == null || q == null) return (p), para ser más conciso. Dejamoscomo ejercicio al lector la implementación del método de transposición para un vector y el método demoverse-al-frente.

Ambos métodos están basados en el fenómeno observado de que un registro que ha sidorecuperado tiene probabilidad de ser recuperado de nuevo. Adelantando dichos registros hacia elfrente de la tabla, las recuperaciones subsecuentes serán más eficientes.

5.5.6 La búsqueda en una tabla ordenada.Si la tabla se almacena en orden ascendente o descendente de las claves de los registros

pueden usarse varias técnicas para mejorar la eficiencia de la búsqueda. Esto es cierto en especial sila tabla es de tamaño fijo. Una ventaja obvia de la búsqueda en un archivo ordenado se tienecuando la clave del argumento no está presente en el archivo. En el caso de un archivo ordenadosuponiendo que las claves argumentos están distribuidas de manera uniforme sobre el rango declaves del archivo, se necesitan (en promedio) solo n/2 comparaciones. Esto ocurre porque sabemosque una clave está faltando en un archivo ordenado de manera ascendente tan pronto comoencontremos una clave que sea mayor que el argumento.

5.5.7 La búsqueda secuencial indexada.Hay otra técnica para perfeccionar la eficiencia de la búsqueda en un archivo ordenado, pero

involucra un incremento en la cantidad de espacio requerido. Este método se llama método debúsqueda secuencial indexada. Se aparta una tabla auxiliar, llamada index además del propio archivoordenado. Cada elemento en el index consta de una clave kindex y un puntero al registro del archivoque corresponde a kindex. Los elementos en el índice tanto como los elementos en el archivo, tienenque estar ordenados de acuerdo con las claves. Si el índice es un octavo de las claves del archivo,cada octavo registro del archivo tiene que estar representado en el índice. Esto se ilustra en la figurasiguiente.

Page 134: programacion2

Algoritmos avanzados de ordenación y búsqueda.

137

LQGLFH

NLQGH[ SLQGH[

N

FODYH

U

5HJLVWUR

Figura 5.18. Un archivo secuencial indexado.

El algoritmo usado para buscar en un archivo secuencial indexado es de forma directa. Seanr, k y key definidas como ante, kindex un vector de claves del índice y pindex un vector de punterosdentro del índice que apuntan a los registros reales del archivo. Supongamos que el archivo estáordenado en un vector, que n es el tamaño y que indxsize es el tamaño del índice.

for ( i = 0; < indxsize && kindex(i) <= key; i++);

if (i == 0) lowlim = 0else lowlim = pindex (i – 1);

if (i == indxsize) hilim = n – 1else hilim = pindex(i) – 1;

for (j = lowlim; j <= hilim && k(j) != key; j++);

return ((j > hilim) ? –1 : j);

Obsérvese que en el caso de registros múltiples con registros múltiples con la misma clave, elalgoritmo anterior no da como resultado un puntero al primero de tales registros en todos los casos.

Page 135: programacion2

Programación II

138

La ventaja real del método secuencial indexado es que los elementos de la tabla puedenexaminarse de manera secuencial sin que todos los registros del archivo sean accedidos, sinembargo el tiempo de búsqueda de un elemento en particular se reduce de forma considerable. Seejecuta una búsqueda secuencial en el índice, que es menor que la tabla. Una vez que se haencontrado la posición correcta en el índice se ejecuta una segunda búsqueda secuencial sobre unaporción menor de la propia tabla de registros.

El uso de un índice es aplicable a una tabla ordenada almacenada tanto como una lista ligadaque como un vector. Usar una lista ligada implica una gran sobrecarga de espacio para punterosaunque las inserciones y eliminaciones pueden ejecutarse con mucha mayor facilidad.

Si la tabla es tan grande que incluso el uso de un índice no alcanza suficiente eficiencia (yasea porque el índice es extenso con el objetivo de reducir la búsqueda secuencial en la tabla o que elíndice es pequeño de manera que las claves adyacentes del índice están muy alejadas una de otraen la tabla), se puede usar un índice secundario. El índice secundario actúa como un índice al índiceprimario que apunta a la entrada de la tabla secuencial. Esto se ilustra en la figura 5.19.

Page 136: programacion2

Algoritmos avanzados de ordenación y búsqueda.

139

NFODYH

U

5HJLVWUR

LQGLFHVHFXQGDULR

LQGLFHSULPDULR

WDEODVHFXHQFLDO

Figura 5.19. Uso de un índice secundario.

Las eliminaciones de una tabla secuencial indexada se pueden hacer con mayor facilidadetiquetando las entradas eliminadas. En la búsqueda secuencial a lo largo de la tabla, se ignoran lasentradas eliminadas. Obsérvese que si se elimina un elemento, incluso si su clave está en el índice,nada tiene que hacer al índice; sólo se etiqueta la entrada de la tabla original.

Page 137: programacion2

Programación II

140

La inserción en una tabla secuencial indexada es más difícil, dado que puede no haberespacio entre dos entradas de la tabla ya existentes, necesitándose así un desplazamiento de ungran número de elementos de la tabla. Sin embargo, si ha sido etiquetado un elemento cercano en latabla cuando se eliminó, se necesitará recorrer sólo unos pocos y escribir sobre el elementoeliminado. Esto puede requerir de una alteración del índice si se recorre un elemento apuntado por unelemento del índice. Un método alternativo es mantener un área de desborde en alguna otralocalización y ligarla a cualquier registro insertado. Sin embargo, esto requeriría un campo punteroextra en cada registro de la tabla original. Dejamos como ejercicio el estudio de esas posibilidades.

5.5.8 Búsqueda binaria.El método de búsqueda más eficiente en una tabla secuencial sin usar índices o tablas

auxiliares, es el de búsqueda binaria. Básicamente, se compara el argumento con la clave delelemento medio de la tabla. Si son iguales, la búsqueda termina con éxito; en caso contrario se buscade manera similar en la mitad superior o inferior de la tabla.

La mejor manera de definir la búsqueda binaria es la forma recursiva. Sin embargo la recargaasociada a la recursividad puede hacerla inapropiada para su uso en situaciones prácticas en lascuales la eficiencia se considera primordial. En consecuencia, presentamos la siguiente versión norecursiva del algoritmo de búsqueda binaria:

low = 0;hi = n – 1;while (low <= hi)

mid = (low + hi)/2;if (key == k(mid))

return (mid);if (key < k(mid))

hi =mid – 1;else

low = mid + 1; /* fin de while */return (-1);

Cada comparación en la búsqueda binaria reduce el número de posibles candidatos en unfactor de dos. Así, el número máximo de comparaciones de claves es de manera aproximada lg n. (En realidad es 2*lg n dado que en C, se hacen cada vez dos comparaciones de claves a través delciclo: key == k(mid) y key <k(mid) ). Así, podemos decir que el algoritmo de búsqueda binaria es O(lgn).

Obsérvese que la búsqueda binaria se puede usar en conjunción con la organizaciónsecuencial indexada de la tabla mencionada antes. En lugar de buscar de buscar en el índice demanera secuencial, se puede usar una búsqueda binaria. La búsqueda binaria también puede usarseal buscar en la tabla principal una vez que los dos registros frontera hayan sido identificados. Sinembargo, es probable que el tamaño de este segmento de tabla sea tan pequeño que la búsquedabinaria no sea más ventajosa que una búsqueda secuencial.

Por desgracia, el algoritmo de búsqueda binaria solo puede usarse si la tabla estáalmacenada como un vector. Esto ocurre porque hace uso del hecho de que los índices de loselementos del vector son enteros consecutivos. Por esta razón, la búsqueda binaria es prácticamenteinútil en situaciones donde, se requieran muchas eliminaciones e inserciones.

Page 138: programacion2

Algoritmos avanzados de ordenación y búsqueda.

141

5.5.9 Búsqueda por inte rpolación.Otra técnica para buscar en un vector ordenado es la llamada búsqueda por interpolación. Si

las claves están distribuidas de manera uniforme entre k(0) y k(n-1), el método puede ser aún máseficiente que la búsqueda binaria.

En principio, como en la búsqueda binaria, low se hace 0 y high se hace n-1, y a través delalgoritmo, se sabe que la clave argumento key está entre k(low) y k(high). Suponiendo que las clavesestán distribuidas de manera uniforme entre esos dos valores, se esperaría que key estuviese deforma aproximada en la posición:

mid = low + (high – low) * ((key – k(low)/(k(high) – k(low)))

Si key es menor que k(mid) haga high igual a mid-1; si es mayor, haga low igual a mid+1. Repetir elproceso hasta que la clave haya sido encontrada o low > que high.

En efecto si las claves están distribuidas de manera uniforme a lo largo del vector labúsqueda por interpolación requiere un promedio de lg(lg n) comparaciones y es raro que requieramuchas más, comparado con la búsqueda binaria que requiere lg n. Sin embargo, si las claves noestán distribuidas de manera uniforme, la búsqueda por interpolación puede tener un comportamientopromedio muy pobre. En el peor de los casos, el valor de mid puede ser igual a low+1 o hign-1 encuyo caso la búsqueda por interpolación degenera en búsqueda secuencial. En situaciones prácticaslas claves tienden con frecuencia a aproximarse en torno a ciertos valores y no están distribuidas deforma uniforme. Por ejemplo hay más nombres que comiencen por s que por q. En situaciones talesla búsqueda binaria es muy superior a la interpolación.

Sin embargo las mayorías de las computadoras, los cálculos requeridos por la búsqueda deinterpolación son muy lentos, dado que involucran aritmética con las claves y multiplicaciones ydivisiones complicadas. La búsqueda binaria requiere sólo aritmética en números enteros y divisionesentre dos que puede ejecutarse de manera eficiente recorriendo un bit a la derecha. Así, losrequerimientos computacionales de la búsqueda por interpolación, ocasionan con frecuencia que estase ejecute más despacio que la búsqueda binaria, aún cuando la primera requiere menoscomparaciones.

Vamos a ver un ejemplo con números para que nos aclare todo esto. Si tenemos la siguientesecuencia de valores en un vector:

Posición 0 1 2 3 4 5 6 7 8 9

Valor 1 2 3 4 5 6 7 8 9 10

y queremos buscar un elemento en el, por ejemplo el valor de clave 3, tendríamos lo siguiente:

• Valores iniciales de las variables:

low = 0

high = (10 – 1) = 9

key = 3

• calculamos mid con la fórmula anterior y obtenemos:

mid = 0 + (9 – 0) * ((3 – k(0) / (k(9) – k(0)))

Page 139: programacion2

Programación II

142

sustituimos los valores de las k, que son:

k(0) = 1

k(9) = 10

y obtenemos que mid = 0 + 9 * 2 / 9 = 2,

• comparamos el valor de k(2) con key (el buscado) y como son iguales acabamos.

Otro ejemplo podría ser buscar el valor 4, y para este tendríamos:

• Valores iniciales de las variables:

low = 0

high = (10 – 1) = 9

key = 4

• calculamos mid con la fórmula anterior y obtenemos:

mid = 0 + (9 – 0) * ((4 – k(0) / (k(9) – k(0)))

sustituimos los valores de las k, que para nuestro ejemplo son:

k(0) = 1

k(9) = 10

y obtenemos que mid = 0 + 9 * 3 / 9 = 3,

• comparamos el valor de k(3) con key (el buscado) y como son iguales acabamos.

El algoritmo que realizaría esto no es muy complicado, con lo cual se deja como ejercicio dellector su realización.

5.6 Arboles Binarios de Búsqueda.La búsqueda en árboles binarios es un método de búsqueda simple, dinámico y eficiente

considerado como uno de los fundamentales. De forma que terminología sobre árboles, tan solorecordar que la propiedad que define un árbol binario es que cada nodo tiene a lo más un hijo a laizquierda y otro a la derecha. Para construir los algoritmos consideremos que cada nodo contiene unregistro con un valor clave a través del cual efectuamos las búsquedas.

Un árbol binario de búsqueda es un árbol binario con la propiedad de que todos loselementos almacenados en el subárbol izquierdo de cualquier nodo x (incluyendo la raíz) sonmenores que el elemento almacenado en x, y todos los elementos almacenados en el subárbolderecho de x son mayores que el elemento almacenado en x.

En la figura siguiente se muestran dos árboles binarios de búsqueda construidos basándoseen el mismo conjunto de enteros.

Page 140: programacion2

Algoritmos avanzados de ordenación y búsqueda.

143

Figura 5.20. Árboles binarios de búsqueda.

Obsérvese la interesante propiedad de sí se listan los nodos del árbol binario de búsqueda eninorden (como vimos en el tema de TDA) nos da la lista de nodos ordenados. Esta propiedad defineun método de ordenación similar al Quicksort, con el nodo raíz jugando un papel similar al delelemento partición del Quicksort aunque con los árboles binarios de búsqueda hay un gasto extra dememoria debido a los punteros.

La propiedad de árbol binario de búsqueda hace que sea muy simple diseñar unprocedimiento para realizar la búsqueda. Para determinar si k está presente en el árbol lacomparamos con la clave situada en la raíz, r. Si coincide la búsqueda finaliza con éxito, si k<r esevidente que k, de estar presente, a de ser un descendiente del hijo izquierdo de la raíz, y si es mayorserá un descendiente de hijo derecho. La función puede ser codificada fácilmente de la siguienteforma, utilizando el TDA árbol binario visto en el capitulo anterior seria:

#define BINARIO_VACIO NULL#define NODO_NULO NULL

typedef int TEtiqueta;typedef struct tipo_celdaB

TEtiqueta etiqueta;struct tipo_celdaB * hizqda;struct tipo_celdaB * hdrcha;struct tipo_celdaB * padre; tipo_celdaB;

typedef tipo_celdaB * TNodoB;typedef TNodoB TArbolB;

Page 141: programacion2

Programación II

144

int pertenece(TEtiqueta e, TNodoB n, TArbolB T)

if (n == NODO_NULO) return (0);else if (e == n ->etiqueta) return (1);

else if (e < n -> etiqueta) return (pertenece(e, hizqdaB(n, T), T));else return (pertenece(e, hdrchaB(n, T), T));

Figura 5.21. Función pertenece para buscar un elemento en el árbol.

Es conveniente hacer notar la diferencia entre este procedimiento y el de búsqueda binaria.En éste podría pensarse que se utiliza un árbol binario para describir la secuencia de comparacioneshechas por una función de búsqueda en el vector. En cambio en los árboles binarios de búsqueda seconstruya una estructura de datos con registros conectados con punteros y se usa esta estructurapara la búsqueda.

El procedimiento de construcción de un árbol binario de búsqueda puede basarse en unprocedimiento de inserción que vaya añadiendo elementos al árbol. Tal procedimiento inserta(e, n, T)comenzaría mirando si T == BINARIO_VACIO y de ser así se crearía un nuevo árbol con un nodopara e y dejaría T apuntando hacia él. Si T no esta vacío se busca e como lo hace el procedimientopertenece, solo que al encontrar un puntero a NODO_NULO durante la búsqueda, se reemplaza porun puntero a un nodo nuevo que contenga e. El código podría ser el siguiente:

void inserta(TEtiqueta e, TNodoB n, TArbolB T)

TNodoB nodo;if (T == BINARIO_VACIO) T= CrearB(e, NULL, NULL);

else if (e < n->etiqueta)if (n->hizqda == NULL)

nodo = (TNodoB) malloc (sizeof (tipo_celdaB));if (!nodo)

printf (“error no hay memoria”);exit (1);

nodo->padre=n;nodo->hizqda=NULL;nodo->hdrcha=NULL;nodo->etiqueta=e;n->hizqda = nodo;

else inserta(e, n->hizqda, T);

else if (e > n->etiqueta)

if (n->hdrcha == NULL)nodo = (TNodoB) malloc (sizeof (tipo_celdaB));if (!nodo)

printf (“error no hay memoria”);exit (1);

nodo->padre=n;

Page 142: programacion2

Algoritmos avanzados de ordenación y búsqueda.

145

nodo->hizqda=NULL;nodo->hdrcha=NULL;nodo->etiqueta=e;n->hdrcha = nodo;

else inserta(e, n->hdrcha, T);

Figura 5.22. Procedimiento para rellenar un árbol binario de búsqueda.

El código se complica un poco por tener que introducir el valor del padre en el nuevo nodocreado. Se plantea como ejercicio al lector que defina un nuevo TDA árbol binario en el cual no setenga en cuenta el nodo padre, y a continuación diseñe la función pertenece y la de inserta.

Por ejemplo supongamos que queremos construir un árbol binario de búsqueda a partir delconjunto de enteros 10, 5, 14, 7, 12, 15, 18 aplicando reiteradamente el procedimiento de insertaanterior. El resultado sería:

Figura 5.23. Ejemplo de rellenado de un árbol binario de búsqueda usando el procedimiento inserta.

Page 143: programacion2

Programación II

146

5.7 Hashing.

5.7.1 Introducción.Una aproximación a la búsqueda radicalmente diferente a las anteriores consiste en proceder,

no por comparaciones entre valores clave, sino encontrando alguna función h(k) que nos dédirectamente la localización de la clave k en la tabla.

La primera pregunta que podemos hacernos es si es fácil encontrar tales funciones h. Larespuesta es, en principio, bastante pesimista, puesto que si tomamos como situación ideal el que talfunción dé siempre localizaciones distintas a claves distintas y pensamos por ejemplo en una tabla detamaño 40 en donde (alrededor de 1048) posibles funciones del conjunto de claves en la tabla, y solo40 * 39 * …… * 11 = 40! / 10! (alrededor de 2*1041) de ellas no generan localizaciones duplicadas. Enotras palabras solo 2 de cada 10 millones de tales funciones serían “perfectas” para nuestrospropósitos y encontrarlas no parece una cuestión trivial.

Las funciones que evitan valores duplicados son sorprendentemente difíciles de encontrar,incluso para tablas pequeñas. Por ejemplo, la famosa “paradoja del cumpleaños” asegura que si enuna reunión están presentes 23 o más personas, hay bastante posibilidad de que dos de ellas hayannacido el mismo día del mismo mes. En otras palabras, si seleccionamos una función aleatoria queaplique 23 claves a una tabla de tamaño 365 la probabilidad de que dos claves no caigan en la mismalocalización es de solo 0.4927.

En consecuencia, las aplicaciones h(k), que podemos llamar desde ahora funciones hash,tiene la particularidad de que podemos esperar que h(ki) = h(kj) para bastantes pares distintos (ki, kj).El objetivo será pues encontrar una función hash que provoque el menor número posible decolisiones6, aunque esto es solo un aspecto del problema, el otro será el de diseñar métodos deresolución de colisiones cuando éstas se produzcan.

5.7.2 Funciones hash.El primer problema que hemos de abortar es el cálculo de la función hash que transforme

claves en localizaciones de la tabla. Más precisamente necesitamos una función que transformeclaves (normalmente enteros o cadenas de caracteres) en enteros en un rango [0 .. M-1], donde M esel número de registros que podemos manejar con la memoria de que dispongamos. Como factores atener en cuenta para la elección de la función h(k) están que minimice las colisiones y que searelativamente rápida y fácil de calcular, aunque la situación ideal seria encontrar un h que generaravalores aleatorios uniformemente sobre el intervalo [0 .. M-1]. Las dos aproximaciones que veremosestán encaminadas hacia este objetivo y ambas están basadas en generadores de númerosaleatorios.

Hashing multiplicativo.Esta técnica trabaja multiplicando la clave k por si misma o por una constante, usando

después alguna porción de los bit del producto como una localización de la tabla hash.

6 Ocurrencias de sinónimos a la hora de obtener la posición que tienen que ocupar en la tabla.

Page 144: programacion2

Algoritmos avanzados de ordenación y búsqueda.

147

Cuando la elección es multiplicar k por si misma y quedarse con algunos de los bits centrales,el método se denomina del cuadrado medio. Este método aún siendo simple y pudiendo cumplir elcriterio de que los bits elegidos marcar la localización son función de todos los bits originales de k,tiene como principales inconvenientes el que las claves con muchos ceros se reflejarán en valoreshash también con muchos ceros, y el que el tamaño de la tabla está restringido a ser una potencia de2.

Otro método multiplicativo, que evita las restricciones anteriores consiste en calcular h(k)=Int[M * Frac(C*k)] donde M es el tamaño de la tabla y 0 < C < 1, siendo importante elegir C concuidado para evitar efectos negativos como que una clave alfabética k sea sinónima a otras clavesobtenidas permutando los caracteres de k. Knuth [Knu87] prueba que un valor recomendable esC=1/R con R=(1/2)(1+sqrt(5)).

Hashing por división.En este caso la función has se calcula simplemente como:

h(k) = k mod M

usando el 0 como primer índice de la tabla hash de tamaño M.

Aunque la fórmula es aplicable a tablas de cualquier tamaño, es importante elegir el valor deM con cuidado. Por ejemplo si M fuera par, todas las claves pares (resp. impares) serían aplicadas alocalizaciones pares (resp. impares), lo que constituiría un sesgo muy fuerte. Una regla simple paraelegir M es tomarlo como un número primo. En cualquier caso existen reglas más sofisticadas para laelección de M [Knu87], basadas todas en estudios teóricos de funcionamiento de los métodoscongruenciales de generación de números aleatorios.

5.7.3 Resolución de coli siones.El segundo aspecto importante a estudiar es el hashing es la resolución de colisiones entre

sinónimos. Estudiaremos tres métodos básicos de resolución de colisiones, uno de ellos depende dela idea de mantener listas enlazadas de sinónimos, y los otros dos del calculo de una secuencia delocalizaciones en la tabla hash hasta que se encuentre una vacía. El análisis comparativo de losmétodos de hará en base al estudio del número de localizaciones que han de examinarse hastadeterminar dónde situar cada nueva clave en la tabla.

Para todos los ejemplos el tamaño de la tabla será M = 13 y las definiciones de tipos queadoptaremos son:

typedef struct item_hash int clave;tipo_ele dato;struct item_hash *siguiente;

;typedef struct item_hash tabla_hash[13];

La función hash h1(k) que utilizaremos será:

HASH = Clave mod M

y los valores de la clave kj que consideraremos son los expuestos en la tabla siguiente:

Page 145: programacion2

Programación II

148

j k j h1(kj) j k j h1(kj)

1 119 2 7 109 5

2 85 7 8 147 4

3 43 4 9 38 12

4 141 11 10 137 7

5 73 7 11 148 5

6 91 0 12 101 10

Suponiendo que k = 0 no ocurre de forma natural, podemos marcar todas las localizacionesde la tabla, inicialmente vacías, dándoles el valor 0. Finalmente, y puesto que las operaciones debúsqueda e incluso de inserción están muy relacionadas, se presentarán algoritmos para buscar y unitem insertándolo si es necesario (salvo que esta operación ocasione un desbordamiento de la tabla)devolviendo la localización del item o un –1 en caso de desbordamiento.

EncadenamientoLa manera más simple de resolver una colisión es construir, para cada localización de la

tabla, una lista enlazada de registros cuyas claves caigan en esa dirección. Este método se conocenormalmente con el nombre de encadenamiento separado y obviamente la cantidad de tiemporequerido para una búsqueda dependerá de la longitud de las listas y de las posiciones relativas delas claves en ellas. Existen variantes dependiendo del mantenimiento que hagamos de las listas desinónimos (FIFO, LIFO, por valor clave, etc), aunque en la mayoría de los casos, y dado que las listasindividuales no han de tener un tamaño excesivo, se suele optar por la alternativa más simple, laFIFO.

En cualquier caso, si las listas se mantienen en orden esto puede verse como unageneralización del método de búsqueda secuencial de listas. La diferencia es que en lugar demantener una sola lista, con un solo nodo cabecera se mantienen M listas con M nodos cabecera detal forma que se reduce el número de comparaciones de la búsqueda secuencial en un factor M (enmedia) usando espacio extra para M punteros.

Para nuestro ejemplo, y con la alternativa LIFO, la tabla quedaría:

Page 146: programacion2

Algoritmos avanzados de ordenación y búsqueda.

149

A veces, y cuando en número de entradas es la tabla es relativamente moderado, no esconveniente dar a las entradas de la tabla hash el papel de cabecera de listas, lo que nos conduciríaa otro método de encadenamiento, conocido cono encadenamiento interno. Es este caso, la uniónentre sinónimos está dentro de la propia tabla hash, vía campos cursores (punteros) que nosinicializados a –1 y que irán apuntando hacia sus sinónimos respectivos.

Direccionamiento abierto.Si se tiene una estimación del número de elemento a colocar en la tabla, y no hay problema

con la memoria disponible, podrían ponerse todas las claves en la propia tabla y no usar listasenlazadas. Son los métodos de direccionamiento abierto. La idea general es inspeccionar unasucesión de localizaciones de la tabla hasta que se encuentre la clave buscada o se localice un lugarvacío.

La primera técnica para hacerlo es simplemente comenzar en la localización h1(k) y examinarsecuencialmente las restantes (mod M). Es el llamado hashing lineal. La secuencia de pruebas podríaexpresarse como:

hi(k) = (h1(k) + (i-1)) mod M i=2, 3,….

En nuestro ejemplo, después de insertar las 7 primeras claves nos aparece la Tabla 5.4.Cuando hemos de situar la clave 147, la insertamos en la localización 6, (Tabla 5.5) después de quetal falle el test de encontrar lugares vacíos en las localizaciones 4, 5 y 7, 8, y después de la inserción,esos dos grupos se han combinado en una gran agrupación primaria, de modo que cualquier claveque haya de ser colocada en localizaciones cercanas al principio de tal agrupación requiereinevitablemente un número relativamente grande de pruebas, con el agravante de que este fenómenoes cada vez peor cuanto más crece el tamaño de los grupos. Para solucionar el problema y que elhashing trabaje bien necesitamos tener los agujeros distribuidos aleatoriamente, pero el hashinglineal propaga agrupaciones primarias que rompen esta propiedad. Al final cuando se han insertadotodas las claves la tabla resultante es la Tabla 5.6, donde al lado de cada entrada aparece el númerode pruebas requeridas para su inserción.

i clave

0 91

1

2 119

3

4 43

5 109

6

7 85

8 72

9

10

11 141

12

Tabla 5.4.

i clave

0 91

1

2 119

3

4 43

5 109

6 147

7 85

8 72

9

10

11 141

12

Tabla 5.5.

i clave intentos

0 91 1

1 101 5

2 119 1

3 0

4 43 1

5 109 1

6 147 3

7 85 1

8 72 2

9 137 3

10 148 6

11 141 1

12 38 1

Tabla 5.6.

Page 147: programacion2

A primera vista, podríamos intentar resolver el problema de los agrupamientos primariosintentando la secuencia de pruebas:

hi(k) = (h1(k) +(i-1) * C) mod M C > 1 primo relativo con M

pero aunque esto evitaría la formación de agrupaciones primarias, no solventaría el problema de laformación de agrupaciones secundarias ( agrupaciones separadas por una distancia C). El problemabásico del hashing lineal es que cualesquiera dos claves con el mismo valor hash, utilizarían unasecuencia idéntica de pruebas (como cuando caminamos en la playa sobre las huellas de otrapersona), y lo ideal sería que tal secuencia de pruebas fuera verdaderamente aleatoria, aunquedesde un punto de vista práctico, buscamos una secuencia fácil de calcular y lo suficientementemezclada para evitar la formación de agrupaciones, con la propiedad adicional obvia de que ha depoder acceder a todas las localizaciones de la tabla, puesto que ésta podría eventualmente llenarse.

Un método de cumple los requisitos anteriores es el denominado hashing doble. Estemétodo, al producirse una colisión usando nuestra habitual función hash, la secuencia de pruebaspara el hashing viene determinada por:

hi(k) = (hi-1(k) + h0(k)) mod M i=2, 3,….

donde h0 no debería ser cero y sería conveniente que fuera primo relativo con el tamaño de la tablagarantizando en acceso a todas las localizaciones. En la práctica suele usarse

h0(k) = 1 + k mod (M – 2)

Esta forma de hashing doble es particularmente buena cuando M y M – 2 son primosrelativos.

El resultado de aplicar el método a nuestro ejemplo puede verse en las tablas 5.7 y 5.8. En laprimera se incluyen los valores de h0 para cada clave y en la segunda pueden verse laslocalizaciones finales de las claves en la tabla así como las pruebas requeridas para su inserción.

La función para realizar el hashing doble esta en la figura 5.24 donde h0(k) se implementacomo INCR = 1 + clave mod (M - 2). Obsérvese el uso de una variable total para hacer unseguimiento del número de entradas. En particular si no se permite a total exceder a M-1, segarantiza que siempre habrá una localización vacía para forzar la terminación de bucle while en elcaso de que una clave no esté en la tabla.

kj h1(kj) h0(kj)

119 2 10

85 7 9

43 4 11

141 11 10

72 7 7

91 0 4

109 5 11

147 4 5

38 12 6

137 7 6

148 5 6

101 10 3

12 38 1

Tabla 5.7.

i clave intentos

0 91 1

1 72 2

2 119 1

3 101 3

4 43 1

5 109 1

6 137 3

7 85 1

8

9 147 2

10 148 4

11 141 1

12 38 1

Tabla 5.8.

Page 148: programacion2

Algoritmos avanzados de ordenación y búsqueda.

151

int hash_doble( int arg, tabla_hash tabla)int encontrado;nodo_hash total, i, j;encontrado = 0;i = hash (arg);j = INCR (arg);while ((tabla[i].clave != 0) && (encontrado = 0))

if (encontrado = 0) if ( total = M – 1) hash_doble = -1;

else total = total +1;tabla[i].clave = arg;

Figura 5.24. Función hash doble.

5.7.4 Borrados y rehash ing.Cuando intentamos borrar un valor ki de una tabla hash que ha sido generada por

direccionamiento abierto, nos encontramos con un problema. Si ki precede a cualquier otro valor kj enuna secuencia de pruebas, no podemos eliminarlo sin más, ya que si lo hiciéramos las pruebassiguientes para kj se encontrarían el “agujero” dejado por ki con lo que podríamos concluir que kj noestá en la tabla, hecho que puede ser falso. Podemos comprobarlo en nuestro ejemplo en cualquierade las tablas. La solución es que necesitamos mirar cada localización de la tabla hash como inmersaen uno de tres posibles estados: vacía, ocupada o borrada, de forma que en lo que concierne a labúsqueda, una celda borrada se trata exactamente igual que una ocupada. En el caso de inserciones,podemos usar la primera localización vacía o borrada que se encuentre en la secuencia de pruebaspara realizar la operación. Observemos que este problema no afecta a los borrados de la lista en elencadenamiento separado. Para la implementación de la idea anterior podría pensarse en laintroducción de algoritmos de un valor etiqueta para marcar las casillas borradas, pero esto sería solouna solución parcial ya que quedaría el problema de si los borrados son frecuentes, las búsquedassin éxito podrían requerir O(M) pruebas para detectar que un valor no está presente.

Cuando una tabla hash llega a un desbordamiento o, cuando su eficiencia baja demasiadodebido a los borrado, el único recurso es llevarla a otra tabla de tamaño más apropiado, nonecesariamente mayor, puesto que como las localizaciones borradas no tienen porque reasignarse, lanueva tabla podría ser mayor, menor, o incluso del mismo tamaño que la original. Este proceso sesuele denominar rehashing y es simple de implementar si el área de la nueva tabla es distinta al de laprimitiva, pero puede complicarse bastante si deseamos hacer un rehashing en la propia tabla.

5.8 Bibliografía.

• [Joy90] Joyanes Aguilar, L. “Fundamentos de programación. Algoritmos y estructuras de datos.”MacGraw Hill, 1990.

Page 149: programacion2

Programación II

152

• [Joy90] Joyanes Aguilar, L. “Problemas de metodología de la programación”. MacGraw Hill, 1990.

• [Knu73] Kunth D. E. “Then Art of Computer Programming.” Vol. 3: Sorting and Searching.Addison-Wesley, 1973.

• [Knu87] Knuth D. E. “Clasificación y búsqueda”, Reverté, 1987.

• [SF90] Springer, G. Y Friedman, D. P. “Scheme and the art of programming”, MacGraw Hill,1990.

• [AYM93] Aaron M. Tenenbaum, Yedidyah Langsam, Moshe A. Augenstein. “Estructura de datosen C”, Prentice Hall Hispanoamericana S.A., 1993.

• [Aho83] Aho, A. V. Hopcroft, J. E., Ullman, J. A. “Data Structures and algorithms”. Addison-Wesley, ,1983.

• [Sed87] Sedgawick, R. “Algorithms” Addison-Wesley, 1987

• [Smi87] Smith, H. F., “Data structures. Form and function.”, Harcourt Brace JovanovichPublishers, 1987.

• [Wir76] Wirth, N., “Algorithms + data structures = programs”, Prentice Hall, 1976.

Page 150: programacion2

Capítulo 6: Métodos de prueba del software.

La prueba del software es un elemento crítico para la garantía de calidad del software yrepresenta una revisión final de las especificaciones, del diseño y de la codificación.

La importancia de los “costes” asociados a un fallo está motivando la creación de pruebasminuciosas y bien planificadas. No es raro que una organización de desarrollo de software empleeentre el 30 y 40 por ciento del esfuerzo total de un proyecto en la prueba.

El diseño de casos de prueba se centra en un conjunto de técnicas para la creación de casosde prueba que satisfagan los objetivos globales de la prueba.

El principal objetivo del diseño de casos de prueba es obtener un conjunto de pruebas quetengan la mayor probabilidad de descubrir los efectos del software. Para llevar a cabo este objetivo,se usan dos categorías diferentes de técnicas de diseño de casos de prueba: prueba de caja blanca yprueba de caja negra.

6.1 FUNDAMENTOS DE LA PRUEBA DEL SOFTWARE.La prueba presenta una interesante contrariedad para el ingeniero de software.

Durante las fases anteriores de definición y de desarrollo, el ingeniero intenta construir el softwarepartiendo de un concepto abstracto y llegando a una implementación tangible. A continuación, llega laprueba. El ingeniero crea una serie de casos de prueba que intentan “demoler” el software construido.De hecho la prueba es uno de los pasos de la ingeniería del software, que se puede ver comodestructivo en lugar de constructivo.

6.1.1 Objetivos de la prueba.Se pueden establecer varias normas que pueden servir acertadamente como

objetivos de la prueba:

1. La prueba es un proceso de ejecución de un programa con la intención de descubrir unerror.

2. Un buen caso de prueba7 es aquel que tiene una alta probabilidad de mostrar un error nodescubierto hasta entonces.

3. Una prueba tiene éxito si descubre un error no detectado hasta entonces.

Nuestro objetivo es diseñar pruebas que sistemáticamente saquen a la luz diferentes clasesde errores, haciéndolo con la menor cantidad de tiempo y de esfuerzo.

Si la prueba se lleva a cabo con éxito, descubrirá errores en el software. Como ventajasecundaria, la prueba demuestra hasta qué punto el software parece funcionar de acuerdo con lasespecificaciones y parecen alcanzarse los requisitos de rendimiento. Los datos que se vanrecogiendo a medida que se lleva a cabo la prueba proporcionan una buena indicación de la fiabilidaddel software y, de alguna manera, indican la calidad del software como un todo. Sin embargo, hayuna cosa que no puede hacer la prueba:

la prueba no puede asegurar la ausencia de defectos, sólo puede demostrar que existendefectos en el software.

7 Diferentes ordenes a realizar, para probar que nuestro programa realiza lo esperado.

Page 151: programacion2

Programación II

154

6.1.2 Principios de la prueba.Los principios básicos que guían las pruebas del software, según Davis [Dav95] serian:

• A todas las pruebas se les debería poder hacer un seguimiento hasta losrequisitos del cliente.

• Las pruebas deberían planificarse mucho antes de que empiecen.

• El principio de Pareto es aplicable a la prueba del software. El principio de Paretoimplica que al 80 por ciento de todos los errores descubiertos durante las pruebassurgen al hacer un seguimiento de sólo el 20 por ciento de todos los módulos delprograma.

• Las pruebas deberían empezar por “lo pequeño” y progresar hacia “lo grande”.Las primeras pruebas planeadas y ejecutadas se centran generalmente enmódulos individuales del programa. A medida que avanzan las pruebasdesplazan su punto de mira en un intento de encontrar errores en grupointegrados de módulos y finalmente en el sistema entero.

• No son posibles las pruebas exhaustivas.

• Para ser más efectivas, las pruebas deberían ser conducidas por un equipoindependiente.

Los desarrolladores de software experimentados dicen que “la prueba nunca termina,simplemente se transfiere de usted al cliente.

6.1.3 Facilidad de prueba.Un ingeniero del software diseña un programa de computadora, un sistema, o un producto

con la “facilidad de prueba” en mente. Esto permite a los encargados de las pruebas diseñar casos depruebas más fácilmente, James Bach8 describe la facilidad de prueba de la siguiente manera:

La facilidad de prueba del software es simplemente lo fácil que se puede probar un programade computadora. Como la prueba es tan profundamente difícil, merece la pena saber qué se puedehacer para hacerlo más sencillo. A veces los programadores están dispuestos a hacer cosas quefaciliten el proceso de prueba y una lista de comprobación de posibles puntos de diseño,características, etc., puede ser útil a la hora de negociar con ellos.

La siguiente lista de comprobación proporciona un conjunto de características que llevan a unsoftware fácil de probar.

• Operatividad . “ Cuanto mejor funcione, más eficientemente se puede probar”.

• Observabilidad . “Lo que ves es lo que pruebas”.

• Controlabilidad . “Cuanto mejor podamos controlar el software, más se puedeautomatizar y optimizar.

• Capacidad de descomposición . “ Controlando el ámbito de las pruebas, podemos aislarmás rápidamente los problemas y llevar a cabo mejores pruebas de regresión”.

8 Los siguientes párrafos son copyright 1994 de James Bach, y se han adaptado de su página deInternet.

Page 152: programacion2

Métodos de prueba del software.

155

• Simplicidad . “ Cuanto menos haya que probar, más rápidamente podremos probarlo”.

• Estabilidad . “Cuanto menos cambios, menos interrupciones a las pruebas”.

• Facilidad de compresión. “ Cuanta más información tengamos, más inteligentes seránlas pruebas”.

Los atributos sugeridos por James Bach los puede emplear el ingeniero del software paradesarrollar una configuración del software que pueda probarse.

Kaner, Falk y Nguyen [KFN93] sugieren los siguientes atributos de una “buena” prueba:

1. Una buena prueba tiene una alta probabilidad de encontrar un error.

2. Una buena prueba no debe ser redundante.

3. Una buena prueba debería ser “la mejor de la cosecha”.

4. Una buena prueba no debería ser ni demasiado sencilla ni demasiado compleja.

6.2 DISEÑO DE CAS OS DE PRUEBA.El diseño de pruebas para el software puede requerir tanto esfuerzo como el propio diseño

inicial del producto. Los ingenieros del software, a menudo tratan la prueba como algo sinimportancia, desarrollando casos de prueba que “parezcan adecuados”, pero que tienen pocagarantía de ser completos. Recordando el objetivo de la prueba, debemos diseñar pruebas quetengan la mayor probabilidad de encontrar el mayor número de errores con la mínima cantidad deesfuerzo y tiempo posible.

Cualquier producto de ingeniería puede comprobarse de una de estas dos formas:

1. conociendo la función específica para la que fue diseñado el producto, y al mismotiempo buscando errores en cada función;

2. conociendo el funcionamiento del producto, se pueden desarrollar pruebas queaseguren que “todas las piezas encajan”. El primer enfoque de prueba se denominaprueba de caja negra y el segundo, prueba de caja blanca.

Cuando se considera el software de computadora, la prueba de caja negra se refiere a laspruebas que se llevan a cabo cobre la interfaz del software. O sea, los casos de prueba pretendendemostrar que las funciones del software son operativas, que la entrada se acepta de formaadecuada y que se produce un resultado correcto, así como que la integridad de la informaciónexterna se mantiene. Una prueba de caja negra examina algunos aspectos del modelo fundamentaldel sistema sin tener en cuenta la estructura lógica interna del software.

La prueba de caja blanca del software se basa en el minucioso examen de los detallesprocedimentales. Se comprueban los caminos lógicos del software proponiendo casos de prueba queejerciten conjuntos específicos de condiciones y/o bucles.

A primera vista parecería que una prueba de caja blanca muy profunda nos llevaría a tener“programas cien por cien correctos”. Todo lo que tenemos que hacer es definir todos los caminoslógicos, desarrollar casos de prueba que los ejerciten y evaluar los resultados; o sea, generar casosde prueba que ejerciten exhaustivamente la lógica del programa. Desgraciadamente, la pruebaexhaustiva presenta ciertos problemas logísticos. Incluso para pequeños programas, el número decaminos lógicos posibles puede ser enorme.

Page 153: programacion2

Programación II

156

La prueba de caja blanca, no se debe desechar como impracticable. Se puede elegir yejercitar una serie de caminos lógicos importantes. Se pueden comprobar las estructuras de datosmás importantes para su validez.

Se pueden combinar los atributos de la prueba de caja blanca sí como los de caja negra, parallegar a un método que valide la interfaz del software y asegure selectivamente que el funcionamientointerno del software es correcto.

6.3 PRUEBA DE CAJ A BLANCA.La prueba de caja blanca, denominada a veces prueba de caja de cristal es un método de

diseño de casos de prueba que usa la estructura de control del diseño procedimental para obtener loscasos de prueba que:

1. garanticen que se ejercita por lo menos una vez todos los caminos independientes decada módulo;

2. ejerciten todas las decisiones lógicas en sus vertientes verdadera y falsa;

3. ejecuten todos los bucles en sus límites y con sus límites operacionales, y

4. ejerciten las estructuras de datos para asegurar su validez.

¿Por qué emplear tiempo y energía preocupándose de (y probando) las minuciosidadeslógicas cuando podríamos emplear mejor el esfuerzo asegurando que se han alcanzado los requisitosdel programa? La respuesta se encuentra en la naturaleza misma de los defectos del software:

• Los errores lógicos y las suposiciones incorrectas son inversamente proporcionales a laprobabilidad de que se ejecute un camino del programa. Los errores tienden aintroducirse en nuestro trabajo cuando diseñamos e implementamos funciones,condiciones o controles que se encuentran fuera de lo normal. El procedimiento habitualtiende a hacerse más comprensible, mientras que el procesamiento de casos especialestiende a caer en el caos.

• A menudo creemos que un camino lógico tiene pocas posibilidades de ejecutarse cuando,de hecho, se puede ejecutar de forma normal. El flujo lógico de un programa a veces noes nada intuitivo, lo que significa que nuestras suposiciones intuitivas sobre el flujo decontrol y los datos nos pueden llevar a tener errores de diseño que sólo se descubrencuando comienza la prueba del camino.

• Los errores tipográficos son aleatorios. Cuando se traduce un programa a código fuenteen un lenguaje de programación, es muy probable que se den algunos errores deescritura. Muchos serán descubiertos por los mecanismos de comprobación de sintaxis,pero otros permanecerán sin detectar hasta que comience la prueba.

Cada una de las razones nos da un argumento para llevar a cabo las pruebas de caja blanca.La prueba de caja negra, sin tener en cuenta cómo sea de completa, puede pasar por alto los tipos deerrores que acabamos de señalar. Como estableció Beizer: “Los errores se esconden en los rinconesy se aglomeran en los límites”. Es mucho más fácil descubrirlos con la prueba de caja blanca.

Las pruebas de caja blanca se centran en la estructura de control del programa. Se obtienencasos de prueba que aseguren que durante la prueba se han ejecutado por lo menos una vez todaslas sentencias del programa y que se ejercitan todas las condiciones lógicas.

Page 154: programacion2

Métodos de prueba del software.

157

6.3.1 Prueba del camino básico.El método del camino básico permite al diseñador de casos de prueba obtener una medida de

a complejidad lógica de un diseño procedimental y usar esa medida como guía para la definición deun conjunto básico de caminos de ejecución.

Los casos de prueba obtenidos del conjunto básico garantizan que durante la prueba seejecuta por lo menos una vez cada sentencia del programa.

Notación de grafo de flujo.El grafo de flujo presenta el flujo de control lógico mediante la notación ilustrada en la figura

6.1. Cada construcción estructurada tiene su correspondiente símbolo en el grajo de flujo.

Para ilustrar el uso de un grafo de flujo, consideremos la representación del diseñoprocedimental en la figura 6.3a. En la figura 6.3b, cada círculo, denominado nodo del grafo de flujo,representa una o más sentencias procedimentales. Un solo nodo puede corresponder a unasecuencia de cuadros de proceso y a un rombo de decisión. Las flechas del grafo de flujo,denominadas aristas o enlaces, representan flujos de control y son análogas a las flechas deldiagrama de flujo. Una arista debe terminar en un nodo, incluso aunque el nodo no representeninguna sentencia procedimental. Las áreas delimitadas por aristas y nodos se denominan regiones.Cuando contabilizamos las regiones incluimos el área exterior del grafo contando como otra regiónmás.

Cualquier representación del diseño procedimental se puede traducir a un grafo de flujo. En lafigura 6.5 se muestra un segmento de código en algorítmica y su correspondiente grafo de flujo. Sepuede observar que se han numerado las sentencias algorítmicas y que en el grafo de flujo se usa lamisma numeración.

Cuando en un diseño procedimental se encuentran condiciones compuestas la generacióndel grafo de flujo se hace un poco más complicada. Una condición compuesta se da cuando aparecenuno o más operadores lógicos (or, and) en una sentencia condicional.

En la figura 6.4 el segmento en algorítmico se traduce en un grafo de flujo anexo. Se crea unnodo aparte para cada una de las condiciones a y b de la sentencia SI a or b. Cada nodo quecontiene una condición se denomina nodo predicado y está caracterizado porque dos o más aristasemergen de él.

6HFXHQFLD

&RQGLFLyQ ,)

6HOHFWLYD P~OWLSOH &DVH

%XFOH :KLOH

%XFOH +DVWD

&DGD FLUFXOR UHSUHVHQWD XQD R PiV VHQWHQFLDV VLQ ELIXUFDFLRQHVHQ FyGLJR IXHQWH

Page 155: programacion2

Programación II

158

Figura 6.1. Notación para el grafo de flujo.

$

;%

;<

D

1RGRV

SUHGLFDGR

,I D 25 EWKHQ SURFHGLPLHQWR [HOVH SURFHGLPLHQWR \

HQGLI

Figura 6.2. Lógica compuesta.

Complejidad ciclomáticaEl valor calculado como complejidad ciclomática define el número de caminos

independientes del conjunto básico de un programa y nos da un límite superior para el número depruebas que se deben realizar para asegurar que se ejecuta cada sentencia al menos una vez.

Un camino independiente es cualquier camino del programa que introduce por lo menos unnuevo conjunto de sentencias de proceso o una nueva condición.

5

5

5

5

1RGRV

$ULVWDV

5HJLRQHV

(a) (b)

Figura 6.3. Notación del grafo de flujo.

Page 156: programacion2

Métodos de prueba del software.

159

D

E

Figura 6.4. Traducción de algorítmica a grafo de flujo.

Procedimiento Ordenarinicio/*1*/ repite mientras queden registros

leer registros/*2*/ si campo 1 de registro = 0/*3*/ entonces procesar registro;

guardar bucle; incrementar contador;

/*4*/ sino si campo 2 del registro = 0/*5*/ entonces reiniciar contador;/*6*/ sino procesar registro;

guardar en el archivo;/*7a*/ fin-si

fin-si/*7b*/ fin-repite/*8*/ fin

Figura 6.5. Algoritmo.

En términos del grafo de flujo, un camino independiente está constituido por lo menos por unaarista que no haya sido recorrida anteriormente a la definición del camino. Por ejemplo, para el grafode flujo de la figura 6.3b, un conjunto de caminos independientes sería:

Camino 1: 1-11

Page 157: programacion2

Programación II

160

Camino 2: 1-2-3-4-5-10-1-11

Camino 3: 1-2-3-6-8-9-10-1-11

Camino 4: 1-2-3-6-7-9-10-1-11

Fíjese que cada nuevo camino introduce una nueva arista. El camino

1-2-3-4-5-10-1-2-3-6-8-9-10-1-11

no se considera camino independiente.

Los caminos 1,2,3 y 4 definidos anteriormente componen un conjunto básico para el grafo deflujo de la figura 6.3b. O sea, se pueden diseñar pruebas que habrá ejecutado al menos una vez cadasentencia del programa y que cada condición se habrá ejecutado en sus vertientes verdadera y falsa.

La complejidad ciclomática está basada en la teoría de grafos y nos da una métrica delsoftware extremadamente útil. La complejidad se puede calcular de tres formas:

El número de regiones del grafo de flujo coincide con la complejidad ciclomática.

La complejidad ciclomática, V(G), de un grafo de flujo G se define como:

V(G)=A-N+2

donde A es el número de aristas del grafo de flujo y N es el número de nodos.

La complejidad ciclomática, V(G), de un grafo de flujo G también se define como:

V(G)=P+1

donde P es el número de nodos predicado contenidos en el grafo de flujo G.

Refiriéndonos de nuevo al grafo de flujo de la figura 6.3b, la complejidad ciclomática sepuede calcular mediante cualquiera de los anteriores algoritmos:

El grafo de flujo tiene cuatro regiones.

V(G)= 11 aristas – 9 nodos + 2 = 4

V(G)= 3 nodos predicado + 1 = 4

Por tanto, la complejidad ciclomática del grafo de flujo de la figura 6.3b es 4.

Más importante, el valor de V(G) nos da un límite superior para el número de caminosindependientes que componen el conjunto básico y, consecuentemente, un valor límite superior parael número de pruebas que se deben diseñar y ejecutar para garantizar que se cubren todas lassentencias del programa.

Obtención de casos de prueba.En esta sección, presentaremos la prueba del camino básico como una serie de pasos.

Usaremos el procedimiento media, representado en algorítmica en la figura 6.6, como ejemplo parailustrar todos los pasos del método de diseño de casos de prueba.

Page 158: programacion2

Métodos de prueba del software.

161

1. Usando el diseño o el código como base, dibujamos el correspondiente grafo deflujo

2. Determinamos la complejidad ciclomática del grafo de flujo resultante.

Procedimiento media;

Este procedimiento calcula la media de 100 o menos números que se encuentrenentre unos límites; también calcula el total de entradas y el total de números válidos.

valor: array (1..100) de reales;

media, entrada, válido: real;

mínimo, máximo, suma: real;

i: real;

i=1;

entrada = válido = 0;

suma=o;

Repita mientras valor[i] <>-999 and entrada<100

Incrementar entrada en 1;

si (valor[i]>mínimo) AND (valor[i]<=máximo)

entonces incrementar válido en 1;

suma = suma + valor[i];

sino ignorar

fin-si

Incrementar i en 1;

fin-repetir

si total.válido > 0

entonces media = suma/válido;

sino media = - 999;

fin-si

fin media

Figura 6.6. Algoritmo para diseño de pruebas con nodos identificados.

Page 159: programacion2

Programación II

162

Figura 6.7. Grafo de flujo del procedimiento media.

En la figura 6.7,

V(G)= 6 regiones

V(G)= 17 aristas – 13 nodos + 2 =6

V(G)= 5 nodos predicado + 1 =6

3. Determinamos un conjunto básico de caminos linealmente independientes 9:

Camino 1: 1-2-10-11-13

Camino 2: 1-2-10-12-13

Camino 3: 1-2-3-10-11-13

Camino 4: 1-2-3-4-5-8-9-2-...

Camino 5: 1-2-3-4-5-6-8-9-2-...

Camino 6: 1-2-3-4-5-6-7-8-9-2-...

Los puntos suspensivos (...) que siguen a los caminos 4, 5 y 6 indican que cualquier caminodel resto de la estructura de control es aceptable. Normalmente merece la pena identificar los nodospredicado para que sea más fácil obtener los casos de prueba. En este caso, los nodos 2, 3, 5, 6 y 10son nodos predicado.

9 Caminos que recorre nodos diferentes entre ellos.

Page 160: programacion2

Métodos de prueba del software.

163

4. Preparamos los casos de prueba que forzarán la ejecución de cada camino delconjunto básico.

• Caso de prueba del camino 1:

valor (k)=entrada válida, donde k<i definida a continuación

valor (i)= -999, donde 2 ≤ i ≤ 100

resultados esperados: media correcta sobre los k valores y totales adecuados

Nota: el camino 1 no puede probar por sí solo; debe ser probado como parte de laspruebas de los caminos 4, 5 y 6.

• Caso de prueba del camino 2:

Valor (1)= -999

Resultados esperados: media = -999; otros totales con sus valores iniciales

• Caso de prueba del camino 3:

Intento de procesar 101 o más valores

Los primeros 100 valores deben ser válidos

Resultados esperados: igual que en el caso de prueba 1

• Caso de prueba del camino 4:

Valor (i) = entrada válida donde i<100

Valor (k)<mínimo, para k<i

Resultados esperados: media correcta sobre los k valores y totales adecuados.

• Caso de prueba del camino 5:

Valor (i) = entrada válida donde i<100

Valor (k)>máximo, para k ≤ i

Resultados esperados: media correcta sobre los n valores y totales adecuados.

• Caso de prueba del camino 6:

Valor (i)= entrada válida donde i<100

Resultados esperados: media correcta sobre los n valores y totales adecuados.

Ejecutamos cada caso de prueba y comparamos los resultados obtenidos con los esperados.Una vez terminados todos los casos de prueba, el responsable de la prueba podrá estar seguro deque todas las sentencias del programa se han ejecutado por lo menos una vez.

Algunos caminos independientes (por ejemplo: el camino 1 de nuestro ejemplo) no se puedenprobar de forma aislada. En tales casos, estos caminos se han de probar como parte de otra pruebade camino.

Page 161: programacion2

Programación II

164

Matrices de grafosEl procedimiento para obtener el grafo de flujo e incluso la determinación de un conjunto de

caminos básicos es susceptible de ser mecanizado.

Una matriz de grafo es una matriz cuadrada cuyo tamaño es igual al número de nodos delgrafo de flujo. Cada fila y cada columna corresponde a un nodo específico y las entradas de la matrizcorresponden a las conexiones (aristas) entre los nodos.

$

%

'

(

)

*

Figura 6.8. Grafo de flujo.

En la figura, cada nodo del grafo de flujo está identificado por un número, mientras que cadaarista lo está por su letra. Se sitúa una entrada en la matriz por cada conexión entre dos nodos. Porejemplo, el nodo 3 está conectado con el nodo 4 por la arista b.

Conectado alNodo nodo 1 2 3 4 5

1 A

2

3 D B

4 C F

5 G E

Figura 6.9. Matriz del grafo.

Añadiendo un peso de enlace10 a cada entrada de la matriz, la matriz de grafo se puedeconvertir en una potente herramienta para evaluación de la estructura de control del programadurante la prueba.

10 Coste que nos puede llevar a recorrer cada una de las aristas del grafo.

Page 162: programacion2

Métodos de prueba del software.

165

A los pesos de enlace se les puede asignar propiedades como:

• La probabilidad de que un enlace (arista) sea ejecutado.

• El tiempo de procesamiento asociado al recorrido de un enlace.

• La memoria requerida durante el recorrido de un enlace.

• Los recursos requeridos durante el recorrido de un enlace.

Para ilustrarlo, usaremos la forma más simple de peso, que indica la existencia de conexiones(0 o 1). Se ha reemplazado cada letra por un 1, indicando la existencia de una conexión (se hanexcluido los ceros por claridad). Cuando se representan de esta forma la matriz se denomina matrizde conexiones. En la figura 6.10, cada fila con dos o más entradas representa un nodo predicado. Portanto, los cálculos aritméticos que se muestran a la derecha de la matriz de conexiones nos dan otronuevo método de determinación de la complejidad ciclomática.

Conectado alNodo nodo 1 2 3 4 5 conexiones

1 1 1 – 1 = 0

2

3 1 1 2 – 1 = 1

4 1 1 2 – 1 = 1

5 1 1 2 – 1 = 1

3 + 1 = 4

Complejidad ciclomática

Figura 6.10. Matriz de conexiones.

Prueba de bucles.Los bucles son la piedra angular de la inmensa mayoría de los algoritmos implementados en

software. Y, sin embargo, les prestamos normalmente poca atención cuando llevamos a cabo laprueba del software.

La prueba de bucles se centra en la validez de las construcciones de bucles. Se puedendefinir cuatro clases diferentes de bucles:

Page 163: programacion2

Programación II

166

%XFOHV

VLPSOHV

%XFOHV

$QLGDGRV%XFOHV

FRQFDWHQDGRV

%XFOHV QR

HVWUXFWXUDGRV

Figura 6.11. Bucles.

• Bucles simples . Son los que dentro no tienen mas bucles pudiendo tener uno o másinstrucciones. A los bucles simples se les debe aplicar el siguiente conjunto de pruebas,donde n es el número máximo de pasos permitidos por el bucle:

1. Pasar por alto totalmente el bucle.

2. Pasar una sola vez por el bucle.

3. Pasar dos veces por el bucle.

4. Hacer m pasos por el bucle con m< n.

5. Hacer n –1, n + 1 pasos por el bucle.

• Bucles anidados . Si extendiéramos el enfoque de prueba de los bucles simples a losbucles anidados, el número de posibles pruebas aumentaría geométricamente a medidaque aumenta el nivel de anidamiento. Esto llevaría a un número impracticable depruebas. Beizer [Bei90] sugiere un enfoque que ayuda a reducir el número de pruebas:

1. Comenzar por el bucle más interior. Establecer o configurar los demás buclescon sus valores mínimos.

2. Llevar a cabo las pruebas de bucles simples para el bucle más interior,mientras se mantienen los parámetros de iteración (p. Ej.: contadores debucles) de los bucles externos en sus valores mínimos. Añadir otras pruebaspara valores fuera de rango o excluidos.

3. Progresar hacia fuera, llevando a cabo pruebas para el siguiente bucle, peromanteniendo todos los bucles externos en sus valores “típicos”.

4. Continuar hasta que se hayan probado todos los bucles.

• Bucles concatenados. Los bucles concatenados se pueden probar mediante el enfoqueanteriormente definido para los bucles simples, mientras cada uno de los bucles seaindependiente del resto.

Page 164: programacion2

Métodos de prueba del software.

167

• Bucles no estructurados. Siempre que sea posible, esta clase de bucles se debenrediseñar para que se ajusten a las construcciones de programación estructurada.

6.4 PRUEBA DE CAJ A NEGRA.La prueba de caja negra permite al ingeniero del software obtener conjuntos de condiciones

de entrada que ejerciten completamente todos los requisitos funcionales de un programa. La pruebade caja negra no es una alternativa a las técnicas de prueba de caja blanca. Más bien se trata de unenfoque complementario.

La prueba de caja negra intenta encontrar errores de las siguientes categorías:

1. funciones incorrectas o ausentes,

2. errores de interfaz,

3. errores en estructuras de datos o en accesos a bases de datos externas,

4. errores de rendimiento y

5. errores de inicialización y de determinación.

A diferencia de la prueba de caja blanca, que se lleva a cabo previamente en el proceso deprueba, la prueba de caja negra tiende a aplicarse durante fases posteriores de la prueba. Ya que laprueba de caja negra ignora intencionadamente la estructura de control, centra su atención en elcampo de la información. Las pruebas se diseñan para responder a las siguientes preguntas:

¿Cómo se prueba la validez funcional?

¿Qué clases de entrada compondrán unos buenos casos de prueba?

¿Es el sistema particularmente sensible a ciertos valores de entrada?

¿De qué forma están aislados los límites de una clase de datos?

¿Qué volúmenes y niveles de datos tolerará el sistema?

¿Qué efectos sobre la operación del sistema tendrán combinaciones específicas de datos?

Mediante las técnicas de prueba de caja negra se obtiene un conjunto de casos de pruebaque satisfacen los siguientes criterios [Mye79]:

1. reducen, en un coeficiente que es mayor que uno, el número de casos de pruebaadicionales que se deben diseñar para alcanzar una prueba razonable

2. casos de prueba que nos dicen algo sobre la presencia o ausencia de claves de erroresen lugar de errores asociados solamente con la prueba que estamos realizando.

Page 165: programacion2

Programación II

168

Las pruebas de caja negra son diseñadas para validar los requisitos funcionales sin fijarse enel funcionamiento interno de un programa. Las técnicas de prueba de caja negra se centran en elámbito de información de un programa, de forma que se proporcione una cobertura completa deprueba.

6.4.1 Métodos de prueba basados en grafos.La prueba del software empieza creando un grafo de objetos importantes y sus relaciones y

después diseñando una serie de pruebas que cubran el grafo de manera que se ejerciten todos losobjetos y sus relaciones para descubrir los errores.

Para llevar a cabo estos pasos, el ingeniero del software empieza creando un grafo: querepresentan las relaciones entre los objetos; pesos de nodos que describen las propiedades de unnodo y pesos de enlaces que describen alguna característica de un enlace.

2EMHWR

2EMHWR

2EMHWR

(QODFH QR GLULJLGR

(QODFH GLULJLGR

(QODFHV SDUDOHORV

3HVR GHO QRGR

YDORU

$

3HVR GH HQODFH

1XHYR

DUFKLYR

9HQWDQD GH

GRFXPHQWR

7H[WR GHO

GRFXPHQWR

6H UHSUHVHQWD FRPR

/D VHOHFFLyQ GHO PHQX JHQHUDO

&RQWLHQH

$WULEXWRVGLPHQVLyQ GH LQLFLRFRQILJXUDFLyQ R SUHIHUHQFLDVSRU GHIHFWRFRORU GH IRQGR EODQFRFRORU GHO WH[WR FRORU RSUHIHUHQFLDV SRU GHIHFWR

%

7LHPSR GH JHQHUDFLyQ VHJ

3HUPLWH OD HGLFLyQ GH

Figura 6.12. (a) notación del grafo. (b) Sencillo ejemplo.

En la figura 6.12(a) se muestra una representación simbólica del grafo. Los nodos serepresentan como círculos conectados por enlaces que toman diferentes formas. Un enlace dirigidoindica que una relación se mueve sólo en una dirección. Un enlace bidireccional implica que larelación se aplica en ambos sentidos. Los enlaces paralelos se usan cuando se establecen diferentesrelaciones entre los nodos del grafo. Como ejemplo sencillo, consideremos una porción de un grafode una aplicación de proceso de textos (figura 6.12(b)) donde:

objeto #1 = selección en el menú archivo nuevo

objeto #2 = ventana del documento

Page 166: programacion2

Métodos de prueba del software.

169

objeto #3 = texto del documento

Como se muestra en la figura, una selección del menú en archivo nuevo genera una ventanade documento. El peso del nodo de ventana de documento proporciona una lista de los atributos de laventana que se esperan cuando se genera una ventana. El peso del enlace indica que la ventana setiene que generar en menos de 1.0 segundos. Un enlace no dirigido establece una relación simétricaentre selección en el menú archivo nuevo y texto del documento, y los enlaces paralelos indican lasrelaciones entre la ventana del documento y el texto del documento. En realidad, se debería generarun grafo bastante más detallado como prepursor al diseño de casos de prueba. El ingeniero delsoftware obtiene entonces casos de prueba atravesando el grafo y cubriendo cada una de lasrelaciones mostradas.

Estos casos de prueba están diseñados para intentar encontrar errores en alguna de lasrelaciones.

Beizer [Bei95] describe un número de métodos de prueba de comportamiento que puedenhacer uso de los grafos:

Modelación del flujo de transacción. Los nodos representan los pasos de algunatransacción y los enlaces representan las conexiones lógicas entre los pasos.

Modelación de estado finito. Los nodos representan diferentes estados del softwareobservables por el usuario y los enlaces representan las transiciones que ocurren para moverse deestado a estado.

Modelación del flujo de datos. Los nodos son objetos de datos y los enlaces son lastransformaciones que ocurren para convertir un objeto de datos en otro.

Modelación de planificación. Los nodos son objetos de programa y los enlaces son lasconexiones secuenciales entre esos objetos.

6.4.2 Partición equivalente.La partición equivalente es un método de prueba de caja negra que divide el dominio de

entrada de un programa en clases de datos de los que se pueden derivar casos de prueba. Un casode prueba ideal descubre de forma inmediata una clase de errores que de otro modo, requerirían laejecución de muchos casos antes de detectar el error genérico. La partición equivalente se dirige a ladefinición de casos de prueba que descubran clases de errores, reduciendo así el número total decasos de prueba que hay que desarrollar.

Si un conjunto de objetos puede unirse por medio de relaciones simétricas, transitivas yreflexivas, entonces existe una clase de equivalencia [BEI]. Una clase de equivalencia representa unconjunto de estados válidos o no válidos para condiciones de entrada. Una condición de entrada esun valor numérico específico, un rango de valores, un conjunto de valores relacionados o unacondición lógica. Se pueden definir de acuerdo con las siguientes directrices:

1. Si una condición de entrada especifica un rango, se define una clase de equivalenciaválida y dos no válidas.

2. Si una condición de entrada requiere un valor específico, se define una clase deequivalencia válida y dos no válidas.

Page 167: programacion2

Programación II

170

3. Si una condición de entrada especifica un miembro de un conjunto, se define unaclase de equivalencia válida y una no válida.

4. Si una condición de entrada es lógica, se define una clase de equivalencia válida yuna no válida.

Cómo ejemplo, consideremos los datos contenidos en una aplicación de automociónbancaria. El usuario puede “llamar” al banco usando su ordenador personal, dar su contraseña de 6dígitos y continuar con una serie de ordenes clave que desencadenarían varias funciones bancarias.El software proporcionado por la aplicación bancaria acepta datos de la siguiente forma:

Código de área: En blanco o un número de tres dígitos.

Prefijo: Número de tres dígitos que no comience por 0 o 1.

Sufijo: Número de cuatro dígitos.

Contraseña: Valor alfanumérico de seis dígitos.

Ordenes: “Comprobar”, “depositar”, “pagar facturas”, etc.

Las condiciones de entrada asociadas con cada elemento de la aplicación bancaria sepueden especificar como:

Código de área: condición de entrada, lógica –el código de área puede estarno presente.

condición de entrada, rango –valores definidos entre 200 y999, con excepciones específicas.

Prefijo: condición de entrada, rango –valor especificado >200 sindígitos 0.

Sufijo: condición de entrada, valor –longitud de cuatro dígitos.

Contraseña: condición de entrada, lógica –la palabra clave puede estar ono presente.

condición de entrada, valor –cadena de seis caracteres.

Orden: condición de entrada, conjunto –contenido en las ordeneslistadas anteriormente.

Aplicando las directrices para la obtención de clases de equivalencia, se pueden desarrollarcasos de prueba para cada elemento de datos del campo de entrada. Los casos de prueba seseleccionan de forma que se ejercite el mayor número de atributos de cada clase de equivalencia a lavez.

6.4.3 Análisis de valores límite (AVL)Los errores tienden a darse más en los límites del campo de entrada que en el “centro”.

El análisis de valores límite es una técnica de diseño de casos de prueba que complementa ala partición equivalente. En lugar de seleccionar cualquier elemento de una clase de equivalencia, elAVL lleva a la elección de casos de prueba en los “extremos” de la clase. En lugar de centrarsesolamente en las condiciones de entrada, el AVL obtiene casos de prueba también para el campo desalida [Mye79].

Page 168: programacion2

Métodos de prueba del software.

171

Las directrices de AVL son similares en muchos aspectos a las que proporciona la particiónequivalente:

1. Si una condición de entrada especifica un rango delimitado por los valores a y b, sedeben diseñar casos de prueba para los valores a y b y para los valores justo por debajoy justo por encima de a y b, respectivamente. Por ejemplo un programa que pida la edadde una persona y suponiendo que esta está entre 0 y 200, tendríamos que probar aintroducir los siguientes valores: el 0, el 200, el –1, y el 201, debiendo de detectar erroresen los dos últimos casos.

2. Si una condición de entrada especifica un número de valores, se deben desarrollar casosde prueba que ejerciten los valores máximo y mínimo. También se deben probar losvalores justo por encima y justo por debajo del máximo y del mínimo. Por ejemplo, unaaplicación que nos pide una contraseña, la cual debe de tener más de cinco caracteres ymenos de diez, tendríamos que probar por ejemplo con las siguientes contraseñas:

- abcde,

- abcdefghij,

- abcd,

- abcdefghijk,

debiéndonos de detectar error en los dos últimos.

3. Aplicar las directrices 1 y 2 a las condiciones de salida. Por ejemplo supongamos que serequiere una tabla de “temperatura/presión” como salida de un programa de análisis deingeniería. Se deben diseñar casos de prueba que creen un informe de salida queproduzca el máximo (y el mínimo) número permitido de entradas en la tabla.

4. Si las estructuras de datos internas tienen límites preestablecidos (por ejemplo: un vectorque tenga un límite definido de 100 entradas) hay que asegurarse de diseñar un caso deprueba que ejercite la estructura de datos en sus límites.

6.4.4 Prueba de comparación.Hay situaciones en las que la fiabilidad del software es algo absolutamente crítico. En ese tipo

de aplicaciones, a menudo se utiliza hardware y software redundante para minimizar la posibilidad deerror. Cuando se desarrolla software redundante, usando las mismas especificaciones se debenprobar todas las versiones con los mismos datos de prueba, para asegurar que todas proporcionanuna salida idéntica.

Cuando se han producido múltiples implementaciones de la misma especificación, a cadaversión del software se le proporciona como entrada los caos de prueba diseñados mediante algunaotra técnica de caja negra.

Si las salidas producidas por las distintas versiones son idénticas, se asume que todas lasimplementaciones son correctas. Si la salida es diferente, se investigan todas las aplicaciones paradeterminar el defecto responsable de la diferencia en una o más versiones.

La prueba de comparación no es infalible. Si el error se encuentra en la especificación a partirde la cual se han desarrollado todas las versiones, lo más probable es que todas las versionesreflejen ese error. Además, si todas las versiones independientes producen unos resultados idénticos,pero erróneos, la prueba de comparación no detectará el error.

Page 169: programacion2

Programación II

172

6.5 Bibliografía.• [Bei90] BEIZER, B., Software Testing Tecyhniques, segunda edición, Van Nostrand

Reinhold, 1990.

• [Bei95] BEIZER, B., Black-Box Testing, Wiley, 1995.

• [Ber92] BERSON, A., Client/Server Architectures, McGraw-Hill, 1992.

• [Bri87] BRILLANT, S.S., J.C. KNIGHT, y N.G. LEVENSON, “ The Consistent ComparisonProblem in N-Version Software”, ACM Software Engineering, R. JENSEN y C. TONIES(eds.), Prentice-Hall, 1979, pp. 329-408.

• [Dav85] DAVIS, A., 201 Principles of Software Development, McGraw-Hill, 1984.

• [Deu79] DEUSTH, M., “Verification and Validation”, in Software Engineering, R. JENSENy C. TONIES (eds.), Prentice-Hall, 1979, pp. 329-408.

• [Dun84] DUNN, R., Software Defect Removal, McGraw-Hill, 1984.

• [Fos84] FOSTER, K.A., “Sensitive Test Data for Boolean Expressions”, ACM SoftwareEngineering Notes, vol. 9 nº 2, abril, 1984, pp 120-125.

• [Fra88] FRANKL, P.G. Y E.J. WEYUKER, “An Experimental Comparison of tgheEffectiveness of Branch Testing and Data Flow”, IEEE rans. Foftware engineering, vol. 19,nº 8, agosto 1993, pp. 770-787.

• [Het84] HETZEL, W., The Complete Guide to Software Testing, QED InformationSciences, Inc., Wellesley, Ma, 1984.

• [How82] HOWDEN, W.E., “ Weak Mutation Testing and the Completeness of Test Cases”,IEEE Trans. Software Engineering, vol.SE-8, nº 4, julio 1982, pp 371-379.

• [Jon81] JONES, T. C., Programming Productivity: Issues for the 80’s, IEEE VomputerSociety Press, 1981.

• [KFN93] KANER, C., J. FALK, y H.Q. NGUYEN, Testing Computer Software, 2ª edición,Van Nostrand Reinhold, 1993.

• [Kni89] KNIGHT, J., y p. Ammann, “ Testing Software Using Multiple Version”, SoftwareProductivity Consortium, eport nº 89029N, Reston VA, junio 1989.

• [Mcc76] MCCABE, T., <a Software Complexity Measure”, IEEE Trans. SoftwareEngineering, vol. 2, diciembre 1976, pp. 308-320.

• [Mye79] MYERS, G., Ther Art of Software Testing, Wiley, 1979.

• [Ntn88] NTAFOS, S.C., “ A comparison of Some Structural Testing Strategies”, IEE TransSoftware, vol. 16, nº 6, junio 1988, pp 868-874.

• [TS87] TAI, K.C. y H.K. SU, “Test Generation for Boolean Expresions”, Proc.COMPSAC’87. Octubre 1987, pp. 278-283.

• [Tai89] TAI, K.C., “What to Do Beyond Branch Testing”, ACM Software EngineeringNotes, vol. 14, nº 2, abril, 1989, pp. 58-61.

• [Was93] WASKEVITCH, D., Client/Server Strategies, IDG Books, 1993.

• [WC80] WHITE, L.J y E.I. COHEN, “A Domain Strategie for Program Testing”, IEEETrans. Foftware Engineering, vol. SE-6, nº 5, mayo 1980, pp 247-257.

Page 170: programacion2

Capítulo 7: Programación orientada a objetos

7.1 Paradigmas de la programaciónObservando la historia de la disciplina de la Programación, se vislumbran de forma clara dos

tipos de lenguajes: lenguajes imperativos y lenguajes declarativos, estos lenguajes propician dosestilos de programación alternativos: Programación Imperativa, Programación Declarativa.

La evolución de los lenguajes imperativos está claramente influenciada por la arquitecturaVon Neuman. La abstracción sobre la arquitectura de Von Neuman da lugar en los lenguajesimperativos a los conceptos de variable, operación de asignación e iteración. La unidad de trabajo deun programa escrito en uno de estos lenguajes es la instrucción, combinando los efectos de lasinstrucciones individuales en un programa para alcanzar los resultados deseados.

En contraposición a los lenguajes imperativos surgen los lenguajes declarativos con laintención de distanciarse de la dependencia de la máquina que representa la arquitectura de VonNeuman, y de los conceptos asociados: variable, asignación, etc. La idea de los lenguajesdeclarativos es que el programador especifique el problema, es decir, que el programador indique"qué" es lo que hay que resolver, en contraposición a los lenguajes imperativos en los cuales seindica "cómo" resolver el problema indicando los pasos a seguir. Las tres características quepresentan este tipo de lenguajes son: la expresividad, la fiabilidad, y ser matemáticamente elegantes.Los lenguajes declarativos más significativos son los lenguajes funcionales y lógicos.

Aunque estos dos grupos de lenguajes son suficientemente amplios, hay ciertos tipos delenguajes que, o bien se encuentran en medio de dos clases, como es el caso de los de flujo de datosy de los lenguajes orientados a objetos que pueden ser tanto imperativos como declarativos, o bienno encajan en esta clasificación, como es el caso de los lenguajes de cuarta generación.

Son pues dos estilos distintos de entender la programación, alrededor de los cuales se handesarrollado las filosofías de programación más ampliamente utilizadas: Programación Estructurada,Programación Orientada a Objetos, Programación Funcional, Programación Lógica.

A continuación detallaremos en qué consisten estas filosofías de la programación indicandocuales son sus características y aportaciones. Esta exposición nos permite una mayor explicitación delos contenidos de la programación enriqueciendo la visión de la disciplina.

7.1.1 Programación estructuradaDurante los años 70-80 el pilar de la metodología de la programación fue la Programación

Estructurada. La Programación Estructurada se caracteriza por el uso en el diseño de programas detres principios fundamentales:

- Estructuras Básicas

- Recursos Abstractos

- Diseño descendente (top-down)

Las estructuras básicas para el diseño de programas estructurados son: estructura secuencial,selectiva y repetitiva. El uso de estas estructuras para el diseño de programas esta respaldado por elteorema de Böhm y Jacopini. Según este teorema, puede contruirse cualquier programa utilizandoexclusivamente las estructuras de control mencionadas anteriormente.

El uso de recursos abstractos en la programación estructurada, consiste en no tener encuenta los recursos concretos de que se dispone (la máquina donde se va a ejecutar y el lenguaje deprogramación que se va a utilizar), de manera que para resolver el problema se descompone unadeterminada acción compleja en términos de un número de acciones más simples, que podrían serejecutadas por una máquina abstracta, y así sucesivamente se siguen descomponiendo las accionesen otras más simples hasta que en un determinado nivel de descomposición las subaccionesobtenidas constituyan instrucciones para el ordenador actualmente disponible. A lo largo de ladescomposición se desciende a través de diversos niveles de abstracción, aplicando los distintostipos de abstracción: abstracción procedural, abstracción de datos.

Page 171: programacion2

Programación II

174

La metodología descendente consiste en establecer la solución de un problema medianterefinamientos sucesivos (step-wise) descomponiendo el problema en etapas o estructurasjerárquicas. El diseño se basa pues en la realización de diferentes niveles, el primer nivel resuelve elproblema y el segundo y sucesivos niveles son refinamientos sucesivos del primero, y en todos ellosse sigue siempre el uso de recursos abstractos.

Son muchas las aportaciones de la programación estructurada para el diseño de programas,y todas ellas han tenido una influencia sin igual en la evolución de la disciplina de la Programación.Una de las principales aportaciones de la programación estructurada es el facilitar el nacimiento deuna potente metodología: la metodología descendente. Esta metodología ha influido en otras formasde programación como la Programación Modular y se sigue aplicando en el diseño de programasaún en nuestros días. La programación estructurada, gracias a la metodología descendente, produceprogramas más claros y legibles, lo cual permite programas más fáciles de verificar y mantener,principios estos que deben estar siempre presentes en la programación moderna.

Por otro lado, la aparición de la programación estructurada ha supuesto un impactoincalculable en la mejora de la enseñanza de la programación, entre otras razones por permitirdiscutir y comparar programas. Además con la programación estructurada se aportó la posibilidad dedemostrar formalmente si un programa era o no correcto.

Una de las principales aportaciones de la programación estructurada es el uso de recursosabstractos como la abstracción de datos y procedural en el diseño de un programa. Ambos tipos deabstracciones constituyeron un potente punto de partida para el diseño modular, así como el principiode ocultamiento de información que es fundamental para el desarrollo de una modularidad efectiva ypara el diseño de abstracciones de datos. Estos tres principios: abstracción, ocultamiento de lainformación y modularidad, suponen los principios de la programación moderna.

El estilo de diseño y los principios surgidos con la programación estructurada siguen teniendovigencia hoy en día en el diseño de programas, y su evolución ha supuesto el desarrollo de otro estilode programación la "Programación Orientada a Objetos".

7.1.2 Programación orientada a objetosEl término Programación Orientada a Objetos se refiere a un estilo de programación por lo

que un lenguaje orientado a objetos puede ser tanto imperativo como declarativo, lo que loscaracteriza es la forma de manejar la información.

La Programación Orientada a Objetos surge como una abstracción de la ProgramaciónEstructurada para resolver algunos problemas que se encuentran en el diseño tradicional deprogramas, como la dificultad de mantenimiento, el alejamiento a la hora de representar el modeloreal y, por último, la escasa facilidad para reutilizar programas. Aunque la Programación Estructuradapone especial interés en esto último, resulta difícil con las herramientas de que dispone construirfunciones que tengan aplicación en distintos proyectos.

La idea de reusabilidad está presente desde los primeros lenguajes de programación. Paraobtener reusabilidad se han utilizado varias técnicas como subprogramas, sobrecarga y genericidad.Con la sobrecarga y genericidad el programador puede escribir el mismo código y utilizarlo dediferente forma dependiendo de las instanciaciones, pero estas técnicas no son lo suficientementeflexibles ya que los módulos genéricos son parametrizables y abiertos pero no son directamenteutilizables, mientras que sus instancias son utilizables pero no modificables, luego no puedendescribir una jerarquía completa de representaciones con diferentes niveles de parametrización. Elparadigma Orientado a Objetos proporciona mecanismos para la consecución de la deseadareusabilidad.

El paradigma Orientado a Objetos es un paradigma de clasificación, en contra de lo queinicialmente se pensó por el hecho de que los objetos necesitarán comunicarse por el paso demensajes. La Programación Orientada a Objetos está basada en el mecanismo de clasificación y

Page 172: programacion2

Programación orientada a objetos

175

organización de objetos. Para resolver los problemas de la programación tradicional, el paradigmaOrientado a Objetos se basa en la aplicación práctica de tres características: objetos, clases yherencia.

Un objeto es una entidad software que posee información y capacidad de procesamiento. Enconsecuencia, el concepto de objeto viene a sustituir al concepto de variable con la salvedad de lacapacidad de procesamiento que posee el objeto. Dicha capacidad de procesamiento esimplementada a través de métodos (procedimientos en la programación tradicional) y requerida a losobjetos a través de mensajes. En la resolución de problemas dentro de la Programación Orientada aObjetos se intenta hacer corresponder determinados objetos del dominio del problema condeterminados objetos del dominio de la solución, de manera que se acerque el dominio de la soluciónal dominio del problema.

Una clase es un mecanismo que permite agrupar objetos con el mismo comportamiento amodo de plantilla. Es decir, una clase es un conjunto de objetos que vistos desde el exteriorpresentan todos el mismo protocolo, y vistos desde el interior presentan todos los mismos métodos ydatos internos llamados variables de instancia.

La herencia es un mecanismo para modelar el comportamiento por modificación ocomposición. La herencia organiza las clases para describir dominios de aplicación, de forma quesegún la filosofía de la Programación Orientada a Objetos, en un futuro la comunidad deprogramadores estará dividida en productores de clases y consumidores de clases.

El uso de objetos, clases, subclases y herencia es fundamental para la reutilización decomponentes de software, creando objetos que se forman sobre los atributos y las operacionesheredadas de una clase o de una subclase, sólo siendo necesario especificar las diferencias entre elnuevo objeto y la clase, en lugar de definir todas las características del nuevo objeto.

Estas tres características son las propias de un lenguaje orientado a objetos, pues no essuficiente con que un lenguaje soporte objetos como característica del lenguaje para ser un lenguajeorientado a objetos; en este caso únicamente se trata de un lenguaje basado en objetos. Ahora bien,si en dicho lenguaje cada objeto debe pertenecer a una clase el lenguaje se dirá basado en clases, ysi además las clases soportan la herencia como mecanismo para compartir recursos en la jerarquíade clases, se llamará lenguaje orientado a objetos.

A parte de las tres características básicas de la Programación Orientada a Objetos, alrededorde este paradigma aparecen otros conceptos como: ligadura o tiempo de ligadura, abstracción dedatos, concurrencia, persistencia y ocultamiento de la información. La manifestación de estosconceptos en mayor o menor grado en los lenguajes orientados a objetos establece una clasificaciónde los mismos. Observando estos conceptos es claro comprobar que la Programación Orientada aObjetos, está basada en los métodos de la programación tradicional, en la medida en que hace usopara el diseño de los programas de los principios de abstracción, ocultamiento de la información ymodularidad. La programación Orientada a Objetos se verá con mas detalle posteriomente.

7.1.3 Programación func ionalLa Programación Funcional surgió como una alternativa a la programación imperativa. El

fundamento matemático de esta filosofía de programación tiene su origen en el problema decomputabilidad de funciones matemáticas, y, concretamente, en el llamado lambda cálculo. Laesencia de esta filosofía es el uso de funciones para crear programas, es decir, en un lenguajefuncional no hay instrucciones. La inexistencia de la instrucción de asignación comporta que losusuarios no se tengan que preocupar del manejo de la memoria. Esto permitirá que el programadorconcentre todos sus esfuerzos en la descripción de lo que se ha de computar. Sin embargo, estafacilidad añade cierto grado de ineficiencia cuando el programa se implementa para máquinas con laarquitectura Von Neumann tradicional.

Page 173: programacion2

Programación II

176

Un programa funcional es pues una función que se define por composición de funciones mássimples, y para ejecutarla se aplica a los datos de entrada (los argumentos o parámetros de lafunción) y se obtiene un resultado (el valor calculado de la función).

La programación funcional pura se caracteriza por una propiedad fundamental: latransparencia referencial. Esta propiedad consiste en que el valor de una función dependeúnicamente de los valores de sus argumentos, de forma que este principio excluye los efectoslaterales dentro de las expresiones. Esta es una diferencia fundamental con los lenguajes imperativosque proporciona bastantes ventajas a la hora de escribir y corregir un programa.

Otro punto de interés en la programación funcional, es el trato proporcionado a las funciones.Las funciones en estos lenguajes son tratadas de la misma manera que cualquier otro valor, es decir,pueden ser pasadas como parámetros, pueden ser el valor de una expresión o pueden seralmacenadas en una estructura de datos.

Algunas veces el término funcional se usa solamente para aquellos lenguajes con una totalausencia de las características procedimentales explícitas, es decir, los lenguajes funcionales puros.Sin embargo, existen lenguajes llamados funcionales que soportan algunas característicasprocedimentales, y como consecuencia muchos de los beneficios del enfoque funcional se pierden alintroducir estas características, este es el caso de lenguajes como APL, Standar ML, o la mayoría devariantes de LISP. A pesar de todo, en estos lenguajes el estilo de programación está dominado porla parte pura de los lenguajes funcionales.

En un entorno de programación funcional se proporcionarán la funciones más útiles a las quese llama funciones primitivas, de manera que el programador puede ampliar y definir su propioentorno añadiendo las primitivas necesarias. La esencia de la programación funcional es combinarfunciones para producir otras más potentes.

7.1.4 Programación lógicaLa idea básica de la Programación Lógica se puede expresar en una frase:

Algoritmos = Lógica+Control

En principio esta frase es aplicable a cualquier tipo de programación. Lo que ocurre es queen los lenguajes tradicionales, lo que podríamos llamar su lógica (o sus principios de deducción) noestá definida y, en cambio, el control (su forma de ejecución) acostumbra a ser trivial.

En general, cuando se habla de programación lógica se entiende que el lenguaje deprogramación será un lenguaje lógico en el sentido tradicional, normalmente una restricción delcálculo de predicados de primer orden. Así pues, en programación lógica tampoco encontraremos laasignación explícita.

El lenguaje de programación lógica más conocido es PROLOG. La base de PROLOG es lalógica clausal, restringida a las cláusulas de Horn y su forma de ejecución es el principio deresolución. En este lenguaje no hay tipificación y, a diferencia de la mayoría de los lenguajes, losprogramas no tienen que definir que es un dato y que es un resultado. Este lenguaje ha conseguidoincrementar su eficiencia y salir así del terreno puramente experimental, introduciendo ciertospredicados extralógicos, pero a diferencia de los lenguajes funcionales no puros, estos añadidos noson características sacadas de los lenguajes imperativos.

La Programación Lógica trata con relaciones (predicados) entre objetos (datos), en lugar dehacerlo con funciones. Programar con relaciones es más flexible que hacerlo con funciones, pues lasrelaciones tratan argumentos y resultados uniformemente. Las relaciones serán especificadas conreglas y con hechos. La ejecución de programas lógicos consiste en la demostración de hechos sobrelas relaciones por medio de preguntas.

Page 174: programacion2

Programación orientada a objetos

177

La Programación Lógica es otra forma de entender la programación, donde el diseño de unprograma depende de la estrategia de resolución utilizada, del conocimiento expresado en él y de lasinferencias lógicas realizadas internamente por el ordenador.

7.2 El estilo orientad o a objetosDurante los últimos años han aparecido y se han asentado las técnicas orientadas a objetos,

y como parte de ellas la Programación Orientada a Objetos. Sin embargo, ¿qué caracteriza a esteestilo de programación? ¿qué factores motivan su aparición? Estos y otros aspectos serán tratadosen este punto.

En el resto de las secciones se ilustrarán los principales conceptos en la orientación a objetosy algunos de los lenguajes de programación orientada a objetos existentes. Los ejemplos deimplementación (código) se ilustrarán utilizando C++, sin embargo queda fuera de las objetivos deeste tema el aprendizaje de un lenguaje concreto.

7.2.1 Características del softwareAntes de entrar a tratar las metodologías tradicionales de desarrollo de software se tratarán

las características del software que se pretenden conseguir con estas metodologías.

Uno de los principales objetivos, si no el principal, en la construcción de software es la calidadde este aunque existen otros factores. Meyer en [Mey98] enumera las siguientes características quese desea tenga el software:

• Corrección : debe cumplir las especificaciones.

• Robustez : siempre reacciona adecuadamente, también ante entradas incorrectas.

• Extensibilidad : facilidad de adaptación.

• Reutilización : los componentes software deben permitir ser utilizados en diferentesaplicaciones.

• Compatibilidad : debe ofrecer facilidades para combinarse con otros elementos software.

• Eficiencia : debe ofrecer el máximo rendimiento con el menor consumo de recursos posible.

• Portabilidad : facilidad de adaptarse a cambios de entorno.

• Facilidad de uso .

• Funcionalidad : debe ofrecer el máximo número de posibilidades.

• Oportunidad : debe ser acabado o lanzado al mercado en el momento adecuado y sinretrasos.

• Verificabilidad : debe ofrecer facilidades para comprobar su funcionamiento.

• Integridad : debe ofrecer mecanismos de protección contra modificaciones y accesos noautorizados.

• Reparabilidad : debe permitir la detección y reparación de fallos o defectos.

• Economía : su coste no debe ser excesivo.

Un componente software que tenga todas estas características sería prácticamente perfecto,aunque en general no es nada sencillo conseguirlo. Además, en muchas ocasiones los factoresanteriormente mencionados entran en conflicto y se debe buscar un compromiso entre ellos. Sinembargo, el software siempre debe ser correcto, ya que no es aceptable que no cumpla los fines paralos cuales ha sido diseñado y desarrollado.

Page 175: programacion2

Programación II

178

Todos los factores de calidad del software mencionados no son igualmente importantes.Algunos tienen una mayor trascendencia y son: corrección, robustez, extensibilidad y reutilización.Los dos primeros se resumen en el término: fiabilidad . Por otro lado, la extensibilidad y lareutilización se engloban en la modularidad . Así un software fiable y modular cumplirá susespecificaciones y dará siempre una respuesta adecuada. Además, se podrá adaptar a diferentesentornos y podrá servir como elemento para desarrollar otras aplicaciones.

7.2.2 Mecanismos de abstracciónVeamos ahora como han aparecido históricamente diferentes técnicas para el desarrollo de

software.

Inicialmente la mayoría de los programas se desarrollaban en ensamblador, y normalmentepor un único programador. Posteriormente, con la aparición de los lenguajes de alto nivel,aumentaron las expectativas de que tipo de problemas se podían resolver con un computador. Prontose observó que el tamaño de los problemas superaban la capacidad de los programadores, y secomenzaron a formas equipos de desarrollo. Sin embargo, la tarea realizada por un programador enuna determinada cantidad de tiempo, no la desarrollaban dos programadores en la mitad ([Bud94]).

El hecho anterior era debido a las interconexiones entre componentes software . Los sistemassoftware desarrollados mediante técnicas convencionales presentan un alto grado de interconexión(dependencia de una parte del código con otra sección de código). Esto provoca que una parte de unsistema software no pueda ser entendida de forma aislada. Este hecho hace que el software puedaser difícil de depurar, reutilizar y mantener.

Para controlar esta complejidad se recurren a mecanismos de abstracción, o sea, deencapsulación y aislamiento de información de diseño e implementación. Las técnicas orientadas aobjetos son, entre otras cosas, uno de estos mecanismos y surgen como un producto de la evoluciónque va de los procedimientos, a los módulos, pasando por los tipos abstractos de datos para concluirfinalmente con los objetos.

Los procedimientos y funciones evitan que se duplique gran cantidad de código yproporcionan cierta capacidad de ocultación de información ya que se pueden utilizar sin necesidadde saber como están implementadas (solo es necesario conocer sus parámetros). Por otro lado, losmódulos permiten establecer una parte pública y otra privada. La parte publica es accesible desdefuera del módulo y la privada solo desde dentro. De esta forma proporciona un mecanismo deocultación de información. Sin embargo, no permite la creación de ejemplares. Por último, los tiposabstractos de datos se corresponden con un conjunto de datos (con unos valores permitidos) y unconjunto de operaciones primitivas que pueden ejecutarse sobre esos datos. Los usuarios puedencrear variables de un determinado tipo de datos y manejar sus datos mediante las operaciones queproporcionan. Los módulos suelen ser una forma de implementación de los tipos abstractos de datos.

Veamos un ejemplo para ilustrar lo anteriormente expuesto. Supongamos que necesitamosuna cola con prioridad en la aplicación que desarrollamos. Podemos utilizar por ejemplo un vectorpara implementarla y diseñamos un conjunto de funciones para obtener , introducir , extraer unelemento, etc. De esta forma se puede utilizar la cola con prioridad utilizando exclusivamente esasfunciones o procedimientos, independientemente de la estructura interna de esta. Sin embargo estono garantiza que desde una parte del código se acceda directamente al vector sin pasar por lasfunciones diseñadas.

Los módulos nos permiten que la estructura interna que contiene a los elementos de la colase sitúen en la parte privada del módulo y las funciones para manipular la cola en la parte pública.Esto impide accesos directos a los elementos y estructura de la pila. Sin embargo, ¿qué ocurre sinecesitamos otra pila más? . Tendríamos que utilizar otro módulo ya que los módulos no ofrecenposibilidad de obtener diferentes instancias.

Page 176: programacion2

Programación orientada a objetos

179

Otra solución posible sería utilizar un tipo de dato abstracto, ya que si nos permite crearinstancias y disponer de un conjunto de primitivas para la manipulación. Si se utilizan los móduloscomo forma de implementación de los tipos de datos abstractos, se podrá proteger la parte del tipo dedato que no debe ser accesible directamente y forzar así el uso de las primitivas. En caso contrario sepodría acceder al tipo de dato directamente en caso de que se conozca su estructura interna.Veremos posteriormente como la orientación a objetos nos proporciona mecanismos adicionales a losque proporcionan los tipos de datos abstractos.

7.2.3 Descomposición funcionalLa base tradicional de las arquitecturas software son las funciones. Se establece la función

que representa al sistema que se desea implementar y por refinamiento sucesivo se va reduciendo elnivel de abstracción para ir obteniendo funciones mas sencillas. Este método de diseño presenta unaserie de problemas:

• Puede cambiar la función que caracteriza el sistema debido a un cambio en lasespecificaciones.

• Puede ser difícil encontrar la función que caracteriza todo el sistema.

• La interfaz de usuario constituye muchas veces la estructura del sistema. Esta estructurapuede cambiar afectando a todo el sistema.

• No se favorece la reutilización. Las funciones que cumplen algún fin específico para otrafunción de más alto nivel es poco probable que sirvan en otro contexto.

• Se suele dar una ordenación prematura de las operaciones. Se establece demasiado prontoel orden exacto en que se producirán las diferentes operaciones sin ser necesario. Esto restaflexibilidad.

Este diseño descendente sirve muy bien para explicar un problema, pero no es la mejor formade desarrollo. En matemáticas la forma utilizada para explicar muchos temas sigue una estructuraparecida a la descomposición funcional. Se van estableciendo teoremas que permiten llegar a otrosteorema más complejos. Sin embargo, el orden utilizado en la exposición no coincide con el orden enque se fueron desarrollando estos teoremas. Exponer el diseño de un sistema de forma descendentesuele ser adecuado para la comprensión de éste, pero no es necesariamente la mejor forma derealizar su desarrollo.

7.2.4 Programación orientada a objetosEn los puntos anteriores se han establecido las características que deben tener los sistemas

software al igual que algunos mecanismos de diseño y desarrollo tradicionales. En esta sección sepasará a introducir el enfoque orientado a objetos.

La programación orientada a objetos constituye un paradigma, al igual que otros yaexistentes: programación imperativa, funcional, etc. Un paradigma es elconjunto de teorías,estándares y métodos que representan una forma de organizar el conocimiento. Es, por lo tanto, unaforma de ver el mundo ([Bud94]).

Mediante este paradigma se pretende conseguir software más fiable y modular que con lastécnicas existentes. Para conseguir programas orientados a objetos no basta con utilizar undeterminado lenguaje para realizar la codificación. Como ya se ha mencionado la programaciónorientada a objetos implica una forma de percibir la realidad distinta a la usada en los métodostradicionales.

El aspecto central en la computación no son las funciones sino los objetos. Pensemos cómose organiza la actividad cotidiana en el mundo real. Cuando necesitamos resolver algún problema

Page 177: programacion2

Programación II

180

buscamos el agente adecuado y le solicitamos el servicio requerido. Es responsabilidad del agenteresolver nuestra solicitud y no necesitamos saber cómo se hará. Un ejemplo lo encontramos en elhecho de comprar el periódico: buscamos un kiosco y le indicamos al encargado el nombre delperiódico que deseamos y el nos lo da, acabando la compra con la entrega del dinero. Normalmenteno comprobamos que el periódico es del día actual y no el del día anterior, ya que es responsabilidadde la persona que nos atendió que esto sea así.

En la programación orientada a objetos la acción se produce por el paso de un mensaje a unobjeto (agente). El objeto receptor, en caso de aceptar el mensaje, llevará a cabo la acción indicada.Aunque se pueda ver el paso de mensajes como una llamada a una función, existen diferenciasimportantes: el mensaje posee un receptor y la interpretación de este depende del receptor. El efectode un mensaje puede variar en función del receptor.

Cada objeto es un ejemplar o instancia de una clase. Es la clase de un objeto la queestablece las características de este y a qué mensajes puede responder y de qué forma. Todos losobjetos de una clase usan el mismo método en respuesta a mensajes similares. Así, elcomportamiento de un objeto queda determinado por la clase a la que pertenece. Volviendo alejemplo anterior, si otro día nos dirigimos a otro kiosco y nos atiende una persona distinta, sabemosque todo va a funcionar de manera muy parecida. Sabemos que todas las personas que tienen unkiosco nos atenderán de forma similar, y sabemos que información tenemos que darles para obtenerel periódico, revista, etc. que deseamos.

Entre las clases se podrá establecer una relación jerárquica de herencia que se detallarámas adelante. Esta relación permitirá que una clase hija herede el comportamiento de su clase padre.

Así la computación puede verse como simulación. Se crea una especie de universo con unaserie de elementos (objetos) que tienen definidas las posibilidades de interacción estableciendose elcomportamiento de cada uno de esos elementos. La computación se lleva a cabo por interacciónentre estos elementos. Básicamente se crean una serie de objetos y se les van aplicando una seriede mensajes, y no existe un programa principal como se conoce normalmente.

Además la parte más importante son los datos, los objetos y no las funciones como en otrasmetodologías de programación.

7.3 ClasesEl concepto de clase es el mas importante en la programación orientada a objetos. La

definición que se da en [Mey98] es : una clase es un tipo abstracto de datos con una implementaciónposiblemente parcial (parte la implementación puede delegarse en otras clases). Una clase describelos objetos que son instancia de esa clase, y puede verse como el molde o modelo de los objetos.Además, las clases son las unidades de descomposición del software orientado a objetos. Así , lasclases pueden verse como módulos y como tipos.

La clase determina la información de estado de sus instancias y su interfaz hacia el exterior.Establece los datos, su estructura y tipo, que podrá contener cada objeto, al igual que lasposibilidades de interacción con otros objetos. Así los objetos tendrán estado y comportamiento quequedan determinados por la clase. Por lo tanto, la clase de un objeto determina los atributos de éste yademás qué mensajes aceptará.

Las clases son por tanto un mecanismo de ocultación de información ya que establecen queparte de los objetos será accesible desde el exterior y que parte será privada.

Veamos un ejemplo de cómo se declara una clase en C++

Page 178: programacion2

Programación orientada a objetos

181

class Punto2D // Parte privada private :

int x; int y;

// Parte pública public :

int obtener_x() return x;

int obtener_y()

return y; void init(int a,int b)

x=a; y=b;

;Se observa como un objeto de la clase Punto2D tendrá dos atributos o variables de instancia,

que son las coordenadas. Estos atributos son privados no accesibles desde el exterior. El acceso aatributos y la llamada a métodos de un objeto en C++ es similar al acceso a los campos de un registro

Punto2D p; //p identifica a un objeto de la Punto2Dp.x=4; // error, este atributo es privado

El ejemplo anterior produciría un error ya que el atributo es privado y por tanto no puedeaccederse a él directamente. Aunque se conozca la estructura interior y privada de un objeto, estesolo permitirá el acceso a su parte pública.

Punto2D p;p.init(4,5); //inicializa los atributos del objeto

Gracias a que puede controlarse todo el acceso a la información de estado de un objeto sepuede así asegurar la integridad de esta.

7.4 ObjetosUn objeto es una instancia de una clase durante la ejecución. Cada objeto tiene una identidad

única, independientemente del valor de sus atributos o información de estado. Aunque dos objetostengan los mismos valores para todos sus atributos, son objetos distintos.

Un objeto es una combinación de estado y comportamiento. El comportamiento estádeterminado por la clase a la que pertenece y es común a todas las instancias de esa clase. Sinembargo los valores que establecen el estado de cada objeto en general es diferente. No hay queolvidar que en cierta manera el comportamiento de un objeto también está condicionado por suestado ya que el tipo de respuesta a un mensaje puede depender del estado actual.

Page 179: programacion2

Programación II

182

Figura 7.1: Acceso a un objeto

Como ya se ha mencionado antes, las operaciones que podemos realizar con un objeto sonlas que indican los mensajes que acepta, tal y como se encuentra especificado en la declaración desu clase. Existen, sin embargo, una serie de operaciones comunes como son: creación, inicializacióny la destrucción.

La creación de un objeto involucra una serie de cuestiones tales como cuándo se crea ydónde se almacena. Existen lenguajes como C++ en los que podemos tener objetos que se crean deforma automática y que se almacenen en la pila al entrar en el bloque donde se declaran y objetosque se crean de forma dinámica en cualquier zona del código. El almacenamiento de estos últimos serealiza en el heap. En otros lenguajes como Object Pascal sólo existen objetos dinámicos. Acontinuación se muestra un ejemplo de creación de objetos en C++.

// Inicio de un bloque

Punto2D p; //se le reserva espacio automáticamente al entrar en el bloque

p.init(1,2);

Punto2D *p2; //se declara un puntero a un objeto Punto2D

p2->init(3,4); //error, no se ha reservado espacio;

p2=new Point2D() //se reserva espacio y se crea el objeto en el heap

p2->init(5,6); //se inicializa el objeto

Una vez tratada la cuestión del momento de la creación y lugar de almacenaje de los objetosse pasará a la inicialización. Como se ha visto se puede crear uno o varios métodos que permitan lainicalización del estado del objeto. Sin embargo, algunos lenguajes proporcionan mecanismos paraque esta inicialización se realice durante la creación. Esto evita que utilicen objetos no correctamenteinicializados. En C++ esto se realiza mediante los constructores. Un constructor es un método con elmismo nombre de la clase (con o sin argumentos) que se llama automáticamente durante la creación.

InterfazObjeto

Acceso noautorizado !!!

Page 180: programacion2

Programación orientada a objetos

183

class Punto2D // Parte privada private :

int x; int y;

// Parte pública public :

Point2D(int a,int b) // Constructor

x=a; y=b;

Point2D() // Constructor x=0; y=0;.................

;

En el ejemplo anterior se declaran dos constructores, uno que recibe dos argumentos y otrosin argumentos. C++ permite la sobrecarga de funciones, pudiéndose así tener varios funciones conel mismo nombre y que se diferencien en la cantidad de argumentos y/o el tipo de estos.

Point2D p; //se crea un punto con sus coordenadas inicializadas a 0Point2D p1(3,5); // se crea un punto (3,5)Point2D p2(5); // error, no existe un constructor con un solo argumentoPoint2D *p3, *p4;p3=new Point2D(2,3);p4=new Point2D();

Otros lenguajes como Object Pascal requieren la inicialización explícita.

Por último restaría tratar la destrucción de los objetos. Los objetos almacenados en la pila sondestruidos y su espacio liberado automáticamente. En cuanto a los objetos creados dinámicamente,algunos lenguajes como C++ requieren que el programador explícitamente provoque la liberación dememoria. También dispone de la posibilidad de crear métodos que se llaman automáticamente aldestruir el objeto: destructores. En otros lenguajes como Smalltalk y Java la liberación del espacioutilizado por un objeto se realiza de forma automáticamente mediante un recolector de basura. Este,de forma transparente al programador, se encarga de liberar la memoria utilizada por un objeto apartir de que este ya no pueda ser utilizado.

Los destructores en C++ son métodos que tienen el nombre de la clase precedido por elsímbolo ~ y son llamados de forma automática cuando se destruye un objeto, ya sea dinámico oestático. Para el ejemplo que se esta tratanto, el destructor se declararía como: ~Point2D. La llamadaal destructor se realizaría de forma automática para objetos estáticos y cuando se utiliza el operadordelete para objetos dinámicos. En este caso no sería necesario utilizar un destructor, pero resulta

Page 181: programacion2

Programación II

184

fundamental cuando por el ejemplo un objeto reserva memoria dinámicamente para sus variables deinstancia, ya que es necesario liberar explícitamente esta memoria.

7.5 HerenciaLa herencia es la propiedad de que instancias u objetos de una clase dispongan también de

los atributos y métodos de los objetos de otra clase (clase padre). La herencia establece una relaciónentre las clases, de forma que estas se puedan organizar en una estructura jerárquica.

Una clase hija puede verse como una especialización de la clase padre. Supongamos la claseque representa a los mamíferos. La clase perro podría derivarse de la clase anterior. Claramente unperro es una denominación mas específica de un animal que mamífero. Por otro lado un perro tienetodas las características y comportamiento comunes de los mamíferos y además otrascaracterísticas y comportamiento adicionales específicos de los perros. De esta forma, la clase perroextiende a la clase mamífero. Así, una clase derivada es tanto una especialización como unaextensión de la clase padre, dependiendo del punto de vista utilizado. Una forma habitual de ver larelación entre una clase padre A y una clase derivada B es como una relación “es-un” (B es un A).

Figura 7.2: Herencia

La herencia proporciona un mecanismo potente para la reutilización. Si se dispone de unconjunto de clases, se pueden construir aplicaciones utilizando estas clases y otras nuevasderivadas. Así además de utilizar las clases existentes podemos diseñar otras que se adapten mejora nuestras necesidades derivándolas de las ya desarrolladas sin tener que diseñarlas partiendodesde cero.

El uso de la herencia normalmente implicará un ahorro de código importante, debido a quese reutilizan las clases existentes. Sin embargo, el ahorro en la codificación no debe ser la principalrazón para utilizar herencia. Se debe tener en cuenta la relación entre las clases que se deseaestablecer una relación de herencia. Si disponemos de una clase para representar puntos en elespacio 3D con una serie de métodos tales como trasladar, reflejar respecto a un eje, etc. no tendríamucho sentido crear la clase circunferencia como hija de la anterior, aunque se pudiera aprovechartoda la implementación de esta. Conceptualmente una circunferencia no es un punto, aunque laimplementación de muchos de sus métodos sea igual.

La herencia a la que se ha hecho referencia hasta ahora se denomina herencia simple. Sinembargo también existe la herencia múltiple, o sea, la posibilidad de heredar de mas de una clasepadre. Esto permitiría que una clase tuviese varias clases padre. Esta facilidad no la soportan todoslos lenguajes orientados a objetos.

Clase

Clase derivada2Clase derivada1

Page 182: programacion2

Programación orientada a objetos

185

7.6 PolimorfismoPolimorfismo se refiere a la posibilidad de adoptar varias formas. Esto permite que un

identificador o variable pueda tomar varias formas, lo que quiere decir que puede referenciar objetosde distintas clases. Así, si disponemos de una clase A y una clase derivada B, una variable declaradapara designar un objeto de la clase A, también puede referenciar a un objeto de la clase B. Estoparece lógico, ya que los objetos de la clase B a su vez son objetos de la clase A. Veamos otroejemplo: supongamos que la plase perro es derivada de la clase mamífero. Un identificador paraobjetos de la clase mamífero podrá referenciar objetos de la clase mamífero y también de la claseperro (un perro es un mamífero). Sin embargo un identificador para objetos de la clase perro no podráreferenciar objetos de la clase mamífero. En lenguajes como Smalltalk sin comprobación de tipos,una variable puede denotar cualquier tipo de objeto.

Esto permite que en tiempo de compilación no se necesite saber exactamente de qué claseserá el objeto referenciado por un identificador, aunque si se sabe que pertenecerá a la clase queaparece en la declaración del identificador o una de sus subclases (en los lenguajes concomprobación de tipos). Esto implica que puede haber llamadas a métodos o mensajes que no sepuedan resolver en tiempo de compilación, sino en tiempo de ejecución. Veamos un ejemplo:

class Aprivate : int a;public : virtual void asigna() a=1; ;

class B: public A //es una clase derivada de Apublic : void asigna() a=2; ;

A *puntero; //apuntará a un objeto de la clase A o sus derivadasif (f==1) puntero=new A();else puntero=new B();puntero->asigna();

En el ejemplo anterior no se conoce en tiempo de compilación el objeto al que apunta lavariable puntero. Por tanto, en tiempo de compilación tampoco se puede conocer que método sedebe ejecutar en la ultima sentencia; podría ser tanto el método de la clase A como de la clase B.Esto no se conocerá hasta la ejecución del código. Se produce por tanto una ligadura dinámica entrela llamada al método y el código a ejecutar. Cuando esto se hace en tiempo de compilación sedenomina ligadura estática. En C++ se fuerza la ligadura dinámica mediante la palabra reservada

Page 183: programacion2

Programación II

186

virtual. La ligadura dinámica tiene un coste computacional en tiempo de ejecución, pero por otro ladoofrece mucha flexibilidad.

La sobrecarga de funciones podría considerarse también como un tipo de polimorfismo.Mediante la sobrecarga de funciones y operadores se puede utilizar el mismo nombre de función osímbolo de operador para funciones con una implementación diferente. El tipo y/o el número deargumentos servirá para distinguir que código se debe enlazar. Un ejemplo de sobrecarga defunciones puede observarse en la declaración de contructores en C++ ya que son funciones cn elmismo nombre.

7.7 GenericidadEn muchas ocasiones en los lenguajes con comprobación de tipos se plantea la necesidad de

utilizar clases o tipos abstractos de datos con parametrización de tipos. Imaginemos que disponemosde la clase Lista de enteros. Si necesitamos la clase Lista de valores reales, ésta tendría la mismaestructura interna y el mismo comportamiento que la anterior, salvo por la clase de los objetos quecontiene la lista. No tendría sentido por tanto duplicar la implementación y disponer de una clasedistinta para cada clase de objeto que puede contener la lista.

Para solucionar este problema los lenguajes orientados a objetos suelen permitir declararclases parametrizadas. Así se puede declarar una clase Lista genérica en la que no se especifica laclase de los objetos que contendrá. En el momento de declarar una variable de la clase lista, seindicará además la clase de los objetos de la lista. Este mecanismo favorece la reutilización decódigo.

7.8 Lenguajes orient ados a objetosEn esta sección se describirán brevemente las características de algunos de los lenguajes

orientados a objetos existentes

7.8.1 SimulaSu diseño se completó en 1967, mucho antes de que apareciera el concepto de tipo abstracto

de datos. Su antecesor fue Simula I, un lenguaje para simulaciones de eventos discretos.

Es una extensión orientada a objetos de Algol 60, del que hereda las estructuras de controlbásicas, tipos de datos básicos, etc. En general un programa Algol correcto también lo será enSimula.

Soporta la recolección automática de basura.

El éxito comercial no fue muy grande, sino mas bien a nivel intelectual.

7.8.2 SmallTalkSe desarrollo en los años 70 en el centro de investigación de Xerox en Palo Alto y su última

versión es SmallTalk-80.

Solo utiliza ligadura dinámica y no existe comprobación de tipos. Otra característicaimportante es que las clases también son objetos y en general todo en el sistema Smalltalk es unobjeto. Proporciona un entorno de programación que utiliza técnicas innovadoras para su época:múltiples ventanas, iconos, gráficos, uso del ratón, etc. Permite con facilidad el prototipado. Ademássoporta la recolección de basura.

Disfruto de éxito comercial a principios de los 90.

Page 184: programacion2

Programación orientada a objetos

187

7.8.3 C++Fue diseñado por Bjarne Strouptrup y nació alrededor del año 1986. Su objetivo era obtener

los beneficios de la tecnología orientada a objetos, manteniendo la compatibilidad con C.Normalmente un programa correcto en C lo será en C++.

Permite la ligadura dinámica, pero utiliza la ligadura estática por defecto y no dispone derecolección de basura. Otra diferencia con los lenguajes vistos es la comprobación estricta de tipos.

Ha tenido gran existo comercial, incluso inicialmente, debido a que se esperaba reciclar deforma rápida los programadores de C en programadores de C++, lo cual no ha producido siempre elmejor resultado. También ha sido ampliamente criticado por su excesiva complejidad y por sucarácter híbrido ya que incluye al lenguaje C.

7.8.4 Objective-CFue desarrollado por Brad Cox y es básicamente una capa con los conceptos de Smaltalk

sobre una base de C.

Se pone énfasis en la ligadura dinámica y el polimorfismo.

Ha tenido mucho menos éxito que C++.

7.8.5 JavaFue creado por un equipo de Sun Microsystems en 1996. Su principal innovación es la

tecnología de implementación. La ejecución de los programas Java se hace sobre una máquinavirtual fácilmente disponible. El código Java es traducido a un bytecode (código con formato de bajonivel, portable e interpretable) que es el ejecutado por la máquina virtual. Así, los programas enbytecode pueden ser ejecutados en cualquier plataforma para la que se disponga de máquina virtual.Existen máquinas virtuales para muchas plataformas y además suele venir incorporada en losnavegadores Web. Esto permite además su ejecución desde los navegadores, por lo que la explosiónde Internet a dado un gran impulso a Java. Esto hace de Java un lenguaje muy portable.

El lenguaje utilizado es una extensión orientada a objetos de C.

Uno de sus principales inconvenientes es la eficiencia ya que la ejecución requiere lainterpretación del bytecode por parte de la máquina virtual.

Figura 7.3: Ejecución de programas Java

CódigofuenteJava

Bytecode

Máquina virtual Máquina virtual

Plataforma 1 Plataforma 2

Page 185: programacion2

Programación II

188

7.9 Bibliografía

• [AU87] A.V. Aho, J.A. Ullman. Data structures and algorithms. Addison-Wesley. 1992.

• [Bud94] T. Budd. Introducción a la Programación Orientada a Objetos. Addison-WesleyIberoamericana.1994

• [FGG98] J. Fernández, A. Garrido, M. García. Estructuras de datos. Un enfoque prácticousando C. Universidad de Granada. 1998.

• [Mey98] B. Meyer. Construcción de Software Orientado a Objetos. Prentice Hall. SegundaEdición.1998