1. 题目数据 (Problem Metadata)

2. 题意简述 (Problem Summary)

起点到终点距离为 LL1L1091 \le L \le 10^9),中间有 NN 块岩石,第 ii 块距起点 DiD_i(按升序给出)。至多移走 MM0MN500000 \le M \le N \le 50000)块岩石(不含起点和终点),求移走后最短跳跃距离的最大值

ans=max移走Mmin所有相邻跳跃距离\text{ans} = \max_{\text{移走} \le M \text{块}} \min_{\text{所有相邻跳跃}} \text{距离}

3. 朴素解法 (Brute-Force)

枚举从 NN 块岩石中移走哪 MM 块(或不移),对每种方案计算相邻跳跃距离的最小值,取所有方案中的最大值。

  • 方案数:(NM)\binom{N}{M}N=50000N = 50000 时完全不可行。
  • 20%20\% 数据 N10N \le 10(105)=252\binom{10}{5} = 252,可过。

4. 核心解法 (Main Solution)

特殊性质

本题是经典的「最大化最小值」问题。设 f(x)f(x) 表示「能否通过移走 M\le M 块岩石,使最短跳跃距离 x\ge x」,则 ff 具有 二段性:若 f(x)f(x) 为真,则 x<x\forall x' < xf(x)f(x') 也为真。这是因为同一组移走方案如果保证了最小距离 x\ge x,自然也 x\ge x'。则存在临界值 ans\text{ans},使得 f(x)f(x)x[0,ans]x \in [0, \text{ans}] 恒真、x(ans,L]x \in (\text{ans}, L] 恒假。

关键突破

(NM)\binom{N}{M} 的枚举瓶颈出发,利用二段性(单调性)将「求最优解」转化为「判定某个 xx 是否可行」。判定可以用 贪心O(N)O(N) 内完成:从左到右扫描岩石,遇到与上一次落脚点距离 <x< x 的岩石就移走,否则保留并更新落脚点。这样移走的岩石数即为使最小距离 x\ge x 所需的最少移走数,与 MM 比较即可判定。

推导过程

二分

[0,L][0, L] 上二分答案,找最大的 xx 使 f(x)f(x) 为真,设答案最大值为 ans\text{ans}

mid=l+r2\text{mid} = \left\lceil \frac{l + r}{2} \right\rceil

  • f(mid)f(\text{mid}) 为真 l=mid\Rightarrow l = \text{mid}ansmid\text{ans} \ge \text{mid}
  • f(mid)f(\text{mid}) 为假 r=mid1\Rightarrow r = \text{mid} - 1ans<mid\text{ans} < \text{mid}

贪心

下面是判定函数 f(x)f(x)bool check(int x))的过程,用伪代码描述:

1
2
3
4
5
6
7
8
9
10
last = 0  #(起点)
cnt = 0 #(移走计数)
for i = 1 to N:
if d[i] - last < x:
cnt++ # 移走第 i 块岩石
else:
last = d[i] # 保留,更新落脚点
if L - last < x:
cnt++ # 最后一块保留的岩石离终点太近,移走
return cnt <= M

5. 正确性证明 (Proof of Correctness)

二段性

上文“特殊性质”证明了这个问题具有二段性,所以二分算法正确。

贪心最优性

需证:上述贪心策略移走的岩石数是所有合法方案中的最小值

设贪心保留的岩石位置为 g0=0<g1<g2<<gkg_0 = 0 < g_1 < g_2 < \cdots < g_kg0g_0 为起点),任一合法方案保留的岩石位置为 o0=0<o1<<ojo_0 = 0 < o_1 < \cdots < o_j。合法意味着 oi+1oixo_{i+1} - o_i \ge x

