【问题标题】:Best suited data-structure for prefix based searches最适合基于前缀的搜索的数据结构
【发布时间】:2018-06-05 15:49:50
【问题描述】:

我必须维护一个键值对的内存数据结构。我有以下限制:

  1. 键和值都是长度为 256 和 1024 的文本字符串 分别。任何键通常看起来像 k1k2k3k4k5,每个 k(i) 本身就是 4-8 字节的字符串。
  2. 内存中的数据结构应尽可能具有连续的内存。我有 400 MB 的键值对,并允许分配 120% 的值。 (仅在需要时额外增加 20% 的元数据。)
  3. DS 将进行以下操作:
  4. 添加[不频繁操作]:典型签名类似于void add_kv(void *ds, char *key, char *value);
  5. 删除[不频繁操作]:典型签名看起来像void del_kv(void *ds, char *key);
  6. 查找 [最频繁的操作]:典型签名看起来像 char *lookup(void *ds, char *key);
  7. 迭代[最频繁操作]:此操作基于前缀。它分配一个迭代器,即迭代整个 DS 并返回与 prefix_key 匹配的键值列表(例如,“k1k2k3.*”,k(i) 定义如上)。每次迭代都在这个迭代器(列表)上进行迭代。释放迭代器释放列表。通常期望迭代器在 400 MB DS (100KB:400 MB :: 1:4000) 中返回价值 100 KB 的键值对。典型的签名看起来像void *iterate(void *ds, char *prefix_key);
  8. Bullet 6 和 Bullet 7 是最频繁的操作,需要进行优化。

我的问题是最适合上述约束的数据结构是什么?

我考虑过哈希。添加/删除/查找可以在 o(1) 中完成,因为我有足够的内存,但它不是迭代的最佳选择。散列散列(在 k1 上散列,然后在 k2 上,然后在 k3 上......)或散列数组可以完成,但它违反了项目符号 2。我还有哪些其他选择?

【问题讨论】:

  • 前缀搜索的最佳解决方案是三叉树。数据不需要存储在树本身中,数据可以保存在一个连续的数组中。树可以简单地以前缀可搜索的方式保存指向数据的指针。三叉树不会满足你的 120%。您可以查看Ternary Tree for Prefix Searches,看看是否满足您的需求。
  • @DavidC.Rankin 你确定吗?为什么不用红黑代替呢?
  • 我认为答案在某种程度上取决于插入和删除的罕见程度。如果它们非常罕见,您可以只使用排序数组。并使用 bsearch() 查找记录。
  • 当您提出基于意见的问题时,此问题可能会被搁置。
  • @Jasen Red/Black 只是另一种平衡二叉树。正是附加节点(每个节点的第三个指针)使三叉树特别适合前缀搜索。 Trie 树也可以工作,但是你有(每个节点的字母表指针数的大小,它会膨胀分配的大小)

标签: c regex algorithm data-structures hash


【解决方案1】:

我可能会为此使用 B+树之类的东西:https://en.wikipedia.org/wiki/B%2B_tree

由于内存效率对您很重要,因此当叶块已满时,您应尽可能在多个块之间重新分配键,以确保块始终 >= 85% 已满。块大小应该足够大,内部节点的开销只有几个 %。

您还可以优化叶块中的存储,因为块中的大多数键都有一个很长的公共前缀,您可以从更高级别的块中找出。因此,您可以从叶块中的键中删除公共前缀的所有副本,并且您的 400MB 键值对将占用少于 400MB 的 RAM。这会使插入过程有些复杂。

你可以做其他事情来进一步压缩这个结构,但这会很快变得复杂,而且听起来你不需要它。

【讨论】:

  • 我考虑过 B+Tree。这里的一个问题是键不是数字,而是基于文本的字符串。所以 B+Tree 添加/删除/查找/插入的每个操作都会涉及字符串比较,这比整数比较昂贵得多。
  • 为了实现您的解决方案,我们需要一些机制将字符串键转换为整数键。如果可以将正则表达式转换为键的某个上限和下限,则迭代可以变得更简单。将字符串转换为整数实际上开辟了新的解决方案。
  • 将字符串转换为整数确实带来了新的可能性。 stackoverflow.com/questions/50676031/… 跟踪相同。
  • 您可以在进行搜索时有效地进行字符串比较,方法是记住两边匹配前缀的长度并在下一次比较中跳过 min(prefixleft,prefixright) 个字符
  • 您只会比较字符串的一部分,但仍然是字符串。整数比较是一条指令。字符串比较是每个比较字符的 1 条指令。还是太贵了。
