高级数据结构——线段树(Segment Tree)

目录

  • 序言
  • 基本概念
    • 对比
    • 示例
    • 性质
      • 指针表示
      • 数组表示
  • 基本操作
    • 构建线段树
    • 更新区间
      • 懒更新
    • 查询区间
  • 代码实现

序言

刚刚接触线段树,感觉线段树的想法真厉害,整个结构非常的优美而且缜密,所以这里特别记录一下。在这里我主要根据例题来对线段树进行讲解,并且将代码自己重新写了一遍,这样会更加深入的了解线段树中一些细节,如果不对,还请指正。

基本概念

对比

第一次看到线段树1的时候,是看到了它和树状数组2(Fenwick Tree,Binary Indexed Tree)的比较,这两个感觉挺相似的,区别以后再介绍吧,毕竟我自己还有很多不理解。除了线段树、树状数组,还有权值线段树3、可持久化线段树3(Persistent Segment Tree,也叫总书记树、主席树或者函数式线段树)、zkw线段树4(也叫重口味树)等等,这些基本上都是线段树的变体,这些也以后再讲吧。

示例

首先我们用一个题目来引出我们所要讲解的内容:

题目描述
如题,已知一个数列,你需要进行下面两种操作:

  1. 将某区间每一个数加上x
  2. 求出某区间每一个数的和

输入格式
第一行包含两个整数N、M,分别表示该数列数字的个数和操作的总个数。
第二行包含N个用空格分隔的整数,其中第i个数字表示数列第i项的初始值。
接下来M行每行包含3或4个整数,表示一个操作,具体如下:
操作1: 格式:1 x y k 含义:将区间[x,y]内每个数加上k
操作2: 格式:2 x y 含义:输出区间[x,y]内每个数的和

输出格式
输出包含若干行整数,即为所有操作2的结果。

输入样例

5 5
1 5 4 2 3
2 2 4
1 2 3 2
2 3 4
1 1 5 1
2 1 4

输出样例

11
8
20

题目来源:P3372 【模板】线段树 1

这个例子本身如果使用循环解法的话,操作1是需要 O ( m ) O(m) O(m)次的, m m m为需要修改的个数,而操作2如果用DP,时间复杂度最快也要 O ( n ) O(n) O(n),并且还需要额外数组来存和,这样对于数据量较大的时候是不理想的,为了解决上述的问题,因此产生了线段树这个概念。

用线段树可以将时间复杂度由 O ( n ) O(n) O(n)降到 O ( l o g n ) O(logn) O(logn),而且后面最多只需要加一个常数而已,这样就更加适合频繁的查询和更新数据了。那么现在就来讲讲如何构造这么一个线段树。

性质

线段树本身是一个二叉平衡树,因此树的最大高度不会超过 O ( l o g n ) O(logn) O(logn),并且线段树的每个节点都存储着一个区间,代表这个节点存储着这个区间的信息,线段树的每个叶子节点代表单个数值,每个叶子节点也存储有区间,只不过是区间左边界和区间右边界相等的节点,因此线段树一定有n个叶子节点;而每个非叶子节点里面存储着代表该区间要维护的信息,可以是最大值最小值等等。

指针表示

根据上述描述可知,线段树的每个节点都会有这几个变量,左区间、右区间、维护的信息以及左孩子和右孩子,因此线段树的指针表示方法如下:

struct SegmentTree
{
    int l, r;	// 左右区间
    int tag;	// lazy tag,后面会介绍
    Data val;	// 数据
    SegmentTree *left, *right;	// 左右孩子
    SegmentTree(int _l, int _r, int _val) :
    	l(_l), r(_r), val(_val), left(nullptr), right(nullptr) {}
};

数组表示

然而,由于完全二叉树还可以用静态数组来进行表示,此时第 i 个节点的左孩子下标为 2i,右孩子为 2i+1。因此可以把线段树当成一颗完全二叉树放到数组里进行存储(虽然有些节点会一直用不上)。

基本操作

