图解B树及C#实现(2)数据的读取及遍历

   2023-02-09 学习力0
核心提示:目录前言查询数据算法说明代码实现查询最值算法说明代码实现B树的遍历算法说明代码实现Benchmarks总结参考资料前言本文为系列文章B树的定义及数据的插入数据的读取及遍历(本文)数据的删除前一篇文章为大家介绍了 B树 的基本概念及其插入算法。本文将基于前

前言

本文为系列文章

  1. B树的定义及数据的插入
  2. 数据的读取及遍历(本文)
  3. 数据的删除

前一篇文章为大家介绍了 B树 的基本概念及其插入算法。本文将基于前一篇的内容,为大家介绍插入到 B树 中的数据该怎么读取及遍历,

本文的代码基于前一篇文章的代码,已经实现的功能可能会被省略,只介绍新增的功能。

在本文开始前,再次复习下 B树 的顺序特性:

  • 每个 节点 中的 Item 按 Key 有序排列(规则可以是自定义的)。
  • 升序排序时,每个 Item 左子树 中的 Item 的 Key 均小于当前 Item 的 Key。
  • 升序排序时,每个 Item 右子树 中的 Item 的 Key 均大于当前 Item 的 Key。

理解数据的顺序性对本文的理解至关重要。

查询数据

算法说明

B树 是基于二分查找算法进行设计的,某些资料中你也会看到用 多路搜索树 来归类 B树。

在 B树 中查找数据时,二分体现在两个方面:

  1. 在节点中查找数据时,使用二分查找算法。
  2. 当节点中找不到数据时,使用二分查找算法找到下一个节点。

具体的查找过程如下:

  1. 从根节点开始,在节点中使用二分查找算法查找数据。
  2. 如果没有找到数据,则根据查找的 Key 值与节点中的 Key 值的大小关系,决定下一个节点的位置。
  3. 重复步骤 1 和 2,直到找到数据或者找到叶子节点。如果在叶子节点中也没有找到数据,则说明数据不存在。

举例说明:
在下面的 B树 中,查找 Key 为 8 的数据。

  1. 从根节点开始,使用二分查找算法没有找到数据
  2. 根据 Key 值与节点中的 Key 值的大小关系,决定下一个节点的位置应该在 6 和 9 之间,也就是 6 的右子树。
  3. 在 6 的右子树中,使用二分查找算法找到了数据。

代码实现

前一篇文章我们定义了 Items 类,用于存储节点中的数据,并且在一开始就定义了一个二分查找算法,用于在 Items 查找 Item。

前一篇用它来找到合适的插入位置,现在我们用寻找已经存在的数据。

在当前节点找到 Item 时,index 对应的就是 Item 的位置。没找到时则代表下一个子树的索引。

理解代码时请参考下图:

internal class Items<TKey, TValue>
{
    public bool TryFindKey(TKey key, out int index)
    {
        if (_count == 0)
        {
            index = 0;
            return false;
        }

        // 二分查找
        int left = 0;
        int right = _count - 1;
        while (left <= right)
        {
            int middle = (left + right) / 2;
            var compareResult = _comparer.Compare(key, _items[middle]!.Key);
            if (compareResult == 0)
            {
                index = middle;
                return true;
            }

            if (compareResult < 0)
            {
                right = middle - 1;
            }
            else
            {
                left = middle + 1;
            }
        }

        index = left;
        return false;
    }
}

在 Node 中,我们需要找到合适的子树,然后递归调用子节点的 TryFind 方法。

internal class Node<TKey, TValue>
{
    public bool TryFind(TKey key, out Item<TKey, TValue?> item)
    {
        if (_items.TryFindKey(key, out int index))
        {
            item = _items[index];
            return true;
        }

        if (IsLeaf)
        {
            item = default!;
            return false;
        }

        return _children[index].TryFind(key, out item);
    }
}

BTree 类中,我们只需要调用根节点的 TryFind 方法即可。

public sealed class BTree<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue?>>
{
    public bool TryGetValue([NotNull] TKey key, out TValue? value)
    {
        ArgumentNullException.ThrowIfNull(key);

        if (_root == null)
        {
            value = default;
            return false;
        }

        if (!_root.TryFind(key, out var item))
        {
            value = default;
            return false;
        }

        value = item.Value;
        return true;
    }
}    

查询最值

算法说明

