learn-tech/专栏/程序员的数学课/16二分法:如何利用指数爆炸优化程序?.md
2024-10-16 09:22:22 +08:00

301 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
16 二分法:如何利用指数爆炸优化程序?
正式讲课之前,我先问你这样一个问题,请你尽可能快速回答。
一张 1 毫米厚度的纸对折几次后可以达到地球到月球的距离39 万公里)?
我在写这篇稿子的时候,问了身边的几个朋友。最小的回答是 1 万次,最大的则是 100 万次。
请问在你的直觉下,你的答案又是多少呢?我猜想无论如何都是上万次吧,毕竟我们离月球有 39 万公里呢。
折纸的过程就是 1 变 22 变 44 变 8这样一个翻一倍的过程。聪明的你会发现其实这就是一个关于指数函数和对数函数的问题。
那么,这与我们的编程有什么关系吗?其实基于这个数学原理,编程中有一种分治法的二分策略。这一讲,我们就来讨论一下如何利用指数爆炸来优化程序。
折纸,飞奔到月球
接下来我们定义下面的数学符号。n 为折叠的次数h(n) 为纸张对折 n 次后的厚度。显然,每次对折纸张时,厚度都会增加一倍。
不对折时,纸张的厚度为 h(0)=1mm每次对折纸张时厚度都会增加一倍如果将纸对折 1 次,则厚度为 h(1)=2mm如果对折 2 次,则厚度为 h(2)=4mm对折 3 次,厚度为 h(3) = 8mm。
我们耐着性子继续往下计算,可以得到下面的对折次数与厚度的关系表。
到这里我们发现,对折 10 次后,厚度也不过才刚刚达到 1 m。也许你会不仅感慨以这样的速度何时才能到达月球啊。
还是耐着性子我们继续计算并整理为下面的表格。区别是这次我们以米m为单位。
这时候,也许你会发现一些端倪。对折 10 次是 1 m对折 20 次竟然到了 1 公里,成长速度非常快。
接着我们继续耐着性子来计算并整理为下面的表格。区别是这次我们以千米km为单位。
我们知道地球到月亮的距离是 38 公里,也就是 3.8×105km对折 30 次后,厚度竟然已经达到了 103km。虽然离月球仍然很远但结合这个增长速度感觉已经快到月球了。
我们继续耐着性子来计算,并整理到下面的表格中。区别是,这次我们以 103km 为单位。
此时,你就会看到一个惊天结果。对折 40 次后,厚度达到了 106km。这已经超过了地月距离的 3.8×105km往回看你会发现在对折第 39 次时,厚度就已经开始超过地月距离了。原本猜测的至少要对折 10 万次,竟然只需要 39 次就到达了月球。
【飞奔到月球的代码实现】
为了仔细验证上面的结果,我们还可以把 h(n) 当作是一个数列。显然,它是一个首项为 1公比为 2 的等比数列,它的通项公式为 h(n)=2n (mm)。
如果要计算折叠多少次厚度可达地月距离(约为 3.8×1011mm可以对上面式子两边同时取关于 2 的对数,则有 log22n= n = log2(3.4×1011) ≈ 38.47。
因此在第 38 次折叠时,厚度还没有到达月球;但是第 39 次对折时,纸张厚度就可以突破地月距离。
对这个问题,我们可以用以下代码实现计算:
a = 1
h = a
times = 0
while h < 380000000000:
h = h * 2
times += 1
print times
代码含义为
1 定义纸张厚度为 1mm
23 定义对折 0 次时厚度为纸张厚度 1mm
4 判断当还没有到达月球时
5 执行对折的操作厚度为原来的两倍
同时第 6 对对折次数进行加 1 的操作
直到达到月球后跳出循环并打印出到达月球的次数
上图中的程序运行结果与刚刚我们的计算一致都为 39
指数爆炸的反向应用二分查找
在计算机中上面的现象也被称作指数爆炸你可以理解为某个看似不起眼的任务每次以翻倍的速度进行增长很快就会达到星星之火可以燎原的爆炸式效果和影响面显然指数爆炸性质的问题如果在程序中发生会让系统迅速瘫痪
不过如果可以把指数爆炸的思想反过来用就能对程序的效率进行优化 具体而言某个任务虽然很庞大很复杂但是每次我们都让这个任务的复杂性减半那么用不了多久这个庞大而又复杂的任务就会变成一个非常简单的任务了
所以指数爆炸思想的反向应用就是分治法而分治法中的一个经典案例就是二分查找
1.二分查找算法
二分查找是一种查找算法用于从某个有序数组 a 查找目标数字 obj 出现的位置 index
二分查找的思路是将目标数字 obj 与数组 a 的中位数 a[median] 进行比较
若相等则查找结束
如果 obj 小于 a[median]则问题缩小为从 a 数组的左半边查找 obj
如果 obj 大于 a[median]则问题缩小为从 a 数组的右半边查找 obj
重复这个过程直到查找到 index obj 未在数组 a 中出现为止
我们围绕下面的例子来使用一下二分查找算法假设数组 a 的元素如下表所示要查找的目标值 obj 7
第一轮数组 a 的中位数为 a[4] = 14因为目标值 obj 7小于 14则问题被缩小为在数组 a 的左半边查找 obj
第二轮上一轮剩下的 a 数组的查找范围中新的中位数为 a[1] = 2因为目标值 obj 7大于 2则问题缩小为在右半边继续查找 obj
第三轮上一轮剩下的 a 数组的查找范围中新的中位数为 a[2] = 7因为目标值 obj 7等于 a[2]则说明查找到结果输出 index 值为 2
好了现在我们来复盘一下刚才的执行过程
在上面的查找过程中每轮的查找动作都基于 obj 与中位数的大小关系来作出保留左边或保留右边的决策这样来看每轮的查找动作可以让 obj 的搜索空间减半这也是二分查找的命名由来
在利用二分查找后原本 10 个元素的数组 a只需要 3 次比较就找到了 obj 的位置 index你可能会决策10 次计算缩减为 3 区区几微秒的时间这对于强大的计算机而言根本不算什么
可如果数组 a 的元素个数为 3.8×1011个又会发生什么呢
还记得这个数字吗这就是刚刚我们计算的毫米单位的地月距离
从指数爆炸的反向结论来看对于这么多个元素的数组 a你只需要 39 次计算就能完成对 obj 的查找假设一次查找需要耗时 1μs则采用二分查找后节省的时间能达 3.8×1011μs= 3.8×108ms = 3.8×105s 100h
2.二分查找算法的代码
不知道你有没有发现二分查找的每一轮都是在处理同样的问题区别只不过是数组的查找范围变小了而已
这是不是很像上一课时讲到的递归的基本操作呢这里的递归结构如下
def fun(N,x):
if condition(N):
xxx
else:
fun(N1,x)
递归的两个关键问题是终止条件和递归体
二分查找的终止条件有以下两个可能第一中位数恰好是 obj说明找到了目标则打印中位数的索引值 index第二查找完发现没有任何一个数字等于 obj则打印 -1
递归体需要做两个分支的判断即如果 obj 比中位数大则把数组的右半边保留继续递归调用查找函数如果 obj 比中位数小则把数组的左半边保留继续递归调用查找函数
这样就可以得到如下代码
def binary_search(obj,a,begin,end):
median = (begin + end) / 2
if obj == a[median]:
print median
elif begin > end:
print -1
else:
if obj > a[median]:
binary_search(obj,a,median + 1,end)
else:
binary_search(obj,a,begin,median - 1)
a = [1,2,7,11,14,24,33,37,44,51]
binary_search(7,a,0,9)
【我们对这段代码进行走读】
第 1 行,说明 binary_search 的入参包括查找目标 obj、数组 a、查找范围的开始索引 begin以及查找范围的终点索引 end。
第 2 行,计算出查找范围内的中位数 median。
接着进行终止条件的判断:
第 3 行,如果 obj 和中位数相等,则直接打印 median
第 5 行如果发现开始索引比终止索引更大则说明没有找到目标值obj打印 -1。
第 7 行,开始是递归体:
第 8 行,判断 obj 和中位数的大小关系;
如果 obj 更大,则第 9 行递归查找数组右半边,更改开始索引为 median + 1
反之,则第 11 行递归查找数组左半边,更改终止索引为 median - 1。
利用以上程序,在数组 a = [1,2,7,11,14,24,33,37,44,51] 中查找数字 7因为 a[2] = 7因此预期的返回结果是 2。
程序的执行结果如下图,结果也为 2这与我们手动计算的结果是一致结果正确。
指数爆炸和二分查找的数学基础
指数爆炸为什么那么恐怖?二分查找又为什么那么厉害?其实这都源自两个数学运算,分别是指数运算和对数运算。
1.指数运算
指数运算,即幂运算,写作 an其中 a 为底数n 为指数:
当 n 为正数时an 表示含义为 n 个 a 相乘的积;
当 n 为 0 时a0=1
当 n 为负数时an = 1/a-n
除此以外,指数运算还有下面三个关键性质:
*a*n*∙ a*m=*a*n+m
*a*n*∙ b*n= (ab)n
(*b*n)m=*b*nm
2.对数运算
对数运算是指数运算的逆运算,设幂运算 an = y此幂运算的逆运算为 n=logay。
其中 a 是对数运算的底,而 n 就是 y 对于底数 a 的对数。
对数有下面三个重要性质:
logb(x ∙ y) = logbx +logby
logb*x*y=*y ∙*logbx
logb1 = 0
接着,我们从计算机运行的复杂度来看一下。我们先把对数函数、线性函数、指数函数在一张图中画出来。假设对数函数和指数函数的底数选择为 2线性函数选择为 y = x其函数图如下所示。
其中,灰色线为指数函数 y = 2x 的图像,橙色线为函数 y = x 的图像,蓝色线为对数函数 y = log2x 的图像,图中的这三条线,刻画了自变量 x 和因变量 y 之间的变化趋势关系,其中需要你重点关注的是指数函数和对数函数。
指数函数
对于指数函数而言,自变量 x 的增加会让因变量 y 快速达到“爆炸”状态。如果程序的复杂度与数据量是指数爆炸的趋势,那么随着数据量的增加,系统可能很快就会陷入瘫痪的状态。
现实中也有与之类比的案例。比如人们常说的一传十、十传百就是一种指数爆炸又比如2020 年开始的疫情,之所以要所有人隔离,就是要避免又传染带来的指数爆炸。
对数函数
反之,对于对数函数而言,自变量 x 的增加对因变量 y 增加的趋势影响非常小。
程序员应该多利用这个思想来进行程序优化。例如,刚刚讲解的二分策略的程序,即使任务量很大,也可以在很少的计算时间内完成运算。
现实中也有与之类比的场景。例如,你要在一个英文词典里面查找某个单词。虽然词典的厚度可能达到成百上千页,但因为单词排列有序,你完全可以通过二分查找去找到某个单词的所在位置。同时,即使某天人们新造出很多单词,哪怕是单词数量翻倍,也不会让查单词的复杂度有明显提高。
指数爆炸的正向应用——密码学
指数爆炸的反向应用是程序的优化,而指数爆炸的正向应用就是密码学。
决定密码安全性的一个重要因素,就是密码的搜索空间 S。假设大漂亮做了个密码系统在这个系统中密码的每一位都由 09 的数字构成时。这样,密码的每一位就有 10 个可能性。
如果密码的长度为 n则密码的搜索空间为 S = 10n。假设 n 为 5则密码共有 105 = 1 万种可能性。要想破译密码,无异于万里挑一。
可见,要想把密码做得很复杂,一个可行的方法是,利用指数爆炸不断增加位数,来获得更大的搜索空间;除了增加密码尾数的方式外,将单个密码位上的构成可能增加也是一种提升安全性的手段。
例如,如果把每一位的密码,由先前的数字调整为数字或区分大小写的字母,则意味着密码的搜索空间由 S = 10n提高到 S = 62n。
26 个小写字母、26 个大写字母、10 个数字,合在一起是 62 个可能性。
所以,增加每一位密码的可能性时,搜索空间 S 也可以获得提高。
小结
这一课时,我们了解了指数爆炸(运算)与对数运算,以及它们在程序和生活中的应用。而指数爆炸的思维过程就是“折纸,分奔到月球”的过程,其正向应用就是密码学。
而指数爆炸的反向应用有二分查找算法(也就是基于对数函数性质),二分查找算法是提高程序效率的重要手段,其前提条件是搜索空间有序,其实现方法需要采用上一讲所学的递归思想,需要预先定义递归的终止条件和递归体。
最后,我们留个课后习题,在上面的内容中,我们介绍了对数和指数的一些关键性质,你可以试着从数学的角度来证明这些性质的成立。