线段树 Segment Tree(一)

线段树 Segment Tree (一)

今天刷 Leetcode 刷到了一个名叫 “Largest rectangle in histogram" 的题,想破天都不知道该怎么用小于 O ( n 2 ) O(n^2) O(n2) 的时间复杂度来解决这个问题。于是乎网上寻求方法,其中有一个时间复杂度为 O ( n log ⁡ ( n ) ) O(n\log(n)) O(nlog(n)) 的方法(还有另外一个方法只要线性的时间复杂度),这个方法用到一个叫线段树 (segment tree) 的树结构。(咳咳咳,终于到了正题)所以今天就随便扯扯线段树。这篇文章参考了这篇文章,原文为英文,有兴趣的小伙伴可过去瞧瞧。另,理解不深,诸位见谅。

定义

(可自行百科,不想看的小伙伴可以直接跳到实现部分)

线段树就是一种能够实现区间查询和区间更新的数据结构(脑补二叉树)。它的优点就是能够以均为 O ( log ⁡ ( n ) ) O(\log(n)) O(log(n)) 的时间复杂度来解决区间最大/最小/之和的查询 (Range Min/Max & Sum Queries) 和区间更新。说的好像有点绕,下面来举个?。

假设我们有一个数组 arr[0, ..., n-1],数组的长度为 n n n,我们想要实现:

  1. 给定区间 [ l e f t , r i g h t ) [left, right) [left,right),求区间内arr元素之和,
  2. 更新arr中某一个元素的值,即arr[i] = x i ∈ [ 0 , n − 1 ] i\in[0,n-1] i[0,n1]

一个简单的方法是从 l e f t left left r i g h t right right 跑一个循环来计算得到这个区间 [ l e f t , r i g h t ) [left, right) [left,right) 的元素之和。那么这样更新数组arr中某一个元素的值我们只需要执行arr[i] = x就可以了。实现求和需要 O ( n ) O(n) O(n) 的时间,而更新只需要 O ( 1 ) O(1) O(1) 的时间。

我们再考虑另外一种相反的方法,能调换实现 1 1 1 2 2 2 的时间复杂度。首先另外创建一个数组用来存储从首元素到各个元素区间的和,即 sumArr[0, ..., n-1],其中sumArr[i]用来存储数组arr [ 0 , i + 1 ) [0, i+1) [0,i+1) 区间的元素之和。说白了,就是我们用另外一个数组直接来存储算好了的和,然后用直接取值来代替循环叠加求和,降低了求和的复杂度。这样一来,实现 1 1 1 的复杂度就是 O ( 1 ) O(1) O(1)。(有人会问,实现 1 1 1 是要求一个区间的和,不仅仅是求从头开始的和呀!但是两和作差不就是中间区间的和了嘛!所以还是 O ( 1 ) O(1) O(1))。而实现 2 2 2 的复杂度却是到了 O ( n ) O(n) O(n),因为我们每更新一个值,就得对从被更新的元素开始重新计算元素和。这种方法适用于查询(即实现 1 1 1)操作很多而更新很少的情况。

我们上面是用求和来做的例子,其实查询区间最小值、最大值是一样一样的,可能在存储最大值最小值的部分有所变化,这里不细究了~

实现

废话说了这么多,终于进入正轨了。 如果我们想要查询和更新一样快呢!这时候就到主角线段树上场了。

和标准定义的线段树不太相同,这篇用的是功能相同只是结构不同的线段树,属于完全二叉树,代码要简洁一点。这里的线段树 T T T 的要求是:

  1. T T T 的根节点表示arr的整个区间 [ 0 , n ) [0,n) [0,n) 内的查询量(最大值,最小值,之和)
  2. T T T 的叶子节点表示arr的具体元素,即arr[0], arr[1], ... arr[n-1]
  3. T T T 的非叶子节点(内部结点)各自表示其所对应区间的查询量。对应区间为其左右孩子区间的并集。

从代码角度,线段树的存储可以直接采用数组的形式。(接着用上面的arr,长度为 n n n 的数组)线段树 T T T 对应长度为 2 n 2n 2n 的数组segTree,该数组满足以下条件(与线段树的要求一一对应):

  1. 数组的第一个元素不使用,即数组下标从 1 1 1 开始(方便理清左右孩子),segTree[1]用来存储根节点,表示的是整个区间的的查询量
  2. 下标从 n n n 2 n − 1 2n-1 2n1segTree数组元素(叶子节点)用来存储arr的所有元素,即segTree[n + i] = arr[i],其中 i ∈ [ 0 , n ) i\in[0,n) i[0,n)
  3. 下标从 0 0 0 n − 1 n-1 n1segTree数组元素(非叶子节点),即segTree[i] i ∈ [ 0 , n ) i\in[0,n) i[0,n) 表示其左右孩子segTree[2*i]segTree[2*i + 1]所对应区间的并集的查询量。

