最适合基于前缀搜索的数据结构

3
我需要维护一个内存中的键值对数据结构,并有以下限制:
  1. 键和值都是长度为256和1024的文本字符串,任何键通常看起来像k1k2k3k4k5,其中每个k(i)本身都是4-8字节的字符串。
  2. 尽可能使用连续的内存作为内存数据结构。我有400 MB的键值对,允许分配120%的内存(如果需要,则额外20%用于元数据)。
  3. 数据结构将具有以下操作:
  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. 迭代[最频繁的操作]:此操作是基于前缀的。它分配一个迭代器即遍历整个数据结构并返回匹配前缀键(例如“k1k2k3。*”,k(i)如上所定义)的键值列表。每次迭代都会在此迭代器(列表)上进行。释放迭代器会释放列表。通常希望迭代器在400 MB数据结构中返回100 KB的键值对(100KB:400 MB :: 1:4000)。典型的签名如下所示:void *iterate(void *ds, char *prefix_key);
  8. 因为第6和第7个项目是最频繁使用的操作,因此需要进行优化。
我的问题是,哪种数据结构最适合以上限制条件?
我曾考虑过哈希表。如果我有足够的内存,则可以在o(1)的时间内进行添加/删除/查找,但这不是迭代的最佳选择。可以进行哈希嵌套(在k1上哈希,然后在k2上哈希,然后在k3上哈希..)或哈希数组,但这违反了第2点的要求。我还有哪些选项?

1
前缀搜索的最佳解决方案是三叉树。数据不需要存储在树本身中,可以将数据保存在连续的数组中。树可以简单地以可搜索前缀的方式持有指向数据的指针。三叉树无法满足您的120%需求。您可以查看Ternary Tree for Prefix Searches,看看是否符合您的需求。 - David C. Rankin
2
我认为答案在某种程度上取决于插入和删除的频率。如果它们非常罕见,您可以使用排序数组,并使用bsearch()查找记录。 - Jasen
这个问题可能会被暂停,因为您正在提出基于观点的问题。 - Unmanned Player
5个8字节的子密钥将占用40字节,而不是256字节。 - Jasen
很罕见吗?您能估计每种类型操作的比例吗? - Jasen
显示剩余5条评论
3个回答

4
我会建议使用类似B+树的数据结构: https://en.wikipedia.org/wiki/B%2B_tree,因为它能够在保证内存效率的同时,使得块始终保持在85%以上的填充率。块的大小应该足够大,以便内部节点的开销只占几个百分点。
你还可以优化叶子块中的存储,因为一个块中的大多数键都有一个长的公共前缀,你可以从高层块中找出这些前缀。因此,在叶子块中删除所有公共前缀的副本,你的400MB键值对将占用远少于400MB的RAM。这将使插入过程变得更加复杂。
还有其他一些方法可以进一步压缩这个结构,但是这样做会很快变得复杂,而且听起来你并不需要这样做。

我曾考虑过B+树。这里的一个问题是键不是数字,而是基于文本的字符串。因此,对B+树的每个操作(添加/删除/查找/插入)都将涉及字符串比较,这比整数比较要昂贵得多。 - Subhajit Kundu
为了实现您的解决方案,我们需要一些机制将字符串键转换为整数键。如果可以将正则表达式转换为键的某些上限和下限,则迭代可能会变得更简单。将字符串转换为整数实际上打开了新的解决方案可能性。 - Subhajit Kundu
将字符串转换为整数确实为开启新可能性带来了很大的帮助。https://stackoverflow.com/questions/50676031/ways-to-convert-special-purpoes-strings-to-integers上的文章也谈到了这一点。 - Subhajit Kundu
在搜索时,通过记住匹配前缀的长度并跳过min(prefixleft,prefixright)个字符来高效地进行字符串比较。 - Matt Timmermans
你只会这样比较字符串的一部分,但它仍然是一个字符串。整数比较只需要一条指令。每个字符比较的字符串比较需要1条指令。仍然太昂贵了。 - Subhajit Kundu
你可以将4个字符的子串映射为整数。另一方面,memcmp()库函数的实现可能已经这样做了。 - Jasen

0

我会将其实现为哈希表进行查找,并使用单独的倒排索引进行迭代。我认为尝试将这些单独的关键段转换为整数,正如你在将特殊字符串转换为整数的方法中所询问的那样,是一堆不必要的工作。

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,则您有一个k1k2缓存,其中包含“abcxyz=[键列表]”。


0

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

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

例如:

#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哈希表之外的所有哈希表都用于扫描项目集,因此它们的引用数组可能会变得非常长,并不是一件坏事:这些数组几乎完全包含了人们正在查找的项目!(换句话说,如果一个引用数组增长到比如说10,000个条目长,如果它被用来查找所需的大约9,750个条目,那么这并不是一个问题。)
我个人也考虑过某种类型的表格,例如每个关键部分都是表格中的一个附加级别。然而,查找具有给定前缀的条目集涉及到表格遍历和相当分散的内存访问。我相信,但尚未通过微基准测试比较这两种方法,具有潜在大型引用数组的哈希表在运行时更有效率。

这似乎是解决一个不同的问题。 - Jasen
@Jasen:你为什么这么认为?我相信我很清楚地理解了OP的需求,并在我的答案中分别强调了OP的第6和第7点。我没有像描述的那样实现一个迭代器,而是实现了一个回调,尽管返回匹配项数组将是一个简单的修改(将其视为建议之内的建议)。你确定完全理解了OP的问题吗? - Nominal Animal
他从未提到在没有k1的情况下使用k2。 - Jasen

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