夜深人静写算法(十三)- 树状数组

63 篇文章 312 订阅 ¥999.99 ¥499.90
本文介绍了树状数组在图形学算法中的应用,以Median Filter为例,逐步引出树状数组的概念。通过一维模型分析,提出数据结构设计需求,然后详细解释了树状数组的插入、删除和查询操作。接着,文章探讨了树状数组在单点更新和成段求和的经典模型,以及在逆序对、K大数、离散化等问题中的应用场景。最后,文章简要提到了树状数组的高维扩展,包括二维和三维树状数组。
摘要由CSDN通过智能技术生成

一、前言

  目前本专栏正在进行优惠活动,在博主主页添加博主好友,可以获取 付费专栏优惠券
  你怎么过一天,就会怎么过一辈子。
  其实是不是在打工不重要,关键是你怎么度过你工作的八小时,你是把自己当成一个出卖劳动力来获取工资的商品,还是当成一个在投资自己未来,而获取自我价值的人,这才是最重要的!
  有时候,每天一点一滴的积累,短期内看不到效果,但是我相信,只要坚持,总会有质变的一天,只要还活着,这一天终将到来,这,就是厚积薄发!

二、从图形学算法说起

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

  • 如图二-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(n105) 个范围在 [ 0 , 255 ] [0, 255] [0,255] 的数字序列 a [ i ] ( 1 ≤ i ≤ n ) a[i] (1 \le i \le n) a[i](1in) 和一个值 r ( 2 r + 1 ≤ n ) r (2r+1\le n) r(2r+1n),对于所有的 a [ k ] ( r + 1 ≤ k ≤ n − r ) a[k] (r+1 \le k \le n-r) a[k](r+1knr),将它变成 a [ k − r . . . k + r ] a[k-r ... k+r] a[kr...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 n2r,取这些元素的左右 r r r 个元素的值需要 2 r + 1 2r+1 2r+1 次操作, ( n − 2 r ) ∗ ( 2 r + 1 ) (n-2r)*(2r+1) (n2r)(2r+1) r = n − 1 4 r = \frac {n-1}{4} r=4n1 时取得最大值,为 ( 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[ir...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+1r...i+1+r],后者比前者少了一个元素 a [ i − r ] a[i-r] a[ir],多了一个元素 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 nr 个元素。所有操作都在 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=1id[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)(1ik):对第 i i i 个元素的值加 x x x
  2、 s u m ( i ) ( 1 ≤ i ≤ k ) sum( i ) (1 \le i \le k) sum(i)(1ik):统计 [ 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(1ik),得到最小的 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(logklogk) 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) xy(x<y),如果满足: x + 2 k = y ( k 等 于 x 的 二 进 制 表 示 中 末 尾 0 的 个 数 ) x + 2^k = y (k 等于 x 的二进制表示中末尾 0 的个数) x+2k=y(kx0),那么定义 ( y , x ) (y, x) (y,x) 为一组树上的父子关系,其中 y y y 为父结点, x x x 为子结点。

图三-1-1

  • 如图三-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=i2k+1iAj
  • 比较直观的理解就是,左区间的下标是通过 右区间下标 减去 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+Ai2k+Ai2k+1+...+Ai=A1+A2+Ai2k+Ci=sum(i2k)+Ci=sum(ilowbit(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 C5C6C8;而 A 1 A_1 A1 的改变影响的是 C 1 、 C 2 、 C 4 、 C 8 C_1、C_2、C_4、C_8 C1C2C4C8
  • 我们知道,树状数组上的父子关系 ( 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 (X0X1X2Xn2Xn1)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=0nki<n)
  • x x x 的补码就是由三部分组成(其中 X n − k − 1 = 1 X_{n-k-1} = 1 Xnk1=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)(X1X2Xnk1)(k 00...00)2
  • − x -x x 的补码也是由三部分组成(其中 Y n − k − 1 = 1 Y_{n-k-1} = 1 Ynk1=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)(Y1Y2Ynk1)(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,nk1))
  • 那么 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(n105) 的元素序列,一开始都为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(x1) 的值。
  • 如图四-1-1所示,区间和可以通过两个前缀和相减得到;
    图四-1-1

2、成段更新,单点求值

【例题3】一个长度为 n ( n ≤ 1 0 5 ) n(n \le 10^5) n(n105) 的元素序列,一开始都为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(n105) 个数的排列 a [ i ] ( 1 ≤ a [ i ] ≤ n ) a[i] (1 \le a[i] \le n) a[i](1a[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<v106
  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 xvk
  • 因为第 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 xvk 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 i12345610^6
h a s h [ i ] hash[i] hash[i]1004320
  • 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=i106hash[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(i1)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 xvk,则增大 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(n105) 个数的序列 a [ i ] ( 1 ≤ a [ i ] ≤ 1 0 9 ) a[i] (1 \le a[i] \le 10^9) a[i](1a[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(n1000) 个数的序列 a [ i ] ( 1 ≤ a [ i ] ≤ 2 31 − 1 ) a[i] (1 \le a[i] \le 2^{31}-1) a[i](1a[i]2311),然后给出 Q ( Q ≤ 1 0 5 ) Q(Q \le 10^5) Q(Q105) 次询问 ( L , R ) (L, R) (L,R),每次询问区间 [ L , R ] [L, R] [L,R] 中满足 L ≤ x < y ≤ R L \le x < y \le R Lx<yR 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](iR) 插入树状数组,确保每个数据 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(n105) 个结点的树,求每个结点的子树中结点编号比它小的数的个数。

  • 这是一个树上的逆序对的问题,通过树的先序遍历可以将树转换成数组,令树上的某个结点 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、三维树状数组

  • 三维的情况和二维类似,嵌套循环从两次变成三层即可;

  • 关于 树状数组 的内容到这里就结束了。
  • 如果还有不懂的问题,可以 想方设法 找到作者的微信进行在线咨询。


在这里插入图片描述


七、树状数组相关题集整理

题目链接难度解析
HDU 1166 敌兵布阵★☆☆☆☆单点更新
HDU 2689 Sort it★☆☆☆☆逆序数
PKU 2299 Ultra-QuickSort★☆☆☆☆逆序数
HDU 1394 Minimum Inversion Number★☆☆☆☆逆序数
HDU 2838 Cow Sorting★☆☆☆☆逆序数
HDU 1541 Stars★☆☆☆☆逆序数
HDU 1556 Color the ball★☆☆☆☆成段更新
HDU 1892 See you~★☆☆☆☆二维 + 单点更新
PKU 1195 Mobile phones★☆☆☆☆二维 + 单点更新
HDU 5480 Conturbatio★☆☆☆☆二维 + 前缀和
HDU 2443 Counter Strike★★☆☆☆逆序数
HDU 3743 Frosh Week★★☆☆☆逆序数 + 离散化
PKU 3067 Japan★★★☆☆转换成逆序数求解
PKU 2481 Cows★★☆☆☆区间问题转化成逆序求解
HDU 5775 Bubble Sort★★☆☆☆逆序数
HDU 4000 Fruit Ninja★★☆☆☆逆序数 + 补集计数
HDU 2492 Ping pong★★☆☆☆逆序数
PKU 1990 MooFest★★☆☆☆转化成求和
HDU 2892 Tunnel Warfare★★☆☆☆二分答案 + 前缀和统计
HDU 4907 Task schedule★★☆☆☆二分答案 + 前缀和统计
HDU 5147 Sequence II★★☆☆☆逆序数 + 前缀和统计
HDU 4970 Killing Monsters★★☆☆☆成段更新 + 离线算法
HDU 2385 Stock★★★☆☆排序 + 单点更新
HDU 5273 Dylans loves sequence★★★☆☆区间逆序数
PKU 3321 Apple Tree★★★☆☆树的先序遍历转换
HDU 3887 Counting Offspring★★★☆☆树的先序遍历转换
HDU 3465 Life is a Line★★★☆☆逆序数 + 计算几何
HDU 3030 Increasing Speed Limits★★★☆☆离散化 + 逆序数
HDU 3648 Median Filter★★★☆☆二维 + 蛇形更新
HDU 3333 Turing Tree★★★☆☆离散化 + 离线算法
HDU 3874 Necklace★★★☆☆前缀和 + 离线算法
HDU 4046 Panda★★★☆☆单点更新 + 成段求和
HDU 4339 Query★★★☆☆单点更新 + 成段求和 + 二分答案
HDU 4311 Meeting point-1★★★☆☆排序 + 前缀和统计
HDU 4417 Super Mario★★★☆☆降维思想
HDU 3758 Factorial Simplification★★★☆☆单点更新 + 因式分解
HDU 2227 Find the non sub★★★☆☆动态规划+树状数组
HDU 3450 Counting Sequences★★★☆☆动态规划+树状数组
HDU 4991 Ordered Subsequence★★★☆☆动态规划+树状数组
HDU 2836 Traversal★★★☆☆动态规划 + 树状数组
HDU 5542 The Battle of Chibi★★★☆☆动态规划+树状数组
HDU 1899 Sum the K-th’s★★★☆☆K大数问题 单点更新 + 二分答案
HDU 2852 KiKi’s K-Number★★★☆☆K大数问题 单点更新 + 二分答案
HDU 4006 The kth great number★★★☆☆K大数问题 单点更新 + 二分答案
HDU 5805 NanoApe Loves Sequence★★★☆☆K大数问题 + 单点更新 + 二分答案
HDU 5806 NanoApe Loves Sequence Ⅱ★★★☆☆K大数问题 双指针 + 单点更新 + 二分答案
HDU 2275 Kiki & Little Kiki 1★★★☆☆单点更新 + 二分模型
HDU 3015 Disharmony Trees★★★☆☆离散化 + 单点更新
HDU 6609 Find the answer★★★☆☆离散化 + 单点更新
PKU 2985 The k-th Largest Group★★★☆☆并查集 + 树状数组
PKU 2155 Matrix★★★☆☆二维 + 成段更新
PKU 2642 Stars★★★☆☆二维
HDU 5791 Two★★★☆☆二维 + LCS
HDU 3584 Cube★★★☆☆三维 + 成段更新
HDU 5057 Argestes and Sequence★★★☆☆离线算法 + 前缀和统计
HDU 5497 Inversion★★★☆☆区间逆序数
HDU 4358 Boring counting★★★★☆树的先序遍历转换数组
HDU 3303 Harmony Forever★★★★☆二分 + 单点更新
HDU 5021 Revenge of kNN II★★★★☆区间三分 + 单点更新
HDU 5032 Always Cook Mushroom★★★★☆极角排序 + 离线算法
HDU 4456 Crowd★★★★★二维 + 坐标变换
HDU 4312 Meeting point-2★★★★★二维 + 坐标变换
HDU 4605 Magic Ball Game★★★★★树状数组 + 主席树
  • 19
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
树形动态规划(Tree DP)是一种解决树状结构问题的算法思想。它利用了树这种特殊的数据结构的性质进行求解,常用来解决树的最优路径、最大值、最小值等类型的问题。 在夜深人静的时候算法,我通常会采用以下步骤来完成树形dp的实现: 第一步是定义状态。我们首先需要确定问题的状态表示方式。对于树形dp来说,常用的状态表示方式是以节点为单位进行表示。我们可以定义dp[i]表示以节点i为根的子树的某种性质,比如最大路径和、最长路径长度等。 第二步是确定状态转移方程。根据问题的特点,我们需要找到状态之间的关系,从而确定状态转移方程。在树形dp中,转移方程常常与节点的子节点相关联。我们可以通过遍历节点的子节点,利用它们的状态来更新当前节点的状态,从而得到新的状态。 第三步是确定初始条件。在动态规划中,我们需要确定初始状态的值。对于树形dp来说,我们可以选择将叶节点作为初始状态,然后逐步向上更新,最终得到整棵树的最优解。 第四步是确定计算顺序。树形dp的计算通常是从根节点开始,自顶向下逐步计算,直到达到叶节点。因为树形dp的计算过程中需要利用到子节点的状态来更新当前节点的状态,所以必须按照计算顺序进行。 夜深人静时,算法树形dp是相对较复杂的算法,需要仔细思考问题的状态表示方式,转移方程以及初始条件。在实现过程中,可以采用递归的方式进行代码编,或者利用栈等数据结构进行迭代实现。 总的来说,夜深人静算法树形dp需要耐心和细心,经过思考和实践,才能顺利解决树状结构问题。但是,一旦理解并掌握了树形dp的思想和方法,就能够高效地解决各种树形结构问题,提升算法的效率和准确性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

英雄哪里出来

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值