【1 月小记】Part 1: 树状数组 / 线段树进阶

树状数组 / 线段树进阶

一、分块、线段树、树状数组

我们在维护支持“区间修改、单点查询”的序列时,可以从分块想到多层分块,将每层的块长钦定为 \(\dfrac n2\),随后层数变为 \(O(\log n)\) 量级的,单词修改或查询的时间复杂度都变为了 \(O(\log n)\),极大地优化了时间开销。

接着我们发现每一个块都在下一层被分成了两个块,这有点像树状数据结构,于是我们叫它线段树。

从分块说开来,第一个诞生的,用于维护这种序列操作的数据结构应该是线段树。

这里的线段树只是从分块演变而来的一个初级版本,可以被认为是多层分块中的一种特殊情况,还没有懒标记的事情呢,自然也维护不了区间修改区间查询的序列。

线段树因为其特殊的节点存储方式(即二叉树的左右儿子表示法),需要开 \(4n\) 的空间,很大程度上约束了空间开销。

接着,我们遇到了空间限制,于是开始想解决方法。我们充分发扬人类智慧,运用差分思想,注意到 \([l,r]\) 的询问可以被拆分成 \([1, r] - [1,l - 1]\) 的。于是我们只需要支持一种询问操作,使其能够查询序列内区间 \([1, x]\) 的所有元素之和。

同样地,我们依旧在线段树上模拟这个过程,发现如果要求 \([1, x]\) 区间内元素之和,那么对于其递归下来的任意一个区间,它一定总完全包含线段树上某一个节点的左子结点。也就是说,对于这样的任意一个区间,总是完全贴着线段树的“边”的。因此,我们发现,对于线段树上任意一个节点的右子节点,它永远不会被访问。这样的话,把这些右子节点删除,就只剩下 \(n\) 个节点了(读者自证不难),空间开销变为了原来的四分之一,进而我们就可以用数组存储这个数据结构。

接着就是 lowbit 这种事情了。

纵观历程可以看到,树状数组在解决单点修改区间查询问题中,运用了差分思想,消除了线段树递归时“不贴边”的弊端,减少了有用的块,减少了空间开销。因此,树状数组更像是线段树的一种优化版。

二、树状数组常见科技

1. 用于维护支持区间修改单点查询的序列

在差分数组上维护树状数组即可。比较简单的一个科技,这里不多赘述。

2. 用于维护支持区间修改区间查询的序列

我们在 P3368 一题中已经学会了区间修改单点查询的差分数组写法,现在我们尝试将单点查询修改到区间查询。

我们知道,对于数组 \(a\) 的差分数组 \(d\),对于任意的下标 \(i\),满足 \(a_i = \sum _{j=1}^{i}d_j\)

因此,想要查询区间 \([1, x]\) 内所有数字的和,等价于求对于每个 \(i\in[1, x]\),其对应的 \(\sum _{j=1}^{i}d_j\) 的和(即两次求和)。例如,求 \([1, 3]\) 的区间和,等价于求 \((d_1) + (d_1 + d_2) + (d_1 + d_2 + d_3)\)

因此,bit.query(int x) 应当返回 \(xd_1 + (x-1)d_2 + (x-2)d_3 + ... + d_x\),即等于 \(x(d_1 + d_2 + ... + d_x) - (0d_1 + 1d_2 + 2d_3 + ... + (x-1)d_x)\)

进一步地,当我们实施区间加和的操作时,只需要在 \(d_1, d_2, ..., d_n\) 这个差分数组,以及 \(0d_1, 1d_2, ..., (n-1)d_n\) 这个带权的差分数组上修改所要加和的区间的两个端点即可。

部分代码如下:

struct BIT {
	int t[N][2];
	BIT() { memset(t, 0, sizeof(t)); }
	inline int lowbit(int x) { return x & -x; }
	// 0 代表不带权的差分数组,1 代表带权的差分数组!
	void modify(int x, int d) {
		for (int i = x; i <= n; i += lowbit(i)) {
			t[i][0] += d;
			t[i][1] += (x - 1) * d;
		}
	}
	int query(int x) {
		int res = 0;
		for (int i = x; i; i -= lowbit(i)) {
			res += x * t[i][0] - t[i][1];
		}
		return res;
	}
} bit;

3. 用于维护不可差分信息

