客户端基本不用的算法:线段树实战要点

640?wx_fmt=png

在上一公众号中

空间退化问题

《LeetCode-307 区域和检索 - 数组可修改》[1] 中,我们会遇到下标索引超出范围的 9/10 的 case。这也就是我们遇到的第一个最直观的坑。

640?wx_fmt=png

上文我们说过,线段树是一棵完美二叉树(Perfect Binary Tree),可是题目中给出的结点个数不一定是 2 的 N 次幂个。所以,这就带来了空间结构退化的问题

这里我们假设 N = 13 这个情况,然后我们通过之前的线段树代码进行代码实现后其结构变成了这样:

640?wx_fmt=png

通过上图,我们发现如果我们使用 2N = 26 的数组空间,实际上线段树已经覆盖了下标 31 ,这个场景下我们开 2N 的数组是不够的。

这里直接说结论:我们对线段树的描述数组开 4N 的空间,是绝对够用的。 具体的证明后续文章中单独写。

在谈 RMQ 问题

在第一篇文章中,我们讲了区间和的场景,将最重要的向上更新操作 Push Up 也做了介绍,并且给大家留了一道思考题:如何使用线段树来实现 RMQ 问题。

其实我们只需要修改两个地方:

  1. 在向上更新的时候,重新制定规则 - 父结点是两个子节点的大值;

  2. 在查询的时候,将结果取递归搜索的大值;

代码如下:

// 吸取上面的教训,现在我们数组开 4 倍
int tree[maxn << 2];

void push_up(int rt) {
    // 父结点是子节点中的最大值
    tree[rt] = max(tree[rt << 1], tree[rt << 1 | 1]);
}

int query(int L, int R, int l, int r, int rt) {
    if (L <= l && r <= R) {
        return tree[rt];
    }
    int m = (l + r) >> 1;
    int ret = 0;
    // 修改成递归查找区间最大值当做查询结果
    if (L <= m) ret = max(ret, query(L, R, l, m, rt << 1));
    if (R > m)  ret = max(ret, query(L, R, m + 1, r, rt << 1 | 1));
    return ret;
}

其实 RMQ 线段树并没有对线段树结构有任何改变,仅仅是修改了父子结点间的运算规则。

这样我们对于线段树的理解又加深了一层,因为“线段”并不仅仅代表着一段的加和,延伸来看,其实对某一块区间有着一定结果的运算规则,就可以使用线段树结构来优化查询和更新效率。

求解逆序数

这是一个很典型的统计场景,具体的题目是 《LeetCode-315 计算右侧小于当前元素的个数》[2],其实就是在大学时《线性代数》课程中的求逆序数。由于在课程题目中,每个数都是有规律的,所以我们根据通项公式或者递推公式就可以求解。但是这道题目是给我们一个任意的数组,不存在数列所具有的特殊性,所以我们只能通过统计的效率来解决这个问题。

逆序数的简单介绍:假设我们有这样一组数 [2, 4, 3, 5, 1],它有 5 个逆序对,分别是 (2, 1)(4, 3)(4, 1)(3, 1)(5, 1),我们要求的答案是,以每个数为首位逆序数的个数,例如上述这组需要输出 [1, 2, 1, 1, 0]

这种题我们应该如何考虑呢?我们换一个角度考虑,假设我们有一个以正整数范围的空线段树,仍旧是区间和线段树,这棵树的每一个叶子结点记录的是当前下标数字出现的次数。

我们需要的操作仍旧是 单点更新区间查询。顺着以下思路来考虑问题:

  1. 构建一个 [1, MAXN] 范围的线段树,所有结点全部填 0。MAXN 代表数组中数字的最大值;

  2. 反向遍历传入的数组;

  3. 遍历到 x 的时候,对线段树做一次 query(1, x - 1) 操作,来记录有多少个比 x 小的数已经出现;

  4. 对线段树执行一次 update(x, num + 1) 操作,让线段树更新 x 的计数,做加 1 操作;

  5. 重复 3-4 操作,每次的 query 结果就是最后数组中对应的每一个值。

如上面的动画所示,Nums 的箭头代表遍历情况,Result 数组代表最终的返回结果,右边的操作记录是对线段树的操作 Log。

离散化

在上面求解逆序对的题目中,其求解的情况是不完整的。因为我们将数组中的数字全部映射到了数组的下标中,但是数组中的每个数字的取值范围是 [-2^31, 2^31] ,如果出现负数和零那就无法完成映射了(因为线段树的下标都是正数)。在这种情况下我们要如何解决这个问题呢?