线段树基本有三个操作:

  1. Build —— 构建线段树
  2. Update —— 更新区间
  3. Query —— 查询区间

构建线段树

首先,为了能快速访问节点的左孩子和右孩子,增加如下函数:

inline ll ls(ll p) { return p << 1; } // 左孩子
inline ll rs(ll p) { return p << 1 | 1; } // 右孩子

并且根据不同的题目要求,实现从叶子节点到父节点的回溯更新(用 push_up 表示),这里就以示例为准,每个节点的信息维护这个区间的和:

void push_up(ll p) {
    ans[p] = ans[ls(p)] + ans[rs(p)];   // 回溯时,将信息往父节点传递
}

递归的构建子树,并且回溯的时候更新左子树和右子树

void build(ll p, ll l, ll r) {
    tag[p] = 0; // 初始化tag数组

    if (l == r) {
        ans[p] = a[l];
        return;
    }

    ll mid = (l+r) >> 1;
    build(ls(p), l, mid);
    build(rs(p), mid+1, r); // 递归建树

    push_up(p); // 回溯节点
}

更新区间

更新区间需要从末端开始更新,即从每个叶子节点开始更新,然后不断回溯更改父节点的维护的信息,以此来达到递归更新的效果。然而这样更新有个缺点就是:每次更新的时候都需要从叶子节点更新到父节点,假设现在有多个值需要更新,这些值根据线段树的结构可以分割成 m m m个区间,那么根据树的高度 n n n,则更新区间的时间复杂度就是 O ( l o g n + m ) O(logn+m) O(logn+m),而且每次更新都需要 O ( l o g n + m ) O(logn+m) O(logn+m)时间,因此,为了能更加节省时间,我们线段树的每个节点多维护一个叫做 Lazy Tag 的信息。

懒更新

节点内维护 Lazy Tag 这样的更新方法叫做懒更新。为什么叫做懒更新,是因为:Lazy Tag 存储的是每一次更新操作所要修改的值

懒更新的意思是:每此执行更新区间的操作的时候,假设某个节点的区间在需要更新的区间范围内,我们就直接更新该节点的值,并且用 Lazy Tag 去存储当前需要更新值,然后等到下次需要查询或者更新之前,我们先将之前需要进行更新的值通过 Lazy Tag 传递到子节点,并且对每个孩子节点进行更新。总结来说,就是我们先将需要更新的值保存下来,然而当下一次需要查询或者更新之前,将上一次需要更新的值再传递下去。(它的形式正好和回溯相反,所以我们这里用 push_down 来表示)

void func(ll p, ll l, ll r, ll k) {
    tag[p] += k;    // 加上从上向下传递的tag值,由于节点本身可能有tag,所以是"+="
    ans[p] += k*(r-l+1);    // 修改当前节点的信息
}

void push_down(ll p, ll l, ll r) {
    ll mid = (l+r) >> 1;
    func(ls(p), l, mid, tag[p]);
    func(rs(p), mid+1, r, tag[p]);  // 向左右孩子传递tag值,并更新信息
    tag[p] = 0; // 重置当前节点的tag值
}

void update(ll nl, ll nr, ll l, ll r, ll p, ll k) {
    // nl, nr 为要更新的区间的值
    // l, r 为当前节点所在的区间
    // p 为当前节点的下表
    // k 为要修改的值
    if (nl <= l && r <= nr) {
        func(p, l, r, k);   // 如果当前节点的区间在需要修改的区间内,则直接更新当前节点信息
        return;
    }

    push_down(p, l, r); // 在更新孩子节点之前,要将tag值向下传递

    ll mid = (l+r) >> 1;
    if (nl <= mid) update(nl, nr, l, mid, ls(p), k);
    if (nr > mid) update(nl, nr, mid+1, r, rs(p), k);
    
    push_up(p); // 孩子更新了之后,父节点的值也要进行更新
}

查询区间

查询区间就不多说了,也是递归回溯查询信息,需要注意的就是查询之前需要将 Lazy Tag 向子节点传递,防止子节点未更新