一般而言,树状数组维护的信息及运算要满足结合律且可差分。此处可差分是指,对于某种运算符 \(*\),已知 \(x * y\)\(x\),可以求出 \(y\)。显然地,像加法、乘法、异或这些运算都是可差分运算;像 \(\max\)\(\min\) 这些运算都是不可差分运算。

所以树状数组一般不能用于维护区间最值。但是这里为了学习树状数组,我们搞一个解法。实际上有一种 \(O(n \log^2 n)\) 的做法,可以使得树状数组能够带修维护不可差分运算。

考虑对于区间 \([l, r]\),存在以下操作:

区间查询

\(r\) 一直沿着它的 \(\text{lowbit}\) 往前跳,但是肯定不能跳到 \(l\) 的左边。因此如果我们跳到了 \(t[x]\) 这个位置,要判断下一次所跳到的位置(即 \(x - \text{lowbit}(x)\))是否小于 \(l\)。如果小于,直接将原数组这一个点合并到答案里,然后把 \(r\) 指针左移一位;如果大于或等于 \(l\),合并 \(t[x]\) 到答案里,再跳即可。

单点修改

\(x\) 这个位置更新每个区间时,顺便枚举这个区间所包含的小区间,然后更新它,这样就算小区间也包含了这个位置。而小区间一定被提前更新过。

部分代码如下:

struct BIT {
	int t[N][2];
	BIT() { memset(t, 0, sizeof(t)); }
	inline int lowbit(int x) { return x & -x; }
	void change(int x, int k) {
		t[x][0] = t[x][1] = a[x] = k;
		while (x <= n) {
			t[x][0] = t[x][1] = a[x];
			for (int j = 1; j < lowbit(x); j <<= 1) {
				t[x][0] = min(t[x][0], t[x - j][0]);
				t[x][1] = max(t[x][1], t[x - j][1]);
			}
			x += lowbit(x);
		}
	}
	int query_min(int l, int r) {
		int res = inf;
		while (l <= r) {
			if (l <= r - lowbit(r) + 1) {
				res = min(res, t[r][0]);
				r -= lowbit(r);
			} else {
				res = min(res, a[r]);
				r--;
			}
		}
		return res;
	}
	int query_max(int l, int r) {
		int res = 0;
		while (l <= r) {
			if (l <= r - lowbit(r) + 1) {
				res = max(res, t[r][1]);
				r -= lowbit(r);
			} else {
				res = max(res, a[r]);
				r--;
			}
		}
		return res;
	}
	int query(int l, int r) {
		return query_max(l, r) - query_min(l, r);
	}
} bit;

三、树状数组与线段树在扫描线中的应用

例题 1:P2163 [SHOI2007] 园丁的烦恼

首先我们还是利用差分思想,将矩形查询转换为二维差分。这样,我们只需要维护查询坐标系内点 \((1,1)\) 与点 \((x,y)\) 构成的矩形内有多少个点就可以了。

我们利用离线思想,把所有操作存到一起。在坐标系内添加的点,我们称之为“追加点”;查询操作中询问的点,我们称之为“查询点”。

考虑一条平行于 \(x\) 轴,从负无穷到正无穷不断移动的扫描线。这条扫描线上,不同的位置代表不同的权值,可以认为是一根数轴。对于这条扫描线,每遇到一个追加点,就在这条扫描线上,将追加点所对应的横坐标的位置 \(+1\);每遇到一个查询点,就在扫描线上查询小于等于查询点横坐标的点的个数即可。

我们发现这是单点改区间查的问题。于是我们使用树状数组。值域范围比较大,所以需要离散化。

例题 2:P1908 逆序对

这题是常客了,各种算法都能解决这玩意。

考虑将数组上的每个数抽象为平面直角坐标系内一个坐标为 \((a_i,i)\) 的点,这样就和上道题一样了。

因为值域比较大,所以我们考虑离散化。定义 \(rk_i\) 表示 \(a_i\) 在数组内的相对大小。不妨使用一种特制的排序方法,保证两个数的 \(rk\) 一定不会相同。

树状数组中,query 保证了之前所 change 的所有 \(i\) 都是小于现在的 \(i\) 的,因此现在只需要考虑 \(a_i > a_j\) 这一个限制即可。

例题 3:P5490 【模板】扫描线 & 矩形面积并

