Listas y hashes (I)

Anuncio
Resumen PAV (7)
Este resumen ha sido elaborado para ser leído después de haber asistido a clase: pretende fijar y
profundizar en los conceptos ya explicados.
Listas y hashes (I)
Introducción
Los programas necesitan crear objetos.
Dependiendo del problema que estemos
resolviendo, en ocasiones será posible conocer de antemano el número de objetos que necesitará
manejar el programa. Pero otras veces no podremos saber de antemano cuántos objetos
necesitaremos crear.
Java tiene muchos modos de almacenar objetos. Además de poder almacenar la referencia a
un objeto directamente en una variable, el lenguaje implementa arreglos (arrays). Una vez creado
el arreglo, se puede acceder a sus elementos individuales usando un índice. Suponiendo que
exisitiera una clase Racional, podríamos escribir:
// espacio para 200 objetos de tipo Racional
Racional[] racionales = new Racional[200];
// creo un objeto de tipo Racional, y lo almaceno en el array
racionales[0] = new Racional(5, 4);
En contrapartida a su velocidad de acceso aleatorio, los arreglos tienen tamaño fijo, y una
vez creados no pueden aumentar ni disminuir su capacidad.
Java, como la mayoría de lenguajes modernos, tiene librerías que permiten manejar
colecciones de objetos (Collections). Una colección es una clase que, como un arreglo, maneja
agrupaciones de objetos, pero su tamaño es flexible y, dependiendo de la colección de que se trate,
optimizan el acceso, la iteración de los elementos, mantienen la colección ordenada según un
criterio, etc.
Antes de entrar a estudiar la librería de colecciones de Java, explicaremos la teoría de las
listas enlazadas y hashes. Las listas enlazadas y los hashes son un tema clásico de ciencias de la
informática. A nosotros nos interesa saber los conceptos generales sobre cómo funcionan y cómo se
implementan (por lo menos a nivel básico) para poder entender mejor cómo funciona la librería de
colecciones de Java.
En los programas que desarrollemos, salvo necesidad especial, usaremos la librería de
colecciones de Java (Java Collections Framework) para almacenar nuestros objetos. Como
explican los tutorials de Java1, el uso de la librería de colecciones nos permite usar clases ya
probadas, conocidas y optimizadas, y centrarnos en la solución del problema que tenemos entre
manos, sin necesidad de reinventar la rueda cada vez.
1 http://java.sun.com/docs/books/tutorial/collections/intro/index.html
1
Listas enlazadas2
Una lista enlazada es una serie de objetos en la que cada objeto contiene dos tipos de
información:
a) los atributos propios del objeto (las variables de la clase que representan el estado del
objeto en cuestión);
b) un atributo del tipo del mismo objeto, en el que se almacena la referencia a otro objeto de
la lista. Es decir, una variable que apunta al siguiente elemento de la lista.
Los objetos que forman la lista se denominan nodos. Según cómo esté implementada la
lista,
●
cada nodo puede tener unicamente una referencia al siguiente nodo de la lista. Son las listas
“simplemente” enlazadas, single linked lists. Sólo se pueden recorrer en una dirección (de
inicio a fin);
Data
●
next
Data
next
Data
next
cada nodo puede tener dos referencias, una al nodo anterior y otra al nodo siguiente. Son
listas “doblemente” enlazadas, double linked lists, que pueden recorrerse tanto hacia
adelante como hacia atrás).
prev
Data
next
prev
Data
next
prev
Data
next
Las listas permiten iterar rápidamente sus elementos (acceso secuencial), e insertar o
eliminar un elemento en tiempo constante y con un mínimo de operaciones. En cambio acceder a
un elemento determinado de la lista (acceso aleatorio) es una operación costosa en ciclos de
máquina.
Otros tipos de estructuras (Stacks, Queues) utilizan listas enlazadas en su implementación.
Implementación de una lista simplemente enlazada en Java
Veamos como podrían declararse estas clases en Java para una lista simplemente enlazada.
Se suele usar null en la referencia del último nodo de la lista, para indicar que la lista ha terminado.
(El caso de la lista doble es similar.)
Nodo.java
public class Nodo
{
private Object data;
private Nodo next;
// constructor
public Nodo(Object data) {
this.data = data;
}
2 Se puede encontrar información adicional en los apuntes del curso en la intranet y en Wikipedia:
http://en.wikipedia.org/wiki/Linked_lists
2
public Nodo getNext()
public void setNext(Nodo next)
{
{
return next;
this.next = next;
}
}
public Object getData()
{
public void setData(Object data){
return data;
this.data = data;
}
}
/**
* inserta un nodo delante del este nodo
*/
public void insertNodo(Object data) {
Nodo nuevo = new Nodo(data);
nuevo.setNext(next);
next = nuevo;
}
/**
* borra el nodo siguiente
*/
public void deleteNodo() {
// antes de borrar, veo si no estoy al final de la lista
if(next != null) {
next = next.getNext();
} // end if
}
}
La variable data es de tipo Object. Esto nos permite usar la lista para almacenar cualquier
tipo de objetos, pero obliga a usar un downcast cada vez que se use una funcionalidad específica de
la clase del objeto almacenado en el nodo.
Ahora escribiremos un programa que use la clase Nodo. Necesitamos algún tipo de objeto
para almacenar en la lista. Para eso, usaremos la clase Persona, que definimos a continuación:
Persona.java
public class Persona
{
private String nombre;
private String apellido;
private int edad;
// Constructor
public Persona(String nombre, String apellido, int edad) {
this.nombre = nombre;
this.apellido = apellido;
this.edad = edad;
}
public String getNombre()
{ return nombre;
}
public String getApellido() { return apellido; }
public int getEdad()
{ return edad;
}
}
// override del método toString()
public String toString() {
return apellido + ", " + nombre + " (" + edad + " años)";
}
A continuación una clase que usa Nodo para guardar Personas:
3
TestNodo.java
public class TestNodo
{
public static void main(String[] args) {
Nodo lista = null;
// insertamos algunos objetos Persona en la lista
// el primer nodo lo asignamos directamente a lista
lista = new Nodo(new Persona("Juan","Pérez", 45));
// el resto de nodos son insertados en la segunda posición
lista.insertNodo(new Persona("José", "Pérez", 30));
lista.insertNodo(new Persona("Juana", "Pérez", 28));
lista.insertNodo(new Persona("Benjamín", "Pérez", 18));
// imprimimos la lista
System.out.println("--------------------");
printList(lista);
// ahora vamos a borrar el *segundo* nodo:
lista.deleteNodo();
System.out.println("--------------------");
printList(lista);
//
//
//
//
para acceder a los métodos de Persona, necesitamos
hacer un downcast (salvo que sean override de los
métodos de Object, en cuyo caso la llamada es polimórfica)
recuperamos la persona del segundo elemento de la lista
Persona p = (Persona) lista.getNext().getData();
System.out.println("Apellido: " + p.getApellido());
}
/**
* imprime la lista desde el nodo list en adelante
*/
public static void printList(Nodo list) {
while(list != null) {
// imprimimos el nodo. getData devuelve un
// Object, pero no necesitamos hacer un downcast:
// toString (que es llamado por el println)
// está definido en Object, Persona hace
// un override de toString. Por tanto, la llamada al toString
// de Object es polimórfica.
System.out.println( list.getData() );
}
}
// avanzamos al siguiente nodo
list = list.getNext();
// end while
}
Generics para especificar el tipo de dato que guarda la lista
4
Releyendo la clase Nodo que hemos escrito, podríamos preguntarnos: ¿no sería posible usar
un generic para que nuestro Nodo sepa qué tipo de objeto está almacenando? Así nos podríamos
ahorrar el downcast cada vez que necesitemos acceder a los métodos de Persona.
Definir una clase que luego permita a los que la usan especificar un tipo de datos usando
generics no es complicado. Por ejemplo, si definimos una clase Test así:
class Test<TipoDeDato> {
TipoDeDato[] arreglo = new TipoDeDato[100];
}
public TipoDeDato get(int i) {
return arreglo[i];
}
public void set(int i, TipoDeDato dato) {
arreglo[i] = dato;
}
podremos crear objetos de Test que guardan hasta 100 objetos de cualquier clase que
indiquemos, sin tener que preocuparnos del downcast:
Test<Persona> lista = new Test<Persona>();
lista.set(0, new Persona(“Juan”, “Pérez”, 45));
// en vez de ((Persona) lista).get(0)
System.out.println(lista.get(0));
A efectos del generic, podemos imaginar que cuando se ejecuta el programa, TipoDeDato
es reemplazado en la clase Test por el tipo de dato que haya especificado el usuario entre brackets
(< >) al declarar que una variable es de tipo Test.
Cambiamos la clase Nodo para que el tipo de objeto almacenado en el nodo se especifique
usando generics:
NodoGenerics.java
public class NodoGenerics<Tipo> {
// aquí era private Object data
private Tipo data;
private NodoGenerics<Tipo> next;
public NodoGenerics(Tipo data) {
this.data = data;
}
public NodoGenerics<Tipo> getNext() { return next; }
public void setNext(NodoGenerics<Tipo> next) { this.next = next; }
// aquí era public Object getData
public Tipo getData() { return data; }
public void setData(Tipo data) { this.data = data; }
/**
* inserta un nodo delante del este nodo
*/
public void insertNodo(Tipo data) {
NodoGenerics<Tipo> nuevo = new NodoGenerics<Tipo>(data);
nuevo.setNext(next);
next = nuevo;
}
/**
5
}
* borra el nodo siguiente
*/
public void deleteNodo() {
if(next != null) {
next = next.getNext();
}
}
TestNodoGenerics.java
public class TestGenericsNode
{
public static void main(String[] args) {
// declaro el nodo usando generics: un nodo
// que almacena objetos Persona
NodoGenerics<Persona> lista = null;
lista = new NodoGenerics<Persona>(new Persona("Juan","Pérez", 45));
// el resto de nodos son insertados en la segunda posición
lista.insertNodo(new Persona("José", "Pérez", 30));
lista.insertNodo(new Persona("Juana", "Pérez", 28));
lista.insertNodo(new Persona("Benjamín", "Pérez", 18));
// imprimimos la lista
System.out.println("--------------------");
printList(lista);
// ahora, vamos a borrar el *segundo* nodo:
lista.deleteNodo();
System.out.println("--------------------");
printList(lista);
// aquí ya no necesito hacer el downcast
Persona p = lista.getNext().getData();
System.out.println("Apellido: " + p.getApellido());
}
}
/**
* imprime la lista desde el nodo list en adelante
*/
public static void printList(NodoGenerics<Persona> list) {
while(list != null) {
System.out.println( list.getData() );
list = list.getNext();
} // end while
}
Otras estructuras de datos que usan listas enlazadas
a) Pilas (stacks)
Un stack es una estructura de datos que sólo permite insertar y quitar elementos en uno de
los extremos. Dicho de otro modo, se retira el elemento que haya sido insertado más
recientemente. Por eso se dice que es una estructura LIFO (Last-in, First-out): el último
elemento insertado es el primero en ser recuperado.
Los nombres de los métodos de un stack suelen ser bastante estándares:
void push(Object o)
inserta el objeto o en el stack
Object pop()
retira el último elemento insertado del stack
6
Object peek()
true
el último elemento insertado, pero sin quitarlo del stack
Además suele haber un método emtpy() o isEmpty() que devuelve un boolean
si el stack está vacío.
Pedro
Juan
Ana
Benjamín
pop()
push()
A continuación presentamos la implementación de un stack usando generics, y una clase que
ejemplifica su uso:
Stack.java
public class Stack<Tipo>
{
private StackNode<Tipo> head = null;
/**
* inserta un elemento en el stack
*/
public void push(Tipo dato) {
// crea un nuevo nodo y lo inserta delante del primero
StackNode<Tipo> nodo = new StackNode<Tipo>(dato);
nodo.setNext(head);
head = nodo;
}
/**
* pop - devuelve el último elemento insertado y lo quita
* del stack
*/
public Tipo pop() {
if(!isEmpty()) {
StackNode<Tipo> node = head;
head = head.getNext();
return node.getData();
} else
return null;
} // end pop
/**
* peek - devuelve el último elemento insertado, pero
* sin quitarlo de la pila
*/
public Tipo peek() {
if(!isEmpty()) {
return head.getData();
} else
return null;
} // end peek
/**
* devuelve true si el stack está vacío
7
*/
public boolean isEmpty() {
return head == null;
} // end isEmpty
}
class StackNode<Tipo> {
private Tipo data;
private StackNode<Tipo> next;
public StackNode(Tipo data) {
this.data = data;
}
public void setNext(StackNode<Tipo> next) {
this.next = next;
}
public StackNode<Tipo> getNext() {
return next;
}
public void setData(Tipo data) {
this.data = data;
}
}
public Tipo getData() {
return data;
}
TestStack.java
public class TestStack
{
public static void main(String[] args) {
Stack<Persona> stack = new Stack<Persona>();
stack.push(new
stack.push(new
stack.push(new
stack.push(new
Persona("Juan","Pérez", 45));
Persona("José", "Pérez", 30));
Persona("Juana", "Pérez", 28));
Persona("Benjamín", "Pérez", 18));
System.out.println("Peek: " + stack.peek());
while(!stack.isEmpty()) {
System.out.println("pop: " + stack.pop());
}
}
}
Se pueden implementar también stacks de tipo FIFO (first-in, first-out): los objetos se
insertan por abajo de la pila y se retiran por arriba, de modo que el primero en insertarse es el
primero en salir.
Hashes
Qué es un hash
Un hash es un número que se usa para representa a un objeto. Una función hash es una
función que recibe como parámetro un objeto y devuelve un número tal que se cumple:
●
para el mismo objeto, la función siempre devuelve el mismo número hash.
8
●
●
si dos objetos son iguales, entonces deben tener el mísmo número hash.
si dos objetos son distintos, en la medida de lo posible, no tienen el mismo número
hash3. Si dos objetos distintos tienen el mismo número hash, se dice que hay una
colisión.
Para qué sirve un hash
Los arreglos permiten acceder a los elementos que almacenan de modo rápido y preciso
usando un entero como índice:
Persona[] lista = new Persona[100];
(...)
Persona juan = lista[15];
// una lista de 100 personas
Sin embargo hay otras situaciones en las que necesitamos acceder a un elemento del arreglo
usando como índice un tipo de dato distinto a un número. Por ejemplo, consideremos una clase que
implementa una lista de palabras y sus definiciones para un diccionario. Cada “entrada” del
diccionario consiste en la palabra y su definición:
class
}
Entrada {
String palabra;
String definicion;
Supongamos que nuestro diccionario está ya creado y almacenado en un arreglo
Si queremos buscar la definición de la palabra “perro” en el diccionario, no
podemos acceder directamente al elemento del diccionario que contiene la definición, porque no
sabemos en qué posición de diccionario está. Estamos forzados a usar algún método de búsqueda
para iterar los objetos Entrada de diccionario hasta encontrar el objeto en el que el atributo
palabra es igual a perro:
diccionario.
String searchString = “perro”;
String definicion = null;
for(int i = 0; i < diccionario.length; i++) {
if (diccionario[i].palabra.equals(searchString)) {
definicion = diccionario[i].definicion;
break;
}
}
En realidad, lo que desearíamos es poder acceder a los elementos de diccionario usando
la palabra que buscamos como índice:
String definicionDePerro = diccionario[“perro”];
Otros lenguajes (por ejemplo Python, Ruby, PHP) tienen un tipo de datos que usa un String
como índice del arreglo: este tipo de datos se llama diccionario, o también arrays asociativo. Sin
embargo, en Java los arrays asociativos no son parte del lenguaje4.
3 También se habla de funciones hash en criptografía. En ese contexto, las funciones hash tienen requisitos
adicionales que no son materia de este resumen. Se puede consultar, por ejemplo, BRUCE SCHNEIDER, Applied
Cryptography.
4 Se implementan usando una serie de clases de la librería.
9
La solución a este problema es usar una tabla de hash: usar una función hash para convertir
la llave (key) en un número entero; una vez que tenemos el número entero, podemos usarlo como
índice de un arreglo convencional para acceder a la data (el valor asociado).
Llaves
Índices (hashes)
5
7
8
9
10
perro
(...)
kiwi
agua
Pares llave-valor
agua: molécula formada...
perro: animal que ladra
kiwi: fruta neozelandesa
En teoría, con la función hash adecuada podríamos escribir algo así:
Entrada[] diccionario = new Entrada[1000];
/*
(...) aquí lleno de algún modo el diccionario con las definiciones
*/
String definicionDePerro = diccionario(hashCode(“perro”));
Sin embargo, la cosa no es tan sencilla. Para implementar una buena tabla hash, se necesita:
a) Un buena función hash. Para que el hash pueda usarse como índice de un arreglo de una
tabla hash, es necesario que el rango de valores que devuelve la función hash esté dentro de
un rango razonable. Si quiero implementar un diccionar de 1000 palabras, y el hash de
“perro” es 1.543.756, no sería lógico reservar espacio para un arreglo de millón y medio de
elementos.
Por eso, muchas veces el valor que devuelve la función hash es convertido en un
número que esté dentro del rango de índices del array (pero esto aumenta la probabilidad de
una colisión). Una técnica común es tomar el residuo de la división entera del resultado de
la función hash entre el número de posiciones de la tabla (se llama método de división).
La siguiente clase contiene dos funciones: RSHash, que genera un hash para un
objeto de tipo String; y remapHash, que remapea un valor de modo que esté dentro de las
dimensiones de la tabla (indicada por la variable tamanoTabla).
public class Hash {
int tamanoTabla = 1023;
// función hash (función “mágica”)
public long RSHash(String str)
{
int b = 378551;
int a = 63689;
long hash = 0;
for(int i = 0; i < str.length(); i++) {
hash = hash * a + str.charAt(i);
a = a * b;
}
return (hash & 0x7FFFFFFF);
} // end RSHash
public long remapHash(long hash) {
10
}
return hash % tamanoTabla;
// end remapHash
public Hash() {
System.out.println("Hashes --------------------------");
System.out.println("Hash para 'perro': " + RSHash("perro"));
System.out.println("Hash para 'gato':
" + RSHash("gato"));
}
System.out.println("\nHashes remapeados para a una tabla de "
+ tamanoTabla + " elementos");
System.out.println("--------------------------");
System.out.println("Hash para 'perro': "
+ remapHash(RSHash("perro")));
System.out.println("Hash para 'gato':
"
+ remapHash(RSHash("gato")));
public static void main(String[] args) {
new Hash();
}
}
Es deseable también que los números que produce la función hash estén distribuidos
de modo uniforme dentro del rango. Cuando parte de los resultados de la función hash se
acumulan alrededor de pocos valores, se dice que hay clustering.
Google Code tiene una página interesante con algunas de las funciones de hash que
usa Google en su buscador: http://goog-sparsehash.sourceforge.net/. En todo caso, el tema
de cómo diseñar una función hash está más allá de los objetivos del curso.
b) Un manejo adecuado de las colisiones. Si se produce una colisión5, la implementación de
la tabla hash debe solucionarla de algún modo.
Dos técnicas populares de resolución de colisiones son:
■
■
hash de encadenamiento (chaining) Se crea en cada dirección de la tabla hash una
lista enlazada (“lista encadenada”). Cuando más de un objeto distinto tiene el mismo
hash, se guarda en la lista enlazada que está en esa posición de la tabla hash.
el hash direccionadamente abierto (open addressing) Cuando se produce una
colisión, no se usa una lista enlazada, sino que se busca una posición libre en el
arreglo para guardar el valor. Esta técnica requiere una tabla de mayor tamaño que
los valores que se quieren almacenar.
Hay diversas estrategias para encontrar el siguiente valor libre: por ejemplo,
búsqueda lineal (linear probing), cuadrática (quadratic probing), etc.
El método hashCode de Object
La clase Object de Java define el método public int hashCode(). Las clases definidas
5 De la voz hash_table en Wikipedia (http://en.wikipedia.org/wiki/Hash_table): “To give an idea of the importance of
a good collision resolution strategy, consider the following result, derived using the birthday paradox. Even if we
assume that our hash function outputs random indices uniformly distributed over the array, and even for an array
with 1 million entries, there is a 95% chance of at least one collision occurring before it contains 2500 records.”
La paradoja del cumpleaños dice que si hay más de 23 personas en una habitación, la probabilidad de que dos
tengan cumplan años el mismo día es más del 50%.
11
en la librería de Java (por ejemplo, String) ya tienen un método hashCode adecuado. En cambio,
si necesitamos usar como llave (key) nuestros propios objetos, entonces será necesario hacer un
override del método hashCode.
La documentación de la librería de Java especifica qué condiciones tiene que cumplir el
método hashCode, que son similares a las que ya hemos considerado antes:
● Si hashCode es invocado más de una vez sobre el mismo objeto durante la ejecución del
programa, y el estado del objeto no ha cambiado, hashCode debe devolver siempre el
mismo número entero. Se considera que el estado del objeto ha cambiado cuando la
información que se usa en una comparación usando el método equals del objeto ha
cambiado (en otras palabras, basta que no cambien las variables que se usan en la
comparación). En cambio, no hace falta que el entero devuelto sea el mismo cada vez que se
ejecuta el programa.
● Si dos objetos se consideran iguales usando el método equals, entonces la llamada de
hashCode sobre cada uno de los objetos debe dar el mismo resultado.
● No es necesario que los dos objetos distintos produzcan resultados distintos.
Es conveniente revisar los apuntes del curso6 que están en la intranet para ver los modos más
usuales de manejar las colisiones y de implementar las tablas de hashes. También se puede
encontrar
un
buen
resumen
en
la
voz
Hash
table
de
Wikipedia
(http://en.wikipedia.org/wiki/Hash_table).
Entonces, ¿para qué necesitamos saber cómo funciona una
función hash?
Algunas de las clases de la librería de colecciones de Java implementan tablas de hash. Si
en esas colecciones usamos como llave un tipo de objeto que ya tenga un método hashCode
implementado, entonces no necesitamos hacer un override de hashCode.
En cambio, si usamos como llave del hash un objeto que no tiene hashCode implementado7,
entonces necesitamos hacer un override del método hashCode, porque de lo contrario la
colección no funcionará como esperamos (no podremos recuperar los objetos).
Comentarios, correcciones y sugerencias: Roberto Zoia (roberto.zoia@gmail.com)
This work is licensed under the Creative Commons Attribution-ShareAlike 2.5 License. To view a copy of this
license, visit http://creativecommons.org/licenses/by-sa/2.5/ or send a letter to Creative Commons, 543 Howard
Street, 5th Floor, San Francisco, California, 94105, USA.
6 GUEVARA ÁLVAREZ, ERNESTO. Apuntes de Programación Avanzada. Facultad de Ingeniería, Universidad de Piura.
Enero 2006.
7 Por ejemplo, una clase que nosotros hayamos definido.
12
Descargar