这里给出这两点思考方向:

  1. 虽然每个数字的取值范围是 [-2^31, 2^31],但是给定数组的长度不会超过 50000

  2. 逆序对只是比较了两个数的大小关系,而不用在意具体的数字是多少

这两点是不是可以理解成,如果我们将这 N 个数,根据大小关系,映射到 [1, 50000] 这个范围内就可以解决问题了?

例如我们有这么一组数 [-1, -5, 0, 12, 8],我们先升序排序处理一下 [-5, -1, 0, 8, 12] ,然后做一个 Hash 来映射到整数范围 {-5: 1, -1: 2, 0: 3, 8: 4, 12: 5}。通过 Hash 我们的数组变成了 [2, 1, 3, 5, 4] 。如此我们就可以继续使用上面的思路来求解了。

是不是这个思路非常巧妙~ 其实这种区间问题只涉及到大小关系的时候,都可以通过这个方法进行数字映射,从而投影到我们可求解的区域内。这种思路就叫做离散化。所谓的离散化官方的定义就是:把无限空间中有限的个体映射到有限的空间中去,以此提高算法的时空效率。当我们对这类题目求解的时候,由于我们缩小了求解范围,从而算法的时间常数和空间复杂度都会有所降低,所以离散化也是最容易想到的优化点之一。

希望大家学习了逆序场景以及离散化的优化思路,可以自行 AC 这道题。另外,《LeetCode-493 翻转对》这个题目也可以通过这两个思路来尝试解决一下。

优美的 notonlysuccess 写法

notonlysuccess 是一个 ACM-ICPC 的前辈(网络号 id),因为他的线段树代码十分清晰且优雅,所以他的代码经常作为各个参赛选手的模板代码(虽然线段树最后大家都能徒手写出来)。具体何为优雅,下面放上 notonlysuccess 的线段数区间和的代码(其实在我公众号上,所有的代码风格都是在模仿 notonlysuccessRespect !!):

#include <cstdio>

// 优雅点 1:参数宏定义
#define lson l , m , rt << 1
#define rson m + 1 , r , rt << 1 | 1

const int maxn = 55555;
int sum[maxn << 2];

// 优雅点 2:PushUp 抽离
void PushUP(int rt) {
    sum[rt] = sum[rt << 1] + sum[rt << 1 | 1];
}

void build(int l, int r, int rt) {
    if (l == r) {
        scanf("%d", &sum[rt]);
        return ;
    }
    // 优雅点 3:能用位运算就用位运算
    int m = (l + r) >> 1;
    build(lson);
    build(rson);
    PushUP(rt);
}

void update(int p, int add, int l, int r, int rt) {
    if (l == r) {
        sum[rt] += add;
        return ;
    }
    int m = (l + r) >> 1;
    if (p <= m)
        update(p , add , lson);
    else 
        update(p , add , rson);
    PushUP(rt);
}

int query(int L, int R, int l, int r, int rt) {
    if (L <= l && r <= R) {
        return sum[rt];
    }
    int m = (l + r) >> 1;
    int ret = 0;
    if (L <= m) ret += query(L , R , lson);
    if (R > m)  ret += query(L , R , rson);
    return ret;
}

总结

这篇文章讲述了在题目实战中使用线段树的一些技巧。包括:

  1. 数组上限大小;

  2. RMQ 线段树实现方式;

  3. 逆序数使用线段树的求解思路;

  4. 离散化的优化方法;

  5. notonlysuccess 版优雅线段树模板;

学习了这篇文章,你可以求解以下 LeetCode 题目:

  1. LeetCode-307 区域和检索 - 数组可修改

  2. LeetCode-315 计算右侧小于当前元素的个数

  3. LeetCode-493 翻转对[3]

最后你会发现 Hard 题有好多套路!不是难,而是你没有掌握的知识!?

参考资料

[1]

《LeetCode-307 区域和检索 - 数组可修改》: https://leetcode-cn.com/classic/problems/range-sum-query-mutable/description/

[2]

《LeetCode-315 计算右侧小于当前元素的个数》: https://leetcode-cn.com/classic/problems/count-of-smaller-numbers-after-self/

[3]

LeetCode-493 翻转对: https://leetcode-cn.com/classic/problems/reverse-pairs/description/


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值