C/Traballar con árbores

< C
C
← Volver a Traballar con listas encadeadas Traballar con árbores Seguir con Comentarios


Introdución á programación recursiva editar

Antes de comezar a aprender o que son as árbores e como traballar con elas, cómpre coñecer un pouco por riba unha forma de programar moi empregada: a “programación recursiva”.

Ata o momento as funcións que se programaron estaban escritas para ser chamadas por outras funcións ─ou polo sistema operativo no caso da función principal─ e asemade chamar a outras funcións. Existe un caso especial en que unha función se chama a si mesma ─ou máis exactamente, chama a unha función igual ca ela─. Neste caso estaremos falando de programación recursiva.

Un exemplo simple dunha función en que se pode empregar a programación recursiva sería o dunha función para calcular o factorial[1] dun número. Seguindo o procedemento que se leva empregado ata o de agora, probablemente escribiríamos unha función como a seguinte para o cálculo do factorial:

signed long CalcularOFactorial(signed long numero)
{
  signed long factorial = 1;
  signed long indice;

  for(indice=1; indice<numero; indice++)
    factorial = factorial * indice;
  
  return factorial;
}

Porén, a programación recursiva pode axudar aquí permitindo reducir a función ao seguinte código:

signed long CalcularOFactorial(signed long numero)
{
  if(numero==1)
    return 1;
  else
    return numero * CalcularOFactorial(numero-1);
}

Este tipo de programación é o que fai posible o uso de estruturas de datos recursivas, coma as árbores ou os gráficos (ou grafos).

Introdución ás árbores editar

Unha árbore é unha estrutura ─unha colección organizada de datos─ dinámica non lineal recursiva almacenada en memoria interna, formada por un conxunto de nodos e un conxunto de pólas. É dinámica porque se almacena en memoria dinámica, polo que o seu tamaño ─a cantidade de nodos que contén─ pode variar durante a execución dun programa. Non é lineal porque existe máis dun camiño posible entre un elemento da estrutura e outro. E é recursiva porque cada nodo da árbore é a raíz dunha nova árbore, «unha árbore é un conxunto de árbores».

Nunha árbore existe un nodo especial, a “raíz da árbore”, que é o único nodo de nivel 0, é dicir, é o que serve de referencia para toda a árbore, do que colgan o resto de nodos ou árbores subordinadas. Como toda estrutura de datos dinámica, as árbores constan dun punteiro que sinala á árbore, e cuxo valor será nulo ─NULL─ cando non haxa árbore. Dito punteiro sinalará á raíz da árbore. Asemade, cada nodo da árbore ─incluída a raíz─ sinalará a unha certa cantidade de nodos subordinados a el.

Os nodos finais, os que xa non teñen ningún nodo subordinado a eles, teñen a mesma estrutura que o resto dos nodos ─datos e punteiros aos nodos directamente subordinados─, coa diferencia de que os punteiros aos subordinados están baleiros ─non apuntan a ningures─.

Hai outros conceptos cos que cómpre familiarizarse antes de empezar a traballar con árbores:

  • O número de pólas dun nodo denomínase “grao do nodo”.
  • O “nivel dun nodo” nunha árbore equivale á distancia entre dito nodo e a raíz da árbore ─que ten nivel 0─.
  • O nivel do nodo de maior nivel dunha árbore é a “altura da árbore”.

Organización dunha árbore editar

Visto en que consiste unha árbore, cómpre agora entender de que maneira están ordenados os distintos nodos dunha árbore. Para iso utilizaremos como exemplo o caso dunha árbore binaria ─que máis adiante se estudarán en profundidade─, que simplemente é unha árbore cuxos nodos sinalan a dous nodos subordinados.

Chamando a estes dous nodos subordinados de cada nodo «esquerdo» e «dereito», e tomando como referencia un dato ou conxunto de datos concreto dos nodos ─por exemplo unha variable chamada “dato”─, cumprirase sempre que o dato do nodo subordinado esquerdo dun nodo será sempre inferior ao dato do nodo, e o dato do nodo subordinado dereito dun nodo será sempre superior ao dato do nodo.

