组合

数学和计算机科学中的许多问题都有递归解。在这节课的这一部分,我们将学习一个数学问题,它有一个优雅的递归解。

组合符号或者“n选k”在数学中有很多赢博体育。概率论中计算有多少种方法可以得到k序列中的正面n投掷一枚均匀的硬币。

是通过公式计算的吗

有两种特殊情况很容易计算。

计算组合的一种方法是计算赢博体育涉及的阶乘,然后将分母除以分子。即使对于中等大的n值,这也不实用,因为阶乘作为n的函数增长得非常快。相反,计算组合的最常见方法是利用这种递归关系:

这种递归关系加上这两种特殊情况使得在Python中构造递归函数定义来计算组合成为可能。

def C(n,k):如果k == 1:返回n如果k == n:返回1返回C(n-1,k-1)+C(n-1,k)

这是有效的,并且为组合计算正确的值。

这种方法确实有一个缺陷。考虑一下当我们尝试计算C(8,5)时会发生什么。

C(8,5)呼叫C(7,4)和C(7,5) C(7,4)呼叫C(6,3)和C(6,4) C(7,4)呼叫C(6,5) C(7,5)呼叫C(6,5) C(7,5)呼叫C(6,4) C(6,3)呼叫C(5,2)和C(5,3) C(6,4)呼叫C(5,3)和C(5,4) C(6,4)呼叫C(5,3)和C(5,4) C(6,5)呼叫C(5,4)和C(5,5)

过了一段时间,你开始注意到同一个函数被多次调用。对于较大的n和k,这种影响变得非常严重,以至于上面显示的简单递归函数变得非常低效。

我们可以构建一个程序来说明这个问题有多严重。诀窍是声明一个全局二维数组

count = np. 0 ((18,18),dtype=int)

它存储的信息是,对于每一对参数值n和k,我们调用C的次数。

然后,我们修改计算组合的函数,以记录每对参数调用它的次数。

def C(n,k): counts[n][k] += 1;如果k == 1:返回n如果k == n:返回1返回C(n-1,k-1)+C(n-1,k)

在程序的最后,我们将这个计数数据转储到一个文件中。

def saveCounts(): with open('counts.txt','w') as f: for row in counts: for col in row: f.write('{:5d}'.format(col)) f.write('\n')

结果令人震惊。下面是我们尝试计算C(16,10)时得到的计数集:

0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1287 1287 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 495 1287 792 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 165 495 792 462 0 0 0 0 0 0 0 0 0 0 0 0 0 0 45 165 330 462 252 0 0 0 0 0 0 0 0 0 0 0 0 0 9 45 120 210 252 126 0 0 0 0 0 0 0 0 0 0 0 0 1 9 36 84 126 126 56 0 0 0 0 0 0 0 0 0 0 0 0 1 8 28 56 70 56 21 0 0 0 0 0 0 0 0 0 0 0 0 1 7 21 35 35 21 6 0 0 0 0 0 0 0 0 0 0 0 0 1 6 15 20 15 6 1 0 0 0 0 0 0 0 0 0 0 0 0 1 5 10 10 5 1 0 0 0 0 0 0 0 0 0 0 04 6 0 0 1 4 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 3 3 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 2 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0

从这个表中我们可以看出,在计算C(16,10)的过程中,C(2,1)、C(2,2)和C(3,2)各被调用了1287次。随着n和k的增大,冗余函数调用的问题会呈指数级恶化。当n = 30时,这种简单的递归计算C(n,k)的方法就完全不实用了。

记忆的结果

消除对递归函数的冗余调用所需的简单修复称为记忆化。其思想是记住我们计算的任何值,以便下次我们被要求计算一个值时,我们可以查找它,而不是重新计算它。这对于具有中等数量整数参数的递归函数最有效。在这种情况下,我们可以通过将值存储在表中来记住前面计算的值。每当要求我们计算一个值时,我们首先查阅表。

对于计算组合的函数,我们进行如下操作。我们首先创建一个全局二维数组来保存记住的函数值。

C = np. 0 ((100,100),dtype=int)

然后重写递归函数以使用该表。每当我们被要求计算特定n和k的C(n,k)时,我们首先检查表,看看是否已经计算过了。如果还没有计算,则在返回结果之前计算它并将值保存在表中。

def C(n,k): result = C [n][k] if result == 0: if k == 1: result = n elif k == n: result = 1 else: result = C(n-1,k) +C(n-1,k) C [n][k] = result返回result

用表代替递归

记忆解的一个有趣的副作用是,它最终用c (n,k)的值填满了表c[n][k]。

