You are on page 1of 74

Apuntes para el curso de

“Estructuras de datos en C/C++ ”

Dr. Abdiel E. Cáceres González


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ósitos, y por eso es importante saber sus ventajas y desventajas. Este documen-
to es una colección de apuntes para el curso de Estructuras de Datos. Los apuntes
se han tomado de algunas fuentes que son detalladas en la sección de bibliografı́a.

Índice

1. Preliminares de programación en C/C++ 3

1.1. Arreglos 3

1.2. Apuntadores 10

1.3. Estructuras C/C++ 15

1.4. Ejercicios de programación 19

2. La pila 21

2.1. Definición y ejemplos 21

2.2. Operaciones básicas 24

2.3. Ejemplo: Número de paréntesis 25

2.4. La estructura de datos Pila en C/C++ 26

2.5. La representación en C/C++ de las operaciones de una pila 27

2.6. Problemas de programación 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ón 34

4. Recursión 36

4.1. Peligros en la recursividad 39

4.2. Ejercicios de programación 40

5. Listas 42

5.1. Grafos 42

5.2. Listas simplemente encadenadas 44

5.3. El uso de memoria dinámica en C/C++ 51

5.4. Listas ligadas usando memoria dinámica 54

5.5. Ejercicios de programación 56

6. Árboles 57

6.1. Concepto general de árbol 57

6.2. Árboles binarios 57

6.3. Representación en C/C++ de los árboles binarios 64

6.4. Árboles 66

6.5. Ejercicios de programación 69

7. Grafos 71

7.1. Recordatorio de las definiciones 71

7.2. Aplicación ejemplo 73

2
1. Preliminares de programación en C/C++

En esta sección recordaremos tres temas de programación en C/C++ que son


fundamentales para estudiar estructuras de datos; estos temas son los arreg-
los, los registros y los punteros. Los tres temas han sido tomados fundamen-
talmente de [MP97]

1.1. Arreglos

Definición 1 Un arreglo se compone de elementos de igual tamaño almace-


nados 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ándar; 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ño. Puesto que todos los
elementos son del mismo tamaño y ya que este hecho se utiliza para ayudar
a determinar cómo localizar un elemento dado, resulta que los elementos son
almacenados en localidades de memoria contiguas.

Lo más 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ás
especı́ficamente, b) un puntero constante -significa una dirección de memoria
bloqueada para el primer elemento de un arreglo-. Por ejemplo, aunque una
declaración de arreglo toma la fórma genérica:

Tipo_ElementoArray NombreArray [ NumeroDeElementos ]

El compilador ve la declaración como

Tipo_ElementoArray * const NombreArray = &NombreArray[0];

Por esta razón, 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ón.

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ón inicial del propio arreglo.

1.1.1. Declaraciones de un arreglo

La sintaxis de declaración de arreglos es:

tipo nombre_arreglo [numero_de_elementos];

Los siguientes son dos ejemplos de declaraciones de arreglos válidas 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álido

En la figura 1 se muestra el primer arreglo que fue declarado con el tipo


de números enteros, llamado CoordenadasDePantalla, ocupa en memoria 5
localidades de memoria contiguas, cada una de ellas capaz de almacenar un
número entero. Actualmente es común que los números 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ón del tamaño del arreglo hasta la ejecución del
programa. La expresión debe ser un valor constante, para que el compilador
sepa exactamente cuánto espacio de memoria tiene que reservar para el arreglo.

Una buena práctica de programación 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ño del arreglo definido.

1.1.2. Iniciación 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áticos.
Explı́cita: Cuando son creados, suministrando datos de iniciación
Tiempo de ejecución: Durante la ejecución del programa cuando se asig-
nan 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ón de que
el primer elemento está en una posición 0, no 1. De manera que el último
elemento del arreglo lo encontramos en n-1, donde n es el número de elementos.
Supongamos la siguiente declaración:

int Estado[Rango_Maximo_Estado]={-1,0,1};

La siguiente sentencia tiene acceso a -1:

Estado[0];

Si escribimos Estado[3] causará un error porque no hay 4 elementos.

1.1.4. Cálculo del tamaño 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<iostream.h>

5
#define maxDiasSemana 7

int main(void){
int desplazamiento, maxHorasDiarias[maxDiasSemana];

cout<<"sizeof(int) es"<<(int)sizeof(int)<<"\n\n";

for(desplazamiento=0;desplazamiento<maxDiasSemana;
desplazamiento++)
cout<<"&maxHorasDiarias["
<<desplazamiento
<<"]="
<<&maxHorasDiarias[desplazamiento]<<"\n";
return 0;
}

1.1.5. Arreglos multidimensionales

El término dimensión representa el número de ı́ndices utilizados para referirse


a un elemento particular en el arreglo. Los arreglos de más de una dimensión
se llaman arreglos multidimensionales.

/*
/ dosDim.cpp
*/

#include <iostream>
#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<numFilas;despFila++)
for(despColumna=0;despColumna<numColumnas;despColumna++){
desplazamiento=numColumnas-despColumna;
multiplo=despFila;
despCalculados[despFila][despColumna]=
(despFila+1)*despColumna+desplazamiento * multiplo;
};
for(despFila=0;despFila<numFilas;despFila++){
std::cout<<"Fila actual: "<<despFila<<"\n";
std::cout<<"Distancia relativa desde la base: "<<"\n";

6
for(despColumna=0;despColumna<numColumnas;despColumna++)
std::cout<<" "
<<despCalculados[despFila][despColumna]
<<" ";
std::cout<<"\n\n";
}

return 0;
}
}

El programa utiliza dos ciclos for para calcular e inicial cada uno de los
elementos del arraglo a su respectiva distancia relativa desde la base. El arreglo
creado tiene 4 filas y 5 columnas por fila, haciendo un total de 20 elementos
enteros.

Los arreglos multidimensionales son almacenados de forma lineal en la memo-


ria de la computadora. Los elementos en los arreglos multidimensionales están
agrupados desde el ı́ndice más a la derecha hacia el centro. En el ejemplo an-
terior, fila 1, columna 1 serı́a el elemento 3 del arreglo almacenado. Aunque el
cálculo del desplazamiento aparece un poco difı́cil, es referenciado fácilmente
cada elemento del arreglo.

La salida del programa anterior es:

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ámetros de fun-


ciones:

1. Todos los arreglos son pasados en llamada-por referencia.


2. Debido a que el arreglo es pasado en llamada por referencia, serı́a in-
correcto para la función llamada devolver el arreglo en una sentencia
return();. Esta sentencia está de más.
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ón
del elemento.

/*
// ereArray.xcode
*/
#include <iostream>
#include <ctype.h>

#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<maxArray;
desplazamiento++)
std::cout<<Array[desplazamiento];
std::cout<<"\n";
ArrayMayuscula(Array);
for(desplazamiento=0;desplazamiento<maxArray;
desplazamiento++)
std::cout<<Array[desplazamiento];
return 0;
}

void ArrayMayuscula(char Array[maxArray])


{
for(int desplazamiento=0;desplazamiento<maxArray;
desplazamiento++)
Array[desplazamiento]=toupper(Array[desplazamiento]);
//Aqui return array seria incorrecto
}

