Anda di halaman 1dari 128

* Introducción

* ¿Qué es una estructura de datos?


* Tipos Abstractos de Datos
* Cómo leer estos temas.

Introducción

Para procesar información en un computador es necesario hacer una abstracción de los datos que
tomamos del mundo real -abstracción en el sentido de que se ignoran algunas propiedades de los
objetos reales, es decir, se simplifican-. Se hace una selección de los datos más representativos
de la realidad a partir de los cuales pueda trabajar el computador para obtener unos resultados.

Cualquier lenguaje suministra una serie de tipos de datos simples, como son los números enteros,
caracteres, números reales. En realidad suministra un subconjunto de éstos, pues la memoria del
ordenador es finita. Los punteros (si los tiene) son también un tipo de datos. El tamaño de todos
los tipos de datos depende de la máquina y del compilador sobre los que se trabaja.

En principio, conocer la representación interna de estos tipos de datos no es necesaria para


realizar un programa, pero sí puede afectar en algunos casos al rendimiento.

¿Qué es una estructura de datos?

Se trata de un conjunto de variables de un determinado tipo agrupadas y organizadas de alguna


manera para representar un comportamiento. Lo que se pretende con las estructuras de datos es
facilitar un esquema lógico para manipular los datos en función del problema que haya que tratar
y el algoritmo para resolverlo. En algunos casos la dificultad para resolver un problema radica en
escoger la estructura de datos adecuada. Y, en general, la elección del algoritmo y de las
estructuras de datos que manipulará estarán muy relacionadas.

Según su comportamiento durante la ejecución del programa distinguimos estructuras de datos:

- Estáticas: su tamaño en memoria es fijo. Ejemplo: arrays.


- Dinámicas: su tamaño en memoria es variable. Ejemplo: listas enlazadas con punteros, ficheros,
etc.

Las estructuras de datos que trataremos aquí son los arrays, las pilas y las colas, los árboles, y
algunas variantes de estas estructuras. La tabla que se encuentra al comienzo de esta página
agrupa todas las estructuras de datos que emplearán los algoritmos explicados en esta web.

Tipos Abstractos de Datos


Los tipos abstractos de datos (TAD) permiten describir una estructura de datos en función de las
operaciones que pueden efectuar, dejando a un lado su implementación.

Los TAD mezclan estructuras de datos junto a una serie de operaciones de manipulación.
Incluyen una especificación, que es lo que verá el usuario, y una implementación (algoritmos de
operaciones sobre las estructuras de datos y su representación en un lenguaje de programación),
que el usuario no tiene necesariamente que conocer para manipular correctamente los tipos
abstractos de datos.

Se caracterizan por el encapsulamiento. Es como una caja negra que funciona simplemente
conectándole unos cables. Esto permite aumentar la complejidad de los programas pero
manteniendo una claridad suficiente que no desborde a los desarrolladores. Además, en caso de
que algo falle será más fácil determinar si lo que falla es la caja negra o son los cables.

Por último, indicar que un TAD puede definir a otro TAD. Por ejemplo, en próximos apartados
se indicará como construir pilas, colas y árboles a partir de arrays y listas enlazadas. De hecho,
las listas enlazadas también pueden construirse a partir de arrays y viceversa.

Como leer estos temas

Se recomienda tratar en profundidad los temas de estructuras de datos antes de entrar de lleno en
los algoritmos, si bien es muy recomendable al menos leer la introducción a los algoritmos y
algunos de los temas que suelen ser más conocidos, tales como la ordenación y la búsqueda.

En primer lugar es fundamental el conocimiento de la recursividad, inherente a muchas


estructuras de datos y algoritmos. Es este por tanto el primer tema que hay tratar. Posteriormente
conviene estudiar los temas de arrays y listas enlazadas, puesto que son básicos para
implementar el resto de estructuras de datos. Los temas de pilas y colas son fundamentales, y
mucho más sencillas de entender y aplicar que los temas restantes. Como temas avanzados (y no
por ello menos importantes, además de que requieren el conocimiento de los temas anteriores)
figuran los grafos, si bien en la sección de estructuras de datos se estudiarán sólo sus
implementaciones y recorridos, los árboles y los montículos, una implementación especial de
árbol. De manera independiente puede leerse el tema de conjuntos.

Asimismo, suele ocurrir que unos temas entran en el terreno de otros, y por lo tanto serán
habituales las referencias a otros temas, ya sean a estructuras de datos y a algoritmos.

Nota: Además de las estructuras de datos que se estudian aquí existe otra que es el fichero; dicha
estructura no se estudiará en estas páginas. Sólo se utilizarán ficheros en algunos casos -
únicamente de tipo ASCII-, y exclusivamente para facilitar la introducción de los datos que se
requieren para resolver un problema y también para mostrar los datos producidos por el
algoritmo que procesa esos datos.
È È

Se dice que algo es recursivo si se define en función de sí mismo o a sí mismo. También se dice
que nunca se debe incluir la misma palabra en la definición de ésta. El caso es que las
definiciones recursivas aparecen con frecuencia en matemáticas, e incluso en la vida real. Un
ejemplo: basta con apuntar una cámara al monitor que muestra la imagen que muestra esa
cámara. El efecto es verdaderamente curioso, en especial cuando se mueve la cámara alrededor
del monitor.

En matemáticas, tenemos múltiples definiciones recursivas:

- Números naturales:

(1) 1 es número natural.


(2) el siguiente número de un número natural es un número natural

- El factorial: n!, de un número natural (incluido el 0):

(1) si n = 0 entonces: 0! = 1
(2) si n > 0 entonces: n! = n · (n-1)!

Asimismo, puede definirse un programa en términos recursivos, como una serie de pasos
básicos, o Oaso base (también conocido como condición de parada), y un Oaso recursivo, donde
vuelve a llamarse al programa. En un computador, esta serie de pasos recursivos debe ser finita,
terminando con un paso base. Es decir, a cada paso recursivo se reduce el número de pasos que
hay que dar para terminar, llegando un momento en el que no se verifica la condición de paso a
la recursividad. Ni el paso base ni el paso recursivo son necesariamente únicos.

Por otra parte, la recursividad también puede ser indirecta, si tenemos un procedimiento P que
llama a otro Q y éste a su vez llama a P. También en estos casos debe haber una condición de
parada.

Existen ciertas estructuras cuya definición es recursiva, tales como los árboles, y los algoritmos
que utilizan árboles suelen ser en general recursivos.

Un ejemplo de programa recursivo en C, el factorial:

m m m 


m  
 
  !  



Como se observa, en cada llamada recursiva se reduce el valor de n, llegando el caso en el que n
es 0 y no efectúa más llamadas recursivas. Hay que apuntar que el factorial puede obtenerse con
facilidad sin necesidad de emplear funciones recursivas, es más, el uso del programa anterior es
muy ineficiente, pero es un ejemplo muy claro.
A continuación se expone un ejemplo de programa que utiliza recursión indirecta, y nos dice si
un número es par o impar. Al igual que el programa anterior, hay otro método mucho más
sencillo de determinar si un número es par o impar, basta con determinar el resto de la división
entre dos. Por ejemplo: si hacemos par(2) devuelve 1 (cierto). Si hacemos impar(4) devuelve 0
(falso).

m  m m



m  m 


m m m 



m  m 


m  
 
  



m m m 


m  
  
  



En Pascal se hace así (notar el uso de forward):

 m m  


 
 m   
 


 m   
 
m 
m   !  
 m "

 


 m m  
 
m 
m   ! m 
m  "

 

Ejemplo: si hacemos la llamada impar(3) hace las siguientes llamadas:


par(2)
impar(1)
par(0) -> devuelve 1 (cierto)

Por lo tanto 3 es un número impar.


¿Qué pasa si se hace una llamada recursiva que no termina?

Cada llamada recursiva almacena los parámetros que se pasaron al procedimiento, y otras
variables necesarias para el correcto funcionamiento del programa. Por tanto si se produce una
llamada recursiva infinita, esto es, que no termina nunca, llega un momento en el que no quedará
memoria para almacenar más datos, y en ese momento se abortará la ejecución del programa.
Para probar esto se puede intentar hacer esta llamada en el programa factorial definido
anteriormente:

factorial(-1);

Por supuesto no hay que pasar parámetros a una función que estén fuera de su dominio, pues el
factorial está definido solamente para números naturales, pero es un ejemplo claro.

¿Cuándo utilizar la recursión?

Para empezar, algunos lenguajes de programación no admiten el uso de recursividad, como por
ejemplo el ensamblador o el FORTRAN. Es obvio que en ese caso se requerirá una solución no
recursiva (iterativa). Tampoco se debe utilizar cuando la solución iterativa sea clara a simple
vista. Sin embargo, en otros casos, obtener una solución iterativa es mucho más complicado que
una solución recursiva, y es entonces cuando se puede plantear la duda de si merece la pena
transformar la solución recursiva en otra iterativa. Posteriormente se explicará como eliminar la
recursión, y se basa en almacenar en una pila los valores de las variables locales que haya para
un procedimiento en cada llamada recursiva. Esto reduce la claridad del programa. Aún así, hay
que considerar que el compilador transformará la solución recursiva en una iterativa, utilizando
una pila, para cuando compile al código del computador.
Por otra parte, casi todos los algoritmos basados en los esquemas de vuelta atrás y divide y
vencerás son recursivos, pues de alguna manera parece mucho más natural una solución
recursiva.

Aunque parezca mentira, es en general mucho más sencillo escribir un programa recursivo que
su equivalente iterativo. Si el lector no se lo cree, posiblemente se deba a que no domine todavía
la recursividad. Se propondrán diversos ejemplos de programas recursivos de diversa
complejidad para acostumbrarse a la recursión.

Ejercicio

La famosa sucesión de Fibonacci puede definirse en términos de recurrencia de la siguiente


manera:
(1) Fib(1) = 1 ; Fib(0) = 0
(2) Fib(n) = Fib(n-1) + Fib(n-2) si n >= 2

¿Cuantas llamadas recursivas se producen para Fib(6)?. Codificar un programa que calcule
Fib(n) de forma iterativa.
Nota: no utilizar estructuras de datos, puesto que no queremos almacenar los números de
Fibonacci anteriores a n; sí se permiten variables auxiliares.

Ejemplos de programas recursivos

˜   


  
 
   
  
 
   
 




m  m m m 




m  
 
  m  "



La condición de parada se cumple cuando el exponente es cero. Por ejemplo, la evaluación de


potencia(-2, 3) es:
potencia(-2, 3) ->
(-2) · potencia(-2, 2) ->
(-2) · (-2) · potencia(-2, 1) ->
(-2) · (-2) · (-2) · potencia(-2, 0) ->
(-2) · (-2) · (-2) · 1

y a la vuelta de la recursión se tiene:

(-2) · (-2) · ¢  o o (-2) · (-2) · ¢ 


¢ 
< (-2) · ¢ ¢  o o (-2) · ¢ 
¢ 
< ¢  o o ¢ 
¢ 
<  o o 
¢ 

en negrita se ha resaltado la parte de la expresión que se evalúa en cada llamada recursiva.

˜   

     


 
               
 
        

m # m  $%m mm m &




m mm  &"
  $mm %
  $mm %'# mm '&


———

