SlideShare a Scribd company logo
1
Benjamín Joaquín Martínez
Linkedin:
https://www.linkedin.com/in/benjaminjoaquinmartinez/
“Manipulación de conjuntos”
2
Índice
Técnicas básicas de ordenación
Quicksort ………………………………………………….…………………………………………………. 3
Mergesort ………………………………………………….…………………………………………………. 11
Estructuras de datos para manipulaciónde conjuntos
Árbolesbinarios ………………………………………………….…………………………………………………. 20
AVL ………………………………………………….…………………………………………………. 27
Árboles2-3 ………………………………………………….…………………………………………………. 38
Hashing ………………………………………………….…………………………………………………. 43
Algoritmosde selección
Max-Min ………………………………………………….…………………………………………………. 61
K-ésimo ………………………………………………….…………………………………………………. 65
Colas de prioridad
Heaps ………………………………………………….…………………………………………………. 70
Heapsort ………………………………………………….…………………………………………………. 74
Memoriasecundaria
ÁrbolesB ………………………………………………….…………………………………………………. 80
Hashing extendible ………………………………………………….…………………………………………………. 88
Uniónfind………………………….………………………………………………………… 91
3
Técnicas básicas de ordenación
La ordenaciónde losdatosconsisteendisponeroclasificarunconjuntode datos(ounaestructura)
en algún determinado orden con respecto a alguno de sus campos. Orden: Relación de una cosa con
respectoa otra.Clave:Campopor el cual se ordena.• Una listade datosestáordenadapor la clave ksi la
lista está en orden con respecto a la clave anterior. Este Orden puede ser: − Ascendente: (i<=k[j]) −
Descendente: (i>j) entonces (k[i]>=k[j])
Quicksort
Como el ordenamiento por mezcla, el ordenamiento rápido utiliza divide y vencerás, así
que es un algoritmo recursivo. La manera en que el ordenamiento rápido utiliza divide y vencerás
es un poco diferente de como lo hace el ordenamiento por mezcla. En el ordenamiento por mezcla,
el paso de dividir casi no hace nada, y todo el trabajo real ocurre en el paso de combinar. El
ordenamiento rápido es lo contrario: todo el trabajo real ocurre en el paso de dividir. De hecho, el
paso de combinar en el ordenamiento rápido no hace absolutamente nada.
El ordenamiento rápido tiene un par de otras diferencias con el ordenamiento por mezcla.
El ordenamiento rápido trabaja in situ. Y el tiempo de ejecución de su peor caso es tan malo como
el del ordenamiento por selección y el ordenamiento por inserción: Θ(𝑛2
). Pero el tiempo de
ejecución de su caso promedio es tan bueno como el del ordenamiento por mezcla: Θ(n𝑙𝑜𝑔2n).
¿Entonces por qué pensar acerca del ordenamiento rápido cuando el ordenamiento por mezcla es
por lo menos igual de bueno? Eso es porque el factor constante escondido en la notación Θ grande
para el ordenamiento rápido es bastante bueno. En la práctica, el ordenamiento rápido tiene un
mejor desempeño que el ordenamiento por selección y tiene un desempeño significativamente
mejor que el ordenamiento por inserción.
4
Aquí está cómo el ordenamiento rápido usa divide y vencerás. Como con el ordenamiento
por mezcla, piensa en ordenar un subarreglo array[p..r], donde inicialmente el subarreglo
es array[0..n-1].
1. Divide al escoger cualquier elemento en el subarreglo array[p..r]. Llama a este
elemento el pivote. Reorganiza los elementos en array[p..r] de modo que todos los elementos
en array[p..r] que sean menores o iguales que el pivote estén a su izquierda y todos los elementos
que sean mayores que el pivote estén a su derecha. A este procedimiento lo llamamos hacer una
partición. En este punto, no importa qué orden tengan los elementos a la izquierda del pivote en
relación con ellos mismos, y lo mismo ocurre para los elementos a la derecha del pivote. Solo nos
importa que cada elemento esté en algún lugar del lado correcto del pivote.
Como una cuestión práctica, siempre vamos a escoger el elemento de hasta la derecha en el
subarreglo, array[r], como el pivote. Así que, por ejemplo, si el subarreglo consiste de [9, 7, 5, 11,
12, 2, 14, 3, 10, 6], entonces escogemos 6 como el pivote. Después de hacer la partición, el
subarreglo podría verse como [5, 2, 3, 6, 12, 7, 14, 9, 10, 11]. Sea q el índice de dónde está el
pivote.
2. Vence al ordenar de manera recursiva los subarreglos array[p..q-1] (todos los
elementos a la izquierda del pivote, los cuales deben ser menores o iguales que el pivote)
y array[q+1..r] (todos los elementos a la derecha del pivote, los cuales deben ser mayores que el
pivote).
3. Combina al hacer nada. Una vez que el paso de conquistar ordena de manera
recursiva, ya terminamos. ¿Por qué? Todos los elementos a la izquierda del pivote, en array[p..q-
1], son menores o iguales que el pivote y están ordenados, y todos los elementos a la derecha del
5
pivote, en array[q+1..r], son mayores que el pivote y están ordenados. ¡Los elementos
en array[p..r] tienen que estar ordenados!
Piensa acerca de nuestro ejemplo. Después de ordenar de manera recursiva los subarreglos
a la izquierda y a la derecha del pivote, el subarreglo a la izquierda del pivote es [2, 3, 5], y el
subarreglo a la derecha del pivote es [7, 9, 10, 11, 12, 14]. Así que el subarreglo tiene [2, 3, 5],
seguido de 6, seguido de [7, 9, 10, 11, 12, 14]. El subarreglo está ordenado.
Los casos base son subarreglos con menos de dos elementos, justo como en el ordenamiento
por mezcla. En el ordenamiento por mezcla nunca ves un subarreglo sin elementos, pero si puedes
verlos en el ordenamiento rápido si los otros elementos en el subarreglo son todos menores que el
pivote o son todos mayores que el pivote.
Volvamos al paso de vencer y recorramos el ordenamiento recursivo de los subarreglos.
Después de hacer la primera partición tenemos los subarreglos [5, 2, 3] y [12, 7, 14, 9, 10, 11], con
6 como el pivote.
Para ordenar el subarreglo [5, 2, 3], escogemos a 3 como el pivote. Después de hacer la
partición, tenemos [2, 3, 5]. El subarreglo [2], a la izquierda del pivote, es un caso base cuando
hacemos recursividad, como lo es el subarreglo [5], a la derecha de pivote.
Para ordenar el subarreglo [12, 7, 14, 9, 10, 11], escogemos 11 como el pivote, que resulta
en [7, 9, 10] a la izquierda del pivote y [14, 12] a la derecha. Después de que estos subarreglos
están ordenados, tenemos [7, 9, 10], seguido de 11, seguido de [12, 14].
Aquí está cómo se desarrolla todo el algoritmo del ordenamiento rápido. Las ubicaciones
de los arreglos en azul han sido pivotes en llamadas recursivas anteriores, así que los valores en
estas ubicaciones no se van a volver a examinar o mover:
6
Tiempo de ejecución del peor caso
Cuando el ordenamiento rápido siempre tiene las particiones más desbalanceadas posibles,
entonces la llamada original tarda un tiempo cn para alguna constante c, la llamada recursiva sobre
n−1elementos tarda un tiempo c(n-1), la llamada recursiva sobre n-2 elementos tarda un tiempo
c(n-2) y así sucesivamente. Aquí está un árbol de los tamaños de los subproblemas con sus tiempos
para hacer las particiones:
7
Cuando sumamos los tiempos para hacer las particiones para cada nivel, obtenemos
cn+c(n−1)+c(n−2)+⋯+2c =c(n+(n−1)+(n−2)+⋯+2) =c((n+1)(n/2)−1) .
Tenemos algunos términos de orden inferior y coeficientes constantes, pero los
ignoramos cuando usamos notación Θ grande. En notación Θ grande, el tiempo de ejecución del
peor caso del ordenamiento rápido es Θ(𝑛2
).
Tiempo de ejecución del mejor caso
El mejor caso del ordenamiento rápido ocurre cuando las particiones están balanceadas tan
uniformemente como sea posible: sus tamaños son iguales o difieren en 1. El primer caso ocurre si
el subarreglo tiene un número impar de elementos, el pivote está justo en medio después de hacer
la partición y cada partición tiene (n-1)/2 elementos. El otro caso ocurre si el subarreglo tiene un
8
número par n de elementos y una partición tiene n/2 elementos mientras que la otra tiene n/2-1. En
cualquiera de estos casos, cada partición tiene a lo más n/2 elementos, y el árbol de los tamaños de
los subproblemas se parece mucho al árbol de los tamaños de los subproblemas para el
ordenamiento por mezcla, con los tiempos para hacer las particiones que se ven como los tiempos
de mezcla:
Al usar notación Θ grande, obtenemos el mismo resultado que para un ordenamiento por
mezcla: Θ(n𝑙𝑜𝑔2n).
Tiempo de ejecución del caso promedio
Mostrar que el tiempo de ejecución del caso promedio también es Θ(n𝑙𝑜𝑔2n). requiere unas
matemáticas bastante complicadas, así que no lo vamos a hacer. Pero podemos obtener un poco de
intuición al ver un par de otros casos para entender por qué podría ser Θ(n𝑙𝑜𝑔2n) (una vez que
9
tengamos Θ(n𝑙𝑜𝑔2n)., la cota de Θ(n𝑙𝑜𝑔2n). se sigue porque el tiempo de ejecución del caso
promedio no puede ser mejor que el tiempo de ejecución del mejor caso). Primero, imaginemos
que no siempre obtenemos particiones igualmente balanceadas, pero que siempre obtenemos una
razón de 3 a 1. Es decir, imagina que cada vez que hacemos una partición, un lado obtiene 3n/4,
elementos y el otro lado obtiene n/4 (para mantener limpias las matemáticas, no vamos a
preocuparnos por el pivote). Entonces, el árbol de los tamaños de los subproblemas y los tiempos
para hacer las particiones se vería así:
El hijo izquierdo de cada nodo representa un subproblema cuyo tamaño es un 1/4 del
tamaño del padre, y el hijo derecho representa un subproblema cuyo tamaño es 3/4 del tamaño del
padre. Como los subproblemas más pequeños están del lado izquierdo, al seguir el camino de hijos
10
izquierdos llegamos de la raíz hasta un problema de tamaño 1 más rápido que por cualquier otro
camino.
En cada uno de los primeros 𝑙𝑜𝑔4n niveles hay n nodos (de nuevo, incluyendo pivotes que
en realidad ya no se están particionado), así que el tiempo total de hacer particiones para cada uno
de estos niveles es de cn. ¿Qué pasa con el resto de los niveles? Cada uno tiene menos de n nodos,
así que el tiempo para hacer particiones para cada nivel es a lo más cn. Todo junto, hay 𝑙𝑜𝑔4/3n
niveles, así que el tiempo total para hacer particiones es O(n 𝑙𝑜𝑔4/3n)
Hay un hecho matemático que dice que
para todos los números positivos a, b y n. Al hacer a = 4/3, b=2, obtenemos que
así que 𝑙𝑜𝑔4/3n y 𝑙𝑜𝑔2n solo difieren por un factor de 𝑙𝑜𝑔24/3 ,que es una constante. Como
los factores constantes no importan cuando usamos la notación O grande, podemos decir que si
todas las divisiones tienen una razón de 3 a 1, entonces el tiempo de ejecución del ordenamiento
rápido es Θ(n𝑙𝑜𝑔2n), aunque con un factor constante más grande escondido que el tiempo de
ejecución del mejor caso.
11
Mergesort
La idea de este algoritmo es la de dividir el problema en subproblemas más pequeños. Para
eso es indispensable utilizar la recursividad.
La idea es que es fácil, dadas dos listas ordenadas cada una, obtener una tercera que tiene
todos sus elementos ordenados.
Ejemplo:
 [2, 6, 10] + [-1, 3, 12] => [-1, 2, 3, 6, 10, 12]
El algoritmo llama a esto mezclar.
Entonces el algoritmo completo se basa en:
 Caso Base: una lista vacía o de un elemento ya está ordenada
 Regla Recursiva:
 partir la lista en 2 partes
 ordenar recursivamente ambas partes
 luego mezclarlas como vimos arriba.
Ejemplo
Veamos un ejemplo paso a paso antes de entrar en la implementación.
Nuestra lista:
[2, 124, 23, 5, 89, -1, 44, 643, 34]
12
A continuación las llamadas recursivas van a ir anidándose dividiendo la lista y llegando a
este punto:
Luego todas las hojas se resuelven fácil porque dijimos que las listas de 1 elemento se
consideran ordenadas.
Entonces pasamos a:
Ojo porque acá el símbolo más no significan que se deben concatenar las listas, sino que se
deben MEZCLAR, como dijimos antes, mezclar dos listas es generar una tercera que tiene los
elementos de ambas, pero ordenados. En este primer paso las mezclas son simples porque son entre
listas de un solo elemento.
Resolvemos las mezclas y queda:
13
Como se ve estamos comenzando a "volver". Ojo, se parece mucho a como era inicialmente
al diagrama inicial, pero presten bastante atención porque antes [5,23] estaba desordenado, era [23,
5] y lo mismo para el nodo de [89, -1] y [34, 643].
Ahora sí, todas las hojas están ordenadas, así que volvemos un nivel en cada una:
Resolvemos las mezclas que ahora son un poco más complicadas porque tenemos mezclas
de listas de 2 elementos, y otra de 1 + 2 elementos. Queda:
Ahora que tenemos dos ramas ordenadas las subimos.
14
Resolvemos la nueva mezcla con listas de 2 y 3 elementos:
Ya estan ordenadas así que retornamos:
Y resolviendo esta última mezcla obtenemos el resultado:
Implementación
Vamos a presentar dos variantes de la implementación. Sin embargo la primer parte del
algoritmo es igual para ambas. Lo que cambia es la forma de hacer la mezcla de dos listas.
Entonces, el mergeSort sería:
def mergeSort(lista):
if len (lista) == 1: return lista
izq, der = dividirAlMedio(lista)
return mezclar(mergeSort(izq), mergeSort(der))
15
def dividirAlMedio(lista):
middle = len(lista) // 2
return lista[0:middle], lista[middle:]
Como vemos es bastante expresiva la implementación, el mergeSort de una lista es:
 ella misma si tiene un solo elemento
 en otro caso, se divide al medio, se aplica el mergeSort de cada parte, y luego se mezclan
las ordenadas.
Es claro entonces que es una función recursiva, en este caso aplicar la recursividad a ambas
mitades de la lista.
Ahora, para la implementación del mezclar vamos a mostrar dos casos:
Mezclar Iterando
Esta primer versión itera ambas listas y se va quedando con el elemento menor. Como
ambas están ordenadas, esto garantiza el orden de la lista resultado. Así va avanzando por la lista
que tenía el menor.
def merge(first, second):
mergedList = []
i, j = 0, 0
while i < len(first) and j < len(second):
if first[i] < second[j]:
mergedList.append(first[i])
i += 1
else:
mergedList.append(second[j])
j += 1
16
mergedList.extend(first[i:])
mergedList.extend(second[j:])
return mergedList
Puede ser que avance más rápido por una que por la otra, o que tengan distinta cantidad de
elemento. Por lo que luego del while, se agregan los elementos restantes.
Mezclar Recursivo con Colas
Esta otra implementación utiliza recursividad para mezclar las listas, y en lugar de
recorrerlas con índices utiliza la estructura Cola que permite ir consumiendo el próximo elemento.
def mezclar(izq, der):
r = []
mezclarQueues(deque(izq), deque(der), r)
return r
def mezclarQueues(firstQ, secondQ, result):
if isEmpty(firstQ):result.extend(secondQ); return
if isEmpty(secondQ):result.extend(firstQ); return
result.append(firstQ.popleft() if cabeza(firstQ) <= cabeza(secondQ) else secondQ.popleft())
mezclarQueues(firstQ, secondQ, result)
La lógica realmente está en mezclarQueues que es la función recursiva. La otra es
símplemente para hacer el llamado más simple, porque mezclarQueues utiliza una semilla y además
deberíamos crear Colas para cada lista.
MezclarQueues entonces se define como:
 en result se va acumulando la lista final mezclada
17
 Si la primer cola está vacía, entonces símplemente agrega todos los elementos de la segunda
a result
 Si la segunda está vacía, agrega todos los de la primera.
 Si ambas tienen elemento entonces agrega un solo elemento a "result", que es el menor
entre la cabeza de cada cola, sacándo dicho elemento de la cola (usando popleft()).
 Luego vuelve a llamarse recursivamente con los mismos valores, ya que el popLeft fue el
que modificó la cola, una de ella tendrá un elemento menos.
Tiempo de ejecución
Como con la ordenación por inserción sea T(n) el tiempo total que tarda el algoritmo en
ordenar n elementos. Cuando n es suficientemente chico, digamos n <= c, entonces resolvemos el
problema de la manera obvia, para este caso nuestro programa tarda un tiempo constante, por lo
que podemos decir que tiene una complejidad de O(1). En general, supongamos que la division de
nuestro problema nos entrega a subproblemas, cada uno de tamaño 1/b (para el caso de la
ordenación por mezcla, tanto a como b son iguales a 2). Si tomamos que D(n) es el tiempo que
tardamos en dividir el problema en subproblemas, y C(n), el tiempo que tardamos en combinar los
subproblemas en una solución, entonces tenemos que
Hay un "teorema maestro" que sirve para resolver recurrencias de esta forma, sin embargo para
varios casos, como el de la ordenación por mezcla, se puede ver de manera intuitiva cual va a ser
el resultado. Revisemos paso a paso cada una de las partes.
18
 Dividir: Para dividir, basta con cálcular cual es la mitad del arreglo, esto, se puede hacer
sin ningún problema en tiempo constante, por lo que tenemos que para la ordenación por
mezcla D(n) = O(1)
 Vencer: Como dividimos el problema a la mitad y lo resolvemos recursivamente, entonces
estamos resolviendo dos problemas de tamaño n/2 lo que nos da un tiempo T(n) = 2T(n/2)
 Combinar: Vimos anteriormente que nuestro Mezcla era de orden lineal, por lo que
tenemos que C(n)=O(n)
Para sustituir, sumamos D(n) + C(n) = O(n) + O(1) = O(n), recordemos que para el análisis de
complejidad únicamente nos importa el término que crezca más rápido. Por lo que sustituyendo
en la recurrencia tenemos que:
donde c representa una constante igual al tiempo que se requiera para resolver un problema de
tamaño 1.
Sabemos que n sólo puede ser dividido a la mitad lg n veces, por lo que la profundidad de nuestra
recursión será de lg n, también del funcionamiento del algoritmo podemos ver que cada vez que
dividamos a la mitad, vamos a tener que mezclar los n elementos, y sabemos que
mezclar n elementos nos toma un tiempo proporcional a n, por lo que resolver la recursión debe
tomar un tiempo proporcional a n lg n, que de hecho es la complejidad del algortimo. La
ordenación por mezcla tiene una complejidad de O(n lg n).
Podemos comprobarlo, sustituyendo valores en la ecuación de recurrencia.
19
valor de n función de recurrencia tiempo total
1 c c
2 2T(1) + 2c 2c + 2c = 4c = c(2 lg(2) + 2)
4 2T(2) + 4c 8c + 4c =12c = c(4 lg(4) + 4)
8 2T(4) + 8c 24c+8c=32c=c(8 lg(8) + 8)
. . .
n 2T(n/2) + nc c(n lg(n) + n)
De la tabla anterior vemos que el tiempo real de corrida del ordenamiento por mezcla es
proporcional a n lg n + n, de nuevo, como en el análisis de complejidad únicamente nos importa
el término que crezca más rápido y en ésta función ese término es n lg n, por lo tanto la complejidad
del sistema es O(n lg n).
20
Estructuras de datos para manipulación de
conjuntos
Una estructura de datos es una forma particular de organizar datos en una computadora para que
pueda ser utilizado de manera eficiente. Diferentes tipos de estructuras de datos son adecuados para
diferentes tipos de aplicaciones, y algunos son altamente especializados para tareas específicas.
Las estructuras de datos son un medio para manejar grandes cantidades de datos de manera
eficiente para usos tales como grandes bases de datos y servicios de indización de Internet. Por lo
general, las estructuras de datos eficientes son la clave para diseñar algoritmos eficientes. Algunos
métodos formales de diseño y lenguajes de programación destacan las estructuras de datos, en lugar
de los algoritmos, como el factor clave de organización en el diseño de software. Las estructuras
de datos se basan generalmente en la capacidad de un ordenador para recuperar y almacenar datos
en cualquier lugar de su memoria.
Arboles binarios
Un árbol binario es un conjunto finito de elementos, el cual está vacío o dividido en tres
subconjuntos separados:
• El primer subconjunto contiene un elemento único llamado raíz del árbol.
• El segundo subconjunto es en sí mismo un árbol binario y se le conoce como subárbol izquierdo
del árbol original.
• El tercer subconjunto es también un árbol binario y se le conoce como subárbol derecho del árbol
original.
21
El subárbol izquierdo o derecho puede o no estar vacío. Cada elemento de un árbol binario se
conoce como nodo del árbol.
Si B es la raíz de un árbol binario y D es la raíz del subárbol izquierdo/derecho, se dice que B es el
padre de D y que D es el hijo izquierdo/derecho de B.
A un nodo que no tiene hijos, tal como A o C de la Ilustración , se le conoce como hoja.
Un nodo n1 es un ancestro de un nodo n2 (y n2 es un descendiente de n1) si n1 es el padre de n2 o
el padre de algún ancestro de n2.
Recorrer un árbol de la raíz hacia las hojas se denomina descender el árbol y al sentido opuesto
ascender el árbol. Un árbol estrictamente binario es aquel en el que cada nodo que no es hoja, tiene
subárboles izquierdo y derecho que no están vacíos.
Un árbol estrictamente binario con n hojas siempre contiene 2n-1 nodos. El nivel de un nodo en un
árbol binario se define del modo siguiente:
1.La raíz del árbol tiene el nivel 0.
22
2.El nivel de cualquier otro nodo en el árbol es uno más que el nivel de su padre.
La profundidad o altura de un árbol binario es el máximo nivel de cualquier hoja en el árbol.
Un árbol binario completo de profundidad p, es un árbol estrictamente binario que tiene todas sus
hojas en el nivel p.
Operaciones en árboles binarios.
Se aplican varias operaciones primitivas a un árbol binario. Si p es un apuntador a un nodo nd de
un árbol binario:
1.La función info(p) regresa el contenido de nd.
2.La función left(p) regresa un apuntador al hijo izquierdo de nd.
3.La función right(p) regresa un apuntador al hijo derecho de nd.
4.La función father(p) regresa un apuntador al padre de nd.
5.La función brother(p) regresa un apuntador al hermano de nd.
6.La función isLeft(p) regresa true si nd es un hijo izquierdo de algún otro nodo en el árbol, y false
en caso contrario
. 7.La función isRight(p) regresa true si nd es un hijo derecho de algún otro nodo en el árbol, y
false en caso contrario.
En la construcción de un árbol binario son útiles las operaciones:
1.makeTree(x) crea un nuevo árbol que consta de un nodo único con un campo de información x,
y regresa un apuntador a este nodo.
2.setLeft(p, x) crea un nuevo hijo izquierdo de node(p) con el campo de información x.
23
3.setRight(p, x) crea un nuevo hijo derecho de node(p) con el campo de información x.
Aplicaciones de árboles binarios.
Un árbol binario es una estructura de datos útil cuando deben tomarse decisiones en dos sentidos
en cada punto de un proceso.
Suponga que se desea encontrar todos los duplicados de una lista de números. Considérese lo
siguiente:
1.El primer número de la lista se coloca en un nodo que se ha establecido como la raíz de un árbol
binario con subárboles izquierdo y derecho vacíos.
2.Cada número sucesivo en la lista se compara con el número en la raíz, aquí se tienen 3 casos:
a.Si coincide, se tiene un duplicado.
b.Si es menor, se examina el subárbol izquierdo.
c.Si es mayor, se examina el subárbol derecho.
3.Si alguno de los subárboles esta vacío, el número no es un duplicado y se coloca en un nodo
nuevo en dicha posición del árbol.
4.Si el subárbol no está vacío, se compara el número con la raíz del subárbol y se repite todo el
proceso con el subárbol.
Un árbol binario de búsqueda (ABB) no tiene valores duplicados en los nodos y además, tiene la
característica de que:
1.Los valores en cualquier subárbol izquierdo son menores que el valor en su nodo padre.
2.Los valores en cualquier subárbol derecho son mayores que el valor en su nodo padre.
24
El árbol binario de búsqueda de la Ilustración fue construido dada la siguiente secuencia de
elementos: 14, 15, 4, 9, 7, 18, 3, 5, 16, 4, 20, 17
Una operación común es recorrer todo un árbol binario en un orden específico.
A la operación de recorrer un árbol de una forma específica y de “numerar” sus nodos, se le conoce
como visitar el árbol (procesar el valor del nodo).
En general, se definen tres métodos de recorrido de un árbol binario. Antes de presentarlos se
deberán tener en mente las siguientes consideraciones:
1.No se necesita hacer nada para un árbol binario vacío.
2.Todos los métodos se definen recursivamente.
3.Siempre se recorren la raíz y los subárboles, la diferencia radica en el orden en que se visitan.
Para recorrer un árbol binario no vacío en orden previo (orden de primera profundidad) se ejecutan
tres operaciones:
25
1.Visitar la raíz.
2.Recorrer el subárbol izquierdo en orden previo
.3.Recorrer el subárbol derecho en orden previo
Para recorrer un árbol binario no vacío en orden (orden simétrico) se ejecutan tres operaciones:
1.Recorrer el subárbol izquierdo en orden.
2.Visitar la raíz.
3.Recorrer el subárbol derecho en orden.
Para recorrer un árbol binario no vacío en orden posterior se ejecutan tres operaciones:
1.Recorrer el subárbol izquierdo en orden posterior.
2.Recorrer el subárbol derecho en orden posterior.
3.Visitar la raíz.
Ejercicios a desarrollar con árboles:
1.Ordenamiento de números e identificación de elementos repetidos almacenados en una lista.
2.Árboles de expresiones.
3.Determinar el número de nodos de un árbol binario.
26
4.La suma de todos los nodos.
5.La profundidad de un árbol binario.
6.Determinar si un árbol binario es o no estrictamente binario.
7.Determinar si un árbol binario es o no completo de nivel p.
Eliminación de un ABB.
La eliminación es el problema inverso a la inserción, sin embargo, las cosas no son tan sencillas
como para la inserción.
Si el nodo que se pretende eliminar es un nodo hoja o un nodo con un solo descendiente, la
eliminación es directa. La dificultad radica en la eliminación de un nodo con dos descendientes.
En este caso, el elemento eliminado será substituido por el descendiente más a la derecha de su
subárbol izquierdo (o bien por el descendiente más a la izquierda de su subárbol derecho).
Obsérvese que estos nodos substitutos tienen a lo más, un descendiente. Lo anterior queda mejor
representado en la Ilustración
27
ARBOLES EQUILIBRADOS AVL.
Comencemos con un ejemplo: Supongamos que deseamos construir un ABB para la siguiente tabla
de datos:
El resultado se muestra en la figura siguiente:
Como se ve ha resultado un árbol muy poco balanceado y con características muy pobres para la
búsqueda. Los ABB trabajan muy bien para una amplia variedad de aplicaciones, pero tienen el
28
problema de que la eficiencia en el peor caso es O(n). Los árboles que estudiaremos a continuación
nos darán una idea de cómo podria resolverse el problema garantizando en el peor caso un tiempo
O(log2 n).
Diremos que un árbol binario está equilibrado (en el sentido de Addelson-Velskii y Landis) si, para
cada uno de sus nodos ocurre que las alturas de sus dos subárboles difieren como mucho en 1. Los
árboles que cumplen esta condición son denominados a menudo árboles AVL.
En la primera figura se muestra un árbol que es AVL, mientras que el de la segunda no lo es al no
cumplirse la condición en el nodo k.
29
A través de los árboles AVL llegaremos a un procedimiento de búsqueda análogo al de los ABB
pero con la ventaja de garantizaremos un caso peor de O(log2 n), manteniendo el árbol en todo
momento equilibrado. Para llegar a este resultado , podríamos preguntarnos cual podría ser el peor
AVL que podríamos construir con n nodos, o dicho de otra forma cuanto podríamos permitir que
un árbol binario se desequilibrara manteniendo la propiedad de AVL. Para responder a la pregunta
podemos construir para una altura h el AVL Th, con mínimo número de nodos. Cada uno de estos
árboles mínimos debe constar de una raiz, un subárbol AVL minimo de altura h-1 y otro subárbol
AVL también minimo de altura h-2. Los primeros Ti pueden verse en la siguiente figura:
Es fácil ver que el número de nodos n(Th) está dado por la relación de recurencia [1]:
n(Th) = 1 + n(Th-1) + n(Th-2)
Relación similar a la que aparece en los números de Fibonacci (Fn = Fn-1 + Fn-2) , de forma que la
ss, de valores para n(Th) está relacionada con los valores de la ss. de Fibonacci:
 AVL -> -, -, 1, 2, 4, 7, 12, ...
 FIB -> 1, 1, 2, 3, 5, 8, 13, ...
