Sunteți pe pagina 1din 45

Programación Modular. ETSIT. 1o C.

Guión del profesor Juan Falgueras


Curso 2005
versión: 5 de mayo de 2005

5
Tipos abstractos de datos

Contenido
5. Tipos de datos abstractos 2
5.1. Definición de TDA . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
5.1.1. Historia de la abstracción de los datos en programación . . . . . . . . . . . 3
5.1.2. Especificaciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
5.1.3. Operaciones y operadores . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
5.2. Ejemplos de TDA . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
5.2.1. Ejemplo de TDA, los Naturales . . . . . . . . . . . . . . . . . . . . . . . . . 7
5.2.2. Segundo ejemplo, el Vector . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
5.2.3. Especificación del TDA TVector . . . . . . . . . . . . . . . . . . . . . . . . 8
5.3. Implementación mediante Objetos C++ . . . . . . . . . . . . . . . . . . . . . . . . . 10
5.4. Pilas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
5.5. Especificación del TDA pila . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
5.5.1. Especificación formal del TDA pila . . . . . . . . . . . . . . . . . . . . . . . 11
5.5.2. Procedimientos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
5.5.3. Excepciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
5.6. Formas de implementación . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
5.6.1. Comparación de las complejidades de las implementaciones de pilas . . . . 14
5.7. Aplicaciones de las pilas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
5.7.1. Análisis de expresiones aritméticas . . . . . . . . . . . . . . . . . . . . . . . 15
5.7.2. Paso de parámetros . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
5.7.3. Eliminación de la recursividad con pilas . . . . . . . . . . . . . . . . . . . . 17
5.7.4. Comprobación de paréntesis . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
5.7.5. Pilas.Ejercicios . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
5.8. Colas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
5.9. Especificación del TDA cola . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
5.9.1. Especificación formal del TDA cola . . . . . . . . . . . . . . . . . . . . . . . 22
5.9.2. Constructores, Selectores, Iteradores . . . . . . . . . . . . . . . . . . . . . . 23
5.9.3. Excepciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
5.9.4. Formas de Implementación . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
5.9.5. Colas de prioridad . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
5.9.6. Colas.Ejercicios . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
5.10. Listas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
5.10.1. Especificación formal del TDA lista . . . . . . . . . . . . . . . . . . . . . . 28
5.10.2. Interfaz e implementaciones . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
5.10.3. Implementaciones acotada . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
5.10.4. Implementación no acotada . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
5.10.5. Listas.Ejercicios . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
5.11. Conjuntos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
5.11.1. Iteradores sobre tablas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
5.11.2. Colisiones en hashing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
5.11.3. Propiedades de las funciones hash . . . . . . . . . . . . . . . . . . . . . . . 36
5 Tipos de datos abstractos 2

5.11.4. Técnicas de manejo de colisiones . . . . . . . . . . . . . . . . . . . . . . . . 37


5.11.5. Hashing de dirección abierta . . . . . . . . . . . . . . . . . . . . . . . . . . 37
5.11.6. Hashing de encadenamiento externo . . . . . . . . . . . . . . . . . . . . . . 39
5.11.7. Complejidades . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
5.11.8. Ejercicios Tablas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
5.12. Referencias de consulta . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
5.13. Apendice A: Random . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44

5. Tipos de datos abstractos

5.1. Definición de TDA


Los Tipos de Datos Abstractos (TDAs1 , Abstract Data Types —ADTs) fueron por prime-
ra vez propuestos por Barbara Liskov en 1974 (“Programming with Abstract Data Types”). Un
TDA está caracterizado por un conjunto de operaciones (procedimientos y funciones) denomina-
dos usualmente su interfaz pública y representan el comportamiento del TDA; mientras que
la implementación privada del TDA está oculta al programa cliente que lo usa2 . Todos los
lenguajes de alto nivel tienen predefinidos TDAs; lo son los tipos denominados simples y las es-
tructuras predefinidas, y estos tienen sus interfaces públicas que incluyen las operaciones (como
+, -, *, [], etc.).
No se ofrece acceso ni visibilidad ni se necesita conocer cómo actúan tales operadores sobre la
representación interna (implementación privada) de esos tipos, que además, suele ser una imple-
mentación bastante dependiente de la máquina sobre la que trabaje el compilador, cosa de la que
el cliente felizmente no tendrá que preocuparse. Lo interesante es que los lenguajes actuales nos
van a permitir además ampliar los TDAs predefinidos con otros que serán definidos por el propio
programador para adecuar ası́ los tipos de datos a las necesidades de los programas.

Un TDA es un conjunto de operaciones que exponen y/o modifican


el estado de una información internamente almacenada.

Los TDAs que nos van a interesar de ahora en adelante son aquellos que reflejen cierto
comportamiento organizando variedad de datos estructuradamente. A esta forma estructurada
de almacenar los datos será a la que nos refiramos para caracterizar cada TDA. Los TDAs que
tienen informaciones simples pero dependientes de un comportamiento estructural serán llamados
polilı́ticos por contra de aquellos TDAs simples, ya conocidos, como son los tipos predefinidos
simples, o TDAs monolı́ticos dónde la información no es relacionada mediante ninguna estructura
y no admiten más que un valor en cada momento. Un número entero o vale 10 o vale 5, pero no
puede contener simultáneamente ambos estados ya que para ello tendrı́a que disponer de una
estructura, definida de alguna manera, que relacionara ambos valores.
Un TDA tı́pico por su sencillez es la pila (stack ). El concepto de pila es el de un montón de
información (del mismo tipo) a la que se puede acceder sólo por el “sitio por dónde se introduce”,
o sea, que sólo se puede extraer el objeto que se acaba de añadir. Esta limitación, sin embargo, la
hace mucho más simple aún de ser implementada, y además no deja de hacerla sumamente útil en
muchı́simas situaciones. De hecho, después del array, la pila es la estructura que probablemente
más tiempo está funcionando en cualquier computador moderno3 . Nótese que para hablar del TDA
1 Con frecuencia diremos también con gran parte de la bibliografı́a en castellano, Tipos Abstractos de Datos en

vez de la más correcta Tipos de Datos Abstractos, ya que la abstracción se refiere a los datos no al ya abstracto
concepto de tipo, pero esto se convierte más en un juego de palabras, y, en cualquier caso, TDA es más fácil de
decir TAD que TDA. Usaremos ambas siglas indistintamente.
2 de ahora en adelante hablaremos sencillamente de cliente del TDA
3 recuérdese que las llamadas a las funciones están basadas en pilas dónde se almacenan los registros de estado.
5.1 Definición de TDA 3

pila no hemos hecho ninguna alusión a ningún tipo de elemento que tenga que estar apilado, sino
tan sólo a la forma en cómo disponer los elementos. Sólo nos interesa la estructura que soporta
la información. Los elementos a guardar dependerán de cada programa concreto en el último
momento.
¿Cómo se caracteriza este comportamiento estructural? Observando el tipo de acciones que
podemos aplicar sobre la pila. En este caso tenemos la posibilidad de amontonar y de sacar ele-
mentos, luego una pila basta que tenga las operaciones de apilar y desapilar para estar totalmente
controlada y llegar, mediante estas operaciones a ser posible construir cualquier instancia de la
misma.
Las operaciones apilar y desapilar que constituyen las mı́nimas necesarias para manipular
una pila (de lo que sea) constituyen la interfaz pública del TDA pila. Podremos apilar números,
letras, arrays, estructuras complejas e incluso otra pila, pero las operaciones serán las mismas. Es
por eso que la caracterización del TDA pila la dan los operadores, no los elementos que llamaremos
base, de tipo base o ı́tems que la integren.
Los TDAs tendrán una parte interna invisible al usuario. Esta parte oculta, innecesaria para
su uso, la constituyen tanto la maquinaria algorı́tmica que implemente la semántica de los opera-
dores, como los datos que sirvan de enlace entre los ı́tems del TDA, información interna necesaria
por la implementación que se esté haciendo para ese comportamiento del TDA. Ası́ tanto la im-
plementación de los operadores como los datos serán internos al TDAs, privados al acceso externo
y ocultos a cualquier otra parte cliente de la misma.
Esta división de aspectos no es nueva con la aparición de los TDAs y se ha demostrado muy
fructı́fera en muchos otros terrenos de la ingenierı́a, por ejemplo, la mecánica y los mecanismos de
conducción de automóviles, la independencia de la interfaz persona-ordenador de su implementa-
ción en el entorno orientado a gráficos, etcétera.
Los TDAs refuerzan el diseño y desarrollo de aplicaciones desde la perspectiva de “la infor-
mación y de su uso”. Por un lado, desde el punto de vista de la información pura, los TDAs están
relacionados con la diagramación tipo Entidad-Relación, mientras que desde el punto de vista del
uso, los TDAs permiten caracterı́sticas funcionales que admiten su exploración tanto en forma
top-down como bottom-up. Ambas perspectivas ofrecen una nueva forma de diseño más completa
que cada una por separado.
En este tema no desarrollaremos toda la teorı́a subyacente al diseño orientado a TDAs y los
procesos seguidos para ello, sino que nos guiaremos de algunos TDAs muy comunes para verlos en
acción en situaciones frecuentes en programación.

5.1.1. Historia de la abstracción de los datos en programación


Los primeros lenguajes (FORTRAN, COBOL y ALGOL 60) introducen la abstracción de
datos a nivel de estructuras simples, secuencias de bits interpretadas como datos simples: enteros,
reales, caracteres, booleanos; se introducen operadores y una estructura tipo tabla o matriz. Estos
tipos predefinidos corresponden a un nivel bajo de abstracción: el programador puede ignorar los
detalles de la implementación haciendo declaraciones como
REAL X,Y;
y posteriormente
X = X + 3.14 * Y;
representando “*” y “+” las operaciones matemáticas abstractas × y + ignorando el programador
los detalles de su implementación hardware y software.
Los siguientes, Simula 67, ALGOL 68 y Pascal permiten ya mecanismos para el diseño de
nuevas abstracciones. Pascal y ALGOL 68, además de ampliar los tipos predefinidos, introducen
mecanismos para la declaración de nuevos tipos (“modos” en ALGOL 68). Con ello consiguen
mayor expresividad en el algoritmo y mecanismos de verificación en el propio compilador. Los tipos
de datos permiten jerarquı́as en las definiciones y, mediante los punteros, definición de estructuras
recursivas. Sin embargo Pascal aún no permite heredar en la creación de los tipos más que las
5.1 Definición de TDA 4

operaciones definidas por el lenguaje para los tipos básicos, las operaciones añadidas para los
tipos creados por el usuario no se heredan. Tampoco permite forzar esta “herencia”, definiendo el
antiguo operador que necesitamos para el nuevo tipo con el mismo nombre y que el compilador
elija el que debe usarse según el parámetro sea del tipo original o el derivado. No existe pues
la sobrecarga de operadores. Tampoco el polimorfismo, que es una propiedad que abarca a la
sobrecarga de operadores y que en esencia es la posibilidad de que un objeto cualquiera pueda ser
de distintos tipos, siendo el compilador el que seleccione según el contexto, los métodos a usar.
Cuando en un lenguaje como C, Pascal, o Modula-2 se define un tipo “nuevo” con la decla-
ración typedef o TYPE realmente no se está creando un nuevo tipo en el sentido estricto; y en
realidad el tipo nuevo es compatible (intercambiable) con su base:
TYPE LONGITUD= CARDINAL;
VAR l: LONGITUD;
i: CARDINAL;
...
l:= i; (* es válido *)
luego son de igual tipo (Ada es más coherente respecto a esto). En realidad lo que estos lenguajes
crean con los nuevos tipos son subtipos (denominados subtypes en Ada). El siguiente esquema
representa el árbol tipológico usual en los lenguajes procedurales tipo Pascal:

 Bits
Bytes
Words
Atómicos Integers
@ No Punto Fijo

@ No Punto Flotante
@
R
@ Enumerados

Tipos base

H
J HH
j Compuestos  * Arrays

J HHj Records
J
^ Punteros
J - Acceso a otros

En los lenguajes de tipificación simple como éstos se pueden crear:


TYPE= <Ident>

Qualident
SingleType
EnumerationSubrange, (no en C)
ArrayType
RecordType
SetType
PointerType
ProcedureType
estos lenguajes sin embargo hacen incompatibles declaraciones diferentes aunque sean del mismo
tipo base siempre que sean en distintos TYPE= excepto que los tipos base de ambas sean iguales y
sólo se estén haciendo subrangos.
TYPE LONGITUD, AREA= CARDINAL;
VOLUMEN = CARDINAL;
ENDOCENA = [0..12];
DIAMES = [1..31];
VAR l: LONGITUD;
a: AREA;
v: VOLUMEN;
dc: ENDOCENA;
5.1 Definición de TDA 5

d: DIAMES;
...
l:= a; (* SI es válido! *)
l:= v; (* NO es válido *)
dc:= d;(* SI es válido! *)

5.1.2. Especificaciones
Siempre debemos distinguir entre la implementación de un TDA y su especificación. Uno es
el qué y el otro el cómo debe hacerlo. Cuando nos preocupamos de la implementación estamos
hablando de una estructura de datos no de un TDA.
Una especificación debe ser formal. Por ejemplo, una especificación informal del tipo pila
podrı́a ser:
“Una pila es una colección de elementos o datos (ı́tems) de un mismo tipo puestos
de forma que el primero que se extraiga sea temporalmente el último que se añadió y
subsiguientes extracciones sean por orden los últimos añadidos. Se llama head, top,
cima o cabeza al elemento que acabo de añadir que es además el único que puede ser
extraı́do.”
Las posibles operaciones son:
cima devuelve el top
apilar añade una nueva cima
desapilar extrae la cima
crear crea un stack vacı́o
estáVacı́a devuelve pila = vacı́o
formalidad
Sin embargo, veamos lo que es una especificación formal. Existen dos formas posibles de especificar
formalmente un TDA
1. Axiomática expresión de la forma de las operaciones
2. Semántica expresión de la operatoria de las operaciones
Existen cuatro partes en la especificación formal de un TDA:
1. Nombre tanto del TDA como del Objeto ı́tem
2. Conjuntos de objetos y/o otros TDAs involucrados en las operaciones
3. Sintaxis método de uso de los objetos y nombre de las operaciones
4. Semántica axiomática y/u operativa

completitud
1. El conjunto de axiomas que definen un TDA debe ser completo en el sentido de definir el
resultado de todas las aplicaciones permisibles de las operaciones sobre el TDA.
2. completo en el sentido de definir operaciones que permitan construir todas las posibles ins-
tancias o situaciones del TDA
Se pueden poner expresiones compuestas
Se considera sintácticamente no válido
Desapilar(Desapilar(Desapilar(Apilar(Crear(),i))))

En orden de poder aplicar todas las posibles operaciones a todos los posibles resultados
conviene ampliar con
5.1 Definición de TDA 6

Axiomas:
a) Cima(E2) ::= E2
b) Desapilar(E1) ::= E1
pero esto puede añadir complicados y numerosos axiomas. Una mejor opción es usar la
aserción de invariantes:
aserción de invariantes: en el caso de aplicarse una operación a un valor de excepción
el resultado es el mismo valor de excepción

Para especificar los TDAs usaremos una notación formal ampliamente aceptada.

1. En primer lugar describiremos los nombres tanto del TDA (que coincidirá con la clase de
programación orientada a objetos) y del elemento que constituye la base del mismo.