m  $(%  ) "*
m & (
m  +,- +#  &



Notar que la condición de parada se cumple cuando se llega al final del array. Otra alternativa es
recorrer el array desde el final hasta el principio (de 
 a 
):

m # m  $%m mm




m mm  
  $mm %
  $mm %'# mm "


———

m  $(%  ) "*
m & (
m  +,- +# &"




˜   

     


 
  
         
      
       
  
     
      

  ˜ 
   !

"   
 !#

m # m  $%m mm




m $mm % "
  
  $mm %'# mm '


———

m  $(%  )."*"
m  +,- +# 



La razón por la que se incluye este ejemplo se debe a que en general no se conocerá el número de
elementos de la estructura de datos sobre la que se trabaja. En ese caso se introduce un centinela
-como la constante -1 de este ejemplo o la constante NULO para punteros, u otros valores como
el mayor o menor entero que la máquina pueda representar- para indicar el fin de la estructura.

˜   

     


 
               
 
     


m # m  $%m mm




m /
m mm  

  $mm %
 
/ # mm "

m $mm %0/

  $mm %

 /


———
m  $(%  )."*"
m & (
m  +,- +# .



˜ $
   ! " !%   

  
 
 $  &    
 
!          

 &  "    $ ' !%
$  ()*+,-.˜*/
&  (.˜*/ ˜  0 &  (,./ ˜   0 &  (*)/ ˜   

Para resolverlo, se parte del primer elemento de A y se compara a partir de ahí con todos los
elementos de B hasta llegar al final de B o encontrar una diferencia.
A = {2,3,4,5}, B = {3,4}
--
2,3,4,5
3,4
^

En el caso de encontrar una diferencia se desplaza al segundo elemento de A y así sucesivamente


hasta demostrar que B es igual a un subarray de A o que B tiene una longitud mayor que el
subarray de A.
3,4,5
3,4

Visto de forma gráfica consiste en deslizar B a lo largo de A y comprobar que en alguna posición
B se suporpone sobre A.
Se han escrito dos funciones para resolverlo,   y 1

. La primera devuelve cierto


si el subarray A y el array B son iguales; tiene dos condiciones de parada: o que se haya
recorrido B completo o que no coincidan dos elementos. La segunda función es la principal, y su
cometido es ir 'deslizando' B a lo largo de A, y en cada paso recursivo llamar una vez a la
función  ; tiene dos condiciones de parada: que el array B sea mayor que el subarray A
o que B esté contenido en un subarray A.

m   m m 1$%m $%m m m 




m  
 
m 1$'% $%

   m 1'


  


m 2 # m 1$%m $%m  m m 


m '0

  
m   m 1 


 

 2 # 1 '


———
m 1$.%  )*.(
m $*%  *.(
m 2 # 1.(

m  +-   m 1+



m  +-    m 1+


Hay que observar que el requisito n >= m indicando en el enunciado es innecesario, si m > n
entonces devolverá falso nada más entrar en la ejecución de 1

.

Este algoritmo permite hacer búsquedas de palabras en textos. Sin embargo existen algoritmos
mejores como el de Knuth-Morris-Prat, el de Rabin-Karp o mediante autómatas finitos; estos
algoritmos som más complicados pero mucho más efectivos.

˜   

     


 
               
 
     
    

 !
      
      

       
   ! !

2


m# m  $%m mm m 




m mm  

  $mm %
 
# mm "

m $mm %0

  $mm %


———
m  $(%  )."*"
m 3
# ("43

m  +,- +3


Hay que tener cuidado con dos errores muy comunes: el primero es declarar la variable para que
se pase por valor y no por referencia, con lo cual no se obtiene nada. El otro error consiste en
llamar a la función pasando en lugar del parámetro por referencia una constante, por ejemplo:
mayor(numeros, 5-1, 0); en este caso además se producirá un error de compilación.
˜ 3 2#  $4
      
  
    2     
%

Ackermann(0, n) = n + 1
Ackermann(m, 0) = A(m-1, 1)
Ackermann(m, n) = A(m-1, A(m, n-1))

Aunque parezca mentira, siempre se llega al caso base y la función termina. Probar a ejecutar
esta función con diversos valores de n y m... ¡que no sean muy grandes!. En Internet pueden
encontrarse algunas cosas curiosas sobre esta función y sus aplicaciones.

Ejercicios propuestos

Nota: para resolver los ejercicios basta con hacer un único recorrido sobre el array. Tampoco
debe utilizarse ningún array auxiliar, pero si se podrán utilizar variables de tipo entero o
booleano.

˜   

     


 
               


 2#              
    
      



˜   

     


 
               


 2#     
      !

     
   


             
   



˜   

 $  &      


!              5

    
!  

        &  "    $
6 

       " 
     
   

 



 
 



Conclusiones

En esta sección se ha pretendido mostrar que la recursividad es una herramienta potente para
resolver múltiples problemas. Es más, todo programa iterativo puede realizarse empleando
expresiones recursivas y viceversa.

 
ÈÈ 

Cuestiones generales

Un array es un tipo de estructura de datos que consta de un número fijo de elementos del mismo
tipo. En una máquina, dichos elementos se almacenan en posiciones contiguas de memoria. Estos
elementos pueden ser variables o estructuras. Para definirlos se utiliza la expresión:

tipo_de_elemento nombre_del_array[número_de_elementos_del_array];
int mapa[100];

Cada uno de los elementos de los que consta el array tiene asignado un número (índice). El
primer elemento tiene índice 0 y el último tiene índice número_de_elementos_del_array-1. Para
acceder a ese elemento se pone el nombre del array con el índice entre corchetes:

nombre_del_array[índice]
mapa[5]

Los elementos no tienen por qué estar determinados por un solo índice, pueden estarlo por dos
(por ejemplo, fila y columna), por tres (p.e. las tres coordenadas en el espacio), o incluso por
más. A estos arrays definidos por más de un índice se le llaman arrays multidimensionales o
matrices, y se definen:

tipo_de_elemento nombre_del_array[número1] [número2]... [númeroN];


int mapa[100][50][399];

Y para acceder a un elemento de índices i1,i2,...,iN, la expresión es similar:

nombre_del_array[i1][i2]...[iN]
mapa[34][2][0]

Hay que tener cuidado con no utilizar un índice fuera de los límites, porque dará resultados
inesperados (tales como cambio del valor de otras variables o finalización del programa, con
error "invalid memory reference").

Manejo de arrays

Para manejar un array, siempre hay que manejar por separado sus elementos, esto es, NO se
pueden utilizar operaciones tales como dar valores a un array (esperando que todos los elementos
tomen ese valor). También hay que tener en cuenta de que al definir un array, al igual que con
una variable normal, los elementos del array no están inicializados. Para ello, hay que dar valores
uno a uno a todos los elementos. Para arrays unidimensionales, se utiliza un for:
for(i=0;i<N;i++)
array[i]=0;

y para arrays multidimensionales se haría así:

for(i1=0;i1<N1;i1++)
for(i2=0;i2<N2;i2++)
...
for(iN=0;iN<NN;iN++)
array1[i1][i2]...[iN]=0;

Hay una función que, en determinadas ocasiones, es bastante útil para agilizar esta inicialización,
pero que puede ser peligrosa si se usa sin cuidado: memset (incluida en string.h). Lo que hace
esta función es dar, byte a byte, un valor determinado a todos los elementos:

memset(array,num,tamaño);

donde array es el nombre del array (o la dirección del primer elemento), num es el valor con el
que se quiere inicializar los elementos y tamaño es el tamaño del array, definido como el número
de elementos por el tamaño en bytes de cada elemento. Si el tamaño de cada elemento del array
es 1 byte, no hay problema, pero si son más, la función da el valor num a cada byte de cada
elemento, con lo que la salida de un programa del tipo:

#include<stdio.h>
#include<string.h>

void main()
{
short mapa[10];
memset(mapa,1,10*sizeof(short));
printf("%d",mapa[0]);
}

no es 1 (0000000000000001 en base 2), como cabría esperar, sino 257 (0000000100000001 en


base 2).

También hay otra función que facilita el proceso de copiar un array en otro: memcpy (incluido
también en string.h). Esta función copia byte a byte un array en otro.

Arrays dinámicos

Si al iniciar un programa no se sabe el número de elementos del que va a constar el array, o no se


quiere poner un límite predetermiado, lo que hay que hacer es definir el array dinámicamente.
Para hacer esto, primero se define un puntero, que señalará la dirección de memoria del primer
elemento del array:

tipo_de_elemento *nombre_de_array;

y luego se utiliza la función malloc (contenida en stdlib.h) para reservar memoria:

nombre_de_array=(tipo_de_elemento *)malloc(tamaño);

donde tamaño es el número de elementos del array por el tamaño en bytes de cada elemento. La
función malloc devuelve un puntero void, que indica la posición del primer elemento. Antes de
asignarlo a nuestro puntero, hay que convertir el puntero que devuelve el malloc al tipo de
nuestro puntero (ya que no se pueden igualar punteros de distintos tipos).

Para arrays bidimensionales, hay que hacerlo dimensión a dimensión; primero se define un
puntero de punteros:

int **mapa;

Luego se reserva memoria para los punteros:

mapa=(int **)malloc(sizeof(int *)*N1);

y, por último, para cada puntero se reserva memoria para los elementos:

for(i1=0;i1<N1;i1++)
mapa[i1]=(int *)malloc(sizeof(int)*N2);

Ya se puede utilizar el array normalmente. Para arrays de más de dos dimensiones, se hace de
forma similar.

Temas relacionados

- Algoritmos de ordenación

- Algoritmos de búsqueda

 
ð


* Definición
* Operaciones básicas sobre listas
* Listas ordenadas
* Listas reorganizables
* Cabecera ficticia y centinela
* Listas doblemente enlazadas
* Listas circulares
* Algoritmos de ordenación de listas
* Conclusión y problemas

Definición

Una lista es una estructura de datos secuencial.

Una manera de clasificarlas es por la forma de acceder al siguiente elemento:

- lista densa: la propia estructura determina cuál es el siguiente elemento de la lista. Ejemplo: un
array.
- lista enlazada: la posición del siguiente elemento de la estructura la determina el elemento
actual. Es necesario almacenar al menos la posición de memoria del primer elemento. Además es
dinámica, es decir, su tamaño cambia durante la ejecución del programa.

Una lista enlazada se puede definir recursivamente de la siguiente manera:

- una lista enlazada es una estructura vacía o


- un elemento de información y un enlace hacia una lista (un nodo).

Gráficamente se suele representar así:

Como se ha dicho anteriormente, pueden cambiar de tamaño, pero su ventaja fundamental es que
son flexibles a la hora de reorganizar sus elementos; a cambio se ha de pagar una mayor lentitud
a la hora de acceder a cualquier elemento.

En la lista de la figura anterior se puede observar que hay dos elementos de información, x e y.
Supongamos que queremos añadir un nuevo nodo, con la información p, al comienzo de la lista.
Para hacerlo basta con crear ese nodo, introducir la información p, y hacer un enlace hacia el
siguiente nodo, que en este caso contiene la información x.
¿Qué ocurre si quisiéramos hacer lo mismo sobre un array?. En ese caso sería necesario
desplazar todos los elementos de información "hacia la derecha", para poder introducir el nuevo
elemento, una operación muy engorrosa.


 
 


Para representar en lenguaje C esta estructura de datos se utilizarán punteros, un tipo de datos
que suministra el lenguaje. Se representará una lista vacía con la constante NULL. Se puede
definir la lista enlazada de la siguiente manera:

m

m 
mm


Como se puede observar, en este caso el elemento de información es simplemente un número


entero. Además se trata de una definición autorreferencial. Pueden hacerse definiciones más
complejas. Ejemplo:



!  $) %
m 


m


m 
mm


Cuando se crea una lista debe estar vacía. Por tanto para crearla se hace lo siguiente:

m5
5 &655

Operaciones básicas sobre listas

- Inserción al comienzo de una lista:

Es necesario utilizar una variable auxiliar, que se utiliza para crear el nuevo nodo mediante la
reserva de memoria y asignación de la clave. Posteriormente es necesario reorganizar los
enlaces, es decir, el nuevo nodo debe apuntar al que era el primer elemento de la lista y a su vez
debe pasar a ser el primer elemento.
En el siguiente ejemplo se muestra un programa que crea una lista con cuatro números. Notar
que al introducir al comienzo de la lista, los elementos quedan ordenados en sentido inverso al de
su llegada. Notar también que se ha utilizado un puntero auxiliar O para mantener correctamente
los enlaces dentro de la lista.

Üm 7m —!0

m

m 
mm


m m m


m5
m
m m
5 &6558 mm

 m .m0 m""

 
9m  
  m
 m: m


"0 m m m 

"0m 5 m:
5  

  


- Recorrido de una lista.

La idea es ir avanzando desde el primer elemento hasta encontrar la lista vacía. Antes de acceder
a la estructura lista es fundamental saber si esa estructura existe, es decir, que no está vacía. En el
caso de estarlo o de no estar inicializada es posible que el programa falle y sea difícil detectar
donde, y en algunos casos puede abortarse inmediatamente la ejecución del programa, lo cual
suele ser de gran ayuda para la depuración.
Como se ha dicho antes, la lista enlazada es una estructura recursiva, y una posibilidad para su
recorrido es hacerlo de forma recursiva. A continuación se expone el código de un programa que
muestra el valor de la clave y almacena la suma de todos los valores en una variable pasada por
referencia (un puntero a entero). Por el hecho de ser un proceso recursivo se utiliza un
procedimiento para hacer el recorrido. Nótese como antes de hacer una operación sobre el
elemento se comprueba si existe.

m m m


m5
m
m 
5 &655
m
———

  
 54

  


m m5m 


m 5; &655
 
m  +,+5"0

 '5"0
 5"0m




Sin embargo, a la hora de hacer un programa, es más eficaz si el recorrido se hace de forma
iterativa. En este caso se necesita una variable auxiliar que se desplace sobre la lista para no
perder la referencia al primer elemento. Se expone un programa que hace la misma operación
que el anterior, pero sin recursión.

m m m


m5
m
m 
5 &655
m
———
 5
  
!m ; &655
 
m  +,+"0

 '"0
 "0m

  


A menudo resulta un poco difícil de entender la instrucción p = p->sig; Simplemente cambia la


dirección actual del puntero p por la dirección del siguiente enlace. También es común encontrar
instrucciones del estilo: p = p->sig->sig; Esto puede traducirse en dos instrucciones, de la
siguiente manera:
p = p->sig;
p = p->sig;
Obviamente sólo debe usarse cuando se sepa que p->sig es una estructura no vacía, puesto que si
fuera vacía, al hacer otra vez p = p->sig se produciría una referencia a memoria no válida.

¿Y si queremos insertar en una posición arbitraria de la lista o queremos borrar un elemento?


Como se trata de operaciones algo más complicadas (tampoco mucho) se expone su desarrollo y
sus variantes en los siguientes tipos de listas: las listas ordenadas y las listas reorganizables.
Asimismo se estudiarán después las listas que incorporan cabecera y centinela. También se
estudiarán las listas con doble enlace. Todas las implementaciones se harán de forma iterativa, y
se deja propuesta por ser más sencilla su implementación recursiva, aunque es recomendable
utilizar la versión iterativa.

Listas ordenadas

Las listas ordenadas son aquellas en las que la posición de cada elemento depende de su
contenido. Por ejemplo, podemos tener una lista enlazada que contenga el nombre y apellidos de
un alumno y queremos que los elementos -los alumnos- estén en la lista en orden alfabético.

La creación de una lista ordenada es igual que antes:

m5
5 &655

Cuando haya que insertar un nuevo elemento en la lista ordenada hay que hacerlo en el lugar que
le corresponda, y esto depende del orden y de la clave escogidos. Este proceso se realiza en tres
pasos:
1.- Localizar el lugar correspondiente al elemento a insertar. Se utilizan dos punteros: 

y
 , que garanticen la correcta posición de cada enlace.
2.- Reservar memoria para él (puede hacerse como primer paso). Se usa un puntero auxiliar
( ) para reservar memoria.
3.- Enlazarlo. Esta es la parte más complicada, porque hay que considerar la diferencia de
insertar al principio, no importa si la lista está vacía, o insertar en otra posición. Se utilizan los
tres punteros antes definidos para actualizar los enlaces.

A continuación se expone un programa que realiza la inserción de un elemento en una lista


ordenada. Suponemos claves de tipo entero ordenadas ascendentemente.

Üm 7m—!0
Üm 7m —!0

m

m 
mm


m
mm  m5m 


m m m


m5
5 &6555mm

 m m ! * 
m  45

m  45

m  45"

  

mm  m5m 


m m 

—" mm 
 m  5
!m ; &65544"07
 
 m 
 "0m


)—" 
   m
 m: m


 "0 

*—"2 :
m  m &655<< m 
 m m mm
 "0m  m
5  m m m mmm: 


 m  mm 
 "0m 
 m"0m  



Se puede apreciar que se pasa la lista L con el parámetro **L . La razón para hacer esto es que
cuando se inserta al comienzo de la lista (porque está vacía o es donde corresponde) se cambia la
cabecera.

Un ejemplo de prueba: suponer que se tiene esta lista enlazada: 1 -> 3 -> 5 -> NULL
Queremos insertar un 4. Al hacer la búsqueda el puntero   apunta al 5. El puntero 


apunta al 3. Y   contiene el valor 4. Como no se inserta al principio se hace que el enlace


siguiente a   sea  , es decir, el 5, y el enlace siguiente a 

será  , es decir, el
4.
La mejor manera de entender el funcionamiento es haciendo una serie de seguimientos a mano o
con la ayuda del depurador.

A continuación se explica el borrado de un elemento. El procedimiento consiste en localizarlo y


borrarlo si existe. Aquí también se distingue el caso de borrar al principio o borrar en cualquier
otra posición. Se puede observar que el algoritmo no tiene ningún problema si el elemento no
existe o la lista está vacía.

m  m5m 




m m
—" mm —=mm> m m ? 7

 m  5
!m ; &65544"07
 
 m 
 "0m


)—"5 m/m
m ; &65544"0 
 
m  m 
 m
5 "0m m  5
"0m
  mm
 m"0m "0m
 




Ejemplo: para borrar la clave '1' se indica así: borrar(&L, 1);

Listas reorganizables

Las listas reorganizables son aquellas en las que cada vez que se accede a un elemento éste se
coloca al comienzo de la lista. Si el elemento al que se accede no está en la lista entonces se
añade al comienzo de la misma. Cuando se trata de borrar un elemento se procede de la misma
manera que en la operación de borrado de la lista ordenada. Notar que el orden en una lista
reorganizable depende del acceso a un elemento, y no de los valores de las claves.

No se va a desarrollar el procedimiento de inserción / acceso en una lista, se deja como ejercicio.


De todas formas es sencillo. Primero se busca ese elemento, si existe se pone al comienzo de la
lista, con cuidado de no perder los enlaces entre el elemento anterior y el siguiente. Y si no existe
pues se añade al principio y ya está. Por último se actualiza la cabecera.

Cabecera ficticia y centinela

Como se ha observado anteriormente, a la hora de insertar o actualizar elementos en una lista


ordenada o reorganizable es fundamental actualizar el primer elemento de la lista cuando sea
necesario. Esto lleva un coste de tiempo, aunque sea pequeño salvo en el caso de numerosas
inserciones y borrados. Para subsanar este problema se utiliza la cabecera ficticia.

La cabecera ficticia añade un elemento (sin clave, por eso es ficticia) a la estructura delante del
primer elemento. Evitará el caso especial de insertar delante del primer elemento. Gráficamente
se puede ver así:
Se declara una lista vacía con cabecera, reservando memoria para la cabecera, de la siguiente
manera:

m 
m 
mm

———
m5
5  m
 m: m


5"0m &655

Antes de implementar el proceso de inserción en una lista con cabecera, se explicará el uso del
centinela, y se realizarán los procedimientos de inserción y borrado aprovechando ambas ideas.

El centinela es un elemento que se añade al final de la estructura, y sirve para acotar los
elementos de información que forman la lista. Pero tiene otra utilidad: el lector habrá observado
que a la hora de buscar un elemento de información, ya sea en la inserción o en el borrado, es
importante no dar un paso en falso, y por eso se comprueba que no se está en una posición de
información vacía. Pues bien, el centinela evita ese problema, al tiempo que acelera la búsqueda.

A la hora de la búsqueda primero se copia la clave que buscamos en el centinela, y a


continuación se hace una búsqueda por toda la lista hasta encontrar el elemento que se busca.
Dicho elemento se encontrará en cualquier posición de la lista, o bien en el centinela en el caso
de que no estuviera en la lista. Como se sabe que el elemento está en algún lugar de la lista
(aunque sea en el centinela) no hay necesidad de comprobar si estamos en una posición vacía.
Cuando la lista está vacía la cabecera apunta al centinela. El centinela siempre se apunta a si
mismo. Esto se hace así por convenio.
Gráficamente se puede representar así:

A continuación se realiza una implementación de lista enlazada ordenada, que incluye a la vez
cabecera y centinela.

m

m 
mm

m  # m 
m

m 
 m 


Procedimiento de inicialización (nótese el *LCC):

m588 m588


588"0   m
 m: m


588"0 m   m
 m: m


588"0 "0m 588"0 m 
588"0 m "0m 588"0 m m   m


Procedimiento de inserción:

mm 588 m588m 




m m 

—" 
 m 588— 
 588— "0m
588— m "0 
!m "07
 
 m 
 "0m


)—"
   m
 m: m


 "0 
*—" :
 "0m 
 m"0m  


Procedimiento de borrado:

m 588 m588m 




m m
—" 
 m 588— 
 588— "0m
588— m "0 
!m "07
 
 m 
 "0m

)—" m/m
m ; 588— m 44"0 
 
 m"0m "0m
 




Ejemplo de uso:

Üm 7m—!0
Üm 7m —!0

m

m 
mm

m

m 
 m 

m588 m588

mm 588 m588m 

m 588 m588m 


m m m


m588
588 4588

m 588 588*

 588 588*

  


La realización de la lista reorganizable aprovechando la cabecera y el centinela se deja propuesta


como ejercicio.

Listas doblemente enlazadas

Son listas que tienen un enlace con el elemento siguiente y con el anterior. Una ventaja que
tienen es que pueden recorrerse en ambos sentidos, ya sea para efectuar una operación con cada
elemento o para insertar/actualizar y borrar. La otra ventaja es que las búsquedas son algo más
rápidas puesto que no hace falta hacer referencia al elemento anterior. Su inconveniente es que
ocupan más memoria por nodo que una lista simple.
Se realizará una implementación de lista ordenada con doble enlace que aproveche el uso de la
cabecera y el centinela. A continuación se muestra un gráfico que muestra una lista doblemente
enlazada con cabecera y centinela, para lo que se utiliza un único nodo que haga las veces de
cabecera y centinela.

- Declaración:

m@=

m 
m@= 
m


- Procedimiento de creación:

m@= m@=5@=


5@=  m@=
 m: m@=


 5@=
"0m  5@=
"0  5@=


- Procedimiento de inserción:

mm @= m@=5@=m 




m@= 

 
 5@="0m
5@="0 

!m "07

 "0m


   m@=
 m: m@=


 "0 

 :
"0 "0m  
 "0  "0 
 "0m 
"0   


- Procedimiento de borrado:

m @= m@=5@=m 




m@=

 
 5@="0m
5@="0 

!m "07

 "0m

 
m ; 5@=44"0 
 
"0m"0  "0 
"0 "0m "0m
 




Para probarlo se pueden usar las siguientes instrucciones:

m@=5@=
———
@= 45@=

m @= 5@=

@= 5@=


Listas circulares

Las listas circulares son aquellas en las que el último elemento tiene un enlace con el primero. Su
uso suele estar relacionado con las colas, y por tanto su desarrollo se realizará en el tema de
colas. Por supuesto, se invita al lector a desarrollarlo por su cuenta.

Algoritmos de ordenación de listas


c Un algoritmo muy sencillo:

Se dispone de una lista enlazada de cualquier tipo cuyos elementos son todos comparables entre
sí, es decir, que se puede establecer un orden, como por ejemplo números enteros. Basta con
crear una lista de tipo ordenada e ir insertando en ella los elementos que se quieren ordenar al
tiempo que se van borrando de la lista original sus elementos. De esta manera se obtiene una lista
ordenada con todos los elementos de la lista original. Este algoritmo se llama Inserción Directa;
ver Algoritmos de Ordenación. La complejidad para ordenar una lista de  elementos es:
cuadrática en el peor caso ( 7 ) -que se da cuando la lista inicial ya está ordenada- y lineal en el
mejor () -que se da cuanda la lista inicial está ordenada de forma inversa.
Para hacer algo más rápido el algoritmo se puede implementar modificando los enlaces entre los
elementos de la lista en lugar de aplicar la idea propuesta anteriormente, que requiere crear una
nueva lista y borrar la lista no ordenada.

El algoritmo anterior es muy rápido y sencillo de implementar, pues ya están creadas las
estructuras de listas ordenadas necesarias para su uso. Eso sí, en general es ineficaz y no debe
emplearse para ordenar listas grandes. Para ello se emplea la ordenación por fusión de listas.

c Un algoritmo muy eficiente: ordenación por fusión o intercalación (ver Ordenación por
fusión).

Problemas OroOuestos:

- 3 
 # !
2# 

: consiste en desarrollar un algoritmo para fusionar dos
listas pero que no sea recursivo. No se trata de desarrollar una implementación iterativa del
programa anterior, sino de realizar una ordenación por fusión    . Se explica mediante
un ejemplo:
3 -> 2 -> 1 -> 6 -> 9 -> 0 -> 7 -> 4 -> 3 -> 8
se fusiona el primer elemento con el segundo, el tercero con el cuarto, etcétera:
[(3) -> (2)] -> [(1) -> (6)] -> [(9) -> (0)] -> [(7) -> (4)] -> [(3) -> (8)]
queda:
2 -> 3 -> 1 -> 6 -> 0 -> 9 -> 4 -> 7 -> 3 -> 8
se fusionan los dos primeros (primera sublista) con los dos siguientes (segunda sublista), la
tercera y cuarta sublista, etcétera. Observar que la quinta sublista se fusiona con una lista vacía,
lo cual no supone ningún inconveniente para el algoritmo de fusión.
[(2 -> 3) -> (1 -> 6)] -> [(0 -> 9) -> (4 -> 7)] -> [(3 -> 8)]
queda:
1 -> 2 -> 3 -> 6 -> 0 -> 4 -> 7 -> 9 -> 3 -> 8
se fusionan los cuatro primeros con los cuatro siguientes, y aparte quedan los dos últimos:
[(1 -> 2 -> 3 -> 6) -> (0 -> 4 -> 7 -> 9)] -> [(3 -> 8)]
queda:
0 -> 1 -> 2 -> 3 -> 4 -> 6 -> 7 -> 9 -> 3 -> 8
se fusionan los ocho primeros con los dos últimos, y el resultado final es una lista totalmente
ordenada:
0 -> 1 -> 2 -> 3 -> 3 -> 4 -> 6 -> 7 -> 8 -> 9

Para una lista de N elementos, ordena en el mejor y en el peor caso en un tiempo proporcional a:
N·logN. Observar que para ordenar una lista de 2 elementos requiere un paso de ordenación, una
lista de 4 elementos requiere dos pasos de ordenación, una lista de 8 elementos requiere tres
pasos de ordenación, una lista de 16 requiere cuatro pasos, etcétera. Es decir:
log 2 = 1
log 4 = 2
log 8 = 3
log 16 = 4
log 32 = 5
De ahí el logaritmo en base 2.
N aparece porque en cada paso se requiere recorrer toda la lista, luego el tiempo es proporcional
a N·logN.

e Oide: codificar el algoritmo de ordenación por fusión ascendente.

Conclusión

Las listas enlazadas son muy versátiles. Además, pueden definirse estructuras más complejas a
partir de las listas, como por ejemplo arrays de listas, etc. En algunas ocasiones los grafos se
definen como listas de adyacencia (ver sección de grafos). También se utilizan para las tablas de
hash (dispersión) como arrays de listas.

Son eficaces igualmente para diseñar colas de prioridad, pilas y colas sin prioridad, y en general
cualquier estructura cuyo acceso a sus elementos se realice de manera secuencial.

Problemas

Biblioteca. OIE 99. (Enunciado) (Solución)

  

En una biblioteca infantil se ha diseñado un sistema para determinar cada día en qué orden
elegirán los niños el libro que desean leer. Para ello los bibliotecarios han decidido seguir la
siguiente estrategia: cada día se formará un círculo con todos los niños. Uno de los niños es
elegido al azar como el número 1 y el resto son numerados en orden creciente hasta N siguiendo
el sentido de las agujas del reloj.

Comenzando desde 1 y moviéndose en sentido horario uno de los bibliotecarios cuenta k niños
mientras el otro bibliotecario comienza en N y se mueve en sentido antihorario contando m
niños. Los dos niños en los que se paren los bibliotecarios son elegidos; si los dos bibliotecarios
coinciden en el mismo niño, ese será el único elegido.
Cada uno de los bibliotecarios comienza a contar de nuevo en el siguiente niño que permanezca
en el círculo y el proceso continua hasta que no queda nadie.

Tened en cuenta que los dos niños abandonan simultáneamente el círculo, luego es posible que
uno de los bibliotecarios cuente un niño ya seleccionado por el otro.

Se pide la construcción de un programa que, dado el número N de niños y los números k y m que
utilizará cada bibliotecario en la selección, indique en qué orden irán siendo seleccionados los
niños.

Entrada

Residente en el fichero de caracteres "BIBL.DAT": En cada línea aparecerán tres números (N, k
y m, los tres mayores que 0 y menores que 20) separados por un único espacio en blanco y sin
blancos ni al comienzo ni al final de la línea. La última línea del fichero contendrá siempre tres
ceros.

Salida

A guardar en el fichero de caracteres "BIBL.OUT": Para cada línea de datos del fichero de
entrada (excepto, claro está, la última que contiene tres ceros), se generará una línea de números
que especifique el orden en que serían seleccionados los niños para esos valores de N, k y m.
Cada número de la línea ocupará tres caracteres en el fichero (no llevarán ceros a la izquierda y
serán completados con blancos por ese lado hasta alcanzar ese tamaño). Cuando dos niños son
seleccionados simultáneamente, en el fichero aparecerá primero el elegido por el bibliotecario
que cuenta en sentido horario. Los grupos de elegidos (de uno o dos niños cada uno) vendrán
separados entre sí por comas (no debe ponerse una coma después del último grupo).

Ejemplo de entrada

10 4 3
528
13 2 2
000

Ejemplo de salida
4 8, 9 5, 3 1, 2 6, 10, 7
2 3, 5, 4 1
2 12, 4 10, 6 8, 9 5, 13 1, 7, 3 11

 ð 

La solución que aquí se presenta utiliza una lista enlazada con doble enlace y circular. La razón
por la que se ha escogido esta estructura de datos es porque es idónea para realizar la selección
de los niños de una manera muy natural. Así, la utilización de un doble enlace implica que cada
bibliotecario puede avanzar en un sentido inverso al del otro, y además mantener la lista circular
simula perfectamente un círculo de niños. Puesto que los niños se eliminan del círculo una vez
escogidos un array no es lo más adecuado para efectuar los recorridos.

Respecto a la implementación: la única dificultad estriba en crear y mantener los enlaces de la


lista. El algoritmo que realiza la selección termina cuando ya no quedan más niños en el círculo
(variable i). En ningún momento es necesario comprobar si la lista está vacía o no.

Este gráfico ilustra una estructura en la que aparecen dos niños, y los correspondientes
bibliotecarios que hacen recorrido horario (hor) y antihorario (ant):

Temas relacionados

Listas enlazadas

Código

Código fuente en C

 

ð

* Definición
* Implementación mediante array
* Implementación mediante lista enlazada
* Otras consideraciones

Definición

Una pila es una estructura de datos de acceso restrictivo a sus elementos. Se puede entender
como una pila de libros que se amontonan de abajo hacia arriba. En principio no hay libros;
después ponemos uno, y otro encima de éste, y así sucesivamente. Posteriormente los solemos
retirar empezando desde la cima de la pila de libros, es decir, desde el último que pusimos, y
terminaríamos por retirar el primero que pusimos, posiblemente ya cubierto de polvo.

En los programas estas estructuras suelen ser fundamentales. La recursividad se simula en un


computador con la ayuda de una pila. Asimismo muchos algoritmos emplean las pilas como
estructura de datos fundamental, por ejemplo para mantener una lista de tareas pendientes que se
van acumulando.

Las pilas ofrecen dos operaciones fundamentales, que son apilar y desapilar sobre la cima. El uso
que se les de a las pilas es independiente de su implementación interna. Es decir, se hace un
encapsulamiento. Por eso se considera a la pila como un tipo abstracto de datos.

Es una estructra de tipo LIFO (Last In First Out), es decir, último en entrar, primero en salir.

A continuación se expone la implementación de pilas mediante arrays y mediante listas


enlazadas. En ambos casos se cubren cuatro operaciones básicas: Inicializar, Apilar, Desapilar, y
Vacía (nos indica si la pila está vacía). Las claves que contendrán serán simplemente números
enteros, aunque esto puede cambiarse a voluntad y no supone ningún inconveniente.

Implementación mediante array

Esta implementación es estática, es decir, da un tamaño máximo fijo a la pila, y si se sobrepasa


dicho límite se produce un error. La comprobación de apilado en una pila llena o desapilado en
una pila vacía no se han hecho, pero sí las funciones de comprobación, que el lector puede
modificar según las necesidades de su programa.

˜ Declaración:

m

m m
m  $31ABC51%


Nota: MAX_PILA debe ser mayor o igual que 1.

˜ Procedimiento de Creación:

m mm


m"0m "


˜ Función que devuelve verdadero si la Oila está vacía:

m m mm


  m"0m "



˜ Función que devuelve verdadero si la Oila está llena:

m   mm


  m"0m 31ABC51



˜ Procedimiento de aOilado:

mm mmm 




m"0 $''m"0m% 


˜ Procedimiento de desaOilado:

mm mmm 




 m"0 $m"0m""%


Programa de Orueba:

Üm 7m—!0

m m m


mm
m 

 4m

m m 4m

m  +- Cmm—+

m   4m

m  +- Cm —+



m 4m

m 4m4

  


Puesto que son muy sencillos, el usuario puede decidir implementar una pila 'inline', es decir, sin
usar procedimientos ni funciones, lo cual aumentará el rendimiento a costa de una cierta
legibilidad. Es más, los problemas que aparecen resueltos en esta web en general utilizan las
pilas con arrays de forma 'inline'. Además, esta implementación es algo más rápida que con listas
enlazadas, pero tiene un tamaño estático.

En C y en algún otro lenguaje de programación puede modificarse el tamaño de un array si éste


se define como un puntero al que se le reserva una dirección de memoria de forma explícita
(mediante malloc en C). Sin embargo, a la hora de alterar dinámicamente esa región de memoria,
puede ocurrir que no haya una región en la que reubicar el nuevo array (mediante realloc en C)
impidiendo su crecimiento.

Implementación mediante lista enlazada

Para hacer la implementación se utiliza una lista con cabecera ficticia (ver apartado de listas).
Dado el carácter dinámico de esta implementación no existe una función que determine si la pila
está llena. Si el usuario lo desea puede implementar un análisis del código devuelto por la
función de asignación de memoria.

˜ Declaración:

m

m 
mm


˜ Procedimiento de creación:

m mm


m  m
 m: m


 m
"0m &655

˜ Función que devuelve verdadero si la Oila está vacía:

m m mm


  m"0m &655



˜ Procedimiento de aOilado (aOila al comienzo de la lista):

mm mmm 




m 

   m
 m: m


 "0 
 "0m m"0m
m"0m  


˜ Procedimiento de desaOilado (desaOila del comienzo de la lista):

mm mmm 




m/

/ m"0m
 /"0
m"0m /"0m
 /



Programa de Orueba:

m m m


mm
m 

 4m

m m m

m  +- Cmm;+

m m

m m4


  


Ver pilasle.c para tener una implementación completa con lista enlazada.

En este caso, hacerlo 'inline' puede afectar seriamente la legibilidad del programa.
Si el usuario desea hacer un programa a prueba de balas puede probar el siguiente procedimiento
de apilado, que simplemente comprueba si hay memoria para una asignación de memoria:

mm mmm 




m 


m   m
 m: m

 &655

 B

 
 "0 
 "0m m"0m
m"0m  



Es obvio que si se llama al procedimiento generar_error es que el sistema se ha quedado sin


memoria, o al menos se ha agotado la región de memoria que el sistema operativo y/o el
compilador dedican para almacenar los datos que la ejecución del programa crea.

Otras consideraciones

˜ ¿Cuantos elementos hay aOilados?

En algunos casos puede ser interesante implementar una función para contar el número de
elementos que hay sobre la pila. En la implementación con arrays esto es directo. Si se hace
sobre listas enlazadas entonces hay que hacer alguna pequeña modificación sobre la declaración
e implementación:

 

m 
 m

m

m  B m   
 m


Los detalles de la implementación no se incluyen, pues es sencilla.

˜ ¿Cómo vaciar la Oila?


En el caso de la implementación con array es directo, basta con inicializar la cima al valor de
vacío. Si es una lista enlazada hay que ir borrando elemento a elemento (o desapilarlos todos).
Los detalles se dejan para el lector.

Elegir entre implementación con listas o con arrays.

El uso del array es idóneo cuando se conoce de antemano el número máximo de elementos que
van a ser apilados y el compilador admite una región contigua de memoria para el array. En otro
caso sería más recomendable usar la implementación por listas enlazadas, también si el número
de elementos llegase a ser excesivamente grande.

La implementación por array es ligeramente más rápida. En especial, es mucho más rápido a la
hora de eliminar los elementos que hayan quedado en la pila. Por lista enlazada esto no es tan
rápido. Por ejemplo, piénsese en un algoritmo que emplea una pila y que en algunos casos al
terminar éste su ejecución deja algunos elementos sobre la pila. Si se implementa la pila
mediante una lista enlazada entonces quedarían en memoria una serie de elementos que es
necesario borrar. La única manera de borrarlos es liberar todas las posiciones de memoria que le
han sido asignadas a cada elemento, esto es, desapilar todos los elementos. En el caso de una
implementación con array esto no es necesario, salvo que quiera liberarse la región de memoria
ocupada por éste.


COLAS

* Definición
* Implementación mediante array circular
* Implementación mediante lista enlazada
* Otras consideraciones

Definición

Una cola es una estructura de datos de acceso restrictivo a sus elementos. Un ejemplo sencillo es
la cola del cine o del autobús, el primero que llegue será el primero en entrar, y afortunadamente
en un sistema informático no se cuela nadie salvo que el programador lo diga.

Las colas serán de ayuda fundamental para ciertos recorridos de árboles y grafos.

Las colas ofrecen dos operaciones fundamentales, que son encolar (al final de la cola) y
desencolar (del comienzo de la cola). Al igual que con las pilas, la implementación de las colas
suele encapsularse, es decir, basta con conocer las operaciones de manipulación de la cola para
poder usarla, olvidando su implementación interna.

Es una estructra de tipo FIFO (First In First Out), es decir: primero en entrar, primero en salir.

A continuación se expone la implementación de colas, con arrays y con listas enlazadas


circulares. En ambos casos se cubren cuatro operaciones básicas: Inicializar, Encolar,
Desencolar, y Vacía. Las claves que contendrán serán simplemente números enteros.

Implementación mediante array circular

Esta implementación es estática, es decir, da un tamaño máximo fijo a la cola. No se incluye


comprobación de errores dentro del encolado y el desencolado, pero se implementan como
funciones aparte.
¿Por qué un array circular? ¿Qué es eso? Como se aprecia en la implemetación de las pilas, los
elementos se quitan y se ponen sobre la cima, pero en este caso se introducen por un sitio y se
quitan por otro.

Podría hacerse con un array secuencial, como se muestra en las siguientes figuras. 'Entrada' es la
posición de entrada a la cola, y 'Salida' por donde salen.

En esta primera figura se observa que se han introducido tres elementos: 3, 1 y 4 (en ese orden):
se desencola, obteniendo un 3:

se encola un 7:

Enseguida se aprecia que esto tiene un grave defecto, y es que llega un momento en el que se
desborda la capacidad del array. Una solución nada efectiva es incrementar su tamaño. Esta
implementación es sencilla pero totalmente ineficaz.

Como alternativa se usa el array circular. Esta estructura nos permite volver al comienzo del
array cuando se llegue al final, ya sea el índice de entrada o el índice de salida.

Se implementarán dos versiones de arrays circulares, con lo que el programador podrá escoger
entre la que más le guste.

Primera versión:

Esta versión requiere el uso de la operación módulo de la división para determinar la siguiente
posición en el array.

Por ejemplo, supóngase un array de N = 2 elementos, contando desde 0 hasta 1. Suponer que
entrada = 0, salida = 1; Para determinar la posición siguiente del índice  en el array se procede
así:
i <- (i+1) Mod N

siendo Mod la operación resto de la división entera. Asi:


- sustituyendo i por salida se determina que salida = 0.
- sustituyendo i por entrada se determina que entrada = 1.
Nota: si el array está indexado entre 1 y N -como suele ser habitual en Pascal- entonces la
expresión que determina la posición siguiente es esta:
i <- (i Mod N) + 1

si entrada = 1, salida = 2, entonces:


- sustituyendo i por salida se determina que salida = 1.
- sustituyendo i por entrada se determina que entrada = 2.

De esta manera se van dando vueltas sobre el array. La lógica es la siguiente:


Para encolar: se avanza el índice 
 a la siguiente posición, y se encola en la posición que
apunte éste.
Para desencolar: el elemento desencolado es el que apunta el índice , y posteriormente se
avanza  a la siguiente posición.

Cola vacía: la cola está vacía si el elemento siguiente a 


 es , como sucede en el
ejemplo anterior.
Cola llena: la cola está llena si el elemento que sigue al que sigue a 
 es .
Esto obliga a dejar un elemento vacío en el array, puesto que se reserva una posición para separar
los índices 
 y .

Para aclararlo, se muestran una serie de gráficos explicativos, partiendo de un array de tres
elementos, es decir, una cola de DOS elementos.

Cola vacía:

Se encola un 3.

Se desencola el 3; ahora se tiene una cola vacía.


Se encolan el 5 y el 7. Se obtiene una cola llena.

Si se desencola se obtiene el 5. ¡Si en lugar de desencolar se encola un elemento cualquiera se


obtiene una cola vacía!.

˜ Declaración:



m  m
m  $31AB8D51%


Una cola que tenga un elemento requiere que MAX_COLA = 2.

˜ Función que devuelve la Oosición siguiente a en el array circular.

m mm  m m


  m'
,31AB8D51



˜ Creación:

m 


"0m  
"0  31AB8D51"

˜ Función que devuelve verdadero si la cola está vacía, cosa que ocurre cuando el siguiente
tras 
 es :

m m 


  mm  "0 
 "0m



˜ Función que devuelve verdadero si la cola está llena, caso que se da cuando el siguiente
elemento que sigue a 
 es :

m   


  mm  mm  "0 

 "0m



˜ Encolado:

m  m 




"0  mm  "0 

"0 $"0 % 


˜ Desencolado:

m  m 




 "0 $"0m%
"0m mm  "0m



˜ Programa de Orueba:

Üm 7m—!0

Üm 31AB8D51( .E 

m m m



m 

 4

m m 4

m  +- 8m—+

m   4

m  +- 8 —+



  4

  44

  


egunda versión:

En este caso se omite la función siguiente, y se aprovechan todos los elementos. Sin embargo se
contabiliza en una variable el número de elementos que hay en un momento dado en la cola. Esta
implementación es parecida a la secuencial, pero vigilando que los índices no se pasen 


¿Como se determina la siguiente posición? Se avanza una posición, y si llega al límite del array
el índice se actualiza al primer elemento. La lógica es la siguiente:

Para encolar: se encola en la posición indicada por 


, y se avanza una posición.
Para desencolar: el elemento desencolado es el que apunta el índice , y posteriormente se
avanza  a la siguiente posición.

Cola vacía: la cola está vacía si el número de elementos es cero.


Cola llena: la cola está llena si el número de elementos es el máximo admitido.

˜ Declaración:



m 
m  m
m  $31AB8D51%


Una cola que tenga dos elementos requiere que MAX_COLA = 2.

˜ Creación:

m 


"0 "0m "0   


˜ Función que devuelve verdadero si la cola está vacía:

m m 


  "0 


˜ Función que devuelve verdadero si la cola está llena:

m   


  "0 31AB8D51



˜ Encolado:

m  m 




"0''
"0 $"0 ''% 
m "0  31AB8D51

"0   


˜ Desencolado:

m  m 




"0""
 "0 $"0m''%
m "0m 31AB8D51

"0m  


˜ Programa de Orueba:

El mismo de antes sirve.

Implementación mediante lista enlazada

Para hacer la implementación se utilizará una   



  
.
La cola estará inicialmente vacía. Cuando se añadan elementos el puntero que mantiene la cola
apunta al último elemento introducido, y el siguiente elemento al que apunta es al primero que
está esperando para salir.

- ¿Cómo encolar?. Se crea el nuevo elemento, se enlaza con el primero de la cola. Si no está
vacía hay que actualizar el enlace del, hasta el momento de la inserción, último elemento
introducido. Por último se actualiza el comienzo de la cola, esté vacía o no.
- ¿Cómo desencolar?. Si tiene un sólo elemento se borra y se actualiza el puntero a un valor
nulo. Si tiene dos o más elementos entonces se elimina el primero y el último apuntará al
segundo.
Ejemplo gráfico de encolado. Partiendo de una cola que tiene el elemento , se van añadiendo el
y el  (observar de izquierda a derecha). A la hora de desencolar se extrae el siguiente al que
apunta á.

Ejemplo gráfico de desencolado. Partiendo de la cola formada anteriormente, se han quitado los
dos primeros elementos introducidos (observar de izquierda a derecha).

˜ Declaración:



m 
m


˜ Creación:

m 


 &655


˜ Función que devuelve cierto si la cola está vacía:


m m 


   &655



˜ Encolado:

m  m 




 

   
 m: 


 "0 
m  &655

 "0m  
 
 "0m  
"0m
 
"0m  

 
  


˜ Desencolado:

m  m 




/

  
"0m"0
m 
  
"0m
 
 

 &655

 
/  
"0m
 
"0m /"0m
 /




˜ Programa de Orueba:

Üm 7m—!0
Üm 7m —!0

m m m



m 

 4

m m 

m  +- 8m;+

  4*

  44


  


Ver colasle.c para tener una implementación completa con listas enlazadas.

Al igual que en las pilas implementadas por listas enlazadas, es recomendable analizar el código
devuelto por la función de asignación de memoria para evitar posibles problemas en un futuro.

Otras consideraciones

En algunos casos puede ser interesante implementar una función para contar el número de
elementos que hay en la cola. Una manera de hacer esto con listas enlazadas es empleando la
siguiente declaración:

struct nodo
{
int clave;
struct nodo *sig;
};
struct tcola
{
int numero_elems; /* mantiene el numero de elementos */
struct nodo *cola;
};

Los detalles de la implementación no se incluyen, pues es sencilla.

¿Qué implementación es mejor, arrays o listas?

Al igual que con las pilas, la mejor implementación depende de la situación particular. Si se
conocen de antemano el número de elementos entonces lo ideal es una implementación por
array. En otro caso se recomienda el uso de lista enlazada circular.


CONJUNOTS

Introducción
* Representación mediante arrays de bits
* Representación mediante array
* Representación mediante lista enlazada

Introducción

Los conjuntos son una de las estructuras básicas de las matemáticas, y por tanto de la
informática. No se va a entrar en la definición de conjuntos ni en sus propiedades. Se supondrá
que el lector conoce algo de teoría de conjuntos. Con lo más básico es suficiente.

En realidad las estructuras de datos que se han implementado hasta ahora no son más que
elementos diferentes entre sí (en general) en los que se ha definido una relación. Por ejemplo, en
las listas ordenadas o los árboles binarios de búsqueda se tiene una serie de elementos que están
ordenados entre sí. Obviando las propiedades de las estructuras, se ve que forman un conjunto, y
su cardinal es el número de elementos que contenga la estructura. En los conjuntos no existen
elementos repetidos, y esto se respeta en las implementaciones que se ofrecen a continuación.

Ahora bien, en esta sección se van definir unas implementaciones que permitan aplicar el álgebra
de conjuntos, ya sea unión, intersección, pertenencia, etc. Se realizan tres implementaciones:
array de bits, arrays y listas enlazadas.

Representación mediante arrays de bits

Ciertamente, un bit no da más que para representar dos estados diferentes. Por supuesto, pueden
ser atributos muy variados, por ejemplo, ser hombre o mujer, adulto o niño, Windows o Linux,
etc. También sirve para indicar si un elemento está o no dentro de un conjunto.

El array se utiliza para representar un conjunto de números naturales (u otro tipo de datos cuyos
elementos se identifiquen por un número natural único mediante una correspondencia) entre 0 y
N, siendo N la capacidad del array unidimensional (es decir, un vector); almacenará valores
booleanos, esto es, 1 ó 0.

Por ejemplo, suponer el conjunto universal formado por los enteros entre 0 y 4: U = {0, 1, 2, 3,
4}, y el conjunto C = {1, 2}. Se representará de esta manera:

:   
0 1 1 0 0
1 : indica que el elemento pertenece al conjunto.
0 : indica que el elemento no pertenece al conjunto.

Ahora bien, se ha dicho que se va a emplear un array de bits. ¿Qué se quiere decir con esto? Que
no se va a emplear un array o vector como tal, sino un tipo de datos definido por el lenguaje de
programación, que suele ocupar entre 8 y 64 bits, y por tanto podrá incluir hasta 64 elementos en
el conjunto. Por ejemplo, en C o Pascal se define un tipo que ocupa 8 bits:

unsigned char conjunto8;


var conjunto8 : byte;

Si todos los bits de conjunto8 están a 1 entonces se tiene el conjunto: U = {0, 1, 2, 3, 4, 5, 6, 7},
y su cardinal es 8. Si todos los bits están a 0 se tiene el conjunto vacío. El bit más significativo
señalará al elemento de 

, el bit menos significativo al de  

. Ejemplos (bit
más significativo a la izquierda):

11111111 -> U = {0, 1, 2, 3, 4, 5, 6, 7}


11110001 -> U = {0, 4, 5, 6, 7}
01010101 -> U = {0, 2, 4, 6}
00000000 -> U = vacío

La razón para emplear los arrays de bits es que las operaciones sobre los conjuntos se realizan de
manera muy rápida y sencilla, al menos con los computadores actuales, que tienen un tamaño de
palabra múltiplo de 8. Por supuesto, la ocupación en memoria está optimizada al máximo.
El inconveniente es que el rango de representación es muy limitado. Por eso su aplicación es
muy restringida, y depende fuertemente del compilador y el computador sobre el que se
implementan, pero es increíblemente rápida.

Nota: Pascal cuenta con un juego de instrucciones para manipular conjuntos definidos mediante
arrays de bits, dando al usuario transparencia total sobre su manejo.

A continuación se implementa un TAD sobre conjuntos en C mediante array de bits.

˜ TiOo de datos emOleado:

typedef unsigned long tconjunto;

El tipo long suele ocupar 32 bits, por tanto el rango será: [0..31].

Nota imOortante: en los ejemplos se muestran conjuntos que sólo tienen un máximo de 8
elementos (8 bits). Esto está puesto simplemente por aumentar la claridad, y cómo no, por
ahorrar 
.
˜ Definición de conjunto vacío y universal:

const tconjunto Universal = 0xFFFFFFFF;


const tconjunto vacio = 0;

Es decir, 32 bits puestos a 1 para el conjunto universal, 32 bits puestos a 0 para el conjunto vacío.

˜ Unión:

Se realiza mediante la operación de OR inclusivo. Ejemplo (con 8 bits en lugar de 32):

11001100 -> A = {2,3,6,7}


Or 10010100 -> B = {2,4,7}
---------
11011100 -> C = {2,3,4,6,7}

y se codifica así:

 ?  m?  ? 1 ? 



  1<


˜ Intersección:

Se realiza mediante la operación AND. Ejemplo:

11001100 -> A = {2,3,6,7}


And 10010100 -> B = {2,4,7}
---------
10000100 -> C = {2,7}

y se codifica así:

 ? m m  ? 1 ? 



  14


˜ Diferencia:

Para obtener C = A-B se invierten todos los bits de B y se hace un AND entre A y B negado.
Ejemplo:
10011101 -> A = {0,2,3,4,7}
10110010 -> B = {1,4,5,7}
B negado: 01001101 -> B(negado) = {0,2,3,6}

10011101
And 01001101
---------
00001101 -> C = {0,2,3}

y se codifica así:

 ? m m)  ? 1 ? 



  14F


˜ Diferencia simétrica:

C = (A-B) Unión (B-A)


Se realiza mediante la operación de OR exclusivo (XOR) o aplicando las primitivas definidas
anteriormente. Ejemplo:

11110000 -> A = {4,5,6,7}


Xor 00011110 -> B = {1,2,3,4}
---------
11101110 -> C = {1,2,3,5,6,7}

y se codifica así:

 ? mm  ? 1 ? 



  1G


˜ Igualdad de conjuntos:

La implementación es directa, si todos los bits de A y B se corresponden entonces son iguales:

int iguales(tconjunto A, tconjunto B)


{ return (A == B); }

˜ ubconjuntos:
Si un conjunto A es subconjunto (considerando que un conjunto cualquiera es subconjunto de si
mismo) de otro B entonces verifica esta relación: A intersección B = A. Notar que A es
subconjunto de A, pues A intersección A = A.

Ejemplo:
A = {1,2,3,4}, B = {0,1,2,3}
C = A intersección B = {1,2,3}; C es distinto de A.

Se codifica así:

m   ?   ? 1 ? 



  m m m 1
1



˜ Pertenencia:

Determinar si un elemento pertenece a un conjunto requiere efectuar una operación de


desplazamiento a nivel de bits y una posterior comprobación del bit de signo resultante. Como
siempre, un ejemplo o dos lo aclaran:
Sea x = 0 y A = {0,1,2,5}. Determinar si x pertecene a A.
00100111 -> A.
Primero se desplazan los bits de A tantas veces a la derecha como valga x, en el ejemplo no se
desplazan; se obtiene A'. A continuación se aplica el test del bit de signo sobre A', que consiste
en obtener el resto de la división entera entre dos. Si el resto es uno, entonces x pertenece a A.
En caso contrario no pertenece a A. En el ejemplo: A' mod 2 = 1, luego x pertenece a A.

Otro ejemplo: x = 3, A = {0,1,2,5}.


Se desplazan los bits de A tres posiciones a la derecha:
00000100 -> A'.
Se hace el test de signo: A' mod 2 = 0. x no pertenece a A.

La codificación es la siguiente:

m    ? 1m /



  100/
,)


