二分索引树与线段树分析

简述

  二分索引树是一种树状数组,其全名为Binary Indexed Tree。二分索引树可以用作统计作用,用于计某段连续区间中的总和,并且允许我们动态变更区间中存储的值。二分索引树和线段树非常相似,二者都享有相同的O(log2(n))时间复杂度的更新操作和O(log2(n))时间复杂度的查询操作,区别在于二分索引树更加简洁高效,而线段树则较冗杂低效,原因在于对二分索引树的操作中是使用了计算机中整数存储的特性来进行加速,而线段树中由于使用的是比较操作,因此性能不及二分索引树。那么为什么我们不抛弃线段树呢?原因在于所有二分索引树能解决的问题,线段树也都可以解决,但是线段树还能解决许多二分索引树无法解决的问题。下面将先后讨论二分索引树和线段树。

二分索引树(一维)

  要叙述二分索引树,我们必须先说明二分索引树高效的关键,lowbit操作,其中lowbit(x)=x&-x。我们知道在计算机中,对于一个带符号n位整数b,其由n个二进制位表示,可以记为(b[n-1],b[n-2],...,b[0]),其中对于任意0<=i$$ value\left(b\left[n-1\right],\cdots ,b\left[0\right]\right)=-b\left[n-1\right]\cdot 2^{n-1}+\sum_{i=0}^{n-2}{b\left[i\right]\cdot 2^i} $$对于一个任意正整数X,我们先对其按位取反(除了首位),得到一个新的数Y,很容易可以得知X+Y=2^(n-1)-1,因此X+Y+1=2^(n-1),换言之Y+1-2^(n-1)=-X,因此我们得到-X的二进制表示,其后n-1位为X按位取反后加1得到的(与Y+1的后n-1位相同),且首位为1。接下来考虑X&-X的实际含义,注意由于X为非负正数,因此X的首位为0,而运算为且运算,因此我们可以忽略-X的符号位带来的影响。我们不妨认为X的后面k位均为0,且X[k]=1,对应的Y的后面k位均为1,而Y[k]=0。而Y+1的后面k位均为0,而(Y+1)[k]=1,之后的前面n-2-k位与Y一致,与X相反。因此X&-X得到的值应该为2^k,到此我们可以得出一个结论X&-X得到的数为以二进制视角从后数起第一个为1的二进制位所代表的整数值。事实上这个结论对于X为0时也是成立的,并且由于且运算满足交换律,因此当X为负数时也可以得到正确结果,这些只是补充说明而已,我们要用到的只是lowbit应用到正整数情况下表现出的性质而已。

  接下来我们用二分索引树表示一段长度为L的数组A,且从1开始计数,即我们认为下标分别为1,2,...,L。二分索引树支持两种操作,修改某个A[i]的值,以及查询S[j]=A[1]+A[2]+...+A[j]的加总和,两种操作允许以任意次序交错执行。

  不难知道使用原始数组存取,我们的查询时间复杂度将达到O(L),在查询密集的情况下是一个噩梦,而如果我们缓存S[j]的值,这样每次更新的时间复杂度将达到O(L),这也是不允许的。我们可以利用一个精致的机制,我们首先定义一个长度为L的数组data,其中data[i]表示S[i]-S[i-lowbit(i)]的值,换言之,data[i]表示A在区间(i-lowbit[i],i]范围内所有元素的加总值。这样我们的查询操作可以用下面的伪代码实现:

query(x)//查询A[1]+...+A[x]
    sum = 0
    for(i = x; i > 0; i = i - lowbit(i))
        sum = sum + data[i]
    return sum

  我们定义函数f(x):=x-lowbit(x),g(x):=x+lowbit(x)

  在整个流程中我们汇总了下标为f^0(x),f^1(x),...,f^k(x)的data中的值,我们对其进行加总得到:$$ data\left[f^0\left(x\right)\right]+data\left[f^1\left(x\right)\right]+\cdots +data\left[f^k\left(x\right)\right] $$ $$ =S\left[f^0\left(x\right)\right]-S\left[f^1\left(x\right)\right]+S\left[f^1\left(x\right)\right]-S\left[f^2\left(x\right)\right]+\cdots +S\left[f^k\left(x\right)\right]-S\left[0\right] $$ $$ =S\left[f^0\left(x\right)\right]=S\left[x\right] $$

  到此我们说明了query返回的结果是正确的,而时间复杂度,我们每次调用函数f都会导致i最靠后的为1的二进制位被修改为0,而i中最多有log2(i)个为1的二进制位,因此时间复杂度为O(log2(i)),在i=L的情况下达到最大O(log2(L))。

  说明完了查询,下面说明如何存储数据。当我们修改了A[i]中的值,我们势必需要修正所有满足f(x)

  命题1:对于一个给定数k和i,最多只存在一个同时满足lowbit(x)=2^k且f(x)

  证明:假设x与y为两个不同的满足条件的值,则有x-2^k2^k(由于2^k同时是x与y的约数,后面不再说明),而y-2^k>x>i>y-2^k将成立,这是不可能的,因此命题成立。

  命题2:若两个不同的数x,y同时满足f(x)

  证明:记2^p=lowbit(x),则x>y可以得出x-y>=2^p,即i>x-2^p>=y,这与前提相悖,因此命题成立。

  命题3:若某个数x满足f(x)=2^t

  证明:设lowbit(x)=2^p<2^t,我们可以记y为x将后面t位均设置为0后的值,因此有y+2^t>x>=i,即i-y<2^t,从而可得i<=y,而x-2^p>=y>=i,因此命题成立。

  命题4:若某个数x满足f(x)

  证明:证明y=x+lowbit(x)满足f(y)x,且f(z)lowbit(x),由此可知z-x>=lowbit(x),即z>=lowbit(x)+x=y。

  由上面4个命题可以得出一系列需要更新的下标从小到大排序为g^0(x),g^1(x),...,g^k(x)。因此我们给出下面代码:

update(j, val) //令A[j]增加val
    for(i = j; i <= L; i = i + lowbit(i))
        data[i] = data[i] + val

  这里使用的是加法,和前面提及的直接赋值有所不同,但是可以将直接对A[j]赋值v对应的修改为令A[j]增加v-A[j]即可。

  由于i在每次循环,其lowbit值不断增大,因此update的时间复杂度也是log2(L)。

