
1. 从一道题看二分查找的本质最近在洛谷上刷题又看到了P1163这道经典的“银行贷款”题。这道题本身并不复杂但它却是一个绝佳的窗口让我们能深入理解二分查找算法在解决实际问题时的核心思想——不仅仅是“在有序数组里找数”更是一种逼近问题精确解的高效策略。很多初学者学完二分查找的模板后遇到这类应用题还是会发懵不知道如何下手。今天我就结合这道题把二分查找从“形”到“神”彻底拆解一遍让你以后遇到任何需要“二分答案”的题目都能一眼看穿本质。P1163的题目大意是已知贷款总额、每月还款额和还款月数要求计算实际的月利率。银行算利息用的是复利即“利滚利”。我们无法直接通过一个公式解出利率但可以验证给定一个猜测的利率我们能精确算出在这个利率下总还款额是否等于贷款本金加利息。如果还多了说明利率猜高了还少了说明利率猜低了。这不就是一个典型的单调性问题吗随着利率升高总还款额单调递增。我们的目标就是在这个单调的序列利率对应总还款额里找到那个使总还款额“刚好”等于目标值的利率。这就是“二分答案”的经典场景。所以二分查找在这里扮演的角色不是一个“查找工具”而是一个“试错与逼近的引擎”。我们通过不断猜测并检验将解的范围一分为二快速排除错误区间最终逼近到满足精度要求的答案。理解这一点比你背十个二分模板都有用。2. 解题核心建模与单调性证明拿到任何二分答案的题目第一步永远不是写代码而是建立数学模型并证明其单调性。这是二分法能够成立的前提也是最容易出错的地方。2.1 建立复利还款模型设贷款本金为 $M$单位元还款月数为 $N$单位月每月还款额为 $Y$单位元月利率为 $x$是一个小数例如0.01表示1%。采用等额本息还款法题目隐含计算比较复杂。但题目给了每月还款额我们可以用现值的思想来建模这是金融学里的常见方法。考虑第 $k$ 个月偿还的 $Y$ 元在贷款发放时的“现值”是多少因为钱有时间价值未来的钱不值现在的钱。在月利率 $x$ 下$k$ 个月后的 $Y$ 元相当于现在的 $Y / (1x)^k$ 元。那么将所有月份的还款现值加起来应该等于最初的本金 $M$。这就得到了我们的核心方程$$ \sum_{k1}^{N} \frac{Y}{(1x)^k} M $$这个方程关于未知数 $x$ 没有简单的代数解这就是为什么我们需要用二分法来数值求解。2.2 证明单调性二分成立的关键为什么我们可以对 $x$ 进行二分我们必须证明函数 $f(x) \sum_{k1}^{N} \frac{Y}{(1x)^k}$ 关于 $x$ 是单调的。观察公式$x$ 在分母的底数 $(1x)$ 里。显然当 $x$ 增大时$(1x)$ 增大因此每一项 $Y / (1x)^k$ 都会减小。所以整个和式 $f(x)$ 随着 $x$ 的增大而单调递减。这意味着当我们猜测的利率 $x$偏高时计算出的还款现值总和 $f(x)$ 会小于实际本金 $M$。当我们猜测的利率 $x$偏低时计算出的还款现值总和 $f(x)$ 会大于实际本金 $M$。这个“偏大偏小”的关系就是我们二分判断的依据。很多同学在这里容易搞反一定要根据自己推导的模型来确认。我个人的心得是在写判断条件前先在纸上用极限值验证一下。比如假设利率 $x$ 为0那么 $f(0) N \times Y$这显然是最大值。如果 $N \times Y M$那这贷款银行亏死了题设通常不会出现。随着 $x$ 变大$f(x)$ 会逐渐减小到接近0。所以存在唯一的一个 $x$ 使得 $f(x) M$。这个单调递减的性质确保了二分的正确性。3. 二分查找的实战实现与细节处理理论清晰后我们来动手实现。这里的二分是在一个连续的实数区间内搜索与在整数数组里搜索有些许不同。3.1 确定搜索边界利率 $x$ 的范围是多少题目要求输出百分数且保留小数点后一位。通常月利率不会超过100%但我们需要一个安全的、包含所有可能解的范围。下界left显然利率最小可以为0无息贷款。所以我们设left 0。上界right设多少合适题目说“利率是正数”为了保险我们可以设一个较大的数比如1.0即100%月利率这已经非常夸张了。实际上经过计算right 100也完全可以因为二分收敛很快。一个更稳妥的方法是根据每月还款额 $Y$ 和本金 $M$ 来估算。如果 $Y$ 很接近 $M$那么利率会很低如果 $Y$ 比 $M/N$ 大很多利率可能较高。但最简单粗暴且安全的方式是直接设right 100即10000%二分法在实数域上对初始范围不敏感。3.2 设计判断函数这是二分的核心即如何根据猜测的mid来判断下一步搜索方向。根据2.2节的推导我们定义判断函数check(x) 计算在利率为x时所有还款的现值总和total_present_value。 如果total_present_value M说明现值总和比本金还高这意味着我们假设的利率太低了导致未来还款折现到现在还是很多。为了让现值总和降到本金 $M$我们需要提高利率。因此搜索区间应该向右侧利率更高的方向移动即left mid。 反之如果total_present_value M说明利率假设高了需要降低利率即right mid。这里有一个关键点由于浮点数计算存在精度误差我们通常不判断total_present_value M而是通过区间宽度来控制精度。3.3 精度控制与循环终止在实数域上二分我们通常采用固定循环次数或设定精度阈值的方法来终止循环。方法一固定循环次数这是一个非常稳健且推荐的方法。对于浮点数二分循环100次足以将区间长度缩小到 $2^{-100}$ 倍对于任何题目要求的精度都绰绰有余。for (int i 0; i 100; i) { double mid (left right) / 2; if (check(mid)) { left mid; } else { right mid; } } // 循环结束后left或right都是满足精度的答案方法二设定精度阈值例如要求输出保留小数点后1位那么我们可以让二分持续到区间宽度小于一个更小的值比如1e-50.00001。while (right - left 1e-5) { double mid (left right) / 2; if (check(mid)) { left mid; } else { right mid; } }两种方法都可以固定循环次数更简单不用担心精度判断的边界问题。我个人的习惯是使用固定循环次数100次对于浮点数二分是黄金标准。3.4 完整代码实现与解析下面给出P1163的完整C解答并附上逐行解析。#include iostream #include iomanip #include cmath using namespace std; double M, Y; // 本金月还款额 int N; // 还款月数 // 判断函数当利率为x时是否还有调整空间即现值总和是否大于本金 bool check(double x) { double sum 0; for (int k 1; k N; k) { sum Y / pow(1 x, k); // 计算第k个月还款的现值并累加 } // 如果现值总和大于等于本金说明利率x可能偏低或刚好需要尝试调高利率向左边界移动 // 注意这里根据我们模型的单调递减性质当 sum M 时x偏小我们希望增大x所以应返回true让 leftmid。 // 但更直观的写法是直接判断 sum 和 M 的关系。 // 另一种清晰写法if (sum M) return true; else return false; // 我们采用清晰写法 return sum M; } int main() { cin M Y N; double left 0, right 100; // 设置一个非常大的上界 for (int i 0; i 100; i) { // 二分100次 double mid (left right) / 2; if (check(mid)) { left mid; // sum M利率mid偏低需要提高所以移动左边界 } else { right mid; // sum M利率mid偏高或刚好需要降低所以移动右边界 } } // 输出结果转换为百分比并保留一位小数 cout fixed setprecision(1) left * 100 endl; return 0; }代码要点解析check函数计算现值总和。这里使用pow函数计算幂次。循环从第1个月到第N个月。判断逻辑check(mid)为真意味着sum M。根据我们单调递减的模型这说明当前利率mid太小了导致现值总和过大。为了让总和降下来接近M我们需要尝试更大的利率因此将搜索区间的下界left提升到mid。反之则提升上界right。输出二分结束后left和right都非常接近真实利率。我们输出left * 100即为百分比形式的月利率。用fixed和setprecision(1)控制输出格式。4. 二分查找的常见陷阱与深度扩展你以为写出上面的代码就能AC所有二分题了吗现实往往更骨感。二分查找尤其是二分答案充满了细节陷阱。下面我分享几个最容易踩坑的地方和对应的思考方式。4.1 陷阱一死循环与边界更新这是整数二分的老大难问题在浮点数二分中由于我们通常用mid (lr)/2和区间精度控制问题不大。但在整数二分中mid的取整方向和l、r的更新方式 (lmid还是lmid1) 必须配套否则极易死循环。核心心法明确你的搜索区间是[left, right]还是[left, right)以及check(mid)为真时你想要保留的区间是哪一半。对于P1163这类浮点数二分通用模板是while (r - l eps) { double mid (l r) / 2; if (check(mid)) l mid; else r mid; }这个模板的前提是当check(mid)成立时答案一定在[mid, r]区间内包含mid。所以用lmid来更新。因为mid是浮点数区间是连续的所以不会死循环。4.2 陷阱二精度误差与比较浮点数运算有误差。在check函数中应避免直接判断sum M。同样在决定输出left还是right时有时会因为误差导致错误。P1163这道题比较简单输出left或right乘以100后四舍五入到一位小数结果通常一致。但在更严格的题目中可能需要处理。一个技巧是在二分结束后再用check函数验证一下left和right或者直接输出(leftright)/2作为最终答案这通常能抵消一部分误差。4.3 陷阱三单调性未证明盲目二分这是最致命的错误。不是所有求极值或解方程的问题都能用二分。必须确保答案相对于判断条件是单调的。例如如果函数是波浪形的有多个解标准二分就会失效。在比赛中可以通过观察样例、分析题目物理/数学意义或者画图来验证单调性。P1163的单调性由数学公式保证但有些题目可能需要更细致的分析。4.4 从P1163到更复杂的二分答案问题P1163是一个引子。掌握了它你就可以解决一大类“二分答案”问题。这类问题的通用特征是答案在一个确定的范围内。很难直接计算答案但给定一个猜测值我们可以很容易地判断这个值是“大了”还是“小了”。判断条件具有单调性使得我们可以根据判断结果确定性地缩小答案所在的范围。举例延伸“最大值最小化”或“最小值最大化”问题例如将一条很长的绳子切成N段每段长度至少为L问L最大能是多少使得能切出至少N段。我们可以二分猜测L然后判断以这个长度能切出多少段。如果段数 N说明L可能还能更大或刚好调整左边界否则调整右边界。可行性判断问题例如有N项任务和M个工人每个工人能力相同问最短需要多少时间能完成所有任务。我们可以二分猜测时间T然后判断在时间T内M个工人是否能完成所有任务。这需要另一个贪心算法作为check函数。思维升华二分答案的本质是将求解问题转化为判定问题。原问题是“求满足条件C的最优解x”我们将其转化为“给定x判断条件C是否成立”。只要这个判定问题更容易解决且具有单调性二分法就能大显神通。这种“转化”的思想在算法设计中至关重要。5. 调试技巧与实战心得理论再强代码写错也白搭。分享几个我在调试二分查找特别是二分答案题目时的实用技巧。5.1 输出中间过程在二分循环内部打印出left,mid,right以及check(mid)的结果。这是最直接的调试方法。你可以清晰地看到区间是如何缩小的以及你的判断逻辑是否符合预期。for (int i 0; i 10; i) { // 先循环10次看看 double mid (left right) / 2; bool result check(mid); printf(Iter %d: l%.5f, mid%.5f, r%.5f, check%d\n, i, left, mid, right, result); if (result) { left mid; } else { right mid; } }观察check(mid)的变化是否单调以及区间端点是否向一个方向稳定收敛。5.2 使用极限值测试在写check函数前后用极限值手动测试一下。对于P1163测试利率x0此时每月还款现值就是Y总和为N*Y。这个值应该远大于本金M否则银行亏本。所以check(0)应该返回true因为我们定义sum M时返回true。测试一个很大的利率比如x101000%此时(1x)很大pow(1x, k)巨大sum趋近于0肯定小于M。所以check(10)应该返回false。如果这两个极限测试的结果符合预期那么你的check函数逻辑基本正确单调性也大概率成立。5.3 警惕整数溢出在P1163中我们用的是浮点数没有这个问题。但在很多整数二分答案问题中check函数内部的计算可能导致整数溢出。例如判断“能否在时间T内完成”可能需要累加量这个累加和可能超出int范围。务必使用long long。5.4 关于浮点数二分的精度设置如果题目要求输出小数点后k位那么你的二分精度eps应该设置为1e-(k2)或更小。例如要求保留1位小数eps设为1e-4或1e-5是安全的。使用固定循环次数如100次则无需担心此问题因为它能达到的精度远高于通常的要求。我个人的实战心得是对于二分答案60%的精力花在建模和设计正确的check函数上30%的精力花在证明或确认单调性上最后10%才是写二分循环本身。循环的模板几乎固定但前面的思考决定了算法的正确性。P1163是一个完美的起点它涉及的金融概念和单调性分析为你应对更复杂的二分答案问题打下了坚实的基础。下次遇到“猜一个值然后判断是否可行”的题目不妨先想想能不能用二分来加速这个“猜”的过程。