【算法笔记】【专题】RMQ 问题:ST表/树状数组/线段树

news/2024/10/4 3:27:09

0. 前言

好久没更算法笔记专栏了,正好学了新算法来更新……
这也是本专栏的第一个专题问题,涉及到三种数据结构,如果写得有问题请各位大佬多多指教,谢谢!

1. 关于 RMQ 问题

RMQ 的全称是 Range Minimum/Maximum Query,即区间最大/最小值问题。

本文中,我们的算法以求最大值为例(最小值基本一致)。题面如下:

给定一个长为\(N\)的序列\(A=(A_1,A_2,\dots,A_N)\)
\(Q\)个询问,第\(i\)个询问如下:
\(\to~\)给定\(1\le l_i\le r_i\le N\),求区间\([l_i,r_i]\)的最大值,即\(\displaystyle\max_{j=l_i}^{r_i} A_j\)

下面,我们将从暴力算法开始,逐步讲解 RMQ 问题的常用解法。

通用的 RMQ 问题(除暴力外所有算法都能通过):

  • 洛谷 P1816 忠诚(标准的RMQ,没有任何变动,全篇通用例题)
  • 洛谷 P2880 [USACO07JAN] Balanced Lineup G
  • 洛谷 P2251 质量检测
  • 洛谷 P8818 [CSP-S 2022] 策略游戏(有一定思维难度)

2. 解法

2.1 暴力法

我们先读取序列\(A\),再逐个读取询问,对于每个询问直接遍历\(A_l\dots A_r\),最终输出结果。
总时间复杂度为\(\mathcal O(\sum r_i-l_i)=\mathcal O(NQ)\)

#include <cstdio>
#define maxn 100005
using namespace std;int a[maxn];int main()
{int n, q;scanf("%d%d", &n, &q);for(int i=0; i<n; i++)scanf("%d", a + i);while(q--){int l, r;scanf("%d%d", &l, &r);l --, r --;int res = a[l];for(int i=l+1; i<=r; i++)if(a[i] < res)res = a[i];printf("%d ", res);}return 0;
}

然而,当你提交到洛谷 P1816时……

TLE一个点

肯定还是时间复杂度的锅,算法需要进一步优化

2.2 Sparse Table

Sparse Table(以下称ST表)是用于静态求解 RMQ 问题的数据结构。

静态求解是指在原始序列不改变的情况下求解问题。或者说,ST表不能直接进行修改操作

ST表的初始化时间复杂度为\(\mathcal O(N\log N)\),单次查询时间复杂度为\(\mathcal O(1)\)

2.2.1 存储结构

ST表的本质是一个\(N\times\lceil\log N\rceil\)的二维数组,其定义如下(令\(A\)表示原数组,求最小值同理):

\[st[i][j]=\max\{A_i,A_{i+1},\dots,A_{i+2^j-1}\} \]

也就是说,\(st[i][j]\)表示从\(A_i\)开始,\(2^j\)个元素中的最大值。这运用了倍增的思想。

下面考虑如何快速初始化整个数组。

2.2.2 初始化

根据倍增的常用算法,使用类似于DP的方式填满整个表:

\[st[i][j]=\max\{st[i][j-1],st[i+2^{j-1}][j-1]\} \]

填表时,先枚举\(j\),再枚举\(i\)。由于整个表共有大约\(N\log N\)个状态,而计算一个状态值的时间复杂度为\(\mathcal O(1)\),所以初始化的总时间复杂度为\(\mathcal O(N\log N)\)

伪代码如下:

function init() {for i = 1 to Nst[i][0] = A[i]for j = 1 to log2(N)for i = 1 to (N + 1 - 2^j)st[i][j] = max(st[i][j - 1], st[i + 2^(j-1)][j - 1])
}

C++ 实现:

void init()
{for(int i=0; i<n; i++)st[i][0] = A[i];for(int j=1; j<=log2(n); j++)for(int i=0; i+(1<<j)<=n; i++) // 注意必须是<=n,1<<j即为2^jst[i][j] = max(st[i][j - 1], st[i + (1 << j - 1)][j - 1]);
}