2. Después describiremos todos los conjuntos de datos que van a intervenir en la definición
del TDA. Entre ellos estarán los conjuntos del TDA en definición, el conjunto de elemento
del tipo base, y conjuntos adecuados para trabajar con estos.

3. Las especificaciones sintácticas aclaran mediante un esquema de dominio-rango sobre


qué conjuntos actúan las operaciones para dar qué otros elementos, independientemente de
la sintaxis. Piénsese que muchas funciones de acceso sobre los TDAs pueden ser descritas
mediante una sintaxis u otra, incluso mediante un mecanismo de comunicación tipo devolu-
ción de valores o modificación de parámetros. En las especificaciones sintácticas se reflejan
tan sólo los participantes en estas operaciones.
Al construir las especificaciones sintácticas ya se indican qué procedimientos son suficientes
e imprescindibles para, por un lado, construir cualquier instancia del TDA en cuestión y,
por otro, poder utilizarlo, esto es, conocer los detalles suficientes del estado del TDA.
Los procedimientos de acceso pueden ser:

Constructores entre ellos siempre se contarán el constructor y el destructor del TDA-


clase. Pero además estarán todos aquellos procedimientos que modifiquen el estado
del TDA llevándolo ası́ a nuevos estados dentro de su gama de posibles estados que en
total son su cardinalidad.
Selectores que son los procedimientos que sirven para informarnos cómo está el TDA, sin
modificarlo. Aquı́ estarán tanto los que averiguan si está vacı́o, el número de elementos
que tiene (si interesara en un TDA el saberlo), si está lleno, aunque esto depende
más de la forma de implementación, pero puede extenderse como un selector universal.
También estarán aquı́ los métodos que nos devuelvan valores seleccionados del interior
de la estructura del TDA.
Iteradores no siempre necesarios y/o fáciles de implementar. Un iterador es un operador
que guı́a el recorrido de una función sobre la estructura en curso sin modificar la estruc-
tura, aunque sı́ puede modificar los valores en los nodos de la estructura. Ası́ se pueden
entender los iteradores como activos o pasivos según puedan modificar o no los nodos de
las estructuras y, por otro lado, se pueden parar antes de llegar al final de su recorrido
según la función a la que pasean lo indique, usualmente cuando hayan encontrado lo
que buscan, mediante un resultado, quizás, positivo, sobre el nodo final.

4. Las especificaciones constructivas detallan las pre y postcondiciones de cada procedi-


miento de acceso al TDA. Aquı́ se detalla una forma de sintaxis para cada método. Ası́ se
indica ya si se pretende usar como función o procedimiento que modifica parámetros, etcéte-
ra, pero lo importante es tratar de indicar el estado anterior y posterior en el TDA.
5.2 Ejemplos de TDA 7

5.1.3. Operaciones y operadores


Los posibles operadores se pueden clasificar en:
Constructores modifican el estado del objeto
Selectores evalúan el estado del objeto; se pueden diferenciar dentro de ellos los predicadores,
que evalúan atributos más particulares
Iteradores permiten “visitar” todas las partes elementales de la estructura del objeto (ver los
ı́tems por separado). Se diferencian los activos de los pasivos en que los primeros modifi-
can (volvemos a tener un constructor) mientras que los pasivos sólo ven la información sin
modificarla (un selector)
La taxonomı́a más completa en los TDAs más generales (dejando en las hojas del árbol los nombres
más comunes de los procedimientos) se muestra en Fig. 1.

Operaciones
abstractas

Cambios Visualiza
de estado estado

Primitivas Constructores Selectores Iteradores


de construcción

Ver Cambiar
Limpiar Asignar EstáDefinido IsEmpty ítems
Crear Destruir ítems
EsIqual TamañoDe
Loop Traverse Loop Traverse
Over Change Change

Figura 1: Taxonomı́a de los posibles métodos de acceder a los TDAs

La diferencia entre “LoopOver” y “Traverse” es que aunque ambos reciben el objeto y un


procedimiento de tratamiento de los ı́tems, LoopOver puede detenerse cuando la función devuelva
un valor False, mientras Traverse ha de recorrer todos los componentes del TDA. El caso de
LoopChange y TraverseChange es el mismo excepto que además se permite cambiar los elementos
componentes.
El orden de recorrido dependerá en cada caso del iterador (LoopOver, Traverse, etc) pudiendo
además existir más de un tipo de iterador según la técnica de recorrido (DepthTraverse (recorrido
en profundidad) y BreadthTraverse (en anchura), por ejemplo).
Con cada TDA se irán viendo los diferentes procedimientos, su significado, semántica, pre-
condiciones, postcondiciones, invariantes, etc.

5.2. Ejemplos de TDA


Como ejemplos de TDAs veamos cómo especificarı́amos y definirı́amos la semántica operativa y
axiomática de un tipo de datos simple y de una familiar estructura de datos, de manera que po-
damos abstraer los detalles de implementación. Aún no utilizaremos la notación orientada a mensajes
propia de los objetos, que usaremos el resto del curso.

5.2.1. Ejemplo de TDA, los Naturales


Los números naturales (N) se pueden definir como un conjunto infinito de objetos sobre los
que se pueden aplicar una serie de operaciones. Anticipemos una definición de TDA numero
natural:
Nombre: Naturales ()
5.2 Ejemplos de TDA 8

Conjuntos:
N: conjunto de los objetos-números naturales
B: (TRUE, FALSE) (booleanos)
E: (FueraDeRango)

Especificaciones Sintácticas:
Cero: →N
EsCero: N →B
Pred: N →N ∪ E
Suce: N →N
Añade: N × N →N
Multip: N × N →N

Axiomas:
A1) EsCero(Cero) ::= TRUE
A2) EsCero(Suce( )) ::= FALSE
A3) Pred(Suce(x)) ::= x
A4) Pred(Cero) ::= FueraDeRango
A5) Añade(x,y) ::= si EsCero(y) entonces
x
sino
Suce(Añade(x, Pred(y)))
finsi
A6) Mult(x,y) ::= si EsCero(y) o EsCero(x) entonces
Cero
sino
Añade(Mult(x, Pred(y)), x)
finsi

Notar que los números naturales son: Cero, Suce(Cero), Suce( Suce( Cero )), etc.
hasta que reciben “nombres más cortos”: ‘0’, ‘1’, ‘2’, etc.

5.2.2. Segundo ejemplo, el Vector


Los vectores vienen definidos en la mayorı́a de los lenguajes de forma predefinida como forma-
ciones (arrays) unidimensionales. Se trata de una estructura monolı́tica (no reestructurable) por
definición acotada y compuesta por un número finito de elementos todos del mismo tipo (base)
a los que se accede mediante un segundo tipo, llamado ı́ndice que es de tipo conjunto finito, de
forma que cada valor de este conjunto finito de valores nos lleva a un valor del tipo base. No se
pueden ni borrar ni insertar elementos de un vector.Ver [Mar86].

5.2.3. Especificación del TDA TVector

Especificación formal del TDA TVector Nombre: TVector (TBase, Index )

Conjuntos:
V: conjunto de los TVector
I: conjunto de items de tipo = base
X: conjunto finito. Por ej.: 1..k
E: Excepciones
5.2 Ejemplos de TDA 9

Especificaciones Sintácticas:
Crear: →V
Cambiar: V × X × I →V
Valor: T × X →I
Destruir: V→

Especificaciones Constructivas:
CreateVector(S
 TVector v);
Pre ::= Ninguna
Post ::= v 0 ∈ V (X, I)
Cambiar(VAR
 v : VECTOR; i: INDEX; x: TBase);
Pre ::= v ∈ V (X, I)
Post ::= Valor(v 0 , i) = x
Valor( v : VECTOR; i: INDEX; VAR x: TBase);
Pre ::= v ∈ V (X, I)
Post ::= v 0 = v; x0 = Valor(v, i)
Destruir(VAR
 v : VECTOR);
Pre ::= v ∈ V (X, I)
Post ::= v 6∈ V (X, I)

Axiomas:
∀v ∈ V (X, I); i, j ∈ X; x ∈ I − {error}
A1) Valor(CreateVector(), i) ::= error
A2) Valor(Cambiar(v, i, x), j) ::= si i=j entonces
x
sino
Valor(v, j)
finsi

Registro Si queremos, en el tipo vector, podemos eliminar el conjunto X de ı́ndices mediante


la selección particular de cada componente. Bastarı́a con sustituir:
Valor(v, i) → Valori (v)
Cambiar(v, i, x) → Cambiari (v, x)
con lo que tendrı́amos k procedimientos Valor y k procedimientos Cambiar para un vector de k
ı́ndices, uno por ı́ndice. Ahora, al haber distintos procedimientos de acceso para cada componente,
podemos tener también distintos tipos base en cada posición. Esta estructura serı́a la equivalente
al tipo registro de Pascal, o structura de C.
Ambos tipos, el vector y el registro, tienen las caracterı́sticas comunes de mantener cada
componente asociado con un predeterminado conjunto de componentes. Esta relación corresponde
a los miembros del conjunto de ı́ndices o a las parejas Valor-Cambiar, dentro del conjunto de
operaciones. De esta forma el conjunto de componentes de un vector o de un registro es fijo y no
se puede borrar un componente sin sustituirlo por otro.

Comparación con las secuencias En el caso de las secuencias, la relación ı́ndice-componente


no es tan fuerte como en el de los vectores. Si se añade un elemento al principio de la secuencia,
todos los componentes de esta cambian automáticamente el valor del ı́ndice asociado, en este caso,
añadiéndole 1. Es por esto el que en las secuencias no tenga tanto sentido la función:
TBase Valor(SEQUENCE s, INDEX i)
que asocia ı́ndice y valor. Las secuencias además pueden crecer o encoger y no es necesario sustituir
los componentes para borrarlos.
5.3 Implementación mediante Objetos C++ 10

5.3. Implementación mediante Objetos C++


Según Meyer (1988)4 , el objetivo fundamental del ocultamiento de la información es la abs-
tracción, no la protección.
La programación orientada a objetos aporta su capacidad de encapsulado para poder abs-
traer la interfaz de la implementación de los TDAs. Por otro lado, la sobrecarga de operadores y
funciones para integrar más armoniosamente los nuevos tipos con los ya existentes sin sobrecargar
la el número de funciones y facilitando su uso. Ası́mismo el control de excepciones permite ais-
lar la implementación los TDAs de la interfaz dónde se controlarán mejor los posibles errores de
funcionamiento.
Sin embargo, aún con la potencia de la programación orientada a objetos no llegan a cubrir
dos grandes aspectos:

1. la genericidad de las definiciones


2. y la total independencia entre especificación e independización.

El problema primero, el de falta de genericidad es el de que cuando se declara una clase para
especificar un TDA es necesario concretar el tipo base sobre el que se está trabajando en el TDA
de manera que la clase pueda recibir parámetros de ese tipo, devolverlos etc. Sin embargo, en
realidad cuando especificamos formalmente un TDA no hacemos referencia alguna a la base o
tipo de los elementos sobre los que la estructura abstracta está. En realidad sı́ hay una referencia,
pero muy elemental. Los elementos de tipo base se supone que son copiables. Suponemos que los
elementos de tipo base que hemos de meter en una pila, por ejemplo, admitirán la operación de
copia, ası́ como suponemos que podemos devolver esos elementos copiados. En la mayorı́a de las
ocasiones ésta será la única exigencia que se le hará a los tipos base y esto es ası́ en todos los tipos
simples y en los objetos usuales, o al menos se podrá definir una operación de copia de objetos
cuando nos haga falta el usarlos como base de una estructura de un TDA.
Aunque sólo exijamos la mayorı́a de las veces tan sólo la copiabilidad del tipo base del TDA,
sin embargo, la programación orientada a objetos nos obliga a especificar el tipo del parámetro de
manera que habrı́a que tener una clase para definir pilas de caracteres, otra clase, con otro nombre,
para pilas de enteros, otra para. . . lo que es bastante incómodo por no decir inútil o imposible.
En principio, en las especificaciones formales en pseudocódigo o sencillamente en sus especifi-
caciones algebraicas formales, nos referiremos al tipo base de una manera general y no hará falta
concretar si el tipo base es un carácter o un número real o una estructura. Dejaremos este pro-
blema de la concreción de la base para ser resuelto en el lenguaje de programación concreto. En
particular el lenguaje de programación C++ aporta un potente mecanismo pseudosintáctico que
permite la construcción de unidades (funciones y clases) genéricas en las que serı́a posible dejar sin
especificar aún algún tipo, etc. y postergando la concreción del tipo con el que deberá trabajar la
clase hasta el último momento en el que se use. Tenemos, pues un mecanismo bastante potente con
el que aproximarnos a esta deseada genericidad. Por otro lado, la construcción sin estas plantillas
de las clases correspondientes a los TDAs más importantes es tarea suficientemente interesante y
compleja como para poder, en principio dejar de usar estas plantillas que permiten la genericidad
en aras de una mayor sencillez del código y menor distracción sintáctica con esta complicación
añadida. Más tarde, se indicará cómo emplear estas plantillas.
La segunda gran dificultad que no resuelven la programación orientada a objetos es la inde-
pendencia total de la declaración de la clase (y, por ende, del TDA) de la implementación. Y es que
aunque eliminemos la construcción de los métodos online y posterguemos totalmente el contenido
de los métodos al fichero correspondiente de implementación del TDA, queda la parte privada.
Inevitablemente, si una clase tiene atributos, estos atributos deben aparecer en la declaración.
Lenguajes como C++ no permiten el que los atributos se puedan postergar a la implementación
del TDA. Esto expone innecesariamente una parte pretendidamente privada en la cabecera de una
clase-TDA. Aunque el cliente del TDA no pueda hacer uso de los atributos privados del TDA, sin
embargo la más leve modificación en la forma de implementar el TDA implicará la modificación
4 uno de los padres de Smalltalk y desarrollador del también, más moderno, lenguaje orientado a objetos, Eiffel
5.4 Pilas 11

de estos atributos por parte del programador del TDA, que ası́ tendrá que tocar en el fichero de
cabecera de declaración de la clase-TDA con lo que todo el sistema que depende de él tendrá que
ser recompilado aún sin necesidad ya que ninguno de los clientes del TDA, como hemos dicho, veı́a
o hacı́a uso de la parte privada. Tan sólo habrı́a sido necesario recompilar, al menos en teorı́a, la
parte de la implementación del TDA, pero no todos los clientes de la clase.
Por ejemplo, si decidimos que el TDA pila debe ser implementado mediante un array, este
array deberı́a estar declarado en la parte privada de la clase pila. Si en otra ocasión, vemos
más adecuada la implementación la pila mediante una lista de nodos encadenados, la declaración
privada será diferente y todos los clientes (que no tendrı́an en principio que saber nada de estos
mecanismos internos del TDA pila) se tendrán que compilar de nuevo.
La única solución práctica que vemos a esta limitación de la programación orientada a objetos
es la utilización, al estilo Modula-2 de tipos opacos. Este tipo es un sencillo puntero sin tipo destino
(una especie de void *), que se resolverı́a dentro de la implementación de la clase pero ya sin
tocar más la declaración aunque se cambiase de array a lista o a cualquier otro el mecanismo de
implementación del TDA.
Este método de implementar los TDAs asemejarı́a mucho a los lenguajes orientados a objetos
al mecanismo de ocultación de Modula-2. Sólo que la programación orientada a objetos añade, en
el caso de C++ la posibilidad de plantillas (cosa innecesaria, por otro lado en el lenguaje Ada).