说的有点拗口。。。如果用最大值来代替查询量,大概意思就是根节点表示的是整个区间的最大值,然后这整个区间被它的左右孩子分别瓜分了一半(暂且把 n n n 当做 2 2 2 的正数次幂,这样线段树就是个满二叉树),左右孩子各自代表瓜分到的区间的最大值。以此类推一直到左右孩子为叶子节点的内部结点,这个内部结点表示的就是其两个叶子节点(也就是两个arr的元素)的最大值。所以我们可以看到整个区间被线段树瓜分到不能再瓜分只剩具体元素的时候,整个树的高度为 log ⁡ ( n ) \log(n) log(n),其中 n n n 为数组arr的长度。

n 为 2 的正数次幂

下图大致示意了求arr = {2, 1, 5, 6, 2, 3, 5, 4}区间和的线段树。图用 Graphviz 画的。我会在末尾给出来对应的.dot文件。
arr长度为 8 对应的线段树上图中,根节点对应segTree[1],它存储了arr在整个区间 [ 0 , 8 ) [0, 8) [0,8) 上的和 28 28 28,它是通过对它的左右两个孩子segTree[2]segTree[3]求和得到的,这左右两个孩子同样是通过这种方法得到自身对应区间(如上图所示,分别为 [ 0 , 4 ) [0, 4) [0,4) [ 4 , 8 ) [4, 8) [4,8))的和。注意,图中segTree[0]并没有画出,实际中可将segTree[0]设置为0,它的值无关紧要。

话不多说,上 C++ 代码

下面给出了线段树 (Segment Tree) 的 C++ 代码。本来我是准备放我的代码的,可是临时兴起想看看别人怎么写的,于是发现有个人写的简洁很多,而且用到了简单的位运算(&|^)来代替乘除 2 2 2 的运算,感觉高大上了很多,所以就借鉴了这位 大神(点我跳转) 的代码,当然我自己改了部分的内容,更适用于一般的情况,而且他的代码是 C 的风格,并不是 C++。代码实现的是区间求和查询,和区间最小(大)值查询一毛一样,替换的话就是把初始化SegmentTree(),更新update()和查询query()+改成对应的比较求最小(大)值就行了。Perfect!

#include <vector>
#include <iostream>

class SegmentTree {
private:
    int length;                 // length of arr
    std::vector<int> segTree;   // segment tree

public:
    explicit SegmentTree(const std::vector<int>& arr); // construct the segment tree
    void update(int p, int value);  // set value at position p, 0 <= p <= length-1
    int query(int l, int r);    // sum on interval [l, r), 0 <= l < r <= length-1
};

SegmentTree::SegmentTree (const std::vector<int>& arr) :
        length(arr.size()),                          // get length
        segTree(std::vector<int>(length * 2, 0))   // initialize segment tree
{
    for (int i = 0; i < length; ++i) segTree[i + length] = arr[i];
    for (int i = length - 1; i > 0; --i) segTree[i] = segTree[i<<1] + segTree[i<<1 | 1];
}

void SegmentTree::update (int p, int value) {
    for (segTree[p += length] = value; p > 1; p >>= 1) segTree[p>>1] = segTree[p] + segTree[p^1];
}

int SegmentTree::query (int l, int r) {
	// set result to 0
	// for max then set to -infinity, for min then set to infinity
    int res = 0;    
    for (l += length, r += length; l < r; l >>= 1, r >>= 1) {
        if (l&1) res += segTree[l++];
        if (r&1) res += segTree[--r];
    }
    return res;
}

