【愚公系列】2021年11月 C#版 数据结构与算法解析(线段树)

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
简介: 【愚公系列】2021年11月 C#版 数据结构与算法解析(线段树)
/// <summary>
/// 线段树:线段树是二叉树的一种,常常被用于求区间和与区间最大值等操作
/// </summary>
public class SegmentTree
{
    List<int> _orignalData = new List<int>();
    List<int?> _tree = new List<int?>();
    public SegmentTree()
    {
        for (int i = 0; i < 1000; i++)
        {
            _tree.Add(null);
        }
    }
    public void Print()
    {
        for (int i = 0; i < _tree.Count; i++)
        {
            if (_tree[i] == null)
            {
                continue;
            }
            Console.WriteLine($"第{i}:{_tree[i]}");
        }
    }
    public void Fill(List<int> data)
    {
        _orignalData = data;
        Fill(0, 0, _orignalData.Count - 1);
    }
    private void Fill(int node, int start, int end)
    {
        if (start == end)
        {
            _tree[node] = _orignalData[start];
        }
        else
        {
            int mid = (start + end) / 2;
            int leftNode = 2 * node + 1;
            int rightNode = 2 * node + 2;
            Fill(leftNode, start, mid);
            Fill(rightNode, mid + 1, end);
            _tree[node] = _tree[leftNode] + _tree[rightNode];
        }
    }
    public void Set(int index, int val)
    {
        SetValue(0, 0, _orignalData.Count - 1, index, val);
    }
    private void SetValue(int node, int start, int end, int index, int val)
    {
        if (start == end)
        {
            _orignalData[index] = val;
            _tree[node] = val;
        }
        else
        {
            int mid = (start + end) / 2;
            int leftNode = 2 * node + 1;
            int rightNode = 2 * node + 2;
            if (index >= start && index <= mid)
            {
                SetValue(leftNode, start, mid, index, val);
            }
            else
            {
                SetValue(rightNode, mid + 1, end, index, val);
            }
            _tree[node] = _tree[leftNode] + _tree[rightNode];
        }
    }
    public int? GetSum(int left, int right)
    {
        return Query(0, 0, _orignalData.Count - 1, left, right);
    }
    private int? Query(int node, int start, int end, int left, int right)
    {
        if (right < start || left > end)
        {
            return 0;
        }
        else if (left <= start && end <= right)
        {
            return _tree[node];
        }
        else if (start == end)
        {
            return _tree[node];
        }
        else
        {
            int mid = (start + end) / 2;
            int leftNode = 2 * node + 1;
            int rightNode = 2 * node + 2;
            int? sumLeft = Query(leftNode, start, mid, left, right);
            int? sumRight = Query(rightNode, mid + 1, end, left, right);
            return sumLeft + sumRight;
        }
    }
}

原理

(注:由于线段树的每个节点代表一个区间,以下叙述中不区分节点和区间,只是根据语境需要,选择合适的词)

线段树本质上是维护下标为1,2,…,n的n个按顺序排列的数的信息,所以,其实是“点树”,是维护n的点的信息,至于每个点的数据的含义可以有很多,

在对线段操作的线段树中,每个点代表一条线段,在用线段树维护数列信息的时候,每个点代表一个数,但本质上都是每个点代表一个数。以下,在讨论线段树的时候,区间[L,R]指的是下标从L到R的这(R-L+1)个数,而不是指一条连续的线段。只是有时候这些数代表实际上一条线段的统计结果而已。


线段树是将每个区间[L,R]分解成[L,M]和[M+1,R] (其中M=(L+R)/2 这里的除法是整数除法,即对结果下取整)直到 L==R 为止。

开始时是区间[1,n] ,通过递归来逐步分解,假设根的高度为1的话,树的最大高度为(n>1)。

线段树对于每个n的分解是唯一的,所以n相同的线段树结构相同,这也是实现可持久化线段树的基础。

下图展示了区间[1,13]的分解过程:

image.png

上图中,每个区间都是一个节点,每个节点存自己对应的区间的统计信息。


(1)线段树的点修改:


假设要修改[5]的值,可以发现,每层只有一个节点包含[5],所以修改了[5]之后,只需要每层更新一个节点就可以线段树每个节点的信息都是正确的,所以修改次数的最大值为层数。

复杂度O(log2(n))


(2)线段树的区间查询:


线段树能快速进行区间查询的基础是下面的定理:

定理:n>=3时,一个[1,n]的线段树可以将[1,n]的任意子区间[L,R]分解为不超过个子区间。

这样,在查询[L,R]的统计值的时候,只需要访问不超过个节点,就可以获得[L,R]的统计信息,实现了O(log2(n))的区间查询。


下面给出证明:


(2.1)先给出一个粗略的证明(结合下图):

先考虑树的最下层,将所有在区间[L,R]内的点选中,然后,若相邻的点的直接父节点是同一个,那么就用这个父节点代替这两个节点(父节点在上一层)。这样操作之后,本层最多剩下两个节点。若最左侧被选中的节点是它父节点的右子树,那么这个节点会被剩下。若最右侧被选中的节点是它的父节点的左子树,那么这个节点会被剩下。中间的所有节点都被父节点取代。

