1 Introducción a las estructuras de datos
1.1 La información y su significado
En un sentido general, la información es un conjunto organizado de datos, que constituyen un mensaje sobre un determinado ente o fenómeno. De esta manera, por ejemplo, si organizamos los datos sobre un país (número de habitantes, densidad de población, nombre del presidente, etc.) y escribimos el capítulo de un libro, podemos decir que ese capítulo constituye información sobre ese país. Cuando tenemos que resolver un determinado problema o tomar alguna decisión, empleamos la información.
Desde un punto de vista estricto la información también esta compuesta de datos, un dato es la
cantidad mínima de información no elaborada, sin sentido por sí misma, pero que
convenientemente tratada se puede utilizar en la realización de cálculos o toma de decisiones. Es de empleo muy común en el ámbito computacional.
Toda información esta constituida de datos, pero no todos los datos producen información específica e inteligente. Los datos adicionales pueden agregar nuevas dimensiones a la información existente, pero la interpretación requiere criterio humano.
1.1.1 Definición de Bit y Byte
Un bit (binary digit) es la unidad básica de información, cuyo valor confirma dos posibilidades. Por ejemplo: un dispositivo electrónico puede estar en una de dos posiciones pero no en ambas al mismo tiempo, el hecho es que al estar en la posición de “encendido” o “apagado”, éste representa un bit de información. Si un dispositivo puede estar en más de dos estados, al tomar un estado en particular se tiene más de un bit de información.
Los bits están agrupados en unidades mas grandes de información llamados bytes, que son unidades de almacenamiento compuestos por 8 bits en la memoria de las computadoras. Por lo general un byte representa un carácter alfanumérico codificado.
La computadora es un sistema rápido y exacto para manipular símbolos (datos), diseñado para aceptar y almacenar datos de entrada, procesarlos y producir resultados. De esta forma la agrupación de bits puede representar datos que pueden ser manipulados en una computadora.
1.2 Almacenamiento de información
El almacenamiento de información en dispositivos electrónicos consiste en identificar, clasificar y estructurar los datos de información en unidades de almacenamiento computacional, para posteriormente ser accedidas y operadas. Debido a las características computacionales los bits y los bytes resultan la manera más sencilla de almacenamiento de información en dispositivos electrónicos, de ésta manera el sistema numérico binario es ampliamente empleado para representar, inicialmente, números enteros sin signo y posteriormente se amplio el rango a los números negativos, reales y caracteres.
1.2.1 Representación interna de los datos Números binarios sin signo
En el sistema binario cada posición del bit representa una potencia de 2. El bit mas a la derecha representa 20 el cual es igual a 1, la siguiente posición a la izquierda representa 21 el cual es 2, la siguiente posición representa 22 es decir 4, y así sucesivamente un numero binario entero representa la suma de potencias de 2. Los ceros son ausentes en la suma de potencias.
De esta forma el numero binario 00100110 representa la suma de potencias (25 + 22 + 21) que es igual a 38. Bajo esta interpretación, cualquier cadena de bits de tamaño n representa un número no negativo entre 0 y 2n -1.
Números binarios con signo
Las computadoras deben interpretar números positivos y negativos. Los números binarios se caracterizan por su magnitud y su signo. El signo indica si el número es positivo o negativo y la magnitud el valor del número. Existen 3 formas de representar números binarios enteros con signo:
a) Signo – magnitud. b) Complemento a 1. c) Complemento a 2.
a) Signo – Magnitud: Los números positivos y negativos tienen la misma notación para los bits de magnitud pero se diferencian en el bit del signo. El bit del signo es el bit situado más a la izquierda en el número binario. En números positivos se emplea el bit "0" y en números negativos el bit "1". Además el número no debe estar complementado.
De esta manera 21 se expresa en binario de 8 bits 0001 0101, donde el primer bit "0" denota el bit de una magnitud positiva. Por otro lado, –21 se expresa en binario 1001 0101, donde el primer bit "1" denota el bit de una magnitud negativa.
El sistema de signo – magnitud contiene el mismo numero de enteros positivos y negativos en el rango de –2n-1 – 1 a 2n-1 – 1, con dos posibles representaciones del cero.
Ejemplos
011111112 = +127 y 111111112 = -127 000000002 = +0 y 100000002 = -0
b) Complemento a 1: Se obtiene cambiando los unos por ceros y los ceros por unos. La representación de números positivos en complemento a 1 sigue las mismas reglas del sistema signo-magnitud y la representación de los números negativos en complemento 1 es el complemento a 1 del número positivo (operador de negación).
Así el número 21 se expresa en complemento a 1 de 8 bits como 0001 0101, donde el primer bit "0" denota el bit de una magnitud positiva. El número –21, se obtiene por medio del complemento a 1 del número positivo 0001 0101 el cual es 1110 1010.
Tome en cuenta que –2n-1 y 2n-1 no puede ser representado en la notación de complemento a 1. c) Complemento a 2: Las computadoras utilizan la representación binaria en complemento a 2
para representar números negativos. La representación de números positivos en complemento a 2 sigue las mismas reglas del sistema signo-magnitud y la representación de los números negativos en complemento a 2 se obtiene de la siguiente forma:
1. Se representa el número decimal dado en magnitud positiva.
2. El número de magnitud positiva se representa en forma binaria positiva.
3. Se obtiene el complemento 1 del número binario mediante el cambio de los unos por ceros y viceversa.
4. Al complemento 1 se le suma uno y el resultado es la representación en el complemento 2.
Representar el número –5 en binario, utilizando el complemento a 2 con 8 bits: 1. –5 se cambia a 5.
2. Escribimos el número 5 en binario de 8 bits 0000 0101
3. Obtenemos el complemento a 1 de 0000 0101 es decir, 1111 1010
4. Al complemento del número anterior se la suma 1. El resultado es 1111 1011. Podemos comprobar la obtención del negativo del número inicial utilizando el método del complemento a 2:
11111011= (-128 + 64 + 32 +16 + 8 + 0 + 2 + 1)10 = - 5
El rango de números que se puede representar es –2n-1 y 2n-1 –1, es decir un numero de 8 bits (1
byte) puede representar valores entre -128 y 127. Además existe solo una representación para el
número 0:
0000 0000 signo – magnitud 1111 1111 complemento 1 1 0000 0000 complemento 2
Puesto que solo se permiten 8 bits, el bit más a la izquierda se elimina, quedando únicamente el 0000 0000.
Números reales
El método usual para representar números reales es la notación de punto flotante. Existen muchas variantes de la notación de punto flotante con características propias. El aspecto clave es que los números reales están representados por un numero llamado mantisa que se multiplica por una
base que es una potencia de enteros llamado exponente. La base es usualmente fija, y la mantisa
y el exponente varían para representar diferentes números reales.
Si la base es 10, el numero 387.53 puede ser representado como 38753 X 10-2. La mantisa es 38753 y el exponente es -2.
En los números reales representados por 32 bits, la cadena consistente de 24 bits representan la mantisa y los siguientes 8 bits el exponente y la base es fija a 10. Tanto la mantisa como el exponente son enteros binarios de complemento 2 de esta forma:
Por ejemplo:
24 bits representan el 38753 es decir, 000000001001011101100001 8 bits en complemento a 2 representan -2 como 11111110
Por lo tanto 387.53 es representado como 00000000100101110110000111111110 en 32 bits. Cadenas de caracteres
No toda la información es representada de manera numérica, también es necesario representar información referente a caracteres, en tal caso la información necesariamente es representada como una cadena de caracteres. En un principio 8 bits eran usados para representar un carácter, hasta 256 caracteres podían ser representados, a este formato de caracteres se le denomino código ASCII y era capas de representar la totalidad de caracteres de un alfabeto occidental. En la actualidad es utilizado sistema UNICODE, que es capas de representar hasta 216 (65536). La mayoría de los sistemas actuales sin capaces de diferenciar entre un código ASCII y UNICODE.
1.3 Estructura de datos
Una herramienta útil para especificar las propiedades lógicas de un tipo de dato abstracto. Fundamentalmente, un tipo de dato es una colección de valores y un conjunto de operaciones
sobre esos valores, y forman una construcción matemática que podría ser implementada usando
un hardware particular o una estructura de datos en software. El termino “tipo de dato abstracto” se refiere al concepto matemático básico que define el tipo de dato.
Existen numerosos métodos para especificar tipos de dato abstractos. Hasta el momento hemos explicado algunos tipos de datos, que denominamos nativos, como son los enteros, reales, y caracteres. En lenguajes de alto nivel podemos encontrar tipos de datos como los apuntadores, que son identificadores de referencia a una localidad de memoria y que pueden almacenar tipos de datos nativos, así como tipos de dato abstracto.
Por lo tanto el estudio de estructura de datos involucra dos objetivos complementarios:
• El primero es identificar y desarrollar entidades matemáticas útiles y operaciones para determinar que clases de problemas pueden ser resueltos por el uso de estas entidades y operaciones.
• El segundo es determinar representaciones para estas entidades abstractas e implementar operaciones abstractas en estas representaciones concretas.
En el primero de estos objetivos se ve al tipo de dato de alto nivel como una herramienta que puede ser usada para solucionar otros problemas, y en la segunda, la implementación de tales tipos de datos se ve como un problema a ser resuelto usando los tipos de datos ya existentes. Inicialmente examinaremos varias estructuras de datos ya existentes en C, como son los arreglos y las estructuras. Describiremos los servicios que están disponibles en C para utilizar estas estructuras, además de enfocarnos en la definición abstracta de estas estructuras de datos y como ellas pueden ser útiles en problemas a resolver.
Mas adelante se desarrollaran estructuras de datos mas complejas y veremos su utilidad en la solución de problemas, además veremos como implementar estas estructuras de datos usando las estructuras ya disponibles en C.
1.4 Arreglos
La forma más simple de arreglo es el arreglo de una dimensión, que podría ser definido de manera abstracta como un conjunto ordenado de elementos homogéneos de tamaño finito.
Posición individual A [ índice 0 ] A [ índice 1 ] ... A [ índice i-ésimo ] Arreglo elemento 0 elemento 1 ... elemento i-ésimo Por ejemplo, en C:
<tipo> vector[100]; // arreglo de 100 posiciones de enteros
Las dos operaciones básicas que se pueden realizar en un arreglo son la extracción y el
almacenamiento. La operación de extracción es una función que acepta un arreglo y un índice, y
regresa el elemento del arreglo. En C esta operación es denotada como: vector [ índice ]
La operación de almacenamiento acepta un elemento x en un índice cualquiera del arreglo: vector [ índice ] = x
En los vectores existe un limite inferior (0 en C, debido a que se expresa como desplazamientos de memoria) que es la primera posición del arreglo y un limite superior (n-1 en el caso de C) que es el ultimo elemento del arreglo.
Los arreglos son usados cuando es necesario mantener una gran numero de elementos en memoria y referenciar todos sus elementos de manera uniforme. A continuación se presenta un ejemplo en C++ que permite crear arreglos de manera dinámica y se emplean algunas funciones que permiten manipular su contenido.
#include <iostream> using namespace std;
int * crea( int & ); // crea el vector dinamico void captura( int [], int ); // captura los datos
void mostrar( const int *, int ); // muestra el contenido int main(void)
{
int *vector; // definimos un puntero de tipo int int num; vector = crea(num); captura(vector, num); mostrar(vector, num); cin.ignore(); cin.get(); return 0; }
/* La funcion es de tipo int * ya que pretendemos regresar la direccion de memoria disponoble para el vector, el operador & indica paso de parametro por referencia
*/
int * crea( int &n ) {
int *ptr;
cout << "cuantos elementos ? "; cin >> n;
ptr = new int [n];
return ptr; // regresa la direccion de inicio del vector dinamico }
/* captura, recibe el vector dinamico y su dimension, tome en cuanta que los valores contenidos en vector seran modificados mediante vec. El argumento n es pasado por valor.
*/
void captura( int vec [], int n ) {
for( int i=0; i < n; i++ ) {
cout << " numero -> "; cin >> vec[i];
return; }
/* el primer argumento se declara tipo const (constante) lo que impide que los valores del vector sean modificados.
*/
void mostrar(const int * vec, int n) {
cout <<"Contenido del vector..."<<endl; for (int i=0; i < n; i++)
{ cout << vec[i] <<'\t'; } cout << endl; return; }
En el caso de los arreglos de caracteres (strings) es importante recordar que el carácter ‘\0’ es utilizado para representar el fin del arreglo y ocupa una posición real dentro del mismo. También es importante tener en mente que en C se pueden crear arreglos de tamaño variable empleando punteros. A continuación se muestra un ejemplo del manejo de arreglos de caracteres empleando el carácter de control para manejar su limite.
#include <iostream> using namespace std;
int nveces( char *, char * ); int main()
{
char *s1 = "si", *s = "si y solo si, si?"; cout << nveces(s1, s);
cin.get(); return 0; }
int nveces( char *s1, char *s ) {
int n = 0, i = 0, j = 0; bool c = false;
while ( s[j] != '\0' ){
for ( i = 0; s1 [i] != '\0'; )
c = (s1[i++] == s[j++])? true: false;
/* Esto es mas eficiente pues una vez que no hay
concordancia ya no continua checando y rompe el for if ( s1[i++] == s[j++]) c = true; else { c = false; break; // aqui } */ n += (c == true)? 1: 0; } return n; }
Los arreglos en C pueden tener múltiples índices y son mas comúnmente conocidos como
matrices. La matriz es un arreglo bidimensional organizado como una tabla, donde los datos se
encuentran organizados en filas y columnas:
columna c columna c +1 columna … columna m fila f elemento(f , c) elemento(f , c+1) … elemento( f, m ) fila f + 1 elemento(f +1 , c ) elemento(f +1 , c +1) … elemento(f +1 , m )
fila … … … … …
fila n elemento(n , c ) elemento(n , c +1) … elemento(n , m)
Las matrices se declaran de forma muy similar a los vectores, únicamente se agrega una dimensión, recuerde que el lenguaje C también permite agregar más dimensiones a los arreglos: <Tipo> nombre_matriz [ filas ] [ columnas ];
A continuación se presenta una forma de asignar matrices dinamicas en C: #include<stdio.h>
#include<stdlib.h> int main(void) {
int i, j;
int **p = calloc(2, sizeof(int *)); for( i = 0; i < 3; i++)
p[i] = calloc(3, sizeof(int)); for ( i = 0; i < 2; i++)
for ( j = 0; j < 3; j++) p[i][j] = i + j; for ( i = 0; i < 2; i++)
for ( j = 0; j < 3; j++)
printf( "%d %p ",p[i][j], &p[i][j] ); return 0;
}
Un esquema similar se puede emplear en C++ #include <iostream>
using namespace std; int main()
{
short **matriz; // declara un puntero puntero int rens, cols; // numero de renglones y columnas cout << "numero de renglones ";
cin >> rens;
cout << "numero de columnas "; cin >> cols;
cout << " Matriz de "<<rens << " por " << cols << endl; // asigna espacio para crear un vector de punteros
for(int i = 0; i < rens; i++) {
/* a cada casilla del vector de punteros se le asigna una cantidad de columnas de tipo short */
matriz[i] = new short [cols]; for(int j = 0; j < cols; j++)
{ // ingresamos un elemento para la matriz
cout << "elemento short "<<i<<","<<j<<" ";
cin >> matriz[i][j]; /* finalmente la matriz puede ser usada como cualquier matriz */
} }
cout << "\nLa matriz contiene:\n"; for(int i = 0; i < rens; i++) { for(int j = 0; j < cols; j++) cout << matriz[i][j] << " "; cout << "\n"; } cin.ignore(); cin.get(); return 0; }
1.5 Estructuras (registros)
Una estructura es una forma de agrupar un conjunto de datos de distinto tipo bajo un mismo
nombre o identificador. Por ejemplo: struct Estructura { <tipo> miembro1; <tipo> miembro2; ... };
Estructura corresponde al identificador con el que nos referimos a la estructura, además existen los miembros o variables que están asociadas bajo el mismo nombre de la estructura. De esta manera
podemos generar variables de tipo Estructura, a las variables de este tipo las denominaremos registros, por ejemplo generamos el registro llamado Datos;
struct Estructura Datos;
Dentro del registro existen miembros y peden ser alcanzados con el operador de acceso a miembros:
Datos.miembro1 = valor
Los miembros de las estructuras pueden ser variables de cualquier tipo, incluyendo punteros, vectores, matrices, e incluso estructuras previamente definidas.
Las estructuras constituyen uno de los aspectos más potentes del lenguaje C. En esta sección se
funciones miembro además de variables miembro, llamándolo clase, y convirtiéndolo en la base de
la programación orientada a objetos.
A continuación se presenta un programa con manejo de estructuras que captura y manipula un arreglo de estructuras de un tipo de dato definido (Alumno), observe que el manejo eficiente de estructuras siempre reducirá el nivel de complejidad de un problema:
#include <iostream> using namespace std; #include <conio2.h>
struct Alumno // estructura Alumno, conserva datos de alumno, así como {
int id;
char nombre[30]; char carrera[30];
struct Materia // conserva los datos asociados a todas { // las materias de un alumno
char nombre[30]; float calificacion;
}materias[2]; //vector tipo Materia almacenamos cada materia };
void captura( Alumno * ); // Captura los datos de cada alumno void imprime( Alumno * ); // imprime el contenido del vector void verVector( Alumno [] , int );
int main() {
Alumno *al = new Alumno; /* estructura dinamica */ int n; // tamaño del vector dinamico grupo
cout << "Numero de alumnos -> "; cin >> n;
Alumno *grupo = new Alumno [n]; // vector dinamico grupo for (int cont = 0; cont < n; cont++){
captura(al); //captura datos de cada alumno en la estructura grupo[cont] = *al;
}
for (int i=0; i < n; i++){
*al = grupo[i]; // vacia el contenido de grupo[i] en al imprime( al ); } clrscr(); verVector( grupo, n ); cin.ignore(); cin.get(); delete [] grupo; delete al; return 0; }
/* captura la informacion de cada alumno, recibe la referencia de una estructura Alumno que sera modificada localmente en la funcion */
void captura(Alumno *al) {
cout << "\tDatos del alumno" << endl << "Clave: "; cin >> al->id; cout << "Nombre: "; cin.ignore(); cin.get(al->nombre,30); cout << "Carrera: "; cin.ignore(); cin.get(al->carrera,30);
cout << "\tCaptura de Materias\n"; for(int i=0; i < 2; i++) {
cout <<"Materia "<<(i+1)<<": "; cin.ignore(); cin.get(al->materias[i].nombre,30); cout << "Calificacion: "; cin >> al->materias[i].calificacion; } return; }
/* imprime la informacion de cada alumno, recibe la referencia de una estructura Alumno que sera modificada localmente en la funcion */
void imprime( Alumno *al ) {
cout<<"\tReporte Datos del alumno\n Clave:"<< al->id;
/* referencia valida a miembros para una estructura tipo puntero */ cout << "\n Nombre: " << (*al).nombre; /* referencia valida a miembros para una estructura tipo puntero */
cout << "\n Carrera: " << al->carrera;
// ambos tipos de referencia a miembros ( -> y *. ) son validas cout << "\n\tMaterias del Alumno\n";
for(int i=0; i < 2; i++) {
cout <<"Materia " << (i+1)<<": " << al->materias[i].nombre; cout << "\tCalificacion: " << al->materias[i].calificacion; cout << endl;
}
return; }
/* recibe como argumento la direccion del arreglo completo de alumnos, es decir el grupo, de manera que es posible tener acceso a todos los elementos del arreglo */
void verVector( Alumno grp[], int n ) {
cout << "impresion de grupo \n"; for ( int i = 0; i < n; i++ ){
cout << "id " << grp[i].id << endl; cout << "nombre " << grp[i].nombre << endl; cout << "carrera " << grp[i].carrera << endl; for ( int j = 0; j < 2; j++ ){
cout << "materia " << grp[i].materias[j].nombre << endl;
cout<<"calificacion" << grp[i].materias[j].calificacion << endl; }
cout << endl; }
return; }
1.6 Clases
Una clase engloba el concepto de “tipo de dato abstracto” ya que define tanto el conjunto de valores de un tipo dado, como el conjunto de operaciones que pueden realizarse con esos valores. Una variable de tipo clase es conocida como objeto y las operaciones que ese tipo realiza son llamadas métodos. Se dice que los objetos envían mensajes entre ellos a partir de dichos métodos. class Nombre {
<tipo> elemento; <tipo> elemento; ...
<tipo> método(lista de argumentos); <tipo> método(lista de argumentos); ...
}
Las reglas por las se rige el acceso a los miembros es identica a las estructuras, en cierta forma a las clases se les puede ver como una estructura evolucionada que permite realizar operaciones limpiamente sobre sus datos.
#include <iostream> using namespace std; class Juego
{
private:
int rango, num, intento, numIntentos; private:
// genera un numero aleatorio entre 0 y limite
int Juego::random(int limite) { return rand() % limite; } public:
Juego::Juego( int Rango = 31, int Intentos = 5 ) {
rango = Rango;
srand( time(NULL) ); // semilla de numeros aleatorios variables num = random(rango); // extrae el numero aleatorio
intento = 0; numIntentos = Intentos; } bool Juego::acierta() { int val = 0; do {
cout << " adivina el numero entre (0..30)?, intento " << ++intento << " ";
cin >> val;
if ( val > num ) // si es mayor le decimos que le baje cout << "es menor\n";
else if ( val < num ) // si es menor le decimos que le suba cout << "es mayor\n";
// mientras no sea el numero y los intentos sean menos de numIntentos
} while ( val != num && intento < numIntentos ); if (val != num)
return false; else
return true; }
int Juego::intentos() { return intento; }
int Juego::NumIntentos() { return numIntentos; } };
int main() {
int limNum, limInt;
cout << "generar numeros entre 0 y ? "; cin >> limNum;
cout << "cuantos intentos maximos ? "; cin >> limInt;
Juego obj(limNum, limInt); if ( obj.acierta() )
cout << " Acerto !!! "; else
cout << " Fallo!!! ";
cout << " intentos " << obj.intentos() << " de " << obj.NumIntentos() << " \n";
system("pause"); return 0;
}
Bibliografía
Y. Langsam, M. J. Augenstein, A. Tenenbaum. Data Structures using C and C++. Prentice Hall, Second edition. ISBN 0-13-036997-7.