优秀程序的良好习惯-三-

50 阅读58分钟

优秀程序的良好习惯(三)

原文:Good Habits for Great Coding

协议:CC BY-NC-SA 4.0

十八、不要冷落低效的算法

在非常受欢迎的数学书籍《金奖券》(P、NP 和《寻找不可能的事情》)中,读者被要求将下面的 38 个数字分成两组不同的 19 个数字,每组的总和为 100 万。

    Lst =  [14175, 15055, 16616, 17495, 18072, 19390, 19731, 22161, 23320, 23717, 26343, 28725, 29127, 32257, 40020, 41867, 43155, 46298, 56734, 57176, 58306, 61848, 65825, 66042, 68634, 69189, 72936, 74287, 74537, 81942, 82027, 82623, 82802, 82988, 90467, 97042, 97507, 99564]

作者评论道,“没那么容易,是吧。有超过 170 亿种方法可以将这些数字分成两组。”作者心目中的编程解决方案是“动态规划”这种方法很难应用,所以整本书的例子都是为了帮助程序员提高技能而写的。

也就是说,还有另一种更简单的方法来解决这个问题,一种每个程序员都应该拥有的方法:快速失败猜测。我的代码如下。

    Lst =  [See above.]
    count = 0
    flag = True
    while flag:
    #----Initializing.
         count += 1
         s = set() # = empty set

    #----Randomly assemble 19 different indices.
         while len(s) < 19:
             s.add(randint(0,37)) # Duplicates are never added.

    #----Check the total.
         if sum(Lst[n] for n in s) == 1000000:

         #--Print the solution.
            s = sorted(s)
            print('Answer =', end = ' ')
            for n in s:
                print(Lst[n], end =', ')
            print('\ntotal =', sum(Lst[n] for n in s))
            print('This took', count, 'tries.')
            flag = False

我的代码不到十秒钟就解决了这个问题(大概 220000 次猜测)。显然,对于原始问题有许多解决方案。如果只有一种解决方案,那么在最坏的情况下,系统地检查每一种可能性可能需要几乎九天的时间(每秒 22,000 次独特的探测)。当我们需要一个快速的数值解,而又没有找到它的算法时,快速失败猜测法有时可以快速找到一个合适的答案,有时甚至是最好的答案。

下面是臭名昭著的冒泡排序,或者至少是我的六行版本:

  • 冒泡排序似乎没有什么值得推荐的,除了一个朗朗上口的名字,以及它会引出一些有趣的理论问题的事实。—唐纳德·克努特,《计算机编程的艺术》,第 3 卷。
def bubble(x):
    leng = len(x)
    for i in range(leng-1):
        for j in range(leng-i-1):
            if x[j] > x[j+1]:
               x[j], x[j+1] = x[j+1], x[j]
    return x

除了向初学者介绍排序算法,冒泡排序还有什么好处吗?我们会看到的。这种冒泡排序可以变得更有效。你明白了吗? [1

不需要return x。我把它放进去有两个原因。首先,调用x = bubble(x)明确地告诉读者x正在被修改,而不必查看函数的代码。第二,如果功能代码后来被修改,使得x的地址被重新分配,那么代码将仍然工作。

第一次冒泡排序将把最后一个元素放置到位。第二遍将放置倒数第二个元素,依此类推。每经过一次,我们就少排序一个元素。这解释了leng-i表达式。

我了解到冒泡排序是世界上对四个或更少元素最快的排序。这似乎很合理,多年来我一直在和我的学生分享这个事实。有一天,我决定将冒泡排序与内置的 Python 排序进行比较。为了对四个随机浮点数进行一百万次排序,Python 内置的排序用了非常短的时间:0.63 秒。上面的冒泡排序花了 2.93 秒。我没有预料到时间会有如此大的差异,我很沮丧,我长达几十年的说法似乎是错误的。然后我意识到我可以作弊。请看下面的bub1函数。

def bub1(x):
    if x[0] > x[1]:
       x[0], x[1] = x[1], x[0]
    if x[1] > x[2]:
       x[1], x[2] = x[2], x[1]
    if x[2] > x[3]:
       x[2], x[3] = x[3], x[2]
    if x[0] > x[1]:
       x[0], x[1] = x[1], x[0]
    if x[1] > x[2]:
       x[1], x[2] = x[2], x[1]
    if x[0] > x[1]:
       x[0], x[1] = x[1], x[0]
    return x

这要快得多(1 秒),但还不够快。我还能作弊吗?是的,请看bub2

def bub2(x):
    [a,b,c,d] = x
    if a > b:
       a, b = b, a
    if b > c:
       b, c = c, b
    if c > d:
       c, d = d, c
    if a > b:
       a, b = b, a
    if b > c:
       b, c = c, b
    if a > b:
       a, b = b, a
    return [a, b, c, d]

结果是 0.52 秒。我的说法被证实了。或者是吗?我的代码省略了循环,更改了数据类型(列表变量的访问速度比原始变量慢),并将列表元素复制到一个新列表中,而不是就地排序。这就是人们所认为的泡沫类型吗?此外,Python 内置的排序可能正在运行 C 代码(比 Python 代码快 40-50 倍)。

我的小实验没有说服力。我需要对同样用 Python 编写的快速排序运行标准冒泡排序(bubble)。在堆栈溢出网站上,我发现了下面这个巧妙而快速的快速排序版本

def quickSort(array):
    if len(array) < 2:
        return array
    less, equal, greater = [], [], []
    pivot = array[0]

    for x in array:
        if x <  pivot:
            less.append(x)
        elif x == pivot:
            equal.append(x)
        else:
            greater.append(x)

    return quickSort(less) + equal + quickSort(greater)

这段代码运行耗时长达 3 秒。当以类似的方式编程时,对于四个元素,标准冒泡排序比快速排序稍快。你可能不会被打动。谁在乎排序四个元素?

假设您需要就地排序一个大列表(几乎没有额外的内存)。你会用哪种?也许不是快速排序,因为在迭代和递归版本中都需要额外的堆栈内存。你会因为太慢而拒绝冒泡排序吗?那将是一个巨大的错误。快速排序的速度只有调好的冒泡排序的两到三倍,而且冒泡排序更容易编程。让我们假设一百万个随机整数。再看一下这条冒泡排序线:

for j in range(leng-i-1).

-i使得冒泡排序更加有效,因为移动到末尾的元素不需要重新检查。假设我们将-i-gap交换,其中变量gap(初始化为leng = len(x))在每次传递中都会减小大小(通过除以1.3),直到它变成1

这个聪明的技巧(首次发表于 1980 年)产生了所谓的梳状排序。梳状排序的速度是快速排序的一半到三分之一,更容易编程,而且(因为它是交换排序)几乎不需要额外的内存。【参见维基百科,s.v. comb sort。]

那么梳状排序是冒泡排序还是近亲排序呢?这个问题没有答案,因为冒泡排序的定义并不精确。在这一章中,我的观点是,在某些情况下,即使是低效的想法也可能是高效的。

尽管 comb 排序听起来很简单,但我花了两个多小时来编写、调试、测试和重构代码。什么花了这么长时间?为什么不自己写这种排序,并把你的代码和编程时间与我的进行比较。我的代码如下:

def combSort(array):
    aLength    = len(array)
    recentSwap = False
    gap        = aLength
    while recentSwap or gap > 1:
        gap        = max(1, int(gap/1.3))
        recentSwap = False
        for i in range(aLength-gap):
            j = i+gap
            if array[i] > array[j]:
               array[i],  array[j] = array[j], array[i]
               recentSwap = True
    return array

我写了下面的代码来测试我的作品:

def sortTest(trialRuns, sortFunct):
#---This sub function checks if an array is sorted or not.
    def arraySorted(x):
        for i in range(len(x)-1):
            if x[i] > x[i+1]:
                print('NOT SORTED! at positions', i, 'and', i+1)
                return False
        return True

#---Create random-sized array of random integers, then sort and check if sorted.
    for n in range(trialRuns):
        listSize = randint(0,50)
        array = []
        r = randint(0,20)
        for i in range(listSize):
            array.append(randint(-r,r))

        sortFunct(array)

        if not arraySorted(array):
            exit()

    print('\nTested', sortFunct)
    print('Passed test of', trialRuns, 'random trialRuns.')
    print('-'*46)
#============<GLOBAL CONSTANTS and GLOBAL IMPORTS>=============

from random import shuffle, randint
#========================<MAIN>================================

def main():
    sortTest(trialRuns = 10000, sortFunct = combSort)
#--------------------------------------------------------------

为了测试combSort实际上对数组进行了排序,我必须编写一个布尔arraySorted函数来检查数组中的每个元素。我在sortTest函数中嵌入了这个函数。然后,sortTest函数用随机整数创建了 10,000 个随机大小的数组,并测试了combSort 10,000 次。作业是写一个函数,comb sort,但是我觉得我必须再写两个函数来信任我的代码。因此,这两个小时。

最快的排序是 nlog(n)阶。这实际上是 knlog(n),其中 k 取决于编程效率、处理器速度等。这个表达式使得对数函数的基数看起来必须是 10。但基数是多少并不重要,因为只有一个 log 函数(你自己选)。所有其他的都是你选择的对数函数的倍数,例如{\log}_{10}(x)=c{\log}_2(x),其中 c 不随 x 的变化而变化。你能计算出这个方程中 c 的数值吗?答案在脚注里。 2

在特殊情况下,我们可以比 n log(n) order 时间更快地排序 n 个数字。假设我需要对一个包含 10000 个随机数的列表进行排序。为什么我不能直接读入上周排序的一万个随机数的列表。在这种情况下,排序是常数,顺序为 O(1)。假设我要对 1 到 100 范围内的 10000 个整数进行排序。在这种情况下,我可以计算每个值有多少个,并生成一个排序列表。这是一种线性顺序,O(n)。作为一个挑战,现在就写这个countSort。你可以把你的代码的可读性和我的进行比较。sortTes t 代码可以重复使用。我的代码如下:

def countSort(array, max):
#---This array is assumed to take values in the range of 0 to max (inclusive).
    counters = [0] * max

    for number in array:
        counters[number] += 1

    array = []
    for (number, count) in enumerate(counters):
        array.extend([number]*count)

    return array

在编程时,我们总是需要问一些问题,我在这里问了这些问题:

  1. 如果我们的函数是一个算法,测试应该先写吗?
  2. 如果我们需要一个列表元素和它的索引,我们应该使用 Python enumerate函数吗?
  3. 如果一个for循环产生一个列表,我们应该使用列表理解吗?

countSort的三个答案是是、是和否。通过使用内置的enumerate函数和使用extend而不是append,我能够只用两个循环编写这段代码。尽管我们有一个产生列表的for循环,但如果不对排序后的数组进行额外的展平,我就无法理解列表,我认为这会使函数变得复杂。

这里的寓意是,某些原本效率低下的算法可能在某些情况下工作得很好,或者具有优势——例如,快速编程——这使得它在特定情况下是一个不错的选择。

我在某处读到过,对于 50 个元素或更少的数据大小,所有算法都是有效的。即使是不起眼的——写起来很琐碎——冒泡排序在这样的数据集上看起来也不错。

Footnotes 1

j+1计算进行三次。让k = j+1然后让k替换j+1。一个学生不得不向我指出这一点。

  2

c={\log}_{10}(x)/{\log}_2(x)={\log}_{10}(x)\div \left({\log}_{10}(x)/{\log}_{10}(2)\right)={\log}_{10}(2)=0.30102\dots

十九、值得解决的问题

  • 在一门运行良好的计算机课程中,学生要做许多练习。他至少也要做一道题。区别在于:一个练习与一个特定的技术相关,并且方法通常被详细说明。另一方面,一个问题将涉及一个广泛的目标,使用许多技术,很少详细说明。——弗雷德·格雷伯格(兰德)和乔治·贾夫雷(洛杉矶山谷学院),《计算机解决方案的问题》(约翰·威利,1965),第十五页。

我以前的几个学生后来成了程序员,他们回来给我讲专业编程。其中一个人提到他已经建立了短期的课后班来帮助新程序员提高他们的技能。他告诉我,他很失望地发现,一些新程序员不是试图解决他给他们的问题,而是在互联网上找到解决方案,并将其作为自己的工作上交。我的猜测是,这些程序员在学校里太依赖朋友、互联网的帮助,或许还有分数膨胀。

以下面试型问题 1 不仅根据代码运行情况评分,还根据其设计和可读性评分。祝你好运。

问题一。微软程序员史蒂夫·马奎尔(Steve Maguire)曾经请透视程序员为他写代码。 2 这是令人望而生畏的,因为有很多算法是很难动态规划的。有一次,Maguire 要求他的候选人写一个只大写字母的函数。忽略这样一个事实,即已经有一个内置函数可以做到这一点。虽然这听起来很简单,但超过一半的受访程序员没有完成令人满意的工作。既然每个候选人都可能提交了有效的代码,那么 Maguire 的反对理由是什么呢?写出你自己的代码函数,并将其与下面的几个设计进行比较。

#                       Problem 1 Answers
#=====================<FIVE POSSIBLE ANSWERS>==================

def upper1(ch): # Bad. It should ignore non-lowercase letters.
    return chr(ord(ch) - 32)
#------------------------------------------------------------

def upper2(ch): # BAD: It aborts program.
    if 'a' <= ch <= 'z':
        return chr(ord(ch) - 32)
    exit('ERROR: Bad input = ' + str(ch))
#------------------------------------------------------------

def upper3(ch): # BAD: It returns TWO different data types.
    if 'a' <= ch <= 'z':
        return chr(ord(ch) - 32)
    return -1
#------------------------------------------------------------

def upper4(ch): # OK, however, the error traps are unnecessary.
    assert type(ch) == str and len(ch) == 1, ch
    if 'a' <= ch <= 'z':
        ch = chr(ord(ch) - 32)
    return ch
#------------------------------------------------------------

def upper5(ch): # Best: 1\. It ignores non-lowercase letters.
                #       2\. It returns only one data type.
                #       3\. It has no needless error traps.
    if 'a' <= ch <= 'z':
        ch = chr(ord(ch) - 32)
    return ch

要问的重要问题是,“背景是什么?”可能这个函数将用于帮助解析一个字符串,其中用户只需要一种形式的字母(大写)。如果将一个数字或标点符号传递给函数会发生什么?该函数可能会忽略它。如果将多字符字符串传递给函数会发生什么?这是一个巨大的错误,应该抛出一个异常。

问题二。用你最喜欢的语言,或者用伪代码,写一个名为equal的函数,它将接受两个数字num1num2(浮点数、整数或者两者各一)。如果这两个数字相差万亿分之一或更多,该函数将返回False,否则返回True。之所以选择万亿分之一,是因为在 Python 中,你可以将十分之一(0.1)加近 1000 次,然后你就会得到正负万亿分之一的舍入误差。 3 完成后,将你的工作与下面我的 Python 解决方案进行比较。

