【解题报告】NOIP 2001 提高组解题报告


前言

笔者一直在想抽出一点时间来好好地写一写 NOIP 早年的题目。然而一来因为一直没有时间,二来对早年的大搜索,dp 和大模拟实在是充满恐惧,就没有敢动笔写。

然而,随着笔者一直在一点点成长,OI 水平也在慢慢地进步,这些起初看起来很让人抓狂的模拟题,也不再是那么的不顺手了。

尽管早年的题目与当今 NOIP 的题目偏重不尽相同,但是我觉得无论是以前的,还是现在的,做 NOIP 套题的感觉,和琢磨高效算法的快感是不变的— —这不正是我们热爱 OI 的原因吗?

希望我们不忘初心吧,以后还能一如既往地热爱 OI,热爱这种充满思考的生活。

整体难度分析

毕竟是早年的 NOIP 题目,总体来讲并不难。

早期的 NOIP 对算法的要求并不高,但对代码能力和思维的要求较高,题目类型偏重于大模拟、搜索和 dp。即使是在数据结构和高级算法盛行的今天,我们也要时常做一做这些题目,确保自己的码力。毕竟,算法知识是思维上的问题,想要落实在实践中,就必须以代码作为载体。这也是为什么,笔者的第一篇解题报告,从 NOIP2001 TG 写起。

空有思维却不能落于实地,那再完美的思路,也只是空中楼阁罢了;实践能力更强的人,走得才能更久。

题解

T1 一元三次方程求解

题意分析

求三次方程 \(ax^3+bx^2+cx+d=0\) 的三个实根。

题目思路

非常裸的一道二分答案题。

题目保证存在三个实数根,也就保证了应该有三个单调区间,就有了两个极值点。

定义函数 \(f(x)=ax^3+bx^2+cx+d\),我们可以求出其导数为 \(f^\prime(x)={3ax}^2+2bx+c\),那么原函数的两个极值点分别为导函数的顶点,用求根公式求出极值点,即 \(x_{1,2}=\dfrac{-2b\pm\sqrt{(2b)^2-4\times3ac}}{2\times 3a}=\dfrac{-b\pm\sqrt{b^2-3ac}}{3a}\)

求出两个极值点后,将区间 \([-100,100]\) 分割为三个单调小区间 \([-100,x_1]\)\([x_1,x_2]\)\([x_2,100]\),分别对这三个区间进行二分答案,就可以求出所有的零点。时间复杂度 \(\mathcal{O}(\)能过\()\) QwQ。

注意精度,而且系数要用 double 存储。

代码

#include 
#include 
using namespace std;
const double eps = 1e-5;

double a, b, c, d;
struct Part{  //定义区间结构体 
	double left, right;  //左右端点 
	int deriv;  //区间单调性 
} A, B, C;

inline double calc(double x) {
	return a * pow(x, 3) + b * pow(x, 2) + c * x + d;
}

void Binary_Search(Part p) {  //二分答案求出某个区间中的零点 
	double mid;
	
	while(abs(p.right - p.left) >= eps) 
		mid = (p.right + p.left) / 2, (calc(mid) * p.deriv > 0) ? (p.right = mid) : (p.left = mid);
		
	printf("%.2f ", mid);
}

int main(void) {
	freopen("solve.in", "r", stdin);
	freopen("solve.out", "w", stdout);
	
	scanf("%lf %lf %lf %lf", &a, &b, &c, &d);
	
	A.left = -100;
	A.right = B.left = (- b - sqrt(b * b - 3 * a * c)) / (3 * a);  //导数法求出三次函数的极值点 
	B.right = C.left = (- b + sqrt(b * b - 3 * a * c)) / (3 * a);
	C.right = 100;
	
	A.deriv = calc(A.left) > calc(A.right) ? -1 : 1; Binary_Search(A);  //二分求出三个单调区间的零点 
	B.deriv = calc(B.left) > calc(B.right) ? -1 : 1; Binary_Search(B);
	C.deriv = calc(C.left) > calc(C.right) ? -1 : 1; Binary_Search(C);
	
	return 0;
}

T2 数的划分

题意分析

求出将 \(n\) 能表示成多少种 \(k\)正整数的和的形式,如果两种表示形式中,加数只是顺序不同,则算作同一种形式。

一种能过的解法

这道题当然是一道 dp 题,我们定义 \(dp(i,j)\) 代表将 \(i\) 分成 \(j\) 个正整数和的方案数。

