如何高效地获取std::set的中间值(中位数)?

16

std::set 是一种有序树。它提供了 beginend 方法,所以我可以获取最小值和最大值,并且提供了 lower_boundupper_bound 用于二分搜索。但如果我想要获取指向中间元素的迭代器(如果有偶数个元素,则其中之一)怎么办?

有没有一种高效的方法(O(log(size)) 而不是 O(size))来实现这个目标?

{1} => 1
{1,2} => 1 or 2
{1,2,3} => 2
{1,2,3,4} => 2 or 3 (but in the same direction from middle as for {1,2})
{1,312,10000,14000,152333} => 10000

提示: 同样的问题有俄语版本。


排序二叉树可能是std::set的实现细节,但这并非必须。如果您需要排序数组或二叉树,则最好使用您需要的内容。 - Öö Tiib
@ÖöTiib,我需要动态插入元素并获取集合的中间值。排序后的数组/向量会导致插入变为O(n),但我希望插入和查询都能以O(lb(n))的速度工作。我知道使用隐式键的Decart树可以实现这一点,但我不想实现它,而是希望std::set足够好以实现这一点。 - Qwertiy
在大多数情况下,由于高速缓存局部性,向向量插入数据将非常快。std::set,以及链表,使用指向散布在各处的子元素的指针,因此在许多情况下可能会更慢。请参阅以下内容:为什么您永远不应该再次在代码中使用链表Bjarne Stroustrup: 为什么应该避免使用链表列表是邪恶的吗? - phuclv
你真的需要有排序过的元素吗?还是只需要最小值、最大值和中位数?如果是后者,请考虑使用std::nth_elementstd::vector - D Drmmr
@DDrmmr,我只需要中间值,但需要用对数来获取它,不需要完全扫描。目前我认为保持相应的迭代器是最好的想法。 - Qwertiy
6个回答

20

根据您插入/移除项与查找中间/中位数的频率,比明显做法更高效的可能解决方案是保持对中间元素的持久迭代器,并在插入/删除集合中的项时更新它。这将需要处理一堆边缘情况(奇数和偶数项,移除中间项,空集等),但基本思想是,当您插入小于当前中间项的项时,您的中间迭代器可能需要减少,而如果您插入较大的项,则需要增加。从移除的角度看则相反。

在查找时,这当然是O(1),但每次插入/删除也实质上具有O(1)成本,即N次插入后的O(N),需要分摊到足够数量的查找中,才能使其比暴力方法更高效。


10

这个建议非常巧妙,但如果存在重复项,则会失败

根据你插入/删除项目与查找中间/中位数的频率,可能比显而易见的解决方案更有效的解决方案是保持对中间元素的持久迭代器,并在插入/删除项目时更新它。需要处理一堆边缘情况(奇数 vs 偶数数量的项目,删除中间项目,空集等),但基本想法是当您插入小于当前中间项的项目时,您的中间迭代器可能需要递减,而如果您插入大于当前中间项的项,则需要递增。删除操作则相反。

建议

  1. 第一个建议是使用std::multiset而不是std::set,这样可以很好地处理重复项
  2. 我的建议是使用两个multiset来跟踪小部分和大部分并平衡它们之间的大小

算法

1. 保持集合平衡,使size_of_small == size_of_big或size_of_small + 1 == size_of_big

void balance(multiset<int> &small, multiset<int> &big)
{
    while (true)
    {
        int ssmall = small.size();
        int sbig = big.size();

        if (ssmall == sbig || ssmall + 1 == sbig) break; // OK

        if (ssmall < sbig)
        {
            // big to small
            auto v = big.begin();
            small.emplace(*v);
            big.erase(v);
        }
        else 
        {
            // small to big
            auto v = small.end();
            --v;
            big.emplace(*v);
            small.erase(v);
        }
    }
}

2. 如果集合是平衡的,中位数总是大集合的第一个项目

auto medium = big.begin();
cout << *medium << endl;

3. 添加新项目时要谨慎

