domingo, 10 de julio de 2011

Arboles binarios

Un árbol binario es un conjunto de finito de elementos, cuyo nombre es nodos de forma que cada nodo tiene dos hijos, un hijo izquierdo y uno derecho, no es posible que tengan más de dos hijos, si el árbol binario es vació si no tiene ningún elemento en el.

Algunos de los usos más comunes que tienen estos arboles son los de: árboles binarios de búsquedamontículos binarios, y codificación de Huffman.

Imagen que demuestra la apariencia de un árbol binario.

Estructura de un árbol binario

Un árbol es una estructura de datos ampliamente usada que emula la forma de un árbol (un conjunto de nodos conectados). Un nodo es la unidad sobre la que se construye el árbol y puede tener cero o más nodos hijos conectados a él. Se dice que un nodo b es hijo de un nodo a si existe un enlace desde b hasta a. Sólo puede haber un único nodo sin padres, que llamaremos raíz. Un nodo que no tiene hijos se conoce como hoja. Los demás nodos (tienen padre y uno o varios hijos) se les conoce como rama.

Cada nodo tiene un dato, para el ordenamiento necesita una función de procedencia (de origen) y una regla de orden (esta regla implementa el orden relativo de los datos padre e hijos), el grado de un nodo es la cantidad de hijos que tiene (máximo dos).

Estructura típica de un nodo.

La profundidad de un nodo es el camino de la raíz al nodo, la profundidad de un árbol es la profundidad de la hoja más profunda.

Análisis asintótico 

n = número de nodos que contiene el árbol
Un árbol balanceado con n = 8 y profundidad tres

El peor caso de falta de balance para n = 8 tiene profundidad seis
Asintóticamente lineal.

Rotaciones

Ayudan a entender un problema cuando un nodo es capaz de detectar in imbalance (esto sucede cuando se agrega o elimina un nodo del árbol), estas rotaciones proceden recursivamente hasta la raíz hasta que no haya imbalance.

Existen cuatro distintos tipos de rotación, como:

Imagen que demuestra la rotación simple izquierda.

Imagen que demuestra la rotación simple derecha.

Imagen que demuestra la rotación doble izquierda-derecha.

Imagen que demuestra la rotación doble derecha-izquierda.

Condiciones de rotación


Estas son las condiciones que se tienen que llevar a cabo para poder realizar la operación de rotaciones.

Inserción

La inserción se puede dar una solución tanto iterativa como recursiva. Si tenemos inicialmente como parámetro un árbol vacío se crea un nuevo nodo como único contenido el elemento a insertar. En caso de que no este, se comprueba si el elemento dado es menor que la raíz del árbol inicial con lo que se inserta en el sub-árbol izquierdo y si es mayor se inserta en el sub-árbol derecho. De esta forma las inserciones se hacen en las hojas.

Proceso de inserción, número 5.

Ejemplo de pseudocódigo de inserción

PROC InsertarABB(árbol:TABB; dato:TElemento)
VARIABLES
   nuevonodo,pav,pret:TABB
   clavenueva:Tclave
   ele:TElemento
INICIO
   nuevonodo <- NUEVO(TNodoABB)
   nuevonodo^.izq <- NULO
   nuevonodo^.der <- NULO
   nuevonodo^.elem <- dato
   SI ABBVacío (árbol) ENTONCES
      árbol <- nuevonodo
   ENOTROCASO
      clavenueva <- dato.clave
      pav <- árbol                // Puntero Avanzado
      pret <- NULO                // Puntero Retrasado
      MIENTRAS (pav <- NULO) HACER
          pret <- pav
          ele = pav^.elem
          SI (clavenueva < ele.clave ) ENTONCES
               pav <- pav^.izq 
          EN OTRO CASO
               pav <- pav^.dch
          FINSI
      FINMIENTRAS
      ele = pret^.elem
      SI (clavenueva < ele.clave ) ENTONCES
          pret^.izq <- nuevonodo 
      EN OTRO CASO
          pret^.dch <- nuevonodo 
      FINSI
   FINSI
