Curso de C
Por Nacho Cabanes, versión 0.90 pre4

9. Punteros y gestión dinámica de memoria

9.1. ¿Por qué usar estructuras dinámicas?

Hasta ahora teníamos una serie de variables que declaramos al principio del programa o de cada función. Estas variables, que reciben el nombre de ESTÁTICAS, tienen un tamaño asignado desde el momento en que se crea el programa.

Este tipo de variables son sencillas de usar y rápidas... si sólo vamos a manejar estructuras de datos que no cambien, pero resultan poco eficientes si tenemos estructuras cuyo tamaño no sea siempre el mismo.

Es el caso de una agenda: tenemos una serie de fichas, e iremos añadiendo más. Si reservamos espacio para 10, no podremos llegar a añadir la número 11, estamos limitando el máximo. Una solución sería la de trabajar siempre en el disco: no tenemos límite en cuanto a número de fichas, pero es muchísimo más lento.

Lo ideal sería aprovechar mejor la memoria que tenemos en el ordenador, para guardar en ella todas las fichas o al menos todas aquellas que quepan en memoria.

Una solución “típica” (pero mala) es sobredimensionar: preparar una agenda contando con 1000 fichas, aunque supongamos que no vamos a pasar de 200. Esto tiene varios inconvenientes: se desperdicia memoria, obliga a conocer bien los datos con los que vamos a trabajar, sigue pudiendo verse sobrepasado, etc.

La solución suele ser crear estructuras DINÁMICAS, que puedan ir creciendo o disminuyendo según nos interesen. Ejemplos de este tipo de estructuras son:

Y la cosa se va complicando: en los árboles cada elemento puede tener varios sucesores, etc.

Todas estas estructuras tienen en común que, si se programan bien, pueden ir creciendo o decreciendo según haga falta, al contrario que un array, que tiene su tamaño prefijado.

En todas ellas, lo que vamos haciendo es reservar un poco de memoria para cada nuevo elemento que nos haga falta, y enlazarlo a los que ya teníamos. Cuando queramos borrar un elemento, enlazamos el anterior a él con el posterior a él (para que no “se rompa” nuestra estructura) y liberamos la memoria que estaba ocupando.

 

9.2. ¿Qué son los punteros?

Un puntero no es más que una dirección de memoria. Lo que tiene de especial es que normalmente un puntero tendrá un tipo de datos asociado: por ejemplo, un “puntero a entero” será una dirección de memoria en la que habrá almacenado (o podremos almacenar) un número entero.

Vamos a ver qué símbolo usamos en C para designar los punteros:

int num; /* "num" es un número entero */
int *pos; /* "pos" es un "puntero a entero" (dirección de
   memoria en la que podremos guardar un entero) */

Es decir, pondremos un asterisco entre el tipo de datos y el nombre de la variable. Ese asterisco puede ir junto a cualquiera de ambos, también es correcto escribir

int* pos;

Esta nomenclatura ya la habíamos utilizado aun sin saber que era eso de los punteros. Por ejemplo, cuando queremos acceder a un fichero, hacemos

FILE* fichero;

Antes de entrar en más detalles, y para ver la diferencia entre trabajar con “arrays” o con punteros, vamos a hacer dos programas que pidan varios números enteros al usuario y muestren su suma. El primero empleará un “array” (una tabla, de tamaño predefinido) y el segundo empleará memoria que reservaremos durante el funcionamiento del programa.

El primero podría ser así:

/*---------------------------*/
/*  Ejemplo en C nº 71:      */
/*  C071.C                   */
/*                           */
/*  Sumar varios datos       */
/*  Version 1: con arrays    */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
 
main() {
  int datos[100];  /* Preparamos espacio para 100 numeros */      
  int cuantos;     /* Preguntaremos cuantos desea introducir */
  int i;           /* Para bucles */
  long suma=0;     /* La suma, claro */
 
  do {
    printf("Cuantos numeros desea sumar? ");
    scanf("%d", &cuantos);
    if (cuantos>100)  /* Solo puede ser 100 o menos */
      printf("Demasiados. Solo se puede hasta 100.");
  } while (cuantos>100);  /* Si pide demasiado, no le dejamos */
 
  /* Pedimos y almacenamos los datos */
  for (i=0; i<cuantos; i++) {
    printf("Introduzca el dato número %d: ", i+1);
    scanf("%d", &datos[i]);
  }
 
  /* Calculamos la suma */
  for (i=0; i<cuantos; i++) 
    suma += datos[i];
 
  printf("Su suma es: %ld\n", suma);
}
 

Los más avispados se pueden dar cuenta de que si sólo quiero calcular la suma, lo podría hacer a medida que leo cada dato, no necesitaría almacenar todos. Vamos a suponer que sí necesitamos guardarlos (en muchos casos será verdad, si los cálculos son más complicados). Entonces nos damos cuenta de que lo que hemos estado haciendo hasta ahora no es eficiente:

La solución es reservar espacio estrictamente para lo que necesitemos, y eso es algo que podríamos hacer así:

/*---------------------------*/
/*  Ejemplo en C nº 72:      */
/*  C072.C                   */
/*                           */
/*  Sumar varios datos       */
/*  Version 2: con punteros  */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
#include <stdlib.h>
 
main() {
  int* datos;      /* Necesitaremos espacio para varios numeros */      
  int cuantos;     /* Preguntaremos cuantos desea introducir */
  int i;           /* Para bucles */
  long suma=0;     /* La suma, claro */
  do {
    printf("Cuantos numeros desea sumar? ");
    scanf("%d", &cuantos);
    datos = (int *) malloc (cuantos * sizeof(int));
    if (datos == NULL)  /* Si no hay espacio, avisamos */
      printf("No caben tantos datos en memoria.");
  } while (datos == NULL);  /* Si pide demasiado, no le dejamos */
 
  /* Pedimos y almacenamos los datos */
  for (i=0; i<cuantos; i++) {
    printf("Introduzca el dato número %d: ", i+1);
    scanf("%d", datos+i);
  }
 
  /* Calculamos la suma */
  for (i=0; i<cuantos; i++) 
    suma += *(datos+i);
 
  printf("Su suma es: %ld\n", suma);
  free(datos);
}
 