5.4. Pilas
Una estructura que aparece frecuentemente en programación es la Pila (Stack ). En lenguajes
de alto nivel, es muy importante para la la eliminación de la recursividad, análisis de expresiones,
etc. En lenguajes de bajo nivel es indispensable y actúa constantemente en todos los lenguajes
compilados y en todo el sistema operativo.
La caracterı́stica más importante de las pilas es su forma de acceso. En los arrays y en las
listas, el acceso es directo: se selecciona el ı́tem de la secuencia mediante algún parámetro. La
pila simplifica el acceso a su información y tan sólo son imprescindibles dos procedimientos para
trabajar con una pila: añadido y extracción. No se necesitan parámetros para ninguno de los
procedimientos, en general. Es pues un tipo muy simple de uso e implementación, como veremos.

Una pila es una estructura de datos ordenados según el orden de inserción y de los que
sólo es posible acceder al último insertado. Este tipo de control del acceso se denomina
LIFO: último en entrar primero en salir (last in first out).

Una pila es pues un conjunto totalmente ordenado, en el que se insertan y eliminan elementos pero
sólo accesibles por el elemento CIMA (o Top) que fue el último en ser insertado.

5.5. Especificación del TDA pila


5.5.1. Especificación formal del TDA pila
Denotaremos: p la pila recibida, p0 la pila procesada, p = () la pila vacı́a, p = (t1 , . . . , tn pila
con 1 ó más elementos, Apilados por orden de t1 a la actual cimae, tn :

Nombre: TPila (TBase)

Conjuntos:
P: conjunto de las pilas
I: conjunto de items de tipo TBase (copiables)
B: {FALSO, CIERTO}
E: PilaVacı́a
5.5 Especificación del TDA pila 12

Especificaciones Sintácticas:
Crear: →P
Destruir: P→
Apilar: P × I →P ∪ E
Desapilar: P → (P × I) ∪ E
Cima: P →I ∪ E
EstáVacı́a: P →B

Especificaciones Constructivas:
TPila p()

Pre ::= Ninguna
Post ::= EstáVacı́a(p0 )
~TPila p()

Pre ::= existe p
Post ::= p no existe
p.Apilar(TBase y)

Pre ::= existe p
Post ::= ¬EstáVacı́a(p0 ) ∧ Cima(p0 ) = y
TBase p.Desapilar()

Pre ::= ¬ EstáVacı́a(p)
Post ::= p0 6= p
Ret ::= Cima(p)
TBase p.Cima()

Pre ::= ¬ EstáVacı́a(p)
Post ::= p0 = p
Ret ::= Cima(p)
Bool p.EstáVacı́a()

Pre ::= existe p
Post ::= p = p0
Ret ::= desde Crear(p), No de Apilar == No Desapilar

Axiomas:
A1) Cima(Apilar(s,i)) ::= i
A2) IsEmpty(Crear()) ::= TRUE
A3) IsEmpty(Apilar(s,i)) ::= FALSE
A4) Desapilar(Apilar(s,i)) ::= s
A5) Desapilar(Crear()) ::= E1
A6) Cima(Crear()) ::= E2

En la sintaxis sólo se han considerado los excepciones que producirı́an cualquier forma de imple-
mentación sin considerar la verificación de las precondiciones. Para considerar todas las posibles
excepciones es necesario concretar la forma de implementación. La sintaxis es variable en cuanto
a la forma en que se devuelven los objetos de tipo TBase; a veces se empleará el modo “return”
y otras, como en la expuesta, en modo parámetro. En la descripción semántica formal de las pre
y las postcondiciones no se ha incluido el método de control de estas excepciones; aunque esta
sintaxis podrı́a corresponder a control de excepciones por “variable global”.
También es conveniente el el constructor de formas de implementación acotadas el indicar
(mediante un parámetro con algún valor por defecto) el tamaño total de la estructura estática.
5.6 Formas de implementación 13

5.5.2. Procedimientos
La pila crece con Apilar y decrece con Desapilar, los demás son selectores y modificadores
globales, no siempre utilizados.

Selectores EstáVacı́a, Cima

Constructores Apilar, Desapilar, Crear y Destruir

Iteradores No existen

En nuestra implementación se ha reunido Cima con Desapilar, esto es, se ha hecho que al eliminar
la cima, Desapilar, se devuelva el elemento Cima. Esto no siempre se hace ası́, la ventaja es práctica,
según las aplicaciones.

5.5.3. Excepciones
La única excepción que puede aparecer en un TDA pila es la de tratar de leer (desapilar) algo
de una pila vacı́a. Otras situaciones de excepción no dependen de las especificaciones formales,
sino de la forma de implementación y deberı́an estar reflejadas en cada caso. En este segundo caso
la excepción no aparece por violación de precondiciones sino, en general, por alguna limitación de
la forma de implementación.
El control de excepciones es un problema muy difı́cil, por no decir, imposible, en general, en el
desarrollo de software, sin embargo cada vez son más los lenguajes que se apoyan en el mecanismo
de aptrapar excepciones ideado en Ada, en el que se forman ámbitos en los que cualquier excepción
lanzada en cualquier parte interna del mismo se caza mediante un mecanismo de selección del tipo
de excepción, etcétera.
No nos preocuparemos más que de destacar el tipo de excepciones que se pueden dar en cada
caso, pero no haremos más hincapié en este tema.
En la práctioca se pueden dar las excepciones:

1. Por violación de las precondiciones. Esto es, el usuario del TDA comete algún error de uso
que detectará el propio TDA. Estas excepciones son debidas a defectos del programa usuario.
Son:

SINDATOS Se trata de extraer un elemento de una pila vacı́a. Este es un error del usuario del
TDA.
EXCEDIDA tı́pico de las implementaciones acotadas de los TDAs aunque se puede dar alguna
vez en las no acotadas.

2. Por defecto de la implementación se podrı́an dar errores como el de ı́ndice fuera de rango,
etcétera.

5.6. Formas de implementación


Una implementación de un TDA puede ser más o menos buena de por sı́ o y más o menos
adecuada a la aplicación final. Esta adecuación, sin embargo no tiene nada que ver son el proceso
algorı́tmico, sino con cosas como las previsiones del volumen de datos a tratar, la necesidad de
espacio o de velocidad en el proceso, etcétera. Por lo tanto, no deberı́a, como hemos visto, afectar
al algoritmo (programas) dependientes de una forma explı́cita, sino a la ejecución del programa
final, de alguna forma.
Una implementación reutilizable no debe presuponer la verificación de las pre o las post-
condiciones de modo que deberı́an codificarse, según la semántica de cada procedimiento, las
correspondientes rutinas de control. Una forma segura y relativamente simple y fácil de control
de errores en lenguajes como procedurales es la variable de control exportada por el módulo del
TDA. Cada tipo de excepción puede ser etiquetado y exportada su etiqueta, aunque ésta no es
una cuestión que ofrezca mayor seguridad, sino depurabilidad. Una forma de exportar fácilmente
5.7 Aplicaciones de las pilas 14

un flag condición de error global es mediante una variable definida en la interfaz de declaración
del TDA.
Según la extensibilidad espacial, existen dos formas de implementación, la acotada y la no
acotada.

La implementación acotada utiliza un array (que tiene un tamaño fijo, aunque en C++ este
tamaño se puede concretar fácilmente en el momento de la “construcción” del objeto, en ese caso
hay que guardar ese tamaño en un atributo nuevo, para no sobrepasar los lı́mites del array), y
tiene también un ı́ndice entero que es el punto de lectura escritura en el array. Ver ejercicio 1.

La implementación no acotada se gestiona mediante la forma más simple de nodos dinámicos


enlazados. Basta con añadir y quitar por el propio punto de comienzo de esta cadena de nodos. La
clase sólo necesitará un puntero. La pila estará vacı́a cuando este puntero valga 0. Ver el ejercicio 2.

5.6.1. Comparación de las complejidades de las implementaciones de pilas


Es intersante observar que la estructura pila, por su sencillez, no tiene ningún procedimiento
de acceso de complejidad lineal como ocurre con las listas. En las listas el acceso a una posición
es inevitablemente de complejidad lineal T (N ) ∈ O(N ); aunque se pueda mejorar el coeficiente
(T (N ) = 1/2N ), en el peor caso. En las pilas, en todos los procedimientos de acceso nos encontra-
mos en el mejor caso del acceso a listas, de complejidad constante, T (N ) ∈ C, tanto Apilar, como
Desapilar, que son los más complicados, necesitan acceder tan sólo al primer elemento, indepen-
dientemente del tamaño de la pila.
¿Cuál es la diferencia entre las implementaciones no acotada y acotada, en cuanto a comple-
jidad? Ninguna. Sin embargo, los tiempos de ejecución sı́ son un poco diferentes. Nótese que el
algoritmo de la implementación No acotada para Desapilar
1 TBase PilaNoAc::Desapilar() {
2 TBase tmp = datos->dato;
3 Nodo *ptmp = datos;
4 datos = datos->sigui;
5 delete ptmp;
6 return tmp;
7 }

es más lenta que


1 TBase PilaAc::Desapilar() {
2 return datos[cima--];
3 }

pero, en ninguno de los dos casos el tiempo de acceso depende del aumento del tamaño de la
pila. En la instrucción delete de la implementación No acotada sin embargo topamos con una
tı́pica “caja negra” muy dependiente del sistema compilador-y/o-sistema operativo, pero siempre
aportando un mayor número de instrucciones internas, eso sı́, independientes, de nuevo del tamaño
de nuestra pila.
En cuanto a las complejidades espaciales, la complejidad de la implementación No acotada es
lineal (O(N )), mientras que la no acotada es constante (C). Lo que ocurre es que esa constante
C es un valor mucho más alto que el coeficiente de N de la implementación No acotada. Dicho
de otra forma la implentación acotada requiere un espacio fijo, mucho mayor que el pequeño y
variable espacio requerido por los elementos que se van añadiendo a la implementación dinámica,
que además es utilizable en más diversas aplicaciones, por la adaptabilidad de su tamaño, etc.
Veremos que la implementación acotada es la más utilizada a bajo nivel, mientras la No
acotada se emplea más en lenguajes a alto nivel.

5.7. Aplicaciones de las pilas


Veremos tres aplicaciones muy importantes, una a alto nivel, el análisis de expresiones arit-
méticas, otra a bajo nivel, el paso de parámetros entre rutinas y una tercera, muy relacionada con
5.7 Aplicaciones de las pilas 15

la anterior pero que se implementa a alto nivel que es la eliminación de la recursividad por medio
de pilas.

5.7.1. Análisis de expresiones aritméticas


La interpretación de la sintaxis de los lenguajes de alto nivel pasa por una etapa denominada
“parsing” y requiere estructuras un poco más complejas que la pila. Sin embargo una fase de esta
interpretación es la conversión de expresiones escritas bajo la sintaxis infija (tipo 3 + 2 × 5) a otra
forma sin embargo mucho más fácil de evaluación, la forma postfija (como 2 5 × 3 + ).

a+b +ab ab+


infija prefija posfija

a * (b+c) *a+bc bc+a*

Figura 2: Las expresiones infijas recurren a los paréntesis para indicar el orden en que
se desea se hagan las operaciones.

Efectivamente todos hemos aprendido matemáticas utilizando una sintaxis llamada infija, por
situar los operadores entre los operandos. Todos sabemos como calcular 3+2×5. En las operaciones
infijas es necesario primero ver la expresión entera ya que tenemos que tener el cuenta la prioridad
de los operadores. En la expresión anterior entendemos que debe primero multiplicar 2 por 5 y, al
resultado, sumarle 3. En esta expresión no hace falta utilizar paréntesis porque el orden buscado
de las operaciones es el mismo que el de la prioridad implı́cita de los operadores. Sin embargo,
en la expresión 3 × (2 + 5) los paréntesis son inevitables, ya que debemos superar la prioridad
del signo × realizando antes el +. Ası́ pues las expresiones en formato infijo no se pueden evaluar
secuencialmente y requieren el uso de paréntesis.
Además de la notación infija existen otras dos, la prefija y la postfija. La prefija sitúa los
operadores antes que los operandos, por ejemplo + 3 × 2 5, mientras la postfija, lo hace al revés:5
25×3+.
Volviendo a nuestras expresiones en forma infija, vemos que el segundo ejemplo con paréntesis
se puede escribir en forma postfija como 2 5 + 3× (aunque también como: 3 2 5 + ×).
¿Cómo se leen estas expresiones postfijas?
Para evaluar una expresión postfija se empiezan tomando los operandos de izquierda a derecha
(2, y 5); cuando se topa con un operador (+), se hace actuar sobre los operandos leı́dos y el
resultado se toma como la expresión semievaluada en curso (7); ası́, se sigue hacia la derecha
siempre tomando ahora el siguiente operador/operando, etc. En nuestro ejemplo el operando 3 y,
después el operador × que por tanto actúa sobre los operando guardados hasta ahora, 7 y 3. El
resultado que nos pedı́an es 21.
Dos cosas fundamentales:

1. No se requieren nunca paréntesis

2. la evaluación puede hacerse secuencialmente de izquierda a derecha con tal de ir guardando


los operandos anteriores y tomándolos después en el orden inverso del de guardado
Pero nótese que esta forma de almacenamiento en la que se accede únicamente al último elemento
guardado en forma LIFO es precisamente la que nos ofrece la estructura pila.
Dado que cada vez que encontramos un operador, éste actúa sobre los operandos a la izquierda
más recientes, el uso de la pila parece adecuado. Por otro lado cada vez que un operador consume
5 Veremos que los tres tipos de presentaciones tienen una muy natural representación en forma de árboles binarios

en los que los nodos operadores tienen descendientes u operadores u operandos, y tenemos la representación lineal
prefija, infija o postfija, según recorramos esos árboles en forma “preorden”, “enorden” o “Posorden”, pero dejaremos
esto para el tema de árboles.
5.7 Aplicaciones de las pilas 16

(normalmente) dos operandos, éstos se extraen de la pila; posteriormente el resultado del operador
sobre los operandos se apila.
Esta eficiente forma de evaluación fue descubierta por el matemático polaco Lukasiewicz y
hoy dı́a tiene muchos adeptos (existen incluso calculadoras de bolsillo que la emplean pese a que
no es la más familiar). Muchos lenguajes de programación (FORTH, PostScript, . . . ) basan su
estructura de valuación directamente en la sintaxis postfija, de manera que en ellos, para calcular
el seno de 30, escribimos “30 sin”, etc. Además, los compiladores de todos los lenguajes dejan un
código máquina tipo postfijo.
A las expresiones aritméticas en forma postfijas las llamaremos expresiones “polacas”
Definición Una expresión polaca es una secuencia de operandos numéricos x, y, . . . (números)
(N ) y de operadores (por ejemplo binarios +, −, ×, /, Pow; que representaremos +, -, *, /, ^;

unarios: , sin, etc, que podemos representar q, s, etc.) en general n con n siendo normalmente
1, 2 ó 3 representando la aridad: número de operandos que requiere el operador. Estos operandos
y operadores para formar una expresión postfija o polaca habrán de estar ordenados de la siguiente
forma:
1. x ∈ N es (ya) una expresión polaca
2. si pi son 1 o más expresiones polacas entonces también lo es p1 . . . pn n
Ejemplos de expresiones polacas:
1 32
2 17 40 *
3 35 17 40 * +
4 35 17 - 40 * 9 5 4 - + *

