1. 先把“二分法”说清楚:它到底是查找还是求根
“二分法”这四个字在中文技术语境里其实背了两个身份。一个是数据结构课上讲的二分查找,也叫折半查找,在有序序列里定位目标值;另一个是数值分析里的二分法求根,靠不断把包含根的区间对折,逼出方程的近似解。两者思想内核完全一致——每一步把候选范围砍掉一半,问题规模以指数速度缩小;但落到具体代码上,它们踩的坑完全是两套东西,混着记最容易出事。
我自己带的几个新人,几乎都在同一个地方栽过跟头:记住了“每次取中间”,但没记住“边界怎么收缩才不出错”。结果就是二分查找写出死循环,或者 NTC 查表算出来的温度差个两三度,回头一看,明明是二分找对了区间,插值那一步偷懒了。所以这篇东西我不打算只讲那段十几行的模板代码,而是把二分法拆成三条线:数组上的定位、传感器查表上的定位加插值、以及连续函数上的求根。三条线共用一套思路,但注意事项完全不同。
这篇文章适合谁看?如果你是刚开始刷算法题的学生,2 到 3 节能帮你把边界问题一次性理清;如果你是在做嵌入式固件、要处理 NTC 热敏电阻查表的工程师,第 3 节基本就是给你写的;如果你在做数值计算,4 节会把迭代次数和终止条件讲透。全文所有代码都可以直接抄下来跑,参数我会给出计算过程,不是拍脑袋写的。
1.1 两个同名东西,一个砍区间一个砍范围
先把概念分开。二分查找处理的是离散问题:我有一个长度为 n 的有序数组,想找出某个值在不在里面,或者找到它应该插入的位置。它的操作对象是下标,每次比较a[mid]和目标值,然后决定保留左半边还是右半边。区间是离散的整数下标,收缩方式直接影响正确性。
二分法求根处理的是连续问题:我有一个连续函数 f(x),知道 f(a) 和 f(b) 异号,想找一个 x 使得 f(x) 接近 0。它的操作对象是实数区间,每次取中点 m,看 f(m) 的符号,然后把区间换成 [a, m] 或 [m, b]。这里没有“下标”概念,只有区间长度,终止条件是区间足够小。
你会发现,两者的抽象结构其实是同一个:一个满足单调性的判定条件 + 一个待收缩的范围。二分查找里,判定条件可以写成“a[i] 是否大于等于目标值”,这个命题关于 i 是单调的(false 一段、true 一段);二分求根里,判定条件是“f(m) 与 f(a) 是否同号”。只要你的问题能写成这种单调命题,就能用二分。这个视角很重要,后面讲“二分答案”和“二分定位 NTC 区间”都是它的直接推论。
区分清楚的好处是:写代码时你会先问自己一句“我维护的到底是什么”——是不变量、是区间端点、还是下标。这一步想明白了,代码基本不会错。
1.2 为什么它总是快:从“折半”到 log 复杂度
二分法的效率来自一个非常朴素的事实:2 的 10 次方是 1024,2 的 20 次方大约是一百万。也就是说,数据量涨一千倍,步数只涨 10 次。
| 数据规模 n | 线性查找最坏比较次数 | 二分查找最坏比较次数 |
|---|---|---|
| 100 | 100 | 7 |
| 10,000 | 10,000 | 14 |
| 1,000,000 | 1,000,000 | 20 |
| 100,000,000 | 100,000,000 | 27 |
这张表看着平平无奇,但在嵌入式场景里它意味着很实际的东西。比如一张 NTC 阻温表有 200 个点,线性查表平均要比较 100 次,二分只要 8 次左右。如果你的 MCU 主频只有 24MHz,而且这段代码放在 1ms 定时中断里跑,省下来的时间是真金白银。更关键的是,当你想把表做细(比如从 5 度一个点细化到 1 度一个点)时,线性查表的耗时跟着涨,二分的耗时几乎不动——这对后期改需求特别友好。
提示:二分法的前提是序列有序且单调。只要数据有序,无论它是整数、浮点数、结构体数组还是字符串数组,二分都成立。前提被破坏,二分的正确性就没了,这是所有坑的根源。
1.3 什么时候不能用:单调性这道门槛
我见过最常见的误用,是拿一段本身不单调的数据去二分。比如某些传感器标定表,是用实测数据拼出来的,中间因为测量误差出现了小幅回弹——本来应该是 25 度对应 10.0k,结果 25.2 度记成了 10.02k。这种数据你直接拿去二分查找,结果会非常随机:有时候正确,有时候偏一格,而且越是边界附近越容易错。
判断标准很简单:把你要比较的那个字段按顺序打印出来,肉眼确认严格单调。严格单调的意思是相邻两个值要么一直增、要么一直减,不能出现相等(除非你的查找逻辑专门处理相等),更不能反向。NTC 的阻温表天然满足电阻随温度单调递减,如果实测数据破坏了这一条,正确做法是先做单调化处理(比如排序、或者用平滑拟合重新生成表),而不是硬着头皮上二分。
第二类不能用的情况是:判定函数不是单调的。比如你想在数组里找“最接近目标值的元素”,这个目标是可以用二分的,但你不能直接二分“距离”,因为距离的序列是先降后升的(单峰函数),这时候要用三分法或者先二分找谷底。很多新手把单峰当单调,写出来的代码在远离峰值的地方是对的,靠近峰值就开始飘。
2. 二分查找的边界问题:90% 的 bug 都出在这里
如果让我统计自己写过的二分 bug,九成以上集中在一行代码上:mid怎么算、lo和hi怎么更新、循环条件是<还是<=。这三个决定必须成套地选,不能随便组合。下面的写法我用了很多年,先在纸上把不变量写清楚,再动键盘,基本零失误。
2.1 闭区间与左闭右开:两种写法的取舍
第一种写法是闭区间[lo, hi],循环条件while (lo <= hi),区间内始终存在待检查的元素。当lo == hi时区间里还有一个元素,必须再进循环检查一次。
int binary_search(const int *a, int n, int target) { int lo = 0, hi = n - 1; /* 闭区间 [lo, hi] */ while (lo <= hi) { int mid = lo + (hi - lo) / 2; if (a[mid] == target) return mid; if (a[mid] < target) lo = mid + 1; /* 目标在右半边 */ else hi = mid - 1; /* 目标在左半边 */ } return -1; }第二种写法是左闭右开[lo, hi),循环条件while (lo < hi),hi指向“最后一个候选元素的下一位”。这种写法的好处是hi - lo直接就是候选个数,收缩逻辑更统一,很多标准库(比如 C++ 的 lower_bound)就是这套。
int lower_bound(const int *a, int n, int target) { int lo = 0, hi = n; /* 左闭右开 [lo, hi) */ while (lo < hi) { int mid = lo + (hi - lo) / 2; if (a[mid] < target) lo = mid + 1; else hi = mid; } return lo; /* 第一个 >= target 的位置,可能等于 n */ }选哪种?我的建议是:只查“在不在”用闭区间,查“第一个满足条件的”用左闭右开。因为后者返回的位置本身就是答案,不需要额外判断,代码更干净。
注意:绝对不要混搭,比如用左闭右开的初始化配
lo <= hi的循环条件。这类代码在 n 比较小的时候往往表现得“像是对的”,等数据量上来或者目标值落在边界上时才炸,调试成本极高。
2.2 mid 的取整方向决定了会不会死循环
mid的计算里有两个细节,任何一个写错都会出事。
第一个是溢出。教科书上的(lo + hi) / 2在 32 位有符号整数下,当lo和hi都接近 2^31 时会溢出成负数,进而导致数组越界。正确写法是lo + (hi - lo) / 2。这个坑在算法竞赛里出过大事故——某个知名库的标准实现就因为这个问题在特定输入下崩溃。写业务代码时你可能觉得“数组哪有那么大”,但你没法保证lo和hi永远不来自外部算出来的值。
第二个是取整方向。C 语言里整数除法是向零取整,所以lo + (hi - lo) / 2得到的是偏左的中点。这个“偏左”在某些场景下会引发死循环:
/* 危险写法:寻找最后一个满足条件的元素 */ while (lo < hi) { int mid = lo + (hi - lo) / 2; /* 偏左 */ if (ok(mid)) lo = mid; /* 死循环风险! */ else hi = mid - 1; }问题出在lo = mid这一步。当区间只剩两个元素时,hi == lo + 1,mid算出来等于lo,如果ok(mid)成立,lo被赋值为它自己,区间再也不会缩小。修法有两种:把mid改成偏右,lo + (hi - lo + 1) / 2;或者把更新写成lo = mid + 1并配合别的不变量。我个人的习惯是——凡是会出现lo = mid的地方,mid 一律取偏右,这比在脑子里推十遍不变量可靠得多。
| 更新方式 | mid 取整方向 | 是否安全 |
|---|---|---|
lo = mid + 1/hi = mid - 1 | 偏左偏右都行 | 安全 |
lo = mid/hi = mid - 1 | 必须偏右 | 偏左会死循环 |
lo = mid + 1/hi = mid | 必须偏左 | 偏右会死循环 |
这张表建议直接记住。写代码时先看更新语句里有没有= mid这种“不收缩端点”的写法,有的话就去检查 mid 的取整方向。
2.3 三种变体:找第一个、找最后一个、找插入位置
实际工程里,纯粹“找等于”的需求反而不多,更多的是这几种变体。我把它们整理成对照,全是左闭右开的写法,可以直接用。
| 需求 | 返回语义 | 关键判断 |
|---|---|---|
| 第一个 >= target | lower_bound | a[mid] < target时lo = mid + 1 |
| 第一个 > target | upper_bound | a[mid] <= target时lo = mid + 1 |
| 最后一个 <= target | upper_bound - 1 | 复用上面结果再减一 |
| 最后一个 < target | lower_bound - 1 | 复用上面结果再减一 |
注意后两行:不要另写一套循环,直接用lower_bound和upper_bound的结果加减一即可。这样代码量少、测试覆盖集中,出 bug 的概率大幅下降。如果减法结果可能是 -1,说明目标值比数组里所有元素都小,调用方要处理这个边界。
一个典型应用是区间统计:数组里有若干重复值,想数出值等于 target 的元素个数,直接upper_bound - lower_bound,O(log n) 搞定,不用扫一遍。
2.4 二分答案:把二分用在判定函数上
这是我个人觉得二分法最漂亮的一层抽象。有些问题不是“在数组里找东西”,而是“求一个最小的数 x,使得某个条件成立”。只要条件关于 x 单调(x 越大越容易满足,或者反过来),就可以对 x 的取值范围做二分。
举个贴近生活的例子:你要把一批长度不同的木料切成若干段等长的小段,问最大能切多长。这就是经典的“二分答案”题。思路是:猜一个长度 L,写一个判定函数数一数能切出几段,如果段数够就说明 L 可以再大一点,不够就说明 L 太大。判定函数本身是 O(n) 的,但外层只用了 O(log(范围)) 次,整体效率相当可观。
写这类代码的关键是把判定函数写对,然后套用lower_bound的模板,注意返回值到底是“最后一个满足”还是“第一个满足”。我踩过的坑是把判定写反了,导致二分总是收敛到区间的端点,而且表面上输出还挺“合理”,不容易发现。
3. NTC 查表为什么“二分法不准”:问题不在二分,在插值
这一节是给做硬件的朋友准备的。网上搜“ntc查表 二分法不准”,会看到一堆人抱怨二分查找定位出来的温度偏差大,然后有人建议改回线性查找。这个建议本身就很说明问题——如果线性查找“准”而二分查找“不准”,那说明两者的结果根本不同,也就是二分找错了位置。而如果两者结果相同,那偏差的来源就与查找方式无关,而是查表之外的环节。下面把这件事拆开讲。
3.1 阻温表为什么能二分:单调性从哪来
NTC 是负温度系数热敏电阻,温度升高、电阻下降。它的阻温关系可以用 B 值公式近似描述:
R(T) = R25 × exp(B × (1/T − 1/T25))
其中 R25 是 25 摄氏度时的标称阻值(比如 10k),B 是材料常数(比如 3435K),T 是绝对温度。这个函数在正常温度区间内是严格单调递减的,所以用电阻去查表,天然满足二分的前提。这也是为什么几乎所有 NTC 驱动都用二分——表一般是按温度均匀步长(比如每 1 度或每 5 度一个点)生成的,对应下来就是电阻从大到小排列。
提示:如果你的表是按温度升序排列的,那么电阻就是降序;用
lower_bound那套模板时,比较方向要反过来。这是最容易写错的地方之一,建议在表生成脚本里就把顺序统一固定,别让不同项目各写各的。
3.2 “二分法不准”的锅到底该谁背:误差拆解表
我把实际项目中遇到的偏差来源列成一张表,你可以对照着自己排查。注意误差是一层层叠加的,单看某一项可能都不大,加起来就明显了。
| 误差来源 | 典型量级 | 是否与二分有关 |
|---|---|---|
| 只取最近表点、不做插值 | 半个步长,5 度表约 2.5 度 | 无关,是插值缺失 |
| 表步长过粗(如 10 度一点) | 最大 5 度 | 无关,是表设计问题 |
| 表由 B 值公式生成,与实物有偏差 | 0.5 到 2 度 | 无关,是标定问题 |
| 电阻测量误差(分压电阻精度、ADC 参考) | 0.3 到 1 度 | 无关 |
| 阻值计算的截断误差 | 视实现,可能到 1 度 | 有关,见下 |
| 二分定位写错,落到了相邻区间 | 一个步长 | 有关 |
| NTC 自热效应 | 0.1 到 0.5 度 | 无关 |
看这张表就清楚了:绝大多数“不准”根本不来自二分本身,而是来自“二分只负责定位,定位完不插值”。二分查找返回的是一个下标,如果你直接拿这个下标对应的温度当结果,误差上限就是表步长的一半。5 度一个点的表,最坏情况就是 2.5 度的偏差——注意,这个偏差用线性查找也一样存在,甚至可能更大,因为线性查找通常会取“第一个小于等于”的位置,方向固定后误差就单边化了。
真正与二分有关的误差来源只有两类。一类是“阻值计算截断”:有些实现在比较时把电阻转成了uint16_t或者做了整型除法,导致边界附近匹配到了错误的区间。另一类是“表顺序搞反”:温度升序的表用了假设降序的模板,返回的下标完全错位,但这种错误通常表现为大面积偏差,而不是零点几度的小偏差,比较好识别。
3.3 正确姿势:二分定位 + 线性插值(C 代码)
核心思路是:二分找到阻值所在的那一段,然后用线性插值算出段内的温度。为什么用线性插值而不是二次插值?因为在 1 到 5 度的窄区间内,阻温曲线足够平滑,线性插值的残差远小于 ADC 本身的量化误差,为它引入更复杂的计算不值当。这是一个典型的工程权衡:精度提升有限,但代码复杂度和 CPU 开销上升明显。
下面是我在 Cortex-M 系列上常用的实现。表按电阻降序(即温度升序)排列,阻值单位毫欧,温度单位毫摄氏度,全部走整型,避免在没有 FPU 的芯片上引入浮点运算。
#include <stdint.h> typedef struct { int32_t res_mohm; /* 电阻,单位毫欧,随温度升高递减 */ int32_t temp_mc; /* 温度,单位毫摄氏度 */ } ntc_pt_t; /* 表由脚本生成,按 res_mohm 降序排列(等于温度升序) */ extern const ntc_pt_t ntc_tab[]; extern const int ntc_tab_len; /* 成功返回 0;阻值超出表范围返回 -1 */ int ntc_res_to_temp(int32_t r_mohm, int32_t *out_temp_mc) { int lo = 0, hi = ntc_tab_len - 1; if (r_mohm > ntc_tab[lo].res_mohm || r_mohm < ntc_tab[hi].res_mohm) return -1; /* 超出表的覆盖范围,交给上层处理 */ /* 逆序表:找最后一个 res >= r 的下标 */ while (lo < hi) { int mid = lo + (hi - lo + 1) / 2; /* 偏右,防止死循环 */ if (ntc_tab[mid].res_mohm >= r_mohm) lo = mid; else hi = mid - 1; } if (lo >= ntc_tab_len - 1) { /* 正好落在最末一点 */ *out_temp_mc = ntc_tab[lo].temp_mc; return 0; } int32_t r0 = ntc_tab[lo].res_mohm; int32_t r1 = ntc_tab[lo + 1].res_mohm; int32_t t0 = ntc_tab[lo].temp_mc; int32_t t1 = ntc_tab[lo + 1].temp_mc; /* 线性插值:t = t0 + (r - r0) * (t1 - t0) / (r1 - r0) * 因为 r0 > r >= r1,分子分母均为负,结果为正的偏移量。 * 乘法用 int64 接住,防止 32 位溢出。 */ *out_temp_mc = t0 + (int32_t)(((int64_t)(r_mohm - r0) * (t1 - t0)) / (r1 - r0)); return 0; }几个细节值得单独说。第一,mid用的是偏右版本,因为更新语句里有lo = mid,这个组合前面表格里说过必须配套。第二,比较用的是>=,保证相等时停在靠低温侧的点,插值分母不会为零——因为表是严格单调的,r0 == r1不可能出现,但如果你的表里真有重复点,这里会除零,必须在生成表的时候查一遍。第三,插值的乘法用了int64_t,(r_mohm - r0)可能是几万,(t1 - t0)是 1000 到 5000(毫摄氏度),两者相乘轻松超过 32 位,这个溢出坑我在一个量产项目上真的遇到过,表现是高温段温度算出来是负数。
如果你用的是带 FPU 的芯片,直接写浮点版本也完全可以,代码更短:
*out_temp_c = t0 + (r - r0) * (t1 - t0) / (r1 - r0);但要注意编译器优化等级和 FPU 开关,别在中断里做浮点除法——有些老芯片的浮点运算是软件模拟的,一次除法几百个周期,放在高频中断里会拖垮实时性。
3.4 表数据本身的坑:步长、来源、端点
插值补上之后,剩下的精度问题基本都在表数据本身上。我把常见的几个坑列一下。
步长不是越细越好。一张 200 点的表和一张 20 点的表,在 -20 到 100 度这个区间里,用线性插值的最大残差差别很小,因为曲线足够平滑。真正决定精度的是 ADC 的分辨率。如果你用的是 10 位 ADC、3.3V 参考、10k 分压电阻,单个 LSB 对应的阻值变化在某些温区就能引起 0.5 度以上的误差,那表做到 0.1 度一个点也没意义。合理的做法是:先算清 ADC 量化误差对应的温度误差,再决定表步长。
表的来源要统一。用 B 值公式生成表最简单,但 B 值公式本身在宽温区(比如低于 -20 度或高于 80 度)偏差会变大,有些厂家会给多个 B 值分段拟合。如果你的项目对全温区精度有要求,应该用厂家提供的阻温表数据,或者自己做标定,而不是用单一 B 值生成。这一点非常关键,我见过一个项目在常温下表现完美,到了零下就开始飘,最后查出来就是 B 值公式在低温端失真。
分压电路的非线性要考虑。电阻转电压是 R/(R + R_ref) 的形式,不是线性的。如果你的代码是把 ADC 值直接当电阻去查表,那就引入了额外的误差。正确做法是先按分压公式反算电阻,再查表。这个公式在代码里就是一行,但漏掉它的人不少。
端点处理要明确。阻值超出表的上下限时,是报错、钳位到端点、还是外推?三种策略都有人用,但必须明确选一个并且写进注释。我一般选“超出范围返回错误码”,让上层去做故障保护,因为探头开路或者短路导致的极端阻值,钳位后给一个看似合理的温度,反而会让保护逻辑失效。
4. 二分法求方程根:数值版的二分法
换到连续函数这条线,二分法的形式变了但思想没变。这一节讲清楚前提、迭代次数怎么算、以及它和牛顿法的取舍。
4.1 前提与边界:连续 + 异号
二分求根需要两个条件:函数在区间上连续,以及两个端点的函数值异号。由介值定理,区间内至少有一个根。如果端点同号,区间内可能有两个根、也可能一个都没有,二分法直接失效——它会朝一个“看起来在收敛”的方向走,最后停在一个根本不是根的地方。
def bisect_root(f, a, b, eps=1e-9, max_iter=200): fa, fb = f(a), f(b) if fa == 0.0: return a if fb == 0.0: return b if (fa > 0) == (fb > 0): # 同号,不要用 fa*fb>0 判断 raise ValueError("端点同号,区间内不保证有根") for _ in range(max_iter): m = 0.5 * (a + b) fm = f(m) if fm == 0.0 or (b - a) < eps: return m if (fa > 0) == (fm > 0): a, fa = m, fm # 根在 [m, b] else: b, fb = m, fm # 根在 [a, m] return 0.5 * (a + b)注意:符号判断不要写成
fa * fb < 0。当函数值特别小或者特别大的时候,两个数相乘可能下溢到 0 或者溢出到无穷,判断会出错。用比较正负号的方式更稳妥。
还有个实操细节:max_iter一定要设。如果 eps 设得比浮点精度还小(比如对 double 设 1e-18),区间长度在达到 2^-53 附近就再也降不下去了,中间值m会和a或b相等,循环永远不结束。加上迭代上限,最坏情况也能退出。
4.2 迭代次数怎么算
区间长度每次减半,n 次迭代后长度是 (b − a) / 2^n。要让它小于 eps,求 n:
n ≥ log2((b − a) / eps)
举个例子,区间 [0, 2],要求精度 1e-6。代入:log2(2 / 1e-6) = log2(2 × 10^6) ≈ 20.93,向上取整得到 21 次。
| 区间长度 | 目标精度 | 所需迭代次数 |
|---|---|---|
| 2 | 1e-3 | 11 |
| 2 | 1e-6 | 21 |
| 2 | 1e-9 | 31 |
| 100 | 1e-6 | 27 |
这些数字说明一件事:二分法对精度的需求非常“便宜”。从 1e-3 提到 1e-9,只要多迭代 20 次。所以在实际使用中,与其纠结精度设多少,不如把 max_iter 设成理论值加个十来次的余量,让它自然收敛。
4.3 和牛顿迭代的取舍
二分法收敛速度是线性的(每次误差减半),牛顿法是二阶的(每次有效位数翻倍)。理论上牛顿法快得多,十几次就能到机器精度。但牛顿法要求能算导数,而且对初值敏感:初值选得不好,可能发散或者跳到另一个根上。
| 对比项 | 二分法 | 牛顿法 |
|---|---|---|
| 收敛速度 | 线性 | 二阶 |
| 是否需要导数 | 不需要 | 需要 |
| 初值要求 | 只要求端点异号 | 要求足够接近根 |
| 稳定性 | 只要前提满足,必然收敛 | 可能发散 |
| 单次迭代开销 | 一次函数求值 | 一次函数值加一次导数值 |
我的选择习惯是:能用二分就用二分。工程代码里函数的导数往往没法解析求出来,只能数值差分,那还不如直接二分。只有在性能极其敏感、且导数解析可求的场景下,我才会考虑牛顿法,而且会加一个兜底——牛顿法迭代若干次不收敛就退回二分。
4.4 精度陷阱:浮点与终止条件
有一个容易被忽略的点:终止条件用区间长度(b - a) < eps和用函数值abs(f(m)) < eps是两个不同的东西。前者保证的是“x 的精度”,后者保证的是“y 的精度”。如果你的函数在根附近非常平坦(导数接近 0),这两个条件会差很多——区间缩得很小了,函数值可能还是不小;反过来,函数值很小了,x 可能离真根还有一段距离。
我的做法是两个条件都写,用“或”连接,并且额外限制迭代次数。这样无论函数在根附近是陡还是平,都能及时退出,而且结果不会太离谱。如果你的场景对 x 的精度有明确要求(比如要定位一个临界温度),那就以前者为准;如果对 y 的精度有要求(比如要找一个使误差项为零的参数),那就以后者为准。
5. 实操落地:从伪代码到能跑的代码
理论讲完,来点能直接跑的东西。这一节给两套代码和一个自测清单,都是我在实际项目里用过的。
5.1 整型版的 NTC 查表与测试
前面给过 C 版本的核心函数,这里补一个用 Python 生成表的脚本,把表直接写成 C 数组,省去手工抄写出错的可能。B 值公式本身很简单,关键是别用浮点常量在 C 代码里现场算,那样既慢又占代码空间。
import math R25 = 10000.0 # 25 摄氏度标称阻值 B = 3435.0 # 材料常数 T25 = 298.15 # 25 摄氏度,绝对温度 def res_at(temp_c): t = temp_c + 273.15 return R25 * math.exp(B * (1.0 / t - 1.0 / T25)) # 生成 -40 到 125 度,每 5 度一个点,按电阻降序排列 pts = [] for tc in range(125, -41, -5): pts.append((int(round(res_at(tc) * 1000)), int(round(tc * 1000)))) print("const ntc_pt_t ntc_tab[] = {") for r, t in pts: print(" { %d, %d }," % (r, t)) print("};") print("const int ntc_tab_len = %d;" % len(pts))生成出来的表是 34 个点,电阻从 125 度的几百欧到 -40 度的两百多千欧,跨度两个数量级。这个跨度提醒我们:比较时用整型毫欧是必要的,如果用欧姆为单位做整数,低温端的精度会被截断影响。
5.2 对拍测试:用暴力法验证二分
写二分最容易犯的错是“看起来对”。我的验证方法是写一个暴力版本,然后随机造数据对拍。暴力版本就是遍历所有相邻区间,找到目标所在的那一段,然后插值。两者结果必须在容差内一致。
import random from bisect import bisect_right def make_tab(): tab = [] for tc in range(125, -41, -5): t = tc + 273.15 r = 10000.0 * math.exp(3435.0 * (1.0 / t - 1.0 / 298.15)) tab.append((r, float(tc))) tab.reverse() # 按电阻升序(即温度降序) return tab def fast(r, tab): res_list = [p[0] for p in tab] if r > res_list[-1] or r < res_list[0]: return None i = bisect_right(res_list, r) - 1 if i >= len(tab) - 1: return tab[-1][1] r0, t0 = tab[i] r1, t1 = tab[i + 1] return t0 + (r - r0) * (t1 - t0) / (r1 - r0) def slow(r, tab): for i in range(len(tab) - 1): r0, t0 = tab[i] r1, t1 = tab[i + 1] if r0 <= r <= r1: return t0 + (r - r0) * (t1 - t0) / (r1 - r0) return None tab = make_tab() lo, hi = tab[0][0], tab[-1][0] worst = 0.0 for _ in range(200000): r = random.uniform(lo, hi) a, b = fast(r, tab), slow(r, tab) worst = max(worst, abs(a - b)) print("最大偏差:", worst)跑下来最大偏差应该是 0 或者 1e-12 量级,说明二分加插值和暴力遍历完全等价。如果出现明显的偏差,那就说明二分定位的边界逻辑有问题,回头去核对bisect_right的语义和表的排序方向。
提示:对拍测试要专门赌边界值——把
r设成恰好等于表里某个点的阻值,设成比最小值还小一点点,设成比最大值还大一点点。这几类输入能覆盖绝大部分 off-by-one 的问题,随机采样的命中率反而不高。
5.3 上线前的自测清单
我在项目里维护了一份二分代码的检查清单,每次改完都过一遍:
- 数组长度是 0 或 1 时,函数返回什么?会不会越界?
- 目标值比所有元素都小、都大,返回什么?调用方处理了吗?
- 数组里有重复值,返回的是第一个还是任意一个?
- 表是否严格单调?有没有相等的相邻点?相等会导致插值除零。
mid的计算有没有用lo + (hi - lo) / 2?- 如果更新语句里有
lo = mid,mid是否取了偏右? - 循环是否一定能退出?有没有可能区间不收缩?
- 中间结果的乘法会不会溢出?需要
int64吗?
这八条如果都能答上来,二分代码基本就是干净的。
6. 踩坑记录与常见问题速查
6.1 死循环、越界、不收敛
症状:程序卡死在 while 循环里。九成是区间没收缩。检查所有更新语句,确保每次循环至少有一个端点在动。如果两个端点都可能原地不动(比如lo = mid且hi = mid),那就是逻辑本身错了。
症状:偶尔返回 -1 或者返回错误的元素。通常是 off-by-one。用左闭右开写法时,hi的初值是n而不是n-1,这是最常见的笔误。还有一种是循环条件写成lo < hi - 1,提前退出了。
症状:数组越界。检查mid计算有没有整数溢出,以及循环体内访问a[mid]时mid是否还在合法范围内。如果hi初值是n且不小心访问了a[hi],就是越界。
症状:二分求根不收敛。先看端点是否真的异号,再看 eps 是不是小于浮点精度,最后看m的计算是不是被优化器改写了。加个迭代上限能兜住所有情况。
6.2 NTC 查表问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 全温区偏差 2 到 3 度 | 没做插值,直接用最近表点 | 加上线性插值 |
| 常温准、低温飘 | 表由单一 B 值生成,低温端失真 | 换厂家阻温表或分段 B 值 |
| 高温段算出来是负数 | 插值乘法 32 位溢出 | 改用 int64 中间变量 |
| 偏差随温度方向固定 | 二分存在 off-by-one,定位偏一格 | 对拍暴力版本 |
| 整体偏高零点几度 | 自热效应或分压电阻精度不足 | 减小激励电流,换 1% 电阻 |
| 数值跳变大、有毛刺 | ADC 采样噪声未滤波 | 加滑动平均或一阶低通 |
| 温度不变但数值缓慢漂移 | 参考电压漂移 | 用内部参考或定期校准 |
这张表里的每一行我都至少遇到过一次。最隐蔽的是“方向固定的偏差”,因为它看起来很像传感器本身的误差,很容易被当成硬件问题排查半天,最后发现是代码里少加了个 1。
6.3 几条实操心得
第一,二分只负责定位,别指望它给你答案。无论是数组查找还是查表,二分的输出是“位置”,不是“结果”。所有需要连续量的场景,定位之后都要补一步插值或换算。这一条我用了很多年才真正形成肌肉记忆。
第二,把不变量写在注释里。我写二分的习惯是先在函数头写一行注释,比如/* 循环不变式:答案一定在 [lo, hi] 内 */。写下来之后,更新语句怎么写就变成了一道推导题,而不是记忆题。这个习惯帮我省下的调试时间,比任何模板都值。
第三,能用标准库就用标准库。C++ 的lower_bound、Python 的bisect、Java 的Arrays.binarySearch,都是被无数人验证过的实现。自己写二分的唯一理由是需要定制逻辑,比如查表加插值这种标准库覆盖不到的场景。即使这样,定位那一段也完全可以用库函数完成,只把插值逻辑自己写。
第四,对拍比肉眼审查可靠得多。二分代码的正确性很难靠眼睛看出来,尤其是边界情况。花十分钟写个暴力版本做随机对拍,比盯着代码看半小时有效。
最后分享一个小技巧。如果你的 NTC 表是用脚本生成的,顺手在生成脚本里加上两行校验:检查电阻是否严格单调递减,检查对应的温度是否严格单调递减。这两个断言在表数据换供应商或者重新标定之后会自动帮你发现问题,而且是那种一旦出问题就会导致大面积异常的严重问题。我在两个项目里靠这个断言提前拦住了表数据错序,事后想想,如果没有它,故障会出现在客户现场而不是实验室。