面试竟然问线段树,互联网行业已经这么内卷了吗?

341 阅读5分钟

大家好,我是洪爵,今天想给大家讲一个算法:线段树

线段树是啥,它能干什么呢?

我们先来看下这样一个场景:现在有一个长度为5的数组a。

在这里插入图片描述

现在对它有如下几个操作:一个是求区间和,即给一个下标的范围[left, right]然后求数组a[left] ~ a[right]的和,我们把这个操作叫做query在这里插入图片描述

第二个是对数组a某个位置进行数值更新,比如a[1] = 3,现在把a[1]更新为5,我们把这个操作称为update

在这里插入图片描述

我们可以先来看下常规操作的时间复杂度和空间复杂度是多少,query是求区间[left, right]和,时间复杂度是o(n),然后我们只需要使用一个变量去存储这个答案,所以空间复杂度是o(1)

update是通过下标直接更新,所以它的时间复杂度o(1)

总结一下,这种做法query的时间复杂度是o(n),update的时间复杂度是o(1),我们有没有更好的方法呢?

我们来看一下前缀和解法是否适用于这种场景,我们新建一个数组b,长度和a数组一样,b[0] = a[0],b[1] = b[0] + a[1],b[2] = b[1] + a[2]……b[n] = b[n - 1] + a[n]。当我们进行query操作的时候,比如求区间[left, right],那么我们只需要使用b[right] - b[left - 1](left等于0的时候需要处理下,避免数组越界)就能够得到query的答案,所以时间复杂度为o(1)

在这里插入图片描述

但如果要进行update操作,比如更新了a[3]的值,b[3] ~ b[n]的值都需要进行更新,所以它的时间复杂度是o(n)

在这里插入图片描述

使用前缀和算法的话,我们做一个总结,query的操作是o(1),update的操作是o(n),然后因为开了一个长度为n的数组,所以它的空间复杂度为o(n)。

所以不管是比较常规的做法或者是使用前缀和,update和query总有一个的时间复杂度是o(n),那么有没有时间复杂度更低的解决方法呢?我们可不可以把时间复杂度降到**o(logn)**呢?

我们可以先来看下o(n)和o(logn)的曲线:

在这里插入图片描述

o(n)曲线

在这里插入图片描述

o(logn)曲线

从图中我们可以看出o(n)的时间随着n增大是呈线性增长的,o(logn)时间复杂度随着n增大,增加的趋势不明显,并且随着n越来越大,o(logn)体现的效率会越来越明显,所以我们能得出:如果能把时间复杂度优化到o(logn),将会大大提高运行的效率。

线段树是一个queryupdate的**时间复杂度都为o(logn)**的数据结构。它是怎么实现的呢?我们还是看这样一个原数组。

在这里插入图片描述

我们把这个数组构建成一棵树,但是这棵树和平常的二叉树不同,它的每一个节点保存的信息不是数值,而是left,right的区间范围,还有保存区间值,可能这样说还是比较抽象,我们来看下每个节点是长啥样的:

struct Node {
    int left;
    int right;
    int sum;
};

因为使用的是线段树,我们除了queryupdate操作,还需要添加一个建树的操作,我们称为build,这个build的操作只需要在一开始的时候初始化一次就ok,后面就不需要在build了。

那么问题来了,这棵树要怎么建才能使得时间复杂度为o(logn)呢,我们可以采用二分的思想,先来看下示意图:

在这里插入图片描述

再来看下代码的实现:

//调用build(0, 4, 0);
void build(int left, int right, int root) {
    p[root].left = left;
    p[root].right = right;
    if(left == right) {
        p[root].sum = num[left];
        return;
    }
    int mid = (right+left) / 2;
    build(left, mid, root * 2);
    build(mid + 1, right, root * 2 + 1);
    p[root].sum = p[root * 2].sum + p[root * 2 + 1].sum;
}

这样一颗线段树就构建好了,如果说我们要查询[1,1]这个区间的和,只需要遍历下面这几个节点就可以:

在这里插入图片描述

如果说我们要更新a[1]也一样,也是上面这几个节点需要更新,这样就能保证不论是query还是update操作都是**o(logn)**的时间复杂度,我们来看下代码实现吧。

友情提示:

root * 2 = root << 1
root * 2 + 1 = root << 1 | 1
#include <bits/stdc++.h>
#define MAXN 50005
using namespace std;
struct Node{
    int left;
    int right;
    int sum;
}p[MAXN * 4]; // 为什么是4倍呢, 大家也可以思考下
int num[MAXN];
void Pushup(int root) {
    p[root].sum = p[root << 1].sum + p[root << 1 | 1].sum;
}
// 建树操作
void build(int left, int right, int root) {
    p[root].left = left;
    p[root].right = right;
    if(left == right) {
        p[root].sum = num[left];
        return;
    }
    int mid = (right + left) >> 1;
    build(left, mid, root << 1);
    build(mid + 1, right, root << 1 | 1);
    Pushup(root);
}
// 更新a[i]为a[i] + j
void Update(int root, int i, int j) {
    p[root].sum += j;
    if(p[root].left == i && p[root].right == i) {
        return;
    }
    int mid = (p[root].left + p[root].right) >> 1;
    if(i <= mid) {
        Update(root << 1, i, j);
    }else{
        Update(root << 1 | 1, i, j);
    }
}
// 查询区间[left, right]的和
int Query(int root, int left, int right) {
    if(p[root].left == left && p[root].right == right) {
        return p[root].sum;
    }
    int mid = (p[root].right + p[root].left) >> 1;
    if(right <= mid) {
        return Query(root << 1, left, right);
    }else if(mid < left){
        return Query(root << 1 | 1, left, right);
    }else{
        return Query(root << 1, left, mid) + Query(root << 1| 1, mid + 1, right);
    }
}

这里给到一个例题:acm.hdu.edu.cn/showproblem…

思考:

大家也许会很疑惑**为什么要开4倍呢?**在做题的时候,你可能试过开2倍或者3倍,但是都不能过题,但是4倍的话就可以,为啥呢?

我们可以思考下,二叉树和线段树的一个区别,线段树采用二分去分区间,而二叉树是存储每个节点的值,当遇到不能完全平分的时候,就会出现叶子节点不在同一层的情况,而第n层的节点数是1~n-1层节点数的两倍(近似),所以正常是2n - 1个节点,但是需要乘以2,所以大约是4n个节点。

好的。线段树就说到这里啦,希望本篇文章对你有用!

在这里插入图片描述

愿每个人都能带着怀疑的态度去阅读文章并探究其中原理。

道阻且长,往事作序,来日为章。

期待我们下一次相遇!