30
es decir [2],
n(Th) = Fh+2 - 1
Resolviendo [1] y utilizando [2] llegamos tras algunos cálculos a:
log2(n+1) <= h < 1.44 log2(n+2)-0.33
o dicho de otra forma, la longitud de los caminos de búsqueda (o la altura) para un AVL de n nodos,
nunca excede al 44% de la longitud de los caminos (o la altura) de un árbol completamente
equilibrado con esos n nodos. En consecuencia, aún en el peor de los casos llevaría un tiempo
O(log2 n) al encontrar un nodo con una clave dada.
Parece, pues, que el único problema es el mantener siempre tras cada inserción la condición de
equilibrio, pero esto puede hacerse muy fácilmente sin más que hacer algunos reajustes locales,
cambiando punteros.
Antes de estudiar mas detalladamente este tipo de árboles realizamos la declaración de tipos
siguiente:
typedef int tElemento;
typedef struct NODO_AVL {
tElemento elemento;
struct AVL_NODO *izqda;
struct AVL_NODO *drcha;
int altura;
} nodo_avl;
typedef nodo_avl *arbol_avl;
#define AVL_VACIO NULL
#define maximo(a,b) ((a>b)?(a):(b))
31
En muchas implementaciones, para cada nodo no se almacena la altura real de dicho nodo en el
campo que hemos llamada altura, en su lugar se almacena un valor del conjunto {-1,0,1} indicando
la relación entre las alturas de sus dos hijos. En nuestro caso almacenamos la altura real por
simplicidad. Por consiguiente podemos definir la siguiente macro:
#define altura(n) (n?n->altura:-1)
La cual nos devuelve la altura de un nodo_avl.
Con estas declaraciones la funciones de creación y destrucción para los árboles AVLpueden ser
como sigue:
arbolAVL Crear_AVL()
{
return AVL_VACIO;
}
void Destruir_AVL (arbolAVL A)
{
if (A) {
Destruir_AVL(A->izqda);
Destruir_AVL(A->drcha);
free(A);
}
}
Es sencillo realizar la implementación de una función que podemos llamar miembro que nos
devuelve si un elemento pertenece al árbol AVL. Podría ser la siguiente:
int miembro_AVL(tElemento e,arbolAVL A)
{
if (A == NULL)
return 0;
if (e == A->elemento)
return 1;
else
if (e < A->elemento)
return miembro_AVL(e,A->izqda);
else
return miembro_AVL(e,A->drcha);
}
32
Veamos ahora la forma en que puede afectar una inserción en un árbol AVL y la forma en que
deberiamos reorganizar los nodos de manera que siga equilibrado. Consideremos el esquema
general de la siguiente figura, supongamos que la inserción ha provocado que el subárbol que
cuelga de Ai pasa a tener una altura 2 unidades mayor que el subárbol que cuelga de Ad . ¿Qué
operaciones son necesarias para que el nodo r tenga 2 subárboles que cumplan la propiedad de
árboles AVL
Para responder a esto estudiaremos dos situaciones distintas que requieren 2 secuencias de
operaciones distintas:
 La inserción se ha realizado en el árbol A. La operación a realizar es la de una rotación
simple a la derecha sobre el nodo r resultando el árbol mostrado en la siguiente figura.
33
 La inserción se ha realizado en el árbol B. (supongamos tiene raiz b, subárbol izquierdo B1
y subárbol derecho B2). La operación a realizar es la rotación doble izquierda-derecha la
cual es equivalente a realizar una rotación simple a la izquierda sobre el nodo Ai y despues
una rotación simple a la derecha sobre el nodo r (por tanto, el árbol B queda dividido). El
resultado se muestra en la figura siguiente:
En el caso de que la inserción se realice en el subárbol Ad la situación es la simétrica y para las
posibles violaciones de equilibrio se aplicará la misma técnica mediante la rotación simple a la
34
izquierda o la rotación doble izquierda-derecha. Se puede comprobar que si los
subárboles Ad y Ai son árboles AVL, estas operaciones hacen que el árbol resultante también sea
AVL. Por último, destacaremos que para realizar la implementación definitiva en base a la
declaración de tipos que hemos propuesto tendremos que realizar un ajuste de la altura de los nodos
involucrados en la rotación además del ya mencionado ajuste de punteros. Por ejemplo: En la
rotación simple que se ha realizado en la primera de las situaciones, el campo de altura de los
nodos r y Ai puede verse modificado.
Estas operaciones básicas de simple y doble rotación se pueden implementar de la siguiente forma:
void Simple_derecha(arbolAVL *A)
{
nodoAVL *p;
p = (*A)->izqda;
(*A)->izqda = p->drcha;
p->drcha = (*A);
(*A) = p;
/* Ajustamos las alturas */
p = (*A)->drcha;
p->altura = maximo(altura(p->izqda),altura(p->drcha))+1;
(*A)->altura = maximo(a1tura((*T)->izqda),altura((*T)->drcha))+1;
}
void Simple_izquierda(arbolAVL *A)
{
nodoAVL *p;
p = (*A)->drcha;
(*A)->drcha = p->izqda;
p->izqda = (*A);
(*A) = p;
/*Ajustamos las alturas */
p = (*A)->izqda;
p->altura = maximo(altura(p->izqda),altura(p->drcha))+1;
(*A)->altura = maximo(altura((*A)->izqda),altura((*A)->drcha))+1;
}
void Doble_izquierda_derecha (arbolAVL *AT)
{
simple_izquierda(&((*A)->izqda));
simple_derecha(A);
}
void Doble_derecha_izquierda (arbolAVL *A)
35
{
simple_derecha(&((*A)->drcha));
simple_izquierda(A);
}
Obviamente, el reajuste en los nodos es necesario tanto para la operación de inserción como para
la de borrado. Por consiguiente, se puede programar la inserción de forma que descendamos en el
árbol hasta llegar a una hoja donde insertar y después recorrer el mismo camino hacia arriba
realizando los ajustes necesarios (igualmente en el borrado se realizaría algo similar). Para hacer
más fácil la implementación, construiremos la función ajusta_avl(e,&T) cuya misión consiste en
ajustar los nodos que existen desde el nodo conteniendo la etiqueta e hasta el nodo raiz en el árbol
T. La usaremos como función auxiliar para implementar las funciones de inserción y de borrado.
El código es el siguiente:
void ajusta_AVL (tElemento e, arbolAVL *A)
{
if (!(*A))
return;
if (e > (*A)->elemento)
ajusta_AVL(e,&((*A)->drcha));
else if (e < (*A)->elemento)
ajusta_avl(e,&((*A)->izqda));
switch (altura((*A)->izqda)-altura((*A)->drcha)) {
case 2:
if (altura((*A)->izqda->izqda) > altura((*A)->izqda->drcha))
simple_derecha(A);
else doble_izquierda_derecha(A);
break;
case -2:
if (altura((*A)->drcha->drcha) > altura((*A)->drcha->izqda))
simple_izquierda(A);
else doble_derecha_izquierda(A);
break;
default:
(*A)->altura = maximo(altura((*A)->izqda),altura((*A)->drcha))+1;
}
}
Para la operación de inserción se deberá profundizar en el árbol hasta llegar a un nodo hoja o un
nodo con un solo hijo de forma que se añade un nuevo hijo con el elemento insertado. Una vez
36
añadido sólo resta ajustar los nodos que existen en el camino de la raíz al nodo insertado. El código
es el siguiente:
void insertarAVL (tElemento e, arbolAVL *A)
{
nodoAVL **p;
p=T;
while (*p!=NULL)
if ((*p)->elemento > e)
p = &((*p)->izqda);
else p = &((*p)->drcha);
(*p)=(nodo_avl *)malloc(sizeof(nodoAVL));
if (!(*p))
error("Error: Memoria insuficiente.");
(*p)->elemento = e;
(*p)->altura = 0;
(*p)->izqda = NULL;
(*p)->drcha = NULL;
ajustaAVL(e,A);
}
En el caso de la operación de borrado es un poco más complejo pues hay que determinar el
elemento que se usará para la llamada a la función de ajuste. Por lo demás es muy similar al borrado
en los árboles binarios de búsqueda. En la implementación que sigue usaremos la
variable elem para controlar el elemento involucrado en la función de ajuste.
void borrarAVL (tElemento e, arbolAVL *A)
{
nodoAVL **p,**aux,*dest;
tElemento elem;
p=A;
elem=e;
while ((*p)->elemento!=e) {
elem=(*p)->elemento;
if ((*p)->elemento > e)
p=&((*p)->izqda);
else p=&((*p)->drcha);
}
if ((*p)->izqda!=NULL && (*p)->drcha!=NULL) {
aux=&((*p)->drcha);
elem=(*p)->elemento;
while ((*aux)->izqda) {
elem=(*aux)->elemento;
37
aux=&((*aux)->izqda);
}
(*p)->elemento = (*aux)->elemento;
p=aux;
}
if ((*p)->izqda==NULL && (*p)->drcha==NULL) {
free(*p);
(*p) = NULL;
} else if ((*p)->izqda == NULL) {
dest = (*p);
(*p) = (*p)->drcha;
free(dest);
} else {
dest = (*p);
(*p) = (*p)->izqda;
free(dest);
}
ajustaAVL(elem,A);
}
38
Arboles 2-3
Los árboles 2-3 son árboles cuyos nodos internos pueden contener hasta 2 elementos (todos los
árboles vistos con anterioridad pueden contener sólo un elemento por nodo), y por lo tanto un nodo
interno puede tener 2 o 3 hijos, dependiendo de cuántos elementos posea el nodo. De este modo,
un nodo de un árbol 2-3 puede tener una de las siguientes formas:
Un árbol 2-3 puede ser simulado utilizando árboles binarios:
Una propiedad de los árboles 2-3 es que todas las hojas están a la misma profundidad, es decir,
los árboles 2-3 son árboles perfectamente balanceados. La siguiente figura muestra un ejemplo de
un árbol 2-3:
39
Nótese que se sigue cumpliendo la propiedad de los árboles binarios: nodos internos + 1 = nodos
externos. Dado que el árbol 2-3 es perfectamente balanceado, la altura de éste esta acotada por:
Inserción en un árbol 2-3
Para insertar un elemento X en un árbol 2-3 se realiza una búsqueda infructuosa y se inserta dicho
elemento en el último nodo visitado durante la búsqueda, lo cual implica manejar dos casos
distintos:
 Si el nodo donde se inserta X tenía una sola llave (dos hijos), ahora queda con dos llaves
(tres hijos).
 Si el nodo donde se inserta X tenía dos llaves (tres hijos), queda transitoriamente con tres
llaves (cuatro hijos) y se dice que está saturado (overflow). En este caso se debe realizar
una operación de split: el nodo saturado se divide en dos nodos con un valor cada uno (el
menor y el mayor de los tres). El valor del medio sube un nivel, al padre del nodo saturado.
40
El problema se resuelve a nivel de X y Z, pero es posible que el nodo que contiene a Y ahora
este saturado. En este caso, se repite el mismo prodecimiento anterior un nivel más arriba.
Finalmente, si la raíz es el nodo saturado, éste se divide y se crea una nueva raíz un nivel
más arriba. Esto implica que los árboles 2-3 crecen "hacia arriba".
Ejemplos de inserción en árboles 2-3:
41
Eliminación en un árbol 2-3
Sin perder generalidad se supondrá que el elemento a eliminar, Z, se encuentra en el nivel más bajo
del árbol. Si esto no es así, entonces el sucesor y el predecesor de Z se encuentran necesariamente
en el nivel más bajo (¿por qué?); en este caso basta con borrar uno de ellos y luego escribir su valor
sobre el almacenado en Z. La eliminación también presenta dos posibles casos:
 El nodo donde se encuentra Z contiene dos elementos. En este caso se elimina Z y el nodo
queda con un solo elemento.
 El nodo donde se encuentra Z contiene un solo elemento. En este caso al eliminar el
elemento Z el nodo queda sin elementos (underflow). Si el nodo hermano posee dos
elementos, se le quita uno y se inserta en el nodo con underflow.
42
Si el nodo hermano contiene solo una llave, se le quita un elemento al padre y se inserta en
el nodo con underflow.
Si esta operación produce underflow en el nodo padre, se repite el procedimiento anterior
un nivel más arriba. Finalmente, si la raíz queda vacía, ésta se elimina.
Costo de las operaciones de búsqueda, inserción y eliminación en el peor caso: .
43
Hashing
Una aproximación a la búsqueda radicalmente diferente a las anteriores consiste en proceder, no
por comparaciones entre valores clave, sino encontrando alguna función h(k) que nos dé
directamente la localización de la clave k en la tabla.
La primera pregunta que podemos hacernos es si es fácil encontrar tales funciones h. La respuesta
es, en principio, bastante pesimista, puesto que si tomamos como situacion ideal el que tal función
dé siempre localizaciones distintas a claves distintas y pensamos p.ej. en una tabla de tamaño 40
en donde queremos direccionar 30 claves, nos encontramos con que hay 4030 = 1.15 * 1048 posibles
funciones del conjunto de claves en la tabla, y sólo 40*39*11 = 40!/10! = 2.25 * 1041 de ellas no
generan localizaciones duplicadas. En otras palabras, sólo 2 de cada 10 millones de tales funciones
serian 'perfectas' para nuestros propósitos. Esa tarea es factible sólo en el caso de que los valores
que vayan a pertenecer a la tabla hash sean conocidas a priori. Existen algoritmos para construir
funciones hash perfectas que son utilizadas para organizar las palabras clave en un compilador de
forma que la búsqueda de cualquiera de esas palabras clave se realice en tiempo constante.
Las funciones que evitan valores duplicados son sorprendentemente dificiles de encontrar, incluso
para tablas pequeñas. Por ejemplo, la famosa "paradoja del cumpleaños" asegura que si en una
reunión están presentes 23 ó más personas, hay bastante probabilidad de que dos de ellas hayan
nacido el mismo dia del mismo mes. En otras palabras, si seleccionamos una función aleatoria que
aplique 23 claves a una tabla de tamaño 365 la probabilidad de que dos claves no caigan en la
misma localización es de sólo 0.4927.
En consecuencia, las aplicaciones h(k), a las que desde ahora llamaremos funciones hash, tienen la
particularidad de que podemos esperar que h( ki ) = h( kj ) para bastantes pares distintos ( ki,kj ). El
objetivo será pues encontrar una función hash que provoque el menor número posible de colisiones
44
(ocurrencias de sinónimos), aunque esto es solo un aspecto del problema, el otro será el de diseñar
métodos de resolución de colisiones cuando éstas se produzcan.
FUNCIONES HASH.
El primer problema que hemos de abordar es el cálculo de la función hash que transforma claves
en localizaciones de la tabla. Más concretamente, necesitamos una función que transforme
claves(normalmente enteros o cadenas de caracteres) en enteros en un rango [0..M-1], donde M es
el número de registros que podemos manejar con la memoria de que dispongamos.como factores a
tener en cuenta para la elección de la función h(k) están que minimice las colisiones y que sea
relativamente rápida y fácil de calcular, aunque la situación ideal sería encontrar una función h que
generara valores aleatorios uniformemente sobre el intervalo [0..M-1]. Las dos aproximaciones que
veremos están encaminadas hacia este objetivo y ambas están basadas en generadores de números
aleatorios.
Hasing Multiplicativo.
Esta técnica trabaja multiplicando la clave k por sí misma o por una constante, usando después
alguna porción de los bits del producto como una localización de la tabla hash.
Cuando la elección es multiplicar k por sí misma y quedarse con alguno de los bits centrales, el
método se denomina el cuadrado medio. Este metodo aún siendo simple y pudiendo cumplir el
criterio de que los bits elegidos para marcar la localización son función de todos los bits originales
de k, tiene como principales inconvenientes el que las claves con muchos ceros se reflejarán en
valores hash también con muchos ceros, y el que el tamaño de la tabla está restringido a ser una
potencia de 2.
45
Otro método multiplicativo, que evita las restricciones anteriores consiste en calcular h(k) = Int[M
* Frac(C*k)] donde M es el tamaño de la tabla y 0 <= C <= 1, siendo importante elegir C con
cuidado para evitar efectos negativos como que una clave alfabética K sea sinónima a otras claves
obtenidas permutando los caracteres de k. Knuth (ver bibliografía) prueba que un valor
recomendable es:
Hasing por División.
En este caso la función se calcula simplemente como h(k) = k mod M usando el 0 como el primer
índice de la tabla hash de tamaño M.
Aunque la fórmula es aplicable a tablas de cualquier tamaño es importante elegir el valor de M con
cuidado. Por ejemplo si M fuera par, todas las claves pares (resp. impares) serían aplicadas a
localizaciones pares (resp. impares), lo que constituiría un sesgo muy fuerte. Una regla simple para
elegir M es tomarlo como un número primo. En cualquier caso existen reglas mas sofisticadas para
la elección de M (ver Knuth), basadas todas en estudios téoricos de funcionamiento de los métodos
congruenciales de generación de números aleatorios.
RESOLUCIÓN DE COLISIONES.
El segundo aspecto importante a estudiar en el hasing es la resolución de colisiones entre
sinónimos. Estudiaremos tres métodos basicos de resolución de colisiones, uno de ellos depende
de la idea de mantener listas enlazadas de sinónimos, y los otros dos del cálculo de una secuencia
de localizaciones en la tabla hash hasta que se encuentre que se encuentre una vacía. El análisis
comparativo de los métodos se hará en base al estudio del número de localizaciones que han de
examinarse hasta determinar donde situar cada nueva clave en la tabla.
46
Para todos los ejemplos el tamaño de la tabla será M=13 y la función hash h1(k) que utilizaremos
será:
HASH = Clave Mod M
y los valores de la clave k que consideraremos son los expuestos en la siguiente tabla:
Suponiendo que k=0 no ocurre de forma natural, podemos marcar todas las localizaciones de la
tabla, inicialmente vacías, dándoles el valor 0. Finalmente y puesto que las operaciones de
búsqueda e inserción están muy relacionadas, se presentaran algoritmos para buscar un item
insertándolo si es necesario (salvo que esta operación provoque un desbordamiento de la tabla)
devolviendo la localización del item o un -1 (NULL) en caso de desbordamiento.
Encadenamiento separado o Hasing Abierto.
La manera más simple de resolver una colisión es construir, para cada localización de la tabla, una
lista enlazada de registros cuyas claves caigan en esa dirección. Este método se conoce
normalmente con el nombre de encadenamiento separado y obviamente la cantidad de tiempo
requerido para una búsqueda dependerá de la longitud de las listas y de las posiciones relativas de
las claves en ellas. Existen variantes dependiendo del mantenimiento que hagamos de las listas de
sinónimos (FIFO, LIFO, por valor Clave, etc), aunque en la mayoría de los casos, y dado que las
47
listas individuales no han de tener un tamaño excesivo, se suele optar por la alternativa más simple,
la LIFO.
En cualquier caso, si las listas se mantienen en orden esto puede verse como una generalización
del método de búsqueda secuencial en listas. La diferencia es que en lugar de mantener una sola
lista con un solo nodo cabecera se mantienen M listas con M nodos cabecera de forma que se
reduce el número de comparaciones de la búsqueda secuencial en un factor de M (en media) usando
espacio extra para M punteros. Para nuestro ejemplo y con la alternativa LIFO, la tabla quedaría
como se muestra en la siguiente figura:
A veces y cuando el número de entradas a la tabla es relativamente moderado, no es conveniente
dar a las entradas de la tabla hash el papel de cabeceras de listas, lo que nos conduciría a otro
método de encadenamiento, conocido como encadenamiento interno. En este caso, la unión entre
48
sinónimos está dentro de la propia tabla hash, mediante campos cursores (punteros) que son
inicializados a -1 (NULL) y que irán apuntando hacia sus respectivos sinónimos.
Direccionamiento abierto o Hasing Cerrado.
Otra posibilidad consiste en utilizar un vector en el que se pone una clave en cada una de sus
casillas. En este caso nos encontramos con el problema de que en el caso de que se produzca una
colisión no se pueden tener ambos elementos formando parte de una lista paraesa casilla. Para
solucionar ese problema se usa lo que se llama rehashing. El rehashing consiste en que una vez
producida una colisión al insertar un elemento se utiliza una función adicional para determinar cual
será la casilla que le corresponde dentro de la tabla, aesta función la llamaremos función de
rehashing,rehi(k).
A la hora de definir una función de rehashing existen múltiples posibilidades, la más simple
consiste en utilizar una función que dependa del número de intentos realizados para encontrar una
casilla libre en la que realizar la inserción, a este tipo de rehashing se le conoce como hashing
lineal. De esta forma la función de rehashing quedaria de la siguiente forma:
rehi(k) = (h(k)+(i-1)) mod M i=2,3,...
En nuestro ejemplo, después de insertar las 7 primeras claves nos aparece la tabla A, (ver la tabla
siguiente). Cuando vamos a insertar la clave 147, esta queda situada en la casilla 6, (tabla B) una
vez que no se han encontrado vacías las casillas 4 y 5. Se puede observar que antes de la inserción
del 147 había agrupaciones de claves en las localizaciones 4,5 y 7,8, y después de la inserción, esos
dos grupos se han unido formando una agrupación primaria mayor, esto conlleva que si se trata de
insertar un elemento al que le corresponde algunas de las casillas que están al principio de esa
agrupación el proceso de rehashing tendrá de recorrer todas esas casillas con lo que se degradará
49
la eficiencia de la inserción. Para solucionar este problema habrá que buscar un método de
rehashing que distribuya de la forma más aleatoria posible las casillas vacías.
Despues de llevar a cabo la inserción de las claves consideradas en nuestro ejemplo, el estado de
la tabla hash será el que se puede observar en la tabla (C) en la que adémas aparece el número de
intentos que han sido necesarios para insertar cada una de las claves.
Para intentar evitar el problema de las agrupaciones que acabamos de ver podríamos utilizar la
siguiente función de rehashing:
rehi(k) = (h(k)+(i-1)*C) mod M C>1 y primo relativo con M
50
pero aunque esto evitaría la formación de agrupaciones primarias, no solventaría el problema de la
formación de agrupaciones secundarias (agrupaciones separadas por una distancia C). El problema
básico de rehashing lineal es que para dos claves distintas que tengan el mismo valor para la función
hash se irán obteniendo exactamente la misma secuencia de valores al aplicar la función de
rehashing, cunado lo interenante seria que la secuencia de valores obtenida por el proceso de
rehashing fuera distinta. Así, habrá que buscar una función de rehashing que cumpla las siguientes
condiciones:
 Sea fácilmente calculable (con un orden de eficiencia constante),
 que evite la formación de agrupaciones,
 que genere una secuencia de valores distinta para dos claves distintas aunque tenga el
mismo valor de función hash, y por último
 que garantice que todas las casillas de la tabla son visitadas.