Este fuente es más difícil de leer, pero a cambio es mucho más eficiente: funciona perfectamente si sólo queremos sumar 5 números, pero también si necesitamos sumar 120.000 (y si caben tantos números en la memoria disponible de nuestro equipo, claro).

Vamos a ver las diferencias:

En primer lugar, lo que antes era int datos[100] que quiere decir “a partir de la posición de memoria que llamaré datos, querré espacio para a guardar 100 números enteros”, se ha convertido en int* datos que quiere decir “a partir de la posición de memoria que llamaré datos voy a guardar varios números enteros (pero aún no sé cuantos)”.

Luego reservamos el espacio exacto que necesitamos, haciendo datos = (int *) malloc (cuantos * sizeof(int)); Esta orden suena complicada, así que vamos a verla por partes:

La forma de guardar los datos que teclea el usuario también es distinta. Cuando trabajábamos con un “array”, hacíamos scanf("%d", &datos[i]) (“el dato número i”), pero con punteros usaremos scanf("%d", datos+i) (en la posición datos + i). Ahora ya no necesitamos el símbolo “ampersand” (&). Este símbolo se usa para indicarle a C en qué posición de memoria debe almacenar un dato. Por ejemplo, float x; es una variable que podremos usar para guardar un número real. Si lo hacemos con la orden “scanf”, esta orden no espera que le digamos en qué variable deber guardar el dato, sino en qué posición de memoria. Por eso hacemos scanf("%f", &x); En el caso que nos encontramos ahora, int* datos ya se refiere a una posición de memoria (un puntero), por lo que no necesitamos & para usar “scanf”.

Finalmente, la forma de acceder a los datos también cambia. Antes leíamos el primer dato como datos[0], el segundo como datos[1], el tercero como datos[2] y así sucesivamente. Ahora usaremos el asterisco (*) para indicar que queremos saber el valor que hay almacenado en una cierta posición: el primer dato será *datos, el segundo *(datos+1), el tercero será *(datos+2) y así en adelante. Por eso, donde antes hacíamos suma += datos[i]; ahora usamos suma += *(datos+i);

También aparece otra orden nueva: free. Hasta ahora, teníamos la memoria reservada estáticamente, lo que supone que la usábamos (o la desperdiciábamos) durante todo el tiempo que nuestro programa estuviera funcionando. Pero ahora, igual que reservamos memoria justo en el momento en que la necesitamos, y justo en la cantidad que necesitamos, también podemos volver a dejar disponible esa memoria cuando hayamos terminado de usarla. De eso se encarga la orden “free”, a la que le debemos indicar qué puntero es el que queremos liberar.

9.3. Repasemos con un ejemplo sencillo

El ejemplo anterior era “un caso real”. Generalmente, los casos reales son más aplicables que los ejemplos puramente académicos, pero también más difíciles de seguir. Por eso, antes de seguir vamos a ver un ejemplo más sencillo que nos ayude a asentar los conceptos: Reservaremos espacio para un número real de forma estática, y para dos números reales de forma dinámica, daremos valor a dos de ellos, guardaremos su suma en el tercer número y mostraremos en pantalla los resultados.

/*---------------------------*/
/*  Ejemplo en C nº 73:      */
/*  C073.C                   */
/*                           */
/*  Manejo básico de         */
/*  punteros                 */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
#include <stdlib.h>
 
main() {
  float n1;           /* Primer número, estático */
  float *n2, *suma;   /* Los otros dos números */      
 
  n1 = 5.0;           /* Damos un valor prefijado a n1 (real) */
  n2 = (float *) malloc (sizeof(float)); /* Reservamos espacio para n2 */
  *n2 = 6.7;          /* Valor prefijado para n2 (puntero a real) */
 
  suma = (float *) malloc (sizeof(float)); /* Reservamos espacio para suma */
  *suma = n1 + *n2;   /* Calculamos la suma */
 
  printf("El valor prefijado para la suma era %4.2f\n",
    *suma);
 
  printf("Ahora es tu turno: Introduce el primer número ");
  scanf("%f",&n1);   /* Leemos valor para n1 (real) */
 
  printf("Introduce el segundo número ");
  scanf("%f",n2);    /* Valor para n2 (puntero a real) */  
 
  *suma = n1 + *n2;  /* Calculamos nuevamente la suma */
 
  printf("Ahora la suma es %4.2f\n", *suma);
 
  free(n2);          /* Liberamos la memoria reservada */
  free(suma);
}
 

Las diferencias son:

(En este ejemplo, no hemos comprobado si el resultado de “malloc” era NULL, porque sólo pedíamos espacio para dos variables, y hemos dado por sentado que sí habría memoria disponible suficiente para almacenarlas; en un caso general, deberemos asegurarnos siempre de que se nos ha concedido ese espacio que hemos pedido).

9.4. Aritmética de punteros

Si declaramos una variable como int n=5 y posteriormente hacemos n++, debería resultar claro que lo que ocurre es que aumenta en una unidad el valor de la variable n, pasando a ser 6. Pero ¿qué sucede si hacemos esa misma operación sobre un puntero?

int *n;
n = (int *) malloc (sizeof(int));
*n = 3;
n++;

Después de estas líneas de programa, lo que ha ocurrido no es que el contenido de la posición n sea 4. Eso lo conseguiríamos modificando “*n”, de la misma forma que le hemos dado su valor inicial. Es decir, deberíamos usar

(*n) ++;

