Explora Libros electrónicos
Categorías
Explora Audiolibros
Categorías
Explora Revistas
Categorías
Explora Documentos
Categorías
PARCIAL
Árboles binarios y árboles Bayer, grafos, recursividad, pistas, colas y
listas enlazadas
Estructura de un nodo:
Cada elemento de un nodo interno actúa como un valor separador, que lo
divide en subárboles.
Los nodos internos de un árbol B, es decir los nodos que no son hoja,
usualmente se representan como un conjunto ordenado de elementos y punteros
a los hijos. Cada nodo interno contiene un máximo de U hijos y, con excepción del
nodo raíz, un mínimo de L hijos. Esta relación entre U y L implica que dos nodos que
están a medio llenar pueden juntarse para formar un nodo legal, y un nodo lleno
puede dividirse en dos nodos legales. Estas propiedades hacen posible que el árbol
B se ajuste para preservar sus propiedades ante la inserción y eliminación de
elementos.
Los nodos hoja tienen la misma restricción sobre el número de elementos,
pero no tienen hijos, y por tanto carecen de punteros. El nodo raíz tiene límite
superior de número de hijos, pero no tiene límite inferior. Algunos árboles
balanceados guardan valores sólo en los nodos hoja, y por lo tanto sus nodos
internos y nodos hoja son de diferente tipo. Los árboles B guardan valores en cada
nodo, y pueden utilizar la misma estructura para todos los nodos. Sin embargo,
como los nodos hoja no tienen hijos, una estructura especial para éstos mejora el
funcionamiento.
Terminología de grafos:
• Vértice: Nodo.
• Enlace: Conexión entre dos vértices (nodos).
• Adyacencia: Se dice que dos vértices son adyacentes si entre ellos hay un
enlace directo.
• Vecindad: Conjunto de vértices adyacentes a otro.
• Camino: Conjunto de vértices que hay que recorrer para llegar desde un
nodo origen hasta un nodo destino.
• Grafo conectado: Aquél que tiene camino directo entre todos los nodos.
• Grafo dirigido: Aquél cuyos enlaces son unidireccionales e indican hacia
donde están dirigidos.
• Gafo con pesos: Aquél cuyos enlaces tienen asociado un valor. En general en
este tipo de grafos no suele tener sentido que un nodo se apunte a sí mismo
porque el coste de este enlace sería nulo.
Tipos de grafos
• Grafo simple o simplemente grafo es aquel que acepta una sola arista
uniendo dos vértices cualesquiera. Esto es equivalente a decir que una arista
cualquiera es la única que une dos vértices específicos. Es la definición
estándar de un grafo.
• Multígrafo o pseudografo son grafos que aceptan más de una arista entre
dos vértices. Estas aristas se llaman múltiples o lazos (loops en inglés). Los
grafos simples son una subclase de esta categoría de grafos. También se les
llama grafos no-dirigido.
• Grafo etiquetado. Grafos en los cuales se ha añadido un peso a las aristas
(número entero generalmente) o un etiquetado a los vértices.
• Grafo aleatorio. Grafo cuyas aristas están asociadas a una probabilidad.
• Hipergrafo. Grafos en los cuales las aristas tienen más de dos extremos, es
decir, las aristas son incidentes a 3 o más vértices.
• Grafo infinito. Grafos con conjunto de vértices y aristas de cardinal infinito.
Representación de grafos en una 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.
int V, A;
int a[maxV][maxV];
void inicializar()
{
int i,x,y,p;
char v1,v2;
// Leer V y A
memset(a,0,sizeof(a));
for (i=1; i<=A; i++)
{
scanf("%c %c %d\n",&v1,&v2,&p);
x=v1-'A'; y=v2-'A';
a[x][y]=p; a[y][x]=p;
}
}
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).
struct nodo {
int v;
int p;
nodo *sig;
};
int V,A; // vértices y aristas del grafo
struct nodo *a[maxV], *z;
void inicializar() {
int i,x,y,peso;
char v1,v2;
struct nodo *t;
z=(struct nodo *)malloc(sizeof(struct nodo));
z->sig=z;
for (i=0; i<V; i++)
a[i]=z;
for (i=0; i<A; i++) {
scanf("%c %c %d\n",&v1,&v2,&peso);
x=v1-'A'; y=v2-'A';
t=(struct nodo *)malloc(sizeof(struct nodo));
t->v=y; t->p=peso; t->sig=a[x]; a[x]=t;
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
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.
int id=0;
int val[V];
void buscar()
{
int k;
for (k=1; k<=V; k++)
val[k]=0;
for (k=1; k<=V; k++)
if (val[k]==0) visitar(k);
}
Sustituyéndola por:
val[++id]=k;
Para el orden de exploración de ejemplo anterior los valores serían los siguientes:
val[1]=3;
val[2]=1;
val[3]=2;
val[4]=4;
Programación recursiva:
Crear una subrutina recursiva requiere principalmente la definición de un "caso
base", y entonces definir reglas para subdividir casos más complejos en el caso base. Para
una subrutina recursiva es esencial que, con cada llamada recursiva, el problema se
reduzca de forma que al final llegue al caso base.
Algunos expertos clasifican la recursión como "generativa" o bien "estructural". La
distinción se hace según de donde provengan los datos con los que trabaja la subrutina.
Si los datos proceden de una estructura de datos similar a una lista, entonces la subrutina
es "estructuralmente recursiva"; en caso contrario, es "generativamente recursiva".
Pseudocódigo:
función factorial:
input: entero n de forma que n >= 0
output: [n × (n-1) × (n-2) × … × 1]
1. if n es 0, return 1
2. else, return [ n × factorial(n-1) ]
end factorial
Implementación en C:
#include<iostream>
using namespace std;
int factorial(int n)
{
if(n > 1)
return n * factorial(n - 1);
else
return 1;
}
Pseudocódigo:
Función fib is:
input: entero n de forma que n >= 0
1. sí n es = 0, return 0
2. sí n es = 1, return 1
3. else, return [ fib(n-1) + fib(n-2) ]
end fib
Implementación en C:
#include<stdio.h>
#include<conio.h>
int main()
{
int first_number = 0, second_number = 1, third_number, i, number;
printf("Introduzca el número de la serie fibonacci:");
scanf("%d",&number);
printf("Serie Fibonacci para un número determinado:");
printf("\n%d %d", first_number, second_number); //Para imprimir
0y1
for(i = 2; i < number; ++i) //bucle comenzará a partir de 2 porque
hemos impreso 0 y 1 antes
{
third_number = first_number + second_number;
printf(" %d", third_number);
first_number = second_number;
second_number = third_number;
}
return 0;
}
4.1. Pilas:
Las pilas son estructuras de datos que tienes dos operaciones básicas: push (para
insertar un elemento) y pop (para extraer un elemento). Su característica fundamental es
que al extraer se obtiene siempre el último elemento que acaba de insertarse. Por esta
razón también se conocen como estructuras de datos LIFO (del inglés Last In First Out)
que quiere decir que el último que entra es el primero en salir. Una posible
implementación mediante listas enlazadas sería insertando y extrayendo siempre por el
principio de la lista. Gracias a las pilas es posible el uso de la recursividad (lo veremos en
detalle en el tema siguiente). La variable que llama al mismo procedimiento en el q está,
habrá que guardarla, así como el resto de las variables de la nueva llamada, para a la
vuelta de la recursividad ir sacándolas, esto es posible a la implementación de pilas.
Las pilas se utilizan en muchas aplicaciones que utilizamos con frecuencia. Por
ejemplo, la gestión de ventanas en Windows (cuando cerramos una ventana siempre
recuperamos la que teníamos detrás). Otro ejemplo es la evaluación general de cualquier
expresión matemática para evitar tener que calcular el número de variables temporales
que hacen falta. Por ejemplo:
3 + 4 * (8 – 2 * 5)
4.2. Colas
Las colas también son llamadas FIFO (First In First Out), que quiere decir “el
primero que entra es el primero que sale”.
Colas simples:
Se inserta por un sitio y se saca por otro, en el caso de la cola simple se inserta por
el final y se saca por el principio. Para gestionar este tipo de cola hay que recordar
siempre cual es el siguiente elemento que se va a leer y cual es el último elemento que
se ha introducido.
Colas circulares:
En las colas circulares se considera que después del último elemento se accede de
nuevo al primero. De esta forma se reutilizan las posiciones extraídas, el final de la cola
es a su vez el principio, creándose un circuito cerrado.
Lo que se ha hecho es insertar (5), sacar (1), e insertar (8). Se sabrá que una tabla
está llena cuando “rear” y “front” estén en una posición de diferencia.
El teclado de ordenador se comporta exactamente como una cola circular.
Para implementar las colas circulares mediante listas enlazadas se pone en el tipo
T_Lista los punteros front y rear.
Implementación de pilas en C:
#include <stdlib.h>
#include <stdio.h>
int main() {
Pila = NULL;
Push(&pila, 20);
Push(&pila, 10);
printf("%d, ", Pop(&pila));
Push(&pila, 40);
Push(&pila, 30);
printf("%d, ", Pop(&pila));
printf("%d, ", Pop(&pila));
Push(&pila, 90);
printf("%d, ", Pop(&pila));
printf("%d\n", Pop(&pila));
getchar();
return 0;
}
int main() {
pNodo primero = NULL, ultimo = NULL;
return 0;
}
Una lista enlazada o estructura ligada, es una estructura lineal que almacena
una colección de elementos generalmente llamados nodos, en donde cada nodo
puede almacenar datos y ligas a otros nodos. De esta manera los nodos pueden
localizarse en cualquier parte de la memoria, utilizando la referencia que lo
relaciona con otro nodo dentro de la estructura.
Las listas enlazadas son estructuras dinámicas que se utilizan para almacenar
datos que están cambiando constante mente. A diferencia de los vectores, las
estructuras dinámicas se expanden y se contraen haciéndolas más flexibles a la
hora de añadir o eliminar información.
• Clase auto referenciada: es una clase con al menos un campo cuyo tipo de
referencia es el nombre de la misma clase:
struct lista {
gint dato;
lista *siguiente;
};
En una lista enlazada, cada elemento apunta al siguiente excepto el
último que no tiene sucesor y el valor del enlace es null. Por ello los
elementos son registros que contienen el dato a almacenar y un enlace al
siguiente elemento. Los elementos de una lista suelen recibir también el
nombre de nodos de la lista.
• Nodo: es un objeto creado a partir de una clase auto referenciada.
• Campo de enlace: es la variable de instancia que contiene el tipo que
corresponde con el nombre de la clase.
• Enlace: es el contenido del campo de enlace, que hace referencia (guarda la
dirección) a otro nodo.
Para que esta estructura sea un TDA lista enlazada, debe tener unos operadores
asociados que permitan la manipulación de los datos que contiene. Los operadores
básicos de una lista enlazada son:
• Insertar: inserta un nodo con dato x en la lista, pudiendo realizarse esta
inserción al principio o final de la lista o bien en orden.
• Eliminar: elimina un nodo de la lista, puede ser según la posición o por el
dato.
• Buscar: busca un elemento en la lista.
• Localizar: obtiene la posición del nodo en la lista.
• Vaciar: borra todos los elementos de la lista.
GSList:
La definición de la estructura GSList o, lo que es lo mismo, un nodo de la lista, está
definido de la siguiente manera:
struct GSList {
gpointer data;
GSList *next;
};
• Representa el dato a almacenar. Se utiliza un puntero genérico por lo que
puede almacenar un puntero a cualquier tipo de dato o bien almacenar un
entero utilizando las macros de conversión de tipos.
• Se trata de un puntero al siguiente elemento de la lista.
Las dos funciones expuestas para la eliminación de nodos, si bien tienen una
definición prácticamente idéntica, el resultado obtenido es distinto. En el caso
de g_slist_remove, se eliminará el nodo que contenga el valor data. Si hay varios nodos
con el mismo valor, sólo se eliminará el primero. Si ningún nodo contiene ese valor, no se
realiza ningún cambio en el GSList. En el caso de g_slist_remove_all, se eliminan todos los
nodos de la lista que contengan el valor data y nos devuelve la nueva lista resultante de
la eliminación de los nodos.
Ejemplo: Elimina un elemento de la lista.
if (list2 != NULL) {
g_print ("\nEl dato %d será eliminado de la lista.\n", list2-
>data);
Obtener el último
GSList* g_slist_last (GSList *list)
nodo.
Obtener el
g_slist_next (slist)
siguiente nodo.
Obtener un nodo
GSList* g_slist_nth (GSList *list, guint n)
por su posición.
Obtener el dato
de un nodo según su gpointer g_slist_nth_data (GSList *list, guint n)
posición.
gint i = 0;
Sobre este TDA se definen los mismos operadores básicos que en las listas simples.
CONCLUSIÓN
Un árbol binario es una estructura de datos en la cual cada nodo puede tener un
hijo izquierdo y un hijo derecho. En un árbol binario cada nodo puede tener cero, uno o
dos hijos (subárboles). No pueden tener más de dos hijos (de ahí el nombre "binario"). Un
árbol Bayer o B-árbol es un árbol de búsqueda que puede estar vacío o aquel cuyos nodos
pueden tener varios hijos, existiendo una relación de orden entre ellos. Los grafos no son
más que la versión general de un árbol, es decir, cualquier nodo de un grafo puede
apuntar a cualquier otro nodo de éste (incluso a él mismo). Podemos definir los grafos
como un conjunto de vértices o nodos unidos por enlaces llamados aristas o arcos, que
permiten representar relaciones binarias entre elementos de un conjunto.