2.2.3 查询

对于\([l,r)\)区间的 RMQ 查询,根据ST表的原理,我们要找到两个区间\([l,a)\)\([b,r)\),使得它们的并集正好为\([l,r)\)。即:\(l\le b\le a\le r\)

为什么是并集?
\(\to~\)因为\(\max(a,a)=a\),所以重复的不影响结果。
\(\to~\)如果出现遗漏,且遗漏的正好为最大/最小值,那会影响最终结果,所以不能遗漏。
\(\to~\)如果检查到了多余的元素,且多余的正好大于原区间的最大值,会使查询结果变大,所以不能有多余。
综上,必须满足\([l,a)\)\([b,r)\)并集正好为\([l,r)\)才能查询。

要满足上述条件,我们可以\(a\)尽可能靠近\(r\),让\(b\)尽可能靠近\(l\),来达到这样的效果。
此时,我们还需要满足并集的条件\(l\le a,b\le r\),因此我们需要找到最大的\(k\),使得\(a=l+2^k\)\(b=r-2^k\)

则有

\[\left\{ \begin{array}{c} l+2^k\le r \\ r-2^k\ge l \end{array} \right.~~\to~~ 2^k\le r-l\\ ~\\ \to~k\le \log_2(r-l) \]

又因为\(k\)必须是整数,所以取\(k=\lfloor\log_2(r-l)\rfloor\)即可。

// query(l, r) = max(A[l], ..., A[r - 1])
inline int query(int l, int r)
{int k = log2(r - l);return max(st[l][k], st[r - (1 << k)][k]);
}

2.2.4 完整实现

下面给出用Sparse Table解决例题的完整代码。总时间复杂度为\(\mathcal O(Q+N\log N)\)

#include <cstdio>
#include <cmath>
#include <algorithm>
#define maxn 100005
using namespace std;int st[maxn][17]; // 2^17=131072void init(int n)
{for(int j=1, t=log2(n); j<=t; j++)for(int i=0; i+(1<<j)<=n; i++)st[i][j] = min(st[i][j - 1], st[i + (1 << j - 1)][j - 1]);
}inline int query(int l, int r)
{int k = log2(r - l);return min(st[l][k], st[r - (1 << k)][k]); // 注意此题为min,不是求max
}int main()
{int n, q;scanf("%d%d", &n, &q);for(int i=0; i<n; i++)scanf("%d", st[i]); // 直接读入到ST表中,节约时间和空间init(n);while(q--){int l, r;scanf("%d%d", &l, &r);// 此处注意因为是左闭右开区间[l,r),所以只有l需要-1printf("%d ", query(--l, r));}return 0;
}
AC

运行时间:\(128\mathrm{ms}\)
使用内存:\(6.90\mathrm{MB}\)

2.3 树状数组

关于树状数组的原理我已经在这篇文章中讲过,这里不再多说了。下面我们考虑如何应用树状数组解决 RMQ 问题。

2.3.1 原算法

树状数组可以用lowbit操作实现prefixSum(前缀和)以及update(更新)操作,时间复杂度均为\(\mathcal O(N\log N)\)。不仅是加法,对于任意满足结合律的运算这两种操作都有效。

我们来简单实现一下支持prefixMaxupdate操作的树状数组:

#define INF 2147483647
#define lowbit(x) ((x) & -(x))
inline void setmax(int& x, int y) { if(y > x) x = y; }int n, A[N], bit[N];// max(A[1], ..., A[i])
inline int prefixMax(int i)
{int res = -INF;for(; i>0; i-=lowbit(i))setmax(res, bit[i]);return res;
}// A[i] = max(A[i], val)
inline void update(int i, int val)
{for(; i<=n; i+=lowbit(i))setmax(bit[i], val);
}

若要初始化树状数组,可以利用update操作进行\(\mathcal O(N\log N)\)的初始化:

inline void init()
{for(int i=1; i<=n; i++)bit[i] = -INF; // 这一段不要忘!for(int i=1; i<=n; i++)update(i, A[i]);
}

另外,我们也可以用子节点直接更新父节点,达到\(\mathcal O(N)\)建树的效果:

inline void init()
{for(int i=1; i<=n; i++)bit[i] = A[i];for(int i=1; i<=n; i++){int j = i + lowbit(i);if(j <= n) setmax(bit[j], bit[i]);}
}

考虑加法时我们计算rangeSum(区间和)的算法:

inline int rangeSum(int l, int r)
{return prefixSum(r) - prefixSum(l - 1);
}

也就是用\((A_1+A_2+\dots+A_r)-(A_1+A_2+\dots+A_{l-1})=A_l+\dots+A_r\)

现在回过来考虑 RMQ 的查询,Min/Max运算不可逆,所以很明显不能用这种计算方式。
下面我们来介绍针对 RMQ 的树状数组设计。

2.3.2 RMQ 树状数组

我们令\(f(l,r)\)表示rangeMax(l, r),即\([l,r]\)的区间最大值。
\(t=r-\mathrm{lowbit}(r)\)\(A\)表示原序列,\(B\)表示树状数组,考虑如下递推式:

\[f(l,r)=\begin{cases} \max\{B_r,f(l,t)\} & (t\ge l)\\ \max\{A_r,f(l,r-1)\} & (t<l)\\ -\infty & (l < r) \end{cases} \]

等式\(1\) 证明
根据树状数组的定义,\(B_i=\max\{A_{i-\mathrm{lowbit}(i)+1},\dots,A_i\}=f(i-\mathrm{lowbit}(i)+1,i)\)
又有\(f(l,r)=\max\{f(l,t),f(t+1,r)\}\),由于\(f(t+1,r)=f(r-\mathrm{lowbit}(r)+1,r)=B_r\),所以当\(t\ge l\)时,\(f(l,r)=\max\{B_r,f(l,t)\}\)

等式\(2\) 证明
根据\(\max\)操作的结合律可得:\(f(l,r)=\max\{f(l,r-1),f(r,r)\}=\max\{f(l,r-1),A_r\}\)
这个等式对于任意\(r\)都成立,但出于时间考虑,我们尽可能使用等式\(1\)(如果全用等式\(2\)就退化成了\(\mathcal O(N)\)的暴力)。

代码实现:

int rangeMax(int l, int r)
{if(l == r) return A[l];int t = r - lowbit(r);return t < l?max(A[r], rangeMax(l, r - 1)):max(bit[r], rangeMax(l, t));
}

这种查询方式的时间复杂度不好估算,可粗略地记为\(\mathcal O(\log N)\)。实际情况下,运行时间可能稍大于这个值。
另外,此算法对任意满足结合律的运算(如gcdlcm)都有效。

2.3.3 完整实现

下面给出用树状数组解决例题的完整代码。总时间复杂度为\(\mathcal O(N+Q\log N)\)[1]

#include <cstdio>
#include <algorithm>
#define maxn 100005
using namespace std;#define lowbit(x) ((x) & -(x))int a[maxn], bit[maxn];int rangeMin(int l, int r)
{if(l == r) return a[l];int t = r - lowbit(r);return t < l?min(a[r], rangeMin(l, r - 1)):min(bit[r], rangeMin(l, t));
}inline void init(int n)
{for(int i=1; i<=n; i++)bit[i] = a[i];for(int i=1; i<=n; i++){int j = i + lowbit(i);if(j <= n)bit[j] = min(bit[j], bit[i]);}
}int main()
{int n, q;scanf("%d%d", &n, &q);for(int i=1; i<=n; i++)scanf("%d", a + i);init(n);while(q--){int l, r;scanf("%d%d", &l, &r);printf("%d ", rangeMin(l, r));}return 0;
}
AC

运行时间:\(207\mathrm{ms}\)
使用内存:\(1.10\mathrm{MB}\)

另外,我们还可以把rangeMin写成非递归的形式,以进一步节省运行时间:

#include <cstdio>
#define maxn 100005
using namespace std;#define INF 2147483647
#define lowbit(x) ((x) & -(x))
inline void setmin(int& x, int y) { if(y < x) x = y; }int a[maxn], bit[maxn];int rangeMin(int l, int r)
{int res = INF;while(l <= r){int t = r - lowbit(r);if(t < l) setmin(res, a[r--]);else setmin(res, bit[r]), r = t;}return res;
}inline void init(int n)
{for(int i=1; i<=n; i++)bit[i] = a[i];for(int i=1; i<=n; i++){int j = i + lowbit(i);if(j <= n) setmin(bit[j], bit[i]);}
}int main()
{int n, q;scanf("%d%d", &n, &q);for(int i=1; i<=n; i++)scanf("%d", a + i);init(n);while(q--){int l, r;scanf("%d%d", &l, &r);printf("%d ", rangeMin(l, r));}return 0;
}
AC

运行时间:\(135\mathrm{ms}\)
使用内存:\(1.14\mathrm{MB}\)

2.4 线段树

线段树和树状数组一样,都是解决区间问题的树状结构。不过线段树的应用范围更加广泛,时间复杂度与树状数组基本一致,但每种操作都有一个\(3\thicksim4\)之间的常数。线段树建树(初始化)的时间复杂度为\(\mathcal O(N)\),单次区间查询的时间复杂度为\(\mathcal O(\log N)\)

RMQ 问题不涉及修改操作,因此我们暂时不考虑这种操作。

线段树

如图即为\(N=10\)的一棵线段树。可以发现,线段树本质上是一棵二叉树,每个结点代表一个区间,其存储的值为这个区间的区间和(在 RMQ 问题中为区间最大/最小值)。一个结点的左儿子结点和右儿子结点对应区间的并集正好为这个结点对应的区间(叶子结点除外),且左右两区间的长度的差值不超过\(1\)

从树的角度考虑,\(n_1=0\),即没有子结点数量为\(1\)的结点。

一般的,若一个结点对应的区间为\([l,r]\)

  • \(l=r\):此结点为叶子结点。
  • \(l<r\),令\(m=\lfloor\frac{l+r}2\rfloor\)
    • 左子结点对应的区间为\([l,m]\)
    • 右子结点对应的区间为\([m+1,r]\)

顺便纠正一下线段树的几个误区:
线段树是一棵完全二叉树。
请仔细看看图。
线段树是一棵二叉搜索树。
反正我是没找到哪里这样定义的。百度百科 OI Wiki
线段树上同一深度的结点所对应的区间长度一定相等。
看看图,\([1,3]\)\([4,5]\)两个区间,长度明显不相等。
特例:当\(N\)\(2\)的整数次幂时,这句话一定成立。

2.4.1 存储结构

线段树采用堆式储存法,根结点为\(1\),结点\(u\)的父亲为\(\lfloor\frac u2\rfloor\),左子结点为\(2u\),右子结点为\(2u+1\)

可以用位运算优化:

  • \(u\)的父亲:\(\lfloor\frac u2\rfloor=~\)u >> 1
  • \(u\)的左儿子:\(2u=~\)u << 1
  • \(u\)的右儿子:\(2u+1=~\)u << 1 | 1(或u << 1 ^ 1
// 数组定义
int a[N], c[4 * N];// 宏定义
#define INF 2147483647
#define ls(x) (x << 1) // 左儿子结点
#define rs(x) (x << 1 | 1) // 右儿子结点
#define par(x) (x >> 1) // 父亲结点

下文中,我们令\(N\)表示元素个数,\(A\)表示原数组,\(C\)表示线段树(数组形式存储)。
可以证明,线段树的结点个数不会超过\(4N-5\),所以我们可以把\(C\)的长度设为\(4N\)

2.4.2 建树(初始化)

对于一个结点数据的计算,我们可以先递归地初始化其左子树,再到右子树,最后两儿子的数据取\(\max\)即可。

代码实现:

// 结点p, 区间[l, r]建树
void build(int l, int r, int p)
{if(l == r){c[p] = a[l];return;}int m = l + r >> 1;build(l, m, ls(p));build(m + 1, r, rs(p));c[p] = max(c[ls(p)], c[rs(p)]);
}

2.4.3 区间查询

同样采用递归的方式,设\(p\)为当前结点,\([l,r]\)为当前结点区间,\([a,b]\)为当前查询区间,函数返回\([l,r]\)\([a,b]\)交集区间和

  • 如果\([l,r]\)\([a,b]\)的子集(\(a\le l\le r\le b\)),直接返回当前结点对应的区间和。
  • 否则,递归查询左右子树,如果没有交集则不查询。返回查询的子树的最大值。

如果上面不是很好理解,可以直接看代码实现:

// 结点p, 查询区间[a, b], 当前区间[l, r]
int query(int l, int r, int a, int b, int p)
{if(a <= l && r <= b) return c[p];int m = l + r >> 1, res = -INF;if(m >= a) res = max(res, query(l, m, a, b, ls(p)));// m + 1 <= b 即为 m < bif(m < b) res = max(res, query(m + 1, r, a, b, rs(p)));return res;
}

2.4.4 完整实现

下面给出用线段树解决例题的完整代码。总时间复杂度为\(\mathcal O(N+Q\log N)\)

#include <cstdio>
#include <algorithm>
#define maxn 100005
using namespace std;#define INF 2147483647
#define ls(x) (x << 1)
#define rs(x) (x << 1 | 1)
#define par(x) (x >> 1)int a[maxn], c[maxn << 2];void build(int l, int r, int p)
{if(l == r){c[p] = a[l];return;}int m = l + r >> 1;build(l, m, ls(p));build(m + 1, r, rs(p));c[p] = min(c[ls(p)], c[rs(p)]);
}int query(int l, int r, int a, int b, int p)
{if(a <= l && r <= b) return c[p];int m = l + r >> 1, res = INF;if(m >= a) res = min(res, query(l, m, a, b, ls(p)));if(m < b) res = min(res, query(m + 1, r, a, b, rs(p)));return res;
}int main()
{int n, q;scanf("%d%d", &n, &q);for(int i=0; i<n; i++)scanf("%d", a + i);build(0, n - 1, 1);while(q--){int l, r;scanf("%d%d", &l, &r);printf("%d ", query(0, n - 1, --l, --r, 1));}return 0;
}
AC

运行时间:\(163\mathrm{ms}\)
使用内存:\(1.78\mathrm{MB}\)

3. 总结

我们来对比一下四种算法,从理论的角度:

算法 预处理时间复杂度 单次查询时间复杂度 空间复杂度 符合题目要求?[2]
暴力 \(\mathcal O(1)\) \(\mathcal O(N)\) \(\mathcal O(N)\)
ST表 \(\mathcal O(N\log N)\) \(\mathcal O(1)\) \(\mathcal O(N\log N)\) ✔️
树状数组 \(\mathcal O(N)\)\(\mathcal O(N\log N)\) \(\mathcal O(\log N)\) \(\mathcal O(N)\) ✔️
线段树 \(\mathcal O(N)\) \(\mathcal O(\log N)\) \(\mathcal O(N)\) ✔️

从洛谷 P1816上实际运行情况的角度(暴力TLE,不考虑):

算法 运行时间 内存占用 代码长度
ST表 \(128\mathrm{ms}\) \(6.90\mathrm{MB}\) \(625\mathrm B\)
树状数组(递归)[3] \(207\mathrm{ms}\) \(1.10\mathrm{MB}\) \(720\mathrm B\)
树状数组(非递归)[3:1] \(135\mathrm{ms}\) \(1.14\mathrm{MB}\) \(786\mathrm B\)
线段树 \(163\mathrm{ms}\) \(1.78\mathrm{MB}\) \(905\mathrm B\)

可以看出,ST表写起来简单省事,运行时间也是最快,不过内存占用稍高,毕竟空间复杂度是\(\mathcal O(N\log N)\)
树状数组可以说是均衡了时间与空间,尽量使用非递归查询,比递归速度快\(53\%\),当然如果想省事也可以使用递归式查询;
线段树可以说是完全输给了树状数组(非递归),不过线段树功能比较多,用来做 RMQ 可以说是大材小用。所以线段树在 RMQ 问题中没什么优势,有一些缺点还是可以理解的。


本文到此结束,希望大家给个三连!
这也是我在\(2023\)年写的第一篇文章(也是我的第一篇万字长文),祝大家新年快乐!


  1. 直接调用update建树的方法总时间复杂度为\(\mathcal O((N+Q)\log N)\),这里采用的是前面说的\(\mathcal O(N)\)快速建树。在此问题中,由于不需要修改,使用\(\mathcal O(N)\)建树可省去update方法。 ↩︎

  2. 以洛谷 P1816为准,\(N\le 10^5\),所以单次查询时间复杂度不能超过\(\mathcal O(\sqrt N)\)。 ↩︎

  3. 此处指递归/非递归的单次查询实现,两者其他操作完全一致。 ↩︎ ↩︎

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.ryyt.cn/news/58129.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈,一经查实,立即删除!

相关文章

js的对象

1.js有var开辟数组使用var a=[1,2,3,4,5,6]或者var a=new Array(1,1,1,1,1); 2.Array支持push添加操作,和indexof 查找,foreach遍历操作pop删除操作

【算法笔记】树形DP算法总结详解

0. 定义 树形DP,又称树状DP,即在树上进行的DP,是DP(动态规划)算法中较为复杂的一种。 1. 基础 令\(f[u]=~\)与树上顶点\(u\)有关的某些数据,并按照拓扑序(从叶子节点向上到根节点的顺序)进行\(\text{DP}\),确保在更新一个顶点时其子节点的dp值已经被更新好,以更新当前…

Seay安装和初步使用

作者网站现在已经无法访问:http://www.cnseay.com/2951/, 可以使用这个GitHub - f1tz/cnseay: Seay源代码审计系统 下载完安装包之后,解压到自己想要的电脑路径即可,无需进行任何额外的配置 利用工具对sql注入进行分析 进入软件之后,点击新建项目,选择需要分析的文件(这里…

代码整洁之道--读书笔记(5)

代码整洁之道简介: 本书是编程大师“Bob 大叔”40余年编程生涯的心得体会的总结,讲解要成为真正专业的程序员需要具备什么样的态度,需要遵循什么样的原则,需要采取什么样的行动。作者以自己以及身边的同事走过的弯路、犯过的错误为例,意在为后来者引路,助其职业生涯迈上更…

02网络参考模型

02网络参考模型02网络参考模型常见网络模型因为 OSI协议栈比较复杂 ,且TCP和IP两大协议在业界被广泛使用,所以 TCP/IP参考模型 成为了互联网的主流参考模型。OIS网络模型层级作用7.应用层 应用层 对应用程序提供接口。6.表示层进行数据格式的转换,以确保一个系统生成的应用层…

《痞子衡嵌入式半月刊》 第 107 期

痞子衡嵌入式半月刊: 第 107 期这里分享嵌入式领域有用有趣的项目/工具以及一些热点新闻,农历年分二十四节气,希望在每个交节之日准时发布一期。 本期刊是开源项目(GitHub: JayHeng/pzh-mcu-bi-weekly),欢迎提交 issue,投稿或推荐你知道的嵌入式那些事儿。 上期回顾 :《…

鼠标悬停显示的轮播图

今日整理,发现这种轮播图是最难实现的一种, 1.再循环中难以控制单一品类商品显示 解决办法: 在外面的主类里面添加&:hover触发标签属性的更改,这样可以单一作用 2.在循环中触发事件,所有的同一事件都会触发 解决办法:先建立模版控制排版,再从单一内容开始微调 <script s…

如何把网页的公式优雅地拷贝到word中:数学公式识别神器—Mathpix Snip

这个编辑器其实在把chatgpt的公式粘贴到word中时就已经使用了,用的是网页版。 现在下载了软件(但是好像一个月试用期过后得收费?但是就目前来说,体验感真的超级好) 把公式复制粘贴转成mathtype公式 可以截取电脑屏幕上的图像,如果图像上面有公式的话,就会识别,之后可以…