尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

二分答案算法详解:从路标设置问题到最小化最大值通用解法

二分答案算法详解:从路标设置问题到最小化最大值通用解法 1. 从“路标设置”到“二分答案”的解题直觉最近在洛谷上刷题又遇到了一个经典的“二分答案”问题——P3853 [TJOI2007] 路标设置。题目本身描述的是一个公路养护的场景一条笔直的公路上起点和终点已经各有一个路标中间还有一些原有的路标。现在给你一些额外的路标要求你把这些新路标插入到公路上目标是让相邻两个路标之间的最大距离尽可能小。这个“最小化最大距离”的描述几乎是二分答案类问题的“标准开场白”。很多朋友第一次看到这类题可能会有点懵不知道从何下手或者写出来的循环逻辑总是差一点。今天我就结合这道题把二分答案的思路掰开揉碎了讲尤其是循环边界的处理这是最容易出错的地方。简单来说这道题的核心是已知公路总长度 L已有 N 个路标的位置包括起点0和终点L以及 K 个可用的新路标。我们需要找到一个最小的整数 D使得在插入这 K 个新路标后任意相邻路标间的距离都不超过 D。这个 D 就是我们要求的答案。为什么想到二分因为答案 D 有一个明显的单调性如果某个距离 X 能够满足要求即用不超过 K 个新路标就能让所有间隔 ≤ X那么所有比 X 大的距离也一定能满足反之如果 X 不能满足那么所有比 X 小的距离也都不能满足。这种“能满足”和“不能满足”之间的分界线就是我们要找的最小 D。二分查找正是寻找这种分界线的利器。2. 问题拆解与“检查函数”的设计逻辑二分答案的框架通常很固定先确定答案的可能范围然后在这个范围内进行二分查找每次猜测一个中间值mid并判断这个mid是否可行。判断可行性的函数我们一般称之为check(mid)或isValid(mid)它是整个算法的灵魂。对于这道题check(mid)函数要解决的问题是假设我们允许的最大间隔是 mid那么至少需要插入多少个新路标具体逻辑如下我们遍历每两个相邻的原有路标计算它们之间的距离gap。如果gap mid说明这段距离本身已经满足要求不需要插入新路标。如果gap mid说明这段距离太长了我们需要在中间插入新路标来“切割”它。那么需要插入多少个呢这里有个小技巧我们需要让切割后的每一段都 ≤ mid。想象一下一段长度为gap的绳子要切成若干段每段最长mid最少需要切几刀需要的段数至少是ceil(gap / mid)。而插入的路标数等于段数减1因为 N 段只需要 N-1 个分隔点。所以需要的路标数是ceil(gap / mid) - 1。在编程中我们通常用整数除法来避免浮点数运算和ceil函数(gap mid - 1) / mid等价于ceil(gap / mid)。因此所需路标数need (gap mid - 1) / mid - 1。遍历所有间隔累加需要的路标数total_need。最后判断total_need K我们拥有的新路标数。如果成立说明用mid作为最大间隔是可行的甚至可能用不完 K 个路标否则不可行。这个check函数的设计是整个问题的核心它把抽象的“是否可行”转化为了具体的、可计算的数学逻辑。2.1 一个具体的计算例子假设两个原有路标距离为gap 10我们猜测的mid 3。需要的段数ceil(10 / 3) ceil(3.333...) 4用整数除法(103-1)/3 12/3 4。需要插入的路标数4 - 1 3。验证插入3个路标后会将10的距离分成4段长度分别为3, 3, 3, 1都满足 ≤ 3。3. 二分查找的边界与循环细节剖析设计好了check函数接下来就是二分查找的主循环。这里往往是新手和老手拉开差距的地方主要在于搜索区间和循环条件的把握。搜索区间 [left, right] 如何确定left左边界理论上最优解 D 至少为 1。因为距离是整数且如果所有路标都挤在一起虽然题目不允许最小距离可以是1。所以我们可以设left 1。right右边界最坏的情况是一个额外路标都没有K0那么最大间隔就是原始路标之间的最大距离。但题目保证了至少有一个额外路标不过为了普适性我们可以直接将右边界设为公路全长 L。因为即使把所有路标都拿走只剩起点终点最大间隔也就是 L。所以right L是一个绝对安全的上界。循环条件与指针更新二分查找有两种常见的写法while (left right)和while (left right)。在这类“寻找最小可行解”的问题中我强烈推荐使用while (left right)配合mid (left right) / 2的写法因为它可以优雅地避免死循环并且循环结束时left就是答案。关键点在于mid可行时我们如何更新边界。如果check(mid) true说明mid是一个可行的解但我们要找的是最小的可行解。因此答案可能等于mid也可能比mid更小。所以我们应该将搜索范围向左侧收缩即right mid注意不是mid - 1因为mid本身可能是答案。如果check(mid) false说明mid太小了无法满足要求。那么答案肯定比mid大。所以我们应该将搜索范围向右侧收缩即left mid 1。这样循环会不断缩小[left, right)区间注意是左闭右开或左闭右闭的变体最终收敛时left right。当while (left right)循环退出时left和right指向同一个值这个值就是我们要找的最小可行 D。3.1 为什么是right mid而不是right mid - 1这是最容易出错的地方。我们来看一个反例假设答案 D 就是 5。在某轮循环中left4, right10, mid7。check(7)返回 true因为7比5大肯定是可行的。如果我们错误地执行right mid - 1 6那么搜索区间就变成了[4, 6]答案5仍然在其中这没问题。但是如果下一轮mid (46)/2 5check(5)返回 true我们再次执行right mid - 1 4区间变成[4, 4]循环结束。我们得到left4但这并不是正确答案正确答案是5。错误的原因在于当mid可行时它可能就是答案将其减1可能会把正确答案排除在搜索区间之外。因此安全的做法是right mid确保可行解mid始终在区间内。4. 完整代码实现与逐行解读理解了上述原理我们来看完整的C代码实现。我会在关键位置加上注释。#include iostream #include vector #include algorithm using namespace std; int main() { int L, N, K; cin L N K; vectorint signs(N); for (int i 0; i N; i) { cin signs[i]; } // 检查函数判断最大间隔为 max_gap 时是否需要不超过 K 个新路标 auto check [](int max_gap) - bool { int need 0; // 总共需要的新路标数量 for (int i 1; i N; i) { int distance signs[i] - signs[i - 1]; // 相邻原有路标的距离 if (distance max_gap) { // 核心计算公式需要插入的路标数 ceil(distance / max_gap) - 1 need (distance max_gap - 1) / max_gap - 1; // 如果中途发现需要的路标已经超过K可以提前结束节省时间 if (need K) return false; } } return need K; }; // 二分查找的初始边界 int left 1; // 最小可能间隔是1 int right L; // 最大可能间隔是公路全长L // 也可以将 right 初始化为原始路标间的最大距离但 L 是绝对安全的上界 // for (int i 1; i N; i) right max(right, signs[i] - signs[i-1]); // 二分查找核心循环 while (left right) { int mid left (right - left) / 2; // 防止(leftright)可能溢出的写法 if (check(mid)) { // mid 可行答案可能是 mid 或更小搜索左半部分包含mid right mid; } else { // mid 不可行答案一定比 mid 大搜索右半部分不包含mid left mid 1; } } // 循环结束left right即为答案 cout left endl; return 0; }代码关键点解读输入处理直接读取公路长度L、原有路标数N、可用新路标数K并读入原有路标位置数组signs。题目已保证signs是递增的所以不需要排序。Lambda表达式check我使用了C11的lambda表达式来定义检查函数这样主函数逻辑更清晰。它捕获了外部变量signs, N, K。核心计算(distance max_gap - 1) / max_gap - 1(distance max_gap - 1) / max_gap实现了对distance / max_gap的向上取整ceil。减去1得到需要插入的路标数量。提前剪枝在check函数中一旦累计需要的路标数need超过了K立即返回false无需继续遍历后面的间隔。这是一个有效的优化。二分循环while (left right)这是寻找最小可行解的标准模板。更新规则right mid和left mid 1确保了正确性。计算mid使用left (right - left) / 2而非(left right) / 2是为了防止在left和right都很大时求和溢出。虽然本题数据范围不大但这是一个好习惯。5. 常见错误与调试心得在实际编码和调试中以下几个坑点值得特别注意坑点一整数除法的向上取整这是最高频的错误。直接写need distance / max_gap - 1是向下取整会导致路标数计算不足。必须使用(distance max_gap - 1) / max_gap - 1这个形式。你可以多试几组数据比如distance10, max_gap3用错误写法得到10/3-13-12但实际上需要3个路标分成4段。坑点二二分边界初始化和更新左边界left不能从0开始。因为如果max_gap0在check函数中会出现除零错误(distance 0 - 1) / 0。从1开始是安全的。右边界right初始化为L是万无一失的。有些人想优化初始化为原始最大间隔max_gap_original。但要注意如果K很大最优解可能比原始最大间隔还要小通过插入很多路标来减小最大间隔。所以初始化为L更保险。更新规则务必牢记可行时right mid不可行时left mid 1。搞反了就会陷入死循环或者得到错误答案。坑点三忽略起点和终点题目明确说明起点0和终点L已经存在路标。我们的signs数组里是否包含它们在输入中N是包括起点和终点的原有路标总数数组signs[0]就是起点通常是0signs[N-1]就是终点L。因此我们在check函数中遍历i from 1 to N-1计算的就是signs[i] - signs[i-1]这自然包含了从起点到第一个路标以及最后一个路标到终点的所有间隔。不需要特殊处理。调试技巧 当你的程序答案不对时不要慌。可以尝试以下方法小数据测试自己构造一个极小的例子比如 L10, 原有路标在 [0, 10]K1。手算一下答案应该是多少比如插入在5最大间隔为5然后单步调试你的程序看check函数计算是否正确二分过程是否合理。打印中间状态在二分循环中打印出每一轮的left,right,mid以及check(mid)的结果。观察搜索区间是如何缩小的最终是否收敛到正确答案。验证check函数单独测试你的check函数。固定一个max_gap手动计算需要多少路标再与程序输出对比。6. 算法复杂度分析与拓展思考时间复杂度check函数需要遍历一次原有路标间隔复杂度为 O(N)。二分查找的区间是[1, L]每次将区间减半需要进行 O(log L) 次迭代。因此总时间复杂度为O(N log L)。对于本题典型的数据范围L ≤ 10^9, N ≤ 10^5这个复杂度是完全可接受的。空间复杂度 只需要存储原有路标位置的数组因此是 O(N)。拓展思考 “最小化最大值”或“最大化最小值”是一类非常经典的优化问题统称为极小化极大问题。二分答案是其通用的解决方案。除了这道“路标设置”还有很多类似的问题“跳石头”在一条数轴上移走最多 M 块石头使得最短跳跃距离最大。这是“最大化最小值”。“月度开销”将 N 个连续的费用分成 M 段使得最大段的和最小。这是“最小化最大值”。“数列分段”类似月度开销。“聪明的质检员”通过调整参数 W使得检验结果与标准值 S 的绝对值差最小。这需要结合前缀和与二分。解决这类问题的通用步骤可以总结为识别单调性确定答案变量是否具有单调性如果 X 可行则大于/小于 X 的都可行。设计检查函数这是最关键的一步需要根据题意设计一个函数check(mid)能在多项式时间内判断“如果答案限制为 mid是否可行”。确定搜索范围根据题意确定答案的最小可能值left和最大可能值right。套用二分模板根据寻找的是“最小可行解”还是“最大可行解”选择合适的二分循环和边界更新规则。掌握这个套路你就能解决一大类二分答案的题目。核心还是在于对问题本质的理解和check函数的正确实现。多练习多思考下次再遇到“最小化最大”或“最大化最小”这类字眼你就能立刻反应出二分答案的解法了。
返回列表