FIN

Eliminación

La operación de borrado no es tan sencilla. Existen varios casos que tenemos que considerar:
Borrar un nodo sin hijos o nodo hoja: simplemente se borra y se establece a NULL el apuntador de su padre.
Aquí se borra el nodo "74".

Borrar un nodo con un sub-árbol hijo: se borra el nodo y se asigna su sub-árbol hijo como sub-árbol de su padre.
Aquí se elimina el nodo "70".

Borrar un nodo con dos sub-árboles hijo: la solución está en reemplazar el valor del nodo por el de su padre o por el de su hijo en inorden y posteriormente borrar este nodo. Su padre en inorden será el nodo más a la derecha de su sub-árbol izquierdo (mayor nodo del sub-arbol izquierdo), y su hijo el nodo más a la izquierda de su sub-árbol derecho (menor nodo del subarbol derecho).

Aquí se elimina el nodo "59".

Ejemplo de codificación de eliminación

void borrar(tArbol **a, int elem)
{
  void reemplazar(tArbol **a, tArbol **aux);
  tArbol *aux;
 
  if (*a == NULL)
    return;
 
  if ((*a)->clave < elem)
    borrar(&(*a)->hDerecho, elem);
  else if ((*a)->clave > elem)
    borrar(&(*a)->hIzquierdo, elem);
  else if ((*a)->clave == elem)
  {
    aux = *a;
    if ((*a)->hIzquierdo == NULL)
      *a = (*a)->hDerecho;
    else if ((*a)->hDerecho == NULL)
      *a = (*a)->hIzquierdo;
    else
      reemplazar(&(*a)->hIzquierdo, &aux);
 
    free(aux);
  }
}
 
void reemplazar(tArbol **a, tArbol **aux)
{
  if ((*a)->hDerecho == NULL)
  {
    (*aux)->clave = (*a)->clave;
    *aux = *a;
    *a = (*a)->hIzquierdo;
  }
  else
    reemplazar(&(*a)->hDerecho, aux);
}

Referencias:

sábado, 9 de julio de 2011

Tablas de dispersión

Una tabla hash o de dispersión es una estructura de datos que puede ligar claves o llaves con valores.


Imagen donde se encuentra una tabla de hash.

En comparación con otras estructuras de arrays asociadas, las tablas hash son más útiles cuando almacenamos grandes cantidades de información. Las tablas hash almacenan la información en posiciones pseudo-aleatorias, así que el acceso ordenado a su contenido es bastante lento.

El objetivo principal de estas tablas es el de realizar inserciones, eliminaciones y búsquedas en un cierto tiempo promedio constante.


La estructura de hash se tiene que construir con 3 elementos básicos:



  1. Tiene que tener un vector que sea capaz de almacenar N elementos.
  2. Necesita una función de dispersión que nos deje a partir de la clave obtener el indice o posición en donde se encuentra el dato asociado a la clave (en caso de haber más de un elemento en la misma clave se forma o extiende una lista enlazada en ellos).
  3. Por ultimo una función de resolución de colisiones.

Análisis asintótico de la tabla hash


Las tablas hash suelen implementarse sobre vectores de una dimensión, también se pueden implementar multi-dimensionales basadas en varias claves. Como en el caso de los arrays, las tablas hash proporcionan tiempo constante de búsqueda promedio O(1), no importa el número de elementos en la tabla. Aunque, en casos particulares malos el tiempo de búsqueda puede llegar a O(n), es decir, en función del número de elementos. 

En cuanto a otras estructuras como árboles binarios de búsqueda equilibrados son más rápidos en promedio (tiempo de búsqueda O(log n)) pero la información está ordenada en todo momento.

¿Como funciona?

Las operaciones básicas que se implementan en las tablas hash son las siguientes:

inserción (llave, valor) 
búsqueda (llave) y esta devuelve valor

