文章目录
一、前言
目前本专栏正在进行优惠活动,在博主主页添加博主好友,可以获取 付费专栏优惠券。
你怎么过一天,就会怎么过一辈子。
其实是不是在打工不重要,关键是你怎么度过你工作的八小时,你是把自己当成一个出卖劳动力来获取工资的商品,还是当成一个在投资自己未来,而获取自我价值的人,这才是最重要的!
有时候,每天一点一滴的积累,短期内看不到效果,但是我相信,只要坚持,总会有质变的一天,只要还活着,这一天终将到来,这,就是厚积薄发!
二、从图形学算法说起
1、Median Filter 概述
- Median Filter 在现代的图形处理中是非常基础并且广泛应用的算法 (翻译为 中值滤波),如图二-1-1,它是一种非线性平滑技术,它将每一像素点的值设置为该点某邻域窗口内的所有像素点的中值。用于去除图片上的一些噪点。
图二-1-1
2、r pixel-Median Filter 算法
- 中指滤波算法处理的是图片,对于一张宽为 W,高为 H 的图片,每个像素点存了一个颜色值,这里为了把问题简化,先讨论黑白图片,黑白图片的每个像素值可以认为是一个 [ 0 , 255 ] [0, 255] [0,255] 的整数,而对于彩色图片则是对 RGB 三个通道进行分别处理。
r pixel-Median Filter 算法描述如下:
对于每个第 i i i 行第 j j j 列的像素点 p ( i , j ) p(i, j) p(i,j),像四周扩展一个宽和高均为 ( 2 r + 1 ) (2r + 1) (2r+1) 的矩形区域,将矩形区域内的像素值按非降序排列后,用排在最中间的那个数字取代原来的像素点 p ( i , j ) p(i, j) p(i,j) 的值( 边界的那圈不作考虑 )。
- 如图二-2-1,下标从 0 计数,行优先,且 r = 1 r = 1 r=1,红框代表 ( 2 , 3 ) (2, 3) (2,3) 这个像素点所选取的 2 r + 1 2r + 1 2r+1 的矩形区域,将矩形中数字进行非降序排列后,得到 [ 0 , 1 , 1 , 2 , 3 , 4 , 6 , 7 , 9 ] [0, 1, 1, 2, 3, 4, 6, 7, 9] [0,1,1,2,3,4,6,7,9],所以 ( 2 , 3 ) (2, 3) (2,3) 这个像素点的值将从 6 6 6 变成 3 3 3。
- 这样就可以粗略得到一个时间复杂度为 O ( n 4 l o g n ) O(n^4log_n ) O(n4logn) 的算法:枚举每个像素点,对于每个像素点取矩形窗口元素排序后取中值。 n n n 代表了图片的尺寸,也就是当图片越大,这个算法的执行效率就越低,而且增长迅速。那么如何将这个算法进行优化呢?如果对于二维的情况不是很容易下手的话,不妨先从一维的情况进行考虑。
3、一维模型
- 我们可以尝试将问题进行降维处理,可以描述成如下的问题:
【例题1】给定 n ( n ≤ 1 0 5 ) n(n \le 10^5) n(n≤105) 个范围在 [ 0 , 255 ] [0, 255] [0,255] 的数字序列 a [ i ] ( 1 ≤ i ≤ n ) a[i] (1 \le i \le n) a[i](1≤i≤n) 和一个值 r ( 2 r + 1 ≤ n ) r (2r+1\le n) r(2r+1≤n),对于所有的 a [ k ] ( r + 1 ≤ k ≤ n − r ) a[k] (r+1 \le k \le n-r) a[k](r+1≤k≤n−r),将它变成 a [ k − r . . . k + r ] a[k-r ... k+r] a[k−r...k+r] 中的中位数。
朴素法求解过程
- 例如给出数组 a [ i ] a[i] a[i],且 r = 2 r = 2 r=2,有: a [ 1...7 ] = ( 1 , 7 , 6 , 4 , 3 , 2 , 1 ) a[1...7] = (1,7,6,4,3,2,1) a[1...7]=(1,7,6,4,3,2,1)
- 计算中位数的过程如下:
- d [ 3 ] = m e d i a n ( [ 1 , 7 , 6 , 4 , 3 ] ) = 4 d[3] = median( [1,7,6,4,3] ) = 4 d[3]=median([1,7,6,4,3])=4
- d [ 4 ] = m e d i a n ( [ 7 , 6 , 4 , 3 , 2 ] ) = 4 d[4] = median( [7,6,4,3,2] ) = 4 d[4]=median([7,6,4,3,2])=4
- d [ 5 ] = m e d i a n ( [ 6 , 4 , 3 , 2 , 1 ] ) = 3 d[5] = median( [6,4,3,2,1] ) = 3 d[5]=median([6,4,3,2,1])=3
- 于是,原数组就会变成 [ 1 , 7 , 4 , 4 , 3 , 2 , 1 ] [1, 7, 4, 4, 3, 2, 1] [1,7,4,4,3,2,1]
- 以上需要计算中位数的元素为 n − 2 r n-2r n−2r,取这些元素的左右 r r r 个元素的值需要 2 r + 1 2r+1 2r+1 次操作, ( n − 2 r ) ∗ ( 2 r + 1 ) (n-2r)*(2r+1) (n−2r)∗(2r+1) 当 r = n − 1 4 r = \frac {n-1}{4} r=4n−1 时取得最大值,为 ( n + 1 ) 2 4 \frac {(n+1)^2} 4 4(n+1)2,再乘上排序的时间复杂度,所以最坏情况的时间复杂度为 O ( n 2 l o g n ) O(n^2 log_n) O(n2logn)。 n n n 的范围不允许这么高复杂度的算法,尝试进行优化。
突破口
- 考虑第 i i i 个元素的 2 r + 1 2r+1 2r+1 区域 a [ i − r . . . i + r ] a[ i-r ... i+r ] a[i−r...i+r] 和第 i + 1 i+1 i+1 个元素的 2 r + 1 2r+1 2r+1 区域 a [ i + 1 − r . . . i + 1 + r ] a[ i+1-r ... i+1+r ] a[i+1−r...i+1+r],后者比前者少了一个元素 a [ i − r ] a[i-r] a[i−r],多了一个元素 a [ i + 1 + r ] a[i+1+r] a[i+1+r],其它元素都是一样的,那么在计算第 i + 1 i+1 i+1 个元素的中位数时如何利用第 i i i 个元素已经计算出来的结果就成了问题的关键。
4、数据结构的设计
- 我们现在假设有这样一种数据结构,也可以叫容器;
支持以下三种操作:
1、插入操作(Insert):将一个数字插入到该容器中;
2、删除操作(Delete):将指定数字从该容器中删除;
3、询问操作(Query):询问该容器中所有数字的中位数;
- 如果这三个操作都能在 O ( l o g n ) O( log_n ) O(logn) 或者 O ( 1 ) O(1) O(1) 的时间内完成,那么这个问题就可以完美解决了。
算法分析
- 首先将 a [ 1...2 r + 1 ] a[1...2r+1] a[1...2r+1] 这些元素都插入到该容器中,然后询问中位数后替换掉 a [ r + 1 ] a[r+1] a[r+1],再删除容器中的 a [ 1 ] a[1] a[1],插入 a [ 2 r + 2 ] a[2r+2] a[2r+2],询问中位数替换掉 a [ r + 2 ] a[r+2] a[r+2],以此类推,直到计算完第 n − r n-r n−r 个元素。所有操作都在 O ( l o g n ) O( log_n ) O(logn) 时间内完成的话,总的时间复杂度就是 O ( n l o g n ) O( nlog_n ) O(nlogn)。
数据结构分析
- 我们来看什么样的数据结构可以满足这三条操作都在 O ( l o g n ) O( log_n ) O(logn) 的时间内完成,考虑每个数字的范围是 [ 0 , 255 ] [0, 255] [0,255],如果我们将这些数字映射到一个哈希表中,插入和删除操作都可以做到 O ( 1 ) O(1) O(1)。
- 具体得,用一个辅助数组
d
[
256
]
d[256]
d[256],插入
a
[
i
]
a[i]
a[i] 执行的是
d[ a[i] ] ++
,删除 a [ i ] a[i] a[i] 执行的是d[ a[i] ] --
;询问操作是对d[...]
数组进行顺序统计,顺序枚举 i i i,得到前缀和: s u m ( i ) = ∑ j = 1 i d [ j ] sum(i) = \sum_{j=1}^{i} d[j] sum(i)=j=1∑id[j] - 找到第一个满足 s u m ( i ) ≥ r + 1 sum(i) \ge r+1 sum(i)≥r+1 的 i i i 就是所求的中位数,这样就得到了一个时间复杂度为 O ( K n ) O(Kn) O(Kn) 的算法,其中 K K K 是数字的值域(这里讨论的问题值域是 256)。
- 相比朴素算法,这种方法已经前进了一大步,至少 n n n 的指数下降了大于一个数量级,但是也带来了一个问题,如果数字的值域很大,复杂度还是会很大,所以需要更好的算法支持。
5、树状数组华丽登场
- 这里引入一种数据结构 - 树状数组 ( Binary Indexed Tree,BIT,二分索引树 );
它只有两种基本操作:
1、 a d d ( i , x ) ( 1 ≤ i ≤ k ) add( i, x ) (1 \le i \le k) add(i,x)(1≤i≤k):对第 i i i 个元素的值加 x x x;
2、 s u m ( i ) ( 1 ≤ i ≤ k ) sum( i ) (1 \le i \le k) sum(i)(1≤i≤k):统计 [ 1... i ] [1...i] [1...i] 元素值的和;
- 试想一下,如果用哈希表来实现这两个函数,那么第1步的复杂度是 O ( 1 ) O(1) O(1),而第2步的复杂度就是 O ( k ) O(k) O(k) 了,而树状数组实现的这两个函数可以让两者的复杂度都达到 O ( l o g k ) O(log_k) O(logk),具体的实现留到第二节着重介绍。
- 有了这两种操作,我们需要将它们转化成之前设计的数据结构的那三种操作,如下:
- 1、插入(Insert),对应的是 a d d ( i , 1 ) add(i, 1) add(i,1),时间复杂度 O ( l o g k ) O( log_k ) O(logk)
- 2、删除(Delete), 对应的是 a d d ( i , − 1 ) add(i, -1) add(i,−1), 时间复杂度 O ( l o g k ) O( log_k ) O(logk)
- 3、询问(Query), 由于 s u m ( i ) sum( i ) sum(i) 能够统计 [ 1... i ] [1...i] [1...i] 元素值的和,换言之,它能够得到我们之前插入的数据中小于等于 i i i 的数的个数,那么如果能够知道 s u m ( i ) ≥ r + 1 sum(i) \ge r + 1 sum(i)≥r+1 的最小的 i i i,那这个 i i i 就是所有插入数据的中位数了。因为 s u m ( i ) sum(i) sum(i) 是关于 i i i 的递增函数,所以基于单调性我们可以二分枚举 i ( 1 ≤ i ≤ k ) i (1 \le i \le k) i(1≤i≤k),得到最小的 i i i 满足 s u m ( i ) ≥ r + 1 sum(i) \ge r + 1 sum(i)≥r+1,每次的询问复杂度就是 O ( l o g k ∗ l o g k ) O( log_{k} * log_{k} ) O(logk∗logk)。 l o g k log_{k} logk 是二分枚举的复杂度,另一个 l o g k log_{k} logk 是 s u m sum sum 函数求解的复杂度。
- 这样一来,一维的 Median Filter 模型的整体时间复杂度就降到了 O ( n l o g k l o g k ) O(n log_klog_k) O(nlogklogk),已经是比较高效的算法了。
- 接下来就是要来说说树状数组的具体实现了。
三、细说树状数组
1、树 or 数组 ?
- 名曰树状数组,那么究竟它是树还是数组呢?数组在物理空间上是连续的,而树是通过父子关系关联起来的,而树状数组正是这两种关系的结合,首先在存储空间上它是以数组的形式存储的,即下标连续;其次,对于两个数组下标 x , y ( x < y ) x,y(x < y) x,y(x<y),如果满足: x + 2 k = y ( k 等 于 x 的 二 进 制 表 示 中 末 尾 0 的 个 数 ) x + 2^k = y (k 等于 x 的二进制表示中末尾 0 的个数) x+2k=y(k等于x的二进制表示中末尾0的个数),那么定义 ( y , x ) (y, x) (y,x) 为一组树上的父子关系,其中 y y y 为父结点, x x x 为子结点。
- 如图三-1-1所示,其中 A 为普通数组,C 为树状数组(C 在物理空间上和 A 一样都是连续存储的)。树状数组的第 4 个元素 C 4 C_4 C4 的父结点为 C 8 C_8 C8 (4的二进制表示为 ( 100 ) 2 (100)_2 (100)2,所以 k = 2 k=2 k=2,那么 4 + 2 2 = 8 4 + 2^2 = 8 4+22=8), C 6 C_6 C6 和 C 7 C_7 C7 同理。 C 2 C_2 C2 和 C 3 C_3 C3 的父结点为 C 4 C_4 C4,同样也是可以用上面的关系得出的,那么从定义出发,奇数下标一定是叶子结点。
2、结点的含义
- 然后我们来看树状数组上的结点 C i C_i Ci 具体表示什么,这时候就需要利用树的递归性质了。
- 我们定义
C
i
C_i
Ci 的值为它的所有子结点的值 和
A
i
A_i
Ai 的总和,之前提到当
i
i
i 为奇数时
C
i
C_i
Ci 一定为叶子结点,所以有
C
i
=
A
i
C_i = A_i
Ci=Ai (
i
i
i 为奇数 )。从图三-1-1中可以得出如下等式组:
{ C 1 = A 1 C 2 = C 1 + A 2 = A 1 + A 2 C 3 = A 3 C 4 = C 2 + C 3 + A 4 = A 1 + A 2 + A 3 + A 4 C 5 = A 5 C 6 = C 5 + A 6 = A 5 + A 6 C 7 = A 7 C 8 = C 4 + C 6 + C 7 + A 8 = A 1 + A 2 + A 3 + A 4 + A 5 + A 6 + A 7 + A 8 \begin {cases} C_1 = A_1 \\ C_2 = C_1 + A_2 = A_1 + A_2 \\ C_3 = A_3 \\ C_4 = C_2 + C_3 + A_4 = A_1 + A_2 + A_3 + A_4 \\ C_5 = A_5 \\ C_6 = C_5 + A_6 = A_5 + A_6 \\ C_7 = A_7 \\ C_8 = C_4 + C_6 + C_7 + A_8 = A_1 + A_2 + A_3 + A_4 + A_5 + A_6 + A_7 + A_8 \end{cases} ⎩⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎧C1=A1C2=C1+A2=A1+A2C3=A3C4=C2+C3+A4=A1+A2+A3+A4C5=A5C6=C5+A6=A5+A6C7=A7C8=C4+C6+C7+A8=A1+A2+A3+A4+A5+A6+A7+A8 - 我们从上面的公式中可以发现,其实 C i C_i Ci 还有一种更加普适的定义,它表示的其实是一段原数组 A A A 的区间和。根据定义,右区间的下标是很明显的,一定是 i i i,即 C i C_i Ci 表示的区间的最后一个元素一定是 A i A_i Ai,那么接下来就是要求 C i C_i Ci 表示的左区间的下标是什么。从图中可以看出,其实就是顺着 C i C_i Ci 的最左儿子一直找直到找到叶子结点,那个叶子结点就是 C i C_i Ci 表示区间的第一个元素。
- 更加具体的,如果 i i i 的二进制表示为 ( ? ? ? ? 1000 ) 2 (????1000)_2 (????1000)2,那么它最左边的儿子就是 ( ? ? ? ? 0100 ) 2 (????0100)_2 (????0100)2,这一步是通过结点父子关系的定义进行逆推得到,并且这条路径可以表示如下: ( ? ? ? ? 1000 ) 2 → ( ? ? ? ? 0100 ) 2 → ( ? ? ? ? 0010 ) 2 → ( ? ? ? ? 0001 ) 2 (????1000)_2 \to (????0100)_2 \to (????0010)_2 \to (????0001)_2 (????1000)2→(????0100)2→(????0010)2→(????0001)2
- 这时候, ( ? ? ? ? 0001 ) 2 (????0001)_2 (????0001)2 已经是叶子结点了,所以它就是 C i C_i Ci 能够表示的第一个元素的下标,那么我们发现,如果用 k k k 来表示 i i i 的二进制末尾 0 的个数, C i C_i Ci 能够表示的 A A A 数组的区间的元素个数为 2 k 2^k 2k,又因为该区间的最后一个数一定是 A i A_i Ai,所以有如下公式: C i = ∑ j = i − 2 k + 1 i A j C_i = \sum_{ j = i - 2^k + 1}^i A_j Ci=j=i−2k+1∑iAj
- 比较直观的理解就是,左区间的下标是通过 右区间下标 减去 2 k 2^k 2k 加上 1 得出。
3、求和操作
- 明白了
C
i
C_i
Ci 的含义后,我们需要通过它来求
∑
j
=
1
i
A
j
\sum_{j=1}^{i} A_j
∑j=1iAj,也就是之前提到的
s
u
m
(
i
)
sum(i)
sum(i) 函数。为了简化问题,用一个函数
l
o
w
b
i
t
(
i
)
lowbit(i)
lowbit(i) 来表示
2
k
2^k
2k (
k
k
k 等于
i
i
i 的二进制表示中末尾 0 的个数)。那么:
s u m ( i ) = A 1 + A 2 + . . . + A i = A 1 + A 2 + A i − 2 k + A i − 2 k + 1 + . . . + A i = A 1 + A 2 + A i − 2 k + C i = s u m ( i − 2 k ) + C i = s u m ( i − l o w b i t ( i ) ) + C i \begin{aligned}sum(i) &= A_1 + A_2 + ... + A_i \\ &= A_1 + A_2 + A_{i-2^k} + A_{i-2^k+1} + ... + A_i\\ &= A_1 + A_2 + A_{i-2^k} + C_i\\ &= sum(i - 2^k) + C_i\\ &= sum( i - lowbit(i) ) + C_i \end{aligned} sum(i)=A1+A2+...+Ai=A1+A2+Ai−2k+Ai−2k+1+...+Ai=A1+A2+Ai−2k+Ci=sum(i−2k)+Ci=sum(i−lowbit(i))+Ci - 由于 C i C_i Ci 已知,所以 s u m ( i ) sum(i) sum(i) 可以通过递归求解,递归出口为当 i = 0 i = 0 i=0 时,返回 0。 s u m ( i ) sum(i) sum(i) 函数的函数主体只需要一行代码:
int sum(int i) {
return i ? C[i] + sum(i - lowbit(i)):0;
}
- 观察
i - lowbit(i)
,其实就是将 i i i 的二进制表示的最后一个 1 去掉,最多只有 l o g i log_i logi 个 1,所以求 s u m ( i ) sum(i) sum(i) 的最坏时间复杂度为 O ( l o g i ) O(log_i) O(logi)。由于递归的时候常数开销比较大,所以一般写成迭代的形式更好,写成迭代形式的代码如下:
int sum(int i) {
int s = 0;
while (i >= 1) {
s += c[i];
i -= lowbit(i);
}
return s;
}
4、更新操作
- 更新操作就是之前提到的 a d d ( i , 1 ) add(i, 1) add(i,1) 和 a d d ( i , − 1 ) add(i, -1) add(i,−1),更加具体得,可以推广到 a d d ( i , v ) add(i, v) add(i,v),表示的其实就是 A i = A i + v A_i = A_i + v Ai=Ai+v。但是我们不能在原数组 A A A 上操作,而是要像求和操作一样,在树状数组 C C C 上进行操作。
- 从求和公式可以知道 A i A_i Ai 的改变只会影响 C i C_i Ci 及其祖先结点,即 A 5 A_5 A5 的改变影响的是 C 5 、 C 6 、 C 8 C_5、C_6、C_8 C5、C6、C8;而 A 1 A_1 A1 的改变影响的是 C 1 、 C 2 、 C 4 、 C 8 C_1、C_2、C_4、C_8 C1、C2、C4、C8。
- 我们知道,树状数组上的父子关系 ( y , x ) (y, x) (y,x) 满足 x + 2 k = y x + 2^k = y x+2k=y,所以我们可以通过这个公式从叶子结点不断往上递归,直到 y y y 超过最大值 m a x n maxn maxn 为止,祖先结点最多为 l o g m a x n log_{maxn} logmaxn 个, a d d ( i , v ) add(i, v) add(i,v) 的主体代码(去除边界判断)也只有一行代码:
const int maxn = 100000;
void add(int i, int v){
if(i <= maxn){
C[i] += v, add( i + lowbit(i), v);
}
}
- 和求和操作类似,递归的时候常数开销比较大,所以一般写成迭代的形式更好。写成迭代形式的代码如下:
const int maxn = 100000;
void add(int i, int v) {
while (i <= maxn) {
c[i] += v;
i += lowbit(i);
}
}
5、lowbit 函数 O(1) 实现
- 上文提到的两个函数 s u m ( i ) sum(i) sum(i) 和 a d d ( i , v ) add(i, v) add(i,v) 都用到了一个函数叫 l o w b i t ( i ) lowbit(i) lowbit(i),表示的是 2 k 2^k 2k,其中 k k k 为 i i i 的二进制表示末尾 0 的个数,那么最简单的实现办法就是通过位运算的右移,循环判断最后一位是 0 还是 1,从而统计末尾 0 的个数,一旦发现 1 后统计完毕,计数器保存的值就是 k k k,当然这样的做法总的复杂度为 O ( l o g i ) O( log_i ) O(logi),一个32位的整数最多可能进行31次运算。
- 这里介绍一种 O ( 1 ) O(1) O(1) 的方法计算 2 k 2^k 2k 的方法。
补码
- 来看一段补码小知识:清楚补码的表示的可以跳过这一段,计算机中的符号数有三种表示方法,即原码、反码和补码。三种表示方法均有符号位和数值位两部分,符号位都是用0表示“正”,用1表示“负”,而数值位,三种表示方法各不相同。这里只讨论整数补码的情况,在计算机系统中,数值一律用补码来表示和存储。原因在于,使用补码,可以将符号位和数值域统一处理;同时,加法和减法也可以统一处理。整数补码的表示分两种:
- 正数:正数的补码即其二进制表示;
- 例如一个 8 位二进制的整数 +5,它的补码就是 ( 00000101 ) 2 (00000101)_2 (00000101)2;
- 负数:负数的补码即将其整数对应的二进制表示所有位取反(包括符号位)后加 1;
- 例如一个 8 位二进制的整数 -5,它的二进制表示是 ( 00000101 ) 2 (00000101)_2 (00000101)2,取反后为 ( 11111010 ) 2 (11111010)_2 (11111010)2,再加 1 就是 ( 11111011 ) 2 (11111011)_2 (11111011)2,这就是它的补码了。
- 下面的等式可以帮助理解补码在计算机中是如何工作的: + 5 + ( − 5 ) = 00000101 + 11111011 = 1 00000000 ( 溢 出 了 ! ! ! ) = 0 +5 + (-5) = 00000101 + 11111011 = 1 \ 00000000 (溢出了!!!) = 0 +5+(−5)=00000101+11111011=1 00000000(溢出了!!!)=0
- 这里的加法没有将数值位和符号位分开,而是统一作为二进制位进行计算,由于表示的是8进制的整数,所以多出的那个最高位的1会直接舍去,使得结果变成了0,而实际的十进制计算结果也是0,正确。
x & (-x)
- 补码复习完毕,那么来看下下面这个表达式的含义:
x & (-x);
- 首先进行 & 运算,我们需要将 x x x 和 − x -x −x 都转化成补码,然后再看 & 之后会发生什么。
- 令 x x x 的二进制表示为 ( X 0 X 1 X 2 … X n − 2 X n − 1 ) 2 (X_0X_1X_2…X_{n-2}X_{n-1})_2 (X0X1X2…Xn−2Xn−1)2 这里的 X 0 X_0 X0 表示符号位,我们假设 x x x 的二进制表示的末尾是连续的 k k k 个 0,则有 ( X i = 0 ∣ n − k ≤ i < n ) (X_i = 0 | n-k \le i < n) (Xi=0∣n−k≤i<n)
- x x x 的补码就是由三部分组成(其中 X n − k − 1 = 1 X_{n-k-1} = 1 Xn−k−1=1): ( 符 号 位 0 ) ( X 1 X 2 … X n − k − 1 ) ( 00...00 ⏟ k ) 2 (符号位0)(X_1X_2…X_{n-k-1})(\underbrace {00...00}_{\rm k})_2 (符号位0)(X1X2…Xn−k−1)(k 00...00)2
- − x -x −x 的补码也是由三部分组成(其中 Y n − k − 1 = 1 Y_{n-k-1} = 1 Yn−k−1=1): ( 符 号 位 1 ) ( Y 1 Y 2 … Y n − k − 1 ) ( 00...00 ⏟ k ) 2 (符号位1)(Y_1Y_2…Y_{n-k-1})(\underbrace {00...00}_{\rm k})_2 (符号位1)(Y1Y2…Yn−k−1)(k 00...00)2
- 根据补码的定义,可得 X i + Y i = 1 ( i ∈ [ 0 , n − k − 1 ) ) X_i + Y_i = 1 ( i \in [0, n-k-1) ) Xi+Yi=1(i∈[0,n−k−1));
- 那么
x & (-x)
也就显而易见了,为 ( 1 00...00 ⏟ k ) 2 (1\underbrace {00...00}_{\rm k})_2 (1k 00...00)2,表示成十进制为 2 k 2^k 2k,正好是我们要求的 l o w b i t ( x ) lowbit(x) lowbit(x) 。由于&的优先级低于-,所以代码可以这样写:
int lowbit(int x) {
return x & -x;
}
6、小结
- 至此,树状数组的基础内容就到此结束了,三个函数就诠释了树状数组的所有内容,并且都只需要一行代码实现,单次操作的时间复杂度为 O ( l o g n ) O( log_n ) O(logn),空间复杂度为 O ( n ) O(n) O(n),所以它是一种性价比非常高的轻量级数据结构。
- 下面会通过一些例子来具体阐述树状数组的经典模型和应用场景。
四、树状数组的经典模型
1、单点更新,成段求和
【例题2】一个长度为 n ( n ≤ 1 0 5 ) n(n \le 10^5) n(n≤105) 的元素序列,一开始都为0,现给出三种操作:
1、add x v : 给第 x x x 个元素的值加上 v v v;
2、sub x v : 给第 x x x 个元素的值减去 v v v;
3、sum x y: 询问第 x x x 到第 y y y 个元素的和;
- 这是树状数组最基础的模型,1 和 2 的操作就是对应的单点更新,3 的操作就对应了成端求和。
- 具体得,1 和 2 只要分别调用 a d d ( x , v ) add(x, v) add(x,v) 和 a d d ( x , − v ) add(x, -v) add(x,−v), 而 3 则是求 s u m ( y ) − s u m ( x − 1 ) sum(y) - sum(x-1) sum(y)−sum(x−1) 的值。
- 如图四-1-1所示,区间和可以通过两个前缀和相减得到;
图四-1-1
2、成段更新,单点求值
【例题3】一个长度为 n ( n ≤ 1 0 5 ) n(n \le 10^5) n(n≤105) 的元素序列,一开始都为0,现给出两种操作:
1、add x y v : 给第 x x x 个元素到第 y y y 个元素的值都加上 v v v;
2、get x:询问第 x x x 个元素的值;
- 这类问题对树状数组稍微进行了一个转化,但是还是可以用 a d d add add 和 s u m sum sum 这两个函数来解决,对于操作 1 我们只需要执行两个操作,即 a d d ( x , v ) add(x, v) add(x,v) 和 a d d ( y + 1 , − v ) add(y+1, -v) add(y+1,−v);而操作 2 则是输出 s u m ( x ) sum(x) sum(x) 的值。
- 这样就把区间更新转化成了单点更新,单点求值转化成了区间求和。
五、树状数组的应用场景
1、逆序对
【例题4】给定 n ( n ≤ 1 0 5 ) n (n \le 10^5) n(n≤105) 个数的排列 a [ i ] ( 1 ≤ a [ i ] ≤ n ) a[i] (1 \le a[i] \le n) a[i](1≤a[i]≤n),求满足 i < j i < j i<j 且 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j] 的数对 ( i , j ) (i, j) (i,j) 的个数。
- 这个问题就是经典的逆序数问题,如果采用朴素算法,就是枚举 i i i 和 j j j,并且判断 a [ i ] a[i] a[i] 和 a [ j ] a[j] a[j] 的值进行数值统计,如果 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j] 则计数器加一,统计完后计数器的值就是答案。 但是这么做的时间复杂度为 O ( n 2 ) O(n^2) O(n2),对于这个问题来说时间复杂度太高。
- 我们可以考虑如果只枚举 j j j,那么如何在 O ( l o g n ) O(log_n) O(logn) 的时间内,快速得到满足 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j] 的 i i i 的个数,就是我们要做的事。
- 考虑到所有数最大不会超过 n n n,所以每当枚举到 j j j 时,我们可以把 a [ j ] a[j] a[j] 插入树状数组中(即 调用 a d d ( a [ j ] , 1 ) add(a[j], 1) add(a[j],1)),同时利用求和函数 s u m ( n ) − s u m ( a [ j ] ) sum(n) - sum(a[j]) sum(n)−sum(a[j]) 可以得到树状数组中大于 a [ j ] a[j] a[j] 的数有多少个,记为 a n s j ans_j ansj,而这些数的下标正好是小于 j j j 的,满足逆序数的要求,那么插入和查询都能做到 O ( l o g n ) O(log_n) O(logn),扫描一遍以后, ∑ j = 1 n a n s j \sum_{j=1}^{n}ans_j ∑j=1nansj 就是我们要求的答案了。时间复杂度 O ( n l o g n ) O(nlog_n) O(nlogn)。
2、K 大数
【例题5】有一种容器,支持两种操作 (操作总数 ≤ 1 0 6 \le 10^6 ≤106):
1、Insert v v v;对容器插入一个大小为 v v v 的数,其中 0 < v ≤ 1 0 6 0 < v \le 10^6 0<v≤106;
2、Query k k k;询问容器中第 k k k 大的数;
- 如果一个数是第
k
k
k 大的,那么容器里的数
v
v
v,大于等于它的数记为
x
v
x_v
xv,则
x
v
x_v
xv 是满足下述不等式中最小的:
x v ≥ k x_v \ge k xv≥k - 因为第 k k k 大的数可能有多个,所以这里的符号是 ‘>=’ 而不是 ‘=’ ;
朴素思路
- 如果用数组作为容器来存储这些数字,数组元素为 ( v , c ) (v, c) (v,c),其中 v v v 代表值, c c c 代表这个值出现了多少次。维护一个 v v v 降序的数组,每次插入数据的时间复杂度是 O ( n ) O(n) O(n);询问采用累加前缀和的方式,扫描数组并且累加数字出现的个数,对于某个数 v v v,大于等于它的数记为 x v x_v xv,如果满足 x v ≥ k x_v \ge k xv≥k 则 v v v 作为一个可行解,答案就是所有满足可行解里 v v v 值最大的,询问的时间复杂度也是 O ( n ) O(n) O(n)。
优化思路
- 考虑到数的范围在 [ 1 , 1 0 6 ] [1, 10^6] [1,106], 我们可以尝试用哈希的方式来存储这些插入的数字,假设插入的数字有 6、5、4、1,数字有重复,分别出现次数为 2、3、4、1,映射到哈希数组后如下:
i i i | 1 | 2 | 3 | 4 | 5 | 6 | … | 10^6 |
---|---|---|---|---|---|---|---|---|
h a s h [ i ] hash[i] hash[i] | 1 | 0 | 0 | 4 | 3 | 2 | … | 0 |
-
h
a
s
h
[
i
]
hash[i]
hash[i] 代表
i
i
i 这个数在容器中有
h
a
s
h
[
i
]
hash[i]
hash[i] 次。这样插入的时间复杂度为
O
(
1
)
O(1)
O(1)。询问某个数是否是
k
k
k 大的,采用倒序枚举的方式,假设当前第
k
k
k 的数是
i
i
i,那么我们要求
i
i
i 尽量大,且必须满足:
∑ j = i 1 0 6 h a s h [ j ] ≥ k \sum_{j = i}^{10^6}hash[j] \ge k j=i∑106hash[j]≥k - 这里涉及到了成段求和,将上述式子进行一个转换,得到:
s u m ( 1 0 6 ) − s u m ( i − 1 ) ≥ k sum(10^6) - sum(i-1) \ge k sum(106)−sum(i−1)≥k - 其中 s u m ( i ) sum(i) sum(i) 代表 h a s h [ i ] hash[i] hash[i] 的前缀和,并且是随着 i i i 单调不降的,所以我们能够通过二分枚举 i i i 来找到满足上面式子的最大的 i i i,而这个 i i i 就是我们想要求的最大的数。而这一步求和操作正好对应了树状数组的成段求和。
最终方案
- 1)插入时,利用树状数组的单点更新来对数字 v v v 进行插入操作;
- 2)询问时,二分枚举一个答案 v v v,利用树状数组的成段求和来统计树状数组中大于等于 v v v 的数的个数 x v x_v xv,如果满足 x v ≥ k x_v \ge k xv≥k,则增大 v v v 的值,否则减小 v v v 的值,直到找到一个满足条件的最大的 v v v。总的时间复杂度为 O ( n l o g n l o g n ) O(n log_n log_n) O(nlognlogn)。
3、离散化
【例题6】给定 n ( n ≤ 1 0 5 ) n (n \le 10^5) n(n≤105) 个数的序列 a [ i ] ( 1 ≤ a [ i ] ≤ 1 0 9 ) a[i] (1 \le a[i] \le 10^9) a[i](1≤a[i]≤109),求满足 i < j i < j i<j 且 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j] 的数对 ( i , j ) (i, j) (i,j) 的个数。
- 这个问题和 【例题4】 的逆序数问题差别在于数字的范围变大了,这样我们就无法映射到数组中了,但是有一点就是:我们并不关心
a
[
i
]
a[i]
a[i] 具体有多大,而只关心两个值
a
[
i
]
a[i]
a[i] 和
a
[
j
]
a[j]
a[j] 之间的相对关系,所以对于下面两个数组,他们的逆序数是一样的:
图五-3-1 - 所以我们可以把所有出现过的数字进行排序,然后去掉重复的数,用每个数的下标去代替原数,这种将原本离散的数值转换成连续的下标,这一步操作就被称为 离散化。因为涉及到排序,所以离散化的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)。
- 我们已经把所有已经出现过的数都进行排序去重,所以就可以通过二分查找找到原数所在的下标的值,这一步操作是 O ( l o g 2 n ) O(log_2n) O(log2n) 的。
- 离散化的排序去重代码实现如下:
sort(v, v + vsize);
int tmpvsize = 1;
for (int i = 1; i < vsize; ++i) {
if (v[i] != v[tmpvsize - 1])
v[tmpvsize++] = v[i];
}
vsize = tmpvsize;
- 数组
v[i]
的值需要在数据进行输入的时候进行收集。 - 二分查找原数在新数组的下标的过程实现如下:
int getIndex(int now) {
int l = 0, r = vsize - 1;
while (l <= r) {
int mid = (l + r) >> 1;
if (v[mid] == now) {
return mid + 1;
}else if (now < v[mid]) {
r = mid - 1;
}else {
l = mid + 1;
}
}
}
- 这里需要注意的是,树状数组下标是从 1 开始的,所以如果离散化数组的下标从 0 开始的话需要进行一个偏移,也就是代码里面的
return mid + 1
。
4、区间逆序对
【例题7】给定 n ( n ≤ 1000 ) n (n \le 1000) n(n≤1000) 个数的序列 a [ i ] ( 1 ≤ a [ i ] ≤ 2 31 − 1 ) a[i] (1 \le a[i] \le 2^{31}-1) a[i](1≤a[i]≤231−1),然后给出 Q ( Q ≤ 1 0 5 ) Q(Q \le 10^5) Q(Q≤105) 次询问 ( L , R ) (L, R) (L,R),每次询问区间 [ L , R ] [L, R] [L,R] 中满足 L ≤ x < y ≤ R L \le x < y \le R L≤x<y≤R 且 a [ x ] > a [ y ] a[x] > a[y] a[x]>a[y] 的下标 ( x , y ) (x, y) (x,y) 的对数。
- 首先我们发现对于逆序对 a [ x ] > a [ y ] a[x] > a[y] a[x]>a[y] 来说,如果确定 x x x,那么 y y y 的取值为 ( x , R ] (x, R] (x,R], 和 左区间 L L L 并没有关系;
- 我们尝试将截止到某个区间右端点的数全部插入树状数组中,如下图所示,红色线段标记的区间为
[
L
,
R
]
[L, R]
[L,R],蓝色框中的数都已经插入到树状数组了;
图五-4-1 - 枚举区间
[
L
,
R
]
[L, R]
[L,R] 内的数
a
[
x
]
(
x
∈
[
L
,
R
]
)
a[x] ( x \in [L, R])
a[x](x∈[L,R]),那么就是要求
a
[
y
]
<
a
[
x
]
(
y
∈
(
x
,
R
]
)
a[y] < a[x] (y \in (x, R])
a[y]<a[x](y∈(x,R]),图五-4-2中橙色框代表其中一个
a
[
x
]
a[x]
a[x];
图五-4-2 - 那么我们尝试去树状数组中找比
a
[
x
]
a[x]
a[x] 小的数,但是树状数组中比
a
[
x
]
a[x]
a[x] 小的数中,下标
y
y
y 有比
x
x
x 小的,也有比
x
x
x 大的,通俗点讲,就是它有可能分布在
a
[
x
]
a[x]
a[x] 的两边。如图五-4-3所示,黄色圈出来的数都满足
a
[
y
]
<
a
[
x
]
a[y] < a[x]
a[y]<a[x],所以我们需要把
y
<
x
y < x
y<x 的情况去掉,而
a
[
y
]
<
a
[
x
]
a[y] < a[x]
a[y]<a[x] 且
y
<
x
y < x
y<x 的情况我们可以通过在将
a
[
x
]
a[x]
a[x] 插入树状数组之前就通过询问
s
u
m
(
a
[
x
]
−
1
)
sum(a[x]-1)
sum(a[x]−1) 求出来,求出来以后可以保存在
s
[
x
]
s[x]
s[x] 供后续使用;
图五-4-3
算法整理
- 1)对数据 a [ i ] a[i] a[i] 进行离散化;
- 2)对所有区间按照右端点 R R R 进行单调不降排序;
- 3)枚举所有区间,对于某个区间的右端点 R R R 作为当前插入树状数组数据的截止点,将数据 a [ i ] ( i ≤ R ) a[i] (i \le R) a[i](i≤R) 插入树状数组,确保每个数据 a [ i ] a[i] a[i] 在树状数组中只插入一次,并且记录 s [ i ] = s u m ( a [ i ] − 1 ) s[i] = sum(a[i]-1) s[i]=sum(a[i]−1);
- 4)对当前枚举到的区间 [ L , R ] [L, R] [L,R],枚举所有的 x ∈ [ L , R ] x \in [L, R] x∈[L,R], 从树状数组中求出比 a [ x ] a[x] a[x] 小的数 s u m ( a [ x ] − 1 ) sum(a[x]-1) sum(a[x]−1),如果这个区间在原输入数据中的下标为 index,则累加答案到 a n s [ i n d e x ] ans[ index ] ans[index] 上,即 a n s [ i n d e x ] + = s u m ( a [ x ] − 1 ) − s [ x ] ans[ index ] += sum(a[x]-1) - s[x] ans[index]+=sum(a[x]−1)−s[x]
- 5)最后,按顺序输出 ans[…] 的值即可;
离线算法
- 上面的这个问题在对区间进行询问时没有当即做出计算,而是把所有询问都询问完毕后,再对区间排序后逐个求解的过程,被称为 离线算法;
5、树上逆序对
【例题8】给定 n ( n ≤ 1 0 5 ) n (n \le 10^5) n(n≤105) 个结点的树,求每个结点的子树中结点编号比它小的数的个数。
- 这是一个树上的逆序对的问题,通过树的先序遍历可以将树转换成数组,令树上的某个结点 u u u,先序遍历到的顺序为 i d [ u ] id[u] id[u], u u u 的子结点个数为 s [ u ] s[u] s[u],则转换成数组后 u u u 管辖的区间为 [ i d [ u ] , i d [ u ] + s [ u ] − 1 ] [id[u], id[u] + s[u] - 1] [id[u],id[u]+s[u]−1],然后就可以转换成区间逆序对问题进行求解了。
6、再说 Median Filter
- 基于二分的一维 Median Filter 问题已经圆满解决了,那么最后让我们回到二维的 Median Filter 问题上来。
- 有了一维的基础,对于二维的情况,其实也是一样的,如图所示,图中红色的框为 (1, 1) 这个像素点的 ( 2 r + 1 ) (2r+1) (2r+1) 矩形区域,橙色的框则是 (1, 2) 的,它们的差别其实只是差了两列;同样的,橙色框和黄色框也差了两列,于是,我们可以从左向右枚举,每次将这个矩形框向右推进一格,然后将"离开"框的那一列数据从树状数组中删除,将"进入"框的那一列数据插入到树状数组中,然后统计中位数。
- 当枚举到右边界时,将矩形框向下推进一格,然后迂回向左,同样按照之前的方案统计中位数,就这样呈蛇字型迂回前进(具体顺序如图所示的红、橙、黄、绿、青、蓝、紫),这样就得到了一个 O ( n 3 l o g 2 k l o g 2 k ) O( n3log_2klog_2k ) O(n3log2klog2k) 的算法,比朴素算法下降了一个数量级。
六、树状数组的高维扩展
1、二维树状数组
- 树状数组扩展到二维时,可以用来求一个离散平面上的统计问题;
- 代码实现如下:
void add(int x, int y, int v, int xmax, int ymax) {
while (x <= xmax) {
int ty = y;
while (ty <= ymax) {
c[x][ty] += v;
ty += lowbit(ty);
}
x += lowbit(x);
}
}
int sum(int x, int y) {
ll s = 0;
while (x >= 1) {
int ty = y;
while (ty >= 1) {
s += c[x][ty];
ty -= lowbit(ty);
}
x -= lowbit(x);
}
return s;
}
2、三维树状数组
- 三维的情况和二维类似,嵌套循环从两次变成三层即可;
- 关于 树状数组 的内容到这里就结束了。
- 如果还有不懂的问题,可以 想方设法 找到作者的微信进行在线咨询。
- 本文所有示例代码均可在以下 github 上找到:github.com/WhereIsHeroFrom/Code_Templates