目录

信竞学习笔记:线段树基础

发表于
更新于
8 4.1~5.3 分钟 1848

1. 认识线段树

1.1. 线段树的概念

线段树是一种主要用于维护区间信息的数据结构。主要用于单点修改、单点查询、区间修改、区间查询。

线段树单次操作时间复杂度为 O(\log n)

1.2. 线段树的基本思想

线段树的基本思想是将区间二分分段,构成一棵树,直至叶子节点所代表的区间只有一个元素为止。例如,将区间 [1,3] 分割成区间 [1,2] 和区间 [3,3]。此时区间 [1,2] 还并非只有一个元素,再分成区间 [1,1] 和区间 [2,2],即为分割成的线段树。

1.3. 线段树的性质

线段树有如下的性质:

  • 对于每个节点,要么没有子节点(即叶子结点),要么有两个子节点;
  • 长度为 n 的序列,建立的线段树有 2n-1 个节点;
  • 线段树的高度为 \lceil \log n \rceil

线段树维护的信息必须具有可合并性,即更大区间的信息可以通过小区间合并得到。

2. 基础的线段树

例题:洛谷 P3372

2.1. 建树

定义如下的结构体和数组作为节点:

struct node {
    int l, r, sum;
} t[N << 2];

注意这里数组要开成 4 倍大小:用数组存树,父节点编号为 i,左子节点编号为 2i,右子节点编号为 2i+1,线段树有 2n-1 个节点,最大节点编号不超过 2 \times (2n-1) + 1 = 4n-1。的所以只需要开 4 倍空间就可以了。

采用递归的方式建立线段树,如果 l = r,说明这个节点是叶子节点,否则,将区间进一步拆分,分为左子节点和右子节点,然后再将区间信息合并(一般称为 pushup)。代码如下:

void pushup(int u) {
    t[u].sum = t[u << 1].sum + t[u << 1 | 1].sum;
}

void build(int u, int l, int r) {
    if (l == r) t[u] = {l, r, a[l]}; // 叶子结点
    else {
        t[u].l = l, t[u].r = r;
        int mid = l + r >> 1;
        build(u << 1, l, mid);
        build(u << 1 | 1, mid + 1, r);
        pushup(u);
    }
}

build(1, 1, n);

2.2. 区间修改和查询

要修改某一区间 [l, r],实际上就是要修改所有完全包含在 [l, r] 中的线段树上的区间的值。考虑下面一个问题:如何在不专门修改一个区间的子区间的记录值的情况下,修改整个区间的记录值,同时保证区间和子区间的记录都正确、

为此,我们引入懒标记(lazy tag)的概念。具体来说,在修改当前区间的值时,懒标记将原本要下放到子区间的变化存储起来,在适当的时候再下传(pushdown),即下放到子区间。于是,我们首先写出 maketag 函数和 pushdown 函数:

void maketag(int u, int len, ll x) {
    tag[u] += x;
    t[u] += x * len;
}

void pushdown(int u, int l, int r) {
    int mid = l + r >> 1;
    maketag(u << 1, mid - l + 1, tag[u]);
    maketag(u << 1 | 1, r - mid, tag[u]);
    tag[u] = 0;
}

这里注意,在打懒标记的时候,要修改当前区间的记录值。下传懒标记之后,要将当前区间的懒标记清空,以避免重复统计。

接下来,我们要对区间进行修改。像前面说的,要修改某一区间 [l, r],实际上就是要修改所有完全包含在 [l, r] 中的线段树上的区间的值。那么,如果一个节点代表的区间完全被包含在 [l, r] 中,我们可以直接对这个区间打标记修改;如果这个区间与 [l, r] 有重合,就要再对这个区间进行分割,直到区间被 [l, r] 完全包含。在这个过程中,我们先下传区间懒标记信息,更新子区间信息后在汇总信息。代码如下:

void update(int u, int L, int R, int l, int r, ll x) {
    if (l <= L && R <= r) maketag(u, R-L+1, x);
    else if (l <= R && r >= L) {
        int mid = L + R >> 1;
        pushdown(u, L, R);
        update(u<<1, L, mid, l, r, x);
        update(u<<1|1, mid+1, R, l, r, x);
        pushup(u);
    }
}

区间查询也是同理,注意查询子区间前要下传懒标记,否则子区间数据不正确。代码如下:

ll query(int u, int L, int R, int l, int r) {
    if (l <= L && R <= r) return t[u];
    else if (L > r || R < l) return 0;
    else {
        int mid = L + R >> 1;
        pushdown(u, L, R);
        return query(u<<1, L, mid, l, r) + query(u<<1|1, mid+1, R, l, r);
    }
}

至此,我们便完成了单懒标记线段树的操作的实现。

3. 多懒标记线段树

3.1. 乘法和加法的处理

例题:洛谷 P3373

考虑采用乘法和加法两个懒标记,我们可以为相关函数添加 type 参数来区分乘法和加法操作。考虑下面的问题:在下传时,应该先下传乘法,还是先下传加法?

假定一个区间原来的记录值是 t,考虑下面两种情况:先乘 x,再加 y,此时记录值应该变更为 xt + y,注意到此时两个标记互不产生影响;先加 x,后乘 y,此时记录值应该变更为 (t + x)y,即 xt + xy,注意到此时,乘法标记 y 对加法标记 x 产生了影响。

因此,我们在修改乘法标记时,需要同步修改加法标记;同时,在下传标记时,我们要先下传乘法标记,再下传加法标记。有了以上思路,这道题代码只需在上面的基础上稍作修改即可。这里不再给出。注意本体要求模去一个大数,要在加和乘的时候做处理,防止数据溢出。

3.2. 赋值和加法的处理

例题:洛谷 P1253

在这道题中,要求区间最大值。考虑最大值这一操作具有可合并性。并且有两个操作:赋值和加法,因此我们需要使用两个懒标记。考虑:在赋值时,原本的加法懒标记都会失效;在加法时,如果已经有赋值懒标记了,那么加法会作用在赋值懒标记的基础上。也就是说,如果当前操作是赋值,就直接修改赋值标记并将加法标记清空;如果当前操作时加法,考虑两种情况:如果当前赋值标记不为空,在赋值标记上做加法,否则在加法标记上做加法。由于有可能赋值为 0,因此赋值标记为空时应当用一个极大值进行标记(如 INT_MAX0x3f3f3f3f)。将 maketagpushdown 函数修改如下:

void maketag(int u, ll x, int type) {
    if (type == 1) { // 赋值操作
        tag_add[u] = 0, tag_set[u] = x, t[u] = x;
    }
    else { // 加法操作
        if (tag_set[u] == INF) tag_add[u] += x;
        else tag_set[u] += x;
        t[u] += x;
    }
}

void pushdown(int u) {
    if (tag_set[u] == INF) {
        maketag(u<<1, tag_add[u], 2);
        maketag(u<<1|1, tag_add[u], 2);
        tag_add[u] = 0;
    }
    else {
        maketag(u<<1, tag_set[u], 1);
        maketag(u<<1|1, tag_set[u], 1);
        tag_set[u] = INF;
    }
}

4. 总结

在算法竞赛中,线段树是一种非常有用的数据结构,可以用于维护区间可合并信息(如加和、最大值等)。有效利用线段树,是算法水平进阶的一个重要能力。


上一篇 短文一篇