线段树是一种二叉搜索树。线段树顾名思义,是用树中的一个结点,来表示一段区间的信息。保存区间的什么信息,要根据具体的需求来决定。线段树的根结点表示整个区间,父结点代表的区间是两个子结点的区间和,两个子结点代表的区间相互不重合。我们来看一个例子来对线段树进行说明。
有这样一个一维数组arr[6] = {1,4,5,6,8,9},用线段树的形式表示如下:
其中每个结点代表一段区间,区间信息在图中用[ ]来表示,它的两个子结点是将父结点的区间分成2半得来的。
有了上面这棵线段树,我们就能把一些对于区间的修改,查询从O(N)的时间复杂度变为O(logN)。具体怎么做呢?我们下面引入一个问题来说明,假设我们现在需要查询上面数组中一段区间内的数字和是多少,带着这个问题,我们来接着完善这棵树。我们首先将单一结点的值填入进来。
有了这些基础的结点之后,我们就能把整个树的结点信息完善出来,结点中保存的是两个子结点的和。整个线段树最终填充情况如下:
有了这棵完整的线段树,我们现在要求arr[2]-->arr[5]的和是多少,我们只需要从根结点开始遍历,左子树的区间范围[0-2],右子树的区间范围[3-5],他们都包含我们查询的区间,所以我们两个子树都需要遍历。我们先遍历左子树[0-2],它的左子树范围是[0-1],不包含我们的查询区间,这个子树不再遍历,右子树[2]包含在我们的查询范围中,并且他不能再遍历,我们返回5。接着看右子树[3-5],它全部包含在我们的查询范围中,直接返回23,所以我们将它们相加就得到了最终的结果28。这相比较最笨的方法从arr[2]+arr[3]+arr[4]+arr[5]快了许多。
线段树如果需要进行修改也很简单。假设我们现在需要将arr[4]的值从8改为9,我们会从根结点开始,它的左子树的区间为[0-2],右子树的区间为[3-5],所以我们没必要去遍历左子树,只需要遍历右子树[3-5],接着就是一个递归操作,[3-5]这个结点的左子树区间为[3-4],右子树区间为[5],所以我们只需要遍历左子树,递归直到找到[4]结点,将线段树中的相应的结点从8改为9。然后我们还需要更新线段树的结点的值,也就是需要将我们刚刚遍历路径上的结点用更改后的左右子树重新求和(其他路径结点不需要更新)。
这个操作的时间复杂度也是O(logN)。我们在之前堆排序的博客中讲过数组表示堆的方法,因为堆是一个完全二叉树,所以它的父结点和子结点有如下关系:
假设数组下标为i,那么有
parent = (i - 1) / 2
c1 = 2*i +1
c2 = 2*i +2
但线段树不一定是一个完全二叉树,我们上面的例子就不是一个完全二叉树,但我们也有方法。我们可以将线段树填充成一个完全二叉树。
我们用绿色的数字表示数组的下标,这样我们就将一个线段树填充成了一个完全二叉树,这个二叉树的数组形式如下,并且数组下标也满足我们上面列出的关系式。
有了上面这些基础我们就可以编写代码了,我们先来看下二叉树的构建。
#include <stdio.h>
#define MAX_LEN 1000
/*node: 线段树中要建立的结点
* start: 数组的起始点
* end: 数组的终点
*/
void build_tree(int arr[], int tree[], int node, int start, int end) {
if (start == end) {
tree[node] = arr[start];//递归出口
} else {
int mid = (start + end) / 2;//找到分隔区间
int left_node = 2 * node + 1;//根据完全二叉树关系找到左右子结点
int right_node = 2 * node + 2;
build_tree(arr, tree, left_node, start, mid);//线段树的建立就是一个递归过程
build_tree(arr, tree, right_node, mid + 1, end);
tree[node] = tree[left_node] + tree[right_node];//父结点存储的是两个子结点的和
}
}
int main() {
int arr[] = {1,4,5,6,9,9};
int size = 6;
int tree[MAX_LEN] = {0};
build_tree(arr, tree, 0, 0, size - 1);
//完全二叉树的结点个数为2^n - 1
for (int i = 0; i < 15; i++) {
printf("%d\n",tree[i]);
}
printf("\n");
return 0;
}
我们最终打印的结果是和我们上面的结果是完全一致的。
下面我们来看下修改操作,我们将arr[] ={1,4,5,6,9,9}最后一个元素修改为10。所以新的线段树如下所示:
线段树的数组形式如下:
#include <stdio.h>
#define MAX_LEN 1000
/*node: 线段树中要建立的结点
* start: 数组的起始点
* end: 数组的终点
* index: 需要修改的数组下标
* val: 需要修改的新的值
*/
void update_tree(int arr[], int tree[], int node, int start, int end, int index, int val) {
if (start == end) {
arr[index] = val;//仔细看我们上面列出来的修改操作的示意图,当start==end的时候就证明找到了我们需要修改的点
tree[node] = val;//同时需要更新线段树的这个点的值
} else {
int mid = (start + end) / 2;//找到分隔区间
int left_node = 2 * node + 1;//根据完全二叉树关系找到左右子结点
int right_node = 2 * node + 2;
if (index >= start && index <= mid) {//判断index是在左子树还是在右子树
update_tree(arr, tree, left_node, start, mid, index, val);
} else {
update_tree(arr, tree, right_node, mid + 1, end, index, val);
}
tree[node] = tree[left_node] + tree[right_node];//父结点存储的是两个子结点的和
}
}
/*node: 线段树中要建立的结点
* start: 数组的起始点
* end: 数组的终点
*/
void build_tree(int arr[], int tree[], int node, int start, int end) {
if (start == end) {
tree[node] = arr[start];//递归出口
} else {
int mid = (start + end) / 2;//找到分隔区间
int left_node = 2 * node + 1;//根据完全二叉树关系找到左右子结点
int right_node = 2 * node + 2;
build_tree(arr, tree, left_node, start, mid);//线段树的建立就是一个递归过程
build_tree(arr, tree, right_node, mid + 1, end);
tree[node] = tree[left_node] + tree[right_node];//父结点存储的是两个子结点的和
}
}
int main() {
int arr[] = {1,4,5,6,9,9};
int size = 6;
int tree[MAX_LEN] = {0};
build_tree(arr, tree, 0, 0, size - 1);
update_tree(arr, tree, 0, 0, size- 1, 5, 10);
//完全二叉树的结点个数为2^n - 1
for (int i = 0; i < 15; i++) {
printf("%d\n",tree[i]);
}
printf("\n");
return 0;
}
我们可以看到,修改之后的线段树和我们上面列出来的结果相同。
我们最后讲解一下查询操作的代码,查询操作和上面几个操作类似,都是先求出左右两个子树的区间范围,哪个子树的区间范围在我们的查询范围内,我们就继续遍历这个子树,直到找到符合条件的结点返回。
#include <stdio.h>
#define MAX_LEN 1000
int query_tree(int arr[], int tree[], int node, int start, int end, int query_left, int query_right) {
if (query_right < start || query_left > end) {
return 0;
} else if (start == end) {
return tree[node];
} else if (query_left <= start && end <= query_right) {
return tree[node];
} else {
int mid = (start + end) / 2;//找到分隔区间
int left_node = 2 * node + 1;//根据完全二叉树关系找到左右子结点
int right_node = 2 * node + 2;
int sum_left = query_tree(arr, tree, left_node, start, mid, query_left, query_right);
int sum_right = query_tree(arr, tree, right_node, mid + 1, end, query_left, query_right);
return sum_left + sum_right;
}
}
/*node: 线段树中要建立的结点
* start: 数组的起始点
* end: 数组的终点
*/
void build_tree(int arr[], int tree[], int node, int start, int end) {
if (start == end) {
tree[node] = arr[start];//递归出口
} else {
int mid = (start + end) / 2;//找到分隔区间
int left_node = 2 * node + 1;//根据完全二叉树关系找到左右子结点
int right_node = 2 * node + 2;
build_tree(arr, tree, left_node, start, mid);//线段树的建立就是一个递归过程
build_tree(arr, tree, right_node, mid + 1, end);
tree[node] = tree[left_node] + tree[right_node];//父结点存储的是两个子结点的和
}
}
int main() {
int arr[] = {1,4,5,6,9,9};
int size = 6;
int tree[MAX_LEN] = {0};
build_tree(arr, tree, 0, 0, size - 1);
int ret = query_tree(arr, tree, 0, 0, size - 1, 2, 4);
printf("querry result:%d\n",ret);
return 0;
}
我们这边查询的是arr[2]--arr[4]的和,我们通过打印可以得知结果为20.
至此线段树的基本操作就讲解完成了,大家可以试试如果要查询区间arr[2]--arr[4]中的最大值或最小值该如何修改代码,试着动手敲遍代码加深下对算法的理解。