#              Problem 2 Answers
#
def equal1(num1, num2): # Terrible code
#---Check the data
    if not isinstance(num1, (int, float)) or \
       not isinstance(num2, (int, float)):
       return None
#---Return equality (True or false)
    if abs(num1 - num2) < 0.000000000001:
       return True
    return False

def equal2(x, y):  # Ex.: equals2(0.000 000 000 01,  0) is False,
                   # but  equals2(0.000 000 000 001, 0) is True.
    return abs(x-y) <= 1e-12 # 1e-12 = 0.000 000 000 001 (eleven decimal zeros)

def equal3(x, y): # Ex.: equals3(0.000 000 000 01,  0) is False,
                  # but  equals3(0.000 000 000 001, 0) is True.
    return round (x, 11) == round (y, 11) # 1e-12 = 0.000 000 000 001 = 1 billionth

注意,第一个版本返回两种不同的数据类型:Boolean 和 None。这通常是一个错误。第一个版本中的错误陷阱是不必要的,因为编译器会在运行时捕获这个错误。这些注释在接下来的两个版本中会有所帮助。最后一个函数似乎最容易调试。

问题三。想想著名的伯特兰盒子悖论(1889)。 4

  • 一个柜子有三个抽屉。每个抽屉里有两枚硬币。一个抽屉里有两枚金币。另一个抽屉里有一枚金币和一枚银币。最后一个抽屉里有两枚银币。你走到柜子前,随便拉出一个抽屉。你伸手进去,随机拿出一枚硬币。这是一枚金币。另一枚硬币也是金币的概率是多少(0 到 1 之间的数字)?

通过计算机模拟编写解决这个问题的代码片段。换句话说,用计算机代码制作一个抽象模型来重现谜题中描述的情况。然后将这种情况运行 100,000 次,以发现当选择的第一个硬币是黄金时,第二个硬币是黄金的频率。接下来打印这个比例,就是答案。我的解决方案如下。

#              Problem 3 My Answer

def solveBertrandsParadox():
#---Initialize.
    from random import randint
    trials    = 100000
    goldFirst = 0
    goldMatch = 0
    coin      = [['gold',  'gold'  ],
                 ['gold',  'silver'],
                 ['silver','silver'],]

#---Run many simulation trials.
    for n in range(trials):
        drawer   = randint(0,2)
        position = randint(0,1)
        if coin[drawer][position] == 'silver':
           continue
        goldFirst += 1
        if coin[drawer][position] == coin[drawer][1-position]:
           goldMatch += 1

#---Print labeled answer.
    print('Six coin answer for', trials, 'trials:',
            round(goldMatch/goldFirst * 100,  1), '%')

输出答案应该是 2/3,而不是 1/2。请注意,continue语句回答了这个问题,“如果先选择银币会发生什么?”这种设计密切反映了物理现实;您不希望在模拟中缩写或浓缩。

关于计算机证明的注记(模拟与验证):计算机模拟有多重要?据我所知,只有五种方法可以在科学上取得进步:1)抽象建模(有数学证明),2)实地观察,3)实验,4)测量值的数学计算,5)计算机模拟。在某些情况下,有些人更喜欢模拟而不是数学证明,尤其是当他们不能理解数学证明的时候。但是即使是最严格的证明也有一些哲学上的异议。 5

  • 问题 4a。从 18 世纪到 1910 年,剑桥大学举行了名为“Tripos”的纯数学和应用数学考试这些考试异常困难,持续了几天。在 1854 年 1 月 18 日上午的会议中,提出了以下问题:一根[直]杆被随机标记在两个点上,然后在这些点上分成三部分;确定这三块组成三角形的概率。 6 你的工作是利用计算机模拟来回答 Tripos 问题——即将物理现实转化为由计算机代码形成的虚拟世界。然后反复运行你的假冒现实(10,000,000 次),数一数某些事件发生了多少次或者没有发生。通过形成这些计数的比率,可以获得描述真实世界的概率(精确到小数点后三位)。
  • 问题 4b。奇怪的是,“随机”还有另一个定义可以应用于这个问题。一个人会折断给定的棍子一次,然后折断两段中较长的一段。你的工作还是用这个“随机”的定义来解决 Tripos 问题
  • 问题 4c。令人惊讶的是,“随机”还有第三种定义可以适用于这个问题。一个人可能会折断给定的棍子一次,然后随机地抓住两根棍子中的一根来折断下一根。同样,你的工作是使用“随机”的第三个定义来解决 Tripos 问题
  • 问题 4d。信不信由你,在这个问题上,“随机”还有第四个定义。一个人可能会折断一次给定的棍子,然后以与它的长度成正比的概率随机抓住两个碎片中的一个。[例如,如果一个棋子的长度是另一个的两倍,那么较长的那个棋子将有 2/3 的概率被选作第二个断点。]然后将选择的棋子分成两部分。同样,你的工作是用“随机”的第四个定义来解决 Tripos 问题
########################<START OF PROGRAM>####################
"""
   VERSION 4a. Two break points are randomly marked on the given stick, and the stick is broken into
               three parts.
"""
def puzzle4a():
    triangleCount = 0

    for n in range(TOTAL_RUNS):
        a, b  = random(), random()
        if a > b:
            a, b = b, a          # a   = length of left   piece
        if (a < 0.5 and b-a < 0.5 and b > 0.5):                           # b-a = length of middle piece.
           triangleCount += 1                          # 1-b = length of right  piece.

    print('Puzzle 4a: The probability of forming a triangle is',
                      round( triangleCount/TOTAL_RUNS, 3) )
#---Output: Probability of forming a triangle is +------+ in 4.39 seconds.
#                                                | 0.25 |
#                                                +------+
#----------------------------------------computer simulation--

"""
   VERSION 4b. One break point is randomly marked on the given stick. The stick is broken into two parts.
               A second break point is marked on the longer of
               the two sticks. That stick is broken.
"""
#----------------------------------------computer simulation--

def puzzle4b():
    triangleCount = 0

    for n in range(TOTAL_RUNS):
        a = random()
        if a < 0.5:
           b = uniform(a, 1)
        else:
            b = a
            a = uniform(0, b)
        if (a < 0.5 and b-a < 0.5 and b > 0.5): # a < b
           triangleCount += 1

    print('Puzzle 4b: The probability of forming a triangle is',
                      round( triangleCount/TOTAL_RUNS, 3) )
#---Output: Probability of forming a triangle is  +-------+ in 8.3 seconds.
#                                                 | 0.386 |
#                                                 +-------+
#-----------------------------------------computer simulation--

"""
   VERSION 4c. One break point is randomly marked on the given stick.The stick is broken. One of the sticks
               is randomly chosen,and a second break point is
               marked on it. That stick is broken.
"""
def puzzle4c():
    triangleCount = 0

    for n in range(TOTAL_RUNS):
        r    = random()      # r = first break point
        if random() < 0.5:   # flip a coin
           a = uniform(0, r) # cut on the left side
           b = r
        else:
            a = r            # cut on the right side
            b = uniform(r, 1)
        if (a < 0.5 and b-a < 0.5 and b > 0.5): # a < b
           triangleCount += 1

    print('Puzzle 4c: The probability of forming a triangle is',
                      round( triangleCount/TOTAL_RUNS, 3) )
#---Output: Probability of forming a triangle is  +-------+ in 8.70 seconds.
#                                                 | 0.193 |
#                                                 +-------+
#-----------------------------------------computer simulation--

"""
   VERSION 4d. One break point is randomly marked on the given stick. The stick is broken. One of the sticks
               is randomly chosen WITH A PROBABILITY
               PROPORTIONAL TO ITS LENGTH, and a second break point is marked
               on it. That stick is broken.
"""
def puzzle4d():
    triangleCount = 0

    for n in range(TOTAL_RUNS):
        r    = random()      # r = first break point
        if random() < r:     # break left stick
           a = uniform(0, r)
           b = r
        else:                # break right stick
            a = r
            b = uniform(r, 1)
        if (a < 0.5 and b-a < 0.5 and b > 0.5): # a < b
           triangleCount += 1

    print('Puzzle 4d: The probability of forming a triangle is',
                      round( triangleCount/TOTAL_RUNS, 3) )
#---Output: Probability of forming a triangle is  +-------+ in 8.68 seconds
#                                                 | 0.25  |
#                                                 +-------+

请注意,问题 4d 的答案与问题 4a 的答案相同。这里要吸取的一个教训是,在你编程之前要确保你理解了问题,尤其是一个概率问题。随机这个词可以有不同的意思。

  • 我认为,数学的这个分支[概率论]是唯一一个优秀作家经常得出完全错误的结果的分支。—查尔斯·s·皮尔士,“机会主义”,《大众科学月刊》(1878),见于贾斯汀·布赫勒,《皮尔士哲学著作》(多佛,1955),第 157 页。

问题 5。(开发一个算法。 7 )有时候我们需要从一个序列的某种排序中生成第r个排列(n -choose- r = nPr)。写这个函数。特别是,编写一个名为permute(Lst, r)的递归函数来接受一个类似Lst = [0,1,2,3,]的序列和一个正整数,比如r = 13。然后 permute 函数返回给定序列的第r个排列。当然“排序”是任意的,但是对于特定的问题是固定的。【我记得写这个函数花了 45 分钟。]

例:[0,1,2,3,]有 24 种排列,如下图。在这个排序下,对这个问题来说是极好的,第 13 个排列是[2,0,3,1,]。好的符号可以使问题更容易解决。我们从0(不是1)开始计数。

+-------------------------------------------------------------------+
|                  ==> Lst = [0,1,2,3,] <==                         |
|                                                                   |
| 0 [0, 1, 2, 3,]  6 [1, 0, 2, 3,] 12 [2, 0, 1, 3,] 18 [3, 0, 1, 2,]     |
| 1 [0, 1, 3, 2,]  7 [1, 0, 3, 2,] 13 [2, 0, 3, 1,] 19 [3, 0, 2, 1,]     |
| 2 [0, 2, 1, 3,]  8 [1, 2, 0, 3,] 14 [2, 1, 0, 3,] 20 [3, 1, 0, 2,]     |
| 3 [0, 2, 3, 1,]  9 [1, 2, 3, 0,] 15 [2, 1, 3, 0,] 21 [3, 1, 2, 0,]     |
| 4 [0, 3, 1, 2,] 10 [1, 3, 0, 2,] 16 [2, 3, 0, 1,] 22 [3, 2, 0, 1,]     |
| 5 [0, 3, 2, 1,] 11 [1, 3, 2, 0,] 17 [2, 3, 1, 0,] 23 [3, 2, 1, 0,]     |
+-------------------------------------------------------------------+