这道题的核心思路是采用垂直于 \(y\) 轴的扫描线,将二维平面上的矩形面积并问题转化为一维的区间覆盖问题。想象有一根水平的直线从 \(y\) 轴负无穷向正无穷匀速移动,这根扫描线在移动过程中会与矩形相交,相交部分形成若干条线段。我们的核心观察是:相邻两条扫描线之间的面积可以看作是一个矩形的面积,这个矩形的高度是两条扫描线的 \(y\) 坐标之差,宽度是当前扫描线被矩形覆盖的总长度。

我们使用离线思想。首先,我们将每个矩形的两条垂直边(上边界和下边界)看作是两个事件。下边界事件表示扫描线开始进入这个矩形,上边界事件表示扫描线离开这个矩形。每个事件包含四个信息:\(y\) 坐标(扫描线位置)、\(x\) 区间左边界、\(x\) 区间右边界、以及类型(\(+1\) 表示进入,\(-1\) 表示离开)。

由于 \(x\) 坐标的范围可能很大,我们需要对 \(x\) 坐标进行离散化处理。

接下来我们按 \(y\) 坐标从小到大处理所有事件。维护一棵线段树,这棵线段树覆盖了所有离散化后的 \(x\) 区间。线段树的每个节点需要记录两个关键信息:该区间被矩形覆盖的次数(\(cnt\)),以及该区间实际被覆盖的长度(\(len\))。当一个事件来临时,我们在线段树上更新对应的 x 区间(区间覆盖次数增加或减少),然后通过 pushup 操作更新每个区间的实际覆盖长度。

这里有一个精妙的设计。如果一个区间的 \(cnt \ne 0\),说明这个区间被完全覆盖,那么它的 \(len\) 就是这个区间对应的实际长度;如果 \(cnt = 0\),那么它的 \(len\) 就等于两个子区间 \(len\) 之和(对于叶子节点则为 \(0\))。

计算面积的过程是增量式的。在处理第 \(i\) 个事件时(\(i > 0\)),我们知道当前扫描线位置是 \(evt_i.y\),上一个事件的位置是 \(evt_{i-1}.y\),这两条扫描线之间的距离就是两者之差 \(\Delta y\)。此时线段树根节点记录的 \(len\) 就是在这段区间内被矩形覆盖的总高度。因此,从上一个事件到当前事件这一段的面积就是 \(len \times \Delta y\)。将所有这样的面积累加起来,就得到了最终的总面积。

四、动态开点线段树

例题:P13825 【模板】线段树 1.5

动态开点线段树是一种在需要时才创建节点的线段树,能够有效节省空间并处理大范围的数据。

动态开点线段树与普通线段树的主要区别在于,动态开点线段树并不在一开始就构建所有节点,而是根据需要动态创建节点。这种方法可以显著减少内存使用,尤其是在处理大范围数据时。动态开点线段树的空间复杂度大致为 \(O(n\log ⁡n)\)

实现方法

  1. 节点结构:每个节点通常包含表示区间的左右边界和该区间的值。

  2. 开点操作:当需要访问某个节点时,如果该节点尚未创建,则动态分配内存并初始化该节点。可以使用数组或结构体来存储节点信息。

更新和查询

动态开点线段树的更新和查询操作与普通线段树类似,但需要在访问节点时检查节点是否已创建。如果未创建,则先创建节点。

应用场景

动态开点线段树适用于以下场景:

  1. 处理大范围数据:当数据范围非常大时,普通线段树可能会占用过多内存,而动态开点线段树可以根据实际需要动态分配内存。

  2. 稀疏数据:在某些情况下,数据可能是稀疏的,动态开点线段树可以有效地处理这些情况,而不必为未使用的节点分配内存。

五、值域线段树

例题:P3369 【模板】普通平衡树

我们的思想是:将这个可重集视为一个桶,在这个桶上种植线段树。

对于操作 1 和操作 2,它们等价于在这棵树上进行单点修改。

对于操作 3,等价于树上 \([1, x]\) 的区间求和。

对于操作 4,可以考虑在线段树上二分,即对于每个节点,若要查询的数 \(k\) 小于或等于左子节点的桶的区间和,往左递归;反之,往右递归,并补偿左子结点的损失。

对于操作 5 和操作 6,存在

\[\text{pre}(x) = \text{kth}(\text{query}(x - 1))\\ \text{suf}(x) = \text{kth}(\text{query}(x) + 1) \]

利用这个等式即可。

由于值域比较大,所以需要动态开点。

代码:

#include <bits/stdc++.h>
#define int long long
using namespace std;
constexpr int N = 1e5 + 5, inf = 1e7;
int q;
struct Seg {
	int t[N << 2], lson[N << 5], rson[N << 5];
	#define mid (l + r >> 1)
	#define lson lson[id]
	#define rson rson[id]
	int tot = 1;
	void pushup(int id) {
		t[id] = t[lson] + t[rson];
	};
	int modify(int &id, int l, int r, int pos, int val) {
		if (!id) id = ++tot; // 动态开点
		if (l == r) {
			t[id] += val;
			return id;
		} else {
			if (pos <= mid) modify(lson, l, mid, pos, val);
			else modify(rson, mid + 1, r, pos, val);
			pushup(id);
			return id;
		}
	}
	// 操作 3 所用的 query 函数,实质上是区间求和。
	int query_cnt(int id, int l, int r, int ql, int qr) {
		if (!id) return 0;
		if (l == ql && r == qr) {
			return t[id];
		} else {
			if (qr <= mid) return query_cnt(lson, l, mid, ql, qr);
			else if (ql > mid) return query_cnt(rson, mid + 1, r, ql, qr);
			else return query_cnt(lson, l, mid, ql, mid) + query_cnt(rson, mid + 1, r, mid + 1, qr);
			pushup(id);
		}
	}
	int query_kth(int id, int l, int r, int k) {
		if (!id) return -1;
		if (l == r) {
			return l;
		} else {
			if (k <= t[lson]) return query_kth(lson, l, mid, k);
			else return query_kth(rson, mid + 1, r, k - t[lson]); // 一定要注意查询的数是哪个
		}
		return -1;
	}
} seg;
signed main() {
	cin.tie(0) -> sync_with_stdio(0);
	cin >> q;
	int root = 1;
	while (q--) {
		int op, x;
		cin >> op >> x;
		if (op == 1) {
			seg.modify(root, -inf, inf, x, 1);
		} else if (op == 2) {
			seg.modify(root, -inf, inf, x, -1);
		} else if (op == 3) {
			cout << seg.query_cnt(1, -inf, inf, -inf, x - 1) + 1 << '\n';
		} else if (op == 4) {
			cout << seg.query_kth(1, -inf, inf, x) << '\n';
		} else if (op == 5) {
			cout << seg.query_kth(1, -inf, inf, seg.query_cnt(1, -inf, inf, -inf, x - 1)) << '\n';
		} else {
			cout << seg.query_kth(1, -inf, inf, seg.query_cnt(1, -inf, inf, -inf, x) + 1) << '\n';
		}
	}
	return 0;
}

六、线段树的分治思想

例题 1:T563191 线段树 - 最大子段和

例题 2:P2572 [SCOI2010] 序列操作

以上两道例题见前一篇博客

例题 3:P7706 「Wdsr-2.7」文文的摄影布置

直接看代码吧,里面有注释

struct Seg {
    #define mid (l + r >> 1)
    #define lson (id << 1)
    #define rson (id << 1 | 1)
    // 求:max(a_a - b_b + a_c) (l <= a < b < c <= r)
    // 单点改不用懒标记。
    struct Node {
        // 考虑到答案的来源有四种(其中 | 为左右分割),所以我们需要维护一些信息。
        // 1. i j k |
        // 2. i j | k
        // 3. i | j k
        // 4. | i j k
        // 这些信息比较复杂,以下逐个说明。
        int max_a; // 区间内最大的 a[i]
        int min_b; // 区间内最小的 b[i](因为所求式子里 b 项前面是负号)
        int max_a_b; // 区间内最大的 a[i] - b[j] (i < j)
        int max_b_a; // 区间内最大的 -b[j] + a[i] (i < j)
        int max_ans; // 答案
        Node() {
            max_a = max_a_b = max_b_a = max_ans = -inf;
            min_b = inf;
        }
        friend Node operator + (Node a, Node b) {
            Node res;
            res.max_a = max(a.max_a, b.max_a);
            res.min_b = min(a.min_b, b.min_b);
            res.max_a_b = max({a.max_a_b, b.max_a_b, a.max_a - b.min_b});
            res.max_b_a = max({a.max_b_a, b.max_b_a, -a.min_b + b.max_a});
            res.max_ans = max({a.max_ans, b.max_ans, a.max_a_b + b.max_a, a.max_a + b.max_b_a});
            return res; // 这句话千万不要忘了!!!!
        }
    } t[N << 2];
    void pushup(int id) {
        t[id] = t[lson] + t[rson];
    }
    void build(int id, int l, int r) {
        if (l == r) {
            t[id].max_a = a[l];
            t[id].min_b = b[l];
        } else {
            build(lson, l, mid);
            build(rson, mid + 1, r);
            pushup(id);
        }
    }
    void change(int id, int l, int r, int type, int pos, int val) {
        if (l == r) {
            if (type == 1) { // 修改 a 数组
                t[id].max_a = val;
            } else { // 修改 b 数组
                t[id].min_b = val;
            }
            return;
        } else {
            if (pos <= mid) change(lson, l, mid, type, pos, val);
            else change(rson, mid + 1, r, type, pos, val);
            pushup(id);
        }
    }
    Node query(int id, int l, int r, int ql, int qr) {
        if (l == ql && r == qr) {
            return t[id];
        } else {
            if (qr <= mid) return query(lson, l, mid, ql, qr);
            else if (ql > mid) return query(rson, mid + 1, r, ql, qr);
            else return query(lson, l, mid, ql, mid) + query(rson, mid + 1, r, mid + 1, qr);
        }
    }
} seg;

