发布时间:2026/9/8 12:28:16
C++二分查找细节敲定:从边界条件到树状数组实战 二分这种东西写起来感觉就几行真正敢说完全拿捏的人却没几个。哪怕从面试到工程实战都绕不开它很多人依然会在边界条件上反复横跳不是死循环就是答案差一位。我早些年也栽过不少跟头后来把C里二分的那些细节规则彻底捋了一遍才算是真正稳下来。这篇就把我对二分细节敲定的理解一次性说透从最基础的写法到树状数组上的特殊玩法再到工程落地时的调试习惯全给你盘明白。1. 整体设计思路与细节根源1.1 为什么二分写着简单错起来却要命二分查找的核心思想一句话就能讲完在一个有序区间里每次比较中间值排除掉一半不可能的区域把搜索范围缩到足够小。逻辑上无懈可击但一旦落到代码上问题就全出来了。最典型的几个“翻车点”包括while循环里到底该写left right还是left rightmid到底该不该加1更新区间时left和right究竟谁该等于mid谁该等于mid 1或mid - 1。我见过太多人在这些细节上凭感觉写结果要么死循环要么区间收缩不到正确位置。说实话这真不是智商问题而是二分这东西天然有“规则内隐”的特征——你看那些教科书里的模板边界条件往往是基于特定的区间定义和循环不变量推导出来的如果没搞懂背后的不变量只是机械地背一个模板换个场景立刻报废。我自己后来总结出一条经验写二分之前先花十秒钟想清楚两个问题。第一你维护的区间是左闭右开[left, right)还是左闭右闭[left, right]。第二你的循环退出条件是什么退出之后left或者right指向的到底是哪个位置。这两个问题一旦敲定后面所有的边界处理都是机械推导根本不需要每次重新猜。1.2 从区间定义推导细节而不是背模板很多人学二分喜欢背模板背那种“万能二分”的写法。我只能说模板可以背但不能只背一个。因为实际需求千变万化找第一个等于目标的位置、找最后一个等于目标的位置、找第一个大于等于目标的位置、找最后一个小于等于目标的位置……每一种需求的边界细节都有微妙差别。我的建议是把“维护的区间”看作二分的真正主角。比如经典写法中如果你维护的是[left, right]闭区间那么初始left 0right n - 1循环条件就是left right因为当left right时区间才真正为空。而如果维护的是[left, right)半开区间初始right n循环条件就得是left right因为当left right时区间已经为空。这两种写法都能做对但混用就全乱了。我自己实际写代码时绝大多数情况倾向于用[left, right)半开区间原因有两点一是它和C标准库的迭代器区间风格一致begin()和end()本身就是左闭右开[first, last)已经约定俗成二是半开区间在表示空区间、表示“插入位置”这类需求时特别自然不需要额外处理-1或1的边界。1.3 敲定规则的第一步明确你的搜索语义在动笔之前还有一件事比区间定义更前置那就是搞清楚你要的“答案”到底是什么语义。是要找“值等于target的下标”还是“第一个不小于target的位置”还是“最后一个不大于target的位置”语义不同同一种区间定义写出来的代码也会不一样。我建议把常见语义整理成一张速查表这是我自己每次写二分之前都会在心里过的目标语义典型场景区间定义建议返回位置精确查找值等于target数组查值闭区间或半开区间均可找到返回下标找不到返回-1找第一个target的位置lower_bound半开区间最自然返回第一个满足条件的位置找第一个target的位置upper_bound半开区间最自然返回第一个大于target的位置找最后一个target的位置前缀边界查询闭区间或半开区间均可返回最后一个满足条件的位置有了这张表你写二分时的每一步“敲定”都有了依据而不是靠感觉。2. 核心细节解析边界、中点和循环不变量2.1 mid的取整方向与死循环的根源mid的计算看起来最简单left (right - left) / 2但取整方向其实大有讲究。C整数除法是向零取整对于非负数来说就是向下取整所以mid天然偏向左边。这个“偏向左边”在很多场景下没问题但在某些区间更新规则下会直接导致死循环。最经典的死循环场景是这样的当你维护[left, right)区间且right left 1时mid left (right - left) / 2算出来等于left。如果这时你的更新规则是“满足条件时left mid”那么区间就永远不会收缩死循环了。反过来如果你在某些场景下需要mid偏向右边就得写成mid left (right - left 1) / 2也就是向上取整。我记得有一道很经典的题——寻找左边界和右边界两种写法刚好对应这两种mid取整方向。找左边界时left mid 1、right mid的组合配合向下取整mid永远不会死循环。而找右边界时如果写left mid、right mid - 1就必须配合向上取整的mid否则在right left 1时直接卡死。关于这一点我特意做了一个小实验代码很简单但结果很能说明问题#include iostream #include vector int main() { std::vectorint arr {1, 2, 3, 4, 5}; int target 3; int left 0, right arr.size(); while (left right) { int mid left (right - left) / 2; if (arr[mid] target) { left mid 1; // 左边界收缩安全 } else { right mid; // 右边界收缩配合向下取整 } } std::cout 找到的位置: left std::endl; return 0; }这段代码是安全的。如果把left mid 1改成left mid把right mid改成right mid - 1再配合向下取整当区间缩到[2, 3)时就会原地打转。这就是mid取整方向与区间更新规则不匹配导致的经典死循环。2.2 循环不变量写对二分的唯一方法论聊二分细节不提循环不变量等于没聊。所谓循环不变量就是你在循环过程中始终保持的一个性质它决定了你每一步更新之后答案仍然在你维护的区间内。举个例子我写lower_bound找第一个大于等于target的位置时维护的不变量是left左侧的所有元素都严格小于targetright右侧的所有元素都大于等于target。初始时left 0左侧没有元素这个性质成立right n右侧没有元素也成立。每次循环比较arr[mid]与target如果arr[mid] target说明mid及其左侧都不能是答案所以left mid 1不变量依然成立否则right mid说明mid可能是答案但右边不会是第一个收缩右边界不变量依然成立。循环结束时left right整个搜索区间为空而答案就在left位置。看一旦把不变量写在纸上连代码都不需要猜边界的每一步都是逻辑推导出来的。我强烈建议所有被二分困扰过的人在写每一道二分题、每一段二分代码之前先在注释里把不变量写清楚。工程上这也有实际价值——别人review你代码的时候看到不变量注释一眼就能确认你的逻辑没缺陷。2.3 防溢出与性能取舍mid计算的工程细节mid (left right) / 2这种写法在面试里说说还行工程上我基本不用因为left right存在溢出风险。虽然很多场景下left和right都是数组下标int溢出需要数组大到离谱但一旦你写的是通用模板或者处理的是迭代器差值很大的场景溢出就变成真实风险了。所以我一律写成mid left (right - left) / 2把加法换成减法从根本上规避溢出。这个习惯成本极低但能把一类潜在bug直接消灭在源头。另外在性能敏感的场合有人会把除以2改成位运算 1但我个人建议除非你确实在写那种被压榨到极致的性能关键代码否则别这么做。现代编译器对除以常量的优化已经非常到位写成位运算反而降低可读性维护起来也更容易犯错。还有一种情况需要格外注意就是left (right - left) / 2在right - left得到的是size_t这类无符号类型时运算结果会变成无符号这可能带来意外行为。我通常在二分函数里显式把区间端点转成有符号类型比如int或ptrdiff_t宁可多写一行强转也不愿留一个不容易察觉的类型隐患。3. 实操过程从标准库到树状数组上二分的完整实现3.1 标准库的二分lower_bound与upper_bound的正确打开方式C标准库早就提供了现成的二分实现std::lower_bound、std::upper_bound、std::binary_search。这几个函数放在algorithm头文件里内部实现基本是教科书级别的二分。但很多人直接用它们时还是会出错原因在于没搞懂它们返回的迭代器语义。我用一个简单的例子说明#include iostream #include vector #include algorithm int main() { std::vectorint arr {1, 2, 2, 2, 3, 4, 5}; auto it1 std::lower_bound(arr.begin(), arr.end(), 2); auto it2 std::upper_bound(arr.begin(), arr.end(), 2); std::cout 第一个2的位置: (it1 - arr.begin()) std::endl; std::cout 第一个2的位置: (it2 - arr.begin()) std::endl; std::cout 等于2的元素个数: (it2 - it1) std::endl; return 0; }输出结果分别是1、4、3完全符合语义。这里能看到lower_bound和upper_bound联合起来能直接算出某个值在有序数组中的重复区间长度。这是它们最经典的配合玩法在统计频次、范围查询等场景特别实用。std::binary_search返回bool表示是否存在目标值但它内部就是调用lower_bound判断迭代器是否指向目标值并不比直接调用lower_bound更高效。所以我很少用binary_search因为它只告诉“有没有”不告诉“在哪里”大多数工程场景我们恰恰需要“在哪里”。3.2 手写二分的两个黄金模板虽然标准库很香但有些场景必须手写二分比如在复杂结构上二分、在自定义判断条件下二分。这里分享两个我实测下来最稳的模板一个用于“找左边界”一个用于“找右边界”都基于半开区间[left, right)和明确的循环不变量。先看第一个模板找第一个满足条件的位置条件用函数bool check(int idx)抽象。// 找第一个满足check的位置 // 不变量: left左侧全部不满足, right右侧全部满足 int binary_search_left(int left, int right, const std::functionbool(int) check) { while (left right) { int mid left (right - left) / 2; // 向下取整 if (check(mid)) { right mid; // mid可能是答案, 但答案不可能在mid右侧 } else { left mid 1; // mid不满足, 答案不可能在mid及左侧 } } return left; // 此时 left right, 就是第一个满足条件的位置 }这个模板的精髓在于check(mid)为true时收缩右边界到mid为false时收缩左边界到mid 1。用向下取整的mid配合left mid 1永远不会死循环因为每次循环区间长度至少减1。再看第二个模板找最后一个满足条件的位置。// 找最后一个满足check的位置 // 不变量: left左侧全部满足, right右侧全部不满足 int binary_search_right(int left, int right, const std::functionbool(int) check) { while (left right) { int mid left (right - left 1) / 2; // 向上取整 if (check(mid)) { left mid; // mid满足, 答案不可能在mid左侧 } else { right mid - 1; // mid不满足, 答案不可能在mid及右侧 } } return left; // 此时 left right, 就是最后一个满足条件的位置 }注意这里的mid用了向上取整这是关键。因为在right left 1时如果还用向下取整mid left一旦check(mid)为true就会执行left mid区间不收缩死循环。向上取整后mid right无论哪个分支都能让区间变短。这两个模板我建议直接背下来然后花十分钟分别用几个典型场景验证一下比如在{1, 2, 2, 2, 3}里找第一个2和最后一个2一旦确认逻辑没问题后面所有的二分题都可以往里套不用每次重新推导。3.3 进阶玩法树状数组上的二分装完基础模板再来点硬核的——树状数组上二分。树状数组Fenwick Tree常用来维护前缀和支持单点修改和前缀和查询。常规查询某个前缀和达成什么位置时需要O(log n)的二分套O(log n)的查询总复杂度O(log² n)。而树状数组上二分可以把这玩意儿压到O(log n)原理是利用树状数组的二进制结构直接倍增定位。做法是从最高位开始往下枚举维护一个pos表示当前已经确定的位置sum表示pos位置的前缀和。初始pos 0sum 0。从大到小枚举每一位从LOG - 1到0如果pos (1 k) n且sum tree[pos (1 k)] target就更新pos (1 k)、sum tree[pos]。循环结束后pos就是最后一个前缀和小于target的位置pos 1就是第一个前缀和大于等于target的位置。直接看代码更清楚#include iostream #include vector class Fenwick { public: explicit Fenwick(int n) : tree(n 1, 0), n(n) {} void add(int idx, int delta) { while (idx n) { tree[idx] delta; idx idx -idx; } } // 找最小的 idx 使得前缀和 target // 前提: 树状数组中所有元素非负, 前缀和单调不减 int lower_bound_prefix_sum(int target) { int pos 0; int sum 0; // LOG 取 20 足够覆盖 1e6 量级, 更大量级可以按需调整 for (int k 20; k 0; --k) { int next pos (1 k); if (next n sum tree[next] target) { sum tree[next]; pos next; } } return pos 1; } private: std::vectorint tree; int n; }; int main() { Fenwick fw(10); for (int i 1; i 5; i) { fw.add(i, i); // 位置i的值设为i } // 前缀和: 1, 3, 6, 10, 15 std::cout 第一个前缀和6的位置: fw.lower_bound_prefix_sum(6) std::endl; std::cout 第一个前缀和7的位置: fw.lower_bound_prefix_sum(7) std::endl; std::cout 第一个前缀和16的位置: fw.lower_bound_prefix_sum(16) std::endl; return 0; }这里add(i, i)是把位置i的值设为i前缀和依次是1、3、6、10、15。lower_bound_prefix_sum(6)返回3lower_bound_prefix_sum(7)返回4lower_bound_prefix_sum(16)返回11超出范围时返回n1。结果完全符合预期。注意前提条件树状数组里存的必须是单调不减的前缀和序列也就是所有单点值非负。如果存在负值前缀和不单调这个倍增法直接失效。这一点是树状数组上二分最大的约束工程上遇到带负权值的需求时得换线段树加二分或者用其他数据结构。3.4 浮点数二分的注意事项除了整数二分浮点数二分也经常被忽视。浮点数二分没有“死循环”问题因为循环条件一般是right - left eps但它的坑在精度控制上。eps设太大结果不够精确设太小循环次数暴增甚至可能因为浮点精度问题永远达不到条件而死循环。我的一般做法是不把eps设成固定值而是设定固定的迭代次数比如100次。100次二分可以把一个长度1的区间压缩到2的-100次方对绝大多数浮点场景都绰绰有余而且完全规避了eps设置不当导致的死循环。double binary_search_float(double left, double right, const std::functiondouble(double) func) { for (int i 0; i 100; i) { double mid left (right - left) / 2.0; if (func(mid) 0) { right mid; } else { left mid; } } return left; }循环100次而不是用while (right - left eps)是我从实际项目中总结出来的一个细节它让代码行为完全可预测不会因为目标函数的性质导致循环次数失控。代价只是多跑几十次迭代在浮点二分场景下这点开销完全可以忽略。4. 常见问题与排查技巧实录4.1 死循环的快速定位与修复方法写二分最常遇见的错误就是死循环。程序卡住不动CtrlC中断后发现卡在while循环里。快速定位的思路其实很固定把区间长度变化过程打印出来或者直接在循环里输出left、right、mid三个值。我用过一个很笨但有效的方法在while循环里加打印如果某次迭代left和right都没变化说明区间没有收缩死循环的根源就在这一步。然后检查两个地方一是mid的取整方向二是区间更新时哪个分支没有让区间长度减少。按照我的经验90%的死循环都是“向下取整的mid配合了left mid”或“向上取整的mid配合了right mid”这两对组合全是雷。4.2 答案差一位边界返回值永远要用真实案例验证二分返回值的差一问题比死循环隐蔽得多因为程序能跑完结果却不对。我自己踩过的坑主要出在“答案在左边界还是右边界”的语义混淆。比如找第一个大于等于target的位置有人会返回left有人会返回right在循环结束时left right理论上一样但如果循环条件或区间更新写错了这俩就可能差1。我的排查方法是写一个简单的有序数组把每个可能的目标值都测一遍用assert断言结果和标准库lower_bound一致。像我这种习惯用半开区间的人几乎每写一段手写二分都会顺手加一个对照标准库的验证用例。别嫌麻烦二分这东西太容易被细节坑了能自动验证就自动验证。4.3 二分答案应用中的check函数设计陷阱二分的应用不只在数组里找值还有一大类叫“二分答案”——在一个单调的可行性函数上二分找到满足条件的边界值。比如“最小化最大值”问题就是二分化可行性。这种场景下真正的细节难点不再是二分本身而是check函数的实现。我踩过的一个典型坑是check函数里忘了恢复修改的状态。比如某道题需要dfs验证某种放置方案是否可行dfs过程会修改全局状态如果check返回false后没有恢复状态下一次check就全乱了。排查这类bug特别痛苦因为问题不在二分模板里而在check函数的副作用上。我的建议是设计check函数时坚持一个原则——要么没有副作用要么所有修改必须在使用完后恢复原状。最好把check写成纯函数只返回true或false不改变任何外部状态。4.4 调试技巧把二分过程可视化除了打印中间变量还有一个很高效的调试技巧用树状图把二分区间收缩过程画出来。我一般会把left、right、mid以及当前比较结果记录在一个列表里跑完之后对着列表手工推演一遍定位具体是哪一步的区间更新逻辑出了问题。在工程上我建议把二分核心逻辑封装成一个独立的函数然后写一个小的测试driver用随机数据自动验证。比如生成一个随机有序数组随机选target同时跑手写二分和标准库lower_bound结果不一致就打印现场数据。这个driver我几乎每个二分项目都会写不超过20行代码但能省下无数排查时间。5. 二分细节敲定的工程哲学与习惯养成回头再看“C二分细节敲定规则”这件事技术点其实就那么多区间定义、mid取整、循环不变量、边界返回值、check函数设计。但真正让一个人二分水平质变的不是记住某个模板而是养成一套敲定规则的思维习惯。我自己现在的习惯是拿到任何二分需求先花一分钟在纸上写清楚三件事。第一我要找的答案语义是什么是“第一个满足”还是“最后一个满足”还是“精确等于”。第二我准备维护什么区间是半开区间还是闭区间选定后整个过程不再切换。第三我的循环不变量是什么每次更新后答案还在不在区间内。这三件事敲定之后代码怎么写都是水到渠成的事。如果你现在还在被二分细节折磨建议别急着刷题先按这个思路把基础模板推演一遍再用几个经典题目验证相信我一旦捋顺这个流程二分会成为你最有把握的算法之一。最后再分享一个小技巧写二分时给变量起名字要带语义。别用l、r这种缩写用left、right条件允许的话用first_ok、first_bad这种带语义的名字。工程上的二分代码可读性往往比那点性能重要得多清晰的变量名会让边界讨论变得直观很多。这个习惯我坚持了好几年收益远超想象。