Por exemplo, imaxínese unha árbore que contén os datos: 1, 3, 5, 7, 8, 9, 10, 12, 13, 14, 15, 20, 25 e 35. Seguindo o enunciado no parágrafo anterior, a árbore quedaría coa forma seguinte:

            10    
       /           \
      7            15  
   /    \       /      \
  3      9     13      25
 / \    /     /  \    /  \
1   5  8     12  14  20  35   

Eficiencia das árbores editar

Unha vez familiarizados coa forma en que se organizan os nodos dunha árbore, pódese comprender a eficiencia en certos casos ─especialmente á hora de buscar datos─ do uso de árbores ante, por exemplo, o uso de listas encadeadas. Visualícese a árbore binaria do exemplo anterior.

Durante unha busca de datos, a árbore binaria resultaría moito máis eficiente que a lista para esta busca. Cada comprobación na árbore permitirá descartar directamente a metade dos datos, de aí a súa eficiencia. Porén, na lista, se se buscase un dos últimos datos serían necesarias moitas comprobacións. Vexamos algúns números para o exemplo dado:

Dato a buscar Comprobacións (lista) Comprobacións (árbore)
1 1 4
9 6 3
10 7 1
13 9 3
35 14 4

Percorrido dunha árbore editar

Percorrer unha árbore consiste en visitar todos e cada un dos nodos que contén, de xeito que cada nodo se visite unha única vez. Este percorrido é posible mediante o uso de funcións recursivas.

As funcións para percorrer árbores variarán dependendo do grao da árbore, dos datos utilizados para organizar a árbore, así como da tarefa que se vaia realizar durante o percorrido ─gravar os datos en disco, borrar os nodos da árbore, etc.─.

Equilibrio editar

Unha árbore está equilibrada se para cada nodo o número de nodos que “colgan” de cada un dos seus nodos directamente subordinados non difiren os uns dos outros en máis dunha unidade.

O problema é que a medida que se utilizan, as árbores desequilíbranse ─algunhas das pólas quedan máis longas que outras─, polo que será necesario utilizar unha función para equilibralas.

Por exemplo, visualícese a árbore binaria que se utilizou previamente. Imaxínese como quedaría dita árbore en caso de inserir nela outro nodo co valor 16. A árbore quedaría entón así:

            10    
       /           \
      7            15  
   /    \       /      \
  3      9     13      25
 / \    /     /  \    /  \
1   5  8     12  14  20  35
                    /
                   16  

E en caso de meterlle a continuación outro nodo de valor 17, e a continuación outro de valor 18, e logo un de valor 19, a árbore quedaría completamente desequilibrada:

            10    
       /           \
      7            15  
   /    \       /      \
  3      9     13      25
 / \    /     /  \    /  \
1   5  8     12  14  20  35
                    /
                   16
                     \
                     17
                       \
                       18
                         \
                         19

É por esta razón que ás veces, tras o seu uso continuado, adoita facerse necesario equilibrar as árbores.

Árbores binarias editar

Ás arbores binarias conteñen en cada nodo, ademais dos datos do elemento, dous punteiros, un a cada un dos seus nodos directamente subordinados. Podemos ver isto como se os subordinados estivesen cada un a un lado ─esquerdo e dereito─, pois esta forma de visualizar as árbores binarias facilita a comprensión do funcionamento das funcións que traballan con elas.

Declarar unha árbore binaria editar

Para declarar unha árbore binaria é necesario, primeiro, definir a estrutura dos seus nodos, e segundo, declarar un punteiro a unha estrutura dese tipo. O valor inicial de dito punteiro debería ser nulo, de xeito que se poida saber durante a execución do programa se nun momento dado a árbore está baleira ou non.

O seguinte código define a estrutura dun nodo sinxelo que contén, ademais dos punteiros aos seus nodos subordinados, un único dato de tipo signed int:

typedef struct estruturasimpledunnodo
{
  signed int dato;
  struct estruturasimpledunnodo * nodoesquerdo;
  struct estruturasimpledunnodo * nododereito;
}
estruturanodo;

Partindo desta estrutura, a declaración dunha árbore deste tipo realizaríase así:

estruturanodo * arbore;

Os seguintes exemplos de funcións para o traballo con árbores binarias utilizarán como base a estrutura aquí declarada.