七、矩乘线段树

1. 广义矩阵乘法

考虑将原矩阵乘法法则推广,即广义矩阵乘法。

对于矩阵 \(A_{n×m}\)\(B_{m×r}\),若有

\[C_{i,j}=A×B=\oplus_{k=1}^m (A_{i,k}\otimes B_{k,j}) \]

则称之为 \((\otimes,\oplus)\) 的矩阵乘法。

当满足以下条件时,广义矩阵乘法满足结合律:

  • \(\oplus\) 具有交换律;
  • \(\otimes\) 具有结合律和交换律;
  • \(\otimes\)\(\oplus\) 存在分配律。

常见的广义矩阵乘法形式有 \((\times, +)\)\((+,\max / \min⁡)\)\((\and,\or)\)\((\or,\and)\)

2. 为什么要用矩乘替代多重标记下传

当我们涉及多种操作,例如区间加法和区间乘法同时存在时,传统的多重标记下传方法往往会遇到标记合并顺序的难题。这是因为加法和乘法操作不具备交换律,先乘后加与先加后乘会导致完全不同的结果。如果直接在线段树节点上分别维护加法和乘法标记,在下传时必须严格规定操作顺序,这常常会引发逻辑混乱与错误(例如:P7453 [THUSC 2017] 大魔法师,这题如果写传统懒标记会极其麻烦)。

使用矩阵乘法来统一表示这两种操作,可以优雅地解决顺序问题。其核心思想是将每个区间视为一个单列矩阵(即向量),而加法和乘法操作都可以表示为对这个向量进行线性变换的矩阵。

矩阵乘法的关键优势在于结合律,即无论连续进行多少次乘法和加法操作,这些操作对应的矩阵可以先按顺序相乘,得到一个复合矩阵。这个复合矩阵完全保留了所有操作的原始顺序信息。在线段树中,我们将这个复合矩阵作为“懒标记”打在节点上。下传时,只需用父节点的矩阵去乘子节点的矩阵(注意顺序),就能保证操作顺序完全正确,从而避免了手动处理“先乘后加”或“先加后乘”的复杂逻辑。

这种方法将多种操作抽象为统一的矩阵形式,把顺序问题转化为矩阵乘法的结合律问题,由矩阵乘法自身的代数性质来保证正确性。它提升了代码的抽象层次与可维护性,尤其适合操作种类多、交互复杂的情景。虽然矩阵运算带来了一定的常数开销,但其在逻辑清晰性与可扩展性上的优势,使得它在处理多重标记顺序敏感的问题时,成为一种非常有力且可靠的设计范式。

另外,在 \((+, \max)\) 问题中,虽然变换可能不是线性的,看似不能用狭义矩阵维护,但加法操作与最大值操作的复合依然是满足分配律的,因此依然可以沿用这种矩阵合并的思想。这正是其强大通用性的根源。

3. 矩乘思想在复杂信息维护中的应用

dyx 的模板 U648565 【模板】矩乘线段树

大型复杂矩阵的推导 P4314 CPU 监控

广义矩阵的应用 P1253 扶苏的问题

posted @ 2026-01-15 20:33  L-Coding  阅读(2)  评论(0)    收藏  举报