如果我们可以提取最左边的数字([2, 0, 3, 1]中的2,那么我们可以递归地调用我们的函数从一个较小的列表中提取最左边的数字,等等。重要提示:我们会错过Lst = [0,1,3,]。也就是说,我们会跳过去掉了2[0,1,2,3,],而不是[0,3,1,]。这个违反直觉的事实让我困惑了一会儿。我的代码如下。

Problem 5 My Answer

def permute(Lst, r):
    from math import factorial

    L = len(Lst)
    assert L>=1 and r>=0 and r<factorial(L), ['L=', L, 'r=', r]
    Lst = Lst[:]
    if L == 1: return Lst

    d     = factorial(L-1)
    digit = Lst[r//d]
    Lst.remove(digit)
    return [digit] + permute(Lst, r%d)

问题 6。编写一个名为fizzBuzz(limit)的函数,打印从1limit = 100的正整数。但是对于 3 的倍数,它打印“Fizz”而不是整数;对于 5 的倍数,它打印“嗡嗡声”而不是整数;对于 3 和 5 的倍数,它打印短语“嘶嘶和嗡嗡声”,而不是整数。见维基百科“嘶嘶嗡嗡”的文章。

编程大师和互联网博客作者杰夫·阿特伍德用这个测试来测试申请公司职位的程序员。你认为他能基于这样一个简单的测试对一个程序员做出好的决定吗?

当我在高中时,我听到一个餐馆检查员声称他可以根据点一杯咖啡来给一家餐馆评级。我当时怀疑他的说法。多年后的今天,我认为他的说法至少有一半是真的。在糟糕的餐馆里,一切似乎都很糟糕:食物、服务、银器、瓷器和环境。全体员工似乎对细节都不敏感。

也许阿特伍德先生可以用这个小小的测试淘汰最差的程序员。在你写完这段代码后,我将向你展示几种解决方案。大部分展示了一些巧妙的设计,但也有少数很糟糕。这绝对是一个值得一个学生花时间思考的问题。

#                Problem 6: Answers
#
#--Solution 1 Best, because it is so easy to debug.
    for x in range(1,101):
        if x % 15 == 0: print('Fizz and Buzz'); continue
        if x %  3 == 0: print('Fizz');          continue
        if x %  5 == 0: print('Buzz');          continue
        print(x)
#-------------------------------------------------------------

#--Solution 2  Mr. Stueben's solution.
    for x in range(1, 101):
        if x % 15 == 0:                print('Fizz and Buzz')
        if x % 3  == 0 and x % 5 != 0: print('Fizz')
        if x % 5  == 0 and x % 3 != 0: print('Buzz')
        if x % 5  != 0 and x % 3 != 0: print(x)
#-------------------------------------------------------------

#--Solution 3 Not bad.
    for x in range(1, 101):
        if x % 15 == 0:
            print('Fizz and Buzz')
        elif x % 3 == 0:
            print('Fizz')
        elif x % 5 == 0:
            print('Buzz')
        else:
            print(x)
#-------------------------------------------------------------

#--Solution 4 Clever.
    for x in range(1, 101):
        stng = ''
        if x % 3  == 0: stng += 'Fizz'
        if x % 15 == 0: stng += ' and '
        if x % 5  == 0: stng += 'Buzz'
        print(stng if stng else x)
#-------------------------------------------------------------

#--Solution 5 Maybe too clever.
    for x in range(1, 101):
        stng =      'Fizz and Buzz' if x%15 == 0 \
              else  'Fizz'          if x% 3 == 0 \
              else  'Buzz'          if x% 5 == 0 \
              else  ''
        print(stng if stng else x)
#-------------------------------------------------------------

#--Solution 6 # The "not" makes the code more difficult to understand.
    for n in range (101):
        stng = str(n)
        if not(n%3): stng = 'Fizz'
        if not(n%5): stng = 'Buzz'
        if not(n%3 + n%5):
                     stng ='Fizz and Buzz'
        print(n, stng)
#-------------------------------------------------------------

#--Solution 7 This code says much about the programmer's lack
#             of experience in refactoring.
    for n in range(1,101):
       flag = True
       if n%3 == 0:
          print('Fizz', end = '')
          if n%15 == 0:
             print(' and Buzz', end = '')
          print()
          flag = False
       if flag and n%5 == 0:
          print('Buzz')
          flag = False
       if flag:
          print(n)
#-------------------------------------------------------------

#--Solution 8 Why would anyone work with x+1 instead of x? Why would
#             anyone write "if (x+1) % 3 == 0: if (x+1) % 5 == 0",
#             instead of a single "if (x+1) % 15 == 0"?
    for x in range(100):
        if (x+1) % 3 ==0:
           if (x+1) % 5 == 0:
              print('Fizz and Buzz')
           else:
              print('Fizz')
        elif (x+1) % 5 == 0:
              print('Buzz')
        else:
              print((x+1))

我的学生花了三到七分钟手写这个圈(铅笔和纸)。总共有 45 名学生(73%)通过了考试,18 名学生(29%)不及格。我没有让任何写不必要的复杂代码的学生不及格。失败的主要原因不是通过几个例子在精神上反复检查逻辑。至少这个练习告诉了我不应该雇佣谁做暑期助理。谢谢杰夫·阿特伍德。

  • 非常坦率地说,我宁愿淘汰那些不早开始小心的人,而不是晚。这听起来很无情,老天作证,的确如此。但它不是一些人认为的“如果你不能承受压力,就离开厨房”那种言论。不,这是更深层的东西:我不愿意和不小心的人一起工作。这是软件开发中的达尔文主义。—Linus Torvalds(Linux 的创造者),发现于比尔·布伦登,《软件驱魔》(Apress,2003),第 1 页。

问题 4a 的注释。事实上,数学证明很容易理解,但是很难建立,除非你有一些这种证明的经验。

认为棍子是从 0 到 1 的区间。这两次切割是在区间上随机选择的两个数字。设 x 是较小的数,y 是较大的数。我们可以考虑在单位正方形的左上部分随机选择有序对(x,y)。见图。如果三块拼成一个三角形,那么 x 不能大于。(区域 I 被消除。)且 y 不得小于。(区域 II 被删除。)最后,从 x 到 y 的距离不得大于。(区域三被删除。)由于左上方的四个三角形都全等,所以答案一定是。资料来源:托马斯·j·班农,《数学教师》,第 103 卷,第 1 期(2009 年 8 月),第 56-61 页。

A461100_1_En_19_Figa_HTML.jpg

任务:写一个程序来模拟通过三次随机切割将一个圆切割成三块。一块比半圆大的概率是多少?【备选说法:圆上随机选取的三个点包含在半圆内的概率是多少?]惊喜,这和把一根棍子分成三段做三角形是一个问题。为什么呢?因为第一刀会把圆切成一段。那么接下来的两个切割对应的是上一个问题中的 x 和 y。然而,这个重新表述的直杆问题的答案是。

Footnotes 1

目前在互联网上,你可以找到计算机科学家兼记者布莱恩·海斯的一组精彩的 C.S .文章和书评。只需输入Brian Hayes - American Scientist或进入 http://www.americanscientist.org/authors/detail/brian-hayes

  2

史蒂夫·马奎尔,《编写可靠的代码》(微软出版社,1993 年),第 100-101 页。

  3

这个万亿分之一的证明对你来说有效吗?当我第一次写它的时候,它确实对我有影响,但是后来我意识到它只不过是好听的话。我把这类东西归类为形而上学,在我看来,这是废话的另一种说法。“形而上学,是由语言传播的妄想的沃土……”—j·s·穆勒。“那就把它(任何一本形而上学的书)付之一炬吧:因为它除了诡辩和幻觉之外什么也没有。”——大卫·休谟。以下是一个玄学笑话,我认为,说明玄学离疯狂只有一步之遥。

当伟大的法国哲学家让-保罗·萨特年轻的时候,他问他的叔叔,他是否可以在周六下午在他叔叔的咖啡馆里做服务员,这样他就可以赚些零花钱。已经知道年轻的萨特是个怪人的叔叔犹豫不决,但还是决定让他的侄子试一试。“记住今天的菜单,我来考你,”叔叔说。萨特花了异常长的时间研究菜单,甚至坚持要和厨房核实。但最终他完成了任务。“穿上这条围裙,去那边招呼顾客,我会看着你的,”他叔叔说。萨特照办了,走近顾客。“先生,我能为您做些什么?”年轻的萨特问道。“给我一杯咖啡,不加奶油,”顾客回答。“我们没有奶油了,”萨特说。"我给你拿杯不加牛奶的咖啡好吗?"

  4

关于可读和有趣的讨论,见维基百科“伯特兰的盒子悖论”这篇文章参考了其他简单到状态的谜题和违反直觉的答案,它们为高中计算机科学学生提供了极好的练习题。

  5

推论,规则。演绎方法(假定不会导致错误),通常以谨慎的方式(希望不涉及错误)与公理(认为不会不一致)结合,在数学研究中产生定理(假定不会自相矛盾),数学是一门结论被认为绝对确定的科学。【换句话说,演绎最终是建立在归纳的基础上。]

  6

资料来源:Gerald S. Goodman,“折断的棍子的问题”,《数学智能》,第 30 卷,第 3 期(Springer,2008),第 43–49 页。为了我们的目的,我稍微修改了一下问题的措辞。分号后的那一行原来是这样写的:"证明用这些棋子组成三角形的可能性是。"

  7

我最喜欢的描述计算机科学的方式是说它是研究算法的。—唐纳德·e·克努特,“计算机科学及其与数学的关系”,《美国数学月刊》(1974 年 4 月),第 323 页。

二十、解决问题

  • 在路上的瘸子跑得比迷路的雨燕还快。—弗朗西斯·培根(科学哲学家),《新有机论》(1620),第六十一部分。
  • 半夜写代码可能很有趣,但是在作业到期的那天半夜写代码就不好玩了。—一名大四学生正在上他的第四节编程课(2011 年 12 月)。
  • 这么多短篇之所以平淡无奇,另一个原因是有足够写作经验的学术作家太少了。一个好的作家,就像一个好的钢琴家,需要日常练习和对艺术本身的热爱。为了保持练习,他必须每周至少写三到五千字。——g . b .哈里森《英语专业》(双日,1962),第 111 页。 1

当你不知道该做什么的时候你会做什么?当然,你可以上网查查,复习一下书,和别人聊聊天——如果你能让他们听进去的话。在那之后,当没有新的想法出现时,然后呢?看起来似乎没什么可做的。但这是不正确的。

首先,开始编造例子:寻找模式、观察和关系。(“极端案例特别有教育意义。”—Polya)当你注意到关系时,你可以从关系的角度来思考。这叫深度思考。如果你能在关系中找到关系,那么你就能做更深层次的思考。第二,工作相关,但是比较容易的问题。这样你就训练自己去解决原来的问题。

我已经列出了要采取的两个行动。还有第三个行动比前两个更重要:带着尝试解决挑战性问题的历史来解决问题。这是解决所有难题的关键,也是为什么练习解决问题很重要。我说重要了吗?“至关重要”是更好的词。

那么一个人如何有效地学习或练习呢?第一步是模仿和记忆。第二步是尝试自己解决许多具有挑战性的问题。如果经过相当大的努力,你还是不能解决问题,那就寻求帮助。但是你不能忽略斗争。否则,技能和事实记忆都会受到阻碍。已故数学家乔治·波利亚试图将这一建议浓缩成以下轶事:

  • 房东太太急忙跑到后院,把捕鼠器放在地上(这是一个老式的捕鼠器,一个有活板门的笼子),并喊她的女儿去把猫拿来。陷阱里的老鼠似乎明白这些程序的要点;他在笼子里疯狂地奔跑,猛烈地扑向栅栏,时而在这边,时而在那边,最后一刻他成功地挤过栅栏,消失在邻居的地里。在捕鼠器的栅栏之间,在那一边一定有一个稍微宽一点的开口。女房东看起来很失望,迟到的猫也是。我从一开始就同情老鼠;所以我发现很难对房东太太或猫说些礼貌的话;但是我默默的恭喜了老鼠。他解决了一个大问题,并且树立了一个好榜样。
  • 那才是解决问题的方法。我们必须一次又一次地尝试,直到最终我们认识到一切所依赖的各种开口之间的细微差别。我们必须改变我们的试验,以便我们可以探索问题的所有方面。事实上,我们无法预先知道哪边是我们可以挤过的唯一可行的开口。
  • 老鼠和人的根本方法是一样的;尝试,再尝试,改变尝试,这样我们就不会错过一些有利的可能性。
  • ——乔治·波利亚,《数学发现》,合并版(威利出版社,1981 年),第 75-76 页。

学习的第三步是反思结果和经历。每当你解决一个棘手的问题时,你都需要变得富有哲理:你应该注意到什么,以便更快找到解决方案?解决方案可以简化吗?该解决方案是否提供了解决其他问题的关键?

一种奇特的自我反省方法叫做“五个为什么法” 3 举例:

  1. 为什么会这样?(我忽略了一个特例。)
  2. 为什么我忽略了这个特例?(我从来没想过。)
  3. 为什么我没有想到?(我的思考很肤浅。)
  4. 为什么我的想法很肤浅?(我工作太快了。)
  5. 为什么我工作得太快了?(我想结束。)

第四步,也是最后一步,与聪明人交往,让他们谈生意(或者读他们写的书)。

斯坦福计算机科学教授 Donald Knuth 对编程中的常见错误做了一个有趣的观察。

  • 在《计算机编程的艺术》第一卷中,我写道:“另一个好的调试实践是记录下所犯的每一个错误。它将帮助你学会如何减少未来的错误。”但是如果你问这样的日志[TEX 中的 916 个错误]是否帮助我学会了如何减少未来的错误,我的答案是否定的。我继续犯同样的错误。——Donald e . Knuth,《识字编程》,CSLI 讲义 27(语言和信息研究中心,1992 年),第 286 页。

我想 Knuth 说的是我们都会犯的小问题,我们都会很快解决。这种错误比妨碍编程更令人尴尬。当然,一些编程人员在编程多年后并没有变得更好,因为他们没有分析自己的错误,忘记了太多的经验。他们与工作脱节。其他人则相反,随着他们解决每一个难题而变得更好。

以下是我与学生们分享的常见错误汇编。这个列表减少了他们的错误吗?很少,因为这样的清单必须从个人经验中构建,以便在需要时回忆起来。同样,每个学生必须自学。老师只是选择问题,然后在学生准备好欣赏它们时提供见解。

  1. 您互换了参数—例如,(a,b)被作为(b,a)传递;坐标xy互换;矩阵行和列下标互换。
  2. 您有一个内存位置错误。某些内容被移动、覆盖,或者您的引用被意外更改。
  3. 你有一个混淆错误——即两个变量访问同一个内存地址(没有生成deepcopy)。您有两个同名的函数。您使用了保留字作为变量名或文件名。
  4. 您正在查看一个文件(比如说,lab99.py),但是正在运行另一个文件(lab99)。
  5. 你一开始就没有调用这个函数。
  6. 外部 for 循环索引用作内部循环索引。[这在 Python 中不会出现。]
  7. 您删除了函数名中的括号对。
  8. 你用==代替了=,反之亦然。
  9. 你把<写成了<=,反之亦然。这个错误已经花费了我几个小时的时间。]
  10. 你对优先顺序一无所知——例如,a and b != True的意思是a and (b != True),而不是(a and b) != True.
  11. 您未能初始化变量(在 Python 中不可能)。
  12. 您的数字太大(溢出)。
  13. 你拼错了两个相似的单词——例如,变量名differenceInYearsdifferenceinYearsdifferenceInYears都是不同的。
  14. 舍入累计产生了错误的数字。
  15. for 循环头中的列表/数组在 for 循环体中被更改。
  16. 你有一个双簧管(一个错误)。
  17. 您有缩进或范围错误。
  18. 您混淆了列表值(x[n])及其位置(n)。
  19. 你假设A += B总是像A = A + B一样运行。不是用列表。
  20. 您误解了内置函数的工作方式,例如,函数可能就地操作数据,而不会像您所想的那样返回数据。
  21. 你比较了绝对平等的浮点数。
  22. 你从未掌握你的语言。内置函数或巧妙的语法安排会简化复杂的代码。
  23. 你把字母'O'写成零(0),反之亦然。
  24. 您期望函数头中有[]""None,但却得到了其中一个。
  25. 你的陈述看起来是独立的,但却是有联系的。你可能有 1. 一个悬空的else(一个else连接到错误的if), 2. 背后捅刀子的 else(两个或更多的if后面跟着一个else),以及 3. 在if比较之间改变测试数据的出血if。 

  • 战争故事 1。在我布置的一个图形程序中,一个学生从讲义上复制了我的代码,然后告诉我她一直得到错误“未赋值的全局变量…”。全局变量被导入并在所有其他学生的计算机上运行。经过五分钟的代码检查,并与我的进行比较,我一无所获。怎么办?你会怎么做?我从来没有发现问题是什么,但我能够消除错误。我只是复制了我的工作代码,删除了我想让学生写的函数,然后通过电子邮件发给了她。成功了。我后来让她把有缺陷的代码发给我,但她已经覆盖了它。太糟糕了,因为这些错误教会了我们一些东西。

  • 战争故事 2。我曾经让一个学生构建了一个无法编译的巨型 Python 字典。编译错误通常很容易消除,但这一个却不容易。这本由许多行组成的词典被计算机看作是一行。因此,编译器无法给出错误的实际物理行号。在对字典进行了多次分解之后,我发现了这个错误。在字典的第二行,学生写了字母“o”代表零(0)。

  • 战争故事 3。我的同事 Torbert 博士曾经花了半天时间(对,半天!)试着调试一个学生的代码。这名学生使用了合理的标识符getxgety,她和托伯特博士都不知道,这是继承的JPanel类中的保留字。你可能会认为最初的设计者会使用更模糊的标识符,甚至是像JPgetXGETX这样的东西。

  • 战争故事 4。我曾经写过一个解决数独难题的程序。我创建了一个单元格对象矩阵来表示数独板。每个单元格对象都包含其自身矩阵的地址:包含所有单元格的矩阵。这是用 Python 类变量完成的。(见下文。)因此,引用另一个单元格的代码可以检测到一个单元格中值的变化。

    class cell(object):
        matrix = None <-- class variable
    #--constructor-------------------------
        def __init__(self, val, r, c, matrix):
           if val != 0:
              self.value = {val,}
           else:
              self.value = {1,2,3,4,5,6,}
           self.row    = r
           self.col    = c
           self.block  = self.blockNumber(r, c)
           cell.matrix = matrix <–- accessed with the class name.
    
    
  • 对于简单的数独游戏,这个程序运行得很好。这让我对类变量和一般设计充满信心。但是程序在递归下失败了。经过大约一周的调试,我终于意识到在回溯中矩阵没有被重置,即使重置矩阵的代码正在执行。这怎么可能呢?

  • 最终,我复制了代码,扔掉了所有看起来与 bug 无关的代码行。这给了我一个更简单的结构来检查 bug。令我惊讶的是,bug 并没有出现。我决定以后再考虑这个问题,然后起身去吃午饭。当我走过大厅时,我突然想到了完整的答案。显然,我的大脑一直在思考这个问题,而我却没有意识到。

  • 复制矩阵时出现问题。当数据结构被复制时,副本驻留在新的地址,但是每个单元包含原始矩阵的地址。记住,我使用了一个类变量,而不是实例变量来保存矩阵的初始地址。这些单元地址需要改变,或者复制矩阵的值需要反馈到原始矩阵中以重置它。我和我的学生讨论了这个错误,并以下面的评论作为结论。

  • 如果没有 bug 影响,递归错误可能需要几个小时才能修复,直到深入递归。如果我们在这个问题上投入足够的时间,我们通常可以解决它。这个过程会让我们失去很多情感。有些人能够处理无休止的挫折,不让它从编程的更愉快和更有创造性的方面带走。然而,有许多聪明人对这种精神斗争没有耐心。对他们来说,编程似乎很痛苦。我能给你的唯一一般性建议是,问问你发现的每一个大错误是如何避免的,然后根据你的分析改变你写代码的方式。

  • 给了你这条建议后,你可能会问我如何避免花一周时间去寻找我之前讨论过的矩阵 bug。我会不会设置了一个错误陷阱?我能早点测试吗?事实上,我不知道我能做些什么来避免这个错误,或者更早地暴露它。我以前从未使用过递归中的类变量。

