今天插一个特别的主题, 介绍一个高级的数据结构: 线段树. 这是我在写 Tracing GC 时想到内存管理中的伙伴系统临时想起来的东西. 大家学有余力的就学一下, 如果看不懂就算了, 不用死磕这种用得不是很频繁的东西.
在编程实践中, 我们经常会遇到一些在区间上进行查询, 修改的需求. 为了支持这些操作, 引入了一种名为线段树的数据结构. 线段树有以下特点:
线段树是一棵高度平衡的二叉树, 有可能是满二叉树或者完全二叉树, 但这不是必要条件.
线段树的每一个结点都代表一个区间. 父结点所代表的区间是两个子结点的和. 兄弟结点所代表的区间相互不重叠. 根结点代表整个区间
如图 5.1 所示, 根结点代表区间 [0,7], 而其左子结点, 则代表区间 [0,3], 右子结点代表区间 [4,7]. 这是一种把父结点所代表的区间直接二分的实现方法, 虽然这种线段树最为常见, 但两个子结点的区间长度实际上是可以随意变化的. 这种变化的好处是树是高度平衡的, 树的高度为 log(n), 这就意味着从根结点到任意叶子结点的路径长度就是 log(n).
这样的数据结构有什么好处呢? 我们来看这样一道题目. 给定包含 N 个数的数组 data[N] 和 Q 个查询. 每个查询的输入 (a, b) 都是一对整数, 要求打印出 data[a] 到 data[b] 之间的最大值和最小值之差. 例如, N = 6,Q = 3, 一个输入样例是
其输出分别是 6,3,0.
如果直接去做的话, 每一次查询都要找到这个区间里的最大值和最小值, 然后求差. 显然这种做法是十分低效的. 现在有了线段树以后, 我们可以把线段树里的每个结点所代表的区间的最大值和最小值都记录在树的结点里. 先不管这棵树是如何构建的. 假如现在有一棵已经构造好的线段树, 要查某一个区间的最大最小值, 只需要 O(logn) 的复杂度就可以得到结果了.
以区间 [0,4] 为例, 我们只需要比较 [0,3] 这个结点和 [4,4] 这两个结点上的最大值和最小值就以求得 [0,4] 这整个区间上的最大最小值了. 而如果要查询的结果是整个区间的最大最小值, 那么直接返回根结点的最大最小值即可. 如图 5.2. 可见, 线段树大大提高了查询的效率. 那么又如何构建这样一棵线段树呢?
线段树本质上还是一棵二叉树, 所以构建线段树的方法与构建二叉树的方法是相同的, 都是采用递归的方法来建树. 所不同的只是结点的数据, 每个结点都保存了自己所代表的区间, 以及这个区间上的最大最小值. 实际上, 自己所代表的区间也是可以省略的, 我们接下来会看到, 通过巧妙地设置递归程序的参数, 每个结点所代表的区间都被省略了.
在递归地构建完某一个结点的两个子结点以后, 回溯到该结点时, 我们就可以通过比较两个子结点上的最大值和最小值, 来更新该结点的最大最小值. 如果这个结点是叶子结点, 那么这个结点所对应的区间就只有一个数, 所以最大值与最小值就都是这个数. 为题目中所示的例子构建线段树, 最终的结果如图 5.3 所示.
- struct node
- {
- int max, min;
- node * pleft, * pright;
- };
- struct node line_tree[N * 2 + 10];
- int data[N + 10];
- int n;
- int max, min;
- int nodeCnt = 0;
- node * getNewNode()
- {
- return &line_tree[nodeCnt++];
- }
- node * buildTree(int left, int right)
- {
- node * root = getNewNode();
- root->max = -1;
- root->min = MAX;
- if (left <right)
- {
- int mid = (left + right)>> 1;
- root->pleft = buildTree(left, mid);
- root->pright = buildTree(mid + 1, right);
- if (root->pleft->max> root->max)
- root->max = root->pleft->max;
- if (root->pright->max> root->max)
- root->max = root->pright->max;
- if (root->pleft->min <root->min)
- root->min = root->pleft->min;
- if (root->pright->min <root->min)
- root->min = root->pright->min;
- }
- else
- {
- root->min = data[left];
- root->max = data[left];
- }
- return root;
- }
- void question(node * root, int left, int right, int lleft, int lright)
- {
- int mid;
- if (max> root->max && min <root->min)
- return;
- if (lleft == left && lright == right)
- {
- if (root->max> max)
- max = root->max;
- if (root->min <min)
- min = root->min;
- return;
- }
- mid = (left + right)>> 1;
- if (mid>= lleft)
- {
- if (mid>= lright)
- question(root->pleft, left, mid, lleft, lright);
- else
- {
- question(root->pleft, left, mid, lleft, mid);
- question(root->pright, mid + 1, right, mid + 1, lright);
- }
- }
- else
- question(root->pright, mid + 1, right, lleft, lright);
- }
- int main()
- {
- int tmp, Q;
- int a, b;
- scanf_s("%d %d", &n, &Q);
- for (int i = 1; i <= n; i++)
- scanf_s("%d", data + i);
- node * root = buildTree(1, n);
- for (int i = 0; i < Q; i++)
- {
- scanf_s("%d %d", &a, &b);
- max = -1;
- min = MAX;
- question(root, 1, n, a, b);
- printf("%d\n", max - min);
- }
- return 0;
- }
整个程序很容易理解, buildTree 这个函数与递归构建二叉树的程序没有什么区别, 所不同的只是在回溯到本结点之后, 更新了这个结点的最大最小值而已.
这里重点讲解一下 question 这个函数的实现. root 代表了当前所查询的结点, lleft 和 lright 分别代表查询区间的左端和右端, 而 left 和 right 则分别代表当前结点区间的左端和右端. 如果 lleft 和 left 相等, lright 和 right 相等, 那就表示, 要查询的区间和当前结点所代表区间完全匹配. 否则, 就看要查询的区间在落在当前结点的左孩子还是右孩子.
落在右孩子时, 要查询区间完全位于当前结点区间的右半侧, 也就是 lleft 大于 mid, 此时, 递归查询右孩子即可. 落在左孩子时, 要查询的区间就完全位于当前结点区间的左半侧, 也就是 lright 小于 mid, 此时要递归查询左孩子. 如果要查询区间跨两个孩子区间的话, 那就把查询区间在 mid 处分开, 分别查询两个子结点.
程序分析过了, 可见, 线段树虽然复杂, 其根本还是一棵二叉树. 这也就是为什么, 在二叉树那一节, 我们介绍说二叉树可以说得上是掌握数据结构的基石. 如果能够深刻理解二叉树的结构, 那么掌握线段树也就成了一件很简单的事情.
来源: https://juejin.im/entry/5b9a0ffff265da0aa74f133f