线段树(Segment Tree)是常用的维护区间信息的数据结构,它可以在 O(logn) 的时间复杂度下实现单点修改、区间修改、区间查询(区间求和、区间最大值或区间最小值)等操作,常用来解决 RMQ 问题。
RMQ(Range Minimum/Maximum Query) 问题是指:对于长度为 n 的数列 A,回答若干询问 RMQ(A, i, j) 其中 i, j <= n,返回数列 A 中下标在 i, j 里的最小(大)值。也就是说:RMQ问题是指求区间最值的问题。通常该类型题目的解法有递归分治、动态规划、线段树和单调栈/单调队列。
这篇内容断断续续写了两周,随着练习对线段树的理解不断深入,慢慢地学习下来也不觉得它有多么困难,更多的体会还是熟能生巧,虽然它起初看上去确实代码量大一些,但是我觉得只要大家放平心态,循序渐进的掌握下文中的三部分,也没什么难的。
1. 线段树
线段树会将每个长度不为 1 的区间划分成左右两个区间来递归求解,通过合并左右两区间的信息来求得当前区间的信息。
比如,我们将一个大小为 5 的数组 nums = {10, 11, 12, 13, 14} 转换成线段树,并规定线段树的根节点编号为 1。用数组 tree[] 来保存线段树的节点,tree[i] 表示线段树上编号为 i 的节点,图示如下:

图示中每个节点展示了区间和以及区间范围,tree[i] 左子树节点为 tree[2i],右子树节点为 tree[2i + 1]。如果 tree[i] 记录的区间为 [a, b] 的话,那么左子树节点记录的区间为 [a, mid],右子树节点记录的区间为 [mid + 1, b],其中 mid = (a + b) / 2。
现在我们已经对线段树有了基本的认识,接下来我们看看区间查询和单点修改的代码实现。
区间查询和单点修改线段树
首先,我们定义线段树的节点:
/**
* 定义线段树节点
*/
class Node {
/**
* 区间和 或 区间最大/最小值
*/
int val;
int left;
int right;
public Node(int left, int right) {
this.left = left;
this.right = right;
}
}
注意其中的 val 字段保存的是区间的和。定义完树的节点,我们来看一下建树的逻辑,注意代码中的注释,我们为线段树分配的节点数组大小为原数组大小的 4 倍,这是考虑到数组转换成满二叉树的最坏情况。
public SegmentTree(int[] nums) {
this.nums = nums;
tree = new Node[nums.length * 4];
// 建树,注意表示区间时使用的是从 1 开始的索引值
build(1, 1, nums.length);
}
/**
* 建树
*
* @param pos 当前节点编号
* @param left 当前节点区间下界
* @param right 当前节点区间上界
*/
private void build(int pos, int left, int right) {
// 创建节点
tree[pos] = new Node(left, right);
// 递归结束条件
if (left == right) {
// 赋值
tree[pos].val = nums[left - 1];
return;
}
// 如果没有到根节点,则继续递归
int mid = left + right >> 1;
build(pos << 1, left, mid);
build(pos << 1 | 1, mid + 1, right);
// 当前节点的值是左子树和右子树节点的和
pushUp(pos);
}
/**
* 用于向上回溯时修改父节点的值
*/
private void pushUp(int pos) {
tree[pos].val = tree[pos << 1].val + tree[pos << 1 | 1].val;
}
我们在建树时,表示区间并不是从索引 0 开始,而是从索引 1 开始,这样才能保证在计算左子树节点索引时为 2i,右子树节点索引为 2i + 1。
build()方法执行时,我们会先在对应的位置上创建节点而不进行赋值,只有在递归到叶子节点时才赋值,此时区间大小为 1,节点值即为当前区间的值。之后非叶子节点值都是通过pushUp()方法回溯加和当前节点的两个子节点值得出来的。
接下来我们看修改区间中的值,线段树对值的更新方法,关注其中的注释:
/**
* 修改单节点的值
*
* @param pos 当前节点编号
* @param numPos 需要修改的区间中值的位置
* @param val 修改后的值
*/
private void update(int pos, int numPos, int val) {
// 找到该数值所在线段树中的叶子节点
if (tree[pos].left == numPos && tree[pos].right == numPos) {
tree[pos].val = val;
return;
}
// 如果不是当前节点那么需要判断是去左或右去找
int mid = tree[pos].left + tree[pos].right >> 1;
if (numPos <= mid) {
update(pos << 1, numPos, val);
} else {
update(pos << 1 | 1, numPos, val);
}
// 叶子节点的值修改完了,需要回溯更新所有相关父节点的值
pushUp(pos);
}
修改方法比较简单,当叶子节点值更新完毕时,我们仍然需要调用pushUp()方法对所有相关父节点值进行更新。
接下来我们看查找对应区间和的方法:
/**
* 查找对应区间的值
*
* @param pos 当前节点
* @param left 要查询的区间的下界
* @param right 要查询的区间的上界
*/
private int query(int pos, int left, int right) {
// 如果我们要查找的区间把当前节点区间全部包含起来
if (left <= tree[pos].left && tree[pos].right <= right) {
return tree[pos].val;
}
int res = 0;
int mid = tree[pos].left + tree[pos].right >> 1;
// 根据区间范围去左右节点分别查找求和
if (left <= mid) {
res += query(pos << 1, left, right);
}
if (right > mid) {
res += query(pos << 1 | 1, left, right);
}
return res;
}
该方法也比较简单,需要判断区间范围是否需要对向左子节点和右子节点的分别查找计算。
现在表示区间和的线段树已经讲解完毕了,为了方便大家学习和看代码,我把全量的代码在这里贴出来:
public class SegmentTree {
/**
* 定义线段树节点
*/
static class Node {
线段树:区间操作与动态开点详解

最低0.47元/天 解锁文章
2255

被折叠的 条评论
为什么被折叠?