这里说明一下为什么这个线段树好使而且这么好使,参考了这里

  1. 之前提到的,从图中也可以看出,叶子节点是连续存储的,下标从 n n n 2 n − 1 2n-1 2n1segTree[i + n]对应arr[i]。所以我们可以直接将arr中的初值直接放到segTree的对应位置。
  2. 在做查询操作之前需要先把线段树给建立起来,这需要 O ( n ) O(n) O(n) 的时间。因为父节点总是比孩子节点的下标要小,所以我们可以通过递减下标的方法来处理所有的内部结点。这里需要说明一下构造函数SegmentTree()中位运算的含义(这也算是我贴这个代码的部分原因,加深一下对位运算的印象),其中segTree[i] = segTree[i<<1] + segTree[i<<1|1]等同于segTree[i] = segTree[2*i] + segTree[2*i + 1]。没明白的小伙伴网上搜搜哈,挺简单的。
  3. 更新一个元素也是很直观的,而且需要的时间与树的高度成正比,即 log ⁡ ( n ) \log(n) log(n)。我们只需要更新更改过的元素的父节点,以及父节点的父节点(一直循环下去)就行。所以我们通过自底向上得到p的父节点p / 2或者高端一点p>>1,两者意思一样。另外,p^1的意思是把2*i转换成2*i + 1,也可以把2*i + 1转成2i(举个例子,可以通过这个方法转化 6 ( 2 × 3 ) 6(2\times3) 6(2×3) 7 ( 2 × 3 + 1 ) 7(2\times3+1) 7(2×3+1),亲测),所以pp^1可以表示左右孩子也可以表示右左孩子,结果都一样。
  4. 查询区间和同样只需要 O ( log ⁡ ( n ) ) O(\log(n)) O(log(n)) 的时间。为了更好的理解这个逻辑,你可以试一试上图中查询一下区间 [ 2 , 6 ) [2, 6) [2,6) (arr) 即区间 [ 10 , 14 ) [10, 14) [10,14) (segTree) 的元素之和,看一看和代码逻辑算出来的一不一样,多试几次就能大致搞清代码的脉络了。

可以这样来理解。如果左端点l是基数的话(即l$1)那么l是其父节点的右孩子(看之前的那个图)。进而我们的区间仅仅包括进了l而没有包括进它的父节点。因此我们把segTree[i]加上,然后通过l = (l + 1)/2移动到l右边节点的父节点上。如果l是偶数,它就是左孩子,那么区间就将它的父节点也一起包括了进去(除非右端点也插了一腿进来),所以我们只需要通过l = l/2移动到l的父节点上。类似的处理也用于对右端点奇偶情况的讨论。最终一旦lr碰面了我们就停止搜寻。

优势

这种线段树只用了2n的空间,而不像其他需要4n空间的线段树。数组元素从下标n开始连续存储(注意是始终都连续存储,不存在两个相邻数组元素存储在不相邻的地址中,这一点区别于其他线段树的实现方法,易理解且代码易懂)。所有的操作因此而变得十分高效且易实现,只要理解了两三行就搞定了。

n 为任意值

对于 n n n 2 2 2 的整数幂的特殊情况,我们可以看到所有的arr元素都在同一层的叶子节点上,线段树为满二叉树,可是当 n n n 为任意值的时候,就不是这样了。

上面给出的代码仍然适用于 n n n 为任意值的时候

解释为什么同样适用于 n n n 为任意值比较复杂难搞,我也画不出来具体的证明的图形。不过我给了当 n = 6 n=6 n=6 n = 13 n=13 n=13 的线段图。由于后者节点较多,所以采用了表格的方式来表示线段树。.dot文件同样附在最后。
arr长度为 6 对应的线段树
arr长度为 13 对应的线段树
上面第一张图arr长度为6的线段图。黑色代表叶子节点(arr数组元素),灰色代表不使用的节点。

第二张图是arr长度为13对应的线段图(用 Excel 画的,早知道画的这么快就不用 Graphviz 了,看起来还更清晰,但是懒得把以前的重新画了 /(ㄒoㄒ)/~~)。绿色的代表叶子节点(arr数组元素),灰色代表不使用的节点,\杠掉的是不在segTree存储空间里面的(长度只到 26 26 26,不是 32 32 32)。

这线段树虽不是满二叉树但是仍是完全二叉树。其实这个线段树已经不算是一个单一的树组成的了,而是由多个高度不尽相同的满二叉树组成。这些满二叉树是:高度为 4 4 4 的节点 2 2 2,高度为 2 2 2 的节点 7 7 7,高度为 2 2 2 的节点 12 12 12 和高度为 1 1 1 的节点 13 13 13。灰色的节点在查询过程query()中是使用不到的,所以不用考虑构建线段树SegmentTree()的时候会给这些灰色的节点赋成什么样的值。这回叶子节点就不在同一层上了,但是可以通过裁剪节点 13 13 13 前面的树把图中右边加粗框的部分移到大树的左边去。可以认为裁剪移动后的更大一点的树和之前的树是同构的(同构是说对我们所进行的操作update()query()而言两者没有区别,不是一般意义上的同构~),所以这个算法也同样适用于 n n n 为任意值的情况。不理解一脸懵逼也没关系!知道可以用怎么用就行了!