8
La salida del programa demuestra que el arreglo se pasa en llamada por refer-
encia, ya que el primer ciclo for da como salida los contenidos de minúsculas
originales: aeiou, mientras que el segundo ciclo for en main() da como salida
los contenidos del arreglo después del llamado a la función ArrayMayuscula():
AEIOU.

Claramente, dentro del cuerpo de la función ArrayMayuscula(), ha cambiado


el arreglo de regreso en la función main(). el siguiente ejemplo es una simple
modificación de este algoritmo, sólo que en vez de pasar el arreglo completo,
se pasa cada elemento individual:

/*
// ereArray2.xcode
*/

#include <iostream>
#include <ctype.h>

#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<maxArray;desplazamiento++)
std::cout<<Array[desplazamiento];
std::cout<<"\n";

for(desplazamiento=0;desplazamiento<maxArray;desplazamiento++)
ElementosArrayMayuscula(Array[desplazamiento]);

for(desplazamiento=0;desplazamiento<maxArray;desplazamiento++)
std::cout<<Array[desplazamiento];
return 0;
}

void ElementosArrayMayuscula(char unChar)


{
unChar=toupper(unChar);
}

La salida del programa es:

9
aeiou
aeiou
valarray has exited with status 0.

1.2. Apuntadores

Definición 2 Un apuntador es una variable que contiene una dirección 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ón
de la variable en lugar de su contenido. Ası́ que para asignar la dirección 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ón 7751

En la figura 2 se ilustra el nombre de la variable contenidoRAM y se observa


que se encuentra en la dirección 7751 de la memoria. El contenido de esta
localidad no se muestra. Una variable que contiene una dirección, tal como
direccionRAM, se llama variable apuntador o simplemente apuntador.

Despues que la sentencia anterior se ejecuta, la dirección de contenidoRAM


será asignada a la variable apuntador direccionRAM. La relación se expresa
diciendo que direccionRAM apunta a contenidoRAM. La figura 3 ilustra esta
relación.

El accceso al contenido de una celda cuya dirección está 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á de 20 (véase la figura 4).

10
Figura 3. Notación 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ón para cada variable. Para definir una variable
apuntador direccionRAM que pueda contener la dirección de una variable
int, se escribe:

int *direccionRAM;

Realmente existen dos partes separadas en esta declaración. 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ón a un int:
int *

11
En C/C++ una variable apuntador contiene la dirección 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 éste para apuntar a otro tipo de dato, pueden ocurrir
errores en tiempo de ejecución y advertencias en tiempo de compilación. Una
práctica de programación pobre serı́a definir un apuntador de una forma y
luego utilizar éste de alguna otra forma. Por ejemplo:

int *direccion_int;
float un_float = 98.34;

direccion_int = &un_float;

1.2.2. Utilización de punteros en sentencias sencillas

Veamos el siguiente ejemplo:

/*
// changeVals.xcode
*/

(01) #include <iostream>


(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:"<<A_int<<"\n";
(08) std::cout<<"El contenido de B_int es:"<<B_int<<"\n";
(09) direccion_int = &A_int;
(10) Temp_int = *direccion_int;
(11) *direccion_int = B_int;
(12) B_int = Temp_int;
(13) std::cout<<"Despues del intercambio:"<<"\n\n";
(14)
(15) std::cout<<"El contenido de A_int es:"<<A_int<<"\n";
(16) std::cout<<"El contenido de B_int es:"<<B_int<<"\n";
(17) return 0;
(18) }

12
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 éstas. Supondremos que la dirección de
memoria asignada para la variable A int es la dirección 5328, y la dirección
en memoria RAM asignada para la variable B int es la dirección 7916, y la
celda llamada Temp int se le ha asignado la dirección 2385. Véase la figura 5;

Figura 5. Descripción 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 ésta un nombre.

Luego, en la lı́nea (09), la tercera sentencia asigna a direccion_int la direc-


ción de A_int (figura 6).

Figura 6. direccion int dada la dirección de A int

La lı́nea (10) utiliza la expresión *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ón al-
macenarı́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ón.

13
Este puede ser un error muy difı́cil de localizar puesto que muchos compi-
ladores 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ño que un entero en una PC.

La sentencia (11) copia el contenido de la variable B int en la celda apuntada


por la dirección almacenada en direccion int(figura 7):

*direccion_int = B_int;

Figura 7. Se copia el contenido de B int usando la notación de flecha de apuntadores

La ú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ón normal.


Debemos de asegurarnos de comprender la diferencia entre qué se referencia
cuando una variable puntero está precedida por el operador de indirección y
cuándo no está 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ón de otra celda que puede contener un entero.

14
1.2.3. Utilización incorrecta del operador de dirección

No se puede utilizar el operador de dirección sobre toda expresión C/C++ . El


siguiente ejemplo demuestra aquellas situaciones donde no se puede aplicar el
operador de dirección &.

puedeAlmacenarDireccionDeConstante = &37;

int RAM_int = 5;
puedeAlmacenarDireccionDeExpresionTemp = &(RAM_int +15);

puedeAlmacenarDireccionDeRegistro = &varRegistro;

La primera sentencia trata de obtener ilegalmente la dirección de un valor


constante integrado. La sentencia no tiene sentido puesto que 37 no tiene una
celda de memoria asociada con éste.

La segunda sentencia de asignación intenta devolver la dirección de la expre-


sión RAM_int+15. No existe dirección asociada con la expresión puesto que la
expresión en sı́ misma es realmente un proceso de manipulación de pila.

Normalmente, el último ejemplo respeta la demanda del programador para


definir varRegistro como un registro más que como una celda de almace-
namiento en la memoria interna. Por consiguiente, no podrı́a devolverse y
almacenarse la dirección de celda de memoria. El compilador C/C++ da la
memoria de variable, no el almacenamiento de registro.

1.3. Estructuras C/C++

Definición 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ón de una estructura puesto que ésta 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ón


requiere el uso del campo etiqueta de la estructura. Si esta sentencia está con-
tenida dentro de una función, entonces la estructura, llamada stbarco_usado,
tiene un ámbito local a esa función. Si la sentencia está contenida fuera de
todas las funciones de programa, la estructura tendrá un ámbito 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ón de variable va antes del punto y coma final. Cuando se


asocia sólo 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ón 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á la marca del stbarco_usado en el arreglo de caracteres,


mientras la próxima sentencia imprimirá 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 <iostream>

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ándares, pero no se
checan los tipos o los números de argumentos con el número de parámetros.
Los prototipos se usan para inicializar apuntadores a funciones, antes de
que las funciones sean definidas.
La lista de parámetros se usa para checar la correspondencia de los argu-
mentos en la llamada a la función con los parámetros en la definición de la
función

const en parmetros de funciones

El especificador const puede ser utilizado en la definición de parámetros 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ón no podrá cambiar dichos
argumentos:

Con parámetros de funciones que sean de tipo matriz (que se pasan por
referencia). Ejemplo: int strlen(const char[]);
Cuando los parámetros son punteros (a fin de que desde dentro de la función
no puedan ser modificados los objetos referenciados). Ejemplo: int printf
(const char *format, ...);
Cuando el argumento de la función sea una referencia, previniendo ası́ que la
función pueda modificar el valor referenciado. Ejemplo: int dimen(const
X &x2);

1.4. Ejercicios de programación