B树的顺序性使得我们可以很方便的找到最值。

  1. 最小值:从根节点开始,一直往左子树走,直到叶子节点。
  2. 最大值:从根节点开始,一直往右子树走,直到叶子节点。

可以看到,B树 寻找最值的时间复杂度只和树的高度有关,而不是数据的个数,如果树的高度为 h,那么时间复杂度为 O(h)。只要树的 度(degree) 足够,每层能放的数据其实是很多的,那么树的高度就会很小,查询最值的时间复杂度也很小。

代码实现

internal class Node<TKey, TValue>
{
    public Item<TKey, TValue?> Max()
    {
        // 沿着右子树一直走,直到叶子节点,叶子节点的最大值就是最大值
        if (IsLeaf)
        {
            return _items[ItemsCount - 1];
        }

        return _children[ChildrenCount - 1].Max();
    }

    public Item<TKey, TValue?> Min()
    {
        // 沿着左子树一直走,直到叶子节点,叶子节点的最小值就是最小值
        if (IsLeaf)
        {
            return _items[0];
        }

        return _children[0].Min();
    }
}

BTree 类中,我们只需要调用根节点的 Max 和 Min 方法即可。

public sealed class BTree<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue?>>
{
    public KeyValuePair<TKey, TValue?> Max()
    {
        if (_root == null)
        {
            throw new InvalidOperationException("BTree is empty.");
        }

        var maxItem = _root.Max();
        return new KeyValuePair<TKey, TValue?>(maxItem.Key, maxItem.Value);
    }

    public KeyValuePair<TKey, TValue?> Min()
    {
        if (_root == null)
        {
            throw new InvalidOperationException("BTree is empty.");
        }

        var minItem = _root.Min();
        return new KeyValuePair<TKey, TValue?>(minItem.Key, minItem.Value);
    }
}

B树的遍历

算法说明

B树的遍历和二叉树的遍历是相通的,都可以分为深度遍历和广度遍历。深度遍历又分为先序遍历、中序遍历和后序遍历。

本文将以中序遍历为例介绍 B树 的遍历,通过中序遍历可以对 B树 中的数据从小到大进行排序。

其他遍历方式的也都可以理解成 二叉树 遍历方式的拓展,有兴趣的读者朋友可以自行尝试实现一下。

不过,B树的遍历和二叉树的遍历还是有一些区别的,我们先来看一下二叉树的中序遍历。

二叉树的中序遍历分为下面几步:

  1. 先遍历左子树。
  2. 访问当前节点。
  3. 遍历右子树。

在每个子树中,重复上面的步骤。

以下面的二叉树为例再次说明一遍:

  1. 先遍历 8 的左子树 T1

  2. 在 T1 中先遍历 4 的左子树 T2

  3. 在 T2 中先遍历 2 的左子树,只有一个节点,直接访问 1,

  4. 在 T2 中访问 2

  5. 在 T2 中遍历 2 的右子树,只有一个节点,直接访问 3,T2 遍历完毕

  6. 在 T1 中访问 4

  7. 在 T1 中遍历 4 的右子树 T3

  8. ... 以此类推,直到遍历完整棵树。

B树的中序遍历也是类似的,只不过 B树 的节点中有多个 Item 和 多个 子树,我们需要遍历每个 Item 的 左右子树以及 Item 。
B树的中序遍历分为下面几步:

  1. 遍历节点中的第一个子树,也就是第一个 Item 的左子树。
  2. 遍历节点中的第一个 Item。
  3. 遍历节点中的第二个子树,也就是第一个 Item 的右子树。
  4. 直至遍历完所有的 Item,遍历节点中的最后一个子树。

在每个子树中,重复上面的步骤。

如下图所示,我们以中序遍历的方式遍历 B树,会先遍历 3 的左子树,然后访问 3,再遍历 3 的右子树,直至遍历完 9 的右子树。

代码实现

遍历每个节点的 Item 和 子树,我们可以使用递归的方式实现,代码如下:

internal class Node<TKey, TValue>
{
    public IEnumerable<Item<TKey, TValue?>> InOrderTraversal()
    {
        var itemsCount = ItemsCount;
        var childrenCount = ChildrenCount;
        if (IsLeaf)
        {
            for (int i = 0; i < itemsCount; i++)
            {
                yield return _items[i];
            }

            yield break;
        }

        // 左右子树并不是相当于当前的 node 而言,而是相对于每个 item 来说的
        for (int i = 0; i < itemsCount; i++)
        {
            if (i < childrenCount)
            {
                foreach (var item in _children[i].InOrderTraversal())
                {
                    yield return item;
                }
            }

            yield return _items[i];
        }

        // 最后一个 item 的右子树
        if (childrenCount > itemsCount)
        {
            foreach (var item in _children[childrenCount - 1].InOrderTraversal())
            {
                yield return item;
            }
        }
    }
}