相关新闻

2026/9/8 12:28:16

YOLOv8网络结构深度拆解:从C2f到注意力机制改进实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/8 12:28:16

个人免费AI编程软件怎么选?从额度到本地的实战评测

先把结论放在前面:作为一个每天跟代码纠缠超过八小时的人,我在“个人免费AI编程软件”这个关键词上花的测试时间,比很多朋友实际写项目的时间都长。原因很简单,市面上的AI编程工具从浏览器插件、IDE插件到独立编辑器,从…

2026/9/8 12:28:16

opencode实战指南:从安装配置到Skills与Agent工作流

opencode我用了三个多月,从最初的命令行尝鲜,到后来把日常开发流程整个迁过来,算是把这条路趟得比较熟了。如果你最近在关注AI编程助手,大概率会频繁看到这个名字——它和Codex、Claude Code这类工具一样,都属于终端里…

2026/9/8 13:28:23

Python unittest从入门到实践:用标准库搭建稳健的单元测试体系

我见过太多 Python 项目,上线前跑得欢,一上线就出幺蛾子。原因大多不是功能没写对,而是没人敢保证改 A 模块不会弄坏 B 模块。今天聊的 Python 官方自带测试框架 unittest,就是帮你把基础功能钉死的那颗钉子。它是标准库成员&…