多维二分索引树

  上面讲解的是一维二分索引树,下面说明多维二分索引树。

  首先说明问题:存在一个n维数组,修改操作修改矩阵任意下标M[i[0][i[1]]...[i[n]]的元素,查询操作查询S[i[0]][i[1]]...[i[n]]=ΣA[j[0]][j[1]]...[j[n]],其中j[t]<=i[t]。

  我们定义一个n维数组data,其与M等大,且data[i[1][i[2]]...[i[n]]=ΣA[j[1]][j[2]]...[j[n]],其中j[t]-lowbit(j[t])

query(i)
    sum = 0
    for(v[1] = i[1]; v[1] > 0; v[1] = v[1] - lowbit(v[1])
        ...
            for(v[n] = i[n]; v[n] > 0; v[n] = v[n] - lowbit(v[n])
                sum = sum + data[v[1]]...[v[n]]
    return sum

  而对于A[i[1]]...[i[n]]的修改操作,我们只需要明确具体哪些data元素需要修正,即我们要找到所有向量j,使得j[t]-lowbit[j[t]]

update(i, val)
    for(v[1] = i[1]; v[1] <= L[1]; v[1] = v[1] + lowbit(v[1]))
        ...
            for(v[n] = i[n]; v[n] <= L[n]; v[n] = v[n] + lowbit(v[n])
                data[v[1]]...[v[n]] = data[v[1]]...[v[n]] + val

  综上可以发现二分索引树很容易就可以扩展到多维空间中,并且保留原来的简洁高效。多维二分索引树的查询和修改操作的时间复杂度上界一致,均为O(log2(L[1])...log2(L[n]))。

线段树

  我们接下来用线段树来完成区间修改和区间最值问题。假设我们用数组A[1...n]来表示区间[1,n]的值。此时很容易发现一次对区间[l,r]的修改或最值查询都要耗费O(r-l+1)的时间复杂度,这样对于特定数据每次操作的时间复杂度均为O(n)。

  换个思路,我们利用分组的思想来优化时间复杂度。我们将A按k进行分组,即我们将子数组A[1...k]作为第1组,将A[k+1,...,2k]作为第二组,以此类推得知第i组为A[(i-1)k+1,ik]。我们发现总共有$ \lceil k/t\rceil $个分组,我们对应的建立一个辅助数组A1,其中A1[i]表示对A分组结果中第i组的最大值,即max{A[(i-1)k+1,ik]}。好的做完了上面步骤,我们现在来观察一次修改和查询的时间复杂度:当我们对某个区间[l,r]做查询时,我们发现若A的分组i其所代表的区间若完整落于[l,r]之中,那么我们就可以直接读取A1[i]来替代对A的分组i的完全检索查询其上最大值。由于区间是连续的,因此最多有两个分组只有部分与[l,r]相交,此时我们对这两个分组特殊处理,直接去A中搜索两个分组与区间[l,r]交集对应的单元的值,并提取最大值。因此我们最多完整扫描了A1和A中的两个分组,时间复杂度为O(n/k+2k)。当k取$ \sqrt{n} $时,此时查询的时间复杂度为O($ \sqrt{n} $)。

  看吧,我们已经成功优化了查询的性能。但难道这就是极限吗?我们发现在查询的过程中同样涉及到了一个类似的问题,我们对A1做了区间最值的查询!哈哈,可让我找到了。考虑到我们之前分组带来的性能的巨大优化,我们显然可以继续对区间A1按k进行分组,得到辅助数组A2,这时候按类似的分析技巧可以得出查询的时间复杂度为O(n/k^2+4k)。当k取$ \sqrt[4]{n} $时,时间复杂度则为O($ \sqrt[4]{n} $)。不难发现按同样的套路对A2进行分组,得到A3,同时继续对A3分组。直到我们进行了logk(n)次分组,得到了Alogk(n),而Alogk(n)只有一个元素,其代表区间[1,n]之间的最大值,我们已经无法再继续分组了。再考虑此时的时间复杂度,可以得出为$ O\left(2k\log_k\left(n\right)+1\right) $。设n充分大,令式子对k进行微分:

$$ \frac{d\left(2k\log_kn+1\right)}{dk}=2\log_kn+2k\frac{1}{n\ln k}>0 $$

因此我们知道时间复杂度与k的取值正相关,我们取k允许的最小值2,得到最优时间复杂度O(log2(n))。

  这整段的证明我们都忽略了n可能不是2的幂次这样一个事实,这样分析还是有效的吗?事实上我们只需要对n进行扩增使得等于2^m即可,其中m尽可能的小,可以证明2^m<2*n。这样我们的分析的结果仅仅有一个常数(log2(2n)-log2(n)=1)级别的误差,可以忽略。还有就是为什么在只有A2的时候,查询的时间复杂度为O(n/k^2+4k),而不是O(n/k^2+6k)?这是因为A2最多有两个区间只与[l,r]部分相交的,分别记为[l,l']与[r',r]。这时候我们会选择在A1中找[l,l']的最大值时,此时最多只有一个A的分组(即对应的A1中的元素)与[l,l']部分相交,原因是l'必然是A的某个分组的右边界。对于[r',r]也是同理,因此在每一层的搜索中,我们能保证每层最多只有两个区间与查询区间部分相交。

  我们将k=2的分组下所有的辅助数组进行组合,将Alog2(n)-d放在深度d,我们就构建了一个树形结构,即得到了线段树。事实上我们可以为每个辅助数组中的元素设置左右孩子,其左孩子和右孩子分别为用来总结出自身的两个下级元素。这样的方法得到的树显然是满二叉树。

query(from, to, left, right, node)
    if(from <= left && to >= right)
        return node.val
    result  = 0
    center = (left + right) / 2
    if(from <= center)
        result = max(result, query(from, to, left, center, node.left)
    if(to > c)
        result = max(result, query(from, to, center + 1, right, node.right)
    return result

  到此我们竭尽精力优化了查询的性能。但是区间更新操作呢?对区间[1,n]整体做一次更新,我们发现原数组A与所有辅助数组都必须得到了更新,而考虑满二叉树的性质,我们发现总共需要更新2n个结点才行。这不是性能更差了吗,整一个拆东墙补西墙的操作。别急,有办法解决。每次我们更新的时候,可以从上至下递归执行,即: 

update(from, to, val, left, right, node)
    if(left == right)
        node.val = val
     return
center = (left + right) / 2 if(from <= center) update(from, to, val, left, center, node.left) if(to > center) update(from, to, val, center + 1, right, node.right) node.val = max(node.left.val, node.right.val)

  注意上面这个过程其时间复杂度最大可以达到O(n),下面我们说明如何降低更新的时间复杂度。我们为每个结点引入一个惰性标记,若其非空,则表示有一个更新操作停滞在这个结点(这个结点已经更新完毕),但是尚未对其子结点进行更新。若一个结点x惰性标志不为空,那么其子结点中保存的值是错误的,这个错误将一直延续到父结点放弃对该次操作的滞留,即允许该次操作向下传播,对后代结点进行更新。由于这个原因,因此我们在访问某个结点的时候,必须先判断其祖先结点是否有非空惰性标志,若有,就无法取到正确值。但是由于我们的查询和更新都是至顶而下的,因此我们可以保证在处理某个结点之前都可以保证其祖先结点没有维护惰性标志,并且同样在访问孩子之前必须先清除自身的惰性标志。惰性标志的清除非常简单,直接对孩子代表的子树进行全体更新即可。

  为了帮助降低时间复杂度,我们在引入一个约束,一个结点只能缓存完整更新自身所代表区间的操作,且如果一个结点能滞留某个完整的更新操作,其必定会将其滞留。由这个约束将带来一个重要的优化,即如果一个结点接受到一次全体更新请求,且发现自身已经有一个滞留的惰性标志了,那么我们可以合并这两次更新操作,并将合并结果覆盖原来的惰性标志。在赋值更新的情况下,合并的结果为新的赋值操作,而在增量更新的情况下,合并的结果是二者增量之和。这样我们实际上就相当于少执行了若干次更新操作,但是这还不是其最大的意义,最大的意义在于要清除一个结点的惰性标志,其时间上相当于将惰性标志下放给其子结点代表的子树,但是下放的惰性标志由于完整的更新了这些子树,因此必定会被该结点的子结点所滞留。这样一来一去,清除某个结点的惰性标志的时间复杂度就成为了常数。

  修改后的查询操作和更新操作与原来版本并没有什么区别,只是增加了在访问子结点之前清除惰性标记的操作,以及结点滞留更新操作的过程。下面仅给出清除惰性标记的方法:

clear(l, r, node)
  update(l, r, node.lazy, l, r, node.left)
  update(l, r, node.lazy, l, r, node.right)
  node.lazy = NIL

  修改后的查询时间复杂度并没有改变,只是增加了一个时间费用为常数的清除惰性标记的操作(原来该函数执行一次也是常数时间复杂度,因此加上后没有改变)。而对比更新操作和查询操作,二者的程序流程是完全一致的,区别仅在于操作不同而已,考虑到二者函数执行一次(即不考虑递归)的时间复杂度均为常数,因此更新操作的时间复杂度与查询一致,也是O(log2(n))。

转载于:https://www.cnblogs.com/dalt/p/8088078.html

你可能感兴趣的:(数据结构与算法)