学生每写一行代码,通过强化坏习惯,学生就变成了一个糟糕的程序员,这种想法不无道理。一个好老师不能在这里帮忙吗?除了提供有价值的任务和发表许多有见地的评论,有些是关于生活的。认为我们可以拯救他人是一种虚荣——他们只能拯救自己。 4

所以,再一次,我们如何成为优秀的编程员,尤其是在错误无法消除的情况下?答案是学习我们的计算机语言的细节,解决许多具有挑战性的问题,提供洞察力,不要轻易放弃这些问题,练习重构,反思解决方案和错误,并与其他优秀的程序员交流。

现在是一个惊喜。如果有任何建议可以帮助你提高编程技能,那么你必须自己去发现,或者至少你必须找出编程问题,这样你就可以通过向他人提出明确的问题来寻求建议。对于初学者来说,这本书一定只是背景噪音。我的观点不可能是你的。即使告诉你我的观点也不足以让它有意义。这本书的目的是告诉你,职业程序员(以及棋手和钢琴家)认为某些习惯提高了他们的生产力,减少了他们的挫败感。我的话只能是在编程中找到自己个人视角的一个弱指导。祝你好运。

程序员的进化

A461100_1_En_20_Figa_HTML.gif

Footnotes 1

G.哈里森简短而精彩的著作《英语职业》(1967)试图回答他在大学英语教学中应该达到的目标。我认为他的一些观点适用于任何学科或工艺的教学。

  2

1.“我们应该如何着手努力改善?我的猜测是,国际象棋技能来自于国际象棋比赛和国际象棋训练,其中“训练”意味着为自己解决问题。”—(通用)乔纳森·罗森,《斑马的国际象棋》(Gambit,2005),第 28-29 页。

2.日本谚语:“野心是纪律的源泉。”——托马斯·p·罗兰,《日本的高中》(加州大学,1983 年),第 266 页。

3."受教最少的学生被教得最好."——R.L .摩尔,见于约翰·帕克,r . l .摩尔(MAA,2005 年),页 263。【著名的摩尔教学法就是让解题几乎贯穿整个课程体验。很少讲课,没有测试,没有小测验,当然也没有提示,只是让每个学生自己解决问题。如果一个人的目标是提高学生解决问题的能力,我认为摩尔基本上是对的。那是学习数学的最好方法。"我确信,摩尔方法是教授任何事物的正确方法."——保罗·哈尔莫斯,我想成为一名数学家——斯普林格出版社,1985 年版,第 258 页。]

  3

这个想法来自 Kent Beck 的极限编程讲解,第 2 版。(艾迪森·韦斯利,2005),第 65 页。

  4

转述自法国电影《玩乐女王》(2009)。

二十一、动态规划

前言。一章的序言是不寻常的,但是动态规划需要一些动机。

有史以来最深刻的学术笑话

一位教授正在路灯柱附近寻找他掉的钥匙,这时他以前的一个学生走过。“你丢了钥匙吗,教授?”学生问。

“是的,我做了,”教授回答。

“好吧,我来帮你找,”学生说。

找了几分钟后,学生问道:“你知道你最有可能把它们掉在路灯柱的哪一边吗?”

“哦,”教授说,“我把它们丢在那边大楼旁边的某个地方了。”

“什么!”学生大声说道。“那你为什么在这里找他们?”

“哦,这里的光线好得多,所以搜索起来更容易。”

理查德·海明的回忆录

Alan Chynoweth 提到我过去常常在物理桌上吃饭。我和数学家们一起吃饭,我发现我已经知道了相当多的数学知识;事实上,我没学到多少东西。正如他所说,物理桌是一个令人兴奋的地方,但我认为他夸大了我的贡献。听肖克利、布拉顿、巴丁、约翰逊、肯·麦凯和其他人的音乐非常有趣,我学到了很多东西。但不幸的是诺贝尔奖来了,升职来了,很多都走了。餐厅的另一边是一张化学桌。我曾和其中一位研究员戴夫·麦考尔一起工作过;此外,他当时正在追求我们的秘书。我走过去说:“你介意我加入你吗?”他无法拒绝,于是我开始和他们一起吃了一段时间。我开始问,“你所在领域的重要问题是什么?”大约一周后,“你在研究什么重要的问题?”又过了一段时间,有一天我来了,我说,“如果你正在做的事情不重要,如果你认为它不会带来什么重要的东西,那你为什么要在贝尔实验室研究它?”从那以后我就不受欢迎了。我不得不找别人一起吃饭!那是在春天。

秋天,戴夫·麦考尔在大厅里拦住我说:“海明,你的那句话刺痛了我的心。整个夏天我都在思考这个问题,即在我的领域里有哪些重要的问题。“我没有改变我的研究,”他说,“但我认为这是非常值得的。”我说,“谢谢你,戴夫,”然后继续。我注意到几个月后他被任命为部门主管。前几天我注意到他是国家工程学院的成员。我注意到他成功了。在科学和科学圈里,我从未听说过那张桌子上其他任何人的名字。他们不能问自己,“在我的领域里,什么是重要的问题?”——理查德·海明(摘自理查德·海明 1986 年 3 月 7 日《你和你的研究》。整个演讲都在网上。读一下。)

旅行者

这位旅行者,看到通往真理的道路,大吃一惊。那里杂草丛生。“哈,”他说,“我看很久没有人经过这里了。”后来他发现每一棵杂草都是一把独特的刀。“好吧,”他最后咕哝道,“无疑还有别的路。”—斯蒂芬·克莱恩,《战争是仁慈的及其他台词》(1899)。

引言。欢迎来到动态规划,以及本书最难的章节。为什么这么难的题目会放在一本针对仍在开发中的程序员的书里?答案是,我们通过尝试派生和编程困难的算法来建立派生和编程困难的算法的技能。这是唯一的办法。

历史。在 20 世纪 40 年代末和 50 年代初,数学家理查德·贝尔曼 1 博士是兰德公司雇佣来解决军事和工业问题的众多数学家之一。他观察到他和他的一些同事经常使用相同的方法来解决某些类型的问题。他创造了术语动态规划来描述这些方法。 2 他的技术著作《动态规划》于 1957 年出版,同年第一种高级编程语言 Fortran 问世。31962 年,他和合著者斯图尔特·德雷福斯(Stuart Dreyfus)发表了第二篇论述:应用动态规划。 4

术语“动态规划”不是特别具有描述性,但是“线性规划”是一个新的术语,指的是通过处理线性不等式系统来解决问题的过程。动态规划通过处理递归函数方程组来解决问题。另外,贝尔曼在他有趣的自传中承认,他喜欢“动态”这个词。

定义。运筹学中的术语“动态规划”是指多阶段决策的数学理论,即在一个过程的不同阶段做出最佳决策,通常是通过创建最佳政策函数。这是它的典型特征。“动态规划”中的“编程”一词在这个术语被创造出来的时候甚至在今天都意味着调度或计划。有时,策略函数用于查找单个(通常是最优)值,例如,最短路径的长度而不是路径本身(到达目标的方向)。

既然微积分以通过消失导数的方式寻找最大值和最小值而闻名,那么动态规划给最优化研究带来了什么?工业和军事中常见的应用问题通常是离散的,不是连续的,因此没有导数。应用问题通常有太多的变量,以至于微积分表达式变得太难计算,即使对计算机来说也是如此。

The Method of Dynamic Programming (DP)

  1. 通过减少参数、选择、决策、容量、对象的数量或整数域的大小,将问题简化为子案例,即在每一步减少问题的维度。然后将这些子案例减少到更多的子案例,再减少到更多的子案例。这样做,直到子案例很容易解决。
  2. 所有的子案例必须以相似的(递归的)方式产生。
  3. 任何情况或阶段的值必须由其直接子情况的值的某种组合来确定,通常(但不总是)作为子情况的最大值或最小值。这就是所谓的优化原则,并导致一个最佳的政策功能。
  4. 修剪在实践中通常是必要的。找到一种计算重叠(共享)子案例(如果它们存在)不超过一次的方法。[每个子案例只会比其父案例稍微简单一点。如果每个子案例都比其父案例简单得多,那么就没有必要进行动态规划。仅仅以任何方式将一个问题分解成子问题,然后用蛮力来解决它们就可以了。]

请注意:在计算机科学中,DP 已经意味着一种递归算法,它不会对一个子问题求值两次。参见维基百科。根据你试图解决的问题选择你的定义。

动态规划有三种(很多人说是两种)形式:

  • 表单 a)一种迭代算法,构建一个过去计算的表格,用于进行新的计算(称为自底向上方法)
  • 形式 b)一种递归算法,不涉及累积内存,只是重复计算相同的子情况(称为自顶向下方法)
  • 形式 c)一种递归算法,它记住先前计算的子情况(也称为自顶向下方法)。

注意,上面的表格 b 违反了动态规划的第四个属性。因此,有些人不认为形式 b 是动态规划。然而,表格 b 满足运筹学 DP 的定义特征,是编写的最简单的 DP 函数,有时足以解决手边的问题,并且是编写表格 c 的第一步,这反过来通常有助于产生更快的表格 a。因此,表格 b 至少是动态规划工具箱的一部分。

函数方程。DP 的子情况通常涉及函数方程,即至少包含一个函数的方程。这里有一个简单的例子(Denardo,第 28 页)。假设你在用一对公平骰子掷出 7 之前,想知道掷出 3 的概率:f\left(3,7\right)=?。一掷中得 3 的概率是\frac{2}{36}。得到 7 的概率是\frac{6}{36}。两者都得不到的概率是1-\frac{2}{36}-\frac{6}{36}。那么我们的答案就是无限几何级数:

f\left(3,7\right)=\frac{2}{36}+\left(1-\frac{2}{36}-\frac{6}{36}\right)\frac{2}{36}+{\left(1-\frac{2}{36}-\frac{6}{36}\right)}²\frac{2}{36}+{\left(1-\frac{2}{36}-\frac{6}{36}\right)}³\frac{2}{36}+\dots

这一系列可以用预先计算公式求解:

a+ ar+a{r}²+a{r}³+\cdots =\sum \limits_{k=0}^{\infty }a{r}^k=\frac{a}{1-r},\mathrm{for} \mid r\mid <1

如果你想不起公式了怎么办?一个简单的想法是将级数视为函数方程(1):

f\left(3,7\right)=\frac{2}{36}+\left(1-\frac{2}{36}-\frac{6}{36}\right)f\left(3,7\right)

而现在只要求解 x: x=\frac{2}{36}+\frac{28x}{36}\Rightarrow \frac{8x}{36}=\frac{2}{36}\Rightarrow x=f\left(3,7\right)=\frac{1}{4}。因此,如果你知道函数方程,那么你永远不需要记住无穷几何级数公式。,除了|r| < 1。顺便说一句,你如何检查等式(1)的有效性?答案在脚注里。 5 函数方程是方便的工具,可以帮助我们解决问题,也可以简化计算。

自下而上、自上而下和记忆化。考虑尝试生成第五个斐波那契数。一个自然的解决方案是用 DP 来表示,即,用降维的递归函数方程来嵌入原始问题:

  • (5) f[5] = f[4] + f[3]
  • (4) f[4] = f[3] + f[2]
  • (3) f[3] = f[2] + f[1]
  • (2) f[2] = f[1] + f[0]
  • (1) f[1] = 1
  • (0) f[0] = 0

在这个自下而上的例子中,我们只需要在第(2)行计算一次f[2],然后在第(3)和(4)行使用它。如果我们想自顶向下工作,我们需要调用第(3)和(4)行的f[2]。但是当我们最终在第(2)行计算f[2]时,我们可以保存结果,而不需要在第 3 行和第 4 行重新计算。

如果您让初学者编写一个 Python 函数来打印第 n 个斐波那契数,他或她可能会编写一个简单的迭代函数,如下所示:

def fib1 (num): # ITERATION, bottom-up (form a)
    if num < 3: return 1
    a = b = 1
    for i in range(2, num):
        a, b = b, a+b
    return b
#--------------------------------------------------------------

如果你让初学者递归地解决同一个问题,你会得到这样的结果:

def fib2 (num): # RECURSION, top-down (form b)
    if num < 3: return 1
    return fib2(num-1) + fib2(num-2)
#--------------------------------------------------------------

需要注意的是,自顶向下(实际上是递归)方法是指初始函数调用从一端开始,直到调用到达另一端才获得值,然后返回数字。因为实际的计算直到自顶向下的调用到达另一端才能开始,所以嵌入自顶向下方法的是自底向上方法。既然如此,为什么会有人选择自顶向下的方法呢?回答:较慢的自顶向下方法比较快的自底向上方法更容易编写。

在这里,自上而下的方法非常低效。它重复计算完全相同的子案例。我们可以通过引入动态(变化的)查找表来改进fib2。这个技巧叫做记忆化。 6 下面是两个版本。第一个将早期的数字保存在一个半全局列表中。第二个版本将早期的数字保存在 Python 字典中,该字典的地址随每次递归调用一起传递。

def fib3 (num): # RECURSION with memoization, top-down (form c)
    if num < len(fibNums): return fibNums[num]
    fibNums.append(fib2(num-1) + fib2(num-2))
    return fibNums.pop()
fibNums =[0,1,1]
#--------------------------------------------------------------