Como ejercicio, evaluarlas y pasarlas a forma infija.

Algoritmo de evaluación Para evaluar una expresión s polaca bien formada se usan las dos
reglas siguientes que se aplican hasta que sólo queda un número en la expresión. El rastreo se hace
desde la izquierda, cada lectura se hace desde el punto en que se quedó en la última:
1. Analizar la expresión hacia la derecha hasta encontrar el primer operador si =
2. Aplicar los operadores a los operandos xi−2 y xi−1 , inmediantamente a su izquierda, o sea,
x1−2 xi−1 , (suponiendo un operador binario, si no, si fuera n-ario, a los n a su izquierda),
obteniendo de esta operación el resultado r, y reemplazar la secuencia xi−n . . . xi−1 de la
expresión por r

Uso de pilas en las expresiones polacas si nos servimos de una estructura de almacenamiento
temporal de tipo pila, podemos ir calculando los resultados intermedios y almacenándolos en la
pila conforme leemos le expresión polaca. Al tomar los operandos de las expresiones polacas vamos
“hacia atrás” tomando los operandos en el sentido contrario al de su lectura. Ası́, si conforme leemos
la expresión polaca hacemos el paso primero pero guardando los operandos en una pila, cuando
topemos con un operador, tan sólo tendremos que desapilar los últimos operandos necesarios para
el operador. Se trata pues de analizar la expresión hacia la derecha y con cada operando: apilar el
operando, con cada operador : desapilar tantos operandos como necesite éste operador y apilar en
su lugar el resultado de operarlos.
Como ejemplo, evaluar 1 2 5 + - 2 *:
125+−2∗ queda en la pila: 1
1
125+−2∗ queda en la pila: 2
1
2
125+−2∗ queda en la pila: 5
5.7 Aplicaciones de las pilas 17

1
125+−2∗ queda en la pila: 7
125+−2∗ queda en la pila: −6
−6
125+−2∗ queda en la pila: 2
125+−2∗ queda en la pila: −12
Si tenemos a la expresión polaca original escrita en una cadena de caracteres en la que cada
operando ocupa un carácter: podrı́amos evaluar esta expresión con:
1 TBase EvaluaPolish(char *s);
2 {
3 TPila p;

5 for (int long = strlen(s), int i = 0; i < long ; i++) {


6 int x = s[i];
7 if (x >= ’0’ && x<= ’9’)
8 p.Apilar(x - ’0’); // el número entero
9 else {
10 // suponemos operadores binarios todos
11 int x1 = p.Desapilar(), x2 = p.Desapilar();
12 switch (x) OF
13 case ’+’: p.Apilar(x2 + x1); break;
14 case ’-’: p.Apilar(x2 - x1); break;
15 case ’*’: p.Apilar(x2 * x1); break;
16 case ’/’: p.Apilar(x2 / x1); break;
17 };
18 };
19 return p.Desapilar();
20 }

5.7.2. Paso de parámetros


El paso de argumentos entre distintos procedimientos se implementa normalmente con pilas.
Cuando escribimos en un programa f(a, 3, b);, el compilador construye un “código objeto” que
contiene los pasos necesarios para saltar a la rutina etiquetada f, pero además, y antes de hacer ese
salto debe preparar los parámetros para que al empezar a actuar la rutina los tenga disponibles.
Para esto existen varias técnicas, pero la única que permite llamadas recursivas es la del uso de
una pila. Piénsese que igualmente una vez llegados a la rutina f, ella misma puede autollamarse
con nuevos parámetros (f(x,y,z);) y al volver de esa segunda llamada se deben restaurar los
valores de los parámetros iniciales.
Para conseguir el paso de parátros con posibilidad de recursividad en las llamadas los com-
piladores mantienen una pila en el código máquina en la que previamente al salto a la dirección
de la rutina a llamar se apilan los parámetros. Una vez apilados, se salta a la rutina. En la rutina
se hace la operación inversa cada vez que se la llama, esto es, nada más comenzar se deja un
código máquina que leen los parámetros que se hayan escrito en la misma pila. Si esta llamada se
vuelve a hacer, y el llamador es la misma rutina que se “autollama”, siempre se emplea el mismo
mecanismo de apilar antes los parámetros de la llamada, de manera que al reentrarse en la rutina
se leen ahora los últimos parámetros apilados, quedando los anteriores “ocultos” por éstos.
A la vuelta de una llamada el código máquina generado por los compiladores “limpia” la
pila eliminando los parámetros (Desapilar) que antes de la llamada hubo apilado. Ası́, después de
completarse la llamada, la pila de parámetros debe quedar exactamente igual que estaba.

5.7.3. Eliminación de la recursividad con pilas


Los algoritmos que tan sólo enpleen condicionales (Si-Entonces,. . . ) y bucles (Mientras,. . . )
pueden escribirse en forma recursiva. Esto sin embargo no siempre mejora la legibilidad del al-
goritmo. Pero, cuando se desea literalmente eliminar la recursión existente podemos recurrir al
siguiente mecanismo:
5.7 Aplicaciones de las pilas 18

1. Al principio del procedimiento (o función) se inserta código que declare un PILA (llamado
pila de recursión) y lo inicialize a vacı́o. La mayorı́a de las veces el mismo PILA podrá ser
usado para guradar parámetros, variables locales y una dirección de vuelta para cada llamada
recursiva, pero pueden usarse PILAs independientes.
2. Se etiqueta 1 a la primera sentencia ejecutable.
3. Si el procedimiento es una función, entonces, todas las apariciones de return convertirlas en
los pasos 9, 10 y 11 y en una asignación del valor a devolver a una variable z del mismo tipo
que la función
Con cada llamada recursiva, hacer los siguiente:
4. Guardar los valores de todos los parámetros por copia (sin &) en la pila. El Cima de pila es
global para todo el algoritmo.
5. Crear una etiqueta secuencialmente conforme nos encontramos con llamadas recursivas, sea
la etiqueta la i-sima. Guardar i en la pila. El valor guardado en la pila será usado como
dirección de vuelta.
6. Evaluar los argumentos correspondientes (sin &) y asignar los resultados a los parámetros
formales inicialmente recibidos.
7. Insertar un salto incondicional (goto) al comienzo del procedimiento (ya etiquetado)
8. Si estamos tratando con un procedimiento, añadir la etiqueta creada en 5 a la instrucción
inmediatamente siguiente al salto incondicional. Si esta sentencia ya tuviese una etiqueta,
cambiarla y todas sus referencias por la calculada en 5. Si se trata de una función, continuar
el salto incondicional con el código en que se asigna z en vez del supuesto valor devuelto por
la función. Etiquetar esa sentencia con la etiqueta calculada en 5
Con esto ya hemos eliminado todas las llamadas recursivas. Necesitamos ahora preceder la salida
final con:
9. Si la pila de recursión está vacı́a, usar el valor de z como valor de return, si se trata de una
función, si no, hacer return.
10. Si la pila no está vacı́a, restaurar el valor de todos los parámetros por valor y de todas las
variables locales que no sean parámetros por referencia. Estos valores están en la cima de la
pila. Usar el valor de vuelta del tope de la pila y ejecutar un salto a esa etiqueta. Esto puede
hacerse usando una instrucción select (switch).
11. Si existiese una etique al final del código se mueve a la primera lı́nea del código para 9 y 10
Se deja como ejercicio el comprobar este algoritmo para el algortimo recursivo del cálculo del
factorial de un número y de el n-simo número de Fibonacci.

5.7.4. Comprobación de paréntesis


Supongamos la expresión:

(x × (y + z × (u − v)))/(y − z)

parece que está bien parentizada. Usualmente esto lo comprobamos contando:

( x× ( y+z× ( u−v ) 2 ) ) / ( y−z )


|{z} |{z} |{z} |{z} |{z} |{z} |{z} |{z}
1 2 3 2 1 0 1 0

Pero la expresión:

( x× ( y+z× ) u−v ) ) ( / ( y−z )


|{z} |{z} |{z} |{z} |{z} |{z} |{z} |{z}
1 2 1 0 −1 0 1 0
5.7 Aplicaciones de las pilas 19

da también resultado neto 0 y no está bien parentizada. En este caso bastarı́a con considerar que
el contador no debe hacerse nunca negativo. Sin embargo la expresión:

(x × {y + z × (u} − v)))/(y − z)

