如何给定一组预定的键,重新排序键,以便在插入 B-Tree 时使用最少数量的节点?

Posted

技术标签:

【中文标题】如何给定一组预定的键,重新排序键,以便在插入 B-Tree 时使用最少数量的节点?【英文标题】:How to, given a predetermined set of keys, reorder the keys such that the minimum number of nodes are used when inserting into a B-Tree? 【发布时间】:2014-09-19 17:25:22 【问题描述】:

所以我有一个我很确定可以解决的问题,但是经过许多小时的思考和讨论,只取得了部分进展。

问题如下。我正在构建一个可能包含几百万个键的 BTree。在搜索BTree时,是从磁盘按需分页到内存中的,每一个页在操作中的开销都比较大。这实际上意味着我们希望需要遍历尽可能少的节点(尽管在遍历一个节点之后,遍历该节点直到该节点的成本为 0)。因此,我们不想让大量节点接近最小容量来浪费空间。理论上,这应该是可以避免的(在合理范围内),因为树的结构取决于插入键的顺序。

因此,问题是如何重新排序键,以便在构建 BTree 后使用最少数量的节点。这是一个例子:

我确实偶然发现了这个问题In what order should you insert a set of known keys into a B-Tree to get minimal height?,不幸的是,它提出了一个稍微不同的问题。答案,似乎也没有解决我的问题。还值得补充的是,我们希望通过不手动构建树而仅使用插入选项来获得数学保证。我们不想手动建一棵树,犯了错误,然后发现它是不可搜索的!

我还偶然发现了 2 篇研究论文,它们非常接近解决我的问题,但还没有完全解决! B-Trees 中的时间和空间最优性Optimal 2,3-Trees(我实际上是从上面的图像中提取的)讨论和量化空间最优之间的差异和空间 pessimal BTrees,但据我所知,不要描述如何设计插入顺序。

对此的任何帮助将不胜感激。

谢谢

研究论文可在以下网址找到:

http://www.uqac.ca/rebaine/8INF805/Automne2007/Sujets2007Automne/p174-rosenberg.pdf

http://scholarship.claremont.edu/cgi/viewcontent.cgi?article=1143&context=hmc_fac_pub

编辑:: 我最终用 FILLORDER 算法填充了如上述论文中所述构造的 btree 骨架。如前所述,我希望避免这种情况,但我最终在发布 2 个优秀答案之前实施了它!

【问题讨论】:

您可以通过对项目进行排序并使用该排序列表构建树来非常轻松地批量构建 B 树。你甚至不需要一一插入。 恐怕我不太明白。就我所见,按排序顺序插入项目肯定不会产生空间最佳的 BTree? 算法在插入项目时不起作用。您对列表进行排序,然后从该列表构建叶节点。现在你有了完美的叶子节点。接下来,您在这些叶节点之上构建附加层。 RDBMS 所做的是它们在末尾有一个特殊的插入规则。他们不会平均拆分页面,而是创建一个新的空白页面。不要太从字面上理解 b-tree 算法(来自***)。你可以和他们一起玩。 哦,我明白了。我非常不愿意从叶子上构建一棵树,尽管那时可能会引入一些我们没有预见到的特殊百万分之一的错误,并最终创建一些无效的不可搜索的 B-Tree 这将超级,超级有问题。实际上,我实际上并不担心插入后记。构建完成后,我们真的不需要对其进行任何插入。 如果不确切知道插入操作的工作原理,这将很难回答。我假设您有一些黑盒实现,允许您插入一个项目,然后以某种方式将其放置在树中?那么答案将取决于算法何时决定分割块,以及它在何时分割它们。 【参考方案1】:

这是一种可以在任何 BST(包括 b 树)中实现最小高度的方法:-

    排序数组 假设你可以在 b 树中有 m 键 使用父级中的 m 个键将数组递归地分成 m+1 个相等的部分。 使用递归构造 n/(m+1) 个排序键的子树。

示例:-

m = 2 array = [1 2 3 4 5 6 7 8 9 10]

divide array into three parts :-

root = [4,8]

recursively solve :-

child1 = [1 2 3]

root1 = [2]

left1 = [1]

right1 = [3]

similarly for all childs solve recursively.

【讨论】:

【参考方案2】:

下面的算法尝试准备密钥的顺序,这样您就不需要权力甚至不需要了解插入过程。唯一的假设是过度填充的树节点要么在中间分裂,要么在最后插入元素的位置分裂,否则 B-树可以被视为一个黑盒子。

诀窍是以受控方式触发节点拆分。首先,您准确地填充一个节点,左半部分使用属于一起的键,右半部分使用属于一起的另一范围的键。最后,您插入一个介于这两个范围之间但不属于任何一个范围的键;这两个子范围被分成不同的节点,最后插入的键最终在父节点中。以这种方式拆分后,您可以填充两个子节点的其余部分,以使树尽可能紧凑。这也适用于具有两个以上子节点的父节点,只需对其中一个子节点重复该技巧,直到创建所需数量的子节点。下面,我使用概念上最右边的子节点作为“分割地”(步骤 5 和 6.1)。

