5分钟用一道典例教你学会ST表

写在前面

我将用一道例题由浅入深为你讲解什么是ST表,如何使用ST表,以及使用它有什么优点,本题来自:洛谷P1816 忠诚,当然本题有多种算法,我将先演示暴力破解,并引入ST表。本文如有疏漏或错误,还请您指出并谅解,让我们开始。

让我们先看原题:

简单来说就是:给定数组求某一区间的最小值。

一.暴力解法 (想直接看ST可跳过)

这里我将演示本题的暴力解法,即每次查询,都遍历指定位置寻找最小值,当然这也是最容易想到和比较常见的思路,如果你想直接找ST表的解法,可以跳过这个部分

int Min(int a[], int start, int end)
{
	int min = a[start-1];
	for (int i = start - 1; i <= end - 1; i++)
	{
		if (a[i] < min)min = a[i];
	}
	return min;
}

这个Min函数就是我们的主要实现,它包含了一个int数组,一个起始位置,和一个结束位置(注意,这个位置是从1开始的),我们遍历数组,从起始的那一个数开始,一直到结束的那一个数。

具体思路就不展开讲了,很基础的算法,如果你不理解,其实这个就是不断找比min还小的数,然后赋值给min,这样min就会最小。

让我们直接看全部代码:

#include<iostream>
using namespace std;
#define MAX 100000

int a[MAX] = { 0 };
int start[MAX] = { 0 };
int endd[MAX] = { 0 };

int Min(int a[], int start, int end)
{
	int min = a[start-1];
	for (int i = start - 1; i <= end - 1; i++)
	{
		if (a[i] < min)min = a[i];
	}
	return min;
}

int main()
{
	int m, n;
	cin >> m >> n;


	for (int i = 0; i < m; i++)
	{
		cin >> a[i];
	}


	for (int j = 0; j < n; j++)
	{
		cin >> start[j] >> endd[j];
	}

	for (int j = 0; j < n; j++)printf("%d ",Min(a, start[j], endd[j]));
}

可以看到,我们这个代码的时间复杂度应该与n:也就是有问题的账本数(其实就是要算的最小值的个数),和end-start:就是区间的长度,他们是呈现相乘关系的,即时间复杂度应当为:O(n*(end-start)),近似O(n^2),看起来还能接受吗?有点牵强,不过,这个算法可以有90分

但是,原题中n最大给到10^5,我们假设end-start取平均值5*10^4,平均总计算次数近似为:10*10^4*5*10^4 也就是:5*10^9次,所以有一个测试点(就是最大的那个)会超时。

那么我们想,有没有什么更好的方法:不要每次都遍历一遍?

或者,换句话说,我们能不能将最小值:提前计算好,随取随用?

二.ST表

在上一篇文章,我有简单的介绍了一下DP,也就是动态规划,如果你还不知道什么是DP,我建议你可以去先阅读一下那个文章,因为我们一会儿要讲的可能与DP有些关联。

1.引入

首先,什么是ST表?ST是一种求静态RMQ(Range Min/Max Query:区间最大最小值)问题的方法,何为静态?其实就是说我们的数组不能一直在变动值,填好了就不能动了。

我们现在假设有一个长度为6的数组,其中的元素为1,2,3,4,5,6,我们要求随意给定一个区间,然后查询其中的最小值。

2.基本原理

接下来,我们定义一个二维数组,f(i)(j),其表示的含义为:第i个数开始,长度为2^j的区域内的最小值(也可以是最大值)。什么意思?拿f(1)(0)举例,就是从第一个数:1开始,长度为1(2的0次方)的范围内的最小值,显然这个数就是1本身。同理如图:

但是,这有什么用?我们又不需要用这样复杂的方式去表示一个数本身,别急,我们可以扩大范围,引入f(1)(1),看看会发生什么。

如你所见,f(1)(1)其实就是从1开始,长度为2的范围内的最小值,在这里它其实就是1,因为f(1)(1)=min( f(1)(0) , f(2)(0) ).

还能再长吗?当然可以,我们可以不断扩大j,也就是区间长度,直到其覆盖了整个区间。让我们来继续画图。

如你所见,开始有点不一样了?f(1)(2)作为1为起点,长度为4的区间最小值,它实际上是1为起点,长度为2的区间最小值,和3为起点,长度为2的区间的最小值:他们这两个最小值的最小值。

是不是有点绕,其实很好理解:一个长度为4的区间,由两个长度为2的区间拼成。

同理f(1)(3)就是1开始,长度为8,而我们只需要计算从1开始长度为4,和5开始长度为4这两个区间中,最小值的最小值就可以得到了。当然,这两个区间又可以继续分。