1. El siguiente algoritmo es el método de inserción 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étodo de inserción
b) ilustre cómo opera el algoritmo insertionSort(A) usando como en-
trada el arreglo A=<31,41,59,26,41,58>
2. Reescriba el programa y nómbrelo insertionSortNondec para que or-
dene los elementos en orden decreciente
3. Considere el siguiente problema de búsqueda:
Input: Una secuencia de n números 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úsqueda.
4. Considere el problema de sumar dos números binarios de longitud n.
Cada número se almacena en uno de los arreglos A y B de tamaño n. La
suma se almacena en un arreglo C de tamaño n + 1, también como un
número binario. Escriba un programa que resuelva este problema.

20
2. La pila

Uno de los conceptos más útiles en las ciencias de la computación es el de pila.


En esta sección vamos a definir este concepto de manera abstracta y veremos
cómo se usa para convertirse en una herramienta concreta y de gran valor en
las soluciones de problemas. La información contenida en esta sección se ha
tomado de [TA83].

2.1. Definición y ejemplos

Definición 4 Una pila (stack) es una colección 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ás alto” en la pila que el otro. En
la figura 9 el elemento F es el más alto de todos los elementos que están en la
pila. El elemento D es el más 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ómo funciona esta estructura, debemos agregar un nuevo ele-
mento, el elemento G. Después de haber agregado el elemento G a la pila, la
nueva configuración es la que se muestra en la figura 10.

De acuerdo con la definición, existe solamente un lugar en donde cualquier


elemento puede ser agregado a la pila. Después de haber insertado el nuevo
elemento, G ahora es el elemento en la cima. Debedos aclarar en qué pila
deseamos insertar elementos, puesto que es posible tener más de una pila al
mismo tiempo.

21
Figura 10. Operación 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á en la cima de la pila y solamente podemos retirar el elemento que está en
la cima. Para que la sentencia “retira C de la pila” tenga sentido, debemos
replantear las órdenes 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 configu-


ración global de la pila es como se muestra en la figura 11

Figura 11. Operación de retirar de la pila P

El concepto de pila es muy importante en computación y en especial en teorı́a


de lenguajes de programación. En lenguajes procedurales como Pascal o C, la
pila es una estructura indispensable, debido a las llamadas a función.

Resulta que el flujo de instrucciones va de arriba hacia abajo, y cuando ocurre


una llamada a alguna función, el estado global del sistema se almacena en un
registro y éste 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ún procedimiento, se recupera el registro que
está en la cima de la pila. En ese registro están los valores de las variables como
estaban antes de la llamada a la función, o algunas pueden haber cambiado si
valor, dependiendo del ámbito de las variables.

Cada elemento en la pila que es retirado, significa que se ha terminado de


ejecutar alguna función. Cuando se termina de ejecutar el programa, la pila
de llamadas a subprogramas debe haber quedado en 0 también, 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 en-
contrar errores.

La dinámica de la pila, es decir, la manera en cómo entran los datos a la


estructura de datos y cómo salen, se denomina fifo, que viene del ingés first
in first out (primero en entrar, primero en salir).

Figura 12. Dinámica 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á posteriormente debajo de F.

Lo que sucede es que, cuando se retira el elemento G se debe hacer una evalu-
ación 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és de haber insertado F, insertamos de nuevo los elementos F y G en ese


orden, además de insertar finalmente el elemento I que queda en la cima de la
pila. Enseguida veremos con más detalle las operaciones básicas de las pilas.

23
2.2. Operaciones básicas

Las operaciones básicas de una pila son:

1. En la pila S, insertar un elemento e: push(S,e),


2. Retirar un elemento de la pila S: pop(S),
3. Verificar si la pila S está vacı́a: stackempty(S) y
4. Saber cuál es el elemento en la cima de la pila S: stacktop(S).

enseguida cada una de estas operaciones:

2.2.1. La operación push

Esta operación sirve para insertar un elemento e en la pila S, lo vamos a


escribir como:

push(S,e)

Después de hacer esta operación sucede que:

El elemento en la cima de la pila S ahora es e

2.2.2. La operación 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ón pop escribiéndola
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ón 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ón, e era el elemento en la cima, ahora ya no lo es más.
El apuntador “cima” decrece en una unidad.

2.2.3. La operación stackempty

Esta operación toma como argumento una estructura del tipo stack (pila) y
devuelve un valor booleano, devuelve un true si la pila está vacı́a y devuelve

24
un false si la pila tiene al menos un elemento, es decir:


 true si S tiene 0 elementos

stackempty(S) =
 f alse si S tiene más de 0 elementos

2.2.4. La operación stacktop

La operación stacktop(S) devuelve el valor del elemento en la cima de la pila


S. Para hacer esta operación escribiremos:

v=stacktop(S)

las implicaciones de usar esta operación son:

Se hace una copia del elemento que está en la cima


En realidad se hacen dos operaciones, primero se hace v=pop(S), luego un
push(S,v), porque después de la operación stacktop, la pila S queda sin
cambio alguno.

2.3. Ejemplo: Número de paréntesis

Supongamos ahora la expresión ((5+6)*4)/(17+9), una de las condiciones


para que sea una expresión aritmética correcta en que tengas sus paréntesis
balanceados, ası́ que deseamos saber si el número de paréntesis que abres es
el mismo número de paréntesis que cierran.

Para resolver este problema usaremos el concepto de pila. La idea es simple.


Vamos a leer cada elemento de la expresión, si se trata de un paréntesis que
abre, entonces lo insertaremos en una pila; si se trata de un paréntesis que
cierra, entonces sacamos un elemento de la pila. Al terminar de leer la expre-
sión revisaremos si la pila está vacı́a, en cuyo caso habremos concluı́do que el
número de paréntesis que abre es el mismo que el número de paréntesis que
cierra y la expresión tiene paréntesis balanceados.

Veamos cómo funciona:

‘(’ : push(S,‘(’)
‘(’ : push(S,‘(’)
‘5’ : nada que hacer
‘+’ : nada que hacer
‘6’ : nada que hacer
‘)’ : v=pop(S)

25
‘*’ : nada que hacer
‘4’ : nada que hacer
‘)’ : v=pop(S)
‘/’ : nada que hacer
‘(’ : push(S,‘(’)
‘17’: nada que hacer
‘+’ : nada que hacer
‘9’ : 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 éxito, de otro mod
señalamos 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ón del balance de paréntesis en una expresión aritmética

2.4. La estructura de datos Pila en C/C++

Una pila está conformada por dos elementos:

Un espacio suficientemente grande para almacenar los elementos insertados


en la pila
Una parte que nos señale cuál 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ácilmente podemos describir un código en C/C++ que represente lo anterior-


mente 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ón en C/C++ de las operaciones de una pila

En esta sección veremos una implementación de las cuatro operaciones básicas


de las pilas. Todas estas operaciones se han hecho desde un punto de vista de
programación funcional, sin duda se pueden describir en un modelo orientado
a objetos.

2.5.1. La operación push

El siguiente segmento de código ilustra cómo se puede implementar la op-


eración insertar un elemento en una pila. Hemos supuesto que la pila ya
está 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ón push recibe dos parámetros: la


dirección 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ón 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ón pop

La operación pop se escribe en forma de código en C/C++ con la siguiente


secuencia de órdenes:

(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ón devuelve un tipo entero, el tipo de
elementos guardados en la pila; luego notamos que debemos dar sólo la direc-
ción de alguna variable de tipo estructura de pila (struct stack *). Obtener la
dirección se logra con el operador de indirección (&).

Las lı́neas (4) y (5) hacen todo el trabajo de esta función, se almacena el valor
que será devuelto en una variable de tipo entero y luego se decrementa el tope
de la pila.

2.5.3. La operación stackempty

La operación stackempty se describe en el siguiente segmento de código:

(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ón que se muestra en la lı́nea (1) establece que


se devuelve un valor booleano, y que se debe dar un parámetro, que es la
dirección de una localidad de memoria que almacena una estructura de tipo
pila. El objetivo de esta función 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ón stacktop

Este es un caso especial porque no se requiere hacer ningún código.

Esta función debe devolver un número 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ón para dar la dirección de la variable que
alberga una estructura de tipo pila. El siguiente segmento de código ilustra
cómo se han usado las funciones antes creadas, por supuesto que se pueden
separar y crear una nueva función 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: "<<valor<<"\n";
(5) push(&A,valor);
(6) } else
(7) std::cout<<"La pila esta vacia";
(8) break;
(9) }
...

2.6. Problemas de programación

Los siguientes ejercicios deben ser resueltos en un prográma (en C/C++ ):

1. Expresiones entrefijas y prefijas. Las expresiones aritméticas pueden


representarse de varias maneras, una de ellas, la más usual es la notación
entrefija.
La notación 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éntesis más interno establece la may-
or 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éntesis cuadrados son para ilustrar el ejemplo y no
son necesarios para su evaluación.
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álidos son: las letras
mayúsculas y minúsculas, los números enteros, los paréntesis normales,
los cuatro operadores (+, −, ∗, /, ) y el operador unario (−).

Figura 14. Ilustración 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ón: Placas (6 digi-
tos), Estado (2-3 caracteres, p.e. SON, DF, CHI, YUC), Marca, Mod-
elo, Año-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ún vehı́culo del estacionamien-
to, regresando los vehı́culos en el orden en que estaban.
e) Toda la corrida del programa debe hacerse hacia/desde la terminal
estándar.
3. Haga un programa que implemente 2 pilas en 1 arreglo A[1..n] de man-
era que ninguna pila se desborde a menos que el número de elementos en
ambas pilas sea n

30
3. Colas

Definición 5 Las colas son una estructura de datos similar a las pilas. Recorde-
mos que las pilas funcionan en un depósito en donde se insertan y se retiran
elementos por el mismo extremo. En las colas sucede algo diferente, se inser-
tan 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ámica 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ásicas


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 único elemento es la parte frontal y la parte
trasera de la cola al mismo tiempo.

31
Teóricamente no hay lı́mite para el tamaño 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ón remove sólamente se puede hacer si
la cola no está vacı́a.

3.1. Estructura de las colas en C/C++

De manera similar a las pilas, las colas definen una estructura no estándar, 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ún tipo especı́fico, puede incluso ser un


tipo estándar o no.
Un número que indica el elemento que está en la posición del frente de la
cola.
Un número que indica el elemento que está en la posición trasera de la cola.

Suponiendo que los elementos son números 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ón con arreglos es completamente válida, 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ás
elementos de lo que el arreglo puede almacenar, la operación insert podrı́a
quedar como:

void insert(struct cola *C, int e){


C->items[++C->rear]=e;
}

y al operación x=remove(Q)

int remove(struct cola *C){


return C->items[C->front++];

32
}

y finalmente la operación 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ón insert(CPA,x) inserta
el elemento x en la cola CPA; y la operación 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ólo permite la supresión


del elemento más 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ón empty(C) se aplica a cualquier tipo de cola y determina si una


cola de prioridad está vacı́a. Las operaciones de insertar y borrar se aplican
solamente si la pila no está vacı́a.

Los elementos de la cola de prioridad no necesitan ser números o caracteres


para que puedan compararse directamente. Pueden ser estructuras complejas
ordenadas en uno o varios campos. Por ejemplo, las agendas telefónicas constan
de apellidos, nombres, direcciones y números de teléfono y están ordenadas
por apellido.

A diferencia de las pilas y las colas, en las colas de prioridad se pueden sacar
los elementos que no están 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ál 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és es necesario hacer una compactación, reubicando los elementos
en el frente de la cola.
2. Cada supresión puede compactar el arreglo, cambiando los elementos
depués del elemento eliminado en una posición y después decrementando
rear en 1. La inserción no cambia. En promedio, se cambian la mitad de
los elementos de una cola de prioridad para cada supresión, por lo que
esta operación no es eficiente.

3.3. Ejercicio de programación

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 elimi-


narse elementos en cualquier extremo y en el cual pueden insertarse ele-
mentos en cualquier extremo. Llamemos a los dos extremos de un deque
left (izquierdo) y right (derecho). ?’cómo 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úrese 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ólo 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á en el extremo norte, se sacan todos los automóviles 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én 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úmero 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ándar) un mensaje cada vez que entra
o sale un auto. Cuando llega un carro, el mensaje debe especificar si hay
espacio o no para él 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ó el auto dentro del estacionamiento, incluyendo la salida misma,
pero no la llegada. Este número es 0 si el carro sale de la fila de espera.

35
4. Recursión

Un tema fundamental para los próximos temas es el de recusrión. La recursión


es muy importante tanto en mateáticas como em computación, pues se usa
recursión para definir procedimientos autosimilares.

Definición 6 Decimos que un objeto es recursivo si en su definición se nom-


bra a sı́ mismo.

En programación, una función es recursiva si en el ámbito de esa función 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ásico
es el factorial de un número.

Una manera de definir el factorial de un número n > 1 es:

n
Y
!n = i,
i=1

es decir, el producto de todos los números enteros menores o guales que él, lo
que se puede resolver fácilmente con una función iterativa, esto es, una función
con un ciclo que itere suficientes veces, incrementando un valor y entonces ir
almacenando en una variable el resultado de esas multiplicaciones.

Una implementación de esta definición iterativa es:

(1) int i,n;


(2) long double valorAc;
(4) valorAc=1.0;
(5) std::cout << "Numero entero:";
(6) std::cin>> n;
(7) for(i=1; i<=n; i++) valorAc = valorAc*i;
(8) std::cout<<"El factorial de "<<n<<" es:"<<valorAc;

El ciclo principal es en la lı́nea (7). No hay ningún truco hasta aquı́. La


única observación importante es en la lı́nea (2) en donde se declara el tipo
long double para el valor del resultado, la razón para tal acción es que el
número factorial crece muy rápido y aún con entradas en el rango de los
caracteres (hasta 255), el factorial es muy grande. Este procedimiento com-
putacional no hace uso de técnicas especiales empleadas para tratar números
grandes.

Sin embargo una solución más elegante es usar la definición recursiva, y esta
es:

36
!n = n ∗ !(n − 1)

El programa en C/C++ es el que se muestra a continuación:

( 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 "<<n<<" es: "<< factorial(n);
(10) return 0; }

Aquı́ hay varias cosas que señalar, en primer lugar se ha creado una nueva
función, a diferencia de la definición iterativa en donde era suficiente traba-
jar en el programa principal. Esta función se llama factorial (como era de
suponerse), y empieza su encabezado en la lı́nea (1).

Allı́ mismo en la misma lı́nea (1), es de notar que hemos emplado ahora el
tipo double tanto para el tipo devuelto como para el tipo del argumento, a
diferencia de la versión iterativa en donde empleábamos tipos diferentes. La
razón es que al iniciar la recursión el argumento es del tipo devuelto, asi que
deben ser del mismo tipo.

Cada llamada recursiva genera una entrada a una pila, en donde se guardan
(como elementos) los estados generales del sistema al momento de hacer la
llamada, entonces, cuando se termina la función se recupera una entrada de la
pila. En la figura 16 ilustra cómo funciona la recursividad cuando se intenta
obtener el factorial(5).

Figura 16. Recursividad cuando se ejecuta factorial(5)

37
4.0.1. La serie Fibonacci

Una de las series más famosas es sin duda alguna la serie de Fibonacci:

1, 1, 2, 3, 5, 8, 13, 21, 34, . . .

Un poco de observación es sufucuente para encontrar que cualquier número


(a partir del tercero de la serie, osea el segundo 1) es igual a la suma de los
dos números anteriores.

Daremos en primer lugar la versión iterativa. En este algoritmo deseamos


encontrar el n-ésimo número 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ón iterativa
empieza desde los primeros 1’s, sumándolos y encontrando el tercero, luego
para encontrar el cuarto número se suman el tercero (recién encontrado) y el
segundo, y ası́ en adelante hasta encontrar el número buscado.

#include <iostream>

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<n);
}
std::cout << "\nEl "<<n<<"-esimo numero de
la serie Fibonacci es: "<<fib;
return 0;
}

