凌晨两点,温控板上那支 NTC 又把 62 度的水温读成了 76 度,而同一块板子在常温下准得让人放心。那一刻我盯着屏幕上的二分查找代码,明明教科书上三行就能写完、二分法查找的复杂度还漂亮得不行,问题却出在最不该出问题的地方。后来把链路一层层剥开才发现,二分法本身没算错任何一个下标,真正捅刀子的是"折半定位之后拿什么当结果"这件事——用最近邻查表去逼近一条指数曲线,误差被放大到十几度完全不奇怪。这篇就把二分法从"算法"和"工程"两个角度一起捋一遍:前半段讲清楚折半的边界怎么写、为什么那么写,后半段专治 NTC 查表里那些"二分法不准"的疑难杂症,顺带把数学课本里那个求方程近似根的二分法也一并收了。代码给 C,因为真正在板子上跑的、真正会踩坑的,基本都是 C。
1. 二分查找的两条铁律与一次手算推演
1.1 折半的本质:用有序性换对数时间
二分法最原始的形态就是猜数字:我心里想一个 1 到 100 之间的数,你猜,我告诉你大了还是小了。笨办法是从 1 开始一个一个试,运气差要试 100 次;聪明办法是先猜 50,一刀砍掉一半,剩下 25 次的机会都没有,最多 7 次就能锁定。这就是二分法的全部魅力——它不是"更快一点的查找",而是把一个 O(n) 的问题硬生生压成了 O(log n)。
这个压缩能成立,靠的是一个非常强的不变式:每次比较之后,我们能确定答案一定不在刚刚排除掉的那一半里。换句话说,有序性把一个"全局未知"的问题,变成了"局部可判定"的问题。我只要知道中点的值和目标的大小关系,就能给整个左半边或者右半边判死刑,不需要看它们具体是什么。这个思维方式比代码本身重要得多,后面你会看到 NTC 查表之所以能用二分,也是因为它满足同样的单调结构。
具体到次数,n 个元素最多需要 ⌈log2(n+1)⌉ 次比较。1000 个元素 10 次,100 万个元素 20 次,40 亿个元素 32 次。我做过一个粗略对比:在一个 4096 项的整型表里做线性查找,平均值大约 2048 次比较,每次几个周期,算下来几万个时钟周期;换成二分只要 12 次比较,几百个周期就结束了。对那种每秒要采几千次温度、每次都要查表的嵌入式场景,这个差距是实打实的。
1.2 单调性、可随机访问、边界可比:缺一不可
很多人写二分只在数组上练过,于是下意识以为二分是"数组专属技巧"。其实二分的前提条件只有三条,数组只是恰好最方便满足它们而已,理解这三条能帮你在遇到"二分结果不对"时快速定位问题出在哪一层。
| 前提条件 | 含义 | 不满足时会怎样 | 典型反例 |
|---|---|---|---|
| 序列单调 | 整体升序或整体降序,且比较方向与代码假设一致 | 排除掉的一侧可能真的藏着答案,结果直接错 | NTC 阻值表是降序,套用升序模板 |
| O(1) 随机访问 | 能在常数时间内取到任意下标的元素 | 每次"跳一半"都要走一遍链表,复杂度退化成 O(n log n) | 单链表、只读流式数据 |
| 比较关系良序 | 任意两元素可比且无歧义 | 收缩方向不确定,可能死循环或漏掉元素 | 含 NaN 的浮点数组、结构体只比了部分字段 |
还有一个容易被忽略的隐含条件:每一步都必须让搜索区间严格变小。也就是说,算出来的 mid 必须真的落在 (left, right) 内部,否则 left 和 right 不动,while 条件永远为真,程序就挂在那儿了。这个坑在"找下界"这类变体里特别容易踩,因为那种写法里有一侧是"保留 mid"而不是"跳过 mid"的,一旦边界公式写错就死循环。
提示:判断一段数据能不能二分,最快的自检方式是问自己"如果我排除掉左半边,我能保证答案不在这里面吗"。答不上来,就别用二分。
1.3 十个元素的手算全过程
光看代码很难建立手感,我习惯让新人先手算一遍。取数组[3, 7, 11, 18, 22, 30, 41, 55, 63, 90],下标 0 到 9,找目标 41,用最朴素的闭区间写法:
| 轮次 | left | right | mid | a[mid] | 与目标比较 | 下一轮区间 |
|---|---|---|---|---|---|---|
| 1 | 0 | 9 | 4 | 22 | 小于 41,答案在右侧 | [5, 9] |
| 2 | 5 | 9 | 7 | 55 | 大于 41,答案在左侧 | [5, 6] |
| 3 | 5 | 6 | 5 | 30 | 小于 41,答案在右侧 | [6, 6] |
| 4 | 6 | 6 | 6 | 41 | 命中 | 结束 |
四轮就把 10 个元素锁死了。注意第 3 轮里 mid 算出来是 5,而不是 5.5 或者 6,这个取整方向决定了区间能不能严格收缩。再看不存在的目标,比如找 40:最后一轮会走到 left=6、right=5,循环条件left <= right不成立,退出,返回未找到。此时 left 的语义是"第一个大于 40 的位置",也就是 6(值是 41),而 right 是"最后一个小于 40 的位置",也就是 5(值是 30)。这两个位置的语义在后面插值查表时会变成主角,所以现在就把它们记牢。
2. 三种二分写法的边界差异与选型依据
2.1 闭区间模板:left <= right
这是最常见的写法,区间语义是"答案可能在 [left, right] 里,两端都还没被排除"。
int bsearch_closed(const int *a, int n, int target) { int left = 0, right = n - 1; while (left <= right) { int mid = left + (right - left) / 2; if (a[mid] == target) return mid; if (a[mid] < target) left = mid + 1; /* mid 已排除 */ else right = mid - 1; /* mid 已排除 */ } return -1; /* 没找到 */ }三个细节值得说清楚。第一,mid写成left + (right - left) / 2而不是(left + right) / 2,不是为了好看,是因为后者在 left 和 right 都很大时会整数溢出。32 位平台上 left 和 right 都接近 2^31 时,相加直接翻负,mid 变成负数,然后你就眼睁睁看着数组越界访问。第二,两个分支都写了mid ± 1,因为闭区间写法里 mid 自己已经被比较过了,能排除就一定要排除,否则区间不收缩。第三,left <= right里的等号不能丢,因为它对应"区间里还剩一个元素"的情况,丢了等号就会漏掉最后一个候选。
2.2 左闭右开模板:left < right
把右端改成开区间,语义变成"[left, right) 是候选范围,right 本身不在范围里"。
int bsearch_halfopen(const int *a, int n, int target) { int left = 0, right = n; /* 注意这里是 n,不是 n-1 */ while (left < right) { int mid = left + (right - left) / 2; if (a[mid] < target) left = mid + 1; else right = mid; /* mid 保留 */ } return (left < n && a[left] == target) ? left : -1; }这个写法我一开始很不习惯,觉得"右边都不在范围里了还怎么查"。真正上手之后才发现它有两个好处:一是区间收缩的公式只有一条(right = mid),不容易写错方向;二是循环结束时 left 和 right 相等,而且这个位置天然就是"第一个不小于 target 的下标",语义非常干净,拿来插值正合适。代价是循环结束后必须再判断一次a[left]到底等不等于目标,不能直接返回。
2.3 下界模板:找第一个不小于目标的位置
把 2.2 里的"相等判断"去掉,就是标准的下界函数。它不告诉你"有没有这个元素",只告诉你"从哪个位置开始,元素就不小于目标了"。
/* 返回第一个满足 a[i] >= target 的下标,可能等于 n */ int lower_bound(const int *a, int n, int target) { int left = 0, right = n; while (left < right) { int mid = left + (right - left) / 2; if (a[mid] < target) left = mid + 1; else right = mid; } return left; }不变式是:[0, left)里的元素全部小于 target,[left, n)里的元素全部不小于 target。返回值等于 n 表示整张表都小于 target。这个语义为什么重要?因为插值查表要的恰恰就是"目标值夹在哪两个表项之间",而下界给出的正是这个分界点的下标。找到下标 i 之后,i-1 和 i 就是夹住目标的两个表项,可以直接代入插值公式。这一段先记住,第 4 节会直接用到。
2.4 循环结束后 left 与 right 到底落在哪
初学者最容易蒙的就是"退出循环之后这两个变量还有意义吗"。有,而且意义很明确。以左闭右开模板为例,循环结束的那一刻 left 一定等于 right(因为区间长度为 0 才会退出),并且这个位置满足:左边所有元素都小于 target,右边所有元素都不小于 target。所以 left 就是插入点,right 也是插入点,它俩指的是同一个位置。
用这个视角回看闭区间模板:退出时 left = right + 1,left 是第一个大于 target 的位置,right 是最后一个小于 target 的位置,两者刚好把 target 应该待的"缝"夹在中间。搞清楚了这一点,你就会发现很多"找不到就返回 -1"的写法其实是把有用信息丢掉了。
我用一个表把三种模板的差异压缩一下,方便贴在手边:
| 模板 | 初始区间 | 循环条件 | 收缩方式 | 退出后 left 的含义 | 常用场景 |
|---|---|---|---|---|---|
| 闭区间 | [0, n-1] | left <= right | mid ± 1 双排除 | 第一个大于目标的位置 | 单纯判断存在性 |
| 左闭右开 | [0, n] | left < right | 单侧保留 | 插入点 | 需要插入位置时 |
| 下界 | [0, n] | left < right | 单侧保留 | 第一个不小于目标的位置 | 查表插值、分段定位 |
3. NTC查表"二分法不准"的根因逐条拆解
3.1 从ADC值到温度:查表这一步到底在做什么
先说清楚整条链路,不然很容易把锅甩给二分。典型电路是 NTC 和一只上拉电阻串联分压,NTC 在下、上拉在上、分压点接 ADC。上拉电阻取 Rp、ADC 位数为 N、满量程码值是FULL = (1<<N) - 1,那么从采样码值反算 NTC 阻值的式子是:
/* code 是 ADC 采样值,Rp 是上拉电阻,FULL 是满量程码值 */ int32_t r_ntc = (int32_t)((int64_t)Rp * code / (FULL - code));拿到阻值之后,第五步才是查表:在"温度-阻值"表里找到这个阻值对应的温度。二分法只负责第五步中的"定位"动作,它不产生任何数值误差。你说二分法不准,其实是"定位之后拿什么当结果"不准。把这句话想明白,排查方向就清晰了——问题一定出在定位的方向、定位的精度、或者数值类型这三处中的某一处。
3.2 方向反了:阻值-温度表是降序的
NTC 的名字就叫负温度系数:温度升高,阻值下降。所以按温度升序排列的表,阻值一列是严格递减的。而所有教科书模板、几乎所有标准库的bsearch(),都默认数据是升序的。你要是把这只表直接丢给bsearch(),比较函数里还写着a < b返回负数,那结果就是随机的——可能返回一个莫名其妙的下标,也可能干脆返回 NULL。
更隐蔽的是"看起来能用"。因为二分的排除逻辑在降序表上并不是完全随机崩溃,有些查询会碰巧返回正确位置,让你误以为代码没问题,直到某一段温度区间开始偏,才意识到方向错了。所以降序表必须用降序模板,我一般写成这样:
/* r[] 按温度升序排列,同时阻值严格递减;返回第一个满足 r[i] <= rx 的下标 */ static int ntc_lower_bound_desc(const int32_t *r, int n, int32_t rx) { int left = 0, right = n; /* 答案范围 [0, n] */ while (left < right) { int mid = left + (right - left) / 2; if (r[mid] > rx) left = mid + 1; /* 中点在冷侧,往热端走 */ else right = mid; /* 中点在热侧,保留 */ } return left; /* 可能是 n,表示比所有表项都热 */ }判断方向的诀窍是不看符号看物理:把r[mid]和rx的关系翻译成"谁更冷",比在脑子里推大小号靠谱得多。r[mid] > rx表示表项阻值更大,也就是表项更冷,而我们要找的温度比它热,所以往热端(下标增大方向)走。
3.3 返回最近邻:把连续的物理量当成量化的档位
这是"不准"最主要的来源。很多老代码的逻辑是:二分找到某个下标 i,直接return temp_table[i]。这在温度上看就是"档位制"——表的步长是 25 度,那读数只能取到 25 的倍数;步长是 1 度,读数就只能取整。表面上看 1 度精度也够用,但下面的算例会告诉你,即使在阻值域里"找最近",误差也远比你想的夸张。
用常见的 10K、B=3950 的 NTC 举例子,理想模型下的阻值表(25 度步长)是这样的:
| 温度 | 0 °C | 25 °C | 50 °C | 75 °C | 100 °C |
|---|---|---|---|---|---|
| 阻值 | 33620 Ω | 10000 Ω | 3587 Ω | 1492 Ω | 698 Ω |
假设真实水温 60 °C,模型阻值是 2487 Ω。二分在阻值域里找最近邻:到 3587 Ω(50 °C)的距离是 1100 Ω,到 1492 Ω(75 °C)的距离是 995 Ω,最近的居然是 75 °C 那一项。于是程序老老实实输出 75 °C,而实际是 60 °C,偏差 15 度。这不是编程错误,是数学上的必然——阻值随温度是近似指数下降的,中间段极其陡峭,在阻值域里等距地看,温度域上是严重偏斜的。曲线越"弯",最近邻越容易往热端倒。
第二层原因是灵敏度不均匀。高温段阻值变化慢,同样的阻值差对应更大的温度差,所以最近邻误差在热端被进一步放大。这解释了一个很典型的现象:常温准得不行、一到 70 度以上就开始飘。不是你运气不好,是物理特性决定的。
3.4 整数与截断:误差被放大到肉眼可见
还有一类"不准"纯粹是数值处理惹的祸,而且症状非常有辨识度——读数只在几个整数值之间跳,永远看不到小数。
第一个元凶是先除后乘。有人写插值的时候顺手写成temp = t0 + (r0 - rx) / (r0 - r1) * (t1 - t0),整数运算下(r0 - rx) / (r0 - r1)先被截断成 0,结果恒等于t0。整段温度全变成表项的整数度,而且永远取冷端那个值。修法很简单:先乘后除,中间结果用 64 位承住。
第二个元凶是阻值类型选得太小。NTC 在低温段阻值可以是几十千欧,加上上拉电阻后分压算出来的中间量很容易超过 16 位。我用int16_t存过一次阻值,-10 °C 附近直接翻负,查表拿到一个负数去二分,输出一个疯狂的高温值,排查了半天才反应过来是溢出。
第三个元凶是表里出现重复或近重复的阻值。把厂家表按四舍五入取整到欧姆时,低温段相邻两度可能取到同一个数,于是插值公式的分母变成 0,除法直接崩。这类问题在低温段尤其常见,因为那里的曲线太平了。我的做法是生成表的时候加一道校验,相邻两项差值为 0 就报警,宁可手工改一个欧姆也不能留个地雷。
4. 插值查表的实现:把误差从十几度压到零点几度
4.1 线性插值的公式推导与定点化
思路很朴素:二分只负责找到夹住目标的两个表项,剩下的交给线性插值。设找到的下标关系是r[i] >= rx >= r[i+1](降序表),对应温度是t[i] < t[i+1],那么
temp = t[i] + (r[i] - rx) * (t[i+1] - t[i]) / (r[i] - r[i+1])分子(r[i] - rx)非负,分母(r[i] - r[i+1])恒正,公式在数值上是安全的。物理意义也直观:把 rx 在[r[i+1], r[i]]这段阻值区间里的相对位置,映射到温度区间[t[i], t[i+1]]上。因为阻值和温度在局部区间内是一一对应的单调关系,这种映射不会出现多值。
定点化有两个要点。第一,温度统一用 0.01 °C 为单位,也就是把 25.00 °C 存成 2500,这样输出天然带两位小数,不用浮点。第二,中间乘积用int64_t。取个最坏情况:阻值差最大约 3 万(低温段),温度差最大约 2500(百摄氏度步长),乘起来 7500 万,32 位放得下,但为了不给自己留隐患,我直接上 64 位,代价可以忽略。
4.2 完整查表代码
下面是能直接编译运行的版本,包括降序二分、插值、边界钳位和异常兜底。表数据按 10 °C 步长给出,用的是 B=3950 的理想模型值,实际项目请换成厂家给的 R-T 表。
#include <stdint.h> #include <stddef.h> /* 温度单位 0.01 摄氏度,阻值单位欧姆,均按温度升序排列 */ static const int16_t s_temp_c100[] = { 0, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000 }; static const int32_t s_res_ohm[] = { 33620, 20175, 12536, 8037, 5302, 3587, 2487, 1760, 1270, 934, 698 }; #define NTC_TABLE_LEN (sizeof(s_res_ohm) / sizeof(s_res_ohm[0])) /* 返回 0 表示成功,-1 表示阻值超出表的覆盖范围,结果已钳位 */ int ntc_res_to_temp(int32_t r_meas, int16_t *temp_out_c100) { int n = (int)NTC_TABLE_LEN; if (r_meas >= s_res_ohm[0]) { /* 比最冷端还冷,钳到表头 */ *temp_out_c100 = s_temp_c100[0]; return -1; } if (r_meas <= s_res_ohm[n - 1]) { /* 比最热端还热,钳到表尾 */ *temp_out_c100 = s_temp_c100[n - 1]; return -1; } /* 降序下界:找第一个满足 s_res_ohm[i] <= r_meas 的下标 */ int left = 0, right = n; while (left < right) { int mid = left + (right - left) / 2; if (s_res_ohm[mid] > r_meas) left = mid + 1; else right = mid; } /* 前面已经排除两端越界,所以 left 一定落在 [1, n-1] */ int i = left - 1; int32_t num = (s_res_ohm[i] - r_meas); /* >= 0 */ int32_t den = (s_res_ohm[i] - s_res_ohm[i + 1]); /* > 0 */ int32_t dt = (int32_t)s_temp_c100[i + 1] - s_temp_c100[i]; int64_t delta = (int64_t)num * dt / den; /* 先乘后除,注意顺序 */ *temp_out_c100 = (int16_t)(s_temp_c100[i] + delta); return 0; }有两处容易看漏的地方。一是钳位分支放在二分之前,这样后面的二分就不必处理 left 等于 0 或者等于 n 的边界,代码短一截,也不容易出错。二是delta的除法发生在乘法之后,且被除数是 64 位,不会出现 3.4 节里那种"恒等于冷端温度"的截断。
4.3 表项疏密的取舍与实测误差
表越密,插值误差越小,代价是 Flash 占用和生成表的工作量。我用理想 B 模型估算过不同步长下的最大插值误差(在区间中段取点,实际以厂家表为准):
| 表步长 | 覆盖 -40~125 °C 的表项数 | 仅最近邻的误差 | 插值后的误差 | 两个数组的 Flash 占用 |
|---|---|---|---|---|
| 25 °C | 约 8 项 | 可达 15 °C | 约 3 °C | 约 100 字节 |
| 10 °C | 约 18 项 | 约 5 °C | 约 0.5 °C | 约 220 字节 |
| 5 °C | 约 35 项 | 约 2.5 °C | 约 0.15 °C | 约 420 字节 |
| 1 °C | 约 166 项 | 约 0.5 °C | 小于 0.01 °C | 约 2 千字节 |
验证方式和数据都能对得上:25 °C 步长下 60 °C 插值出来是 63.1 °C,误差 3.1 °C;10 °C 步长下 65 °C 插值出来是 65.5 °C,误差 0.5 °C;5 °C 步长下 62.5 °C 插值出来是 62.64 °C,误差 0.14 °C。趋势非常清楚,步长减半,误差大致降到四分之一,因为插值误差和步长的平方成正比。
这里有个很实际的取舍判断。当表步长降到 5 °C,插值误差已经到 0.15 °C 量级,而常见的 NTC 传感器本身公差是 R25 ±1%、B 值 ±1%,换算到温度上是零点几度到一度。也就是说,算法误差已经被传感器误差盖住了,再加密表就是白费 Flash。我的经验线是:插值后的算法误差做到传感器公差的三分之一以下,就可以收手了,5 °C 步长对绝大多数消费级和工业级场景刚好卡在这条线上。
4.4 边界钳位与异常兜底
查表函数最容易在边界上翻车。真实阻值可能因为以下原因跑到表外:传感器断线(阻值无穷大)、短路(阻值接近 0)、接线氧化导致接触电阻偏大、低温环境超出表的下限。这些情况如果直接喂给插值公式,分母可能为 0,或者算出下标 -1 直接越界读数组,程序的状态就完全不可预期了。
我习惯在函数出口统一约定返回值语义:0 表示正常,-1 表示钳位。调用方看到 -1 就知道该走故障处理流程了——断线报故障、超范围报警、或者按场景降级使用。这样既不阻塞正常的温度读取,又不会把异常数据悄悄混进控制回路里。
还有一类兜底是排序稳定性。表是工具生成的,万一手抖把某两行顺序写反,单调性就破了,二分会给出一个"看起来正常"的错误结果。我的做法是在初始化阶段跑一次全表校验:检查阻值列是否严格递减、温度列是否严格递增、相邻阻值差是否都大于某个阈值(比如 5 欧姆)。这个校验只有几十次循环,上电时跑一次几乎不花时间,但能挡住绝大多数低级错误。
5. 一次完整排障:从"常温准高温偏"到定位修复
5.1 现象记录与最小复现
先把现象钉死,不然排查会一直在猜。当时记录到的是:室温 25 °C 附近读数偏差小于 0.5 °C;55 °C 以上开始明显偏大,且偏得没规律;最诡异的线索是读数只在几个固定值之间跳,比如 60、65、70 这种整五度,中间值永远看不到。把这三条放一起,指向性其实很强——"只跳固定值"说明根本没有插值,只用表项温度当结果;"热端偏大"说明最近邻在阻值域里往热端倒,也就是 3.3 节那个机制。
复现方式也很简单:不用真的去搭恒温槽,直接绕开 ADC,把一组已知阻值的电阻(或者高精度电阻箱)接到分压点位置,手工喂给ntc_res_to_temp(),让函数把返回值打印出来。这是最快的定位手段,因为它把 ADC 本身的误差、参考电压漂移、走线噪声全部剔除了,剩下的只有查表逻辑。
5.2 分层定位:把链路切成三段分别验证
链路是"分压 → ADC → 反算阻值 → 查表 → 温度",我把它切成三段独立验证,每段只留一个变量。
第一段是 ADC 到阻值。拿万用表直接量分压点的电压,按分压公式算出理论阻值,再和代码里反算出来的阻值对比。这一步的目的是确认 ADC 采样值和换算公式都没问题。当时量下来两者差不到 1%,说明前两段是干净的。
第二段是阻值到温度。把上一步得到的阻值丢到厂家提供的 R-T 表里,用计算器手查,得到的温度和标准温度计差 0.3 °C,说明表数据本身没问题。
第三段是查表代码。把ntc_res_to_temp()单独抠出来,喂入几个已知阻值,把left、right、mid、r[mid]全打印出来。马上就看到了问题:函数返回的温度全是表项上的整度值,delta恒等于 0。再翻代码,delta那行的写法是(num / den) * dt——先除后乘,整数除法把 num/den 截成了 0。与此同时,二分定位到的下标也确实偏向了热端,因为表步长是 25 °C 的粗表,最近邻本来就会倒向热端。
嵌入式环境里打印变量有时候不方便,替代方案是用一个环形缓冲把left/right/mid记下来,跑完之后一次性 dump;或者用一根空闲 GPIO 翻转电平,接逻辑分析仪看波形。都比盲猜快。
5.3 修复与验证:怎么确认不是"看起来对了"
改动只有三处:把升序模板换成 3.2 节的降序模板;把先除后乘改成先乘后除并加 64 位中间量;补上边界钳位和返回值语义。改完之后"读数只跳整度"和"热端偏大"两个症状同时消失,这本身就说明方向找对了。
但我不太信任"改完看着对了"这种验证方式。真正让我放心的是两组等价性测试。第一组是逐点比对:写一个线性扫描版本(从表头开始一项一项找,配上同样的插值公式),然后把二分版本和线性版本在 -50 到 150 °C 之间按 1 Ω 步长全部跑一遍,要求两者输出完全一致,一个 bit 都不能差。第二组是随机模糊测试:随机生成十万个阻值(包含越界值),同样要求两个版本结果一致。二分和线性扫描在逻辑上是等价的,任何不一致都说明二分模板有问题,这个测试能覆盖掉绝大多数边界 bug。
还有一组针对性的边界测试值得单独跑:喂入比表头还冷的阻值(比如 1 MΩ)和比表尾还热的阻值(比如 10 Ω),检查返回的是钳位值加 -1,而不是越界下标。这两种情况在传感器断线和短路时是真实会出现的,测试里必须覆盖。
5.4 同类问题清单
排完这一趟,我把这个项目里所有"查表不准"的坑都整理了一遍,后来发现它们反复出现在几乎每一个用 NTC 的项目里:
| 症状 | 最可能的原因 | 快速验证方法 |
|---|---|---|
| 读数只在固定值上跳 | 没有插值,直接返回表项 | 看返回温度是否总是表步长的整数倍 |
| 热端偏大、冷端还行 | 表方向搞反或最近邻偏向热端 | 用电阻箱喂已知阻值,对照厂家表 |
| 返回值恒等于冷端温度 | 整数除法先除后乘 | 看插值中间量是否为 0 |
| 低温段直接算出巨大异常值 | 阻值用 16 位溢出或下标越界 | 查数据类型和数组下标范围 |
| 插值算到一半崩溃 | 表里有重复阻值,分母为 0 | 全表扫描相邻差值 |
| 换了上拉电阻后整体偏移 | 表按另一个上拉阻值生成 | 核对生成表时的分压参数 |
| 高低温都有零点几度固定偏移 | 传感器公差,不是算法问题 | 用高精度电阻箱替掉传感器再测 |
最后那一行值得多说一句。很多人把算法误差压到 0.01 °C 之后还在纠结为什么实测差 0.5 °C,然后继续改代码,越改越离谱。其实这时候误差已经转移到传感器公差和自发热上了——自发热尤其容易被忽略,如果分压电流偏大(比如上拉用了 1 kΩ),NTC 自身会被加热,读数系统性偏低,而且这个偏差和功耗、散热、封装都相关,绝不是改代码能解决的。
6. 换个场景:二分法求方程近似根
6.1 从查找问题到求根问题的转换
数学教材里"二分法"这个名字,更多指的是求方程近似根,和查表定位是同一套思想的两个投影。给定连续函数 f(x),如果在区间 [a, b] 上 f(a) 和 f(b) 异号,那么区间内至少有一个根。取中点 m,看 f(m) 的符号:它和 f(a) 同号就把 a 移到 m,异号就把 b 移到 m,区间长度每次减半,若干轮之后中点就是根的一个近似值。
本质上它和二分查找干的是同一件事:靠一个可判定的性质(符号),每次砍掉一半的搜索空间。区别在于查找的判定是"等于/大于/小于",求根的判定是"同号/异号",但收缩区间的机制完全一致。
6.2 迭代终止条件与精度换算
精度和迭代次数是可以直接算出来的。初始区间长度是b - a,每轮减半,k 轮之后长度是(b-a)/2^k。要求最终精度不超过 eps,解不等式得到:
k >= log2((b - a) / eps)举个数:区间长度 2,目标精度 1e-6,那么 k >= log2(2×10^6) ≈ 20.93,取 21 轮。二十来次迭代就能把一个两位数精度的答案直接压到六位小数,这就是指数收敛的力量。反过来说,如果你发现有人跑了一万次迭代,那说明终止条件写错了,不是收敛慢的问题。
#include <math.h> double bisect_root(double (*f)(double), double a, double b, double eps, int max_iter) { double fa = f(a), fb = f(b); if (fa * fb > 0.0) return NAN; /* 端点同号,不能保证有根 */ for (int i = 0; i < max_iter && (b - a) > eps; ++i) { double m = 0.5 * (a + b); double fm = f(m); if (fm == 0.0) return m; /* 运气好,正中根上 */ if (fa * fm < 0.0) { /* 根在左半段 [a, m] */ b = m; } else { /* 根在右半段 [m, b] */ a = m; fa = fm; /* 复用,避免重复调 f */ } } return 0.5 * (a + b); }代码里有两个设计细节值得展开。第一,fa被缓存下来,每次只需要算一次f(m)而不是两次f(a)和f(m)。函数调用次数减半,在这个场景下是很划算的优化,因为 f 往往是整个流程里最贵的一环。第二,终止条件用的是区间长度(b - a) > eps,而不是fabs(fm) > eps。这两者差别很大:当函数在根附近非常平缓(导数接近 0)时,x 偏离根很远但 f(x) 已经很小了,用函数值做终止条件会提前退出,给出的 x 完全不准。区间长度是自变量空间的度量,用它才算真正控制了"根的精度"。
用浮点做二分会遇到一个理论上的极限:当区间长度小到相邻两个可表示浮点数之间的距离(ulp)时,m算出来只会等于 a 或 b,区间不再收缩,就陷入死循环了。所以max_iter这个保险必须加,不能只依赖 eps。工程上一般给 100 到 200 轮上限,足够覆盖 double 的全部有效位。
6.3 求根二分的局限与替代方案
二分求根最大的优点是稳,缺点是慢。它每轮只把误差减半,属于线性收敛,大约每 3.3 轮才多出一位有效数字。相比之下,牛顿法在根附近是平方收敛——每轮有效位数翻倍,通常四到六轮就能到机器精度。但牛顿法需要导数,而且初值选得不好会发散、会跑到别的根上去,甚至在迭代中跨过不连续点直接飞掉。
我实际的做法是混合:先用二分把区间收缩到足够小,比如收缩到原区间的千分之一,再切到牛顿法或者割线法快速收尾。这样既有二分的全局保证(只要端点异号,一定能收敛),又有牛顿法的末段速度。交换机内燃机标定、传感器线性化系数反解、温控回路的稳态点求解这类场景,这个策略都很好用。
还有几个信号很明确的失效情形需要提前防住。如果 f(a) 和 f(b) 同号,二分根本启动不了,这时候得先扫描一遍找变号区间,不能硬塞进去。如果区间内有偶数个根,端点异号并不能保证你收敛到想要的那个根,可能收敛到旁边的根上。如果函数在某处不连续,二分的收缩可能直接把根"跳过"。这些都是数学前提的问题,不是代码能补救的。
最后再分享一个我在实际操作中的小体会:无论用二分做哪种事,我都强迫自己先把"不变式"写在注释里——"循环开始时,[0, left) 全部小于目标,[left, n) 全部不小于目标",或者"循环开始时,f(a) 与 f(b) 异号"。写完注释再写代码,边界条件的错误率能降一个数量级。二分法的代码短到可以背下来,但正是因为它短,一旦写错就特别难看出来;而不变式是唯一能在不看运行结果的情况下判断对错的东西。这个习惯我从查表这件事上养起来,后来写别的算法也一直在用。