En cambio, nosotros hemos aumentado el valor de “n”. Como “n” es un puntero, estamos modificando una dirección de memoria. Por ejemplo, si “n” se refería a la posición de memoria número 10.000 de nuestro ordenador, ahora ya no es así, ahora es otra posición de memoria distinta, por ejemplo la 10.001.

¿Y por qué “por ejemplo”? Porque, como ya sabemos, el espacio que ocupa una variable en C depende del sistema operativo. Así, en un sistema operativo de 32 bits, un “int” ocuparía 4 bytes, de modo que la operación

n++;

haría que pasáramos de mirar la posición 10.000 a la 10.004. Generalmente no es esto lo que querremos, sino modificar el valor que había almacenado en esa posición de memoria. Olvidar ese * que indica que queremos cambiar el dato y no la posición de memoria puede dar lugar a fallos muy difíciles de descubrir (o incluso a que el programa se interrumpa con un aviso de “Violación de segmento” porque estemos accediendo a zonas de memoria que no hemos reservado).

9.5. Punteros y funciones: parámetros por referencia

Hasta ahora no sabíamos cómo modificar los parámetros que pasábamos a una función. Recordemos el ejemplo 64:

/*---------------------------*/
/*  Ejemplo en C nº 64:      */
/*  C064.C                   */
/*                           */
/*  Dos variables locales    */
/*  con el mismo nombre      */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
 
void duplica(int n) {
   n = n * 2;
}
 
main() {
   int n = 5;
   printf("n vale %d\n", n);
   duplica(n);
   printf("Ahora n vale %d\n", n);
}
 

Cuando poníamos este programa en marcha, el valor de n que se mostraba era un 5, porque los cambios que hiciéramos dentro de la función se perdían al salir de ella. Esta forma de trabajar (la única que conocíamos hasta ahora) es lo que se llama “pasar parámetros por valor”.

Pero existe una alternativa. Es lo que llamaremos “pasar parámetros por referencia”. Consiste en que el parámetro que nosotros pasamos a la función no es realmente la variable, sino la dirección de memoria en que se encuentra dicha variable (usando &). Dentro de la función, modificaremos la información que se encuentra dentro de esa dirección de memoria (usando *), así:

/*---------------------------*/
/*  Ejemplo en C nº 74:      */
/*  C074.C                   */
/*                           */
/*  Modificar el valor de    */
/*  un parámetro             */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
 
void duplica(int *x) {
   *x = *x * 2;
}
 
main() {
   int n = 5;
   printf("n vale %d\n", n);
   duplica(&n);
   printf("Ahora n vale %d\n", n);
}
 

Esto permite que podamos obtener más de un valor a partir de una función. Por ejemplo, podemos crear una función que intercambie los valores de dos variables enteras así:

/*---------------------------*/
/*  Ejemplo en C nº 75:      */
/*  C075.C                   */
/*                           */
/*  Intercambiar el valor de */
/*  dos parámetros           */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
 
void intercambia(int *x, int *y) {
   int auxiliar;
   auxiliar = *x;
   *x = *y;
   *y = auxiliar ;
}
 
main() {
   int a = 5;
   int b = 12;
   intercambia(&a, &b);
   printf("Ahora a es %d y b es %d\n", a, b);
}
 

Este programa escribirá en pantalla que a vale 12 y que b vale 5. Dentro de la función “intercambia”, nos ayudamos de una variable auxiliar para memorizar el valor de x antes de cambiarlo por el valor de y.

Ejercicio propuesto: crear una función que calcule las dos soluciones de una ecuación de segundo grado (Ax2 + Bx + C = 0) y devuelva las dos soluciones como parámetros.

9.6. Punteros y arrays

En C hay muy poca diferencia “interna” entre un puntero y un array. En muchas ocasiones, podremos declarar un dato como array (una tabla con varios elementos iguales, de tamaño predefinido) y recorrerlo usando punteros. Vamos a ver un ejemplo:

/*---------------------------*/
/*  Ejemplo en C nº 76:      */
/*  C076.C                   */
/*                           */
/*  Arrays y punteros (1)    */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
 
main() {
   int datos[10];
   int i;
 
   /* Damos valores normalmente */
   for (i=0; i<10; i++)
     datos[i] = i*2;   
   /* Pero los recorremos usando punteros */
   for (i=0; i<10; i++)
     printf ("%d ", *(datos+i));
}
 

Pero también podremos hacer lo contrario: declarar de forma dinámica una variable usando “malloc” y recorrerla como si fuera un array:

/*---------------------------*/
/*  Ejemplo en C nº 77:      */
/*  C077.C                   */
/*                           */
/*  Arrays y punteros (2)    */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
#include <stdlib.h>
 
main() {
   int *datos;
   int i;
 
   /* Reservamos espacio */
   datos = (int *) malloc (20*sizeof(int));
   /* Damos valores como puntero */
   printf("Uso como puntero... ");
   for (i=0; i<20; i++)
     *(datos+i) = i*2;   
   /* Y los mostramos */
   for (i=0; i<10; i++)
     printf ("%d ", *(datos+i));
   /* Ahora damos valores como array */
   printf("\nUso como array... ");
   for (i=0; i<20; i++)
     datos[i] = i*3;   
   /* Y los mostramos */
   for (i=0; i<10; i++)
     printf ("%d ", datos[i]);
   /* Liberamos el espacio */
   free(datos);     
}
 

9.7. Arrays de punteros

Igual que creamos “arrays” para guardar varios datos que sean números enteros o reales, podemos hacerlo con punteros: podemos reservar espacio para “20 punteros a enteros” haciendo

int *datos[20];

Tampoco es algo especialmente frecuente en un caso general, porque si fijamos la cantidad de datos, estamos perdiendo parte de la versatilidad que podríamos tener al usar memoria dinámica. Pero sí es habitual cuando se declaran varias cadenas:

char *mensajesError[3]={"Fichero no encontrado", "No se puede escribir",
"Fichero sin datos"};

Un ejemplo de su uso sería este:

