【问题标题】:Is it bad practice to maintain two copies of the same data, if they are in different data structures?如果它们位于不同的数据结构中,维护相同数据的两个副本是不好的做法吗?
【发布时间】:2017-11-07 08:51:58
【问题描述】:

假设我有一组通用的索引对象 U 和这些对象的子集 SS 很大(例如,1,000,000 个元素),但 U 更大(例如,至少 100,000,000 个)。

我想对这些集合执行两个基本操作:

(1) 给定任意整数x,从0到U的大小减1,检查S的成员关系,如果不是成员,则将x添加到S,然后

(2) 从S 中选择(并删除)一个随机元素。

为了执行操作 (1) 的第一部分,对我来说保留一个大小为 U 的布尔向量 v 是有意义的,如果元素 x 是集合S的成员。

但是,因为US 大得多,所以在v 中选择一个随机元素并希望它也是S 中的一个元素是没有意义的。如果US 大100 倍,那么它只会找到S 的元素,平均每100 次尝试一次。

因此,为了执行第二个操作,维护S 中元素的索引列表并从中选择一个随机元素是有意义的。

现在唯一的问题是,现在有两个相同数据的副本,并且每次操作都需要分别更新它们。这是第一个操作的伪代码:

** operation 1 - check membership and add **
input: boolean vector, v
       integer vector, S
       integer, x

if v[x] is not true:
    v[x] = true
    append x to S
return

这相对简单,但它必须更新索引向量,即使它没有使用它。这是第二个操作:

** operation 2 - select and remove random element of S **
input: boolean vector, v
       integer vector, S

generate random integer x between 0 and size of S
set v[S[x]] to false
remove S[x] from S
return

维护数据的两个副本使这两个操作变得更加复杂,因为每个都必须更新两个数据结构,即使它只需要一个。这是不好的做法吗?

我能想到的唯一选择是使用其中一个。但这使一种操作更简单,而另一种则更复杂。例如(只给出比较复杂的):

** operation 1 - check membership and add**
input: integer vector, S
       integer, x

iterate over S
if x in S:
    return
else:
    append x to S
    return

所以每次都必须遍历整个S,而不是单次查找,并且

** operation 2 - select and remove random element of S **
input: boolean vector, v

while true:
    generate random integer x between 0 and size of S
    if v[x] true:
        v[x] = false
        return

这两种方法似乎都非常低效,特别是如果US 的大小很大,并且US 之间的差异也很大。有没有一种方法可以只用一个数据结构有效地执行这两项操作?或者维护同一事物的两个副本真的没有什么大问题吗?

编辑:

我正在编写的代码是用 c++ 编写的,所以我想我是在特别询问 c++ 数据结构,但这个问题并不是特定于语言的。

【问题讨论】:

  • std::set<int> 应该能够在log N 中执行任一操作
  • 啊!这真是个好消息。我原以为那套东西太麻烦了。如果我还必须在不插入或删除任何内容的情况下进行大量成员资格查找,std::set<int> 仍然可以吗?或者我会更好地为查找维护一个布尔向量? O(log N)O(1) 之间的区别对于大型 N 来说是相当大的!
  • 按照 Bjarns S. 的说法,一个类是“拥有不变量”的东西。诚然,您不想最小化范围,但这实际上是一种设计原则,以保持数据之间的一致性,根据它们自己的类型,这些数据可能具有无效值。这是封装。
  • 您需要在内存和时间复杂度之间进行权衡。

标签: c++ performance data-structures


【解决方案1】:

我认为这 3 种方法都没有(主要)问题。在决定其中之一时,您必须考虑:

  • 代码可读性
  • 代码可维护性
  • 性能

代码可读性

理解代码的作用是多么容易和直观。代码不应有任何令人惊讶的行为。

如果使用良好的命名和清晰的结构化代码,这三个都可以相当具有同等的可读性。

代码可维护性

调试、测试、扩展代码是多么容易。

具有两个结构的变体的成本略高。但只是轻微的。与其他人相比,我认为复杂性并不多。您可以在单元测试中进行测试,以检查方案的完整性。 IE。检查布尔向量和整数向量是否同意 S 是什么。