更加形象地,我们可以认为 \(dp(i,j)\) 代表把 \(i\) 个球放入 \(j\) 个盒子里,且每个盒子都不能为空的放法。那么,我们可以先往所有的盒子里先放一个球,剩下了 \(i-j\) 个球,因为所有的盒子都不是空的,就可以随便放了,那么有 \(dp(i-j, 1)+dp(i-j, 2)+\cdots+dp(i-j, j)\) 中放法。

所以状态转移方程为:

\[dp(i, j)=\sum_{k=1}^j{dp(i-j, k)} \]

然后暴力转移就可以,时间复杂度为 \(\Theta(n^2k)\)

优化算法

这道题中 \(n\)\(k\) 都特别小,所以 \(\Theta(n^2k)\) 是可以完成的,如果将 \(n\)\(k\) 都放大亿些,例如 \(n\le 2\times10^5\)\(k\le 600\)(传送门),这个时候,\(\Theta(n^2k)\) 就会被卡爆时了 QwQ。

我们发现,在 dp 的过程中,我们产生了大量的重复转移:如果我们可以通过某一个确定的值,直接求出 \(dp(i,j)\),那就可以将时间复杂度优化到 \(\Theta(nk)\),这就是很大的一个优化了!

当然,这是可以做到的:考虑 \(dp(i-1,j-1)\) 的转移方程,有

\[\begin{aligned} dp(i-1,j-1) &= \sum_{k=1}^{j-1}{dp((i-1)-(j-1),k)}\\ &= \sum_{k=1}^{j-1}{dp(i-j,k)}\\ &= \sum_{k=1}^j{dp(i-j,k)}-dp(i-j,j)\\ &= dp(i,j)-dp(i-j,j) \end{aligned} \]

移项,得 \(dp(i,j)=dp(i-j,j)+dp(i-1,j-1)\),这个时候,我们就可以 \(\Theta(1)\) 转移每个状态,整个时间复杂度为 \(\Theta(nk)\),进行了优化。

同时,我们发现 \(dp(i,j)\) 只与 \(dp(i-j,j)\)\(dp(i-1,j-1)\) 有关,于是可以压掉 \(j\) 一维,将空间复杂度压到 \(\Theta(n)\)但是我没有 QwQ。

代码

#include 
#define reg register
using namespace std;
const int maxN=201;

int n, k;
int dp[201][7] = {{1}};  //定义 dp[i][j] 为数 i 划分为 j 份的方案数 
int main(void){
	freopen("divide.in", "r", stdin);
	freopen("divide.out", "w", stdout);
	
	scanf("%d%d", &n, &k);
	
	for(reg int j(1); j <= k; ++j)
		for(reg int i(j); i <= n; ++i)
				dp[i][j] = dp[i - j][j] + dp[i - 1][j - 1];  //状态转移 
	
	printf("%d", dp[n][k]);
	return 0;
}
//by CaO

总结

这道题的思路很简单,但非常引人深思。要求出状态转移方程并不难,但是想要进行时间复杂度的优化,就需要在方程上利用数学推导简化计算。

事实上,我们学习这么多的算法,无论是通过数学的方式,还是通过数据结构优化复杂度,目的归根结底只有一个,就是减少计算过程中的重复搜索

T3 统计单词个数

题意分析

给定一个字符串和一个含有 \(s\) 个词的词典,将这个字符串分成 \(k\) 个部分,并求出这 \(k\) 个部分最多可以包含多少个单词。注意:如果两个单词的首字母在原串中位置相同,那么只能选择一个。

题目思路

依旧是一道 dp 题,我们定义 \(dp(i,j)\) 为将前 \(i\) 个字符串分成 \(j\) 个部分,包含的最多单词数,那么很明显,我们可以枚举所有划分的断点 \(k\),就可以求出 \(dp(k,j-1)+cnt(k+1,i)\),其中 \(cnt(i,j)\) 代表区间 \([i,j]\) 的子串中包含多少词典中的单词。

状态转移方程即为:

\[dp(i,j)=\max\limits_{k\in[1,i-1]} (dp(k,j-1)+cnt(k+1,i)) \]

字符串有 \(p\) 行,一共 \(20p\) 个字符,我们可以 \(\Theta(400p^2)\) 对所有的区间 \([l,r]\) 预处理出 \(cnt(l,r)\),然后再完成 dp。总的时间复杂度为 \(\Theta(400p^2k)\)

注意边界状态。

代码

#include 
#include 
#define reg register
using namespace std;

int p, k, s;
int dp[201][41];  //定义 dp[i][j] 为将前 i 个字符划分为 j 份包含的最大单词数 
int cnt[201][201];  //定义 cnt[i][j] 为区间 [i, j] 中包含的单词数 
//状态转移方程为:dp[i][j] = max(dp[i][l] + dp[l + 1][j]), l = i, i + 1, ..., j 

