【问题标题】:Why storing a tree as a contiguous chunk of memory?为什么将树存储为连续的内存块?
【发布时间】:2014-08-04 08:03:57
【问题描述】:

我刚刚发现有一些基于树的数据结构,在寻求高性能时,通常会存储为连续的内存块,这在使用所谓的“基于策略的数据结构”时特别受欢迎。

问题是我无法理解为什么要这样做;当您尝试“线性化”一棵树以将其存储为向量/数组时,如何确保以有意义的方式重新排列分支和叶子以提高性能?这仅适用于完美平衡的树木吗?

换句话说,我无法想象用于访问跨越多个级别并具有多个叶子的线性数据结构的模式;通常一棵树为每个节点/叶子添加 1 级间接,这为用户简化了很多事情,但是应该如何组织这种“线性”树呢?

【问题讨论】:

  • 连续存储通常可以显着提升性能。预取器可以去检索实际需要的内存。
  • 您是否曾经编写过最小(或最大)heap data structure?存储在连续内存中的二叉树通常用于此类事情。有关示例和存储要求,请参阅标准库提供的heap-operations 系列。
  • 只需为 x 个叶子保留一个内存块(如果需要,可以添加更多)。 Tree in an Array
  • OP:你必须有不同的想法:16 GB 的内存只是一个大的一维数组,我们几乎可以在其中存储任何东西!正是我们将数据存储在其中的方式以及将其排列成 2d、3d(或树)的方式。
  • 是否可以想象这个问题有一个“正确”的答案?

标签: c++ vector tree contiguous


【解决方案1】:

区分树和AVL tree 很重要。在您的问题中,您谈到了平衡树,因此您的问题可能是关于数组中的 AVL 树表示。

所有其他答案都是关于树而不是 AVL 树。据我所知,这种树可以用数组表示,但不能有效地更新,因为您必须重新排列数组的许多元素而不是使用内存指针。

这意味着只要输入元素已经排序,您就可以在数组中表示完美有序的平衡树。这棵树会比通常的内存树快,但更新它会“更难”。

我的结论是:

  • 具有大量访问权限的只读树 => 在数组中
  • 具有大量更改的可更新树 => 在内存中