归纳证明 gioig_i \le o_iimin(k,j)\forall i \le \min(k, j)

  • 基础:g0=0=o0g_0 = 0 = o_0(起点必保留)。
  • 归纳:设 gioig_i \le o_i。贪心保留 gi+1g_{i+1}第一个满足 dgi+xd \ge g_i + x 的岩石。而合法方案中 oi+1oi+xgi+xo_{i+1} \ge o_i + x \ge g_i + x,所以 oi+1o_{i+1} 本身就满足 dgi+xd \ge g_i + x,即 oi+1o_{i+1} 是一个候选位置。贪心取最早的候选,故 gi+1oi+1g_{i+1} \le o_{i+1}

gioig_i \le o_i 可知贪心保留的岩石数 kjk \ge j,即贪心移走的岩石数 NkNjN - k \le N - j。贪心移走数 \le 任一合法方案移走数,故为最小。

终点处理:循环结束后若 Lgk<xL - g_k < x,需移走 gkg_k。此时 Lgk1=(Lgk)+(gkgk1)L - g_{k-1} = (L - g_k) + (g_k - g_{k-1}),其中 gkgk1xg_k - g_{k-1} \ge x(贪心性质),故 Lgk1xL - g_{k-1} \ge x,移走 gkg_k 即可,无需连锁移走。这样既能构造出一种合法方案,又保证需要移走的石头最少。

通俗地,核心思想非常简单:贪心算法从不回头。它每次都选第一个满足距离限制的石头落脚,把后面的路留到最长,因此它移走的石头数量自然就是最少的。

6. 复杂度分析 (Complexity)

  • 时间复杂度O(NlogL)O(N \log L)。每次 check\text{check}O(N)O(N),二分 log2(109)=30\lceil \log_2(10^9) \rceil = 30 轮。50000×30=1.5×10650000 \times 30 = 1.5 \times 10^6,1s 内轻松通过。
  • 空间复杂度O(N)O(N),存储岩石位置数组。

7. 实现细节与避坑指南 (Implementation Details)

坑点 说明
LL 的范围 0L1090 \le L \le 10^9int 足够(INT_MAX 2.1×109\approx 2.1 \times 10^9)。二分中 l + r + 1 最大约 2×1092 \times 10^9,不溢出。
M=0M = 0 不能移走任何岩石,答案为相邻跳跃距离的最小值,贪心自然处理。
M=NM = N 可移走所有岩石,答案为 LL(直接从起点跳到终点)。
N=0N = 0 无中间岩石,答案为 LL
二分相关 使用 mid = (l + r + 1) >> 1 配合 l = mid, r = mid - 1 实现向上取整,避免死循环。

Bug 分析

  • Bug 描述:我最早实现的版本的 check 函数采用三趟扫描策略(起点侧 → 终点侧 → 中间贪心),因为当时没想到末端点怎么处理,没有证明出来贪心最优性的“终点处理”。其中第二趟和第三趟内层循环(具体见注释)在标记岩石时执行 flag[i] = 1, res++均未检查 flag[i] 是否已为 1。当一块岩石被多趟扫描覆盖时(例如同时靠近起点和终点,或同时靠近终点和另一块保留的岩石),res 被重复累加,导致 res 虚高,check(x) 误返回 false,最终答案偏小。

  • 官方数据覆盖情况:代码通过了洛谷全部官方测试数据并 AC。两个 bug 的触发条件较为特殊(需要单块岩石同时满足两个距离约束),官方数据未覆盖。

  • Hack 数据

    Hack 1(触发第二趟 bug):

    1
    2
    4 1 1
    2

    正确输出:4
    代码实际输出:2

    追踪:L=4,d1=2L=4, d_1=2check(3)\text{check}(3) 时,第一趟标记 d1=2<3d_1=2 < 3res=1\text{res}=1),第二趟发现 Ld1=2<3L-d_1=2 < 3 再次累加(res=2>M=1\text{res}=2 > M=1),误返回 false。正确结果应为移走唯一岩石,跳跃距离 44

    Hack 2(代码审查发现,触发第三趟 bug):

    1
    2
    3
    10 2 1
    4
    7

    正确输出:4
    代码实际输出:3

    追踪:check(4)\text{check}(4) 时,第二趟标记 d2=7d_2=7L7=3<4L-7=3 < 4res=1\text{res}=1),第三趟中 d1=4d_1=4 未被标记,内层循环发现 d2d1=3<4d_2-d_1=3 < 4,再次累加(res=2>M=1\text{res}=2 > M=1),误返回 false。正确结果应为移走岩石 77,跳跃距离序列 04100 \to 4 \to 10,最小距离 44

  • 修复方案

    • 最小改动:在第二趟和第三趟内层循环中加 if (!flag[...]) 守卫(见 §9)。
    • 推荐替代:用单趟贪心替换整个三趟逻辑(见 §8),从根本上消除多趟扫描的边界问题。