La mayor parte de las implementaciones incluyen borrar (llave), ya que se pueden ofrecer funciones con iteración en la tabla, para incrementarla o vaciarla, incluso algunas tablas permiten almacenar múltiples valores bajo la misma clave. 

Funciones hash

Una buena función hash es fundamental para el buen provecho de una tabla hash. Las colisiones son generalmente resueltas por algún tipo de búsqueda lineal, entonces si la función tiende a crear valores similares, las búsquedas resultantes se volverán lentas.

Las funciones hash que se encuentran más utilizadas son:

  • Hash de división
La función de este método es la de dividir el valor de la llave entre un numero apropiado, y después utilizar el residuo de la división como dirección relativa para el registro (dirección = llave módulo divisor). La función se representa así: H(K) = (K mod N) + 1
  • Hash de multiplicación
Si por alguna razón, se necesita una tabla hash con tantos elementos o punteros como una potencia de 2 o de 10, será mejor usar una función hash de multiplicación, independiente del tamaño de la tabla. Se escoge un tamaño de tabla m >= |D| (m mayor o igual al tamaño del diccionario) y un cierto número irracional φ (normalmente se usa 1+5^(1/2)/2 o 1-5^(1/2)/2). De este modo se define h(k)= Suelo(m*Parte fraccionaria(k*φ)).

Proceso de Inserción
  1. Para poder almacenar un elemento en la tabla hash se tiene que convertir su clave a un número. Esto lo podemos conseguir aplicando la función resumen (hash) a la clave del elemento.
  2. El resultado de la función resumen tiene que mapearse al espacio de direcciones del arreglo que se usa como soporte, lo cual conseguimos con la función módulo. Después este paso se obtiene un índice válido para la tabla.
  3. El elemento se almacena en la posición de la tabla obtenido en el paso anterior.
  • Si en la posición de la tabla ya había otro elemento, se ha producido una colisión. Este problema se puede solucionar uniendo una lista a cada posición de la tabla, aplicando otra función o buscando el siguiente elemento libre. Estas posibilidades se deben de considerar al momento de recuperar los datos.
Proceso de Búsqueda
  1. Para poder recuperar los datos, necesitamos únicamente conocer la clave del elemento, a la cual se le aplica la función resumen.
  2. El valor que obtenemos se mapea al espacio de direcciones de la tabla.
  3. Si el elemento existente en la posición indicada en el paso anterior tiene la misma clave que la usa en la búsqueda, entonces es el querido. Si la clave es diferente, se ha de buscar el elemento según la técnica usada para solucionar el problema de las colisiones al guardar el elemento.
Resolución de colisiones

Existen varias técnicas de resolución de colisiones, pero las más populares y/o usadas son encadenamiento y direccionamiento abierto.

Encadenamiento separado o Hashing abierto

Es la técnica más simple de encadenamiento, cada casilla en el array cita una lista de los registros insertados que colisionan en la misma casilla. La inserción consiste en encontrar la casilla correcta y agregar al final de la lista correspondiente. El borrado consiste en buscar y quitar de la lista.

Ventajas de la técnica sobre el direccionamiento abierto:

  • El borrado es simple 
  • El crecimiento de la tabla puede posponerse durante mucho más tiempo ya que el rendimiento baja mucho más lentamente incluso cuando todas las casillas ya están ocupadas.
  • Muchas tablas hash encadenadas no requieren crecimiento nunca, dado que la degradación de rendimiento es lineal en la medida que se va llenando la tabla.
Algunas estructuras de datos se pueden utilizar para el encadenamiento en lugar de las listas ligadas. Cuando usamos árboles binarios de búsqueda equilibrados, por ejemplo, el tiempo teórico del peor de los casos disminuye de O(n) a O(log n). Aunque, dado que supongamos que cada lista debe ser pequeña, esta estrategia es normalmente ineficiente a menos que la tabla hash sea diseñada para correr a máxima capacidad o existan índices de colisión particularmente grandes.

Imagen que demuestra el encadenamiento.

Direccionamiento abierto o Hashing cerrado

