归档 220927 // 悬线法,永远的神

因为我学习了一年半的单调队列之后才知道世界上还有单调栈这种东西,三观碎裂,所以我对单调栈一直是比较排斥的。

有一段时间看见单调栈就抑郁,所以做题的时候就东贺贺,西贺贺,最终了解到了世界上还有一种很神奇的方法叫悬线法。

你可以从置顶文章中看出我一直很想写悬线法,结果当然是我咕咕咕了(


例题:土豪聪要请客

http://222.180.160.110:1024/contest/2870/problem/4

题意简述:给定一个 \(n\times m\) 的矩阵,其中有一部分地方有障碍。在整个地图上找到周长最大的、不包含障碍的矩形。

输入一个由 .(空地)和 X(障碍)组成的矩阵,输出最大矩形周长减 \(1\)

一些鲜花

看到题过后第一时间想到悬线法,但是中午太困了处于游离状态一直掉线,所以干瞪着电脑屏幕打瞌睡。

于是这篇文章从 220927 被拖到了 230916,哈哈真神奇 现在是 231004 了,我才动笔。


首先预处理出 \(s_{i, j}\),表示从 \((i,\,j)\) 向上,共有多少个连续的 .

for (int i = 1; i <= n; ++i) {
	for (int j = 1; j <= m; ++j)
		s[i][j] = (a[i][j] == '.' ? s[i - 1][j] + 1 : 0);
}

悬线法的名字很形象,拎着一根细线的头,让它自然下垂。

为了方便思考和实现,我们这样想象:一个地图,我们手里拿着一根硬棒朝上举,然后固定我们手只能在一行上运动,用它左右「刷」沿途的矩形。

具象地说,选定一行 \(i\),枚举每一个 \(j\),寻找以第 \(i\) 行为底,包含 \((i,\,j)\),高为 \(s_{i,\,j}\) 的最宽矩形。

也就是从 \((i, j)\) 出发,往左右分别找到最远的一个位置 \(L_j, R_j\),满足 \(s_{i, L_j \sim R_j} \ge s_{i, j}\)。那么悬线法最抽象的部分就讲完了,接下来是最神奇的部分。

在第 \(i\) 行内,从每个 \((i, j)\) 开始找到 \(L_j, R_j\),如果暴力那么明显是个 \(\mathcal O(m^2)\) 的时间。

但是我们考虑这么一件事情。假设 \(L_{i-1}\) 已经求出。

\(k = j-1\)\(L_j\) 初值赋为 \(j\)(左端点至少是自己)。

  1. \(L_j=1\)
    即刻停止算法,因为 \(1\) 是可达的最左位置,不能再往左了。
  2. \(a_k > a_j\)
    \(a_k\) 就像一堵墙,堵住了我们要继续往左刷的硬棒,故不改变 \(L_j\) 并停止算法。
  3. 否则,由于 \(a_{L_k\sim k}\ge a_k\ge a_j\),从 \(j\) 开始往左刷至少都能够到 \(L_k\)。此时我们令 \(k=L_k-1\),回到第一步。

我们就可以求解到正确的 \(L_j\) 的。求解 \(R_j\) 的流程和上述大致相同,不再赘述。


那么是一个非常神奇的事情。悬线法的时间复杂度怎么证明呢?

我们思考。假设 \(a_{j-1}>a_j\),算法会即刻停止;否则,当前定位直接跳到 \(L_{j-1}\) 之前,也就是说,为了求解 \(L_{j-1}\) 而遍历过的位置,求解 \(L_j\) 时都不会再遍历第二遍。

没有值会被遍历第二遍,所以是 \(\mathcal O(m)\) 的。


按照上述流程,算法总体时间复杂度 \(\mathcal O(n\times m)\),和单调栈完全一致。

namespace XSC062 {
using namespace fastIO;
const int maxn = 2e3 + 5;
int n, m, ans;
char a[maxn][maxn];
int s[maxn][maxn], l[maxn][maxn], r[maxn][maxn];
inline int max(int x, int y) {
	return x > y ? x : y;
}
int main() {
	read(n);
	read(m);
	for (int i = 1; i <= n; ++i)
		scanf("%s", a[i] + 1);
	for (int i = 1; i <= n; ++i) {
		for (int j = 1; j <= m; ++j)
			s[i][j] = (a[i][j] == '.' ? s[i - 1][j] + 1 : 0);
	}
	for (int i = 1; i <= n; ++i) {
		for (int j = 1; j <= m; ++j) {
			l[i][j] = j;
			while (l[i][j] > 1 && s[i][j] <= s[i][l[i][j] - 1])
				l[i][j] = l[i][l[i][j] - 1];
		}
	}
	for (int i = 1; i <= n; ++i) {
		for (int j = m; j; --j) {
			r[i][j] = j;
			while (r[i][j] < m && s[i][j] <= s[i][r[i][j] + 1])
				r[i][j] = r[i][r[i][j] + 1];
		}
	}
	for (int i = 1; i <= n; ++i) {
		for (int j = 1; j <= m; ++j) {
			if (!s[i][j])
				continue;
			ans = max(ans, (s[i][j] +
					  (r[i][j] - l[i][j] + 1)) * 2);
		}
	}
	printf("%d", ans - 1);
	return 0;
}
} // namespace XSC062

上述处理 \(s\) 数组的「竖向压缩」技巧是处理矩阵类悬线法题目的常用技巧,这里使用另一道题来举例子。

E. 玉蟾宫 / City Game / 城市游戏

http://222.180.160.110:1024/contest/1655/problem/2

这道题和上一道非常相似,只需改变求答案的式子即可。

namespace XSC062 {
using namespace fastIO;
const int maxn = 1e3 + 5; 
char t;
int n, m, ans;
int s[maxn][maxn];
int l[maxn][maxn], r[maxn][maxn];
int max(int x, int y) {
	return x > y ? x : y;
}
int main() {
	scanf("%d %d", &n, &m);
	for (int i = 1; i <= n; ++i) {
		for (int j = 1; j <= m; ++j) {
			scanf("%1s", &t);
			if (t == 'F')
				s[i][j] = s[i - 1][j] + 1;
			l[i][j] = j;
			while (l[i][j] > 1 && s[i][j]
						<= s[i][l[i][j] - 1])
				l[i][j] = l[i][l[i][j] - 1];
		}
	}
	for (int i = 1; i <= n; ++i) {
		r[i][m + 1] = m + 1;
		for (int j = m; j; --j) {
			r[i][j] = j;
			while (r[i][j] < m && s[i][j]
						 <= s[i][r[i][j] + 1])
				r[i][j] = r[i][r[i][j] + 1];
			ans = max(ans, s[i][j] *
						(r[i][j] - l[i][j] + 1));
		}
	}
	print(ans * 3, '\n');
	return 0;
}
} // namespace XSC062

值得注意的是,悬线法仅指求解最远左右端点的技巧。

同时可以维护过程中的其它信息,例如 情景剧 一题。链接中有详细讲述。相互引用可耻


悬线法的应用场景很广(基本就是在抢单调栈的业务),维护信息的功能也比单调栈更加实用。

就先写这些吧。

posted @ 2023-10-04 20:39  XSC062  阅读(45)  评论(0编辑  收藏  举报