8. 参考代码 (Reference Code)

证明了终点处理的正确性后,采用单趟贪心判定,避免多趟扫描的重叠计数问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <bits/stdc++.h>
using namespace std;

const int N = 5e4 + 5;

int L, n, m;
int d[N];

bool check(int x) {
int cnt = 0, last = 0;
for (int i = 1; i <= n; i++) {
if (d[i] - last < x)
cnt++; // 移走当前岩石
else
last = d[i]; // 保留,更新落脚点
}
if (L - last < x)
cnt++; // 最后一块岩石离终点太近,移走
return cnt <= m;
}

int main() {
ios::sync_with_stdio(false);
cin >> L >> n >> m;
for (int i = 1; i <= n; i++)
cin >> d[i];

int l = 0, r = L;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (check(mid))
l = mid;
else
r = mid - 1;
}
cout << l << endl;
return 0;
}

9. 补充说明 (Additional Notes)

  • 题目渊源:本题出自 NOIP 2015 Day2 T1,是「二分答案 + 贪心判定」的经典入门题。这类「最大化最小值 / 最小化最大值」的模型在竞赛中极为常见,核心识别信号是「最大化最小」或「最小化最大」——出现这类表述时,应优先考虑二分答案。
  • 同类问题:见二分笔记,通过 Obsidian 反向链接关联。

替代版本

以下是我第一次对这个问题代码(可 AC 官方数据,但存在已知 double-counting bug,仅 Hack 数据会触发错误):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <bits/stdc++.h>
using namespace std;

const int N = 5e4 + 5;

int L, n, m;
int d[N];

bool check(int x);

signed main() {
ios::sync_with_stdio(false);
cin >> L >> n >> m;
for (int i = 1; i <= n; i++)
cin >> d[i];

int l = 0, r = L;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (check(mid))
l = mid;
else
r = mid - 1;
}
cout << l << endl;
return 0;
}

bool flag[N];

// ⚠️ 已知 bug:第 54-55 行和第 61-62 行缺少 if (!flag[...]) 守卫,
// 导致同时满足两个距离约束的岩石被重复计入 res。
// 可 AC 洛谷和官方的数据,但针对性的 Hack 数据(如 "4 1 1 / 2")会输出错误结果。
bool check(int x) {
int res = 0;
memset(flag, 0, sizeof(flag));
for (int i = 1; i <= n && d[i] < x; i++)
flag[i] = 1, res++;
for (int i = n; i && L - d[i] < x; i--)
flag[i] = 1, res++; // BUG: 未检查 flag[i] 是否已为 1

for (int i = 1; i <= n; i++) {
if (flag[i])
continue;
for (int j = i + 1; j <= n && d[j] - d[i] < x; j++)
flag[j] = 1, res++; // BUG: 未检查 flag[j] 是否已为 1
}
return res <= m;
}

最小修复(保留三趟结构):将第 54–55 行改为 if (!flag[i]) { flag[i] = 1; res++; },将第 61–62 行内层循环改为 if (!flag[j]) { flag[j] = 1; res++; }。或者直接写进 for 循环的条件。但更推荐直接使用 §8 的单趟贪心。


本站由 zaochen 使用 Stellar 1.33.1 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
全站访问量 - 次 · 访客数 - 人 · 本页面浏览 -