2026/9/8 13:28:23

Fedora上为RISC-V交叉编译FFmpeg的完整实践指南

1. 为什么要为 RISC-V 交叉编译 FFmpeg先交代一下背景。手头有一块 RISC-V 开发板,装了 Linux,想在上面做视频处理,但板子的性能和存储空间都有限。直接用板子编译 FFmpeg 不是不行,只是整个过程会让人崩溃:源码体积大…

2026/9/8 13:28:23

FPGA学习路线图:从数字逻辑到高速接口的完整工程实践指南

最近有个之前带过的师弟来问我FPGA到底怎么学,说看了不少视频教程,代码也照着敲了,但一到自己写项目就卡壳,面试更是心里没底。这问题我见过太多次了。FPGA这个行当,资料确实多,但九成都是零散的知识点&…

2026/9/8 13:28:23

TensorFlow Lite Android 图像分类实战:从模型选型到性能优化

简介:面向需要在Android端快速落地图像分类功能的开发者,这是一套基于TensorFlow Lite的完整示例工程。资源共包含110个文件,核心由Java源码、tflite模型、xml布局与配置、gradle构建脚本以及若干图片资源组成,其中xml负责页面与资…

2026/9/8 13:28:23

Java单元测试太难?飞算JavaAI测试生成器自动生成JUnit/Mockito用例

