Subido por Jhoan Manuel Venegas Olmos

Estructuras de Datos con Java. Héctor Tejeda V

Anuncio
Estructuras de Datos con Java
Héctor Tejeda V
Abril, 2010
Índice general
1. Introducción
1.1. Clases, tipos, y objetos . . . .
1.1.1. Tipos base . . . . . . .
1.1.2. Objetos . . . . . . . .
1.1.3. Tipo enum . . . . . . .
1.2. Métodos . . . . . . . . . . . .
1.3. Expresiones . . . . . . . . . .
1.3.1. Literales . . . . . . . .
1.3.2. Operadores . . . . . .
1.3.3. Conversiones tipo base
1.4. Control de flujo . . . . . . . .
1.4.1. Sentencias if y switch
1.4.2. Ciclos . . . . . . . . .
1.4.3. Sentencias explı́citas de
1.5. Arreglos . . . . . . . . . . . .
1.5.1. Declaración de arreglos
1.5.2. Arreglos como objetos
1.6. Entrada y salida . . . . . . .
1.7. Clases anidadas y paquetes . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
7
7
10
10
17
17
23
23
24
28
30
30
32
35
37
39
40
41
45
2. Diseño orientado al objeto
2.1. Metas, principios, y patrones . . . . . . . . . . .
2.1.1. Metas del diseño orientado a objetos . .
2.1.2. Principios del diseño orientado al objeto
2.1.3. Patrones de diseño . . . . . . . . . . . .
2.2. Herencia y polimorfismo . . . . . . . . . . . . .
2.2.1. Herencia . . . . . . . . . . . . . . . . . .
2.2.2. Polimorfismo . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
47
47
47
49
51
52
52
54
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
control de
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
flujo
. . .
. . .
. . .
. . .
. . .
ÍNDICE GENERAL
2.2.3. Uso de herencia en Java . . . . .
2.3. Excepciones . . . . . . . . . . . . . . . .
2.3.1. Lanzamiento de excepciones . . .
2.3.2. Atrapar excepciones . . . . . . .
2.4. Interfaces y clases abstractas . . . . . . .
2.4.1. Implementación de interfaces . .
2.4.2. Herencia múltiple en interfaces .
2.4.3. Clases abstractas y tipado fuerte
2.5. Conversiones y genéricos . . . . . . . . .
2.5.1. Conversiones para objetos . . . .
2.5.2. Genéricos . . . . . . . . . . . . .
3
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
55
63
63
66
68
68
70
72
73
73
78
3. Arreglos, listas enlazadas y recurrencia
3.1. Que son las estructuras de datos . . . . . . . . . . . . .
3.1.1. Generalidades de las Estructuras de Datos . . .
3.2. Arreglos . . . . . . . . . . . . . . . . . . . . . . . . . .
3.2.1. Guardar entradas de juego en un arreglo . . . .
3.2.2. Ordenar un arreglo . . . . . . . . . . . . . . . .
3.2.3. Métodos para arreglos y números aleatorios . .
3.2.4. Arreglos bidimensionales y juegos de posición .
3.3. Listas simples enlazadas . . . . . . . . . . . . . . . . .
3.3.1. Inserción en una lista simple enlazada . . . . . .
3.3.2. Quitar un elemento de una lista simple enlazada
3.4. Listas doblemente enlazadas . . . . . . . . . . . . . . .
3.4.1. Inserción en el centro de la LDE . . . . . . . . .
3.4.2. Remoción en el centro de la LDE . . . . . . . .
3.4.3. Una implementación . . . . . . . . . . . . . . .
3.5. Listas circulares y ordenamiento . . . . . . . . . . . . .
3.5.1. Lista circular enlazada . . . . . . . . . . . . . .
3.5.2. Ordenar una LDE . . . . . . . . . . . . . . . . .
3.6. Recurrencia . . . . . . . . . . . . . . . . . . . . . . . .
3.6.1. Recurrencia binaria . . . . . . . . . . . . . . . .
3.6.2. Recurrencia múltiple . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
83
83
83
85
85
92
95
101
106
108
110
111
114
115
116
119
119
124
124
132
135
4. Herramientas de análisis
139
4.1. Funciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
4.1.1. La función constante . . . . . . . . . . . . . . . . . . . 139
4.1.2. La función logarı́tmica . . . . . . . . . . . . . . . . . . 140
4
ÍNDICE GENERAL
4.1.3. La función lineal . . . . . . . . . . . . . . .
4.1.4. La funcion N-Log-N . . . . . . . . . . . . .
4.1.5. La función cuadrática . . . . . . . . . . . . .
4.1.6. La función cúbica y otras polinomiales . . .
4.1.7. La función exponencial . . . . . . . . . . . .
4.1.8. Relaciones de crecimiento . . . . . . . . . .
4.2. Análisis de algoritmos . . . . . . . . . . . . . . . .
4.2.1. Estudios experimentales . . . . . . . . . . .
4.2.2. Operaciones primitivas . . . . . . . . . . . .
4.2.3. Notación asintótica . . . . . . . . . . . . . .
4.2.4. Análisis asintótico . . . . . . . . . . . . . . .
4.2.5. Uso de la notación O-grande . . . . . . . . .
4.2.6. Algoritmo recursivo para calcular potencias
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
141
141
141
142
144
146
147
148
150
152
156
158
162
5. Pilas y colas
5.1. Pilas . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.1.1. El tipo de dato abstracto pila . . . . . . . . . .
5.1.2. Implementación de una pila usando un arreglo .
5.1.3. Implementación de una pila usando lista simple
5.1.4. Invertir un arreglo con una pila . . . . . . . . .
5.1.5. Aparear paréntesis y etiquetas HTML . . . . . .
5.2. Colas . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.2.1. Tipo de dato abstracto cola . . . . . . . . . . .
5.2.2. Implementación simple de la cola con un arreglo
5.2.3. Implementación con una lista enlazada . . . . .
5.2.4. Planificador Round Robin . . . . . . . . . . . .
5.3. Colas con doble terminación . . . . . . . . . . . . . . .
5.3.1. Tipo de dato abstracto deque . . . . . . . . . .
5.3.2. Implementación de una deque . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
165
165
165
168
173
176
177
181
182
185
188
189
191
191
192
6. Listas e Iteradores
197
6.1. Lista arreglo . . . . . . . . . . . . . . . . . . . . . . . . . . . . 197
6.1.1. El tipo de dato abstracto lista arreglo . . . . . . . . . . 198
6.1.2. Patrón de diseño adaptador . . . . . . . . . . . . . . . 199
6.1.3. Implementación simple con un arreglo . . . . . . . . . 199
6.1.4. Interfaz ListaIndice y la clase java.util.ArrayList 202
6.1.5. Lista arreglo usando un arreglo extendible . . . . . . . 203
6.2. Listas nodo . . . . . . . . . . . . . . . . . . . . . . . . . . . . 207
ÍNDICE GENERAL
5
6.2.1. Operaciones basadas en nodos . . . . . . . . . .
6.2.2. Posiciones . . . . . . . . . . . . . . . . . . . . .
6.2.3. El tipo de dato abstracto lista nodo . . . . . . .
6.2.4. Implementación con lista doblemente enlazada .
6.3. Iteradores . . . . . . . . . . . . . . . . . . . . . . . . .
6.3.1. Tipos de dato abstracto Iterador e Iterable . . .
6.3.2. El ciclo for-each . . . . . . . . . . . . . . . . . .
6.3.3. Implementación de los iteradores . . . . . . . .
6.3.4. Iteradores lista en Java . . . . . . . . . . . . . .
6.4. ADT listas y el marco de colecciones . . . . . . . . . .
6.4.1. Listas en el marco de colecciones Java . . . . .
6.4.2. Secuencias . . . . . . . . . . . . . . . . . . . . .
6.5. Ejemplo: Heurı́stica mover al frente . . . . . . . . . . .
6.5.1. Implementación con una lista ordenada y una
anidada . . . . . . . . . . . . . . . . . . . . . .
6.5.2. Lista con heurı́stica mover al frente . . . . . . .
7. Árboles
7.1. Árboles generales . . . . . . . . . . . . . . .
7.1.1. Definiciones de árboles y propiedades
7.1.2. El tipo de dato abstracto árbol . . .
7.1.3. Interfaz del ADT árbol . . . . . . . .
7.2. Algoritmos de recorrido para árboles . . . .
7.2.1. Profundidad y altura . . . . . . . . .
7.2.2. Recorrido en preorden . . . . . . . .
7.2.3. Recorrido en postorden . . . . . . . .
7.3. Árboles Binarios . . . . . . . . . . . . . . .
7.3.1. El ADT árbol binario . . . . . . . . .
7.3.2. Una interfaz árbol binario en Java . .
7.3.3. Propiedades del árbol binario . . . .
7.3.4. Una estructura enlazada para árboles
7.3.5. Árbol binario con lista arreglo . . . .
7.3.6. Recorrido de árboles binarios . . . .
7.3.7. Plantilla método patrón . . . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
binarios
. . . . .
. . . . .
. . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
. . .
clase
. . .
. . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
208
208
209
213
221
221
222
224
226
227
228
232
235
. 235
. 237
243
. 243
. 243
. 247
. 248
. 251
. 251
. 254
. 257
. 260
. 262
. 263
. 264
. 266
. 275
. 278
. 286
6
ÍNDICE GENERAL
Capı́tulo 1
Introducción
1.1.
Clases, tipos, y objetos
Para la construcción de estructuras de datos y algoritmos se necesitan
dar instrucciones precisar a la computadora, lo cual se puede lograr usando
un lenguaje de computadora de alto nivel, como Java. Se presenta en este
capı́tulo un repaso breve de los elementos usados en Java y en el siguiente
capı́tulo se repasan los principios del diseño orientado al objeto. No se da
una descripción completa del lenguaje Java porque hay algunos aspectos
importantes que no son relevantes en el diseño de estructuras de datos.
Los programas en Java manipulan objetos. Los objetos guardan datos y
dan métodos para acceder y modificar sus datos. Cada objeto es una instancia
de una clase, la cual define el tipo del objeto, y las operaciones que realiza.
Los miembros importantes de una clase en Java son los siguientes:
Datos de objetos Java son guardados en variables de instancia, o
campos. Por lo tanto, si un objeto de alguna clase es para guardar
datos, entonces la clase debe especificar variables de instancia para
guardar. Las variables de instancia pueden ser tipos base, como enteros,
números punto flotante, o booleanos, o ser referencias a objeto de otras
clases.
Las operaciones que se pueden realizar con los datos son llamados
métodos. Estos consisten de constructores, procedimientos, y funciones.
Los cuales definen el comportamiento de los objetos de esa clase.
8
Introducción
Declaración de una clase
Un objeto es una combinación especı́fica de datos y métodos que pueden
procesar y comunicar esos datos. Las clases definen los tipos para los objetos:
por lo tanto, los objetos son en ocasiones referidos como instancias de su clase
definidora, porque toman el nombre de esa clase como su tipo.
Un ejemplo de la definición de una clase Java se muestra en el listado 1.1
con la clase Contador.
1
2
3
4
5
6
7
8
9
10
11
public class Contador {
private int cuenta ; // una variable entera de instancia
/* * El constructor por defecto para un objeto Contador */
Contador () { cuenta = 0; }
/* * Un m é todo accesor para obtener el valor actual */
public int getCuenta () { return cuenta ; }
/* * Un m é todo modificador para incrementar la cuenta */
public void i n c r e m e n t aC u e n t a () { ++ cuenta ; }
/* * Un m é todo modificador para decrementar la cuenta */
public void d e c r e m e n t a C u e n t a () { -- cuenta ; }
}
Listado 1.1: Clase Contador usada para llevar una cuenta simple, la cual puede ser
accesada, incrementada y decrementada
La definición de la clase está delimitada por llaves, se usa “{” para marcar
el inicio y “}” para marcar el final. Cualquier conjunto de sentencias entre
llaves definen un bloque de programa.
La clase Contador es una clase pública, por lo tanto cualquier otra clase
puede crear y usar un objeto Contador. La clase Contador tiene una variable
entera de instancia llamada cuenta. La variable es inicializada a cero en
el método constructor, Contador, el cual es llamado cuando se crea un
nuevo objeto Contador. La clase tiene un método accesor, getCuenta(),
el cual devuelve el valor actual del contador. También tiene dos métodos
actualizadores, incrementaCuenta() y decrementaCuenta(). Esta clase no
tiene método main() y por lo tanto no hace nada por sı́ misma.
El nombre de una clase, método, o variable en Java es llamado un identificador, el cual puede ser cualquier cadena de caracteres de tamaño arbitrario,
debiendo iniciar con una letra seguida de letras, números, y guiones bajos,
siendo letra y número de cualquier lenguaje escrito definido en el conjunto de
caracteres Unicode. La excepciones a la regla anterior son los identificadores
Java del cuadro 1.1.
1.1 Clases, tipos, y objetos
abstract
assert
boolean
break
byte
case
catch
char
class
const
continue
9
default
goto
package
this
do
if
private
throws
double implements
protected
throws
else
import
public
transient
enum
instanceof
return
true
extends
int
short
try
false
interface
static
void
final
long
strictfp
volatile
finally
native
super
while
float
new
switch
for
null
synchronized
Cuadro 1.1: Lista de palabras reservadas en Java, por lo que no pueden ser usadas
como nombres de variables o métodos.
Modificadores de clase
Los modificadores de clase son palabras reservadas opcionales que
preceden a la palabra reservada class. Los diferentes modificadores de clase
y su significado son los siguientes:
abstract indica que una clase tiene métodos abstractos. Los métodos
abstractos son declarados con la palabra reservada abstract y están
vacı́os, es decir, no tienen un bloque definiendo un cuerpo de código
para el método. Una clase abstract usualmente tiene una combinación
de métodos abstractos y métodos concretos. Lo anterior se aborda en la
sección 2.4.
final indica una clase que no puede tener subclases o ser extendida.
public indica una clase que puede ser instanciada por cualquiera en
el mismo paquete, o por cualquiera que importe la clase. Las clases
públicas son declaradas en su propio archivo separado teniendo el mismo
nombre de la clase y terminación .java.
Cuando no se usa el modificador del clase public, la clase se considera
amigable. Esto significa que puede ser usada e instanciada por todas
las clases en el mismo paquete. Este es el modificador de clase por
defecto.
10
1.1.1.
Introducción
Tipos base
Los tipos de los objetos están determinados por la clase de la cual vienen.
Para propósitos de eficiencia y simplicidad, Java también tiene los siguientes
tipos base, o tipos primitivos, los cuales no son objetos:
Tipo base Descripción
boolean
Valor booleano: true o false
char
Carácter Unicode de 2 bytes
byte
Entero con signo en complemento a dos de 1 byte
short
Entero con signo en complemento a dos de 2 bytes
int
Entero con signo en complemento a dos de 4 bytes
long
Entero con signo en complemento a dos de 8 bytes
float
Número de punto flotante 4 bytes (IEEE 754-1985)
double
Número de punto flotante 8 bytes (IEEE 754-1985)
Alguna variable teniendo uno de estos tipos guarda un valor de ese tipo, en
vez de una referencia a algún objeto. Las constantes enteras, como 18 o -255,
son de tipo int, a menos que vayan seguidos inmediatamente por una ‘L’ o ‘l’,
siendo en tal caso del tipo long. Constantes de punto flotante, como 3.1416 o
2.78e5, son de tipo double, a menos que vayan seguidos inmediatamente por
una ‘F’ o ‘f’, siendo en tal caso del tipo float. Se muestra en el listado 1.2
una clase sencilla, Base, que define tipos base como variables locales para el
método main().
Comentarios
Los comentarios son anotaciones para los lectores humanos y no son
procesados por el compilador Java. Java permite dos tipos de comentarios,
comentarios de bloque y comentarios en lı́nea, los cuales definen el texto
ignorado por el compilador. Un comentario de bloque inicia con “/*” y se
cierra con “*/”. Un comentario que inicia con “/**” es usado por el programa
javadoc, para generar la documentación del software.
Se usa también “//” para iniciar comentarios en lı́nea y se ignora todo
hasta el final de la lı́nea.
1.1.2.
Objetos
Un nuevo objeto es creado usando el operador new. Este operador crea
un objeto nuevo de una clase especificada y devuelve una referencia a ese
1.1 Clases, tipos, y objetos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
11
public class Base {
public static void main ( String [] args ) {
boolean bandera = true ;
char c = ’z ’;
byte b = -127;
short s = 32000;
int i = 12345;
long l = 6789 L ;
float f = 3.1416 f ;
double d = 2.1232 e8 ;
System . out . println ( " bandera = " + bandera ); // + operador concatenaci ó n
System . out . println ( " c = " + c );
System . out . println ( " b = " + b );
System . out . println ( " s = " + s );
System . out . println ( " i = " + i );
System . out . println ( " l = " + l );
System . out . println ( " f = " + f );
System . out . println ( " d = " + d );
}
}
Listado 1.2: La clase Base muestra ejemplos de uso de tipos base.
objeto. Enseguida inmediatamente del operador new se pone una llamada al
constructor para ese tipo de objeto. Cualquier constructor que esté incluido
en la definición de la clase se puede usar. En las lı́neas 4 y 5 de la de la clase
Ejemplo, listado 1.3, se muestra el uso del operador new para crear objetos y
asignar la referencia a esos objetos a una variable.
1
2
3
4
5
6
7
8
public class Ejemplo {
public static void main ( String [] args ) {
Contador c ; // declaraci ó n de la variable c del tipo Contador
Contador d = new Contador (); // declaraci { on y creaci ó n
c = new Contador (); // se crea un objeto Contador , devolviendo una ref .
d = c ; // la referencia de c se asigna a d
}
}
Listado 1.3: Ejemplos de uso del operador new.
Al usar el operador new con algún tipo de clase, se suceden los siguientes
tres eventos:
Un nuevo objeto es asignado dinámicamente en memoria, y todas sus
variables de instancia son inicializadas a valores por defecto. El valor
por defecto para las variables objeto es null y para todos los tipos base
es cero, excepto las variables booleanas, puestas a false.
12
Introducción
El constructor para el nuevo objeto es llamado con los parámetros
indicados.
Después que el constructor termina, el operador new devuelve una
referencia, o dirección de memoria, para el objeto nuevo creado. Si la
expresión está en la forma de una sentencia de asignación, entonces su
dirección se guarda en la variable objeto, ası́ la variable objeto se refiere
al objeto nuevo creado.
Objetos número
Cuando se requiere guardar números como objetos, se pueden usar las
clases envoltura de Java. A estas se les conoce como clases número y hay
una clase número por cada tipo base.
El cuadro 1.2 muestra los tipos base numéricos y su correspondiente clase
numérica. Desde Java 5, una operación de creación es realizada automáticamente en cualquier momento que se pasa un número base a un método que
espera un objeto. De igual forma, el método accesor correspondiente es usado
automáticamente cada vez que se quiere asignar el valor de objeto Number a
un tipo base numérico.
Tipo
Nombre
base
de la clase
byte
Byte
short
Short
int
Integer
long
Long
float
Float
double
Double
Ejemplo
de creación
n=new ((byte)1);
n=new ((short)1);
n=new (1);
n=new (1L);
n=new Floate(3.9F);
n=new Double(3.9);
Ejemplo
de acceso
n.byteValue()
n.shortValue()
n.intValue()
n.longValue()
n.floatValue()
n.doubleValue()
Cuadro 1.2: Clases número de Java. Cada clase está dada con su correspondiente
tipo base y ejemplos para crear y acceder tales objetos.
Objetos String
Una cadena es una secuencia de caracteres que provienen de algún alfabeto, el conjunto de todos los posibles caracteres. Cada carácter c que forma
1.1 Clases, tipos, y objetos
13
una cadena s puede ser referenciado por su ı́ndice en la cadena, que es la
cantidad de caracteres que están antes de c en s, ası́ el primer carácter tiene
ı́ndice cero. El alfabeto usado para definir cadenas es el conjunto de caracteres
internacional Unicode, una codificación de caracteres de 2 bytes que cubre la
mayorı́a de los lenguajes escritos. Java define una clase especial incorporada
de objetos llamados objetos String. Las cadenas literales en Java debe ser
encerradas entre comillas dobles.
Concatenación
El procesamiento de cadenas requiere manejar cadenas. La operación
primaria para combinar cadenas es llamada concatenación, la cual toma
una cadena p y una cadena q y las combina en una nueva cadena, indicada por
p + q, la cual consiste de todos los caracteres de p seguidos de los caracteres
de q. En Java, la operación de concatenación funciona como se indica en la
descripción. Ası́ es legal en Java escribir una sentencia de asignación como
String s = "fut" + "bol";
La sentencia define una variable s que refiere un objeto de clase String,
y le asigna la cadena "futbol". En Java se asume que cada objeto tiene un
método incorporado toString() que devuelve una cadena asociada con el
objeto.
Referencias a objetos
Crear un nuevo objeto involucra el uso del operador new para asignar el
espacio de memoria del objeto y usar el constructor del objeto para inicializar
este espacio. La localidad, o dirección, de este espacio, generalmente, es luego
asignado a una variable referencia. Por lo tanto, una variable de referencia
puede ser vista como un “apuntador” a algún objeto. Es como si la variable
fuera un contenedor para un control remoto que puede ser usado para manejar
el objeto nuevo creado. La variable tiene una forma de apuntar al objeto y
pedir que haga cosas o de acceso a sus datos.
El operador punto
Cada variable referencia objeto deberá referirse a algún objeto, a menos
que esta sea null, en tal caso no apunta a nada.
14
Introducción
Puede haber varias referencias al mismo objeto, y cada referencia a un
objeto especı́fico puede ser usada para llamar métodos en ese objeto. Si se
usa una variable referencia objeto para cambiar el estado del objeto, entonces
su estado cambia para todas las referencias a este. Este comportamiento se
da por el hecho de que todas las referencias apuntan al mismo objeto.
Uno de los usos primarios de una variable referencia objeto es para acceder
los miembros de la clase para este objeto, una instancia de su clase. Esto es,
una variable referencia objeto es útil para acceder los métodos y las variables
instancias asociadas con un objeto. Este acceso es realizado con el operador
punto (“.”). Se llama a un método asociado con un objeto usando el nombre
de la variable referencia, seguida por el operador punto y luego el nombre del
método y sus parámetros.
Si hay varios métodos definidos con el mismo nombre para el objeto,
entonces el sistema de ejecución Java usa el que empate en la cantidad de
parámetros y esté más cercano a sus tipos respectivos. Un nombre de método
combinado con la cantidad y los tipos de sus parámetros es llamado la firma
del método. Cuando se hace la llamada a cierto método se usa la firma para
determinar el método actual a usar. Considerar los siguientes ejemplos:
estufa.cocinaCena();
estufa.cocinaCena(comida);
estufa.cocinaCena(comida,temporada);
Cada una de las llamadas a métodos se refiere a un método diferente
con el mismo nombre definido en la clase a la cual pertenece estufa. Sin
embargo, la firma de un método en Java no incluye el tipo que regresa el
método, ası́ Java no permite dos métodos con la misma firma para regresar
tipos diferentes.
Variables de instancia
Las clases de Java pueden definir variables de instancia, las cuales son
llamadas también campos. Estas variables representan los datos asociados
con los objetos de una clase. Las variables de instancia deberán tener un
tipo, el cual puede ser un tipo base, tal como int, long, double, o un tipo
referencia, esto es, una clase, tal como un String, una interfaz, o un arreglo.
Dada una variable referencia v, la cual apunta a un objeto o, se puede
acceder cualquier variable de instancia para o que las reglas de acceso lo
permitan. Por ejemplo, las variables de instancia public son accesibles para
1.1 Clases, tipos, y objetos
15
todos. Usando el operador punto se puede acceder y poner el valor de tales
variables de instancia. Por ejemplo, si estudiante se refiere a un objeto
Estudiante que tiene variables de instancia públicas llamadas nombre y
edad, entonces las siguientes sentencias son permitidas:
estudiante.nombre = "Enrique";
estudiante.edad = 18;
Una referencia objeto no tiene que ser solamente una variable de referencia.
También puede ser cualquier expresión que devuelva una referencia objeto.
Modificadores de variables
Cuando se declara una variable de instancia se puede opcionalmente definir
un modificador de variable seguido por el tipo de la variable y el identificador
que será usado para la variable. Adicionalmente, se puede opcionalmente
asignar un valor inicial a la variable con el operador de asignación “=”. Las
reglas para un nombre de variable son las misma que para cualquier otro
identificador. El parámetro del tipo de la variable puede ser un tipo base,
indicando que guarda valores de ese tipo, o un nombre de clase, indicando
que guarda una referencia a un objeto de esa clase. El valor inicial opcional
que se podrı́a asignar a una variable de instancia deberá empatar el tipo de la
variable. La clase Duende tiene varias definiciones de variables de instancias,
mostrado en el listado 1.4. Las variables edad, magico, y estatura son tipos
base, la variable nombre es una referencia a una instancia de la clase String,
y la variable amigoDuende es una referencia a un objeto de la clase que se
define. Los valores constantes asociados con una clase deberán ser siempre
declarados como static y final, como lo es la “variable” ESTATURA MAX.
El alcance, o visibilidad, de variables de instancia puede ser cambiado
como se muestra en la siguiente tabla:
Modificador
public
protected
sin modificador
private
Niveles de acceso
Clase Paquete Subclase
S
S
S
S
S
S
S
S
N
S
N
N
Resto
S
N
N
N
Además de los modificadores de variable de alcance, también están los
siguientes modificadores:
16
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Introducción
public class Duende {
// variables de instancia
public String nombre ;
public int edad ;
public Duende amigoDuende ;
private boolean magico = false ;
protected double estatura = 85;
public static final int ESTATURA_MAX = 100;
// constructores
Duende ( String n , int e , Duende a , double est ) { // parametrizado compl
nombre = n ;
edad = e ;
amigoDuende = a ;
estatura = est ;
}
Duende () { // constructor por defecto
nombre = " Puck " ;
edad = 128;
amigoDuende = null ;
estatura = 95;
}
// m é todos
public static void hacerRey ( Duende d ) {
d . nombre = " Rey " + d . getNombreReal ();
d . magico = true ;
}
public void hacerRey () {
nombre = " Rey " + getNombreReal ();
magico = true ;
}
public boolean esMagico () { return magico ; }
public void setEstatura ( int nvaEst ) { estatura = nvaEst ; }
public String getNombre () { return " ¡ No te lo dir é ! " ; }
public String getNombreReal () { return nombre ; }
public void renombrar ( String s ) { nombre = s ; }
}
Listado 1.4: La clase Duende
1.2 Métodos
17
1. static. Se usa para declarar que una variable que está asociada con la
clase, no con instancias individuales de esa clase. Las varibles static
son usadas para guardar información global de la clase y existen aún si
no se han creado instancias de la clase.
2. final. Una variable de instancia final es una a la que se le debe asignar
un valor inicial, y entonces nunca puede se puede asignar un nuevo
valor. Si esta es un tipo base, entonces es una constante. Si una variable
objeto es final, entonces siempre se referirá al mismo objeto (aún si el
objeto cambia su estado interno).
1.1.3.
Tipo enum
Desde Java 5 se soportan tipos enumerados, llamados enum. Estos son
tipos a los cuales se les permite tomar solo valores dados de un conjunto
especificado de nombres. Estos pueden ser declarados dentro de una clase
como sigue:
[modificador] enum nombre { nombre valor0, nombre valor1, ...};
donde el modificador es opcional, o puede ser public, protected, o private.
El nombre es cualquier identificador legal de Java. Cada uno de los valores
identificadores es el nombre de un valor posible que variables de este tipo enum
puede tomar. Cada uno de estos nombre valor puede ser cualquier identificador
Java, pero por convención estos deberán ser palabras en mayúsculas.
El tipo enum definido se puede usar para definir otras variables, como si
fuera el nombre de una clase. Los tipos enum tiene unos cuantos métodos
incorporados, como el método valueOf() que devuelve el valor enum que
corresponde con la cadena dada. Se muestra en la clase DiaCurso, listado 1.5,
el uso de un tipo enum.
1.2.
Métodos
Los métodos en Java son conceptualmente similares a funciones y procedimientos en otro lenguajes de programación de alto nivel. Son lı́neas de código
que son llamadas para un objeto particular de alguna clase. Los métodos
pueden aceptar parámetros como argumentos y entonces el comportamiento
dependerá de estos y del objeto. Cada método es indicado en el cuerpo de
18
1
2
3
4
5
6
7
8
9
10
11
Introducción
public class DiaCurso {
public enum Dia { LUN , MAR , MIE , JUE , VIE , SAB , DOM };
public static void main ( String [] args ) {
Dia d = Dia . LUN ;
System . out . println ( " Inicialmente d es " + d );
d = Dia . VIE ;
System . out . println ( " Ahora es " + d );
Dia o = Dia . valueOf ( " VIE " );
System . out . println ( " ¿ Son d y t lo mismo ? " +( d == o ));
}
}
Listado 1.5: Un ejemplo del uso del tipo enum
alguna clase. La definición de un método tiene dos partes: la firma, la cual
define el nombre y los parámetros del método, y el cuerpo, el cual define lo
que el método hace.
Un método permite al programador mandar un mensaje a un objeto. La
firma del método indica como un mensaje deberá parecer y el cuerpo del
método indica que hará el objeto cuando reciba tal mensaje.
Declaración de métodos
La sintaxis para definir un método es:
[modificadores] tipo nombre(tipo0 parámetro0, tipo1 parámetro1, . . . ){
// cuerpo del método ...
}
Los modificadores incluyen los mismos tipos de los modificadores de alcance
usados para variables, como public, protected, y static, con significado
similar. El tipo de la declaración define el tipo devuelto por el método. El
nombre es cualquier identificador Java válido. La lista de parámetros y sus
tipos declaran variables locales que corresponden a valores que son pasados
como argumentos al método. Cada declaración tipo puede ser cualquier tipo
Java y cada parámetro es un identificador Java. La lista de parámetros y
sus tipos puede estar vacı́a, por lo cual no se pasan valores al método al ser
llamado. Las variables parámetro, al igual que las variables de instancia de la
clase, pueden ser usadas dentro del cuerpo del método. De igual forma, otros
métodos de esta clase pueden ser llamados desde el cuerpo de un método.
Cuando un método de una clase es llamado, se invoca con una instancia
particular de esa clase y puede cambiar el estado de ese objeto, excepto para
1.2 Métodos
19
un método static, el cual está asociado con la propia clase. Al invocar el
método renombrar() de la clase Duende, listado 1.4, cambia su nombre.
Modificadores de método
Al igual que las variables de instancia, los modificadores de método pueden
restringir el alcance de un método:
1. public. Cualquiera puede llamar al método.
2. protected. Sólo métodos del mismo paquete o de sus subclases pueden
llamar al método.
3. private. Sólo métodos de la misma clase pueden llamar.
4. Si ninguno de los anteriores modificadores es usado, el método es amigable, por lo que puede ser llamado por objetos de clases en el mismo
paquete.
Los modificadores anteriores podrı́an ser seguidos por los modificadores
adicionales:
1. abstract. Un método declarado como abstracto no tiene código. La
firma de tal método está seguida por un punto y coma sin cuerpo del
método. Por ejemplo:
public abstract void setEstatura( double nvaEst );
Los métodos abstractos pueden aparecer sólo en clases abstractas. Se
revisa su utilidad en la sección 2.4.
2. final. El método no puede ser anulado por una subclase.
3. static. El método está asociado con la propia clase, y no con alguna
instancia particular de la clase. Estos métodos pueden ser usados para
cambiar el estado de variables estáticas asociadas a la clase, siempre y
cuando estas no sean final.
20
Introducción
Tipos devueltos
Una definición de método deberá indicar el tipo de valor que el método
regresará. Si el método no regresa un valor, entonces se debe usar la palabra
reservada void. Si el tipo devuelto es void, el método es llamado un procedimiento; de otra forma, es llamado una función. Para regresar un valor en Java,
un método deberá usar la palabra reservada return y una expresión, la cual
debe empatar con el tipo regresado por el método. En el método esMagico()
de la clase Duende, listado 1.4 se devuelve un valor usando return. Tan
pronto como return es ejecutado, el método termina.
Las funciones Java pueden devolver sólo un valor. Para regresar valores
múltiples se deberán combinar todos los valores que se quieran devolver en un
objeto compuesto, cuyas variables de instancia incluirán todos los valores
que se quieran devolver, y luego regresar una referencia al objeto compuesto.
Además, se puede cambiar el estado interno de un objeto que es pasado a un
método como otra forma de “devolver” resultados múltiples.
Parámetros
Los parámetros de un método están definidos en una lista separada por
comas y encerrada entre paréntesis después del nombre del método. Un
parámetro consiste de dos partes, el tipo y el nombre. Si un método no tiene
parámetros, entonces solo un par de paréntesis vacı́os es usado.
Todos los parámetros en Java son pasados por valor, es decir, cuando se
pasa un parámetro a un método, una copia de ese parámetro es hecha para
ser usada dentro del cuerpo del método. El método puede cambiar la copia
pero no el original. Si se pasa una referencia objeto como un parámetro a un
método, entonces la referencia también es copiada. Cambiando la referencia
interna dentro de un método no cambiará la referencia que fue pasada. Por
ejemplo si se pasa una referencia Duende x a un método que llame a este
parámetro d, entonces el método puede cambiar la referencia d para que
apunte a un objeto diferente, pero x seguirá refiriéndose al mismo objeto. Por
supuesto, el método puede usar la referencia d para cambiar el estado interno
del objeto, y esto también cambiará al objeto de x, ya que ambos apuntan al
mismo objeto. Esto último se hace en el método de clase hacerRey(Duende)
de la clase Duende, listado 1.4.
1.2 Métodos
21
Constructores
Un constructor es un tipo especial de método usado para inicializar
objetos nuevos creados. Java tiene una forma particular para declarar el
constructor y una forma especial para llamar al constructor. La sintaxis para
declarar un constructor es:
[modificadores] nombre(tipo0 parámetro0, tipo1 parámetro1, . . . ){
// cuerpo del constructor ...
}
La sintaxis es casi la misma que para el método, excepto que el nombre del
constructor deberá ser el mismo nombre de la clase que construye, y además no
se indica que tipo devuelve, ya que su tipo devuelto es implı́citamente el mismo
que su nombre. Los modificadores del constructor siguen las mismas reglas
que los métodos, excepto que abstract, static y final no se permiten.
El constructor para una clase Camaron podrı́a ser el siguiente:
public Camaron(int p, String n) {
peso = p;
nombre = n;
}
Definición del constructor e invocación
El cuerpo de un constructor es como el cuerpo de un método, con el
siguiente par de excepciones menores. Una es el encadenado del constructor y
la otra es la prohibición del uso de la sentencia return.
Los constructores son invocados en una forma única: deberán ser llamados
con el operador new. Cuando se invoca una nueva instancia de la clase es
automáticamente creada y entonces su constructor es llamado para inicializar
sus variables de instancia y realizar otras tareas de configuración. Por ejemplo
considerar la siguiente llamada al constructor y declaracion de la variable
miCamaron:
Camaron miCamaron = new Camaron(50,"Popeye");
Una clase puede tener varios constructores, pero cada uno deberá tener
una firma diferente, es decir, cada uno deberá distinguirse por el tipo y la
cantidad de parámetros que toma.
22
Introducción
Método main()
Algunas clases en Java son diseñadas para ser usadas por otras clases,
otras son programas independientes. Las clases que definen programas independientes deberán contener un tipo especial de método, el método main().
Cuando se quiere ejecutar una programa Java independiente, se refiere el
nombre de la clase que define el programa precedido del siguiente comando:
java Acuario
En este caso, el sistema de ejecución Java busca una versión compilada de
la clase Acuario, y luego llama al método especial main en esa clase. Este
método se debe declarar como sigue:
public static void main(String[] args) {
// cuerpo del método main
}
Los argumentos pasados como los parámetros args al método main() son
los argumentos lı́nea de comandos dados cuando el programa es ejecutado.
La variable args es un arreglo de objetos String, es decir, una colección
de cadenas indizadas, con la primera cadena siendo args[0], la segunda
args[1], etc.
Bloques de sentencias y variables locales
El cuerpo de un método es un bloque de sentencias, el cual es una
secuencia de sentencias y declaraciones entre las llaves “{” y “}”. Los cuerpos
de los métodos y otros bloques de sentencias pueden tener bloques anidados
dentro de ellos. Los bloques de sentencias pueden contener declaraciones
de variables locales. Estas variables son declaradas dentro del cuerpo de
sentencias, generalmente al inicio, pero dentro de las llaves. Las variables
locales son similares a variables de instancia, pero sólo existen mientras el
bloque de sentencias está siendo ejecutado. Tan pronto como el flujo del
programa sale del bloque, todas las variables locales dentro de este no pueden
ser referenciadas más. Una variable local puede ser un tipo base, como int,
float, o double, o una referencia a una instancia de alguna clase. Las
sentencias y declaraciones son siempre terminadas con punto y coma.
Hay dos formas de declarar variables locales:
1.3 Expresiones
23
tipo nombre;
tipo nombre = valor inicial;
La primera declaración solo define el identificador, nombre, para ser de
tipo especificado. La segunda define el identificador, su tipo, e inicializa la
variable al valor inidicado. Enseguida están algunos ejemplos de declaraciones
de variables locales:
{
double r;
Punto p1 = new Punto(1, 2);
Punto p2 = new Punto(5, 6);
int i = 1024;
double e = 2.71828;
}
1.3.
Expresiones
Las variables y constantes son usadas en expresiones para definir nuevos
valores y para modificar variables. Las expresiones involucran el uso de literales,
variables, y operadores.
1.3.1.
Literales
Una literal es cualquier valor “constante” que puede ser usado en una
asignación u otra expresión. Java permite los siguientes tipos de literales:
La referencia objeto null. Es la única literal objeto y se permite usar
con cualquier tipo de referencia.
Las constantes booleanas true y false.
Entero. El tipo por defecto para un entero como 176, o -12 es int, el
cual es un entero de 4 bytes. Una literal entera long debe terminar con
“L” o “l”, como 176L, o -12l, y define un entero de 8 bytes.
Punto flotante. El tipo por defecto para un punto flotante, como 3.1416
y -432.1 es double. Para indicar una literal como float, esta debe
terminar con “F” o “f”. Literales punto flotante en notación cientı́fica
24
Introducción
también son permitidas, tal como 3.14E5 o -0.19e8. La “E” o “e” indica
por diez elevado a la potencia, del mismo modo que una calculadora.
Carácter. Las constantes carácter son tomadas del alfabeto Unicode.
Un carácter está definido como un sı́mbolo individual encerrado entre
comillas simples. Por ejemplo, ’a’ y ’?’ son caracteres constantes.
Además Java define las constantes carácter especial siguientes:
’\n’
’\b’
’\f’
’\’’
nueva lı́nea
retroceso
avance forma
comilla simple
’\t’
’\r’
’\\’
’\”’
tabulador
retorno
diagonal invertida
comilla doble
Cadena. Una cadena literal es una secuencia de caracteres encerrados
con comillas dobles. Por ejemplo, la siguiente es una literal de cadena:
"Monarcas permanecerá en primera división"
1.3.2.
Operadores
Las expresiones Java involucran componer literales y variables con operadores.
Operador de asignación
El operador de asignación es “=”. Es usado para asignar un valor, principalmente, a una variable de instancia o variable local. Su sintaxis es la
siguiente:
variable = expresión;
donde variable refiere a una variable que es permitida ser referenciada por el
bloque conteniendo la expresión. El valor de una operación es el valor de la
expresión que fue asignada. Por ejemplo, si i y j son ambas declaradas del
tipo int, es correcto tener una sentencia de asignación como:
i = j = 25;
La asignación anterior funciona ya que los operadores de asignación son
evaluados de derecha a izquierda.
1.3 Expresiones
25
Operadores aritméticos
Los siguientes son los operadores aritméticos binarios en Java:
+ adición
− sustracción
∗ multiplicación
/ división
% módulo
El operador módulo también es conocido como el operador “residuo”,
porque es el residuo sobrante después de realizar la división entera.
Java también proporciona un menos unario (−), el cual puede ser colocado
antes de la expresión aritmética para invertir su signo. Los paréntesis pueden
ser usados en cualquier expresión para definir el orden de evaluación. Java usa
reglas de precedencia de operadores para determinar el orden de evaluación
cuando los paréntesis no son usados.
Java proporciona los operadores incremento en uno (++) y decremento
en uno (−−). Si los operadores son usados antes de la variable, entonces uno
es agregado, o sustraido de, la variable y su valor es leido en la expresión. Si
es usado después de una variable, entonces el valor es primero leido y luego la
variable es incrementada o decrementada en uno. Por ejemplo, el fragmento
de código
int
int
int
int
int
i
j
k
m
n
=
=
=
=
=
20;
i++;
++i;
i--;
9 + i++;
asigna 20 a j, 22 a k, 22 a m, 30 a n, y deja i con 22.
Operadores lógicos
Java permite los operadores de comparación estándar entre números:
<
<=
>
>=
==
!=
menor que
menor que o igual a
mayor que
mayor que o igual a
igual a
no igual a
26
Introducción
Los operadores == y != también pueden ser usados con referencias objetos.
El tipo del resultado de una comparación es un boolean.
Los operadores que pueden operar con valores booleanos son los siguientes:
! negación (prefijo)
&& condicional y
|| condicional o
Los operadores lógicos && y || no evaluarán el segundo operador en
su expresión si no es necesario determinar el valor de la expresión. Esta
caracterı́stica es útil para construir expresiones booleanas donde primero se
prueba que una cierta condición se cumpla, como que una referencia no sea
nula, y luega se prueba una condición que podrı́a haber generado de otra
forma una condición de error si la primera prueba no hubiera sucedido.
Operadores a nivel bit
Java proporciona los siguientes operadores a nivel bit (bitwise) para
enteros:
~
&
|
^
<<
>>
>>>
complemento (operador unario prefijo)
y bitwise
o bitwise
o-exclusivo bitwise
desplazamiento a la izquierda
desplazamiento a la derecha sin signo
desplazamiento a la izquierda con signo
Operadores de asignación
Para expresiones del tipo
variable = variable operador expresión;
Java proporciona operadores de asignación que tienen efectos laterales de
operación. Estos operadores son de la forma
variable operador= expresión;
excepto si la variable contiene una expresión, como un ı́ndice arreglo, la
expresión es evaluada una sola vez. Ası́, el fragmento de código
1.3 Expresiones
27
a[5] = 20;
i = 5;
a[i++] += 3;
deja a[5] con el valor 23 e i con valor 6.
Concatenación de cadenas
Las cadenas pueden ser compuestas usando el operador de concatenación
(+).
Precedencia de operadores
Los operadores en Java tienen precedencia, la cual sirve para determinar
el orden en el cual las operaciones son hechas. Los operadores son evaluados
de acuerdo a la siguiente tabla si lo paréntesis no son usados para determinar
el orden de evaluación. Los operadores en la misma lı́nea son evaluados
de izquierda a derecha, excepto para la asignación y operaciones prefijas,
las cuales son evaluadas de derecha a izquierda, de acuerdo a la regla de
evaluación condicional para las operaciones lógicas Y y O. Sin paréntesis, los
operadores de mayor precedencia son evaluados antes que los operadores de
menor precedencia.
Tipo
Postfijo
Prefijo
Conversión
Mult./div.
add./subs.
desplazamiento
comparación
igualdad
y bitwise
xor bitwise
o bitwise
y
o
condicional
asignación
Sı́mbolos
exp++ exp−−
++exp −−exp +exp −exp ˜exp !exp
(tipo)exp
∗ / %
+−
<< >> >>>
< <= > >= instanceof
== ! =
&
ˆ
|
&&
||
? :
= += -= /= %= >>= <<= >>>= &= ˆ= |=
28
Introducción
1.3.3.
Conversiones tipo base
La conversión es una operación que permite cambiar el tipo de un valor.
Se puede tomar un valor de algún tipo y convertirlo en un valor equivalente de
otro tipo. Las conversiones son útiles para hacer ciertas operaciones númericas
y de entrada/salida.
La sintaxis para conversión de una expresión a un tipo deseado es:
(tipo) exp
donde tipo indica a que tipo se quiere convertir la expresión que se tiene. Hay
dos tipos fundamentales de conversiones que pueden ser hechas. Se puede
convertir respecto a tipos base numérica o con respecto a objetos. Se aborda
en este capı́tulo la conversión numérica y de cadenas, y la de objetos en la
sección 2.5.1.
Conversión ordinaria
Cuando se convierte de un tipo double a un int, se podrı́a perder precisión,
siendo el valor double truncado. Pero se puede convertir un int a un double
sin problema. Por ejemplo, considerar lo siguiente:
double
double
int i1
int i2
double
d1 = 1.2;
d2 = 3.9999;
= (int )d1; // i1 tiene valor 1
= (int )d2; // i2 tiene valor 3
d3 = (double )i2; // d3 tiene valor 3.0
Conversión con operadores
Ciertas operaciones binarias, como la división, darán resultados diferentes
dependiendo de los tipos de variables usadas. Se debe asegurarse que las
operaciones realizadas con los valores de los tipos pretendidos son las deseadas.
Por ejemplo, cuando se usan enteros, la división no considera la parte fraccional,
pero si cuando se usan números punto flotante, como se muestra en el siguiente
ejemplo:
int i1 = 4;
int i2 = 8;
dResultado = (double )i1 / (double )i2; // dResultado <-- 0.5
dResultado = i1 / i2;
// dResultado <-- 0.0
1.3 Expresiones
29
Cuando i1 e i2 fueron convertidos a dobles, se realizó una división para
números reales. Cuando no fueron convertidos, se realizó una división entera
y ası́ el resultado fue cero. Java no hace una conversión implı́cita para asignar
un valor int al resultado double.
Conversión implı́cita y autoempacado/desempacado
Hay casos donde Java realiza una conversión implı́cita, de acuerdo al
tipo de la variable de asignación, dado que no exista pérdida de precisión.
Por ejemplo:
int iResultado = 3;
double dResultado = 3.2;
dResultado = i/d; // dResultado <-- 0.9375. i convertido a double
iResultado = i/d; // pérdida de precisión -> error de compilación
iResultado = (int )i/d; // iResultado <-- 0, se pierde la fracción
Como Java no realiza conversiones implı́citas donde se pierda precisión, la
conversión explı́cita en la última lı́nea es requerida.
A partir de Java 5 hay un nuevo tipo de conversión explı́cita, para ir entre
objetos Number, como Integer y Float, a su tipo base relacionado, como
int y float. En cualquier momento que un objeto Number es esperado como
parámetro para un método, el correspondiente tipo base puede ser pasado. En
este caso, Java realizará una conversión implı́cita, llamada autoempacado,
la cual convertirá el tipo base a su correspondiente objeto Number. De igual
forma, cada vez que un tipo base es esperado en una expresión que involucra
una referencia Number, el objeto Number es cambiado al correspondiente tipo
base, en uno operación llamada desempacado. Se muestra enseguida código
que usa las nuevas conversiones explı́citas.
Integer a = 5; // autoempacado de un tipo base
int b = a + 3; // desempacado de un objeto Number
Una advertencia, del autoempacado y desempacado, es que si un referencia
Number es null, entonces intentando desempacar generará un error, llamado
NullPointerException. Otra advertencia, es con el operador ==, ya que es
usado para probar la igualdad de valores tipo base, ası́ como si dos referencias
Number están apuntando al mismo objeto. Ası́ que cuando se intenta probar
la igualdad, evitar las conversiones implı́citas hechas por el autoempacada
30
Introducción
y desempacado. Por último, las conversiones implı́citas, de cualquier tipo,
toman tiempo, por lo tanto se deben tratar de minimizar para no retardar la
ejecución del programa.
En cualquier momento que una cadena es concatenada con cualquier
objeto o tipo base, este es automáticamente convertido a cadena. Conversión
explı́cita de un objeto o tipo base a una cadena no está permitida. Por lo
tanto, las siguientes sentencias son incorrectas:
String s = (String) 4.5;
String t = "Valor = " + (String) 67;
String u = 22;
// incorrecto
// incorrecto
// incorrecto
Para realizar una conversión a cadena, se deberá usar el método toString()
apropiado o realizar una conversión implı́cita usando la operación de concatenación, como se muestra en las siguientes sentencias:
String s = "" + 4.5;
String t = "Valor = " + 67;
String u = Integer.toString(22);
1.4.
// correcto, pero estilo pobre
// correcto
// correcto
Control de flujo
El control de flujo en Java es parecido al de otros lenguajes de alto nivel.
Se revisa la estructura básica y la sintaxis del control de flujo en esta sección,
incluyendo regreso del método, sentencia if, sentencia switch, ciclos, y formas
restringidas de saltos, sentencia break y continue.
1.4.1.
Sentencias if y switch
Los condicionales dan una forma de evaluar una decisión y luego ejecutar
una sentencia o mas bloques de sentencias diferentes de acuerdo a la salida
de la decisión.
Sentencia if
La sintaxis de una sentencia if simple es:
if (expresión booleana)
sentencia true;
1.4 Control de flujo
31
else
sentencia false;
donde sentencia true y sentencia false tienen una sola sentencia, o bien, un
bloque de sentencias encerradas entre llaves. La parte else y su sentencia
asociada en una sentencia if es opcional. Se puede agrupar una cantidad de
pruebas booleanas, como sigue:
if (primera expresión booleana)
sentencia true;
else if (segunda expresión booleana)
segunda sentencia true;
else
sentencia false;
Si la primera expresión booleana es falsa, la segunda expresión boolena
será probada, y ası́ sucesivamente. Una sentencia if puede tener una cantidad
arbitraria de partes else if. Para estar seguro cuando se definan sentencias
if complicadas, usar llaves para encerrar todas las sentencias de los cuerpos.
Por ejemplo, la siguiente es una sentencia correcta if.
if (nivelAgua < 20) {
irAClase();
regresarACasa();
}
else if (nivelAgua < 50) {
salirEnLancha();
recogerTrampas();
}
else
quedarseEnCasa();
Sentencia switch
Java proporciona para control de flujo de valores múltiples la sentencia
switch, la cual es útil con tipos enum. El siguiente es un ejemplo la cual usa
una variable d del tipo enum Dia del listado 1.5 de la sección 1.1.3.
switch (d) {
case LUN:
32
Introducción
System.out.println("Exito");
break ;
case MAR:
System.out.println("Triunfo");
break ;
case MIE:
System.out.println("Fe");
break ;
case JUE:
System.out.println("Paz");
break ;
case VIE:
System.out.println("Amor");
break ;
default :
System.out.println("Salud");
}
La sentencia switch evalúa un entero o enum y hace que el control de
flujo salte a la localidad del código etiquetado con el valor de esta expresión.
Si no hay una etiqueta que empate, entonces del flujo del programa salta a la
localidad etiquetada como default. Este es el único salto explı́cito hecho por
la sentencia switch, sin embargo, el control de flujo “pasa” a otros casos si el
código para cada caso no es terminado con una sentencia break, la cual hace
que el control de flujo pase a la siguiente sentencia después de switch.
1.4.2.
Ciclos
Otro mecanismo importante del control de flujo en un lenguaje de programación es la repetición. Java tiene tres tipos de ciclos.
Ciclos while
El tipo de ciclo más simple es el ciclo while. Este ciclo prueba que una
cierta condición se cumpla y realizará el cuerpo del ciclo cada vez que la
condición sea evaluada a true. La sintaxis para probar una condición antes
que el cuerpo del ciclo sea ejecutado es:
while (condición)
1.4 Control de flujo
33
sentencia ciclo;
Al inicio de cada iteración, el ciclo prueba la expresión y luego ejecuta
el cuerpo del ciclo, sólo si la expresion booleana evalúa a true. La sentencia
del cuerpo del ciclo también puede ser un bloque de sentencias y la condición
deberá ser una expresión booleana.
Considerar por ejemplo, un duende que intenta regar todas sus zanahorias
en su huerta, lo cual él hará mientras su regadera no esté vacı́a. Como su
regadera podrı́a estar vacı́a, se podrı́a escribir el código que haga esta tarea
como:
public void regarZanahorias() {
Zanahoria actual = huerta.encontrarSigZanahoria();
while (!regadera.estáVacı́a()) {
regar(actual,regadera);
actual = huerta.encontrarSigZanahoria():
}
}
Ciclos for
Otro tipo de ciclo es el ciclo for. Los ciclos for, en su forma más simple,
proporciona repetición de código para una determinada cantidad de veces,
pero se pueden hacer otras tareas también. La funcionalidad de un ciclo for
es flexible, gracias a que está dividido en cuatro secciones: la inicialización, la
condición, el incremento, y el cuerpo.
La sintaxis para un ciclo for es:
for (inicialización; condición; incremento)
sentencia ciclo;
donde las secciones inicialización, condición, e incremento pueden estar vacı́as.
En la sección de inicialización, se puede declarar una variable ı́ndice que
sólo existe en el alcance del ciclo for. Por ejemplo, si se quiere un ciclo que
indice en un contador, y no se tiene necesidad de la variable contador fuera
del ciclo for, entonces declarando algo como lo siguiente
for (int contador=0; condición; incremento)
sentencia ciclo;
34
Introducción
declarará una variable contador cuyo alcance es sólo el cuerpo del ciclo.
En la sección condición, se indica la condición para repetir el ciclo. Esta
deberá ser una expresión booleana. El cuerpo del ciclo for será ejecutado
cada vez que la condición sea true cuando sea evaluado al inicio de una
iteración posible. Tan pronto como la condición evalúe a false, entonces el
cuerpo del ciclo ya no es ejecutado, y la ejecución del programa continúa con
la siguiente sentencia después del ciclo for.
En la sección incremento, se declara la sentencia de incremento para el
ciclo. Esta deberá ser cualquier sentencia legal permitida para flexibilidad en
la codificación. La sintaxis de un ciclo for es equivalente a:
inicialización;
while (condición) {
sentencia ciclo;
incremento;
}
excepto que en Java, un ciclo while no puede tener una condición booleana
vacı́a, mientras en un ciclo for está permitido. El siguiente ejemplo muestra
un ciclo for
public void comerManzanas(Manzanas manzanas) {
int cantManzanas = manzanas.getCantidad();
for (int x=0; x<cantManzanas; ++x) {
comerManzana(manzanas.getManzana(x));
tirarCorazón();
}
}
En el ejemplo anterior, la variable de ciclo x fue declarada en int x =
0. Antes de cada iteración, el ciclo prueba la condición x<cantManzanas y
ejecuta el cuerpo del ciclo solo si esto es verdadero. Al final de cada iteración
el ciclo usa la sentencia ++x para incrementar la variable de ciclo antes de
probar nuevamente la condición.
Ciclos do...while
El ciclo do...while prueba una condición después del cuerpo del ciclo,
a diferencia de los ciclos anteriores, los cuales prueban una condición antes
1.4 Control de flujo
35
de realizar una iteración en el cuerpo del ciclo. La sintaxis para el ciclo
do...while es:
do
sentencia ciclo;
while(expr booleana);
La sentencia del cuerpo del ciclo puede ser una sola sentencia o un bloque
de sentencias, y la condición debe ser una expresión booleana. En este ciclo
se repite el cuerpo del ciclo tanto como la condición sea verdadera cada vez
que es evaluada.
Por ejemplo, considerar que se quiere pedir al usuario una entrada y luego
hacer algo útil con esa entrada. Una condición posible, en este caso, para salir
del ciclo es cuando el usuario ingresa una cadena vacı́a. Sin embargo, aún en
este caso, se podrı́a querer manejar la entrada e informar al usuario que se
quiere salir. El siguiente ejemplo ilustra ese caso:
public void getEntradaUsuario() {
String entrada;
do {
entrada = getEntradaString();
manejarEntrada(entrada);
} while (entrada.length()>0);
}
Observar en el ejemplo anterior que la condición de salida es cuando la
condición es no verdadera.
1.4.3.
Sentencias explı́citas de control de flujo
Java también proporciona sentencias que permiten explı́citamente el cambio en el flujo de un programa.
Regresar de un método
Si un método está declarado con un tipo de retorno void, entonces el flujo
regresa cuando alcanza la última lı́nea de código en el método, o cuando se
encuentra una sentencia return sin argumento. Sin embargo, si un método
está declarado con un tipo de retorno, el método es una función y este
deberá salir regresando el valor de la función como un argumento a una
36
Introducción
sentencia return. El siguiente ejemplo correcto ilustra el regreso de una
función:
// Revisar por un cumplea~
nos particular
public boolean revisaCumple(int fecha) {
if (fecha == Cumples.CUMPLE JUAN) {
return true ;
}
return false ;
}
Se sigue que la sentencia return deberá ser la última sentencia ejecutada
en una función, ya que el resto del código nunca será ejecutado.
Observar que hay una diferencia significativa entre una sentencia que es
la última lı́nea de código ejecutada en un método y la última lı́nea de código
en el método. En el ejemplo anterior, la lı́nea return true; no es la última
lı́nea de código escrita en la función, pero podrı́a ser la última lı́nea que es
ejecutada cuando la condición involucrando a fecha sea true. Tal sentencia
explı́citamente interrumpe el flujo en el método.
Sentencia break
El uso tı́pico de la sentencia break tiene la siguiente sintaxis simple:
break;
Se usa break para salir del cuerpo de la sentencia switch, for, while,
o do...while más interna. Cuando es ejecutado break, el flujo pasa a la
siguiente sentencia después del cuerpo del ciclo o switch que contiene el
break.
La sentencia break también puede ser usada en una forma etiquetada
para salir del ciclo más externo o sentencia switch. La sintaxis es:
break etiqueta;
donde etiqueta es un identificador Java que es usado para etiquetar un ciclo
o sentencia switch. Tal etiqueta puede aparecer una sola vez al inicio de la
declaración de un ciclo.
Se muestra el uso de una etiqueta con una sentencia break en el siguiente
ejemplo:
1.5 Arreglos
37
public static boolean tieneEntradaCero( int [][] a) {
boolean banderaEnc = false ;
busquedaCero:
for (int i=0; i<a.length; ++i)
for (int j=0; j<a[i].length; ++j)
if (a[i][j] == 0) {
banderaEnc = true ;
break busquedaCero;
}
return banderaEnc;
}
Sentencia continue
La sentencia continue también cambia explı́citamente el flujo en un
programa, la cual tiene la siguiente sintaxis:
continue [etiqueta];
donde etiqueta es un identificador opcional Java que es usado para etiquetar
un ciclo. Esta sentencia sólo puede ser usada adentro de ciclos. La sentencia
continue causa que la ejecución salte sobre los pasos restantes del cuerpo del
ciclo en la iteración actual, pero el ciclo continúa si su condición es satisfecha.
1.5.
Arreglos
Una tarea de programación común es manejar un grupo numerado de
objetos relacionados. Por ejemplo, se podrı́a querer que un videojuego maneje
las diez puntuaciones mejores del juego. En vez de usar diez variables diferentes
para esa tarea, se podrı́a preferir usar un sólo nombre para el grupo y usar
un ı́ndice numérico para referirse a cada unna de las puntucaciones. De igual
forma, se podrı́a querer que un sistema de información médico maneje los
pacientes asignados a camas en un cierto hospital. De nueva cuenta no se
tienen que introducir 200 variables sólo por que el hospital tenga 200 camas.
En situaciones como las anteriores, se puede ahorrar esfuerzo de programación usando un arreglo, el cual es una colección numerada de variables todas
ellas con el mismo nombre. Cada variable, o celda, en un arreglo tiene un
38
Introducción
ı́ndice, el cual se refiere de forma única al valor guardado en esa celda. Las
celdas del arreglo a están numeradas por 0, 1, 2, etc. Se muestra enseguida
un arreglo de las diez puntuaciones más altas para un vı́deo juego.
987 876
0
1
Puntuaciones mejores
765 654 543 432 321 210 109
2
3
4
5
6
7
8
ı́ndices
98
9
Esta organización es útil, ya que permite hacer cálculos interesantes. En
el siguiente ejemplo, listado 1.6, el método, de la clase DemoArreglos suma
todos los números en un arreglo de enteros:
1
2
3
4
5
6
7
/* * Agrega todos los n ú meros en un arreglo de enteros */
public static int suma ( int [] a ) {
int total = 0;
for ( int i =0; i < a . length ; ++ i )
total += a [ i ];
return total
}
Listado 1.6: Método para sumar todos los elementos de un arreglo de enteros
En este ejemplo se usa una caracterı́stica importante de Java para los
arreglos, el tamaño de celdas que un arreglo guarda, es decir, su tamaño
(length). En Java, un arreglo es un tipo especial de objeto donde el tamaño
del arreglo es guardado en una variable de instancia, length, la cual se accede
de la siguiente forma:
nombre arreglo.length
Las celdas de un arreglo a están numeradas como 0, 1, 2, y ası́ hasta
a.length-1.
Elementos y capacidades del arreglo
Cada objeto guardado en un arreglo es llamado un elemento del arreglo.
Elemento número 0 es a[0], elemento número 1 es a[1], y ası́ sucesivamente.
Como el tamaño de un arreglo determina la cantidad máxima de cosas que
pueden ser guardadas en el arreglo, en ocasiones se refiere el tamaño de un
arreglo como su capacidad. Enseguida se muestra otro uso simple de un
arreglo en el listado 1.7, clase DemoArreglos, el cual cuenta la cantidad de
veces que cierto número aparece en un arreglo:
1.5 Arreglos
1
2
3
4
5
6
7
8
39
/* * Cuenta la cantidad de veces que un entero est á en el arreglo . */
public static int e nc u en tr aC u en ta ( int [] a , int k ) {
int cuenta = 0;
for ( int i =0; i < a . length ; ++ i )
if ( a [ i ]== k ) // revisar si el elemento actual es igual a k
++ cuenta ;
return cuenta ;
}
Listado 1.7: Método para contar las veces que se encuentra k en el arreglo.
Error fuera de los lı́mites
Es un descuido serio intentar indizar un arreglo a con un número fuera del
rango de 0 a a.length-1. Se dice que tal referencia está fuera de los lı́mites.
Las referencias fuera de los lı́mites han sido usadas numerosas ocasiones por
los hackers usando un método llamado el ataque de desbordamiento del búfer
para comprometer la seguridad del sistema de cómputo, escrito en lenguajes
que no son Java. Como medida de seguridad, los ı́ndices de los arreglos son
siempre revisados en Java para que no estén fuera de los lı́mites. En el caso
de que un ı́ndice se encuentre fuera de los lı́mites, el ambiente de ejecución
Java indica una condición de error, indicada por la clase ArrayIndexOutOfBoundsException. Mediante esta revisión Java evita problemas de seguridad
donde otros lenguajes deben hacer frente.
Se pueden evitar errores fuera de los lı́mites asegurándose que siempre
se indiza en un arreglo, a, usando un valor entero entre cero y a.length.
Una forma breve de lograrlo es usando cuidadosamente la caracterı́stica de
terminación temprana de los operadores lógicos de Java. Por ejemplo, una
sentencia como la siguiente nunca generará un error fuera de los lı́mites:
if ((i>=0) && (i<a.length) && (a[i]>2) )
x = a[i];
para la comparación a[i]>2 sólo será hecha si las primeras dos comparaciones
son exitosas.
1.5.1.
Declaración de arreglos
Una forma para declarar e inicializar un arreglo es la siguiente:
tipo elemento[] nombre arreglo = {val 0, val 1, ...};
40
Introducción
El tipo elemento puede ser cualquier tipo base Java o nombre de clase, y
nombre arreglo es un identificador Java válido. Los valores iniciales deberán
ser del mismo tipo que el del arreglo. Por ejemplo, la siguiente declaración de
un arreglo que es inicializado con los primeros diez números primos:
int [] primos = {2,3,5,7,11,13,17,19,23,29};
Para declarar una variable arreglo sin inicializarlo, se hace como sigue:
tipo elemento[] nombre arreglo;
Una vez que se ha declarado un arreglo de la forma anterior, se puede
crear la colección de celdas para el arreglo usando la siguiente sintaxis:
new tipo elemento[tamaño]
donde tamaño es un entero positivo indicando el tamaño del arreglo creado.
Usualmente esta expresión aparece en una sentencia de asignación con el
nombre del arreglo en lado izquierdo del operador de asignación. Los arreglos
creados son inicializados con ceros si el tipo del arreglo es un tipo base
numérico. Los arreglos de objetos son inicializados a referencias null. En el
siguiente código se define una variable de arreglo llamada a, y después se le
asigna un arreglo de diez celdas, cada una de tipo double, siendo después los
elementos inicializados a 1.0:
double [] a;
a = new double [10];
for (int k=0; k<a.length; ++k)
a[k] = 1.0;
1.5.2.
Arreglos como objetos
Los arreglos en Java son un tipo especial de objeto. Por esta razón se
puede usar el operador new para crear una nueva instancia de un arreglo. El
arreglo puede ser usado como cualquier otro objeto general, pero se tiene
una sintaxis especial, usando los corchetes, para referirse a sus miembros.
Como un arreglo sea un objeto, el nombre de un arreglo es una referencia
a la dirección en memoria donde está el arreglo guardado, de igual manera,
no hay nada especial en usar el operador punto y la variable de instancia,
length, para referirse al tamaño del arreglo.
Como los arreglos son objetos, entonces cuando se tiene una sentencia
como la siguiente, donde a y b son referencias a arreglos del mismo tipo
1.6 Entrada y salida
41
b = a;
significa que a y b se refieren al mismo objeto. Ası́ que cuando se tenga una
sentencia como
b[3] = 5;
entonces también se tiene que a[3] guarda un 5.
Clonación de un arreglo
Cuando se quiere crear una copia exacta del arreglo, a, y asignar ese
arreglo al arreglo variable, b, se podrı́a escribir
b = a.clone();
el cual copia el contenido de todas las celdas de a en un arreglo nuevo y lo
asigna b para que apunte a este. El método clone() es un método incorporado
de cada objeto Java. Si antes de la asignación b[3] = 5; se hubiera clonado
el arreglo a en b, entonces los elementos en a y b para el ı́ndice 3 podrı́an ser
diferentes, ya que cada referencia apunta a su propio arreglo.
Se debe observar que las celdas de un arreglo son copiadas cuando este es
clonado. Si las celdas son un tipo base, como int, sus valores son copiados.
Pero si las celdas son referencias objetos, entonces estas referencias son
copiadas. Esto significa que hay dos formas para referirse a un objeto.
1.6.
Entrada y salida
Java tiene un conjunto de clases y métodos para la entrada y salida en
un programa. Hay clases para el diseño de interfaces gráficas de usuario, con
ventanas pop-up (emergentes) y menús, al igual que métodos para mostrar
e introducir texto y números. También hay métodos para manejar objetos
gráficos, imágenes, sonidos, páginas web, y eventos de ratón. Varios de estos
método de entrada y salida pueden ser usados en programas autónomos o en
applets.
La entrada y salida simple se da con la ventana consola Java. Dependiendo
del ambiente Java donde se esté usando, la ventana es una ventana emergente
especial que puede ser usada para mostrar e introducir texto, o una ventana
usada para introducir comandos al sistema operativo, estas ventanas son
referidas como ventanas shell, DOS o terminal.
42
Introducción
Métodos de salida simple
Java proporciona un objeto estático incorporado, llamado System.out,
que realiza salida al dispositivo de la “salida estándar”, siendo este la ventana
consola Java. El objeto System.out es una instancia de la clase java.io.PrintStream. Esta clase define métodos para un flujo de salida de memoria,
donde los caracteres son puestos en una localidad temporal, llamada buffer,
el cual después es vaciado cuando la ventana consola está lista para imprimir
los caracteres.
En particular, la clase java.io.PrintStream tiene los siguientes métodos
para realizar salida simple (se usa tipo base para referirse a cualquiera de los
tipos base posibles):
print(Object o) Imprime el objeto o usando su método
toString().
print(String s) Imprime la cadena s.
print(tipo base t) Imprime el valor del tipo base t.
println(String s) Imprime la cadena s, seguida del carácter
nueva lı́nea.
Cuando el siguiente fragmento de código:
System.out.print("Valores Java: ");
System.out.print(3.1416);
System.out.print(’,’);
System.out.print(15);
System.out.print(" (double,char,int).");
es ejecutado, mostrará lo siguiente en la ventana consola Java:
Valores Java: 3.1416,15 (double,char,int).
Entrada simple con la clase java.util.Scanner
Para realizar entrada desde la ventana consola Java se emplea el objeto
especial System.in. La entrda viene del dispositivo de “entrada estándar”,
el cual por defecto es el teclado de la computadora replicando sus teclas
presionadas en la consola. El objeto System.in está asociado con el dispositivo
de entrada estándar. Una forma simple de leer la entrada de este objeto es
usándolo para crear un objeto Scanner de la siguiente forma:
new Scanner(System.in)
1.6 Entrada y salida
43
La clase Scanner tiene un conjunto de métodos adecuados que leen desde
el flujo de entrada dado. En la clase EjemploEntrada, listado 1.8, se usa un
objeto Scanner para procesar la entrada:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java . io . IOException ;
import java . util . Scanner ;
public class EjemploE ntrada {
public static void main ( String [] args ) throws IOException {
Scanner s = new Scanner ( System . in );
System . out . print ( " Ingresa tu edad en a ~
n os : " );
int edad = s . nextInt ();
System . out . print ( " Ingresa la frecuencia card ı́ aca m á xima : " );
double frecuencia = s . nextDouble ();
double qg = ( frecuencia - edad )*0.65;
System . out . println ( " Su meta de ı́ ndice de quema de grasa es : " +
qg + " . " );
}
}
Listado 1.8: La clase EjemploEntrada.
Cuando se ejecuta, la aplicación podrı́a generar lo siguiente en la consola
Java:
Ingresa tu edad en a~
nos: 48
Ingresa la frecuencia cardı́aca máxima: 170
Su meta de ı́ndice de quema de grasa es: 79.3.
Métodos de la clase java.util.Scanner
La clase Scanner lee el flujo de entrada y lo divide en tokens, los cuales
son cadenas de caracteres separados por delimitadores. Un delimitador es una
cadena de separación especial, y el delimitador por defecto es el espacio blanco.
De esta forma, los tokens son separados por cadenas de espacios, tabuladores,
y nuevas lı́neas, por defecto. Los tokens pueden ser leı́dos inmediatamente
como cadenas o un objeto Scanner puede convertir un token a un tipo base,
siempre que token esté con la sintaxis correcta. La clase Scanner incluye los
siguientes métodos para trabajar con tokens:
44
Introducción
hasNext() Devuelve true si y sólo si hay otro token
en el flujo de entrada.
next() Devuelve la siguiente cadena token en un
flujo de entrada; se genera un error si ya
no quedan más tokens.
hasNextTipo () Devuelve true si y solo si hay otro token
en el flujo de entrada y este puede ser interpretado como el Tipo base correspondiente,
siendo tipo Boolean, Byte, Double, Float,
Int, Long, Short.
nextTipo () Devuelve el siguiente token en el flujo de
entrada, devuelto como el correspondiente
Tipo base; se genera un error si ya no quedan más tokens o si el siguiente token no
puede ser interpretado como un tipo base
correspondiente a Tipo.
Adicionalmente, los objetos Scanner pueden procesar la entrada lı́nea por
lı́nea, ignorando los delimitadores, y también buscar patrones dentro de lı́neas.
Los métodos para procesar la entrada de esta forma incluyen los siguientes:
hasNextLine() Devuelve true si y solo si el flujo de entrada
tiene otra lı́nea de texto.
nextLine() Avanza la entrada pasando al final de la
lı́nea actual y regresando la entrada que fue
saltada.
findInLine(String s) Intenta encontrar una cadena que empate
el patrón s en la lı́nea actual. Si el patrón es
encontrado, este se devuelve y se avanza al
primer carácter después del apareamiento.
Si el patrón no es encontrado, se devuelve
null y no se avanza.
Algunos de los métodos mencionados se podrı́an usar como se muestra
enseguida para lograr que el usuario ingrese un entero:
Scanner entrada = new Scanner(System.in);
System.out.print("Favor de ingresar un entero: ");
while (!entrada.hasNextInt()) {
input.nextLine();
System.out.print("No se ingreso un entero. ");
1.7 Clases anidadas y paquetes
45
System.out.print("Por favor ingrese un entero: ");
}
int i = entrada.nextInt();
1.7.
Clases anidadas y paquetes
Java tiene una forma útil y general para la organización de clases en
programas. Cada clase pública autónoma definida en Java deberá estar en
archivo separado. El nombre del archivo es el nombre de la clase con la
extensión .java. Se describe enseguida dos maneras que Java tiene para
organizar múltiples clases en forma útil.
Clases anidadas
Java permite que definiciones de clases sean anidadas, o puestas, dentro
de las definiciones de otras clases. El principal uso para tales clases anidadas
es para definir una clase que está fuertemente afiliada con otra clase. Por
ejemplo, definir la clase cursor como clase anidada en la definición de la clase
editor de texto mantiene a estas dos clases altamente relacionadas en el mismo
archivo. Más aún, permite que cada una de ellas acceda a los métodos no
públicos de la otra. Un punto técnico respecto a las clases anidadas es que
la clase anidada debe ser declarada como static. Esta declaración implica
que la clase anidada está asociada con la clase externa, no una instancia de
la clase externa, que es, un objeto especı́fico.
Paquetes
Un conjunto de clases, todas definidas en un subdirectorio común, pueden
ser un paquete Java. Cada archivo en un paquete inician con la lı́nea:
package nombre paquete;
El subdirectorio conteniendo el paquete deberá ser nombrado igual que el
paquete. Se puede también definir un paquete en sólo archivo que contenga
varias definiciones de clases, pero cuando este es compilado, todas las clases
serán compiladas en archivos separados en el mismo subdirectorio.
En Java, se pueden usar clases que están definidos en otros paquetes
prefijando los nombres de las clases con puntos que correspondan a las
estructuras de directorios de otros paquetes.
46
Introducción
public boolean temperatura(
TA.Medidas.Termometro termometro, int temp) {
//...
}
En la firma del método temperatura se usa una clase Termometro como
parámetro. Termometro está definido en el paquete TA en un subpaquete
llamado Medidas. Los puntos en TA.Medidas.Termometro corresponden directamente a la estructura de directorios en el paquete TA.
Para evitar todo el tecleado extra para referirse a una clase foránea del
paquete actual, en Java se puede usar la palabra reservada import para
incluir clases externas o paquetes enteros en el archivo actual. Para importar
clases individuales de un paquete particular, se pone lo siguiente al inicio del
archivo:
import nombrePaquete.nombreClase;
Por ejemplo, se podrı́a teclear
package Proyecto;
import TA.Medidas.Termometro;
import TA.Medidas.Escala;
al inicio de una clase del paquete Proyecto, para indicar que se están importando las clases llamadas TA.Medidas.Termometro y TA.Medidas.Escala.
También se puede importar un paquete entero, con la siguiente sintaxis:
import nombrePaquete.*;
Cualquiera de las dos formas de importación usadas permiten códigos más
breves, ası́ el método Temperatura() se podrı́a reescribir como:
import TA.Medidas.*;
public boolean temperatura(Termometro termometro, int temp) {
//...
}
En resumen, la estructura de un programa Java, permite tener variables
de instancia y métodos dentro de una clase, y clases dentro de un paquete.
Capı́tulo 2
Diseño orientado al objeto
2.1.
Metas, principios, y patrones
Los “actores” principales en el paradigma del diseño orientado al objetos
son llamados objetos. Un objeto viene de una clase, la cual es una especificación de los campos de datos, también llamados variables de instancia, que el
objeto contiene, al igual los métodos u operaciones que el objeto puede ejecutar. Cada clase presenta al mundo exterior una vista concisa y consistente de
los objetos que son instancias de esta clase, sin requerir ir en muchos detalles
innecesarios o dando otros accesos a las tareas internas de los objetos. Esta
vista computacional intenta llenar varias metas e incorporar varios principios
de diseño.
2.1.1.
Metas del diseño orientado a objetos
Las implementaciones de software deben lograr robustez, adaptabilidad, y
reutilización.
Robustez
Como buen programador se quiere desarrollar software que sea correcto,
es decir, que un programa dé la salida correcta para todas las entradas
anticipadas. Además, se quiere que el software sea robusto, esto es, capaz de
manejar entradas no esperadas las cuales no están explı́citamente definidas
para la aplicación. Por ejemplo, si un programa está esperando un número
punto flotante positivo, ya que representa el precio de un artı́culo, y en vez de
48
Diseño orientado al objeto
esto se da un valor negativo, entonces el programa se debe poder recuperar
de este error. En aplicaciones de seguridad crı́tica, donde un error de software
puede llevar a una lesión, o la pérdida de la vida, el software que no es robusto
podrı́a ser mortal. Un caso de este tipo se presentó entre 1985 y 1987 en
accidentes que involucraron a la máquina de radioterapia therac-25, donde
al menos seis pacientes recibieron sobredosis de radiación, y tres de estos
murieron como consecuencia directa. Una comisión investigadora concluyó que
la razón primaria deberı́a ser atribuida al mal diseño de software y malas
prácticas de desarrollo, y no explı́citamente a varios errores de codificación
que fueron encontradas.
Adaptabilidad
Las aplicaciones actuales de software, como los navegadores Web y los
motores de búsqueda de Internet, generalmente involucran programas grandes
que son usados por muchos años. Por lo tanto el software necesita ser capaz de
evolucionar en el tiempo en respuesta a condiciones cambiantes en su ambiente.
Ası́, otra meta importante de calidad del software es la adaptabilidad o
evolucionabilidad. Relacionado a este concepto es la portabilidad, la cual
es la habilidad del software para ejecutarse con cambios mı́nimos en hardware
y plataformas de sistemas operativos diferentes. El software escrito en Java
es portable.
Reutilización
Otra capacidad deseable del software es la reutilización, es decir, el mismo
código deberı́a ser utilizable como un componente de diferentes sistemas
para varias aplicaciones. El desarrollo de software de calidad puede ser una
actividad costosa, y su costo se puede recuperar si el software es diseñado de
manera que sea fácilmente reutilizado en aplicaciones futuras. La reutilización
deberá hacerse con cuidado, sin embargo, una de las mayores fuentes de errores
de software en la therac-25 se debió por el reuso inadecuado del software de
la therac-20, la cual no estaba orientada al objeto y no estaba diseñada para
la plataforma de hardware de la therac-25.
2.1 Metas, principios, y patrones
2.1.2.
49
Principios del diseño orientado al objeto
Para lograr las metas indicadas previamente se puede hacer con los principios de la aproximación orientada al objeto, los cuales son:
Abstracción
Encapsulación
Modularidad
Abstracción
La noción de abstracción es extraer de un sistema complejo sus partes
más fundamentales y describirlas de una forma precisa y simple. Describir
las partes de un sistema involucra nombrarlas y explicar su funcionalidad.
Aplicando el paradigma de abstracción al diseño de estructuras de datos da
lugar a tipos de datos abstractos o ADT (abstract data type). Un ADT
es un modelo matemático de una estructura de datos que especifica el tipo de
dato guardado, las operaciones soportadas, y los tipos de parámetros de las
operaciones. Un ADT indica qué puede realizar cada operación, pero no cómo
se hace. En Java, un ADT puede ser expresado por una interfaz, la cual es
una lista de declaraciones de métodos, donde cada método tiene un cuerpo
vacı́o. En la sección 2.4 se abordan las interfaces.
Un ADT es realizado por una estructura de datos concreta, la cual está modelada en Java por una clase. Una clase define los datos que son guardados y
las operaciones soportadas por los objetos que son instancias de la clase. Diferente a las interfaces, las clases indican cómo las operaciones son realizadas
en el cuerpo de cada método. Implementar una interfaz, por una clase,
es cuando la clase incluye todos los métodos declarados en la interfaz, dando
un cuerpo para estos. Sin embargo, una clase puede tener mas métodos que
los de la interfaz.
Encapsulación
El concepto de encapsulación es otro principio importante en el diseño
orientado al objeto, el cual establece que los diferentes componentes de un
sistema de software no deberán mostrar los detalles internos de sus respectivas
implementaciones. Una de las ventajas principales de la encapsulación es
que esta da libertad al programador para implementar los detalles de un
50
Diseño orientado al objeto
sistema. La única restricción para el programador es que mantenga la interfaz
abstracta que los externos ven.
Modularidad
Otro principio fundamental del diseño orientado al objeto es la modularidad. Los sistemas actuales de software generalmente consisten de varios
componentes diferentes que deben interactuar correctamente en orden para
que el sistema completo trabaje correctamente. Mantener estas interacciones
en orden requiere que estos componentes diferentes estén bien organizados.
En el diseño orientado al objeto, este enfoque de estructuración del código
se logra con el concepto de modularidad. La modularidad se refiere a un
principio de organización para el código, en el cual diferentes componentes de
un sistema de software son divididos en unidades funcionales separadas.
Organización jerárquica
La estructura impuesta por la modularidad ayuda a permitir la reutilización del software. Si los módulos de software son escritos en una forma
abstracta para resolver problemas generales, entonces los módulos pueden ser
reusados con instancias de estos mismos problemas generales que surgen en
otros contextos.
Por ejemplo, la definición estructural de un muro es la misma para cualquier
casa, siendo definido generalmente por cantidad de soportes, espaciamiento de
los soportes, etc. De esta forma, un constructor puede reusar su definiciones de
muro de una casa a otra. En la reutilización, algunas partes podrı́an requerir
ajustes, por ejemplo un muro en un edificio comercial podrı́a ser similar al de
una casa, pero el sistema eléctrico y el soporte podrı́an ser diferentes.
Una forma natural de organizar varios componentes estructurales de un
paquete de software es de modo jerárquico, la cual agrupa definiciones
abstractas similares por nivel, yendo de lo más especı́fico a lo más general
conforme se recorre hacia arriba la jerarquı́a. Un uso común de tales jerarquı́as
es con una gráfica organizacional, donde cada enlace hacia arriba puede
leerse como “es un”, como en “un rancho es una casa, y una casa es una
construcción”. Este tipo de jerarquı́a es útil en el diseño de software, porque
agrupa la funcionalidad común en el nivel más general, y vistas especializadas
del comportamiento como una extensión de la vista general.
2.1 Metas, principios, y patrones
2.1.3.
51
Patrones de diseño
Investigadores y profesionales de la computación han desarrollado una
variedad de conceptos organizacionales y metodologı́as para diseñar software
de calidad orientado al objeto para que sea conciso, correcto, y reutilizable.
El concepto de patrón de diseño es importante en el material tratado
en estas notas, ya que describe una solución para un problema de diseño
de software tı́pico. Un patrón es un modelo general para una solución que
puede ser aplicada en varias situaciones diferentes. Este describe los elementos
principales de una solución en una forma abstracta que puede ser especializada
para un problema particular. El patrón tiene un nombre para identificación,
un contexto para describir los escenarios donde puede ser aplicado, un modelo
para indicar como el modelo es aplicado, y un resultado que describe y analiza
lo que el patrón produce.
Se revisan varios patrones de diseño en este material, y se muestra como
pueden ser aplicados consistentemente a implementaciones de estructuras de
datos y algoritmos. Los patrones de diseño caen en dos grupos, patrones para
resolver problemas de diseño de algoritmos y para problemas de ingenierı́a de
software. Algunos de los patrones de diseño de algoritmos son:
Recursión
Amortización
Divide y vencerás
Recortar y buscar o, divide y vencerás
Fuerza bruta
Método voraz
Programación dinámica
Algunos de los patrones de diseño de ingenierı́a de software que serán
revisados son:
Posición
Adaptador
52
Diseño orientado al objeto
Iterador
Método modelo
Composición
Comparador
Decorador
2.2.
Herencia y polimorfismo
Para poder emplear las relaciones jerárquicas, las cuales son comunes en
proyectos de software, la aproximación de diseño orientada al objeto da varias
formas de reusar código.
2.2.1.
Herencia
El paradigma orientado al objeto da una estructura organizacional modular
y jerárquica para reusar código, mediante la técnica de herencia. Esta técnica
permite el diseño de clases generales que pueden ser especializadas a clases
más particulares, con las clases especializadas reutilizando el código de la clase
general. La clase general, la cual también es conocida como una clase base o
superclase, puede definir variables de instancia y métodos generales que son
aplicables la mayorı́a de las veces. Una clase que especializa, o extiende o
hereda de, una superclase no necesita dar nuevas implementaciones para los
métodos generales, para eso los hereda. Sólo deberı́a definir aquellos métodos
que son especializados para la subclase particular.
Ejemplo. Considerar una clase S que define objetos con un campo, x
entero, y tres métodos, a(), b(), y c() devolviendo diferentes tipos. Suponer
que se define una clase T que extiende a S e incluye un campo adicional, y
entero, y dos métodos, d() y e() devolviendo diferentes tipos. La clase T
hereda los miembros de la clase S. Se ilustra la relación entre la clase S y T en
un diagrama de Lenguaje Unificado de Modelado o UML (Unified Modeling
Language) en la figura 2.1. Cada rectángulo en el diagrama indica una clase,
poniendo en subrectángulos el nombre, los campos, y los métodos.
2.2 Herencia y polimorfismo
-x
+a
+b
+c (int
53
S
: int
: int
: int
n) : void
~
w
w
w
w
T
-y : int
+d : int
+e (int n) : void
Figura 2.1: Diagrama UML de la relación entre las clases S y T.
Creación y referencia de objetos
Cuando un objeto o es creado, memoria es asignada para sus campos de
datos, y estos campos son inicializados a valores especı́ficos. Generalmente,
se asocia un objeto nuevo o con una variable, la cual sirve como un “enlace”
al objeto o, y se dice que referencia o. Para acceder al objeto o se puede
solicitar la ejecución de algunos de los métodos de o, o mirar uno de los
campos de o. La forma primaria como un objeto p interactúa con otro objeto
o es mandando un “mensaje” a o usando alguno de los métodos de o. La
segunda forma como p puede interactuar con o es accediendo alguno de los
campos de o directamente, siempre y cuando o dé los permisos para ello.
Enlazado dinámico
Cuando un programa desea llamar un cierto método a() de algún objeto
o, este manda un mensaje a o, el cual se denota, usando la sintaxis operador
punto, como o.a(). Cuando se ejecuta la versión compilada del programa se le
indica al ambiente de ejecución que revise la clase T de o para indicar si la clase
T soporta un método a(), y si es ası́, lo ejecute. En la revisión, primero, se ve
si la clase T define un método a() para ejecutarlo, si no lo define, entonces se
revisa la superclase S de T. Si S lo define, entonces se ejecuta. Si S tampoco
define a(), entonces se repite la búsqueda en la superclase de S. La búsqueda
54
Diseño orientado al objeto
continúa hacia arriba en la jerarquı́a de clases hasta que se encuentra el método
a() y lo ejecuta, o hasta que se alcanza la clase raı́z, la clase Object, sin un
método a(), lo cual genera un error en tiempo de ejecución. El algoritmo que
procesa el mensaje o.a() para encontrar el método especı́fico invocado es
llamado el algoritmo de enlazado dinámico, o envı́o dinámico, el cual da
un mecanismo efectivo para encontar software reutilizable. También permite
otra técnica poderosa de la programación orientada al objeto, polimorfismo.
2.2.2.
Polimorfismo
Polimorfismo significa varias formas. En el contexto del diseño orientado
al objeto, se refiere a la habilidad de una variable objeto de tomar diferentes
formas. Java direcciona objetos usando variables referencia. La variable referencia o deberá definir cual clase de objetos se le permite referir, en términos
de alguna clase S. Pero esto implica que o pueda también referirse a cualquier
objeto perteneciente a una clase T que extienda S. Ahora suponer lo que
sucede cuando S define un método a() al igual que T. El algoritmo de enlazado
dinámico para la invocación del método siempre inicia su búsqueda desde
la clase más restrictiva que aplique. Cuando o refiere a un objeto de clase T,
entonces usará el método a de T cuando se pida por o.a(), y no el de S. Se
dice, en este caso, que T anula el método a() de S. Por otra parte, cuando
o se refiere a un objeto de clase S , se ejecutará el método a() de S para
o.a(). El polimorfismo es útil porque la llamada o.a() no necesita saber si
el objeto o se refiere a una instancia de T o S para lograr que el método a()
se ejecute correctamente. Ası́, la variable objeto o puede ser polimórfica,
dependiendo de la clase especı́fica de los objetos que sean referenciados. Con
esta funcionalidad se permite que una clase especializada T que extiende una
clase S, herede los métodos estándar de S, y redefina otros métodos de S para
considerar las propiedad particulares de objetos de T.
Java también permite una técnica útil relacionada al polimorfismo, llamada
sobrecarga de métodos. La sobrecarga se da cuando una clase T tiene métodos
múltiples con el mismo nombre, y cada uno con firma diferente. La firma de
un método es la combinación del nombre y el tipo y la cantidad de argumentos
que le son pasados. No importa que varios métodos en una clase tengan el
mismo nombre, ya que pueden diferenciarse por el compilador, dado que
estos tienen firmas diferentes. En los lenguajes que permiten sobrecarga de
métodos, el ambiente de ejecución determina cual método actual llamar para
un método especı́fico buscando hacia arriba en la jerarquı́a de clases para
2.2 Herencia y polimorfismo
55
encontrar el primer método con una firma que empate con el método llamado.
Por ejemplo, para una clase T, la cual define un método a() y extiende a
la clase U, qua a su vez define un método a(x,y). Si un objeto o de clase T
recibe el mensaje o.a(x,y), entonces esta es la versión de U del método a()
que es llamada. El polimorfismo verdadero aplica solo a métodos que tengan
la misma firma, pero que están definidos en clases diferentes.
Herencia, polimorfismo, y sobrecarga de métodos soportan el desarrollo de
sofware reutilizable. Se pueden definir clases que hereden variables y métodos
de instancia y luego se pueden definir variables y métodos de instancia nuevos
que traten los aspectos especiales de objetos de la nueva clase.
2.2.3.
Uso de herencia en Java
Hay dos formas básicas de usar herencia de clases en Java, la especialización y la extensión.
Especialización
Se realiza cuando se especializa una clase general a subclases particulares.
Tales subclases tienen una relación “es un(a)” a su superclase. Una subclase
hereda todos los métodos de la superclase. Para cada método heredado, si
ese método funciona correctamente no importando si está funcionando para
una especialización, no se requiere trabajo adicional. Por otra parte, si un
método general de la superclase no trabaja correctamente en la subclase,
entonces se podrı́a anular el método para lograr la funcionalidad correcta en
la subclase. Por ejemplo, se podrı́a tener una clase general, Perro, la cual
tiene métodos beber() y olfatear(). Especializando esta clase a Sabueso
quizás no requiera anular el método beber(), pero podrı́a requerirse anular
el método olfatear(), ya que un sabueso tiene más desarrollado su olfato.
Ası́, la clase Sabueso especializa los métodos de su superclase, Perro.
Extensión
En la extensión se usa herencia para reutilizar el código escrito para
métodos de la superclase, pero luego se agregan nuevos métodos en la subclase
para extender su funcionalidad. Por ejemplo, para la clase Perro, se podrı́a
crear una subclase, Xolo, la cual hereda los métodos de la superclase, paro
luego agregar un metodo nuevo, curiosidad(), ya que estos tienen un instinto
56
Diseño orientado al objeto
curioso que no está presente en los perros en general. Cuando se agrega el
método nuevo, se está extendiendo la funcionalidad del perro en general.
En Java, cada clase puede extender a una sola classe. Aún si una definición
de clase no hace uso explı́cito de la cláusula extends, esta todavı́a hereda
de la clase java.lang.Object. Por lo anterior, se dice que Java solo permite
herencia única entre clases.
Formas de anulación de métodos
En la declaración de una nueva clase, Java usa dos formas de anulación
de métodos, refinamiento y reemplazo. En el reemplazo, un método completamente sustituye el método de la superclase que está anulando. En Java,
todos los métodos regulares utilizan esta forma.
Sin embargo, en el refinamiento un método no reemplaza el método de su
superclase, pero en su lugar agrega código adicional al de la superclase. En
Java, todos los constructores utilizan la forma de refinamiento, un esquema
llamado, encadenamiento constructor. Es decir, un constructor inicia su
ejecución llamando un constructor de la superclase. Esta llamada puede ser
hecha explı́citamente o implı́citamente. Para llamar un constructor de la
superclase explı́citamente, se usa la palabra reservada super para referirse a
la superclase. Por ejemplo, super() llama el constructor de la superclase sin
argumentos. Sin embargo, si no se hace una llamada explı́cita en el cuerpo del
constructor, el compilador inserta automáticamente, como la primera lı́nea
del constructor, una llamada a super(). En resumen, los constructores usan
la forma refinamiento mientras los métodos usan reemplazo.
Palabra reservada this
En ocasiones, en una clase Java es conveniente referirse a la instancia
actual de esa clase. Java proporciona la palabra reservada, this, para tal
referencia. La referencia this es útil, por ejemplo, si se quisiera pasar el
objeto actual como parámetro a un método. Otra aplicación de this es para
referenciar un campo dentro del objeto actual que tenga un nombre que
colisione con el nombre del parámetro de una clase, o la variable definida en
el bloque actual, como se muestra en la clase ProbadorThis, listado 2.1.
Cuando ProbadorThis es ejecutado, se muestra lo siguiente:
La variable local perro = 8
El campo perro = 1
2.2 Herencia y polimorfismo
1
2
3
4
5
6
7
8
9
10
11
12
57
public class ProbadorThis {
public int perro = 1; // variable de instancia
public static void main ( String args []) {
ProbadorThis t = new ProbadorThis ();
t . golpear ();
}
public void golpear () {
int perro = 8; // ¡ un perro diferente !
System . out . println ( " La variable local perro = " + perro );
System . out . println ( " El campo perro = " + this . perro );
}
}
Listado 2.1: Programa de ejemplo que muestra el uso de la referencia this para
resolver la ambigüedad entre un campo del objeto actual y la variable local con el
mismo nombre
Ejemplo de herencia
Se consideran los siguientes ejemplos simples para mostrar los conceptos
previos de herencia y polimorfismo revisados previamente.
Se considera un conjunto de varias clases para generar y mostrar progresiones numéricas. Una progresión numérica es una secuencia de números, donde
cada uno depende de uno, o de más números previos. En una progresión
aritmética se determina el siguiente número por adición y en una progresión geométrica se determina el siguiente número por multiplicación. En
cualquier caso, una progresión requiere una forma para definir su primer valor
y también para identificar el valor actual.
Se inicia definiendo una clase, Progresion, mostrada en el listado 2.2, la
cual define los campos estándares y los métodos de una progresión numérica.
En particular, se definen los siguientes dos campos entero largo:
primero. Primer valor de la progresión;
actual. Valor actual de la progresión;
y los siguientes tres métodos:
primerValor(). Reinicia la progresión al primer valor y devuelve este.
siguienteValor(). Avanza la progresión al siguiente valor y lo devuelve.
58
Diseño orientado al objeto
mostrarProgresion(). Reinicia la progresión e imprime los primeros
n valores de la progresión.
El método mostrarProgresion() no tiene “salida” en el sentido de que no
regresa ningún valor, mientras ambos métodos primerValor() y siguienteValor() devuelven enteros largos. Entonces, primerValor() y siguienteValor() son funciones, y mostrarProgresion() es un procedimiento.
La clase Progresion también incluye un método Progresion(), el cual es
un constructor. Los constructores configuran todas las variables de instancia
al momento que un objeto de esta clase es creado. El objetivo de la clase
Progresion es ser una superclase desde la cual las clases especializadas
hereden, ası́ el constructor está codificado por lo que será incluido en los
constructores de cada clase que extienda la clase Progresion.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Progresion {
/* * Primer valor de la progresi ó n . */
protected long primero ;
/* * Valor actual de la progresi ó n . */
protected long actual ;
/* * Constructor por defecto . */
Progresion () {
actual = primero = 0;
}
/* * Reinciar la progresi ó n al primer valor .
*
* @return primer valor
*/
protected long primerValor () {
actual = primero ;
return actual ;
}
/* * Avanzar la progresi ó n al siguiente valor .
*
* @return siguiente valor de la progresi ó n
*/
protected long si guienteV alor () {
return ++ actual ; // siguiente valor por defecto
}
/* * Imprimir los primeros n valores de la progresi ó n
*
* @param n n ú mero de valores a imprimir
*/
public void m o s t r a r P r o g r e s i o n ( int n ) {
System . out . print ( primerValor ());
for ( int i = 2; i <= n ; i ++)
System . out . print ( " " + sigui enteValo r ());
System . out . println (); // salto del l ı́ nea
}
}
Listado 2.2: Clase progresión numérica general.
2.2 Herencia y polimorfismo
59
Una clase de progresión aritmética
Se considera ahora el diseño de la clase ProgresionAritmetica, la cual se
muestra en el listado 2.3. Esta clase define una progresión aritmética, donde
el siguiente valor es determinado agregando un incremento constante, inc, al
valor previo. ProgresionAritmetica hereda los campos primero y actual y
los métodos primerValor() y mostrarProgresion() de la clase Progresion.
Además esta agrega un campo nuevo, inc, para guardar el incremento, y dos
constructores para configurar el incremento. ProgresionAritmetica anula
el método siguienteValor() para calcular el siguiente valor de acuerdo a
una progresión aritmética.
El polimorfismo es usado en las clases señaladas previamente. Cuando una
referencia Progresion está apuntando a un objeto ProgresionAritmetica,
entonces los constructores y siguienteValor() de ProgresionAritmetica
serán usados. El polimorfismo está también disponible dentro de la versión heredada de mostrarProgresion(), ya que las llamadas a los métodos
primerValor() y siguienteValor() están implı́citas para el objeto actual,
siendo de la clase Progresion y de la clase ProgresionAritmetica respectivamente.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class P r o g r e s i o n A r i t m e t i c a extends Progresion {
/* * Incremento . */
protected long inc ;
// Se heredan las variables primero y actual de Progresion .
/* * Constructor por defecto que fija un incremento unitario . */
P r o g r e s i o n A r i t m e t i c a () {
this (1);
}
/* * Constructor parametrizado que proporciona el incremento . */
P r o g r e s i o n A r i t m e t i c a ( long incremento ) {
inc = incremento ;
}
/* * Avanza la progresi ó n agregando el incremento al valor actual .
*
* @return valor siguiente de la progresi ó n .
*/
protected long si guiente Valor () {
actual += inc ;
return actual ;
}
// Se heredan los m é todos primerValor () y m o s t r a r P r o g r e s i o n ( int ).
}
Listado 2.3: Clase para progresiones aritméticas que extiende a la clase Progresión
(listado 2.2).
60
Diseño orientado al objeto
Ejemplo de constructores y this
En la definición de la clase ProgresionAritmetica, se agregaron dos
constructores, uno por defecto, el cual no toma parámetros, y el otro con
un parámetro, el cual toma un parámetro entero como el incremento de la
progresión. El constructor por defecto llama al de un parámetro, usando
this y pasando un uno como el parámetro incremento. Los dos constructores
muestran sobrecarga de método, ya que un método es especificado por su
nombre, la clase del objeto que llama, y los tipos de argumentos que son
pasados a este. En este caso, la sobrecarga es para los constructores, un
constructor por defecto y un constructor parametrizado.
La llamada this(1), lı́nea 13, listado 2.3, al constructor parametrizado
como la primera sentencia del constructor por defecto lanza una excepción
a la regla encadenamiento constructor general revisada en la sección 2.2.3.
Es decir, siempre que la primera sentencia de un constructor C 0 llama otro
constructor C 00 de la misma clase usando la referencia this, el constructor de la
superclase no es implı́citamente llamado por C 0 . Observar que el constructor de
la superclase será eventualmente llamado en la cadena, ya sea explı́citamente
o implı́citamente.
Una clase de progresión geométrica
La clase ProgresionGeometrica, listado 2.4, incrementa y muestra una
progresión geométrica, donde el siguiente valor se calcula multiplicando el
valor previo por una base fija, base. Una progresión geométrica es parecida a
una progresión general, excepto en la forma como se determina el siguiente
valor. Por lo anterior, ProgresionGeometrica es declarada como una subclase de la clase Progresion. ProgresionGeometrica hereda los campos
primero y actual, y los métodos primerValor() y mostrarProgresion()
de Progresion.
2.2 Herencia y polimorfismo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
61
/* *
* Progresi ó n geom é trica
*/
class P r o g r e s i o n G e o m e t r i c a extends Progresion {
/* * Base . */
protected long base ;
// Se heredan las variables primero y actual
/* * Constructor por defecto poniendo base 2. */
P r o g r e s i o n G e o m e t r i c a () {
this (2);
}
/* * Constructror parametrizado que proporciona la base .
*
* @param b base de la progresi ó n .
*/
P r o g r e s i o n G e o m e t r i c a ( long b ) {
base = b ;
primero = 1;
actual = primero ;
}
/* * Avanza la progresi ó n multiplicando la base con el valor actual .
*
* @return siguiente valor de la progresi ó n
*/
protected long si guiente Valor () {
actual *= base ;
return actual ;
}
// Se heredan los m é todos primerValor () e i m p r i m i r P r o g r e s i o n ( int ).
}
Listado 2.4: Clase para progresiones geométricas.
Una clase de progresión Fibonacci
Se define la clase ProgresionFibonacci, listado 2.5, que representa la
progresión Fibonacci, donde el siguiente valor es la suma del valor actual
y del previo. Se usa un constructor parametrizado en la clase ProgresionFibonacci para dar una forma diferente de iniciar la progresión.
1
2
3
4
5
6
7
8
9
10
11
12
/* *
* Progresi ó n de Fibonacci
*/
class P r o g r e s i o n F i b o n a c c i extends Progresion {
/* * Valor previo . */
long prev ;
// Se heredan las variables primero y actual
/* * Constructor por defecto poniendo 0 y 1 como los primeros dos valores . */
P r o g r e s i o n F i b o n a c c i () {
this (0 , 1);
}
/* * Constructor param é trizado dando el primer valor y el segundo .
62
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Diseño orientado al objeto
*
* @param valor1 primer valor .
* @param valor2 segundo valor .
*/
P r o g r e s i o n F i b o n a c c i ( long valor1 , long valor2 ) {
primero = valor1 ;
prev = valor2 - valor1 ; // valor ficticio precediendo al primero
}
/* * Avanza la progresi ó n agregando el valor previo al valor actual .
*
* @return valor siguiente de la progresi ó n
*/
protected long si guienteV alor () {
long temp = prev ;
prev = actual ;
actual += temp ;
return actual ;
}
// Se heredan los m é todos primerValor () e i m p r i m i r P r o g r e s i o n ( int ).
}
Listado 2.5: Clase para la progresione Fibonacci.
La clase ProbarProgresion, listado 2.6, realiza una prueba simple de
cada una de las tres subclases anteriores. La variable prog es polimórfica
porque se asignan referencias objeto de la subclases Progresion.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* * Aplicaci ó n para las clases progresion */
class P r o b a r P r o g r e s i o n {
public static void main ( String [] args ) {
Progresion prog ;
// probar P r o g r e s i o n A r i t m e t i c a
System . out . println ( " La progresi ó n aritm é tica con el incremento por defecto : " );
prog = new P r o g r e s i o n A r i t m e t i c a ();
prog . m o s t r a r P r o g r e s i o n (10);
System . out . println ( " Progresi ó n aritm é tica con incremento 5: " );
prog = new P r o g r e s i o n A r i t m e t i c a (5);
prog . m o s t r a r P r o g r e s i o n (10);
// probar P r o g r e s i o n G e o m e t r i c a
System . out . println ( " Progresi ó n geom é trica con base por defecto : " );
prog = new P r o g r e s i o n G e o m e t r i c a ();
prog . m o s t r a r P r o g r e s i o n (10);
System . out . println ( " Progresi ó n geom é trica con base 3: " );
prog = new P r o g r e s i o n G e o m e t r i c a (3);
prog . m o s t r a r P r o g r e s i o n (10);
// probar P r o g r e s i o n F i b o n a c c i
System . out . println ( " Progresi ó n de Fibonacci con valores de inicio por defecto : " );
prog = new P r o g r e s i o n F i b o n a c c i ();
prog . m o s t r a r P r o g r e s i o n (10);
System . out . println ( " Progresion de Fibonacci iniciando con valores 4 y 6: " );
prog = new P r o g r e s i o n F i b o n a c c i (4 ,6);
prog . m o s t r a r P r o g r e s i o n (10);
}
}
Listado 2.6: Aplicación para probar las clases progresión.
2.3 Excepciones
63
Al ejecutar la aplicación ProbarProgresion genera la siguiente salida:
La progresión aritmética con el incremento por defecto:
0 1 2 3 4 5 6 7 8 9
Progresión aritmética con incremento 5:
0 5 10 15 20 25 30 35 40 45
Progresión geométrica con base por defecto:
1 2 4 8 16 32 64 128 256 512
Progresión geométrica con base 3:
1 3 9 27 81 243 729 2187 6561 19683
Progresión de Fibonacci con valores de inicio por defecto:
0 1 1 2 3 5 8 13 21 34
Progresion de Fibonacci iniciando con valores 4 y 6:
4 6 10 16 26 42 68 110 178 288
La clase Progresion, sus subclases, y el programa de prueba tienen una
cantidad de fallas. Un problema se presenta con la progresión geométrica y la
de Fibonacci porque crecen rápidamente, y no se realiza algo para manejar el
sobreflujo inevitable de los enteros largos involucrados. Por ejemplo, como
340 > 263 , una progresión geométrica con base tres desbordará un entero largo
después de 40 iteraciones. De igual modo, el 94o (nonagésimo cuarto) número
de Fibonacci es mayor que 264 ; ası́, la progresión de Fibonacci desbordará un
entero largo después de 94 iteraciones. Otro problema es que no se deberı́a
permitir iniciar una progresión Fibonacci con valores arbitrarios. Manejar
errores de entrada o de condición que suceden en la ejecución de un programa
Java requiere de un mecanismo que lo maneje. Este se comenta en la siguiente
sección.
2.3.
Excepciones
Las excepciones son eventos no esperados que ocurren en la ejecución
de un programa. Una excepción se puede deber a una condición de error o
una entrada no anticipada. Las excepciones, en Java, pueden ser vistas como
objetos.
2.3.1.
Lanzamiento de excepciones
Las excepciones son objetos que son lanzados por el código que encuentra
algún tipo de condición no esperada. También pueden ser lanzadas por el
64
Diseño orientado al objeto
ambiente de ejecución cuando se encuentra una condición no esperada, como
ejecución fuera de memoria. Una excepción lanzada es atrapada por otro
código que “maneja” la excepción de algún modo, o el programa es terminado
inesperadamente.
Las excepciones se originan cuando una pieza de código Java encuentra
algún tipo de problema durante la ejecución y lanza un objeto excepción. Es
conveniente dar un nombre descriptivo a la clase del objeto excepción. Por
ejemplo, si se intenta borrar el décimo elemento de una secuencia que tiene sólo
cinco elementos, el código podrı́a lanzar una ExcepcionViolacionLimites.
Esta acción podrı́a hacerse usando el siguiente fragmento de código:
if (indice >= A.length)
throw new
ExcepcionViolacionLimites("Índice "+indice+" inválido.");
Es conveniente instanciar un objeto excepción al momento que la excepción
tiene que ser lanzada. Ası́, una sentencia throw se usa como sigue:
throw new tipo excepción(param 0, param 1,. . . );
donde tipo excepción es el tipo de la excepción y los param i forman la
lista de parámetros para un constructor de este tipo de excepción.
Las excepciones son también lanzadas por el propio ambiente de ejecución
Java. Por ejemplo, si se tiene un arreglo de seis elementos y se pide el
noveno elemento, entonces la excepción ArrayIndexOutOfBoundsException
es lanzada por el sistema de ejecución Java.
La cláusula throws
Al declarar un método es apropiado indicar las excepciones que podrı́a
lanzar, debido a que tiene un propósito funcional y de cortesı́a. Le permite al
usuario saber que esperar y le indica al compilador las excepciones a las que
se debe preparar. Enseguida se muestra un ejemplo de tal definición en un
método:
public void irCompras() throws
ExcepcionListaComprasCorta, ExcepcionSinDinero {
// cuerpo del método ...
}
2.3 Excepciones
65
Indicando todas las excepciones que podrı́an ser lanzadas por un método,
se prepara a otros para que puedan manejar todos los casos excepcionales que
podrı́an surgir por usar este método. Otro beneficio de declarar excepciones es
que no se necesitan atrapar estas excepciones en el método. En ocasiones esto
es apropiado cuando otro código es el responsable del origen de la excepción.
Se muestra enseguida una excepción que es “pasada”:
public void estarListoParaClases() throws
ExcepcionListaComprasCorta, ExcepcionSinDinero {
irCompras();
/* no se tiene que intentar o atrapar las excepciones
que irCompras() podrı́a lanzar, ya que estarListoParaClase() las pasará de largo.
*/
hacerGalletas();
}
Un método puede declarar lanzar tantas excepciones como se quiera. Tal
lista puede simplificarse si todas las excepciones lanzadas son subclases de la
misma excepción. En tal caso, sólo se tiene que declarar que un método lanza
la superclase apropiada.
Tipos de lanzables
Java define las clases Exception y Error como subclases de Throwable.
Throwable indica que cualquier objeto podrı́a ser lanzado y atrapado. La
clase RuntimeException está definida como subclase de Exception. La clase
Error es usada para condiciones anormales en el ambiente de ejecución, como
agotamiento de memoria. Los errores pueden ser atrapados, pero quizás no se
deberı́a, porque estos avisan de problemas que no pueden ser manejados apropiadamente. Un mensaje de error o una repentina terminación de programa
está más allá de lo manejable. La clase Exception es la raı́z de la jerarquı́a
excepción. Excepciones especializadas, como ExcepcionViolacionLimites,
deberı́an ser definidas como subclases de Exception o de RuntimeException.
Las excepciones que no son subclases de RuntimeException deberán ser
declaradas en la cláusula throws de cualquier método que pueda lanzarla.
66
Diseño orientado al objeto
2.3.2.
Atrapar excepciones
Cuando una excepción es lanzada, esta deber ser atrapada o el programa
terminará. En cualquier método particular, una excepción puede ser pasada al
método llamador o puede ser atrapada en el método. Cuando una excepción
es atrapada, esta puede ser analizada y manejada. La forma para manejar las
excepciones es try (intentar) ejecutar alguna porción de código que podrı́a
lanzar una excepción. Si se lanza una excepción, entonces la excepción es
atrapada y el flujo de control pasa a un bloque catch predefinido con el
código para manejar la excepción.
La sintaxis para un bloque try...catch es:
try {
bloque principal de sentencias
} catch(tipo excepcion1 variable 1) {
bloque de sentencias 1
} catch(tipo excepcion2 variable 2) {
bloque de sentencias 2
...
} finally {
bloque de sentencias n
}
donde debe haber al menos un catch, siendo finally opcional. Cada tipo de sentencias i es el tipo de alguna excepción.
El ambiente de ejecución Java inicia ejecutando un bloque try...catch
con la ejecución del bloque de sentencias, bloque principal de sentencias.
Si esta ejecución no genera excepciones, entonces el flujo de control sigue
con la primera sentencia después de la última lı́nea del bloque completo
try...catch, a menos que se incluya la parte finally opcional.
Por otra parte, si el bloque, bloque principal de sentencias, genera
una excepción, entonces la ejecución en el bloque try...catch termina en
ese punto y la ejecución pasa al bloque catch cuyo tipo excepcion sea más
cercana a la excepción lanzada. La variable para esta sentencia catch refiere
al propio objeto excepción, el cual puede ser usado en el bloque de la sentencia
catch apareado. Una vez que la ejecución de ese bloque catch se completa,
el flujo de control es pasado al bloque finally opcional, si este existe, o a la
primera sentencia después de la última lı́nea del bloque try...catch. De otra
forma, si no hay bloque catch que empate la excepción lanzada, entonces el
2.3 Excepciones
67
control es pasado al bloque finally opcional, si existe, y luego la excepción
es lanzada de regreso al método llamador.
Considerar el siguiente ejemplo del fragmento de código:
int indice = Integer.MAX VALUE; // -1+2E31
try {
// El código podrı́a tener un problema ...
String aComprar = listaCompras[indice];
}
catch (ArrayIndexOutOfBoundsException e) {
System.out.println("Índice "+indice+" inválido.");
}
Si el código anterior no atrapa una excepción lanzada, entonces el flujo
de control saldrá inmediatamente del método y regresará al código que
llamó al método. En el método que hizo la llamada, el ambiente de ejecución
buscará nuevamente por un bloque catch. Si no lo hay, el flujo de control
pasará al que hizo la llamada para ir a ese método, y ası́ sucesivamente.
Eventualmente, si ningún código atrapa la excepción, el ambiente de ejecución,
el originador del flujo de control del programa, atrapará la excepción. En
este punto, un mensaje de error y la traza de la pila es mostrada en la salida
estándar y el programa es terminado.
El siguiente es un mensaje de error en tiempo de ejecución:
java.lang.NuIlPointerException: Returned a null locator
at java.awt.Component.handleEvent(Component.java:900)
at java.awt.Component.postEvent(Component.java:838)
at java.awt.Component.postEvent(Component.java:845)
at sun.awt.motif.MButtonPeer.action(MButtonPeer.java:39)
at java.lang.Thread.run(Thread.java)
Una vez que una excepción es atrapada, hay varias cosas que un programador podrı́a querer hacer. Una posibilidad es mostrar un mensaje de error y
terminar el programa. También hay algunos casos interesantes en los cuales
la mejor forma para manejar una excepción es ignorarla (usando un bloque
catch vacı́o).
Ignorar una excepción es hecho usualmente, por ejemplo, cuando el programador no le importa si hay o no hay excepción. Otra manera legı́tima
de manejar excepciones es creando y lanzando otra excepción, quizás una
que indique la condición excepcional más precisa. El siguiente código es un
ejemplo de esta forma:
68
Diseño orientado al objeto
catch (ArrayIndexOutOfBoundsException e) {
throw new ExcepciónListaComprasCorta(
"Índice del producto no está en lista de compras");
}
La mejor forma de manejar una excepción, aunque no siempre es posible,
es encontrar el problema, arreglarlo, y continuar la ejecución.
2.4.
Interfaces y clases abstractas
Para que dos objetos puedan interactuar, estos deben “conocer” acerca de
los mensajes que cada uno podrá aceptar, es decir, los métodos que cada objeto
soporta. Para reforzar este “conocimiento”, el paradigma del diseño orientado
al objeto requiere que las clases indiquen la interfaz de programación de
aplicaciones o API (Application Programming Interface), o interfaz, que
sus objetos presentan a otros objetos. En la aproximación ADT usada, sección
2.1.2, para estructuras de datos en este texto, una interfaz definiendo un ADT
es indicado como un tipo definición y una colección de métodos para este tipo,
con los argumentos para cada método siendo de tipos especificados. En su
momento, esta especificación es reforzada por el compilador o el sistema de
ejecución, los cuales requieren que los tipos de los parámetros que son pasados
a los métodos sean del tipo indicado en la interfaz. Este requerimiento es
conocido como tipado fuerte. El programador es obligado a tener que definir
interfaces y usar en estas tipado fuerte, pero esta carga es recompensada, por
obligar a emplear el principio de encapsulación y porque atrapa errores de
programación que de otra forma no se sabrı́an.
2.4.1.
Implementación de interfaces
El elemento estructural principal en Java que forza una API es la interfaz.
Una interfaz es una colección de declaraciones de métodos sin datos y sin
cuerpos. Es decir, los métodos de una interfaz siempre están vacı́os, son sólo
firmas de métodos. Cuando una clase implementa una interfaz, deberá implementar todos los métodos declarados en la interfaz. De esta forma, las
interfaces hacen cumplir los requisitos que una clase implementadora tenga
métodos con ciertas firmas especificadas.
Por ejemplo, suponer que se quiere crear un inventario de antigüedades,
categorizadas como objetos de tipos varios y con propiedades diversas. Se
2.4 Interfaces y clases abstractas
69
podrı́a querer identificar algunos de los objetos como vendibles, en tal caso
estos deberı́an implementar la interfaz Vendible, listado 2.7.
1
2
3
4
5
6
7
8
9
10
11
12
/* * Interfaz para objetos que pueden ser vendidos . */
public interface Vendible {
/* * descripci ó n del objeto */
public String descripcion ();
/* * precio de lista */
public float precioLista ();
/* * precio m ı́ nimo de venta */
public float precioMinimo ();
}
Listado 2.7: Interfaz Vendible.
Después se puede definir una clase concreta, Fotografia, listado 2.8,
que implementa la interfaz Vendible, para indicar que se estarı́a dispuesto
a vender cualquiera de los objetos fotografı́a. Esta clase define un objeto
que implementa cada uno de los métodos de la interfaz Vendible, como se
requiere. Ademas, agrega el método esColor() especializado para objetos
Fotografia.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* * Clase para fotograf ı́ as que pueden ser vendidas . */
public class Fotografia implements Vendible {
private String descripcion ;
private float precio ;
// precio puesto
private boolean color ;
// true si foto es a color
// constructor
public Fotografia ( String d , float p , boolean c ) {
descripcion = d ;
precio = p ;
color = c ;
}
public
public
public
public
String descripcion () { return descripcion ; }
float precioLista () { return precio ; }
float precioMinimo () { return precio /4; }
boolean esColor () { return color ; }
}
Listado 2.8: Clase Fotografia implenentando la interfaz Vendible.
Otro tipo de antigüedades en el inventario podrı́a ser algo que se deba
70
Diseño orientado al objeto
transportar. Para tales objetos, se define la interfaz Transportable, listado
2.9.
1
2
3
4
5
6
7
8
9
/* * Interfaz para objetos que pueden ser transportados . */
public interface Transportable {
/* * peso en gramos
public int peso ();
*/
/* * si el objeto es peligroso */
public boolean esPeligroso ();
}
Listado 2.9: Interfaz Transportable.
Se podrı́a luego definir la clase ArticuloEmpacado, listado 2.10, para
antigüedades diversas que puedan ser vendidas, empacadas, y enviadas. Por
lo tanto, la clase ArticuloEmpacado implementa los métodos de la interfaz
Vendible y Transportable, además también se agregan los métodos especializados para poner un seguro y las dimensiones de un artı́culo empacado.
La clase ArticuloEmpacado muestra otra caracterı́stica de las clases y las
interfaces, una clase puede implementar varias interfaces, lo cual da flexibilidad
cuando se definen clases que deberı́an cumplir varias API. Mientras que
una clase en Java puede extender solamente una clase, puede sin embargo
implementar varias interfaces.
2.4.2.
Herencia múltiple en interfaces
La habilidad de extender de más de una clase se conoce como herencia
múltiple. En Java, herencia múltiple está permitida para interfaces pero
no para clases. En Java no se permite la herencia múltiple en clases, por la
posible confusión cuando una clase intente extender de dos clases que tengan
métodos con cuerpo diferente y firma igual. Esta confusion no existe en las
interfaces porque sus métodos están vacı́os. Al no haber confusión, la herencia
múltiple de interfaces es útil.
En Java, el uso de herencia múltiple de interfaces es para aproximar una
técnica de herencia múltiple llamada mezcla. Se mezclan los métodos de dos o
más interfaces no relacionadas para definir una interface que combine su funcionalidad, posiblemente agregando más métodos por su cuenta. Para el ejemplo
de los objetos antigüedad, se podrı́a definir la interfaz ArticuloAsegurable
para los artı́culos asegurados, como se muestra enseguida:
2.4 Interfaces y clases abstractas
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* * Clase para objetos que pueden ser vendidos , empacados ,
* y enviados . */
public class A r t i c u l o E m p a c a d o implements Vendible , Transportable {
private String descripcion ;
private float precio ;
private int peso ;
private boolean pelig ;
private int largo = 0; // largo empaque en cm
private int ancho = 0; // ancho empaque en cm
private int alto = 0;
// alto empaque en cm
/* * Constructor */
public A r t i cu l o E m p a c a d o ( String d , float p , int pes , boolean pel ) {
descripcion = d ;
precio = p ;
peso = pes ;
pelig = pel ;
}
public String descripcion () { return descripcion ; }
public float precioLista () { return precio ; }
public float precioMinimo () { return precio /4; }
public int peso () { return peso ; }
public boolean esPeligroso () { return pelig ; }
public float v alorAseg urado () { return precio *1.5 f ; }
public void setCaja ( int l , int an , int al ) {
largo = l ;
ancho = an ;
alto = al ;
}
}
Listado 2.10: Clase ArticuloEmpacado
71
72
Diseño orientado al objeto
public interface ArticuloAsegurable
extends Transportable, Vendible {
/** Devolver el valor asegurado */
public int valorAsegurado();
}
La interfaz ArticuloAsegurable mezcla los métodos de la interfaz Transportable con los métodos de la interfaz Vendibles, y agrega el método extra
valorAsegurado(). La interfaz ArticuloAsegurable podrı́a permitir definir
la clase ArticuloEmpacado como:
public class ArticuloEmpacado
implements ArticuloAsegurable {
// mismo código de la clase ArticuloEmpacado
}
Las interfaces de Java que aproximan la mezcla son: java.lang.Cloneable,
la cual agrega una caracterı́stica de copia para una clase; java.lang.Comparable, la cual agrega una caracterı́stica de comparación para una
clase; y java.util.Observer, la cual agrega una caracterı́stica de actualización a una clase que quiere ser notificada cuando ciertos objetos “observables”
cambien de estado.
2.4.3.
Clases abstractas y tipado fuerte
Una clase abstracta es una clase que contiene declaraciones de métodos
vacı́os, o sea, declaraciones de métodos sin cuerpo, y posiblemente definiciones
de métodos concretos y variables de instancia. Ası́, una clase abstracta
está entre una interfaz y una clase concreta. Al igual que una interfaz, una
clase abstracta no puede ser instanciada, o sea, crear un objeto desde la clase
abstracta. Una subclase de una clase abstracta deberá dar una implementación
para los métodos abstractos de su superclase, a menos que esta misma sea
abstracta. Al igual que una clase concreta, una clase abstracta A puede
extender otra clase abstracta, y clases abstractas o concretas pueden también
extender a A. Una clase nueva que no sea abstracta y extienda superclases
abstractas deberá implementar todos los métodos abstractos. Una clase
abstracta usa el estilo de especificación de herencia, pero también permite la
especialización y estilos de extensión.
2.5 Conversiones y genéricos
73
La clase java.lang.Number
Las clases número Java, cuadro 1.2, especializan la clase abstracta java.lang.Number. Cada clase número concreta, tales como java.lang.Integer
y java.lang.Double, extienden la clase java.lang.Number y completan los
detalles para los métodos abstractos de la superclase. Por ejemplo, los métodos
intValue(), floatValue(), longValue(), y doubleValue() son todos abstractos en java.lang.Number. Cada clase número concreta deberá especificar
los detalles de estos métodos.
Tipado fuerte
Un objeto puede ser visto como siendo de varios tipos. El tipo primario de
un objeto o es la clase C indicada al momento que o fue instanciado. Además,
o es del tipo S para cada superclase S de C y es del tipo I para cada interfaz
I implementada por C.
Sin embargo, una variable puede ser declarada para ser de un sólo tipo,
ya sea clase o interfaz, la cual determina como la variable es usada y como
ciertos métodos actuarán en esta. De igual forma, un método tiene un único
tipo de regreso. En general, una expresión tiene un tipo único.
La técnica de tipado fuerte de Java, forzar a que todas las variables sean
tipadas y que los métodos declaren el tipo que se espera y que se regresa,
ayuda a prevenir errores. Pero por los requerimientos rı́gidos con tipos, es en
ocasiones necesario cambiar, o convertir, un tipo en otro. Tales conversiones
podrı́an tener que ser especificadas por un operador de conversión explı́cito.
En la sección 1.3.3 se indica como convertir a tipos base. En la siguiente
sección se indica como se realizan las conversiones para variables referencia.
2.5.
2.5.1.
Conversiones y genéricos
Conversiones para objetos
Se comenta enseguida varios métodos para conversiones de tipo para
objetos.
74
Diseño orientado al objeto
Conversiones anchas
Una conversión ancha ocurre cuando un tipo T es convertido a un tipo
U “amplio”. Los siguientes son casos comunes de conversiones anchas:
T y U son tipos clase y U es una superclase de T.
T y U son tipos interfaz y U es una superinterfaz de T.
T es una clase que implementa la interfaz U.
Las conversiones anchas son hechas automáticamente para guardar el
resultado de una expresión en una variable, sin la necesidad de una conversión
explı́cita. Por lo tanto, se puede asignar directamente el resultado de una
expresión de tipo T en una variable v de tipo U cuando la conversión de T
a U es una conversión ancha. El siguiente fragmento de código muestra una
expresión de tipo Integer, un objeto nuevo construido, asignándose a una
variable de tipo Number.
Integer i = new Integer(3);
Number n = i; // conversión ancha
La correctez de una conversión ancha es revisada por el compilador y su
validez no requiere prueba por el ambiente de ejecución Java al ejecutar el
programa.
Conversiones estrechas
Una conversión estrecha ocurre cuando un tipo T es convertido a un
tipo S “estrecho”. Los siguientes son casos comunes de conversiones estrechas:
T y S son tipos clase y S es una subclase de T.
T y S son tipos interfaz y S es una subinterfaz de T.
T es una interfaz implementada por la clase S.
En general, una conversión estrecha de tipos referencia necesita una
conversión explı́cita. También, la correctez de una conversión estrecha podrı́a
no ser verificada por el compilador. Por lo tanto, su validación deberı́a ser
probada por el ambiente de ejecución en la ejecución del programa.
El siguiente fragmento de código ejemplo muestra como usar una conversión
para realizar una conversión estrecha desde el tipo Number al tipo Integer.
2.5 Conversiones y genéricos
75
Number n = new Integer(2); // conv. ancha Integer->Number
Integer i = (Integer) n;
// conv. estrecha Number->Integer
En la primera sentencia, un objeto nuevo de la clase Integer es creado y
asignado a una variable n del tipo Number. Ası́, una conversión ancha ocurre
y no se ocupa conversión. En la segunda sentencia, se asigna n a una variable
i del tipo Integer usando una conversión. La asignación es posible porque n
se refiere a un objeto del tipo Integer. Sin embargo, como la variable n es
del tipo Number, una conversión estrecha ocurre y la conversión es necesaria.
Excepciones en conversiones
Se puede convertir una referencia objeto o de tipo T en un tipo S, si el
objeto o referido es en realidad del tipo S. Por otra parte, si el objeto o no
es del tipo S, entonces intentar convertir o al tipo S lanzará la excepción
ClassCastException. Lo anterior se presenta en el siguiente fragmento de
código:
Number n;
Integer i;
n = new Integer(3);
i = (Integer) n;
// válido
n = new Double(3.1416);
i = (Integer) n;
// no válido
Para evitar problemas como el anterior y evitar poner en el código bloques
try...catch cada vez que se haga una conversión, Java proporciona una
forma de asegurarse que una conversión objeto será correcta mediante el
operador, instanceof, el cual prueba si una variable objeto se refiere a un
objeto de una cierta clase o implementa una cierta interfaz. La sintaxis es:
referencia objeto instanceof tipo referencia
siendo referencia objeto una expresión que evalúa a una referencia objeto
y tipo referencia es el nombre de alguna clase, interfaz, o enumeración existente. Si referencia objeto es una instancia de tipo referencia entonces
el operador da true, de otra forma da false. Para evitar una excepción
ClassCastException que lanzarı́a el código previo, se deberá modificar de
la siguiente forma:
76
Diseño orientado al objeto
Number n;
Integer i;
n = new Integer(3);
if (n instanceof Integer)
i = (Integer) n;
// válido
n = new Double(3.1416);
if (n instanceof Integer)
i = (Integer) n;
// no será intentado
Conversiones con interfaces
Las interfaces permiten obligar que los objetos implementen ciertos métodos, pero usando variables interfaz con objetos concretos en ocasiones se
requiere conversión. Suponer que se declara una interfaz Persona, listado 2.11.
Observar que el método igualA() de la interfaz Persona toma un parámetro
de tipo Persona. De esta forma, se puede pasar un objeto de cualquier clase
que implemente la interfaz Persona a este método.
1
2
3
4
5
public interface Persona {
public boolean igualA ( Persona otra ); // ¿ misma persona ?
public String getNombre (); // obtener nombre de la persona
public int getEdad ();
// obtener edad de la persona
}
Listado 2.11: Interfaz Persona
En la clase Estudiante, listado 2.12, se implementa la interfaz Persona.
Se asume en el método igualA() que el argumento, declarado de tipo Persona,
es también del tipo Estudiante y se realiza una conversión estrecha del tipo
Persona, una interfaz, al tipo Estudiante, una clase, con una conversión
explı́cita. La conversión es permitida en este caso, porque es una conversión
estrecha desde una clase T a una interfaz U, en donde se tiene un objeto
tomado desde T tal que T extiende S y S implementa U.
Se requiere asegurar que en la implementación del método igualA() de la
clase Estudiante, no se intente la comparación de objetos Estudiante con
otros tipos de objetos, o de otra forma, la conversión en el método igualA()
fallará.
La posibilidad de realizar conversiones estrechas desde tipos interfaz a
tipos clase permite escribir tipos generales de estructuras de datos que sólo
2.5 Conversiones y genéricos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
77
public class Estudiante implements Persona {
String id ;
String nombre ;
int edad ;
public Estudiante ( String i , String n , int e ) {
id = i ;
nombre = n ;
edad = e ;
}
protected int horasEstudio () { return edad /3; }
public String getId () { return id ; } // id del estudiante
public String getNombre () { return nombre ; } // de interfaz
public int getEdad () { return edad ; } // de interfaz
public boolean igualA ( Persona otra ) { // de interfaz
Estudiante otr oEstudi ante = ( Estudiante ) otra ;
return ( id . equals ( otr oEstudi ante . getId ()));
}
public String toString () { // para mostrar
return " Estudiante ( Id : " + id +
" , Nombre : " + nombre +
" , Edad : " + edad + " ) " ;
}
}
Listado 2.12: Clase Estudiante implementando la interfaz Persona
hagan suposiciones mı́nimas acerca de los elementos que guardan. Se bosqueja
enseguida como construir un directorio para guardar parejas de objetos que
implementan la interfaz Persona. El método quitar() realiza una búsqueda
en el contenido del directorio y quita el par de personas indicadas, si existe, y
al igual que el método encontrarOtra(), usa el método igualA() para la
búsqueda.
public class DirectorioPareja {
// ... variables de instancia ...
public DirectorioPareja() { }
public void insertar(Persona persona, Persona otra) { }
public Persona encontrarOtra(Persona persona) {
return null ;
}
public void quitar (Persona persona, Persona otra) { }
}
Suponer que se ha llenado un directorio, miDirectorio, con parejas de
objetos Estudiante que representan parejas de compañeros de cuarto. Para
78
Diseño orientado al objeto
lograr encontrar el compañero de cuarto de un objeto Estudiante dado,
estudiante1, se podrı́a intentar hacer lo siguiente, lo cual está mal:
Estudiante estudiante2 =
miDirectorio.encontrarOtra(estudiante1);
Esta sentencia provoca un error de compilación “explicit-cast-required ”
(conversión explı́cita requerida), debido a que se intenta hacer una conversión estrecha sin una conversión explı́cita. Es decir, el valor devuelto por
el método encontrarOtra() es del tipo Persona mientras que la variable
estudiante2, a la cual es asignada, es del tipo estrecho Estudiante, una
clase que implementa la interfaz Persona, Ası́, se debe hacer una conversión
del tipo Persona al tipo Estudiante, como se muestra enseguida:
Estudiante estudiante2 =
(Estudiante)miDirectorio.encontrarOtra(estudiante1);
Convertir el valor del tipo Persona regresado por el método encontrarOtra() al tipo Estudiante trabaja bien siempre que se asegure que la
llamada a miDirectorio.encontrarOtra() realmente devuelva un objeto
Estudiante. En general, las interfaces pueden ser una herramienta valiosa
para el diseño de estructuras de datos generales, las cuales pueden entonces
ser especializados por otros programadores usando la conversión.
2.5.2.
Genéricos
A partir de Java 5.0 se incluye una estructura genérica para usar
tipos abstractos con la finalidad de evitar conversiones de tipos. Un tipo
genérico es un tipo que no está definido en tiempo de compilación, pero queda
especificado completamente en tiempo de ejecución. La estructura genérica
permite definir una clase en términos de un conjunto de parámetros de
tipo formal, los cuales podrı́an ser usados, por ejemplo, para abstraer los
tipos de algunas variables internas de la clase. Se usan paréntesis angulares
(<>) para encerrar la lista de los parámetros de tipo formal. Aunque cualquier
identificador válido puede ser usado para un parámetro de tipo formal, por
convención son usados los nombres de una sola letra mayúscula. Dada una
clase que ha sido definida con tales tipos parametrizados, se instancia un
objeto de esta clase usando los parámetros de tipo actual para indicar los
tipos concretos que serán usados.
2.5 Conversiones y genéricos
79
En el listado 2.13, se muestra la clase Par que guarda el par llave-valor,
donde los tipos de la llave y el valor son especificados por los parámetros L y V,
respectivamente. El método main() crea dos instancias de esta clase, una para
un par String-Integer (por ejemplo, para guardar un tipo de dimensión y
su valor), y otro para un par Estudiante-Double (por ejemplo, para guardar
la calificación dada a un estudiante).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Par <L , V > { // entre < y > los par á metros de tipo formal
L llave ;
V valor ;
public void set ( L l , V v ) {
llave = l ;
valor = v ;
}
public L getLlave () { return llave ; }
public V getValor () { return valor ; }
public String toString () {
return " [ " + getLlave () + " , " + getValor () + " ] " ;
}
public static void main ( String [] args ) {
Par < String , Integer > par1 = new Par < String , Integer >();
par1 . set ( new String ( " altura " ) , new Integer (2));
System . out . println ( par1 );
Par < Estudiante , Double > par2 = new Par < Estudiante , Double >();
par2 . set ( new Estudiante ( " 8403725 A " ," Hector " ,14) , new Double (9.5));
System . out . println ( par2 );
}
}
Listado 2.13: Clase Par
La salida de la ejecución de la clase Par se muestra enseguida:
[altura, 2]
[Estudiante(Matricula: 8403725A, Nombre: Hector, Edad: 14), 9.5]
En el ejemplo, el parámetro del tipo actual puede ser un tipo arbitrario. Para restringir el tipo de parámetro actual se puede usar la cláusula extends, como se muestra enseguida, donde la clase DirectorioParejaGenerico está definida en términos de un parámetro de tipo genérico P, parcialmente especificado declarando que extiende la clase Persona.
80
Diseño orientado al objeto
public class DirectorioParejaGenerico<P extends Persona> {
// ... variables de instancia ...
public DirectorioParejaGenerico() { }
public void insertar(P persona, P otra) { }
public P encontrarOtra(P persona) { return null ; }
public void quitar (P persona, P otra) { }
}
Esta clase deberı́a ser comparada con la clase DirectorioPareja de la
sección 2.5.1. Dada la clase anterior, se puede declarar una variable refiriendo
a una instancia de DirectorioParejaGenerico, que guarde pares de objetos
de tipo Estudiante:
DirectorioParejaGenerico<Estudiante> miDirectorioEstudiante;
La estructura genérica permite definir versiones genéricas de métodos. En
este caso, se puede incluir la definición genérica entre los métodos modificadores. Por ejemplo, se muestra enseguida la definición de un método que
puede comparar las llaves de cualesquiera dos objetos Par, dado que sus llaves
implementen la interfaz Comparable:
public static <K extends Comparable,V,L,W> int
comparaPares(Par<K,V> p, Par<L,W> q) {
return p.getLlave().compareTo(q.getLlave());
// la llave de p implementa compareTo()
}
Una advertencia importante relacionada a los tipos genéricos es que
los elementos guardados en un arreglo no pueden ser de un tipo variable
o parametrizado. Java permite que un arreglo sea definido con un tipo
parametrizado, pero no permite que un tipo parametrizado sea usado para
crear un nuevo arreglo. Java permite que para un arreglo definido con un tipo
parametrizado sea inicializado con un arreglo nuevo no parametrizado, como
se muestra en la clase UsaPar, listado 2.14. Aún ası́, este mecanismo tardı́o
causa que el compilador de Java emita una advertencia, porque esto no es
tipado 100 % seguro.
2.5 Conversiones y genéricos
1
2
3
4
5
6
7
8
9
81
public class UsaPar {
public static void main ( String [] args ) {
Par < String , Integer >[] a = new Par [10]; // correcto , pero da una advertencia
Par < String , Integer >[] b = new Par < String , Integer >[10]; // incorrecto
a [0] = new Par < String , Integer >(); // est á completamente bien
a [0]. set ( " Gato " ,10); // esta y la siguiente tambien est á n bien
System . out . println ( " Primer par es " + a [0]. getLlave ()+ " ," + a [0]. getValor ());
}
}
Listado 2.14: Ejemplo que muestra que un tipo parametrizado sea usado para crear
un nuevo arreglo.
82
Diseño orientado al objeto
Capı́tulo 3
Arreglos, listas enlazadas y
recurrencia
3.1.
Que son las estructuras de datos
Una estructura de datos es un arreglo de datos en la memoria de
una computadora, y en algunas ocasiones en un disco. Las estructuras de
datos incluyen arreglos, listas enlazadas, pilas, árboles binarios, y tablas
de dispersión entre otros. Los algoritmos manipulan los datos en estas
estructuras de varias formas, para buscar un dato particular u ordenar los
datos.
3.1.1.
Generalidades de las Estructuras de Datos
Una forma de ver las estructuras de datos es enfocándose en sus fortalezas
y debilidades. En el cuadro 3.1 se muestran las ventajas y desventajas de
las estructuras de datos que se revisarán, los términos que se usan serán
abordados en otros capı́tulos.
Las estructuras mostradas en el cuadro 3.1, excepto los arreglos, pueden
ser pensados como tipos de datos abstractos o ADT (Abstract Data
Type). Un ADT, de forma general, es una forma de ver una estructura de
datos: enfocándose en lo que esta hace e ignorando como hace su trabajo, es
decir, el ADT deberá cumplir con ciertas propiedades, pero la manera como
84
Arreglos, listas enlazadas y recurrencia
Estructura
Arreglo
Ventajas
Inserción rápida, acceso muy rápido si se
conoce el ı́ndice.
Arreglo ordenado Búsqueda más rápida que el arreglo desordenado.
Pila
Último en entrar es
el primero en salir.
El primero en entrar
Cola
es el primero en salir.
Inserción y borrado
Lista enlazada
rápido.
Árbol binario
Búsqueda, inserción
y borrado rápido si el
árbol permanece balanceado.
Árbol rojinegro
Búsqueda, inserción
y borrado rápido.
Árbol siempre balanceado.
Árbol 2-3-4
Búsqueda, inserción
y borrado rápido.
Árbol siempre balanceado.
Tabla de disper- Acceso muy rápido si
sión
se conoce la llave. Inserción rápida.
Montı́culo
Grafo
Desventajas
Búsqueda y borrado
lento, tamaño fijo.
Inserción y borrado
lento, tamaño fijo.
Acceso lento a otros
elementos.
Acceso lento a otros
elementos.
Búsqueda lenta.
Complejo.
Complejo.
Complejo.
Borrado lento, acceso lento si no se conoce la llave, uso ineficiente de la memoria.
Inserción, borrado y Acceso lento a otros
acceso al elemento elementos.
más grande rápido
Modela situaciones Algunos algoritmos
del mundo real
son lentos y complejos.
Cuadro 3.1: Caracterı́sticas de las Estructuras de Datos
3.2 Arreglos
85
estará implementado puede variar, aún empleando el mismo lenguaje. Por
ejemplo, el ATD pila puede ser implementado con un arreglo o bien con una
lista enlazada.
Varios de los algoritmos que serán discutidos en este material se aplicarán directamente a estructuras de datos especı́ficas. Para la mayorı́a de las
estructuras de datos, se requiere que hagan las siguientes tareas:
1. Insertar un nuevo dato.
2. Buscar un elemento indicado.
3. Borrar un elemento indicado.
También se necesitará saber como iterar a través de todos los elementos en
la estructura de datos, visitando cada uno en su turno ya sea para mostrarlo,
o para realizar alguna acción en este.
Otro algoritmo importante es el ordenamiento. Hay varias formas para
ordenar los datos, desde ideas simples hasta otras que permiten realizarlo de
manera rápida.
El concepto de recurrencia es importante en el diseño de ciertos algoritmos.
La recurrencia involucra que un método se llame a si mismo.
3.2.
Arreglos
En esta sección, se exploran unas cuantas aplicaciones con arreglos.
3.2.1.
Guardar entradas de juego en un arreglo
La primera aplicación que será revisada se usa para guardar las entradas
en un arreglo—en particular, las mejores marcas para un videojuego. Los
arreglos también se podrı́an usar para guardar registros de pacientes en un
hospital, o los nombres de estudiantes.
En primer lugar se debe determinar lo que se quiere incluir en el registro
de una puntuación alta. Por supuesto, un componente que se deberá incluir es
un entero representando los puntos obtenidos, al cual llamaremos puntuacion.
Otra caracterı́stica que deberá incluirse es el nombre de la persona que obtuvo
la puntuación, la cual denominaremos nombre. La clase EntradaJuego, listado
3.1, tiene el diseño para manejar una puntuación.
86
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Arreglos, listas enlazadas y recurrencia
public class EntradaJuego {
protected String nombre ; // nombre del due ~
n o de la puntuaci ó n
protected int puntuacion ; // el valor de la puntuaci ó n
/* * Constructor para crear una entrada del juego */
public EntradaJuego ( String n , int p ) {
nombre = n ;
puntuacion = p ;
}
/* * Recupera el campo nombre */
public String getNombre () { return nombre ; }
/* * Recupera el campo puntuaci ó n */
public int getPuntuacion () { return puntuacion ; }
/* * Regresa una cadena de esta entrada */
public String toString () {
return " ( " + nombre + " , " + puntuacion + " ) " ;
}
}
Listado 3.1: Clase EntradaJuego.java
Clase para las mayores puntuaciones
La cantidad de puntuaciones que serán guardadas se indica mediante una
constante con nombre, MAX ENTRADAS, la cual es inicializada con el valor 10.
Se usa esta constante para el caso en el que se quiera cambiar fácilmente la
cantidad de puntuaciones guardadas. Se define un arreglo, entradas, para
que sea un arreglo de tamaño MAX ENTRADAS. Cuando se crea el arreglo sus
entradas son null, pero conforme se juega, se van llenando las entradas del
arreglo con referencias a objetos EntradaJuego.
Se quiere almacenar los mayores registros en un arreglo llamado entradas.
La cantidad de registros puede variar, por lo que se usará el nombre simbólico,
maxEntradas, para representar el número máximo de registros que se pueden
guardar. Se debe poner esta variable con un valor especı́fico, y usando esta
variable en el código, se puede hacer fácilmente un cambio más tarde si se
requiere. Se define después el arreglo, entradas, para que sea un arreglo
de tamaño maxEntradas. Inicialmente, el arreglo guarda solamente entradas
nulas, pero conforme el usuario juega, se llenará en el arreglo con nuevos
objetos EntradaJuego. Por lo que se necesitará definir métodos para actualizar
las referencias EntradaJuego en las entradas del arreglo.
La forma como se mantiene organizado el arreglo entradas es simple—se
guarda el conjunto de objetos EntradaJuego ordenado por sus valores enteros
puntuacion, de mayor a menor. Si el número de objetos EntradaJuego es
menor que maxEntradas, entonces se permite que al final del arreglo se
3.2 Arreglos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
87
/* * Clase para guardar las puntuaciones altas en un arreglo
en orden descendente . */
public class Puntuaciones {
public static final int MAX_ENTRADAS = 10; // cantidad de puntuaciones
protected int numEntradas ;
// cantidad . actual de entradas
protected EntradaJuego [] entradas ; // arreglo de entradas
/* * Constructor por defecto */
public Puntuaciones () {
entradas = new EntradaJuego [ MAX_ENTRADAS ];
numEntradas = 0;
}
/* * Regresa una cadena de las mayores puntuaciones */
public String toString () {
String s = " [ " ;
for ( int i = 0; i < numEntradas ; i ++) {
if ( i > 0) s += " , " ; // separar entradas con comas
s += entradas [ i ];
}
return s + " ] " ;
}
// ... m é todos para actualizar el conjunto de puntuaciones ...
Listado 3.2: Clase Puntuciones para manejar un conjunto de objetos Entrada.
guarden referencias null. Con este diseño se previene tener celdas vacı́as, u
“hoyos”, y los registros están desde el ı́ndice cero hasta antes de la cantidad de
juegos jugados. Se ilustra una instancia de la estructura de datos en la figura
3.1 y en la clase Puntuaciones, listado 3.2 parcial, se muestra el diseño.
El método toString() devuelve una cadena representando las mayores
puntuaciones del arreglo entradas. Puede también ser empleado para propósitos de depuración. La cadena será una lista separada con comas de objetos
EntradaJuego del arreglo entradas. La lista se genera con un ciclo for, el
cual agrega una coma justo antes de cada entrada que venga después de la
primera.
Inserción
Una de las actualizaciones más comunes que se quiere hacer al arreglo
entradas es agregar una nueva entrada. Suponiendo que se quiera insertar un
nuevo objeto e del tipo EntradaJuego se considera como se harı́a la siguiente
operación en una instancia de la clase Puntuaciones:
88
Arreglos, listas enlazadas y recurrencia
Figura 3.1: Una ilustración de un arreglo de tamaño 10 guardando referencias a
6 objetos entradaJuego desde la celda 0 hasta la 5, y el resto siendo referencias
null.
add(e):inserta la entrada de juego e en la colección de las
mayores puntuaciones. Si la colección está llena,
entonces e es agregado solamente si su puntuación
es mayor que la menor puntuación en el conjunto,
y en este caso, e reemplaza la entrada con la menor
puntuación.
El reto principal en la implementación de esta operación es saber donde e
deberá ir en el arreglo entradas y hacerle espacio a e.
Se supondrá que las puntuaciones están en el arreglo de izquierda a
derecha desde la mayor hasta la menor. Entonces se quiere que dada una
nueva entrada, e, se requiere saber donde estará. Se inicia buscando al final
del arreglo entradas. Si la última referencia en el arreglo no es null y su
puntuación es mayor que la puntuación de e, entonces se detiene la búsqueda,
ya que en este caso, e no es una puntuación alta. De otra forma, se sabe
que e debe estar en el arreglo, y también se sabe que el último registro en
el arreglo ya no debe estar. Enseguida, se va al penúltima referencia, si esta
referencia es null o apunta a un objeto EntradaJuego con una puntuación
que es menor que e, esta referencia deberá ser movida una celda a la derecha
en el arreglo entrada. Se continua comparando y desplazando referencias de
entradas del juego hasta que se alcanza el inicio del arreglo o se compara la
3.2 Arreglos
89
puntuación de e con una entrada con una puntuación mayor. En cualquier
caso, se habrá identificado el lugar al que e pertenece. Ver figura 3.2
Figura 3.2: Preparación para agregar un nuevo objeto entradaJuego al arreglo
Entradas. Para hacer espacio a una nueva referencia, se tienen que desplazar las
referencias con los registros más pequeños que la nueva una celda a la derecha.
En el listado 3.3 se muestra la implementación para agregar la entrada
juego nuevo e al arreglo entradas. Se usa un ciclo para desplazar las referencias. La cantidad de veces que se repite el ciclo depende de la cantidad de
referencias que se tengan que mover para hacer espacio para la nueva entrada.
La rapidez del método dependerá de la cantidad de entradas en el arreglo
y del valor de la puntuación. Cuando el arreglo está lleno puede ser que la
referencia a la última entrada se pierda o se falle al agregar la nueva entrada
de juego, e.
Remoción de un objeto
Suponiendo que algún jugador hace trampa y obtiene una puntuación
alta, se desearı́a tener un método que permita quitar la entrada del juego de
la lista de las mayores puntuaciones. Por lo tanto se considera como deberı́a
implementarse la siguiente operación:
90
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Arreglos, listas enlazadas y recurrencia
/* * Intenta agregar una nueva puntuaci ó n a la coleccion si es lo
s uf ic ie n te me nt e alta . */
public void add ( EntradaJuego e ) {
int n ue va Pu n tu ac i on = e . getPuntuacion ();
// ¿ es la nueva entrada realmente un r é cord ?
if ( numEntradas == MAX_ENTRADAS ) { // el arreglo est á lleno
if ( n u ev aP u nt ua ci o n <= entradas [ numEntradas -1]. getPuntuacion () )
return ; // la nueva entrada , e , no es un record
}
else // el arreglo no est á lleno
numEntradas ++;
// Encontrar el lugar en donde la nueva entrada (( e )) estar á
int i = numEntradas -1;
for ( ; ( i >= 1) && ( n u ev aP u nt ua ci o n > entradas [i -1]. getPuntuacion ()); i - -)
entradas [ i ] = entradas [ i - 1]; // mover entrada i un lugar a la derecha
entradas [ i ] = e ;
// agregar el nuevo r é cord a entradas
}
Listado 3.3: Codificación en la clase Puntuciones del método add() para insertar
un objeto Entrada.
remove(i):quita y regresa la entrada de juego e con el ı́ndice
i en el arreglo entradas. Si el ı́ndice i está fuera de los lı́mites del arreglo entradas, entonces
el método deberá lanzar una excepción; de otra
forma, el arreglo entradas será actualizado para
quitar el objeto en la posición i y todos los objetos guardados con ı́ndices mayores que i serán
movidos encima para llenar el espacio del dejado
por el objeto quitado.
La implementación para remove() será parecida al algoritmo para agregar
un objeto, pero en forma inversa. Para remover la referencia al objeto con
ı́ndice i, se inicia en el ı́ndice i y se mueven todas las referencias con ı́ndices
mayores que i una celda a la izquierda. Ver figura 3.3.
Los detalles para hacer la operación de remoción consideran que primeramente se debe guardar la entrada del juego, llamada e, en el ı́ndice i del
arreglo, en una variable temporal. Se usará esta variable para regresar e
cuando se haya terminado de borrar. Para el movimiento de las referencias
que son mayores que i una celda a la izquierda, no se hace hasta el final del
arreglo—se detiene en la penúltima referencia, ya que la última referencia
no tiene una referencia a su derecha. Para la última referencia en el arreglo
entradas, es suficiente con anularla. Se termina regresando una referencia de
3.2 Arreglos
91
Figura 3.3: Remoción en el ı́ndice 3 en el arreglo que guarda las referencias a objetos
entradaJuego.
la entrada removida, la cual no tiene una referencia que le apunte desde el
arreglo entradas. En el listado 3.4 se muestra la codificación del método.
Los métodos para agregar y quitar objetos en el arreglo de puntuaciones
altas son simples. Pero forman las bases de las técnicas usadas para construir
estructuras de datos más complejas.
1
2
3
4
5
6
7
8
9
10
11
12
/* * Quitar el r é cord del ı́ ndice i y devolverlo . */
public EntradaJuego remove ( int i ) throws I n d e x O u t O f B o u n d s E x c e p t i o n {
if (( i < 0) || ( i >= numEntradas ))
throw new I n d e x O u t O f B o u n d s E x c e p t i o n ( " Í ndice no v á lido : " + i );
// guardar temporalmente el objecto a ser quitado
EntradaJuego temp = entradas [ i ];
for ( int j = i ; j < numEntradas - 1; j ++) // contar hacia adelante desde i
entradas [ j ] = entradas [ j +1];
// mover una celda a la izquierda
entradas [ numEntradas - 1 ] = null ; // anular el registro menor
numEntradas - -;
return temp ;
// devolver objeto eliminado
}
Listado 3.4: Codificación en la clase Puntuciones del método remove() de acuerdo
a un ı́ndice pasado.
92
3.2.2.
Arreglos, listas enlazadas y recurrencia
Ordenar un arreglo
En la sección previa, se mostró como se pueden agregar objetos o quitar
estos en un cierto ı́ndice i en un arreglo mientras se conserva el ordenamiento
previo de los objetos intacto. En esta sección, se revisa una forma de iniciar
con un arreglo de objetos que no están ordenadados y dejarlos en orden. Esto
se conoce como el problema de ordenamiento.
Un algoritmo simple de inserción ordenada
Se revisarán varios algoritmos de ordenamiento en este material. Como
introducción, se describe en esta sección, un algoritmo simple de ordenamiento,
llamado inserción ordenada. En este caso, se describe una versión especı́fica
del algoritmo donde la entrada es un arreglo de elementos comparables. Se
consideran tipos más generales de algoritmos de ordenamiento mas adelante.
El algoritmo de inserción ordenada trabaja de la siguiente forma. Se
inicia con el primer elemento en el arreglo y este elemento por sı́ mismo ya
está ordenado. Entonces se considera el siguiente elemento en el arreglo. Si
este es menor que el primero, se intercambian. Enseguida se considera el
tercer elemento en el arreglo. Se intercambia hacia la izquierda hasta que
esté en su posición correcta respecto a los primeros dos elementos. De esta
manera se considera el resto de los caracteres, hasta que el arreglo completo
esté ordenado. Mezclando la descripción informal anterior con construcciones
de programación, se puede expresar el algoritmo de inserción ordenada como
se muestra enseguida.
Algoritmo InserciónOrdenada(A):
Entrada: Un arreglo A de n elementos comparables
Salida: El arreglo A con elementos reacomodados en orden creciente
para i ← 1 hasta n − 1 hacer
Insertar A[i] en su posición correcta en A[0], A[1], . . . , A[i − 1].
Esta es una descripción de alto nivel de inserción ordenada y muestra
porque este algoritmo recibe tal nombre—porque cada iteración inserta el
siguiente elemento en la parte ordenada del arreglo que está antes de este.
Antes de codificar la descripción se requiere trabajar más en los detalles de
como hacer la tarea de inserción.
Se reescribirá la descripción de tal forma que se tenga un ciclo anidado
dentro de otro. El ciclo externo considerará cada elemento del arreglo en turno
y el ciclo interno moverá ese elemento a su posición correcta en el subarreglo
3.2 Arreglos
93
ordenado de caracteres que están a su izquierda.
Inserción ordenada refinada
Se describe a continuación una descripción en nivel intermedio del algoritmo de inserción ordenada. Esta descripción está más cercana al código
actual, ya que hace una mejor descripción para insertar el elemento A[i] en el
subarreglo que está antes de este. Todavı́a emplea una descripción informal
para el movimiento de los elementos si no están todavı́a ordenados.
Algoritmo InserciónOrdenada(A):
Entrada: Un arreglo A de n elementos comparables
Salida: El arreglo A con elementos recodados en orden creciente
para i ← 1 hasta n − 1 hacer
{Insertar A[i] en su sitio correcto en A[0], A[1], . . . , A[i − 1].}
actual ← A[i]
j ←i−1
mientras j ≥ 0 Y A[j] > actual hacer
A[j + 1] ← A[j]
j ←j−1
A[j + 1] ← actual {actual está ahora en el lugar correcto}
Se ilustra un ejemplo de la ejecución del algoritmo de ordenamiento en la
figura 3.4. La parte ordenada del arreglo está en blanco, y el siguiente elemento
a ser insertado está en azul. Este se encuentra guardado en la variable cur.
Cada renglón corresponde a una iteración del ciclo externo, y cada copia del
arreglo en un renglón corresponde a una iteración del ciclo interno. Cada
comparación se muestra con un arco. También se indica si la comparación
dió un movimiento o no.
Descripción de inserción ordenada con Java
La clase OrdenaArreglo tiene el método estático insercionOrdenada()
el cual codifica el algoritmo de inserción ordenada. El código proporcionado
es para el caso especial cuando A es un arreglo de caracteres, referenciado
por a.
94
Arreglos, listas enlazadas y recurrencia
Figura 3.4: Ejecución del algoritmo de ordenamiento de un arreglo de 8 caracteres.
1
2
3
4
5
6
7
8
9
10
11
/* * Inserci ó n ordenada de un arreglo de caracteres en orden creciente */
public static void i n se r c i o n O r d e n a d a ( char [] a ) {
int n = a . length ;
for ( int i =1; i < n ; i ++) { // ı́ ndice desde el 2 ◦ car á cter en a
char actual = a [ i ];
// car á cter actual a ser insertado
int j = i -1;
// se inicia con la celda izquierda de i
while (j >=0 && a [ j ] > actual ) // mientras a [ j ] no est é ordenado con actual
a [ j +1 ] = a [ j - - ]; // mover a [ j ] a la derecha y decrementar j
a [ j +1 ] = actual ;
// este es el lugar correcto para actual
}
}
Listado 3.5: Método Java para ordenar un arreglo de caracteres usando el algoritmo
inserción ordenada
3.2 Arreglos
3.2.3.
95
Métodos para arreglos y números aleatorios
Como los arreglos son muy importantes, Java proporciona un determinado
número de métodos incorporados para realizar tareas comunes en arreglos.
Estos métodos son estáticos en la clase java.util.Arrays. Esto es, ellos
están asociados con la propia clase, y no con alguna instancia particular de la
clase.
Métodos útiles de Arrays
Se listan a continuación algunos métodos simples de la clase java.util.Arrays que no requieren explicación adicional:
equals(A, B):regresa verdadero si y solo si el arreglo A y el
arreglo B son iguales. Se considera que dos arreglos
son iguales si estos tienen el mismo número de
elementos y cada par correspondiente de elementos
en los dos arreglos son iguales. Esto es, A y B
tienen los mismos elementos en el mismo orden.
fill(A, x):guarda el elemento x en cada celda del arreglo A.
sort(A):ordena el arreglo A usando el ordenamiento natural
de sus elementos. Para ordenar usa el algoritmo de
ordenamiento rápido, el cual es mucho más rápido
que el de inserción ordenada.
toString(A):devuelve un String representante del arreglo A.
Generación de números pseudoaleatorios
Otra capacidad incorporada en Java es la habilidad para generar números
pseudoaleatorios, los cuáles son útiles cuando se prueban programas que usan
arreglos. Estos números son estadı́sticamente aleatorios, pero no necesariamente aleatorios verdaderos. En particular, Java tiene la clase incorporada,
java.util.Random, cuyas instancias son generadores de números pseudoaleatorios. Las secuencias no son actualmente aleatorias porque es posible
predecir el siguiente número en la secuencia dada la lista de números anteriores. Un generador de números pseudoaletorios popular para generar el
siguiente número, sig, a parte del número actual, actual, es:
sig = (a*actual + b) % n;
96
Arreglos, listas enlazadas y recurrencia
donde a, b, y n son enteros escogidos apropiadamente. La secuencia generada
se puede mostrar que es uniforme estadı́sticamente, lo cual está bien para la
mayorı́a de las aplicaciones que requieran números aleatorios, como los juegos.
Para aplicaciones donde se requieran secuencias aleatorias no predecibles,
como en configuraciones de seguridad de computadoras, una fórmula como la
anterior no debe ser usada. Se debe muestrear de una fuente que sea aleatoria,
como la estática de radio proveniente del espacio exterior.
Como el siguiente número en un generador pseudoaleatorio es determinado
por el número previo, entonces se requiere que el generador tenga un valor
para iniciar, el cual es llamado su semilla. La secuencia de números generados
por una semilla dada siempre será la misma. La semilla para una instancia
de la clase java.util.Random puede ser puesta en su constructor o con el
método setSeed().
Un truco para obtener una secuencia diferente cada vez que una aplicación
es ejecutada es usar una semilla que será diferente para cada ejecución. Por
ejemplo, se podrı́a usar algún tiempo ingresado por el usuario, o se podrı́a
poner la semilla al tiempo actual en milisegundos desde el 1◦ de enero de
1970, dado por el método System.currentTimeMillis().
Algunos de los métodos incluidos en la clase java.util.Random son los
siguientes:
nextBoolean():regresa el siguiente valor boolean pseudoaletario.
nextFloat():regresa el siguiente valor float pseudoaletario,
entre 0.0 y 1.0.
nextInt():regresa el siguiente valor int pseudoaletario.
nextInt(n):regresa el siguiente valor int pseudoaletario en el
rango [0, n).
setSeed(s):pone la semilla de este generador de números pseudoaleatorios con el entero largo s.
Ejemplo con números pseudoaleatorios
Se proporciona la aplicacion PruebaArreglo, listado 3.6, para mostrar el
uso de las clase Arrays y Random del paquete java.util
Una posible salida de esta aplicación es:
arreglos iguales antes de ordenar: true
arreglos iguales después de ordenar: false
ant = [49, 41, 8, 10, 48, 87, 52, 2, 97, 81]
3.2 Arreglos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
97
import java . util . Arrays ;
import java . util . Random ;
/* * Programa que muestra algunos usos de los arreglos */
public class PruebaArreglo {
public static void main ( String [] args ) {
int num [] = new int [10];
Random rand = new Random (); // un generador de n ú meros p s e u d o a l e a t o r i o s
rand . setSeed ( System . c u r r e n t T i m e M i l l i s ()); // tiempo actual como semilla
// llenar el arreglo num con n ú meros p s e u d o a l e a t o ri o s entre 0 y 99 , incl .
for ( int i = 0; i < num . length ; i ++)
num [ i ] = rand . nextInt (100); // siguiente numero p se ud oa l ea to ri o
int [] ant = ( int []) num . clone (); // clonar arreglo num
System . out . println ( " arreglos iguales antes de ordenar : " +
Arrays . equals ( ant , num ));
Arrays . sort ( num ); // ordenar arreglo num ( ant esta sin cambios )
System . out . println ( " arreglos iguales despu é s de ordenar : " +
Arrays . equals ( ant , num ));
System . out . println ( " ant = " + Arrays . toString ( ant ));
System . out . println ( " num = " + Arrays . toString ( num ));
}
}
Listado 3.6: Aplicación PruebaArreglo usando varios métodos incorporados en
Java
num = [2, 8, 10, 41, 48, 49, 52, 81, 87, 97]
Por cierto, hay una leve posibilidad de que los arreglos ant y num permanezcan igual, aún después de que num sea ordenado, es decir, si num ya
está ordenado antes de que sea clonado. Pero la probabilidad de que esto
ocurra es menor que uno en cuatro millones, ası́ que es improbable que suceda
en unos cuantos miles de pruebas, per.
Criptografı́a simple con cadenas y arreglos de caracteres
Una de las aplicaciones primarias de los arreglos es la representación de
String. Los objetos cadena son usualmente representados internamente como
un arreglo de caracteres. Aún si las cadenas pudieran estar representadas de
otra forma, hay una relación natural entre cadenas y arreglos de caracteres—
ambos usan ı́ndices para referirse a sus caracteres. Gracias a esta relación,
Java hace fácil la creación de objetos String desde arreglos de caracteres y
viceversa. Para crear un objeto String de un arreglo de caracteres A, se usa
la expresión,
new String(A)
98
Arreglos, listas enlazadas y recurrencia
Esto es, uno de los constructores de la clase String toma un arreglo de
caracteres como su argumento y regresa una cadena teniendo los mismos
caracteres en el mismo orden como en el arreglo. De igual forma, dado un
String S, se puede crear un arreglo de caracteres de la representación de S
usando la expresión,
S.toCharArray()
es decir, la clase String tiene un método, toCharArray(), el cual regresa
un arreglo, del tipo char[] con los mismos caracteres de S.
El cifrador César
Una área donde se requiere poder cambiar de una cadena a un arreglo
de caracteres y de regreso es útil en criptografı́a, la ciencia de los mensajes
secretos y sus aplicaciones. Esta área estudia varias formas de realizar el
encriptamiento, el cual toma un mensaje, denominado el texto plano, y
lo convierte en un mensaje cifrado, llamado el texto cifrado. Asimismo,
la criptografı́a también estudia las formas correspondientes de realizar el
descifrado, el cual toma un texto cifrado y lo convierte de regreso en el texto
plano original.
Es discutible que el primer esquema de encriptamiento es el cifrador de
César, el cual es nombrado después de que Julio César usó este esquema
para proteger mensajes militares importantes. El cifrador de César es una
forma simple para oscurecer un mensaje escrito en un lenguaje que forma
palabras con un alfabeto.
El cifrador involucra reemplazar cada letra en un mensaje con una letra
que está tres letras después de esta en el alfabeto para ese lenguaje. Por lo
tanto, en un mensaje del español, se podrı́a reemplazar cada A con D, cada B
con E, cada C con F, y ası́ sucesivamente. Se continúa de esta forma hasta la
letra W, la cual es reemplazada con Z. Entonces, se permite que la sustitución
del patrón dé la vuelta, por lo que se reemplaza la X con A, Y con B, y Z
con C.
Usar caracteres como ı́ndices de arreglos
Si se fueran a numerar las letras como ı́ndices de arreglos, de tal forma
que A sea 0, B sea 1, C sea 2, y ası́ sucesivamente, entonces se puede escribir
la fórmula del cifrador de César para el alfabeto inglés de la siguiente forma:
3.2 Arreglos
99
Reemplazar cada letra i con la letra (i + 3) mód 26,
donde mód es el operador módulo, el cual regresa el residuo después de
realizar una división entera. El operador se denota con % en Java, y es el
operador que se requiere para dar la vuelta en el final del alfabeto. Para el 26
el módulo es 0, para el 27 es 1, y el 28 es 2. El algoritmo de desciframiento
para el cifrador de César es exactamente lo opuesto—se reemplaza cada letra
con una que está tres lugares antes que esta, con regreso para la A, B y C.
Se puede capturar esta regla de reemplazo usando arreglos para encriptar
y descifrar. Porque cada carácter en Java está actualmente guardado como un
número—su valor Unicode—se pueden usar letras como ı́ndices de arreglos.
Para una letra mayúscula C, por ejemplo, se puede usar C como un ı́ndice
de arreglo tomando el valor Unicode de C y restando A. Lo anterior sólo
funciona para letras mayúsculas, por lo que se requerirá que los mensajes
secretos estén en mayúsculas. Se puede entonces usar un arreglo, encriptar,
que represente la regla de encriptamiento, por lo que encriptar[i] es la
letra que reemplaza la letra con código i, la cual es C − A para una letra
mayúscula C en Unicode. De igual modo, un arreglo, descrifrar, puede
representar la regla de desciframiento, por lo que descifrar[i] es la letra
que reemplaza la letra número i.
D E
0 1
F G H
2 3 4
I J
5 6
Arreglo encriptar
K L M N ... X
7 8 9 10 ... 20
Y Z A B C
21 22 23 24 25
El arreglo de encriptamiento para el cifrador César quedarı́a como se
muestra previamente. Por ejemplo, para codificar el carácter ’J’, este es
usado como ı́ndice, haciendo ’J’ - ’A’, que es calculado por Java como 74
- 65 = 9, (los valores son sus códigos Unicode). Luego al acceder el arreglo
con ese ı́ndice se obtiene el carácter ’M’.
La aplicación Cesar, listado 3.7, tiene un cifrador de César simple y
completo usando la aproximación anterior. Además realiza las conversiones
entre cadenas y arreglos de caracteres.
Cuando se ejecuta este programa, se obtiene la siguiente salida:
Orden de Encriptamiento = DEFGHIJKLMNOPQRSTUVWXYZABC
Orden de Descifrado = XYZABCDEFGHIJKLMNOPQRSTUVW
HVWUXFWXUDV GH GDWRV; DUUHJORV B OLVWDV OLJDGDV.
ESTRUCTURAS DE DATOS; ARREGLOS Y LISTAS LIGADAS.
100
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Arreglos, listas enlazadas y recurrencia
/* * Clase para encriptar y descifrar con el cifrador de C é sar . */
public class Cesar {
public static final char [] abc = { ’A ’ , ’B ’ , ’C ’ , ’D ’ , ’E ’ , ’F ’ , ’G ’ , ’H ’ , ’I ’ ,
’J ’ , ’K ’ , ’L ’ , ’M ’ , ’N ’ , ’O ’ , ’P ’ , ’Q ’ , ’R ’ , ’S ’ , ’T ’ , ’U ’ , ’V ’ , ’W ’ , ’X ’ , ’Y ’ , ’Z ’ };
protected char [] encriptar = new char [ abc . length ]; // Arreglo encrip tamiento
protected char [] descifrar = new char [ abc . length ]; // Arreglo descif ramiento
/* * Constructor para inicializar los arreglos de encri ptamien to y
* descif ramiento */
public Cesar () {
for ( int i =0; i < abc . length ; i ++)
encriptar [ i ] = abc [( i + 3) % abc . length ]; // rotar alfabeto 3 lugares
for ( int i =0; i < abc . length ; i ++)
descifrar [ encriptar [ i ] - ’A ’] = abc [ i ]; // descifrar inverso a encriptar
}
/* * M é todo de encri ptamien to */
public String encriptar ( String secreto ) {
char [] mensj = secreto . toCharArray (); // arreglo mensaje
for ( int i =0; i < mensj . length ; i ++)
// ciclo de e ncripta miento
if ( Character . isUpperCase ( mensj [ i ])) // se tiene que cambiar letra
mensj [ i ] = encriptar [ mensj [ i ] - ’A ’ ]; // usar letra como ı́ ndice
return new String ( mensj );
}
/* * M é todo de desci framien to */
public String descifrar ( String secreto ) {
char [] mensj = secreto . toCharArray ();
// arreglo mensaje
for ( int i =0; i < mensj . length ; i ++)
// ciclo desc iframien to
if ( Character . isUpperCase ( mensj [ i ]))
// se tiene letra a cambiar
mensj [ i ] = descifrar [ mensj [ i ] - ’A ’ ]; // usar letra como ı́ ndice
return new String ( mensj );
}
/* * Metodo main para probar el cifrador de C é sar */
public static void main ( String [] args ) {
Cesar cifrador = new Cesar (); // Crear un objeto cifrado de C é sar
System . out . println ( " Orden de Encri ptamien to = " +
new String ( cifrador . encriptar ));
System . out . println ( " Orden de Descifrado = " +
new String ( cifrador . descifrar ));
String secreto = " ESTRUCTURAS DE DATOS ; ARREGLOS Y LISTAS LIGADAS . " ;
secreto = cifrador . encriptar ( secreto );
System . out . println ( secreto );
// el texto cifrado
secreto = cifrador . descifrar ( secreto );
System . out . println ( secreto );
// deber ı́ a ser texto plano
}
}
Listado 3.7: Una clase simple y completa de Java para el cifrador de César
3.2 Arreglos
101
0
1
2
3
4
0
1
2
3
4
5
1
2
3
4
5
6
2
3
4
5
6
7
3
4
5
6
7
8
4
5
6
7
8
9
5
6
7
8
9
0
6
7
8
9
0
1
Cuadro 3.2: Representación del arreglo de enteros bidimensional, Y, con 5 renglones
y 7 columnas. El valor de Y[2][4] es 7.
3.2.4.
Arreglos bidimensionales y juegos de posición
Varios juegos de computadora, sean juegos de estrategia, de simulación,
o de conflicto de primera persona, usan un “tablero” bidimensional. Los
programas que manejan juegos de posición requieren una forma de representar
objetos en un espacio bidimensional. Una forma natural de hacerlo es con un
arreglo bidimensional, donde se usan dos ı́ndices, por ejemplo i y j, para
referirse a las celdas en el arreglo. El primer ı́ndice se refiere al número del
renglón y el segundo al número de la columna. Dado tal arreglo se pueden
mantener tableros de juego bidimensionales, ası́ como realizar otros tipos de
cálculos involucrando datos que están guardados en renglones y columnas.
Los arreglos en Java son unidimensionales; se puede usar un sólo ı́ndice
para acceder cada celda de un arreglo. Sin embargo, hay una forma como se
pueden definir arreglos bidimensionales en Java—se puede crear un arreglo
bidimensional como un arreglo de arreglos. Esto es, se puede definir un arreglo
de dos dimensiones para que sea un arreglo con cada una de sus celdas siendo
otro arreglo. Tal arreglo de dos dimensiones es a veces llamado una matriz.
En Java, se declara un arreglo de dos dimensiones como se muestra en el
siguiente ejemplo:
int[][] Y = new int[5][7];
La sentencia crea una “arreglo de arreglos” bidimensional, Y, el cual es
5 × 7, teniendo cinco renglones y siete columnas. Es decir, Y es un arreglo
de longitud cinco tal que cada elemento de Y es un arreglo de longitud de
siete enteros. Ver cuadro 3.2. Lo siguiente podrı́a entonces ser usos válidos
del arreglo Y y siendo i y j variables tipo int:
102
Arreglos, listas enlazadas y recurrencia
Y[i][i+1] = Y[i][i] + 3;
i = a.length;
// i es 5
j = Y[4].length; // j es 7
Se revisa enseguida una aplicación de un arreglo bidimensional implementando un juego simple posicional, para revisar algunos detalles del uso de
arreglos bidimensionales.
El gato
Es un juego que se practica en un tablero de tres por tres. Dos jugadores—
X y O—alternan en colocar sus respectivas marcas en las celdas del tablero,
iniciando con el jugador X. Si algún jugador logra obtener tres marcas suyas
en un renglón, columna o diagonal, entonces ese jugador gana.
La idea básica es usar un arreglo bidimensional, tablero, para mantener
el tablero de juego. Las celdas en este arreglo guardan valores para indicar
si la celda está vacı́a, o guarda una X, o una O. Entonces, el tablero es
una matriz de tres por tres, donde por ejemplo, el renglón central son las
celdas tablero[1][0], tablero[1][1], tablero[1][2]. Para este caso, se
decidió que las celdas en el arreglo tablero sean enteros, un cero indica celda
vacı́a, un uno indica una X, y un menos uno indica O. Esta codificación
permite tener una forma simple de probar si en una configuración del tablero
es una victoria para X o para O, sabiendo si los valores de un renglón, columna
o diagonal suman -3 o 3. Se ilustra lo anterior en la figura 3.5.
Figura 3.5: Una ilustración del juego del gato a la izquierda, y su representación
empleando el arreglo tablero.
3.2 Arreglos
103
La clase Gato, listado 3.8, modela el tablero del gato para dos jugadores.
El código sólo mantiene el tablero del gato y registra los movimientos; no
realiza ninguna estrategia, ni permite jugar al gato contra la computadora.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/* * Simulaci ó n del juego del gato ( no tiene ninguna estrategia ). */
public class Gato {
protected static final int X = 1 , O = -1; // jugadores
protected static final int VACIA = 0;
// celda vac ı́ a
protected int tablero [][] = new int [3][3]; // tablero
protected int jugador ;
// jugador actual
/* * Constructor */
public Gato () { li mpiarTa blero (); }
/* * Limpiar el tablero */
public void li mpiarTa blero () {
for ( int i = 0; i < 3; i ++)
for ( int j = 0; j < 3; j ++)
tablero [ i ][ j ] = VACIA ;
// cada celda deber á estar vac ı́ a
jugador = X ;
// el primer jugador es ’X ’
}
/* * Poner una marca X u O en la posici ó n i , j */
public void ponerMarca ( int i , int j ) throws I l l e g a l A r g u m e n t E x c e p t i o n {
if ( ( i < 0) || ( i > 2) || ( j < 0) || ( j > 2) )
throw new I l l e g a l A r g u m e n t E x c e p t i o n ( " Posici ó n invalida en el tablero " );
if ( tablero [ i ][ j ] != VACIA )
throw new I l l e g a l A r g u m e n t E x c e p t i o n ( " Posici ó n del tablero ocupada " );
tablero [ i ][ j ] = jugador ; // colocar la marca para el jugador actual
jugador = - jugador ;
// intercambiar jugadores , se usa O es -X
}
/* * Revisar si la configuraci ó n del tablero es ganadora para un jugador dado */
public boolean esGanador ( int marca ) {
return (( tablero [0][0] + tablero [0][1] + tablero [0][2] == marca *3) // ren . 0
|| ( tablero [1][0] + tablero [1][1] + tablero [1][2] == marca *3) // ren . 1
|| ( tablero [2][0] + tablero [2][1] + tablero [2][2] == marca *3) // ren . 2
|| ( tablero [0][0] + tablero [1][0] + tablero [2][0] == marca *3) // col . 0
|| ( tablero [0][1] + tablero [1][1] + tablero [2][1] == marca *3) // col . 1
|| ( tablero [0][2] + tablero [1][2] + tablero [2][2] == marca *3) // col . 2
|| ( tablero [0][0] + tablero [1][1] + tablero [2][2] == marca *3) // diag .
|| ( tablero [2][0] + tablero [1][1] + tablero [0][2] == marca *3)); // diag .
}
/* * Regresar el jugador ganador o 0 para indicar empate */
public int ganador () {
if ( esGanador ( X ))
return ( X );
else if ( esGanador ( O ))
return ( O );
else
return (0);
}
/* * Regresar una cadena simple mostrando el tablero actual */
public String toString () {
String s = " " ;
for ( int i =0; i <3; i ++) {
for ( int j =0; j <3; j ++) {
switch ( tablero [ i ][ j ]) {
case X :
s += " X " ; break ;
case O :
s += " O " ; break ;
104
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
Arreglos, listas enlazadas y recurrencia
case
VACIA : s += " " ; break ;
}
if ( j < 2) s += " | " ;
// l ı́ mite de columna
}
if ( i < 2) s += " \n - - - - -\ n " ;
// l ı́ mite de renglon
}
return s ;
}
/* * Ejecuci ó n de prueba de un juego simple */
public static void main ( String [] args ) {
Gato juego = new Gato ();
/* mueve X : */
/* mueve O : */
juego . ponerMarca (1 ,1);
juego . ponerMarca (0 ,2);
juego . ponerMarca (2 ,2);
juego . ponerMarca (0 ,0);
juego . ponerMarca (0 ,1);
juego . ponerMarca (2 ,1);
juego . ponerMarca (1 ,2);
juego . ponerMarca (1 ,0);
juego . ponerMarca (2 ,0);
System . out . println ( juego . toString () );
int jugador Ganador = juego . ganador ();
if ( jugadorG anador != 0)
System . out . println ( jug adorGana dor + " gana " );
else
System . out . println ( " Empate " );
}
}
Listado 3.8: Clase Gato para simular el tablero del juego del gato.
Se muestra en la figura 3.6 el ejemplo de salida de la clase Gato.
O|X|O
----O|X|X
----X|O|X
Empate
Figura 3.6: Salida del ejemplo del juego del gato
Métodos para arreglos de arreglos
Se habı́a señalado en la sección 1.5, que una referencia arreglo apunta a
un objeto arreglo. Si se tienen dos arreglos bidimensionales, A y B, con ambos
3.2 Arreglos
105
teniendo las mismas entradas, se podrı́a decir que el arreglo A es igual a B.
Pero los arreglos unidimensionales que forman los renglones de A y B están
guardados en localidades de memoria diferentes, a pesar de que tengan el
mismo contenido interno. Por lo tanto, una llamada al método java.util.Arrays.equals(A,B) devolverá false en este caso. La razón de lo anterior
es porque el método equals() realiza una igualdad superficial, es decir,
prueba sólo si los elementos correspondientes en A y B son iguales entre ellos
usando sólo una noción simple de igualdad. La regla igualdad simple dice
que dos variables tipo base son iguales si estas tienen el mismo valor y dos
referencias objetos son iguales si ambas se refieren al mismo objeto. Cuando
se quiere realizar una igualdad profunda para arreglos de objetos, como los
arreglos bidimensionales, la clase java.util.Arrays proporciona el siguiente
método:
deepEquals(A,B) Devuelve si A y B son iguales profundamente. Serán profundamente iguales A y B si
estos tienen la misma cantidad de elementos y los dos elementos A[i] y B[i] en cada
pareja de elementos correspondientes son
por sı́ mismos iguales en el sentido simple,
son arreglos del mismo tipo primitivo tal
que Arrays.equals(A[i],B[i]) podrı́an
devolver true, o son arreglos de referencia objeto tal que A[i] y B[i] son iguales
profundamente.
Además de la igualdad profunda, se quiere un método profundo para
convertir un arreglo bidimensional o un arreglo de referencias objeto en una
cadena. Tal método es proporcionado también por la clase java.util.Arrays
deepToString(A) Devuelve una cadena representando los
contenidos de A. Para convertir un elemento A[i] a una cadena, si A[i] es
una referencia arreglo entonces se llama a
Arrays.deepToString(A[i]), de otro modo el método String.valueOf(A[i]) es
usado.
No está disponible un método para hacer una copia idéntica de un arreglo
bidimensional, por lo tanto, si se quiere hacer lo anterior, se necesita hacer una
llamada a A[i].clone() o java.util.Arrays.copyOf(A[i],A[i].length)
para cada renglón, A[i], de A.
106
3.3.
Arreglos, listas enlazadas y recurrencia
Listas simples enlazadas
Los arreglos son elegantes y simples para guardar cosas en un determinado
orden, pero tienen el inconveniente de no ser muy adaptables, ya que se tiene
que fijar el tamaño del arreglo por adelantado.
Hay otras formas de guardar una secuencia de elementos, que no tiene el
inconveniente de los arreglos. En esta sección se explora una implementación
alterna, la cual es conocida como lista simple enlazada.
Una lista enlazada, en su forma más simple, es una colección de nodos
que juntos forman un orden lineal. El ordenamiento está determinado de tal
forma que cada nodo es un objeto que guarda una referencia a un elemento y
una referencia, llamado siguiente, a otro nodo. Ver la figura 3.7.
Figura 3.7: Ejemplo de una lista simple enlazada cuyos elementos son cadenas
indicando códigos de aeropuertos. El apuntador sig de cada nodo se muestra como
una flecha. El objeto null es denotado por Ø.
Podrı́a parecer extraño tener un nodo que referencia a otro nodo, pero tal
esquema trabaja fácilmente. La referencia sig dentro de un nodo puede ser
vista como un enlace o apuntador a otro nodo. De igual nodo, moverse de un
nodo a otro siguiendo una referencia a sig es conocida como salto de enlace
o salto de apuntador. El primer nodo y el último son usualmente llamados la
cabeza y la cola de la lista, respectivamente. Ası́, se puede saltar a través de
la lista iniciando en la cabeza y terminando en la cola. Se puede identificar la
cola como el nodo que tenga la referencia sig como null, la cual indica el
final de la lista. Una lista enlazada definida de esta forma es conocida como
una lista simple enlazada.
Como en un arreglo, una lista simple enlazada guarda sus elementos en
un cierto orden. Este orden está determinado por la cadenas de enlaces sig
yendo desde cada nodo a su sucesor en la lista. A diferencia de un arreglo, una
lista simple enlazada no tiene un tamaño fijo predeterminado, y usa espacio
proporcional al número de sus elementos. Asimismo, no se emplean números
ı́ndices para los nodos en una lista enlazada. Por lo tanto, no se puede decir
sólo por examinar un nodo si este es el segundo, quinto u otro nodo en la
lista.
3.3 Listas simples enlazadas
107
Implementación de una lista simple enlazada
Para implementar una lista simple enlazada, se define la clase Nodo, listado
3.9, la cual indica el tipo de los objetos guardados en los nodos de la lista.
Para este caso se asume que los elementos son String. También es posible
definir nodos que puedan guardar tipos arbitrarios de elementos. Dada la
clase Nodo, se puede definir una clase, ListaSimple, listado 3.10, para definir
la lista enlazada actual. Esta clase guarda una referencia al nodo cabeza y
una variable guarda el total de nodos de la lista.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* * Nodo de una lista simple enlazada de cadenas . */
public class Nodo {
private String elemento ; // Los elementos son cadenas de caracteres
private Nodo sig ;
/* * Se crea un nodo con el elemento dado y el nodo sig . */
public Nodo ( String e , Nodo n ) {
elemento = e ;
sig = n ;
}
/* * Regresa el elemento de este nodo . */
public String getElemento () { return elemento ; }
/* * Regresa el nodo siguiente de este nodo . */
public Nodo getSig () { return sig ; }
// m é todos modificadores
/* * Pone el elemento de este nodo . */
public void setElemento ( String nvoElem ) { elemento = nvoElem ; }
/* * Pone el nodo sig de este nodo . */
public void setSig ( Nodo nvoSig ) { sig = nvoSig ; }
}
Listado 3.9: Implementación de un nodo de una lista simple enlazada
1
2
3
4
5
6
7
8
9
10
11
/* * Lista simple enlazada . */
public class ListaSimple {
protected Nodo cabeza ; // nodo cabeza de la lista
protected long tam ;
// cantidad de nodos en la lista
/* * constructor por defecto para crear una lista vac ı́ a */
public ListaSimple () {
cabeza = null ;
tam = 0;
}
// ... m é todos de actualizacion y b ú squeda deber ı́ an ir aqu ı́
}
Listado 3.10: Implementación parcial de una lista simple enlazada
108
3.3.1.
Arreglos, listas enlazadas y recurrencia
Inserción en una lista simple enlazada
Cuando se usa una lista simple enlazada, se puede fácilmente insertar un
elemento en la cabeza de la lista, como se muestra en la figura 3.8.
Figura 3.8: Inserción de un elemento en la cabeza de una lista simple enlazada: (a)
antes de la inserción; (b) creación de un nuevo nodo; (c) después de la inserción.
La idea principal es crear un nodo nuevo, poner su enlace sig para que se
refiera al mismo objeto que la cabeza, y luego poner la cabeza apuntando al
nuevo nodo.
Se muestra enseguida el algoritmo para insertar un nodo nuevo v al inicio
de una lista simple enlazada. Observar que este método trabaja aún si la lista
está vacı́a. Observar que se ha puesto el apuntador sig para el nuevo nodo v
antes de hacer que la variable cabeza apunte a v.
Algoritmo agregarInicio(v):
v.setSiguiente(cabeza) {hacer que v apunte al viejo nodo cabeza}
cabeza ← v {hacer que la variable cabeza apunte al nuevo nodo}
tam ← tam + 1 {incrementar la cuenta de nodos }
3.3 Listas simples enlazadas
109
Inserción de un elemento en la cola
Se puede también fácilmente insertar un elemento en la cola de la lista,
cuando se ha proporcionado una referencia al nodo cola, como se muestra en
la figura 3.9.
Figura 3.9: Inserción en la cola de una lista simple enlazada: (a) antes de la inserción;
(b) creación de un nuevo nodo; (c) después de la inserción. Observar que se puso el
enlace sig para cola en (b) antes de asignar a la variable cola para que apunte al
nuevo nodo en (c).
En este caso, se crea un nuevo nodo, se asigna su referencia sig para que
apunte al objeto null, se pone la referencia sig de la cola para que apunte a
este nuevo objeto, y entonces se asigna a la referencia cola este nuevo nodo.
El algoritmo agregarFinal inserta un nuevo nodo al final de la lista simple
enlazada. El método también trabaja si la lista está vacı́a. Se pone primero
el apuntador sig para el viejo nodo cola antes de hacer que la variable cola
apunte al nuevo nodo.
Algoritmo agregarFinal(v):
v.setSiguiente(null) {hacer que el nuevo nodo v apunte al objeto null}
cola.setSiguiente(v) {el viejo nodo cola apuntará al nuevo nodo}
cola ← v { hacer que la variable cola apunte al nuevo nodo }
tam ← tam + 1 {incrementar la cuenta de nodos }
110
3.3.2.
Arreglos, listas enlazadas y recurrencia
Quitar un elemento de una lista simple enlazada
La operación contraria de insertar un nuevo elemento en la cabeza de una
lista enlazada es quitar un elemento en la cabeza. Esta operación es ilustrada
en la figura 3.10.
Figura 3.10: Remoción de un elemento en la cabeza de una lista simple enlazada:
(a) antes de la remoción; (b) desligando el viejo nodo; (c) después de la remoción.
En el algoritmo removerPrimero se muestra la forma de realizar la
remoción del elemento en la cabeza de la lista.
Algoritmo removerPrimero():
si cabeza = null Entonces
Indicar un error: la lista está vacı́a.
t ← cabeza
cabeza ← cabeza.getSiguiente() { cabeza apuntará al siguiente nodo }
t.setSiguiente(null) { anular el apuntador siguiente del nodo borrado.}
tam ← tam − 1 { decrementar la cuenta de nodos }
Desafortunadamente, no se puede borrar fácilmente el nodo cola de una
lista simple enlazada. Aún si se tiene una referencia cola directamente al
3.4 Listas doblemente enlazadas
111
último nodo de la lista, se necesita poder acceder el nodo anterior al último
nodo en orden para quitar el último nodo. Pero no se puede alcanzar el nodo
anterior a la cola usando los enlaces siguientes desde la cola. La única forma
para acceder este nodo es empezar en la cabeza de la lista y moverse a través
de la lista. Pero tal secuencia de operaciones de saltos de enlaces podrı́a tomar
un tiempo grande.
3.4.
Listas doblemente enlazadas
Como se comentaba en la sección previa, quitar un elemento de la cola de
una lista simple enlazada no es fácil. Además, el quitar cualquier otro nodo
de la lista diferente de la cabeza de una lista simple enlazada es consumidor
de tiempo, ya que no se tiene una forma rápida de acceder al nodo que se
desea quitar. Por otra parte, hay varias aplicaciones donde no se tiene un
acceso rápido tal como el acceso al nodo predecesor. Para tales casos, serı́a
bueno tener manera de ir en ambas direcciones en una lista enlazada.
Hay un tipo de lista enlazada que permite ir en ambas direcciones, hacia
adelante y hacia atrás. Esta es la lista doblemente enlazada ó LDE. Tal
lista permite una gran variedad de operaciones rápidas de actualización,
incluyendo la inserción y el borrado en ambos extremos, y en el centro. Un
nodo en una LDE guarda dos referencias—un enlace sig, el cual apunta al
siguiente nodo en la lista, y un enlace prev, el cual apunta al nodo previo en
la lista.
Una implementación de un nodo de una LDE se muestra en la clase NodoD,
listado 3.11, donde se asume que los elementos son cadenas de caracteres.
Centinelas cabeza y cola
Para simplificar la programación, es conveniente agregar nodos especiales
en los extremos de la LDE: un nodo cabeza (header ) justo antes de la cabeza
de la lista, y un nodo cola (trailer ) justo después de la cola de la lista. Estos
nodos “falsos”, o centinelas, no guardan a ningún elemento. La cabeza tiene
una referencia sig válida pero una referencia prev nula, mientras cola tiene
una referencia prev válida pero una referencia sig nula. Una LDE con estos
centinelas se muestra en la figura 3.11.
Un objeto lista enlazada podrı́a necesitar guardar referencias a estos
dos centinelas y un contador tam que guarde el número de elementos, sin
112
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Arreglos, listas enlazadas y recurrencia
/* * Nodo de una lista doblemente enlazada de una lista de cadenas */
public class NodoD {
protected String elemento ; // Nodo guarda un elemento String
protected NodoD sig , prev ; // Apuntadores a nodos siguiente y previo
/* * Constructor que crea un nodo con los campos dados */
public NodoD ( String e , NodoD p , NodoD s ) {
elemento = e ;
prev = p ;
sig = s ;
}
/* * Regresa el elemento de este nodo */
public String getElemento () { return elemento ; }
/* * Regresa el nodo previo de este nodo */
public NodoD getPrev () { return prev ; }
/* * Regresa el nodo siguiente de este nodo */
public NodoD getSig () { return sig ; }
/* * Pone el elemento de este nodo */
public void setElemento ( String nvoElem ) { elemento = nvoElem ; }
/* * Pone el nodo previo de este nodo */
public void setPrev ( NodoD nvoPrev ) { prev = nvoPrev ; }
/* * Pone el nodo siguiente de este nodo */
public void setSig ( NodoD nvoSig ) { sig = nvoSig ; }
}
Listado 3.11: Clase NodoD representando un nodo de una lista doblemente enlazada
que guarda una cadena de caracteres.
Figura 3.11: Una lista doblemente enlazada con centinelas, cabeza y cola, marcando
el final de la lista.
3.4 Listas doblemente enlazadas
113
considerar a los centinelas, en la lista.
Insertar o remover elementos en cualquier extremo de la LDE se hace
directo. Además, el enlace prev elimina la necesidad de recorrer la lista para
obtener el nodo que está antes de la cola. Se muestran los detalles de la
operación remover en la cola de la lista doblemente enlazada en la figura 3.12.
Figura 3.12: Remoción del nodo al final de una lista doblemente enlazada con
centinelas: (a) antes de borrar en la cola; (b) borrando en la cola; (c) despueś del
borrado.
Los detalles de la remoción en la cola de la LDE se muestran en el algoritmo
removerÚltimo.
Algoritmo removerÚltimo():
si tam = 0 entonces
Indicar un error: la lista está vacı́a.
v ← cola.getP rev() { último nodo }
u ← v.getP rev() { nodo anterior al último nodo }
cola.setP rev(u)
u.setSig(cola)
v.setP rev(null)
v.setSig(null)
tam ← tam − 1 { decrementar la cuenta de nodos }
De igual modo, se puede fácilmente hacer una inserción de un nuevo
elemento al inicio de una LDE, como se muestra en la figura 3.13.
La implementación de la inserción de un nuevo nodo v al inicio, de acuerdo
al algoritmo agregarInicio, donde la variable tam guarda la cantidad de
114
Arreglos, listas enlazadas y recurrencia
Figura 3.13: Inserción de un elemento en el frente: (a) previo; (b) final.
elementos en la lista. Este algoritmo también trabaja con una lista vacı́a.
Algoritmo agregarInicio(v):
w ← cabeza.getSig() { primer nodo }
v.setSig(w)
v.setP rev(cabeza)
w.setP rev(v)
cabeza.setSig(v)
tam ← tam + 1
3.4.1.
Inserción en el centro de la LDE
Las listas dobles enlazadas no nada mas son útiles para insertar y remover
elementos en la cabeza o en la cola de la lista. Estas también son convenientes
para mantener una lista de elementos mientras se permite inserción y remoción
en el centro de la lista. Dado un nodo v de una lista doble enlazada, el cual
posiblemente podrı́a ser la cabeza pero no la cola, se puede fácilmente insertar
un nuevo nodo z inmediatamente después de v. Especı́ficamente, sea w el
siguiente nodo de v. Se ejecutan los siguientes pasos.
1. Hacer que el enlace prev de z se refiera a v.
2. Hacer que el enlace sig de z se refiera a w.
3. Hacer que el enlace prev de w se refiera a z.
4. Hacer que el enlace sig de v se refiera a z.
3.4 Listas doblemente enlazadas
115
Este método está dado en detalle en el algoritmo agregarDespués y es
ilustrado en la figura 3.14. Recordando el uso de los centinelas cabeza y cola,
este algoritmo trabaja aún si v es el nodo cola, el nodo que está justo antes
del centinela cola.
Figura 3.14: Inserción de un nuevo nodo después del nodo que guarda JFK: (a)
creación de un nuevo nodo con el elemento BWI y enlace de este; (b) después de la
inserción.
Algoritmo agregarDespues(v, z):
w ← v.getSig() { nodo después de v }
z.setP rev(v) { enlazar z a su predecesor, v }
z.setSig(w) { enlazar z a su sucesor, w }
w.setP rev(z) { enlazar w a su nuevo predecesor, z }
v.setSig(z) { enlazar v a su nuevo sucesor, z }
tam ← tam + 1
3.4.2.
Remoción en el centro de la LDE
De igual modo, es fácil remover un nodo v intermedio de una lista doblemente enlazada. Se acceden los nodos u y w a ambos lados de v usando
sus métodos getPrev y getSig, estos nodos deberán existir, ya que se están
usando centinelas. Para quitar el nodo v, se tiene que hacer que u y w se
apunten entre ellos en vez de hacerlo hacia v. Esta operación es referida como
desenlazar a v. También se anulan los apuntadores prev y sig de v para que
ya no retengan referencias viejas en la lista. El algoritmo remover muestra
como quitar el nodo v aún si v es el primer nodo, el último o un nodo no
centinela y se ilustra en la figura 3.15.
116
Arreglos, listas enlazadas y recurrencia
Figura 3.15: Borrado del nodo que guarda PVD: (a) antes del borrado; (b) desenlace
del viejo nodo; (c) después de la remoción.
Algoritmo remover(v):
u ← v.getP rev() { nodo predecesor a v }
w ← v.getSig() { nodo sucesor a v }
w.setP rev(u) { desenlazar v }
u.setSig(w)
v.setP rev(null) { anular los campos de v }
v.setSig(null)
tam ← tam − 1 { decrementar el contador de nodos }
3.4.3.
Una implementación
La clase ListaDoble, listado 3.12, muestra una implementación de una
LDE con nodos que guardan un String como elementos. Enseguida se dan
un conjunto de observaciones de la clase ListaDoble.
Los objetos de la clase NodoD, los cuales guardan elementos String, son
usados para todos los nodos de la lista, incluyendo los nodos centinela
cabeza y cola.
Se puede usar la clase ListaDoble para una LDE de objetos String
3.4 Listas doblemente enlazadas
117
solamente. Para construir una lista de otros tipos, se puede usar una
declaración genérica.
Los métodos getPrimero() y getUltimo() dan acceso directo al primer
nodo y el último en la lista.
Los métodos getPrev() y getSig() permiten recorrer la lista.
Los métodos tienePrev() y tieneSig() detectan los lı́mites de la lista.
Los métodos agregarInicio() y agregarFinal() agregan un nuevo
nodo al inicio y al final de la lista, respectivamente.
Los métodos agregarAntes() y agregarDespues() agregan un nodo
antes y después de un nodo existente, respectivamente.
Tener sólo un método para borrar, remover(), no es actualmente una
restricción, ya que se puede remover al inicio o al final de una LDE L ejecutando L.remover(L.getPrimero()) o L.remover(L.getUltimo()),
respectivamente.
El método toString() que convierte la lista entera en una cadena es
útil para propósitos de prueba y depuración.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* * Lista doblemente enlazada de nodos tipo NodoD para cadenas . */
public class ListaDoble {
protected int tam ;
// cantidad de elementos
protected NodoD cabeza , cola ; // centinelas
/* * Constructor que crea una lista vac ı́ a */
public ListaDoble () {
tam = 0;
cabeza = new NodoD ( null , null , null ); // crer cabeza
cola = new NodoD ( null , cabeza , null ); // crear terminaci ó n
cabeza . setSig ( cola ); // cabeza apunta a terminaci ó n
}
/* * Regresa la cantidad de elementos en la lista */
public int tam () { return tam ; }
/* * Indica si la lista esta vac ı́ a */
public boolean estaVacia () { return ( tam == 0); }
/* * Regresa el primer nodo de la lista */
public NodoD getPrimero () throws I l l e g a l S t a t e E x c e p t i o n {
if ( estaVacia ()) throw new I l l e g a l S t a t e E x c e p t i o n ( " La lista esta vac ı́ a " );
return cabeza . getSig ();
}
/* * Regresa el ú ltimo nodo de la lista */
public NodoD getUltimo () throws I l l e g a l S t a t e E x c e p t i o n {
if ( estaVacia ()) throw new I l l e g a l S t a t e E x c e p t i o n ( " La lista esta vac ı́ a " );
return cola . getPrev ();
118
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
Arreglos, listas enlazadas y recurrencia
}
/* * Regresa el nodo anterior al nodo v dado . Un error ocurre si v
* es la cabeza */
public NodoD getPrev ( NodoD v ) throws I l l e g a l A r g u m e n t E x c e p t i o n {
if ( v == cabeza ) throw new I l l e g a l A r g u m e n t E x c e p t i o n
( " No se puede mover hacia atr á s de la cabeza de la lista " );
return v . getPrev ();
}
/* * Regresa el nodo siguiente al nodo v dado . Un error ocurre si v
* es la cola */
public NodoD getSig ( NodoD v ) throws I l l e g a l A r g u m e n t E x c e p t i o n {
if ( v == cola ) throw new I l l e g a l A r g u m e n t E x c e p t i o n
( " No se puede mover hacia adelante de la terminaci ó n de la lista " );
return v . getSig ();
}
/* * Inserta el nodo z dado antes del nodo v dado . Un error
* ocurre si v es la cabeza */
public void agregarAntes ( NodoD v , NodoD z ) throws I l l e g a l A r g u m e n t E x c e p t i o n {
NodoD u = getPrev ( v ); // podr ı́ a lanzar un I l l e g a l A r g u m e n t E x c e p t i o n
z . setPrev ( u );
z . setSig ( v );
v . setPrev ( z );
u . setSig ( z );
tam ++;
}
/* * Inserta el nodo z dado despues del nodo v dado . Un error
* ocurre si v es la cabeza */
public void ag regarDes pues ( NodoD v , NodoD z ) {
NodoD w = getSig ( v ); // podr ı́ a lanzar un I l l e g a l A r g u m e n t E x c e p t i o n
z . setPrev ( v );
z . setSig ( w );
w . setPrev ( z );
v . setSig ( z );
tam ++;
}
/* * Inserta el nodo v dado en la cabeza de la lista */
public void agregarInicio ( NodoD v ) {
agre garDespu es ( cabeza , v );
}
/* * Inserta el nodo v dado en la cola de la lista */
public void agregarFinal ( NodoD v ) {
agregarAntes ( cola , v );
}
/* * Quitar el nodo v dado de la lista . Un error ocurre si v es
* la cabeza o la cola */
public void remover ( NodoD v ) {
NodoD u = getPrev ( v ); // podr ı́ a lanzar I l l e g a l A r g u m e n t E x c e p t i o n
NodoD w = getSig ( v ); // podr ı́ a lanzar I l l e g a l A r g u m e n t E x c e p t i o n
// desenlazar el nodo v de la lista
w . setPrev ( u );
u . setSig ( w );
v . setPrev ( null );
v . setSig ( null );
tam - -;
}
/* * Regresa si un nodo v dado tiene un nodo previo */
public boolean tienePrev ( NodoD v ) { return v != cabeza ; }
3.5 Listas circulares y ordenamiento
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
119
/* * Regresa si un nodo v dado tiene un nodo siguiente */
public boolean tieneSig ( NodoD v ) { return v != cola ; }
/* * Regresa una cadena representando la lista */
public String toString () {
String s = " [ " ;
NodoD v = cabeza . getSig ();
while ( v != cola ) {
s += v . getElemento ();
v = v . getSig ();
if ( v != cola )
s += " ," ;
}
s += " ] " ;
return s ;
}
}
Listado 3.12: Una clase de lista doblemente enlazada.
3.5.
Listas circulares y ordenamiento
En esta sección se revisan algunas aplicaciones y extensiones de listas
enlazadas.
3.5.1.
Lista circular enlazada
El juego de niños, “Pato, Pato, Ganso”, es jugado en varias culturas. Una
variación de la lista simple enlazada, llamada la lista circular enlazada, es
usada para una variedad de aplicaciones involucrando juegos circulares, como
el juego que se comenta. Enseguida se muestra la lista circular y su aplicación
en un juego circular.
Una lista circular enlazada tiene el mismo tipo de nodos que una lista
simple enlazada. Esto es, cada nodo en una lista circular enlazada tiene un
apuntador siguiente y una referencia a un elemento. Pero no hay una cabeza
o cola en la lista circular enlazada. En vez de hacer que el apuntador del
último nodo sea null, se hace que apunte de regreso al primer nodo. Por lo
tanto, no hay un primer nodo o último. Si se recorren los nodos de una lista
circular enlazada desde cualquier nodo usando los apuntadores sig, entonces
se hará un ciclo a través de los nodos.
Aún cuando una lista circular enlazada no tiene inicio o terminación, no
obstante se necesita que algún nodo esté marcado como especial, el cual
será llamado cursor. El nodo cursor permite tener un lugar para iniciar si se
120
Arreglos, listas enlazadas y recurrencia
requiere recorrer una lista circular enlazada. Y si se recuerda esta posición
inicial, entonces también se puede saber cuando se haya terminado con un
recorrido en la lista circular enlazada, porque es cuando se regresa al nodo
que fue el nodo cursor cuando se inició.
Se pueden entonces definir algunos métodos simples de actualización para
una lista circular enlazada:
agregar(v):inserta un nuevo nodo v inmediatamente después
del cursor; si la lista está vacı́a, entonces v se
convierte en el cursor y su apuntador sig apunta
a él mismo.
remover():borra y regresa el nodo v inmediatamente después
del cursor (no el propio cursor, a menos que este
sea el único nodo); si la lista queda vacı́a, el cursor
es puesto a null.
avanzar():pasa el cursor al siguiente nodo en la lista.
La clase ListaCircular, listado 3.13, muestra una implementación Java
de una lista circular enlazada, la cual usa la clase Nodo, listado 3.9. También
incluye un método toString() para generar una representación de cadena
de la lista.
Algunas observaciones acerca de la clase ListaCircular
Es un programa simple que puede dar suficiente funcionalidad para simular
juegos circulares, como Pato, Pato, Ganso, como se ve enseguida. No es un
programa robusto, sin embargo. En particular, si una lista circular está vacı́a,
entonces llamar avanzar o remover en esa lista causará una excepción.
Pato, Pato, Ganso
En el juego de niños Pato, Pato, Ganso, un grupo de niños se sienta en
cı́rculo. Uno de ellos es ellos es elegido para quedarse parado y este camina
alrededor del cı́rculo por fuera. El niño elegido toca a cada niño en la cabeza,
diciendo “Pato” o “Ganso”. Cuando el elegido decide llamar a alguien el
“Ganso”, el “Ganso” se levanta y persigue al otro niño alrededor del cı́rculo.
Si el “Ganso” no logra tocar al elegido, entonces a él le toca quedarse para la
próxima ronda, sino el elegido lo seguirá siendo en la siguiente ronda.
3.5 Listas circulares y ordenamiento
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
121
/* * Lista circularmente enlazada con nodos de tipo Nodo guardando cadenas . */
public class ListaCircular {
protected Nodo cursor ; // cursor actual
protected int tam ;
// cantidad de nodos en la lista
/* * Constructor que crea una lista vacia */
public ListaCircular () { cursor = null ; tam = 0; }
/* * Regresa cantidad de nodos */
public int tam () { return tam ; }
/* * Regresa cursor */
public Nodo getCursor () { return cursor ; }
/* * Mueve cursor hacia adelante */
public void avanzar () { cursor = cursor . getSig (); }
/* * Agrega nodo despu é s del cursor */
public void agregar ( Nodo nodoNuevo ) {
if ( cursor == null ) {
// ¿ lista vacia ?
nodoNuevo . setSig ( nodoNuevo );
cursor = nodoNuevo ;
}
else {
nodoNuevo . setSig ( cursor . getSig ());
cursor . setSig ( nodoNuevo );
}
tam ++;
}
/* * Quita nodo despu é s del cursor */
public Nodo remover () {
Nodo nodo = cursor . getSig (); // nodo que ser á removido
if ( nodo == cursor )
cursor = null ; // la lista quedar á vac ı́ a
else {
cursor . setSig ( nodo . getSig () ); // desenlazar el nodo
nodo . setSig ( null );
}
tam - -;
return nodo ;
}
/* * Devuelve una representaci ó n String de la lista ,
* iniciando desde el cursor */
public String toString () {
if ( cursor == null ) return " [] " ;
String s = " [... " + cursor . getElemento ();
Nodo cursorOrig = cursor ;
for ( avanzar (); cursorOrig != cursor ; avanzar () )
s += " , " + cursor . getElemento ();
return s + " ...] " ;
}
}
Listado 3.13: Una clase de lista circular enlazada con nodos simples.
122
Arreglos, listas enlazadas y recurrencia
Simular este juego es una aplicación ideal de una lista circular enlazada.
Los nodos pueden representar a los niños que están sentados en cı́rculo. El
niño “elegido” puede ser identificado como el niño que está sentado después
del cursor, y puede ser removido del cı́rculo para simular el recorrido alrededor.
Se puede avanzar el cursor con cada “Pato” que el elegido identifique, lo
cual se puede simular con una decisión aleatoria. Una vez que un “Ganso”
es identificado, se puede remover este nodo de la lista, hacer una selección
aleatoria para simular si el “Ganso” alcanzó al elegido, e insertar el ganador
en la lista. Se puede entonces avanzar el cursor e insertar al ganador y repetir
el proceso, o terminar si esta es la última partida del juego.
Lista circular enlazada para Pato, Pato, Ganso
La clase PatoPatoGanso, listado 3.14, muestra el código para simular el
juego Pato, Pato, Ganso.
Se muestra un ejemplo de la salida de la ejecución del programa Pato,
Pato, Ganso a continuación:
Los jugadores son: [...Pedro, Alex, Eli, Paco, Gabi, Pepe, Luis, To~
no...]
Alex es el elegido.
Jugando Pato, Pato, Ganso con: [...Pedro, Eli, Paco, Gabi, Pepe, Luis, To~
no...]
Eli es un pAto.
¡Paco es el ganso!
¡El ganso ganó!
Alex es el elegido.
Jugando Pato, Pato, Ganso con: [...Paco, Gabi, Pepe, Luis, To~
no, Pedro, Eli...]
Gabi es un pato.
¡Pepe es el ganso!
¡El ganso perdió!
Pepe es el elegido.
Jugando Pato, Pato, Ganso con: [...Alex, Luis, To~
no, Pedro, Eli, Paco, Gabi...]
Luis es un pato.
To~
no es un pato.
¡Pedro es el ganso!
¡El ganso ganó!
El circulo final es [...Pedro, Pepe, Eli, Paco, Gabi, Alex, Luis, To~
no...]
Observar que cada iteración en esta ejecución del programa genera una
salida diferente, debido a las configuraciones iniciales diferentes y el uso de
opciones aleatorias para identificar patos y gansos. Además, la decisión de
que el ganso alcance al jugador parado es una decisión aleatoria.
3.5 Listas circulares y ordenamiento
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
123
import java . util . Random ;
public class PatoPatoGanso {
/* * Simulaci ó n de Pato , Pato , Ganso con una lista circular enlazada . */
public static void main ( String [] args ) {
ListaCircular lc = new ListaCircular ();
int n = 3; // veces que se jugar á el juego
Nodo eleg ; // jugador elegido
Nodo ganso ; // el ganso
Random rand = new Random ();
rand . setSeed ( System . c u r r e n t T i m e M i l l i s () ); // tiempo actual como semilla
// Los jugadores ...
String [] noms = { " Alex " ," Eli " ," Paco " ," Gabi " ," Pepe " ," Luis " ," To ~
n o " ," Pedro " };
for ( String nombre : noms ) {
lc . agregar ( new Nodo ( nombre , null ) );
lc . avanzar ();
}
System . out . println ( " Los jugadores son : " + lc . toString ());
for ( int i = 0; i < n ; i ++) {
// jugar Pato , Pato , Ganso n veces
eleg = lc . remover ();
System . out . println ( eleg . getElemento () + " es el elegido . " );
System . out . println ( " Jugando Pato , Pato , Ganso con : " + lc . toString ());
// marchar alrededor del circulo
while ( rand . nextBoolean () || rand . nextBoolean ()) {
lc . avanzar (); // avanzar con probabilidad 3/4
System . out . println ( lc . getCursor (). getElemento () + " es un pato . " );
}
ganso = lc . remover ();
System . out . println ( " ¡ " + ganso . getElemento () + " es el ganso ! " );
if ( rand . nextBoolean () ) {
System . out . println ( " ¡ El ganso gan ó ! " );
lc . agregar ( ganso ); // meter ganso al c ı́ rculo
lc . avanzar ();
// cursor est á sobre el ganso
lc . agregar ( eleg ); // elegido seguir á si é ndolo en la pr ó xima ronda
}
else {
System . out . println ( " ¡ El ganso perdi ó ! " );
lc . agregar ( eleg ); // poner elegido en lugar del ganso
lc . avanzar ();
// cursor est á sobre persona elegida
lc . agregar ( ganso ); // ganso es el elegido en la siguiente ronda
}
}
System . out . println ( " El circulo final es " + lc . toString ());
}
}
Listado 3.14: El método main() de una aplicación que usa una lista circular enlazada
para simular el juego de niños Pato, Pato, Ganso.
124
3.5.2.
Arreglos, listas enlazadas y recurrencia
Ordenar una LDE
Para ordenar una LDE se puede emplear el algoritmo InsercionOrdenada.
Algoritmo InsercionOrdenada(L):
Entrada: Una lista L doblemente enlazada de elementos comparables
Salida: La lista L con elementos rearreglados en orden creciente
si L.tam() ≤ 1 entonces
regresar
f in ← L.getP rimero()
mientras f in no sea el último nodo en L hacer
pivote ← f in.getSig()
Quitar pivote de L
ins ← f in
mientras ins no sea la cabeza y el elemento de ins sea mayor
que el del pivote hacer
ins ← ins.getP rev()
Agregar pivote justo después de ins en L
si ins = f in entonces { pivote no se movió por ser el mayor}
f in ← f in.getSig()
Una implementación en Java del algoritmo de inserción ordenada para una
lista doblemente enlazada representada por la clase ListaDoble se muestra
en el listado 3.15.
3.6.
Recurrencia
La repetición se puede lograr escribiendo ciclos, como por ejemplo con
for, o con while. Otra forma de lograr la repetición es usando recurrencia,
la cual ocurre cuando una función se llama a sı́ misma. Se han visto ejemplos
de métodos que llaman a otros métodos, por lo que no deberı́a ser extraño que
muchos de los lenguajes de programación actual, incluyendo Java, permitan
que un método se llame a sı́ mismo. En esta sección, se verá porque esta
capacidad da una alternativa poderosa para realizar tareas repetitivas.
La función factorial
Para ilustrar la recurrencia, se inicia con un ejemplo sencillo para calcular
el valor de la función factorial. El factorial de un entero positivo n, denotada
3.6 Recurrencia
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
125
/* * Inserci ó n ordenada para una lista doblemente enlazada
de la clase ListaDoble . */
public static void ordenar ( ListaDoble L ) {
if ( L . tam () <= 1) return ; // L ya est á ordenada en este caso
NodoD pivote ; // nodo pivote
NodoD ins ;
// punto de inserci ó n
NodoD fin = L . getPrimero (); // fin de la corrida
while ( fin != L . getUltimo ()) {
pivote = fin . getSig (); // obtener el siguiente nodo de pivote
L . remover ( pivote );
// quitar pivote de L
ins = fin ;
// iniciar busqueda desde fin de la corrida ordenada
while ( L . tienePrev ( ins ) &&
ins . getElemento (). compareTo ( pivote . getElemento ()) > 0)
ins = ins . getPrev (); // mover a la izquierda
// agregar el pivote de regreso , despues del punto de inserci ó n
L . agregarD espues ( ins , pivote );
if ( ins == fin )
// se ha agregado pivote despu é s de fin ( se quedo
fin = fin . getSig (); // en el mismo lugar ) -> incrementar marcador fin
}
}
Listado 3.15: Ordenamiento por inserción para una lista doblemente enlazada
representada por la clase ListaDoble.
por n!, está definida como el producto de los enteros desde 1 hasta n. Si n = 0,
entonces n! está definida como uno por convención. Mas formalmente, para
cualquier entero n ≥ 0,
1
si n = 0
n! =
1 · 2 · · · (n − 2) · (n − 1) · n si n ≥ 1
Para denotar n! se empleará el siguiente formato usado en los métodos de
Java, factorial(n).
La función factorial puede ser definida de una forma que sugiera una
formulación recursiva. Para ver esto, observar que
factorial(5) = 5 · (4 · 3 · 2 · 1) = 5 · factorial(4)
Por lo tanto, se puede definir factorial(5) en términos del factorial(4).
En general, para un entero positivo n, se puede definir factorial(n) como
n · factorial(n − 1). Esto lleva a la siguiente definición recursiva.
1
si n = 0
factorial(n) =
(3.1)
n · factorial(n − 1) si n ≥ 1
Esta definición es tı́pica de varias definiciones recursivas. Primero, esta
126
Arreglos, listas enlazadas y recurrencia
contiene uno, o más caso base, los cuales no están definidos recursivamente,
si no en términos de cantidades fijas. En este caso, n = 0 es el caso base.
Esta definición también contiene uno o más casos recursivos, los cuales
están definidos empleando la definición de la función que está siendo definida.
Observar que no hay circularidad en esta definición, porque cada vez que la
función es invocada, su argumento es más pequeño en uno.
Implementación recursiva de la función factorial
Se considera una implementación en Java, listado 3.16, de la función factorial que está dada por la fórmula 3.1 con el nombre factorialRecursivo().
Observar que no se requiere ciclo. Las invocaciones repetidas recursivas de la
función reemplazan el ciclo.
1
2
3
4
public static int f a c t o r i a l R e c u r s i v o ( int n ) { // funci ó n recursiva factorial
if ( n == 0) return 1; // caso base
else return n * f a c t o r i a l R e c u r s i v o (n -1); // caso recursivo
}
Listado 3.16: Una implementación recursiva de la función factorial.
¿Cuál es la ventaja de usar recurrencia? La implementación recursiva de
la función factorial es un poco más simple que la versión iterativa, en este
caso no hay razón urgente para preferir la recurrencia sobre la iteración. Para
algunos problemas, sin embargo, una implementación recursiva puede ser
significativamente más simple y fácil de entender que una implementación
iterativa como en los ejemplos siguientes.
Dibujar una regla inglesa
Como un caso más complejo del uso de recurrencia, considerar como dibujar
las marcas de una regla tı́pica inglesa. Una regla está dividida en intervalos
de una pulgada, y cada intervalo consiste de un conjunto de marcas colocadas
en intervalos de media pulgada, un cuarto de pulgada, y ası́ sucesivamente.
Como el tamaño del intervalo decrece por la mitad, la longitud de la marca
decrece en uno, ver figura 3.16.
Cada múltiplo de una pulgada también tiene una etiqueta numérica. La
longitud de la marca más grande es llamada la longitud de la marca principal.
No se consideran las distancias actuales entre marcas, sin embargo, se imprime
una marca por lı́nea.
3.6 Recurrencia
---- 0
-------- 1
-------- 2
(a)
127
----- 0
---------------- 1
(b)
------------
0
1
2
3
(c)
Figura 3.16: Ejemplos de la función dibuja regla: (a) regla de 2” con longitud 4 de
la marca principal; (b) regla de 1” con longitud 5; (c) regla de 3” con longitud 3.
Una aproximación recursiva para dibujar la regla
La aproximación para dibujar tal regla consiste de tres funciones. La
función principal dibujaRegla() dibuja la regla entera. Sus argumentos son
el número total de pulgadas en la regla, nPulgadas, y la longitud de la marca
principal, longPrincipal. La función utilidad dibujaUnaMarca() dibuja una
sola marca de la longitud dada. También se le puede dar una etiqueta entera
opcional, la cual es impresa si esta no es negativa.
El trabajo interesante es hecho por la función recursiva dibujaMarcas(),
la cual dibuja la secuencia de marcas dentro de algún intervalo. Su único
argumento es la longitud de la marca asociada con la marca del intervalo
central. Considerar la regla de 1” con longitud 5 de la marca principal mostrada
en la figura 3.16(b). Ignorando las lı́neas conteniendo 0 y 1, considerar como
dibujar la secuencia de marcas que están entre estas lı́neas. La marca central
(en 1/2”) tiene longitud 4. Los dos patrones de marcas encima y abajo de
esta marca central son idénticas, y cada una tiene una marca central de
longitud 3. En general, un intervalo con una marca central de longitud L ≥ 1
está compuesto de lo siguiente:
128
Arreglos, listas enlazadas y recurrencia
Un intervalo con una longitud de marca central L − 1
Un sólo interalo de longitud L
Un intervalo con una longitud de marca central L − 1
Con cada llamada recursiva, la longitud decrece por uno. Cuando la
longitud llega a cero, se debe regresar. Como resultado, este proceso recursivo
siempre terminará. Esto sugiere un proceso recursivo, en el cual el primer
paso y último son hechos llamando a dibujaMarcas(L-1) recursivamente. El
paso central es realizado llamando a la función dibujaUnaMarca(L). Esta
formulación recursiva es mostrada en el listado 3.17. Como en el ejemplo del
factorial, el código tiene un caso base, cuando L = 0. En este ejemplo se
hacen dos llamadas recursivas a la función.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// dibuja regla conteniendo nPulgadas con marca de tama ~
n o longPrincipal
public static void dibujaRegla ( int nPulgadas , int longPrincipal ) {
dibu jaUnaMar ca ( longPrincipal , 0); // dibujar marca 0 y su etiqueta
for ( int i = 1; i <= nPulgadas ; i ++) {
dibujaMarcas ( longPrincipal -1);
// dibujar marcas para esta pulgada
dibu jaUnaMar ca ( longPrincipal , i ); // dibujar marca i y su etiqueta
}
}
// dibuja marcas de longitud dada
public static void dibujaMarcas ( int longMarca ) {
if ( longMarca > 0) { // parar cuando la longitud llegue a 0
dibujaMarcas ( longMarca -1); // rec ursivame nte dibujar marca superior
dibu jaUnaMar ca ( longMarca ); // dibujar marca principal
dibujaMarcas ( longMarca -1); // rec ursivame nte dibujar marca inferior
}
}
// dibuja una marca sin etiqueta
public static void dibu jaUnaMa rca ( int longMarca ) {
dibu jaUnaMar ca ( longMarca , -1);
}
// dibuja una marca
public static void dibu jaUnaMa rca ( int longMarca , int etiqMarca ) {
for ( int i = 0; i < longMarca ; i ++)
System . out . print ( " -" );
if ( etiqMarca >= 0) System . out . print ( " " + etiqMarca );
System . out . print ( " \ n " );
}
Listado 3.17: Implementación recursiva del método dibujaMarcas() para mostrar
marcas de una regla.
Sumar elementos de un arreglo recursivamente
Suponer que se da un arreglo, A, de n enteros que se desean sumar juntos.
Se puede resolver el problema de la suma usando recurrencia lineal observando
3.6 Recurrencia
129
que la suma de todos los n enteros de A es igual a A[0], si n es 1, o la suma
de los primeros n − 1 enteros más la suma del último elemento en A. En
particular, se puede resolver esta suma usando el algoritmo recursivo siguiente.
Algoritmo SumaLineal(A, n):
Entrada: Un arreglo A de enteros y un entero n ≥ 1 tal que A tiene
al menos n elementos.
Salida: La suma de los primeros n enteros en A.
si n = 1 entonces
regresar A[0]
si no
regresar SumaLineal(A, n − 1) + A[n − 1]
Este ejemplo, también ilustra una propiedad importante que un método
recursivo deberı́a siempre tener—que el método termina. Se asegura esto
escribiendo una sentencia no recursiva para el caso n igual a 1. Además,
siempre se hace una llamada recursiva sobre un valor más pequeño del
parámetro n menos 1 que el que fue dado, n, por lo tanto, en algún punto,
en el punto mas bajo de la recurrencia, se hará la parte no recursiva del
cálculo, regresando A[0]. En general, un algoritmo que usa recurrencia lineal
tı́picamente tiene la siguiente forma:
Prueba para los casos base. Se inicia probando un conjunto de casos
base, deberı́a haber al menos uno. Estos casos basos deberı́an estar
definidos, por lo que cada posible encadenamiento de llamadas recursivas
eventualmente alcanzará un caso base, y el manejo de cada caso base
no deberı́a usar recurrencia.
Recurrencia. Después de probar los casos base, entonces se realizan
llamadas recursivas simples. Este paso recursivo podrı́a involucrar una
prueba que decida cual de varias posibles llamadas recursivas hacer, pero
finalmente deberı́a escoger hacer sólo una de estas posibles llamadas
cada vez que se haga este paso. Mas aún, se deberı́a definir cada posible
llamada recursiva para que esta progrese hacia el caso base.
Invertir un arreglo por recurrencia
Se considera ahora el problema de invertir los n elementos de un arreglo, A,
para que el primer elemento sea el último, el segundo elemento sea el penúltimo,
y ası́ sucesivamente. Se puede resolver este problema usando recurrencia lineal,
130
Arreglos, listas enlazadas y recurrencia
observando que la inversión de un arreglo se puede lograr intercambiando
el primer elemento y el último, y entonces invertir recursivamente el resto
de los elementos en el arreglo. Se describen a continuación los detalles de
este algoritmo, usando la convención de que la primera vez que se llama el
algoritmo se hace como InvertirArreglo(A, 0, n − 1).
Algoritmo InvertirArreglo(A, i, j):
Entrada: Un arreglo A e ı́ndices enteros no negativos i y j.
Salida: La inversión de los elementos en A iniciando en el ı́ndice i
y terminando en j.
si i < j entonces
Intercambiar A[i] y A[j]
InvertirArreglo(A, i + 1, j − 1)
regresar
En este algoritmo se tienen dos casos base implı́citos, a saber, cuando
i = j y cuando i > j. Sin embargo, en cualquier caso, se termina el algoritmo,
ya que una secuencia con cero elementos, o un elemento es trivialmente igual a
su inversión. Además, en el paso recursivo se está garantizando ir progresando
hacia uno de los dos casos base. Si n es par, eventualmente se alcanzará el
caso i = j, y si n es impar, eventualmente se alcanzará el caso i > j. El
argumento anterior implica que el algoritmo recursivo está garantizado para
terminar.
Definir problemas para facilitar la recurrencia
Para diseñar un algoritmo recursivo para un problema dado, es útil pensar
en las diferentes formas en que se puede subdividir el problema para definir
problemas que tengan la misma estructura general que el problema original.
Este proceso en ocasiones significa que se necesita redefinir el problema
original para facilitar la búsqueda de problemas similares. Por ejemplo, con
el algoritmo InvertirArreglo, se agregaron los parámetros i y j por
lo que una llamada recursiva para invertir la parte interna del arreglo A
podrı́a tener la misma estructura y la misma sintaxis, como la llamada para
invertir cualquier otro par de valores del arreglo A. Entonces, en vez de
inicialmente llamar al algoritmo como InvertirArreglo(A), se llama a este
inicialmente como InvertirArreglo(A, 0, n − 1). En general, si se tiene
dificultad para encontrar la estructura repetitiva requerida para diseñar un
algoritmo recursivo, en algunos casos es útil trabajar el problema con algunos
3.6 Recurrencia
131
ejemplos concretos pequeños para ver como los subproblemas deberı́an estar
definidos.
Recurrencia de cola
Usar la recurrencia puede ser con frecuencia una herramienta útil para
diseñar algoritmos que tienen definiciones cortas y elegantes. Pero esta utilidad
viene acompañado de un costo modesto. Cuando se usa un algoritmo recursivo
para resolver un problema, se tienen que usar algunas de las localidades de la
memoria para guardar el estado de las llamadas recursivas activas. Cuando la
memoria de la computadora es un lujo, entonces, es más útil en algunos casos
poder derivar algoritmos no recurrentes de los recurrentes.
Se puede emplear la estructura de datos pila para convertir un algoritmo recurrente en uno no recurrente, pero hay algunos casos cuando se
puede hacer esta conversión más fácil y eficiente. Especı́ficamente, se puede
fácilmente convertir algoritmos que usan recurrencia de cola. Un algoritmo usa recurrencia de cola si este usa recursión lineal y el algoritmo hace
una llamada recursiva como su última operación. Por ejemplo, el algoritmo
InvertirArreglo usa recurrencia de cola.
Sin embargo, no es suficiente que la última sentencia en el método definición
incluya una llamada recurrente. Para que un método use recurrencia de cola, la
llamada recursiva deberá ser absolutamente la última parte que el método haga,
a menos que se esté en el caso base. Por ejemplo, el algoritmo SumaLineal no
usa recurrencia de cola, aún cuando su última sentencia incluye una llamada
recursiva. Esta llamada recursiva no es actualmente la última tarea que el
método realiza. Después de recibir el valor regresado de la llamada recursiva,
le agrega el valor de A[n − 1] y regresa su suma. Esto es, la última tarea que
hace el algoritmo es una suma, y no una llamada recursiva.
Cuando un algoritmo emplea recurrencia de cola, se puede convertir el
algoritmo recurrente en uno no recurrente, iterando a través de las llamadas
recurrentes en vez de llamarlas a ellas explı́citamente. Se ilustra este tipo de
conversión revisitando el problema de invertir los elementos de un arreglo.
El siguiente algoritmo no recursivo realiza la tarea de invertir los elementos
iterando a través de llamadas recurrentes del algoritmo InvertirArreglo.
Inicialmente se llama a este algoritmo como InvertirArregloIterativo(A, 0, n − 1).
132
Arreglos, listas enlazadas y recurrencia
Algoritmo InvertirArregloIterativo(A, i, j):
Entrada: Un arreglo A e ı́ndices enteros no negativos i y j.
Salida: La inversión de los elementos en A iniciando en el ı́ndice i
y terminando en j.
mientras i < j hacer
Intercambiar A[i] y A[j]
i←i+1
j ←j−1
regresar
3.6.1.
Recurrencia binaria
Cuando un algoritmo hace dos llamadas recurrentes, se dice que usa
recurrencia binaria. Estas llamadas pueden, por ejemplo, ser usadas para
resolver dos hojas similares de algún problema, como se hizo para dibujar
una regla inglesa, sección 3.6. Como otra aplicación de recurrencia binaria,
se revisitará el problema de sumar los n elementos de un arreglo de enteros
A. En este caso, se pueden sumar los elementos de A: (i) recurrentemente
los elementos en la primera mitad de A; (ii) recurrentemente los elementos
en la segunda mitad de A; y (iii) agregando estos dos valores juntos. Se dan
los detalles en el siguiente algoritmo, el cual es inicialmente llamado como
SumaBinaria(A, 0, n).
Algoritmo SumaBinaria(A, i, n):
Entrada: un arreglo A de enteros y enteros i y n.
Salida: la suma de los primeros n enteros en A iniciando en el ı́ndice i.
si n = 1 entonces
regresar A[i]
regresar SumaBinaria(A, i, dn/2e)+SumaBinaria(A, i + dn/2e, bn/2c)
Para analizar el algoritmo SumaBinaria, se considera, por simplicidad,
el caso cuando n es potencia de 2. La figura 3.17 muestra el trazado de la
recursión de una ejecución del método SumaBinaria(0,8). Se etiqueta cada
recuadro con los valores de los parámetros i y n, los cuales representan el
ı́ndice inicial y el tamaño de la secuencia de los elementos que serán sumados,
respectivamente. Observar que las flechas en el trazado van de un recuadro
etiquetado (i, n) a otro recuadro etiquetado (i, n/2) o (i+n/2, n/2). Esto es, el
valor del parámetro n es dividido en dos en cada llamada recursiva. Por lo tanto,
la profundidad de la recursión, esto es, el número máximo de instancias del
3.6 Recurrencia
133
método que están activas al mismo tiempo, es 1+log2 n. Entonces el algoritmo
usa una cantidad de espacio adicional aproximadamente proporcional a su
valor. El tiempo de ejecución del algoritmo es todavı́a proporcional a n, ya
que cada recuadro es visitado en tiempo constante y hay 2n − 1 recuadros.
Figura 3.17: Trazado de la recursión de SumaBinaria(0, 8).
Fibonacci con recurrencia binaria
Se considera ahora el problema de calcular el k-ésimo número de Fibonacci.
Los números de Fibonacci están recursivamente definidos como:
F0 = 0
F1 = 1
Fi = Fi−1 + Fi−2 para i > 1.
Aplicando directamente la definición anterior, el algoritmo FibBinario,
mostrado a continuación, encuentra la secuencia de los números de Fibonacci
empleando recurrencia binaria.
Algoritmo FibBinario(k):
Entrada: Un entero k no negativo.
Salida: El k-ésimo número de Fibonacci Fk .
si k ≤ 1 entonces
regresar k
si no
regresar FibBinario(k − 1)+FibBinario(k − 2)
Desafortunadamente, a pesar de que la definición de Fibonacci parece una
recursión binaria, esta técnica es ineficiente en este caso. De hecho, toma un
número de llamadas exponencial para calcular el k-ésimo número de Fibonacci
de esta forma. Especı́ficamente, sea nk el número de llamadas hechas en la
134
Arreglos, listas enlazadas y recurrencia
ejecución de FibBinario(k). Entonces, se tienen los siguientes valores para
las diferentes nk :
n0
n1
n2
n3
n4
n5
n6
n7
n8
=
=
=
=
=
=
=
=
=
1
1
n1 + n0 + 1 = 3
n2 + n1 + 1 = 5
n3 + n2 + 1 = 9
n4 + n3 + 1 = 15
n5 + n4 + 1 = 25
n6 + n5 + 1 = 41
n7 + n6 + 1 = 67
Si se sigue hacia adelante el patrón, se ve que el número de llamadas es
más del doble para un ı́ndice que esté dos ı́ndices anteriores. Esto es, n4 es
más del doble que n2 , n5 es más del doble que n3 , y ası́ sucesivamente. Por
lo tanto, nk > 2k/2 , lo que significa que FibBinario(k) hace un número de
llamadas que son exponenciales en k. En otras palabras, emplear recurrencia
binaria para calcular números de Fibonacci es muy ineficiente.
Fibonacci con recurrencia lineal
El principal problema con la aproximación anterior, basada en recurrencia
binaria, es que el cálculo de los números de Fibonacci es realmente un problema
de recurrencia lineal. No es este un buen candidato para emplear recurrencia
binaria. Se empleó recurrencia binaria por la forma como el k-ésimo número
de Fibonacci, Fk , dependı́a de los dos valores previos, Fk−1 y Fk−2 . Pero se
puede encontrar Fk de una forma más eficiente usando recurrencia lineal.
Para poder emplear recurrencia lineal, se necesita redefinir ligeramente
el problema. Una forma de lograr esta conversión es definir una función
recursiva que encuentre un par consecutivo de números Fibonacci (Fk−1 , Fk ).
Entonces se puede usar el siguiente algoritmo de recurrencia lineal mostrado
a continuación.
3.6 Recurrencia
135
Algoritmo FibLineal(k):
Entrada: Un entero k no negativo.
Salida: El par de números Fibonacci (Fk , Fk−1 ).
si k ≤ 1 entonces
regresar (k, 0)
si no
(i, j) ← FibLineal(k − 1)
regresar (i + j, i)
El algoritmo dado muestra que usando recurrencia lineal para encontrar
los números de Fibonacci es mucho más eficiente que usando recurrencia
binaria. Ya que cada llama recurrente a FibLineal decrementa el argumento
k en 1, la llamada original FibLineal(k) resulta en una serie de k − 1
llamadas adicionales. Esto es, calcular el k-ésimo número de Fibonacci usando
recurrencia lineal requiere k llamadas al método. Este funcionamiento es
significativamente más rápido que el tiempo exponencial necesitado para
el algoritmo basado en recurrencia binaria. Por lo tanto, cuando se usa
recurrencia binaria, se debe primero tratar de particionar completamente el
problema en dos, o se deberı́a estar seguro que las llamadas recursivas que se
traslapan son realmente necesarias.
Usualmente, se puede eliminar el traslape de llamadas recursivas usando
más memoria para conservar los valores previos. De hecho, esta aproximación
es una parte central de una técnica llamada programación dinámica, la cual
está relacionada con la recursión.
3.6.2.
Recurrencia múltiple
La generalización de la recurrencia binaria lleva a la recurrencia múltiple, que es cuando un método podrı́a hacer varias llamadas recursivas, siendo
ese número potencialmente mayor que dos. Una de las aplicaciones más comunes de este tipo de recursión es usada cuando se quiere enumerar varias
configuraciones en orden para resolver un acertijo combinatorio. Por ejemplo,
el siguiente es un acertijo de suma:
par + ras = assa
Para resolver el acertijo, se necesita asignar un dı́gito único, esto es,
0,1,. . . ,9, a cada letra en la ecuación, para poder hacer la ecuación verdadera.
Tı́picamente, se resuelve el acertijo usando observaciones humanas del acertijo
136
Arreglos, listas enlazadas y recurrencia
particular que se está intentando resolver para eliminar configuraciones hasta
que se pueda trabajar con las configuraciones restantes, probando la correctez
de cada una.
Sin embargo, si el número de configuraciones posibles no es muy grande, se
puede usar una computadora para enumerar todas las posibilidades y probar
cada una, sin emplear observaciones humanas. Además, tal algoritmo puede
usar recurrencia múltiple para trabajar a través de todas las configuraciones
en una forma sistemática. Se proporciona enseguida pseudocódigo para tal
algoritmo. Para conservar la descripción general suficiente para ser usado con
otros acertijos, el algoritmo enumera y prueba todas las secuencias de longitud
k sin repeticiones de los elementos de un conjunto dado U . Se construyen las
secuencias de k elementos mediante los siguientes pasos:
1. Generar recursivamente las secuencias de k − 1 elementos.
2. Agregar a cada una de tales secuencias un elemento que no esté contenido
dentro de esta.
En toda la ejecución del algoritmo, se usa el conjunto U para saber los
elementos no contenidos en la secuencia actual, por lo que un elemento e no
ha sido usado aún sı́ y sólo sı́ e está en U .
Otra forma de ver el algoritmo siguiente es que este enumera cada subconjunto ordenado posible de tamaño, y prueba cada subconjunto para ser
una posible solución al acertijo.
Para acertijos de sumas, U = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9} y cada posición en
la secuencia corresponde a una letra dada. Por ejemplo, la primera posición
podrı́a ser b, la segunda o, la tercera y, y ası́ sucesivamente.
3.6 Recurrencia
Algoritmo ResolverAcertijo(k, S, U ):
Entrada: Un entero k, secuencia S, y conjunto U .
Salida: Una enumeración de todas las extensiones k-longitud para S
usando elementos en U sin repeticiones.
para cada e en U hacer
Remover e de U { e está ahora siendo usado }
Agregar e al final de S
si k = 1 entonces
Probar si S es una configuración que resuelva el acertijo
si S resuelve el acertijo entonces
regresar “Solución encontrada: ” S
si no
ResolverAcertijo(k − 1, S, U )
Agregar e de regreso a U { e está ahora sin uso }
Remover e del final de S
137
138
Arreglos, listas enlazadas y recurrencia
Capı́tulo 4
Herramientas de análisis
4.1.
Funciones
Se revisan brevemente, en esta sección, las siete funciones más importantes
usadas en el análisis de algoritmos.
4.1.1.
La función constante
La función más simple que se emplea es la función constante. Esta es
la función
f (n) = c,
para alguna constante fija c, tal como c = 7, c = 25, o c = 28 . Con esta
función, para cualquier argumento n, la función constante f (n) asigna el valor
c, es decir, no importa cual sea el valor de n, f (n) siempre será igual al valor
constante c.
Como se emplean generalmente funciones enteras en el análisis, la función
constante más usada es g(n) = 1, y cualquier otra función constante, f (n) = c,
puede ser escrita como una constante c veces g(n), es decir, f (n) = cg(n).
Esta función caracteriza el número de pasos requeridos para hacer una
operación básica en la computadora, como la suma de dos números, asignar
un valor a una variable, o comparar dos números.
140
4.1.2.
Herramientas de análisis
La función logarı́tmica
La función logarı́tmica f (n) = logb n, para alguna constante b > 1 es
ubicua, ya que aparece con bastante frecuencia en el análisis de estructuras
de datos y algoritmos. Esta función está definida como sigue:
x = logb n sı́ y sólo sı́ bx = n.
Por definición, logb 1 = 0. El valor de b es conocido como la base del
logaritmo.
Para encontrar el valor exacto de esta función para cualquier entero n se
requiere el uso de cálculo, pero se puede usar una aproximación útil en el
análisis sin emplear cálculo. En particular, se puede calcular el entero más
pequeño que sea mayor o igual a logb n, ya que este número es igual al número
de veces que se puede dividir n por b hasta que se obtenga un número menor
que, o igual a 1. Por ejemplo, log3 27 es 3, ya que 27/3/3/3 = 1. De igual
modo, log4 64 es 3, porque 64/4/4/4 = 1, y la aproximación a log2 12 es 4, ya
que 12/2/2/2/2 = 0.75 ≤ 1. La aproximación base dos surge en el análisis
de algoritmos, ya que es una operación común en los algoritmos “divide y
vencerás” por que repetidamente dividen la entrada por la mitad.
Además, como las computadoras almacenan los enteros en binario, la base
más común para la función logarı́tmica en ciencias de la computación es dos,
por lo que tı́picamente no se indica cuando es dos, ası́ se tiene,
log n = log2 n.
Hay algunas reglas importantes para logaritmos, parecidas a las reglas de
exponentes.
Proposición 4.1: Reglas de logaritmos dados números reales a > 0,
b > 1, c > 0, y d > 1, se tiene:
1. logb ac = logb a + logb c
2. logb a/c = logb a − logb c
3. logb ac = c logb a
4. logb a = (logd a)/ logd b
5. blogd a = alogd b
Como notación abreviada, se usa logc n para denotar la función (log n)c .
4.1 Funciones
4.1.3.
141
La función lineal
Otra función simple e importante es la función lineal,
f (n) = n.
Esto es, dado un valor de entrada n, la función lineal f asigna el propio
valor n.
Esta función surge en el análisis de algoritmos cuando se tiene que hacer una operación básica para cada uno de los n elementos. Por ejemplo,
comparar un número x con cada uno de los elementos de una arreglo de
tamaño n requerirá n comparaciones. La función lineal también representa el
mejor tiempo de ejecución que se espera lograr para cualquier algoritmo que
procese una colección de n objetos que no estén todavı́a en la memoria de la
computadora, ya que leer los n objetos requiere n operaciones.
4.1.4.
La funcion N-Log-N
La función
f (n) = n log n,
es la que asigna a una entrada n el valor de n veces el logaritmo de base dos
de n. Esta función crece un poco más rápido que la función lineal, pero es
más rápida que la función cuadrática. Por lo tanto, si se logra mejorar el
tiempo de ejecución de algún problema desde un tiempo cuadrático a n-log-n,
se tendrá un algoritmo que corra más rápido en general.
4.1.5.
La función cuadrática
La función cuadrática también aparece muy seguido en el análisis de
algoritmos,
f (n) = n2 .
Para un valor de entrada n, la función f asigna el producto de n con ella
misma, es decir, “n cuadrada”.
La razón principal por la que la función cuadrática aparece en el análisis
de algoritmos es porque hay muchos algoritmos que tienen ciclos anidados,
donde el ciclo interno realiza un número lineal de operaciones y el ciclo
142
Herramientas de análisis
externo también es realizado un número lineal de veces. Ası́, en tales casos, el
algoritmo realiza n · n = n2 operaciones.
Ciclos anidados y la función cuadrática
La función cuadrática puede también surgir en los ciclos anidados donde la
primera iteración de un ciclo usa una operación, la segunda usa dos operaciones,
la tercera usa tres operaciones, y ası́ sucesivamente, es decir, el número de
operaciones es
1 + 2 + 3 + · · · + (n − 2) + (n − 1) + n.
En otras palabras, este es el número total de operaciones que serán hechas
por el ciclo anidado, si el número de operaciones realizadas dentro del ciclo
se incrementa por uno con cada iteración del ciclo exterior.
Se cree que Carl Gauss usó la siguiente identidad para resolver el problema
que le habı́an dejado de encontrar la suma desde 1 hasta 100, cuando tenı́a
aproximadamente 10 años.
Proposición 4.2: Para cualquier entero n ≥ 1, se tiene
n(n + 1)
.
2
En la figura 4.1 se da la justificación visual de la proposición 4.2.
Lo que se desprende de este resultado es que si se ejecuta un algoritmo
con ciclos anidados tal que las operaciones del ciclo anidado se incrementan
en uno cada vez, entonces el número total de operaciones es cuadrático en el
número de veces, n, que se hace el ciclo exterior. En particular, el número de
operaciones es n2 /2 + n/2, en este caso, lo cual es un poco más que un factor
constante (1/2) veces la función cuadrática n2 .
1 + 2 + 3 + · · · + (n − 2) + (n − 1) + n =
4.1.6.
La función cúbica y otras polinomiales
De las funciones que son potencias de la entrada, se considera la función
cúbica,
f (n) = n3 ,
la cual asigna a un valor de entrada n el producto de n con el mismo tres
veces. Esta función aparece con menor frecuencia en el contexto del análisis
4.1 Funciones
143
Figura 4.1: Justificación visual de la proposición 4.2. Las figuras muestran la
identidad en términos del área total cubierta. En (a) los rectángulos son mostrados
cubriendo un triángulo de área n2 /2, base n y altura n, más n triángulos pequeños
de área 1/2 cada uno, base 1 y altura 1. En (b), se aplica sólo cuando n es par y
los rectángulos cubren un rectángulo de base n/2 y altura n + 1.
de algoritmos que las funciones constante, lineal o cuadrática pero aparece de
vez en cuando.
Polinomiales
Algunas de las funciones que se han revisado previamente pueden ser
vistas como una parte de una clase de funciones más grandes, las funciones
polinomiales.
Una función polinomial es una función de la forma,
f (n) = a0 + a1 n + a2 n2 + a3 n3 + · · · + ad nd ,
donde a0 , a1 , . . . , ad son constantes, llamados los coeficientes de la polinomial,
y ad 6= 0. El entero d, el cual indica la potencia más grande en el polinomio,
es llamado el grado del polinomio.
144
Herramientas de análisis
Sumatorias
Una notación que aparece varias veces en el análisis de las estructuras de
datos y algoritmos es la sumatoria, la cual está definida como sigue:
b
X
f (i) = f (a) + f (a + 1) + f (a + 2) + · · · + f (b),
i=a
donde a y b son enteros y a ≤ b. Las sumatorias surgen en el análisis de
algoritmos y estructuras de datos porque los tiempos de ejecución de ciclos
naturalmente dan estos.
La fórmula de la proposición 4.2 se puede reescribir usando la sumatoria
como:
n
X
i=
i=1
n(n + 1)
.
2
De igual forma, puede escribir un polinomio f (n) de grado d con coeficientes a0 , . . . , ad como:
f (n) =
d
X
ai ni .
i=0
La notación de sumatoria da una forma breve de expresar sumas de
términos incrementales que tienen una estructura regular.
4.1.7.
La función exponencial
Otra función usada en el análisis de algoritmos es la función exponencial
f (n) = bn ,
donde b es una constante positiva, llamada la base, y el argumento n es el
exponente, es decir, la función f (n) asigna al argumento de entrada n el
valor obtenido de multiplicar la base b por sı́ misma n veces. En análisis de
algoritmos, la base más común para la función exponencial es b = 2. Por
ejemplo, si se tiene un ciclo que inicia realizando una operación y entonces
dobla el número de operaciones hechas con cada iteración, entonces el número
de operaciones realizadas en la n-ésima iteración es 2n . Además, una palabra
entera conteniendo n bits puede representar todos los enteros no negativos
4.1 Funciones
145
menores que 2n . La función exponencial también será referida como función
exponente.
En ocasiones se tienen otros exponentes además de n, por lo que es útil
conocer unas cuantas reglas para trabajar con exponentes.
Proposición 4.3: REglas de exponentes dados enteros positivos a, b, y
c, se tiene
1. (ba )c = bac
2. ba bc = ba+c
3. ba /bc = ba−c
Se puede extender la función exponencial a exponentes que son fracciones
o números reales y exponentes negativos, como sigue. Dado un entero positivo
k, se define b1/k como la k-ésima raı́z de b, esto es, el número r tal que
rk = b. Esta aproximación permite definir cualquier potencia cuyo exponente
pueda ser expresado como una fracción, para ba/c = (ba )1/c , por la regla 1 de
exponentes.
Se puede extender la función exponencial para definir bx para cualquier
número real x, calculando series de números de la forma ba/c para fracciones
a/c que estén progresivamente más y más cerca a x. Dado un exponente
negativo d, se define bd = 1/b−d , lo cual corresponde a usar la regla 3 de
exponentes con a = 0 y c = −d.
Sumas geométricas
Cuando se tiene un ciclo donde cada iteración toma un factor multiplicativo mayor que la previa, se puede analizar este ciclo usando la siguiente
proposición.
Proposición 4.4: Para cualquier entero n ≥ 0 y cualquier número real a tal
que a > 0 y a 6= 1, considerar la sumatoria
n
X
ai = 1 + a + a2 + · · · + an
i=0
recordando que a0 = 1 si a > 0. Esta suma es igual a
an+1 − 1
.
a−1
146
Herramientas de análisis
Las sumatorias, como la mostrada en la proposición anterior, son llamadas
sumatorias geométricas, porque cada término es geométricamente mayor
que el previo si a > 1. Por ejemplo, en la siguiente sumatoria
1 + 2 + 4 + 8 + · · · + 2n−1 = 2n − 1,
se calcula el entero más grande que puede ser representado en notación binaria
usando n bits.
4.1.8.
Relaciones de crecimiento
El cuadro 4.1 muestra en orden de crecimiento cada una de las siete
funciones usadas principalmente en el análisis de algoritmos.
constante
1
logarı́tmica
log n
lineal
n
n-log-n
n log n
cuadrática
n2
cúbica
n3
exponencial
an
Cuadro 4.1: Clases de funciones. Se asume que a > 1 es una constante.
Se quisiera que las operaciones en estructuras de datos se ejecuten en
tiempos proporcionales a la constante o la logarı́tmica, y que los algoritmos
lo hagan en tiempo lineal o n-log-n. Los algoritmos con tiempos de ejecución
cuadrático o cúbico son menos prácticos, pero algoritmos con tiempos exponenciales son imposibles excepto para entradas pequeñas. Las siete funciones
están graficadas en la figura 4.2. Se usó base a = 2 para la función exponencial. Las funciones están graficadas en una escala logarı́tmica, para comparar
principalmente las relaciones de crecimiento como pendientes.
Función piso y techo
El valor de un logaritmo generalmente no es un entero, aunque el tiempo
de ejecución de un algoritmo usualmente es dado por una cantidad entera, tal
como la cantidad de operaciones realizadas. Ası́, el análisis de un algoritmo
podrı́a en ocasiones involucrar el uso de la función piso y la función techo,
las cuales son definidas respectivamente como sigue:
bxc
dxe
el entero más grande menor que o igual a x.
el entero más pequeño mayor que o igual a x.
4.2 Análisis de algoritmos
147
Figura 4.2: Relaciones de crecimiento para las siete funciones fundamentales usadas
en análisis de algoritmos.
4.2.
Análisis de algoritmos
Se está interesado en el diseño de estructuras de datos y algoritmos
“buenos”. De una manera simple, una estructura de datos es una forma
sistemática de organizar y acceder datos, y un algoritmo es un procedimiento
paso a paso para realizar alguna tarea en una cantidad finita de tiempo. Para
poder clasificar las estructuras de datos y los algoritmos como “buenos”, se
deben tener formas precisas de analizarlos.
La herramienta de análisis primario que se usará involucra la caracterización de los tiempos de ejecución de los algoritmos y las operaciones de las
estructuras de datos, siendo también el espacio usado de interés. El tiempo
de ejecución es una medida natural de la “bondad”, ya que el tiempo es un
recurso precioso, las soluciones computacionales deberán ejecutarse tan rápido
como sea posible.
En general, el tiempo de ejecución de un algoritmo o de un método de una
estructura de datos se incrementa con el tamaño de la entrada, sin embargo
podrı́a variar para entradas diferentes con el mismo tamaño. También, el
tiempo de ejecución es afectado por el ambiente del hardware del sistema de
cómputo, como el procesador, frecuencia del reloj, memoria, disco, etc., y por
el ambiente del software, como el sistema operativo, lenguaje de programación, compilador, intérprete, etc., en el cual el algoritmo está implementado,
compilado y ejecutado. Siendo todos los factores iguales, el tiempo de ejecucion del mismo algoritmo con la misma entrada de datos será menor si
148
Herramientas de análisis
la computadora tiene un procesador más rápido, o si la implementación es
hecha en un programa compilado en código de máquina nativo en vez de una
implementación interpretada en una máquina virtual. Sin embargo, a pesar
de las posibles variaciones que se dan por factores ambientales diferentes, se
intenta enfocarse en la relación entre el tiempo de ejecución de un algoritmo
y el tamaño de su entrada.
Se está interesado en caracterizar el tiempo de ejecución del algoritmo
como una función del tamaño de la entrada, para lo cual se requiere saber de
una forma apropiada de medirla.
4.2.1.
Estudios experimentales
Si un algoritmo ha sido implementado, se puede estudiar su tiempo de
ejecución usando varias pruebas de entrada y guardando el tiempo usado
en cada ejecución. Para hacer estas mediciones de forma precisa se pueden
hacer llamadas al sistema que están construidas en el lenguaje, o en el sistema
operativo, por ejemplo, usando el método System.currentTimeMillis().
Tales pruebas asignan un tiempo de ejecución especı́fico a una entrada de
tamaño especı́fico, pero se está interesado en determinar la dependencia
general del tiempo de ejecución de acuerdo al tamaño de la entrada, para
lo cual se deben hacer varios experimentos con muchas entradas de prueba
diferentes de varios tamaños. Entonces se pueden visualizar los resultados de
tales experimentos graficando el rendimiento de cada ejecución del algoritmo
como un punto con coordenada x igual al tamaño de la entrada, n, y la
coordenada y igual al tiempo de ejecución, t, como se hace en la figura 4.3. De
la gráfica y de los datos que lo soportan, se puede hacer un análisis estadı́stico
que encuentre la mejor función que represente el tamaño de entrada a los
datos experimentales. Para que sea útil, este análisis requiere que se escojan
entradas de muestra buenas y se prueben lo suficiente para hacer que tenga
sentido lo indicado por la estadı́stica acerca del tiempo de ejecución del
algoritmo.
Las limitaciones que tienen los estudios experimentales son:
Los experimentos sólo pueden hacerse en un conjunto limitado de
pruebas de entrada, por lo que quedarán entradas no incluidas que
podrı́an ser importantes.
Es difı́cil comparar los tiempos experimentales de ejecución de dos
4.2 Análisis de algoritmos
149
Figura 4.3: Resultados de un estudio experimental del tiempo de ejecución de un
algoritmo.
algoritmos a menos que los experimentos sean hechos en el mismo
hardware y software.
Se tiene que implementar de forma completa y ejecutar un algoritmo
para poder estudiar sus tiempos de ejecución experimentalmente.
El último requerimiento es probablemente el que más tiempo consume,
aunque también las otras limitaciones también imponen obstáculos serios. Por
lo tanto, idealmente se quiere tener una herramienta de análisis que nos evite
realizar experimentos.
Se desarrolla una forma general de analizar los tiempos de ejecución de
algoritmos que:
Toma en cuenta todas las posibles entradas.
Permite evaluar la eficiencia relativa de cualesquiera dos algoritmos
en una forma que es indenpendiente del ambiente del hardware y el
software.
Puede ser realizado estudiando una descripción de alto nivel del algoritmo sin tenerlo que implementar o ejecutar experimentos en este.
Esta metodologı́a permite asociar, con cada algoritmo, una función f (n)
que caracteriza el tiempo de ejecución del algoritmo como una función del
150
Herramientas de análisis
tamaño de entrada n. Las funciones tı́picas que serán encontradas incluyen
las siete funciones mencionadas al inicio del capı́tulo.
4.2.2.
Operaciones primitivas
Como se mencionó previamente, los análisis experimentales son valiosos,
pero tienen sus limitaciones. Si se desea analizar un algoritmo particular
sin realizar experimentos en su tiempo de ejecución, se puede realizar un
análisis directo de alto nivel en el pseudo-código. Se define un conjunto de
operaciones primitivas tales como las siguientes:
Asignar un valor a una variable.
Llamar un método.
Realizar una operación aritmética.
Comparar dos números.
Indexar en un arreglo.
Seguir la referencia a un objeto.
Regresar desde un método.
Conteo de operaciones primitivas
Una operación primitiva corresponde a una instrucción de bajo nivel con
un tiempo de ejecución que es constante. Se cuentan cuantas operaciones
primitivas son ejecutadas, y se usa este número t como una medida del tiempo
de ejecución del algoritmo.
El conteo de las operaciones está correlacionado a un tiempo de ejecución actual en una computadora particular, para cada operación primitiva
corresponde a una instrucción constante en el tiempo, y sólo hay número
fijo de operaciones primitivas. El número, t, de operaciones primitivas que
un algoritmo realiza será proporcional al tiempo de ejecución actual de ese
algoritmo.
Un algoritmo podrı́a ejecutarse más rápido con algunas entradas que con
otras del mismo tamaño. Por lo tanto, se podrı́a desear expresar el tiempo
de ejecución de un algoritmo como la función del tamaño de la entrada
4.2 Análisis de algoritmos
151
obtenida de tomar el promedio de todas las posibles entradas del mismo
tamaño. Desafortunadamente, el análisis promedio suele ser muy difı́cil,
ya que requiere definir una distribución de probabilidad sobre el conjunto de
entradas, lo cual es una tarea difı́cil. La figura 4.4 muestra como, dependiendo
de la distribución de la entrada, el tiempo de ejecución de un algoritmo puede
estar en cualquier lugar entre el tiempo peor caso y el tiempo mejor caso. Por
ejemplo, ¿qué tal si las entradas son sólo de los tipos A y D?
Figura 4.4: Gráfica del tiempo de ejecución de algún algoritmo mostrando la
diferencia entre el tiempo peor caso y mejor caso.
Enfoque en el peor caso
Un análisis en caso promedio usualmente requiere que se calcule los tiempos
esperados de ejecución basados en una distribución de entrada dada, la cual
requiere generalmente teorı́a de probabilidad sofisticada. Por lo tanto, en este
documento, a menos que se indique lo contrario, se caracterizarán los tiempos
de ejecución en términos del peor caso, como una función del tamaño de la
entrada, n, del algoritmo.
El análisis del peor caso es más fácil que el caso promedio, ya que sólo
requiere la habilidad de identificar el peor caso de entrada, el cual es frecuentemente simple. Con esta aproximación también se llega a mejores algoritmos,
152
Herramientas de análisis
ya que logrando que el algoritmo se comporte bien para el peor caso, entonces
también lo hara para cualquier entrada.
4.2.3.
Notación asintótica
En general, cada paso básico en una descripción en pseudo-código o en una
implementación de lenguaje de alto nivel corresponde a un número pequeño
de operaciones primitivas, excepto por las llamadas a métodos. Por lo tanto
se puede hacer un análisis simple de un algoritmo que estime el número de
operaciones primitivas ejecutadas hasta un factor constante, por lo pasos en el
pseudo-código. Se debe ser cuidadoso, ya que una sóla lı́nea de pseudo-código
podrı́a comprender una cantidad de pasos en ciertos casos.
En el análisis de algoritmos se enfoca en la relación de crecimiento del
tiempo de ejecución como una función del tamaño de la entrada n, tomando
una aproximación o una “foto panorámica”. Es en ocasiones suficiente con
saber que el tiempo de ejecución de un algoritmo, tal como maxArreglo
mostrado enseguida, crece proporcionalmente a n, siendo n veces un factor
constante que depende de la computadora, que su verdadero tiempo de
ejecución
Algoritmo maxArreglo(A, n):
Entrada: Un arreglo A con n ≥ 1 enteros.
Salida: El elemento máximo en A.
maxAct ← A[0]
para i ← 1 hasta n − 1 hacer
si maxAct < A[i] entonces
maxAct ← A[i]
regresar maxAct
Se analizan los algoritmos usando una notación matemática para funciones
que no tiene en cuenta los factores constantes. Se caracterizan los tiempos
de ejecución de los algoritmos usando funciones que mapean el tamaño de
la entrada, n, a valores que corresponden al factor principal que determina
la relación de crecimiento en términos de n. Esta aproximación permite
enfocarse en los aspectos de la “foto panorámica” del tiempo de ejecución de
un algoritmo.
4.2 Análisis de algoritmos
153
La notación “O-grande”
Sean f (n) y g(n) funciones que mapean enteros no negativos a números
reales. Se dice que f (n) es O(g(n)) si hay una constante c > 0 y una constante
entera n0 ≥ 1 tal que
f (n) ≤ cg(n), para n ≥ n0 .
Esta definición es frecuentemente referida como la notación “O-grande”,
por eso en ocasiones se dice “f (n) es O-grande de g(n)”. Alternativamente,
se puede también decir “f (n) es orden de g(n)”. La definición se ilustra en la
figura 4.5
Figura 4.5: Ilustración de la notación “O-grande”. f (n) es O(g(n)) pues f (n) ≤
c · g(n) cuando n ≥ n0 .
Ejemplo 4.1. La función 8n − 2 es O(n)
Justificación. Por la definición de O-grande, se necesita encontrar una
constante real c > 0 y una constante entera n0 ≥ 1 tal que 8n − 2 ≤ cn para
cada entero n ≥ n0 . Una posible opción es c = 8 y n0 = 1 de la infinidad
de opciones disponibles, ya que cualquier número real mayor que o igual a 8
funciona para c, y cualquier entero mayor que o igual a 1 trabajará para n0 .
La notación O-grande permite decir que una función f (n) es “menor que, o
igual a” otra función g(n) hasta un factor constante y en el sentido asintótico
conforme n tiene a infinito. Esto se debe al hecho de que la definición usa “≤”
para comparar f (n) con g(n) veces una constante, c, para los casos asintóticos
cuando n ≥ n0 .
154
Herramientas de análisis
Caracterización de tiempos de ejecución con O-grande
La notación O-grande es usada ampliamente para caracterizar los tiempos
de ejecución y el espacio requerido en términos de algún parámetro n, el
cual cambia de problema a problema, pero es siempre definido como una
medida escogida del “tamaño” del problema. Por ejemplo, si se está interesado
en encontrar el elemento más grande en un arreglo de enteros, como en el
algoritmo maxArreglo, n deberá denotar la cantidad de elementos en el
arreglo. Con la notación O-grande, se puede decir la siguiente proposición
matemáticamente precisa del tiempo de ejecución del algoritmo maxArreglo
para cualquier computadora.
Proposición 4.5: El algoritmo maxArreglo, para calcular el elemento
máximo en un arreglo de n enteros, corre en tiempo O(n).
Justificación. La cantidad de operaciones primitivas ejecutadas por el algoritmo maxArreglo en cada iteración es una constante. Por lo tanto, como
cada operación primitiva se ejecuta en tiempo constante, se puede decir que
el tiempo de ejecución del algoritmo maxArreglo para una entrada de
tamaño n es a lo más una constante por n, esto es, se podrı́a concluir que el
tiempo de ejecución del algoritmo maxArreglo es O(n).
Algunas propiedades de la notación O-grande
La notación O-grande permite ignorar los factores constantes y los términos
de orden menor y enfocarse en los componentes principales de una función
que afectan su crecimiento.
Ejemplo 4.2. 5n4 + 3n3 + 2n2 + 4n + 1 es O(n4 )
Justificación: Observar que 5n4 +3n3 +2n2 +4n+1 ≤ (5+3+2+4+1)n4 = cn4 ,
para c = 15, cuando n ≥ n0 = 1
Se puede caracterizar la relación de crecimiento de cualquier función
polinomial.
Proposición 4.6: Si f (n) es un polinomio de grado n, esto es
f (n) = a0 + a1 n + · · · ad nd ,
y ad > 0, entonces f (n) es O(nd ).
Justificación: observar que, para n ≥ 1, se tiene 1 ≤ n ≤ n2 ≤ · · · ≤ nd por
lo tanto
a0 + a1 n + · · · + ad nd ≤ (a0 + a1 + · · · + ad )nd .
4.2 Análisis de algoritmos
155
Ası́, se puede mostrar que f (n) es O(nd ) definiendo c = a0 + a1 + · · · + ad y
n0 = 1.
Ası́, el término de mayor grado en una función polinomial es el término
que determina la razón de crecimiento asintótico del polinomio. Se muestran a
continuación ejemplos adicionales, en donde se combinan las funciones vistas
al inicio del capı́tulo.
Ejemplo 4.3. 5n2 + 3n log n + 2n + 5 es O(n2 ).
Justificación: 5n2 + 3n log n + 2n + 5 ≤ (5 + 3 + 2 + 5)n2 = cn2 , para c =
15 cuando n ≥ n0 = 2. Observar que n log n es cero para n = 1.
Ejemplo 4.4. 20n3 + 10n log n + 5 es O(n3 ).
Justificación: 20n3 + 10n log n + 5 ≤ 35n3 , para n ≥ 1.
Ejemplo 4.5. 3 log n + 2 es O(log n).
Justificación: 3 log n + 2 ≤ 5 log n, para n ≥ 2. La razón por la que se usa
n ≥ 2 = n0 es porque log n es cero para n = 1.
Ejemplo 4.6. 2n+2 es O(2n ).
Justificación: 2n+2 = 2n 22 = 4 · 2n ; por lo tanto, se puede tomar c = 4 y
n0 = 1 en este caso.
Ejemplo 4.7. 2n + 100 log n es O(n).
Justificación: 2n + 100 log n ≤ 102n para n ≥ 2 = n0 ; por lo tanto, se puede
tomar a c = 102 en este caso.
Caracterización de funciones en términos más simples
Se podrı́a usar la notación O-grande para caracterizar una función tan
cercana como sea posible. Para la función f (n) = 4n3 + 3n2 es cierto que es
O(n5 ) o aún O(n4 ), pero es más preciso decir que f (n) es O(n3 ). También
se considera de mal gusto incluir factores constantes y términos de orden
inferior en la notación O-grande. No está de moda decir que la función 2n2
es O(4n2 + 6n log n), sin embargo esto es completamente correcto. Se debe
intentar describir la función en O-grande en los términos más simples.
Las funciones descritas en la sección 4 son las funciones más comunes
usadas en conjunción con la notación O-grande para caracterizar los tiempos
de ejecución y el espacio usado de los algoritmos. Además, se usan los nombres
de estas funciones para referirse a los tiempos de ejecución de los algoritmos
que están caracterizando. Por ejemplo, se podrı́a decir que un algoritmo que
se ejecuta con tiempo 4n2 + n log n en el peor caso como un algoritmo tiempo
cuadrático, ya que corre en tiempo O(n2 ). De igual forma, un algoritmo
156
Herramientas de análisis
ejecutándose en tiempo a lo más 5n + 20 log n + 4 podrı́a ser llamado un
algoritmo lineal.
Ω-grande
La notación O-grande proporciona una forma asintótica de decir que una
función es “menor que o igual a” otra función, la siguiente notación da una
forma asintótica para indicar que una función crece a un ritmo que es “mayor
que o igual a” otra función.
Sean f (n) y g(n) funciones que mapean enteros no negativos a números
reales. Se dice que f (n) es Ω(g(n)) (pronunciada “f(n) es notación Omegagrande de g(n)”) si g(n) es O(f (n)), esto es, existe una constante real c > 0
y una constante entera n0 ≥ 1 tal que
f (n) ≥ cg(n), para n ≥ n0 .
Esta definición permite decir asintóticamente que una función es mayor
que o igual a otra, hasta un factor constante.
Ejemplo 4.8. La función 3n log n + 2n es Ω(n log n)
Justificación. 3n log n + 2n ≥ 3n log n, para n ≥ 2.
Θ (Teta-grande)
Además, hay una notación que permite decir que dos funciones crecen a la
misma velocidad, hasta unos factores constantes. Se dice que f (n) es Θ(g(n))
(pronunciado “f (n) es Tetha-grande de g(n)”) si f (n) es O(g(n)) y f (n) es
Ω(g(n)), esto es, existen constantes reales c0 > 0 y c00 > 0, y una constante
entera n0 ≥ 1 tal que
c0 g(n) ≤ f (n) ≤ c00 g(n), para n ≥ n0 .
Ejemplo 4.9. 3n log n + 4n + 5 log n es Θ(n log n)
Justificación. 3n log n ≤ 3n log n + 4n + 5 log n ≤ (3 + 4 + 5)n log n para
n ≥ 2.
4.2.4.
Análisis asintótico
Suponiendo dos algoritmos que resuelven el mismo problema: un algoritmo
A, el cual tiene tiempo de ejecuión O(n), y un algoritmo B, con tiempo de
4.2 Análisis de algoritmos
157
ejecución O(n2 ). Se desea sabe cuál algoritmo es mejor. Se sabe que B es
O(n2 ), lo cual implica que el algoritmo A es asintóticamente mejor que el
algoritmo B, sin embargo para un valor pequeño de n, B podrı́a tener un
tiempo de ejecución menor que A.
Se puede emplear la notación O-grande para ordenar clases de funciones
por su velocidad de crecimiento asintótico. Las siete funciones son ordenadas
por su velocidad de crecimiento incremental en la siguiente secuencia, esto es,
si una función f (n) precede a una función g(n) en la secuencia, entonces f (n)
es O(g(n)):
1 log n n n log n n2 n3 2n
Se muestra enseguida la tasa de crecimiento de algunas funciones importantes a continuación:
n log n n n log n
2
1
2
2
4
2
4
8
8
3
8
24
16
4
16
64
32
5
32
160
64
6
64
384
128
7
128
896
256
8
256 2,048
512
9
512 4,608
n2
n3
4
8
16
64
64
512
256
4,096
1,024
32,768
4,096
262,144
16,384
2,097,152
65,536 16,777,216
262,144 134,217,728
2n
4
16
256
65,536
4,294,967,296
1.84 × 1019
3.40 × 1038
1.15 × 1077
1.34 × 10154
La siguiente tabla explora el tamaño máximo permitido para una instancia
de entrada que es procesada por un algoritmo en 1 segundo, 1 minuto, y 1
hora. Se muestra la importancia del diseño de un buen algoritmo, porque un
algoritmo lento asintóticamente es derrotado en una ejecución grande por
un algoritmo más rápido asintóticamente, aún si el factor constante para el
algoritmo más rápido es malo.
Tiempo
Ejecución (µs)
400n
2n2
2n
Tamaño máximo del problema (n)
1 segundo 1 minuto
1 hora
2,500
150,000
9,000,000
707
5,477
42,426
19
25
31
158
Herramientas de análisis
La importancia del diseño de un buen algoritmo va más allá de que puede
ser resuelto eficientemente en una computadora dada. Como se muestra en
la siguiente tabla aún si se logra una aceleración dramática en el hardware,
todavı́a no se puede superar la desventaja de un algoritmo asintóticamente
lento. La tabla muestra el tamaño máximo del nuevo problema que se calcula
para una cantidad fija de tiempo, suponiendo que los algoritmos con los
tiempos de ejecución dados son ejecutados ahora en una computadora 256
veces más rápida que la previa.
Tiempo de ejecución Tamaño máximo del nuevo problema
400n
256m
2n2
16m
n
2
m+8
4.2.5.
Uso de la notación O-grande
Se considera un abuso de la notación decir “f (n) ≤ O(g(n))”, ya que
la notación O-grande ya denota el concepto “menor que o igual a”. De
igual forma no es correcto completamente decir “f (n) = O(g(n)), porque no
hay forma de que haga sentido la sentencia “O(g(n)) = f (n)”. Además, es
completamente incorrecto decir “f (n) ≥ O(g(n))” o “f (n) > O(g(n))”, ya
que g(n) en la notación O-grande indica una cota superior de f (n). Es mejor
decir,
f (n)esO(g(n)).
Una expresión más matemática para lo anterior es,
f (n) ∈ O(g(n)),
ya que la notación O-grande está indicando una colección completa de
funciones.
Advertencia
El uso de la O-grande y las notaciones relacionadas puede ser engañoso
debido a que los factores constantes que “ocultan” pueden ser muy grandes.
Por ejemplo, es cierto que la función 10100 n es O(n), si este es el tiempo de
ejecución de un algoritmo siendo comparado con otro cuyo tiempo de ejecución
4.2 Análisis de algoritmos
159
es 10n log n, se podrı́a preferir el algoritmo de tiempo O(n log n), a pesar de
que el algoritmo lineal es asintóticamente más rápido. La decisión es por el
factor constante, 10100 , “un googol”, considerado por muchos astrónomos
como una cota superior de la cantidad de átomos en el universo observable.
Serı́a improbable tener un problema del mundo real que tenga esa cantidad
como tamaño de entrada. Por lo tanto, incluso cuando se usa la notación
O-grande, se debe ser consciente de los factores constantes y los términos de
orden inferior que se están “ocultando”.
En general, cualquier algoritmo ejecutándose en tiempo O(n log n), con
un factor constante razonable, deberá ser considerado eficiente. Un método
con tiempo O(n2 ) podrı́a ser lo suficente rápido en algunos casos, por ejemplo,
cuando n es pequeño. Pero un algoritmo ejecutándose en tiempo O(2n ) casi
nunca deberı́a ser considerado eficiente.
Tiempos de ejecución exponencial
Si se deben separar los algoritmos eficientes de los ineficientes, se deberı́an
distinguir entre los algoritmos que se ejecutan en tiempo polinomial y los de
tiempo exponencial. Esto es, hacer la distinción entre los algoritmos con un
tiempo de ejecución O(nc ), para alguna constante c > 1, y aquellos con un
tiempo de ejecución O(bn ), para alguna constante b > 1. Podrı́a existir “un
prietito en el arroz” como un algoritmo ejecutándose en O(n100 ) no deberı́a ser
considerado como eficiente. Aún ası́, la distinción entre algoritmos de tiempo
polinomial y exponencial es considerado una medida robusta de tratabilidad.
Las notaciones asintóticas de O-grande, Omega-grande, y Teta-grande
dan un lenguaje conveniente para analizar estructuras de datos y algoritmos,
ya que permiten concentrarse en la foto grande en vez de los detalles de bajo
nivel.
Análisis asintótico para promedios de prefijos
Se analizan dos algoritmos que resuelven el mismo problema pero teniendo
diferentes tiempos de ejecución. El problema es el cálculo de promedios de
prefijos de una secuencia de números. Es decir, dado un arreglo X con n
números, se quiere calcular un arreglo A tal que A[i] es el promedio de los
elementos X[0], . . . , X[i], para i = 0, . . . , n − 1, esto es,
Pi
j=0 X[j]
A[i] =
.
i+1
160
Herramientas de análisis
El cálculo de promedios de prefijos tiene varias aplicaciones en economı́a
y estadı́stica.
Un algoritmo de tiempo cuadrático
Para el problema de promedios de prefijos, se muestra enseguida el algoritmo promediosPrefijo1, el cual calcual cada elemento de A separadamente,
de acuerdo a la definición.
Algoritmo promediosPrefijo1(X):
Entrada: un arreglo X con n-elementos de números.
Salida: un arreglo A con n-elementos de números
tal que A[i] es el promedio de X[0], . . . , X[i].
Sea A un arreglo de n números.
para i ← 0 hasta n − 1 hacer
a←0
para j ← 0 hasta i hacer
a ← a + X[j]
A[i] ← a/(i + 1)
regresar arreglo A
El análisis del algoritmo promediosPrefijo1 es el siguiente:
Inicializar el arreglo A al inicio y devolver el arreglo al final puede
ser hecho con una cantidad constante de operaciones primitivas por
elemento, y toma tiempo O(n).
Para los dos ciclos anidados para, el cuerpo del ciclo externo, controlado
por el contador i, es ejecutado n veces para i = 0, . . . , n − 1. Por lo
que sentencias como a ← 0 y A[i] ← a/(i + 1) son ejecutadas n veces
cada una. Estas dos sentencias, además del incremento y la prueba
del contador i, contribuyen una cantidad de operaciones primitivas
proporcional a n, o sea, tiempo O(n).
El cuerpo del ciclo interno, controlado por el contador j, se ejecuta
i + 1 veces, es decir, depende del valor actual del contador i del ciclo
externo. De este modo, la sentencia a ← a + X[j] en el ciclo interno es
ejecutada 1 + 2 + 3 + · · · + n = n(n + 1)/2 veces, ası́ que la sentencia en el
ciclo interno contribuye con tiempo O(n2 ). De igual manera sucede con
el incremento y la prueba del contador j, que también tienen tiempo
O(n2 ).
4.2 Análisis de algoritmos
161
El tiempo de ejecución de promediosPrefijo1 está dado por la suma de
los tres términos. Los primeros dos términos son O(n), y el tercero es O(n2 ).
Usando la proposición 4.6, el tiempo de ejecución es O(n2 ).
Un algoritmo de tiempo lineal
Para calcular los promedios de prefijos de forma más eficiente, se puede
observar que dos promedios consecutivos A[i − 1] y A[i] son similares:
A[i − 1] = (X[0] + X[1] + · · · + X[i − 1])/i
A[i] = (X[0] + X[1] + · · · + X[i])/(i + 1)
Si se denota con Si la suma prefijo X[0] + X[1] + · · · + X[i], el cálculo
de los promedios prefijo es A[i] = Si /(i + 1). Es fácil llevar la suma prefijo
actual cuando se recorre el arreglo X con un ciclo. El siguiente algoritmo
promediosPrefijo2 usa la suma prefijo.
Algoritmo promediosPrefijo2(X):
Entrada: un arreglo X con n-elementos de números.
Salida: un arreglo A con n-elementos de números
tal que A[i] es el promedio de X[0], . . . , X[i].
Sea A un arreglo de n números.
s←0
para i ← 0 hasta n − 1 hacer
s ← s + X[i]
A[i] ← s/(i + 1)
regresar arreglo A
El análisis del tiempo de ejecución del algoritmo promediosPrefijo2 es
el siguiente:
Inicializar el arreglo A al inicio y devolver el arreglo al final puede
ser hecho con una cantidad constante de operaciones primitivas por
elemento, y toma tiempo O(n).
Inicializar la variable s al inicio toma tiempo O(1).
Hay un sólo ciclo para, controlado por el contador i. El cuerpo del
ciclo es ejecutado n veces, para i = 0, . . . , n − 1. Ası́ las sentencias
162
Herramientas de análisis
s ← s + X[i] y A[i] ← s/(i + 1) son ejecutadas n veces cada uno.
Además de las operaciones anteriores, las de incremento y prueba del
contador i también contribuyen una cantidad de operaciones primitivas
proporcional a n, es decir, tiempo O(n).
El tiempo de ejecución del algoritmo promediosPrefijo2 está dado por
la suma de los tres términos. El primer término y el último son O(n), y el
segundo es O(1). Usando la proposición 4.6, el tiempo de ejecución es O(n),
el cual es mucho mejor que el tiempo cuadrático de promediosPrefijo1.
4.2.6.
Algoritmo recursivo para calcular potencias
Se revisa un ejemplo más interesante de análisis de algoritmos, que es
el elevar un número x a un entero no negativo arbitrario, n. Es decir, se
quiere calcular la función potencia p(x, n), definida como p(x, n) = xn .
Esta función tiene una definición recursiva basada en recursión lineal:
1
si n = 0
p(x, n) =
x · p(x, n − 1) de otra forma.
Esta definición da un algoritmo recursivo que usa O(n) llamadas al método
para calcular p(x, n). Se puede calcular la función potencia más rápido usando
la siguiente definición alterna, también basada en recursión lineal, la cual
emplea una técnica de cuadrado:

si n = 0
 1
2
x · p(x, (n − 1)/2) si n > 0 es impar
p(x, n) =

p(x, n/2)2
si n > 0 es par.
Para mostrar como esta definición trabaja, se dan los siguientes ejemplos:
2
24 = 2(4/2) = (24/2 )2 = (22 )2 = 42 = 16
2
25 = 21+(4/2) = 2(24/2 )2 = 2(22 )2 = 2(42 ) = 32
2
26 = 2(6/2) = (26/2 )2 = (23 )2 = 82 = 64
2
27 = 21+(6/2) = 2(26/2 )2 = 2(23 )2 = 2(82 ) = 128
De la última definción se puede obtener el siguiente algoritmo Potencia:
4.2 Análisis de algoritmos
163
Algoritmo Potencia(x, n):
Entrada: un número x y un entero n ≥ 0.
Salida: el valor xn .
si n = 0 entonces
regresar 1
si n es impar entonces
y ← Potencia(x, (n − 1)/2)
regresar x · y · y
sino
y ← Potencia(x, n/2)
regresar y · y
Para analizar el tiempo de ejecución del algoritmo, se observa que en cada
llamada recursiva al método Potencia(x, n) se divide el exponente, n, por dos.
Ası́, hay O(log n) llamadas recursivas. Entonces, usando la recursión lineal y
la técnica del cuadrado, se reduce el tiempo de ejecución para el cálculo de la
función potencia de O(n) a O(log n).
164
Herramientas de análisis
Capı́tulo 5
Pilas y colas
5.1.
Pilas
Una pila (stack ) es una colección de objetos que son insertados y removidos
de acuerdo al principio último en entrar, primero en salir, LIFO (last-in
first-out). Los objetos pueden ser insertados en una pila en cualquier momento,
pero solamente el más reciente insertado, es decir, el “último” objeto puede ser
removido en cualquier momento. Una analogı́a de la pila es el dispensador de
platos que se encuentra en el mobiliario de alguna cafeterı́a o cocina. Para este
caso, las operaciones fundamentales involucran push (empujar) platos y pop
(sacar) platos de la pila. Cuando se necesita un nuevo plato del dispensador,
se saca el plato que está encima de la pila, y cuando se agrega un plato, se
empuja este hacia abajo en la pila para que se convierta en el nuevo plato de
la cima. Otro ejemplo son los navegadores Web de internet que guardan las
direcciones de los sitios recientemente visitados en una pila. Cada vez que un
usuario visita un nuevo sitio, esa dirección del sitio es empujada en la pila de
direcciones. El navegador, mediante el botón Volver atrás, permite al usuario
sacar los sitios previamente visitados.
5.1.1.
El tipo de dato abstracto pila
Las pilas son las estructuras de datos más simples, pero también están
entre las más importantes, porque se usan en una gran cantidad de aplicaciones diferentes que incluyen varias estructuras de datos más sofisticadas.
Formalmente, una pila es un tipo de dato abstracto que soporta los siguientes
dos métodos:
166
Pilas y colas
push(e):inserta el elemento e, para que sea la cima de la
pila.
pop():quita el elemento de la cima de la pila y lo regresa;
un error ocurre si la pila está vacı́a.
Adicionalmente, también se podrı́an definir los siguientes métodos:
size():regresa el número de elementos en la pila.
isEmpty():regresa un booleano indicando si la pila está vacı́a.
top():regresa el elemento de la cima de la pila, sin removerlo; un error ocurre si la pila está vacı́a.
La siguiente tabla muestra una serie de operaciones en la pila y su efecto
en una pila de enteros inicialmente vacı́a, observar que los elementos son
metidos y sacados por el mismo lado, el lado derecho.
Operación
Salida
Contenido
push(5)
–
(5)
push(3)
–
(5,3)
pop()
3
(5)
push(7)
–
(5,7)
pop()
7
(5)
top()
5
(5)
pop()
5
()
pop()
“error”
()
isEmpty()
true
()
push(9)
–
(9)
push(7)
–
(9,7)
push(3)
–
(9,7,3)
push(5)
–
(9,7,3,5)
size()
4
(9,7,3,5)
pop()
5
(9,7,3)
push(8)
–
(9,7,3,8)
pop()
8
(9,7,3)
pop()
3
(9,7)
Una interfaz pila en Java
Debido a su importancia, la estructura de datos pila está incluida como una clase en el paquete java.util. La clase java.util.Stack es una
5.1 Pilas
167
estructura que guarda objetos genéricos Java e incluye, entre otros, los
métodos push(), pop(), peek() (equivalente a top()), size(), y empty()
(equivalente a isEmpty()). Los métodos pop() y peek() lanzan la excepción EmptyStackException del paquete java.util si son llamados con pilas
vacı́as. Mientras sea conveniente sólo usar la clase incorporada java.util.Stack, es instructivo aprender como diseñar e implementar una pila “desde
cero”.
Implementar un tipo de dato abstracto en Java involucra dos pasos. El
primer paso es la definición de una interfaz de programación de aplicaciones o API, o interfaz, la cual describe los nombres de los métodos que
la estructura abstracta de datos soporta y como tienen que ser declarados y
usados.
Además, se deben definir excepciones para cualquier condición de error
que pueda originarse. Por ejemplo, la condición de error que ocurre cuando se
llama al método pop() o top() en una cola vacı́a es señalado lanzando una
excepción de tipo EmptyStackException, la cual está definida en el listado
5.1
1
2
3
4
5
6
7
8
9
/* *
* Excepci ó n Runtime lanzada cuando se intenta hacer una operaci ó n
* top o pop con una cola vac ı́ a .
*/
public class E m p t y S t a c k E x c e p t i o n extends R u n t i m e E x c e p t io n {
public E m p t y S t a c k E x c e p t i o n ( String err ) {
super ( err );
}
}
Listado 5.1: Excepción lanzada por los métodos pop() y top() de la interfaz pila
cuando son llamados con una pila vacı́a.
Una interfaz completa para el ADT pila está dado en el listado 5.2. Esta
interfaz es muy general ya que especifica que elementos de cualquier clase dada,
y sus subclases, pueden ser insertados en la pila. Se obtiene esta generalidad
mediante el concepto de genéricos (sección 2.5.2).
Para que un ADT dado sea para cualquier uso, se necesita dar una clase
concreta que implemente los métodos de la interfaz asociada con ese ADT. Se
da una implementación simple de la interfaz Stack en la siguiente subsección.
168
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Pilas y colas
/* *
* Interfaz para una pila : una colecci ó n de objetos que son insertados
* y removidos de acuerdo al principio ú ltimo en entrar , primero en salir .
* Esta interfaz incluye los m é todos principales de java . util . Stack .
*
* @see E m p t y S t a c k E x c e p t i o n
*/
public interface Stack <E > {
/* *
* Regresa el n ú mero de elementos en la pila .
* @return n ú mero de elementos en la pila .
*/
public int size ();
/* *
* Indica si la pila est á vacia .
* @return true si la pila est á vac ı́a , de otra manera false .
*/
public boolean isEmpty ();
/* *
* Explorar el elemento en la cima de la pila .
* @return el elemento cima en la pila .
* @exception E m p t y S t a c k E x c e p t i o n si la pila est á vac ı́ a .
*/
public E top ()
throws E m p t y S t a c k E x c e p t i o n ;
/* *
* Insertar un elemento en la cima de la pila .
* @param elemento a ser insertado .
*/
public void push ( E elemento );
/* *
* Quitar el elemento de la cima de la pila .
* @return elemento removido .
* @exception E m p t y S t a c k E x c e p t i o n si la pila est á vac ı́ a .
*/
public E pop ()
throws E m p t y S t a c k E x c e p t i o n ;
}
Listado 5.2: Interfaz Stack con documentación en estilo Javadoc. Se usa el tipo
parametrizado genérico, E, para que la pila pueda contener elementos de cualquier
clase.
5.1.2.
Implementación de una pila usando un arreglo
Se puede implementar una pila guardando sus elementos en un arreglo. La
pila en esta implementación consiste de una arreglo S de n elementos además
de una variable entera t que da el ı́ndice del elemento de la cima en el arreglo
S.
En Java el ı́ndice para los arreglos inicia con cero, por lo que t se inicializa
5.1 Pilas
169
Figura 5.1: Implementación de una pila con un arreglo S. El elemento de la cima
está guardado en la celda S[t].
con -1, y se usa este valor de t para identificar una pila vacı́a. Asimismo,
se pueda usar t para determinar el número de elementos (t+1). Se agrega
también una nueva excepción, llamada FullStackException, para señalar
el error que surge si se intenta insertar un nuevo elemento en una pila llena.
La excepción FullStackException es particular a esta implementación y no
está definida en la ADT pila. Se dan los detalles de la implementación de la
pila usando un arreglo en los siguientes algoritmos.
Algoritmo size():
regresar t + 1
Algoritmo isEmpty():
regresar (t < 0)
Algoritmo top():
si isEmpty() entonces
lanzar una EmptyStackException
regresar S[t]
Algoritmo push(e):
si size()= N entonces
lanzar una FullStackException
t←t+1
S[t] ← e
Algoritmo pop():
si isEmpty() entonces
lanzar una EmptyStackException
e ← S[t]
S[t] ← null
t←t−1
regresar e
170
Pilas y colas
Análisis de la implementación de pila con arreglos
La correctez de los métodos en la implementación usando arreglos se tiene
inmediatamente de la definición de los propios métodos. Hay, sin embargo, un
punto medio interesante que involucra la implementación del método pop().
Se podrı́a haber evitado reiniciar el viejo S[t] a null y se podrı́a tener
todavı́a un método correcto. Sin embargo, hay una ventaja y desventaja en
evitar esta asignación que podrı́a pensarse al implementar los algoritmos en
Java. El equilibrio involucra el mecanismo colector de basura de Java que
busca en la memoria objetos que ya no son referenciados por objetos activos,
y recupera este espacio para su uso futuro. Sea e = S[t] el elemento de la
cima antes de que el método pop() sea llamado. Haciendo que S[t] tenga una
referencia nula, se indica que la pila no necesita mantener más una referencia
al objeto e. Además, si no hay otras referencias activas a e, entonces el espacio
de memoria usado por e será reclamado por el colector de basura.
En la siguiente tabla se muestran los tiempos de ejecución para los métodos
en la ejecución de una pila con un arreglo. Cada uno de los métodos de la pila en
el arreglo ejecuta un número constante de sentencias involucrando operaciones
aritméticas, comparaciones, y asignaciones. Además, pop() también llama
a isEmpty(), el cual también se ejecuta en tiempo constante. Por lo tanto,
en esta implementación del ADT pila, cada método se ejecuta en tiempo
constante, esto es, cada uno corre en tiempo O(1).
Método Tiempo
size
O(1)
isEmpty
O(1)
top
O(1)
push
O(1)
pop
O(1)
La clase ArregloStack, listado 5.3, implementa la interfaz Stack. Se usa
el nombre simbólico, CAPACIDAD, para indicar el tamaño del arreglo. Este
valor permite indicar la capacidad del arreglo en un sólo lugar y que se visto
en todo el código.
5.1 Pilas
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
171
/* *
* Implementaci ó n de la ADT pila usando un arreglo de longitud fija .
* Una excepci ó n es lanzada si la operaci ó n push es intentada cuando
* el tama n
~ o de la pila es igual a la longitud del arreglo . Esta
* clase incluye los m é todos principales de la clase agregada
* java . util . Stack .
*
*/
public class ArregloStack <E > implements Stack <E > {
protected int capacidad ; // capacidad actual del arreglo del stack
public static final int CAPACIDAD = 1000; // capacidad por defecto
protected E [] S ; // arreglo ègenrico usado para implementar el stack
protected int t = -1; // ı́ ndice para la cima del stack
public ArregloStack () {
this ( CAPACIDAD ); // capacidad por defecto
}
public ArregloStack ( int cap ) {
capacidad = cap ;
S = ( E []) new Object [ capacidad ]; // el compilador podr ı́ a dar advertencia
// pero est á bien
}
public int size () {
return ( t + 1);
}
public boolean isEmpty () {
return ( t < 0);
}
public void push ( E elemento ) throws F u l l S t a c k E x c e p t i o n {
if ( size () == capacidad )
throw new F u l l S t a c k E x c e p t i on ( " La pila est á llena . " );
S [++ t ] = elemento ;
}
public E top () throws E m p t y S t a c k E x c e p t i o n {
if ( isEmpty ())
throw new E m p t y S t a c k E x c e p t i o n ( " La pila est á vac ı́ a . " );
return S [ t ];
}
public E pop () throws E m p t y S t a c k E x c e p t i o n {
if ( isEmpty ())
throw new E m p t y S t a c k E x c e p t i o n ( " La pila est á vac ı́ a . " );
E elemento = S [ t ];
S [t - -] = null ; // des referenciar S [ t ] para el colector de basura .
return elemento ;
}
public String toString () {
String s = " [ " ;
if ( size () > 0) s += S [0];
for ( int i = 1; i <= size () -1; i ++)
s += " , " + S [ i ];
return s + " ] " ;
}
// Imprime informaci ó n del estado de la pila y de la ú ltima operaci ó n
public void estado ( String op , Object elemento ) {
System . out . print ( " ----- -> " + op );
// imprime esta operaci ó n
System . out . println ( " , regresa " + elemento ); // que fue regresado
System . out . print ( " resultado : num . elems . = " + size ());
172
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
Pilas y colas
System . out . print ( " , ¿ est á vac ı́ o ? " + isEmpty ());
System . out . println ( " , pila : " + this );
// contenido de la pila
}
/* *
* Prueba el programa haciendo una serie de operaciones en pilas ,
* imprimiendo las operaciones realizadas , los elementos regresados y
* el contenido de la pila involucrada , despues de cada operaci ó n .
*/
public static void main ( String [] args ) {
Object o ;
ArregloStack < Integer > A = new ArregloStack < Integer >();
A . estado ( " new ArregloStack < Integer > A " , null );
A . push (7);
A . estado ( " A . push (7) " , null );
o = A . pop ();
A . estado ( " A . pop () " , o );
A . push (9);
A . estado ( " A . push (9) " , null );
o = A . pop ();
A . estado ( " A . pop () " , o );
ArregloStack < String > B = new ArregloStack < String >();
B . estado ( " new ArregloStack < String > B " , null );
B . push ( " Paco " );
B . estado ( " B . push (\" Paco \") " , null );
B . push ( " Pepe " );
B . estado ( " B . push (\" Pepe \") " , null );
o = B . pop ();
B . estado ( " B . pop () " , o );
B . push ( " Juan " );
B . estado ( " B . push (\" Juan \") " , null );
}
}
Listado 5.3: Implementación de la interfaz Stack con un arreglo.
Salida ejemplo
Se muestra enseguida la salida del programa ArregloStack. Con el uso
de tipos genéricos, se puede crear un ArregloStack A para guardar enteros
y otro ArregloStack B para guardar String.
------> new ArregloStack<Integer> A, regresa null
resultado: num. elems. = 0, está vacio = true, pila: []
------> A.push(7), regresa null
resultado: num. elems. = 1, está vacio = false, pila: [7]
------> A.pop(), regresa 7
resultado: num. elems. = 0, está vacio = true, pila: []
------> A.push(9), regresa null
resultado: num. elems. = 1, está vacio = false, pila: [9]
5.1 Pilas
173
------> A.pop(), regresa 9
resultado: num. elems. = 0, está vacio = true, pila: []
------> new ArregloStack<String> B, regresa null
resultado: num. elems. = 0, está vacio = true, pila: []
------> B.push("Paco"), regresa null
resultado: num. elems. = 1, está vacio = false, pila: [Paco]
------> B.push("Pepe"), regresa null
resultado: num. elems. = 2, está vacio = false, pila: [Paco, Pepe]
------> B.pop(), regresa Pepe
resultado: num. elems. = 1, está vacio = false, pila: [Paco]
------> B.push("Juan"), regresa null
resultado: num. elems. = 2, está vacio = false, pila: [Paco, Juan]
Limitación de la pila con un arreglo
La implementación con un arreglo de una pila es simple y eficiente. Sin
embargo, esta implementación tiene un aspecto negativo—esta debe asumir
un lı́mite superior fijo, CAPACIDAD, en el tamaño de la pila. Para el ejemplo
mostrado en el listado 5.3 se escogió una capacidad de 1,000 arbitrariamente.
Una aplicación podrı́a actualmente necesitar mucho menos espacio que esto,
por lo que se tendrı́a un desperdicio de memoria. Alternativamente, una
aplicación podrı́a necesitar más espacio que esto, lo cual causarı́a que la
implementación de la pila genere una excepción tan pronto como un programa
cliente intente rebasar la capacidad de la pila, por defecto 1,000 objetos. A
pesar de la simplicidad y eficiencia, la pila implementada con arreglo no es
precisamente ideal.
Afortunadamente, hay otra implementación, la cual se revisa a continuación, que no tiene una limitación del tamaño y usa espacio proporcional al
número actual de elementos guardados en la pila. Sin embargo, en casos dónde
se tiene una buena estimación del número de elementos que se guardarán en
la pila, la implementación usando un arreglo es difı́cil de vencer. Las pilas
tienen un papel importante en un número de aplicaciones computacionales,
por lo tanto es útil tener una implementación rápida del ADT pila, como una
implementación simple basada en el arreglo.
5.1.3.
Implementación de una pila usando lista simple
Se revisa la implementación del ADT pila usando una lista simple enlazada.
En el diseño se requiere decidir si la cima de la pila está en la cabeza de la
174
Pilas y colas
lista o en la cola. La mejor opción es en la cabeza ya que se pueden insertar
elementos y borrar en tiempo constante. Por lo tanto, es más eficiente tener
la cima de la pila en la cabeza de la lista. También, para hacer la operación
size() en tiempo constante, se lleva la cuenta del número actual de elementos
en una variable de instancia.
En vez de usar una lista ligada que solamente pueda guardar objetos
de un cierto tipo, como se mostró en la sección 3.3, se quiere, en este caso,
implementar una pila genérica usando una lista genérica enlazada. Por lo
tanto, se necesita usar un nodo de tipo genérico para implementar esta lista
enlazada. Se muestra tal clase Nodo en el listado 5.4.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/* *
* Nodo de una lista simple enlazada , la cual guarda referencias
* a su elemento y al siguiente nodo en la lista .
*
*/
public class Nodo <E > {
// Variables de instancia :
private E elemento ;
private Nodo <E > sig ;
/* * Crea un nodo con referencias nulas a su elemento y al nodo sig . */
public Nodo () {
this ( null , null );
}
/* * Crear un nodo con el elemento dado y el nodo sig . */
public Nodo ( E e , Nodo <E > s ) {
elemento = e ;
sig = s ;
}
// M é todos accesores :
public E getElemento () {
return elemento ;
}
public Nodo <E > getSig () {
return sig ;
}
// M é todos modificadores :
public void setElemento ( E nvoElem ) {
elemento = nvoElem ;
}
public void setSig ( Nodo <E > nvoSig ) {
sig = nvoSig ;
}
}
Listado 5.4: La clase Nodo implementa un nodo genérico para una lista simple
enlazada.
5.1 Pilas
175
Clase genérica NodoStack
Una implementación de una pila, usando una lista simple enlazada, se da
en el listado 5.5. Todos los métodos de la interfaz Stack son ejecutados en
tiempo constante. Además de ser eficiente en el tiempo, esta implementación
de lista enlazada tiene un requerimiento de espacio que es O(n), donde n es
el número actual de elementos en la pila. Por lo tanto, esta implementación
no requiere que una nueva excepción sea creada para manejar los problemas
de desbordamiento de tamaño. Se usa una variable de instancia cima para
referirse a la cabeza de la lista, la cual apunta al objeto null si la lista
está vacı́a. Cuando se mete un nuevo elemento e en la pila, se crea un nuevo
nodo v para e, e es referenciado desde v, y se inserta v en la cabeza de la
lista. De igual modo, cuando se quita un elemento de la pila, se remueve el
nodo en la cabeza de la lista y se regresa el elemento. Por lo tanto, se realizan
todas las inserciones y borrados de elementos en la cabeza de la lista.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class NodoStack <E > implements Stack <E > {
protected Nodo <E > cima ; // referencia el nodo cabeza
protected int tam ;
// n ú mero de elementos en la pila
/* * Crear una pila vac ı́ a . */
public NodoStack () {
cima = null ;
tam = 0;
}
public int size () { return tam ; }
public boolean isEmpty () {
return cima == null ;
}
public void push ( E elem ) {
Nodo <E > v = new Nodo <E >( elem , cima ); // crear un nuevo nodo y enlazarlo
cima = v ;
tam ++;
}
public E top () throws E m p t y S t a c k E x c e p t i o n {
if ( isEmpty ()) throw new E m p t y S t a c k E x c e p t i o n ( " La pila est á vac ı́ a . " );
return cima . getElemento ();
}
public E pop () throws E m p t y S t a c k E x c e p t i o n {
if ( isEmpty ()) throw new E m p t y S t a c k E x c e p t i o n ( " La pila est á vac ı́ a . " );
E temp = cima . getElemento ();
cima = cima . getSig (); // desenlazar el antiguo nodo cima
tam - -;
return temp ;
}
public String toString () {
Nodo <E > cur = cima ;
String s = " [ " ;
while ( cur != null ) {
if ( cur == cima ) // primer elemento
s += cur . getElemento ();
else
176
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
Pilas y colas
s += " , " + cur . getElemento ();
cur = cur . getSig ();
}
return s + " ] " ;
}
// Imprime informaci ó n del estado de la pila y de la ú ltima operaci ó n
public static void estado ( Stack S , String op , Object elemento ) {
System . out . println ( " - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - " );
System . out . println ( op );
System . out . println ( " Regresado : " + elemento );
String estadoVacio ;
if ( S . isEmpty ())
estadoVacio = " vac ı́ o " ;
else
estadoVacio = " no vac ı́ o " ;
System . out . println ( " tam = " + S . size () + " , " + estadoVacio );
System . out . println ( " Pila : " + S );
}
/* *
* Programa de prueba que realiza una serie de operaciones en una pila
* e imprime la operaci ó n realizada , el elemento regresado
* y el contenido de la pila despu é s de cada operaci ó n .
*/
public static void main ( String [] args ) {
Object o ;
Stack < Integer > A = new NodoStack < Integer >();
estado (A , " Nueva Pila Vac ı́ a " , null );
A . push (5);
estado (A , " push (5) " , null );
A . push (3);
estado (A , " push (3) " , null );
A . push (7);
estado (A , " push (7) " , null );
o = A . pop ();
estado (A , " pop () " , o );
A . push (9);
estado (A , " push (9) " , null );
o = A . pop ();
estado (A , " pop () " , o );
o = A . top ();
estado (A , " top () " , o );
}
}
Listado 5.5: Clase NodoStack implementando la interfaz Stack usando una lista
simple enlazada con los nodos genéricos del listado 5.4.
5.1.4.
Invertir un arreglo con una pila
Se puede usar una pila para invertir los elementos en un arreglo, ası́ el
problema de la sección 3.6 se resuelve sin recursión. La idea básica es meter
todos los elementos del arreglo en orden en una pila y luego rellenar el arreglo
5.1 Pilas
177
sacando los elementos de la pila, el listado 5.6 muestra la implementación de
esta idea. En el método invertir() se muestra como se pueden usar tipos
genéricos en una aplicación simple que usa una pila genérica. En particular,
cuando los elementos son sacados de la pila en el ejemplo, estos son automáticamente regresados como elementos del tipo E; por lo tanto, estos pueden ser
inmediatamente regresados al arreglo de entrada. Se muestran dos ejemplos
que usan este método.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static <E > void invertir ( E [] a ) {
Stack <E > S = new ArregloStack <E >( a . length );
for ( int i =0; i < a . length ; i ++)
S . push ( a [ i ]);
for ( int i =0; i < a . length ; i ++)
a [ i ] = S . pop ();
}
/* * Rutina para probar la inversi ó n de arreglos */
public static void main ( String args []) {
// autoboxing : a partir de Java 1.5 , hace la conversi ó n de tipos
//
primitivos a objetos autom á ticamente y viceversa .
//
Se usa a continuaci ó n .
Integer [] a = {4 , 8 , 15 , 16 , 23 , 42};
String [] s = { " Jorge " ," Paco " ," Pedro " ," Juan " ," Marta " };
System . out . println ( " a = " + Arrays . toString ( a ));
System . out . println ( " s = " + Arrays . toString ( s ));
System . out . println ( " Invirtiendo ... " );
invertir ( a );
invertir ( s );
System . out . println ( " a = " + Arrays . toString ( a ));
System . out . println ( " s = " + Arrays . toString ( s ));
}
}
Listado 5.6: Método genérico para invertir los elementos en un arreglo genérico
mediante una pila de la interfaz Stack<E>
La salida del listado 5.6 se muestra a continuación:
a = [4, 8, 15, 16, 23, 42]
s = [Jorge, Paco, Pedro, Juan, Marta]
Invirtiendo ...
a = [42, 23, 16, 15, 8, 4]
s = [Marta, Juan, Pedro, Paco, Jorge]
5.1.5.
Aparear paréntesis y etiquetas HTML
En esta subsección, se exploran dos aplicaciones relacionadas de pilas, la
primera es para el apareamiento de paréntesis y sı́mbolos de agrupamiento en
expresiones aritméticas.
178
Pilas y colas
Las expresiones aritméticas pueden contener varios pares de sı́mbolos de
agrupamiento, tales como:
Paréntesis: “(” y “)”
Llaves: “{” y “}”
Corchetes: “[” y “]”
Sı́mbolos de la función piso: “b” y “c”
Sı́mbolos de la función techo: “d” y “e”
y cada sı́mbolo de apertura deberá aparearse con su correspondiente sı́mbolo
de cierre. Por ejemplo, un corchete izquierdo, “[”, deberá aparearse con su
correspondiente corchete derecho, “]”, como en la siguiente expresión:
[(5 + x) − (y + z)].
Los siguientes ejemplos ilustran este concepto:
Correcto: ()(()){([()])}
Correcto: ((()(()){([()])}))
Incorrecto: )(()){([()])}
Incorrecto: ({[ ])}
Incorrecto: (
Un algoritmo para el apareamiento de paréntesis
Un problema importante en el procesamiento de expresiones aritméticas es
asegurar que los sı́mbolos de agrupamiento se apareen correctamente. Se puede
usar una pila S para hacer el apareamiento de los sı́mbolos de agrupamiento
en una expresión aritmética con una sola revisión de izquierda a derecha. El
algoritmo prueba que los sı́mbolos izquierdo y derecho se apareen y también
que ambos sı́mbolos sean del mismo tipo.
Suponiendo que se da una secuencia X = x0 x1 x2 . . . xn−1 , donde cada xi es
un sı́mbolo que puede ser un sı́mbolo de agrupamiento, un nombre de variable,
un operador aritmético, o un número. La idea básica detrás de la revisión de
5.1 Pilas
179
que los sı́mbolos de agrupamiento en S empaten correctamente, es procesar los
sı́mbolos en X en orden. Cada vez que se encuentre un sı́mbolo de apertura,
se mete ese sı́mbolo en S, y cada vez que se encuentre un sı́mbolo de cierre, se
saca el sı́mbolo de la cima de la pila S, suponiendo que S no está vacı́a, y se
revisa que esos sı́mbolos sean del mismo tipo. Suponiendo que las operaciones
push y pop son implementadas para ejecutarse en tiempo constante, este
algoritmo corre en tiempo O(n), o sea lineal. Se da un pseudocódigo de este
algoritmo a continuación.
Algoritmo ApareamientoParéntesis(X, n):
Entrada: Un arreglo X de n sı́mbolos, cada uno de los cuales
es un sı́mbolo de agrupamiento, una variable, un operador
aritmético, o un número.
Salida: true si y sólo si todos los sı́mbolos de agrupamiento
en X se aparean.
Sea S una pila vacı́a
para i ← 0 hasta n − 1 hacer
si X[i] es un sı́mbolo de apertura entonces
S.push(X[i])
si no Si X[i] es sı́mbolo de cierre entonces
si S.isEmpty() entonces
regresar false { nada para aparear }
si S.pop() no empata el tipo de X[i] entonces
regresar false { tipo incorrecto }
si S.isEmpty() entonces
regresar true { cada sı́mbolo apareado }
si no
regresar false { algunos sı́mbolos no empataron }
Apareamiento de etiquetas en un documento HTML
Otra aplicación en la cual el apareamiento es importante es en la validación
de documentos HTML. HTML es el formato estándar para documentos
hiperenlazados en el Internet. En un documento HTML, porciones de texto
están delimitados por etiquetas HTML. Una etiqueta simple de apertura
HTML tiene la forma “<nombre>” y la correspondiente etiqueta de cierre tiene
la forma “</nombre>”. Las etiquetas HTML comúnmente usadas incluyen
body: cuerpo del documento
180
Pilas y colas
h1: sección cabecera
blockquote: sección sangrada
p: párrafo
ol: lista ordenada
li: elemento de la lista
Idealmente, un documento HTML deberı́a tener todas sus etiquetas apareadas, sin embargo varios navegadores toleran un cierto número de etiquetas
no apareadas.
El algoritmo de la sección para el apareamiento de paréntesis puede ser
usado para aparear las etiquetas en un documento HTML. En el listado 5.7
se da un programa en Java para aparear etiquetas en un documento HTML
leı́do de la entrada estándar. Por simplicidad, se asume que todas las etiquetas
son etiquetas simples de apertura y cierre, es decir sin atributos, y que no
hay etiquetas formadas incorrectamente. El método esHTMLApareada usa una
pila para guardar los nombres de las etiquetas
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java . io . IOException ;
import java . util . Scanner ;
/* * Prueba simplificada de apareamiento de etiquetas en un documento HTML . */
public class HTML {
/* * Quitar el primer car á cter y el ú litmo de una < etiqueta > cadena . */
public static String quitarEx tremos ( String t ) {
if ( t . length () <= 2 ) return null ; // esta es una etiqueta degenerada
return t . substring (1 , t . length () -1);
}
/* * Probar si una etiqueta desnuda ( sin los s ı́ mbolos inicial y final )
* esta vac ı́ a o es una etiqueta verdadera de apertura . */
public static boolean e s E t i q u e t a A p e r t u r a ( String etiqueta ) {
return etiqueta . length ()==0 || etiqueta . charAt (0)!= ’/ ’;
}
/* * Probar si etiqueta1 desnuda aparea con etiqueta2 de cierre . */
public static boolean seAparean ( String etiqueta1 , String etiqueta2 ) {
// probar contra el nombre despu é s de ’/ ’
return etiqueta1 . equals ( etiqueta2 . substring (1));
}
/* * Probar si cada etiqueta de apertura tiene una etiqueta de cierre . */
public static boolean esHTML Apareada ( String [] etiqueta ) {
Stack < String > S = new NodoStack < String >(); // Pila para aparear etiquetas
for ( int i =0; (i < etiqueta . length )&&( etiqueta [ i ]!= null ); i ++)
if ( e s E t i q u e t a A p e r t u r a ( etiqueta [ i ]))
S . push ( etiqueta [ i ]); // etiqueta apertura ; meterla en la pila
else {
if ( S . isEmpty ())
return false ;
// nada para aparear
5.2 Colas
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
181
if (! seAparean ( S . pop () , etiqueta [ i ]))
return false ;
// apareamiento incorrecto
}
if ( S . isEmpty ()) return true ; // se apareo todo
return false ; // hay algunas etiquetas que nunca fueron apareadas
}
public final static int CAPACIDAD = 1000; // Capacidad del arreglo etiqueta
/* Dividir un documento HTML en un arreglo de etiquetas html */
public static String [] parseHTML ( Scanner s ) {
String [] etiqueta = new String [ CAPACIDAD ]; // el arreglo etiqueta
int contador = 0;
// contador de etiquetas
String elemento ;
// elemento regresado por el scanner s
while ( s . hasNextLine ()) {
while (( elemento = s . findInLine ( " <[^ >]* > " ))!= null ) // encontrar sig etiq
etiqueta [ contador ++]= qui tarExtre mos ( elemento );
s . nextLine ();
// ir a la siguiente l ı́ nea
}
return etiqueta ;
// arreglo de etiquetas desnudas
}
public static void main ( String [] args ) throws IOException { // probador
System . out . print ( " El archivo de entrada es un documento " );
if ( esHTMLAp areada ( parseHTML ( new Scanner ( System . in ))))
System . out . println ( " HTML apareado " );
else
System . out . println ( " HTML NO apareado " );
}
}
Listado 5.7: Clase HTML para revisar el apareamiento de las etiquetas en un documento HTML.
5.2.
Colas
Otra estructura de datos fundamental es la cola (queue). Una cola es una
colección de objetos que son insertados y removidos de acuerdo al principio
primero en entrar, primero en salir, o FIFO (first-in first-out). Esto es, los
elementos pueden ser insertados en cualquier momento, pero sólo el elemento
que ha estado en la cola por más tiempo es el siguiente en ser removido.
Se dice que los elementos entran a una cola por la parte de atrás y son
removidos del frente. La metáfora para esta terminologı́a es una fila de gente
esperando para subir a un juego mecánico. La gente que espera para tal juego
entra por la parte trasera de la fila y se sube al juego desde el frente de la
lı́nea.
182
Pilas y colas
5.2.1.
Tipo de dato abstracto cola
El tipo de dato abstracto cola define una colección que guarda los objetos
en una secuencia, donde el acceso al elemento y borrado están restringidos
al primer elemento en la secuencia, el cual es llamado el frente de la cola, y
la inserción del elemento está restringida al final de la secuencia, la cual es
llamada la parte posterior de la cola. Esta restricción forza la regla de que
los elementos son insertados y borrados en una cola de acuerdo al principio
primero en entrar, primero en salir, FIFO.
El ADT cola soporta los siguientes dos métodos fundamentales:
enqueue(e):insertar el elemento e en la parte posterior de la
cola.
dequeue():quitar el elemento del frente de la cola y regresarlo;
un error ocurre si la cola está vacı́a.
Adicionalmente, de igual modo como que con el ADT pila, el ADT cola
incluye los siguientes métodos de apoyo:
size():regresar el número de elementos en la cola.
isEmpty():regresar un booleano indicando si la cola está vacı́a.
front():regresar el elemento del frente de la cola, sin removerlo; un error ocurre si la cola está vacı́a.
La siguiente tabla muestra una serie de operaciones de cola y su efecto en
una cola Q inicialmente vacı́a de objetos enteros. Por simplicidad, se usan
enteros en vez de objetos enteros como argumentos de las operaciones.
5.2 Colas
183
Operación
Salida
f rente ← Q ← cola
enqueue(5)
–
(5)
enqueue(3)
–
(5,3)
dequeue()
5
(3)
enqueue(7)
–
(3,7)
dequeue()
3
(7)
front()
7
(7)
dequeue()
7
()
dequeue() “error”
()
isEmpty()
true
()
enqueue(9)
–
(9)
enqueue(7)
–
(9,7)
size()
2
(9,7)
enqueue(3)
–
(9,7,3)
enqueue(5)
–
(9,7,3,5)
dequeue()
9
(7,3,5)
Hay varias aplicaciones posibles para las colas. Tiendas, teatros, centros
de reservación, y otros servicios similares que tı́picamente procesen peticiones
de clientes de acuerdo al principio FIFO. Una cola serı́a por lo tanto una
opción lógica para una estructura de datos que maneje el procesamiento de
transacciones para tales aplicaciones.
Interfaz java.util.Queue
El API de Java proporciona una interfaz cola, java.util.Queue, la cual
tiene funcionalidad similar al ADT cola, dado previamente, pero la documentación para esta interfaz no indica que sólo soporte el principio FIFO. Cuando
se soporta FIFO, los métodos de las dos interfaces son equivalentes.
ADT Queue Interfaz java.util.Queue
size()
size()
isEmpty()
isEmpty()
enqueue(e)
add(e) o offer(e)
dequeue()
remove() o poll()
front()
peek() o element()
Hay clases concretas del paquete java.util.concurrent de Java que
implementan la interfaz java.util.Queue con el principio FIFO como:
ArrayBlockingQueue, ConcurrentLinkedQueue y LinkedBlockingQueue
184
Pilas y colas
Interfaz cola
Una interfaz Java para el ADT cola se proporciona en el listado 5.8. Esta
interfaz genérica indica que objetos de tipo arbitrario pueden ser insertados
en la cola. Por lo tanto, no se tiene que usar conversión explı́cita cuando se
remuevan elementos.
Los métodos size() e isEmpty() tienen el mismo significado como sus
contrapartes en el ADT pila. Estos dos métodos, al igual que el método
front(), son conocidos como métodos accesores, por su valor regresado y no
cambian el contenido de la estructura de datos.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* *
* Interfaz para una cola : una colecci ó n de elementos que son insertados
* y removidos de acuerdo con el principio primero en entrar , primero en
* salir .
*
* @see E m p t y Q u e u e E x c e p t i o n
*/
public interface Queue <E > {
/* *
* Regresa el numero de elementos en la cola .
* @return n ú mero de elementos en la cola .
*/
public int size ();
/* *
* Indica si la cola est á vac ı́ a
* @return true si la cola est á vac ı́a , false de otro modo .
*/
public boolean isEmpty ();
/* *
* Regresa el elemento en el frente de la cola .
* @return elemento en el frente de la cola .
* @exception E m p t y Q u e u e E x c e p t i o n si la cola est á vac ı́ a .
*/
public E front () throws E m p t y Q u e u e E x c e p t i o n ;
/* *
* Insertar un elemento en la zaga de la cola .
* @param elemento nuevo elemento a ser insertado .
*/
public void enqueue ( E element );
/* *
* Quitar el elemento del frente de la cola .
* @return elemento quitado .
* @exception E m p t y Q u e u e E x c e p t i o n si la cola est á vac ı́ a .
*/
public E dequeue () throws E m p t y Q u e u e E x c e p t i o n ;
}
Listado 5.8: Interfaz Queue con documentación estilo Javadoc.
5.2 Colas
5.2.2.
185
Implementación simple de la cola con un arreglo
Se presenta una realización simple de una cola mediante un arreglo, Q,
de capacidad fija, guardando sus elementos. Como la regla principal con el
tipo ADT cola es que se inserten y borren los objetos de acuerdo al principio
FIFO, se debe decidir como se va a llevar el frente y la parte posterior de la
cola.
Una posibilidad es adaptar la aproximación empleada para la implementación de la pila, dejando que Q[0] sea el frente de la cola y permitiendo que
la cola crezca desde allı́. Sin embargo, esta no es una solución eficiente, para
esta se requiere que se muevan todos los elementos hacia adelante una celda
cada vez que se haga la operación dequeue. Tal implementación podrı́a tomar
por lo tanto tiempo O(n) para realizar el método dequeue(), donde n es el
número actual de objetos en la cola. Si se quiere lograr tiempo constante para
cada método de la cola, se necesita una aproximación diferente.
Usar arreglo en forma circular
Para evitar mover objetos una vez que son colocados en Q, se definen dos
variables f y r, que tienen los siguientes usos:
f es un ı́ndice a la celda de Q guardando el primer elemento de la
cola, el cual es el siguiente candidato a ser eliminado por una operación
dequeue, a menos que la cola esté vacı́a, en tal caso f = r.
r es un ı́ndice a la siguiente celda del arreglo disponible en Q.
Inicialmente, se asigna f = r = 0, lo cual indica que la cola está vacı́a.
Cuando se quita un elemento del frente de la cola, se incrementa f para indicar
la siguiente celda. Igualmente, cuando se agrega un elemento, se guarda este
en la celda Q[r] y se incrementa r para indizar la siguiente celda disponible en
Q. Este esquema permite implementar los métodos front(), enqueue(), y
dequeue() en tiempo constante, esto es, tiempo O(1). Sin embargo, todavı́a
hay un problema con esta aproximación.
Considerar, por ejemplo, que sucede si repetidamente se agrega y se quita
un solo elemento N veces. Se tendrı́a f = r = N . Si se intentará entonces
insertar el elemento una vez más, se podrı́a obtener un error de arreglo fuera
de lı́mites, ya que las N localidades válidas en Q son de Q[0] a Q[N − 1],
aunque hay espacio en la cola para este caso. Para evitar este problema y
186
Pilas y colas
poder utilizar todo el arreglo Q, se permite que los ı́ndices reinicien al final
de Q. Esto es, se ve ahora a Q como un “arreglo circular” que va de Q[0] a
Q[N − 1] y entonces inmediatamente regresan a Q[0] otra vez, ver figura 5.2.
Figura 5.2: Usando arreglo Q en un modo circular: (a) la configuración “normal”
con f ≤ r; (b) la configuración “envuelto alrededor de” con r < f
Implementar esta vista circular de Q es fácil. Cada vez que se incremente
f o r, se calcula este incremento como “(f + 1) mód N ” o “(r + 1) mód N ”,
respectivamente.
El operador “mód” es el operador módulo, el cual es calculado tomando
el residuo después de la división entera. Por ejemplo, 14 dividido por 4 es 3
con residuo 2, ası́ 14 mód 4 = 2. Especı́ficamente, dados enteros x y y tal
que x ≥ 0 y y ≥ 0, se tiene x mód y = x − bx/ycy. Esto es, si r = x mód y,
entonces hay un entero no negativo q, tal que x = qy + r. Java usa “ %” para
denotar el operador módulo. Mediante el uso del operador módulo, se puede
ver a Q como un arreglo circular e implementar cada método de la cola en
una cantidad constante de tiempo, es decir, tiempo O(1). Se describe como
usar esta aproximación para implementar una cola enseguida.
Algoritmo size():
return (N − f + r) mód N
Algoritmo isEmpty():
return (f = r)
Algoritmo front():
si isEmpty() entonces
lanzar una EmptyQueueException
return Q[f ]
5.2 Colas
187
Algoritmo enqueue(e):
si size()= N − 1 entonces
lanzar una FullQueueException
Q[r] ← e
r ← (r + 1) mód N
Algoritmo dequeue():
si isEmpty() entonces
lanzar una EmptyQueueException
e ← Q[f ]
Q[f ] ← null
f ← (f + 1) mód N
return e
La implementación anterior contiene un detalle importante, la cual podrı́a
ser ignorada al principio. Considerar la situación que ocurre si se agregan N
objetos en Q sin quitar ninguno de ellos. Se tendrı́a f = r, la cual es la misma
condición que ocurre cuando la cola está vacı́a. Por lo tanto, no se podrı́a
decir la diferencia entre una cola llena y una vacı́a en este caso. Existen varias
formas de manejarlo.
La solución que se describe es considerando que Q no puede tener más de
N − 1 objetos. Esta regla simple para manejar una cola llena se considera
en el algoritmo enqueue dado previamente. Para señalar que no se pueden
insertar más elementos en la cola, se emplea una excepción especı́fica, llamada
FullQueueException. Para determinar el tamaño de la cola se hace con
la expresión (N − f + r) mód N , la cual da el resultado correcto en una
configuración “normal”, cuando f ≤ r, y en la configuración “envuelta”
cuando r < f . La implementación Java de una cola por medio de un arreglo
es similar a la de la pila, y se deja como ejercicio.
Al igual que con la implementación de la pila con arreglo, la única desventaja de la implementación de la cola con arreglo es que artificialmente se pone
la capacidad de la cola a algún valor fijo. En una aplicación real, se podrı́a
actualmente necesitar más o menos capacidad que esta, pero si se tiene una
buena estimación de la capacidad, entonces la implementación con arreglo es
bastante eficiente.
La siguiente tabla muestra los tiempos de ejecución de los métodos de
una cola empleando un arreglo. Cada uno de los métodos en la realización
arreglo ejecuta un número constante de sentencias involucrando operaciones
aritméticas, comparaciones, y asignaciones. Por lo tanto, cada método en esta
188
Pilas y colas
implementación se ejecuta en tiempo O(1).
Método Tiempo
size
O(1)
isEmpty
O(1)
front
O(1)
enqueue
O(1)
dequeue
O(1)
5.2.3.
Implementación con una lista enlazada
Se puede implementar eficientemente el ADT cola usando una lista genérica
simple ligada. Por razones de eficiencia, se escoge el frente de la cola para que
esté en la cabeza de la lista, y la parte posterior de la cola para que esté en
la cola de la lista. De esta forma se quita de la cabeza y se inserta en la cola.
Para esto se necesitan mantener referencias a los nodos cabeza y cola de la
lista. Se presenta en el listado 5.9, una implementación del método queue()
de la cola, y en el listado 5.10 del método dequeue().
1
2
3
4
5
6
7
8
9
10
11
public void enqueue ( E elem ) {
Nodo <E > nodo = new Nodo <E >();
nodo . setElemento ( elem );
nodo . setSig ( null ); // nodo ser á el nuevo nodo cola
if ( tam == 0)
cabeza = nodo ; // caso especial de una cola previamente vac ı́ a
else
cola . setSig ( nodo ); // agregar nodo en la cola de la lista
cola = nodo ; // actualizar la referencia al nodo cola
tam ++;
}
Listado 5.9: Implementación del método enqueue() del ADT cola usando de una
lista simple ligada con nodos de la clase Nodo.
1
2
3
4
5
6
7
8
9
10
public E dequeue () throws E m p t y Q u e u e E x c e p t i o n {
if ( tam == 0)
throw new E m p t y Q u eu e E x c e p t i o n ( " La cola est á vac ı́ a . " );
E e = cabeza . getElemento ();
cabeza = cabeza . getSig ();
tam - -;
if ( tam == 0)
cola = null ; // la cola est á ahora vac ı́ a
return e ;
}
Listado 5.10: Implementación del método dequeue() del ADT cola usando una
lista simple ligada con nodos de la clase Nodo.
5.2 Colas
189
Cada uno de los métodos de la implementación del ADT cola, con una
implementación de lista ligada simple, corren en tiempo O(1). Se evita la
necesidad de indicar un tamaño máximo para la cola, como se hizo en la
implementación de la cola basada en un arreglo, pero este beneficio viene a
expensas de incrementar la cantidad de espacio usado por elemento. A pesar
de todo, los métodos en la implementación de la cola con la lista ligada son
más complicados que lo que se desearı́a, se debe tener cuidado especial de el
manejo de los casos especiales, como cuando la cola está vacı́a y se hace la
operación enqueue, o cuando la cola se vacı́a por la operación dequeue.
5.2.4.
Planificador Round Robin
Un uso popular de la estructura de datos cola es para implementar un planificador round robin, donde se itera a través de una colección de elementos
en forma circular y se “atiende” cada elemento mediante la realización de
una acción dada sobre este. Dicha pauta es usada, por ejemplo, para asignar
equitativamente un recurso que deberá ser compartido por una colección
de clientes. Por ejemplo, se puede usar un planificador round robin para
asignar una parte del tiempo del CPU a varias aplicaciones ejecutándose
concurrentemente en una computadora.
Se puede implementar un planificador round robin usando una cola, Q,
mediante la repetición de los siguientes pasos:
1. e ← Q.dequeue()
2. Servir elemento e
3. Q.enqueue(e)
El problema de Josefo
En el juego de niños de la “Papa Caliente”, un grupo de n niños sentados
en cı́rculo se pasan un objeto, llamado la “papa” alrededor del cı́rculo. El
juego se inicia con el niño que tenga la papa pasándola al siguiente en el
cı́rculo, y estos continúan pasando la papa hasta que un coordinador suena
una campana, en donde el niño que tenga la papa deberá dejar el juego y pasar
la papa al siguiente niño en el cı́rculo. Después de que el niño seleccionado
deja el juego, los otros niños estrechan el cı́rculo. Este proceso es entonces
continuado hasta que quede solamente un niño, que es declarado el ganador.
190
Pilas y colas
Si el coordinador siempre usa la estrategia de sonar la campana después de
que la papa ha pasado k veces, para algún valor fijo k, entonces determinar el
ganador para una lista de niños es conocido como el problema de Josefo.
Solución del problema de Josefo con una cola
Se puede resolver el problema de Josefo para una colección de n elementos
usando una cola, mediante la asociación de la papa con el elemento en el
frente de la cola y guardando elementos en la cola de acuerdo a su orden
alrededor del cı́rculo. Por lo tanto, pasar la papa es equivalente a retirar un
elemento de la cola e inmediatamente agregarlo otra vez. Después de que
este proceso se ha realizado k veces, se quita el elemento del frente usando
dequeue y descartándolo. Se muestra en el listado 5.11, un programa Java
completo para resolver el problema de Josefo, el cual describe una solución
que se ejecuta en tiempo O(nk).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Josefo {
/* * Soluci ó n del problema de Josefo usando una cola . */
public static <E > E Josefo ( Queue <E > Q , int k ) {
if ( Q . isEmpty ()) return null ;
while ( Q . size () > 1 ) {
System . out . println ( " Cola : " + Q + " k = " + k );
for ( int i =0; i < k ; i ++)
Q . enqueue ( Q . dequeue ()); // mover el elemento del frente al final
E e = Q . dequeue (); // quitar el elemento del frente de la coleccci ó n
System . out . println ( " \ t " + e + " est á eliminad@ " );
}
return Q . dequeue ();
// el ganador
}
/* * Construir una cola desde un arreglo de objetos */
public static <E > Queue <E > construirCola ( E a []) {
Queue <E > Q = new NodoQueue <E >();
for ( E e : a )
Q . enqueue ( e );
return Q ;
}
/* * M é todo probador */
public static void main ( String [] args ) {
String [] a1 = { " Jorge " ," Pedro " ," Carlos " };
String [] a2 = { " Gabi " ," To ~
n o " ," Paco " ," Mari " ," Iv á n " ," Alex " ," N é stor " };
String [] a3 = { " Rigo " ," Edgar " ," Karina " ," Edgar " ," Temo " ," H é ctor " };
System . out . println ( " Primer ganador es " + Josefo ( construirCola ( a1 ) ,7));
System . out . println ( " Segundo ganador es " + Josefo ( construirCola ( a2 ) ,10));
System . out . println ( " Tercer ganador es " + Josefo ( construirCola ( a3 ) ,2));
}
}
Listado 5.11: Un programa Java completo para resolver el problema de Josefo
usando una cola. La clase NodoQueue es mostrada en los listados 5.9 y 5.10.
5.3 Colas con doble terminación
5.3.
191
Colas con doble terminación
Considerar una estructura de datos parecida a una cola que soporte
inserción y borrado tanto en el frente y la parte posterior de la cola. Tal
extensión de una cola es llamada una cola con doble terminación o en
inglés double-ended queue, o deque, está última se pronuncia ‘dek’, para
evitar confusión con el método dequeue() de un ADT cola regular, que se
pronuncia como ‘dikiu’
5.3.1.
Tipo de dato abstracto deque
El tipo de dato abstracto es más completo que los ADT pila y cola. Los
métodos fundamentales del ADT deque son los siguientes:
addFirst(e):inserta un nuevo elemento e al inicio de la deque.
addLast(e):inserta un nuevo elemento e al final de la deque.
removeFirst():retira y regresa el primer elemento de la deque; un
error ocurre si la deque está vacı́a.
removeLast():retira y regresa el último elemento de la deque; un
error ocurre si la deque está vacı́a.
Adicionalmente, el ADT deque podrı́a también incluir los siguientes métodos de apoyo:
getFirst():regresa el primer elemento de la deque; un error
ocurre si la deque está vacı́a.
getLast():regresa el último elemento de la deque; un error
ocurre si la deque está vacı́a.
size():regresa el número de elementos de la deque.
isEmpty():determina si la deque está vacı́a.
La siguiente tabla muestra una serie de operaciones y sus efectos sobre
una deque D inicialmente vacı́a de objetos enteros. Por simplicidad, se usan
enteros en vez de objetos enteros como argumentos de las operaciones.
192
Pilas y colas
Operación
Salida
D
addFirst(3)
–
(3)
addFirst(5)
–
(5,3)
removeFirst()
5
(3)
addLast(7)
–
(3,7)
removeFirst()
3
(7)
removeLast()
7
()
removeFirst() “error”
()
isEmpty()
true
()
5.3.2.
Implementación de una deque
Como la deque requiere inserción y borrado en ambos extremos de una
lista, usando una lista simple ligada para implementar una deque serı́a ineficiente. Sin embargo, se puede usar una LDE, para implementar una deque
eficientemente. La inserción y remoción de elementos en cualquier extremo de
una LDE es directa para hacerse en tiempo O(1), si se usan nodos centinelas
para la cabeza y la cola.
Para una inserción de un nuevo elemento e, se puede tener acceso al nodo
p que estará antes de e y al nodo q que estará después del nodo e. Para
insertar un nuevo elemento entre los nodos p y q, cualquiera o ambos de estos
podrı́an ser centinelas, se crea un nuevo nodo t, haciendo que los enlaces prev
y sig de t hagan referencia a p y q respectivamente.
De igual forma, para quitar un elemento guardado en un nodo t, se puede
acceder a los nodos p y q que están a los lados de t, y estos nodos deberán
existir, ya que se usan centinelas. Para quitar el nodo t entre los nodos p y q,
se tiene que hacer que p y q se apunten entre ellos en vez de t. No se requiere
cambiar ninguno de los campos en t, por ahora t puede ser reclamado por el
colector de basura, ya que no hay nadie que esté apuntando a t.
Todos los métodos del ADT deque, como se describen previamente, están
incluidos en la clase java.util.LinkedList<E>. Por lo tanto, si se necesita
usar una deque y no se desea implementarla desde cero, se puede usar la clase
anterior.
De cualquier forma, se muestra la interfaz Deque en el listado 5.12 y una
implementación de esta interfaz en el listado 5.13.
5.3 Colas con doble terminación
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
193
/* *
* Interfaz para una deque : una colecci ó n de objetos que pueden ser
* insertados y quitados en ambos extremos ; un subconjunto de los
* m é todos de java . util . LinkedList .
*
*/
public interface Deque <E > {
/* *
* Regresa el n ú mero de elementos en la deque .
*/
public int size ();
/* *
* Determina si la deque est á vac ı́ a .
*/
public boolean isEmpty ();
/* *
* Regresa el primer elemento ; una excepci ó n es lanzada si la deque
* est á vac ı́ a .
*/
public E getFirst () throws E m p t y D e q u e E x c e p t i o n ;
/* *
* Regresa el ú ltimo elemento ; una excepci ó n es lanzada si la deque
* est á vac ı́ a .
*/
public E getLast () throws E m p t y D e q u e E x c e p t i o n ;
/* *
* Insertar un elemento para que sea el primero en la deque .
*/
public void addFirst ( E elemento );
/* *
* Insertar un elemento para que sea el ú ltimo en la deque .
*/
public void addLast ( E elemento );
/* *
* Quita el primer elemento ; una excepci ó n es lanzada si la deque
* est á vac ı́ a .
*/
public E removeFirst () throws E m p t y D e q u e E x c e p t i o n ;
/* *
* Quita el ú ltimo elemento ; una excepci ó n es lanzada si la deque
* est á vac ı́ a .
*/
public E removeLast () throws E m p t y D e q u e E x c e p t i o n ;
}
Listado 5.12: Interfaz Deque con documentación estilo Javadoc. Se usa el tipo
parametrizado genérico E con lo cual una deque puede contener elementos de
cualquier clase especificada.
194
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
Pilas y colas
/* *
* Implementaci ó n de la interfaz Deque mediante una lista doblemente
* enlazada . Esta clase usa la clase NodoDL , la cual implementa un nodo de
* la lista .
*/
public class NodoDeque <E > implements Deque <E > {
protected NodoDL <E > cabeza , cola ; // nodos centinelas
protected int tam ;
// cantidad de elementos
/* * Constructor sin par á metros para crear una deque vac ı́ a . */
public NodoDeque () { // inicializar una deque vac ı́ a
cabeza = new NodoDL <E >();
cola = new NodoDL <E >();
cabeza . setSig ( cola ); // hacer que cabeza apunte a cola
cola . setPrev ( cabeza ); // hacer que cola apunte a cabeza
tam = 0;
}
public int size () {
return tam ;
}
public boolean isEmpty () {
return tam == 0;
}
public E getFirst () throws E m p t y D e q u e E x c e p t i o n {
if ( isEmpty ())
throw new E m p t y D e q u e E x c e p t i o n ( " Deque est á vac ı́ a . " );
return cabeza . getSig (). getElemento ();
}
public E getLast () throws E m p t y D e q u e E x c e p t i o n {
if ( isEmpty ())
throw new E m p t y D e q u e E x c e p t i o n ( " Deque est á vac ı́ a . " );
return cola . getPrev (). getElemento ();
}
public void addFirst ( E o ) {
NodoDL <E > segundo = cabeza . getSig ();
NodoDL <E > primero = new NodoDL <E >( o , cabeza , segundo );
segundo . setPrev ( primero );
cabeza . setSig ( primero );
tam ++;
}
public void addLast ( E o ) {
NodoDL <E > penultimo = cola . getPrev ();
NodoDL <E > ultimo = new NodoDL <E >( o , penultimo , cola );
penultimo . setSig ( ultimo );
cola . setPrev ( ultimo );
tam ++;
}
public E removeFirst () throws E m p t y D e q u e E x c e p t i o n {
if ( isEmpty ())
throw new E m p t y D e q u e E x c e p t i o n ( " Deque est á vac ı́ a . " );
NodoDL <E > primero = cabeza . getSig ();
E e = primero . getElemento ();
NodoDL <E > segundo = primero . getSig ();
cabeza . setSig ( segundo );
segundo . setPrev ( cabeza );
tam - -;
return e ;
5.3 Colas con doble terminación
57
58
59
60
61
62
63
64
65
66
67
68
69
195
}
public E removeLast () throws E m p t y D e q u e E x c e p t i o n {
if ( isEmpty ())
throw new E m p t y D e q u e E x c e p t i o n ( " Deque est á vac ı́ a . " );
NodoDL <E > ultimo = cola . getPrev ();
E e = ultimo . getElemento ();
NodoDL <E > penultimo = ultimo . getPrev ();
cola . setPrev ( penultimo );
penultimo . setSig ( cola );
tam - -;
return e ;
}
}
Listado 5.13: Clase NodoDeque que implementa la interfaz Deque con la clase NodoDL
(que no se muestra). NodoDL es un nodo de una lista doblemente enlazada genérica.
196
Pilas y colas
Capı́tulo 6
Listas e Iteradores
6.1.
Lista arreglo
Supóngase que se tiene una colección S de n elementos guardados en un
cierto orden lineal, de este modo se puede referir a los elementos en S como
primero, segundo, tercero, etc. Tal colección es generalmente referida como
una lista o secuencia. Se puede referir únicamente a cada elemento e en S
usando un entero en el rango [0, n − 1], esto es igual al número de elementos
de S que preceden a e en S. El ı́ndice de un elemento e en S es el número
de elementos que están antes que e en S. Por lo tanto, el primer elemento en
S tiene ı́ndice cero y el último elemento tiene ı́ndice n − 1. También, si un
elemento de S tiene ı́ndice i, su elemento previo, si este existe, tiene ı́ndice
i − 1, y su siguiente elemento, si este existe, tiene ı́ndice i + 1. Este concepto
de ı́ndice está relacionado al de rango de un elemento en una lista, el cual es
definido para que sea uno más que su ı́ndice; por lo que el primer elemento
tiene rango uno, el segundo tiene rango dos, etc.
Una secuencia que soporte acceso a sus elementos mediante sus ı́ndices
es llamado una lista arreglo, o también con el término más viejo vector.
Como la definición de ı́ndice es más consistente con la forma como los arreglos
son ı́ndizados en Java y otros lenguajes de programación, como C y C++, se
hará referencia al lugar donde un elemento es guardado en una lista arreglo
como su “ı́ndice”, no su “rango”.
Este concepto de ı́ndice es simple pero potente, ya que puede ser usado
para indicar donde insertar un nuevo elemento en una lista o de donde quitar
un elemento viejo.
198
6.1.1.
Listas e Iteradores
El tipo de dato abstracto lista arreglo
Como un ADT, una lista arreglo S tiene los siguientes métodos, además
de los métodos estándares size() e isEmpty():
get(i):regresa el elemento de S con ı́ndice i; una condición
de error ocurre si i < 0 ó i >size()−1.
set(i, e):reemplaza con e y regresa el elemento en el ı́ndice
i; una condición de error ocurre si i < 0 ó i >
size() − 1.
add(i, e):inserta un nuevo elemento e en S para tener ı́ndice
i; una condición de error ocurre si i < 0 ó i >
size().
remove(i):quita de S el elemento en el ı́ndice i; una condición
de error ocurre si i < 0 ó i > size() − 1.
No se insiste en que un arreglo deberı́a ser usado para implementar una
lista arreglo, ası́ el elemento en el ı́ndice 0 está guardado en el ı́ndice 0 en el
arreglo, aunque esta es una posibilidad muy natural. La definición de ı́ndice
ofrece una forma para referirse al “lugar” donde un elemento está guardado en
una secuencia sin tener que preocuparse acerca de la implementación exacta
de esa secuencia. El ı́ndice de un elemento podrı́a cambiar cuando la secuencia
sea actualizada, como se muestra en el siguiente ejemplo.
Operación
Salida
S
add(0,7)
–
(7)
add(0,4)
–
(4,7)
get(1)
7
(4,7)
add(2,2)
–
(4,7,2)
get(3)
“error”
(4,7,2)
remove(1)
7
(4,2)
add(1,5)
–
(4,5,2)
add(1,3)
–
(4,3,5,2)
add(4,9)
–
(4,3,5,2,9)
get(2)
5
(4,3,5,2,9)
set(3,8)
2
(4,3,5,8,9)
6.1 Lista arreglo
6.1.2.
199
Patrón de diseño adaptador
Las clases son frecuentemente escritas para dar funcionalidad similar a
otras clases. El patrón de diseño adaptador se aplica a cualquier contexto
donde se quiere modificar una clase existente para que sus métodos empaten
con los de una clase o una interfaz relacionada diferente. Una forma general
para aplicar el patrón adaptador es definir la nueva clase de tal forma que esta
contenga una instancia de la clase vieja como un campo oculto, e implementar
cada método de la nueva clase usando métodos de esta variable de instancia
oculta. El resultado de aplicar el patrón adaptador sirve para que una nueva
clase realice casi las mismas funciones que alguna clase previa, pero de una
forma más conveniente.
Con respecto al ADT lista arreglo, se observa que esta es suficiente para
definir una clase adaptadora para el ADT deque, como se ve en la tabla 6.1:
Método Deque
Realización con Métodos
de Lista Arreglo
size(), isEmpty() size(), isEmpty()
getFirst()
get(0)
getLast()
get(size()-1)
addFirst(e)
add(0,e)
addLast(e)
add(size(),e)
removeFirst()
remove(0)
removeLast()
remove(size()-1)
Cuadro 6.1: Realización de una deque por medio de una lista arreglo.
6.1.3.
Implementación simple con un arreglo
Una opción obvia para implementar el ADT lista arreglo es usar un arreglo
A, donde A[i] guarda una referencia al elemento con ı́ndice i. Se escoge el
tamaño N del arreglo A lo suficientemente grande, y se mantiene el número
de elementos en una variable de instancia, n < N .
Los detalles de esta implementación del ADT lista arreglo son simples.
Para implementar la operación get(i), por ejemplo, sólo se regresa A[i]. La
implementación de los métodos add(i, e) y remove(i) son dados a continuación. Una parte importante y consumidora de tiempo de esta implementación
200
Listas e Iteradores
involucra el desplazamiento de elementos hacia adelante y hacia atrás para
mantener las celdas ocupadas en el arreglo de forma continua. Estas operaciones de desplazamiento son requeridas para mantener la regla de guardar
siempre un elemento cuyo ı́ndice en la lista es i en el ı́ndice i del arreglo A,
ver figura 6.1.
Algoritmo add(i, e):
para j ← n − 1, n − 2, . . . , i hacer
A[j + 1] ← A[j]
{hacer espacio para el nuevo elemento}
A[i] ← e
n←n+1
Algoritmo remove(i):
e ← A[i] { e es una variable temporal }
para j ← i, i + 1, . . . , n − 2 hacer
A[j] ← A[j + 1]
{llenar el espacio del elemento quitado}
n←n−1
regresar e
Figura 6.1: Implementación basada en un arreglo de una lista arreglo S que
está guardando n elementos: (a) desplazando hacia adelante para una inserción en
el ı́ndice i; (b) desplazamiento hacia atrás para un borrado en el ı́ndice i.
6.1 Lista arreglo
201
Rendimiento de una implementación simple con un arreglo
El cuadro 6.2 muestra los tiempos de ejecución en el peor caso de un
arreglo lista con n elementos realizados por medio de un arreglo. Los métodos
isEmpty(), size(), get() y set() se ejecutan en tiempo O(1), pero los
métodos de inserción y borrado pueden tomar más tiempo. En particular,
add(i, e) se ejecuta en tiempo O(n). En efecto, el peor caso para esta operación
ocurre cuando i es cero, ya que todos los n elementos existentes tienen que
ser desplazados hacia adelante. Un argumento similar se aplica al método
remove(i), el cual corre en tiempo O(n), porque se tiene que desplazar hacia
atrás n − 1 elementos en el peor caso cuando i es igual a cero. De hecho,
suponiendo que cada ı́ndice posible es igualmente probable de que sea pasado
como un argumento a estas operaciones, su tiempo promedio es O(n), ya que
se tienen que desplazar n/2 elementos en promedio.
Método
Tiempo
size()
O(1)
isEmpty()
O(1)
get(i)
O(1)
set(i, e)
O(1)
add(i, e)
O(n)
remove(i)
O(n)
Cuadro 6.2: Rendimiento de un lista arreglo con n elementos usando un arreglo. El
espacio usado es O(N ), donde N es el tamaño del arreglo.
Para add(i, e) y remove(i) se observa que el tiempo en cada ejecución es
O(n − i + 1), ya que sólo aquellos elementos con ı́ndice igual o mayor a i tienen
que ser desplazadas hacia atrás o adelante. Ası́, insertar y quitar un elemento
al final de una lista arreglo, usando los métodos add(n, e) y remove(n − 1),
respectivamente toman tiempo O(1) cada uno. Por otra parte, esta observación
tiene una consecuencia interesante para la adaptación del ADT lista arreglo
al ADT deque, dado en la sección 5.12, si el ADT lista arreglo en este caso es
implementado por medio de un arreglo como se describe previamente, entonces
los métodos addLast y removeLast del deque se ejecutan en tiempo O(1).
Sin embargo, los métodos addFirst y removeFirst del deque se ejecutan en
tiempo O(n).
202
Listas e Iteradores
Actualmente, con un poco de esfuerzo, se puede generar una implementación basada en un arreglo para que el ADT lista arreglo logre tiempo O(1)
para inserciones y borrados en el ı́ndice 0, al igual que inserciones y borrados
al final de la lista arreglo. Para lograrlo se requiere que no se dé la regla de
que un elemento con ı́ndice i es guardado en el arreglo en el ı́ndice i, se ocupa
en este caso usar una aproximación de arreglo circular como la que fue usada
en la sección 5.2 para implementar una cola.
6.1.4.
Interfaz ListaIndice y la clase java.util.ArrayList
Para construir una implementación Java del ADT lista arreglo, se muestra, en el listado 6.1 la interfaz ListaIndice, la cual captura los métodos principales del ADT lista arreglo. En este caso, se usa la excepción
IndexOutOfBoundsException para indicar un argumento con ı́ndice inválido.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* *
* Una interfaz para listas arreglo .
*/
public interface ListaIndice <E > {
/* * Regresa el n ú mero de elementos en esta lista . */
public int size ();
/* * Prueba si la lista vac ı́ a . */
public boolean isEmpty ();
/* * Insertar un elemento e para estar en el ı́ ndice i ,
* desplazando todos los elementos despu é s de este . */
public void add ( int i , E e )
throws I n d e x O u t O f B o u n d s E x c e p t i o n ;
/* * Regresar el elemento en el ı́ ndice i , sin quitarlo . */
public E get ( int i )
throws I n d e x O u t O f B o u n d s E x c e p t i o n ;
/* * Quita y regresa el elemento en el ı́ ndice i ,
* desplazando los elementos despu é s de este . */
public E remove ( int i )
throws I n d e x O u t O f B o u n d s E x c e p t i o n ;
/* * Reemplaza el elemento en el ı́ ndice i con e ,
* regresando el elemento previo en i . */
public E set ( int i , E e )
throws I n d e x O u t O f B o u n d s E x c e p t i o n ;
}
Listado 6.1: La interfaz ListaIndice para el ADT lista arreglo.
6.1 Lista arreglo
203
La clase java.util.ArrayList
Java proporciona la clase, java.util.ArrayList, la cual implementa
todos los métodos que se dieron previamente para el ADT lista arreglo. Esta
incluye todos los métodos dados en el listado 6.1 para la interfaz ListaIndice.
Por otra parte, la clase java.util.ArrayList tiene caracterı́sticas adicionales
a las que fueron dadas en el ADT lista arreglo simplificado. Por ejemplo, la
clase incluye el método, clear(), para remover todos los elementos de la lista
arreglo, y el método, toArray(), el cual regresa un arreglo conteniendo todos
los elementos de la lista arreglo con el mismo orden. Además, también tiene
métodos para buscar en la lista, como el método indexOf(e), el cual regresa
el ı́ndice de la primera ocurrencia de un elemento igual a e en la lista arreglo,
y el método lastIndexOf(e), el cual regresa el ı́ndice de la última ocurrencia
de un elemento igual a e en la lista arreglo. Ambos métodos regresan el valor
ı́ndice inválido −1 si no se encuentra un elemento igual a e.
6.1.5.
Lista arreglo usando un arreglo extendible
Además de implementar los métodos de la interfaz ListaIndice y algunos
otros métodos útiles, la clase java.util.ArrayList proporciona una caracterı́stica interesante que supera una debilidad en la implementación simple de
la lista arreglo.
La mayor debilidad de la implementación simple para el ADT lista arreglo
dado en la sección 6.1.3, es que esta requiere una especificación avanzada de la
capacidad fija, N , para el número total de elementos que podrı́an ser guardados
en la lista arreglo. Si el número actual de elementos, n, de la lista arreglo es
mucho menor que N , entonces la implementación desperdiciará espacio. Peor
aún, si n se incrementa y rebasa a N , entonces esta implementación fallará.
Para evitar lo anterior, java.util.ArrayList usa una técnica interesante
de arreglo extendible con lo que no habrá preocupación de que el arreglo sea
desbordado cuando se usa esta clase.
Al igual que la clase java.util.ArrayList, se proporciona un medio
para crecer el arreglo A que guarda los elementos de una lista arreglo S. Por
supuesto, en Java y en otros lenguajes de programación, no se puede crecer
el arreglo A; su capacidad está fija a algún número N , como ya se ha visto.
Cuando un desbordamiento ocurre, es decir, n = N y se hace una llamada al
método add, se realizan los siguientes pasos adicionales.
204
Listas e Iteradores
1. Reservar un nuevo arreglo B de capacidad 2N .
2. Hacer B[i] ← A[i], para i = 0, . . . , N − 1.
3. Hacer A ← B, esto es, se usa B como el arreglo que soporta a S.
4. Insertar el nuevo elemento en A.
Esta estrategia de reemplazo de arreglo es conocida como arreglo extendible, y puede ser vista como extender la terminación del arreglo subyacente
para hacerle espacio para más elementos, ver figura 6.2. Intuitivamente, este
estrategia es parecida a la del cangrejo ermitaño, el cual se cambia a un
caracol más grande cuando sobrepasa al anterior.
Figura 6.2: Los tres pasos para “crecer” un arreglo extendible: (a) crea un nuevo
arreglo B; (b) copiar los elementos de A a B; (c) reasignar referencia A al nuevo
arreglo
Implementación de ListaIndice con un arreglo extendible
Se dan porciones de una implementación Java del ADT lista arreglo usando
un arreglo extendible en el código 6.2. Esta clase sólo proporciona los medios
para crecer. Un ejercicio es la implementación para poder reducir.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* * Realizaci ó n de una lista indizada por medio de un arreglo , el cual es
* doblado cuando el tama ~
n o de la lista excede la capacidad del arreglo .
*/
public class ArregloListaIndice <E > implements ListaIndice <E > {
private E [] A ; // arreglo almacenando los elementos de la lista indizada
private int capacidad = 16; // tama ~
n o inicial del arreglo A
private int tam = 0; // n ú mero de elementos guardados en la lista
/* * Crear la lista indizada con capacidad inicial de 16. */
public A r r e g l o L i s t a I n d i c e () {
A = ( E []) new Object [ capacidad ]; // compilador podr ı́ a advertir
}
/* * Insertar un elemento en el ı́ ndice dado . */
public void add ( int r , E e )
throws I n d e x O u t O f B o u n d s E x c e p t i o n {
revisarIndice (r , size () + 1);
6.1 Lista arreglo
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
205
if ( tam == capacidad ) { // un sobreflujo
capacidad *= 2;
E [] B =( E []) new Object [ capacidad ]; // el compilador podr ı́ a advertir
for ( int i =0; i < tam ; i ++)
B [ i ] = A [ i ];
A = B;
}
for ( int i = tam -1; i >= r ; i - -) // desplazar elementos hacia adelante
A [ i +1] = A [ i ];
A[r] = e;
tam ++;
}
/* * Quitar el elemento guardado en el ı́ ndice dado . */
public E remove ( int r )
throws I n d e x O u t O f B o u n d s E x c e p t i o n {
revisarIndice (r , size ());
E temp = A [ r ];
for ( int i = r ; i < tam -1; i ++) // desplazar elementos hacia atr á s
A [ i ] = A [ i +1];
tam - -;
return temp ;
}
Listado 6.2: Parte de la clase ArregloListaIndice realizando el ADT lista arreglo
por medio de un arreglo extendible. El método revisarIndice vigila que el ı́ndice
r esté entre 0 y n − 1.
Análisis amortizado del arreglo extendible
La estrategia de reemplazo del arreglo podrı́a parecer lento a simple vista,
ya que para realizarlo se ocupa tiempo O(n). Pero se debe observar que
después de que se haga el reemplazo, en el nuevo arreglo se pueden agregar
hasta n nuevos elementos en la lista arreglo antes de que el arreglo deba ser
reemplazado nuevamente. Este hecho permite mostrar que realizando una
serie de operaciones en un lista arreglo vacı́o es actualmente muy eficiente. Se
considerará la inserción de un elemento con el ı́ndice más grande posible, es
decir, n. En la tabla 6.3 se muestra el tiempo que toma realizar la inserción
al final de la lista y el tamaño actual del arreglo. El tamaño inicial del arreglo
es uno.
Usando un patrón de diseño algorı́tmico llamado amortización, se puede
mostrar que realizando una secuencia de operaciones, como las descritas
previamente, sobre una lista arreglo implementada con un arreglo extendible
es eficiente. Para realizar un análisis amortizado, se usa una técnica de
conteo dónde se ve a la computadora como una aplicación operada con
monedas que requiere el pago de un ciber-peso para una cantidad constante de
206
Listas e Iteradores
Elementos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Tiempo
1
2
3
1
5
1
1
1
9
1
1
1
1
1
1
1
Tamaño de A
1
2
4
4
8
8
8
8
16
16
16
16
16
16
16
16
Cuadro 6.3: Tiempo de ejecución para la inserción de elementos en un arreglo con
reemplazo.
tiempo computacional. Cuando una operación es ejecutada, se deberán tener
suficientes ciber-pesos disponibles en la “cuenta bancaria” para pagar las
tiempos de ejecución de las operaciones. Ası́, la cantidad total de ciber-pesos
gastados para cualquier cómputo será proporcional al tiempo gastado en
ese cómputo. La ventaja de usar este método de análisis es que se pueden
sobrecargar algunas operaciones para ahorrar ciber-pesos para pagar otras.
Proposición 6.1: Sea S una lista arreglo implementada por medio de un
arreglo extendible con un tamaño inicial uno. El tiempo total para realizar
una serie de n inserciones al final de la lista en S, iniciando con S vacı́a es
O(n)
Justificación. Suponiendo que un ciber-peso es suficiente para pagar por la
operación de inserción al final de la lista en S, sin considerar el tiempo gastado
en crecer el arreglo. Suponer que el crecimiento del arreglo de tamaño k a
2k requiere k ciber-pesos por el tiempo gastado en copiar los elementos. Se
cargará en cada operación de inserción tres ciber-pesos. Ası́, se sobrecarga
cada operación de inserción que no causa un sobreflujo por dos ciber-pesos.
Estos dos ciber-pesos se pueden pensar como un ahorro. Un sobreflujo ocurre
cuando la lista arreglo S tiene 2i elementos, para algún i ≥ 0, y el tamaño
6.2 Listas nodo
207
del arreglo usado es 2i . Entonces, doblar el tamaño del arreglo requerirá 2i
ciber-pesos. Estos ciber-pesos se pueden tomar de los elementos guardados
desde la celda 2i−1 hasta 2i − 1 (ver figura 6.3). Observar que sobreflujo
previo ocurrió cuando la cantidad de elementos se hizo más grande que 2i−1 la
primera vez, y de esta forma los ciber-pesos ahorrados en las celdas 2i−1 hasta
2i −1 no fueron previamente gastados. Por lo tanto, se tiene un esquema válido
de amortización en el cual cada operación es cargada con tres ciber-pesos
y todos los tiempos de cómputo son pagados. Es decir, se puede pagar la
ejecución de n inserciones al final usando 3n ciber-pesos. Es decir, el tiempo
de ejecución amortizado para cada operación de inserción al final es O(1);
por lo tanto, el tiempo total de ejecución de n operaciónes de inserciones al
final es O(n).
Figura 6.3: Ilustración de una serie de operaciones de inserción al final de la lista
arreglo: (a) un arreglo de 8 celdas lleno, con dos ciber-pesos ahorrados en las celdas
del 4 al 7; (b) una operación de inserción causa un sobreflujo y un doblado de
la capacidad. Copiar los 8 viejos elementos al nuevo arreglo se ha pagado con
los ciber-pesos ahorrados. Insertar el nuevo elemento es pagado por uno de los
ciber-pesos dados, y dos ciber-pesos son ahorrados en la celda 8.
6.2.
Listas nodo
Usar un ı́ndice no es la única forma de referirse al lugar donde un elemento
aparece en una secuencia. Si se tiene una secuencia S implementada con una
lista enlazada, simple o doble, entonces podrı́a ser más natural y eficiente
208
Listas e Iteradores
usar un nodo en vez de un ı́ndice como medio de identificación para acceder
a S o para actualizar. Se define en esta sección el ADT lista nodo el cual
abstrae la estructura de datos concreta lista enlazada, secciones 3.3 y 3.4,
usando el ADT posición relacionada, el cual abstrae la noción de “lugar” en
una lista nodo.
6.2.1.
Operaciones basadas en nodos
Sea S una lista enlazada simple o doble. Se desearı́a definir métodos para
S que tomen nodos como parámetros y proporcionen nodos como tipos de
regreso. Tales métodos podrı́an dar aumentos de velocidad significativos sobre
los métodos basados en ı́ndices, porque encontrar el ı́ndice de un elemento
en una lista enlazada requiere buscar a través de la lista incrementalmente
desde el inicio o final, contando los elementos conforme se recorra.
Por ejemplo, se podrı́a definir un método hipotético remove(v) que quite
el elemento de S guardado en el nodo v de la lista. Usando un nodo como un
parámetro permite remover un elemento en tiempo O(1) yendo directamente
al lugar donde está guardado el nodo y entonces “desenlazando” este nodo
mediante una actualización a los enlaces sig y prev de sus vecinos. De igual
modo, se puede insertar, en tiempo O(1), un nuevo elemento e en S con una
operación tal como addAfter(v, e), la cual indica el nodo v después del cual
el nodo del nuevo elemento deberı́a ser insertado. En este caso se “enlaza” el
nuevo nodo.
Para abstraer y unificar las diferentes formas de guardar elementos en las
diversas implementaciones de una lista, se introduce el concepto de posición,
la cual formaliza la noción intuitiva de “lugar” de un elemento relativo a los
otros en la lista.
6.2.2.
Posiciones
A fin de ampliar de forma segura el conjunto de operaciones para listas,
se abstrae la noción de “posición” la cual permite usar la eficiencia de
la implementación de las listas simples enlazadas, o dobles, sin violar los
principios del diseño orientado a objetos. En este marco, se ve a la lista como
una colección que guarda a cada elemento en una posición y que mantiene
estas posiciones organizadas en un orden lineal. Una posición es por sı́ misma
un tipo de dato abstracto que soporta el siguiente método simple:
6.2 Listas nodo
209
elemento():regresa el elemento guardado en esta posición.
Una posición está siempre definida relativamente, esto es, en términos
de sus vecinos. En una lista, una posición p siempre estará “después” de
alguna posición q y “antes” de alguna posición s, a menos que p sea la primera
posición o la última. Una posición p, la cual está asociada con algún elemento
e en una lista S, no cambia, aún si el ı́ndice de e cambia en S, a menos que se
quite explı́citamente e, y por lo tanto, se destruya la posición p. Por otra parte,
la posición p no cambia incluso si se reemplaza o intercambia el elemento
e guardado en p con otro elemento. Estos hechos acerca de las posiciones
permiten definir un conjunto de métodos de lista basados en posiciones que
usan objetos posición como parámetros y también proporcionan objetos
posición como valores de regreso.
6.2.3.
El tipo de dato abstracto lista nodo
Usando el concepto de posición para encapsular la idea de “nodo” en
una lista, se puede definir otro tipo de ADT secuencia llamado el ADT lista
nodo. Este ADT soporta los siguientes métodos para una lista S:
first():regresa la posición del primer elemento de S; un
error ocurre si S está vacı́a.
last():regresa la posición del último elemento de S; un
error ocurre si S está vacı́a.
prev(p):regresa la posición del elemento de S precediendo
al de la posición p; un error ocurre si p está en la
primera posición.
next(p):regresa la posición del elemento de S siguiendo al
de la posición p; un error ocurre si p está en la
última posición.
Los métodos anteriores permiten referirse a posiciones relativas en una
lista, comenzando en el inicio o final, y moviéndose incrementalmente a la
izquierda o a la derecha de la lista. Estas posiciones pueden ser pensadas como
nodos en la lista pero, se debe observar que no hay referencias especı́ficas a
objetos nodos. Además, si se proporciona una posición como un argumento a
un método lista, entonces la posición deberá representar una posición válida
en esa lista.
210
Listas e Iteradores
Métodos de actualización de la lista nodo
Además de los métodos anteriores y los métodos genéricos size e isEmpty,
se incluyen también los siguientes métodos de actualización para el ADT lista
nodo, los cuales usan objetos posición como parámetros y regresan objetos
posición como valores de regreso algunos de ellos.
set(p, e):reemplaza el elemento en la posición p con e, regresando el elemento anterior en la posición p.
addFirst(e):inserta un nuevo elemento e en S como el primer
elemento.
addLast(e):inserta un nuevo elemento e en S como el último
elemento.
addBefore(p, e):inserta un nuevo elemento e en S antes de la posición p.
addAfter(p, e):inserta un nuevo elemento e en S después de la
posición p.
remove(p):quita y regresa el elemento e en la posición p en
S, invalidando esta posición en S.
El ADT lista nodo permite ver una colección ordenada de objetos en
términos de sus lugares, sin tenerse que preocupar acerca de la forma exacta
como estos lugares están representados, ver figura 6.4.
Figura 6.4: Una lista nodo. Las posiciones en el orden actual son p, q, r, y s.
Podrı́a parecer a primera vista que hay redundancia en el repertorio
anterior de operaciones para el ADT lista nodo, ya que se puede hacer la operación addFirst(e) con addBefore(first(),e), y la operación addLast(e)
con addAfter(last(),e). Pero estas sustituciones sólo pueden ser hechas
para una lista no vacı́a.
Observar que una condición de error ocurre si una posición pasada como
argumento a una de las operaciones de la lista es inválida. Las razones para
que una posición p sea inválida incluyen:
6.2 Listas nodo
211
p = null
p fue previamente borrada de la lista
p es una posición de una lista diferente
p es la primera posición de la lista y se llama prev(p)
p es la última posición de la lista y se llama next(p)
Se muestra enseguida una serie de operaciones para una lista nodo S
inicialmente vacı́a. Se usan las variables p1 , p2 , etc., para denotar diferentes
posiciones, y se muestra el objeto guardado actualmente en tal posición entre
paréntesis.
Operación
Salida
S
addFirst(8)
–
(8)
p1 ← first()
[8]
(8)
addAfter(p1 , 5)
–
(8,5)
p2 ← next(p1 )
[5]
(8,5)
addBefore(p2 , 3)
–
(8,3,5)
p3 ← prev(p2 )
[3]
(8,3,5)
addFirst(9)
–
(9,8,3,5)
p2 ← last()
[5]
(9,8,3,5)
remove(first())
9
(8,3,5)
set(p3 , 7)
3
(8,7,5)
addAfter(first(), 2)
–
(8,2,7,5)
El ADT lista nodo, con su noción incorporada de posición, es útil para
realizar ajustes. Por ejemplo, un programa que simula un juego de cartas
podrı́a simular la mano de cada persona como una lista nodo. Ya que la
mayorı́a de las personas guarda las cartas de la misma figura juntas, insertar
y sacar cartas de la mano de una persona podrı́a ser implementado usando los
métodos del ADT lista nodo, con las posiciones siendo determinadas por un
orden natural de las figuras. Igualmente, un editor de texto simple encaja la
noción de inserción posicional y remoción, ya que tales editores tı́picamente
realizan todas las actualizaciones relativas a un cursor, el cual representa la
posición actual en la lista de caracteres de texto que está siendo editada.
Una interfaz Java representando el ADT posición está dado en el listado
6.3.
212
1
2
3
4
Listas e Iteradores
public interface Posicion <E > {
/* * Regresa el elemento guardado en esta posici ó n . */
E elemento ();
}
Listado 6.3: Una interfaz Java para el ADT posición.
Una interfaz, con los métodos requeridos, para el ADT lista nodo, llamada
ListaPosicion, está dada en el listado 6.4. Esta interfaz usa las siguientes
excepciones para indicar condiciones de error.
BoundaryViolationException: lanzada si un intento es hecho al acceder
una elemento cuya posición está fuera del rango de posiciones de la
lista, por ejemplo llamando al método next en la última posición de la
secuencia.
InvalidPositionException: lanzada si una posición dada como argumento
no es válida, por ejemplo, esta es una referencia null o esta no tiene
lista asociada.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java . util . Iterator ;
/* *
* Una interfaz para listas posicionales .
*
*/
public interface ListaPosicion <E > extends Iterable <E > {
/* * Regresa un iterador de todos los elementos en la lista . */
public Iterator <E > iterator ();
/* * Regresa una colecci ó n iterable de todos los nodos en la lista . */
public Iterable < Posicion <E > > positions ();
/* * Regresa el n ú mero de elementos en esta lista . */
public int size ();
/* * Indica si la lista est á vac ı́ a . */
public boolean isEmpty ();
/* * Regresa el primer nodo en la lista . */
public Posicion <E > first ();
/* * Regresa el ú ltimo nodo en la lista . */
public Posicion <E > last ();
/* * Regresa el nodo siguiente a un nodo dado en la lista . */
public Posicion <E > next ( Posicion <E > p )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n ;
/* * Regresa el nodo anterior a un nodo dado en la lista . */
public Posicion <E > prev ( Posicion <E > p )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n ;
/* * Inserta un elemento en el frente de la lista ,
* regresando la nueva posici ó n . */
public void addFirst ( E e );
/* * Inserta un elemento en la parte de atr á s de la lista ,
6.2 Listas nodo
29
30
31
32
33
34
35
36
37
38
39
40
41
42
213
* regresando la nueva posici ó n . */
public void addLast ( E e );
/* * Inserta un elemento despu é s del nodo dado en la lista . */
public void addAfter ( Posicion <E > p , E e )
throws I n v a l i d P o s i t i o n E x c e p t i o n ;
/* * Inserta un elemento antes del nodo dado en la lista . */
public void addBefore ( Posicion <E > p , E e )
throws I n v a l i d P o s i t i o n E x c e p t i o n ;
/* * Quita un nodo de la lista , regresando el elemento guardado . */
public E remove ( Posicion <E > p ) throws I n v a l i d P o s i t i o n E x c e p t i o n ;
/* * Reemplaza el elemento guardado en el nodo dado ,
regresando el elemento viejo . */
public E set ( Posicion <E > p , E e ) throws I n v a l i d P o s i t i o n E x c e p t i o n ;
}
Listado 6.4: Interfaz para el ADT lista nodo.
Otro adaptador Deque
El ADT lista nodo es suficiente para definir una clase adaptadora para el
ADT deque, como se muestra en la tabla 6.4.
Método Deque
Realización con Métodos
de la lista nodo
size(), isEmpty() size(), isEmpty()
getFirst()
first().elemento()
getLast()
last().elemento()
addFirst(e)
addFirst(e)
addLast(e)
addLast(e)
removeFirst()
remove(first())
removeLast()
remove(last())
Cuadro 6.4: Realización de una deque por medio de una lista nodo.
6.2.4.
Implementación con lista doblemente enlazada
Si se quiere implementar el ADT lista nodo usando una lista doblemente
enlazada, sección 3.4, se debe hacer que los nodos de la lista enlazada implementen el ADT posición. Esto es, se tiene cada nodo implementando la
interfaz Posicion y por lo tanto definen un método, elemento(), el cual
regresa el elemento guardado en el nodo. Ası́, los propios nodos actúan como
214
Listas e Iteradores
posiciones. Son vistos internamente por la lista enlazada como nodos, pero
desde el exterior, son vistos solamente como posiciones. En la vista interna,
se puede dar a cada nodo v variables de instancia prev y next que respectivamente refieran a los nodos predecesor y sucesor de v, los cuales podrı́an ser
de hecho nodos centinelas, cabeza y cola, marcando el inicio o el final de la
lista. En lugar de usar las variables prev y next directamente, se definen los
métodos getPrev, setPrev, getNext, y setNext de un nodo para acceder y
modificar estas variables.
En el listado 6.5, se muestra la clase Java, NodoD, para los nodos de una
lista doblemente enlazada implementando el ADT posición. Esta clase es
similar a la clase NodoD mostrada en el listado 3.11, excepto que ahora los
nodos guardan un elemento genérico en lugar de una cadena de caracteres.
Observar que las variables de instancia prev y next en la clase NodoD son
referencias privadas a otros objetos NodoD.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class NodoD <E > implements Posicion <E > {
private NodoD <E > prev , next ; // Referencia a los nodos anterior y posterior
private E elemento ; // Elemento guardado en esta posici ó n
/* * Constructor */
public NodoD ( NodoD <E > nuevoPrev , NodoD <E > nuevoNext , E elem ) {
prev = nuevoPrev ;
next = nuevoNext ;
elemento = elem ;
}
// M é todo de la interfaz Posicion
public E elemento () throws I n v a l i d P o s i t i o n E x c e p t i o n {
if (( prev == null ) && ( next == null ))
throw new I n v a l i d P o s i t i o n E x c e p t i o n ( " ¡ La posici ó n no est á en una lista ! " );
return elemento ;
}
// M é todos accesores
public NodoD <E > getNext () { return next ; }
public NodoD <E > getPrev () { return prev ; }
// M é todos actu alizador es
public void setNext ( NodoD <E > nuevoNext ) { next = nuevoNext ; }
public void setPrev ( NodoD <E > nuevoPrev ) { prev = nuevoPrev ; }
public void setElemento ( E nuevoElemento ) { elemento = nuevoElemento ; }
}
Listado 6.5: Clase NodoD realizando un nodo de una lista doblemente enlazada
implementando la interfaz Posicion.
Dada una posición p en S, se puede “desenvolver” p para revelar el nodo
v subyacente mediante una conversión de la posición a un nodo. Una vez que
se tiene un nodo v, se puede, por ejemplo, implementar el método prev(p)
con v.getPrev, a menos que el nodo regresado por v.getPrev sea la cabeza,
6.2 Listas nodo
215
en tal caso se señala un error. Por lo tanto, posiciones en una implementación
lista doblemente enlazada se pueden soportar en una forma orientada al objeto
sin ningún gasto adicional de tiempo o espacio.
Considerar ahora como se podrı́a implementar el método addAfter(p, e),
para insertar un elemento e después de la posición p. Se crea un nuevo nodo v
para mantener el elemento e, se enlaza v en su lugar en la lista, y entonces se
actualizan las referencias next y prev de los dos vecinos de v. Este método
está dado en el siguiente pseudocódigo, y se ilustra en la figura 6.5, recordando
el uso de centinelas, observar que este algoritmo trabaja aún si p es la última
posición real.
Algoritmo addAfter(p, e):
Crear un nuevo nodo v
v.setElemento(e)
v.setPrev(p) { enlazar v a su predecesor }
v.setNext(p.getNext()) { enlazar v a su sucesor }
(p.getNext()).setPrev(v) { enlazar el viejo sucesor de p a v }
p.setNext(v) { enlazar p a su nuevo sucesor, v }
Figura 6.5: Agregar un nuevo nodo después de la posición “JFK”:(a) antes de la
inserción; (b) creación de un nodo v con el elemento “BWI” y el enlazado a la lista;
(c) después de la inserción.
Los algoritmos para los métodos addBefore, addFirst, y addLast son
similares al método addAfter. Se dejan sus detalles como un ejercicio.
216
Listas e Iteradores
Considerar, ahora, el método remove(e), el cual quita el elemento e guardado en la posición p. Para realizar esta operación, se enlazan los dos vecinos
de p para referirse entre ellos como nuevos vecinos—desenlazando p. Observar
que después de que p está desenlazado, no hay nodos apuntando a p; por lo
tanto, el recolector de basura puede reclamar el espacio de p. Este algoritmo
está dado en el siguiente pseudocódigo y se ilustra en la figura 6.6. Recordando
el uso de los centinelas cabeza y cola, este algoritmo trabaja aún si p es la
primera posición, la última, o solamente una posición real en la lista.
Algoritmo remove(p):
t ← p.elemento() { una variable temporal para guardar el valor de regreso }
(p.getPrev()).setNext(p.getNext()) { desenlazar p }
(p.getNext()).setPrev(p.getPrev())
p.setPrev(null) { invalidar la posición p }
p.setNext(null)
regresar t
Figura 6.6: Quitando el objeto guardado en la posición para “PVD”: (a) antes de
la remoción; (b) desenlazando el nodo viejo; (c) después de la remoción.
En conclusión, usando una lista doblemente enlazada, se pueden ejecutar todos los métodos del ADT lista nodo en tiempo O(1). Ası́, una lista
doblemente enlazada es una implementación eficiente del ADT lista nodo.
6.2 Listas nodo
217
Implementación de la lista nodo en Java
La clase Java ListaNodoPosicion, la cual implementa el ADT lista nodo
usando una lista doblemente enlazada, se muestra en el listado 6.6. En esta
clase se tienen declaradas variables de instancia para guardar el número
de elementos en la lista, ası́ como los centinelas especiales para manejar
más fácilmente la lista. También se tiene un constructor sin parámetros que
permite crear una lista nodo vacı́a. En esta clase además de implementar los
métodos declarados en la interfaz ListaPosicion se tienen otros métodos
de conveniencia, por ejemplo para revisar si una posición es válida, probar
si una posición es la primera o la última, intercambiar los contenidos de dos
posiciones, u obtener una representación textual de la lista.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java . util . Iterator ;
/* *
* Realizaci ó n de una ListaPosici ó n usando una lista doblemente enlazada
* de nodos .
*
*/
public class ListaNodoPosicion <E > implements ListaPosicion <E > {
protected int tam ;
// n ú mero de elementos en la lista
protected NodoD <E > cabeza , cola ; // centinelas especiales
/* * Constructor que crea una lista vac ı́ a ; tiempo O (1) */
public L i s t a N o d o P o s i c i o n () {
tam = 0;
cabeza = new NodoD <E >( null , null , null ); // crea cabeza
cola = new NodoD <E >( cabeza , null , null ); // crea cola
cabeza . setNext ( cola ); // hacer que cabeza apunte a cola
}
/* * Regresa el n ú mero de elementos en la lista ; tiempo O (1) */
public int size () { return tam ; }
/* * Valida si la lista est á vac ı́ a ; tiempo O (1) */
public boolean isEmpty () { return ( tam == 0); }
/* * Regresa la primera posici ó n en la lista ; tiempo O (1) */
public Posicion <E > first ()
throws E m p t y L i s t E x c e p t i o n {
if ( isEmpty ())
throw new E m p t y L i s t E x c e p t i on ( " La lista est á vac ı́ a " );
return cabeza . getNext ();
}
/* * Regresa la ú ltima posici ó n en la lista ; tiempo O (1) */
public Posicion <E > last ()
throws E m p t y L i s t E x c e p t i o n {
if ( isEmpty ())
throw new E m p t y L i s t E x c e p t i on ( " La lista est á vac ı́ a " );
return cola . getPrev ();
}
/* * Regresa la posici ó n anterior de la dada ; tiempo O (1) */
public Posicion <E > prev ( Posicion <E > p )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n {
NodoD <E > v = revis aPosicio n ( p );
NodoD <E > prev = v . getPrev ();
218
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
Listas e Iteradores
if ( prev == cabeza )
throw new B o u n d a r y V i o l a t i o n E x c e p t i o n
( " No se puede avanzar m á s all á del inicio de la lista " );
return prev ;
}
/* * Regresa la posici ó n despu é s de la dada ; tiempo O (1) */
public Posicion <E > next ( Posicion <E > p )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n {
NodoD <E > v = revis aPosicio n ( p );
NodoD <E > next = v . getNext ();
if ( next == cola )
throw new B o u n d a r y V i o l a t i o n E x c e p t i o n
( " No se puede avanzar m á s all á del final de la lista " );
return next ;
}
/* * Insertar el elemento dado antes de la posici ó n dada : tiempo O (1) */
public void addBefore ( Posicion <E > p , E elemento )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
NodoD <E > v = revis aPosicio n ( p );
tam ++;
NodoD <E > nodoNuevo = new NodoD <E >( v . getPrev () , v , elemento );
v . getPrev (). setNext ( nodoNuevo );
v . setPrev ( nodoNuevo );
}
/* * Insertar el elemento dado despu é s de la posici ó n dada : tiempo O (1) */
public void addAfter ( Posicion <E > p , E elemento )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
NodoD <E > v = revis aPosicio n ( p );
tam ++;
NodoD <E > nodoNuevo = new NodoD <E >( v , v . getNext () , elemento );
v . getNext (). setPrev ( nodoNuevo );
v . setNext ( nodoNuevo );
}
/* * Insertar el elemento dado al inicio de la lista , regresando
* la nueva posici ó n ; tiempo O (1) */
public void addFirst ( E elemento ) {
tam ++;
NodoD <E > nodoNuevo = new NodoD <E >( cabeza , cabeza . getNext () , elemento );
cabeza . getNext (). setPrev ( nodoNuevo );
cabeza . setNext ( nodoNuevo );
}
/* * Insertar el elemento dado al final de la lista , regresando
* la nueva posici ó n ; tiempo O (1) */
public void addLast ( E elemento ) {
tam ++;
NodoD <E > ultAnterior = cola . getPrev ();
NodoD <E > nodoNuevo = new NodoD <E >( ultAnterior , cola , elemento );
ultAnterior . setNext ( nodoNuevo );
cola . setPrev ( nodoNuevo );
}
/* * Quitar la posici ó n dada de la lista ; tiempo O (1) */
public E remove ( Posicion <E > p )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
NodoD <E > v = revis aPosicio n ( p );
tam - -;
NodoD <E > vPrev = v . getPrev ();
NodoD <E > vNext = v . getNext ();
6.2 Listas nodo
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
219
vPrev . setNext ( vNext );
vNext . setPrev ( vPrev );
E vElem = v . elemento ();
// desenlazar la posici ó n de la lista y hacerlo inv á lido
v . setNext ( null );
v . setPrev ( null );
return vElem ;
}
/* * Reemplaza el elemento en la posici ó n dada con el nuevo elemento
* y regresar el viejo elemento ; tiempo O (1) */
public E set ( Posicion <E > p , E elemento )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
NodoD <E > v = revis aPosicio n ( p );
E elemVjo = v . elemento ();
v . setElemento ( elemento );
return elemVjo ;
}
/* * Regresa un iterador con todos los elementos en la lista . */
public Iterator <E > iterator () { return new ElementoIterador <E >( this ); }
/* * Regresa una colecci ó n iterable de todos los nodos en la lista . */
public Iterable < Posicion <E > > positions () { // crea una lista de posiciones
ListaPosicion < Posicion <E > > P = new ListaNodoPosicion < Posicion <E > >();
if (! isEmpty ()) {
Posicion <E > p = first ();
while ( true ) {
P . addLast ( p ); // agregar posici ó n p como el ú lt . elemento de la lista P
if ( p == last ())
break ;
p = next ( p );
}
}
return P ; // regresar P como el objeto iterable
}
// M é todos de conveniencia
/* * Revisa si la posici ó n es v á lida para esta lista y la convierte a
* NodoD si esta es v á lida : tiempo O (1) */
protected NodoD <E > rev isaPosic ion ( Posicion <E > p )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
if ( p == null )
throw new I n v a l i d P o s i t i o n E x c e p t i o n
( " Posici ó n Null pasada a ListaNodo " );
if ( p == cabeza )
throw new I n v a l i d P o s i t i o n E x c e p t i o n
( " El nodo cabeza no es una posici ó n v á lida " );
if ( p == cola )
throw new I n v a l i d P o s i t i o n E x c e p t i o n
( " El nodo cola no es una posici ó n v á lida " );
try {
NodoD <E > temp = ( NodoD <E >) p ;
if (( temp . getPrev () == null ) || ( temp . getNext () == null ))
throw new I n v a l i d P o s i t i o n E x c e p t i o n
( " Posici ó n no pertenece a una ListaNodo v á lida " );
return temp ;
} catch ( C l a s s C a s t E x c e p t i o n e ) {
throw new I n v a l i d P o s i t i o n E x c e p t i o n
( " Posici ó n es de un tipo incorrecto para esta lista " );
}
220
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
Listas e Iteradores
}
/* * Valida si una posici ó n es la primera ; tiempo O (1). */
public boolean isFirst ( Posicion <E > p )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
NodoD <E > v = revis aPosicio n ( p );
return v . getPrev () == cabeza ;
}
/* * Valida si una posici ó n es la ú ltima ; tiempo O (1). */
public boolean isLast ( Posicion <E > p )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
NodoD <E > v = revis aPosicio n ( p );
return v . getNext () == cola ;
}
/* * Intercambia los elementos de dos posiciones dadas ; tiempo O (1) */
public void swapElements ( Posicion <E > a , Posicion <E > b )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
NodoD <E > pA = rev isaPosic ion ( a );
NodoD <E > pB = rev isaPosic ion ( b );
E temp = pA . elemento ();
pA . setElemento ( pB . elemento ());
pB . setElemento ( temp );
}
/* * Regresa una representaci ó n textual de una lista nodo usando for - each */
public static <E > String f or Ea c hT oS tr i ng ( ListaPosicion <E > L ) {
String s = " [ " ;
int i = L . size ();
for ( E elem : L ) {
s += elem ; // moldeo impl ı́ cito del elemento a String
i - -;
if ( i > 0)
s += " , " ; // separar elementos con una coma
}
s += " ] " ;
return s ;
}
/* * Regresar una representaci ó n textual de una lista nodo dada */
public static <E > String toString ( ListaPosicion <E > l ) {
Iterator <E > it = l . iterator ();
String s = " [ " ;
while ( it . hasNext ()) {
s += it . next (); // moldeo impl ı́ cito del elemento a String
if ( it . hasNext ())
s += " , " ;
}
s += " ] " ;
return s ;
}
/* * Regresa una representaci ó n textual de la lista */
public String toString () {
return toString ( this );
}
}
Listado 6.6: La clase ListaNodoPosicion implementando el ADT lista nodo con
una lista doblemente enlazada.
6.3 Iteradores
6.3.
221
Iteradores
Un cálculo tı́pico en una lista arreglo, lista, o secuencia es recorrer sus
elementos en orden, uno a la vez, por ejemplo, para buscar un elemento
especı́fico.
6.3.1.
Tipos de dato abstracto Iterador e Iterable
Un iterador (iterator) es un patrón de diseño de software que abstrae
el proceso de explorar un elemento a la vez desde una colección de elementos.
Un iterador consiste de una secuencia S, un elemento actual de S, y una
forma de pasar al siguiente elemento en S y hacerlo el elemento actual. Ası́,
un iterador extiende el concepto del ADT posición que se introdujo en la
sección 6.2. De hecho, una posición puede ser pensada como un iterador que
no va a ninguna parte. Un iterador encapsula los conceptos de “lugar” y
“siguiente” en una colección de objetos.
Se define el ADT iterador soportando los siguientes dos métodos:
hasNext():prueba si hay elementos pendientes en el iterador.
next():regresa el siguiente elemento en el iterador.
El ADT iterador usa el concepto de cursor como el elemento “actual” en
el recorrido de una secuencia. El primer elemento en un iterador es devuelto
en la primera llamada al método next, siempre y cuando el iterador contenga
al menos un elemento.
Un iterador da un esquema unificado para acceder a todos los elementos de
una colección de objetos en una forma que es independiente de la organización
especı́fica de la colección. Un iterador para una lista arreglo, lista nodo, o
secuencia deberá regresar los elementos de acuerdo a su orden lineal.
Iteradores simples en Java
Java proporciona un iterador a través de su interfaz java.util.Iterator.
La clase java.util.Scanner implementa esta interfaz. Esta interfaz soporta
un método adicional opcional para quitar el elemento previamente regresado
de la colección. Esta funcionalidad, de quitar elementos a través de un iterador,
es algo controversial desde un punto de vista orientado al objeto, sin embargo,
no es sorprendente que su implementación por las clases es opcional. Java
también da la interfaz java.util.Enumeration, la cual es históricamente
222
Listas e Iteradores
más vieja que la interfaz iterador y en vez de los nombres anteriores, usa
hasMoreElements() y nextElement().
El tipo de dato abstracto Iterable
Para dar un mecanismo unificado genérico para explorar a través de una
estructura de datos, los ADT que guardan colecciones de objetos deberı́an
soportar el siguiente método:
iterator():regresa un iterador de los elementos en la colección.
Este método está soportado por la clase java.util.ArrayList y es
muy importante, por lo que hay una interfaz completa java.lang.Iterable
conteniendo sólo este método. Este método puede simplificar al usuario la
tarea de recorrer los elementos de una lista. Para garantizar que una lista
nodo soporta el método anterior, por ejemplo, se podrı́a agregar este método
a la interfaz ListaPosicion, como se muestra en el listado 6.4. En este caso,
también se quisiera indicar que ListaPosicion extiende Iterable. Por lo
tanto, se asume que las listas arreglo y las listas nodo soportan el método
iterator().
1
2
3
public interface ListaPosicion <E > extends Iterable <E > {
/* * Regresa un iterador de todos los elementos en la lista . */
public Iterator <E > iterator ();
Listado 6.7: Agregando el método iterator a la interfaz ListaPosicion
Dada tal definición de una ListaPosicion, se podrı́a usar el iterador
devuelto por el método iterator() para crear una representación de cadena
de una lista nodo, como se muestra en el listado 6.8.
6.3.2.
El ciclo for-each
Para recorrer los elementos regresados por un iterador es una construcción
común, Java proporciona una notación breve para tales ciclos, llamada el
ciclo ciclo for-each. La sintaxis para tal ciclo es la siguiente:
for (tipo nombre : expresión)
sentencia del ciclo
6.3 Iteradores
1
2
3
4
5
6
7
8
9
10
11
12
223
/* * Regresar una representaci ó n textual de una lista nodo dada */
public static <E > String toString ( ListaPosicion <E > l ) {
Iterator <E > it = l . iterator ();
String s = " [ " ;
while ( it . hasNext ()) {
s += it . next (); // moldeo impl ı́ cito del elemento a String
if ( it . hasNext ())
s += " , " ;
}
s += " ] " ;
return s ;
}
Listado 6.8: Ejemplo de un iterador Java usado para convertir una lista nodo a
una cadena.
donde expresión evalúa a una colección que implementa la interfaz java.lang.Iterable, tipo es el tipo de objeto regresado por el iterador para esta clase, y
nombre es el nombre de una variable que tomará los valores de los elementos
de este iterador en la sentencia del ciclo. Esta notación es la versión corta de
la siguiente:
for (Iterator<tipo> it=expresión.iterator(); it.hasNext(); ) {
Tipo nombre= it.next();
sentencia del ciclo
}
Por ejemplo, si se tiene una lista, valores, de objetos Integer, y valores
implementa java.lang.Iterable, entonces se pueden sumar todos los enteros
en valores como sigue:
List<Integer> valores;
...sentencias que crean una nueva lista y la llenan con Integer.
int suma = 0;
for (Integer i : valores)
suma += i; // el moldeo implı́cito permite esto
Se podrı́a leer el ciclo anterior como, “para cada Integer i en valores,
hacer el cuerpo del ciclo, en este caso, sumar i a suma”
Además de la forma anterior del ciclo for-each, Java también permite que
224
Listas e Iteradores
un ciclo for-each sea definido cuando expresión es un arreglo de tipo Tipo, el
cual puede ser un tipo base o un tipo objeto. Por ejemplo se puede totalizar
los enteros en un arreglo, v, el cual guarda los primeros diez enteros positivos,
como sigue:
int[] v={1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int total = 0;
for (int i: v)
total += i;
6.3.3.
Implementación de los iteradores
Una forma de implementar un iterador para una colección de elementos es
hacer una “foto” de esta e iterar sobre esta. Esta aproximación involucrarı́a
guardar la colección en una estructura de datos separada que soporte acceso
secuencial a sus elementos. Por ejemplo, se podrı́an insertar todos los elementos de la colección en una cola, en tal caso el método hasNext() podrı́a
corresponder a !isEmpty() y next() podrı́a corresponder a dequeue(). Con
esta aproximación, el método iterator() toma tiempo O(n) para una colección de tamaño n. Como esta sobrecarga de copiado es relativamente costosa,
se prefiere, en muchos casos, tener iteradores que operen sobre la misma
colección, y no en una copia.
Para implementar esta aproximación directa, se necesita solo realizar un
seguimiento a donde el cursor del iterador apunta en la colección. Ası́, crear un
nuevo iterador en este caso involucra crear un objeto iterador que represente
un cursor colocado solo antes del primer elemento de la colección. Igualmente,
hacer el método next() involucra devolver el siguiente elemento, si este existe,
y mover el cursor pasando esta posición del elemento. En esta aproximación,
crear un iterador cuesta tiempo O(1), al igual que hacer cada uno de los
métodos del iterador. Se muestra una clase implementando tal iterador en el
listado 6.9, y se muestra en el listado 6.10 como este iterador podrı́a ser usado
para implementar el método iterator() en la clase ListaNodoPosicion.
Iteradores posición
Para los ADT que soporten la noción de posición, tal como las ADT lista
y secuencia, se puede también dar el siguiente método:
6.3 Iteradores
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
225
public class ElementoIterador <E > implements Iterator <E > {
protected ListaPosicion <E > lista ; // la lista subyacente
protected Posicion <E > cursor ; // la siguiente posici ó n
/* * Crea un elemento iterador sobre la lista dada . */
public E l e m en t o I t e r a d o r ( ListaPosicion <E > L ) {
lista = L ;
cursor = ( lista . isEmpty ())? null : lista . first ();
}
/* * Valida si el iterador tiene un objeto siguiente . */
public boolean hasNext () { return ( cursor != null ); }
/* * Regresa el siguiente objeto en el iterador . */
public E next () throws N o S u c h E l e m e n t E x c e p t i o n {
if ( cursor == null )
throw new N o S u c h E l e m e n t E x c e p t i o n ( " No hay elemento siguiente " );
E aRegresar = cursor . elemento ();
cursor = ( cursor == lista . last ())? null : lista . next ( cursor );
return aRegresar ;
}
Listado 6.9: Una clase elemento iterador para una ListaPosicion.
1
2
/* * Regresa un iterador con todos los elementos en la lista . */
public Iterator <E > iterator () { return new ElementoIterador <E >( this ); }
Listado 6.10: El método iterator() de la clase ListaNodoPosicion.
positions():regresa un objeto Iterable, por ejemplo una lista
arreglo, o una lista nodo, conteniendo las posiciones en la colección como elementos.
Un iterador regresado por este método permite recorrer las posiciones de
una lista. Para garantizar que una lista nodo soporta este método, se podrı́a
agregar a la interfaz ListaPosicion, por lo que se debe agregar una implementación del método positions() en la clase ListaNodoPosicion, como se
muestra en el listado 6.12. Este método usa la propia clase ListaNodoPosicion
para crear una lista que contiene las posiciones de la lista original como sus elementos. Regresando esta lista posición como su objeto Iterable permitiendo
llamar iterator() sobre este objeto para obtener un iterador de posiciones
de la lista original.
1
2
3
4
5
public interface ListaPosicion <E > extends Iterable <E > {
// ... todos los otros m é todos de la ADT lista ...
/* * Regresa una colecci ó n iterable de todos los nodos en la lista . */
public Iterable < Posicion <E > > positions ();
}
226
Listas e Iteradores
Listado 6.11: Adición de la interfaz Iterable a la interfaz ListaPosicion.
1
2
3
4
5
6
7
8
9
10
11
12
13
public Iterable < Posicion <E > > positions () { // crea una lista de posiciones
ListaPosicion < Posicion <E > > P = new ListaNodoPosicion < Posicion <E > >();
if (! isEmpty ()) {
Posicion <E > p = first ();
while ( true ) {
P . addLast ( p ); // agregar posici ó n p como el ú lt . elemento de la lista P
if ( p == last ())
break ;
p = next ( p );
}
}
return P ; // regresar P como el objeto iterable
}
Listado 6.12: El método positions() de la clase ListaNodoPosicion.
El iterador regresado por iterator() y otros objetos Iterable definen un
tipo restringido de iterador que permite solo una pasada a los elementos. Sin
embargo, iteradores más poderosos pueden también ser definidos, los cuales
permiten moverse hacia atrás y hacia adelante sobre un cierto ordenamiento
de los elementos.
6.3.4.
Iteradores lista en Java
La clase java.util.LinkedList no expone un concepto posición a los
usuarios en su API. La forma para acceder y actualizar un objeto LinkedList
en Java, sin usar ı́ndices, es usando un ListIterator que es generado por
LinkedList, usando el método listIterator(). Tal iterador da métodos
de recorrido hacia atrás y hacia adelante al igual que métodos locales para
actualizar. Este ve su posición actual como estando antes del primer elemento,
entre dos elementos, o después del último elemento. Esto es, usa un cursor
lista, parecido a como un cursor de pantalla es visto localizado entre dos
caracteres en una pantalla. La interfaz java.util.ListIterator incluye los
siguientes métodos:
6.4 ADT listas y el marco de colecciones
227
add(e):agrega el elemento e al final de la lista.
add(i, e):inserta el elemento e en la posición i especificada
en la lista.
hasNext():verdadero si y solo si hay un elemento después de
la posición actual del iterador.
hasPrevious():verdadero si y solo si hay un elemento antes de la
posición actual del iterador.
previous():regresa el elemento e antes de la posición actual y
mueve el cursor a e, por lo que puede usarse para
iterar hacia atrás.
next():regresa el elemento e después de la posición actual
y mueve el cursor a e, por lo que puede usarse para
iterar hacia adelante.
nextIndex():regresa el ı́ndice del siguiente elemento.
previousIndex():regresa el ı́ndice del elemento previo.
set(e):reemplaza el último elemento regresado por next()
o previous() con el elemento indicado.
remove():quita de la lista el último elemento que fue regresado por next() o previous().
Es riesgoso usar múltiples iteradores sobre la misma lista mientras se
modifica su contenido. Si inserciones, borrados, o reemplazos son ocupados
en múltiples “lugares” en una lista, es más seguro usar posiciones para
indicar estas localidades. Pero la clase java.util.LinkedList no expone
sus objetos posición al usuario. Por lo tanto, para evitar los riesgos de
modificar una lista que ha creado múltiples iteradores, por llamadas a su
método iterator, los objetos java.util.Iterator tienen una capacidad
fail-fast que inmediatamente invalida tal iterador si su colección subyacente
es modificada inesperadamente. Java permite muchos iteradores lista para
estar recorriendo una lista ligada al mismo tiempo, pero si uno de ellos la
modifica, usando add, remove o set, entonces el resto de los iteradores se
hacen inválidos.
6.4.
ADT listas y el marco de colecciones
Se revisa en esta sección los ADT lista general, los cuales combinan
métodos de la deque, lista arreglo, y lista nodo y como son usados en el marco
de colecciones de Java.
228
6.4.1.
Listas e Iteradores
Listas en el marco de colecciones Java
Java proporciona un paquete de interfaces y clases para estructuras de
datos, las cuales conjuntamente definen el marco de colecciones Java (Java
Collections Framework ). Este paquete, java.util, incluye versiones de varias
de las estructuras de datos comentadas en este texto, algunas de las cuales
ya han sido discutidas y otras serán revisadas posteriormente. La interfaz
raı́z en el marco de colecciones Java es la interfaz Collection. Esta es una
interfaz general para cualquier estructura de datos que contenga una colección
de elementos, como una lista. Collection extiende a java.lang.Iterable;
por lo tanto, incluye el método iterator(), el cual regresa un iterador de
los elementos en esta colección. También es una superinterface para otras
interfaces en el marco de colecciones que guardan elementos, incluyendo las
interfaces Deque, List y Queue de java.util. Además Collection tiene
una subinterface que define el tipo de colección Set y tiene varias interfaces
importantes relacionadas, incluyendo las interfaces Iterator y ListIterator,
al igual que la interfaz Map.
El marco de colecciones de Java también incluye varias clases concretas
basadas en lista que implementan varias combinaciones de interfaces basadas
en lista. Las siguientes clases son incluidas en java.util:
ArrayBlockingQueue:Una implementación con arreglo de la interfaz
Queue con capacidad limitada.
ArrayDeque:Una implementación de la interfaz Deque que usa
un arreglo, el cual puede crecer o decrecer, como
se requiera.
ArrayList:Una implementación de la interfaz List que usa
un arreglo, el cual puede crecer o decrecer, como
se requiera.
ConcurrentLinkedQueue:Una implementación con lista enlazada de la interfaz Queue que es proceso seguro.
LinkedBlockingDeque:Una implementación con lista enlazada de la interfaz Deque con capacidad limitada.
LinkedBlockingQueue:Una implementación con lista enlazada de la interfaz Queue con capacidad limitada.
LinkedList:Una implementación con lista enlazada de la interfaz List.
Stack:Una implementación del ADT pila.
6.4 ADT listas y el marco de colecciones
229
Cada una de las clases anteriores implementan la interfaz java.lang.Iterable, por lo tanto, incluyen el método iterator() y pueden ser usados en el
ciclo for-each. Además cualquier clase implementando la interfaz java.util.List, con las clases ArrayList y LinkedList, también incluyen el método
listIterator(). Como se indicó previamente, estas interfaces son útiles para
recorrer los elementos de una colección o lista.
Algoritmos basados en lista en el marco de colecciones Java
Además de las clases basadas en lista que son dadas en el marco de
colecciones Java, también hay una cantidad de algoritmos simples que son
dados. Estos métodos están implementados como métodos de clase en la clase
java.util.Collections y se incluyen los siguientes métodos.
disjoint(C, D):regresa un valor booleano indicando si las colecciones C y D son disjuntas.
fill(L, e):Reemplaza cada uno de los elementos en la lista
L con el mismo elemento, e.
frecuency(C, e):Regresa la cantidad de elementos en la colección
C que son iguales a e.
max(C):Regresa el elemento máximo en la colección C, basados en el ordenamiento natural de sus elementos.
min(C):Regresa el elemento mı́nimo en la colección C, basados en el ordenamiento natural de sus elementos.
replaceAll(L, e, f ):Reemplaza cada uno de los elementos en L que
son iguales a e con el mismo elemento, f .
reverse(L):Invierte el ordenamiento de los elementos en la
lista L.
rotate(L, d):Rota los elementos en la lista L por la distancia d,
la cual puede ser negativa, en una forma circular.
shuffle(L):Permuta pseudoaleatoriamente el ordenamiento de
los elementos en la lista L.
sort(L):Ordena la lista L, usando el ordenamiento natural
de los elementos en L.
Conversión de listas a arreglos
Las listas son un concepto interesante y pueden ser aplicadas en una
cantidad de contextos diferentes, pero hay algunos casos dónde podrı́a ser
230
Listas e Iteradores
más útil usar la lista como un arreglo. La interfaz java.util.Collection
incluye los siguientes métodos útiles para generar un arreglo que tenga los
mismos elementos que la colección dada:
toArray():Regresa un arreglo de elementos del tipo Object
conteniendo todos los elementos en esta colección.
toArray(A):Regresa un arreglo de elementos del mismo tipo
de A conteniendo todos los elementos en esta colección.
Si la colección es una lista, entonces el arreglo devuelto debe tener sus
elementos guardados en el mismo orden que la lista original. Por lo tanto,
si se tiene un método útil basado en arreglo que se quiere usar en una lista
u otro tipo de colección, entonces se puede usar el método toArray() de la
colección para producir una representación arreglo de esa colección.
Conversión de arreglos a listas
De manera similar, es útil en ocasiones poder convertir un arreglo en su
lista equivalente. La clase java.util.Arrays incluye el siguiente método:
asList(A):Regresa una representación de lista del arreglo A,
con el mismo tipo de elemento que los elementos
de A.
La lista devuelta por este método usa el arreglo A como su representación
interna para la lista. Por lo que esta lista está garantizada para ser un
lista basada en arreglo y cualquier cambio hecho a esta automáticamente
será reflejada en A. Por estos tipos de efectos laterales, el uso del método
asList() deberá hacerse siempre con cuidado, para evitar consecuencias no
deseadas. Usando este método con cuidado puede ahorrar trabajo.
La interfaz java.util.List y sus implementaciones
Java proporciona funcionalidad similar a los ADT lista arreglo y lista
nodo en la interfaz java.util.List, la cual es implementada con un arreglo
en java.util.ArrayList y con una lista ligada en java.util.LinkedList.
Hay algunas compensaciones entre estas dos implementaciones. Java usa
iteradores para lograr una funcionalidad similar a la del ADT lista nodo
6.4 ADT listas y el marco de colecciones
231
derivada de posiciones. El cuadro 6.5 indica la correspondencia entre los
métodos de los ADT lista (arreglo y nodo) con las interfaces java.util.List
y java.util.ListIterator.
Método del
ADT lista
size()
isEmpty()
get(i)
Método de
List
size()
isEmpty()
get(i)
first()
listIterator()
last()
listIterator(size())
prev(p)
next(p)
set(p, e)
set(i, e)
set(i, e)
add(i, e)
remove(i)
add(i, e)
remove(i)
addFirst(e)
addFirst(e)
addLast(e)
addLast(e)
addAfter(p, e)
add(0, e)
addFirst(e)
add(e)
addLast(e)
Método de
ListIterator
previous()
next()
set(e)
add(e)
addBefore(p, e)
add(e)
remove(p)
remove()
Notas
O(1)
O(1)
A es O(1),
L es O(mı́n{i, n − i})
Primer elemento
es next
Último elemento
es previous
O(1)
O(1)
O(1)
A es O(1),
L es O(mı́n{i, n − i})
O(n)
A es O(n),
L es O(mı́n{i, n − i})
A es O(n), L es O(1)
Sólo en L, O(1)
O(1)
Sólo en L, O(1)
A es O(n), L es O(1)
inserción en el cursor
A es O(n), L es O(1)
inserción en el cursor
A es O(n), L es O(1)
borrado en el cursor
Cuadro 6.5: Correspondencia entre los métodos de los ADT lista arreglo y lista nodo
(1a columna) contra las interfaces List y ListIterator de java.util. Se usa A y L
como abreviaturas para las clases java.util.ArrayList y java.util.LinkedList
232
Listas e Iteradores
6.4.2.
Secuencias
Una secuencia es un ADT que soporta todos los métodos del ADT deque
(sección 5.12), el ADT lista arreglo (sección 6.1), y el ADT lista nodo (sección
6.2). Entonces, este proporciona acceso explı́cito a los elementos en la lista ya
sea por sus ı́ndices, o por sus posiciones. Por otra parte, como se proporciona
capacidad de acceso dual, se incluye en el ADT secuencia, los siguientes dos
métodos “puente” que proporcionan conexión entre ı́ndices y posiciones:
atIndex(i):regresar la posición del elemento con ı́ndice i; una
condición de error ocurre si i < 0 o i > size() − 1.
indexOf(p):regresar el ı́ndice del elemento en la posición p.
Herencia múltiple en el ADT secuencia
La definición del ADT secuencia incluye todos los métodos de tres ADT
diferentes siendo un ejemplo de herencia múltiple. Esto es, el ADT secuencia
hereda métodos de tres “super” tipos de datos abstractos. En otras palabras,
sus métodos incluyen la unión de los métodos de estos super ADT. Ver el
listado 6.13 para una especificación Java del ADT como una interfaz Java.
1
2
3
4
5
6
7
8
9
10
11
12
/* *
* Una interfaz para una secuecia , una estructura de datos que
* soporta todas las operaciones de un deque , lista indizada y
* lista posici ó n .
*/
public interface Secuencia <E >
extends Deque <E > , ListaIndice <E > , ListaPosicion <E > {
/* * Regresar la posici ó n conteniendo el elemento en el ı́ ndice dado . */
public Posicion <E > atIndex ( int r ) throws B o u n d a r y V i o l a t i o n E x c e p t i o n ;
/* * Regresar el ı́ ndice del elemento guardado en la posici ó n dada . */
public int indexOf ( Posicion <E > p ) throws I n v a l i d P o s i t i o n E x c e p t i o n ;
}
Listado 6.13: La interfaz Secuencia definida por herencia múltiple por las interfaces
Deque, ListaIndice, y ListaPosicion y agregando los dos métodos puente.
Secuencia implementada con una lista ligada
Suponiendo que se implementa una secuencia con una lista doblemente enlazada, la cual, como se indica en la documentación para la clase
6.4 ADT listas y el marco de colecciones
233
java.util.LinkedList, es la implementación para esta clase. Con esta implementación, todos los métodos de actualización basados en posición se
ejecutan en tiempo O(1) cada uno. De igual forma, todos los métodos del
ADT deque corren en tiempo O(1), ya que sólo se requiere actualizar o acceder
la lista en sus extremos. Pero los métodos del ADT lista arreglo, los cuales
están incluidos en la clase java.util.LinkedList, no son muy adecuados
para una implementación de una secuencia con una lista doblemente enlazada.
Balance de eficiencia en una secuencia con lista enlazada
Como una lista enlazada no permite acceso indizado a sus elementos, realizar la operación get(i), para regresar el elemento en un ı́ndice i dado, requiere
que se hagan “saltos” desde uno de los extremos de la lista, incrementando
o decrementando, hasta que se ubique el nodo que guarda el elemento con
ı́ndice i. Como una leve optimización, se puede iniciar saltando del extremo
más cercano de la lista, logrando ası́ el siguiente tiempo de ejecución
O(mı́n(i + 1, n − i))
donde n es el número de elementos en la lista. El peor caso de este tipo de
búsquedas ocurre cuando
r = bn/2c
Por lo tanto, el tiempo de ejecución todavı́a es O(n).
Las operaciones add(i, e) y remove(i) también deben hacer saltos para
ubicar el nodo que guarda el elemento con ı́ndice i, y entonces insertar o
borrar un nodo. Los tiempos de ejecución de estas implementaciones son
también
O(mı́n(i + 1, n − i + 1))
lo cual es O(n). Una ventaja de esta aproximación es que, si i = 0 o i = n − 1,
como en el caso de la adaptación del ADT lista arreglo para el ADT deque dado
en la sección 6.1.2, entonces estos métodos se ejecutan en tiempo O(1). Pero,
en general, usar métodos lista arreglo con un objeto java.util.LinkedList
es ineficiente.
234
Listas e Iteradores
Secuencia implementada con un arreglo
Supóngase que se quiere implementar la secuencia S guardando cada
elemento e de S en una celda A[i] de un arreglo A. Se puede definir un
objeto posición p para guardar un ı́ndice i y una referencia al arreglo A, como
variables de instancia en este caso. Entonces se puede implementar el método
elemento(p) para regresar A[i]. Una desventaja importante es que las celdas
en A no tendrán forma de referirse a su correspondiente posición. Ası́, después
de realizar la operación addFirst, no se tiene forma de avisar a las posiciones
existentes en S que cada uno de sus ı́ndices se debe incrementar en uno (las
posiciones en una secuencia están definidas relativamente a las posiciones
vecinas, no a los ı́ndices). Por lo tanto, si se implementa una secuencia con
un arreglo, se necesita una aproximación diferente.
Considerar una solución alterna en la cual, en vez de guardar los elementos
de S en el arreglo A, se guarda un nuevo tipo de objeto posición en cada
celda de A, y se guardan los elementos en las posiciones. El nuevo objeto
posición p guarda el ı́ndice i y el elemento asociado con p.
Con esta estructura de datos, ilustrada en la figura 6.7, se puede buscar a
través del arreglo para actualizar la variable ı́ndice i para cada posición cuyo
ı́ndice cambie debido a una inserción o borrado.
Figura 6.7: Una implementación basada en un arreglo del ADT secuencia.
Balance de eficiencia en una secuencia con arreglo
En esta implementación usando un arreglo para una secuencia, los métodos
addFirst, addBefore, addAfter, y remove toman tiempo O(n), por que se
tienen que desplazar objetos posición para hacer espacio para la nueva posición,
6.5 Ejemplo: Heurı́stica mover al frente
235
o para llenar el espacio creado por la remoción de una vieja posición, como
en los métodos insertar y remover basados en ı́ndice. Todos los otros métodos
basados en la posición toman tiempo O(1).
6.5.
Ejemplo: Heurı́stica mover al frente
Supóngase que se desea mantener una colección de elementos además de
saber el número de veces que cada elemento es accedido. Conocer el número de
accesos permite, por ejemplo, conocer cuales son los primeros diez elementos
más populares. Ejemplos de tales escenarios incluyen los navegadores Web
que mantienen un registro de las direcciones Web más populares visitadas
por un usuario o un programa álbum de fotos que mantiene una lista de
las imágenes más populares que un usuario ve. Adicionalmente, una lista de
favoritos podrı́a ser usada en una interfaz gráfica para conocer las acciones
más populares de un menú desplegable, para después presentar al usuario un
menú condensando conteniendo sólo las opciones más populares.
Se considera en esta sección como se puede implementar el ADT lista de
favoritos, el cual soporta los métodos size() e isEmpty() además de los
siguientes:
access(e):acceder el elemento e e incrementar su cuenta de
acceso, o agregarlo a la lista de favoritos, si todavı́a
no estaba presente.
remove(e):quitar el elemento e de la lista de favoritos, dado
que este se encuentre.
top():regresar un colección iterable de los k elementos
más accedidos.
6.5.1.
Implementación con una lista ordenada y una
clase anidada
La primera implementación de una lista de favoritos que se considera (listado 6.14) es construyendo una clase, ListaFavoritos, guardando referencias a
los objectos accesados en una lista ligada ordenada de forma decreciente, por
la cantidad de accesos. La clase usa una caracterı́stica de Java que permite
definir una clase anidada y relacionada dentro de la definición de una clase. Tal
clase anidada deberá ser declarada static, para indicar que esta definición
236
Listas e Iteradores
está relacionada a la clase que la contiene, y no a una instancia especifica
de esa clase. Mediante clases anidadas se pueden definir clases de “ayuda” o
“soporte” que pueden ser protegidas de uso externo.
La clase anidada, Entrada, guarda para cada elemento e en la lista, un
par (c, v), donde c es la cuenta de accesos para e y v es una referencia valor
al propio elemento e. Cada vez que un elemento es accedido, se busca este
en la liga ligada (agregándolo si todavı́a no está) e incrementando su cuenta
de acceso. Remover un elemento requiere encontralo y quitarlo de la lista
ligada. Devolver los k elementos más accedidos involucra copiar los valores de
entrada en un lista de salida de acuerdo a su orden en lista ligada interna.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/* * Lista de elementos favoritos , con sus cuentas de acceso . */
public class ListaFavoritos <E > {
protected ListaPosicion < Entrada <E > > listaF ; // lista de entradas
/* * Constructor ; tiempo O (1) */
public Lista Favorit os () {
listaF = new ListaNodoPosicion < Entrada <E > >();
}
/* * Regresa el ùnmero de elementos en la lista ; tiempo O (1). */
public int size () { return listaF . size (); }
/* * Prueba si la lista est á vac ı́ a ; tiempo O (1) */
public boolean isEmpty () { return listaF . isEmpty (); }
/* * Quitar un elemento dado , dado que esta en la lista ; tiempo O ( n ) */
public void remove ( E obj ) {
Posicion < Entrada <E > > p = encontrar ( obj ); // b ú squeda de obj
if ( p != null )
listaF . remove ( p ); // quitar la entrada
}
/* * Incrementar la cuenta de accesos para el elemento dado y lo inserta
* si todav ı́ a no est á presente ; tiempo O ( n ) */
public void access ( E obj ) {
Posicion < Entrada <E > > p = encontrar ( obj ); // b ú squeda de obj
if ( p != null )
p . elemento (). i n c r e m e n t a r C u e n t a (); // incrementar cuenta de acceso
else {
listaF . addLast ( new Entrada <E >( obj )); // agregar la entrada al final
p = listaF . last ();
}
moverArriba ( p );
}
/* * Encontar la posici ó n de un elemento dado , o regresar null ; tiempo O ( n ) */
protected Posicion < Entrada <E > > encontrar ( E obj ) {
for ( Posicion < Entrada <E > > p : listaF . positions ())
if ( valor ( p ). equals ( obj ))
return p ; // encontrado en posici ó n p
return null ;
// no encontrado
}
protected void moverArriba ( Posicion < Entrada <E > > cur ) {
Entrada <E > e = cur . elemento ();
int c = cuenta ( cur );
while ( cur != listaF . first () ) {
Posicion < Entrada <E > > prev = listaF . prev ( cur ); // posici ó n previa
if ( c <= cuenta ( prev ) ) break ; // entrada en su posic ó n correcta
6.5 Ejemplo: Heurı́stica mover al frente
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
237
listaF . set ( cur , prev . elemento ()); // descender la entrada previa
cur = prev ;
}
listaF . set ( cur , e ); // guardar la entrada en su posici ó n final
}
/* * Regresa los k elementos m á s accedidos , para un k ; tiempo O ( k ) */
public Iterable <E > top ( int k ) {
if ( k <0 || k > size () )
throw new I l l e g a l A r g u m e n t E x c e p t i o n ( " Argumento inv á lido " );
ListaPosicion <E > T = new ListaNodoPosicion <E >(); // lista top - k
int i =0; // contador de las entradas agregadas a la lista
for ( Entrada <E > e : listaF ) {
if ( i ++ >= k )
break ; // todas las k entradas han sido agregadas
T . addLast ( e . valor () ); // agregar una entrada a la lista
}
return T ;
}
/* * Representaci ó n String de lista de favoritos */
public String toString () { return listaF . toString (); }
/* * M é todo auxiliar que extrae el valor de la entrada en una posici ó n */
protected E valor ( Posicion < Entrada <E > > p ) { return p . elemento (). valor (); }
/* * M é todo auxiliar que extrae el contador de la entrada en una pos . */
protected int cuenta ( Posicion < Entrada <E > > p ) { return p . elemento (). cuenta (); }
/* * Clase interna para guardar elementos y sus cuentas de acceso . */
protected static class Entrada <E > {
private E valor ; // elemento
private int cuenta ; // cuenta de accesos
/* * Constructor */
Entrada ( E v ) {
cuenta = 1;
valor = v ;
}
/* * Regresar el elemento */
public E valor () { return valor ; }
public int cuenta () { return cuenta ; }
public int i n c r e m e n t a r C u e n t a () { return ++ cuenta ; }
public String toString () { return " [ " + cuenta + " ," + valor + " ] " ; }
}
} // Fin de la clase Li staFavo ritos
Listado 6.14: La clase ListaFavoritos que incluye una clase anidada, Entrada, la
cual representa los elementos y su contador de accesos
6.5.2.
Lista con heurı́stica mover al frente
La implementación previa de la lista de favoritos hace el método access(e)
en tiempo proporcional al ı́ndice de e en la lista de favoritos, es decir, si e es
el k-ésimo elemento más popular en la lista, entonces accederlo toma tiempo
O(k). En varias secuencias de acceso de la vida real, es común, que una vez
238
Listas e Iteradores
que el elemento es accedido, es muy probable que sea accedido otra vez en el
futuro cercano. Se dice que tales escenarios poseen localidad de referencia.
Una heurı́stica, o regla de oro, que intenta tomar ventaja de la localidad
de referencia que está presente en una secuencia de acceso es la heurı́stica
mover al frente. Para aplicar esta huerı́stica, cada vez que se accede un
elemento se mueve al frente de la lista. La esperanza es que este elemento sea
accedido otra vez en el futuro cercano. Por ejemplo, considerar un escenario
en el cual se tienen n elementos donde cada elemento es accedido n veces, en
el siguiente orden: se accede el primer elemento n veces, luego el segundo n
veces, y ası́ hasta que el n-ésimo elemento se accede n veces.
Si se guardan los elementos ordenados por la cantidad de accesos, e
insertando cada elemento la primera vez que es accedido, entonces:
cada acceso al elemento 1 se ejecuta en tiempo O(1);
cada acceso al elemento 2 se ejecuta en tiempo O(2);
...
cada acceso al elemento n se ejecuta en tiempo O(n);
Ası́, el tiempo total para realizar las series de accesos es proporcional a
n + 2n + 3n + . . . + nn = n(1 + 2 + 3 + . . . + n) = n · n(n + 1)/2
lo cual es O(n3 ).
Por otra parte, si se emplea la heurı́stica mover al frente, insertando cada
elemento la primera vez, entonces
cada acceso al elemento 1 se ejecuta en tiempo O(1);
cada acceso al elemento 2 se ejecuta en tiempo O(1);
...
cada acceso al elemento n se ejecuta en tiempo O(1);
Por lo que el tiempo de ejecución para realizar todos los accesos en este
caso es O(n2 ). Ası́, la implementación mover al frente tiene tiempos de acceso
más rápidos para este escenario. Sin embargo este beneficio vien a un costo.
6.5 Ejemplo: Heurı́stica mover al frente
239
Implementación de la heurı́stica mover al frente
En el listado 6.15 se da una implementación de una lista de favoritos usando
la heurı́stica mover al frente, definiendo una nueva clase ListaFavoritosMF, la
cual extiende a la clase ListaFavoritos y entonces se anulan las definiciones
de los métodos moverArriba y top. El método moverArriba en este caso
quita el elemento accedido de su posición frente en la lista ligada y la inserta
de regreso en la lista en el frente. El método top, por otra parte, es más
complicado.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/* * Lista de elementos favoritos con la heur ı́ stica mover al frente */
public class ListaFavoritosMF <E > extends ListaFavoritos <E >{
/* * Constructor por defecto */
public L i s t aF a v o r i t o s M F () { }
/* * Mueve una entrada a la primera posici ó n : tiempo O (1) */
protected void moverArriba ( Posicion < Entrada <E > > pos ) {
listaF . addFirst ( listaF . remove ( pos ));
}
/* * Regresa los k elementos m á s accedidos , para un k ; tiempo O ( k ) */
public Iterable <E > top ( int k ) {
if ( k <0 || k > size () )
throw new I l l e g a l A r g u m e n t E x c e p t i o n ( " Argumento inv á lido " );
ListaPosicion <E > T = new ListaNodoPosicion <E >(); // lista top - k
if ( ! isEmpty () ){
// copiar las entradas en una lista temporal C
ListaPosicion < Entrada <E > > C = new ListaNodoPosicion < Entrada <E > >();
for ( Entrada <E > e : listaF )
C . addLast ( e );
// encontrar los top k elementos , uno a la vez
for ( int i =0; i < k ; i ++) {
Posicion < Entrada <E > > posMax = null ; // posici ó n de la entrada sup .
int cuentaMax = -1; // cuenta de accesos de la entrada top
for ( Posicion < Entrada <E > > p : C . positions ()) {
// examinar todas las entradas de C
int c = cuenta ( p );
if ( c > cuentaMax ) { // encontr ó una entrada con una cuenta mayor
cuentaMax = c ;
posMax = p ;
}
}
T . addLast ( valor ( posMax ) ); // agregar entrada top a T
C . remove ( posMax ); // quitar la posici ó n que ya fue agregada
}
}
return T ;
}
} // Fin de la clase L i s t a F a v o r i t o s M F
Listado 6.15: La clase ListaFavoritosMF implementa la heurı́stica mover al frente.
Esta clase extiende a la clase ListaFavoritos y anula los métodos moverArriba
y top.
240
Listas e Iteradores
El listado 6.16 muestra una aplicación que crea dos listas de favoritos,
una usa la clase ListaFavoritos y la otra ListaFavoritosMF. Las listas de
favoritos son construidas usando un arreglo de direcciones Web, y después se
generan 20 números pseudoaleatorios que están en el rango de 0 al tamaño
del arreglo menos uno. Cada vez que se marca un acceso se muestra el estado
de las listas. Posteriormente se obtiene, de cada lista, el top del tamaño de la
lista. Con el sitio más popular de la 1a lista se abre en una ventana.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import java . io .*;
import javax . swing .*;
import java . awt .*;
import java . net .*;
import java . util . Random ;
/* * Programa ejemplo para las clases List aFavorit os y L i s t a F a v o r i t o s M F */
public class P r o b a d o r F a v o r i t o s {
public static void main ( String [] args ) {
String [] arregloURL = { " http :// google . com " ," http :// mit . edu " ,
" http :// bing . com " ," http :// yahoo . com " ," http :// unam . mx " };
ListaFavoritos < String > L1 = new ListaFavoritos < String >();
ListaFavoritosMF < String > L2 = new ListaFavoritosMF < String >();
int n = 20; // cantidad de operaciones de acceso
// Escenario de simulaci ó n : acceder n veces un URL aleatorio
Random rand = new Random ();
for ( int k =0; k < n ; k ++) {
System . out . println ( " _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ " );
int i = rand . nextInt ( arregloURL . length ); // ı́ ndice aleatorio
String url = arregloURL [ i ];
System . out . println ( " Accediendo : " + url );
L1 . access ( url );
System . out . println ( " L1 = " + L1 );
L2 . access ( url );
System . out . println ( " L2 = " + L2 );
}
int t = L1 . size ()/2;
System . out . println ( " - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - " );
System . out . println ( " Top " + t + " en L1 = " + L1 . top ( t ));
System . out . println ( " Top " + t + " en L2 = " + L2 . top ( t ));
// Mostrar una ventana navegador del URL m á s popular de L1
try {
String popular = L1 . top (1). iterator (). next (); // el m á s popular
JEditorPane jep = new JEditorPane ( popular );
jep . setEditable ( false );
JFrame frame = new JFrame ( " URL m á s popular en L1 : " + popular );
frame . getCo ntentPa ne (). add ( new JScrollPane ( jep ) , BorderLayout . CENTER );
frame . setSize (640 ,480);
frame . setVisible ( true );
frame . s e t D e f a u l t C l o s e O p e r a t i o n ( JFrame . EXIT_ON_CLOSE );
} catch ( IOException e ) { /* ignorar excepciones I / O */ }
}
}
Listado 6.16: ProbadorFavoritos usa ListaFavoritos y ListaFavoritosMF para
contar la simulación de accesos a páginas Web y mostrar la más popular.
6.5 Ejemplo: Heurı́stica mover al frente
241
El compromiso con la heurı́stica mover al frente
La lista de favoritos no será mantenida en orden por la cantidad de accesos,
cuando se pide encontrar los k elementos más accedidos, se necesita buscarlos.
Se puede implementar el método top(k) como sigue:
1. Se copian las entradas de la lista de favoritos en otra lista, C, y se crea
una lista vacı́a, A.
2. La lista C se explora k veces. En cada ocasión, se encuentra una entrada
de C con la cuenta de acceso más grande, quitando esta entrada de C,
e insertando su valor al final de A.
3. Se regresa la lista A.
La implementación del método top() toma tiempo O(kn). Ası́, cuando
k es una constante, el método se ejecuta en tiempo O(n). Esto ocurre si se
quiere obtener la lista del top-ten. Sin embargo, si k es proporcional a n,
entonces la ejecución toma tiempo O(n2 ) y sucede cuando se quiere la lista
del top 25 %.
242
Listas e Iteradores
Capı́tulo 7
Árboles
7.1.
Árboles generales
En este capı́tulo, se discute una de las estructuras de datos no lineal
más importante en computación—árboles. Las estructuras árbol son además
un progreso en la organización de datos, ya que permiten implementar un
conjunto de algoritmos más rápidos que cuando se usan estructuras lineales
de datos, como una lista. Los árboles también dan una organización natural
para datos, y por lo tanto, se han convertido en estructuras indispensables
en sistemas de archivos, interfaces gráficas de usuario, bases de datos, sitios
Web, y otros sistemas de cómputo.
Cuando se dice que los árboles “no son lineales”, se hace referencia a
una relación organizacional que es más rica que las relaciones simples “antes”
y “después” entre objetos en secuencias. Las relaciones en un árbol son
jerárquicas, con algunos objetos estando “encima” y algunos otros “abajo”.
Actualmente, la terminologı́a principal para estructuras de dato árbol viene
de los árboles genealógicos, con los términos “padre”, “hijo”, “ancestro”,
y “descendiente” siendo las palabras más comunes usadas para describir
relaciones.
7.1.1.
Definiciones de árboles y propiedades
Un árbol es un tipo de dato abstracto que guarda elementos jerárquicamente. Con la excepción del elemento cima, cada elemento en un árbol tiene
un elemento padre y cero o mas elementos hijos. Un árbol es visualizado
colocando elementos dentro de óvalos o rectángulos, y dibujando las conexio-
244
Árboles
nes entre padres e hijos con lı́neas rectas, ver figura 7.1. Se llama al elemento
cima la raı́z del árbol, pero este es dibujado como el elemento más alto, con
los otros elementos estando conectados abajo, justo lo opuesto a un árbol
vivo.
Figura 7.1: Árbol con 17 nodos para representar la organización de una corporación.
Definición formal de árbol
Se define un árbol A como un conjunto de nodos guardando elementos
tal que los nodos tienen una relación padre-hijo, satisfaciendo las siguientes
propiedades:
Si A no está vacı́o, este tiene un nodo especial, llamado la raı́z, o root,
de A, que no tiene padre.
Cada nodo v de A diferente de la raı́z tiene un nodo padre único w;
cada nodo con padre w es un hijo de w.
7.1 Árboles generales
245
De acuerdo a la definición, un árbol puede estar vacı́o, es decir, no tener
nodos. Esta convención también permite definir un árbol recursivamente, tal
que un árbol A está vacı́o o consiste de un nodo r, llamado la raı́z de A, y un
conjunto de árboles, posiblemente vacı́o, cuyas raı́ces son los hijos de r.
Dos nodos que son hijos del mismo padre son hermanos. Un nodo v es
externo si v no tiene hijos. Un nodo v es interno si este tiene uno o más
hijos. Los nodos externos son también conocidos como hojas.
Ejemplo 7.1. En la mayorı́a de los sistemas operativos, los archivos son
organizados jerárquicamente en directorios anidados, también conocidos como
carpetas, los cuales son presentados al usuario en la forma de un árbol, ver
figura 7.2. Más especı́ficamente, los nodos internos del árbol están asociados
con directorios y los nodos externos están asociados con archivos regulares.
En el sistema operativo UNIX. la raı́z del árbol es llamada el “directorio raı́z”,
y es representada por el sı́mbolo “/”.
Figura 7.2: Representación de árbol de una porción de un sistema de archivos.
Un nodo u es un ancestro de un nodo v si u = v o u es un ancestro del
padre de v. A la inversa, se dice que un nodo v es un descendiente de un
nodo u si u es un ancestro de v. En la figura 7.2 cs252/ es un ancestro de
246
Árboles
papers/, y pr3 es un descendiente de cs016/. El subárbol enraı́zado de
A en un nodo v es el árbol consistente de todos los descendientes de v en
A, incluyendo al propio v. En la figura 7.2, el subárbol enraizado en cs016/
consiste de los nodos cs016/, grades, homeworks/, programs/, hw1, hw2,
hw3, pr1, pr2 y pr3.
Aristas y caminos en árboles
Una arista del árbol A es un par de nodos (u, v) tal que u es el padre de
v, o viceversa. Un camino de A es una secuencia de nodos tal que dos nodos
consecutivos cualesquiera en la secuencia forman una arista. Por ejemplo, el
árbol de la figura 7.2 contiene el camino cs016/programs/pr2.
Ejemplo 7.2. La relación de herencia entre clases en un programa Java
forman un árbol. La raı́z, java.lang.Object, es un ancestro de todas las otras
clases. Cada clase, C, es una descendiente de esta raı́z y es la raı́z de un
subárbol de las clases que extienden a C. Ası́, hay un camino de C a la raı́z,
java.lang.Object, en este árbol de herencia.
Árboles ordenados
Si hay un ordenamiento lineal definido para los hijos de cada nodo en
un árbol, es un árbol ordenado; esto es, se puede identificar los hijos de
un nodo como el primero, el segundo, el tercero, etc. Tal ordenamiento es
visualizado arreglando los hermanos de izquierda a derecha, de acuerdo a su
ordenamiento. Los árboles ordenados indican el orden lineal entre hermanos
listándolos en el orden correcto.
Ejemplo 7.3. Los componentes de un documento estructurado, tal como un
libro, están jerárquicamente organizados como un árbol cuyos nodos internos
son partes, capı́tulos, y secciones, y cuyos nodos externos son párrafos, tablas,
figuras, etcétera, ver figura 7.3. La raı́z del árbol corresponde al propio libro.
Se podrı́a considerar expandir más el árbol para mostrar párrafos consistentes
de sentencias, sentencias consistentes de palabras, y palabras consistentes
de caracteres. Tal árbol es un ejemplo de un árbol ordenado, porque hay un
ordenamiento bien definido entre los hijos de cada nodo.
7.1 Árboles generales
247
Figura 7.3: Un árbol ordenado asociado con un libro.
7.1.2.
El tipo de dato abstracto árbol
El ADT árbol guarda elementos en posiciones, las cuales, al igual que las
posiciones en una lista, están definidas relativamente a posiciones vecinales.
Las posiciones en un árbol son sus nodos y las posiciones vecinales satisfacen las relaciones padre-hijo que definen un árbol válido. Por lo tanto, se usan
los términos “posición” y “nodo” indistintamente para árboles. Al igual que
con una lista posición, un objeto posición para un árbol soporta el método:
elemento():regresa el objeto guardado en esta posición.
Sin embargo, las posiciones nodo en un árbol tienen una funcionalidad
mayor, ya que hay métodos accesores del ADT árbol que aceptan y regresan
posiciones, como las siguientes:
root():regresa la raı́z del árbol; un error ocurre si el árbol
está vacı́o.
parent(v):regresa el padre de v; un error ocurre si v es la
raı́z.
children(v):regresa una colección iterable que lleva los hijos
del nodo v.
Si un árbol A está ordenado, entonces la colección iterable, children(v),
guarda los hijos de v en orden. Si v es un nodo externo, entonces children(v)
está vacı́o.
248
Árboles
Además de los métodos accesores fundamentales previos, también se
incluyen los siguientes métodos de consulta:
isInternal(v):indica si el nodo v es interno.
isExternal(v):indica si el nodo v es externo.
isRoot(v):indica si el nodo v es la raı́z.
Estos métodos hacen la programación con árboles más fácil y más legible,
ya que pueden ser usados en las condiciones de las sentencias if y de los ciclos
while, en vez de usar un condicional no intuitivo.
Hay también un número de métodos genéricos que un árbol probablemente
podrı́a soportar y que no están necesariamente relacionados con la estructura
del árbol, incluyendo los siguientes:
size():regresa la cantidad de nodos en el árbol.
isEmpty():valida si el árbol no tiene nodos.
iterator():regresa un iterador de todos los elementos guardados en nodos del árbol.
positions():regresa una colección iterable de todos los nodos
del árbol.
replace(v, e):regresa el elemento del nodo v antes de cambiarlo
por e.
Cualquier método que tome una posición como un argumento podrı́a
generar una condición de error si esa posición es no válida. No se definen
métodos de actualización especializados para árboles. En su lugar, se describen
métodos de actualización diferentes de acuerdo con las aplicaciones especı́ficas
de árboles.
7.1.3.
Interfaz del ADT árbol
La interfaz Java mostrada en el listado 7.1 representa el ADT árbol.
Las condiciones de error son manejadas como sigue: cada método puede
tomar una posición como un argumento, estos métodos podrı́an lanzar
InvalidPositionException para indicar que la posición es inválida. El
método parent() lanza BoundaryViolationException si este es llamado
sobre un árbol vacı́o. El método root() lanza EmptyTreeException si es
llamado con un árbol vacı́o.
7.1 Árboles generales
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
249
import java . util . Iterator ;
/* *
* Una interfaz para un á rbol donde los nodos pueden tener
* una cantidad arbitraria de hijos .
*/
public interface Tree <E > {
/* * Regresa el n ú mero de nodos en el á rbol . */
public int size ();
/* * Valida si el á rbol est á vac ı́ o . */
public boolean isEmpty ();
/* * Regresa un iterador de los elementos guardados en el á rbol . */
public Iterator <E > iterator ();
/* * Regresa una colecci ó n iterable de los nodos . */
public Iterable < Posicion <E > > positions ();
/* * Reemplaza el elemento guardado en un nodo dado . */
public E replace ( Posicion <E > v , E e )
throws I n v a l i d P o s i t i o n E x c e p t i o n ;
/* * Regresa la ra ı́ z del á rbol . */
public Posicion <E > root () throws E m p t y T r e e E x c e p t i o n ;
/* * Regresa el padre de un nodo dado . */
public Posicion <E > parent ( Posicion <E > v )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n ;
/* * Regresa una colecci ó n iterable de los hijos de un nodo dado . */
public Iterable < Posicion <E > > children ( Posicion <E > v )
throws I n v a l i d P o s i t i o n E x c e p t i o n ;
/* * Valida si un nodo dado es interno . */
public boolean isInternal ( Posicion <E > v )
throws I n v a l i d P o s i t i o n E x c e p t i o n ;
/* * Valida si un nodo dado es externo . */
public boolean isExternal ( Posicion <E > v )
throws I n v a l i d P o s i t i o n E x c e p t i o n ;
/* * Valida si un nodo dado es la ra ı́ z del á rbol . */
public boolean isRoot ( Posicion <E > v )
throws I n v a l i d P o s i t i o n E x c e p t i o n ;
}
Listado 7.1: Interfaz Java Tree para representar el ADT árbol. Métodos adicionales
de actualización podrı́an ser agregados dependiendo de la aplicación.
Una estructura enlazada para árboles generales
Una forma natural para crear un árbol A es usar una estructura enlazada, donde se representa cada nodo v de A por un objeto posición, ver figura
7.4 (a), con los siguientes campos: una referencia al elemento guardado en v,
un enlace al padre de v, y algún tipo de colección, por ejemplo, una lista o
arreglo, para guardar enlaces a los hijos de v. Si v es la raı́z de A entonces el
campo parent de v es null. También, se guarda una referencia a la raı́z de
A y el número de nodos de A en variables internas. Se muestra en la figura
7.4 (b) la estructura esquemáticamente.
250
Árboles
Figura 7.4: La estructura enlazada para un árbol general: (a) el objeto posición
asociado con un nodo; (b) la porción de la estructura de dato asociada con un nodo
y sus hijos.
La tabla 7.1 resume el rendimiento de la implementación de un árbol
general usando una estructura enlazada. Se observa que usando una colección
para guardar los hijos de cada nodo v, se puede implementar children(v)
regresando una referencia a esta colección.
Operación
size(), isEmpty()
iterator(), positions()
replace()
root(), parent()
children(v)
isInternal(), isExternal(), isRoot()
Tiempo
O(1)
O(n)
O(1)
O(1)
O(cv )
O(1)
Cuadro 7.1: Tiempos de ejecución de los métodos de un árbol general de n-nodos
con una estructura enlazada. cv denota el número de hijos de un nodo v. El espacio
usado es O(n).
7.2 Algoritmos de recorrido para árboles
7.2.
251
Algoritmos de recorrido para árboles
En esta sección, se presentan algoritmos para hacer recorridos en un árbol
accediendo este a través de los métodos del ADT árbol.
7.2.1.
Profundidad y altura
Sea v un nodo de un árbol A. La profundidad de v es el número de ancestros de v, excluyendo al propio v. Esta definición implica que la profundidad
de la raı́z de A es cero.
La profundidad de un nodo v también puede ser definida recursivamente
como sigue:
Si v es la raı́z, entonces la profundidad de v es cero.
De otra forma, la profundidad de v es uno mas la profundidad del padre
de v.
Usando la definición previa se presenta un algoritmo recursivo simple,
depth para calcular la profundidad de un nodo v de A. Este método se llama
a sı́ mismo recursivamente en el padre de v, y agrega uno al valor regresado.
Algoritmo depth(A, v):
si v es la raı́z de A entonces
regresar 0
si no
regresar 1+depth(A, w), donde w es el padre de v en A
Una implementación Java simple de este algoritmo se da en el listado 7.2.
1
2
3
4
5
6
public static <E > int depth ( Tree <E > T , Posicion <E > v ) {
if ( T . isRoot ( v ) )
return 0;
else
return 1 + depth (T , T . parent ( v ) );
}
Listado 7.2: El método depth() escrito en Java.
El tiempo de ejecución del algoritmo depth(A, v) es O(dv ), donde dv
denota la profundidad del nodo v en el árbol A, porque el algoritmo realiza
un paso recursivo tiempo constante para cada ancestro de v. Ası́, el algoritmo
depth(A, v) se ejecuta en tiempo O(n) en el peor caso, donde n es el número
total de nodos de A, ya que un nodo de A podrı́a tener profundidad n − 1 en
252
Árboles
el peor caso. Aunque tal tiempo de ejecución es una función del tamaño de
entrada, es más preciso caracterizar el tiempo de ejecución en términos del
parámetro dv , debido a que este parámetro puede ser mucho menor que n.
Altura
La altura de un nodo v en un árbol A se puede definir también recursivamente:
Si v es un nodo externo, entonces la altura de v es cero.
De otra forma, la altura de v es uno más la altura máxima de los hijos
de v.
La altura de un árbol no vacı́o A es la altura de la raı́z de A. Por
ejemplo, el árbol de la figura 7.1 tiene altura 4. Además la altura puede
también ser definida como sigue.
Proposición 7.1: La altura de un árbol no vacı́o A es igual a la máxima
profundidad de un nodo externo de A.
El algoritmo height1() calcula la altura de un árbol no vacı́o A usando
la proposición anterior y el algoritmo depth. El listado 7.3 muestra su
implementación en Java.
Algoritmo height1(A):
h←0
para cada vértice v en A hacer
si v es un nodo externo de A entonces
h ← max(h, depth(A, v))
regresar h
1
2
3
4
5
6
7
public static <E > int height1 ( Tree <E > T ) {
int h = 0;
for ( Posicion <E > v : T . positions () )
if ( T . isExternal ( v ) )
h = Math . max ( h , depth (T , v ) );
return h ;
}
Listado 7.3: Método height1(), que usa el método max() de la clase java.lang.Math
7.2 Algoritmos de recorrido para árboles
253
Desafortunadamente, el algoritmo height1 no es muy eficiente. Como
height1 llama al algoritmo depth(v) en cada nodo
P externo v de A, el tiempo
de ejecución de height1 está dado por O(n + v (1 + dv )), donde n es el
número de nodos de A, dv es la profundidad del nodo v, y la suma se
Phace sólo
con el conjunto de nodos externos de A. En el peor caso, la suma v (1 + dv )
es proporcional a n2 . Ası́, el algoritmo height1 corre en tiempo O(n2 ).
El algoritmo height2, mostrado a continuación e implementado en Java
en el listado 7.4, calcula la altura del árbol A de una forma más eficiente
usando la definición recursiva de altura.
Algoritmo height2(A, v):
si v es un nodo externo de A entonces
regresar 0
si no
h←0
para cada hijo w de v en A hacer
h ← max(h,height2(A, w))
regresar h+1
1
2
3
4
5
6
7
public static <E > int height2 ( Tree <E > T , Posicion <E > v ) {
if ( T . isExternal ( v ) ) return 0;
int h = 0;
for ( Posicion <E > w : T . children ( v ) )
h = Math . max (h , height2 ( T , w ));
return 1 + h ;
}
Listado 7.4: Método height2() escrito en Java.
El algoritmo height2 es más eficiente que height1. El algoritmo es
recursivo, y, si este es inicialmente llamado con la raı́z de A, este eventualmente
será llamado con cada nodo de A. Ası́, se puede determinar el tiempo de
ejecución de este método sumando, sobre todos los nodos, la cantidad de
tiempo gastado en cada nodo. Procesar cada nodo en children(v) toma
tiempo O(cv ), donde cv es el número de hijos de un nodo v. También, el
ciclo while tiene cv iteraciones y cada iteración en el ciclo toma tiempo O(1)
más el tiempo para la llamada recursiva con un hijo de v. Ası́, el algoritmo
height2
gasta tiempo O(1 + cv ) en cada nodo v, y su tiempo de ejecución es
P
O( v (1 + cv )).
Sea un árbol A con n nodos, y cv el número
P de hijos de un nodo v de A,
entonces sumando sobre los vértices en A, v cv = n − 1, ya que cada nodo
254
Árboles
de A, con la excepción de la raı́z, es un hijo de otro nodo, y ası́ contribuye
una unidad a la suma anterior.
Con el resultado anterior, el tiempo de ejecución del algoritmo height2,
cuando es llamado con la raı́z de A, es O(n), donde n es el número de nodos
de A.
7.2.2.
Recorrido en preorden
Un recorrido de un árbol A es una forma sistemática de acceder, “visitar”,
todos los nodos de A. Se presenta un esquema básico de recorrido para árboles,
llamado recorrido en preorden. En la siguiente sección, se revisa otro esquema
básico de recorrido, llamado recorrido en postorden.
En un recorrido en preorden de un árbol A, la raı́z de A es visitada
primero y entonces los subárboles enraizados con sus hijos son recorridos recursivamente. Si el árbol está ordenado, entonces los subárboles son recorridos
de acuerdo al orden de los hijos. La acción especı́fica asociada con la “visita”
de un nodo v depende de la aplicación de este recorrido, y podrı́a involucrar
desde incrementar un contador hasta realizar algún cálculo complejo para v.
El pseudocódigo para el recorrido en preorden del subárbol enraizado en un
nodo v se muestra enseguida.
Algoritmo preorden(A, v):
Realizar la acción “visita” para el nodo v
para cada hijo w de v en A hacer
preorden(A, w) {recursivamente recorrer el subárbol en w}
El algoritmo de recorrido en preorden es útil para producir un ordenamiento
lineal de los nodos de un árbol donde los padres deberán estar antes que
sus hijos en el ordenamiento. Tales ordenamientos tienen varias aplicaciones
diferentes. Se comenta a continuación un ejemplo simple de tal aplicación en
el siguiente ejemplo.
Ejemplo 7.4. El recorrido en preorden del árbol asociado con un documento
examina un documento entero secuencialmente, desde el inicio hasta el fin.
Si los nodos externos son quitados antes del recorrido, entonces el recorrido
examina la tabla de contenido del documento. Ver figura 7.5.
El recorrido en preorden es también una forma eficiente para acceder a
todos los nodos de un árbol. El análisis es similar al del algoritmo height2.
En cada nodo v, la parte no recursiva del algoritmo de recorrido en preorden
requiere tiempo O(1 + cv ), donde cv es el número de hijos de v, Ası́, el tiempo
7.2 Algoritmos de recorrido para árboles
255
Figura 7.5: Recorrido en preorden de un árbol ordenado, donde los hijos de cada
nodo están ordenados de izquierda a derecha.
total de ejecución en el recorrido en preorden de A es O(n). También se puede
observar que cada nodo es visitado sólo una vez.
El algoritmo toStringPreorden implementado con Java, listado 7.5,
hace una impresión en preorden del subárbol de un nodo v de A, es decir,
realiza el recorrido en preorden del subárbol enraizado en v, e imprime los
elementos guardados en un nodo cuando el nodo es visitado. El método
A.children(v) regresa una colección iterable que accede a los hijos de v en
orden.
1
2
3
4
5
6
public static <E > String t o S t r i n g P r e o r d e n ( Tree <E > T , Posicion <E > v ) {
String s = v . elemento (). toString (); // acci ó n principal de visita
for ( Posicion <E > h : T . children ( v ))
s += " , " + t o S t r i n g P r e o r d en (T , h );
return s ;
}
Listado 7.5: Método toStringPreordenque hace una impresión en preorden de los
elementos en el subárbol del nodo v de A.
Hay una aplicación interesante del algoritmo de recorrido en preorden
que produce una representación de cadena de un árbol entero. Suponiendo
que para cada elemento e guardado en el árbol A, la llamada e.toString()
regresa una cadena asociada con e. La representación parentética de
cadena P (A) del árbol A definida recursivamente como sigue. Si A tiene un
sólo nodo v, entonces
P (A) = v.elemento().toString().
256
Árboles
De otra forma,
P (A) = v.elemento().toString() + “(” + P (A1 ) + “,” + · · · + “,” + P (Ak ) + “)”,
donde v es la raı́z de A y A1 , A2 , . . . , Ak son los subárboles enraizados de los
hijos de v, los cuales están dados en orden si A es un árbol ordenado.
La definición de P (A) es recursiva y el operador “+” indica la concatenación de cadenas. La representación parentética del árbol de la figura 7.1 se
muestra enseguida, donde el sangrado y los espacios han sido agregados para
claridad, y además se han retirado las comas.
Electronics R’Us (
R&D
Sales (
Domestic
International (
Canada
S. America
Overseas (
Africa
Europe
Asia
Australia
)
)
)
Purchasing
Manufacturing (
TV
CD
Tuner
)
)
El método Java representacionParentetica, listado 7.6, es una variación del método toStringPreorden, listado 7.5. Está implementado por la
definición anterior para obtener una representaión parentética de cadena de
un árbol A. El método representacionParentetica hace uso del método
toString que está definido para cada objeto Java. Se puede ver a este método
como un tipo de método toString() para objetos árbol.
7.2 Algoritmos de recorrido para árboles
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
257
public static <E > String r e p r e s e n t a c i o n P a r e n t e t i c a (
Tree <E > T , Posicion <E > v ) {
String s = v . elemento (). toString (); // acci ó n principal de visita
if ( T . isInternal ( v )) {
Boolean primeraVez = true ;
for ( Posicion <E > w : T . children ( v ))
if ( primeraVez ) {
s += " ( " + r e p r e s e n t a c i o n P a r e n t e t i c a (T , w ); // 1 er hijo
primeraVez = false ;
}
else
s += " , " + r e p r e s e n t a c i o n P a r e n t e t i c a (T , w ); // hijos siguientes
s += " ) " ; // cerrar par é ntesis
}
return s ;
}
Listado 7.6: Método representacionParentetica() dónde se emplea el operador
+ para concatenar dos cadenas.
7.2.3.
Recorrido en postorden
El algoritmo de recorrido en postorden puede ser visto como el opuesto
al recorrido en preorden, porque este recursivamente recorre los subárboles
enraizados en los hijos de la raı́z primero, y después visita la raı́z. Al igual que
en el recorrido en preorden se emplea el algoritmo para resolver un problema
particular especializando una acción asociada con la “visita” de un nodo v. Si
el árbol está ordenado, se hacen llamadas recursivas para los hijos de un nodo
v de acuerdo a su orden indicado. El pseudocódigo es dado a continuación.
Algoritmo postorden(A, v):
para cada hijo w de v en A hacer
postorden(A, w) {recursivamente recorrer el subárbol en w}
Realizar la acción “visita” para el nodo v
El nombre de recorrido en postorden se debe a que este método de recorrido
visitará un nodo v después de que este ha visitado todos los nodos en los
subárboles enraizados en v. Ver figura 7.6.
El análisis del tiempo de ejecución del recorrido en postorden es análogo
al del recorrido en preorden. El tiempo total gastado en las porciones no
recursivas del algoritmo es proporcional al tiempo gastado en visitar los hijos
de cada nodo en el árbol. Entonces el recorrido de un árbol A con n nodos
toma tiempo O(n), suponiendo que cada visita tome tiempo O(1), ası́ el
recorrido se ejecuta en tiempo lineal.
258
Árboles
Figura 7.6: Recorrido en postorden del árbol ordenado de la figura 7.5
En el listado 7.7 se muestra el método Java toStringPostOrden() el cual
realiza un recorrido en postorden de un árbol A. Este método muestra el
elemento guardado en un nodo cuando este es visitado. Este método también
llama implı́citamente al método toString() con los elementos, cuando están
involucrados en una operación de concatenación de cadenas.
1
2
3
4
5
6
7
public static <E > String t o S t r i n g P o s t o r d e n ( Tree <E > T , Posicion <E > v ) {
String s = " " ;
for ( Posicion <E > h : T . children ( v ))
s += " , " + t o St r i n g P r e o r d en (T , h )+ " " ;
s += v . elemento ();
return s ;
}
Listado 7.7: Método toStringPostorden que realiza una impresión en postorden
de los elementos en el subárbol del nodo v de A.
El método de recorrido en postorden es útil para resolver problemas donde
se desea calcular alguna propiedad para cada nodo v en un árbol, pero para
poder calcular esa propiedad sobre v se requiere que ya se haya calculado
esa propiedad para los hijos de v. Se muestra una aplicación en el siguiente
ejemplo.
Ejemplo 7.5. Considerar un árbol sistema de archivos A, donde los nodos
externos representan archivos y los nodos internos representan directorios.
Supóngase que se quiere calcular el espacio en disco usado en un directorio,
ver figura 7.7, lo cual es recursivamente dado por la suma de:
El tamaño del directorio propio.
Los tamaños de los archivos en el directorio.
7.2 Algoritmos de recorrido para árboles
259
El espacio usado por los directorios hijos.
El cálculo puede ser hecho en un recorrido en postorden del árbol A.
Después de que los subárboles de un nodo interno v han sido recorridos, se
calcula el espacio usado por v agregando el tamaño del propio directorio v y
los tamaños de los archivos contenidos en v al espacio usado por cada hijo
interno de v, el cual fue calculado por los recorridos recursivos en postorden
de los hijos de v.
Figura 7.7: El árbol de la figura 7.2 del sistema de archivos con nombre, tamaño
asociado para cada archivo o directorio dentro de cada nodo, y el espacio usado
por cada directorio encima de cada nodo interno.
Cálculo del espacio en disco
De acuerdo con el ejemplo anterior, el método espacioDisco(), mostrado
en el listado 7.8, realiza un recorrido en postorden de un árbol sistema
de archivos A, imprimiendo el nombre y el espacio usado por el directorio
asociado con cada nodo interno de A. Cuando se llama desde la raı́z de A,
espacioDisco se ejecuta en tiempo O(n), donde n es el número de nodos de
A dados que los métodos auxiliares nombre y tam toman tiempo O(1).
260
1
2
3
4
5
6
7
8
9
10
Árboles
public static <E > int espacioDisco ( Tree <E > T , Posicion <E > v ) {
int t = tam ( v ); // iniciar con el tama ~
n o del propio nodo
for ( Posicion <E > h : T . children ( v ))
// agregar espacio calculado recurs ivament e usado por los hijos
t += espacioDisco (T , h );
if ( T . isInternal ( v ))
// imprimir nombre y espacio en disco usado
System . out . print ( nombre ( v ) + " : " + t );
return t ;
}
Listado 7.8: El método espacioDisco imprime el nombre y el espacio en disco
usado por los directorios asociados con cada nodo interno de un árbol sistema de
archivos. Los métodos auxiliares nombre y tam deben ser definidos para regresar
el nombre y el tamaño asociado con un nodo.
Otros tipos de recorridos
A pesar de que los recorridos en preorden y postorden son formas comunes de visitar los nodos de un árbol, se pueden tener otros recorridos. Por
ejemplo, se podrı́a recorrer un árbol del que se visiten todos los nodos de una
profundidad p antes que se visiten los nodos en una profundidad p + 1. Este
recorrido es conocido “por niveles”. Cuando se recorren los nodos del árbol
consecutivamente numerándoles conforme se visitan se llama la numeración
por nivel de los nodos de A.
7.3.
Árboles Binarios
Un árbol binario es un árbol ordenado con las siguientes propiedades:
1. Cada nodo tiene a lo más dos hijos.
2. Cada nodo hijo está etiquetado ya se como un hijo izquierdo o como un
hijo derecho.
3. Un hijo izquierdo precede a un hijo derecho en el ordenamiento de los
hijos de un nodo.
El subárbol enraizado en un hijo izquierdo o un hijo derecho de un
nodo interno v es llamado un subárbol izquierdo o subárbol derecho,
respectivamente, de v. Un árbol binario es árbol binario propio si cada
7.3 Árboles Binarios
261
nodo tiene cero o dos hijos. Algunos autores también se refieren a tales árboles
como árboles binarios árbol binario completo. Ası́, en un árbol binario
propio, cada nodo interno tiene exactamente dos hijos. Un árbol binario que
no es propio es árbol impropio.
Ejemplo 7.6. Una clase de árboles binarios se emplean en los contextos donde
se desea representar un número de diferentes salidas que pueden resultar de
contestar una serie de preguntas si o no. Cada nodo interno está asociado con
una pregunta. Iniciando en la raı́z, se va con el hijo izquierdo o derecho del
nodo actual, dependiendo de si la respuesta a la pregunta fue “Si” o “No”. Con
cada decisión, se sigue una arista desde un padre a un hijo, eventualmente
trazando un camino en el árbol desde la raı́z a un nodo externo. Tales árboles
binarios son conocidos como árboles de decisión, porque cada nodo externo
V en tal árbol representa una decisión de que hacer si la pregunta asociada con
los ancestros de v fueron contestadas en una forma que llevan a v. Un árbol de
decisión es un árbol binario propio. La figura 7.8 muestra un árbol de decisión
para mostrar en orden ascendente tres valores guardados en las variables A,
B y C, donde los nodos internos del árbol representan comparaciones y los
nodos externos representan la secuencia ordenada en forma creciente.
Figura 7.8: Un árbol de decisión para ordenar tres variables en orden creciente
Ejemplo 7.7. Una expresión aritmética puede ser representada con un árbol
binario cuyos nodos externos están asociados con variables o constantes, y
cuyos nodos internos están asociados con los operadores +, −, × y /. Ver
figura 7.9. Cada nodo en tal árbol tiene un valor asociado con este.
Si un nodo es externo, entonces su valor es aquel de su variable o
262
Árboles
constante.
Si un nodo es interno, entonces su valor está definido aplicando la
operación a los valores de sus hijos.
Un árbol de expresión aritmética es un árbol binario propio, ya que los
operadores +, −, × y / requieren exactamente dos operandos.
Figura 7.9: Un árbol binario representando la expresión aritmética ((((3 + 1) ×
3)/((9 − 5) + 2)) − ((3 × (7 − 4)) + 6)).
Definición recursiva de un árbol binario
Se puede definir un árbol binario en una forma recursiva tal que un árbol
binario está vacı́o o consiste de:
Un nodo r, llamado la raı́z de A y que guarda un elemento.
Un árbol binario, llamado el subárbol izquierdo de A.
Un árbol binario, llamado el subárbol derecho de A.
7.3.1.
El ADT árbol binario
Como un tipo de dato abstracto, un árbol binario es una especialización
de un árbol que soporta cuatro métodos accesores adicionales:
7.3 Árboles Binarios
263
left(v):Regresa el hijo izquierdo de v; una condición de
error ocurre si v no tiene hijo izquierdo.
right(v):Regresa el hijo derecho de v; una condición de
error ocurre si v no tiene hijo derecho.
hasLeft(v):Prueba si v tiene hijo izquierdo.
hasRight(v):Prueba si v tiene hijo derecho.
No se definen métodos de actualización especializados sino que se consideran algunas posibles actualizaciones de métodos cuando se describan
implementaciones especı́ficas y aplicaciones de árboles binarios.
7.3.2.
Una interfaz árbol binario en Java
Se modela un árbol binario como un tipo de dato abstracto que extiende el
ADT árbol y agrega los cuatro métodos especializados para un árbol binario.
En el listado 7.9, se muestra la interfaz que se define con esta aproximación.
Como los árboles binarios son árboles ordenados, la colección iterable regresada
por el método children(v), que se hereda de la interfaz Tree guarda el hijo
izquierdo de v antes que el hijo derecho.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* *
* Una interfaz para un á rbol binario donde cada nodo tiene
* cero , uno o dos hijos .
*
*/
public interface ArbolBinario <E > extends Tree <E > {
/* * Regresa el hijo izquierdo de un nodo . */
public Posicion <E > left ( Posicion <E > v )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n ;
/* * Regresa el hijo derecho de un nodo . */
public Posicion <E > right ( Posicion <E > v )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n ;
/* * Indica si un un nodo tiene hijo izquierdo . */
public boolean hasLeft ( Posicion <E > v ) throws I n v a l i d P o s i t i o n E x c e p t i o n ;
/* * Indica si un un nodo tiene hijo derecho . */
public boolean hasRight ( Posicion <E > v ) throws I n v a l i d P o s i t i o n E x c e p t i o n ;
}
Listado 7.9: Interfaz Java ArbolBinario para el ADT árbol binario que extiende a
Tree (listado 7.1).
264
7.3.3.
Árboles
Propiedades del árbol binario
Las propiedades de los árboles binarios se relacionan con sus alturas y el
número de nodos Se denota al conjunto de todos los nodos de un árbol A
que están a la misma profundidad d como el nivel de un árbol binario de
A. Para el árbol binario, el nivel 0 tiene a lo más un nodo, la raı́z, el nivel 1
tiene a los más dos nodos, los hijos de la raı́z, el nivel 2 tiene a lo más 4 hijos,
y ası́ sucesivamente. Ver figura 7.10. En general, el nivel d tiene a lo más 2d
nodos.
Figura 7.10: Número máximo de nodos en los niveles de un árbol binario.
Se observa que el número máximo de nodos en los niveles del árbol binario
crece exponencialmente conforme se baja en el árbol. De esta observación, se
pueden derivar las siguientes propiedades de relación entre la altura de un
árbol binario A con el número de nodos.
Proposición 7.2: Sea A un árbol binario no vacı́o, y sean n, nE , nI y h el
número de nodos, número de nodos externos, numero de nodos internos, y la
altura de A, respectivamente. Entonces A tiene las siguientes propiedades:
1. h + 1 ≤ n ≤ 2h+1 − 1
7.3 Árboles Binarios
265
2. 1 ≤ nE ≤ 2h
3. h ≤ nI ≤ 2h − 1
4. log(n + 1) − 1 ≤ h ≤ n − 1
También, si A es propio, entonces A tiene las siguientes propiedades:
1. 2h + 1 ≤ n ≤ 2h+1 − 1
2. h + 1 ≤ nE ≤ 2h
3. h ≤ nI ≤ 2h − 1
4. log(n + 1) − 1 ≤ h ≤ (n − 1)/2
También se cumple la siguiente relación entre el número de nodos internos
y los nodos externos en un árbol binario propio
nE = nI + 1.
Para mostrar lo anterior se pueden quitar los nodos de A y dividirlos en
dos “pilas”, una pila con los nodos internos y otra con los nodos externos,
hasta que A quede vacı́o. Las pilas están inicialmente vacı́as. Al final, la
pila de nodos externos tendrá un nodo más que la pila de nodos internos,
considerar dos casos:
Caso 1: Si A tiene un sólo nodo v, se quita v y se coloca en
la pila externa. Ası́, la pila de nodos externos tiene un sólo nodo
y la pila de nodos internos está vacı́a.
Caso 2: De otra forma, A tiene más de un nodo, se quita de
A un nodo externo arbitrario w y su padre v, el cual es un nodo
interno. Se coloca a w en la pila de nodos externos y v en la pila
de nodos internos. Si v tiene un padre u, entonces se reconecta u
con el ex-hermano z de w, como se muestra en la figura 7.11. Esta
operación quita un nodo interno y uno externo, y deja al árbol
como un árbol binario propio.
Se repite esta operación, hasta que eventualmente se tiene un
árbol final consistente de un sólo nodo. Entonces se quita el nodo
del árbol final se coloca en la pila externa, teniendo ası́ esta pila
un nodo más que la pila interna.
266
Árboles
Figura 7.11: Operación para quitar un nodo externo y su nodo padre, usado en la
demostración
7.3.4.
Una estructura enlazada para árboles binarios
Una forma natural de realizar un árbol binario A es usar una estructura
ligada, donde se representa cada nodo v de A por un objeto Posicion, ver
figura 7.12, con los campos dando referencias al elemento guardado en v y a
objetos Posicion asociados con los hijos y el padre de v.
Si v es la raı́z de A, entonces el campo padre de v es null. También, se
guarda el número de nodos de A en la variable tam.
Implementación Java de un nodo del árbol binario
Se emplea la interfaz Java PosicionAB para representar un nodo del árbol
binario. Esta interfaz extiende Posicion, por lo que se hereda el método
elemento, y tiene métodos adicionales para poner el elemento en el nodo,
setElemento y para poner y devolver el padre, el hijo izquierdo y el hijo
derecho, como se muestra en el listado 7.10.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* *
* Interfaz para un nodo de un á rbol binario . Esta mantiene un elemento ,
* un nodo padre , un nodo izquierdo , y nodo derecho .
*
*/
public interface PosicionAB <E > extends Posicion <E > { // hereda elemento ()
public void setElemento ( E o );
public PosicionAB <E > getIzq ();
public void setIzq ( PosicionAB <E > v );
public PosicionAB <E > getDer ();
public void setDer ( PosicionAB <E > v );
public PosicionAB <E > getPadre ();
public void setPadre ( PosicionAB <E > v );
}
Listado 7.10: Interfaz Java PosicionAB para definir los métodos que permiten
acceder a los elementos de un nodo del árbol binario.
7.3 Árboles Binarios
267
Figura 7.12: Representación de un árbol binario con estructura enlazada. (a) Diseño
del nodo; (b) Ejemplo.
268
Árboles
La clase NodoAB, listado 7.11, implementa la interfaz PosicionAB para un
objeto con los campos elemento, izq, der, y padre, lo cual, para un nodo v,
se refiere al elemento en v, el hijo izquierdo de v, el hijo derecho de v, y el
padre de v respectivamente.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* *
* La clase implementa un nodo de un á rbol binario guardando las referencias
* a un elemento , un nodo padre , un nodo izquierdo , y un nodo derecho .
*
*/
public class NodoAB <E > implements PosicionAB <E > {
private E elemento ; // elemento guardado en este nodo
private PosicionAB <E > izq , der , padre ; // nodos adyacentes
/* * Constructor por defecto */
public NodoAB () { }
/* * Constructor principal */
public NodoAB ( E elemento , PosicionAB <E > padre ,
PosicionAB <E > izq , PosicionAB <E > der ) {
setElemento ( elemento );
setPadre ( padre );
setIzq ( izq );
setDer ( der );
}
/* * Regresa el elemento guardado en esta posici ó n */
public E elemento () { return elemento ; }
/* * Pone el elemento guardado en esta posici ó n */
public void setElemento ( E o ) { elemento = o ; }
/* * Regresa el hijo izquierdo de esta posici ó n */
public PosicionAB <E > getIzq () { return izq ; }
/* * Pone el hijo izquierdo de esta posici ó n */
public void setIzq ( PosicionAB <E > v ) { izq = v ; }
/* * Regresa el hijo derecho de esta posici ó n */
public PosicionAB <E > getDer () { return der ; }
/* * Pone el hijo derecho de esta posici ó n */
public void setDer ( PosicionAB <E > v ) { der = v ; }
/* * Regresa el padre de esta posici ó n */
public PosicionAB <E > getPadre () { return padre ; }
/* * Pone el padre de esta posici ó n */
public void setPadre ( PosicionAB <E > v ) { padre = v ; }
}
Listado 7.11: Clase auxiliar NodoAB para implementar los nodos del árbol binario.
Implementación Java del árbol binario enlazado
En el listado 7.12 se muestra la clase ArbolBinarioEnlazado que implementa la interfaz ArbolBinario, listado 7.9, usando una estructura de datos
enlazada. Esta clase guarda el tamaño del árbol y una referencia al objeto
NodoAB asociado con la raı́z del árbol en variables internas. Adicional a los
métodos de la interfaz ArbolBinario, la clase tiene otros métodos, incluyendo
7.3 Árboles Binarios
269
el método accesor sibling(v), el cual regresa el hermano de un nodo v, y
los siguientes métodos de actualización:
addRoot(e):Crea y regresa un nuevo nodo r que guarda el
elemento e y hace a r la raı́z del árbol, un error
ocurre si el árbol no está vacı́o.
insertLeft(v, e):Crea y regresa un nuevo nodo w que guarda a e,
agrega a w como el hijo izquierdo de v y regresa
w, un error ocurre si v ya tiene un hijo izquierdo.
insertRight(v, e):Crea y regresa un nuevo nodo w que guarda a e,
agrega a w como el hijo derecho de v y regresa w,
un error ocurre si v ya tiene un hijo derecho.
remove(v):Quita el nodo v y lo reemplaza con su hijo, si
tiene, y regresa el elemento guardado en v; un
error ocurre si v tiene dos hijos.
attach(v, T1 , T2 ):Une T1 y T2 como los subárboles izquierdo y derecho del nodo externo v, una condición de error
ocurre si v no es externo.
La clase ArbolBinarioEnlazado tiene un constructor sin argumentos que
regresa un árbol binario vacı́o. Iniciando con este árbol vacı́o, se puede construir
cualquier árbol binario creando el primer nodo con el método addRoot() y
aplicando repetidamente los métodos insertLeft(), insertRight() o el
método attach(). De igual forma, se puede desmantelar cualquier árbol
binario A usando la operación remove, llegando hasta un árbol binario vacı́o.
Cuando una posición v es pasado como un argumento a alguno de los
métodos de la clase ArbolBinarioEnlazado, su validez se revisa llamando un
método auxiliar de ayudo, checkPosition(v). Una lista de los nodos visitados
en un recorrido en preorden del árbol es construido por un método recursivo
preorderPositions. Las condiciones de error son indicadas lanzando excepciones InvalidPositionException, BoundaryViolationException, EmptyTreeException, y NonEmptyTreeException.
270
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
Árboles
import java . util . Iterator ;
/* *
* Una implementaci ó n de la interfaz ArbolBinario usando una estructura
* enlazada .
* @see ArbolBinario */
public class ArbolBinarioEnlazado <E > implements ArbolBinario <E > {
protected PosicionAB <E > raiz ; // referencia a la ra ı́ z
protected int tam ;
// n ú mero de nodos
/* * Crea un á rbol binario vac ı́ o */
public A r b o l B i n a r i o E n l a z a d o () {
raiz = null ; // empezar con un á rbol binario
tam = 0;
}
/* * Regresa el n ú mero de nodos en el á rbol . */
public int size () {
return tam ;
}
/* * Indica si el á rbol est á vac ı́ o . */
public boolean isEmpty () {
return ( tam == 0);
}
/* * Indica si un nodo es interno . */
public boolean isInternal ( Posicion <E > v ) throws I n v a l i d P o s i t i o n E x c e p t i o n {
revi saPosici on ( v );
// m é todo auxiliar
return ( hasLeft ( v ) || hasRight ( v ));
}
/* * Indica si un nodo es externo */
public boolean isExternal ( Posicion <E > v ) throws I n v a l i d P o s i t i o n E x c e p t i o n {
return ! isInternal ( v );
}
/* * Indica si un nodo es la ra ı́ z */
public boolean isRoot ( Posicion <E > v ) throws I n v a l i d P o s i t i o n E x c e p t i o n {
revi saPosici on ( v );
return ( v == root ());
}
/* * Indica si un nodo tiene hijo izquierdo */
public boolean hasLeft ( Posicion <E > v ) throws I n v a l i d P o s i t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
return ( vv . getIzq () != null );
}
/* * Indica si un nodo tiene hijo derecho */
public boolean hasRight ( Posicion <E > v ) throws I n v a l i d P o s i t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
return ( vv . getDer () != null );
}
/* * Regresa la ra ı́ z del á rbol */
public Posicion <E > root () throws E m p t y T r e e E x c e p t i o n {
if ( raiz == null )
throw new E m p t y T r e e E x c e p t i o n ( " El á rbol est á vac ı́ o " );
return raiz ;
}
/* * Regresa el hijo izquierdo de un nodo . */
public Posicion <E > left ( Posicion <E > v )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
Posicion <E > posIzq = vv . getIzq ();
7.3 Árboles Binarios
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
271
if ( posIzq == null )
throw new B o u n d a r y V i o l a t i o n E x c e p t i o n ( " Sin hijo izquierdo " );
return posIzq ;
}
/* * Regresa el hijo izquierdo de un nodo . */
public Posicion <E > right ( Posicion <E > v )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
Posicion <E > posDer = vv . getDer ();
if ( posDer == null )
throw new B o u n d a r y V i o l a t i o n E x c e p t i o n ( " Sin hijo derecho " );
return posDer ;
}
/* * Regresa el padre de un nodo . */
public Posicion <E > parent ( Posicion <E > v )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
Posicion <E > posPadre = vv . getPadre ();
if ( posPadre == null )
throw new B o u n d a r y V i o l a t i o n E x c e p t i o n ( " Sin padre " );
return posPadre ;
}
/* * Regresa una colecci ó n iterable de los hijos de un nodo . */
public Iterable < Posicion <E > > children ( Posicion <E > v )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
ListaPosicion < Posicion <E > > children = new ListaNodoPosicion < Posicion <E > >();
if ( hasLeft ( v ))
children . addLast ( left ( v ));
if ( hasRight ( v ))
children . addLast ( right ( v ));
return children ;
}
/* * Regresa una colecci ó n iterable de los nodos de un á rbol . */
public Iterable < Posicion <E > > positions () {
ListaPosicion < Posicion <E > > posiciones = new ListaNodoPosicion < Posicion <E > >();
if ( tam != 0)
p r e o r d e n P o s i c i o n e s ( root () , posiciones ); // asignar posiciones en preorden
return posiciones ;
}
/* * Regresar un iterado de los elementos guardados en los nodos . */
public Iterator <E > iterator () {
Iterable < Posicion <E > > posiciones = positions ();
ListaPosicion <E > elementos = new ListaNodoPosicion <E >();
for ( Posicion <E > pos : posiciones )
elementos . addLast ( pos . elemento ());
return elementos . iterator (); // Un iterador de elementos
}
/* * Reemplaza el elemento en un nodo . */
public E replace ( Posicion <E > v , E o )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
E temp = v . elemento ();
vv . setElemento ( o );
return temp ;
}
// M é todos adicionales accesores
/* * Regresa el hermano de un nodo . */
272
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
Árboles
public Posicion <E > sibling ( Posicion <E > v )
throws InvalidPositionException , B o u n d a r y V i o l a t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
PosicionAB <E > posPadre = vv . getPadre ();
if ( posPadre != null ) {
PosicionAB <E > posHermano ;
PosicionAB <E > posIzq = posPadre . getIzq ();
if ( posIzq == vv )
posHermano = posPadre . getDer ();
else
posHermano = posPadre . getIzq ();
if ( posHermano != null )
return posHermano ;
}
throw new B o u n d a r y V i o l a t i o n E x c e p t i o n ( " Sin hermano " );
}
// M é todos adicionales de actualizaci ó n
/* * Agrega un nodo ra ı́ z a un á rbol vac ı́ o */
public Posicion <E > addRoot ( E e ) throws N o n E m p t y T r e e E x c e p t i o n {
if (! isEmpty ())
throw new N o n E m p t y T r e e E x c e p t i o n ( " El á rbol ya tiene ra ı́ z " );
tam = 1;
raiz = creaNodo (e , null , null , null );
return raiz ;
}
/* * Insertar un hijo izquierdo en un nodo dado . */
public Posicion <E > insertLeft ( Posicion <E > v , E e )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
Posicion <E > posIzq = vv . getIzq ();
if ( posIzq != null )
throw new I n v a l i d P o s i t i o n E x c e p t i o n ( " El nodo ya tiene hijo izquierdo " );
PosicionAB <E > ww = creaNodo (e , vv , null , null );
vv . setIzq ( ww );
tam ++;
return ww ;
}
/* * Insertar un hijo derecho en un nodo dado . */
public Posicion <E > insertRight ( Posicion <E > v , E e )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
Posicion <E > posDer = vv . getDer ();
if ( posDer != null )
throw new I n v a l i d P o s i t i o n E x c e p t i o n ( " El nodo ya tiene hijo derecho " );
PosicionAB <E > w = creaNodo (e , vv , null , null );
vv . setDer ( w );
tam ++;
return w ;
}
/* * Quitar un nodo con un hijo o sin hijos . */
public E remove ( Posicion <E > v )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
PosicionAB <E > posIzq = vv . getIzq ();
PosicionAB <E > posDer = vv . getDer ();
if ( posIzq != null && posDer != null )
throw new I n v a l i d P o s i t i o n E x c e p t i o n ( " No se puede remover nodo con dos hijos " );
7.3 Árboles Binarios
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
273
PosicionAB <E > ww = null ; // el ú nico hijo de v , si tiene
if ( posIzq != null )
ww = posIzq ;
else if ( posDer != null )
ww = posDer ;
if ( vv == raiz ) {
// v es la ra ı́ z entonces
if ( ww != null )
// hacer al hijo la nueva ra ı́ z
ww . setPadre ( null );
raiz = ww ;
}
else {
// v no es la ra ı́ z
PosicionAB <E > uu = vv . getPadre ();
if ( vv == uu . getIzq ())
uu . setIzq ( ww );
else
uu . setDer ( ww );
if ( ww != null )
ww . setPadre ( uu );
}
tam - -;
return v . elemento ();
}
/* * Conecta dos á rboles para ser los sub á rboles de un nodo externo . */
public void attach ( Posicion <E > v , ArbolBinario <E > T1 , ArbolBinario <E > T2 )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
if ( isInternal ( v ))
throw new I n v a l i d P o s i t i o n E x c e p t i o n ( " No se pueda conectar del nodo interno " );
if (! T1 . isEmpty ()) {
PosicionAB <E > r1 = revisaPo sicion ( T1 . root ());
vv . setIzq ( r1 );
r1 . setPadre ( vv );
// T1 deber á ser inv á lidado
}
if (! T2 . isEmpty ()) {
PosicionAB <E > r2 = revisaPo sicion ( T2 . root ());
vv . setDer ( r2 );
r2 . setPadre ( vv );
// T2 deber á ser inv á lidado
}
}
/* * Intercambiar los elementos en dos nodos */
public void swapElements ( Posicion <E > v , Posicion <E > w )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
PosicionAB <E > vv = revisaP osicion ( v );
PosicionAB <E > ww = revisaP osicion ( w );
E temp = w . elemento ();
ww . setElemento ( v . elemento ());
vv . setElemento ( temp );
}
/* * Expandir un nodo externo en un nodo interno con dos nodos
* externos hijos */
public void ex pandExt ernal ( Posicion <E > v , E l , E r )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
if (! isExternal ( v ))
throw new I n v a l i d P o s i t i o n E x c e p t i o n ( " El nodo no es externo " );
insertLeft (v , l );
insertRight (v , r );
274
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
Árboles
}
/* * Quitar un nodo externo v y reemplazar su padre con el hermano de v */
public void r e m o v e A b o v e E x t e r n a l ( Posicion <E > v )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
if (! isExternal ( v ))
throw new I n v a l i d P o s i t i o n E x c e p t i o n ( " El nodo no es externo " );
if ( isRoot ( v ))
remove ( v );
else {
Posicion <E > u = parent ( v );
remove ( v );
remove ( u );
}
}
// M é todos auxiliares
/* * Si v es un nodo de un á rbol binario , convertir a PosicionAB ,
* si no lanzar una excepci ó n */
protected PosicionAB <E > re visaPosi cion ( Posicion <E > v )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
if ( v == null || !( v instanceof PosicionAB ))
throw new I n v a l i d P o s i t i o n E x c e p t i o n ( " La posici ó n no es v á lida " );
return ( PosicionAB <E >) v ;
}
/* * Crear un nuevo nodo de un á rbol binario */
protected PosicionAB <E > creaNodo ( E elemento , PosicionAB <E > parent ,
PosicionAB <E > left , PosicionAB <E > right ) {
return new NodoAB <E >( elemento , parent , left , right ); }
/* * Crear una lista que guarda los nodos en el sub á rbol de un nodo ,
* ordenada de acuerdo al recorrido en preordel del sub á rbol . */
protected void p r e o r d en P o s i c i o n e s ( Posicion <E > v , ListaPosicion < Posicion <E > > pos )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
pos . addLast ( v );
if ( hasLeft ( v ))
p r e o r d e n P o s i c i o n e s ( left ( v ) , pos ); // recursividad en el hijo izquierdo
if ( hasRight ( v ))
p r e o r d e n P o s i c i o n e s ( right ( v ) , pos ); // recursividad en el hijo derecho
}
/* * Crear una lista que guarda los nodos del sub á rbol de un nodo ,
ordenados de acuerdo al recorrido en orden del sub á rbol . */
protected void p o s i c i o n e s E n o r d e n ( Posicion <E > v , ListaPosicion < Posicion <E > > pos )
throws I n v a l i d P o s i t i o n E x c e p t i o n {
if ( hasLeft ( v ))
p o s i c i o n e s E n o r d e n ( left ( v ) , pos ); // recursividad en el hijo izquierdo
pos . addLast ( v );
if ( hasRight ( v ))
p o s i c i o n e s E n o r d e n ( right ( v ) , pos ); // recursividad en el hijo derecho
}
}
Listado 7.12: ArbolBinarioEnlazado el cual implementa la interfaz ArbolBinario.
7.3 Árboles Binarios
275
Rendimiento de ArbolBinarioEnlazado
Los tiempos de ejecución de los métodos de la clase ArbolBinarioEnlazado,
el cual usa una representación de estructura enlazada, se muestran enseguida:
Los métodos size() e isEmpty() usan una variable de instancia que
guarda el número de nodos de A, y cada uno toma tiempo O(1).
Los métodos accesores root(), left(), right(), sibling(), y parent()
toman tiempo O(1).
El método replace(v, e) toma tiempo O(1).
Los métodos iterator() y positions() están implementados haciendo un recorrido en preorden del árbol, usando el método auxiliar
preordenPosiciones(). El iterador de salida es generado con el método
iterator() de la clase ListaNodoPosicion. Los métodos iterator()
y positions() toma tiempo O(n).
El método children() emplea una aproximación similar para construir
la colección iterable que se regresa, pero este corre en tiempo O(1), ya
que hay a lo más dos hijos para cualquier nodo en un árbol binario.
Los métodos de actualización insertLeft(), insertRight(), attach(),
y remove() corren en tiempo O(1), ya que involucran manipulación en
tiempo constante de un número constante de nodos.
7.3.5.
Árbol binario con lista arreglo
Esta representación alterna de un árbol binario A está basado en una
forma de numerar los nodos de A. Para cada nodo v de A, sea p(v) el entero
definido como sigue:
Si v es la raı́z de A, entonces p(v) = 1.
Si v es el hijo izquierdo de un nodo u, entonces p(v) = 2p(u).
Si v es el hijo derecho de un nodo u, entonces p(v) = 2p(u) + 1.
276
Árboles
Figura 7.13: Numeración por nivel de un árbol binario: (a) esquema general; (b)
un ejemplo.
7.3 Árboles Binarios
277
La función de numeración p se conoce como numeración de nivel de
los nodos en un árbol binario A, esta numera los nodos en cada nivel de A en
orden creciente de izquierda a derecha, pudiendo brincarse algunos números,
ver figura 7.13.
La función de numeración de nivel p sugiere una representación de un
árbol binario A por medio de una lista arreglo S tal que cada nodo v de
A es el elemento de S en el ı́ndice p(v). Como se menciono en el capı́tulo
previo, se lleva a cabo la lista arreglo S mediante un arreglo extendible, ver
sección 6.1. Tal implementación es simple y eficiente, para poder usarlo para
los métodos root(), parent(), left(), right(), hasLeft(), hasRight(),
isInternal(), isExternal(), e isRoot() usando operaciones aritméticas
sobre los números p(v) asociados con cada nodo v involucrado en la operación.
Se muestra un ejemplo de representación lista arreglo de un árbol binario
en la figura 7.14.
Figura 7.14: Representación de un árbol binario A por medio de una lista arreglo
S.
Sea n el número de nodos de A, y sea pM el valor máximo de p(v) sobre
todos los nodos de A. La lista arreglo S tiene tamaño N = pM + 1 ya que el
elemento de S en el ı́ndice 0 no está asociado con ningún nodo de A. También,
S tendrá, en general, un número de elementos vacı́os que no se refieren a
nodos existentes de A. En el peor caso, N = 2n .
Los tiempos de ejecución para un árbol binario A implementado con una
lista arreglo S, son iguales a los del árbol binario enlazado, sección 7.3.4.
278
Árboles
7.3.6.
Recorrido de árboles binarios
Como en los árboles generales, los cálculos de árboles binarios involucran
recorridos. Se muestra enseguida ejemplos de recorridos que pueden ocuparse
para resolver algunos problemas.
Construcción de un árbol de expresión
Para el problema de construir un árbol de expresión de una expresión
aritmética completamente parentetizada de tamaño n se da el algoritmo
construirExpresion, suponiendo que todas las operaciones aritméticas
son binarias y las variables no están parentetizadas. Ası́, cada subexpresión
parentetizada contiene un operador en el centro. El algoritmo usa una pila P
mientras se explora la expresión de entrada E buscando variables, operadores,
y paréntesis derechos.
Cuando se ve una variable u operador x, se crea un árbol binario de un
sólo nodo A, cuya raı́z guarda a x, y se mete A a la pila.
Cuando se ve un paréntesis derecho, “)”, se sacan de la cima tres árboles
de la pila S, los cuales representan una subexpresión (E1 ◦ E2 ). Entonces
se conectan los árboles para E1 y E2 en uno para ◦, y se mete el resultado
de regreso a la pila P .
Se repite esto hasta que la expresión E ha sido procesada, en ese momento
el elemento de la cima en la pila es el árbol de expresión para E. El tiempo
total de ejecución es O(n).
Algoritmo construirExpresion(E):
Entrada: Una expresión aritmética completamente parentetizada E =
e0 , e1 , . . . , en−1 , siendo cada ei una variable, operador, o sı́mbolo paréntesis
Salida: Un árbol binario A representando la expresión aritmética E
P ← una nueva pila inicialmente vacı́a
para i ← 0 hasta n − 1 hacer
si ei es una variable o un operador entonces
A ← un nuevo árbol binario vacı́o
A.addRoot(ei )
P.push(A)
si no si ei =0 (0 entonces
Continuar el ciclo
7.3 Árboles Binarios
279
si no { ei =0 )0 }
A2 ← P.pop() { el árbol que representa a E2 }
A ← P.pop() { el árbol que representa a ◦ }
A1 ← P.pop() { el árbol que representa a E1 }
A.attach(A.root(), A1 , A2 )
P.push(A)
regresar P.pop()
Recorrido en preorden de un árbol binario
Como cualquier árbol binario puede ser visto como un árbol general, el
recorrido en preorden para árboles generales, sección 7.2.2, puede ser aplicado
a cualquier árbol binario y simplificado como se da a continuación.
Algoritmo preordenBinario(A, v):
Realizar la acción “visita” para el nodo v
si v tiene un hijo izquierdo u en A entonces
preordenBinario(A, u) { recursivamente recorrer el subárbol izquierdo }
si v tiene un hijo derecho w en A entonces
preordenBinario(A, w) { recursivamente recorrer el subárbol derecho }
Como en el caso de los árboles generales, hay varias aplicaciones de
recorrido en preorden para árboles binarios.
Recorrido en postorden de un árbol binario
De manera análoga, el recorrido en postorden para árboles generales,
sección 7.2.3, puede ser especializado para árboles binarios, como se muestra
enseguida.
Algoritmo postordenBinario(A, v):
si v tiene un hijo izquierdo u en A entonces
postordenBinario(A, u) { recursivamente recorrer el subárbol izquierdo }
si v tiene un hijo derecho w en A entonces
postordenBinario(A, w) { recursivamente recorrer el subárbol
derecho }
Realizar la acción “visita” para el nodo v
280
Árboles
Evaluación de un árbol de expresión
El recorrido en postorden de un árbol binario puede ser usado para resolver
el problema de evaluación del árbol de expresión. En este problema, se da
un árbol de expresión aritmética, es decir, un árbol binario donde cada nodo
externo tiene asociado un valor con este y cada nodo interno tiene un operador
aritmético asociado con este, y se quiere calcular el valor de la expresión
aritmética representada por el árbol.
El algoritmo evaluarExpresion, dado enseguida, evalúa la expresión
asociada con el subárbol enraizado en el nodo v de un árbol A de expresión
aritmética realizando un recorrido en postorden de A iniciando en v. En este
caso, la acción “visita” consiste realizar una sola operación aritmética. Se
supone que el árbol de expresión aritmética es un árbol binario propio.
Algoritmo evaluarExpresion(A, v):
si v es un nodo interno en A entonces
Sea ◦ el operador guardado en v
x ← evaluarExpresion(A, A.left(v))
y ← evaluarExpresion(A, A.right(v))
regresar x ◦ y
si no
regresar el valor guardado en v
El algoritmo evaluarExpresion, al hacer un recorrido en postorden,
da un tiempo O(n) para evaluar un expresión aritmética representada por un
árbol binario con n nodos. Al igual que en el recorrido en postorden general,
el recorrido en postorden para árboles binarios puede ser aplicado a otros
problemas de evaluación bottom-up (ascendente) también, tal como calcular
el tamaño dado en el ejemplo 7.5.
Recorrido en orden de un árbol binario
Un método de recorrido adicional para un árbol binario es el recorrido
recorrido en orden. En este recorrido, se visita un nodo entre los recorridos
recursivos de sus subárboles izquierdo y derecho. El recorrido en orden del
subárbol enraizado en un nodo v de un árbol binario A se da en el siguiente
algoritmo.
7.3 Árboles Binarios
281
Algoritmo enordenBinario(A, v):
si v tiene un hijo izquierdo u en A entonces
enordenBinario(A, u) { recursivamente recorrer el subárbol izquierdo }
Realizar la acción “visita” para el nodo v
si v tiene un hijo derecho w en A entonces
enordenBinario(A, w) { recursivamente recorrer el subárbol derecho }
El recorrido en orden de un árbol binario A puede ser visto informalmente
como visitar los nodos de A “de izquierda a derecha”. Además, para cada
nodo v, el recorrido en orden visita v después de que todos los nodos en el
subárbol izquierdo de v y antes que todos los nodos en subárbol derecho de v,
ver figura 7.15.
Figura 7.15: Recorrido en orden de un árbol binario.
Árboles binarios de búsqueda
Sea S un conjunto cuyos elementos tienen una relación de orden. Por
ejemplo, S podrı́a ser un conjunto de enteros. Un árbol árbol binario de
búsqueda para S es un árbol binario propio A tal que
Cada nodo interno v de A guarda un elemento de S, denotado con x(v).
Para cada nodo interno v de A, los elementos guardados en el subárbol
izquierdo de v son menores que o iguales a x(v) y los elementos guardados
en el subárbol derecho de v son mayores que x(v).
Los nodos externos de A no guardan ningún elemento.
282
Árboles
Un recorrido en orden de los nodos internos de un árbol binario de búsqueda
A visita los elementos en orden creciente, ver figura 7.16.
Figura 7.16: Un árbol binario de búsqueda ordenando enteros. El camino azul sólido
es recorrido cuando se busca exitosamente el 36. El camino azul discontinuo es
recorrido cuando se busca sin éxito el 70.
Se puede usar un árbol binario de búsqueda A para un conjunto S para
encontrar si un valor de búsqueda y está en S, recorriendo el camino descendente del árbol A, iniciando con la raı́z, En cada nodo interno v encontrado,
se compara el valor de búsqueda y con el elemento x(v) guardado en v. Si
y < x(v), entonces la búsqueda continúa en el subárbol izquierdo de v. Si
y = x(v), entonces la búsqueda termina exitosamente. Si y > x(v), entonces
la búsqueda continúa en el subárbol derecho de v. Finalmente, si se alcanza
un nodo externo, la búsqueda termina sin éxito. El árbol binario de búsqueda
puede ser visto como un árbol de decisión, donde la pregunta hecha en cada
nodo interno es si el elemento en ese nodo es menor que, igual a, o mayor que
el elemento que se está buscando.
El tiempo de ejecución de la búsqueda en un árbol binario de búsqueda A
es proporcional a la altura de A. La altura de un árbol binario propio con n
nodos puede ser tan pequeño como log(n + 1) − 1 o tan grande como (n − 1)/2,
ver la propiedades del árbol binario, sección 7.3.3. Por lo anterior los árboles
binarios de búsqueda son más eficientes cuando tienen alturas pequeñas.
Recorrido en orden para dibujar un árbol
El recorrido en orden puede ser aplicado al problema de dibujar un árbol
binario. Se puede dibujar un árbol binario A con un algoritmo que asigne
7.3 Árboles Binarios
283
coordenadas x e y a un nodo de v de A usando las siguientes reglas, ver figura
7.17:
x(v) es el número de nodos visitados antes de v en el recorrido en orden
de A.
y(v) es la profundidad de v en A.
En esta aplicación, se toma la convención común en computación para
las gráficas de que la coordenada x se incrementa de izquierda a derecha y la
coordenada y se incrementa de arriba hacia abajo. Ası́ el origen está en la
esquina superior izquierda de la pantalla de la computadora.
Figura 7.17: Un dibujo en orden de un árbol binario.
Recorrido de Euler de un árbol binario
Los algoritmos de recorrido para árbol que se han visto, todos ellos son
de la forma de iteradores. Cada recorrido visita los nodos de un árbol en
un cierto orden, y se garantiza visitar cada nodo exactamente una vez. Se
pueden unificar los tres algoritmos de recorrido dados en una sola estructura,
relajando los requerimientos de que cada nodo sea visitado una sola vez. La
ventaja de este recorrido es que permite tipos más generales de algoritmos
para ser expresados sencillamente.
El recorrido de Euler de un árbol binario A puede ser informalmente
definido como una “caminata” alrededor de A, que se inicia yendo desde la
raı́z hacia su hijo izquierdo, viendo a las aristas de A como “paredes” que
siempre se conservan a la izquierda, ver figura 7.18. Cada nodo v de A es
encontrado tres veces por el recorrido de Euler:
284
Árboles
Figura 7.18: Un recorrido de Euler de un árbol binario.
“A la izquierda”, antes del recorrido de Euler de subárbol izquierdo de
v.
“Desde abajo”, entre los recorridos de Euler de los dos subárboles de v.
“A la derecha”, después del recorrido de Euler de subárbol derecho de
v.
Si v es un nodo externo, entonces estas tres “visitas” suceden todas al
mismo tiempo. Se describe el recorrido de Euler del subárbol enraizado en v
en el siguiente algoritmo:
Algoritmo RecorridoEuler(A, v):
realizar la acción para visitar nodo v a la izquierda
si v tiene un hijo izquierdo u en A entonces
RecorridoEuler(A, u) { recursivamente recorrer subárbol izquierdo de v }
realizar la acción para visitar nodo v desde abajo
si v tiene un hijo derecho w en A entonces
RecorridoEuler(A, w) { recursivamente recorrer subárbol derecho de v }
realizar la acción para visitar nodo v a la derecha
El tiempo de ejecución del recorrido de Euler de un árbol de n-nodos
se obtiene ası́, suponiendo que cada acción visita toma tiempo O(1). Como
se usa una cantidad constante de tiempo en cada nodo del árbol durante el
recorrido, el tiempo de ejecución total es O(n).
El recorrido en preorden de un árbol binario es equivalente a un recorrido
de Euler tal que cada nodo tiene asociado una acción “visita” ocurriendo
7.3 Árboles Binarios
285
solamente cuando este es encontrada a la izquierda. De la misma forma, los
recorridos en orden y postorden son equivalentes a un recorrido de Euler
cuando el nodo encontrado desde abajo o a la derecha, respectivamente. Se
puede usar el recorrido de Euler para hacer otros tipos de recorridos. Por
ejemplo, suponer que se desea calcular el número de descendientes de cada
nodo v en un árbol binario de n nodos. Se inicia un recorrido de Euler
inicializando un contador a cero, y entonces incrementando el contador cada
vez que se visita un nodo a la izquierda. Para determinar el número de
descendientes de un nodo v, se calcula la diferencia entre los valores del
contador cuando v es visitado a la izquierda y cuando es visitado a la derecha,
y agregado uno. Esta regla da el número de descendientes de v, porque cada
nodo en el subárbol enraizado es contado la visita de v y la visita de v a
la derecha. Por lo tanto, se tiene un método tiempo O(n) para calcular el
número de descendientes de cada nodo.
Otra aplicación del recorrido de Euler es imprimir una expresión aritmética
completamente parentizada a partir de su árbol de expresión. El algoritmo
imprimirExpresión, mostrado a continuación, realiza esta tarea realizando
las siguientes acciones en un recorrido de Euler:
Acción “a la izquierda”: si el nodo es interno, imprimir “(”
Acción “desde abajo”: imprimir el valor u operador guardado en el nodo
Acción “a la derecha”: si el nodo es interno, imprimir “)”
Algoritmo imprimirExpresión(A, v):
si A.isInternal(v) entonces
imprimir “(”
si A.hasLeft(v) entonces
imprimirExpresión(A, A.left(v))
si A.isInternal(v) entonces
imprimir el operador guardado en v
si no
imprimir el valor guardado en v
si A.hasRight(v) entonces
imprimirExpresión(A, A.right(v))
si A.isInternal(v) entonces
imprimir “)”
286
Árboles
7.3.7.
Plantilla método patrón
Los métodos de recorrido para árboles descritos previamente son ejemplos
de patrones de diseño de software orientado al objeto, la plantilla método
patrón. La plantilla método patrón describe un mecanismo genérico de cálculo
que puede ser especializado para una aplicación particular redefiniendo cierto
pasos. Siguiendo la plantilla método patrón, se diseña un algoritmo que
implementa un recorrido de Euler genérico de un árbol binario. Se muestra
enseguida el algoritmo plantillaRecorridoEuler.
Algoritmo plantillaRecorridoEuler(A, v):
r ← nuevo objeto del tipo ResultadoRecorrido
visitarIzquierda(A, v, r)
si A.hasLeft(v) entonces
r.izq ← plantillaRecorridoEuler(A, A.left(v))
visitarAbajo(A, v, r)
si A.hasRight(v) entonces
r.der ← plantillaRecorridoEuler(A, A.right(v))
visitarDerecha(A, v, r)
regresar r.salida
Cuando se llama con un nodo v, el método plantillaRecorridoEuler()
llama varios métodos auxiliares en diferentes fases del recorrido. Como se
describe enseguida:
Crear una variable local r del tipo ResultadoRecorrido, la cual es usada
para guardar resultados intermedios del cálculo, que tiene campos izq,
der y salida.
Se llama al método auxiliar visitarIzquierda(A, v, r), el cual realiza
los cálculos asociados encontrándose a la izquierda del nodo.
Si v tiene hijo izquierdo, se llama recursivamente sobre él mismo con el
hijo izquierdo de v y se guarda el valor regresado en r.izq.
Se llama al método auxiliar visitarAbajo(A, v, r), el cual realiza los
cálculos asociados encontrándose abajo del nodo.
Si v tiene hijo derecho, se llama recursivamente sobre él mismo con el
hijo derecho de v y se guarda el valor regresado en r.der.
7.3 Árboles Binarios
287
Se llama al método auxiliar visitarDerecha(A, v, r), el cual realiza los
cálculos asociados encontrándose a la derecha del nodo.
Regresar r.salida.
El método plantillaRecorridoEuler puede ser visto como una plantilla
o “esqueleto” de un recorrido Euleriano.
Implementación Java
La clase RecorridoEuler, mostrada en el listado 7.13, implementa un
recorrido transversal de Euler usando la plantilla método patrón. El recorrido
transversal es hecha por el método RecorridoEuler(). Los métodos auxiliares
llamados por RecorridoEuler son lugares vacı́os, tienen cuerpos vacı́os o
solo regresan null. La clase RecorridoEuler es abstracta y por lo tanto no
puede ser instanciada. Contiene un método abstracto, llamado ejecutar, el
cual necesita ser especificado en las subclases concretas de RecorridoEuler.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* *
* Plantilla para algoritmos que recorren un á rbol binario usando un
* recorrido euleriano . Las subclases de esta clase redefinir á n
* algunos de los m é todos de esta clase para crear un recorrido
* espec ı́ fico .
*/
public abstract class RecorridoEuler <E , R > {
protected ArbolBinario <E > arbol ;
/* * Ejecuci ó n del recorrido . Este m é todo abstracto deber á ser
* especificado en las subclases concretas . */
public abstract R ejecutar ( ArbolBinario <E > T );
/* * Inicializaci ó n del recorrido */
protected void inicializar ( ArbolBinario <E > T ) { arbol = T ; }
/* * M é todo plantilla */
protected R recorri doEuler ( Posicion <E > v ) {
ResultadoRecorrido <R > r = new ResultadoRecorrido <R >();
v i s i t a r I z q u i e rd a (v , r );
if ( arbol . hasLeft ( v ))
r . izq = r ecorrido Euler ( arbol . left ( v )); // recorrido recursivo
visitarAbajo (v , r );
if ( arbol . hasRight ( v ))
r . der = r ecorrido Euler ( arbol . right ( v )); // recorrido recursivo
visi tarDerec ha (v , r );
return r . salida ;
}
// M é todos auxiliares que pueden ser redefinidos por las subclases
/* * M é todo llamado para visitar a la izquierda */
protected void v i s i t a r I z q u i e r d a ( Posicion <E > v , ResultadoRecorrido <R > r ) {}
/* * M é todo llamado para visitar abajo */
protected void visitarAbajo ( Posicion <E > v , ResultadoRecorrido <R > r ) {}
/* * M é todo llamado para visitar a la derecha */
protected void vi sitarDe recha ( Posicion <E > v , ResultadoRecorrido <R > r ) {}
288
33
34
35
36
37
38
39
40
Árboles
/* Clase interna para modelar el resultado del recorrido */
public class ResultadoRecorrido <R > {
public R izq ;
public R der ;
public R salida ;
}
}
Listado 7.13: Clase RecorridoEuler.java que define un recorrido Euleriano genérico de un árbol binario
La clase, RecorridoEuler, por si misma no hace ningún cálculo útil. Sin
embargo, se puede extender y reemplazar los métodos auxiliares vacı́os para hacer cosas útiles. Se ilustra este concepto usando árboles de expresión aritmética,
ver ejemplo 7.7. Se asume que un árbol de expresión aritmética tiene objetos de
tipo TerminoExpresion en cada nodo. La clase TerminoExpresion tiene subclases VariableExpresión (para variables) y OperadorExpresion (para operadores). A su vez, la clase OperadorExpresion tiene subclases para los operadores aritméticos, tales como OperadorSuma y OperadorMultiplicacion. El
método valor de TerminoExpresion es reemplazado por sus subclases. Para
una variable, este regresa el valor de la variable. Para un operador, este regresa
el resultado de aplicar el operador a sus operandos. Los operandos de un
operador son puesto por el método setOperandos() de OperadorExpresion.
En los códigos de los listados 7.14, 7.15, 7.16 y 7.17 se muestran las clases
anteriores.
7.3 Árboles Binarios
1
2
3
4
5
6
7
289
/* * Clase para un t é rmino ( operador o variable de una expresi ó n
* aritm é tica .
*/
public class T e r m i n o E x p r e s i o n {
public Integer getValor () { return 0; }
public String toString () { return new String ( " " ); }
}
Listado 7.14: Clase TerminoExpresion.java para una expresión
1
2
3
4
5
6
7
8
/* * Clase para una variable de una expresi ó n artim é tica . */
public class V a r i a b l e E x p r e s i o n extends Te r m i n o E x p r e s i o n {
protected Integer var ;
public V a r i a b l e E x p r e s i o n ( Integer x ) { var = x ; }
public void setVariable ( Integer x ) { var = x ; }
public Integer getValor () { return var ; }
public String toString () { return var . toString (); }
}
Listado 7.15: Clase VariableExpresion.java para una variable
1
2
3
4
5
6
7
8
/* * Clase para un operador de una expresi ó n aritm é tica . */
public class O p e r a d o r E x p r e s i o n extends Te r m i n o E x p r e s i o n {
protected Integer primerOperando , s eg un d oO pe ra n do ;
public void setOperandos ( Integer x , Integer y ) {
prim erOperan do = x ;
s eg un do O pe ra nd o = y ;
}
}
Listado 7.16: Clase OperadorExpresion.java para un operador genérico
1
2
3
4
5
6
7
8
/* * Clase para el operador adici ó n en una expresi ó n aritm é tica . */
public class OperadorSuma extends O p e r a d o r E x p r e s i o n {
public Integer getValor () {
// desencajonar y despu é s encajonar
return ( prim erOperan do + se gu n do Op er a nd o );
}
public String toString () { return new String ( " + " ); }
}
Listado 7.17: Clase OperadorSuma.java el operador suma
En los listados 7.18 y 7.19, se muestran las clases RecorridoEvaluarExpresion y RecorridoImprimirExpresion, especializando RecorridoEuler, que
evalúa e imprime una expresión aritmética guardada en un árbol binario,
respectivamente. La clase RecorridoEvaluarExpresion reemplaza el método
auxiliar visitarDerecha(A, v, r) con el siguiente cálculo:
Si v es un nodo externo, poner r.salida igual al valor de la variable
guardada en v;
290
Árboles
si no (v es un nodo interno), combinar r.izq y r.der con el operador
guardado en v, y pone r.salida igual al resultado de la operación.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* * Calcular el valor de un á rbol de expresi ó n aritm é tica . */
public class R e c o r r i d o E v a l u a r E x p r e s i o n
extends RecorridoEuler < TerminoExpresion , Integer > {
public Integer ejecutar ( ArbolBinario < TerminoExpresion > T ) {
inicializar ( T ); // llamar al m é todo de la superclase
return recorr idoEule r ( arbol . root ()); // regresar el valor de la expresi ó n
}
protected void vi sitarDer echa ( Posicion < TerminoExpresion > v ,
ResultadoRecorrido < Integer > r ) {
T e r m i n o E x p r e s io n termino = v . elemento ();
if ( arbol . isInternal ( v )) {
O p e r a d o r E x p r e s i o n op = ( O p e r a d o r E x p r e s i o n ) termino ;
op . setOperandos ( r . izq , r . der );
}
r . salida = termino . getValor ();
}
}
Listado
7.18:
Clase
RecorridoEvaluarExpresion.java
especializa
RecorridoEuler para evaluar la expresión asociada con un árbol de expresión
aritmética.
La clase RecorridoImprimirExpresion reemplaza los métodos visitarIzquierda, visitarAbajo, visitarDerecha siguiendo la aproximación de
la versión pseudocódigo dada previamente.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* * Imprimir la expresi ó n guardada en un á rbol de expresi ó n aritm é tica . */
public class R e c o r r i d o I m p r i m i r E x p r e s i o n
extends RecorridoEuler < TerminoExpresion , String > {
public Integer ejecutar ( ArbolBinario < TerminoExpresion > T ) {
inicializar ( T );
System . out . print ( " Expresi ó n : " );
reco rridoEul er ( T . root ());
System . out . println ();
return null ; // nada que regresar
}
protected void v i s i t a r I z q u i e r d a ( Posicion < TerminoExpresion > v ,
ResultadoRecorrido < String > r ) {
if ( arbol . isInternal ( v )) System . out . print ( " ( " ); }
protected void visitarAbajo ( Posicion < TerminoExpresion > v ,
ResultadoRecorrido < String > r ) {
System . out . print ( v . elemento ()); }
protected void vi sitarDer echa ( Posicion < TerminoExpresion > v ,
ResultadoRecorrido < String > r ) {
7.3 Árboles Binarios
23
24
291
if ( arbol . isInternal ( v )) System . out . print ( " ) " ); }
}
Listado
7.19:
Clase
RecorridoImprimirExpresion.java
especializa
RecorridoEuler para imprimir la expresión asociada con un árbol de expresión aritmética.
292
Árboles
Bibliografı́a
[1] R. Lafore, Data Structures & Algorithms, Second Edition, Sams, 2003.
[2] M. Goodrich, R. Tamassia, Data Structures and Algorithms in Java,
Fourth Edition, John Wiley & Sons, 2005.
Índice alfabético
ı́ndice, 38, 199
árbol, 245, 246
árbol binario, 263
árbol binario completo, 263
árbol binario de búsqueda, 281
árbol binario propio, 263
árbol impropio, 263
árbol ordenado, 248
árboles, 245
árboles de decisión, 263
Scanner, 43
break, 32, 36
catch, 68
clone(), 41
continue, 37
do...while, 35
double, 23
enum, 17
extends, 58
false, 23
finally, 68
for, 33
if, 30
import, 46
instanceof, 78
int, 23
length, 38
new, 13
null, 13, 23
return, 20, 36
super, 58
switch, 31
this, 58
true, 23
try, 68
void, 20
while, 32
abstracción, 51
adaptabilidad, 50
adaptador, 201
ADT, 85
alcance, 15
alfabeto, 12
algoritmo, 149
algoritmos, 85
altura, 254
amortización, 207
análisis amortizado, 207
análisis promedio, 153
ancestro, 247
API, 70, 169
arista, 248
arreglo, 38
arreglo bidimensional, 103
arreglo extendible, 206
atrapada, 66, 68
autoempacado, 29
bloque, 8
bloque de sentencias, 22
ÍNDICE ALFABÉTICO
camino, 248
campos, 7, 14
capacidad, 39
caso base, 128
casos recursivos, 128
celda, 38
ciclo for-each, 224
cifrador de César, 100
clase, 7, 49, 51
abstract, 9
final, 9
public, 9
amigable, 9
clase base, 54
clases número, 12
cola con doble terminación, 194
concatenación, 13, 27
constructor, 21
conversión ancha, 76
conversión implı́cita, 29
criptografı́a, 100
cuerpo, 18
cursor, 213
definición recursiva, 127
deque, 194
descendiente, 247
descifrado, 100
desempacado, 29
dirección, 13
double-ended queue, 194
elemento, 39
encadenamiento constructor, 58
encapsulación, 51
encriptamiento, 100
enlazado dinámico, 56
envı́o dinámico, 56
295
especializa, 54
especialización, 57
estructura de datos, 85, 149
estructura genérica, 81
etiqueta, 37
etiquetas HTML, 183
evolucionabilidad, 50
extensión, 57
externo, 247
extiende, 54
FIFO, 185
firma, 18
for
condición, 34
incremento, 34
inicialización, 33
función cúbica, 144
función constante, 141
función cuadrática, 143
función exponencial, 146
función exponente, 147
función factorial, 126
función lineal, 143
función logarı́tmica, 142
función piso, 148
función potencia, 164
función techo, 148
funciones polinomiales, 145
generadores de números pseudoaleatorios, 97
herencia, 54
herencia única, 58
herencia múltiple, 74
hermanos, 247
heurı́stica, 239
heurı́stica mover al frente, 239
296
hijos, 245
hojas, 247
identificador, 8
igualdad profunda, 107
igualdad simple, 107
igualdad superficial, 107
Implementar una interfaz, 51
inserción ordenada, 94
interfaz, 70
interfaz de programación de aplicaciones, 70, 169
interno, 247
iterador, 223
iterator, 223
jerárquicas, 245
jerárquico, 52
lanza, 66
lanzados, 66
LDE, 113
LIFO, 167
lista, 199
lista arreglo, 199
lista circular enlazada, 121
lista de favoritos, 237
lista doblemente enlazada, 113
lista enlazada, 108
lista nodo, 211
lista simple enlazada, 108
literal, 23
localidad de referencia, 239
método
firma, 14, 56
métodos, 7
módulo, 101, 189
marco de colecciones Java, 230
ÍNDICE ALFABÉTICO
matriz, 103
mezcla, 74
miembros, 7
modificador
final, 17
static, 17
modificadores de clase, 9
modularidad, 52
nivel de un árbol binario, 266
nodo cabeza, 113
nodo cola, 113
nodos, 108
notación Omega-grande, 158
notación Tetha-grande, 158
numeración de nivel, 274
numeración por nivel, 261
O-grande, 155
objeto, 8
objeto compuesto, 20
objetos, 7
operaciones primitivas, 152
operador
new, 10
padre, 245
padre-hijo, 246
paquete, 46
parámetros de tipo actual, 81
parámetros de tipo formal, 81
patrón de diseño, 53
peor caso, 153
pila, 167
plantilla método patrón, 285
polimórfica, 56
por valor, 20
portabilidad, 50
posición, 210
ÍNDICE ALFABÉTICO
posiciones en un árbol, 249
problema de Josefo, 193
profundidad, 253
progresión aritmética, 59
progresión Fibonacci, 63
progresión geométrica, 59
297
texto plano, 100
tipado fuerte, 76
tipo, 7, 14
tipo genérico, 81
tipos, 8
tipos base, 10
tipos de datos abstractos, 51, 85
tipos primitivos, 10
raı́z, 246
rango, 199
recorrido, 256
variables de instancia, 7, 14
recorrido en orden, 280
variables locales, 22
recorrido en postorden, 259
vector, 199
recorrido en preorden, 256
recurrencia, 126
recurrencia binaria, 134
recurrencia de cola, 133
recurrencia múltiple, 137
reemplazo, 58
referencia, 10, 13, 55
refinamiento, 58
reglas de exponentes, 147
reglas de logaritmos, 142
relativamente, 211
representación parentética de cadena,
257
robusto, 49
round robin, 192
secuencia, 199, 234
semilla, 98
sobrecarga, 56
subárbol derecho, 263
subárbol enraı́zado, 248
subárbol izquierdo, 263
sumatoria, 146
sumatorias geométricas, 148
superclase, 54
texto cifrado, 100
Descargar