Estas tablas hash de direccionamiento abierto pueden guardar los registros directamente en el array. Las colisiones se resuelven mediante una investigación del array, en el que se buscan distintas plazas del array (secuencia de investigación) hasta que el registro es encontrado o se llega a una casilla vacía, indicando que no existe esa llave en la tabla.

Entre las secuencias de investigación más útiles se encuentran:
  • sondeo lineal: es cuando el intervalo de cada intento es constante (con frecuencia 1).
  • sondeo cuadrático: es cuando el intervalo de los intentos incrementa linealmente (los indices son descritos en funciones cuadráticas).
  • doble hasheo: cuando el intervalo es constante para cada registro que es calculado por otra función de hash.
                                                      Imagen que demuestra el direccionamiento abierto.

Ejemplo de seudocódigo de una tabla hash

registro par { llave, valor }
var vector de pares casilla [0,... Ncasillas - 1]
función buscar_casilla (llave) {
   i = hash (llave) módulo de Ncasillas
   mientras {
         if casilla [i] esta libre o casilla[i].llave = llave
               return i
         i = (i +1) módulo de Ncasillas
                 }
         }
función búsqueda (llave)
   i = buscar_casilla (llave)
   if casilla [i] está ocupada /* Llave en la tabla */
         return casilla[i].valor
   else  /* Llave está en la lista */
         return no encontrada
función asignar (llave, valor) {
   i = buscar_llave (casilla)
   if casilla [i] está ocupada
      casilla[i].valor = valor
   else {
          if tabla casi llena {
                hacer tabla más grande (nota 1)
                    i = buscar_casilla (llave)
            }
             casilla[i].llave = llave
             casilla[i].valor = valor
}
}

Un ejemplo en donde aplicamos esto en la vida cotidiana es en los directorios telefónicos, donde tenemos que hace runa búsqueda y eliminamos lo que no es o sea similar, y cuando encontramos lo que queríamos habremos terminado nuestra búsqueda (un ejemplo simple).

Referencias:

jueves, 7 de julio de 2011

Kernighan & Ritchie

Bueno como nos comento en clase la dra. Schaeffer del libro de estos autores que nos serviría de mucha ayuda en el tema de apuntadores.


Algo de historia



En 1978, Ritchie y Brian Kernighan publicaron la primera edición de El lenguaje de programación C, también conocido como La biblia de C. Este libro fue durante años la especificación informal del lenguaje. El lenguaje descrito en este libro recibe habitualmente el nombre de "el C de Kernighan y Ritchie" o simplemente "K&R C" (La segunda edición del libro cubre el estándar ANSI C, descrito más abajo.)
Kernighan y Ritchie introdujeron las siguientes características al lenguaje:
  • El tipo de datos struct
  • El tipo de datos long int
  • El tipo de datos unsigned int
  • Los operadores =+ y =- fueron sustituidos por += y -= para eliminar la ambigüedad sintáctica de expresiones como i=-10, que se podría interpretar bien como i=-10 o bien como i=-10
El C de Kernighan y Ritchie es el subconjunto más básico del lenguaje que un compilador debe de soportar. Durante muchos años, incluso tras la introducción del ANSI C, fue considerado "el mínimo común denominador" en el que los programadores debían programar cuando deseaban que sus programas fueran transportables, pues no todos los compiladores soportaban completamente ANSI, y el código razonablemente bien escrito en K&R C es también código ANSI C válido.
En estas primeras versiones de C, las únicas funciones que necesitaban ser declaradas si se usaban antes de la definición de la función eran las que retornaban valores no enteros. Es decir, se presuponía que una función que se usaba sin declaración previa (prototipo) devolvería un entero.

Brian Kernighan y Dennis Ritchie (respectivamente).

"La Biblia del C".


Ejemplo de listas enlazadas

Lista enlazada simple

Las listas enlazadas las podemos utilizar cuando necesitemos hacer varias operaciones de ingresar y eliminar elementos.


