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

时间:2018-06-03 22:50:31

标签: c regex algorithm data-structures hash

我必须维护Key-Value对的内存数据结构。我有以下约束:

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

    我考虑过哈希。添加/删除/查找可以在o(1)中完成,因为我有足够的内存,但它不适合迭代。散列哈希(哈希在k1然后在k2然后在k3 ...)或散列数组可以完成,但它违反了子弹2.我还有其他选择吗?

3 个答案:

答案 0 :(得分:3)

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

由于内存效率对您很重要,当叶块块满了时,如果可能,您应该在几个块之间重新分配密钥,以确保块总是> = 85%已满。块大小应该足够大,以至于内部节点的开销只有几个百分点。

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

还可以采取其他措施来进一步压缩这种结构,但这种情况很快变得复杂,听起来并不像你需要的那样。

答案 1 :(得分:0)

我会将其实现为查找的哈希表,并为您的迭代实现单独的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的键列表,查询包含qgx的键列表的k3哈希表。然后,您可以对这两个列表进行交集以获得结果。

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

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

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

另一种可能的优化是创建组合键的其他索引。例如,如果查询经常询问k1和k2,并且可能的组合相当小,那么组合k1k2缓存是有意义的。因此,如果某人搜索k1 = abc和k2 = xyz,则您有一个k1k2缓存,其中包含" abcxyz = [密钥列表]"。

答案 2 :(得分: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;
}

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

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

换句话说,这种方法试图最小化访问的高速缓存行数以找到所需的对。

由于初始部分将具有大量重复哈希(相对较少的唯一前缀哈希)和哈希长度,因此使哈希表更小可能更有效。 (引用数组会更大。)因为哈希和哈希长度不会改变,所以可以在任何时候调整任何哈希表的大小,而不必重新计算任何哈希值。

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

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