Tipos de datos abstractos
Upcoming SlideShare
Loading in...5
×
 

Tipos de datos abstractos

on

  • 455 views

 

Statistics

Views

Total Views
455
Views on SlideShare
455
Embed Views
0

Actions

Likes
0
Downloads
10
Comments
0

0 Embeds 0

No embeds

Accessibility

Categories

Upload Details

Uploaded via as Adobe PDF

Usage Rights

© All Rights Reserved

Report content

Flagged as inappropriate Flag as inappropriate
Flag as inappropriate

Select your reason for flagging this presentation as inappropriate.

Cancel
  • Full Name Full Name Comment goes here.
    Are you sure you want to
    Your message goes here
    Processing…
Post Comment
Edit your comment

Tipos de datos abstractos Document Transcript

  • 1. Capítulo 4: Tipos de Datos Abstractos. 1.1 Introducción Proceso de Programación: Fase de modelación: se expresan ciertos aspectos de un problema a través de un modelo formal. En esta etapa, la solución del problema será un algoritmo expresado de manera muy informal. Fase TDA: Aplicando refinamientos por pasos llegamos a un programa en pseudolenguaje . Se crean los tipos de datos abstractos para cada tipo de datos (a excepción de los datos de tipo elemental). La solución al problema será un algoritmo en pseudolenguaje definido sobre tipos de datos abstractos y sus operaciones. Fase Estructuras de datos: Para cada tipo de datos abstracto se elija una representación y se reemplace por sentencias en C. El resultado será un programa ejecutable Modelo Matemático algoritmo Informal TDAs algoritmo pseudolenguaje Estructuras de datos Programa en C
  • 2. 1.2 Concepto de TDA Introducción al concepto de TDA Comparación Procedimiento-TDA: • Los procedimientos generalizan el concepto de operador. Evitan al programador limitarse a los operadores incorporados en un lenguaje de programación. El programador es libre de definir sus propios operadores y aplicarlos a operandos que no tienen por qué ser de tipo fundamental. Ej. : multiplicación de matrices. • Pueden utilizarse para encapsular partes de un algoritmo, localizando en una sección de un programa todas las sentencias que incumben a un aspecto del programa en concreto. Un ejemplo de encapsulación es el uso de un procedimiento para leer todas las entradas y verificar su validez. Definición TDA: Es un modelo matemático con una serie de operaciones definidas sobre ese modelo. Un ejemplo de TDA son los conjuntos de números enteros con las operaciones de unión, intersección y diferencia. Las operaciones de un TDA pueden tener como operandos no solo los del TDA que se define, sino también otros tipos de operandos, como enteros o de otros TDA, y el resultado de una operación puede no ser un caso de ese TDA. 2
  • 3. Las propiedades de generalización y encapsulación, son igualmente aplicables a los tipos de datos abstractos: • Los TDA son generalizaciones de los tipos de datos primitivos (enteros, caracteres,...), al igual que los procedimientos son generalizaciones de operaciones primitivas (suma, resta,...). • 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. De esta forma, si se desea cambiar la forma de implementar un TDA, se sabe hacia dónde dirigirse. Una vez definido un TDA, éste se puede utilizar como si fuese un tipo de dato primitivo, sin preocuparse por cual sea su implementación. Metodología para la definición de un TDA Definir el dominio del TDA en donde tomará valores una entidad que pertenezca al modelo matemático del TDA. • • Definir los efectos que producen en el dominio del TDA cada una de las operaciones definidas. Especificando sintácticamente las operaciones, indicando las reglas que hay que seguir para hacer referencia a una operación. Especificando semánticamente para conocer que significado o consecuencia tiene cada operación. 3
  • 4. Dominio de un TDA Formas para describir el dominio de un TDA: Si el dominio es finito y pequeño, éste puede ser enumerado. Por ejemplo, el dominio del tipo booleano es {true, false}. • • • Se puede hacer referencia a un dominio conocido de objetos matemáticos. Por ejemplo, el conjunto de los números negativos. Se puede definir constructivamente. Enumerando unos cuantos miembros básicos del dominio y proporcionando reglas para generar o construir los miembros restantes a partir de los enumerados. Ejemplo. Dominio de las cadenas de caracteres : 1. Cualquier letra es una cadena. 2. Cualquier cadena seguida de una letra es una cadena. Este tipo de definiciones se llama también definiciones recursivas, ya que hacen referencia a los elementos del dominio definido para crear nuevos elementos. Especificación sintáctica de un TDA Consiste en determinar como hay que escribir las operaciones de un TDA, dando el tipo de operandos y el resultado. 4
  • 5. El tipo entero se puede especificar sintácticamente, enumerando las siguientes operaciones: + : entero x entero entero - : entero x entero entero > : entero x entero boolean abs: entero entero Otra forma más relacionada con la forma de escribir en un lenguaje de programación es: int ‘+’ (int a,b) int ‘-‘ (int a,b) unsigned char ‘>’ (int a,b) int abs (int a) Tomamos esta última como notación para la especificación sintáctica de los TDA. La sintaxis de las operaciones vendrá descrita por las cabeceras de las funciones correspondientes a cada una de las operaciones. Especificación semántica de un TDA 1. Una forma de especificar el significado de las operaciones sería mediante el lenguaje natural. Sin embargo, el uso del lenguaje natural puede dar lugar a ambigüedades. 2. Notación algebraica: Consiste en dar un conjunto de axiomas verificados por las operaciones del TDA. 5
  • 6. Ejemplo: Definición del TDA sucesión de forma algebraica Sintaxis:• • Sucesión nula: sucesión ‘<>’() 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: sucesión ‘ο’ (sucesión a, b) Ultimo: D last (sucesión a) Cabecera: sucesión leader (sucesión a) Primero: D first (sucesión a) Cola: sucesión trailer (sucesión a) Longitud: int length (sucesión a) Semántica a) last (x ο <d>) = d b) leader (x ο <d>) = x c) x ο (y ο z) = (x ο y) ο z d)first (<d> ο x) = d e) trailer (<d> ο x) = x f) length (<>) = 0 g) length (x ο <d>) = 1 + length (x) h) <> ο x = x ο <> = x i) Si x ≠ <>, y ≠ <>, first (x) = first(y), trailer(x) = trailer(y), entonces x = y j) first (<>) = last (<>) = leader (<>) = trailer(<>) = ERROR 6
  • 7. En las definiciones axiomáticas no se dice nada acerca del dominio del TDA. Los objetos que pertenecen al dominio son los que se pueden obtener a través de las operaciones, cuyo resultado se determina a partir de los axiomas. Un elemento de este dominio tendría la forma: <d1> ο<d2>ο....ο<dn> que de forma abreviada escribiremos como <d1,d2, . . ,dn> 3. Modelos abstractos: Este método se basa en el hecho de que podemos describir el dominio de un tipo en términos de otro tipo y, entonces, usar las operaciones del segundo tipo para describir las operaciones del que estamos definiendo. Este tipo de especificación se conoce también como especificación operacional. Ejemplo: Tipo String. Podemos definir el tipo String como: {a| a=<a1, . . , an>, ai es carácter, n ≤ N} Especificación sintáctica y semántica Vamos a usar una sintaxis similar a la de Java 7
  • 8. 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). precondiciones y postcondiciones. 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. String concat (String a, b) pre a=<a1,a2, .. ,an>, b=<b1,b2, .. ,bm> length(a)+length(b)=n+m ≤ N post concat = <a1,a2, . . ,an,b1,b2, . . ,bm> String substr (String a,int f,t) pre a=<a1,a2, . . ,an> f ≥1,t ≤ length(a), f ≤ t post substr= <af, . . ,at> Bajo el cumplimiento de las precondiciones, se podrá ejecutar la operación y tras su ejecución se debe cumplir la postcondición. No se afirma nada en el caso de que no se cumplan las precondiciones, puede dar un resultado indeterminado o error. Lo ideal es que diese error advirtiéndose de esta forma el mal uso de la operación. Vamos a utilizar el nombre de la operación para denotar su resultado en la postcondición. 8
  • 9. Uso de los TDA en Programación La información, los datos con los que se puede trabajar en un lenguaje de programación están organizados por tipos. El diseñador del lenguaje en cuestión: Comunica estos tipos al usuario de forma abstracta: identificando los objetos y sus operaciones. Diseña un compilador en el que para cada tipo se determina como hay que organizar, interpretar y operar con la información representada en la memoria, de forma que se imite el comportamiento del tipo correspondiente. Con todo esto 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. El programador debe realizar una tarea similar a la que hemos descrito para el constructor del compilador. El proceso sería el siguiente: 1. Determinar, ante un problema, los objetos candidatos a tipos de datos, estén o no estén en el lenguaje de partida. 2. Identificar las operaciones básicas, primitivas entre dichos objetos. 3. Especificar dichas operaciones 9
  • 10. 4. Construir un programa, que usando estos tipos y estas operaciones resuelva el problema original. 5. Implementar los tipos y operaciones que no estén en el lenguaje de partida, con los elementos de dicho lenguaje 6. Determinar la eficiencia de la implementación. Con esta metodología se logra disminuir la complejidad inherente a la tarea de construir programas, separando dos problemas, 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. Implementar estos objetos en base a los elementos del lenguaje y razonar sobre ellos. Se debe llevar de forma estricta esta separación: construir el programa, no haciendo ninguna referencia a la implementación usada para los objetos diseñar una implementación para estos sin tener que conocer todos los detalles del problema en el que se vaya a usar. Esta separación nos permite especificar las operaciones y construir el programa original sin llegar a implementarlas hasta el final. 10
  • 11. Esto nos permite posponer la decisión acerca de la implementación final hasta una etapa posterior en el desarrollo de la solución, en la cual pueda resultar más fácil realizar la elección de la estructura de datos más adecuada. Esta separación en la construcción de los programas se conoce con el nombre de abstracción-realización. La abstracción consiste en la descripción de un determinado sistema, teniendo en cuenta solo las características importantes del mismo, y no considerando los detalles irrelevantes. La realización consiste en completar los detalles que antes se han dejado sin especificar. Para el caso que nos ocupa, la realización de un programa, en base a unos determinados TDA sería una descripción abstracta del mismo. La implementación de los TDA correspondería a la tarea de realización. La construcción de software siguiendo esta metodología nos va a permitir: 1. Construir la solución despreciando detalles de implementación. Esto nos permite obtener la solución en menos tiempo. Para obviar el tipo de implementación que se ha usado necesitamos realizar ocultamiento de la información. 11
  • 12. 2. Los TDA se pueden diseñar 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. De esta manera, disponemos de un nuevo tipo de dato para posibles problemas futuros. Para garantizar la reutilización de los nuevos tipos las operaciones que definamos sobre ellos deben ser completas, es decir, que nos permitan poder realizar sobre ellos todas las operaciones que en el futuro puedan ser necesarias. Sin exceder en el número de operaciones que necesitamos programar realmente. A veces añadir una nueva primitiva puede ser redundante pues tal vez sea posible programarla en base a las demás. Aunque en otras ocasiones puede ser interesante añadir una primitiva por cuestión de eficiencia, pues tal vez accediendo a la implementación de forma directa el resultado acabe siendo más eficiente. Ejemplo de un TDA: TDA Polinomio Para el TDA polinomio podemos enumerar las siguientes operaciones primitivas: 1. crearPolinomio: obtiene los recursos necesarios y devuelve el polinomio nulo. 12
  • 13. 2. grado: devuelve el grado del polinomio. 3. coeficiente: devuelve un coeficiente del polinomio. 4. asigCoeficiente: asigna un coeficiente del polinomio 5. destruirPolinomio: libera los recursos del tipo obtenido. Estas operaciones las podemos clasificar como sigue: De creación: crearPolinomio• • • De destrucción: destruirPolinomio De acceso y actualización: grado, coeficiente, asigCoeficiente. Especificación del TDA polinomio Dominio del TDA Polinomio 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. El polinomio P (x) = 0 es llamado polinomio nulo. P (x) = a0 + a1 x1 + a2 x2 + …+ anxn Donde n es un número natural y ai es un número real. 13
  • 14. Especificación Polinomio crearPolinomio (int maxGrado) pre maxGrado ≥ 0. post devuelve el polinomio nulo. El objetivo de esta función es reservar los recursos necesarios, inicializar con el valor nulo y devolver un tipo polinomio. int grado () pre Polinomio p está inicializado post devuelve el grado del polinomio indicado por p. double coeficiente (natural n) pre Polinomio p está inicializado. n ≤ maxGrado. post devuelve el coeficiente correspondiente al monomio de grado n del polinomio p. void asigCoeficiente (natural n, double c) pre Polinomio p está inicializado. n ≤ maxGrado. post asigna al monomio de grado n el coeficiente c 14
  • 15. Los pasos a seguir para hacer uso de una instancia p de este tipo son: 1. Declaración del tipo. Declaramos a p de tipo polinomio (Polinomio p). 2. Creación de la estructura. 3. Uso de las funciones de acceso y actualización sobre p. 4. Destrucción de los recursos usados por p. (Esta operación en Java se realiza de forma automática por el recolector de basura) Ejemplo del uso del TDA Polinomio en una aplicación: Evaluar un Polinomio En la solución de la evaluación de un polinomio se aprecian dos aspectos importantes de los TDA: 1. Construir la solución despreciando detalles de implementación. El TDA Polinomio puede representarse en la forma en que se desee. 2. Inicialmente se ha diseñado el TDA polinomio independientemente del problema a resolver. De esta manera, disponemos de un nuevo tipo reutilizable en posibles problemas futuros. 15
  • 16. public double evaluarPolinomio (Polinomio p, double valor) /*Función que dado un polinomio p lo evalúa para un valor x */ { int i,grado; double resultado,coeficiente; grado=p.grado(); resultado=0.0; for (i=0;i<=grado;i++) { coeficiente= p.coeficiente (i); resultado=resultado+coeficiente*(pow(valor,i)); } return (resultado); } Implementación del TDA polinomio Una vez especificado el TDA Polinomio queda elegir una implementación y realizarla. 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. Posibles implementaciones del tipo polinomio: 16 1. Un vector en el que se guarden los distintos coeficientes, de tal forma que el coeficiente i-ésimo se guardará en la posición i del vector.
  • 17. 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, esta operación tendrá un O (n). 2. 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. En este caso la implementación de la operación para determinar el grado será O (1). 3. Un registro con tres campos: los dos de la implementación anterior más un campo (pos_min) en el que se indica la posición del polinomio en la que existe el primer coeficiente, comenzando por el término independiente, distinto de cero. Así, 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 x545 se representen sin desperdiciar el espacio para guardar los primeros 544 coeficientes cero. 4. Un registro con dos campos: uno para guardar el grado y otro correspondiente a una referencia a una serie de celdas enlazadas una por cada coeficiente distinto de cero que posea el polinomio, cada una contiene un par coeficiente-grado. 17
  • 18. 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. Como inconvenientes tenemos que operaciones como asigCoeficiente, coeficiente y destruirPolinomio dejan de tener un orden de eficiencia constante. Criterios que suelen prevalecer en la elección de la representación: a) Cuáles son las operaciones que se van a realizar con más frecuencia. 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. Pues las representaciones basadas en estructuras de datos estáticas (como las representaciones 1, 2 y 3 del TDA polinomio definidas anteriormente) limitan el tamaño de los objetos del TDA. 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. Consideraciones generales para la elección de primitivas El conjunto de primitivas de un TDA no es único pues es posible optar por un conjunto distinto teniendo en cuenta los siguientes puntos: 18
  • 19. 1. El conjunto de primitivas debe ser suficiente pero no obligatoriamente mínimo. Puede ser conveniente añadir nuevas funciones si existen motivos: a) La función va a ser probablemente muy usada. b) La función va a ser usada con cierta asiduidad y su implementación haciendo uso de las demás funciones primitivas empeora considerablemente la eficiencia de la operación. 2. Puede ser necesario rehacer el conjunto de primitivas atendiendo a razones referentes a una eficiente utilización de los recursos hardware. Este el caso de la función crearPolinomio, la cual en ciertas implementaciones es altamente probable que sea transformada en una función de creación complementada con otra de destrucción.: a) destruir: cuando un polinomio ya no va a ser utilizado, se usa esta primitiva para liberar los recursos de memoria que mantienen la estructura. b) crear: antes de usar un nuevo polinomio, se utiliza esta primitiva para reservar y asociar la memoria necesaria para mantener la estructura del polinomio en memoria. 19
  • 20. 3. Las cabeceras de las funciones pueden necesitar ser modificadas para hacer viable su implementación. 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. Es pues aconsejable no pasar estructuras directamente sino un referencia a ellas, y que una función no devuelva un valor sino que lo devuelva mediante los parámetros a través del paso por referencia. 4. Un tipo de dato abstracto es un producto software y como tal es algo dinámico y está sujeto a mantenimiento. Por tanto, el conjunto de primitivas de un TDA es algo extensible. Es conveniente retrasar la incorporación de ciertas primitivas cuya necesidad sea dudosa. Pues desde el punto de vista del mantenimiento del software, es mucho menos costoso la adición de nuevas primitivas que la supresión de algunas ya existentes. 1.3 Tipo de datos, estructura de datos y tipo de datos abstracto. Características del Concepto de Tipo 1. Un tipo determina la clase de valores que pueden tomar las variables y expresiones. 2. Todo valor pertenece a uno y sólo un tipo. 20
  • 21. 3. El tipo de un valor denotado por una constante, variable o expresión puede deducirse de su forma o contexto. 4. Cada operador está definido para operandos de varios tipos, y calcula el resultado obteniendo un tipo que esta determinado por el de éstos (usualmente el mismo sí son iguales). 5. Las propiedades de los valores de un tipo y de sus operaciones primitivas se especifican formalmente. 6. La información del tipo en un lenguaje de programación se usa, por una parte para prevenir errores, y por otra, para determinar el método de representar y manipular los datos en un ordenador. En la mayoría de los lenguajes suelen aparecer como tipos básicos los proporcionados por el ordenador: enteros, reales, carácter y lógicos. A partir de estos tipos básicos se pueden generar nuevos tipos de datos, aplicando mecanismos de estructuración del lenguaje de programación. A estos nuevos tipos se les denomina tipos de datos compuestos o estructurados (estructuras de datos) 21
  • 22. Estas estructuras de datos pueden servir de base para diseñar estructuras de datos más complejas, y de esta forma construir verdaderas jerarquías de estructuras, pero los componentes últimos deben ser de tipo básico. Los mecanismos de estructuración que forman parte de la mayor parte de los lenguajes son: array, cadena de caracteres y registro. 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. De ahí que reciban el nombre de estructuras de datos estáticas Hay ocasiones en que es deseable aprovechar mejor la memoria solicitándola conforme sea necesario y liberándola cuando ya no haga falta, sin necesidad de reservar una cantidad fija e invariable. Las estructuras diseñadas de este modo reciben el nombre de estructuras de datos dinámicas. Para generar este tipo de estructuras es imprescindible 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. Este método se conoce como asignación dinámica de memoria y tiene como elemento base al tipo de dato referencia. 22
  • 23. TDA y Estructuras de datos El diseño de un TDA conlleva dos fases: • una de especificación • y otra de realización. 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, es decir emplearán estructuras de datos. Un TDA se podrá implementar bajo diferentes estructuras de datos, a la hora de elegir una estructura frente a otras se deben tener en cuenta los siguientes principios: 1. La eficiencia en tiempo de las operaciones que se utilizarán con mayor frecuencia. 2. Las posibles restricciones de espacio en memoria derivadas del uso de la estructura. En el caso de estructuras estáticas se asigna un tamaño fijo para almacenar los objetos del TDA, con ello se limita el tamaño máximo de los mismos y en muchas ocasiones se desaprovecha espacio en memoria. 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 es conveniente una estructura de datos estática. 23
  • 24. No se deben confundir los conceptos de TDA y estructura de datos. A través de una estructura de datos y sus operaciones podemos definir un tipo de dato abstracto. Existen un conjunto de estructuras de datos que son ampliamente usadas junto con sus operaciones más frecuentes, es el caso de: • Listas • Árboles binarios • Árboles binarios de búsqueda (ABB) • Árboles equilibrados (AVL), • Árboles binarios parcialmente ordenados (APO) • Tablas hash • Grafos. Algunas de estas estructuras de datos (listas y árboles) vamos a estudiarlas y presentarlas como tipos de datos abstractos. 24
  • 25. Cuando en un programa necesitemos manejar objetos estructurados según alguna de las estructuras de datos de que disponemos, no tendremos más que hacer uso de ellas: • añadiendo el módulo correspondiente a nuestro programa • usando la estructura en base a las especificaciones como tipo de dato abstracto sin necesidad de considerar detalles de implementación. 1.4 Tipos de datos abstractos lineales • Listas: que son secuencias de elementos • Pilas: listas donde los elementos se insertan y eliminan solo en un extremo • Colas: listas donde los elementos se insertan por un extremo y se eliminan por el otro. El tipo de datos abstracto “Lista” Las listas constituyen una estructura flexible en particular, porque pueden crecer y acortarse según se requiera. Los elementos son accesibles y se pueden insertar y suprimir en cualquier posición de la lista. Las listas también pueden concatenarse entre sí o dividirse en sublistas. 25
  • 26. 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. En una lista 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, a esta posición se le llama posición que sucede al último elemento de la lista, e indicará el final de la lista. La posición final de la lista está a una distancia que varía conforme la lista crece o se reduce con respecto a la primera posición de la lista, mientras que las demás posiciones guardan una distancia fija con respecto al principio de la lista. 26
  • 27. 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. El tipo posición no siempre lo 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 TLista son las siguientes, teniendo en cuenta que lista denota una instancia de TLista. void anula () post (lista) = <> TElemento primero () pre lista está inicializada. post primero = 0 {Si lista está vacía, la posición que se devuelve es fin () } TElemento fin () pre lista está inicializada. post fin = n+1 27
  • 28. {posición detrás de la última} void insertar (TElemento x,TElemento ap) pre lista = <a1,a2,…,ap,...,an> 1 ≤ p ≤ n+1 post lista = <a1,…,ap-1,x,ap,…,an> Si (p= fin (lista)) entonces lista = <a1,a2,…,an,x> {resulta una lista de longitud n+1, en la que x ocupa la posición p. Si la lista lista no tiene posición p, el resultado es indefinido} void borrar (TElemento ap) pre lista = <a1,a2,… ap,...an> 1 ≤ p ≤ n post lista = <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 lista no tiene posición p o si p = fin (lista)} TElemento elemento (TPosicion p) pre lista = <a1,a2,…,an> 28
  • 29. 1 ≤ p ≤ n post elemento = ap {devuelve el elemento que está en la posición p de la lista lista. El resultado no está definido si p = fin (lista) o si lista no tiene posición p} TElemento siguiente (TElemento ap) pre lista = <a1,a2,... ,ap, ap+1,...,an> 1 ≤ p ≤ n post siguiente = ap+1 {devuelve la posición siguiente a p en la lista lista. Si p es la última posición de lista, siguiente (p, lista) = fin (lista). El resultado no está definido si p = fin (lista), o si la lista lista no tiene posición p} TElemento anterior (TElemento ap) pre lista = <a1,a2, ... ,ap-1 ,ap …,an> 2 ≤ p ≤ n+1 post anterior = ap-1 {devuelve la posición anterior al elemento ap en lista. El resultado no está definido si p=1 , o si lista no tiene posición p} 29
  • 30. Tposicion posicion (TElemento x) pre lista está inicializada post Si ∃ j ∈ {1,2,…,n}, tal que aj = x, entonces posicion = i, donde i verifica que: 1. ai = x 2. Si aj = x, entonces j ≥ i. Si no existe j ∈ {1,2,…,n}, tal que aj = x, entonces posicion = n+1 {devuelve la posición de la primera aparición de x en lista. Si x no figura en la lista entonces se devuelve fin (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. Con estas consideraciones, el procedimiento para eliminar las repeticiones de una lista sería: void elimina (TLista lista) { TPosicion p,q; for (p = lista.primero ();p!= lista.fin ();p= lista.siguiente (p)) { q=lista.siguiente(p); 30
  • 31. while (q!=lista.fin ()) if (igual (lista.elemento (p),lista.elemento (q))) lista.borrar (q); else q=lista.siguiente(q); } } 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 derecha de su posición, usaremos la posición q para recorrer los elementos a la derecha de p. Cuando se elimine el elemento de la posición q los elementos que estaban en las posiciones siguientes retroceden una posición en la lista, en estos casos no será necesario pasar a la posición siguiente de q, nos quedaremos en la misma posición q. Implementación de las listas Implementación de las listas mediante vectores En la realización de una lista mediante un vector, los elementos de ésta se almacenan en celdas contiguas de un vector. 31
  • 32. Como las listas tienen longitud variable y los vectores longitud fija, debemos tomar vectores de tamaño igual a la longitud máxima de la lista y utilizar un entero que nos indique la posición del último elemento de la lista. Definiremos el tipo TLista como un objeto con dos variables miembro: • La primera un vector que tiene la longitud adecuada para contener la lista de mayor tamaño que se pueda presentar. • El segundo campo es un entero que indica la posición del último elemento de la lista en el vector. El i-ésimo elemento de la lista está en la (i-1)-ésima celda del vector. Las posiciones en la lista se representan mediante enteros. Para esta realización basada en vectores, el tipo TLista se va a definir con la ayuda de un interfaz que marca como debe ser cualquier objeto que se utilice como una lista. El TElemento será la clase general Object puesto que todos los objetos en Java pertenecen a la clase Object. El TPosicion será una variable de tipo Object que en la práctica podrá ser un Integer, que nos representa perfectamente la posición de un objeto dentro de una lista. El interfaz TLista define como sigue: public interface TLista{ 32
  • 33. Object primero(); Object fin(); boolean insertar(Object x, Object ap); boolean borrar (Object ap); Object elemento(Object p); Object siguiente(Object ap); Object anterior (Object ap) Object posicion (Object ap) } La implementación de un TLista utilizando una estructura del tipo java.util.Vector está en la clase Lista, definida a continuación: public class Lista implements TLista{ public static final int LMAX=100; Vector vector = new Vector(LMAX); int n=-1; ... } 0 Primer Elemento 1 Segundo Elemento . .. n Último Elemento tamMax-1 Elementos . . . . 33
  • 34. La implementación de la mayoría de los métodos es inmediata. Los métodos más simples son: Object primero (){ if (n>=0) return (new Integer(0)); else return null; } Object fin (){ return (new Integer(n+1)); } Object siguiente (Object ap){; int i=((Integer)ap).intValue(); if (i<n) return (new Integer (i+1)); else return null; } Object anterior (Object ap){ int i=((Integer)ap).intValue(); if (i>0) return (new Integer(i-1)); else return null; } 34
  • 35. Object elemento (Object ap){ int i=((Integer)ap).intValue(); if ((i < 0) || (i > n)){ //La posición no está en la lista return null; } return vector.elementAt(i); } La función posicion tiene que realizar una búsqueda secuencial en un vector. En el caso de que el elemento no esté en el vector la función devolverá la siguiente posición a insertar. Object posicion (Object p){ int index=0; while (index<=n){ if (vector.elementAt(index).equals(p)) return (new Integer(index)); index++; } return (new Integer(n+1)); } Para insertar un elemento en la posición p de la lista se debe desplazar una posición dentro del vector a todos los elementos que siguen al elemento de la posición p, con el fin de hacer previamente un hueco donde realizar la inserción. boolean insertar (Object x, Object p){ if (n== Lista.LMAX){ return false; //La lista está llena } 35
  • 36. else { /*Desplaza los elementos en p,p+1, … una posición hacia abajo*/ int posP = ((Integer)posicion(p)).intValue(); for (int q=n;q>=posP;q--) vector.add(q+1, vector.elementAt(q)); vector.add(posP,x); return true; } } La eliminación de un elemento, excepto en el caso del último, requiere desplazamientos de elementos para llenar el vacío formado. boolean borrar (Object p){ int posP = ((Integer)posicion(p)).intValue(); if(posP==n+1){ return false; //La posición no está en la lista } else { /*Desplaza los elementos en p+1,p+2,… una posición hacia arriba*/ for (int q=posP;q<n-1;q++) lista.add(q,lista.elementAt(q+1)); lista.add(n, null); return true; } } Estas tres últimas operaciones tienen una eficiencia del orden del tamaño de la lista, pues en el peor de los casos tendrían que recorrer la lista por completo para realizar la operación en cuestión. 36
  • 37. Inconveniente de esta implementación: • 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. • Siempre hay una cantidad de espacio reservada para los elementos de la lista desperdiciada al ser el tamaño de la lista, en un momento dado, menor que el tamaño máximo. Este problema se acentúa si las distintas listas que se representan son de un tamaño muy dispar, en este caso no es conveniente el uso de esta representación. Para mejorar esta implementación incorporamos un parámetro en el constructor, que va a determinar el tamaño máximo de la lista. La versión optimizada sería: public class Lista{ public int LMax; Vector vector; int n; public Lista (int tamMax) { // Constructor LMax = tamMax; vector = new Vector(tamMax); 37
  • 38. n = -1; } ... } Las demás primitivas quedarían de la misma forma sustituyendo LMAX por LMax. En esta nueva implementación conseguimos resolver con éxito dos cosas: 1. Tamaños variables. Ahora a la primitiva encargada de crear el objeto (constructor) se le pasa un parámetro indicando el tamaño máximo que tendrá la lista. 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. 2. Creación y destrucción. 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. El uso de la constructor (new) 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. Tras la destrucción de una lista, se podrá usar de nuevo la misma variable en la creación, uso y destrucción de una nueva lista. 38
  • 39. En JAVA no es necesario destruir los objetos ya que, cuando salen de su ámbito, son marcados para ser eliminados, de lo cual se encarga el “garbage collector” (recolector de basura). En caso de que fuera necesario realizar alguna tarea en el momento de la destrucción de un objeto, deberá especificarse en el método void finalize() Una vez implementado el TDA Lista estará disponible para ser usado siguiendo los siguientes pasos: 1. Declaración de la variable de tipo Lista. 2. Creación de la lista mediante su constructor. 3. Uso de la lista mediante primitivas distintas a la de creación y destrucción. 4. Destrucción de la lista mediante su constructor (opcional). Al escribir los programas en función de TDAs, es posible modificar las estructuras de datos de los programas más fácilmente con sólo aplicar de nuevo las operaciones. La flexibilidad que se obtiene con esto, puede ser especialmente importante en proyectos grandes, aunque no sea tan evidente en los ejemplos pequeños. 39
  • 40. Implementación de listas mediante celdas enlazadas por referencias En esta implementación se utilizan referencias para enlazar elementos consecutivos, evitando el empleo de memoria contigua para almacenar una lista y eludiendo los desplazamientos de elementos para hacer inserciones y eliminaciones. No obstante, hay que pagar el precio de un espacio adicional para las referencias. En esta representación, una lista está formada por celdas; cada elemento ai de una lista <a1,a2,…,an>, se representa por una celda dividida en dos partes: • un primer campo que contiene al elemento ai de la lista • un segundo campo donde se almacena una referencia a la celda que contiene al siguiente elemento ai+1. La celda que contiene a an posee un referencia a NULL (referencia nulo), para indicar el final de la lista. a2 ana1 Para realizar más fácilmente las operaciones es conveniente considerar una celda inicial, llamada cabecera donde no se almacena ningún elemento de la lista. 40
  • 41. La lista vendrá representada por un referencia que indique la dirección de la cabecera En el caso de una lista vacía, el referencia cabecera será NULL. Cabecera a1 an L La posición i será un referencia pero no a la celda que contiene al elemento ai , sino a la celda donde está el elemento anterior ai. Con esto se puede acceder al elemento y también se facilita el diseño de las operaciones de inserción y borrado. La posición del primer elemento será una referencia a la celda cabecera o inicial, y la posición fin() es un referencia a la última celda de la estructura. La definición de Celda es como sigue: public class Celda{ public Celda sig; public Object elemento; } La definición de la clase TDA que implementa el TLista sería: public class Lista implements TLista{ Celda cabecera; ... } 41
  • 42. Los métodos se detallan a continuación: public Lista (){ // Constructor de lista cabecera = new Celda(); cabecera.sig = null; } Object fin (){ Celda pos = cabecera; while (pos.sig!=null) pos=pos.sig; return pos; } Este método es ineficiente pues se requiere recorrer toda la lista para devolver el referencia a la última celda de la lista. Su eficiencia es pues del orden de la longitud de la lista. 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. Sustituir el uso de fin() donde sea posible. Por ejemplo, en un ciclo while con una condición del tipo p !=fin () se debería sustituir por: q= fin(); while (p!=q) .... donde q es de tipo Celda y fin() no se ve afecta de ninguna forma en el interior del ciclo. 42
  • 43. 2. Considerar listas con un referencia a la cabecera y otra a la posición fin(), aunque esto complicaría ligeramente algunas operaciones, pero se ganaría mucha eficiencia en esta función. void insertar (Object x, Object ap){ Celda c=new Celda(); c.elemento=x; c.sig=ap.sig; ap.sig= c; } Mecánica del manejo de referencias en la función inserta: ( a ) Situación inicial ap ai ai+1 ( b ) Asignaciones de x ap x ( c ) Asignación de ap ap x x ai x ai ai+1 ai+1 43
  • 44. Sobre este método cabe señalar varias cosas: 1. Tarda siempre un tiempo constante frente a la implementación vectorial en que se tardaba un tiempo proporcional a la longitud de la lista. 2. No se comprueba la precondición pues se perdería mucho tiempo en la comprobación, y dejaría de ser tan eficiente la operación. Es responsabilidad del programador utilizarla siempre con posiciones de esta lista. Si no se hace así, se puede dar lugar a incongruencias. 3. Gracias al uso de la celda cabecera no es necesario distinguir en que posición se realiza la inserción. El procedimiento funciona bien en los casos extremos de la primera posición y fin () posición. En listas sin cabecera estos casos habrían sido necesarios considerarlos aparte, complicando la realización de la operación Object siguiente (Object ap){ if (ap.sig==null){ return null; //No hay siguiente al fin de la lista } return (ap.sig); } 44
  • 45. Celda primero (){ return (cabecera); } Object posicion (Object x){ Celda p; boolean encontrado; p=primero (); encontrado=false; while ((p.sig!=null)&&(!encontrado)) if (p.sig.equals(x)) encontrado=true; else p=p.sig; return (p); } Respecto a esta función es conveniente destacar: a) La función cumple la postcondición en los dos casos posibles: cuando éste y no éste el elemento buscado en la lista. b) La complejidad es la misma que para la implementación mediante vectores. c) En la condición del bucle aparece la comparación (p.sig!=NULL). Esta es equivalente a (p!=fin()), pero no se utiliza esta última pues aumentaría mucho la complejidad debido a la ineficiencia ya comentada de la función fin (). 45
  • 46. No se debe sustituir en cualquier programa esta última condición por la que hemos usado aquí, pues iría en contra de todo lo comentado sobre la construcción de programas haciendo uso de TDAs. Object elemento (Object p){ return p.sig.elemento; } boolean borrar (Object ap) { if (ap.sig==null){ return false; } else { ap.sig=ap.sig.sig; return true; } } Mecánica de referencias que se realizan en esta operación ap ai ai+1 ai+2 Ventajas de esta representación: • ya no se acota el tamaño máximo de las listas. 46 • las operaciones de inserción y borrado que en la anterior representación tenían un O(n), donde n es la longitud de la
  • 47. lista, en esta representación requieren únicamente un tiempo constante. Inconvenientes de esta representación: • requiere un espacio adicional para la referencia de cada celda. Si conocemos el tamaño aproximado de las listas que se van manejar podría ser más conveniente representar el tipo lista mediante vectores, con el consiguiente ahorro de espacio en memoria. Comparación de los métodos Ante una aplicación que necesite el uso del TDA listas hay que determinar si es mejor usar una implementación de listas basadas en celdas enlazadas o en vectores. La decisión dependerá de las operaciones que se deseen realizar, o de las que se realizan más frecuentemente. Otras veces la decisión es en base a la longitud de la lista. Los puntos principales a considerar son los siguientes: 1. La implementación mediante vectores requiere especificar el tamaño máximo de una lista en el momento de la compilación. 47
  • 48. Si no es posible acotar la longitud de la lista, posiblemente deberíamos escoger una representación basada en referencias. Este problema ha sido parcialmente solucionado con la parametrización del tamaño máximo de la lista, pero aún así hay que delimitar el tamaño máximo para cada una de las listas. 2. Ciertas operaciones son más lentas en una realización que en otra. Por ejemplo, insertar y borrar realizan un número constante de pasos para una lista enlazada, pero necesitan un tiempo proporcional al número de elementos de la lista para la representación basada en vectores. Inversamente, 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 referencias simplemente- enlazadas 3. La realización con vectores puede malgastar espacio, pues usa la cantidad máxima de espacio independientemente del número de elementos presentes en la lista en un momento dado. 48
  • 49. La implementación por referencias utiliza sólo el espacio necesario para los elementos que actualmente tienen la lista, pero necesita espacio adicional para los referencias de cada celda. 4. En las listas enlazadas la posición de un elemento se determina con un referencia 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. Listas Doblemente-Enlazadas En algunas aplicaciones puede ser deseable recorrer eficientemente una lista, tanto hacia delante como hacia atrás. O, dado un elemento, podría desearse determinar con rapidez el siguiente y el anterior. En tales situaciones podríamos desear añadir a cada celda de una lista un referencia a la anterior celda tal y como se muestra: Otra ventaja de las listas doblemente enlazadas es que permiten usar un referencia a la celda que contiene el i-ésimo elemento de una lista para representar la posición i, en vez de usar el referencia a la celda anterior, que es menos natural. 49
  • 50. El único precio que se paga por estas características es la presencia de un referencia adicional en cada celda y consecuentemente procedimientos algo más lentos para alguna de las operaciones básicas de las listas. El tiempo para todas las operaciones es constante, excepto para la operación posición que requiere un tiempo proporcional a la longitud de la lista. La declaración de una lista doblemente enlazada sería: public class Celda{ public Object elemento; public Celda sig, ant; } Un procedimiento para borrar un elemento en la posición p de una lista doblemente-enlazada es: boolean borrar (Object p){ if (p.ant!=null) p.ant.sig=p.sig; if (p.sig!=null) p.sig.ant=p.ant; } Los cambios sufridos por los referencias en este procedimiento son: 50
  • 51. Para evitar las verificaciones sobre si la celda a suprimir es la primera o la última, se suele hacer que la primera celda de la lista doblemente_enlazada sea una celda que efectivamente cierre el círculo, es decir, que el campo ant de esta celda apunte a la última celda y el campo sig de la última celda apunte a la primera. Pilas Una pila es un tipo especial de lista en la que todas las inserciones y borrados tienen lugar en un extremo denominado tope. A las pilas se les llama también “listas LIFO” (last in first out) o listas “último en entrar, primero en salir”. El modelo intuitivo de una pila es precisamente un mazo de cartas puesto sobre una mesa, o de platos en una estantería, situaciones todas en las que sólo es conveniente quitar o agregar un objeto del extremo superior de la pila, al que se denominará en lo sucesivo tope. Especificación de las operaciones primitivas del TDA Pila Vamos a notar al tipo pila como TPila, al conjunto de los elementos básicos como TElemento . Un tipo de dato abstracto pila suele incluir a menudo las siguientes operaciones: 51
  • 52. void anula () post (P)=<> {Esta operación es la misma que la de las listas generales} boolean vacia () pre P está inicializada post Si (P = <>) entonces vacia = true sino vacia = false TElemento tope () pre no vacia (P) post tope = a1 {Devuelve el elemento en la cabeza de la pila P. Esta operación puede escribirse en términos de las operaciones de listas como elemento(primero(P),P) , pues la cabeza de una pila se identifica con la posición 1} void push (TElemento x) pre P=<a1,a2,…,an> post P = <x,a1,a2,…,an> {inserta el elemento x en el tope de la pila P. En términos de primitivas de listas esta operación es inserta(x,primero(P),P)} void pop () pre no vacia (P) P=<a1,a2,…,an> post P = <a2,…,an> 52
  • 53. {Borra el elemento del tope de la pila P. En términos de primitivas de listas Borra (primero (P),P). Algunas veces es conveniente implementar pop como una función que devuelve el elemento que acaba de borrar} Al igual que se hizo con la operación anula para las listas, 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. Sin embargo las operaciones de las pilas son más específicas y por tanto la implementación puede ser mejorada especialmente en el caso de la implementación basada en vectores. Implementación de pilas basadas en vectores La implementación basada en vectores para las listas, no es particularmente buena para las pilas, porque cada push o pop requiere mover la lista entera hacia arriba o hacia abajo. Estas operaciones requieren un tiempo proporcional al número de elementos en la pila. 53
  • 54. Un mejor criterio para usar un array es tener en cuenta que las inserciones y las supresiones ocurren sólo en la parte superior. 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. Un cursor llamado tope indica la posición actual del primer elemento de la pila. 0 Último Elemento . Segundo Elemento Tope Primer Elemento tamMax-1 Elementos . . . . . El interfaz TPila se define como sigue: Public interface TPila{ boolean vacia (); Object tope(); boolean push (); boolean pop(); } La implementación de un TPila utilizando una estructura del tipo java.util.Vector está en la clase Pila, definida a continuación: 54
  • 55. Public class Pila implements TPila{ public int LMax; Vector vector = new Vector (LMAX); int tope; public Pila (int tamMax){// Constructor Lmax =tamMax; vector= new Vector (tamMax); tope =-1; ... } La implementación de la mayoría de los métodos es inmediata: boolean vacia () { return (tope == -1); } Object tope () { if (vacia (){ // la pila está vacía return null; } return vector.elementAt(tope); } boolean pop () { if (vacia ()){ 55
  • 56. // la pila está vacía return false; } tope--; return true; } boolean push (Object x) { if (tope == Lmax-1){ // la pila está llena; return false; } tope++; vector.add(tope)=x; return true; } Implementación de Pilas mediante celdas enlazadas Para esta representación las operaciones push y pop operan sólo sobre el primer elemento y no existe la noción de posición. La definición de Celda es como sigue: public class Celda{ public Celda sig; public Object elemento; } 56
  • 57. a1 anP Cabecera tope de la pila fondo de la pila La definición de la clase Pila que implementa el TPila sería: public class Pila implements Tpila{ Celda Cabecera; ... } Los métodos se detallan a continuación: public Pila (){ //Constructor de Pila cabecera=new Celda (); cabecera.sig=null; } bolean vacia () { return (Cabecera.sig==null); } boolean pop () { if (vacia ()){ // la pila está vacía return false; } 57 Cabecera =Cabecera.sig;
  • 58. } Object tope () { if (vacia ()){ //la pila está vacía return null; } return (Cabecera.sig.elemento); } boolean push (Object x) { Celda c=new Celda (); c.elemento = x; c.sig=Cabecera.sig; Cabecera.sig=c; } 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). Las colas se conocen también como “listas FIFO” (first-in first- out) o listas “primero en entrar, primero en salir”. Las operaciones para una cola son análogas a las de las pilas; las diferencias sustanciales consisten en que las inserciones se hacen al final de la lista, y no al principio, y en que la terminología tradicional para colas y listas no es la misma. 58
  • 59. 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. Las primitivas que vamos a considerar para las colas son las siguientes: void anula () post (C)=<> {Esta operación es la misma que la de las listas generales} boolean vacia () pre C está inicializada post Si (C = <>) entonces vacia = true sino vacia = false TElemento frente () pre no vacia () post frente = a1 {devuelve el valor del primer elemento de la cola C. Se puede escribir en función de las operaciones primitivas de las listas como: elemento (primero (C),C)} int poner_en_cola (TElemento x) pre C =<a1,a2,…,an> post C =<a1,a2,…,an,x> poner_en_cola True si consigue poner en cola {inserta el elemento x al final de la cola C. En función de las operaciones de las listas sería: inserta(x, fin (C),C)} 59
  • 60. int quitar_de_cola () pre no vacia () C =<a1,a2,…,an> post C =<a2,…,an> quitar_de_cola Trae si consigue quitar de cola {suprime el primer elemento de C. En función de las operaciones de las listas sería: borra (primero ( C),C)} Implementación de las colas basada en celdas enlazadas Como en el caso de las pilas, cualquier realización de listas es lícita para las colas. No obstante, 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 y mantener un referencia al último elemento. Como en las listas de cualquier clase, también se mantiene una referencia al frente de la lista; en las colas, esa referencia es útil para ejecutar mandatos del tipo frente o quita_de_cola. Al igual que para las listas, utilizaremos una celda cabecera con el referencia frontal apuntándola. Esta convención permite manejar convenientemente una cola vacía. 60 Cab a1 anant post C
  • 61. Para esta realización se va a definir el tipo TCola con la ayuda de un interfaz que marca como debe ser cualquier objeto que se utilice como una cola. public interface TCola{ boolean vacia(); Object frente (); boolean poner_en_cola (Object p); boolean quitar_de_cola(); } Una cola es pues un referencia a una estructura compuesta por dos referencias, uno al extremo anterior de la cola y otro al extremo posterior. La primera celda es una celda cabecera cuyo campo elemento se ignora. La definición de tipo es la siguiente: public class Celda{ Object elemento; Celda sig; } public class Cola implements TCola { Celda ant, post; //Entran por post y salen por ant … } 61
  • 62. Con esta definición del tipo cola, la implementación de las primitivas es la siguiente: public Cola () { //Constructor ant=new Celda(); ant.sig=null; post=ant; } boolean vacia (){ return (post.sig==null); } Object frente (){ //Elemento que entró primero (el más antiguo) if (vacia ()){ return null; } return (ant.sig.elemento); } boolean poner_en_cola (Object x){ Celda c=new Celda(); c.sig=null; c.elemento=x; post.sig=c; post=c; if (post.sig!=null) post.sig.sig=c; if (ant.sig==null) ant.sig=c; post.sig=c; return true; } 62 void quitar_de_cola (){
  • 63. if (ant.sig!=null){ ant.sig=ant.sig.sig; return true; } else return false; } El procedimiento quitar_de_cola suprime el primer elemento de la cola C deconectando la cabecera antigua de la cola, de forma que el primer elemento de la cola se convierte en la nueva cabecera. C (a) C = crear ( ) (b) poner_en_cola (x,C) poner_en_cola (y,C) C (c) quitar_de_cola (C) C ant post ant post x y NULL ant post x y NULL 63
  • 64. Implementación de las colas usando matrices circulares La implementación de listas por medio de matrices puede usarse para las colas, pero no es muy eficiente. Con un referencia al último elemento es posible ejecutar poner_en_cola en un tiempo constante. Pero quitar_de_cola, que suprime el primer elemento, 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. Para evitarlo se puede adoptar un criterio diferente. Imaginemos a la matriz como un círculo en el que la primera posición sigue a la última. tamMax-1 0 0 1 post ant Cola Para insertar un elemento en la cola se mueve el referencia post una posición en el sentido de las agujas del reloj y se escribe el elemento en esa posición. 64
  • 65. Para suprimir un elemento simplemente se mueve ant una posición en el sentido de las agujas del reloj. De esta forma, la cola se mueve en es e sentido conforme se insertan y suprimen elementos. 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. Existe un problema y 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. Si no deseamos mantener este bit debemos prevenir que la cola llene alguna vez la matriz. Una cola vacía tiene post a una posición de ant en el sentido de las agujas del reloj, que es exactamente la misma posición relativa que cuando la cola tenía tamMax elementos. Así cuando la matriz tenga tamMax casillas, no podemos hacer crecer la cola más allá de tamMax-1 casillas, a menos que introduzcamos un mecanismo para distinguir si la cola está vacía o llena. 67
  • 66. post post ant ant Cola Llena Cola vacía Las primitivas de las colas usando esta representación se describen a continuación: public class Cola implements TCola{ int LMax; Vector vector; int ant, post; public Cola(int tamMax){ //Constructor LMax = tamMax+1; vector = new Vector(LMax); ant=0; post=LMax-1; } boolean vacia () { return ((post+1)%(Lmax)==ant); } 68
  • 67. Object frente () { if (vacia())){ return null; } else return (vector.elementAt(ant)); } boolean poner_en_cola (Object x) { if ((post+2)%(LMax)==ant){ //La cola está llena return false; } else { post=(post+1)%(LMax); vector.add(post, x); return true; } } boolean quitar_de_cola () { if (vacia()){ return false; } else { ant= (ant+1)%(LMax); return true; } } 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. 69
  • 68. 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. 1.5 El TDA Árbol Introducción y terminología básica En esta sección vamos a considerar una estructuración de los datos más compleja: los árboles. Este tipo de estructura es usual incluso fuera del campo de la informática. Como es el caso de los árboles gramaticales para analizar oraciones, los árboles para analizar circuitos eléctricos, los árboles genealógicos, representación de jerarquías, etc … Para tratar esta estructura cambiaremos la notación: • Las listas tienen posiciones. Los árboles tienen nodos. • Las listas tienen un elemento en cada posición. Los árboles tienen una etiqueta en cada nodo algunos autores distinguen entre árboles con y sin etiquetas. Un árbol sin etiquetas tiene sentido aunque en la inmensa mayoría de los problemas necesitaremos etiquetar los nodos. Usando esta notación, un árbol es una colección de elementos llamados nodos, uno de los cuales se distingue como raíz, junto con una relación de paternidad que impone una estructura jerárquica sobre los nodos. 70
  • 69. Formalmente, un árbol se puede definir de manera recursiva como sigue: 1. Un solo nodo es, por sí mismo, un árbol. Ese nodo es también la raíz de dicho árbol. 2. Supóngase que n es un nodo y que A1,A2,…, Ak son árboles con raíces n1,n2, …,nk, respectivamente. Se puede construir un nuevo árbol haciendo que n se constituya en el padre de los nodos n1,n2,…,nk. En dicho árbol, n es la raíz y A1,A2,…,Ak son los subárboles de la raíz. Los nodos n1,n2,…,nk reciben el nombre de hijos del nodo n. A veces conviene incluir entre los árboles un árbol especial el árbol nulo, un árbol sin nodos que se representa mediante Λ. Ejemplo de un Árbol: C C1 C2 C3 s1.1 s1.2 s2.1 s2.2 s2.3 s4.1 s2.2.1 s2.2.2 s2.2.3 Los árboles normalmente se presentan en forma descendente y se interpretan de la siguiente forma: C es la raíz del árbol. C1, C2, C3 son los hijos de C 71
  • 70. C1, s1.1, s1.2 componen un subárbol de la raíz. s1.1, s1.2, s2.1, s2.2.1, s2.2.2, s2.2.3, s2.3,s4.1 son las hojas del árbol. Además de los términos introducidos consideraremos la siguiente terminología: a) Grado de salida o simplemente grado: se denomina grado de un nodo al número de hijos que tiene. Así el grado de un nodo hoja es cero. En la figura el nodo con etiqueta C tiene grado 3. b) Caminos: si n1,n2,…,nk es una sucesión de nodos en un árbol tal que ni es el padre de ni+1 para 1≤ i ≤ k-1, entonces esta sucesión se llama un camino del nodo n1 al nodo nk. La longitud de un camino es el número de nodos menos uno, que hay en el mismo. Existe un camino de longitud cero de cada nodo a sí mismo. Ejemplos sobre el árbol de la figura: C,C2,s2.2,s2.3 es un camino de C a s2.2.3 ya que C es padre de s2, éste es padre de s2.2, etc. • • C1,C,C2 no es un camino de C1 a C2 ya que C1 no es padre de C. c) Ancestros y descendientes: si existe un camino, del nodo a al nodo b, entonces a es un ancestro de b y b es un descendiente de a. 72
  • 71. En el ejemplo anterior los ancestros de s2.2 son s2.2, C2 y C y sus descendientes s2.2.1, s2.2.2, s2.2.4. (cualquier nodo es a la vez ancestro y descendiente de sí mismo). Un ancestro o descendiente de un nodo, distinto de sí mismo, se llama un ancestro propio o descendiente propio respectivamente. Podemos definir en términos de ancestros y descendientes los conceptos de raíz, hoja y subárbol: • En un árbol, la raíz es el único nodo que no tiene ancestros propios. • Una hoja es un nodo sin descendientes propios. • Un subárbol de un árbol es un nodo, junto con todos sus descendientes. d) Altura: la altura de un nodo es un árbol es la longitud del mayor de los caminos del nodo a cada hoja. La altura de un árbol es la altura de la raíz. Ejemplo: la altura de C2 es 2 y la del árbol es 3. e) Profundidad: la profundidad de un nodo es la longitud del único camino de la raíz a ese nodo. Ejemplo: la profundidad de C2 es 1. f) Niveles: dado un árbol de altura h se definen los niveles 0…h de manera que el nivel i está compuesto por todos los nodos de profundidad i. 73
  • 72. g) Orden de los nodos: los hijos de un nodo usualmente están ordenados de izquierda a derecha. Si deseamos explícitamente ignorar el orden de los hijos, nos referiremos a un árbol como un árbol no-ordenado. La ordenación izquierda-derecha de hermanos puede ser extendida para comparar cualesquiera dos nodos que no están relacionados por la relación ancestro-descendiente. La regla a usar es que si n1 y n2 son hermanos y n1 está a la izquierda de n2, entonces todos los descendientes de n1 están a la izquierda de todos los descendientes de n2. Ejemplo: en la figura el nodo s2.2.1 está a la derecha de los nodos C1, s1.1, s1.2, s2.1 y a la izquierda de los nodos s2.2.2, s2.2.3, s2.3, C3, s4.1. Recorridos de un árbol Existen distintos métodos útiles para recorrer sistemáticamente recorrer todos los nodos de un árbol. Los tres recorridos más importantes se denominan preorden, inorden y postorden. Estos ordenamientos se definen recursivamente como sigue: • Si un árbol A es nulo, entonces la lista vacía es el listado de los nodos de A en los recorridos preorden, inorden y postorden. 74
  • 73. Si A contiene un solo nodo, entonces ese nodo constituye el listado de los nodos de A en los recorridos preorden, inorden y postorden. • Si ninguno de los anteriores es el caso, sea A un árbol con raíz n y subárboles A1,A2,…,Ak. n … AkA2A1 1. El listado en preorden de los nodos de A está formado por el nodo raíz de A, seguido de los nodos de A1 listados en preorden, luego por los de A2 en preorden y así sucesivamente hasta los nodos de Ak listados en preorden. 2. El listado en inorden de los nodos de A está formado por los nodos de A1 listados en inorden, seguidos de n (nodo raíz) y luego por los nodos de los subárboles A2,…, Ak listados en inorden. 3. El listado en postorden de los nodos de A está formado por los nodos de A1 listados en postorden, 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. Como ejemplo de listados veamos el resultado que se obtendría sobre el siguiente árbol. 75
  • 74. 1 2 3 4 5 6 7 8 9 10 Los resultados de los listados en preorden, postorden e inorden son los siguientes: 1. Listado preorden: 1,2,3,5,8,9,6,10,4,7 2. Listado postorden: 2,8,9,5,10,6,3,7,4,1 3. Listado inorden: 2,1,8,5,9,3,10,6,7,4 Un truco útil para producir los tres recorridos es el siguiente: si caminamos por la periferia del árbol, partiendo de la raíz, y avanzando en el sentido contrario de las agujas del reloj. El recorrido en preorden se lista un nodo la primera vez que se pasa por él. En el caso del recorrido postorden, se lista un nodo la última vez que se pasa por él, conforme se sube hacia su padre. Para el recorrido inorden, se lista una hoja la primera vez que se pasa por ella, y un nodo interior, la segunda vez que se pasa por él. 76
  • 75. El orden de las hojas en los tres recorridos corresponde al mismo ordenamiento de izquierda a derecha de las hojas. Sólo el orden de los nodos interiores y su relación con las hojas varía entre los tres ordenamientos. 1 2 3 4 5 6 7 8 9 10 Un árbol no puede, en general, recuperarse con uno solo de sus recorridos. Será a partir de los recorridos preorden y postorden cuando se pueda determinar unívocamente. 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). Para determinar si un nodo x es descendiente de un nodo y, basta verificar que se cumple: ord_post(y)-desc(y) ≤ ord_post(x) ≤ ord_post(y) Una propiedad similar se cumple para el recorrido en preorden. 77
  • 76. Una aplicación: árboles de expresión Una importante aplicación de los árboles en la informática es la representación de árboles sintácticos, es decir, árboles que contienen las derivaciones de una gramática necesarias para obtener una determinada frase de un lenguaje. Podemos etiquetar los nodos de un árbol con operandos y operadores de manera que un árbol represente una expresión. * + - x z x y La expresión aritmética de este árbol sería: (x+z)*(x-y). Las reglas para que un árbol represente a una expresión son: 1. Cada hoja está etiquetada con un operando y sólo consta de ese operando. 2. Cada nodo interior está etiquetado con un operador. 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). 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. 78
  • 77. 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. 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 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. 79
  • 78. 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. • 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. En el ejemplo, la expresión postfija correspondiente 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 el ejemplo, la sucesión inorden del árbol es x+y*x-y. El TDA Árbol La estructura de árbol puede ser tratada como un tipo de dato abstracto. Notaremos al tipo Árbol como TArbol, al tipo nodo como TNodo y al tipo de las etiquetas TEtiqueta. 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. Para expresar que un nodo no existe 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. 80
  • 79. Especificación de las operaciones primitivas del TDA Árbol crear (TEtiqueta u) { Construye la raiz del árbol como un nuevo nodo r con etiqueta u y sin hijos. } TNodo padre () {Devuelve el padre del nodo n. Si n es la raíz, que no tiene padre, devuelve NODO_NULO. Como precondición n no es NODO_NULO} TNodo hizqda () {Devuelve el descendiente más a la izquierda en el siguiente nivel del nodo n, y devuelve NODO_NULO si n no tiene hijo a la izquierda. Como precondición n no es NODO_NULO. En el ejemplo, hijo_izda (n2) = n4, , hijo_izda (n5) = NODO_NULO} n1 n2 n3 n4 n5 n6 n7 TNodo herdrcha () {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 el ejemplo, hermano_drcha (n4) = n5} 81
  • 80. TEtiqueta etiqueta () {Devuelve la etiqueta del nodo n en el árbol T} void reEtiqueta (TEtiqueta e) {Asigna una nueva etiqueta e al nodo n} TNodo raiz () {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) {Inserta el árbol Ti como hijo a la izquierda del nodo n. Como precondición n no es NODO_NULO y Ti no es ARBOL_VACIO} void insertar_hermano_drcha (TNodo n) {Inserta el árbol Td como hermano a la derecha del nodo n. Como precondición n no es NODO_NULO y Td no es ARBOL_VACIO} TArbol podar_hijo_izqda (TNodo n) {Devuelve el subárbol con raíz hijo a la izquierda de n el cual se ve privado de estos nodos. Como precondición n no es NODO_NULO} TArbol podar_hermano_drcha (TNodo n) {Devuelve el subárbol con raíz hermano a la derecha de n, el cual se ve privado de estos nodos. Como precondición n no es NODO_NULO} 82
  • 81. Implementación de árboles basada en celdas enlazadas Vamos a declarar un nodo de forma que la estructura del árbol pueda ir en aumento mediante la obtención de memoria de forma dinámica, haciendo una petición de memoria adicional cada vez que se quiere crear un nuevo nodo. Bajo esta implementación cada nodo de un árbol contiene 3 referencias: padre que apunta al padre, hizqda que apunta al hijo izquierdo y herdrcha que apunta al hermano a la derecha del nodo. La definición de la clase TNodo es como sigue, se ha incluido en ella todas aquellas operaciones de los arboles que trabajan únicamente con nodos: Public class TNodo { Public static final NODO_NULO NULL; Object etiqueta; TNodo padre,hizqda,herdrcha; Public TNodo (Object u){ //Constructor etiqueta=u; padre=hizqda=herdrcha=NULL; } TNodo padre () { return (padre); } 83
  • 82. TNodo hizqda () { return (hizqda); } TNodo herdrcha () { return (herdrcha); } TEtiqueta etiqueta () { return (etiqueta); } void reEtiqueta (TEtiqueta e) { etiqueta = e; } }// fin de la clase TNodo A continuación se detalla como quedaría la clase TArbol a partir de lo especificado en la clase TNodo: Public class TArbol{ Public static final ARBOL_VACIO NULL TNodo raiz; TNodo TArbol (Object u) { raiz=new TNodo(u); } 84
  • 83. TNodo raiz(){ return (raiz); } void insertar_hijo_izqda (TNodo n) { raiz.herdrcha=n.hizqda; raiz.padre=n; n.hizqda=raiz; } void insertar_hermano_dcha(TNodo n) { raiz.herdrcha=n.herdrcha; raiz.padre=n.padre; n.herdrcha=raiz; } TArbol podar_hijo_izqda (TNodo n) { TArbol Taux=new TArbol(); Taux.raiz = n.hizqda; if (Taux!=ARBOL_VACIO){ n.hizqda = Taux.raiz.herdrcha; Taux.raiz.padre = TNodo.NODO_NULO; Taux.raiz.herdrcha = TNodo.NODO_NULO; } return (Taux); } TArbol podar_hermano_drcha (TNodo n) { TArbol Taux=new TArbol(); 85
  • 84. Taux.raiz = n.herdrcha; if (Taux!=ARBOL_VACIO){ n.herdrcha = Taux.raiz.herdrcha; Taux.raiz.padre = NODO_NULO; Taux.raiz.herdrcha = NODO_NULO; } return (Taux); } private static void preorden (TNodo n) { Escribir (n.etiqueta ()); for(n=n.hizqda();n!=TNodo.NODO_NULO;n=herdrcha()) preorden (n); } public void preordenAr(){ preorden(raiz); } private static void inorden (TNodo n) { TNodo c; c=n.hizqda (); if (c!=TNodo.NODO_NULO){ inorden (c); Escribir (n.etiqueta ()); for (c=c.herdrcha();c!=TNodo.NODO_NULO;c=c.herdrcha ()) inorden (c); 86
  • 85. } else Escribir (n.etiqueta ()); } public void inordenAr(){ inorden(raiz); } private void static postorden (TNodo n) { TNodo c; for (c=n.hizqda();c!=TNodo.NODO_NULO;c=c.herdrcha ()) postorden (c); Escribir (n.etiqueta ()); } public void postordenAr(){ postorden(raiz); } }//fin de la clase Implementación de los recorridos de un árbol 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. 87
  • 86. El procedimiento recursivo que, dado un nodo n, lista las etiquetas en preorden del subárbol con raíz n se ha definido en la clase TArbol, se ha utilizado 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. El recorrido preorden no recursivo , necesita una pila para encontrar el camino alrededor del árbol. El tipo TPila es realmente pila de nodos, es decir, pila de posiciones de nodos. La idea básica subyacente al algoritmo es que cuando estamos en un nodo p, la pila alojará el camino desde la raíz a p, con la raíz en el fondo de la pila y el nodo p en la cabeza. El programa tiene dos modos de operar: • En el primer modo desciende por el camino más a la izquierda en el árbol, escribiendo y apilando los nodos a lo largo del camino, hasta que encuentre una hoja. • En el segundo modo de operación en el cual vuelve hacia atrás por el camino apilado en la pila, extrayendo los nodos de la pila hasta que se encuentra un nodo en el camino con un hermano a la derecha. Entonces tras ello el programa vuelve al primer modo de operación, comenzando el descenso desde el inexplorado hermano de la derecha. El programa comienza en modo uno en la raíz y termina cuando la pila está vacía. Veamos otra versión de preorden mediante el uso de una Pila: 88
  • 87. void preordenAr () { Pila P; /*Pila de posiciones: TElemento de la pila es el tipo nodo*/ TNodo m; P = new Pila (); /*creación del TDA Pila*/ m=raiz (); do { if (m!=TNodo.NODO_NULO)){ Escribir (n.etiqueta ()); P.Push (m); m = m.hizqda (); } else if (!P.vacia ()){ m=(P.tope()).herdrcha (); P.pop (); } }while (!P.vacia ()); } Inorden: los pasos a seguir son los siguientes:• 1. Recorrer el subárbol más a la izquierda en inorden. 2. Visitar la raíz. 3. Recorrer el subárbol del siguiente hijo a la derecha en inorden. El procedimiento recursivo para listar las etiquetas de sus nodos en inorden se ha definido en la clase TArbol. 89
  • 88. 90 • Postorden: los pasos a seguir son: 1. Recorrer el subárbol más a la izquierda en postorden. 2. Recorrer el subárbol de la derecha en postorden. 3. Visitar la raíz. El procedimiento recursivo para listar las etiquetas de sus nodos en postorden se ha definido en la clase TArbol El TDA Árbol Binario Un árbol binario puede definirse como un árbol que en cada nodo puede tener como mucho grado 2, es decir, a lo más 2 hijos. Los hijos suelen denominarse hijo a la izquierda e hijo a la derecha, estableciéndose de esta forma un orden en el posicionamiento de los mismos. Todas las definiciones básicas que se dieron para árboles generales permanecen inalteradas sin más que hacer las particularizaciones correspondientes. En los árboles binarios hay que tener en cuenta el orden izqda- drcha de los hijos. Vease los siguientes ejemplos: 1 1 1 2 2 2 3 4 3 4 3 4
  • 89. 5 5 5 (a) (b) (c) Si los árboles binarios (a) y (b) son diferentes, puesto que difieren en el nodo 5. El árbol (c) es el correspondiente árbol general, por convenio se supone igual al (b) y no al (a), aunque los árboles generales no son directamente comparables a los árboles binarios. Especificación del TDA Árbol Binario Vamos a notar al tipo árbol binario como TArbolB y al tipo nodo como TNodoB. Las operaciones primitivas para el TDA árbol binario son las siguientes: TArbolB (TEtiquetaB e, TArbolB ti,TArbolB td) {Devuelve un árbol cuya raíz contiene la etiqueta e, y como subárbol a la izquierda ti y como subárbol a la derecha td} TNodoB padreB () {Devuelve el padre del nodo n. Si n no tiene padre, devuelve NODO_NULO . Como precondición n no es NODO_NULO} TNodoB hizdaB () {Devuelve el hijo a la izquierda del nodo, y devuelve NODO_NULO si no tiene hijo a la izquierda.} 91
  • 90. TNodoB hdchaB () {Devuelve el hijo a la derecha del nodo y devuelve NODO_NULO si no tiene hijo a la derecha. } TEtiqueta etiquetaB () {Devuelve la etiqueta del nodo.} void reEtiquetaB (TEtiqueta e) {Asigna una nueva etiqueta e al nodo.} TNodoB raizB () {Devuelve el nodo que está en la raíz del árbol o NODO_NULO si el árbol es BINARIO_VACIO } void insertar_hizdaB (TNodoB n) {Inserta el árbol sobre el que se aplica el método como hijo a la izquierda del nodo n. Como precondiciones n no es NODO_NULO } void insertar_hdchaB (TNodoB n) {Inserta el árbol sobre el que se aplica el método como hijo a la derecha del nodo n. Como precondiciones n no es NODO_NULO } TArbolB podar_hizdaB (TNodoB n) {Devuelve el subárbol con raíz hijo a la izquierda de n, el cual se ve privado de estos nodos. Como precondición n no es NODO_NULO} 92
  • 91. TArbolB podar_hdchaB (TNodoB n) {Devuelve el subárbol con raíz hijo a la derecha de n, el cual se ve privado de estos nodos. Como precondición n no es NODO_NULO} Implementación del TDA Árbol Binario Vamos a realizar una implementación mediante referencias como sigue: public class TEtiquetaB { String cadena; } class TNodoB { public static final TNodoB NODO_NULO = null; TEtiquetaB etiqueta; TNodoB hizda, hdcha, padre; public TNodoB (TEtiquetaB etiqueta) { this.etiqueta=etiqueta; hizda=hdcha=padre=null; } public TNodoB padreB () { 93
  • 92. return padre; } public TNodoB hizdaB () { return hizda; } public TNodoB hdchaB () { return hdcha; } public TEtiquetaB etiquetaB () { return etiqueta; } public void reEtiquetaB (TEtiquetaB e) { etiqueta=e; } } public class TArbolB { public static final TArbolB BINARIO_VACIO = null; private TNodoB raiz; public TArbolB (TEtiquetaB etiqueta) { raiz=new TNodoB(etiqueta); } 94
  • 93. public TArbolB (TEtiquetaB etiqueta, TArbolB ti, TArbolB td) { raiz=new TNodoB(etiqueta); raiz.hizda=ti.raiz; if (ti!=BINARIO_VACIO) ti.raiz.padre=raiz; raiz.hdcha=td.raiz; if(td!=BINARIO_VACIO) td.raiz.padre=raiz; raiz.padre=null; } public TArbolB (TNodoB raiz) { this.raiz=raiz; } public TNodoB raizB () { return raiz; } public void insertar_hizdaB (TNodoB n) { // Insertar el arbol como hijo izda de nodo n n.hizda=raiz; if (raiz!=TNodoB.NODO_NULO) raiz.padre=n; } public void insertar_hdchaB (TNodoB n) { // Insertar el arbol como hijo dcha de nodo n n.hdcha=raiz; if (raiz!=TNodoB.NODO_NULO) raiz.padre=n; } 95
  • 94. public TArbolB podar_hizdaB (TNodoB n) { if (n!=TNodoB.NODO_NULO) { if (n.hizda!=TNodoB.NODO_NULO) { TArbolB t = new TArbolB(n.hizda); n.hizda=TNodoB.NODO_NULO; return t; } } return BINARIO_VACIO; } public TArbolB podar_hdchaB (TNodoB n) { if (n!=TNodoB.NODO_NULO) { if (n.hdcha!=TNodoB.NODO_NULO) { TArbolB t = new TArbolB(n.hdcha); n.hdcha=TNodoB.NODO_NULO; return t; } } return BINARIO_VACIO; } private void preorden (TNodoB n) { if (n!= TNodoB.NODO_NULO) { escribir (n.etiquetaB()); preordenArB (n.hizdaB()); preordenArB (n.hdchaB()); } } 96
  • 95. void preordenArB () { preorden(raiz); } private void postorden (TNodoB n) { if (n!= TNodoB.NODO_NULO) { postorden(n.hizdaB()); postorden(n.hdchaB()); escribir (n.etiquetaB()); } } void postordenArB () { postorden(raiz); } private void inorden (TNodoB n) { if (n!= TNodoB.NODO_NULO){ inorden (n.hizdaB()); escribir (n.etiquetaB()); inorden (n.hdchaB()); } } void inordenArB () { inorden(raiz); 97