/*---------------------------*/
/*  Ejemplo en C nº 78:      */
/*  C078.C                   */
/*                           */
/*  Arrays de punteros       */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
 
main() {
   char *mensajesError[3]={"Fichero no encontrado", 
     "No se puede escribir", 
     "Fichero sin datos"};
 
   printf("El primer mensaje de error es: %s\n",
     mensajesError[0]);
   printf("El segundo mensaje de error es: %s\n",
     mensajesError[1]);
   printf("El tercer mensaje de error es: %s\n",
     mensajesError[2]);          
}
 

9.8. Punteros y estructuras

Igual que creamos punteros a cualquier tipo de datos básico, le reservamos memoria con “malloc” cuando necesitamos usarlo y lo liberamos con “free” cuando terminamos de utilizarlo, lo mismo podemos hacer si se trata de un tipo de datos no tan sencillo, como un “struct”.

Eso sí, la forma de acceder a los datos en un struct cambiará ligeramente. Para un dato que sea un número entero, ya sabemos que lo declararíamos con int *n y cambiaríamos su valor haciendo algo como *n=2, de modo que para un struct podríamos esperar que se hiciera algo como *persona.edad = 20. Pero esa no es la sintaxis correcta: deberemos utilizar el nombre de la variable y el del campo, con una flecha (->) entre medias, así: persona->edad = 20. Vamos a verlo con un ejemplo:

/*---------------------------*/
/*  Ejemplo en C nº 79:      */
/*  C079.C                   */
/*                           */
/*  Punteros y structs       */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
 
main() {    
   /* Primero definimos nuestro tipo de datos */    
   struct datosPersona {
     char nombre[30];
     char email[25];
     int edad;
   };
 
   /* La primera persona será estática */
   struct datosPersona persona1;
   /* La segunda será dinámica */
   struct datosPersona *persona2;
 
   /* Damos valores a la persona estática */
   strcpy(persona1.nombre, "Juan");
   strcpy(persona1.email, "j@j.j");
   persona1.edad = 20;
 
   /* Ahora a la dinámica */
   persona2 = (struct datosPersona*)
     malloc (sizeof(struct datosPersona));
   strcpy(persona2->nombre, "Pedro");
   strcpy(persona2->email, "p@p.p");
   persona2->edad = 21;
 
   /* Mostramos los datos y liberamos la memoria */
   printf("Primera persona: %s, %s, con edad %d\n",
     persona1.nombre, persona1.email, persona1.edad);
   printf("Segunda persona: %s, %s, con edad %d\n",
     persona2->nombre, persona2->email, persona2->edad);
   free(persona2);
}
 

Ejercicio propuesto: mejorar la versión de la agenda que leía todos los datos al principio de la ejecución y guardaba todos los datos cuando terminábamos su uso (apartado 6.4). Esta nueva versión deberá estar preparada para manejar hasta 1000 fichas, pero sólo reservará espacio para las que realmente sean necesarias.

9.9. Parámetros de “main”

Es muy frecuente que un programa que usamos desde la “línea de comandos” tenga ciertas opciones que le indicamos como argumentos. Por ejemplo, bajo Linux o cualquier otro sistema operativo de la familia Unix, podemos ver la lista detallada de ficheros que terminan en .c haciendo

ls –l *.c

En este caso, la orden sería “ls”, y las dos opciones (argumentos o parámetros) que le indicamos son “-l” y “*.c”.

Pues bien, estas opciones que se le pasan al programa se pueden leer desde C. La forma de hacerlo es con dos parámetros. El primero (que por convenio se suele llamar “argc”) será un número entero que indica cuantos argumentos se han tecleado. El segundo (que se suele llamar “argv”) es una tabla de cadenas de texto, que contiene cada uno de esos argumentos.

Por ejemplo, si bajo Windows o MsDos tecleamos la orden “DIR *.EXE P”, tendríamos que:

* argc es la cantidad de parámetros, incluyendo el nombre del propio programa (3, en este ejemplo).
* argv[0] es el nombre del programa (DIR, en este caso).
* argv[1] es el primer argumento (*.EXE).
* argv[2] es el segundo argumento (/P).

Un fuente en C de ejemplo, que mostrara todos los parámetros que se han tecleado sería:

/*---------------------------*/
/*  Ejemplo en C nº 80:      */
/*  C080.C                   */
/*                           */
/*  Argumentos de "main"     */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
 
int main (int argc, char *argv[])
{
  int i;
 
  printf ("Nombre del programa:\"%s\".\n",argv[0]);
  if (argc > 0)
      for (i = 1; i<argc; i++)
           printf("Parámetro %d = %s\n", i, argv[i]);
  else
      printf("No se han indicado parámetros.\n");
  return 0;
}
 

Ejercicios propuestos:

9.10. Estructuras dinámicas habituales 1: las listas enlazadas

Ahora vamos a ver dos tipos de estructuras totalmente dinámicas (que puedan aumentar o disminuir realmente de tamaño durante la ejecución del programa). Primero veremos las listas, y más adelante los árboles binarios. Hay otras muchas estructuras, pero no son difíciles de desarrollar si se entienden bien estas dos.

Ahora “el truco” consistirá en que dentro de cada dato almacenaremos todo lo que nos interesa, pero también una referencia que nos dirá dónde tenemos que ir a buscar el siguiente.

Sería algo como:

(Posición: 1023).
Nombre : 'Nacho Cabanes'
Web : 'www.nachocabanes.com'
SiguienteDato : 1430

Este dato está almacenado en la posición de memoria número 1023. En esa posición guardamos el nombre y la dirección (o lo que nos interese) de esta persona, pero también una información extra: la siguiente ficha se encuentra en la posición 1430.

Así, es muy cómodo recorrer la lista de forma secuencial, porque en todo momento sabemos dónde está almacenado el siguiente dato. Cuando lleguemos a uno para el que no esté definido cual es el siguiente dato, quiere decir que se ha acabado la lista.