La definición recursiva para encontrar todos los n primeros números de la serie


Fibonacci es:

38

1

Si n = 1 ó n = 2
fib(n) =
 fib(n − 1) + fib(n − 2) Si n > 2

En el siguiente código, la solución que propone la recursividad resulta en una


programación elegante, aunque costosa. El código que hace esto es:

( 1) #include <iostream>
( 2) //====================
( 3) int fib(int val){
( 4) if ((val==1)||(val==2))
( 5) return 1;
( 6) else
( 7) return (fib(val-1)+fib(val-2));
( 8) }
( 9) //====================
(10) int main (int argc, char * const argv[]) {
(11) int n;
(12) std::cout<<"Numero entero:"; std::cin>>n;
(13) std::cout<<"\nEl "<< n
(14) <<"-esimo numero fibonacci es: "<< fib(n);
(15) return 0;
(16) }

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ón 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á el momento en que la pila “se desborde”, que por
cierto es un término usado en computación para decir que ya no hay más
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ón,
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ón, es cuando deja
de hacer llamadas a la función recursiva y hace evaluaciones devolviendo
los resultados. En el ejemplo de la serie de Fibonacci, el paso base está en
la lı́nea ( 5). Además se debe asegurar de que es posible entrar a este paso.
2. El paso recursivo: Es la parte de la definición que hace llamadas a
esa misma función y que es la causante de las inserciones en la pila,
almacenando en cada una de las llamadas, información 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 prob-
lema.

El siguiente ejemplo ilustra este problema

( 1) #include <iostream>
( 2) int malaFuncion( int n ){
( 3) std::cout << "malaFuncion es una recursion infinita. n="<<n;
( 4) if( n == 0 )
( 5) return 0;
( 6) else
( 7) return malaFuncion( n / 3 + 1 ) + n - 1;
( 8) }
( 9) int main (int argc, char * const argv[]) {
(10) std::cout << malaFuncion(10);
(11) return 0;
(12) }

4.2. Ejercicios de programación

Los siguientes ejercicios deben de ser programados en C/C++ :

1. Búsqueda binaria: Considere un arreglo de elementos (números enteros


está bien) en el cual los objetos ya estan ordenados, y se desea encon-
trar un elemento dentro de este arreglo. Es decir, se desea realizar una
“búsqueda”.
La idea general de este método de búsqueda binaria es:
Si el arreglo tiene 1 elemento, se compara con el numero requerido y la

40
búsqueda termina.
Si el arreglo tiene más de 1 elemento, tendremos que dividir en dos el
arreglo y decidir en qué 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áticas 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ón es usar listas. Las listas son estructuras de datos que son dinámi-
cas, 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ón 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ón 7 Si A y B son dos conjuntos, decimos que a ∈ A está 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én el conjunto de personas,
y R es “debe dinero a”; marisolRrafaelle significa que “marisol debe
dinero a rafaelle” y de ningún 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ón “toma
el curso de” es el siguiente:

42
Figura 17. Relación “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áficamente 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ón “toma el curso de”.


De manera que podemos definir un grafo como una representación gráfica de
una relación.

Definición 8 Para definir formalmente un grafo debemos establecer la sigu-


iente 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ún 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ón en A:

R = {(1, 2), (2, 3), (3, 4), (4, 5), (5, 6)}

Esta relación luce como aparece en la figura 20.

Figura 19. Relación R de A en A

y en forma de grafo es:

Figura 20. Grafo de la relación R : A → A

A esta clase de grafos, en las que cada nodo tiene a lo más una arista dirigida
que sale y a lo más una arista dirigida que entra, se le llama lista.

5.2. Listas simplemente encadenadas

Como vimos en la sección anterior, una lista es una relación de elementos, tales
que cada elemento está relacionado con únicamente un elemento del conjunto,
diferente a sı́ mismo.

44
Como cada elemento puede tener a lo más 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ácticas, 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ón 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ón del primer elemento


de la lista, ası́ como sucede con los arreglos. El valor NULO es útil para saber
cuándo termina la lista, es una constante estándar 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én es válido
usarlos.

Normalmente cada nodo de la lista está estructurado con dos partes:

1. La parte de información.
2. La parte de dirección al siguiente nodo de la lista.

El campo de información contiene el elemento real de la lista. El campo de


dirección al siguiente contiene un apuntador al elemento con el cuál está rela-
cionado, es decir, al elemento siguiente de la lista. La lista completa se accesa
mediante el identificador de lalista. El campo de la dirección del ú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ón
de memoria que puede albergar una variable del tipo que se hayan definido
los nodos; null es una dirección de cualquier tipo, ası́ que el compilador asigna
la dirección null a lista.

45
Enseguida vamos a dar una lista de términos usados para manejar los elemen-
tos 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ón 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ón del nodo al que apunta p.
next(p): hace referencia a la parte dirección siguiente y, por tanto, es un
apuntador.

Ası́ que la expresión info(next(p)) significa que se hace referencia a la sección


de información 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ón 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ón con el resto de los nodos de la lista.

Insertar un elemento al inicio de la lista. La operación p=getnode();


obtiene un nodo vacı́o y establece el contenido de una variable nombrada p en
la dirección de este nodo, como se muestra en la figura 22.a. Este nodo aún
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ón de un nuevo nodo. b) El nuevo nodo debe de ir insertado al


frente, atrás 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ón de ese nodo con la operación info(p), como
se ilustra en el siguiente ejemplo con el dato 6.

46
info(p)=6;

Después de esstablecer la parte de información 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ón de ese primer nodo, node(p) se agrega a la lista ejecutando la
operación