有效解决组合问题的更直接方法是编写代码,用正确的值填充表。

我们可以用一个函数来做

def initTable(): #为c[n][k]填充正确的值#对于赢博体育n和k的值

一旦c数组正确地填满了值,我们就可以重写函数来计算组合

def C(n,k):如果k <= n:返回C [n][k]返回0

要编写initTable函数,我们只需要复制原始递归解决方案中的逻辑

def C(n,k):如果k == 1:返回n如果k == n:返回1返回C(n-1,k-1)+C(n-1,k)

在一组循环中。我们需要做两件事。第一种方法是写一对循环来填充基本情况中赢博体育n和k的c[n][k]项。组合的基本情况是k = 1和k = n:

def initTable(): #填充n在range(0,MAX_N)中的基本情况:c[n][k] = 1;C [n][1] = n;C [n][0] = 1;

最后,我们构建一个循环,为赢博体育剩余的条目填充c[n][k]。在构造循环时,我们必须小心确保c[n][k]的公式只使用循环中已经填入的项。下面是正确的循环结构。

MAX_N = 100 MAX_K = 100 c = np.zeros(MAX_N,MAX_K) def initTable(): #填充n在range(0,MAX_N)中的基本情况:c[n][k] = 1 c[n][1] = n c[n][0] = 1 #填充n在range(2,MAX_N)中的剩余条目:对于k在range(2,n)中的:c[n][k] = c[n][k] = c[n][k] = 0

伯格集成

在之前的课上,我们用梯形法则来估计曲线下的面积。其基本思想是将积分范围划分为大量的子区间。在每一个子区间上,我们构造一个梯形,其面积近似于该子区间上曲线下的面积。

一个这样的梯形的面积是h (f(xi) + f(xi+1))/2。把赢博体育这些梯形面积加起来,我们就得到了N个子区间的梯形面积估计值:

梯形法则为更复杂的方法提供了起点。这种方法的第一步是一种叫做理查森外推法的技术。

理查德森外推从考虑梯形规则中的误差项开始。当我们使用梯形规则来估计N个子区间和步长为h的积分时,梯形规则通常会产生精确到与h2成正比的因子的结果。

这里用的符号O(h2)表示误差是h2阶的。另一种写法是把误差项写成h的不同次幂的组合

(这有点超出了这里讨论的范围,但事实证明,梯形法则的误差项只包含h的偶次幂。)

下一步是运用理查森外推法,这是一种奇怪但有效的代数技巧。在这个技巧中,我们把误差关系写了两次,一次是针对N个梯形,就像我们上面看到的,第二次是针对2n个梯形。将梯形的数量增加2倍,将步长从h减小到h/2,因此我们得到误差的两个表达式:

第二个方程乘以4,然后减去第一个方程,得到

我们可以把它写成

通过使用这个简单的代数技巧,我们已经从一个误差项大小为0 (h2)的方法切换到一个误差项大小为0 (h4)的方法。

更好的是,这个技巧可以反复使用。我就不赘述细节了,但是迭代这个技巧是一种叫做Romberg积分的积分技术的基础。下面是Romberg技术的总结。函数R(k,j)表示该技术的第j次迭代,从N = 2k-1步的梯形规则开始。

R(a,b,k,1) =梯形面积(a,b,2k-1)

下面是一个Python程序,它使用这个递归公式来计算积分估计。

这是我们将在本例中使用的函数def (x):返回math.sqrt(4.0 - x**2) #通过使用步长为(b-a)/N的梯形#计算f(x)从a到b的积分#的近似值def梯形(a, b, N): h = (b-a)/N x = np。range(a, b, h) return h *(f(x).sum()) - h * f(a) / 2 + h * f(b) / 2 #递归定义的Romberg公式def Romberg (a,b,k,j):如果j == 1:返回trapezoidArea(a,b,2**(k-1)) elif k < j:返回0否则:返回Romberg (a,b,k,j-1) + (Romberg (a,b,k,j-1)- Romberg (a,b,k-1,j-1))/(4**(j-1)-1) print(“ j估计误差”);对于范围(2,6)中的j: estimate = romberg(0.0,2.0,4*j,j) error = math.fabs(math. fabs)。π-估计)打印(“{:> 3 d} {: f} {: .16f}”.format (j、估计、错误))

要解开这个程序正在做的事情需要一点努力。底部的循环是计算R(a,b, 4j,j)对于j在2到5的范围内。对于给定的j值,该方法从使用2k-1 = 24 j -1步的梯形规则估计开始。当j = 5时,这意味着使用220 - 1 = 524288步。

