Tablas de Dispersión (Hash Tables) Introducción: Algoritmos de Búsqueda Un algoritmo de búsqueda consiste en buscar un elemento k en un conjunto A. El conjunto A está formado por elementos del mismo tipo y cada elemento, a su vez, está constituido por uno o más atributos. El atributo que identifica unívocamente a un elemento se lo denomina clave (key). En un conjunto no puede haber dos elementos con la misma clave. Ejemplos Conjunto Elementos Atributos de los elementos Clave Curso Estudiantes Matricula, apellido, nombre, año que cursa Matricula Biblioteca Libros Código, titulo, autor, tema Código Banco Clientes Número de cuenta corriente, apellido, nombre, Número saldo corriente de cuenta Formalmente el problema de búsqueda es: Dada una clave k y un conjunto A de elementos del mismo tipo, verificar si existe algún elemento en A cuya clave sea k. Cuando ningún atributo sirve de clave, se puede definir la clave como la concatenación de dos o más atributos. Por ejemplo, en la Universidad la clave de cada estudiante es facultad_matricula; las cuentas corrientes en un banco en general se identifican como nroDeSucursal_nroDeCtaCte. Debido a que la búsqueda es el algoritmo básico de la consulta, ésta debe ser lo más óptima posible en cuanto al tiempo. Pero tampoco podemos permitir un algoritmo sumamente ineficiente en espacio ni algoritmos demasiados lentos para las otras operaciones vinculadas con la administración de la información que son el insertar nuevos elementos (altas), eliminar elementos (bajas) y la generación de reportes ordenados. Entonces cabe preguntarse cómo guardar la información para obtener algoritmos de máximo rendimiento. La forma más eficiente en cuanto al tiempo es que la clave k sea un número entero mayor o igual a cero. La información se almacena en un arreglo A de manera que el elemento de clave k se guarda en A[k]. Conjunto de elementos Tabla de acceso directo . . k i I . . i j m J . J . k K . Alicia Gioia 1/23 Tablas de Dispersión Para recuperar el elemento de clave, tendremos simplemente que retornar A[k].Si la información es mucha y no cabe en memoria RAM, en lugar de un arreglo se utiliza un archivo, y tendremos que recuperar el registro k del archivo. Igualmente sencillos son los algoritmos de inserción y eliminación, ambos de O(1), como también la generación de un informe ordenado, siendo éste de O(n). EJERCICIO 1 a) Escribir las funciones para insertar y eliminar elementos de un conjunto de datos y la función para obtener un reporte ordenado considerando que los elementos se almacenan de tal forma que el elemento de clave k se guarda en la posición k de un arreglo. Inconvenientes de esta representación 1. Mal uso del espacio. Supongamos que se desea asignar como clave para los integrantes de un curso de 30 alumnos al número de su DNI. El número de DNI más bajo es 25.678.901 y el más alto 27.234.567. La diferencia entre ambos es 1.555.666. Si al alumno de DNI número 25.678.901 lo almacenamos en la posición cero del arreglo al alumno de DNI número 27.234.567 lo deberíamos almacenar en la posición 1.555.666. O sea que habría que definir un arreglo o archivo de 1.555.666 elementos sólo para guardar 30. ¡Absurdo!. 2. Elección de claves. Si bien las claves enteras son muy cómodas para el profesional de sistemas, no lo son para el usuario del mismo. Supongamos una fábrica que confecciona 10 modelos de ropa. Cada modelo se hace en 7 colores y en 6 talles, esto da 420 prendas. Podríamos numerarlas como 1, 2, ...., 420 pero para el usuario es mucho más difícil recordar que 300 es el pullover de cuello V de color celeste talle 46 que si la clave fuera, por ejemplo: P V C 4 6 P: pullover, V: cuello en V, C: color celeste, 46: número del talle En general, es deseable que las claves sean cadenas de caracteres no demasiadas largas y fáciles de recordar para el usuario del sistema. Una forma de evitar estos inconvenientes es almacenar la información en una lista. Una lista puede ser desordenada u ordenada según la clave y además puede estar implementada en forma estática (en un arreglo) o en forma dinámica (listas encadenadas). En la siguiente tabla se muestran los órdenes de los algoritmos básicos en las distintas implementaciones. Lista Desordenada Algoritmos Estática búsqueda O(n) inserción O(1) eliminación O(n) reportes ordenados (#) O(nlog2n) Ordenada Dinámica (*) Dinámica O(n) O(log2n) O(1) O(n) O(1) O(n) O(1) O(n) O(n) O(1) (+) Estática O(nlog2n) (+) (*) O(n) resulta de aplicar búsqueda binaria resulta de aplicar el algoritmo de quicksort para ordenar previamente los dados (#) resulta de agregar al final El menor tiempo de la operación de búsqueda se obtiene en la lista ordenada implementada en forma estática pero esta implementación es ineficiente para los algoritmos de inserción y eliminación frente a las otras implementaciones. Lo que buscamos son algoritmos eficientes en tiempo y espacio tanto para las búsquedas (+) Alicia Gioia 2/23 Tablas de Dispersión como también para las inserciones, eliminaciones y generación de reportes ordenados. En un árbol binario de búsqueda equilibrado tenemos que las operaciones de inserción, eliminación y recuperación son del orden logarítmico, pero es costoso tener el árbol equilibrado y cuando la información es mucha un orden logarítmico puede ser ineficiente. Una forma de almacenar la información para lograr todos los requisitos anteriores, excepto la generación de reportes ordenados, son las Tablas de Dispersión (Hash Tables). Definiciones y Conceptos Básicos Supongamos que el conjunto de claves posibles, denominado de ahora en más espacio de claves, sea el conjunto de números enteros no negativos, N. Supongamos también que tenemos una función h: N 0, 1, ... m-1 para algún m 1. Sea T un arreglo o un archivo de m posiciones donde en cada posición se encuentra la información relativa a un elemento o está vacía. Podemos representar un conjunto A de elementos poniendo T[j] = x para todo x A, con k = clave(x) y j = h(k). Conjunto de elementos Tabla de dispersión . . i K1 K2 . . K2 J m donde i = h(k2) j = h(k3) k =h(k1) K3 . . K3 k K1 . Ejemplo Sean m = 13, el conjunto de elementos A y el espacio de claves C: A = {(22, “José”, 1), (39, “Mario”, 1), (46, “María”, 1), (54, “Juan”, 2 ), (79, “Silvia”, 4 ), (198, “Martin”, 3)} C = {22, 39, 46, 54, 79, 198} Función h(k) = k % 13 Aplicando la función h(k) a cada clave, resulta: h(22) = 22 % 13 = 9 h(39) = 39 % 13 = 0 h(46) h(54) = = 46 % 13 = 7 54 % 13 = 2 h(79) = 79 % 13 = 1 h(198) = 198 % 13 = 3 Resultando el siguiente arreglo T: 0 39, “Mario”, 1 1 79, “Silvia”, 4 Alicia Gioia 3/23 Tablas de Dispersión 2 54, “Juan”, 2 3 198, “Martin”, 3 4 5 6 7 46, “María”, 1 8 9 22, “José”, 1 10 11 12 La función h se denomina función de dispersión (hash function), el arreglo T es la tabla de dispersión (hash table) o área primaria y h(k) es la dirección o posición en T de la clave k. Una función de dispersión transforma el espacio de claves en un conjunto de enteros de 0 a m-1, siendo m el tamaño o capacidad de la tabla. Si las claves son números enteros la función de dispersión más simple es h(k) = k % m. EJERCICIO 2 Sea m = 17 y el siguiente espacio de claves: {39, 35, 53, 43, 8, 50}. Hallar las direcciones a donde son dispersadas las claves si la función de dispersión es h(k) = k % 17. EJERCICIO 3 Encontrar el valor de m más pequeño tal que sea primo y el espacio de claves {14, 22, 39, 46, 54, 74, 198} se dispersen a direcciones distintas con la función de dispersión h(k) = k % m. Claves equivalentes y colisiones Si ahora agregamos al conjunto A del ejemplo anterior el elemento: (14, “Pedro”, 3). El espacio de claves es: {14} C y h(14) = 14 % 13 = 1 Vemos que tanto el elemento de clave 14 como el de clave 79 ocuparían la misma posición de la tabla. Cuando dos o más claves ocupan la misma posición se dicen que son claves equivalentes y se ha producido una colisión. Cuando se produce una colisión hay diversos caminos a tomar: Evitar las colisiones, ya sea Usando funciones de dispersión perfectas Aumentando la capacidad de la tabla de dispersión Permitir que haya colisiones y proveer mecanismos para su tratamiento. EJERCICIO 4 Agregar tres claves al espacio de claves del ejercicio 2 de manera que colisionen con la clave 43. Funciones perfectas Una función de dispersión que transforma cada clave del espacio de claves en un valor entero positivo distinto se dice perfecta. Es digno calcular cuantas funciones perfectas hay dado un conjunto de n claves y m posiciones (1 n m). Consideremos, por ejemplo, n = m = 2. Alicia Gioia 4/23 Tablas de Dispersión Las posibles funciones son: h1(C1) = 0 y h1(C2) = 0 h2(C1) = 0 y h2(C2) = 1 h3(C1) = 1 y h3(C2) = 0 h4(C1) = 1 y h4(C2) = 1 Hay cuatro funciones, de las cuales sólo h2 y h3 son perfectas. En este caso hay un 50% de funciones perfectas. Pero si consideramos n = m = 3, encontraremos que sólo el 26% de funciones son perfectas. En general puede demostrarse que la cantidad de funciones es mn siendo a lo sumo m!/(m-n)! la cantidad de perfectas. Así resulta que si n = 4 hay 9% de funciones perfectas y para n = 5 sólo un 3%. Para los valores de n que ocurren en la realidad (que está en los órdenes de cientos de miles o incluso millones) la búsqueda de funciones perfectas es imposible. EJERCICIO 5 Escribir todas las funciones que transformen un espacio de claves de tamaño 2 en los enteros 0, 1, 2. Indicar las perfectas. Verificar que la cantidad de funciones y la cantidad de funciones perfectas coinciden con las fórmulas dadas arriba. EJERCICIO 6 Escribir todas las funciones que transformen un espacio de claves de tamaño 3 en los enteros 0, 1. Indicar las perfectas. Tablas con más Capacidad La otra opción es usar tablas de dispersión con mayor capacidad. Supongamos, igual que antes que la capacidad de la tabla es m = 13, pero el espacio de claves es: C = 753, 546, 695, 125, 320, 276, 402, 855, 963, 689, 603, 662, 128 y h(k) = k % 13 k 753 546 695 125 320 276 402 855 963 689 603 662 128 h(k) 12 0 6 8 8 3 12 10 1 0 5 12 11 Así vemos que a dos claves distintas le corresponden la posición 0, produciéndose una colisión, otras dos van a la posición 8, produciéndose otra colisión y tres van a 12 produciéndose allí dos colisiones. Por otro lado las posiciones 2, 4, 7, y 9 están desocupadas, originando un 30% de lugar desperdiciado. Si duplicamos el tamaño de la tabla a m = 26 y por consiguiente usamos la función de dispersión h(k) = k % 26, obtenemos 2 colisiones pero un 57% de lugar desperdiciado. En la siguiente tabla se muestra para distintos valores de m, la cantidad de colisiones y el espacio desperdiciado. Alicia Gioia 5/23 Tablas de Dispersión m Colisiones Lugares libres 13 4 13% 16 5 50% 17 3 41% 26 2 57% En vista de los resultados, vemos que no tiene sentido agrandar demasiado la tabla. Lo aconsejable es que su capacidad sea aproximadamente un 20% mayor que la cantidad de claves y que además sea un número primo. Al cociente entre la cantidad de claves y la capacidad de la tabla se denomina factor de carga (se lo simboliza con la letra ) y es una medida de lo saturada que ésta se encuentra, esto es = n/m EJERCICIO 7 Una escuela tiene capacidad para 1000 alumnos. Calcular el valor de m adecuado para guardar la información en una tabla de dispersión. Funciones De Dispersión Si m es el tamaño de la tabla, la función de dispersión debe: garantizar que el valor h(k) k perteneciente al espacio de claves esté entre 0 y m-1 dispersar lo más uniformemente posible (evitar que haya un número excesivo de colisiones y demasiados lugares libres) debe ser calculable de modo eficiente, o sea, estar compuesta de un número reducido de operaciones aritméticas básicas. No hay reglas que permitan determinar cual será la función más apropiada para un conjunto de claves, con el fin de asegurar una uniformidad máxima. Hacer un análisis de las características de las claves, puede sin embargo ayudar en la elección de la misma. La implementación de la función de dispersión depende del tipo de clave. No va a ser la misma si la clave es un int, un float o un String A continuación algunas funciones a modo de ejemplo : Función Modulo: Esta función también llamada resto o residuo consiste en tomar el resto de la división entre la clave y la capacidad m. Estadísticamente se puede verificar que para una mayor uniformidad en la distribución, m debe ser un número primo, o al menos que sea divisible por pocos números. H(k) = (K % m) Función Cuadrado: Elevamos al cuadrado la clave y tomamos los dígitos centrales como dirección. El número de dígitos a tomar es determinado por el rango del claves (por ejemplo si tengo 100 claves, tomo solo 2 dígitos) H(k) = dig_centrales(K2) Alicia Gioia 6/23 Tablas de Dispersión Con claves no enteras la función de dispersión debe primero transformar la clave a un número entero denominado valor intermedio. Ejemplo Una universidad le asigna a cada estudiante una clave que está formada por la inicial del nombre más la inicial del apellido más 4 dígitos que corresponden al año de ingreso más un dígito que corresponde al semestre en que ingresó y un número consecutivo de 4 dígitos. Algunas claves posibles son: AG200020001 AG200020002 SM200110001 La universidad tiene aproximadamente 3000 alumnos, por consiguiente el área primaria debería tener una capacidad mayor en 20% a 3000. Elegimos como valor para m al número primo 3607. Supongamos que la función de dispersión fuera la suma de los valores ASCII de las letras más la suma de los dígitos y al valor intermedio obtenido le aplicamos la función resto. Resulta: AG200020001 65 + 71 + 5 = 142 142 % 3607 = 142 AG200020002 65 + 71 + 6 = 143 143 % 3607 = 143 SM200110001 83 + 77 + 5 = 165 165 % 3607 = 165 ¿Cuál es el valor intermedio más grande? ZZ199929999 90 + 90 + 66 = 226 y el valor más pequeño AA200010001 65 + 65 + 4 = 134 Como vemos el valor intermedio está en 134, 226. Como todos son menores que 3607, el resto es él mismo. Esto significa que las 3000 claves se transforman en alguno de los 92 valores que hay entre 134 y 226. Por lo tanto es de esperar que haya en promedio 3000/92 = 33 colisiones y 3515 lugares libre o sea el 97% de lugar desperdiciado. Esta función si bien cumple con el primer requisito, no cumple con el segundo. Otra forma de obtener el valor intermedio es multiplicar el valor ASCII de las letras por la suma de los dígitos. AG200020001 65 * 71 * 5 = 23075 23075 % 3607 = 1433 AG200020002 65 * 71 * 6 = 27690 27690 % 3607 = 2441 SM200110001 83 * 77 * 5 = 31955 31955 % 3607 = 3099 ZZ199929999 90 * 90 * 66 = 534600 534600 % 3607 = AA200010001 65 * 65 * 4 = 16900 764 16900 % 3607 = 2472 Como vemos esta función dispersa más uniformemente que la anterior. Observar que si k1 k2 no es siempre válido que h(k1) h(k2). En una tabla de dispersión no existe noción de orden, no hay anterior, ni sucesor, ni primero, ni último. Por esta razón las tablas de dispersión no sirven para obtener informes ordenados. A continuación veremos otras técnicas para construir funciones de dispersión. Consideraremos a modo de ejemplo a m = 3607. 1. Funciones de división Si las claves son enteros muy grandes para que quepan en una variable long int, las guardamos en cadenas y luego las partimos en dos del mismo número de dígitos y se suman, repitiendo el proceso hasta obtener una dirección válida. Alicia Gioia 7/23 Tablas de Dispersión 38.998.787 → 3.899 + 8.787 = 12.686 12.686 → 126 + 86 = 212 2. Funciones de truncamiento Eliminar alternativamente el primero y último dígitos, hasta obtener una dirección válida. 38.998.787.665 → 899.878.766 → 9.987.876 → 98.787 → 878 Truncar suficientes dígito en el medio de la cadena numérica, hasta obtener una dirección válida. 38.998.787.665 → 3865 3. Funciones sobre un espacio intermedio La clave puede ser alfanumérica. Se aplica primero una función que obtenga un valor entero y sobre el resultado obtenido se aplica la función resto. Por ejemplo podríamos multiplicar el código ASCII de cada carácter de la cadena y luego obtener el resto de la división entre este número y m: Si k = “PTRD0”, entonces h(k) = 80*84*82*68*48 % 3607 = 80 Si k = “ZZZZZ”, entonces h(k) = 90*90*90*90*90 % 3607 = 2938 Pero si las cadenas tienen longitud 6 el producto de los códigos ASCII va a dar overflow. Otra función de dispersión es tratar los caracteres de la clave k como los dígitos de un entero en una base b. Por ejemplo, b = 37 anda muy bien. Si ei es el código ASCII del carácter ci, entonces, h(k) = (ek * bk + ek-1 * bk-1 + . . . + e0 * b0) % m Si k = “PTRD0”, resulta h(k) = (80*374 + 84*373 + 82*372 + 68* 371 + 48*370) % 3607 = 3250 Esta expresión, aplicando la regla de Horner y las propiedades de la aritmética modular, puede ser escrita como: h(k) = ((((80*37 + 84)*37 + 82)*37 + 68)* 37 + 48) % 3607 = = (((((((80*37 + 84) % 3607) *37 + 82) % 3607) *37 + 68) % 3607)* 37 + 48) % 3607 evitando de esta manera problemas de overflow. Concatenar los dígitos que representan los caracteres ASCII de la clave. Si k = “CASA”, resulta h(k) = 67.658.465 % 3607 = 1966 Alicia Gioia 8/23 Tablas de Dispersión EJERCICIO 8 El plan de estudios de una carrera universitaria consta de 5 años y en cada año se cursan 12 asignaturas. Las claves de cada asignatura está formada por el número del año a la que pertenece más un entero consecutivo. Elegir un m y una función de dispersión adecuada. Especificación del TDA Tabla de Dispersión Nombre: TablaH Constructoras: crearTablaH: int /* Crea y retorna una tabla vacía con una capacidad dada */ precondición: int m, siendo m un número primo postcondición: TablaH t de tamaño m TablaH Modificadoras: insertar: TablaH X tipoelemento /* inserta un nuevo elemento en la tabla hash */ precondición: TablaH t; elemento e; e no pertenece a t postcondición: t con el nuevo elemento e eliminar: TablaH X tipoelemento TablaH /* elimina de la tabla hash el elemento cuya clave coincide con la dada*/ precondición: TablaH t; tipoelemento e con clave k; un elemento con clave k pertenece a t postcondición: t sin el elemento con clave k TablaH Analizadoras: recuperar: TablaH X tipoelemento tipoelemento /* recupera de la tabla hash el elemento cuya clave coincide con la del elemento dado */ precondición: TablaH t; tipoelemento e con clave k; k pertenece a t postcondición: elemento de la tabla tal que la clave sea k existe: TablaH X tipoelemento boolean /* verifica si existe en la tabla un elemento cuya clave coincida con la del elemento dado */ precondición: TablaH t; tipoelemento e con clave k; postcondición: verdadero si existe un elemento cuya clave coincide con la dada; falso en caso contrario obtenerArBinBus: TablaH ArBinBus /* obtiene un árbol binario de búsqueda con los elementos de la tabla hash para su posterior impresión */ precondición: TablaH t; postcondición: ArBinBus a Destructora: destruirTablaH: TablaH /* libera la memoria ocupada por una tabla hash */ precondición: TablaH t postcondición: - Alicia Gioia 9/23 - Tablas de Dispersión Estrategias De Resolución De Colisiones Y De Desbordamiento De Tabla Antes de codificar las operaciones descritas en la especificación debemos resolver dos problemas: cómo tratar las colisiones qué hacer cuando el valor de m queda chico (desbordamiento de tabla). Para el primer problema hay varias técnicas de resolución, éstas son: 1) Dispersión abierta 2) Dispersión cerrada i) con exploración lineal ii) con exploración cuadrática iii) con dispersión doble iv) con dispersión coalescente Para el problema de desbordamiento de tabla consideraremos las siguientes técnicas: 1) Expansión de la tabla 2) Dispersión extensible. Resolución de colisiones Dispersión abierta Esta estrategia consiste en construir la tabla de dispersión como un arreglo de listas, donde cada lista está formada por los elementos que tienen claves equivalentes. Así el ejemplo 1 da la siguiente tabla: 0 . 39, “Mario”, 1 1 . 79, “Silvia”, 4 2 . 54, “Juan”, 2 3 . 198, “Martin”, 3 4 5 6 7 . 46, “María”, 1 22, “José”, 1 8 9 . 10 11 12 Si agregamos los siguientes elementos: (14, “Oscar”,3), (41,”Roberto”,2) y (67,”Mariela”, 3) en ese orden, la tabla queda: Alicia Gioia 10/23 Tablas de Dispersión 0 . 39, “Mario”, 1 1 . 14, “Oscar”, 3 . 79, “Silvia”, 4 2 . 67, “Mariela”, 3 . 41,”Roberto”,2 . 3 . 198, “Martin”, 3 4 5 46, “María”, 1 22, “José”, 1 6 7 . 8 9 . 10 11 12 54, “Juan”, 2 Cuando busquemos una clave vamos a tener que recorrer la lista que le corresponda hasta encontrar nuestro elemento. Si la función de dispersión distribuye uniformemente entonces el tamaño promedio de cada lista es . Las listas pueden dejarse desordenadas o bien mantenerlas ordenadas. Lo más frecuente es usar listas desordenadas porque son más eficientes para la implementación de la operación insertar y como generalmente son listas pequeñas la búsqueda no se degrada. En el caso que utilicemos la tabla en su mayor parte haciendo búsquedas va a ser útil mantener las listas ordenadas, con esto lograremos acelerar las búsquedas por un factor de 2 a cambio de una inserción más lenta. ¿Cómo elegir el tamaño m de la tabla? Deberíamos elegir un valor de m que sea suficientemente pequeño de manera que no estemos derrochando una gran cantidad de memoria contigua con punteros a vacío, pero lo suficientemente grande de modo que las búsquedas secuenciales sean eficientes. Como ya dijimos, la práctica indica que debemos elegir un m que sea aproximadamente entre 1/5 y 1/10 mayor que la cantidad de claves que esperamos que se ingresen en la tabla. Una de las mayores virtudes de la dispersión abierta es que esta decisión no es crítica: si se ingresan más claves de las esperadas, simplemente las búsquedas van a ser un poco más lentas que con un m más grande. Mientras que si se ingresan menos claves de las esperadas, tendremos búsqueda muy eficientes con tal vez cierto desperdicio de espacio. Implementación en Java public class TablaHash { // Implementacion de algunos metodos de // Tabla Hash con resolucion dinamica de colisiones // El objeto que se inserta debe implementar las interfaces Comparable y Hashable. // Autor Alicia Gioia private ListaD t[]; private int capacidad; public TablaHash(int M) { if (!Primo.esPrimo(M)) M = Primo.proxPrimo(M); capacidad = M; t = new ListaD[M]; for(int i = 0; i < M ; i++) t[i] = new ListaD(); Alicia Gioia 11/23 Tablas de Dispersión } public void insertar (Object x) { int k =((Hashable) x).hash(capacidad); t[k].insertar(x); } public Object buscar (Object x) { int k = ((Hashable) x).hash(capacidad); t[k].irPrimero(); int l = t[k].longitud(); for (int i = 0 ; i < l ; i ++ ) if (((Comparable) x).compareTo(t[k].recuperarActual())== 0) return t[k].recuperarActual(); return x; } public ArBinBus obtenerArBinBus () { ArBinBus a = new ArBinBus(); for (int i = 0; i < capacidad; i++ ) { if (!t[i].esVacia()) { t[i].irPrimero(); for (int j = 0; j < t[i].longitud() ; j++) a.insertar(t[i].recuperarActual()); t[i].irSiguiente(); j++; } } } return a; } } public interface Comparable { int compareTo(Object x); } public interface Hashable { int hash(int M); } public class Primo { public static boolean esPrimo(int n) { if (n == 1 || n == 2 || n == 3) return true; if (n % 2 == 0) return false; else { int k = 3; while (k <= Math.sqrt(n)) { if (n % k == 0) return false; k = k +2; } } return true; } public static int proxPrimo(int n) { if (n % 2 == 0) n++; Alicia Gioia 12/23 { Tablas de Dispersión while (!esPrimo(n)) n = n + 2; return n; } } EJERCICIO 9 Dado el siguiente conjunto de claves {4371,, 1323, 6173, 4199, 4344, 9679, 1989} y la función de dispersión h(k) = k % 10, mostrar cómo resulta la tabla de dispersión abierta. EJERCICIO 10 Codificar el resto de las operaciones indicadas en la especificación. Dispersión cerrada En la dispersión cerrada todos los elementos se almacenan en la propia tabla. En este caso las colisiones se resuelven calculando una secuencia de huecos en la tabla. Esta secuencia explora la tabla hasta que se encuentra un hueco en el caso de insertar o se encuentra la clave en el caso de recuperar o eliminar. Básicamente la dispersión cerrada consiste en aplicar la función de dispersión, si el lugar está libre colocamos el elemento sino “exploramos” la tabla hasta encontrar un lugar libre. La exploración se realiza calculando las posibles posiciones para una clave k con la siguiente función: di(k) = (h(k) + f(i)) % m para i = 1, 2, 3, ... Exploración lineal Se elige a f(i) = i, así la función de exploración queda: di(k) = (h(k) + i) % m para i = 1, 2, 3, ... En el ejemplo anterior inicialmente tenemos: Alicia Gioia 13/23 Tablas de Dispersión 0 39, “Mario”, 1 1 79, “Silvia”, 4 2 54, “Juan”, 2 3 198, “Martin”, 3 4 5 6 7 46, “María”, 1 8 9 22, “José”, 1 10 11 12 Al agregar el elemento (14, “Oscar”,3), resulta que h(14) = 14 % 13 = 1 y este lugar está ocupado. Calculamos entonces 39, “Mario”, 1 0 d1(14) = (h(14) + 1) % 13 = 2 (también está ocupado) 79, “Silvia”, 4 Calculamos 1 54, “Juan”, 2 d2(14) = (h(14) + 2) % 13 = 3 (también está ocupado) 2 Calculamos 198, “Martin”, 3 3 d3(14) = (h(14) + 3) % 13 = 4 (está libre) 14, “Oscar”,3 4 5 6 7 46, “María”, 1 8 9 22, “José”, 1 10 11 12 Ahora insertamos (41,”Roberto”,2). Aplicamos h(42), resultando 3, que está ocupado. Calculamos d1(41) que da 4, lugar ocupado y finalmente d2(41) da 5 y ahí lo insertamos. Finalmente al agregar el elemento (67,”Mariela”, 3), procediendo de la misma manera, éste cae en la posición 6. Resultando la tabla: Alicia Gioia 14/23 Tablas de Dispersión 0 39, “Mario”, 1 1 79, “Silvia”, 4 2 54, “Juan”, 2 3 198, “Martin”, 3 4 14, “Oscar”,3 5 41,”Roberto”,2 6 67,”Mariela”, 3 7 46, “María”, 1 8 9 22, “José”, 1 10 11 12 0. 9 0. 8 0. 6 0. 7 0. 3 0. 4 0. 5 100 90 80 70 60 50 40 30 20 10 0 0 0. 1 0. 2 probes La ventaja de este enfoque es que evita el uso de punteros. La memoria que se ahorra al no almacenar punteros puede utilizarse para construir una tabla más grande que probablemente conduzca a menos colisiones. Ahora vamos a necesitar indefectiblemente una tabla más grande, tal que el tamaño m sea mayor que la cantidad n de elementos. El factor de carga es crítico, como se ve en la siguiente gráfica: Factor de carga En la exploración lineal se produce además un problema conocido como agrupamiento primario. Los elementos, como vemos en el ejemplo, tienden a juntarse y en consecuencia la búsqueda se hace más lenta. Por ejemplo para buscar el elemento de clave 67, debemos calcular h(67) = 2, comparamos 67 con la clave guardada en 2, si coinciden ya está sino calculamos d1(67) = 3, comparamos 67 con la clave guardada en 3, si coinciden ya está sino continuamos calculando d2(67), d3(67), y así hasta encontrarla. La razón por la cual se producen estos agrupamientos es la siguiente: Supongamos que todas las posiciones de la tabla sean equiprobables y las i posiciones anteriores a una posición p estén ocupadas. Alicia Gioia 15/23 Tablas de Dispersión p-i ocupada . ocupada p-2 ocupada p-1 ocupada P libre Todas las claves que deberían ir a p, p-1, p-2, ...,p-i van a ir a p, por lo tanto la probabilidad que una clave vaya a p es (i+1)/m. En cambio si la celda anterior está desocupada la probabilidad que una clave vaya a p es 1/m. EJERCICIO 11 Dado el siguiente espacio de claves {4371, 1323, 6173, 4199, 4344, 9679, 1989} y la función de dispersión h(k) = k % 10, mostrar cómo resulta la tabla de dispersión cerrada con exploración lineal. Exploración cuadrática La exploración cuadrática es un método de resolución de colisiones que mejora el problema del agrupamiento primario (Clustering) de la exploración lineal. El proceso es similar al anterior, pero mientras que la función de inspección de la exploración lineal es f(i) = i ; ahora vamos a usar una función cuadrática f(i) = i2, entonces, en una inserción cuando encontremos un lugar ocupado avanzaremos primero 1, luego 2 2, luego 32 ,etc. hasta encontrar un lugar vacío. Si bien exploración cuadrática mejora sustancialmente el problema del agrupamiento primario, tiene la desventaja de que pueden quedar celdas sin visitar (Clustering Secundario), en particular, si la tabla esta llena en más de un 50% (>0.5) , no hay garantías de encontrar una celda vacía si el tamaño de la tabla no es PRIMO. ¿Cómo borrar una clave de una tabla con exploración lineal o cuadrática? No podemos simplemente borrarla, porque los elementos que fueron insertados después no van a ser encontrados ya que la búsqueda va a ser interrumpida por el “agujero” que dejo el borrado. Supongamos tener la siguiente tabla donde el valor –1 indica que esa posición está libre Buscamos con exploración lineal la clave 32. Como h(32) da 6 comparamos 32 con la clave que está en la posición 6. Como ésta es 67, calculamos d1(32), resultando 7, como este lugar está ocupado por la clave 46, calculamos d2(32), dando 8, como este lugar está libre concluimos que la clave 32 no está en la tabla, lo que es correcto. Alicia Gioia 16/23 0 39, “Mario”, 1 1 79, “Silvia”, 4 2 54, “Juan”, 2 3 198, “Martin”, 3 4 14, “Oscar”,3 5 41,”Roberto”,2 6 67,”Mariela”, 3 7 46, “María”, 1 8 -1 9 22, “José”, 1 10 -1 11 -1 12 -1 Tablas de Dispersión Supongamos, ahora que eliminamos el 41. La tabla queda: Buscamos ahora el 67, la secuencia va a ser 2, 3, 4 y 5. Como la posición 5 está libre concluiremos que el 67 no está en la tabla lo cual es erróneo. Eso se debe que la posición 5 antes estuvo ocupada y ahora está libre. Hay que diferenciar una posición que está vacía porque nunca fue ocupada y otra que está vacía pero alguna vez estuvo ocupada. Le podemos agregar a cada elemento una marca o centinela cuyos posibles valores pueden ser: Valor de marca 0 Significado libre (nunca fue ocupado) 1 ocupado 2 libre (hubo un elemento que fue eliminado) 0 39, “Mario”, 1 1 79, “Silvia”, 4 2 54, “Juan”, 2 3 198, “Martin”, 3 4 14, “Oscar”,3 5 -1 6 67,”Mariela”, 3 7 46, “María”, 1 8 -1 9 22, “José”, 1 10 -1 11 -1 12 -1 EJERCICIO 12 Codificar las operaciones descritas en la especificación de tabla de dispersión utilizando la estrategia de dispersión cerrada con exploración lineal. Dispersión doble Una solución para eliminar casi completamente el agrupamiento tanto primario como secundario de los métodos citados anteriormente, es dispersión doble, en donde di(k) = (h1(k) + ih2(k)) % m para i = 1, 2, ..... Tanto h1 como h2 son dos funciones de dispersión. Dispersión coalescente Esta forma es similar a la dispersión abierta excepto que todos los elementos se almacenan en la propia tabla. Esto se consigue permitiendo que en cada celda se almacene un elemento y un “puntero”. Estos “punteros” almacenan el valor –1 o bien la dirección de alguna celda. Durante la inserción una colisión se resuelve insertando el elemento con el número más alto que esta libre y haciendo el encadenamiento. Ejemplo Sea m = 7; h(k) = k % 7 y espacio de claves {4, 8, 9, 2, 13, 6} Inicialmente la tabla es: Alicia Gioia 17/23 Tablas de Dispersión 0 -1 1 -1 2 -1 3 -1 4 -1 5 -1 6 -1 0 -1 Luego de insertar el 4, el 8 y el 9, 1 8, -1 2 9, -1 -1 3 4 4, -1 5 -1 6 -1 Al aplicar la función hash a la clave de valor 2, resulta que su posición es 2, pero este lugar está ocupado por la clave de valor 9, entonces guardamos el 2 en la posición más alta que está vacía (en este ejemplo es la número 6) y el puntero de la clave 9 toma el valor 6. -1 0 1 8, -1 2 9, 6 -1 3 4 4, -1 -1 5 6 2, -1 La clave de valor 13 también le corresponde la posición 6, pero como está ocupada la guardamos en la posición 5 (que es la más alta libre). -1 0 1 8, -1 2 9, 6 -1 3 4 4, -1 5 13, -1 6 2, 5 Finalmente al insertar la clave de valor 6, la tabla resulta: Alicia Gioia 18/23 Tablas de Dispersión -1 0 1 8, -1 2 9, 6 3 6, -1 4 4, -1 5 13, 3 6 2, 5 Veamos ahora cómo realizamos las búsquedas, clave a buscar h(k) = k % 7 6 h(6) = 6 15 h(15) = 1 20 h(20) = 6 6 es igual a 2 ? NO 6 es igual a 13? NO 6 es igual a 6? SI prox. dirección: 5 prox. dirección: 3 BUSQUEDA EXITOSA 15 es igual a 8? NO prox. dirección: -1 BUSQUEDA NO EXITOSA 20 es igual a 2? NO 20 es igual a 13? NO 20 es igual a 6? NO prox. dirección: 5 prox. dirección: 3 prox. dirección: -1 BUSQUEDA NO EXITOSA En la eliminación debemos actualizar los punteros. Por ejemplo, si eliminamos el 13 resulta: -1 0 1 8, -1 2 9, 6 3 6, -1 4 4, -1 -1 5 6 2, 3 Una variante que mejora el manejo de los espacios libres es dividir la tabla en dos partes: la zona principal y la zona de desbordamiento o excedentes. La función hash da resultados que caen sólo en la zona principal. Alicia Gioia 19/23 Tablas de Dispersión -1 0 1 8, -1 2 9, 7 -1 3 4 4, -1 -1 5 6 13, 8 7 2, -1 8 6, -1 9 10 } Zona Principal } Zona Excedentes Estudios empíricos recomiendan alrededor de 15% a la zona de excedentes. Mejora el tiempo a costa de espacio. EJERCICIO 13 Dado el siguiente espacio de claves {4371, 1323, 6173, 4199, 4344, 9679, 1989} y la función de dispersión h(k) = k % 10, mostrar cómo resulta la tabla de dispersión coalescente . Desbordamiento de la tabla Hasta este punto, hemos asumido que el tamaño m de la tabla de dispersión será siempre suficientemente grande como para acomodar los conjuntos de datos con los que estamos trabajando. En la práctica, sin embargo, debemos considerar la posibilidad de una inserción en una tabla llena (esto se denomina desbordamiento de la tabla). Si se está utilizando dispersión abierta, como ya hemos dicho, éste no es habitualmente un problema dado que el tamaño total de las cadenas sólo está limitado por la memoria disponible. Obviamente en dispersión cerrada éste es un problema muy importante. Consideraremos dos técnicas que ressuelven el problema del desbordamiento de la tabla asignando memoria adicional. Redispersión (re-hashing) Cuando las tablas se llenan demasiado, el tiempo de ejecución de algunas operaciones va a empezar a ser muy largo. Sobre todo cuando combinamos muchas inserciones con borrados. Una solución es crear otra tabla que sea el doble de grande (con una nueva función de dispersión asociada) y procesar la tabla de dispersión original entera, computando el nuevo valor de dispersión para cada elemento e insertándolo en la nueva tabla. Esta operación se denomina redispersión. Es una operación muy costosa (orden N), pero dado que no va a pasar muy frecuentemente, no está nada mal y su efecto va a pasar prácticamente desapercibido. Solo el desafortunado usuario cuya inserción provoque una redispersión va a sentir el efecto. En que momento aplicar la redispersión queda a criterio del programador, algunas posibilidades son: - Aplicar redispersión cada vez que la tabla este llena en más de un 50% Aplicar redispersión sólo cuando una inserción falla - Aplicar redispersión cuando el factor de carga supera un valor estipulado (por ejemplo 0.8 ). EJERCICIO 14 Aplicar redispersión a la tabla del ejercicio 11. Alicia Gioia 20/23 Tablas de Dispersión Dispersión Extensible Una variante más efectiva de la redispersión es la dispersión extensible. En lugar de tener una única tabla de dispersión se tienen varias. Supongamos que un valor adecuado para m sea 43, entonces en lugar de tener una tabla de 43 posiciones tendríamos, por ejemplo, 4 de 11. Cuando la tabla grande esté ocupada en aproximadamente 34 posiciones ( 0.80) es el momento de aplicar la redispersión, mientras que en las tablas de 11 elementos la redispersión habría que aplicarla cuando estén ocupados unos 9 lugares. En la dispersión extensible el proceso de dispersión se realiza en dos partes. Primero se calculan los primeros bits de la clave para ver que tabla le corresponde y luego se aplica la función residuo para calcular la posición dentro de la tabla. Más formalmente, si D es el número de bits usados para definir la tabla, la cual a veces se llama directorio, el número de entradas en el directorio es 2D. Supongamos que nuestros datos consisten en enteros de seis bits y D = 2 (el directorio tendrá como entradas 00, 01, 10 y 11). Claves = {40, 11, 32, 4, 44, 57, 56, 8, 46, 20, 24, 10} Como la representación en binario de 40 es 101000 le corresponde la entrada 10, y como h(40) = 40 % 11 = 7, ocupará la posición 7. Continuando de la misma manera con el resto de las claves, resulta. Directorio 00 . 01 . 10 . 11 . 11 4 8 24 44 20 46 56 10 40 32 57 Si ahora insertamos las claves 36, 43, 39, 38, 45, la tabla queda de la siguiente manera: 00 . 01 . 10 . 11 . 11 4 8 24 44 43 46 56 57 10 20 36 45 39 38 40 32 Como la tabla de entrada 10 tiene un factor de carga de aproximadamente 0.8, ampliamos el tamaño del directorio no el de la tabla. Así D pasa a valer 3. Alicia Gioia 21/23 Tablas de Dispersión 000 . 11 4 8 10 001 24 010 . 20 011 36 100 . 101 44 110 . 45 46 56 57 38 39 32 40 43 111 Observar que las tablas que no fueron partidas están apuntadas por dos entradas de directorio adyacentes. Si por ejemplo se llena la tabla que está apuntada por 000 y 001, no duplicamos el directorio sino que creamos una nueva tabla que será apuntada por 001 y redispersamos sólo los elementos de esa tabla. La ventaja de esta estrategia es que se redispersan tablas más pequeñas con el consecuente ahorro de tiempo. EJERCICIOS 15 Mostrar el resultado de insertar las claves 10111101,00000010, 10011011, 10111110, 01111111, 01010001, 10010110, 00001011, 11001111, 10011110, 11011011, 00101011, 01100001, 11110000, 01101111 en una estructura de datos de dispersión extensible inicialmente vacía con m = 4 y D =2. Resumen Las tablas de dispersión se pueden usar para implantar las operaciones insertar y buscar en tiempo medio constante. Es de especial importancia cuidar detalles como el factor de carga cuando se emplean tablas de dispersión, ya que de otra forma las cotas de tiempo no son válidas. También es importante escoger con cuidado la función de dispersión. Para la dispersión abierta, el factor de carga debe ser cercano a 1, aunque el rendimiento no se degrada significativamente a menos que el factor de carga crezca mucho. Para dispersión cerrada, el factor de carga no debe exceder 0.5. Si se usa exploración lineal, el rendimiento se degenera con rapidez conforme el factor de carga se acerca a 1. Si se programa en un lenguaje (C, C++, Java) que permite la asignación de arreglos sin conocer su tamaño en tiempo de compilación, entonces se puede implantar la redispersión. Esta permite que la tabla crezca y se contraiga para mantener un factor de carga razonable. En comparación con los árboles binarios de búsqueda, sabemos que en éstos las operaciones de insertar y buscar son O(log2n) y permiten rutinas que permiten orden y por ello son más potentes. Con una tabla de dispersión, no es posible encontrar el elemento mínimo ni máximo. Tampoco es posible buscar parte de la clave. Un árbol binario de búsqueda podría encontrar rápidamente todos los elementos en un intervalo dado; esto no es posible en las tablas de dispersión. además, la cota O(log2n) no es mucho más que O(1), pero si la entrada de datos es más o menos ordenada puede provocar un rendimiento deficiente en los árboles. Es muy costoso implantar árboles balanceados, así que si no se requiere información de ordenamiento y hay alguna sospecha de que la entrada podría estar ordenada entonces la dispersión es la estructura ordenada. Las aplicaciones de la dispersión son abundantes. Son utilizadas: por los compiladores para seguir el rastro de las variables declaradas en el código fuente; en problemas de teoría de grafos; en los programas de juegos; en revisores de ortografía en línea. Bibliografía Knuth, The art of computer programming Alicia Gioia 22/23 Tablas de Dispersión Weiss, Estructuras de datos y algoritmos Wood, Data strucures, algorithms and perfomance Heileman, Estructuras de datos, algoritmos y programación orientada a objetos. Villalobos, Diseño y manejo de estructuras de datos en C Alicia Gioia 23/23