【问题标题】:What's the most appropriate data structure for autosuggest?最适合自动建议的数据结构是什么?
【发布时间】:2015-04-30 11:09:07
【问题描述】:

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

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

要求很简单:

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

你会选择什么数据结构在 Javascript 中实现这个?

是否有任何 Javascript/Node.js 库可以提供帮助?

【问题讨论】:

  • 建议从何而来?一些固定的字符串列表,还是有不同的来源?
  • 您是否在服务器上寻找通过 ajax 查询的搜索数据结构?那是静态的还是动态更新的?或者你想把这个数据结构放在客户端(需要遵守内存限制、小传输大小、快速构建算法)?
  • @VadimLanda 假设有一个固定的字符串列表可供选择。
  • 你处理多少个字符串?
  • @Bergi 我将有一个进程定期从数据库中提取数据并将其写入文件(以 JSON 格式)。该算法将在服务器上运行,并且需要使用此 JSON 数据。所以,我假设会有某种构建过程从这个 JSON 创建一个数据结构。然后,希望搜索会非常快。数据量约为 50000。

标签: javascript node.js data-structures


【解决方案1】:

我认为此类任务的最佳数据结构是trie。关于不区分大小写 - 在添加到 trie 之前,只需将每个单词小写,并对小写单词执行搜索。

当你到达 trie 的某个点时,有许多子节点是 满足字符串的 - 字符串的前缀是从根到当前点。

输出建议 - 从当前点递归walk(从根到达用户键入的前缀)并在标记为叶子的节点上打印建议。在大约 10 个输出后停止打印,因为 trie 可能有很多令人满意的单词。

这里是 js 实现:trie-jstrie 和许多其他的。只需搜索 js+trie。可能trie+autosuggest+js也可以)

更新 1

如果您想输出O(1) 中的所有变体(当然,每个建议的O(1)),没有recursive walk,您可以在每个节点中存储引用的arraylist。 Arraylist 存储 属于 节点的所有单词的索引,每个值都是其他字典 araylist 中的索引。

类似的东西:

将单词添加到字典中:

  1. 签入O(word_len) 是否在特里(已添加)。如果没有,添加到字典并记住“存储”中的索引

     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. 搜索:

    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 个条目(该单词在全局存储数组中的一个整数索引)。因此,假设每个单词最多有 20 个字母,您只需要额外的 O(dictionary_length) 内存,即 ~ 50000*20 = 1 000 000 个整数 ~ 4 MB。因此,所需内存的上限为 4 MB。

更新 4

关于'e e' --> 东鹰。

好的,在发布任何想法之前,我想警告一下,这是非常奇怪的自动建议行为,通常自动建议匹配一个前缀而不是所有前缀。

有一个非常简单的想法会增加这种几个前缀并行搜索的搜索复杂度,其中delta复杂度=查找集合交集的复杂度。

  1. 现在全局存储不仅包含索引,还包含对 &lt;a,b&gt; where a = index in storage, b = index in pharse. 对于简单的单词 b=0 或 -1 或任何特殊值。
  2. 现在每个 trie 节点引用数组列表都包含对。当用户键入前缀短语时,例如“ea ri”,您应该像往常一样找到“ea”节点,遍历引用但只考虑那些条目,其中 a=any, b =1,因为键入短语中的 ea 索引 = 1。将所有此类 a 索引放在 b=1 的某个集合中。像往常一样找到ri 节点,遍历引用并将那些a 索引放到b=2 的其他集合中,依此类推。查找索引集的交集。按索引输出存储词,其中索引属于集合的交集。

当您搜索的不是短语而是简单的词时,您会遍历 所有 参考项目。

【讨论】:

  • 如果您的键(单个小写单词)与值(短语)不同,您可以将 trie 用作映射,而不是作为集合使用
  • @Baurzhan 谢谢!这真的很有帮助。但是,建议的选项应保留大小写,即我无法将小写版本存储在字典中。你会怎么处理呢?
  • @MishaMoroshko,只需放入“存储”区分大小写的单词并放入 trie “规范化”(小写)单词。在 trie 中成功的“用户键入的前缀”搜索将意味着具有此类前缀的单词在字典中,但可能在其他情况下(在 trie 中存在“park”前缀意味着可能 ParK,pARk 前缀单词在字典中。但是当您通过前缀节点的引用输出单词时,您将以正确的大小写输出它们,因为您将它们“按原样”存储在字典存储中
  • @Baurzhan 您将如何搜索给定的短语?例如,如果字典中有两个短语:East RichmondEast Eagle,我们搜索e e,我们只想得到East Eagle。您将如何实现搜索?
  • @MishaMoroshko,我认为您只需要每个短语都可以通过其中任何单词的前缀进行搜索。您正在向我展示通过 2 个子短语 simultaneoulsy 的组合前缀进行搜索。我的意思是,我的方法是搜索东部 (e,ea,eas,east, r,ri,rich..., eag,eagle..) 但不是搜索 'ea ri', ri ea', 'ea ea' -在这种情况下,trie 会有所帮助。实际上,我没有看到 autosuggest 以下列方式搜索。
【解决方案2】:

试试http://lunrjs.com。如果您愿意,它可以让您设置提升某些属性。简单而小巧。

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

【讨论】:

    【解决方案3】:

    有时简单的方法是最好的。您说您的字典中有大约 50,000 个条目。您不会说其中有多少有多个单词(即“Bell Park”或“Martin Luther King Drive”等)。只是为了论证,我们假设每个字典条目的平均单词数是 2。

    我不擅长 Javascript,所以我将笼统地描述它,但你应该能够相当容易地实现它。

    在您的预处理步骤中,创建一个包含数据库中所有项目的数组(即全部 50,000 个)。所以,例如:

    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”结果的子集。

    因此,如果您修改了代码以使客户端仅在键入的第一个字符时向服务器发出请求,您就可以在客户端上进行后续搜索。您所要做的就是让服务器返回匹配的单词列表(来自第二个数据结构)以及由这些单词索引的匹配短语。所以当用户输入第二个、第三个、第四个等字符时,客户端会进行过滤。服务器不必参与。

    这样做的好处当然是更快的响应和更少的服务器负载。代价是客户端增加了少量的复杂性,并在第一次请求时返回了少量的额外数据。但是第一次请求返回的额外数据可能会少于服务器在第二次请求中返回的数据。

    【讨论】:

      【解决方案4】:

      事实上,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"]
      

      【讨论】:

        猜你喜欢
        • 2010-12-19
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2016-07-18
        • 1970-01-01
        • 2012-03-17
        • 1970-01-01
        相关资源
        最近更新 更多