严格的证明就不给了,本人也没那个能耐~。其实随便弄一个范围来自己亲手跟着代码的步骤在图上走几遭就差不多意会那个意思了,嘿嘿!这里我们来试试查询区间 [ 0 , 7 ) [0, 7) [0,7) 的元素之和。首先根据query()里面的逻辑,lr都被加了 n n n 变成了l = 13, r = 20,且l为奇数则加上segTree[13](值为 0 0 0),并执行++l。第二遍循环的时候l = 7, r = 10,同样的加上segTree[7](值为 3 3 3) 并执行++l。第三层循环时l = 4, r = 5,突然之间区间内的节点都在同一层了(印证了之前说的同构)。现在r为奇数则加上segTree[--r]segTree[4](值为 18 18 18)。这遍循环结束进入下一循环时l = r = 2,所以查询终止,最终得到的结果是 21 21 21,没毛病!

到此为止线段树的简单实现就差不多了,今天的总结也终于是写完了,其实是写了两天,第一次发博有点小紧张,有的地方说的不对不好请多多评论指正!这里其实关于线段树的内容还没讲完,但是感觉一篇博文里面塞太多东西有点让人消化不良,暂且就记住这个好用又好写的线段树实现就好。陆续我会把剩下的内容一一补上!线段树 Segment Tree(二)见!

待续…

REFERENCES

1: http://codeforces.com/blog/entry/18051
2: https://www.geeksforgeeks.org/segment-tree-set-1-range-minimum-query/
3: https://www.youtube.com/watch?v=Oq2E2yGadnU&t=1s

附录

差点忘了… 下面分别为数组长为 8 8 8 6 6 6 的树图的 Graphviz 代码。

digraph segmentTree8{
    a [label="8: 2", fillcolor=black, style=filled, fontcolor=white];  
    b [label="9: 1", fillcolor=black, style=filled, fontcolor=white];  
    c [label="10: 5", fillcolor=black, style=filled, fontcolor=white];  
    d [label="11: 6", fillcolor=black, style=filled, fontcolor=white];  
    e [label="12: 2", fillcolor=black, style=filled, fontcolor=white]; 
    f [label="13: 3", fillcolor=black, style=filled, fontcolor=white];
    g [label="14: 5", fillcolor=black, style=filled, fontcolor=white];
    h [label="15: 4", fillcolor=black, style=filled, fontcolor=white]; 

    ab [label="4: sum = 3"];
    cd [label="5: sum = 11"];
    ef [label="6: sum = 5"];
    gh [label="7: sum = 9"];
    
    abcd [label="2: sum = 14"];
    efgh [label="3: sum = 14"];

    T [label="1: sum = 28", xlabel="[0, 8)"];
 
    T->abcd [label="[0, 4)"]
    T->efgh [label="[4, 7)"]
    abcd->ab [label="[0, 2)"]
    abcd->cd [label="[2, 4)"]
    efgh->ef [label="[4, 6)"]
    efgh->gh [label="[6, 8)"]
    ab->a
    ab->b
    cd->c
    cd->d
    ef->e
    ef->f
    gh->g
    gh->h
}
digraph segmentTree6{
    a [label="8: 2", fillcolor=black, style=filled, fontcolor=white];  
    b [label="9: 1", fillcolor=black, style=filled, fontcolor=white];  
    c [label="10: 5", fillcolor=black, style=filled, fontcolor=white];  
    d [label="11: 6", fillcolor=black, style=filled, fontcolor=white];  
    e [label="6: 2", fillcolor=black, style=filled, fontcolor=white]; 
    f [label="7: 3", fillcolor=black, style=filled, fontcolor=white];

    ab [label="4: sum = 3"];
    cd [label="5: sum = 11"];
    
    abcd [label="2: sum = 14"];
    ef [label="3: sum = 5"];

    T [label="1: --", fillcolor=grey, style=filled];
 
    T->abcd [label="[0, 4)"]
    T->ef [label="[4, 6)"]
    abcd->ab [label="[0, 2)"]
    abcd->cd [label="[2, 4)"]
    ef->e
    ef->f
    ab->a
    ab->b
    cd->c
    cd->d
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值