Tenemos que tomar en cuenta con algunos de los requisitos como:

  • El tipo o tipos de datos que vamos a utilizar.
  • Las estructuras o bases.
  • El u
  • so de la palabra typedef (palabra reservada de C y C++, que se utiliza para asignar un alias o sobrenombre a un tipo de dato, sin afectar el tipo). 
  • Los punteros.
  • Las funciones usuario (que vendrían siendo las definidas por el mismo).
Como crear el modelo de un elemento de la lista


Tenemos que utilizar la palabra struct que nos servirá para agrupar variables de distinto tipo si asi de desea, bajo un mismo nombre.


Nuestro elemento de la lista tiene que tener un espacio de dato y un puntero siguiente, este puntero siguiente tiene que ser igual al tipo del elemento, si no, no se podrá seguir a ese elemento.


Este puntero siguiente permitirá el acceso al próximo elemento.



typedef struct  ElementoLista    {
  char                *dato;
  struct  ElementoLista *siguiente;
}  Elemento;

Para poder tener el control de nuestra lista de preferencia tenemos que guardar algunos elementos, como el primer elemento, ultimo y número de elementos.

Para esto podemos utilizar otra estructura, aunque no es obligatorio, podemos utilizar variables.

typedef struct ListaIdentificar {
  Elemento *inicio;
  Elemento *fin;
  int tamaño;
} Lista;

El puntero inicio tiene la dirección del primer elemento de la lista.  El puntero fin tiene la dirección del último elemento de la lista.  La variable tamaño tiene el número de elementos. 

No importa la posición en la lista, pues los punteros inicio y fin siempre apuntaran al primer y ultimo elemento. Mientras que el campo tamaño tendrá el numero de elementos que contendrá la lista, sin importar la operación que se realice.

Modelo de la función: 
void inicializacion (Lista *lista);

Esta operación se debe de hacer antes de alguna otra operación sobre la lista. 
Esta inicializa el puntero inicio y el puntero fin con el puntero NULL, y el tamaño con el valor 0. 

Función

void inicializacion (Lista *lista){
  lista->inicio = NULL;
  lista->fin = NULL;
  tamaño = 0;
}

Como insertar un elemento en la lista
Tenemos que seguir una serie de pasos para poder lograr esta tarea:
  • Declarar el elemento a insertar.
  • Asignar la memoria para el nuevo elemento.
  • Rellenar el contenido del campo de datos.
  • Actualizar los punteros hacia el primer y ultimo elemento si se necesita.
    • Un caso particular: en una lista con un solo elemento, el primero es al mismo tiempo el último.
    • Tenemos que actualizar el tamaño de la lista.
Existen varios casos para cuando queremos añadir un elemento a la lista:
  • 1. Inserción en una lista vacía
  • 2. Inserción al inicio de la lista
  • 3. Inserción al final de la lista
  • 4. Inserción en otra parte de la lista.
Inserción en una lista vacía
Ejemplo de función:
int ins_en_lista_vacia (Lista *lista, char *dato);
La función devuelve 1 en caso de error, si no devuelve 0. 

Etapas a seguir:
  • Asignar la memoria para el nuevo elemento.
  • Rellenar el campo de datos del nuevo elemento.
  • El puntero siguiente del nuevo elemento apuntará hacia NULL (debido a que la inserción esta hecha en una lista vacía se utiliza la dirección del puntero inicio con valor NULL).
  • Los punteros inicio y fin apuntaran hacia el nuevo elemento.
  • El tamaño se actualiza.

Lista vacía.
Dato ingresado en una lista vacía.
Función:
/* inserción en una lista vacía */
int ins_en_lista_vacia (Lista * lista, char *dato){
  Element *nuevo_elemento;
  if ((nuevo_elemento = (Element *) malloc (sizeof (Element))) == NULL)
    return -1;
  if ((nuevo _elemento->dato = (char *) malloc (50 * sizeof (char)))
      == NULL)
    return -1;
  strcpy (nuevo_elemento->dato, dato);

  nuevo_elemento->siguiente = NULL;
  lista->inicio = nuevo_elemento;
  lista->fin = nuevo_elemento;
  lista->tamaño++;
  return 0;
}