ll query(ll ql, ll qr, ll l, ll r, ll p) {
    ll res = 0;
    if (ql <= l && qr >= r) {
        return ans[p];
    }
    
    push_down(p, l, r);

    ll mid = (l+r) >> 1;
    if (ql <= mid) res += query(ql, qr, l, mid, ls(p));
    if (qr > mid) res += query(ql, qr, mid+1, r, rs(p));

    return res;
}

代码实现

具体代码实现如下,如有不对,还请指正

#include 
#include 

using namespace std;

#define MAXN 1000001
#define ll long long

inline ll ls(ll p) { return p << 1; } // 左孩子
inline ll rs(ll p) { return p << 1 | 1; } // 右孩子

unsigned ll a[MAXN], ans[MAXN<<2], tag[MAXN<<2];

void push_up(ll p) {
    ans[p] = ans[ls(p)] + ans[rs(p)];   // 回溯时,将信息往父节点传递
}

void build(ll p, ll l, ll r) {
    tag[p] = 0; // 初始化tag数组

    if (l == r) {
        ans[p] = a[l];
        return;
    }

    ll mid = (l+r) >> 1;
    build(ls(p), l, mid);
    build(rs(p), mid+1, r); // 递归建树

    push_up(p); // 回溯节点
}

void func(ll p, ll l, ll r, ll k) {
    tag[p] += k;    // 加上从上向下传递的tag值,由于节点本身可能有tag,所以是"+="
    ans[p] += k*(r-l+1);    // 修改当前节点的信息
}

void push_down(ll p, ll l, ll r) {
    ll mid = (l+r) >> 1;
    func(ls(p), l, mid, tag[p]);
    func(rs(p), mid+1, r, tag[p]);  // 向左右孩子传递tag值,并更新信息
    tag[p] = 0; // 重置当前节点的tag值
}

void update(ll nl, ll nr, ll l, ll r, ll p, ll k) {
    // nl, nr 为要更新的区间的值
    // l, r 为当前节点所在的区间
    // p 为当前节点的下表
    // k 为要修改的值
    if (nl <= l && r <= nr) {
        func(p, l, r, k);   // 如果当前节点的区间在需要修改的区间内,则直接更新当前节点信息
        return;
    }

    push_down(p, l, r); // 在更新孩子节点之前,要将tag值向下传递

    ll mid = (l+r) >> 1;
    if (nl <= mid) update(nl, nr, l, mid, ls(p), k);
    if (nr > mid) update(nl, nr, mid+1, r, rs(p), k);
    
    push_up(p); // 孩子更新了之后,父节点的值也要进行更新
}

ll query(ll ql, ll qr, ll l, ll r, ll p) {
    ll res = 0;
    if (ql <= l && qr >= r) {
        return ans[p];
    }
    
    push_down(p, l, r);

    ll mid = (l+r) >> 1;
    if (ql <= mid) res += query(ql, qr, l, mid, ls(p));
    if (qr > mid) res += query(ql, qr, mid+1, r, rs(p));

    return res;
}

int main() {
    int N, M;
    cin >> N >> M;

    for (int i=1; i<=N; ++i) {
        cin >> a[i];
    }

    build(1, 1, N);

    ll b, c, d, e, f;
    
    while(M--) {
        ll cmd;
        scanf("%lld", &cmd);

        switch(cmd)
        {
            case 1:{
                scanf("%lld %lld %lld", &b, &c, &d);
                update(b, c, 1, N, 1, d);
                break;
            }

            case 2:{
                scanf("%lld %lld", &e, &f);
                printf("%lld\n", query(e, f, 1, N, 1));
                break;
            }
        }
    }
    
    return 0;
}

  1. 洛谷日报#4 浅谈线段树(Segment Tree) ↩︎

  2. 树状数组 ↩︎

  3. 洛谷日报#24 浅谈权值线段树到主席树 ↩︎ ↩︎

  4. 洛谷日报#35 线段树的扩展之浅谈zkw线段树 ↩︎

你可能感兴趣的:(数据结构和算法)