Por tanto, en cada dato tenemos un enlace con el dato siguiente. Por eso este tipo de estructuras recibe el nombre de “listas simplemente enlazadas” o listas simples. Si tuvieramos enlaces hacia el dato siguiente y el posterior, se trataría de una “lista doblemente enlazada” o lista doble, que pretende hacer más sencillo el recorrido hacia delante o hacia atrás.

Con este tipo de estructuras de información, hemos perdido la ventaja del acceso directo: ya no podemos saltar directamente a la ficha número 500. Pero, por contra, podemos tener tantas fichas como la memoria nos permita, y eliminar una (o varias) de ellas cuando queramos, recuperando inmediatamente el espacio que ocupaba.

Para añadir una ficha, no tendríamos más que reservar la memoria para ella, y el compilador de C nos diría “le he encontrado sitio en la posición 4079”. Entonces nosotros iríamos a la última ficha y le diríamos “tu siguiente dato va a estar en la posición 4079”.

Esa es la “idea intuitiva”. Ahora vamos a concretar cosas en forma de programa en C.

Primero veamos cómo sería ahora cada una de nuestras fichas:

struct f { /* Estos son los datos que guardamos: */
   char nombre[30]; /* Nombre, hasta 30 letras */
   char direccion[50]; /* Direccion, hasta 50 */
   int edad; /* Edad, un numero < 255 */
   struct f* siguiente; /* Y dirección de la siguiente */
};

La diferencia con un “struct” normal está en el campo “siguiente” de nuestro registro, que es el que indica donde se encuentra la ficha que va después de la actual, y por tanto será otro puntero a un registro del mismo tipo, un “struct f *”.

Un puntero que “no apunta a ningún sitio” tiene el valor NULL (realmente este identificador es una constante de valor 0), que nos servirá después para comprobar si se trata del final de la lista: todas las fichas “apuntarán” a la siguiente, menos la última, que “no tiene siguiente”, y apuntará a NULL.

Entonces la primera ficha definiríamos con

struct f *dato1; /* Va a ser un puntero a ficha */

y la comenzaríamos a usar con

dato1 = (struct f*) malloc (sizeof(struct f)); /* Reservamos memoria */
strcpy(dato1->nombre, "Pepe"); /* Guardamos el nombre, */
strcpy(dato1->direccion, "Su casa"); /* la dirección */
dato1->edad = 40; /* la edad */
dato1->siguiente = NULL; /* y no hay ninguna más */

(No debería haber anada nuevo: ya sabemos cómo reservar memoria usando “malloc” y como acceder a los campos de una estructura dinámica usando ->).

Ahora que ya tenemos una ficha, podríamos añadir otra ficha detrás de ella. Primero guardamos espacio para la nueva ficha, como antes:

struct f *dato2;

dato2 = (struct f*) malloc (sizeof(struct f)); /* Reservamos memoria */
strcpy(dato2->nombre, "Juan"); /* Guardamos el nombre, */
strcpy(dato2->direccion, "No lo sé"); /* la dirección */
dato2->edad = 35; /* la edad */
dato2->siguiente = NULL; /* y no hay ninguna más */

y ahora enlazamos la anterior con ella:

dato1->siguiente = dato2;

Si quisieramos introducir los datos ordenados alfabéticamente, basta con ir comparando cada nuevo dato con los de la lista, e insertarlo donde corresponda. Por ejemplo, para insertar un nuevo dato entre los dos anteriores, haríamos:

struct f *dato3;

dato3 = (struct f*) malloc (sizeof(struct f)); /* La tercera */
strcpy(dato3->nombre, "Carlos");
strcpy(dato3->direccion, "Por ahí");
dato3->edad = 14;
dato3->siguiente = dato2; /* enlazamos con la siguiente */
dato1->siguiente = dato3; /* y la anterior con ella */
printf("La lista inicialmente es:\n");

La estructura que hemos obtenido es la siguiente

Dato1 - Dato3 - Dato2 - NULL

Gráficamente:

Es decir: cada ficha está enlazada con la siguiente, salvo la última, que no está enlazada con ninguna (apunta a NULL).

Si ahora quisiéramos borrar Dato3, tendríamos que seguir dos pasos:

1.- Enlazar Dato1 con Dato2, para no perder información.
2.- Liberar la memoria ocupada por Dato3.

Esto, escrito en "C" sería:

dato1->siguiente = dato2; /* Borrar dato3: Enlaza Dato1 y Dato2 */
free(dato3); /* Libera lo que ocupó Dato3 */

Hemos empleado tres variables para guardar tres datos. Si tenemos 20 datos, ¿necesitaremos 20 variables? ¿Y 3000 variables para 3000 datos?

Sería tremendamente ineficiente, y no tendría mucho sentido. Es de suponer que no sea así. En la práctica, basta con dos variables, que nos indicarán el principio de la lista y la posición actual, o incluso sólo una para el principio de la lista.

Por ejemplo, una rutina que muestre en pantalla toda la lista se podría hacer de forma recursiva así:

void MuestraLista ( struct f *inicial ) {
   if (inicial!=NULL) { /* Si realmente hay lista */
     printf("Nombre: %s\n", inicial->nombre);
     printf("Dirección: %s\n", inicial->direccion);
     printf("Edad: %d\n\n", inicial->edad);
     MuestraLista ( inicial->siguiente ); /* Y mira el siguiente */
   }
}

Lo llamaríamos con "MuestraLista(Dato1)", y a partir de ahí el propio procedimiento se encarga de ir mirando y mostrando los siguientes elementos hasta llegar a NULL, que indica el final.

Antes de seguir, vamos a juntar todo esto en un programa, para comprobar que realmente funciona: añadimos los 3 datos y decimos que los muestre desde el primero; luego borramos el del medio y los volvemos a mostrar:

/*---------------------------*/
/*  Ejemplo en C nº 81:      */
/*  C081.C                   */
/*                           */
/*  Primer ejemplo de lista  */
/*  enlazada simple          */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
#include <stdlib.h>
 
struct f {           /* Estos son los datos que guardamos: */
  char nombre[30];          /*   Nombre, hasta 30 letras */
  char direccion[50];       /*   Direccion, hasta 50 */
  int edad;                 /*   Edad, un numero < 255 */
  struct f* siguiente;      /*   Y dirección de la siguiente */
};
 
struct f *dato1;            /* Va a ser un puntero a ficha */
struct f *dato2;            /* Otro puntero a ficha */
struct f *dato3;            /* Y otro más */
 
void MuestraLista ( struct f *inicial ) {
  if (inicial!=NULL) {                      /* Si realmente hay lista */ 
    printf("Nombre: %s\n", inicial->nombre);
    printf("Dirección: %s\n", inicial->direccion);
    printf("Edad: %d\n\n", inicial->edad);
    MuestraLista ( inicial->siguiente );    /* Y mira el siguiente */
  }
}
 
int main() {
  dato1 = (struct f*) malloc (sizeof(struct f)); /* Reservamos memoria */
  strcpy(dato1->nombre, "Pepe");           /* Guardamos el nombre, */
  strcpy(dato1->direccion, "Su casa");     /*   la dirección */
  dato1->edad = 40;                        /*   la edad */
  dato1->siguiente = NULL;                 /* y no hay ninguna más */
 
  dato2 = (struct f*) malloc (sizeof(struct f)); /* Reservamos memoria */
  strcpy(dato2->nombre, "Juan");           /* Guardamos el nombre, */
  strcpy(dato2->direccion, "No lo sé");    /*   la dirección */
  dato2->edad = 35;                        /*   la edad */
  dato2->siguiente = NULL;                 /* y no hay ninguna más */
  dato1->siguiente = dato2;                /* Enlazamos anterior con ella */
 
  dato3 = (struct f*) malloc (sizeof(struct f)); /* La tercera */
  strcpy(dato3->nombre, "Carlos");
  strcpy(dato3->direccion, "Por ahí");
  dato3->edad = 14;
  dato3->siguiente = dato2;                /* enlazamos con la siguiente */
  dato1->siguiente = dato3;                /* y la anterior con ella */
  printf("La lista inicialmente es:\n");
 
  MuestraLista (dato1);
  dato1->siguiente = dato2;        /* Borrar dato3: Enlaza Dato1 y Dato2 */
  free(dato3);                     /* Libera lo que ocupó Dato3 */
  printf("Y tras borrar dato3:\n\n");
  MuestraLista (dato1);
  return 0;
}
 

Vamos a ver otro ejemplo, que cree una lista de números, y vaya insertando en ella varios valores ordenados. Ahora tendremos una función para mostrar datos, otra para crear la lista insertando el primer dato, y otra que inserte un dato ordenado

/*---------------------------*/
/*  Ejemplo en C nº 82:      */
/*  C082.C                   */
/*                           */
/*  Segundo ejemplo de lista */
/*  enlazada (ordenada)      */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
#include <stdlib.h>
 
struct lista {           /* Nuestra lista */
  int numero;            /* Solo guarda un numero */
  struct lista* sig;     /* Y el puntero al siguiente dato */
};
 
struct lista* CrearLista(int valor) {  /* Crea la lista, claro */
  struct lista* r;        /* Variable auxiliar */
  r = (struct lista*) 
    malloc (sizeof(struct lista)); /* Reserva memoria */                           
  r->numero = valor;      /* Guarda el valor */
  r->sig = NULL;          /* No hay siguiente */
  return r;               /* Crea el struct lista* */
};
 
void MuestraLista ( struct lista *lista ) {
  if (lista) {                          /* Si realmente hay lista */
      printf("%d\n", lista->numero);    /* Escribe el valor */
      MuestraLista (lista->sig );       /* Y mira el siguiente */
  };
};
 
void InsertaLista( struct lista **lista, int valor) {
  struct lista* r;        /* Variable auxiliar, para reservar */
  struct lista* actual;   /* Otra auxiliar, para recorrer */
 
  actual = *lista;        
  if (actual)                           /*  Si hay lista */
    if (actual->numero < valor)         /*    y todavía no es su sitio */
      InsertaLista(&actual->sig,valor); /*   mira la siguiente posición */
    else {                     /* Si hay lista pero ya es su sitio */
      r = CrearLista(valor);   /* guarda el dato */
      r->sig = actual;         /* pone la lista a continuac. */
      *lista = r;              /* Y hace que comience en el nuevo dato */
    } 
  else {              /* Si no hay lista, hay que crearla */
    r = CrearLista(valor); 
    *lista = r;       /* y hay que indicar donde debe comenzar */
   }
}
 
int main() {
  struct lista* l;               /* La lista que crearemos */
  l = CrearLista(5);             /* Crea una lista e introduce un 5 */
  InsertaLista(&l, 3);           /* Inserta un 3 */
  InsertaLista(&l, 2);           /* Inserta un 2 */
  InsertaLista(&l, 6);           /* Inserta un 6 */
  MuestraLista(l);               /* Muestra la lista resultante */
  return 0;                      /* Se acabó */
}
 
 

No es un fuente fácil de leer (en general, no lo serán los que manejen punteros), pero aun así los cambios no son grandes:

Finalmente, hay varios casos particulares que resultan más sencillos que una lista “normal”. Vamos a comentar los más habituales:

Ejercicio propuesto: Las listas simples, tal y como las hemos tratado, tienen la ventaja de que no hay limitaciones tan rígidas en cuanto a tamaño como en las variables estáticas, ni hay por qué saber el número de elementos desde el principio. Pero siempre hay que recorrerlas desde DELANTE hacia ATRAS, lo que puede resultar lento. Una mejora relativamente evidente es lo que se llama una lista doble o lista doblemente enlazada: si guardamos punteros al dato anterior y al siguiente, en vez de sólo al siguiente, podremos avanzar y retroceder con comodidad. Implementa una lista doble enlazada que almacene números enteros.