˜ Inserción y borrado:

Para insertar un elemento x es necesario poner a 1 el bit correspondiente. Una manera sencilla de
hacerlo es mediante una suma. Hay que sumar un valor que se corresponda con el bit que se
quiere establecer a 1. Para hacerlo se volverá a aplicar una operación de desplazamiento, pero
esta vez hacia la izquierda y aplicada sobre el número 1. Se desplazan x bits hacia la izquierda,
suponiendo que el compilador llena con ceros por la derecha.
Por ejemplo, partir de A = conjunto vacío: { }.
Se quieren insertar los elementos 0,2,3 sobre A.
Insertar 0:
x = 0. p = 1, (00000001 en binario). Se desplaza p x (0) bits a la izquierda, p' = 1, y se suma a A.
Queda: A <- A + p'. A = 1.

Insertar 2:
x = 2. p = 1. Se desplaza p x (2) bits a la izquierda, p' = 4 (000000100 en binario). A <- A (1) +
p' (4), A = 5 (000000101).

Insertar 3:
x =3. p = 1. Se desplaza p x (3) bits a la izquierda, p' = 8. A <- A (5) + p' (8), A = 13 (00001101).

El borrado es exactamente lo mismo, pero hay que restar p en vez de sumar. Ejemplo: borrar 3
de A.
A <- A (13) - p' (8), A = 5 (00000101)

Antes de la codificación, hay que considerar otro detalle: es necesario comprobar previamente si
el elemento ya está en el conjunto para evitar problemas inesperados. Por tanto, la codificación
queda así para la inserción:

 ? m   ? 1m /




m   1/

 1
  1'  ? 