1. 先聊聊Java新手写单元测试这件事我做了这么多年Java开发,带过不少新人,也面试过不少人,发现一个特别普遍的现象:很多Java新手写业务代码挺溜,增删改查信手拈来,但一提到写单元测试就头大。要么干脆不写&…

2026/9/8 13:23:21

GPU利用率低下?从调度顺序优化入手,不买卡也能提升训练吞吐

GPU 采购单越堆越长,账单上的数字越来越吓人,但模型的训练时长却纹丝不动——这种荒诞感我太熟悉了。过去半年里我接手过好几个团队的项目,诊断到最后,绝大多数性能瓶颈都不在算力总量,而在调度顺序。GPU 数量从来不是…

2026/9/8 7:15:10

超人会飞不算本事:系统稳定依赖清晰规则与边界设计

开头先不绕弯子。“#斯坦李吐槽dc 所以超人是无缘无故会飞的嘛哈哈哈哈哈哈哈锤哥真是技术人才啊!#雷神 #复联”这类调侃式短标题,第一波冲击力在于它把两个宇宙的角色塞进同一个吐槽箱里,但细想一下就能发现,它真正碰到的根本不是…

2026/9/8 7:15:15

超人VS蜘蛛侠:拆解超级IP的影响力与传播方法论

把“蜘蛛侠 vs 超人”放在 CSDN 上聊,可能很多人第一反应是走错片场了。但如果把这两个角色看成“两个持续运营了 80 多年的文化产品”,你会发现,这场比较本质上是两个不同 IP 策略的长期结果对比:超人赢在定义了整个超级英雄题材…

