第2章 数值算法
数值算法就是对数值进行计算,这些算法执行随机取数、分解质因数、寻找最大公约数和几何面积计算等任务。
所有的这些算法在不同情况下有各自的用途,但是它们也展示了有效的算法技巧,比如自适应算法、蒙特卡罗方法和用表格来存储中间结果。
2.1 随机化数据
随机化在许多应用中起到重要作用。这种算法让一个程序模仿随机过程,测试算法在随机输入时的表现并寻找复杂问题的解决方案。2.5节描述的蒙特卡罗积分,即采用随机选点的办法来估算复杂几何面积的大小。
在任何一种随机的算法中,第一步都是建立随机数表。
2.1.1 随机数生成
尽管许多程序员谈到“随机”数发生器,但只要是用计算机来生成数据的算法,就并非真正的随机。如果知道该算法的细节和其内部状态,就能正确地预见它生成的“随机”数。
为了获得真正的不可预见的随机状态,需要一个并非计算机程序的数据来源。例如,可以采用一台辐射探测仪,测量从辐射试样中散发出来的粒子来获取随机数值。因为没有人能够精确预见什么时候粒子将出现,这是真正的随机。
其他可能是真正随机数据来源的还有掷骰子、分析电磁波中的静电干扰和研究布朗运动。Random.org测量大气噪音来生成随机数。
不幸的是,由于这类真随机数发生器(True Random Number Generator,TRNG)相对复杂并且速度较慢,所以大多数应用场合采用一种更快的伪随机数发生器(Pseudo Random Number Generator,PRNG)来替代。对于多数应用场景,如果数据在某种程度上足够随机,程序就可以利用它们并取得好的结果。
1.生成随机数
线性同余发生器是一个简单并且常见的伪随机数生成的方式,这种发生器用以下关系生成数:
其中A、B和M是常数。
X0的值初始化这个发生器,这样不同的X0值就会产生不同的数组。用来初始化伪随机数发生器的值叫做种子,比如例子中的X0。
由于在一个数组中的所有数值都和M同余,在最多M个数后,发生器会产生一个它之前产生过的数,然后这个数组从这个点开始重复。
举一个十分简单的例子,假设A=7,B=5,M=11。如果以X0=0开始,之前的方程式会给出以下的数组:
X0=0
X1=(7×0+5) Mod 11=5
X2=(7×5+5) Mod 11=40 Mod 11=7
X3=(7×7+5) Mod 11=54 Mod 11=0
X4=(7×10+5) Mod 11=75 Mod 11=9
X5=(7×9+5) Mod 11=68 Mod 11=2
X6=(7×2+5) Mod 11=19 Mod 11=8
X7=(7×8+5) Mod 11=61 Mod 11=6
X8=(7×6+5) Mod 11=47 Mod 11=3
X9=(7×3+5) Mod 11=26 Mod 11=4
X10=(7×4+5) Mod 11=33 Mod 11=0
由于X10= X0= 0,这个数组开始重复。
数值0、5、7、10、9、2、8、6、3、4看起来十分随机,但是既然已经知道了这个程序用来产生这些数的方式,如果给出该程序所产生的一个确切的数,你就能够正确地推断出接下来的数是什么。
一些伪随机数发生器算法使用有着多个常数的多元线性同余发生器,然后从每一步生成的数值中选择一些来使这些数看起来更加随机,并且增加数组的重复周期。这能够使程序产生更多看起来随机的结果,但是那些方法仍然不是真正地随机。
注 大多数程序设计语言内置伪随机数发生方法,读者可以直接使用这些方法而不是编写自己的算法。这些方法普遍是相当快的,并且在发生重复前会产生一段非常长的数组,因此对大多数程序来说,可以直接使用它们而不是编写自己的算法。
伪随机数发生器的一个特点或者说优点是:可以使用一个特定的种子来重复生成相同的“随机”数组。这也许看起来是一个缺点,因为这意味着这些数将会更容易被预测,但是重复使用相同的数可以使一些程序调试起来更加简单。
重复使用相同的数组也可以使一些应用程序以非常紧凑的方式存储复杂的数据。例如,假设一个程序需要使对象在地图上进行一段漫长而复杂的伪随机运动。该程序会生成路线并且存储该路线的所有坐标,以便它能在以后重新绘制这条路线。另外一个选择是这个程序可以只存储一个种子值,然后每当它需要绘制路线时,可以用种子重新启动一个伪随机数发生器来产生相同的路线。
在图2-1中的随机树程序使用种子值来代表随机树。输入一个种子值,然后点击“Go”来生成一个随机树。即使两个种子有很小的差别,也会产生完全不同的结果。
随机树程序使用你输入的种子值来生成绘制参数,例如每一步产生的树枝数、子树枝相对其母树枝弯曲的角度,以及每一条树枝相对其母树枝缩短的程度。读者可以从本书网站上下载程序来查看详细信息。
如果输入了同一个种子数两次,则会产生两个相同的随机树。
基于密码安全性的伪随机数发生器
任何线性同余发生器都存在一个重复周期,因此它不能用于加密。
例如,使用伪随机数发生器,为消息中的每一个字母生成一个值,然后将这个值与字母相加,通过这种方式将一段消息加密。举个例子,字母A加3是D,因为D是字母表中A之后的第三个字母。如果累加到了Z,那么再加1的时候就回到A。这样,举例来说,Y加3是B。
只要数组中的数是随机的,这种技术就会运行得十分良好,但是一个线性同余发生器对于某个种子值只能产生有限的数。破解密码时需要做的就是通过尝试所有的可能种子值来解密。对于每一个可能的解密结果,系统可以通过检测字母频率来检验这个结果是否看起来是真正的文本。如果使用了错误的种子,每个字母的出现频率应当大致相同。如果猜到了正确的种子,一些字母(例如E和T),将会比其他字母(例如J和X)出现得多很多。如果字母是十分不平均地分配,也许就是猜到了正确的种子值。
这也许看起来工作量很大,但是在现代计算机上这不难实现。如果种子值是一个32位整数,只可能有大约 40亿个不同种子值。一台现代计算机可以在几秒钟、最多几分钟之内,检验每一个可能的种子。
基于密码安全性的伪随机数发生器(Cryptographically Secure Pseudorandom Number Generator,CSPRNG)使用更多的复杂算法来生成难以预测的数,并且生成长得多的数组而无需进入循环。它们通常使用更大值的种子。伪随机数发生器可能会使用32位长的种子,而基于密码安全性的伪随机数发生器可能会使用1000位长的种子来初始化算法。
基于密码安全性的伪随机数发生器是十分有趣并且“随机的”,但是它们也有一些缺点。其复杂性导致其运算比其他更简单的算法慢。它们也许不能手动初始化,因此可能不能简单地生成一个可重复的数组。如果要使用相同的数组一次以上,应当使用较简单的伪随机数发生器。所幸大部分算法不需要基于密码安全性的伪随机数发生器,这样可以仅用简单些的伪随机数发生器。
2.确保公平性
通常程序需要使用一个公平的伪随机数发生器。公平伪随机数发生器按相同概率产生可能输出结果。不均衡的伪随机数发生器是有偏斜的。例如,一个三分之二概率正面向上的硬币是有偏斜的。
许多程序设计语言有着在任何所需范围内产生随机数的方法。但是如果需要编写代码将伪随机数发生器产生的数值转换到一个特定范围内,要注意以均衡的方式来完成。
线性同余发生器产生一个范围大于等于0而小于M的数,M是生成器方程中的模。
Xn+1=(A×Xn+B) Mod M
通常一个程序需要一个范围不在0到M之间的随机数。以下方程式显然可以产生满足最大值和最小值范围的随机数,但并不恰当:
result=Min+number Mod (Max-Min+1)
例如,为了获得一个1到100之间的随机数,需要计算下列算式:
result=1+number Mod (100-1+1)
这种方法的问题在于,相对于其他方法它产生的结果可能更加相似。
为说明原因,请思考这样一个例子:M=3,Min=0,Max=1。如果发生器以合理的方式运行,它会以大致相同的概率产生数值0、1和2。如果将这三个值代入之前的方程式,你会得到如表2-1所示的结果。
在表2-1中,结果0出现的次数是结果1的两倍,因此最终结果存在偏斜。
在一个真正的伪随机数发生器中,模M可能非常大,这时这个问题变得微小,但仍然存在。
较好的方法是将伪随机数发生器生成的值转换到0和1之间,然后再与所需要范围相乘,如下列方程式所示:
result=min+(number÷M)×(Max-Min)
另一种将一个伪随机数从一个范围转换到另一范围的方法是简单地忽略掉所需范围之外的所有结果。在前例中,可以使用有限的伪随机数发生器来生成一个0和2之间的值。如果你得到2,它超过了所需范围,可以忽视掉2而留下其他数。
举一个稍微现实些的例子,假设将一块饼干给四位朋友中的一位,并且有一个六面骰子。这种情况下可以重复地抛骰子,直到得到一个1和4之间的值。
3.从偏斜的来源获得公平性
即使一个伪随机数发生器是偏斜的,也可能存在一个生成公平随机数的方法。例如,假设你认为一枚硬币是偏斜的,但不知道正面向上或背面向上的概率,不过预想到可能性一定不是0.5。在这种情况下,以下算法产生一个公平的硬币抛掷结果:
来看看为何这么做,假设偏斜的硬币正面向上的概率是P,那么背面向上的概率就是1-P。从而第一次正面向上、第二次背面向上的几率就是P×(1-P)。同理,第一次背面向上、第二次正面向上的几率就是(1-P)×P。这两个几率相同,因此这个算法输出正面向上或背面向上的几率是相同的,也就是说结果是公平的。
如果偏斜的硬币两次正面向上或者两次背面向上,需要重复这个算法。如果很不走运,或者硬币是十分偏斜的,也许需要重复这个算法很多次才能得到一个公平的结果。例如,如果P=0.9,该硬币两次正面向上的可能性是81%,而两次背面向上的可能性是1%。
注 使用偏斜的硬币来生成公平硬币抛掷在实际程序中未必有效。但是它是概率的一个不错的应用,并且可能成为一个面试中的有趣问题,因此它值得理解。
可以使用相似的技术来扩展伪随机数发生器。例如,假设想将一块饼干给五位朋友中的一位,并且获得随机数的唯一途径是一枚公平硬币。在这种情况下,可以抛掷这枚硬币三次然后将结果看做一个二进制数,正面向上代表1,背面向上代表0。举个例子:抛掷结果为正面、背面、正面代表二进制数101,也就是十进制中的5。如果获得一个所需范围之外的结果(在本例中抛掷结果为正面、正面、正面代表二进制数111或十进制数8,比朋友的数多),则放弃这个结果并且重新开始。
总之,程序设计语言附带的伪随机数发生器工具可以适用于大部分的程序。如果需要更好的随机化,应当使用基于密码安全性的伪随机数发生器。使用公平硬币来获取一个1到100之间的随机数或者使用偏斜的信息来源去生成公平数,这在作为面试问题或一些不可思议的情况下是有用的。
2.1.2 随机化数组
生成随机化数组中的项是程序中一个十分常见的任务。例如,假设一个日程安排程序需要分配员工轮班工作。如果程序按照他们出现在数据库或其他固定顺序中的字母顺序分配员工工作,总是被分配在后半夜工作的员工会不满意。
一些算法也可以利用随机化来防止最坏情况的发生。例如,标准的快速排序算法通常运行良好,但如果该算法需要进行排序的值最初已经被排序,这时算法会运行得较差。一种避免这种情况发生的方法是在排序前随机化这些值。
下面的算法展示了随机化数组的一种方式:
该算法访问这个数组上的每个位置一次,因此它有一个对于大多数应用程序来说足够快的运行时间O(N)。
请注意重复这个算法并没有使该数组“更加随机化”。当洗一摞扑克牌时,开始时相邻的两张扑克牌有保持相邻的趋势(即使可能相距稍远些),因此需要洗很多次来得到一个相当随机的结果。该算法可以在一个过程中彻底随机化数组,所以重复运行它就是浪费时间了。
十分随机的数组
算法的另一个重要考虑因素是它是否产生一个公平的排列。换句话说,一个排列在任何给定位置结束的几率对于排列在所有位置来说相同吗?例如,如果在第一个位置开始的一个排列有半数几率在第一个位置结束,那么这种情况就是不公平的。
在介绍中说过本书不包含复杂的数学证明。所以可以跳过以下的讨论并且认为随机算法是公平的。但是如果懂得一些概率,就会发现下面的讨论是有几分趣味的。
对于一个数组中的确定项,考虑它被放置在位置k的可能性。为了被放置在位置k,它必须不能被放置在位置1、2、3、…、k-1上,然后才能被放置在位置k上。
定义P-i为某项没有被放置在位置i的几率,前提是该项之前没有被放置在位置1、2、…、i-1。同理定义Pk为某项被放置在位置k的几率,前提是该项之前没有被放置在
位置1、2、…、k-1。然后该项被放置在k上的所有几率是P-1×P-2×P-3×…× P-(k-1)×Pk.
P1是1/N,所以P-1是1-P1=1-1/N=(N-1)/N。
在第一项确定后,第N-1项可以被放置在位置2上,所以P-2是1-P2=1-1/(N-1)=(N-2)/(N-1)。
概括地说,Pi=1/(N-(i-1)),P-i=1-Pi=1-1/(N-(i-1))=(N-(i-1)-1)/(N-(i-1))=(N-i)/(N-i+1)。
将所有几率乘在一起,P-1×P-2×P-3×…×P-(k-1)×Pk,得到如下方程式:
观察以上方程式,会发现可以约分每一项的分子和其后一项的分母。完成所有约分后,方程式被简化为1/N。
这意味着无论k为何值,某项被放置在位置k的几率都是1/N,因此这个排列是公平的。
一个和随机化数组生成十分相似的任务是从无重复数组中选择某一确定数量的随机项。
例如,假设某作家正在举行一场赠送五本著作的抽签活动(我有时这样做),有100位参与者。一种找出这五位幸运儿的方法是将这100个名字编成一个数组,随机化该数组,然后向这个随机名单中的前五位赠书。每个确定姓名出现在五个获奖位置的几率是相同的,因此这次抽签是公平的。
2.1.3 生成不均匀分布
一些程序需要生成不均匀分布的伪随机数。这些程序通常模仿其他形式的随机数生成过程。例如,一个程序也许会通过模仿两个六面骰子的滚动来生成一个2和12之间的数。
这种情况下不可以简单地取2和12之间的一个伪随机数,因为滚动两个骰子所得到各个数的几率是不同的。
解决方法是实际模拟一个骰子的滚动,生成两个1和6之间的数,再将它们相加。