77/




y para el borrado:

 ?    ? 1m /




m   1/

 1
  1"  ? 
77/




Conclusiones

Sin duda alguna, la gran ventaja de esta implementación es la rapidez de ejecución de todas las
operaciones, que se ejecutan en tiempo constante: O(1). Además los elementos se encuentran
empaquetados ocupando el menor espacio posible, esto es, un único bit.

La desventaja es que no admiten un rango muy amplio de representación. Aun así, para
incrementar el rango basta con crear un array de tipo conjunto, por ejemplo: tconjunto
superconjunto[10], y aplicar las operaciones sobre los bits en todos los elementos del array,
excepto para la inserción y borrado, en cuyo caso hay que encontrar el bit exacto a manipular.
Representación mediante array

Los elementos del conjunto se guardan uno a continuación de otro empleando una lista densa
representada mediante un array.

Ejemplo: Sea el conjunto C = {1, 2}. Se representará de esta manera:

:   
1 2

y su cardinal es 2.

Esta representación no limita el rango de representación más que al tipo de datos empleado. Por
supuesto, ya no puede definirse explícitamente el conjunto universal.

Por razones de eficiencia a la hora de implementar las primitivas, las estructuras se pasan por
referencia. Es un detalle importante, porque C garantiza que un array se pasa siempre por
referencia, pero eso no es cierto si el array se pasa como parte de una estructura.

No se implementan rutinas de control de errores ni su detección. Se produce un error cuando se


tratan de añadir elementos y estos desbordan la capacidad del array.

  !
 : los elementos dentro del array no están ordenados entre sí.

˜ TiOo de datos emOleado:

#m Hm

#

Hm$31A=5=3%
m m 
 ? 

˜ Definición de conjunto vacío:

Un conjunto está vacío si su cardinal es cero. Para inicializar un conjunto a vacío basta con una
instrucción:
A->cardinal = 0

˜ Pertenencia:

Para determinar si un elemento x pertenece al conjunto basta con recorrer el array hasta
encontrarlo. Se devuelve True si se encuentra. Codificación:

m    ? 1Hm/




m m
 m  m71"0m m''

m 1"0$m% /
 
  


˜ Inserción y borrado:

Para insertar un elemento, primero debe comprobarse que no está, después se inserta en la última
posición, esto es, la que señale el cardinal, que se incrementa en una unidad. Codificación:

mm   ? 1Hm/




m ;  1/


1"0$1"0m ''% /


Borrar es aparentemente más complicado. No se puede eliminar el elemento y dejar un hueco,


puesto que en ese caso ya no se tiene una lista. Para eliminar este problema se sustituye el
elemento borrado por el último de la lista. Codificación:

m   ? 1Hm/




m m
 m  m71"0m m''

m 1"0$m% /
 
1"0$m% 1"0$""1"0m % 



˜ Unión:

Para hacer C = A Unión B, se introducen en C todos los elementos de A y todos los elementos de
B que no pertenezcan a A. Codificación:
m m?  ? 1 ?  ? 8


m m
8 1
 m  m7"0m m''

m ;  1"0$m%


m  8"0$m%



˜ Intersección:

Para hacer C = A intersección B, se hace un recorrido sobre A (o B) y se insertan en C los


elementos que estén en B (o A).

El pseudocódigo es:

C = vacío
para cada x elemento de A
si x pertenece a B entonces insertar x en C.

En C:

mm m  ? 1 ?  ? 8




m m
8"0m   
 m  m71"0m m''

m   1"0$m%


m  81"0$m%



˜ Diferencia:

Para hacer C = A-B, se hace un recorrido sobre A (o B) y se insertan en C los elementos que no
estén en B (o A).

El pseudocódigo es:

C = vacío
para cada x elemento de A
si x no pertenece a B entonces insertar x en C.

En C:

mm m  ? 1 ?  ? 8




m m
8"0m   
 m  m71"0m m''

m ;  1"0$m%


m  81"0$m%



˜ Diferencia simétrica:

Sea C = (A-B) Unión (B-A). Para obtener este resultado se puede aprovechar el código estudiado
anteriormente.

El pseudocódigo es:

C = vacío
para cada x elemento de A
si x no pertenece a B entonces insertar x en C
para cada x elemento de B
si x no pertenece a A entonces insertar x en C

En C:

mmm  ? 1 ?  ? 8




m m
8"0m   
 m  m71"0m m''

m ;  1"0$m%


m  81"0$m%

 m  m7"0m m''

m ;  1"0$m%


m  8"0$m%



˜ ubconjuntos:

Determinar si un conjunto A es subconjunto de B se reduce a comprobar si todo elemento de A


es elemento de B. Se devuelve True si A es subconjunto de B. Codificación:

m   ?   ? 1 ? 




m m

 
 m  m71"0m m''

m ;  1"0$m%

  
 


˜ Igualdad de conjuntos:

Un conjunto A es igual a otro B si A es subconjunto de B y ambos tienen los mismos elementos.


Se devuelve True si A es igual a B. Codificación:

m m  ? 1 ? 




    ?  1
441"0m  "0m 



Conclusiones

La ventaja de esta implementación es que no limita el rango de representación de los elementos


del conjunto, y por supuesto tampoco limita el tipo de datos, siempre y cuando se pueda deducir
cuando un elemento es igual a otro o no.

La desventaja de esta implementación con respecto a la de arrays de bits es su mala eficacia con
respecto al tiempo de ejecución. El coste de la inserción y borrado es O(1). Siendo |A| el cardinal
de un conjunto cualquiera A las operaciones de pertenencia se ejecuta en un tiempo O(|A|). En
las restantes operaciones, que implican a dos conjuntos, la complejidad es O(|A|·|B|)

El espacio que ocupa un conjunto es de O(|MAXIMO|), siendo MAXIMO el tamaño del array.

Representación mediante lista enlazada

Esta representación es muy parecida a la implementación mediante un array, pero con alguna
particularidad que la hace más interesante en algunos casos. Por supuesto, los tipos de datos de
los elementos que se insertan son igualmente admisibles con listas como lo eran con arrays.

Suponer que entre los elementos del conjunto se puede definir una relación de orden, es decir,
que se puede determinar si un elemento es mayor que otro. En este caso se pueden insertar y
borrar elementos del conjunto de forma que la lista que los mantiene esté ordenada. Esto puede
resultar interesante en algunas aplicaciones.

Sea |A| y |B| el cardinal de unos conjuntos cualesquiera A y B. Aplicando la suposición anterior
las operaciónes de búsqueda, inserción y borrado se ejecutan en un tiempo O(|A|).
Pero hay una gran ventaja, y es que las restantes operaciones se ejecutan en un tiempo
O(|A|+|B|). ¿Cómo se consigue ésto? Aprovechando las propiedades de tener listas ordenadas
basta con hacer un único recorrido sobre cada lista. Esto es posible implementando un algoritmo
basado en el algoritmo de fusión de dos listas ordenadas, que obtiene una lista ordenada a partir
de dos o más listas ordenadas con un único recorrido de cada lista. (Es recomendable ver primero
los Algoritmos de ordenación de listas y entender el proceso de intercalación o fusión, pero NO
es necesario estudiar el proceso recursivo ya que no tiene interés aquí). Las operaciones de
unión, intersección, etcétera, e incluiso el determinar si un conjunto es subconjunto de otro se
efectúan haciendo pequeñas variaciones sobre el algoritmo de intercalación.

Nota sobre la imOlementación: Al estudiar la codificación se podrá notar que los conjuntos A y
B sobre los que se hacen los recorridos no se modifican sino que quedan como están. Para ganar
en eficiencia se puede hacer que el nuevo conjunto C no cree su propia lista de elementos sino
que simplemente aproveche los enlaces de las listas que mantienen los conjuntos A y B,
deshaciendo estos. Esto es algo opcional y no se ha implementado, pero es útil si dichos
conjuntos no se van a emplear más. Los punteros c1 y c2 recorren las listas que representan a los
conjuntos A y B respectivamente. El puntero c3 y aux sirven para crear el nuevo conjunto C.

Definición y tiOo de datos emOleado:

Se empleará una lista enlazada con cabecera ficticia y centinela. La razón es que se realizarán
inserciones y búsquedas sobre la lista que contiene los elementos del conjunto. Como se ha
comentado anteriormente, los elementos de la lista estarán ordenados. Por tanto, para emplear
esta representación los elementos deben ser 
  . En el código propuesto, se tratarán
conjuntos de números enteros.

Los tipos de datos se declaran así:

#m

m 
mm
m


# ? 

m 
  m 
m m 
 ? 

˜ Creación del conjunto vacío:

La creación de un nuevo conjunto (vacío) se realiza estableciendo a cero el número de elementos


y reservando memoria para los elementos de cabecera y centinela de la lista.
m?  ? ?


?"0   m
 m: m


?"0 m   m
 m: m


?"0 "0m ?"0 m 
?"0 m "0m ?"0 m m   m
?"0m   


˜ Pertenencia:

Para determinar si un elemento x pertenece al conjunto basta con recorrer la lista hasta
encontrarlo o llegar al final de ésta. Se devuelve True si se encuentra antes del centinela.

m    ? ?m /




m

 ?— "0m
?— m "0 /
!m "0; /

 "0m
m  ?— m 

  

 


˜ Inserción y borrado:

Para insertar un elemento primero debe comprobarse que no está, después se inserta
ordenadamente en la lista, y se incrementa el cardinal en una unidad.

mm   ? ?m /




m m 

—" 
 m ?"0 
 ?"0 "0m
?"0 m "0 /
!m "07/
 
 m 
 "0m


m "0; /<< ?"0 m 
 
)—"
   m
 m: m


 "0 /
*—" :
 "0m 
 m"0m  

?"0m ''



Para borrar un elemento basta con localizarlo dentro de la lista y eliminarlo.

m   ? ?m /




m m
—" 
 m ?"0 
 ?"0 "0m
?"0 m "0 /
!m "07/
 
 m 
 "0m

)—" m/m
m ; ?"0 m 44"0 /
 
 m"0m "0m
 




˜ Unión:

A partir de los conjuntos A y B se crea un nuevo conjunto C. Se supone que el conjunto C no ha


sido inicializado antes. En cada paso se añade siempre un nuevo elemento. Por último se
comprueba que no queden elementos sin copiar.

m m ?  ? 1 ?  ? 8




m)*/

? 8

* 8"0 
 1— "0m) — "0m
!m ; 1— m 44); — m 
 
/  m
 m: m


m "07)"0
 
/"0 "0
 "0m

m "00)"0
 
/"0 )"0
) )"0m

 
/"0 "0 m /"0 )"0
 "0m
) )"0m

/"0m 8"0 m 
*"0m /* /
8"0m ''

m  m! m
m ; 1— m 
 
 !m ; 1— m 
 
 /  m
 m: m


 /"0 "0
 /"0m 8"0 m 
 *"0m /* /
 8"0m ''
  "0m
 

m ); — m 
 
 !m ); — m 
 
 /  m
 m: m


 /"0 )"0
 /"0m 8"0 m 
 *"0m /* /
 8"0m ''
 ) )"0m
 



˜ Intersección:

C = A Intersección B, es el nuevo conjunto que se crea. Se añade un elemento cuando coincide


en ambas listas a la vez (c1->elem == c2->elem).

mm m  ? 1 ?  ? 8




m)*/

? 8

* 8"0 
 1— "0m) — "0m
!m ; 1— m 44); — m 
 
m "07)"0

 "0m
m "00)"0

) )"0m
 
/  m
 m: m


/"0 "0 m /"0 )"0
/"0m 8"0 m 
*"0m /* /
8"0m ''
 "0m
) )"0m
 


˜ Diferencia:

C = A-B. Se añade un nuevo elemento sólo cuando (c1->elem < c2->elem).

mm m  ? 1 ?  ? 8




m)*/

? 8

* 8"0 
 1— "0m) — "0m
!m ; 1— m 44); — m 
 
 m "07)"0
 
/  m
 m: m


/"0 "0
/"0m 8"0 m 
*"0m /* /
8"0m ''
 "0m
 
m "00)"0

) )"0m

 "0m
 ) )"0m

 m>>1
!m ; 1— m 
 
 /  m
 m: m


 /"0 "0
 /"0m 8"0 m 
 *"0m /* /
 8"0m ''
  "0m



˜ Diferencia simétrica:

C = (A-B) Unión (B-A). Es decir, todos los elementos no comunes de ambos conjuntos. Se
añaden elementos si (c1->elem != c2->elem).

mmm  ? 1 ?  ? 8




m)*/

? 8

* 8"0 
 1— "0m) — "0m
!m ; 1— m 44); — m 
 
m "0; )"0
 
/  m
 m: m


 m "07)"0
 /"0 "0 "0m
  /"0 )"0) )"0m
 
 /"0m 8"0 m 
 *"0m /* /
 8"0m ''
 
 
 "0m
) )"0m


m  m! m
m ; 1— m 
 
 !m ; 1— m 
 
 /  m
 m: m


 /"0 "0
 /"0m 8"0 m 
 *"0m /* /
 8"0m ''
  "0m
 

m ); — m 
 
 !m ); — m 
 
 /  m
 m: m


 /"0 )"0
 /"0m 8"0 m 
 *"0m /* /
 8"0m ''
 ) )"0m
 



˜ ubconjuntos:

Determinar si un conjunto A es subconjunto de B se reduce a comprobar si todo elemento de A


es elemento de B. Se devuelve True si A es subconjunto de B. Observar que si (c1->elem < c2-
>elem) entonces A ya no puede ser subconjunto de B, pues implica que dicho elemento no está
en B, ya que c2 representa al menor de los elementos restantes del conjunto. Por último, observar
la última condición: return (essub && c1 == A.centinela);. Es decir, quedan elementos de A que
no han sido recorridos, pero B ya está totalmente recorrido, luego A no es subconjunto de B. Si
se da el caso de que  
 y  8 $    entonces se puede devolver un tercer valor
que indique que B es subconjunto de A.

m   ?   ? 1 ? 




m   
m)

 1— "0m) — "0m
!m ; 1— m 44); — m 44
 
 m "07)"0

    
 m "00)"0

 ) )"0m
  
  "0m
 ) )"0m
 

   44 1— m 



˜ Igualdad de conjuntos:

Un conjunto A es igual a otro B si ambos tienen los mismos elementos. Se devuelve True si A es
igual a B. Se comprueba primero el cardinal de ambos conjuntos.

m m  ? 1 ? 




m m
m)

m 1—m  —m 

 1— "0m) — "0m
!m ; 1— m 44); — m 44m
 
m "0; )"0

 m  
 "0m
 ) )"0m

  m



Programa de Orueba:

m m m


 ? 18)*.(I

? 41

? 4

? 48


1  )*(
  )*.(
8  *.I

m  41)
m  41*
m  41(

m  4
m  4)
m  4*
m  4.