2026/9/8 7:15:10

基于CNN的调制信号识别:MATLAB实现时频图分类实战

简介:本资源是一套面向通信工程与信号处理方向学习者、研究者的深度学习实践方案,聚焦调制信号自动检测与识别这一典型无线通信任务,解决传统方法依赖人工特征、低信噪比下性能下降等痛点。压缩包共12个文件(10.73MB)&…

2026/9/8 0:01:49

踩多轮坑才跑通|OpenClaw 3.1.0 双平台本地 AI 自动化搭建实操实录

🔹 工具简述 OpenClaw 是一款备受开发者与办公人群青睐的开源本地智能工具,凭借离线本地运行、可视化图形面板、全流程自主任务处理三大核心特点,积累了众多忠实用户。与普通对话类 AI 产品不同,它能够直接调用电脑的软硬件操作权…

2026/9/8 0:01:50

拒绝复杂命令行,Hermes Agent 一键包快速解锁智能办公能力

🔍前言 不少想要体验 Hermes Agent 办公能力的使用者,往往会被复杂的环境配置拦住使用脚步。手动下载匹配依赖、反复调整系统目录、处理命令行持续报错、修复权限异常、补全丢失核心文件等一系列操作,对普通使用者而言门槛较高,很…

2026/9/7 16:23:03

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/7 22:46:00

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/7 22:45:59

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…