Estructura de la información

Anuncio
Estructura de la información
TEMA 1 TIPOS ABSTRACTOS DE DATOS
Tema 1
Tipos abstractos de datos
1. Concepto TAD
2. Una jerarquía de TAD
3. Implementación de TAD
4. Eficiencia de las
implementaciones
5. Representación de
contenedores usando vectores
6. Implementación de TAD con
una memoria dinámica
1. CONCEPTO TAD
Un tipo abstracto de Datos se define como un dominio de valores sobre los
que se pueden aplicar una o más operaciones, que también forman parte del TAD.
El concepto de TAD es la evolución natural de la noción más primitiva de tipos de
datos, propia de los programas de los años sesenta, que se centraban en los
valores y obviaban las operaciones.


Un TAD consta de dos partes:
La Especificación: introduce las operaciones de TAD (nombre e interfaz) y
fijan su comportamiento.
La Implementación: Determina una representación de los valores del TAD y
la codificación de las operaciones de acuerdo con esta representación.
En Orientación a Objetos, la clase es la unidad básica de encapsulación, que
combina un conjunto de datos (representado por los atributos) y un conjunto de
operaciones (también denominados métodos). Los objetos que interaccionan en
los programas construidos con lenguajes orientados a objetos son instancias de
clases: sólo pueden tomar los valores permitidos por la clase y solo se les puede
aplicar métodos de clase. Los objetos se crean mediante los llamados métodos
constructores, y se destruyen también mediante métodos o bien de forma
implícita.
Algunas clases no permiten crear instancias, bien porque hay algún método
sin implementar (clases diferidas), bien porque la clase caracteriza ciertas
propiedades del dominio (clases abstractas). Por otro lado, existen las clases
genéricas en las que el tipo de determinantes atribuidos está aún sin concretar, su
utilidad es enorme porque permiten definir categorías similares de clases de una
sola vez.
2. UNA JERARQUÍA DE TAD
Operaciones de los
contenedores
OPERACIONES BÁSICAS
Inserción
Supresión
Consulta sobre elementos
OTRAS OPERACIONES
Creación
Destrucción
Predicados
Recorridos…
El Objetivo de este apartado es ofrecer un catálogo versátil de TAD
encapsulados en clases genéricas y organizados jerárquicamente, para poder
gestionar colecciones de elementos con distintas políticas de accesos. Empezamos
por introducir el concepto de contenedor como nexo de todos los TADs que
forman una jerarquía. Un contenedor es cualquier TAD que se caracterice por
contener un conjunto de elementos (por ejemplo un TAD para registrar los socios
de un club, las direcciones web de una hiperagenda, etc).
El concepto de contenedor es muy general, lo representamos mediante una
clase diferida parametrizada:
 Diferida porque algunos métodos no se pueden implementar.
 Parametrizada porque el comportamiento del TAD es independiente de los tipos
de elementos almacenados.



Los contenedores se caracterizan porque:
Debe ser posible crearlos y destruirlos.
Todo contenedor tendrá una ocupación determinado, por eso definimos un
atributo tamaño que registra el número de elementos en él contenidos.
Sus elementos son todos del mismo tipo.
2 · Estructura de la información
Se ofrece en la página siguiente una figura en representación UML de la clase
contenedor y de varias clases herederas que estudiaremos en este módulo.
Jerarquía de clases


Contenedores recorribles: Una de las funciones que solemos pedir a los
contenedores es la obtención de todos sus elementos, a veces siguiendo un
orden determinado, a veces aleatorio. En cualquier caso, todos los
contenedores recorribles seguirán el mismo patrón: se situarán sobre el primer
elemento, y después consultarán el elemento actual y avanzarán al siguiente
reiteradamente hasta llegar al final. Como paso previo a la definición de
contenedores recorribles, formulamos la clase iterator que ofrece las cuatro
opciones mencionadas y se definen los contenedores recorribles por herencia
múltiple de las clases cotenedor e iterator.
Contenedores acotados: Los TAD contenedores no pueden tener una
capacidad infinita dado que todo ordenador no tiene una capacidad infinita; por
eso se introduce esta clase que controla que no se produzca un
desbordamiento en esta familia de Tad. Normalmente le pasamos la dimensión
como parámetro de la operación de creación pues resulta más fácil en el
entorno Java.
Por último, establecemos una jerarquía de elementos añadiendo varias
especializaciones: ElemOrd (orden total), ElemEsp (identifica un elemento especial),
ElemClave (define los elementos en los que se puede distinguir una clave que los
identifica).
En un segundo nivel, por herencia múltiple de estas clases, encontramos:
ElemOrdClave (permite comparar las claves de los elementos de ElemClave),
ElemClaveEsp (la clave tiene un valor especial que utilizamos sobre todo como
indicador de inexistencia de un elemento en un contenedor) y ElemSum (añade una
operación de suma de los elementos, así como un segundo elemento especial, esta
clase nos permitirá asignar pesos a los elementos). Todos estos conceptos
podemos verlos agrupados en la imagen de la página siguiente
Estructura de la información · 3
Los TAD, son, por tanto, el eje vertebrador de la asignatura, y un efecto de la
variedad de los mismos es la posible combinación entre ellos, heredando atributos,
métodos y restricciones propios de su raiz correspondiente.
Jerarquía de elementos
Clasificación de los contenedores
Clases de Contenedores
SECUENCIAS
Pilas
Colas
Colas prioritarias
Listas con puntos de interés
ÁRBOLES BINARIOS
CONJUNTOS
Conjuntos como tales
Sacos
FUNCIÓN
Función Parcial
Función Total
RELACIONES
Relaciones Binarias
Grafos Dirigidos
Grafos Dirigidos Etiquetados
Grafos No Dirigidos Etiquetados
Dada su funcionalidad, distinguimos cinco clases principales de
contenedores, que describimos a continuación (cada una con sus particularidades y
a la vez, subclases):
Las Secuencias (1ª clase) representan disposiciones lineales de
elementos. Existe un primer elemento, un último y una política de acceso lineal a
todos ellos. Los elementos se añaden de uno en uno. Existen muchos TAD con este
tipo de disposición, pero vamos a destacar las pilas y colas. Las pilas se
caracterizan porque se borra y se consulta el último elemento insertado (como una
pila de platos, el último en poner es el primero que debemos coger) mientras que
en las colas se consulta y se borra el primer elemento insertado (como en la cola
del cine), a excepción de las colas prioritarias en las que la posición que ocupa
un elemento dentro de la cola viene dada por su prioridad y no por el orden de
inserción. Otro caso especial son las listas cuyo nombre auténtico es listas con
puntos de interés, en las que los elementos se pueden insertar y borrar en
cualquier secuencia. (podemos observar la jerarquía colgante en la página
siguiente).
Los árboles binarios (2º) nos sirven para introducir relaciones
jerárquicas. Cada elemento está por encima de dos más, como mucho y está por
debajo de un tercero (excepto el elemento raiz). En este tipo la clase todavía no
está definida y la política de obtención de elementos tampoco. Lo que si es
necesario es tratar un mismo árbol con diferentes recorridos: inorden, posorden…
Los conjuntos (3º) tienen una operación conmutativa para añadir
elementos, y la operación de suprimir, por su parte, no viene dada por la posición
4 · Estructura de la información
del elemento, sino por una operación que explicita exactamente el elemento a
suprimir. Distinguimos dos categorías de conjuntos: los conjuntos como tales, y los
conjuntos con repeticiones denominados sacos. En los primeros los elementos no
pueden aparecer repetidos, en los segundos sí, y por ello la supresión de un
elemento elimina solo una aparición de un elemento. Como es habitual, en la
jerarquía colgante, podemos crear las versiones recorribles.
Jerarquía colgante de Secuencia
Las funciones (4º) se caracterizan por la
existencia de un segundo dominio de elementos. El
contenedor se ocupa de asociar un valor de este
segundo dominio (alcance) a todo elemento del
primero que aparezca (dominio). Por este motivo, la
inserción implica pares de elementos. La supresión y
la consulta se llevan a cabo de acuerdo con el primer
elemento insertado. Este concepto es el de función
parcial. Pero por otro lado existen también las
funciones totales, que exigen que el alcance de la
función tenga un elemento especial, que es el
resultado que se obtiene cuando se consulta un
elemento que no forma parte del dominio de la
función. (ElemEsp).
Otra característica especial es cuando uno de
los dos dominios está contenido en el otro (por
ejemplo una función que dado el DNI de una persona
proporciona la ficha completa de ésta, incluido el
mismo DNI).
Jerarquía colgante de Conjunto
Jerarquía colgante de Función
Por último, restan las relaciones (5º y último
tipo de jerarquía de clase) que son relaciones binarias
entre pares de elementos. Al igual que sucedía con las
funciones, hay dos dominios de elementos
involucrados en el contenedor, y las operaciones de
añadir o borrar se refieren al establecimiento o
ruptura de interrelaciones entre elementos de los dos dominios. Es importante la
operación para obtener todos los elementos de un dominio relacionados con un
Estructura de la información · 5
elemento de otro. Dado que además se trata de dos recorridos posibles, conviene
heredar dos veces de los iteradotes (como vemos en la página siguiente).
También es importante la asociación de pesos
llamadas etiquetas, que deben presentar ciertas
operaciones para manipularlas cómodamente.
Clases de Relaciones
Otra especialización básica es el TAD de los
grafos, que tiene lugar cuando todos los dominios
son el mismo. Tenemos entonces los grafos dirigidos
(restricción sobre dominios), grafos dirigidos
etiquetados.
Hay dos aspectos importantes en la
traducción de estas jerarquías a Java, y son la
genericidad y la herencia múltiple.



Dado que Java no ofrece genericidad
podemos aprovechar que Java consideran que todas
sus clases heredan de una superclase llamada Object.
Así definimos los contenedores sobre valores Object y
éstos pueden tomar cualquier forma y se les puede
aplicar los métodos adecuados. Sin embargo esto
también nos puede conducir a 3 problemas
principales:
Se pueden construir contenidos heterogéneos donde se mezclen elementos de
varias clases.
La clase Object no ofrece alguna de las funciones que algunos contenedores
necesitan más adelante.
Los elementos del contenedor deben ser objetos de clase y éste no es el caso
habitual de valores de tipos predefinidos.
El otro gran problema es la simulación de la herencia múltiple que Java no
soporta, pero que podemos eludir usando las interfaces de Java para simularla
(recordemos que las interfaces son clases en las que todos los métodos son
virtuales). Las propiedades más importantes es que una interfaz puede heredar de
más de una interfaz (múltiple); una clase además puede implementar una o más
interfaces (totalmente o parcialmente).
Dos detalles más al implementar con Java: las operaciones constructoras deben
tener el mismo nombre de la clase; y no hay operaciones de destrucción dado que
Java hace gestión de la memoria dinámica que destruye automáticamente los
objetos.
3. IMPLEMENTACIÓN DE TAD


La implementación de un TAD consta de 3 partes:
Decidir la representación del TAD, es decir, la estrategia de representación
de sus valores. Decidir, por tanto, un método de almacenamiento de los
elementos del contenedor y representarlo mediante atributos privados de la
clase (Podemos decir que se trata de elegir una de los 5 contenedores antes
comentados). Los atributos que podemos elegir son los tipos voléanos,
numéricos, caracteres, etc, hasta los vectores y tuplas.
Establecer las relaciones entre los atributos mediante el invariante de la
representación, que identifica los estados admisibles de los objetos de la
clase. Consistirá en un booleano y su efecto es documental. Forma parte de la
precondición y postcondición de toda operación pública o protegida del TAD; y
la idea fundamental es que independientemente de si usamos una notación
6 · Estructura de la información
formal, semiformal o totalmente informal, una clase de implementación siempre
debe incluir este invariante.
 Escribir algoritmos que codifiquen cada una de las operaciones del TAD. Estos