m  4(

m  48*
m  48.
m  48I



m   1(

m  +( 1+



m ;  I

m  +- I  +




  )*.(I
 m ? 184


)  *
m m 184)


*  .
m m 14*


. I  ).(I( J
mm 814.

mm 184I

mm 4(


m m .I

m  +- . I+



m   ?  

m  +-   ? +



m ;  ?  8

m  +-    ? 8+



m   ?  1

m  +- 1  ? +



m   ?  (1

m  +- (  ? 1+





  


Conclusiones

Esta implementación tampoco limita el rango de representación de los elementos del conjunto, y
por supuesto tampoco limita el tipo de datos, siempre y cuando se pueda deducir cuando un
elemento es igual a otro o no.

Dado un conjunto A y B, las operaciones de inserción, borrado y pertenencia se ejecutan en un


tiempo de O(|A|). Las operaciones de unión, intersección, diferencia, diferencia simétrica,
subconjunto e igualdad se ejecutan en un tiempo de O(|A|+|B|).

El espacio que ocupa un conjunto es de O(|A|), siendo |A| el cardinal del conjunto A. Por
supuesto es proporcional al tamaño del conjunto implementado mediante array, multiplicado por
una constante debido al espacio ocupado por los punteros.


ARBOLES

* Definición de árbol
* Formas de representación
* Nomenclatura sobre árboles
* Declaración de árbol binario
* Recorridos sobre árboles binarios
* Construcción de un árbol binario
* Árbol binario de búsqueda
* Problemas

Definición de árbol

Un árbol es una estructura de datos, que puede definirse de forma recursiva como:

- Una estructura vacía o


- Un elemento o clave de información (nodo) más un número finito de estructuras tipo árbol,
disjuntos, llamados subárboles. Si dicho número de estructuras es inferior o igual a 2, se tiene un
árbol binario.

Es, por tanto, una estructura no secuencial.

Otra definición nos da el árbol como un tipo de grafo (ver grafos): un árbol es un grafo acíclico,
conexo y no dirigido. Es decir, es un grafo no dirigido en el que existe exactamente un camino
entre todo par de nodos. Esta definición permite implementar un árbol y sus operaciones
empleando las representaciones que se utilizan para los grafos. Sin embargo, en esta sección no
se tratará esta implementación.

Formas de representación

- Mediante un grafo:
Figura

- Mediante un diagrama encolumnado:

a
b
d
c
e
f

En la computación se utiliza mucho una estructura de datos, que son los árboles binarios. Estos
árboles tienen 0, 1 ó 2 descendientes como máximo. El árbol de la figura anterior es un ejemplo
válido de árbol binario.

Nomenclatura sobre árboles

- Raíz: es aquel elemento que no tiene antecesor; ejemplo: .


- Rama: arista entre dos nodos.
- Antecesor: un nodo X es es antecesor de un nodo Y si por alguna de las ramas de X se puede
llegar a Y.
- Sucesor: un nodo X es sucesor de un nodo Y si por alguna de las ramas de Y se puede llegar a
X.
- Grado de un nodo: el número de descendientes directos que tiene. Ejemplo:  tiene grado 2, 
tiene grado 0,  tiene grado 2.
- Hoja: nodo que no tiene descendientes: grado 0. Ejemplo: 
- Nodo interno: aquel que tiene al menos un descendiente.
- Nivel: número de ramas que hay que recorrer para llegar de la raíz a un nodo. Ejemplo: el nivel
del nodo  es 1 (es un convenio), el nivel del nodo es 3.
- Altura: el nivel más alto del árbol. En el ejemplo de la figura 1 la altura es 3.
- Anchura: es el mayor valor del número de nodos que hay en un nivel. En la figura, la anchura
es 3.

2claraciones: se ha denominado  a la raíz, pero se puede observar según la figura que cualquier
nodo podría ser considerado raíz, basta con 

el árbol. Podría determinarse por ejemplo que 
fuera la raíz, y  y  los sucesores inmediatos de la raíz . Sin embargo, en las implementaciones
sobre un computador que se realizan a continuación es necesaria una jerarquía, es decir, que haya
una única raíz.

Declaración de árbol binario

Se definirá el árbol con una clave de tipo entero (puede ser cualquier otra tipo de datos) y dos
hijos: izquierdo (izq) y derecho (der). Para representar los enlaces con los hijos se utilizan
punteros. El árbol vacío se representará con un puntero nulo.

Un árbol binario puede declararse de la siguiente manera:

# 

m 
 m:>
 

Otras declaraciones también añaden un enlace al nodo padre, pero no se estudiarán aquí.

Recorridos sobre árboles binarios

Se consideran dos tipos de recorrido: recorrido en profundidad y recorrido en anchura o a nivel.


Puesto que los árboles no son secuenciales como las listas, hay que buscar estrategias
alternativas para visitar todos los nodos.

- Èecorridos en Orofundidad:

* Recorrido en Oreorden: consiste en visitar el nodo actual (visitar puede ser simplemente
mostrar la clave del nodo por pantalla), y después visitar el subárbol izquierdo y una vez
visitado, visitar el subárbol derecho. Es un proceso recursivo por naturaleza.
Si se hace el recorrido en preorden del árbol de la figura 1 las visitas serían en el orden siguiente:
a,b,d,c,e,f.

m  




m ; &655
 
mm 

 "0m:>

 "0




* Recorrido en inorden u orden central: se visita el subárbol izquierdo, el nodo actual, y después
se visita el subárbol derecho. En el ejemplo de la figura 1 las visitas serían en este orden:
b,d,a,e,c,f.

mm   




m ; &655
 
m  "0m:>

mm 

m  "0




* Recorrido en Oostorden: se visitan primero el subárbol izquierdo, después el subárbol derecho,


y por último el nodo actual. En el ejemplo de la figura 1 el recorrido quedaría así: d,b,e,f,c,a.

m  


m ; &655
 
 "0m:>

 "0

mm 




La ventaja del recorrido en postorden es que permite borrar el árbol de forma consistente. Es
decir, si visitar se traduce por borrar el nodo actual, al ejecutar este recorrido se borrará el árbol o
subárbol que se pasa como parámetro. La razón para hacer esto es que no se debe borrar un nodo
y después sus subárboles, porque al borrarlo se pueden perder los enlaces, y aunque no se
perdieran se rompe con la regla de manipular una estructura de datos inexistente. Una alternativa
es utilizar una variable auxiliar, pero es innecesario aplicando este recorrido.

- Èecorrido en amOlitud:

Consiste en ir visitando el árbol por niveles. Primero se visitan los nodos de nivel 1 (como
mucho hay uno, la raíz), después los nodos de nivel 2, así hasta que ya no queden más.
Si se hace el recorrido en amplitud del árbol de la figura una visitaría los nodos en este orden:
a,b,c,d,e,f
En este caso el recorrido no se realizará de forma recursiva sino iterativa, utilizando una cola
(ver Colas) como estructura de datos auxiliar. El procedimiento consiste en encolar (si no están
vacíos) los subárboles izquierdo y derecho del nodo extraido de la cola, y seguir desencolando y
encolando hasta que la cola esté vacía.
En la codificación que viene a continuación no se implementan las operaciones sobre colas.
mm  


8K mK  m m
 /

m ; &655
 
88 

  

!m ;m 

 
  /

mm /

m /"0m:>; &655
  /"0m:>

m /"0; &655
  /"0





Por último, considérese la sustitución de la cola por una pila en el recorrido en amplitud. ¿Qué
tipo de recorrido se obtiene?

Construcción de un árbol binario

Hasta el momento se ha visto la declaración y recorrido de un árbol binario. Sin embargo no se


ha estudiado ningún método para crearlos. A continuación se estudia un método para crear un
árbol binario que no tenga claves repetidas partiendo de su recorrido en preorden e inorden,
almacenados en sendos arrays.

Antes de explicarlo se recomienda al lector que lo intente hacer por su cuenta, es sencillo cuando
uno es capaz de construir el árbol viendo sus recorridos pero sin haber visto el árbol terminado.

Partiendo de los recorridos preorden e inorden del árbol de la figura 1 puede determinarse que la
raíz es el primer elemento del recorrido en preorden. Ese elemento se busca en el array inorden.
Los elementos en el array inorden entre izq y la raíz forman el subárbol izquierdo. Asimismo los
elementos entre der y la raíz forman el subárbol derecho. Por tanto se tiene este árbol:
A continuación comienza un proceso recursivo. Se procede a crear el subárbol izquierdo, cuyo
tamaño está limitado por los índices izq y der. La siguiente posición en el recorrido en preorden
es la raíz de este subárbol. Queda esto:

El subárbol  tiene un subárbol derecho, que no tiene ningún descendiente, tal y como indican
los índices izq y der. Se ha obtenido el subárbol izquierdo completo de la raíz , puesto que  no
tiene subárbol izquierdo:

Después seguirá construyéndose el subárbol derecho a partir de la raíz .


La implementación de la construcción de un árbol partiendo de los recorridos en preorden y en
inorden puede consultarse aquí (en C).

Árbol binario de búsqueda

Un árbol binario de búsqueda es aquel que es:

- Una estructura vacía o


- Un elemento o clave de información (nodo) más un número finito -a lo sumo dos- de
estructuras tipo árbol, disjuntos, llamados subárboles y además cumplen lo siguiente:

* Todas las claves del subárbol izquierdo al nodo son menores que la clave del nodo.
* Todas las claves del subárbol derecho al nodo son mayores que la clave del nodo.
* Ambos subárboles son árboles binarios de búsqueda.

Un ejemplo de árbol binario de búsqueda:

Figura

Al definir el tipo de datos que representa la clave de un nodo dentro de un árbol binario de
búsqueda es necesario que en dicho tipo se pueda establecer una relación de orden. Por ejemplo,
suponer que el tipo de datos de la clave es un puntero (da igual a lo que apunte). Si se codifica el
árbol en Pascal no se puede establecer una relación de orden para las claves, puesto que Pascal
no admite determinar si un puntero es mayor o menor que otro.

En el ejemplo de la figura 5 las claves son números enteros. Dada la raíz +, las claves del
subárbol izquierdo son menores que 4, y las claves del subárbol derecho son mayores que 4. Esto
se cumple también para todos los subárboles. Si se hace el recorrido de este árbol en orden
central se obtiene una lista de los números ordenada de menor a mayor.
Cuestión: ¿Qué hay que hacer para obtener una lista de los números ordenada de mayor a menor?
Una ventaja fundamental de los árboles de búsqueda es que son en general mucho más rápidos
para localizar un elemento que una lista enlazada. Por tanto, son más rápidos para insertar y
borrar elementos. Si el árbol está Oerfectamente equilibrado -esto es, la diferencia entre el
número de nodos del subárbol izquierdo y el número de nodos del subárbol derecho es a lo sumo
1, para todos los nodos- entonces el número de comparaciones necesarias para localizar una
clave es aproximadamente de logN en el peor caso. Además, el algoritmo de inserción en un
árbol binario de búsqueda tiene la ventaja -sobre los arrays ordenados, donde se emplearía
búsqueda dicotómica para localizar un elemento- de que no necesita hacer una reubicación de los
elementos de la estructura para que esta siga ordenada después de la inserción. Dicho algoritmo
funciona avanzando por el árbol escogiendo la rama izquierda o derecha en función de la clave
que se inserta y la clave del nodo actual, hasta encontrar su ubicación; por ejemplo, insertar la
clave 7 en el árbol de la figura 5 requiere avanzar por el árbol hasta llegar a la clave 8, e
introducir la nueva clave en el subárbol izquierdo a l.
El algoritmo de borrado en árboles es algo más complejo, pero más eficiente que el de borrado
en un array ordenado.

Ahora bien, suponer que se tiene un árbol vacío, que admite claves de tipo entero. Suponer que
se van a ir introduciendo las claves de forma ascendente. Ejemplo: 1,2,3,4,5,6
Se crea un árbol cuya raíz tiene la clave 1. Se inserta la clave 2 en el subárbol derecho de . A
continuación se inserta la clave 3 en el subárbol derecho de ).
Continuando las inserciones se ve que el árbol degenera en una lista secuencial, reduciendo
drásticamente su eficacia para localizar un elemento. De todas formas es poco probable que se de
un caso de este tipo en la práctica. Si las claves a introducir llegan de forma más o menos
aleatoria entonces la implementación de operaciones sobre un árbol binario de búsqueda que
vienen a continuación son en general suficientes.

Existen variaciones sobre estos árboles, como los AVL o Red-Black (no se tratan aquí), que sin
llegar a cumplir al 100% el criterio de árbol perfectamente equilibrado, evitan problemas como
el de obtener una lista degenerada.

ËOeraciones básicas sobre árboles binarios de búsqueda

- Búsqueda

Si el árbol no es de búsqueda, es necesario emplear uno de los recorridos anteriores sobre el


árbol para localizarlo. El resultado es idéntico al de una búsqueda secuencial. Aprovechando las
propiedades del árbol de búsqueda se puede acelerar la localización. Simplemente hay que
descender a lo largo del árbol a izquierda o derecha dependiendo del elemento que se busca.

    m 




m  &655
 L152=
m "07
   "0

m "00
   "0m:>

 H96=

˜ Inserción

La inserción tampoco es complicada. Es más, resulta practicamente idéntica a la búsqueda.


Cuando se llega a un árbol vacío se crea el nodo en el puntero que se pasa como parámetro por
referencia, de esta manera los nuevos enlaces mantienen la coherencia. Si el elemento a insertar
ya existe entonces no se hace nada.

mm   m 




m  &655
 
   
 m:  


 
"0 
 
"0m:>  
"0 &655

m 
"07
m  4 
"0

m 
"00
m  4 
"0m:>



˜ Borrado

La operación de borrado si resulta ser algo más complicada. Se recuerda que el árbol debe seguir
siendo de búsqueda tras el borrado. Pueden darse tres casos, una vez encontrado el nodo a borrar:
1) El nodo no tiene descendientes. Simplemente se borra.
2) El nodo tiene al menos un descendiente por una sola rama. Se borra dicho nodo, y su primer
descendiente se asigna como hijo del padre del nodo borrado. Ejemplo: en el árbol de la figura 5
se borra el nodo cuya clave es -1. El árbol resultante es:

3) El nodo tiene al menos un descendiente por cada rama. Al borrar dicho nodo es necesario
mantener la coherencia de los enlaces, además de seguir manteniendo la estructura como un
árbol binario de búsqueda. La solución consiste en sustituir la información del nodo que se borra
por el de una de las hojas, y borrar a continuación dicha hoja. ¿Puede ser cualquier hoja? No,
debe ser la que contenga una de estas dos claves:
· la mayor de las claves menores al nodo que se borra. Suponer que se quiere borrar el nodo +
del árbol de la figura 5. Se sustituirá la clave 4 por la clave 2.
· la menor de las claves mayores al nodo que se borra. Suponer que se quiere borrar el nodo +
del árbol de la figura 5. Se sustituirá la clave 4 por la clave 5.

El algoritmo de borrado que se implementa a continuación realiza la sustitución por la mayor de


las claves menores, (aunque se puede escoger la otra opción sin pérdida de generalidad). Para
lograr esto es necesario descender primero a la izquierda del nodo que se va a borrar, y después
avanzar siempre a la derecha hasta encontrar un nodo hoja. A continuación se muestra
gráficamente el proceso de borrar el nodo de clave 4:

Codificación: el procedimiento sustituir es el que desciende por el árbol cuando se da el caso del
nodo con descencientes por ambas ramas.

m   m 




mmm   /

 /

m  &655
 /m
 

m 
"07
  4 
"0

m 
"00
  4 
"0m:>

m 
"0 
 
/ 
m 
"0m:> &655
  
"0
m 
"0 &655
  
"0m:>
mm 4 
"0m:>4/
m#
# 

 /



Ficheros relacionados

Implementación de algunas de las operaciones sobre árboles binarios.

Ejercicio resuelto

Escribir una función que devuelva el numero de nodos de un árbol binario. Una solución
recursiva puede ser la siguiente:

funcion nodos(arbol : tipoArbol) : devuelve entero;


inicio
si arbol = vacio entonces devolver 0;
en otro caso devolver (1 + nodos(subarbol_izq) + nodos(subarbol_der));
fin

Adaptarlo para que detecte si un árbol es perfectamente equilibrado o no.

Problemas propuestos

årboles binarios: OIE 98. (Enunciado)

Aplicación práctica de un árbol

Se tiene un fichero de texto ASCII. Para este propósito puede servir cualquier libro electrónico
de la librería Gutenberg o Cervantes, que suelen tener varios cientos de miles de palabras. El
objetivo es clasificar todas las palabras, es decir, determinar que palabras aparecen, y cuantas
veces aparece cada una. Palabras como 'niño'-'niña', 'vengo'-'vienes' etc, se consideran diferentes
por simplificar el problema.

Escribir un programa, que recibiendo como entrada un texto, realice la clasificación descrita
anteriormente.
Ejemplo:
Texto: "a b'a c. hola, adios, hola"

La salida que produce es la siguiente:


a2
adios 1
b1
c1
hola 2

Nótese que el empleo de una lista enlazada ordenada no es una buena solución. Si se obtienen
hasta 20.000 palabras diferentes, por decir un número, localizar una palabra cualquiera puede
ser, y en general lo será, muy costoso en tiempo. Se puede hacer una implementación por pura
curiosidad para evaluar el tiempo de ejecución, pero no merece la pena.

La solución pasa por emplear un árbol binario de búsqueda para insertar las claves. El valor de
log(20.000) es aproximadamente de 14. Eso quiere decir que localizar una palabra entre 20.000
llevaría en el peor caso unos 14 accesos. El contraste con el empleo de una lista es simplemente
abismal. Por supuesto, como se ha comentado anteriormente el árbol no va a estar perfectamente
equilibrado, pero nadie escribe novelas manteniendo el orden lexicográfico (como un
diccionario) entre las palabras, asi que no se obtendrá nunca un árbol muy degenerado. Lo que
está claro es que cualquier evolución del árbol siempre será mejor que el empleo de una lista.

Por último, una vez realizada la lectura de los datos, sólo queda hacer un recorrido en orden
central del árbol y se obtendrá la solución pedida en cuestión de segundos.

Una posible definición de la estructura árbol es la siguiente:

# 

!$31AC15191%
m   mm — mm 
 m:>

 
GRAFOS

Definición

Un grafo es un objeto matemático que se utiliza para representar circuitos, redes, etc. Los grafos
son muy utilizados en computación, ya que permiten resolver problemas muy complejos.

Imaginemos que disponemos de una serie de ciudades y de carreteras que las unen. De cada
ciudad saldrán varias carreteras, por lo que para ir de una ciudad a otra se podrán tomar diversos
caminos. Cada carretera tendrá un coste asociado (por ejemplo, la longitud de la misma). Gracias
a la representación por grafos podremos elegir el camino más corto que conecta dos ciudades,
determinar si es posible llegar de una ciudad a otra, si desde cualquier ciudad existe un camino
que llegue a cualquier otra, etc.

El estudio de grafos es una rama de la algoritmia muy importante. Estudiaremos primero sus
rasgos generales y sus recorridos fundamentales, para tener una buena base que permita
comprender los algoritmos que se pueden aplicar.

Glosario

Un grafo consta de 5
  (o nodos) y 
 . Los vértices son objetos que contienen
información y las aristas son conexiones entre vértices. Para representarlos, se suelen utilizar
puntos para los vértices y líneas para las conexiones, aunque hay que recordar siempre que la
definición de un grafo no depende de su representación.

Un  entre dos vértices es una lista de vértices en la que dos elementos sucesivos están
conectados por una arista del grafo. Así, el camino AJLOE es un camino que comienza en el
vértice A y pasa por los vértices J,L y O (en ese orden) y al final va del O al E. El grafo será
 9 si existe un camino desde cualquier nodo del grafo hasta cualquier otro. Si no es conexo
constará de varias !    9.

Un  ! es un camino desde un nodo a otro en el que ningún nodo se repite (no se pasa
dos veces). Si el camino simple tiene como primer y último elemento al mismo nodo se
denomina . Cuando el grafo no tiene ciclos tenemos un "
 (ver árboles). Varios árboles
independientes forman un  . Un "
  9!# de un grafo es una reducción del grafo
en el que solo entran a formar parte el número mínimo de aristas que forman un árbol y conectan
a todos los nodos.

Según el número de aristas que contiene, un grafo es !  si cuenta con todas las aristas
posibles (es decir, todos los nodos están conectados con todos), !
 si tiene relativamente
pocas aristas y   si le faltan pocas para ser completo.

Las aristas son la mayor parte de las veces bidireccionales, es decir, si una arista conecta dos
nodos A y B se puede recorrer tanto en sentido hacia B como en sentido hacia A: estos son
llamados 
2  
. Sin embargo, en ocasiones tenemos que las uniones son
unidireccionales. Estas uniones se suelen dibujar con una flecha y definen un 
2 
.
Cuando las aristas llevan un coste asociado (un entero al que se denomina ! ) el grafo es
!
. Una
 es un grafo dirigido y ponderado.

Representación de grafos

Una característica especial en los grafos es que podemos representarlos utilizando dos estructuras
de datos distintas. En los algoritmos que se aplican sobre ellos veremos que adoptarán tiempos
distintos dependiendo de la forma de representación elegida. En particular, los tiempos de
ejecución variarán en función del número de vértices y el de aristas, por lo que la utilización de
una representación u otra dependerá en gran medida de si el grafo es denso o disperso.

Para nombrar los nodos utilizaremos letras mayúsculas, aunque en el código deberemos hacer
corresponder cada nodo con un entero entre 1 y V (número de vértices) de cara a la manipulación
de los mismos.

Representación por matriz de adyacencia

Es la forma más común de representación y la más directa. Consiste en una tabla de tamaño V x
V, en que la que a[i][j] tendrá como valor 1 si existe una arista del nodo i al nodo j. En caso
contrario, el valor será 0. Cuando se trata de grafos ponderados en lugar de 1 el valor que tomará
será el peso de la arista. Si el grafo es no dirigido hay que asegurarse de que se marca con un 1 (o
con el peso) tanto la entrada a[i][j] como la entrada a[j][i], puesto que se puede recorrer en
ambos sentidos.

m M1
m $/M%$/M%

mm mmm:


m m/#
!)
5M#1
  m: 


 m m7 1m''

 
  +,,,- +44)4

/ "N1N# )"N1N
$/%$#% $#%$/% 



En esta implementación se ha supuesto que los vértices se nombran con una letra mayúscula y no
hay errores en la entrada. Evidentemente, cada problema tendrá una forma de entrada distinta y
la inicialización será conveniente adaptarla a cada situación. En todo caso, esta operación es
sencilla si el número de nodos es pequeño. Si, por el contrario, la entrada fuese muy grande se
pueden almacenar los nombres de nodos en un árbol binario de búsqueda o utilizar una tabla de
dispersión, asignando un entero a cada nodo, que será el utilizado en la matriz de adyacencia.

Como se puede apreciar, la matriz de adyacencia siempre ocupa un espacio de V*V, es decir,
depende solamente del número de nodos y no del de aristas, por lo que será útil para representar
grafos densos.

Representación por lista de adyacencia

Otra forma de representar un grafo es por medio de listas que definen las aristas que conectan los
nodos. Lo que se hace es definir una lista enlazada para cada nodo, que contendrá los nodos a los
cuales es posible acceder. Es decir, un nodo A tendrá una lista enlazada asociada en la que
aparecerá un elemento con una referencia al nodo B si A y B tienen una arista que los une.
Obviamente, si el grafo es no dirigido, en la lista enlazada de B aparecerá la correspondiente
referencia al nodo A.

Las listas de adyacencia serán estructuras que contendrán un valor entero (el número que
identifica al nodo destino), así como otro entero que indica el coste en el caso de que el grafo sea
ponderado. En el ejemplo se ha utilizado un nodo z ficticio en la cola (ver listas, apartado
cabeceras y centinelas).

 

m 
m 
 m


m M1Om#m
 $/M%:

mm mmm:


m m/#
!)
 
:  
 m:  


:"0m :
 m m7Mm''

$m% :
 m m71m''

 
  +,,,- +44)4

/ "N1N# )"N1N

  
 m:  


"0 #"0 "0m $/%$/% 

  
 m:  


"0 /"0 "0m $#%$#% 



En este caso el espacio ocupado es O(V + A), muy distinto del necesario en la matriz de
adyacencia, que era de O(V2). La representación por listas de adyacencia, por tanto, será más
adecuada para grafos dispersos.

Hay que tener en cuenta un aspecto importante y es que la implementación con listas enlazadas
determina fuertemente el tratamiento del grafo posterior. Como se puede ver en el código, los
nodos se van añadiendo a las listas según se leen las aristas, por lo que nos encontramos que un
mismo grafo con un orden distinto de las aristas en la entrada producirá listas de adyacencia
diferentes y por ello el orden en que los nodos se procesen variará. Una consecuencia de esto es
que si un problema tiene varias soluciones la primera que se encuentre dependerá de la entrada
dada. Podría presentarse el caso de tener varias soluciones y tener que mostrarlas siguiendo un
determinado orden. Ante una situación así podría ser muy conveniente modificar la forma de
meter los nodos en la lista (por ejemplo, hacerlo al final y no al principio, o incluso insertarlo en
una posición adecuada), de manera que el algoritmo mismo diera las soluciones ya ordenadas.

Exploración de grafos

A la hora de explorar un grafo, nos encontramos con dos métodos distintos. Ambos conducen al
mismo destino (la exploración de todos los vértices o hasta que se encuentra uno determinado), si
bien el orden en que éstos son "visitados" decide radicalmente el tiempo de ejecución de un
algoritmo, como se verá posteriormente.

En primer lugar, una forma sencilla de recorrer los vértices es mediante una función recursiva, lo
que se denomina búsqueda en profundidad. La sustitución de la recursión (cuya base es la
estructura de datos pila) por una cola nos proporciona el segundo método de búsqueda o
recorrido, la búsqueda en amplitud o anchura.
Suponiendo que el orden en que están almacenados los nodos en la estructura de datos
correspondiente es A-B-C-D-E-F... (el orden alfabético), tenemos que el orden que seguiría el
recorrido en profundidad sería el siguiente:

A-B-E-I-F-C-G-J-K-H-D

En un recorrido en anchura el orden sería, por contra:

A-B-C-D-E-G-H-I-J-K-F

Es decir, en el primer caso se exploran primero los verdes y luego los marrones, pasando primero
por los de mayor intensidad de color. En el segundo caso se exploran primero los verdes, después
los rojos, los naranjas y, por último, el rosa.

Es destacable que el nodo D es el último en explorarse en la búsqueda en profundidad pese a ser


adyacente al nodo de origen (el A). Esto es debido a que primero se explora la rama del nodo C,
que también conduce al nodo D.

En estos ejemplos hay que tener en cuenta que es fundamental el orden en que los nodos están
almacenados en las estructuras de datos. Si, por ejemplo, el nodo D estuviera antes que el C, en
la búsqueda en profundidad se tomaría primero la rama del D (con lo que el último en visitarse
sería el C), y en la búsqueda en anchura se exploraría antes el H que el G.

Búsqueda en profundidad

Se implementa de forma recursiva, aunque también puede realizarse con una pila. Se utiliza un
array val para almacenar el orden en que fueron explorados los vértices. Para ello se incrementa
una variable global id (inicializada a 0) cada vez que se visita un nuevo vértice y se almacena id
en la entrada del array val correspondiente al vértice que se está explorando.

La siguiente función realiza un máximo de V (el número total de vértices) llamadas a la función
visitar, que implementamos aquí en sus dos variantes: representación por matriz de adyacencia y
por listas de adyacencia.

m m 
m $M%

m 


m P
 P P7 MP''

$P% 
 P P7 MP''

m $P%
mm P



mmm m P
m:# m

m 
$P% ''m
  7 M''

m $P%$%44$%
mm 



mmm m P
m# m

 
$P% ''m
  $P%; : "0m

m $"0%
mm "0



El resultado es que el array val contendrá en su i-ésima entrada el orden en el que el vértice i-
ésimo fue explorado. Es decir, si tenemos un grafo con cuatro nodos y fueron explorados en el
orden 3-1-2-4, el array val quedará como sigue:

$% )m m  


$)% *  m 
$*% —
$.% .

Una modificación que puede resultar especialmente útil es la creación de un array "inverso" al
array val que contenga los datos anteriores "al revés". Esto es, un array en el que la entrada i-
ésima contiene el vértice que se exploró en i-ésimo lugar. Basta modificar la línea

$P% ''m

sustituyéndola por

$''m% P

Para el orden de exploración de ejemplo anterior los valores serían los siguientes:

$% *
$)% 
$*% )
$.% .

Búsqueda en amplitud o anchura

La diferencia fundamental respecto a la búsqueda en profundidad es el cambio de estructura de


datos: una cola en lugar de una pila. En esta implementación, la función del array val y la
variable id es la misma que en el método anterior.


mmm m P
m# m

 
  4P

!m ;m 


 
  44P

$P% ''m
  $P%; : "0m

 
m $"0%

 
  4"0

$"0% "




ALGORITMOS

* Como leer estos temas


* Introducción a los algoritmos

Como leer estos temas

No hay un orden fijo para leerlos. En general es común que ya se conozcan algunos métodos de
ordenación o de búsqueda, siendo por tanto un buen punto para empezar. La sección recursividad
que aparece en esta sección de algoritmos está principalmente centrada en su eliminación y
puede no resultar interesante en principio.

Un posible comienzo sería el backtracking (o vuelta atrás) por ser una base para tratar otros
algoritmos más complejos, y terminar con la programación dinámica, un tema de características
diferentes al resto.

De forma independiente pueden estudiarse los algoritmos sobre grafos y los algoritmos
matemáticos.

Pero si de verdad hay algo fundamental es la introducción que se expone a continuación.

Introducción a los algoritmos

˜ ¿Qué es un algoritmo?

Una definición informal (no se considera aquí una definición formal, aunque existe): conjunto
finito de reglas que dan una secuencia de operaciones para resolver todos los problemas de un
tipo dado. De forma más sencilla, podemos decir que un algoritmo es un conjunto de pasos que
nos permite obtener un dato. Además debe cumplir estas condiciones:

 : : el algoritmo debe acabar tras un número finito de pasos. Es más, es casi fundamental
que sea en un número razonable de pasos.
  2: el algoritmo debe definirse de forma precisa para cada paso, es decir, hay que
evitar toda ambigüedad al definir cada paso. Puesto que el lenguaje humano es impreciso, los
algoritmos se expresan mediante un lenguaje formal, ya sea matemático o de programación para
un computador.
 