def fib4 (num, Dict): # RECURSION with memoization, top-down, (form c)
    if num in Dict: return Dict[num]
    Dict[num] = fib4(num-1, Dict) + fib4(num-2, Dict)
    return Dict[num]

    print(fib4(12, {1:1, 2:1})) # The call.
#--------------------------------------------------------------

通过检查导致第 n 个斐波那契数的递归调用树,可以得到一个重要的观察结果。每一层(除了最下面的几层)的节点数都是上一层的两倍。有了记忆,计算机只能沿着树的一边往下走,除了为每一层回忆一个以前计算过的数字之外,永远不会分叉。这是极端的修剪。这是将指数运行时间变为线性运行时间。这就是为什么递归动态规划通常与记忆相结合。 7

运筹学的一个问题。每当我们意识到一个问题可以被简化为一个更简单的情况,而这个情况又可以以同样的方式被简化为一个更简单的情况,那么我们可能就在谈论动态规划。考虑著名的吉普问题(又名穿越沙漠问题)。没有要求你解决这个难题,只需要注意解决方案的形式。 8

问题。(兰德 1946)假设我们有一辆吉普车,它能载足够的汽油行驶 d 英里。为了在平坦贫瘠的地形上穿越 2d 英里的距离,有必要建立中间的汽油贮藏处。假设吉普车的燃料消耗是恒定的,并且在任何时候,吉普车可以将它携带的任何数量的燃料留在缓存中,或者可以收集在前一次旅行中留在缓存中的任何数量的燃料,只要它的燃料负载不超过一个满箱。家得宝有无限量的汽油。两个问题自然出现:1)应该如何定位贮藏处,以最小化行驶 2d 英里所需的汽油总支出,以及 2)吉普车到达目的地的总行驶距离是多少?——r . Bellman,《动态规划》(普林斯顿,1957),第 103 页,问题 54(此处转述)。

评论。有许多不同的方案来进行 2d 穿越沙漠的旅行。请考虑以下情况。假设我们从 11 箱汽油开始,来回移动 d/4 的距离,每次放下半箱。在第十一次旅行中,我们到达了 d/4,还剩下 5-3/4 罐。通过重复该过程,我们移动到 d/2,剩下 3 个罐。然后我们移动到 3d/4,剩下 1-3/4 个坦克。然后我们移动到 d 点,还剩一个油箱,刚好够我们飞完最后一程到达 2d 点。这是一个解决方案(11 辆坦克),但我们可以做得更好。

通过动态规划求解(N.J. Fine,美国数学月刊,第 54 卷,1947 年,第 458-462 页)。让我们考虑递归函数方程。我们定义 f (t) = d,其中 t 是满满一箱汽油,d 以英里为单位。 9

那么 f (2t) = d/3 + d,为什么呢?吉普车前进了 d/3 英里,储存了三分之一的油箱,然后返回家得宝。在第二次旅行中,它到达缓存并重新装满油箱,问题简化为 f (t)。

接下来 f (3t) = d/5 + d/3 + d,为什么?吉普车前进到 d/5,放下 3/5 的油箱,然后返回。然后它重复这一行程。在第三次也是最后一次旅行中,吉普车带着 4/5 的油箱到达储藏处,还有 6/5 的油箱在等着它。这是两个满罐,问题以与前一种情况相同的方式减少。

接下来 f (4t) = d/7 + d/5 + d/3 + d,为什么?吉普车开始前进 d/7 英里,放下 5/7 的坦克。它重复这个旅程两次以上。在第四次旅行中,吉普车带着 6/7 的油箱到达第一个缓存,发现相当于 15/7 的油箱在等待。这是 3 个满罐,问题以与前一种情况相同的方式减少。

因此,我们看到 n 个满罐的缓存模式:f (nt) = d,d/3,d/5,d/7,d/9,d/11,…,d/(2n-1)。带着 n 箱燃料向前行进到沙漠中的距离可以表示为递归函数方程:

f (nt) = d/(2n-1) + f ((n-1)t),其中 f (t) = d。

因此,在家得宝有 8 个油箱的情况下,吉普车可以向前行驶 f(8t)= d+d/3+d/5+d/7+d/9+d/11+d/13+d/15≈2.02d。当然,在有 8 个油箱的情况下,吉普车将行驶 8d 的总距离(来回)。其核心思想是将原问题的解反复嵌入一族越来越小维度(这里是整数域)的递归函数方程中。

| 英里距离 | 在装满的吉普车油箱中测量燃油 | | :-- | :-- | | 2d | eight | | 三维(three dimension 的缩写) | Fifty-seven | | 4d | Four hundred and nineteen | | 5d | Three thousand and ninety-two | | 6d | Twenty-two thousand eight hundred and forty-six | | 7d | One hundred and sixty-eight thousand eight hundred and four | | 8d | One million two hundred and forty-seven thousand two hundred and ninety-eight | | 9d | Nine million two hundred and sixteen thousand three hundred and fifty-four | | 10d | Sixty-eight million one hundred thousand one hundred and fifty-one |

因为奇数分母的分数序列是发散的,所以吉普车的行驶距离(理论上)没有限制。看看右边的桌子。n 箱燃油的行驶距离也可以以封闭形式给出:

1+\frac{1}{3}+\frac{1}{5}+\cdots +\frac{1}{2n-1}=\sum \limits_{k=1}^n\kern0.50em \frac{1}{2k-1}

注意:这不是一般第 n 种情况的证明(“我们可以看到模式”),也不是最优性证明。贝尔曼的书包含了许多页的 DP 定理的存在性和唯一性证明,这些证明只有数学专家才能理解。

我们现在将考察四个经典的动态规划问题。最理想的方法是在考虑我的解决方案之前,试着在每个问题上取得一些进展。其他解决方案和问题可以在互联网上找到。

问题 1。最短路径。

在下面的(无环有向) 10 图中我们求从任意节点到节点 9 的最短路径。

还是我们?难道我们不寻求一个函数(最优策略)给定一个节点,告诉我们下一个节点移动到最优路径吗?这不符合 DP 的精神吗?既有也有。产生一个函数来指导我们选择下一个节点是贝尔曼最初如何描述 DP 的。这就是工业所需要的。然而,要从任何当前节点找到下一个最佳节点,我们必须首先从当前节点找到整个最佳路径。所以,我们不能缺一不可。 11

A461100_1_En_21_Figa_HTML.jpg

这个数字摘自 Eric V. Denardo 的《动态规划模型和应用》(Dover,2003),第 9 页。11 条可能路径中最短的是通过节点 1、3、4、5、7、9,总长度或成本为 19。奇怪的是,贪婪(近视)算法产生长度为 27 的最长路径(1,2,4,6,8,9)。

首先,把图形图片翻译成电脑数据。这将是一个关联列表,即一个列表列表:一个节点列表,其中每个节点都有自己的直接转发邻居列表以及到这些邻居的距离。Python 提供了内置的字典数据类型来帮助我们处理这些信息。以下数据结构可用于(不变)自顶向下和自底向上算法:

graph = {1:[(1,2), (2,3)], # (d,n) = (distance to next node, next node)
         2:[(12,5),(6,4)],
         3:[(3,4), (4,6)],
         4:[(4,5), (15,7), (7,8), (3,6)],
         5:[(7,7)],
         6:[(7,8), (15,9)],
         7:[(3,9)],
         8:[(10,9)],
         9:[(0,0)], }

现在问问你自己,在任何一个节点上,你需要什么样的信息,才能以最优路径到达我们的目标(这里是节点 9)。您需要一个直接转发邻居列表以及从您当前位置(节点)到每个邻居的距离。该信息已经在问题陈述中给出(graph数据结构)。您需要的最后一条信息是从每个邻居(I)到目标的最佳距离 f (i)。这就是递归的用武之地。找到从邻居节点到目标节点的最短距离与我们在当前节点上问的问题完全相同,只是维度(节点中剩余路径的长度)略有减少。寻找这样一个递归函数通常需要很大的独创性。如果能够找到并解决,那么未来的求解者将能够在每个阶段做出最优决策。尽管这种思想是递归的,但我们编写的函数可以是迭代的,也可以是递归的,就像我们看到的两个 Fibonacci 函数一样。

我们的函数 f (i)表示节点 I 和节点 9 之间的最小(最佳)距离。显然,f(9)=0。然而

  • f(i)=\underset{j}{\min}\left({d}_{ij}+f(j)\right),【贝尔曼方程】

其中 d ij 是从节点 I 转发到最近的邻居节点 j 的距离。注意i<j,并且 f ( j)是从节点 j 转发到节点 9 的最小距离。在 DP 理论中,从技术上讲,当递归函数被导出时,优化问题就解决了。 12

上面简洁的措辞需要更加具体。因此,通过查看图形图片或graph数据结构,使用公式(*)手动写出九个等式f(9) = 0 至f(1) = 19。警告:不要跳过这一步。答案(做完后检查)后面给。

下一段包含了确定最小路径长度的关键思想,而不必检查每条可能路径的长度。但是,我们必须将每个节点与一个数字相关联(从该节点到节点 9 的最小距离)。

当我们来到节点 6 时,我们必须评估到目标的两个(短)距离。当我们到达节点 4 时,我们只需要检查四个(而不是五个)距离,因为节点 6 现在只与一个距离(最佳距离)相关联,而不是两个距离。当我们来到节点 3 时,我们只需要检查两个距离,而不是七个距离,因为节点 4 只与一个距离相关联,而节点 6 只与一个距离相关联。这就是记忆化动态规划的修剪能力。这让我们看到了你的第一个任务。我的代码(解决方案)遵循分配。

  • 作业 1。写一个名为fa (form a)的迭代函数,只接收一个节点(graph数据是全局的),返回从那个节点到目标节点的距离(9)。这个短函数的关键思想是递归函数方程(*)。您必须创建一个本地数据结构来保存从每个节点到目标节点的最佳距离。我把我的数据结构命名为data。我的笔记告诉我,第一个函数花了我 50 分钟来编写,又花了 10 分钟来重构。
  • 作业 2。写一个名为fb (form b)的递归函数(无记忆化剪枝)只接收一个节点(graph数据是全局的),返回该节点到目标节点的距离(9)。
  • 作业 3。编写一个名为fc(形式 c)的递归函数,它是对函数fb的修改,包括记忆化。
  • 作业 4。编写一个函数determineMinimumPathAndDistance来调用fbfcfa,并返回最短路径和该路径的长度。

九个方程式