性能

您可能整天都在假设什么变体以及速度快多少,但归根结底,如果没有实际的分析,任何关于性能的讨论都是毫无意义的。如果性能对您来说是一个重要因素,那么请实现所有 3 种方法并衡量它们的实际性能。

【讨论】:

  • 谢谢,我会按照上面的建议尝试使用std::set<int>,并维护一个布尔向量进行查找并进行一些性能测量。我的主要领域是数学,而不是编程,我只是担心为相同的数据维护两个数据结构可能是一个明确的编码禁忌!
【解决方案2】:

经过一番考虑,我认为std::map 可能是最合适的。

#include <random>
#include <iterator>
#include <map>
#include <vector>

struct Data {};                           // Your actual Object here

constexpr auto universal_size = 100'000;  // I shrank it a little for
constexpr auto subset_size = 1'000;       // the example

std::vector<Data> U(universal_size);      // This is the indexed data store

std::map<int, Data*> S;                   // This is the subset

void add_if_not_in(int idx)               // idx is universal index.
{                                         // This is one of the
    S[idx] = &U[idx];                     // functionalities you
}                                         // requested.

void remove_by_universal_index(int idx)   // Not strictly needed.
{                                         // Removes object from 
    S.erase(idx);                         // subset, by universal
}                                         // index.

void remove_by_subset_index(int idx)      // Removes object from
{                                         // subset, by subset
    auto iter = S.begin();                // index. Used by 
    std::advance(iter, idx);              // remove_random()
    S.erase(iter);
}

std::mt19937 gen{};                       // A random generator

void remove_random()                      // The second functionality
{                                         // you requested.
    auto sz = S.size();                   // Removes one random element
    std::uniform_int_distribution<>       // from the subset.
        dis(0, sz-1);
    auto num = dis(gen);

    remove_by_subset_index(num);
}

void add_random()                         // Used to initialize subset.
{                                         // Adds one random element of
    auto sz = U.size();                   // universal set to subset.
    std::uniform_int_distribution<>
        dis(0, sz-1);
    auto idx = dis(gen);

    add_if_not_in(idx);    
}

void setup()                              // Initialize subset.
{                                         // Just add random until
    while (S.size() < subset_size)        // size is specified.
        add_random();
}

int main()                                // Try it 
{
    setup();
    add_random();
    remove_random();
}

在这里在线尝试代码http://coliru.stacked-crooked.com/a/8236da0ccaf05079

【讨论】:

  • 这看起来是一个很好的解决方案,虽然我的问题再次是,如果我还有很多成员资格查找要做(即,元素 xS 中)没有任何替换或删除,这是否会改变这是否是一个更好的解决方案? std::map::find 具有复杂性 O(log N),而 std::vector::operator[] 具有复杂性 O(1),这与大型 N 有很大不同。也许维护查找的布尔向量会更好,但是我肯定会使用std::map 进行添加和删除操作!非常感谢!
  • 性能考虑只能通过测量来确定。当然,O(1) 比 O(log n) 好,但 log n 增长非常缓慢,比例如 √n 好得多。
  • 如果您正在分析,也可以尝试 unordered_map 而不是 map。有时会产生很大的影响
【解决方案3】:

如果你不介意卷起袖子,我推荐一个“稀疏分层位集”(我能想到的最好的名字——我不擅长命名)。大意是这样的:

我使用这种结构作为std::set&lt;int&gt; 的一般替代品,因为至少对于我的一些用例(看起来与您的类似,并且具有数亿类似的输入大小)。自然而然,与平衡二叉树(如 15 兆字节和 2 吉字节之间的差异)或存储整数的哈希表相比,这些类型的输入大小会占用更少的内存。毕竟,一个 64 位整数可用于指示多达 64 个索引值的数据,而不是一个。