对最下层处理完之后,考虑它的上一层,继续进行同样的处理,可以发现,每一层最多留下2个节点,其余的节点升往上一层,这样可以说明分割成的区间(节点)个数是大概是树高的两倍左右。


下图为n=13的线段树,区间[2,12],按照上面的叙述进行操作的过程图:


image.png

image.png

由图可以看出:在n=13的线段树中,[2,12]=[2] + [3,4] + [5,7] + [8,10] + [11,12] 。


(2.2)然后给出正式一点的证明:

定理:n>=3时,一个[1,n]的线段树可以将[1,n]的任意子区间[L,R]分解为不超过个子区间。


用数学归纳法,证明上面的定理:

首先,n=3,4,5时,用穷举法不难证明定理成立。

假设对于n= 3,4,5,…,k-1上式都成立,下面来证明对于n=k ( k>=6 )成立:

分为4种情况来证明:


情况一:[L,R]包含根节点(L=1且R=n),此时,[L,R]被分解为了一个节点,定理成立。


情况二:[L,R]包含根节点的左子节点,此时[L,R]一定不包含根的右子节点(因为如果包含,就可以合并左右子节点,

用根节点替代,此时就是情况一)。这时,以右子节点为根的这个树的元素个数为。

[L,R]分成的子区间由两部分组成:

一:根的左子结点,区间数为1

二:以根的右子节点为根的树中,进行区间查询,这个可以递归使用本定理。

由归纳假设可得,[L,R]一共被分成了个区间。

情况三:跟情况二对称,不一样的是,以根的左子节点为根的树的元素个数为。

[L,R]一共被分成了个区间。

从公式可以看出,情况二的区间数小于等于情况三的区间数,于是只需要证明情况三的区间数符合条件就行了。


于是,情况二和情况三定理成立。


情况四:[L,R]不包括根节点以及根节点的左右子节点。

于是,剩下的层,每层最多两个节点(参考粗略证明中的内容)。

于是[L,R]最多被分解成了个区间,定理成立。


上面只证明了是上界,但是,其实它是最小上界。

n=3,4时,有很多组区间的分解可以达到最小上界。

当n>4时,当且仅当n=2^t (t>=3),L=2,R=2^t -1 时,区间[L,R]的分解可以达到最小上界。

就不证明了,有兴趣可以自己去证明。

下图是n=16 , L=2 , R=15 时的操作图,此图展示了达到最小上界的树的结构。


image.png

image.png

image.png

3)线段树的区间修改:

线段树的区间修改也是将区间分成子区间,但是要加一个标记,称作懒惰标记。

标记的含义:

本节点的统计信息已经根据标记更新过了,但是本节点的子节点仍需要进行更新。

即,如果要给一个区间的所有值都加上1,那么,实际上并没有给这个区间的所有值都加上1,而是打个标记,记下来,这个节点所包含的区间需要加1.打上标记后,要根据标记更新本节点的统计信息,比如,如果本节点维护的是区间和,而本节点包含5个数,那么,打上+1的标记之后,要给本节点维护的和+5。这是向下延迟修改,但是向上显示的信息是修改以后的信息,所以查询的时候可以得到正确的结果。有的标记之间会相互影响,所以比较简单的做法是,每递归到一个区间,首先下推标记(若本节点有标记,就下推标记),然后再打上新的标记,这样仍然每个区间操作的复杂度是O(log2(n))。


标记有相对标记和绝对标记之分:

相对标记是将区间的所有数+a之类的操作,标记之间可以共存,跟打标记的顺序无关(跟顺序无关才是重点)。

所以,可以在区间修改的时候不下推标记,留到查询的时候再下推。

注意:如果区间修改时不下推标记,那么PushUp函数中,必须考虑本节点的标记。

而如果所有操作都下推标记,那么PushUp函数可以不考虑本节点的标记,因为本节点的标记一定已经被下推了(也就是对本节点无效了)

绝对标记是将区间的所有数变成a之类的操作,打标记的顺序直接影响结果,

所以这种标记在区间修改的时候必须下推旧标记,不然会出错。


注意,有多个标记的时候,标记下推的顺序也很重要,错误的下推顺序可能会导致错误。


之所以要区分两种标记,是因为非递归线段树只能维护相对标记。

因为非递归线段树是自底向上直接修改分成的每个子区间,所以根本做不到在区间修改的时候下推标记。

非递归线段树一般不下推标记,而是自下而上求答案的过程中,根据标记更新答案。


(4)线段树的存储结构:

线段树是用数组来模拟树形结构,对于每一个节点R ,左子节点为 2R (一般写作R<<1)右子节点为 2R+1(一般写作R<<1|1)

然后以1为根节点,所以,整体的统计信息是存在节点1中的。

这么表示的原因看下图就很明白了,左子树的节点标号都是根节点的两倍,右子树的节点标号都是左子树+1:

image.png

线段树需要的数组元素个数是:,一般都开4倍空间,比如: int A[n<<2];