Inserción al inicio de la lista
Ejemplo de función:
int ins_inicio_lista (Lista *lista,char *dato);
La función devuelve -1 en caso de error, si no devuelve 0.  Etapas a seguir:
  • Asignar la memoria al nuevo elemento.
  • Rellenar el campo de datos del nuevo elemento.
  • El puntero siguiente del nuevo elemento apunta hacia el primer elemento.
  • El puntero inicio apunta hacia el nuevo elemento.
  • El puntero fin no cambia.
  • El tamaño se incrementa.
Lista que no esta vacía y dato que deseamos insertar.

Empezando con la inserción.

Resultado de la inserción.
Función:
/* inserción al inicio de la lista */
int ins_inicio_lista (Lista * lista, char *dato){
  Element *nuevo_elemento;
  if ((nuevo_elemento = (Element *) malloc (sizeof (Element))) == NULL)
    return -1;
  if ((nuevo_elemento->dato = (char *) malloc (50 * sizeof (char)))
      == NULL)
    return -1;
  strcpy (nuevo_elemento->dato, dato);

  nuevo_elemento->siguiente = lista->inicio
  lista->inicio = nuevo_elemento;
  lista->tamaño++;
  return 0;
}

Inserción al final de la lista
Ejemplo de función:
int ins_fin_lista (Lista *lista, Element *actual, char *dato);
La función devuelve -1 en caso de error, si no devuelve 0.  Etapas a seguir:
  • Asignar la memoria al nuevo elemento.
  • Rellenar el campo de datos del nuevo elemento.
  • El puntero siguiente del ultimo elemento apunta hacia el nuevo elemento.
  • El puntero fin apunta hacia el nuevo elemento.
  • El puntero inicio no cambia.
  • El tamaño se incrementa.
Lista que no esta vacía y dato que se desea insertar.

Empezando con la inserción.

Resultado de la inserción.

Función:
/*inserción al final de la lista */
int ins_fin_lista (Lista * lista, Element * actual, char *dato){
  Element *nuevo_elemento;
  if ((nuevo_elemento = (Element *) malloc (sizeof (Element))) == NULL)
    return -1;
  if ((nuevo_elemento->dato = (char *) malloc (50 * sizeof (char)))
      == NULL)
    return -1;
  strcpy (nuevo_elemento->dato, dato);

  actual->siguiente = nuevo_elemento;
  nuevo_elemento->siguiente = NULL;

  lista->fin = nuevo_elemento;

  lista->tamaño++;
  return 0;
}

Inserción en otra parte de la lista
Ejemplo de función:
int ins_lista (Lista *lista, char *dato,int pos);
La función devuelve -1 en caso de error, si no devuelve 0. 
Etapas a seguir:
  • Asignar la memoria al nuevo elemento.
  • Rellenar el campo de datos del nuevo elemento.
  • Elegir una posición en la lista (la inserción se hará después de haber elegido la posición).
  • El puntero siguiente del nuevo elemento apunta hacia la dirección a la que apunta el puntero siguiente del elemento actual.
  • El puntero siguiente del elemento actual apunta al nuevo elemento.
  • Los punteros inicio y fin no cambian.
  • El tamaño se incrementa en una unidad.
Lista no vacía y dato que se desea insertar.

Proceso de insertar.

Resultado de inserción en cualquier lugar de la tabla.