{\displaystyle \begin{array}{l}f(9)=0\\ {}f(8)=10+f(9)=10\\ {}f(7)=3+f(9)=3\\ {}f(6)=\min \Big\{\begin{array}{l}7+f(8)=7+10\\ {}15+f(9)=15+0\end{array}=15\\ {}f(5)=7+f(7)=10\end{array}}

{\displaystyle \begin{array}{l}f(4)=\min \Big\{\begin{array}{l}4+f(5)=4+10\\ {}15+f(7)=15+3\\ {}7+f(8)=7+10\\ {}3+f(6)=3+15\end{array}=14\\ {}f(3)=\min \Big\{\begin{array}{l}3+f(4)=3+14\\ {}4+f(6)=4+15\end{array}=17\\ {}f(2)=\min \Big\{\begin{array}{l}12+f(5)=12+10\\ {}6+f(4)=6+14\end{array}=20\\ {}f(1)=\min \Big\{\begin{array}{l}1+f(2)=1+20\\ {}2+f(3)=2+17\end{array}=19\end{array}}

The

Author's Four Solutions

"""+===============-========-========-========-========-======+
   ||      DYNAMIC PROGRAMMING (shortest route problem)        ||
   ||          by M. Stueben (October 8, 2017)               ||
   ||                                                        ||
   || Description: This program contains three functions (fa, ||   ||              fb, and fc) which each determine the next  ||   ||              node to move to in proceeding by the shortest    ||   ||              path to goal node 9\. Then each of these    ||   ||              functions is used to find the shortest route   ||   ||              and its distance from node 1 to node 9\.       ||
   || Reference:   Eric V. Denardo, Dynamic Programming      ||   ||              (Dover, 2003), pages 6-19\.                ||
   || Language:    Python Ver. 3.4                           ||
   || Graphics:    None                                      ||
   +===========-========-========-========-========-==========+
"""

####################<BEGINING OF PROGRAM>######################
#============<GLOBAL CONSTANTS and GLOBAL IMPORTS>=============
graph = {1:[(1,2),  (2,3)], # Each neighbor node moves us towards goal node 9.
         2:[(12,5), (6,4)], # (d,n) = (distance to next node

, next node)
         3:[(3,4), (4,6)],
         4:[(4,5), (15,7),(7,8),(3,6)],
         5:[(7,7)],
         6:[(7,8), (15,9)],
         7:[(3,9)],
         8:[(10,9)],
         9:[(0,0)], }
count = 0                    # Counts the number of recursive calls.
#==============================================================

def printResults(distance, path, func):
    print('--', func.__name__,'min path:', path)
    print('   distance =', distance, 'recursive calls =', count)
#==============================================================

def fb(node):                # Recursion with NO memoization.
    global count; count  += 1
    if node == 9: return 0
    shortest =  min([dist + fb(neighbor) for (dist, neighbor) in graph[node]])
    return shortest          # = shortest distance from current node to goal node

.
#==============================================================

def fc(node, dict = {}):     # Recursion with memoization.
    global count; count += 1;
    if node == 9: return 0
    data = []                # data = [(dist to goal, neighbor),...]

    for (dist, neighbor) in graph[node]:
        if neighbor in dict:
            data.append((dist + dict[neighbor], neighbor))
        else:
            neighborsDistToGoal = fc(neighbor, dict)
            data.append((dist + neighborsDistToGoal, neighbor))
            dict[neighbor] = neighborsDistToGoal

    shortest = min(data)[0]
    return shortest # = shortest distance

from current node to the goal node.
#==============================================================

def fa (node):
    data = ['-',0,0,0,0,0,0,0,0,0,] # = distances of each node to goal node (9).
    for n in range (8, 0, -1):
        data[n] = min([dist + data[neighbor] for (dist, neighbor) in graph[n]])
    return data[node]
#==============================================================

def determineMinimumPathAndDistance(func, node):
    global count; count = 0
    minimumPath         = [node]
    shortestDistance    = 0

    while node != 9:
        (_, dist, node)  = min([(dist + func(neighbor), dist, neighbor)
                               for (dist, neighbor) in graph[node]])
        minimumPath.append(node)
        shortestDistance += dist
    return shortestDistance, minimumPath
#==========================<MAIN>==============================

def main():
    for func in (fb, fc, fa):
        distance, path = determineMinimumPathAndDistance(func, node=1)
        printResults(distance, path, func)
#--------------------------------------------------------------

if __name__ == '__main__':
   from time import clock

; START_TIME = clock();
   main(); print('- '*16);
   print('Program run time:%6.2f'%(clock()-START_TIME), 'seconds.')
########################<END OF PROGRAM>#######################

输出:

-- fb min path: [1, 3, 4, 5, 7, 9]
   distance = 19 recursive calls = 63
-- fc min path: [1, 3, 4, 5, 7, 9]
   distance = 19 recursive calls = 16
-- fa min path: [1, 3, 4, 5, 7, 9]
   distance = 19 recursive calls = 0
- - - - - - - - - - - - - - - -
Program run time:  0.06 seconds.

一百万次呼叫的时间如下:

  • 函数运行时间:16.5 秒,63 次递归调用,最大递归深度 27。
  • fc(函数运行时间:8.5 秒,16 次递归调用,最大递归深度 4。
  • fa功能运行时间:6.5 秒。

我的经验一直是迭代 DP 比递归 DP 快。然而,当我第一次运行这个测试时,fcfa快了许多倍。我知道我在比较时间时犯了一些错误,但是是什么错误呢?也许读者在看脚注之前就能猜到。 13

代码注释:

  1. 当主函数中的for循环实际上简化了代码的阅读时,这是一种罕见的情况。但是,这段代码的目的是演示这三个功能,而不是解决一个问题。
  2. 函数determineMinimumPathAndDistance包括抛弃的下划线变量(' _ ')。
  3. 为什么我在图中把距离放在邻居之前,而不是相反?答:min函数只检查元组或列表中的第一个元素。当用 Python 调用带有元组或列表的minmax函数时,这是一个有用的设计技巧。
  4. 请注意,我使用了所谓的幻数,而不是将这些数字分配给标识符——例如rootNode = 9。这使得代码更容易理解,但如果放在一个更大的程序中,则更难扩展或调试。

尽管记忆化使得函数更难编写,但记忆化(通过递归或迭代)赋予了动态规划强大的功能。如果读者已经到了这一步而没有写任何代码,那么是时候把书放在一边,回去从记忆和理解两方面写代码了。卡住了就偷看。

现在是一个惊喜。写完简单的fb,我们可以用装饰器将fc定义为fb:

def memoize(function):
    from sys  import setrecursionlimit; setrecursionlimit(100) # default = 1000
    dict = {}
    def wrapper(num):
       if num not in dict:
          dict[num] = function(num)
       return dict[num]
    wrapper.__name__ = function.__name__ # In case we need the function's name.
    return wrapper
#==============================================================

@memoize
def fb(node):              # Recursion with NO memoization.
    global count; count  += 1
    if node == 9: return 0
    shortest =  min([dist + fb(neighbor) for (dist, neighbor) in graph[node]])
    return shortest        # = shortest distance from current node to goal node.

修饰的缺点是 1)它将代码放在两个不同的位置,2)它需要更多的递归,3)它更慢,4)如果您没有掌握修饰语法,代码更难理解。

这里有些奇怪的东西。可以这样构造graph:

graph = {9:[(10,8), (3,7), (15,6)],
         8:[(7,6), (7,4)],
         7:[(7,5), (15,4)],
         6:[(3,4), (4,3)],
         5:[(4,4),(12,2)],
         4:[(3,3), (6,2)],
         3:[(2,1)],
         2:[(1,1)],
         1:[(0,0)], }

因此,新邻居(I)是馈入给定节点(j)的节点,而不是来自给定节点的邻居。那么贝尔曼方程是这样的:f(j)=\underset{i}{\min}\left({d}_{ij}+f(i)\right),其中i<j,f (i)是从节点 I 回到节点 1 的距离,f(1)=0。哪种形式比较好?据我所知,都不是。如果您感兴趣,以下是三种表单的代码:

#---1\. Returns distance only (form a).
def f(n): # ITERATIVE, bottom-up, memoization.
    ff = [0,0,0,0,0,0,0,0,0,0,]
    for i in range(1, n+1):
        ff[i] = min([(ff[j]+d) for (d,j) in graph[i]])
    return ff[n] # = dist. from node n down to node 1.
#--------------------------------------------------------------

#---2\. Returns distance

only (form b).
def f(n): # RECURSIVE, top-down, no memoization.
    if n == 1: return 0
    return min([ d+f(neighbor) for (d, neighbor) in graph[n] ]) # Bellman equation
#--------------------------------------------------------------

#---3\. Returns distance only (form c).
def f(n): # RECURSIVE, top-down, memoization.
    dist = []
    for (d, neighbor) in graph[n]:
        if neighbor not in f.dict: f.dict[neighbor] = f(neighbor)
        dist.append( d + f.dict[neighbor] )
    return min(dist)
f.dict = {0:0, 1:0} # A global dictionary makes the code easier to understand.
#--------------------------------------------------------------

贝尔曼和斯图尔特·e·德雷福斯一起写了第二本关于动态规划的书。15 年后,德雷福斯写了另一本关于动态规划的书,其中包括 187 个已解决的问题。德雷福斯和他的合著者提出了以下建议:

  • 根据教授这门学科的丰富经验,我们确信,只有通过学生的积极参与,才能学会使用动态规划来制定和解决问题的艺术。再多的被动听课或阅读文本材料也不能让学生准备好阐述和解决新问题。学生必须首先从经验中发现,正确的公式并不像阅读教科书上的解答时那样简单。然后,通过大量独立解决问题的实践,他将获得对主题的感觉,最终使正确的表述变得容易和自然。由于这个原因,这本书包含了大量的教学问题。这个学生必须自己做这些题。任何学生在认真尝试解决问题之前阅读解决方案,后果自负。当面对考试或面对现实世界的问题时,他几乎肯定会后悔这种被动。不要只是阅读解决方案,并认为“当然,这就是如何做到这一点。”——斯图亚特·德雷福斯和阿威里尔·m·劳,《动态规划的艺术和理论》(学术出版社,1977),页 xi。

一个自然的问题是:为什么不是所有的教科书都包含大量的算出的例子?我的观点:1)找到好的例子很难,也很费时间。2)作者担心批评可能来自他们的非最优解。3)作者要么已经记住了这些例子,要么可以毫不费力地构建它们,并且没有意识到如果没有这些例子,他们的文本对其他人来说是不容易理解的。

有人说,以身作则不仅仅是一种教学方式,而是唯一的教学方式。我会更进一步。应该给学生许多他们觉得不容易的问题,但这些问题可以通过给出的例子中说明的原理来解决(参见维基百科,s.v. Moore method)。我们已故的朋友乔治·波利亚是这样说的:

  • “解决问题的教学是意志的教育。在解决对他来说不太容易的问题时,学生学会了在不成功中坚持,学会了欣赏微小的进步,学会了等待重要的想法,学会了在想法出现时全力以赴。如果学生在学校没有机会熟悉为解决问题而斗争的各种情绪,他的数学教育就在最重要的一点上失败了。”—乔治·波利亚,如何解决,2 nd Ed。(《双日》,1957),第 94 页。

问题二。0-1 背包问题(又名货物装载问题)。

一个背包的最大容量是 20 磅.给定的一组物品,每个都有重量和美元值,可以放在背包中。确定背包在容量限制下所能容纳的最大元总值。(稍后我们将确定放入背包的物品,以实现价值最大化。但是作为初学者,我们先做简单的问题。)

0-1 表示任何特定重量的物品只能装载一件。因此,该物品要么包含在背包中(1),要么不包含在背包中(0)。以下是我们将使用的值:

value          cost (= weight); C = 8
             v[1] = 15,     w[1] = 1
             v[2] = 10,     w[2] = 5
             v[3] =  9,     w[3] = 3
             v[4] =  5,     w[4] = 4

回想一下,在动态规划中,原始问题被递归地分解成更小的问题。背包可以具有更小的容量(j)或者允许更少的物品(索引为i)进入背包。因此,我们可以用两种不同的方法来减少问题的规模(维度)。下表中的数字代表所有可能的子情况。我们考虑将物品放入背包的顺序并不重要。答案在右下角。这个表格/矩阵是如何产生的?

              The matrix (M) is a table of values
                  0  1  2  3  4  5  6  7  8 <--remaining capacity of knapsack
                +--------------------------
       0th item | 0  0  0  0  0  0  0  0  0
       1st item | 0 15 15 15 15 15 15 15 15
       2nd item | 0 15 15 15 15 15 25 25 25
       3rd item | 0 15 15 15 24 24 25 25 25
       4th item | 0 15 15 15 24 24 25 25 29 Answer = max value = 29 = M[4][8]
                                     Best weight set: [4, 3, 1]

请注意:j值是后面代码中的索引。因此,这种方案不适用于非整数的重量/成本。

考虑尝试将第i件物品(重量w[i]和价值v[i]放入剩余容量j的部分装满(或空)的背包中。换句话说,我们寻求单元格M[i][j]的值。只会出现三种情况:

  • 案例一。(最简单)。我们永远不能把w[i]放在背包里,因为光是w[i]就比C大。背包中当前的值是最优的。因此,M[i][j] = M[i-1][j]

  • 案例二。我们不应该把重量w[i]放在背包里,因为w[i]会推出其他重量,使背包比有w[i]的重量更有价值。(我们怎么会知道这个呢?你马上就会看到。)又来了,M[i][j] = M[i-1][j]

  • 案例三。我们应该将w[i]重量放入背包中,但是之后我们将不得不取出背包中的一些物品(或者不取出),并用较小重量的最佳组合来填充剩余空间(如果有的话)。这个最佳组合已经确定为M[i-1][j-w[i]]。由此,

    M[i][j] = v[i]+ M[i-1][j-w[i]].
    
    

我们可以结合案例 2 和案例 3:

M[i][j] = max( M[i-1][j], v[i]+M[i-1][j-w[i]] )

再看案例 2 和案例 3。假设背包(容量= C)中没有足够的剩余空间来插入考虑中的当前物品(重量w[i])。这并不意味着我们不能插入它。我们简单地清空背包,将考虑中的物品放入背包——这将背包容量减少到已经考虑的数目(C – w[i])——然后重新装载容量减少的背包。我们如何知道将哪些物品放入背包?这个问题的答案已经在j = C – w[i]下面的表格里了。然后我们决定:插入物品(可能会推出一些其他物品)是否会增加背包的价值(与不插入相比)?

将使这种编程更容易的是向数据集wv附加两个零。

    if w[0] != 0 or v[0] != 0:
        w = [0] + w
        v = [0] + v

这些零是必需的,因为如果我们不小心的话,索引i-1最终会减少到-1。如果我们不添加零,那么我们将需要更多的if语句,这将使代码更加复杂。为了测试你的程序,这里有一些数据集及其答案:

# Data set 1
w = [ 1,  5, 3, 4]     # weights with index i.
v = [15, 10, 9, 5]     # values

  with index i, not j.
C = 8                  # Answer: max val = 29; weights = [4, 3, 1]
#-----------------------------

# DATA SET 2
w = [1,  2,  3,  4,  5,  6,  7,  8,  9,]   # w[i]
v = [7,  4,  5, 15,  9, 12, 11, 10,  3,]   # v[i]
C = 20                 # Answer: max value: 49; weights: [7, 6, 4, 2, 1]
#-----------------------------

# DATA SET 3
w = [1,2,3,4,5,6,7,8,9]
v = [5,2,8,1,9,7,4,3,6]
C = 20                 # Answer: max val = 31; weights =[6, 5, 3, 2, 1]
                       # Note that 6+5+3+2+1 = 17, not C = 20.
#-----------------------------

# DATA SET 4
w = [ 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,15,]
v = [12, 2,11, 1, 9,10, 4,15, 6, 7, 8,14, 3, 5, 9,]
C = 25           # Answer: max value = 59 weights = [8, 6, 5, 3, 2, 1]
C = 50           # Answer: max value = 81 weights = [12, 11, 8, 6, 5, 3, 2, 1]
C = 60           # Answer: max value = 88 weights = [12, 11, 10, 8, 6, 5, 3, 2, 1]
#-----------------------------

编写迭代函数来返回任何数据集的最大值。我的迭代函数如下。它引用先前给出的数据集wvC中的任何一个。

def knapsackI(w,v,C): # Iterative: returns max value.
#---Special case (impossible).
    if w == []:
        return (0, [])

#---Append zero weights and values to make the top row and left col zeros.
    w = [0]+w
    v = [0]+v

#---Set matrix size.
    rowMax = len(w)
    colMax = C + 1

#---Create empty matrix, filled with zeros. Note: Because of w[0] = 0 and
#   v[0] = 0, the top row and left col are complete as zeros.
    M = [[0 for j in range(colMax)] # j = col index.
            for i in range(rowMax)] # i = row index.

#This is what we have so far:
#                      0  1  2  3  4  5  6  7  8 <--capacities of the knapsack (j)
#                    +--------------------------
#       i = 0th item | 0  0  0  0  0  0  0  0  0
#       i = 1st item | 0  0  0  0  0  0  0  0  0
#  M =  i = 2nd item | 0  0  0  0  0  0  0  0  0
#       i = 3rd item | 0  0  0  0  0  0  0  0  0
#       i = 4th item | 0  0  0  0  0  0  0  0  0

#---Fill the matrix with values from the bottom-up, starting at 1.
    for i in range(1,rowMax):
        for j in range(1,colMax):
            if w[i] > j:              # Case 1: weight exceeds capacity C.
                M[i][j] = M[i-1][j]
            else:
                M[i][j] = max(  M[i-1][j],  v[i]+M[i-1][j-w[i]]  ) # cases 2 & 3

#---Select the answer (lower-right corner) and return it.
    return M[rowMax-1][colMax-1]
#--------------------------------------------------------------

现在我们不用记忆递归地写同样的函数(形式 b)。我用两种不同的方式编写了这个函数:

def knapsackR(i,j,w,v): # RECURSIVE, NO MEMOIZATION (returns max value only)
#---Special case.
    if w == []:
       return (0)

#---Append zero weights.
    if w[0] != 0 or v[0] != 0:
        w = [0] + w
        v = [0] + v
        i += 1

#---Base cases.
    if i == 0 or j == 0:
        return 0 # base cases

#---Recursive cases.
    if w[i] > j:
        return knapsackR(i-1,j,w,v)
    return max(knapsackR(i-1,j,w,v), v[i] + knapsackR(i-1,j-w[i],w,v))

# The call: print('Maximum value =', knapsackR(len(w)-1, C, w, v))
#------------------------------------------- Knapsack problem--

注意 b 型递归方法比迭代方法要短得多,也简单得多。不幸的是,每个递归调用都要考虑“特殊情况”。这是低效的。特殊情况只需要在第一次呼叫时考虑。下一个版本弥补了这种低效率。也许,在看之前,你可以通过设计来确定我是如何做到的,而不是通过if语句。我的代码如下:

def knapsackRR(w,v,C): # RECURSIVE, NO MEMOIZATION (returns max value only)
#---Special case.
    if w == []:
       return (0)

#---Append zero weights, if necessary.
    if w[0] != 0 or v[0] != 0:
        w = [0] + w
        v = [0] + v
#--------------------------------------------------------------
    def f(i,j): # <-- Helper function. Remember this trick.
#------Base cases.
       if i == 0 or j == 0:
           return 0 # base cases

#------Recursive cases.
       if w[i] > j:
           return f(i-1,j)

       return max(f(i-1,j), v[i] + f(i-1,j-w[i]))
#--------------------------------------------------------------
#---Call the recursive function

with lower-left indices of the implicit matrix.
    return(f(len(w)-1,C))

# The call: print('Maximum value =', knapsackRR(w,v,C)
#--------------------------------------------------------------

(knapsackRknapsackRR)哪种形式更好?我更喜欢knapsackRR,因为knapsackRR(w,v,C)knapsackR(len(w)-1, C, w, v))更简单。

