Como pesquisar rapidamente uma coleção de chave/valor baseada em string

Olá companheiros stackoverflowers!

Eu tenho uma lista de palavras de 200.000 entradas de string, o tamanho médio da string é de cerca de 30 caracteres. Esta lista de palavras é a chave e para cada chave eu tenho um objeto de domínio. Eu gostaria de encontrar os objetos de domínio nesta coleção, conhecendo apenas uma parte da chave. I.E. a string de pesquisa "kov" corresponderia, por exemplo, à chave "stackoverflow".

Atualmente estou usando uma Ternary Search Tree (TST), que normalmente encontrará os itens dentro de 100 milissegundos. No entanto, isso é muito lento para minhas necessidades. A implementação do TST poderia ser melhorada com algumas pequenas otimizações e eu poderia tentar equilibrar a árvore. Mas eu percebi que essas coisas não me dariam a melhoria de velocidade de 5x a 10x que estou mirando. Eu estou supondo que a razão para ser tão lento é que eu basicamente tenho que visitar a maioria dos nós na árvore.

Alguma idéia de como melhorar a velocidade do algoritmo? Existem outros algoritmos que eu deveria estar olhando?

Desde já, obrigado, Oskar

13
Aprendi uma coisa nova hoje: Trie.
adicionado o autor Will, fonte
Em que língua você está trabalhando? Esta informação é necessária, pois todas as linguagens não tratam buscas e coleções da mesma forma.
adicionado o autor WolfmanDragon, fonte
Eu acho que deveria ser "Trie" ou "Ternary Search Tree".
adicionado o autor Tomalak, fonte
Esse é o tipo de pergunta que eu adoro: nada supera um bom desafio de vez em quando ... :-)
adicionado o autor Konrad Rudolph, fonte
A. Você poderia explicar como você conseguiu usar o TST para o que parece ser uma busca por algo que não é prefixo nem sufixo? (No seu exemplo, "kov" não é prefixo nem sufixo para "stackoverflow"), ou seja, você pode descrever a maneira como você insere elementos no TST? B. Você pode - dizer, novamente para o seu exemplo específico de "kov" - descrever como sua implementação da função TST pesquisa sabe como/quando excluir certos nós da inspeção (novamente sob a suposição de A que você está procurando por um termo sem prefixo nem sufixo)?
adicionado o autor MrCC, fonte

7 Respostas

Suffix Array e q -gram index

Se suas strings tiverem um limite superior estrito no tamanho, você poderá considerar o uso de uma matriz de sufixos strong> : Simplesmente preencha todas as suas strings com o mesmo comprimento máximo usando um caractere especial (por exemplo, o caractere nulo). Em seguida, concatene todas as cadeias e construa um índice de matriz de sufixos sobre elas.

This gives you a lookup runtime of m * log n where m is the length of your query string and n is the overall length of your combined strings. If this still isn't good enough and your m has a fixed, small length, and your alphabet Σ is restricted in size (say, Σ < 128 different characters) you can additionally build a q-gram index. This will allow retrieval in constant time. However, the q-gram table requires Σm entries (= 8 MiB in the case of just 3 characters, and 1 GiB for 4 characters!).

Tornando o índice menor

Pode ser possível reduzir o tamanho da tabela q -gram (exponencialmente, na melhor das hipóteses) ajustando a função hash. Em vez de atribuir um número único a todos os possíveis q -gram, você pode empregar uma função hash com perdas. A tabela, em seguida, teria que armazenar listas de possíveis índices de matriz de sufixo em vez de apenas uma entrada de matriz de sufixo correspondente a uma correspondência exata. Isso implicaria que a pesquisa não é mais constante, porque todas as entradas na lista teriam que ser consideradas.

A propósito, não tenho certeza se você está familiarizado com como um q índice de diagrama funciona porque a Internet não é útil neste tópico. Eu já mencionei isso antes em outro tópico. Portanto, incluí uma descrição e um algoritmo para a construção da minha tese de bacharel .

Prova de conceito

I've written a very small C# Prova de conceito (since you stated otherwise that you worked with C#). It works, however it is very slow for two reasons. First, the suffix array creation simply sorts the suffixes. This alone has runtime n2 log n. There are far superior methods. Worse, however, is the fact that I use SubString to obtain the suffixes. Unfortunately, .NET creates copies of the whole suffix for this. To use this code in practice, make sure that you use in-place methods which do not copy any data around unnecessarily. The same is true for retrieving the q-grams from the string.

Seria possivelmente melhor não construir a string m_Data </​​code> usada no meu exemplo. Em vez disso, você poderia salvar uma referência à matriz original e simular todos os meus acessos SubString trabalhando nessa matriz.