【讨论】:

    【解决方案2】:

    实际上有很多这样的模式,它们有两个目的:节省内存和保持节点在一起,主要是为了分页性能。

    一个非常简单的版本是简单地分配三个块,一个父块和两个子块,这个块有四个“子”块,每个子块两个。这将您的分配减少了三分之一。这不是什么优化,直到你扩大它,分配 7、15、31、63 ......如果你能得到它,以便尽可能多的键适合单个内存系统页面,那么你最小化等待硬盘的时间。如果您的密钥是每个 32 字节,而一个页面是 4K,那么您最多可以存储 125 个密钥,这意味着您只需为树的每 7 行从硬盘驱动器加载一个页面。此时,您加载“子”页面,然后跟随另外 7 行。常规的二叉树每页可能只有一个节点,这意味着您在迭代树时花费 7 倍的时间等待硬盘驱动器。很慢。旋转有点棘手,因为您必须实际交换数据,而不是树实现中常见的指针。此外,当树变大时,使用大量空间也是一种浪费。

                   ---------
                   |   O   |
                   |  / \  |
                   | O   O |
                  _---------_
               __/    \ /    \__
              /       | |       \
    --------- --------- --------- ---------
    |   O   | |   O   | |   O   | |   O   |
    |  / \  | |  / \  | |  / \  | |  / \  |
    | O   O | | O   O | | O   O | | O   O |
    --------- --------- --------- ---------
    

    另一种更复杂的“模式”是将树垂直切成两半,因此顶部是一个“子树”,它有许多子“子树”,并将每个“子树”线性存储。然后你递归地重复这个。这是一个非常奇怪的模式,但最终的工作方式与上面的模式有点相似,除了它是“缓存遗忘”,这意味着它适用于 任何 页面大小或缓存层次结构。很酷,但它们很复杂,而且几乎所有东西都运行在三种众所周知的架构之一上,所以它们并不受欢迎。它们也非常难以插入/删除

    另一个非常简单的变体是将整个树放入通过 indecies 访问的数组中,这样可以节省总内存,但只有顶部是缓存友好的,较低级别的缓存比常规二进制文件更糟糕树。实际上,根在索引 i=0 处,左孩子在 (n*2+1 = 1),右孩子在 (n*2+2 = 2)。如果我们在索引为 24 的节点上,它的父节点是 ((n-1)/2 = 12),它的左右子节点分别是 49 和 50。这对小树非常有用,因为它不需要任何指针开销,数据存储为连续的值数组,并且通过索引推断关系。此外,添加和删除子节点总是发生在右端附近,并且适用于正常的二叉树插入/旋转/擦除。这些也有一个有趣的数学新颖性,如果将索引加一转换为二进制,则与树中的位置相对应。如果我们考虑索引 24 处的节点,二进制中的 24+1 是 11001 -> 第一个 1 始终表示根,从那里每个 1 表示“向右”,每个 0 表示“向左”,这意味着要从根目录到索引 24,您需要向右、向左、向左、向右,然后就到了。此外,由于有 5 个二进制数字,您知道它位于第五行。这些观察都不是特别有用,除了它们暗示根节点是一个右孩子,这有点有趣。 (如果您扩展到其他基础,则根始终是最右边的孩子)。话虽如此,如果您使用双向迭代器,将根实现为左节点通常仍然很有用。

         0
        / \
       /   \
      1     2
     / \   / \
    3   4 5   6
    
    [0][1][2][3][4][5][6]
    

    【讨论】:

      【解决方案3】:

      " ...尝试将树“线性化”以将其存储为向量/数组,您如何确保以有意义的方式重新排列分支和叶子以提高性能..."

      我相信你想太多了。

      在普通树中,您使用“new”请求创建节点的可用空间。

      您使用 delete 将不再需要的空间返回到堆中。

      然后使用指针连接节点。


      对于“向量中的树”,您可以简单地重新实现 new 和 delete 以在向量中查找空间。

      我认为您使用索引(指向父节点或左节点或右节点)而不是指针(指向父节点或左节点或右节点)。

      我相信向量中第 n 个项目的索引(在重新分配增长之前和之后)没有变化。

      另一个挑战是删除一个节点......但这可以简单到任何大于被删除节点的节点(或索引)减少 1。


      这些选择对于很少改变但必须尽快捕获的树来说是公平的交易。

      存储树真的需要向量块保存的性能吗?矢量块保存实际上是否比同一棵树的深度优先保存更快。你真的需要衡量。

      【讨论】:

      • 我在嵌入式系统中解决了这个问题。 “向量”实际上是在一个固定的、电池供电的静态内存中。一旦你把数据放在那里,它就已经被存储了。它仅在人工更改配置项时更新。
      【解决方案4】:

      你可能会觉得这篇短文here很有趣

      基本上,为这种结构使用连续内存块的原因是,在处理潜在的大型数据集时,它极大地改善了查找和扫描时间。如果你的内存是不连续的,你可能不得不使用昂贵的遍历算法来从数据结构中检索数据。

      希望这能满足您的兴趣。

      以下是文章中的两张图片来说明这一概念:

      平衡树

      存储在连续内存中的树:

      【讨论】:

      • 好吧,如果你从第 N 层转到第 N+1 层,这很有效,但如果你以对角线方式穿过树叶(这是一种非常流行的树木查找模式),这很可怕安排。
      • 一点也不,因为您可以使用任何给定级别的树中节点数的数学方程来确定性地计算对角线路径中每个节点的内存偏移量,这使您可以查看树使用直接内存寻址。然而应该注意的是,这并不是所有情况下的最佳解决方案,但对于大小受某个已知值限制的实现,或内存受限的机器,肯定会有所帮助。
      • 我可以计算出你的建议,但这并不意味着我会在缓存中找到所有内容,这是关于性能的重点,我可以确定在哪里可以找到 X 节点或叶子,但这并不意味着我的树会神奇地适合管道。加上这种基于向量的方法,当我向树中添加更多级别时,我需要的连续空间呈指数增长。但是当您说“大小受某个已知值的限制”时,您正在考虑的大小是多少?
      • 你的图片也显示了一个平衡的树,这是一个微不足道的例子,我从中得到的是,当你有一个平衡的树时,这种安排是有意义的(因为一个不平衡的树会提供更多不可预知的模式)并且你还有一个相对较小的树,因为如果你线性化一棵大树,在某些情况下你很容易得到很多缓存未命中。
      • @user2485710:这种安排同样适用于平衡和不平衡的树,您只需要有一些方法来标记哪些“节点”没有数据。这种安排实际上对小树很有效,但是对大树来说很糟糕。
      【解决方案5】:

      如何确保重新排列树枝和树叶 以有意义的方式提高性能?

      如果您的程序已经在运行(使用不连续的树),您可以随时检测您的程序以报告其实际的节点访问模式通常是什么样的。一旦您对节点的访问方式有了很好的了解,您就可以自定义节点分配器,以相同的顺序在内存中分配节点。

      【讨论】:

      • 如果行为是不确定的怎么办?那时为什么要使用树呢?如果我有恒定的模式,我不能只使用向量。
      • 如果访问模式是完全随机的,那么我认为将数据放在连续内存中对您没有帮助。但行为不需要完全确定;如果它足够规则以至于您可以看到一般模式(例如,查询从根节点向下递归,并且在每个节点上查询从第一个到最后一个检查子节点),那么您可以使用该信息以这样的方式对数据进行排序缓存未命中被最小化。
      【解决方案6】:

      在连续内存中存储数据结构是一种用于内存受限系统(例如嵌入式系统)的技术。该技术也可用于安全和性能关键系统。

      桌面系统通常具有大量内存,并且它们的应用程序寿命很短。他们动态内存分配的过程是在内存池中找到下一个可用块并返回。如果没有可用的内存(例如在碎片中),则分配失败。无法控制可以消耗多少内存。

      通过连续分配方法,可以限制或限制创建的节点数量。这意味着在具有 32k 内存的系统中,树不会用完所有内存并留下漏洞。

      使用连续系统的分配过程更快。你知道方块在哪里。此外,可以存储索引值,而不是存储链接的指针。这也允许将树存储到文件中并轻松检索。

      您可以通过创建节点数组或向量来对此进行建模。更改节点数据结构以使用数组索引而不是指针。

      请记住,了解性能问题的唯一方法是分析。

      【讨论】:

      • 我对模式感兴趣,对树的内部“线性化”为向量感兴趣,向量是一维数据结构,一棵树是二维的,再加上一棵树,它不一定是平衡的。你在写有趣的东西,但这不是我感兴趣的,我正在寻找使用树作为向量或反之亦然的原因。
      猜你喜欢
      • 2011-05-02
      • 2014-10-29
      • 2017-01-19
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2022-01-06
      相关资源
      最近更新 更多