接下来,我们寻求返回最优的权重集以放入背包,而不仅仅是最大值。我们通过回溯来做到这一点。先做迭代函数。你需要一些关于如何做这件事的提示吗?也许不是,因为是没有提示的解决方法建立了我们的技能。如果你想要的话,提示在下一段。

从矩阵右下角的底部开始:M[maxRow-1][maxCol-1]。如果这个数字大于它正上方的数字,那么我们在我们的答案(特定权重列表)中包括w[i],并向上移动一行i = i-1,并向左移动一段距离w[i] ( j = j - w[i],并重复。如果该数字不大于它上面的数字,那么我们只是向上移动,并且不将w[i]包括在我们的最佳权重集合中。就这么简单。下面是我的代码,它只是knapsackI函数的一些附加代码:

def knapsackII(w,v,C): # Iterative: returns both max value and list of weights.
#---Special case:
    if w == []:
        return (0, [])

#---Append zero weights and values, then when the "empty" matrix

is created, the top row and left column are correct as zeros.
    w = [0]+w
    v = [0]+v

#---Set matrix size.
    rowMax = len(w)
    colMax = C + 1

#---Create empty matrix with top row and left col correct as zeros.
    M = [[0 for j in range(colMax)] # j = col index.
            for i in range(rowMax)] # i = row index.

#---Fill the matrix with values from the bottom-up.
    for i in range(rowMax):
        for j in range(colMax):
            if w[i] > j:
                M[i][j] = M[i-1][j]
            else:
                M[i][j] = max(  M[i-1][j],  v[i]+M[i-1][j-w[i]]  )
    maxValue = M[rowMax-1][colMax-1]

#---Backtrack through matrix to find weights to give the maxValue. Without the w[0] = 0 (and v[0] = 0), this code
#   would ignore the first weight. Thefinal value if i-1
#   in M[i-1][j] would refer to the last row of M,
#   instead of the first row.

    i = rowMax-1
    j = colMax-1                      # i,j is the lower-right corner of M.
    bestWeights = w[1:]               # Ignore the 0th weight element.
    wPtr = len(bestWeights)-1         # wPtr is a pointer to the weight
                                      # currently under consideration

.

   for n in range(len(bestWeights)):
        if M[i-1][j] < M[i][j]:
           j -= bestWeights[wPtr]     # Keep this weight.
        else:
           bestWeights.pop(wPtr)      # Remove a weight from bestWeights list.
        wPtr -= 1
        i    -= 1
    return maxValue, bestWeights
#--------------------------------------------------------------

只要再写一个函数,我们就完成了 0-1 背包问题。这是一个带记忆的递归函数。我们希望在不构建矩阵的情况下找到权重的最优集合和最大值。因为回溯和矩阵完全一样,这应该很容易,对吗?我做了一个假设,把这个任务变成了一场噩梦。我很快编写了一个函数,它在前面的所有测试案例中都运行良好,但是如果有一个物品的重量大于空背包的容量,它就会失败。

w = [0,  1,  5, 3,  8]   # weights with index i.
v = [0, 15, 10, 9, 50]   # values  with index i, not j.
C = 8                    # Answer: max val = 50; weights = [8]

我的递归函数一直声称要么有一个超出范围的列表索引错误,要么之前计算的值的字典没有保存必要的值。解决方案是在递归开始之前将矩阵的顶行和左列放入记忆字典。这是编程(调试)算法如此困难的另一个例子。编程者没有意识到必须在代码中反映的微妙关系。以下是更正后的代码:

def knapsackRR(w,v,C): # Recursive: returns both max value and list of weights.
                       # Uses a dictionary (dict) for memoization.

#---This function recursively finds the max value while building a dictionary.
    def f(i,j, dict): # <-- Helper function
        if i == 0 or j == 0:
           return 0 # Base cases
        if w[i] > j:
            if (i,j) not in dict:
               dict[i,j] = f(i-1,j, dict)
            return dict[i,j]

        if (i-1,j) not in dict:
           dict[i-1,j] = f(i-1,j, dict)
        a = dict[i-1,j]

        if (i-1,j-w[i]) not in dict:
           dict[i-1,j-w[i]] = f(i-1,j-w[i], dict)
        b = v[i] + dict[i-1,j-w[i]]

        dict[i,j] = max(a,b)
        return dict[i,j]
#    ----------------<End of helper function>------------------

#---Special case:
    if w == []:
        return (0, [])

#---Having w[0] = 0 and v[0] = 0 simplifies the code.
    if w[0] != 0 or v[0] != 0:
        w = [0] + w
        v = [0] + v

#---Make (i,j) the lower right-hand corner of table.
    i = len(w)-1
    j = C

#---Set up dictionary base cases (top row and left column).
    dict = {}
    for ii in range(i+1):
        dict[(ii,0)] = 0 # <-- Necessary (Omitting this was my 3-day mistake.)
    for jj in range(j+1):
        dict[(0,jj)] = 0 # <-- Necessary (Omitting this was my 3-day mistake.)

#---Find max value.
    maxValue = f(i,j, dict)

#---Backtrack through dictionary to find best weights.
    bestWeights = w[1:]                 # Ignore the 0th weight.
    wPtr = len(bestWeights)-1           # = weight pointer
    for n in range(len(bestWeights)):
        if (dict[(i-1, j)] < dict[(i,j)]):
           j -= bestWeights[wPtr]
        else:
           bestWeights.pop(wPtr)   # Remove a weight from bestWeights.
        wPtr -= 1
        i    -= 1
    return maxValue, bestWeights
#--------------------------------------------------------------

在工业界,测试有时是在要测试的功能之前编写的。在某种程度上,我做到了。我有一个简单的数据集,答案显而易见:

w = [0,  1,  5, 3, 4]     #
v = [0, 15, 10, 9, 5]     #
C = 8                     # Answer: max val = 29; weights = [4, 3, 1]

但这还不足以成为一项测试。背包函数需要测试一千次:

def runKnapsackTests(runs = 10):
    print('Wait. Now running tests.')
    from random import randint, random
    for n in range(runs):
        if n % 100 == 0: print('.', end = '') # crude animation for time.
        arrayLength = randint( 0, 30)
        sm          = randint( 1, 20)   # sm = smallest possible value in array.
        lg          = randint(20, 40)   # lg = largest  possible value in array.
        w           = list({randint(sm,lg) for j in range(arrayLength)})
        C           = int(random() * sum(w))
        v           = [randint(1,40) for j in range(len(w))]
        ans1 = knapsackII(w,v,C)
        ans2 = knapsackRR(w,v,C)
        if ans1 != ans2:
           print('\n==FAILED!: w =', w, 'v =', v, 'C =', C )
           print('Iterative results =', ans1)
           print('Recursive results =', ans2)
           return
    print('\nPassed', runs, 'tests.')
#--------------------------------------------------------------

有了复杂的算法,你永远不能相信自己的想法。必须运行数千个随机测试才能宣称代码已经完成。

请注意这个粗糙的动画,它告诉用户所取得的进展。我们可以在使用 Windows 的 Python 程序结束时发出警报。

def noise():
    import winsound
    winsound.Beep(1500,500) # Frequency, milliseconds
    winsound.MessageBeep()
    soundfile =  'c:/windows/media/chimes.wav'
    soundfile =  'c:/windows/media/tada.wav'
    soundfile =  'c:/windows/media/Alarm10.wav' # 01 to 10
    soundfile =  'c:/windows/media/Ring01.wav'  # 01 to 10
    winsound.PlaySound(soundfile, winsound.SND_FILENAME)

递归形式比迭代形式节省了多少空间?非常少:矩阵越大,字典就需要越大。迭代形式比递归形式快一点,即使递归代码被调整了。

背包问题是一个很好记忆的问题,因为它的解决方案是典型的动态规划策略。天才理查德·贝尔曼发现这些问题有多容易?我们知道:

  • 这些问题虽然出现在许多不同的领域,但有一个共同的特点——它们极其困难。——理查德·贝尔曼,动态规划(多佛,2003),转载自 1957 年版,第八页。

然而,我们受益于个人电脑、更快的电脑、互联网、更方便的操作系统、语法简单的语言、强大的内置指令和有用的数据类型等。

问题三。矩阵括号计数。

假设我们有几个矩阵要按固定顺序相乘,例如A×B×C×D×E×F。有许多不同的方法(实际上有 42 种)插入括号来得到我们的答案——例如,((((A×B)×C)×D)×E)×F(A×((B×C)×(D×E)))×F。这是我们的问题:

给定 n 个按固定顺序相乘的矩阵,有多少种方法给矩阵加上括号?

前四个数字很简单

A           ‡ f(1) = 1
A×B         ‡ f(2) = 1
A×B×C       ‡ f(3) = 2
A×B×C×D     ‡ f(4) = 5
A×B×C×D×E   ‡ f(5) = ?
A×B×C×D×E×F ‡ f(6) = ? etc.

我们可以通过用所有可能的方法将它分成两组来解决这个问题,从而减少先前解决的案例的维度。如果有 4 个矩阵(A×B×C×D),那么我们只需要考虑A×(B×C×D)(A×B)×(C×D)(A×B×C)×D。在这里,A×(B×C)×D这个词并没有被忽略。它是通过在(A×B×C)×D中将(A×B×C)拆分成所有可能的对而得到的。换句话说,

f(4) = f(1)*f(3) + f(2)*f(2) + f(3)*f(1) = 1*2 + 1*1 + 2*1 = 5.

现在,在你的头脑中,不用笔和纸,确定有多少种方法可以插入f(5)的括号。答案如下。

数学上我们可以这样陈述我们的成对观察:

f(n)=\Big\{{\displaystyle \begin{array}{l}1,\mathrm{if}\ n=1\ \mathrm{or}\ \mathrm{else}\\ {}\sum \limits_{k=1}^{n-1}f(k)f\left(n-k\right)\end{array}}

{\displaystyle \begin{array}{l}f(1)=1\\ {}f(2)=f(1)\times f(1)=1\times 1=1\\ {}f(3)=f(1)\times f(1)+f(2)\times f(1)=1\times 1+1\times 1=2\\ {}f(4)=f(1)\times f(3)+f(2)\times f(2)+f(3)\times f(1)=1\times 2+1\times 1+2\times 1=5\\ {}f(5)=f(1)\times f(4)+f(2)\times f(3)+f(3)\times f(2)+f(4)\times f(1)=14\\ {}f(6)=f(1)\times f(5)+f(2)\times f(4)+f(3)\times f(3)+f(4)\times f(2)+f(5)\times f(1)=42\end{array}}

我们的递归函数方程 f (n)是前面单元格的乘积之和。没有涉及最大值或最小值,但它仍然被认为是动态规划,就像斐波纳契函数和吉普问题。你的工作是写一个递归函数(无记忆)来返回这个数字。我的代码如下:

def f(n): # recursive only
#---base case
    if n == 1:
       return 1

#---recursive cases (n >= 2).
    total = 0
    for k in range(1, n):
        total += f(k)*f(n-k)
    return total
#--------------------------------------------------------------

顺便说一下,得到的数字称为加泰罗尼亚数字:1,1,2,5,14,42,132,429,1430,4862,16796,58786,208012,742900,2674440,9694845,35357670,129644790,477638700,1767263190,673190 第一个数字的索引是 1,而不是 0,例如,f (1) = 1,f (2) = 1,f (3) = 2,f (4) = 5,等等。第零个加泰罗尼亚数为零:f (0) = 0。

我的代码是 form b。我们需要动态规划的记忆,使这成为一个更快的函数。用迭代和递归的方式重写它。我的代码如下。

def f(n, ff = [0, 1]): # recursive with memoization
#---base case
    if n == 1:
       return 1

#---recursive cases (n >= 2).
    total = 0
    for k in range(1, n):
        if n-k >= len(ff):
            ff.append(f(n-k))
        total += ff[k]*ff[n-k]
    return total
#--------------------------------------------------------------

def f(n): # iterative
    ff = [0, 1]
    for i in range(2, n+1):
        total = 0
        for k in range(1, i):
           total += ff[k]*ff[i-k]
        ff.append(total)
    return ff[n]
#-------------------------------------------------------------

问题 4。矩阵圆括号。我们现在来看动态规划中的一个著名问题。你可能还记得矩阵乘法是不可交换的——也就是说,A×B通常与B×A不同。但是矩阵乘法是结合律——例如A×(B×C) = (A×B)×C。如果你用一个 4×3 的矩阵(A)乘以一个 3×2 的矩阵(B),你最终要做 24 次(= 4×3×2)乘法才能得到A×B。如果你把这个结果乘以一个 2 乘 5 的矩阵C,你最终会做 64 次(= 4×3×2 + 4×2×5)乘法来得到(A×B)×C。如果我们把这三个矩阵按不同的顺序相乘:A×(B×C),那么我们需要做 90 次(= 3×2×5 + 4×3×5)的乘法。一阶更好。这是我们的问题:

将一组要按固定顺序相乘的矩阵用括号括起来,以尽量减少乘法次数。

有必要检查每对要相乘的矩阵是否相容,即左矩阵的列数等于右矩阵的行数。否则,我们就是在编程废话。

对于 20 个矩阵,使用蛮力,我们将不得不考虑大约 17 亿种情况。如果我们使用记忆化,那么许多子案例重叠,不需要重新计算,只需回忆。请注意,我们没有被要求在代码中乘以任何矩阵。

如果你不知道如何将递归应用于一个特定的问题,有一个心理学技巧可能会有所帮助。开始写出一个又一个基本案例。(这就是我对这个问题的看法。)当我解决三个矩阵的情况时,我突然发现这种情况可以简化为两个各有两个矩阵的情况。在那一点上,我看到了所有大型矩阵集合的递归模式。

选择符号花了一些时间。以下是我的一个输入与输出。矩阵A为 4×3;矩阵B为 3×2,矩阵C为 2×5,矩阵D为 5×10,矩阵E为 10×4。0 用于表示获得特定矩阵所需的乘法次数。对于ABC,这个数字在(AB)C的最佳形式下是 64。

    initialMatrixList = [(0, 'A', 4, 3), (0, 'B', 3,  2),
                         (0, 'C', 2, 5), (0, 'D', 5, 10), (0, 'E', 10, 4)]

#   Output: expr = (AB)((CD)E) value = 236 # optimum placement of parentheses

然后我的字典(关联initialMatrixList)就变成了这个样子(手动排序):

  dictionary (dict)
num    key     value
 1\.     A: (0,   'A',             4,  3)
 2\.     B: (0,   'B',             3,  2)
 3\.     C: (0,   'C',             2,  5)
 4\.     D: (0,   'D',             5, 10)
 5\.     E: (0,   'E',             10, 4)
 6\.    AB: (24,  '(AB)',          4,  2)
 7\.    BC: (30,  '(BC)',          3,  5)
 8\.    CD: (100, '(CD)',          2, 10)
 9\.    DE: (200, '(DE)',          5,  4)
10\.   ABC: (64,  '((AB)C)',       4,  5)
11\.   BCD: (160, '(B(CD))',       3, 10)
12\.   CDE: (180, '((CD)E)',       2,  4)
13\.  ABCD: (204, '((AB)(CD))',    4, 10)
14\.  BCDE: (204, '(B((CD)E))',    3,  4)
15\. ABCDE: (236, '((AB)((CD)E))', 4,  4)

对于密钥ABCD,最小乘法次数为 204,但只有四个矩阵像这样(AB)(CD)相乘时。我的代码如下:

def f(M): # Recursive chain matrix multiplication

with NO MEMOIZATION
#    Example:
#    M = [(0, 'A',4,3), (0, 'B',3,2,),  (0, 'C',2,5,), (0, 'D',5,3,)]
#         (0 = value (multiplications), 'A' = expression, 4 = rows, 3 = cols)
#    answer = 'expr = (AB)(CD) value = 78'

    n = len(M)      # = 4 in the example above.
    if n == 1:      # A trivial, but necessary, base case.
        return M[0] # M[0] = (0, 'A',4,3) in the example above.

    if n == 2:  # This base case combines two previously computed expressions.
                # Almost all of the function's work is done here, because the
                # magic line (for n > 2) repeatedly calls this base case.
       value = M[0][0]+M[1][0]+M[0][2]*M[0][3]*M[1][3]
       key = '(' + M[0][1] + M[1][1] + ')' # Insert parentheses = (AB) in ex. above.
       rows = M[0][2]
       col  = M[1][3]
       return (value, key, rows, col)

    if n > 2:  # Recursive case.
        best = []
        for k in range(1,n):
             best.append(  f([ f(M[:k]), f(M[k:]) ])  ) # The magic line.
    return min(best) # min evaluates on the first component

of each tuple.
#--------------------------------------------------------------

如果您没有自己解决这个问题,并且不能理解我的代码,那么您可能需要复制我的代码,用 print 语句加载它,然后运行它来理解它是如何工作的。我需要用我在网上或书中找到的代码做很多次。

def f(M, dict = {}): # Recursive chain matrix multiplication with memoization.
    n = len(M)
    if n == 1:
        return M[0]
    if n == 2:
       key = '('+ M[0][1]+'x'+M[1][1]+')'
       if key not in dict:
           result = M[0][0]+M[1][0]+M[0][2]*M[0][3]*M[1][3], \
                   '('+M[0][1]+'x'+M[1][1]+')',   M[0][2],   M[1][3],
           dict[key] = result
       return (dict[key])
    if n > 2:
        best = []

        for k in range(1,n):
             best.append(  f([ f(M[:k], dict), f(M[k:], dict) ], dict) )
    return min(best)
#--------------------------------------------------------------

接下来是迭代函数,它使用相同的符号。你可能没有足够的时间来尝试这个问题。在你提交之前,先看看我的代码有多长。祝你好运。

def f(matrices): # Iterative using memoization
#---Check data format.
    for m in matrices:
        assert len(m) == 4 #  example: m = (0, 'A', 4, 3)
        assert m[0]   == 0
        assert 65 <= ord(m[1]) <= 90
        assert type(m[2]) == type(m[3]) == int
    for n in range(len(matrices)-1):
        assert matrices[n][3] == matrices[n+1][2]

#---Calculate the number of matrices
    limit = len(matrices)

#   HELPER FUNCTION
    def insertInDict (A,B,dict):
       # Example: if A = (0, 'A', 4, 3) and B = (0, 'B', 3, 2), then
       # key = 'AB' and result = (24, '(AB)', 4, 2)
       key        = A[1]+B[1]
       value      = A[0]+B[0]+A[2]*A[3]*B[3]
       expression = '('+A[1]+B[1]+')'
       result     = value, expression, A[2], B[3]
       dict[key]  = result

#   HELPER FUNCTION

    def dictKey(Lst):
        # Example: Lst =[(0, 'B', 3, 2), (0, 'C', 2, 5)] returns key = 'BC'.
        key = ''
        for x in Lst:
            key += ''.join(x[1])
        return key

#   HELPER FUNCTION
    def mult (key1, key2, dict):
        # This function multiplies two matrix expressions (denoted by their
        # keys) and puts the result in the dictionary with a new key.
        newKey = key1 + key2
        A = dict[key1]
        B = dict[key2]
        value  = A[0]+B[0]+A[2]*A[3]*B[3]
        expression = '('+A[1]+B[1]+')'
        # Below, we tack on the newKey with the result and return both.
        result = value, expression, A[2], B[3], newKey
        return result

#---Create empty dictionary.
    dict = {}

#---Insert singles into dictionary--e.g., (0, 'A', 4, 3) with a key of 'A'
    for n in range(0,limit):
           key = matrices[n][1]
           dict[key] = matrices[n]

#---insert the rest (doubles, triples, quads, etc.) into dictionary.
#   This is a complicated function/algorithm with FOUR loops.
    for i in range(2,limit+1):       # i = len(Lst)
        for j in range(0,limit-i+1): # Lst below starts at position j.
               Lst = [matrices[j+n] for n in range(0, i)]
#              Example: Lst = [(0, 'A', 4, 3), (0, 'B', 3, 2), (0, 'C', 2, 5)] 

               candidates = []
             # Strategy: Split any Lst into two consecutive parts. (This can be
             #           done several ways.) Then multiply the two parts and
             #           place the result in the candidates list. Then only the
             #           candidate with the least value goes into the dictionary

               for k in range(1,len(Lst)):
                   key1 = dictKey(Lst[:k]) # = left  part of Lst.
                   key2 = dictKey(Lst[k:]) # = right part of Lst.
                   candidates.append(mult(key1, key2, dict))
               best = min(candidates)
               dict[best[4]] = best[:-1] # The key is at the end (index 4).
    printDictionary(dict)

#---Return dictionary value

with key equal to all matrix letters.
    finalKey = ''
    for tuple in matrices:
        finalKey += tuple[1]
    return dict[finalKey]
#--------------------------------------------------------------

也许读者可以改进,或者至少在不到 10 天的时间内完成。我能在五天内写完这个程序吗?也许,如果我被一个期限所激励的话。我能把这个问题分配给我的高中生吗?我总有几个学生比我编程快得多。只有那几个学生能解决这个问题。

我可以把作业分成小部分,然后让学生把这些部分组合起来解决大问题。偶尔我会这样做,但是这种教学策略有两个缺点。

首先,老师在给学生做作业。他们只是在解决简单的部分。尽管如此,他们还是看到了大局。第二个事实是,当把完成的部分放在一起时,一些学生甚至还没有完成第一部分。这通常不是因为缺乏智力。一些学生有严重的拖延症,任何小的分心都会使他们失去联系。衰老有时会解决这个问题。

为了在高级编程课程或数学优等生课程中提供足够的指导,我一直认为有必要向接近顶端的位置而不是中间的位置教学,并调整分数,以便 a 比其他任何分数都多,并且很少有学生(如果有的话)得到 D 或 f。这当然是分数膨胀,它有其不利的一面。这也让我不再给优等生布置没有挑战性的作业。这是最好的教学方式吗?对我在教高级班的学生来说,是的;对其他老师和其他学生来说,肯定不是,而且有充分的理由。这个世界既需要容易的老师,也需要难的老师。即使是同一所学校教的同一门课,既需要轻松的老师,也需要辛苦的老师。一种尺寸不适合所有人。

总之,我希望读者在这些文章中发现了一些有价值的东西,如果没有超过哲学的话,那就是我们必须做我们的主题,以充分和自信地教授它。(G.B .哈里森是对的。)祝你以后编程好运。

Footnotes 1

1979 年,理查德·贝尔曼因其在动态规划方面的工作获得了 IEEE 荣誉奖章(电气工程的最高奖项)。1985 年,为了表彰他的贡献,设立了数学生物科学的贝尔曼奖。

  2

我发现最早使用“动态规划”这个术语的是理查德·贝尔曼,“论动态规划理论”,美国国家科学院院刊,38 (8),716–719(1952),可以在网上找到。在这里,他说,“由于 Wald [Wald 的统计决策函数,John Wiley & Sons,1950],动态规划的理论与序列分析的理论(1947)密切相关。]“亚伯拉罕·瓦尔德于 1950 年死于一次飞机失事,享年 48 岁。在这篇论文中,贝尔曼引用了其他几篇关于决策过程的技术论文,如 Arrow,K.J .,Blackwell,d .,Girshick,M.A .,“序列决策问题的贝叶斯和极大极小解”,计量经济学,17,214–244(1949)。

在 DVD《贝尔曼方程式》(米沙媒体,2013 年)中,贝尔曼的一个妻子说,贝尔曼告诉她,当时动态规划正在兴起。如果他没有发现它(实际上正式化了这个方法,给它命名,并写了一本书阐述它的用途),那么别人会发现的。Bellman 在 RAND 的同事 Harold J. Kushner 曾经在一次演讲中说:“Bellman 并没有完全发明动态规划,许多其他人对它的早期发展做出了贡献。但没有人像贝尔曼那样抓住它的本质,分离出它的基本特征,并展示出它在控制和运筹学以及在生物和社会科学应用中的全部潜力。”

  3

Fortran 在许多程序中取代了汇编语言,从而使这些程序的规模平均缩小了 20 倍。参见维基百科,s.v. Fortran。1957 年几乎没有计算机,部分原因是它们太贵了,而且现存的计算机计算能力很弱。处理速度和内存大小都极其有限。计算机内存正从水银管转换成铁芯。操作系统和编辑器都很粗糙。这些机器是用汇编语言编程的。第一台商用计算机(带有 5000 个真空管的 UVIVAC I)直到 1952 年才发货,定价为 159,000 美元。最终价格涨到了 150 万美元。作为对比,我记得我母亲在 50 年代末抱怨说,她很难用每周 20 美元为一个四口之家购买食品杂货。贝尔曼在兰德公司工作,他们的计算机是 JOHNNIAC,由他们的工程师用空军的资金手工制造,于 1953 年首次投入使用。(机器)故障之间的平均空闲时间为 500 秒。在这样一台机器上运行一个复杂的程序是多么困难,这是很难表达的。在互联网上搜索弗雷德·约瑟夫·格伦伯格(1968)的 20 页《琼尼亚克的历史》。

  4

1973 年,贝尔曼患上了脑瘤,切除后他严重残疾。然而,他继续以很高的速度出版作品,直到 1984 年 63 岁去世。

"哈尔·沙佩里奥问我[贝尔曼],你认为你会成为比 Erdős 更好的数学家吗?"“好多了,”我说。四双怀疑的眼睛立刻盯着我。我解释道。“Erdős 很有天赋,甚至可以说是天才,但他没有判断力。他解决的问题与他的能力不匹配。”我怀疑当时那些听众是否明白了我的意思。我想他们现在明白了。——理查德·贝尔曼,《飓风之眼》(世界科学,1984),第 109 页。

这句话是在 1946 年前后说的。贝尔曼(博士预科)26 岁,匈牙利人保罗·erdős 35 岁。Erdős 后来成为世界上最多产、最受尊敬和钦佩的数学家之一。他的领域是解析数论,这是数学中最难的领域之一。贝尔曼最初专攻同一领域,最终放弃了应用数学。在我看来,这两位数学家不能相提并论。世界两者都需要。请注意,贝尔曼 1946 年的评论呼应了这一章的序言。

  5

根据同样的推理,f (7,3)的概率应该是,事实也确实如此。

  6

1968 年,英国人工智能先驱唐纳德·米基从词根“memo”中创造了“memoization”一词。

  7

那些不记得过去的人注定要重蹈覆辙。——乔治·桑塔亚纳,(1905)《常识中的理性》,《理性的一生》第 1 卷第 284 页。

  8

我在马丁·加德纳找到了答案,我最擅长的数学和逻辑难题(多佛,1994)。加德纳的早期著作(1961 年)给出了解决方案。我见过马丁·加德纳两次,发现他是一个非常热情和谦虚的人。加德纳于 2010 年去世。直到今天(2017 年),还有每次都叫做 Gathering4Gardner 的会议。

  9

技术。注意。任何物理老师都坚持写 f (t) = d,两个字母都包含单位(油箱和英里)。大多数数学老师倾向于保持单位隐式,以更加关注数学结构:f (1) = d。物理老师是正确的。

  10

术语“非循环”意味着不可能有循环,而“有向”意味着所有链接都是单向的。这里我们用节点和链接(弧)来代替顶点和边,用图来代替网络。任何非循环有向图都可能有其节点被标记,使得任何链接(I,j)[从节点 I 到 j]都将具有 i < j 的性质。为什么?如果图是非循环的,那么至少有一个节点没有输入链接。标记节点 1,并删除该节点的所有传出链路。那么剩余的网络必须至少有一个节点没有输入链路(出于与前面相同的原因)。然后重复。

  11

参考:R. Bellman 和 S. Dreyfus,应用动态规划(普林斯顿,1962),229 页,一个路由问题。

  12

技术。注意。这个特殊的递归函数方程(*)被称为贝尔曼方程,或者更准确地说,这个问题的贝尔曼方程。参见维基百科,s.v .贝尔曼方程。动态规划中的一些问题不需要最大值或最小值,例如,斐波那契函数、吉普问题和问题 3,稍后给出。

  13

每次通话开始前,我都忘了拆字典。因此,在第一次呼叫之后,neighbor总是在dict。对于最后的 999,999 次呼叫,从来不需要计算贝尔曼方程。哎呀!

  14

这个问题的早期参考是理查德·贝尔曼的《动态规划》(普林斯顿,1957),第 45 页,问题 21。贝尔曼指的是在船上装货,而不是背包。在《动态规划的艺术和理论》(学术出版社,1977 年)的第 117 页,作者(Dreyfus 和 Law)暗示背包问题只是货物装载问题的 0-1 版本。今天,整本书都在讨论货物装载问题及其变体。

第一部分:学校里不教的东西

第二部分:编程建议

第三部分:视角

第四部分:像专家一样