news 2026/8/24 21:04:16

二分答案算法精讲:从河中跳房子问题理解最值优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
二分答案算法精讲:从河中跳房子问题理解最值优化

1. 从“河中跳房子”到二分答案:一个经典思维的诞生

如果你刚开始接触算法竞赛或者刷题,看到“1247:河中跳房子”这个标题,可能会觉得有点摸不着头脑。这听起来像是个游戏或者物理题,怎么就成了经典的算法例题?我第一次遇到它时也有同样的疑惑,但真正动手做下来才发现,这道题简直是理解“二分答案”这个核心思想的绝佳敲门砖。它没有复杂的图论结构,也没有繁琐的动态规划状态转移,就是用一个最朴素的场景,逼着你去思考一个根本问题:当答案本身难以直接求解,但给定一个“候选答案”后,我们能快速判断它是否可行时,我们该怎么办?

“河中跳房子”描述的场景非常直观:有一条河,河中间有N个石头(房子),它们与起点的距离是已知的。现在我们要从起点跳到终点,每次跳跃必须落在石头上,并且跳跃距离不能小于一个给定的值。问题是,在必须移走恰好M块石头的情况下,如何安排移走哪些石头,使得最终能够完成跳跃的最短跳跃距离尽可能大?这个“尽可能大的最短跳跃距离”,就是我们要求解的答案。

为什么这个问题适合用二分答案?因为答案——那个最短跳跃距离——是一个整数,并且存在一个明确的边界。想象一下,如果允许的跳跃距离非常小,比如1,那么你几乎可以踩着所有石头过去,移走M块石头后肯定也能过去,所以这个答案是“可行”的。如果允许的跳跃距离非常大,大到超过相邻石头间的最大间隔,那么你移走M块石头后,必然存在一段你跳不过去的空隙,所以这个答案是“不可行”的。于是,在“可行”与“不可行”之间,存在一个临界点。我们的目标就是找到这个最大的、依然可行的跳跃距离。手动去猜这个数显然效率低下,而二分查找,正是用来在有序范围内快速定位这种临界点的利器。

网络上相关的热词如“二分答案”、“二分查找”、“贪心算法”都指向了这里。很多人学二分,只记住了在有序数组里找某个数,却不知道“二分答案”才是二分思想更具威力的应用。这道题就是一个完美的桥梁,它要求你跳出“在给定序列中查找”的定式思维,转而去在一个答案的可能区间里,利用一个判断函数来缩小区间。这个思维模式的转换,是解决一大批最优化问题的关键,从安排会议时间到分配资源,其内核都是相通的。

2. 问题拆解:定义、约束与核心挑战

在动手写代码之前,我们必须把题目嚼碎了咽下去,理解每一个条件背后的意图。这不仅仅是读懂题目,更是为了设计出正确的“可行性判断”函数,这是二分答案的灵魂。

2.1 问题要素的精确翻译

首先,我们把生活化的描述翻译成程序员熟悉的语言:

  • 输入:河的长度L(起点到终点的距离),石头数量N(不包括起点和终点),必须移走的石头数M,以及N块石头距离起点的位置(升序给出)。
  • 隐含点:起点(位置0)和终点(位置L)是固定的,不能移动。我们跳跃的“舞台”就是由起点、终点以及剩下的 (N - M) 块石头构成的。
  • 动作:从起点开始,每次跳到下一块剩余的石头,最终跳到终点。每次跳跃的距离,就是两块石头位置之差。
  • 目标:在移走恰好M块石头后,找出一种保留石头的方案,使得所有跳跃距离中的最小值最大。输出这个最大的最小值。

这里有一个非常关键的理解点:我们不是要找出移走哪M块石头,而是要找到一个最大的距离D,使得存在一种移走M块石头的方法,让剩下的石头(包括起点终点)中,任意相邻两块的距离都至少为D。前者是一个具体的组合方案,后者是一个数值目标。二分答案帮我们找到的是后者。只要我们能判断某个D是否可行,我们就能用二分逼近最大的那个D。