【解决方案2】:

我会将其实现为用于查找的哈希表,并为您的迭代使用单独的inverted index。正如您在Ways to convert special-purpoes-strings to Integers 中所要求的那样,我认为尝试将这些单独的关键段转换为整数是一堆不必要的工作。

已经有很多好的 C 哈希表实现可用,所以我不会深入讨论。

要为迭代创建倒排索引,请创建 N 个哈希表,其中 N 是键段的数量。然后,对于每个键,将其分成单独的段,并将该值的条目添加到适当的哈希表中。因此,如果您有密钥“abcxyzqgx”,其中:

k1 = abc
k2 = xyz
k3 = qgx

然后在 k1 哈希表中添加一个条目“abc=abcxyzqgx”。在 k2 哈希表中添加一个条目“xyz=abcxyzqgx”。在 k3 哈希表中添加“qgx=abcxyzqgx”。 (当然,这些值不会是字符串键本身,而是对字符串键的引用。否则您将拥有 O(nk) 个 256 个字符的字符串。)

完成后,您的每个哈希表都将唯一的段值作为键,这些值是这些段所在的键的列表。

如果要查找所有具有 k1=abc 和 k3=qgx 的键,请查询 k1 哈希表以获取包含 abc 的键列表,查询 k3 哈希表以获取包含 qgx 的键列表.然后你对这两个列表进行交集以获得结果。

构建单个哈希表的一次性成本为 O(nk),其中 n 是键的总数,k 是键段的数量。内存要求也是 O(nk)。当然,这有点贵,但你说的总共只有 160 万个密钥。

迭代的情况是 O(m*x),其中 m 是单个键段引用的键的平均数,x 是查询中的键段数。

一个明显的优化是在这个查找之前放置一个 LRU 缓存,以便从缓存中提供频繁的查询。

另一个可能的优化是创建组合键的附加索引。例如,如果查询频繁地请求 k1 和 k2,并且可能的组合相当小,那么组合 k1k2 缓存是有意义的。因此,如果有人搜索 k1=abc 和 k2=xyz,您就有一个包含“abcxyz=[键列表]”的 k1k2 缓存。