可以看到,每一次的f()(),都可以由另外两个f()()取最小值得到。

由此我们根据规律得到转移方程

                                   f(i)(j) = min( f(i) (j - 1) , f(i + 2^{j-1} ) (j-1) )

一目了然,很好理解,这样,我们就实现了基本理论的学习。 

3.预处理代码

这里我们回归题目,j取1到20,是表示:单个区间最大覆盖2的20次方,也就是1048576,因为原题是100000,2的19次方524288是不足够的。

而,i取1到m,表示的就是单个区间的开始位置,因为我们的数组只有m个数。

在这里我们使用了位运算符号来表示平方,如果你不了解位运算符,1 << j-1,实际上就是1左移j-1位,在二进制中,0001变成0010(左1位)是1乘2的1次方,那么1 << j-1就是1乘2的j-1次方,对应我们上面的公式。

for (int j = 1; j <= 20; j++) //j表示区间的长度最大为2^n
{
	for (int i = 1; i <= m; i++) //i表示区间的开始位置,不能大于m(个账目)
	{
		if (i + (1 << j-1) <= m) //1向左移动j位,表示1*2^j,同时检查第二个最小值的起点是否越界
		{
			ST[i][j] = min(ST[i][j - 1] , ST[i + (1 << j-1)][j - 1]);
		}
	}
}

需要注意!这里我们需要判断i + (1 << j-1) <= m,还记得我们那张图吗?你可以想想为什么有f(6)(0),但是没有f(6)(1)?因为f(6)(1)需要我们知道f(7)(0)的值,而我们没有7,这是越界访问的。

即使你的数组很长,只是使用了一部分数,也不能将未赋值的部分纳入计算,因为你未赋值的地方会默认0,这也会导致我们的计算出错。

大功告成,但是我们还差一步,那就是如何查找?

4.查找代码

不难发现,我们刚刚的区域长度都是2的n次幂,如果用户需要查询一个奇数长度的区域该怎么办?查找一个不是2的n次幂长度的该怎么办?

其实很简单,我们只需要用2个符合我们要求的区间,将这一个区间覆盖即可,而且我们无需担心重复,因为这并不影响,只要完全覆盖就好。

让我们直接看实现:

int Search(int s, int e) //start起点,end终点
{
	int length = e - s + 1; //数组的长度
	int pow = log2(length); //取长度的对数
	return min(ST[s][pow], ST[e - (1 << pow) + 1][pow]);
}

数组的长度很好理解,但是,取对数是什么意思?假设我们需要查询1到20,也就是长为20,log2(20)在C语言中是4,也就是说我们会使用2个长度为2^4,也就是16的数组来覆盖,这样是一定能够完全覆盖,并且区间长度也符合我们的ST表对2的n次幂的要求。

然后,我们直接调用f(start) (pow),f(end - 2^pow + 1) (pow),即可取出这两段区间的最小值,然后,我们再对这两个最小值取最小值,结果就是我们所需要区间的最小值。

如果你不理解,我又又又画了一个图:、

5.全部代码与总结

众神归位,让我们来看全部代码:

#include<iostream>
#include<cmath>

using namespace std;

int ST[100005][22]; //ST表数组
int m, n; 
int start, endd; //开始和结束的位置

int Search(int s, int e)
{
	int length = e - s + 1;
	int pow = log2(length);
	return min(ST[s][pow], ST[e - (1 << pow) + 1][pow]);
}

int main()
{
	cin >> m >> n; //m个账目中有n个有问题

	for (int i = 1; i <= m; i++)
	{
		cin >> ST[i][0];
	}

	for (int j = 1; j <= 20; j++) //j表示区间的长度最大为2^n
	{
		for (int i = 1; i <= m; i++) //i表示区间的开始位置,不能大于m(个账目)
		{
			if (i + (1 << j-1) <= m) //1向左移动j位,表示1*2^j
			{
				ST[i][j] = min(ST[i][j - 1] , ST[i + (1 << j-1)][j - 1]);
			}
		}
	}

	for (int i = 1; i <= n; i++)
	{
		cin >> start >> endd;
		cout << Search(start, endd) <<" ";
	}
	return 0;
}

运行代码,我们终于AC了,分析时间复杂度,是20*m,哎呦,这是本题中的情况,我们的j值为log2(n)+1,m值由用户决定,假设也为n,那么综合时间复杂度是O(nlog2(n)),其中查找的时间复杂度是O(1):效率非常高!

感谢阅读

欢迎关注,收藏,和点赞,谢谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值