用于大 N 的字符串容器

String container for large N

本文关键字:字符串 用于      更新时间:2023-10-16

我正在寻找适合大量字符串(> 10^9)的字符串容器。 字符串具有可变长度。 它必须快速插入和查找,并且具有节俭的内存使用。填充容器时字符串是无序的。平均字符串长度约为 10 个字节。对确切的字符串值进行查找。可擦除性 - 可选。N事先是未知的。适用于 64 位架构。用例 - 考虑 AWK 的关联数组。

map<string>每根弦大约有 20-40 个咬合,每次插入调用一个 malloc(或两个)。所以它不快也不节俭。

有人可以指出我 C/C++ 库、数据结构或论文吗?

Relavant -- 哈希表库的比较

编辑我删除了"大数据",将N提升到更大的值,澄清了需求。

没有灵丹妙药,但基数树具有 trie 的优势(快速查找和插入,至少是渐近的)——空间消耗更好。

但是 - 两者都被认为不是"缓存效率" - 这可能很重要,尤其是在某个时候需要迭代数据时。

对于您的问题,64 位计算机上的指针几乎与数据的长度相匹配。因此,在问题中为每个字符串使用多个指针(平均长度小于 10 字节)将使数据结构的大小主导输入的大小。

处理此问题的一种常规方法是不使用指针来表示字符串。使用 32 位偏移量到存储所有字符串的大页面中的专用表示形式将使指针内存要求减半,但代价是需要对指针进行添加以检索字符串。

编辑:下面是这种表示的示例(未经测试的)实现(为简单起见,使用struct,实际实现当然只会使用户界面公开)。该表示形式假定哈希表插入,因此为next_留出空间。请注意,偏移量按 hash_node 大小缩放,以允许以 32 位偏移量表示。

struct hash_node {
    uint32_t next_;
    char * str () { return (const char *)(&next+1); }
    const char * str () const { return (const char *)(&next+1); }
};
struct hash_node_store {
    std::vector<hash_node> page_; /* holds the allocated memory for nodes */
    uint32_t free_;
    hash_node * ptr (uint32_t offset) {
        if (offset == 0) return 0;
        return &page_[offset-1];
    }
    uint32_t allocate (const char *str) {
        hash_node *hn = ptr(free_);
        uint32_t len = strlen(str) + 1;
        uint32_t node_size =
            1 + (len / sizeof(hash_node)) + !!(len % sizeof(hash_node));
        strcpy(hn->str(), str);
        free_ += node_size;
        return 1 + (hn - &page_[0]);
    }
};
哈希

表将包含一个节点存储和一个哈希桶向量。

struct hash_table {
    hash_node_store store_;
    std::vector<uint32_t> table_; /* holds allocated memory for buckets */
    uint32_t hash_func (const char *s) { /* ... */ }
    uint32_t find_at (uint32_t node_offset, const char *str);
    bool insert_at (uint32_t &node_offset, const char *str);
    bool insert (const char *str) {
        uint32_t bucket = hash_func(s) % table_.size();
        return insert_at(table_[bucket], str);
    }
    bool find (const char *str) {
        uint32_t bucket = hash_func(s) % table_.size();
        return find_at(table_[bucket], str);
    }
};

其中find_atinsert_at只是以预期方式实现的简单功能。

uint32_t hash_table::find_at (uint32_t node_offset, const char *str) {
    hash_node *hn = store_.ptr(node_offset);
    while (hn) {
        if (strcmp(hn->str(), str) == 0) break;
        node_offset = hn->next_;
        hn = store_.ptr(node_offset);
    }
    return node_offset;
}
bool hash_table::insert_at (uint32_t &node_offset, const char *str) {
    if (! find_at(node_offset, str)) {
        uint32_t new_node = store_.allocate(str);
        store_.ptr(new_node)->next_ = node_offset;
        node_offset = new_node;
        return true;
    }
    return false;
}

由于您只是插入值,因此字符串数据本身可以在插入时连接起来 - 每个都带有分隔符,例如 NUL。 该单个缓冲区中的字符偏移量唯一标识字符串。 这意味着共享公共子字符串的字符串集将完全冗余地单独指定,但反驳说,不会花费任何精力来查找或编码这种分解:这可能会适得其反高度不相关的字符串值(例如随机文本)。

要查找字符串,可以使用哈希表。 鉴于您的目标是避免频繁的动态内存分配,为了有效地处理冲突,您需要使用置换列表:这个想法是,当插入一个哈希到已使用的存储桶的字符串时,您可以添加一个偏移量(如有必要,环绕表)并尝试另一个存储桶,继续直到找到空存储桶。 这意味着您需要一个位移列表来尝试:您可以手动编码有限列表以帮助您入门,甚至可以在"大位移"列表上嵌套循环,该列表的值被添加到"小位移"列表中的值中,直到找到空桶,例如,两个手动编码的 10 个位移列表产生 100 种组合。 (可以使用替代哈希算法代替置换列表或与置换列表结合使用。 不过,您确实需要有一个合理的总桶与已用桶的比例......我希望 1.2 左右的东西通常可以正常工作,较大的值优先考虑速度而不是空间 - 您可以使用示例数据填充您的系统并根据口味进行调整。

因此,空间要求是:

total_bytes_of_string_data + N delimiters + total_buckets * sizeof(string_offset)

其中 sizeof(string_offset) 可能需要 8 个字节,因为 10^9 * 10 已经超过 2^32。

对于 ~10 个字符和 1.2*10^9 个存储桶的 10^9 字符串,这大约是 10^9 * (10+1) + 1.2*10^9 * 8 字节 = 20.6^10^9 字节或 19.1 GB。

值得注意的是,64 位虚拟地址空间意味着您可以安全地为串联字符串数据和哈希表分配比实际需要的更多的空间,并且只有那些实际访问的页面需要虚拟内存(最初是物理内存,但以后可以通过正常的虚拟内存机制交换到磁盘)。

讨论

如果没有对所用字符串数据或字符集中的重复性的假设/见解,就无法保证减少字符串内存使用量。

如果所有插入之后都进行了大量搜索,则对字符串数据进行排序并使用二进制搜索将是一个理想的解决方案。 但是,对于穿插搜索的快速插入,上述内容是合理的。

你也可以有一个基于平衡二叉树的索引,但为了避免每次插入的内存分配,你需要将很多节点分组到一个内存页中,并在不太精细的级别上手动管理它们的排序和拆分:实现起来很痛苦。 可能已经有一个图书馆在做,但我还没有听说过。

您已经添加了"AWK 中的关联数组"作为其用途的示例。 您只需将每个映射到的值紧跟在串联数据中的字符串键之后即可。

(低)误报率是否可以接受?如果是这样,那么布隆过滤器将是一种选择。如果您满足于百万分之一或 2^(-20) 的误报率,则需要使用大约是预期字符串数 30 倍的缓冲区大小,即 3*10^10 位。这不到4GB。您还需要大约 20 个独立的哈希函数。

如果您不能接受误报,您应该考虑在您构建的任何其他解决方案之前放置一个 Bloom 过滤器,以快速清除大多数负面。