: el algoritmo tendrá cero o más entradas, es decir, cantidades dadas antes de empezar
el algoritmo. Estas cantidades pertenecen además a conjuntos especificados de objetos. Por
ejemplo, pueden ser cadenas de caracteres, enteros, naturales, fraccionarios, etc. Se trata siempre
de cantidades representativas del mundo real expresadas de tal forma que sean aptas para su
interpretación por el computador.
 1: el algoritmo tiene una o más salidas, en relación con las entradas.
Ò 2  : se entiende por esto que una persona sea capaz de realizar el algoritmo de modo
exacto y sin ayuda de una máquina en un lapso de tiempo finito.

A menudo los algoritmos requieren una organización bastante compleja de los datos, y es por
tanto necesario un estudio previo de las estructuras de datos fundamentales. Dichas estructuras
pueden implementarse de diferentes maneras, y es más, existen algoritmos para implementar
dichas estructuras. El uso de estructuras de datos adecuadas pueden hacer trivial el diseño de un
algoritmo, o un algoritmo muy complejo puede usar estructuras de datos muy simples.

Uno de los algoritmos más antiguos conocidos es el algoritmo de Euclides. El término algoritmo
proviene del matemático Muhammad ibn Musa al-Khwarizmi, que vivió aproximadamente entre
los años 780 y 850 d.C. en la actual nación Iraní. El describió la realización de operaciones
elementales en el sistema de numeración decimal. De al-Khwarizmi se obtuvo la derivación
algoritmo.

˜ Clasificación de algoritmos

* Algoritmo determinista: en cada paso del algoritmo se determina de forma única el siguiente
paso.
* Algoritmo no determinista: deben decidir en cada paso de la ejecución entre varias
alternativas y agotarlas todas antes de encontrar la solución.

Todo algoritmo tiene una serie de características, entre otras que requiere una serie de recursos,
algo que es fundamental considerar a la hora de implementarlos en una máquina. Estos recursos
son principalmente:
· El tiempo: período transcurrido entre el inicio y la finalización del algoritmo.
· La memoria: la cantidad (la medida varía según la máquina) que necesita el algoritmo para su
ejecución.

Obviamente, la capacidad y el diseño de la máquina pueden afectar al diseño del algoritmo.

En general, la mayoría de los problemas tienen un parámetro de entrada que es el número de


datos que hay que tratar, esto es, . La cantidad de recursos del algoritmo es tratada como una
función de N. De esta manera puede establecerse un tiempo de ejecución del algoritmo que suele
ser proporcional a una de las siguientes funciones:

đ : Tiempo de ejecución constante. Significa que la mayoría de las instrucciones se


ejecutan una vez o muy pocas.

đ logN : Tiempo de ejecución logarítmico. Se puede considerar como una gran constante.
La base del logaritmo (en informática la más común es la base 2) cambia la constante,
pero no demasiado. El programa es más lento cuanto más crezca N, pero es inapreciable,
pues logN no se duplica hasta que N llegue a N2.

đ N : Tiempo de ejecución lineal. Un caso en el que N valga 40, tardará el doble que otro
en que N valga 20. Un ejemplo sería un algoritmo que lee N números enteros y devuelve
la media aritmética.

đ NÒlogN : El tiempo de ejecución es N·logN. Es común encontrarlo en algoritmos como


Quick Sort y otros del estilo divide y vencerás. Si N se duplica, el tiempo de ejecución es
ligeramente mayor del doble.

đ N : Tiempo de ejecución cuadrático. Suele ser habitual cuando se tratan pares de


elementos de datos, como por ejemplo un bucle anidado doble. Si N se duplica, el tiempo
de ejecución aumenta cuatro veces. El peor caso de entrada del algoritmo Quick Sort se
ejecuta en este tiempo.

đ N : Tiempo de ejecución cúbico. Como ejemplo se puede dar el de un bucle anidado


triple. Si N se duplica, el tiempo de ejecución se multiplica por ocho.

đ N : Tiempo de ejecución exponencial. No suelen ser muy útiles en la práctica por el


elevadísimo tiempo de ejecución. El problema de la mochila resuelto por un algoritmo de
fuerza bruta -simple vuelta atrás- es un ejemplo. Si N se duplica, el tiempo de ejecución
se eleva al cuadrado.

* Algoritmos polinomiales: aquellos que son proporcionales a Nk. Son en general factibles.
* Algoritmos exponenciales: aquellos que son proporcionales a kN. En general son infactibles
salvo un tamaño de entrada muy reducido.

˜ Notación ˘grande

En general, el tiempo de ejecución es proporcional, esto es, multiplica por una constante a alguno
de los tiempos de ejecución anteriormente propuestos, además de la suma de algunos términos
más pequeños. Así, un algoritmo cuyo tiempo de ejecución sea T = 3N2 + 6N se puede
considerar proporcional a N2. En este caso se diría que el algoritmo es del orden de N2, y se
escribe O(N2)
Los grafos definidos por matriz de adyacencia ocupan un espacio O(N2), siendo N el número de
vértices de éste.

La notación O-grande ignora los factores constantes, es decir, ignora si se hace una mejor o peor
implementación del algoritmo, además de ser independiente de los datos de entrada del
algoritmo. Es decir, la utilidad de aplicar esta notación a un algoritmo es encontrar un límite
superior del tiempo de ejecución, es decir, el peor caso.

A veces ocurre que no hay que prestar demasiada atención a esto. Conviene diferenciar entre el
peor caso y el esperado. Por ejemplo, el tiempo de ejecución del algoritmo Quick Sort es de
O(N2). Sin embargo, en la práctica este caso no se da casi nunca y la mayoría de los casos son
proporcionales a N·logN. Es por ello que se utiliza esta última expresión para este método de
ordenación.

Una definición rigurosa de esta notación es la siguiente:


ß 2# g(N) !
   O(f(N))   #  9      ;  ;    %
|g(N)| <= |c0·f(N)| , !
  N >= N0.

˜ Clasificación de Oroblemas

Los problemas matemáticos se pueden dividir en primera instancia en dos grupos:

* Problemas indecidibles: aquellos que no se pueden resolver mediante un algoritmo.


* Problemas decidibles: aquellos que cuentan al menos con un algoritmo para su cómputo.

Sin embargo, que un problema sea decidible no implica que se pueda encontrar su solución, pues
muchos problemas que disponen de algoritmos para su resolución son inabordables para un
computador por el elevado número de operaciones que hay que realizar para resolverlos. Esto
permite separar los problemas decidibles en dos:

* intratables: aquellos para los que no es factible obtener su solución.


* tratables: aquellos para los que existe al menos un algoritmo capaz de resolverlo en un tiempo
razonable.

Los problemas pueden clasificarse también atendiendo a su comOlejidad. Aquellos problemas


para los que se conoce un algoritmo polinómico que los resuelve se denominan clase P. Los
algoritmos que los resuelven son deterministas. Para otros problemas, sus mejores algoritmos
conocidos son no deterministas. Esta clase de problemas se denomina clase NP. Por tanto, los
problemas de la clase P son un subconjunto de los de la clase NP, pues sólo cuentan con una
alternativa en cada paso.
ORDENACION

Cuestiones generales

Su finalidad es organizar ciertos datos (normalmente arrays o ficheros) en un orden creciente o


decreciente mediante una regla prefijada (numérica, alfabética...). Atendiendo al tipo de
elemento que se quiera ordenar puede ser:

- Ordenación interna: Los datos se encuentran en memoria (ya sean arrays, listas, etc) y son de
acceso aleatorio o directo (se puede acceder a un determinado campo sin pasar por los
anteriores).

- Ordenación externa: Los datos están en un dispositivo de almacenamiento externo (ficheros) y


su ordenación es más lenta que la interna.

Ordenación interna

Los métodos de ordenación interna se aplican principalmente a arrays unidimensionales. Los


principales algoritmos de ordenación interna son:

elección: Este método consiste en buscar el elemento más pequeño del array y ponerlo en
primera posición; luego, entre los restantes, se busca el elemento más pequeño y se coloca en
segudo lugar, y así sucesivamente hasta colocar el último elemento. Por ejemplo, si tenemos el
array {40,21,4,9,10,35}, los pasos a seguir son:

{4,21,40,9,10,35} <-- Se coloca el 4, el más pequeño, en primera posición : se cambia el 4 por el


40.
{4,9,40,21,10,35} <-- Se coloca el 9, en segunda posición: se cambia el 9 por el 21.
{4,9,10,21,40,35} <-- Se coloca el 10, en tercera posición: se cambia el 10 por el 40.
{4,9,10,21,40,35} <-- Se coloca el 21, en tercera posición: ya está colocado.
{4,9,10,21,35,40} <-- Se coloca el 35, en tercera posición: se cambia el 35 por el 40.

Si el array tiene N elementos, el número de comprobaciones que hay que hacer es de N*(N-1)/2,
luego el tiempo de ejecución está en O(n2)

m #$&%
m m? /

@ #

 m m7&"m''


 ? m'  m?7&?''

m #$?%7#$ %
2m ? > 
  ?  ?—
/ #$m%2m  m  
#$m% #$ %mm m# 
#$ % /  m /mm—


Burbuja: Consiste en comparar pares de elementos adyacentes e intercambiarlos entre sí hasta


que estén todos ordenados. Con el array anterior, {40,21,4,9,10,35}:

Primera pasada:
{21,40,4,9,10,35} <-- Se cambia el 21 por el 40.
{21,4,40,9,10,35} <-- Se cambia el 40 por el 4.
{21,4,9,40,10,35} <-- Se cambia el 9 por el 40.
{21,4,9,10,40,35} <-- Se cambia el 40 por el 10.
{21,4,9,10,35,40} <-- Se cambia el 35 por el 40.

Segunda pasada:
{4,21,9,10,35,40} <-- Se cambia el 21 por el 4.
{4,9,21,10,35,40} <-- Se cambia el 9 por el 21.
{4,9,10,21,35,40} <-- Se cambia el 21 por el 10.

Ya están ordenados, pero para comprobarlo habría que acabar esta segunda comprobación y
hacer una tercera.

Si el array tiene N elementos, para estar seguro de que el array está ordenado, hay que hacer N-1
pasadas, por lo que habría que hacer (N-1)*(N-i-1) comparaciones, para cada i desde 1 hasta N-
1. El número de comparaciones es, por tanto, N(N-1)/2, lo que nos deja un tiempo de ejecución,
al igual que en la selección, en O(n2).

m #$&%
m m?/

@ #

 m m7&"m''
Q&"—

 ? ?7&"m"?''
3m&"m"—
 
m #$?'%7#$?%
2m ?' > ?
 
/ #$?'%2m  m  
#$?'% #$?%mm ?#?'
#$?% /  m /mm—




Inserción directa: En este método lo que se hace es tener una sublista ordenada de elementos
del array e ir insertando el resto en el lugar adecuado para que la sublista no pierda el orden. La
sublista ordenada se va haciendo cada vez mayor, de modo que al final la lista entera queda
ordenada. Para el ejemplo {40,21,4,9,10,35}, se tiene:
{40,21,4,9,10,35} <-- La primera sublista ordenada es {40}.

Insertamos el 21:
{40,40,4,9,10,35} <-- aux=21;
{21,40,4,9,10,35} <-- Ahora la sublista ordenada es {21,40}.

Insertamos el 4:
{21,40,40,9,10,35} <-- aux=4;
{21,21,40,9,10,35} <-- aux=4;
{4,21,40,9,10,35} <-- Ahora la sublista ordenada es {4,21,40}.

Insertamos el 9:
{4,21,40,40,10,35} <-- aux=9;
{4,21,21,40,10,35} <-- aux=9;
{4,9,21,40,10,35} <-- Ahora la sublista ordenada es {4,9,21,40}.

Insertamos el 10:
{4,9,21,40,40,35} <-- aux=10;
{4,9,21,21,40,35} <-- aux=10;
{4,9,10,21,40,35} <-- Ahora la sublista ordenada es {4,9,10,21,40}.

Y por último insertamos el 35:


{4,9,10,21,40,40} <-- aux=35;
{4,9,10,21,35,40} <-- El array está ordenado.

En el peor de los casos, el número de comparaciones que hay que realizar es de N*(N+1)/2-1, lo
que nos deja un tiempo de ejecución en O(n2). En el mejor caso (cuando la lista ya estaba
ordenada), el número de comparaciones es N-2. Todas ellas son falsas, con lo que no se produce
ningún intercambio. El tiempo de ejecución está en O(n).

El caso medio dependerá de cómo están inicialmente distribuidos los elementos. Vemos que
cuanto más ordenada esté inicialmente más se acerca a O(n) y cuanto más desordenada, más se
acerca a O(n2).

El peor caso es igual que en los métodos de burbuja y selección, pero el mejor caso es lineal,
algo que no ocurría en éstos, con lo que para ciertas entradas podemos tener ahorros en tiempo
de ejecución.

m #$&%
m m?/

@ #

 m m7&m''
m m  R  m—

2m  Sm m—
/ #$m%
 ? m"?0 ?""
2 mK 

  mmT  m—
m /0#$?%
2m  mmT 
 
#$?'% /C 
 P#mm  R—

m m K —
#$?'% #$?%

m ? "
m!mmm # !  

#$ % />mmT m mm—


Inserción binaria: Es el mismo método que la inserción directa, excepto que la búsqueda del
orden de un elemento en la sublista ordenada se realiza mediante una búsqueda binaria (ver
algoritmos de búsqueda), lo que en principio supone un ahorro de tiempo. No obstante, dado que
para la inserción sigue siendo necesario un desplazamiento de los elementos, el ahorro, en la
mayoría de los casos, no se produce, si bien hay compiladores que realizan optimizaciones que lo
hacen ligeramente más rápido.

hell: Es una mejora del método de inserción directa, utilizado cuando el array tiene un gran
número de elementos. En este método no se compara a cada elemento con el de su izquierda,
como en el de inserción, sino con el que está a un cierto número de lugares (llamado salto) a su
izquierda. Este salto es constante, y su valor inicial es N/2 (siendo N el número de elementos, y
siendo división entera). Se van dando pasadas hasta que en una pasada no se intercambie ningún
elemento de sitio. Entonces el salto se reduce a la mitad, y se vuelven a dar pasadas hasta que no
se intercambie ningún elemento, y así sucesivamente hasta que el salto vale 1.

Por ejemplo, lo pasos para ordenar el array {40,21,4,9,10,35} mediante el método de Shell
serían:

Salto=3:
Primera pasada:
{9,21,4,40,10,35} <-- se intercambian el 40 y el 9.
{9,10,4,40,21,35} <-- se intercambian el 21 y el 10.
Salto=1:
Primera pasada:
{9,4,10,40,21,35} <-- se intercambian el 10 y el 4.
{9,4,10,21,40,35} <-- se intercambian el 40 y el 21.
{9,4,10,21,35,40} <-- se intercambian el 35 y el 40.
Segunda pasada:
{4,9,10,21,35,40} <-- se intercambian el 4 y el 9.

Con sólo 6 intercambios se ha ordenado el array, cuando por inserción se necesitaban muchos
más.
m #$&%
m  m/m

  &);  )
=&)!—

  m  m; 
3m m  mR  
 
 m 
 m m7&m''
 
m #$m"%0#$m%
#mK  
 
/ #$m%  
#$m% #$m"%
#$m"% /
 m''#  m—




Ërdenación ráOida (quicksort): Este método se basa en la táctica "divide y vencerás" (ver
sección divide y vencerás), que consiste en ir subdividiendo el array en arrays más pequeños, y
ordenar éstos. Para hacer esta división, se toma un valor del array como pivote, y se mueven
todos los elementos menores que este pivote a su izquierda, y los mayores a su derecha. A
continuación se aplica el mismo método a cada una de las dos partes en las que queda dividido el
array.

Normalmente se toma como pivote el primer elemento de array, y se realizan dos búsquedas: una
de izquierda a derecha, buscando un elemento mayor que el pivote, y otra de derecha a izquierda,
buscando un elemento menor que el pivote. Cuando se han encontrado los dos, se intercambian,
y se sigue realizando la búsqueda hasta que las dos búsquedas se encuentran.Por ejemplo, para
dividir el array {21,40,4,9,10,35}, los pasos serían:

{21,40,4,9,10,35} <-- se toma como pivote el 21. La búsqueda de izquierda a derecha encuantra
el valor 40, mayor que pivote, y la búsqueda de derecha a izquierda encuentra el valor 10, menor
que el pivote. Se intercambian:
{21,10,4,9,40,35} <-- Si seguimos la búsqueda, la primera encuentra el valor 40, y la segunda el
valor 9, pero ya se han cruzado, así que paramos. Para terminar la división, se coloca el pivote en
su lugar (en el número encontrado por la segunda búsqueda, el 9, quedando:
{9,10,4,21,40,35} <-- Ahora tenemos dividido el array en dos arrays más pequeños: el {9,10,4}
y el {40,35}, y se repetiría el mismo proceso.

La implementación es claramente recursiva (ver recursividad), y suponiendo el pivote el primer


elemento del array, el programa sería:

Üm 7m—!0

m  m m m 


mm


@#

  # &"
C mT 


m  m #m m !


m m/mm: R>m:>m!
#?m: R>!m:>m—
m 0 !

 

 m ' !
Mm mm R>—
 
 m7 !44#$m%7 #$%m''
Cm R>
 0 44#$%0 #$%""
  R>
m m7
m ! :m  m
 
/ #$m%
#$m% #$%
#$% /

m! :m 
 P

m  "
2m  R>#>
 m K>S m Om
/ #$%8m mmT 
#$% #$%
#$% /

  #"
D m #—
  #'!
D   #—


En C hay una función que realiza esta ordenación sin tener que implementarla, llamada qsort
(incluida en stdlib.h):

qsort(nombre_array,número,tamaño,función);

donde nombre_array es el nombre del array a ordenar, número es el número de elementos del
array, tamaño indica el tamaño en bytes de cada elemento y función es un puntero a una función
que hay que implementar, que recibe dos elementos y devuelve 0 si son iguales, algo menor que
0 si el primero es menor que el segundo, y algo mayor que 0 si el segundo es menor que el
primero. Por ejemplo, el programa de antes sería:

Üm 7m—!0
Üm 7m —!0

m  m  m m


mm


@#

> #&m: #$ %
 m



m  m  m m


m  m 
7 m 


 "

m  m 
0 m 


 






Claramente, es mucho más cómodo usar qsort que implementar toda la función, pero hay que
tener mucho cuidado con el manejo de los punteros en la función, sobre todo si se está trabajando
con estructuras.

Intercalación: no es propiamente un método de ordenación, consiste en la unión de dos arrays


ordenados de modo que la unión esté también ordenada. Para ello, basta con recorrer los arrays
de izquierda a derecha e ir cogiendo el menor de los dos elementos, de forma que sólo aumenta
el contador del array del que sale el elemento siguiente para el array-suma. Si quisiéramos sumar
los arrays {1,2,4} y {3,5,6}, los pasos serían:

Inicialmente: i1=0, i2=0, is=0.


Primer elemento: mínimo entre 1 y 3 = 1. Suma={1}. i1=1, i2=0, is=1.
Segundo elemento: mínimo entre 2 y 3 = 2. Suma={1,2}. i1=2, i2=0, is=2.
Tercer elemento: mínimo entre 4 y 3 = 3. Suma={1,2,3}. i1=2, i2=1, is=3.
Cuarto elemento: mínimo entre 4 y 5 = 4. Suma={1,2,3,4}. i1=3, i2=1, is=4.
Como no quedan elementos del primer array, basta con poner los elementos que quedan del
segundo array en la suma:
Suma={1,2,3,4}+{5,6}={1,2,3,4,5,6}

m mm)m
m #$&%#)$&)%$&'&)%

 m m) m m7&44m)7&)m''
3m    m# m
#)

m #$m%7#)$m)%
2m # 
 
$m% #$m%mm:#—
m''

Cm #) 
 
$m% #)$m)%mm:#)—
m)''


 m7&m''m''
1Sm # m>
—
$m% #$m%
 m)7&)m)''m''
1Sm #) m>
—
$m% #)$m)%

Fusión: Consta de dos partes, una parte de intercalación de listas y otra de divide y vencerás.

- Primera parte: ¿Cómo intercalar dos listas ordenadas en una sola lista ordenada de forma
eficiente?

Suponemos que se tienen estas dos listas de enteros ordenadas ascendentemente:

lista : 1 -> 3 -> 5 -> 6 -> 8 -> 9


lista ): 0 -> 2 -> 6 -> 7 -> 10

Tras mezclarlas queda:

lista: 0 -> 1 -> 2 -> 3 -> 5 -> 6 -> 6 -> 7 -> 8 -> 9 -> 10

Esto se puede realizar mediante un único recorrido de cada lista, mediante dos punteros que
recorren cada una. En el ejemplo anterior se insertan en este orden -salvo los dos 6 que puede
variar según la implementación-: 0 (lista 2), el 1 (lista 1), el 2 (lista 2), el 3, 5 y 6 (lista 1), el 6 y
7 (lista 2), el 8 y 9 (lista 1), y por llegar al final de la lista 1, se introduce directamente todo lo
que quede de la lista 2, que es el 10.

En la siguiente implementación no se crea una nueva lista realmente, sólo se 2 los
enlaces destruyendo las dos listas y fusionándolas en una sola. Se emplea un centinela que
apunta a sí mismo y que contiene como clave el valor más grande posible. El último elemento de
cada lista apuntará al centinela, incluso si la lista está vacía.

m

m 
mm



m m 
 m   m
 m: m


 m "0m  m 
 m "0 &HB31A
———

mm mm)


mm m

m "07)"0
 m m  "0m
 m m )) )"0m
 m m
!m ;  m 44);  m 
 
m "07)"0
 
"0m  "0m

 
"0m )) )"0m

 "0m

m ;  m 
"0m 
m );  m 
"0m )
 m m


- Segunda parte: divide y vencerás. Se separa la lista original en dos trozos del mismo tamaño
(salvo listas de longitud impar) que se ordenan recursivamente, y una vez ordenados se fusionan
obteniendo una lista ordenada. Como todo algoritmo basado en divide y vencerás tiene un caso
base y un caso recursivo.

* á  : cuando la lista tiene 1 ó 0 elementos (0 se da si se trata de ordenar una lista vacía).
Se devuelve la lista tal cual está.

* á

: cuando la longitud de la lista es de al menos 2 elementos. Se divide la lista en
dos trozos del mismo tamaño que se ordenan recursivamente. Una vez ordenado cada trozo, se
fusionan y se devuelve la lista resultante.

El esquema es el siguiente:

D  m5