next(p)=lista;

Esta operación coloca el valor de lista (la dirección 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ón 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 em-
bargo, debido a que list es el apuntador externo a la lista deseada, su valor
debe modificarse en la dirección del nuevo primer nodo de la lista. Esto se
hace ejecutando la operación

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ón 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ón de listas usando arreglos, cada


elemento del arreglo debe ser un elemento compuesto. Cada elemento debe
contener una parte para la información y otra parte para apuntar al elemento
siguiente:

#include <iostream>
( 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ón 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úmeros
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 último nodo apunta a NULL, que se representa con


el valor entero -1. Tenemos también los siguientes elementos de cada nodo:
node[p] corresponde a next(p), por la notación propia del lenguaje; también
node[p].info para info(p) y finalmente node[p].next hace referencia al
nodo siguiente next(p).

Al principio todos los nodos están sin usar, porque solamente se ha creado
el arreglo. Ası́ que todos los nodos van a formar parte de una lista de no-
dos 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<numNodes-1; i++){
node[i].next = i+1;
}
node[numNodes-1].next = -1;
}

Cuando se requiere un nodo para usarlo en la lista, se obtiene de la lista


disponible. Cuando ya no es necesario ese nodo, se devuelve a la lista disponible.
Estas dos operaciones se implementan mediante las rutinas en C/C++ getnode
y freenode:

49
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á completamente lleno. Esto significa que las estructuras de lista
de un programa particular han desbordado el espacio disponible. La función
freeNode acepta un apuntador (número 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 al-
goritmos correspondientes. La rutina insAfter acepta un apuntador p a un
nodo y un elemento x como parámetros. Primero se asegura que p no sea nulo
y después 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és 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ámica en C/C++

Como sabemos, en lenguaje C/C++ , &x es la dirección 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ómo
asignar y liberar el almacenamiento en forma dinámica y cómo se accesa al
almacenamiento dinámico en C/C++ .

En C/C++ , una variable que debe contener la dirección en la memoria que


almacena un número entero se crea mediante la declaración

int *p;

Recordemos que esta declaración 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ámicamente un objeto de este tipo especı́fico
y asignar su dirección a p.

Esto se hace en C/C++ mediante la función de la biblioteca estándar malloc(size).


La fucnión malloc asigna de manera dinámica una parte de memoria de
tamaño 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ón tiene un en-
lace externo. Esto significa que la variable o función a la que nos referimos
está definida en algún otro archivo fuente, o más adelante en el mismo archi-
vo. Sin embargo, en C/C++ podemos usar esta palabra clave extern con una
cadena. La cadena indica que se está usando el convenio de enlace de otro
lenguaje para los identificadores que se están 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 de-
nominan variables dinámicas. Al ejecutar estos enunciados, el operador sizeof
devuelve el tamaño en bytes de su operando. Esto se usa para conservar la
independencia de máquina. Después, malloc crea un objeto de este tamaño.
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ñale a un entero, usamos el operador de
cálculo (int *) ó (float *).

El operador sizeof, devuelve un valor de tipo int, en tanto que la función


malloc espera un parámetro de tipo unsigned. Para hacer que correspondan,
debemos escribir

pi=(int *)malloc((unsigned)(sizeof(int)));

Como ejemplo, vamos a considerar este breve código:

#include <iostream>

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 << " " << *q << "\n";
return 0;
}

En la l’inea (3), se crea una variable de tipo entero y su dirección se coloca


en p. La lı́nea (4) establece el valor de esa variable en 3. La lı́nea (5) hace
que la dirección q sea la misma dirección que p. El enunciado de la lı́nea (5)
es perfectamente válido, 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úmero 7 dos veces.

La lı́nea (10) crea una nueva variable entera y coloca su dirección en p. Ahora
*p hace referencia a la variable entera recién creada que todavı́a no ha recibido
un valor. q no ha cambiado; por lo que el valor de *q sigue siendo 7. Observe-
mos que *p no hace referencia a una variable especı́fica única. Su valor cambia
conforme se modifica el valor de p. La lı́nea (11) establece el valor de esta
variable recién creada en 5 y la lı́nea 12 imprime los valores 5 y 7. Y ası́ la
salida del programa es:

3 3
7 7
5 7

mallocEjemplo has exited with status 0.

La función free se usa en C para liberar almacenamiento de una variable


asignada dinámicamente. 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úso el almacenamiento ocupado por *p, si es necesario.

La función free espera un parámetro 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 <iostream>

int main (int argc, char * const argv[]) {


int *p, *q;

( 1) p=(int *)malloc(sizeof(int));
( 2) *p=5;
( 3) q=(int *)malloc(sizeof(int));
( 4) *q=8;
( 5) free(p);
( 6) p=q;
( 7) q=(int *)malloc(sizeof(int));
( 8) *q=6;
( 9) std::cout<<*p<<" "<<*q<<"\n";
return 0;
}

¿Qué 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ón no se guardó.

5.4. Listas ligadas usando memoria dinámica

Para hacer las listas ligadas necesitamos un conjunto de nodos, cada uno de
los cuales tiene dos campos: uno de información y un apuntador al siguiente
nodo de la lista. Además, un apuntador externo señala 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ón 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, éstos se asignan y liberan según es necesario. Se elimina la necesidad
de un conjunto de nodos previamente declarado.

Si declaramos

nodePtr p;

la ejecución de la orden

p=getNode();

debe colocar la dirección 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ón dinámica de


una lista ligada. Supongamos que list es una variable apuntador que señala
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ón

1. Implemente una pila usando memoria dinámica en listas ligadas. Imple-


mente las operaciones push, pop, empty y stackTop.
2. Implemente una cola usando memoria dinámica en listas ligadas. Imple-
mente las operaciones empty, insert y remove.
3. Desarrolle un programa para buscar un elemento en la lista (de números
enteros) y borrar la primera ocurrencia de ese elemento.
4. Desarrolle un programa para buscar un elemento en la lista (de números
enteros) y borrar todas las ocurrencias de ese elemento.
5. Las listas doblemente ligadas tienen nodos que están divididos en tres
segmentos:
a) Anterior: Un apuntador a un nodo
b) Info: La información de un nodo
c) Siguiente: Un apuntador a un nodo
Implemente las operaciones borrarNodo(p), insertarNodoAntes e
insertarNodoDespues.

56
6. Árboles

Los árboles son estructuras de datos útiles en muchas aplicaciones. Hay varias
formas de árboles y cada una de ellas es práctica en situaciones especiales, en
este capı́tulo vamos a definir algunas de esas formas y sus aplicaciones.

6.1. Concepto general de árbol

Desde el punto de vista de estructuras de datos, un árbol es un concepto


simple en su definición, sin embargo es muy ingenioso. Un árbol es un grafo
con caracterı́sticas muy especiales:

Definición 9 Un árbol es un grafo A que tiene un único nodo llamado raı́z


que:

Tiene 0 relaciones, en cuyo caso se llama nodo hoja


tiene un número finito de relaciones, en cuyo caso, cada una de esas rela-
ciones es un subárbol

Para empezar a estudiar los árboles, nos concentraremos en primer lugar en


el caso en que el nodo raı́z tenga 0, 1 ó 2 subárboles.