está mal agrupada porque se ha abierto ha cerrado un subgrupo parentizao con {} y sin haber
cerrado un subgrupo interior a él, se ha cerrado el exterior.
Para comprobar este tipo de cuestiones no hay nada mejor que una pila. Si apilamos los
paréntesis de apertura ‘(’, ‘[’, ‘{’, ‘<’, ‘¡’, ‘¿’, “’, ‘“’, etcétera y cuando nos encontremos uno de
cierre vemos que en la cima de la pila está su correspondiente pareja, es que todo va bien. Al final
la pila debe quedar vacı́a.
Se deja al estudiante la comprobación de esto construyendo una rutina de utilidad que ins-
tanciarı́a una pila de caracteres y recibiendo una cadena de caracteres con paréntesis, devolverı́a
cierto o falso según estuviese bien o mal parentizada.

5.7.5. Pilas.Ejercicios
. 1 Implementar el TDA pila mediante una clase que contenga los atributos: un array (un puntero
al mismo; el tamaño por definir) y el ı́ndice entero dónde está el último, el tamaño total del
array según se pasa al constructor en la creación del objeto, 100 por defecto.

. 2 Implementar el TDA pila mediante una clase que contenga como atributo una lista de nodos
dinámicamente enlazados. Para encolar, añadir por el principio, para desencolar, borrar el
primero. Ver las Figuras 3, 4 y 5.

Figura 3: Estructura para la implementación no acotada de una pila p.

1 2
nuevo nuevo

p p

Figura 4: Mecanismo de Apilar en la forma no acotada de pila.

. 3 Utilizando la clase pila, diseña un algoritmo que determine si una cadena de caracteres de
entrada es de la forma
xx̂
dónde x es una cadena que consiste en caracteres arbitrarios y x̂ es lexicográficamente la
inversa de x. Por ejemplo, si x = αβγδ, entonces x̂ = δγβα.
5.7 Aplicaciones de las pilas 20

temp 1 temp 2

p p

Figura 5: Mecanismo de Desapilar en la forma no acotada de pila.

. 4 Hemos visto como se puede evaluar una expresión polaca de operandos simples. Desarrollar
un algoritmo que construya dos pilas, una con los operandos (la ya conocida) y otra con los
operadores. La interfaz serı́a:
void CompilarPolaca(const char s[], PilaChar& prandos, PilaChar& pdores);

Nótese que la pila de operandos es de caracteres de manera que puede contener sı́mbolos
(a, b, c . . . ) y no números. De esta forma:
int EvalCPolaca(int valores[], const PilaChar& prandos, const PilaChar& pdores)

puede recibir en su primer parámetro los valores actuales de las variables simbolizadas por
a, b, . . . .
Cuando se encuentre un sı́mbolo x = b en la ‘prandos’ su valor actual será valores[x-’a’];
. 5 Analizar el siguiente código que convierte una expresión infija en posfija:
1 void infijaAposfija (char infija[], char posfija[])
2 {
3 int iinfija=0, iposfija=0;
4 PilaNoAc pila;

6 while (infija[iinfija] != ’\0’) {


7 if (infija[iinfija] == ’(’) {
8 pila.Apilar(infija[iinfija]);
9 } else {
10 if (infija[iinfija] == ’)’) {
11 while (!pila.EstaVacia() && pila.Cima() != ’(’) {
12 posfija[iposfija++] = pila.Desapilar();
13 posfija[iposfija++]= ’ ’;
14 }
15 if (!pila.EstaVacia())
16 pila.Desapilar();
17 } else {
18 if (!ispunct(infija[iinfija])) {
19 posfija[iposfija++]=infija[iinfija];
20 posfija[iposfija++]= ’ ’;
21 } else {
22 while (!pila.EstaVacia() &&
23 prioridad(infija[iinfija]) < prioridad(pila.Cima())) {
24 if (pila.Cima() != ’(’) {
25 posfija[iposfija++]=pila.Cima();
26 posfija[iposfija++]= ’ ’;
27 }
28 pila.Desapilar();
29 }
30 pila.Apilar(infija[iinfija]);
31 }
32 }
33 }
34 iinfija++;
5.8 Colas 21

35 }
36 while (!pila.EstaVacia())
37 posfija[iposfija++]=pila.Desapilar();
38 posfija[iposfija]=’\0’;
39 }

41 int prioridad (char c)


42 {
43 switch (c){
44 case ’^’: return 4;
45 case ’*’: return 3;
46 case ’/’: return 3;
47 case ’+’: return 2;
48 case ’-’: return 1;
49 }
50 return 0;
51 }

5.8. Colas
Las colas, al igual que las pilas, aparecen espontáneamente de la solución de muchos problemas
informáticos. Aunque en general no tanto con problemas algorı́tmicos como con problemas de tipo
“productor-consumidor”, esto es una parte del sistema produce algo que otra consume a un ritmo
diferente, normalmente más lento.

productor consumidor

Figura 6: El productor produce a un ritmo diferente del ritmo al que consume el con-
sumidor

Una cola o queue es un almacén Q = (a1 , . . . , an ), ordenado según se llega, y dónde los
elementos salen “por un lado” (Top, Frente, Primero o Cabeza) mientras que se añaden “por el
otro” (Bottom, Rear, Último o Final ).
Es un almacén de datos de tipo FIFO (First In First Out): el primer elemento que entra es
el primero en salir.
Se respeta el orden de llegada.

an -Cima
an−1
..
.
a2
a1 Prime a1 a2 . . . an−1 an Ulti
Pila Cola

La cola es una forma de almacenar los datos muy común cuando el consumidor de los mismos no
los puede atender tan rápido como los prepara el productor. Es muy útil en sistemas operativos
multitarea, donde una tarea produce información a un ritmo a veces mayor que la tarea que la
absorbe. En estos casos, se almacenan los elementos conforme llegan de los productores y los
5.9 Especificación del TDA cola 22

retiran los consumidores, de una cola; de esta forma es atendido cada proceso según el orden de
llegada. Esto se puede hacer ignorando las prioridades de cada proceso o, si se tienen en cuenta,
creando una cola por cada grado de prioridad con tipos de cola sencilla.
Mientras que las pilas se ven más frecuentemente asociadas a la ejecución de algunos algorit-
mos, las colas se usan como almacenes de datos.
Al igual que la pila, una cola es un conjunto totalmente ordenado en el tiempo, en el que
se añaden y eliminan elementos. Ahora los elementos son accesibles por dos puntos, el Primero
o Frente de dónde se borran y extraen los elementos y el Último o Final por dónde se añaden.
Igual que con las pilas, se trata de una ordenación temporal, en cuanto al orden de la inserción:
un elemento está antes si se añadió antes. A ver qué

5.9. Especificación del TDA cola


5.9.1. Especificación formal del TDA cola
Sea la cola q = (a1 , a2 , . . . , an ) con a1 el primer elemento insertado y an el último:

Nombre: TCola (TBase)

Conjuntos:
Q: conjunto de las colas
I: conjunto de items de tipo = base
N: conjunto de los números naturales {0, 1, . . . }
E: Excepciones (colaVacı́a)

Especificaciones Sintácticas:
Crear: →Q
Destruir: Q→
Encolar: Q × I →Q
Desencolar: Q → (Q × I) ∪ E
Primero: Q→I ∪ E
Ultimo: Q→I ∪ E
NElementos: Q→N

Especificaciones Constructivas:
TCola q();

Pre ::= Ninguna
Post ::= NElementos(q 0 ) = 0 ∨ q 0 = ( )
~TCola q();

Pre ::= q ∈ Q
Post ::= q 6∈ Q
q.Encolar(x TBase);

Pre ::= q ∈ Q
Post ::= an = x ∧ NElementos(q 0 ) = NElementos(q) + 1
TBase q.Desencolar();

Pre ::= qn ∈ Q ∧ q.NElementos() > 0
Post ::= q 0 6= q ∧ NElementos(q 0 ) = NElementos(q) − 1
Ret ::= a1 / q0 .Encolar(a1 )
TBase q.Primero();

Pre ::= q ∈ Q ∧ q.NElementos() > 0
Post ::= q 0 = q
Ret ::= a1 / q0 .Encolar(a1 )
5.9 Especificación del TDA cola 23

TBase q.Ultimo();

Pre ::= q ∈ Q ∧ q.NElementos() > 0
Post ::= q 0 = q
Ret ::= an / qn−1 .Encolar(an )
N q.NElementos();

Pre ::= q ∈ Q
Post ::= q = q 0
Ret ::= n

5.9.2. Constructores, Selectores, Iteradores




 Crear
Encolar

Constructores:

 Desencolar
Destruir


 P rimero
Selectores: U ltimo
N Elementos

Iteradores: no definidos

5.9.3. Excepciones
En la especificación abstracta del TAD cola sólo existe la posible excepción genérica colaVacı́a,
que se puede dar con: 
 Desencolar
colaVacı́a : Primero
Ultimo

dependiendo de las implementaciones concretas podrán aparecer otras excepciones.

5.9.4. Formas de Implementación


La implementación del TDA cola es algo más complicada que la de las pilas porque se requieren
dos referencias a la estructura el Primero y el Último.

Acotadas: (implementaciones hechas sobre arrays, cursores, etc.)


No Acotadas (implementaciones hechas mediante punteros, ficheros, etc)

Acotada mediante array Tenemos una implementación acotada del tipo d[N] y necesitamos
dos variables prim y ulti para acceder a los dos extremos. Convendremos en que ult indica
donde está actualmente el último que ha entrado (q.Ultimo()) y prim donde está q.Primero()
el primero que entró y el primero que va a salir. Para leer esos extremos bastará indicar el ı́ndice
correspondiente. Para desencolar se leerá en prim y se aumentará prim para preparar el acceso al
antes era el segundo. Para encolar se aumentará ulti para posicionarnos en un sitio vacante aún
no usado y se escribirá allı́ el nuevo. En otras palabras, tendrı́amos:
1 ultimo == d[ulti]
2 primero == d[prim]
3 encolar -> d[++ulti] = x
4 desencolar -> d[prim++]
5.9 Especificación del TDA cola 24

Tenemos pues una persecución del primero hacia el último

prim ulti d[0] d[1] d[2] d[3] d[4] d[5] d[6] Paso
0 -1 Crear
0 0 a0 Encolar()
0 1 a0 a1 Encolar()
0 2 a0 a1 a2 Encolar()
1 2 a1 a2 Desencolar()
1 3 a1 a2 a3 Encolar()
2 3 a2 a3 Desencolar()

y además, aunque halla pocos datos estos van moviéndose como una mancha hacia lo alto del array.
Naturalmente, si no le ponemos remedio, aunque haya uno o pocos elementos, el ı́ndice dónde hay
que escribir el siguiente último, sobrepasará N. Nótese la inicialización a 0 y −1: cuando creamos
la pila ponemos ult=-1 con lo cual el pre incremento antes de escribir colocará al nuevo encolado
en el ı́ndice 0 del array6 .
Pero esta implementación va dejando espacio inutilizado desde la cima que se va eliminando y
tiene un periodo de utilizabilidad muy pequeño: aunque vayamos extrayendo elementos, los datos
van corriendo hacia el final del array hasta tropezar con su lı́mite quedando seguramente el primer
segmento del array aún vacı́o. Ası́ pues esta implementación NO nos interesa. Veamos la forma de
aprovechar siempre todo el array para los datos.
Para hacer esto definiremos un array circular, esto es, el siguiente del final es el primero.
Manteniendo la misma definición del tipo, hemos de preocuparnos en la implementación de los
procedimientos de que cuando vayamos a insertar por encima del final fı́sico del array, insertemos,
si hay sitio, en su comienzo fı́sico, reciclando ası́ esas posiciones vacı́as.
La solución está, en esta forma de implementar las colas con arrays, en utilizar un indexado
del array circular. De manera que si al incrementar el ı́ndice sobrepasamos el máximo, volvemos
el ı́ndice a cero. Ası́, en vez de sencillamente:
++ulti

tenemos que hacer


ulti = (ulti + 1) % N

(¡aritmética modular!), tanto para ulti como para prim. Ver Figura 7.

PRIMERO

PRIMERO ÚLTIMO

ÚLTIMO

Figura 7: Movimiento persecutorio aparente de la cola hacia la cabeza que se dá al


añadir los elementos por ulti y consumirlos por prim.

El siguiente problema es cómo saber si la cola está totalmente llena o totalmente vacı́a.
¿Cuántos elementos hay en la cola? La primera respuesta serı́a: ult-prim+1, pero esto sólo serı́a
válido en un estado inicial (antes de empezar a girar la cola) antes de dar la vuelta ult. Ası́ pues
habrı́a que considerar (ver Fig. 8):
6 Sin embargo ponemos 0 en el ı́ndice del primer elemento aún cuando no hay ninguno, esto es conveniente para

no tener que aumentar artificiosamente prim tras el primer añadido a la cola.


5.9 Especificación del TDA cola 25

0 prim ulti N-1


Quedan

ulti–prim+1 ulti-prim+1

ulti prim
ulti+1 N–1–prim+1 N–prim+ulti+1

ulti prim
recién llenada

recién vaciada
ulti prim
?

Figura 8: Incertidumbre respecto a la parte ocupada en un array circular.

1 si ulti >= prim entonces


2 ulti - prim + 1
3 sino
4 (N - prim) + (ulti + 1)

O sea que justo cuando extraigamos el último elemento (supiendo el ı́ndice prim hasta ponerse por
delante de ulti) estaremos en la misma situación que cuando añadamos completando la capacidad
un elemento, en cuyo caso será ulti el que se pondrá justo detrás de prim. Por lo tanto habrá un
caso en el que no sabremos si el array está lleno del todo o vacı́o totalmente.
Para evitar este estado confuso podrı́amos hacer una de estas dos cosas:

1. Dejar una celda vacı́a antes de llegar a prim. Cuando la cola esté vacı́a, la distancia de ulti
a prim será de uno, pero cuando al la distancia sea de dos, estará llena.

2. Mantener un atributo extra (nelem) que nos guarde cuántos elementos hay tras cada enco-
lado/desencolado de la cola sin recurrir a cálculos entre ulti y prim.

Cuando se estuviese usando la cola, el control del error por sobrepasar la capacidad de la cola
habrı́a que hacerlo antes de llamar al método de encolado, para no caer en un desbordamiento de
la capacidad.

Esto lleva a que en las implementaciones acotadas parezca necesario el conocer a priori
antes de encolar si vamos a poder meter el elemento o a posteriori si el proceso se pudo
hacer; lo que suele resolverse añadiendo un método bool EstáLlena(); sin embargo
no lo creemos aconsejable ya que esto crea una dependencia del uso respecto de la
implementación muy poco en la filosofı́a de los TADs. La solución vuelve a estar,
como tantas otras veces, en una adecuada elección de la implementación (acotada y
con qué lı́mites o no acotada) antes de elegirla.

El saber si una cola está vacı́a habrı́a que considerlo antes del desencolado (si queremos controlar
este posible error) como de Primero() y de Ultimo() y se debe controlar siempre mediante la
respuesta del método NElementos().
Se deja al alumno la realización de esta forma de implementación acotada. Ver el ejercicio 7.

Dinámica La implementación no acotada o dinámica de cola es semejante en principio a la


de la pila. Tiene incluso más sentido aquı́ que allı́ ya que la cola sı́ que suele usarse más como
almacenamiento de capacidad no previsible, mientras que la pila no. Sin embargo mientras que
la implementación no acotada de la pila es extremadamente simple, la de la cola sólo lo es si se
emplea un truco:

hacer una lista de nodos circular y considerar el nodo último aquél al que apuntamos
con la referencia exterior. Los enlaces se pondrán además al revés que hasta ahora:
“mirando hacia atrás”.
5.9 Especificación del TDA cola 26

q ulti prim

Figura 9: Truco para enlazar los nodos permitiendo un encolado y desencolado directos.

ulti (1)

(3) (2)

q prim

Figura 10: Encolado: (1) el nuevo nodo apunta al siguiente del q; (2) el q apunta al
nuevo y actualizamos el q.

La Figura 9 muestra el aspecto de los enlaces, mientras que las figuras 10 y 11 muestran las
operaciones de encolado (tres pasos) y desencolado (un paso).
Hay que tener cuidado, sin embargo, como es usual con los casos extremos de extraer el último
que queda y en el encolado del primer nodo. Ver ejercicio 6.

5.9.5. Colas de prioridad


Cuando queremos mantener algún tipo de orden impuesto sobre el orden de llegada en la cola
debemos añadir un indicador ordinal a cada elemento al encolar. La prioridad debe ser un valor
ordinal de rango pequeño (normalmente menor que 256, usualmente {alta, media, baja}, etc.).
Al encolar con una prioridad el elemento se adelanta a todos los que tengan menor prioridad
que él y queda detrás de todos los que tengan la misma o mayor.
Sin embargo, en algún raro caso, también puede ser adecuado el encolar normalmente con la
prioridad asociada y al desencolar recorrer la cola en busca del elemento de mayor prioridad.
La interfaz de la cola de prioridad (TColaP) puede ser idéntica a la de la cola normal. En ese
caso se supondrı́a que los elementos TBase llevan internamente un campo reconocible tipo x.p que
se puede comparar, es de tipo ordinal. Ası́ la implementación genérica del TAD podrı́a siempre
mantener en su código expresiones del tipo:
1 if (x.p > datos->sigui->p) { // encolar antes del primero
2 nuevo->sigui = datos->sigui;
3 datos->sigui = nuevo;
4 }

Sin embargo parece más fácil mantener un parámetro adicional para indicar la prioridad con
la que entra el elemento (independiente ası́ su contenido de la prioridad)
void Encolar(const TBase x, const int p);

(1)

q prim
ulti

Figura 11: Desencolado: (1) el nodo q apunta al siguiente de su siguiente.


5.9 Especificación del TDA cola 27

En este caso hay que cuidar la devolución en TBase Desencolar(int &p) dónde serı́a necesario
obtener no sólo el valor encolado sino también su prioridad, por ejemplo, como una parámetro por
referencia. No siempre es necesario recuperar la prioridad, que parece más un medio de acomoda-
miento de la información, pero a nivel estructural, si no se recupera la prioridad, no podrı́amos,
por ejemplo, copiar una cola de prioridad, duplicarla.
Las colas de prioridad son almacenes, por lo que parece, en general más conveniente la expo-
sición del selector NElementos() que la del EstaVacia(), más propia en el caso de la pila.
En la implementación de colas de prioridad hay que cuidar (en el segundo caso de paso
explı́cito del parámetro prioridad) mantener el campo prioridad en cada nodo o celdilla junto a la
información TBase neta.
Si se implementa en forma no acotada, se puede mantener la misma estructura de lista de
nodos simples que se usó en las colas normales, pero ahora hay que apartar dos casos (además
del de está vacı́a que también es un caso aparte), el del caso en el que el elemento entre después
del último, como en una cola normal, por llevar menor o igual prioridad que el último de la cola
(p <= datos->p) y el caso de que el elemento entre antes que el primero por tener más prioridad que
el primero (p > datos->sigui->p). En el caso que queda, si no se ha dado ninguno de los anteriores,
se deberá recorrer la cola desde el primero hacia al último hasta que se encuentre un elemento de
menor prioridad que la que traemos.
Se deja como ejercicio para el estudiante la implementación de las colas de prioridad mediante
una lista de nodos dinámicamente enlazados (8).
Otra técnica de implementación, rápida por lo sencilla, es la de mantener un array de colas
normales, de manera que en cada celda del array el ı́ndice del array indica la prioridad de la
correspondiente cola. Ver el ejercicio 9.

5.9.6. Colas.Ejercicios
. 6 Implementar el TDA cola en forma no acotada como se indica en la Figura 12. ¿Qué ocurrirı́a

último primero

produce consume

Figura 12: Implementación del TDA cola en forma no acotada mediante una lista
dinámica de nodos en la que cada nuevo nodo apunta “hacia atrás”. En
esta representación, el último que entra es apuntado por el anterior último
y él mismo apunta al primero a salir.

si el puntero q apuntase al primer elemento en vez de al último de la cola? ¿Qué ocurrirı́a si


los nodos apuntasen (como en una cola de cine, por ejemplo) al que llegó antes?
. 7 Implementar el TDA cola mediante una clase de atributos: un array (un puntero al mismo;
el tamaño por definir), el ı́ndice entero dónde está el primero, el ı́ndice entero dónde se
introdujo el último, el tamaño total del array según se pasa al constructor en la creación del
objeto, 100 por defecto; y, finalmente, (a) el tamaño actual lleno de la cola o bien (b)ñada
más y considerar lleno el array justo cuando quede sólo un elemento por ocupar, de manera
que no se pierda el control de lleno o vacı́o.
. 8 Implementar una cola de prioridad mediante una lista de nodos dinámicamente enlazados.
. 9 Implementar una cola de prioridad mediante un array de N Colas normales. Siendo N el
número de prioridades existente, que será pasado al constructor de la clase teniendo un valor
por defecto de 10.
5.10 Listas 28

5.10. Listas
Definición Una lista es, o el vacı́o (notado por ‘()’), o una sucesión finita de elementos del
mismo tipo, notada por (a1 , a2 , . . . , an ) de la que se pueden tanto leer, como borrar o insertar los
elementos indicando su posición. Al número de elementos de la lista n lo llamaremos “longitud de
la lista”.

5.10.1. Especificación formal del TDA lista


Sea la lista l = (a1 , a2 , . . . , an ):

Nombre: TLista (TBase)

Conjuntos:
L: conjunto de las listas
I: conjunto de items de tipo = base
N: números naturales
E: Excepciones (Fuera de rango)

Especificaciones Sintácticas:
Crear: →L
Destruir: L→
Longitud: L→N
Elemento: L × N →I ∪ E
Reescribir: L × N × I →L ∪ E
Insertar: L × N × I →L ∪ E
Borrar: L × N →L ∪ E

Especificaciones Constructivas:
TLista l();
Pre ::= Ninguna
Post ::= Longitud(l0 ) = 0
~TLista
 l();
Pre ::= l ∈ L
Post ::= l 6∈ L
l.Longitud();

Pre ::= l ∈ L
Post ::= l0 = l
Ret ::= n
l.Elemento(N p);

Pre ::= l ∈ L ∧ 1 <= p <= n
Post ::= l0 = l
Ret ::= ap
l.Reescribir(N p, TBase x);

Pre ::= l ∈ L ∧ 1 <= p <= n
Post ::= n0 = n ∧ a0p = x
l.Insertar(N p, TBase x);

Pre ::= l ∈ L ∧ 1 <= p <= n + 1
Post ::= n0 = n + 1 ∧ a0k = ak ∀k < p; a0p = x; a0m = am−1 ∀m > p
l.Borrar(N p);

Pre ::= l ∈ L ∧ 1 <= p <= n
Post ::= n0 = n − 1 ∧ a0k = ak ∀k < p; a0m = am+1 ∀m ≥ p
5.10 Listas 29

5.10.2. Interfaz e implementaciones


Podemos resumir en la interfaz:

INTERFAZ CLASE TLista


TIPOS
// Definición del tipo TBase
METODOS
Crear()
Destruir();
N Longitud();
TBase Elemento(E N p)
Reescribir(E N p, E TBase x)
Insertar(E N p, E TBase x)
Borrar(E N p)
FIN TLista

5.10.3. Implementaciones acotada


Al igual que con otros TDAs este tipo de implementación puede ser muy adecuada si de
antemano se sabe algo del total de elementos que se va a manejar, y, en el caso de las listas,
también es importante que el número de accesos de lectura/reescritura sea muy superior al de
inserción/borrado, en otro caso no es conveniente, ya que para valores no pequeños de n (el tamaño)
el tener que correr los elementos “hacia arriba” (en la inserción) o “hacia abajo” (en el borrado)
no es nada eficiente. Sin embargo, cuando se puede usar es, con diferencia, la implementación más
eficiente en el acceso a los elementos.
Si utilizamos clases de C++ para la implementación del TDA lista acotada podemos explotar
el hecho de que en la propia creación se puede indicar el total de elementos que vamos a querer
tener para el objeto que necesitamos. Aparte de este tamaño (que también habrı́a que guardar
como atributo), no es necesario retener más atributos que los propios datos, que en este caso,
serı́a un sencillo array (un puntero al bloque-array con los datos de tipo base) que después sólo
tendrı́amos que indexar para gestionar en él los elementos.

Cursores Una implementación acotada muy importate es la de los bloques de memoria con
cursores.
La lista implementada mediante cursores es interesante por dos motivos:

es sólo algo menos eficiente que el array en su acceso, pero lo es igualmente en el borrado e
inserción, pero además

puede ser manipulada como un todo de forma que todo el bloque de cursores puede, por
ejemplo ser copiado como bloque de memoria sin necesidad de pedir bloques pequeños de
memoria que se localizarı́an en distintos sitios. Ası́mismo, puede, por ejemplo, ser guardada
o leı́da directamente como un fichero7 .

Muchos lenguajes carecen del tipo de datos de bajo nivel puntero, esto es, son incapaces de re-
ferenciar mediante variables de programa la dirección fı́sica en tiempo de ejecución de los datos.
Ejemplos de tales son FORTRAN, o COBOL, o Java, por nombrar los más conocidos. Es pues
inevitable en estos casos tratar de conseguir implementaciones eficientes de las listas usando el
mecanismo mejor de los cursores. Con los cursores lo que realmente se hace es implementar un
mecanismo semejante al de new delete que tiene ya el sistema, pero desarrollados por nosotros
7 si se utilizan punteros, el almacenamiento y posterior recuperación de la información en un fichero ha de hacerse

secuencialmente elemento a elemento ya que NO se pueden guardar los punteros de memoria interna en un fichero.
Muchos sistemas almacenan diversos tipos de estructuras más o menos complejas mediante cursores debido a que
los cursores son arrays de tamaño fijo en los que los elementos se acceden mediante ı́ndices numéricos (offsets) y,
por lo tanto son manipulables como un todo con gran eficiencia.
5.10 Listas 30

en una parcela mucho más controlada de la memoria, un bloque nuestro. Mediante los cursores se
dan de alta y de baja celdillas dentro de aquél bloque de memoria.
Pues bien, para dar de alta y de baja celdillas dentro de nuestra memoria, necesitaremos dos
procedimientos internos nuestros new y dispose que desarrollaremos dentro de la implementación
de la lista. El primero rastreará nuestra memoria y buscará una celdilla libre que ofrecer al
solicitante (dándola a la vez de baja de las celdillas libres). El segundo procedimiento delete
hará lo contrario: liberará, dará de alta como libre, la celdilla que se le indique.
Para poder gestionar los cursores es conveniente una estructura semejante a:
1 int maxelem;
2 struct Celdilla {
3 TBase elemento;
4 int sigui;
5 };
6 struct Bloque {
7 int primero, primerVacio, longitud;
8 Celdilla datos[maxelem]; // ó *datos inicializando antes
9 };

de manera que en primerVacio se guarda la posición de la primera celdilla fı́sica del array datos
que está disponible para ser reutilizada. Ahora bien, cada celdilla referencia a su vez a una siguiente
celdilla mediante sigui, de esta forma las celdillas vacı́as quedan encadenadas unas a otras desde
la primero, como una cadena de latas vacı́as. Al construir inicialmente nuestro objeto TLista,
deberemos poner todos los elementos a vacı́o. Para ello enlazamos a cada uno con el siguiente
(excepto el último, que enlaza con −1) y ponemos primerVacio apuntando al primero (0). Y
ponemos también entonces primero a −1 (nada). Ver Figura 13.

primero lUltimaVisita
0 1 2 3 4 5

primero = 1
primVacio = 2 -1 3 5 0 -1 4
lUltimaVisita = 3
posUltimaVisita = 2 primVacio

Figura 13: Implementación en memoria interna con cursores del TDA lista.

Se dejan como ejercicios las implementaciones del TDA lista como array y cursores.

5.10.4. Implementación no acotada


Es muy frecuente ya que las listas sean repositorios de datos. Especialmente útil cuando no se
sabe nada de la cantidad de ellos con la que se trabajará ni la frecuencia de inserciones/borrados
que serán necesarios.
La implementación más importante de lista en forma no acotada es la de los bloques de
memoria dinámicamente enlazados en la memoria principal. Ver Figura 14

lUltimaVisita

Figura 14: Implementación simple de listas en memoria interna en forma no acotada.


5.10 Listas 31

Se pueden tratar como listas también a los ficheros que tendrı́an ası́ caracterı́sticas muy
interesantes en cuanto a su capacidad y persistencia, pero en estos casos será necesaria una imple-
mentación con cursores en la que el bloque de celdillas será el fichero completo. Una ventaja extra
de los ficheros es que, al contrario de los cursores en memoria interna, no tienen por qué estar
acotados.
Esta implementación, aunque es muy adecuada para frecuentes inserciones/borrados y para
cuando se quiere liberar el máximo tamaño, tiene el grave problema de que si la lista crece mucho,
los accesos a los elementos se ralentizan, en particular, por ejemplo, los añadidos. Nótese que cada
vez que se quiere acceder a un elemento en la posición p hay que recorrer con un bucle del tipo:
1 tmp = l;
2 while (tmp != 0 && i < p) {
3 tmp = tmp->sigui;
4 ++i;
5 };
6 // usar *tmp

o, si no se necesita verificar la posibilidad de tener un valor de p fuera de rango:


1 for (tmp=l, i=1; i < p; i++, tmp=tmp->sigui)
2 ;
3 // usar *tmp

que conforme p aumente es más lento. Para insertar o borrar nos debemos detener en el nodo
anterior a p.

Optimizaciones de la implementación no acotada Para evitar el tener que recorrer toda


la lista cada vez que se quiere ver un elemento, y de nuevo, para el siguiente, se trata de guardar
la referencia de “por dónde ı́bamos” la última vez. Esto es útil especialmente en los recorridos
secuenciales, que son frecuentes en las listas. Cuando se solicita el elemento p y después el elemento
p+1 tratamos de evitar volver a recorrer los p elementos anteriores. Para ello pensemos en la manera
tı́pica de leer una libro: poner una marca de “por dónde vamos”. Esto se puede hacer guardando
no sólo entre los atributos del tipo el puntero al comienzo, sino también el puntero a la última
visita:
1 struct nodo {
2 dato;
3 nodo *sigui;
4 };
5 nodo *datos;
6 nodo *lUltimaVisita; // enlace al nodo que se visitó la última vez

Claro, que si sólo recordamos el enlace a último nodo visitado, no servirá de mucho excepto que
nuestro único método de acceso sea “siguiente” (secuenciales) cosa que no es ası́. Los accesos son
a posiciones. Ası́ tendrı́amos que guardar no sólo ese enlace, sino también la posición en la que
éste nodo está.
1 struct nodo {
2 dato;
3 nodo *sigui;
4 };
5 nodo *datos;
6 nodo *lUltimaVisita;
7 int posUltimaVisita; // número de la posición de la última visita

Esta primera optimización, como hemos dicho, afecta enormemente los recorridos secuenciales del
tipo
1 for (n=l.Longitud(), i=1; i <= n; ++i)
2 // procesa l.Elemento(i);

Ver la Figura 15.


5.10 Listas 32

lUltimaVisita
posUltimaVisita = 3

Figura 15: Optimización simple de la implementación de lista con recuerdo de la última


visita.

Ahora, bien, ya animados a optimizar, ya que esta primera optimización ha sido tan eficiente
y fácil, quisiéramos poder mejorar no sólo los accesos secuenciales. Con este método los accesos a
elementos en orden creciente son más rápidos, pero, los accesos a nodos anteriores no mejoran en
nada. En otras palabras, un acceso inverso, hacia atrás, no sólo no mejora nada sino que al tener
que estar actualizando nuestras marcas, resulta aún más penoso.
Solución: tener enlaces no sólo hacia adelante, sino también hacia atrás. Los nodos tendrı́an
un enlace sigui también un enlace ante. Esto permitirá varias cosas:
mejora los recorridos inversos
mejora los accesos a puntos intermedios ya que se elije el comienzo del recorrido de entre
tres puntos: el principio de la lista, el punto de la última visita y el final de la misma, lo que,
en promedio, divide por cuatro los recorridos. Ver Figura 16.
Simplifica la lógica de la inserción y borrado al poderse hacer todo desde el mismo punto
afectado, aunque aumenta el número de actualizaciones ya que intervienen más punteros.
Esto simplificación se nota sobre todo en que bastará con un procedimiento que devuelva el
nodo p-ésimo para todas las operaciones.

A B C D

lUltimaVisita
posUltimaVisita = 3

Figura 16: Segunda y más completa optimización de la implementación de lista con


recuerdo de última visita y doble enlace para facilitar los recorridos.

Se dejan como ejercicios las implementaciones del TDA lista en forma no acotada y sus
optimizaciones.

5.10.5. Listas.Ejercicios
. 10 Desarrollar la interfaz del TAD TLista posicional. Hacer la implementación mediante
1. arrays,
2. cursores
3. lista de nodos dinámicamente enlazados simple
4. primera optimización de la lista de nodos
5. segunda optimización de la lista de nodos
5.10 Listas 33

. 11 Desarrollar un TAD polinomio:


1 TAD POLINOMIO(TBase)
2 INTERFAZ
3 Crear // polinomio 0
4 Crear(q) // polinomio por copia de otro
5 Destruir
6 p.SumaMono(E TBase coef, E N grado)
7 TBase p.LeeCoef(E N grado)
8 N p.Grado();
9 p.SumaPoli(E Poli q)
10 TBase p.Evalua(E TBase x) // para un valor de x

. 12 Desarrollar el TAD polinomio sobre el TAD TLista, esto es, en la implementación, los
datos se guardarán sobre una lista. Ası́ la base de la lista serán el grado/coeficiente de cada
monomio
5.11 Conjuntos 34

5.11. Conjuntos
La caracterı́stica fundamental de los conjuntos es su ausencia de estructura. Como ya se
estudió en matemáticas, cuando se habla de conjuntos únicamente se trata de agrupar elementos
bajo un concepto simple, un atributo. En el caso de los TDAs conjuntos sólo tendremos pues
que preocuparnos de Añadir, de Borrar y de Leer la información de un elemnto para agotar los
requisitos de un conjunto. Sin embargo, surge inmediatamente el inconveniente práctico de la
necesaria iteración (ya sea para copiarlo en otro, ya sea para presentar quizás, su contenido, o sólo
por observar, ver desfilar, sus componentes con otro criterio).
Se habla también de Tablas, que son conjuntos en los que se diferencia una clave (única) por
elemento. Una clave identificativa para cada elemento. Es difı́cil encontrar la diferencia entre Tabla
y Diccionario.
En C++ podrı́amos definir una tabla como:
1 typedef char TClave[16];
2 typedef struct TBase{
3 TClave clave;
4 ...
5 };

7 class TTabla {
8 public:
9 TTabla();
10 ~TTabla(void);
11 void Anyadir(TClave k, TBase x);
12 void Borrar(TClave k);
13 bool EstaEn(TClave k, TBase &devolver);
14 private:
15 ...
16 };

En el caso de la tabla, al no haber primero ni estructura sobre la que ver los elementos, por
ejemplo como Izda()/Dcha(), Elemento(pos), Desapilar(), etc. que tienen los demás TDAs
es posible algún tipo de iteración sobre los elementos. En realidad, como en las pilas o colas,
la iteración no es precisamente la operación más frecuente ni necesaria, pero de cara a “ver” la
estructura, como hemos dicho antes, sı́ es importante. Particularmente veremos cómo se podrı́a
definir una interfaz adecuada para las tablas.

5.11.1. Iteradores sobre tablas


Aunque no tan importantes como los de los árboles, utilizaremos el caso de los iteradores de
las tablas para ver sus posibles formas. Como vimos en § 5.1.2 en general se pueden dar tres tipos
de iteradores: pasivos, activos y de búsqueda. En todos los casos se trata de que un procedimiento
(pasado en forma de parámetro) sea paseado por cada nodo (ı́tem) de la estructura. El primer y
último tipo no modifican ningún ı́tem, el segundo puede hacerlo:
1 void IterPasivo(void proc(const TBase x));
2 void IterActivo(void proc( TBase &x));
3 bool IterBusca( bool proc(const TBase x));

5.11.2. Colisiones en hashing


Una función hash asocia un valor de clave a un rango restringido de números. Usualmente se
trata de establecer una relación de muchos, en el sentido literal de “una cantidad muy grande de
posibles valores”, como lo pueden ser posibles nombres de personas, identificadores, DNIs, etc. a
un rango relativamente pequeño de ı́ndices de un array estático, o sea, a unos pocos valores. Es por
tanto una función del tipo “muchos-a-uno” en las que son inevitable la existencia de repeticiones
en el dominio de salida, y cuyo caso diremos que tenemos una colisión. Por ejemplo, si el dominio
de salida (rango de ı́ndices del array de salida) es de 101, si h(x) = x mód 101, entonces serı́a
h(233) = h(536). Ver Figura 17.
5.11 Conjuntos 35

31
132
233 31
334
455
536

Figura 17: Colisión de múltiples valores de clave en un valor único final con el hashing
h(x) = x mód 101.

Las claves que colisionan se dicen sinónimas respecto de h.

Paradoja de los cumpleaños La probabilidad, precisamente, de que NO existan colisiones,


es muy pequeña. Este fenómeno se conoce como la “paradoja de los cumpleaños”:
basta con que haya 23 personas en una fiesta para la probabilidad de que ya dos de
ellas tengan el mismo dı́a de cumpleaños sea alta ( > 0,5).
Esto significarı́a que si invitamos a más de 20 amigos a un cumpleaños debemos considerar se-
riamente comprar dos tartas y no sólo la nuestra. Para verlo, es mucho más fácil estudiar la
probabilidad de lo contrario: dada una persona, la probabilidad de que una segunda tenga su
cumpleaños diferente dı́a que la anterior serı́a 364/365 = (365 − 2 + 1)/365, una tercera persona
no coincidirı́a con las dos primeras en una probabilidad de 363/365 = (365 − 3 + 1)/365 ya que
hay 363 oportunidades de NO coincidencia.
Y ası́, sucesivamente, la n-sima persona no coincidirı́a con las anteriores con un
365 − n + 1
365
de probabilidad. Todas estas son probabilidades aisladas, cada nuevo participante no afecta a los
que ya estaban, para que se den simultáneamente (en la fiesta de cumpleaños) por ser sucesos es-
tocásticamente independientes, tienen una probabilidad de suceso conjunto que es la multiplicación
de la probabilidad de cada suceso (ver Figura 18):
n
364 363 365 − n + 1 Y 365 − i + 1
f (n) = × × ··· =
365 365 365 i=2
365

1
0.8
0.6
0.4
0.2
10 20 30 40

Figura 18: La probabilidad de NO coincidencia en los cumpleaños decrece rápidamente


con el número de invitados.

(Algo que demostró Feller en 1950. Ver [Knu73, p. 553] ó [Kru88]). Concretamente, si selec-
cionamos una función hash al azar que aplica 23 claves en una tabla de 365 celdas, la probabilidad
de que no haya dos coincidencias de las claves en la misma posición es de sólo f (23) = 0,4927
ó f (22) = 0,5243).
De hecho
n
Y 365 − i + 1
f (n) =
i=2
365
se puede expandir a:
(−1)n−1 Pochhammer(−364, n − 1)
365n−1
5.11 Conjuntos 36

con
Γ(a + n)
Pochhammer(a, n) ≡ (a)n ≡ (a)n̄ = ,
Γ(a)
función que aparece en la expansión de funciones hipergeométricas y que tiene valor definido aún
cuando la función Γ sea infinito en ella. Ver Abramowitz and Stegun 1972, p. 256; Spanier 1987;
Koepf 1998, p. 5.

5.11.3. Propiedades de las funciones hash


Las funciones hash, ya sea sobre claves numéricas o de otro tipo, deben cumplir las siguientes
propiedades:
1. Debe ser rápida y fácil de calcular. Normalmente sólo aparecerán operadores del tipo × y +.
2. Deben distribuir los valores aleatoriamente en el array con la mı́nima probabilidad de colisión
(ser uniformes). La probabilidad para todos los valores de hashing deberı́a ser la misma:
1
Prob.(h(x) = i) = ∀i
núm. celdas

Ejemplos de funciones hash:


División Por ejemplo h(k) = k mód n es simple y efectiva y, si n es el tamaño de la tabla
distribuye uniformemente los valores de hashing en toda la tabla. Para que se den el mı́nimo
número de colisiones, es conveniente (ver Knuth, “Clasificación y búsqueda” 1973) que n,
el tamaño de la tabla, sea un número primo alto lejano a una potencia de dos. Si
esto no es ası́, se tendrá un comportamiento desequilibrado formándose patrones de datos
acumulados. Por ejemplo, si n es potencia del tamaño de la base del ordenador, entonces
h(k) nos darı́a siempre los restantes dı́gitos significativos de k independientemente de los
otros dı́gitos.
Multiplicación Como el método de los dı́gitos medios del cuadrado (“mid-squared method”):
h(x) = middig(k × k). Tiene las propiedades de ser fácil de implementar; y de tomar en
consideración toda la clave de entrada. El resultado es aleatorio. El problema principal es
tener una función rápida que tome los dı́gitos intermedios. Esto es muy fácil en notación
binaria a nivel máquina. En C++ proponemos:
MillCent(N) -> (N / 100) % 100

o, en general:
Cifras(N, desde, hasta) -> (N / (desde-1)) % hasta

Selección de dı́gitos Si se tienen claves con muchos dı́gitos (números de teléfono, por ejemplo),
es interesante hacer una selección de los dı́gitos más cambiantes, los últimos, probáblemente,
para evitar las colisiones, ya que en determinadas secuencias de claves (números de teléfonos,
por ejemplo) se suelen repetir insistentemente partes de la clave.
Plegado (“folding”) Consiste en la suma de los dı́gitos (o caracteres):

h(38219) = (3 + 8 + 2 + 1 + 9) mód N = 23,


1 int TTabla::hash(char *clave)
2 {
3 int s=0;
4 for (int i=0; clave[i] ; i++)
5 s += clave[i];
6 return s % max;
7 }

Es la técnica más comúnmente empleada con cadenas de caracteres.


5.11 Conjuntos 37

5.11.4. Técnicas de manejo de colisiones


La posición a la que una función hash transforma la clave se le llama dirección básica de
destino (home address). Si dos claves colisionan hay que tratar de resolver esta colisión. Esto
puede hacerse de dos formas:
Hash cerrado o interno, también llamado de “dirección abierta”
Hash abierto o encadenamiento externo
Cada técnica tiene sus ventajas e inconvenientes.

5.11.5. Hashing de dirección abierta


El hash cerrado (o de dirección abierta) utiliza la técnica de rehashing para las colisiones,
volviendo a aplicar sucesivas funciones hash (h2 (x), h3 (x), . . . ) sobre la clave hasta que no existan
más colisiones. Esto es a lo que se llama una secuencia de pruebas.
El rehashing lineal8 sencillamente prueba cada posición una detras de otra, si se llega al
final se sigue por el principio del array; por ejemplo:
1 int TTabla::rehash(int pos)
2 {
3 return (pos + 1) % max;
4 }

Problemas Con la prueba lineal se da un fenómeno indeseable: Cualquier clave que colisione
con otra tendrá que colisionar necesariamene con todas con las que ya haya también colisionado la
anterior antes de encontrar un lugar libre. Esto provoca lo que se llama una agrupación primaria
(“primary clustering”)9 .
Considérese por ejemplo la Figura 19, inicialmente, con la tabla vacı́a, la probabilidad de

a b c d e

Figura 19: Acumulación de probabilidad de colisiones. Al crecer la zona rellenada


(“mancha”) aumenta la probabilidad de que aún crezca más.

que una clave cualquiera se inserte en la posición ‘b’ es de 1/N . Una vez llenada la celda ‘a’, la
probabilidad de que ‘b’ se llene se ha duplicado, ya que también los elementos que vayan a ‘a’
terminan en ‘b’. Una vez llenado ‘b’, la probabilidad de que ‘e’ se llene es 5/N . Se trata pues de
que mientras más larga es la cadena, más larga aún tiende a hacerse (efecto “bola de nieve”). Es
pues un problema de inestabilidad en el reparto de los elementos.

Soluciones Se pueden adoptar muchas técnicas para disminuir al máximo la ineficiencia del hash
cerrado conforme se va llenando la tabla, sobre todo en lo referente a la formación de agrupaciones.
Las alternativas más importantes la prueba lineal son:
8 Realmente la mayorı́a de los autores llaman a la resolución de colisiones lineal hashing lineal y reservan la

palabra rehashing para el caso en que se usen distintas funciones posteriores de hashing. Como veremos más
adelante.
9 Los nombres de primario o n-ario para el clustering vienen de que la cadena (chain) tiene su forma establecida

desde la n-sima prueba, la forma de la cadena depende del n-simo rehashing


5.11 Conjuntos 38

Doble rehashing Para evitar la agrupación se afina en el cálculo de nuevas direcciones. Hay
muchas formas; la técnica de rehashing doble utiliza una segunda función de cálculo de la
dirección para obtener la segunda dirección a considerar. Si la distribución inicial está sufi-
cientemente dispersa, no serı́a necesario una función independiente, pero es esto lo que va a
mantener la dispersión, la independencia de las posiciones ya encontradas en el cálculo de
las nuevas.
El rehashing doble utiliza la misma técnica que el lineal pero la función hi (x) = (h(x) + i ×
u) mód N . La elección de u el tamaño de los sucesivos saltos y de N , el tamaño de la tabla,
deben hacerse con cuidado. Evidentemente valores como u = 0 ó u = 2N son inaceptables.
Es importante que u y N sean primos relativos (no tengan factores comunes). Esto se puede
conseguir haciendo N primo y u < N .
Prueba cuadrática En vez de posiciones consecutivas se hacen aumentos cuadráticos hi (x) =
(h0 + i2 ) mód N . NO prueba, sin embargo, toda la tabla. Si N = 2k (potencia de dos), se
probarı́an especialmente pocas posiciones. Ahora bien, supongamos que N es primo, y que
vamos calculando nuevas posiciones, desde la prueba i a la prueba j en que por fin vuelve a
coincidirse con la posición calculada en la prueba i. Entonces:

(h0 + i2 = h0 + j 2 ) mód N ⇒ ((j − i)(i + j) = 0) mód N

y ya que N es primo, debe dividir a algún factor de los dos. Si divide a (j − i), sólo lo
hará cuando j diste de i en N pruebas (o un múltiplo de N : entonces j = cN + i, y
j 2 = c2 N 2 + 2cN i + i2 que en módulo N nos da

(c2 N 2 + 2cN i + i2 ) mód N = c2 N 2 mód N + 2cN i mód N + i2 mód N = i2 mód N.

de nuevo i2 ), con lo que se habrı́an probado ya N posiciones diferentes y tendrı́amos un hash


completo. Sin embargo también puede dividir a (i + j), y esto ocurrirá cuando j = N − i,
con lo que todos los cuadrados de j > N − i, i < j tienen un valor para el que coinciden.
Veámoslo, j = N − i implica j 2 = N 2 − 2N i + i2 que en aritmética modular igualmente se
simplifica a i2 :

(N 2 − 2N i + i2 ) mód N = N 2 mód N − 2N i mód N + i2 mód N = i2 mód N.

Todos los valores de j ∈ [0, N − 1] tienen un correspondiente i ∈ [0, N − 1] con el mismo


valor de hash. Ası́ pues aunque antes pudieran preverse N posiciones distintas a visitar,
con estas nuevas coincidencias (reflejas) el total de posiciones realmente visitables con la
prueba cuadrática se reduce a la mitad (N + 1) div 2. Es costumbre suponer que ha habido
un desbordamiento cuando este número de posiciones ha sido probado ya. Ver [Wir76]. Ver
ejercio 20. Los resultados son satisfactorios suponiendo que hay desbordamiento después
de probar este número de posiciones. En favor, a pesar de esto, de esta técnica, obsérvese
que la eficiencia del hashing de dirección abierta disminiye, después veremos cómo, muy
rápidamente cuando la tabla se acerca al lleno.
Nótese que en la prueba cuadrática no será necesario calcular cuadrados si hacemos los
sucesivos rehashings con un desplazamiento incremental:
i+1
h(x, i) = (h(x, i − 1) + i) mód N = . . . = (x + 1 + 2 + · · · i) mód N = x + i
2
que es cuadrático en el orden de la prueba.
Rehashing aleatorio Distribuye las claves aleatoriamente en la tabla aplicando una función ge-
neradora de números pseudoaleatorios para encontrar las posiciones. Pero, claro, para poder
localizar después los elementos en la tabla a partir de sus claves es conveniente, por ejemplo,
tomar como semilla10 de la generación de los números seudoaleatorios la propia clave, de
10 Recordemos que una vez dada una semilla a un generador de números seudoaleatorios la secuencia de seudoa-

leatorios queda perfectamente definida y repetible. Esto es la secuencia es sólo función de la semilla.
5.11 Conjuntos 39

forma que siempre se produzca la misma secuencia. Este tipo de rehashing tiene la ventaja
de eliminar las agrupaciones (clusterings) primarias y secundarias, pero el inconveniente de
ser de más complejidad y de que con un generador tı́pico de números aleatorios se repetirán
localizaciones y no se tiene, en pocas pruebas por qué visitar toda la tabla.
Una variante de este mecanismo es la de tomar una de las N ! permutaciones posible de
secuencias para nuentra tabla de N elementos. Mediante esta alternativa se evitan clustering,
repeticiones, etc. Ver [HS87] (págs. 452+), [AHU83] (págs. 122+), [Har89] (Chapter 9).

5.11.6. Hashing de encadenamiento externo


El hashing cerrado tiene el problema de la formación de clusters y, además, el de la limitación
del tamaño de la tabla. Como ventajas, su simplicidad y eficiencia. El hashing abierto es más
adecuado en casos en que no se sabe mucho del número de claves a insertar.
Para evitar la limitación del tamaño de la tabla se utiliza el encadenamiento externo, en el que
cada celda de la tabla contiene una lista no acotada de más elemementos asociados a esa celda. Ver
Fig. 20 La inserción de los elementos colisionados en la lista correspondiente puede ser ordenada

Figura 20: Hash abierto. Los valores de hash coincidentes se encadenan sin lı́mite de
capacidad.

o no. La búsqueda posterior mejorarı́a, lógicamente, si fuese ordenada, como vimos en el tema de
listas, pero, dado que estas listas son muy cortas, no merece la pena mantenerlas ordenadas. Una
buena elección para el tamaño de la tabla hash es de un décimo del total de elementos esperados;
entonces el promedio de longitud de las listas serı́a de 10.

5.11.7. Complejidades
Se pueden calcular de una manera relativamente fácil las complejidades de los rehashing
aleatorios, en los que la posición siguiente, en cada prueba se encuentra con toda la tabla, como
posible diana y con la misma probabilidad para coda celda. Veremos, en este apartado cómo estimar
estas complejidades y las de los demás métodos de hashing y rehashing, en forma comparativa.
Una medida de la probabilidad de colisión la da el factor de carga α:
núm. celdas ocup.
α=
N
Con las técnicas de dirección abierta, la cantidad de comparaciones necesarias para encontrar
celdas libres aumenta rápidamente cuando α → 1, sin embargo, en el encadenamiento externo
del hashing abierto el número de comparaciones necesarias depende directamente del número de
colisiones ocurridas. Concretamente, para una celda i con una lista externa con ni elementos se
habrán dado ni colisiones y la complejidad de la búsqueda (si la inserción no ha sido ordenada en la
lista) es proporcional a ni . Ası́ dado que el tamaño de la tabla es fijo, en el hash de encadenamiento
externo la complejidad del acceso depende, en promedio, directamente del número de colisiones
totales.
5.11 Conjuntos 40

Notaciones Sea HT (0 : N − 1) una tabla hash con N celdas. Sea h una función hash uniforme
de rango [0, N − 1]. Si se insertan n identificadores x1 , x2 , . . . , xn en la tabla, habrá N n posibles
secuencias h(x1 ), h(x2 ), . . . , h(xn ) de hashings igualmente probables según los posibles órdenes de
llegada11 . Sea
S(α)
el número de comparaciones de identificadores esperadas para localizar el identificador xi (1 ≤
i ≤ n) y por tanto S(α) es el promedio de comparaciones necesarias para localizar cualquier xj
existente. Es por lo que no se espera dependencia del xi que sea, sino tan sólo de α. Sea igualmente

U (α)

el número de comparaciones necesarias para averiguar que un identificador NO está en la tabla


(‘Unsuccess’).
Para los rehashings aleatorio y cuadrático es fácil comprobar que, siendo α la probabilidad
de acertar en una casilla ocupada en una primera prueba y 1 − α la de acertar en una vacı́a, la
probabilidad de que una búsqueda infructuosa termine al segundo intento será su producto (llena
× vacı́a): α × (1 − α). Análogamente, la probabilidad de que se hagan k pruebas en una búsqueda
infructuosa será αk−1 (1 − α). Ası́, el número medio de pasos en las pruebas infructuosas serı́a la
suma de los pasos por las correspondientes probabilidades para todos los pasos posibles, esto es:

X
U (α) = kαk−1 (1 − α)
k=1

pero

X 1
αxα−1 = ,
α=1
(1 − α)2
nos queda,
1
UHΑL UHΑL= €€€€€€€€€€€€€€€€€€€€
H1 - ΑL
10

0.2 0.4 0.6 0.8 Α

U (α) = 1/(1 − α).


Como el número de intentos para localizar un elemento es el mismo número de fallos espera-
dos más un acierto final. Si queremos calcular el número promedio de intentos fructuosos en la
localización de un elemento genérico existente en la tabla debemos contemplar todas las posibles
configuraciones de la tabla y promediar. Ası́, si tenemos un elemento, la probabilidad de localizarlo
es de 1, esto, es S(1/N ) = 1. Para dos elementos, debemos tener en cuenta los dos posibles órdenes
de entrada. Esto último equivale a una primera posibilidad de orden de entrada (A, B) que en
la búsqueda de A, nos da SA (2/N ) = 1 dividida este número de intentos por su probabilidad de
aparición: 2, más, en este caso, la otra alternativa, que es la búsqueda del mismo A, pero habiendo
entrado primero B y luego A, lo que nos da un número de intentos infructuosos U (1/N ) más el
acierto, luego una longitud de camino U (1/N ) + 1, con una probabilidad igual a la de antes.
Ası́, para la localización de cualquier elemento en la tabla, debemos pensar que ese elemento
pudo entrar el primero, el segundo, . . . , hasta el n (α = n/N ). Sumar los pasos requeridos en cada
caso multiplicados por sus probabilidades que en todos, es la misma: 1/n, ya que son perfectamente
equiprobables esos órdenes de llegada. Por otro lado, el localizar un elemento que entró en la tabla
11 Variaciones de N elementos tomados de n en n
5.11 Conjuntos 41

en la i entrada, de nuevo, es equivalente a considerar la longitud promedio de fallos en su búsqueda


en una tabla que tuviese tan sólo i − 1 elementos más el acierto.
En promedio aproximado, desde el comienzo de llenado de la tabla hasta un valor α, tendremos:
n−1
1 α
Z
1X 1 1 1 1
S(α) ≈ U (α(ν)) ≈ U (ν) dν = ln( ). O sea: S(α) ≈ ln( )
n ν=0 α 0 α 1−α α 1−α

Con α(ν) = ν/N .


Para este rehashing aleatorio el comportamiento en el número de intentos esperados para
localizar un elemento es sorprendentemente bueno, como se ve en la siguiente tabla:

α S(α) 3
0.1 1.05 2.5
0.25 1.15
2
0.5 1.39
0.75 1.85 1.5
0.9 2.56 1
0.95 3.15 0.5
0.99 4.66
0.2 0.4 0.6 0.8 1

Nótese que una tabla al 90 % llena requiere ¡tan sólo 2.56 pruebas! para localizar un elemento.
El estudio anterior (que se puede encontrar, por ejemplo en [Kru88]), suponen una distribu-
ción uniforme (aleatoriamente uniforme) de cada dirección hash en cada intento, lo que ha hecho
relativamente fácil el estudio. Los métodos reales más utilizados, como el de rehashing lineal, tienen
un comportamiento algo peor, y la evaluación del mismo es más complicada.
En el caso del encadenamiento externo, la búsqueda infructuosa requiere un número pro-
medio de pasos
Uexterno (α) ≈ α
pero téngase en cuenta que aquı́ α = n/N puede ser mayor que 1. Si se trata de localizar exito-
samente el elemento, suponiendo listas encadenadas desordenadas, de longitud promedio α todas
ellas, la posición de aparición del elemento en la lista requerirá longitud-lista/2 pasos, de modo
que el todal de pasos para el encuentro exitoso será:
α
1+ .
2
En el caso lineal, el estudio es complicado (ver [Knu73]); tan sólo exponemos los resultados
pesimistas aproximados, pues los reales son algo mejores:
3
α S(α)
0.1 1.06 2.5

0.25 1.17 2
1 1 
S(α) ≈ 1+ 0.5 1.50 1.5
2 1−α
0.75 2.50
1
0.9 5.50
0.95 10.50 0.5

0.2 0.4 0.6 0.8

que vemos que tiene un comportamiento muy bueno excepto que la tabla esté prácticamente llena.
5.11 Conjuntos 42

En resumen:
Tipo ≈ U (α) ≈ S(α)
1 1  1 1 
Lineal 1+ 1+
2 (1 − α)2 2 1−α
1 1
Aleatorio − loge (1 − α)
1−α α
α
Externo α 1+
2
Ver Figura 21.

10
Slineal(α)
8
Ulineal(α)
6 Saleatorio(α)
Ualeatorio(α)
4

0
0.0 0.2 0.4 0.6 0.8 1.0
Sexterno(α) Uexterno(α)

Figura 21: Comparación de comportamiento de los distintos hashing.

Las demostraciones para el rehashing lineal se pueden encontrar en [Knu73]. Comentarios


sobre los demás en [Wir76], [AHU83] y [Kru88].

5.11.8. Ejercicios Tablas


. 13 Diseñar una función hash lo más sencilla, rápida y homogénea posible que permita corres-
ponder palabras de tres letras a enteros entre 0 y N − 1, inclusives. Encontrar los resultados
de su aplicación con las palabras:
MAR SAL CAL SIN VER SOL TUL BAR RAM SON TAN LEM TEN CON
con N = 11, 13, 17 y 19. Intentar tener el menor número posible de colisiones.
. 14 Calcular las complejidades espaciales de las implementaciones de hashing abiertas y cerra-
das y compararlas en función del tamaño del registro (información) y el factor de carga
α.
. 15 Comparar las complejidades de los métodos de rehashing lineal con las que surgen del man-
tenimiento en un array de elementos ordenados por su clave en búsquedas exitosas (S(α)) e
infructuosas (U (α)). Suponer conocido el conjunto de elementos actuales en el array.
. 16 Costruir una tabla hash sobre una clave cadena en forma cerrada (con hashing y rehashing
lineales). Añadir al interfaz del tipo iterador especial ilustrativo que presente claramente el
estado actual de la tabla (t.dispTable()) en la pantalla.
NOTA: Incluir el campo de marca de estado de la celda.
. 17 Al dejar celdas borradas en el array de una tabla hash cerrada, una de las optimizaciones
interesantes es que se procuren reutilizar las primeras posiciones borradas o vacı́as y no las
últimas, de forma que ulteriores búsquedas nos localicen antes los elementos en las cadenas
de pruebas de rehashing. Cabe además otra optimización: ampliar los parámetros de del
5.11 Conjuntos 43

procedimiento buscador de la celda para una clave de forma que además diga, si se ha
localizado un elemento, si es el último de una cadena, de forma que esta información la
pueda recoger el método de borrado para marcar esa posición no a “borrado” sino a “vacı́o”,
recuperando ası́ posiciones vacı́as y acortando las agrupaciones.

. 18 Implementar el TDA TTabla, tal y como se definió inicialmente, mediante el uso de técnicas
hashing (hash abierto), suponiendo para esto que el tipo base de la tabla admite directamente
una función int hash(TBase).

. 19 Desarrollar una función que devuelva los dı́gitos i al j de un número positivo cualquiera

dn dn−1 . . . d2 d1

siendo estos dı́gitos representantes del número en una base b.

. 20 Implementar una técnica de rehashing cuadrático examinando las posiciones h(x), (h(x) +
i2 ) mód N , y (h(x) − i2 ) mód N con 1 ≤ i ≤ (N − 1)/2 y N un número primo de la forma
4j + 3. Comprobar que con estas funciones de rehashing se examinan todas las posiciones de
la tabla de tamaño N .

. 21 Implementar una tabla hash de encadenamiento externo de sı́mbolos, esto es, la clave será un
array de caracteres.

. 22 Otro método cuasiabierto de controlar las colisiones es el de la tabla de desbordamiento


que consiste en el mantenimiento de una tabla con todos los elementos que hayan podido
colisionar. Las búsquedas en ésta última podrán ser lineales o mediante otro hashing. Discutir
sus ventajas e inconvenientes.

. 23 Otro método de control de colisiones en forma cuasiabierto es el uso de bloques en vez de


celdas. Bloques donde pueden colocarse varios elementos. Es análogo a una “lista acotada”
de desbordamientos. Discutir sus ventajas e inconvenientes.

. 24 En la paradoja de los cumpleaños se puede comprender mejor el hecho de ser llamada para-
doja, viendo las respuestas a

1. ¿Cuál es la probabilidad de que entre las n personas de la habitación, al menos una


tenga su cumpleaños un dı́a dado?;
2. ¿cuál es la probabilidad de que dos de las n personas los tengan el mismo dı́a?;
3. ¿cuál es la probabilidad de que ninguna de las n personas coincidan en sus cumpleaños?

. 25 Implementar el procedimiento de inserción del hash abierto. Comentar posibles mejoras en el


tratamiento de las listas externas (ordenadas, elemento en la tabla y desbordamientos fuera,
un total de B elementos en la tabla y listas de desbordamiento, tabla independiente para
elementos colisionados, etc.)

. 26 Otro método de mantener las listas de desbordamiento es permitiendo que el primer elemento
de ellas esté en el propio array. Estudiar la mejora de este método en cuanto a complejidad
espacial y ver cómo hay que modificar el algoritmo de mantenimiento.

. 27 Suponiendo un borrado muy infrecuente, evitar la marca de borrado del hashing cerrado
mediante un procedimiento de borrado que mueva el elemento que siga a la posición borrada
al lugar borrado, y ası́ con el resto de la cadena que haya. ¿Cuándo conviene este método?

. 28 Implementar la función α (que devuelva el factor de carga) dentro de la implementación de


una tabla de hash cerrado.
5.12 Referencias de consulta 44

. 29 Implementar un procedimiento que evalúe U (α) y S(α). Para ello muestrear con 1000 búsque-
das infructuosas (U ) y 1000 búsquedas exitosas (S) para valores de α de 18 , 14 , 34 y 78 (o más
puntos) y graficar los resultados.
NOTA: Para conseguir estos valores de α llenar la tabla con datos variados (un función
aleatoria serı́a aquı́ adecuada) hasta llenados adecuados.
. 30 Desarrollar un tratamiento de ficheros “indexado”. Para ello utilizar la técnica de hashing
desarrollada en el ejercicio 16.
1. Dar un tamaño fijo al fichero.
2. Estructurar una cabecera en el fichero (el registro cero) que mantenga el tamaño actual
del fichero y sólo cuando todas las celdas actuales del fichero estén llenas aumentar
el tamaño de este razonablemente, actualizando entonces el tamaño guardado en la
cabecera.

5.12. Referencias de consulta


La mejor referencia de consulta para la comprensión del formalismo de los TDAs es el texto
de Johannes Martin [Mar86].
Las implementaciones de pilas, listas, etc. a nivel elemental se pueden encontrar en muchos
textos, cito: [Wir76], [Kru88], [Tuc88], [Har89], [AHU83] ó [HS87].
El apartado (§5.7.3) referente a un método semiautomático de eliminación de cualquier tipo
de recursividad mediante el uso de saltos no estructrurados y pilas locales se ha tomado de [HS87].

5.13. Apendice A: Random


No existe forma de generar números aleatorios en un computador (o cualquier máquina de-
terminista). Se trata de producir una secuencia con las mismas propiedades estadı́sticas de distri-
bución uniforme que los números aleatorios. Por este motivo es por que en realidad se les llama
números pseudoaleatorios.
Veamos el método de D. Lehmer (1951): método lineal congruente (ver [Sed88]).
A partir de una ”semilla”se construye una secuencia:
1 a[0] = semilla;
2 for (i = 1; i < N; i++)
3 a[i] = (a[i-1] * b + 1) % m;

que nos da enteros entre 0 y m-1.


El producto produce overflows del rango de los enteros.
Para que la secuencia sea ‘buena’ se deben elegir, semilla, b y m adecuadamente. m debe ser
grande, preferiblemente potencia de 2, del tamaño de la palabra de la máquina. b una constante
arbitraria sin ningún patrón especial en sus dı́gitos excepto que debe acabar en 21, y antes, ser
par.
Para evitar el overflow, descomponemos los multiplicandos en

p = 104 p1 + p0

y
q = 104 q1 + q0
de forma que

p×q = (104 p1 + p0 ) × (104 q1 + q0 ) =


= 108 p1 q1 + 104 (p1 q0 + p0 q1 ) + p0 q0

y, dado que se quieren sólo 8 dı́gitos, ignorando el primer término.


Un Componente reutilizable generador de números aleatorios podrı́a ser:
REFERENCIAS 45

1 class Random {
2 public:
3 Random(const unsigned long int s=314159) {
4 pow = 10000; b = 31415821; m = 100000000; a = s;}
5 void Reiniciar(const unsigned long int s=314159) { a=s; }
6 unsigned random(unsigned desde=0, unsigned hasta=100) {
7 a = (mult(a,b)+1) % m;
8 return desde + ((a / pow) * hasta) / pow;
9 }
10 private:
11 unsigned long pow, a, b, m;
12 unsigned long mult(unsigned long p,q);
13 };

15 unsigned long Random::mult(unsigned long p,q)


16 {
17 unsigned long p1,p0,q1,q0;

19 p1 = p / pow; p0 = p % pow;
20 q1 = q / pow; q0 = q % pow;
21 return (((p0*q1+p1*q0) % pow) * pow+p0*q0) % m;
22 }

Referencias
[AHU83] A. Aho, J. Hopcroft, and J. Ullman. Data Structures and Algorithms. Addison-Wesley,
1983. Traducido al castellano, 1988.

[Har89] Rachel Harrison. Abstract Data Types in Modula-2. John Wiley & Sons, 1989.

[HS87] E. Horowitz and S. Sahni. Fundamentals of Data Structures in Pascal. Computer Science
Press, 1987.

[Knu73] Donald E. Knuth. The Art of Computer Programming. Vol. 3: Searching and Sorting.
Addison-Wesley, Massachusetts, 1973. Traducido al castellano en Ed. Reverté, Barcelona.

[Kru88] Robert L. Kruse. Estructura de datos y diseño de programas. Prentice-Hall Hispa-


noamericana, 1988. Traducción de Data Structures and Program Design; Prentice-Hall
(1988).

[Mar86] Johannes J. Martin. Data types and data structures. Prentice-Hall, 1986.

[Sed88] R. Sedgewick. Algorithms. Addison-Wesley, second edition, 1988. Hay al menos otros
dos tı́tulos de igual contenido “Algorithms in Pascal” y “Algorithms in C” (1990).
[Tuc88] Allen B. Tucker. Computer Science. A second course using Modula-2. McGraw-Hill,
New York, 1988.

[Wir76] Niklaus Wirth. Algorithms + Data Structures = Programs. Prentice-Hall, New York,
1976. Traducción al castellano en Ed. del Castillo, Madrid (1980).

Juan Falgueras
Dpto. Lenguajes y Ciencias de la Computación
Universidad de Málaga
Despacho 3.2.32

S-ar putea să vă placă și