string str, tmp;
string dict[7];

int found(int l, int r) {  //寻找区间 [l, r] 之间是否有以 str[l] 为起点的单词 
	string sub = str.substr(l, r - l + 1);
	
	for(reg int i(1); i <= s; ++i)
		if(sub.find(dict[i]) == 0)
			return 1;
	
	return 0;
}

int main(void) {
	freopen("count.in", "r", stdin);
	freopen("count.out", "w", stdout); 
	
	cin >> p >> k;
	for(reg int i(1); i <= p; ++i) 
		cin >> tmp, str += tmp;  //拼接字符串 
	
	cin >> s;
	for(reg int i(1); i <= s; ++i)
		cin >> dict[i];
	
	for(reg int j = str.size() - 1; j >= 0; --j)
		for(reg int i(j); i >= 0; --i)
			cnt[i][j] = cnt[i + 1][j] + found(i, j);
	
	for(reg int i(0); str[i]; ++i)
		dp[i][1] = cnt[0][i];
	
	for(reg int i(1); i < k; ++i)
		dp[i][i + 1] = dp[i - 1][i] + cnt[i][i];  //设置边界状态 
	
	for(reg int i(0); str[i]; ++i)
		for(reg int j(1); j <= k && j < i; ++j)
			for(reg int l(j); l < i; ++l)
				dp[i][j] = max(dp[i][j], dp[l][j - 1] + cnt[l + 1][i]);  //状态转移 

	printf("%d", dp[str.size() - 1][k]);
	return 0;
}
//by CaO

T4 Car 的旅行路线

题意分析

在 Decartes 坐标系中存在 \(S\) 个矩形,Car 可以在这些矩形的顶点上移动:

  • 对于第 \(i\) 个矩形,从这个矩形上的一个点移动到另一个点,若距离为 \(d\),那么她就要耗费 \(t_i\cdot d\) 的花费,\(i=1,2,\cdots,S\)

  • 从某个矩形上的一个点移动到另一个矩形上的另一个点,若距离为 \(d\),那么她就要耗费 \(T\cdot d\) 的花费。

Car 可以从矩形 \(A\) 上的任一个点出发,移动到矩形 \(B\) 上的任一个点上,求她需要的最小花费。

题目思路

虽然这是一道蓝题,但是思路却十分简单— —建稠密图跑 Dijkstra:因为任意两个点之间的花费都唯一确定,而且花费的值总是大于 \(0\) 的,我们考虑任意两点之间建一条双向边,双向边的权值就是两点之间的花费。于是我们就可以在图上以 \(A\) 的四个顶点为源点跑 Dijkstra 了。

这里有一些小细节:

考虑到这张图是稠密图,在不加堆优化的情况下,Dijkstra 的复杂度为 \(\mathcal{O}(|V|^2+|E|)\),稠密图下为 \(\mathcal{O}(|V|^2)\);加了堆优化的复杂度为 \(\mathcal{O}((|E|+|V|)\log|V|)\),稠密图下会退化到 \(\mathcal{O}(|V|^2\log|V|)\),反倒不如不加。

单次询问中有 \(S\) 个矩形,就有 \(4S\) 个顶点,时间复杂度为 \(\mathcal{O}(16S^2)\)\(n\) 次询问后的复杂度为 \(\mathcal{O}(16n\overline{S^2})\)。本题中,\(n\le 10\)\(S\le 100\),则 \(16n\overline{S^2}\le 1.6\times10^6\),可以轻松扫过。

每个矩形都只给了三个顶点,怎么判断第四个顶点的坐标?我们可以通过勾股逆定理求出这三个顶点中,构成直角的顶点坐标 \(A(x_1,y_1)\),另外两个点坐标为 \(B(x_2,y_2),C(x_3,y_3)\),那么 \(\vec{AB}+\vec{AC}=\vec{AD}\),即:

\[(x_2-x_1,y_2-y_1)+(x_3-x_1,y_3-y_1)=(x_4-x_1,y_4-y_1) \]

有:

\[\begin{cases} x_2+x_3-2x_1=x_4-x_1,\\ y_2+y_3-2y_1=y_4-y_1. \end{cases} \]

那么第四个点的坐标就是 \((x_2+x_3-x_1,y_2+y_3-y_1)\).

代码

口胡五分钟,代码两小时……这道题的细节还是挺多的,不要漏了。

#include 
#include 
#include 
#include 
#define SQ_OF_DIS(A, B) ((x[A] - x[B]) * (x[A] - x[B])\
                        +(y[A] - y[B]) * (y[A] - y[B]))
