Saltar a contenido

BK-Tree (Burkhard–Keller Tree)

Cuando se realizan búsquedas de strings similares dentro de un texto más grande, suele ocurrir que se puede cometer un typo. A pesar de que este sea mínimo, con los algoritmos tradicionales de búsqueda exacta no funcionan, ya que como lo indica su nombre, solo encuentran coincidencias exactas, y no si tienen mínimos errores de escritura. El BK-Tree (Burkhard–Keller Tree) es una solución eficiente para realizar búsquedas aproximadas en espacios donde los elementos pueden compararse mediante una distancia métrica, como la distancia de Levenshtein (edición).

Descripción

Un BK-Tree es una estructura de datos tipo árbol diseñada para realizar búsquedas aproximadas en un conjunto de elementos, donde se puede definir una distancia métrica entre cada par. Una métrica es una función que mide que tan distintos son dos elementos.
Para que sea una métrica válida, debe cumplir tres propiedades fundamentales:

  1. No negatividad:
    dist(a, b) ≥ 0, y dist(a, b) = 0 si y solo si a = b.
  2. Simetría:
    dist(a, b) = dist(b, a).
  3. Desigualdad triangular:
    dist(a, c) ≤ dist(a, b) + dist(b, c).

Estas propiedades garantizan que las distancias tengan un comportamiento coherente, lo que permite realizar búsquedas eficientes.

Estructura del árbol

  • Cada nodo almacena un elemento del conjunto (por ejemplo, una palabra).
  • Cada arista que conecta un nodo con su hijo está etiquetada con la distancia entre el nodo y el elemento hijo, calculada mediante la métrica.
  • Cada nodo puede tener varios hijos, uno por cada valor distinto de distancia.

Implementación

A continuación una implementación en C++ de la estructura descrita.

// C++ program to demonstrate working of BK-Tree
#include "bits/stdc++.h"
using namespace std;

// maximum number of words in dict[]
#define MAXN 100

// defines the tolerance value
#define TOL  2

// defines maximum length of a word
#define LEN 10

struct Node
{
    // stores the word of the current Node
    string word;

    // links to other Node in the tree
    int next[2*LEN];

    // constructors
    Node(string x):word(x)
    {
        // initializing next[i] = 0
        for(int i=0; i<2*LEN; i++)
            next[i] = 0;
    }
    Node() {}
};

// stores the root Node
Node RT;

// stores every Node of the tree
Node tree[MAXN];

// index for current Node of tree
int ptr;

int min(int a, int b, int c)
{
    return min(a, min(b, c));
}

// Edit Distance
// Dynamic-Approach O(m*n)
int editDistance(string& a,string& b)
{
    int m = a.length(), n = b.length();
    int dp[m+1][n+1];

    // filling base cases
    for (int i=0; i<=m; i++)
        dp[i][0] = i;
    for (int j=0; j<=n; j++)
        dp[0][j] = j;

    // populating matrix using dp-approach
    for (int i=1; i<=m; i++)
    {
        for (int j=1; j<=n; j++)
        {
            if (a[i-1] != b[j-1])
            {
                dp[i][j] = min( 1 + dp[i-1][j],  // deletion
                                1 + dp[i][j-1],  // insertion
                                1 + dp[i-1][j-1] // replacement
                              );
            }
            else
                dp[i][j] = dp[i-1][j-1];
        }
    }
    return dp[m][n];
}

// adds curr Node to the tree
void add(Node& root,Node& curr)
{
    if (root.word == "" )
    {
        // if it is the first Node
        // then make it the root Node
        root = curr;
        return;
    }

    // get its editDistance from the Root Node
    int dist = editDistance(curr.word,root.word);

    if (tree[root.next[dist]].word == "")
    {
        /* if no Node exists at this dist from root
         * make it child of root Node*/

        // incrementing the pointer for curr Node
        ptr++;

        // adding curr Node to the tree
        tree[ptr] = curr;

        // curr as child of root Node
        root.next[dist] = ptr;
    }
    else
    {
        // recursively find the parent for curr Node
        add(tree[root.next[dist]],curr);
    }
}

vector <string> getSimilarWords(Node& root,string& s)
{
    vector < string > ret;
    if (root.word == "")
       return ret;

    // calculating editdistance of s from root
    int dist = editDistance(root.word,s);

    // if dist is less than tolerance value
    // add it to similar words
    if (dist <= TOL) ret.push_back(root.word);

    // iterate over the string having tolerance
    // in range (dist-TOL , dist+TOL)
    int start = dist - TOL;
    if (start < 0)
       start = 1;

    while (start <= dist + TOL)
    {
        vector <string> tmp =
             getSimilarWords(tree[root.next[start]],s);
        for (auto i : tmp)
            ret.push_back(i);
        start++;
    }
    return ret;
}