2.2 为什么贪心算法是可行性判断的“最优解”?

给定一个候选的“最短跳跃距离”D,如何判断在移走不超过M块石头的前提下,能否实现所有跳跃距离都 >= D?

一个最直接的思路是模拟跳跃过程。我们从起点(位置0)开始,看向下一块石头。如果当前石头与下一块石头的距离 >= D,那么我们可以安全地跳过去,并把下一块石头作为新的起点。如果距离 < D,说明这块石头太近了,如果我们不跳过去,就无法到达终点(因为我们必须按顺序跳)。那么唯一的办法就是移走这块距离太近的石头,然后继续比较当前石头与再下一块石头的距离。

这个过程天然就是一个贪心算法:我们在每一步都做出局部最优选择——只要石头够远就跳过去,不够远就移走它。为什么贪心在这里是正确的?因为我们的目标是让所有间隔 >= D,并且希望移走的石头尽可能少。如果当前石头和下一块石头距离小于D,保留下一块石头必然导致这段间隔不达标。移走它,是为后续的间隔创造可能(让当前石头直接对接更后面的石头)。这个决策只影响当前这一段,不会对未来的决策产生后效性,因此贪心是有效的。

具体判断函数check(D)的逻辑如下:

  1. 初始化last_pos = 0(起点位置),removed = 0(移走石头计数)。
  2. 遍历每一块石头(位置为stone[i]):
    • 计算stone[i] - last_pos
    • 如果距离 < D,说明石头i太近,必须移走:removed++
    • 如果距离 >= D,说明可以跳到石头i,更新last_pos = stone[i]
  3. 遍历结束后,不要忘记终点!计算L - last_pos(最后一块保留的石头到终点的距离)。如果这个距离也 < D,那么意味着即使调整石头,从最后一块石头也无法跳到终点,这个D肯定不可行。实际上,在贪心过程中,如果最后一段距离小于D,我们已无石头可移(终点不能移),所以直接判定不可行。
  4. 判断removed <= M。如果成立,说明用不超过M次的移除操作,可以实现所有跳跃 >= D,D是可行的;否则不可行。

注意:这里有一个非常重要的细节,也是容易出错的地方。check(D)函数判断的是“能否在移走不超过M块石头的情况下实现条件”。题目要求是“移走恰好M块”,那会不会有“移走少于M块就能满足条件,导致我们找到的不是题目要求的解”的情况?实际上,如果某个D满足“移走 <= M 块石头即可”,那么它一定是可行的。因为我们可以通过额外移走一些无关紧要的石头(比如在已经很远的间隔中间再移走一块),凑足恰好M块,而这并不会降低已有的最短跳跃距离。所以,check(D)的条件是宽松的,这保证了二分过程的正确性。我们最终找到的,是满足“移走 <= M 块石头即可”的最大D,它必然也对应着一种“移走恰好M块石头”的方案(可以通过额外移除来凑数)。

3. 二分查找的边界与循环设计:避开死循环的坑

理解了check(D),我们就有了在答案空间里导航的指南针。接下来,我们需要确定搜索的起点和终点,并设计一个永不迷路的二分循环。

3.1 答案边界的确定

答案(最短跳跃距离的最大值)最小是多少?理论上可以是0,但0没有实际意义,且我们的判断函数在D=0时总是成立。更实际的下界是1。答案最大是多少?一种朴素的认为是河的长度L,但显然不可能跳那么远。一个更紧的上界是L本身(如果你能一脚从起点跳到终点)。但在二分时,我们通常设置一个安全的、肯定不可行的上界。因为当D大于任意两块石头(包括起点终点)之间的间隔时,必然不可行。所以我们可以设置left = 1,right = L。为了确保完全覆盖,有时会设置right = L + 1,这样即使check(L)为真,我们的二分区间也能容纳它。

在我的实践中,更推荐一种清晰且不易出错的方式:

int left = 1; // 最短跳跃距离至少为1 int right = L; // 最长不会超过河的长度 // 或者,考虑到如果所有石头都移走,最短距离就是L,所以right=L是合理的。