递归地应用拆分技巧,所有元素都应该在它们的理想位置结束(这取决于元素的数量)。我相信下面的算法保证了树的高度总是最小的,并且除了根之外的所有节点都尽可能地满。但是,正如您可能想象的那样,如果没有实际实施和彻底测试,很难完全确定。我已经在纸上进行了尝试,我确信这个算法或非常相似的算法应该可以完成这项工作。

具有最大分支因子M的隐含树T

keys 长度为 N 的***过程:

    进行排序。 将 minimal-tree-height 设置为 ceil(log(N+1)/log(M))。 使用 chunk = keysH = minimal-tree-height 调用 insert-chunk

过程 insert-chunk,其中 chunk 长度为 L,子树高度 H

    如果 H 等于 1:
      chunk 中的所有键插入 T 立即返回。
    将理想的子块大小 S 设置为 pow(M, H - 1)。 设置子树的数量T为ceil((L + 1) / S)。 将实际子块大小S'设置为ceil((L + 1) / T)。 递归调用 insert-chunk,其中 chunk' = 的最后一层((S - 1) / 2) 键块H' = H - 1. 对于每个 ceil(L / S') 子块(大小为 S'),除了索引为
      递归调用 insert-chunk,其中 chunk' = subchunk S - 1) / 2) 键>I 和 H' = H - 1. 将子块 I 的最后一个键插入 T(此插入有意触发拆分)。 递归调用 insert-chunk,其中 chunk' = subchunk I(如果有)和 H' = H - 1.
    递归调用 insert-chunk,其中 chunk' = 最后一个子块的剩余键,H' = H em> - 1.

请注意,每个子树都会调用递归过程两次;这很好,因为第一次调用总是会创建一个完全填充的半子树。

【讨论】:

【参考方案3】:

下面的算法应该适用于节点中键数最少 = d 且最大值 = 2*d 的 B-Trees /p>

下面的算法旨在最大限度地减少节点数量,而不仅仅是树的高度。

该方法基于将键放入任何非完整叶子的想法,或者如果所有叶子都已满,则将密钥放在最低的非完整节点下。

更准确地说,本文算法生成的树满足以下要求: 它具有最小的可能高度; 它在每个级别上不超过两个非完整节点。 (总是最右边的两个节点。)

由于我们知道除根之外的任何级别上的节点数严格等于节点数和以上级别上的总键数之和,我们可以证明在级别之间没有有效的节点重排,从而减少了节点总数。例如,在任何特定级别以上插入的密钥数量增加将导致该级别上的节点增加,从而增加节点总数。虽然任何将键数量减少到某个级别以上的尝试都将导致该级别上的节点数减少,并且在不增加树高的情况下无法适应该级别上的所有键。 同样明显的是,任何特定级别的键排列都是最佳排列之一。 使用上述推理,还可以通过数学归纳构建更正式的证明。

这个想法是保存计数器列表(列表的大小不大于树的高度)来跟踪每个级别添加了多少键。一旦我将 d 个键添加到某个级别,这意味着在该级别中创建了一半的节点,如果有足够的键来填充该节点的另一半,我们应该跳过这些键并为更高级别添加 root。通过这种方式,root 将准确地放置在前一个子树的前半部分和下一个子树的前半部分之间,这将导致分裂,当 root 取代它时,两半子树将分开。跳过的钥匙的位置将是安全的,而我们会通过更大的钥匙,以后可以填写。

这是几乎可以工作的(伪)代码,数组需要排序:

PushArray(BTree bTree, int d, key[] Array)

    List<int> counters = new List<int>0;
    //skip list will contain numbers of nodes to skip 
    //after filling node of some order in half
    List<int> skip  = new List<int>();
    List<Pair<int,int>> skipList = List<Pair<int,int>>();

    int i = -1;
    while(true)
         
       int order = 0;
       while(counters[order] == d) order += 1;
       for(int j = order - 1; j >= 0; j--) counters[j] = 0;

       if (counters.Lenght <= order + 1) counters.Add(0);
       counters[order] += 1;

       if (skip.Count <= order)
              skip.Add(i + 2);    
       if (order > 0)
           skipList.Add(i,order); //list of skipped parts that will be needed later
       i += skip[order];

       if (i > N) break;

       bTree.Push(Array[i]);
    

    //now we need to add all skipped keys in correct order
    foreach(Pair<int,int> p in skipList)
    
        for(int i = p.2; i > 0; i--)
            PushArray(bTree, d, Array.SubArray(p.1 + skip[i - 1], skip[i] -1))
    

例子:

这里是数字和对应的计数器键在第一次通过数组时应该如何排列 d = 2。我用'o'标记了在第一次通过(循环循环之前)推入B树的键,并用'x'跳过。

                                                              24
        4         9             14             19                            29 
0 1 2 3   5 6 7 8   10 11 12 13    15 16 17 18    20 21 22 23    25 26 27 28    30 ...
o o x x o o o x x o  o  o  x  x  x  x  x  x  x  x  x  x  x  x  o  o  o  x  x  o  o ...
1 2     0 1 2     0  1  2                                      0  1  2        0  1 ...
0 0     1 1 1     2  2  2                                      0  0  0        1  1 ...
0 0     0 0 0     0  0  0                                      1  1  1        1  1 ...
skip[0] = 1 
skip[1] = 3 
skip[2] = 13

由于我们不遍历跳过的键,因此我们有 O(n) 时间复杂度,而无需添加到 B-Tree 本身和排序数组;

在这种形式中,如果在跳过块后没有足够的键来填充节点的后半部分,可能不清楚它是如何工作的,但是如果数组的总长度小于 ~ i,我们也可以避免跳过所有的 skip[order] 键+ 2 * skip[order] 和 skip[order - 1] 键代替,在更改计数器之后但在更改变量 i 之前可能会添加这样的字符串:

while(order > 0 && i + 2*skip[order] > N) --order;

这将是正确的,因为如果当前级别的键总数小于或等于 3*d,如果按原始顺序添加它们,它们仍然会正确拆分。这将导致在某些级别上最后两个节点之间的键重新排列略有不同,但不会破坏任何描述的要求,并且可能会使行为更易于理解。

找到一些动画并观察它是如何工作的可能是合理的,这是应该在 0..29 范围内生成的序列:0 1 4 5 6 9 10 11 24 25 26 29 /end of第一关/2 3 7 8 14 15 16 19 20 21 12 13 17 18 22 23 27 28

【讨论】:

【参考方案4】:

您的问题是关于 btree 优化的。您不太可能只是为了好玩而这样做。所以我只能假设你想优化数据访问——可能是数据库编程的一部分或类似的东西。您写道:“在搜索 BTree 时,它​​会根据需要从磁盘分页到内存中”,这意味着您要么没有足够的内存来进行任何类型的缓存,要么您有一个尽可能少地利用内存的策略。无论哪种方式,这都可能是您的问题的任何答案都不令人满意的根本原因。让我解释一下原因。

在数据访问优化方面,内存是您的朋友。如果您需要内存进行读取或写入优化,这并不重要。任何类型的写优化总是假设它可以快速读取信息(从内存中)——排序需要数据。如果您没有足够的内存来进行读取优化,那么您也没有足够的内存来进行写入优化。

只要您愿意接受至少一些内存利用率,您就可以重新考虑您的陈述“当搜索 BTree 时,它​​会根据需要从磁盘分页到内存中”,这为读取和写入优化之间的平衡留出了空间。 A 到最大优化 BTREE 是最大化写入优化。在大多数数据访问场景中,我知道您在任何 10-100 次读取时都会获得写入。这意味着最大化的写入优化可能会在数据访问优化方面表现不佳。这就是为什么数据库接受重组周期、关键空间浪费、不平衡的 btree 和类似的事情......

【讨论】:

【参考方案5】:

那么这是关于优化创建过程还是优化树?

您可以通过首先创建一个完整的平衡二叉树,然后收缩节点来清楚地创建一个效率最高的 B-Tree。

在二叉树的任何级别,两个节点之间的数字间隙包含二叉树定义的这两个值之间的所有数字,这或多或少是 B-Tree 的定义。您只需开始将二叉树划分收缩为 B-Tree 节点。由于二叉树是通过构造来平衡的,因此同一级别的节点之间的间隙总是包含相同数量的节点(假设树被填满)。因此这样构造的 BTree 保证了平衡。

在实践中,这可能是创建 BTree 的一种相当缓慢的方法,但它确实符合您构建最佳 B-Tree 的标准,并且有关创建平衡二叉树的文献非常全面。

======================================

在您的情况下,您可能会比构建的最佳版本“更好”地使用现成的产品,您是否考虑过简单地更改子节点可以拥有的数量?您的图表看起来像一棵经典的 2-3 树,但完全有可能拥有一棵 3-4 树或一棵 3-5 树,这意味着每个节点都至少有三个子节点。

【讨论】:

我不知道 3-3 树是否可能?有人知道吗? 你不能同时支持3-3 no 3-4 no 3-5,使用经典的B-tree算法,只有3-6或者3-7。我不确定是否根本不能发明 3-3 树,但如果每个节点都包含 3 个键并且 n 不能被 3 整除,那么显然你不能在树中保留 n 个键。所以你需要一些额外的缓冲区至少保留 n%3 个键。 这不是一个硬约束,因为你可以有一些“空等效”叶子节点。例如添加一些 long.minvalue 到列表中,因为它们始终是树中的最低节点,您可以轻松跟踪它们并在需要添加节点时删除它们。 3-6 和 3-7 更方便,但您避免了这种复杂性。 3-3 的问题是,只要移除一个节点,就必须进行重新平衡。

以上是关于如何给定一组预定的键,重新排序键,以便在插入 B-Tree 时使用最少数量的节点?的主要内容,如果未能解决你的问题,请参考以下文章

如果我只使用整数作为键,如何在本地存储中获取最后插入的键?

您应该按啥顺序将一组已知键插入 B-Tree 以获得最小高度?

perl 第七弹 变量 IV

如何使用字典中的键获取索引?

[PY3]——字典中的键如何映射多个值?字典如何排序?

如何排序地图值?