9.11. Estructuras dinámicas habituales 2: los árboles binarios

En las listas, después de cada elemento había otro, el “siguiente” (o ninguno, si habíamos llegado al final).

Pero también nos puede interesar tener varias posibilidades después de cada elemento, varios “hijos”, por ejemplo 3. De cada uno de estos 3 “hijos” saldrían otros 3, y así sucesivamente. Obtendríamos algo que recuerda a un árbol: un tronco del que nacen 3 ramas, que a su veces se subdividen en otras 3 de menor tamaño, y así sucesivamente hasta llegar a las hojas.

Pues eso mismo será un árbol: una estructura dinámica en la que cada nodo (elemento) puede tener más de un “siguiente”. Nos centraremos en los árboles binarios, en los que cada nodo puede tener un hijo izquierdo, un hijo derecho, ambos o ninguno (dos hijos como máximo).

Para puntualizar aun más, trataremos los árboles binarios de búsqueda, en los que tenemos prefijado un cierto orden, que nos ayudará a encontrar un cierto dato dentro de un árbol con mucha rapidez.

Este "orden prefijado" será el siguiente: para cada nodo tendremos que
* la rama de la izquierda contendrá elementos menores que él.
* la rama de la derecha contendrá elementos mayores que él.

Para que se entienda mejor, vamos a introducir en un árbol binario de búsqueda los datos 5,3,7,2,4,8,9

Primer número: 5 (directo)

5

Segundo número: 3 (menor que 5)

 5
/
3

Tercer número: 7 (mayor que 5)

 5
/ \
3 7

Cuarto: 2 (menor que 5, menor que 3)

   5
  / \
  3 7
/
2

Quinto: 4 (menor que 5, mayor que 3)

   5
  / \
  3 7
 / \
 2 4

Sexto: 8 (mayor que 5, mayor que 7)

5
/ \
3 7
/ \ \
2 4 8

Séptimo: 9 (mayor que 5, mayor que 7, mayor que 8)

   5
  / \
 3  7
 / \ \
 2 4  8
       \
        9

¿Qué ventajas tiene esto? La rapidez: tenemos 7 elementos, lo que en una lista supone que si buscamos un dato que casualmente está al final, haremos 7 comparaciones; en este árbol, tenemos 4 alturas => 4 comparaciones como máximo.

Y si además hubiéramos “equilibrado” el árbol (irlo recolocando, de modo que siempre tenga la menor altura posible), serían 3 alturas. Esto es lo que se hace en la práctica cuando en el árbol se va a hacer muchas más lecturas que escrituras: se reordena internamente después de añadir cada nuevo dato, de modo que la altura sea mínima en cada caso. De este modo, el número máximo de comparaciones que tendríamos que hacer sería log2(n), lo que supone que si tenemos 1000 datos, en una lista podríamos llegar a tener que hacer 1000 comparaciones, y en un árbol binario, log2(1000) => 10 comparaciones como máximo. La ganancia en velocidad de búsqueda es clara.

No vamos a ver cómo se hace eso de los “equilibrados”, que sería propio de un curso de programación más avanzado, pero sí vamos a empezar a ver rutinas para manejar estos árboles binarios de búsqueda.

Recordemos que la idea importante es que todo dato menor estará a la izquierda del nodo que miramos, y los datos mayores estarán a su derecha.

Ahora la estructura de cada nodo (dato) será:

struct arbol { /* El tipo base en sí: */
   int dato; /* - un dato (entero) */
   struct arbol* hijoIzq; /* - puntero a su hijo izquierdo */
   struct arbol* hijoDer; /* - puntero a su hijo derecho */
};

Y las rutinas de inserción, búsqueda, escritura, borrado, etc., podrán ser recursivas. Como primer ejemplo, la de escritura de todo el árbol en orden sería:

void Escribir(struct arbol *punt)
{
   if (punt) /* Si no hemos llegado a una hoja */
   {
     Escribir(punt->hijoIzq); /* Mira la izqda recursivamente */
     printf("%d ",punt->dato); /* Escribe el dato del nodo */
     Escribir(punt->hijoDer); /* Y luego mira por la derecha */
   };
};

Quien no se crea que funciona, debería coger lápiz y papel comprobarlo con el árbol que hemos visto antes como ejemplo. Es muy importante que esta función quede clara antes de seguir leyendo, porque los demás serán muy parecidos.

La rutina de inserción sería parecida, aunque algo más "pesada" porque tenemos que pasar el puntero por referencia, para que se pueda modificar el puntero:

void Insertar(struct arbol **punt, int valor)
{
   struct arbol * actual= *punt;
   if (actual == NULL) /* Si hemos llegado a una hoja */
   {
     *punt = (struct arbol *)
     malloc (sizeof(struct arbol)); /* Reservamos memoria */
     actual= *punt;
     actual->dato = valor; /* Guardamos el dato */
     actual->hijoIzq = NULL; /* No tiene hijo izquierdo */
     actual->hijoDer = NULL; /* Ni derecho */
   }
   else /* Si no es hoja */
     if (actual->dato > valor) /* Y encuentra un dato mayor */
       Insertar(&actual->hijoIzq, valor); /* Mira por la izquierda */
     else /* En caso contrario (menor) */

      Insertar(&actual->hijoDer, valor); /* Mira por la derecha */
};

Y finalmente, la de borrado de todo el árbol, casi igual que la de escritura, sólo que en vez de borrar la izquierda, luego el nodo y luego la derecha, borraremos primero las dos ramas y en último lugar el nodo, para evitar incongruencias (intentar borrar el hijo de algo que ya no existe):