// driver program to run above functions
int main(int argc, char const *argv[])
{
    // dictionary words
    string dictionary[] = {"hell","help","shell","smell",
                           "fell","felt","oops","pop","oouch","halt"
                          };
    ptr = 0;
    int sz = sizeof(dictionary)/sizeof(string);

    // adding dict[] words on to tree
    for(int i=0; i<sz; i++)
    {
        Node tmp = Node(dictionary[i]);
        add(RT,tmp);
    }

    string w1 = "ops";
    string w2 = "helt";
    vector < string > match = getSimilarWords(RT,w1);
    cout << "similar words in dictionary for : " << w1 << ":\n";
    for (auto x : match)
        cout << x << endl;

    match = getSimilarWords(RT,w2);
    cout << "Correct words in dictionary for " << w2 << ":\n";
    for (auto x : match)
        cout << x << endl;

    return 0;
}

Ejemplo

A continuación se presenta un ejemplo adaptado del sitio GeeksforGeeks [1], donde se construye un BK-Tree utilizando la métrica de distancia de edición (Levenshtein) sobre un conjunto de diccionario:

{"hell", "help", "shell", "smell", "fell", "felt", "oops", "pop", "oouch", "halt"}.

La raíz del árbol se inicializa con la palabra "help". Cada palabra restante se inserta calculando su distancia con respecto a "help" y ubicándola en el hijo correspondiente, de acuerdo con ese valor.
Por ejemplo:

  • dist("help", "hell") = 1, entonces "hell" se ubica como hijo a distancia 1.
  • dist("help", "shell") = 2, entonces "shell"` se ubica como hijo a distancia 2.
  • dist("help", "halt") = 3, entonces "halt" se ubica como hijo a distancia 3.

Construcción inicial del árbol BK con la raíz “help” y sus hijos según distancia

Durante una búsqueda aproximada se especifica una palabra de consulta y una tolerancia de error TOL.
Por ejemplo, si buscamos "oop" con TOL = 2, primero se calcula la distancia con la raíz:

\[ dist("help", "oop") = 3 \]

Aplicando la desigualdad triangular, se determina que solo es necesario explorar los subárboles cuyas aristas estén en el rango:

\[ [dist("help","oop") - TOL, \; dist("help","oop") + TOL] = [1, 5] \]

Ejemplo de búsqueda en el árbol

De esta forma, el algoritmo evita visitar ramas cuya distancia de etiqueta esté fuera de ese rango, reduciendo significativamente el número de comparaciones.
El resultado final de la búsqueda entrega las palabras similares encontradas en el diccionario:

{"oops", "pop"}

Inserte Figura 3: resultado final de la búsqueda.

Este ejemplo ilustra cómo el BK-Tree permite realizar búsquedas aproximadas eficientes, aprovechando las propiedades métricas para podar ramas y acotar el espacio de búsqueda, sin necesidad de recorrer la totalidad del conjunto.

Complejidad teórica

  • Construcción: O(nlog(n)), en caso promedio.
  • Búsqueda: para un k pequeño, la complejidad se acerca a O(log(n)). Para un k grande, la complejidad se acerca a O(n).

Aplicaciones

La aplicación más habitual del BK-Tree se encuentra en los sistemas de corrección ortográfica y autocompletado, en los cuales permite sugerir términos cercanos a una palabra ingresada con errores tipográficos. Por ejemplo, puede utilizarse en los autocorrectores de teclado, donde se buscan las palabras más similares a la escrita por el usuario. También se usa mucho en los motores de búsqueda, que ante un error de tipeo presentan el mensaje “también se muestran resultados para...”, ofreciendo coincidencias cercanas a la consulta original. Se adjunta ejemplo en la figura 2.

Búsqueda google

En términos generales, esta estructura resulta especialmente adecuada cuando se cumplen las siguientes condiciones:

  • La métrica utilizada satisface la desigualdad triangular.
  • El conjunto de datos es relativamente estático (es decir, las inserciones o eliminaciones son poco frecuentes).
  • Las operaciones de búsqueda son considerablemente más comunes que las de actualización.

Gracias a estas características, el BK-Tree constituye una herramienta eficiente y versátil para la búsqueda rápida y tolerante a errores en una amplia variedad de dominios, tales como el procesamiento de texto, la recuperación de información y la comparación de secuencias.

Referencias

[1] GeeksforGeeks. "BK-tree Introduction and Implementation".
Disponible en: https://www.geeksforgeeks.org/dsa/bk-tree-introduction-implementation/