树状数组(Binary Indexed Tree)
前面几篇文章我们分享的都是关于区间求和问题的几种解决方案,同时也介绍了线段树这样的数据结构,我们从中可以体会到合理解决方案带来的便利,对于大部分区间问题,线段树都有其绝对的优势,今天这篇文章,我们就来欣赏由线段树变形的另外一个数据结构--树状数组,树状数组通常也用于解决区间求和、单点更新的问题,而且效率比线段树高一些(树状数组区间求和和单点更新的时间复杂度均为o(log n)),相对而言,线段树的应用范围可能更广泛一些。但不得不承认,树状数组确实也是一种优雅高效的结构。接下来,我们就一起来揭开它的神秘面纱。
第一点,树状数组的结构和线段树类似,但是比线段树的节点少,第二点,树状数组的每个节点中存储的也是对应范围的元素和,同时与线段树一样,树状数组也是采用数组存储结构,第三点,树状数组与线段树的下标定义规则不同。如下图所示,这是线段树的存储图:
我们从上篇文章(文章链接:线段树第二弹(区间更新))中可以发现,线段树的下标编码规则是由上而下、从左至右依次编码,在树状数组中,不再采用这样的编码方式,具体如何编码稍后将会解释,现在我们先来观察一下线段树的特征。
我们假设,此线段树中每个节点存储的是相应的区间范围内所有元素的和。这时我们可以发现,对于每个右孩子节点的值,我们总能通过其父亲节点值减去左兄弟节点值来计算,这就意味着,即使没有右节点,也丝毫不影响我们求解对应区间的区间和,那我们何不节省空间,但这样又引发了另外一个问题,在去掉所有的右节点之后,之前的下标编码方式肯定是用不了了,这时候我们就需要一套新的下标编码方式,既能节省这部分空间,又不会将原来的问题复杂化,最好能将问题进一步简化,这就是树状数组的产生背景。至于新的一套编码方式一路走来经历怎样的探索过程,不是我们今天要说明的重点,在此我就不作过多的解释,现在我就直接抛出树状数组最后确定版的下标编码方式,使用这个方式的原因当然是:它简单啊、好用啊、优雅啊,何以见得?等看完这篇文章,你大概就能体会到它的魅力了。
如下图所示为树状数组的逻辑结构,其中每个节点中存储的依然是对应区间的元素和:
这是树状数组的逻辑结构,其中方块中的数字表示对应区间的下标范围,红色字体表示节点的下标,图中的蓝色粗线条将每个节点和其对应的下标连接(线条这么粗,大概不会有人看不清楚了吧)。乍一看觉得节点下标的编码方式似乎有点无理取闹,同一层级的两个节点竟然下标不相连,这是什么逻辑?每当我们感觉走投无路的时候,也就是我们需要重新审视手里掌握的所有线索的时候,只有不放过任何一个细微的线索,才能找到破解之法。不妨我们就将所有能观察到的线索一一列出。
上面我们介绍的其实是树状数组的逻辑结构,它的物理存储就是一个一维的数组。我们将上图的特征制表如下:
观察上表,我们可以得出如下结论:
一、节点下标为 i 时,节点中对应的最后一个元素下标为 i
二、节点下标对应的二进制数末尾有 k 个 0 ,节点中对应的元素个数为 2 ^ k
三、节点中对应的元素下标是连续的
四、树状数组的节点个数和原数据元素个数相等
以上便是树状数组的主要基本特征,知道了这些特征之后,我们可以发现,要改变原数据数组中的一个元素值,在树状数组中最多需要更改 o(log n)个节点值,因此单点更新的时间复杂度为 o(log n)。单点更新的具体实现怎么做,在文章末尾会向大家展示,现在先继续讨论接下来的问题。
这时候我们会发现,刚才所列出来的所有特征,似乎没什么用得上的,就像在生活中我们手里掌握的零碎的知识、技能、人脉等看起来是一片散沙,我们不知道什么时候才会用得到,甚至穷尽一生也不可能全都用得到,但是一旦有机会用,我们才能真正意识到那些是多么的重要,其中的联系是多么紧密。与其说学习算法是在学一门技术,不如说是在学习一门艺术,因为在此期间接触到的很多方法都可以从生活中找到影子。所以我们暂时不要灰心,继续研究,也许更深入些,这些琐碎的特征就会变得有用。
树状数组方便处理的其实是“前 i 个元素和”这种问题,
以上图为例:
前 1 个元素和为
sum[1] = sum[0001] = tree[1]= tree[0001]
前 2 个元素和为
sum[2] = sum[0010] = tree[2]=tree[0010]
前 3 个元素和为
sum[3] = sum[0011] = tree[2]+tree[3]= tree[0011] + tree[0010]
前 4 个元素和为
sum[4] =sum[0100] = tree[4]= tree[0100]
前 5 个元素和为
sum[5] = sum[0101]= tree[4]+tree[5]=tree[0101]+tree[0100]
前 6 个元素和为
sum[6] = sum[0110] = tree[4]+tree[6]= tree[0110] + tree[0100]
前 7 个元素和为
sum[7]=sum[0111]=tree[7]+tree[6]+tree[4]=tree[0111]+tree[0110]+tree[0100]
前 8 个元素和为
sum[8] = sum[1000] = tree[8]= tree[1000]
红色部分是下标的二进制表示形式,我觉得到目前为止,我们可能真的是走投无路,才无所不用其极,连下标也不放过。仔细观察这些下标,似乎还是有一定的规律可循的,现在我们就挑一个表达式最长,能说明问题的来研究一下
sum[7]=sum[0111]=tree[7]+tree[6]+tree[4]=tree[0111]+tree[0110]+tree[0100]
由上述表达式可以发现,前7个元素和 sum[0111] 的加数包括 tree[0111], 在此基础上,每次将下标从右向左数第一位 1 抹去作为下一个加数的下标, 直到数字变为 0 结束,0111 抹去最后一位 1 得到 0110,0110 抹去最后一位 1 得到 0100,0100 抹去最后一位 1 得到 0000 结束运算。这似乎勉强可以算作一个规律吧,经过验证发现,以上所有的表达式均符合这个规律。在此我可以告诉大家,经过无数的高手验证,这个规律确实存在,所以我们可以大胆的使用。
但是,在我们用话语描述的时候,可以说抹掉最后一位 1 ,在实际的实现中,我们就需要用规范的语言来表达,要想达到抹掉最后一位 1 的效果,就需要减去一个数 x ,将 0111 抹掉最后一位 1 得到 0110 时,x = 0001,将0110抹掉最后一位 1 得到 0100 时,x = 0010 ,将 0100 抹掉最后一位 1 时,x = 0100 。也就意味着,原数值需要抹掉哪一位,那么 x 的哪一位就为 1 ,其余各位均为 0 。如何求原数值最后一位 1 是哪一位呢?我们可以发现原数值末位有 k 个 0 时,x = 2 ^ k,现在,我们前面列出来的特征就有联系了。目前我们的任务就是求 k 的值,当然对于人来说,一眼看出一个数末尾有几个0简直易如反掌,但对于计算机,似乎没那么容易,这时候如果将原数值的二进制数看作整体,似乎不合理,我们需要将其各位分离,这就用到了位运算。对于当前的问题,有一个求解技巧可以分享给大家,我们都知道在计算机内部两数的运算用的是补码实现的,对于正数来说,补码和原码形式是一样的,但对于负数来说,补码便是将原码按位取反后在末位加 1 ,这就导致一个负数绝对值的补码和这个负数的补码在形式上满足:以从右向左数第一个非零位为界,在左侧,负数绝对值的补码和负数的补码各位均不相同,在右侧,负数绝对值的补码和负数的补码各位均为0,将两数做 and 运算得到的数字刚好就是我们需要求的 x ,我们知道 负数的绝对值和负数本身互为相反数,同时也说明一个正数的补码与其相反数的补码做 and 运算 得到的数字就是 x 。
验证实例如下:
1 &(-1)补码运算 : 0001 & 1111 = 0001
2 &(-2)补码运算 : 0010 & 1110 = 0010
3 &(-3)补码运算 : 0011 & 1101 = 0001
4 &(-4)补码运算 : 0100 & 1100 = 0100
5 &(-5)补码运算 : 0101 & 1011 = 0001
6 &(-6)补码运算 : 0110 & 1010 = 0010
7 &(-7)补码运算 : 0111 & 1001 = 0001
8 &(-8)补码运算 : 1000 & 1000 = 1000
由此,我们得到每次的减数 x =i & ( -i )
至此,求 前 n 项和的规律已经找到,我们的任务就是将这个规律用规范的语句描述并尝试着用代码实现。
求和具体实现描述如下:
假设 当前求前 i 项 之和
第一步:判断 i > 0 是否成立。如果成立进行下一步,否则退出循环
第二步:sum = sum + tree[ i ] , x= i &(-i)
第三步:i = i - x ,回到第一步
知道了前 i 项和的求解方法之后,要求解区间和相对来说就容易多了,例如求解区间为 i ~ j ,显然,我们就可以得知
sum[ i~j ] = sum [ j ] - sum [ i ] 。
解决了前 i 项和的问题之后,现在我们再回过头来分析单点更新的问题,还是刚才的图:
假设我们现在要修改的元素在原数据中下标为 3 ,那么在树状数组中,我们需要修改的节点下标分别为 3 、4、8
还是按照刚才的分析方法,在原数据中待修改元素下标为 3 = 0011
在树状数组中,需要修改的节点下标为 3 = 0011、4 =0100、8 =1000,
观察发现,0011 +0001 = 0100 ,0100 +0100 = 1000 ,发现规律了吗?x 值依然是刚才的求法,现在是每次给当前的下标值加 x 就得到下一个加数的下标值,直到下标值大于元素的总个数停止。具体的实例不过多赘述,大家可以自己私下验证。
单点更新的具体描述如下:(假设更新的规则是给 下标为 i 的元素加 y )
假设待更新元素下标为 i ,元素总个数为 n
第一步:判断 i <= n 是否成立,成立则进行下一步,不成立结束循环
第二步:tree[ i ] = tree[ i ] + y , x = i & ( -i ) ,进行下一步
第三步:i = i + x ,跳回第一步
以上就是今天内容的理论部分,下面为大家奉上核心代码实现部分,希望今天的分享能让大家有收获。代码如下:
除了并查集,这应该是见过的最精简最优雅最高效的代码了。
还没有关注公众号的朋友可以长按下图识别图中二维码关注我。
老规矩,打开网页http://paste.ubuntu.com/25548013/查看网页版代码。