1 差分 2 思考与总结 3 函数思想 4 二维差分 5 差分与贡献 5.1 线性序列上的贡献模型 5.2 两种视角:推土机与接收者 5.3 从累计贡献理解差分 5.4 从单链推广到树 5.5 多链 DAG:能到达,不代表只到达一次 5.6 进阶:树上差分 6 练习题目 7 相关题解 1 差分
设原数组的第i i i 个元素为a i a_i a i ,那么差分数组的第i i i 个元素为d i = a i − a i − 1 d_i = a_i- a_{i-1} d i = a i − a i − 1
有一组数据如下
下标 1 2 3 4 5 6 原数组 a [ i ] 3 5 1 7 8 4 差分数组 d [ i ] 3 2 − 4 6 1 − 4 差分数组 d 前缀和 s [ i ] 3 5 1 7 8 4 \begin{array}{r|c|c|c|c|c|c}
\text{下标} & 1 & 2 & 3 & 4 & 5 & 6 \\
\hline
\text{原数组 } a[i]& 3 & 5 & 1 & 7 & 8 & 4 \\
\text{差分数组 } d[i]& 3 & 2 & -4 & 6 & 1 & -4 \\
\text{差分数组$d$前缀和 } s[i]& 3 & 5 & 1 & 7 & 8 & 4 \\
\end{array} 下标 原数组 a [ i ] 差分数组 d [ i ] 差分数组 d 前缀和 s [ i ] 1 3 3 3 2 5 2 5 3 1 − 4 1 4 7 6 7 5 8 1 8 6 4 − 4 4
发现:对差分数组进行前缀和操作,就可以得到原数组了
S i = ∑ j = 1 i d i = d 1 + d 2 + ⋯ + d i = ( a 1 − a 0 ) + ( a 2 − a 1 ) + ⋯ + ( a i − a i − 1 ) = ( a 1 − a 0 ) + ( a 2 − a 1 ) + ⋯ + ( a i − a i − 1 ) = a i − a 0 因为 a 0 = 0 = a i \begin{aligned}
S_i = \sum_{j=1}^i d_i &= d_1 +d_2 + \cdots + d_i \\
&= (a_1 - a_0) + (a_2 - a_1) + \cdots + (a_i - a_{i-1}) \\
&= (\bcancel{ a_1 } - a_0) + (\bcancel{a_2} - \bcancel{a_1}) + \cdots + (a_i - \bcancel{a_{i-1}}) \\
& = a_i - a_0 \;\;\; \text{因为$a_0 = 0$} \\
& = a_i
\end{aligned} S i = j = 1 ∑ i d i = d 1 + d 2 + ⋯ + d i = ( a 1 − a 0 ) + ( a 2 − a 1 ) + ⋯ + ( a i − a i − 1 ) = ( a 1 − a 0 ) + ( a 2 − a 1 ) + ⋯ + ( a i − a i − 1 ) = a i − a 0 因为 a 0 = 0 = a i
现在对原数组a a a 的区间[ 2 , 4 ] [2,4] [ 2 , 4 ] 上的每个数都增加2 2 2 ,得到如下
下标 1 2 3 4 5 6 原数组 a [ i ] 3 7 3 9 8 4 差分数组 d [ i ] 3 4 − 4 6 − 1 − 4 差分数组 d 前缀和 s [ i ] 3 7 3 9 8 4 \begin{array}{r|c|c|c|c|c|c}
\text{下标} & 1 & \color{Blue}{2} & \color{Blue}{3} & \color{Blue}{4} & 5 & 6 \\
\hline
\text{原数组 } a[i]& 3 & \color{Blue}{7} & \color{Blue}{3} & \color{Blue}{9} & 8 & 4 \\
\text{差分数组 } d[i]& 3 & \color{Blue}{4} & -4 & 6 & \color{Blue}{-1} & -4 \\
\text{差分数组$d$前缀和 } s[i]& 3 & \color{Blue}{7} & \color{Blue}{3} & \color{Blue}{9} & 8 & 4 \\
\end{array} 下标 原数组 a [ i ] 差分数组 d [ i ] 差分数组 d 前缀和 s [ i ] 1 3 3 3 2 7 4 7 3 3 − 4 3 4 9 6 9 5 8 − 1 8 6 4 − 4 4
发现,对原数组进行区间[ 2 , 4 ] [2,4] [ 2 , 4 ] 修改: 差分数组只会修改两个值,第一个值为b 2 = b 2 + 2 b_2 = b_2+2 b 2 = b 2 + 2 ,第二个值为b 5 = b 5 − 2 b_5 =b_5-2 b 5 = b 5 − 2 ,且对修改后的差分数组进行前缀后依然得到了原数组.
于是,得出结论,如果对原 数组进行区间[ l , r ] [l,r] [ l , r ] 上的每个数都增加v v v ,那么对应的差分数组只需要修改两位置b l = b l + v b_l = b_l+v b l = b l + v , b r + 1 = b r + 1 − v b_{r+1} = b_{r+1}-v b r + 1 = b r + 1 − v .原来需要r − l + 1 r-l+1 r − l + 1 次的区间操作,现在只需要进行2 2 2 次区间操作.也就是把区间增减转了两次的单点增减 .大大节省了时间
对于,那些题目
适合使用差分思想(把原数组转化成差分数组来操作)
2 思考与总结
差分是前缀和的逆运算
已知,原数组a a a 就是数组s s s ,两者一模一样.所以在这里我们其实是先假定数组a a a 是数组d d d 形成的前缀和数组,进行就想到能否通过前缀和数组a a a 推导出数组d d d 呢?
我们现在认为原数组a a a 上每个值a i a_i a i 都是某个数组d d d 的前缀和.那么可以得到
a i = a i − 1 + d i (1)
a_i = a_{i-1} + d_i \tag 1
a i = a i − 1 + d i ( 1 ) 移项,进而得到
d i = a i − a i − 1 (2)
d_i = a_i - a_{i-1} \tag 2
d i = a i − a i − 1 ( 2 ) 通过( 2 ) (2) ( 2 ) 式,把数组a a a 转化成d d d 数组.也就是前缀和数组转化成差分数组
可以通过a a a 得到d d d ,也可以通过d d d 得到a a a ,他们互为逆运算
a ⇌ d
a \rightleftharpoons d
a ⇌ d 可以认为,a a a 与d d d 是同一事物的不同表现形式.
综上,可以得到如下的性质:
设d d d 是原数组,a a a 是d d d 的前缀和,
在d d d 的两个位置i , j i,j i , j 上,同时+ v , − v +v,-v + v , − v ,也就是d i = d i + v , d j = d j − v d_i = d_i+v,d_j = d_j-v d i = d i + v , d j = d j − v ,那么体现到a a a 上,那就是区间[ i , j − 1 ] [i,j-1] [ i , j − 1 ] 上的每个数都增加v v v
在a a a 上的区间[ i , j − 1 ] [i,j-1] [ i , j − 1 ] 上的每个数都增加v v v ,那么体现到d d d 上,那就是位置i , j i,j i , j ,+ v , − v +v,-v + v , − v ,也就是d i = d i + v , d j = d j − v d_i = d_i+v,d_j = d_j-v d i = d i + v , d j = d j − v
3 函数思想
设a a a 是一个数字组成的序列,定义函数如下:
f ( a ) = b f(a) = b f ( a ) = b ,把序列a a a 转化成b b b ,其中b i = ∑ 1 i a i b_i = \sum_1^i a_i b i = ∑ 1 i a i ,也就是前缀和
f − 1 ( a ) = b f^{-1}(a) = b f − 1 ( a ) = b ,表示把序列a a a 转化成b b b ,其中b i = a i − a i − 1 b_i = a_i - a_{i-1} b i = a i − a i − 1 ,也就是差分
显然f f f 与f − 1 f^{-1} f − 1 互为逆运算,也就是
f − 1 ( f ( a ) ) = a f ( f − 1 ( a ) ) = a
\begin{gather}
f^{-1}(f(a)) = a \\
f(f^{-1}(a)) = a
\end{gather}
f − 1 ( f ( a )) = a f ( f − 1 ( a )) = a 同样可以想到f f f 与f − 1 f^{-1} f − 1 是双射函数 ,也就是f ( a ) = b f(a) = b f ( a ) = b 也就是说r a n f ranf r an f 与d o m f domf d o m f 一一映射
定义函数r a n g e ( a , l , r ) range(a,l,r) r an g e ( a , l , r ) ,表示对序列a a a 区间[ l , r ] [l,r] [ l , r ] 上的每个数都增加1 1 1
r a n g e ( a 1 , l , r ) = a 2
range(a_1,l,r) = a_2
r an g e ( a 1 , l , r ) = a 2 表示a 1 a_1 a 1 经过区间[ l , r ] [l,r] [ l , r ] 上的 每个数都增加1 1 1 后得到a 2 a_2 a 2 ,显然a 1 ≠ a 2 a_1 \neq a_2 a 1 = a 2 ,则f − 1 ( a 1 ) = d 1 ≠ f − 1 ( a 2 ) = d 2 f^{-1}(a_1) = d_1 \neq f^{-1}(a_2) = d_2 f − 1 ( a 1 ) = d 1 = f − 1 ( a 2 ) = d 2 ^[因为一一映射的性质]
根据差分的性质知d 1 , d 2 d_1,d_2 d 1 , d 2 只有两个点不同,且这两个点是l , r + 1 l,r+1 l , r + 1
定义函数
定义函数a d d ( a , l , r ) add(a,l,r) a dd ( a , l , r ) ,表示对序列a a a 上的两点l , r + 1 l,r+1 l , r + 1 分别+ 1 , − 1 +1,-1 + 1 , − 1
a d d ( a 1 , l , r ) = a 2
add(a_1,l,r) = a_2
a dd ( a 1 , l , r ) = a 2
则r a n g e ( a 1 , l , r ) range(a_1,l,r) r an g e ( a 1 , l , r ) 与a d d ( d 1 , l , r ) add(d_1,l,r) a dd ( d 1 , l , r ) 是一一映射 的.也就是每一个在序列a + 1 a+1 a + 1 上的r a n g e range r an g e 操作,都等价于在序列d 1 = f − 1 ( a 1 ) d_1 = f^{-1}(a_1) d 1 = f − 1 ( a 1 ) 上的add操作^[这里其时使用了反证法]
4 二维差分
11 − 9 − 3 5 11 − 9 − 3 5 11 − 9 − 3 5
\def\arraystretch{2}
\begin{array}{|c : c : c:c|}
\hline
11 &-9& -3& 5\\
\hline
11 &-9& -3& 5\\
\hline
11 &-9& -3& 5\\
\hline
\end{array}
11 11 11 − 9 − 9 − 9 − 3 − 3 − 3 5 5 5 根据上面的思考,可以认为此时的二维矩阵上的值,都是每个二维矩阵d d d 的前缀和.也就是
⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡
\begin{array}{cccccc}
\color{Blue}{\boxdot} & \color{Blue}{\boxdot} &\color{Blue}{\boxdot} & \color{Green}\boxdot & \boxdot \\
\color{Blue}{\boxdot} & \color{Blue}{\boxdot} &\color{Blue}{\boxdot} & \color{Green}\boxdot & \boxdot \\
\color{Blue}{\boxdot} & \color{Blue}{\boxdot} &\color{Blue}{\boxdot} & \color{Green}\boxdot & \boxdot \\
\color{Green}{\boxdot} & \color{Green}{\boxdot} &\color{Green}{\boxdot} & \color{Red}{\boxdot} & \boxdot \\
\boxdot & \boxdot & \boxdot & \boxdot & \boxdot &
\end{array}
⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ ⊡ S i , j = S i − 1 , j + S i , j − 1 − S i − 1 , j − 1 + d i , j
S_{i,j} = S_{i-1,j} + S_{i,j-1} - S_{i-1,j-1} + d_{i,j}
S i , j = S i − 1 , j + S i , j − 1 − S i − 1 , j − 1 + d i , j 移项,得到原二维矩阵上的每个值为:
d i , j = S i , j − S i − 1 , j − S i , j − 1 + S i − 1 , j − 1
d_{i,j} = S_{i,j}-S_{i-1,j}-S_{i,j-1} + S_{i-1,j-1}
d i , j = S i , j − S i − 1 , j − S i , j − 1 + S i − 1 , j − 1 对S S S 矩阵上的子矩阵( x i , y i ) , ( x j , y j ) (x_i,y_i),(x_j,y_j) ( x i , y i ) , ( x j , y j ) 进行+ v +v + v 操作,反应到d d d 上就是
( x i , y i ) , ( x j + 1 , y j + 1 ) (x_i,y_i),(x_{j+1},y_{j+1}) ( x i , y i ) , ( x j + 1 , y j + 1 ) 上的每个数都+ v +v + v
( x i , y j + 1 ) , ( x j + 1 , y i ) (x_{i},y_{j+1}),(x_{j+1},y_{i}) ( x i , y j + 1 ) , ( x j + 1 , y i ) 上的每个数都− v -v − v
小技巧: 点d i , j d_{i,j} d i , j 的值是S i , j − S i − 1 , j − S i , j − 1 + S i − 1 , j − 1 S_{i,j} - S_{i-1,j} - S_{i,j-1} + S_{i-1,j-1} S i , j − S i − 1 , j − S i , j − 1 + S i − 1 , j − 1 ,
也就是说,与以i , j i,j i , j 作为左下角的四个格子的值有关.也就是说i , j i,j i , j 的覆盖范围,有奇数个+ v +v + v 时才会产生影响.
题目:
5 差分与贡献
5.1 线性序列上的贡献模型
引入
有 4 4 4 个数字 a 1 , a 2 , a 3 , a 4 a_1,a_2,a_3,a_4 a 1 , a 2 , a 3 , a 4 ,初始值依次为 1 , 2 , 3 , 4 1,2,3,4 1 , 2 , 3 , 4 。每个数字 a i a_i a i 对后面的所有位置 [ i + 1 , n ] [i+1,n] [ i + 1 , n ] ,各贡献一次自己的初始值 。
例如,a 1 a_1 a 1 使位置 2 , 3 , 4 2,3,4 2 , 3 , 4 的值各增加 1 1 1 。每个位置还保留自己的初始值。
下标
a 1 a_1 a 1
a 2 a_2 a 2
a 3 a_3 a 3
a 4 a_4 a 4
初始值
1 1 1
2 2 2
3 3 3
4 4 4
所有贡献结算完毕后,每个位置的最终值是多少?
输入:
输出:
用 a a a 保存不变的初始值,用 b b b 保存最终值。直接做法是先令 b i = a i b_i=a_i b i = a i ,再枚举每个贡献者及其影响的位置。
暴力代码(核心片段)
点击
1
2
3
4
5
6
7
for ( int i = 1 ; i <= n; ++ i) b[ i] = a[ i] ;
for ( int i = 1 ; i <= n; ++ i) {
for ( int j = i + 1 ; j <= n; ++ j) {
b[ j] += a[ i] ;
}
}
复制
时间复杂度为 O ( n 2 ) O(n^2) O ( n 2 ) 。慢在每个 a i a_i a i 都要逐个访问它后面的所有位置,而这些后缀大量重叠。
5.2 两种视角:推土机与接收者
每个数都要给后面所有位置贡献,能不能只交给紧挨着的下一个?
先试着让位置 1 1 1 只给位置 2 2 2 贡献,执行 a[2] += a[1]。位置 2 2 2 从 2 2 2 变成 2 + 1 = 3 2+1=3 2 + 1 = 3 ,但位置 3 , 4 3,4 3 , 4 还没有收到位置 1 1 1 的贡献。
位置 1 1 1 还没完成的贡献,由谁继续送?
由位置 2 2 2 接着送。注意,此时的 a[2] 已经不是初始值 2 2 2 ,而是 2 + 1 2+1 2 + 1 :既有位置 2 2 2 自己的贡献,也有位置 1 1 1 交过来的贡献。
因此,下一步执行 a[3] += a[2],加过去的就是整包 2 + 1 2+1 2 + 1 ,位置 3 3 3 变成 3 + ( 2 + 1 ) = 6 3+(2+1)=6 3 + ( 2 + 1 ) = 6 。这一步同时完成了两件事:位置 2 2 2 给位置 3 3 3 贡献了 2 2 2 ,还替位置 1 1 1 把那份 1 1 1 继续传给了位置 3 3 3 。
那么,位置 4 4 4 怎么收到前三个位置的贡献?
继续做同一个动作:a[4] += a[3]。此时 a[3] 已经装着 3 + 2 + 1 3+2+1 3 + 2 + 1 ,所以位置 4 4 4 一次就收到了前三个位置的贡献,变成 4 + ( 3 + 2 + 1 ) = 10 4+(3+2+1)=10 4 + ( 3 + 2 + 1 ) = 10 。
把这三步连起来看。表中的数字始终表示各个位置的初始值来源 ,括号表示这一步传过去的整包贡献:
操作
a 1 a_1 a 1
a 2 a_2 a 2
a 3 a_3 a 3
a 4 a_4 a 4
初始
1 1 1
2 2 2
3 3 3
4 4 4
a[2] += a[1]
1 1 1
2 + ( 1 ) = 3 2+(1)=3 2 + ( 1 ) = 3
3 3 3
4 4 4
a[3] += a[2]
1 1 1
2 + 1 = 3 2+1=3 2 + 1 = 3
3 + ( 2 + 1 ) = 6 3+(2+1)=6 3 + ( 2 + 1 ) = 6
4 4 4
a[4] += a[3]
1 1 1
2 + 1 = 3 2+1=3 2 + 1 = 3
3 + 2 + 1 = 6 3+2+1=6 3 + 2 + 1 = 6
4 + ( 3 + 2 + 1 ) = 10 4+(3+2+1)=10 4 + ( 3 + 2 + 1 ) = 10
这就是这里的“推土机”:每个位置把收到的贡献,连同自己的初始值,一起传给下一个位置。 “传给”表示给下一位置加上一份,当前位置的值仍然保留。
于是,不用让每个数亲自跑遍后面所有位置,只需从左向右执行:
1
2
3
4
5
for ( int i = 1 ; i < n; ++ i) {
a[ i + 1 ] += a[ i] ;
}
复制
这里直接修改 a a a ,循环结束后,a a a 中保存的就是最终结果;最后一个位置没有下一个位置,所以循环条件是 i < n。
为什么这样传递不会漏算或重算?
执行到位置 i i i 时,前面各位置的初始值都已沿相邻位置传到这里,且各有一份。下一步只向位置 i + 1 i+1 i + 1 传递一次,再加上位置 i + 1 i+1 i + 1 原有的初始值,就又得到了各有一份的新一包贡献。如此继续,每个初始值都恰好经过它后面的每个位置一次。
整个过程只有 n − 1 n-1 n − 1 次相邻传递,时间复杂度为 O ( n ) O(n) O ( n ) 。从关系上看,“给后面所有位置贡献”被拆成了相邻位置之间的接力;这里还依赖从前到后只有一条传递路径。后面推广到 DAG 时,要特别留意路径不唯一的情况。
站在接收者的位置看,答案又是什么?
为了写清公式,下面重新用 a a a 表示不变的初始值,用 b b b 表示最终值。位置 i i i 接收所有 j < i j<i j < i 的贡献,再保留自己的初始值,因此
b i = a i + ∑ j = 1 i − 1 a j = ∑ j = 1 i a j .
\begin{aligned}
b_i &= a_i+\sum_{j=1}^{i-1}a_j \\
&=\sum_{j=1}^{i}a_j.
\end{aligned}
b i = a i + j = 1 ∑ i − 1 a j = j = 1 ∑ i a j . 这就是前缀和。这里 a i a_i a i 是位置 i i i 自己保留的值,并不是题目额外规定的一次自我贡献。
从贡献者出发,把累计值送给下一位置,是“刷表 / Push”的视角;从接收者出发,写成 b i = b i − 1 + a i b_i=b_{i-1}+a_i b i = b i − 1 + a i (b 0 = 0 b_0=0 b 0 = 0 ),是“填表 / Pull”的视角。两者描述的是同一个计算过程。
贡献取的是初始值。若直接对每个后方位置执行 b[j] += b[i],就会把已经收到的贡献再次发出,导致重复计算。
可以令 b = a 后仅执行相邻转移 b[i + 1] += b[i]:此时 b[i] 表示需要向下一位置传递的整包贡献,而不再是单个贡献者的初始值。
5.3 从累计贡献理解差分
前缀和负责向后传递,那差分数组里的一个数代表什么?
把差分数组 d d d 当作贡献源。还原时执行
a 0 = 0 , a i = a i − 1 + d i ,
a_0=0,\qquad a_i=a_{i-1}+d_i,
a 0 = 0 , a i = a i − 1 + d i , 意味着 d i d_i d i 从位置 i i i 开始,对后面每个位置都贡献一次。因此,d[l] += v 会使整个后缀 [ l , n ] [l,n] [ l , n ] 增加 v v v 。
如果只想影响 [ l , r ] [l,r] [ l , r ] ,怎样让贡献停止?
在 r + 1 r+1 r + 1 放一个抵消源:d[r + 1] -= v。两个贡献源叠加后,各位置得到的增量为
Δ a i = { 0 , i < l , v , l ≤ i ≤ r , v − v = 0 , i > r .
\Delta a_i=
\begin{cases}
0, & i<l, \\
v, & l\le i\le r, \\
v-v=0, & i>r.
\end{cases}
Δ a i = ⎩ ⎨ ⎧ 0 , v , v − v = 0 , i < l , l ≤ i ≤ r , i > r . 于是,差分标记负责说明贡献从哪里开始、在哪里抵消 ,前缀和负责把这些标记沿相邻位置累计展开。这就是差分能够把一次区间修改变成两次单点修改的原因。
从逆运算看,固定 a 0 = 0 a_0=0 a 0 = 0 后,
d i = a i − a i − 1 ⟺ a i = ∑ j = 1 i d j .
d_i=a_i-a_{i-1}
\quad\Longleftrightarrow\quad
a_i=\sum_{j=1}^{i}d_j.
d i = a i − a i − 1 ⟺ a i = j = 1 ∑ i d j . 这是一一对应的两种表示;加法也能保留:原数组的增量之和,对应差分标记的增量之和。因此可以先叠加所有修改的标记,最后统一还原。若使用“同构”这个词,这里指的是保持加法结构的对应,而不只是形式相似。
5.4 从单链推广到树
现在把一条链换成一棵以 1 1 1 为根的树。规定每个节点的初始权值贡献给它的所有祖先,每个节点也保留自己的初始权值。
1
2
3
4
5
1
/ \
2 3
/ \ \
4 5 6
复制
图中的边表示父子关系,贡献沿孩子到父亲的方向传递 。例如 4 , 5 4,5 4 , 5 的贡献先到 2 2 2 ,再到 1 1 1 ;6 6 6 的贡献先到 3 3 3 ,再到 1 1 1 。方向由关系决定,与节点编号的大小无关。
父节点能否只接收孩子打包后的贡献?
可以。设 s u s_u s u 表示节点 u u u 及其所有后代的初始权值之和。节点 u u u 的各棵孩子子树互不相交,因此
s u = a u + ∑ v ∈ children ( u ) s v .
s_u=a_u+\sum_{v\in\operatorname{children}(u)}s_v.
s u = a u + v ∈ children ( u ) ∑ s v . 例如 s 2 = a 2 + s 4 + s 5 s_2=a_2+s_4+s_5 s 2 = a 2 + s 4 + s 5 。只需先算出所有孩子的 s v s_v s v ,再算 s u s_u s u ,最后将 s u s_u s u 传给父亲。这就是后序遍历 / 自底向上汇总 。
每条父子边只传递一次,整棵树的汇总时间为 O ( n ) O(n) O ( n ) 。任意后代到祖先的路径唯一,所以每份初始权值在对应祖先处恰好出现一次。
所以可以使用的方法是
后序遍历
1
2
3
4
5
6
7
8
9
10
11
12
vector< int > g[ maxn] ;
long long a[ maxn] ;
long long s[ maxn] ;
void dfs ( int u) {
s[ u] = a[ u] ;
for ( int v : g[ u] ) {
dfs ( v) ;
s[ u] += s[ v] ;
}
}
复制
2 .bfs + 倒序队列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
vector< int > g[ maxn] ;
long long a[ maxn] ;
long long s[ maxn] ;
vector< int > order;
queue< int > q;
q. push ( 1 ) ;
while ( ! q. empty ( ) ) {
int u = q. front ( ) ;
q. pop ( ) ;
order. push_back ( u) ;
for ( int v : g[ u] ) q. push ( v) ;
}
for ( int u = 1 ; u <= n; ++ u) s[ u] = a[ u] ;
reverse ( order. begin ( ) . order. end ( ) ) ;
for ( auto u : order) {
for ( int v : g[ u] )
s[ u] += s[ v] ;
}
复制
5.5 多链 DAG:能到达,不代表只到达一次
如果一个节点可以把贡献传向多个后继,就得到一般的 DAG。仍然把边 v → u v\to u v → u 定义为“贡献由 v v v 传给 u u u ”。
怎样保证向外传递时,当前节点已经收齐贡献?
按这个方向的拓扑序 处理:先令 s u = a u s_u=a_u s u = a u ,再对每条边 v → u v\to u v → u 执行 s[u] += s[v]。拓扑序保证所有前驱都在 u u u 之前处理。对于前面的树,孩子先于父亲的后序遍历就是一种合法顺序。
点击
拓扑序 + 沿边累加(每条路径各产生一份贡献)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
vector< int > g[ maxn] ;
int indeg[ maxn] ;
long long a[ maxn] ;
long long s[ maxn] ;
void dag_topo ( ) {
for ( int u = 1 ; u <= n; ++ u) s[ u] = a[ u] ;
queue< int > q;
for ( int u = 1 ; u <= n; ++ u)
if ( indeg[ u] == 0 ) q. push ( u) ;
while ( ! q. empty ( ) ) {
int v = q. front ( ) ;
q. pop ( ) ;
for ( int u : g[ v] ) {
s[ u] += s[ v] ;
if ( -- indeg[ u] == 0 ) q. push ( u) ;
}
}
}
复制
DAG 的可达关系加上自身关系后构成偏序;拓扑序给出了一个与该偏序相容的处理顺序。但是,处理顺序正确并不能消除多条路径带来的重复。
考虑菱形结构,所有箭头都沿贡献方向:
令 a 4 = 1 a_4=1 a 4 = 1 ,其他节点初始值为 0 0 0 。节点 2 , 3 2,3 2 , 3 都收到 1 1 1 ,最终节点 1 1 1 收到 2 2 2 ,因为从 4 4 4 到 1 1 1 有两条路径。
因此,上面的累加实际计算的是
s u = ∑ v a v P ( v , u ) ,
s_u=\sum_v a_v\,P(v,u),
s u = v ∑ a v P ( v , u ) , 其中 P ( v , u ) P(v,u) P ( v , u ) 是从 v v v 到 u u u 的有向路径条数,约定 P ( u , u ) = 1 P(u,u)=1 P ( u , u ) = 1 ,不可达时为 0 0 0 。
若每条路径都应产生一份贡献,按拓扑序累加正确,时间为 O ( n + m ) O(n+m) O ( n + m ) ,其中 m m m 是边数。
若每个可达节点只应贡献一次,就必须去重。例如维护包含自身的可达来源集合,沿边做集合并;规模允许时可用 bitset,最后对集合中的节点权值求和。其代价取决于集合表示,不能再直接套用上述 O ( n + m ) O(n+m) O ( n + m ) 的求和结论。
点击
可达集合去重(每个可达节点只算一次)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
vector< int > g[ maxn] ;
int indeg[ maxn] ;
long long a[ maxn] ;
long long s[ maxn] ;
bitset< maxn> reach[ maxn] ;
void dag_reach_dedup ( ) {
vector< int > order;
queue< int > q;
for ( int u = 1 ; u <= n; ++ u) {
reach[ u] . reset ( ) ;
reach[ u] . set ( u) ;
if ( indeg[ u] == 0 ) q. push ( u) ;
}
while ( ! q. empty ( ) ) {
int v = q. front ( ) ;
q. pop ( ) ;
order. push_back ( v) ;
for ( int u : g[ v] ) {
reach[ u] |= reach[ v] ;
if ( -- indeg[ u] == 0 ) q. push ( u) ;
}
}
for ( int u = 1 ; u <= n; ++ u) {
s[ u] = 0 ;
for ( int v = 1 ; v <= n; ++ v)
if ( reach[ u] . test ( v) ) s[ u] += a[ v] ;
}
}
复制
在上面那张菱形图上取 a 4 = 1 a_4=1 a 4 = 1 、其余为 0 0 0 :拓扑累加版在节点 1 1 1 得到 2 2 2 (两条路径各算一次),去重版得到 1 1 1 。
从单链推广到多链时,除了检查依赖顺序,还要检查多条路径是否重复携带了同一份贡献。 树上的 LCA 可以帮助处理树路径的交汇,但不能直接用来解决一般 DAG 的可达集合去重。
5.6 进阶:树上差分
树上已经知道怎样累计,能否反过来设计贡献源?
可以。设 d u d_u d u 是节点 u u u 的标记,令自底向上汇总后的结果为
c u = d u + ∑ v ∈ children ( u ) c v .
c_u=d_u+\sum_{v\in\operatorname{children}(u)}c_v.
c u = d u + v ∈ children ( u ) ∑ c v . 也就是说,c u c_u c u 是 u u u 子树内所有标记之和。反过来,已知目标增量 c c c 时,有
d u = c u − ∑ v ∈ children ( u ) c v .
d_u=c_u-\sum_{v\in\operatorname{children}(u)}c_v.
d u = c u − v ∈ children ( u ) ∑ c v . 这就是树上“累计”与“差分”的逆运算关系。这里将 d d d 初始置零,c c c 只表示修改增量,最终点权为 a u + c u a_u+c_u a u + c u 。
给节点 x x x 到其祖先 u u u 的整条路径加 v v v ,在哪里放标记?
在 x x x 放 d[x] += v,这个贡献会一路传向根。要使它在经过 u u u 后停止,就在 u u u 的父节点放 d[fa[u]] -= v。
路径 x → u x\to u x → u 上每个节点的子树只包含正标记,得到 + v +v + v ;u u u 的严格祖先同时包含正负两个标记,增量抵消;其他节点不受影响。如果 u u u 是根,则没有需要抵消的祖先,跳过第二个标记。
如果路径两端 x , y x,y x , y 没有祖先关系呢?
设 w = LCA ( x , y ) w=\operatorname{LCA}(x,y) w = LCA ( x , y ) 为最近公共祖先。路径由 x → w x\to w x → w 和 y → w y\to w y → w 两条向上的链组成,但交汇点 w w w 只应增加一次。于是做四处标记:
1
2
3
4
5
6
d[ x] += v;
d[ y] += v;
d[ w] -= v;
if ( fa[ w] != 0 ) d[ fa[ w] ] -= v;
复制
两份正标记在 w w w 汇合,减去一份后恰好保留 v v v ;在 w w w 的父节点再减一份,使更高祖先处的总增量为 0 0 0 。这也覆盖 x = y x=y x = y 、一端是另一端祖先、w w w 为根等情况。
全部修改标记完成后,自底向上汇总一次 d d d ,就能得到每个点的总增量:
1
2
3
4
5
6
7
for ( int u : order) {
if ( fa[ u] != 0 ) d[ fa[ u] ] += d[ u] ;
}
for ( int u = 1 ; u <= n; ++ u) {
b[ u] = a[ u] + d[ u] ;
}
复制
已知 LCA 后,每次修改只需 O ( 1 ) O(1) O ( 1 ) 次标记;最后汇总为 O ( n ) O(n) O ( n ) 。若用倍增查询 LCA,包含预处理的 q q q 次路径修改总时间为 O ( n log n + q log n + n ) O(n\log n+q\log n+n) O ( n log n + q log n + n ) 。这里是先完成所有修改,再统一查询最终点权 的离线做法,并且公式针对点权路径加;边权路径加的抵消位置不同。
从序列到树,差分都在做同一件事:沿确定方向传递贡献,在目标范围之外放抵消标记,最后统一累计还原。
6 练习题目
HDU 1121
luogu 3948
luogu P1969 积木大赛
P6070
P3655
P7404
roj 求最少多少次把所有的元素变成一样大,平数II
7 相关题解
题目
OJ
难度
区间最大和
roj
普及+/提高-