什么是最适合自动建议的数据结构?

3

我想实现一个自动建议组件。 对于每个用户输入,该组件应提供零个或多个建议。

例如,如果用户输入'park' ,建议可以是:['Parkville', 'Parkwood', 'Bell Park']

要求很简单:

  • 它应该不区分大小写(用户应该获得相同的建议'park''PARK''PaRk'
  • 它应该匹配每个单词的开头('pa'将匹配'Parkville''Bell Park''Very cool park',但不是'Carpark'

您会选择哪种数据结构来使用JavaScript实现此功能?

是否有任何JavaScript / Node.js库可以帮助实现此功能?


建议从哪里来 - 是一些固定的字符串列表,还是有不同的来源? - Vadim Landa
你是否正在寻找一个数据结构,用于在服务器上通过ajax查询?这个数据结构是静态的还是动态更新的?或者你想把这个数据结构放在客户端(需要遵守内存限制、小传输大小和快速建立算法)? - Bergi
@VadimLanda 假设有一个固定的字符串列表可供选择。 - Misha Moroshko
嗯,为什么不直接使用包含数据的数据库呢?数据库正是为此而优化的 - Bergi
@Bergi 实际上,数据将来自多个地方(数据库)。我只是在这里试图保持简单。 - Misha Moroshko
显示剩余2条评论
4个回答

4
我认为这种任务最好的数据结构是trie。关于大小写问题,只需在添加到trie之前将每个单词转换为小写,并在小写单词上执行搜索。
当您到达trie的某个点时,有许多子节点是满足字符串 - 具有从根到当前点的前缀的字符串。
输出建议 - 从当前点(从根到用户键入的前缀到达的点)递归地遍历并打印标记为叶子节点的建议。停止在 ~10 个输出后打印,因为trie可能有许多满足条件的单词。
以下是js实现:trie-jstrie和其他许多实现。只需搜索js+trie。也可以使用trie+autosuggest+js)。 更新1

如果您想在不进行递归遍历的情况下以O(1)的时间复杂度(当然,每个建议需要O(1)的时间)输出所有变体,则可以在每个节点中存储引用的数组列表。数组列表存储属于该节点的所有单词的索引,每个值是另一个字典数组列表中的索引。

就像这样:

将单词添加到字典中:

  1. Check in O(word_len) is it in a trie (already added). If not, add to dictionary and remember index in "storage"

     if(!inTrie(word)){
        dict.push(word);
        index = dict.length-1; //zero based indices
        // now adding to trie
        for each node visited while addition to trie : node.references.push(index)
     }
    
  2. Search:

    Go to node with prefix==typed word;
    for(int i=0;i<min(curNode.references.length(),10);i++)
    print(dict[curNode.references[i]];
    

更新2

关于 'pa' --> '非常酷的公园'

你绝对应该将短语拆分为单独的单词,以便每个单词在trie中都是“可搜索的”。但是!由于您将短语视为一个单词,因此应将其存储在一个单元格中。

我的意思是:

String phrase = "Very cool parl";
dict.push(phrase);
index = dict.length-1;

parts[] = split(phrase);
for part in parts{
 add part - for each node visited while addition of part perform node.references.push(index);
}

换句话说,短语的代码与单词的代码相同。引用也相同,因为我们将所有部分作为短语存储在一个单元格中。区别在于通过部分拆分和添加短语。简单明了,你看到了。
更新3
顺便说一下,引用存储在内存消耗上并不昂贵 - 单词中的每个字母都是trie中的某个节点,这意味着在某个arraylist中有1个条目(此单词在全局存储数组中的一个整数索引)。因此,您只需要额外的O(dictionary_length)内存,即~50000 * 20 = 1,000,000个整数〜4 MB,假设每个单词最多有20个字母。因此,所需内存的上限为4 MB。
更新4
关于“e e” - > East Eagle。
好的,在发布任何想法之前,我想警告这是非常奇怪的自动建议行为,通常自动建议匹配一个前缀而不是所有前缀。
有一个非常简单的想法,将搜索复杂性增加到多个前缀并行搜索某个delta的情况,其中delta复杂性=查找集合交集的复杂性。
  1. 现在全局存储不仅包含索引,还包括配对的<a,b>,其中a = 存储中的索引,b = 短语中的索引。对于简单的单词,b = 0或-1或任何特殊值。
  2. 现在每个Trie节点引用ArrayList都包含一对。当用户键入前缀短语,例如“ea ri”时,您应该像往常一样找到'ea'节点,迭代引用但只考虑那些条目,其中a = any,b = 1,因为ea索引在键入的短语中= 1。将所有这样的a索引放入某个集合中,其中b = 1。像往常一样找到ri节点,迭代引用并将那些a索引放入另一个集合中,其中b = 2等等。找到索引集合的交集。输出存储单词的索引,其中索引属于集合的交集。

当您搜索简单单词而不是短语时,您需要迭代所有引用项。


如果你的键(单个小写单词)与值(短语)不同,你会将trie用作映射而不是集合。 - Bergi
@Baurzhan 谢谢!这真的很有帮助。不过,建议选项应该保持大小写,即我不能在字典中存储小写版本。你会如何处理呢? - Misha Moroshko
@MishaMoroshko,将大小写敏感的单词放入“存储”中,并将规范化(小写)的单词放入trie中。在trie中成功进行“用户输入前缀”搜索意味着具有该前缀的单词在字典中,但可能使用其他大小写形式(trie中存在“park”前缀意味着可能存在ParK、pARk等前缀单词在字典中)。但是,当您通过前缀节点引用输出单词时,将按照存储在字典中的正确大小写输出它们“原样”。 - user2104560
@Baurzhan 如果要查找给定短语,你会如何进行?例如,如果字典中有两个短语:“East Richmond”和“East Eagle”,而我们搜索“ee”,我们只想得到“East Eagle”。你会如何实现这个搜索? - Misha Moroshko
@MishaMoroshko,我认为你只需要每个短语都可以通过其中任何单词的前缀进行搜索。你向我展示了同时由两个子短语组成的前缀搜索。我的意思是,我的方法是用于搜索east(e、ea、eas、east、r、ri、rich...,eag、eagle..),但不适用于'ea ri'、'ri ea'、'ea ea' - 在这种情况下,trie将无法帮助。实际上,我没有看到自动建议以以下方式进行搜索。 - user2104560
@Baurzhan 如果您输入'e e',您希望在自动建议中看到什么?您期望没有结果,还是期望看到'East Richmond'和'East Eagle'两个选项?(用户可以任意输入...) - Misha Moroshko

1
有时候简单的方式是最好的。你说你的字典里有大约50000个条目,但你没有说其中有多少个是由多个单词组成的(例如“贝尔公园”或“马丁·路德·金大道”等)。为了论证,假设每个字典条目平均包含2个单词。
我不太擅长JavaScript,所以我会用通俗易懂的语言来描述,但你应该能够相对容易地实现它。
在预处理步骤中,创建一个包含数据库中所有项目(即全部50000个)的数组。例如:
Carpark
Cool Park
Park
Pike's Peak
...

接下来,创建一个包含每个单词条目和指向包含该单词的第一个数组项的索引列表的映射。因此,您的第二个数组将包含:

Carpark, {0}
Cool, {1}
Park, {1,2}
Pike's, {3}
Peak, {3}

将第二个数组按单词排序。因此顺序应为{Carpark,Cool,Park,Peak,Pike's}

当用户键入“P”时,在单词数组上执行二进制搜索,以找到以“P”开头的第一个单词。您可以从该点开始对数据进行顺序扫描,直到到达不以P开头的单词为止。在访问每个单词时,请将索引列表中引用的单词添加到输出中。(您需要进行一些去重操作,但这很容易。)

二进制搜索是O(log n),因此找到第一个单词将非常快。特别是对于如此少量的数据。如果您对每个键入的字母执行HTTP请求,则通信时间将超过处理时间。没有特别好的理由尝试在服务器端加速此过程。

但是,您可以减少服务器的负载。意识到当用户键入“P”并且客户端从服务器获取数据时,客户端现在拥有可能返回的所有可能字符串,如果用户键入“PA”,则来自“PA”的结果是来自“P”的结果的子集。

所以,如果您修改了代码,使客户端仅在键入第一个字符时向服务器发出请求,您可以在客户端上进行后续搜索。您需要做的就是让服务器返回匹配单词列表(来自第二个数据结构)以及由这些单词索引的匹配短语列表。因此,当用户键入第二个、第三个、第四个等字符时,客户端会执行过滤过程,而无需涉及服务器。
当然,这样做的好处是响应速度更快,服务器负载更轻。成本是在客户端上增加了一小部分复杂性,以及在第一次请求时返回了一小部分额外的数据。但是,在第二个请求中返回给服务器的额外数据量可能会比服务器返回的数据量少。

1

试试http://lunrjs.com,它可以让你设置某些属性的提升,如果你喜欢的话。简单而小巧。

如果你需要更简单的东西,你可以看看是否有Levenshtein Distance的任何实现。一个更酷的算法是Soundex,它根据单词的语音特性进行评分。


0
事实上,Trie是实现你的目标的适当数据结构。其实现简单而且短小。我的解决方案如下所示。在Trie实现后附上用法。
function TrieNode(ch) {
    this.key = ch;
    this.isTail = false;
    this.children = [];
}

TrieNode.prototype = {
    constructor : TrieNode,

    /**
     * insert keyword into trie
     */
    insert : function(word) {
        if (word && word.length == 0)
            return;
        var key = word[0];
        if (this.children[key] == null) {
            this.children[key] = new TrieNode(key);
        }
        if (word.length == 1) {
            this.children[key].isTail = true;
        } else if (word.length > 1) {
            this.children[key].insert(word.slice(1));
        }
    },

    /**
    * return whether a word are stored in trie
    */
    search : function(word) {
        if (word && word.length == 0 || this.children[word[0]] == null)
            return false;
        if (word.length == 1) {
            return this.children[word[0]].isTail;
        } else {
            return this.children[word[0]].search(word.slice(1));
        }
    },


    /**
     * NOTICE: this function works only if length of prefix longer than minimum trigger length
     * 
     * @param prefix
     *            keywords prefix
     * @returns {Array} all keywords start with prefix
     */
    retrieve : function(prefix) {
        var MINIMUM_TRIGGER_LENGTH = 1;
        if (prefix == null || prefix.length < MINIMUM_TRIGGER_LENGTH)
            return [];
        var curNode = this.walk(prefix);
        var collection = [];
        curNode && curNode.freetrieve(prefix, collection);
        return collection;
    },

    walk : function(prefix) {
        if (prefix.length == 1) {
            return this.children[prefix];
        }
        if (this.children[prefix[0]] == null) {
            return null;
        }
        return this.children[prefix[0]].walk(prefix.slice(1));
    },

    freetrieve : function(got, collection) {
        for ( var k in this.children) {
            var child = this.children[k];
            if (child.isTail) {
                collection.push(got + child.key);
            }
            child.freetrieve(got + child.key, collection);
        }
    }
}

// USAGE lists below
function initTrieEasily(keywords){
    let trie= new TrieNode();
    keywords.forEach(word => trie.insert(word));
    return trie;
}

var words=['mavic','matrix','matrice','mavis','hello world'];

var trie=initTrieEasily(words);
trie.retrieve('ma');  // ["mavic", "mavis", "matrix", "matrice"]
trie.retrieve("mat")  // ["matrix", "matrice"]
trie.search("hello"); // "false"
trie.search("hello world");  //"true"
trie.insert("hello");
trie.search("hello"); // "true"
trie.retrieve("hel"); // ["hello", "hello world"]

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接