algoritmos deben usar atributos de la clase y pueden invocar métodos de otras
clases con las que tiene establecida una relación de uso. Una parte importante
de estos algoritmos son el invariante del bucle y consiste en un predicado
booleano que se debe cumplir cuando se empieza y acaba la ejecución de un
bucle, así como justo antes de entrar y salir del mismo. Otra parte importante
de la codificación de las operaciones es el encapsulado de operaciones, que se
realizan en métodos que actúan siempre sobre objetos (eventualmente serán
funciones, si retornan algún valor).
Existen, por tanto, muchas representación de TAD posibles para implementar
en un mismo problema, pero podemos llegar a necesitar otras distintas según los
casos, por lo que es muy interesante contar con una variada librería de TADs
eficaces y comprobados.
4. EFICIENCIA DE LAS IMPLEMENTACIONES
El problema de medir una implementación para saber si es correcta (no
existe la “mejor” implementación, sino la más correcta para unos determinados
requisitos de uso) surge al elegir los criterios por el cual vamos a medirlos, estos
criterios deben cumplir:






Deben ser completamente objetivos, o sea, calculable a partir de unas reglas
deterministas.
Se tiene que basar en un análisis del código y no en pruebas de ejecución.
Tiene que ser independiente de la máquina que ejecutará el código, por lo cual
la medida en tiempo (segundos) o en espacio (bytes) no son las indicadas.
Tiene que ignorar factores constantes que los avances tecnológicos acabarán
superando.
Tiene que considerar la dimensión de los datos de entrada.
Tiene que considerar los grandes volúmenes de datos, porque es cuando las
diferencias son realmente importantes.
Por ello, para medir la eficiencia de las implementaciones tanto en tiempo como
en espacio, nos decidimos por las notaciones asintóticas, las cuales no intentan
establecer el tiempo o espacio exacto de ejecución, sino que caracterizan estas
magnitudes mediante una función sobre el volumen de los datos de entrada. Hay
varias familias de notaciones asintóticas, pero nosotros nos decidimos por la
llamada O-grande, que calcula la eficiencia en el peor de los casos.
La O grande es el conjunto de funciones para las que f es asintóticamente una
cota superior. Es decir, a partir de cierto punto No, solo hay que multiplicar f por
una constante Co para conseguir que el valor de las funciones g este siempre por
debajo del valor de f.
Esto quiere decir que dado el algoritmo o representación de tipo que
queremos evaluar, obtenemos una función g(n) donde n es la dimensión de los
datos de entrada. Esta g pertenecerá a O(f) para una f dada que caracterice la
eficiencia del algoritmo o representación. Para la mayoría de los algoritmos la
función f, estará dentro de una de las siguientes categorías:
Coste
Operaciones de los
contenedores
Representación y Características
Constante
f(n)=k
Logarítmica
f(n)=log n
Se representa mediante O(log n), y encontramos investigaciones dicotómicas
Lineal
f(n)=n
Se representa por O(n), como la búsqueda de un elemento en un vector odenado
Cuasi-lineal
f(n)=n log n
O(n log n), y el ejemplo es el método de ordenación de vectores
Cuadrática
f(n)=n2
Se representa por O(n2) y surge en algoritmos de 2 bucles anidados o vectores bidimensionales
Cúbica
f(n)=n3
Se representa por O(n3) y surge en algoritmos de 3 bucles anidados o vectores tridimensionales
n
Se representa por O(kn) y surge en algoritmos que ensayan soluciones hasta encontrar la buena
Exponencial
f(n)=k
Se representa mediante O(1), suele ser la asignación de valores de tipos simples
Estructura de la información · 7
Las categorías anteriores se encuentran ordenadas de mejor (menos
tiempo o espacio) a peor.
Eficiencia temporal
La función de eficiencia temporal T(x) denota el tiempo de ejecución del
fragmento de código x medido con la notación O. Para medir esta eficiencia
temporal tendremos siempre presentes las siguientes reglas:







El acceso a un atributo o posición de vector de tipo predefinido y las
operaciones aritméticas y booleanas tienen coste O(1).
La evalucación de una expresión tiene el coste de evaluar sus subespresiones
más el coste del operador.
El coste de una composición secuencial de instrucciones, es la suma de los
costes de las instrucciones individuales que la componen.
El coste de la asignación es el de la evaluación de las expresiones que aparecen
más el de transferencia de datos que corresponde a su dimensión.
El coste de una instrucción alternativa es la suma de los costes de evaluar la
expresión y ejecutar las dos ramas.
El coste de invocar a un método es el de la evaluación de las expresiones que
se pasan como parámetros más el coste de la función invocada.
El coste de una instrucción iterativa es el resultado de multiplicar el número de
vueltas por el coste de una vuelta.
Además, en las operaciones internas del método O grande, estableceremos las
siguientes propiedades:




O(c x f(n)) = O(f(n))
C x O(f(n)) = O (f(n))
f(n) x O(g(n)) = O (f(n) x g(n))
La suma de dos o más O es la mayor de todas ellas.
Eficiencia espacial
La función de eficiencia espacial E(x) denota el espacio ocupado por la
representación de tipo x medido con la notación O. Su determinación se basa en las
siguientes reglas:




Representación de contenedores usando vectores
Representación secuencial
Rep. secuencial “simple”
Rep- secuencial marcada
Rep. Secuencial ordenada
Representación encadenada
Rep. Encadenada “simple
Rep. Encadenada circular
Rep. Doblemente
Un atributo o posición de un vector de tipo predefinido, consume un espacio
O(1), igual que cualquier referencia a un objeto.
Un vector de n posiciones ocupa el producto de n por el espacio de cada
posición (por ejemplo si son tipos predefinidos sería entonces O(n) ).
Una tupla de n componentes ocupa la suma de los espacios de los
componentes.
El espacio de la implementación de un TAD es igual a la suma del espacio
requerido por sus atributos.
A veces se prefiere contar el espacio en términos de espacio de memoria
ocupada (bytes); esto se debe al hecho de que muy a menudo las alternativas
tendrán un comportamiento asintótico idéntico y se tendrá que llevar a cabo un
estudio más cuidadoso para compararlas.
5. REPRESENTACIÓN DE CONTENEDORES USANDO VECTORES
El punto clave de la implementación de los TAD de los contenedores es la
estrategia de almacenamiento, que veremos en este apartado pero solo en lo
8 · Estructura de la información
referente a guardarlos en un vector, dejando para el siguiente el uso de la memoria
dinámica; y nos centraremos en el estudio del saco acotado.
Representación secuencial
La forma más simple de representar un contenedor consiste en almacenar
todos sus elementos en un vector añadiendo un valor natural que delimita la parte
ocupada y la parte libre del vector. El apuntador de sitio libre (apunta a la
primera posición libre) o apuntador al último (si apunta a la ultima posición
ocupada).
La figura lateral muestra el estado de un vector con apuntador de sitio libre
que almacena n elementos desde v1 hasta vn. Este tipo de implementaciones
suelen tener características comunes:
Interfaz saco acotado
Saco acotado
Tamaño, maximo; Nat
Crea (n:Nat)
Vacio()
Lleno()
nbElems()
destruye()
añade (v:Elem)
borra (v:Elem): Bool
nAps (v: Elem): Nat
Elem
Vector secuencial





Son simples y muy fácilmente comprensibles
Espacialmente son óptimas si conocemos el número de elementos que se
tienen que almacenar, ya que solo necesita espacio para éstos y algún
apuntador adicional de conste prescindible.
La consulta tiene coste lineal a no ser que los elementos estén ordenados y
apliquemos la búsqueda dicotómica con coste logarítmico (mucho mejor).
La inserción es más o menos costosa si hay que controlar la existencia previa
del elemento
La supresión es de coste lineal a causa de la búsqueda. Una vez localizado el
elemento a eliminar se tiene que mover el último elemento ocupado a la
posición borrada.
Es útil esta primera representación cuando los requisitos de eficiencia no son
demasiado elevados o cuando el volumen de datos a manejar es reducido.
La representación por tanto quedará como sigue: disponemos los
elementos de los sacos en posiciones consecutivas de un vector (cada aparición de
un mismo elemento ocupa un espacio) empezando por la primera y siguiendo la
estrategia secuencial. Habrá un contador de elementos que en realidad es el mismo
atributo tamaño que se hereda de contenedor. Declaramos el atributo vector y
reservamos en su interior una posición adicional para usar la técnica del centinela
en las búsquedas exigidas por la supresión y consulta de exigencia. El invariante,
debe poner trabas a que se supere el tamaño máximo permitido, claro.
Cuando se suprime un elemento hay que buscar la primera aparición (dado
que todos son equivalentes) y copiaremos el último elemento de la parte ocupada
del vector en ese lugar eliminado. Para contar el número de apariciones de un
elemento se hace imprescindible recorrer todo el vector e ir contando.
Respecto a la eficiencia de esta implementación obtenemos que el coste
de crear es O(máximo x n) -recordemos que máximo es el tamaño máximo de
cada elemento que, a priori no podemos conocer- y n el número de elementos.
Comprobar si esta vacio, lleno cuesta O(1), insertar tiene coste O(n), borrar tiene
coste O(máximo x n), ver si existe un elemento determinado O(máximo x n). El
coste espacial es de O(máximo x n) como nos podíamos imaginar y en cuanto al
coste espacial real es de (máximo + 1) x tamaño de los elementos + 2 naturales.
Representación secuencial marcada
El movimiento de elementos exigido por las actualizaciones secuenciales
provoca inconvenientes cuando se trata de TADs complejos; además también da
problemas cuando se acoplan dos estructuras diferentes interrelacionadas. Lo que
haremos para evitarlo será realizar una marca: es un indicador del estado de la
posición del vector, que puede indicar libre u ocupada. En el dibujo lateral
observamos que aparecen posiciones marcadas, es decir elementos que estuvieron
dentro de la estructura en un pasado, pero que se han borrado y ya no son
relevantes. Entonces, la supresión no efectúa ningún movimiento, sino que la
Rep. Secuencial marcada
Estructura de la información · 9
marca de la posición que contiene el elemento que se debe borrar se activa; se dice
que la posición se marca y está disponible para ser “rellenada”.
Ahora bien, en la inserción de un elemento podemos hacer dos cosas:
 Cuando se agota la parte libre se reorganiza el vector para eliminar las
posiciones marcadas y volver a la situación ideal, sin marcas y vacías; pero esto
hace que movamos elementos, función que queriamos evitar con la utilización
de las marcas.
 En cada inserción hay que buscar la primera posición marcada del vector y
ocuparla con el nuevo elemento. Lo hemos reciclado.
La búsqueda es una pena pero también pasa por las posiciones marcadas a
pesar de que sabemos que no contendrán el elemento buscado.
Representación secuencial ordenada
Las estrategias secuenciales penalizan las operaciones de actualización del
contenedor como la consulta individual de los elementos. Lo primero puede resultar
aceptable, pero la consulta tiene muy mal comportamiento y a veces las
rechazamos. Para evitarlo tenemos las representaciones secuenciales ordenadas
que es una estrategia que optimiza las consultas.
Se caracteriza porque no añadimos ningún atributo nuevo, solo que el
invariante añade una restricción, es decir, cada elemento es mayor que el anterior
y menor o igual que el siguiente (para el caso de las sucesivas apariciones de un
mismo elemento). Así, la operación de consulta deja de ser lineal y pasa a ser
logarítmico, que es muy aceptable.
La única pega de esta nueva estrategia es que todo cambio en la
configuración del vector obliga a mover elementos para asegurar su continuidad y
su ordenación; pero dado que su coste (el de la inserción y supresión) ya era de
O(máximo x n) tampoco perdemos nada. Y en cambio, las operaciones consultoras
salen ganando muchísimo. En cuanto al coste espacial, el gasto es el mismo que
antes.
Representaciones encadenadas
De cualquier forma, las representaciones secuenciales suponen tanto al
añadir como al borrar elementos, todo un gran movimiento de los mismos para
asegurarnos de su secuencialidad y, en otros casos también de su orden. Las
principales pegas son:



Vectores encadenados
Como los TADs son genéricos, los elementos a los que hacemos referencia
pueden ser realmente grandes y, en este caso y al desconocer su dimensión, el
gasto en el movimiento de elementos puede ser muy grande.
Los movimientos pueden desajustar varias estructuras acopladas.
El número de movimiento según el tamaño del TAD puede resultar excesivo.
Para evitar esto tenemos las estrategias de representación
encadenadas también llamadas estrategias enlazadas. En este caso en el
vector no habrá zonas diferenciadas ocupadas o libres, sino que cualquier
posición puede estar ocupada por un elemento o libre, independientemente del
estado de las posiciones más próximas. Esto se realiza añadiendo a la marca
que ya teníamos por cada posición un nuevo campo adicional de tipo entero, de
forma que una posición ocupada lleva a otra siguiendo el valor de este campo,
o sea, apunta a la siguiente dirección ocupada. En la imagen lateral podemos
ver esta estrategia con dos vectores que contienen los mismos elementos en el
mismo orden.
10 · Estructura de la información
Ahora bien, nos queda como resolver la gestión del espacio libre del vector.
Es decir, cada vez que se inserte un elemento hay que obtener una posición del
vector donde almacenarlo y, al borrarlo, hay que recuperar esta posición para
reutilizarla. Una primera solución es marcar las posiciones del vector como
ocupadas o libres y tomar espacio al final del vector hasta que se agote y
reorganizarlas; pero claro, estaríamos en el mismo problema del apartado anterior.
Lo que se puede hacer es encadenar los sitios libres igual que los ocupados pero
con la marca de libres y así se obtiene rápido una pila de sitios libres, y además
aprovechamos el mismo campo de encadenamiento para enlazar los elementos y
no ocupamos más espacio.
Gestión de sitios libres
La figura lateral muestra el mismo vector de la página anterior pero añade
la gestión de sitios libres y el llamado elemento fantasma que se deposita
cuando se crea la estructura encadenada y que simplifica los algoritmos de
manipulación del tipo, especialmente la supresión.
Representación encadenada circular y doblemente encadenada
En diferentes contextos nos vemos en la necesidad de adaptar las
estructuras vistas anteriormente para casos especiales. Dos son las principales
estructuras encadenadas que encontramos:


Rep. Encadenadas circulares que enlazan el último elemento con el
primero, así se puede acceder desde un elemento a cualquier otro (esté
situado delante o detrás) y podemos recorrer la estructura entera.
Rep. Doblemente encadenada, cada elemento apunta, no solo al
siguiente, sino también al anterior, de forma que podemos recorrer la
estructura en dos sentidos distintos.
Rep. encadenada circular
Rep. doblemente encadenada
En resumen, tenemos a continuación una tabla comparativa de
implementación de los sacos, cuya confección es de ayuda inestimable en el
momento de decidir cuál es la mejor implementación de un TAD en un contexto
determinado.
Estrategia
T(crea) T(añade)
T(borra)
T(nAps)
E(Saco)
Mov.
Rep. Secuencial
mxn
n
mxn
mxn
(m+1)xn0+2k
Sí
Rep. secuencial marcada
mxn
mxn
mxn
mxn
(m+1)x(n0+b)+2k
No
Rep. secuencial ordenada
mxn
mxn
mxn
Rep. encadenada
mxn
n
mxn
mxn
(m+1)x(n0+k)+3k
No
mxn
mxn
mxn
mxn
(m+1)x(n0+k)+3k
No
Rep. encadenada circular
log(m) x n (m+1)x(n0+k)+3k
Sí
Siendo m: máximo, n: E(Elem), k: E(nat) y b: E(bool)
6. IMPLEMENTACIÓN DE TAD CON MEMORIA DINÁMICA
Las representaciones vistas hasta ahora se basan en el uso de vectores
que, a veces resultan inconvenientes por que:



Hay que determinar su dimensión en algún momento.
El espacio ocupado por la estructura es fijo, independientemente de que esté
lleno o vacío.
La dimensión del vector actúa como cota del número máximo de elementos
que caben en el mismo, de forma que los TAD son finitos.
Para solucionar estos problemas se presenta la noción de memoria
dinámica que es el espacio del almacenamiento de datos que se adapta a las
necesidades del programa. Es decir, el programa puede ir depositando datos en la
Estructura de la información · 11
memoria dinámica a medida que estos se crean y aumentan; cuando ya no son
necesarios, el espacio usado para almacenarlos queda libre y es reutilizable.
La memoria dinámica en Java se caracteriza porque:



Cuando ya no hay ningún apuntador que de acceso a un objeto, éste es
destruido automáticamente por el recolector de basuras; aunque se puede
invocar manualmente al recolector también.
Los datos declarados de una clase (variables, atributos) nunca contienen los
valores reales, sino apuntadores a los mismos.
La creación del objeto se relaciona con la inicialización de sus atributos
mediante los constructores. Esto favorece que el objeto no se cree hasta que
realmente se necesita.
Para duplicar un objeto en Java utilizamos la función
clone que se limita a duplicar el estado del objeto pero los
atributos del nuevo objeto en realidad apuntan a los mismos del
viejo.
Comportamiento de Clone
No obstante, el uso de memoria dinámica también tiene
sus riesgos:



La memoria dinámica tampoco es infinita, dado que la memoria del ordenador
también puede agotarse.
Los punteros con clone son referencias directas a la memoria que, en algún
punto del programa pueden causar errores algorítmicos.
Varios punteros pueden designar un mismo objeto, la modificación del objeto
desde un puntero determinado da lugar a la modificación del objeto apuntado
por el resto de los punteros.
12 · Estructura de la información
TEMA 2 SECUENCIAS Y ÁRBOLES
1. PILAS Y COLAS
Las pilas y las colas son los dos modelos básicos de secuencias a los que se
puede acceder según una estrategia restringida diferente en cada caso. Los dos
tipos de secuencias se pueden considerar como un almacén lineal de elementos,
pero mientras que en el caso de las pilas la política de acceso es que lo último que
entra es lo primero que sale (pila de platos, por ejemplo), en el caso de las colas se
sigue un principio diferente: lo primero que entra es lo primero que sale (fila en el
cine para entrar a la sesión).
Las pilas se utilizan para transformar un programa recursivo en un
programa iterativo. También aparecen en el entorno de la programación en la
gestión de las llamada a subrutinas, asegurándonos que se vuelve siempre al
mismo punto que ha llamado a la rutina.
Las colas suelen aparecer con frecuencia para asegurar un acceso justo a
varios recursos.
1.1. Modelo y especificación
En ambos TADs, pilas y colas, son necesarias las siguientes operaciones:






Crear la secuencia vacía.
Añadir un elemento por un extremo,
Eliminar el elemento de uno de los extremos: en el caso de las pilas será el
último incluido y en el de las colas será el primero.
Obtener (consulta) el elemento de uno de los extremos: igual que en el caso
anterior es aconsejable saber si es el primero o el último.
Averiguar la longitud de la secuencia
Averiguar si la secuencia está vacía o no.
Estas especificaciones son válidas para pilas y colas no acotadas. En caso
de estar acotadas habrá que variar:



Crear la secuencia vacía: añadirá un parámetro con el número máximo de
elementos.
Añadir un elemento: debe controlas que no se sobrepasa la capacidad de la
secuencia.
Lleno?: Para saber si la secuencia admite la inserción de más elementos o no.
1.2. Implementaciones secuenciales. Vectores circulares.
Las implementaciones secuenciales se basan en el hecho de que los
elementos de la estructura se disponen de forma contigua en un vector, de modo
que la adyacencia lógica se corresponde con la adyacencia física. Para ello,
haremos uso de los apuntadores que sean necesarios.
La implementación secuencial de las pilas es muy sencillo, solo
necesitamos un apuntador de sitio libre que identifique donde acaba la parte
ocupada y donde empieza la parte libre y servirá de referencia para las operaciones
de inserción, supresión y consulta. En la imagen de la página siguiente podemos
observar la estrategia de la representación secuencial de las pilas. En este caso la
representación del tipo incluyo dos atributos: la tabla y el apuntador. El
invariante de la representación establece la restricción de valores sobre el
apuntador entre cero y el máximo dado y el coste de todas las operaciones es
Tema 2
Secuencias y árboles
1. Pilas y colas
2. Listas con punto de interés
3. Árboles binarios
4. Colas prioritarias
Estructura de la información · 13
Representación secuencial de las pilas
constante, mientras que el espacio necesario será
proporcional al número máximo de elementos de la
tabla.
La implementación secuencial de las
colas tiene una diferencia importante, y es que
ninguno de los dos extremos está fijo, como
podemos observar en la imagen lateral. Este
esquema plantea un problema obvio, el apuntador
de la derecha puede llegar al extremo derecho del
vector sin que la cola esté realmente llena, porque
quede un trozo libre a la izquierda. La solución
consiste en llevar el apunto sde la derecha a la
izquierda o, dicho con otras palabras, en considerar
que después de la posición max-1 viene la posición
0, con lo cual perdemos el concepto de derecha e
izquierda, y encontramos un sistema de gestión
circular que queda claro también en la
representación inferior.
Representación secuencial de las colas
La circularidad la obtenemos de forma
natural con la operación mod que calcula el resto
de la división entera, asi la expresión (x+1)mod
max, será x+1 si x se encuentra entre 0 y max-1 y
valdrá 0 si x=max-1.
Por otro lado, si los dos apuntadores son
iguales, el motivo puede ser tanto que la cola esté
vacía como llena; necesitamos un método adicional
para este caso particular, y lo que haremos será
aprovechar el atributo tamaño usado como
contador de elementos.
Las operaciones quedan de coste constante
y el espacio quedará lineal respecto al número
máximo de elementos, igual que pasaba en las
pilas.
1.3. Implementaciones encadenadas
Representación encadenada de una cola
Las implementaciones encadenadas de
cualquier clase de secuencia, exigen la existencia
de un encadenamiento único, de forma que si el
elemento A es el predecesor de B, el apuntador de
la celda que almacena A apunta a la celda que
almacena B.
Así observamos que en las colas son necesarios los
dos apuntadores, aunque nos podemos ahorrar uno
si
establecemos
una
representación
encadenada circular, es decir, si encadenamos el
último elemento al primero. Para mayor facilidad en
las implementaciones creamos el elemento fantasma
que nunca se borra; y así el último elemento apunta
al fantasma, y éste al primero.
Toda la mecánica de inserción y supresión sobre las
colas, puede observarse en la imagen lateral adjunta.
14 · Estructura de la información
2. LISTAS CON PUNTOS DE INTERÉS
Representación encadenada de las colas
El TAD de las listas es la generalización de los
tipos abstractos pilas y colas; pues en un alista se puede
insertar un elemento en cualquier posición y borrar y
consultar cualquiera de los mismos.
En la vida cotidiana las listas con punto de
interés pueden ser el hecho de seleccionar una canción
concreta de un CD o el cursor en Word que sirve de
referencia para saber donde se van a insertar o borrar
los siguientes caracteres, no teniendo que estar
necesariamente colocado al final.
2.1. Modelo y especificación
Como su nombre indica, las listas con punto de
interés se caracterizan por la existencia de un
elemento distinguido que sirve de referencia para casi todas las operaciones del
tipo; decimos que el punto de interés apunta al elemento distinguido. El punto de
interés puede desplazarse por la lista desde el primer puesto hasta el último en el
que haya un elemento, más allá dará error para ciertas operaciones como pueden
ser consulta, borrado…
Las operaciones que definen el TAD son:










Crear una lista vacía: Retorna una lista sin ningún elemento y deja el punto
de interés en el extremo derecho.
Insertar un elemento: Inserta un elemento ante el punto de interés, que no
cambia (decisión arbitraria sobre donde insertar los nuevos elementos).
Suprimir un elemento: Borra el elemento distinguido al que apunta y el
punto de interés queda sobre el siguiente elemento (esto es una decisión
arbitraria, pero alguna decisión sobre el apuntador había que tomar).
Modificar un elemento: Modifica el valor del elemento distinguido, sin
modificar el punto de interés.
Consultar un elemento: Retorna el elemento distinguido o da error si el
punto de interés está en el extremo derecho.
Obtener la longitud: de los elementos que contiene la cadena.
Mirar si la lista está vacía.
Poner el punto de interés al principio: Lo coloca sobre el primer elemento
si lo hay o en el extremo derecho si no hay ninguno.
Avanzar el punto de interés: El elemento distinguido pasa a ser el siguiente
al actual. O da error si ya estaba situado en el extremo derecho.
Mirar si el punto de interés está en el extremo derecho: Retornando
cierto o falso según el caso.
2.2. Implementación secuencial
Encontramos dos tipos de estrategias de representación secuencial: por un
lado la estrategia de almacenamiento consecutivo de los elementos en un vector, y
por otro la estrategia de partición de la lista en dos pilas.
En el almacenamiento consecutivo de los elementos en un vector
encontramos una estrategia habitual de almacenamiento en la parte izquierda del
vector, con un apuntador ll a la primera posición libre y act como punto de interés.
Observamos en la gráfica lateral la representación de la inserción y supresión de
elementos. Denotar que además este tipo de representación tiene su mayor
Ejemplo de operaciones en
lista con punto de interés
Estructura de la información · 15
dificultad en esto precisamente: la inserción y supresión, pues exige el
desplazamiento de elementos para dejar espacio libre (inserción) o para recuperar
el sitio liberado (supresión), y quedan de coste lineal sobre el número de elementos
de la lista.
Representación secuencial consecutiva de las listas con
Punto de interés
En la estrategia de partición de la lista en
dos pilas lo que hacemos es dividir la lista en dos
partes, a la izquierda van los elementos anteriores al
punto de interés, en el centro quedan las zonas libres y
a la derecha encontramos los elementos que van
después del punto de interés. Cuando se inserten
elementos en la lista lo harán al final de la parte
izquierda y cuando se borren será al inicio de la parte
derecha. Así pues la gestión es como si se tratase de
dos pilas. En la pila de la izquierda siempre se inserta y
en la derecha se borra y además al avanzar insertamos
en la pila izquierda la pila de la cima derecha y
borramos esta última.
Estas operaciones de inserción y borrado
quedan de coste constante, ahora bien, la operación de
ir al principio es de coste lineal pues es necesario apilar
en la pila de la derecha todos los elementos de la pila
izquierda, lo que implica movimientos múltiples.
Por este motivo, elegiremos la estrategia secuencial en dos pilas cuando
haya muchas intersecciones y supresiones y pocos recorridos (en listas muy
volátiles) y la estrategia secuencial simple en caso contrario. También puede ocurrir
que cambiemos la representación durante la vida de la lista si hay varias etapas:
unas más volátiles y otras menos.
Representación secuencial de las listas con
Punto de interés en dos pilas
2.3. Implementación encadenada
Otra tercera opción es la representación
encadenada. En este caso nos es necesario un
apuntador que nos lleve al elemento distinguido, pero
si el apuntador apunta al elemento distinguido, la
inserción sería costosa de implementar pues el
elemento anterior al distinguido tendría que apuntar al
elemento insertado, y éste no es accesible
directamente. La solución, por tanto, consiste en
apuntar al anterior al distinguido, así la inserción,
supresión etc, están disponibles pues conocemos el
elemento anterior al distinguido, el distinguido y el
posterior a éste.
Ahora bien, si el elemento apuntado es el
anterior al distinguido, si éste es el primero o la lista
está vacía: ¿adonde apuntamos? Pues nos evitamos
este problema creando el elemento fantasma que nos
ayudará en este caso especial. Vemos a continuación
un esquema de la estrategia de representación
encadenada con su inserción y supresión. El coste de
las operaciones siempre es O(1), salvo la creación de la
lista que queda O(max).
16 · Estructura de la información
3. SECUENCIAS Y ÁRBOLES
Representación encadenada de las listas
con punto de interés
En los árboles binarios los elementos se
encuentran organizados en nodos, ya no hay un
elemento anterior y un elemento sucesor sino que se
forma una jerarquía y, en el caso de los árboles
binarios esta jerarquía se caracteriza porque de cada
nodo solo parten dos hijos. Las relaciones jerárquicas en
nuestra vida habitual por ejemplo se observa en los
árboles genealógicos
3.1. Modelo y especificación
Todo (sub)árbol tiene una raíz única y cero o
más subárboles y todo nodo es la raíz de algún subárbol.
Se denomina aridad de un nodo al número de
subárboles que lo tienen como raiz. Cuando un árbol está cerrado por prefijo
significa que si existe el elemento 331, también existe el 33 y el 3; esto lo podemos
observar en la figura adjunta.
Las operaciones del TAD de los árboles binarios pueden ser:




Crear el árbol vacio: retorna el árbol sin ningún elemento.
Enraizar un elemento con dos subárboles.
Obtener la raíz de un árbol: Obtiene la etiqueta del nodo raíz de un árbol no
vacío.
Obtener los subárboles izquierdo y derecho: Retorna el subárbol
correspondiente de un árbol no vacío.
3.2. Implementación encadenada
Como es habitual podemos implementar los árboles con estrategia
secuencial o encadenada. Dado que la implementación encadenada es más fácil de
implementar (al contrario de las pilas y colas) que la secuencial, empezaremos por
las primeras.
En la representación encadenada usando memoria dinámica, se
añade un apuntador de cada nodo a los dos hijos del nodo, si un hijo no está, el
encadenamiento correspondiente es nulo. Las operaciones quedan de orden
constante mientras que el espacio utilizado es lineal con respecto al número de
nodos que lo forman. El Invariante debe controlar que la forma del árbol sea la
correcta, prohibiendo que haya más de un encadenamiento apuntando al mismo
nodo
La representación encadenada con vectores, si presentamos cada
árbol en un vector, las operaciones de enraizar y obtener los subárboles izquierdo y
derecho exigen duplicar o destruir (es decir, insertar en la pila de sitios libres) todos
los nodos de un árbol, por eso resultan de orden lineal sobre este factor.
3.3. Implementación secuencial por montículo
Se basa en el almacenamiento de las etiquetas en las posiciones del vector.
Esto, en las secuencias era relativamente sencillo, pero al considerar que cada nodo
tiene dos sucesores, la implementación se nos complica. La solución consistirá en
deducir una fórmula que asigne a cada posible nodo del árbol, una posición dentro
del vector y, si hay nodos que no existen, la posición correspondiente del vector se
marcará como desocupada.
Cerrado por prefijo
Estructura de la información · 17
Así lo que hacemos es situar el nodo en la primera posición, el hijo
izquierdo de la raíz en la segunda, el hijo derecho de la raíz en la tercera, el hijo
izquierdo del hijo izquierdo de la raíz en la cuarta… y así, según vemos en el
ejemplo lateral obtenemos un árbol y su representación secuencial. Por ejemplo la
posición 212 en el árbol se calculará para el vector: 2^3 + (4+0+1) = 13.
A partir de aquí podemos derivar las
diferentes relaciones entre la posición que ocupa un
nodo y la que ocupan sus hijos, hermano y padre.
Un árbol y su representación secuencial
Esta representación secuencial exige que cada
árbol se implemente en un vector único y las
operaciones de enraizar y obtener subárboles resultan
de coste lineal sobre el número de nodos
involucrados. Su utilidad principal, por ello, reside en
árboles con operaciones individuales sobre nodos.
Hemos de darnos cuenta que si el árbol tiene pocos
nodos pero muchos niveles, el espacio ahorrado por la
ausencia de apuntadores no compensa las posiciones
del vector desocupadas. El caso ideal se produce al almacenar árboles
completos: todos sus niveles contienen todos los nodos que puede haber.
También puede ser útil en los árboles cuasicompletos, que son aquellos en los
que solo en su nivel inferior hay posiciones desaprovechadas pero aun así serán las
posiciones derechas pues mantendrá su orden de árbol que es casi completo
(observemos que el árbol completo es un caso especial de árbol cuasicompleto).
3.4. Recorridos de árboles binarios
Ejemplo de recorrido
Es habitual que los programas que trabajan con árboles necesiten aplicar una
consulta que les lleva a visitar todos los nodos. Para ellos existen 3 recorridos
diferentes que se pueden realizar:



Recorrido en preorden: si a está vacio se acaba el recorrido; si no, primero se
visita la raíz de a y a continuación, se recorren en preorden los subárboles
izquierdo y derecho. Suele utilizarse en el cálculo de atributos heredados de
una gramática, como en la construcción de compiladores. En el ejemplo lateral
el recorrido sería: A B D G J E F H I.
Recorrido en inorden: si a está vacío, se acaba el recorrido; si no, primero se
recorre el subárbol izquierdo de a en inorden, a continuación su raíz, y
finalmente el subárbol derecho de a en inorden. En este caso si todos los nodos
del subárbol de la izquierda presentan una etiqueta menor que la raíz y los de
la derecha una mayor, el árbol es un árbol de búsqueda que se caracteriza por
la ordenación de los elementos en un recorrido inorden. Recorrido del ejemplo:
D J G B A E C H F I.
Recorrido en postorden: si a esta vacío se acaba el recorrido; si no, primero
se recorren sus subárboles izquierdo y derecho y, a continuación su raiz. Es
equivalente a la evaluación de una expresión matemática, donde en las hojas
existen valores y en los nodos intermedios los símbolos de la operación. En el
ejemplo el recorrido es: J G D B E H I F C A.
4. Colas prioritarias
La cola prioritaria es una cola donde los elementos no se insertan por el
final, sino que se ordenan según una prioridad que tienen asociada. Por ejemplo
cuando la CPU ejecuta determinadas tareas no por el orden de llegada, sino por
una prioridad dada. Definimos por tanto la cola prioritaria como un caso especial de
secuencia de tal forma que si a<b, a tendrá que salir antes que b en la cola.
18 · Estructura de la información
En cuanto al modelo y especificación debemos reconocer las siguientes
operaciones:




Crear la cola vacía
Encolar un elemento según su prioridad
Eliminar el primer elemento de la cola prioritaria, donde se requiere
evidentemente que la cola no esté vacía.
Obtener el primer elemento: retorna el elemento más prioritario.
Estos requerimientos nos pueden llevar a pensar que la mejor
implementación sea una lista ordenada o desordenada, pero esto aporta varias
dificultades. En un alista desordenada, la inserción queda de coste constante, pero
la supresión y la consulta exigen una búsqueda lineal. En la liste ordenada, la
inserción requiere una búsqueda lineal a cambio de un coste constante en la
consulta, mientras que la supresión depende de la representación concreta de la
lista. En cualquier caso queremos evitar cualquier operación de coste lineal y esto
lo conseguimos con una representación de árbol parcialmente ordenado cuyo
coste logarítmico de las operaciones nos es satisfactorio.
La característica más importante de los árboles parcialmente ordenados y
además cuasicompletos es que el menor nodo del árbol reside en la raiz. En la
imagen lateral observamos un árbol ordenado y cuasicompleto. Es de especial
utilidad estudiar el proceso de inserción y supresión en el árbol

Inserción: Se inserta el elemento en la primera posición libre del árbol en
recorrido lineal, en este caso se inserta como la hoja del último árbol. Al no
cumplir la condición de ordenación, se intercambian el padre y el hijo hasta que
se cumple. En el peor de los casos el número de intercambios será igual al
número de niveles del árbol, lo que nos asegura un coste de la operación
O(log(n)).
Supresión: Dado que el elemento menor reside en la raíz, la supresión
consiste en eliminarla. Para formar un nuevo árbol cuasi completo,
simplemente movemos el último elemento del árbol hasta la raíz; sin embargo
esta raíz no será menor que sus hijos, por lo que reestructuramos el árbol pero
en sentido inverso, bajando el padre con el hijo hasta cumplir el criterio de
ordenación. El coste, igualmente nos queda logarítmico.
En la representación de la clase queda claro que se utiliza un vector
para guardar el montículo; la primera posición del vector contendrá la raíz del
árbol. El invariante de la representación asegura que la relación de orden entre los
nodos se cumple y en cuanto a las operaciones de inserción y supresión, notamos
que no se intercambian los elementos a cada paso, sino que el elemento en
proceso de ubicación se mueve hasta que no se sabe la posición que le
corresponde, lo que ahorra la mitad de los movimientos, aunque no mejore el coste
asintótico.
El algoritmo de ordenación heapsort es una aplicación de estas colas
prioritarias que consiste en ordenar una lista con el menor coste posible. En un
primer paso se construye un montículo con la lista y, a continuación, se deshace el
montículo para formar el resultado ordenado. Podríamos formar el montículo por un
lado y el vector por otro, esto nos daría un coste logarítmico pero el coste espacial
sería mayor debido al espacio adicional y esto es lo que pretende solucionar este
algoritmo.
Consideramos entonces que el vector tiene dos partes: una dedicada a
simular la cola y la otra contiene una parte de la solución. En la cola, los elementos
se ordenarán según la relación > de forma que en la raíz siempre tenemos el
mayor elemento. En primer lugar formamos los nodos, empezando por abajo y de
derecha a izquierda y subiendo por niveles formando un árbol cuasi completo y
ordenado.
Inserción
en
un
árbol
parcialmente ordenado y
cuasicompleto
Estructura de la información · 19
Ordenación mediante heapsort
En un segundo bucle construimos el vector ordenado, a cada paso se
selecciona el mayor elemento de la cola y se coloca en la posición
correspondiente del vector, de modo que todos los elementos a su
izquierda son menores (aunque no están ordenados) y los de la
derecha son mayores y además están ordenados). Se continúa así
hasta tener toda la ordenación completa.
Observamos en la imagen lateral un ejemplo de ordenación
según este método de heapsort
20 · Estructura de la información
TEMA 3 FUNCIONES Y CONJUNTOS
1. MODELO Y TAD DE LAS FUNCIONES Y LOS CONJUNTOS
Los TADs estudiados hasta ahora no son suficientes para implementar de
forma eficaz todos los casos que informáticamente puedan dársenos. En este caso
nos falta un tad importante como es el de las funciones.
Una función es una relación establecida entre dos dominios de datos A y
B, de tal forma que a cada elemento de A le corresponde como mucho un elemento
de B. Las funciones de A y B se denotan mediante A  B. El conjunto de A se llama
dominio de la función y B es el alcance. Los elementos de A se denominan claves y
los de B información. A veces también se les llama tabla (vease figura lateral),
precisamente porque la representación gráfica de una función es una tabla, pero en
nuestro caso este vocablo quedará restringido a una técnica concreta de
implementación de las funciones y los conjuntos.
1.1. Modelo para las funciones parciales
Las funciones parciales son aquellas en las que no podemos asegurar que
todos los elementos de A tengan una información asociada (en contraposición a las
totales en las que sí se asegura). Los métodos de las funciones parciales son:





Crear la función vacía: con dominio real vacío.
Añadir un nuevo par (clave, información). Si existe una asociación anterior con
esta clave, se pierde.
Borrar un par de la tabla: Elimina la clave referenciada, si es que existe.
Obtener la información asociada a una clave: Aplica la función en un punto y
obtiene la información asociada a este. La clave debe estar definida.
Averiguar si una clave está en la tabla: Devuelve un booleano con la respues a
si una clave determinado existe o no (Consulta).
1.2. Modelo para las funciones totales
En las funciones totales existe el llamado elemento indefinido denotado
por indef., que se asociará por defecto a todos los elementos de A y nos permitirá
asegurar así que las funciones son efectivamente totales: La función de creación
ahora no crea un dominio vacío, sino definido; la operación borrar cambia el
antiguo valor asociado por el indef.; la operación de consulta deja de requerir
condiciones sobre el dominio de la función.





Crear la función vacía: con el dominio ya definido.
Añadir un nuevo par (clave, información): Define la función en un punto A,
pero no permite definir como alcance el valor indefinido.
Borrar un par de la función: Cambia el antiguo valor asociado por el valor
indefinido.
Obtener la información asociada a una clave: Obtiene la información a partir de
la clave.
Comprobar si una clave tiene información asociada definida.
1.3. Variante: los conjuntos con clave
Los conjuntos con clave son aquellos en los que el identificador se
encuentra dentro del propio elemento. Por ejemplo en un conjunto para almacenar
los empleados de una empresa, una persona se caracteriza por toda una serie de
Tema 3
Funciones y Conjuntos
1. Modelo TAD de las funciones y
conjuntos
2. Implementación de las
funciones mediante dispersión
3. Implementación de las
funciones mediante árboles
binarios de búsqueda
Tablas
Función AB
A
1
2
3
B
12
24
36
Estructura de la información · 21
atributos (sueldo, DNI, nombre y apellidos, categoría laboral) y uno de ellos (por
ejemplo el DNI) sirve como medio de referencia.
Las operaciones quedan definidas como sigue:





Crear el conjunto vacío.
Añadir un nuevo elemento al conjunto: Añade un elemento que no estaba o
sustituye uno que ya estaba. No puede haber más de un elemento con la
misma clave (precondición).
Borrar un elemento del conjunto: Elimina un elemento a partir de su clave; y ya
sabemos que no puede haber más de un elemento con la misma clave.
Obtener un elemento del conjunto a partir de su clave: Obtiene la información
asociada a partir de la clave.
Comprobar si hay algún elemento en el conjunto identificado por una clave,
sabiendo además que como mucho solo habrá uno.
1.4. El TAD de los conjuntos
Se diferencia del TAD anterior en que realmente no exigimos que un
elemento tenga una parte diferenciada que lo identifique, y lo usaremos
especialmente para definir conjuntos de tipo simple como tipo numérico o cadena
de caracteres.
Las operaciones definidas en él son:




Crear el conjunto vacío
Añadir un nuevo elemento al conjunto.
Borrar un elemento del conjunto.
Comprobar si un elemento pertenece al conjunto.
Dada la similitud entre todos los TAD vistos en este primer apartado, en el
resto del tema, nos centraremos en el primer modelo: las funciones parciales.
2. IMPLEMENTACIÓN DE LAS FUNCIONES MEDIANTE DISPERSIÓN
Para implementar las funciones de las que hemos tratado en el punto
anterior, la representación más intuitiva será un simple vector, indexado por las
claves que contienen la información asociada a sus posiciones. Lo malo de esto es
que a veces el domino de las claves no resulta válido como índice de vector o que
el dominio real de la función será menor que el dominio de sus claves (por ejemplo
utilizar el DNI para identificar a los solo 200 trabajadores de una empresa).
Lo que vamos a hacer es llevar a cabo esta implementación de la que
hemos hablado a través de vectores, pero solucionando los dos problemas
anteriores:
 Llevaremos a cabo una función que asigna a las claves un entero que se puede
usar como índice del vector.
 Para no desaprovechar espacio, simplemente ajustamos el número de
posiciones a la dimensión esperada del dominio real de la tabla.
Lo que vamos a llevar a cabo se denomina tabla de dispersión. La
función que asigna enteros a las claves se llama función de dispersión y los valores
enteros asignados, valores de dispersión. Eso sí, será necesario tener una idea
aproximada del número de elementos que se guardarán en la tabla. Pero nos surge
un problema evidente; si no van a existir tantas posiciones del vector como claves
en el dominio de la función existirán claves que se tendrían que almacenar en una
misma posición, eso es lo que denominamos colisión; para evitar esto tenemos
que implementar una política de organizaciones de dispersión.
22 · Estructura de la información
El objetivo de la función de dispersión (como se ve Función de dispersión
en el ejemplo lateral) es asignar un entero llamado valor de
dispersión a cada una de las claves que forman el dominio de
la función; de tal manera que esa clave se encontrará entre 0 y
r-1 (siendo r el número de claves que se esperan dentro de la
tabla).
La función de dispersión tiene que distribuir entonces
uniformemente el dominio de las claves en el intervalo (0, r-1),
además debe ser totalmente aleatoria para evitar que haya más
colisiones de la cuenta y tiene que ser rápida de calcular para evitar que se retrase
el acceso a la tabla.
Un ejemplo bastante típico es el de las funciones de dispersión sobre
cadenas de caracteres. Lo que vamos a realizar es asignar a cada letra un valor
y sumarle ese valor según la posición en que se encuentre. Lo primero es definir
qué valores ASCI son los que tendremos en cuenta para llevar a cabo la función. Si
estamos definiendo nombres y apellidos de personas descartaremos todos los
valores tipo asterisco, barras, etc, definiremos solo letras en mayúsculas y
minúsculas, guiones y quizá el punto, poco más. Eso nos reduce los 128 caracteres
a solo 54 caracteres de código. Hecho esto llevamos a cabo la función de dispersión
como la suma ponderada según la posición, que podemos ver en el ejemplo de la
tabla lateral.
Suma ponderada
Sea a = ”HOLA” y b = 26,
entonces haremos:
H= conv(H) = 8
O= conv(O)·26 = 390
L = conv(L)·676 = 15.576
A = conv(A)·17.576 = 17.576
HOLA = 26.186
Esta conversión tiene ciertas deficiencias, en general podemos llegar
fácilmente a tener un desbordamiento de cálculo, por ejemplo en una función
como la definida en el ejemplo a partir del 8º carácter en una máquina de 32 bits
se puede ya producir desbordamientos. Tenemos dos posibles soluciones:


Ignorar los bits que se van generando fuera de la dimensión de la
palabra del computador: lo cual ignora cierta cantidad de
información y reduce la aleatoriedad de la clave, amén de que a
veces es difícil y poco eficiente detectar e ignorar este
desbordamiento.
Particionar las claves de entrada y combinar los resultados
parciales mediante sumas.
Partición claves de entrada
División de una clave de 22 caracteres de letras
mayúsculas en 6 fragmentos, los cinco primeros
de 4, y el último, de 2 caracteres.
Aparte de este tipo de funciones de dispersión sobre cadenas de caracteres
existen otras muy típicas funciones de restricción de un entero a un
intervalo. Queremos transformar enteros grandes en índices válidos dentro del
dominio (0, r-1). Una de las más utilizadas es la función módulo, es uniforme,
rápida de calcular y simple MOD(x) = x mod r. Eso sí, el valor de r es fundamental
para una buena distribución de esos índices. En general no es bueno que r sea par
ni múltiplo de 3 porque existirían muchas colisiones, lo ideal es que r no tenga
divisores menores que 20.
Ahora bien, tenemos las funciones de dispersión, pero éstas
provocan alguna que otra colisión (si hemos definido una buena
función de dispersión, serán pocas) que debemos resolver. La forma
más sencilla es organizar listas con las claves sinónimas que provocan
estas
colisiones,
se
tendrían
que
organizar
mediante
encadenamientos y dan lugar a representaciones encadenadas que se
denominan tablas de dispersión encadenadas abiertas.
Tabla de dispersión encadenada abierta
En el gráfico lateral se muestra la lista correspondiente a los s sinónimos
que hay para claves con valor de dispersión igual a 0. Si asociamos una estructura
individual a cada una de las listas, nos vemos abocados a representarlas mediante
memoria dinámica, porque de otro modo la suma del espacio desperdiciado en las
diferentes listas podría llegar a ser inaceptable.
Estructura de la información · 23
El coste asintótico queda O(r) para la creación de la tabla y de complejidad
lineal sobre la longitud de las listas para el resto. Si la longitud de las listas es
corta, este factor lineal se comporta realmente como si fuera constante. Por eso
hace falta que r sea aproximadamente igual al número esperado de claves y que la
función de dispersión realmente distribuya bien, de forma que lo más probable sea
que la mayoría de las listas quede de una longitud parecida.
Las tablas de dispersión en Java se implementan a través de la clase
Hashtable contenida en el paquete java.util. Tiene operaciones individuales como
put, get y remove y dispone de los predicados isEmpty, containKey y contains (ésta
última para saber si un valor está en la tabla).
3. IMPLEMENTACIÓN DE LAS FUNCIONES MEDIANTE ÁRBOLES BINARIOS DE
BÚSQUEDA
Pese a todas las ventajas descritas anteriormente, la dispersión presenta
algunas desventajas que también es preciso aclarar:




El buen comportamiento de la dispersión depende de una buena función de
dispersión; pero aunque hayamos definido una muy buena siempre existe un
cierto componente aleatorio en su funcionamiento.
Es necesario determinar el número aproximado de elementos que se espera
guardar en la tabla.
Hay que efectuar un análisis para decidir qué función y que organización de
dispersión son las mejores para el problema actual.
Si se quiere ampliar el concepto básico de tabla para efectuar otro tipo de
operaciones, podemos tener problemas.
La existencia de estos inconvenientes nos obliga a enriquecer el modelo de las
tablas con operaciones de recorrido ordenado, estableciendo un nuevo tipo de
modelo que son las funciones recorribles ordenadamente. Este modelo se
diferencia de las funciones habituales por el añadido de las operaciones de
recorrido propias de los iteradores con la restricción de ordenación. En el momento
de determinar el modelo y tal como sucedía en las listas con puntos de interés,
debemos registrar que claves se han examinado ya y cuales no. Elegimos el
conjunto de claves ya visitadas y según ello obtenemos los siguientes métodos:




Función
Función
Función
Función
posiciona.
avanza
consulta
fin??
Veamos estas funciones con dos tipos de búsquedas los árboles binarios de
búsqueda y los árboles binarios de búsqueda AVL. Los árboles binarios de
búsqueda son árboles binarios que cumplen una de las dos condiciones
siguientes:
 El árbol a está vacío.
 La raíz de a es mayor que todos los elementos de su árbol izquierdo (si lo
tiene) y menor que todos los elementos de su árbol derecho (si lo tiene) y a su
vez sus subárboles izquierdo y derecho son también árboles de búsqueda.
Árboles binarios de búsqueda
La propiedad que hace interesante a estos árboles es que sea cual sea su
forma el recorrido en inorden proporciona los elementos del árbol
ordenados. Las operaciones que vamos a realizar en este árbol son:

Búsqueda de un elemento: si a está vacío el elemento no está en el árbol. Si a
no está vacío y no es el elemento buscado, por su valor sabremos si debemos
seguir buscando en el árbol derecho o izquierdo del mismo.
24 · Estructura de la información


Inserción: Para que al insertar un elemento el árbol resultante siga siendo de
búsqueda, se aplica primero la búsqueda del elemento, y si no se encuentra se
inserta allá donde se haya posicionado la búsqueda anterior. En el ejemplo
lateral se observa como se insertan dos nuevos elementos como dos hojas
(como pasará siempre). Evidentemente el peor coste posible es tener que
recorrer todo el árbol, si éste es cuasicompleto el coste será logarítmico, sin
embargo si se trata de un árbol degenerado con un solo hijo por cada nodo,
nos encontraremos frente a un coste n, ya que no existe ninguna propiedad
que restringa la forma del árbol.
Supresión: Primero se aplica el algoritmo de búsqueda habitual. Si el
elemento no se encuentra, la supresión acaba; si el elemento se encuentra, se
procede a eliminar el nodo n que lo contiene, ahora bien el comportamiento
depende del número de hijos que tiene el nodo n, puede ocurrir:
o Si n es una hoja, simplemente desaparece y ya está.
o Si n tiene un único subárbol, éste se sube a la posición que ocupaba n.
o Si n tiene dos hijos, ninguno de los dos métodos anteriores asegura
que nos siga quedando un árbol binario de búsqueda. Para conservar
esta propiedad se mueve o bien el mayor elemento de los elementos
menores que v (dentro del subárbol izquierdo del árbol que tiene n
como raíz) o bien el menor elemento de los elementos mayores que v
(subárbol derecho). En cualquier caso, el nodo n´ que contiene el
elemento que responde a esta descripción es o bien una hoja o un
nodo con un único hijo, de forma que a continuación se le aplica el
tratamiento correspondiente en estos casos. En el ejemplo lateral
observamos estas supresiones con un ejemplo. La eficiencia temporal
es igual a la de la inserción.
Para obtener el mayor de los menores se hace un recorrido en
inorden del hijo derecho (eso hacen las funciones auxiliares para
obtener el máximo y el mínimo de un árbol de búsqueda que nosotros
predefinimos) cuando se podría simplemente bajar de forma repetida
por la rama derecha.
Para borrar un elemento volvemos después a buscar
recursivamente con borra.
Supresión en árbol binario de búsqueda
Un caso especial de árbol binario de búsqueda lo constituyen los árboles
binarios de búsqueda AVL que se caracterizan por ser árboles binarios de
búsqueda equilibrados, es decir, el valor absoluto de las diferencias de las alturas
de sus subárboles es menor o igual que uno y sus subárboles también están
equilibrados. Este equilibrio se mantiene a fuerza de reorganizaciones del árbol al
hacerle inserciones y supresiones; pero nos asegura un coste logarítmico a las
operaciones de acceso individual.
A continuación pasamos a estudiar el caso de inserciones y supresiones en
un árbol AVL.
Inserción en árbol binario
de búsqueda (4 y 3)
Estructura de la información · 25
Para insertar un elemento en un árbol AVL por un lado aplicaremos la
casuística de la inserción “normal” que ya hemos visto anteriormente para los
árboles de búsqueda y después nos aseguraremos que el árbol queda equilibrado.
Si el árbol no resultase equilibrado puede deberse a dos casos:

Caso DD: El subárbol derecho de s tiene una altura superior en una unidad al
subárbol izquierdo de s y el nodo correspondiente al par insertado (a,b) se
inserta en el surbarbol derecho de s y, además, provoca un incremento de su
altura en uno. En este caso lo que se hace es que la raíz B del subárbol
derecho de s pasa a ser la nueva raíz del subárbol y conserva su hijo derecho
que es el que ha provocado el desequilibrio con su incremento de altura y que
con cada movimiento queda a un nivel más cerca de la raíz del árbol, de modo
que se compensa el aumento. La antigua raíz A se s se convierte en hijo
izquierdo de B y conserva su subárbol izquierdo; finalmente el anterior subárbol
izquierdo de B se convierte en su subárbol derecho de A para conservar la
propiedad de ordenación de los árboles de búsqueda. Alguno autores llaman a
estos movimientos rotaciones.
Inserción tipo DD en un árbol AVL
 Caso DI: El nodo se
inserta en el subárbol
izquierdo del subárbol
derecho de s. Este caso
no es tan evidente como
el
anterior
porque
aplicando las mismas
rotaciones de subárboles
no se soluciona el
desequilibrio y por eso
hay que descomponer
también el subárbol de s
que
cuelga
por
la
izquierda de su subárbol
derecho;
por
ello
también hay que distinguir el caso trivial en que este subárbol esté vacío y no
se pueda descomponer. El nuevo nodo puede ir a parar indistintamente a
cualquiera de los dos subárboles que cuelgan de ese subárbol sin que esto
afecte a las rotaciones definidas. En esta página observamos el caso trivial al
que hacíamos mención anteriormente, y en la siguiente vemos un caso más
complejo.
Inserción tipo DI en un árbol AVL
26 · Estructura de la información
Inserción tipo DI en un árbol AVL
Ahora bien, la supresión presenta a su vez 3 tipos de desequilibrio que
pueden llegar a darse:

DD en la supresión: En el ejemplo siguiente observamos que al borrarse un
nodo de alfa (previamente ambos subárboles tenían la misma altura), las
alturas de los dos subárboles no son iguales, y la resolución es también como
se muestra en el ejemplo.
Supresión tipo DD en un árbol AVL

DD con cambio de altura: La altura del subárbol izquierdo es menor que la
del subárbol derecho y además se borra un nodo del subárbol menor, en este
caso alfa, lo que obliga a examinar si algún subárbol que lo engloba también se
desequilibra.
Supresión tipo DD con cambio de altura en un árbol AVL
Estructura de la información · 27

Caso DI: Es el caso más complicado y podemos observar lo que ocurre en el
ejemplo cuando eliminamos el elemento 3.
Supresión tipo DI en un árbol AVL
28 · Estructura de la información
TEMA 4 RELACIONES
1. RELACIÓN
Se definen las relaciones sobre dos dominios A y B como cualquier
correspondencia entre los elementos de estos dos dominios; estas relaciones se
caracterizan por un enunciado que determina qué elementos de A están
relacionados con determinados elementos de B y viceversa. Por ejemplo: se
establece una relación entre los alumnos de un centro académico y las asignaturas
impartidas; la relación surge por el hecho de que los primeros se matriculan en las
segundas; además la relación puede ser valuada (cuando en la relación además
existe un valor determinado) que puede ser la nota obtenida de un alumno en una
asignatura en concreto.
Tema 4
Relaciones
1. Relación
2. Grafos
3. Recorrido de grafos
4. Algoritmos de cálculo de
distancias mínimas
5. Algoritmos de cálculo de
árboles de expansión mínimos
1.1. Método y especificación
En las relaciones no valuadas existen pares de elementos que cumplen
un enunciado, este modelo de representación es el típico modelo cartesiano de una
matriz de filas y columnas o, simplemente podemos tabular los pares de la relación,
como observamos en la figura lateral. Dado el modelo que explicamos, podemos
apreciar que las relaciones combinan operaciones de acceso individual con
operaciones de obtención de grupos de elementos. Por ejemplo, puedo tomar
nota de si un alumno se matricula de una asignatura de terminada, pero también
de qué asignaturas cursa un alumno y cuantos alumnos cursan una asignatura
determinada.
Como resultado de todo ello, las operaciones son las siguientes:





Crear la relación vacía
Establecer una relación entre dos elementos.
Deshacer la relación entre dos elementos: se borra el par.
Averiguar si dos elementos están relacionados o no: retorna un booleano.
Obtener el conjunto de elementos relacionados con uno dado: Es una plantilla
de iteración o, mejor dicho, dos, dependiendo de si estamos interesados en
elementos del primer o del segundo dominio. Así el recorrido cumple las
funciones: posiciona / consulta / avanza / fin (si ha encontrado lo que busca o
si es el último elemento).
En las relaciones valuadas cada par que forma parte de una relación valuada
tiene asociada una etiqueta única. Así en la relación del alumno A con la asignatura
B, de existir estará valuada por la nota C. Las operaciones son parecidas al caso
anterior, si bien hay que añadir un parámetro más a la operación de establecer una
relación. Hay que poder obtener la etiqueta de la relación dados dos elementos
relacionados y hay que añadir la operación de consulta en los recorridos. Así, las
operaciones a realizar son:





Crear una relación valuada.
Establecer una relación valuada, que incluye la relación entre los dos elementos
y el establecimiento de la oportuna etiqueta.
Deshacer la relación valuada entre los dos elementos.
Averiguar si dos elementos están relacionados.
Encontrar la etiqueta de la relación: además suponemos que las etiquetas
presentan dos elementos distinguidos inf y sup, que son las dos cotas del
dominio; esto es fundamental para el correcto funcionamiento de algunos de
los algoritmos sobre grafos que veremos más adelante.
Relación R
Matricial y Tabulación
Estructura de la información · 29
1.2. Implementación secuencial
La representación secuencial de las relaciones no hace más que traducir la
representación gráfica vista anteriormente en estructuras de datos. Es decir,
utilizamos un vector bidimensional para almacenar todos los elementos que
pertenecen a la matriz. Así, en una relación no valuada el vector será de
voléanos y la posición (a,b) será cierta si los elementos a y b están relacionados. En
una relación valuada las posiciones del vector contendrán etiquetas: usaremos la
etiqueta sup para identificar los pares de elementos no relacionados.






Representación por vector
Con estas premisas vemos que:
El invariante de la representación establece que cualquier vector representa
una relación válida.
El coste de duplicar elementos es constante
El coste de acceso individual también es constante
La creación es O(rxs)
Los recorridos O(r) y O(s) dependiendo del dominio que recorren
La complejidad espacial es O(r x s) y es la que determina la operación crea.
Esta complejidad determina el coste temporal de los recorridos; si hay muchos
elementos relacionados esta situación puede ser satisfactoria pero no lo suele
ser en el caso general. Las matrices con pocos elementos relacionados se
denominan matrices dispersas, para las que es evidente que hay que buscar
una representación alternativa.
La solución puede consistir en almacenar solo los pares que forman parte de la
relación, usando un único vector gestionado de la forma habitual (ejemplo lateral),
pero para ello necesitamos determinar el tamaño lo que ya de entrada, no siempre
es posible. Lo que se puede hacer es organizar la parte ocupada en varias zonas,
cada una de las cuales corresponde a todos los pares que tiene uno de los dos
elementos en común (por ejemplo tomamos el primer dominio), así se puede
añadir un vector índice al primer par de cada valor de este dominio. En este caso
las consultas y uno de los recorridos (sobre el primer dominio en este caso) salen
beneficiados y quedan lineales sobre un factor menos, el coste de inserción y
supresión permanece lineal, porque aunque la localización es muy rápida, una vez
insertado/borrado el par buscado, hay que reorganizar toda la estructura.
1.3. Implementaciones encadenadas
Evidentemente la variante estudiada anteriormente es problemática, dado
que la secuencialidad obliga a mover elementos al insertar o borrar pares y además
intentar gráficamente representar dos vectores unidimensionales es complicado
Vamos a intentar mejorar la implementación anterior a través de una
implementación encadenada en la que vamos a tener dos variantes: las listas y las
multilistas.
En la primera representación asociamos una lista a cada
elemento de cada uno de los dos dominios, que contenga un nodo para
cada elemento del otro dominio con el que está relacionado. Entonces
para cada par 8ª,b) existirán dos nodos, uno que corresponde a a y otro
a b, como podemos ver en la imagen lateral. Según esta estrategia, la
complejidad de las operaciones nos queda:
Encadenamiento por lista


 La creación es O(r+s)
 Las consultas individuales serían O(mínimo(r,s)) dado que