6.2. Árboles binarios

Definición 10 Un árbol binario es una estructura de datos de tipo árbol en


donde cada uno de los nodos del árbol puede tener 0, 1, ó 2 subárboles 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ón a la izquierda, el segundo elemento de la
relación es el subárbol izquierdo.
Si el nodo raı́z tiene 1 relación a la derecha, el segundo elemento de la
relación es el subárbol derecho.

La figura 25 muestra algunas configuraciones de grafos que sı́ son árboles


binarios, y la figura 26 muestra algnas configuraciones de grafos que no son
árboles binarios.

Vamos a dar una lista de teérminos que se usan frecuentemente cuando se


trabaja con árboles:

57
Figura 25. Grafos que son estructuras tipo árbol binario

Figura 26. Grafos que no son árboles binarios

Si A es la raı́z de un árbol y B es la raı́z de su subárbol izquierdo (o dere-


cho), 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 descen-


diente del nodo a), si a es el padre de b o el padre de algún 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érminos relacionados con árboles, tienen que ver con su funcinoamiento
y topologı́a:

Si cada nodo que NO es una hoja tiene un subárbol izquierdo y un subárbol


derecho, entonces se trata de un árbol binario completo.

El nivel de un nodo es el número 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ás uno.

La profundidad de un nodo es el máximo nivel de cualquier hoja en el


árbol.

Si un árbol binario tiene m nodos en el nivel l, el máximo número de nodos


en el nivel l + 1 es 2m. Dado que un árbol binario sólo tiene un nodo en el
nivel 0, puede contener un máximo de 2l nodos en el nivel l. Un árbol binario
completo de profundidad d es el árbol que contiene exactamente 2l nodos en
cada nivel l entre 0 y d. La cantidad total de nodos tn en un árbol binario
completo de profundidad d, es igual a la suma de nodos en cada nivel entre 0
y d, por tanto:

d
tn = 20 + 21 + 22 + · · · + 2d = 2j
X

j=0

Usando inducción matemática se puede demostrar que dj=0 2j = 2d+1 − 1.


P

Dado que todas las hojas en este árbol están en el nivel d, el árbol contiene
2d hojas y, por tanto, 2d − 1 nodos que no son hojas.

Si conocemos el número total de nodos tn en un árbol binario completo, pode-


mos calcular su profundidad d, a partir de la expresión tn = 2d+1 −1. Ası́ sabe-
mos que la profundidad d es igual a 1 menos que el número de veces que 2
debe ser multiplicado por sı́ mismo para llegar a tn + 1. Es decir, que en un
árbol binario completo,

d = log2 (tn + 1)

Definición 11 Un árbol binario es un árbol binario casi completo si:

1. Cualquier nodo nd a un nivel menor que d − 1 tiene 2 hijos


2. Para cualquier nodo nd en el árbol 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 ó
tiene dos hijos

Los nodos en un árbol binario (completo, casi completo o incompleto) se


pueden enumerar del siguiente modo. Al nodo raı́z le corresponde el número
1, al hijo izquierdo le corresponde el doble del número asignado al padre y al
hijo derecho le corresponde el doble más 1 del número asignado al padre.

59
Figura 27. Comparación de un árbol binario y un árbol binario casi completo. El
árbol mostrado en (A) descumple la regla 2 de los árboles binarios casi completos.

6.2.1. Operaciones con árboles binarios

Con los árboles binarios es posible definir algunas operaciones primitivas, estas
operaciones son en el sentido de saber la información de un nodo y sirven para
desplazarse en el árbol, 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ógicas, 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ón de un árbol binario son útiles las operaciones makeTree,


setLeft y setRight. La operación makeTree(x) crea un nuevo árbol binario
que consta de un único nodo con un campo de información x y devuelve un
apuntador a ese nodos. La operación setLeft(p,x) acepta un apuntador p
a un nodo de árbol binario sin hijo izquierdo. Crea un nuevo hijo izquierdo
de node(p) con el campo de información x. La operación setRight(p,x) es
similar, excepto que crea un hijo derecho.

6.2.2. Aplicaciones de árboles binarios

Un árbol binario es una estructura de datos ú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ón es
bastante ú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úmero pequeño, pero el problema se
va complicando más a medida que n aumenta.

Si usamos un árbol binario, el número de comparaciones se reduce bastante,


veamos cómo.

El primer número del arreglo se coloca en la raı́z del árbol (como en este
ejemplo siempre vamos a trabajar con árboles binarios, simplemente diremos
árbol, para referirnos a un árbol binario) con sus subárboles izquierdo y dere-
cho vacı́os. Luego, cada elemento del arreglo se compara son la información
del nodo raı́z y se crean los nuevos hijos con el siguiente criterio:

Si el elemento del arreglo es igual que la información del nodo raı́z, entonces
notificar duplicidad.
Si el elemento del arreglo es menor que la información del nodo raı́z, entonces
se crea un hijo izquierdo.
Si el elemento del arreglo es mayor que la información del nodo raı́z, entonces
se crea un hijo derecho.

Una vez que ya está creado el árbol, se pueden buscar los elementos repetidos.
Si x el elemento buscado, se debe recorrer el árbol del siguiente modo:

61
Sea k la información 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<info(p))
q=left(p)
else
q=right(p)
}
if(k==info(p))
despliega<<" el numero es duplicado";
else
if (k<info(p))
setLeft(p,k)
else
setRight(p,k)
}

Figura 28. Árbol binario para encontrar números duplicados

Para saber el contenido de todos los nodos en un árbol es necesario recorrer


el árbol. Esto es debido a que solo tenemos conocimiento del contenido de
la dirección de un nodo a la vez. Al recorrer el árbol es necesario tener la
dirección de cada nodo, no necesariamente todos al mismo tiempo, de hecho
normalmente se tiene la dirección de uno o dos nodos a la vez; de manera que
cuando se tiene la dirección de un nodo, se dice que se visita ese nodo.

62
Aunque hay un orden preestablecido (la enumeración de los nodos) no siempre
es bueno recorrer el árbol en ese orden, porque el manejo de los apuntadores
se vuelve más complejo. En su lugar se han adoptado tres criterios princi-
pales para recorrer un árbol binario, sin que de omita cualquier otro criterio
diferente.

Los tres criterios principales para recorrer un árbol binario y visitar todos sus
nodos son, recorrer el árbol en:

preorden: Se ejecutan las operaciones:


1. Visitar la raı́z
2. recorrer el subárbol izquierdo en preorden
3. recorrer el subárbol derecho en preorden
entreorden: Se ejecutan las operaciones:
1. recorrer el subárbol izquierdo en entreorden
2. Visitar la raı́z
3. recorrer el subárbol derecho en entreorden
postorden: Se ejecutan las operaciones:
1. recorrer el subárbol izquierdo en postorden
2. recorrer el subárbol derecho en postorden
3. Visitar la raı́z

Al considerar el árbol binario que se muestra en la figura 28 usando cada uno


de los tres criterios para recorrer el árbol 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ón, el ordenamiento de los elementos


de un arreglo.

Para ordenar los elementos de un arreglo en sentido ascendente, se debe con-


struir un árbol similar al árbol binario de búsqueda, pero sin omitir las coin-
cidencias.

El arreglo usado para crear el árbol binario de búsqueda fue

