参考《算法竞赛进阶指南》 、AcWing题库
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 33 34 35 36 class BIT {public : vector<int > c; int n; BIT () {} BIT (int n) { this ->n = n; c.resize (n + 1 ); } BIT (vector<int > &a) { int n = a.size () + 1 ; c.resize (n); for (int i = 1 ; i <= n; ++i) { auto val = a[i - 1 ]; c[i] += val; if (i + lowbit (i) <= n) c[i + lowbit (i)] += c[i]; } } void add (int x, int w) { for (; x <= n; x += lowbit (x)) c[x] += w; } int askSum (int x) { int ans = 0 ; for (; x; x -= lowbit (x)) ans += c[x]; return ans; } int lowbit (int x) { return x & -x; } };
树状数组
在基本算法中, 我们详细探讨了位运算、二分、倍增等贯穿于各种数据结构设计始末的思想。无论是二进制、折半还是翻倍, 都与 “二” 这个数字密切相关。请读者回想倍增一文的 ST 表, 在对一个较大的连续线性范围进行统计时, 我们把它按照 2 的整数次幂分成若干个小范围进行预处理和计算。再回想位运算一文的快速幂算法, 根据任意正整数关于 2 的不重复次幂的唯一分解性质, 若一个正整数 x x x 的二进制表示为 a k − 1 a k − 2 ⋯ a 2 a 1 a 0 a_{k-1} a_{k-2} \cdots a_{2} a_{1} a_{0} a k − 1 a k − 2 ⋯ a 2 a 1 a 0 , 其中等于 1 的位是 { a i 1 , a i 2 , ⋯ , a i m } \left\{a_{i_{1}}, a_{i_{2}}, \cdots, a_{i_{m}}\right\} { a i 1 , a i 2 , ⋯ , a i m } , 则正整数 x x x 可以被 “二进制分解” 成:
x = 2 i 1 + 2 i 2 + ⋯ + 2 i m x=2^{i_{1}}+2^{i_{2}}+\cdots+2^{i_{m}}
x = 2 i 1 + 2 i 2 + ⋯ + 2 i m
不妨设 i 1 > i 2 > ⋯ > i m i_{1}>i_{2}>\cdots>i_{m} i 1 > i 2 > ⋯ > i m , 进一步地, 区间 [ 1 , x ] [1, x] [ 1 , x ] 可以分成 O ( log x ) O(\log x) O ( log x ) 个小区间:
长度为 2 i 1 2^{i_{1}} 2 i 1 的小区间 [ 1 , 2 i 1 ] \left[1,2^{i_{1}}\right] [ 1 , 2 i 1 ]
长度为 2 i 2 2^{i_{2}} 2 i 2 的小区间 [ 2 i 1 + 1 , 2 i 1 + 2 i 2 ] \left[2^{i_{1}}+1,2^{i_{1}}+2^{i_{2}}\right] [ 2 i 1 + 1 , 2 i 1 + 2 i 2 ]
长度为 2 i 3 2^{i_{3}} 2 i 3 的小区间 [ 2 i 1 + 2 i 2 + 1 , 2 i 1 + 2 i 2 + 2 i 3 ] \left[2^{i_{1}}+2^{i_{2}}+1,2^{i_{1}}+2^{i_{2}}+2^{i_{3}}\right] [ 2 i 1 + 2 i 2 + 1 , 2 i 1 + 2 i 2 + 2 i 3 ]
…
长度为 2 i m 2^{i_{m}} 2 i m 的小区间 [ 2 i 1 + 2 i 2 + ⋯ + 2 i m − 1 + 1 , 2 i 1 + 2 i 2 + ⋯ + 2 i m ] \left[2^{i_{1}}+2^{i_{2}}+\cdots+2^{i_{m-1}}+1,2^{i_{1}}+2^{i_{2}}+\cdots+2^{i_{m}}\right] [ 2 i 1 + 2 i 2 + ⋯ + 2 i m − 1 + 1 , 2 i 1 + 2 i 2 + ⋯ + 2 i m ]
[x - lowbit(x) + 1, x] = (x - lowbit(x), x]
这些小区间的共同特点是:若区间结尾为 R R R , 则区间长度就等于 R R R 的 “二进制分解”下最小的 2 的次幕, 即 lowbit ( R ) \operatorname{lowbit}(R) lowbit ( R ) 。例如 x = 7 = 2 2 + 2 1 + 2 0 x=7=2^{2}+2^{1}+2^{0} x = 7 = 2 2 + 2 1 + 2 0 , 区间 [ 1 , 7 ] [1,7] [ 1 , 7 ] 可以分成 [ 1 , 4 ] 、 [ 5 , 6 ] [1,4] 、[5,6] [ 1 , 4 ] 、 [ 5 , 6 ] 和 [ 7 , 7 ] [7,7] [ 7 , 7 ] 三个小区间, 长度分别是 lowbit ( 4 ) = 4 、 lowbit ( 6 ) = 2 \operatorname{lowbit}(4)=4 、 \operatorname{lowbit}(6)=2 lowbit ( 4 ) = 4 、 lowbit ( 6 ) = 2 和 lowbit ( 7 ) = 1 (7)=1 ( 7 ) = 1 。
我们在位运算 一文中探讨过 lowbit 运算, 并介绍了如何利用 lowbit 运算找出整数在 二进制表示下所有等于 1 的位。类似地, 给定一个整数 x x x , 下面这段代码可以计算出区间 [ 1 , x ] [1, x] [ 1 , x ] 分成的 O ( log x ) O(\log x) O ( log x ) 个小区间:
1 2 3 while (x > 0 ) { printf ("[%d, %d]\n" , x - (x & -x) + 1 , x); }
树状数组结构
树状数组 (Binary Indexed Trees) 就是一种基于上述思想的数据结构, 其基本用途是维护序列的前缀和 。对于给定的序列 a a a , 我们建立一个数组 c c c , 其中 c [ x ] c[x] c [ x ] 保存序列 a a a 的区间 [ x − lowbit ( x ) + 1 , x ] [x-\operatorname{lowbit}(x)+1, x] [ x − lowbit ( x ) + 1 , x ] 中所有数的和, 即 ∑ i = x − lowbit ( x ) + 1 x a [ i ] \sum_{i=x-\operatorname{lowbit}(x)+1}^{x} a[i] ∑ i = x − lowbit ( x ) + 1 x a [ i ] 。
事实上, 数组 c c c 可以看作一个如下图所示的树形结构, 图中最下边一行是 N N N 个叶节点 ( N = 16 ) (N=16) ( N = 16 ) , 代表数值 a [ 1 ∼ N ] a[1 \sim N] a [ 1 ∼ N ] 。该结构满足以下性质:
每个内部节点 c [ x ] c[x] c [ x ] 保存以它为根的子树中所有叶节点的和。
每个内部节点 c [ x ] c[x] c [ x ] 的子节点个数等于 lowbit ( x ) \operatorname{lowbit}(x) lowbit ( x ) 的位数。
除树根外, 每个内部节点 c [ x ] c[x] c [ x ] 的父节点是 c [ x + lowbit ( x ) ] c[x+\operatorname{lowbit}(x)] c [ x + lowbit ( x )] 。
树的深度为 O ( log N ) \mathrm{O}(\log N) O ( log N ) 。
如果 N N N 不是 2 的整次幂, 那么树状数组就是一个具有同样性质的森林结构。
操作一:求前缀和
树状数组支持的基本操作有两个, 第一个操作是查询前缀和 , 即序列 a a a 第 1 ∼ x 1\sim x 1 ∼ x 个数的和。按照我们刚才提出的方法, 应该求出 x x x 的二进制表示中每个等于 1 的位, 把 [ 1 , x ] [1, x] [ 1 , x ] 分成 O ( log N ) O(\log N) O ( log N ) 个小区间, 而每个小区间的区间和都已经保存在数组 c c c 中。 所以, 把上面的代码稍加改写即可在 O ( log N ) O(\log N) O ( log N ) 的时间内查询前缀和:
1 2 3 4 5 int ask (int x) { int ans = 0 ; for (; x; x -= x & -x) ans += c[x]; return ans; }
当然, 若要查询序列 a a a 的区间 [ l , r ] [l, r] [ l , r ] 中所有数的和, 只需计算 ask ( r ) − ask ( l − 1 ) \operatorname{ask}(r)-\operatorname{ask}(l- 1) ask ( r ) − ask ( l − 1 ) 。
操作二:单点增加
树状数组支持的第二个基本操作是单点增加 。意思是给序列中的一个数 a [ x ] a[x] a [ x ] 加上 y y y , 同时正确维护序列的前缀和。根据上面给出的树形结构和它的性质, 只有节点 c [ x ] c[x] c [ x ] 及其所有祖先节点保存的 “区间和” 包含 a [ x ] a[x] a [ x ] , 而任意一个节点的祖先至多只有 log N \log N log N 个, 我们逐一对它们的 c c c 值进行更新即可。下面的代码在 O ( log N ) O(\log N) O ( log N ) 时间内执行单点增加操作:
1 2 3 void add (int x, int y) { for (; x <= N; x += x & -x) c[x] += y; }
树状数组初始化
在执行所有操作之前, 我们需要对树状数组进行初始化 ——针对原始序列 a a a 构造一个树状数组。
为了简便, 比较一般的初始化方法是: 直接建立一个全为 0 的数组 c c c , 然后对每个位置 x x x 执行 add ( x , a [ x ] ) \operatorname{add}(x, a[x]) add ( x , a [ x ]) , 就完成了对原始序列 a a a 构造树状数组的过程, 时间复杂度为 O ( N log N ) O(N \log N) O ( N log N ) 。通常采用这种初始化方法已经足够。
1 2 int c[N];for (int i = 1 ; i <= n; ++i) add (i, a[i]);
更高效的初始化方法是: 从小到大依次考虑每个节点 x x x , 借助 lowbit 运算扫描它的子节点并求和。若采用这种方法, 上面树形结构中的每条边只会被遍历一次, 时间复杂度为 O ( ∑ k = 1 log N k ∗ N / 2 k ) = O ( N ) O\left(\sum_{k=1}^{\log N} k * N / 2^{k}\right)=O(N) O ( ∑ k = 1 l o g N k ∗ N / 2 k ) = O ( N ) 。
1 2 3 4 5 int c[N];for (int i = 1 ; i <= n; ++i) { c[i] += a[i]; if (i + lowbit (i) <= n) c[i + lowbit (i)] += c[i]; }
还有一种简单的初始化方式:
先求前缀和sum数组
c[x] = sum[x] - sum[x - lowbit(x)]
树状数组与逆序对
任意给定一个集合 a a a , 如果用 t [ v a l ] t[v a l] t [ v a l ] 保存数值 v a l val v a l 在集合 a a a 中出现的次数, 那么数组 t t t 在 [ l , r ] [l, r] [ l , r ] 上的区间和 (即 ∑ i = l r t [ i ] \sum_{i=l}^{r} t[i] ∑ i = l r t [ i ] ) 就表示集合 a a a 中范围在 [ l , r ] [l, r] [ l , r ] 内的数有多少个。
我们可以在集合 a a a 的数值范围上 建立一个树状数组, 来维护 t t t 的前缀和。这样即使在集合 a a a 中插入或删除一个数, 也可以高效地进行统计。
我们在排序一文中提到了逆序对问题以及使用归并排序的解法。对于一个序列 a a a ,若 i < j i<j i < j 且 a [ i ] > a [ j ] a[i]>a[j] a [ i ] > a [ j ] , 则称 a [ i ] a[i] a [ i ] 与 a [ j ] a[j] a [ j ] 构成逆序对。按照上述思路, 利用树状数组也可以求出一个序列的逆序对个数:
在序列 a a a 的数值范围上建立树状数组, 初始化为全零。
倒序扫描给定的序列 a a a , 对于每个数 a [ i ] a[i] a [ i ] :
在树状数组中查询前缀和 [ 1 , a [ i ] − 1 ] [1, a[i]-1] [ 1 , a [ i ] − 1 ] , 累加到答案 a n s a n s an s 中。
执行 “单点增加” 操作, 即把位置 a [ i ] a[i] a [ i ] 上的数加 1 (相当于 t [ a [ i ] ] t[a[i]] t [ a [ i ]] ++ ), 同时正确维护 t t t 的前缀和。这表示数值 a [ i ] a[i] a [ i ] 又出现了 1 次。
ans 即为所求。
1 2 3 4 for (int i = n; i; --i) { ans += ask (a[i] - 1 ); add (a[i], 1 ); }
在这个算法中, 因为倒序扫描, “已经出现过的数” 就是在 a [ i ] a[i] a [ i ] 后边的数, 所以我们通过树状数组查询的内容就是 “每个 a [ i ] a[i] a [ i ] 后边有多少个比它小” 。每次查询的结果之和当然就是逆序对个数。时间复杂度为 O ( ( N + M ) log M ) , M \mathrm{O}((N+M) \log M), M O (( N + M ) log M ) , M 为数值范围的大小。
当数值范围较大时, 当然可以先进行离散化, 再用树状数组进行计算。不过因为离散化本身就要通过排序来实现, 所以在这种情况下就不如直接用归并排序来计算逆序对数了。
在完成了分配任务之后,西部 314 314 314 来到了楼兰古城的西部。
相传很久以前这片土地上(比楼兰古城还早)生活着两个部落,一个部落崇拜尖刀(V
),一个部落崇拜铁锹(∧
),他们分别用 V
和 ∧
的形状来代表各自部落的图腾。
西部 314 314 314 在楼兰古城的下面发现了一幅巨大的壁画,壁画上被标记出了 n n n 个点,经测量发现这 n n n 个点的水平位置和竖直位置是两两不同的。
西部 314 314 314 认为这幅壁画所包含的信息与这 n n n 个点的相对位置有关,因此不妨设坐标分别为 ( 1 , y 1 ) , ( 2 , y 2 ) , … , ( n , y n ) (1,y_1),(2,y_2),…,(n,y_n) ( 1 , y 1 ) , ( 2 , y 2 ) , … , ( n , y n ) ,其中 y 1 ∼ y n y_1 \sim y_n y 1 ∼ y n 是 1 1 1 到 n n n 的一个排列。
西部 314 314 314 打算研究这幅壁画中包含着多少个图腾。
如果三个点 ( i , y i ) , ( j , y j ) , ( k , y k ) (i,y_i),(j,y_j),(k,y_k) ( i , y i ) , ( j , y j ) , ( k , y k ) 满足 1 ≤ i < j < k ≤ n 1 \le i < j < k \le n 1 ≤ i < j < k ≤ n 且 y i > y j , y j < y k y_i > y_j, y_j < y_k y i > y j , y j < y k ,则称这三个点构成 V
图腾;
如果三个点 ( i , y i ) , ( j , y j ) , ( k , y k ) (i,y_i),(j,y_j),(k,y_k) ( i , y i ) , ( j , y j ) , ( k , y k ) 满足 1 ≤ i < j < k ≤ n 1 \le i < j< k \le n 1 ≤ i < j < k ≤ n 且 y i < y j , y j > y k y_i < y_j, y_j > y_k y i < y j , y j > y k ,则称这三个点构成 ∧
图腾;
西部 314 314 314 想知道,这 n n n 个点中两个部落图腾的数目。
因此,你需要编写一个程序来求出 V
的个数和 ∧
的个数。
输入格式
第一行一个数 n n n 。
第二行是 n n n 个数,分别代表 y 1 , y 2 , … , y n y_1,y_2,…,y_n y 1 , y 2 , … , y n 。
输出格式
两个数,中间用空格隔开,依次为 V
的个数和 ∧
的个数。
数据范围
对于所有数据,n ≤ 200000 n \le 200000 n ≤ 200000 ,且输出答案不会超过 i n t 64 int64 in t 64 。
y 1 ∼ y n y_1 \sim y_n y 1 ∼ y n 是 1 1 1 到 n n n 的一个排列。
输入样例:
输出样例:
算法分析
题目描述的第一句话实际上告诉我们, 如果把这 N N N 个点按照横坐标排序, 那么它们的纵坐标是 1 ∼ N 1 \sim N 1 ∼ N 的一个排列。我们把这个排列记为 a a a 。
在树状数组求逆序对的算法中, 我们已经知道如何在一个序列中计算每个数后边有多少个数比它小。类似地, 我们可以:
倒序扫描序列 a a a , 利用树状数组求出每个 a [ i ] a[i] a [ i ] 后边有几个数比它大, 记为 r i g h t [ i ] right[i] r i g h t [ i ] 。
正序扫描序列 a a a , 利用树状数组求出每个 a [ i ] a[i] a [ i ] 前边有几个数比它大, 记为 l e f t [ i ] left[i] l e f t [ i ] 。
依次枚举每个点作为中间点, 以该点为中心的 “ v \mathrm{v} v ” 字图腾个数显然是 l e f t [ i ] ∗ r i g h t [ i ] left[i] * right[i] l e f t [ i ] ∗ r i g h t [ i ] 。所以 “ v \mathrm{v} v " 字图腾的总数就是 ∑ i = 1 N left [ i ] ∗ right [ i ] \sum_{i=1}^{N} \operatorname{left}[i] * \operatorname{right}[i] ∑ i = 1 N left [ i ] ∗ right [ i ] 。按照同样的方法, 我们可以统计出 “^
” 字图腾的个数。
Solution
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 33 34 35 36 37 38 39 40 41 42 43 44 #include <iostream> #include <cstring> using namespace std;const int N = 2e5 + 10 ;int n;int a[N], c[N];int l[N], r[N];int ask (int x) { int ans = 0 ; for (; x; x -= x & -x) ans += c[x]; return ans; } void add (int x, int w) { for (; x <= n; x += x & -x) c[x] += w; } int main () { cin >> n; for (int i = 1 ; i <= n; ++i) cin >> a[i]; for (int i = n; i; --i) { int val = a[i]; r[i] = ask (val - 1 ); add (val, 1 ); } memset (c, 0 , sizeof c); for (int i = 1 ; i <= n; ++i) { int val = a[i]; l[i] = ask (val - 1 ); add (val, 1 ); } long long ans1 = 0 , ans2 = 0 ; for (int i = 1 ; i <= n; ++i) { ans1 += 1LL * (i - 1 - l[i]) * (n - i - r[i]); ans2 += 1LL * l[i] * r[i]; } cout << ans1 << " " << ans2; }
树状数组的扩展应用
给定长度为 N N N 的数列 A A A ,然后输入 M M M 行操作指令。
第一类指令形如 C l r d
,表示把数列中第 l ∼ r l \sim r l ∼ r 个数都加 d d d 。
第二类指令形如 Q x
,表示询问数列中第 x x x 个数的值。
对于每个询问,输出一个整数表示答案。
输入格式
第一行包含两个整数 N N N 和 M M M 。
第二行包含 N N N 个整数 A [ i ] A[i] A [ i ] 。
接下来 M M M 行表示 M M M 条指令,每条指令的格式如题目描述所示。
输出格式
对于每个询问,输出一个整数表示答案。
每个答案占一行。
数据范围
1 ≤ N , M ≤ 1 0 5 1 \le N,M \le 10^5 1 ≤ N , M ≤ 1 0 5 ,
∣ d ∣ ≤ 10000 |d| \le 10000 ∣ d ∣ ≤ 10000 ,
∣ A [ i ] ∣ ≤ 1 0 9 |A[i]| \le 10^9 ∣ A [ i ] ∣ ≤ 1 0 9
输入样例:
1 2 3 4 5 6 7 10 5 1 2 3 4 5 6 7 8 9 10 Q 4 Q 1 Q 2 C 1 6 3 Q 2
输出样例:
算法分析
本题的指令有 “区间增加 ” 和 “单点查询 ”, 而树状数组仅支持 “单点增加” , 需要作出一些转化来解决问题。
新建一个数组 b b b , 起初为全零。对于每条指令 “ C l r d \mathrm{C} \;l \;\mathrm{r} \;d C l r d ", 我们把它转化成以下两 条指令:
把 b [ l ] b[l] b [ l ] 加上 d d d 。
再把 b [ r + 1 ] b[r+1] b [ r + 1 ] 减去 d d d 。
执行上面两条指令之后, 我们来考虑一下 b b b 数组的前缀和 (b [ 1 ∼ x ] b[1 \sim x] b [ 1 ∼ x ] 的和 ) 的情况:
对于 1 ≤ x < l 1 \leq x<l 1 ≤ x < l , 前缀和不变。
对于 l ≤ x ≤ r l \leq x \leq r l ≤ x ≤ r , 前缀和增加了 d d d 。
对于 r < x ≤ N r<x \leq N r < x ≤ N , 前缀和不变 ( l (l ( l 处加 d , r + 1 d, r+1 d , r + 1 处减 d d d , 抵消)。
我们发现, b b b 数组的前缀和 b [ 1 ∼ x ] b[1 \sim x] b [ 1 ∼ x ] 就反映了指令 “ C l r d \mathrm{C\;l\;r\;d} C l r d ” 对 a [ x ] a[x] a [ x ] 产生的影响。
于是, 我们可以用树状数组来维护数组 b b b 的前缀和 (对 b b b 只有单点增加操作)。因为各次操作之间具有可累加性, 所以在树状数组上查询前缀和 b [ 1 ∼ x ] b[1 \sim x] b [ 1 ∼ x ] , 就得出了到目前为止所有 “ C \mathrm{C} C ” 指令在 a [ x ] a[x] a [ x ] 上增加的数值总和。再加上 a [ x ] a[x] a [ x ] 的初始值, 就得到了 “ Q x \mathrm{Q} \;x Q x ” 的答案。
该做法把 “维护数列的具体值” 转化为 “维护指令的累积影响”。每次修改的 “影响” 在 l l l 处产生, 在 r + 1 r+1 r + 1 处消除。 “影响” 的前缀和 b [ 1 ∼ x ] b[1 \sim x] b [ 1 ∼ x ] 就代表了数值 a [ x ] a[x] a [ x ] 的变化情况, 如上图所示。该做法巧妙地把 “区间增加” + “单点查询” 变为树状数组擅长的 “单点增加” + “区间查询” 进行处理 。
Solution
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 33 34 35 36 37 #include <iostream> using namespace std;using LL = long long ;const int N = 1e5 + 10 ;int n, m;int a[N], c[N];LL ask (int x) { LL ans = 0 ; for (; x; x -= x & -x) ans += c[x]; return ans; } void add (int x, int w) { for (; x <= n; x += x & -x) c[x] += w; } int main () { cin >> n >> m; for (int i = 1 ; i <= n; ++i) cin >> a[i]; while (m--) { char op; cin >> op; if (op == 'Q' ) { int x; cin >> x; cout << a[x] + ask (x) << endl; } else { int l, r, d; cin >> l >> r >> d; add (l, d); add (r + 1 , -d); } } }
给定一个长度为 N N N 的数列 A A A ,以及 M M M 条指令,每条指令可能是以下两种之一:
C l r d
,表示把 A [ l ] , A [ l + 1 ] , … , A [ r ] A[l],A[l+1],…,A[r] A [ l ] , A [ l + 1 ] , … , A [ r ] 都加上 d d d 。
Q l r
,表示询问数列中第 l ∼ r l \sim r l ∼ r 个数的和。
对于每个询问,输出一个整数表示答案。
输入格式
第一行两个整数 N , M N,M N , M 。
第二行 N N N 个整数 A [ i ] A[i] A [ i ] 。
接下来 M M M 行表示 M M M 条指令,每条指令的格式如题目描述所示。
输出格式
对于每个询问,输出一个整数表示答案。
每个答案占一行。
数据范围
1 ≤ N , M ≤ 1 0 5 1 \le N,M \le 10^5 1 ≤ N , M ≤ 1 0 5 ,
∣ d ∣ ≤ 10000 |d| \le 10000 ∣ d ∣ ≤ 10000 ,
∣ A [ i ] ∣ ≤ 1 0 9 |A[i]| \le 10^9 ∣ A [ i ] ∣ ≤ 1 0 9
输入样例:
1 2 3 4 5 6 7 10 5 1 2 3 4 5 6 7 8 9 10 Q 4 4 Q 1 10 Q 2 4 C 3 6 3 Q 2 4
输出样例:
算法分析
在上一题中, 我们用树状数组维护了一个数组 b b b , 对于每条指令 “ C l r d \mathrm{C} \;l\; \mathrm{r} \;d C l r d ”, 把 b [ l ] b[l] b [ l ] 加上 d d d , 再把 b [ r + 1 ] b[r+1] b [ r + 1 ] 减去 d d d 。
我们已经讨论过, b b b 数组的前缀和 ∑ i = 1 x b [ i ] \sum_{i=1}^{x} b[i] ∑ i = 1 x b [ i ] 就是经过这些指令后 a [ x ] a[x] a [ x ] 增加的值。那么序列 a a a 的前缀和 a [ 1 ∼ x ] a[1 \sim x] a [ 1 ∼ x ] ,整体增加的值就是:
∑ i = 1 x ∑ j = 1 i b [ j ] \sum_{i=1}^{x} \sum_{j=1}^{i} b[j]
i = 1 ∑ x j = 1 ∑ i b [ j ]
上式可以改写为:
∑ i = 1 x ∑ j = 1 i b [ j ] = ∑ i = 1 x ( x − i + 1 ) ∗ b [ i ] = ( x + 1 ) ∑ i = 1 x b [ i ] − ∑ i = 1 x i ∗ b [ i ] \sum_{i=1}^{x} \sum_{j=1}^{i} b[j]=\sum_{i=1}^{x}(x-i+1) * b[i]=(x+1) \sum_{i=1}^{x} b[i]-\sum_{i=1}^{x} i * b[i]
i = 1 ∑ x j = 1 ∑ i b [ j ] = i = 1 ∑ x ( x − i + 1 ) ∗ b [ i ] = ( x + 1 ) i = 1 ∑ x b [ i ] − i = 1 ∑ x i ∗ b [ i ]
这个推导也可以用图形来直观描绘:
在本题中, 我们增加一个树状数组, 用于维护 i ∗ b [ i ] i * b[i] i ∗ b [ i ] 的前缀和 ∑ i = 1 x i ∗ b [ i ] \sum_{i=1}^{x} i * b[i] ∑ i = 1 x i ∗ b [ i ] , 上式就可以直接查询、计算了。
具体来说, 我们建立两个树状数组 c 0 c_{0} c 0 和 c 1 c_{1} c 1 , 起初全部赋值为零。对于每条指令 “ Clrd \operatorname{C l r d} Clrd ”, 执行 4 个操作:
在树状数组 c 0 c_{0} c 0 中, 把位置 l l l 上的数加 d d d 。
在树状数组 c 0 c_{0} c 0 中, 把位置 r + 1 r+1 r + 1 上的数减 d d d 。
在树状数组 c 1 c_{1} c 1 中, 把位置 l l l 上的数加 l ∗ d l * d l ∗ d 。
在树状数组 c 1 c_{1} c 1 中, 把位置 r + 1 r+1 r + 1 上的数减 ( r + 1 ) ∗ d (r+1) * d ( r + 1 ) ∗ d 。
另外, 我们建立数组 sum 存储序列 a a a 的原始前缀和。对于每条指令 “ Q l r Q \;l\; r Q l r ”, 当然还是拆成 1 ∼ r 1 \sim r 1 ∼ r 和 1 ∼ l − 1 1 \sim l-1 1 ∼ l − 1 两个部分, 二者相减。写成式子就是:
( s u m [ r ] + ( r + 1 ) ∗ a s k ( c 0 , r ) − a s k ( c 1 , r ) ) −
(sum[r]+(r+1)*ask(c_{0}, r)-ask(c_{1}, r)) \;- ( s u m [ r ] + ( r + 1 ) ∗ a s k ( c 0 , r ) − a s k ( c 1 , r )) −
( s u m [ l − 1 ] + l ∗ a s k ( c 0 , l − 1 ) − a s k ( c 1 , l − 1 ) ) (sum[l-1]+l * ask(c_{0}, l-1)-ask(c_{1}, l-1))
( s u m [ l − 1 ] + l ∗ a s k ( c 0 , l − 1 ) − a s k ( c 1 , l − 1 ))
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 33 34 35 36 37 38 39 40 41 42 43 #include <iostream> using namespace std;using LL = long long ;const int N = 1e5 + 10 ;int n, m;int a[N];LL sum[N], c[2 ][N]; LL ask (int k, int x) { LL ans = 0 ; for (; x; x -= x & -x) ans += c[k][x]; return ans; } void add (int k, int x, LL w) { for (; x <= n; x += x & -x) c[k][x] += w; } LL prefixSum (int x) { return sum[x] + (x + 1 ) * ask (0 , x) - ask (1 , x); } int main () { cin >> n >> m; for (int i = 1 ; i <= n; ++i) cin >> a[i], sum[i] = sum[i - 1 ] + a[i]; while (m--) { char op; int l, r; cin >> op >> l >> r; if (op == 'Q' ) { cout << prefixSum (r) - prefixSum (l - 1 ) << endl; } else { int d; cin >> d; add (0 , l, d); add (0 , r + 1 , -d); add (1 , l, l * d); add (1 , r + 1 , -(r + 1 ) * d); } } }
值得指出的是, 为什么我们把 ∑ i = 1 x ( x − i + 1 ) ∗ b [ i ] \sum_{i=1}^{x}(x-i+1) * b[i] ∑ i = 1 x ( x − i + 1 ) ∗ b [ i ] 变成 ( x + 1 ) ∑ i = 1 x b [ i ] − ∑ i = 1 x i ∗ b [ i ] (x+1) \sum_{i=1}^{x} b[i]-\sum_{i=1}^{x} i * b[i] ( x + 1 ) ∑ i = 1 x b [ i ] − ∑ i = 1 x i ∗ b [ i ] 进行统计呢? 仔细观察该式的定义, 这里的 x x x 是关于 “前缀和 a [ 1 ∼ x ] a[1 \sim x] a [ 1 ∼ x ] ” 这个询问的变量, 而 i i i 是在每次修改时影响的对象。
对于前者来说, 求和式中的每一项同时包含 x x x 和 i i i , 在修改时无法确定 ( x − i + 1 ) (x-i+1) ( x − i + 1 ) 的值, 只能维护 b [ i ] b[i] b [ i ] 的前缀和。在询问时需要面临一个 “系数为等差数列” 的求和式, 计算起来非常困难。
对于后者来说, 求和式中的每一项只与 i i i 有关。它通过一次容斥, 把 ( x + 1 ) (x+1) ( x + 1 ) 提取为常量, 使得 b [ i ] b[i] b [ i ] 的前缀和与 i ∗ b [ i ] i * b[i] i ∗ b [ i ] 的前缀和可以分别用树状数组进行维护。这种分离包含有多个变量的项, 使公式中不同变量之间互相独立 的思想非常重要, 我们在讨论动态规划的优化策略时会多次用到。
Solution
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 #include <iostream> using namespace std;using LL = long long ;const int N = 1e5 + 10 ;int n, m;int a[N];LL sum[N], c[2 ][N]; LL ask (int k, int x) { LL ans = 0 ; for (; x; x -= x & -x) ans += c[k][x]; return ans; } void add (int k, int x, LL w) { for (; x <= n; x += x & -x) c[k][x] += w; } LL prefixSum (int x) { return (x + 1 ) * ask (0 , x) - ask (1 , x); } int main () { cin >> n >> m; for (int i = 1 ; i <= n; ++i) cin >> a[i]; for (int i = 1 ; i <= n; ++i) { int b = a[i] - a[i - 1 ]; add (0 , i, b); add (1 , i, 1LL * i * b); } while (m--) { char op; int l, r; cin >> op >> l >> r; if (op == 'Q' ) { cout << prefixSum (r) - prefixSum (l - 1 ) << endl; } else { int d; cin >> d; add (0 , l, d); add (0 , r + 1 , -d); add (1 , l, l * d); add (1 , r + 1 , -(r + 1 ) * d); } } }
有 n n n 头奶牛,已知它们的身高为 1 ∼ n 1 \sim n 1 ∼ n 且各不相同,但不知道每头奶牛的具体身高。
现在这 n n n 头奶牛站成一列,已知第 i i i 头牛前面有 A i A_i A i 头牛比它低,求每头奶牛的身高。
输入格式
第 1 1 1 行:输入整数 n n n 。
第 2.. n 2..n 2.. n 行:每行输入一个整数 A i A_i A i ,第 i i i 行表示第 i i i 头牛前面有 A i A_i A i 头牛比它低。
(注意:因为第 1 1 1 头牛前面没有牛,所以并没有将它列出)
输出格式
输出包含 n n n 行,每行输出一个整数表示牛的身高。
第 i i i 行输出第 i i i 头牛的身高。
数据范围
1 ≤ n ≤ 1 0 5 1 \le n \le 10^5 1 ≤ n ≤ 1 0 5
输入样例:
输出样例:
算法分析
如果最后一头奶牛前面有 A n A_{n} A n 头牛比它低, 那么显然它的身高 H n = A n + 1 H_{n}=A_{n}+1 H n = A n + 1 ,在所有牛中身高排第 A n + 1 A_{n}+1 A n + 1 位。如果倒数第二头奶牛前有 A n − 1 A_{n-1} A n − 1 头牛比它低, 其在剩下的牛中的身高排第 A n − 1 + 1 A_{n-1}+1 A n − 1 + 1 位,那么:
若 A n − 1 < A n A_{n-1}<A_{n} A n − 1 < A n , 则它的身高 H n − 1 = A n − 1 + 1 H_{n-1}=A_{n-1}+1 H n − 1 = A n − 1 + 1 。
若 A n − 1 ≥ A n A_{n-1} \geq A_{n} A n − 1 ≥ A n , 则它的身高 H n − 1 = A n − 1 + 2 H_{n-1}=A_{n-1}+2 H n − 1 = A n − 1 + 2 。
依此类推, 如果第 k k k 头牛前面有 A k A_{k} A k 头比它低, 那么它的身高 H k H_{k} H k 是数值 1 ∼ n 1 \sim n 1 ∼ n 中第 A k + 1 A_{k}+1 A k + 1 小的没有在 { H k + 1 , H k + 2 , ⋯ , H n } \left\{H_{k+1}, H_{k+2}, \cdots, H_{n}\right\} { H k + 1 , H k + 2 , ⋯ , H n } 中出现过的数。
核心问题:
从剩余数中,找出第 k k k 小的数
删除某个数
具体来说, 我们建立一个长度为 n n n 的 01 序列 b b b , 起初全部为 1 ,b[i] = 1
表示身高 i
可用。然后, 从 n n n 到 1 倒序扫描每个 A i A_{i} A i , 对每个 A i A_{i} A i 执行以下两个操作:
查询序列 b b b 中第 A i + 1 A_{i}+1 A i + 1 个 1 在什么位置, 这个位置号就是第 i i i 头奶牛的身高 H i H_{i} H i 。
把 b [ H i ] b\left[H_{i}\right] b [ H i ] 减 1 (从 1 变为 0) ) ) 。
也就是说, 我们需要实时维护一个 01 序列, 支持查询第 k k k 个 1 的位置 ( k (k ( k 为任意整数), 以及修改序列中的一个数值 。
方法一: 树状数组+二分 , 单次操作 O ( log 2 n ) O\left(\log ^{2} n\right) O ( log 2 n )
用树状数组 c c c 维护 01 序列 b b b 的前缀和, 在每次查询时二分答案, 通过 ask ( m i d ) \operatorname{ask}(\mathrm{mid}) ask ( mid ) 即可得到前 mid 个数中有多少个 1, 与 k k k 比较大小, 即可确定二分上下界的变化。
方法二: 树状数组+倍增 , 单次操作 O ( log n ) O(\log n) O ( log n )
用树状数组 c c c 维护 01 序列 b b b 的前缀和, 在每次查询时:
初始化两个变量 a n s = 0 a n s=0 an s = 0 和 s u m = 0 s u m=0 s u m = 0 。
从 log n \log n log n (下取整) 到 0 倒序考虑每个整数 p p p 。
对于每个 p p p , 若 a n s + 2 p ≤ n a n s+2^{p} \leq n an s + 2 p ≤ n 且 s u m + c [ a n s + 2 p ] < k s u m+c\left[a n s+2^{p}\right]<k s u m + c [ an s + 2 p ] < k , 则令 s u m + = c [ a n s + 2 p ] , a n s + = 2 p s u m+=c[a n s+2^{p}]\;,\;ans+=2^{p} s u m + = c [ an s + 2 p ] , an s + = 2 p 。
最后, H i = a n s + 1 H_{i}=a n s+1 H i = an s + 1 即为所求。
该做法与 “倍增” 中给出的第一个小问题采用了类似的思想, 都是 “以 2 的整数次幂为步长, 能累加则累加 ”。树状数组 c c c 恰好已经为我们维护了区间长度为 2 的次幂的一些信息, 直接结合树状数组使用这种思想, 就得到了上面这个高效算法。
Solution
树状数组 + 二分
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 33 34 35 36 37 38 #include <iostream> using namespace std;const int N = 1e5 + 10 ;int n;int h[N], c[N], ans[N];void add (int x, int w) { for (; x <= n; x += x & -x) c[x] += w; } int ask (int x) { int ans = 0 ; for (; x; x -= x & -x) ans += c[x]; return ans; } int main () { cin >> n; for (int i = 2 ; i <= n; ++i) cin >> h[i]; for (int i = 1 ; i <= n; ++i) add (i, 1 ); for (int i = n; i; --i) { int k = h[i] + 1 ; int l = 1 , r = n; while (l < r) { int mid = l + r >> 1 ; if (ask (mid) >= k) r = mid; else l = mid + 1 ; } ans[i] = r; add (r, -1 ); } for (int i = 1 ; i <= n; ++i) cout << ans[i] << endl; }