Beau
好了,Jo。上一章我们把换底公式从里到外都推导了一遍,什么 log_a(b) 等于 log_b(a) 的倒数之类的……我感觉我的代数脑细胞已经快耗尽了。所以,咱们现在能聊点实际的吗?比如说,我在我的 C++ 代码里,到底要怎么用这些东西?
Transcript
Beau
好了,Jo。上一章我们把换底公式从里到外都推导了一遍,什么 log_a(b) 等于 log_b(a) 的倒数之类的……我感觉我的代数脑细胞已经快耗尽了。所以,咱们现在能聊点实际的吗?比如说,我在我的 C++ 代码里,到底要怎么用这些东西?
Jo
当然可以。这正是关键所在,把理论变成工具。在 C++ 里,你基本上会和 <cmath> 这个头文件打交道。它给你准备好了几个核心的对数函数。
Beau
哦?是像计算器上那样的 log 按钮吗?
Jo
差不多,但有个很重要的区别,这也是很多初学者的一个坑。在 <cmath> 里,如果你直接调用 log() 函数,比如 log(x),你得到的不是以 10 为底的常用对数,而是以 e 为底的自然对数,也就是 ln(x)。
Beau
等等,log() 是自然对数?这… 这有点反直觉。那我想要以 10 为底的怎么办?
Jo
很简单,库里有另一个函数叫 log10()。所以,log10(x) 就是我们熟悉的常用对数。还有一个更常用的,尤其是在计算机科学领域,是 log2(),用来计算以 2 为底的对数。
Beau
啊,log2() 这个我熟,二分搜索的时间复杂度 O(log n) 就是它。所以 C++ 直接就内置了这三个最常用的底:e, 10 和 2。这倒是挺方便的。
Jo
没错。但对数在工程上真正的威力,并不仅仅是算个数。它能解决一个非常棘手的问题:浮点数下溢。
Beau
下溢?Overflow 我听过,就是数太大了存不下。下溢是……数太小了?
Jo
精确地说,是数字太接近于零了。想象一下,你在做一个机器学习模型,比如朴素贝叶斯分类器,你需要计算一连串概率的乘积。每个概率都是一个小于 1 的小数,比如 0.1, 0.05, 0.002……
Beau
好的,我脑子里有这个画面了。一堆小数乘在一起。
Jo
当你把几十个甚至几百个这样的小数乘起来,结果会怎么样?
Beau
会……变得非常非常小。小数点后面跟着一大串零。
Jo
对。小到超出了计算机浮点数能表示的精度范围。计算机会怎么办?它会直接把这个结果当成 0。这就是下溢。你的计算结果就废了,因为零乘以任何数都得零,后续的比较和判断就全乱了。
Beau
哇,这听起来是个大问题。那……对数怎么解决它?
Jo
还记得我们上上节课证明的对数积规则吗?log(M * N) = log(M) + log(N)。
Beau
当然记得。对数能把乘法变成加法。哦!我好像有点明白了!
Jo
说来听听。
Beau
我们不直接去乘那一堆小概率 p1, p2, p3... 我们先对每个概率取对数,得到 log(p1), log(p2), log(p3)... 然后把它们加起来!
Jo
完全正确。因为概率值是 (0, 1) 之间的小数,所以它们的对数值会是负数。但这些负数都是很正常的浮点数,比如 -2.3, -5.7 之类的。你把一堆这样的数加起来,完全不会有下溢的风险。你就得到了一个总的和,这个和等于你原来想求的那个极小概率的对数值。
Beau
这太巧妙了。所以我们全程在对数空间里做加法,而不是在原始空间里做乘法,来避免精度丢失。但如果我们最后还是需要比较两个概率的大小呢?比如 P(A) 和 P(B),我们现在只有 log(P(A)) 和 log(P(B))。
Jo
好问题。因为对数函数是单调递增的,所以如果 P(A) > P(B),那么 log(P(A)) 也一定大于 log(P(B))。你完全可以直接比较它们的对数值,大小关系是不变的。所以你根本不需要再把它们转换回原来的概率值。
Beau
哇塞,这一下就从一个数学公式,变成了一个真正能解决后端数据处理问题的工程技巧了。感觉完全不一样了。
Jo
这就是它在隐马尔可夫模型、数据挖掘里被广泛应用的原因。不过,还有一个实际问题。刚才我们说 C++ 提供了 log, log10, log2。但如果我想算一个 log_7(x) 怎么办?
Beau
这个我知道!该轮到上一章的换底公式出场了。log_7(x) 可以写成 log_k(x) / log_k(b)... 啊不,是 log_k(x) / log_k(7)。
Jo
没错。这里的 k,也就是新的底数,我们可以选任何 C++ 支持的。比如自然对数 e。那么在代码里,log_7(x) 就可以手动实现为:log(x) / log(7)。
Beau
或者 log2(x) / log2(7),或者 log10(x) / log10(7)。结果都应该是一样的吧?
Jo
理论上完全一样。但在实际计算中,由于浮点数精度的问题,可能会有极其微小的差异。所以通常的建议是,在同一个项目或者同一段逻辑里,始终坚持使用一种底进行转换,比如统一用自然对数 log(),这样可以保证计算的一致性。
Beau
这个建议很实在。等于说,<cmath> 给了我们基本的积木块,换底公式就是那个可以让我们搭出任意形状的说明书。
Jo
很好的比喻。所以你看,从最开始的代数推导,到换底公式的证明,再到今天我们聊的 C++ 工程实践,这一整条线索是连贯的。理论知识最终都转化成了你代码库里解决实际问题的工具。