Apuntes para el curso de “Estructuras de datos en C/C++ ” Dr. Abdiel E. C´aceres Gonz´alez ITESM-CCM
2 de junio de 2005
Resumen Una estructura de datos es una manera de almacenar y organizar datos para facilitar el acceso y modificaciones. No hay una estructura de datos que sirva para todos los prop´ositos, y por eso es importante saber sus ventajas y desventajas. Este documento es una colecci´on de apuntes para el curso de Estructuras de Datos. Los apuntes se han tomado de algunas fuentes que son detalladas en la secci´on de bibliograf´ıa.
´ Indice 1.
Preliminares de programaci´on en C/C++
1.1. Arreglos
3 3
1.2. Apuntadores
10
1.3. Estructuras C/C++
15
1.4. Ejercicios de programaci´on
19
2.
21
La pila
2.1. Definici´on y ejemplos
21
2.2. Operaciones b´asicas
24
2.3. Ejemplo: N´ umero de par´entesis
25
2.4. La estructura de datos Pila en C/C++
26
2.5. La representaci´on en C/C++ de las operaciones de una pila
27
2.6. Problemas de programaci´on
29
1
3.
Colas
31
3.1. Estructura de las colas en C/C++
32
3.2. Colas con prioridad
33
3.3. Ejercicio de programaci´on
34
4.
36
Recursi´on
4.1. Peligros en la recursividad
39
4.2. Ejercicios de programaci´on
40
5.
Listas
42
5.1. Grafos
42
5.2. Listas simplemente encadenadas
44
5.3. El uso de memoria din´amica en C/C++
51
5.4. Listas ligadas usando memoria din´amica
54
5.5. Ejercicios de programaci´on
56
6.
´ Arboles
57
6.1. Concepto general de ´arbol
57
´ 6.2. Arboles binarios
57
6.3. Representaci´on en C/C++ de los ´arboles binarios
64
´ 6.4. Arboles
66
6.5. Ejercicios de programaci´on
69
7.
71
Grafos
7.1. Recordatorio de las definiciones
71
7.2. Aplicaci´on ejemplo
73
2
1.
Preliminares de programaci´ on en C/C++
En esta secci´on recordaremos tres temas de programaci´on en C/C++ que son fundamentales para estudiar estructuras de datos; estos temas son los arreglos, los registros y los punteros. Los tres temas han sido tomados fundamentalmente de [MP97]
1.1.
Arreglos
Definici´ on 1 Un arreglo se compone de elementos de igual tama˜ no almacenados linealmente en posiciones de memoria consecutiva. Se puede acceder a cada elemento de datos individual utilizando un sub´ındice, o ´ındice, para seleccionar uno de los elementos. En C/C++ , un arreglo no es un tipo de datos est´andar; es un tipo agregado compuesto de cualquier otro tipo de datos. Los arreglos se pueden definir usando tipos de datos mixtos debido a que se supone que todos los elementos son del mismo tama˜ no. Puesto que todos los elementos son del mismo tama˜ no y ya que este hecho se utiliza para ayudar a determinar c´omo localizar un elemento dado, resulta que los elementos son almacenados en localidades de memoria contiguas. Lo m´as importante a tener en cuenta es: El nombre de un arreglo es visto por el compilador como un puntero-constante al primer elemento del arreglo. Esto es muy importante: a) El nombre del arreglo es visto como un tipo puntero, y m´as espec´ıficamente, b) un puntero constante -significa una direcci´on de memoria bloqueada para el primer elemento de un arreglo-. Por ejemplo, aunque una declaraci´on de arreglo toma la f´orma gen´erica: Tipo_ElementoArray NombreArray [ NumeroDeElementos ] El compilador ve la declaraci´on como Tipo_ElementoArray * const NombreArray = &NombreArray[0]; Por esta raz´on, un identificador de arreglo no puede ser usado nunca como un valor-i (valor izquierdo). Los valores izquierdos representan variables que su contenido puede ser alterado por el programa; frecuentemente aparecen a la izquierda de las sentencias de asignaci´on. Si los nombres de arreglo fueran variables izquierdos permitidos, el programa podr´ıa cambiar sus contenidos. 3
float SalariosDeEmpleados[Max_empleados]; . . . SalariosDeEmpleados = 45739.0; El efecto har´ıa cambiar la direcci´on inicial del propio arreglo.
1.1.1.
Declaraciones de un arreglo
La sintaxis de declaraci´on de arreglos es: tipo nombre_arreglo [numero_de_elementos]; Los siguientes son dos ejemplos de declaraciones de arreglos v´alidas en C/C++ : int CoordenadasDePantalla[5]; /*Un arreglo de 5 enteros */ char IDCompania[20]; /*Un arreglo de 20 caracteres */
Figura 1. Arreglo CoordenadasDePantalla con ´ındices de desplazamiento v´alido
En la figura 1 se muestra el primer arreglo que fue declarado con el tipo de n´ umeros enteros, llamado CoordenadasDePantalla, ocupa en memoria 5 localidades de memoria contiguas, cada una de ellas capaz de almacenar un n´ umero entero. Actualmente es com´ un que los n´ umeros enteros sean de 32 bits, esto hace que el arreglo CoordenadasDePantalla ocupe 32 × 5 = 160 bits No se permite utilizar nombres de variables dentro de los corchetes. Por esto no es posible evitar la especificaci´on del tama˜ no del arreglo hasta la ejecuci´on del programa. La expresi´on debe ser un valor constante, para que el compilador sepa exactamente cu´anto espacio de memoria tiene que reservar para el arreglo. Una buena pr´actica de programaci´on es usar constantes predefinidas. 4
#define Coordenadas_Max 20 #define Tamano_MaX_Compania_Id 15 int CoordenadasDePantalla[Coordenadas_Max]; char IDCompania[Tamano_MaX_Compania_Id]; El uso de constantes predefinidas garantiza que futuras referencias al arreglo no excedan el tama˜ no del arreglo definido.
1.1.2.
Iniciaci´on del arreglo
C/C++ proporciona 3 maneras de iniciar elementos del arreglo: Por defecto: Cuando son creados, se aplica solamente a arreglos globales y est´aticos. Expl´ıcita: Cuando son creados, suministrando datos de iniciaci´on Tiempo de ejecuci´ on: Durante la ejecuci´on del programa cuando se asignan o copias datos en el arreglo.
1.1.3.
Acceso a los elementos de un arreglo
Si se tiene un error cuando se utilizan arreglos en C/C++ , de seguro el error involucra el acceso a los elementos del arreglo, por la simple raz´on de que el primer elemento est´a en una posici´on 0, no 1. De manera que el u ´ltimo elemento del arreglo lo encontramos en n-1, donde n es el n´ umero de elementos. Supongamos la siguiente declaraci´on: int Estado[Rango_Maximo_Estado]={-1,0,1}; La siguiente sentencia tiene acceso a -1: Estado[0]; Si escribimos Estado[3] causar´a un error porque no hay 4 elementos.
1.1.4.
C´alculo del tama˜ no de un arreglo (sizeof())
Es frecuente utilizar el operador sizeof() para calcular la cantidad de espacio que se necesita almacenar para un objeto: /* * exploresz.cpp */ #include
5
#define maxDiasSemana 7 int main(void){ int desplazamiento, maxHorasDiarias[maxDiasSemana]; cout<<"sizeof(int) es"<<(int)sizeof(int)<<"\n\n"; for(desplazamiento=0;desplazamiento
1.1.5.
Arreglos multidimensionales
El t´ermino dimensi´ on representa el n´ umero de ´ındices utilizados para referirse a un elemento particular en el arreglo. Los arreglos de m´as de una dimensi´on se llaman arreglos multidimensionales. /* / dosDim.cpp */ #include #define numFilas 4 #define numColumnas 5 int main (int argc, char * const argv[]) { int despFila, despColumna, desplazamiento, multiplo, despCalculados[numFilas][numColumnas]; for(despFila=0;despFila
for(despColumna=0;despColumna
Fila actual: 0 Distancia relativa desde la base: 0 1 2 3 4 Fila actual: 1 Distancia relativa desde la base: 5 6 7 8 9 Fila actual: 2 Distancia relativa desde la base: 10 11 12 13 14 Fila actual: 3 Distancia relativa desde la base: 15 16 17 18 19 dosdim has exited with status 0. 7
1.1.6.
Arreglos como argumentos de funciones
Es necesario recordar tres cosas al pasar arreglos como par´ametros de funciones: 1. Todos los arreglos son pasados en llamada-por referencia. 2. Debido a que el arreglo es pasado en llamada por referencia, ser´ıa incorrecto para la funci´on llamada devolver el arreglo en una sentencia return();. Esta sentencia est´a de m´as. 3. Todos los elementos del arreglo son pasados a las funciones en llamada por valor. lo que significa que se pasa una copia del elemento, no la direcci´on del elemento. /* // ereArray.xcode */ #include #include #define maxArray 5 void ArrayMayuscula(char Array[maxArray]); int main (int argc, char * const argv[]) { int desplazamiento; char Array[maxArray]= {’a’,’e’,’i’,’o’,’u’}; for(desplazamiento=0;desplazamiento
La salida del programa demuestra que el arreglo se pasa en llamada por referencia, ya que el primer ciclo for da como salida los contenidos de min´ usculas originales: aeiou, mientras que el segundo ciclo for en main() da como salida los contenidos del arreglo despu´es del llamado a la funci´on ArrayMayuscula(): AEIOU. Claramente, dentro del cuerpo de la funci´on ArrayMayuscula(), ha cambiado el arreglo de regreso en la funci´on main(). el siguiente ejemplo es una simple modificaci´on de este algoritmo, s´olo que en vez de pasar el arreglo completo, se pasa cada elemento individual: /* // ereArray2.xcode */
#include #include #define maxArray 5 void ElementosArrayMayuscula(char unChar); int main (int argc, char * const argv[]) { int desplazamiento; char Array[maxArray]= {’a’,’e’,’i’,’o’,’u’}; for(desplazamiento=0;desplazamiento
aeiou aeiou valarray has exited with status 0.
1.2.
Apuntadores
Definici´ on 2 Un apuntador es una variable que contiene una direcci´ on de memoria. Supongamos una variable de tipo entero que se llama contenidoRAM y otra variable que se llama direccionRAM que puede contener una variable de tipo entero. En C/C++ una variable precedida del operador & devuelve la direcci´on de la variable en lugar de su contenido. As´ı que para asignar la direcci´on de una variable a otra variable del tipo que contiene direcciones se usan sentencias como esta: direccionRam = &contenidoRAM
Figura 2. contenidoRAM se asigna a la localidad de memoria con direcci´on 7751
En la figura 2 se ilustra el nombre de la variable contenidoRAM y se observa que se encuentra en la direcci´on 7751 de la memoria. El contenido de esta localidad no se muestra. Una variable que contiene una direcci´on, tal como direccionRAM, se llama variable apuntador o simplemente apuntador. Despues que la sentencia anterior se ejecuta, la direcci´on de contenidoRAM ser´a asignada a la variable apuntador direccionRAM. La relaci´on se expresa diciendo que direccionRAM apunta a contenidoRAM. La figura 3 ilustra esta relaci´on. El accceso al contenido de una celda cuya direcci´on est´a almacenada en la variable direccionRAM es tan sencillo como poner al inicio de la variable apuntador un asterisco: *direccionRAM. Lo que se ha hecho es eliminar la referencia directa. Por ejemplo, si se ejecutan las siguientes dos sentencias, el valor de la celda llamada contenidoRAM ser´a de 20 (v´ease la figura 4). 10
Figura 3. Notaci´on de flecha para los apuntadores
direccionRAM = &contenidoRAM; *direccionRAM = 20;
Figura 4. A contenidoRAM se le asigna el valor entero 20
1.2.1.
Declaraciones de variables apuntador
C/C++ requiere una definici´on para cada variable. Para definir una variable apuntador direccionRAM que pueda contener la direcci´on de una variable int, se escribe: int *direccionRAM; Realmente existen dos partes separadas en esta declaraci´on. El tipo de dato de direccionRAM es: int * y el identificador para la variable es direccionRAM El asterisco que sigue a int significa “apuntador a”. Esto es, el siguiente tipo de dato es una variable apuntador que puede contener una direcci´on a un int: int * 11
En C/C++ una variable apuntador contiene la direcci´on de un tipo de dato particular: char *direccion_char; char *direccion_int; El tipo de dato de direccion char es diferente del tipo de dato de la variable apuntador direccion int. En un programa que define un apuntador a un tipo de dato y utliza ´este para apuntar a otro tipo de dato, pueden ocurrir errores en tiempo de ejecuci´on y advertencias en tiempo de compilaci´on. Una pr´actica de programaci´on pobre ser´ıa definir un apuntador de una forma y luego utilizar ´este de alguna otra forma. Por ejemplo: int *direccion_int; float un_float = 98.34; direccion_int = &un_float;
1.2.2.
Utilizaci´on de punteros en sentencias sencillas
Veamos el siguiente ejemplo: /* // changeVals.xcode */ (01) #include (02) (03) int main (int argc, char * const argv[]) { (04) int A_int=15, B_int=37, Temp_int; (05) int *direccion_int; (06) (07) std::cout<<"El contenido de A_int es:"<
En la l´ınea (04) se han declarado tres variables de tipo entero, se da a cada celda un nombre y se inicializan 2 de ´estas. Supondremos que la direcci´on de memoria asignada para la variable A int es la direcci´on 5328, y la direcci´on en memoria RAM asignada para la variable B int es la direcci´on 7916, y la celda llamada Temp int se le ha asignado la direcci´on 2385. V´ease la figura 5;
Figura 5. Descripci´on de las tres variables en la memoria
En la l´ınea (05) se define un apuntador a un tipo de dato entero llamado direccion int. La sentencia asigna la celda y da a ´esta un nombre. Luego, en la l´ınea (09), la tercera sentencia asigna a direccion_int la direcci´on de A_int (figura 6).
Figura 6. direccion int dada la direcci´on de A int
La l´ınea (10) utiliza la expresi´on *direccion_int para acceder al contenido de la celda a la cual apunta direccion_int: Temp_int = *direccion_int; Por consiguiente, el valor entero 15 se almacena en la variable Temp_int. Si no se pone el * enfrente de direccion_int;, la sentencia de asignaci´on almacenar´ıa ilegalmente el contenido de direccion_int en la celda nombrada Temp_int, pero se supone que Temp_int contiene un entero, no una direcci´on. 13
Este puede ser un error muy dif´ıcil de localizar puesto que muchos compiladores no emiten ninguna advertencia/error. Para empeorar el asunto, la mayor´ıa de los apuntadores son cercanos, lo que significa que ocupan 2 bytes (4 bytes para aplicaciones de 32-bits), el mismo tama˜ no que un entero en una PC. La sentencia (11) copia el contenido de la variable B int en la celda apuntada por la direcci´on almacenada en direccion int(figura 7): *direccion_int = B_int;
Figura 7. Se copia el contenido de B int usando la notaci´on de flecha de apuntadores
La u ´ltima sentencia en la l´ınea (12) simplemente copia el contenido de una variable entera, Temp int en otra variable entera B int (figura 8
Figura 8. Se copia Temp int en B int utilizando asignaci´on normal.
Debemos de asegurarnos de comprender la diferencia entre qu´e se referencia cuando una variable puntero est´a precedida por el operador de indirecci´on y cu´ando no est´a precedida por este operador. Para este ejemplo, la primera sintaxis es un apuntador a una celda que puede contener un valor entero. La segunda sintaxis referencia la celda que contiene la direcci´on de otra celda que puede contener un entero. 14
1.2.3.
Utilizaci´on incorrecta del operador de direcci´ on
No se puede utilizar el operador de direcci´on sobre toda expresi´on C/C++ . El siguiente ejemplo demuestra aquellas situaciones donde no se puede aplicar el operador de direcci´on &. puedeAlmacenarDireccionDeConstante = &37; int RAM_int = 5; puedeAlmacenarDireccionDeExpresionTemp = &(RAM_int +15); puedeAlmacenarDireccionDeRegistro = &varRegistro; La primera sentencia trata de obtener ilegalmente la direcci´on de un valor constante integrado. La sentencia no tiene sentido puesto que 37 no tiene una celda de memoria asociada con ´este. La segunda sentencia de asignaci´on intenta devolver la direcci´on de la expresi´on RAM_int+15. No existe direcci´on asociada con la expresi´on puesto que la expresi´on en s´ı misma es realmente un proceso de manipulaci´on de pila. Normalmente, el u ´ltimo ejemplo respeta la demanda del programador para definir varRegistro como un registro m´as que como una celda de almacenamiento en la memoria interna. Por consiguiente, no podr´ıa devolverse y almacenarse la direcci´on de celda de memoria. El compilador C/C++ da la memoria de variable, no el almacenamiento de registro.
1.3.
Estructuras C/C++
Definici´ on 3 Una estructura es un grupo de variables las cuales pueden ser de diferentes tipos sostenidas o mantenidas juntas en una sola unidad. La unidad es la estructura.
1.3.1.
Sintaxis y reglas para estructuras en C/C++
En C/C++ se forma una estructura utilizando la palabra reservada struct, seguida por un campo etiqueta opcional, y luego una lista de miembros dentro de la estructura. La etiqueta opcional se utiliza para crear otras variables del tipo particular de la estructura: struct campo_etiqueta{ tipo_miembro miembro_1; tipo_miembro miembro_2; 15
tipo_miembro miembro_3; : : tipo_miembro miembro_n; }; Un punto y coma finaliza la definici´on de una estructura puesto que ´esta es realmente una sentencia C/C++ . Algunos de los ejemplos usan la estructura: struct stbarco{ char sztipo[iString15+iNull_char]; char szmodelo[iString15+iNull_char]; char sztitular[iString20+iNull_char]; int ianio; long int lhoras_motor; float fprecioventa; }; En un programa, podemos asociar una variable con una estructura utilizando una sentencia similar a la siguiente: struct stbarco stbarco_usado; La sentencia define stbarco_usado de tipo struct stbarco. La declaraci´on requiere el uso del campo etiqueta de la estructura. Si esta sentencia est´a contenida dentro de una funci´on, entonces la estructura, llamada stbarco_usado, tiene un ´ambito local a esa funci´on. Si la sentencia est´a contenida fuera de todas las funciones de programa, la estructura tendr´a un ´ambito global. Es posible declarar una variable usando esta sintaxis: struct stbarco{ char sztipo[iString15+iNull_char]; char szmodelo[iString15+iNull_char]; char sztitular[iString20+iNull_char]; int ianio; long int lhoras_motor; float fprecioventa; } stbarco_usado; Aqu´ı la declaraci´on de variable va antes del punto y coma final. Cuando se asocia s´olo una variable con el tipo estructura, el campo etiqueta puede ser eliminado, por lo que ser´ıa posible escribir: struct { char sztipo[iString15+iNull_char]; char szmodelo[iString15+iNull_char]; 16
char sztitular[iString20+iNull_char]; int ianio; long int lhoras_motor; float fprecioventa; } stbarco_usado;
1.3.2.
Utilizaci´on de miembros de estructuras
Para accesar a los miembros de las estructuras se usa el punto u operador miembro (.). La sintaxis es: estructuraNombre.miembroNombre Por ejemplo en: gets(stbarco_usado.szmodelo); Aqu´ı, stbarco_usado es el nombre asociado con la estructura, y szmodelo es una variable miembro de la estructura, otro ejemplo: std::cin>> stbarco_usado.sztipo; Esta sentencia leer´a la marca del stbarco_usado en el arreglo de caracteres, mientras la pr´oxima sentencia imprimir´a el precio de venta de stbarco_usado en la pantalla. srd::cout<< stbarco_usado.fprecioventa; Ejemplo de estructuras: /* fractionStruct.cpp Programa para demostrar el uso de los tipos Struct en C++, este tipo de datos es util para los programadores para crear sus propias estructuras de tipos. */ #include using namespace std; // Definimos un nuevo tipo de estructura llamada Fraction // como la definicion se puso antes del "main" // los tipos Fraction se pueden usar como prototipos 17
struct Fraction { // declaramos sus dos miembros int numerator; int denominator; }; // Note el punto y coma al final // funciones prototipos void getFraction(Fraction &f); void printFraction(const Fraction &f); int main (int argc, char * const argv[]) { // declaramos variables de tipo Fraction Fraction f1, f2; // obtenemos dos fracciones y las desplegamos getFraction(f1); cout << "\nf1 = "; printFraction(f1); getFraction(f2); cout << "\nf2 = "; printFraction(f2); cout << endl; return 0; } // pedimos al usuario los valores del denominador y numerador // los almacenamos en su adecuado lugar en la estrcututra; checamos si // el valor del denominador es valido y lo ponemos en 1 si no lo es. void getFraction(Fraction &f) { cout << "\nEnter the numerator: "; cin >> f.numerator; cout << "Enter the denominator: "; cin >> f.denominator; if (f.denominator == 0) { cout << "\nIllegal denominator! Denominator is being set to 1.\n"; f.denominator = 1; } } // imprimimos la fraccion void printFraction(const Fraction &f) { cout << f.numerator << "/" 18
<< f.denominator << "\n"; }
Nota sobre las funciones prototipos: Las funciones prototipo tienen los siguientes usos importantes: Establecen el tipo devuelto para las funciones que devuelven otros tipos diferentes que int. Aunque las funciones que devuelven valores enteris no necesitan prototipos, se recomienda tener prototipos. Sin prototipos completos, se hacen las conversiones est´andares, pero no se checan los tipos o los n´ umeros de argumentos con el n´ umero de par´ametros. Los prototipos se usan para inicializar apuntadores a funciones, antes de que las funciones sean definidas. La lista de par´ametros se usa para checar la correspondencia de los argumentos en la llamada a la funci´on con los par´ametros en la definici´on de la funci´on const en parmetros de funciones El especificador const puede ser utilizado en la definici´on de par´ametros de funciones. Esto resulta de especial utilidad en tres casos. En los tres el fin que se persigue es el mismo: indicar que la funci´ on no podr´ a cambiar dichos argumentos: Con par´ametros de funciones que sean de tipo matriz (que se pasan por referencia). Ejemplo: int strlen(const char[]); Cuando los par´ametros son punteros (a fin de que desde dentro de la funci´on no puedan ser modificados los objetos referenciados). Ejemplo: int printf (const char *format, ...); Cuando el argumento de la funci´on sea una referencia, previniendo as´ı que la funci´on pueda modificar el valor referenciado. Ejemplo: int dimen(const X &x2);
1.4.
Ejercicios de programaci´on
1. El siguiente algoritmo es el m´etodo de inserci´on para ordenar elementos en un arreglo: insertionSort(A) for j:=2 to length[A] do key:=A[j] -> Inserta el elemento A[j] -> en la secuencia ordenada A[1..j-1] i:=j-1 19
while i>0 and A[i]>key do A[i+1]=A[i] i:=i-1 A[i+1]:=key a) desarrolle un programa en C/C++ del m´etodo de inserci´on b) ilustre c´omo opera el algoritmo insertionSort(A) usando como entrada el arreglo A=<31,41,59,26,41,58> 2. Reescriba el programa y n´ombrelo insertionSortNondec para que ordene los elementos en orden decreciente 3. Considere el siguiente problema de b´ usqueda: Input: Una secuencia de n n´ umeros A = ha1 , a2 , . . . , an i y un valor v. Output: Un ´ındice i tal que v = A[i] o el valor espacial N IL si v no ocurre en A. Escriba un programa que resuelva este problema de b´ usqueda. 4. Considere el problema de sumar dos n´ umeros binarios de longitud n. Cada n´ umero se almacena en uno de los arreglos A y B de tama˜ no n. La suma se almacena en un arreglo C de tama˜ no n + 1, tambi´en como un n´ umero binario. Escriba un programa que resuelva este problema.
20
2.
La pila
Uno de los conceptos m´as u ´tiles en las ciencias de la computaci´on es el de pila. En esta secci´on vamos a definir este concepto de manera abstracta y veremos c´omo se usa para convertirse en una herramienta concreta y de gran valor en las soluciones de problemas. La informaci´on contenida en esta secci´on se ha tomado de [TA83].
2.1.
Definici´on y ejemplos
Definici´ on 4 Una pila (stack) es una colecci´ on ordenada de elementos en la cual se pueden insertar nuevos elementos por un extremo y se pueden retirar otros por el mismo extremo; ese estremos se llama “la parte superior” de la pila. Si tenemos un par de elementos en la pila, uno de ellos debe estar en la parte superior de la pila, que se considera “el m´as alto” en la pila que el otro. En la figura 9 el elemento F es el m´as alto de todos los elementos que est´an en la pila. El elemento D es el m´as alto de los elementos A,B,C, pero es menor que los elementos E y F.
Figura 9. Pila con 6 elementos
Para describir c´omo funciona esta estructura, debemos agregar un nuevo elemento, el elemento G. Despu´es de haber agregado el elemento G a la pila, la nueva configuraci´on es la que se muestra en la figura 10. De acuerdo con la definici´on, existe solamente un lugar en donde cualquier elemento puede ser agregado a la pila. Despu´es de haber insertado el nuevo elemento, G ahora es el elemento en la cima. Debedos aclarar en qu´e pila deseamos insertar elementos, puesto que es posible tener m´as de una pila al mismo tiempo. 21
Figura 10. Operaci´on de insertar el elemento G en la pila P
Cuando se desea retirar un elemento de la pila, solo basta ordenar que sea retirado un elemento; no podemos decir “retira C de la pila”, porque C no est´a en la cima de la pila y solamente podemos retirar el elemento que est´a en la cima. Para que la sentencia “retira C de la pila” tenga sentido, debemos replantear las ´ordenes a algo como: Retira de la pila hasta que el elemento retirado sea C. Ni siquiera es necesario decir: “Retira un elemento de la pila...” porque es sobreentendido que solamente se puede sacar un elemento a la vez. Siguiendo nuestro ejemplo, ahora deseamos retirar de la pila P. La configuraci´on global de la pila es como se muestra en la figura 11
Figura 11. Operaci´on de retirar de la pila P
El concepto de pila es muy importante en computaci´on y en especial en teor´ıa de lenguajes de programaci´on. En lenguajes procedurales como Pascal o C, la pila es una estructura indispensable, debido a las llamadas a funci´on. Resulta que el flujo de instrucciones va de arriba hacia abajo, y cuando ocurre una llamada a alguna funci´on, el estado global del sistema se almacena en un registro y ´este en una pila. As´ı que la pila va a contenr todas las llamadas a procedimientos que se hagan. 22
Cuando se termina de ejecutar alg´ un procedimiento, se recupera el registro que est´a en la cima de la pila. En ese registro est´an los valores de las variables como estaban antes de la llamada a la funci´on, o algunas pueden haber cambiado si valor, dependiendo del ´ambito de las variables. Cada elemento en la pila que es retirado, significa que se ha terminado de ejecutar alguna funci´on. Cuando se termina de ejecutar el programa, la pila de llamadas a subprogramas debe haber quedado en 0 tambi´en, de otro modo podr´ıa causar algun tipo de error. Esto nos lleva a pensar en otras utilidades de la pila. La pila sirve para encontrar errores. La din´amica de la pila, es decir, la manera en c´omo entran los datos a la estructura de datos y c´omo salen, se denomina fifo, que viene del ing´es first in first out (primero en entrar, primero en salir).
Figura 12. Din´amica de la pila P
En la figura 12 se muestran “fotograf´ıas” en distintos momentos de la pila, cuando se desea insertar H justo debajo de F. Para hacer esto se requiere, retirar tantos elementos como sean necesarios, aqu´ı se han retirado de la cima G y F para luego insertar H, que quedar´a posteriormente debajo de F. Lo que sucede es que, cuando se retira el elemento G se debe hacer una evaluaci´on para determinar si el elemento retirado es el elemento objetivo, en este caso el elemento objetivo es F, puesto que se desea insertar un elemento debajo de F. Despu´es de haber insertado F, insertamos de nuevo los elementos F y G en ese orden, adem´as de insertar finalmente el elemento I que queda en la cima de la pila. Enseguida veremos con m´as detalle las operaciones b´asicas de las pilas. 23
2.2.
Operaciones b´asicas
Las operaciones b´asicas de una pila son: 1. 2. 3. 4.
En la pila S, insertar un elemento e: push(S,e), Retirar un elemento de la pila S: pop(S), Verificar si la pila S est´a vac´ıa: stackempty(S) y Saber cu´al es el elemento en la cima de la pila S: stacktop(S).
enseguida cada una de estas operaciones:
2.2.1.
La operaci´on push
Esta operaci´on sirve para insertar un elemento e en la pila S, lo vamos a escribir como: push(S,e) Despu´es de hacer esta operaci´on sucede que: El elemento en la cima de la pila S ahora es e
2.2.2.
La operaci´on pop
Para retirar un elemento de la pila S y asignarlo a una variable del mismo tipo que el tipo de los elementos de la pila, usaremos la operaci´on pop escribi´endola como: v=pop(S); En donde v es una variable que almacena el valor del elemento que estaba en la cima de S. Hacer esta operaci´on tiene algunas implicaciones: La variable v debe ser del mismo tipo que los elementos almacenados en la pila. Solamente se puede retirar un elemento de la pila a la vez. Antes de la operaci´on, e era el elemento en la cima, ahora ya no lo es m´as. El apuntador “cima” decrece en una unidad.
2.2.3.
La operaci´on stackempty
Esta operaci´on toma como argumento una estructura del tipo stack (pila) y devuelve un valor booleano, devuelve un true si la pila est´a vac´ıa y devuelve 24
un false si la pila tiene al menos un elemento, es decir:
stackempty(S) =
true si S tiene 0 elementos f alse si S tiene m´ as de 0 elementos
2.2.4.
La operaci´on stacktop
La operaci´on stacktop(S) devuelve el valor del elemento en la cima de la pila S. Para hacer esta operaci´on escribiremos: v=stacktop(S) las implicaciones de usar esta operaci´on son: Se hace una copia del elemento que est´a en la cima En realidad se hacen dos operaciones, primero se hace v=pop(S), luego un push(S,v), porque despu´es de la operaci´on stacktop, la pila S queda sin cambio alguno.
2.3.
Ejemplo: N´ umero de par´entesis
Supongamos ahora la expresi´on ((5+6)*4)/(17+9), una de las condiciones para que sea una expresi´on aritm´etica correcta en que tengas sus par´entesis balanceados, as´ı que deseamos saber si el n´ umero de par´entesis que abres es el mismo n´ umero de par´entesis que cierran. Para resolver este problema usaremos el concepto de pila. La idea es simple. Vamos a leer cada elemento de la expresi´on, si se trata de un par´entesis que abre, entonces lo insertaremos en una pila; si se trata de un par´entesis que cierra, entonces sacamos un elemento de la pila. Al terminar de leer la expresi´on revisaremos si la pila est´a vac´ıa, en cuyo caso habremos conclu´ıdo que el n´ umero de par´entesis que abre es el mismo que el n´ umero de par´entesis que cierra y la expresi´on tiene par´entesis balanceados. Veamos c´omo funciona: ‘(’ ‘(’ ‘5’ ‘+’ ‘6’ ‘)’
: : : : : :
push(S,‘(’) push(S,‘(’) nada que hacer nada que hacer nada que hacer v=pop(S) 25
‘*’ : ‘4’ : ‘)’ : ‘/’ : ‘(’ : ‘17’: ‘+’ : ‘9’ : ‘)’ :
nada que hacer nada que hacer v=pop(S) nada que hacer push(S,‘(’) nada que hacer nada que hacer nada que hacer v=pop(S)
Empezamos con un contador iniciado en 0, y por cada push aumentamos un contador, y por cada pop decrementamos el contador. Al final vemos el valor del contador, si el contador=0 entonces terminamos con ´exito, de otro mod se˜ nalamos el error. En la figura 13 se muestra la actividad de la pila a medida que se van agregando y quitando elementos.
Figura 13. Evaluaci´on del balance de par´entesis en una expresi´on aritm´etica
2.4.
La estructura de datos Pila en C/C++
Una pila est´a conformada por dos elementos: Un espacio suficientemente grande para almacenar los elementos insertados en la pila Una parte que nos se˜ nale cu´al es el elemento en la cima de la pila. Estas partes las conformamos en una estructura, descrita como sigue: definir numero maximo de elementos en la pila definir nuevo tipo estructura llamado "stack" con item : un arreglo de 1 a maximos elementos enteros top : un numero de 0 a maximos elementos 26
fin de la nueva estructura F´acilmente podemos describir un c´odigo en C/C++ que represente lo anteriormente propuesto. // En la parte de definiciones #define maxElem 100 // En la parte de tipos struct stack { int item[maxElem]; int top; }; // En la parte de variables struct stack A;
2.5.
La representaci´on en C/C++ de las operaciones de una pila
En esta secci´on veremos una implementaci´on de las cuatro operaciones b´asicas de las pilas. Todas estas operaciones se han hecho desde un punto de vista de programaci´on funcional, sin duda se pueden describir en un modelo orientado a objetos.
2.5.1.
La operaci´on push
El siguiente segmento de c´odigo ilustra c´omo se puede implementar la operaci´on insertar un elemento en una pila. Hemos supuesto que la pila ya est´a definida como una estructura stack. (1) void push(struct stack *S,int e){ (2) S->top++; (3) S->item[S->top]=e; (4) } En la l´ınea (1) se observa que la operaci´on push recibe dos par´ametros: la direcci´on de una estructura de tipo pila y un elemento de tipo entero. La l´ınea (2) incrementa el tope (cima) de la pila en una unidad, con el fin de agregar el elemento en una posici´on libre de la pila, lo cual se logra en la l´ınea (3), asignando el valor e en la casilla S->top del arreglo item de la pila. 27
2.5.2.
La operaci´on pop
La operaci´on pop se escribe en forma de c´odigo en C/C++ con la siguiente secuencia de ´ordenes: (1) int pop(struct stack *S){ (2) int valReturn; (3) (4) valReturn=S->item[S->top]; (5) S->top--; (6) return valReturn; (7) } La l´ınea (1) describe que esta funci´on devuelve un tipo entero, el tipo de elementos guardados en la pila; luego notamos que debemos dar s´olo la direcci´on de alguna variable de tipo estructura de pila (struct stack *). Obtener la direcci´on se logra con el operador de indirecci´on (&). Las l´ıneas (4) y (5) hacen todo el trabajo de esta funci´on, se almacena el valor que ser´a devuelto en una variable de tipo entero y luego se decrementa el tope de la pila.
2.5.3.
La operaci´on stackempty
La operaci´on stackempty se describe en el siguiente segmento de c´odigo: (1) bool stackempty(struct stack *S){ (2) bool valorDevuelto; (3) if(S->top== -1) (4) valorDevuelto=true; (5) else (6) valorDevuelto=false; (7) return valorDevuelto; (8) } El encabezado de la funci´on que se muestra en la l´ınea (1) establece que se devuelve un valor booleano, y que se debe dar un par´ametro, que es la direcci´on de una localidad de memoria que almacena una estructura de tipo pila. El objetivo de esta funci´on es claro: La l´ınea (3) establece la verdacidad o falsedad del predicado (S->top==-1), determinando si el nivel del tope es igual que -1, en cuyo caso devuelve un verdadero (4), de otro modo ha de devolver un valor falso (6). Se ha establecido un -1 como vac´ıo porque el manejo de arreglos en C/C++ empieza en el ´ındice 0, que a diferencia de otros lenguajes como Pascal, empiezan en 1. 28
2.5.4.
La operaci´on stacktop
Este es un caso especial porque no se requiere hacer ning´ un c´odigo. Esta funci´on debe devolver un n´ umero entero y dejar la pila sin cambio. Para lograr esto se debe hacer un pop(&A), mostrar el elemento y luego insertar de nuevo el elemento en la pila haciendo un push(&A,elemento), notemos que se han usado los operadores de direcci´on para dar la direcci´on de la variable que alberga una estructura de tipo pila. El siguiente segmento de c´odigo ilustra c´omo se han usado las funciones antes creadas, por supuesto que se pueden separar y crear una nueva funci´on que haga lo mismo: ... (1) case 4:{ (2) if(not stackempty(&A)){ (3) valor=pop(&A); (4) std::cout<<"La cima de la pila es: "<
2.6.
Problemas de programaci´on
Los siguientes ejercicios deben ser resueltos en un progr´ama (en C/C++ ): 1. Expresiones entrefijas y prefijas. Las expresiones aritm´eticas pueden representarse de varias maneras, una de ellas, la m´as usual es la notaci´on entrefija. La notaci´on entrefija establece que en medio de dos operandos se escribe un operador, como por ejemplos: a) a ∗ b, donde los operandos son a y b, y el operador es el s´ımbolo ∗; b) 2 + 5 ∗ ((5 + 7)/4) Donde el par´entesis m´as interno establece la mayor prioridad, de manera que primero se debe evaluar (5 + 7), luego (12/4), luego 2 + (5 ∗ 3) y fimalmente (2 + 15), dando como resultado 17. c) −1 No hay nada que hacer, pues es un operador unario. En las expresiones prefijas se establece que el orden de escritura debe ser, primero el operador y luego la lista de operandos: a) ∗ab, donde los operandos son a y b, y el operador es el s´ımbolo ∗; b) +2 ∗ 5/ + 574 Lo primero que hay que hacer es tomar el primer 29
operador y tomar los operandos necesarios siguientes (dos si se trata de un operador binario y uno si es un operador unario). En este caso se trata de evaluar 2 + [∗5/ + 574]. Cada uno de los operandos debe ser tratado de nuevo como una expresion en prefijo, de manera que se repite lo anterior, tomar el operador y la lista de sus operandos y tratar cada uno de sus operandos como expresiones en prefijo: 2 + [5 ∗ [/ + 574]], luego 2 + [5 ∗ [[+57]/4]] y finalmente 2 + [5 ∗ [[5 + 7]/4]] y evaluar. Los par´entesis cuadrados son para ilustrar el ejemplo y no son necesarios para su evaluaci´on. c) −1 No hay nada que hacer, pues es un operador unario. Haga un programa en C/C++ que transforme expresiones de entrefijo a prefijo, y de prefijo a entrefijo. Los caracteres v´alidos son: las letras may´ usculas y min´ usculas, los n´ umeros enteros, los par´entesis normales, los cuatro operadores (+, −, ∗, /, ) y el operador unario (−).
Figura 14. Ilustraci´on del estacionamiento mencionado en el problema 2
2. en cierto punto de la ciudad hay un estacionamiento como el que se muestra en la figura 14, en donde hay lugar para 9 veh´ıculos. haga un programa que muestre el manejo de este estacionamiento, considerando los siguientes requisitos: a) Los veh´ıculos proporcionan la siguiente informaci´on: Placas (6 digitos), Estado (2-3 caracteres, p.e. SON, DF, CHI, YUC), Marca, Modelo, A˜ no-Modelo, Nombre del propietario. b) Al llegar un ve´ıculo se acepta solamente si hay lugar disponible. c) Validar todas las operaciones de la pila. d ) En cualquier momento se puede sacar alg´ un veh´ıculo del estacionamiento, regresando los veh´ıculos en el orden en que estaban. e) Toda la corrida del programa debe hacerse hacia/desde la terminal est´andar. 3. Haga un programa que implemente 2 pilas en 1 arreglo A[1..n] de manera que ninguna pila se desborde a menos que el n´ umero de elementos en ambas pilas sea n
30
3.
Colas
Definici´ on 5 Las colas son una estructura de datos similar a las pilas. Recordemos que las pilas funcionan en un dep´ osito en donde se insertan y se retiran elementos por el mismo extremo. En las colas sucede algo diferente, se insertan elementos por un extremo y se retiran elementos por el otro extremo. De hecho a este tipo de dispositivos se les conoce como dispositivos “fifo” (first in, first out) porque funcionan como una tuber´ıa, lo que entra primero por un extremo, sale primero por el otro extremo. En una cola hay dos extremos, uno es llamado la parte delantera y el otro extremo se llama la parte trasera de la cola. En una cola, los elementos se retiran por la parte delantera y se agregan por la parte trasera.
Figura 15. Din´amica de una cola. a) estado actual con una cola con tres elementos a,b,c; b) estado de la cola cuando se agrega el elemento d; c) estado de la cola cuando se elimina el elemento a del frente de la cola
En la figura 15 se muestra una actividad t´ıpica de la cola, en donde se muestra que se agregan datos por la parte trasera de la cola y se eliminana datos por el frente de la cola. Si Q es una cola y x es un elemento, se pueden hacer tres operaciones b´asicas con las colas: 1. insert(Q,x), que inserta el elemento x en la parte trasera de la cola Q. 2. x=remove(Q), que almacena en x el valor del elemento retirado de la parte frontal de la cola Q. 3. empty(Q), que es un predicado de valor booleano, y es verdadero cuando la cola Q tiene 0 elementos, y es f also cuando la cola Q tiene al menos un elemento, en cuyo caso, ese u ´nico elemento es la parte frontal y la parte trasera de la cola al mismo tiempo. 31
Te´oricamente no hay l´ımite para el tama˜ no de la cola, asi que siempre se deber´ıa poder insertar elementos a una cola, sin embargo, al igual que las pilas, normalmente se deja un espacio de memoria para trabajar con esta estructura. Por el contrario, la operaci´on remove s´olamente se puede hacer si la cola no est´a vac´ıa.
3.1.
Estructura de las colas en C/C++
De manera similar a las pilas, las colas definen una estructura no est´andar, de manera que se debe crear un nuevo tipo de dado, el tipo cola, que debe tener los siguientes elementos: Un arreglo de n elementos de alg´ un tipo espec´ıfico, puede incluso ser un tipo est´andar o no. Un n´ umero que indica el elemento que est´a en la posici´on del frente de la cola. Un n´ umero que indica el elemento que est´a en la posici´on trasera de la cola. Suponiendo que los elementos son n´ umeros enteros, una idea para representar una cola en C/C++ es usar un arreglo para contener los elementos y emplear otras dos variables para representar la parte frontal y trasera de la cola. #define maxQueue 100 struct cola{ int items[maxQueue]; int front; int rear; }; Esta representaci´on con arreglos es completamente v´alida, pero debemos tener cuidado con los l´ımites del arreglo. Suponiendo que no existiera la posibilidad de caer en un desbordamiento del arreglo, es decir, que se insertaran m´as elementos de lo que el arreglo puede almacenar, la operaci´on insert podr´ıa quedar como: void insert(struct cola *C, int e){ C->items[++C->rear]=e; } y al operaci´on x=remove(Q) int remove(struct cola *C){ return C->items[C->front++]; 32
} y finalmente la operaci´on empty(Q): bool empty(struct cola *C){ if(C->front>C->rear) return true; else return false; }
3.2.
Colas con prioridad
Una cola con prioridad es una estructura de datos en la que se ordenan los datos almacenados de acuerdo a un criterio de prioridad. Hay dos tipos de colas de prioridad: Las colas de prioridad con ordenamiento descendente. Las colas de prioridad con ordenamiento ascendente. En las colas de prioridad ascendente se pueden insertar elementos en forma arbitraria y solamente se puede remover el elemento con menor prioridad. Si CPA es una cola de prioridad ascendente, la operaci´on insert(CPA,x) inserta el elemento x en la cola CPA; y la operaci´on x=minRemove(CPA) asigna a x el valor del elemento menor (de su prioridad) y lo remueve de la cola. En las colas de prioridad descendente es similar, pero s´olo permite la supresi´on del elemento m´as grande. Las operaciones aplicables a la cola de prioridad descendente son insert(CPD,x) y x=maxRemove(CPD), cuando CPD es una cola de prioridad descendente y x es un elemento. La operaci´on empty(C) se aplica a cualquier tipo de cola y determina si una cola de prioridad est´a vac´ıa. Las operaciones de insertar y borrar se aplican solamente si la pila no est´a vac´ıa. Los elementos de la cola de prioridad no necesitan ser n´ umeros o caracteres para que puedan compararse directamente. Pueden ser estructuras complejas ordenadas en uno o varios campos. Por ejemplo, las agendas telef´onicas constan de apellidos, nombres, direcciones y n´ umeros de tel´efono y est´an ordenadas por apellido. A diferencia de las pilas y las colas, en las colas de prioridad se pueden sacar los elementos que no est´an en el primer sitio del extremo donde salen los elementos. Esto es porque el elemento a retirar puede estar en cualquier parte 33
del arreglo. Cuando se requiere eliminar un dato de una cola de prioridad se necesita verificar cada uno de los elementos almacenados para saber cu´al es el menor (o el mayor). Esto conlleva algunos problemas, el principal problema es que el tiempo necesario para eliminar un elemento puede crecer tanto como elementos tenga la cola. Para resolver este problema hay varias soluciones: 1. Se coloca una marca de “vac´ıo” en la casilla de un elemento suprimido. Este enfoque realmente no es muy bueno, porque de cualquier modo se accesan los elementos para saber si es una localidad vac´ıa o no lo es. Por otro lado, cuando se remueven elementos, se van creando lugares vac´ıos y despu´es es necesario hacer una compactaci´on, reubicando los elementos en el frente de la cola. 2. Cada supresi´on puede compactar el arreglo, cambiando los elementos depu´es del elemento eliminado en una posici´on y despu´es decrementando rear en 1. La inserci´on no cambia. En promedio, se cambian la mitad de los elementos de una cola de prioridad para cada supresi´on, por lo que esta operaci´on no es eficiente.
3.3.
Ejercicio de programaci´on
1. Modifique los procedimientos de insertar, retirar y verificar-cola-vac´ıa para que considere aprovechar los espacios dejados al retirar elementos. 2. Un deque es un conjunto ordenado de elementos del cual pueden eliminarse elementos en cualquier extremo y en el cual pueden insertarse elementos en cualquier extremo. Llamemos a los dos extremos de un deque left (izquierdo) y right (derecho). ?’c´omo se representa un deque en un arreglo en C/C++ ? escriba un programa que maneje un deque, y que considere las cuatro rutinas removeLeft removeRight insertLeft insertRight para remover e insertar elementos en los extemos izquierdo y derecho de un deque. Aseg´ urese de que las rutinas funcionan adecuadamente para que un deque vac´ıo y que detectan desbordamiento y subdesbordamiento. 3. Programe las colas de prioridad ascendente y descendente. 4. Existe un estacionamiento que tiene un s´olo carril que aloja hasta 10 34
carros. Los autos llegan por el extremo sur del estacionamiento y salen por el extremo norte del mismo. Si llega un cliente para recoger un carro que no est´a en el extremo norte, se sacan todos los autom´oviles de ese lado, se retira el auto y los otros coches se restablecen en el mismo orden que estaban. Cada vez que sale un auto, todos los autos del lado sur se mueven hacia adelante para que en todas las ocasiones todos los espacios vac´ıos est´en en la parte sur del estacionamiento. Escriba un programa que lea un grupo de lineas de ingreso. Cada l´ınea contiene una “A” para las llegadas y una “D” para las salidas y un n´ umero de placa. Se supone que los carros llegan y salen en el orden especificado en la entrada. El programa debe imprimir (en la terminal est´andar) un mensaje cada vez que entra o sale un auto. Cuando llega un carro, el mensaje debe especificar si hay espacio o no para ´el en el estacionamiento. Si no hay espacio, el carro espera hasta que hay espacio o hasta que se lee una l´ınea de salida para el auto. Cuando queda disponible espacio, debe imprimirse otro mensaje. Cuando salga un coche, el mensaje debe incluir la cantidad de veces que se movi´o el auto dentro del estacionamiento, incluyendo la salida misma, pero no la llegada. Este n´ umero es 0 si el carro sale de la fila de espera.
35
4.
Recursi´ on
Un tema fundamental para los pr´oximos temas es el de recusri´on. La recursi´on es muy importante tanto en mate´aticas como em computaci´on, pues se usa recursi´on para definir procedimientos autosimilares. Definici´ on 6 Decimos que un objeto es recursivo si en su definici´ on se nombra a s´ı mismo. En programaci´on, una funci´on es recursiva si en el ´ambito de esa funci´on hay una llamada a s´ı misma, C/C++ permite esta clase de acciones. Los algoritmos recursivos dan elegancia a las soluciones de los problemas. Un ejemplo cl´asico es el factorial de un n´ umero. Una manera de definir el factorial de un n´ umero n > 1 es:
!n =
n Y
i,
i=1
es decir, el producto de todos los n´ umeros enteros menores o guales que ´el, lo que se puede resolver f´acilmente con una funci´on iterativa, esto es, una funci´on con un ciclo que itere suficientes veces, incrementando un valor y entonces ir almacenando en una variable el resultado de esas multiplicaciones. Una implementaci´on de esta definici´on iterativa es: (1) (2) (4) (5) (6) (7) (8)
int i,n; long double valorAc; valorAc=1.0; std::cout << "Numero entero:"; std::cin>> n; for(i=1; i<=n; i++) valorAc = valorAc*i; std::cout<<"El factorial de "<
El ciclo principal es en la l´ınea (7). No hay ning´ un truco hasta aqu´ı. La u ´nica observaci´on importante es en la l´ınea (2) en donde se declara el tipo long double para el valor del resultado, la raz´on para tal acci´on es que el n´ umero factorial crece muy r´apido y a´ un con entradas en el rango de los caracteres (hasta 255), el factorial es muy grande. Este procedimiento computacional no hace uso de t´ecnicas especiales empleadas para tratar n´ umeros grandes. Sin embargo una soluci´on m´as elegante es usar la definici´on recursiva, y esta es: 36
!n = n ∗ !(n − 1) El programa en C/C++ es el que se muestra a continuaci´on: ( 1) double factorial(double a){ ( 2) if (a<=1) return 1.0; ( 3) else return (a *factorial(a-1.0)); } ( 4) ( 5) int main (int argc, char * const argv[]) { ( 6) double n; ( 7) std::cout << "Numero entero:"; ( 8) std::cin>> n; ( 9) std::cout<<"El factorial de "<
Figura 16. Recursividad cuando se ejecuta factorial(5)
37
4.0.1.
La serie Fibonacci
Una de las series m´as famosas es sin duda alguna la serie de Fibonacci:
1, 1, 2, 3, 5, 8, 13, 21, 34, . . .
Un poco de observaci´on es sufucuente para encontrar que cualquier n´ umero (a partir del tercero de la serie, osea el segundo 1) es igual a la suma de los dos n´ umeros anteriores. Daremos en primer lugar la versi´on iterativa. En este algoritmo deseamos encontrar el n-´esimo n´ umero de la serie Fibonacci. As´ı si n = 4 el resultado del algoritmo debe ser 3; si n = 6 el resultado debe ser 8. La versi´on iterativa empieza desde los primeros 1’s, sum´andolos y encontrando el tercero, luego para encontrar el cuarto n´ umero se suman el tercero (reci´en encontrado) y el segundo, y as´ı en adelante hasta encontrar el n´ umero buscado. #include int main (int argc, char * const argv[]) { int i,n,fib,fib1,fib2,fibx; std::cout<<"Un numero entero:"; std::cin>>n; fib1=2; fib2=1; i=3; if((n==1)||(n==2)) fib=1; else{ do{ fib = fib1 + fib2; fibx = fib1; i++; fib1 = fib; fib2 = fibx; }while(i
fib(n) =
1
Si n = 1 ´o n = 2
fib(n − 1) + fib(n − 2) Si n > 2
En el siguiente c´odigo, la soluci´on que propone la recursividad resulta en una programaci´on elegante, aunque costosa. El c´odigo que hace esto es: ( 1) ( 2) ( 3) ( 4) ( 5) ( 6) ( 7) ( 8) ( 9) (10) (11) (12) (13) (14) (15) (16)
#include //==================== int fib(int val){ if ((val==1)||(val==2)) return 1; else return (fib(val-1)+fib(val-2)); } //==================== int main (int argc, char * const argv[]) { int n; std::cout<<"Numero entero:"; std::cin>>n; std::cout<<"\nEl "<< n <<"-esimo numero fibonacci es: "<< fib(n); return 0; }
Como regla general, cualquier algoritmo recursivo se puede reescribir en un algoritmo iterativo. La ventaja de tener un algoritmo iterativo es que no se usa una pila para guardar llamadas a la misma funci´on de manera recursiva, esto es una ventaja porque el espacio de memoria destinado al uso de la pila es generalmente limitado, de manera que cuando se hacen demasiadas funciones push seguramente llegar´a el momento en que la pila “se desborde”, que por cierto es un t´ermino usado en computaci´on para decir que ya no hay m´as espacio disponible en la pila.
4.1.
Peligros en la recursividad
El principal peligro al usar recursividad, es no tener una manera de salir del paso recursivo, esto es peligroso porque se hacen llamadas a la misma funci´on, lo que significa una entrada en la pila donde se almacenan los estados generales del programa. Para decidir hacer un programa recursivo se deben de tener al menos dos cosas muy claras: 39
1. El paso base: Esta es la clave para terminar la recursi´on, es cuando deja de hacer llamadas a la funci´on recursiva y hace evaluaciones devolviendo los resultados. En el ejemplo de la serie de Fibonacci, el paso base est´a en la l´ınea ( 5). Adem´as se debe asegurar de que es posible entrar a este paso. 2. El paso recursivo: Es la parte de la definici´on que hace llamadas a esa misma funci´on y que es la causante de las inserciones en la pila, almacenando en cada una de las llamadas, informaci´on del programa, del estado de sus variables locales y globales. En el mismo ejemplo de la serie Fibonacci, el paso recursivo se muestra en la l´ınea ( 7). Otras cosas que se deben tener claras son por ejemplo si se pasa una variable como referencia o por valor, si las variables apuntadores son del tipo adecuado etc. Frecuentemente tanto el paso base como el paso recursivo, se encuentran en una sentencia condicional if, pero porsupuesto que es posible usar cualquier otra sentencia de control, dependiendo de las necesidades particulares del problema. El siguiente ejemplo ilustra este problema ( 1) ( 2) ( 3) ( 4) ( 5) ( 6) ( 7) ( 8) ( 9) (10) (11) (12)
#include int malaFuncion( int n ){ std::cout << "malaFuncion es una recursion infinita. n="<
4.2.
Ejercicios de programaci´on
Los siguientes ejercicios deben de ser programados en C/C++ : 1. B´ usqueda binaria: Considere un arreglo de elementos (n´ umeros enteros est´a bien) en el cual los objetos ya estan ordenados, y se desea encontrar un elemento dentro de este arreglo. Es decir, se desea realizar una “b´ usqueda”. La idea general de este m´etodo de b´ usqueda binaria es: Si el arreglo tiene 1 elemento, se compara con el numero requerido y la 40
b´ usqueda termina. Si el arreglo tiene m´as de 1 elemento, tendremos que dividir en dos el arreglo y decidir en qu´e parte del arreglo buscar; luego buscarlo usando busqueda binaria 2. Escriba un programa para calcular la cantidad de maneras diferentes en las cuales un entero n se puede expresar como la suma de dos enteros menores p < n y q < n tales que p + q = n
41
5.
Listas
Hay dos desventajas serias con respecto a las estructuras est´aticas de pilas y colas usando arreglos. Estas desventajas son que tienen un espacio limitado de memoria y la otra desventaja es que es posible no ocupar toda la memoria disponible, haciendo que se desperdicie espacio. Una soluci´on es usar listas. Las listas son estructuras de datos que son din´amicas, esto significa que adquieren espacio y liberan espacio a medida que se necesita. sin embargo, hay una advertencia. Como regla general siempre hay que tener cuidado al manejar direcciones de espacios de memoria, porque es posible que accedamos a una localidad de memoria de la cual no deseabamos cambiar su contenido. Antes de estudiar las listas, daremos una breve introducci´on a los grafos, pues las listas son un caso especial de los grafos.
5.1.
Grafos
Los grafos son una manera visual de representar las relaciones. Definici´ on 7 Si A y B son dos conjuntos, decimos que a ∈ A est´ a relacionado con b ∈ B si es verdadera una sentencia R que considere a ambos elementos. Esta sentencia R puede ser cualquier predicado, por ejemplo: “es padre de”, “debe dinero a”, “toma el curso de” etc.; si el predicado es verdadero para ese par de elementos, lo escribimos como aRb, y si el predicado es falso, lo escribimos como b 6 . As´ı los ejemplos citados, si a ∈ A, b ∈ B se puede leer: Si A es el conjunto de alumnos, B es el conjunto de materias y R es “toma el curso”, entonces pedroRlogica se lee “pedro toma el curso de logica. En la figura 17 se puede apreciar esto en forma de diagramas de Venn. Si A es el conjunto de personas y B es tambi´en el conjunto de personas, y R es “debe dinero a”; marisolRrafaelle significa que “marisol debe dinero a rafaelle” y de ning´ un modo es al contrario, es decir “rafaelle no debe dinero a marisol”. Los elementos de la figura 17 definen un nuevo conjunto de elementos, el conjunto de pares de elementos que estan relacionados. As´ı la relaci´on “toma el curso de” es el siguiente: 42
Figura 17. Relaci´on “toma el curso de” para los conjuntos A de personas y B de materias.
R = {(diana, programacion), (carolina, programacion), (carolina, compiladores),
(carolina, lenguajes),
(rafael, compiladores),
(gustavo, lenguajes),
(fabiola, lenguajes)} Gr´aficamente podemos ilustrar el conjunto R de “toma el curso de” con un grafo como el que se muestra en la figura 18.
Figura 18. Grafo que ilustra la relaci´on “toma el curso de”.
De manera que podemos definir un grafo como una representaci´on gr´afica de una relaci´on. Definici´ on 8 Para definir formalmente un grafo debemos establecer la siguiente tupla: G = hA, N i Donde A es un conjunto de aristas y N 6= ∅ un conjunto no vac´ıo de nodos. En el caso de R, el conjunto A ∪ B es el conjunto de nodos y el conjunto de flechas es el conjunto de aristas. 43
Notemos que el conjunto A de aristas puede ser un conjunto vac´ıo, pero de ning´ un modo hay grafo sin nodos, es decir el conjunto N debe ser diferente que el conjunto vac´ıo. Supongamos ahora A = {1, 2, 3, 4, 5, 6} y la siguiente relaci´on en A: R = {(1, 2), (2, 3), (3, 4), (4, 5), (5, 6)} Esta relaci´on luce como aparece en la figura 20.
Figura 19. Relaci´on R de A en A
y en forma de grafo es:
Figura 20. Grafo de la relaci´on R : A → A
A esta clase de grafos, en las que cada nodo tiene a lo m´as una arista dirigida que sale y a lo m´as una arista dirigida que entra, se le llama lista.
5.2.
Listas simplemente encadenadas
Como vimos en la secci´on anterior, una lista es una relaci´on de elementos, tales que cada elemento est´a relacionado con u ´nicamente un elemento del conjunto, diferente a s´ı mismo. 44
Como cada elemento puede tener a lo m´as una arista dirigida que sale y una arista dirigida que entra, bien puede tener 0 aristas que salen, o cero aristas que entran. Si el nodo tiene 0 aristas que salen, entonces es el final de la lista. Si el nodo tiene 0 aristas que entran, entonces es el inicio de la lista. Por razones pr´acticas, se dibujan una flecha que sale de un identificador de la lista y entra al inicio de la lista y otra flecha que sale del final de la lista y apunta a un s´ımbolo que se llama NULO.
Figura 21. Grafo de la relaci´on R : A → A con apuntadores del nombre de la lista listaLigada y hacia NULL
En C/C++ el identificador de la lista contiene la direcci´on del primer elemento de la lista, as´ı como sucede con los arreglos. El valor NULO es u ´til para saber cu´ando termina la lista, es una constante est´andar y no tiene valor. El contenido de los nodos, como ya hemos visto, son los elementos de un conjunto. Si ese conjunto tiene elementos estructurados, tambi´en es v´alido usarlos. Normalmente cada nodo de la lista est´a estructurado con dos partes: 1. La parte de informaci´ on. 2. La parte de direcci´ on al siguiente nodo de la lista. El campo de informaci´on contiene el elemento real de la lista. El campo de direcci´on al siguiente contiene un apuntador al elemento con el cu´al est´a relacionado, es decir, al elemento siguiente de la lista. La lista completa se accesa mediante el identificador de lalista. El campo de la direcci´on del u ´ltimo nodo apunta a una direccion nula. La lista que no tiene elementos (solamente tiene un identificador que apunta a nulo) se llama lista nula o lista vac´ıa. Una lista se inicializa a una lista vac´ıa haciendo lista=null, recordemos que lista es un apuntador a una direcci´on de memoria que puede albergar una variable del tipo que se hayan definido los nodos; null es una direcci´on de cualquier tipo, as´ı que el compilador asigna la direcci´on null a lista. 45
Enseguida vamos a dar una lista de t´erminos usados para manejar los elementos de una lista simplemente encadenada, aunque no son los que usa C/C++ , pero s´ı son bastante claros para hacer algoritmos. Si p es un apuntador a la direcci´on de una variable del tipo declarado para los nodos de una lista: node(p): hace referencia al noso al que se apunta mediante p. info(p): hace referencia a la informaci´on del nodo al que apunta p. next(p): hace referencia a la parte direcci´on siguiente y, por tanto, es un apuntador. As´ı que la expresi´on info(next(p)) significa que se hace referencia a la secci´on de informaci´on del nodo siguiente al que apunta p.
5.2.1.
Insertar y eliminar nodos de una lista
En el uso de las listas ligadas se ven involucradas varias operaciones, entre ellas la de insertar un nuevo nodo a la lista y la operaci´on de eliminar un nodo de la lista. En ambos casos debemos recordar que se trata de manejo de la memoria, as´ı que insertar un nodo en la lista significa obtener un espacio de memoria disponible y relacionarlo con los elementos de la lista; as´ı mismo, eliminar un nodo de la lista significa liberar la memoria que ocupa ese nodo sin perder la relaci´on con el resto de los nodos de la lista. Insertar un elemento al inicio de la lista. La operaci´on p=getnode(); obtiene un nodo vac´ıo y establece el contenido de una variable nombrada p en la direcci´on de este nodo, como se muestra en la figura 22.a. Este nodo a´ un no pertenece a alguna lista, simplemente se ha logrado dedicar un especio de memoria que es apuntado por p, figura 22.b.
Figura 22. a) Creaci´on de un nuevo nodo. b) El nuevo nodo debe de ir insertado al frente, atr´as o en medio de la lista.
Una vez que se ha creado un nuevo espacio para el nuevo nodo, se debe de establecer la parte de informaci´on de ese nodo con la operaci´on info(p), como se ilustra en el siguiente ejemplo con el dato 6. 46
info(p)=6; Despu´es de esstablecer la parte de informaci´on es necesario establecer la parte siguiente de este nodo. Debido a que node(p) va a insertarse en la parte delantera de la lista, el nodo que sigue debe ser el primer nodo actual de la lista. Debido a que la variable lista (el identificador de la lista) contiene la direcci´on de ese primer nodo, node(p) se agrega a la lista ejecutando la operaci´on next(p)=lista; Esta operaci´on coloca el valor de lista (la direcci´on del primer nodo en la lista) en el campo siguiente de node(p). Estos pasos se ilustran en la figura 23
Figura 23. Operaciones involucradas en la inserci´on de un nuevo nodo al inicio de una lista: c) info(p). d) next(p)=list. e) list=p
Hasta ahora, p apunta a la lista con el elemento adicional incluido. Sin embargo, debido a que list es el apuntador externo a la lista deseada, su valor debe modificarse en la direcci´on del nuevo primer nodo de la lista. Esto se hace ejecutando la operaci´on list=p; En resumen, ya tenemos un algoritmo para insertar un elemento al inicio de una lista simplemente ligada, al reunir todos los pasos tenemos: p=getnode(); info(p)=6; 47
next(p)=list; list=p; Eliminar un elemento de la lista. Para eliminar un elemento del inicio de la lista, se siguen los mismos pasos que se usan para insertar un elemento, pero en un orden diferente: p=list; x=info(p); list=next(p); Comentaremos cada una de estas tres l´ıneas, que se pueden apreciar en la figura 24
Figura 24. Operaciones involucradas en la eliminaci´on de un nodo al inicio de una lista: c) p=list). d) x=info(p). e) list=next(p)
5.2.2.
Listas en C/C++ con arreglos
Vamos a empezar una primera implementaci´on de listas usando arreglos, cada elemento del arreglo debe ser un elemento compuesto. Cada elemento debe contener una parte para la informaci´on y otra parte para apuntar al elemento siguiente: #include ( 1) #define numNodes 500 ( 2) struct nodeType{ ( 3) int info; 48
( 4) int next; ( 5) }; ( 6) struct nodeType node[numNodes]; int main (int argc, char * const argv[]) { std::cout << "Hello, World!\n"; return 0; } En el programa anterior, en las l´ıneas (2) a (5) se crea un nuevo tipo de dato, el tipo nodo. Cada nodo tiene dos partes, su parte de informaci´on y su parte de apuntador al siguiente. Como solamente tenemos 500 nodos (declarados en la l´ınea (1), el tipo de siguiente es entero y hemos decidido almacenar n´ umeros enteros solamente. En la l´ınea (6) se ha declarado una variable global de tipo arreglo de estructura de nodos, es decir, se ha creado un arreglo de 500 nodos. En este esquema, el u ´ltimo nodo apunta a NULL, que se representa con el valor entero -1. Tenemos tambi´en los siguientes elementos de cada nodo: node[p] corresponde a next(p), por la notaci´on propia del lenguaje; tambi´en node[p].info para info(p) y finalmente node[p].next hace referencia al nodo siguiente next(p). Al principio todos los nodos est´an sin usar, porque solamente se ha creado el arreglo. As´ı que todos los nodos van a formar parte de una lista de nodos disponibles. Si se usa la variable global avail para apuntar a la lista disponible, podr´ıamos organizar inicialmente esta lista como: void inicializaAvail(void){ int i; avail = 0; for(i=0; i
int getNode(void){ int p; if (avail==-1){ std::cout<<"Overflow\n"; exit(1); } p=avail; avail=node[avail].next; return p; } Si avail es igual a -1 significa que no hay nodos disponibles, es decir, que el arreglo est´a completamente lleno. Esto significa que las estructuras de lista de un programa particular han desbordado el espacio disponible. La funci´on freeNode acepta un apuntador (n´ umero entero) a un nodo y devuelve ese nodo a la lista de disponibles: void freeNode(int p){ node[p].next=avail; avail=p; } Las operaciones primitivas para listas son versiones directas en C de los algoritmos correspondientes. La rutina insAfter acepta un apuntador p a un nodo y un elemento x como par´ametros. Primero se asegura que p no sea nulo y despu´es se inserta x en el nodo siguiente al indicado por p. void insAfter(int p, int x){ int q; if(p==-1){ std::cout<<"void insertion\n"; } else{ q=getNode(); node[q].info=x; node[q].next=node[p].next; node[p].next=q; } } La rutina delAfter(p,px), llamada por el enunciado delAfter(p,&x), suprime el nodo despu´es de node(p) y almacena su contenido en x; void delAfter(int p, int *px){ int q; if((p==-1)||(node[p].next==-1)){ 50
std::cout<<"void detection\n"; } else{ q=node[p].next; *px = node[q].info; node[p].next=node[q].next; freeNode(q); } } Antes de llamar insAfter debemos asegurarnos de que ni p ni node[p].next sean nulos.
5.3.
El uso de memoria din´amica en C/C++
Como sabemos, en lenguaje C/C++ , &x es la direcci´on donde se almacena en memoria la variable x. Si p es un apuntador en C/C++ , *p es el contenido de la localidad de memoria p. Si usamos C/C++ para implementar listas ligadas, podemos usar estos apuntadores. Sin embargo, primero analizaremos c´omo asignar y liberar el almacenamiento en forma din´amica y c´omo se accesa al almacenamiento din´amico en C/C++ . En C/C++ , una variable que debe contener la direcci´on en la memoria que almacena un n´ umero entero se crea mediante la declaraci´on int *p; Recordemos que esta declaraci´on se divide en dos partes: la parte de tipo int *, que indica que se trata de un apuntador a un entero; y la parte de identificador, en este caso p. Una vez declarada la variable p como un apuntador a un tipo espec´ıfico de dato, debe ser posible crear din´amicamente un objeto de este tipo espec´ıfico y asignar su direcci´on a p. Esto se hace en C/C++ mediante la funci´on de la biblioteca est´andar malloc(size). La fucni´on malloc asigna de manera din´amica una parte de memoria de tama˜ no especificado en size y devuelve un apuntador a un elemento de tipo char. Consideremos las siguientes declaraciones extern char *malloc(); int *pi; float *pr; 51
La palabra clave extern especifica que una variable o funci´on tiene un enlace externo. Esto significa que la variable o funci´on a la que nos referimos est´a definida en alg´ un otro archivo fuente, o m´as adelante en el mismo archivo. Sin embargo, en C/C++ podemos usar esta palabra clave extern con una cadena. La cadena indica que se est´a usando el convenio de enlace de otro lenguaje para los identificadores que se est´an definiendo. Para los programas C++ la cadena por defecto es “C++”. Los enunciados pi = (int *) malloc(sizeof(int)); pr = (float *) malloc(sizeof(float)); crean directamente la variable entera *pi y la variable real *pr. Estas se denominan variables din´amicas. Al ejecutar estos enunciados, el operador sizeof devuelve el tama˜ no en bytes de su operando. Esto se usa para conservar la independencia de m´aquina. Despu´es, malloc crea un objeto de este tama˜ no. Por tanto, malloc(sizeof(int)) asigna almacenamiento para un entero, en tanto que malloc(sizeof(float)) asigna espacio necesario para un real. De igual manera, malloc devuelve un apuntados al almacenamiento que asigna. Este apuntador es al primer byte de este almacenamiento y es de tipo char *. Para obligar al apuntador a que se˜ nale a un entero, usamos el operador de c´alculo (int *) ´o (float *). El operador sizeof, devuelve un valor de tipo int, en tanto que la funci´on malloc espera un par´ametro de tipo unsigned. Para hacer que correspondan, debemos escribir pi=(int *)malloc((unsigned)(sizeof(int))); Como ejemplo, vamos a considerar este breve c´odigo: #include int main (int argc, char * const argv[]) { ( 1) int *p, *q; ( 2) int x; ( 3) p = (int *)malloc(sizeof(int)); ( 4) *p = 3; ( 5) q = p; ( 6) std::cout<< *p << " " << *q << "\n"; ( 7) x = 7; ( 8) *q = x; ( 9) std::cout<< *p << " " << *q << "\n"; (10) p = (int *)malloc(sizeof(int)); (11) *p = 5; 52
(12)
std::cout<< *p << " return 0;
" << *q << "\n";
} En la l’inea (3), se crea una variable de tipo entero y su direcci´on se coloca en p. La l´ınea (4) establece el valor de esa variable en 3. La l´ınea (5) hace que la direcci´on q sea la misma direcci´on que p. El enunciado de la l´ınea (5) es perfectamente v´alido, pues se asigna a una variable de tipo apuntador (q) el valor de otra variable del mismo tipo (p). En este momento *p y *q hacen referencia a la misma variable. Por tanto, la l´ınea (6) imprime el contenido de esa variable (que ahora es 3) dos veces. En la l´ınea (7), se almacena el valor 7 en la variable entera x. La l´ınea (8) cambia el valor de *q al valor de x. sin embargo, dado que p y q apuntan a la misma variable, *p y *q tienen el valor 7. Por tanto la l´ınea (9) imprime el n´ umero 7 dos veces. La l´ınea (10) crea una nueva variable entera y coloca su direcci´on en p. Ahora *p hace referencia a la variable entera reci´en creada que todav´ıa no ha recibido un valor. q no ha cambiado; por lo que el valor de *q sigue siendo 7. Observemos que *p no hace referencia a una variable espec´ıfica u ´nica. Su valor cambia conforme se modifica el valor de p. La l´ınea (11) establece el valor de esta variable reci´en creada en 5 y la l´ınea 12 imprime los valores 5 y 7. Y as´ı la salida del programa es: 3 7 5
3 7 7
mallocEjemplo has exited with status 0. La funci´on free se usa en C para liberar almacenamiento de una variable asignada din´amicamente. La orden free(p); invalida cualquier referencia futura a la variable *p (a menos que se asigne nuevo espacio de memoraia a esa variable). Llamar free(p) hace que quede disponible para re´ uso el almacenamiento ocupado por *p, si es necesario. La funci´on free espera un par´ametro apuntador del tipo char *, para que no tengamos problemas de tipos, debemos hacer free((char *)p); Consideremos el siguiente ejemplo para ilustrar el uso de free: 53
#include int main (int argc, char * const argv[]) { int *p, *q; ( ( ( ( ( ( ( ( (
1) 2) 3) 4) 5) 6) 7) 8) 9)
p=(int *)malloc(sizeof(int)); *p=5; q=(int *)malloc(sizeof(int)); *q=8; free(p); p=q; q=(int *)malloc(sizeof(int)); *q=6; std::cout<<*p<<" "<<*q<<"\n"; return 0;
} ¿Qu´e se imprime a la salida del programa? Observemos que si se llama malloc dos veces sucesivas y se asigna su valor a la misma variable como en: p=(int *)malloc(sizeof(int)); *p=3; p=(int *)malloc(sizeof(int)); *p=7; Se pierde la primera copia de *p, dado que su direcci´on no se guard´o.
5.4.
Listas ligadas usando memoria din´ amica
Para hacer las listas ligadas necesitamos un conjunto de nodos, cada uno de los cuales tiene dos campos: uno de informaci´on y un apuntador al siguiente nodo de la lista. Adem´as, un apuntador externo se˜ nala el primer nodo de la lista. Usamos variables de apuntador para implementar apuntadores de listas. As´ı que definimos el tipo de un apuntador y un nodo mediante struct node{ int info; struct node *next; }; typedef struct node *nodePtr; 54
Un nodo de este tipo es igual a los nodos de la implementaci´on con arreglos, excepto que el campo next es un apuntador y no un entero. En lugar de declarar un arreglo, para que represente un conjunto acumulado de nodos, ´estos se asignan y liberan seg´ un es necesario. Se elimina la necesidad de un conjunto de nodos previamente declarado. Si declaramos nodePtr p; la ejecuci´on de la orden p=getNode(); debe colocar la direcci´on de un nodo disponible en p: nodePtr getNode(void){ nodePtr p; p=(nodePtr)malloc(sizeof(struct node)); return(p); } Para liberar la memoria utilizada usamos freeNode. void freeNode(nodePtr p){ free(p); } Los procedimientos insAfter y delAfter usan la implementaci´on din´amica de una lista ligada. Supongamos que list es una variable apuntador que se˜ nala al primer nodo de una lista (si lo hay) y es igual a NULL en el caso de una lista vac´ıa. void insAfter(nodePtr p, int x){ nodePtr q; if(p==NULL){ std::cout<<"Insercion nula\n"; } else{ q=getNode(); q->info=x; q->next=p->next; p->next=q; } } void delAfter(nodePtr p, int *px){ 55
nodePtr q; if((p==NULL)||(p->next==NULL)){ std::cout<<"Borrado prohibido\n"; } else{ q=p->next; *px=q->info; p->next=q->next; freeNode(q); } }
5.5.
Ejercicios de programaci´on
1. Implemente una pila usando memoria din´amica en listas ligadas. Implemente las operaciones push, pop, empty y stackTop. 2. Implemente una cola usando memoria din´amica en listas ligadas. Implemente las operaciones empty, insert y remove. 3. Desarrolle un programa para buscar un elemento en la lista (de n´ umeros enteros) y borrar la primera ocurrencia de ese elemento. 4. Desarrolle un programa para buscar un elemento en la lista (de n´ umeros enteros) y borrar todas las ocurrencias de ese elemento. 5. Las listas doblemente ligadas tienen nodos que est´an divididos en tres segmentos: a) Anterior: Un apuntador a un nodo b) Info: La informaci´on de un nodo c) Siguiente: Un apuntador a un nodo Implemente las operaciones borrarNodo(p), insertarNodoAntes e insertarNodoDespues.
56
´ Arboles
6.
Los ´arboles son estructuras de datos u ´tiles en muchas aplicaciones. Hay varias formas de ´arboles y cada una de ellas es pr´actica en situaciones especiales, en este cap´ıtulo vamos a definir algunas de esas formas y sus aplicaciones.
6.1.
Concepto general de ´arbol
Desde el punto de vista de estructuras de datos, un ´arbol es un concepto simple en su definici´on, sin embargo es muy ingenioso. Un ´arbol es un grafo con caracter´ısticas muy especiales: Definici´ on 9 Un ´arbol es un grafo A que tiene un u ´nico nodo llamado ra´ız que: Tiene 0 relaciones, en cuyo caso se llama nodo hoja tiene un n´ umero finito de relaciones, en cuyo caso, cada una de esas relaciones es un sub´arbol Para empezar a estudiar los ´arboles, nos concentraremos en primer lugar en el caso en que el nodo ra´ız tenga 0, 1 ´o 2 sub´arboles.
6.2.
´ Arboles binarios
Definici´ on 10 Un ´arbol binario es una estructura de datos de tipo ´ arbol en donde cada uno de los nodos del ´arbol puede tener 0, 1, ´ o 2 sub´ arboles llamados de acuerdo a su caso como: Si el nodo ra´ız tiene 0 relaciones se llama hoja. Si el nodo ra´ız tiene 1 relaci´on a la izquierda, el segundo elemento de la relaci´on es el sub´arbol izquierdo. Si el nodo ra´ız tiene 1 relaci´ on a la derecha, el segundo elemento de la relaci´on es el sub´arbol derecho. La figura 25 muestra algunas configuraciones de grafos que s´ı son ´arboles binarios, y la figura 26 muestra algnas configuraciones de grafos que no son ´arboles binarios. Vamos a dar una lista de te´erminos que se usan frecuentemente cuando se trabaja con ´arboles: 57
Figura 25. Grafos que son estructuras tipo ´arbol binario
Figura 26. Grafos que no son ´arboles binarios
Si A es la ra´ız de un ´arbol y B es la ra´ız de su sub´arbol izquierdo (o derecho), se dice que A es el padre de B y se dice que B es el hijo izquierdo (o derecho) de A. Un nodo que no tiene hijos se denomina hoja El nodo a es antecesor del nodo b (y rec´ıprocamente el nodo b es descendiente del nodo a), si a es el padre de b o el padre de alg´ un ancestro de b. Un nodo b es un descendiente izquierdo del nodo a, si b es el hijo izquierdo de a o un descendiente del hijo izquierdo de a. Un descendiente derecho se define de la misma forma. Dos nodos son hermanos si son hijos izquierdo y derecho del mismo padre. Otros t´erminos relacionados con ´arboles, tienen que ver con su funcinoamiento y topolog´ıa: Si cada nodo que NO es una hoja tiene un sub´arbol izquierdo y un sub´arbol derecho, entonces se trata de un ´ arbol binario completo. El nivel de un nodo es el n´ umero de aristas que se deben recorrer para 58
llegar desde ese nodo al nodo ra´ız. De manera que el nivel del nodo ra´ız es 0, y el nivel de cualquier otro nodo es el nivel del padre m´as uno. La profundidad de un nodo es el m´aximo nivel de cualquier hoja en el ´arbol. Si un a´rbol binario tiene m nodos en el nivel l, el m´aximo n´ umero de nodos en el nivel l + 1 es 2m. Dado que un ´arbol binario s´olo tiene un nodo en el nivel 0, puede contener un m´aximo de 2l nodos en el nivel l. Un ´arbol binario completo de profundidad d es el ´arbol que contiene exactamente 2l nodos en cada nivel l entre 0 y d. La cantidad total de nodos tn en un ´arbol binario completo de profundidad d, es igual a la suma de nodos en cada nivel entre 0 y d, por tanto:
tn = 20 + 21 + 22 + · · · + 2d =
d X
2j
j=0
Usando inducci´on matem´atica se puede demostrar que dj=0 2j = 2d+1 − 1. Dado que todas las hojas en este ´arbol est´an en el nivel d, el ´arbol contiene 2d hojas y, por tanto, 2d − 1 nodos que no son hojas. P
Si conocemos el n´ umero total de nodos tn en un ´arbol binario completo, podemos calcular su profundidad d, a partir de la expresi´on tn = 2d+1 −1. As´ı sabemos que la profundidad d es igual a 1 menos que el n´ umero de veces que 2 debe ser multiplicado por s´ı mismo para llegar a tn + 1. Es decir, que en un ´arbol binario completo,
d = log2 (tn + 1) Definici´ on 11 Un ´arbol binario es un ´ arbol binario casi completo si: 1. 2.
Cualquier nodo nd a un nivel menor que d − 1 tiene 2 hijos Para cualquier nodo nd en el ´ arbol con un descendiente derecho en el nivel d debe tener un hijo izquierdo y cada descendiente izquierdo de nd: es una hoja en el nivel d ´ o tiene dos hijos
Los nodos en un ´arbol binario (completo, casi completo o incompleto) se pueden enumerar del siguiente modo. Al nodo ra´ız le corresponde el n´ umero 1, al hijo izquierdo le corresponde el doble del n´ umero asignado al padre y al hijo derecho le corresponde el doble m´as 1 del n´ umero asignado al padre. 59
Figura 27. Comparaci´on de un ´arbol binario y un ´arbol binario casi completo. El ´arbol mostrado en (A) descumple la regla 2 de los ´arboles binarios casi completos.
6.2.1.
Operaciones con ´arboles binarios
Con los ´arboles binarios es posible definir algunas operaciones primitivas, estas operaciones son en el sentido de saber la informaci´on de un nodo y sirven para desplazarse en el ´arbol, hacia arriba o hacia abajo. info(p) que devuelve el contenido del nodo apuntado por p. left(p) devuelve un apuntador al hijo izquierdo del nodo apuntado por p, o bien, devuelve NULL si el nodo apuntado por p es una hoja. right(p) devuelve un apuntador al hijo derecho del nodo apuntado por p, o bien, devuelve NULL si el nodo apuntado por p es una hoja. father(p) devuelve un apuntador al padre del nodo apuntado por p, o bien, devuelve NULL si el nodo apuntado por p es la ra´ız. brother(p) devuelve un apuntador al hermano del nodo apuntado por p, o bien, devuelve NULL si el nodo apuntado por p no tiene hermano. Estas otras operaciones son l´ogicas, tienen que ver con la identidad de cada nodo: isLeft(p) devuelve el valor true si el nodo actual es el hijo izquierdo del nodo apuntado por p, y false en caso contrario. isRight(p) devuelve el valor true si el nodo actual es el hijo derecho del nodo apuntado por p, y false en caso contrario. isBrother(p) devuelve el valor true si el nodo actual es el hermano del nodo apuntado por p, y false en caso contrario. Como ejemplo, un algoritmo para el procedimiento isLeft es como sigue: q=father(p); if(q==NULL) return(false) /* porque p apunta a la raiz */ 60
if (left(q)==p) return(true); return(false); En la construcci´on de un ´arbol binario son u ´tiles las operaciones makeTree, setLeft y setRight. La operaci´on makeTree(x) crea un nuevo ´arbol binario que consta de un u ´nico nodo con un campo de informaci´on x y devuelve un apuntador a ese nodos. La operaci´on setLeft(p,x) acepta un apuntador p a un nodo de ´arbol binario sin hijo izquierdo. Crea un nuevo hijo izquierdo de node(p) con el campo de informaci´on x. La operaci´on setRight(p,x) es similar, excepto que crea un hijo derecho.
6.2.2.
Aplicaciones de ´arboles binarios
Un ´arbol binario es una estructura de datos u ´til cuando se trata de hacer modelos de procesos en donde se requiere tomar decisiones en uno de dos sentidos en cada parte del proceso. Por ejemplo, supongamos que tenemos un arreglo en donde queremos encontrar todos los duplicados. Esta situaci´on es bastante u ´til en el manejo de las bases de datos, para evitar un problema que se llama redundancia. Una manera de encontrar los elementos duplicados en un arreglo es recorrer todo el arreglo y comparar con cada uno de los elementos del arreglo. Esto implica que si el arreglo tiene n elementos, se deben hacer n comparaciones, claro, no es mucho problema si n es un n´ umero peque˜ no, pero el problema se va complicando m´as a medida que n aumenta. Si usamos un ´arbol binario, el n´ umero de comparaciones se reduce bastante, veamos c´omo. El primer n´ umero del arreglo se coloca en la ra´ız del ´arbol (como en este ejemplo siempre vamos a trabajar con ´arboles binarios, simplemente diremos ´arbol, para referirnos a un ´arbol binario) con sus sub´arboles izquierdo y derecho vac´ıos. Luego, cada elemento del arreglo se compara son la informaci´on del nodo ra´ız y se crean los nuevos hijos con el siguiente criterio: Si el elemento del arreglo es igual que la informaci´on del nodo ra´ız, entonces notificar duplicidad. Si el elemento del arreglo es menor que la informaci´on del nodo ra´ız, entonces se crea un hijo izquierdo. Si el elemento del arreglo es mayor que la informaci´on del nodo ra´ız, entonces se crea un hijo derecho. Una vez que ya est´a creado el ´arbol, se pueden buscar los elementos repetidos. Si x el elemento buscado, se debe recorrer el ´arbol del siguiente modo: 61
Sea k la informaci´on del nodo actual p. Si x > k entonces cambiar el nodo actual a right(p), en caso contrario, en caso de que x = k informar una ocurrencia duplicada y en caso de que x ≥ k cambiar el nodo actual a left(p). El siguiente algoritmo leer numero buscado >> n tree=makeTree(n) while(hay numeros en el arreglo){ leeSiguienteNumero >> k p=q=tree; while(k!=info(p)&&q!=NULL){ p=q if(k
´ Figura 28. Arbol binario para encontrar n´ umeros duplicados
Para saber el contenido de todos los nodos en un ´arbol es necesario recorrer el ´arbol. Esto es debido a que solo tenemos conocimiento del contenido de la direcci´on de un nodo a la vez. Al recorrer el ´arbol es necesario tener la direcci´on de cada nodo, no necesariamente todos al mismo tiempo, de hecho normalmente se tiene la direcci´on de uno o dos nodos a la vez; de manera que cuando se tiene la direcci´on de un nodo, se dice que se visita ese nodo. 62
Aunque hay un orden preestablecido (la enumeraci´on de los nodos) no siempre es bueno recorrer el ´arbol en ese orden, porque el manejo de los apuntadores se vuelve m´as complejo. En su lugar se han adoptado tres criterios principales para recorrer un ´arbol binario, sin que de omita cualquier otro criterio diferente. Los tres criterios principales para recorrer un ´arbol binario y visitar todos sus nodos son, recorrer el ´arbol en: preorden: Se ejecutan las operaciones: 1. Visitar la ra´ız 2. recorrer el sub´arbol izquierdo en preorden 3. recorrer el sub´arbol derecho en preorden entreorden: Se ejecutan las operaciones: 1. recorrer el sub´arbol izquierdo en entreorden 2. Visitar la ra´ız 3. recorrer el sub´arbol derecho en entreorden postorden: Se ejecutan las operaciones: 1. recorrer el sub´arbol izquierdo en postorden 2. recorrer el sub´arbol derecho en postorden 3. Visitar la ra´ız Al considerar el ´arbol binario que se muestra en la figura 28 usando cada uno de los tres criterios para recorrer el ´arbol se tienen las siguientes secuencias de nodos: En preorden: h14, 4, 3, 9, 7, 5, 15, 18, 16, 17, 20i En entreorden: h3, 4, 5, 7, 9, 14, 15, 16, 17, 18, 20i En postorden: h3, 5, 7, 9, 4, 17, 16, 20, 18, 15, 14i Esto nos lleva a pensar en otra aplicaci´on, el ordenamiento de los elementos de un arreglo. Para ordenar los elementos de un arreglo en sentido ascendente, se debe construir un ´arbol similar al ´arbol binario de b´ usqueda, pero sin omitir las coincidencias. El arreglo usado para crear el ´arbol binario de b´ usqueda fue <14,15,4,9,7,18,3,5,16,4,20,17,9,14,5> El ´arbol de ordenamiento es el que se muestra en la figura 29 Para ordenar los elementos de este arreglo basta recorrer el ´arbol en forma de entreorden. 63
´ Figura 29. Arbol binario para ordenar una secuencia de n´ umeros
¿Cu´al ser´ıa el algoritmo para ordenarlo de manera descendente?
6.3.
Representaci´on en C/C++ de los ´ arboles binarios
Vamos a estudiar estas representaciones por partes, primero los nodos y el ´arbol; despu´es las operaciones para el manejo del ´arbol.
6.3.1.
Representaci´on de los nodos
Los nodos de los ´arboles binarios son estructuras en C/C++ que estan compuestas por tres partes: Un apuntador al sub´arbol izquierdo, left Un apuntador al sub´arbol derecho, right Una parte de informaci´on, que puede ser una estructura en s´ı misma, info. Adicionalmente es muy u ´til poner un apuntador al padre del nodo. father. Usando una implementaci´on de arreglos tenemos: #define numNodes 500 struct nodeType{ int info; int left; int right; int father; }; struct nodeType node[numNodes]; y usando una representaci´on con memoria din´amica, los nodos de un ´arbol se puede representar tambien con una estructura en C/C++ : struct nodeType{ 64
int info; struct nodeType *left; struct nodeType *right; struct nodeType *father; }; struct nodeType *nodePtr; La operaciones info(p), left(p), right(p) y father(p) se implementar´ıan mediante referencias a p->info, p->left, p->right y p->father respectivamente. Las rutinas getnode y freenode simplemente asignan y liberan nodos usando las rutinas malloc y free. nodePtr makeTree(int x){ nodePtr p; p = getNode(); p->info = x; p->left = NULL; p->right = NULL; return p; } La rutina setLeft(p,x) establece un nodo con contenido x como el hijo izquierdo de node(p). void setLeft(nodePtr p, int x){ if(p == NULL) std::cout<<"Insercion nula\n"; else if(p->left != NULL) std::cout<<"Insercion no valida\n"; else p->left=maketree(x); } La rutina para setRight(p,x) es similar a la rutina anterior. Cuando se establece la diferencia entre los nodos de hojas y los no-hojas, los nodos que no son hojas se llaman nodos internos y los nodos que s´ı son hojas se llaman nodos externos.
6.3.2.
Recorridos de ´arbol binario en C/C++
Aqu´ı usaremos recursividad para hacer estas rutidas de los recorridos de ´arboles binarios. Las rutinas se llaman preTr, inTr y postTr, que imprimen el contenido de los nodos de un ´arbol binario en orden previo, en orden 65
y en orden posterior, respectivamente. El recorrido en pre orden se logra con esta rutina: void preTr(nodePtr tree){ if (tree != NULL){ std::cout<info; preTr(tree->left); preTr(tree->right); } } El recorrido en entre-orden se logra con esta rutina: void inTr(nodePtr tree){ if (tree != NULL){ inTr(tree->left); std::cout<info; inTr(tree->right); } } y el recorrido en post-orden se logra con esta rutina: void postTr(nodePtr tree){ if (tree != NULL){ postTr(tree->left); postTr(tree->right); std::cout<info; } }
6.4.
´ Arboles
Hasta ahora hemos visto los ´arboles binarios que son aquellos ´arboles que sus nodos solamente pueden tener un m´aximo de dos hijos. Cuando ocurre que los nodos tienen cualquier n´ umero finito de hijos, son ´arboles (en genreal). De manera que Definici´ on 12 Un ´arbol es un conjunto finito no vac´ıo de elementos en el cual un elemento se denomina la ra´ız y los restantes se dividen en m ≥ 0 subconjuntos disjuntos, cada uno de los cuales es por s´ı mismo un ´ arbol. Cada elemento en un ´arbol se denomina un nodo del ´ arbol 66
Un nodo sin sub´arboles es una hoja. Usamos los t´erminos padre, hijo, hermano, antecesor, descendiente, nivel y profundidad del mismo modo que en los ´arboles binarios. El grado de un nodo es en n´ umero m´aximo de hijos que al´ un nodo tiene. Un ´ arbol ordenado de define como un ´arbol en el que los sub´arboles de cada nodo forman un conjunto ordenado. En un ´arbol ordenado, podemos hablar del primero, segundo o u ´ltimo hijo de un nodo en particular. El primer hijo de un nodo en un ´arbol ordenado se denomina con frecuencia el hijo m´ as viejo de este nodo y el u ´ltimo se denomina el hijo m´ as joven. V´ease la figura 30. Un bosque es un conjunto ordenado de ´arboles ordenados.
Figura 30. El ´arbol de la izquierda es ordenado y el ´arbol de la derecha es un ´arbol no ordenado.
6.4.1.
Representaci´on din´amica en C de los ´ arboles
Al igual que en los ´arboles binarios, los nodos en un ´arbol tienen una parte de informaci´on, un apuntador al padre y uno o m´as apuntadores a los hijos. De manera que una soluci´on es crear una estructura que incluya una lista din´amica de apuntadores, como lo muestra la figura 31.
Figura 31. Representaci´on con listas de los nodos de un ´arbol
struct treeNode{ 67
int info; struct treeNode *father; struct treeNode *son; struct treeNode *next; }; typedef struct treeNode *nodePtr; Si todos los recorridos se realizan de un nodo a sus hijos se omite el campo father. Incluso si es necesario acceder al padre de un nodo, el campo father se omite colocando un apuntador al padre en el campo next del hijo m´as joven, en lugar de dejarlo en null. Se podr´ıa usar un campo l´ogico adicional para indicar si el campo next apunta al siguiente hijo “real” o al padre. Si consideramos que son corresponde al apuntador left de un nodo de ´arbol binario y que next corresponde a su apuntador right, este m´etodo representa en realidad un ´arbol ordenado general mediante un ´arbol binario.
6.4.2.
Recorridos de ´arbol
Los m´etodos de recorrido para ´arboles binarios inducen m´etodos para recorrer los ´arboles en general. Si un ´arbol se representa como un conjunto de nodos de variables din´amicas con apuntadores son y next, una rutina en C/C++ para imprimir el contenido de sus nodos se escribir´ıa como: void inTr(nodePtr tree){ if (tree != NULL){ inTr(tree->left); std::cout<info; inTr(tree->right); } } Las rutinas para recorrer el ´arbol en los dem´as ordenes son similares. Estos recorridos tambi´en se defininen directamente as´ı: Orden previo: similar al caso binario. 1. Visitar la ra´ız 2. Recorrer en orden previo los sub´arboles de izquierda a derecha Las demas rutinas son similares. Un bosque puede ser representado medianto un ´arbol binario. 68
Para hacer esta representaci´on, la ra´ız de cada ´arbol se coloca en una lista de apuntadores; luego para cada nodo en la lista (la ra´ız de cada ´arbol) se procede del siguiente modo: 1. Se crea una lista de sub´arboles izquierdos con los apuntadores a cada uno de los ´arboles en el bosque. 2. si un nodo tiene m´as de un hijo, entonces se crea un sub´arbol izquierdo y se forma una lista de sub´arboles izquierdos con todos los hijos de ese nodo.
Figura 32. Arriba: Un bosque de ´arboles. Abajo: El ´arbol binario que corresponde a ese bosque.
Para recorrer los nodos de un bosque, es preferible convertir todo el bosque en un ´arbol binario correspondiente, como se ilustra en la figura 32. Cuando ya se tiene el ´arbol binario que corresponde a ese bosque, entonces se aplican las rutinas ya conocidas. Si el bosque es un bosque ordenado, es decir, que todos los ´arboles del bosque son ´arboles ordenados; entonces un recorrido en entreorden dar´a como resultado una secuencia de nodos ordenada en sentido ascendente.
6.5.
Ejercicios de programaci´on
1. Escriba un programa que acepte un apuntador a un nodo y devuelva un valor verdadero si este nodo es la ra´ız de un ´arbol binario v´alido y falso en caso contrario. 2. Escriba un programa que acepte un apuntador a un ´arbol binario y un apuntador a un nodo del ´arbol, y devuelva el nivel del nodo en el ´arbol. 3. Escriba un programa para ejecutar el experimento siguiente: genere 100 n´ umeros aleatorios. Conforme se genera cada n´ umero, ins´ertelo en un 69
a´rbol de b´ usqueda binaria inicialmente vac´ıo. Despu´es de insertar los 100 n´ umeros, imprima el nivel de la hoja que tiene el nivel m´as grande y el nivel de la hoja que tiene el nivel m´as chico. Repita este proceso 50 veces. Imprima una tabla que indique cu´antas veces de las 50 ejecuciones produjeron una diferencia entre el nivel de hoja m´aximo y m´ınimo de 0,1,2,3, y as´ı sucesivamente. 4. Implemente los recorridos de los ´arboles binarios. 5. Si un bosque se representa mediante un ´arbol binario, muestre que el n´ umero de v´ınculos derechos nulos es 1 mayor que el n´ umero de no hojas del bosque.
70
7.
Grafos
En esta parte del curso vamos a retomar la idea de los gr´afos. Hasta ahora homos visto las listas y los ´arboles como casos especiales de los grafos. Resumiendo, las listas son grafos en donde cada nodo tiene una arista que sale y una arista que llega, excepto un par de nodos, uno de esos nodos es el inicio de la lista que tiene no tiene arista que entra; y el otro nodo es el final de la lista que no tiene arista que sale; En los ´arboles, los nodos tienen una arista que llega (la del padre) y una o m´as aristas que salen (los hijos). Como veremos m´as adelante con mucho mayor detalle, los nodos en los grafos no tienen l´ımite de aristas que salen o aristas que lleguen, por eso tanto las listas como los ´arboles son casos particulares de los grafos.
7.1.
Recordatorio de las definiciones
Un grafo consiste de una tupla G = hN, A i, en donde N es un conjunto de elementos llamados nodos; y A es una relaci´on, representada por un conjunto de pares ordenados de nodos. El conjunto N de nodos debe de ser un conjunto no-vac´ıo, esto significa que para que exista un grafo es necesario al menos un nodo. El conjunto A de aristas puede ser el conjunto vac´o. En la figura 33 se muestra un grafo y sus conjuntos de nodos y de aristas.
Figura 33. Grafo dirigido o digrafo
Si las aristas de un grafo no est´an dirigidas se omiten las flechas, y se dice entonces que es un grafo (no un grafo dirigido). Cuando en las aristas no hay flechas, se entiende que hay una relaci´on reflexiva, es decir, si para un grafo 71
G = hN, A i; a, b ∈ N y se tiene que (a, b), (b, a) ∈ A , entonces no se dibujan las flechas. Porque la flecha indica el sentido de la relaci´on. Si G = hN, Ai es un grafo, los siguientes t´erminos son frecuentemente usados al trabajar con G: Nodo incidente: Si ∃ (a, b) ∈ A entonces tanto el nodo a como el nodo b son nodos incidentes. Grado de incidencia: Se define para cada nodo, y es su m´aximo numero de incidencias. Tambi´en se conoce con el nombre de “valencia”. Grado interno: Tambi´en se define para cada nodo y es el n´ umero de aristas que llegan a ese nodo. Otro nombre para este t´ermino es “valencia de entrada”. Grado externo: Para cada nodo es el n´ umero de aristas que salen del nodo. Se conoce tambi´en con el nombre de “valencia de salida”. Adyacencia: Si a, b ∈ N , el nodo a es adyacente al nodo b si (a, b) ∈ A . Note que si (b, a) ∈ A , pero (a, b) 6∈ A , entonces el nodo a no es adyacente al nodo b, pero el nodo b si es adyacente al nodo a. Sucesor: Si el nodo a es adyacente al nodo b, entonces el nodo b es el sucesor del nodo a. Antecesor: Si el nodo a es adyacente al nodo b, entonces el nodo a es el antecesor del nodo b. Es posible asociar una etiqueta a cada arista, como se muestra en la figura 34. La etiqueta asociada con cada arista se denomina peso.
Figura 34. Grafo dirigido con pesos
Los grafos ponderados son relaciones definidas por un conjunto de elementos, en donde cada elemento es un trio ordenado (a, b, c) donde a, b ∈ N y c ∈ W , para alg´ un conjunto W de pesos. Con los grafos y grafos ponderados (los que tienen pesos) se pueden tener algunas operaciones b´asicas: 72
Con grafos: join(a,b): Agrega una relaci´on del nodo a al nodo b. Si la relaci´on no existe, entonces crea una relaci´on. removeArc(a,b): Quita un arco del nodo a al nodo b Con grafos ponderados: joinWt(a,b,w): Agrega una relaci´on del nodo a al nodo b y le asocia el peso w. Si la relaci´on no existe, entonces de crea la relaci´on y le asocia el peso indicado. removeArcWt(a,b): Quita un arco del nodo a al nodo b con peso w. La operaci´on isAdjacent(a,b) devuelve un valor TRUE si el nodo a es adyacente al nodo b, y devuelve un valor FALSE en caso contrario. Una trayectoria de longitud k del nodo a al nodo b se define como una secuencia de k + 1 nodos hn1 , n2 , . . . , nk , nk+1 , i tal que n1 = a, nk+1 = b y isAdjacent(ni ,ni+1 ) para todas las 1 ≤ i < k. Una trayectoria de longitud 1 un nodo a s´ı mismo es un autociclo. Si existe una trayectoria de longitud mayor que 1 de un nodo a s´ı mismo, entonces es un ciclo. Si el grafo es ac´ıclico y dirigido, entonces se llama dag (directed acyclic graph). 7.2.
Aplicaci´on ejemplo
Supongamos el grafo ponderado de la figura 35, este grafo tiene como conjunto de nodos N = {3, 10, 17, 5, 8, 6} y una relaci´on R = {(3, 10, 1), (10, 17, 7), (8, 17, 1), (5, 8, 3), (5, 6, 1), (6, 17, 5)}
Figura 35. Grafo G = hN, Ri
Se desea saber si existe un camino entre un par de nodos dado.
73
Referencias [LAT97] Yedidyah Langsam, Moshe J. Augenstein, and Aaron M. Tenenbaum. Estructura de datos con C y C++. Prentice-Hall, Inc., 2a edition, 1997. [MP97] William H. Murray and Chris H. Pappas. Manual de Borland C++, volume ISBN: 0-07-882216-5. Osborne McGraw-Hill, 1997. [TA83] Aaron M. Tenenbaum and Moshe J. Augenstein. Estructura de datos en Pascal. Phh-PrenticeHall, 1983.
74