3.2 二分循环的“左闭右开”与“左开右闭”抉择

这是二分查找最容易写出死循环的地方。关键在于明确你维护的区间含义。

  • 我们寻找的是最后一个满足check(mid) == true的D。
  • 假设我们维护的区间是[left, right],其中check(left)为真,check(right)为假。我们的目标是不断缩小这个区间,直到leftright相邻。

我强烈推荐并使用“左闭右开”的写法,即区间表示为[left, right)。它的循环不变式是:left指向一个可行的答案,right指向一个不可行(或未探索)的边界。最终,当left + 1 == right时,left就是我们要找的最大可行解。

对应的循环模板如下:

while (left + 1 < right) { int mid = left + (right - left) / 2; // 防止溢出 if (check(mid)) { left = mid; // mid可行,说明答案至少是mid,将左边界推进到mid } else { right = mid; // mid不可行,说明答案必须小于mid,将右边界收缩到mid } } cout << left << endl; // 循环结束时,left就是最大可行值

这种写法的好处非常明显:

  1. 永不退循环:条件left + 1 < right保证了区间内至少有两个元素时才需要循环。当区间缩小到[left, left+1)时,循环结束。
  2. 更新清晰:因为区间是右开的,当mid可行时,我们将left设为mid,这很自然。当mid不可行时,我们将right设为mid,因为mid本身已经不可行,新的右边界应该是它(开区间不包含mid)。
  3. 答案明确:循环结束后,left就是最后一个被验证可行的值,直接输出即可。

对比常见的while (left <= right)写法,那种写法需要处理mid的加减1,并且最终答案的存储变量(是left还是right还是ans)容易混淆。“左闭右开”模板将答案的维护隐含在了区间边界里,逻辑更简洁,几乎可以成为二分答案问题的标准写法。

3.3 一个完整的算法流程梳理

  1. 读入L, N, M以及石头位置数组stones。为了方便处理,可以在数组开头插入0(起点),末尾插入L(终点)。
  2. 定义check(int d)函数,实现上述贪心逻辑,返回布尔值。
  3. 初始化二分边界left = 1,right = L + 1(或right = L,但需确保check(L)为真时也能正确处理)。
  4. 执行“左闭右开”的二分循环。
  5. 输出left

4. 代码实现、测试与极端情况分析

理论清晰之后,我们来落地成代码,并思考一些边界情况,确保我们的解决方案是健壮的。

4.1 完整的C++代码实现

#include <iostream> #include <vector> #include <algorithm> using namespace std; int L, N, M; vector<int> stones; // 判断是否能在移走不超过M块石头的情况下,使得最短跳跃距离至少为d bool check(int d) { int last_pos = 0; // 起点位置 int removed = 0; // 遍历所有石头 for (int i = 0; i < N; ++i) { if (stones[i] - last_pos < d) { // 距离太近,必须移走当前石头 removed++; if (removed > M) { // 移走数量已超限,提前返回false return false; } } else { // 可以跳过去,更新上一个位置 last_pos = stones[i]; } } // 检查最后一块保留的石头到终点的距离 // 注意:终点L已经包含在stones数组末尾了吗?这里假设没有。 // 更稳妥的做法是,将终点L也视为一块“石头”加入数组,这样循环内就包含了终点判断。 // 以下是未将终点加入数组时的判断: if (L - last_pos < d) { return false; // 最后一段跳不到终点 } return removed <= M; } int main() { cin >> L >> N >> M; stones.resize(N); for (int i = 0; i < N; ++i) { cin >> stones[i]; } // 为了方便,可以对石头位置排序(题目虽说是升序给出,但排序是个好习惯) sort(stones.begin(), stones.end()); // 二分查找 int left = 1; int right = L + 1; // 右开区间,L+1是一个肯定不可行的值(因为最大距离是L) while (left + 1 < right) { int mid = left + (right - left) / 2; if (check(mid)) { left = mid; // mid可行,尝试更大的 } else { right = mid; // mid不可行,缩小范围 } } cout << left << endl; return 0; }

代码优化点:如注释所述,将终点L作为一块“石头”插入stones数组末尾,可以使check函数逻辑更统一,无需单独判断最后一段。修改如下:

stones.push_back(L); // 在输入并排序后,加入终点 N = stones.size(); // 更新石头数量(包含了终点) // 修改check函数,移除对 L - last_pos 的单独判断,因为终点已在数组中。 bool check(int d) { int last_pos = 0; int removed = 0; for (int pos : stones) { // 现在stones包含了终点 if (pos - last_pos < d) { removed++; if (removed > M) return false; } else { last_pos = pos; } } return true; // 如果能遍历完所有“石头”(包括终点),说明成功到达 }

4.2 极端情况与测试用例

任何健壮的算法都需要考虑边界。

  • 情况一:M = 0(一块石头都不能移)。此时问题退化为:给定间隔,求最小间隔的最大值?实际上,答案就是所有相邻石头(包括起点终点)间隔的最小值。我们的算法能工作吗?可以。check(D)函数会尝试移走距离小于D的石头,但因为M=0,一旦需要移走就会返回false。二分会找到最大的那个D,使得没有任何间隔小于D,即所有间隔都 >= D。这个D就是最小间隔。
  • 情况二:M = N(所有石头都可以移走)。此时我们可以移走所有石头,直接从起点跳到终点。那么最大的最短跳跃距离就是河的长度L。我们的算法中,check(L)会成功吗?在贪心过程中,因为起点0到任何一块石头的距离都小于L(除非石头就在L),所以所有石头都会被标记为移走,removed = N,满足removed <= M。并且最后last_pos还是0,终点L到0的距离等于L,满足条件。所以check(L)返回true。二分会找到L。
  • 情况三:石头位置有重复。题目通常保证位置互异,但如果输入有重复,我们的算法依然有效。对于两个位置相同的石头,它们之间的距离为0,在任何D>0的情况下,第一块都会被保留,第二块会被移走(因为距离0 < D)。
  • 情况四:L很小,N很大。二分查找的复杂度是 O(logL * N),在常规数据范围内(L<=10^9, N<=50000)完全可行。

4.3 与“最小值最大化”同类问题的对比

“河中跳房子”是“最小值最大化”问题的典型代表。类似的还有:

  • “进击的奶牛”:在一条数轴上放N个牛棚,要放入C头牛,使得任意两头牛之间的最小距离最大。解法几乎一模一样,check(d)函数判断能否在保证牛之间距离至少为d的情况下放下所有牛。
  • “砍树”:有N棵树,需要砍下M米长的木材,锯子的高度为H,树木高于H的部分会被砍下。求最大的H,使得砍下的木材总长度至少为M。这里check(H)计算木材总长度是否 >= M。
  • “分配预算”:将总额为M的预算分配给N个项目,每个项目有一个最低需求和最高需求,求在满足所有项目最低需求后,能使获得预算最少的那个项目得到的预算最大值。check(x)判断能否在满足每个项目至少获得x预算的前提下,分配完总预算。

它们的共同模式是:答案是一个数值,其可行性与数值大小呈单调关系(通常,数值越大越难满足条件),并且存在一个判断给定数值是否可行的函数。识别出这种模式,就立刻可以套用二分答案的框架。

5. 从二分答案到更广阔的算法思维

通过“河中跳房子”这个具体的例子,我们深入演练了二分答案的完整流程。但这道题的价值不止于此,它更像一个引子,让我们看到算法思维是如何层层递进的。

5.1 贪心与二分的结合:1+1>2

这道题的精妙之处在于它将贪心二分完美结合。贪心算法负责在给定约束下的快速可行性判断(check函数),其时间复杂度是O(N)。二分查找则负责在巨大的答案空间(1到L)中进行高效搜索,时间复杂度是O(logL)。两者结合,总复杂度为O(N logL),轻松处理大规模数据。这种“二分外壳 + 贪心/其他算法内核”的结构,是解决许多最优化问题的标准套路。关键在于,你必须能够写出一个正确的、单调的check函数。

5.2 调试二分:当答案不对时怎么办?

二分查找的bug往往难以察觉。如果你得到的答案不对,可以按以下步骤排查:

  1. 验证check函数:这是最容易出错的地方。构造几个小例子,手动模拟check(D)的过程,特别是边界情况(D很小、D很大、M=0、M=N)。确保你的贪心逻辑是正确的。
  2. 验证二分边界:打印出循环过程中left,right,midcheck(mid)的值。观察区间是否在正确收敛。确保你的初始right设置得足够大(是一个肯定不可行的值)。
  3. 验证循环条件:确认你的循环最终会停止。对于“左闭右开”模板,while (left + 1 < right)是安全的。
  4. 验证最终答案:循环结束后,输出的是left还是right?根据你的区间定义来确认。在我们的模板里,输出left

5.3 举一反三:识别二分答案的适用场景

在以后的刷题或工作中,如何判断一个问题是否能用二分答案解决?问自己三个问题:

  1. 问题的答案是一个数值吗?(通常是最大或最小的某个指标)。
  2. 如果我猜一个答案,我能相对容易地判断它是否“可行”吗?(即能写出check函数)。
  3. 可行性和数值大小之间是否存在单调性?(例如,对于“最小值最大化”问题,数值越大越难满足;对于“最大值最小化”问题,数值越小越难满足)。

如果这三个问题的答案都是“是”,那么二分答案就很可能是一个高效的解决方案。它把求解最优值的问题,转化为了若干个判定性问题,极大地简化了思维难度。

回过头看“1247:河中跳房子”,它之所以经典,就是因为它干净利落地呈现了这个思维范式。没有多余的干扰,直指核心。吃透这道题,你收获的不仅仅是一个AC的代码,更是一种面对复杂最优化问题时,化繁为简、分而治之的强大武器。下次再遇到“最大的最小”、“最小的最大”这类字眼,你会条件反射般地想到:也许,可以试试二分答案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/24 20:59:35

基于SpringBoot+vue的相机租赁管理系统毕业设计项目源码

联系博主 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 …

作者头像 李华
网站建设 2026/8/24 20:59:20

分布式锁核心原理与Redisson生产级实现详解

分布式锁&#xff0c;这个在面试中高频出现、在项目中却又常常被误解或误用的技术&#xff0c;到底解决了什么问题&#xff1f;为什么单体应用时代我们很少关心它&#xff0c;而一旦系统走向分布式&#xff0c;它就变成了一个绕不开的坎&#xff1f; 很多开发者对分布式锁的理…

作者头像 李华
网站建设 2026/8/24 20:58:22

基于物理光学模型的视频眼镜移除技术:原理、部署与工程实践

这次我们来看一个名为“Unwarping the Lens: A Physics-Grounded Approach to Video Glasses Removal”的研究项目。简单来说&#xff0c;这是一个利用物理基础模型从视频中移除眼镜的技术。它要解决的核心问题是&#xff1a;当一个人戴着眼镜出现在视频中时&#xff0c;眼镜镜…

作者头像 李华
网站建设 2026/8/24 20:55:53

3 步把 Visio 文件转进 drawio-desktop

3 步把 Visio 文件转进 drawio-desktop 【免费下载链接】drawio-desktop Official electron build of draw.io 项目地址: https://gitcode.com/GitHub_Trending/dr/drawio-desktop 同事在 Windows 上画好架构&#xff0c;发来一个 .vsdx&#xff0c;你在 Mac 上双击&…

作者头像 李华
网站建设 2026/8/24 20:55:37

BepInEx 6.0 IL2CPP适配实战:从签名耗尽到稳定启动的完整流程

BepInEx 6.0 IL2CPP适配实战&#xff1a;从签名耗尽到稳定启动的完整流程 【免费下载链接】BepInEx Unity / XNA game patcher and plugin framework 项目地址: https://gitcode.com/GitHub_Trending/be/BepInEx 如果你装的是 IL2CPP 后端的游戏&#xff0c;双击启动后大…

作者头像 李华