这个程序还有最后一个缺陷,这个缺陷严重地阻碍了从j= 4开始的计算。这里的问题是过度递归。从上面的代码中可以看到,对romberg函数的一个典型调用会触发三个额外的递归调用。如果这些依次触发对romberg函数的更多调用,那么最终可能会对该函数进行大量调用。

为了解决这个问题,我们可以使用一个简单的缓存策略。每次我们为k和j的特定组合计算romberg(a,b,k,j)的值时,我们应该记录下这个结果。下次我们需要这个值时,我们可以简单地查找结果,而不必重新计算任何东西。

程序的最终版本使用了这种缓存策略。我们建立了一个二维列表R,它可以存储k和j的多种组合的值。romberg函数然后将大部分工作交给递归内部函数r_int。r_int通过首先检查缓存来进行优化计算,看看k和j的组合是否已经记录了结果。如果是,它立即停止并返回缓存的值。如果没有,则返回递归公式来计算结果,然后在返回答案之前缓存该结果。

这是我们将在本例中使用的函数def (x):返回math.sqrt(4.0 - x**2) #通过使用步长为(b-a)/N的梯形#计算f(x)从a到b的积分#的近似值def梯形(a, b, N): h = (b-a)/N x = np。range(a, b, h) return h * (f(x).sum()) - h * f(a) / 2 + h * f(b) / 2 #递归定义的Romberg公式与缓存# r_int将计算R[k,j],但记住# result在R列表中以加快后续计算。def r_int(a,b,k,j,R): if R[k][j] != -1: #我们以前见过这个k,j吗?返回R [k] [j] #如果是,就返回缓存的值#如果没有,计算,缓存,并返回结果如果j = = 1: R [k] [j] = trapezoidArea (a, b, 2 * * (k - 1)) elif k <珍:R [k] [j] = 0: R [k] [j] = r_int (a, b, k, j - 1, R) + (r_int (a, b, k, j - 1, R) -r_int (a, b, k - 1、j - 1 R)) / (4 * * (j - 1) 1)返回R [k] [j] . def伯格(a, b, k, j): R = [[1 c的范围(j + 1)] R的范围(k + 1)]返回r_int (a, b, k, j, R)打印(“N估计错误”);对于range(4,27,2)中的j: estimate = romberg(0.0, 2.0, j, j) error = np.fabs(np。π-估计)打印(“{:8 d >} {: f} {: .16f}”.format (2 * * (j - 1)估计,错误))

注意,由于我们现在有了一个更有效的方法,我们可以安全地扩展我们计算的j的范围。下面是这个程序返回的结果。

N估计误差8 3.124218 0.0173744893594270 32 3.139447 0.0021459042519050 128 3.141325 0.0002678879206544 512 3.141559 0.0000334785532052 2048 3.141588 0.00000041846140313 8192 3.141592 0.000000530705571 32768 3.141593 0.000000000653836278 131072 3.141593 0.0000000010216188 2097152 3.141593 0.000000001277005 8388608 3.141593 0.0000000000159650 33554432 3.141593 0.00000000159650

编程任务

在这门课的前几节课中我们看到了一个多项式插值问题的例子。给定一个要插值的点列表(x0, y0), (x1, y1),…,(xn, yn),我们想构造一个经过这些点的n次多项式。

牛顿通过寻找一个特殊形式的多项式来解决这个问题,这个多项式被称为牛顿多项式:

Pn (x) = a0 + a1 (x - x0) + a2 (x - x0) (x - x1) +⋯+一个(x - x0) (x - x1)⋯(x - xn-1)

牛顿提出了一个递归关系这个多项式的系数满足。他引入了一种特殊的符号

使用这个符号,牛顿多项式的系数是

Ak = f[0,k]

编写一个程序,可以从文本文件中读取数据点列表,并构造一个牛顿多项式来插值这些点。文本文件中的每一行都包含一个x值和一个y值。您的程序应该读取这些数据点并将它们存储在两个列表xs和ys中。然后,程序将提示用户输入x的值,然后计算并打印该x处的牛顿多项式的值。

你的程序应该包含至少一个函数,f(i,j),用来计算因子f[i,j]。你可能还想写第二个函数,term(k,x)来计算插值多项式的第k项:

F [0,k] (x- x)(x - x1)⋯(x - xk-1)

为了测试您的程序,这里有一些测试数据。

x y
1.0 0.7651977
1.3 0.6200860
1.6 0.4554022
1.9 0.2818186
2.2 0.1103623

经过这些点的四次多项式在x = 1.5处的值约为0.5118200。