【问题标题】:What is the most efficient way to populate a red black tree?填充红黑树的最有效方法是什么?
【发布时间】:2018-10-30 13:18:51
【问题描述】:

假设我知道关于某个数据集的所有信息以及它进入的控制顺序——将它组织成一棵红黑树的最有效方法是什么?

或者,在流行的std::set/map 实现(基于“红黑树”)的上下文中——用上述数据集填充我的std::set 的最有效方法是什么?

在你回答之前,请考虑一下:

  • afaik,红黑树具有廉价的 O(1)(正确提示)插入...除非树深度超出特定限制,在这种情况下,它将被重新平衡(使用 O(log N) 成本)-就像std::vector::push_back() 的情况一样,我们最终得到了摊销常数复杂度

  • 例如如果数据集是一个值列表 [0,999],则应该有一系列提示插入,这些插入永远不会触发重新平衡(即保持每个插入 O(1))。

非常琐碎的例子(需要弄清楚如何选择这些 YYY/ZZZ 值):

std::set<int> s;
std::vector< std::set<int>::iterator > helper(1000);

helper[0] = s.insert(0);
helper[1] = s.insert(helper[0], 1);
//...
helper[500] = s.insert(helper[YYY], 500);
//...
helper[999] = s.insert(helper[ZZZ], 999);

我在寻找什么:

  1. 一种算法,允许我使用(特别)准备好的(任意长的)序列填充(基于“红黑树”)std::set,其中每个插入保证 O(1)

  2. 应该有一种方法可以减少额外的内存需求(即helper 的大小)或在理想情况下消除对它的需求

  3. 一种在最坏情况下填充树的算法(以了解传入数据集应该不是什么样子)——这是我们最终得到最大可能数量@的情况987654327@事件

  4. 奖励目标是为基于“AVL 树”的std::set

  5. 获得问题 1-3 的答案

谢谢

【问题讨论】:

  • 插入初始数据后,您的地图和矢量会发生变化吗?
  • @HolyBlackCat 我希望优化初始种群,之后将丢弃所有其他结构(例如 helper 向量)。之后 std::set/map 会发生什么无关紧要,但您可以假设对您来说最方便的方式
  • @alfC 传入的数据可以按照您想要的任何方式进行准备/排序,您可以假设它完全在内存中(如果有帮助的话)。我正在考虑与您的建议类似的东西,但它太模糊了,需要额外的内存(没有便宜的std::set::middle(it1, it2)),也许有更好(更正式)的算法
  • @C.M.这很重要,因为如果容器在创建后没有更改,则根本不应该使用地图/集。它们可以替换为普通的排序数组(可通过二分搜索进行搜索)。
  • 也许可以试试远程构造函数。如果输入范围已经排序,则它们是线性的。

标签: c++ algorithm data-structures red-black-tree


【解决方案1】:

找到一种不需要额外内存且适用于任何二叉搜索树(红黑/AVL/等)的算法:

  1. 表示“扁平化”二叉树的传入数据数组(根位于 [0],根子位于 [1] 和 [2],左节点子位于 [3] 和 [4],右节点子位于 [5] ] 和 [6] 等)。诀窍是选择每个子树的根,使得生成的二叉树的每个 lvl(但最后一个)都被填充,并且在最后一层,所有节点形成一条“不间断”的线。像这样:

         N11
       /     \
     N21      N22
     / \      /
    N31 N32 N33
    

请参阅下面的代码,了解如何将已排序的数组转换为这样的序列。我相信对于任何序列,只有一种可能的方法可以将它排列在这样的二叉搜索树中——即你最终会在这里得到某种“稳定性”保证(对于给定的序列长度,我们确切地知道每个元素将在哪里结束在树上)。

  1. 然后您对数据执行一次遍历并逐级填充您的树。在每个级别,我们确切地知道在切换到下一个 lvl(或用完数据)之前要提取多少元素 (2^(lvl-1))。在每次迭代开始时,我们将位置重置为最左边的元素(std::set&lt;T&gt;::begin()),在插入左右子元素后,我们移动到当前级别的下一个叶子(从最后一次insert() 调用的结果中加倍++it)。