si no cumpliera esto último se podría dar el caso de que aún quedaran casillas libres pero no
podemos insertar un determinado elemento porque los valores correspondientes a esas casillas no
son obtenidos durante el rehashing.
Una función de rehashing que cumple las condiciones anteriores es la función de rehashing doble.
Esta función se define de la siguiente forma:
hi(k) = (hi-1(k)+h0(k)) mod M i=2,3,...
con h0(k) = 1+k mod (M-2) y h1(k) = h(k).
Existe la posibilidad de hacer otras elecciones de la función h0(k) siempre que la función escogida
no sea constante.
51
Esta forma de rehashing doble es particularmente buena cuando M y M-2 son primos relativos.
Hay que tener en cuenta que si M es primo entonces es seguro que M-2 es primo relativo suyo
(exceptuando el caso trivial de que M=3).
El resultado de aplicar este método a nuestro ejemplo puede verse en las tablas siguientes. En la
primera se incluyen los valores de h para cada clave y en la segunda pueden verse las localizaciones
finales de las claves en la tabla así como las pruebas requeridas para su inserción.
52
BORRADOS Y REHASING.
Cuando intentamos borrar un valor k de una tabla que ha sido generada por direccionamiento
abierto, nos encontramos con un problema. Si k precede a cualquier otro valor k en una secuencia
de pruebas, no podemos eliminarlo sin más, ya que si lo hiciéramos, las pruebas siguientes para k se
encontrarian el "agujero" dejado por k por lo que podríamos concluir que k no está en la tabla,
hecho que puede ser falso.Podemos comprobarlo en nuestro ejemplo en cualquiera de las tablas.
La solución es que necesitamos mirar cada localización de la tabla hash como inmersa en uno de
los tres posibles estados: vacia, ocupada o borrada, de forma que en lo que concierne a la
busqueda, una celda borrada se trata exectamente igual que una ocupada.En caso de inserciones,
podemos usar la primera localización vacia o borrada que se encuentre en la secuencia de pruebas
para realizar la operación. Observemos que este problema no afecta a los borrados de las listas en
el encadenamiento separado. Para la implementación de la idea anterior podria pensarse en la
introducción en los algorítmos de un valor etiqueta para marcar las casillas borradas, pero esto sería
solo una solución parcial ya que quedaría el problema de que si los borrados son frecuentes, las
búsquedas sin éxito podrían requerir O(M) pruebas para detectar que un valor no está presente.
Cuando una tabla llega a un desbordamiento o cuando su eficiencia baja demasiado debido a los
borrados, el único recurso es llevarla a otra tabla de un tamaño más apropiado, no necesariamente
mayor, puesto que como las localizaciones borradas no tienen que reasignarse, la nueva tabla podría
ser mayor, menor o incluso del mismo tamaño que la original. Este proceso se suele denominar
rehashing y es muy simple de implementar si el arca de la nueva tabla es distinta al de la primitiva,
pero puede complicarse bastante si deseamos hacer un rehashing en la propia tabla.
53
EVALUACIÓN DE LOS MÉTODOS DE RESOLUCIÓN.
El aspecto más significativo de la búsqueda por hashing es que si eficiencia depende del
denominado factor de almacenamiento Ó= n/M con n el número de items y M el tamaño de la
tabla.
Discutiremos el número medio de pruebas para cada uno de los métodos que hemos visto de
resolución de colisiones, en términos de BE (búsqueda con éxito) y BF (búsqueda sin éxito).
Encadenamiento separado.
Aunque puede resultar engañoso comparar este método con los otros dos, puesto que en este caso
puede ocurrir que Ó>1, las fórmulas aproximadas son:
Estas expresiones se aplican incluso cuando Ó>>1, por lo que para n>>M, la longitud media de
cada lista será Ó, y deberia esperarse en media rastrear la mitad de la lista, antes de encontrar un
determinado elemento.
Hasing Lineal.
Las fórmulas aproximadas son:
Como puede verse, este método, aun siendo satisfactorio para Ó pequeños, es muy pobre cuando
Ó -> 1, ya que el límite de los valores medios de BE y BF son respectivamente:
54
En cualquier caso, el tamaño de la tabla en el hash lineal es mayor que en el encadenamiento
separado, pero la cantidad de memoria total utilizada es menor al no usarse punteros.
Hasing Doble.
Las fórmulas son ahora:
BE=-(1/1-Ó) * ln(1-Ó)
BF=1/(1-Ó)
con valores medios cuando Ó -> 1 de M y M/2, respectivamente.
Para facilitar la comprensión de las fórmulas podemos construir una tabla en la que las evaluemos
para distintos valores de Ó:
La elección del mejor método hash para una aplicación particular puede no ser fácil. Los distintos
métodos dan unas características de eficiencia similares. Generalmente, lo mejor es usar el
encadenamiento separado para reducir los tiempos de búsqueda cuando el número de registros a
procesar no se conoce de antemano y el hash doble para buscar claves cuyo número pueda, de
alguna manera, predecirse de antemano.
55
En comparación con otras técnicas de búsqueda, el hashing tiene ventajas y desventajas. En
general, para valores grandes de n (y razonables valores de Ó) un buen esquema de hashing
requiere normalmente menos pruebas (del orden 1.5 - 2) que cualquier otro método de búsqueda,
incluyendo la búsqueda en árboles binarios. Por otra parte, en el caso peor, puede comportarse muy
mal al requerir O(n) pruebas. También puede considerarse como una ventaja el hecho de que
debemos tener alguna estimación a priori de número máximo de items que vamos a colocar en la
tabla aunque si no disponemos de tal estimación siempre nos quedaría la opción de usar el metodo
de encadenamiento separado en donde el desbordamiento de la tabla no constituye ningún
problema.
Otro problema relativo es que en una tabla hash no tenemos ninguna de las ventajas que tenemos
cuando manejamos relaciones ordenadas, y así p.e. no podemos procesar los items en la tabla
secuencialmente, ni concluir tras una búsqueda sin éxito nada sobre los items que tienen un valor
cercano al que buscamos, pero en cualquier caso el mayor problema que tener el hashing cerrado
es el de los borrados dentro de la tabla.
Implementación de Hasing Abierto.
En este apartado vamos a realizar una implementación simple del hasing abierto que nos servirá
como ejemplo ilustrativo de su funcionamiento. Para ello supondremos un tipo de dato char * para
el cual diseñaremos una función hash simple consistente en la suma de los codigos ASCII que
componen dicha cadena.
Una posible implementación utilizando el tipo de dato abstracto lista sería la siguiente:
#define NCASILLAS 100 /*Ejemplo de número de entradas en la tabla.*/
56
typedef tLista *TablaHash;
Para la cual podemos diseñar las siguientes funciones de creación y destrución:
TablaHash CrearTablaHash ()
{
tLista *t;
register i;
t=(tLista *)malloc(NCASILLAS*sizeof(tLista));
if (t==NULL)
error("Memoria insuficiente.");
for (i=0;i<NCASILLAS;i++)
t[i]=crear();
return t;
}
void DestruirTablaHash (TablaHash t)
{
register i;
for (i=0;i<NCASILLAS;i++)
destruir(t[i]);
free(t);
}
Como fue mencionado anteriormente la función hash que será usada es:
int Hash (char *cad)
{
int valor;
unsigned char *c;
for (c=cad,valor=O;*c;c++)
valor+=(int)(*c);
return(valor%NCASILLAS);
}
57
Y funciones del tipo MiembroHash, InsertarHash, BorrarHash pueden ser programadas:
int MiembroHash (char *cad,TablaHash t)
{
tPosicion p;
int enc;
int pos=Hash(cad);
p=primero(t[pos]);
enc=O;
while (p!=fin(t[pos]) && !enc) {
if (strcmp(cad,elemento(p,t[pos]))==O)
enc=1;
else
p=siguiente(p,t[pos]);
}
return enc;
}
void InsertarHash (char *cad,TablaHash t)
{
int pos;
if (MiembroHash(cad,t))
return;
pos=Hash(cad);
insertar(cad,primero(t[pos]),t[pos]);
}
void BorrarHash (char *cad,TablaHash t)
{
tPosicion p;
int pos=Hash(cad);
p=primero(t[pos]);
while (p!=fin(t[pos]) && !strcmp(cad,elemento(p,t[pos])))
p=siguiente(p,t[pos]));
if (p!=fin(t[pos]))
borrar(p,t[pos]);
}
Como se puede observar esta implementación es bastante simple de forma que puede sufrir
bastantes mejoras. Se propone como ejercicio el realizar esta labor dotando al tipo de dato de
posibilidades como:
 Determinación del tamaño de la tabla en el momento de creación.
58
 Modificación de la función hash utilizada, mediante el uso de un puntero a función.
 Construcción de una función que pasa una tabla hash de un tamaño determinado a otra tabla
con un tamaño superior o inferior.
 Construcción de un iterador a través de todos los elementos de la tabla.
 etc...