#define reg register
using namespace std;
const int maxN = 50001;

int n, s, t, A, B;
int top, head[501];

//状态压缩,对于编号为 i 的节点,i >> 2 代表其所在城市的信息,i & 3 代表机场的编号 
struct Point {
	int x, y;
	double dis;
	bool vis;
} point[501];  //统计机场的信息 

struct Edge {
	int to;
	double w;
	int next;
} edge[maxN];

inline void add_edge(int u, int v, double w) {
	edge[++top].to = v;
	edge[top].w = w;
	edge[top].next = head[u];
	head[u] = top;
}

inline double getDis(int M, int N) {
	return sqrt(pow(point[M].x - point[N].x, 2) + pow(point[M].y - point[N].y, 2));
}  //两点间距离函数 

inline void getOther(int x[], int y[]) {  //给定其中三个点,求出矩形第四个点 
	if(SQ_OF_DIS(0, 1) == SQ_OF_DIS(0, 2) + SQ_OF_DIS(1, 2)) {
		x[3] = x[0] + x[1] - x[2];
		y[3] = y[0] + y[1] - y[2];
	}
	
	else if(SQ_OF_DIS(0, 2) == SQ_OF_DIS(0, 1) + SQ_OF_DIS(2, 1)) {
		x[3] = x[0] + x[2] - x[1];
		y[3] = y[0] + y[2] - y[1];
	}
	
	else if(SQ_OF_DIS(1, 2) == SQ_OF_DIS(1, 0) + SQ_OF_DIS(2, 0)) {
		x[3] = x[1] + x[2] - x[0];
		y[3] = y[1] + y[2] - y[0];
	}
}

int main(void){
	freopen("travel.in", "r", stdin);
	freopen("travel.out", "w", stdout);
	
	scanf("%d", &n);
	
	while(n--) {
		top = 0;  //一定不要忘了清空多测!!! 
		memset(head, 0, sizeof(head));
		memset(edge, 0, sizeof(edge));
		memset(point, 0, sizeof(point));
		
		scanf("%d%d%d%d", &s, &t, &A, &B);
		
		for(reg int i(0); i < (s << 2); ++i)
			point[i].dis = 1e+10;  //初始化 
		
		for(reg int i(0); i < 4; ++i)
			point[(A - 1) << 2 | i].dis = 0;
		
		int cur = (A - 1) << 2;
		for(reg int i(0); i < s; ++i) {
			int x[4], y[4], ti;
			
			for(reg int j(0); j < 3; ++j)
				scanf("%d%d", &x[j], &y[j]);
			scanf("%d", &ti);
			
			getOther(x, y);
			
			for(reg int j(0); j < 4; ++j) {			
				point[(i << 2) | j].x = x[j];
				point[(i << 2) | j].y = y[j];
				
				for(reg int k(j - 1); k >= 0; --k) {
					add_edge((i << 2) | j, (i << 2) | k, ti * getDis((i << 2) | j, (i << 2) | k));
					add_edge((i << 2) | k, (i << 2) | j, ti * getDis((i << 2) | j, (i << 2) | k));
				}
			}
		}
		
		for(reg int i(0); i < (s << 2); ++i)
			for(reg int j(0); j < (s << 2); ++j) {
				if(i >> 2 == j >> 2) continue;
				add_edge(i, j, t * getDis(i, j));
			}
		
		//Dijkstra 算法求出最短路,稠密图中选择不加堆优化 
		while(!point[cur].vis) {
			double mindis = 1e+10;
			point[cur].vis = true;
			
			for(int ptr = head[cur]; ptr; ptr = edge[ptr].next) {
				int curv = edge[ptr].to;
				point[curv].dis = min(point[curv].dis, point[cur].dis + edge[ptr].w);
			}
			
			for(reg int i(0); i < (s << 2); ++i)
				if(!point[i].vis && point[i].dis < mindis)
					cur = i, mindis = point[i].dis;
		}
		
		double ans = point[(B - 1) << 2].dis;
		
		for(reg int i(1); i < 4; ++i)
			ans = min(ans, point[(B - 1) << 2 | i].dis);
		
		printf("%.1lf\n", ans);
	}
	return 0;
}
//by CaO

总结

尽管作为 NOIP2001 TG 的最后一道题,它的思维并不难,但是它的计算几何方面的处理以及建图都是非常锻炼码力的,这也是为什么我一直推荐刷一刷早年的 NOIP 题的原因— —近年 OI 题目的趋势开始向思维和代码难度双提升的方向发展,只有既将算法烂熟于心,又稳步提升代码能力,才能在风云变幻的 OI 圈中屹立不倒。