Ainda assim, é fácil ver que esta implementação esperou essencialmente uma recuperação constante de tempo (se o dicionário for bem comportado)! Esta é uma conquista que não pode ser derrotada por uma árvore de busca/trie!

class QGramIndex {
    private readonly int m_Maxlen;
    private readonly string m_Data;
    private readonly int m_Q;
    private int[] m_SA;
    private Dictionary m_Dir = new Dictionary();

    private struct StrCmp : IComparer {
        public readonly String Data;
        public StrCmp(string data) { Data = data; }
        public int Compare(int x, int y) {
            return string.CompareOrdinal(Data.Substring(x), Data.Substring(y));
        }
    }

    private readonly StrCmp cmp;

    public QGramIndex(IList strings, int maxlen, int q) {
        m_Maxlen = maxlen;
        m_Q = q;

        var sb = new StringBuilder(strings.Count * maxlen);
        foreach (string str in strings)
            sb.AppendFormat(str.PadRight(maxlen, '\u0000'));
        m_Data = sb.ToString();
        cmp = new StrCmp(m_Data);
        MakeSuffixArray();
        MakeIndex();
    }

    public int this[string s] { get { return FindInIndex(s); } }

    private void MakeSuffixArray() {
       //Approx. runtime: n^3 * log n!!!
       //But I claim the shortest ever implementation of a suffix array!
        m_SA = Enumerable.Range(0, m_Data.Length).ToArray();
        Array.Sort(m_SA, cmp);
    }

    private int FindInArray(int ith) {
        return Array.BinarySearch(m_SA, ith, cmp);
    }

    private int FindInIndex(string s) {
        int idx;
        if (!m_Dir.TryGetValue(s, out idx))
            return -1;
        return m_SA[idx]/m_Maxlen;
    }

    private string QGram(int i) {
        return i > m_Data.Length - m_Q ?
            m_Data.Substring(i) :
            m_Data.Substring(i, m_Q);
    }

    private void MakeIndex() {
        for (int i = 0; i < m_Data.Length; ++i) {
            int pos = FindInArray(i);
            if (pos < 0) continue;
            m_Dir[QGram(i)] = pos;
        }
    }
}

Exemplo de uso:

static void Main(string[] args) {
    var strings = new [] { "hello", "world", "this", "is", "a",
                           "funny", "test", "which", "i", "have",
                           "taken", "much", "too", "far", "already" };

    var index = new QGramIndex(strings, 10, 3);

    var tests = new [] { "xyz", "aki", "ake", "muc", "uch", "too", "fun", "est",
                         "hic", "ell", "llo", "his" };

    foreach (var str in tests) {
        int pos = index[str];
        if (pos > -1)
            Console.WriteLine("\"{0}\" found in \"{1}\".", str, strings[pos]);
        else
            Console.WriteLine("\"{0}\" not found.", str);
    }
}
13
adicionado
Existe uma maneira de dividir uma tabela de q-gram para que você não debata o disco usando-a?
adicionado o autor Will, fonte
Eu não estou ciente de qualquer maneira. Sua melhor aposta pode ser reduzir o alfabeto fazendo hash de vários caracteres para a mesma chave, reduzindo também o tamanho da tabela exponencialmente. No entanto, você precisa cuidar das colisões.
adicionado o autor Konrad Rudolph, fonte
@ Rafał: Eu estou preenchendo as seqüências de caracteres para que eu possa calcular o índice de forma facilmente a posição na matriz de sufixos. Existem outras soluções, mas elas exigem a modificação da matriz de sufixos, dificultando a construção.
adicionado o autor Konrad Rudolph, fonte
Uma matriz de sufixos é melhor que uma árvore de sufixos porque ela pode ser armazenada com muito mais eficiência de espaço. Mais importante, você precisa de um sufixo array para criar o índice q-gram com eficiência (pelo menos eu não conheço nenhum algoritmo para criar um índice q-gram para uma árvore de sufixos).
adicionado o autor Konrad Rudolph, fonte
@ Rafał: “Encontrar a string original pelo sufixo deve ser rápido” - como? No entanto, reconheço que o preenchimento da string geralmente não é um bom caminho. Seria melhor construir o array de sufixos sobre o array de strings. Isso é possível, embora um pouco mais difícil. Vou atualizar meu texto de acordo.
adicionado o autor Konrad Rudolph, fonte
@ Rafał: Dê uma olhada no meu post de acompanhamento. No entanto, em resposta à sua proposta de log (N): tenha em mente que o seu N aqui não é apenas 200.000, mas sim o número de todos os sufixos, que é muito maior.
adicionado o autor Konrad Rudolph, fonte
Por que o preenchimento das seqüências de caracteres é necessário? A matriz de sufixos é melhor que uma árvore de sufixos?
adicionado o autor Rafał Dowgird, fonte
Bons pontos sobre a árvore. Voltar para o preenchimento - como eu entendo, você pode obter o sufixo inteiro da tabela ("kov" -> "koverflow"). Encontrar a string original pelo sufixo deve ser rápido (ou até mesmo pelo prefixo, se você construir a tabela a partir de strings invertidas). Corrigir?
adicionado o autor Rafał Dowgird, fonte
Você pode encontrar a string pelo sufixo no tempo O (log (N)) se você mantiver uma tabela adicional das strings ordenadas pelo seu reverso. Ou mantenha as strings classificadas naturalmente e construa o array de sufixos a partir de strings invertidas, obtendo prefixos ao invés de sufixos.
adicionado o autor Rafał Dowgird, fonte