void Borrar(struct arbol *punt)
{
   if (punt) /* Si no hemos llegado a una hoja */
   {
     Borrar(punt->hijoIzq); /* Va a la izqda recursivamente */
     Borrar(punt->hijoDer); /* Y luego a la derecha */
     free (punt); /* Finalmente, libera lo que ocupa el nodo */
   };
};

Vamos a juntar todo esto en un ejemplo "que funcione":

/*---------------------------*/
/*  Ejemplo en C nº 83:      */
/*  C083.C                   */
/*                           */
/*  Arbol binario de         */
/*  búsqueda                 */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
#include <stdlib.h>
 
struct arbol {             /*  El tipo base en sí: */
      int dato;               /*    - un dato (entero) */
      struct arbol* hijoIzq;  /*    - puntero a su hijo izquierdo */
      struct arbol* hijoDer;  /*    - puntero a su hijo derecho */
 };
 
void Escribir(struct arbol *punt)
{
    if (punt)                  /*  Si no hemos llegado a una hoja */
    {
    Escribir(punt->hijoIzq);   /*  Mira la izqda recursivamente */
    printf("%d ",punt->dato);  /*  Escribe el dato del nodo */
    Escribir(punt->hijoDer);   /*  Y luego mira por la derecha */
    };
};
 
void Insertar(struct arbol **punt, int valor)
{
    struct arbol * actual= *punt;
    if (actual == NULL)              /*  Si hemos llegado a una hoja */
    {
      *punt = (struct arbol *) 
         malloc (sizeof(struct arbol));  /* Reservamos memoria */
      actual= *punt;
      actual->dato = valor;         /* Guardamos el dato */
      actual->hijoIzq = NULL;       /* No tiene hijo izquierdo */
      actual->hijoDer = NULL;       /* Ni derecho */
    }
    else                                   /*      Si no es hoja      */
   if (actual->dato > valor)              /* Y encuentra un dato mayor */
      Insertar(&actual->hijoIzq, valor);  /* Mira por la izquierda */
   else                                   /* En caso contrario (menor) */
 
      Insertar(&actual->hijoDer, valor);  /* Mira por la derecha */
};
 
/*  Cuerpo del programa  */
int main()
{
    struct arbol *arbol = NULL;
    Insertar(&arbol, 5);
    Insertar(&arbol, 3);
    Insertar(&arbol, 7);
    Insertar(&arbol, 2);
    Insertar(&arbol, 4);
    Insertar(&arbol, 8);
    Insertar(&arbol, 9);
    Escribir(arbol);
    return 0;
}
 

9.12. Indirección múltiple

Lo que estamos haciendo mediante los punteros es algo que técnicamente se conoce como “direccionamiento indirecto”: cuando hacemos int *n, generalmente no nos va a interesar el valor de n, sino que n es una dirección de memoria a la que debemos ir a mirar el valor que buscamos.

Pues bien, podemos hacer que ese direccionamiento sea todavía menos directo que en el caso normal: algo como int **n se referiría a que n es una dirección de memoria, en la que a su vez se encuentra como dato otra dirección de memoria, y dentro de esta segunda dirección de memoria es donde se encuentra el dato. Es decir, n sería un “puntero a puntero a entero”.

Esta no es una situación habitual, así que no profundizaremos más en ella.

Aun así, lo que sí se debe recordar es que char **datos es algo muy parecido a char datos[][], por lo que alguna vez lo veremos indicado como parámetros de una función de una forma o de la otra.

9.13. Un ejemplo: copiador de ficheros en una pasada

Como ejemplo de un fuente en el que se apliquen algunas de las ideas más importantes que hemos visto, vamos a crear un copidor de ficheros, que intente copiar todo el fichero de origen en una única pasada: calculará su tamaño, intentará reservar la memoria suficiente para almacenar todo el fichero a la vez, y si esa memoria está disponible, leerá el fichero completo y lo guardará con un nuevo nombre.

/*---------------------------*/
/*  Ejemplo en C nº 84:      */
/*  C084.C                   */
/*                           */
/*  Copiador de ficheros en  */
/*  una pasada               */
/*                           */
/*  Curso de C,              */
/*    Nacho Cabanes          */
/*---------------------------*/
 
#include <stdio.h>
 
FILE *fichOrg, *fichDest;      /* Los dos ficheros */
char *buffer;                  /* El buffer para guardar lo que leo */
char nombreOrg[80],            /* Los nombres de los ficheros */
  nombreDest[80];
long longitud;                 /* Tamaño del fichero */
long cantidad;                 /* El número de bytes leídos */
 
main()
{
    /* Accedo al fichero de origen */
    printf("Introduzca el nombre del fichero Origen: ");
    scanf("%s",nombreOrg);
    if ((fichOrg = fopen(nombreOrg, "rb")) == NULL)
    {
      printf("No existe el fichero origen!\n");
      exit(1);
 
    }
    /* Y ahora al de destino */
    printf("Introduzca el nombre del fichero Destino: ");
    scanf("%s",nombreDest);
    if ((fichDest = fopen(nombreDest, "wb")) == NULL)
    {
      printf("No se ha podido crear el fichero destino!\n");
      exit(2);
    }
 
    /* Miro la longitud del fichero de origen */
    fseek(fichOrg, 0, SEEK_END);
    longitud = ftell(fichOrg);
    fseek(fichOrg, 0, SEEK_SET);
    /* Reservo espacio para leer todo */
    buffer = (char *) malloc (longitud);
    if (buffer == NULL) 
    {
      printf("No se ha podido reservar tanto espacio!\n");
      exit(3);
    }
    /* Leo todos los datos a la vez */
    cantidad = fread( buffer, 1, longitud, fichOrg);
    /* Escribo tantos como haya leído */
    fwrite(buffer, 1, cantidad, fichDest);
 
    if (cantidad != longitud)
      printf("Cuidado: no se han leido (ni copiado) todos los datos\n");
 
    /* Cierro los ficheros */
    fclose(fichOrg);
    fclose(fichDest);
}