相关文章
|
2月前
|
存储 算法 Java
解析HashSet的工作原理,揭示Set如何利用哈希算法和equals()方法确保元素唯一性,并通过示例代码展示了其“无重复”特性的具体应用
在Java中,Set接口以其独特的“无重复”特性脱颖而出。本文通过解析HashSet的工作原理,揭示Set如何利用哈希算法和equals()方法确保元素唯一性,并通过示例代码展示了其“无重复”特性的具体应用。
59 3
|
22天前
|
机器学习/深度学习 人工智能 算法
深入解析图神经网络:Graph Transformer的算法基础与工程实践
Graph Transformer是一种结合了Transformer自注意力机制与图神经网络(GNNs)特点的神经网络模型,专为处理图结构数据而设计。它通过改进的数据表示方法、自注意力机制、拉普拉斯位置编码、消息传递与聚合机制等核心技术,实现了对图中节点间关系信息的高效处理及长程依赖关系的捕捉,显著提升了图相关任务的性能。本文详细解析了Graph Transformer的技术原理、实现细节及应用场景,并通过图书推荐系统的实例,展示了其在实际问题解决中的强大能力。
124 30
|
2天前
|
存储 监控 算法
企业内网监控系统中基于哈希表的 C# 算法解析
在企业内网监控系统中,哈希表作为一种高效的数据结构,能够快速处理大量网络连接和用户操作记录,确保网络安全与效率。通过C#代码示例展示了如何使用哈希表存储和管理用户的登录时间、访问IP及操作行为等信息,实现快速的查找、插入和删除操作。哈希表的应用显著提升了系统的实时性和准确性,尽管存在哈希冲突等问题,但通过合理设计哈希函数和冲突解决策略,可以确保系统稳定运行,为企业提供有力的安全保障。
|
26天前
|
存储 算法
深入解析PID控制算法:从理论到实践的完整指南
前言 大家好,今天我们介绍一下经典控制理论中的PID控制算法,并着重讲解该算法的编码实现,为实现后续的倒立摆样例内容做准备。 众所周知,掌握了 PID ,就相当于进入了控制工程的大门,也能为更高阶的控制理论学习打下基础。 在很多的自动化控制领域。都会遇到PID控制算法,这种算法具有很好的控制模式,可以让系统具有很好的鲁棒性。 基本介绍 PID 深入理解 (1)闭环控制系统:讲解 PID 之前,我们先解释什么是闭环控制系统。简单说就是一个有输入有输出的系统,输入能影响输出。一般情况下,人们也称输出为反馈,因此也叫闭环反馈控制系统。比如恒温水池,输入就是加热功率,输出就是水温度;比如冷库,
217 15
|
1月前
|
算法 Linux 定位技术
Linux内核中的进程调度算法解析####
【10月更文挑战第29天】 本文深入剖析了Linux操作系统的心脏——内核中至关重要的组成部分之一,即进程调度机制。不同于传统的摘要概述,我们将通过一段引人入胜的故事线来揭开进程调度算法的神秘面纱,展现其背后的精妙设计与复杂逻辑,让读者仿佛跟随一位虚拟的“进程侦探”,一步步探索Linux如何高效、公平地管理众多进程,确保系统资源的最优分配与利用。 ####
71 4
|
1月前
|
缓存 负载均衡 算法
Linux内核中的进程调度算法解析####
本文深入探讨了Linux操作系统核心组件之一——进程调度器,着重分析了其采用的CFS(完全公平调度器)算法。不同于传统摘要对研究背景、方法、结果和结论的概述,本文摘要将直接揭示CFS算法的核心优势及其在现代多核处理器环境下如何实现高效、公平的资源分配,同时简要提及该算法如何优化系统响应时间和吞吐量,为读者快速构建对Linux进程调度机制的认知框架。 ####
|
1月前
|
存储 消息中间件 NoSQL
Redis数据结构:List类型全面解析
Redis数据结构——List类型全面解析:存储多个有序的字符串,列表中每个字符串成为元素 Eelement,最多可以存储 2^32-1 个元素。可对列表两端插入(push)和弹出(pop)、获取指定范围的元素列表等,常见命令。 底层数据结构:3.2版本之前,底层采用**压缩链表ZipList**和**双向链表LinkedList**;3.2版本之后,底层数据结构为**快速链表QuickList** 列表是一种比较灵活的数据结构,可以充当栈、队列、阻塞队列,在实际开发中有很多应用场景。
|
1月前
|
算法 C#
C#常见的四种经典查找算法
C#常见的四种经典查找算法
|
1月前
|
算法 C# 索引
C#线性查找算法
C#线性查找算法!
|
1月前
|
存储 NoSQL 关系型数据库
Redis的ZSet底层数据结构,ZSet类型全面解析
Redis的ZSet底层数据结构,ZSet类型全面解析;应用场景、底层结构、常用命令;压缩列表ZipList、跳表SkipList;B+树与跳表对比,MySQL为什么使用B+树;ZSet为什么用跳表,而不是B+树、红黑树、二叉树

推荐镜像

更多