Here's a WAG for you. I am in NO WAY Knuthian in my algorithm savvy

Okay, so the naiive Trie encodes string keys by starting at the root of the tree and moving down branches that match each letter in the key, starting at the first letter of the key. So the key "foo" would be mapped to (root)->f->fo->foo and the value would be stored in the location pointed to by the 'foo' node.

Você está procurando por qualquer substring dentro da chave, não apenas substrings que começam no início da chave.

Então, o que você precisa fazer é associar um nó a qualquer chave que contenha essa substring específica. No exemplo foo que eu dei antes, você NÃO teria encontrado uma referência ao valor de foo sob os nós 'f' e 'fo'. Em um TST que suporta o tipo de pesquisa que você deseja fazer, você não apenas localizaria o objeto foo em todos os três nós ('f', 'fo' e 'foo'), mas também o encontraria. sob 'o' e 'oo' também.

Há algumas consequências óbvias para expandir a árvore de pesquisa para suportar esse tipo de indexação. Primeiro, você acabou de explodir o tamanho da árvore. Incrivelmente. Se você puder armazená-lo e usá-lo de maneira eficiente, suas pesquisas levarão o tempo O (1). Se as suas chaves permanecerem estáticas, e você puder encontrar uma maneira de particionar o índice para que você não tenha uma penalidade enorme de IO ao usá-lo, isso pode ser amortizado para valer a pena.

Segundo, você descobrirá que as pesquisas por strings pequenas resultarão em um grande número de ocorrências, o que pode tornar sua pesquisa inútil, a menos que você, digamos, coloque um tamanho mínimo nos termos da pesquisa.

On the bright side, you might also find that you can compress the tree via tokenization (like zip compression does) or by compressing nodes that don't branch down (i.e., if you have 'w'->'o'->'o'-> and the first 'o' doesn't branch, you can safely collapse it to 'w'->'oo'). Maybe even a wicked-ass hash could make things easier...

De qualquer forma, WAG como eu disse.

2
adicionado
Não é o mesmo que o índice de q-gram que Konrad estava falando?
adicionado o autor Pacerier, fonte

/EDIT: Um amigo meu apenas apontou uma suposição estúpida na minha construção da tabela q-grama. A construção pode ser muito mais simples - e, conseqüentemente, muito mais rápida. Eu editei o código fonte e a explicação para refletir isso. Eu acho que pode ser a solução final .

Inspirado pelo comentário de Rafał Dowgird à minha resposta anterior, atualizei meu código. Acho que isso merece uma resposta própria, já que também é bastante longa. Em vez de preencher as strings existentes, esse código cria o índice sobre a matriz original de strings. Em vez de armazenar uma única posição, o array de sufixos armazena um par: o índice da string de destino e a posição do sufixo nessa string. No resultado, apenas o primeiro número é necessário. No entanto, o segundo número é necessário para a construção da tabela q -gram.

A nova versão do algoritmo cria a tabela q -gram caminhando sobre a matriz de sufixos em vez das strings originais. Isso salva a pesquisa binária da matriz de sufixos. Conseqüentemente, o tempo de execução da construção cai de O ( n * log n ) para O ( n ) (onde n é o tamanho da matriz de sufixos).

Observe que, assim como na minha primeira solução, o uso de SubString resulta em muitas cópias desnecessárias. A solução óbvia é escrever um método de extensão que crie um wrapper leve em vez de copiar a string. A comparação deve então ser ligeiramente adaptada. Isso é deixado como um exercício para o leitor. ;-)

using Position = System.Collections.Generic.KeyValuePair;

class QGramIndex {
    private readonly int m_Q;
    private readonly IList m_Data;
    private Position[] m_SA;
    private Dictionary m_Dir;

    public QGramIndex(IList strings, int q) {
        m_Q = q;
        m_Data = strings;
        MakeSuffixArray();
        MakeIndex();
    }

    public int this[string s] { get { return FindInIndex(s); } }

    private int FindInIndex(string s) {
        int idx;
        if (!m_Dir.TryGetValue(s, out idx))
            return -1;
        return m_SA[idx].Key;
    }

