programacion2

March 25, 2018 | Author: Luis Castro | Category: Computer Program, Recursion, Programming Language, Software, Software Engineering


Comments



Description

PROGRAMACIÓN IIÁngel Aguilera García Juan Manuel Fernández Luna Miguel Lastra Leidinger Mari Lina Rivero Cejudo Prólogo La asignatura de Programación II aparece en la especialidad de Telemática de la Ingeniería Té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 problema mediante el diseño de un algoritmo y posteriormente, su implantación en un programa escrito en un lenguaje de programación de alto nivel, al alumnado se le presentan en esta nueva asignatura un conjunto de herramientas que le facilitarán las labores de desarrollo. En ella se sentarán las bases necesarias para que el futuro ingeniero técnico pueda afrontar con total garantías de éxito el desarrollo 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 desde un estudio de la recursividad a una introducción de la programación dirigida a objetos, pasando por las técnicas de análisis de eficiencia de algoritmos, los tipos de datos abstractos, algoritmos avanzados 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 un material de referencia básico. Ofrecer al alumnado una amplia bibliografía organizada por capítulos, donde poder estudiar con más profundidad los temas presentados. Además, este libro de apuntes puede utilizarse como continuación a cualquier curso de introducció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 ÍNDICE CAPÍTULO 1: INTRODUCCIÓN.......................................................................................................................9 1.1 CONSTRUCCIÓN DE SOFTWARE .......................................................................................................................9 1.2 FUNDAMENTOS DE DISEÑO DE SOFTWARE ....................................................................................................10 1.2.1 Abstracción...........................................................................................................................................10 1.2.2 Refinamiento......................................................................................................................................... 10 1.2.3 Modularidad .........................................................................................................................................10 1.2.4 Arquitectura del software .....................................................................................................................11 1.2.5 Jerarquía de control .............................................................................................................................11 1.2.6 Estructura de datos...............................................................................................................................11 1.2.7 Procedimientos del software.................................................................................................................11 1.2.8 Ocultamiento de información ...............................................................................................................11 1.3 BIBLIOGRAFIA ..............................................................................................................................................12 CAPÍTULO 2: RECURSIVIDAD......................................................................................................................13 2.1 INTRODUCCIÓN A LA RECURSIVIDAD. ...........................................................................................................13 2.2 DISEÑO DE MÓDULOS RECURSIVOS............................................................................................................... 15 2.3 LA PILA DEL ORDENADOR Y LA RECURSIVIDAD. ........................................................................................... 20 2.4 PASO DE PARÁMETROS A LOS MÓDULOS RECURSIVOS. ................................................................................. 25 2.5 ¿ RECURSIVIDAD O ITERACIÓN ?................................................................................................................... 27 2.6 ELIMINACIÓN DE LA RECURSIVIDAD.............................................................................................................28 2.7 ALGUNAS TÉCNICAS DE RESOLUCIÓN DE PROBLEMAS BASADAS EN LA RECURSIVIDAD. ............................... 32 2.7.1 Divide y vencerás..................................................................................................................................33 2.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............................................................................................ 39 3.2 EL TIEMPO DE EJECUCIÓN DE UN ALGORITMO Y SU ORDEN DE EFICIENCIA....................................................41 3.3 LAS NOTACIONES ASINTÓTICAS....................................................................................................................45 3.4 CÁLCULO DEL TIEMPO DE EJECUCIÓN DE UN ALGORITMO ITERATIVO Y DE SU ORDEN DE EFICIENCIA...........48 3.5 CÁLCULO DEL TIEMPO DE EJECUCIÓN DE ALGORITMOS RECURSIVOS Y DE SU ORDEN DE EFICIENCIA............ 54 3.6 RESOLUCIÓN DE RECURRENCIAS HOMOGÉNEAS. ..........................................................................................56 3.7 RESOLUCIÓN DE RECURRENCIAS NO HOMOGÉNEAS. .....................................................................................57 3.8 RESOLUCIÓN DE RECURRENCIAS MEDIANTE CAMBIO DE VARIABLE.............................................................. 59 3.9 BIBLIOGRAFÍA. .............................................................................................................................................59 CAPÍTULO 4: TIPOS DE DATOS ABSTRACTOS........................................................................................ 61 4.1 INTRODUCCIÓN............................................................................................................................................. 61 4.2 CONCEPTO DE TDA......................................................................................................................................61 4.2.1 Introducción al concepto de TDA.........................................................................................................61 4.2.2 Uso de los TDA en Programación........................................................................................................65 4.2.3 Ejemplo de un TDA...............................................................................................................................66 4.2.4 Consideraciones generales para la elección de primitivas ..................................................................69 4.3 TIPO DE DATOS, ESTRUCTURA DE DATOS Y TIPO DE DATOS ABSTRACTO....................................................... 70 4.4 TIPOS DE DATOS ABSTRACTOS LINEALES ......................................................................................................72 4.4.1 El tipo de datos abstracto “Lista”........................................................................................................72 4.4.2 Pilas...................................................................................................................................................... 85 4.4.3 Colas.....................................................................................................................................................89 4.5 EL TDA ÁRBOL ...........................................................................................................................................96 4.5.1 Introducción y terminología básica ...................................................................................................... 96 4.5.2 Una aplicación: árboles de expresión ................................................................................................100 4.5.3 El TDA Árbol ......................................................................................................................................101 4.5.4 El TDA Árbol Binario.........................................................................................................................108 4.6 BIBLIOGRAFÍA ............................................................................................................................................112 Programación II CAPÍTULO 5: ALGORITMOS AVANZADOS DE ORDENACIÓN Y BÚSQUEDA............................... 115 5.1 INTRODUCCIÓN...........................................................................................................................................115 5.1.1 Algoritmos simples de clasificación ...................................................................................................115 5.1.2 Clasificación por inserción.................................................................................................................118 5.1.3 Clasificación por selección.................................................................................................................119 5.1.4 Complejidad de tiempos de los algoritmos y cuenta de intercambios ................................................120 5.1.5 Limitaciones de los algoritmos simples ..............................................................................................121 5.2 CLASIFICACIÓN RÁPIDA (QUICKSORT) ........................................................................................................121 5.2.1 Tiempo de ejecución del quicksort......................................................................................................125 5.2.2 Mejoras al quicksort. ..........................................................................................................................126 5.3 CLASIFICACIÓN POR MONTÍCULOS (HEAPSORT) ..........................................................................................127 5.4 CLASIFICACIÓN POR URNAS (BINSORT).......................................................................................................128 5.4.1 Clasificación general por residuos (radix sort)..................................................................................130 5.4.2 Análisis de la clasificación por residuos ............................................................................................131 5.5 TÉCNICAS BÁSICAS DE BÚSQUEDA..............................................................................................................131 5.5.1 El diccionario como un tipo de datos abstracto. ................................................................................131 5.5.2 Notación algorítmica ..........................................................................................................................132 5.5.3 Búsqueda secuencial...........................................................................................................................133 5.5.4 Eficiencia de la búsqueda secuencial. ................................................................................................135 5.5.5 Reordenamiento de una lista para alcanzar máxima eficiencia de búsqueda. ...................................135 5.5.6 La búsqueda en una tabla ordenada...................................................................................................136 5.5.7 La búsqueda secuencial indexada. .....................................................................................................136 5.5.8 Búsqueda binaria................................................................................................................................140 5.5.9 Búsqueda por interpolación. ..............................................................................................................141 5.6 ARBOLES BINARIOS DE BÚSQUEDA. ...........................................................................................................142 5.7 HASHING. ...................................................................................................................................................146 5.7.1 Introducción. ......................................................................................................................................146 5.7.2 Funciones hash. ..................................................................................................................................146 5.7.3 Resolución de colisiones.....................................................................................................................147 5.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.........................................................................153 6.1.1 Objetivos de la prueba........................................................................................................................153 6.1.2 Principios de la prueba. .....................................................................................................................154 6.1.3 Facilidad de prueba............................................................................................................................154 6.2 DISEÑO DE CASOS DE PRUEBA. ........................................................................................................155 6.3 PRUEBA DE CAJA BLANCA. ................................................................................................................156 6.3.1 Prueba del camino básico. .................................................................................................................157 6.4 PRUEBA DE CAJA NEGRA....................................................................................................................167 6.4.1 Métodos de prueba basados en grafos. ..............................................................................................168 6.4.2 Partición equivalente..........................................................................................................................169 6.4.3 Análisis de valores límite (AVL) .........................................................................................................170 6.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 .........................................................................................................173 7.1.1 Programación estructurada................................................................................................................173 7.1.2 Programación orientada a objetos .....................................................................................................174 7.1.3 Programación funcional.....................................................................................................................175 6 ÍNDICE 7.1.4 Programación lógica ..........................................................................................................................176 7.2 EL ESTILO ORIENTADO A OBJETOS ..............................................................................................................177 7.2.1 Características del software ...............................................................................................................177 7.2.2 Mecanismos de abstracción................................................................................................................178 7.2.3 Descomposición funcional..................................................................................................................179 7.2.4 Programación orientada a objetos .....................................................................................................179 7.3 CLASES .......................................................................................................................................................180 7.4 OBJETOS .....................................................................................................................................................181 7.5 HERENCIA ..................................................................................................................................................184 7.6 POLIMORFISMO...........................................................................................................................................185 7.7 GENERICIDAD .............................................................................................................................................186 7.8 LENGUAJES ORIENTADOS A OBJETOS ..........................................................................................................186 7.8.1 Simula .................................................................................................................................................186 7.8.2 SmallTalk............................................................................................................................................186 7.8.3 C++ ....................................................................................................................................................187 7.8.4 Objective-C.........................................................................................................................................187 7.8.5 Java ....................................................................................................................................................187 7.9 BIBLIOGRAFÍA ............................................................................................................................................188 7 Capítulo 1: Introducción 1.1 Construcción de software Escribir 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 y documentación, hasta la evaluación de la solución. En este capítulo se propone la metodología de la programación a seguir para resolver un problema tal como se tratará en este libro. Las fases de esta metodología son las siguientes: 1. Formulación y especificación del problema: A veces a la hora de abordar los problemas estos no tienen una especificación simple y precisa. Sin embargo, es posible expresar ciertos aspectos de un problema con un modelo formal y una vez formalizado el problema pueden buscarse soluciones en función de un modelo preciso y determinar si ya existe un problema que resuelva tal problema. Aún cuando no sea tal el caso será posible averiguar lo que se sabe acerca del modelo 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 una primera solución en términos generales. Siguiendo una método de diseño de refinamiento por pasos, llegaremos a una solución escrita en seudolenguaje suficientemente detallada para que las operaciones a realizar con los distintos tipos de datos estén bien determinadas. Entonces se crean los tipos de datos abstractos para cada tipo de datos (con excepción de los datos de tipo elemental como los enteros, los reales o las cadenas de caracteres) dando un nombre de procedimiento a cada operación y sustituyendo los usos de las operaciones por invocaciones a los procedimientos correspondientes. 3. Implementación de la solución: Seleccionar una implementación para cada tipo de datos abstracto, eligiendo una estructura de datos adecuada para escribir un procedimiento por cada operación con ese tipo. El resultado de esta fase será un algoritmo codificado en un lenguaje adecuado que resuelva el problema. A lo largo de este libro el lenguaje que vamos a elegir para la codificación será el lenguaje C. 4. Prueba y documentación de la aplicación: Comprobar que el programa codificado se ajusta a las 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 para segmentos cortos del programa. b) Pruebas de validación: se centra en las funciones externas, realizando pruebas que aseguren que la entrada definida produce realmente los resultados que se requieren, o sea, se comprueba si se está realizando o no el producto adecuado. Para realizar la prueba se deben seleccionar una gran variedad de valores tipos para las variables del programa y prever con anterioridad los resultados que hay que obtener si el programa funciona bien. La prueba sólo puede 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 del programa. 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 las funciones de procesamiento de determinados bloques de código, añadir comentarios sobre los valores que tienen determinadas variables en determinados puntos del programa, 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: 2 Refinamiento Como ya se ha mencionado en el punto anterior. 10 . Refinamiento. En la actualidad suele ser más importante el segundo criterio. Pueden considerarse principalmente tres tipos de abstracción: • Procedimental: una operación puede considerarse como básica a un nivel de abstracción. o cercana a este. Se va realizando un proceso de refinamiento de los detalles procedimentales.1 Abstracción En el desarrollo de una solución software se consideran normalmente diferentes niveles de abstracción. 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én pueden abstraerse. En este proceso se va pasando de un nivel de abstracción mas alto hasta el mas bajo a nivel de la implementación. Así. Se aplica así la técnica de divide y vencerás. Estos componentes se denominan módulos. 1.3 Modularidad En el proceso de desarrollo de software este se descompone en componentes. 1. hasta llegar finalmente a las sentencias de un lenguaje de programación.Programación II • Eficiencia en espacio: espacio que ocupan en memoria las estructuras de datos del programa.2. y que además funcione correctamente.2. • Eficiencia en tiempo: rapidez de ejecución del programa. • • 1. De esta forma el software se hace mas manejable. Arquitectura del software. Estos conceptos se desarrollan a continuación y son: • • • • • • • • Abstracción. a los cuales se le asocia normalmente un nombre. Jerarquía de control. Procedimientos del software. pero estará compuesta de operaciones mas básicas. Modularidad. 1. una solución software se va obteniendo normalmente mediante refinamientos sucesivos de la formulación inicial de la solución.2.2 Fundamentos de diseño de software Tras el proceso de desarrollo de software se desea obtener software que funcione. del problema. ya que es mas sencillo enfrentarse a un problema por partes que a a la totalidad. Estructuras de datos. existirá una formulación de la solución en el lenguaje. que se describen a un nivel de abstracción mas bajo. Ocultamiento de la información. Esta solución se irá refinando hasta obtener una solución implementable. Existen unos conceptos fundamentales en la Ingeniería del Software que persiguen precisamente este propósito. La visibilidad y conectividad de cada módulo son dos características de la jerarquía de control. pero existen sin embargo una serie de estructuras clásicas como son las listas. 1. repetición de operaciones. De esta forma.2. Cada módulo superior podrá ser a su vez un módulo subordinado. etc. etc.2. 11 .2. la descomposición del software en un número excesivo de módulos puede producir un aumento de la complejidad ya que aumentan las interfaces entre los módulos. Estas estructuras de datos se tratarán en detalle en el capítulo siguiente.8 Ocultamiento de in formación Uno 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 esa información. El número organizaciones de datos esta solo limitado por el ingenio del diseñador. Esta relación determina en gran medida el diseño procedimental final. incluyendo secuencias de sucesos. 1. pilas colas.5 Jerarquía de contro l La jerarquía de control se denomina también estructura del programa.4 Arquitectura del so ftware La arquitectura del software se refiere a la estructura jerárquica de los componentes procedimentales y las estructuras de datos. 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). puntos de decisión. 1. estableciéndose la comunicación entre esos módulos solo mediante la información necesaria para realizar su función.6 Estructura de dato s La estructura de datos es la relación lógica entre los distintos elementos individuales de datos. 1. Conectividad: módulos a los que invoca o utiliza sus datos otro módulo.Introducción Sin embargo.7 Procedimientos de l software El procedimiento del software se centra en los detalles de procesamiento de cada módulo individual. existirán módulos subordinados y otros módulos superiores. De esta forma se conseguirán módulos independientes. 1.2.2. Unos mismos requisitos para el software pueden dar lugar a diferentes estructuras y no resulta de forma general sencillo determinar que estructura de las posibles es mas adecuada. La organización de los módulos de un programa se realiza normalmente de forma jerárquica. [Pres94] R. 1998. Aho. Pressman. García. 1994. Ingeniería del software.Mc Graw Hill. 1992. Ullman.Programación II 1. Addison-Wesley. S. [FGG98] J. J. Data structures and algorithms. Fernández. Un enfoque práctico usando C.3 Bibliografia • • • [AU87] A.V. Garrido. A.A. Estructuras de datos. M. 12 . Universidad de Granada. un número natural se define en términos de los naturales anteriores a él. son resueltos de manera sencilla y elegante. procediendo seguidamente a encontrar la solución de estos subproblemas. supongamos que pudiéramos resolver Q mediante la búsqueda de la solución de otro nuevo problema. es decir. cuando n es mayor que 0. es 1 si dicho número es 0. Una vez conocidas estas soluciones. Así. es 1 si n es igual a 0. La recursividad es una técnica ampliamente utilizada en matemáticas. Para ello. La recursividad es una técnica de resolución de problemas muy potente ya que muchos problemas que a primera vista parecen poseer una solución difícil. pero de un tamaño menor que ambos. realiza de nuevo las correspondientes divisiones en problemas más pequeños aún. Si seguimos descomponiendo el problema en otro más pequeño de manera sucesiva tendremos que 3! es 3 * 2!. por tanto. entonces. aplicando de nuevo la definición. por tanto. Para ilustrar el funcionamiento de la resolución de problemas mediante la recursividad indicado en el párrafo anterior. En la definición recursiva del factorial se dice que el factorial de 0 es 1. incluso inmediata de forma recursiva. por que nos permitirán. podremos ir resolviendo sucesivamente los problemas más grandes hasta llegar a la solución buscada del problema primeramente planteado. Si el problema R fuera tan simple que su solución es obvia o directa. dado que sabemos la solución de R.Capítulo 2: Recursividad 2. o n multiplicado por el factorial del número n-1. pero más pequeño. y que básicamente consiste en realizar una definición de un concepto en términos del propio concepto que se está definiendo. por ejemplo 5. que sigue siendo del mismo tipo que Q y P. o el factorial de un número se establece en función del factorial de otro número. soluciones en las que se basará la del problema inicialmente planteado. Calcular 5! se basa en multiplicar 5 por 4!. Veamos algunos ejemplos: • • • Los números naturales se pueden definir de la siguiente forma: 0 es un número natural y el sucesor de un número natural es también un número natural. es 4! = 4 * 3!. supongamos que se pudiera resolver un problema P conociendo la solución de otro problema Q que es del mismo tipo que P. una vez resuelto. finalmente se obtendría la solución definitiva al primer problema.1 Introducción a la recursividad. hemos encontrado la solución al caso más sencillo. Es de vital importancia que exitan dichas soluciones a los problemas más sencillos. Nuestro nuevo problema es ahora calcular 4! que. el obtener el valor de 5! dependerá de cuánto valga 4! (se ha reducido el tamaño del problema en una unidad). para calcular 4! debemos hallar 3!. procederíamos a resolver Q y. En todos estos ejemplos se utiliza el concepto definido en la propia definición. hasta que éstos tengan un tamaño tan pequeño para que su solución se conozca de forma directa. Igualmente. 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 . Este método divide el problema original en varios más pequeños que son del mismo tipo que el inicial. 2! = 2 * 1! y 1! = 1 * 0!. P. La n-ésima potencia de un número x. o el producto de x por la potencia (n-1)-ésima de x. en caso contrario. a partir de ellas. El típico problema que clarifica lo descrito en el párrafo anterior es el cálculo del factorial de un número. R. ir resolviendo los problemas más complejos. El factorial de un número natural n. que sin duda alguna aclarará bastante las ideas y que se sale del ámbito de las matemáticas. dicho problema se reduce sucesivamente a los subproblemas cuyas soluciones se conocen directamente. pues sólo habrá que buscarla secuencialmente. en un cuarto del diccionario. a la hora de diseñar un algoritmo. estaríamos siempre aplicando la misma operación de división de los problemas. pero de alguna forma menores en tamaño. De manera general. aunque en algunos casos estas soluciones no son mejores que las correspondientes iterativas. ya que si no se hace. 3. Si está en la primera. hacemos lo propio con esa segunda parte del diccionario. aquellos problemas que puedan ser resueltos de forma recursiva tendrán las siguientes características: 1. es la más idónea. pero no recursivas. el cual se convertirá en la búsqueda en su correspondiente mitad. no recursiva. 4. Un aspecto que hay que tener en cuenta cuando se está diseñando un algoritmo recursivo es la cuestión de la eficiencia. Los problemas pueden ser redefinidos en términos de uno o más subproblemas. fundamentalmente por que son tan ineficientes que son totalmente impracticables. Otro ejemplo de la resolución de un problema de forma recursiva. tendrá que realizar una o varias llamadas a sí mismo. 14 . En este ejemplo podemos observar cómo el problema se va reduciendo de tamaño: el problema de buscar una palabra en un diccionario se reduce al de buscarla en una de sus mitades. La solución a los problemas más simples se utiliza para construir la solución al problema inicial. hasta que se lleguen a problemas cuyas soluciones se conozcan. De esta forma vamos reduciendo el tamaño del diccionario donde buscamos hasta que tenga el tamaño de una página. situación en la que será fácil localizar la palabra. Por tanto. ofrece soluciones simples y elegantes a algunos problemas. deducimos que la descomposición del problema deberá finalizar en algún momento. se corre el riesgo de no llegar nunca a la solución del problema inicial por estar llamándose el módulo a sí mismo de forma infinita. Cuando plasmamos la solución de un problema de manera recursiva en un algoritmo diremos entonces que ha sido resuelto mediante un algoritmo recursivo. habrá que decidir que tipo de solución. recursiva o iterativa. como ya hemos dicho. sin llegar a ningún lado y con peligro de agotar los recursos del ordenador. decidimos en qué mitad está la palabra buscada. es de vital importancia para aplicar la recursividad determinar aquellos subproblemas cuya soluciones vengan dadas por soluciones directas o conocidas. 2. Este algoritmo. es la búsqueda de una palabra en un diccionario: abrimos el diccionario más o menos por la mitad.Programación II Del ejemplo anterior. momento en el cual será fácil buscar la palabra. buscamos la palabra en dicha mitad utilizando esta misma técnica y si lo está en la segunda. solventando así los problemas mayores que los originaron. Por tanto. La recursividad es una alternativa a la resolución de problemas de forma iterativa y. o en general cualquier módulo. es decir. idénticos en naturaleza al problema original. Aplicando la redefinición del problema en términos de problemas más pequeños. y así sucesivamente hasta que el proceso finalice cuando el diccionario conste tan sólo de una página. Uno o más subproblemas tienen solución directa o conocida. Es muy importante que siempre se determine en qué momento se detendrán las llamadas recursivas del módulo. en cuyo caso irán devolviendo el control a los módulos desde donde fueron llamados. las cuales descompondrán el problema en otros subproblemas con un tamaño más reducido. ya que si no fuera así. y como veremos en las secciones siguientes. En el resto del tema nos centraremos en el estudio detallado de algunos aspectos necesarios para implementar funciones recursivas en C. por lo que se descartará este método de resolución de problemas en caso de tener que utilizarlos. el paso de parámetros a funciones de ese tipo (sección 4). en nuestro caso en C. pero si F posee una referencia a otra función Q. la cual se obtiene de manera directa a partir de la propia definición del factorial (en este caso es una función recursiva directa): long factorial (long n) { if (n == 0) return 1. aunque obviamente se comentará como se solucionarán los problemas tratados. y por analogía con los algoritmos. Comencemos clasificando a las funciones recursivas. el valor para el cual se conoce una solución no recursiva. pero debido a la simplicidad de los problemas que vamos a tratar inicialmente. Sin el caso base la rutina recursiva se llamaría indefinidamente y no finalizaría nunca. Actuar como condición de finalización de la función recursiva. la conversión de una resolución recursiva en iterativa (sección 6). el siguiente paso dentro del proceso de desarrollo del software es realizar su implementación en un lenguaje de programación. y en general cualquier tipo de solución. C++. el funcionamiento de la pila durante la ejecución de una función recursiva (sección 3). utilizaremos el leguaje C para estudiar los conceptos básicos de la recursividad. La existencia del caso base cubre fundamentalmente dos objetivos 1. else return n * factorial(n-1). sería diseñar el algoritmo y posteriormente implementarlo. entonces se dice que es recursiva de forma directa. 1 Como ya hemos dicho anteriormente. Otros como C. y finalmente. las ventajas e inconvenientes de las soluciones recursivas frente a las iterativas (sección 5). entonces F es recursiva de forma indirecta. buscando vías alternativas. como son las consideraciones básicas para crear dichos subprogramas (sección 2). que a su vez contiene una llamada a F. funciones 1 recursivas . creando para ello. la llamada recursiva. } En esta función podemos claramente determinar dos partes principales: 1. que expresa el problema original en términos de otro más pequeño. el esbozo de algunas técnicas de resolución de problemas basadas en recursividad (sección 7). la línea lógica de desarrollo de un programa que implemente una solución recursiva a un problema. 15 . Si una función F contiene una llamada explícita a sí misma. 2. aunque no todos están preparados para ello. y 2. FORTRAN o COBOL no permiten la implementación de algoritmos recursivos. Esto es lo que se conoce como caso base: una instancia del problema cuya solución no requiere de llamadas recursivas.Recursividad Una vez que hemos diseñado el algoritmo recursivo. Pascal o Java sí incluyen esta característica. En nuestro caso. La primera implementación de una función recursiva que vamos a tratar es el factorial de un número. Lenguajes como BASIC. éstos se van a implementar directamente sin necesidad de exponer explícitamente su algoritmo.2 Diseño de módu los recursivos. . 3. La secuencia es la siguiente: 1. . a partir de la suma del tercero y el segundo.Programación II 2. que fue creada inicialmente para modelar el crecimiento de una colonia de conejos. 1. 21. A la hora de resolver recursivamente un problema. Veamos seguidamente. 89. 55. 34. en la figura 1. 13. Se puede observar que el tercer término de la sucesión se obtiene sumando el segundo y el primero. una traza de las llamadas recursivas a la función factorial 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. El cuarto. 2. Es el cimiento sobre el cual se construirá la solución completa al problema. 8. son cuatro las preguntas que nos debemos plantear: [P1] ¿Cómo se puede definir el problema en términos de uno o más problemas más pequeñ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 al problema original? Apliquemos esta técnica de preguntas y respuestas para diseñar una función recursiva que obtenga el valor del n-ésimo término de la secuencia de Fibonacci. 5. 16 .. que se obtendrá sumando los términos n-1 y n-2. El problema es calcular el valor del nésimo término de la solución. 144. Recursividad Las respuestas a la preguntas anteriores serían: [P1] fibonacci(n) = fibonacci(n-1) + fibonacci(n-2). [P4] fibonacci(3) = fibonacci(2) + fibonacci(1) = 1 + 1. [P2] En este caso hay que seleccionar como casos bases fibonacci(1) = 1 y fibonacci(2)=1. 17 . Teniendo en cuenta estas respuestas estamos preparados para implementar la función fibonacci en C: int fibonacci(int n) { if ((n == 1) || (n == 2)) return 1. por lo que siempre se alcanzará uno de los dos casos bases. [P3] En cada llamada a la rutina fibonacci se reduce el tamaño del problema en uno o en dos. 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 serie sería la siguiente: Figura 2: evolución de las llamadas recursivas para calcular fibonacci(3). Se construye la solución del problema n==2 a partir de los dos casos bases. [P2] Si n divide a m. donde m mod n es el resto de la división de m por n (en C se representa con el operador %). n mod m) es de tamaño menor que MCD(m. denominándose a este tipo recursión no lineal. el resto de dividir m entre n es m. pero justifiquemos que MCD(m. Generalmente.. n): 1. [P4] En este caso.. Seguidamente vamos a estudiar tres ejemplos más: el cálculo del máximo común divisor de dos números. cuando se llega al caso base es cuando se obtiene la solución final al problema. El algoritmo de Euclides para encontrar el MCD de m y n es el siguiente: si n divide a m. la solución obtenida al encontrar el caso base sirve para construir las soluciones a los problemas mayores que han derivado al caso base. contar el número de veces que aparece un valor en un vector y calcular el número de distintas formas de seleccionar k objetos de entre un total de n. m % n)). por tanto. la recursión no lineal requiere la identificación de más de un caso base. no negativos. MCD(m. 3. por lo que la primera llamada recursiva es MCD(n. Este problema es un ejemplo. la función en C que implementa el cálculo del máximo común divisor es la siguiente: int MCD (int m. y en otro caso. en contraposición a la recursión lineal producida cuando sólo hay una única llamada recursiva. m mod n) es menor que MCD(m. entonces MCD(m. [P3] Se alcanzará el caso base ya que n se hace más pequeño en cada llamada y además se cumple que 0 ≤ m mod n ≤ n-1. El rango de m mod n es 0. Teniendo en cuenta las respuestas anteriores. . En los dos problemas anteriores. en cuyo caso MCD(m. Apliquemos la técnica de las cuatro preguntas para ver si esta definición está correctamente hecha: [P1] Es evidente que MCD(m. pero hay ocasiones donde esa solución del caso base es la propia solución del problema original. m mod n) es equivalente a MCD(n. por tanto el resto es siempre menor que n. que es el mayor entero que divide a ambos. Se pretende implementar una función recursiva en C que calcule el máximo común divisor (MCD) de dos enteros m y n. entonces MCD(n. lo que tiene el efecto de intercambiar los argumentos y producir el caso anterior. n) = n. m mod n). } 18 . Máximo común divisor de dos números m y n. n) = MCD(n. n). m).. Con ello podremos afianzar la técnica de las cuatro preguntas. n) ha sido definida en términos de un problema del mismo tipo. else return (MCD(n.Programación II Esta función posee dos peculiaridades: tiene más de un caso base y hay más de una llamada recursiva. Si n > m. n) = n. si y sólo si. se conseguirá el caso base cuando el resto sea cero. int n) { if (m % n == 0) return n. 2. Si m > n. m mod n = 0. n-1. por lo que no se construye la solución general en base a soluciones de problemas más simples. Primero+1)).Vector. 19 . el problema a resolver recursivamente consiste en contar el número de veces que un valor dado aparece en dicho vector. Dado un vector de n enteros. el valor a buscar (Objetivo) y el índice del primer elemento del vector a procesar (Primero) y su código es el siguiente: int ContarOcurrencias (int N.Recursividad Contar del número de veces que aparece un valor dado en un vector. la solución al problema original será sólo el número de veces que el valor se encuentra en las posiciones restantes. Si no estuviera en la primera posición. Objetivo. else { if (Vector[Primero] == Objetivo) return(1 + ContarOcurrencias(N. por lo que el número de ocurrencias del valor buscado en dicho array será cero. else return(ContarOcurrencias(N. int *Vector. [P2] El caso base será cuando el vector a inspeccionar no tenga elementos. k.Vector. Comencemos por las respuestas a las preguntas P1. por lo que siempre se formula un nuevo problema con un tamaño menor del vector en una unidad. En este problema típico de combinatoria se desea calcular cuántas combinaciones de k elementos se pueden hacer de entre un total de n (combinaciones sin repetición de n elementos tomados de k en k). por lo que nos aseguramos que en N llamadas habremos alcanzado el caso base. P2. por tanto. n. } } Combinaciones sin repetición de n objetos tomados de k en k. sumando ambos resultados: ncsr(n. Objetivo. k-1) + ncsr(n-1. El resultado del problema inicial se obtendrá. siendo estos dos nuevos problemas instancias más pequeñas que el original de donde provienen y del mismo tipo. La función en C que implementa la solución recursiva al problema en curso tiene cuatro parámetros: el tamaño del vector (N). entonces la solución será 1 más el número de veces que aparece dicho valor en el resto del vector. P3 y P4: [P1] Si el primer elemento del vector es igual que el valor buscado. k) = ncsr(n1. k). [P3] Cada llamada recursiva se reduce en uno el tamaño del vector. int Objetivo. Este enunciado nos sirve para ejemplificar cómo los casos bases pueden ser elaborados en función de varias condiciones. La función recursiva que implementaremos en C se llamará "ncsr" y tendrá dos parámetros: el número total de elementos de que disponemos. y el tamaño del conjunto que se desea generar. el propio vector (Vector). El retorno del control de las sucesivas llamadas comenzará inicialmente sumando 0 cuando nos hayamos salido de las dimensiones del vector. 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 puede descomponer en dos problemas: calcular el número de combinaciones de n-1 elementos tomados de k en k y el número de combinaciones de n-1 elementos tomados de k-1 en k-1. Primero+1)). int Primero) { if (Primero > N-1) return 0. [P4] Cuando se encuentra una coincidencia se suma uno al valor devuelto por otra llamada recursiva. La forma de resolución recursiva que se ha elegido se puede interpretar como la suma de los grupos que se pueden hacer de un tamaño k sin contar a la persona A (ncsr(n-1. int k) { if (k > n) return 0. y en la otra se resta la unidad tanto al número de elementos como al tamaño de la combinación. k-1) (contamos todos los que se pueden hacer con una persona menos y luego le añadimos esa persona). Fijada la persona A. {B. } 2. Así. o lo que es lo mismo.1) será igual a 3 ya que podemos hacer 3 conjuntos de tamaño 1:{B}.D} y {D. n) = 1. k-1). ncsr(n-1. Si k = n. la implementación de la función que acabamos de diseñar es la siguiente: int ncsr(int n. seremos conscientes de los problemas que se nos pueden plantear si el diseño del módulo recursivo no es correcto. entonces este caso sólo ocurre una vez. C y D. 20 . supongamos que tenemos un total de n personas y queremos determinar cuántos grupos de k personas se pueden formar de entre las n personas en total. en cuyo caso el total de combinaciones en este caso es 0. k) + ncsr(n-1. [P3] Considerando que en una de las llamadas recursivas restamos uno al número de elementos. k)=0 con k > n.1)+ncsr(3. que en este caso es A (ncsr(3. En esta sección vamos a estudiar cómo gestiona un ordenador las llamadas recursivas cuando éste ejecuta un módulo recursivo.2). ncsr(n. calculamos cuántos grupos se pueden realizar con una persona menos. Resumiendo: ncsr(n.Programación II Para facilitar el entendimiento del problema. Si k = 0. siendo uno el número de combinaciones en este caso. Cuando el otro sumando alcance a un caso base.2). uno de los sumandos tendrá un valor. Veamos el siguiente ejemplo: tenemos cuatro personas: A.2) será también 3 ya que podemos generar {B. comprendiendo este funcionamiento. calcular ncsr(4. [P4] Apenas se haya encontrado un caso base. y queremos determinar cuántos grupos de dos personas podemos formar.k)).3 La pila del orden ador y la recursividad. {C} y {D}) y se lo sumamos al número de comités que se pueden hacer.C}). utilizada ésta última fundamentalmente en las llamadas a rutinas y que se denomina pila (en inglés stack).0)=0 y ncsr(n. más todos los que se puedan realizar incluyendo a esa misma persona. else return ncsr(n-1. 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. nos aseguramos que en un número finito de pasos se habrá alcanzado uno de los casos bases. Este valor se obtiene al aplicar la definición: ncsr(3. pero sin A (ncsr(3. se podrá hacer la suma y se devolverá a un valor que irá construyendo la solución final conforme se produzcan las devoluciones de control.C}. B. el número total de elementos a tomar coincide con el tamaño del grupo de elementos de donde seleccionarlo. [P2] Los casos bases son los siguientes: ƒ ƒ ƒ Si k > n entonces no hay suficientes elementos para formar combinaciones de tamaño k. también de tamaño 2. Para concluir. else if (n == k || k == 0) return 1. Conforme se van llamando de manera anidada a las rutinas. sobre el que se reservará espacio para el de M2. almacenándose de forma apilada: si M1 llama a M2 y a su vez. se van creando sucesivos registros de activación.Recursividad Cuando un programa principal llama a una rutina M. El registro de activación almacena información como constantes y variables locales del módulo. así como sus parámetros formales. una cuarta zona. a su vez. ya que en él se encuentran los entornos apilados en el orden en que han sido llamados. Esta forma de gestionar ese espacio de memoria permite que el lenguaje de programación correspondiente pueda utilizar la recursividad. de tal manera que el registro activo en cada momento es el que está situado en la cabecera de la pila. se devuelve el control a M2. y por último. El módulo se representa mediante una rectángulo con cuatro divisiones: la primera contendrá el nombre del módulo. E2. devolviéndose el control a M1 y quedando sólo el registro E1 correspondiente a ese último módulo. En la siguiente figura se introduce una notación gráfica para representar el registro de activación de un módulo. Figura 3: estado de la pila cuando se llaman tres módulos de manera anidada. conviviendo los tres entornos mientras se esté ejecutando M3. la tercera la zona donde aparecerán las variables locales e igualmente sus valores. donde aparecerán algunas sentencias que pueden ser interesantes para entender el funcionamiento del módulo. Al acabar M2 se elimina de la pila el entorno E2. creada sólo a efectos pedagógicos. que desaparecerá. y posteriormente para el de M3. ocupando así el lugar más alto de esta hipotética pila. invoca a M3. Cuando finaliza la ejecución del último módulo invocado. quedando ahora la pila compuesta sólo por dos registros de activación. asociado al módulo M. cuando acabe su ejecución. E3. la segunda el área de almacenamiento de los argumentos formales y sus valores. 21 . Como se puede observar. se crea en la pila lo que se denomina el registro de activación o entorno E. la pila estará formada por un primer registro de activación correspondiente a M1. y el espacio ocupado por su entorno se libera. E1. Esta situación queda representada gráficamente en el gráfico de la figura 3. este trozo de memoria recibe el nombre de pila por la forma en que se gestiona. el resto. [P2] El caso más simple en la impresión en orden inverso de una cadena será cuando sólo quede una palabra (n == 1). en n-1 llamadas se habrá alcanzado el caso base. si se introdujera la frase "En un lugar de la Mancha de cuyo nombre no quiero acordarme". que la profundidad de un módulo recursivo sea pequeña no indica que el algoritmo que implemente sea eficiente. que recibirá como argumento el número de palabras que quedan por leer (n). llamamos profundidad de recursión de un módulo recursivo al número de entornos que están presentes en la pila en un momento dado. y en general en todos aquellos que impliquen un procesamiento de listas de objetos. y al igual que ocurrió en el que contaba las ocurrencias de un valor en un vector. 22 .Programación II Figura 4. siendo todos ellos independientes y distintos entre sí. Cabe destacar que en este problema. y recursivamente se llama a la función para que lea e imprima el resto de palabras. El concepto de profundidad se suele usar como elemento de decisión para optar por una solución recursiva de un problema. Así. para que finalmente se imprima la primera leída. se procesará inicialmente el primer elemento de la lista y posteriormente y de forma recursiva. Seguidamente. se evitará utilizar una solución recursiva en aquellos casos en los que la profundidad sea muy grande. Por ejemplo. [P3] Como en cada llamada recursiva hay que leer una palabra menos del total. El problema que vamos a resolver en este caso es la impresión de n palabras 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. ya que problemas que requieran profundidades muy grandes pueden originar que se llene la memoria del ordenador dedicada a la pila (desbordamiento de la pila). 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 hayan efectuado. en cuyo caso se imprimirá directamente. Como regla general. Aplicaremos la técnica de responder a las ya conocidas cuatro preguntas: [P1] Se lee la primera palabra. y mediante un ejemplo. Obviamente. Comencemos a diseñar la función recursiva Imp_OrdenInverso. estudiaremos cómo evoluciona la pila durante las diferentes llamadas recursivas. la función recursiva imprimiría: "acordarme quiero no nombre cuyo de Mancha la de lugar un En". El problema de leer n palabras e imprimirlas hacia atrás se convierte en realizar el mismo proceso pero sobre n-1 palabras. Retomemos el objetivo principal de esta sección. printf("%s". en este caso para la invocación Imp_OrdenInverso(4) y se reservan las zonas de memoria para los parámetros y las variables locales. Palabra). momento en el cual se cumple el caso base. y se lee la última palabra y seguidamente se imprime por la salida estándar. La implementación en C de la rutina Imp_OrdenInverso es la siguiente: void Imp_OrdenInverso(int n) { char Palabra[MAXTAMPAL]. 23 . } } MAXTAMPAL es una constante declarada en algún fichero que albergará el valor del tamaño máximo de una palabra. if (n == 1) { scanf("%s". por ejemplo.Recursividad [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 delante las palabras obtenidas desde la entrada estándar. Este proceso se repite tres veces más. se crea el registro de activación correspondiente. representado gráficamente por las dos siguientes gráficos (figuras 5 y 6). Al llamar a Imp_OrdenInverso(5). Imp_OrdenInverso(n-1). A continuación se produce la llamada recursiva con una unidad menos en n (n==4). "uno". } else { scanf("%s". para lo cual estudiaremos cómo evoluciona gráficamente la pila si llamáramos a la función Imp_OrdenInverso con un valor del parámetro n igual a 5. Este es el momento al que se corresponde la 5. printf("%s". Se comienza a ejecutar el código de dicha función y tras comprobar que no se da el caso base. Palabra). Palabra). Encima del registro de la llamada inicial se crea un nuevo registro de activación. encontrándose en el tope de la pila el registro correspondiente a la llamada Imp_OrdenInverso(1). Palabra). el usuario introduce una primera cadena de caracteres. n == 1. en el cual se reserva memoria para almacenar el valor del parámetro actual n == 5 y la variable palabra. Programación II . Al llegar el tope de la pila al primer registro de activación. Evolución de la pila con las llamadas recursivas. es decir. el caso de n==4. Para finalizar la ejecución de esa función sólo queda imprimir la palabra leída (palabra == "cuatro") y se devolverá el control a la función que la invocó. 2 y 1 respectivamente. se elimina el registro de la pila. El vaciado de la pila según van devolviendo el control las funciones recursivas corresponde a la figura 6. El mismo proceso se repite para las funciones que fueron invocadas con n == 3. En la salida estándar se habrá escrito "cinco cuatro tres dos uno". Figura 5. se imprimirá la palabra "una" y finalizará su ejecución devolviendo el control a la función main. quedando en el tope la llamada anterior. Una vez finalizada la función. tras eliminarse la zona de memoria reservada para alojar a esta función. 24 . se generará una solución incorrecta. De esta forma. las sentencias ejecutadas cuando se dé un caso base deben originar una solución correcta para una instancia particular del problema. Supongamos que modificamos el código de la función factorial y el parámetro formal n que pasábamos por copia. Centrémonos en primer lugar en la forma de pasar los parámetros formales. 4. salida o de entrada/salida (si se pasarán por copia o por referencia) ya que puede influir en la correcta ejecución del módulo recursivo.4 Paso de parámet ros a los módulos recursivos. En caso contrario. ahora lo pasamos por referencia: 25 . 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. Vaciado de la pila con el retorno de las llamadas recursivas.Recursividad Figura 6. hacer hincapié en la corrección de los casos base como forma de evitar una recursión infinita. Hay que asegurarse que cada subproblema esté más cercano a algún caso base. Debemos tener mucho cuidado a la hora de decidir el tipo de paso de parámetros. y por tanto. Aconsejamos al lector para que llegue a comprender totalmente cómo funciona la pila del ordenador. 3. que la pila se agote: 1. que implemente los ejemplos mostrados en este capítulo y los ejecute paso a paso utilizando un depurador. 2. 2. podrá observar cómo va evolucionando la pila conforme se producen las llamadas recursivas. Los casos bases deben ser correctos. por lo que en repetidas llamadas se alcanzará alguno de éstos. es decir. Cada rutina recursiva requiere como mínimo un caso base. sin el cual se generarán una secuencia de llamadas infinitas originando el agotamiento de la pila. Se debe identificar todos los casos base ya que en caso contrario se puede producir recursión infinita. Para finalizar. La regla general para decidir si un parámetro será pasado por copia o por referencia es la siguiente: si se necesita recordar el valor que tenía un parámetro una vez que se ha producido la vuelta atrás de la recursividad. long *fact) { if (n == 0) *fact=1. n no se puede pasar por referencia porque en la vuelta atrás de la recursividad se va a utilizar el valor antiguo que tenía antes de invocar a la función recursiva. resultado a todas luces incorrecto. fact). y por tanto. Ë return (*n==0) * 1 = 0 Ë return (*n==0) * 0 Ë return (*n==0) * 0 Ë fact= 0 En este caso. } } 26 . fact= factorial(*n == 3) ¯ (*n == 2) * factorial (*n == 2) ¯ (*n == 1) *factorial(*n == 1) ¯ (*n==0)*factorial(*n==0) ¯ return 1. Obviamente.Programación II long factorial (long* n) { if (*n == 0) return 1. se podrá pasar por referencia. En caso contrario. tamanio= 3. si convertimos una función en un procedimiento habrá que introducir un nuevo parámetro pasado por referencia para ir guardando el valor solución en cada momento: void factorial (long n. estudiemos la traza correspondiente al siguiente trozo de código para calcular el factorial de 3. ¿qué ocurre en ese momento? Que *n siempre será igual a 0. Pero. else { factorial(n-1. fact= factorial(&tamanio). *fact = *fact * n. } El desarrollo de las llamadas recursivas sería correcto. long fact. return (*n+1) * factorial(n). de tal forma que n se va reduciendo a 0. se devolverá siempre 1. entonces ha de pasarse por valor. por lo que finalmente el factorial de cualquier número será 1. Para aclarar lo anterior. else { *n = *n -1. momento en el cual se comenzarán a devolver las llamadas recursivas al haberse alcanzado el caso base. entonces la recursividad permite expresarlo. utilizaremos esta primera técnica. la cual no tendrá parámetros formales o si los tiene. Los parámetros se pasarán únicamente a la primera y serán globales a la segunda. hubiera hecho int ContarOcurrencias ( int Vector[MAX]. los vectores y las estructuras de datos grandes no se pasarán por copia ya que se requiere una mayor demanda de memoria. si en vez de haber diseñado la cabecera de la función ContarOcurrencias como int ContarOcurrencias (int N. y por tanto implementarlo más fácilmente. 2. o cualquier mecanismo de protección de los parámetros. Se podría solucionar el inconveniente del número y tamaño de los parámetros de tres formas diferentes: a) b) Declarar algunas variables como globales. int Objetivo. reduciendo el tamaño requerido (esta es la solución recogida en la función ContarOcurrencias). int Primero). sobre todos aquellos cuya propia definición es recursiva. si un problema se puede describir en términos de versiones más pequeñas de él mismo. Crear una función y dentro de ella implementar la función recursiva (la primera recubrirá a la segunda). 27 . Generalmente pasar por copia los parámetros necesarios para determinar si se ha alcanzado el caso base. en la mayoría de los casos. y por tanto. Si la rutina acumula valores en alguna variable o vector. para evitar efectos secundarios y escribir un código lo más limpio posible. vectores o estructuras extensas). int Primero).Recursividad El otro aspecto a tener en cuenta en el diseño de la cabecera de un módulo es el número y el tipo de parámetros que se le pasarán. De igual forma. habrá que tener en cuenta que: 1. Cualquier cambio en dicho vector se repercutirá en el resto de llamadas recursivas posteriores. los cuales resueltos iterativamente podrían tener una resolución muy compleja. Como resumen. no pasarlas como parámetros a la función. sólo vamos a tomar como única solución válida la b) olvidando completamente las dos restantes en nuestras implementaciones. Por ejemplo. y en pocas llamadas recursivas se podría agotar. Hacer pasos por referencia. se utilizará la palabra reservada const si trabajamos en C. si no que se pasarán por referencia y para evitar que se puedan modificar de manera errónea. De esta manera se introduce a la función punteros en lugar de los propios objetos. ya que suelen consumir un mayor tiempo de cálculo. Aunque depende del tipo de lenguaje. si existen en él como mínimo el paso por referencia y el paso por copia.5 ¿ Recursividad o iteración ? La recursividad es una herramienta muy poderosa para resolver problemas. 3. int *Vector. en cada llamada recursiva se hará una copia de Vector en la pila y si el tamaño MAX es muy grande. En general. se corre el riesgo de agotar la memoria a las pocas llamadas. serán muy pocos. aunque esto no asegura que dichos algoritmos recursivos posean una eficiencia alta. c) Aunque son soluciones posibles. ya que en cada llamada recursiva la cantidad de memoria ocupada en la pila que se necesita para almacenar los parámetros actuales es muy grande. como ocurre en C. No es conveniente pasar ni muchos parámetros ni parámetros que tengan un tamaño muy grande (por ejemplo. cuando la recursividad nos permita solucionar problemas cuyas soluciones iterativas sean difíciles de implementar. éste debe ser pasado por referencia. int Objetivo. 2. fijémonos en el módulo que resuelve la sucesión de Fibonacci. que conforme aumente n. el número de cálculos repetidos que se realizarán será mucho mayor. fundamentalmente por que no establecen la zona de la pila en la memoria del ordenador. ya que un gran número de llamadas recursivas almacenadas en la pila y pendientes a ser resueltas puede ocasionar que esta zona de memoria se agote. Es de suponer. intentar eliminar la recursividad. En la siguiente sección se hará un breve estudio de esta segunda alternativa. para evitar de esta forma. según estas evidencias. se deberá estudiar la posibilidad de eliminar la recursión total o parcialmente. También. presentando una gran cantidad de procesamiento duplicado que no es aprovechado para evitar llamadas recursivas posteriores. dada la rutina recursiva. 2. hay dos aspectos que influyen en la ineficiencia de algunas soluciones recursivas. Una solución podría ser la utilización de algún tipo de estructura de datos adicional. Si se trazan las llamadas recursivas de fibonacci(6) podremos apreciar cómo fibonacci(4) se calcula dos veces como problemas separados. ya que en cada llamada se aloja en la pila un nuevo entorno. y relacionado con la gestión de la pila en la recursividad. b) La ineficiencia inherente de algunos algoritmos recursivos. En el momento de diseñar un algoritmo recursivo hay que decidir si la facilidad de elaboración del algoritmo merece la pena con respecto al costo en tiempo de ejecución que incrementará la gestión de la pila originado por el gran número de posibles llamadas recursivas. pudiendo bloquearse el ordenador o abortándose la ejecución del programa. debemos plantearnos el buscar una solución iterativa. En una rutina iterativa la operación se realiza sólo una vez y se puede considerar a este tiempo irrelevante con respecto a la ejecución total de la rutina. hay dos alternativas a la hora de diseñar una rutina que resuelva un problema dado: buscar un algoritmo puramente iterativo o. la solución recursiva debe ser evitada cuando implique más inconvenientes que ventajas. como por ejemplo un vector o una lista. es de especial importancia que la rutina recursiva implemente algún tipo de técnica que evite realizar procesamiento duplicado en diferentes partes de la ejecución de la rutina. pero una simple llamada recursiva inicial puede generar un gran número de llamadas posteriores. 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. cálculos redundantes. utilizando para ello dos técnicas principales: 28 . Adicionalmente. cabe destacar que otro problema puede ser el planteado por la profundidad de la recursión. y que tras evaluar convenientemente debemos decidir si 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.6 Eliminación de la recursividad. manteniendo la idea básica que sustenta dicha rutina recursiva. penalizando sustancialmente el tiempo final de ejecución con el tiempo total de creación de los entornos. con el consiguiente tiempo adicional consumido en esta operación. Por ejemplo. fibonacci(3) se obtiene tres veces. Así. cinco veces y fibonacci(1). En estos casos. y como hemos comentado en la sección anterior. tres.Programación II A pesar de estas ideas expresadas en el párrafo anterior. Habiendo decidido que una solución recursiva concreta tiene más inconvenientes que ventajas. si el tamaño del problema a resolver va a ser considerable. fibonacci(2). que mantuviera toda la información calculada hasta el momento. Por tanto. de. Llamada recursiva. a esta llamada se le denomina llamada de recursión de cola. usando.n. else if (n>1) { TorresHanoi(n-1. Actualizar condiciones. Cuando existe una llamada recursiva en un módulo que es la última instrucción de la rutina. while (n>1) { 29 . hacia). char usando) { if (n==1) printf(“Moviendo disco 1 desde el poste %c al %c\n”. de. usando. la rutina quedaría como sigue: void TorresHanoi(int n. char hacia. } Sirva como ejemplo la función recursiva que implementa la solución al problema de las Torres de Hanoi. Actualizar condiciones.de. frente a una estructura con una única llamada recursiva de cola ajustándose al siguiente patrón: if (condición) { Ejecutar una tarea. hacia). problema que se estudiará detalladamente en secciones posteriores. } } Tras eliminar la recursión de cola. char hacia. pero que nos servirá seguidamente para ejemplificar la eliminación de la recursión de cola: La rutina recursiva es: void TorresHanoi(int n. a. char de. hacia). Para eliminar la recursión de cola. printf(“Moviendo disco %i desde el poste %c al %c\n”. char usando) { char temp. sustituyendo la última llamada TorresHanoi(n-1. a. de) por las asignaciones correspondientes para que los parámetros actuales tengan los valores con los que se hacía esta llamada. usando. En algunos casos puede ser suficiente eliminar esta llamada recursiva para obtener alguna ganancia en lo que se refiere a términos de eficiencia y de espacio de pila ocupado.Recursividad a) Eliminación de la recursión de cola. En general. que haga que sea una solución viable la recursiva. de). TorresHanoi(n-1. se sustituye la llamada recursiva por las sentencias que cambian los valores de los parámetros que se utilizan en la llamada a suprimir por los valores con los que se iba a realizar dicha llamada. ya que es equivalente a un ciclo while: while (condición) { Ejecutar una tarea. } El paso a estructura iterativa es inmediato. char de. int Tamanio) { while (Tamanio > 0) { printf(“%c”. se saca de la pila la dirección de retorno y los valores de las variables locales. Cuando se saca de la pila.de. 2 Se recomienda. hacia). la función iterativa obtenida al eliminar la recursión de cola quedaría como sigue: void Imp_OrdenInversoVector(int *Vector. generalmente while. usando= temp. se asignan los parámetros actuales a los formales y se comienza la ejecución por la primera instrucción de la rutina llamada. de. hacia). y sacándolos para comprobar si se cumplen los casos bases. hacia). en lugar de hacer las llamadas recursivas. Tamanio-1). la dirección de la siguiente instrucción a ejecutar una vez que finalice la llamada. así como en algunos casos la dirección de retorno (el lugar donde deberá volver el control cuando la invocación de la rutina termine). int Tamanio) { if (Tamanio > 0) { printf(“%c”. el programador implementa una estructura de 2 datos con la que simulará la pila del ordenador. } } Aplicando el cambio propuesto anteriormente. introduciendo los valores de las variables locales y parámetros en la pila. --Tamanio. Cuando se produce una llamada a una rutina. usando. 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. temp= de. que iterará mientras la pila no esté vacía. la cual imprime de forma inversa un vector de enteros (es una variación de la ya estudiada Imp_OrdenInverso): void Imp_OrdenInversoVector(int *Vector. ya que se recuperan los valores de las variables locales y parámetros formales con los que se haría la llamada recursiva. de. y por último se ejecuta la siguiente instrucción. Vector[Tamanio-1]). } } b) Eliminación de la recursión mediante la utilización de pilas.n. para la perfecta compresión de esta sección que el lector estudie detenidamente el concepto de Tipo de Dato Abstracto Pila 30 . d= usando. Cuando acaba el procedimiento o función. se está simulando el comienzo de la rutina recursiva. En esta técnica de eliminación de recursividad. Imp_OrdenInversoVector(Vector. n=n-1. Vector[Tamanio-1]). } if (n==1) printf(“Moviendo disco 1 desde el poste %c al %c\n”. printf(“Moviendo disco %i desde el poste %c al %c\n”. } Consideremos otro ejemplo: la función recursiva Imp_OrdenInversoVector. se simula la invocación recursiva.Programación II TorresHanoi(n-1. se introducen en la pila los valores de las variables locales. Se añade un bucle. k) + ncsr(n-1. int Suma=0. Pila). pop(Pila). Pila.D. Pila).A. TElemento tope(TPila UnaPila) => Devuelve el valor que ocupa el tope de la pila UnaPila. A continuación. k-1). Pila). else { push(n-1. while (!vacia(Pila)) { k= tope(Pila). push(k.Recursividad Como hemos dicho anteriormente. return Suma. } Obteniendo la siguiente función no recursiva: int ncsr_nr(int n. } } destruir(Pila). eliminaremos la recursividad de la ya conocida función ncsr: int ncsr(int n. push(k-1. void push(TElemento x. push(n-1. void pop(Tpila UnaPila) => Elimina el elemento que está en el tope de la pila. Pila). pop(Pila). Pila). else if ((n == k) || (k == 0)) return 1. Pila=crear(). else if (k == n || k== 0) Suma +=1. se va a utilizar el T. int k) { tPila Pila. push(n. int vacia(TPila UnaPila) => Devuelve 1 si la pila UnaPila no tiene elementos y 0 en caso de que no esté vacía. Pila). n= tope(Pila). TPila UnaPila) => Introduce en el tope de la pila UnaPila el valor de la variable x. } 31 . int k) { if (k > n) return 0. if (k >n ) Suma += 0. push(k. del cual vamos a utilizar en esta 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. else return ncsr(n-1. void destruir(TPila P) => Libera los recursos ocupados por una pila. se introducen en la pila los valores de dichos parámetros (en nuestro ejemplo. son cuatro los valores que se meten. Seguidamente hallamos un ciclo while cuyo cuerpo se ejecutará mientras la pila no esté vacía. 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. en la rutina recursiva se procedería a llamarse a sí misma con diferentes parámetros. Hay que tener en cuenta que.Programación II Después de crear la pila. Ahora. 32 . 2. Si no Meter en la pila los valores con los que se produciría la(s) llamada(s) recursiva(s). De manera general.7 Algunas técnicas de resolución de problemas basadas en la recursividad. En esta sección serán dos las que presentaremos someramente y de las que ofreceremos algún ejemplo para comprender sus fundamentos. habrá que tener tantas pilas como tipos haya para que alberguen los valores de las variables de esos tipos. si los parámetros y las variables locales son de diferente tipo. Son muchas las técnicas de resolución de problemas que utilizan la recursividad como idea básica. Repetir mientras no esté vacía la pila Sacar los elementos que están en el tope de la pila Si éstos cumplen los casos bases Entonces Realizar las acciones que correspondan a dichos casos bases. Si no Llamar recursivamente a la rutina actualizando convenientemente los parámetros de dicha llamada. ¿Qué se hace dentro del ciclo? Se recuperan los valores de n y k de la pila y se comprueban los casos bases. correspondientes con los dos parámetros de las dos llamadas recursivas) para posteriormente. sacarlos y procesarlos. las primeras sentencias con las que nos encontramos son las que introducen en la pila los dos valores de los parámetros formales. En caso contrario. dada una estructura recursiva básica de la forma: RutinaRecursiva(parámetros) Si se han alcanzado los casos bases Entonces Realizar las acciones que correspondan a dichos casos bases. Si se cumplen se actualiza la variable Suma con los valores que devolvería la rutina recursiva cuando se alcanzaran los casos bases. cuando comience de nuevo el bucle. se dividirá el problema de tamaño n en m subproblemas distintos de menor tamaño y se encontrará la solución a cada uno de ellos mediante una llamada recursiva. ¿Cuándo acabaríamos? En el momento en que encontremos el valor buscado. int LimSup. 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). En primer lugar estudiemos el algoritmo de búsqueda binaria recursiva de un valor en un vector ordenado. Una vez decidida la mitad donde deberíamos buscar. aunque hay que avisar de nuevo que no todos los problemas son aptos para ser resueltos por el "divide y vencerás". Si estuviera. como el número de subproblemas en los que se dividirán el primero y la forma de combinar las soluciones dependerá claramente del tipo de problema. El código en C de la función que resuelve el problema es el siguiente: int BusqBinaria(int *Vector. un algoritmo sencillo para resolverlo. o cuando el tamaño del vector sea cero. En caso contrario. En este caso. if (LimInf > LimSup) return -1. 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. y siguiendo las directrices de la técnica "divide y vencerás". Tanto el tamaño umbral en el que se aplicará un algoritmo sencillo. int LimInf. lo que implica que posteriormente no habrá que combinar las soluciones. en donde volveríamos a repetir todo el proceso descrito anteriormente. el problema se divide en dos subproblemas de igual tamaño e independientes entre sí. el problema se resuelve determinando si el elemento buscado está en la posición que ocupa la mitad del vector. el problema original se reduce de tamaño). Si no es así. en la parte derecha. ya que el tamaño del problema permite que sea abordado por un algoritmo que encuentre la solución de forma simple y rápida. pues la solución a un subproblema será la solución general. en caso de que exista.7. que ya utilizamos en la primera sección de este capítulo para ayudarnos a explicar el concepto de recursión. entonces se podrá aplicar. y si fuera mayor. int Valor) { int Mitad. Básicamente. lo que indica que no está el valor buscado. si éste tamaño es menor que un umbral dado. deberíamos buscar en la mitad izquierda. determinamos en qué mitad debería estar el elemento: si fuera menor o igual que el valor de la posición de la mitad. construyendo la solución al problema original a partir de dichas "subsoluciones". no existiendo una regla que pueda determinar dichos valores. Los límites nos indicarán en cada momento el tamaño y el subvector determinado donde buscaremos. Finalmente. y una vez solucionados dichos subproblemas.Recursividad 2. de alguna forma se combinarán para calcular la solución al problema inicial. y será la persona que diseñe el algoritmo quien deberá decidir. todos ellos pasados por copia. y tres parámetros formales enteros: los límites inferior (LimInf) y superior (LimSup) del vector y el valor a buscar (Valor). si se desea encontrar un problema de tamaño n. else { 33 .1 Divide y vencerás. teniendo en cuenta este procedimiento de actuación. Búsqueda binaria. concluiríamos la búsqueda. el problema original (búsqueda de un elemento en el vector completo) lo sustituimos por la búsqueda sólo en una mitad (obviamente. Así. Como se puede apreciar la filosofía de esta técnica se corresponde totalmente con la recursividad. if (Valor == Vector[Mitad]) return Mitad. lo que implica que el valor buscado no está en el vector. en cuyo caso el inferior es mayor que el superior. Mitad-1. se determina en qué mitad está: si lo está en la mitad izquierda (es menor o igual que el valor de la posición central). Dejamos a cargo del lector la comprobación de las cuatro preguntas para validar esta solución recursiva. utilizando como poste auxiliar el restante (C). en donde se desea buscar el valor 8 sería: Posicion= BusqBinaria(VectEnt. Para ello. El estado inicial de los postes y el final es el que queda representado gráficamente en la figura 7 mediante las situaciones 1 y 2: 34 . B y C) y n discos de diferentes tamaños y con un agujero en sus mitades para que pudieran meterse en los postes.Valor). debía elegir a un nuevo sabio del reino tras la jubilación del actual. que esa posición no contenga dicho valor. devolviendo la función -1 para indicarlo. el problema consistía en pasarlos.Programación II Mitad= (LimInf + LimSup)/2. el sabio todavía en ejercicio pensó en un problema y aquella persona que lo solucionara ocuparía su puesto en la corte. es la siguiente: Cuenta la leyenda que el emperador de un país con capital en la actual ciudad vietnamita de Hanoi. donde estaban inicialmente colocados. LimSup. Mitad+1. LimInf. y una vez calculada la posición de la mitad. cumpliendo siempre la restricción de que un disco mayor no se pueda situar encima de otro menor. y considerando que un disco sólo puede situarse en un poste encima de uno de tamaño mayor. del poste A. else if (Valor <= Vector[Mitad]) return BusqBinaria(Vector. else return BusqBinaria(Vector. los límites se convertirán en Mitad + 1 y LimSup. Las torres de Hanoi. Utilizando tres postes (A. Expresada de manera algo abreviada. al poste B. Los autores de [CHV88] hacen una descripción bastante ilustrativa del problema que vamos a abordar a continuación. si lo está en la mitad superior (el valor es mayor que el central). los nuevos límites del vector donde se buscarán serán LimInf y Mitad -1. 8). Valor). } } Tras comprobar que los límites pasados como parámetros actuales se han cruzado. 9. 0. de uno en uno. La invocación inicial para un vector de enteros llamado VectEnt compuesto de 10 elementos. nos queda mover los n-1 discos de C a B. Destino). Solventar el problema para n-1 discos. El monje le dio primeramente la solución para un sólo disco: mover el disco de A a B.1 discos estarán en C y el más grande permanecerá en A. Origen. else { TorresHanoi(NumDiscos-1. diciéndole que el problema era tan fácil . char Auxiliar) { if (NumDiscos == 1) printf("Mover el disco de arriba del disco %c al %c. Origen. 2. cuando un monje budista se presentó ante él . Destino). Una vez hecho esto anterior. en cuyo caso se movería ese disco de A a B (estado 4 de la figura 7).\n". Aplicando de manera directa los tres pasos que el monje indicaba. y el que queda será tan simple de resolver que no hace falta una nueva invocación recursiva. Destino. tendríamos el problema cuando n = 1. pero ahora teniendo en cuenta que el poste destino será C y B será el auxiliar (estado 3 de la figura 7). void TorresHanoi(int NumDiscos.Recursividad Figura 7. Destino y Auxiliar). ignorando el último de ellos. 3. Origen. Auxiliar). aunque nadie lo conseguía. Auxiliar. TorresHanoi(1. Por último. el problema original se divide en tres subproblemas de tamaño menor. char Origen. El problema parecía fácil y fueron muchos los que lo intentaron. char Destino. 35 . que casi se resolvía a sí mismo. para continuar seguidamente con la solución para el caso en que hubiera más de un disco: 1. utilizando A como poste auxiliar (estado 5 de la figura 7). correspondiente a los tres puntos anteriores. Desde el punto de la filosofía de la técnica "divide y vencerás". Por tanto. Estados inicial y final y pasos intermedios a la resolución. dos de los cuales se solventarán utilizando llamadas recursivas. El emperador estaba ya algo desesperado al no encontrar sucesor para el puesto de sabio. Esto sorprendió al emperador y éste le dijo que se lo explicara. los n . presentaremos la función N-1 movimientos y que recursiva TorresHanoi que implementa la solución al problema en un total de 2 tiene como parámetros el número de discos con los que se resolverá el problema (NumDiscos) y tres caracteres que representan a cada poste (Origen. Si se ha conseguido sin que le den jaque. Para comprender el funcionamiento de esta técnica vamos a comenzar viendo cómo se soluciona el "problema de las ocho reinas". 'C'). comentar a modo de curiosidad. Si no se consigue. Supongamos que se han situado las seis primeras reinas. Puede llegar un momento en el que no se pueda situar la séptima sin que ninguna otra le dé jaque. la llamada a la función sería: TorresHanoi(5. se deshace el movimiento que ha ubicado la sexta reina y busca otra posición válida para dicha reina. En general. Al hacer esto. en situar una reina en cada fila y en cada columna. por tanto. se sitúa en la segunda columna la segunda reina y se "tachan" las nuevas casillas prohibidas. } } Para un total de 5 discos. Una solución para el problema de las 8 reinas. El problema consiste en situar ocho reinas en el tablero de tal forma que no se den jaque entre ellas. que otra leyenda cuenta que hay un grupo de monjes que tienen la misión de resolver el problema con un total de 40 discos de oro sobre tres postes de diamante y que cuando finalicen.2 Backtracking. la columna y las dos diagonales. si hay algún problema. 36 . Teniendo en cuenta estas restricciones. se vuelve deshacer el movimiento de la sexta reina y se intenta buscar otra localización.7. la octava. se conoce como bactracking (vuelta atrás). En el caso de que no sea posible. Si se puede colocar. se sigue hacia atrás intentando colocar la reina quinta. En la figura siguiente. el mundo se acabará. el cual tiene un total de 64 casillas (8 filas x 8 columnas). Si no se pudiera. eliminamos como posibles casillas donde localizar reinas la fila. entonces se procederá a dejar en el tablero la sexta de nuevo. En ella se van dando pasos hacia delante mientras sea posible y se deshacen cuando se ha llegado a una situación que no conduce a la resolución del problema original. Partimos de un tablero de ajedrez. podemos observar una solución al problema: R R R R R R R R Figura 8. ¿Cómo actuaríamos para situar las ocho reinas? Comenzaríamos por la primera columna y situaríamos la primera reina. Esperemos por nuestro bien que estos monjes no utilicen un ordenador para solucionarlo . La solución a este rompecabezas pasa. Seguidamente se procede a poner la tercera reina y así sucesivamente.-) 2. se intentará emplazar la séptima y. Por último. Origen). En ese momento. por último. 'A'. 'B'. Destino. se deshace el último movimiento y se prueba a localizar una casilla alternativa. columna o diagonal en la que se encuentra dicha reina.Programación II TorresHanoi(NumDiscos-1. Una reina puede dar jaque a aquellas reinas que se sitúen en la misma fila. Ésta técnica de prueba-error o avance-retroceso. se vuelve hacia atrás y así sucesivamente. Auxiliar. else { *Situada=0. void EliminarReinaDe(int Fila. donde un 1 en una posición indicará que hay situada una reina en ella y un 0. *Situada). Las funciones auxiliares que nos ayudarán a resolver el problema y que no implementaremos aquí por ser irrelevantes al tema que nos centra nuestra atención. Columna) indicando que está ocupada por una reina. else { AsignarReinaA(Fila. en cuyo caso no se hará nada. int RecibeJaqueEn(int Fila. int *Situada). se vuelve hacia atrás y se prueba otras posiciones. Columna)) ++Fila. que no está ocupada. Por tanto. Para implementar la función recursiva en C que encuentre una solución para el problema de las ocho reinas necesitaremos una estructura de datos para representar el tablero. Columna). SituarReina(Columna+1. son: ƒ ƒ void AsignarReinaA(int Fila. Columna). while (!(*Situada) && (Fila <= 7)) if (RecibeJaqueEn(Fila. ¿El caso base? Cuando el tablero se haya reducido a uno de tamaño cero. momento en el cual habremos alcanzado el caso base. y Situada. se hace una llamada recursiva. Columna se pasará por copia para que. La llamada a esta función se hace con Col == 0 (primera columna de la matriz). Inicialmente todos los elementos de la matriz bidimensional serán 0. Fila=1. int Columa) => Sitúa un 1 en la casilla (Fila. int Tablero[8][8]. void SituarReina(int Columna. ƒ La función recursiva que implementaremos será void SituarReina(int Columna. indicando que no hay ninguna reina en el tablero y será global a la función recursiva. es un parámetro que tomará el valor 1 cuando se haya logrado ubicar correctamente a la reina correspondiente. El parámetro formal Col indica en qué columna se quiere situar la reina. y 0 cuando el intento haya sido infructuoso. Columna) indicando que esa casilla está libre (antes estaba ocupada por una reina y ahora deja de estarlo). si no. pasado por referencia. con objeto de ahorrar espacio en la pila. Columna) recibe jaque de alguna reina y 0 en caso contrario.Recursividad ¿Cómo se puede aplicar la recursividad para solventar este problema? De forma muy sencilla: dado que se ha situado una reina en una posición correcta en una columna. if (Columna > 7) *Situada=1. para lo cual utilizaremos una matriz de tamaño 8 x 8 de enteros. Si se puede situar una reina en la columna correspondiente. if ( !(*Situada)) { EliminaReinaDe(Fila. 37 . int Columna) => Devuelve 1 si la casilla (Fila. int *Situada) { int Fila. En este momento realizaríamos una llamada recursiva. cuando se realice backtracking se pueda trabajar con el valor original de esta variable. se partirá de un problema de tamaño 8. hay que considerar el problema de situar otra reina en la columna siguiente: resolver el mismo problema con una columna menos. int Columna) => Sitúa un 0 en la casilla (Fila. y se considerarán problemas de tamaño menor quitando una columna en cada llamada recursiva hasta que se llegue a un tablero de tamaño cero. John Wiley and Sons (1. tarea que dejamos a cargo del lector. Berlioux. se comprueba si se está en el caso base. Programación: parte teórica. Cuando han finalizado las llamadas recursivas. si no todas las existentes.997). A.998). Ullman.988). En otro caso. Addison-Wesley (1. Rivero Cejudo.988). En el bucle se comprobará si en la casilla (Fila. En el caso de que no. Data structures with C++. P. [Sed98] R. Metodología de la programación. se comenzará un bucle que iterará mientras no se haya situado correctamente la reina dentro de la columna especificada en el parámetro. [FT96] W. Si no recibiera jaque una reina situada en esa casilla. en cuanto a las filas se refiere. J. Universidad de Jaén (1.C. se incrementa la fila para probar en otra casilla de la misma columna. Algorithms in C. Aho. Masson (1. en cuyo caso se indica que se ha situado la reina en una casilla libre. Addison-Wesley (1. J. Wirth. Estructuras de datos con C y C++. Prentice Hall (1986). Bizard. Helman. Data abstraction and problem solving with C++.990). [BB86] P.986). Hopcroft. Ford. Langsom. proof and analysis of programs. se deshace la asignación anterior y se incrementa en una la fila para probar con otra casilla. Veroff. [Wir86] N. R. rd nd 38 . 2ª Edición. P.J. Colección Apuntes.8 Bibliografía. Sedgewick. no sólo una solución posible. 2. 84-604-7652-9 (1. 3 Edition. Programas y estructuras de datos en Pascal. } } } } Básicamente. Si no es así. Columna) se puede situar la reina.Programación II ++Fila. Concepción y análisis. se asigna a la posición del tablero y se llama recursivamente a la función SituarReina para situar en la siguiente columna otra reina.996). De forma sencilla se puede modificar esta función para que calcule. Addison-Wesley (1. 2 Edition. Algorithms. Brassad. [BB90] G. The construction. Algorithms and data structures. ISBN. o nos hayamos salido fuera del tablero. Prentice Hall (1. Addison-Wesley (1. Prentice Hall International (1. Pons. Bratley. W. Canorro. [Riv99] M. O. [LAT97] Y. • • • • • • • • • • • [AHU88] A. M. Cubero. 2 Edition. Tenenbaum. Estructuras de datos y algoritmos. Sedgewick.988). [Sed88] R. [CHV88] F. [CCP93] F. Cortijo.993). Topp. P. Augenstein. L. Algorithms in C. se comprueba el valor de Situada para ver si ha habido éxito en el resto de intentos.E. nd Walls and Mirrors.999). Algorítmica. J. calculando el número de acciones elementales ejecutadas por un procesador. y teniendo en cuenta los avances tecnológicos actuales podríamos pensar: ¿por qué deberíamos preocuparnos por la calidad de un algoritmo. 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. alternativamente. Si cambiamos el ordenador que estamos usando por uno 100 veces más rápido. Por otro lado. ya que este aumento de las prestaciones no implica una clara mejora en el tiempo de ejecución. Si nos centramos en las dos primeras. es evidente que si un programa se ejecuta lentamente en una máquina determinada. o si éste consume mucha memoria. la velocidad quedará cuantificada determinando el tiempo que tarda en finalizar el programa o. 3.1 Introducción al análisis de la eficiencia. Si fuera 20. resolveríamos el problema con 45 datos en un año. añadirle algunos módulos de memoria más? De hecho. si mejoramos las prestaciones. Si n=10. A pesar de todo lo anteriormente comentado. Sólo se mejoraría de manera importante si se modificara el diseño del programa. Teóricamente. el programa terminará en 0'1 segundos. 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. 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. Una vez que conocemos qué criterios se pueden usar para decidirnos por un algoritmo u otro. lograremos que finalice más rápidamente. 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. mejora que no es del todo significativa. aproximadamente 10 minutos. o el que finalice la ejecución más velozmente. si lo único que tenemos que hacer para que un programa vaya más rápido es utilizar un ordenador con más potencia. 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. éstas pueden ser cuantificadas más fácilmente. e incluso de la pericia del programador (si cambiamos alguno de estos elementos probablemente se obtengan resultados diferentes. y de entre éstos escoger el mejor. 30. Cuando se nos propone realizar un programa para resolver un problema puede ser interesante plantearnos el diseño de varios algoritmos. Supongamos que un ordenador es capaz de ejecutar un programa cuya entrada son n datos -4 n en 10 2 segundos. 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. un año. con lo cual no podemos establecer una eficiencia empírica absoluta). Esta afirmación no es del todo correcta. sustituyendo este algoritmo anterior por uno más eficiente. .Capítulo 3: Análisis de la eficiencia de los algoritmos. del lenguaje de programación usado y del traductor con el que se obtenga el código ejecutable. un día y 40. 40 . a realizar la misma tarea pero en algoritmos recursivos en la última sección de este capítulo. concepto al que ya hemos hecho referencia en párrafos anteriores y que debemos clarificar. 10 o 20. Estos inconvenientes no se encuentran cuando se lleva a cabo un estudio teórico. así como su orden de eficiencia. Existe una tercera línea a la que podríamos calificar como híbrida entre empírica y la teórica. como puede ser la regresión. 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. el valor de ese número entero es el que nos determinaría el tamaño de nuestro problema. Si ahora debiéramos estudiar la eficiencia de un algoritmo para el cálculo del factorial de un entero. En este capítulo nos vamos a centrar exclusivamente en el estudio o análisis teórico de la eficiencia de los algoritmos. mediante técnicas estadísticas. y del número de carreteras que unan dichas poblaciones. 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. sino que tendrá que obtenerse según las características de cada uno. que cosiste en encontrar una ruta que una un número de ciudades pero con coste mínimo. Si dicho valor crece. a todos aquellos cuyo crecimiento. pues se realiza independientemente de todos los factores anteriormente citados. dependa del tamaño del problema o de los datos de entrada. En este caso. Cuando el total de poblaciones a unir es bajo. pero si ese número se elevara a 100. Tras esta introducción. No existe una regla exacta para determinar el tamaño de un problema. por ejemplo en kilómetros a recorrer. De manera general. que dependen de la implementación concreta. para pasar a continuación. ahora mismo no existiría ningún ordenador capaz de encontrar la solución en un tiempo razonable.Programación II 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. 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. el propio valor que tiene ese ejemplo (el valor del entero al cual se le quiere hallar su factorial). aunque sea de manera intuitiva para poder seguir adelante. Dicho tamaño puede venir expresado no sólo en una dimensión. a igual que ocurre con el tiempo. aunque se podrán realizar las mismas consideraciones con respecto al consumo de memoria e incluso a otros recursos. 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. como ocurre en los ejemplos anteriores. el resultado lo obtendremos en unos pocos segundos. Supongamos que tenemos entre manos el estudio de la eficiencia de un algoritmo de ordenación de un vector de n enteros. en la siguiente sección definiremos el tiempo de ejecución de un algoritmo. n es el tamaño de los datos de entrada. como es el caso del viajante de comercio. cuestión que trataremos detalladamente más adelante. se puede correr el peligro de que el tiempo de ejecución crezca de manera exagerada. Es el caso del tamaño de un vector. aunque sólo sea un poco. En general. sino puede depender de varias. Un ejemplo es el problema del viajante de comercio. posteriormente determinar los parámetros numéricos de las funciones matemáticas obtenidas. o por ejemplo. que consiste en hacer un estudio teórico del algoritmo en cuestión y. y más concretamente en su vertiente del tiempo. para pasar a formalizar las notaciones asintóticas en la sección 4. ya que la eficiencia de un algoritmo que lo resuelva vendrá dada en función del número de poblaciones a visitar. pero sólo un cambio de algoritmo nos permitirá obtener una mejoría mayor cuanto más crezca el tamaño de los ejemplos. al contrario que ocurre con el mejor caso. En este capítulo. Una vez definido el tamaño de la entrada. el del peor y del mejor caso. 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.. el tiempo de ejecución aplicado a un vector ordenado crecientemente es proporcional al número de elementos del vector. milisegundos. y probablemente el comportamiento del algoritmo sea algo mejor que el obtenido por el análisis. En general se suele dejar sin 1 especificar las unidades empleadas en T(n) y se asume que n≥0 y T(n) es positivo. operaciones aritméticas. los tiempos son diferentes. para resolver un problema de tamaño n. para representar el número de unidades de tiempo (segundos. se suele utilizar más a menudo que el mejor caso. indicando que el algoritmo. n.Análisis de la eficiencia de los algoritmos. En este otro algoritmo. 1 Hacerlo así no es restrictivo. que toman t1(n) y t2(n) unidades de tiempo..) que un algoritmo tardaría en ejecutarse con unos datos de entrada de tamaño n. válido independientemente tanto del ordenador que estemos utilizando. se calculará la expresión analítica del peor caso. 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. al caso promedio. comparaciones.) que se ejecutan.. sino el número de instrucciones simples (asignaciones. y si lo aplicamos al vector ordenado decrecientemente. donde cada una de las instrucciones simples consumen una unidad de tiempo. existe un c>0 tal que t1(n) ≤ ct2(n).2 El tiempo de ejecución de un algoritmo y su orden de eficiencia. salvo que se diga lo contrario. como del compilador. cuando tratamos con el algoritmo de ordenación mediante inserción. ya que si T(n) es el tiempo de ejecución en un ordenador ideal.. El principio establece que dos implementaciones distintas de un mismo algoritmo. no diferirán en eficiencia en más de una constante multiplicativa. T(n). 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. aunque los abundantes y a veces complicados cálculos para realizar un análisis de eficiencia del caso promedio complican notablemente su uso. Así. Sin embargo. 41 . Este es un ejemplo en el que ambos tiempos. 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). o de forma equivalente. dicho tiempo se incrementará de manera proporcional al cuadrado de n. respectivamente. 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.. Ahora bien. del tiempo de ejecución para cualquier entrada al algoritmo. El caso promedio será una media ponderada de ambos casos. el tiempo de ejecución del algoritmo en un ordenador idealizado. coinciden. estamos indicando un límite superior. no se ejecutará por debajo de ese tiempo. donde ofrecemos un límite inferior. y otros con los que finalice antes. Aunque el peor caso suele ser bastante pesimista.. sería preferible que T(n) no represente un tiempo. el máximo valor. En el primero de ellos. Expresado matemáticamente. o al mejor caso. resulta conveniente usar una función. se podrá hacer que un programa vaya 10 ó 1000 veces más rápido cambiando de máquina. lo que nos llevará a ignorar las constantes multiplicativas a todos los efectos. 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. 3. y se ejecuta o no el bloque then. ci. 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. resta. 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. división. La evaluación de una expresión tendrá como tiempo de ejecución lo que se tarde en realizar las operaciones que contenga. los ejemplos serán implementados en C y a partir de dicha implementación se estudiará su eficiencia. Consideraremos. Como hemos considerado trabajar siempre con el peor caso. 42 . Por último. tendrán un costo unidad. En la quinta línea se tendrán en cuenta dos constantes: una por el incremento del contador j .j++) if (Vector[j] < Vector[min]) /* 6 */ /* 7 */ min= j. Si consideramos que esas constantes son la unidad. Para un bucle. int n) /* 2 */ { /* 3 */ int j. multiplicación. Agrupando todo lo anterior en una única expresión tendríamos: T(n)= ca + (ci + ce)(ce+ca)(n-1) + cr. por lo que contabilizará una constante ca. La línea 4 pertenece a una asignación. y otra por la evaluación de la condición booleana. se denominarán operaciones elementales. más dos veces adicionales correspondientes al caso en que j == n+1. la suma de los tiempos de cada una de las instrucciones. que las operaciones de suma. /* 8 */ return min. 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. 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. El cuerpo del bucle podrá tener como coste ce ó ce+ca. módulo y similares son operaciones elementales y por tanto. Una asignación a una variable simple de una expresión. dependiendo de si la condición del if se evalúa verdadera o falsa. /* 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. que consumirá una constante cr. min. por provenir de operaciones elementales. que sólo depende de la implementación. Aunque posteriormente lo estudiaremos con detalle. 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. En nuestro caso. y a partir de este momento. al igual que una operación de escritura de una expresión.Programación II ¿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. Hemos considerado hasta ahora que el análisis de la eficiencia se hace siempre sobre un algoritmo. nos queda la línea 8. /* 4 */ min= 0. que incrementarán el tiempo de ejecución tantas veces como se ejecute el bucle: n-1 veces. al habernos desprendido de las constantes multiplicativas. j<n. haciendo cálculos obtendremos T(n)= 4(n-1) + 4= 4n. Por tanto. Una secuencia de instrucciones. esbozaremos seguidamente algunas ideas. Veamos un primer ejemplo para identificar intuitivamente estos conceptos. Todas aquellas instrucciones cuyo tiempo de ejecución queda limitado superiormente por una constante. 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. /* 5 */ for (j=1. sin tener en cuenta la implementación en un lenguaje de alto nivel. Una lectura de una variable requiere un tiempo constante. 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. ce. por lo que empezaremos por la línea 4 a hacer el cálculo del tiempo de ejecución. por tanto. 43 . Un algoritmo será. En vez de decir que el tiempo de ejecución del algoritmo es T(n)=4n+3. y ln n como el logaritmo en base e de n (loge n). si n=1.000. Además. 2 f(n)= n => Cuadrática. f(n)= log n => Logarítmica. para tamaños de problemas de dimensión n. Con este ejemplo podemos ver cómo es más importante la forma de las funciones (n y n ). A es mejor que B.Análisis de la eficiencia de los algoritmos. 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). se indicará simplemente log n. f(n)= n => Cúbica. A es el doble de rápido que B. pero en el caso de que n≥50. Se dice que un algoritmo necesita un tiempo de ejecución del orden de una función cualquiera f(n). 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. lo que implica que el tiempo de ejecución es alguna constante multiplicada por n. n f(n)= k => Exponencial. f(n)= n => Lineal. diremos que T(n) es del orden de n. Como se puede observar. parece lógico que nos olvidemos de calcular los factores multiplicativos al analizar un algoritmo. Cuando nos dé igual la base. siendo la función f(n) la que marca cómo crecerá dicho tiempo de ejecución cuando aumente n. lo resuelven mediante las 2 funciones TA(n)=100n y TB(n)=2n . dejando a un lado cuestiones como la velocidad del ordenador y la eficiencia del compilador. Las funciones que nos marcan más comúnmente el orden de crecimiento de los algoritmos y 2 los nombres por las que se les conocen son las siguientes : f(n)= 1 => Constante. 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. más eficiente que otro si el tiempo de ejecución del peor caso tiene un orden de crecimiento menor que el segundo. y en general. por ejemplo. ¿Cuál deberíamos usar? Si n<50. A es mejor que B. En este momento ya estamos en condiciones de aclarar el concepto de eficiencia. por tanto.A y B. respectivamente. Es por esto. 3 k 2 Notaremos lg n como el logaritmo en base dos de n (log2 n). que las constantes (2 y 100). dicha eficiencia sólo depende del tamaño del problema. A es 20 veces más rápido que 2 B). Supongamos que para un cierto problema dos algoritmos . haciéndose mayor la ventaja de A sobre B conforme n crece (si n= 100. f(n)= n => polinómica. f(n)= nlog n => Quasilineal. por lo que se especificará mediante una función de crecimiento. 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.000.000 1. 3 2 3 los tiempos de ejecución T1(n)= 2n + 4n + 5 y T2(n)= n .000. A pesar de esto. los algoritmos que resuelven un problema en tiempo polinomial. debiéndose elegir un algoritmo cuya aplicación sea la más fácil.000 n 1.931. En el caso de las funciones las polinómicas. multiplica el tamaño del problema que puede tratar por un factor c . los órdenes de eficiencia son clases de equivalencia de funciones.000.Programación II En general. al duplicar el tiempo se pueden tratar problemas aproximadamente del doble de tamaño.877 1. Nosotros destacamos dos principalmente: • Si un programa se va a ejecutar pocas veces. al multiplicar el tiempo disponible por un factor c en un orden del tipo k 1/k n .000 100. El mejor orden es el logarítmico. son funciones que pertenecen a la 3 misma clase de equivalencia cuya representante es la función f(n)= n . el costo de escritura es el principal.000 3 2 1.000. mientras que doblar el tiempo disponible permite tratar problemas enormes en relación con el original. y análogamente. En la siguiente tabla se puede observar el crecimiento de algunas funciones conforme crece el tamaño n del problema. Por ejemplo. se consideran manejables.000 1'0e+12 1'0e+15 1'0e+18 1.4n.000. con lo cual necesitamos mucho tiempo para resolver un problema que ha crecido relativamente poco en tamaño. Básicamente.000 10. ya que exigen unos recursos prohibitivos.000 100. suele admitirse como algoritmo eficiente aquel que alcanza como mucho un coste quasilineal.000 1.964 19. y cuando se calcula el orden de un algoritmo. Las tasas de crecimiento más frecuentes se pueden comparar en la representación gráfica de la figura 1.000.000. 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. ya que al doblar el tamaño del problema apenas afecta al tiempo de ejecución.569 n 2 n 100 10.000 Tabla 1.000 10.000. log n n 10 100 0 3 7 10 1. no influyendo en dicho costo el tiempo de ejecución.660.000.000 1.024 1'267650600228e+30 1'071508607186e+301 Enorme Más enorme Sin comentarios 1. 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.000 nlog n 33 664 9. incluso para casos de tamaño moderado. 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. Cualquier coste superior al polinómico es un coste que se califica como intratable.000. Esas clases de equivalencia se forman con funciones que son equivalentes según el principio de invarianza expuesto anteriormente. 44 .000.966 132. Crecimientos de las funciones más comunes del orden de eficiencia. 3. En esta sección vamos a introducir las notaciones que serán utilizadas para razonar sobre la eficiencia de los algoritmos. la o minúscula y las notaciones Θ y Ω. supongamos dos implementaciones de un mismo algoritmo cuyos tiempos de ejecución 2 3 son 100n y 5n . entonces la velocidad de crecimiento del tiempo de ejecución puede ser menos importante que las constantes multiplicativas. y será la que utilicemos de la siguiente sección en adelante. 45 . 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. Se cumple que para n < 20. Son tres las notaciones asintóticas que vamos a tratar: la O mayúscula.Análisis de la eficiencia de los algoritmos. ya que 3 2 debido a la constante 5. nos decantaremos claramente por la primera implementación. el segundo programa será más rápido que el primero. Por ejemplo. ya que 5n estará siempre por debajo que 100n . Representación gráfica de los órdenes de crecimiento más frecuentes. Figura 1. Dichas notaciones se califican como asintóticas debido a que el estudio de los órdenes de eficiencia se hace en casos límite. en este caso menores que 20. ya que la velocidad de crecimiento es menor. conviene ejecutar el segundo. por lo que si el programa se va a ejecutar normalmente con entradas pequeñas.3 Las notaciones asintóticas. Otra cuestión es que esas entradas sean muy grandes. • Si un algoritmo se va a ejecutar con entradas pequeñas. en cuyo caso. aunque nos centraremos algo más en la primera que es la que representa el peor comportamiento. respectivamente. al contrario que lo que dijimos varios párrafos atrás. es decir. 000n + 100n . en cuyo caso se notará como g(n) ∈ O(f(n)) (g es del + orden de f). ya que existen dos constantes positivas n0= 2.m) } + Algunas propiedades interesantes de la notación O(f). T(n)= 1. la cual es del orden de f(n). O(f(n)).6 ∈ O(n ). hay problemas. h: • • • • • ∀c ∈ R .001n . n2) se tiene que f(n)+f'(n) ≤ (c1 + c2) max(f(n). g ∈ O(f) si y sólo si c· g ∈ O(f) [Invarianza multiplicativa].∀n ≥ n1. el tiempo g'(n) empleado por cualquier otra implementación que difiera de la primera en el lenguaje y en el compilador utilizado. n n Como ya dijimos anteriormente. notado como O(f(n)). 2 2 T(n) = 6· 2 + n ∈ O(2 ) debido a que se pueden encontrar dos constantes n0= 4. m)). que acote superiormente el crecimiento de otra g(n). o la propia máquina. f'. entonces g + h ∈ O(max(f. n 2 n T(n) = 3 ∉ O(2 ). y c0=7 que n 2 n hacen que 6· 2 + n ≤ 7· 2 . para cualesquiera que sean las funciones f. tal que 3n+2 ≤ 4n. 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. n2 / h(n) ≤ c2f'(n). Veamos algunos ejemplos: T(n)= 3n + 2 ∈ O(n). las funciones g tales que f llega a ser en algún momento una cota superior para g. La definición formal es la siguiente: sea f:N→R ∪{0} una función cualquiera. Demostración: Si g(n) ∈ O(f(n)) entonces ∃c1. también será del orden de f(n).m) ≤ c0f(n. m). representa el conjunto de funciones g que crecen como mucho tan rápido como f. g(n. La notación O mayúscula. 46 . ∀ n ≥ n0 y m ≥ m0. f')) [Regla de la suma]. ya que son básicas para el análisis de la eficiencia de un algoritmo: • Si g ∈ O(f) y h ∈ O(f'). como son los que tratan con grafos. Entonces c0 ≥ (3/2) para cualquier valor n≥n0. 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. Supongamos que existen dos constantes n0 y c0 tales que para todo n≥n0. f(n). el conjunto de las funciones del orden de f(n. Si h(n) ∈ O(f'(n)) entonces ∃c2. n1 / g(n) ≤ c1f(n). notado como O(f(n. n n n se tiene que 3 ≤ c02 . o lo que es lo mismo. ∀ 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).m0∈ N. en los que el tiempo de ejecución depende de más de un parámetro. el conjunto de las funciones del orden de f(n). se define como sigue: O(f(n.] g ∈ O(f) si y sólo si O(g) ⊆ O(f) [Criterio de Caracterización]. En definitiva se trata de buscar una función sencilla. pero esta desigualdad anterior n no se verifica nunca ya que no existe ninguna constante suficientemente grande que (3/2) para todo n. ∀n ≥ n2.m)) = { g ∃c0 ∈ R y ∃n0. g.Programación II Las notaciones 0 mayúscula y o minúscula.g(n)).000 que hacen que se 2 2 cumpla que 1. y c0=4. + + ∀c ∈ R .000n + 100n . por que existen n0= 100. y c0=1.6 ≤ 1. ∀n ≥ max(n1. se define: O(f(n)) = { g ∃c0 ∈ R y ∃n0 ∈ N. Mención especial merecen las conocidas como reglas de la suma y del producto. el del código completo pertenecerá a O(nlgn). ∀ n ≥ n2. O(n ).Análisis de la eficiencia de los algoritmos. Así. en donde en algún momento una constante por f(n) llega a ser una cota superior para g(n). si el trozo de código más interno es O(lgn). para Ω(· ). indicando que independientemente del factor que se utilice. indicar que se cumple la siguiente cadena de inclusiones: O(1) ⊂ O(log n) ⊂ O(n) ⊂ O(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. 47 . entonces g(n) ∈ De igual manera que la notación O(· ) posee la notación o(· ). m1 / g(n) ≤ c1f(n). ofrece un mínimo del cual nunca baja (g crece más deprisa que f). ∀ n ≥ n1. Ω(f(n)). y el más externo posee O(n). Existen dos: Ωk y Ω∞. co. 2n ∈ o(n ). 3 nlgn))= O(n ). En este caso.. el tiempo de ejecución de los tres será O(max(n .. Demostración: Si g(n) ∈ O(f(n)) entonces ∃c1. entonces g· h ∈ O(f· f') [Regla del producto]. aunque teniendo en cuenta que tratamos ahora cotas inferiores. uno con eficiencia O(f(n)) y otro O(f'(n)). f'(n)). indica que el crecimiento de g(n) es estrictamente más lento que el de f(n).. si g(n) ∈ Ω∞(f(n)). En este caso.. dados los órdenes de eficiencia de tres trozos consecutivos de un 2 3 2 3 programa. uno con eficiencia O(f(n)) y otro con O(f'(n)). entonces en una cantidad infinita de ocasiones g crece lo suficiente como para alcanzar a f. aunque diferente de O(f(n)): si g(n) ∈ o(f(n)). si el límite cuando n tiende a infinito de g(n)/f(n) es infinito. Por ejemplo. las notaciones Ω nos dan una cota inferior en el orden de eficiencia (el mejor caso). ∀ n≥ n1· n2 se tiene que f(n)· f'(n) ≤ (c1· c2) f(n)· g(n). La notación o minúscula es una cota superior. O(n ) y O(nlgn). m2 / h(n) ≤ c2f'(n). Su definición es la siguiente: o(f(n)) = { g ∀c0 ∈ R y ∃n0 ∈ N. pero 2n ∉ o(n ). De igual forma que las notaciones O y o se encargan de establecer cotas superiores. 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. f siempre estará por encima de g. cuyas definiciones respectivas son las siguientes: Ωk(f(n)) = { g ∃c0 ∈ R y ∃n0 ∈ N. con el mismo significado que o. ⊂ O(n ) ⊂. • Si g ∈ O(f) y h ∈ O(f'). existe la notación ω(· ). al contrario que ocurría con O(· ). Si h(n) ∈ O(f'(n)) entonces ∃c2.⊂ O(2 ) ⊂ O(n!) 2 a n Las notaciones Ω. la eficiencia del trozo completo es O(f(n)*g(n)). la eficiencia del trozo completo será O(max(f(n). 2 2 2 por ejemplo. Esta regla nos asegura que si se dispone de dos trozos de código independientes. Esta regla nos asegura que si existen dos trozos de código anidados (no independientes). Por último. ∀ n ≥ n0 g(n) ≥ c0f(n) } + Ω∞(f(n)) = { g ∃c0 ∈ R y ∃n0 ∈ N. n . o lo que es lo mismo. Por otro lado. ∀ 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. El objetivo es pues obtener el orden de eficiencia de un programa. Representaciones gráficas de las diferentes notaciones asintóticas. entonces g(n) ∈ Θ(f(n)). ∀ n ≥ n0. es decir. 3. c1∈ R y ∃n0 ∈ N. y a modo de aclaración. se pueden observar ejemplos gráficos del significado de cada notación: Figura 2. Esta notación define las funciones con la misma tasa de crecimiento (crecen al mismo ritmo) que f. si existen dos constantes por las que la función g queda embutida entre la función f. En este caso. al peor de los casos. Θ(f) = O(f) ∩ Ωk(f).4 Cálculo del tiempo de ejecución de un algoritmo iterativo y de su orden de eficiencia. 0 ≤ c0f(n) ≤ g(n) ≤ c1f(n) } + En definitiva. entonces g(n) ∈ Θ(f(n)) (leído sería el orden exacto de g es f). por tanto. condicionales e iterativas. Formalmente: Θ (f(n)) = { g ∃c0. Relaciones entre las diferentes notaciones. 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. En el gráfico de la figura 2. el límite cuando n tiende a infinito de g(n)/f(n) es igual a k>0. A partir de este momento sólo trabajaremos con la notación O(· ). haciendo referencia. 48 .Programación II Las notaciones Θ. 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. b. se aplica la regla de la suma. hemos visto que.Análisis de la eficiencia de los algoritmos. y posteriormente calcular su orden de eficiencia. Sentencias de repetición. En el siguiente bucle: /*1*/ for (i=0. i<n. el de la lectura y toa el que se tarda en hacer una operación aritmética más una asignación. /*4*/ printf("Introduzca los dos números a sumar: "). Una vez conocido ese valor. tl.) tardará un tiempo O(1). asignación. /*7*/ printf("\n La suma de %i y %i es %i. Cualquier sentencia simple (lectura. El objetivo final que vamos buscando se puede alcanzar de dos formas diferentes: • • Obtener el tiempo de ejecución del programa. &b). La otra forma de llegar al mismo resultado sería la siguiente: T(n)= te+tl+toa+te= c. el orden del programa completo será O(1+1+1+1) = O(max(1. ya que los límites están expresados en el mismo bucle. i). por lo que su orden de eficiencia va a ser. 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. 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. es decir. escritura.1. Es fácilmente comprobable que T(n)=c ∈ O(1). Fijémonos primeramente en los bucles controlados por contador. más el de incremento de la variable contadora. tomando el máximo de los ordenes de eficiencia de dicho bloque.1))= O(1). siendo te el tiempo constante que lleva ejecutar una escritura. por tanto. Sentencias simples.\n".. Esto podremos hacerlo siempre y cuando no intervengan en dicha sentencia ni variables estructuradas. En este ejemplo anterior. y sólo queda realizar una resta de ambos límites. /*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). &a. para calcular el orden de un bloque de sentencias. ¿Cuántas veces se repite un bucle de este tipo? En estas sentencias iterativas es muy fácil de determinarlo. c. a. Esta forma de proceder será independiente de los tipos de sentencias de que conste el bloque. /*6*/ c= a+b. Veamos un ejemplo de un programa muy sencillo: /*1*/ void main() /*2*/ { /*3*/ int a. O(1). /*5*/ scanf("%i %i". el orden constante. . sumándole posteriormente el tiempo de evaluación de la condición. 49 . b.i++) /*2*/ printf(" %i ".ni operandos aritméticos que dependan del tamaño del problema.c). Veamos por qué: Hemos dicho anteriormente que las escrituras.1. Ir calculando el orden de eficiencia de las diferentes sentencias y bloques existentes en el programa. salidas y asignaciones tienen todas ellas O(1). utilizando la regla de la suma.. es decir. el orden de eficiencia sería O(n· m). el de la evaluación de la expresión booleana. Como el ciclo se repite n veces. g(n)).while el anterior. siendo tc la constante que identifica el tiempo de evaluación de la condición más el incremento. /*3*/ else /*4*/ for (i=0. por lo que su orden de eficiencia será n.i++) for (j=0. nos servirá para ejemplificar cómo se debe aplicar la regla del producto para obtener su orden de eficiencia: /*1*/ for (i=0. En este caso también despreciamos el tiempo constante de la evaluación de la expresión del bucle. es decir.. Aplicando en este caso la regla del producto. Nos centraremos ahora en los bucles controlados por centinela. tendríamos que T(n) =tc + nte ∈ O(n).j++) /*2*/ /*3*/ Matriz[i][j]= 0. j<n.i++) /*5*/ m= m * n. 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. /*2*/ while (i<n && Vector[i] != valor) /*3*/ i++. en este caso. siendo un ejemplo del caso en el que se tienen más de dos parámetros como tamaño de la entrada. Sentencias condicionales. Cuando estamos tratando con órdenes. La asignación de la línea 3 tiene un orden de ejecución constante. Si la matriz no hubiera sido cuadrada. el cual busca la posición en la que se encuentra un valor dentro de un vector. se desprecia.. El código que se muestra a continuación. En la siguiente sentencia if: /*1*/ if (n> m) /*2*/ n= n *m. el bucle while repetirá n veces una sentencia de O(n). En este caso. i<n. Si convirtiéramos en un bucle do. que se tenga que recorrer todo el vector: /*1*/ i=0. y T1-3(n)= n(nta+tc) +tc = tan +tcn +tc ∈ O(n ) para las tres juntas. igual que ocurre con el ciclo de la primera línea (O(n)).Programación II La sentencia printf tiene un tiempo de ejecución te. Así. para las 2 2 sentencias 2 y 3. si el bloque then tiene una eficiencia O(f(n)) y el bloque else O(g(n)). Así. la inicialización de una matriz cuadrada. que es constante. 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. si la dimensión fuera n x m. En el trozo de código que se muestra a continuación. 50 . Obteniendo el tiempo de ejecución del bloque completo tendríamos: T2-3(n) = nta + tc. por lo que posee O(n). i<n. el orden de eficiencia de la sentencia if será O(max(f(n). supondremos el peor caso. la forma de proceder sería la misma. llegamos a la conclusión que el tiempo de ejecución de 2 estos dos ciclos anidados pertenece a O(n ). el bucle de la línea 2 se repite n veces. y posteriormente se multiplica por el número de iteraciones que se realizan. el tiempo de ejecución será el tiempo de evaluación de la condición. Finalmente. por lo que su tiempo de ejecución será ntea (en esta constante también incluimos el tiempo de gestión del bucle). Además. Así. En una condición de un bucle (cualquier tipo). 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. se deberá incluir su tiempo de ejecución de una u otra manera: • En una asignación. Si hay más de una función invocada en la sentencia. Con esta función no hay ningún problema. HacerAlgo1 no invoca a ninguna. la segunda función invoca a una tercera. 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. el tiempo de ejecución de esa asignación básicamente será el de la función. con lo que procederíamos a obtener su eficiencia directamente. por lo que para determinar su orden de eficiencia. Dependiendo de dónde estén situadas las llamadas a funciones. Para poder calcular el tiempo de ejecución del programa principal debemos calcular el tiempo de cada una de las funciones que son referenciadas. 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. Para los controlados por contador. para bucles controlados por centinela. y finalmente. Seguidamente nos disponemos a hacer lo mismo para HacerAlgo2. éste sería T(n) = te + max(tea. el bucle for tiene un tiempo de ejecución que pertenece a O(n). • • • 51 . En el caso de órdenes. A su vez.Análisis de la eficiencia de los algoritmos. tea. que coincide con el bloque de sentencias de la parte else del condicional. el T(n) de la sentencia condicional será el máximo de ambos tiempos más el tiempo de evaluación. por lo que el orden de la sentencia condicional será el máximo de 1 y n. que claramente es n. Si en un programa se llama a una o varias funciones no recursivas. 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. ntea)= te + ntea ∈ O(n). obtenemos el de la función main basado en las llamadas a HacerAlgo1 y HacerAlgo2. el bloque de la parte then posee O(1). Supongamos que un programa en C llama en dos lugares distintos a una función HacerAlgo1 y a otra HacerAlgo2. el correspondiente a la asignación más la suma. Llamadas a funciones. corresponde al máximo de cada uno. se sumará el tiempo de ejecución de la función al tiempo de ejecución de cada iteración. comenzando por aquellas que no llaman a otras. Como hemos visto anteriormente. y también se puede hacer directamente. multiplicándose finalmente ese tiempo por el número de iteraciones que realiza el bucle. pero ésta sí realiza una llamada a otra función. corresponderá a la suma de tiempos. En una secuencia de sentencias: simplemente aplicar la regla de la suma. El bucle ejecuta n veces una asignación. si tratamos con órdenes. En el caso de hacer el cálculo obteniendo el tiempo de ejecución. utilizando para ello los tiempos de cada una de las funciones que ya han sido calculados. debemos previamente encontrar el de HacerAlgo3. procedemos a incorporar el orden de eficiencia de HacerAlgo3 en los cálculos para obtener el de HacerAlgo2. Una vez realizada esta tarea. donde te corresponde con el tiempo que se tardaría en hacer la evaluación de la condición del if. esto no se daría). o sumar tiempos si son tiempos de ejecución. se deberá calcular el tiempo de ejecución de cada una de ellas. Por otro lado. HacerAlgo3. y dentro de él nos encontramos una sentencia if con una bloque then. y encontramos el for de la línea 3. -5. Una posible implementación en C es la siguiente: En las implementaciones que vamos a estudiar y para facilitar el entendimiento de las expresiones a la hora de calcular la eficiencia. En este caso. 7 y 8). Comencemos por la función Burbuja. por lo que el tiempo de ejecución del bucle será una constante multiplicada por: ∑ (n − i) = i =1 n −1 n(n − 1) n 2 n = − 2 2 2 2 que pertenece a O(n ). i++) /*3*/ for (j = n-1. 13. vamos a consierar las constantes igual a 1 para simplificar las operaciones. el cual está formado por tres sentencias simples (líneas 6. la cual ordena un vector de n números enteros de forma creciente mediante el método de la burbuja. j. -4. -2. /*1*/ int aux. Así.1. j--) if (Vector[j-1] > Vector[j]) /*4*/ /*5*/ { /*6*/ Aux= Vector[j-1]. dado el vector -2. la línea 2 ejecutará n-1 veces. En esta sección vamos a calcular el orden de eficiencia de varios programas. pero como buscamos el peor caso. Por ejemplo. 13. Continuamos hacia fuera. 11. 52 3 . -5. /*2*/ for (i = 0. el valor máximo que se puede conseguir al sumar varios elementos consecutivos del vector es 20 y se alcanza en 11. se obtiene igualmente O(1). para obtener seguidamente el orden de eficiencia de la sentencia condicional de la línea 4. no sabemos si se llegará a ejecutar el cuerpo then del if. Su código es el siguiente: void Burbuja (int *Vector. 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. para posteriormente ir ascendiendo. tendremos que el código de las líneas 3 a 9 tendrá una eficiencia O((n-i) * 1) = O(n-i). cada una ellas llevará O(1). lo tenemos en cuenta. -4. /*9*/ } } Comenzamos el estudio por el bloque de código más interno. el cual consiste en encontrar una secuencia de números consecutivos almacenados en un 3 vector de tamaño n cuya suma sea máxima . /*7*/ Vector[j-1]= Vector[j]. Al aplicar la regla de la suma. /*8*/ Vector[j]= Aux. j< i. int n) { int i. Para ello. Cada iteración tomará un tiempo perteneciente a O(1). en cuyo caso el if se ejecutará en O(1). supondremos que los valores se sitúan a partir de la posición primera del vector. nos vamos al segundo bucle for. y por tanto. aplicaremos las reglas explicadas en los apartados anteriores. Además. i < n .Programación II Ejemplos. y al repetirse el ciclo n-i veces. Finalmente nos encontramos con el bucle más externo: en este caso. Seguimos avanzando hacia fuera. Claramente el tiempo de ejecución de la función vendrá dada por la siguiente suma: T (n) = ∑∑∑1 . i++) for (j=i. int n) { int i. consiguiendo un orden de eficiencia cuadrático: long SumaSubsecuenciaMaxima(int *Vector. /*1*/ int SumMax=0. realizamos los cálculos sobre la primera 2 n n n sumatoria. que representa el bucle de la línea 6.j<=n. long SumaSubsecuenciaMaxima(int *Vector. Por otro lado. el bucle se podría repetir n veces. lo que implica que pertenecerá a O(j-i+1). nos damos cuenta que se podría evitar ese orden si se elimina un bucle for. PosInicioSec=0. SumaActual. PosFinSec=0. 53 . SumaActual. int n) { int i. y por último. como ya sabemos. /*13*/ } /*14*/ } /*15*/ return SumMax. /*2*/ for (i=0. la cual determina el número de veces que se ejecuta la instrucción de la i =1 j = i k = i n n j línea 6. } Entre las líneas 4 y 14 tenemos una asignación. /*11*/ PosInicioSec= i. Si resolvemos la siguiente sumatoria. k. PosFinSec=0. aplicando la regla de la suma. La asignación. La suma de más a la derecha. i++) /*6*/ /*7*/ SumaActual+= Vector[k]. el condicional podemos considerarlo también como O(1).i<=n. for (k=i. /*1*/ int SumMax=0. un bucle for y un if. bucle de la línea 2. j. aplicada a la expresión anterior: ∑ i =1 n (n − i + 1)(n − i + 2) 1 3 1  = i 2 −  n +  i + (n 2 + 3n + 3) 1 = 2 2 i =1 2  i =1 2  i =1 ∑ ∑ ∑ = 1 n(n + 1)(2n + 1)  3  n(n + 1) n 2 + 3n + 2 n 3 + 3n 2 + 2n + ∈ O( n 3 ) −n +  n= 2 6 2 2 2 6  Teniendo en cuenta el orden de complejidad de la función.Análisis de la eficiencia de los algoritmos. En este caso. con lo cual nos debemos quedar con O(n) como el máximo de los tres órdenes. /*12*/ PosFinSec= j. ofrece como resultado ji+1. j++) /*3*/ /*4*/ { /*5*/ SumaActual=0. obtendremos: ∑ ( j − i + 1) = j =i n (n − i + 1)(n − i + 2) . j. Como debemos ponernos en el peor de los casos. tomaremos el orden que sea mayor de los tres. que se corresponde con el bucle de la línea 3. k. PosInicioSec=0. If (SumaAcutal > SumaMax) /*8*/ /*9*/ { /*10*/ SumaMax= SumaActual.k<=j. el bucle repetirá j-i+1 veces una asignación. y observando la implementación. es O(1). } Este es un ejemplo de cómo un estudio de la complejidad algorítmica puede hacer que nos replanteemos el algoritmo. /*11*/ PosInicioSec= i. Una vez que tenemos una recurrencia. Si repetimos el proceso i pasos. dando lugar finalmente a un diseño mucho más eficiente. se puede reducir más su orden de complejidad mediante un algoritmo recursivo a O(nlgn). /*2*/ else return n * factorial(n-1). } Podemos observar que si n ≤ 1. la recurrencia tendría la siguiente forma: T(n)=id+T(n-i). 3. el tiempo de ejecución de la función recursiva es T(n)=c y que si n > 1. If (SumaAcutal > SumaMax) /*8*/ /*9*/ { /*10*/ SumaMax= SumaActual. De nuevo. tenemos que T(n)= (n-1)d + T(n-(n-1))= (n-1)d + T(1)= (n-1)d+c. Este tipo de ecuaciones se denominan relaciones recurrentes o recurrencias.Programación II /*2*/ for (i=0. pero por ahora obtendremos la expresión no recursiva de T(n) mediante la expansión de la misma. j++) /*5*/ /*6*/ { /*7*/ SumaActual+= Vector[k]. 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). 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. Al final el tiempo de ejecución pertenece a O(n).j<=n. expresiones de la forma T(n)=E(n). ya que conocemos que T(1) es 1. tarea que le proponemos al lector. se ignorarán las constantes multiplicativas. /*13*/ } /*14*/ } /*15*/ } /*16*/ return SumMax. apareciendo la propia función T en la expresión E. En particular. i++) /*3*/ { /*4*/ SumaActual=0. /*12*/ PosFinSec= j. suele ocurrir habitualmente que las funciones del tiempo de ejecución que se obtengan sean también recursivas. sustituyendo T(n-1) por su valor correspondiente. ¿Cómo resolvemos esta recurrencia? Más adelante estableceremos formalmente varios métodos. es decir. 54 . es decir. n>i.i<=n. for (j=i. De hecho. se cumplirá que T(n) = d + T(n-1). para poder obtener su orden de eficiencia deberíamos encontrar una expresión no recursiva para la función T(n). Cuando se analiza la eficiencia de un programa recursivo.5 Cálculo del tiempo de ejecución de algoritmos recursivos y de su orden de eficiencia. para i=n-1. En el caso contrario. n-1). if (MaxPos != 0) { i= Vector[0].i++) if (Vector[i] > Vector[MaxPos]) MaxPos= i.. Es en este aspecto en el que nos vamos a centrar a continuación: en los diferentes métodos de resolución de recurrencias. MaxPos. Una vez planteada la ecuación recurrente procederemos a su resolución mediante la técnica de la expansión. T(1)=1. ya que sólo tendremos que contar el tiempo que tarda la evaluación de la condición. n ≥ 2.. Vector[0]= Vector[MaxPos]. i<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. 55 . que un programa recursivo generará una recurrencia que describe su tiempo de ejecución en función de ella misma. que se repite n veces más el tiempo de la llamada recursiva. cuando el vector a ordenar tenga una longitud menor o igual que uno. n ≥ i + 1 . y que para obtener su eficiencia se debe resolver. el cual suponemos 1. es decir. en cuyo caso. Por tanto T(n) = T(n-1) + n. int n) { int i. } } La ecuación de recurrencia está definida en dos partes: la primera corresponde al caso base.Análisis de la eficiencia de los algoritmos. } OrdenarVector(Vector+1. ya que no siempre se pueden expandir los tiempos de ejecución de manera tan sencilla. for (i=1. Vector[MaxPos]= i. if (n>1) { MaxPos=0. tendremos un bucle con un cuerpo constante. + n = ∑ (n − j ) j =0 n− 2 ∑ (n − j ) = i =0 n−2 (n + 1)n ∈ O( n 2 ) 2 Hemos visto. pero esta vez aplicada a un vector con un tamaño en una unidad menor que el de la llamada original. podremos eliminar el término recurrente. obteniendo T (n) = T (1) + Por último nos queda resolver la sumatoria: T (n) = 1 + 2 + . y en general: T (n) = T (n − i ) + ∑ ( n − j ). j =0 i −1 Si particularizamos para el valor i=n-1. por tanto. 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. .4tn-2 = 0 El siguiente paso será hacer la sustitución tn=x . Si sustituimos esta solución en [1] obtenemos: a0x + a1x n n-1 n + . Las soluciones que buscamos son de la forma tn=x . si n ≥ 2  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 . ya que es la forma de la solución que buscamos: x . veamos otro ejemplo sobre la recurrencia que corresponde al tiempo de ejecución de la sucesión de Fibonacci: tn= tn-1 + tn-2. Supongamos que las k raíces de esta ecuación característica son r1. los coeficientes ai son constantes y la recurrencia es homogénea por que la combinación lineal de los ti es igual a 0. .. Para afianzar el cálculo. que no nos sirve.4tn-2 => tn . . rk.(-1) ) ∈ O(4 ).Programación II 3. A continuación. si n = 1 T 3T (n − 1) + 4T (n − 2). donde los ri son las soluciones de la recurrencia. 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. + ak= 0. si n = 1   (n) =  1. y t0= 0. Si sustituimos n=0 y n=1 en la ecuación anterior..3x n n-1 n tn = ∑c r i =1 k k i i de términos ri n es solución para la ecuación recurrente lineal .. Las recurrencias lineales homogéneas tienen la forma: a0tn + a1tn-1 + . ecuación que se denomina ecuación característica asociada a la ecuación recurrente inicial. tn= c1(-1) + c24 n n Las constantes se obtienen a partir de las condiciones iniciales.6 Resolución de recurrencias homogéneas. entonces cualquier combinación lineal homogénea. y a0x + a1x + . donde x es una constante. valores que se substituirán para llegar a la ecuación del n n n tiempo de ejecución sin recurrencias: tn= (1/5)(4 . n≥2. con objeto de eliminar la solución trivial Posteriormente dividimos cada término por x = x y conseguimos la ecuación característica: 2 n-k n-2 x -3x-4=0.3tn-1 . Clarifiquemos esta explicación teórica anterior con la resolución de la siguiente recurrencia: 0.4x n-2 =0. r2.. t1= 1 56 . + aktn-k = 0 [1] donde los ti son los valores buscados (aplicamos el adjetivo de lineal por que no hay términos 2 de la forma titi+j ó ti .. se resuelve la ecuación obteniendo como raíces -1 y 4. por ejemplo). + ak x n-k =0 [2] k k-1 Esta ecuación tiene dos soluciones x=0. La solución general a la expresión recursiva tendrá la forma de la expresión [2].... A partir de ahí. tn=c1r1 +c2r2 . condiciones iniciales n=0 y n=1. c1 + 2c2 + 2c3= 0 y c1 + 4c2 + 8c3= 0.. Cambiando de notación.+ ak)(x-b) d+1 =0 [4] d+1 Donde (a0x + a1x +. nos 1/2 n quedaría sustituir c1 y c2 en la expresión de la solución de la recurrencia. Sustituyendo las n n. Las soluciones son c1= -2. sustituyendo tn=x y dividiendo por x = x característica: tn-tn-1-tn-2=0 => x -x-1=0 2 1/2 n n-k n-2 se obtiene la ecuación Si resolvemos la ecuación. sus soluciones son r1= (1+5 )/2 y r2=(1-5 )/2. n≥3. n n 2 n m-1 n entonces se añadirán los sumandos r . que en el ejemplo tendrá tres ecuaciones con tres incógnitas: c1 + c2= 0. y b es una constante (el resto es igual que [1]). Al solucionarlo. A partir de este momento. en la expresión que nos da la solución.tn-1 . para finalmente encontrar la expresión de la solución a la recurrencia: tn= 2 n+1 .n2 n-1 . se concluye el proceso dando los mismos pasos que en la solución de recurrencias homogéneas. multiplicados por sus correspondientes constantes.. Una vez en este punto.. es decir.+ aktn-k = b p(n) [3] Donde p(n) es un polinomio de grado d. las raíces de la ecuación característica han sido todas distintas. las soluciones son 1.7 Resolución de recurrencias no homogéneas. c2= 2 y c3=-1/2. por tanto: tn= c11 + c22 + c3n2 n n n 3 2 1/2 1/2 1/2 Esta expresión se crea igual que la expresión [2] para las raíces simples. La solución general será..4= 0 Tras resolver la ecuación anterior. añadiéndole tantos sumandos como multiplicidades tengan las raíces múltiples: si m es la multiplicidad de una raíz r.8tn-2 + 4tn-3... todo se desarrolla igual: dadas las condiciones iniciales. Las recurrencias que vamos a tratar a continuación son más generales que las anteriores y tienen el siguiente aspecto: a0tn + a1tn-1 +. t1=1. t0=0.. t2=2 La ecuación característica es: x . y 2...+ ak) es la aportación de la parte homogénea y (x-b) de la parte no homogénea.2 ∈O(2 ). Finalmente. n r . n 3. El siguiente ejemplo nos permitirá identificar cada una de las funciones que componen la expresión [3]: tn= 1 + tn-1 + tn-2 => tn ..5x + 8x .tn-2 = 1 57 . se intentará obtener la ecuación característica. (x-1)(x-2) =0. que tendrá el siguiente aspecto: (a0x + a1x k k-1 k k-1 n +. pero puede darse el caso en el que se repitan. con 2 multiplicidad 2. nr . Al final T(n) ∈ O((1/(5 )) ) En los dos ejemplos anteriores.Análisis de la eficiencia de los algoritmos. como ocurre en el siguiente ejemplo: Dada la recurrencia: tn= 5tn-1 . obtenemos el sistema de dos ecuaciones con dos incógnitas: c1 + c2 = 0 y c1r1 + c2r2 = 1. con multiplicidad 1. las incógnitas toman los valores c1=1/(5 ) y c2=-1/(5 ). n r . se plantean las ecuaciones de un sistema lineal. n ≥1 y T(0) = 0... p(n) = 1 y. b=3. La recurrencia será: T(n) = 2T(n-1) + n.. concluiremos que T(n) ∈ O(n2 ). se pueden resolver recurrencias de la forma: a0tn + a1tn-1 +. n≥1 y T(0)= 0.b2) d1+2 . con lo que se obtiene la ecuación característica siguiente: (x-2)(x-1) = 0 Por tanto. podemos obtener la ecuación característica: (x-2)(x-1) (x-2) = 0 Su solución tiene la forma: tn=c11 + c2n1 + c32 + c4n2 = c1 + c2n + c32 + c4n2 n n n n n n 2 n n Sustituyendo las condiciones iniciales y resolviendo el sistema de cuatro ecuaciones con n cuatro incógnitas. se obtienen t1 y t2.+ aktn-k = b1 p1(n) + b2 p2(n) + b3 p3(n) +. Cambiando la notación tendremos la siguiente ecuación lineal no homogénea: tn . 58 . De esta manera.Programación II Como 1= 1 p(n). p(n)= n+5.2tn-1= (n+5)3 (x-2)(x-3) =0 Veamos un último ejemplo.2tn-1 = n Identificamos b=1 y p(n)=n. p1(n)= n (grado d=1) y b2= 2 .b1) d1+1 (x . 4c1 + c2 + 2c3 = 4 Las soluciones son c1 = 2.2tn-1 = n + 2n La parte derecha de la igualdad debemos expresarla de la forma: n + 2n = b1 p1(n) + b2 p2(n) Cosa que conseguiremos si identificamos b1= 1. p2(n)= 1 (d=0).+ ak)(x .2 ∈ O(2 n n+1 ) De esta misma manera.n . 2c1 + c2 + c3 = 1. por tanto.n -2 = 2 n n n+1 n n n n 2 2 n En este caso. esta vez completo. siendo p(n)=1 con grado d=0 y b=1. La ecuación característica sería: . En primer lugar cambiemos la notación y reorganicemos la expresión: tn = 2tn-1 + n + 2n => tn . con d=1.. con lo que finalmente: tn =2· 2 . b=3. y se sustituyen en la ecuación anterior. Las ecuaciones características tendrán el siguiente patrón: (a0x + a1x k k-1 n + ... obteniendo la ecuación característica: (x-2)(x-3)=0 Si la ecuación recurrente fuera: tn . siendo d=1. Esto implica que la ecuación característica quedaría: (x -x-1)(x-1)=0 Un segundo ejemplo: tn .2tn-1 = 3 n 2 n En este caso.. d2 el de p2(n) y así sucesivamente. la solución es: tn= c12 + c21 + c3n1 = c12 + c2 +c3n Con t0. [5] Siendo d1 el grado de p1(n). obteniendo el siguiente sistema de tres ecuaciones con tres incógnitas: c1 + c2 = 0.= 0 [6] n Resolvamos la siguiente recurrencia: T(n)= 2T(n-1) + n + 2 . c2 = -2 y c3 = -1.. d=0. Introduction to algorithm. Rivest.9 Bibliografía. Masson. En esta sección mostramos cómo se llevaría a cabo con dos ejemplos. L. Deshaciendo los cambios. que pasamos a resolver como se ha comentado en la sección anterior. Bratley. n = 1  Debido a que el tamaño del problema se divide en dos suponemos que n es potencia de dos. m 3. 3. 2 2 2 m m 2 m m m-1 2 m 0 Al hacer el mismo cambio de variable que en el ejercicio anterior. m ≥ 1. m ≥ i Particularizando para m=i para así poder eliminar la recurrencia. T(2 )= T(2 )+i. • • • [AHU87] A. P. R. El segundo. Ullman.992). Un segundo ejemplo donde n es una potencia de 2. obtenemos una ecuación recurrente no homogénea.990). finalmente concluimos que T(n)= lgn + 1 ∈ O(lgn). se llega a )+4 . n ≥ 2 T ( n) =  2  1. Realizamos varias expansiones: 0 T(2 )= T(2 y en general: m m-1 ) + 1 =T(2 m m-2 ) + 1 + 1 = T(2 m-i m-3 ) + 1 + 1 + 1. Cormen. J. Aho.V. Concepción y análisis. corresponde a la resolución de la recurrencia: T(n)= 4T(n/2) + n T(2 )= 4T(2 O lo que es lo mismo: tm= 4tm-1 + 4 La ecuación característica es: (x-4) =0 Y la solución buscada tendrá la forma: tm= c14 + c2k4 . El primero lo resolveremos cambiando de variable y seguidamente expandiendo. Esta ecuación recurrente corresponde al tiempo de ejecución de la búsqueda binaria recursiva:  n T ( ) + n. MIT Press. desaciendo el cambio. 59 . Data structures and algorithms.8 Resolución de recurrencias mediante cambio de variable. C. A menudo se pueden resolver recurrencias más complicadas mediante un cambio de variable. tendríamos: T(2 )= T(2 ) + m = m + 1 Como m= lg n. m por lo que podemos hacer n=2 . Addison-Wesley (1. Algoritmica. tras realizar el cambio de variable. Leiserson. siendo el caso base T(2 )= 1. (1. quedando: T(2 )= T(2 m m-1 ) + 1. [BB90] G.H.Análisis de la eficiencia de los algoritmos. el tiempo de ejecución es T(n)= c1n + c2n lg n ∈ O(n lg n). [CLR??] T. Brassard. E.A. W. Garrido. Brown. (1. Formalismo y abstracción. Fernández.997). Sedgewick. Diseño de programas. H. C. A. S. [Peñ93] R. 2 Edition. Rajasekaran. Shapiro. E. Weiss. S. Peña.998). The analysis of algorithms. Design and analysis of algorithms. [MS91] B. A.991). Estructuras de datos. 3 Edition. M. Addison-Wesley (1. M. Algorithms from P to NP. Algorithms. CBS College Publishing (1. [HSR97] E. Un enfoque práctico usando C.D. Horowitz.989).998). The spirit of computing.Programación II • • • • • • • • • [FGG98] J. [Har92] D. [Wei95] M. Algorithms in C++. Computer Science Press (1. Volume I. Purdon. Universidad de Granada (1. Moret. rd nd 60 . [Sed98] R. Computer algorithms. PWS-KENT publishing company (1.992). [PB84] P. Harel. Prentice Hall (1. Estructuras de datos y algoritmos.984). A. Addison-Wesley (1.D. Benjamin/Cummings (1. García. Sahni. [Smi89] J.995).993). Design and eficiency. Smith. Una vez se cuente con un modelo matemático adecuado del problema. los reales o las cadenas de caracteres) dando un nombre de procedimiento a cada operación y sustituyendo los usos de las operaciones por invocaciones a los procedimientos correspondientes. En esta etapa. Para convertir en programa un algoritmo tan informal. de ahí que a esta fase la podamos llamar fase de modelación.1 se resume el proceso de programación en tres etapas. Para encontrar un modelo se puede recurrir a cualquier rama de las matemáticas. el de procedimiento. puede buscarse una solución en función de ese modelo.Capítulo 4: Tipos de Datos Abstractos 4. . es necesario pasar por varias etapas de formalización (refinamientos por pasos) hasta llegar a un programa cuyos pasos tengan un significado formalmente definido en un lenguaje de programación. Después de depurarlo será finalmente un programa operativo. Ej.1: Proceso de solución de problemas Para conseguir definitivamente un programa ejecutable es necesaria una última etapa en la que para cada tipo de datos abstracto se elija una representación y se reemplace por sentencias en C toda proposición que quede escrita en seudolenguaje. Evitan al programador limitarse a los operadores incorporados en un lenguaje de programación. la solución del problema será un algoritmo expresado de manera muy informal.2 Concepto de TDA 4. el programa en seudolenguaje estará suficientemente detallado para que las operaciones a realizar con los distintos tipos de datos estén bien determinadas. La única noción nueva es la de tipo de datos abstracto. En la figura 4. Modelo Matemático algoritmo Informal Tipos de datos abstractos algoritmo seudolenguaje Estructuras de datos Programa en C Figura 4. En algún punto de este proceso.2. Para comenzar es útil comparar este concepto con el más familiar. con el uso de procedimientos. En la representación de cada tipo de datos abstracto se definirá el nombre del tipo de datos abstracto mediante declaraciones en C de acuerdo a la estructura de datos seleccionada y para cada operación del tipo de datos abstracto se escribirá un procedimiento en C que realice la operación deseada. El resultado será un programa ejecutable.1 Introducción al con cepto de TDA La mayor parte de los conceptos introducidos en la sección anterior son familiares del primer curso de programación. por eso antes de continuar. la solución al problema será un algoritmo en seudolenguaje definido sobre tipos de datos abstractos y sus operaciones. el programador es libre de definir sus propios operadores y aplicarlos a operandos que no tienen por qué ser de tipo fundamental. 4. Entonces se crean los tipos de datos abstractos para cada tipo de datos (con excepción de los datos de tipo elemental como los enteros. se analizará el papel de estos tipos durante el proceso general de diseño de programas.1 Introducción En el primer capítulo se ha descrito la metodología de programación a seguir para resolver un problema. En la primera etapa se expresan ciertos aspectos de un problema a través de un modelo formal. Por tanto. : multiplicación de matrices. Los procedimientos generalizan el concepto de operador. en la segunda etapa. el conjunto de los números negativos. esto implicará realizar una especificación sintáctica de las operaciones. Y en segundo lugar.. si se produce un fallo en la entrada de datos se sabe con exactitud donde están las líneas que provocan el fallo. resta. Un ejemplo de encapsulación es el uso de un procedimiento para leer todas las entradas y verificar su validez. y el resultado de una operación puede no ser un caso de ese TDA.. Los TDA son generalizaciones de los tipos de datos primitivos (enteros. Por ejemplo. indicando las reglas que hay que seguir para hacer referencia a una operación. Definir los efectos que producen en el dominio del TDA cada una de las operaciones definidas. Un ejemplo de TDA son los conjuntos de números enteros con las operaciones de unión. Cualquier letra es una cadena. Metodología para la definición de un TDA Para definir un TDA de manera que pueda ser utilizado necesitamos realizar una especificación del TDA. localizando en una sección de un programa todas las sentencias que incumben a un aspecto del programa en concreto. Sin embargo. éste se puede utilizar como si fuese un tipo de dato primitivo. si se desea cambiar la forma de implementar un TDA. se supone que al menos un operando. como enteros o de otros TDA. son igualmente aplicables a los tipos de datos abstractos. Se puede hacer referencia a un dominio conocido de objetos matemáticos. De esta forma. Las operaciones de un TDA pueden tener como operandos no solo los del TDA que se define. Dominio de un TDA Identificar y describir el dominio de un TDA es bastante sencillo.. sin preocuparse por cual sea su implementación. debemos realizar una especificación semántica. éste puede ser enumerado. se sabe hacia dónde dirigirse.Programación II Otra ventaja de los procedimientos es que pueden utilizarse para encapsular partes de un algoritmo. el dominio de las cadenas de caracteres puede definirse como sigue: 1. Para ello debemos primero conocer como hacer referencia a una operación. sino también otros tipos de operandos.). debemos conocer que significado o consecuencia tiene cada operación. La ventaja de realizar encapsulaciones es que se sabe a donde ir para realizar cambios. teniendo en cuenta: • • Definir el dominio del TDA en donde tomará valores una entidad que pertenezca al modelo matemático del TDA. al igual que los procedimientos son generalizaciones de operaciones primitivas (suma. Un TDA encapsula cierto tipo de datos pues es posible localizar la definición del tipo y todas sus operaciones en una sección del programa. y revisando una pequeña sección del programa se puede tener la seguridad que no hay detalles en otras partes que puedan ocasionar errores relacionados con ese tipo de datos. Por ejemplo. el dominio del tipo booleano es {true.. Las propiedades de los procedimientos mencionadas anteriormente. intersección y diferencia. false}. Por ejemplo. caracteres. de alguna operación pertenece al TDA en cuestión. o el resultado. Se puede definir constructivamente. Podemos ahora definir el concepto de tipo de dato abstracto (TDA) como un modelo matemático con una serie de operaciones definidas en ese modelo. Hay distintas formas de hacerlo: • • • Si el dominio es finito y pequeño.. 62 . Por ejemplo. Una vez definido un TDA. Enumerando unos cuantos miembros básicos del dominio y proporcionando reglas para generar o construir los miembros restantes a partir de los enumerados.). generalización y encapsulación.. Este tipo de definiciones se llama también definiciones recursivas. de forma que cuando se requiera más precisión se debe de utilizar una notación matemática. enumerando las siguientes operaciones: + : entero x entero .b) 63 .b:entero) unsigned char ‘>’ (int a. Una buena especificación debe de identificar todas las posibles acciones que puedan ocurrir. esto puede dejar lugar a dudas sobre como funciona este operador. Por ejemplo. -.: entero x entero > : entero x entero abs: entero int ‘+’ (int a. Especificación sintáctica de un TDA Consiste en determinar como hay que escribir las operaciones de un TDA.b) int ‘-‘ (int a. • 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 sustituyendo el asterisco por el valor a que se aplica: <b>} ƒ Composición: sucesion ‘ο’ (sucesion a.b) int abs (int a) La interpretación de este tipo de especificaciones es la siguiente: + es una función que se aplica sobre dos enteros y devuelve uno. Una forma de especificar el significado de las operaciones sería mediante el lenguaje natural. como es el caso de +. Cualquier cadena seguida de una letra es una cadena. si decimos que ‘div’ divide un entero entre otro. Ejemplo: Vamos a definir el TDA sucesión de forma algebraica. el uso del lenguaje natural puede dar lugar a ambigüedades. Una sucesión es un tipo parametrizado.Tipos de Datos Abstractos 2. A lo largo del libro usaremos la notación correspondiente a la sintaxis del lenguaje C para realizar esta especificación. La función abs que no está entre comillas se expresa en la forma usual de las funciones. Consiste en dar un conjunto de axiomas verificados por las operaciones del TDA. Este es el caso de la notación algebraica. abs(n). el tipo entero se puede especificar sintácticamente. Cuando el nombre del operador se escribe entre comillas. que depende del tipo de sus elementos básicos de un cierto dominio D. Sin embargo. >. significa que al aplicar el operador hay que aplicarlo en forma infija: (operando1 operador operando2). Por ejemplo. entero entero entero boolean Otra forma más relacionada con la forma de escribir en un lenguaje de programación es: Especificación semántica de un TDA Una vez especificada la sintaxis de las operaciones de un TDA hay que dar su significado. La sintaxis de las operaciones vendrá descrita por las cabeceras de las funciones correspondientes a cada una de las operaciones. dando el tipo de operandos y el resultado. ya que hacen referencia a los elementos del dominio definido para crear nuevos elementos. Podemos definir el tipo string como: {a| a=<a1. Este método se basa en el hecho de que podemos describir el dominio de un tipo en términos de otro tipo y.. usar las operaciones del segundo tipo para describir las operaciones del que estamos definiendo. trailer(x) = trailer(y).. cuyo resultado se determina a partir de los axiomas.dn> Un tercer método de especificación semántica de un TDA es mediante el uso de modelos abstractos. b=<b1. y ≠ <>. first (x) = first(y). y precondiciones y postcondiciones. Este tipo de especificación se conoce también como especificación operacional. entonces. y en la especificación semántica de cada operación se usará un lenguaje matemático (basado en este caso en el conocimiento de las sucesiones finitas). Así un elemento de este dominio tendría la forma: <d1> ο<d2>ο. . ai es carácter.a2.d2.b) pre a=<a1.. string concat (string a. por tanto es un caso particular del tipo anteriormente especificado.ο<dn> que de forma abreviada escribiremos como <d1. .. . .b2. Por ejemplo. an>. . .an>. que en nuestro caso notaremos como N. A estas definiciones comparativas se les llama también definiciones que usan modelos abstractos ya que modelizan el dominio y las operaciones de un tipo. . La precondición es la condición previa para poder aplicar una operación y la postcondición especifica el resultado de la operación. . . si queremos definir el tipo string de un lenguaje de programación.Programación II ƒ ƒ ƒ ƒ ƒ 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) j) Si x ≠ <>. entonces x = y first (<>) = last (<>) = leader (<>) = trailer(<>) = ERROR En esta especificación no se ha dicho nada acerca del dominio del tipo sucesión. Vamos a usar una sintaxis similar a la del C. n ≤ N} A continuación se puede dar la sintaxis y la semántica de las operaciones correspondientes. Esto es característico de las definiciones axiomáticas.bm> length(a)+length(b)=n+m ≤ N 64 .. . Los objetos que pertenecen al dominio son los que se pueden obtener a través de las operaciones. Un string no es otra cosa que una sucesión de caracteres con una longitud máxima. usando el dominio y las operaciones de algún tipo o tipos previamente definidos. . Determinar. 2. . interpretar y operar con la información representada en la memoria. En una segunda etapa. . explicando su significado a partir de modelos matemáticos conocidos.2 Uso de los TDA en Programación Hasta ahora hemos considerado los TDA como objetos matemáticos. estén o no estén en el lenguaje de partida. . los datos con los que se puede trabajar en un lenguaje de programación están organizados por tipos.an> f ≥1. ante un problema. pero no hemos comentado cual es su uso en la programación. Construir un programa. debemos realizar ante cualquier problema una tarea similar a la que hemos descrito para el constructor del compilador.a2. En primer lugar. Especificar dichas operaciones 4. la información. 4. . A lo largo del libro. El diseñador del lenguaje en cuestión. separando dos problemas. Implementar estos objetos en base a los elementos del lenguaje y razonar sobre ellos. Sin embargo. de forma que se imite el comportamiento del tipo correspondiente. El proceso sería el siguiente: 1. .a2. se podrá ejecutar la operación y tras su ejecución se debe cumplir la postcondición.t ≤ length(a).b2. puede dar un resultado indeterminado o error.t) pre a=<a1.Tipos de Datos Abstractos post concat = <a1. primitivas entre dichos objetos. Determinar la eficiencia de la implementación. . Con esta metodología se logra disminuir la complejidad inherente a la tarea de construir programas. 3. que se resuelven de forma independiente: ƒ ƒ Construir un programa a partir de unos objetos adecuados a las características particulares del problema que se quiere resolver.an. los objetos candidatos a tipos de datos. . Implementar los tipos y operaciones que no estén en el lenguaje de partida. .2. . por una parte.bm> string substr (string a. f ≤ t post substr= <af. comunica estos tipos al usuario de forma abstracta: identificando los objetos y sus operaciones. vamos a hacer uso de modelos abstractos para la especificación semántica de los TDA. Con ello el programador no tiene que preocuparse de cómo se representa un tipo particular de datos y sólo tiene que trabajar con ellos en base a las especificaciones sintácticas y semánticas del diseñador. y cuando no haya lugar a ambigüedades usaremos también el lenguaje natural. que usando estos tipos y estas operaciones resuelva el problema original.int f. Lo ideal es que diese error advirtiéndose de esta forma el mal uso de la operación. .at> El uso de precondiciones y postcondiciones para la semántica de las operaciones es el siguiente: bajo el cumplimiento de las precondiciones. Vamos a utilizar el nombre de la operación para denotar su resultado en la postcondición. Identificar las operaciones básicas.b1. 65 . 5. con los elementos de dicho lenguaje 6. . y por otra ha diseñado un compilador en el que para cada tipo se determina como hay que organizar. No se afirma nada en el caso de que no se cumplan las precondiciones. La abstracción consiste en la descripción de un determinado sistema.3 Ejemplo de un TDA Para ilustrar las ideas básicas desarrolladas a lo largo de esta sección vamos a desarrollar un TDA. A veces añadir una nueva primitiva puede ser redundante pues tal vez sea posible programarla en base a las demás. en la cual pueda resultar más fácil realizar la elección de la estructura de datos más adecuada. que nos permitan poder realizar sobre ellos todas las operaciones que en el futuro puedan ser necesarias. De esta manera. Para el caso que nos ocupa. tal y como se ha explicado en la sección anterior. Esto nos permite posponer la decisión acerca de la implementación final hasta una etapa posterior en el desarrollo de la solución de nuestro problema. en base a unos determinados TDA sería una descripción abstracta del mismo. no haciendo ninguna referencia a la implementación usada para los objetos y diseñar una implementación para estos sin tener que conocer todos los detalles del problema en el que se vaya a usar. el TDA polinomio. Sin embargo. para posteriormente especificar su funcionamiento y realizar su implementación. es decir en módulos software independientes como pueden ser librerías adicionales con las que finalmente enlazaremos los programas que hacen uso de estos nuevos tipos. 66 . Una vez se ha identificado un tipo para la construcción del TDA correspondiente. y no considerando los detalles irrelevantes. Los conceptos de ocultamiento de la información y reusabilidad del software son básicos dentro de la construcción de tipos de datos abstractos. 3. Esto nos permite obtener la solución en menos tiempo y con un diseño cualitativamente superior que si resolvemos el problema mezclando la solución con detalles de implementación.Programación II Para que se obtenga un ahorro efectivo hay que procurar que se lleve a cabo de forma estricta esta separación: construir el programa. pero sin exceder en el número de operaciones que necesitamos programar realmente. es decir. CrearPolinomio: obtiene los recursos necesarios y devuelve el polinomio nulo. 2. aunque en ciertas ocasiones una operación pueda ser implementada en base a otras operaciones básicas es posible que sea interesante añadirla por cuestión de eficiencia. La realización consiste en completar los detalles que antes se han dejado sin especificar.2. Para garantizar la reusabilidad de los nuevos tipos las operaciones que definamos sobre ellos deben ser completas. al diseñar un TDA disponemos de un nuevo tipo de dato para posibles problemas futuros. 2. Esta separación en la construcción de los programas se conoce con el nombre de abstracción-realización. la realización de un programa. Para obviar el tipo de implementación que se ha usado necesitamos realizar ocultamiento de la información. teniendo en cuenta solo las características importantes del mismo. podemos enumerar las siguientes operaciones primitivas: 1. pues tal vez accediendo a la implementación de forma directa el resultado acabe siendo más eficiente. Aunque la forma de construcción de TDA se haya presentado con el objetivo de resolver un único problema. debemos identificar las operaciones primitivas. 4. Coeficiente: devuelve un coeficiente del polinomio. concretamente el TDA polinomio. Esta separación debe incluso permitirnos sin ningún problema poder especificar las operaciones y construir el programa original sin llegar a implementarlas hasta el final. Construir la solución despreciando detalles de implementación. La construcción de software siguiendo esta metodología nos va a permitir: 1. Grado: devuelve el grado del polinomio. estos tipos se pueden construir de forma independiente. La implementación de los TDA correspondería a la tarea de realización. En el caso que nos ocupa. Declaración del tipo. y con ellas podemos llevar a cabo cualquier aplicación sobre el tipo polinomio sin necesidad de ninguna más. P (x) = a0 + a1 x + a2 x + …+ anx 1 2 n Donde n es un número natural y ai es un número real. float c) pre p está inicializado. inicializar con el valor nulo y devolver un tipo polinomio. natural n) pre p está inicializado. El objetivo de esta función es reservar los recursos necesarios. float Coeficiente (polinomio p. void AsigCoeficiente (polinomio p. Coeficiente. Su especificación es la siguiente: Polinomio CrearPolinomio (int MaxGrado) pre MaxGrado ≥ 0. Los pasos a seguir para hacer uso de una instancia P de este tipo son: 1. post devuelve el polinomio nulo. El polinomio P (x) = 0 es llamado polinomio nulo. 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 de polinomios de cualquier grado en una variable x con coeficiente reales. Estas operaciones son imprescindibles y forman un conjunto mínimo. natural n. Estos dos hechos nos permiten afirmar que son válidas como conjunto de funciones primitivas de nuestro tipo. 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).Tipos de Datos Abstractos 4. n ≤ MaxGrado. post devuelve el coeficiente correspondiente al monomio de grado n del polinomio p. son pues también un conjunto suficiente. Declaramos a P de tipo polinomio (polinomio P). AsigCoeficiente. 67 . n ≤ MaxGrado. AsigCoeficiente: asigna un coeficiente del polinomio 5. Una vez definido el dominio del TDA polinomio. Estas operaciones las podemos clasificar como sigue: • • • De creación: CrearPolinomio De destrucción: DestruirPolinomio De acceso y actualización: Grado. int Grado (polinomio p) pre p está inicializado post devuelve el grado del polinomio indicado por p. DestruirPolinomio: libera los recursos del tipo obtenido. pasamos a especificar sus operaciones. 2 en ella se aprecia dos aspectos importantes de los TDA: 1. float resultado. La solución a este problema se detalla en la figura 4.0. 68 . grado=Grado(p). for (i=0. float EvaluarPolinomio (polinomio p. Una vez especificado el TDA polinomio se puede construir cualquier aplicación sobre el tipo polinomio. esta operación tendrá un O ( n).i)). 2. } return (resultado). De esta manera. 3. Llamamos a la función de creación para crear la estructura de datos que sustenta el tipo asignado el resultado a P. Inicialmente se ha diseñado el TDA polinomio independientemente del problema a resolver. elegir una implementación para el TDA polinomio y realizarla. de forma que el tipo polinomio puede representarse en la forma que se desee sin que los programas que lo utilicen sufran cambios. Construir la solución despreciando detalles de implementación. Esta implementación tiene el inconveniente de que para determinar el grado el polinomio será necesario recorrer ese vector desde el final hasta el inicio buscando el primer coeficiente distinto de cero. } Figura 4. Ahora bien quedaría un tema por resolver. La implementación de un TDA conlleva elegir una estructura de datos para representar el TDA y diseñar en base a la estructura de datos elegida un procedimiento por cada operación del TDA. Destrucción de los recursos usados por P. 4.Programación II 2. entre otras: 1.i).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 resolver cualquier problema. Uso de las funciones de acceso y actualización sobre P. de tal forma que el coeficiente iésimo se guardará en la posición i del vector. float valor) /*Función que dado un polinomio p lo evalúa para un valor x */ { int i.i++) { coeficiente= Coeficiente (p. disponemos de un nuevo tipo reusable en posibles problemas futuros. Creación de la estructura. Un vector en el que se guarden los distintos coeficientes. resultado=0.i<=grado. resultado=resultado+coeficiente*(pow (valor. Una posible aplicación sería evaluar un polinomio.coeficiente.grado. Tenemos una amplia gama de posibilidades para la implementación del tipo polinomio. en este caso el TDA polinomio. el coeficiente 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 x se representen sin desperdiciar el espacio para guardar los primeros 544 coeficientes cero. 4.4 Consideraciones g enerales para la elección de primitivas En el ejemplo anterior mostramos como se debe construir un tipo de datos abstracto. Una vez definidas todas las posibles representaciones para el TDA polinomio. 3. En este caso la implementación de la operación para determinar el grado será O (1). distinto de cero. En aquellos casos en que no se conozca a priori el tamaño de los objetos del TDA con los que se va a trabajar debemos rechazar este tipo de representaciones. Un registro con dos campos: uno para guardar el grado y otro correspondiente a un puntero del que cuelga una serie de celdas enlazadas una por cada coeficiente distinto de cero que posea el polinomio.2. De esta forma no es necesario limitar a priori el número máximo de coeficientes que posee el polinomio y se utiliza sólo la memoria necesaria para guardar los coeficientes distintos de cero. El conjunto de primitivas debe ser suficiente pero no obligatoriamente mínimo. Con el fin de elegir aquella 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. cada una contiene un par coeficiente-grado. se usa esta primitiva para liberar los recursos de memoria que mantienen la estructura. En esta decisión suelen prevalecer dos criterios: a) Cuáles son las operaciones que se van a realizar con más frecuencia. Un registro con dos campos: uno para guardar el grado del polinomio y otro correspondiente a un vector en el que se guardan los coeficientes con el mismo criterio que en la implementación anterior. Este el caso de la función CrearPolinomio. 69 . Pues las representaciones basadas en estructuras de datos estáticas (como las representaciones 1.Tipos de Datos Abstractos 2. Para ello se ha elegido un conjunto de primitivas representativo aunque éste no es único pues es posible optar por un conjunto distinto teniendo en cuenta los siguientes puntos: 1. Coeficiente y DestruirPolinomio dejan de tener un orden de eficiencia constante. Así. 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 primer coeficiente. Aunque no sea necesario incluir nuevas primitivas.: a) destruir: cuando un polinomio ya no va a ser utilizada. debemos decantarnos por alguna de ellas.2 y 3 del TDA polinomio definidas anteriormente) limitan el tamaño de los objetos del TDA. la cual en ciertas implementaciones es altamente probable que sea transformada en una función de creación complementada con otra de destrucción. comenzando por el término independiente. puede ser conveniente añadir nuevas funciones si existen motivos: a) La función va a ser probablemente muy usada. 545 4. b) La función va a ser usada con cierta asiduidad y su implementación haciendo uso de las demás funciones primitivas empeora considerablemente la eficiencia de la operación. 2. Como inconvenientes tenemos que operaciones como AsigCoeficiente. Puede ser necesario rehacer el conjunto de primitivas atendiendo a razones referentes a una eficiente utilización de los recursos hardware. La información del tipo en un lenguaje de programación se usa. su significado es diferente.3 Tipo de datos. y calcula el resultado obteniendo un tipo que esta determinado por el de éstos (usualmente el mismo sí son iguales). el conjunto de funciones que incorporamos a un TDA no debe ser diseñado considerando que debemos de añadir todas y cada una de las primitivas que creemos que se necesitarán. por una parte para prevenir errores. Cada operador está definido para operandos de varios tipos. Por tanto. Las cabeceras de las funciones pueden necesitar ser modificadas para hacer viable su implementación. el conjunto de primitivas de un TDA es algo extensible. 4. Un tipo determina la clase de valores que pueden tomar las variables y expresiones. etc. constante o expresión tiene un único tipo asociado. sin ningún conocimiento de su valor calculado en el momento de ejecución. 2. determina la forma en que las operaciones aritméticas tienen que interpretarse. 70 . En la implementación del lenguaje. Las características del concepto de tipo pueden resumirse en los siguientes puntos: 1. es tructura de datos y tipo de datos abstracto. El tipo de un valor denotado por una constante. 3. 3. En algunos lenguajes como C. y capacita al compilador a detectar errores en aquellos programas que no las utilicen de forma adecuada. variable o expresión puede deducirse de su forma o contexto. 4. 4. en muchos casos. En este sentido. Es el caso por ejemplo de una función que no pueda devolver un tipo de dato o que el tipo de dato sea muy complejo y pasarlo por valor o devolverlo como salida de una función pueda convertirse en algo ineficiente debido a su tamaño. se utiliza esta primitiva para reservar y asociar la memoria necesaria para mantener la estructura del polinomio en memoria. Las propiedades de los valores de un tipo y de sus operaciones primitivas se especifican formalmente. es mucho menos costoso la adición de nuevas primitivas que la supresión de algunas ya existentes. es más conveniente retrasar la incorporación de ciertas primitivas cuya necesidad sea dudosa./. Pues desde el punto de vista del mantenimiento del software. este símbolo puede considerarse como ambiguo. La resolución de la ambigüedad puede realizarse siempre en el momento de compilación. de forma que cada variable. para los enteros y los reales). la asociación de un tipo con una variable se realiza en su declaración. Cuando el mismo símbolo se aplica a diferentes tipos (la división.Programación II b) crear: antes de usar un nuevo polinomio. Todo valor pertenece a uno y sólo un tipo. en otros lenguajes. y por otra. Por tanto. En un lenguaje de programación el concepto de tipo es de central importancia. como el Fortran. ésta se realiza a través de la primera letra. el tipo de datos de una variable es el conjunto de valores que ésta puede tomar. la información referente al tipo. Aunque los términos “tipo de datos” (o simplemente “tipo”). que una función no devuelva un valor sino que lo devuelva mediante los parámetros a través del paso por referencia. 6. “estructura de datos” y “tipo de datos abstracto” parecen semejantes. denotando diferentes operadores según el tipo de operandos a que se aplique. Un tipo de dato abstracto es un producto software y como tal es algo dinámico y está sujeto a mantenimiento. del identificador asociado a dicha variable. 5. será aconsejable no pasar estructuras directamente sino un puntero a ellas. para determinar el método de representar y manipular los datos en un ordenador. Para representar el modelo matemático básico de un TDA. En aquellos casos en que no se conozca de antemano el tamaño aproximado de los objetos del TDA con los que se va a trabajar. no tendremos más que hacer uso de ellas añadiendo el módulo correspondiente a nuestro programa y usando la estructura en base a las especificaciones como tipo de dato abstracto sin necesidad de considerar detalles de implementación. pero los componentes últimos deben ser de tipo básico. a la hora de elegir una estructura frente a otras se deben tener en cuenta los siguientes principios: 1. este método se conoce como asignación dinámica de memoria y tiene como elemento base al tipo de dato puntero. forma de procesamiento. Obviamente también será posible que nuevos tipos de datos abstractos sean creados con estructuras de datos específicas. no es conveniente una estructura de datos estática. árboles binarios de búsqueda (ABB). La fase de realización consistirá en representar los TDA en función de los tipos de datos y los operadores manejados por ese lenguaje. que no estén presentadas como tipo de dato abstracto. …) podemos definir un tipo de dato abstracto. 71 . carácter y lógicos. es en estos caso en los que realmente los tipos de datos abstractos para nuestra aplicación serán construidos sobre los tipos ofrecidos por el lenguaje de programación. A través de una estructura de datos y sus operaciones (métodos de acceso. En el caso de estructuras estáticas se asigna un tamaño fijo para almacenar los objetos del TDA. es el caso de: listas. Pero hay ocasiones en que esto no es lo más conveniente y es deseable aprovechar mejor la memoria solicitándola conforme sea necesario y liberándola cuando ya no haga falta. La eficiencia en tiempo de las operaciones que se utilizarán con mayor frecuencia. tal y como ya se ha comentado. en base a la experiencia acumulada de los programadores. reales. grafos. Es imprescindible para generar este tipo de estructuras disponer de un método para adquirir posiciones adicionales de memoria a medida que se necesiten durante la ejecución del programa y liberarlas posteriormente cuando ya no sean necesarias. Un tipo de datos abstracto es un modelo matemático. A partir de estos tipos básicos se pueden generar nuevos tipos de datos. pero en la mayor parte de los lenguajes se suelen encontrar los siguientes métodos de estructuración estáticos: array. En el primer curso de programación se ha definido este tipo de dato puntero y sus operaciones. pero en la mayoría de los lenguajes suelen aparecer como tipos básicos los proporcionados por el ordenador: enteros. Las posibles restricciones de espacio en memoria derivadas del uso de la estructura. junto con varias operaciones definidas sobre ese modelo. conlleva dos fases: una de especificación y otra de realización. y de esta forma construir verdaderas jerarquías de estructuras. En definitiva. árboles binarios parcialmente ordenados (APO). se emplearán estructuras de datos. Estas estructuras de datos se caracterizan por conocer su tamaño en tiempo de compilación y no variarlo durante la ejecución del programa. podemos considerar un conjunto de estructuras de datos que son ampliamente usadas junto con sus operaciones más frecuentes. cadena de caracteres y registro. Algunas de estas estructuras de datos (listas y árboles) serán estudiadas en este libro y presentadas como tipos de datos abstractos. Las estructuras diseñadas de este modo reciben el nombre de estructuras de datos dinámicas. árboles equilibrados (AVL). con ello se limita el tamaño máximo de los mismos y en muchas ocasiones se desaprovecha espacio en memoria. El diseño de un TDA. Un TDA puede implementarse bajo diferentes estructuras de datos.Tipos de Datos Abstractos Un lenguaje de programación proporciona sus propios tipos de datos básicos. 2. Lógicamente. y estos pueden servir de base para diseñar estructuras de datos más complejas. sin necesidad de reservar una cantidad fija e invariable. tablas hash. De esta manera cuando en un programa necesitemos manejar objetos estructurados según alguna de las estructuras de datos de que disponemos. Los mecanismos de estructuración para construir tipos de datos compuestos a partir de los básicos varían de un lenguaje a otro. aplicando mecanismos de estructuración del lenguaje de programación. árboles binarios. no se deben confundir los conceptos de TDA y estructura de datos. A estos nuevos tipos se les denomina tipos de datos compuestos o estructurados (estructuras de datos). Programación II 4.4 Tipos de datos a bstractos lineales En 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 los elementos se insertan y eliminan solo en un extremo, y las colas donde los elementos se insertan por un extremo y se eliminan por el otro. Para cada uno de estos TDA, se presentarán y analizarán varias realizaciones. 4.4.1 El tipo de datos ab stracto “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ón de la lista. Las listas también pueden concatenarse entre sí o dividirse en sublistas. Suelen ser bastante 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 lineal de 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 n elementos, no existe ningún elemento que ocupe la posición n+1. Sin embargo, conviene considerar esta posición, a la que se llama posición que sucede al último elemento de la lista, ya que esta posició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 posiciones guardan 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 debe definir un conjunto de operaciones. Como sucede con muchos de los TDA, ningún conjunto de operaciones es adecuado para todas las aplicaciones. Aquí se presentará un conjunto representativo de operaciones. Vamos a notar al conjunto de las listas (es decir, al tipo lista) como TLista, al conjunto de los elementos básicos como TElemento, y al tipo posición como TPosicion. Se define el tipo posición ya que 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 post L está inicializada. primero = 1 {Si L está vacía, la posición que se devuelve es fin (L) } 72 Tipos de Datos Abstractos 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 post L = <a1,a2,…,an> 1 ≤ p ≤ n+1 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 tiene posición p, el resultado es indefinido} void borrar (Tposicion p,TLista L) pre post L = <a1,a2,…,an> 1≤p≤n 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 elemento que se encontraba en la posición p+1. El resultado no está definido si L no tiene posición p o si p = fin (L)} TElemento elemento (Tposicion p,TLista L) pre post L = <a1,a2,…,an> 1≤p≤n elemento = ap {devuelve el elemento que está en la posición p de la lista L. El resultado no está definido si p= fin (L) o si L no tiene posición p} TPosicion siguiente (Tposicion p,TLista L) pre post L = <a1,a2,…,an> 1≤p≤n 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 73 Programación II 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 la lista L no tiene posición p} Tposicion posicion (TElemento x,TLista L) pre post L está inicializada 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 lista entonces se devuelve fin (L)} El conjunto de primitivas presentado es un ejemplo representativo de las primitivas más importantes que nos permite mostrar la forma en que se debe construir el tipo de datos abstracto Lista. 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. Los elementos de la lista son de tipo TElemento y existe una función lógica, igual (x,y), que nos dice cuando 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 que TElemento 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 su posición, usaremos la posición q para recorrer los elementos a la izquierda de p. Cuando se elimine el 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 de q 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 las listas. Pero para que esta función sea ejecutable debemos representar el TDA Lista bajo alguna estructura de datos. 74 . .Tipos de Datos Abstractos Implementación de las listas Implementación de las listas mediante vectores En la realización de una lista mediante un array. }Lista. .3. la i_ésima posición. . typedef int TPosicion. if ((*L)==NULL){ printf (“error de memoria”). n . typedef Lista *TLista. Las operaciones más simples son: void anula (TLista * L) { (*L)=(TLista) malloc (sizeof (Lista)). Para esta realización basada en vectores. Tal y como se ilustra en la figura 4. 75 . El i-ésimo elemento de la lista está en la (i-1)-ésima celda del vector. los elementos de ésta se almacenan en celdas contiguas de un vector. 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. el tipo abstracto Lista ser define como sigue: #define LMAX 100 typedef int TElemento typedef struct { TElemento elementos [LMAX]. el primero un vector que tiene la longitud adecuada para contener la lista de mayor tamaño que se pueda presentar. 0 1 Primer Elemento Segundo Elemento . debemos tomar vectores de tamaño igual a la longitud máxima de la lista y utilizar un entero que nos indique la posición del último elemento de la lista. Como las listas tienen longitud variable y los vectores longitud fija. . Último Elemento . . . . int n. mediante el entero i-1. El segundo campo es un entero que indica la posición del último elemento de la lista en el vector. Las posiciones en la lista se representan mediante enteros. Como consecuencia definiremos el tipo Lista como un puntero a un registro con dos campos. } return (p-1). La función posicion tiene que realizar una búsqueda secuencial en un vector. } TElemento elemento (Tposicion p. En el caso de que el elemento no esté en el vector la función devolverá fin (L).TLista L) { TPosicion q. borrar y posicion. int encontrado. } TPosicion primero (TLista L) { return (0). } (*L)->n =-1. } Las únicas operaciones que pueden presentar un poco de dificultad son las de insertar. } return (L->elementos[p]). } TPosicion siguiente (TPosicion p. } return (p+1).Programación II exit (1). } TPosicion fin (TLista L) { return (L->n+1). TPosicion posicion (TElemento x. } return (q).TLista L) { if ((p < 0) || (p > L->n)){ printf (“La posición no está en la lista”). q=encontrado=0. } 76 . exit (1). TLista L) { if ((p<=0) || (p > L->n+1)){ printf (“La posición no está en la lista”). exit (1). } Tposicion anterior (TPosicion p. else q=q+1. while ((q <= L->n)&&(!encontrado)){ if (l->elementos[q]==x) encontrado=1.TLista L) { if ((p<0) || (p > L->n)){ printf (“La posición no está en la lista”). exit (1). if ((p > L->n) || (p<0)){ printf (“La posición no está en la lista”). se debe desplazar una posición dentro del array a todos los elementos que siguen al elemento de la posición p. exit (1). Este problema se acentúa si las distintas listas que se representan son de un tamaño muy dispar. en este caso no es conveniente el uso de esta representación. exit (1). menor que el tamaño máximo.p+2. la versión optimizada sería: 77 . void insertar (TElemento x. la necesidad de una función de destrucción ya que ahora mismo la memoria que se requiere cada vez que se hace una llamada a la función anula no es recuperada en ningún momento.… 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.q<=L->n. siempre hay una cantidad de espacio reservada para los elementos de la lista desperdiciada al ser el tamaño de la lista.p+1. } else if (L->n >= LMAX-1){ printf (“La lista está llena”).TPosicion p. Podemos paliar algunos de los problemas que presenta la implementación considerando las posibilidades que nos brindan el lenguaje C. … una posición hacia abajo*/ L->elementos[q+1] = L->elementos[q]. Otro inconveniente de esta implementación es que las listas tienen un tamaño máximo del que no se puede pasar en contra de la especificación en la que no se le impone ningún límite al tamaño de las listas. } else { L->n--. pues en el peor de los casos tendrían que recorrer la lista por completo para realizar la operación en cuestión. cómo hemos mencionado anteriormente. L->elementos[p]=x. con el fin de hacer previamente un hueco donde realizar la inserción. Además. en un momento dado. } else { for (q=L->n. } } De la misma forma la eliminación de un elemento.TLista L) { TPosicion q.q++) /*Desplaza los elementos en p+1.q—) /*Desplaza los elementos en p.TLista L) { if ((p > L->n) || (p<0)){ printf (“La posición no está en la lista”). requiere desplazamientos de elementos para llenar el vacío formado. Otro detalle importante en esta implementación es. excepto en el caso del último. L->n++.Tipos de Datos Abstractos Para insertar un elemento en la posición p de la lista (excepto para p= fin (L)). void borrar (TPosicion p. exit (1). for (q=p.q>=p. } Las demás primitivas quedarían de la misma forma sustituyendo LMAX por L->Lmax. exit (1). if (L->elementos==NULL){ printf (“error: no hay memoria”). El uso de la primitiva crear sobre una lista ya creada provocará una pérdida de los recursos de memoria ocupados por esta lista y la actualización de su valor a la lista vacía. L->elementos= (TElemento ) malloc (tamMax*sizeof (TElemento)). uso y destrucción de una nueva lista. Creación y destrucción. int n. if (L==NULL){ printf (“error: no hay memoria”). Uso de la lista mediante primitivas distintas a la de creación y destrucción. L =(TLista) malloc (sizeof (Lista)). 2. free(L). Ahora la primitiva anula ha sido sustituida por la primitiva crear a la que se pasa un parámetro indicando el tamaño máximo que tendrá la lista. typedef struct{ TElemento *elementos. Declaración de la variable de tipo TLista.Programación II typedef int TElemento. Después de la destrucción de una lista. exit (1). En esta nueva implementación conseguimos resolver con éxito dos cosas: 1. En esta versión se ofrece el constructor y el destructor del tipo de dato permitiendo de esta forma recuperar los recursos ocupados por las listas que no se volverán a usar. } L->Lmax=tamMax. Se mejora con respecto a la versión anterior en la que el tamaño máximo tenía que ser superior a la más grande de las listas que se manejan y por consiguiente para pequeñas listas habría una gran cantidad de memoria desperdiciada. Una vez implementado el TDA Lista la función elimina será ejecutable y el uso del TDA Lista para la última versión debe ser: 1. typedef int TPosicion. typedef Lista *TLista. 2. Tamaños variables. }Lista. int Lmax. TLista crear (int tanMAx) { TLista L. } } void destruir (TLista L) { free (L->elementos). 3. Creación de la lista mediante la primitiva crear. L->n=-1. 78 . se podrá usar de nuevo la misma variable en la creación. Sin embargo. la definición del tipo lista correspondiente a la implementación por punteros sería: typedef struct celda{ 79 . (a) a1 a2 an (b) Cabecera a1 an L Figura 4. hay que pagar el precio de un espacio adicional para los punteros. Para realizar más fácilmente las operaciones es conveniente considerar una celda inicial. es posible modificar las estructuras de datos de los programas más fácilmente con sólo aplicar de nuevo las operaciones. puede ser especialmente importante en proyectos grandes. De esta forma la lista propiamente dicha vendrá representada por un puntero que indique la dirección de la cabecera y que permite obtener los distintos elementos de la misma como se muestra finalmente en la figura 4. por tanto. la posición i será un puntero pero no a la celda que contiene al elemento ai . Destrucción de la lista mediante la primitiva destruir. La posición del primer elemento será un puntero a la celda cabecera. en lugar de buscar en todos los programas aquellos lugares donde se hacen accesos a las estructuras de datos subyacentes. idéntico a la lista L. aunque no sea tan evidente en los ejemplos pequeños que necesariamente se encuentran en este libro.a2.4 (a). una lista está formada por celdas. se representa por una celda dividida en dos partes: un primer campo que contiene al elemento ai de la lista y un segundo campo donde se almacena un puntero a la celda que contiene al siguiente elemento ai+1. llamada cabecera donde no se almacena ningún elemento de la lista. Con esto se puede acceder al elemento y también se facilita el diseño de las operaciones de inserción y borrado. La celda que contiene a an posee un puntero a NULL (puntero nulo). también elude los desplazamientos de elementos para hacer inserciones y eliminaciones. con ello se evita el empleo de memoria contigua para almacenar una lista y. Al principio puede parecer tedioso escribir procedimientos que rijan todos los accesos a las estructuras subyacentes de un programa. para indicar el final de la lista. No obstante. En el caso de una lista vacía. Aquí. sino a la celda donde está el elemento anterior ai.Tipos de Datos Abstractos 4. y la posición fin (L) es un puntero a la última celda de L.an>. En esta representación. si se logra establecer la disciplina de escribir programas en función de operaciones de manipulación de tipos de datos abstractos en lugar de usar ciertos detalles de implementación particulares.4 (b). Así.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. el puntero cabecera será NULL. La flexibilidad que se obtiene con esto.…. En C. la lista quedaría como se muestra en la figura 4. cada elemento ai de una lista <a1. Implementación de listas mediante celdas enlazadas por punteros En esta implementación se utilizan punteros para enlazar elementos consecutivos. Como se puede observar el tipo lista es el mismo que el de posición. } void destruir (TLista L) { TPosicion p. free(p). 80 . aunque esto complicaría ligeramente algunas operaciones. un puntero a una celda. p=L. return (p). struct celda * sig. } } TPosicion fin (TLista L) { TPosicion p. typedef celda *TPosicion. for (p=L. return (L). exit (1). Considerar listas con un puntero a la cabecera y otro a la posición fin (L).p=L){ L=L->sig. Las operaciones se detallan a continuación.L!=NULL. en un ciclo while con una condición del tipo p !=fin (L) se debería sustituir por: q= fin(L). donde q es de tipo posicion y fin (L) no se ve afecta de ninguna forma en el interior del ciclo.. } L->sig =NULL. typedef celda *TLista. }celda. Su eficiencia es pues del orden de la longitud de la lista. 2. } 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..Programación II TElemento elemento. Si en las aplicaciones que utilizan el tipo lista se va a utilizar con frecuencia esta operación puede optarse por cualquiera de las opciones siguientes: 1. Por ejemplo. pero se ganaría mucha eficiencia en esta función. if (L==NULL){ printf (“error: no hay memoria”).. while (p->sig!=NULL) p=p->sig. Sustituir el uso de fin (L) donde sea posible. while (p!=q) . L=(TLista) malloc (sizeof (celda)). entre ellas destacar que se han diseñado funciones de creación y destrucción para evitar que la memoria utilizada por una lista quede sin recuperar cuando esta ya no es útil: TLista crear () { TLista L. p->sig=q. if (q==NULL) { printf (“error: no hay memoria”). complicando la realización de la operación.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 se tardaba un tiempo proporcional a la longitud de la lista. Es responsabilidad del programador utilizarla siempre con posiciones de esta lista. El procedimiento funciona bien en los casos extremos de la primera posición y fin (L) posición. } q ->elemento=x. En listas sin cabecera estos casos habrían sido necesarios considerarlos aparte.TLista L) { TPosicion q. c) Gracias al uso de la celda cabecera no es necesario distinguir en que posición se realiza la inserción.TPosicion p. } En la figura 4. se puede dar lugar a graves errores.5: inserción en una lista enlazada TPosicion siguiente (TPosicion p. q =(TPosicion) malloc (sizeof (celda)).Tipos de Datos Abstractos void insertar (TElemento x. y dejaría de ser tan eficiente la operación. ( a ) Situación inicial ai p ( b ) Asignaciones de q ai+1 ai ai+1 p x q ( c ) Asignación de p ai p ai+1 x q Figura 4. exit (1).TLista L) 81 . q ->sig=p->sig. Si no se hace así. b) No se comprueba la precondición pues se perdería mucho tiempo en la comprobación. En la función si se ha podido realizar el cambio porque se trata de una operación primitiva. encontrado=0.Programación II { if (p->sig==NULL){ printf (“no hay siguiente del fin de la lista”). } TPosicion primero (TLista L) { return (L). } TPosicion posicion (TElemento x. p=primero (L). Se podría pensar sustituir en cualquier programa esta última condición por la que hemos usado aquí. pero no se utiliza esta última pues aumentaría mucho la complejidad debido a la ineficiencia ya comentada de la función fin (L). pero no se debe de hacer pues iría en contra de todo lo comentado sobre la construcción de programas haciendo uso de TDAs. return (p). exit (1). } void borrar (TPosicion p. c) En la condición del bucle aparece la comparación (p->sig!=NULL). } 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 el elemento buscado en la lista. TElemento elemento (TPosicion p. exit (1). int encontrado. 82 . exit (1). else p=p->sig. Esta es equivalente a (p!=fin (L)).TLista L) { TPosicion q. } return (p->sig->elemento). } return (p->sig).TLista L) { if (p->sig==NULL){ printf (“error posición fin de la lista”). b) La complejidad es la misma que para la implementación mediante vectores. while ((p->sig!=NULL)&&(!encontrado)) if (p->sig->elemento==x) encontrado=1.TLista L) { TPosicion p. if (p->sig==NULL){ printf (“error posición fin de la lista”). donde n es la longitud de la lista. La implementación mediante vectores requiere especificar el tamaño máximo de una lista en el momento de la compilación. Otras veces la decisión es en base a la longitud de la lista. free(q). Este problema ha sido parcialmente solucionado con la parametrización del tamaño máximo de la lista. Por otro lado. En la figura 4. Por ejemplo. en esta representación requieren únicamente un tiempo constante. pero aún así hay que delimitar el tamaño máximo para cada una de las listas. Si no es posible acotar la longitud de la lista. Los punteros antiguos se representan por medio de líneas de trazo continuo.6 se muestra las manipulaciones de punteros que se realizan en esta operación. ai ai+1 p ai+2 Figura 4. pero necesitan un tiempo proporcional al número de elementos de la lista para la representación basada en vectores.Tipos de Datos Abstractos } q=p->sig.6: borrado en una lista enlazada Con esta representación de listas basadas en punteros se consigue solucionar algunos de los problemas que tenía la representación mediante vectores. Sin embargo. la respuesta depende de las operaciones que se deseen realizar. Comparación de los métodos Cabe preguntar si en determinadas circunstancias es mejor usar una implementación de listas basadas en celdas enlazadas o en vectores. 83 . Por un lado. en las listas doblemente-enlazadas se requiere tiempo constante para todas las operaciones (excepto la de posición que requiere un tiempo proporcional a la longitud de la lista). Los puntos principales a considerar son los siguientes: 1. las operaciones de inserción y borrado que en la anterior representación tenían un O(n). ya no se acota el tamaño máximo de las listas y por otro. la ejecución de anterior y fin requiere un tiempo constante con la representación mediante vectores. pero un tiempo proporcional a la longitud de la lista si usamos la implementación por punteros simplemente-enlazadas (aunque recordemos que el problema es solucionable añadiendo un puntero). Ciertas operaciones son más lentas en una realización que en otra. Inversamente. la representación basada en punteros requiere un espacio adicional para el puntero de cada celda. insertar y borrar realizan un número constante de pasos para una lista enlazada. A menudo. y los nuevos por medio de líneas punteadas. } Esta operación es más sencilla que la de inserción. o de las que se realizan más frecuentemente. p->sig=q->sig. posiblemente deberíamos escoger una representación basada en punteros. con el consiguiente ahorro de espacio en memoria. esto significa que si se conocemos el tamaño aproximado de las listas que se van manejar podría ser más conveniente representar el tipo lista mediante vectores. 2. struct celda sig.7. En el caso de la implementación mediante vectores. si borramos un elemento. Por último. La implementación por punteros utiliza sólo el espacio necesario para los elementos que actualmente tienen la lista. 4. En tales situaciones podríamos desear añadir a cada celda de una lista un puntero a la anterior celda tal y como se muestra en la figura 4. Listas Doblemente-Enlazadas En algunas aplicaciones puede ser deseable recorrer eficientemente una lista. que es menos natural. La declaración de las celdas para una lista doblemente enlazada sería: typedef struct celda{ TElemento elemento. }celda. Otra ventaja importante de las listas doblemente enlazadas es que permiten usar un puntero a la celda que contiene el i-ésimo elemento de una lista para representar la posición i. dado un elemento. typedef celda * TPosicion. pero necesita espacio adicional para los punteros de cada celda. ésta queda invalida. El único precio que se paga por estas características es la presencia de un puntero adicional en cada celda y consecuentemente procedimientos algo más lentos para alguna de las operaciones básicas de las listas. if (p->sig!=NULL) p->sig->ant=p->ant. } 84 . free(p). La realización con vectores puede malgastar espacio. podría desearse determinar con rapidez el siguiente y el anterior.ant. O. en vez de usar el puntero a la celda anterior. Figura 4. tanto hacia delante como hacia atrás.Programación II 3. puesto que usa la cantidad máxima de espacio independientemente del número de elementos presentes en la lista en un momento dado.7: borrado en una lista doblemente-enlazada Un procedimiento para borrar un elemento en la posición p de una lista doblemente-enlazada es: void borrar (TPosicion p) { if (p->ant!=NULL) p->ant->sig=p->sig. las listas doblemente-enlazadas aunque son las más eficientes requieren de dos punteros para cada elemento. todas las posiciones posteriores a ese elemento apuntarán al siguiente al que apuntaban y si existe alguna posición apuntando al final de la lista. En las listas enlazadas la posición de un elemento se determina con un puntero a la celda del elemento anterior por lo que hay que tener cuidado con la operación de borrado si se trabaja con varias posiciones consecutivas. 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 post P está inicializada 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.a2.an> P = <a2.a2. o de platos en una estantería.…. pues la cabeza de una pila se identifica con la posición 1} void push (TElemento x.P)} void pop (TPila P) pre post no vacia (P) P=<a1.2 Pilas Una pila es un tipo especial de lista en la que todas las inserciones y borrados tienen lugar en un extremo denominado tope.…. El modelo intuitivo de una pila es precisamente un mazo de cartas puesto sobre una mesa.7 se expresa de forma gráfica los cambios sufridos por los punteros en este procedimiento. al conjunto de los elementos básicos como TElemento .a2. TPila P) pre post P=<a1. primero en salir”.a1.P) . En términos de primitivas de listas esta operación es inserta(x.primero(P). es práctica común hacer que la primera celda de la lista doblemente_enlazada sea una celda que efectivamente cierre el círculo.4. Para evitar las verificaciones sobre si la celda a suprimir es la primera o la última.an> P = <x.…. situaciones todas en las que sólo es conveniente quitar o agregar un objeto del extremo superior de la pila. que el campo ant de esta celda apunte a la última celda y el campo sig de la última celda apunte a la primera. 4. al que se denominará en lo sucesivo tope. Esta operación puede escribirse en términos de las operaciones de listas como elemento(primero(P). A las pilas se les llama también “listas LIFO” (last in first out) o listas “último en entrar. y los nuevos con líneas punteadas. Los punteros antiguos se representan con líneas de traza continua.an> 85 .an> {inserta el elemento x en el tope de la pila P. Especificación de las operaciones primitivas del TDA Pila Vamos a notar al tipo pila como TPila.Tipos de Datos Abstractos En la figura 4. es decir.…. Segundo Elemento Primer Elemento . Esta idea se ilustra en la figura 4.P). En términos de primitivas de listas Borra (primero (P).Programación II {Borra el elemento del topo de la pila P. . Tope Último Elemento .8: implementación de una pila mediante un vector Para esta realización basada en vectores. . Se puede anclar la base de la pila a la base del array (el extremo de índice más bajo) y dejar que la pila crezca hacia el último elemento del array. Algunas veces es conveniente implementar pop como una función que devuelve el elemento que acaba de borrar} Al igual que se hizo con la operación anula para las listas. no es particularmente buena para las pilas.8. . requiere un tiempo proporcional al número de elementos en la pila. int Lmax. . typedef struct { TElemento *elementos. porque cada push o pop requiere mover la lista entera hacia arriba o hacia abajo y por tanto. tamMax-1 Elementos Figura 4. Aún así conviene destacar que las operaciones de las pilas son más específicas y que por lo tanto la implementación puede ser mejorada especialmente en el caso de la implementación basada en vectores. . 0 . en la implementación del tipo pila esta función también será transformada en una función de creación que se verá completada con otra nueva de destrucción. Implementación de las pilas Todas las implementaciones de listas que hemos descrito son válidas para las pilas ya que una pila junto con sus operaciones es un caso especial de una lista con sus operaciones. . . int tope. Un mejor criterio para usar un array es tener en cuenta que las inserciones y las supresiones ocurren sólo en la parte superior. 86 . Un cursor llamado tope indica la posición actual del primer elemento de la pila. Implementación de pilas basadas en vectores La implementación basada en vectores para las listas. el tipo abstracto pila se define como: typedef int TElemento. } 87 . if (P->elementos==NULL){ printf (“error no hay memoria”). } int vacia (TPila P) { return (P->tope == -1). } return (P->elementos[P->tope]). exit (1). exit (1). } void pop (TPila P) { if (vacia (P)){ printf (“la pila está vacía”). P->tope= -1. P= (TPila) malloc (sizeof (Pila)). P->elementos= (TElemento *) malloc (tamMax * sizeof (TElemento)). } P->tope--. typedef Pila *TPila. if (P==NULL){ printf (“error no hay memoria”). exit (1). } void destruir (TPila P) { free (P->elementos). Las operaciones primitivas de las pilas serán implementadas de la siguiente forma: TPila crear (int tamMax) { TPila P. } TElemento tope (TPila P) { if (vacia (P)){ printf (“la pila está vacía”). free (P).Tipos de Datos Abstractos }Pila. } P->Lmax=tamMax. exit (1). } return (P). Esto último hace que las cabeceras puedan ser punteros mejor que celdas completas.9. exit (1). TPila crear () { TPila P. struct nodo * sig. P=(nodo **) malloc (sizeof (nodo *)). ya que no necesitamos representar la posición 1 de forma análoga a otras posiciones. P->elementos[P->tope] = x. } P->tope++. return (P). exit (1). } int vacia (TPila P) { return ((*P)==NULL).9: pila implementada mediante celdas enlazadas Lógicamente. typedef struct nodo{ TElemento elemento. P Cabecera a1 tope de la pila an fondo de la pila Figura 4. } Implementación de Pilas mediante celdas enlazadas La representación por celdas enlazadas de una pila es bastante fácil. el que las funciones sobre pilas sean más específicas que sobre listas implica que en general se simplificará la implementación.9. }nodo.Programación II void push (TElemento x. pues push y pop operan sólo sobre el primer elemento y no existe la noción de posición. if (P==NULL){ printf (“error no hay memoria”). tal y como muestra la figura 4. } (*P) = NULL. Veamos como sería una posible implementación de las pilas bajo la estructura de la figura 4. typedef nodo **TPila. } void pop (TPila P) { 88 . TPila P) { if (P->tope == P->Lmax-1){ printf (“la pila está llena”). if (q==NULL){ printf (“error no hay memoria”). Las operaciones para una cola son análogas a las de las pilas. } q->elemento=x.TPila P) { nodo *q. free (P). (*P)=q->sig. primero en salir”. exit (1).4. y en que la terminología tradicional para colas y listas no es la misma. q=(nodo *)malloc(sizeof (nodo)).3 Colas Una 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).Tipos de Datos Abstractos nodo *q. q->sig=(*P). } TElemento tope (TPila P) { if (vacia (P)){ printf (“la pila está vacía”). (*P)=q. } void push (TElemento x. } void destruir (TPila P) { while (!vacia (P)) pop (P). } q=(*P). } return ((*P)->elemento). } 4. exit (1). Las colas se conocen también como “listas FIFO” (first-in first-out) o listas “primero en entrar. las diferencias sustanciales consisten en que las inserciones se hacen al final de la lista. free (q). exit (1). 89 . y no al principio. if (vacia (P)){ printf (“la pila está vacía”). En función de las operaciones de las listas sería: borra (primero ( C).C) } void poner_en_cola (TElemento x.C)} Implementación de las colas basada en celdas enlazadas Igual que en el caso de las pilas.10. 90 . se puede mantener un puntero al último elemento. 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 post C está inicializada Si (C = <>) entonces vacia = true sino vacia = false int frente (TCola C) pre post no vacia (C) frente = a1 {devuelve el valor del primer elemento de la cola C.a2.an.…. utilizaremos una celda cabecera con el puntero frontal apuntándola.a2. En función de las operaciones de las listas sería: inserta(x. Gráficamente. en las colas. la estructura de la cola es tal como se muestra en la figura 4.…. Como en las listas de cualquier clase.an> C =<a1.x> {inserta el elemento x al final de la cola C. fin (C).an> C =<a2. también se mantiene un puntero al frente de la lista.a2.…. Se puede escribir en función de las operaciones primitivas de las listas como: elemento (primero (C). cualquier realización de listas es lícita para las colas. Esta convención permite manejar convenientemente una cola vacía. TCola C) pre post C =<a1.Programación II 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 como TElemento.…. Al igual que para las listas.C)} void quitar_de_cola (TCola C) pre post no vacia ( C) C =<a1.an> {suprime el primer elemento de C. para aumentar la eficiencia de pone_en_cola es posible aprovechar el hecho de que las inserciones se realizan sólo en el extremo posterior. de forma que en lugar de recorrer la lista de principio a fin cada vez que se desea hacer una inserción. ese puntero es útil para ejecutar mandatos del tipo frente o quita_de_cola. No obstante. struct celda *sig. C= (TCola ) malloc (sizeof (cola)). } TElemento frente (TCola C) { if (vacia (C)){ printf (“error cola vacía”). La definición de tipo es la siguiente: typedef struct celda{ TElemento elemento. Con esta definición del tipo cola. uno al extremo anterior de la cola y otro al extremo posterior. 91 . exit (1). exit (1). } int vacia (TCola C) { return (C->ant==C->post). La primera celda es una celda cabecera cuyo campo elemento se ignora.Tipos de Datos Abstractos post Cab ant a1 an C Figura 4. if (C==NULL){ printf (“error no hay memoria”).10: cola implementada mediante celdas enlazadas Una cola es pues un puntero a una estructura compuesta por dos punteros. }cola. }celda. *post. } C->ant=C->post= (celda *) malloc (sizeof (celda)). if (C->ant==NULL){ printf (“error no hay memoria”). } C->ant->sig= NULL. typedef struct cola{ celda * ant. typedef cola * TCola. la implementación de las primitivas es la siguiente: TCola crear () { TCola C. return ( C). En la figura 4. poner_en_cola (y. } aux=C->ant. exit (1). } C->post=C->post->sig. C->post->sig=NULL. exit (1). } void quitar_de_cola (TCola C) { celda * aux.. La línea punteada indica la memoria que es liberada. C ant post (a) C = crear ( ) 92 . } void destruir (TCola C) { while (!vacia (C)) quitar_de_cola (C).11 puede verse esquemáticamente el resultado de hacer consecutivamente las siguientes operaciones: C= crear(). } El procedimiento quitar_de_cola suprime el primer elemento de la cola C deconectando la cabecera antigua de la cola. free (aux). if (C->post->sig==NULL){ printf (“error no hay memoria”). de forma que el primer elemento de la cola se convierte en la nueva cabecera. free (C) . if (vacia (C)){ printf (“error cola vacía”).C). C->ant=C->ant->sig. TCola C) { C->post->sig=(celda *) malloc (sizeof (celda)). quitar_de_cola (C). poner_en_cola (x. } return (C->ant->sig->elemento). free (C->ant).Programación II exit (1). C->post->elemento=x. } void poner_en_cola (TElemento x.C). La cola se encuentra en alguna parte de ese círculo ocupando posiciones consecutivas. requiere que la cola completa ascienda una posición en la matriz con lo que tiene un orden de eficiencia lineal proporcional al tamaño de la cola. Si no deseamos mantener este bit debemos prevenir que la cola llene alguna vez la matriz. De esta forma. Para evitarlo se puede adoptar un criterio diferente.12.C) C ant post x y NULL (c) quitar_de_cola (C) C ant post x y NULL Figura 4. que post apunte a la última posición en el sentido de las agujas del reloj).Tipos de Datos Abstractos (b) poner_en_cola (x. pero no es muy eficiente. 93 .12 y en cualquier variación menor de esta estrategia (p. Imaginemos a la matriz como un círculo en el que la primera posición sigue a la última. Para insertar un elemento en la cola se mueve el puntero post una posición en el sentido de las agujas del reloj y se escribe el elemento en esa posición.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 quita_de_cola. que suprime el primer elemento. Es cierto que con un puntero al último elemento es posible ejecutar pone_en_cola en un tiempo constante. El problema es que no hay forma de distinguir una cola vacía de una que llene el círculo completo salvo que mantengamos un bit que sea verdad si y solo si la cola está vacía.C) poner_en_cola (y. la cola se mueve en es e sentido conforme se insertan y suprimen elementos. Existe un problema que aparece en la representación de la figura 4.e. Para suprimir un elemento simplemente se mueve ant una posición en el sentido de las agujas del reloj. en la forma sugerida en la figura 4. Obsérvese que utilizando este modelo los procedimientos poner_en_cola y quitar_de_cola se pueden implementar de manera que su ejecución se realice en tiempo constante. }cola.pos. typedef cola * TCola. Para ver como se representa una cola vacía. Entonces ant y post apuntarían a la misma posición. no podemos hacer crecer la cola más allá de tamMax-1 casillas. C= (TCola) malloc (sizeof (cola)).12: realización circular para colas Las primitivas de las colas usando esta representación se describen a continuación: typedef struct { TElemento *elementos. if (C->elementos==NULL){ printf (“error no hay memoria”). Por tanto una cola vacía tiene post a una posición de ant en el sentido de las agujas del reloj.Programación II Para ver por qué puede pasar esto. ¿Qué pasa si la cola estuviera vacía?. } return (C). que es exactamente la misma posición relativa que cuando la cola tenía tamMax elementos. post apuntaría a la posición anterior en el sentido de las agujas del reloj de ant. C->ant=0. supongamos que la cola de la figura anterior tuviera tamMax-1 elementos. ant se mueve una posición en el sentido de las agujas del reloj. int Lmax. exit (1). C->post=C->Lmax-1. int ant. consideremos primero una cola de un elemento. TCola crear (int tamMax) { TCola C. exit (1). Entonces. tamMax-1 0 0 1 post ant Cola Figura 4. a menos que introduzcamos un mecanismo para distinguir si la cola está vacía o llena. 94 . Por tanto vemos que aún cuando la matriz tenga tamMax casillas. if (C==NULL){ printf (“error no hay memoria”). formando una cola vacía. } C->Lmax=tamMax+1. Si extraemos un elemento. C->elementos=(TElemento *) malloc ((tamMax+1) *sizeof (TElemento)). } TElemento frente (TCola C) { if (vacia (C)){ printf (“error cola vacía”). } C->post=(C->post+1)%(C->Lmax). TCola C) { if ((C->post+2)%(C->Lmax)==C->ant){ printf (“error cola llena”). exit (1). Estas dos situaciones por lo tanto vienen representadas tal como se muestra en la figura 4.13. } C->ant= (C->ant+1)%(C->Lmax). } En esta implementación podemos observar que se reserva una posición más que la especificada en el parámetro de la función crear. C->elementos[C->post]=x. exit (1). 95 . La razón de hacerlo es que no se podrán ocupar todos los elementos de la matriz ya que debemos distinguir la cola llena de la cola vacía. exit (1). } void poner_en_cola (TElemento x.Tipos de Datos Abstractos } int vacia (TCola C) { return ((C->post+1)%(C->Lmax)==C->ant)). } return (C->elementos[C->ant]). } void destruir (TCola C) { free (C->elementos). free (C). Como se puede observar en el caso de la cola llena la posición siguiente a post no es usada y por lo tanto es necesario crear una matriz de un tamaño n+1 para tener una capacidad de almacenar n elementos en una cola. } void quitar_de_cola (TCola C) { if (vacia (C)){ printf (“error cola vacía”). nk. representación de jerarquías. por sí mismo. un árbol se puede definir de manera recursiva como sigue: 1. un árbol. El lector seguramente conoce casos como los árboles gramaticales para analizar oraciones. Un árbol sin etiquetas tiene sentido aunque en la inmensa mayoría de los problemas necesitaremos etiquetar los nodos. respectivamente.nk. o sea.n2.1 Introducción y term inología básica Hasta ahora las estructuras de datos que hemos estudiado eran de tipo lineal.13: cola llena y vacía en matrices circulares 4.….nk reciben el nombre de hijos del nodo n.…. Supóngase que n es un nodo y que A1. una cadena de caracteres o un círculo con un número en su interior.A2. uno de los cuales se distingue como raíz. …. 96 .n2. En dicho árbol. Ese nodo es también la raíz de dicho árbol. Ak son árboles con raíces n1. Se puede construir un nuevo árbol haciendo que n se constituya en el padre de los nodos n1. un árbol es una colección de elementos llamados nodos. Usando esta notación. junto con una relación de paternidad que impone una estructura jerárquica sobre los nodos.…. Los árboles tienen nodos. salvo los casos de primero y último). En esta sección vamos a considerar una estructuración de los datos más compleja: los árboles.A2.Ak son los subárboles de la raíz. Los nodos n1. Un solo nodo es.…. etc … La estructura en árbol de los elementos es fundamental dentro del campo de la informática aplicándose en una amplia variedad de problemas como veremos más adelante. Los árboles tienen una etiqueta en cada nodo algunos autores distinguen entre árboles con y sin etiquetas. Formalmente.5. los árboles para analizar circuitos eléctricos. Este tipo de estructura es usual incluso fuera del campo de la informática. Para tratar esta estructura cambiaremos la notación: • • Las listas tienen posiciones.5 El TDA Árbol 4. los árboles genealógicos.Programación II post post ant ant Cola Llena Cola vacía Figura 4. 2. existía una relación de anterior y siguiente entre los elementos que la componían (cada elemento tendrá uno anterior y otro posterior. A menudo se representa un nodo por medio de una letra.n2. n es la raíz y A1. Las listas tienen un elemento en cada posición. s1. s2. s2. Ejemplos sobre la figura 4.1 s2. En la figura 4.1.2.2.2 son s2.2 s2.n2.2 s2.1.3.14: • • C.s2. 97 . se llama un ancestro propio o descendiente propio respectivamente.2.Tipos de Datos Abstractos A veces conviene incluir entre los árboles un árbol especial el árbol nulo.2. La longitud de un camino es el número de nodos menos uno. s1. s2.C2.1. entonces a es un ancestro de b y b es un descendiente de a.2. s1.3 Figura 4. C2.3 es un camino de C a s2. Los árboles normalmente se presentan en forma descendente y se interpretan de la siguiente forma: C es la raíz del árbol.4. Existe un camino de longitud cero de cada nodo a sí mismo.2 componen un subárbol de la raíz.1 s2. s1. c) Ancestros y descendientes: si existe un camino. hoja y subárbol: • En un árbol.2.2.14 C C1 C2 C3 s1.2. s2.2 s2. (cualquier nodo es a la vez ancestro y descendiente de sí mismo).C.2.1 son las hojas del árbol. que hay en el mismo. C1.s4.1 s1. En el ejemplo anterior los ancestros de s2.1 s2. etc.…. entonces esta sucesión se llama un camino del nodo n1 al nodo nk.14: un índice general y su representación como árbol Podemos observar que cada uno de los identificadores representa un nodo y la relación padre-hijo se señala con una línea. Podemos definir en términos de ancestros y descendientes los conceptos de raíz. C1.2. del nodo a al nodo b. un árbol sin nodos que se representa mediante Λ. Ejemplo: consideremos el ejemplo de la figura 4. la raíz es el único nodo que no tiene ancestros propios.2.s2. C3 son los hijos de C C1. s2. distinto de sí mismo.3. 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 de hijos que tiene.2.2.3 s4.1.2.C2 no es un camino de C1 a C2 ya que C1 no es padre de C.2.3 ya que C es padre de s2. Así el grado de un nodo hoja es cero. b) Caminos: si n1. éste es padre de s2.nk es una sucesión de nodos en un árbol tal que ni es el padre de ni+1 para 1≤ i ≤ k-1.1. Un ancestro o descendiente de un nodo. s2.14 el nodo con etiqueta C tiene grado 3. C2 y C y sus descendientes s2.2. s2. f) Niveles: dado un árbol de altura h se definen los niveles 0…h de manera que el nivel i está compuesto por todos los nodos de profundidad i. La ordenación izquierda-derecha de hermanos puede ser extendida para comparar cualesquiera dos nodos que no están relacionados por la relación ancestro-descendiente.14 el nodo s2.14 la profundidad de C2 es 1. junto con todos sus descendientes.15. s1.2.Ak. n … A1 A2 Ak Figura 4. No obstante. Si A contiene un solo nodo.15: árbol A 98 . Ejemplo: en la figura 4.2. Algunos autores prescinden de las definiciones de ancestro propio y descendiente propio asumiendo que un nodo no es ancestro ni descendiente de sí mismo.3. Si deseamos explícitamente ignorar el orden de los hijos. s2. Recorridos de un árbol En una estructura lineal resulta trivial establecer un criterio de movimiento por la misma para acceder a los elementos. entonces ese nodo constituye el listado de los nodos de A en los recorridos preorden. inorden y postorden. C3. s1.2. pero en un árbol esa tarea no resulta tan simple. Ejemplo: en la figura 4.2. s2. entonces todos los descendientes de n1 están a la izquierda de todos los descendientes de n2. Un subárbol de un árbol es un nodo. Si ninguno de los anteriores es el caso.14 la altura de C2 es 2 y la del árbol es 3. Como se representa en la figura 4.A2. e) Profundidad: la profundidad de un nodo es la longitud del único camino de la raíz a ese nodo.1. Los tres recorridos más importantes se denominan preorden. Estos ordenamientos se definen recursivamente como sigue: • • Si un árbol A es nulo. g) Orden de los nodos: los hijos de un nodo usualmente están ordenados de izquierda a derecha.1 está a la derecha de los nodos C1.1 y a la izquierda de los nodos s2. nos referiremos a un árbol como un árbol no-ordenado. inorden y postorden.2. inorden y postorden.…. s2. entonces la lista vacía es el listado de los nodos de A en los recorridos preorden. s4.3. La altura de un árbol es la altura de la raíz.1.Programación II • • Una hoja es un nodo sin descendientes propios. La regla a usar es que si n1 y n2 son hermanos y n1 está a la izquierda de n2. sea A un árbol con raíz n y subárboles A1. Ejemplo: en la figura 4. existen distintos métodos útiles en que podemos sistemáticamente recorrer todos los nodos de un árbol. d) Altura: la altura de un nodo es un árbol es la longitud del mayor de los caminos del nodo a cada hoja. Listado inorden: 2.7.3.6.Tipos de Datos Abstractos 1. En el caso del recorrido postorden.3.4.10. Para el recorrido inorden.8.17 para la figura 4.1.9.9.8. Listado preorden: 1. conforme se sube hacia su padre. y avanzando en el sentido contrario de las agujas del reloj. Listado postorden: 2. El listado en inorden de los nodos de A está formado por los nodos de A1 listados en inorden.16. Para el recorrido en preorden se lista un nodo la primera vez que se pasa por él. A manera de ejemplo.15. Los resultados de los listados en preorden.8.7. Ak listados en inorden. la segunda vez que se pasa por él.9. se lista un nodo la última vez que se pasa por él. se lista una hoja la primera vez que se pasa por ella. 3.2.5. El listado en preorden de los nodos de A está formado por el nodo raíz de A.…. Obsérvese que el orden de las hojas en los tres recorridos corresponde al mismo ordenamiento de izquierda a derecha de las hojas.4. partiendo de la raíz. seguido de los nodos de A1 listados en preorden. en la figura 4.4 Un truco útil para producir los tres recorridos es el siguiente: si caminamos por la periferia del árbol. como está representado en la figura 4.16: ejemplo de árbol 99 .7 2. luego por los nodos de A2 en postorden y así sucesivamente hasta los nodos de Ak listados en postorden y por último la raíz n.17 se pasa por el nodo 1 al empezar. Sólo el orden de los nodos interiores y su relación con las hojas varía entre los tres ordenamientos. El listado en postorden de los nodos de A está formado por los nodos de A1 listados en postorden.10.6. luego por los de A2 en preorden y así sucesivamente hasta los nodos de Ak listados en preorden. 2. y un nodo interior.6. y otra vez al pasar por la “bahía” entre los nodos 2 y 4.5. seguidos de n (nodo raíz) y luego por los nodos de los subárboles A2.10. 1 2 3 4 5 6 7 8 9 10 Figura 4. Como ejemplo de listados veamos el resultado que se obtendría sobre el árbol de la figura 4.1 3. postorden e inorden son los siguientes: 1.5.3. Por ejemplo. 2.17: recorrido del árbol de la figura 4. recuperarse con uno solo de sus recorridos. Las posiciones en postorden de los nodos tienen la útil propiedad de que los nodos de un subárbol con raíz n ocupan posiciones consecutivas de ord_post (n) – desc (n) a ord_post(n). árboles que contienen las derivaciones de una gramática necesarias para obtener una determinada frase de un lenguaje. Será a partir de los recorridos preorden y postorden cuando se pueda determinar unívocamente. Podemos etiquetar los nodos de un árbol con operandos y operadores de manera que un árbol represente una expresión. es decir. También a través de estos recorridos podemos determinar los antecesores de un nodo. 4.18: árbol de expresión 100 . Cada hoja está etiquetada con un operando y sólo consta de ese operando.2 Una aplicación: árb oles de expresión Una importante aplicación de los árboles en la informática es la representación de árboles sintácticos. en general. 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. y que desc (n) es el número de descendientes propios del nodo n. Cada nodo interior está etiquetado con un operador.18 se representa un árbol con la expresión aritmética (x+z)*(x-y). en la figura 4. * + - x z x y Figura 4.5. Para determinar si un nodo x es descendiente de un nodo y. es interesante conocer que un árbol no puede.16 Finalmente. Las reglas para que un árbol represente a una expresión son: 1.Programación II 1 2 3 4 5 6 7 8 9 10 Figura 4. Supóngase que ord_post (n) es la posición del nodo n en el listado en postorden de los nodos de un árbol. Tipos de Datos Abstractos 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 n representa la expresión (E1) θ (E2), (los paréntesis pueden eliminarse si no son necesarios). En la figura 4.18 el hijo izquierdo del nodo raíz está etiquetado con el operador + y sus hijos izquierdo y derecho representan las expresiones x e y, respectivamente. Por tanto, este nodo representa la expresión (x) + (y), o más simple, x+y. De la misma manera la expresión representada por el hijo derecho de la raíz en el árbol de la figura 4.18 será: x-y, así la expresión representada por el nodo raíz será: (x+z) * (x-y). En general, cuando se recorra un árbol en preorden, inorden o postorden, se preferirá listar las etiquetas de los nodos, en lugar de sus nombres. En los árboles de expresión, el listado en preorden de las etiquetas nos da lo que se conoce como la forma prefijo de una expresión, en la que el 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 revisar la expresión prefija θ P1 P2 e identificar unívocamente a P1 como el prefijo más corto (y único) de P1P2, 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 puede verse el prefijo válido más corto de esta expresión es +xy que corresponde al hijo izquierdo del nodo raíz. De manera similar, el listado en postorden de las etiquetas de un árbol de expresión da lo que se 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 y Z2 son las representaciones postfijo de E1 y E2, respectivamente. De nuevo, los paréntesis son innecesarios, porque se puede identificar a Z2 buscando el sufijo más corto de Z1Z2 que sea una expresión postfijo válida. Por ejemplo, la expresión postfija correspondiente 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 Árbol La 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 al tipo Árbol como TArbol, al tipo nodo como TNodo y al tipo de las etiquetas TEtiqueta. Además, para notar el árbol vacío usaremos un valor especial ARBOL_VACIO, al igual que en las listas existe el concepto de lista vacía. De igual forma, es necesario expresar en algunos casos que un nodo no existe para lo cual también usaremos un valor especial NODO_NULO. Un ejemplo de su uso puede ser cuando intentemos extraer el nodo hijo a la izquierda de un nodo hoja. 101 Programación II 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 modelo abstracto, en su lugar utilizaremos el lenguaje natural pues para este caso no hay lugar a ambigüedad. Al igual que con otros TDA, existe una gran variedad de operaciones que pueden llevarse a cabo 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 de asignar 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, devuelve NODO_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, y devuelve NODO_NULO si n no tiene hijo a la izquierda. Como precondición n no es NODO_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 derecha de n en la ordenación de los hijos de p. Devuelve NODO_NULO si n no tiene hermano a la derecha. Como precondición n no es NODO_NULO. En la figura 4.19, hermano_drcha (n4) = n5} n1 n2 n4 n5 n6 n3 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) 102 Tipos de Datos Abstractos {Asigna una nueva etiqueta e al nodo n en el árbol T. Como precondición n no es NODO_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. Como precondició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. Como precondició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 de estos 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 de estos 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 las primitivas 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 toman un á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 un valor de tipo TEtiqueta que se encarga de imprimir en la salida estándar. 103 Visitar la raíz. void inordenAr (TNodo n. La idea básica subyacente al algoritmo es que cuando estamos en un nodo p. extrayendo los nodos de la pila hasta que se encuentra un nodo en el camino con un hermano a la derecha. Escribir (etiqueta (n. 3. 104 . hasta que encuentre una hoja. destruir (P). } }while (!vacia (P)). } else if (!vacia (P)){ m=herdrcha (tope(P). } • Inorden: los pasos a seguir son los siguientes: 1. m = hizqda (m. Push (m. pop (P). do { if (m!=NODO_NULO)){ Escribir (etiqueta (n. En el primer modo desciende por el camino más a la izquierda en el árbol. El programa comienza en modo uno en la raíz y termina cuando la pila está vacía. con la raíz en el fondo de la pila y el nodo p en la cabeza.T)). escribiendo y apilando los nodos a lo largo del camino. Recorrer el subárbol del siguiente hijo a la derecha en inorden.T)). El tipo TPila es realmente pila de nodos. /*Función de creación del TDA Pila*/ m=raiz (T). es decir. c=hizqda (n. 2. if (c!=NODO_NULO){ inordenAr (c. Vamos a escribir un procedimiento recursivo para listar las etiquetas de sus nodos en inorden. usaremos una pila para encontrar el camino alrededor del árbol.Programación II Para el procedimiento no recursivo . void preordenAr (TArbol T) { TPila P. Recorrer el subárbol más a la izquierda en inorden.TArbol T) { TNodo c. pila de posiciones de nodos.T). A continuación el programa entra en el segundo modo de operación en el cual vuelve hacia atrás por el camino apilado en la pila. Entonces el programa vuelve al primer modo de operación.T). /*Pila de posiciones: TElemento de la pila es el tipo nodo*/ TNodo m.T).P). P = crear ().T). la pila alojará el camino desde la raíz a p. El programa tiene dos modos de operar. comenzando el descenso desde el inexplorado hermano de la derecha. Observemos que bajo esta implementación cada nodo de un árbol contiene 3 punteros: padre que apunta al padre. typedef struct tipo_celda{ TEtiqueta etiqueta.T). typedef tipo_celda * TNodo. Visitar la raíz.c!=NODO_NULO.T)) inordenAr (c. typedef TNodo TArbol.T)). hizqda que apunta al hijo izquierdo y herdrcha que apunta al hermano a la derecha del nodo.T). El procedimiento recursivo para listar las etiquetas de sus nodos en postorden es el siguiente: void postordenAr (TNodo n. for (c=hizqda (n. Recorrer el subárbol de la derecha en postorden.T)) postordenAr (c.c=herdrcha (c. } else Escribir (etiqueta (n. } Implementación de árboles basada en celdas enlazadas Al igual que ocurre en los TDA estudiados (Listas. 2. struct tipo_celda * herdrcha.T)). un nodo puede ser declarado de forma que la estructura del árbol pueda ir en aumento mediante la obtención de memoria deforma dinámica. Recorrer el subárbol más a la izquierda en postorden. } tipo_celda. struct tipo_celda * hizqda. haciendo una petición de memoria adicional cada vez que se quiere crear un nuevo nodo. } • Postorden: los pasos a seguir son: 1. struct tipo_celda * padre.T). Escribir (etiqueta (n.T). TArbol T) { TNodo c. La implementación de las operaciones es como sigue: 105 . Pilas o Colas). 3.Tipos de Datos Abstractos for (c=herdrcha (c.c=herdrcha (c. #define ARBOL_VACIO NULL #define NODO_NULO NULL typedef int TEtiqueta.c!=NODO_NULO. raiz ->herdrcha = NULL. TArbol T) { return (n->etiqueta). raiz->hizqda = NULL. } TEtiqueta etiqueta (TNodo n.Programación II TNodo padre (TNodo n. raiz->etiqueta = et. exit (1). TArbol T) { return (n-> herdrcha).TArbol T) { n->etiqueta = e. return (raiz). destruir (T->herdrcha). } TNodo herdrcha (TNodo n. } 106 . } raiz ->padre = NULL. raiz = (TArbol) malloc (sizeof (tipo_celda)). if (!raiz){ printf (" error no hay memoria"). TArbol T) { return (n->padre). } TNodo hizqda (TNodo n. } TArbol creaRaiz (TEtiqueta et) { TArbol raiz. } void reEtiqueta (TEtiqueta e. } TNodo raiz (TArbol T) { return (T). } void destruir (TArbol T) { if (T) { destruir (T->hizqda). TArbol T) { return (n->hizqda). free (T).TNodo n. } 107 . } void insertar_hermano_drcha (TNodo n.TArbol Ti. } return (Taux). Td->padre=n.TArbol T) { if (n==raiz (T)){ printf (“error no se puede insertar hermano a la derecha en la raíz”). n->hizqda=Ti. n->herdrcha=Td. if (Taux!=ARBOL_VACIO){ n->herdrcha = Taux->herdrcha. } TArbol podar_hermano_drcha (TNodo n.TArbol Td. Taux = n->herdrcha. Taux->herdrcha = NODO_NULO. } Td->herdrcha=n->herdrcha. if (Taux!=ARBOL_VACIO){ n->hizqda = Taux->herdrcha.TArbol T) { TArbol Taux. } return (Taux). Taux->herdrcha = NODO_NULO. Taux->padre = NODO_NULO.Tipos de Datos Abstractos } void insertar_hijo_izqda (TNodo n. } TArbol podar_hijo_izqda (TNodo n. Ti->padre=n.TArbol T) { Ti->herdrcha=n->hizqda. exit (1). Taux = n->hizqda.TArbol T) { TArbol Taux. Taux->padre = NODO_NULO. Los hijos suelen denominarse hijo a la izquierda e hijo a la derecha.Programación II 4. Si n es la raíz. TArbolB Ti. Por ejemplo los árboles binarios (a) y (b) de la figura 4.TArbolB T) {Devuelve el padre del nodo n en el árbol T.4 El TDA Árbol Binar io Un árbol binario puede definirse como un árbol que en cada nodo puede tener como mucho grado 2. estableciéndose de esta forma un orden en el posicionamiento de los mismos. es decir. y el árbol (c) es el correspondiente árbol general. En los árboles binarios hay que tener en cuenta el orden izqda-drcha de los hijos. Las operaciones primitivas para el TDA árbol binario son las siguientes: TArbolB crearB (Tetiqueta e. Como precondición n no es NODO_NULO} 108 . puesto que difieren en el nodo 5. que no tiene padre. por convenio se supone igual al (b) y no al (a). y como subárbol a la izquierda Ti y como subá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 debe asignar un nuevo valor con la operación de creación} TNodoB padreB (TNodoB n. aunque los árboles generales no son directamente comparables a los árboles binarios.TArbolB Td) {Devuelve un árbol cuya raíz contiene la etiqueta e. devuelve NODO_NULO .20 son diferentes.5. a lo más 2 hijos.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. 1 1 1 2 2 2 3 4 3 4 3 4 5 (a) 5 (b) (c) 5 Figura 4. Todas las definiciones básicas que se dieron para árboles generales permanecen inalteradas sin más que hacer las particularizaciones correspondientes. Como precondición n no es NODO_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 T) {Devuelve la etiqueta del nodo n en el árbol T.TNodoB n. el cual se ve privado de estos nodos. TArbolB Td.TArbolB T) {Devuelve el subárbol con raíz hijo a la izquierda de n del árbol T.TArbolB T) {Devuelve el hijo a la derecha del nodo n en el árbol T y devuelve NODO_NULO si n no tiene hijo a la derecha.TArbolB T) {Inserta el árbol Ti como hijo a la izquierda del nodo n que pertenece al árbol T. En el caso de que existiese ya el hijo a la izquierda. Una vez especificadas las operaciones del TDA árbol binario la implementación de los recorridos sería la siguiente: 109 . Como precondición n no es NODO_NULO} TEtiqueta etiquetaB (TNodoB n. En el caso de que existiese ya el hijo a la derecha. TArbolB Ti.TArbolB T) {Devuelve el hijo a la izquierda del nodo n en el árbol T. la primitiva se encarga de que sea destruido junto con sus descendientes.TArbolB T) {Asigna una nueva etiqueta e al nodo n en el árbol T.TArbolB T) {Inserta el árbol Td como hijo a la derecha del nodo n que pertenece al árbol T.Tipos de Datos Abstractos TNodoB hizqdaB (TNodoB n. Como precondición n no es NODO_NULO} TArbolB podar_herdrcha (TNodoB n. Como precondición n no es NODO_NULO} TNodoB hdrchaB (TNodoB n. Como precondición n no es NODO_NULO} Como podemos observar estas primitivas son las mismas que para los árboles generales pero adaptadas a las características específicas de los árboles binarios. Como precondiciones n no es NODO_NULO y Ti no es BINARIO_VACIO} void insertar_hidrchaB (TNodoB n. el cual se ve privado de estos nodos.TArbolB T) {Devuelve el subárbol con raíz hermano a la derecha de n del árbol T. y devuelve NODO_NULO si n no tiene hijo a la izquierda. Como precondiciones n no es NODO_NULO y Td no es BINARIO_VACIO} TArbolB podar_hizqdaB (TNodoB n. Como precondición n no es NODO_NULO} void reEtiquetaB (TEtiqueta e. la primitiva se encarga de que sea destruido junto con sus descendientes. T).T)). typedef TNodoB TArbolB. } } Implementación del TDA Árbol Binario Vamos a realizar una implementación mediante punteros.T). postordenArB(hdrchaB (n. 110 . Escribir (etiquetaB(n. La declaración de tipos es como sigue: #define BINARIO_VACIO NULL #define NODO_NULO NULL typedef int TEtiqueta.T).T). } } void postordenArB (TNodoB n. struct tipo_celdaB * hdrcha. } tipo_celdaB. TArbolB T) { if (n!= NODO_NULO){ Escribir (etiquetaB(n.T)).T).T). typedef tipo_celda * TNodoB. Una posible implementación para las primitivas de árboles binarios es la siguiente: TArbolB CrearB (Tetiqueta et.T). } } void inordenArB (TNodoB n.T)). inordenArB(hdrchaB (n.T). preordenArB (hizqdaB (n.T). TArbolB T) { if (n!= NODO_NULO){ postordenArB (hizqdaB (n. typedef struct tipo_celdaB{ TEtiqueta etiqueta. Escribir (etiquetaB(n. struct tipo_celdaB * padre.Programación II void preordenArB (TNodoB n. struct tipo_celdaB * hizqda.T).T). TArbolB Ti. preordenArB(hdrchaB (n. TArbolB T) { if (n!= NODO_NULO){ inordenArB(hizqdaB (n.TArbolB Td) { TArbolB raiz.T). TArbolB T) { n->etiqueta = e. raiz->hizqda=Ti. if (Td!=NULL) Td->padre=raiz. TArbolB T) { return (n->etiqueta). destruirB(T->hdrcha). } raiz->padre=NULL. if (!raiz){ printf (“error no hay memoria”). TArbolB T) { return (n->hizqda). } TNodoB raizB (TArbolB T) { 111 . if (Ti!=NULL) Ti->padre=raiz. } } TNodoB padre (TNodoB n. return (raiz). exit (1). TArbolB T) { return (n->padre). } void reEtiquetaB (Tetiqueta e.TNodoB n. raiz->hdrcha=Td. } TNodoB hdrcha (TNodoB n. raiz->etiqueta=et. free (T). } TNodoB hizqdaB (TNodoB n.Tipos de Datos Abstractos raiz = (TArbolB) malloc (sizeof (tipo_celdaB)). } TEtiqueta etiquetaB (TNodoB n. TArbolB T) { return (n-> hdrcha). } void destruirB (TArbolB T) { if (T){ destruirB(T->hizqda). Data Structures. n->hdrcha = BINARIO_VACIO. and Program Style Using C. } TArbolB podar_hdrchaB (TNodoB n.Programación II return (T).2ª Edición. Algorithms.E. } void insertar_hizqdaB (TNodoB n. TArbolB T) { destruirB (n->hdrcha). } void insertar_hdrchaB (TNodoB n. if (Taux) Taux ->padre = BINARIO_VACIO. Addison_Wesley (1988). n->hizqda = BINARIO_VACIO. Garrett. } 4.6 Bibliografía • • • [AHU88] A. TArbolB T) { destruirB (n->hizqda). Korsh. 112 . TArbolB Th. L. [BOW97] C. n->hizqda = Th.F. Th -> padre = n. Taux=n->hdrcha. Bowman. TArbolB Th. Oxford University Press (1997). Ullman. return (Taux). Algorithms and data structures: An approach in C. Taux=n->hizqda. [KG88] J.TArbolB T) { TArbolB Taux. if (Taux) Taux ->padre = BINARIO_VACIO. J:D. return (Taux). J.J. PWSKENT Publishing Company (1988).TArbolB T) { TArbolB Taux. } TArbolB podar_hizqdaB (TNodoB n. Th -> padre = n. Estructuras de datos y algoritmos. n->hdrcha = Th.V.F. Aho. Hopcroft. Tenenbaum. Addison-Wesley (1. [MEH84] K. W. Prentice Hall International (1. Van Wick. Pretince Hall (1986). Garrido. Ford. [WIR86] N. [FT96] W. A. Algorithms an Data Structures.997). A. 1ª Edición (1998). Data structures and algorithms. Un enfoque práctico. [VAN90] J.Tipos de Datos Abstractos • • • • • • [FGG98] J. M. Mehlhorn. 1-3. 113 . Springer Verlag (1984). Estructuras de datos. 2ª Edición. Topp. M. Data structures and C programs. [LAT97] Y. Augenstein.990). Prentice Hall (1.996). Estructuras de datos con C y C++. Data structures with C++. Langsom. Vols.C. García. Wirth. Fdez-Valdivia. . Se emplean varios criterios para evaluar el tiempo de ejecución de un algoritmo de clasificación interna. Para clasificar suponemos que vamos a trabajar con un registro. La idea básica de este es imaginar que los registros a ordenar están almacenados en un vector vertical. los cuales se pueden utilizar solo con clases especiales de datos.A[j-1]). ni que los registros con claves iguales aparezcan en un orden particular. No es necesario que todos los registros tengan valores distintos. El problema de la clasificación consiste en ordenar una secuencia de registros de tal forma que los valores de sus claves formen una secuencia creciente o decreciente. . y al hacer esto si hay dos elementos adyacentes que no están en orden se invierten. en el peor de los casos tiene un tiempo de O(n ).1 Algoritmos simples de clasificación Uno de los algoritmos de clasificación más simples puede ser el de la burbuja (bubblesort). i<n-1. sin embargo. j--) if (A[j]. Figura 5. ya que la externa se sale de los objetivos de nuestro curso. requiriendo un tiempo de O(n) en el peor de los casos. i++) for (j=n-1. sube al primer valor del vector.1. y así sucesivamente. que tiene un tiempo promedio de O(n lg n). pasa a la segunda posición del vector.Capítulo 5: Algoritmos ordenación y búsqueda. y también la más común. y por ultimo su el tamaño del registro es muy grande. aunque su comportamiento en el caso promedio es peor que el del quicksort. La clasificación externa se utiliza cuando el número de objetos a ordenar es demasiado grande y no cabe en la memoria principal de ordenador. En el segundo recorrido el valor de la clave menor siguiente. Por ejemplo el que se muestra en la figura 5. Si el vector A es un tipo_registro A[n].clave) intercambia(A[j]. El quicksort funciona muy bien para la mayor parte de las 2 aplicaciones. tendríamos el siguiente algoritmo: for (i=0. Algoritmo de la burbuja (Bubblesort). Uno de los algoritmos de clasificación más populares es la clasificación rápida (quicksort). como ≤ para números. Existen otros algoritmos. La primera medición. Se recorre varia veces el vector de abajo hacia arriba. 2 5. 5.1. en este libro vamos a ver con gran detalle la interna. como la clasificación por montículos (heapsort) y la clasificación por intercalación (mergesort) que lleva un tiempo O (n lg n) en el peor de sus casos.1 Introducción Los algoritmos más simples de ordenación requieren un tiempo O(n ) para clasificar n objetos. La clasificación interna es la que se realiza en la memoria principal del ordenador. también puede ser conveniente contar las veces que debe moverse.clave<A[j-1]. se realiza con tanta frecuencia que merece la pena pararse a estudiarlo. El efecto producido por esta operación es que en el primer recorrido es que el registro con valor clave menor. avanzados de El proceso de clasificación u ordenación de una lista de objetos de acuerdo con algún orden lineal. n es el número de registros.3. El mergesort se puede utilizar en la clasificación externa. Otra medición frecuente es el número de comparaciones entre claves que debe efectuarse para clasificar n registros. j>=i+1. y el campo clave contiene el valor de la clave de cada registro. La clasificación la podemos dividir en dos tipos: interna y externa. el cual tiene un campo llamado clave que será el que vamos a utilizar para ordenarlos. es el número de pasos requeridos por el algoritmo para clasificar n registros. aprovechando la capacidad del acceso aleatorio en sus distintas formas. Existen otros algoritmos de clasificación llamados por urnas o por cubetas. typedef struct { tipo_clave clave. registro y) { /*intercambia cambia los valores de x e y*/ registro temp. x = y.1. se muestra los pasos dados por el algoritmo para ordenar los nombres. Ejemplo. y = temp. char apellidos [80]. Para este ejemplo podemos utilizar la siguiente definición de tipos: typedef int tipo_clave. typedef registro* tipo_registro. En la tabla siguiente se muestra una tabla de escritores con el nombre y apellido. si la relación ≤ en objetos con este tipo de claves es el orden lexicográfico habitual. 116 . El algoritmo de clasificación de la burbuja aplicado sobre la lista de escritores.2. Los nombres subrayados indican el punto sobre el cual se sabe que son los primeros en orden alfabético y ocupan el lugar correcto.Programación II El procedimiento de intercambio se utiliza en varios algoritmos de clasificación y se define de la siguiente forma: void intercambia (registro x. El procedimiento intercambia.3. Figura 5. temp =x. Definición de tipos. En la siguiente tabla. } registro. } /*intercambia*/ Figura 5. Nombre Federico Arturo Isabel Pablo Dominique Camilo José Apellidos García Lorca Pérez Reverte Allende Neruda Lapierre Cela Tabla 5. Escritores. los ordena alfabéticamente. Algoritmos avanzados de ordenación y búsqueda. Federico Arturo Isabel Pablo Dominique Camilo José Inicial i=1 Federico Arturo Isabel Pablo Camilo José Dominique para i=1y j=6 Federico Arturo Isabel Camilo José Pablo Dominique para i=1y j=5 Federico Arturo Camilo José Isabel Pablo Dominique para i=1y j=4 Federico Arturo Camilo José Isabel Pablo Dominique para i=1y j=3 Arturo Federico Camilo José Isabel Pablo Dominique para i=1y j=2 Arturo Federico Camilo José Isabel Pablo Dominique Inicial i=2 Arturo Federico Camilo José Isabel Dominique Pablo para i=2 y j=6 Arturo Federico Camilo José Dominique Isabel Pablo para i=2 y j=5 Arturo Federico Camilo José Dominique Isabel Pablo para i=2 y j=4 Arturo Camilo José Federico Dominique Isabel Pablo para i=2 y j=3 Arturo Camilo José Federico Dominique Isabel Pablo Inicial i=3 Arturo Camilo José Federico Dominique Isabel Pablo para i=3 y j=6 Arturo Camilo José Federico Dominique Isabel Pablo para i=3 y j=5 Arturo Camilo José Dominique Federico Isabel Pablo para i=3 y j=4 Arturo Camilo José Dominique Federico Isabel Pablo Inicial i=4 Arturo Camilo José Dominique Federico Isabel Pablo para i=4 y j=6 Arturo Camilo José Dominique Federico Isabel Pablo para i=4 y j=5 117 . cuya clave tiene un valor menor que el de cualquier clave existente entre A[1].2 Clasificación por in serción En este método de clasificación se llama así.5. se hacen dos recorridos más. … . En el segundo recorrido sube a Camilo José hasta la posición dos. se encuentran clasificación los registros colocados en A[1]. i++){ j = i.. A[j-1]). Clasificación por inserción en C.Programación II Arturo Camilo José Dominique Federico Isabel Pablo Inicial i=5 Arturo Camilo José Dominique Federico Isabel Pablo para i=5 y j=6 Como podemos ver el primer recorrido nos intercambia Dominique y Camilo José. } } Figura 5.A[i-1].4. 118 . En el tercer recorrido Dominique sobrepasa a Federico y la lista quedaría ordenada. while (A[j]. que es el nombre más pequeño y lo sube hasta arriba. A[2]..clave = -∞. es útil introducir un elemento A[0]. n mover A[i] hacia la posición j ≤ i tal que A[i] < A[k] para j ≤ k < i. Esto se resume en: Repite para i=2. A[i]. for (i=2. j--. Clasificación por inserción en algorítmico. subiéndolo hasta que se encuentra con Arturo.1. Para facilitar el proceso de mover A[i].clave){ intercambia(A[j]. porque en el i-ésimo recorrido se inserta el iésimo elemento A[i] en el lugar correcto. A[n]. …. y A[i] ≥ A[j-1] o j = 1 Fin-repite Figura 5. Después de hacer esta inserción. entre A[1]. i<=n. 5. pero sin embargo por la definición del algoritmo. Se crea una constante -∞ de tipo tipo_clave que es menor que la clave de cualquier registro que pueda aparecer en la práctica. los cuales fueron ordenados previamente. El programa completo seria el siguiente: A[0].clave < A[j-1]. 3. después de i pasadas. en el orden clasificado.Algoritmos avanzados de ordenación y búsqueda. j++) /*compara cada clave con la actual clave_menor*/ if (A[j]. …. Como resultado.1.n-2 Seleccionar el más pequeño entre A[i]. Esta clasificación la podemos describir de la siguiente forma: Repite para i=0.3 Clasificación por s elección La idea es que en el i-ésimo recorrido se selecciona el registro con la clave más pequeña.. …. los i registros menores ocupan A[0]. ….clave.clave. A[n-1] e intercambiarlo con A[i] */ indice_menor= i. } 119 . …. j<n. for (j=i. entre A[i]. …. A[n-1] e Intercambiarlo con A[i]. A[n]*/ int indice_menor. los cuales se insertarán después. 6. y se intercambia con A[i]. Ejemplo.2.clave < clave_menor) { clave_menor = A[j]. Después de cada recorrido. /*la posición de clave_menor*/ for (i=0. /*la clave menor encontrada actualmente en un recorrido a través de A[i]. Utilizando la tabla del ejemplo anterior mostramos el resultado de los recorridos sucesivos de la clasificación por inserción para i=2. -∞ Federico Arturo Isabel Pablo Dominique Camilo José Inicial -∞ Arturo Federico Isabel Pablo Dominique Camilo José Después de i=2 -∞ Arturo Federico Isabel Pablo Dominique Camilo José Después de i=3 -∞ Arturo Federico Isabel Pablo Dominique Camilo José Después de i=4 -∞ Arturo Dominique Federico Isabel Pablo Camilo José Después de i=5 -∞ Arturo Camilo José Dominique Federico Isabel Pablo Después de i=6 Tabla 5. aunque su orden no tenga relación con los registros encontrados bajo la palabra subrayada. 5. Recorridos de la clasificación por inserción. clave_menor= A[i]. …. indice_menor = j. El programa que realizaría esto sería: tipo_clave clave_menor. A[n-1]. está garantizado que los elementos por arriba de la línea estarán ordenados entre sí. i<n-1. A[i]. i++) { /*elegir el menor entre A[i]. Federico Arturo Isabel Pablo Dominique Camilo José Inicial Arturo Federico Isabel Pablo Dominique Camilo José Después de i=1 Arturo Camilo José Isabel Pablo Dominique Federico Después de i=2 Arturo Camilo José Dominique Pablo Isabel Federico Después de i=3 Arturo Camilo José Dominique Federico Isabel Pablo Después de i=4 Arturo Camilo José Dominique Federico Isabel Pablo Después de i=5 Tabla 5. y considerando que intercambia lleva un tiempo constante. A[indice_menor]).6. es fácil observar que la clasificación por selección es superior a las clasificaciones de la burbuja y por inserción. sin importar qué tipo_registro sea. la posición de Arturo. Entonces se pueden intercambiar los punteros en lugar de registros. ya que es el elemento que se sabe que no está entre los n-2 más pequeños.1. que se intercambia con Federico en A[0].4 Complejidad de tie mpos de los algoritmos y cuenta de intercambios Las clasificaciones de burbuja. está en su lugar correcto. Ejemplo. en el caso de que intercambia sea una operación costosa. Después de n-2 recorridos. 2 120 . vamos a ordenarlos utilizando este algoritmo. el registro A[n-1]. una estrategia muy útil es mantener un vector de punteros a registros por medio de cualquier algoritmo. Así 2 mientras que los tres algoritmos llevan un tiempo proporcional a n . Clasificación por selección. se pueden comparar con más detalle al contar las veces que se usa intercambia. 5. como la comparación de claves o los cálculos en los índices del vector. el procedimiento intercambia.Programación II intercambia ( A[i]. En el primer recorrido el valor de indice_menor es 1. Tomando la tabla de autores de los ejemplos anteriores. } Figura 5. llevará más tiempo que cualquier otro paso. Pablo en nuestra tabla. la clasificación por selección permite a los elementos saltar sobre grandes cantidades de elementos sin necesidad de intercambiarlos entre sí individualmente. Recorridos de la clasificación por selección. Toda la evolución de los elementos a ordenar la podemos observar en la siguiente tabla. Si el tamaño de los registros es grande. Sin embargo. por inserción y por selección llevan un tiempo O(n ) y llevarán 2 un tiempo de Ω(n ) en buena para de las secuencias de entrada de n elementos. A diferencia de las clasificaciones de burbuja y por inserción. Cuando los registros sean grandes y los intercambios costosos. El número de intercambios efectuados en la clasificación por inserción es.3. en promedio idéntico al de la clasificación de la burbuja. 5. al clasificar n/2 pares (A[i]. algunas veces llamado clasificación de incremento decreciente.5 burbuja. es un algoritmo de clasificación O(n ) simple. muy sencillo de ampliar y razonablemente eficiente para valores modestos de n. Así para una n grande. puede ser una perdida de tiempo implantar un algoritmo más complicado que los estudiados en esta sección. tanto en el peor de los casos como para el caso promedio.5 Limitaciones de los algoritmos simples Se debe tener presente que los algoritmos mencionados en esta sección tienen un tiempo de 2 ejecución O(n ). una generalización de la clasificación de la 1. ninguno de estos algoritmos se compara de modo favorable con los algoritmos O (n lg n) que se analizarán en las siguientes secciones. A[n/2+i]) para 0 ” L ” Q HQ HO SULPHU UHFRUULGR Q FXiGUXSORV $>L@ $>Q  L@ $>Q  L@ $>QL@. El procedimiento Shellsort (clasificación de Shell) de la figura 5. La clasificación de Shell. Una regla razonable es que a menos que n sea aproximadamente 100.1.7. clasifica un vector A[n] de enteros.Algoritmos avanzados de ordenación y búsqueda. } incr = incr / 2. y así sucesivamente. para 0 ” L ” Q HQ HO VHJXQGR UHFRUULGR Q yFWXSORV HQ HO WHUFHU UHFRUULGR \ DVt VXFHVLYDPHQWH (Q cada recorrido. Clasificación de Shell (Shellsort). void Shellsort ( int *A ){ int i. A[j]. 121 . incr. A[j+incr]). incr = n / 2. En cada recorrido. 5. el ordenamiento se realiza con la clasificación por inserción. Después se aplica recursivamente el Quicksort a A[0]. alrededor del cual reorganizar los elementos del vector. todo el vector quedará ordenado. A[n-1]. Se permutan los elementos del vector con el fin de que para alguna j. …. n/8 óctuplos en el tercer recorrido. } } } /*Shellsort*/ Figura 5. A[n-1] para clasificar ambos grupos de elementos. la cual termina cuando encuentra dos elementos en el orden apropiado. todos los registros con claves menores que v aparezcan en A[0]. …. y todos aquellos con claves mayores aparezcan en A[j+1]. Es de esperar que el pivote esté cercano a la mediana de la clave del vector.i < n. j = j –incr.j. Dado que todas las claves del primer grupo preceden a todas las claves del segundo grupo.7. while (incr > 0) { for (i=incr + 1. A[n-1] tomando en el vector un valor clave v como elemento pivote. …. i++) { j= i – incr. …. El procedimiento intercambia_enteros queda su realización como ejercicio al lector. while (j> 0) if (A[j] > A[j+incr]) { intercambia_enteros(A[j].2 Clasificación ráp ida (quicksort) La esencia del método consiste en clasificar un vector A[0]. …. A[j] y a A[j+1]. y el segundo. 4. después. 1. llamando de forma recursiva al quicksort para cada uno de los dos vectores obtenidos anteriormente repitiendo este proceso hasta que lleguemos a los casos base. pasando entonces al nivel dos del gráfico.           1LYHO  3DUWLFLyQ Y               1LYHO  3DUWLFLyQ Y         3DUWLFLyQ Y       1LYHO  &RQFOXLGR                   3DUWLFLyQ Y  &RQFOXLGR 3DUWLFLyQ Y              1LYHO  &RQFOXLGR &RQFOXLGR 3DUWLFLyQ Y  &RQFOXLGR 1LYHO  &RQFOXLGR &RQFOXLGR Figura 5. En cada caso. por un lado los elementos menores que el pivote y por otro lado los mayores. uno antes de dividir cada subvector. El procedimiento recursivo quicksort(i. 2. ya que es el mayor de los dos valores claves primeros del vector. Se puede ver que cada nivel consta de dos pasos. …. 6. 9. de tal forma que los elementos primeros de este tienen un valor menor que el pivote y los últimos tienen un valor mayor que el pivote. En la figura siguiente se muestran los pasos recursivos que da el quicksort para ordenar la siguiente secuencia de enteros 3. 5. La recursión acaba al descubrir que las posiciones del vector tienen claves idénticas. Más concretamente para el ejemplo siguiente el elemento pivote elegido en el primer nivel es el 3. ordena desde A[i] hasta A[j] y a grandes rasgos sería: 122 .j) que opera en un vector A con elementos A[0].8. se ha escogido como valor v al mayor de los dos valores distintos que se encuentran más a la izquierda. A[n1]. Ejemplo de ordenación con Quicksort.Programación II Ejemplo. A continuación permutamos los elementos del vector inicial. 3. 5. 1. Con esta clasificación podemos dividir el vector inicial en dos. ….clave > primera_clave) { /*selecciona la clave mayor*/ teminar = 1. …. while ((k <= j) && (terminar == 0)){ /*rastrea en busca de una clave distinta*/ if (A[k]. } else if (A[k]. Lo primero que tenemos que hacer es definir la función encuentra_pivote que obtiene la prueba de la línea /*1*/ del procedimiento anterior. int terminar. primera_clave = A[i]. } } return (valor). valor = k.clave. fin-si Figura 5. devuelve -1. 123 . Esta función se podría escribir como: int encuentra_pivote (int i. …. /*valor de la primera clave encontrada es decir. A[j]. para determinar si todas las claves A[i]. tienen la misma clave. de otra forma . k-1). Algoritmo del quicksort. ….clave*/ int k. …. A[j] tienen claves idénticas. devuelve el índice de la mayor de las dos claves diferentes de más a la izquierda*/ tipo_clave primera_clave. A[j] de manera que para alguna k entre i+1 y j. la cual se convierte en el elemento pivote. el procedimiento no afecta a A. valor = k. A[k-1] tengan claves menores que v y los elementos A[k]. A[j] tengan claves ≥ v quicksort(i. quicksort(k. A[i]. } /*encuentra_pivote*/ Figura 5. /* si nunca se encontraron dos claves diferentes*/ terminar = 0. De otro modo devuelve el índice de la mayor de las dos primeras claves diferentes.clave < primer_clave){ terminar = 1.9. j). A[j] tienen el mismo valor. Si encuentra_pivote no encuentra dos claves distintas.Algoritmos avanzados de ordenación y búsqueda. valor = -1. tipo_registro A) { /*devuelve 0 si A[i]. Obsérvese que si todos los elementos A[i]. Procedimiento encuentra_pivote.10. /*va de izquierda a derecha buscando una clave diferente*/ int valor. /*1*/ /*2*/ /*3*/ /*4*/ /*5*/ /*6*/ Si de A[i] a A[j] existen al menos dos claves distintas entonces sea v la mayor de las dos claves distintas encontradas. int j. …. A[i]. k = i+1. permuta A[i]. se introducen dos cursores. tendrán claves mayores o iguales al pivote. A[i]. …. A[j] para que las claves menores que pivote estén a la izquierda y las claves mayores o iguales que pivote estén a la derecha. tipo_registro A){ /*divide A[i]. 124 . en la A[d] anterior y d se moverá al menos una posición a la izquierda. …. de la siguiente forma: &ODYHV  SLYRWH &ODYHV ≥ SLYRWH .11. Los elementos a la izquierda de z. y los elementos del centro estarán mezclados. d = j. se aplica la línea /*3*/ del procedimiento del quicksort.A[d]). La función partición implementada que realizaría esto podría ser: Int particion (int i. Los elementos a la derecha de d. entonces se ha dividido A[i]. respectivamente. Probar: si z > d. donde se encuentra el problema de la permutación de A[i]. A[j] de forma satisfactoria. z y d. …. se intercambia A[z] con A[d]. utilizando las siguientes operaciones: • • • Rastrear: mueve z a la derecha en los registros cuyas claves sean menores que el pivote y mueve d a la izquierda en las claves mayores o iguales que pivote. La función partición devuelve z. /*cursores*/ z = i. de manera que todas las claves menores que el valor del pivote aparezcan a la izquierda de las demás. …. siempre tendrán claves menores que el pivote. = ' - &ODYHV GHVRUGHQDGDV Figura 5. d.*/ int z. A[j]. Desviar: si z < d (obsérvese que no se puede para durante el rastreo con z=d porque uno u otro se moverá más allá de la clave dada).Programación II Luego. Para realizar esto. int j. Después de hacerlo se sabrá que en la siguiente fase de rastreo z se moverá una posición a la derecha. de la posición de A que es está clasificando. A[j]. A[z1]. …. Devuelve el lugar donde se inicia el grupo de la derecha. tipo_clave pivote. esto es. en el extremo izquierdo y derecho. A[d+1]. en un principio. Situación durante el proceso de permutación. sobre los mismos registros. do{ intercambia (A[z]. esto es. es O(j – i +1). Ahora. pivote.clave < pivote) { z++. A). Si observamos el código fuente vemos que nunca se pasa dos veces por el mismo elemento. pivote. será: void quicksort (int i. } } Figura 5. tipo_registro A) { /*ordena los elementos A[i].clave.clave >= pivote) { d --. /*ahora se inicia la fase de rastreo*/ while (A[z]. A[j] del vector A*/ tipo_clave pivote. dado que existen j – i + 1 elementos en la porción del vector a ordenar. quicksort (i.1 Tiempo de ejecució n del quicksort.12. Procedimiento quicksort. /*el valor del pivote*/ int indice_pivote. k-1. siendo en muchos caso bastante menor. siendo fácil probar que le tiempo consumido por la llamada encuentra_pivote de la línea /*1*/ del procedimiento anterior. Cada llamada individual a quicksort lleva un tiempo como máximo proporcional al número de elementos que se van a ordenar. return (z). if (indice_pivote != (-1)) { /*no hace nada si toda las claves son iguales*/ pivote = A[indice_pivote]. es decir. un tiempo de O(j – i + 1). A). uno hacia abajo y el otro hacia arriba. El algoritmo tiene en promedio un tiempo de O(n lg n) para ordenar n elementos. quicksort (k. La llamada inicial a este procedimiento para ordenar el vector A seria quicksort (0. } /*particion*/ Figura 5. …. Función partición. j. El programa final del quicksort para ordenar un vector A de tipo tipo_registro [n]. k = particion (i.Algoritmos avanzados de ordenación y búsqueda. j. y en el peor 2 de los casos tiene O(n ). A). /*1*/ /*2*/ /*3*/ /*4*/ /*5*/ /*6*/ 5. int j. 125 . intercambiando los registros si es necesario. Para demostrar estos valores lo primero que tenemos que hacer es probar qué partición(i. } } while (z > d). pero nunca vuelven sobre uno ya visitado. j. tenemos que considerar el tiempo de ejecución de quicksort(i. ya que los bucles lo van recorriendo. A). n-1).2.13. j. j. A). A) tiene un tiempo proporcional al número de elementos que debe clasificar. /*indice al inicio del grupo de elementos ≥ pivote*/ indice_pivote = encuentra_pivote(i. } while (A[d]. /*indice de un elemento a A donde clave es el pivote*/ int k. en tiempo promedio tiene O( n lg n). j. Para el caso promedio tenemos que suponer que no existen dos elementos con claves iguales y que cuando se llama a quicksort (i.Programación II En otras palabras. La profundidad de ri es n-i+1 para 1≤ i ≤ n-1. en todos los elementos. saliéndose esta demostración del objetivo del curso. A). el quicksort necesita un tiempo proporcional a n para clasificar n elementos. …. Viéndose que en el peor de los 2 2 2 casos. todas las clasificaciones para A[i]. Quicksort es muy rápido. en el árbol de particiones.4 lg n. donde r0.2. Esta secuencia de particiones forma un árbol como el de la figura siguiente. A[j] son igualmente probables. Sobre el ejemplo de la figura 5. y el otro con todos los demás. uno con un solo elemento. Partiendo de esto y utilizando la probabilidad para obtener el mejor pivote obtendremos una serie de fórmulas que tras ser simplificadas nos dan que el quicksort requiere un tiempo O (n lg n) en el caso promedio.8 el diagrama de niveles que tendríamos sería el siguiente: 126   .2 Mejoras al quickso rt. de las veces que el elemento forma parte del subvector en el que se hizo la llamada a quicksort.14. en comparación la profundidad promedio de un elemento es de cerca de 1. Entonces de dividiría el subvector en dos subvectores más pequeños. y la profundidad de r1es n-1. el tiempo total consumido por quicksort es la suma. Peor secuencia posible de selecciones de pivotes. U U U U 3  3   Figura 5. Es posible mejorar aún más el factor constante al tomar pivotes que dividan cada subvector en partes similares ya que de esta forma cada elemento tendría una profundidad exactamente igual a lg n. En el peor caso. así la suma de las profundidades es n-1+ ∑ n − i +1= i =1 n −1 n2 n + − 1 lo cual es Ω (n2). podría suceder que en cada llamada a quicksort se seleccione el peor pivote posible. por ejemplo. 5. el mayor valor de las claves en el subvector que se está clasificando. r1 … rn-1 es la secuencia de registro en orden creciente de las claves. Algoritmos avanzados de ordenación y búsqueda.          1LYHO  3DUWLFLyQ Y               1LYHO  3DUWLFLyQ Y      3DUWLFLyQ Y     1LYHO  &RQFOXLGR    3DUWLFLyQ Y        3DUWLFLyQ Y   3DUWLFLyQ Y           1LYHO  &RQFOXLGR &RQFOXLGR &RQFOXLGR &RQFOXLGR &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 al azar 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 la secció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 subvectores 2 pequeños. Recuérdese que los métodos simples tienen un tiempo de O(n ), siendo mejor que el de este método que es de O( n lg n), para n pequeñas. Knuth en [Kun87] sugiere 9 como el tamaño del subvector 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 crea un vector de punteros a los registros del vector A. Se efectúan las comparaciones entre las claves de los 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 de elementos de tipo tipo_registro que se usará para guardar los elementos conforme se clasifican. El operador MIN(S) devuelve el registro en S cuya clave tiene el valor más pequeño. El algoritmo de clasificación abstracto se puede transformar en el heapsort, y nos quedaría como: 127 Programación II Repite para x en la lista L INSERTA(x, S); Fin-repite Repite 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 cuales podemos implementar cada operación en un tiempo O(lg n), si los conjuntos no crecen más allá de n elementos. Si se supone que la lista L es de longitud n, el número de operaciones realizadas será n veces INSERTA, n veces SUPRIME, n veces MIN, y n+1 VACIA. El tiempo total consumido por el algoritmo 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 ordenar mediante alguna función que indica si el valor de alguna clave es menor que otro. Pero en muchas ocasiones es posible clasificar en tiempos menores a O(n lg n), siempre que se conozca algo especial acerca 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 claves se 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 almacenados inicialmente 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 completo lleva un tiempo O(n). Trabaja bien cuando existe un único registro con clave v, para todo valor de v entre 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 que A[0]= 5, si sustituimos en la fórmula B[A[0]]=A[0] tendríamos que B[5]=5, que justamente es la posición que tiene que ocupar tras ser ordenado el vector. Si esto lo repetimos para cada uno de los elementos lo que obtenemos es el vector A ordenado en B. Para un solo valor entero quizás no tenga mucho sentido, ya que tendríamos la secuencia completa, pero si esto lo aplicamos a un registro si, ya que nos ordenaría todos los demás campos. 128 Algoritmos avanzados de ordenación y búsqueda. Hay dos formas de clasificar un vector A con claves 0, 1, …, n - 1 en sólo un tiempo O(n). Se recorre A[0], …, A[n - 1] por turno; si el registro a A[i] tiene clave j ≠ i, se intercambia A[i] con A[j]. Si después del intercambio el registro con la clave k se encuentra en A[i], y k ≠ i, se intercambia A[i] con a[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 ya que 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), un proceso de clasificación donde se crea una urna para contener todos los registros con cierta clave. Se examina 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 clave cuyo valor es i. A veces puede ser necesario almacenar más de un registro en una urna teniendo luego que concatenarlas en el orden apropiado. Si B es un vector de tipo TLista [tipo_clave], entonces B es un vector de urnas, que son listas y esta indexado por tipo_clave, existiendo una urna para cada posible valor de clave. 129 como en tipos Tipo_clave = registro Dia : 1. t2. para el primer tipo. k –2.} inicio Repite para i = k. el dígito menos significativo. primero el menor valor. …. clasificar de nuevo en fk-1 .Programación II 5.…. donde cada campo es un carácter. Se pueden considerar las claves del tipo antes definido como si los valores de las claves fueran enteros expresados en alguna notación de residuos extraña. …. fk de tipos t1. . El procedimiento usa los vectores Bi de tipo array [ti] of tipo_lista para 1 ≤ i≤ k. t1 = t2 = … =tk =char. Clasificación por residuos.. dic). tk respectivamente. . t2. 2500}. dic} y t3 ∈ {1900. …..…. como en tipos Tipo_clave = cadena (10). puede considerarse como si el lugar de más a la derecha estuviera en base 100 (correspondiente a los valores entre 1900 y 2500). La idea de esta clasificación es ordenar por urnas todos los registros. fin-tipo_clave. desde el menor hasta el mayor do concatena Bi [v] en el extremo de A fin-repite fin {radixsort} Figura 5. puede considerarse como la expresión de enteros en base 128. …. Mes : (ene. Desde este punto de vista la clasificación por urnas generalizada se conoce como clasificación por residuos (radix sorting). 1 Repite para cada valor de tipo ti do { limpia las urnas} vacia Bi [v] Repite para cada registro r de la lista A do mover r desde A hasta el final de la urna Bi [v]. y así sucesivamente. Por ejemplo. después concatenar las urnas.. O un vector de elementos del mismo tipo. {clasifica la lista A de n registros con claves que consisten en campos f1..31. Un boceto de cómo se podría escribir este algoritmo sería: Procedimiento radixsort. en el vector del mismo tipo.. La definición del tipo secuencia de campos. primero el f k . f2. Supóngase que el tipo_clave es una secuencia de campos. y el tercero en base 31. y k=10.17.31}. fk de tipos t1. k-1.2500. el siguiente lugar en base 12. donde v es el valor del campo fi de la clave de r Repite para cada valor v de tipo ti .1 Clasificación gene ral por residuos (radix sort). f1. t1 ∈{1. Año : 1900.. tk. Se supondrá de aquí en adelante que tipo_clave está constituido por k elementos.4. Por ejemplo. t2 ∈ {ene. donde tipo_lista es una lista enlazada de registros. 130 /*1*/ /*2*/ /*3*/ /*4*/ /*5*/ /*6*/ /*7*/ . f2. …. un árbol o incluso un diagrama. Por ejemplo. 5. La tabla puede estar contenida en su totalidad en la memoria.5. La clave externa es cuando tenemos una tabla de claves diferentes que incluye apuntadores a los registros. Una clave de este tipo se llama clave secundaria.. La asociación entre un registro y su clave puede ser simple o compleja. En la práctica. Sin embargo como cualquier campo de un registro puede servir como la clave en una aplicación particular las claves no siempre necesitan ser únicas. para poder enlazar A[i] a A[i+1] para i = 1. Hay una clave asociada a cada registro. en la memoria auxiliar o estar dividida en ambas. El algoritmo puede dar como resultado el registro entero o un puntero a dicho registro.5 Técnicas básicas de búsqueda Definamos algunos términos antes de considerar técnicas específicas de búsqueda: • • • • • Una tabla o un archivo es un grupo de elementos. el índice dentro del vector de un elemento es una clave externa única para ese elemento.Algoritmos avanzados de ordenación y búsqueda. Una tabla de búsqueda o diccionario puede representarse como un TDA. 5.. typedef KEYTYPE . Primero suponemos dos tipos de declaraciones de los tipo de clave y registros y una función que extrae la clave de un registro del mismo. al tipo tipo_registro. La clave interna está contenida dentro del registro en un tramo a una distancia específica del principio del mismo. sólo es necesario agregar un campo adicional. typedef RECTYPE . que se usa para diferenciar unos de otros. Por ejemplo si el archivo está almacenado como un vector. n-1 y así hacer una lista enlazada del vector A en un tiempo O(n). Sólo se cambian registros de una lista a otra. por ejemplo la de lista ordenada. es probable que no sea única. dado que puede haber dos registros con el mismo estado dentro del archivo.. con frecuencia se diseña una tabla teniendo en mente una técnica de búsqueda específica.4. cada uno de los cuales se llama registro. La clave primaria es un conjunto de claves único para todo archivo. También definimos un registro nulo para representar una búsqueda infructuosa. • • 5. el algoritmo puede dar como resultado un registro “nulo” especial o un puntero a nulo. Si la búsqueda no es exitosa. el campo de enlace.. nunca se copia un registro. Un algoritmo de búsqueda es un algoritmo que afecta un argumento a y trata de encontrar un registro cuya clave sea a. Dado que distintas técnicas de búsqueda pueden ser adecuadas para organizaciones de tablas diferentes. 2. Obsérvese también que si se presentan en esta forma los elementos a clasificar. no de vector. La tabla o el archivo puede ser un vector de registro. /* un tipo de clave*/ /* un tipo de registro */ 131 . …. una lista ligada.1 El diccionario com o un tipo de datos abstracto. en un archivo de nombres y direcciones si se usa el estado (provincia) como clave para una búsqueda particular.2 Análisis de la clasi ficación por residuos Tenemos que escoger la estructura de datos adecuada para hacer que esta clasificación sea eficiente. . abstrac inset (tbl.. /* un registro “null” */ Podemos entonces representar el tipo de datos abstracto table como un simple conjunto de registros. RECTYPE r.. abstract delete (tbl. al último y al sucesor de un elemento dado. { . abstract member (tbl. 5. Una vez establecido un orden entre las claves se hace posible la referencia al primer elemento de una tabla..k) TABLE (rectype) tbl. abstract typedef [rectype] TABLE (RECTIPE) . KEYTYPE keyfunct (r) RECTYPE r. }. la tabla que especificamos se llama tabla desordenada. postcondition tb == (tbl’ – [search (tbl. Como no se presupone que exista relación entre los registros o sus claves asociadas. ( tbl – [r]) == tbl’ . r). La operación de conjuntos x – y denota el conjunto x eliminado de él todos los elementos de y. KEYTYPE k. k) TABLE(RECTYPE) tbl.k) TABLE(RECTYPE) tbl.Programación II RECTYPE nullrec = . KEYTYPE k. 132 . Usamos la notación [eltype] para denotar un conjunto de objetos de tipo eltype. k)) && (search == nulrec) || (member (tbl. postcondition if(existe un r en tbl tal que keyfunct(r) == k) then menber = TRUE else member = FALSE abstract RECTYPE search (tbl. Dejamos la especificación del TDA como ejercicio al lector. Este es nuestro primer ejemplo de un TDA definido como un conjunto y no como una secuencia. keyfunct (r) == FALSE postcondition inset (tbl. KEYTYPE k.k) && keyfunct (search) == k). precondition member (tbl..K]). elt) da como resultado verdadero si elt está en el conjunto s y falso en el caso contrario. postcondition (not member (tbl. Una tabla que cuente con estas facilidades adicionales se llama una tabla ordenada. La función insert (s.5. El TDA para una tabla ordenada debe especificarse como una secuencia para indicar el orden de los registros y no como un conjunto..) TABLE(RECTYPE) tbl.2 Notación algorítmi ca Una tabla organizada como un vector podría declararse como: #define TABESIZE 1000 typedef KEYTYPE . La última instrucción se modifica como sigue: k(n) = key. for (i = 0. como p Æ Sin embargo. al encontrar una que coincida con el argumento de la búsqueda. para una tabla organizada como una lista. en el último. hacemos referencia al registro correspondiente como r(i) o r(p). return (n-1). con el objeto de liberarnos de la necesidad de elegir una representación específica adoptamos la convención algorítmica de hacer referencia a la clave i-ésima como k(i) y a la clave del registro apuntado por p como k(p).k.k. podría usarse la representación dinámica de una lista o la representación con vector de la lista. r(n) = rec. De igual forma. Queremos obtener el entero i más pequeño tal que k(i) sea igual a key si existe tal i y –1 en caso contrario. /* insertar la nueva clave */ /* y el registro */ /* incrementar el tamaño de la tabla */ 133 . RECTYPE r. } table [TABLESIZE]. En el primer caso la clave del registro k. i<n. struct { KEYTYPE k.. typedef RECTYPE . i++) if (key == k(i)) return(i). ya sea como un vector o como una lista ligada. Supongamos también que key es un argumento de búsqueda. En el primer caso la i-ésima clave sería referida como table[i]. Esta búsqueda es aplicable a una tabla organizada. El algoritmo examina cada clave en turno. Si ninguna coincide el resultado es –1. return(-1). apuntado por un puntero p sería referida como node[p].. 5. da como resultado su índice. las técnicas para buscar en estas tablas son muy similares. o como dos arreglos separados: KEYTYPE k[TABLESIZE].3 Búsqueda secuenc ial La forma más simple de búsqueda es la búsqueda secuencial.Algoritmos avanzados de ordenación y búsqueda. Así. 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). en el segundo como k[i]. RECTYPE r[TABLESIZE]. De esta manera podemos concentrar nuestra atención en los detalles de la técnica en lugar de los de la implementación. Este algoritmo puede modificarse con facilidad para agregar un registro rec con clave key a la tabla si key aún no está en la misma. El algoritmo para hacerlo es el siguiente. De manera similar.5. n++ . Supongamos que k es un vector de n claves. /* inserta un nuevo nodo */ s = getnode(). garantizando así que la clave será encontrada. Cuando este algoritmo se implementa. next(s) =null. dos registros no pueden tener la misma clave.Programación II Obsérvese que si se hacen inserciones usando sólo el algoritmo modificado anterior. else next(q) = s. return(i) La clave extra insertada al final del vector se llama un centinela. el método del centinela requiere de guardar un puntero externo adicional al último nodo de la lista. key ! = k(i). la búsqueda secuencial con inserción para una lista ligada puede escribirse de la siguiente manera: q = null. Al almacenar una tabla como una lista ligada tiene la ventaja de que el tamaño de la tabla puede aumentar de manera dinámica cuando sea necesario. r(s) = rec. r. Supongamos que la tabla está organizada como una lista lineal ligada apuntada por table y ligada mediante un campo puntero next. p=next(p)) q = p. p !=null && k(p) != key. Se puede agregar un nodo centinela que contenga la clave del argumento al final de la lista antes de comenzar la búsqueda de manera que la condición en la iteración for sea la condición simple k(p) != key. 134 . Un método de búsqueda aún más eficiente involucra la inserción de la clave del argumento al final del vector antes de comenzar la búsqueda. k(s) = key. for (i = 0. Entonces. Para una búsqueda e inserción la instrucción if completa se reemplaza por: if (i == n) r(n++) = rec. if (i<n) return(i). for(p = table. suponiendo k. k(n) = key. debemos asegurarnos que el incremento de n no haga que su valor exceda el límite superior del vector. if (p != null) /* lo que significa que k(p) == KEY */ if (p != null) return (p). else return(-1). if (q == null) table = s. return(s) La eficiencia de la búsqueda puede perfeccionarse mediante la misma técnica que acabamos de sugerir para un vector. Sin embargo. key y rec como antes. i++) . = p = next(p)) { s = q. */ /* se está dos pasos detrás de p */ for (p = table. siempre que una búsqueda es exitosa. */ next(q) = next(p). el registro recuperado se elimina de su localización actual de la lista y se coloca a la cabeza de la misma. 5. */ if (q == null) /* la clave está en la primera posición de la tabla. una búsqueda exitosa haría (en el promedio) (n+1)/2 comparaciones y. /* Se ha encontrado el registro en la posición p. if (s = null) table = p else next(s) = p. Si el vector está ordenado de alguna manera este método no puede usarse. */ /* Transponer los registros apuntados por p y q. en el cual un registro recuperado se intercambia con el registro que lo precede de manera inmediata. El algoritmo da como resultado un puntero como registro recuperado o el puntero nulo si no se encuentra el registro. La eliminación de un registro de una tabla almacenada como un vector desordenado se implementa reemplazando el registro a ser eliminado por el último registro del vector y reduciendo el tamaño de la tabla en 1. En cualquier caso el número de comparaciones es o(n).Algoritmos avanzados de ordenación y búsqueda.5 Reordenamiento d e una lista para alcanzar máxima eficiencia de búsqueda. Así. p ! = null && k(p) ! = key. } /* fin de for */ if (p == null) return (p). key es el argumento de búsqueda. El otro método se llama transposición. /* q se encuentra u n paso detrás de p. q = p. Como antes. next(p) = q. return(p). se realiza una sola comparación. q = s = null.5. Presentamos un algoritmo para implementar el método de transposición en una tabla almacenada en forma de lista ligada. */ /* No se requiere la transposición */ return(p). una infructuosa n comparaciones. 135 . Si es igualmente probable que el argumento aparezca en cualquier posición dada en la tabla. Si el registro es el primero de la tabla. Hay dos métodos de búsqueda para realizar lo anterior. de tal forma que los registros que se accedan con mayor frecuencia estuvieran al frente y los que se accedan con menor frecuencia al final. El número de comparaciones depende del lugar de la tabla donde aparece el registro que tienen la clave del argumento. 5. En este método. sería de gran ayuda tener un algoritmo que reordenará de manera continua la tabla. /* transponer node(q) y node(p). si el registro es el último. se necesitan n comparaciones. k y r son las tablas de claves y registros.4 Eficiencia de la bús queda secuencial. Uno de ellos se conoce como método de moverse-al-frente y es eficiente en el caso de una tabla como una lista.5. table es un puntero al primer nodo de la lista. Esto es cierto en especial si la tabla es de tamaño fijo.7 La búsqueda secue ncial indexada. llamada index además del propio archivo ordenado. Se aparta una tabla auxiliar. 136 . Hay otra técnica para perfeccionar la eficiencia de la búsqueda en un archivo ordenado. Esto se ilustra en la figura siguiente.5. se necesitan (en promedio) solo n/2 comparaciones. las recuperaciones subsecuentes serán más eficientes. Dejamos como ejercicio al lector la implementación del método de transposición para un vector y el método de moverse-al-frente. Adelantando dichos registros hacia el frente de la tabla.5. Esto ocurre porque sabemos que una clave está faltando en un archivo ordenado de manera ascendente tan pronto como encontremos una clave que sea mayor que el argumento. pero involucra un incremento en la cantidad de espacio requerido. Cada elemento en el index consta de una clave kindex y un puntero al registro del archivo que corresponde a kindex. En el caso de un archivo ordenado suponiendo que las claves argumentos están distribuidas de manera uniforme sobre el rango de claves del archivo. tienen que estar ordenados de acuerdo con las claves. cada octavo registro del archivo tiene que estar representado en el índice. Una ventaja obvia de la búsqueda en un archivo ordenado se tiene cuando la clave del argumento no está presente en el archivo. Este método se llama método de búsqueda secuencial indexada.6 La búsqueda en un a tabla ordenada. 5. Los elementos en el índice tanto como los elementos en el archivo. Ambos métodos están basados en el fenómeno observado de que un registro que ha sido recuperado tiene probabilidad de ser recuperado de nuevo. Si el índice es un octavo de las claves del archivo.Programación II Obsérvese que las dos primeras instrucciones if en el algoritmo anterior pueden combinarse dentro de la instrucción simple if (p == null || q == null) return(p). 5. para ser más conciso. 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. Algoritmos avanzados de ordenación y búsqueda. N FODYH. U 5HJLVWUR. Un archivo secuencial indexado. El algoritmo usado para buscar en un archivo secuencial indexado es de forma directa. 137 . el algoritmo anterior no da como resultado un puntero al primero de tales registros en todos los casos. if (i == 0) lowlim = 0 else lowlim = pindex (i – 1). LQGLFH NLQGH[ SLQGH[                            Figura 5. Sean r. j++) . j <= hilim && k(j) != key. que n es el tamaño y que indxsize es el tamaño del índice.18. return ((j > hilim) ? –1 : j). Supongamos que el archivo está ordenado en un vector. if (i == indxsize) hilim = n – 1 else hilim = pindex(i) – 1. kindex un vector de claves del índice y pindex un vector de punteros dentro del índice que apuntan a los registros reales del archivo. for (j = lowlim. k y key definidas como ante. Obsérvese que en el caso de registros múltiples con registros múltiples con la misma clave. i++) . for ( i = 0. < indxsize && kindex(i) <= key. Si la tabla es tan grande que incluso el uso de un índice no alcanza suficiente eficiencia (ya sea 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 otra en la tabla). Una vez que se ha encontrado la posición correcta en el índice se ejecuta una segunda búsqueda secuencial sobre una porción menor de la propia tabla de registros. El uso de un índice es aplicable a una tabla ordenada almacenada tanto como una lista ligada que como un vector. Se ejecuta una búsqueda secuencial en el índice. Usar una lista ligada implica una gran sobrecarga de espacio para punteros aunque las inserciones y eliminaciones pueden ejecutarse con mucha mayor facilidad.Programación II La ventaja real del método secuencial indexado es que los elementos de la tabla pueden examinarse de manera secuencial sin que todos los registros del archivo sean accedidos. que es menor que la tabla. sin embargo el tiempo de búsqueda de un elemento en particular se reduce de forma considerable. se puede usar un índice secundario. Esto se ilustra en la figura 5.19. El índice secundario actúa como un índice al índice primario que apunta a la entrada de la tabla secuencial. 138 . Algoritmos avanzados de ordenación y búsqueda. WDEOD N FODYH. VHFXHQFLDO U 5HJLVWUR. 139 .  LQGLFH SULPDULR LQGLFH VHFXQGDULR              Figura 5.19. En la búsqueda secuencial a lo largo de la tabla. Las eliminaciones de una tabla secuencial indexada se pueden hacer con mayor facilidad etiquetando las entradas eliminadas. sólo se etiqueta la entrada de la tabla original. Obsérvese que si se elimina un elemento. Uso de un índice secundario. nada tiene que hacer al índice. incluso si su clave está en el índice. se ignoran las entradas eliminadas. En consecuencia.Programación II La inserción en una tabla secuencial indexada es más difícil. Esto ocurre porque hace uso del hecho de que los índices de los elementos del vector son enteros consecutivos. esto requeriría un campo puntero extra en cada registro de la tabla original. se hacen cada vez dos comparaciones de claves a través del ciclo: key == k(mid) y key <k(mid) ). La búsqueda binaria también puede usarse al buscar en la tabla principal una vez que los dos registros frontera hayan sido identificados. podemos decir que el algoritmo de búsqueda binaria es O(lg n). se requieran muchas eliminaciones e inserciones. Sin embargo. while (low <= hi) { mid = (low + hi)/2. Sin embargo. la búsqueda binaria es prácticamente inútil en situaciones donde. Cada comparación en la búsqueda binaria reduce el número de posibles candidatos en un factor de dos. se puede usar una búsqueda binaria. La mejor manera de definir la búsqueda binaria es la forma recursiva. Sin embargo. el algoritmo de búsqueda binaria solo puede usarse si la tabla está almacenada como un vector. } /* fin de while */ return (-1). Por desgracia.8 Búsqueda binaria. if (key < k(mid)) hi =mid – 1. hi = n – 1. Obsérvese que la búsqueda binaria se puede usar en conjunción con la organización secuencial indexada de la tabla mencionada antes. 140 . Así. la búsqueda termina con éxito. en caso contrario se busca de manera similar en la mitad superior o inferior de la tabla. Si son iguales. se necesitará recorrer sólo unos pocos y escribir sobre el elemento eliminado.5. si ha sido etiquetado un elemento cercano en la tabla cuando se eliminó. necesitándose así un desplazamiento de un gran número de elementos de la tabla. ( En realidad es 2*lg n dado que en C. Esto puede requerir de una alteración del índice si se recorre un elemento apuntado por un elemento del índice. presentamos la siguiente versión no recursiva del algoritmo de búsqueda binaria: low = 0. En lugar de buscar de buscar en el índice de manera secuencial. 5. Básicamente. se compara el argumento con la clave del elemento medio de la tabla. es el de búsqueda binaria. el número máximo de comparaciones de claves es de manera aproximada lg n. El método de búsqueda más eficiente en una tabla secuencial sin usar índices o tablas auxiliares. Dejamos como ejercicio el estudio de esas posibilidades. Un método alternativo es mantener un área de desborde en alguna otra localización y ligarla a cualquier registro insertado. dado que puede no haber espacio entre dos entradas de la tabla ya existentes. Así. if (key == k(mid)) return(mid). else low = mid + 1. Sin embargo la recarga asociada a la recursividad puede hacerla inapropiada para su uso en situaciones prácticas en las cuales la eficiencia se considera primordial. es probable que el tamaño de este segmento de tabla sea tan pequeño que la búsqueda binaria no sea más ventajosa que una búsqueda secuencial. Por esta razón. low se hace 0 y high se hace n-1. los requerimientos computacionales de la búsqueda por interpolación. la búsqueda por interpolación puede tener un comportamiento promedio muy pobre. como en la búsqueda binaria. 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))) 141 .5. Repetir el proceso hasta que la clave haya sido encontrada o low > que high. Si tenemos la siguiente secuencia de valores en un vector: Posición Valor 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 10 y queremos buscar un elemento en el. Así. si es mayor. comparado con la búsqueda binaria que requiere lg n. se esperaría que key estuviese de forma 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. 5.9 Búsqueda por inte rpolación. Por ejemplo hay más nombres que comiencen por s que por q. el método puede ser aún más eficiente que la búsqueda binaria. En situaciones prácticas las claves tienden con frecuencia a aproximarse en torno a ciertos valores y no están distribuidas de forma uniforme. Otra técnica para buscar en un vector ordenado es la llamada búsqueda por interpolación. Sin embargo. Sin embargo las mayorías de las computadoras. si las claves no están distribuidas de manera uniforme. los cálculos requeridos por la búsqueda de interpolación son muy lentos. La búsqueda binaria requiere sólo aritmética en números enteros y divisiones entre dos que puede ejecutarse de manera eficiente recorriendo un bit a la derecha. En situaciones tales la búsqueda binaria es muy superior a la interpolación.Algoritmos avanzados de ordenación y búsqueda. ocasionan con frecuencia que esta se ejecute más despacio que la búsqueda binaria. se sabe que la clave argumento key está entre k(low) y k(high). haga low igual a mid+1. Si las claves están distribuidas de manera uniforme entre k(0) y k(n-1). el valor de mid puede ser igual a low+1 o hign-1 en cuyo caso la búsqueda por interpolación degenera en búsqueda secuencial. Suponiendo que las claves están distribuidas de manera uniforme entre esos dos valores. y a través del algoritmo. En el peor de los casos. dado que involucran aritmética con las claves y multiplicaciones y divisiones complicadas. Vamos a ver un ejemplo con números para que nos aclare todo esto. En principio. aún cuando la primera requiere menos comparaciones. En efecto si las claves están distribuidas de manera uniforme a lo largo del vector la búsqueda por interpolación requiere un promedio de lg(lg n) comparaciones y es raro que requiera muchas más. 6 Arboles Binarios de Búsqueda. que son: k(0) = 1 k(9) = 10 y obtenemos que mid = 0 + 9 * 2 / 9 = 2. 142 . con lo cual se deja como ejercicio del lector su realización. y todos los elementos almacenados en el subárbol derecho de x son mayores que el elemento almacenado en x. La búsqueda en árboles binarios es un método de búsqueda simple. tan solo recordar que la propiedad que define un árbol binario es que cada nodo tiene a lo más un hijo a la izquierda y otro a la derecha. Para construir los algoritmos consideremos que cada nodo contiene un registro con un valor clave a través del cual efectuamos las búsquedas. El algoritmo que realizaría esto no es muy complicado. dinámico y eficiente considerado como uno de los fundamentales. De forma que terminología sobre árboles. Otro ejemplo podría ser buscar el valor 4. 5. • comparamos el valor de k(2) con key (el buscado) y como son iguales acabamos. En la figura siguiente se muestran dos árboles binarios de búsqueda construidos basándose en el mismo conjunto de enteros. Un árbol binario de búsqueda es un árbol binario con la propiedad de que todos los elementos almacenados en el subárbol izquierdo de cualquier nodo x (incluyendo la raíz) son menores que el elemento almacenado en x. que para nuestro ejemplo son: k(0) = 1 k(9) = 10 y obtenemos que mid = 0 + 9 * 3 / 9 = 3. 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.Programación II sustituimos los valores de las k. • comparamos el valor de k(3) con key (el buscado) y como son iguales acabamos. Árboles binarios de búsqueda. typedef tipo_celdaB * TNodoB. utilizando el TDA árbol binario visto en el capitulo anterior seria: #define BINARIO_VACIO NULL #define NODO_NULO NULL typedef int TEtiqueta. Obsérvese la interesante propiedad de sí se listan los nodos del árbol binario de búsqueda en inorden (como vimos en el tema de TDA) nos da la lista de nodos ordenados.20. Figura 5. r. de estar presente. con el nodo raíz jugando un papel similar al del elemento partición del Quicksort aunque con los árboles binarios de búsqueda hay un gasto extra de memoria debido a los punteros. struct tipo_celdaB * hizqda. si k<r es evidente que k. La función puede ser codificada fácilmente de la siguiente forma. struct tipo_celdaB * hdrcha.Algoritmos avanzados de ordenación y búsqueda. La propiedad de árbol binario de búsqueda hace que sea muy simple diseñar un procedimiento para realizar la búsqueda. a de ser un descendiente del hijo izquierdo de la raíz. y si es mayor será un descendiente de hijo derecho. Esta propiedad define un método de ordenación similar al Quicksort. Para determinar si k está presente en el árbol la comparamos con la clave situada en la raíz. 143 . Si coincide la búsqueda finaliza con éxito. struct tipo_celdaB * padre. typedef struct tipo_celdaB{ TEtiqueta etiqueta. } tipo_celdaB. typedef TNodoB TArbolB. if (!nodo){ printf (“error no hay memoria”). En éste podría pensarse que se utiliza un árbol binario para describir la secuencia de comparaciones hechas por una función de búsqueda en el vector. } else if (e > n->etiqueta){ if (n->hdrcha == NULL){ nodo = (TNodoB) malloc (sizeof (tipo_celdaB)). T)). TArbolB T) { TNodoB nodo. } else inserta(e. T). solo que al encontrar un puntero a NODO_NULO durante la búsqueda. Función pertenece para buscar un elemento en el árbol. hdrchaB(n. } nodo->padre=n. se reemplaza por un puntero a un nodo nuevo que contenga e. T)). if (!nodo){ printf (“error no hay memoria”). exit (1). El procedimiento de construcción de un árbol binario de búsqueda puede basarse en un procedimiento de inserción que vaya añadiendo elementos al árbol. n->hizqda. T). else if (e < n->etiqueta){ if (n->hizqda == NULL){ nodo = (TNodoB) malloc (sizeof (tipo_celdaB)).Programación II int pertenece(TEtiqueta e. En cambio en los árboles binarios de búsqueda se construya una estructura de datos con registros conectados con punteros y se usa esta estructura para la búsqueda. else if (e < n -> etiqueta) return(pertenece(e. Tal procedimiento inserta(e. nodo->hdrcha=NULL. T). hizqdaB(n. 144 . nodo->hizqda=NULL. nodo->etiqueta=e. T) comenzaría mirando si T == BINARIO_VACIO y de ser así se crearía un nuevo árbol con un nodo para e y dejaría T apuntando hacia él. Si T no esta vacío se busca e como lo hace el procedimiento pertenece. } nodo->padre=n. else return(pertenece(e. Es conveniente hacer notar la diferencia entre este procedimiento y el de búsqueda binaria.21. TNodoB n. TArbolB T) { if (n == NODO_NULO) return(0). NULL. } Figura 5. n->hizqda = nodo. else if (e == n ->etiqueta) return(1). exit (1). NULL). El código podría ser el siguiente: void inserta(TEtiqueta e. n. if (T == BINARIO_VACIO) T= CrearB(e. TNodoB n. El resultado sería: Figura 5. n->hdrcha. Se plantea como ejercicio al lector que defina un nuevo TDA árbol binario en el cual no se tenga en cuenta el nodo padre. 15. T). Por ejemplo supongamos que queremos construir un árbol binario de búsqueda a partir del conjunto de enteros {10. nodo->hdrcha=NULL. Procedimiento para rellenar un árbol binario de búsqueda. nodo->hizqda=NULL. 14.22. Ejemplo de rellenado de un árbol binario de búsqueda usando el procedimiento inserta. } } Figura 5. n->hdrcha = nodo.Algoritmos avanzados de ordenación y búsqueda.23. nodo->etiqueta=e. 7. 5. y a continuación diseñe la función pertenece y la de inserta. 145 . 18} aplicando reiteradamente el procedimiento de inserta anterior. } else inserta(e. El código se complica un poco por tener que introducir el valor del padre en el nuevo nodo creado. 12. 146 . incluso para tablas pequeñas.1 Introducción. que podemos llamar desde ahora funciones hash. M-1]. la famosa “paradoja del cumpleaños” asegura que si en una reunión están presentes 23 o más personas. si seleccionamos una función aleatoria que aplique 23 claves a una tabla de tamaño 365 la probabilidad de que dos claves no caigan en la misma localización es de solo 0.Programación II 5.4927. tiene la particularidad de que podemos esperar que h(ki) = h(kj) para bastantes pares distintos (ki. La primera pregunta que podemos hacernos es si es fácil encontrar tales funciones h. aunque la situación ideal seria encontrar un h que generara valores aleatorios uniformemente sobre el intervalo [0 . Como factores a tener en cuenta para la elección de la función h(k) están que minimice las colisiones y que sea relativamente rápida y fácil de calcular.7.2 Funciones hash. las aplicaciones h(k). 5. El primer problema que hemos de abortar es el cálculo de la función hash que transforme claves en localizaciones de la tabla. En otras palabras. donde M es el número de registros que podemos manejar con la memoria de que dispongamos. Las dos aproximaciones que veremos están encaminadas hacia este objetivo y ambas están basadas en generadores de números aleatorios.7 Hashing. no por comparaciones entre valores clave.. El objetivo será pues encontrar una función hash que provoque el menor número posible de 6 colisiones . Las funciones que evitan valores duplicados son sorprendentemente difíciles de encontrar. aunque esto es solo un aspecto del problema. Esta técnica trabaja multiplicando la clave k por si misma o por una constante. kj). sino encontrando alguna función h(k) que nos dé directamente la localización de la clave k en la tabla. el otro será el de diseñar métodos de resolución de colisiones cuando éstas se produzcan. en principio.. En consecuencia. y solo * 41 40 * 39 …… * 11 = 40! / 10! (alrededor de 2*10 ) de ellas no generan localizaciones duplicadas. hay bastante posibilidad de que dos de ellas hayan nacido el mismo día del mismo mes.7. Hashing multiplicativo. usando después alguna porción de los bit del producto como una localización de la tabla hash. M-1]. 5. En otras palabras solo 2 de cada 10 millones de tales funciones serían “perfectas” para nuestros propósitos y encontrarlas no parece una cuestión trivial. Una aproximación a la búsqueda radicalmente diferente a las anteriores consiste en proceder. La respuesta es. bastante pesimista. Por ejemplo. Más precisamente necesitamos una función que transforme claves (normalmente enteros o cadenas de caracteres) en enteros en un rango [0 . puesto que si tomamos como situación ideal el que tal función dé siempre localizaciones distintas a claves distintas y pensamos por ejemplo en una tabla de 48 tamaño 40 en donde (alrededor de 10 ) posibles funciones del conjunto de claves en la tabla. 6 Ocurrencias de sinónimos a la hora de obtener la posición que tienen que ocupar en la tabla. 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. En cualquier caso existen reglas más sofisticadas para la elección de M [Knu87]. el método se denomina del cuadrado medio. Hashing por división. Estudiaremos tres métodos básicos de resolución de colisiones. El análisis comparativo de los métodos de hará en base al estudio del número de localizaciones que han de examinarse hasta determinar dónde situar cada nueva clave en la tabla. Cuando la elección es multiplicar k por si misma y quedarse con algunos de los bits centrales. struct item_hash *siguiente. basadas todas en estudios teóricos de funcionamiento de los métodos congruenciales de generación de números aleatorios. El segundo aspecto importante a estudiar es el hashing es la resolución de colisiones entre sinónimos. Este método aún siendo simple y pudiendo cumplir el criterio de que los bits elegidos marcar la localización son función de todos los bits originales de k. uno de ellos depende de la idea de mantener listas enlazadas de sinónimos. 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 con cuidado para evitar efectos negativos como que una clave alfabética k sea sinónima a otras claves obtenidas permutando los caracteres de k. es importante elegir el valor de M con cuidado.3 Resolución de coli siones. impares). Otro método multiplicativo. Aunque la fórmula es aplicable a tablas de cualquier tamaño. typedef struct item_hash tabla_hash[13].7. y el que el tamaño de la tabla está restringido a ser una potencia de 2. Una regla simple para elegir M es tomarlo como un número primo. }. tiene como principales inconvenientes el que las claves con muchos ceros se reflejarán en valores hash también con muchos ceros. 5. 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: 147 . tipo_ele dato. lo que constituiría un sesgo muy fuerte. Knuth [Knu87] prueba que un valor recomendable es C=1/R con R=(1/2)(1+sqrt(5)). todas las claves pares (resp. Para todos los ejemplos el tamaño de la tabla será M = 13 y las definiciones de tipos que adoptaremos son: typedef struct item_hash { int clave.Algoritmos avanzados de ordenación y búsqueda. y los otros dos del calculo de una secuencia de localizaciones en la tabla hash hasta que se encuentre una vacía. Por ejemplo si M fuera par. impares) serían aplicadas a localizaciones pares (resp. aunque en la mayoría de los casos. la tabla quedaría:              148 . etc). y dado que las listas individuales no han de tener un tamaño excesivo. y con la alternativa LIFO. La diferencia es que en lugar de mantener una sola lista. Finalmente. Existen variantes dependiendo del mantenimiento que hagamos de las listas de sinónimos (FIFO. Para nuestro ejemplo. la FIFO. inicialmente vacías. dándoles el valor 0. con un solo nodo cabecera se mantienen M listas con M nodos cabecera de tal forma que se reduce el número de comparaciones de la búsqueda secuencial en un factor M (en media) usando espacio extra para M punteros. podemos marcar todas las localizaciones de la tabla. una lista enlazada de registros cuyas claves caigan en esa dirección. se presentarán algoritmos para buscar y un item 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. Este método se conoce normalmente con el nombre de encadenamiento separado y obviamente la cantidad de tiempo requerido para una búsqueda dependerá de la longitud de las listas y de las posiciones relativas de las claves en ellas. Encadenamiento La manera más simple de resolver una colisión es construir. para cada localización de la tabla.Programación II j 1 2 3 4 5 6 kj 119 85 43 141 73 91 h1(kj) 2 7 4 11 7 0 j 7 8 9 10 11 12 kj 109 147 38 137 148 101 h1(kj) 5 4 12 7 5 10 Suponiendo que k = 0 no ocurre de forma natural. En cualquier caso. por valor clave. se suele optar por la alternativa más simple. si las listas se mantienen en orden esto puede verse como una generalización del método de búsqueda secuencial de listas. y puesto que las operaciones de búsqueda e incluso de inserción están muy relacionadas. LIFO. Es el llamado hashing lineal. Al final cuando se han insertado todas las claves la tabla resultante es la Tabla 5.…. y después de la inserción. i 0 1 2 3 4 5 6 7 8 9 10 11 12 Tabla 5. 8.5) después de que tal falle el test de encontrar lugares vacíos en las localizaciones 4. después de insertar las 7 primeras claves nos aparece la Tabla 5. (Tabla 5. Direccionamiento abierto. En nuestro ejemplo. podrían ponerse todas las claves en la propia tabla y no usar listas enlazadas. La primera técnica para hacerlo es simplemente comenzar en la localización h1(k) y examinar secuencialmente las restantes (mod M).4. conocido cono encadenamiento interno. Cuando hemos de situar la clave 147.6. de modo que cualquier clave que haya de ser colocada en localizaciones cercanas al principio de tal agrupación requiere inevitablemente un número relativamente grande de pruebas. lo que nos conduciría a otro método de encadenamiento.6. donde al lado de cada entrada aparece el número de pruebas requeridas para su inserción.5. 5 y 7. la insertamos en la localización 6. vía campos cursores (punteros) que nos inicializados a –1 y que irán apuntando hacia sus sinónimos respectivos. con el agravante de que este fenómeno es cada vez peor cuanto más crece el tamaño de los grupos. esos dos grupos se han combinado en una gran agrupación primaria. 3.4. Son los métodos de direccionamiento abierto. Si se tiene una estimación del número de elemento a colocar en la tabla. Es este caso. Para solucionar el problema y que el hashing trabaje bien necesitamos tener los agujeros distribuidos aleatoriamente. la unión entre sinónimos está dentro de la propia tabla hash. 141 43 109 147 85 72 119 i clave 91 0 1 2 3 4 5 6 7 8 9 10 11 12 i clave 91 101 119 0 43 109 147 85 72 137 148 141 38 1 1 3 1 2 3 6 1 1 intentos 1 5 1 Tabla 5. 149 . no es conveniente dar a las entradas de la tabla hash el papel de cabecera de listas. A veces. La secuencia de pruebas podría expresarse como: hi(k) = (h1(k) + (i-1)) mod M i=2. pero el hashing lineal propaga agrupaciones primarias que rompen esta propiedad. y no hay problema con la memoria disponible. 141 85 72 43 109 119 clave 91 0 1 2 3 4 5 6 7 8 9 10 11 12 Tabla 5. La idea general es inspeccionar una sucesión de localizaciones de la tabla hasta que se encuentre la clave buscada o se localice un lugar vacío. y cuando en número de entradas es la tabla es relativamente moderado.Algoritmos avanzados de ordenación y búsqueda. 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 primos relativos. Un método de cumple los requisitos anteriores es el denominado hashing doble. puesto que ésta podría eventualmente llenarse.8. Obsérvese el uso de una variable total para hacer un seguimiento del número de entradas. El problema básico del hashing lineal es que cualesquiera dos claves con el mismo valor hash. La función para realizar el hashing doble esta en la figura 5. podríamos intentar resolver el problema de los agrupamientos primarios intentando 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. buscamos una secuencia fácil de calcular y lo suficientemente mezclada para evitar la formación de agrupaciones.24 donde h0(k) se implementa como INCR = 1 + clave mod (M . El resultado de aplicar el método a nuestro ejemplo puede verse en las tablas 5. aunque desde un punto de vista práctico. h0(kj) 10 0 1 2 3 4 5 6 7 8 9 10 11 12 147 148 141 38 2 4 1 1 Tabla 5. Este método. la secuencia de pruebas para el hashing viene determinada por: hi(k) = (hi-1(k) + h0(k)) mod M i=2. i clave 91 72 119 101 43 109 137 85 intentos 1 2 1 3 1 1 3 1 119 2 141 11 109 5 147 4 38 12 137 7 148 5 101 10 12 38 . kj 85 43 72 91 h1(kj) 7 4 7 0 9 11 10 7 4 11 5 6 6 6 3 1 Tabla 5.7. En la primera se incluyen los valores de h0 para cada clave y en la segunda pueden verse las localizaciones finales de las claves en la tabla así como las pruebas requeridas para su inserción. 3.A primera vista.2). En particular si no se permite a total exceder a M-1. con la propiedad adicional obvia de que ha de poder acceder a todas las localizaciones de la tabla. donde h0 no debería ser cero y sería conveniente que fuera primo relativo con el tamaño de la tabla garantizando en acceso a todas las localizaciones.….7 y 5.8. no solventaría el problema de la formación de agrupaciones secundarias ( agrupaciones separadas por una distancia C). se garantiza que siempre habrá una localización vacía para forzar la terminación de bucle while en el caso de que una clave no esté en la tabla. utilizarían una secuencia idéntica de pruebas (como cuando caminamos en la playa sobre las huellas de otra persona). y lo ideal sería que tal secuencia de pruebas fuera verdaderamente aleatoria. al producirse una colisión usando nuestra habitual función hash. j = INCR (arg). 5. while ((tabla[i]. Si ki precede a cualquier otro valor kj en una secuencia de pruebas. no podemos eliminarlo sin más. ocupada o borrada. de forma que en lo que concierne a la búsqueda. L. Cuando intentamos borrar un valor ki de una tabla hash que ha sido generada por direccionamiento abierto.clave != 0) && (encontrado = 0)){ if (encontrado = 0) { if (total = M – 1) hash_doble = -1. Este proceso se suele denominar rehashing y es simple de implementar si el área de la nueva tabla es distinta al de la primitiva. podemos usar la primera localización vacía o borrada que se encuentre en la secuencia de pruebas para realizar la operación. } } } } Figura 5.4 Borrados y rehash ing. else { total = total +1. “Fundamentos de programación. nos encontramos con un problema. cuando su eficiencia baja demasiado debido a los borrado. el único recurso es llevarla a otra tabla de tamaño más apropiado. la nueva tabla podría ser mayor.” MacGraw Hill. tabla[i]. int hash_doble( int arg. Para la implementación de la idea anterior podría pensarse en la introducción de algoritmos de un valor etiqueta para marcar las casillas borradas. pero puede complicarse bastante si deseamos hacer un rehashing en la propia tabla. pero esto sería solo una solución parcial ya que quedaría el problema de si los borrados son frecuentes. o incluso del mismo tamaño que la original. 1990.clave = arg. Algoritmos y estructuras de datos.Algoritmos avanzados de ordenación y búsqueda.7. • [Joy90] Joyanes Aguilar. no necesariamente mayor. Observemos que este problema no afecta a los borrados de la lista en el encadenamiento separado. Cuando una tabla hash llega a un desbordamiento o. tabla_hash tabla){ int encontrado. ya que si lo hiciéramos las pruebas siguientes para kj se encontrarían el “agujero” dejado por ki con lo que podríamos concluir que kj no está en la tabla. nodo_hash total. 5. una celda borrada se trata exactamente igual que una ocupada. hecho que puede ser falso. puesto que como las localizaciones borradas no tienen porque reasignarse. La solución es que necesitamos mirar cada localización de la tabla hash como inmersa en uno de tres posibles estados: vacía. i = hash (arg). encontrado = 0. menor. i. En el caso de inserciones. Podemos comprobarlo en nuestro ejemplo en cualquiera de las tablas.24. j. las búsquedas sin éxito podrían requerir O(M) pruebas para detectar que un valor no está presente. 151 .8 Bibliografía. Función hash doble. P. Y Friedman. “Clasificación y búsqueda”. 1987. [AYM93] Aaron M. Ullman. G. 1987. “Data Structures and algorithms”. Reverté. D. Hopcroft. H.. “Data structures.. MacGraw Hill. [Sed87] Sedgawick. A. A. “Algorithms” Addison-Wesley. [Wir76] Wirth.. Tenenbaum. [SF90] Springer. Yedidyah Langsam. MacGraw Hill. F. .. “Problemas de metodología de la programación”. Prentice Hall.1983.”. “Algorithms + data structures = programs”. • • • • • • • 152 . 1990. Moshe A. [Knu87] Knuth D. 1993. “Then Art of Computer Programming.” Vol. Augenstein. [Knu73] Kunth D. 1990.A.Programación II • • [Joy90] Joyanes Aguilar. N. 1973. [Aho83] Aho. 3: Sorting and Searching. E. E. J. “Estructura de datos en C”. “Scheme and the art of programming”. Prentice Hall Hispanoamericana S. Addison-Wesley. J. 1987 [Smi87] Smith. Form and function. V. L. E. AddisonWesley. Harcourt Brace Jovanovich Publishers. R. 1976. 6. del diseño y de la codificación. Para llevar a cabo este objetivo. A continuación. hay una cosa que no puede hacer la prueba: la prueba no puede asegurar la ausencia de defectos. El principal objetivo del diseño de casos de prueba es obtener un conjunto de pruebas que tengan la mayor probabilidad de descubrir los efectos del software. Nuestro objetivo es diseñar pruebas que sistemáticamente saquen a la luz diferentes clases de errores. indican la calidad del software como un todo. La prueba del software es un elemento crítico para la garantía de calidad del software y representa una revisión final de las especificaciones. 3. El diseño de casos de prueba se centra en un conjunto de técnicas para la creación de casos de prueba que satisfagan los objetivos globales de la prueba. sólo puede demostrar que existen defectos en el software.Capítulo 6: Métodos de prueba del software. Los datos que se van recogiendo a medida que se lleva a cabo la prueba proporcionan una buena indicación de la fiabilidad del software y. 6. llega la prueba. La importancia de los “costes” asociados a un fallo está motivando la creación de pruebas minuciosas y bien planificadas. para probar que nuestro programa realiza lo esperado. La prueba es un proceso de ejecución de un programa con la intención de descubrir un error. 7 7 Diferentes ordenes a realizar. Si la prueba se lleva a cabo con éxito. Un buen caso de prueba es aquel que tiene una alta probabilidad de mostrar un error no descubierto hasta entonces. 2. de alguna manera. Sin embargo.1 Objetivos de la pru eba. el ingeniero intenta construir el software partiendo de un concepto abstracto y llegando a una implementación tangible. Como ventaja secundaria. haciéndolo con la menor cantidad de tiempo y de esfuerzo.1. que se puede ver como destructivo en lugar de constructivo. El ingeniero crea una serie de casos de prueba que intentan “demoler” el software construido. descubrirá errores en el software.1 FUNDAMENTOS DE LA PRUEBA DEL SOFTWARE. Una prueba tiene éxito si descubre un error no detectado hasta entonces. No es raro que una organización de desarrollo de software emplee entre el 30 y 40 por ciento del esfuerzo total de un proyecto en la prueba. . Durante las fases anteriores de definición y de desarrollo. Se pueden establecer varias normas que pueden servir acertadamente como objetivos de la prueba: 1. La prueba presenta una interesante contrariedad para el ingeniero de software. De hecho la prueba es uno de los pasos de la ingeniería del software. se usan dos categorías diferentes de técnicas de diseño de casos de prueba: prueba de caja blanca y prueba de caja negra. la prueba demuestra hasta qué punto el software parece funcionar de acuerdo con las especificaciones y parecen alcanzarse los requisitos de rendimiento. podemos aislar más rápidamente los problemas y llevar a cabo mejores pruebas de regresión”. puede ser útil a la hora de negociar con ellos. A medida que avanzan las pruebas desplazan su punto de mira en un intento de encontrar errores en grupo integrados de módulos y finalmente en el sistema entero. “ Cuanto mejor funcione. A veces los programadores están dispuestos a hacer cosas que faciliten el proceso de prueba y una lista de comprobación de posibles puntos de diseño. “ Controlando el ámbito de las pruebas. El principio de Pareto implica que al 80 por ciento de todos los errores descubiertos durante las pruebas surgen al hacer un seguimiento de sólo el 20 por ciento de todos los módulos del programa. “Cuanto mejor podamos controlar el software. No son posibles las pruebas exhaustivas. 8 Los siguientes párrafos son copyright 1994 de James Bach. James Bach 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 programa de computadora. Las pruebas deberían empezar por “lo pequeño” y progresar hacia “lo grande”. • • • Los desarrolladores de software experimentados dicen que “la prueba nunca termina. un sistema. Esto permite a los encargados de las pruebas diseñar casos de 8 pruebas más fácilmente. Para ser más efectivas. Las pruebas deberían planificarse mucho antes de que empiecen. El principio de Pareto es aplicable a la prueba del software. La siguiente lista de comprobación proporciona un conjunto de características que llevan a un software fácil de probar. Como la prueba es tan profundamente difícil. más se puede automatizar y optimizar. más eficientemente se puede probar”. 154 . características. Capacidad de descomposición. 6.3 Facilidad de prueb a.2 Principios de la pru eba. las pruebas deberían ser conducidas por un equipo independiente. “Lo que ves es lo que pruebas”. o un producto con la “facilidad de prueba” en mente.1. etc. Observabilidad.1.. Los principios básicos que guían las pruebas del software. simplemente se transfiere de usted al cliente. Las primeras pruebas planeadas y ejecutadas se centran generalmente en módulos individuales del programa. Controlabilidad. merece la pena saber qué se puede hacer para hacerlo más sencillo. • • • • Operatividad. y se han adaptado de su página de Internet.Programación II 6. Un ingeniero del software diseña un programa de computadora. según Davis [Dav95] serian: • • • A todas las pruebas se les debería poder hacer un seguimiento hasta los requisitos del cliente. El diseño de pruebas para el software puede requerir tanto esfuerzo como el propio diseño inicial del producto. debemos diseñar pruebas que tengan la mayor probabilidad de encontrar el mayor número de errores con la mínima cantidad de esfuerzo y tiempo posible. Una prueba de caja negra examina algunos aspectos del modelo fundamental del sistema sin tener en cuenta la estructura lógica interna del software. o sea. Incluso para pequeños programas. y al mismo tiempo buscando errores en cada función. Cualquier producto de ingeniería puede comprobarse de una de estas dos formas: 1. Todo lo que tenemos que hacer es definir todos los caminos lógicos. pero que tienen poca garantía de ser completos. menos interrupciones a las pruebas”. la prueba de caja negra se refiere a las pruebas que se llevan a cabo cobre la interfaz del software. la prueba exhaustiva presenta ciertos problemas logísticos. Recordando el objetivo de la prueba. Los ingenieros del software.2 DISEÑO DE CAS OS DE PRUEBA. “ Cuanto menos haya que probar. desarrollando casos de prueba que “parezcan adecuados”. generar casos de prueba que ejerciten exhaustivamente la lógica del programa. La prueba de caja blanca del software se basa en el minucioso examen de los detalles procedimentales. A primera vista parecería que una prueba de caja blanca muy profunda nos llevaría a tener “programas cien por cien correctos”. así como que la integridad de la información externa se mantiene. Kaner. más rápidamente podremos probarlo”.Métodos de prueba del software. más inteligentes serán las pruebas”. 4. prueba de caja blanca. los casos de prueba pretenden demostrar que las funciones del software son operativas. Desgraciadamente. se pueden desarrollar pruebas que aseguren que “todas las piezas encajan”. 155 . Una buena prueba tiene una alta probabilidad de encontrar un error. 6. conociendo la función específica para la que fue diseñado el producto. conociendo el funcionamiento del producto. Una buena prueba no debe ser redundante. 2. “ Cuanta más información tengamos. Estabilidad. Se comprueban los caminos lógicos del software proponiendo casos de prueba que ejerciten conjuntos específicos de condiciones y/o bucles. a menudo tratan la prueba como algo sin importancia. 3. desarrollar casos de prueba que los ejerciten y evaluar los resultados. Facilidad de compresión. 2. O sea. • • • Simplicidad. Falk y Nguyen [KFN93] sugieren los siguientes atributos de una “buena” prueba: 1. que la entrada se acepta de forma adecuada y que se produce un resultado correcto. “Cuanto menos cambios. Una buena prueba debería ser “la mejor de la cosecha”. el número de caminos lógicos posibles puede ser enorme. Una buena prueba no debería ser ni demasiado sencilla ni demasiado compleja. Cuando se considera el software de computadora. Los atributos sugeridos por James Bach los puede emplear el ingeniero del software para desarrollar una configuración del software que pueda probarse. El primer enfoque de prueba se denomina prueba de caja negra y el segundo. 156 . puede pasar por alto los tipos de errores que acabamos de señalar. 2. Como estableció Beizer: “Los errores se esconden en los rincones y se aglomeran en los límites”. La prueba de caja negra. lo que significa que nuestras suposiciones intuitivas sobre el flujo de control y los datos nos pueden llevar a tener errores de diseño que sólo se descubren cuando comienza la prueba del camino. de hecho. A menudo creemos que un camino lógico tiene pocas posibilidades de ejecutarse cuando. pero otros permanecerán sin detectar hasta que comience la prueba. mientras que el procesamiento de casos especiales tiende a caer en el caos. La prueba de caja blanca. Se puede elegir y ejercitar una serie de caminos lógicos importantes. 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 los casos de prueba que: 1. El flujo lógico de un programa a veces no es nada intuitivo. Se obtienen casos de prueba que aseguren que durante la prueba se han ejecutado por lo menos una vez todas las sentencias del programa y que se ejercitan todas las condiciones lógicas. Las pruebas de caja blanca se centran en la estructura de control del programa. Cuando se traduce un programa a código fuente en un lenguaje de programación. para llegar a un método que valide la interfaz del software y asegure selectivamente que el funcionamiento interno del software es correcto. Se pueden comprobar las estructuras de datos más importantes para su validez. Los errores tienden a introducirse en nuestro trabajo cuando diseñamos e implementamos funciones. 6. condiciones o controles que se encuentran fuera de lo normal. ejerciten las estructuras de datos para asegurar su validez. se puede ejecutar de forma normal. es muy probable que se den algunos errores de escritura. ejecuten todos los bucles en sus límites y con sus límites operacionales. 3. Los errores tipográficos son aleatorios.Programación II La prueba de caja blanca. ¿Por qué emplear tiempo y energía preocupándose de (y probando) las minuciosidades lógicas cuando podríamos emplear mejor el esfuerzo asegurando que se han alcanzado los requisitos del 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 la probabilidad de que se ejecute un camino del programa. ejerciten todas las decisiones lógicas en sus vertientes verdadera y falsa.3 PRUEBA DE CAJ A BLANCA. Es mucho más fácil descubrirlos con la prueba de caja blanca. garanticen que se ejercita por lo menos una vez todos los caminos independientes de cada módulo. sin tener en cuenta cómo sea de completa. • • Cada una de las razones nos da un argumento para llevar a cabo las pruebas de caja blanca. Se pueden combinar los atributos de la prueba de caja blanca sí como los de caja negra. El procedimiento habitual tiende a hacerse más comprensible. no se debe desechar como impracticable. Muchos serán descubiertos por los mecanismos de comprobación de sintaxis. y 4. Las áreas delimitadas por aristas y nodos se denominan regiones. representa una o más sentencias procedimentales. 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 de un conjunto básico de caminos de ejecución. consideremos la representación del diseño procedimental en la figura 6. denominado nodo del grafo de flujo.Métodos de prueba del software. Cada construcción estructurada tiene su correspondiente símbolo en el grajo de flujo. representan flujos de control y son análogas a las flechas del diagrama de flujo. En la figura 6. Se puede observar que se han numerado las sentencias algorítmicas y que en el grafo de flujo se usa la misma numeración. Cuando en un diseño procedimental se encuentran condiciones compuestas la generación del grafo de flujo se hace un poco más complicada.3a. Un solo nodo puede corresponder a una secuencia de cuadros de proceso y a un rombo de decisión. Una condición compuesta se da cuando aparecen uno o más operadores lógicos (or.3. incluso aunque el nodo no represente ninguna sentencia procedimental.1 Prueba del camino básico. Los casos de prueba obtenidos del conjunto básico garantizan que durante la prueba se ejecuta por lo menos una vez cada sentencia del programa. Cuando contabilizamos las regiones incluimos el área exterior del grafo contando como otra región más.4 el segmento en algorítmico se traduce en un grafo de flujo anexo. El grafo de flujo presenta el flujo de control lógico mediante la notación ilustrada en la figura 6.5 se muestra un segmento de código en algorítmica y su correspondiente grafo de flujo.1. Las flechas del grafo de flujo. denominadas aristas o enlaces. Para ilustrar el uso de un grafo de flujo. Cada nodo que contiene una condición se denomina nodo predicado y está caracterizado porque dos o más aristas emergen de él. and) en una sentencia condicional. En la figura 6. 6. %XFOH +DVWD.3b. cada círculo. Notación de grafo de flujo. En la figura 6. Se crea un nodo aparte para cada una de las condiciones a y b de la sentencia SI a or b. Una arista debe terminar en un nodo. Cualquier representación del diseño procedimental se puede traducir a un grafo de flujo. 6HFXHQFLD &RQGLFLyQ .) %XFOH :KLOH. 6HOHFWLYD P~OWLSOH &DVH. &DGD FLUFXOR UHSUHVHQWD XQD R PiV VHQWHQFLDV VLQ ELIXUFDFLRQHV HQ FyGLJR IXHQWH 157 . 2. Notación para el grafo de flujo.I D 25 E WKHQ SURFHGLPLHQWR [ HOVH SURFHGLPLHQWR \ HQGLI < . D Figura 6.1. Lógica compuesta. Un camino independiente es cualquier camino del programa que introduce por lo menos un nuevo conjunto de sentencias de proceso o una nueva condición. % .3. Notación del grafo de flujo. 1RGRV SUHGLFDGR $    . 158 . Complejidad ciclomática El 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 de pruebas que se deben realizar para asegurar que se ejecuta cada sentencia al menos una vez. $ULVWDV    1RGRV             #   #  # #  5HJLRQHV  (a) (b) Figura 6.Programación II Figura 6. /*4*/ sino si campo 2 del registro = 0 /*5*/ entonces reiniciar contador. un conjunto de caminos independientes sería: Camino 1: 1-11 159 . para el grafo de flujo de la figura 6. un camino independiente está constituido por lo menos por una arista que no haya sido recorrida anteriormente a la definición del camino. Traducción de algorítmica a grafo de flujo. Procedimiento Ordenar inicio /*1*/ repite mientras queden registros leer registros /*2*/ si campo 1 de registro = 0 /*3*/ entonces procesar registro. Algoritmo. Por ejemplo. guardar en el archivo.5. /*7a*/ fin-si fin-si /*7b*/ fin-repite /*8*/ fin Figura 6.Métodos de prueba del software. incrementar contador. guardar bucle. /*6*/ sino procesar registro.3b. En términos del grafo de flujo.     D   E  Figura 6.4. 3 y 4 definidos anteriormente componen un conjunto básico para el grafo de flujo de la figura 6. Obtención de casos de prueba. consecuentemente. el valor de V(G) nos da un límite superior para el número de caminos independientes que componen el conjunto básico y. presentaremos la prueba del camino básico como una serie de pasos.Programación II 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. 160 . V(G).3b. la complejidad ciclomática del grafo de flujo de la figura 6. Los caminos 1. La complejidad ciclomática.6.3b. representado en algorítmica en la figura 6. O sea. Refiriéndonos de nuevo al grafo de flujo de la figura 6. 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. se pueden diseñar pruebas que habrá ejecutado al menos una vez cada sentencia del programa y que cada condición se habrá ejecutado en sus vertientes verdadera y falsa. V(G).3b es 4. la complejidad ciclomática se puede calcular mediante cualquiera de los anteriores algoritmos: El grafo de flujo tiene cuatro regiones. Más importante. En esta sección.2. como ejemplo para ilustrar todos los pasos del método de diseño de casos de prueba. 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. V(G)= 11 aristas – 9 nodos + 2 = 4 V(G)= 3 nodos predicado + 1 = 4 Por tanto. La complejidad se puede calcular de tres formas: El número de regiones del grafo de flujo coincide con la complejidad ciclomática. El camino 1-2-3-4-5-10-1-2-3-6-8-9-10-1-11 no se considera camino independiente. La complejidad ciclomática. un valor límite superior para el número de pruebas que se deben diseñar y ejecutar para garantizar que se cubren todas las sentencias del programa. La complejidad ciclomática está basada en la teoría de grafos y nos da una métrica del software extremadamente útil. Usaremos el procedimiento media. Usando el diseño o el código como base. i: real. suma: real.  si (valor[i]>mínimo) AND (valor[i]<=máximo)  entonces incrementar válido en 1. sino ignorar fin-si Incrementar i en 1. {Este procedimiento calcula la media de 100 o menos números que se encuentren entre unos límites.  suma = suma + valor[i]. válido: real.} valor: array (1. Determinamos la complejidad ciclomática del grafo de flujo resultante. i=1. 1.Métodos de prueba del software. también calcula el total de entradas y el total de números válidos.100) de reales. media. Procedimiento media. dibujamos el correspondiente grafo de flujo 2. mínimo.   fin-repetir  sino media = . Algoritmo para diseño de pruebas con nodos identificados. entrada. suma=o.999. máximo. Repita mientras valor[i] <>-999 and entrada<100    Incrementar entrada en 1.  fin-si  fin {media} Figura 6.  entrada = válido = 0. si total. 161 ..válido > 0  entonces media = suma/válido.6. ) que siguen a los caminos 4.. 5. En la figura 6. 3. Normalmente merece la pena identificar los nodos predicado para que sea más fácil obtener los casos de prueba..Programación II           Figura 6.. Camino 6: 1-2-3-4-5-6-7-8-9-2-... Determinamos un conjunto básico de caminos linealmente independientes : 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-. 162 . En este caso.7. 5 y 6 indican que cualquier camino del resto de la estructura de control es aceptable. Grafo de flujo del procedimiento media.. 9    9 Caminos que recorre nodos diferentes entre ellos. 6 y 10 son nodos predicado.7.. Los puntos suspensivos (. V(G)= 6 regiones V(G)= 17 aristas – 13 nodos + 2 =6 V(G)= 5 nodos predicado + 1 =6 3. los nodos 2.. Camino 5: 1-2-3-4-5-6-8-9-2-. 4. Una vez terminados todos los casos de prueba. • 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.Métodos de prueba del software. 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. 163 . estos caminos se han de probar como parte de otra prueba de camino. Algunos caminos independientes (por ejemplo: el camino 1 de nuestro ejemplo) no se pueden probar de forma aislada. En tales casos. Ejecutamos cada caso de prueba y comparamos los resultados obtenidos con los esperados. el responsable de la prueba podrá estar seguro de que todas las sentencias del programa se han ejecutado por lo menos una vez. para k ≤ i Resultados esperados: media correcta sobre los n valores y totales adecuados. 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. • Caso de prueba del camino 5: Valor (i) = entrada válida donde i<100 Valor (k)>máximo. 5 y 6. para k<i Resultados esperados: media correcta sobre los k valores y totales adecuados. donde k<i definida a continuación valor (i)= -999. debe ser probado como parte de las pruebas de los caminos 4. • Caso de prueba del camino 1: valor (k)=entrada válida. Preparamos los casos de prueba que forzarán la ejecución de cada camino del conjunto básico. • Caso de prueba del camino 2: Valor (1)= -999 Resultados esperados: media = -999.  $   * ( ) % '   Figura 6. 164 . la matriz de grafo se puede convertir en una potente herramienta para evaluación de la estructura de control del programa durante la prueba. Se sitúa una entrada en la matriz por cada conexión entre dos nodos. Matriz del grafo. Conectado al nodo 1 1 2 3 D 4 C 5 G E B F Nodo 2 3 A 4 5 Figura 6. mientras que cada arista lo está por su letra. Por ejemplo. 10 10 Coste que nos puede llevar a recorrer cada una de las aristas del grafo.8.9. En la figura. Añadiendo un peso de enlace a cada entrada de la matriz.Programación II Matrices de grafos El procedimiento para obtener el grafo de flujo e incluso la determinación de un conjunto de caminos básicos es susceptible de ser mecanizado. el nodo 3 está conectado con el nodo 4 por la arista b. Una matriz de grafo es una matriz cuadrada cuyo tamaño es igual al número de nodos del grafo de flujo. cada nodo del grafo de flujo está identificado por un número. Grafo de flujo. Cada fila y cada columna corresponde a un nodo específico y las entradas de la matriz corresponden a las conexiones (aristas) entre los nodos. 10. Los recursos requeridos durante el recorrido de un enlace. Los bucles son la piedra angular de la inmensa mayoría de los algoritmos implementados en software. La memoria requerida durante el recorrido de un enlace. Conectado al nodo 1 1 2 3 1 4 1 5 1 1 1 1 2–1=1 2–1=1 2–1=1 3+1=4 Nodo 2 3 1 4 5 conexiones 1–1=0 Complejidad ciclomática Figura 6. Para ilustrarlo. indicando la existencia de una conexión (se han excluido los ceros por claridad).Métodos de prueba del software. A los pesos de enlace se les puede asignar propiedades como: • • • • La probabilidad de que un enlace (arista) sea ejecutado. que indica la existencia de conexiones (0 o 1). Cuando se representan de esta forma la matriz se denomina matriz de conexiones. La prueba de bucles se centra en la validez de las construcciones de bucles. cada fila con dos o más entradas representa un nodo predicado.10. les prestamos normalmente poca atención cuando llevamos a cabo la prueba del software. Matriz de conexiones. Y. El tiempo de procesamiento asociado al recorrido de un enlace. usaremos la forma más simple de peso. Se pueden definir cuatro clases diferentes de bucles: 165 . los cálculos aritméticos que se muestran a la derecha de la matriz de conexiones nos dan otro nuevo método de determinación de la complejidad ciclomática. En la figura 6. Por tanto. Se ha reemplazado cada letra por un 1. sin embargo. Prueba de bucles. 11. Ej. 5.: contadores de bucles) de los bucles externos en sus valores mínimos. • Bucles concatenados. el número de posibles pruebas aumentaría geométricamente a medida que aumenta el nivel de anidamiento. Beizer [Bei90] sugiere un enfoque que ayuda a reducir el número de pruebas: 1. 2. • Bucles simples. 2. mientras se mantienen los parámetros de iteración (p. pero manteniendo todos los bucles externos en sus valores “típicos”. Hacer m pasos por el bucle con m< n. Continuar hasta que se hayan probado todos los bucles. Pasar una sola vez por el bucle. Esto llevaría a un número impracticable de pruebas. donde n es el número máximo de pasos permitidos por el bucle: 1. Los bucles concatenados se pueden probar mediante el enfoque anteriormente definido para los bucles simples. llevando a cabo pruebas para el siguiente bucle. 4. Comenzar por el bucle más interior. A los bucles simples se les debe aplicar el siguiente conjunto de pruebas. 3. Establecer o configurar los demás bucles con sus valores mínimos. mientras cada uno de los bucles sea independiente del resto. 4. 3. Pasar dos veces por el bucle. Son los que dentro no tienen mas bucles pudiendo tener uno o más instrucciones. Bucles.Programación II %XFOHV VLPSOHV %XFOHV $QLGDGRV %XFOHV FRQFDWHQDGRV %XFOHV QR HVWUXFWXUDGRV Figura 6. Si extendiéramos el enfoque de prueba de los bucles simples a los bucles anidados. Hacer n –1. Progresar hacia fuera. Añadir otras pruebas para valores fuera de rango o excluidos. n + 1 pasos por el bucle. 166 . Llevar a cabo las pruebas de bucles simples para el bucle más interior. Pasar por alto totalmente el bucle. • Bucles anidados. esta clase de bucles se deben rediseñar para que se ajusten a las construcciones de programación estructurada. 4. casos de prueba que nos dicen algo sobre la presencia o ausencia de claves de errores en lugar de errores asociados solamente con la prueba que estamos realizando. 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. Ya que la prueba de caja negra ignora intencionadamente la estructura de control. 2. A diferencia de la prueba de caja blanca. 3. errores de interfaz. 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 prueba que satisfacen los siguientes criterios [Mye79]: 1. • Bucles no estructurados. la prueba de caja negra tiende a aplicarse durante fases posteriores de la prueba. Siempre que sea posible. La prueba de caja negra no es una alternativa a las técnicas de prueba de caja blanca. centra su atención en el campo de la información. que se lleva a cabo previamente en el proceso de prueba. 6. reducen.4 PRUEBA DE CAJ A NEGRA. Más bien se trata de un enfoque complementario. La prueba de caja negra intenta encontrar errores de las siguientes categorías: 1. errores en estructuras de datos o en accesos a bases de datos externas.Métodos de prueba del software. errores de rendimiento y 5. funciones incorrectas o ausentes. 167 . el número de casos de prueba adicionales que se deben diseñar para alcanzar una prueba razonable 2. en un coeficiente que es mayor que uno. errores de inicialización y de determinación. Programación II Las pruebas de caja negra son diseñadas para validar los requisitos funcionales sin fijarse en el funcionamiento interno de un programa. 2EMHWR  (QODFH QR GLULJLG R (QODFH GLULJ LGR 3HVR GH HQODFH . Las técnicas de prueba de caja negra se centran en el ámbito de información de un programa. Para llevar a cabo estos pasos. 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 los objetos y sus relaciones para descubrir los errores.4. 6.1 Métodos de prueba basados en grafos. de forma que se proporcione una cobertura completa de prueba. el ingeniero del software empieza creando un grafo: que representan las relaciones entre los objetos. pesos de nodos que describen las propiedades de un nodo y pesos de enlaces que describen alguna característica de un enlace. 2EMHWR  3HVR G HO QRG R YDORU. 2EMHWR  $ . (QODFHV SDUDOHORV 1XH YR DUFKLYR /D VHOHFFLyQ GHO PHQX JHQHUDO 9HQWDQD G H GRFXP HQWR 7LHPSR GH JHQHUDFLyQ   VHJ. 3HUPLWH OD HGLFLyQ GH 6H UHSUHVHQWD FRPR $WULEXWRV GLPHQVLyQ GH LQLFLR FRQILJXUDFLyQ R SUHIHUHQFLDV &RQWLHQH SRU GHIHFWR FRORU GH IRQGR EODQFR 7H[WR G HO GRFXP HQWR FRORU GHO WH[WR FRORU R SUHIHUHQFLDV SRU GHIHFWR % . consideremos una porción de un grafo de una aplicación de proceso de textos (figura 6. En la figura 6.12(a) se muestra una representación simbólica del grafo. Figura 6. Un enlace dirigido indica que una relación se mueve sólo en una dirección. (a) notación del grafo. Como ejemplo sencillo. (b) Sencillo ejemplo.12. Los nodos se representan como círculos conectados por enlaces que toman diferentes formas.12(b)) donde: objeto #1 = selección en el menú archivo nuevo objeto #2 = ventana del documento 168 . Un enlace bidireccional implica que la relación se aplica en ambos sentidos. Los enlaces paralelos se usan cuando se establecen diferentes relaciones entre los nodos del grafo. 2.Métodos de prueba del software. 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. Los nodos son objetos de datos y los enlaces son las transformaciones que ocurren para convertir un objeto de datos en otro. un conjunto de valores relacionados o una condición lógica. Un caso de prueba ideal descubre de forma inmediata una clase de errores que de otro modo. objeto #3 = texto del documento Como se muestra en la figura. Una clase de equivalencia representa un conjunto de estados válidos o no válidos para condiciones de entrada. 169 .2 Partición equivalen te. Si una condición de entrada especifica un rango. transitivas y reflexivas. 6. reduciendo así el número total de casos de prueba que hay que desarrollar. Modelación de estado finito. Se pueden definir de acuerdo con las siguientes directrices: 1. Los nodos son objetos de programa y los enlaces son las conexiones secuenciales entre esos objetos. Modelación de planificación. Beizer [Bei95] describe un número de métodos de prueba de comportamiento que pueden hacer uso de los grafos: Modelación del flujo de transacción. Los nodos representan diferentes estados del software observables por el usuario y los enlaces representan las transiciones que ocurren para moverse de estado a estado. Los nodos representan los pasos de alguna transacción y los enlaces representan las conexiones lógicas entre los pasos. El peso del nodo de ventana de documento proporciona una lista de los atributos de la ventana que se esperan cuando se genera una ventana.0 segundos. un rango de valores. Si un conjunto de objetos puede unirse por medio de relaciones simétricas. una selección del menú en archivo nuevo genera una ventana de documento. Si una condición de entrada requiere un valor específico. entonces existe una clase de equivalencia [BEI]. En realidad. y los enlaces paralelos indican las relaciones entre la ventana del documento y el texto del documento. se debería generar un grafo bastante más detallado como prepursor al diseño de casos de prueba. Una condición de entrada es un valor numérico específico.4. El ingeniero del software obtiene entonces casos de prueba atravesando el grafo y cubriendo cada una de las relaciones mostradas. se define una clase de equivalencia válida y dos no válidas. El peso del enlace indica que la ventana se tiene que generar en menos de 1. La partición equivalente se dirige a la definición de casos de prueba que descubran clases de errores. Modelación del flujo de datos. se define una clase de equivalencia válida y dos no válidas. Estos casos de prueba están diseñados para intentar encontrar errores en alguna de las relaciones. requerirían la ejecución de muchos casos antes de detectar el error genérico. Un enlace no dirigido establece una relación simétrica entre selección en el menú archivo nuevo y texto del documento. con excepciones específicas. Prefijo: Sufijo: Contraseña: condición de entrada. “depositar”. Orden: condición de entrada. condición de entrada. etc.4. rango –valor especificado >200 sin dígitos 0. Si una condición de entrada especifica un miembro de un conjunto. dar su contraseña de 6 dígitos y continuar con una serie de ordenes clave que desencadenarían varias funciones bancarias. 170 . valor –cadena de seis caracteres. 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. conjunto –contenido en las ordenes listadas anteriormente. En lugar de seleccionar cualquier elemento de una clase de equivalencia. condición de entrada. Contraseña: Valor alfanumérico de seis dígitos. se pueden desarrollar casos de prueba para cada elemento de datos del campo de entrada. condición de entrada. consideremos los datos contenidos en una aplicación de automoción bancaria. lógica –el código de área puede estar no presente. El análisis de valores límite es una técnica de diseño de casos de prueba que complementa a la partición equivalente. condición de entrada. En lugar de centrarse solamente en las condiciones de entrada. Aplicando las directrices para la obtención de clases de equivalencia. Las condiciones de entrada asociadas con cada elemento de la aplicación bancaria se pueden especificar como: Código de área: condición de entrada. lógica –la palabra clave puede estar o no presente. 6. “pagar facturas”. Prefijo: Número de tres dígitos que no comience por 0 o 1. El usuario puede “llamar” al banco usando su ordenador personal.Programación II 3. Cómo ejemplo. se define una clase de equivalencia válida y una no válida. Si una condición de entrada es lógica. 4. el AVL obtiene casos de prueba también para el campo de salida [Mye79]. Los casos de prueba se seleccionan de forma que se ejercite el mayor número de atributos de cada clase de equivalencia a la vez. el AVL lleva a la elección de casos de prueba en los “extremos” de la clase. se define una clase de equivalencia válida y una no válida. valor –longitud de cuatro dígitos. rango –valores definidos entre 200 y 999. Ordenes: “Comprobar”. Sufijo: Número de cuatro dígitos.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”. Si una condición de entrada especifica un número de valores. tendríamos que probar por ejemplo con las siguientes contraseñas: abcde. Si el error se encuentra en la especificación a partir de la cual se han desarrollado todas las versiones. Si las salidas producidas por las distintas versiones son idénticas. Por ejemplo supongamos que se requiere una tabla de “temperatura/presión” como salida de un programa de análisis de ingeniería. el 200. abcdefghij. abcd. el –1. Si una condición de entrada especifica un rango delimitado por los valores a y b.4. para asegurar que todas proporcionan una salida idéntica. se deben desarrollar casos de prueba que ejerciten los valores máximo y mínimo. 4. Aplicar las directrices 1 y 2 a las condiciones de salida. Hay situaciones en las que la fiabilidad del software es algo absolutamente crítico. tendríamos que probar a introducir los siguientes valores: el 0. Se deben diseñar casos de prueba que creen un informe de salida que produzca el máximo (y el mínimo) número permitido de entradas en la tabla.4 Prueba de compara ción. Cuando se han producido múltiples implementaciones de la misma especificación. Las directrices de AVL son similares en muchos aspectos a las que proporciona la partición equivalente: 1. una aplicación que nos pide una contraseña. debiéndonos de detectar error en los dos últimos. La prueba de comparación no es infalible. se investigan todas las aplicaciones para determinar el defecto responsable de la diferencia en una o más versiones. Si la salida es diferente. Si las estructuras de datos internas tienen límites preestablecidos (por ejemplo: un vector que tenga un límite definido de 100 entradas) hay que asegurarse de diseñar un caso de prueba que ejercite la estructura de datos en sus límites. lo más probable es que todas las versiones reflejen ese error. se asume que todas las implementaciones son correctas. 171 . si todas las versiones independientes producen unos resultados idénticos. y el 201. Por ejemplo. 2. se deben diseñar casos de prueba para los valores a y b y para los valores justo por debajo y justo por encima de a y b. También se deben probar los valores justo por encima y justo por debajo del máximo y del mínimo. 3. la prueba de comparación no detectará el error. pero erróneos. Además. respectivamente. a menudo se utiliza hardware y software redundante para minimizar la posibilidad de error. abcdefghijk. a cada versión del software se le proporciona como entrada los caos de prueba diseñados mediante alguna otra técnica de caja negra. En ese tipo de aplicaciones. Cuando se desarrolla software redundante. 6.Métodos de prueba del software. debiendo de detectar errores en los dos últimos casos. usando las mismas especificaciones se deben probar todas las versiones con los mismos datos de prueba. Por ejemplo un programa que pida la edad de una persona y suponiendo que esta está entre 0 y 200. la cual debe de tener más de cinco caracteres y menos de diez. J. Software Defect Removal. 329-408. pp. Wellesley.. <a Software Complexity Measure”. Van Nostrand Reinhold. 201 Principles of Software Development. 1993. vol. pp. 1984. nº 8. JENSEN y C.J. [Mye79] MYERS. junio 1989. 1992. 14. mayo 1980. McGraw-Hill. [Mcc76] MCCABE. Foftware Engineering.A. 1979.. 1981. 1979. KNIGHT. pp. 1995. vol. “ The Consistent Comparison Problem in N-Version Software”. R. [KFN93] KANER. Prentice-Hall.C. Octubre 1987.).C. [Dav85] DAVIS.G. FALK. [Jon81] JONES.C. QED Information Sciences. A. [Ntn88] NTAFOS.S. Client/Server Architectures. IEEE Trans. • • • • • • • • • • • • • • • • • [Tai89] TAI. McGraw-Hill.. Wiley. Software Engineering. “A Domain Strategie for Program Testing”. IDG Books.. “ Testing Software Using Multiple Version”. D. vol. Prentice-Hall.. Programming Productivity: Issues for the 80’s. 16. Ammann.J y E.. y N. Black-Box Testing. 2ª edición. [How82] HOWDEN. C. agosto 1993. vol. W.. 1984. vol.. “ Weak Mutation Testing and the Completeness of Test Cases”. L.. nº 2. WEYUKER. abril. pp 868-874. P. pp. nº 6. Ma.. 1984. 2. ACM Software Engineering Notes. [Bei95] BEIZER. nº 5. vol. 172 . pp 371-379. Expresions”. IEE Trans Software.).. [Ber92] BERSON.Programación II 6. Reston VA. pp 120-125.. abril. pp 247-257. JENSEN y C. nº 4. IEEE Trans. Ther Art of Software Testing. Foftware engineering. segunda edición. K. • • • • [Bei90] BEIZER. TONIES (eds. [WC80] WHITE. A.G. B. “An Experimental Comparison of tghe Effectiveness of Branch Testing and Data Flow”. NGUYEN.SE-8. pp. SU. IEEE rans. ACM Software Engineering Notes. C.Q. K. [Fos84] FOSTER. diciembre 1976. R. pp. T. 1993. 329-408.. y H. 1984.. junio 1988. [Kni89] KNIGHT.. “Sensitive Test Data for Boolean Expressions”. [Dun84] DUNN. “Verification and Validation”. IEEE Trans. y p. vol. “ A comparison of Some Structural Testing Strategies”.K. TONIES (eds.. 1989..5 Bibliografía. [TS87] TAI. S. in Software Engineering. [Bri87] BRILLANT. Software Testing Tecyhniques.. ACM Software Engineering. S. 58-61. 19. K. 278-283. J. Van Nostrand Reinhold. [Deu79] DEUSTH.E. T. Testing Computer Software. G.C. eport nº 89029N. Inc. Software Productivity Consortium. 9 nº 2. Client/Server Strategies. SE-6. R. The Complete Guide to Software Testing.. “Test Generation for Boolean COMPSAC’87. [Was93] WASKEVITCH. 770-787. 308-320. J. 1979.I. McGraw-Hill. 1990. Wiley. M. Proc. [Het84] HETZEL. COHEN. Y E. B. Software Engineering. julio 1982. IEEE Vomputer Society Press. LEVENSON. W. y H. “What to Do Beyond Branch Testing”. [Fra88] FRANKL. Capítulo 7: Programación orientada a objetos 7.1 Paradigmas de la programación Observando la historia de la disciplina de la Programación. La evolución de los lenguajes imperativos está claramente influenciada por la arquitectura Von Neuman. Los lenguajes declarativos más significativos son los lenguajes funcionales y lógicos. A continuación detallaremos en qué consisten estas filosofías de la programación indicando cuales son sus características y aportaciones. Esta exposición nos permite una mayor explicitación de los contenidos de la programación enriqueciendo la visión de la disciplina. de manera que para resolver el problema se descompone una determinada acción compleja en términos de un número de acciones más simples.1. Aunque estos dos grupos de lenguajes son suficientemente amplios. asignación. Programación Lógica. puede contruirse cualquier programa utilizando exclusivamente las estructuras de control mencionadas anteriormente. o bien no encajan en esta clasificación. En contraposición a los lenguajes imperativos surgen los lenguajes declarativos con la intención de distanciarse de la dependencia de la máquina que representa la arquitectura de Von Neuman. abstracción de datos. que el programador indique "qué" es lo que hay que resolver. Programación Funcional. aplicando los distintos tipos de abstracción: abstracción procedural. Programación Orientada a Objetos. Programación Declarativa. hay ciertos tipos de lenguajes que. estos lenguajes propician dos estilos de programación alternativos: Programación Imperativa. A lo largo de la descomposición se desciende a través de diversos niveles de abstracción. El uso de recursos abstractos en la programación estructurada. que podrían ser ejecutadas por una máquina abstracta. etc. 7. La idea de los lenguajes declarativos es que el programador especifique el problema. se vislumbran de forma clara dos tipos de lenguajes: lenguajes imperativos y lenguajes declarativos. La abstracción sobre la arquitectura de Von Neuman da lugar en los lenguajes imperativos a los conceptos de variable. Son pues dos estilos distintos de entender la programación. y así sucesivamente se siguen descomponiendo las acciones en otras más simples hasta que en un determinado nivel de descomposición las subacciones obtenidas constituyan instrucciones para el ordenador actualmente disponible. Las tres características que presentan este tipo de lenguajes son: la expresividad. La Programación Estructurada se caracteriza por el uso en el diseño de programas de tres 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. y ser matemáticamente elegantes. El uso de estas estructuras para el diseño de programas esta respaldado por el teorema de Böhm y Jacopini. la fiabilidad. combinando los efectos de las instrucciones individuales en un programa para alcanzar los resultados deseados. alrededor de los cuales se han desarrollado las filosofías de programación más ampliamente utilizadas: Programación Estructurada. Según este teorema. y de los conceptos asociados: variable. selectiva y repetitiva. como es el caso de los lenguajes de cuarta generación. consiste en no tener en cuenta los recursos concretos de que se dispone (la máquina donde se va a ejecutar y el lenguaje de programación que se va a utilizar). en contraposición a los lenguajes imperativos en los cuales se indica "cómo" resolver el problema indicando los pasos a seguir. es decir.1 Programación estr ucturada Durante los años 70-80 el pilar de la metodología de la programación fue la Programación Estructurada. operación de asignación e iteración. como es el caso de los de flujo de datos y de los lenguajes orientados a objetos que pueden ser tanto imperativos como declarativos. o bien se encuentran en medio de dos clases. . La unidad de trabajo de un programa escrito en uno de estos lenguajes es la instrucción. El diseño se basa pues en la realización de diferentes niveles. Una de las principales aportaciones de la programación estructurada es el uso de recursos abstractos como la abstracción de datos y procedural en el diseño de un programa. principios estos que deben estar siempre presentes en la programación moderna. La idea de reusabilidad está presente desde los primeros lenguajes de programación. Esta metodología ha influido en otras formas de programación como la Programación Modular y se sigue aplicando en el diseño de programas aún en nuestros días. ocultamiento de la información y modularidad. Para obtener reusabilidad se han utilizado varias técnicas como subprogramas. produce programas más claros y legibles. La Programación Orientada a Objetos surge como una abstracción de la Programación Estructurada para resolver algunos problemas que se encuentran en el diseño tradicional de programas. por último. como la dificultad de mantenimiento. Son muchas las aportaciones de la programación estructurada para el diseño de programas. 7. Por otro lado. El estilo de diseño y los principios surgidos con la programación estructurada siguen teniendo vigencia hoy en día en el diseño de programas. la escasa facilidad para reutilizar programas. resulta difícil con las herramientas de que dispone construir funciones que tengan aplicación en distintos proyectos. la aparición de la programación estructurada ha supuesto un impacto incalculable en la mejora de la enseñanza de la programación. sobrecarga y genericidad. y todas ellas han tenido una influencia sin igual en la evolución de la disciplina de la Programación. entre otras razones por permitir discutir y comparar programas. luego no pueden describir una jerarquía completa de representaciones con diferentes niveles de parametrización.2 Programación orie ntada a objetos El 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. suponen los principios de la programación moderna. Con la sobrecarga y genericidad el programador puede escribir el mismo código y utilizarlo de diferente forma dependiendo de las instanciaciones. así como el principio de ocultamiento de información que es fundamental para el desarrollo de una modularidad efectiva y para el diseño de abstracciones de datos. Una de las principales aportaciones de la programación estructurada es el facilitar el nacimiento de una potente metodología: la metodología descendente. gracias a la metodología descendente. Además con la programación estructurada se aportó la posibilidad de demostrar formalmente si un programa era o no correcto. Ambos tipos de abstracciones constituyeron un potente punto de partida para el diseño modular. y su evolución ha supuesto el desarrollo de otro estilo de programación la "Programación Orientada a Objetos". La Programación Orientada a Objetos está basada en el mecanismo de clasificación y 174 . el primer nivel resuelve el problema y el segundo y sucesivos niveles son refinamientos sucesivos del primero. mientras que sus instancias son utilizables pero no modificables. Aunque la Programación Estructurada pone especial interés en esto último. lo cual permite programas más fáciles de verificar y mantener.Programación II La metodología descendente consiste en establecer la solución de un problema mediante refinamientos sucesivos (step-wise) descomponiendo el problema en etapas o estructuras jerárquicas. pero estas técnicas no son lo suficientemente flexibles ya que los módulos genéricos son parametrizables y abiertos pero no son directamente utilizables. el alejamiento a la hora de representar el modelo real y. lo que los caracteriza es la forma de manejar la información. El paradigma Orientado a Objetos proporciona mecanismos para la consecución de la deseada reusabilidad.1. y en todos ellos se sigue siempre el uso de recursos abstractos. El paradigma Orientado a Objetos es un paradigma de clasificación. en contra de lo que inicialmente se pensó por el hecho de que los objetos necesitarán comunicarse por el paso de mensajes. Estos tres principios: abstracción. La programación estructurada. pues no es suficiente con que un lenguaje soporte objetos como característica del lenguaje para ser un lenguaje orientado a objetos. abstracción de datos. persistencia y ocultamiento de la información. 7. en este caso únicamente se trata de un lenguaje basado en objetos. se llamará lenguaje orientado a objetos.1. Es decir. Observando estos conceptos es claro comprobar que la Programación Orientada a Objetos. El fundamento matemático de esta filosofía de programación tiene su origen en el problema de computabilidad de funciones matemáticas. En la resolución de problemas dentro de la Programación Orientada a Objetos se intenta hacer corresponder determinados objetos del dominio del problema con determinados objetos del dominio de la solución.Programación orientada a objetos organización de objetos. Una clase es un mecanismo que permite agrupar objetos con el mismo comportamiento a modo de plantilla. concurrencia. en un futuro la comunidad de programadores estará dividida en productores de clases y consumidores de clases. sólo siendo necesario especificar las diferencias entre el nuevo objeto y la clase. Para resolver los problemas de la programación tradicional. Esto permitirá que el programador concentre todos sus esfuerzos en la descripción de lo que se ha de computar. creando objetos que se forman sobre los atributos y las operaciones heredadas de una clase o de una subclase. Estas tres características son las propias de un lenguaje orientado a objetos. y vistos desde el interior presentan todos los mismos métodos y datos internos llamados variables de instancia. Ahora bien. en la medida en que hace uso para el diseño de los programas de los principios de abstracción. El uso de objetos.3 Programación func ional La Programación Funcional surgió como una alternativa a la programación imperativa. En consecuencia. clases. Un objeto es una entidad software que posee información y capacidad de procesamiento. en un lenguaje funcional no hay instrucciones. esta facilidad añade cierto grado de ineficiencia cuando el programa se implementa para máquinas con la arquitectura Von Neumann tradicional. está basada en los métodos de la programación tradicional. La inexistencia de la instrucción de asignación comporta que los usuarios no se tengan que preocupar del manejo de la memoria. alrededor de este paradigma aparecen otros conceptos como: ligadura o tiempo de ligadura. de manera que se acerque el dominio de la solución al dominio del problema. y. A parte de las tres características básicas de la Programación Orientada a Objetos. una clase es un conjunto de objetos que vistos desde el exterior presentan todos el mismo protocolo. concretamente. es decir. Dicha capacidad de procesamiento es implementada a través de métodos (procedimientos en la programación tradicional) y requerida a los objetos a través de mensajes. La herencia organiza las clases para describir dominios de aplicación. La programación Orientada a Objetos se verá con mas detalle posteriomente. en lugar de definir todas las características del nuevo objeto. si en dicho lenguaje cada objeto debe pertenecer a una clase el lenguaje se dirá basado en clases. La manifestación de estos conceptos en mayor o menor grado en los lenguajes orientados a objetos establece una clasificación de los mismos. en el llamado lambda cálculo. 175 . el paradigma Orientado a Objetos se basa en la aplicación práctica de tres características: objetos. Sin embargo. La esencia de esta filosofía es el uso de funciones para crear programas. y si además las clases soportan la herencia como mecanismo para compartir recursos en la jerarquía de clases. el concepto de objeto viene a sustituir al concepto de variable con la salvedad de la capacidad de procesamiento que posee el objeto. clases y herencia. La herencia es un mecanismo para modelar el comportamiento por modificación o composición. subclases y herencia es fundamental para la reutilización de componentes de software. de forma que según la filosofía de la Programación Orientada a Objetos. ocultamiento de la información y modularidad. en estos lenguajes el estilo de programación está dominado por la parte pura de los lenguajes funcionales. pueden ser el valor de una expresión o pueden ser almacenadas en una estructura de datos. el control (su forma de ejecución) acostumbra a ser trivial. A pesar de todo. es decir. existen lenguajes llamados funcionales que soportan algunas características procedimentales. de forma que este principio excluye los efectos laterales dentro de las expresiones. a diferencia de la mayoría de los lenguajes. y como consecuencia muchos de los beneficios del enfoque funcional se pierden al introducir estas características. en cambio. Algunas veces el término funcional se usa solamente para aquellos lenguajes con una total ausencia de las características procedimentales explícitas. Otro punto de interés en la programación funcional. La programación funcional pura se caracteriza por una propiedad fundamental: la transparencia referencial. Programar con relaciones es más flexible que hacerlo con funciones. 7. los lenguajes funcionales puros. de manera que el programador puede ampliar y definir su propio entorno añadiendo las primitivas necesarias. en lugar de hacerlo con funciones. en programación lógica tampoco encontraremos la asignación explícita. El lenguaje de programación lógica más conocido es PROLOG. lo que podríamos llamar su lógica (o sus principios de deducción) no está definida y. Las funciones en estos lenguajes son tratadas de la misma manera que cualquier otro valor. es decir. 176 . En general. los programas no tienen que definir que es un dato y que es un resultado. pero a diferencia de los lenguajes funcionales no puros. Lo que ocurre es que en los lenguajes tradicionales. pues las relaciones tratan argumentos y resultados uniformemente. En un entorno de programación funcional se proporcionarán la funciones más útiles a las que se llama funciones primitivas. Esta es una diferencia fundamental con los lenguajes imperativos que proporciona bastantes ventajas a la hora de escribir y corregir un programa. Esta propiedad consiste en que el valor de una función depende únicamente de los valores de sus argumentos. Standar ML. este es el caso de lenguajes como APL. es el trato proporcionado a las funciones. La ejecución de programas lógicos consiste en la demostración de hechos sobre las relaciones por medio de preguntas.4 Programación lógi ca La 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. o la mayoría de variantes de LISP. La Programación Lógica trata con relaciones (predicados) entre objetos (datos).1. estos añadidos no son características sacadas de los lenguajes imperativos. La esencia de la programación funcional es combinar funciones para producir otras más potentes. Sin embargo. introduciendo ciertos predicados extralógicos. Este lenguaje ha conseguido incrementar su eficiencia y salir así del terreno puramente experimental. normalmente una restricción del cálculo de predicados de primer orden. restringida a las cláusulas de Horn y su forma de ejecución es el principio de resolución. pueden ser pasadas como parámetros. y para ejecutarla se aplica a los datos de entrada (los argumentos o parámetros de la función) y se obtiene un resultado (el valor calculado de la función). Las relaciones serán especificadas con reglas y con hechos. En este lenguaje no hay tipificación y. cuando se habla de programación lógica se entiende que el lenguaje de programación será un lenguaje lógico en el sentido tradicional. La base de PROLOG es la lógica clausal.Programación II Un programa funcional es pues una función que se define por composición de funciones más simples. Así pues. 177 . • Extensibilidad: facilidad de adaptación. Además. En el resto de las secciones se ilustrarán los principales conceptos en la orientación a objetos y algunos de los lenguajes de programación orientada a objetos existentes. 7. 7. si no el principal. • Reutilización: los componentes software deben permitir ser utilizados en diferentes aplicaciones. en la construcción de software es la calidad de este aunque existen otros factores. ¿qué caracteriza a este estilo de programación? ¿qué factores motivan su aparición? Estos y otros aspectos serán tratados en este punto.1 Características del software Antes 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. • Compatibilidad: debe ofrecer facilidades para combinarse con otros elementos software. Un componente software que tenga todas estas características sería prácticamente perfecto. sin embargo queda fuera de las objetivos de este tema el aprendizaje de un lenguaje concreto. • Reparabilidad: debe permitir la detección y reparación de fallos o defectos. también ante entradas incorrectas. Los ejemplos de implementación (código) se ilustrarán utilizando C++. • Integridad: debe ofrecer mecanismos de protección contra modificaciones y accesos no autorizados. • Funcionalidad: debe ofrecer el máximo número de posibilidades. Sin embargo. • Eficiencia: debe ofrecer el máximo rendimiento con el menor consumo de recursos posible. del conocimiento expresado en él y de las inferencias lógicas realizadas internamente por el ordenador.2 El estilo orientad o a objetos Durante 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. • Oportunidad: debe ser acabado o lanzado al mercado en el momento adecuado y sin retrasos. • Facilidad de uso. • Economía: su coste no debe ser excesivo. Meyer en [Mey98] enumera las siguientes características que se desea tenga el software: • Corrección: debe cumplir las especificaciones. aunque en general no es nada sencillo conseguirlo. • Robustez: siempre reacciona adecuadamente. • Portabilidad: facilidad de adaptarse a cambios de entorno. en muchas ocasiones los factores anteriormente mencionados entran en conflicto y se debe buscar un compromiso entre ellos.Programación orientada a objetos La Programación Lógica es otra forma de entender la programación.2. Sin embargo. el software siempre debe ser correcto. • Verificabilidad: debe ofrecer facilidades para comprobar su funcionamiento. ya que no es aceptable que no cumpla los fines para los cuales ha sido diseñado y desarrollado. Uno de los principales objetivos. donde el diseño de un programa depende de la estrategia de resolución utilizada. etc. independientemente de la estructura interna de esta. uno de estos mecanismos y surgen como un producto de la evolución que va de los procedimientos. De esta forma se puede utilizar la cola con prioridad utilizando exclusivamente esas funciones o procedimientos. no la desarrollaban dos programadores en la mitad ([Bud94]). y normalmente por un único programador. Por último. Sin embargo. y se comenzaron a formas equipos de desarrollo. ¿qué ocurre si necesitamos otra pila más? . los módulos permiten establecer una parte pública y otra privada. Los procedimientos y funciones evitan que se duplique gran cantidad de código y proporcionan cierta capacidad de ocultación de información ya que se pueden utilizar sin necesidad de saber como están implementadas (solo es necesario conocer sus parámetros). reutilizar y mantener. 7. Así un software fiable y modular cumplirá sus especificaciones y dará siempre una respuesta adecuada. Posteriormente. Tendríamos que utilizar otro módulo ya que los módulos no ofrecen posibilidad de obtener diferentes instancias.Programación II Todos los factores de calidad del software mencionados no son igualmente importantes. Sin embargo. a los módulos. Para controlar esta complejidad se recurren a mecanismos de abstracción. Los sistemas software 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). aumentaron las expectativas de que tipo de problemas se podían resolver con un computador. Supongamos que necesitamos una cola con prioridad en la aplicación que desarrollamos. Este hecho hace que el software pueda ser difícil de depurar. Los usuarios pueden crear variables de un determinado tipo de datos y manejar sus datos mediante las operaciones que proporcionan. Los dos primeros se resumen en el término: fiabilidad. Sin embargo. Veamos un ejemplo para ilustrar lo anteriormente expuesto. El hecho anterior era debido a las interconexiones entre componentes software . Por otro lado. Los módulos nos permiten que la estructura interna que contiene a los elementos de la cola se sitúen en la parte privada del módulo y las funciones para manipular la cola en la parte pública. la extensibilidad y la reutilización se engloban en la modularidad. Además. Las técnicas orientadas a objetos son. Podemos utilizar por ejemplo un vector para implementarla y diseñamos un conjunto de funciones para obtener. con la aparición de los lenguajes de alto nivel.2 Mecanismos de ab stracción Veamos ahora como han aparecido históricamente diferentes técnicas para el desarrollo de software. Por otro lado. pasando por los tipos abstractos de datos para concluir finalmente con los objetos. se podrá adaptar a diferentes entornos y podrá servir como elemento para desarrollar otras aplicaciones. De esta forma proporciona un mecanismo de ocultación de información. robustez. introducir. extensibilidad y reutilización. o sea. de encapsulación y aislamiento de información de diseño e implementación. Algunos tienen una mayor trascendencia y son: corrección. Inicialmente la mayoría de los programas se desarrollaban en ensamblador. Esto provoca que una parte de un sistema software no pueda ser entendida de forma aislada. los tipos abstractos de datos se corresponden con un conjunto de datos (con unos valores permitidos) y un conjunto de operaciones primitivas que pueden ejecutarse sobre esos datos. 178 . Pronto se observó que el tamaño de los problemas superaban la capacidad de los programadores. no permite la creación de ejemplares. Esto impide accesos directos a los elementos y estructura de la pila. Sin embargo esto no garantiza que desde una parte del código se acceda directamente al vector sin pasar por las funciones diseñadas. extraer un elemento. La parte publica es accesible desde fuera del módulo y la privada solo desde dentro. Los módulos suelen ser una forma de implementación de los tipos abstractos de datos. entre otras cosas.2. la tarea realizada por un programador en una determinada cantidad de tiempo. 3 Descomposición fu ncional La base tradicional de las arquitecturas software son las funciones. En matemáticas la forma utilizada para explicar muchos temas sigue una estructura parecida a la descomposición funcional. Para conseguir programas orientados a objetos no basta con utilizar un determinado lenguaje para realizar la codificación. Se establece demasiado pronto el orden exacto en que se producirán las diferentes operaciones sin ser necesario. La interfaz de usuario constituye muchas veces la estructura del sistema. etc. una forma de ver el mundo ([Bud94]). Veremos posteriormente como la orientación a objetos nos proporciona mecanismos adicionales a los que proporcionan los tipos de datos abstractos. Se establece la función que representa al sistema que se desea implementar y por refinamiento sucesivo se va reduciendo el nivel de abstracción para ir obteniendo funciones mas sencillas. 7. Puede ser difícil encontrar la función que caracteriza todo el sistema. Este diseño descendente sirve muy bien para explicar un problema. el orden utilizado en la exposición no coincide con el orden en que se fueron desarrollando estos teoremas. Cuando necesitamos resolver algún problema 179 . Esto resta flexibilidad. Pensemos cómo se organiza la actividad cotidiana en el mundo real. Es. Este método de diseño presenta una serie de problemas: • • • • • Puede cambiar la función que caracteriza el sistema debido a un cambio en las especificaciones. Sin embargo. Si se utilizan los módulos como forma de implementación de los tipos de datos abstractos. El aspecto central en la computación no son las funciones sino los objetos. 7. Esta estructura puede cambiar afectando a todo el sistema. funcional. al igual que otros ya existentes: programación imperativa. se podrá proteger la parte del tipo de dato que no debe ser accesible directamente y forzar así el uso de las primitivas. En esta sección se pasará a introducir el enfoque orientado a objetos. Mediante este paradigma se pretende conseguir software más fiable y modular que con las técnicas existentes. Se suele dar una ordenación prematura de las operaciones. pero no es necesariamente la mejor forma de realizar su desarrollo. Un paradigma es elconjunto de teorías. estándares y métodos que representan una forma de organizar el conocimiento. Se van estableciendo teoremas que permiten llegar a otros teorema más complejos. ya que si nos permite crear instancias y disponer de un conjunto de primitivas para la manipulación. No se favorece la reutilización. por lo tanto.2. Exponer el diseño de un sistema de forma descendente suele ser adecuado para la comprensión de éste.2. La programación orientada a objetos constituye un paradigma. En caso contrario se podría acceder al tipo de dato directamente en caso de que se conozca su estructura interna. pero no es la mejor forma de desarrollo. Como ya se ha mencionado la programación orientada a objetos implica una forma de percibir la realidad distinta a la usada en los métodos tradicionales.Programación orientada a objetos Otra solución posible sería utilizar un tipo de dato abstracto.4 Programación orie ntada a objetos En 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. Las funciones que cumplen algún fin específico para otra función de más alto nivel es poco probable que sirvan en otro contexto. y puede verse como el molde o modelo de los objetos. Es responsabilidad del agente resolver nuestra solicitud y no necesitamos saber cómo se hará. Todos los objetos de una clase usan el mismo método en respuesta a mensajes similares. ya que es responsabilidad de la persona que nos atendió que esto sea así. Es la clase de un objeto la que establece las características de este y a qué mensajes puede responder y de qué forma. sabemos que todo va a funcionar de manera muy parecida. Básicamente se crean una serie de objetos y se les van aplicando una serie de mensajes. Se crea una especie de universo con una serie de elementos (objetos) que tienen definidas las posibilidades de interacción estableciendose el comportamiento de cada uno de esos elementos. su estructura y tipo. el comportamiento de un objeto queda determinado por la clase a la que pertenece. 7. Esta relación permitirá que una clase hija herede el comportamiento de su clase padre. En la programación orientada a objetos la acción se produce por el paso de un mensaje a un objeto (agente). acabando la compra con la entrega del dinero. Normalmente no comprobamos que el periódico es del día actual y no el del día anterior. al igual que las posibilidades de interacción con otros objetos. Por lo tanto. Así los objetos tendrán estado y comportamiento que quedan determinados por la clase. las clases pueden verse como módulos y como tipos. Cada objeto es un ejemplar o instancia de una clase. Así . que podrá contener cada objeto. Además la parte más importante son los datos. existen diferencias importantes: el mensaje posee un receptor y la interpretación de este depende del receptor. las clases son las unidades de descomposición del software orientado a objetos. El efecto de un mensaje puede variar en función del receptor. Sabemos que todas las personas que tienen un kiosco nos atenderán de forma similar. y sabemos que información tenemos que darles para obtener el periódico. Las clases son por tanto un mecanismo de ocultación de información ya que establecen que parte de los objetos será accesible desde el exterior y que parte será privada. en caso de aceptar el mensaje.Programación II buscamos el agente adecuado y le solicitamos el servicio requerido. Aunque se pueda ver el paso de mensajes como una llamada a una función. Así. Volviendo al ejemplo anterior. Un ejemplo lo encontramos en el hecho de comprar el periódico: buscamos un kiosco y le indicamos al encargado el nombre del periódico que deseamos y el nos lo da. Además. los objetos y no las funciones como en otras metodologías de programación. Una clase describe los objetos que son instancia de esa clase. que deseamos. Veamos un ejemplo de cómo se declara una clase en C++ 180 . La definición que se da en [Mey98] es : una clase es un tipo abstracto de datos con una implementación posiblemente parcial (parte la implementación puede delegarse en otras clases). revista. Entre las clases se podrá establecer una relación jerárquica de herencia que se detallará mas adelante. Establece los datos. la clase de un objeto determina los atributos de éste y además qué mensajes aceptará. La computación se lleva a cabo por interacción entre estos elementos. etc. El objeto receptor. si otro día nos dirigimos a otro kiosco y nos atiende una persona distinta. Así la computación puede verse como simulación.3 Clases El concepto de clase es el mas importante en la programación orientada a objetos. llevará a cabo la acción indicada. La clase determina la información de estado de sus instancias y su interfaz hacia el exterior. y no existe un programa principal como se conoce normalmente. y=b.int b) { x=a. 7. int y. 181 . que son las coordenadas. No hay que olvidar que en cierta manera el comportamiento de un objeto también está condicionado por su estado ya que el tipo de respuesta a un mensaje puede depender del estado actual. p. El comportamiento está determinado por la clase a la que pertenece y es común a todas las instancias de esa clase. Se observa como un objeto de la clase Punto2D tendrá dos atributos o variables de instancia. son objetos distintos.Programación orientada a objetos class Punto2D { // Parte privada private: int x. // Parte pública public: int obtener_x() { return x. Cada objeto tiene una identidad única. El acceso a atributos y la llamada a métodos de un objeto en C++ es similar al acceso a los campos de un registro Punto2D p. Sin embargo los valores que establecen el estado de cada objeto en general es diferente.x=4. } }. //p identifica a un objeto de la Punto2D // error.init(4. Aunque se conozca la estructura interior y privada de un objeto. Punto2D p.5). este solo permitirá el acceso a su parte pública. p. Aunque dos objetos tengan los mismos valores para todos sus atributos. este atributo es privado El ejemplo anterior produciría un error ya que el atributo es privado y por tanto no puede accederse a él directamente. } int obtener_y() { return y. independientemente del valor de sus atributos o información de estado. //inicializa los atributos del objeto Gracias a que puede controlarse todo el acceso a la información de estado de un objeto se puede así asegurar la integridad de esta. } void init(int a. Un objeto es una combinación de estado y comportamiento.4 Objetos Un objeto es una instancia de una clase durante la ejecución. Estos atributos son privados no accesibles desde el exterior. En otros lenguajes como Object Pascal sólo existen objetos dinámicos. //se inicializa el objeto } Una vez tratada la cuestión del momento de la creación y lugar de almacenaje de los objetos se pasará a la inicialización. La creación de un objeto involucra una serie de cuestiones tales como cuándo se crea y dónde se almacena. Un constructor es un método con el mismo nombre de la clase (con o sin argumentos) que se llama automáticamente durante la creación. Punto2D *p2. //error. no se ha reservado espacio. { // Inicio de un bloque Punto2D p. inicialización y la destrucción. tal y como se encuentra especificado en la declaración de su clase. Existen lenguajes como C++ en los que podemos tener objetos que se crean de forma automática y que se almacenen en la pila al entrar en el bloque donde se declaran y objetos que se crean de forma dinámica en cualquier zona del código. Como se ha visto se puede crear uno o varios métodos que permitan la inicalización del estado del objeto.1: Acceso a un objeto Como ya se ha mencionado antes. El almacenamiento de estos últimos se realiza en el heap.Programación II Objeto Interfaz Acceso no autorizado !!! Figura 7.4).6). sin embargo. Existen. Sin embargo. las operaciones que podemos realizar con un objeto son las que indican los mensajes que acepta. 182 . p2=new Point2D() //se reserva espacio y se crea el objeto en el heap p2->init(5. Esto evita que utilicen objetos no correctamente inicializados. //se declara un puntero a un objeto Punto2D p2->init(3.2).init(1. algunos lenguajes proporcionan mecanismos para que esta inicialización se realice durante la creación. En C++ esto se realiza mediante los constructores. //se le reserva espacio automáticamente al entrar en el bloque p. una serie de operaciones comunes como son: creación. A continuación se muestra un ejemplo de creación de objetos en C++. Para el ejemplo que se esta tratanto. Los destructores en C++ son métodos que tienen el nombre de la clase precedido por el símbolo ~ y son llamados de forma automática cuando se destruye un objeto. Por último restaría tratar la destrucción de los objetos.. *p4. Los objetos almacenados en la pila son destruidos y su espacio liberado automáticamente. En otros lenguajes como Smalltalk y Java la liberación del espacio utilizado por un objeto se realiza de forma automáticamente mediante un recolector de basura. //se crea un punto con sus coordenadas inicializadas a 0 Point2D p1(3. En el ejemplo anterior se declaran dos constructores.. En este caso no sería necesario utilizar un destructor. Otros lenguajes como Object Pascal requieren la inicialización explícita. p3=new Point2D(2.. p4=new Point2D(). } Point2D() // Constructor { x=0. . se encarga de liberar la memoria utilizada por un objeto a partir de que este ya no pueda ser utilizado.int b) // Constructor { x=a. } .. C++ permite la sobrecarga de funciones...Programación orientada a objetos class Punto2D { // Parte privada private: int x. }. y=b. En cuanto a los objetos creados dinámicamente.3). La llamada al destructor se realizaría de forma automática para objetos estáticos y cuando se utiliza el operador delete para objetos dinámicos. algunos lenguajes como C++ requieren que el programador explícitamente provoque la liberación de memoria.. Este. int y.. no existe un constructor con un solo argumento Point2D *p3. y=0. uno que recibe dos argumentos y otro sin argumentos. pudiéndose así tener varios funciones con el mismo nombre y que se diferencien en la cantidad de argumentos y/o el tipo de estos...5) Point2D p2(5). ya sea dinámico o estático.. pero resulta 183 .. También dispone de la posibilidad de crear métodos que se llaman automáticamente al destruir el objeto: destructores.5). Point2D p. // se crea un punto (3. el destructor se declararía como: ~Point2D. // Parte pública public: Point2D(int a... de forma transparente al programador.. // error. ya que es necesario liberar explícitamente esta memoria. dependiendo del punto de vista utilizado.2: Herencia La herencia proporciona un mecanismo potente para la reutilización. Si disponemos de una clase para representar puntos en el espacio 3D con una serie de métodos tales como trasladar. la posibilidad de heredar de mas de una clase padre. Esta facilidad no la soportan todos los lenguajes orientados a objetos. Claramente un perro es una denominación mas específica de un animal que mamífero. el ahorro en la codificación no debe ser la principal razón para utilizar herencia. aunque se pudiera aprovechar toda la implementación de esta. de forma que estas se puedan organizar en una estructura jerárquica. El uso de la herencia normalmente implicará un ahorro de código importante. etc. Supongamos la clase que representa a los mamíferos. aunque la implementación de muchos de sus métodos sea igual. reflejar respecto a un eje. La herencia establece una relación entre las clases. Una clase hija puede verse como una especialización de la clase padre. o sea. una clase derivada es tanto una especialización como una extensión de la clase padre. De esta forma. Así además de utilizar las clases existentes podemos diseñar otras que se adapten mejor a nuestras necesidades derivándolas de las ya desarrolladas sin tener que diseñarlas partiendo desde cero. no tendría mucho sentido crear la clase circunferencia como hija de la anterior.5 Herencia La 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). Esto permitiría que una clase tuviese varias clases padre. Una forma habitual de ver la relación entre una clase padre A y una clase derivada B es como una relación “es-un” (B es un A). Por otro lado un perro tiene todas las características y comportamiento comunes de los mamíferos y además otras características y comportamiento adicionales específicos de los perros. La herencia a la que se ha hecho referencia hasta ahora se denomina herencia simple. Si se dispone de un conjunto de clases. Así. Sin embargo. 184 . la clase perro extiende a la clase mamífero. La clase perro podría derivarse de la clase anterior.Programación II fundamental cuando por el ejemplo un objeto reserva memoria dinámicamente para sus variables de instancia. debido a que se reutilizan las clases existentes. se pueden construir aplicaciones utilizando estas clases y otras nuevas derivadas. 7. Se debe tener en cuenta la relación entre las clases que se desea establecer una relación de herencia. Sin embargo también existe la herencia múltiple. Clase Clase derivada1 Clase derivada2 Figura 7. Conceptualmente una circunferencia no es un punto. lo que quiere decir que puede referenciar objetos de distintas clases. sino en tiempo de ejecución.6 Polimorfismo Polimorfismo se refiere a la posibilidad de adoptar varias formas. si disponemos de una clase A y una clase derivada B. Veamos otro ejemplo: supongamos que la plase perro es derivada de la clase mamífero.Programación orientada a objetos 7. puntero->asigna(). En lenguajes como Smalltalk sin comprobación de tipos. En el ejemplo anterior no se conoce en tiempo de compilación el objeto al que apunta la variable puntero. //apuntará a un objeto de la clase A o sus derivadas if (f==1) puntero=new A(). Así. Esto parece lógico. } }. podría ser tanto el método de la clase A como de la clase B. una variable declarada para designar un objeto de la clase A. Un identificador para objetos de la clase mamífero podrá referenciar objetos de la clase mamífero y también de la clase perro (un perro es un mamífero). else puntero=new B(). una variable puede denotar cualquier tipo de objeto. Por tanto. Esto no se conocerá hasta la ejecución del código. class B: public A //es una clase derivada de A { public: void asigna() { a=2. public: virtual void asigna() { a=1. A *puntero. Veamos un ejemplo: class A { private: int a. } }. ya que los objetos de la clase B a su vez son objetos de la clase A. también puede referenciar a un objeto de la clase B. Se produce por tanto una ligadura dinámica entre la llamada al método y el código a ejecutar. Esto permite que en tiempo de compilación no se necesite saber exactamente de qué clase será el objeto referenciado por un identificador. En C++ se fuerza la ligadura dinámica mediante la palabra reservada 185 . Cuando esto se hace en tiempo de compilación se denomina ligadura estática. aunque si se sabe que pertenecerá a la clase que aparece en la declaración del identificador o una de sus subclases (en los lenguajes con comprobación de tipos). Sin embargo un identificador para objetos de la clase perro no podrá referenciar objetos de la clase mamífero. Esto implica que puede haber llamadas a métodos o mensajes que no se puedan resolver en tiempo de compilación. en tiempo de compilación tampoco se puede conocer que método se debe ejecutar en la ultima sentencia. Esto permite que un identificador o variable pueda tomar varias formas. 7. Para solucionar este problema los lenguajes orientados a objetos suelen permitir declarar clases parametrizadas. pero por otro lado ofrece mucha flexibilidad.8. Este mecanismo favorece la reutilización de código. Soporta la recolección automática de basura. gráficos. Un ejemplo de sobrecarga de funciones puede observarse en la declaración de contructores en C++ ya que son funciones cn el mismo nombre.1 Simula Su diseño se completó en 1967. etc. Así se puede declarar una clase Lista genérica en la que no se especifica la clase de los objetos que contendrá. Imaginemos que disponemos de la clase Lista de enteros. Es una extensión orientada a objetos de Algol 60. 7. iconos. se indicará además la clase de los objetos de la lista.8 Lenguajes orient ados a objetos En esta sección se describirán brevemente las características de algunos de los lenguajes orientados a objetos existentes 7. Disfruto de éxito comercial a principios de los 90. tipos de datos básicos.2 SmallTalk Se 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. salvo por la clase de los objetos que contiene la lista. El éxito comercial no fue muy grande. Solo utiliza ligadura dinámica y no existe comprobación de tipos. La ligadura dinámica tiene un coste computacional en tiempo de ejecución. Permite con facilidad el prototipado. Otra característica importante es que las clases también son objetos y en general todo en el sistema Smalltalk es un objeto. En general un programa Algol correcto también lo será en Simula. sino mas bien a nivel intelectual. No tendría sentido por tanto duplicar la implementación y disponer de una clase distinta para cada clase de objeto que puede contener la lista. 7. Además soporta la recolección de basura. ésta tendría la misma estructura interna y el mismo comportamiento que la anterior. Mediante la sobrecarga de funciones y operadores se puede utilizar el mismo nombre de función o símbolo de operador para funciones con una implementación diferente. Si necesitamos la clase Lista de valores reales. etc. del que hereda las estructuras de control básicas. Su antecesor fue Simula I. Proporciona un entorno de programación que utiliza técnicas innovadoras para su época: múltiples ventanas. 186 . mucho antes de que apareciera el concepto de tipo abstracto de datos. En el momento de declarar una variable de la clase lista.7 Genericidad En 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.Programación II virtual. La sobrecarga de funciones podría considerarse también como un tipo de polimorfismo. un lenguaje para simulaciones de eventos discretos.8. El tipo y/o el número de argumentos servirá para distinguir que código se debe enlazar. uso del ratón. Normalmente un programa correcto en C lo será en C++. Permite la ligadura dinámica. El lenguaje utilizado es una extensión orientada a objetos de C. También ha sido ampliamente criticado por su excesiva complejidad y por su carácter híbrido ya que incluye al lenguaje C. Código fuente Java Bytecode Máquina virtual Plataforma 1 Máquina virtual Plataforma 2 Figura 7. lo cual no ha producido siempre el mejor resultado. Uno de sus principales inconvenientes es la eficiencia ya que la ejecución requiere la interpretación del bytecode por parte de la máquina virtual. los programas en bytecode pueden ser ejecutados en cualquier plataforma para la que se disponga de máquina virtual. Se pone énfasis en la ligadura dinámica y el polimorfismo. Otra diferencia con los lenguajes vistos es la comprobación estricta de tipos. pero utiliza la ligadura estática por defecto y no dispone de recolección de basura. Existen máquinas virtuales para muchas plataformas y además suele venir incorporada en los navegadores Web. por lo que la explosión de Internet a dado un gran impulso a Java.8. Ha tenido mucho menos éxito que C++. 7. El código Java es traducido a un bytecode (código con formato de bajo nivel. portable e interpretable) que es el ejecutado por la máquina virtual.5 Java Fue creado por un equipo de Sun Microsystems en 1996. manteniendo la compatibilidad con C.3 C++ Fue diseñado por Bjarne Strouptrup y nació alrededor del año 1986. debido a que se esperaba reciclar de forma rápida los programadores de C en programadores de C++.8. Su principal innovación es la tecnología de implementación.4 Objective-C Fue desarrollado por Brad Cox y es básicamente una capa con los conceptos de Smaltalk sobre una base de C. Ha tenido gran existo comercial.3: Ejecución de programas Java 187 . incluso inicialmente.Programación orientada a objetos 7. La ejecución de los programas Java se hace sobre una máquina virtual fácilmente disponible. 7. Así. Esto permite además su ejecución desde los navegadores. Esto hace de Java un lenguaje muy portable.8. Su objetivo era obtener los beneficios de la tecnología orientada a objetos. J. Un enfoque práctico usando C. Introducción a la Programación Orientada a Objetos.1998 188 . Addison-Wesley. Data structures and algorithms. Meyer. A. Estructuras de datos.1994 [FGG98] J. Construcción de Software Orientado a Objetos. Universidad de Granada. M.A. Prentice Hall.9 Bibliografía • • • • [AU87] A.Programación II 7. [Mey98] B. García. 1992. Budd. Aho. Garrido. Segunda Edición. Fernández.V. 1998. Addison-Wesley Iberoamericana. [Bud94] T. Ullman.
Copyright © 2024 DOKUMEN.SITE Inc.