BTree 实现了 IEnumerable 接口,以便我们可以使用 foreach 循环来遍历 BTree 中的所有 Item,其代码只要调用 Node 的 InOrderTraversal 方法即可:

public sealed class BTree<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue?>>
{
    public IEnumerator<KeyValuePair<TKey, TValue?>> GetEnumerator()
    {
        foreach (var item in _root!.InOrderTraversal())
        {
            yield return new KeyValuePair<TKey, TValue?>(item.Key, item.Value);
        }
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

Benchmarks

最后,我们来看一下 Degree 对 BTree 的性能的影响。

注意,我们这里只考虑 B树的数据量远大于 Degree 的情况。

我们使用 BenchmarkDotNet 来测试,测试代码如下:

public class BTreeWriteBenchmarks
{
    [Params(2, 3, 4, 5, 6)] public int Degree { get; set; }
    
    private HashSet<int> _randomKeys;
    
    [GlobalSetup]
    public void Setup()
    {
        _randomKeys = new HashSet<int>();
        var random = new Random();
        while (_randomKeys.Count < 1000)
        {
            _randomKeys.Add(random.Next(0, 100000));
        }
    }
    
    [Benchmark]
    public void WriteSequential()
    {
        var bTree = new BTree<int, int>(Degree);
        for (var i = 0; i < 1000; i++)
        {
            bTree.Add(i, i);
        }
    }
    
    [Benchmark]
    public void WriteRandom()
    {
        var bTree = new BTree<int, int>(Degree);
        foreach (var key in _randomKeys)
        {
            bTree.Add(key, key);
        }
    }
}

public class BenchmarkConfig : ManualConfig
{
    public BenchmarkConfig()
    {
        Add(DefaultConfig.Instance);
        Add(MemoryDiagnoser.Default);
            
        ArtifactsPath = Path.Combine(AppContext.BaseDirectory, "artifacts", DateTime.Now.ToString("yyyy-mm-dd_hh-MM-ss"));
    }
}

new BenchmarkSwitcher(new[]
{
    typeof(BTreeReadBenchmarks),
}).Run(args, new BenchmarkConfig());

我们测试了 4 项性能指标,分别是顺序读、随机读、最小值、最大值、遍历,测试结果如下:

可以看到,在相同的数据量下,Degree 越大,性能越好,这是因为 Degree 越大,BTree 的高度越小,所以每次查找的时候,需要遍历的节点越少,性能越好。

但是不是真的 Degree 越大就越好呢,我们再来看下写入性能的测试结果:

public class BTreeWriteBenchmarks
{
    [Params(2, 3, 4, 5, 6)] public int Degree { get; set; }
    
    private HashSet<int> _randomKeys;
    
    [GlobalSetup]
    public void Setup()
    {
        _randomKeys = new HashSet<int>();
        var random = new Random();
        while (_randomKeys.Count < 1000)
        {
            _randomKeys.Add(random.Next(0, 100000));
        }
    }
    
    [Benchmark]
    public void WriteSequential()
    {
        var bTree = new BTree<int, int>(Degree);
        for (var i = 0; i < 1000; i++)
        {
            bTree.Add(i, i);
        }
    }
    
    [Benchmark]
    public void WriteRandom()
    {
        var bTree = new BTree<int, int>(Degree);
        foreach (var key in _randomKeys)
        {
            bTree.Add(key, key);
        }
    }
}

测试结果如下:

可以看到,Degree 越大,写入性能也越好,每个节点的容量够大,需要分裂的次数就变少了。

总结

  • B树是一种多路平衡查找树,可以基于二分查找的思路来查询数据。
  • B树的数据量远大于 Degree 的情况下,B树的 Degree 越大,读写性能越好。如果是磁盘中的实现,每个节点要考虑到磁盘页的大小,Degree 会有上限。

参考资料

Google 用 Go 实现的内存版 B树 https://github.com/google/btree

 
反对 0举报 0 评论 0
 

免责声明:本文仅代表作者个人观点,与乐学笔记(本网)无关。其原创性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容、文字的真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。
    本网站有部分内容均转载自其它媒体,转载目的在于传递更多信息,并不代表本网赞同其观点和对其真实性负责,若因作品内容、知识产权、版权和其他问题,请及时提供相关证明等材料并与我们留言联系,本网站将在规定时间内给予删除等相关处理.

  • VScode运行C++中文终端乱码的解决方案 vscode错误提示中文
    VScode运行C++中文终端乱码的解决方案 vscode错
    目录问题原因查看VSCODE编码方式查看终端编码方式解决办法更改VSCODE编码方式选通过编码保存选择编码方式为gbk总结问题Vscode编辑器中中文显示正常F5调试运行后中文显示乱码原因原因是VSCODE编辑器的编码和终端的编码不一致。VSCODE为utf-8,而cmd的默认编码
    03-08
  • C++ LeetCode1945题解字符串转化后的各位数字之和
    C++ LeetCode1945题解字符串转化后的各位数字
    目录1945.字符串转化后的各位数字之和方法一:计算AC代码C++1945.字符串转化后的各位数字之和力扣题目链接:leetcode.cn/problems/su…给你一个由小写字母组成的字符串 s ,以及一个整数 k 。首先,用字母在字母表中的位置替换该字母,将 s 转化 为一个整数(
  • C++ sdl实现渲染旋转视频的方法分享
    C++ sdl实现渲染旋转视频的方法分享
    目录前言一、如何实现1、计算边框大小2、计算缩放大小3、逆运算视频宽高二、完整代码三、使用示例总结前言一般情况下播放视频时不需要旋转,但是如果是移动端录制的视频有时会出现rotate参数,且视频宽高也是互换的,如果直接渲染则会出现视频90度倒转的问题
  • C++ LeetCode0547题解省份数量图的连通分量
    C++ LeetCode0547题解省份数量图的连通分量
    目录LeetCode 547.省份数量方法一:BFS求图的连通分量AC代码C++LeetCode 547.省份数量力扣题目链接:leetcode.cn/problems/nu…有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城
  • C++ LeetCode1832题解判断句子是否为全字母句
    目录LeetCode 1832.判断句子是否为全字母句方法一:统计AC代码C++LeetCode 1832.判断句子是否为全字母句力扣题目链接:leetcode.cn/problems/ch…全字母句 指包含英语字母表中每个字母至少一次的句子。给你一个仅由小写英文字母组成的字符串 sentence ,请
  • C++ LeetCode1781题解所有子字符串美丽值之和
    C++ LeetCode1781题解所有子字符串美丽值之和
    目录LeetCode 1781.所有子字符串美丽值之和方法一:前缀和AC代码C++方法二:边遍历边计算AC代码C++LeetCode 1781.所有子字符串美丽值之和力扣题目链接:leetcode.cn/problems/su…一个字符串的 美丽值 定义为:出现频率最高字符与出现频率最低字符的出现次
  • C++ LeetCode300最长递增子序列
    C++ LeetCode300最长递增子序列
    目录LeetCode 300.最长递增子序列方法一:动态规划AC代码C++LeetCode 300.最长递增子序列力扣题目链接:leetcode.cn/problems/lo…给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。子序列 是由数组派生而来的序列,删除(或不删除)数组中的元
  • C++ LeetCode1780判断数字是否可以表示成三的幂的和
    C++ LeetCode1780判断数字是否可以表示成三的
    目录LeetCode 1780.判断一个数字是否可以表示成三的幂的和方法一:二进制枚举题目分析解题思路复杂度分析AC代码C++方法二:进制转换AC代码C++LeetCode 1780.判断一个数字是否可以表示成三的幂的和力扣题目链接:leetcode.cn/problems/ch…给你一个整数 n 
  • C++使用宏实现动态库加载 c++加载静态库
    目录前言一、为什么使用宏1、Windows加载2、Linux加载3、宏加载二、具体实现三、如何使用1、引用头文件2、添加导入宏3、直接调用总结前言开发的时候,有些项目不能静态链接动态库,需要程序运行时加载动态库,这个时候根据不同平台我们通常使用LoadLibrary或d
  • C++ LeetCode1805字符串不同整数数目
    目录LeetCode 1805.字符串中不同整数的数目方法一:遍历拆分AC代码C++LeetCode 1805.字符串中不同整数的数目力扣题目链接:leetcode.cn/problems/nu…给你一个字符串 word ,该字符串由数字和小写英文字母组成。请你用空格替换每个不是数字的字符。例如,"a12
点击排行