这道题我当初在赛场上卡了快二十分钟,核心原因就是被名字里的 Search 带偏了节奏。Dora and Search 是某场 Codeforces Div.2 的 C 题,典型的思维题,数据范围 n 到 2e5,题意一句话就能讲完:给你一个 1 到 n 的排列,找一个区间 [l, r],要求两端点 p[l] 和 p[r] 既不是这个区间的最小值,也不是这个区间的最大值;找不到就输出 -1。听起来像是一道区间搜索题,第一反应是二分、滑动窗口、线段树维护极值,实际上这题的正解是一个极其干净的双指针:从左右两端不断向中间收缩,同时维护当前区间数字的最小值和最大值,凡是端点等于极值就剥掉,最终剩下的区间就是答案。
先说清楚这道题适合谁看。如果你是刷 Codeforces 的选手,或者准备 ICPC、蓝桥杯这类算法竞赛,这题是一个很好的“怎么从题目名字和样例里提取正确思路”的训练。如果你只是对算法感兴趣、想练思维题,这个解法本身也就二十来行,理解起来并不难。这篇文章我打算从题目拆解、双指针正确性、完整代码、踩坑记录几个方面展开,最后再聊一个和“Search”有关但不在算法赛场上的真实事故:docker search 返回 500 的排查过程。两件事听起来八竿子打不着,但核心思路其实是相通的:先搞清楚边界条件和版本状态,再动手。
1. 题目拆解:为什么名字里有 Search,却不是在考搜索
1.1 一句话题意与数据范围
给定一个长度为 n 的排列 p,也就是 1 到 n 这 n 个数字各出现一次。你需要找到任意一个下标区间 [l, r],满足:
- 1 ≤ l < r ≤ n
- p[l] 不是区间 [l, r] 内的最小值,也不是最大值
- p[r] 不是区间 [l, r] 内的最小值,也不是最大值
如果存在,输出任意一个满足条件的 l 和 r;否则输出 -1。题目给定 n 最大到 2e5,因此 O(n²) 的枚举区间方案是不可能通过全部数据的。
很多第一次读题的人会把“Search”理解成“搜索一个区间”,于是尝试二分答案,或者用滑动窗口去维护极值。但仔细一分析就会发现,合法区间并不具备单调性:区间变长,可能从不合法变成合法,也可能从合法变成不合法。你想用一个固定长度的滑动窗口去扫,根本不知道应该开多大。
1.2 被名字误导的两个第一直觉
先说暴力枚举,这个最直接。n 等于 2e5 的时候,区间数量是 n(n-1)/2,接近 2e10,完全不可行。就算用 ST 表 O(1) 查询区间极值,枚举区间的数量也摆在那里,依旧会超时。
然后是二分长度。二分的思想是:如果长度为 mid 的区间存在合法解,就继续尝试更大的长度。但问题是这个单调性根本不存在。举个简单的反例:排列 [3, 1, 4, 2],长度 4 的整个区间 [1,4] 是合法的,因为左端点 3 既不是最小值 1 也不是最大值 4,右端点 2 同样安全;但它内部的长度 3 区间没有一个合法。反过来也存在长度更短的合法而更长不合法的情况。所以二分长度这条路走不通。
这两个直觉失效之后,就逼着我们去想:能不能不从枚举区间出发,而是从“端点为什么合法”这个条件去反推?这才是这道题真正的突破口。
1.3 几个小样例带来的关键信息
拿 n=1 和 n=2 来说,根本不可能有合法区间。n=1 时一个数字既是自己的最小值又是最大值;n=2 时区间 [1,2] 里两个数字,一个是最小值一个是最大值,两个端点通通不合格。
n=3 我们再展开看。排列 [2, 1, 3],唯一的整段区间 [1,3] 里,右端点 3 是整个区间的最大值,不合格;长度为 2 的 [1,2] 里左端点 2 是最大值、右端点 1 是最小值,同样不合格;[2,3] 里左端点 1 是最小值、右端点 3 是最大值,也不合格。所以 n=3 的所有排列其实都无解。这个观察告诉我们:合法区间至少需要长度 4,因为区间中间必须同时存在比两个端点都大和都小的数,才能让端点“安全”。
n=4 开始有解了,比如排列 [3, 1, 4, 2],整个区间 [1,4] 就是答案。为什么会这样?因为全局最小值 1 和全局最大值 4 都藏在了区间内部,两个端点恰好躲开了极值的位置。顺着这个思路再往下想:如果一个端点正好站在了全局极值上,那任何包含它的区间里,它都是极值,这个端点永远不可能合法。所以我们要做的,就是把站在极值位置上的端点一个个排除掉。
2. 双指针解法:从两端剥掉“不配当端点”的元素
2.1 核心观察:端点一旦是当前极值,就永远没救
把区间想象成一个不断收缩的“筛选窗口”。窗口的左端是 l,右端是 r。假设 p[l] 恰好等于当前窗口里的最小值,比如最小值是 1,而 p[l] 正好是 1。那么无论 r 怎么向左移动,只要左端点还是 l,由 l 和任意 r'(r' ≤ r)构成的区间,p[l] 始终是那个区间里的最小值。因为更小的数不存在,p[l] 已经是整个窗口内的最小值了。所以说这个左端点已经“废了”,只要它在,任何候选区间都不可能合法。唯一的处理方式就是把 l 向右移动一位,放弃这个端点。
右端点同理。如果 p[r] 是当前窗口内的最大值,那只要右端点还是 r,任何以 r 为右端点的区间里它都是最大值,必须把 r 向左移动一位。这就是双指针收缩的动机:端点一旦命中极值,立刻剥掉。
2.2 维护 mn 和 mx:用排列的唯一性做约束
收缩的过程中,窗口内的数字集合不是一成不变的。我们把当前窗口里的最小值和最大值分别记为 mn 和 mx。初始时窗口是整个排列,所以 mn=1,mx=n,因为排列里一定有 1 和 n。
现在看状态怎么更新。如果 p[l]==mn,也就是左端点恰好等于当前窗口的最小值,那么把 l 加一之后,数字 mn 已经被排除在窗口之外。因为排列中每个数字只出现一次,剩下的未排除数字里,最小值一定是 mn+1。所以直接 mn++。如果 p[l]==mx,那删除的是当前最大值,剩下未排除数字里最大值一定是 mx-1,所以 mx--。右边端点同理。
这一步是很多人写错的地方。有人贪图省事,只在初始时判断端点是不是 1 或 n,之后就不更新了。举个例子:排列 [1, 3, 4, 2],如果只判断全局极值,会先排除左侧的 1,然后看到 p[2]=3 和 p[4]=2 都不是全局 1 或 n,就贸然输出 [2,4],但这个区间里实际最小值是 2,刚好坐在右端点 p[4] 上,答案非法。所以 mn 和 mx 必须是动态维护的当前窗口极值,不能偷懒。
2.3 正确性证明:为什么停下时区间一定合法
双指针算法拿出来的答案必须经得起推敲,否则赛场上写出来没有底气。我们用一个循环不变量来证明:
循环中任意时刻,当前区间 [l, r] 里包含的数字,恰好就是还没有被删除的数字集合 S。因为删除操作永远只发生在左端或右端,所以还未被删除的位置永远是一段连续区间 [l, r],区间里的数字就是 S。mn 和 mx 分别就是 S 的最小值和最大值。
既然 mn 和 mx 就是当前区间的实际最小值和最大值,那么如果 p[l] 既不是 mn 也不是 mx,p[l] 就一定不是当前区间的最小值或最大值。p[r] 同理。区间内部只要还存在这两个极值,它们的位置就不会出现在端点,于是两个端点都满足“不是极值”的要求,算法可以直接输出 [l, r]。
假设算法收缩时漏掉了一个本来合法的区间 [L, R]。那么算法一定在某一轮越过了 L 或 R。比如左指针从 L 移动到了 L+1,说明当时 p[L] 是当前窗口的极值。而 [L, R] 是当前窗口的子区间,p[L] 同样会是 [L, R] 的极值,这与 [L, R] 合法矛盾。右边同理。所以算法不可能漏掉合法解。
2.4 复杂度分析与极限情况
整个过程里,l 最多向右移动 n 次,r 最多向左移动 n 次,mn 和 mx 也最多各自变化 n 次。每次循环都是常数时间操作,总复杂度 O(n),空间复杂度 O(n) 用来存排列。对于 n=2e5 的数据规模,这已经是最优的线性复杂度了。如果题目有多组测试数据,只要总 n 之和有限,复杂度同样线性。
极限情况也要想清楚。如果循环结束的时候 l>=r,说明窗口被压缩到长度不足 2,甚至空掉了,这时候没有合法区间,输出 -1。这里要注意,不能把 l==r 的情况当成合法区间输出,因为单个元素既是自己的最小值又是自己的最大值,端点必然不合格。
3. 完整代码与调试实战
3.1 可直接提交的 C++ 实现
下面这段代码是标准的双指针写法,数组下标从 1 开始,直接使用 vector 存储。
#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; while (T--) { int n; cin >> n; vector<int> p(n + 1); for (int i = 1; i <= n; ++i) { cin >> p[i]; } int l = 1, r = n; int mn = 1, mx = n; while (l < r) { if (p[l] == mn || p[l] == mx) { if (p[l] == mn) ++mn; if (p[l] == mx) --mx; ++l; } else if (p[r] == mn || p[r] == mx) { if (p[r] == mn) ++mn; if (p[r] == mx) --mx; --r; } else { break; } } if (l < r) { cout << l << ' ' << r << '\n'; } else { cout << -1 << '\n'; } } return 0; }这段代码里最值得注意的地方是:当 p[l] 等于 mn 或 mx 时,用了两个独立的 if 而不是 if-else if。虽然在排列中一个数字不可能同时等于 mn 和 mx,但写成两个 if 在逻辑上更抗造,以后改代码不容易留下隐患。
3.2 循环细节:先动左边还是先动右边
有读者会问:while 循环里先检查左端点还是先检查右端点,会影响答案吗?答案是不影响正确性,但可能影响输出的具体区间。因为题目要求输出任意合法区间,所以无论先后顺序,最终找到的都是合法解。
举个对称的例子。排列 [1, 5, 2, 4, 3],初始窗口 [1,5],p[1]=1 是全局最小值,左端点必须排除;如果先检查左边,l 一路走到 2,然后 p[2]=5 是最大值,继续排除;如果先检查右边,p[5]=3 和 p[4]=4 都不是极值,会先保持右端不动,然后处理左边的 1。两条路径最后都会收敛到同一个合法窗口或者 -1,只是中间过程不同。不信可以用暴力枚举去交叉验证,跑一百万个随机排列,两个版本结果一定一致。
3.3 暴力对拍:自己写一个验答案器
写思维题最怕“觉得对但不敢提交”。我的习惯是随手写一个暴力验证程序,用随机数据对拍。暴力逻辑很简单:枚举所有区间,找区间里的最小值和最大值,判断两个端点是否安全。把暴力结果和双指针算法的结果对比。
bool valid(const vector<int>& p, int l, int r) { int mn = *min_element(p.begin() + l, p.begin() + r + 1); int mx = *max_element(p.begin() + l, p.begin() + r + 1); return p[l] != mn && p[l] != mx && p[r] != mn && p[r] != mx; } pair<int, int> brute(const vector<int>& p) { int n = (int)p.size() - 1; for (int l = 1; l <= n; ++l) { for (int r = l + 1; r <= n; ++r) { if (valid(p, l, r)) { return {l, r}; } } } return {-1, -1}; }然后在主程序里生成随机排列,分别跑两个函数并比较结果。双指针返回的是合法区间时,还要再用 valid 函数验证一次。这个方法可以筛掉几乎所有逻辑错误,尤其是对 mn/mx 更新顺序的把握。实测下来,我写这题第一次就漏了右端点正好是 mn 的情况,就是对拍发现的。
3.4 容易踩的 5 个坑
第一个坑是数组下标从 0 开始。题目的 l 和 r 要求输出 1-based 下标,如果内部用 0-based 存储,输出时要记得 +1。最省事的办法是直接开 n+1 大小的数组,下标 1 到 n。
第二个坑是循环终止条件写成 l <= r。这样会把长度为 1 的窗口当成候选答案,但单个数字一定不合法。应该写 l < r,最后判断 l < r 再输出。
第三个坑是没有动态更新 mn 和 mx。前面说过,只判断全局极值会导致漏判或误判。只要窗口缩小,mn 和 mx 必须跟着变。
第四个坑是试图用 set 或线段树去维护当前窗口的极值。这样做复杂度要多一个 log,而且代码量翻倍,最重要的是容易在迭代器的边界处理上写错。双指针本身就是线性,没必要自找麻烦。
第五个坑是不验证就提交。思维题一旦思路写歪,代码再短也白搭。赛场上至少要想清楚证明,平时训练则建议养成对拍的习惯。
4. 从题目里的 Search 到真实世界的搜索报错
题目叫 Dora and Search,这里面有个“Search”。算法题里 Search 往往和二分、BFS、DFS 扯上关系,但现实中我们更常遇到的 Search 是各种搜索接口的调用。上周我帮同事排查一个问题,执行 docker search redis 的时候,直接返回了一长串报错:
“request returned 500 internal server error for api route and version ... check if the server supports the requested api version”。
当时第一反应是 Docker 版本问题。于是用 docker version 查看 Client 和 Server 的 API 版本,发现 Client 的 API 版本比 Server 高了一大截。这是因为 Docker Desktop 升级之后,CLI 默认协商使用最新 API 版本,而当前连接到的 daemon 还停留在旧版本,两边握手失败,daemon 只能回一个 500。排查路径其实和这道题很类似:先检查边界条件,也就是当前会话的 API 状态;再检查版本匹配,相当于算法里维护 mn 和 mx;最后再决定是升级 daemon 还是让 CLI 降级兼容。对症之后,问题很快解决。
另一个常见的 Search 问题是自托管搜索引擎 Meilisearch 部署在宝塔面板上,明明容器端口映射了,前端却提示 search host 丢失。这种多半是反向代理配置里的 Host 头没带过去,或者面板防火墙没有放行对应端口。排查手法同样是先确认“当前可用范围”:curl 直接打本机端口测试容器本身是否正常,再逐层检查 Nginx 配置。和双指针收缩一样,每排除一层,就接近真相一步。
最后再说一个和这道题有关的延伸。极值排除法并不局限于“端点不是 min/max”这一个问法。只要题目要求“找到一个区间,端点满足某种不等于极端值的性质”,都可以先想想能不能用双指针从两边剥除。比如以后遇到“端点不是区间第 k 大或第 k 小”的变式,思路可以往这个方向靠。另外,初学 AC 自动机时有一道经典题 Keywords Search,名字里也有 Search,但它考的是多模式串匹配。所以搜索、查找这类词在不同上下文里代表完全不同的解法,关键还是要回到数据范围和问题结构本身去判断。
说说我自己的体会。这道题我最初写完双指针之后,心里其实没底,总觉得这么简单的收缩逻辑会不会漏掉藏在中间的答案。后来用暴力程序跑了一万多组随机数据,又手动构造了几十个极端排列,确认无误才敢提交。从那以后,我遇到“端点必须满足某种性质”的区间问题,第一反应不再是无脑线段树,而是先想能不能线性剥除。这个习惯帮我解决了不少看似复杂、实则简单的题目。最后再分享一个小技巧:赛场上如果时间紧张,可以在输出前用 isValid 函数验证一下答案合法性,这样即使逻辑写岔了,提交前也能救回来。