m mm
mS5   
5
mS50 )  
5 :5#5)—
5 D  5

5) D  5)

5 Lm  55)

5
m 

El algoritmo funciona y termina porque llega un momento en el que se obtienen listas de 2 ó 3


elementos que se dividen en dos listas de un elemento (1+1=2) y en dos listas de uno y dos
elementos (1+2=3, la lista de 2 elementos se volverá a dividir), respectivamente. Por tanto se
vuelve siempre de la recursión con listas ordenadas (pues tienen a lo sumo un elemento) que
hacen que el algoritmo de fusión reciba siempre listas ordenadas.

Se incluye un ejemplo explicativo donde cada sublista lleva una etiqueta identificativa.

Dada: 3 -> 2 -> 1 -> 6 -> 9 -> 0 -> 7 -> 4 -> 3 -> 8 (lista original)

pp  
*"0)"0"0I"0E m

"0U"0."0*"0V m)

W m m
W*"0)"0"0I"0E m

WWpp  
WW*"0)"0 m

WWI"0E m)

WW m m
WW*"0)"0 m

WWWpp  
WWW*"0) m

WWW m)

WWW m m
WWW*"0) m

WWWpp  
WWW* m> mmV  
—2
*
WWW) m)> mmV  
—2
)
WWWm  ")#>
WWW)"0*—2)"0*
WWW m)

WWW m)> mmV  
—2

WWWm  ")#>
WWW"0)"0* m
—2"0)"0*
WWI"0E m)

WWWpp  
WWWI m)> mmV  
—2I
WWWE m))> mmV  
—2E
WWWm  )"))#>
WWWI"0E m)
—2I"0E
WWm  ")#>
WW"0)"0*"0I"0E—2"0)"0*"0I"0E
W "0U"0."0*"0V m)

W———mmmm  "0*"0."0U
"0V
Wm  ")#>
W "0"0)"0*"0*"0."0I"0U"0V"0E>#
m —

La implementación propuesta emplea un centinela sobre la lista inicial que apunte hacia sí
mismo y que además contiene el máximo valor de un entero. La lista dispone de cabecera y
centinela, pero obsérvese como se elimina durante la ordenación.

Üm 7m —!0
Üm 7mm—!0

m

m 
mm

m  # m 
m

m 
 m 


 m m  
m m 

m m
mm mm)


mm m

m "07)"0
 m m  "0m
 m m )) )"0m
 m m
!m ;  m 44);  m 
 
m "07)"0
 
"0m  "0m

 
"0m )) )"0m

 "0m

m ;  m 
"0m 
m );  m 
"0m )
 m m


m mT mT m mm# K
m m m


m))

 T  
m "0m  m 
 


m
 :!mm
 ) "0m"0m
!m );  m 
 
 "0m
) )"0m"0m

 
) "0m
"0m  m 

 m 
  m 

)  m )


:#m:
 m )

 



m m
m588 m588


588"0   m
 m: m


588"0 m   m
 m: m


588"0 "0m 588"0 m 
588"0 m "0m 588"0 m 


m   m :m
mm Cm m588m 


m 

   m
 m: m


 "0 
 "0m 588— "0m
588— "0m  


m m m


m588
588 4588

 m  588— m 
 m "0 &HB31A

m Cm 588V

m Cm 588*

m Cm 588.

m Cm 588U

m Cm 588

m Cm 588E

m Cm 588I

m Cm 588

m Cm 588)

m Cm 588*

588—   m 588— "0m


  


Este es un buen algoritmo de ordenación, pues no requiere espacio para una nueva lista y sólo las
operaciones recursivas consumen algo de memoria. Es por tanto un algoritmo ideal para ordenar
listas.

La complejidad es la misma en todos los casos, ya que no influye cómo esté ordenada la lista
inicial -esto es, no existe ni mejor ni peor caso-, puesto que la intercalación de dos listas
ordenadas siempre se realiza de una única pasada. La complejidad es proporcional a N·logN,
característica de los algoritmos "Divide y Vencerás". Para hacer más eficiente el algoritmo es
mejor realizar un primer recorrido sobre toda la lista para contar el número de elementos y añadir
como parámetro a la función dicho número.
BUSQUEDA

Cuestiones generales

La búsqueda de un elemento dentro de un array es una de las operaciones más importantes en el


procesamiento de la información, y permite la recuperación de datos previamente almacenados.
El tipo de búsqueda se puede clasificar como interna o externa, según el lugar en el que esté
almacenada la información (en memoria o en dispositivos externos). Todos los algoritmos de
búsqueda tienen dos finalidades:

- Determinar si el elemento buscado se encuentra en el conjunto en el que se busca.


- Si el elemento está en el conjunto, hallar la posición en la que se encuentra.

En este apartado nos centramos en la búsqueda interna. Como principales algoritmos de


búsqueda en arrays tenemos la búsqueda secuencial, la binaria y la búsqueda utilizando tablas de
hash.

Búsqueda secuencial

Consiste en recorrer y examinar cada uno de los elementos del array hasta encontrar el o los
elementos buscados, o hasta que se han mirado todos los elementos del array.

for(i=j=0;i<N;i++)
if(array[i]==elemento)
{
solucion[j]=i;
j++;
}

Este algoritmo se puede optimizar cuando el array está ordenado, en cuyo caso la condición de
salida cambiaría a:

for(i=j=0;array[i]<=elemento;i++)

o cuando sólo interesa conocer la primera ocurrencia del elemento en el array:

for(i=0;i<N;i++)
if(array[i]==elemento)
break;

En este último caso, cuando sólo interesa la primera posición, se puede utilizar un centinela, esto
es, dar a la posición siguiente al último elemento de array el valor del elemento, para estar seguro
de que se encuentra el elemento, y no tener que comprobar a cada paso si seguimos buscando
dentro de los límites del array:
array[N]=elemento;
for(i=0;;i++)
if(array[i]==elemento)
break;

Si al acabar el bucle, i vale N es que no se encontraba el elemento. El número medio de


comparaciones que hay que hacer antes de encontrar el elemento buscado es de (N+1)/2.

Búsqueda binaria o dicotómica

Para utilizar este algoritmo, el array debe estar ordenado. La búsqueda binaria consiste en dividir
el array por su elemento medio en dos subarrays más pequeños, y comparar el elemento con el
del centro. Si coinciden, la búsqueda se termina. Si el elemento es menor, debe estar (si está) en
el primer subarray, y si es mayor está en el segundo. Por ejemplo, para buscar el elemento 3 en el
array {1,2,3,4,5,6,7,8,9} se realizarían los siguientes pasos:

Se toma el elemento central y se divide el array en dos:


{1,2,3,4}-5-{6,7,8,9}
Como el elemento buscado (3) es menor que el central (5), debe estar en el primer
subarray: {1,2,3,4}
Se vuelve a dividir el array en dos:
{1}-2-{3,4}
Como el elemento buscado es mayor que el central, debe estar en el segundo subarray:
{3,4}
Se vuelve a dividir en dos:
{}-3-{4}
Como el elemento buscado coincide con el central, lo hemos encontrado.

Si al final de la búsqueda todavía no lo hemos encontrado, y el subarray a dividir está vacio {},
el elemento no se encuentra en el array. La implementación sería:

m !m mm #


!m m Jm#>Km —
m #$&%

@ —

  ! &"7 !


m  !
m#Tm   
 
m #$%  
mmT 
mm —
m 
mm " K #—
 P2m —

m '!
)@mm# —
m #$m%  
2mm m  
 
mm mmT 
 P# —

m #$m%0 
m 
! m"m#m:>m—
#m#
 m'm#!—


En general, este método realiza log(2,N+1) comparaciones antes de encontrar el elemento, o


antes de descubrir que no está. Este número es muy inferior que el necesario para la búsqueda
lineal para casos grandes.

Este método también se puede implementar de forma recursiva, siendo la función recursiva la
que divide al array en dos más pequeños (ver recursividad).

Búsqueda mediante transformación de claves (hashing)

Es un método de búsqueda que aumenta la velocidad de búsqueda, pero que no requiere que los
elementos estén ordenados. Consiste en asignar a cada elemento un índice mediante una
transformación del elemento. Esta correspondencia se realiza mediante una función de
conversión, llamada función hash. La correspondencia más sencilla es la identidad, esto es, al
número 0 se le asigna el índice 0, al elemento 1 el índice 1, y así sucesivamente. Pero si los
números a almacenar son demasiado grandes esta función es inservible. Por ejemplo, se quiere
guardar en un array la información de los 1000 usuarios de una empresa, y se elige el núemro de
DNI como elemento identificativo. Es inviable hacer un array de 100.000.000 elementos, sobre
todo porque se desaprovecha demasiado espacio. Por eso, se realiza una transformación al
número de DNI para que nos de un número menor, por ejemplo coger las 3 últimas cifras para
guardar a los empleados en un array de 1000 elementos. Para buscar a uno de ellos, bastaría con
realizar la transformación a su DNI y ver si está o no en el array.

La función de hash ideal debería ser biyectiva, esto es, que a cada elemento le corresponda un
índice, y que a cada índice le corresponda un elemento, pero no siempre es fácil encontrar esa
función, e incluso a veces es inútil, ya que puedes no saber el número de elementos a almacenar.
La función de hash depende de cada problema y de cada finalidad, y se pueden utilizar con
números o cadenas, pero las más utilizadas son:

đ Restas sucesivas: esta función se emplea con claves numéricas entre las que existen
huecos de tamaño conocido, obteniéndose direcciones consecutivas. Por ejemplo, si el
número de expediente de un alumno universitario está formado por el año de entrada en
la universidad, seguido de un número identificativo de tres cifras, y suponiendo que
entran un máximo de 400 alumnos al año, se le asignarían las claves:
1998-000 --> 0 = 1998000-1998000
1998-001 --> 1 = 1998001-1998000
1998-002 --> 2 = 1998002-1998000
...
1998-399 --> 399 = 1998399-1998000
1999-000 --> 400 = 1999000-1998000+400
...
yyyy-nnn --> N = yyyynnn-1998000+(400*(yyyy-1998))

đ Aritmética modular: el índice de un número es resto de la división de ese número entre un


número N prefijado, preferentemente primo. Los números se guardarán en las direcciones
de memoria de 0 a N-1. Este método tiene el problema de que cuando hay N+1
elementos, al menos un índice es señalado por dos elementos (teorema del palomar). A
este fenómeno se le llama colisión, y es tratado más adelante. Si el número N es el 13, los
números siguientes quedan transformados en:

13000000 --> 0
12345678 --> 7
13602499 --> 1
71140205 --> 6
73062138 --> 6

đ Mitad del cuadrado: consiste en elevar al cuadrado la clave y coger las cifras centrales.
Este método también presenta problemas de colisión:

123*123=15129 --> 51
136*136=18496 --> 84
730*730=532900 --> 29
301*301=90601 --> 06
625*625=390625 --> 06

đ Truncamiento: consiste en ignorar parte del número y utilizar los elementos restantes
como índice. También se produce colisión. Por ejemplo, si un número de 8 cifras se debe
ordenar en un array de 1000 elementos, se pueden coger la primer, la tercer y la última
cifras para formar un nuevo número:

13000000 --> 100


12345678 --> 138
13602499 --> 169
71140205 --> 715
73162135 --> 715

đ Plegamiento: consiste en dividir el número en diferentes partes, y operar con ellas


(normalmente con suma o multiplicación). También se produce colisión. Por ejemplo, si
dividimos los número de 8 cifras en 3, 3 y 2 cifras y se suman, dará otro número de tres
cifras (y si no, se cogen las tres últimas cifras):
13000000 --> 130=130+000+00
12345678 --> 657=123+456+78
71140205 --> 118 --> 1118=711+402+05
13602499 --> 259=136+024+99
25000009 --> 259=250+000+09

Tratamiento de colisiones: Pero ahora se nos presenta el problema de qué hacer con las
colisiones, qué pasa cuando a dos elementos diferentes les corresponde el mismo índice. Pues
bien, hay tres posibles soluciones:

Cuando el índice correspondiente a un elemento ya está ocupada, se le asigna el primer índice


libre a partir de esa posición. Este método es poco eficaz, porque al nuevo elemento se le asigna
un índice que podrá estar ocupado por un elemento posterior a él, y la búsqueda se ralentiza, ya
que no se sabe la posición exacta del elemento.

También se pueden reservar unos cuantos lugares al final del array para alojar a las colisiones.
Este método también tiene un problema: ¿Cuánto espacio se debe reservar? Además, sigue la
lentitud de búsqueda si el elemento a buscar es una colisión.

Lo más efectivo es, en vez de crear un array de número, crear un array de punteros, donde cada
puntero señala el principio de una lista enlazada. Así, cada elemento que llega a un determinado
índice se pone en el último lugar de la lista de ese índice. El tiempo de búsqueda se reduce
considerablemente, y no hace falta poner restricciones al tamaño del array, ya que se pueden
añadir nodos dinámicamente a la lista (ver listas).
BACKTRAKING

* Introducción
* La vuelta del caballo
* El problema de las ocho reinas
* El problema de la mochila (selección óptima)
* Problemas propuestos

Introducción

Los algoritmos de vuelta atrás se utilizan para encontrar soluciones a un problema. No siguen
unas reglas para la búsqueda de la solución, simplemente una búsqueda sistemática, que más o
menos viene a significar que hay que probar todo lo posible hasta encontrar la solución o
encontrar que no existe solución al problema. Para conseguir este propósito, se separa la
búsqueda en varias búsquedas parciales o subtareas. Asimismo, estas subtareas suelen incluir
más subtareas, por lo que el tratamiento general de estos algoritmos es de naturaleza recursiva.

¿Por qué se llaman algoritmos de vuelta atrás?. Porque en el caso de no encontrar una solución
en una subtarea se retrocede a la subtarea original y se prueba otra cosa distinta (una nueva
subtarea distinta a las probadas anteriormente).

Puesto que a veces nos interesa conocer múltiples soluciones de un problema, estos algoritmos se
pueden modificar fácilmente para obtener una única solución (si existe) o todas las soluciones
posibles (si existe más de una) al problema dado.

Estos algoritmos se asemejan al recorrido en profundidad dentro de un grafo (ver sección de


grafos, estructuras de datos, y recorrido de grafos, algoritmos), siendo cada subtarea un nodo del
grafo. El caso es que el grafo no está definido de forma explícita (como lista o matriz de
adyacencia), sino de forma implícita, es decir, que se irá creando según avance el recorrido. A
menudo dicho grafo es un árbol, o no contiene ciclos, es decir, al buscar una solución es, en
general, imposible llegar a una misma solución ã partiendo de dos subtareas distintas a y b; o de
la subtarea a es imposible llegar a la subtaréa b y viceversa.
Gráficamente se puede ver así:
A menudo ocurre que el árbol o grafo que se genera es tan grande que encontrar una solución o
encontrar la mejor solución entre varias posibles es computacionalmente muy costoso. En estos
casos suelen aplicarse una serie de restricciones, de tal forma que se puedan !
algunas de las
ramas, es decir, no recorrer ciertas subtareas. Esto es posible si llegado a un punto se puede
demostrar que la solución que se obtendrá a partir de ese punto no será mejor que la mejor
solución obtenida hasta el momento. Si se hace correctamente, la poda no impide encontrar la
mejor solución.

A veces, es imposible demostrar que al hacer una poda no se esté ocultando una buena solución.
Sin embargo, el problema quizás no pida la mejor solución, sino una que sea razonablemente
buena y cuyo coste computacional sea bastante reducido. Esa es una buena razón para aumentar
las restricciones a la hora de recorrer un nodo. Tal vez se pierda la mejor solución, pero se
encontrará una aceptable en un tiempo reducido.

Los algoritmos de vuelta atrás tienen un esquema genérico, según se busque una o todas las
soluciones, y puede adaptarse fácilmente según las necesidades de cada problema. A
continuación se exponen estos esquemas, extraídos de Wirth (ver Bibliografía). Los bloques se
agrupan con begin y end, equivalentes a los corchetes de C, además están tabulados.

˜ esquema Oara una solución:

Orocedimiento ensayar (paso : TipoPaso)


reOetir
| seleccionar_candidato
| if aceptable then
| begin
| anotar_candidato
| if solucion_incompleta then
| begin
| ensayar(paso_siguiente)
| if no acertado then borrar_candidato
| end
| else begin
| anotar_solucion
| acertado <- cierto;
| end
hasta que (acertado = cierto) o (candidatos_agotados)
fin Orocedimiento

˜ esquema Oara todas las soluciones:

Orocedimiento ensayar (paso : TipoPaso)


Oara cada candidato hacer
| seleccionar candidato
| if aceptable then
| begin
| anotar_candidato
| if solucion_incompleta then
| ensayar(paso_siguiente)
| else
| almacenar_solucion
| borrar_candidato
| end
hasta que candidatos_agotados
fin Orocedimiento

Por último, se exponen una serie de problemas típicos que se pueden resolver fácilmente con las
técnicas de vuelta atrás. El primero que se expone es muy conocido. Se trata de la vuelta del
caballo. Muchos problemas de los pasatiempos de los periódicos pueden resolverse con la ayuda
de un ordenador y en esta web se muestran algunos de ellos.

La vuelta del caballo


Se dispone de un tablero rectangular, por ejemplo el tablero de ajedrez, y de un caballo, que se
mueve según las reglas de este juego. El objetivo es encontrar una manera de recorrer todo el
tablero partiendo de una casilla determinada, de tal forma que el caballo pase una sola vez por
cada casilla. Una variante es obligar al caballo a volver a la posición de partida en el último
movimiento.
Por último se estudiará como encontrar todas las soluciones posibles partiendo de una misma
casilla.

Para resolver el problema hay que realizar todos los movimientos posibles hasta que ya no se
pueda avanzar, en cuyo caso hay que dar marcha atrás, o bien hasta que se cubra el tablero.
Además, es necesario determinar la organización de los datos para implementar el algoritmo.

- ¿Cómo se mueve un caballo?. Para aquellos que no sepan jugar al ajedrez se muestra un gráfico
con los ocho movimientos que puede realizar. Estos movimientos serán los ocho candidatos.

Con las coordenadas en las que se encuentre el caballo y las ocho coordenadas relativas se
determina el siguiente movimiento. Las coordenas relativas se guardan en dos arrays:
ejex = [2, 1, -1, -2, -2, -1, 1, 2]
ejey = [1, 2, 2, 1, -1, -2, -2, -1]

El tablero, del tamaño que sea, se representará mediante un array bidimensional de números
enteros. A continuación se muestra un gráfico con un tablero de tamaño 5x5 con todo el
recorrido partiendo de la esquina superior izquierda.
Cuando se encuentra una solución, una variable que se pasa por referencia es puesta a 1 (cierto).
Puede hacerse una variable de alcance global y simplificar un poco el código, pero esto no
siempre es recomendable.

Para codificar el programa, es necesario considerar algunos aspectos más, entre otras cosas no
salirse de los límites del tablero y no pisar una casilla ya cubierta (selección del candidato). Se
determina que hay solución cuando ya no hay más casillas que recorrer.

A continuación se expone un código completo en C, que recubre un tablero cuadrado de lado N


partiendo de la posición (0,0).

Üm 7m—!0

Üm &(
Üm  &&

m m  $%$&%m mm B/m B#m >


 m ?/$V%  "")")"))
?#$V%  ")"))"")

m m m


m  $&%$&%  —
m m?>

m mmm: 
 m  m7&m''

 ?  ?7&?''

 $m%$?%  

 mmm 
 $ %$ % 
  )  4>


m >
 !#m —
 m  m7&m''
 
 ?  ?7&?''

m  +,*+ $m%$?%

! N- N




m  +- &/mm - +


  


m m  $%$&%m mm B/m B#m >


m P

P  
>  
 
 B/'?/$P% B#'?#$P%m  m
m 0  447&440  447&
  mmX

m  $%$% 
 mX
 $%$% m  m
m m7 
 m mX
  m'>

m ;>
 $%$%    m

> !#m 


P''
!m ;>44P7V




Cambiando el valor de  puede obtenerse una solución para un tablero cuadrado de tamaño N.

A continuación, se muestra una adaptación del procedimiento que muestra todas las soluciones.
Si se ejecuta para N = 5 se encuentra que hay 304 soluciones partiendo de la esquina superior
izquierda.
Cuando se encuentra una solución se llama a un procedimiento (no se ha codificado aquí) que
imprime todo el tablero.

void mover(int tablero[][N],int i, int pos_x, int pos_y)


{
int k, u, v;

for (k = 0; k < 8; k++) {


u = pos_x + ejex[k]; v = pos_y + ejey[k];
if (u >= 0 && u < N && v >= 0 && v < N) { /* esta dentro de los limites */
if (tablero[u][v] == 0) {
tablero[u][v] = i;
if (i < ncuad)
mover(tablero,i+1,u,v);
else imprimir_solucion(tablero);
tablero[u][v] = 0;
}
}
}
}

El problema de las ocho reinas


Continuamos con problemas relacionados con el ajedrez. El problema que ahora se plantea es
claramente, como se verá, de vuelta atrás. Se recomienda intentar resolverlo  

Se trata de colocar ocho reinas sobre un tablero de ajedrez, de tal forma que ninguna amenace
(pueda comerse) a otra. Para los que no sepan ajedrez deben saber que una reina amenaza a otra
pieza que esté en la misma columna, fila o cualquiera de las cuatro diagonales.

La dificultad que plantea este problema es la representación de los datos. Se puede utilizar un
array bidimensional de tamaño 8x8, pero las operaciones para encontrar una reina que amenace a
otra son algo engorrosas y hay un truco para evitarlas. La solución aquí expuesta vuelve a ser
tomada de Wirth (ver Bibliografía).

Es lógico que cada reina debe ir en una fila distinta. Por tanto, en un array se guarda la posición
de cada reina en la columna que se encuentre. Ejemplo: si en la tercera fila hay una reina situada
en la quinta columna, entonces la tercera posición del array guardará un 5. A este array se le
llamará col.
Hace falta otro array que determine si hay puesta una reina en la fila j-ésima. A este array se le
llamará fila.
Por último se utilizan dos arrays más para determinar las diagonales libres, y se llamarán diagb y
diagc.

Para poner una reina se utiliza esta instrucción:


col[i] = j ; fila[j] = diagb[i+j] = diagc[7+i-j] = FALSE;

Para quitar una reina esta otra:


fila[j] = diagb[i+j] = diagc[7+i-j] = TRUE;

Se considera válida la posición para este caso:


if (fila[j] && diagb[i+j] && diagc[7+i-j]) entonces proceder ...

A continuación se expone el código completo en C. Se han utilizado tipos enumerados para


representar los valores booleanos.