Crear un nodo binario editar

Crear un nodo consiste en facer espazo en memoria para un novo nodo que vai ser inserido nunha árbore.

estruturanodo * NovoNodo(void)
{
  // Declárase un punteiro ao novo nodo, con valor inicial nulo.
  estruturanodo * novo = NULL;

  // Resérvase espazo en memoria dinámica para dito nodo.
  novo = (estruturanodo *) malloc(sizeof(estruturanodo);

  // Devólvese o enderezo de memoria en que está situado o novo nodo.
  return novo; // A función devolverá valor nulo se non puido reservar o novo nodo.
}

Inserir un nodo nunha árbore binaria editar

Inserir un nodo consiste en facer espazo para un novo nodo na árbore, encher o novo nodo cuns certos datos, e situar o nodo no lugar correcto da árbore en función dun deses datos ─ou conxunto de datos─, de xeito que a árbore quede organizada.

Nótese que os novos nodos sempre se colocan ao final das árbores, é dicir, colgando dun nodo pero sen que deles colgue nodo ningún.

signed short int InserirUnNodo(estruturanodo ** punteirosuperior, signed int dato)
{
  // Recíbese o enderezo do nodo superior ou do punteiro á árbore se estaba baleira:
  estruturanodo * arbore = *punteirosuperior; 
  estruturanodo * novo = NULL; 		      // Punteiro ao novo nodo que se vai inserir.
  signed short int control = 0; 	      // Valor para controlar o correcto funcionamento da función.

  if(arbore == NULL) // Se se chegou ao lugar do que vai colgar o novo nodo...
  {
    novo = NovoNodo(); // ...solicítase que se reserve un espazo en memoria para o nodo.
    if(novo == NULL)
      return -1; // Se ocorre un erro a función devolve -1;
    else
    { 
      // De se reservar o espazo en memoria correctamente,
      // énchese o novo nodo coa información recibida pola función:
      novo->dato = dato;

      // e dáselles valor nulo aos punteiros aos seus nodos subordinados
      // dado que os novos nodos nunca teñen subordinados.
      novo->esquerda = NULL;
      novo->dereita = NULL;

      // O nodo superior ten que apuntar ao novo nodo.
      // En caso de que sexa o primeiro nodo da árbore e non haxa nodo superior,
      // quen apuntará ao novo nodo será o punteiro á árbore.
      *punteirosuperior = novo;
    }
  }
  else // Se aínda non se chegou ao lugar do que vai colgar o novo nodo, séguese buscando.
  {
    if(arbore->dato < dato)
      control = InserirUnNodo(&arbore->dereita,dato); // Ben pola dereita,
    else
      control = InserirUnNodo(&arbore->esquerda,dato); // ben pola esquerda.
  }

  // Se houbese algún erro durante o proceso de inserción do novo nodo,
  // a función devolverá -1. Se todo marchou correctamente, devolverá 0.
  return control;
}

Percorrer unha árbore binaria editar

Para percorrer todos os nodos das árbores existen tres formas posibles:

  • Preorde. Para cada nodo visítase primeiro a raíz, logo a árbore esquerda e logo a dereita. “RED”.
Para o exemplo inicial a orde quedaría: 10, 7, 3, 1, 5, 9, 8, 15, 13, 12, 14, 25, 20, 35.
  • Inorde. Para cada nodo visítase primeiro a árbore subordinada esquerda, lodo a raíz e logo a árbore subordinada dereita. “ERD”.
Nótese que o método inorde obtén os datos ordenados en función do valor que se utilizou para ordenar a árbore. Para que esta orde vaia á inversa, abondaría con facer un inorde inverso, é dicir, “DRE” en vez de “ERD”.
Para o exemplo inicial, a orde quedaría: 1, 3, 5, 7, 8, 9, 10, 12, 13, 14, 15, 20, 25, 35.
  • Postorde. Para cada nodo visítase primeiro a árbore subordinada dereita, logo a esquerda, e logo a raíz. “DER”.
O percorrido postorde é o método que se utilizará á hora de borrar a árbore ─liberar o espazo que ocupa en memoria─.
Para o exemplo inicial, a orde quedaría: 35, 20, 25, 12, 14, 13, 15, 1, 5, 3, 8, 9, 7, 10.

Asemade podería considerarse unha cuarta forma de percorrer arbores binarias: inorde inversa. É o sentido inverso da forma inorde (“DRE”), e permite percorrer os valores da árbore ordenados de forma inversa, é dicir, en lugar de facelo dende o menor ao maior, farase dende o maior ao menor.

Trátase de definicións recursivas, é dicir, a orde aplícase ao nodo raíz da árbore, pero tamén a todos os outros nodos (na orde estipulada pola propia definición).

Percorrido inorde dunha árbore binaria editar

A continuación preséntase un exemplo básico de percorrido da árbore polo método inorde. Neste caso simplemente se imprimen os seus valores.

// Esquerda, raíz, dereita. ERD.
void Inorde(estruturanodo * arbore)
{
  if(arbore != NULL)
  {
    Inorde(arbore->esquerda);
    printf("%d\t",arbore->dato);
    Inorde(arbore->dereita);
  }
}

Borrar unha árbore binaria editar

Unha forma de borrar unha árbore completa podería ser recorréndoa en postorde:

void BorrarAArbore(estruturanodo * arbore);
{
  if(arbore != NULL)
  {
    BorrarAArbore(arbore->esquerda);
    BorrarAArbore(arbore->dereita);
    free(arbore);
  }
}

Equilibrar unha árbore binaria editar

Para crear unha árbore binaria perfectamente equilibrada realízase o seguinte proceso recursivo:

Para un número “total” dado de nodos, sendo “nodosdaesquerda” o número de nodos á esquerda e “nodosdadereita” o número de nodos á dereita, calcúlase:
nodosdaesquerda = total / 2
nodosdadereita = total - nodosdaesquerda - 1
E a continuación, para cada nodo:
  • Xérase unha árbore subordinada esquerda cunha cantidade nodosdaesquerda de nodos.
  • Utilízase o propio nodo.
  • Xérase unha árbore subordinada dereita cunha cantidade nodosdadereita de nodos.
  • Devólvese o enderezo do nodo.

Crear unha árbore binaria equilibrada editar

Partindo dunha lista ordenada de datos pódese crear unha árbore equilibrada mediante unha función. esta función recibirá o número de elementos ou nodos a inserir na árbore. A función devolverá un punteiro á raíz da árbore construída. Vexamos un exemplo deste tipo de función:

estruturanodo * CrearUnhaArboreEquilibrada(signed int total)
{
  signed int nodosdaesquerda, nodosdadereita; // Variables previamente enunciadas.
  estruturanodo * novo = NULL; // Variable para almacenar os novos nodos.

  if(total == 0) // Se non hai ningún valor a introducir na árbore.
    return NULL;  // Devólvese o valor nulo.

  nodosdaesquerda = total / 2; // Calcúlanse os nodos da árbore subordinada esquerda,
  nodosdadereita = total - nodosdaesquerda - 1; // e os da árbore subordinada dereita.

  // Resérvase espazo en memoria dinámica para o novo nodo.
  // Esta función xa se enunciou previamente máis arriba.
  novo = NovoNodo();

  // Créase ─de maneira equilibrada─ a árbore subordinada esquerda:
  novo->nodoesquerdo = CrearUnhaArboreEquilibrada(nodosdaesquerda);

  // Énchese de información este nodo.
  // Neste exemplo non se meterá ningunha información, pero podería
  // lerse a información dun ficheiro de xeito que se fose avanzando
  // no ficheiro. O mesmo se aplica a unha pila ou unha cola.

  // Créase ─de maneira equilibrada─ a árbore subordinada dereita:
  novo->nododereito = CrearUnhaArboreEquilibrada(nodosdadereita);

  // Devólvese o punteiro ao novo nodo.
  return novo;
}

Notas editar

  1. O factorial dun número é a multiplicación de todos os números enteiros entre 1 e o propio número. Por exemplo, o factorial de 3 sería (1×2×3=) 6, o de 6 sería (1×2×3×4×5×6=) 720.

Véxase tamén editar

Exercicios editar


C
← Volver a Traballar con listas encadeadas Traballar con árbores Seguir con Comentarios