它可以在 3-4 次迭代中设置几亿个密集元素之间的交集(最好情况下是 log(N)/log(64) 次迭代,最坏情况下是 N*log(N)/log(64) -case),并且在不到一纳秒的时间内。同时,因为它是稀疏的,它只存储一个空指针并避免为完全未使用的索引范围分配内存,所以它不会占用爆炸性的内存量(对于集合内的每个索引,通常平均大约 1-2 位) )。希望该图为如何实现它提供了足够好的线索。设置的交叉点实际上并没有被存储(否则它会更加昂贵)。相反,数据结构会像这样调用回调:

// Indicates that [first, last) are in the resulting set.
typedef void SetResults(int first, int last, void* user_data);

...不能使用仿函数,因为它是用 C 实现的。当然,这种结构有一个明显的弱点,那就是当你在集合中没有很多连续的索引范围时——最坏的情况是每个索引都是奇数,或者每个索引都是偶数,此时根节点将存储一堆 0,并且每次搜索都需要深入到叶子并检查各个位的各个索引,从而达到最坏的 - case N*Log(N)/Log(64) 场景来做诸如设置交叉点之类的事情。然而,对于我的用例,索引往往是连续的,即使在最坏的情况下,当执行诸如集合交集和检查集合中的元素之类的事情时,它仍然优于std::set&lt;int&gt;。对于最坏的情况,它通常仍然比std::set 快一个数量级。对于最好的情况,这些类型的输入大小往往快数百万倍。对于一般情况,它往往快数千倍,例如毫秒和秒之间的差异。

如果这需要太多时间来实现,那么我的第二个建议只是一个稀疏的位集,而不必费心使其分层。存储位集块(例如,指向数组的指针数组)。当一个块中的所有位都设置为 0 时,释放该块并将其指针设置为空,以避免在整个索引范围内占用比指针更多的内存。这实现起来很简单,而且速度仍然很快,因为它可以使用按位指令一次比较多个位。

现在,当您像第 1 步一样执行集合并集时,您所做的只是并行遍历两个集合中占用的块并执行按位或。如果您使用 SIMD,您可以一次对 64 个元素(64 位)进行集合并集(例如:一次 512 个元素),甚至更多。这是一个线性时间集合并集,但值得注意的是,空块可以让您一次跳过 1024 个未使用的位,而当您遇到两个集合中都被占用的块时,您可以使用按位运算来执行并集 64 + 位。

对于#2,首先使用bitwise and 计算US 中的一组索引交集(即使只有稀疏的位集也非常快:最初使用S 进行迭代,因为它要小得多放)。然后使用随机数生成器为生成的交集中的随机位块生成索引。那么你可以从一个随机位开始使用FFS/FFZ。

简单的图表来说明:

...但当然每个块有超过 3 位(实际上,每个分配的块可能有 1024+ 位)。对于集合交集,我们从S 开始,看到块 0 是空的,所以我们跳过它。然后我们查看第 1 块,发现它不为空。

所以现在我们查看 U 中的块 1 并且还看到它不为空。好的,现在我们执行bitwise and 来获取SU 中的结果位。那是你设定的交叉点。它在技术上是线性时间,但在最坏的情况下更像是 O(N/64+)(当它可以跳过许多完全空的块时更好),而不是 O(N),因为我们可以对许多位进行按位运算一次(在 64 位机器上轻松一次 64 位,使用 SIMD 则更多)。

所以,为了执行第二个操作,有意义的是 维护 S 中元素的索引列表并选择一个 随机元素。

使用上述数据结构可以消除这种需求。至于关于最佳实践的一般问题,如果您需要另一种数据结构,那么您需要另一种数据结构。有些人说并行数组很糟糕,尽管它们通常是顺序处理(SoA 方法)的最快解决方案,例如,因为您必须使它们与源数组保持同步。但是你总是可以把它放在一个类后面并很好地测试它。只要您不泄露手动维护这些并行数组与系统中许多地方同步的需要,这还不错。

但是,在这种情况下,如果您使用上面建议的两种数据结构之一,则不需要维护单独的随机访问索引序列来有效地找到两个集合中都存在的随机索引,因为您可以根据需要快速找到设定的交叉点。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-11-16
    • 1970-01-01
    • 2020-12-29
    • 1970-01-01
    相关资源
    最近更新 更多