buscaríamos en la lista de menor longitud si se pudiese saber, de no ser así,
sería O(máximo(r,s)).
Las operaciones de recorrido quedan lineales sobre la dimensión del dominio
donde se hace el recorrido.
El espacio utilizado es O(rxs) si la relación estuviera llena.
30 · Estructura de la información
Ahora bien, nos podemos plantear si es efectivo tener repetida cada Encadenamiento por multilista
relación en cada uno de los dos dominios, si la relación fuese valuada
necesitaríamos duplicar la etiqueta de relación en cada una de las dos listas,
pero existe una posibilidad para evitar esta duplicación que es la
estructura de multilista. En ella (figura lateral), cada par de la relación
da lugar a un único nodo, por lo que la etiqueta aparece una sola vez, el
resto, los identificadores y encadenamientos aparecen igual. En este caso
utilizamos memoria dinámica para representar los pares, el invariante
establece que todos los nodos accesibles desde un valor de uno de los
dominios identifica este valor en el campo correspondiente y también se
explicita que el valor indefinido de las etiquetas no puede aparecer. La
complejidad temporal de las operaciones no cambia respecto a la propuesta
anterior.
Existe otra variante de esta implementación que consiste en cerrar
circularmente las listas de forma que el último nodo de cada lista apunte a su
cabecera. Así cada elemento se asocia a su fila o columna, según la circularidad
que estemos recorriendo. Esta opción es más costosa en el tiempo, porque para
saber la fila o la columna a la que pertenece un elemento hay que recorrer la lista
hasta el final (por eso es especialmente útil en listas cortas), pero nos permite
ahorrarnos los identificadores de dominio que aparecen en las celdas (en el dibujo
inferior aparece este ejemplo).
Otra solución intermedia es utilizar una clase con listas con
identificadores y otra circular, lo cual resulta útil cuando uno de los dos tipos
tiene una longitud máxima pequeña, y lo podemos observar en el dibujo inferior.
Por ejemplo dado que las asignaturas para un alumno siempre serán muy
pequeñas (a lo sumo 10 ó 12) comparadas con las que imparte el centro (que
puede ser de 400) se puede implementar circularmente la lista de asignaturas
mientras que en la de alumnos parece más adecuado el uso de identificadores.
Encadenamiento por lista circular y encadenamiento por lista con identificador y lista
circular
2. GRAFOS
Los grafos son relaciones establecidas sobre elementos de un mismo dominio
V, lo que se llaman relaciones binarias. Distinguimos varios conceptos dentro del
grafo:




Vértices o nodos son los elementos del dominio de la relación; se representan
mediante círculos que contienen el identificador que los caracteriza.
Arista o arco es un par representando dos vértices relacionados, se representan
mediante líneas que unen los nodos relacionales.
Camino: Cuando dos nodos están unidos mediante una secuencia de aristas.
Círculo: Un camino que empieza y acaba en el mismo vértice.
Estructura de la información · 31



Encadenamiento por lista
Relación simétrica: Cuando la relación se puede dar en los dos sentidos, se
llaman también grafos no dirigidos.
Relación no simétrica: Cuando la relación se da en un solo sentido y es
entonces cuando las líneas que representan las aristas se convierten en flechas
que identifican su sentido.
Relación valuada: En este caso se denominan grafos etiquetados y las etiquetas
aparecen al lado de las aristas.
Los grafos nos sirven para modelar situaciones: en el mundo informático son
usuales las distribuciones geográficas como redes de ordenadores, y puede
servirnos por ejemplo para minimizar los costes de conexión, reconfigurar la red
cuando cae algún nodo, etc. En la tabla lateral observamos dos ejemplos de
modelización, uno de transportes y otro de representación de expresiones
matemáticas que nos evitan la redundancia en la información.
2.1. Modelo y especificación
Definimos los grafos dirigidos osbr eun dominio V con etiquetas de un
dominio E como las funciones parciales definidas sobre V x V que tienen como
alcance las etiquetas; así pues {g : V x V  E}. Denotamos con n el número de
vértices n = ||V|| y con a su número de aristas a = ||dom(g)||. De ahora en
adelante, eso sí debemossuponer lo siguiente:







El dominio V es finito, de lo contrario la mayoría de los algoritmos e
implementaciones no funcionaría.
No hay aristas de un vértice a sí mismo.
No puede haber más de una arista entre el mismo par de vértices si el grafo es
no dirigido.
No puede haber más de una arista en el mismo sentido entre el mismo par de
vértices si el grafo es dirigido.
En estas condiciones, el máximo número de aristas de un grafo dirigido de n
nodos en (n2-n); si el grafo es dirigido hay que dividir esta magnitud por dos.
Cuando el grafo tiene el máximo número de aristas se llama completo, si es
cercano se dice que es denso y cuando tiene del orden de n aristas o menos,
se dice que es disperso.
Los sucesores de un nodo x de un grafo dirigido son aquellos a los que llega
una arista procedente de x, mientras que los predecesores son aquellos de los
que sale (nos suelen interesar más los primeros). En el caso de los grafos no
dirigidos, desaparecen estos don conceptos, y son sustituidos por la noción más
general de adyacencia, así llamanos a dos nodos adyacentes cuando hay una
arista que los conecta.
Las operaciones que podemos definir son:





Crear un grafo vacío.
Insertar una arista en un grafo: se prohibe en la precondición que la arista sea
reflexiva y que la etiqueta tenga el valor que usamos como cota máxima.
Borrar una arista de un grafo.
Averiguar si una arista pertenece al grafo.
Obtener la etiqueta de una arista del grafo.
2.2. Implementaciones
Es una implementación muy parecida al caso de las relaciones, pero a su
vez presentamos dos versiones diferentes:
La primera representación es la conocida matriz bidimensional, que en el
caso de los grafos, toma el nombre de matriz de adyacencia. Si usamos las
32 · Estructura de la información
magnitudes n y a para medir el número de nodos y aristas respectivamente,
obtenemos lo siguiente:
 La complejidad espacial de la estructura es O(n2).
 La complejidad temporal de las operaciones es O(n2) para la creación, O(1)
para la inserción, supresión y consulta de aristas, O(n) para la obtención de
sucesores y precedesores (en el peor de los casos).
En la segunda representación, que va a ser encadenada, podríamos usar la
multilista, pero va a ser raro el caso que en un mismo programa nos interesa tratar
tanto los sucesores como los predecesores de un nodo; la mayoría de las veces nos
interesa movernos por el grafo y obtener los sucesores de los nodos, y por ello
solemos utilizar la representación por listas de adyacencia. Se dispone de un
vector indexado por los vértices en el que, para cada posición, cuelga la lista de
nodos sucesores, junto con la etiqueta que forma parte de la arista
correspondiente. El gráfico lateral muestra un grafo dirigido y su implementación
por listas de adyacencia. En esta implementación debemos anotar lo siguiente:





Se representan estas listas de adyacencia mediante un atributo de clase
ListaDeParesVerticeEtiqueta, haciéndola una codificación modular, simple y
resistente a modificaciones.
El invariante del grafo vuelve a reflejar la ausencia de aristas de un vértice a sí
mismo con una variante de la típica función cadena que se define usando
varias operaciones del TAD lista.
Se introduce la función auxiliar búsqueda, que retorna un booleano indicando si
un elemento dado está dentro de una lista o no y, en caso afirmativo, posiciona
el punto de interés, así evitamos algún recorrido redundante por la lista.
La complejidad espacial de la estructura es O(n+a). Normalmente será el
número de aristas pero si hay pocas, el espacio del vector se vuelve relevante.
La complejidad temporal de las operaciones es de O(n) para la creación, O(k)
para la inserción, supresión y consulta de aristas, O(k) también para el
recorrido de la lista de sucesores y O(n+a) para el recorrido de los
predecesores puesto que hay que recorrer todas las listas. Es decir, excepto la
creación y la obtención de sucesores, el coste temporal parece favorecer la
implementación por matriz; ahora bien, el buen comportamiento en la
obtención de los sucesores provoca que muchos de los algoritmos que veremos
funcionen mejor con una implementación por listas, al basarse en un recorrido
de todas las aristas del grafo que se pueden obtener en O(n+a) frente al coste
O(n2) con las matrices.
3. RECORRIDO DE GRAFOS
El recorrido de grafos que más nos interesa, dentro de todos los posibles
que podemos llevar a cabo, es el recorrido en ordenación topológica, aplicable
solo a grafos dirigidos (etiquetados o no) y que está regido por un principio muy
simple: un nodo del grafo se visita si, y sólo si, se han visitado todos su
predecesores. Esta regla no determina un único recorrido válido. Incluso algunas
veces tos interesa el recorrido del grafo en orden topológico inverso, y así se utiliza
como mecanismo de representación de expresiones aritméticas.
Centrándonos de nuevo en el recorrido en ordenación topológica lo que
queremos es implementar una función que nos retorne una lista con punto de
interés que contenga todos sus nodos en un orden que respete la regla que define
el recorrido. Así:


En la precondición se establece la necesidad de que el grafo sea acíclico
usando una función auxiliar que determina si existe un camino entre un par de
nodos. Si no se encuentra al mismo tiempo camino de v a w y de w a v,
tendremos un grafo acíclico.
En la poscondición se establece la condición de ordenación topológica.
Matriz de adyacencia
Estructura de la información · 33





Se eligen los vértices que no tienen predecesores, los que se pueden visitar y, a
medida que se incorporan a la solución, se dejan de considerar las aristas que
salen de éstos.
El invariante establece que, a cada paso del bucle, se dispone del recorrido
parcial de los nodos ya tratados. Esta implementación es costosa pues hay que
buscar repetidamente los vértices sin predecesores, por lo cual suele hacerse:
o Usar un vector de nodos que asocia a cada vértice el número de
predecesores que aún no están en la solución.
o Usa un conjunto ceros en el que se guardan todos los nodos que se
pueden incorporar en el siguiente paso del algoritmos
A su vez, se descubre que las dos subestructuras anteriores pueden fusionarse
en una sola, añadiendo un campo encadenamiento de forma que todos los
nodos que antes estuviesen en el conjunto ceros ahora se encadenen y se
organicen mediante una estructura lineal (la típica pila). Añadir un apuntador al
primer elemento de esta pila y un booleano que indique si está la pila vacía o
no e introducimos un contador de nodos para saber si se tienen que tratar
todos o no.
El coste temporal en la inicialización queda de O(n) en el primer bucle. El
segundo bucle exige un examen de todas las aristas del grafo, si se hace por
lista de adyacencia tendrá un coste de O(a+n), si es por matrices de
adyacencia será de O(n2). El bucle principal parece que tiene un coste de O(n2),
pues se ejecuta n veces y a cada paso de añade un nodo a la solución y en la
iteración se obtienen los sucesores del nodo elegido (O(n) en el peor de los
casos). Ahora bien, si observamos que la iteración interna exige el examen de
todas las aristas que componen el grafo con un tratamiento constante de cada
una y, esto es en realidad O(a+n) y no O(n2) cuando el grafo esté
implementado por listas de adyacencia.
Por ello, se puede concluir que la eficiencia temporal del grafo será de O(a+n)
con listas de adyacencia y O(n2) con matriz de adyacencia. Por eso la
representación por listas de adyacencia es preferible si el grafo es disperso,
mientras que si es denso la implementación no afecta a la eficiencia del
algoritmo.
4. ALGORITMOS DE CÁLCULO DE DISTANCIAS MÍNIMAS
Dado un grafo dirigido que puede ser una red de metro, nos surge la
frecuente necesidad de encontrar la distancia mínima entre dos nodos, entendiendo
por distancia la suma de las etiquetas que se encuentran en un camino de un
grafo. Existen dos algoritmos para ello, el de Dijkstra calcula la distancia mínima
de un nodo al resto de nodos del grafo y el de Floyd calcula la distancia mínima
entre todos los pares de nodos. Como en ningún caso el algoritmo de Dijkstra es
más lento que el Floyd utilizaremos el primero en esta asignatura.
Algoritmo de Djisktra
El algoritmo de Djisktra, por tanto, se ocupa de calcular el camino más
corto de un nodo al resto de nodos, y en su versión estándar más simple, retorna el
coste de este camino, no los nodos que lo integran. En el ejemplo de la tabla
lateral, el vector final es el resultado de la aplicación del algoritmo sobre el grafo
superior. En la especificación del algoritmo destacan:


El coste entre dos nodos no conectados es el valor sup de las etiquetas que
representa el mayor posible.
Para caminos de longitud 1 (es decir, un único nodo y ninguna arista) se
considera que su coste es igual a la etiqueta mínima inf. (en el ejemplo 0).
Respecto a la implementación del algoritmo cabe destacar que pertenece a
los llamados algoritmos voraces caracterizados porque la solución se va
construyendo de forma incremental. Esto significa que el bucle que forma el núcleo
del algoritmo irá calculando un camino mínimo definitivo en cada paso, de forma
que después de n-1 vueltas se tendrán calculadas todas las distancias mínimas.
34 · Estructura de la información
Para saber cuales son los nodos ya tratados, la versión preliminar introduce un
conjunto S auxiliar que los va registrando, cada vez que se añade un nuevo nodo a
este conjunto se estudia si los caminos que aún no son definitivos se pueden
acortar pasando por ese mismo nodo.
El invariante del bucle nos asegura que el vector contiene caminos mínimos
formados íntegramente por nodos de S. En la tabla lateral observamos la evolución
del algoritmo de Djisktra para el ejemplo que establecíamos en la página anterior.
Evolución del algoritmo
Para conocer el coste del algoritmo vamos a establecer 4 partes en el
mismo: inicialización, selección de mínimos, marcaje y recálculo de distancias
mínimas. Podemos estudiarlo además representado por matrices de adyacencia o
por representaciones encadenadas, como vimos en el punto anterior.




Por Matrices de adyacencia el coste del algoritmo queda:
Inicialización: O(n). El bucle de inicialización se ejecuta n veces y el coste de
sus instrucciones es constante.
Selección: O(n2): El bucle de selección se ejecuta tantas veces como elementos
quedan por tratar, la primera vez n-1, la segunda n-2 etcetera, lo que
determina el coste predicho.
Marcaje: La supresión del conjunto se ejecuta un total de n-2 veces con coste
constante cada vez, por tanto O(n).
Recálculo: Por el mismo razonamiento que en el apartado de selección, el coste
es O(n2).
En las
cuestiones:



representaciones
encadenadas
destacamos
las
siguientes
La operación consulta puede llegar a ser lineal sobre el número de nodos,
aunque existe una alternativa. En el recálculo es inútil la obtención de etiquetas
para nodos y que no sean sucesores de w, porque en este caso no hay atajo en
el camino mínimo de v hacia u que pase por w. Por ello, nos podemos ahorrar
esas consultas reformulando la parte correspondiente del algoritmo. Así el coste
del recálculo pasa a ser O(a+n), pudiendo llegar a O(n2) si el grafo es denso.
En la inicialización podemos también evitar la consulta de las etiquetas
desglosando el bucle en dos, cada uno con coste total O(n).
En la fase de selección el coste es independiente de la representación del grafo
y permanece O(n2), pero nos podemos preguntar si es posible reducirlo a
O(a+n) y eso lo vamos a llevar a cabo con la cola prioritaria en el próximo
punto.
Algoritmo de Dijkstra con cola prioritaria
Jerarquía
El paso de selección lo que hace es obtener el nodo no tratado con coste
mínimo en su camino provisional hacia v. El punto clave es la característica de ser
mínimo y recordemos que las colas prioritarias presentan entre sus operaciones
precisamente la obtención y supresión del mínimo. Eso sí, recordemos que la
actualización no es uno de los métodos de las colas prioritarias, por eso definimos
un nuevo TAD que podemos observar en la figura lateral que añade las
operaciones:



Saber si un nodo está en la cola o no (existe?).
Obtención del coste asociado a un nodo cualquiera (valor).
Modificación del coste de un nodo cualquiera (Modifica).
En la implementación del TAD lo que hacemos es añadir un vector indexado
por nodos de tal forma que cada posición apunte al nodo del montículo
correspondiente, con lo que las operaciones crea existe y valor toma el valor O(1) y
añade, borra y modifica O(log n); con lo que ninguna operación llega a ser lineal.
Estructura de la información · 35
En la versión definitiva del algoritmo constatamos:



La existencia de la cola A provoca la desaparición del conjunto T para no
mantener información redundante: un elemento w ya se ha tratado si
A.consulta(w)=sup.
Desaparece el contador porque ahora sabemos que el proceso acaba cuando la
cola está vacía.
El camino de v a sí mismo al acabar la ejecución es igual al coste del ciclo más
corto que sale y vuelve a v y, por eso se pone explícitamente en el valor
mínimo justo al final; así su valor no interfiere en el funcionamiento de la
función.
La eficiencia temporal por tanto queda:




Inicialización: O(n log n) A causa de la inserción reiterada de elementos en la
cola, estando cada inserción logarítmica sobre el número de elementos que
hay.
Selección: O(n): porque cada obtención del mínimo es constante y hay n
obtenciones.
Marcaje: O(n log n): Porque cada supresión del mínimo es logarítmica y hay n.
Recálculo: O(a log n) El bucle interno examina todas las aristas del grafo a lo
largo del logaritmo y, en el peor de los casos, efectúa una sustitución por
arista, de coste O(log n).
El coste temporal definitivo es O(a+n) log n), mejor que la versión con matrices
de adyacencia cuando el grafo es disperso. Sin embargo, si el grafo es denso, el
algoritmo original es más eficiente. Eso sí, el espacio no asintótico es mucho mayor
debido a la nueva estructura de datos.
5. ALGORITMOS DE CÁLCULO DE ÁRBOLES DE EXPANSIÓN MÍNIMOS
El algoritmo del apartado anterior nos permite encontrar la conexión de
coste mínimo entre dos vértices individuales de un grafo etiquetado. Sin embargo,
en otras ocasiones nos interesa obtener un nuevo grafo con las aristas
imprescindibles para una optimización global de las conexiones entre todos los
nodos (ver el ejemplo de la tabla lateral).
Un árbol de expansión mínimo se caracteriza por:



La conectividad asegura que hay un camino entre todo par de vértices.
La aciclicidad garantiza que no hay aristas innecesarias, de hecho si hay n
nodos, deben haber n-1 aristas. Si se introduce una arista más se introduce un
ciclo, mientras que si se borra una, no todos los vértices estarán conectados.
Cualquier par de vértices estará unido por un único camino simple.
Para encontrar este árbol de expansión mínimo en cualquier grafo, haremos
uso del Algoritmo de Prim, que es un algoritmo voraz que aplica reiteradamente
la propiedad de los árboles de expansión mínimos, de forma que va incorporando
en cada bucle una arista a la solución. Su funcionamiento se explica en dos pasos:
1. Se introduce un conjunto U de vértices tratados (la primera vez se almacena un
vértice cualquiera) y se selecciona la arista mínima que une un vértice de U y
uno de su complementario, se incorpora esta arista a la solución y se añade su
vértice no tratado dentro del conjunto de vértices tratados.
2. El invariante nos asegura que realmente el resultado parcial siempre es un
árbol de expansión mínimo. Cuando acaba el algoritmo, el subgrafo es igual al
grafo entero.
36 · Estructura de la información
Evolución del cálculo de un árbol de expansión mínimo
En el algoritmo destacamos la introducción de un vector D indexado por
nodos que, para cada nodo que aún no forma parte del grafo resultante, almacena
la etiqueta mínima de las aristas que lo unen con los nodos tratados. El cálculo de
la eficiencia temporal queda como sigue:



En la Inicialización tenemos O(n) en el caso de la matriz y O(n2) en el caso de
multilistas. Podemos, como en el algoritmo de Dijkstra evitar el uso de la
consulta de forma similar, y reducimos en el caso de la multilista a O(n) pero
no nos va a afectar al coste global del algoritmo.
En el bucle principal el añadido de una arista será constante usando una matriz
y O(n) usando listas. El bucle de selección es O(n) ya que examina todas las
posiciones del vector y como debe ejecutarse n-1 veces, su coste total será de
O(n2). El bucle de reorganización depende de la representación del grafo: el
coste de los adyacentes es O(n) y el coste total queda O(n2) con matriz de
adyacencia, mientras que con multilistas queda O(a+n).
El coste asintótico total es de O(n2) independientemente de la representación,
mientras que el coste espacial es O(n).
El Algoritmo de Prim con cola prioritaria (ver ejemplo en la figura lateral)
es una mejora que se obtiene poniendo en juego las mismas ideas que usábamos
en el algoritmo de Dijkstra y que particularmente en la organización de esta cola
debemos considerar los siguientes aspectos:



Los elementos de la cola son tuplas de 3 componentes que representan aristas:
un par de nodos y la etiqueta de conexión.
Se consulta por nodo no tratado y el resultado de la consulta es la etiqueta de
la arista. Si el nodo consultado no existe, la etiqueta devuelta es sup.
El invariante debe adaptarse para tener en cuenta la existencia de la cola.
El razonamiento sobre la eficiencia del algoritmo de Prim modificado con
cola prioritaria son idénticos a los que usábamos con el algoritmo de Dijkstra y se
obtiene O((a+n) log n) que dado que el grafo inicial es conexo y entonces a>=n-1
nos queda O(a log n). Tendremos en cuenta que:



En el caso de representación por matriz de adyacencia su creación es O(n2) y la
eficacia resultante queda O(n2 +a log n)
En la representación por listas, en el peor de los casos nos queda O(n2).
Si el contexto nos permite elegir el tipo de representación del algoritmo de Prim
modificado, nos decantaremos por listas de adyacencia.
Evolución de un árbol de
expansión mínima con cola
prioritaria
Estructura de la información · 37
TEMA 5 DISEÑO DE ESTRUCTURAS DE DATOS
Para el diseño de nuevos TAD se propone una metodología en cuatro
etapas:
1. Identificación y discusión de los puntos clave del enunciado, haciendo un
análisis que permita identificar las partes relevantes desde el punto de vista del
diseño. Nos fijaremos especialmente en la funcionalidad del TAD, los datos que
intervienen (volúmenes, valores permitidos) y en los requerimientos de
eficiencia (tiempo de las operaciones, espacio ocupado). El resultado es una
lista de los puntos clave acompañados de algunas consecuencias que se
derivan de ello.
2. Decisiones de diseño: Estas decisiones condicionan la estructura de datos
como consecuencia de los puntos clave anteriores. Es importante que se
documente qué puntos clave justifican cada decisión y que se revise si cada
decisión nueva invalida alguna previa. En esta etapa existe un peligro que hay
que evitar: una decisión de diseño puede influir en otras que se hayan tomado
previamente, y hay que asegurarse de que esta interacción no sea perjudicial e
invalide parte del diseño obtenido hasta el momento. No existe ninguna receta
mágica que nos permita agrupar u ordenar los puntos clave de forma que este
peligro desaparezca.
3. Estructura de datos resultante: Una vez que se dispone ya del diseño de la
estructura, hay que plasmarla y éste es el objetivo del tercer paso. Hará falta
decidir también como se interrelacionan las diferentes subestructuras. Mientras
sea posible, se reutilizarán TADs ya existentes. El resultado se plasmará en una
descripción gráfica, la representación del tipo y su invariante.
4. Descripción de las operaciones y cálculo de eficiencia: Se hará una
explicación en lenguaje natural de la forma como se tiene que implementar
cada operación. Si se considera más ilustrativo se puede describir alguna
implementación con pseudocódigo. En cualquier caso, de la descripción se tiene
que concluir su eficiencia. Se trata de comprobar si el diseño obtenido es
correcto, más que de dar la implementación de las operaciones.
5. Implementación completa de la estructura de datos resultante: hay que
codificar hasta el último detalle, tanto los atributos como las operaciones
usando un lenguaje de programación. Esta última etapa no tiene importancia
ninguna en el proceso de creación de la solución.
Texto elaborado a partir de:
Estructura de la información
Xavier Franch Gutiérrez, Fatos Xhafa, Xavier Burgués i Illa
Febrero 2003
Descargar