<14,15,4,9,7,18,3,5,16,4,20,17,9,14,5>

El árbol de ordenamiento es el que se muestra en la figura 29

Para ordenar los elementos de este arreglo basta recorrer el árbol en forma de
entreorden.

63
Figura 29. Árbol binario para ordenar una secuencia de números
¿Cuál serı́a el algoritmo para ordenarlo de manera descendente?

6.3. Representación en C/C++ de los árboles binarios

Vamos a estudiar estas representaciones por partes, primero los nodos y el


árbol; después las operaciones para el manejo del árbol.

6.3.1. Representación de los nodos

Los nodos de los árboles binarios son estructuras en C/C++ que estan com-
puestas por tres partes:

Un apuntador al subárbol izquierdo, left


Un apuntador al subárbol derecho, right
Una parte de información, que puede ser una estructura en sı́ misma, info.
Adicionalmente es muy útil poner un apuntador al padre del nodo. father.

Usando una implementación de arreglos tenemos:

#define numNodes 500


struct nodeType{
int info;
int left;
int right;
int father;
};
struct nodeType node[numNodes];

y usando una representación con memoria dinámica, los nodos de un árbol 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 respectiva-
mente. 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 árbol binario en C/C++

Aquı́ usaremos recursividad para hacer estas rutidas de los recorridos de


árboles binarios. Las rutinas se llaman preTr, inTr y postTr, que impri-
men el contenido de los nodos de un árbol 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<<tree->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<<tree->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<<tree->info;
}
}

6.4. Árboles

Hasta ahora hemos visto los árboles binarios que son aquellos árboles que sus
nodos solamente pueden tener un máximo de dos hijos. Cuando ocurre que
los nodos tienen cualquier número finito de hijos, son árboles (en genreal). De
manera que

Definición 12 Un árbol 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 árbol. Cada
elemento en un árbol se denomina un nodo del árbol

66
Un nodo sin subárboles es una hoja. Usamos los términos padre, hijo, her-
mano, antecesor, descendiente, nivel y profundidad del mismo modo
que en los árboles binarios. El grado de un nodo es en número máximo de
hijos que alún nodo tiene.

Un árbol ordenado de define como un árbol en el que los subárboles de cada


nodo forman un conjunto ordenado. En un árbol ordenado, podemos hablar
del primero, segundo o último hijo de un nodo en particular. El primer hijo de
un nodo en un árbol ordenado se denomina con frecuencia el hijo más viejo
de este nodo y el último se denomina el hijo más joven. Véase la figura 30.
Un bosque es un conjunto ordenado de árboles ordenados.

Figura 30. El árbol de la izquierda es ordenado y el árbol de la derecha es un árbol


no ordenado.

6.4.1. Representación dinámica en C de los árboles

Al igual que en los árboles binarios, los nodos en un árbol tienen una parte
de información, un apuntador al padre y uno o más apuntadores a los hijos.
De manera que una solución es crear una estructura que incluya una lista
dinámica de apuntadores, como lo muestra la figura 31.

Figura 31. Representación con listas de los nodos de un árbol


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ás
joven, en lugar de dejarlo en null. Se podrı́a usar un campo lógico 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 árbol


binario y que next corresponde a su apuntador right, este método representa
en realidad un árbol ordenado general mediante un árbol binario.

6.4.2. Recorridos de árbol

Los métodos de recorrido para árboles binarios inducen métodos para recorrer
los árboles en general. Si un árbol se representa como un conjunto de nodos
de variables dinámicas 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<<tree->info;
inTr(tree->right);
}
}

Las rutinas para recorrer el árbol en los demás ordenes son similares. Estos
recorridos también se defininen directamente ası́:

Orden previo: similar al caso binario.


1. Visitar la raı́z
2. Recorrer en orden previo los subárboles de izquierda a derecha

Las demas rutinas son similares.

Un bosque puede ser representado medianto un árbol binario.

68
Para hacer esta representación, la raı́z de cada árbol se coloca en una lista
de apuntadores; luego para cada nodo en la lista (la raı́z de cada árbol) se
procede del siguiente modo:

1. Se crea una lista de subárboles izquierdos con los apuntadores a cada uno
de los árboles en el bosque.
2. si un nodo tiene más de un hijo, entonces se crea un subárbol izquierdo
y se forma una lista de subárboles izquierdos con todos los hijos de ese
nodo.

Figura 32. Arriba: Un bosque de árboles. Abajo: El árbol binario que corresponde
a ese bosque.

Para recorrer los nodos de un bosque, es preferible convertir todo el bosque


en un árbol binario correspondiente, como se ilustra en la figura 32. Cuando
ya se tiene el árbol 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 árboles del bosque
son árboles ordenados; entonces un recorrido en entreorden dará como resul-
tado una secuencia de nodos ordenada en sentido ascendente.

6.5. Ejercicios de programación

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 árbol binario válido y falso
en caso contrario.
2. Escriba un programa que acepte un apuntador a un árbol binario y un
apuntador a un nodo del árbol, y devuelva el nivel del nodo en el árbol.
3. Escriba un programa para ejecutar el experimento siguiente: genere 100
números aleatorios. Conforme se genera cada número, insértelo en un

69
árbol de búsqueda binaria inicialmente vacı́o. Después de insertar los 100
números, imprima el nivel de la hoja que tiene el nivel más grande y
el nivel de la hoja que tiene el nivel más chico. Repita este proceso 50
veces. Imprima una tabla que indique cuántas veces de las 50 ejecuciones
produjeron una diferencia entre el nivel de hoja máximo y mı́nimo de
0,1,2,3, y ası́ sucesivamente.
4. Implemente los recorridos de los árboles binarios.
5. Si un bosque se representa mediante un árbol binario, muestre que el
número de vı́nculos derechos nulos es 1 mayor que el número de no hojas
del bosque.

70
7. Grafos

En esta parte del curso vamos a retomar la idea de los gráfos. Hasta ahora
homos visto las listas y los árboles como casos especiales de los grafos. Re-
sumiendo, 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 árboles, los nodos tienen una arista
que llega (la del padre) y una o más aristas que salen (los hijos).

Como veremos más 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 árboles 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ón, 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ó. 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án 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ón 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ón.

Si G = hN, Ai es un grafo, los siguientes términos 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áximo numero de
incidencias. También se conoce con el nombre de “valencia”.
Grado interno: También se define para cada nodo y es el número de aris-
tas que llegan a ese nodo. Otro nombre para este término es “valencia de
entrada”.
Grado externo: Para cada nodo es el número de aristas que salen del nodo.
Se conoce también 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ún conjunto W de pesos. Con los grafos y grafos ponderados (los que
tienen pesos) se pueden tener algunas operaciones básicas:

72
Con grafos:
join(a,b): Agrega una relación del nodo a al nodo b. Si la relación no
existe, entonces crea una relación.
removeArc(a,b): Quita un arco del nodo a al nodo b
Con grafos ponderados:
joinWt(a,b,w): Agrega una relación del nodo a al nodo b y le asocia el
peso w. Si la relación no existe, entonces de crea la relación y le asocia el
peso indicado.
removeArcWt(a,b): Quita un arco del nodo a al nodo b con peso w.
La operación 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 se-
cuencia 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ón 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ón

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

You might also like