Función:
/* inserción en la posición solicitada */
int ins_lista (Lista * lista, char *dato, int pos){
  if (lista->tamaño < 2)
    return -1;
  if (pos < 1 || pos >= lista->tamaño)
    return -1;

  Element *actual;
  Element *nuevo_elemento;
  int i;

  if ((nuevo_elemento = (Element *) malloc (sizeof (Element))) == NULL)
    return -1;
  if ((nuevo_elemento->dato = (char *) malloc (50 * sizeof (char)))
      == NULL)
    return -1;

  actual = lista->inicio;
  for (i = 1; i < pos; ++i)
    actual = actual->siguiente;
  if (actual->siguiente == NULL)
    return -1;
  strcpy (nuevo_elemento->dato, dato);

  nuevo_elemento->siguiente = actual->siguiente;
  actual->siguiente = nuevo_elemento;
  lista->tamaño++;
  return 0;

Como eliminar un elemento en una lista
Tenemos que seguir estos pasos:
  • Usar un puntero temporal para guardar la dirección de los elementos que deseamos eliminar.
  • El elemento que deseamos eliminar se encuentra después del elemento actual.
  • 
    
    Necesitamos apuntar el puntero siguiente del elemento actual hacia la dirección del puntero siguiente del elemento que se desea eliminar, tenemos que:
    • Liberar la memoria ocupada por el elemento eliminado
    • Actualizar el tamaño de la lista
    Existen varios casos para la eliminación de datos:
    • Eliminación al inicio de la lista
    • Eliminación en otra parte de la lista
    
    
    Eliminar un elemento al inicio de la lista
    Ejemplo de la función:
    int sup_inicio (Lista *lista);
    La función devuelve -1 en caso de error, si no devuelve 0.  Etapas a seguir:
    • El puntero sup_elem contendrá la dirección del primer elemento.
    • El puntero inicio apuntara hacia el segundo elemento.
    • El tamaño de la lista tendrá que disminuir un elemento.
                                                   Imagen que demuestra la eliminación al inicio de la lista.
    
    
    Función:
    /* eliminación al inicio de la lista */
    int sup_inicio (Lista * lista){
      if (lista->tamaño == 0)
        return -1;
      Element *sup_elemento;
      sup_element = lista->inicio;
      lista->inicio = lista->inicio->siguiente;
      if (lista->tamaño == 1)
        lista->fin = NULL;
      free (sup_elemento->dato);
      free (sup_elemento);
      lista->tamaño--;
      return 0;
    }
    
    
    Eliminar un elemento en otra parte de la lista
    Ejemplo de función:
    int sup_en_lista (Lista *lista, int pos);
    La función devuelve -1 en caso de error, si no devuelve 0.  Etapas a seguir:
    • El puntero sup_elem tiene que contener la dirección hacia la que apunta el puntero siguiente del elemento actual.
    • El puntero siguiente del elemento actual apuntara hacia el elemento al que apunta el puntero siguiente del elemento que sigue al elemento actual en la lista.
    Si el elemento actual es el penúltimo elemento, el puntero fin se debe de actualizar.
    • El tamaño de la lista tendrá que disminuir en un elemento.
    Imagen que demuestra la eliminación de una parte que se solicito.
    Imagen que demuestra la eliminación después del penúltimo elemento de la lista.
    Función:
    /* eliminar un elemento después de la posición solicitada */
    int sup_en_lista (Lista * lista, int pos){
      if (lista->tamaño <= 1 || pos < 1 || pos >= lista->tamaño)
        return -1;
      int i;
      Element *actual;
      Element *sup_elemento;
      actual = lista->inicio;
    
      for (i = 1; i < pos; ++i)
        actual = actual->siguiente;
    
      sup_elemento = actual->siguiente;
      actual->siguiente = actual->siguiente->siguiente;
      if(actual->siguiente == NULL)
              lista->fin = actual;
      free (sup_elemento->dato);
      free (sup_elemento);
      lista->tamaño--;
      return 0;
    }
    
    
    Referencias:
    http://es.wikipedia.org/wiki/Lista_(inform%C3%A1tica)
    http://es.kioskea.net/faq/2842-la-lista-enlazada-simple
    http://www.programacionfacil.com/estructura_de_datos:listas_enlazadas
    
    Las ultimas 3 imágenes tuve la necesidad de poner esas ya que no encontraba más, para la siguiente entrada que no encuentre imágenes las haré yo, como hice en mi entrada pasada referente a las listas.