intuition
最近在刷pta甲级的题目, 解题过程中遇到一个之前没有用过的知识点——树状数组, 原题链接在这里1057 Stack (30 分), 题目的大致意思是在传统的栈的基础上,要添加一个查询当前栈中元素的中位数的功能, 首先想到可以用通过维护一个BST来做, 在一个二叉搜索树中插入和删除元素都是数据结构的基本内容, 查找在所有节点中比当前节点小的节点数也可以在log(n)的复杂度实现, 实现的细节在最后补充中进行了说明. 但实现一个BST数据结构做一道OJ题来说有些傻和重, 所以搜索了一下题解, 发现可以通过树状数组来得到中位数.
在介绍树状数组的查找中位数应用之前,有两个知识点需要介绍:
- 中位数的性质:很重要 对于一个大小为N的序列中的中位数, 序列中存在(N+1) / 2 - 1个数比它小. 要找中位数也就是要统计比在序列中比当前值小的元素个数是否是(N+1)/2-1个.
- 树状数组的概念和特点.
树状数组
网上有不少讲树状数组的中文资料, 但很多都讲得不够直观. 我好不容易搞懂了之后在这里做个记录.
首先这张图反映了树状数组的存储内容特点.
树状数组的元素并不都是存储自己的值, 对于有的位置的元素, 它存储的是一个区间的元素的和, 比如2位置存储的是1和2的值之和, 12存储的是9, 10, 11, 12的元素的值之和, 而8位置存储的是1到8所有元素之和, 每个位置存储哪个区间的值之和的规律是什么呢?
这要讲到二进制相关的规律.
我们看下这几个位置的索引的二进制有什么特点
index | 二进制数值 | 存储的元素之和包含哪些 | 存储的元素之和包含的元素个数 |
---|---|---|---|
2 | 0010 | 1, 2 | 2 |
12 | 1100 | 9,10, 11,12 | 4 |
8 | 1000 | 1,2,…,8 | 8 |
回过来, 看这几个索引的二进制包含的零的数量k和其存储的元素之和包含的元素个数m 存在 m = 2 k m = 2^k m=2k的关系.
也即该索引的二进制表示中, 第一个1表示的数与其包含的求和区域有关, 比如12的第一个1
出现在100
即是4
,8的第一个1
出现在1000
也即8
. 索引和该索引位置存储的求和范围的关系我们搞清楚了, 接下来要回答的是, 这种关系能做什么.
树状数组的常见应用之前缀和
这种关系的一个典型应用是动态前缀和. 所谓动态前缀和就是对于一个序列 a1, a2, ... , an
有查询和修改操作, 查询的内容是对于给定的am
,要查询a1+a2+...+am
的值,;修改是这个序列会动态变化, 增加元素, 删除元素, 修改元素. 对于暴力算法, 一次修改的复杂度是O(1), 一次查询的复杂度是O(n), 我们想要通过某种方法降低查询的复杂度, 这就是树状数组.
树状数组如何查询
由上面的那张图可以看出, 例如要查找13位置的前缀和, 就只需要访问位置13, 12, 8, 对他们的值累加, 就得到了13位置的元素的前缀和.
我们来看二进制视角下, 这一次查询访问的位置有什么特点:
13: 1101
12: 1100
8: 1000
1101 = 1000 + 0100 + 0001
可以看出来, 访问的位置数量就是该索引中1出现的数量, 访问的位置和1的位置有关.
访问13
13: 第一个1为1
13 - 1 -> 12
访问12
12: 第一个1为100
12-4(100) -> 8
访问8
8: 第一个1为1000
8-8 -> 0
结束
树状数组如何修改
修改某个索引的值 am时, 要修改所有包含了am的元素, 对于上图, 修改13时, 要修改14, 16, 修改9时, 要修改10, 12, 16
我们在二进制视角下看看访问的这些位置有什么特点
修改a9
9: 1001 第一个1为1
9+1->10
修改10
10: 1010 第一个1为10
10+2(10)->12
修改12
12: 1100 第一个1为100
12+4(100)->16
修改16, 到达数组的最大尺寸,结束
讲完了动态前缀和, 应该大家就很容易想到, 找中位数的核心是找序列中比它小的数的个数, 也可以用树状数组来做. 此时当am这个值在序列中时, 我们就令am=1, 否则am=0, 统计a_q前缀和就是统计比a_q小的数有多少个.
举个例子, 有一个序列[1,3,4,5,7], 现在用一个长度8起点索引为1的hashTable来表示, 就是[1, 0, 1, 1, 1, 0, 1, 0], 将这个hashTable转换成树状数组的表示就是[1, 1, 1, 3, 1, 1, 1, 5]. 见下表, 可以对照上面的示意图一起看.
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|
hash表示 | 1 | 0 | 1 | 1 | 1 | 0 | 1 | 0 |
树状数组表示 | 1 | 1 | 1 | 3 | 1 | 1 | 1 | 5 |
在讲找中位数之前, 还要讲的一个问题是如何找到一个数的二进制表示下的第一个1的位置
回顾一个问题, 一个正数的补码如何表示, 例如,对于6(0110),它的补码是其反码加1, 也就是(1001+1= 1010), 求一个二进制数的补码, 就是找到这个数中的第一个1
, 其右边保持不变, 左边取反. 于是我们可以用一个很巧妙的方法, 取到第一个1的位置
int lowbit(int i) {
return i & (-1);
}
通过该二进制数和其补码做与运算,保留了第一个1
以及其右边的部分, 得到的就是这个1