    private void MakeSuffixArray() {
        int size = m_Data.Sum(str => str.Length < m_Q ? 0 : str.Length - m_Q + 1);
        m_SA = new Position[size];
        int pos = 0;
        for (int i = 0; i < m_Data.Count; ++i)
            for (int j = 0; j <= m_Data[i].Length - m_Q; ++j)
                m_SA[pos++] = new Position(i, j);

        Array.Sort(
            m_SA,
            (x, y) => string.CompareOrdinal(
                m_Data[x.Key].Substring(x.Value),
                m_Data[y.Key].Substring(y.Value)
            )
        );
    }

    private void MakeIndex() {
        m_Dir = new Dictionary(m_SA.Length);

       //Every q-gram is a prefix in the suffix table.
        for (int i = 0; i < m_SA.Length; ++i) {
            var pos = m_SA[i];
            m_Dir[m_Data[pos.Key].Substring(pos.Value, 5)] = i;
        }
    }
}

O uso é o mesmo que no outro exemplo, menos o argumento maxlen necessário para o construtor.

0
adicionado

Você obteria alguma vantagem tendo suas chaves comparáveis ​​ao tamanho do registro da máquina? Então, se você está em uma caixa de 32 bits, você pode comparar 4 caracteres de uma só vez, em vez de cada personagem individualmente? Não sei o quanto isso aumentaria o tamanho do seu aplicativo.

0
adicionado

Seria possível "hash" o valor-chave? Basicamente, uma segunda árvore terá todos os valores possíveis para procurar por apontar para uma lista de chaves na primeira árvore.

Você vai precisar de 2 árvores; O primeiro é um valor de hash para o objeto de domínio. a segunda árvore é as sequências de pesquisa para o valor de hash. a segunda árvore tem várias chaves para o mesmo valor de hash.

Example tree 1: STCKVRFLW -> domain object

tree 2: stack -> STCKVRFLW,STCK over -> STCKVRFLW, VRBRD, VR

Portanto, usando a pesquisa na segunda árvore, você terá uma lista de chaves para pesquisar na primeira árvore.

0
adicionado

Escolha um tamanho mínimo de string de pesquisa (por exemplo, quatro caracteres). Percorra sua lista de entradas de string e crie um dicionário de cada substring de quatro caracteres, mapeando para uma lista de entradas em que a substring aparece. Ao fazer uma pesquisa, procure com base nos quatro primeiros caracteres da string de pesquisa um conjunto inicial, em seguida, diminua esse conjunto inicial para apenas aqueles que correspondam à cadeia de pesquisa completa.

O pior caso disso é O (n), mas você só conseguirá isso se suas entradas de string forem quase todas idênticas. O dicionário de pesquisa é provavelmente muito grande, por isso é provavelmente uma boa idéia armazená-lo em disco ou usar um banco de dados relacional :-)

0
adicionado

Para consultar um grande conjunto de texto de maneira eficiente, você pode usar o conceito de Edit Distance/Prefix Edit Distance.

Edite a distância ED (x, y): número mínimo de transfroms para ir de x para y

Mas calcular ED entre cada termo e texto de consulta é recurso e consome tempo. Portanto, em vez de calcular o ED para cada termo, primeiro podemos extrair possíveis termos correspondentes usando uma técnica chamada Índice Qgram . e, em seguida, aplique o cálculo do ED nesses termos selecionados.

Uma vantagem da técnica de índice Qgram é o suporte para Fuzzy Search .

Uma possível abordagem para adaptar o índice QGram é construir um índice invertido usando Qgrams. Lá armazenamos todas as palavras que consistem em um determinado Qgram (ao invés de armazenar uma string completa, você pode usar um ID único para cada string).

col: col mbia, col ombo, gan col a, ta col ama

Então, ao consultar, calculamos o número de Qgrams comuns entre o texto da consulta e os termos disponíveis.

Example: x = HILLARY, y = HILARI(query term)
Qgrams
$$HILLARY$$ -> $$H, $HI, HIL, ILL, LLA, LAR, ARY, RY$, Y$$
$$HILARI$$ -> $$H, $HI, HIL, ILA, LAR, ARI, RI$, I$$
number of q-grams in common = 4

Para os termos com alto número de Qgrams comuns, calculamos o ED/PED em relação ao termo da consulta e sugerimos o termo para o usuário final.

you can find an implementation of this theory in following project. Feel free to ask any questions. https://github.com/Bhashitha-Gamage/City_Search

Para estudar mais sobre Edit Distance, prefixo Edit Distance Qgram, por favor, assista ao seguinte vídeo da Prof. Dr. Hannah Bast https://www.youtube.com/embed/6pUg2wmGJRo (a aula começa a partir das 20:06 )

0
adicionado