auto v = big.begin();
if (v != big.end() && new_item > *v)
    big.emplace(new_item );
else
    small.emplace(new_item );

balance(small, big);

复杂性解释

  • 查找中位数的时间复杂度为 O(1)。
  • 添加一个新项的时间复杂度为 O(log n)。
  • 虽然你需要搜索2个集合,但仍可以在 O(log n) 的时间复杂度内搜索到一个项目。

添加的时间复杂度是O(log(n))而不是O(n)。无论如何,对于我来说保持中位数运作良好。 - Qwertiy
对我来说,你回答了“如何有效地获取std :: multiset的中间值(中位数)”这个问题,因为std :: set如果存在重复项,就不会“失败”,因为按定义它不能有这样的。我建议你创建一个关于std::multiset的新问题,并将此答案移动到那里。 PS.版主可以在不丢失分数的情况下在问题之间移动答案。 - R2RT

9

获取二叉搜索树的中间节点的时间复杂度为O(size)。您可以使用std::advance()来获取,具体方法如下:

std::set<int>::iterator it = s.begin();
std::advance(it, s.size() / 2);

我认为Martin的意思是O(height),其中平衡二叉树的高度对应于树的大小的对数。 - chepner
5
@chepner,不对,std::advance 在这种情况下只是调用 ++ 对应的次数。 - Qwertiy
我可以简单地使用一个数组而不是使用这种方法。为什么要建议这样做呢? - Robert Page
因为这是提问者所问的问题。 - user325117

5

请注意,std::set不会存储重复的值。如果您插入以下值{1, 2, 3, 3, 3, 3, 3, 3, 3},您将检索到的中位数是2

std::set<int>::iterator it = s.begin();
std::advance(it, s.size() / 2);
int median = *it;

如果想在计算中位数时包括重复项,可以使用 std::multiset(例如:{1, 2, 3, 3, 3, 3, 3, 3, 3} 的中位数为 3):

std::multiset<int>::iterator it = s.begin();
std::advance(it, s.size() / 2);
int median = *it;

如果你仅仅是为了获取中位数而需要数据排序,我认为使用普通的 std::vector + std::sort 更好。

在进行大样本测试和多次迭代后,我使用 std::vectorstd::sort 完成测试只用了5秒钟,而使用 std::setstd::multiset 则需要13到15秒。当然,实际表现会因数据大小和重复值数量的不同而有所差异。


它与我的问题有什么关系? - Qwertiy
1
我认为在大多数情况下,当你想要中位数时,你希望从完整的数据集中获取它,而不是唯一值的子集。我犯了这个错误,所以我想提到std::multiset,以防止像我这样的人犯同样的错误。但你是对的,它并没有直接回答问题。但是,在次要答案中提供更多信息不会有害吧? - Norgannon

2

正如 @pmdj 所说,我们使用迭代器来跟踪中间元素。以下是以下内容的代码实现:

class RollingMedian {
public:
multiset<int> order;
multiset<int>::iterator it;
RollingMedian() {
}

void add(int val) {
    order.insert(val);
    if (order.size() == 1) {
        it = order.begin();
    } else {
        if (val < *it and order.size() % 2 == 0) {
            --it;
        }
        if (val >= *it and order.size() % 2 != 0) {
            ++it;
        }
    }
}

double median() {
    if (order.size() % 2 != 0) {
        return double(*it);
    } else {
        auto one = *it, two = *next(it);
        return double(one + two) / 2.0;
    }
}  };

随意复制并使用此代码的任何部分。如果没有重复,也可以使用set而不是multiset。


-1
如果你的数据是静态的,那么你可以预先计算并且不插入新的元素 - 使用向量会更简单,对其进行排序,通过索引访问中位数仅需O(1)。
vector<int> data;
// fill data
std::sort(data.begin(), data.end());
auto median = data[data.size() / 2];

但是你无法在O(1)时间内获得中位数。 - ALEXANDER KONSTANTINOV

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