线段树的懒标记(Lazy Propagation)
极难3懒标记(Lazy Propagation):让线段树“偷懒”却更高效
为什么线段树需要“懒标记”?
想象一下,你有一排连续书架(1到n个位置),每个位置放一本书。你想给某个区间(比如第2到第5本)的所有书都贴上标签“科幻”。如果每次贴标签都要走到每一本书前亲手贴,那贴一个区间可能花费很多时间。但如果你在每层的书架管理员那里记一个“这个区间里的书都该贴科幻标签”的标记,而不去实际触碰每一本书,等以后有人要查看某本书时,再根据标记处理,那效率就高多了——这就是“懒标记”的思想。
在线段树中,我们经常需要对一个连续区间进行修改(比如每个数加一个值、赋一个值)。如果每次都更新到叶子节点,那么修改一个区间的时间复杂度会变成O(n)(n是区间长度),失去了线段树O(log n)的优势。懒标记(Lazy Propagation)通过“延迟更新”解决了这个问题:只在完全覆盖的节点上打一个标记,记录“本区间需要统一做的操作”,而暂时不更新它的子节点。 只有当后续查询或修改需要深入到子区间时,才把标记“下传”给子节点,保证正确性。这样,每次区间修改只需在O(log n)个节点上操作。
生活中的类比:老师布置作业的“懒”方法
(保留原有内容)数学老师布置了30道练习题,说:“全班同学都要做,但我不现在批改,等下周再统一收上来改。”这其实就是“懒标记”。老师先记录下“全班需要做30题”这个信息,但暂时不检查每个同学是否真的做了。等到下周收作业时,再逐个检查。如果中间有同学转学进来,老师会把“30题”这个标记传递给他,他也要做。
在线段树中,懒标记就是类似的思想:当我们要把一个区间[L,R]内的每个数都加上一个值d时,我们不立即更新这个区间下的所有叶子节点,而是只在当前节点上打一个“加d”的标记(懒标记),并更新当前节点的值(因为当前节点代表区间,它的和可以直接加上d * 区间长度)。然后暂停向下更新。只有当后续查询或修改需要深入到某个子区间时,才把懒标记下传给子节点。这样每个区间修改操作只需要更新O(log n)个节点。
懒标记的原理与实现
数据结构定义
除了线段树的tree数组外,还需要一个lazy数组(懒标记数组),lazy[p]表示节点p上挂着的尚未下传的加法值(也可以是赋值标记等)。初始时lazy[p] = 0。
核心操作:pushdown(下传标记)—— 关键中的关键
函数pushdown(p, l, r) 将节点p的懒标记传递给它的左右子节点:
- 如果lazy[p] != 0,说明当前节点有未下传的加法。
- 计算左子树区间长度len_left = mid - l + 1,右子树区间长度len_right = r - mid。
- 更新左子树的tree值和lazy标记:tree[p2] += lazy[p] * len_left,lazy[p2] += lazy[p]。
- 同理更新右子树。
- 清除当前节点的懒标记:lazy[p] = 0。
注意:pushdown只会在需要深入子节点时调用,比如在递归的update或query中,访问左右孩子之前先调用pushdown。
为什么要用pushdown? 因为懒标记只记录在父节点上,子节点的实际值还是旧的。如果我们直接访问子节点,得到的就是错误值。所以,只要我们需要进入子节点,就必须先把父节点上的标记“下传”给子节点,让子节点也更新自己的值和标记。这就像老师先把“30题”这个要求记录在班级大名单上,当有同学转班到另一个小组时,老师要把这个要求告诉那个小组长,小组长再负责组员。
区间修改(增加一个值)
函数update(p, l, r, L, R, d):
- 如果当前区间完全被[L,R]覆盖,则直接更新tree[p] += d * (r-l+1),并打上懒标记lazy[p] += d,不再向下递归。这是“偷懒”的关键一步。
- 否则,先调用pushdown下传当前节点的懒标记,然后递归左右子树。
- 回溯时合并左右子节点:tree[p] = tree[p2] + tree[p2+1]。
这样,一次区间修改只影响O(log n)个节点(覆盖路径上的节点和它们的孩子),但每次遇到部分覆盖的节点时都会下传标记,保证后续查询正确。
例子: 假如数组长度为8,初始全为0。执行区间[2,5]加10。参考下面ASCII示意图,我们会发现:节点[3,4]被完全覆盖,所以直接打上标记lazy=10并更新tree=20,而不去更新叶子3和4。只有节点[2,2]和[5,5]因为部分覆盖,才不得不递归到叶子处理。这样,整个操作只访问了少数节点。
区间查询
查询函数query(p, l, r, L, R)在递归时,如果当前区间完全被覆盖则直接返回。否则,先调用pushdown,然后递归左右子树。因为我们需要到子节点获取准确的信息,所以必须先把本节点的懒标记下传。
注意: 查询时也要调用pushdown!因为如果某个父节点还挂着懒标记,它的子节点的实际值还没有更新,直接访问子节点会得到错误结果。例如,上面[3,4]节点打了加10的标记,如果我们要查[3,3]的值,就必须先下传标记,更新叶子3和4,然后再取叶子3的值。
ASCII示意图
(保留原有内容)假设初始数组全为0,长度为8。执行区间[2,5]加10的过程:
[1,8] lazy=0
/ \
[1,4] [5,8]
/ \ / \
[1,2] [3,4] [5,6] [7,8]
/ \ / \ / \ / \
1 2 3 4 5 6 7 8
首先从根节点[1,8]开始,完全覆盖?不,区间[2,5]部分覆盖。所以根节点先调用pushdown(但lazy=0,无事)。然后递归左孩子[1,4]:区间[2,5]与[1,4]部分覆盖(重叠[2,4])。在[1,4]节点上,它不是完全覆盖,于是又递归进入左孩子[1,2]和右孩子[3,4]。
- [1,2]节点:与[2,5]重叠[2,2],部分覆盖,继续递归左右。
- 左孩子[1,1]:不相交,返回。
- 右孩子[2,2]:完全覆盖([2,2]在[2,5]内),更新tree[2,2] += 10,lazy[2,2] += 10(但叶子节点没有后代)。回溯时更新[1,2]的和。
- [3,4]节点:完全覆盖(因为[3,4]在[2,5]内),直接更新tree[3,4] += 10*2=20,lazy[3,4] += 10,不再向下递归。 回溯更新[1,4]的tree,然后返回根节点,再递归右孩子[5,8]。
- [5,8]与[2,5]重叠[5,5],部分覆盖,进入左孩子[5,6]。
- [5,6]与[2,5]重叠[5,5],部分覆盖,进入左孩子[5,5]完全覆盖,更新+10;右孩子[6,6]不相交。 回溯更新。
最终只有少数节点被更新或打了标记,而[3,4]这样的区间直接打标记,没有深入到叶子。由于标记的存在,之后查询[3,4]时就会得到正确值,而查询[3,3]时会先下传标记再得到正确值。
新手容易犯的常见错误
-
忘记在query中调用pushdown
如果query函数在递归子节点前没有调用pushdown,那么子节点的值可能是过时的。比如上面例子中,查询[3,3]时如果忘记下传[3,4]上的标记,就会返回0(错误),实际应该是10。 -
在pushdown中更新子节点时忘了加上父节点的标记
注意是tree[p*2] += lazy[p] * left_len,而不是直接赋值。因为子节点可能本身也有自己的标记(来自之前的更新),我们要叠加。 -
区间修改时,对完全覆盖的节点忘了更新tree值
有些人只打了标记,但没更新当前节点的tree,导致父节点向上合并时出错。必须同时更新tree[p] += d * (r-l+1) 和 lazy[p] += d。 -
递归边界判断错误
比如if (L <= l && r <= R)中的条件写反成l <= L && r <= R,会导致区间不匹配时的错误覆盖。 -
数组大小开得不够
线段树通常需要4 * n的空间,有人只开2*n会导致越界。尤其是带有懒标记时,也可能需要4*n。
完整代码实现(区间加+区间求和,带懒标记)
C++ 实现
#include <iostream>
using namespace std;
const int MAXN = 10000;
int a[MAXN]; // 原始数组
int tree[4 * MAXN]; // 线段树节点存储区间和
int lazy[4 * MAXN]; // 懒标记数组,记录未下传的加法值
// 建树:从原始数组a构造线段树
void build(int p, int l, int r) {
if (l == r) {
tree[p] = a[l];
return;
}
int mid = (l + r) / 2;
build(p * 2, l, mid);
build(p * 2 + 1, mid + 1, r);
tree[p] = tree[p * 2] + tree[p * 2 + 1];
}
// 下传懒标记:将节点p的标记传递给左右孩子
void pushdown(int p, int l, int r) {
if (lazy[p] != 0) {
int mid = (l + r) / 2;
int left_len = mid - l + 1; // 左子树区间长度
int right_len = r - mid; // 右子树区间长度
// 更新左孩子
tree[p * 2] += lazy[p] * left_len;
lazy[p * 2] += lazy[p];
// 更新右孩子
tree[p * 2 + 1] += lazy[p] * right_len;
lazy[p * 2 + 1] += lazy[p];
// 清除当前节点标记
lazy[p] = 0;
}
}
// 区间修改:区间[L,R]每个元素加d
void update(int p, int l, int r, int L, int R, int d) {
if (L <= l && r <= R) { // 当前区间完全被覆盖
tree[p] += d * (r - l + 1); // 更新当前节点和
lazy[p] += d; // 打上懒标记
return;
}
pushdown(p, l, r); // 否则先下传标记
int mid = (l + r) / 2;
if (L <= mid) update(p * 2, l, mid, L, R, d);
if (R > mid) update(p * 2 + 1, mid + 1, r, L, R, d);
tree[p] = tree[p * 2] + tree[p * 2 + 1]; // 回溯更新
}
// 区间查询:返回区间[L,R]的和
int query(int p, int l, int r, int L, int R) {
if (L <= l && r <= R) return tree[p]; // 完全覆盖直接返回
pushdown(p, l, r); // 必须下传,保证子节点正确
int mid = (l + r) / 2;
int res = 0;
if (L <= mid) res += query(p * 2, l, mid, L, R);
if (R > mid) res += query(p * 2 + 1, mid + 1, r, L, R);
return res;
}
int main() {
int n = 8;
for (int i = 1; i <= n; i++) a[i] = 0; // 初始全0
build(1, 1, n);
cout << "Initial sum [1,8] = " << query(1, 1, n, 1, 8) << endl;
update(1, 1, n, 2, 5, 10); // 区间加10
cout << "After update [2,5]+10: sum [1,8] = " << query(1, 1, n, 1, 8) << endl;
cout << "Sum [3,4] = " << query(1, 1, n, 3, 4) << endl; // 应为20
cout << "Sum [2,2] = " << query(1, 1, n, 2, 2) << endl; // 应为10
return 0;
}
输出:
Initial sum [1,8] = 0
After update [2,5]+10: sum [1,8] = 40
Sum [3,4] = 20
Sum [2,2] = 10
Python 实现
class SegmentTree:
def __init__(self, data):
self.n = len(data)
# 将数据转成1-based索引,方便处理
self.a = [0] + data
self.tree = [0] * (4 * (self.n + 1))
self.lazy = [0] * (4 * (self.n + 1))
def build(self, p, l, r):
if l == r:
self.tree[p] = self.a[l]
return
mid = (l + r) // 2
self.build(p * 2, l, mid)
self.build(p * 2 + 1, mid + 1, r)
self.tree[p] = self.tree[p * 2] + self.tree[p * 2 + 1]
def pushdown(self, p, l, r):
if self.lazy[p] != 0:
mid = (l + r) // 2
left_len = mid - l + 1
right_len = r - mid
# 更新左孩子
self.tree[p * 2] += self.lazy[p] * left_len
self.lazy[p * 2] += self.lazy[p]
# 更新右孩子
self.tree[p * 2 + 1] += self.lazy[p] * right_len
self.lazy[p * 2 + 1] += self.lazy[p]
# 清除当前节点标记
self.lazy[p] = 0
def update(self, p, l, r, L, R, d):
if L <= l and r <= R: # 完全覆盖
self.tree[p] += d * (r - l + 1)
self.lazy[p] += d
return
self.pushdown(p, l, r) # 部分覆盖,先下传
mid = (l + r) // 2
if L <= mid:
self.update(p * 2, l, mid, L, R, d)
if R > mid:
self.update(p * 2 + 1, mid + 1, r, L, R, d)
self.tree[p] = self.tree[p * 2] + self.tree[p * 2 + 1]
def query(self, p, l, r, L, R):
if L <= l and r <= R:
return self.tree[p]
self.pushdown(p, l, r)
mid = (l + r) // 2
res = 0
if L <= mid:
res += self.query(p * 2, l, mid, L, R)
if R > mid:
res += self.query(p * 2 + 1, mid + 1, r, L, R)
return res
def range_update(self, L, R, d):
self.update(1, 1, self.n, L, R, d)
def range_query(self, L, R):
return self.query(1, 1, self.n, L, R)
if __name__ == "__main__":
arr = [0] * 8
st = SegmentTree(arr)
st.build(1, 1, st.n)
print("Initial sum [1,8] =", st.range_query(1, 8))
st.range_update(2, 5, 10)
print("After update [2,5]+10: sum [1,8] =", st.range_query(1, 8))
print("Sum [3,4] =", st.range_query(3, 4))
print("Sum [2,2] =", st.range_query(2, 2))
输出同C++。
懒标记的更多用途与注意事项
懒标记不仅适用于区间加,还可以实现区间赋值、区间乘、区间覆盖等。例如:
- 区间赋值:将某个区间所有元素设为同一个值c。此时懒标记记录的是“赋值”操作,下传时要注意覆盖子节点原有值。
- 区间加和区间乘混合:需要维护两个懒标记(加法和乘法),下传时要注意顺序(先乘后加)。这更复杂,但原理相同。
注意事项:
- 不同操作之间的懒标记可能会冲突,需要规定优先级。比如既有加法又有乘法时,通常让乘法先应用,加法后应用。
- 下传标记时,如果子节点已经有标记,要叠加(加法)或覆盖(赋值)处理。
总结要点
- 懒标记是线段树实现高效区间修改的关键技术。
- 核心思想:对区间打标记,延迟更新子节点,只在需要时下传。
- 每次区间修改和区间查询都需要调用pushdown来确保数据一致性。
- 懒标记可实现区间加、区间赋值等操作,但需注意标记冲突(如先加后乘需谨慎处理)。
- 有了懒标记,区间修改和查询的时间复杂度均为O(log n)。
掌握懒标记后,线段树就真正成为了解决区间动态问题的利器。接下来,可以学习线段树的另一种变体——权值线段树,它用线段树来维护值域,常用于统计排名、第k小数等问题。此外,还可以研究区间最值查询(RMQ)与懒标记的结合,或者扫描线算法等高级应用。
你可以试着用懒标记解决以下问题:
- 给定一个数组,支持区间加和区间求和。
- 支持区间赋值和区间求和。
- 支持区间加和区间乘(需要两个懒标记)。
动手练习,你会发现懒标记是线段树中最有趣也最实用的部分!
例题精讲
线段树的懒标记(Lazy Propagation)主要用于解决什么问题?
在使用懒标记的线段树中,当执行区间查询时,如果遇到带有懒标记的节点,需要先将懒标记下传到子节点,然后才能继续查询子区间,否则查询结果可能错误。
以下是一个线段树区间加法更新的代码片段,其中有一个函数用于将当前节点的懒标记下传到子节点。请填空完成该函数。
void push(int p) {
if (lazy[p] != 0) {
int left = p * 2, right = p * 2 + 1;
tree[left] += lazy[p] * (mid - left + 1); // 假设mid已定义
tree[right] += lazy[p] * (right - mid);
lazy[left] += ___;
lazy[right] += lazy[p];
lazy[p] = 0;
}
}
注意:函数中使用了mid变量,假设已从外部获取。假设有一个长度为n的线段树,使用懒标记进行区间赋值操作(将区间内所有值设为x)。在进行完一次区间赋值后,如果接着对该区间的某个子区间进行查询,则以下关于懒标记状态的描述正确的是?
在线段树的懒标记实现中,如果区间更新和区间查询操作都没有覆盖到某个节点的整个区间,那么该节点的懒标记可以永久保留而不被下传,不会影响正确性。