线段树是什么?
首先,应该明确线段树是一种数据结构,而不是算法,其次线段树能够很有效的解决数组上一些问题,所以在用线段树的时候,我们要明确在哪些地方用,以及用在什么地方。说得更加具体一点,例如说有一个数组[1, 3, 5, 7, 9],我们想要求下标从1到4的和,按照正常思路,我们遍历一遍数组,用一个变量来保存求和结果,只需要O(N)的时间复杂度就能够求出来了,这也许很快,但还不够快,至少在线段树面前,那么线段树能够做到什么程度?线段树能够只花费对数级的时间复杂度O(logN)求出来,至于如何做到,接着往下看。
敌兵布阵
标题可以点击
先来看一下这道经典题目描述:
C国的死对头A国这段时间正在进行军事演习,所以C国间谍头子Derek和他手下Tidy又开始忙乎了。A国在海岸线沿直线布置了N个工兵营地,Derek和Tidy的任务就是要监视这些工兵营地的活动情况。由于采取了某种先进的监测手段,所以每个工兵营地的人数C国都掌握的一清二楚,每个工兵营地的人数都有可能发生变动,可能增加或减少若干人手,但这些都逃不过C国的监视。
中央情报局要研究敌人究竟演习什么战术,所以Tidy要随时向Derek汇报某一段连续的工兵营地一共有多少人,例如Derek问:“Tidy,马上汇报第3个营地到第10个营地共有多少人!”Tidy就要马上开始计算这一段的总人数并汇报。但敌兵营地的人数经常变动,而Derek每次询问的段都不一样,所以Tidy不得不每次都一个一个营地的去数,很快就精疲力尽了,Derek对Tidy的计算速度越来越不满:"你个死肥仔,算得这么慢,我炒你鱿鱼!”Tidy想:“你自己来算算看,这可真是一项累人的工作!我恨不得你炒我鱿鱼呢!”无奈之下,Tidy只好打电话向计算机专家Windbreaker求救,Windbreaker说:“死肥仔,叫你平时做多点acm题和看多点算法书,现在尝到苦果了吧!”Tidy说:"我知错了。。。"但Windbreaker已经挂掉电话了。Tidy很苦恼,这么算他真的会崩溃的,聪明的读者,你能写个程序帮他完成这项工作吗?不过如果你的程序效率不够高的话,Tidy还是会受到Derek的责骂的.
可能看完后,你很快就能够写出代码来,但你的代码也许跟tidy的代码是一样的,线性搜索每一个营地,然后汇报给Derek,当你得出结果的时候,也许敌人已经快打到家门口了,所以我们需要精确的求出答案,并且要快速。在这道题面前线段树就起作用了。
构造线段树
在看上面的题,我们假设有5个营地,每个营地有若干个人,每个营地的编号从0 - 4。
很明显这是一个数组,并不是线段树,我们需要做的就是把这个数组转换成线段树。那么问题关键就是如何构造呢?
我们先来看一组数据:
- arr[0, 4] = 25
- arr[0, 2] = 9, arr[3, 4] = 16
- arr[0, 1] = 4, arr[2] = 5, arr[3] = 7, arr[4] = 9
- arr[0] = 1, arr[1] = 3
上面的数据已经看出了树的特征了,我们还是直接看图吧。
这张图片所代表的就是一个线段树,将一个数组分成两份,然后再从每份中继续分下去,我们可以看到其实这个线段树的叶子从左到右就是数组,最关键的如何用代码构造这个线段树,因为用到了树,肯定会用回溯法,那么,我们再来思考一个问题,我们真的需要去构造一个树吗?其实不用,上面这个树其实已经是一颗完全二叉树,我们可以用数组去表示这个树,tree = {25, 9, 16, 4, 5, 7, 9, 1, 3, 0},比较了解树状数组的同学其实就可以知道,在tree数组中,对于一个节点tree[i](i ∈[0…tree.size()]),它的左子树等于tree[2 * i + 1], 右子树等于tree[2 * i + 2], 需要注意的是,如果2 * i + 1超过了数组大小,说明i节点已经是叶子了。方便各位理解,我把上面线段树用下标标记一下。
如果你能看到这里,恭喜你又掌握了一种数据结构,下面最关键的就是用代码来构造一个线段树了。
关于如何构造我不去细讲,因为这真的是回溯法的事,解释都在注释里了。
/**
* 构造线段树 回溯
* @param arr 数组
* @param tree 线段树
* @param node 当前节点
* @param start 开始位置
* @param end 结束为止
*/
static void buidTree(int[] arr, int[] tree, int node, int start, int end) {
if (start == end) tree[node] = arr[start]; // 到叶子节点了,写入到线段树中, 回溯
else {
int mid = (start + end) >> 1;
int left_node = 2 * node + 1;
int right_node = 2 * node + 2;
// 将数组一分为二,从中间分
buidTree(arr, tree, left_node, start, mid); // 左子树
buidTree(arr, tree, right_node, mid + 1, end); // 右子树
// 一个节点一定等于它的左右子树之和,这里回溯
tree[node] = tree[left_node] + tree[right_node];
}
}
打印出来的tree数组如下:
[25, 9, 16, 4, 5, 7, 9, 1, 3, 0]
还有,我要解释一点,如果我们要把一个数组构造成线段树,往往我们是不知道究竟会有多少个节点,所以我们一般定义一个常量,这个常量一定要满足你所能够构造的最大数组的线段树,当然我这儿只是一个base case,方便各位理解。
构造线段树很简单吧,可是上题中,我们每个营地中的人数是会改变的,所以我们还会有一个方法用来修改值。
看下面这张图片,
红框里的节点,其实就是我们对应的营地人数,比如说我们想要修改1号营地的人数,修改成只有2个人。
我们照例用图片来看一下修改过程:
首先,我们先找到需要修改的值,将其修改。至于如何去找?这个问题问的好,我们想要找到1号营地所在位置,其实就是找下图中对应**[1]所在的位置,我们可以通过二分法**去找。
然后依次修改对应的父节点的值,通过回溯法一层一层的修改,其实和buildTree方法差不多。
先用几张图片模拟一下修改过程,方便理解
更新节点的算法Java代码如下,我们需要理解的就是注释的部分。
/**
* @param idx 修改arr数组对应的值
* @param newVal 修改的新值
*/
static void update(int[] arr, int[] tree, int node, int start, int end, int idx, int newVal) {
if (start == end) { // 找到需要修改的节点
arr[idx] = newVal;
tree[node] = newVal; // node对应线段树需要修改的位置
} else {
int mid = (start + end) >> 1;
int left_node = 2 * node + 1;
int right_node = 2 * node + 2;
// 二分搜索,idx在mid的左边还是右边
if (idx >= start && idx <= mid) {
update(arr, tree, left_node, start, mid, idx, newVal);
}
else {
update(arr, tree, right_node, mid + 1, end, idx, newVal);
}
// 修改父节点
tree[node] = tree[left_node] + tree[right_node];
}
}
输出如下:
修改前:[25, 9, 16, 4, 5, 7, 9, 1, 3, 0]
修改后:[24, 8, 16, 3, 5, 7, 9, 1, 2, 0]
整体思路如下:
- 通过二分法找到线段树中的节点修改其值
- 回溯之前的状态,修改其父节点
如果你能看到这儿,你问题基本上解决一半了,接下来就是最重要的内容了,也就是,我们如何将对数组的求和算法从O(N)降到O(log N)。相信很多人已经知道答案了,就是写不出代码了,没事我们一步一步来,先解决一些其他问题。
通过线段树求和
如果Derek想要我报告0到4号营地人数,0到3号营地人数,我们通过线段树很快都能找到答案,可如果Derek想要我们报告2到3号营地的人数,可是上图线段树中并没有这个线段该咋办?
我们想求**[2, 3]的总和,可是线段树中并没有这个线段,只有线段[2]和[3]的和,其实很明显,我们根本没必要去找[2, 3],我们只需关注[2]和[3]就行了,找到后将两者加起来就行了。包括求[0, 4]也是一样,不必关系[0, 4],只关心[0, 2]和[3, 4]**,依次类推就行了,分而治之的思想有点在里面。至于为什么复杂度是O(logN)?那么为什么呢?因为网上这么说的,哈哈,别打我,我是真的有认真研究《算法4》这本书,其实我们求和不用像传统方法来遍历整个数组,因为线段树将数组分成了很多个区间,我们只需要去找特定的区间就行了。
话不多说,上代码:
static int query(int[] arr, int[] tree, int node, int start, int end, int L, int R) {
// 这里相当于状态剪枝,因为我们没必要去找所有节点
// 例如我们求[2, 3]的时候,如果没有这个判断,它会去找[0, 1]或者其他没用的区间
if (node < L || node > R) return 0;
// 找到了此区间,返回此区间的和
if (start == end) return tree[node];
else {
int mid = (start + end) >> 1;
int left_node = 2 * node + 1;
int right_node = 2 * node + 2;
// 从左子树去找
int left_sum = query(arr, tree, left_node, start, mid, L, R);
// 从右子树去找
int right_sum = query(arr, tree, right_node, mid + 1, end, L, R);
return left_sum + right_sum;
}
}
线段树的更新、计算、构造全部说完了,不过说到这里,我还是有必要啰嗦一句,上面的题感兴趣的可以去原网站做一下,是一道很经典的题目,但是在实际开发当中,线段树用的好很牛逼,用的不好就是一个坑,对于一个指定大小的数组,我们求它的线段树也是固定大小的,数组大小更是不可能改变,因为这会导致程序出现overflow的bug。当然这样也限制了线段树,虽然线段树在对数组进行区间访问和修改数组的时候效率很高,但却不能够改变数组的大小,也就是增删数据。
完整代码的话,可以关注一下我的github仓库,说实话这代码我写的是真的丑,因为方法的参数太多,导致代码冗余了,arr和tree完全可以设置成全局变量,感兴趣的可以自己琢磨把代码封装成类。
另外,欢迎大佬指出其中的错误
写在后面
其实对于数据结构和算法,我也有自己的理解,我研究这东西包括在网上刷题已有一年多的时间,我也总结出了自己的一套东西,当然也借鉴了许多大佬的知识。
不过算法这东西的好坏完全依赖于数据结构的使用,比如拿线段树说事,设计出线段树这一数据结构,就是为了解决某种算法上的问题,但线段树并不是算法,它是存储数据的一种结构,而我们如何高效的对数据进行增删改查,这才是算法做的事,算法的背后就是数学,只是依赖于数据结构表现出来而已。
另外为了一点私心,我做一下宣传吧,有兴趣的可以关注一下我的微信公众号:NonCover。另外还有我的CSDN。
撒花。。。。。下次见