【讨论】:

    【解决方案3】:

    我将使用五个并行哈希表,对应于人们可能搜索的五个可能的前缀。每个哈希表槽将包含零个或多个引用,每个引用包含该特定键值对的前缀长度、该键前缀的哈希以及指向实际键和数据结构的指针。

    对于删除,实际的键和数据结构将包含所有五个前缀长度和相应的哈希,加上键和值的字符数据。

    例如:

    #define  PARTS  5
    
    struct hashitem {
        size_t            hash[PARTS];
        size_t            hlen[PARTS];
        char             *data;
        char              key[];
    };
    
    struct hashref {
        size_t            hash;
        size_t            hlen;
        struct hashitem  *item;
    };
    
    struct hashrefs {
        size_t            size;
        size_t            used;
        struct hashref    ref[];
    };
    
    struct hashtable {
        size_t            size[PARTS];
        struct hashrefs **slot[PARTS];
    };
    

    struct hashitem 中,如果keyk1k2k3k4k5,那么hlen[0]=2hash[0]=hash("k1")hlen[1]=4hash[1]=hash("k1k2") 等等,直到hlen[4]=10hash[4]=hash("k1k2k3k4k5")

    当插入一个新的键值对时,首先会找出前缀长度(hlen[])和它们对应的哈希值(hash[]),然后调用一个辅助函数

    static int insert_pair(struct hashtable *ht,
                           const char       *key,
                           const size_t      hash[PARTS],
                           const size_t      hlen[PARTS],
                           const char       *data,
                           const size_t      datalen)
    {
        struct hashitem *item;
        size_t           p, i;
    
        /* Verify the key is not already in the
           hash table. */
    
        /* Allocate 'item', and copy 'key', 'data',
           'hash', and 'hlen' to it. */
    
        for (p = 0; p < PARTS; p++) {
            i = hash[p] % ht->size[p];
    
            if (!ht->entry[i]) {
                /* Allocate a new hashrefs array,
                   with size=1 or greater, initialize
                   used=0 */
            } else
            if (ht->entry[i].used >= ht->entry[i].size) {
                /* Reallocate ht->entry[i] with
                   size=used+1 or greater */
            }
    
            ht->entry[i].ref[ht->entry[i].used].hash = hash[p];
            ht->entry[i].ref[ht->entry[i].used].hlen = plen[p];
            ht->entry[i].ref[ht->entry[i].used].item = item;
    
            ht->entry[i].used++;
        }
    
        return 0; /* Success, no errors */
    }
    

    前缀查找与使用全键查找哈希表相同:

    int lookup_filter(struct hashtable *ht,
                      const size_t      hash,
                      const size_t      hashlen,
                      const size_t      parts, /* 0 to PARTS-1 */
                      const char       *key,
                      int (*func)(struct hashitem *, void *),
                      void             *custom)
    {
        const struct hashrefs *refs = ht->entry[parts][hash % ht->size[parts]];
        int                    retval = -1; /* None found */
        size_t                 i;
    
        if (!refs)
            return retval;
    
        for (i = 0; i < refs->used; i++)
            if (refs->ref[i].hash == hash &&
                refs->ref[i].hlen == hashlen &&
                !strncmp(refs->ref[i].item->key, key, hashlen)) {
                if (func) {
                    retval = func(refs->ref[i].item, custom);
                    if (retval)
                        return retval;
                } else
                    retval = 0;
            }
    
        return retval;
    }
    

    注意使用的回调样式,以允许单个查找匹配所有前缀。假设唯一键的完整键匹配会稍微简单一些:

    struct hashitem *lookup(struct hashtable *ht,
                            const size_t      hash,
                            const size_t      hashlen,
                            const char       *key)
    {
        const struct hashrefs *refs = ht->entry[PARTS-1][hash % ht->size[PARTS-1]];
        size_t                 i;
    
        if (!refs)
            return NULL;
    
        for (i = 0; i < refs->used; i++)
            if (refs->ref[i].hash == hash &&
                refs->ref[i].hlen == hashlen &&
                !strncmp(refs->ref[i].item->key, key, hashlen))
                return refs->ref[i].item;
    
        return NULL;
    }
    

    删除将使用查找,除了可以通过将匹配条目替换为同一引用数组中的最终项目来删除匹配项;或者如果该项是引用数组中唯一的一项,则完全释放整个数组。

    之所以使用引用数组(每个哈希表条目有多个数据项)是可以接受的,是因为当前处理器以块的形式缓存数据(cacheline 是缓存的最小块)。因为每个哈希表槽包含一个或多个匹配,以及代码的完整哈希和哈希长度,所以需要进行逐字节比较以确定实际匹配的实际冲突非常罕见,即使是快速和 -简单的哈希函数。 (我希望每个匹配条目进行 1.05 到 1.10 次字符串比较,即使是像 DJB2 哈希这样简单的东西。)

    换句话说,这种方法试图最小化为找到所需对而访问的缓存线的数量。

    由于初始部分会有很多重复的哈希(相对较少的唯一前缀哈希)和哈希长度,因此将其哈希表缩小可能更有效。 (参考数组会更大。)因为哈希和哈希长度不会改变,所以可以随时调整任何哈希表的大小,而无需重新计算任何哈希。

    请注意,因为除了 PARTS-1 之外的所有哈希表都用于扫描 sets 项,因此它们的引用数组可能会变得很长并不是一件坏事:这些数组将几乎完全包含一个正在查找的项目! (换句话说,如果一个引用数组长到 10,000 个条目,如果它用于查找所需的 9,750 个左右的条目,这不是问题。)

    我个人确实也考虑过某种表格,例如每个关键部分都是表格中的一个附加级别。但是,查找具有给定前缀的条目集涉及表遍历和相当分散的内存访问。我相信,但尚未验证(通过比较两种方法的微基准测试),每个插槽具有潜在大参考数组的哈希表在运行时更有效。

    【讨论】:

    • 这似乎解决了另一个问题。
    • @Jasen:你为什么这么认为?我确实相信我非常清楚地理解了 OP 的需求,并且在我的回答中分别强调了 OP 的第 6 点和第 7 点。我实现了一个回调,而不是描述的迭代器,尽管返回匹配数组将是一个简单的修改(将其视为建议中的建议)。你确定完全理解OP的问题吗?
    • 他没有提到在没有 k1 的情况下使用 k2
    猜你喜欢
    • 2014-06-18
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-10-28
    • 1970-01-01
    • 2012-11-24
    • 2011-05-09
    • 1970-01-01
    相关资源
    最近更新 更多