注意事项:

  • 具有std::set&lt;int&gt; 性能优势(与排序序列的暗示插入相比为 5-10%)

  • 不幸的是,MS 红黑树实现最终在这里执行了许多不必要的工作——检查相邻元素(以确保插入不会破坏二叉树不变量),重新绘制节点(由于某种原因新插入的节点总是红色的),可能还有其他东西。检查邻居涉及额外的比较和内存访问以及遍历树的多个级别

  • 如果这种方法在内部实现(不使用std::set 公共接口)作为期望数据符合要求并在不符合要求时声明“未定义行为”的函数,则该方法的好处将显着提高。 ..

  • ... 在这种情况下,更好的算法会优先填充树深度,并且需要以不同的方式重新排列输入数据(上例中的[N11, N21, N31, N32, N22, N33])。我们最终也将只进行一次树遍历...唉,虽然不能使用std::set 公共接口实现这种方法——它将在构造的每个步骤中强制执行红黑树不变量,从而导致不必要的重新平衡

代码:(MSVC 2015,请原谅马铃薯的质量——它是在一个小时内写在膝盖上的)

#include <set>
#include <cassert>
#include <vector>
#include <utility>
#include <chrono>
#include <cstdio>


using namespace std;


unsigned hibit(size_t n)
{
    unsigned long l;
    auto r = _BitScanReverse(&l, n);
    assert(r);
    return l;
}


int const* pick_root(int const* begin, int const* end)
{
    assert(begin != end);
    size_t count = end - begin;

    unsigned tree_order = hibit(count);         // tree height minus 1
    size_t max_tail_sz = 1 << tree_order;       // max number of nodes on last tree lvl
    size_t filled_sz = max_tail_sz - 1;         // number of nodes on all but last tree lvl
    size_t tail_sz = count - filled_sz;         // number of nodes on last lvl

    return (tail_sz >= max_tail_sz/2) ?         // left half of tree will be completely filled?
        begin + max_tail_sz - 1                 // pick (2^tree_order)'s element from left
        :
        end - max_tail_sz/2;                    // pick (2^(tree_order - 1))'s element from right
}


vector<int> repack(vector<int> const& v)
{
    vector<int> r; r.reserve(v.size());
    if (!v.empty())
    {
        unsigned tree_order = hibit(v.size());  // tree height minus 1

        vector<pair<int const*, int const*>> ranges(1, make_pair(&v[0], &v[0] + v.size()));
        for(size_t i = 0; i <= tree_order; ++i)
        {
            vector<pair<int const*, int const*>> ranges2; ranges2.reserve(ranges.size()*2);

            for(auto const& range: ranges)
            {
                auto root = pick_root(range.first, range.second);
                r.push_back(*root);

                if (root != range.first)
                {
                    ranges2.push_back(make_pair(range.first, root));

                    if (root + 1 != range.second)
                        ranges2.push_back(make_pair(root + 1, range.second));
                }
            }

            ranges.swap(ranges2);
        }
        assert(ranges.empty());
    }
    return r;
}


set<int> populate_simple(std::vector<int> const& vec)
{
    set<int> r;
    for(auto v: vec) r.insert(v);
    return r;
}


set<int> populate_hinted(std::vector<int> const& vec)
{
    set<int> r;
    for(auto v: vec) r.insert(r.end(), v);
    return r;
}


set<int> populate_optimized(std::vector<int> const& vec)
{
    set<int> r;
    if (vec.empty()) return r;

    int const* p = &vec[0];
    int const* pend = &vec[0] + vec.size();

    r.insert(*p++);                   // take care of root
    if (p == pend) return r;

    for(size_t count = 1; ; count *= 2) // max number of pairs on each tree lvl
    {
        auto pos = r.begin();

        for(size_t i = 1; ; ++i)
        {
            r.insert(pos, *p++);
            if (p == pend) return r;

            //++pos;            // MS implementation supports insertion after hint

            pos = r.insert(pos, *p++);
            if (p == pend) return r;
                            // pos points to rightmost leaf of left subtree of "local" tree
            ++pos;          // pos points to root of "local" tree (or end())

            if (i == count) break;

            ++pos;      // pos points to leftmost leaf of right subtree of "local" tree
        }
    }
}


struct stopwatch
{
    chrono::high_resolution_clock::time_point start_;

    stopwatch() : start_(std::chrono::high_resolution_clock::now()) {}