Üm 7m—!0

   L152=H96=
#    

m # m m  >m $%  m$%  m $%
 m$%


m m m


m m
  >

m $V%
  m$V%m $(%m$(%

 m  m7Vm''
m$m% H96=
 m  m7(m''
m $m% m$m% H96=

 # 4>mm m

m >
 
m  +- 2m +

 m  m7Vm''
m  +,+$m%

m  +- &!#m +


  


m # m m  >m $%  m$%  m $%
 m$%


m ?

?  
> L152=
 
m m$?%44m $m'?%44m$U'm"?%
 
$m% ?m$?% m $m'?% m$U'm"?% L152=
m m7U
   m X
 # m'>mm m

m ;>

m$?% m $m'?% m$U'm"?% H96=
> H96=  m 

?''
!m ;>44?7V




Por último, se deja al lector que implemente un procedimiento que encuentre todas las
soluciones. Si se desea complicar más entonces se puede pedir que encuentre todas las
soluciones distintas, es decir, aquellas que no sean rotaciones o inversiones de otras soluciones.

Ahora que se conoce el método general, puede hacerse extensible a múltiples piezas
simultáneamente.

El Problema de la mochila (selección óptima)

Con anterioridad se ha estudiado la posibilidad de encontrar una única solución a un problema y


la posibilidad de encontrarlas todas. Pues bien, ahora se trata de encontrar la mejor solución, la
solución óptima, de entre todas las soluciones.

Partiendo del esquema que genera todas las soluciones expuesto anteriormente se puede obtener
la mejor solución (la solución óptima, seleccionada entre todas las soluciones) si se modifica la
instrucción almacenar_solucion por esta otra:
si f(solucion) > f(optimo) entonces optimo <- solucion
siendo 2  función positiva, !  es la mejor solucion encontrada hasta el momento, y
 es una solucion que se está probando.

El problema de la mochila consiste en llenar una mochila con una serie de objetos que tienen una
serie de pesos con un valor asociado. Es decir, se dispone de  ! de objetos y que no hay un
número limitado de cada tipo de objeto (si fuera limitado no cambia mucho el problema). Cada
tipo  de objeto tiene un peso < positivo y un valor  positivo asociados. La mochila tiene una
capacidad de peso igual a =. Se trata de llenar la mochila de tal manera que se maãimice el
valor de los objetos incluidos pero respetando al mismo tiempo la restricción de capacidad.
Notar que no es obligatorio que una solución óptima llegue al límite de capacidad de la mochila.

EjemOlo: se supondrá:
n=4
W=8
w() = 2, 3, 4, 5
v() = 3, 5, 6, 10
Es decir, hay 4 tipos de objetos y la mochila tiene una capacidad de 8. Los pesos varían entre 2 y
5, y los valores relacionados varían entre 3 y 10.
Una solución no óptima de valor 12 se obtiene introduciendo cuatro objetos de peso 2, o 2 de
peso 4. Otra solución no óptima de valor 13 se obtiene introduciendo 2 objetos de peso 3 y 1
objeto de peso 2. ¿Cuál es la solución óptima?.

A continuación se muestra una solución al problema, variante del esquema para obtener todas las
soluciones.

m!m m mm m m m m




m P

 P mP7 P''
 
m $P%7 
 
!m P"$P%m '$P%m

m m '$P%0m
m m '$P%




Dicho procedimiento puede ser ejecutado de esta manera, siendo n, W, peso y valor variables
globales para simplificar el programa:

n = 4,
W = 8,
peso[] = {2,3,4,5},
valor[] = {3,5,6,10},
optimo = 0;
...
mochila(0, W, 0, &optimo);
Observar que la solución óptima se obtiene independientemente de la forma en que se ordenen
los objetos.

Problemas propuestos

˜ Caminos. OIE 97.


Enunciado - Solución
- Tom y Jerry (solamente el aOartado ). OIE 97.
Enunciado
˜ Buque. OIE 98.
Enunciado
˜ ellos (El correo del zar). OIE 99.
Enunciado
˜ El salto del caballo. OIE 2001.
Enunciado
˜ Islas en el mar. IOI 92.
Enunciado (en inglés: Islands in the sea)
˜ Números Orimos. IOI 94.
Enunciado (en inglés: The Primes) - Solución

SOLUCIONES Y ENUNCIADOS EN LA PAGINA:

http://www.algoritmia.net/articles.php?id=33

- Muchos otros Oroblemas requieren vuelta atrás, en general se trata de algoritmos


auãiliares Oara resolver una Oequeña tarea.

˜ También los algoritmos de vuelta atrás permiten la resolución del Oroblema del laberinto,
puesto que la vuelta atrás no es más que el recorrido sobre un grafo implícito finito o infinito. De
todas formas, los problemas de laberintos se resuelven mucho mejor mediante exploración en
anchura (ver grafos).


 
    È

Cuestiones generales

La técnica de diseño de algoritmos llamada "divide y vencerás" (divide and conquer) consiste en
descomponer el problema original en varios sub-problemas más sencillos, para luego resolver
éstos mediante un cálculo sencillo. Por último, se combinan los resultados de cada sub-problema
para obtener la solución del problema original. El pseudocódigo sería:

 m mmB#B B  




      K>S
m ! !
  P
 m  m 


Un ejemplo de "divide y vencerás" es la ordenación rápida, o quicksort, utilizada para ordenar


arrays. En ella, se dividía el array en dos sub-arrays, para luego resolver cada uno por separado,
y unirlos (ver algoritmos de ordenación). El ahorro de tiempo es grande: el tiempo necesario para
ordenar un array de elementos mediante el método de la burbuja es cuadrático: kN2. Si dividimos
el array en dos y ordenamos cada uno de ellos, el tiempo necesario para resolverlo es ahora
k(N/2)2+k(N/2)2=(kN2)/2. El tiempo necesario para ordenarlo es la mitad, pero sigue siendo
cuadrático.

Pero ahora, si los subproblemas son todavía demasiado grandes, ¿por qué no utilizar la misma
táctica con ellos, esto es, dividirlos a ellos también, utilizando un algoritmo recursivo (ver
recursividad) que vaya dividiendo más el sub-problema hasta que su solución sea trivial? Un
algoritmo del tipo:

 m mmB#B   




m mm
   
m mm
 
      K>S
m ! !
mmB#B    BP

 m  m 



Si aplicamos este método al quicksort, el tiempo disminuye hasta ser logarítmico, con lo que el
tiempo ahorrado es mayor cuanto más aumenta N.

Tiempo de ejecución
El tiempo de ejecución de un algoritmo de divide y vencerás, T(n), viene dado por la suma de
dos elementos:

Ä El tiempo que tarda en resolver los A subproblemas en los que se divide el original,
A·T(n/B), donde n/B es el tamaño de cada sub-problema.
Ä El tiempo necesario para combinar las soluciones de los sub-problemas para hallar la
solución del original; normalmente es O(nk)

Por tanto, el tiempo total es: T(n) = A·T(n/B) + O(nk). La solución de esta ecuación, si A es
mayor o igual que 1 y B es mayor que 1, es:

si A>Bk, T(n) = O(nlogBA)


si A=Bk, T(n) = O(nk·log n)
si A<Bk, T(n) = O(nk)

Determinación del umbral

Uno de los aspectos que hay que tener en cuenta en los algoritmos de divide y vencerás es dónde
colocar el umbral, esto es, cuándo se considera que un sub-problema es suficientemente pequeño
como para no tener que dividirlo para resolverlo. Normalmente esto es lo que hace que un
algoritmo de divide y vencerás sea efectivo o no. Por ejemplo, en el algoritmo de ordenación
quicksort, cuando se tiene un array de longitud 3, es mejor ordenarlo utilizando otro algoritmo de
ordenación (con 6 comparaciones se puede ordenar), ya que el quicksort debe dividirlo en dos
sub-arrays y ordenar cada uno de ellos, para lo que utiliza más de 6 comparaciones.

Problema de los puntos más cercanos

El problema es: "dado un conjunto de puntos P, hallar el par de puntos más cercanos". La
distancia entre dos puntos i y j es sqrt[(xi-xj)2+(yi-yj)2]. Una primera solución podría ser mirar
todos los pares de puntos y quedarse con el más pequeño; al haber n·(n-1)/2 pares de puntos, el
tiempo de ejecución es de O(n2). El programa resultante es muy corto, y rápido para casos
pequeños, pero al ser este procedimiento una búsqueda exhaustiva, debería haber un algoritmo
más rápido.

Supongamos que ordenamos los puntos según la coordenada x; esto supondría un tiempo de
O(n·log n), lo que es una cota inferior para el algoritmo completo. Ahora que se tiene el conjunto
ordenado, se puede trazar una línea vertical, x = xm, que divida al conjunto de puntos en dos: Pi y
Pd. Ahora, o el par más cercano está en Pi, o está en Pd, o un punto está en Pi y el otro en Pd. Si
los dos estuvieran en Pi o en Pd, se hallaría recursivamente, subdividiendo más el problema, por
lo que ahora el problema se reduce al tercer caso, un punto en cada zona.

Llamemos di, dd y dc a las mínimas distancias en el primer caso, en el segundo, y en el tercero,


respectivamente, y dmin al menor de di y dd. Para resolver el tercer caso, sólo hace falta mirar los
puntos cuya coordenada x esté entre xm-dmin y xm+dmin. Para grandes conjuntos de puntos
distribuidos uniformemente, el número de puntos que caen en esa franja es sqrt(n), así que con
una búsqueda exhaustiva el tiempo de ejecución sería de O(n), y tendríamos el problema
resuelto. El tiempo de ejecución sería, según lo dicho en el otro apartado, O(n·log n).

Pero si los puntos no están uniformemente distribuidos, la cosa cambia. En el peor de los casos,
todos los puntos están en la franja, así que la fuerza bruta no siempre funciona en tiempo lineal.
Para ello, se puede recurrir a ordenar los puntos de la franja según la coordenada y, lo que
supone un tiempo de ejecución de O(n·log n). Si la coordenada y difiere en más de dmin, se puede
pasar al siguiente punto. El tiempo de ejecución es O(max(n·log n,n·log n))=O(n·log n), con lo
que mantenemos el tiempo de ejecución anterior. El programa sería:
Üm 7m—!0
Üm 7m —!0
Üm 7m —!0
Üm 7!—!0

Üm 31A 5&RK/m —

 =  —

 /
 #


m   m 

m  /  m m

 m   


 )C K —
 m m  3J mm m—

m m


m  
 /#
 $31A%

   +,+4 
 7 ''
8 
 
  +,,+4/4#

$%—/ /
$%—# #


   
Qm R>—

m  +@m mm m,—*- +m m

m  +C  ,—*,—*
# ,—*,—*
- +—/—#)—/)—#






m   m  


 
m ! 

m 7 
2m !# 
 m—
D   /—
>  m:  
 /

3m   ? m:>m—
   )

3m   ? !—
  ' ) '
)


QJm ?  —
  )0 44$ )%—/"$%—/7m m""

 ! )!7 "44$!%—/"$ )%—/7m m!''


R>/!m  ?  —
  7 !''

 ' 7 ! ''

m  m $%$ %

7m m

 
m m 
—/ $ %—/
—# $ %—#
)—/ $%—/
)—# $%—#



L mT /mm>—
m  /  m m


   

—/7   

—/
X"



L mT >m m  —
 m   


 > —/" —/
 —/" —/
' —#" —#
 —#" —#




Hallar la mediana de un conjunto de puntos

La mediana es el elemento de un conjunto que cumple que en el conjunto hay el mismo número
de elemntos mayores que él y menores que él. Dicho de otra forma, si el conjunto está ordenado,
la mediana es el elemento central. Una forma de resolverlo es esa, ordenar el conjunto y localizar
el elemnto central. El tiempo de ejecución sería de O(n·log n), pero, ¿se podría resolver en
tiempo lineal utilizando una estrategia de divide y vencerás?

Hay un método llamado selección rápida que tiene un tiempo esperado lineal, pero que en
algunos casos puede tener tiempo O(n2). Este método no sólo puede hallar la mediana, sino el
elemento que ocupa la posición k-ésima. Para ello, se toma un elemento pivote (normalmente el
primero), y se divide el conjuntos en dos sub-conjuntos, según los elementos sean mayores o
menores que el pivote. El pivote ahora ocupará una posición p. Si p es igual a k, el número
buscado es el pivote; si p es menor que k, el número buscado está en el primer subconjunto, y si
p es mayor que k, el número buscado está en el segundo subconjunto. Este método se continua
recursivamente, hasta que se halle el elemento buscado. El programa sería:

Üm 7m—!0
Üm 7m —!0

m m m m m m 


m P

m m


m  
m #

  +,+4 
CmS#
# m 
 m: m 
 

  7 ''
@#
  +,+4#$%

  +,+4P
CmP
P""Cm  "

 m m #  "
C mT 

m  +,- +






m m m m #m m !


m m/mm: R>m:>m!
#?m: R>!m:>m—

 m ' !
Mm mm R>—
 
 m7 !44#$m%7 #$%m''
Cm R>
 0 44#$%0 #$%""
2  R>
m m7
m ! :
 
/ #$m%  m—
#$m% #$%
#$% /

m! :
 Pm —

m  "
2m  R>#
 >m 
K>S m Om—
/ #$%8m
#$% #$% mmT —
#$% /

m  P

 #$%
=m  
m 0P

 m m #"

 m#—

 m m #'!

  


#—


En esta solución en tiempo medio lineal se descartan sólo unos cuantos elementos cada vez que
se llama a la función recursiva, dependiendo del pivote que elijamos. Para descartar una fracción
constante de elementos (y mejorar el tiempo de ejecución del peor caso) habría que elegir un
mejor pivote, a ser preferible la mediana (cosa que no puede ser, porque es justamente ella la que
estamos buscando). Tampoco se puede perder mucho tiempo en buscar un buen pivote, porque
haría demasiado lento el programa. Una opción sería elegir tres elementos y utilizar como pivote
la mediana de esos tres, pero en el peor caso eso sigue siendo una mala opción. Hay un algoritmo
de elección del pivote llamado "partición con base en la mediana de la mediana de cinco", que
consiste en:

Ä Dividir los n elementos en n/5 grupos de 5 elementos (ignorando los últimos elementos).
Ä Encontrar la mediana de cada grupo de 5 elementos, lo que da una lista de n/5 medianas M.
Ä Hallar la mediana de M, o un buen pivote en M, lo que se puede hacer utilizando este
algoritmo recursivamente.

Se puede probar que, utilizando este método, y en el peor de los casos, cada llamada a la función
recursiva desprecia a más del 30% de los elementos. Esto hace que el algoritmo de selección
rápida sea lineal, a pesar de que utilizamos un algoritmo auxiliar para la búsqueda del pivote. El
problema completo sería:

Üm 7m—!0
Üm 7m —!0
Üm 7m —!0

m m m m m m m m 

m !m  m m m 


m m


m  P
m #

  +,+4 
CmS#
# m 
 m: m 
 

  7 ''
@#
  +,+4#$%

  +,+4P
CmP
P""

 m 
 m: m 
 


 m m #  "P
C mT 

m  +,- +






m m m m #m m m !m P


m m/mm: R>m:>m!
#?m: R>!m:>m—

Qm m
 m m7 !m''

$m"% m
 !m  #'!"'


/ #$%C m m mm—
#$% #$%
#$% /
 m ' !
Mm mm R>—
 
 m7 !44#$m%7 #$%m''
Cm R>
 0 44#$%0 #$%""
  R>
m m7
m ! :
 
/ #$m%  m—
#$m% #$%
#$% /

m! :
 Pm —

m  "
2m  R>#
 >m 
K>S m Om—
/ #$%8m
#$% #$% mmT —
#$% /

m  P

 #$%
=m  
m 0P

 m m #"P

 m
#—

 m m #'!P

  


#—


m !m  m #m m  


m #) 

m 7(
2m!# ( 
 $ %
m >
 mT J mm  m +#+
 mT 
m m>m: mT —

Qm m  —
#) m 
 m: m 
 (
'.


  '.7 ' (

 
# #)' (
#m: m 
(

  7( ''

  '7(''

m #)$ %0#)$%

 
 #)$ %
#)$ % #)$%
#)$% 
 $ %
$ % $%
$% 

#)$(% #$')%
$(% $')%


Qm m —
 !m  #) (

 #)

 



Multiplicación de matrices

Dadas dos matrices A y B de tamaño n x n, hallar C, el producto de las dos. Un primer algoritmo
se saca de la definición de producto de matrices: Ci j es el producto escalar de la i-ésima fila por
la j-ésima columna. Su tiempo de ejecución sería O(n3):

m m?P
m 1$ %$ %$ %$ %8$ %$ %

@1#—

 m m7 m''

 m m7 m''

 
8$m%$?% 
 m m7 m''

8$m%$?%' 1$m%$P%$P%$?%


Pero este tiempo de ejecución también se puede mejorar. Si n es una potencia de 2, se pueden
dividir A, B y C en cuatro submatrices cada una, de la forma:

Ahora, para hallar las submatrices de C, basta con utilizar que:

C1,1 = A1,1·B1,1 + A1,2·B2,1


C1,2 = A1,1·B1,2 + A1,2·B2,2
C2,1 = A2,1·B1,1 + A2,2·B2,1
C2,2 = A2,1·B1,2 + A2,2·B2,2
Con este método el tiempo de ejecución es de T(n) = 8·T(n/2) + O(n2), o lo que es lo mismo,
O(n3), con lo que no habría diferencia entre este método y el primero propuesto. Hay que reducir
el número de multiplicaciones por debajo de 8. Esto se logra utilizando el algoritmo de Strassen.
Definimos las siguientes matrices:

M1 = (A1,2 - A2,2)·(B2,1 + B2,2)


M2 = (A1,1 + A2,2)·(B1,1 + B2,2)
M3 = (A1,1 - A2,1)·(B1,1 + B2,1)
M4 = (A1,1 + A1,2)·B2,2
M5 = A1,1·(B1,2 - B2,2)
M6 = A2,2·(B2,1 - B1,1)
M7 = (A2,1 + A2,2)·B1,1

para las que se necesitan 7 multiplicaciones diferentes, y ahora se calculan las submatrices de C
utilizando sólo sumas:

C1,1 = M1 + M2 - M4 + M6
C1,2 = M4 + M5
C2,1 = M6 + M7
C2,2 = M2 - M3 + M5 - M7

El nuevo tiempo de ejecución es de T(n) = 7·T(n/2) + O(n2), es decir, de T(n) = O(nlog27) =


O(n2.81). Este algoritmo sólo es mejor que el directo cuando n es muy grande, y es más inestable
cuando se utiliza con números reales, por lo que tiene una aplicabilidad limitada.

Para el caso general en el que n no es potencia de 2, se pueden rellenar las matrices con ceros
hasta que las dos matrices sean cuadradas y n sea una potencia de 2.

El programa queda así:

Üm 7m—!0
Üm 7m —!0
Üm 7m —!0

m / m m 

m mmm: m m m 
L mT m>
mmm
m  m m m 
m
m  m m m 
9m
mm  m m 
5m m

m m


m )
m )) 

  +,,+44
HSmm:
  +,,+4)4)
HS m:
 / / / ))



  7 )
=Smmm
 )Pm   —

 m 
 m: m 

2mm:
  7''

 
$% m 
 m: m 


 $% m: m 



  7''
2 mm:—
  7 ''

  +,+4$%$ %


) m 
 m: m 

2 m:—
  7''

 
)$% m 
 m: m 


 )$% m: m 



  7)''
2  m:—
  7) ''

  +,+4)$%$ %


 mmm: )
2mm —

  7''
2mm—
 
  7) ''

m  +,+$%$ %

m  +- +







m / m m 


 0
X



m mmm: m m )m  


m 3$V%)//)
m 1$)%$)%$)%$)%8$)%$)%
m >

 m 
 m: m 
 

  7 ''

$% m 
 m: m 
 


m  

 
$ %$ % $ %$ %)$ %$ %
 


8 m1#—
 > >7)>''

 
  7)''

 
1$>%$% m 
 m: m 
 )


  7 )''

 
1$>%$%$% m 
 m: m 
 )


  7 )''

1$>%$%$%$% $' )
>%$' )
%


$>%$% m 
 m: m 
 )


  7 )''

 
$>%$%$% m 
 m: m 
 )


  7 )''

$>%$%$%$% )$' )
>%$' )
%



Qm3—
  1$ %$%1$%$% )

)  $%$ %$%$% )

3$% mmm: ) )

m   )

m  ) )


  1$ %$ %1$%$% )

)  $ %$ %$%$% )

3$)% mmm: ) )

m   )

m  ) )


  1$ %$ %1$%$ % )

)  $ %$ %$ %$% )

3$*% mmm: ) )

m   )

m  ) )


  1$ %$ %1$ %$% )

) $%$%
3$.% mmm: ) )

m   )


 1$%$%
)  $ %$%$%$% )

3$(% mmm: ) )

m  ) )


 1$%$%
)  $%$ %$ %$ % )

3$I% mmm: ) )

m  ) )


  1$%$ %1$%$% )

) $ %$ %
3$U% mmm: ) )

m   )


Q m8—

8$ %$ %  3$%3$)% )

/ 8$ %$ %
8$ %$ %  8$ %$ %3$.% )

/) 8$ %$ %
8$ %$ %  8$ %$ %3$I% )

m  / )

m  /) )


8$ %$%  3$.%3$(% )


8$%$ %  3$I%3$U% )


8$%$%  3$)%3$*% )

/ 8$%$%
8$%$%  8$%$%3$(% )

/) 8$%$%
8$%$%  8$%$%3$U% )

m  / )

m  /) )


  7 U''

m  3$% )

6 m mm8 —
 > >7 >''

  7 ''

$>%$% 8$> )
%$ )
%$>, )
%$, )
%
5m  m1#8—
 > >7)>''

  7)''

 
m  1$>%$% )

m  $>%$% )

m  8$>%$% )



 



m  m m )m  

#)—
m  
m 
 m 
 m: m 
 

  7 ''

 
$% m 
 m: m 
 

  7  ''

$%$ % $%$ %')$%$ %

 



m  m m )m  

9)—
m 
m  
 m 
 m: m 
 

  7 ''

 
$% m 
 m: m 
 

  7  ''

$%$ % $%$ %")$%$ %

 



mm  m m  


m 
  7 ''
5m  m Km)@—
 $%

 



Multiplicación de dos enteros

Este problema apareció en la OIE'2000 y su solución más efectiva es la que utiliza la técnica
"divide y vencerás".

El tamaño no es lo importante: Enunciado y Solución.

Ficheros

Multiplicación de matrices en C/C++: multmatrices.cpp

 ð  ð
  
 !"#$