Implementación de Hasing Cerrado.
En este apartado vamos a realizar una implementación simple del hashing cerrado. Para ello
supondremos un tipo de datochar * al igual que en el apartado anterior, para el cual diseñaremos
la misma función hash.
Una posible implementación de la estructura a conseguir es la siguiente:
#define NCASILLAS 100
#define VACIO NULL
static char * BORRADO='''';
typedef char **TablaHash;
Para la cual podemos diseñar las siguientes funciones de creación y destrucción:
TablaHash CrearTablaHash ()
{
TablaHash t;
register i;
t=(TablaHash)malloc(NCASILLAS*sizeof(char *));
if (t==NULL)
error("Memoria Insuficiente.");
for (i=0;i<NCASILLAS;i++)
t[i]=VACIO;
return t;
}
void DestruirTablaHash (TablaHash t)
{
register i;
for (i=O;i<NCASILLAS;i++)
59
if (t[i]!=VACIO && t[i]!=BORRADO)
free(t[i]);
free t;
}
La función hash que será usada es igual a la que ya hemos usado para la implementación del Hasing
Abierto. Y funciones del tipo MiembroHash, InsertarHash, BorrarHash pueden ser programadas
tal como sigue, teniendo en cuenta que en esta implementación haremos uso de un rehashing lineal.
int Hash (char *cad)
{
int valor;
unsigned char *c;
for (c=cad, valor=0; *c; c++)
valor += (int)*c;
return (valor%NCASILLAS);
}
int Localizar (char *x,TablaHash t)
/* Devuelve el sitio donde esta x o donde deberia de estar. */
/* No tiene en cuenta los borrados. */
{
int ini,i,aux;
ini=Hash(x);
for (i=O;i<NCASILLAS;i++) {
aux=(ini+i)%NCASILLAS;
if (t[aux]==VACIO)
return aux;
if (!strcmp(t[aux],x))
return aux;
}
return ini;
}
int Localizar1 (char *x,TablaHash t)
/* Devuelve el sitio donde podriamos poner x */
{
int ini,i,aux;
ini=Hash(x);
for (i=O;i<NCASILLAS;i++) {
60
aux=(ini+i)%NCASILLAS;
if (t[aux]==VACIO || t[aux]==BORRADO)
return aux;
if (!strcmp(t[aux],x))
return aux;
}
return ini;
}
int MiembroHash (char *cad,TablaHash t)
{
int pos=Localizar(cad,t);
if (t[pos]==VACIO)
return 0;
else
return(!strcomp(t[pos],cad));
}
void InsertarHash (char *cad,TablaHash t)
{
int pos;
if (!cad)
error("Cadena inexistente.");
if (!MiembroHash(cad,t)) {
pos=Localizar1(cad,t);
if (t[pos]==VACIO || t[pos]==BORRADO) {
t[pos]=(char *)malloc((strlen(cad)+1)*sizeof(char));
strcpy(t[pos],cad);
} else {
error("Tabla Llena. n");
}
}
}
void BorrarHash (char *cad,TablaHash t)
{
int pos = Localizar(cad,t);
if (t[pos]!=VACIO && t[pos]!=BORRADO) {
if (!strcmp(t[pos],cad)) {
free(t[pos]);
t[pos]=BORRADO;
}
}
}
61
Algoritmos de selección
Max- Min
Existe un algoritmo que se utiliza para encontrar el número máximo y el mínimo dentro de un
arreglo de números (o de otros objetos, dependiendo del programa), es el algoritmo MAXMIN.
El algoritmo sirve para un número n(en potencia de dos) de elementos. Después de encontrar el
máximo y el mínimo en el arreglo, guarda el valor máximo en la posición [0] y el mínimo en la
posición[1] de un arreglo[2].
El algoritmo es recursivo, en el caso base(cuando n es dos) solamente compara los valores y los
coloca en en las casillas correspondientes del arreglo[2].
En el caso recursivo, el arreglo se va partiendo en mitades, de 0 a (n/2)-1 y otro de n/2 a n-1. Así
hasta llegar al caso base.
Consideraremos que el tamaño máximo de la entrada y el vector a ordenar vienen dados por las
siguientes definiciones:
CONSTn =...; (* numero maximo de elementos *)
TYPE vector = ARRAY [1..n] OF INTEGER;
Los procedimientos de ordenación están diseñados para ordenar cualquier subvector de un vector
dado a[1..n]. Por eso generalmente poseerán tres parámetros: el nombre del vector que contiene a
los elementos (a) y las posiciones de comienzo y fin del subvector, como por ejemplo
62
Seleccion(a,prim,ult). Para ordenar todo el vector, basta con invocar al procedimiento con los
valores prim= 1, ult = n
Haremos uso de dos funciones que permiten determinar la posición de los elementos máximo y
mínimo de un subvector dado:
Y utilizaremos un procedimiento para intercambiar dos elementos de un vector:
Veamos los tiempos de ejecución de cada una de ellas:
63
a) El tiempo de ejecución de la función PosMaximo va a depender, además del tamaño del
subvector de entrada, de su ordenación inicial, y por tanto distinguiremos tres casos: – En
el caso mejor, la condición del IF es siempre falsa. Por tanto:
En el caso peor,la condicióndel IFessiempre verdadera.Porconsiguiente:
En el caso medio, vamos a suponer que la condición del IF será verdadera en el 50% de los
casos. Por tanto:
Estos casos corresponden respectivamente a cuando el elemento máximo se encuentra en
la primera posición, en la última y el vector está ordenado de forma creciente, o cuando
consideramos equiprobables cada una de las n posiciones en donde puede encontrarse el
máximo. Como podemos apreciar, en cualquiera de los tres casos su complejidad es lineal
con respecto al tamaño de la entrada.
b) El tiempo de ejecución de la función PosMinimo es exactamente igual al de la función
PosMaximo.
c) La función Intercambia realiza 7 operaciones elementales (3 asignaciones y 4 accesos
al vector), independientemente de los datos de entrada.
Nótese que en las funciones PosMaximo y PosMinimo hemos utilizado el paso del vector
a por referencia en vez de por valor (mediante el uso de VAR) para evitar la copia del vector
64
en la pila de ejecución, lo que incrementaría la complejidad del algoritmo resultante, pues
esa copia es de orden O(n)
.
65
K-ésimo
Sea T[1..n] un arreglo de enteros y sea k un entero entre 1 y n. El k − esimo menor elemento de T
se define como el elemento que estar´ıa en la posici´on k si los elementos en el arreglo estuvieran
ordenados de manera creciente. El problema de encontrar el k−esimo elemento se conoce como el
problema de la selección
EL algoritmo de selección y reemplazo ordena los elementos de un arreglo nativo sin utilizar un
segundo arreglo. El algoritmo se basa en la siguiente idea. En el arreglo ordenado, el mínimo debe
quedar en a[0], de modo que se ubica su posición real en el arreglo. Por ejemplo, se determina que
está en a[3] como lo indica el primer arreglo de izquierda a derecha en la siguiente figura:
Entonces se procede a intercambiar los valores de a[0] con a[3]. Ahora, a[0] contiene el valor
correcto y por lo tanto, nos olvidamos de él. A continuación, el algoritmo ordena el arreglo desde
el índice 1 hasta el 5, ignorando a[0]. Por lo tanto, ubica nuevamente la posición del mínimo entre
a[1], a[2], ..., a[5] y lo intercambia con el elemento que se encuentra en a[1] como se indica en el
segundo arreglo de la figura. Ahora, a[0] y a[1] contienen los valores correctos. Y así el algoritmo
continúa hasta que a[0], a[1], ..., a[5] tengan los valores ordenados.
66
El algoritmo para ordenar un arreglo a de n elementos es:
Realizar un ciclo de conteo para la variable i, de modo que tome valores desde 0 hasta n-1. En cada
iteración:
 Suponer que el arreglo se encuentra ordenado desde a[0] hasta a[i-1].
 Ubicar k>=i, tal que a[k]<=a[j], para todo j en [i,n-1].
 Intercambiar los valores de a[k] y a[i].
Programa:
void seleccion(int[ ] a, int n) {
for (int i= 0; i<n; i++) {
// Buscamos la posicion del minimo en a[i], a[i+1], ..., a[n-1]
int k= i;
for (int j= i+1; j<n; j++) {
if (a[j]<a[k])
k= j;
}
// intercambiamos a[i] con a[k]
int aux= a[i];
a[i]= a[k];
a[k]= aux;
}
}
Caso de borde: cuando la posición del mínimo es k==i. Entonces se intercambia a[i] con a[i]. Una
rápida revisión del intercambio permite apreciar que en ese caso a[i] no altera su valor, y por lo
tanto no es incorrecto.
Se podría introducir una instrucción if para que no se realice el intercambio cuando k==i, pero esto
sería menos eficiente que realizar el intercambio aunque no sirva. En efecto, en algunos casos el if
permitiría ahorrar un intercambio, pero en la mayoría de los casos, el if no serviría y sería un
sobrecosto que excedería con creces lo ahorrado cuando sí sirve.
67
Análisis del tiempo de ordenamiento por selección y reemplazo
Sea t1 el tiempo que toma una iteración del ciclo interno cuando la condición es verdadera. Sea t2
el tiempo que toma una interación del ciclo externo sin contar el tiempo que toma el ciclo interno.
Entonces el tiempo de ejecución en función de n es igual a:
T(n) <= n*t2+(n-1)*t1+(n-2)*t1+(n-3)*t1+...+1*t1
Desarrollando esta inecuación se llega a:
T(n) <= A*n^2+B*n+C
Con A, B y C constantes a determinar. Por lo tanto es fácil probar que:
T(n) = O(n^2)
De la misma forma se puede probar que en el mejor caso y en el caso promedio, el algoritmo
también es O(n^2). Por lo tanto:
 Mejor caso: O(n^2)
 Peor caso: O(n^2)
 Caso promedio: O(n^2)
Comparación con ordenamiento con colas:
Es fácil probar que el algoritmo basado en colas también es O(n^2) cuando las operaciones agregar
y extraer en la cola toman tiempo constante. Esto no es necesariamente cierto porque depende de
cómo esté implementada la clase Queue. En todo caso sí es cierto si la cola se implementa con
arreglos nativos o con las listas enlazadas que veremos pronto.
La siguiente es una comparación empírica de los tiempos de ejecución de ambos algoritmos:
68
Númerode Tiempode Ord. Tiempode Ord.
elementos con colas con arreglos
100 103 miliseg. 4 miliseg.
500 3638 miliseg. 86 miliseg.
1000 20 seg. 0.3 seg.
2000 121 seg. 1.4 seg.
100000 más de 3 días 1 hora
Se puede observar que el uso de arreglos nativos es unas 50 veces más eficiente que la cola. Sin
embargo, el algoritmo de ordenamiento por selección y reemplazo sigue siendo O(n^2) y por lo
tanto es ineficiente.
En efecto, si consideramos el problema de la clase pasada en que se necesitaba ordenar para luego
realizar búsquedas eficientes, esta solución no sirve, porque el ordenamiento tiene un sobrecosto
que excede las ganancias de la búsqueda binaria.
69
Colas de prioridad
Una cola de prioridad es una colección de elementos donde cada elemento tiene asociado un valor
susceptible de ordenación denominado prioridad. Una cola de prioridad se caracteriza por admitir
inserciones de nuevos elementos y la consulta y eliminación del elemento de mínima (o máxima)
prioridad.
Se puede usar una cola de prioridad para hallar el k-ésimo elemento de un vector no ordenado. Se
colocan los k primeros elementos del vector en una max-cola de prioridad y a continuación se
recorre el resto del vector, actualizando la cola de prioridad cada vez que el elemento es menor que
el mayor de los elementos de la cola, eliminando al máximo e insertando el elemento en curso.
Las estructuras de datos que implementan colas de prioridad contienen registros con claves
numéricas (prioridades), y cuentan con algunas de las siguientes operaciones:
Construir una cola de prioridad a partir de N elementos
Insertar un nuevo elemento
Suprimir el elemento más grande
Sustituir el elemento más grande por un nuevo elemento
Cambiar la prioridad de un elemento
Eliminar un elemento arbitrario determinado
Unir dos colas de prioridad en una más grande.
70
Heaps
La estructura heap es frecuentemente usada para implementar colas de prioridad. En este tipo de
colas, el elemento a ser eliminado (borrados) es aquél que tiene mayor (o menor) prioridad. En
cualquier momento, un elemento con una prioridad arbitraria puede ser insertado en la cola. Una
estructura de datos que soporta estas dos operaciones es la cola de prioridad máxima (mínima).
Existen tres categorías de un heap: max heap, min heap y min-max heap.
Un max (min) tree es un árbol en el cual el valor de la llave de cada nodo no es menor (mayor)
que la de los valores de las llaves de sus hijos (si tiene alguno). Un max heap es un árbol binario
completo que es también un max tree. Por otra parte, un min heap es un árbol binario completo
que es también un min tree.
De la definición se sabe que la llave del root de un min tree es la menor llave del árbol, mientras
que la del root de un max tree es la mayor.
Si la llave (key) de cada nodo es mayor que o igual a las llaves de sus hijos, entonces la estructura
71
heap es llamada max heap.
Si la llave (key) de cada nodo es menor que o igual a las llaves d esus hijos, entonces la estructura
heap es llamada min heap.
En una estructura min-max heap, un nivel satisface la propiedad min heap, y el siguiente nivel
inferior satisface la propiedad max heap, alternadamente. Un min-max heap es útil para colas de
prioridad de doble fin.
72
Las operaciones básicas de un heap son:
 Creación de un heap vacío
 Inserción de un nuevo elemento en la estructura heap.
 Eliminación del elemento más grande del heap.
Su único requisito es que sólo es posible acceder a ellos a través de un puntero.
Ventajas:
 Soporta las operaciones insertar y suprimir en tiempo O(log N) en el caso peor.
 Soporta insertar en tiempo constante en promedio y primero en tiempo constante en el
peor caso.
Un heap tiene las siguientes tres propiedades:
 Es completo, esto es, las hojas de un árbol están en a lo máximo dos niveles adyacentes, y
las hojas en el último nivel están en la posición leftmost.
73
 Cada nivel en un heap es llenado en orden de izquierda a derecha.
 Está parcialmente ordenado, esto es, un valor asignado, llamado key del elemento
almacenado en cada nodo (llamado parent), es menor que (mayor que) o igual a las llaves
almacenadas en los hijos de los nodos izquierdo y derecho.
74
Heapsort
Heapsort ordena un vector de n elementos construyendo un heap con los n elementos y
extrayéndolos, uno a uno del heap a continuación. El propio vector que almacena a los n elementos
se emplea para construir el heap, de modo que heapsort actúa in-situ y sólo requiere un espacio
auxiliar de memoria constante. El coste de este algoritmo es (n log n) (incluso en caso mejor) si
todos los elementos son diferentes. En la práctica su coste es superior al de quicksort, ya que el
factor constante multiplicativo del término n log n es mayor.
Características:
 Es un algoritmo que se construye utilizando las propiedades de los montículos binarios.
 El orden de ejecución para el peor caso es O(N·log(N)), siendo N el tamaño de la entrada.
 Aunque teóricamente es más rápido que los algoritmos de ordenación vistos hasta aquí, en
la práctica es más lento que el algoritmo de ordenación de Shell utilizando la secuencia de
incrementos de Sedgewick.
Breve repaso de las propiedades de los montículos binarios (heaps)
Recordemos que un montículo Max es un árbol binario completo cuyos elementos están ordenados
del siguiente modo: para cada subárbol se cumple que la raíz es mayor que ambos hijos. Si el
montículo fuera Min, la raíz de cada subárbol tiene que cumplir con ser menor que sus hijos.
Recordamos que, si bien un montículo se define como un árbol, para representar éste se utiliza un
array de datos, en el que se acceden a padres e hijos utilizando las siguientes transformaciones
75
sobre sus índices. Si el montículo está almacenado en el array A, el padre de A[i] es A[i/2]
(truncando hacia abajo), el hijo izquierdo de A[i] es A[2*i] y el hijo derecho de A[i] es A[2*i+1].
Al insertar o eliminar elementos de un montículo, hay que cuidar de no destruir la propiedad de
orden del montículo. Lo que se hace generalmente es construir rutinas de filtrado (que pueden ser
ascendentes o descendentes) que tomen un elemento del montículo (el elemento que viola la
propiedad de orden) y lo muevan vérticalmente por el árbol hasta encontrar una posición en la cual
se respete el orden entre los elementos del montículo.
Tanto la inserción como la eliminación (eliminar_min o eliminar_max según sea un montículo Min
o Max respectivamente), de un elemento en un montículo se realizan en un tiempo O(log(N)), peor
caso (y esto se debe al orden entre sus elementos y a la característica de árbol binario completo).
Estrategia general del algoritmo.
A grandes razgos el algoritmo de ordenación por montículos consiste en meter todos los elementos
del array de datos en un montículo MAX, y luego realizar N veces eliminar_max(). De este modo,
la secuencia de elementos eliminados nos será entregada en orden decreciente.
Implementación de Heapsort
Heapsort tiene un orden de magnitud que coincide con la cota inferior, esto es, es óptimo incluso
en el peor caso. Nótese que esto no era así para Quicksort, el cual era óptimo en promedio, pero no
en el peor caso.
De acuerdo a la descripción de esta familia de algoritmos, daría la impresión de que en la fase de
construcción del heap se requeriría un arreglo aparte para el heap, distinto del arreglo de entrada.
76
De la misma manera, se requeriría un arreglo de salida aparte, distinto del heap, para recibir los
elementos a medida que van siendo extraídos en la fase de ordenación.
En la práctica, esto no es necesario y basta con un sólo arreglo: todas las operaciones pueden
efectuarse directamente sobre el arreglo de entrada.
En primer lugar, en cualquier momento de la ejecución del algoritmo, los elementos se encuentran
particionados entre aquellos que están ya o aún formando parte del heap, y aquellos que se
encuentran aún en el conjunto de entrada, o ya se encuentran en el conjunto de salida, según sea la
fase. Como ningún elemento puede estar en más de un conjunto a la vez, es claro que, en todo
momento, en total nunca se necesita más de n casilleros de memoria, si la implementación se
realiza bien.
En el caso de Heapsort, durante la fase de construcción del heap, podemos utilizar las celdas de la
izquierda del arreglo para ir "armando" el heap. Las celdas necesarias para ello se las vamos
"quitando" al conjunto de entrada, el cual va perdiendo elementos a medida que se insertan en el
heap. Al concluir esta fase, todos los elementos han sido insertados, y el arreglo completo es un
solo gran heap.
77
En la fase de ordenación, se van extrayendo elementos del heap, con lo cual este se contrae de
tamaño y deja espacio libre al final, el cual puede ser justamente ocupado para ir almacenando los
elementos a medida que van saliendo del heap (recordemos que van apareciendo en orden
decreciente).
Optimización de la fase de construcción del heap
Como se ha señalado anteriormente, tanto la fase de construcción del heap como la de ordenación
demoran tiempo O(n log n). Esto es el mínimo posible (en orden de magnitud), de modo que no es
posible mejorarlo significativamente.
Sin embargo, es posible modificar la implementación de la fase de construcción del heap para que
sea mucho más eficiente.
La idea es invertir el orden de las "mitades" del arreglo, haciendo que el "input" esté a la izquierda
y el "heap" a la derecha.
En realidad, si el "heap" está a la derecha, entonces no es realmente un heap, porque no es un árbol
completo (le falta la parte superior), pero sólo nos interesa que en ese sector del arreglo se cumplan
78
las relaciones de orden entre a[k] y {a[2*k],a[2*k+1]}. En cada iteración, se toma el último
elemento del "input" y se le "hunde" dentro del heap de acuerdo a su nivel de prioridad.
Al concluir, se llega igualmente a un heap completo, pero el proceso es significativamente más
rápido.
La razón es que, al ser "hundido", un elemento paga un costo proporcional a su distancia al fondo
del árbol. Dada las características de un árbol, la gran mayoría de los elementos están al fondo o
muy cerca de él, por lo cual pagan un costo muy bajo. En un análisis aproximado, la mitad de los
elementos pagan 0 (ya están al fondo), la cuarta parte paga 1, la octava parte paga 2, etc. Sumando
todo esto, tenemos que el costo total está acotado por
Para realizar un ordenamiento mediante HeapSort, es necesario generar primero la estructura Heap,
esta se puede hacer utilizando el procedimiento Shift descrito anteriormente, iniciando con una
79
estructura Heap completamente desordenada, es decir, insertar los elementos en la estructura, en
cualquier orden, y posteriormente realizar una operación Shift para cada elemento en la estructura.
El algoritmo HeapSort, consiste en remover el mayor elemento que es siempre la raíz del Heap,
una vez seleccionado el máximo, lo intercambiamos con el último elemento del vector,
decrementamos la cantidad de elementos del Heap e invocamos Shift a partir de la raíz.
Analizando el algoritmo Shift.- Sabemos que un árbol completo tiene log n niveles. La cantidad de
trabajo que se hace en cada nivel es constante, por lo tanto Shift es O(log n).
Ya que HeapSort realiza n - 1 llamadas a Shift (no se cuenta la raíz original del árbol), y cada
llamada es O(log n). Por lo tanto el tiempo total es O((n-1)log n) = O(nlog n).
80
Memoria secundaria
Árboles B
Los B-árboles son árboles cuyos nodos pueden tener un número múltiple de hijos tal como muestra
el esquema de uno de ellos en la siguiente figura .
Como se puede observar en la figura ,un B-árbol se dice que es de orden m si sus nodos pueden
contener hasta un máximo de m hijos.En la literatura también aparece que si un árbol es de
orden m significa que el mínimo número de hijos que puede tener es m+1(m claves).Nosotros no
la usaremos para diferenciar el caso de un número máximo par e impar de claves en un nodo.
El conjunto de claves que se sitúan en un nodo cumplen la condición:
de formaque los elementosque cuelgandel primerhijotienenunaclave con valormenorque K1,losque
cuelgan del segundo tienen una clave con valor mayor que K1 y menor que K2,etc...Obviamente,los que
81
cuelgandel últimohijotienenunaclave convalormayor que laúltimaclave(hayque tenerencuentaque
el nodo puede tener menos de m hijos y por consiguiente menos de m-1 claves).
Para que un árbol sea B-árbol además deberá cumplir lo siguiente:
 Todos los nodos excepto la raíz tienen al menos E((m-1)/2) claves.Lógicamente para los
nodos interiores eso implica que tienen al menos E((m+1)/2) hijos.
 Todas las hojas están en el mismo nivel.
El hecho de que la raíz pueda tener menos descendientes se debe a que si el crecimiento del árbol
hace que la raíz se divida en dos hay que permitir dicha situación para que los nuevos nodos
mantengan esa propiedad.En el caso de que eso ocurra en un nodo interior distinto a la raíz se
soluciona propagando hacia arriba;lógicamente esta operación no se puede realizar en el caso de
raíz.
Por otro lado,con el hecho de que los nodos interiores tengan un número mínimo de descendientes
aseguramos que en el nivel n(nivel 1 corresponde a la raíz)haya un mínimo de 2En-1((m+1)/2)(el 2
es el mínimo de hijos de la raíz y E((m+1)/2) el mínimo para los demás)y teniendo en cuenta que
un árbol con N claves tiene N+1 descendientes en el nivel de las hojas,podemos establecer la
siguiente desigualdad:
Resolviendo:
82
que nos da una cota superior del número de nodos a recorrer para localizar un elemento en el árbol.
BÚSQUEDA EN UN B-ÁRBOL.
Localizar una clave en un B-árbol es una operación simple pues consiste en situarse en el nodo raíz
del árbol,si la clave se encuentra ahí hemos terminado y si no es así seleccionamos de entre los
hijos el que se encuentra entre dos valores de clave que son menor y mayor que la buscada
respectivamente y repetimos el proceso hasta que la encontremos.En caso de que se llegue a una
hoja y no podamos proseguir la búsqueda la clave no se encuentra en el árbol.En definitiva,los
pasos a seguir son los siguientes:
1. Seleccionar como nodo actual la raíz del árbol.
2. Comprobar si la clave se encuentra en el nodo actual:
1. Si la clave está, fin.
2. Si la clave no está:
 Si estamos en una hoja,no se encuentra la clave.Fin.
 Si no estamos en una hoja,hacer nodo actual igual al hijo que corresponde
según el valor de la clave a buscar y los valores de las claves del nodo
actual(i buscamos la clave K en un nodo con n claves:el hijo izquierdo
si K<K1,el hijo derecho si K>Kn y el hijo i-ésimo si Ki<K<Ki+1)yvolver al
segundo paso.
INSERCIÓN EN UN B-ÁRBOL.
Para insertar una nueva clave usaremos un algoritmo que consiste en dos pasos recursivos:
83
1. Buscamos la hoja donde debieramos encontrar el valor de la clave de una forma totalmente
paralela a la búsqueda de ésta tal como comentabamos en la sección anterior(si en esta
búsqueda encontramos en algun lugar del árbol la clave a insertar,el algoritmo no debe
hacer nada más).Si la clave no se encuentra en el árbol habremos llegado a una hoja que es
justamente el lugar donde debemos realizar esa inserción.
2. Situados en un nodo donde realizar la inserción si no está completo,es decir,si el número
de claves que existen es menor que el orden menos 1 del árbol,el elemento puede ser
insertado y el algoritmo termina.En caso de que el nodo esté completo insertamos la clave
en su posición y puesto que no caben en un único nodo dividimos en dos nuevos nodos
conteniendo cada uno de ellos la mitad de las claves y tomando una de éstas para insertarla
en el padre(se usará la mediana).Si el padre está también completo,habrá que repetir el
proceso hasta llegar a la raíz.En caso de que la raíz esté completa,la altura del árbol aumenta
en uno creando un nuevo nodo raíz con una única clave.
En la figura podemos observar el efecto de insertar una nueva clave en un nodo que está lleno.
84
85
Podemos realizar una modificación al algoritmo de forma que se retrase al máximo el momento de
romper un nodo en dos.Con ello podríamos vernos beneficiados por dos razones
fundamentalmente:
1. La razón más importante para modificar así el algoritmo es que los nodos en el árbol están
más llenos con lo cual el gasto en memoria para mantener la estructura es mucho menor.
2. Retrasamos el momento en que la raíz llega a dividirse y por consiguiente retrasamos el
momento en que la altura del árbol aumenta.
La forma más sencilla de realizar esta modificación es que en el caso de que tengamos que realizar
esa división,antes de llevarla a cabo,comprobemos si los hermanos adyacentes tienen espacio libre
de forma que si alguno de ellos lo tiene se redistribuyen las claves que se encuentran en el nodo
actual más las de ese hermano m&as la clave que los separa(que se encuentra en el padre)más la
clave a insertar de forma que en el padre se queda la mediana y las demás quedan distribuidas entre
los dos nodos.
En la siguiente figura podemos observar el efecto de insertar una nueva clave en un nodo que está
lleno pero con redistribución.
86
BORRADO EN UN B-ÁRBOL.
La idea para realizar el borrado de una clave es similar a la inserción teniendo en cuenta que
ahora,en lugar de divisiones,realizamos uniones.Existe un problema añadido,las claves a borrar
pueden aparecer en cualquier lugar del árbol y por consiguiente no coincide con el caso de la
inserción en la que siempre comenzamos desde una hoja y propagamos hacia arriba.La solución a
esto es inmediata pues cuando borramos una clave que está en un nodo interior,lo primero que
realizamos es un intercambio de este valor con el inmediato sucesor en el árbol,es decir,el hijo más
a la izquierda del hijo derecho de esa clave.
Las operaciones a realizar para poder llevar a cabo el borrado son por tanto:
1. Redistribución:la utilizaremos en el caso en que al borrar una clave el nodo se queda con
un número menor que el mínimo y uno de los hermanos adyacentes tiene al menos uno más
que ese mínimo,es decir,redistribuyendo podemos solucionar el problema.
87
2. Unión:la utilizaremos en el caso de que no sea posible la redistribución y por tanto sólo será
posible unir los nodos junto con la clave que los separa y se encuentra en el padre.
En definitiva,el algoritmo nos queda como sigue:
1. Localizar el nodo donde se encuentra la clave.
2. Si el nodo localizado no es una hoja,intercambiar el valor de la clave localizada con el valor
de la clave más a la izquierda del hijo a la derecha.En definitiva colocar la clave a borrar
en una hoja.Hacemos nodo actual igual a esa hoja.
3. Borrar la clave.
4. Si el nodo actual contiene al menos el mínimo de claves como para seguir siendo un B-
árbol,fin.
5. Si el nodo actual tiene un número menor que el mínimo:
1. Si un hermano tiene más del mínimo de claves,redistribución y fin.
2. Si ninguno de los hermanos tiene más del mínimo,unión de dos nodos junto con la
clave del padre y vuelta al paso 4 para propagar el borrado de dicha clave(ahora en
el padre).
88
Hashing extendible
Cuando se almacena información en memoria secundaria (disco), la función de costo pasa a ser el
número de accesos a disco. En hashing para memoria secundaria, la función de hash sirve para
escoger un bloque (página) del disco, en donde cada bloque contiene b elementos. El factor de
carga pasa a ser . Por ejemplo, utilizando hashing encadenado:
Este método es eficiente para un factor de carga pequeño, ya que con factor de carga alto la
búsqueda toma tiempo O(n). Para resolver esto puede ser necesario incrementar el tamaño de la
tabla, y así reducir el factor de carga. En general esto implica reconstruir toda la tabla, pero existe
un método que permite hacer crecer la tabla paulatinamente en el tiempo denominado hashing
extendible.
Hashing extendible
Suponga que las páginas de disco son de tamaño b y una función de hash h(X)>=0 (sin límite
superior). Sea la descomposición en binario de la función de hash h(X)=(... b2(X) b1(X) b0(X))2.
89
Inicialmente, todas las llaves se encuentran en una única página. Cuando dicha página se rebalsa
se divide en dos, usando b0(X) para discriminar entre ambas páginas:
Cada vez que una página rebalsa, se usa el siguiente bit en la sequencia para dividirla en dos.
Ejemplo:
El índice (directorio) puede ser almacenado en un árbol, pero el hashing extensible utiliza una idea
diferente. El árbol es extendido de manera que todos los caminos tengan el mismo largo:
A continuación, el árbol es implementado como un arreglo de referencias:
90
Cuando se inserta un elemento y existe un rebalse, si cae en la página (D,E) esta se divide y las
referencias de los casilleros 2 y 3 apuntan a las nuevas páginas creadas. Si la página (B,C) es la que
se divide, entonces el árbol crece un nivel, lo cual implica duplicar el tamaño del directorio. Sin
embargo, esto no sucede muy a menudo.
El tamaño esperado del directorio es ligeramente superlinear: .
91
Unión Find
La estructura Union-Find, también llamada Disjoint-set Union (abreviado DSU), es una estructura
que nos permite manejar conjuntos disjuntos de elementos. Un ejemplo de esto en grafos son las
componentes conexas, que son conjuntos disjuntos de nodos.
Cada conjunto va a tener un representante que lo identifica, y las operaciones que debemos poder
realizar eficientemente son:
 find(x): Dado un elemento x, nos dice quién es el representante del conjunto al que pertenece.
 union(x,y): Dados dos elementos x e y, unir los conjuntos, es decir, que pasen a ser uno solo,
con un solo representantes para los elementos de ambos conjuntos.
La primera idea poco eficiente que suele surgir, es simplemente tener, para cada elemento, su
representante (lo que facilita la función union), y que al intentar unir dos conjuntos con los
elementos x e y, recorremos todos los elementos, y si su representante es el mismo que el
representante de x (pertenecen al mismo conjunto actualmente), entonces su representante pasa a
ser el representante de y.
Esto podemos hacerlo con un vector que en la posición ii tenga el representante del elemento i.
Pero esto es poco pues eficiente, pues si bien chequear el representante es O(1),unir dos conjuntos
es O(n) donde n es la cantidad total de elementos, pues los revisamos todos.
Optimizaciones
Una posible mejora, es, además de guardar el representante de cada elemento, para cada
representante guardar una lista con los elementos de su conjunto. Luego, al unir x con y,
92
recorremos el conjunto más chico de los que contienen al x y al y (mirando las listas de sus
representantes), y agregamos todos sus elementos a la lista de y, cambiamos sus representantes por
el representante de y, y vaciamos la lista del representante de x.
Con esta mejora, como a cada elemento lo agregamos a un conjunto más grande que en el que está,
se puede ver que si hay O(m) operaciones, a cada elemento lo agregamos a lo sumo a O(log(m))
conjuntos, por lo que la complejidad queda de O(m∗log(m))
Podemos pensar que cada conjunto es un árbol. El representante será la raíz, saber la raíz de un
árbol (o sea el representante de un conjunto) es ir yendo a los padres de cada elemento, y agregar
el conjunto de representante rx al de representante ry, es decirle a rx que su padre es ry, que sería
como colgar el árbol de rx de la raíz ry. Si colgamos el árbol de menor tamaño al de mayor, como
antes con las listas, haremos menos operaciones. Y si además de esto, al buscar el padre de un
elemento recursivamente, lo actualizamos cuando encontremos la raíz, las alturas de los árboles se
achican muchísimo, y al calcular el representante de un elemento dos veces por ejemplo, en la
primera lo encontramos, se lo asignamos como padre, y luego al buscar el representante es subir
una sola vez.
La complejidad es un poco más baja que O(m∗log(m)), podría decirse que es lineal: para O(m)
operaciones el tiempo que lleva procesarlas es casi O(m).
Unión por tamaño
Una observación que podemos hacer es que este inconveniente aparece porque vamos uniendo la
larga cadena de nodos de forma que siempre se añade la cadena como hija de un nuevo nodo, nunca
se crean ramificaciones. Si en lugar de hacer unionSet(1, 2), unionSet(2, 3), unionSet(3,
4) hiciéramos unionSet(2, 1), unionSet(3, 2), unionSet(4, 3), tendríamos todo lo contrario: en lugar
93
de una larga cadena, tendríamos el elemento 1 como elemento representativo y todos los demás
elementos directamente conectados con él.
Los dos árboles representan el mismo conjunto, pero el de la derecha responde más eficientemente.
La idea importante es que no estamos obligados a unir los nodos en el orden tal como se nos da en
los parámetros de la función: como da igual qué elemento sea el representativo, podemos tratar de
hacerlo de la forma más conveniente. Un posible criterio para unir dos conjuntos que evita el
problema de las cadenas largas es la unión por tamaño: ponemos como padre el elemento
característico del conjunto de mayor tamaño (desempatando arbitrariamente). Se puede intuir que
esto evitará que se formen cadenas muy largas, pero se puede establecer una cota formalmente.
Así, tenemos que utilizando este criterio para la unión, cada operación tiene un coste en el peor
caso de O(logn)Bastante mejor que el O(n) que teníamos sin utilizar esta heurística. Para
implementar este criterio, almacenamos en un vector adicional el tamaño del subárbol que hay bajo
cada elemento y lo actualizamos después de cada unión.
void unionSet(int u, int v) {
int ru = findSet(u);
int rv = findSet(v);
if(size[ru] < size[rv]) {
p[ru] = rv;
size[rv] += size[ru];
94
}
else {
p[rv] = ru;
size[ru] += size[rv];
}
}
Compresión de caminos
Olvidemos por un momento la mejora anterior y volvamos al problema que teníamos inicialmente:
cadenas demasiado largas, cada vez que llamamos a findSet tenemos un coste O(n). Otra idea
distinta para mejorar esto es que no tenemos que recorrer toda la cadena de nodos varias veces.
Una vez hemos encontrado el elemento representativo de un conjunto, podemos hacer que todos
los nodos por los que hemos pasado apunten directamente a ese elemento representativo para que
las consultas posteriores sean más rápidas. Así, “comprimimos” los caminos después de
recorrerlos.
Nótese que con esta mejora el coste de una operación en el peor caso sigue siendo O(n), ya que
seguimos pudiendo generar cadenas arbitrariamente largas, aunque después se compriman cuando
las recorremos. Sin embargo, el coste amortizado de una operación, es decir, el coste promedio de
las operaciones dentro de una secuencia de operaciones, es mucho menor. En el ejemplo de generar
una cadena de mm nodos: en cada una de las m−1 uniones se visitan O(1) nodos, así que en total
el coste de las uniones es O(m), y después hacer una operación findSet tiene un coste O(m), así
95
que el coste total de las mm operaciones es O(m)+O(m)=O(m) y por tanto el coste promedio por
operación es O(1). Es decir, como para construir la cadena se necesitan m−1operaciones muy
rápidas, aunque luego hagas una operación lenta (que si la repites deja de ser lenta porque se
comprime el camino) el coste promedio de las operaciones sigue siendo bajo.
Implementar esto es muy fácil tal como teníamos implementada la función findSet:
int findSet(int i) {
if(p[i] != i) p[i] = findSet(p[i]);
return p[i];
}
Manipulacion de conjuntos
Manipulacion de conjuntos

More Related Content

Similar to Manipulacion de conjuntos

Sorting-algorithmbhddcbjkmbgjkuygbjkkius.pdf
Sorting-algorithmbhddcbjkmbgjkuygbjkkius.pdfSorting-algorithmbhddcbjkmbgjkuygbjkkius.pdf
Sorting-algorithmbhddcbjkmbgjkuygbjkkius.pdf
ArjunSingh81957
 
Data Structures 6
Data Structures 6Data Structures 6
Data Structures 6
Dr.Umadevi V
 
Chapter 8 advanced sorting and hashing for print
Chapter 8 advanced sorting and hashing for printChapter 8 advanced sorting and hashing for print
Chapter 8 advanced sorting and hashing for print
Abdii Rashid
 
Module 2_ Divide and Conquer Approach.pptx
Module 2_ Divide and Conquer Approach.pptxModule 2_ Divide and Conquer Approach.pptx
Module 2_ Divide and Conquer Approach.pptx
nikshaikh786
 
Algo PPT.pdf
Algo PPT.pdfAlgo PPT.pdf
Algo PPT.pdf
sheraz7288
 
Algorithm in computer science
Algorithm in computer scienceAlgorithm in computer science
Algorithm in computer science
Riazul Islam
 
Chapter 11 - Sorting and Searching
Chapter 11 - Sorting and SearchingChapter 11 - Sorting and Searching
Chapter 11 - Sorting and Searching
Eduardo Bergavera
 
Unit vii sorting
Unit   vii sorting Unit   vii sorting
Unit vii sorting
Tribhuvan University
 
Sorting algorithums > Data Structures & Algorithums
Sorting algorithums  > Data Structures & AlgorithumsSorting algorithums  > Data Structures & Algorithums
Sorting algorithums > Data Structures & Algorithums
Ain-ul-Moiz Khawaja
 
Sorting in data structures and algorithms , it has all the necessary points t...
Sorting in data structures and algorithms , it has all the necessary points t...Sorting in data structures and algorithms , it has all the necessary points t...
Sorting in data structures and algorithms , it has all the necessary points t...
BhumikaBiyani1
 
Sorting
SortingSorting
Sorting
BHARATH KUMAR
 
CSE680-07QuickSort.pptx
CSE680-07QuickSort.pptxCSE680-07QuickSort.pptx
CSE680-07QuickSort.pptx
DeepakM509554
 
Insertion Sort, Quick Sort And Their complexity
Insertion Sort, Quick Sort And Their complexityInsertion Sort, Quick Sort And Their complexity
Insertion Sort, Quick Sort And Their complexity
Motaleb Hossen Manik
 
Sorting algorithms
Sorting algorithmsSorting algorithms
Sorting algorithms
Zaid Hameed
 
Bs,qs,divide and conquer 1
Bs,qs,divide and conquer 1Bs,qs,divide and conquer 1
Bs,qs,divide and conquer 1
subhashchandra197
 
Sorting
SortingSorting
Sorting
Sameer Memon
 
Basics in algorithms and data structure
Basics in algorithms and data structure Basics in algorithms and data structure
Basics in algorithms and data structure
Eman magdy
 
Algorithm & data structures lec4&5
Algorithm & data structures lec4&5Algorithm & data structures lec4&5
Algorithm & data structures lec4&5
Abdul Khan
 
simple-sorting algorithms
simple-sorting algorithmssimple-sorting algorithms
simple-sorting algorithms
Ravirajsinh Chauhan
 
Sorting
SortingSorting

Similar to Manipulacion de conjuntos (20)

Sorting-algorithmbhddcbjkmbgjkuygbjkkius.pdf
Sorting-algorithmbhddcbjkmbgjkuygbjkkius.pdfSorting-algorithmbhddcbjkmbgjkuygbjkkius.pdf
Sorting-algorithmbhddcbjkmbgjkuygbjkkius.pdf
 
Data Structures 6
Data Structures 6Data Structures 6
Data Structures 6
 
Chapter 8 advanced sorting and hashing for print
Chapter 8 advanced sorting and hashing for printChapter 8 advanced sorting and hashing for print
Chapter 8 advanced sorting and hashing for print
 
Module 2_ Divide and Conquer Approach.pptx
Module 2_ Divide and Conquer Approach.pptxModule 2_ Divide and Conquer Approach.pptx
Module 2_ Divide and Conquer Approach.pptx
 
Algo PPT.pdf
Algo PPT.pdfAlgo PPT.pdf
Algo PPT.pdf
 
Algorithm in computer science
Algorithm in computer scienceAlgorithm in computer science
Algorithm in computer science
 
Chapter 11 - Sorting and Searching
Chapter 11 - Sorting and SearchingChapter 11 - Sorting and Searching
Chapter 11 - Sorting and Searching
 
Unit vii sorting
Unit   vii sorting Unit   vii sorting
Unit vii sorting
 
Sorting algorithums > Data Structures & Algorithums
Sorting algorithums  > Data Structures & AlgorithumsSorting algorithums  > Data Structures & Algorithums
Sorting algorithums > Data Structures & Algorithums
 
Sorting in data structures and algorithms , it has all the necessary points t...
Sorting in data structures and algorithms , it has all the necessary points t...Sorting in data structures and algorithms , it has all the necessary points t...
Sorting in data structures and algorithms , it has all the necessary points t...
 
Sorting
SortingSorting
Sorting
 
CSE680-07QuickSort.pptx
CSE680-07QuickSort.pptxCSE680-07QuickSort.pptx
CSE680-07QuickSort.pptx
 
Insertion Sort, Quick Sort And Their complexity
Insertion Sort, Quick Sort And Their complexityInsertion Sort, Quick Sort And Their complexity
Insertion Sort, Quick Sort And Their complexity
 
Sorting algorithms
Sorting algorithmsSorting algorithms
Sorting algorithms
 
Bs,qs,divide and conquer 1
Bs,qs,divide and conquer 1Bs,qs,divide and conquer 1
Bs,qs,divide and conquer 1
 
Sorting
SortingSorting
Sorting
 
Basics in algorithms and data structure
Basics in algorithms and data structure Basics in algorithms and data structure
Basics in algorithms and data structure
 
Algorithm & data structures lec4&5
Algorithm & data structures lec4&5Algorithm & data structures lec4&5
Algorithm & data structures lec4&5
 
simple-sorting algorithms
simple-sorting algorithmssimple-sorting algorithms
simple-sorting algorithms
 
Sorting
SortingSorting
Sorting
 

More from Benjamín Joaquín Martínez

Sistemas de detección de intrusiones.pdf
Sistemas de detección de intrusiones.pdfSistemas de detección de intrusiones.pdf
Sistemas de detección de intrusiones.pdf
Benjamín Joaquín Martínez
 
Portafolio ingles.pdf
Portafolio ingles.pdfPortafolio ingles.pdf
Portafolio ingles.pdf
Benjamín Joaquín Martínez
 
Tabla de llamadas para linux x86_64 bits.pdf
Tabla de llamadas para linux x86_64 bits.pdfTabla de llamadas para linux x86_64 bits.pdf
Tabla de llamadas para linux x86_64 bits.pdf
Benjamín Joaquín Martínez
 
Sistema de registro con php
Sistema de registro con phpSistema de registro con php
Sistema de registro con php
Benjamín Joaquín Martínez
 
compiladores6Benjamin133467.pdf
compiladores6Benjamin133467.pdfcompiladores6Benjamin133467.pdf
compiladores6Benjamin133467.pdf
Benjamín Joaquín Martínez
 
Compiladores5_Benjamin133467.pdf
Compiladores5_Benjamin133467.pdfCompiladores5_Benjamin133467.pdf
Compiladores5_Benjamin133467.pdf
Benjamín Joaquín Martínez
 
133467 compiladores 4.pdf
133467 compiladores 4.pdf133467 compiladores 4.pdf
133467 compiladores 4.pdf
Benjamín Joaquín Martínez
 
133467_COMPILADORES3.pdf
133467_COMPILADORES3.pdf133467_COMPILADORES3.pdf
133467_COMPILADORES3.pdf
Benjamín Joaquín Martínez
 
133467_COMPILADORES2
133467_COMPILADORES2133467_COMPILADORES2
133467_COMPILADORES2
Benjamín Joaquín Martínez
 
COMPILADORES1.pdf
COMPILADORES1.pdfCOMPILADORES1.pdf
COMPILADORES1.pdf
Benjamín Joaquín Martínez
 
Algoritmos de búsqueda.pdf
Algoritmos de búsqueda.pdfAlgoritmos de búsqueda.pdf
Algoritmos de búsqueda.pdf
Benjamín Joaquín Martínez
 
Logica proposicional
Logica proposicionalLogica proposicional
Logica proposicional
Benjamín Joaquín Martínez
 
Lenguajes para dispositivos moviles 133467
Lenguajes para dispositivos moviles 133467Lenguajes para dispositivos moviles 133467
Lenguajes para dispositivos moviles 133467
Benjamín Joaquín Martínez
 
Bd distribuidas
Bd distribuidasBd distribuidas
diseño de bases de datos distribuidas
diseño de bases de datos distribuidas   diseño de bases de datos distribuidas
diseño de bases de datos distribuidas
Benjamín Joaquín Martínez
 
procesamiento de consultas distribuidas
procesamiento de consultas distribuidasprocesamiento de consultas distribuidas
procesamiento de consultas distribuidas
Benjamín Joaquín Martínez
 
Algoritmo de INGRES
Algoritmo de INGRES Algoritmo de INGRES
Algoritmo de INGRES
Benjamín Joaquín Martínez
 
Fragmentación
FragmentaciónFragmentación
Modelo cliente servidor
Modelo cliente servidorModelo cliente servidor
Modelo cliente servidor
Benjamín Joaquín Martínez
 
Arquitectura de bases de datos distribuidas
Arquitectura de bases de datos distribuidasArquitectura de bases de datos distribuidas
Arquitectura de bases de datos distribuidas
Benjamín Joaquín Martínez
 

More from Benjamín Joaquín Martínez (20)

Sistemas de detección de intrusiones.pdf
Sistemas de detección de intrusiones.pdfSistemas de detección de intrusiones.pdf
Sistemas de detección de intrusiones.pdf
 
Portafolio ingles.pdf
Portafolio ingles.pdfPortafolio ingles.pdf
Portafolio ingles.pdf
 
Tabla de llamadas para linux x86_64 bits.pdf
Tabla de llamadas para linux x86_64 bits.pdfTabla de llamadas para linux x86_64 bits.pdf
Tabla de llamadas para linux x86_64 bits.pdf
 
Sistema de registro con php
Sistema de registro con phpSistema de registro con php
Sistema de registro con php
 
compiladores6Benjamin133467.pdf
compiladores6Benjamin133467.pdfcompiladores6Benjamin133467.pdf
compiladores6Benjamin133467.pdf
 
Compiladores5_Benjamin133467.pdf
Compiladores5_Benjamin133467.pdfCompiladores5_Benjamin133467.pdf
Compiladores5_Benjamin133467.pdf
 
133467 compiladores 4.pdf
133467 compiladores 4.pdf133467 compiladores 4.pdf
133467 compiladores 4.pdf
 
133467_COMPILADORES3.pdf
133467_COMPILADORES3.pdf133467_COMPILADORES3.pdf
133467_COMPILADORES3.pdf
 
133467_COMPILADORES2
133467_COMPILADORES2133467_COMPILADORES2
133467_COMPILADORES2
 
COMPILADORES1.pdf
COMPILADORES1.pdfCOMPILADORES1.pdf
COMPILADORES1.pdf
 
Algoritmos de búsqueda.pdf
Algoritmos de búsqueda.pdfAlgoritmos de búsqueda.pdf
Algoritmos de búsqueda.pdf
 
Logica proposicional
Logica proposicionalLogica proposicional
Logica proposicional
 
Lenguajes para dispositivos moviles 133467
Lenguajes para dispositivos moviles 133467Lenguajes para dispositivos moviles 133467
Lenguajes para dispositivos moviles 133467
 
Bd distribuidas
Bd distribuidasBd distribuidas
Bd distribuidas
 
diseño de bases de datos distribuidas
diseño de bases de datos distribuidas   diseño de bases de datos distribuidas
diseño de bases de datos distribuidas
 
procesamiento de consultas distribuidas
procesamiento de consultas distribuidasprocesamiento de consultas distribuidas
procesamiento de consultas distribuidas
 
Algoritmo de INGRES
Algoritmo de INGRES Algoritmo de INGRES
Algoritmo de INGRES
 
Fragmentación
FragmentaciónFragmentación
Fragmentación
 
Modelo cliente servidor
Modelo cliente servidorModelo cliente servidor
Modelo cliente servidor
 
Arquitectura de bases de datos distribuidas
Arquitectura de bases de datos distribuidasArquitectura de bases de datos distribuidas
Arquitectura de bases de datos distribuidas
 

Recently uploaded

Using Xen Hypervisor for Functional Safety
Using Xen Hypervisor for Functional SafetyUsing Xen Hypervisor for Functional Safety
Using Xen Hypervisor for Functional Safety
Ayan Halder
 
E-Invoicing Implementation: A Step-by-Step Guide for Saudi Arabian Companies
E-Invoicing Implementation: A Step-by-Step Guide for Saudi Arabian CompaniesE-Invoicing Implementation: A Step-by-Step Guide for Saudi Arabian Companies
E-Invoicing Implementation: A Step-by-Step Guide for Saudi Arabian Companies
Quickdice ERP
 
Enums On Steroids - let's look at sealed classes !
Enums On Steroids - let's look at sealed classes !Enums On Steroids - let's look at sealed classes !
Enums On Steroids - let's look at sealed classes !
Marcin Chrost
 
What next after learning python programming basics
What next after learning python programming basicsWhat next after learning python programming basics
What next after learning python programming basics
Rakesh Kumar R
 
SMS API Integration in Saudi Arabia| Best SMS API Service
SMS API Integration in Saudi Arabia| Best SMS API ServiceSMS API Integration in Saudi Arabia| Best SMS API Service
SMS API Integration in Saudi Arabia| Best SMS API Service
Yara Milbes
 
在线购买加拿大英属哥伦比亚大学毕业证本科学位证书原版一模一样
在线购买加拿大英属哥伦比亚大学毕业证本科学位证书原版一模一样在线购买加拿大英属哥伦比亚大学毕业证本科学位证书原版一模一样
在线购买加拿大英属哥伦比亚大学毕业证本科学位证书原版一模一样
mz5nrf0n
 
Odoo ERP Vs. Traditional ERP Systems – A Comparative Analysis
Odoo ERP Vs. Traditional ERP Systems – A Comparative AnalysisOdoo ERP Vs. Traditional ERP Systems – A Comparative Analysis
Odoo ERP Vs. Traditional ERP Systems – A Comparative Analysis
Envertis Software Solutions
 
Lecture 2 - software testing SE 412.pptx
Lecture 2 - software testing SE 412.pptxLecture 2 - software testing SE 412.pptx
Lecture 2 - software testing SE 412.pptx
TaghreedAltamimi
 
KuberTENes Birthday Bash Guadalajara - Introducción a Argo CD
KuberTENes Birthday Bash Guadalajara - Introducción a Argo CDKuberTENes Birthday Bash Guadalajara - Introducción a Argo CD
KuberTENes Birthday Bash Guadalajara - Introducción a Argo CD
rodomar2
 
How Can Hiring A Mobile App Development Company Help Your Business Grow?
How Can Hiring A Mobile App Development Company Help Your Business Grow?How Can Hiring A Mobile App Development Company Help Your Business Grow?
How Can Hiring A Mobile App Development Company Help Your Business Grow?
ToXSL Technologies
 
2024 eCommerceDays Toulouse - Sylius 2.0.pdf
2024 eCommerceDays Toulouse - Sylius 2.0.pdf2024 eCommerceDays Toulouse - Sylius 2.0.pdf
2024 eCommerceDays Toulouse - Sylius 2.0.pdf
Łukasz Chruściel
 
SQL Accounting Software Brochure Malaysia
SQL Accounting Software Brochure MalaysiaSQL Accounting Software Brochure Malaysia
SQL Accounting Software Brochure Malaysia
GohKiangHock
 
Webinar On-Demand: Using Flutter for Embedded
Webinar On-Demand: Using Flutter for EmbeddedWebinar On-Demand: Using Flutter for Embedded
Webinar On-Demand: Using Flutter for Embedded
ICS
 
Top 9 Trends in Cybersecurity for 2024.pptx
Top 9 Trends in Cybersecurity for 2024.pptxTop 9 Trends in Cybersecurity for 2024.pptx
Top 9 Trends in Cybersecurity for 2024.pptx
devvsandy
 
316895207-SAP-Oil-and-Gas-Downstream-Training.pptx
316895207-SAP-Oil-and-Gas-Downstream-Training.pptx316895207-SAP-Oil-and-Gas-Downstream-Training.pptx
316895207-SAP-Oil-and-Gas-Downstream-Training.pptx
ssuserad3af4
 
Fundamentals of Programming and Language Processors
Fundamentals of Programming and Language ProcessorsFundamentals of Programming and Language Processors
Fundamentals of Programming and Language Processors
Rakesh Kumar R
 
一比一原版(USF毕业证)旧金山大学毕业证如何办理
一比一原版(USF毕业证)旧金山大学毕业证如何办理一比一原版(USF毕业证)旧金山大学毕业证如何办理
一比一原版(USF毕业证)旧金山大学毕业证如何办理
dakas1
 
Hand Rolled Applicative User Validation Code Kata
Hand Rolled Applicative User ValidationCode KataHand Rolled Applicative User ValidationCode Kata
Hand Rolled Applicative User Validation Code Kata
Philip Schwarz
 
Energy consumption of Database Management - Florina Jonuzi
Energy consumption of Database Management - Florina JonuziEnergy consumption of Database Management - Florina Jonuzi
Energy consumption of Database Management - Florina Jonuzi
Green Software Development
 
8 Best Automated Android App Testing Tool and Framework in 2024.pdf
8 Best Automated Android App Testing Tool and Framework in 2024.pdf8 Best Automated Android App Testing Tool and Framework in 2024.pdf
8 Best Automated Android App Testing Tool and Framework in 2024.pdf
kalichargn70th171
 

Recently uploaded (20)

Using Xen Hypervisor for Functional Safety
Using Xen Hypervisor for Functional SafetyUsing Xen Hypervisor for Functional Safety
Using Xen Hypervisor for Functional Safety
 
E-Invoicing Implementation: A Step-by-Step Guide for Saudi Arabian Companies
E-Invoicing Implementation: A Step-by-Step Guide for Saudi Arabian CompaniesE-Invoicing Implementation: A Step-by-Step Guide for Saudi Arabian Companies
E-Invoicing Implementation: A Step-by-Step Guide for Saudi Arabian Companies
 
Enums On Steroids - let's look at sealed classes !
Enums On Steroids - let's look at sealed classes !Enums On Steroids - let's look at sealed classes !
Enums On Steroids - let's look at sealed classes !
 
What next after learning python programming basics
What next after learning python programming basicsWhat next after learning python programming basics
What next after learning python programming basics
 
SMS API Integration in Saudi Arabia| Best SMS API Service
SMS API Integration in Saudi Arabia| Best SMS API ServiceSMS API Integration in Saudi Arabia| Best SMS API Service
SMS API Integration in Saudi Arabia| Best SMS API Service
 
在线购买加拿大英属哥伦比亚大学毕业证本科学位证书原版一模一样
在线购买加拿大英属哥伦比亚大学毕业证本科学位证书原版一模一样在线购买加拿大英属哥伦比亚大学毕业证本科学位证书原版一模一样
在线购买加拿大英属哥伦比亚大学毕业证本科学位证书原版一模一样
 
Odoo ERP Vs. Traditional ERP Systems – A Comparative Analysis
Odoo ERP Vs. Traditional ERP Systems – A Comparative AnalysisOdoo ERP Vs. Traditional ERP Systems – A Comparative Analysis
Odoo ERP Vs. Traditional ERP Systems – A Comparative Analysis
 
Lecture 2 - software testing SE 412.pptx
Lecture 2 - software testing SE 412.pptxLecture 2 - software testing SE 412.pptx
Lecture 2 - software testing SE 412.pptx
 
KuberTENes Birthday Bash Guadalajara - Introducción a Argo CD
KuberTENes Birthday Bash Guadalajara - Introducción a Argo CDKuberTENes Birthday Bash Guadalajara - Introducción a Argo CD
KuberTENes Birthday Bash Guadalajara - Introducción a Argo CD
 
How Can Hiring A Mobile App Development Company Help Your Business Grow?
How Can Hiring A Mobile App Development Company Help Your Business Grow?How Can Hiring A Mobile App Development Company Help Your Business Grow?
How Can Hiring A Mobile App Development Company Help Your Business Grow?
 
2024 eCommerceDays Toulouse - Sylius 2.0.pdf
2024 eCommerceDays Toulouse - Sylius 2.0.pdf2024 eCommerceDays Toulouse - Sylius 2.0.pdf
2024 eCommerceDays Toulouse - Sylius 2.0.pdf
 
SQL Accounting Software Brochure Malaysia
SQL Accounting Software Brochure MalaysiaSQL Accounting Software Brochure Malaysia
SQL Accounting Software Brochure Malaysia
 
Webinar On-Demand: Using Flutter for Embedded
Webinar On-Demand: Using Flutter for EmbeddedWebinar On-Demand: Using Flutter for Embedded
Webinar On-Demand: Using Flutter for Embedded
 
Top 9 Trends in Cybersecurity for 2024.pptx
Top 9 Trends in Cybersecurity for 2024.pptxTop 9 Trends in Cybersecurity for 2024.pptx
Top 9 Trends in Cybersecurity for 2024.pptx
 
316895207-SAP-Oil-and-Gas-Downstream-Training.pptx
316895207-SAP-Oil-and-Gas-Downstream-Training.pptx316895207-SAP-Oil-and-Gas-Downstream-Training.pptx
316895207-SAP-Oil-and-Gas-Downstream-Training.pptx
 
Fundamentals of Programming and Language Processors
Fundamentals of Programming and Language ProcessorsFundamentals of Programming and Language Processors
Fundamentals of Programming and Language Processors
 
一比一原版(USF毕业证)旧金山大学毕业证如何办理
一比一原版(USF毕业证)旧金山大学毕业证如何办理一比一原版(USF毕业证)旧金山大学毕业证如何办理
一比一原版(USF毕业证)旧金山大学毕业证如何办理
 
Hand Rolled Applicative User Validation Code Kata
Hand Rolled Applicative User ValidationCode KataHand Rolled Applicative User ValidationCode Kata
Hand Rolled Applicative User Validation Code Kata
 
Energy consumption of Database Management - Florina Jonuzi
Energy consumption of Database Management - Florina JonuziEnergy consumption of Database Management - Florina Jonuzi
Energy consumption of Database Management - Florina Jonuzi
 
8 Best Automated Android App Testing Tool and Framework in 2024.pdf
8 Best Automated Android App Testing Tool and Framework in 2024.pdf8 Best Automated Android App Testing Tool and Framework in 2024.pdf
8 Best Automated Android App Testing Tool and Framework in 2024.pdf
 

Manipulacion de conjuntos

  • 2. 2 Índice Técnicas básicas de ordenación Quicksort ………………………………………………….…………………………………………………. 3 Mergesort ………………………………………………….…………………………………………………. 11 Estructuras de datos para manipulaciónde conjuntos Árbolesbinarios ………………………………………………….…………………………………………………. 20 AVL ………………………………………………….…………………………………………………. 27 Árboles2-3 ………………………………………………….…………………………………………………. 38 Hashing ………………………………………………….…………………………………………………. 43 Algoritmosde selección Max-Min ………………………………………………….…………………………………………………. 61 K-ésimo ………………………………………………….…………………………………………………. 65 Colas de prioridad Heaps ………………………………………………….…………………………………………………. 70 Heapsort ………………………………………………….…………………………………………………. 74 Memoriasecundaria ÁrbolesB ………………………………………………….…………………………………………………. 80 Hashing extendible ………………………………………………….…………………………………………………. 88 Uniónfind………………………….………………………………………………………… 91
  • 3. 3 Técnicas básicas de ordenación La ordenaciónde losdatosconsisteendisponeroclasificarunconjuntode datos(ounaestructura) en algún determinado orden con respecto a alguno de sus campos. Orden: Relación de una cosa con respectoa otra.Clave:Campopor el cual se ordena.• Una listade datosestáordenadapor la clave ksi la lista está en orden con respecto a la clave anterior. Este Orden puede ser: − Ascendente: (i<=k[j]) − Descendente: (i>j) entonces (k[i]>=k[j]) Quicksort Como el ordenamiento por mezcla, el ordenamiento rápido utiliza divide y vencerás, así que es un algoritmo recursivo. La manera en que el ordenamiento rápido utiliza divide y vencerás es un poco diferente de como lo hace el ordenamiento por mezcla. En el ordenamiento por mezcla, el paso de dividir casi no hace nada, y todo el trabajo real ocurre en el paso de combinar. El ordenamiento rápido es lo contrario: todo el trabajo real ocurre en el paso de dividir. De hecho, el paso de combinar en el ordenamiento rápido no hace absolutamente nada. El ordenamiento rápido tiene un par de otras diferencias con el ordenamiento por mezcla. El ordenamiento rápido trabaja in situ. Y el tiempo de ejecución de su peor caso es tan malo como el del ordenamiento por selección y el ordenamiento por inserción: Θ(𝑛2 ). Pero el tiempo de ejecución de su caso promedio es tan bueno como el del ordenamiento por mezcla: Θ(n𝑙𝑜𝑔2n). ¿Entonces por qué pensar acerca del ordenamiento rápido cuando el ordenamiento por mezcla es por lo menos igual de bueno? Eso es porque el factor constante escondido en la notación Θ grande para el ordenamiento rápido es bastante bueno. En la práctica, el ordenamiento rápido tiene un mejor desempeño que el ordenamiento por selección y tiene un desempeño significativamente mejor que el ordenamiento por inserción.
  • 4. 4 Aquí está cómo el ordenamiento rápido usa divide y vencerás. Como con el ordenamiento por mezcla, piensa en ordenar un subarreglo array[p..r], donde inicialmente el subarreglo es array[0..n-1]. 1. Divide al escoger cualquier elemento en el subarreglo array[p..r]. Llama a este elemento el pivote. Reorganiza los elementos en array[p..r] de modo que todos los elementos en array[p..r] que sean menores o iguales que el pivote estén a su izquierda y todos los elementos que sean mayores que el pivote estén a su derecha. A este procedimiento lo llamamos hacer una partición. En este punto, no importa qué orden tengan los elementos a la izquierda del pivote en relación con ellos mismos, y lo mismo ocurre para los elementos a la derecha del pivote. Solo nos importa que cada elemento esté en algún lugar del lado correcto del pivote. Como una cuestión práctica, siempre vamos a escoger el elemento de hasta la derecha en el subarreglo, array[r], como el pivote. Así que, por ejemplo, si el subarreglo consiste de [9, 7, 5, 11, 12, 2, 14, 3, 10, 6], entonces escogemos 6 como el pivote. Después de hacer la partición, el subarreglo podría verse como [5, 2, 3, 6, 12, 7, 14, 9, 10, 11]. Sea q el índice de dónde está el pivote. 2. Vence al ordenar de manera recursiva los subarreglos array[p..q-1] (todos los elementos a la izquierda del pivote, los cuales deben ser menores o iguales que el pivote) y array[q+1..r] (todos los elementos a la derecha del pivote, los cuales deben ser mayores que el pivote). 3. Combina al hacer nada. Una vez que el paso de conquistar ordena de manera recursiva, ya terminamos. ¿Por qué? Todos los elementos a la izquierda del pivote, en array[p..q- 1], son menores o iguales que el pivote y están ordenados, y todos los elementos a la derecha del
  • 5. 5 pivote, en array[q+1..r], son mayores que el pivote y están ordenados. ¡Los elementos en array[p..r] tienen que estar ordenados! Piensa acerca de nuestro ejemplo. Después de ordenar de manera recursiva los subarreglos a la izquierda y a la derecha del pivote, el subarreglo a la izquierda del pivote es [2, 3, 5], y el subarreglo a la derecha del pivote es [7, 9, 10, 11, 12, 14]. Así que el subarreglo tiene [2, 3, 5], seguido de 6, seguido de [7, 9, 10, 11, 12, 14]. El subarreglo está ordenado. Los casos base son subarreglos con menos de dos elementos, justo como en el ordenamiento por mezcla. En el ordenamiento por mezcla nunca ves un subarreglo sin elementos, pero si puedes verlos en el ordenamiento rápido si los otros elementos en el subarreglo son todos menores que el pivote o son todos mayores que el pivote. Volvamos al paso de vencer y recorramos el ordenamiento recursivo de los subarreglos. Después de hacer la primera partición tenemos los subarreglos [5, 2, 3] y [12, 7, 14, 9, 10, 11], con 6 como el pivote. Para ordenar el subarreglo [5, 2, 3], escogemos a 3 como el pivote. Después de hacer la partición, tenemos [2, 3, 5]. El subarreglo [2], a la izquierda del pivote, es un caso base cuando hacemos recursividad, como lo es el subarreglo [5], a la derecha de pivote. Para ordenar el subarreglo [12, 7, 14, 9, 10, 11], escogemos 11 como el pivote, que resulta en [7, 9, 10] a la izquierda del pivote y [14, 12] a la derecha. Después de que estos subarreglos están ordenados, tenemos [7, 9, 10], seguido de 11, seguido de [12, 14]. Aquí está cómo se desarrolla todo el algoritmo del ordenamiento rápido. Las ubicaciones de los arreglos en azul han sido pivotes en llamadas recursivas anteriores, así que los valores en estas ubicaciones no se van a volver a examinar o mover:
  • 6. 6 Tiempo de ejecución del peor caso Cuando el ordenamiento rápido siempre tiene las particiones más desbalanceadas posibles, entonces la llamada original tarda un tiempo cn para alguna constante c, la llamada recursiva sobre n−1elementos tarda un tiempo c(n-1), la llamada recursiva sobre n-2 elementos tarda un tiempo c(n-2) y así sucesivamente. Aquí está un árbol de los tamaños de los subproblemas con sus tiempos para hacer las particiones:
  • 7. 7 Cuando sumamos los tiempos para hacer las particiones para cada nivel, obtenemos cn+c(n−1)+c(n−2)+⋯+2c =c(n+(n−1)+(n−2)+⋯+2) =c((n+1)(n/2)−1) . Tenemos algunos términos de orden inferior y coeficientes constantes, pero los ignoramos cuando usamos notación Θ grande. En notación Θ grande, el tiempo de ejecución del peor caso del ordenamiento rápido es Θ(𝑛2 ). Tiempo de ejecución del mejor caso El mejor caso del ordenamiento rápido ocurre cuando las particiones están balanceadas tan uniformemente como sea posible: sus tamaños son iguales o difieren en 1. El primer caso ocurre si el subarreglo tiene un número impar de elementos, el pivote está justo en medio después de hacer la partición y cada partición tiene (n-1)/2 elementos. El otro caso ocurre si el subarreglo tiene un
  • 8. 8 número par n de elementos y una partición tiene n/2 elementos mientras que la otra tiene n/2-1. En cualquiera de estos casos, cada partición tiene a lo más n/2 elementos, y el árbol de los tamaños de los subproblemas se parece mucho al árbol de los tamaños de los subproblemas para el ordenamiento por mezcla, con los tiempos para hacer las particiones que se ven como los tiempos de mezcla: Al usar notación Θ grande, obtenemos el mismo resultado que para un ordenamiento por mezcla: Θ(n𝑙𝑜𝑔2n). Tiempo de ejecución del caso promedio Mostrar que el tiempo de ejecución del caso promedio también es Θ(n𝑙𝑜𝑔2n). requiere unas matemáticas bastante complicadas, así que no lo vamos a hacer. Pero podemos obtener un poco de intuición al ver un par de otros casos para entender por qué podría ser Θ(n𝑙𝑜𝑔2n) (una vez que
  • 9. 9 tengamos Θ(n𝑙𝑜𝑔2n)., la cota de Θ(n𝑙𝑜𝑔2n). se sigue porque el tiempo de ejecución del caso promedio no puede ser mejor que el tiempo de ejecución del mejor caso). Primero, imaginemos que no siempre obtenemos particiones igualmente balanceadas, pero que siempre obtenemos una razón de 3 a 1. Es decir, imagina que cada vez que hacemos una partición, un lado obtiene 3n/4, elementos y el otro lado obtiene n/4 (para mantener limpias las matemáticas, no vamos a preocuparnos por el pivote). Entonces, el árbol de los tamaños de los subproblemas y los tiempos para hacer las particiones se vería así: El hijo izquierdo de cada nodo representa un subproblema cuyo tamaño es un 1/4 del tamaño del padre, y el hijo derecho representa un subproblema cuyo tamaño es 3/4 del tamaño del padre. Como los subproblemas más pequeños están del lado izquierdo, al seguir el camino de hijos
  • 10. 10 izquierdos llegamos de la raíz hasta un problema de tamaño 1 más rápido que por cualquier otro camino. En cada uno de los primeros 𝑙𝑜𝑔4n niveles hay n nodos (de nuevo, incluyendo pivotes que en realidad ya no se están particionado), así que el tiempo total de hacer particiones para cada uno de estos niveles es de cn. ¿Qué pasa con el resto de los niveles? Cada uno tiene menos de n nodos, así que el tiempo para hacer particiones para cada nivel es a lo más cn. Todo junto, hay 𝑙𝑜𝑔4/3n niveles, así que el tiempo total para hacer particiones es O(n 𝑙𝑜𝑔4/3n) Hay un hecho matemático que dice que para todos los números positivos a, b y n. Al hacer a = 4/3, b=2, obtenemos que así que 𝑙𝑜𝑔4/3n y 𝑙𝑜𝑔2n solo difieren por un factor de 𝑙𝑜𝑔24/3 ,que es una constante. Como los factores constantes no importan cuando usamos la notación O grande, podemos decir que si todas las divisiones tienen una razón de 3 a 1, entonces el tiempo de ejecución del ordenamiento rápido es Θ(n𝑙𝑜𝑔2n), aunque con un factor constante más grande escondido que el tiempo de ejecución del mejor caso.
  • 11. 11 Mergesort La idea de este algoritmo es la de dividir el problema en subproblemas más pequeños. Para eso es indispensable utilizar la recursividad. La idea es que es fácil, dadas dos listas ordenadas cada una, obtener una tercera que tiene todos sus elementos ordenados. Ejemplo:  [2, 6, 10] + [-1, 3, 12] => [-1, 2, 3, 6, 10, 12] El algoritmo llama a esto mezclar. Entonces el algoritmo completo se basa en:  Caso Base: una lista vacía o de un elemento ya está ordenada  Regla Recursiva:  partir la lista en 2 partes  ordenar recursivamente ambas partes  luego mezclarlas como vimos arriba. Ejemplo Veamos un ejemplo paso a paso antes de entrar en la implementación. Nuestra lista: [2, 124, 23, 5, 89, -1, 44, 643, 34]
  • 12. 12 A continuación las llamadas recursivas van a ir anidándose dividiendo la lista y llegando a este punto: Luego todas las hojas se resuelven fácil porque dijimos que las listas de 1 elemento se consideran ordenadas. Entonces pasamos a: Ojo porque acá el símbolo más no significan que se deben concatenar las listas, sino que se deben MEZCLAR, como dijimos antes, mezclar dos listas es generar una tercera que tiene los elementos de ambas, pero ordenados. En este primer paso las mezclas son simples porque son entre listas de un solo elemento. Resolvemos las mezclas y queda:
  • 13. 13 Como se ve estamos comenzando a "volver". Ojo, se parece mucho a como era inicialmente al diagrama inicial, pero presten bastante atención porque antes [5,23] estaba desordenado, era [23, 5] y lo mismo para el nodo de [89, -1] y [34, 643]. Ahora sí, todas las hojas están ordenadas, así que volvemos un nivel en cada una: Resolvemos las mezclas que ahora son un poco más complicadas porque tenemos mezclas de listas de 2 elementos, y otra de 1 + 2 elementos. Queda: Ahora que tenemos dos ramas ordenadas las subimos.
  • 14. 14 Resolvemos la nueva mezcla con listas de 2 y 3 elementos: Ya estan ordenadas así que retornamos: Y resolviendo esta última mezcla obtenemos el resultado: Implementación Vamos a presentar dos variantes de la implementación. Sin embargo la primer parte del algoritmo es igual para ambas. Lo que cambia es la forma de hacer la mezcla de dos listas. Entonces, el mergeSort sería: def mergeSort(lista): if len (lista) == 1: return lista izq, der = dividirAlMedio(lista) return mezclar(mergeSort(izq), mergeSort(der))
  • 15. 15 def dividirAlMedio(lista): middle = len(lista) // 2 return lista[0:middle], lista[middle:] Como vemos es bastante expresiva la implementación, el mergeSort de una lista es:  ella misma si tiene un solo elemento  en otro caso, se divide al medio, se aplica el mergeSort de cada parte, y luego se mezclan las ordenadas. Es claro entonces que es una función recursiva, en este caso aplicar la recursividad a ambas mitades de la lista. Ahora, para la implementación del mezclar vamos a mostrar dos casos: Mezclar Iterando Esta primer versión itera ambas listas y se va quedando con el elemento menor. Como ambas están ordenadas, esto garantiza el orden de la lista resultado. Así va avanzando por la lista que tenía el menor. def merge(first, second): mergedList = [] i, j = 0, 0 while i < len(first) and j < len(second): if first[i] < second[j]: mergedList.append(first[i]) i += 1 else: mergedList.append(second[j]) j += 1
  • 16. 16 mergedList.extend(first[i:]) mergedList.extend(second[j:]) return mergedList Puede ser que avance más rápido por una que por la otra, o que tengan distinta cantidad de elemento. Por lo que luego del while, se agregan los elementos restantes. Mezclar Recursivo con Colas Esta otra implementación utiliza recursividad para mezclar las listas, y en lugar de recorrerlas con índices utiliza la estructura Cola que permite ir consumiendo el próximo elemento. def mezclar(izq, der): r = [] mezclarQueues(deque(izq), deque(der), r) return r def mezclarQueues(firstQ, secondQ, result): if isEmpty(firstQ):result.extend(secondQ); return if isEmpty(secondQ):result.extend(firstQ); return result.append(firstQ.popleft() if cabeza(firstQ) <= cabeza(secondQ) else secondQ.popleft()) mezclarQueues(firstQ, secondQ, result) La lógica realmente está en mezclarQueues que es la función recursiva. La otra es símplemente para hacer el llamado más simple, porque mezclarQueues utiliza una semilla y además deberíamos crear Colas para cada lista. MezclarQueues entonces se define como:  en result se va acumulando la lista final mezclada
  • 17. 17  Si la primer cola está vacía, entonces símplemente agrega todos los elementos de la segunda a result  Si la segunda está vacía, agrega todos los de la primera.  Si ambas tienen elemento entonces agrega un solo elemento a "result", que es el menor entre la cabeza de cada cola, sacándo dicho elemento de la cola (usando popleft()).  Luego vuelve a llamarse recursivamente con los mismos valores, ya que el popLeft fue el que modificó la cola, una de ella tendrá un elemento menos. Tiempo de ejecución Como con la ordenación por inserción sea T(n) el tiempo total que tarda el algoritmo en ordenar n elementos. Cuando n es suficientemente chico, digamos n <= c, entonces resolvemos el problema de la manera obvia, para este caso nuestro programa tarda un tiempo constante, por lo que podemos decir que tiene una complejidad de O(1). En general, supongamos que la division de nuestro problema nos entrega a subproblemas, cada uno de tamaño 1/b (para el caso de la ordenación por mezcla, tanto a como b son iguales a 2). Si tomamos que D(n) es el tiempo que tardamos en dividir el problema en subproblemas, y C(n), el tiempo que tardamos en combinar los subproblemas en una solución, entonces tenemos que Hay un "teorema maestro" que sirve para resolver recurrencias de esta forma, sin embargo para varios casos, como el de la ordenación por mezcla, se puede ver de manera intuitiva cual va a ser el resultado. Revisemos paso a paso cada una de las partes.
  • 18. 18  Dividir: Para dividir, basta con cálcular cual es la mitad del arreglo, esto, se puede hacer sin ningún problema en tiempo constante, por lo que tenemos que para la ordenación por mezcla D(n) = O(1)  Vencer: Como dividimos el problema a la mitad y lo resolvemos recursivamente, entonces estamos resolviendo dos problemas de tamaño n/2 lo que nos da un tiempo T(n) = 2T(n/2)  Combinar: Vimos anteriormente que nuestro Mezcla era de orden lineal, por lo que tenemos que C(n)=O(n) Para sustituir, sumamos D(n) + C(n) = O(n) + O(1) = O(n), recordemos que para el análisis de complejidad únicamente nos importa el término que crezca más rápido. Por lo que sustituyendo en la recurrencia tenemos que: donde c representa una constante igual al tiempo que se requiera para resolver un problema de tamaño 1. Sabemos que n sólo puede ser dividido a la mitad lg n veces, por lo que la profundidad de nuestra recursión será de lg n, también del funcionamiento del algoritmo podemos ver que cada vez que dividamos a la mitad, vamos a tener que mezclar los n elementos, y sabemos que mezclar n elementos nos toma un tiempo proporcional a n, por lo que resolver la recursión debe tomar un tiempo proporcional a n lg n, que de hecho es la complejidad del algortimo. La ordenación por mezcla tiene una complejidad de O(n lg n). Podemos comprobarlo, sustituyendo valores en la ecuación de recurrencia.
  • 19. 19 valor de n función de recurrencia tiempo total 1 c c 2 2T(1) + 2c 2c + 2c = 4c = c(2 lg(2) + 2) 4 2T(2) + 4c 8c + 4c =12c = c(4 lg(4) + 4) 8 2T(4) + 8c 24c+8c=32c=c(8 lg(8) + 8) . . . n 2T(n/2) + nc c(n lg(n) + n) De la tabla anterior vemos que el tiempo real de corrida del ordenamiento por mezcla es proporcional a n lg n + n, de nuevo, como en el análisis de complejidad únicamente nos importa el término que crezca más rápido y en ésta función ese término es n lg n, por lo tanto la complejidad del sistema es O(n lg n).
  • 20. 20 Estructuras de datos para manipulación de conjuntos Una estructura de datos es una forma particular de organizar datos en una computadora para que pueda ser utilizado de manera eficiente. Diferentes tipos de estructuras de datos son adecuados para diferentes tipos de aplicaciones, y algunos son altamente especializados para tareas específicas. Las estructuras de datos son un medio para manejar grandes cantidades de datos de manera eficiente para usos tales como grandes bases de datos y servicios de indización de Internet. Por lo general, las estructuras de datos eficientes son la clave para diseñar algoritmos eficientes. Algunos métodos formales de diseño y lenguajes de programación destacan las estructuras de datos, en lugar de los algoritmos, como el factor clave de organización en el diseño de software. Las estructuras de datos se basan generalmente en la capacidad de un ordenador para recuperar y almacenar datos en cualquier lugar de su memoria. Arboles binarios Un árbol binario es un conjunto finito de elementos, el cual está vacío o dividido en tres subconjuntos separados: • El primer subconjunto contiene un elemento único llamado raíz del árbol. • El segundo subconjunto es en sí mismo un árbol binario y se le conoce como subárbol izquierdo del árbol original. • El tercer subconjunto es también un árbol binario y se le conoce como subárbol derecho del árbol original.
  • 21. 21 El subárbol izquierdo o derecho puede o no estar vacío. Cada elemento de un árbol binario se conoce como nodo del árbol. Si B es la raíz de un árbol binario y D es la raíz del subárbol izquierdo/derecho, se dice que B es el padre de D y que D es el hijo izquierdo/derecho de B. A un nodo que no tiene hijos, tal como A o C de la Ilustración , se le conoce como hoja. Un nodo n1 es un ancestro de un nodo n2 (y n2 es un descendiente de n1) si n1 es el padre de n2 o el padre de algún ancestro de n2. Recorrer un árbol de la raíz hacia las hojas se denomina descender el árbol y al sentido opuesto ascender el árbol. Un árbol estrictamente binario es aquel en el que cada nodo que no es hoja, tiene subárboles izquierdo y derecho que no están vacíos. Un árbol estrictamente binario con n hojas siempre contiene 2n-1 nodos. El nivel de un nodo en un árbol binario se define del modo siguiente: 1.La raíz del árbol tiene el nivel 0.
  • 22. 22 2.El nivel de cualquier otro nodo en el árbol es uno más que el nivel de su padre. La profundidad o altura de un árbol binario es el máximo nivel de cualquier hoja en el árbol. Un árbol binario completo de profundidad p, es un árbol estrictamente binario que tiene todas sus hojas en el nivel p. Operaciones en árboles binarios. Se aplican varias operaciones primitivas a un árbol binario. Si p es un apuntador a un nodo nd de un árbol binario: 1.La función info(p) regresa el contenido de nd. 2.La función left(p) regresa un apuntador al hijo izquierdo de nd. 3.La función right(p) regresa un apuntador al hijo derecho de nd. 4.La función father(p) regresa un apuntador al padre de nd. 5.La función brother(p) regresa un apuntador al hermano de nd. 6.La función isLeft(p) regresa true si nd es un hijo izquierdo de algún otro nodo en el árbol, y false en caso contrario . 7.La función isRight(p) regresa true si nd es un hijo derecho de algún otro nodo en el árbol, y false en caso contrario. En la construcción de un árbol binario son útiles las operaciones: 1.makeTree(x) crea un nuevo árbol que consta de un nodo único con un campo de información x, y regresa un apuntador a este nodo. 2.setLeft(p, x) crea un nuevo hijo izquierdo de node(p) con el campo de información x.
  • 23. 23 3.setRight(p, x) crea un nuevo hijo derecho de node(p) con el campo de información x. Aplicaciones de árboles binarios. Un árbol binario es una estructura de datos útil cuando deben tomarse decisiones en dos sentidos en cada punto de un proceso. Suponga que se desea encontrar todos los duplicados de una lista de números. Considérese lo siguiente: 1.El primer número de la lista se coloca en un nodo que se ha establecido como la raíz de un árbol binario con subárboles izquierdo y derecho vacíos. 2.Cada número sucesivo en la lista se compara con el número en la raíz, aquí se tienen 3 casos: a.Si coincide, se tiene un duplicado. b.Si es menor, se examina el subárbol izquierdo. c.Si es mayor, se examina el subárbol derecho. 3.Si alguno de los subárboles esta vacío, el número no es un duplicado y se coloca en un nodo nuevo en dicha posición del árbol. 4.Si el subárbol no está vacío, se compara el número con la raíz del subárbol y se repite todo el proceso con el subárbol. Un árbol binario de búsqueda (ABB) no tiene valores duplicados en los nodos y además, tiene la característica de que: 1.Los valores en cualquier subárbol izquierdo son menores que el valor en su nodo padre. 2.Los valores en cualquier subárbol derecho son mayores que el valor en su nodo padre.
  • 24. 24 El árbol binario de búsqueda de la Ilustración fue construido dada la siguiente secuencia de elementos: 14, 15, 4, 9, 7, 18, 3, 5, 16, 4, 20, 17 Una operación común es recorrer todo un árbol binario en un orden específico. A la operación de recorrer un árbol de una forma específica y de “numerar” sus nodos, se le conoce como visitar el árbol (procesar el valor del nodo). En general, se definen tres métodos de recorrido de un árbol binario. Antes de presentarlos se deberán tener en mente las siguientes consideraciones: 1.No se necesita hacer nada para un árbol binario vacío. 2.Todos los métodos se definen recursivamente. 3.Siempre se recorren la raíz y los subárboles, la diferencia radica en el orden en que se visitan. Para recorrer un árbol binario no vacío en orden previo (orden de primera profundidad) se ejecutan tres operaciones:
  • 25. 25 1.Visitar la raíz. 2.Recorrer el subárbol izquierdo en orden previo .3.Recorrer el subárbol derecho en orden previo Para recorrer un árbol binario no vacío en orden (orden simétrico) se ejecutan tres operaciones: 1.Recorrer el subárbol izquierdo en orden. 2.Visitar la raíz. 3.Recorrer el subárbol derecho en orden. Para recorrer un árbol binario no vacío en orden posterior se ejecutan tres operaciones: 1.Recorrer el subárbol izquierdo en orden posterior. 2.Recorrer el subárbol derecho en orden posterior. 3.Visitar la raíz. Ejercicios a desarrollar con árboles: 1.Ordenamiento de números e identificación de elementos repetidos almacenados en una lista. 2.Árboles de expresiones. 3.Determinar el número de nodos de un árbol binario.
  • 26. 26 4.La suma de todos los nodos. 5.La profundidad de un árbol binario. 6.Determinar si un árbol binario es o no estrictamente binario. 7.Determinar si un árbol binario es o no completo de nivel p. Eliminación de un ABB. La eliminación es el problema inverso a la inserción, sin embargo, las cosas no son tan sencillas como para la inserción. Si el nodo que se pretende eliminar es un nodo hoja o un nodo con un solo descendiente, la eliminación es directa. La dificultad radica en la eliminación de un nodo con dos descendientes. En este caso, el elemento eliminado será substituido por el descendiente más a la derecha de su subárbol izquierdo (o bien por el descendiente más a la izquierda de su subárbol derecho). Obsérvese que estos nodos substitutos tienen a lo más, un descendiente. Lo anterior queda mejor representado en la Ilustración
  • 27. 27 ARBOLES EQUILIBRADOS AVL. Comencemos con un ejemplo: Supongamos que deseamos construir un ABB para la siguiente tabla de datos: El resultado se muestra en la figura siguiente: Como se ve ha resultado un árbol muy poco balanceado y con características muy pobres para la búsqueda. Los ABB trabajan muy bien para una amplia variedad de aplicaciones, pero tienen el
  • 28. 28 problema de que la eficiencia en el peor caso es O(n). Los árboles que estudiaremos a continuación nos darán una idea de cómo podria resolverse el problema garantizando en el peor caso un tiempo O(log2 n). Diremos que un árbol binario está equilibrado (en el sentido de Addelson-Velskii y Landis) si, para cada uno de sus nodos ocurre que las alturas de sus dos subárboles difieren como mucho en 1. Los árboles que cumplen esta condición son denominados a menudo árboles AVL. En la primera figura se muestra un árbol que es AVL, mientras que el de la segunda no lo es al no cumplirse la condición en el nodo k.
  • 29. 29 A través de los árboles AVL llegaremos a un procedimiento de búsqueda análogo al de los ABB pero con la ventaja de garantizaremos un caso peor de O(log2 n), manteniendo el árbol en todo momento equilibrado. Para llegar a este resultado , podríamos preguntarnos cual podría ser el peor AVL que podríamos construir con n nodos, o dicho de otra forma cuanto podríamos permitir que un árbol binario se desequilibrara manteniendo la propiedad de AVL. Para responder a la pregunta podemos construir para una altura h el AVL Th, con mínimo número de nodos. Cada uno de estos árboles mínimos debe constar de una raiz, un subárbol AVL minimo de altura h-1 y otro subárbol AVL también minimo de altura h-2. Los primeros Ti pueden verse en la siguiente figura: Es fácil ver que el número de nodos n(Th) está dado por la relación de recurencia [1]: n(Th) = 1 + n(Th-1) + n(Th-2) Relación similar a la que aparece en los números de Fibonacci (Fn = Fn-1 + Fn-2) , de forma que la ss, de valores para n(Th) está relacionada con los valores de la ss. de Fibonacci:  AVL -> -, -, 1, 2, 4, 7, 12, ...  FIB -> 1, 1, 2, 3, 5, 8, 13, ...
  • 30. 30 es decir [2], n(Th) = Fh+2 - 1 Resolviendo [1] y utilizando [2] llegamos tras algunos cálculos a: log2(n+1) <= h < 1.44 log2(n+2)-0.33 o dicho de otra forma, la longitud de los caminos de búsqueda (o la altura) para un AVL de n nodos, nunca excede al 44% de la longitud de los caminos (o la altura) de un árbol completamente equilibrado con esos n nodos. En consecuencia, aún en el peor de los casos llevaría un tiempo O(log2 n) al encontrar un nodo con una clave dada. Parece, pues, que el único problema es el mantener siempre tras cada inserción la condición de equilibrio, pero esto puede hacerse muy fácilmente sin más que hacer algunos reajustes locales, cambiando punteros. Antes de estudiar mas detalladamente este tipo de árboles realizamos la declaración de tipos siguiente: typedef int tElemento; typedef struct NODO_AVL { tElemento elemento; struct AVL_NODO *izqda; struct AVL_NODO *drcha; int altura; } nodo_avl; typedef nodo_avl *arbol_avl; #define AVL_VACIO NULL #define maximo(a,b) ((a>b)?(a):(b))
  • 31. 31 En muchas implementaciones, para cada nodo no se almacena la altura real de dicho nodo en el campo que hemos llamada altura, en su lugar se almacena un valor del conjunto {-1,0,1} indicando la relación entre las alturas de sus dos hijos. En nuestro caso almacenamos la altura real por simplicidad. Por consiguiente podemos definir la siguiente macro: #define altura(n) (n?n->altura:-1) La cual nos devuelve la altura de un nodo_avl. Con estas declaraciones la funciones de creación y destrucción para los árboles AVLpueden ser como sigue: arbolAVL Crear_AVL() { return AVL_VACIO; } void Destruir_AVL (arbolAVL A) { if (A) { Destruir_AVL(A->izqda); Destruir_AVL(A->drcha); free(A); } } Es sencillo realizar la implementación de una función que podemos llamar miembro que nos devuelve si un elemento pertenece al árbol AVL. Podría ser la siguiente: int miembro_AVL(tElemento e,arbolAVL A) { if (A == NULL) return 0; if (e == A->elemento) return 1; else if (e < A->elemento) return miembro_AVL(e,A->izqda); else return miembro_AVL(e,A->drcha); }
  • 32. 32 Veamos ahora la forma en que puede afectar una inserción en un árbol AVL y la forma en que deberiamos reorganizar los nodos de manera que siga equilibrado. Consideremos el esquema general de la siguiente figura, supongamos que la inserción ha provocado que el subárbol que cuelga de Ai pasa a tener una altura 2 unidades mayor que el subárbol que cuelga de Ad . ¿Qué operaciones son necesarias para que el nodo r tenga 2 subárboles que cumplan la propiedad de árboles AVL Para responder a esto estudiaremos dos situaciones distintas que requieren 2 secuencias de operaciones distintas:  La inserción se ha realizado en el árbol A. La operación a realizar es la de una rotación simple a la derecha sobre el nodo r resultando el árbol mostrado en la siguiente figura.
  • 33. 33  La inserción se ha realizado en el árbol B. (supongamos tiene raiz b, subárbol izquierdo B1 y subárbol derecho B2). La operación a realizar es la rotación doble izquierda-derecha la cual es equivalente a realizar una rotación simple a la izquierda sobre el nodo Ai y despues una rotación simple a la derecha sobre el nodo r (por tanto, el árbol B queda dividido). El resultado se muestra en la figura siguiente: En el caso de que la inserción se realice en el subárbol Ad la situación es la simétrica y para las posibles violaciones de equilibrio se aplicará la misma técnica mediante la rotación simple a la
  • 34. 34 izquierda o la rotación doble izquierda-derecha. Se puede comprobar que si los subárboles Ad y Ai son árboles AVL, estas operaciones hacen que el árbol resultante también sea AVL. Por último, destacaremos que para realizar la implementación definitiva en base a la declaración de tipos que hemos propuesto tendremos que realizar un ajuste de la altura de los nodos involucrados en la rotación además del ya mencionado ajuste de punteros. Por ejemplo: En la rotación simple que se ha realizado en la primera de las situaciones, el campo de altura de los nodos r y Ai puede verse modificado. Estas operaciones básicas de simple y doble rotación se pueden implementar de la siguiente forma: void Simple_derecha(arbolAVL *A) { nodoAVL *p; p = (*A)->izqda; (*A)->izqda = p->drcha; p->drcha = (*A); (*A) = p; /* Ajustamos las alturas */ p = (*A)->drcha; p->altura = maximo(altura(p->izqda),altura(p->drcha))+1; (*A)->altura = maximo(a1tura((*T)->izqda),altura((*T)->drcha))+1; } void Simple_izquierda(arbolAVL *A) { nodoAVL *p; p = (*A)->drcha; (*A)->drcha = p->izqda; p->izqda = (*A); (*A) = p; /*Ajustamos las alturas */ p = (*A)->izqda; p->altura = maximo(altura(p->izqda),altura(p->drcha))+1; (*A)->altura = maximo(altura((*A)->izqda),altura((*A)->drcha))+1; } void Doble_izquierda_derecha (arbolAVL *AT) { simple_izquierda(&((*A)->izqda)); simple_derecha(A); } void Doble_derecha_izquierda (arbolAVL *A)
  • 35. 35 { simple_derecha(&((*A)->drcha)); simple_izquierda(A); } Obviamente, el reajuste en los nodos es necesario tanto para la operación de inserción como para la de borrado. Por consiguiente, se puede programar la inserción de forma que descendamos en el árbol hasta llegar a una hoja donde insertar y después recorrer el mismo camino hacia arriba realizando los ajustes necesarios (igualmente en el borrado se realizaría algo similar). Para hacer más fácil la implementación, construiremos la función ajusta_avl(e,&T) cuya misión consiste en ajustar los nodos que existen desde el nodo conteniendo la etiqueta e hasta el nodo raiz en el árbol T. La usaremos como función auxiliar para implementar las funciones de inserción y de borrado. El código es el siguiente: void ajusta_AVL (tElemento e, arbolAVL *A) { if (!(*A)) return; if (e > (*A)->elemento) ajusta_AVL(e,&((*A)->drcha)); else if (e < (*A)->elemento) ajusta_avl(e,&((*A)->izqda)); switch (altura((*A)->izqda)-altura((*A)->drcha)) { case 2: if (altura((*A)->izqda->izqda) > altura((*A)->izqda->drcha)) simple_derecha(A); else doble_izquierda_derecha(A); break; case -2: if (altura((*A)->drcha->drcha) > altura((*A)->drcha->izqda)) simple_izquierda(A); else doble_derecha_izquierda(A); break; default: (*A)->altura = maximo(altura((*A)->izqda),altura((*A)->drcha))+1; } } Para la operación de inserción se deberá profundizar en el árbol hasta llegar a un nodo hoja o un nodo con un solo hijo de forma que se añade un nuevo hijo con el elemento insertado. Una vez
  • 36. 36 añadido sólo resta ajustar los nodos que existen en el camino de la raíz al nodo insertado. El código es el siguiente: void insertarAVL (tElemento e, arbolAVL *A) { nodoAVL **p; p=T; while (*p!=NULL) if ((*p)->elemento > e) p = &((*p)->izqda); else p = &((*p)->drcha); (*p)=(nodo_avl *)malloc(sizeof(nodoAVL)); if (!(*p)) error("Error: Memoria insuficiente."); (*p)->elemento = e; (*p)->altura = 0; (*p)->izqda = NULL; (*p)->drcha = NULL; ajustaAVL(e,A); } En el caso de la operación de borrado es un poco más complejo pues hay que determinar el elemento que se usará para la llamada a la función de ajuste. Por lo demás es muy similar al borrado en los árboles binarios de búsqueda. En la implementación que sigue usaremos la variable elem para controlar el elemento involucrado en la función de ajuste. void borrarAVL (tElemento e, arbolAVL *A) { nodoAVL **p,**aux,*dest; tElemento elem; p=A; elem=e; while ((*p)->elemento!=e) { elem=(*p)->elemento; if ((*p)->elemento > e) p=&((*p)->izqda); else p=&((*p)->drcha); } if ((*p)->izqda!=NULL && (*p)->drcha!=NULL) { aux=&((*p)->drcha); elem=(*p)->elemento; while ((*aux)->izqda) { elem=(*aux)->elemento;
  • 37. 37 aux=&((*aux)->izqda); } (*p)->elemento = (*aux)->elemento; p=aux; } if ((*p)->izqda==NULL && (*p)->drcha==NULL) { free(*p); (*p) = NULL; } else if ((*p)->izqda == NULL) { dest = (*p); (*p) = (*p)->drcha; free(dest); } else { dest = (*p); (*p) = (*p)->izqda; free(dest); } ajustaAVL(elem,A); }
  • 38. 38 Arboles 2-3 Los árboles 2-3 son árboles cuyos nodos internos pueden contener hasta 2 elementos (todos los árboles vistos con anterioridad pueden contener sólo un elemento por nodo), y por lo tanto un nodo interno puede tener 2 o 3 hijos, dependiendo de cuántos elementos posea el nodo. De este modo, un nodo de un árbol 2-3 puede tener una de las siguientes formas: Un árbol 2-3 puede ser simulado utilizando árboles binarios: Una propiedad de los árboles 2-3 es que todas las hojas están a la misma profundidad, es decir, los árboles 2-3 son árboles perfectamente balanceados. La siguiente figura muestra un ejemplo de un árbol 2-3:
  • 39. 39 Nótese que se sigue cumpliendo la propiedad de los árboles binarios: nodos internos + 1 = nodos externos. Dado que el árbol 2-3 es perfectamente balanceado, la altura de éste esta acotada por: Inserción en un árbol 2-3 Para insertar un elemento X en un árbol 2-3 se realiza una búsqueda infructuosa y se inserta dicho elemento en el último nodo visitado durante la búsqueda, lo cual implica manejar dos casos distintos:  Si el nodo donde se inserta X tenía una sola llave (dos hijos), ahora queda con dos llaves (tres hijos).  Si el nodo donde se inserta X tenía dos llaves (tres hijos), queda transitoriamente con tres llaves (cuatro hijos) y se dice que está saturado (overflow). En este caso se debe realizar una operación de split: el nodo saturado se divide en dos nodos con un valor cada uno (el menor y el mayor de los tres). El valor del medio sube un nivel, al padre del nodo saturado.
  • 40. 40 El problema se resuelve a nivel de X y Z, pero es posible que el nodo que contiene a Y ahora este saturado. En este caso, se repite el mismo prodecimiento anterior un nivel más arriba. Finalmente, si la raíz es el nodo saturado, éste se divide y se crea una nueva raíz un nivel más arriba. Esto implica que los árboles 2-3 crecen "hacia arriba". Ejemplos de inserción en árboles 2-3:
  • 41. 41 Eliminación en un árbol 2-3 Sin perder generalidad se supondrá que el elemento a eliminar, Z, se encuentra en el nivel más bajo del árbol. Si esto no es así, entonces el sucesor y el predecesor de Z se encuentran necesariamente en el nivel más bajo (¿por qué?); en este caso basta con borrar uno de ellos y luego escribir su valor sobre el almacenado en Z. La eliminación también presenta dos posibles casos:  El nodo donde se encuentra Z contiene dos elementos. En este caso se elimina Z y el nodo queda con un solo elemento.  El nodo donde se encuentra Z contiene un solo elemento. En este caso al eliminar el elemento Z el nodo queda sin elementos (underflow). Si el nodo hermano posee dos elementos, se le quita uno y se inserta en el nodo con underflow.
  • 42. 42 Si el nodo hermano contiene solo una llave, se le quita un elemento al padre y se inserta en el nodo con underflow. Si esta operación produce underflow en el nodo padre, se repite el procedimiento anterior un nivel más arriba. Finalmente, si la raíz queda vacía, ésta se elimina. Costo de las operaciones de búsqueda, inserción y eliminación en el peor caso: .
  • 43. 43 Hashing Una aproximación a la búsqueda radicalmente diferente a las anteriores consiste en proceder, no por comparaciones entre valores clave, sino encontrando alguna función h(k) que nos dé directamente la localización de la clave k en la tabla. La primera pregunta que podemos hacernos es si es fácil encontrar tales funciones h. La respuesta es, en principio, bastante pesimista, puesto que si tomamos como situacion ideal el que tal función dé siempre localizaciones distintas a claves distintas y pensamos p.ej. en una tabla de tamaño 40 en donde queremos direccionar 30 claves, nos encontramos con que hay 4030 = 1.15 * 1048 posibles funciones del conjunto de claves en la tabla, y sólo 40*39*11 = 40!/10! = 2.25 * 1041 de ellas no generan localizaciones duplicadas. En otras palabras, sólo 2 de cada 10 millones de tales funciones serian 'perfectas' para nuestros propósitos. Esa tarea es factible sólo en el caso de que los valores que vayan a pertenecer a la tabla hash sean conocidas a priori. Existen algoritmos para construir funciones hash perfectas que son utilizadas para organizar las palabras clave en un compilador de forma que la búsqueda de cualquiera de esas palabras clave se realice en tiempo constante. Las funciones que evitan valores duplicados son sorprendentemente dificiles de encontrar, incluso para tablas pequeñas. Por ejemplo, la famosa "paradoja del cumpleaños" asegura que si en una reunión están presentes 23 ó más personas, hay bastante probabilidad de que dos de ellas hayan nacido el mismo dia del mismo mes. En otras palabras, si seleccionamos una función aleatoria que aplique 23 claves a una tabla de tamaño 365 la probabilidad de que dos claves no caigan en la misma localización es de sólo 0.4927. En consecuencia, las aplicaciones h(k), a las que desde ahora llamaremos funciones hash, tienen la particularidad de que podemos esperar que h( ki ) = h( kj ) para bastantes pares distintos ( ki,kj ). El objetivo será pues encontrar una función hash que provoque el menor número posible de colisiones
  • 44. 44 (ocurrencias de sinónimos), aunque esto es solo un aspecto del problema, el otro será el de diseñar métodos de resolución de colisiones cuando éstas se produzcan. FUNCIONES HASH. El primer problema que hemos de abordar es el cálculo de la función hash que transforma claves en localizaciones de la tabla. Más concretamente, necesitamos una función que transforme claves(normalmente enteros o cadenas de caracteres) en enteros en un rango [0..M-1], donde M es el número de registros que podemos manejar con la memoria de que dispongamos.como factores a tener en cuenta para la elección de la función h(k) están que minimice las colisiones y que sea relativamente rápida y fácil de calcular, aunque la situación ideal sería encontrar una función h que generara valores aleatorios uniformemente sobre el intervalo [0..M-1]. Las dos aproximaciones que veremos están encaminadas hacia este objetivo y ambas están basadas en generadores de números aleatorios. Hasing Multiplicativo. Esta técnica trabaja multiplicando la clave k por sí misma o por una constante, usando después alguna porción de los bits del producto como una localización de la tabla hash. Cuando la elección es multiplicar k por sí misma y quedarse con alguno de los bits centrales, el método se denomina el cuadrado medio. Este metodo aún siendo simple y pudiendo cumplir el criterio de que los bits elegidos para marcar la localización son función de todos los bits originales de k, tiene como principales inconvenientes el que las claves con muchos ceros se reflejarán en valores hash también con muchos ceros, y el que el tamaño de la tabla está restringido a ser una potencia de 2.
  • 45. 45 Otro método multiplicativo, que evita las restricciones anteriores consiste en calcular h(k) = Int[M * Frac(C*k)] donde M es el tamaño de la tabla y 0 <= C <= 1, siendo importante elegir C con cuidado para evitar efectos negativos como que una clave alfabética K sea sinónima a otras claves obtenidas permutando los caracteres de k. Knuth (ver bibliografía) prueba que un valor recomendable es: Hasing por División. En este caso la función se calcula simplemente como h(k) = k mod M usando el 0 como el primer índice de la tabla hash de tamaño M. Aunque la fórmula es aplicable a tablas de cualquier tamaño es importante elegir el valor de M con cuidado. Por ejemplo si M fuera par, todas las claves pares (resp. impares) serían aplicadas a localizaciones pares (resp. impares), lo que constituiría un sesgo muy fuerte. Una regla simple para elegir M es tomarlo como un número primo. En cualquier caso existen reglas mas sofisticadas para la elección de M (ver Knuth), basadas todas en estudios téoricos de funcionamiento de los métodos congruenciales de generación de números aleatorios. RESOLUCIÓN DE COLISIONES. El segundo aspecto importante a estudiar en el hasing es la resolución de colisiones entre sinónimos. Estudiaremos tres métodos basicos de resolución de colisiones, uno de ellos depende de la idea de mantener listas enlazadas de sinónimos, y los otros dos del cálculo de una secuencia de localizaciones en la tabla hash hasta que se encuentre que se encuentre una vacía. El análisis comparativo de los métodos se hará en base al estudio del número de localizaciones que han de examinarse hasta determinar donde situar cada nueva clave en la tabla.
  • 46. 46 Para todos los ejemplos el tamaño de la tabla será M=13 y la función hash h1(k) que utilizaremos será: HASH = Clave Mod M y los valores de la clave k que consideraremos son los expuestos en la siguiente tabla: Suponiendo que k=0 no ocurre de forma natural, podemos marcar todas las localizaciones de la tabla, inicialmente vacías, dándoles el valor 0. Finalmente y puesto que las operaciones de búsqueda e inserción están muy relacionadas, se presentaran algoritmos para buscar un item insertándolo si es necesario (salvo que esta operación provoque un desbordamiento de la tabla) devolviendo la localización del item o un -1 (NULL) en caso de desbordamiento. Encadenamiento separado o Hasing Abierto. La manera más simple de resolver una colisión es construir, para cada localización de la tabla, una lista enlazada de registros cuyas claves caigan en esa dirección. Este método se conoce normalmente con el nombre de encadenamiento separado y obviamente la cantidad de tiempo requerido para una búsqueda dependerá de la longitud de las listas y de las posiciones relativas de las claves en ellas. Existen variantes dependiendo del mantenimiento que hagamos de las listas de sinónimos (FIFO, LIFO, por valor Clave, etc), aunque en la mayoría de los casos, y dado que las
  • 47. 47 listas individuales no han de tener un tamaño excesivo, se suele optar por la alternativa más simple, la LIFO. En cualquier caso, si las listas se mantienen en orden esto puede verse como una generalización del método de búsqueda secuencial en listas. La diferencia es que en lugar de mantener una sola lista con un solo nodo cabecera se mantienen M listas con M nodos cabecera de forma que se reduce el número de comparaciones de la búsqueda secuencial en un factor de M (en media) usando espacio extra para M punteros. Para nuestro ejemplo y con la alternativa LIFO, la tabla quedaría como se muestra en la siguiente figura: A veces y cuando el número de entradas a la tabla es relativamente moderado, no es conveniente dar a las entradas de la tabla hash el papel de cabeceras de listas, lo que nos conduciría a otro método de encadenamiento, conocido como encadenamiento interno. En este caso, la unión entre
  • 48. 48 sinónimos está dentro de la propia tabla hash, mediante campos cursores (punteros) que son inicializados a -1 (NULL) y que irán apuntando hacia sus respectivos sinónimos. Direccionamiento abierto o Hasing Cerrado. Otra posibilidad consiste en utilizar un vector en el que se pone una clave en cada una de sus casillas. En este caso nos encontramos con el problema de que en el caso de que se produzca una colisión no se pueden tener ambos elementos formando parte de una lista paraesa casilla. Para solucionar ese problema se usa lo que se llama rehashing. El rehashing consiste en que una vez producida una colisión al insertar un elemento se utiliza una función adicional para determinar cual será la casilla que le corresponde dentro de la tabla, aesta función la llamaremos función de rehashing,rehi(k). A la hora de definir una función de rehashing existen múltiples posibilidades, la más simple consiste en utilizar una función que dependa del número de intentos realizados para encontrar una casilla libre en la que realizar la inserción, a este tipo de rehashing se le conoce como hashing lineal. De esta forma la función de rehashing quedaria de la siguiente forma: rehi(k) = (h(k)+(i-1)) mod M i=2,3,... En nuestro ejemplo, después de insertar las 7 primeras claves nos aparece la tabla A, (ver la tabla siguiente). Cuando vamos a insertar la clave 147, esta queda situada en la casilla 6, (tabla B) una vez que no se han encontrado vacías las casillas 4 y 5. Se puede observar que antes de la inserción del 147 había agrupaciones de claves en las localizaciones 4,5 y 7,8, y después de la inserción, esos dos grupos se han unido formando una agrupación primaria mayor, esto conlleva que si se trata de insertar un elemento al que le corresponde algunas de las casillas que están al principio de esa agrupación el proceso de rehashing tendrá de recorrer todas esas casillas con lo que se degradará
  • 49. 49 la eficiencia de la inserción. Para solucionar este problema habrá que buscar un método de rehashing que distribuya de la forma más aleatoria posible las casillas vacías. Despues de llevar a cabo la inserción de las claves consideradas en nuestro ejemplo, el estado de la tabla hash será el que se puede observar en la tabla (C) en la que adémas aparece el número de intentos que han sido necesarios para insertar cada una de las claves. Para intentar evitar el problema de las agrupaciones que acabamos de ver podríamos utilizar la siguiente función de rehashing: rehi(k) = (h(k)+(i-1)*C) mod M C>1 y primo relativo con M
  • 50. 50 pero aunque esto evitaría la formación de agrupaciones primarias, no solventaría el problema de la formación de agrupaciones secundarias (agrupaciones separadas por una distancia C). El problema básico de rehashing lineal es que para dos claves distintas que tengan el mismo valor para la función hash se irán obteniendo exactamente la misma secuencia de valores al aplicar la función de rehashing, cunado lo interenante seria que la secuencia de valores obtenida por el proceso de rehashing fuera distinta. Así, habrá que buscar una función de rehashing que cumpla las siguientes condiciones:  Sea fácilmente calculable (con un orden de eficiencia constante),  que evite la formación de agrupaciones,  que genere una secuencia de valores distinta para dos claves distintas aunque tenga el mismo valor de función hash, y por último  que garantice que todas las casillas de la tabla son visitadas. si no cumpliera esto último se podría dar el caso de que aún quedaran casillas libres pero no podemos insertar un determinado elemento porque los valores correspondientes a esas casillas no son obtenidos durante el rehashing. Una función de rehashing que cumple las condiciones anteriores es la función de rehashing doble. Esta función se define de la siguiente forma: hi(k) = (hi-1(k)+h0(k)) mod M i=2,3,... con h0(k) = 1+k mod (M-2) y h1(k) = h(k). Existe la posibilidad de hacer otras elecciones de la función h0(k) siempre que la función escogida no sea constante.
  • 51. 51 Esta forma de rehashing doble es particularmente buena cuando M y M-2 son primos relativos. Hay que tener en cuenta que si M es primo entonces es seguro que M-2 es primo relativo suyo (exceptuando el caso trivial de que M=3). El resultado de aplicar este método a nuestro ejemplo puede verse en las tablas siguientes. En la primera se incluyen los valores de h para cada clave y en la segunda pueden verse las localizaciones finales de las claves en la tabla así como las pruebas requeridas para su inserción.
  • 52. 52 BORRADOS Y REHASING. Cuando intentamos borrar un valor k de una tabla que ha sido generada por direccionamiento abierto, nos encontramos con un problema. Si k precede a cualquier otro valor k en una secuencia de pruebas, no podemos eliminarlo sin más, ya que si lo hiciéramos, las pruebas siguientes para k se encontrarian el "agujero" dejado por k por lo que podríamos concluir que k no está en la tabla, hecho que puede ser falso.Podemos comprobarlo en nuestro ejemplo en cualquiera de las tablas. La solución es que necesitamos mirar cada localización de la tabla hash como inmersa en uno de los tres posibles estados: vacia, ocupada o borrada, de forma que en lo que concierne a la busqueda, una celda borrada se trata exectamente igual que una ocupada.En caso de inserciones, podemos usar la primera localización vacia o borrada que se encuentre en la secuencia de pruebas para realizar la operación. Observemos que este problema no afecta a los borrados de las listas en el encadenamiento separado. Para la implementación de la idea anterior podria pensarse en la introducción en los algorítmos de un valor etiqueta para marcar las casillas borradas, pero esto sería solo una solución parcial ya que quedaría el problema de que si los borrados son frecuentes, las búsquedas sin éxito podrían requerir O(M) pruebas para detectar que un valor no está presente. Cuando una tabla llega a un desbordamiento o cuando su eficiencia baja demasiado debido a los borrados, el único recurso es llevarla a otra tabla de un tamaño más apropiado, no necesariamente mayor, puesto que como las localizaciones borradas no tienen que reasignarse, la nueva tabla podría ser mayor, menor o incluso del mismo tamaño que la original. Este proceso se suele denominar rehashing y es muy simple de implementar si el arca de la nueva tabla es distinta al de la primitiva, pero puede complicarse bastante si deseamos hacer un rehashing en la propia tabla.
  • 53. 53 EVALUACIÓN DE LOS MÉTODOS DE RESOLUCIÓN. El aspecto más significativo de la búsqueda por hashing es que si eficiencia depende del denominado factor de almacenamiento Ó= n/M con n el número de items y M el tamaño de la tabla. Discutiremos el número medio de pruebas para cada uno de los métodos que hemos visto de resolución de colisiones, en términos de BE (búsqueda con éxito) y BF (búsqueda sin éxito). Encadenamiento separado. Aunque puede resultar engañoso comparar este método con los otros dos, puesto que en este caso puede ocurrir que Ó>1, las fórmulas aproximadas son: Estas expresiones se aplican incluso cuando Ó>>1, por lo que para n>>M, la longitud media de cada lista será Ó, y deberia esperarse en media rastrear la mitad de la lista, antes de encontrar un determinado elemento. Hasing Lineal. Las fórmulas aproximadas son: Como puede verse, este método, aun siendo satisfactorio para Ó pequeños, es muy pobre cuando Ó -> 1, ya que el límite de los valores medios de BE y BF son respectivamente:
  • 54. 54 En cualquier caso, el tamaño de la tabla en el hash lineal es mayor que en el encadenamiento separado, pero la cantidad de memoria total utilizada es menor al no usarse punteros. Hasing Doble. Las fórmulas son ahora: BE=-(1/1-Ó) * ln(1-Ó) BF=1/(1-Ó) con valores medios cuando Ó -> 1 de M y M/2, respectivamente. Para facilitar la comprensión de las fórmulas podemos construir una tabla en la que las evaluemos para distintos valores de Ó: La elección del mejor método hash para una aplicación particular puede no ser fácil. Los distintos métodos dan unas características de eficiencia similares. Generalmente, lo mejor es usar el encadenamiento separado para reducir los tiempos de búsqueda cuando el número de registros a procesar no se conoce de antemano y el hash doble para buscar claves cuyo número pueda, de alguna manera, predecirse de antemano.
  • 55. 55 En comparación con otras técnicas de búsqueda, el hashing tiene ventajas y desventajas. En general, para valores grandes de n (y razonables valores de Ó) un buen esquema de hashing requiere normalmente menos pruebas (del orden 1.5 - 2) que cualquier otro método de búsqueda, incluyendo la búsqueda en árboles binarios. Por otra parte, en el caso peor, puede comportarse muy mal al requerir O(n) pruebas. También puede considerarse como una ventaja el hecho de que debemos tener alguna estimación a priori de número máximo de items que vamos a colocar en la tabla aunque si no disponemos de tal estimación siempre nos quedaría la opción de usar el metodo de encadenamiento separado en donde el desbordamiento de la tabla no constituye ningún problema. Otro problema relativo es que en una tabla hash no tenemos ninguna de las ventajas que tenemos cuando manejamos relaciones ordenadas, y así p.e. no podemos procesar los items en la tabla secuencialmente, ni concluir tras una búsqueda sin éxito nada sobre los items que tienen un valor cercano al que buscamos, pero en cualquier caso el mayor problema que tener el hashing cerrado es el de los borrados dentro de la tabla. Implementación de Hasing Abierto. En este apartado vamos a realizar una implementación simple del hasing abierto que nos servirá como ejemplo ilustrativo de su funcionamiento. Para ello supondremos un tipo de dato char * para el cual diseñaremos una función hash simple consistente en la suma de los codigos ASCII que componen dicha cadena. Una posible implementación utilizando el tipo de dato abstracto lista sería la siguiente: #define NCASILLAS 100 /*Ejemplo de número de entradas en la tabla.*/
  • 56. 56 typedef tLista *TablaHash; Para la cual podemos diseñar las siguientes funciones de creación y destrución: TablaHash CrearTablaHash () { tLista *t; register i; t=(tLista *)malloc(NCASILLAS*sizeof(tLista)); if (t==NULL) error("Memoria insuficiente."); for (i=0;i<NCASILLAS;i++) t[i]=crear(); return t; } void DestruirTablaHash (TablaHash t) { register i; for (i=0;i<NCASILLAS;i++) destruir(t[i]); free(t); } Como fue mencionado anteriormente la función hash que será usada es: int Hash (char *cad) { int valor; unsigned char *c; for (c=cad,valor=O;*c;c++) valor+=(int)(*c); return(valor%NCASILLAS); }
  • 57. 57 Y funciones del tipo MiembroHash, InsertarHash, BorrarHash pueden ser programadas: int MiembroHash (char *cad,TablaHash t) { tPosicion p; int enc; int pos=Hash(cad); p=primero(t[pos]); enc=O; while (p!=fin(t[pos]) && !enc) { if (strcmp(cad,elemento(p,t[pos]))==O) enc=1; else p=siguiente(p,t[pos]); } return enc; } void InsertarHash (char *cad,TablaHash t) { int pos; if (MiembroHash(cad,t)) return; pos=Hash(cad); insertar(cad,primero(t[pos]),t[pos]); } void BorrarHash (char *cad,TablaHash t) { tPosicion p; int pos=Hash(cad); p=primero(t[pos]); while (p!=fin(t[pos]) && !strcmp(cad,elemento(p,t[pos]))) p=siguiente(p,t[pos])); if (p!=fin(t[pos])) borrar(p,t[pos]); } Como se puede observar esta implementación es bastante simple de forma que puede sufrir bastantes mejoras. Se propone como ejercicio el realizar esta labor dotando al tipo de dato de posibilidades como:  Determinación del tamaño de la tabla en el momento de creación.
  • 58. 58  Modificación de la función hash utilizada, mediante el uso de un puntero a función.  Construcción de una función que pasa una tabla hash de un tamaño determinado a otra tabla con un tamaño superior o inferior.  Construcción de un iterador a través de todos los elementos de la tabla.  etc... Implementación de Hasing Cerrado. En este apartado vamos a realizar una implementación simple del hashing cerrado. Para ello supondremos un tipo de datochar * al igual que en el apartado anterior, para el cual diseñaremos la misma función hash. Una posible implementación de la estructura a conseguir es la siguiente: #define NCASILLAS 100 #define VACIO NULL static char * BORRADO=''''; typedef char **TablaHash; Para la cual podemos diseñar las siguientes funciones de creación y destrucción: TablaHash CrearTablaHash () { TablaHash t; register i; t=(TablaHash)malloc(NCASILLAS*sizeof(char *)); if (t==NULL) error("Memoria Insuficiente."); for (i=0;i<NCASILLAS;i++) t[i]=VACIO; return t; } void DestruirTablaHash (TablaHash t) { register i; for (i=O;i<NCASILLAS;i++)
  • 59. 59 if (t[i]!=VACIO && t[i]!=BORRADO) free(t[i]); free t; } La función hash que será usada es igual a la que ya hemos usado para la implementación del Hasing Abierto. Y funciones del tipo MiembroHash, InsertarHash, BorrarHash pueden ser programadas tal como sigue, teniendo en cuenta que en esta implementación haremos uso de un rehashing lineal. int Hash (char *cad) { int valor; unsigned char *c; for (c=cad, valor=0; *c; c++) valor += (int)*c; return (valor%NCASILLAS); } int Localizar (char *x,TablaHash t) /* Devuelve el sitio donde esta x o donde deberia de estar. */ /* No tiene en cuenta los borrados. */ { int ini,i,aux; ini=Hash(x); for (i=O;i<NCASILLAS;i++) { aux=(ini+i)%NCASILLAS; if (t[aux]==VACIO) return aux; if (!strcmp(t[aux],x)) return aux; } return ini; } int Localizar1 (char *x,TablaHash t) /* Devuelve el sitio donde podriamos poner x */ { int ini,i,aux; ini=Hash(x); for (i=O;i<NCASILLAS;i++) {
  • 60. 60 aux=(ini+i)%NCASILLAS; if (t[aux]==VACIO || t[aux]==BORRADO) return aux; if (!strcmp(t[aux],x)) return aux; } return ini; } int MiembroHash (char *cad,TablaHash t) { int pos=Localizar(cad,t); if (t[pos]==VACIO) return 0; else return(!strcomp(t[pos],cad)); } void InsertarHash (char *cad,TablaHash t) { int pos; if (!cad) error("Cadena inexistente."); if (!MiembroHash(cad,t)) { pos=Localizar1(cad,t); if (t[pos]==VACIO || t[pos]==BORRADO) { t[pos]=(char *)malloc((strlen(cad)+1)*sizeof(char)); strcpy(t[pos],cad); } else { error("Tabla Llena. n"); } } } void BorrarHash (char *cad,TablaHash t) { int pos = Localizar(cad,t); if (t[pos]!=VACIO && t[pos]!=BORRADO) { if (!strcmp(t[pos],cad)) { free(t[pos]); t[pos]=BORRADO; } } }
  • 61. 61 Algoritmos de selección Max- Min Existe un algoritmo que se utiliza para encontrar el número máximo y el mínimo dentro de un arreglo de números (o de otros objetos, dependiendo del programa), es el algoritmo MAXMIN. El algoritmo sirve para un número n(en potencia de dos) de elementos. Después de encontrar el máximo y el mínimo en el arreglo, guarda el valor máximo en la posición [0] y el mínimo en la posición[1] de un arreglo[2]. El algoritmo es recursivo, en el caso base(cuando n es dos) solamente compara los valores y los coloca en en las casillas correspondientes del arreglo[2]. En el caso recursivo, el arreglo se va partiendo en mitades, de 0 a (n/2)-1 y otro de n/2 a n-1. Así hasta llegar al caso base. Consideraremos que el tamaño máximo de la entrada y el vector a ordenar vienen dados por las siguientes definiciones: CONSTn =...; (* numero maximo de elementos *) TYPE vector = ARRAY [1..n] OF INTEGER; Los procedimientos de ordenación están diseñados para ordenar cualquier subvector de un vector dado a[1..n]. Por eso generalmente poseerán tres parámetros: el nombre del vector que contiene a los elementos (a) y las posiciones de comienzo y fin del subvector, como por ejemplo
  • 62. 62 Seleccion(a,prim,ult). Para ordenar todo el vector, basta con invocar al procedimiento con los valores prim= 1, ult = n Haremos uso de dos funciones que permiten determinar la posición de los elementos máximo y mínimo de un subvector dado: Y utilizaremos un procedimiento para intercambiar dos elementos de un vector: Veamos los tiempos de ejecución de cada una de ellas:
  • 63. 63 a) El tiempo de ejecución de la función PosMaximo va a depender, además del tamaño del subvector de entrada, de su ordenación inicial, y por tanto distinguiremos tres casos: – En el caso mejor, la condición del IF es siempre falsa. Por tanto: En el caso peor,la condicióndel IFessiempre verdadera.Porconsiguiente: En el caso medio, vamos a suponer que la condición del IF será verdadera en el 50% de los casos. Por tanto: Estos casos corresponden respectivamente a cuando el elemento máximo se encuentra en la primera posición, en la última y el vector está ordenado de forma creciente, o cuando consideramos equiprobables cada una de las n posiciones en donde puede encontrarse el máximo. Como podemos apreciar, en cualquiera de los tres casos su complejidad es lineal con respecto al tamaño de la entrada. b) El tiempo de ejecución de la función PosMinimo es exactamente igual al de la función PosMaximo. c) La función Intercambia realiza 7 operaciones elementales (3 asignaciones y 4 accesos al vector), independientemente de los datos de entrada. Nótese que en las funciones PosMaximo y PosMinimo hemos utilizado el paso del vector a por referencia en vez de por valor (mediante el uso de VAR) para evitar la copia del vector
  • 64. 64 en la pila de ejecución, lo que incrementaría la complejidad del algoritmo resultante, pues esa copia es de orden O(n) .
  • 65. 65 K-ésimo Sea T[1..n] un arreglo de enteros y sea k un entero entre 1 y n. El k − esimo menor elemento de T se define como el elemento que estar´ıa en la posici´on k si los elementos en el arreglo estuvieran ordenados de manera creciente. El problema de encontrar el k−esimo elemento se conoce como el problema de la selección EL algoritmo de selección y reemplazo ordena los elementos de un arreglo nativo sin utilizar un segundo arreglo. El algoritmo se basa en la siguiente idea. En el arreglo ordenado, el mínimo debe quedar en a[0], de modo que se ubica su posición real en el arreglo. Por ejemplo, se determina que está en a[3] como lo indica el primer arreglo de izquierda a derecha en la siguiente figura: Entonces se procede a intercambiar los valores de a[0] con a[3]. Ahora, a[0] contiene el valor correcto y por lo tanto, nos olvidamos de él. A continuación, el algoritmo ordena el arreglo desde el índice 1 hasta el 5, ignorando a[0]. Por lo tanto, ubica nuevamente la posición del mínimo entre a[1], a[2], ..., a[5] y lo intercambia con el elemento que se encuentra en a[1] como se indica en el segundo arreglo de la figura. Ahora, a[0] y a[1] contienen los valores correctos. Y así el algoritmo continúa hasta que a[0], a[1], ..., a[5] tengan los valores ordenados.
  • 66. 66 El algoritmo para ordenar un arreglo a de n elementos es: Realizar un ciclo de conteo para la variable i, de modo que tome valores desde 0 hasta n-1. En cada iteración:  Suponer que el arreglo se encuentra ordenado desde a[0] hasta a[i-1].  Ubicar k>=i, tal que a[k]<=a[j], para todo j en [i,n-1].  Intercambiar los valores de a[k] y a[i]. Programa: void seleccion(int[ ] a, int n) { for (int i= 0; i<n; i++) { // Buscamos la posicion del minimo en a[i], a[i+1], ..., a[n-1] int k= i; for (int j= i+1; j<n; j++) { if (a[j]<a[k]) k= j; } // intercambiamos a[i] con a[k] int aux= a[i]; a[i]= a[k]; a[k]= aux; } } Caso de borde: cuando la posición del mínimo es k==i. Entonces se intercambia a[i] con a[i]. Una rápida revisión del intercambio permite apreciar que en ese caso a[i] no altera su valor, y por lo tanto no es incorrecto. Se podría introducir una instrucción if para que no se realice el intercambio cuando k==i, pero esto sería menos eficiente que realizar el intercambio aunque no sirva. En efecto, en algunos casos el if permitiría ahorrar un intercambio, pero en la mayoría de los casos, el if no serviría y sería un sobrecosto que excedería con creces lo ahorrado cuando sí sirve.
  • 67. 67 Análisis del tiempo de ordenamiento por selección y reemplazo Sea t1 el tiempo que toma una iteración del ciclo interno cuando la condición es verdadera. Sea t2 el tiempo que toma una interación del ciclo externo sin contar el tiempo que toma el ciclo interno. Entonces el tiempo de ejecución en función de n es igual a: T(n) <= n*t2+(n-1)*t1+(n-2)*t1+(n-3)*t1+...+1*t1 Desarrollando esta inecuación se llega a: T(n) <= A*n^2+B*n+C Con A, B y C constantes a determinar. Por lo tanto es fácil probar que: T(n) = O(n^2) De la misma forma se puede probar que en el mejor caso y en el caso promedio, el algoritmo también es O(n^2). Por lo tanto:  Mejor caso: O(n^2)  Peor caso: O(n^2)  Caso promedio: O(n^2) Comparación con ordenamiento con colas: Es fácil probar que el algoritmo basado en colas también es O(n^2) cuando las operaciones agregar y extraer en la cola toman tiempo constante. Esto no es necesariamente cierto porque depende de cómo esté implementada la clase Queue. En todo caso sí es cierto si la cola se implementa con arreglos nativos o con las listas enlazadas que veremos pronto. La siguiente es una comparación empírica de los tiempos de ejecución de ambos algoritmos:
  • 68. 68 Númerode Tiempode Ord. Tiempode Ord. elementos con colas con arreglos 100 103 miliseg. 4 miliseg. 500 3638 miliseg. 86 miliseg. 1000 20 seg. 0.3 seg. 2000 121 seg. 1.4 seg. 100000 más de 3 días 1 hora Se puede observar que el uso de arreglos nativos es unas 50 veces más eficiente que la cola. Sin embargo, el algoritmo de ordenamiento por selección y reemplazo sigue siendo O(n^2) y por lo tanto es ineficiente. En efecto, si consideramos el problema de la clase pasada en que se necesitaba ordenar para luego realizar búsquedas eficientes, esta solución no sirve, porque el ordenamiento tiene un sobrecosto que excede las ganancias de la búsqueda binaria.
  • 69. 69 Colas de prioridad Una cola de prioridad es una colección de elementos donde cada elemento tiene asociado un valor susceptible de ordenación denominado prioridad. Una cola de prioridad se caracteriza por admitir inserciones de nuevos elementos y la consulta y eliminación del elemento de mínima (o máxima) prioridad. Se puede usar una cola de prioridad para hallar el k-ésimo elemento de un vector no ordenado. Se colocan los k primeros elementos del vector en una max-cola de prioridad y a continuación se recorre el resto del vector, actualizando la cola de prioridad cada vez que el elemento es menor que el mayor de los elementos de la cola, eliminando al máximo e insertando el elemento en curso. Las estructuras de datos que implementan colas de prioridad contienen registros con claves numéricas (prioridades), y cuentan con algunas de las siguientes operaciones: Construir una cola de prioridad a partir de N elementos Insertar un nuevo elemento Suprimir el elemento más grande Sustituir el elemento más grande por un nuevo elemento Cambiar la prioridad de un elemento Eliminar un elemento arbitrario determinado Unir dos colas de prioridad en una más grande.
  • 70. 70 Heaps La estructura heap es frecuentemente usada para implementar colas de prioridad. En este tipo de colas, el elemento a ser eliminado (borrados) es aquél que tiene mayor (o menor) prioridad. En cualquier momento, un elemento con una prioridad arbitraria puede ser insertado en la cola. Una estructura de datos que soporta estas dos operaciones es la cola de prioridad máxima (mínima). Existen tres categorías de un heap: max heap, min heap y min-max heap. Un max (min) tree es un árbol en el cual el valor de la llave de cada nodo no es menor (mayor) que la de los valores de las llaves de sus hijos (si tiene alguno). Un max heap es un árbol binario completo que es también un max tree. Por otra parte, un min heap es un árbol binario completo que es también un min tree. De la definición se sabe que la llave del root de un min tree es la menor llave del árbol, mientras que la del root de un max tree es la mayor. Si la llave (key) de cada nodo es mayor que o igual a las llaves de sus hijos, entonces la estructura
  • 71. 71 heap es llamada max heap. Si la llave (key) de cada nodo es menor que o igual a las llaves d esus hijos, entonces la estructura heap es llamada min heap. En una estructura min-max heap, un nivel satisface la propiedad min heap, y el siguiente nivel inferior satisface la propiedad max heap, alternadamente. Un min-max heap es útil para colas de prioridad de doble fin.
  • 72. 72 Las operaciones básicas de un heap son:  Creación de un heap vacío  Inserción de un nuevo elemento en la estructura heap.  Eliminación del elemento más grande del heap. Su único requisito es que sólo es posible acceder a ellos a través de un puntero. Ventajas:  Soporta las operaciones insertar y suprimir en tiempo O(log N) en el caso peor.  Soporta insertar en tiempo constante en promedio y primero en tiempo constante en el peor caso. Un heap tiene las siguientes tres propiedades:  Es completo, esto es, las hojas de un árbol están en a lo máximo dos niveles adyacentes, y las hojas en el último nivel están en la posición leftmost.
  • 73. 73  Cada nivel en un heap es llenado en orden de izquierda a derecha.  Está parcialmente ordenado, esto es, un valor asignado, llamado key del elemento almacenado en cada nodo (llamado parent), es menor que (mayor que) o igual a las llaves almacenadas en los hijos de los nodos izquierdo y derecho.
  • 74. 74 Heapsort Heapsort ordena un vector de n elementos construyendo un heap con los n elementos y extrayéndolos, uno a uno del heap a continuación. El propio vector que almacena a los n elementos se emplea para construir el heap, de modo que heapsort actúa in-situ y sólo requiere un espacio auxiliar de memoria constante. El coste de este algoritmo es (n log n) (incluso en caso mejor) si todos los elementos son diferentes. En la práctica su coste es superior al de quicksort, ya que el factor constante multiplicativo del término n log n es mayor. Características:  Es un algoritmo que se construye utilizando las propiedades de los montículos binarios.  El orden de ejecución para el peor caso es O(N·log(N)), siendo N el tamaño de la entrada.  Aunque teóricamente es más rápido que los algoritmos de ordenación vistos hasta aquí, en la práctica es más lento que el algoritmo de ordenación de Shell utilizando la secuencia de incrementos de Sedgewick. Breve repaso de las propiedades de los montículos binarios (heaps) Recordemos que un montículo Max es un árbol binario completo cuyos elementos están ordenados del siguiente modo: para cada subárbol se cumple que la raíz es mayor que ambos hijos. Si el montículo fuera Min, la raíz de cada subárbol tiene que cumplir con ser menor que sus hijos. Recordamos que, si bien un montículo se define como un árbol, para representar éste se utiliza un array de datos, en el que se acceden a padres e hijos utilizando las siguientes transformaciones
  • 75. 75 sobre sus índices. Si el montículo está almacenado en el array A, el padre de A[i] es A[i/2] (truncando hacia abajo), el hijo izquierdo de A[i] es A[2*i] y el hijo derecho de A[i] es A[2*i+1]. Al insertar o eliminar elementos de un montículo, hay que cuidar de no destruir la propiedad de orden del montículo. Lo que se hace generalmente es construir rutinas de filtrado (que pueden ser ascendentes o descendentes) que tomen un elemento del montículo (el elemento que viola la propiedad de orden) y lo muevan vérticalmente por el árbol hasta encontrar una posición en la cual se respete el orden entre los elementos del montículo. Tanto la inserción como la eliminación (eliminar_min o eliminar_max según sea un montículo Min o Max respectivamente), de un elemento en un montículo se realizan en un tiempo O(log(N)), peor caso (y esto se debe al orden entre sus elementos y a la característica de árbol binario completo). Estrategia general del algoritmo. A grandes razgos el algoritmo de ordenación por montículos consiste en meter todos los elementos del array de datos en un montículo MAX, y luego realizar N veces eliminar_max(). De este modo, la secuencia de elementos eliminados nos será entregada en orden decreciente. Implementación de Heapsort Heapsort tiene un orden de magnitud que coincide con la cota inferior, esto es, es óptimo incluso en el peor caso. Nótese que esto no era así para Quicksort, el cual era óptimo en promedio, pero no en el peor caso. De acuerdo a la descripción de esta familia de algoritmos, daría la impresión de que en la fase de construcción del heap se requeriría un arreglo aparte para el heap, distinto del arreglo de entrada.
  • 76. 76 De la misma manera, se requeriría un arreglo de salida aparte, distinto del heap, para recibir los elementos a medida que van siendo extraídos en la fase de ordenación. En la práctica, esto no es necesario y basta con un sólo arreglo: todas las operaciones pueden efectuarse directamente sobre el arreglo de entrada. En primer lugar, en cualquier momento de la ejecución del algoritmo, los elementos se encuentran particionados entre aquellos que están ya o aún formando parte del heap, y aquellos que se encuentran aún en el conjunto de entrada, o ya se encuentran en el conjunto de salida, según sea la fase. Como ningún elemento puede estar en más de un conjunto a la vez, es claro que, en todo momento, en total nunca se necesita más de n casilleros de memoria, si la implementación se realiza bien. En el caso de Heapsort, durante la fase de construcción del heap, podemos utilizar las celdas de la izquierda del arreglo para ir "armando" el heap. Las celdas necesarias para ello se las vamos "quitando" al conjunto de entrada, el cual va perdiendo elementos a medida que se insertan en el heap. Al concluir esta fase, todos los elementos han sido insertados, y el arreglo completo es un solo gran heap.
  • 77. 77 En la fase de ordenación, se van extrayendo elementos del heap, con lo cual este se contrae de tamaño y deja espacio libre al final, el cual puede ser justamente ocupado para ir almacenando los elementos a medida que van saliendo del heap (recordemos que van apareciendo en orden decreciente). Optimización de la fase de construcción del heap Como se ha señalado anteriormente, tanto la fase de construcción del heap como la de ordenación demoran tiempo O(n log n). Esto es el mínimo posible (en orden de magnitud), de modo que no es posible mejorarlo significativamente. Sin embargo, es posible modificar la implementación de la fase de construcción del heap para que sea mucho más eficiente. La idea es invertir el orden de las "mitades" del arreglo, haciendo que el "input" esté a la izquierda y el "heap" a la derecha. En realidad, si el "heap" está a la derecha, entonces no es realmente un heap, porque no es un árbol completo (le falta la parte superior), pero sólo nos interesa que en ese sector del arreglo se cumplan
  • 78. 78 las relaciones de orden entre a[k] y {a[2*k],a[2*k+1]}. En cada iteración, se toma el último elemento del "input" y se le "hunde" dentro del heap de acuerdo a su nivel de prioridad. Al concluir, se llega igualmente a un heap completo, pero el proceso es significativamente más rápido. La razón es que, al ser "hundido", un elemento paga un costo proporcional a su distancia al fondo del árbol. Dada las características de un árbol, la gran mayoría de los elementos están al fondo o muy cerca de él, por lo cual pagan un costo muy bajo. En un análisis aproximado, la mitad de los elementos pagan 0 (ya están al fondo), la cuarta parte paga 1, la octava parte paga 2, etc. Sumando todo esto, tenemos que el costo total está acotado por Para realizar un ordenamiento mediante HeapSort, es necesario generar primero la estructura Heap, esta se puede hacer utilizando el procedimiento Shift descrito anteriormente, iniciando con una
  • 79. 79 estructura Heap completamente desordenada, es decir, insertar los elementos en la estructura, en cualquier orden, y posteriormente realizar una operación Shift para cada elemento en la estructura. El algoritmo HeapSort, consiste en remover el mayor elemento que es siempre la raíz del Heap, una vez seleccionado el máximo, lo intercambiamos con el último elemento del vector, decrementamos la cantidad de elementos del Heap e invocamos Shift a partir de la raíz. Analizando el algoritmo Shift.- Sabemos que un árbol completo tiene log n niveles. La cantidad de trabajo que se hace en cada nivel es constante, por lo tanto Shift es O(log n). Ya que HeapSort realiza n - 1 llamadas a Shift (no se cuenta la raíz original del árbol), y cada llamada es O(log n). Por lo tanto el tiempo total es O((n-1)log n) = O(nlog n).
  • 80. 80 Memoria secundaria Árboles B Los B-árboles son árboles cuyos nodos pueden tener un número múltiple de hijos tal como muestra el esquema de uno de ellos en la siguiente figura . Como se puede observar en la figura ,un B-árbol se dice que es de orden m si sus nodos pueden contener hasta un máximo de m hijos.En la literatura también aparece que si un árbol es de orden m significa que el mínimo número de hijos que puede tener es m+1(m claves).Nosotros no la usaremos para diferenciar el caso de un número máximo par e impar de claves en un nodo. El conjunto de claves que se sitúan en un nodo cumplen la condición: de formaque los elementosque cuelgandel primerhijotienenunaclave con valormenorque K1,losque cuelgan del segundo tienen una clave con valor mayor que K1 y menor que K2,etc...Obviamente,los que
  • 81. 81 cuelgandel últimohijotienenunaclave convalormayor que laúltimaclave(hayque tenerencuentaque el nodo puede tener menos de m hijos y por consiguiente menos de m-1 claves). Para que un árbol sea B-árbol además deberá cumplir lo siguiente:  Todos los nodos excepto la raíz tienen al menos E((m-1)/2) claves.Lógicamente para los nodos interiores eso implica que tienen al menos E((m+1)/2) hijos.  Todas las hojas están en el mismo nivel. El hecho de que la raíz pueda tener menos descendientes se debe a que si el crecimiento del árbol hace que la raíz se divida en dos hay que permitir dicha situación para que los nuevos nodos mantengan esa propiedad.En el caso de que eso ocurra en un nodo interior distinto a la raíz se soluciona propagando hacia arriba;lógicamente esta operación no se puede realizar en el caso de raíz. Por otro lado,con el hecho de que los nodos interiores tengan un número mínimo de descendientes aseguramos que en el nivel n(nivel 1 corresponde a la raíz)haya un mínimo de 2En-1((m+1)/2)(el 2 es el mínimo de hijos de la raíz y E((m+1)/2) el mínimo para los demás)y teniendo en cuenta que un árbol con N claves tiene N+1 descendientes en el nivel de las hojas,podemos establecer la siguiente desigualdad: Resolviendo:
  • 82. 82 que nos da una cota superior del número de nodos a recorrer para localizar un elemento en el árbol. BÚSQUEDA EN UN B-ÁRBOL. Localizar una clave en un B-árbol es una operación simple pues consiste en situarse en el nodo raíz del árbol,si la clave se encuentra ahí hemos terminado y si no es así seleccionamos de entre los hijos el que se encuentra entre dos valores de clave que son menor y mayor que la buscada respectivamente y repetimos el proceso hasta que la encontremos.En caso de que se llegue a una hoja y no podamos proseguir la búsqueda la clave no se encuentra en el árbol.En definitiva,los pasos a seguir son los siguientes: 1. Seleccionar como nodo actual la raíz del árbol. 2. Comprobar si la clave se encuentra en el nodo actual: 1. Si la clave está, fin. 2. Si la clave no está:  Si estamos en una hoja,no se encuentra la clave.Fin.  Si no estamos en una hoja,hacer nodo actual igual al hijo que corresponde según el valor de la clave a buscar y los valores de las claves del nodo actual(i buscamos la clave K en un nodo con n claves:el hijo izquierdo si K<K1,el hijo derecho si K>Kn y el hijo i-ésimo si Ki<K<Ki+1)yvolver al segundo paso. INSERCIÓN EN UN B-ÁRBOL. Para insertar una nueva clave usaremos un algoritmo que consiste en dos pasos recursivos:
  • 83. 83 1. Buscamos la hoja donde debieramos encontrar el valor de la clave de una forma totalmente paralela a la búsqueda de ésta tal como comentabamos en la sección anterior(si en esta búsqueda encontramos en algun lugar del árbol la clave a insertar,el algoritmo no debe hacer nada más).Si la clave no se encuentra en el árbol habremos llegado a una hoja que es justamente el lugar donde debemos realizar esa inserción. 2. Situados en un nodo donde realizar la inserción si no está completo,es decir,si el número de claves que existen es menor que el orden menos 1 del árbol,el elemento puede ser insertado y el algoritmo termina.En caso de que el nodo esté completo insertamos la clave en su posición y puesto que no caben en un único nodo dividimos en dos nuevos nodos conteniendo cada uno de ellos la mitad de las claves y tomando una de éstas para insertarla en el padre(se usará la mediana).Si el padre está también completo,habrá que repetir el proceso hasta llegar a la raíz.En caso de que la raíz esté completa,la altura del árbol aumenta en uno creando un nuevo nodo raíz con una única clave. En la figura podemos observar el efecto de insertar una nueva clave en un nodo que está lleno.
  • 84. 84
  • 85. 85 Podemos realizar una modificación al algoritmo de forma que se retrase al máximo el momento de romper un nodo en dos.Con ello podríamos vernos beneficiados por dos razones fundamentalmente: 1. La razón más importante para modificar así el algoritmo es que los nodos en el árbol están más llenos con lo cual el gasto en memoria para mantener la estructura es mucho menor. 2. Retrasamos el momento en que la raíz llega a dividirse y por consiguiente retrasamos el momento en que la altura del árbol aumenta. La forma más sencilla de realizar esta modificación es que en el caso de que tengamos que realizar esa división,antes de llevarla a cabo,comprobemos si los hermanos adyacentes tienen espacio libre de forma que si alguno de ellos lo tiene se redistribuyen las claves que se encuentran en el nodo actual más las de ese hermano m&as la clave que los separa(que se encuentra en el padre)más la clave a insertar de forma que en el padre se queda la mediana y las demás quedan distribuidas entre los dos nodos. En la siguiente figura podemos observar el efecto de insertar una nueva clave en un nodo que está lleno pero con redistribución.
  • 86. 86 BORRADO EN UN B-ÁRBOL. La idea para realizar el borrado de una clave es similar a la inserción teniendo en cuenta que ahora,en lugar de divisiones,realizamos uniones.Existe un problema añadido,las claves a borrar pueden aparecer en cualquier lugar del árbol y por consiguiente no coincide con el caso de la inserción en la que siempre comenzamos desde una hoja y propagamos hacia arriba.La solución a esto es inmediata pues cuando borramos una clave que está en un nodo interior,lo primero que realizamos es un intercambio de este valor con el inmediato sucesor en el árbol,es decir,el hijo más a la izquierda del hijo derecho de esa clave. Las operaciones a realizar para poder llevar a cabo el borrado son por tanto: 1. Redistribución:la utilizaremos en el caso en que al borrar una clave el nodo se queda con un número menor que el mínimo y uno de los hermanos adyacentes tiene al menos uno más que ese mínimo,es decir,redistribuyendo podemos solucionar el problema.
  • 87. 87 2. Unión:la utilizaremos en el caso de que no sea posible la redistribución y por tanto sólo será posible unir los nodos junto con la clave que los separa y se encuentra en el padre. En definitiva,el algoritmo nos queda como sigue: 1. Localizar el nodo donde se encuentra la clave. 2. Si el nodo localizado no es una hoja,intercambiar el valor de la clave localizada con el valor de la clave más a la izquierda del hijo a la derecha.En definitiva colocar la clave a borrar en una hoja.Hacemos nodo actual igual a esa hoja. 3. Borrar la clave. 4. Si el nodo actual contiene al menos el mínimo de claves como para seguir siendo un B- árbol,fin. 5. Si el nodo actual tiene un número menor que el mínimo: 1. Si un hermano tiene más del mínimo de claves,redistribución y fin. 2. Si ninguno de los hermanos tiene más del mínimo,unión de dos nodos junto con la clave del padre y vuelta al paso 4 para propagar el borrado de dicha clave(ahora en el padre).
  • 88. 88 Hashing extendible Cuando se almacena información en memoria secundaria (disco), la función de costo pasa a ser el número de accesos a disco. En hashing para memoria secundaria, la función de hash sirve para escoger un bloque (página) del disco, en donde cada bloque contiene b elementos. El factor de carga pasa a ser . Por ejemplo, utilizando hashing encadenado: Este método es eficiente para un factor de carga pequeño, ya que con factor de carga alto la búsqueda toma tiempo O(n). Para resolver esto puede ser necesario incrementar el tamaño de la tabla, y así reducir el factor de carga. En general esto implica reconstruir toda la tabla, pero existe un método que permite hacer crecer la tabla paulatinamente en el tiempo denominado hashing extendible. Hashing extendible Suponga que las páginas de disco son de tamaño b y una función de hash h(X)>=0 (sin límite superior). Sea la descomposición en binario de la función de hash h(X)=(... b2(X) b1(X) b0(X))2.
  • 89. 89 Inicialmente, todas las llaves se encuentran en una única página. Cuando dicha página se rebalsa se divide en dos, usando b0(X) para discriminar entre ambas páginas: Cada vez que una página rebalsa, se usa el siguiente bit en la sequencia para dividirla en dos. Ejemplo: El índice (directorio) puede ser almacenado en un árbol, pero el hashing extensible utiliza una idea diferente. El árbol es extendido de manera que todos los caminos tengan el mismo largo: A continuación, el árbol es implementado como un arreglo de referencias:
  • 90. 90 Cuando se inserta un elemento y existe un rebalse, si cae en la página (D,E) esta se divide y las referencias de los casilleros 2 y 3 apuntan a las nuevas páginas creadas. Si la página (B,C) es la que se divide, entonces el árbol crece un nivel, lo cual implica duplicar el tamaño del directorio. Sin embargo, esto no sucede muy a menudo. El tamaño esperado del directorio es ligeramente superlinear: .
  • 91. 91 Unión Find La estructura Union-Find, también llamada Disjoint-set Union (abreviado DSU), es una estructura que nos permite manejar conjuntos disjuntos de elementos. Un ejemplo de esto en grafos son las componentes conexas, que son conjuntos disjuntos de nodos. Cada conjunto va a tener un representante que lo identifica, y las operaciones que debemos poder realizar eficientemente son:  find(x): Dado un elemento x, nos dice quién es el representante del conjunto al que pertenece.  union(x,y): Dados dos elementos x e y, unir los conjuntos, es decir, que pasen a ser uno solo, con un solo representantes para los elementos de ambos conjuntos. La primera idea poco eficiente que suele surgir, es simplemente tener, para cada elemento, su representante (lo que facilita la función union), y que al intentar unir dos conjuntos con los elementos x e y, recorremos todos los elementos, y si su representante es el mismo que el representante de x (pertenecen al mismo conjunto actualmente), entonces su representante pasa a ser el representante de y. Esto podemos hacerlo con un vector que en la posición ii tenga el representante del elemento i. Pero esto es poco pues eficiente, pues si bien chequear el representante es O(1),unir dos conjuntos es O(n) donde n es la cantidad total de elementos, pues los revisamos todos. Optimizaciones Una posible mejora, es, además de guardar el representante de cada elemento, para cada representante guardar una lista con los elementos de su conjunto. Luego, al unir x con y,
  • 92. 92 recorremos el conjunto más chico de los que contienen al x y al y (mirando las listas de sus representantes), y agregamos todos sus elementos a la lista de y, cambiamos sus representantes por el representante de y, y vaciamos la lista del representante de x. Con esta mejora, como a cada elemento lo agregamos a un conjunto más grande que en el que está, se puede ver que si hay O(m) operaciones, a cada elemento lo agregamos a lo sumo a O(log(m)) conjuntos, por lo que la complejidad queda de O(m∗log(m)) Podemos pensar que cada conjunto es un árbol. El representante será la raíz, saber la raíz de un árbol (o sea el representante de un conjunto) es ir yendo a los padres de cada elemento, y agregar el conjunto de representante rx al de representante ry, es decirle a rx que su padre es ry, que sería como colgar el árbol de rx de la raíz ry. Si colgamos el árbol de menor tamaño al de mayor, como antes con las listas, haremos menos operaciones. Y si además de esto, al buscar el padre de un elemento recursivamente, lo actualizamos cuando encontremos la raíz, las alturas de los árboles se achican muchísimo, y al calcular el representante de un elemento dos veces por ejemplo, en la primera lo encontramos, se lo asignamos como padre, y luego al buscar el representante es subir una sola vez. La complejidad es un poco más baja que O(m∗log(m)), podría decirse que es lineal: para O(m) operaciones el tiempo que lleva procesarlas es casi O(m). Unión por tamaño Una observación que podemos hacer es que este inconveniente aparece porque vamos uniendo la larga cadena de nodos de forma que siempre se añade la cadena como hija de un nuevo nodo, nunca se crean ramificaciones. Si en lugar de hacer unionSet(1, 2), unionSet(2, 3), unionSet(3, 4) hiciéramos unionSet(2, 1), unionSet(3, 2), unionSet(4, 3), tendríamos todo lo contrario: en lugar
  • 93. 93 de una larga cadena, tendríamos el elemento 1 como elemento representativo y todos los demás elementos directamente conectados con él. Los dos árboles representan el mismo conjunto, pero el de la derecha responde más eficientemente. La idea importante es que no estamos obligados a unir los nodos en el orden tal como se nos da en los parámetros de la función: como da igual qué elemento sea el representativo, podemos tratar de hacerlo de la forma más conveniente. Un posible criterio para unir dos conjuntos que evita el problema de las cadenas largas es la unión por tamaño: ponemos como padre el elemento característico del conjunto de mayor tamaño (desempatando arbitrariamente). Se puede intuir que esto evitará que se formen cadenas muy largas, pero se puede establecer una cota formalmente. Así, tenemos que utilizando este criterio para la unión, cada operación tiene un coste en el peor caso de O(logn)Bastante mejor que el O(n) que teníamos sin utilizar esta heurística. Para implementar este criterio, almacenamos en un vector adicional el tamaño del subárbol que hay bajo cada elemento y lo actualizamos después de cada unión. void unionSet(int u, int v) { int ru = findSet(u); int rv = findSet(v); if(size[ru] < size[rv]) { p[ru] = rv; size[rv] += size[ru];
  • 94. 94 } else { p[rv] = ru; size[ru] += size[rv]; } } Compresión de caminos Olvidemos por un momento la mejora anterior y volvamos al problema que teníamos inicialmente: cadenas demasiado largas, cada vez que llamamos a findSet tenemos un coste O(n). Otra idea distinta para mejorar esto es que no tenemos que recorrer toda la cadena de nodos varias veces. Una vez hemos encontrado el elemento representativo de un conjunto, podemos hacer que todos los nodos por los que hemos pasado apunten directamente a ese elemento representativo para que las consultas posteriores sean más rápidas. Así, “comprimimos” los caminos después de recorrerlos. Nótese que con esta mejora el coste de una operación en el peor caso sigue siendo O(n), ya que seguimos pudiendo generar cadenas arbitrariamente largas, aunque después se compriman cuando las recorremos. Sin embargo, el coste amortizado de una operación, es decir, el coste promedio de las operaciones dentro de una secuencia de operaciones, es mucho menor. En el ejemplo de generar una cadena de mm nodos: en cada una de las m−1 uniones se visitan O(1) nodos, así que en total el coste de las uniones es O(m), y después hacer una operación findSet tiene un coste O(m), así
  • 95. 95 que el coste total de las mm operaciones es O(m)+O(m)=O(m) y por tanto el coste promedio por operación es O(1). Es decir, como para construir la cadena se necesitan m−1operaciones muy rápidas, aunque luego hagas una operación lenta (que si la repites deja de ser lenta porque se comprime el camino) el coste promedio de las operaciones sigue siendo bajo. Implementar esto es muy fácil tal como teníamos implementada la función findSet: int findSet(int i) { if(p[i] != i) p[i] = findSet(p[i]); return p[i]; }