    auto click()
    {
        auto finish = std::chrono::high_resolution_clock::now();
        auto mks = std::chrono::duration_cast<std::chrono::microseconds>(finish - start_);
        return mks.count();
    }
};


int main()
{
    size_t N = 100000;
    vector<int> v(N, 0);
    for(unsigned i = 0; i < N; ++i) v[i] = i;   // sorted array

    auto rv = repack(v);

    {
        stopwatch w;
        auto s = populate_simple(v);
        printf("simple   : %I64d mks\n", w.click());
    }

    {
        stopwatch w;
        auto s = populate_hinted(v);
        printf("hinted   : %I64d mks\n", w.click());
    }

    {
        stopwatch w;
        auto s = populate_optimized(rv);
        printf("optimized: %I64d mks\n", w.click());
    }

    return 0;
}

典型结果:

simple   : 14904 mks
hinted   : 7885 mks
optimized: 6809 mks

simple   : 15288 mks
hinted   : 7415 mks
optimized: 6947 mks

我很确定测量值并不完全准确,但关系始终存在——优化版本总是更快。另外,请注意,用于重新排列元素的算法可能会得到改进——目的是优化树种群(而不是输入数据准备)。

【讨论】:

    【解决方案2】:

    首先,对输入进行排序。

    理想的做法是将排序后的输入放入平衡二叉树中,但可以假装它在树中;它只需要更多的簿记。它实际上不必是真正的树数据结构;您可以使用一个数组,其中根为元素 0,元素 i 的子元素位于 2i+1 和 2i+2。在任何情况下,树都可以递归构建。

    一旦您拥有原始数据的平衡二叉树,您需要将其复制到集合中,而不会导致任何重新平衡。为此,请对树进行广度优先扫描(如果您使用上面提到的数组,这只是对数组的顺序扫描,这使得这一步非常容易)。您可以在 BFS 中保存每个级别的插入点,以便获得下一个级别的提示(因此您需要能够将迭代器保持在树的一半左右),但是这样做会更容易并且可能更快在构建时遍历集合,从每个级别的开头开始,否则在每次插入后前进两个元素。

    这一切都比按顺序构建集合更快。但这是问题的答案。

    对于最差的提示插入群体,以反向排序顺序插入元素,用前一个插入点的插入点提示每个插入。

    我认为同样的算法也适用于 AVL 树。

    【讨论】:

    • 我需要稍微玩一下这个算法。但我敢打赌它可以改进——例如,我们可能可以通过利用重新平衡在树深度 > 2*log N(或任何那个数字)时开始的事实来保留不到一半的插入迭代器。 .我正在考虑排序数组树,其中每个数组被插入到树的不同部分而不触发重新平衡。另一个注意事项 - 在最坏的情况下假设插入仍然正确提示......即问题是“如何通过正确提示的插入序列最大化再平衡成本”
    • ... 这个问题的答案可能是“insert(myset.end(), n) 调用的序列”——因为在这种情况下,我们总是插入到二叉树的最右边的分支中,使树的深度增长最大化......究竟是什么我通常在用预先排序的数据填充 std::set 时这样做。 :-\ 那是除非实现专门优化了这种情况(@alfC 可能指的是)。
    • @c.m.:如果你正在构建一个红黑树并且你允许访问内部结构,那么有一个更简单、更有效的算法,尽管它也是从对初始值进行排序开始的。如果您知道(或发现)数据已排序,则可以避免排序。一旦您按顺序排列了元素:通过递归构建前半部分的树,从排序列表构建平衡树;然后是下半场;然后加入中间元素(不是任何一半的一部分)作为根...
    • 然后将所有节点着色为黑色,除了最后一个完整行中具有子节点的节点。对于递归堆栈,您可以在 o(n) 时间和 o(log n) 空间内完成所有这些操作,并且通过一点聪明,您可以摆脱堆栈,使其成为 o(1) 空间。唯一花费 o(n log n) 的就是初始排序,如果需要的话。
    • 另外:我不会为提示保留迭代器向量。正如我所说,您可以通过遍历正在构建的树来找到它们,这是线性时间(而且非常快)。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2011-08-12
    • 1970-01-01
    • 2011-08-22
    • 2014-06-23
    • 1970-01-01
    • 2021-07-27
    • 2010-11-20
    相关资源
    最近更新 更多