优秀程序的良好习惯-全-
优秀程序的良好习惯(全)
一、编程幻想
很久很久以前,一个有才华的年轻程序员没有资源去寻求更多的教育。他的工作没有前途,永远不会有任何提升。此外,他的家人也帮不了他,他住在镇上一个破败、不安全的地方。我们的程序员有四个开发了类似编程技能的朋友,他们也感到受到了机会的限制。他们都有点沮丧,担心自己的未来。
突然,五个程序员发现了一个惊人的机会。如果他们可以组队编写一个特定的计算机应用,那么他们将得到的关注将立即为更好的工作打开大门。
当然,在这种情况下,任何人都想尝试编写应用。但事情没那么简单。以前,他们每个人编写的最具挑战性的程序需要三周时间,每天 1-2 小时。大部分时间都花在了调试上。其中一些错误很难追踪,以至于他们两次放弃了他们的程序,只是出于好奇才回来找他们。事实上,那三周的时间实际上分散在六周内。他们都有同样的经历。
在回顾这个新项目的工作时,似乎这项工作自然可以分成五个相等的部分。问题是每一部分都比他们任何一个人以前做过的任何东西都长五倍。他们有 40 周的时间来完成这个项目。理论上,如果所有人都能保持专注,那就有足够的时间来完成。但实际上,其复杂性超出了任何人的想象。诱人的奖品也是对失败的邀请。简而言之,每个人都认为,他们目前过的这种无处可去的平静生活,可能比几乎肯定会导致失败的 40 周痛苦生活要好。谁需要那个?也许会有别的事情发生。最终,在交谈中,这五个朋友意识到这种失败主义思想是人们无法从生活中的贫困处境中爬出来的一个常见原因。然而,就每个人目前对这个项目的理解而言,完成这个项目对他们来说太难了。如果他们能增加成功的可能性,那么这也许值得一试。那么,该怎么办呢?
首先,这五个程序员必须接受这样一个事实:他们必须把自己变成编程机器人。他们日常生活中的许多乐趣将不得不被数小时的编程所取代。这需要改变习惯和对生活的看法。他们能做到吗?摆在他们面前的奖品可能就足够了。
真正的问题是调试。尽管代码的所有部分看起来都足够合理,但是有太多的部分会导致调试问题大量出现。他们不知道他们中的任何一个人是如何成功的。然后有人提出了一个解决方案:对于几乎每个编写的关键函数,可以编写一个配套函数来测试该函数。在每一次编程之后,测试功能将被运行。另一个程序将导入大多数重要的函数,并通过每个函数运行几组数据。例如,数据将测试函数中几乎所有的 if 语句。
这意味着,如果发生重新设计,受到不利影响的功能将立即被标记出来。为应用中需要的每一个函数编写两个函数将是额外的工作,但是测试函数将很容易编写,并且彼此非常相似。这个被称为单元测试的方案似乎带来了希望。
另一个成员建议团队每周聚在一起阅读彼此的代码,讨论问题,并提出新的解决方案。在这些代码评审中,他们会分享问题和艰难获得的解决方案。另一个建议是用英文描述(docstrings)记录几乎每一个关键函数,以便其他成员可以更容易地理解代码。另一个建议是,他们应该偶尔尝试成对工作(结对编程):一个打字,另一个思考正在键入什么。
该小组认为,他们成功的唯一机会是通过这些公约。其中一名成员后来描述说,使用这些约定就像穿着紧身衣写代码一样。
编程开始后不久,成员们注意到进展缓慢但稳定。不可避免的重新设计,通常基于被忽略的特殊情况和选择不当的数据结构,几乎总是会引起其他变化的多米诺骨牌效应。单元测试很快发现并定位了这些变化。
成员们也开始讨论编程风格的细微差别——例如,是否应该写
if (x and y) == True: print(x)
或者
if x and y: print(x)?
因为意见不一,他们决定以团体风格投票,坚持团体的决定。最终,他们的惯例,通常是武断的,开始看起来正确,任何不同的惯例看起来都是错误的。因为每个人都使用相同的风格,所以他们在阅读以他们商店风格编写的代码时都变得很有效率。
长话短说,他们的牺牲、承诺和关于编写代码的正确决策使他们能够完成项目并赢得更好的生活。最终,他们被寻找专业程序员的雇主雇用了。
他们的新雇主欣赏这个团队的成员有几个原因。首先,程序员将他们的生命投入到编写代码中,他们的技能非常优秀。他们写代码很快,而且几乎没有错误。他们理解他们的语言,并有效地使用其结构。第二,同样重要的是,他们的代码很容易被其他人阅读。第三,他们很灵活。他们在编程中采用了当前的 house 风格,即使他们个人更喜欢以不同的方式编写代码。
在这些编程员工作的一些公司,有裁员。我们的五个原编程员从未被放过。正如一位雇主所说,“他们给的总是比期望的多。谁会让这样的员工离开呢?”
几年过去了,他们都退出了写代码的行业。其中一个年轻的程序员有点无聊。他怀念编程的经历,但年纪太大了,无法重返全职工作。他的配偶注意到邻近的高中需要一名兼职教师来教授一个学期的高级编程课程。他接受了这份工作。
以前的老师希望学生理解不同的算法,并通过在计算机代码中正确实现算法来培养他们的编程技能。这位由程序员转行的老教师同意了这一观点,并意识到商业成功所必需的许多惯例不适用于编写小程序的学生。尽管如此,他认为,编写可读的代码应该与算法、语言指令和数据结构一起教授。课程进行到一半时,他已经讲课并张贴了以下准则。
开发程序员的建议(疼痛管理)
- 将功能局限于单个任务,或者简单且高度相关的任务(内聚与耦合)。
- 标记并对齐您的输出。
- 在顶部记录你的项目:名称、日期、课时(可能是课程和讲师)、标题和项目描述。注意你的拼写、语法和标点。
- 用行号编程,不要少于三个空格。
- 如果要强调重要的关系,请在代码中使用垂直对齐。
- 不要使用 Python 语言名称(保留字和内置名称)作为标识符或文件名,例如
random
、max
、print
、factorial
等。 - 在程序变得更具可读性之后,对其进行重构。这就是学习编程的时间和方式。
- 使用逐步细化:概述程序工作的函数调用。将
main()
函数限制为调用其他函数。在短程序中,你可以给main
函数添加初始化和一些输出行。 - 编写自文档化的代码(描述性标识符,通常是动词对象函数名),从而最大限度地减少注释。避免过度缩写标识符,以节省键入几个字母。
- 总是打印每个程序的运行时间,也许还有其他一些统计数据。
- 避免使用神奇的数字,除非它们能使代码变得非常简单。
- 避免全局变量,但是全局常量是可以接受的。
- 不要写聪明的代码(代码看起来不像它做的那样),简单的代码就可以了。
- 选择可读性,而不是速度优化和内存使用优化。
- 通过使用防御措施(断言、错误陷阱、
try/except
块和中间打印)来预测 bug。只是不要过度。 - 完成后测试每个关键功能。将未测试的代码视为损坏的代码。
- 对于复杂的算法,可以考虑在编写代码之前编写一些简单的测试,而不是在编写代码之后。* * *
- 在完成前一项任务或职能之前,不要开始下一项任务或职能。
- 编程时,你需要全神贯注。避开健谈的同学。(有时候孤立自己的目的是逼你自己解决问题。不要依赖你的同学。)
- 将每个作业保存在至少两个不同的物理设备上。* * *
- 每周写点代码。不要退步。你可能要逼自己。
- 花时间和聪明人在一起,试着让他们谈谈工作。
- 看其他程序员的代码。
- 自学编程工具:复杂的编辑器、语言习惯用法和技巧、内置函数和数据结构。
- 带着尝试挑战性问题的历史来解决你的问题。
- 努力避免作弊。
- 不要让成绩和课外活动破坏你的教育。你要对自己的学习负责,而不是学校。
不幸的是,这份名单不仅被忽视,还被学生们质疑。他无意中听到许多贬低的评论:
- “我不明白为什么我的代码对别人来说是可读的,而对我来说却是可读的,没有人会去读它。让程序运行已经够难了。我需要时间上其他课。”
- “我不敢相信他要求我们不要写聪明的代码。他是想扼杀我们吗?”
- “我认为我的代码足够具有描述性。他太挑剔了,要求更好的描述。”
- “其他的 C.S .老师没有这么挑剔。我希望我在她的班上。”
- “我的代码和保罗的完全一样,因为我们一起工作。他总是说我们需要互相帮助。他最好别说我作弊。”
- “他告诉我们要互相帮助,而不是寻求帮助。这毫无意义。”
- “我的程序中没有错误,那么他为什么要我在代码中设置错误陷阱呢?”
- “我的代码适用于我的输入。这对他的输入不起作用,因为他用奇怪的数据进行测试,比如空集。”
- "我仍然不明白关注分数会对我的教育产生什么负面影响。"
- “为什么我们要在自己的时间里学习工具?他不应该把它们教给我们吗?”
- “互联网上有很多程序不遵守他的规则。那么,他以为自己是谁?”
这位老程序员足够敏感,最终意识到课堂气氛已经从热情转为厌恶。因此,他改变了他的优先事项。只有少数短节目会被检查风格。其他人如果工作的话会被接受。作业变得更短更容易。他表扬了学生们简单的成功。他用一个有趣的 YouTube 视频开始了大多数课程,并允许热烈的讨论继续进行,即使他们抢了全班的练习时间。最后,学生们惊讶于老师进步了这么多。几个学生给了老师临别赠言和小礼物。
就在学年结束前,他反思了所发生的事情。他试图传递给学生的想法、习惯和观点超出了他们的理解范围。他们很快就能捕捉到细节,但没有足够的成熟度或动机去欣赏任何形式的大局。多年前,他和他的朋友们被绝望逼得不得不改变自己。教授他们这种元思维不能通过谈话来完成。它必须以某种方式经历才会被相信。然而,他给他们留下了一个警告。他给他们讲了下面的故事。
The Old Programmer’s Story
同学们,昨晚我发生了一件很神奇的事情,我想和大家分享一下。我与上帝交谈。是的,没错,上帝眷顾了我。诚然,他在梦中来找我,但我知道那是上帝。我们谈到了你的未来。不是对你们所有人,而是对很多人。我想告诉你你的未来会怎样。你的未来会很美好。你要去上大学,毕业,找一份好工作。在你遇到你的另一半之前,你会有很多有趣的假期和冒险。你会有一栋漂亮的房子,享受你的工作,有几个好孩子,还有健康的身体。你将拥有每个人都想拥有的未来。我想我应该现在就告诉你,趁你还年轻,你的未来会有多美好——至少到 45 岁左右。
那时你将被解雇。不是因为你做错了什么。这只是商业的变化和合并。就业部门在不断变化。因为你在你的职业中不突出,你被解雇了。
很自然,你试图找到另一份工作。毕竟,你有多年的经验。不幸的是,编程公司更喜欢雇佣年轻的程序员,他们不必支付那么多工资。不同的管理层认为,几年后,年轻的程序员会有和老程序员一样多的经验。年轻的程序员可能会成为伟大的程序员,而你只是一个普通的程序员。所以你参加了许多面试,但从未得到工作机会。这意味着你的配偶在支撑这个家庭。你大部分时间呆在家里做家务。假期被取消,孩子们的夏令营被取消,电子设备无法升级,当主电视坏了,你的家人把小电视从书房搬到了客厅。你花在自己身上的任何钱都会立即被注意到并受到严厉的批评。当你的配偶和你结婚时,他/她并没有预料到这种简朴的生活方式。怨恨导致了争论。你的配偶在孩子们面前批评你,孩子们也开始对你失去尊重。你的家庭关系变得有毒。最后,你的配偶提出离婚,要求你搬出去。
在离婚时,你从你们的共同储蓄中得到一些钱来买房子,但是你的配偶得到了孩子——你不能抚养他们。最终,你的钱花光了,你最后做了一份清洁工的工作,只是为了支付你的房租和食物。你开始变得沮丧,并开始从廉价的酒精快感中获得安慰。你没有变成酒鬼,但你每天都喝酒。几年后,你偶然照了照镜子,发现自己看起来比实际年龄要老。你掉了一颗牙,又没钱去替换它。你抬头看着天花板说,“为什么这一切会发生在我身上?我做了什么才落得如此下场?”突然,你听到身后有声音,注意到镜子里有动静。你转过身,你猜怎么着,你碰巧看到了我,你以前的计算机科学老师。
“s 先生,我以为你几年前就去世了。你在这里干什么?”
“我几年前就死了。但现在我成了宇宙力量的工具。我是来帮你摆脱困境的。”
“我不敢相信我的运气,”你说。“你要给我找份好工作,让我养家糊口,找回自尊吗?”
“不,宇宙力量不是那样运作的。”
“那你打算给我钱吗?”
“不,宇宙力量也不是那样运作的。”
“嗯然后呢?你打算怎么帮我?”
“首先,我想告诉你,你是如何让自己陷入这种困境的。你因平庸而犯罪。你从未脱颖而出。你没有学习超过你所需要的。你只做了要求的最少部分。你没有努力学习新技能。你没有努力提高你目前的技能,因为你没有必要这样做。当潜在雇主给你的前雇主打电话时,管理层所能做的就是核实你以前的工作。他们对你没什么好评价。难怪你第一个被解雇,最后一个被重新雇用。一旦你明白了这一点,就有希望成功”
“好吧,”你说。“这似乎是真的。在我看来,我从没想过我的未来会变成这样。我以为一般就够好了。除了工作,我还有其他爱好。我不想成为一个工作狂。不过,好吧,我已经吸取教训了。只要让我离开这种生活。”
“你吸取教训了吗?我们会找到答案的。我要让时间倒流,把你送回你在我班上的时候。你会忘记你的未来,除了我现在告诉你的这个关于你生活的小故事。我就像圣诞未来的幽灵。未来不是固定的,否则我不会告诉你这些。你的警告是要比平均水平更好。总是继续学习更多,提高自己的技能。无论是在工作中还是在人际关系中,总是付出比期望的更多。然而,要知道:你不会再有机会了。祝你好运。”
学生们认为这个故事很可爱,他们很欣赏一个能让他们开心的老师。他们中的大多数人很快就忘记了这个故事。只有少数人对此感到困扰。对他们来说,这个故事支持了他们已经开始相信的事情:可怕的陷阱在他们的未来——工作中的陷阱,婚姻中的陷阱,甚至是他们最终试图保护的孩子的陷阱。他从来不知道,但老程序员已经为他的学生做了所有可能的事情。
二、编程技巧
- 掌握分析棋位的技巧需要大量的练习和努力。但是一旦你把它弄下来,你就不会后悔投资了。—乔尔·约翰逊(美国国际象棋大师)《阵型进攻》(私人出版,2010),第 15 页。
本章将采用一个简单的——几乎微不足道的——函数,并用 12 种不同的方式来编写它。这些招数大部分都不是学校教的。你需要自己去学习它们。
定义:斐波纳契 1 数字是一个序列中的数字,从 1,1,…开始,此后每个新数字都是前两个数字的和。以下是前 17 个斐波那契数列:
+----------------------------------------------------------------------------------------+
| Fibonacci numbers: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597 |
| The nth position 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
+----------------------------------------------------------------------------------------+
这里,第 1000 个斐波那契数是 4346655…228875 (209 位)。有时这个序列最初的索引是零,有时它从初始值零开始。如果你让一个初学编程的人编写一个函数来打印第 n 个斐波那契数,他/她可能会编写一个简单的迭代函数,如下所示:
def fibA(num): # This function took 7.45 seconds to find the 1000th
# Fibonacci number 100,000 times in Python Ver. 3.4.
if num < 3:
return 1
a = b = 1
for i in range(2, num):
a, b = b, a+b
return b
如果你让同一个程序员递归地解决这个问题,结果会像下面的函数fibB
一样。
def fibB(num): # Too slow.
if num < 3:
return 1
return fibB(num-1) + fibB(num-2)
这是这个斐波纳契函数集合中唯一一个对于实际工作来说太慢的函数。看起来,fibB
的唯一理由是向初学者介绍递归。并非如此。它也可以作为一个糟糕的递归方法的例子。如果递归做得更好(稍后显示的fibH
,或者可能通过使用记忆装饰器,也在稍后显示),它会快得多。
你可能会说fibB
是这个集合中最差的函数。也是最简单的功能。所以我们已经学会了两种评估函数的方法:速度和简单。还有多少其他方法?至少还有四种方法。我们稍后将回到这个问题。
fibB
函数仅计算第 45 个斐波那契数列一次就花费了 313.48 秒(5 分 13 秒)。我对计算第 1000 个斐波那契数十万次感兴趣。当然,为了让fibB
更快,我们可以提供更多的基本案例。引入查找表是编程中的一个标准技巧。在 Python 中,有时可以用下面显示的巧妙的索引方法来完成。
def fibBB(num): # Still too slow to compare.
if num < 18:
return [0,1,1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,1597,][num]#17 base cases
return fibBB(num-1) + fibBB(num-2)
在fibBB
中,313.45 秒减少到大约半秒。不幸的是,fibBB
函数仅计算第 55 个斐波那契数就花了 51.08 秒:还是太慢了。仔细阅读并检查所有技术资料。你注意到 17 种基本情况需要 18 个数字吗?
我们可以通过引入动态(变化的)查找表来改进双胞胎fibB
和fibBB
。这叫做记忆化。虽然记忆化可以加速重叠子案例的递归,但这个改进的函数(fibC
)仍然比fibA
(迭代)慢 7 倍以上。当追加 17 基情况的查找表时,时间增加了(多么奇怪)几乎 24%(从 57.11 秒增加到 70.69 秒)。有时听起来不错的编程想法并不总是那样。
def fibC(num, dict): # 57.11 seconds to find the 1000th Fibonacci number
# 100,000 times.
if num in dict:
return dict[num]
dict[num] = fibC(num-1, dict) + fibC(num-2, dict)
return dict[num]
# The call to fibC looks like this: print(' C.', fibC(n, {1:1, 2:1}))
函数fibA
和fibC
都是“动态规划”的例子,这是一个困难的话题,我们将在最后一章考虑。在fibC
让字典全球化让我们免于传递字典。然而,使用全局字典不会降低速度,并且尽可能避免使用全局变量。那么,我们能避免在没有全局变量的情况下传递字典吗?是的。Python 函数是类;他们有阶级变量。
def fibD(num): # 73.96 seconds.
if num in fibD.dict:
return fibD.dict[num]
fibD.dict[num] = fibD(num-1) + fibD(num-2)
return fibD.dict[num]
fibD.dict = {1:1, 2:1}
# A Python function's class variable must be declared BELOW the
# function.
不幸的是,fibD
明显比fibC
慢,尽管fibD
代码与fibC
代码完全相同,除了fibC
在传递地址时做了额外的工作。这样的速度变化,尤其是时间的增加,怎么可能呢?显然,访问一个类变量(fibD.dict
)要比访问一个全局变量或参数(dict
)花费更多的时间。
fibD
的设计让我很不舒服,因为我们在代码中有一个四处浮动的查找表。假设他们走散了呢?查看一个函数同时试图找到它的一个引用会降低可读性。我的建议是将它们一起嵌入到另一个函数中。但是时间还是很慢。事实上,嵌套函数的执行速度总是比非嵌套函数慢。
def fibE(num): # 76.35 seconds.
def fib(num):
if num in fib.dict:
return fib.dict[num]
fib.dict[num] = fib(num-1) + fib(num-2)
return fib.dict[num]
fib.dict = {1:1, 2:1}
return (fib(num))
我们能做得更好吗?是,使用初始字典的默认值。这是编程中的标准伎俩。记住它。
def fibF(num, dict = {1:1, 2:1}): # 59.99 seconds.
if num in dict:
return dict[num]
dict[num] = fibF(num-1, dict) + fibF(num-2, dict)
return dict[num]
难道不应该有一个断言语句吗,比如:
assert type(num) == int and num > 1, 'Bad data: num = ' + str(num)
是的,但是对于这些例子,我已经简化了代码。
现在我要引入一个棘手的概念:装饰者。回忆缓慢的fibB
。
def fibB(num): # Simple code, but too slow, or is it?.
if num < 3: return 1
return fibB(num-1) + fibB(num-2)
如果有一个记忆词典,它会运行得更快,但这会使代码变得复杂。那么,我们可以两者兼得吗?嗯,差不多了。Python 的设计者已经引入了一种方法来做到这一点,而没有大多数缺点。唉,代码将驻留在两个地方。这是你怎么做的。
def memoize(function): # function = fibB.
dict = {} # This line is executed only once.
def wrapper(num): # num came from fibB(num).
if num not in dict:
dict[num] = function(num)# The return of fibB is always to dict[num].
return dict[num] # The return is to function, except for final.
return wrapper # This line is executed only once.
@memoize
def fibB(num):
if num < 3: return 1
return fibB(num-1) + fibB(num-2)
这个过程被称为“修饰函数”它不仅使我们不必在每个需要记忆的单参数函数中引入新的字典,而且装饰器还通过提取记忆代码简化了被装饰的函数。不幸的是,设计师们找不到一个简单的设计来装饰一个功能。程序员必须研究和编写许多 decorators 来了解正在发生的事情。
偶尔你可能想为一个功能计时。为什么不把@timer
放在函数定义的上面,从你的个人库中取出这个装饰器呢?
def timer(function):
from time import clock
from sys import setrecursionlimit; setrecursionlimit(100) # default = 1000
startTime = clock()
def wrapper(*args, **kwargs):
result = function(*args, **kwargs)
return result
elapsedTime = round(clock()-startTime, 2)
print('-->', function.__name__ +"'s time =", elapsedTime, 'seconds.')
return wrapper
clock
可以从其他地方导入。可选的setrecursionlimit
有时对递归函数很有用。(*args, **kwargs)
意味着任何一组普通参数和关键字参数都将被接受。function.__name__
只是调出函数名。所以你看装饰者有时可以简化代码。请注意:1)递归修饰函数似乎比没有修饰的函数需要更多的递归。2)装修工在本书中不会用到太多。
看了几个斐波纳契函数之后,我们再次问是否还有其他的方法?如何看待使用公式:没有循环,没有递归?我们怎么忽略了这一点?公式既简单又快速。
def fibG(num):
from math import sqrt
phi1 = (1 + sqrt(5))/2
phi2 = (1 - sqrt(5))/2
return round((phi1**num - phi2**num) / sqrt(5))
# fibG(70) = 190392490709135
嗨。注:这些方程被称为比奈公式,以 1843 年发表该公式的法国学者命名。 2 作为练习,提高fibG
的速度。我的版本在脚注里。 3
然而,使用带有浮点数的公式来产生大数是一个可怕的想法,因为浮点数的精度是有限的,因此最终会输出不正确的值。Python 中的整数只受计算机可用内存的限制。继续使用整数,我们可以精确地生成第一千万个斐波那契数,它有 2,089,877 个数字。至少有四个原因使得计算机算术不总是和数学算术一样。
-
计算机——由于二进制表示——只有近似的浮点数:
print( (1/3) == 0.3333333333333333 ) # = True print(1.0e+309) # = 'inf' print(1.4/10) # = 0.13999999999999999
-
超过有效数字的限制(Python 中的 16 位(53 位)),计算不可信:
print('2.0**53-1 =', 2.0**53-1) # = 2.0**53-1 = 9007199254740991.0 print('2.0**53-0 =', 2.0**53-0) # = 2.0**53-0 = 9007199254740992.0 (limit) print('2.0**53+1 =', 2.0**53+1) # = 2.0**53+1 = 9007199254740992.0
-
舍入误差累积:
print(0.1 + 0.1 + 0.1 == 0.3) # = False print(0.1 + 0.1 + 0.1) # = 0.30000000000000004
-
翻车。在很多语言中,最大的整数加 1,就变成了绝对值几乎相同的负数。Python 整数不会出现这种情况。但是这种便利性和其他便利性(列表中的混合数据类型)使得 Python 比其他语言慢。
Python 确实有大浮点数的十进制格式。可惜慢了。
def fibGG(num): # 1153 seconds = 19 minutes and 13 seconds.
from decimal import Decimal, getcontext
from math import sqrt
if num > 70:
getcontext().prec = 2*num
phi1 = (Decimal(1) + Decimal(5).sqrt())/Decimal(2)
phi2 = (Decimal(1) - Decimal(5).sqrt())/Decimal(2)
return round((phi1**Decimal(num) - phi2**Decimal(num)) /
Decimal(5).sqrt())
在这一点上,大多数人可能会选择fibA
而不是其他函数,因为它容易理解并且比我们见过的其他函数更快。函数fibA
可以在 16 分钟内找到第 1000 万个斐波那契数。
也许一个更好的解决方案是把第一个亿万斐波那契数列保存在磁盘上,然后读出我们想要的那个。下面的代码将在 933 秒(= 15 分 33 秒)内创建一个保存第一个 max = 78125 斐波那契数列的文件。
#---Create file containing the first max Fibonacci numbers.
from time import clock
max = 78125
print('max =', max)
print('start')
start = clock()
file1 = open('g:\\junk.txt', 'w')
file1.write('1\n')
a = b = 1
for i in range(1, max):
file1.write(str(a)+'\n')
a, b = b, a+b
file1.close()
stop = clock()
print('stop')
print('time =', round(stop-start, 2), 'seconds.')
数字范围扩大一倍似乎比时间增加四倍多一点。这可能是因为斐波那契数列的大小在增长。如果将范围扩大一倍将时间乘以 4(可能是一个低估值),那么我们通过对 10,000,000/(27) = 78125 个数字计时并将时间乘以 47 = 16384 来粗略估计创建前一千万个斐波那契数列文件所需的时间。因此,为前 1000 万个斐波那契数列创建一个文件的时间估计至少需要 933*16384 秒= 15286272 秒,差不多是 177 天。注意:你不应该被动地阅读技术资料。你需要检查这些计算背后的逻辑和数学。 4
一旦建立了文件,提取小数字很快,但是提取大数字需要时间。
#---Extract a number from of a file of numbers.
file1 = open('g:\\junk.txt', 'r')
print('start')
start = clock()
for n in range(78124):
file1.readline()
num = (file1.readline())
file1.close()
stop = clock()
print('stop')
print('time =', round(stop-start, 2), 'seconds.') # 8.94 seconds.
现在来了一个惊喜。上面的代码花了 9 秒来提取第 78124 个斐波那契数。函数fibA
将在 0.3 秒内生成第 78124 个斐波那契数。查找表的想法(这里存储在磁盘上)是一个有用的想法。我们已经看到它在fibBB
比fibB
大大提高了速度。但是,访问大型 Python 文件可能比直接计算要慢。
因此,也许你已经学会了一些编程技巧(记忆化、类变量、嵌入式函数、默认值、为了大数的准确性而选择整数而不是浮点数,以及查找表的值)。记住窍门有助于你成为一名成熟的程序员。忘记技巧几乎就像从来没学过一样。那么我们如何记住它们呢?我们使用最近学到的知识编写代码。
这些例子虽然简单,却给了我们一个自然的机会来看看大多数初学者的编程风格。我们将在第四章回到编程技巧。
Footnotes 1
我认为更好的发音是 FEE buh naht chee,但是这已经被英语化成一个可接受的 FIB uh naht chee。我的来源是有用的韦氏新传记词典(韦氏词典)。
2
见罗斯·洪斯伯格《数学瑰宝 II》(MAA,1985),页 108。
3
def fibG(num): # Faster version
from math import sqrt
sqrt5 = sqrt(5) # Do not compute this number more than once.
phi = (1 + sqrt5)/2
return round((phi**num)/sqrt5)
4
如果我们将数字 78125 加倍七次,那么我们得到 10,000,000。因此,如果生成前 78125 个斐波那契数需要 t 秒,那么生成前 10,000,000 个斐波那契数需要(4**7) t 秒= 16384 t 秒。
三、风格
- 硬科学的人很少知道如何写,他们中的大多数也不知道如何编程;他们学习如何编写算法,而不是如何编写可维护的计算机程序。——艾伦·I·霍勒布,《足够打自己脚的绳子》(麦格劳-希尔,1995),页 18。* * *
- 有人告诉我,可读性并不是一切。呼吸也不是,但它确实在接下来发生的事情之前。——威廉·斯隆,《写作的技巧》(诺顿出版社,1979),第 11 页。
风格是预见到其他人在理解、调试、修改和在他们的程序中使用你的代码时会遇到的困难,然后在你的构建中解决这些困难。这是礼貌的一种形式。我把前一章给了我的几个编程班的所有学生,我们详细地讨论了它。然后我收集了讲义,给了下面的作业。
作业:写出以下七个斐波那契函数:
fibA
简单迭代。fibB
简单递归。- 带有装饰器的简单递归。
- 递归和记忆,将字典作为参数传递。
fibD
递归和记忆化,以字典作为类变量。- 带有嵌入函数的递归和记忆。
- 使用默认字典参数的递归和记忆。
- 你必须在网上找到的公式。
任何计算机科学课程的能力范围都是巨大的。有些学生在 30-45 分钟内完成了这项作业。其他人又花了 30 分钟,需要同学们的帮助。有些人无法完成作业,不得不在家完成。
我开始查看作为我的函数的副本提供的工作函数。我会大吃一惊的。编程语言中特殊用途的句法结构被称为“习惯用法”。在 Python 中,将一个值赋给两个变量的首选方式(习惯用法)是这样的:a = b = 1
。两个变量互换的首选方式是这样的:a, b = b, a
。
这是 15 分钟前我给我的学生展示的斐波那契函数。它使用了上面描述的两个成语。
def fibA(num):
if num < 3: return 1
a = b = 1
for i in range(2, num):
a, b = b, a+b
return b
我的一个学生使用 Java/C/C++习惯用法编写了迭代斐波那契函数。
def fibA(n):
if n <= 2: return n
a = 1
b = 1
tmp = 0
for i in range(n-2):
tmp = b
b += a
a = tmp
return b
有一种不去学习一个新成语的自然倾向。如果一个老方法有效,那么为什么不继续使用它呢?这些 Python 习惯用法是如此简单、常见和有用,并且已经在我自己的公共代码中演示了几个月,以至于我很惊讶这个学生没有采用它们。
当我看他的第四个函数时,我不容易理解他的代码,直到我缩进它。这名学生试图重现这段代码:
def fibC(num, Dict):
if num in Dict:
return Dict[num]
Dict[num] = fibC(num-1, Dict) + fibC(num-2, Dict)
return Dict[num]
以下是他想出的方法,它确实有效:
def fibC(n, d:dict):
if n <= 2: return 1
if n-1 in d: a=d[n-1]
else: a = fibC(n-1,d)
if n-2 in d: b = d[n-2]
else: b = fibC(n-2,d)
d[n] = a+b
return a+b
下面是缩进的同一个函数:
def fibC(n, d:dict):
if n <= 2:
return 1
if n-1 in d:
a=d[n-1]
else:
a = fibC(n-1,d)
if n-2 in d:
b = d[n-2]
else:
b = fibC(n-2,d)
d[n] = a+b
return a+b
这个特别的学生是我的一个聪明的学生,并且经常是第一个完成测验的学生之一。然而,这个学生和他的一些同学并没有试图采用一种可读的风格。让代码工作是他们唯一的目标。这至少是他们第三次编程课程的年底。
由于缺乏编程经验,学生不理解风格。他们不写冗长复杂的程序。他们不修改和调试他人编写的遗留代码。因此,他们编写程序的自然风格永远不会超出编写短程序的范围。老师坚持一种风格,这种风格有利于长节目被其他人阅读,这种风格适用于只被学生或者老师阅读的短节目。试图用这种方式教学很容易让认为老师过于迂腐的学生与老师发生冲突。
几年前,我让我的学生用可读性最好的代码来解决一个问题,这些代码会让他们在面试中自豪地展示出来。令我惊讶的是,即使是很强的学生也写出了丑陋且注释过多的代码。他们根本不知道什么是可读代码。之后,我开始展示易读代码和难读代码的例子。
现在,每年大约有五六次,我坚持让我的学生用易读的风格写短程序。当他们打印出他们的小程序并交给我时,我会指出我看到的第一个风格错误,并让他们重新打印。一些学生需要打印六次或更多次他们的程序。我总是看到一些学生在互相讨论,试图预测我的下一个批评会是什么,这样他们就不用再打印他们的程序了。至少他们获得了一些编写可读代码的经验。但是除非他们知道我会检查他们的代码,否则大多数学生不会花时间去写可读的代码。
让学生养成重构的习惯(清理他们的代码,使其更具可读性)的问题与让学生写出合格的论文是一样的。除非老师要求,否则对语法、标点、修辞(句子的有效使用)、措辞(单词的选择)、内容甚至校对的关注大多会被忽略。质量要求高,需要仔细检查每个学生的作业。我问英语系一位德高望重的成员,她是否反复阅读并退回同一篇论文,直到可以接受为止。她说,作为一名初任教师,她这样做了,但停止了这种做法,因为它占用了太多的个人时间。大多数老师在大多数科目上都是如此。我们没有时间定期检查学生的作业以确保质量。最终,每个学生都必须成为自己的老师。
- 科学家在哪里学习如何开发软件和在研究中使用计算机?几乎所有人(2008 年一项网络调查的近 2000 名学术受访者)都表示,非正式的自学最为重要。同伴指导排在第二位,学校或工作中的正式指导远远落后。——格雷格·威尔逊,美国科学家,第 97 卷(2009 年 9-10 月),第 361-362 页。
下面是我用来稍微自动化重构过程的检查表:
Is Your Program Finished Before the Deadline? If Yes,
- 你使用了逐步细化吗?【如果没有,那就回去修。]
- 你完成的时候重构了吗?【如果没有,那就回去修。]
- 你写了自我记录的代码吗?【如果没有,那就回去修。]
- 你把功能限制在单一任务上了吗?【如果没有,那就回去修。]
- 你使用了你的语言中的习语吗?【如果没有,那就回去修。]
- 你使用断言和其他错误陷阱了吗?【如果没有,那就回去修。]
- 你是否在有用的地方使用了垂直对齐?【如果没有,那就回去修。]
- 你创造了有标签的有吸引力的输出吗?【如果没有,那就回去修。]
- 你打印了你的程序运行的时间了吗?【如果没有,那就回去修。]
- 你测试好最终产品了吗,尤其是特殊情况和临界情况?【如果没有,那就回去修。]
- 在你写完之后,你是否立即测试了每个主要的功能?【如果没有,就不要再这样了。采用专业人士的习惯。]
- 你有没有避免写聪明的代码,做不必要的优化,为不重要的情况编程?【如果没有,就不要再这样了。采用专业人士的习惯。]
现在回到更多的编程技巧。
Footnotes 1
最佳定义:礼貌是让你周围的人感到舒适。这又是一个相同的想法:“优秀的作家对他们的读者产生了持久的共鸣,而糟糕的作家则没有。”—布莱恩·加纳,《简明英语法律写作》(芝加哥大学,2001 年),第 145 页。
四、更多编程技巧
- 人们常说,一个人只有在把某样东西教给别人的时候,他才真正理解了它。实际上,一个人并没有真正理解某件事,直到他能把它教给计算机,也就是说,把它表达成一种算法。将事物形式化为算法的尝试比我们简单地试图以传统方式理解事物会导致更深刻的理解。—Donald E. Knuth (1974 年图灵奖 1 获得者)“计算机科学及其与数学的关系”,《美国数学月刊》,第 81 卷,1974 年 4 月,第 327 页。
下面,递归函数(fibH
)是对fibB
的改进。
def fibH(num, a = 0, b = 1): # 31.91 seconds.
if num == 1:
return b
return fibH(num - 1, b, a+b)
我们可以如下所示在一行中写fibH
(没有速度增加)。
def fibHH(n, a = 0, b = 1): # 31.91 seconds.
return fibHH(n-1, b, a+b) if n > 1 else b
由于 Python 允许动态编写匿名函数,所以我们可以使用 lambda,但结果较慢。
f = lambda n, a=1, b=1: int(n<3) or a+f(n-1,b,a+b) # 56.08 seconds.
问题总是这样:三个版本中哪一个最容易调试。
题外话。任何递归函数都可以迭代编写。事实上。递归本身不是递归的。递归被实现为一个带有参数、局部变量和返回到调用例程的地址的调用栈。调用堆栈上每一项的所有这些信息都是一个堆栈帧。
注意,在fibH
中,递归调用是独立的,不像return fib(x-1) + x
中,在递归调用之后附加一个加法。这种独立的或者使递归成为返回之前的最后一个动作(例如return x + fib(x-1)
)被称为“尾部递归”其优势在于,智能编译器(即优化的编译器)将识别尾部递归,并将其更改为goto
,从而降低递归的巨大堆栈内存需求。奇怪的是,Python 编译器没有针对尾部递归进行优化。
即使没有编译器的优化,尾部递归也可以通过消除递归调用极大地提高函数的速度,就像这里的fibH
一样。
考虑阶乘函数,而不是斐波那契函数。这里没有以前解决的案件的总和来得出一个最终数字。下面我们比较阶乘函数的五种不同形式。我们看到尾部递归不比非尾部递归快,因为尾部递归不能消除阶乘函数中的递归调用。即使是查找表也无济于事。
def factorial1(n): # Tail recursion 1 = 12.25 seconds
if n == 1: return 1
return n*factorial1(n-1)
def factorial2(n, x = 1): # Tail recursion 2 = 13.72 seconds
if n == 1: return x
return factorial2(n-1, n*x)
def factorial3(n): # non-Tail recursion = 11.88 seconds
if n == 1: return 1
return factorial3(n-1)*n
def factorial4(n): # Iteration = 5.51 seconds
t = 1
for n in range(1,n+1):
t = t*n
return t
def factorial5(n): # Tail recursion with look-up table = 12.36 seconds
if n <=11:
return [0,1,2,3,24, 120, 720, 5040, 40320,362880, 3628800, 39916800][n]
return n*factorial5(n-1)
与此讨论无关的是下面的好奇心:下面的两个单行函数都将使用 Python“and/or”技巧来计算 n 阶乘,这种技巧可能永远都不应该使用。(为了让这个技巧起作用,中间的表达式必须总是计算为True
。)
def factorialA(n):
return (n>1) and (n*factorialA(n-1)) or 1
#----------------------------------------------
def factorialB(n, x = 1):
return (n>1) and factorialB(n-1, n*x) or x
题外话结束。
我们能不能做得更好,或者更快,或者至少用不同的方法来建立一个斐波纳契函数?在互联网上搜索,我发现了奇怪的“斐波那契矩阵”(又名 Q 矩阵),其中
或者在代码中:
A**n = [ [1,1], [1,0] ]**n = [ [fib(n+1),fib(n)], [fib(n),fib(n-1)] ],
在这一点上,你应该做我第一次遇到这个方程时做的事情。手算几个例子,说服自己矩阵方程是真的。例如:
这不是一个证明,但会给你一个感觉,为什么斐波纳契数会从这些矩阵中冒出来。
矩阵乘法似乎是一种很慢的生成斐波那契数的方法,如果我们需要将A
乘以 22 次才能生成第 23 个斐波那契数(28657),那就是这样。[注:基数为 2 时,23 = 10111。]但假设在生成第二个斐波那契数A**2 = [[2,1],[1,1]]
后,我们将A**2
乘以自身得到A**4 = [[5,3],[3,2]]
。然后我们将A**4
乘以自身,生成A**8 = [[34,21],[21,13]]
。然后我们将A**8
乘以自身,生成A**16 =[[1597,987],[987,610]]
。事实证明
X = A**23 = (A**16) * (A**4) * (A**2) * (A**1) = [[46368,28657],[28657, 17711]].
我们的答案是X[0][1]
= 28657 =第 23 个斐波那契数,只有 7 次(= 4 + 3)矩阵乘法而不是 22 次矩阵乘法。你可能仍然不会对这个计划的速度印象深刻。但是想象一下试图计算第一千万个斐波那契数。
我们只需要计算 23 (= 24-1)个斐波那契数(只需要 30 = 23+7 的矩阵乘法),而不是计算一千万个斐波那契数(几乎需要 10,000,000 次加法)。24 是从哪里来的?以 2 为基数表示的数字 10,000,000 有 24 位。
print(bin(10000000)) # = 100,110,001,001,011,010,000,000 in base 2.
此外,24 是一千万分之二的对数基数,使用基数变化公式向上取整。现在算这个数来检查我的工作。如果你记不住对数的底基变化公式,就去查一查。还是做不到?那就找个能教你怎么做的人。对数很有用。你需要一些技巧。计算在脚注里。 2
我们首先需要一个将两个 2x2 矩阵相乘的效用函数mul(A,B)
。然后为了找到fib(23)
,我们设置A =[1,1,1,0]
(不是[[1,1],[1,0]]
,因为我们希望减少方括号的使用)。接下来,使用mul()
我们生产A**1, A**2, A**4, A**8,
和A**16
。我们让X = A**23 = A**16 * A**4 * A**2 * A**1
。我们如何做到这一点?换句话说,我们是如何决定在X
的计算中忽略A**8
的?我们如何找到一个等于任意正整数的 2 的幂和?我们能肯定我们总能找到这样一笔钱吗?
在介绍二进制系统之前,我喜欢问学生一个问题:一个古怪的富人喜欢购物,但他总是想支付准确的金额(最多 100 美元,包括支票和 99 便士),所以他没有得到零钱。问题:在他去购物之前,他最少需要开多少张支票?答案在脚注里。 3
因为二进制中的 23 是 10111,所以反转数字并将其类型改为字符串,我们得到“11101”。如果我们让X
=这些(A**p)
表达式的乘积,对于所有位置p
(初始位置是 1,不是 0),其中在反转的字符串中有一个 1,那么第 23 个斐波那契数就是答案。这种方案总是可行的,部分原因是任何正整数都可以表示为二进制数。
- 我深感遗憾的是,我没有走得足够远,至少没有理解一些伟大的数学原理,因为被赋予这种天赋的人似乎有一种额外的感觉。—查尔斯·达尔文,自传(剑桥回忆,1828-1831)。
def fibIII(n): # 1.61 seconds. (Remember, fibA took 7.45 seconds.)
def mul(A, B): # multiply two 2x2 matrices
a, b, c, d = A
e, f, g, h = B
return a*e+b*g, a*f+b*h, c*e+d*g, c*f+d*h
A = [1,1,1,0] # = Fibonacci matrix. We will generate A, A**2, A**4, A**8, A**16,
# etc., some of which can be combined to produce matrix X.
X = [1,0,0,1] # = identity matrix, which will later contains the answer:
s = str(bin(n))[2:] # x[1] = fibIII(n). The str(bin(n))[2:] will change fibIII
s = s[::-1] # number to a binary string--e.g., n = 12 --> '1100'.
for n in range(len(s)): # The s[::-1]will reverse digits in a binary string.
if s[n] == '1':
X = mul(X, A) # Matrix X accumulates some of the powers of matrix A--
A = mul(A, A) # e.g., X = A**12 = A**4 + A**8.
return X[1]
这是一个令人印象深刻的时间减少。它不使用递归,只是通过使每一步都是前一步的两倍来大步迈向目标数。但是为什么不重写fibIII
,去掉对内嵌函数的调用呢?当然,这将使功能更快。
def fibII(n): # 2.10 seconds
A = [1,1,1,0] # = Fibonacci matrix.
X = [1,0,0,1] # = identity matrix.
s = str(bin(n))[2:] # Change fibII number to a binary string--e.g., n = 12 --> 1100.
s = s[::-1] # Reverse digits in binary string--e.g., 1100 --> 0011.
for n in range(len(s)):
if s[n] == '1':
X = X[0]*A[0] + X[1]*A[2], X[0]*A[1] + X[1]*A[3], X[2]*A[0] + X[3]*A[2], X[2]*A[1] + X[3]*A[3]
A = A[0]*A[0] + A[1]*A[2], A[0]*A[1] + A[1]*A[3], A[2]*A[0] + A[3]*A[2], A[2]*A[1] + A[3]*A[3]
return X[1]
让我惊讶的是,fibII
功能比fibIII
慢一点。你能通过检查确定原因吗?这个秘密将在下一个函数中解释。
def fibI(n): # 1.37 seconds.
a,b,c,d = 1,1,1,0 # = Fibonacci matrix.
e,f,g,h = 1,0,0,1 # = identity matrix.
s = str(bin(n))[2:] # = base 2 representation of n--e.g., if n = 12, then s= "1100".
r = s[::-1] # = reversed version of s--e.g., if s = "1100", then r= "0011".
for n in range(len(r)):
if r[n] == '1':
e,f,g,h = a*e+b*g, a*f+b*h, c*e+d*g, c*f+d*h # = X*Y (2x2 matrix mult).
a,b,c,d = a*a + b*c, a*b + b*d, c*a + d*c, c*b + d*d # = Y*Y (2x2 matrix mult).
return f
函数fibI
与fibII
完全相同,除了需要更少的列表索引(带方括号)。回想一下,原语标识符(如a
或x
)只是一个内存地址。但是列表中的每个元素(不是内存中连续位置的数组)既是一个值,也是下一个元素的地址。因此为了找到x[3]
,计算机转到地址x
。然后它读取并移动到下一个地址:x[1]
。然后它读取并移动到下一个地址:x[2]
。最后,它读取并移动到下一个地址:x[3]
。fibII
中的代码需要 12 次这样的读取和移动操作。只需查找一次,然后将值分配给非下标标识符,比继续查找链式地址更有效。反正速度提升很小。也许fibIII
是首选,因为它更容易理解。
下面的公式可以从斐波那契矩阵中导出。你能推导出它们吗?
fib(2*k) = fib(k)*(2*fib(k+1)-fib(k)) [= fib(k)*(fib(k+1)+fib(k-1))],
fib(2*k+1) = fib(k+1)**2 + fib(k)**2.
最初,我无法推导出这些公式,但我还是使用了它们。然后让我恼火的是——真的让我恼火——我无法推导出这些线性代数公式。我是什么样的微积分老师?所以我回去摆弄了一下A**n * A**n = A**(2n)
。20 分钟后,答案出来了。(实际上我是从答案开始逆向寻找推导过程的。)
许多数学家使用他们自己无法证明的经典定理。使用经过专家验证的数学是没有问题的,即使我们无法遵循他们的证明。但是,您需要了解约束/限制/界限/附加条款/规定/界限/特殊情况等。
计算机科学家和物理学家经常做所谓的非严格数学——即基于类比和明显模式的数学思维,这种推理对数学家来说是不可接受的。这在计算机科学中是可行的,因为计算机科学家然后基于数学编写一个可行的程序,从而证实(在某种程度上被一些人接受)数学。以类似的方式,物理学家建造起工作的东西,从而证实(在某种程度上被一些人接受)数学。当然,严格证明数学会更好,但这通常需要研究人员不具备的符号操作技能。发展这些技能(如果可能的话)会占用研究时间。大多数现代研究都是在团队中完成的,部分原因是雄心勃勃的项目对一个人来说花费了太多的时间,但也是因为很少有人拥有一个大项目所需的所有技能。顺便问一下,“证明”的定义是什么? 4
注意下面 1)在fibJJ
中不需要else
或elif
。有些人喜欢把它们放进去,2)我们更喜欢用fibJ(k)**2
而不是fibJ(k)*fibJ(k)
来减少一半的递归调用。
def fibJJ(n): # 3158.00 seconds
if n < 3:
return 1
if (n%2) == 0:
k = n//2
return fibJJ(k)*(2*fibJJ(k+1)-fibJJ(k))
k = (n-1)//2
return fibJJ(k+1)*fibJJ(k+1) + fibJJ(k)*fibJJ(k)
我对fibJJ
代码的执行速度之慢感到惊讶,但我一直专注于让函数返回正确的值。几天后,我重新审视它,立刻意识到我写这段代码的效率是多么低。我重写了代码,把时间从 3158 秒减少到 38 秒。然后,我用一个 17 值的基本情况查找表替换了 2 值的基本情况,并将时间减少到 5 秒(fibJ
)。不要忘记考虑查找表的力量。
def fibJ(n): # 5.00 seconds
if n < 18:
return [0,1,1,2,3,5,8,13,21,34,55,89,
144,233,377,610,987,1597,][n]
if (n%2) == 0:
k = n//2
f = fibJ(k)
g = fibJ(k+1)
return f*(2*g-f) # = fibJ(k)*(2*fibJ(k+1)-fibJ(k))
k = (n-1)//2
f = fibJ(k)
g = fibJ(k+1)
return g*g + f*f # = fibJ(k+1)*fibJ(k+1) + fibJ(k)*fibJ(k)
现在也许你明白为什么我选择不使用公式fib(2*k) = fib(k)*(fib(k+1)+fib(k-1))]
。该公式要求代码进行三次递归调用,而不是两次。
fibJ()
函数仍然重新计算一些相同的斐波那契数。因此,我们引入记忆来避免重新计算相同的数字。但是代码现在变得更加复杂。我们曾经想要这样写代码吗?只有在一定要有速度的时候,这个功能确实很快。
def fibK(n, dict = {}): # 1.19 seconds
if n < 18:
return [0,1,1,2,3,5,8,13,21,34,55,89,
144,233,377,610,987,1597,][n]
if (n%2) == 0:
k = n//2
if k not in dict:
dict[k] = fibK(k, dict)
A = dict[k]
if (k+1) not in dict:
dict[k+1] = fibK(k+1, dict)
B = dict[k+1]
return 2*A*B-A*A
else:
k = (n-1)//2
if (k+1) not in dict:
dict[k+1] = fibK(k+1, dict)
A = dict[k+1]
if k not in dict:
dict[k] = fibK(k, dict)
B = dict[k]
return A*A + B*B
题外话:请注意,在 Python 中,默认参数通常不应该像我上面所做的那样设置为空集(或空列表):dict = {}
。即使代码运行良好,第二次运行fibK
,程序没有结束也不会重置dict = {}
。因此,在第二次调用时不需要重新构建字典,这将使函数看起来比重复测试时更快。我已经不止一次被 Python 的这种特性所困扰。看看这段代码:
def doIt(dict ={}):
print(dict)
dict['A'] = 1
def main():
doIt() # output: {}
doIt() # output: {'A': 1}
这里有两种方法可以解决这个问题。
def doIt(Lst = None):
if Lst == None: Lst = []
Lst.append('x')
return Lst
def main():
print(doIt()) # output: main ['x']
print(doIt()) # output: main ['x']
def doIt(Lst = None):
Lst = Lst or []
return Lst
def main():
print(doIt()) # output: main ['x']
print(doIt()) # output: main ['x']
回想一下,Python or
和and
都返回最后检查的值。因此,如果Lst
= None
(=假),则计算机被迫检查[]
并返回[]
。为了保持简单,我保留了最初编写时的样子。题外话结束。
函数fibK
是这个列表中最复杂的函数之一。我们能清理一下吗?是的,通过返回两个值。不幸的是,这使得该函数更难使用。有两次,我把答案当作第二个值,而不是第一个值。函数fibL
看起来比fibK
更简单,也更快。附加 17 值查找表仅增加了大约 25%的速度。也许我应该尝试 100 值查找表。
def fibL(n): # 0.63 seconds [0.46 seconds with the look-up table.]
if n == 0:
return (0, 1)
## if n < 18: # Optional base case look-up table.
## return [(0,1),(1,1),(1,2),(2,3),(3,5),(5,8),(8,13),(13,21),(21,34),(34,55),
## (55,89),(89,144),(144,233),(233,377),(377,610),(610,987),(987,1597),
## (1597,2584),][n]
else:
a, b = fibL(n // 2) # a = fibL(2*k); b = fibL(2*k+1).
c = a*(2*b - a) # fibL(2*k ) = fibL(k)*(2*fibL(k+1) - fibL(k))
d = a*a + b*b # fibL(2*k+1) = fibL(k+1)**2 + fibL(k)**2
if (n%2) == 0:
return (c, d) # return fibL(k), fibL(k+1)
else:
return (d, c + d) # return fibL(k), fibL(k+1)
我们还没有讨论内存使用。所以我们要求每个函数计算第一千万个斐波那契数,这个数以 380546875 结尾,有 2089877 个数字。我们会大吃一惊的。功能fibK
现在比fibL
稍快。
fibA
= 949.76 秒(差不多 16 分钟)。- 不可能的
fibC
=超过了最大递归深度。fibD
=超过了最大递归深度。fibE
=超过了最大递归深度。fibF
=超过了最大递归深度。fibG
=溢出,结果太大。fibH
=超过了最大递归深度。fibI
= 24.09 秒fibJ
= 3.23 秒fibK
= 2.32 秒fibL
= 2.55 秒
总的来说,哪个功能最好?
- 很容易理解,但是对于大数字来说太慢了。
fibI
比其他人慢,但更容易理解。fibJ
比fibI
快 5 倍,但是使用了一些程序员无法推导的公式。fibK
最快,但很复杂。fibL
比fibK
短,几乎和fibK
一样快,但是返回了两个值,这两个值在测试代码的时候让我犯了两次错误。
像生活中的许多问题一样,哪个是最好的问题被证明是毫无意义的,因为我们没有一个单一的“最好”标准。
回想一下,算法及其作为函数的实例化,传统上通过三个标准来评估:
- 速度(“更好”是“足够好”的敌人你可能不需要超快的速度。)令人困惑的是,在一组数据中次好的函数有时在另一组数据中是最好的。
- 可读性(易于调试、修改和理解)。当然,有些函数无论怎么写都很难理解。
- 内存(内存猪不切实际)。
几年前,作为一名学生,我写了快速排序。我的代码对几乎所有的数字进行了排序,但是有几个没有排序。我用了一个“
我的观点是:评估一个算法不仅仅是前面提到的三个标准。算法的易理解性、翻译成计算机代码的难度以及在其他程序中使用代码的难度也是算法的重要属性。
我对技术的定义:硬件、软件和算法。
Footnotes 1
以防读者不知情,计算机科学领域的最高奖项是图灵奖。该奖项每年由 ACM(计算机协会)颁发,旨在表彰对计算机领域做出的持久而重要的技术贡献。图灵奖以早期计算机先驱艾伦·图灵(1912-1954)的名字命名。今天,图灵被认为是计算机科学和人工智能之父。1945 年,图灵因二战期间破译密码的努力被授予大英帝国勋章。1952 年,艾伦·图灵因与一名 19 岁的男性发生同性恋关系而被捕。为了不进监狱,他接受了一种荷尔蒙“治疗”,这种治疗对他的身体和精神都有不良影响。两年后,41 岁的他被发现死于氰化物中毒。验尸结果表明是自杀,但他的母亲和他的许多密友认为这是一场意外。参见维基百科。1966 年,图灵奖成立。1999 年,《时代》杂志宣布艾伦·图灵为 20 世纪 100 位最重要的人物之一。2013 年 12 月 24 日,图灵被英国女王追授特赦(这是二战以来第四次)。2014 年,好莱坞电影《模仿游戏》上映。它记录了图灵作为密码破译者的一生,以及他在生命末期遇到的困难。
2
答案:
3
答:7 张支票。这个古怪的购物者一定有一张 1 美元的支票。然后,如果下一张支票是 2 美元,他可以买任何高达 3 美元的东西。所以他的第三张支票应该是 4 美元。然后他可以买任何高达 7 美元的东西。所以他的第四张支票应该是 8 美元。你可以看到这个模式:1 美元,2 美元,4 美元,8 美元,16 美元,32 美元,64 美元。根据这个论点,任何正整数都可以表示为 2 的不同幂的和。所以 23 表示为和 1 + 2 + 4 + 16。
4
我的定义:证据是令人信服的论点。因此,证明可能是错误的。数学史上有几个这方面的著名案例。想到了 Kempe 发表的证明和 Tait 发表的四色定理证明。11 年来,每个人都没有受到质疑。此外,对一代人来说被接受为证据的东西有时对下一代人来说并不充分。"一天的严格已经足够了."——e·h·摩尔(1903)。
五、函数设计
我认为有效地使用函数是很棘手的,我将用下面的例子来说服你。
大多数情况下,您希望创建只执行一项任务的函数。大多数时候没有多函数。我曾经写过一个图形程序,读入一个图像文件,然后用彩色打印(一个函数)或者用灰度打印(另一个函数)。两个函数中的大部分代码是相同的,或者几乎相同。使用一个布尔参数(colorFlag
),灰色和彩色函数可以合并成一个函数。因此,我用四行额外代码的代价少了一个函数。见下文。
AN EXAMPLE OF MULTI-PURPOSE
CODE
WIDTH = 512
HEIGHT = 512
class ImageFrame:
def __init__(self, colors, wd = WIDTH, ht = HEIGHT, colorFlag= False):
self.img = PhotoImage(width = wd, height = ht)
for row in range(ht):
for col in range(wd):
num = colors[row*wd + col]
if colorFlag == True:
kolor ='#%02x%02x%02x' % (num[0], num[1], num[2]) # = color
else:
kolor ='#%02x%02x%02x' % (num, num, num)# = gray-scale
self.img.put(kolor, (col,row))
c = Canvas(root, width = wd, height = ht); c.pack()
c.create_image(0,0, image = self.img, anchor = NW)
printElapsedTime ('displayed image')
当我一年后回顾我的工作时,我必须阅读代码——不仅仅是函数的名字——才能理解colorFlag
做了什么。如果代码被保存为两个具有描述性名称ImageFrameForColorList
和ImageFrameForGrayScaleList
的函数,那么就没有colorFlag
可以理解。两个函数中的公共代码可以提取到第三个函数中,该函数可以由灰度函数和颜色函数调用。第三个函数的理由是公共代码中的任何更改只需要做一次(干:不要重复)。重复代码的危险在于,你可能在一个地方修改了代码,却没有意识到在另一个地方也需要修改。
这个例子很好地说明了内聚和耦合。将解决这两个相关任务的所有代码放在一个函数中增加了内聚性(通常是好的)。将它分散到两个或三个函数中会增加函数之间的耦合(通常是不好的)。那么哪种方案更好呢——单一函数、两种函数还是三种函数?我的感觉是,由于代码的简单性(至少对我来说),将所有内容放在一个函数中会使代码更容易理解和调试。通常,当我们遵循一个指导方针(最大化内聚性,从而最小化耦合性)时,我们会违反另一个原则(将函数限制到单一任务)。无论你做什么决定,都要意识到其中的问题。编程专家沃德·坎宁安完美地陈述了这一点:“如果你不仔细思考,你可能会认为编程只是用编程语言键入语句。” 1
一个函数应该有多长?程序员 Brian Kernigham 和 P.J. Plauger 曾经提到,他们的函数的中值大小是 15 行,平均值是 19 行。一个函数包含的行数似乎很少会超出屏幕的显示范围。我的文本编辑屏幕有 38 行,字体大小我喜欢。但是,当然,我们从不追求小;我们追求可读性。下面是我的 34 行代码,用来判断 n×n 数独板是否是一个解决方案。
def solutionIsCorrect(matrix):
#---Build lists of rows and columns.
rows = [[]] * MAX
cols = [[]] * MAX
for r in range(MAX):
for c in range(MAX):
rows[r].append(matrix[r][c].value)
cols[c].append(matrix[r][c].value)
#---Build list of blocks.
block = []
for n in range(MAX):
block.append([])
for n in range(MAX):
for r in range(blockHeight):
for c in range(blockWidth):
row = (n//blockWidth)*blockHeight+r
col = (n%blockHeight*blockWidth) +c
block[n].append(matrix[row][col].value)
#---Check all rows for all n digits.
for r in rows:
for n in range(1, MAX+1):
if {n,} not in r: # <--The type must be set({n}), not int (n).
return False
#---Check all columns for all n digits.
for c in cols:
for n in range(1, MAX+1):
if {n,} not in c:
return False
#---Check all blocks for all n digits
.
for b in block:
for n in range(1, MAX+1):
if {n,} not in b:
return False
return True # True means NO errors in the matrix
.
为什么不把小零件推到自己的函数里,从这个函数里调用呢?答案是这些部分很容易调试。没有太多的复杂性需要降低,所以我选择了内聚而不是耦合。注意注释被用作函数头。当多任务函数可以被分解成一组相关的简单的单任务部分时,这种方法非常有效。
为什么有人会费心去创建一行函数,而不是使用一行代码本身呢?答案是函数的名字比单行代码更容易理解。但是这一行代码最终不是必须要被理解吗?除非我们调试或修改特定的代码行。难道你不想遇到布尔表达式(在 Nelder-Mead 算法中)
if triangleHasNotConverged(count, A, B, C):
return
它引用了这个函数
def triangleHasNotConverged(count, A, B, C): # Boolean result
return (count < MAX_TRIANGLE_COUNT and
SMALLEST_TRIANGLE_SIZE < max(B.dist(C), A.dist(B), A.dist(C)))
而不是这条丑陋的线:
If (count < MAX_TRIANGLE_COUNT and
SMALLEST_TRIANGLE_SIZE < max(B.dist(C), A.dist(B), A.dist(C))):
return
我曾经写过函数makeComputerReply()
让一个游戏在屏幕上移动(在《奥赛罗》中)。这是一个很短的函数,只执行一项任务,或者我是这样认为的。但是这个函数实际做的是 1)计算应该移动的位置,2)调用另一个函数在一个内部矩阵中移动,然后 3)在屏幕上显示移动。因为 2)和 3)总是一起发生,也许它们可以被认为是一个任务。不过,这是两项任务,而不是三项。如果有人向我指出这一点,我会说分解函数会增加程序的复杂性,而不是降低它:内聚力超过耦合。函数调用需要从简单的
makeComputerReply()
到更复杂的
bestCol, bestRow, finalPieces = makeComputerReply()
makeMoveInMatrixAndOnScreen (bestCol, bestRow, finalPieces, COMPUTER)
后来我回到我的程序中,意识到计算计算机最佳一步棋的代码,只要稍加修改,也可以计算人类最佳一步棋的反击。因此,计算机可以预先考虑两层而不是一层。如果它可以做两层,那么它可以做四层,并做出一些深思熟虑的举动。所有这些都可以通过重新设计makeComputerReply()
函数来完成。
但正如我所说的,我试图修改的函数也将每一步棋插入到一个矩阵中,并在屏幕上显示出来。所以我不得不从函数中删除插入并打印代码,并把它放在对现在已重命名的bestResponse(player)
的调用下面。最初的设计降低了理解代码的复杂性,但是增加了修改代码的复杂性。以前,我不知道这样的情况是可能的。真是个惊喜。
现在假设你正在写一个需要 2D 和 3D 距离函数的程序。以下三种方法你会选择哪一种?
# METHOD 1 (two functions)
def distance2D(x,y):
assert len(x) == len(y) == 2
return sqrt( (x[0]-y[0])**2 + (x[1]-y[1])**2 )
def distance3D(x,y):
assert len(x) == len(y) == 3
return sqrt( (x[0]-y[0])**2 + (x[1]-y[1])**2 + (x[2]-y[2])**2)
#-------------------------------------------------------------
# METHOD 2 (one function with a for loop)
def distance(x,y):
assert len(x) == len(y) and len(x) in {2,3}
total = 0
for n in range(len(x)):
total += (x[n]-y[n])**2
return sqrt( total)
#-------------------------------------------------------------
# METHOD 3 (one function with a loop comprehension)
def distance(x,y):
assert len(x) == len(y) and len(x) in {2,3}
return sqrt(sum([(x[n]-y[n])**2 for n in range(len(x))]))
当一个函数可以工作时,为什么要编写两个函数呢?一个合理的回答是,两个函数名比单个函数名更具描述性。并且这两个函数比更强大的单个函数更容易调试。然而,因为距离的计算很简单,并且因为我习惯于列出 comps,所以我更喜欢Method 3
。顺便说一句,除非你知道你可能要扩展一个函数,否则不要把它通用化。即使你知道,你可能仍然喜欢让你的程序使用更简单的函数。
也就是说,我实际上认为可以通过展开for
循环来改进Method 3
,如下所示。这就给我们带来了另一个问题。在下面显示的四个错误信息中,你更希望哪一个完成Method 4
?
# METHOD 4 (one function with no loops)
def distance(x,y):
if len(x) == len(y) == 2:
return sqrt((x[0]-y[0])**2 + (x[1]-y[1])**2)
if len(x) == len(y) == 3:
return sqrt((x[0]-y[0])**2 + (x[1]-y[1])**2 + (x[2]-y[2])**2)
通过选择下面的错误陷阱来完成此函数。
#---Exit message A
exit('Error in distance function.')
#---Exit message B
assert(False), 'Error in distance function.'
#---Exit message C
msg = 'len(x) = '+ str(len(x)) + ' and len(y) = '+ str(len(y))
assert False, 'Error in distance function: ' + msg
#---Exit message D
msg = 'len(x) = '+ str(len(x)) + ' and len(y) = '+ str(len(y))
exit('Error in distance function: ' + msg)
我的答案在脚注里。 3
回想一下古老的字母拼图 SEND + MORE = MONEY, 4 其中每个字母代表一个不同的数字。唯一解是 9567 + 1085 = 10652。我曾经让一个班级写一个程序,它可以找到任何一种算法的所有解——例如,DOG * CAT = FIGHT 有 16 种解。我这样做是因为我想让学生们熟悉强大的 Python 命令eval
、maketrans
和translate
命令。我生成的代码(如下所示)让我大吃一惊。
# Teacher's solution
########################<BEGIN PROGRAM>########################
def createAlphametic():
from itertools import permutations
from re import findall # re stands for regular expressions.
puzzle = 'SEND + MORE == MONEY' # Notice we use '==', not '='.
puzzle = 'OOOH + FOOD == FIGHT' # 8886 + 1883 == 10769
print(' NOW ATTEMPTING TO FIND ALL\n SOLUTIONS FOR THIS ALPHAMETIC\n PUZZLE:', puzzle)
solutionFound = False
count = 0
words = findall('[A-Z]+', puzzle.upper()) # words = ['SEND', 'MORE', 'MONEY']
keys = set(''.join(words)) # keys = {'Y', 'S', 'R', 'M', 'O', 'N', 'E', 'D'}
if len(keys) > 10:
print('--- ERROR: The puzzle has MORE than ten letters.')
exit()
initialLetters = {word[0] for word in words} # Example: initialLetters = {'M', 'S'}
numberOfInitials = len(initialLetters)
keys = ''.join(initialLetters) + ''.join(keys - initialLetters) # Example: keys = 'MSEDONRY'
for values in permutations('1234567890', len(keys)):
values = ''.join(values) # Example: ('1', '2', '3', '4', '5', '6', '7', '8') becomes '12345678'
if '0' in values[0:numberOfInitials]: # No zeros are allowed in initial letters.
continue # If eval() finds a number beginning with zero, it will throw an exception.
# 'M': 3, 'S': 8, 'E': 5, ...}
table = str.maketrans(keys, values) # table = {77: 51, 83: 56, 69: 53, ...}
equation = puzzle.translate(table) # Example: equation = 8514 + 3275 == 32156
if eval(equation):
solutionFound = True
if count == 0:
print('------------------------------------')
print('All solutions are listed below:')
count += 1
print(count,'. ', equation, sep = '')
if not solutionFound:
print('No solutions exist.')
#-------------------------ALPHAMETICS-------------------------
def main():
createAlphametic()
#-------------------------ALPHAMETICS-------------------------
if __name__ == '__main__':
from time import clock; START_TIME = clock(); main(); print('\n+===<RUN TIME>===+');
print('| %5.2f'%(clock()-START_TIME), 'seconds |'); print('+================+')
#######################<END OF PROGRAM>#######################
为什么我没有使用逐步细化,把代码分解成单任务函数?例如,为什么不像这样分解它:
def main():
puzzle = createAlphametic()
solutionSet = solveAlphametic(puzzle)
printResults(solutionSet)
事实上,我就是这样开始编写作业的。然而,程序通常需要 30 秒或更长的时间来运行,我希望看到发现的结果,而不是在最后一次打印出来。这意味着我在 main 函数中只有两个调用。但是createAlphametic()
函数太简单了,与其他函数分开并没有增加多少清晰度。结果是,这个复杂的代码并没有因为被分解成几个小函数而变得更加易读。那为什么不把所有代码都塞到主函数里呢?我的策略是用一个描述性的名称来调用任何关键的代码块。主函数应该至少调用一个其他函数。我对这一政策的唯一例外是教学代码,它被设计用来说明语法。
这是另一个例外。当我设计一个玩具神经网络时,我编写了一个函数,它既创建训练数据,又创建随机权重值。(见下文。)这是两个任务。这些任务是如此的短、简单和相关,以至于把它们塞进同一个函数中是有意义的:再次强调,内聚性高于耦合性。
def createNetwork(iMax = 8, jMax = 3, kMax = 8):
#---Create the training data.
inputs = [[1,0,0,0,0,0,0,0,-1], [0,1,0,0,0,0,0,0,-1], [0,0,1,0,0,0,0,0,-1],
[0,0,0,1,0,0,0,0,-1], [0,0,0,0,1,0,0,0,-1],[0,0,0,0,0,1,0,0,-1],
[0,0,0,0,0,0,1,0,-1], [0,0,0,0,0,0,0,1,-1],]
#---Create the w and v weights.
w = [ [uniform(-2,2) for col in range(jMax)] for row in range(iMax+1)] # = 9 rows & 3 cols
v = [ [uniform(-2,2) for col in range(iMax)] for row in range(jMax+1)] # = 4 rows & 8 cols
return inputs, w, v, h
我的观点是:将一个函数限制在一个单一的任务中,并通过使用逐步细化来分解它的各个部分,这些规则非常重要,并且通常需要遵守。规则是人类的产物,并不完美。他们只是向导。一个经常被引用的关于编程的专家规则是“特例不会特殊到违反规则。”我不同意;不同的环境,不同的情况,需要不同的政策。
我第一次在一本哲学书上看到关于警惕规则的警告:“只要道德被认为是达到目的的手段,它就是有价值的;它是一个好仆人,但一个可怕的主人。” 5 生活或编程中没有绝对的规则吗?想了一辈子,还是不确定。考虑到这一点:在编写代码时,可读性是第一位的,如果优化是不必要的,如果时间不能更好地用在其他地方。这是绝对的规则吗?
Footnotes 1
见安德鲁·亨特和戴维·托马斯,《实用主义程序员》(艾迪森·卫斯理,2000 年),第十三页。
2
Pascal 中的软件工具(Addison-Wesley,1981),第 189 页。
3
我的选择是 b。退出消息 C 和 D 花费了太多的时间和代码来处理一个我认为很少会发生的错误。消息 A 简单地退出程序,并显示一条错误消息。消息 B 的作用与 A 相同,但也会将光标放在函数的断言行上。
4
alphametic 是由 Henry Dudeney 发明的,并在 1924 年 7 月的英国 Strand 杂志上首次发表。
5
艾伦·瓦特,《禅宗的精神》(格罗夫出版社,1958),第 61 页。
六、自我记录代码
- 我感到不忠诚,但毫不畏惧地诚实地说,大多数科学家不知道如何写作,因为风格确实背叛了 l ' homme même[这个人自己],他们写作好像他们讨厌写作,最重要的是想与它断绝关系。——彼得·b·梅德瓦尔爵士(诺贝尔奖获得者),《给年轻科学家的忠告》(哈珀&罗出版社,1979 年),第 63 页。
- 值得注意的是,几乎所有的科学家,在他们从数学或化学语言转向英语的时候,似乎都觉得不再有任何精确术语的义务了。——罗伯特·格雷夫斯和艾伦·霍奇,《英语的使用和滥用》(Paragon,1970),第 227 页。
- 很少有人意识到自己写得有多差。——威廉·津瑟,《论写作》,第 5 版。(哈珀,1994),第 19 页。
- 所有好的写作都是自学的。事实是,想成为作家的人必须通过书籍或评论家来自学。——雅克·巴尔赞,《简单而直接》(哈珀&罗出版社,1975 年),第 3 页。[2003 年,雅克·巴尔赞教授(哥伦比亚大学)因其有影响力的著作获得总统自由勋章。]
上述引文的要点是清楚的交流是困难的。如果我们把语言从英语改为计算机语言,难度是否会降低到任何尝试的人都会做得很好的程度?我不这么认为,我的证据是我们可以在互联网和一些计算机书籍中找到的命名不当、缩写过度、结构笨拙的代码。
计算机代码可读性的关键是自文档化代码,即通过精心构建(与相关任务的结合、单个任务的耦合)和选择标识符(函数和数据的描述性名称)来揭示其意图的代码。
作为一般的约定,类和变量名应该是名词或名词短语,函数名应该是动作动词或动宾短语。我有时将函数命名为描述返回项的名词,例如,result
(代表井字游戏的赢、输或平)、symbol
(代表返回的字符)。有人建议所有布尔函数都要以 is 开头。由此可见,allVowels
应该是isAllVowels
。最初,我并不认为这个建议有多好,但后来我注意到它实际上使我的一些代码读起来像英语句子。所以现在我遵循这个建议。我的建议是避免使用笑话名称,可爱的词,和攻击性的词。我总是找到 foo、bar、baz 和 spam 这样的名字来使例子不那么清楚。他们的使用似乎是在用一个圈内人的笑话炫耀。 1 我更喜欢通用的函数名doIt
(动宾)。
当然,在开始时创建描述性名称是困难的,因为您更关注的是让代码工作,而函数任务仍在被修改。也许一个很好的例子是一组没有告诉我们任何东西的标识符:
def process(argument, parameter, data, whatIsIt):
...
something = action(value)
entity = call(variable)
stuff = phunction(identifier)
...
那些容易写的变量bug
、cat
、cow
、dog
、fly
、fox
、hen
、hog
、pig
、rat
,甚至it
呢?(学生守则里的thingy
、stringy
、淫秽用语我都见过。)你还能想到更难听的名字吗?是的,这很简单:不能发音的标识符,比如l01O
、oO0Oo
和一串下划线:____
。也就是说,单下划线(_
)作为变量实际上至少有两种用途。考虑打印列表中数字总和的目标:
Lst = [('A', 1), ('B',2), ('C', 3), ('D',4),]
下面是两种方法。哪个更好?
#---Method 1
total = 0
for (ch,num) in Lst:
total += num
print('total =', total) # output: total = 10
#---Method 2
print('total =', sum([num for (_,num) in Lst]))
# output: total = 10
请注意,下划线在第二个方法中被用作一次性变量。如果这是您第一次看到它,这似乎是一个很差的标识符选择,但是我已经多次在商业代码中看到它的使用。它告诉读者这是一个占位符变量——也就是说,我们必须拥有它,但我们从未使用过它。
我觉得Method 1
更具可读性,但我还是推荐Method 2
。为什么呢?因为Method 2
更蟒,更专业。我们需要以专业人士喜欢的方式来阅读代码,比如在这种情况下使用列表理解和下划线作为虚拟变量。
下面是下划线的另一种用法:
_ = 0 # <-- The underscore is the constant 0.
# Easy to read
M = [[3, _, 4, _, _, 6,],
[_, 7, _, _, _, _,],
[_, _, _, 9, _, _,],
[_, _, 5, _, _, _,],
[2, _, _, _, 1, _,],]
# Less easy to read.
M = [[3, 0, 4, 0, 0, 6,],
[0, 7, 0, 0, 0, 0,],
[0, 0, 0, 9, 0, 0,],
[0, 0, 5, 0, 0, 0,],
[2, 0, 0, 0, 1, 0,],]
在我读到的某处,我们应该避免类似的名字,比如str1
和str2
,因为很容易把一个打成另一个,名字之间的差异没有意义。在我看来,这个想法在小范围内并不成立。
现在做一个小实验。我写了一些代码,其中我需要在 0 和 1 之间选择两个随机数,第一个数小于或等于第二个数。他们的名字我想到了四个选择:(randomNum1
、randomNum2
)、(r1
、r2
)、(x
、y
)、(a
、b
)。您希望调试下面的哪个代码段?
Version 1
for n in range(totalRuns):
randomNum1, randomNum2 = random(), random()
if randomNum1 > randomNum2:
randomNum1, randomNum2 = randomNum2, randomNum1
if (randomNum1 > 0.5 or randomNum2-randomNum1 > 0.5
or randomNum2 < 0.5):
noTriangleCount += 1
else:
triangleCount += 1
Version 2
for n in range(totalRuns):
r1, r2 = random(), random()
if r1 > r2:
r1, r2 = r2, r1
if (r1 > 0.5 or r2-r1 > 0.5 or r2 < 0.5):
noTriangleCount += 1
else:
triangleCount += 1
Version 3.
for n in range(totalRuns):
x, y = random(), random()
if x > y:
x, y = y, x
if (x > 0.5 or y-x > 0.5 or y < 0.5):
noTriangleCount += 1
else:
triangleCount += 1
Version 4.
for n in range(totalRuns):
a, b = random(), random()
if a > b:
a, b = b, a
if (a > 0.5 or b-a > 0.5 or b < 0.5):
noTriangleCount += 1
else:
triangleCount += 1
我选择了版本 4 ( a
和b
),因为单字母标识符最容易阅读,a
和b
有心理顺序(a
< b
)。x
和y
也是如此,但它们也有y
是x
的函数的历史(不在这里)。我知道的另一个常见的对是p
和q
,它们用于指针或列表中的位置。以下两个函数执行相同的任务:它们展平列表,例如,它们都将翻转
[0, [1, [2, 3, [4, 5]], 6, [7]], [8, 9]]
到…里面
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
那么,下面哪个函数更具可读性:第一个使用描述性的newLst
,还是第二个使用模糊性的y
?
def flatten(Lst): # Recursive
newLst = []
for x in Lst:
if type(x) == list:
newLst.extend(flatten(x))
else:
newLst.append(x)
return newLst
def flatten(Lst): # Recursive
y = []
for x in Lst:
if type(x) == list:
y.extend(flatten(x))
else:
y.append(x)
return y
同样,我认为y.append(x)
比newLst.append(x),
更容易理解,尽管newLst
比y
更具描述性。一个在孤立状态下可读性更强的变量在代码中的可读性怎么会更差呢?嗯,通常它不能,但是这个代码足够简单,名字newLst
中的信息是不需要的。有帮助的是,我们期望y
是x
的函数,这正是这里的情况。当我们有简单代码的小片段时,单个字母的变量比多单词的描述性变量更具可读性。一般规则是范围越大,标识符越长。这是一个普遍的规则,而不是一个绝对的,教条式的法律。
命名变量的最大陷阱是没有使它们足够具有描述性。第二大陷阱是过度缩写他们的名字。也就是说,短标识符和单字母标识符对于循环索引和短范围的临时变量是可以接受的。甚至这些小的孩子也可以描述。当然,千万不要用o
2 或者O
(两者看起来都像零:0
),避免用字母l
(看起来像一:1
)。以下是一些描述性的单字母标识符。
b
为布尔型(bool
为内置)c
和也许k
为常数(也许const1
和const2
更好)f
用于功能,不用于标志(使用flag
用于标志)g
用于功能(使用f
后)h
为启发式功能i
和j
,可能还有k
用于循环索引 3 (有时可能还有n
、num
或indx
)p
为位置或指针Q
进行排队(但是为什么不用queue
,甚至que
?)
r
为 random(也许rand
更好),而不是模块名random
。t
为 total(或者tot
,甚至total
,而不是内置函数sum
)。也许用t
表示时间,或者用tictoc
表示时间,但不要用模块名time
。M
为矩阵(也许matrix
更好)(r,c)
行和列(也许row
和col
更好)(x,y,
和也许z)
为坐标(a,b)
对于第一和第二值x[n]
、y[n]
、z[n]
为数组,但arrayX
、arrayY
、arrayZ
可能更好ch
和kh
为字符等。
我尽量避免以下情况:
d
为距离(dist
更好)m
为最大值(big
或maximum
更好,但不是内置的max
p
为概率(prob
更好)- 弦用
s
或s1
(stng
和str1
更好)
甚至像args
、other
、data
、info
、collection
和result
这样的中性标识符在一个短范围内也是可以接受的,它们的含义要么是显而易见的,要么在一个行内注释中解释。举个例子,
data = ['-',0,0,0,0,0,0,0,0,0,] # Distances to goal node from nodes 1-9.
在下面的代码中,我缩短了一个标识符,使代码更易读。
原始版本:
def fb(node):
if node == 9: return 0
shortestDistanceFromNodeToGoal =
min([dist + fb(neighbor) for (dist, neighbor) in graph[node]])
return shortestDistanceFromNodeToGoal
改进版本:
def fb(node):
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
尽可能遵循数学符号。在数学书上,我们这样写线性向量方程:或者这样 Y = m X + b, 4 不这样(除非我们别无选择):
outputVector = matrix*inputVector + auxiliaryVector.
我们这样做是因为前两个表达式比第三个表达式可读性更好。数学常数/变量/参数的命名规则不同于程序变量/函数/模块/库/文件/目录的命名规则。编写数学表达式时,尽量遵循数学惯例。
我看到一个程序作者使用标识符start
和end
来表示列表中的两个位置。这很清楚,但我更喜欢英语习语——例如,begin
和end
,或者start
和stop
,或者first
和last
,甚至left
和right
。他还用piv
做pivot
。为什么不把它说出来?
假设我们有一个男性身高的列表。列表的合理标识符是mensHeights
。但是当我们只选择一个元素时,我们必须用mensHeights[n]
来表示一个人的身高。标识符对于列表来说很好,但是对于列表中的元素就不那么好了。没有一种语言是完美的。那么,我们应该选择哪个标识符呢?比起mansHeight.
,我更喜欢mensHeights
下面一行 Python 代码导致了一个错误(也称为引发、抛出和生成异常),其中A
是一个 Vector 对象:
print(A*2)
在学生的 Vector 类中找到的 Python 方法中存在错误:
def __rmu1__(self, entity):
return self*entity
我找不到错误,因为代码实际上是正确的。那么是什么导致了这个错误呢?以下是更正后的代码:
def __rmul__(self, entity):
return self*entity
纠正后的代码看起来和坏代码一模一样吗?这是因为错误几乎是看不出来的。rmu
1
看起来几乎像rmu
1
。这个学生已经输入了小写字母“l
”的数字“1
”。那么,程序员的字体是什么样子的呢?它是等宽的(对垂直对齐很有用),使不同的字母看起来不同——例如,数字105
看起来不像字母lOS
。顺便说一句,这个例子一直留在我的脑海里,因为我发现它的痛苦。
哪个是最好的函数名:
createMatrix(),6
createPopulation(),
createPopulationMatrix(),
popMat(), or
coffee()?
我们很少对变量的数据类型感兴趣。所以我更喜欢createPopulation()
而不是createMatrix().
7createPopulationMatrix()
似乎没必要那么长。较短的popMat()
对我的口味来说太缩写了。为什么有人会给一个函数命名为coffee
?有些人很难想到描述性的名字。孤立的程序员知道他自己的变量意味着什么,那么为什么不选择一个名字,或者至少是一个快速选择的合理的名字呢?对我来说,主要的实际原因是我经常对大型复杂程序失去控制。程序错综复杂,以至于我开始忘记上周我做了什么和怎么做的。为了理解我自己的工作,我被迫以更易读的风格重新编写程序。作为一名 C.S .老师,我希望我的代码能被别人理解,而不是被它吓倒。
- 人们有时会问我在一个方法中寻找什么样的长度。对我来说,长度不是问题。关键是方法名和方法体之间的语义距离。——马丁·福勒,《重构》(艾迪森·韦斯利,1999),第 77 页。
将数据类型作为标签添加到变量或常数的名称前称为匈牙利表示法。偶尔有一些正当理由,但不经常在学校的问题。
当然,功能之间至少应该用一个空行隔开。你应该在函数之间放一行破折号或星号吗?除了我,我不知道谁会这么做。在屏幕上,大多数程序员不认为这是值得的麻烦,但在纸上(没有颜色),分行有助于阅读代码的讲义。
下面哪个例子更好(对齐或不规则间隔的等号)?
version 1:
bestX = x
bestY = y
bestDirection = f(x,y)
step = 2*pi/64 # = 64 directions
radius = 0.01 # = the distance of the step.
------------------------------------------------------
version 2:
bestX = x
bestY = y
bestDirection = f(x,y)
step = 2*pi/64 # = 64 directions
radius = 0.01 # = the distance of the step.
答:两者都可以接受,因为它们都是可读的。【注意:Python PEP 0008 风格指南不鼓励版本 1。]有些人看不到让代码看起来更有吸引力的好处(版本 1)。事实上,他们为别人在这件事上的大惊小怪而烦恼。垂直对齐确实需要更多的时间来设置和维护。然而,其他人却为缺乏视觉组织而烦恼。所以,我认为这是一种个人风格。顺便提一下,我记得有两个伟大的数学系主任,他们的办公室总是一团糟(缺乏视觉组织)。这没关系,因为他们的工作效率很高。
在一些语言中,程序员可以选择使用作为命名参数接收的命名参数(也称为作为关键字参数接收的关键字参数)。在 Python 中,如果接收的参数集以星号开头,那么关键字参数是必需的。 8 下面是两个例子。[注意:您传递参数(也称为实际参数)和接收参数(也称为形式参数)。]
def fn(*,a,b,c,d):
print(a,b,c,d)
#---------------------
fn(a=1, b=2, d=3, c=4) # output:1 2 4 3
fn(a=1, b=2, d=3, 4) # output:ERROR (missing keyword)
这是个好主意吗?对于很长的参数列表,或者读者需要额外帮助的地方,额外的努力是有意义的。使用命名参数也可以节省空间。而不是这个:
def createArray(arraySize):
array = []
...
def main():
arraySize = 100
array = createArray(arraySize)
少写一行:
def createArray(arraySize):
array = []
...
def main():
array = createArray(arraySize = 100)
如果我们只通过 100,我们就失去了描述符。
如果有许多参数,请使用命名参数和垂直对齐。下面是 O'Reilly Python 编程书第四版中的一行代码:
threadtools.startThread(
action = self.cache.deleteMessages,
args = (msgnumlist,),
context = (popup,),
onExit = self.onDeleteExit,
onFail = self.onDeleteFail,
onProgress = self.onDeleteProgress)
请注意命名参数的使用、垂直对齐和参数的堆叠。我从未在函数头中堆叠过参数,因为我从未见过如此冗长的函数。然而,对于冗长的参数集,堆叠似乎是一个好主意。
以下三个例子哪个可读性最强?
Method 1.
netSalary = (jobIncome + hobbyIncome + stockDividends + \
(rents - utilities) - personalLivingExpenses - \
mortgagePayments - medicalExpenses)
print(netSalary)
Method 2.
netSalary = (jobIncome +
hobbyIncome +
stockDividends +
(rents - utilities) -
personalLivingExpenses -
mortgagePayments -
medicalExpenses)
print(netSalary)
Method 3.
netSalary = (jobIncome
+ hobbyIncome
+ stockDividends
+ (rents - utilities)
- personalLivingExpenses
- mortgagePayments
- medicalExpenses)
print(netSalary)
我倾向于方法 3。在文本中,大多数数学书在运算符后换行。在代码中,有时在运算符之前中断会更好。
使用外部文档。我的意思是在你的程序的顶部,在一个整洁的盒子里,放置一些下面的信息:
- *程序的标题
- *程序描述,可能还有一些程序要求
- *你的名字
- *文件上交的日期(包括年份)
- *课程名称,课时/节
- 编程语言
- 导入的包、模块和库,尤其是图形
- 使用的关键算法
- 计划中实施的策略或设计
- 外部文件
下面是我自己代码中的一个例子:
"""+===============+=====-========*========-======+===========+
|| CIRCLE DETECTION ||
|| by M. Stueben (October 8, 2017) ||
|| Artificial Intelligence; Mr. Stueben, || || Periods 1, 2, and 7 ||
|| ||
|| Description: This program detects a circle (radius || || and center) in a 512x512 gray-scale ||
|| image of a circle and 500 random points || || (aka snow, noise). ||
|| It then draws a new circle in red over the || || initial circle. The circles almost match. ||
|| Algorithms: Gaussian smoothing, Sobel operator/filter, || || Canny edge detection, and a vote accumulator- || || matrix equal to the size of the image. ||
|| Downloads: None ||
|| Language: Python Ver. 3.3 ||
|| Graphics: Tkinter Graphics ||
+==========================================================+
"""
接下来是一个让一些人抓狂的话题:小编程约定。下面哪个表达式可读性最强?
ANN = inputs,w,h,v
ANN = inputs, w, h, v
我稍微倾向于第二种,除非有一种商店风格需要每个人都遵守。你应该写吗
y = 2 * (x + y)
或者
y=2*(x+y)?
有人建议的是
y = 2*(x + y).
为什么?可能是因为教科书上的乘法往往隐含着:2a,而不是 2×a. 9 因此,我们在“+”和“─”周围放置空格,而不是“*”周围。写你认为最清楚的。
下面是一个给定顶点来确定三角形面积的函数。我只在一个操作员周围放置了空格,而没有在其他七个操作员周围放置空格。参数对也由三个空格分隔。
def triangleArea (x1,y1, x2,y2, x3,y3): # vertices
return abs((x1-x3)*(y2-y3) - (x2-x3)*(y1-y3))/2
Python PEP 0008 风格指南建议通常用空格包围赋值和关系:x = 5
,而不是x=5
。但是不允许为指定的变量/参数赋值留空格,例如doIt
(a=1, b=2)
。它还建议函数名: print(x)
后面不要有空格,而不是print (x)
。我努力遵守这些规则,但偶尔也会出错。精彩的 VIM 代码编辑器将标记不遵循 PEP 0008 指南的代码。
我们应该把每一个陈述放在它自己的行上吗?或者这太武断了?摘自一本 1981 年出版的计算机科学旧书 11 :“连续的命令可以写在同一行上,只要它们在逻辑上属于一起。”一如既往,问题是可读性。以下所有方法都可以,因为它们都是可读的。
#--Method 1 (acceptable, but discouraged in Python)
a = 1; b = 2; c = 3; d = 4
#--Method 2 (common in Python)
a, b, c, d = 1, 2, 3, 4
#--Method 3 (bulky, but this is the most readable)
a = 1
b = 2
c = 3
d = 4
根据 PEP 0008,冒号后面不应该有任何内容。换句话说,这是大多数代码读者应该期待的:
if a == b:
doIt(c)
#----------------------
if a == b:
doIt(c)
else:
runIt(c)
#----------------------
for i in range(5):
print(i)
但是如果你在互联网上查看代码,你会发现以下内容。
if a == b: doIt(c)
else: runIt(c)
for n in range(5): doIt(n)
while type(x) == int: (p, x) = (x, array[x])
来自初级 Python 教科书:
def fib(num):
return 1 if num < 3 else fib(num-1) + fib(num-2)
可以,前面几项都是可读的。只是它们出乎意料,让一些编程人员觉得很丑。也就是说,我们都期望在 Python 中使用的 list comprehensions 正是以这种所谓的丑陋方式编写的。
print( [x*x for x in range(5)]) # = [0, 1, 4, 9, 16]
print( [x*x for x in range(5) if x%2 == 0]) # = [0, 4, 16]
print( [x*x if x%2 == 0 else -1 for x in range(5)] ) # = [0, -1, 4, -1, 16]
一般来说,理解列表比循环要快。然而,用一个for
循环(用一个if-else-if-else
)替换一个列表理解(用一个if-else-if-else
)实际上会使代码变慢。那对我来说是一个惊喜。
以下是可读性极强的代码,它打破了冒号规则,甚至将多个语句放在同一行上:
for x in dataSet:
if -10 <= x < 0: print('Case I'); continue
if 0 <= x < 10: print('Case II'); continue
if 10 <= x < 20: print('Case III'); continue
print(x)
因为它使用垂直对齐达到如此好的效果,我不认为这个代码块可以变得更可读。
坦白:我有时会用单行形式(if a == b:
doIt
(c)
),但从来不用else
。一位受人尊敬的 Python 作者建议,函数、循环和 if 语句都使用单行体,在一行中编写是可以接受的。我不喜欢看到这样的代码,但它是可读的。
下面是一个有争议的例子。两个版本都使用遗传交叉方法,从两个父母的染色体(这里是字符串)生成两个遗传上新的孩子。哪个可读性更强?
Version 1:
def produceTwoChildren(parent1, parent2):
r = randint (0, MAX)
child1 = parent1[0:r] + parent2[r:MAX]
child2 = parent2[0:r] + parent1[r:MAX]
return (child1, child2)
Version 2:
def produceTwoChildren(parent1, parent2):
r = randint (0, MAX)
return (parent1[0:r] + parent2[r:MAX], parent2[0:r] + parent1[r:MAX])
Version 1
可读性更好,因为它使用垂直对齐进行计算,包含描述性标识符child1
和child2
,并将两个计算放在不同的行上,这使得它们更容易理解。
Version 2
可读性更好,因为它更短,代码非常简单,我们不需要把它分解,不需要垂直对齐,也不需要描述性的名字。
我更喜欢Version 1
,但无法反驳更喜欢Version 2
的理由。话虽如此,我们再来看同一个问题。直棍的长度是一个单位。棍子上随机做了两个记号。这些标记彼此相差不超过十分之一个单位的概率是多少?通过模拟求解,最大运行次数= 10000000 次。
from random import random
max = 10000000
#---Method 1 (one line, broken into two lines)
print ('Answer1 =', round(sum([abs(random()-random()) <= 0.1
for n in range (max)])/max, 2))
#---Method 2 (five lines)
total = 0
for n in range (max):
total += abs(random()-random()) <= 0.1
answer = round(total/max, 2)
print ('Answer2 =', answer)
我几乎可以像理解Method 2\. Method 1
中的代码一样容易地理解Method 1
中的代码,并且具有只有一个逻辑行长的优点。然而Method 2
是首选,因为它更容易调试。在写Method 1
的时候,我不小心把2
放在了最后一个括号旁边。没有产生编译器错误,代码看起来是正确的。输出是0 2
,而不是正确的0.19
。
那么,对于所有这些例子,我们能说些什么呢?第一,永远不要和店铺风格决裂。如果没有商店风格,如果你打破了 PEP 0008 或其他编程惯例,至少要有这样做的理由。如果其他人不遵循你的小编程惯例,不要开始一场宗教争论。
Footnotes 1
这些占位符在技术上被称为“元同步变量”参见维基百科。foo 和 bar 这两个术语来历不明,但可能与军事俚语 Fubar 有关,“弄得面目全非”垃圾邮件一词(可能是 1937 年引入的“五香火腿”)是指 YouTube 上的一部蒙蒂 Python 喜剧小品(“蒙蒂 Python 垃圾邮件”)。读者可能已经知道,“Python”这个名字的选择是参考了由六名成员组成的英国喜剧团体蒙蒂·Python 的飞行马戏团(从 1969 年到 1974 年有 45 集电视节目,还有五部电影,最后一部是在 1983 年)。这个群体的幽默以不同的方式打动不同的人。当我向我的学生展示 YouTube 上的“Monty Python 辩论诊所”时,一些人认为这很好笑,而另一些人显然很无聊。
2
我有一本 C++教材,作者用o
输出。output
不是更好吗?
3
indexeses 和 index 都是同样可接受的复数,但 indexes 更适合于数学和技术用途。for
循环i
大概代表 index,不是 integer。
4
在线性方程 y = mx + b 中,m 可以被认为代表“矩阵”标量可以被认为是 1×1 矩阵。
5
一个很好的编程类型是 Vera Sans Mono。在网上查一下。
6
camel case(aka cap words aka studly caps)符号比 under_score (aka snake case)符号更容易编写,under _ score(aka snake case)符号更容易阅读—例如,
def extractxandycoordinates from chrome(行):
def extract _ X _ and _ Y _ Coordinates _ From _ chronous(行):
这两种风格对于编程都是可以接受的。
7
对名称的研究,尤其是在技术领域,被称为专名学。
8
保留字即关键字(一个字)不能用作标识符,例如,for = 3
会导致编译器错误,因为编译器认为for
是循环的开始。然而,关键字变量和关键字参数只是函数调用中的命名标识符。
9
回想一下,在解释算术数学表达式时,乘法和除法具有同等的优先地位,也就是说,您按照它们出现的顺序执行这两种运算:8/2×4 = 16。现在,转到代数,设 a = 4。近世代数书有 8/2a = 1。所以我们看到,在代数中,隐式乘法(隐式分组)的优先级不同于显式乘法。在编程中,隐式乘法(通常)是不可能的。但是,在我的可编程 TI-84 计算器上是可能的,它将两个表达式都解释为 16。
10
为什么会这样?行列式是由位置向量〈x 1 ,y 1 〉和〈x 2 ,y 2 〉.组成的平行四边形的面积(可能为负)这很容易用几何图证明。现在就做。(位置向量的初始点在原点。)从点(x3,y3)到点(x1,y1)的向量是位置向量
。从点(x3,y3)到点(x2,y2)的矢量是位置矢量
。所以以这两个向量为边的三角形的面积一定是
。我在布赖恩·海斯的一篇文章中发现了这个计算方法,这篇文章发表在安迪·奥兰姆和格雷格·威尔逊的《美丽密码》(O'Reilly,2007)中。作者试图确定三个点是否共线(如果它们作为顶点形成的三角形的面积为零)。
11
大卫·格里斯,《编程的科学》(施普林格出版社,1981),第 276 页。
七、逐步细化
- 只有故事才是真正可读的。——鲁道夫·弗莱施,《可读写作的艺术》(科利尔·麦克米伦出版社,1949),第 74 页。
自我记录代码的一种方法是使用自顶向下的设计,这是一种结构化编程的形式,也称为逐步细化。 1 在这种风格下,2main 函数包含带有描述性英文名称的函数调用——例如enterData()
、computeData()
、printData()
。函数调用将形成代码所做工作的轮廓。他们讲述一个故事。
当您跟踪这些调用中的一个时,您可能会再次得到子调用的概要,这些子调用描述了子函数的作用。例如,computeData()
可能会把我们引向calculateDistances()
和FindSmallestDistance()
。当然,这不可能永远持续下去,最终读者必须遇到真正的计算机指令。我们的目标是选择描述性很强的函数名,这样读者就可以很容易地理解程序的设计,而不需要阅读太多的计算机代码或注释。这是另一个例子:
def main():
matrix = createSudoku()
matrix = solveTheSudoku(matrix)
printVerification(matrix)
root.mainloop() # Required for Tk graphics.
相比之下,自底向上的设计是一种意识流编程,也称为牛仔编程,也称为凭感觉编程——也就是说,我们编程程序的下一部分,而大画面模糊地保存在我们的脑海中。这种风格适用于小程序。
一个程序是自下而上、自上而下还是两者的混合都没关系。目标是程序自顶向下阅读。这允许在不同的级别(森林级别和树级别)上进行程序验证,并使审阅者更容易阅读程序,审阅者可能是三个月后的作者。
下面是我在 Python 中自顶向下(逐步细化)的主要函数之一:
如果仔细观察,您会注意到大多数行都接受前一行的输出。
def main():
image = list(readPixelColorsFromImageFile\
(IMAGE_FILE_NAME = 'e:\\lena_rgb_p3.ppm'))
displayImageInWindow(image, False)
saveTheImageGrayScaleNumbersToFile\
(image, GRAY_SCALE_NUMBERS_FILE_NAME = 'e:\\grayScale.ppm')
image = extractTheImageGrayScaleNumbersFromFile\
(GRAY_SCALE_NUMBERS_FILE_NAME = 'e:\\grayScale.ppm')
displayImageInWindow(image, False)
image = smoothTheImage\
(image, NUMBER_OF_TIMES_TO_SMOOTH_IMAGE = 4)
saveTheImageGrayScaleNumbersToFile\
(image, GRAY_SCALE_NUMBERS_FILE_NAME = 'e:\\smoothed.ppm')
image = extractTheImageGrayScaleNumbersFromFile\
(GRAY_SCALE_NUMBERS_FILE_NAME = 'e:\\smoothed.ppm')
displayImageInWindow(image, False)
image = sobelTransformation(image) # image = [...(mag, angle)...]
sobelMagnitudes = normalize([x[0] for x in image])
displayImageInWindow(sobelMagnitudes, False)
imageWithGrayValuesTransformedToLists = cannyTransform(image)
image = doubleThresholdImageListsInToGrayScaleValues\
(imageWithGrayValuesTransformedToLists)
displayImageInWindow(image, True)
root.mainloop()
仅仅几个星期后,当我打开程序检查一些细节时,我才怀疑主函数太大了。以下是我的改写:
def main():
imageFileName = 'g:\\lena_rgb_p3.ppm'
grayScaleNumbersFileName = convertColorFileToGrayScaleFile(imageFileName)
smoothedFileName = extractSmoothAndSaveImage(grayScaleNumbersFileName)
imageLists = sobelTransformSmoothedImage(smoothedFileName)
printNormalizedImageLists(imageLists)
imageLists = cannyTransform(imageLists)
image = doubleThresholdImageListsInToGrayScaleValues(imageLists)
displayImageInWindow(image)
root.mainloop()
这个版本可读性更强,因为它更短。能不能写的更清楚一点?也许吧,但这是我两次尝试后最大的努力。
一种重要的编程风格被称为增量式(也称为迭代式,也称为进化式)开发。在这种风格中,程序员首先只编写一小部分需求的程序(一个“行走的骨架”)。一旦这样做了,一组新的需求就增加了。然后,当改进的程序工作时,添加另一组需求,等等。在开发过程中,可能需要多次进行设计重组。有时,进化的方法被称为莫斯科方法:必须拥有、应该拥有、能够拥有和不想拥有,但是想要拥有。有时它被称为时间拳击。
这种方法有很多优点。一个工作的——不可否认是不完整的——程序总是会完成的。这给了程序员心理上的刺激。与大型项目的典型情况相比,项目结束时的压力和不确定性要小得多。由于早期的用户反馈,图形布局、界面和用户指示往往会变得更好。程序的早期版本成为指导最终设计的原型。这是最好的编程方式吗?可能对于有很多功能的程序来说,但是大多数学校的程序只是开发算法。
Footnotes 1
我把结构化编程等同于过程化编程(用函数、过程、子例程和方法编程),以逐步求精为目标。结构化代码的对立面是意大利面条式代码。
2
"风格是选择的艺术。"——温斯顿·韦瑟斯,“该系列的修辞”,见于格伦·a·洛夫和迈克尔·佩恩的《当代风格论》(斯科特·福尔斯曼,1969),第 21 页。
八、注释
小心使用注释。下面的 5 行布尔函数是我对 13 行代码的修改,增加了 9 行注释。更长的版本是一个互联网教师的例子,指示对几乎每一行代码进行注释(即使对初学者来说,这也是一个糟糕的想法)。自文档更好。
def isAllVowels(stng):
for ch in stng.lower():
if ch not in ['a', 'e', 'i', 'o', 'u']:
return False
return True
自文档化代码消除了许多注释的需要。但是我们仍然需要注释,原因如下:
- 为了显示组织(将代码分解成案例),
- 给出洞见——也就是说,让细微的观察变得清晰,
- 陈述一些假设,特别是前置条件、后置条件、不变量和边界限制,或者
- 举个例子(写类的时候有用)。
下面的代码打印出一个有八个皇后的棋盘。第一行显示了示例注释的好处。
def printBoard(board): # Example: board = [3,5,7,2,0,6,4,1]
print("###################")
for col in board:
s = ['- '] * len(board) # build a list of strings with no 'Q '
s[col] = 'Q ' # insert 'Q 's in the correct places
print('# ' + ''.join(s) + "#") # make the list into one string.
print("###################")
注释应该像人教版 0008 建议的那样用完整的句子写吗?是的,如果你可以的话,但是注释是可读的。注释应该像printBoard()
例子中那样写在行内吗?一些专家说不。但我更喜欢这样做简短的注释。这就是 110 字符行长度的优势,不是针对长代码行,而是针对代码后面偶尔出现的行内注释。此外,如上所示,排列注释使代码更容易阅读。
注释应该告诉你为什么(如果代码不清楚),而不是如何。您不需要解释如何做,甚至不需要解释什么,因为这已经在代码中完成了。如果你写了一个关于“如何”的注释,而“如何”被改变了,那么这个注释也需要被改变。但是,仅仅通过代码往往不能理解的是我们为什么要这样做?
你如何看待这两个关于 vector 类中一个方法的注释?
def dist(self, other): # Return the distance between two points (position vectors).
return (self-other).mag() # Vector.dist(A,B) and A.dist(B) both work
"Return the distance ..."
注释有必要吗?我也这么认为类,尤其是复杂的类,应该像手册一样被记录,并且应该包含冗余。作为一个 Python 初学者,我没有意识到 Python 类自动允许这两种符号。因此,我现在试图使可选的符号显式化。注意"self-other"
中的减号看起来像连字符。也许应该写成"self - other."
我怀疑非常有才华的程序员很少会觉得注释和重组代码对他们的高中程序是必要的。他们几乎从来不会迷失在自己的代码中,写得不好的代码对他们来说仍然很容易理解。这也是普通员工难以追随天才的原因之一。他们不怎么努力说清楚,只是简洁。这就是为什么有天赋的学生有时会轻视可读性的要求。他们真的不理解我们的困难。
注释表示代码不好吗?虽然这可能是真的,但这样的声明可能会导致初级程序员回避注释。目标是编写可读的代码。如果注释有帮助,那么就应该使用它们。考虑这个深度优先搜索函数中的注释。
def DFS_FewestNodesPath(node, goalNode, path=[]):
# Notes: 1\. We avoid loops by reference to the path itself.
# 2\. The recursion will be unwound just below the recursive call at (*).
#---Append current node.
path = path + [node]
#---base case
if node == goalNode:
return path
#---recursive case
bestPathSoFar = []
for (child, dist) in graph[node]: # dist is a dummy variable that is never used.
if child not in path:
newPath = DFS_FewestNodesPath(child, goalNode, path) # <-- (*)
if newPath and (len(newPath) < len(bestPathSoFar) or bestPathSoFar == []):
bestPathSoFar = newPath
#---Return best path, which could be [].
return bestPathSoFar
我认为这十行代码体中需要八个注释,因为函数是递归的,对我来说算法很复杂。除非绝对必要,否则工业界尽量避免递归,因为它太难维护了。
下面是来自一个编程竞赛训练手册的建议:“先写注释。如果你不能很容易地写出这些注释,你很可能没有真正理解程序是做什么的。我们发现调试注释比调试程序容易得多。” 1 (我认为通过“注释”他们通过函数调用包括了程序的概要。)不幸的是,在我们理解解决方案之前,我们无法正确地编写注释。当我们发现我们的程序失败时,我们开始理解解决方案,并且我们跟踪代码。我举个例子。我正在编写代码来实现奥赛罗游戏的最小最大决策规则。以下是我的原始注释:
#---Return best board score for white
几天后,我修改了代码,并将注释修改为:
#---Three cases: 1\. Return (usually) the move with the minimum boardScore value (COMPUTER’s choice), or
# 2\. if there is no legal move AND depth is zero, then return
# boardScore(), or
# 3\. if there is no legal move AND depth != 0, then return maxValue(depth-1, alpha, beta)
直到我追溯我失败的程序,发现我头脑简单的代码在某些情况下失败,我从来没有想过一方没有合法行动的立场。所以注释和代码一样错误。不过,先写注释可能是个好主意。我只是从来没有尝试过。作者提出了有见地的观察,即“错误倾向于滋生那些太难看而无法阅读或者太聪明而无法理解的代码。” 2 阿门。
下面是一个函数的两个版本,它接收一个列表和一个数字r
,然后返回列表的第r
个排列。
# VERSION 1.
def permute(Lst, r):
#--initialize
Lst = Lst[:]
L = len(Lst)
#--check data
assert L>=1 and r>=0 and r<factorial(L) and \
type(Lst) == list and type(r)==int
#--base case
if L == 1: return Lst
#--recursive case
d = factorial(L-1)
digit = Lst[r//d]
Lst.remove(digit)
return [digit] + permute(Lst, r%d)
# VERSION 2.
def permute(Lst, r):
Lst = Lst[:]
L = len(Lst)
assert L>=1 and r>=0 and r<factorial(L) and \
type(Lst) == list and type(r)==int
if L == 1: return Lst
d = factorial(L-1)
digit = Lst[r//d]
Lst.remove(digit)
return [digit] + permute(Lst, r%d)
我最初写的是版本 1。但是后来我发现这些注释不仅是不必要的,而且使代码更难阅读。那么,为什么我的观点会发生变化呢?回答:我越来越习惯阅读 Python 代码。对于初学者来说必要的注释对于更有经验的程序员来说是不必要的。
一年后,当我需要在Cell
类中打印一个矩阵时,下面的第一条注释很有帮助。那时我很少使用类,以至于我不能立即记住调用格式。
#---The call looks like this: Cell.print(matrix)
# def print(matrix): # DEBUGGING UTILITY: Print the matrix/board to the console.
. . .
#--------------------------Cell Class--------------------------
我认为编写将重复的小数转换成分数的算法很难。奇怪的是,这些例子很容易理解。为了让学生程序员明白这一点,下面的代码将同一个例子做了两次:用代数方法和用计算机代码。如果我引入了一个小错误,您可能会在一两分钟内发现它。这是记录良好的代码,但是谁有时间编写这样的注释呢?我的回答是,在特殊情况下,这种细节是必要的。
# EXAMPLE:
# Let x = 12.345676767...
# Then 100000x = 1234567.676767676...
# And 1000x = 12345.676767676...
# So 100000x - 1000x = 1234567 - 12345 = 1222222\. <-- Notice that we can ignore the decimal parts.
# Thus, x = 1222222/99000
def repeatingDecimalToFraction(number, repLength):
#---Preconditions: number is float type, repLength is integer and 0 < repLength <= length of decimal portion.
numberCastToString = str(number)
decimalPointPosition = numberCastToString.find('.')
lengthOfDecimalPortion = len(numberCastToString) - decimalPointPosition - 1
# == AN EXAMPLE IS GIVEN TO MAKE THIS ALGORITHM CLEAR. ==
# number = 12.34567 <-- Here, the 67 repeats.
# repLength = 2, the length of 67
numberlength = len(numberCastToString) # numberlength = 8, the total length
lengthOfIntegerPart = len(str(int(number))) # lengthOfIntegerPart = 2, the length of 12
shiftLength = numberlength - (lengthOfIntegerPart + 1 + repLength) # 1 is for the decimal point.
# shiftLength = 8 - (2 + 1 + 2) = 3, the distance
# from the decimal point in 12.34567 to the repeating part (67)
factor1 = int (10**(shiftLength+repLength)) # factor1 = 100000
factor2 = int (10**shiftLength) # factor2 = 1000
numberTimesFactor1 = int(number * factor1) # = 1234567.676767
numberTimesFactor2 = int(number * factor2) # = 12345.676767
numerator = numberTimesFactor1 - numberTimesFactor2 # = 1234567.676767 - 12345.676767= 1222222
denominator = factor1 - factor2 # = 99000 (= 100000x - 1000x = (100000 - 1000)x
return numerator, denominator # postcondition: integer types are returned.
一些程序员会认为这是太多的细节,但对他们来说太多对其他人来说并不算多。如果你仔细观察,你会注意到变量factor1
和factor2
。通常,我们更喜欢描述性更强、不太相似的变量名。我知道这个,但我想不出更好的名字了。
根据人教版 0008,“你应该在句末句号后使用两个空格。”我记得在 1961 年的一次高中打字课上,有人给了我同样的建议。当文字处理软件出现时,一般的建议是在句子之间加一个空格。我不认为两个空格的规则有多大关系。
业界希望每个 Python 函数(docstrings)都有一个注释。这对遗留代码有意义。因为这是世界标准,您可能会考虑养成对复杂函数这样做的习惯。我在互联网上看到过许多命名不当的函数,它们使用了如此缩写的神秘参数,我真希望作者使用了 docstrings。
Footnotes 1
Steven S. Skiena(石溪)和 Miguel A. Revilla(西班牙瓦拉多利斯),《编程挑战》(Springer,2003 年),第 9 页。今天这被称为 CDD(注释驱动开发)。
2
Steven S. Skiena 和 Miguel A. Revilla,《编程挑战》(Springer,2003 年),第 40 页。
九、停止编程
- 当我编程时,我有时会写一些行,希望它们能工作,但并不真正理解它们在做什么。—一名高中生正在上他的第四节编程课(2011 年 12 月)。
当你开始感到困惑的时候,停止编程。当我第一次把旅行推销员问题分配给我的学生时,我附加了以下建议:
-
你程序中的数据将会是一个 xy 坐标列表。让它成为列表的列表,而不是元组。并在第零个位置附加一个
id
-例如, -
为什么要做列表的列表而不是元组的列表?因为你不知道以后会不会需要修改组件,而且元组是不可变的。为什么要附加一个
id
?因为您不知道以后是否需要为您的xy
-坐标提供一个属性——例如,已访问和未访问——或者一个属性元组。
city = [[id, x-value, y-value], [id, x-value, y-value], ..., [id, x-value, y-value]]
我刚来写旅行推销员问题的时候,是用没有id
的元组工作的。最终我开始失去对程序的控制。这些函数变得如此复杂(三个下标的括号),以至于修改起来很痛苦。是重新开始的时候了。基于我的失败,我知道可变列表和属性会简化程序。为什么当初我没有意识到这一点?因为我太专注于概念上的细节,所以我不能很好地考虑实现。只有当我的编程变得困难时,我才意识到我的设计错误。
如果你的程序开始变得如此复杂以至于你不能理解它,那么你必须重构或者完全重新开始。当你不得不重新开始的时候,好消息是你变得更聪明了,你的一些代码是可以挽救的。
在开始之前,你需要概述整个程序或算法吗?如果程序/算法异常复杂,那么你至少需要一些大纲。你会知道这一点,因为你会立即对这项任务感到不舒服。让我明确地说明这一点:你必须在编程前花时间思考你觉得复杂的项目。对于大多数学校的问题,我通常会同时设计和键入代码。任何人都可以用简单的程序做到这一点,但是有一定的难度,超过这个难度,动态规划就不能很好地工作。你需要找到自己的水平,知道什么时候可以,什么时候不能用快速肮脏的风格逃脱。 2 这并不容易,因为习惯很难打破,我们的自我卷入其中,我们想要跟上我们的同学。键入不能很好地一起工作的代码行不是编程,除了在名称上。也许我们已经吸取了教训。注意编程的心理学。
在键盘上勾画轮廓的一种方法是只写函数名(里面没有代码的存根,或者返回虚假数据的模拟)。 3
def doIt(x): # <--STUB
pass
def doIt(x): # <--MOCK
return 0
在计算机编程中有两种相互竞争的设计哲学:做正确的事情,越差越好。“做正确的事情”哲学同样关注软件设计的完整性、一致性、正确性、易用性和简单性。这是构建完美程序的一种尝试。而我们为什么不应该做这种尝试呢?这是专业软件设计师的哲学。为什么有人会说越差越好?原因如下:
- 完整性是指程序中不被忽略的特殊情况。如果用户对这些特例不感兴趣,为什么要花大价钱编写代码呢?有时候完整是浪费时间。
- 一致性对于团队来说是有用和必要的,但是对于孤独的程序员来说,半一致性已经足够好了。我们有限的时间可以用在更好的地方。
- 如果其他人会使用你写的程序,易用性是很重要的。但是学校项目通常只由设计者来执行。除非易用性是任务的目标,否则它可能会妨碍其他目标。
- 甚至希望程序正确的愿望也可能因为好的理由而被牺牲。我曾经看到过一些代码,为了加快程序速度,把距离公式
换成了
。 4 如果我的近似程序运行 10 秒,而你的精确程序运行 3 分钟,你的精确程序会是人们想要使用的程序吗?有时我们会为正确付出过高的代价。
在这一点上做正确的事情的人会想打断。他们会指出我只是从例外中推理。既然每一种哲学都有例外,我的反对就毫无价值。更糟糕的是,这些例外正试图摧毁一种积极的哲学。置换哲学在哪里?很公平。越差越好学校确实有一套替代哲学。这就是:越差越好的哲学主张简单的设计是首要的。它表明,一致性、完整性、易用性、正确性和其他积极的属性更有可能从保持简单的简单设计中演化而来,而不是从追求完美的第一次尝试中演化而来。如果这些特征不是自己出现的,那么我们可以通过修改一个简单可行的程序来插入它们。“做正确的事情”的理念可能适用于花费数月时间设计程序的团队,但这不是一个孤独的程序员应该写程序的方式。
我对这场小辩论的看法是,学校课程完美的目标可能是迂腐的。优秀编程的标准也必须根据资源(主要是时间)和任务背后的动机来衡量。现在举个例子。
编程时间足够长的学生会注意到,他们反复编写调试代码来打印矩阵和其他数据结构。那么为什么不写一个通用的矩阵打印机,放在个人图书馆里呢?我的版本在下面。给它一个整数、浮点、字符串、布尔、None
都混在一起的矩阵(这是 Python,记住),代码会整齐地打印出所有垂直对齐的数据。
def printMatrix(Lst, decimalAccuracy = 2):
print('---MATRIX:')
if type (Lst) != list or type (Lst[0]) != list:
print('*' * 45)
print(' WARNING: The received parameter is NOT a \n',
'matrix type. No printing was done. ')
print('*' * 45)
return
maxLength = 0
for row in Lst:
for x in row:
if type(x) == float: x = round(x, decimalAccuracy)
maxLength = max(len(str(x)), maxLength)
if type(x) == float:
print('%11.2f'%x, end='')
elif type(x) == int:
print('%8d '%x, end='')
elif type(x) == str:
print('%8s '%x, end='')
elif type(x) == bool:
print('%8s '%str(x), end='')
elif x == None:
print('%8s '%str(x), end='')
else:
print(x, ' ')
print()
print('==============================')
print('cell maxlength =', maxLength, '(8 is limit)')
大问题:曾经需要一个通用打印机(这个代码)吗?我打印过的唯一矩阵包含浮点数和整数。现在我似乎被这个小项目的酷冲昏了头脑:一台通用矩阵打印机。它被过度设计了。我违反了 YAGNI 原则(如果你不需要,就不要写代码)。在这里,越差肯定越好。
顺便说一下,这里有一个 Python 技巧来漂亮地打印一个列表:
Lst = ['A', 2, [1,2,3], 4000, 0.123]
print('', *Lst, sep='\n....')
"""
Output:
....A
....2
....[1, 2, 3]
....4000
....0.123
"""
下面的建议实际上包含了一些智慧:没有计划就是计划失败。三思而后行,编程一次。匆忙编程,永远调试。 5 记住一周的调试可以节省整整一个小时的规划。
Footnotes 1
2003 年,在 ARML 举行的 H.S .数学竞赛中,我们学校的数学队队长在决赛前给队友们打了打气。他说他在实践中注意到他的许多队友忽略了他们有能力解决的问题。为什么呢?他们没有足够仔细地阅读问题,以发现给定信息中的微妙关系。他的建议是“在开始解决问题之前仔细阅读每个问题。”那年我们学校赢得了 ARML 奖。
2
在互联网上搜索 BDUF(前期大设计)、RDUF(前期粗略设计)和“紧急设计”在没有编写过相同程序的原型(缩小版)的情况下,设计一个复杂的程序会有很大的问题。
3
存根和模拟的定义各不相同。更安全的说法是使用“假货”
4
错误会有多严重?设,其中 x 和 y 均为非负,
。那么最大的
。
5
罗伯特 l .克鲁斯,数据结构与编程,第二版。(普伦蒂斯-霍尔,1987),第 55 页。
十、测试
据说“永远保持警惕是自由的代价。” 1 是的,因为永远的警惕是一切品质的代价。当编写代码时,这意味着边写边测试,在编写完代码后立即测试每个关键功能,而不是等到整个程序都写完了。CABTAB:编程一点;测试一下。早期测试可能是减少编程错误的最好方法。如果我们在编写代码时没有发现每个代码块中的错误,那么以后当我们试图调试代码时,我们对代码就不太熟悉了。
我们测试已知输入的预期输出。我们测试超出范围的值、偏离 1 的值、无意义/互换的数据、空集、零长度步骤、糟糕的游戏移动(非法移动或不合法移动)、除以零、前置条件、后置条件、不变量、适当的关系,尤其是边界条件。你进行的测试被称为系统测试和增量原型。
回想一下,二维视频屏幕在内部由一维列表表示。因此,如果我们希望通过像素戳在矩形(WIDTH
× HEIGTH
)屏幕上画一个圆,那么二维图像必须转换成一维表示。我试图直接做到这一点。看看下面的代码。一维列表被称为image
。三条线(A、B 和 C)中只有一条是正确的,然而它们看起来都是正确的。哪个是正确的?
def frange(start, stop, step = 1):
i = start
while i < stop:
yield i # <-- not return i
i += step
def drawCircle(cx, cy, radius, image):
from math import cos, sin
for t in frange(0, 6.28, 0.01): # range will not allow float steps.
x = cx + radius*cos(t)
y = cy + radius*sin(t)
image[int(y)*WIDTH + int(x)] = 255 # <--A
image[int(y *WIDTH) + int(x)] = 255 # <--B
image[int(y *WIDTH + x)] = 255 # <--C
return image
唯一正确的线是 b。我花了几分钟看这段代码(和线 A ),试图发现为什么圆被展开成波浪。表达式y*WIDTH
first 必须向下舍入。这个例子的要点是,如果不测试代码,错误是不可能避免的。
下面的陷阱抓住了我的一个聪明的学生:
v = [0]*2
print('v =', v) # v = [0, 0]
m = [v]*2 #
print('m =', m) # m = [[0, 0], [0, 0]]
m[0][0] = 8
print('m =',m) # m = [[8, 0], [8, 0]]
#--Surprise: m[0][0] and m[1][0] share the same memory address.
几年前,我设计了一个简单的问题来确定我的高中三年级和四年级学生中谁在数学方面比较弱。随便找个高中生试试。
根据其他字母求解 y:。[3 分钟]
后来我意识到,编写这个问题的解决方案提供了一个有启发性的例子,说明消除逻辑错误是多么困难。
测验 4。用你最喜欢的语言,写下面这个简短的函数,然后把你的代码和我的进行比较。
def solveEquation(a,b,c,d,x):
# +---------------------------------------------------------+
# | Given: (x-a) = (b*y-c)/(d-y) |
# | Return the unique value for y, if it exists. |
# | if no value for y exists, then print an |
# | error message and exit the program. |
# | if multiple values for y exist, then print |
# | a warning and return a valid value for y. |
# +---------------------------------------------------------+
#... Finish writing this function.
祝你好运。
# QUIZ 4 (My Solution)
def solveEquation(a,b,c,d,x):
# +---------------------------------------------------------+
# | Given: (x-a) = (by-c)/(d-y) |
# | Return the unique value for y, if it exists. |
# | [y = (x*d - a*d + c)/(x-a+b).] |
# | If no value for y exists, then print an |
# | error message and exit the program. |
# | If multiple values for y exist, then print |
# | a warning and return a valid value for y. |
# +---------------------------------------------------------+
if (x == (a-b) and (c != b*d)):
exit('ERROR: No solution. The expression reduces to c = b*d.')
if (x == (a-b) and (c == b*d)):
print('WARNING: y is NOT unique: y may take ANY value, except d.')
return int(not d) # y = 0 or 1
if (x != (a-b) and (c == b*d)):
exit('ERROR: No solution. The expression reduces to y = d.')
#---Note: x != (a-b) and c != b*d).
y = (x*d - a*d + c)/(x-a+b) # <-- No division by zero and no y = d.
return y
不幸的是,这个问题太难了,以至于它的教育用途受到了限制。不过,这是一种培养解决问题技能的练习。
你可能会认为A += B
只是A = A + B
的简写符号。这在 Python 中是不正确的,也许在其他一些语言中也是如此。
def append1(A): #
A += [3] #
#------------------------
def append2(A):
A = A + [3] # The two As are different objects.
#------------------------
def main():
A = [1,2]
append1(A)
print(A) # output: [1, 2, 3]
A = [1,2]
append2(A)
print(A) # output: [1, 2] ß Surprise!
那么,我们如何保护自己免受这种句法毒害呢?答案是让我们的代码保持简单,并且边走边测试。
为了修正一个错误,每个人尝试的第一个工具是猜测,因为这不需要努力,而且很多时候是成功的。只有当我们无法通过猜测来修复错误时,我们才必须停下来思考。但有时我们不会停下来——我们只是不断改变,希望我们的问题会消失。在这一点上,我们的效率实际上可能会降到零以下。我们可能开始改变不应该改变的代码。
发现学校程序中错误的主要方法是通过程序运行测试数据并检查结果(跟踪)。有时,对于复杂的算法,编写一个通过函数运行随机数据并检查答案的测试程序是有帮助的。
另一种发现错误的方法是放置错误陷阱,只有在发现错误时才会打印。这在某种程度上给了我们一个自调试程序。这样做的一个原因是,程序某一部分的错误修正可能会导致另一部分的失败。
Misko Hevery 在 YouTube 视频中提出了一个有趣的问题:你能从测试中重建源代码吗?我最初的反应是“当然不是。”然后他建议用一组测试来讲述一个故事。假设测试看起来像这样:
Test1_ItShouldDoThis()
Test2_ItShouldDoThat()
Test3_ItShouldDoSomethingElse()
Test4_ItShouldDoThisToo()
Test5_ItShouldExitLikeThis()
所以,也许他是对的,也许我们的测试应该是为了讲述一个故事。
测验 5。(重要)回想一下,两个向量的点积是它们成对乘积的和。例如,x = [1,2,3,4]
和y = [2, -3, 0, 5]
的点积为
1×2 + 2×(-3) + 3×0 + 4×5 = 16.
下面的四个函数都正确地计算并返回两个向量(又名列表,又名数组)x
和y
的点积。唯一的区别是错误陷阱。哪一个是首选的错误陷阱:A、B、C 还是 D?
#---Method A.
def dotProd(x,y):
return sum(x[n]*y[n] for n in range(len(x)))
#---Method B.
def dotprod(x,y):
assert type(x) == type(y) == list
return sum(x[n]*y[n] for n in range(len(x)))
#---Method C.
def dotprod(x,y):
assert len(x) == len(y)
return sum(x[n]*y[n] for n in range(len(x)))
#---Method D.
def dotprod(x,y):
assert type(x) == type(y) == list
assert len(x) == len(y)
return sum(x[n]*y[n] for n in range(len(x)))
我的答案在脚注里。 3
编写函数时,考虑测试不会产生编译或运行时错误的任何先决条件和边界条件。养成这种习惯将会节省你几个小时的调试时间。下面是一个奇特的断言,当一个更简单的形式就可以了,它可能不值得花时间去编程。尽管如此,你应该知道这样的形式是可能的。
import sys
assert x>0, 'in function ' + sys._getframe().f_code.co_name + \
' x = ' + str(x)
输出:AssertionError: in function doIt x = -1
此外,不要在 Python 断言周围放置圆括号或方括号。非空的元组或列表总是被评估为真(语法毒药)。
程序员构建的错误陷阱可以打印比断言更多的信息,可以关闭文件,并且可以将信息保存在错误文件中。那么为什么有人更喜欢使用内置的 assert 语句,而不是编写一个错误陷阱呢?回答:
- 断言立即被视为错误陷阱,而不是函数任务的一部分。
- 断言比用户构建的错误陷阱更快更容易编写。
- IDE 会将光标放在程序中的断言行上;一个错误陷阱通常会打印一条错误信息并退出程序。
测验 6。编写一个函数,期望接收两个大小相同的字符串,并返回不同但相对位置相同的字母数,例如,接收“abcdef”和“axcxfe”的函数将返回 4。我的答案在本章末尾。至少在 Python 中,有一种巧妙的方法可以做到这一点。
考虑在程序运行时或结束后打印一些统计数据(时髦的术语:动态性能分析)。当然,总是打印每个程序的运行时间——没有例外。这可以帮助你确定你的程序的大 O。您可能还想打印
- 进行递归调用的次数和达到的递归深度,
- 树中被访问的节点数量(我这样做是为了衡量 alpha-beta 修剪的性能。我的程序通过修剪减少了 2/3 的节点。),
- 已达到最大树级深度,
- 队列或某些其他动态数据结构的最大大小,
- 写入或读出文件的项目数,
- 或者每次移动所用的时间,也许是游戏中每次移动的平均时间。
在编写代码之前编写测试和调试实用程序有几个好处。
- 当你写完你的代码,你可以立即测试它,而不是花几分钟去创建一个测试。
- 您实际上编写了测试,而不是转移到另一个函数。
- 首先编写测试有助于在编写函数之前勾勒出它的轮廓。
不测试你的代码的一些原因是:1)它很无聊/有压力/很累,2)它明显减慢了我们的进度,3)我们不习惯测试,以及 4)我们担心我们可能真的会发现一个 bug。所有这些借口都是自欺欺人的形式。坦率地说,不进行测试意味着草率、懒惰和无能。
- 学生程序员:“有什么办法可以摆脱这种痛苦吗?”
- C.S .老师:“是的。不要一开始就陷入其中:边走边测试,防御性地编写代码。”
-
测验 6 答案:
different sameLettersInSamePlaceCount(stng1, stng2):
return sum(ch1 != ch2 for (ch1, ch2) in zip(stng1, stng2))
好吧,如果你不知道zip
功能,那么你就不可能使用它。指令
print(list(zip(stng1, stng2)))
生产
[('a', 'a'), ('b', 'x'), ('c', 'c'), ('d', 'x'), ('e', 'f'), ('f', 'e')]
zip
函数值得记住。其实这个例子很值得记住。注意这个理解是生成器理解,不是列表理解(没有方括号)。注意函数名有多长。浏览一下这个简单的代码就能告诉读者它是做什么的。那么为什么不用lettersCount
这个名字或者更简单的名字呢?答:因为我们想让读者确切地知道函数计算和返回什么,而不必检查函数代码。
我一生都在研究高中数学/计算机科学问题,这促使我为计算机科学专业的学生提供另外三个数学问题:
-
根据 f (x)给定 g (x),我们能根据 g(x)写出 f (x)吗?有时候,如果你发现了窍门。给定
,用
和
写出 f (x)为 g (x)和参数 a、b、c 和 d 的函数
-
Given
, with
, find f (x) such that
, and f(x) increases uniformly as x increases uniformly—e.g., if x is
of the way between a and b, then f (x) is
of the way between c and d. The need for this formula is common in graphics. For example, you need this formula to draw the fancy web shape on the right. As x ranges from
G
toC
, y must uniformly range fromH
toD
. -
一个汽车散热器能容纳
quartCap
夸脱。它装有百分之pct1
的防冻溶液。编写一个函数antifreeze
,返回正确的溶液夸脱数(四舍五入到最多 2 位小数),该溶液应被排空并重新注入纯防冻剂,以使浓度达到百分之pct2
。在不可能的情况下退出程序——例如,我们假设pct1
和pct2
是介于0
和1
之间的数字。
答案 1。,其中 x 在 f 的定义域内,由于原方程两边的 x 代表相同的值,所以两边用
代替,然后简化。
答案 2。。
代数:我知道的最简单的推导如下图。
解析几何:问题自然归结为求通过点(a,c)和(b,d)的直线。这样一条线的斜率是
,通过将(a,c)代入
可以得到 b ’,这样我们就得到了
。由此,
。这个表达式简化为
。
答案 3。
def antifreeze (quartCap, pct1, pct2):
assert 0<= pct1 <=1 and 0<= pct2 <=1 and pct1 <= pct2 and quartCap > 0, \
["ERROR (bad input):", quartCap, pct1, pct2] # Note the FOUR cases.
return round(quartCap*(pct2-pct1)/(1-pct1), 2)
Footnotes 1
摘自爱尔兰演说家和政治家约翰·菲尔波特·柯伦 1790 年的一次演讲。参见维基百科。
2
如果一种语言提供了方便、简洁的快捷方式,那么这些快捷方式可以被描述为语法糖,这个术语是在 1964 年创造的。Python 中内置的字典数据结构是关联矩阵/列表的语法糖。我怀疑 Python 比其他任何语言都有更多的语法优势。
3
问题 5 答案:我选择 C 是因为如果没有len(x) == len(y)
,一个错误会不被发现地传递到程序的其余部分。我认为如果没有这个错误陷阱,这个函数就没有写好。然而,我们不需要检查x
和y
的数据类型,因为编译器会为我们做这些。编译器检查以确保x
和y
都是可下标的,并且它们是数字的集合,而不是字符串或对象。请记住,Python 中的 times 操作符(*)是重载的,例如’cat’*3 = ‘catcatcat’
。偶尔会出现奇怪的错误。世界级的程序员会测试这种可能性吗?我的观点:保护我们的代码免受每一种极其罕见的可能性,是不划算的。
十一、防御性编程
- 到 1949 年 6 月,人们开始意识到,要把一个程序做好并不像以前那样容易。我清楚地记得当我试图运行我的第一个重要程序时(用汇编代码或者机器语言)。我强烈地意识到,我余生的大部分时间都将用来寻找我自己程序中的错误。图灵显然也意识到了这一点,因为他在会议上发表了关于“检查大型例程”的讲话。—莫里斯·威尔克斯(图灵奖获得者,1967),《计算机先驱回忆录》(麻省理工学院,1985),页 145。
使用防御性编程。因为我们预先知道会有 bug,所以我们可以在几个方面变得积极主动。我们可以编写函数
- 在参数到达函数后打印传递的参数(跟踪),
- 打印中间计算值,以及
- 打印错误消息以捕捉坏数据(错误的类型、错误的大小、错误的顺序、被零除和越界错误)。这被称为错误处理和崩溃报告。在这里,
try/except
构造有时很有用。
所有这些都被称为防御性编程或脚手架,因为其中大部分最终都会被移除。
这里有一个来自工业界的技巧:有一个全局常数叫做,比如说,errorCheck
或者debug
。在每个错误代码块(断言、跟踪、打印输出、try/except
、类型检查等)之前。)是
if errorCheck:...
然后我们不清除错误代码;我们只要用errorCheck = False
关掉它。在工业中,他们有时会将errorCheck
设置为0
(关闭所有检查)、1 =
开启部分检查、2
=开启更多检查等。
我最近写了几行代码来捕捉一个越界的变量。我按照习惯行事,甚至对自己说,“好吧,这是浪费时间。这个变量永远不会越界”,但在下一次运行时却发现这个变量越界了。诸如此类的重复经历让我成为谨慎编程的信徒。 1
防御性编程有一个危险:它可能会隐藏错误。考虑以下代码:
def drawLine(m, b, image, start = 0, stop = WIDTH):
step = 1
start = int(start)
stop = int(stop)
if stop-start < 0:
step = -1
print('WARNING: drawLine parameters were reversed.')
for x in range(start, stop, step):
index = int(m*x + b) * WIDTH + x
if 0 <= index < len(image):
image[index] = 255 # Poke in a white (= 255) pixel.
该功能从start
运行到stop
。如果stop
小于start
,它就后退一步,没有错误报告。也许我们希望这种错误在运行过程中被“修复”——隐藏起来——但是我认为我们至少应该打印一个警告,说明范围正在向后移动。也许我们应该中止这个项目。
给定矩阵 A,我们按照(i = row,j = col)约定引用单个元素(a ij )。类似地,当我们阅读一页文本时,我们首先固定在一行上,然后沿着一列阅读。不幸的是,存在竞争约定:在 xy 平面中,我们通过(x =col,y = row)绘制点,并且我们还使用(col,row)在计算机屏幕上绘制点,但是从左上角开始我们的“第一象限”,而不是左下角。因此,我们倾向于有时使用矩阵(行,列)方案,有时使用点绘图(列,行)方案。这是一个交叉的例子。我从(r = row, c = col
)开始绘制矩阵,然后切换到(x = col, y = row
)进行绘制。
for r in range(8):
for c in range(8):
if M[r][c] == 1:
x = c*70 + 85
y = r*70 + 105
canvas.create_oval(x-25,y-25, x+25, y+25, fill = 'BLACK')
没有问题,因为代码很清楚。我展示这个只是为了说明竞争约定有时会出现在相同的代码块中。
在下一个例子中,我在对同一个函数的两次不同调用中交换了 x 和 y,但是 Python 代码仍然返回相同的正确答案。这怎么可能?答:我在传递索引(关键词)之前,先给它们起了名字。寓意:了解你的语言。
def sub(x, y):
return x-y
#-----------------------------
def main():
print(add(x = 2, y = 1)) # Output: 1
print(add(y = 1, x = 2)) # Output: 1
一位 YouTube 评论员建议永远不要使用x
和y
作为矩阵坐标,而是使用row
和col
。为什么呢?因为为应该是(y,x),
的东西写(x,y)
太容易了,而(row, col)
不太可能互换。
Footnotes 1
在一个函数中放置太多的错误陷阱会掩盖函数应该做的事情。在这种情况下,有时,陷阱应该移动到它们自己的功能中。
十二、重构
当你的程序完成时,不要走开。考虑重新设计它——只要最后期限不是迫在眉睫。重新设计,不是为了优化,而是为了使工作代码更容易理解、调试、修改和与其他代码集成,这种做法很常见,以至于有了一个名字:重构。 1 重构是重新考虑变量和函数名称,将多任务函数分解为单任务函数,应用逐步细化,重新考虑数据结构的选择,考虑内聚性与耦合性,并重写代码以使其更清晰、更高效。
我曾经写过一个简单的函数,它接收一个字符串和一个布尔变量。脑子里立刻响起了警报声。这个功能是在做两个不同的任务吗?不完全是。Boolean 只是告诉函数按字母顺序或频率顺序打印字符串。后来我发现我需要这个函数来打印字符串中出现的字符。我想到我可以传递布尔值True
、False
,或者让接收参数默认为None
。
然而,这是一种糟糕的编程方式。你见过产生三个值的布尔变量吗?编程人员需要假设一些情况永远不会发生,即使它们是可能的。我最终的解决方案是向函数传递一个整数,该函数只需要值 0、1 和 2。其他值会导致出现警告消息,默认值为 0,但不会抛出异常——不会中止程序。
为什么要有默认值?因为函数的用户可能不关心输出是如何排序的,可能不想麻烦地传递参数,或者可能不知道可用的选项。因此,该功能变得更加强大。
赋值:写一个函数返回一个数组中目标元素的第一个索引,否则返回-1
。下面的代码展示了三种不同的方法:
# Method 1 (ugh!)
def indx(array, target): # 11 lines
if array == []:
return -1
n = 0
found = -1
length = len(array)
while n != length:
if array[n] == target:
found = n
break
n += 1
return found
这第一种方法是我用标准想法所能做到的最低效的方法。是一个假想的学生产生的代码,他从来不会问代码是否能以更干净的形式写出来(重构)。
# Method 2 Refactored
def indx(array, target): # 3 lines
for n in range(len(array)):
if array[n] == target: return n
return -1
这第二种方法是我最好的尝试。我认为内置的index
函数会使代码更短。(我错了。)
# Method 3 built-in (Easiest to understand)
def indx(array, target): # 4 lines
try:
return array.index(target)
except:
return -1
这些例子说明了为什么学生需要看其他学生的代码,或者至少是老师的代码。通过这种方式学到的课程会让学生终身难忘。
重构为你提供了设计经验,有助于下一个项目。从不重新设计让你停留在新手水平。有时候函数A
会调用函数B
。后来我们用函数A
调用函数C
并使用来自函数B
的东西。嗯,那么函数C
应该是调用函数B
,而不是函数A
。但是如果我们做了这样的重新设计,那么我们以后可能会丢弃函数C
,并且不得不返回。所以有一段时间我们生活在低效的设计中。只有当程序完成时,或者失去控制时,我们才会考虑重新设计。
我不认为用一个精心制作的设计来完成一个程序是可能的,或者至少是高效的。一路上会改变太多。有些问题非常难,直到你看到你的程序崩溃和烧毁,你不太可能理解任务的困难,或考虑所有的特殊情况,或意识到你最好用数字串,而不是数字列表。 2
- 软件设计师从需求陈述中以一种理性的、无错误的方式推导出他的设计的画面是非常不现实的。从来没有一个系统是以这种方式开发出来的,也许将来也不会有。甚至教科书和论文中展示的小程序开发都是不真实的。它们被修改和润色,直到作者向我们展示了他希望他做了什么,而不是实际发生了什么。—大卫·帕纳斯和保罗·克莱门茨,见史蒂夫·麦康奈尔,《代码完整》,第二版。(微软出版社,2004 年),第 74 页。
- 如果说我们在过去几十年中学到了什么,那就是编程与其说是一门科学,不如说是一门手艺。要写干净的代码,首先要写脏的代码,然后再清理。大多数大一的程序员并没有特别好地遵循这个建议。他们认为首要的目标是让程序运行起来。一旦“工作”了,他们就进入下一个任务。大多数经验丰富的程序员都知道这是职业自杀。——罗伯特·c·马丁,《廉洁守则》(普伦蒂斯霍尔出版社,2009 年),第 30 页。
- 我们不太可能在第一次尝试时就设计好一个库或接口。正如弗雷德·布鲁克斯曾经写道,“计划扔掉一个;无论如何,你会的。”Brooks 写的是大型系统,但是这个想法适用于任何大型软件。通常直到你已经构建并使用了程序的一个版本,你才能足够好地理解问题,从而得到正确的设计。—布莱恩·w·柯尼根和罗布·派克,《编程的实践》(艾迪森-韦斯利,1999),第 87 页。
- 坦率地说,自顶向下设计是重新设计你已经知道如何编写的程序的好方法。—P.J .普劳格尔,《有目的的编程》(普伦蒂斯霍尔出版社,1993 年),第 2 页。
在硬科学中有一种学说认为,如果你不能用一个数字来衡量某样东西,那么它就不存在。 3 有些人认为这种想法是错误的。你不能用一个数字来衡量爱,爱是存在的。就我个人而言,我不太确定有一天爱情不会用一组数字来衡量。但是如果这个教义是假的也没关系,因为对教义的信仰培养了生产性思维。 4 所以我们问:可读性可以用什么单位来衡量?在检查脚注之前决定。 5
重构是一项艰苦的工作。有些程序写起来太累了,我只想在它们最终运行的时候就完成,不要重新设计它们。当然,当我第二年回到他们身边时,我很难理解我自己的代码。
- 基于一些早期的使用进行重构,然后不得不在不久之后撤销它是相当普遍的。——Kent Beck,测试驱动开发(Addison-Wesley,2003),第 102 页。
在我们结束这个话题之前,绝对不要在这样的情况下使用if-else
:
if x > 0:
return True
else:
return False
这种结构有时被称为反习惯用法(糟糕的设计)。相反,我们应该这样写:
return x > 0
测验 7。重构下面的代码。答案在脚注里。 6
if x > 0:
if ch in {'A','B','C'}:
return True
else:
return False
else:
return False
下面这段代码是我偶尔会用到的一个 shell 程序。
"""+===========+=======-=======*========-========+============+
|| TITLE ||
|| by M. Stueben (DATE) ||
|| ||
|| Description: ||
|| Language: Python Ver. 3.4\. ||
|| Graphics: None ||
|| References: None ||
+===========+=======-=======*========-========+============+
"""
######################<START OF PROGRAM>#######################
def fn():
pass
#==========+====<GLOBAL IMPORTS AND CONSTANTS>=================
None
#===========================<MAIN>=============================
def main():
pass
#--------------------------------------------------------------
if __name__ == '__main__':
from math import sqrt; from random import random, randint, uniform, shuffle
from sys import setrecursionlimit; setrecursionlimit(100)
from time import clock; START_TIME = clock(); main(); print('~-'*16)
print('PROGRAM RUN TIME:%6.2f'%(clock()-START_TIME), 'seconds.')
# import winsound; winsound.Beep(1500,500) # Frequency, milliseconds
#########################<END OF PROGRAM>######################
注意底部的五行。第一行导入了学校算法中经常需要的数学函数。第二行将可能的递归深度设置为 100,而不是默认的 1000。很少需要更大的深度。无限递归,这是我的代码中的一个常见错误,用 1000 次递归调用花费的时间太长而失败。接下来的两行计算并打印程序运行时间。最后一行发出哔哔声(如果需要的话),宣布程序结束。
假设您需要让用户输入四个选项中的一个。有一种方法可以做到:
input('Enter PUsh, pOp, View, or Quit. Choice (U,O,V,Q):')
这是另一种方法:
def userChoice():
msg = ''
pr = """
Enter u for push.
Enter o for pop.
Enter v for view.
Enter q for quit (or push the enter key).
Enter choice: """
while True:
try:
choice = input(msg+pr).strip()[0].lower()
except:
return 'q'
if choice not in 'uovq':
msg = 'ERROR: "' + choice +'" is an invalid choice. Try again.\n'
else:
return choice
第二种方法占用更多的空间,也更难调试,但是给了程序一个更漂亮的界面和更健壮的代码。值得付出额外的努力吗?如果你有时间,如果其他人会使用你的程序,那么也许是这样。对于初稿,单行代码更好。
对于简单的if
语句,尽可能避免否定的if
-测试,因为否定比肯定语句更难解析。我希望你已经记住了德摩根定律:
not (A and B) → (notA) or (not B).
not (A or B) → (not A) and (not B).
测验 8。应用德摩根定律,重构下面的循环体。我的解决方案如下。
for n in range(5):
if not A or x >= 10:
doSomething
测验 8 答案:
for n in range(5):
if A and (x < 10):continue
doSomething
第二个版本取消了一个not
并减少了缩进。
一些专家更喜欢在if
测试中使用<
而不是>
,因为这与数字线一致,数字线将较小的数字放在左边。这似乎是合理的,除非出于心理原因,“>
”确实更适合一个表达方式——例如,“??”。回想一下,在英语课上,建议你避免被动写作(“球被男孩击中了。”),而且更喜欢主动写作(“男生击球。”).当然可以。但如果球在故事里比谁打了球更重要,那我们不是更喜欢所谓的被动版吗?无论如何,在if
测试中,可能重要的是x
,而不是0.001
,?? 可能只是一个任意的小数字。
小心多个if
,尤其是最后一个else
。考虑用一组if
语句替换嵌套的else if
语句,也许是通过颠倒结构或者用return
、break
或continue
结束每个if
。为什么?简单的if
比else if
更容易调试。
- 在我们多年分析工业编程问题的过程中,我们发现由多个嵌套的
if
语句导致的复杂性是逻辑错误最常见的原因。——Tom Rugg 和 Phil Feldman,《Turbo Pascal 技巧、诀窍和陷阱》( Que,1986 年),第 132 页。
# LOGIC error (beginner's error 1, bleeding ifs)
x = 1
if x == 1: x = 2
if x == 2: x = 3
if x == 3: x = 4
print(x) # output: 4 (but the programmer expected 2)
# LOGIC error (beginner's error 2, back-stabbing else)
x = 1
if x == 1: x = 2
if x == 3: x = 4
else: x = 5
print(x) # output: 5 (but the programmer expected 2)
# Using returns, breaks, and continues can make code easier to debug.
def doIt(x):
if x == 1:
return 2
if x == 2:
return 3
if x == 3:
return 4
# Here is the useful subscripted list trick:
def doIt(x):
return['-',2,3,4][x]
纠结的代码:if
、elif
和else
语句,缩进几个层次,有时可以戏剧性地重构。熟练地应用这些技巧需要一些练习,所以也许你应该在每次测验后掩盖解决方案,直到你能想到一个重构。
测验 9。重构此函数的主体,使其更具可读性:
def doIt(a,b,c):
if a == 1:
if b == 1:
if c == 1:
print ('abc')
else:
print('ab')
else:
print('a')
else:
print('-')
测验 9 答案:
def doIt(a,b,c):
if a != 1:
print('- '); return
if b != 1:
print('a '); return
if c != 1:
print('ab'); return
print('abc')
这里,重构使测试变成了否定的,这违反了前面给出的建议。一般规则都有例外。
测验 10。重构这段代码。以下是两个解决方案。
#---BLOCK 1 (22 lines).
if a == 1:
if b == 1:
if c == 1:
print ('abc')
else:
print ('ab-')
else:
if c == 1:
print('a-c')
else:
print('a--')
else:
if b == 1:
if c == 1:
print ('-bc')
else:
print('-b-')
else:
if c == 1:
print('--c')
else:
print ('---')
混乱的代码(多个if else
语句)通常可以通过垂直对齐的重复and
语句来改善。
测验 10 个答案:
#---BLOCK 2 (8 lines).
if a == 1 and b == 1 and c == 1: print('abc')
if a == 1 and b == 1 and c == 0: print('ab-')
if a == 1 and b == 0 and c == 1: print('a-c')
if a == 0 and b == 1 and c == 1: print('-bc')
if a == 0 and b == 0 and c == 1: print('--c')
if a == 0 and b == 1 and c == 0: print('-b-')
if a == 1 and b == 0 and c == 0: print('a--')
if a == 0 and b == 0 and c == 0: print('---')
#---Block 3 (8 simpler lines)
if (a,b,c) == (1,1,1): print('abc')
if (a,b,c) == (1,1,0): print('ab-')
if (a,b,c) == (1,0,1): print('a-c')
if (a,b,c) == (1,0,0): print('a--')
if (a,b,c) == (0,1,1): print('-bc')
if (a,b,c) == (0,1,0): print('-b-')
if (a,b,c) == (0,0,1): print('--c')
if (a,b,c) == (0,0,0): print('---')
前面两个方案有点做作。如果标识符是函数调用,代码看起来就不会那么令人印象深刻。这是同样的测验,同样的答案。
#---BLOCK 1 (again).
if inStock(item):
if name in customerList:
if price-1 < payment <= price:
print ('abc')
else:
print ('ab-')
else:
if price-1 < payment <= price:
print('a-c')
else:
print('a--')
else:
if name in customerList:
if price-1 < payment <= price:
print ('-bc')
else:
print('-b-')
else:
if price-1 < payment <= price:
print('--c')
else:
print ('---')
#---BLOCK 2 (again).
if ( inStock(item) and
name in customerList and
price-1 < payment <= price): print('abc')
if ( inStock(item) and
name in customerList and
not(price-1 < payment <= price)): print('ab-')
if ( inStock(item) and
not name in customerList and
price-1 < payment <= price): print('a-c')
if ( inStock(item) and
not name in customerList and
not(price-1 < payment <= price)): print('a--')
if (not inStock(item) and
name in customerList and
price-1 < payment <= price): print('-bc')
if (not inStock(item) and
name in customerList and
not(price-1 < payment <= price)): print('-b-')
if (not inStock(item) and
not name in customerList and
price-1 < payment <= price): print('--c')
if (not inStock(item) and
not name in customerList and
not(price-1 < payment <= price)): print('---')
测验 11。这里,未改进的Block 1
似乎比重构的Block 2
更容易调试。难道没有办法改善Block 1
?是的,改进(Block 3
)在本章末尾。
测验 12。重构这段代码,显著减少行数:
#---BLOCK 1 (13 lines).
if a == 1:
if b == 1:
if c == 1:
print(doIt())
else:
print ('error 3')
return
else:
print('error 2')
return
else:
print('error 1')
return
我的解决方案(Block 2
)在本章末尾。
测验 13。简化/改进以下代码:
def selectCourse(name):
if name != '':
courseName = name
else:
courseName = 'Computer Science 101'
return courseName
测验 13 答案:
def selectCourse(name):
assert type(name) == str
return name or 'Computer Science 101'
assert
是保证name
不是None
、()
、[]
、0 或False
所必需的。“or
诡计”是正当的,还是我掉进了“巧妙代码”的陷阱?2002 年,高露洁大学的一位暑期教师劝阻我不要使用利用语言古怪的伎俩。他可能不赞成这个准则。
测验 11 答案:
#---Block 3 (6 lines)
(item, payment, name) = (0,0,0)
msg = ['-', '-', '-']
if inStock(item): msg[0] = 'a'
if name in customerList: msg[1] = 'b'
if price-1 < payment <= price: msg[2] = 'c'
print (''.join(msg))
测验 12 答案:
#---BLOCK 2 (4 lines).
if a != 1: print ('error 1'); return
if a == 1 and b !=1: print ('error 2'); return
if a == 1 and b ==1 and c != 1: print ('error 3'); return
print (doIt())
要记住的建议:如果你的代码有几个return
,考虑重写它,使其有早的return
而不是晚的return
,即使你需要使你的if
测试为负。
Footnotes 1
将复杂的代码分解成更容易理解的部分被称为“分解”,这是程序员在 20 世纪 80 年代发明的术语。第一次使用“重构”这个词是在 1990 年。参见维基百科,代码重构。
2
我最早参考这一观察是在 1965 年:“在计算中,只有当一个例程被调试和测试,并且一些产品已经运行时,程序员才真正知道他应该如何在第一时间解决问题。”——弗雷德·格雷伯格(兰德)和乔治·贾夫雷(洛杉矶山谷学院),《计算机解决方案的问题》(约翰·威利,1965),第十六页。对于 C.S .老师来说,这仍然是一本优秀的书。这些作者使用了 DEC 公司的 12 位 PDP-8 小型计算机,这是迄今为止商业上最成功的计算机。它使用不同的纸带阅读机和穿孔卡片阅读机。最早的个人电脑(微型计算机)直到 1975 年才推出,当时还只是雏形。
3
我经常说,当你能衡量你在说什么,并用数字表达时,你就对它有所了解;但是当你不能测量它,当你不能用数字表达它,你的知识是贫乏的和不令人满意的:它可能是知识的开始,但是在你的思想中,你几乎没有发展到科学的阶段,不管是什么问题。——开尔文勋爵(威廉·汤普森(1824–1907)。摘自 1883 年的一次演讲。发现在流行的演讲和地址第一卷(伦敦:麦克米伦公司,1894),73 页。
4
我们认为纯粹的虚假没有理由拒绝一个判断。问题是:这个概念在多大程度上保护和促进了人类的生活?最虚假的概念——这些概念属于我们的先验综合判断——也是最不可或缺的概念。没有他的逻辑虚构,没有在一个虚构的绝对和不变的世界中衡量现实,没有用数字不断伪造宇宙,人类就无法继续生活。放弃所有错误的判断将意味着放弃,对生命的否定。—弗里德里希·尼采,《超越善恶》(1866 年最初在德国出版),第一部分,第四部分;这种翻译是发现在托拜厄斯但泽,数字科学的语言,第四版。(《双日锚》,1956),第 249 页。
5
答案是时间。我们试图重构我们的代码,以减少其他人理解代码所需的时间。
6
小测验 7 答案:return (x > 0) and (ch in {'A','B','C'}).
十三、首先编写测试(有时)
- 我们面试并雇佣了很多测试人员。我们还没有遇到一个计算机科学毕业生在大学里学到任何关于测试的有用知识。——Cem Kaner,Jack Falk,Hung,Quoc Nguyen,测试计算机软件第二版和。(威利,1999),第九页。
在工业界,测试的第一步被称为领域测试:变量、约束和正确类型的测试。接下来是单元测试(又名功能测试又名白盒测试):单个功能的测试。最后是黑盒测试:对整个程序的测试。工业界也用程序来测试程序。在学校,我们通常通过追踪数据和检查预期的答案来进行测试。我们一般不会写其他函数来测试我们的函数。这很好,除了一个例外。对于一个复杂的算法,应该先编写一个测试函数——在编写算法之前,然后在编写算法之后编写另一个测试函数。这是两个不同的测试函数。你必须看到一个例子来欣赏这个建议。下面的代码是第一个测试函数,一个冒烟测试, 1 ,这是我在编写二分搜索法之前编写的。
The Notorious Binary Search
如果您忘记了,二分搜索法是一种在数字排序列表中搜索目标数字t
的索引的算法。如果t
不在列表中,那么算法返回-1
。如果t
出现不止一次,搜索返回它的任何一个索引。因为该算法可以用每个探测消除一半的索引,所以长度为L
的列表上的二分搜索法将最多采用 ceil(log 2 ( L
))个探测。对于十亿个指数,这是最坏情况下的 30 次探测。这个算法听起来很容易写。不是的。
def binarySearchTest():
array = [0,1,2,3,4,6,7,8,9] # <--5 is missing
print('array =', array)
print('Test -9 =', binarySearch(array,-9) == -1)
print('Test 0 =', binarySearch(array, 0) == 0)
print('Test 4 =', binarySearch(array, 4) == 4)
print('Test 5 =', binarySearch(array, 5) == -1)
print('Test 9 =', binarySearch(array, 9) == 9)
print('Test 10 =', binarySearch(array,10) == -1)
这个测试代码足够好,可以捕捉到明显的错误,这就是冒烟测试应该做的。当我终于来写binarySearch
的时候,几乎每一个逻辑错误都立刻被这个测试代码暴露出来。当然,修复一个错误会引入另一个错误,但是冒烟测试通常也会发现那个错误。
binarySearch
函数花了我 70 分钟来编写(通过冒烟测试)和重构。我对自己的binarySearch
有多自信?不是很远,因为烟雾测试很粗糙。最后一步是创建和测试 1000 个随机大小的随机整数排序数组。然后搜索每个数组中所有可能的数字,以及一些不在每个数组中的数字。下面的代码可以做到这一点。
def binarySearchTest():
runs = 1000 # The number of random arrays to be tested.
#---A function to verify the binarySearch for a single element.
def check(array, value):
valueIndex = binarySearch(array, value)
if ((valueIndex == -1) and (value in array)) or \
((valueIndex != -1) and (array[valueIndex] != value)):
print('\nFALSE: array =', array)
print('The position of', value, 'is returned as', valueIndex)
exit()
#---Check all numbers in all random arrays created below.
for i in range(runs):
#-------Create a random sized array each with different random values.
arrayLength = randint( 0, 30)
sm = randint(-5, 20) # sm = smallest possible value in array.
lg = randint(20, 40) # lg = largest possible value in array.
array = sorted([randint(sm,lg) for j in range(0,arrayLength)])
#-------Test every value possible in the array and many not in the array.
for value in range(sm-2, lg+2):
check(array, value)
print('True: The binarySearch function passed', runs, 'tests.')
我的二分搜索法唯一没有通过的测试是在空盘上。这是一个快速解决方案,现在我对我的代码很有信心。
My Binary Search
def binarySearch(array, target):
# UNCHECKED preconditions: array is a list of sorted integers.
left = 0
right = len(array)-1
while left < right:
mid = (left + right)//2 # rounds down.
if array[mid] == target:
return mid
if array[mid] < target:
if left == mid:
left = left+1
else:
left = mid
else:
right = mid
#---Check for empty array or possible solution where left = right.
if (array != []) and (array[left] == target):
return left # left = right = index of target.
return -1 # Either array = [], or target not in array.
当我将这个版本与binarySearch
的已发布版本进行比较时,我意识到我做出了一个糟糕的设计决策。我的代码使用了while left < right
,而while left <= right
会产生一个更简单的设计。当你开始设计一个复杂的功能时,很难确定每一个关键关系。
题外话。下面是我在网上找到的二分搜索法。注意elif
和else
。我把这个叫做纠结码。解开代码有一个简单的技巧:只需重复if
测试。
def binarySearch(array, target): # A better design. 29.51 seconds
left = 0
right = len(array)-1
while left <= right:
mid = (left+right)//2 # rounds down.
if array[mid] < target:
left = mid+1
elif array[mid] > target:
right = mid-1
else:
return mid
return -1
下面是解开的代码:
def binarySearchUT(array, target): # Untangled code. 39.33 seconds
left = 0
right = len(array)-1
while left <= right:
mid = (left+right)//2 # rounds down.
if array[mid] < target: left = mid+1
if array[mid] > target: right = mid-1
if array[mid] == target: return mid
return -1
这个更简单,少了三行。在一次千万次运行的测试中,纠结的binarySearch
以 29.51 秒跑完。解开的binarySearchUT
以 39.33 秒完成。重构后的改进值得损失速度吗?被解开的二分搜索法在几乎任何阵列上仍然快如闪电。题外话结束。
一个自然的问题是我们如何测试这些测试?答案是双重的。首先,我们有目的地将坏数据(也称为故障注入)传递给我们的测试代码,以验证它能够检测到错误。第二,用简单的代码,程序在测试验证程序的同时验证测试。
应该报告所有的测试结果还是只报告第一个失败的案例?我倾向于只报告第一个失败的案例,因为测试代码应该尽可能的简单和快速。因此,当发现错误时,该函数打印信息,然后返回或就地退出。我们不想跳出嵌套的for
循环,展开递归,或者携带告诉我们忽略默认True
的错误标志。
尽管二分搜索法是一个有教育意义的例子,但是只有少数学校问题会从先写测试中受益——例如快速排序。在编写旅行推销员问题、A*搜索算法和困难的神经网络反向传播算法时,学生们从未超越用固定数据——也许是教师要求的数据——手动测试程序。因此,当一项作业出现时,如果先写测试会有好处,学生可能不会考虑写测试。
唐纳德·克努特教授声称第一个二分搜索法于 1946 年出版,但第一个无 bug 的二分搜索法直到 1962 年才出版。乔恩·本特利在贝尔实验室和 IBM 的课程中报告说,他要求一百多名职业程序员在两小时内写出一个正确的二分搜索法。只有 10%产生了正确的算法。 3 令人难以置信的是,就连宾利出版的《二分搜索法》也包含了一个微小的错误。 4 既然如此,我怎么可能在 70 分钟内写出正确的二分搜索法呢?首先,我在编写代码之前编写了冒烟测试,这暴露了每次实践运行中难以发现的错误。其次,我对我完成的代码进行了一千次随机测试。
然而,70 分钟不包括编写测试代码的时间。又花了一个小时。当然,这额外的时间是人们不想先写测试的一个原因——或者根本不想写。
顺便说一下,有几个二分搜索法的属性,我错过了。第一个是计算的中间值(探针)的数量。对于长度为 2 n 的数组,应该有 n+1 个或更少的探针。我从没检查过这个。第二种是选择一个大到mid = (left+right)//2
会导致溢出的数组。这在 Python 中不能发生,但在 Java、C++和其他语言中可以。解决方法是mid = left + (right-left)//2
。(这是本特利的微小错误。)第三,我从来没有显式测试过所有值相等的数组,除了一个元素的数组。第四,目标元素是在偶数位置还是奇数位置有关系吗?在我的测试中,我从来没有想过这一点,但是在一千次运行中,偶数和奇数位置肯定出现过多次。我错过了什么吗?我永远也不会确定。
Footnotes 1
冒烟测试是针对常见情况的简单测试。术语“冒烟测试”显然来自硬件测试。打开它。如果设备开始冒烟,请将其关闭。测试结束了。
2
唐纳德·克努特,《计算机编程的艺术》,第 3 卷,分类和搜索,第 2 和版。(Addison-Wesley,1998 年),第 6.2.1 节。
3
乔恩·本特利,《编程珍珠》(艾迪森-韦斯利,1986),页 36。最常见的错误是无限循环。Bentley 的学生可能在纸上手写他们的代码,无法在计算机上测试他们的代码。
4
安迪·奥兰姆和格雷格·威尔逊编辑。,美丽的代码(奥赖利,2007),第 88 页。这里有整整一章专门讨论二分搜索法。
十四、专家意见
- 当然,一个小伙子不能指望不学习一些边远地区居民使用的困难的艺术和实践就一下子成为一个彻底的边远地区居民。如果你研究这本书,你会发现书中的提示告诉你如何去做——这样你就可以自己学习,而不是让老师来教你如何做。——巴登-鲍威尔勋爵,《童子军找男孩》(1908)的前言,在网上找到的。
这一章是我多年来收集的编程技巧列表。最重要的提示是阅读别人的代码,尤其是写得好的代码。
-
快速失败。例如,硬编程您的输入数据,因为每次运行都必须键入相同的输入是不必要的耗时。我曾经指派我的学生写一个程序,运行一个循环 100,000 次。这花了大约 20 秒。令人难以置信的是,一些学生试图用 100,000 这个数字来调试他们的程序。出于调试目的,他们应该将这个数字减少到 10,后来,当程序看起来工作正常时,将它改为 100,000。
-
使用垂直对齐来强调关系,使错误在视觉上突出,并使查找更容易。这就需要一个单倍行距的字体, 1 像快递。
VERTICAL ALIGNMENT M = [[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,-1, 1, 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, 0, 0,], [0, 0, 0, 0, 0, 0, 0, 0,],]
MORE VERTICAL ALIGNMENT for c in range(1,length-1): ch = chr(32) # = blank space = background color if L[c]== 1: ch = chr(9607) # = solid square = foreground color if maxx == max1: canvas.create_text(c*12 + 640-12*r, (r-1)*10, text = ch, \ fill = 'red', font = ('Helvetica', 8, 'bold') ) if maxx == max2: canvas.create_text(c*6 + 640- 6*r, (r-1)*4, text = ch, \ fill = 'red', font = ('Helvetica', 4, 'bold') ) if maxx == max3: canvas.create_text(c*4 + 640- 4*r, (r-1)*3, text = ch, \ fill = 'red', font = ('Helvetica', 2, 'bold') ) if maxx == max4: canvas.create_text(c*2 + 640- 2*r, (r-1)*2, text = ch, \ fill = 'red', font = ('Helvetica', 1, 'bold') )
-
尝试编写健壮的代码。健壮是脆弱的反义词。健壮代码是可以自我修复的代码,允许用户帮助它恢复,或者如果必须的话,优雅地崩溃。
-
避免全局变量。为什么呢?范围较大的变量很难跟踪以检测出意外的变化。另一方面,全局常数是可以接受的。我们用大写字母写常量名的一个原因是这样程序员就知道不要改变它们的常量值。也就是说,有些情况下全局变量是有意义的,例如,全局变量不是代码的一部分。假设您决定引入一个临时变量来计算递归函数回溯的次数。您不希望将这个变量传递给函数并增加已经很长的参数列表。只有在调试时才需要这个变量,它不会影响程序的工作方式。让它全球化。另一个可接受的全局变量是保存程序开始时间的全局变量,它可以用来打印各种函数中的时间增量。这是一个从不被其他代码修改的全局变量,它共享全局常量的属性。我的原则是,没有令人信服的理由,永远不要使用全局变量。
-
首字母缩写词 SESE 指的是一个函数有一个入口点(不要用
goto
)和一个出口点。单一入口点是有意义的。单一出口点限制太多。有时,返回或就地中断比展开多层循环更方便。在 Python 中,使用yield
的函数将从循环的最后一次迭代开始,所以从某种意义上说,您可以在两个不同的点进入函数。我怀疑 SESE 规则的最初动机是它使得证明程序正确性更容易。 -
避免编写返回两种不同数据类型的单个变量的函数。(通常第二种数据类型是错误的指示。)为什么?因为每次调用函数都要用一个
if
语句来调用。这使得函数更难使用。然而,有时你需要返回多种数据类型。考虑一下不起眼的二次公式。大多数学生都会编写一个 9 行的版本,如下所示:def quad(a, b, c): from math import sqrt disc = b*b-4*a*c if disc < 0: return 'There are no real roots.' x1 = (-b+sqrt(disc))/(2*a) x2 = (-b-sqrt(disc))/(2*a) if disc == 0: return x1 return x1, x2
这段代码返回一个字符串、一组两个数字或者一个数字。注意,这个版本没有考虑到三种情况,如果
a = 0
:all real numbers
,no roots real or otherwise
和-c/b
。它也不总是能得到极端数字的正确答案。如果a = 6
、b = 1073741900
、c = 7
,那么电脑有sqrt(b*b-4*a*c) = sqrt(b*b) =|b|
。两个根将(0.0, -178956983.33333334)
。但是零不可能是答案。通过检查,所有根必须是负的。接下来的版本会打印出正确答案:(-178956983.33333334, -6.519257560871937e-09).
def quad(a, b, c): #---Rescale all three coefficients to prevent overflow of b*b and 4*a*c. (Python # has 16-17 digits of accuracy.) Underflow is still possible. Mathematically # the roots are not changed by this process. m = max(abs(a),abs(b),abs(c)) if m != 0: a1 = a/m b1 = b/m c1 = c/m # Now the largest parameter (a, b, c) is 1. #---Special case 1: a = 0, b = 0, and c = 0. if a == 0 and b == 0 and c == 0: return 'All real numbers are roots.' #---Special case 2: a = 0, b = 0, and c != 0. if a == 0 and b == 0 and c != 0: return 'There are no roots (real or otherwise).' #---Special case 3: a = 0 and b != 0. if a == 0 and b != 0: x1 = -c/b # = the only root. #-Cast as int type if possible (optional). if x1 == int(x1): x1 = int(x1) # This turns -0.0 into 0. return x1 #---Bookkeeping. from math import sqrt disc = b*b-4*a*c #---Special case 4: sqrt of negative number. if disc < 0: return 'There are no real roots.' #---Special case 5: a != 0, b = 0, c = 0 (Needed for case 6.) if a != 0 and b == 0 and c == 0: return 0 #---Special case 6: Rationalize the numerator in one of the roots. Why? If b*b # is much much larger than 4*a*c, then sqrt(disc) = |b|. Consequently, # -b + sqrt(b*b) will be zero for b > 0, and -b - sqrt(b*b) will be zero # for b < 0\. We need the "+" and "-" signs reversed in these two situations. if b > 0: x1 = (-b-sqrt(disc))/(2*a) x2 = (-2*c)/(b+sqrt(disc)) # = (-b+sqrt(disc))/(2*a) else: x1 = (-b+sqrt(disc))/(2*a) x2 = (-2*c)/(b-sqrt(disc)) # = (-b-sqrt(disc))/(2*a) #---Cast as int types if possible (optional). This turns -0.0 int 0. if x1 == int(x1): x1 = int(x1) if x2 == int(x2): x2 = int(x2) #---Special case 7\. Only one root. if disc == 0: return x1 return x1, x2
这是写代码的问题代码从 9 行变成了 56 行,需要 19 行注释,需要理性化分子才能理解。值得努力吗?也许越糟越好。
- 在所有情况下,例如
a = 0
, - 打印输出——例如,
"–0.0"
应该打印为"0"
,并且 - 最大限度地发挥计算的极限,例如扩展和合理化。The code went from 9 lines to 56 lines, needing 19 lines of comments, and requiring rationalizing numerators to understand. Is it worth the effort? Maybe worse is better.
- 在所有情况下,例如
-
了解你的操作顺序(又名操作层次,又名操作优先)和你的布尔属性。谁写的这个:
a and b == True,
大概是这个意思:
(a and b) == True
网上有多个评论从来不写“==真”。一个原因是为了避免类似上述的问题。我反对。两个表达式
if x
和if x == True
在 Python 中并不总是等价的(例如x = 'a'
)。并且两个表达式if not x
和if x == False
在 Python 中并不总是等价的(例如x = []
、None
、'a'
)。在 Python 中,空字符串和列表的布尔值为False
。自然地,这使得程序员想写if stng: doSomething
而不是
if len(stng) > 0: doSomething()
或
if stng != '': doSomething()
更长的版本不仅可读性更好,而且保护代码不被
stng
变成None
或数字。回想一下,移位>>2
相当于将一个整数除以 4。因为移位比除法快(除非编译器被优化),你可以考虑用a + b >> 2.
代替
a + b/4
,但是这两个表达式是不等价的。圆括号是必需的。
a = 6 b = 4 print(a + b/4) # output: 7.0 print(a + b >> 2) # output: 3 print(a + (b >> 2)) # output: 7
给出输出:
print(2**3**2)
。答案在脚注里。 2 如果你必须查阅一个运算顺序,那么就用括号把它清楚地告诉读者。 -
要知道一般代码更容易复用,但是具体代码更容易写。除非您怀疑您将扩展一个函数,否则可能不值得您花费时间将其通用化。下面是我输入一个整数的 Python 函数,它使用了一个
try
/except
构造。这样我就可以捕捉任何类型的运行时错误。def dataInput(): s = 'Enter an integer:' posLimit = float('inf') negLimit = -float('inf') while True: try: data = input(s) num = int(data) # a non-int will raise exception. if not (negLimit < num < posLimit): raise Error #out-of-bounds? except: s = '"' + str(data) + '" is NOT an integer! \ Try again. \nEnter an integer:' else: print('input = ', num) return num
我决定重写上面的函数来打印两种错误消息,并接受输入边界的参数——而不是硬编程它们。结果是一个更复杂的函数。这是编程中常见的困境。我们接受更强大的,和/或更通用的 3 (可扩展性,在最初的设计中考虑了未来的增长)以增加编写时间、增加大小和增加复杂性为代价吗?答案往往是个人的。在这种情况下,我回到了上面的简单版本。(对我来说,越差有时越好。)我曾经写过一个 9×9 网格的数独求解器。然后我把它的一部分重写为一个 n×n 的网格。一般案件比具体案件短得多。不幸的是,它也很难调试。下面是 9x9 代码,后面是 n×n 代码。您更愿意调试哪个?
#---Build list of 9x9 blocks. block = [[],[],[], [],[],[], [],[],[],] block[0] = [matrix[0][0].value, matrix[0][1].value, matrix[0][2].value, matrix[1][0].value, matrix[1][1].value, matrix[1][2].value, matrix[2][0].value, matrix[2][1].value, matrix[2][2].value,] block[1] = [matrix[0][3].value, matrix[0][4].value, matrix[0][5].value, matrix[1][3].value, matrix[1][4].value, matrix[1][5].value, matrix[2][3].value, matrix[2][4].value, matrix[2][5].value,] block[2] = [matrix[0][6].value, matrix[0][7].value, matrix[0][8].value, matrix[1][6].value, matrix[1][7].value, matrix[1][8].value, matrix[2][6].value, matrix[2][7].value, matrix[2][8].value,] block[3] = [matrix[3][0].value, matrix[3][1].value, matrix[3][2].value, matrix[4][0].value, matrix[4][1].value, matrix[4][2].value, matrix[5][0].value, matrix[5][1].value, matrix[5][2].value,] block[4] = [matrix[3][3].value, matrix[3][4].value, matrix[3][5].value, matrix[4][3].value, matrix[4][4].value, matrix[4][5].value, matrix[5][3].value, matrix[5][4].value, matrix[5][5].value,] block[5] = [matrix[3][6].value, matrix[3][7].value, matrix[3][8].value, matrix[4][6].value, matrix[4][7].value, matrix[4][8].value, matrix[5][6].value, matrix[5][7].value, matrix[5][8].value,] block[6] = [matrix[6][0].value, matrix[6][1].value, matrix[6][2].value, matrix[7][0].value, matrix[7][1].value, matrix[7][2].value, matrix[8][0].value, matrix[8][1].value, matrix[8][2].value,] block[7] = [matrix[6][3].value, matrix[6][4].value, matrix[6][5].value, matrix[7][3].value, matrix[7][4].value, matrix[7][5].value, matrix[8][3].value, matrix[8][4].value, matrix[8][5].value,] block[8] = [matrix[6][6].value, matrix[6][7].value, matrix[6][8].value, matrix[7][6].value, matrix[7][7].value, matrix[7][8].value, matrix[8][6].value, matrix[8][7].value, matrix[8][8].value,] #---Build list of nxn of blocks. block = [] for n in range(MAX): block.append([]) for n in range(MAX): for r in range(blockHeight): for c in range(blockWidth): row = (n//blockWidth)*blockHeight+r col = (n%blockHeight*blockWidth) +c block[n].append(matrix[row][col].value)
-
避免所谓的神奇数字。幻数是由常数表示的数。如果您在整个程序中使用 10 作为数组的长度,那么您可能会发现自己在程序中将每一个与数组长度相关的 10 改为 100。更好的方法是将所有数组的长度设置为 MAX,也就是设置为 10。有一些小的例外。我们不需要
area = PI * radius ** TWO
里的TWO = 2
。我们不需要FEET_PER_MILE = 5280
,但也许我们需要评论# 5280 = feet-per-mile
。如果我们需要 10 秒钟的暂停,而10
在程序中只出现一次,那么也许10
比pause = 10
更好。secondsInAnHour = 3600 time = round(clock() - START, 2) # START is global time in secs. hours = int(time/secondsInAnHour) time -= hours * secondsInAnHour
另一个例外是使用蒙混因素。“忽悠”这个词在这里的意思是“欺骗”如果一个程序的结果总是相差 2,那么将所有结果加 2,并在代码中记录下来。如果最后期限到了,这也许是可以接受的。(为正确的工作使用正确的工具。 4 )但这是治标不治本。话虽如此,但有一个很大的例外——至少在我看来是这样的:一般比具体更难理解。当使用反向传播编写我的第一个人工神经网络程序时,我更喜欢使用幻数。那是我写过的最难的程序。我需要使它尽可能简单(更少的变量)。
-
不重复代码(干:不重复自己)。这是职业程序员的一大法则。下面是一个测试井字游戏输赢的函数。我更喜欢第二个版本。为什么呢?对一个部分的改变不需要对重复的部分进行改变。如果更改是一个 bug 修复,您可能不会想到在另一个没有执行的行中进行 bug 修复。
```py
FIRST VERSION
def result(board):
score = 'XXX'
B = board
if B[0] + B[1] + B[2] == score or B[3] + B[4] + B[5] == score or \
B[6] + B[7] + B[8] == score or B[0] + B[3] + B[6] == score or \
B[1] + B[4] + B[7] == score or B[2] + B[5] + B[8] == score or \
B[0] + B[4] + B[8] == score or B[2] + B[4] + B[6] == score:
return 'win'
score = 'OOO'
if B[0] + B[1] + B[2] == score or B[3] + B[4] + B[5] == score or \
B[6] + B[7] + B[8] == score or B[0] + B[3] + B[6] == score or \
B[1] + B[4] + B[7] == score or B[2] + B[5] + B[8] == score or \
B[0] + B[4] + B[8] == score or B[2] + B[4] + B[6] == score:
return 'win'
return 'unk'
```
```py
SECOND (BETTER)
VERSION
def result(board):
B = board
for score in ('XXX', 'OOO'):
if B[0] + B[1] + B[2] == score or B[3] + B[4] + B[5] == score or \
B[6] + B[7] + B[8] == score or B[0] + B[3] + B[6] == score or \
B[1] + B[4] + B[7] == score or B[2] + B[5] + B[8] == score or \
B[0] + B[4] + B[8] == score or B[2] + B[4] + B[6] == score:
return 'win'
return 'unk'
```
不重复自己是在因式分解共性。我曾经编写过一个程序,对一个特定的目标节点运行四种不同的深度优先搜索:查找任意路径、查找最少节点路径、查找最小成本路径和查找所有路径。主函数是一堆函数调用和打印结果。给我想要的简单性的是将普通的打印代码分解成一个`printResults`函数。我的代码如下。
```py
def printResults(root, goal, path1, path2, path3, distance, pathsList):
print(' == DFS SEARCHING ==')
print('1\. Random path from', root, 'to', goal, 'is', path1)
#--------------------------------------------------------------
print('2\. Fewest-nodes path from', root, 'to', goal, 'is', path2)
#--------------------------------------------------------------
print('3\. Shortest-dist path from', root, 'to', goal, 'is', path3,
'(', distance,'Km.)')
#--------------------------------------------------------------
if pathsList == []:
print('4\. There are no paths.')
return
print('4\. All paths from', root, 'to', goal, 'are listed below.')
count = 0
pathsList.sort(key = len)
for path in pathsList:
count += 1
print('--%2d'%count, '. ', path, sep = '')
print('\n---TOTAL search time =', round(clock() - startTime, 2),
'seconds.')
#=============================<MAIN>===========================
def main():
root = 'A'; goal = 'B'
path1 = DFS_AnyPath (root, goal)
path2 = DFS_FewestNodes (root, goal)
path3, distance = DFS_ShortestCostPath (root, goal)
pathList = DFS_AllPaths (root, goal)
printResults(root, goal, path1, path2, path3, distance, pathList)
```
关键是主函数现在简单易懂了,因为所有的打印都被推到了`printResults`函数中。
- 不要为了速度或内存使用而优化。这是初学者可能犯的最大错误之一。如果要优化的话,只在程序写完之后。如果一个人在 64 个方向上爬山,也许我们可以通过预先计算 64 个正弦和余弦来优化,并将它们放在一个查找表中,而不是每一步都重新计算它们。话又说回来,如果你的程序足够快,何必呢?有一次我尝试这样做,时间只减少了 23%。我们需要文件中二进制表示的速度吗?根据我的经验,答案是否定的。文本文件更好,因为它们更容易使用和直观检查。如果优化(提高速度、减少内存需求、提高准确性、减少代码行数)会使代码块更加难以理解,那么您必须进行成本/收益分析。目标值得努力吗?你的时间难道不能用来做些别的事情吗?更好是足够好的敌人。有时候,少就是多。* * *
* 玩最好的招式并不总是一个好主意,尤其是当你不得不花很多时间去寻找它们的时候。——西蒙·韦伯,老虎象棋第三版。(巴茨福德,2005),第 15 页。If optimization (increasing speed, reducing memory needs, increasing accuracy, decreasing lines of code) will make a block of code much harder to understand, then you must do a cost/benefit analysis. Is the goal worth the effort? Couldn’t your time be better spent doing something else? Better is the enemy of good enough. Sometimes, less really is more.
* 我的代码优化方法是什么?99%的情况下,简单粗暴的方法就能奏效。—Ken Thompson(贝尔实验室,UNIX 的创造者,UTF-8 的设计者),见 Peter Seibel,Coders in Work(Apress,2009),第 470 页。* * *
* 我认为性能在计算机科学领域被大大高估了,因为你在性能方面需要的是足够好的性能。不需要最好的表现。—Barbara Liskov (2008 年图灵奖获得者),见于 Edgar G. Daylight,《软件工程的黎明》(比利时:孤独的学者,2012),第 155 页。 - 不要写聪明的代码。 5 聪明的代码是滋生 bug 的温床。在下面的等价例子中,A 是最好的,因为它最容易理解,也最容易调试。
```py
#---A.
if random() < 0.8:
theta += 0.3
else:
theta -= 0.1
#---B.
theta = theta - 0.1 + (random()<0.8)*0.4
#---C.
theta += [-0.1, 0.3][random() < 0.8]
#---D.
theta += choice ([-0.1, 0.3, 0.3, 0.3, 0.3])
```
简单代码:
```py
if x > y: z = z + 3
if x <= y: z = z - 5
```
巧妙代码(避免):
```py
z = z + 3*(x > y) - 5*(x <= y)
```
有几种方法可以模拟 Python 中不存在的“switch”语句。问这些结构是否是聪明的代码是一个好问题。在旧代码中必要的巧妙技巧在现代语言中可能不再需要。例如:
1. 整数`num`由多少位数字组成?
```py
print('length =', 1 if num == 0 else floor(log10(abs(num))+1))
print('length =', len(str(abs(num)))) # simpler
```
2. 确定整数`num`右侧的第三位数字。
```py
print('third digit from right =', (abs(num)//100)%10)
print('third digit from right =', int(str(num)[-3])# simpler
```
3. 确定整数`num`左边的第三个数字。
```py
length = 1 if num == 0 else floor(log10(abs(num))+1)
print('third digit from left =', abs(num)//pow(10, length-3)%10)
print('third digit from left =', int(str(abs(num))[2])) # simpler
```
- 当心剑桥教授查尔斯·巴贝奇(1791-1871)的诅咒——或者更确切地说,是降临在巴贝奇教授身上的诅咒。查尔斯·巴贝奇可能是第一个将现代计算机概念化的人。他请求英国政府拨款建造差异引擎 1 号。在建造这个东西的过程中,他意识到它可以做得更好。他放弃了最初的设计,重新开始。通过差异引擎 2,他有了更多的见解,并重新开始(分析引擎)。当助学金(1842 年 17000 英镑)用完时,他仍然没有电脑。事实上,他从来没有造过电脑。对于业余程序员来说,这个教训就是要记录下改进的日志,以构建到下一个设计中。不要将它们合并到当前的项目中(特性蔓延),否则你可能永远也完成不了。
- 考虑结对编程,而不是通常的单独编程。这意味着需要一个合作伙伴。驾驶员键入代码,而导航员在一旁观看并提出建议。最终,他们交换了位置。许多优秀的程序员宁愿写自己的代码,也不愿意带着一个较弱的同学。有消息称工业程序员需要大约 8-12 个小时来适应这个过程。缺点是两个程序员编写一个程序要比他们单独工作多花 15%的时间。 8 结对编程的好处是:程序的 bug 明显更少,可读性更强。程序员们互相学习,学生程序员也获得了一些与他人合作的经验。结对编程在工业界很流行。和两个不同的伙伴试两次。在我职业生涯的大部分时间里,我要求我的学生根据戴尔·卡内基的《如何赢得朋友和影响他人》(1936 年首次出版,目前亚马逊上有 6000 多条顾客评论)写一篇文章。我公开的理由是计算机科学需要人们在团队中工作。但真正的原因是,太多人的人际交往能力很弱,实际上需要阅读这本书。他们需要被说服,以避免争论,很少批评,提供真诚的——只有真诚的——赞美,让其他人说更多的话。你我遇到过多少人会不必要地引起摩擦,却懒得对周围的人说简单的感谢之词?二十年后,我的学生感谢我布置了这本书。
* 不能进行团队合作的优秀程序员不应该让自己处于被传统编程职位聘用的境地——这对所有相关人员来说都是一场灾难,他们的代码对任何继承它的人来说都将是一场噩梦。我实际上认为,如果你不能进行团队合作,那就是缺乏才华。——吉多·范·罗苏姆(Python 语言的创始人),见于 Frederico Biancuzzi 和 Shane Warden,《编程大师》(O'Reilly,2009),第 28 页。For most of my career I have required my students to write an essay based on reading Dale Carnegie’s How to Win Friends and influence People (first published in 1936, and currently with over 6000 customer reviews on Amazon). My public justification was that computer science requires people to work in teams. But the real reason is that too many people have weak people skills and actually need to read this book. They need to be convinced to avoid arguments, to rarely criticize, to offer sincere—and only sincere—compliments, and to let other people do much of the talking. How many people have you and I met who needlessly cause friction and don’t bother to give simple words of appreciation to those around them? I have had students thank me twenty years later for assigning this book. - 当心专家的建议。刚刚给了你专家的建议,其中大部分是常识,为什么要警告你呢?因为专业软件开发人员的工作环境与 C.S .学生大不相同。软件专业人员是一个不断变化的团队的一部分,该团队致力于遗留代码的大型项目的发展。他们编写的软件通常面向需要便捷界面的最终用户。团队协作至关重要。编程风格的一致性是必要的。软件设计师提出的以下问题,学生很少会问:相比之下,学生程序员,尤其是高中生,只是试图编写将在老师面前运行一次的算法。
1. 这个程序容易安装吗?
2. 它会根据可用的计算机内存进行自我调整吗?
3. 界面直观吗?
4. 用户可以修改界面吗?
5. 学习曲线陡吗?
6. 用户能很快得到结果吗?
7. 软件是否在需要的地方向用户提供性能警告?
8. 是否检测到错误输入并通知用户?
9. 该软件是否依赖于互联网网站,这些网站可能会改变或关闭?
10. 它能处理其他软件生成的文件吗?
11. 可以自动更新吗?
12. 它在几个操作系统上运行吗?
13. 是否经过潜在用户的良好测试?
14. 它的设计是否考虑到了未来的增强?
15. 客户支持容易吗?
16. 其数据是否安全并受到保护?In contrast, the student programmer, especially in high school, is only trying to code up algorithms that will be run one time in front of the teacher.
Footnotes 1
字体和类型或字样经常互换使用。字体与字样的属性相关联,例如,Calibri 斜体、Calibri 粗体或 Calibri 单倍行距都是 Calibri 字样的不同字体。字体是指字符的核心形状。罗伯特·哈里斯在《视觉风格的元素》(Houghton Mifflin,2007)中声称,字体分为四大类:衬线字体(有像 Times Roman 这样的扩展字体)、无衬线字体(没有像你正在阅读的 Calibri 字体那样的扩展字体)、手写字体(像 Lucinda 手写的草书字体)和新奇字体(像 Juice ITC)。
2
232 = 2(32) = 512.堆叠指数是我所知道的唯一从右向左计算的代数表达式。
3
有时,您希望设计通用的代码,并且可以轻松扩展以处理更大的数据集。这只有在代码也是可伸缩的情况下才有意义。如果一个程序或算法在小数据集下工作良好,但在大数据集下效率明显较低,则该程序/算法是不可伸缩的。例如,当数据大小增加时,插入排序 O(n 2 )比冒泡排序 O(n 2 )更具可伸缩性,但比 O(nlog(n))排序的可伸缩性差。二分搜索法 O(log(n))具有极强的可伸缩性,哈希表 O(1)对于任何大小的数据集都具有良好的可伸缩性。(不幸的是,在哈希表中保存可搜索数据所需的内存必须比保存数据实际所需的空间多 50%到 100%。随着数据集的增加,哈希键也必须改变。)Python 对于小程序(1000 行以下)来说很棒,但是对于大程序来说就不行了——也就是说,这种语言是不可伸缩的——这主要是因为它缺乏类型检查,并且是一种解释型语言。参见维基百科,s.v .,可扩展性。
4
至少从 1907 年开始,这就是“真正锻炼工具”的广告口号。
5
同样的建议也适用于写作。当文字变得引人注目时,它就转移了它所表达的思想。这是散文和歌词的一个区别。
1.“无论何时,当你有一种冲动想写一篇特别好的文章时,全心全意地服从它,然后在把你的手稿送到出版社之前删除它。谋杀你亲爱的。”——阿瑟·奎勒-库奇爵士,《写作的艺术》(G.P .普特南的儿子,1916),第 281 页。
2."杀了你的宝贝,杀了你的宝贝,即使这会伤了你这个自私的小流氓的心,也要杀了你的宝贝。"——斯蒂芬·金,《论写作》(西蒙&舒斯特出版社,2000 年),第 224 页。
3."寻找所有花里胡哨的词语,并去掉它们."——雅克·巴尔赞,《简单而直接,作家的修辞》(哈珀与罗出版社,1975),第 27 页。读这本书。巴尔尊是公认的天才。
4.通读你的作文,每当你看到你认为特别好的一段,就把它删掉。[这是一位大学导师的陈述,由约翰逊博士在 1773 年回忆。资料来源:詹姆斯·包斯威尔的《塞缪尔·约翰逊传》(1791)。]
5.每隔一段时间,你就会冒出一个似乎有自己生命的短语或段落。它是你希望自己能一直做到的那种聪明和机智的结合。当你写这种东西的时候,使劲咽下去,然后扔掉。两个月后,你会认出这是一篇无关紧要的紫色散文。—P.J. Plauger,计算机语言(1991 年 10 月),“技术写作”,第 32 页。
6
参见《现代世界中的数学》,《科学美国人读本》(W.H. Freeman,1968),第 53-56 页。2002 年,巴贝奇差速发动机 2 终于建成。它耗时 17 年完成,包含约 8000 个零件,重量近 5 吨。
7
我父亲是一名出色的扑克玩家。他曾经提到他年轻时是一个狂热的桥牌手。当我问他为什么放弃比赛时,他回答说“因为我的搭档一直是个白痴。”(我父亲和其他人相处得不好。)这是有才华的程序员可能不想被指派为合伙人的一个原因。而且,一个人做所有事情的挑战和乐趣都被冲淡了。
8
Andy Oram 和 Greg Wilson,编辑,制作软件(O'Reilly,2011),314 页。
9
如果有人曾经为编写代码列出了十条戒律,我有一个关于第十一条戒律的建议:当心古鲁、牧师、解释者和教条。你应该为自己考虑。
十五、一堂设计课
索科尔斯基先生从他的桌子上站起来,走到教室前面的电脑控制台前。这是他在史摩维尔高中教高级计算机科学课的第四天。他接替了休病假的帕姆·琼斯。索科尔斯基面对的是一大堆毫无笑容的面孔。
“我假设每个人都已经完成了第一项任务。稍后我会过来检查你的每一个程序。但是我想让一个学生为全班演示他或她的程序。我可以有一个志愿者吗?”
没有人举手。索科尔斯基先生拿起他的年级记录本,浏览学生名册。
"罗杰,在 1 到 26 之间选一个数字."
“好的,七个,”学生回答道。
“七,那是,让我们看看,那是安娜。安娜,请你上来演示一下你的程序好吗?”
安娜是个聪明的大三学生。她在琼斯小姐的指导下做得很好,她对教学的热情和对编程的热爱得到了她所有学生的赞赏。索科尔斯基先生与众不同。他以前从未当过高中老师,这一点在他的第一项任务中表现得很明显。
安娜拿着她的笔记本电脑来到控制台前。她接上了视频电缆,输入了她的程序名:mult
。屏幕上出现了一个问号。她输入了 2。又出现了一个问号。她输入了 3。答案 6 出现了,后面又是一个问号。安娜一言不发地走回她的座位。从她生硬的态度来看,她显然认为这项任务是浪费时间。
“对不起安娜,但我不能给你这个节目任何荣誉。就是缺。”
安娜看上去既困惑又恼火。“但这正是你要求的。将两个数相乘的程序。”
“好吧,让我想想,”索科尔斯基先生说。他拿出一份作业,读了起来。写一个程序,接受两个数字作为输入,并打印它们的乘积。假设这个任务是一个更大的商业项目的一部分,也就是说,它需要一个用户友好的界面,并且必须包含重要的功能。当然,你的程序必须重构。总是努力付出比预期更多的东西。”
“我做到了,”安娜说。“你还想要什么?”
“好吧,一个用户应该知道他正在运行什么程序。你需要一个标题和一些用户说明。你的问号是什么意思?对用户要明确。你的程序似乎会永远运行下去。用户应该如何退出程序?从笔记本电脑上取下电池?所以让我问你一个问题。在程序打印出答案后,你认为用户应该看到一条信息说“输入 X 退出”,还是“输入 C 继续添加”?这是个好主意吗?”
“嗯,是的,我想是的。但就是这么简单的程序,谁在乎呢?”安娜说。
“给你打分的人会在乎,不管那有多重要。但无论如何,我的建议实际上是一个可怜的建议。我们不希望用户输入不必要的信息。当请求第一个数字时,用户指令可能是,“输入一个数字或“X”退出。”因此,用户只需输入另一个数字即可继续,而不是先输入“C”再输入一个数字。"
安娜看上去有些恼怒,但什么也没说。
“好吧,也许我没有把任务讲得足够清楚。所以我有个主意。在这期间做这个,我们将在 30 分钟后回到教室再次讨论这个项目。而且一定要付出比预期更多。如果你提前完成了,那就开始第二项任务。”
三十分钟后,安娜再次展示了她修改过的程序。
“你给我的比我要求的多吗,安娜?”
“我给了你想要的,”安娜说。
“嗯,也许这就够了。安娜,我可以建议你输入的两个数字吗?”
“当然可以。”
"好,我希望第一个数字是 1 . "
安娜走进了 1 号。
"第二个数字是三分之一,也就是 1 后面跟一个斜杠,后面跟一个 3 . "
“这样的数字是行不通的,索科尔斯基先生。我已经试过了。你必须像这样输入它们。”
安娜输入了0.333333333
。她的程序打印了0.333333333
。
“好吧,但是假设我不希望输出中有那么多小数位。为什么不给我一个选择,安娜?”
"你说用户不应该被问题纠缠,所以我听从了你的建议."
“实际上,我说过不应该要求用户输入任何不必要的信息。所以为什么不把缺省的小数位数设为两位呢,除非输出是整数。并允许用户输入一个数字,输入“X”退出,或者输入“P”将默认精度从 2 更改为 1。这样用户就可以忽略这些选项。”
阶级反应是各种形式的烦恼。
“好吧,那就把这个程序搞定,我周一再看看大家的作品。祝你周末愉快,经常想起我。”
索科尔斯基的讽刺并没有被接受,但他在第一天就与一名学生发生了冲突,其他学生对他的愤怒感到厌倦。
周一,安娜再次用索科尔斯基之前用过的数字演示了她的程序。
“到目前为止还不错,安娜。但是你给我的比我要求的多吗?”
“你告诉我,索科尔斯基先生。”
“好,输入 2 作为你的第一个数字,然后输入字符串‘happy’我想知道两倍的快乐是什么?是的,我想知道那是什么数字。"
安娜输入了 2,后面是“快乐”
程序回答“不是一个数字。请重新输入一个数字。
“啊,非常好的安娜。你预料到了我的无理而扭曲的要求。但是如果我只按回车键,什么都不输入呢?”
“这也是行得通的。我测试过了,”安娜说。
“好吧,那这个程序就改进得多了。但是一个改进是不仅仅打印一个数字的输出,而是打印第一个输入,后面跟着一个乘号,第二个输入,后面跟着一个等号,最后是乘积。这更好,因为它允许用户检查输入错误。所以现在需要这样做。”
他转向全班。
“你们上周写的这个程序的第一个版本可能是一个很好的初稿。我是认真的。当程序不能正确地乘以 2 乘以 3 时,你可以花费大量的时间为特殊情况编程,没有浪费。所以首先要有一个工作的基本版本,并保持它的工作状态。然后为特殊情况编写代码,就像我刚才给安娜的两个例子。有时您需要首先编写测试,有时自动化测试。但那是另一个教训。”
“有四种电脑错误。大多数学生只知道一个。大多数学生认为,如果他们的程序在所有合理的情况下每次都做了它应该做的事情,那么程序就完成了。但这是不真实的。像这样的程序只消除了第一类错误:编译错误和逻辑错误。第二种错误是代码可读性错误或样式错误。关于某人的代码,你能说的最糟糕的事情是,为了调试它,或者修改它,你必须从头开始重写它。第三种错误是功能性错误。程序是否拥有用户想要的所有特性?第四种错误是接口错误。这个程序使用起来直观吗?程序是否在正确的时间给用户他或她需要的信息?由于我们大部分的学校作业都是实现算法,所以很少遇到功能和接口错误。”
“那么,我来问你。你能考虑给我们的“mult”程序更多的功能吗?为什么一个学生会启动我们的程序而不是去拿计算器?我们的程序在将两个数字相乘时可能会做些什么,而这是计算器不容易做到的?”
全班鸦雀无声。
"好吧,我们就坐在这里,直到有人想到办法为止。"
过了一会儿,一个学生举起了手。“我们可以让用户输入一个带逗号的大数字,因为如果它是一个巨大的数字,这使得数字更可读。”
“好吧,我们可以这样做,这样会使程序更好。这是一个很好的建议,但是为了我们的目的,我将忽略逗号。这将是一个小功能的大量工作。我认为我们的时间可以更好地利用。那么,我们还能让我们的程序做些什么呢,把它限制在两个数相乘?”
另一个学生举起了手。“你可以放入‘1/3’。我的意思是我们可以为此编程。”
“这是一个非常好的特性,但是这看起来是不是需要大量的工作?我的意思是你必须扫描输入的斜线,然后试着读出两边的数字。不过,我还是喜欢你的想法。我们还能给这个项目增加什么?
全班又一次安静下来。
"所以,我想,我们明天再考虑这个问题."
安娜举起了手。"我想我们可以允许用户改变两个输入数字的基数."
“是的,一点不错。每个输入数字都有自己的基数。当然,在内部,我们以十为基数工作。我们只需要把 b 进制的输入转换成十进制。我们如何做到这一点?嗯,int casting 的过程非常简单。下面是 6 进制 23 的代码,10 进制 15:int('23',6)
。这有点尴尬,但只有在不使用 base 10 时才需要它。
但是‘1/3’的输入不会直接翻译成数字。那么有没有人知道如何在不扫描的情况下做到这一点,这将是太多的工作?"
全班又一次安静下来。
“我将建议一个你们大多数人不知道的技巧:精彩的 Python eval
指令,它是一个表达式解析器。在网上查一下,但是这里有一个例子,把 6 进制的 23 乘以‘1/3’:
input(x) # x = "int('23',6)*1/3"
print(eval(x)) # Output: 5.0
“事实上,用eval
你可以计算任何算术表达式。所以,在角落里拿起我的《如何计算算术表达式》讲义,然后到你们的电脑上。”
当安娜坐下后,她去网上找了一些关于eval
命令的例子。这个问题开始引起她的兴趣。她没有想到任何人需要将不同基数的数字相乘是不切实际的。总是坐在安娜旁边的伊丽莎白靠了过来。"安娜,你能理解这项任务吗?"
“是的,我想我马上会的。”
“好吧。我想我会等你完成,然后得到你的帮助。”
伊丽莎白的帮助想法是复制一些安娜的代码。安娜注意到索科尔斯基先生似乎从来没有注意到任何抄袭。他偶尔警告班上的同学要当心从同学那里获得太多的帮助,但鼓励学生用代码想法互相帮助。安娜一度想知道为什么伊丽莎白不能自己写很多代码。只是看起来没那么难。安娜停下来,环顾四周。爱丽丝也在阅读关于eval
函数的内容。数学天才尤里已经开始编程了。她听到阿维告诉大卫,“你写精确部分,我写计算部分。”其他学生在网上聊天或玩耍。他们中有太多的人在侍候他们的同学。安娜突然想到这门课只对几个学生开放。其他人只是记忆和复制代码的关键部分,并且似乎满足于这样做。这只是世界上又一件对安娜来说没有意义的怪事。
安娜继续看书。
伊丽莎白在安娜的键盘旁边放了一块新口香糖。
结局
我偶然看到一本有趣的 C.S .的书,名为《测试计算机软件》,第二版。Kaner、Falk 和 Nguyen(Wiley,1999 年)。这本书有一个附录,列举了 340 多个常见的软件错误。大多数错误都与界面和功能有关,这是工业界比计算机科学班的学生更关心的问题。在第一页,作者有一个惊人的例子,引起了我的兴趣。他们要求读者考虑编写一个不超过两个数相加的程序。然后他们用这个简单的例子来说明在设计和测试中什么是行业认为合适的。
首先,他们考虑了接口。用户知道程序应该做什么吗?用户知道他们在程序中的位置吗?有屏幕说明吗?他们清楚了吗?有没有对安全的执念?用户如何停止程序?输入是否与最终答案一起显示?它们是否排成一行或以视觉上吸引人的方式展示?
接下来是关于功能的问题。输入不正确会发生什么?它中止了整个程序还是用户有机会改正它?数字前后可以有空格吗?两个输入基数可以换吗?精度可以改变吗?
因为我作为老师和学生的学校作业很少包括关于接口和功能的问题,所以我决定写这个程序,把加法改为乘法。
我的第一步是编写代码将基数 b 改为基数 10。我写的代码允许用户输入一个“B
”或“b
”,而不是一个数字,如果他想改变一个基数。但是这使得界面不方便。我放弃了这段代码,编写了新的代码,允许用户输入一个数字或者一个以数字为基数的元组——比如(12,8)
。但是这对用户来说太多了(括号和逗号)。我再次放弃了我的代码,编写了新的代码,允许用户输入一个数字、两个数字(一个带基数的数字)或三个数字(一个带基数的数字,后跟所需的小数精度)。这需要我用空格或逗号来分隔数字。如果输入了多余的空格,我就必须把它们去掉。
所有这些重新设计和编程工作持续了几天。花费最多时间的是考虑如何检测无效的用户输入。编程变得既令人沮丧又耗时。我敢给学生布置如此折磨人的作业吗?我学到的唯一一课是,与学术项目相比,消费者项目有多难。事实上,两天过去了,没有任何编程。
最终,我有了一个新想法,允许在同一行上输入两个输入数字,仅用星号将它们分开。然后突然想到了 Python eval
函数。这个函数与一个try/except
块和一个整型转换成一个基数相结合将会使所有这些讨厌的问题消失。我写了一些测试代码,发现sqrt
、log
等 Python 内置函数被eval
完美评估。甚至多余的空格都被eval
忽略了。我的实际程序突然变得容易编写了。
所有这些花了五天的时间思考和编程。为什么我没有马上想到用eval
?答案是,在我去寻找新的想法之前,我必须对我的代码感到不满意。只有某种失败促使我寻找新的设计。我想这可能是我改进代码设计的唯一方法。在这五天里,我个人学到了很多东西。不幸的是,学生们每门课的时间有限,不允许像这样的作业被分配到课堂上。只有少数学生能够取得进步,而大多数学生可能都是靠自己取得这样的进步的。能给学生的,就是想象中的索科尔斯基先生给的那种作业。在几乎所有的最初几堂计算机科学课中,基本思想需要尽早给出,学生们只是通过将各部分放在一起或通过查找关键主题来建立编程技能。在一些简单的版本被证明不合适之后,索科尔斯基先生更进一步,允许这项任务不断发展。下面是我最后的节目。也许你能明白为什么这个程序花了我五天时间。
"""+==========+========-========*========-========+===========+
|| The Multiplying Program ||
|| by M. Stueben (October 8, 2017) ||
|| ||
|| Description:See printDirections(). ||
|| Language: Python Ver. 3.4\. ||
|| Graphics: None ||
|| References: Cem Kaner, Jack Falk, Hung Quoc Nguyen, Testing||
|| Computer Software, 2nd Ed. (John Wiley, 1999), ||
|| pages 1-7\. ||
+==========+========-========*========-========+===========+
"""
#####################<START OF PROGRAM>########################
def printDirections():
print('+-------------------------------------------------+')
print('| == THE MULTIPLICATION PROGRAM == |')
print('| by M. Stueben (Ver. 1.0, August 2017) |')
print('|DIRECTIONS: |')
print('|1\. Enter a first number, followed by an asterisk (*),|')
print('| followed by a second number. Examples: |')
print('| 5280 * 3.14, (-27 + 6) * (1/3), sqrt(100) * log(10). |')
print('|2\. Push enter to see the output. |')
print('|OPTIONS: |')
print('|3\. Enter X to exit the program. |')
print('|4\. Enter P to change the precision (default = 2) of any |')
print('| float output. |')
print("|5\. To enter, say 21 in base 19, type int('21',19). |")
print('| Special case: 0X12 and 0x12 both are 18 in base 10\. |')
print('|6\. The user will be requested to re-enter any bad input. |')
print('+------------------------------------------------+')
print('\n RESULTS:')
#------------------------------------The multiplying program--
def requestPrecisionFromUser():
msg ='Choose the decimal precision of your answer (from 0 to 17):'
while True:
data = input (msg)
ch = data.strip()
if ch in {'X', 'x'}:
print (' Goodbye.')
return
try:
precision = int(data)
if (precision < 0)or(precision> 17)or(type(precision) != int):
raise Error
except:
msg = 'Bad input. Choose a non-negative integer (0 to 17).'
continue
return precision
#------------------------------------The multiplying program--
def requestAndMultiplyTwoNumbers():
#---Initialize.
from math import sqrt, log, log10
precision = 2
problemCounter = 0
errorMsg = ''
while True:
msg = errorMsg \
+ 'Enter expression * expression, P (precision), or X (exit).'
data = input(msg) # Dialog box
#-------Check for 'X or x'.
ch = data.strip()
if ch in {'X', 'x'}:
print (' Goodbye.')
return
#-------Check for 'P or p.
if ch in {'P', 'p'}:
precision = requestPrecisionFromUser()
errorMsg = ''
continue
#-------Attempt to calculate an answer.
try:
answer = eval(data)
if not isinstance(answer,(int, float)): raise exception
errorMsg = ''
except:
errorMsg = '============ BAD INPUT ===========\n'\
+ 'You entered --> ' + data +'.\n'
continue
#-------Print the answer.
# Sample output: "1\. 1.23 * 4.56 = 5.61 [decimal precision = 2.]"
problemCounter += 1
if type(answer) == float:
print(' ', str(problemCounter) + '. ', data, ' = ', \
round(answer, precision), \
' [decimal precision = ', precision, '.]', sep ='')
else:
print(' ', str(problemCounter) + '.', data, '=', answer)
#==========================<MAIN>===========================
def main():
printDirections()
requestAndMultiplyTwoNumbers()
#============<GLOBAL CONSTANTS and GLOBAL IMPORTS>============
if __name__ == '__main__':
from time import clock; START_TIME = clock(); main(); print('- '*12);
print('RUN TIME:%6.2f'%(clock()-START_TIME), 'seconds.');
#######################<END OF PROGRAM>#######################
问题:为什么允许甚至向学生介绍eval
功能?网上到处都是远离这个 Python 函数的警告。为了测试eval
有多危险,我在我的 Windows E
目录下创建了一个名为filex.py
的虚假文件。然后,我通过在 Python 中运行这一行来销毁文件。
eval("__import__('os').remove('e:filex.py')")
我想这一行对于让一个试用程序自行删除可能是有用的。
只有当eval
函数接受来自不可信来源的用户输入时,它才是危险的。由于学生通常是唯一能接触到他或她自己的代码的人,这种担心对于学校的问题是没有根据的。eval
函数可以在程序中创造奇迹,就像这里一样。向学生介绍eval
是一个讨论eval
可以对恶意代码做什么的机会,更有趣的是,是什么促使人们变得恶意。
对eval
函数的讨论,以及对某些编程风格的绝对坚持,很容易变成情感的争论,而不是逻辑的争论。
下面的大纲是我信奉的一种设计方法论,但就像他们在 Zen 里说的,必须经历才能欣赏。
如何着手一个重大的计算机科学项目
- 留出比你认为你需要的更多的时间。你可以花很多时间在一个程序上,但除了一些关于如何不写程序的见解之外,你没有什么可以展示的。
- 计划专注。这意味着远离那个诱人但健谈的同学。如果你有一个伙伴,那么考虑结对编程。
- 理解问题(=分析+程序规范)。这可能意味着构建一些例子。你也在寻找关系和洞察力。
- 选择你的数据类型,然后设计/重新设计你的程序。
- 制作一个最小的设计。首先编写必须具备的功能,因此,这是一个早期的工作程序。后来,该有的功能都加了进去。最后,如果可能的话,编写函数。[在一个聪明的井字游戏程序中,第一个版本将是一个程序,其中计算机合法地玩,但随机地移动。]
- 预计最初的设计可能很差,您的数据类型可能需要更改。
- 写代码。
- 使用逐步细化和自我文档化的代码(很少注释)。
- 使用断言和错误陷阱。
- 写完之后测试每个关键函数(白盒测试)。
- 在编写复杂的算法之前,考虑编写一个粗糙的测试函数。
- 考虑在编写一个复杂的算法之后,用数百个随机输入来测试它。
- 根据新的见解、编程困难、用户反馈以及可能变化的规范,根据需要经常返回步骤 4 重新设计程序并改变数据类型。再次强调,接受最初的版本经常被证明是失败的,这确保了在第二个或更高版本中的成功。
- 通过测试整个程序(黑盒测试)来修复最终的错误。你可能忽略了一些特殊情况或边缘情况。
- 重构整个程序。这是你学习编程的地方。
- 反思你的错误和吸取的教训。
十六、当心 OOP
- 我的观点是 OOP 是对社区犯下的最大的欺诈之一。事实是 OOP 最重要的一个方面是几十年前设计的方法:子程序和数据的封装。其余的都是糖霜。我曾经说过封装是对象编程所提供的 70%,但是我想我要把它改成 90%。—Thomas Kurtz,见于《编程大师》(O'Reilly,2009),第 91 和 93 页。[达特茅斯大学的托马斯·库尔茨教授和约翰·凯米尼教授在 1963-64 年间共同开发了 BASIC 语言。Kemeny 在 1986 年获得了 IEEE 计算机先锋奖,同样的工作,Kurtz 在 1991 年获得了该奖。这次采访时,80 岁的库尔茨已经退休 15 年,不再写代码了。]
- Potok 等人的一项研究表明,OOP 和过程化方法之间的生产率没有显著差异。—维基百科,面向对象编程。
时隔多年,OOP 仍然备受争议。1c++语言(C 带类)并没有取代 C 语言。对类的一个宣称的理由是通过继承的代码重用(an 是一种关系)。当然,我们已经通过剪切粘贴和导入库文件(模块)实现了代码重用。一些类与它们的应用耦合得如此紧密,以至于它们不容易被重用。通过类重用代码的优势在工业界比在学校问题中更受重视。根据没有继承的对象和类进行编程有时被称为基于对象的编程。
也就是说,我曾经使用继承将四个处理向量的函数(方法)导入到一个Vector
类中。那些函数只适用于我使用向量编程的特定问题,我不希望我的Vector
类被重新设计。但这更多的是两个类的组合(a 有一个关系),而不是继承。
继承的另一个优点是,对父代码的单个更改就是对其所有子代码的更改,因为所有子代码的共性只存在于一个地方:父代码。然而,即使对于没有类的程序,通用性也可以被分解到函数中。
类最有用的优点是封装(将函数和数据捆绑成一种新的数据类型,一种抽象, 2 并创建一种迷你语言来操纵它们)。如果类模拟了现实中的某些东西,甚至是程序员对某个问题的观点,那么程序员就可以根据对象而不是它们的单个部分来思考和编写代码。从物体的角度思考就像从和弦而不是单个音符的角度思考音乐一样。这听起来很棒,但是我从未遇到过从抽象数据类型中受益匪浅的有价值的问题。我所遇到的是人为的问题,这些问题被设计成需要封装以供学生学习——例如,汽车和摩托车从车辆继承而来。
封装就是设计,一个高效的设计往往来自于把几个低效的设计扔出去。你可以花很多时间来尝试生成一个泛型类。专家给我们以下建议:
- 尽量写出与现实紧密对应的自然函数。类(抽象)的全部意义在于它们应该使思考和编程更加直观。与其试图设计一个接近最优的类,不如设计一个易于扩展的类。
- 尽管许多声明承诺从面向对象分析到设计的平稳过渡,但实际上这种过渡一点也不平稳。——Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(“四人组”),设计模式,可重用面向对象软件的要素(Addison-Wesley,1995),第 11 页和第 353 页。
随着封装的出现,数据隐藏在私有数据中,也就是说,数据要么不能被访问,要么只能通过限制修改的 getters 和 setters 来访问。用户可以重写这个类,这样就可以无限制地访问私有数据,但这将是一个不同的类。如果一个类经过了很好的测试,那么使用这个类的程序中的错误就不太可能在这个类中被发现。当然,对于任何库模块中经过良好测试的函数也是如此。
很少谈论对象的是对象之间相互通信的方式。根据一些 OOP 专家的观点,高效的消息传递是至关重要的。
当我编写第一个神经网络程序时,我认为创建节点类是有意义的,因为网络是由节点组成的。不幸的是,Node 类似乎让事情变得复杂了。于是我重写了网络,没有上任何课。然后我决定重新编写网络程序,作为只有一个对象的神经网络类。这不应该有任何意义,因为那样的话,该对象将没有其他对象与之交互。有什么意义?但是,事实上,它简化了编程。由于许多内部实例变量的半全局属性,我不必在类方法中传递或返回它们。由于网络很小,全球化的负面影响不会发生。尽管如此,我最终还是重新编写了这个程序,但没有这个类。
具有类的多态性支持运算符重载。例如,当处理向量时,我们可以重载一个Vector
类中的所有操作符,并最终编写如下代码
F = 3*(B+C)/4 - A/2,
而不是:
F = vectMinus(scalarMult(3/4, VectAdd(B,C)), scalarMult(1/2, A)).
现在你明白我为什么构建了一个Vector
类了。运算符重载产生了积极的影响——大约十行代码。这种努力是值得的,主要是因为我的学生学会了如何构建一个类,并将其应用于一个严重的问题:用漂亮的 Nelder-Mead 算法进行搜索。
Java 允许程序员重载函数,但不允许重载操作符。在 Python 中,可以重载操作符,但不能重载函数。我认为这是因为在 Python 中,任何函数都会接受使用星号(*)操作符的可变大小和类型的参数列表(签名)。参见下面的代码。单个doIt()
函数实际上是过载的。
def doIt(*args):
if len(args) == 1:
print(args[0])
return
if type(args[1]) == list:
print('list')
else:
print(args[1])
#-------------------------------
def main():
doIt(1) # output: 1
doIt(1,'A') # output: A
doIt(1,[1,2,]) # output: list
在 C++和 Python 中,可以重载已经存在的运算符,但不能引入新的运算符。继续我的向量例子,如果我想写一行关于叉积的代码,我不能使用字母“x
”作为操作符。相反,我必须编写类似于A = B.crossProd(C),
或A = Vector.crossProd(B,C
的代码,或者重载星号(*
)操作符。
工业界告诉我们,在可以根据对象编写代码的大型程序中,类是有意义的。在大多数学校问题中,这种意识似乎是缺乏的。
题外话。你能想出一个不能按比例绘制的简单几何图形吗?答案在脚注里。 3 结束题外话。
Footnotes 1
见维基百科/面向对象编程/批评。
2
编程中的抽象被认为有两个部分:接口和实现。类接口是方法的集合,例如,getters、setters、finders、modifiers、reporters 等。—用于处理数据。实现由私有方法和该类所有方法主体中的基本语句组成。好处是细节从界面中抽象出来(隐藏起来)。这使得编程更容易。对于所有的类,你需要的方法类型的最小数量是六个:构造函数、getter、setter、mutator(改变对象的一部分)、对象的比较(=,!=,也可能>),还有一台打印机。在 Python 中,你实际上不需要 getters 和 setter——例如,Oop.x = 5
和Oop.setX(5)
是不必要的。
3
没有带单位的叉积图可以按比例绘制。如果向量A
和B
具有以米为单位的标量,那么垂直叉积向量C = AxB
将具有以平方米为单位的标量。还要注意,向量中的标量必须都没有单位,或者必须都有相同的单位。否则量级就不存在了。这是一位物理老师告诉我的,奇怪的是,我从未在数学书上发现这个事实。后来我在大卫·r·考斯顿的另一本优秀的书《生物学家的数学》(伦敦:爱德华·阿诺德,1977 年)第 37 页发现了这个错误。作者试图通过测量茎的长度和花的数量来找出两种植物之间的“距离”。
十七、函数的演变
- 当我编程的时候,最让我头疼的两件事是给事物命名和把事物放在哪里。我得出的结论是,它们是同一个问题。每一个名字是否代表了我想说的关于命名的事物的一切,一起出现的名字是否唤起了似乎一起去的想法?如果我命名事物有困难,我经常发现问题是事物不应该在一起,或者它们不应该在一起。—戴尔·埃默里,理解耦合和内聚,YouTube 视频。
我将向你们展示我曾经处理过的一个小问题:替换字符串中的一个字符。由于 Python 字符串是不可变的(不能更改),所以必须编写一行代码来解决这个限制。那么为什么不使用可变类型呢,比如 list?列表不能是字典的键。所有语言都有其局限性和不完美性。
这个问题中的九个字符串代表一个井字棋盘。空板看起来像这样:
board ='---------'.
走了两步后,棋盘可能看起来像这样:
board = '----X---O'.
那么,我们如何从'---------'
进行到'----x----'
?回答:我们把字符串分开,替换一个连字符,然后把字符串粘在一起:
0.我的第一次尝试立即解决了问题:
board = board[:position] + char + board[position + 1:]
理由:作者不能让这一行更简单了。
1.第一个改进:把线做成函数:
def insertMove(board, position, char):
return board[:position] + char + board[position + 1:]
理由:函数调用insertMove(board, position, char
比指令本身更具描述性。
2.第二个改进:把板子塞进一个列表。
def insertMove(board, position, char, boardCollection):
newBoard = board[:position] + char + board[position + 1:]
boardCollection.append(newBoard)
return newBoard
理由:原来每个新的板都需要存储在一个名为boardCollection
的列表中,因此有了append
行。(后来,这些木板变成了字典键。在程序构造的这个时刻,我没有任何值可以和键匹配。所以,我只是将这些键加载到一个列表中,而不是一个字典中。)
通过将两条指令放在同一个函数中,两行代码(插入和存储)减少为一个函数调用。但是,该函数现在执行两项任务,而不是一项。任何程序员都应该警惕,这(一个函数中的两个任务)使得修改更加困难,错误更加难以发现。
3.第三个改进:改函数名。
def insertMoveAndStoreBoardInDictionary(board, position, char, boardCollection):
newBoard = board[:position] + char + board[position + 1:]
boardCollection.append(newBoard)
return newBoard
理由:函数的名字必须随着函数的发展而改变。
4.第四个改进:把功能拆分成两个功能。
def insertXAndStoreBoardInDictionary(board, position, boardCollection):
newBoard = board[:position] + 'X' + board[position + 1:]
boardCollection.append(newBoard)
return newBoard
def insertOAndStoreBoardInDictionary(board, position, boardCollection):
newBoard = board[:position] + 'O' + board[position + 1:]
boardCollection.append(newBoard)
return newBoard
理由:如果函数的名字告诉我们哪个字母('X'
或'O'
)被插入到电路板中,程序将更容易调试。警报响起:这个代码违反了 DRY(不要重复自己)原则。还有,这两个函数真的能让程序更容易调试吗?注意,这个改进确实从参数列表中删除了char
。
5.第五个也是最后一个改进:回到每个功能一个任务的原则,这仍然违反了 DRY 原则。
def insertX(board, position):
return board[:position] + 'X' + board[position + 1:]
def insertO(board, position):
return board[:position] + 'O' + board[position + 1:]
理由:我厌倦了每次查看代码时脑子里响起的警报。我打破了两个原则,一个任务一个功能。我继续打破 DRY 原则,因为我爱上了这段代码的可读性。我告诉自己,因为这两个功能在物理上彼此接近,所以我不太可能忘记做两个而不是一个更改。
6.尝试改进:更改函数名称(被拒绝) :
def insertXInBoard(board, position): ...
理由:InBoard
使名称更长,对理解没有什么帮助,主要是因为board
是第一个参数的名称。这是一个很好的例子,说明了一个精心选择的参数如何与一个函数名相结合来提高理解。
7.尝试改进:使用 OOP(被拒绝)。
我考虑将数据和它的功能组合成一个类对象。然后,代替写作
insertX(board, position),
我会写
board.insertX(position).
这有帮助吗?我的猜测是否定的,但是在很多情况下,程序员无法知道封装是否带来了优势,除非程序编写一次有封装,一次没有封装。一般规则是,除非对象彼此交互并进行有效的通信,否则它们不会带来好处。
那么,摆弄这些代码有什么意义呢?这是函数调用吗
insertX(board, position)
明显优于原来的单线:
board = board[:position] + char + board[position + 1:] ?
我觉得函数调用更好,因为它帮助我们更快更好地理解,而且事半功倍。这种讨论似乎是对细节的执着。但是对细节的痴迷恰恰是编程、交流复杂想法、下棋和任何创造性活动的正确态度。如果我们很少重新思考我们的设计,因为它们“足够好”,那么我们就没有获得足够的高质量设计的经验。
十八、不要冷落低效的算法
在非常受欢迎的数学书籍《金奖券》(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 函数(你自己选)。所有其他的都是你选择的对数函数的倍数,例如,其中 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
在编程时,我们总是需要问一些问题,我在这里问了这些问题:
- 如果我们的函数是一个算法,测试应该先写吗?
- 如果我们需要一个列表元素和它的索引,我们应该使用 Python
enumerate
函数吗? - 如果一个
for
循环产生一个列表,我们应该使用列表理解吗?
countSort
的三个答案是是、是和否。通过使用内置的enumerate
函数和使用extend
而不是append
,我能够只用两个循环编写这段代码。尽管我们有一个产生列表的for
循环,但如果不对排序后的数组进行额外的展平,我就无法理解列表,我认为这会使函数变得复杂。
这里的寓意是,某些原本效率低下的算法可能在某些情况下工作得很好,或者具有优势——例如,快速编程——这使得它在特定情况下是一个不错的选择。
我在某处读到过,对于 50 个元素或更少的数据大小,所有算法都是有效的。即使是不起眼的——写起来很琐碎——冒泡排序在这样的数据集上看起来也不错。
Footnotes 1
j+1
计算进行三次。让k = j+1
然后让k
替换j+1
。一个学生不得不向我指出这一点。
2
十九、值得解决的问题
- 在一门运行良好的计算机课程中,学生要做许多练习。他至少也要做一道题。区别在于:一个练习与一个特定的技术相关,并且方法通常被详细说明。另一方面,一个问题将涉及一个广泛的目标,使用许多技术,很少详细说明。——弗雷德·格雷伯格(兰德)和乔治·贾夫雷(洛杉矶山谷学院),《计算机解决方案的问题》(约翰·威利,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
的函数,它将接受两个数字num1
和num2
(浮点数、整数或者两者各一)。如果这两个数字相差万亿分之一或更多,该函数将返回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)
的函数,打印从1
到limit = 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 页。
任务:写一个程序来模拟通过三次随机切割将一个圆切割成三块。一块比半圆大的概率是多少?【备选说法:圆上随机选取的三个点包含在半圆内的概率是多少?]惊喜,这和把一根棍子分成三段做三角形是一个问题。为什么呢?因为第一刀会把圆切成一段。那么接下来的两个切割对应的是上一个问题中的 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 举例:
- 为什么会这样?(我忽略了一个特例。)
- 为什么我忽略了这个特例?(我从来没想过。)
- 为什么我没有想到?(我的思考很肤浅。)
- 为什么我的想法很肤浅?(我工作太快了。)
- 为什么我工作得太快了?(我想结束。)
第四步,也是最后一步,与聪明人交往,让他们谈生意(或者读他们写的书)。
斯坦福计算机科学教授 Donald Knuth 对编程中的常见错误做了一个有趣的观察。
- 在《计算机编程的艺术》第一卷中,我写道:“另一个好的调试实践是记录下所犯的每一个错误。它将帮助你学会如何减少未来的错误。”但是如果你问这样的日志[TEX 中的 916 个错误]是否帮助我学会了如何减少未来的错误,我的答案是否定的。我继续犯同样的错误。——Donald e . Knuth,《识字编程》,CSLI 讲义 27(语言和信息研究中心,1992 年),第 286 页。
我想 Knuth 说的是我们都会犯的小问题,我们都会很快解决。这种错误比妨碍编程更令人尴尬。当然,一些编程人员在编程多年后并没有变得更好,因为他们没有分析自己的错误,忘记了太多的经验。他们与工作脱节。其他人则相反,随着他们解决每一个难题而变得更好。
以下是我与学生们分享的常见错误汇编。这个列表减少了他们的错误吗?很少,因为这样的清单必须从个人经验中构建,以便在需要时回忆起来。同样,每个学生必须自学。老师只是选择问题,然后在学生准备好欣赏它们时提供见解。
- 您互换了参数—例如,
(a,b)
被作为(b,a)
传递;坐标x
和y
互换;矩阵行和列下标互换。 - 您有一个内存位置错误。某些内容被移动、覆盖,或者您的引用被意外更改。
- 你有一个混淆错误——即两个变量访问同一个内存地址(没有生成
deepcopy
)。您有两个同名的函数。您使用了保留字作为变量名或文件名。 - 您正在查看一个文件(比如说,
lab99.py
),但是正在运行另一个文件(lab99
)。 - 你一开始就没有调用这个函数。
- 外部 for 循环索引用作内部循环索引。[这在 Python 中不会出现。]
- 您删除了函数名中的括号对。
- 你用
==
代替了=
,反之亦然。 - 你把
<
写成了<=
,反之亦然。这个错误已经花费了我几个小时的时间。] - 你对优先顺序一无所知——例如,
a and b != True
的意思是a and (b != True)
,而不是(a and b) != True.
- 您未能初始化变量(在 Python 中不可能)。
- 您的数字太大(溢出)。
- 你拼错了两个相似的单词——例如,变量名
differenceInYears
、differenceinYears
和differenceInYears
都是不同的。 - 舍入累计产生了错误的数字。
- for 循环头中的列表/数组在 for 循环体中被更改。
- 你有一个双簧管(一个错误)。
- 您有缩进或范围错误。
- 您混淆了列表值(
x[n]
)及其位置(n
)。 - 你假设
A += B
总是像A = A + B
一样运行。不是用列表。 - 您误解了内置函数的工作方式,例如,函数可能就地操作数据,而不会像您所想的那样返回数据。
- 你比较了绝对平等的浮点数。
- 你从未掌握你的语言。内置函数或巧妙的语法安排会简化复杂的代码。
- 你把字母'
O
'写成零(0
),反之亦然。 - 您期望函数头中有
[]
、""
或None
,但却得到了其中一个。 - 你的陈述看起来是独立的,但却是有联系的。你可能有
1. 一个悬空的else
(一个else
连接到错误的if
),
2. 背后捅刀子的 else(两个或更多的if
后面跟着一个else
),以及
3. 在if
比较之间改变测试数据的出血if
。
-
战争故事 1。在我布置的一个图形程序中,一个学生从讲义上复制了我的代码,然后告诉我她一直得到错误“未赋值的全局变量…”。全局变量被导入并在所有其他学生的计算机上运行。经过五分钟的代码检查,并与我的进行比较,我一无所获。怎么办?你会怎么做?我从来没有发现问题是什么,但我能够消除错误。我只是复制了我的工作代码,删除了我想让学生写的函数,然后通过电子邮件发给了她。成功了。我后来让她把有缺陷的代码发给我,但她已经覆盖了它。太糟糕了,因为这些错误教会了我们一些东西。
-
战争故事 2。我曾经让一个学生构建了一个无法编译的巨型 Python 字典。编译错误通常很容易消除,但这一个却不容易。这本由许多行组成的词典被计算机看作是一行。因此,编译器无法给出错误的实际物理行号。在对字典进行了多次分解之后,我发现了这个错误。在字典的第二行,学生写了字母“o”代表零(0)。
-
战争故事 3。我的同事 Torbert 博士曾经花了半天时间(对,半天!)试着调试一个学生的代码。这名学生使用了合理的标识符
getx
和gety
,她和托伯特博士都不知道,这是继承的JPanel
类中的保留字。你可能会认为最初的设计者会使用更模糊的标识符,甚至是像JPgetX
或GETX
这样的东西。 -
战争故事 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
所以,再一次,我们如何成为优秀的编程员,尤其是在错误无法消除的情况下?答案是学习我们的计算机语言的细节,解决许多具有挑战性的问题,提供洞察力,不要轻易放弃这些问题,练习重构,反思解决方案和错误,并与其他优秀的程序员交流。
现在是一个惊喜。如果有任何建议可以帮助你提高编程技能,那么你必须自己去发现,或者至少你必须找出编程问题,这样你就可以通过向他人提出明确的问题来寻求建议。对于初学者来说,这本书一定只是背景噪音。我的观点不可能是你的。即使告诉你我的观点也不足以让它有意义。这本书的目的是告诉你,职业程序员(以及棋手和钢琴家)认为某些习惯提高了他们的生产力,减少了他们的挫败感。我的话只能是在编程中找到自己个人视角的一个弱指导。祝你好运。
程序员的进化
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)
- 通过减少参数、选择、决策、容量、对象的数量或整数域的大小,将问题简化为子案例,即在每一步减少问题的维度。然后将这些子案例减少到更多的子案例,再减少到更多的子案例。这样做,直到子案例很容易解决。
- 所有的子案例必须以相似的(递归的)方式产生。
- 任何情况或阶段的值必须由其直接子情况的值的某种组合来确定,通常(但不总是)作为子情况的最大值或最小值。这就是所谓的优化原则,并导致一个最佳的政策功能。
- 修剪在实践中通常是必要的。找到一种计算重叠(共享)子案例(如果它们存在)不超过一次的方法。[每个子案例只会比其父案例稍微简单一点。如果每个子案例都比其父案例简单得多,那么就没有必要进行动态规划。仅仅以任何方式将一个问题分解成子问题,然后用蛮力来解决它们就可以了。]
请注意:在计算机科学中,DP 已经意味着一种递归算法,它不会对一个子问题求值两次。参见维基百科。根据你试图解决的问题选择你的定义。
动态规划有三种(很多人说是两种)形式:
- 表单 a)一种迭代算法,构建一个过去计算的表格,用于进行新的计算(称为自底向上方法)
- 形式 b)一种递归算法,不涉及累积内存,只是重复计算相同的子情况(称为自顶向下方法)
- 形式 c)一种递归算法,它记住先前计算的子情况(也称为自顶向下方法)。
注意,上面的表格 b 违反了动态规划的第四个属性。因此,有些人不认为形式 b 是动态规划。然而,表格 b 满足运筹学 DP 的定义特征,是编写的最简单的 DP 函数,有时足以解决手边的问题,并且是编写表格 c 的第一步,这反过来通常有助于产生更快的表格 a。因此,表格 b 至少是动态规划工具箱的一部分。
函数方程。DP 的子情况通常涉及函数方程,即至少包含一个函数的方程。这里有一个简单的例子(Denardo,第 28 页)。假设你在用一对公平骰子掷出 7 之前,想知道掷出 3 的概率:。一掷中得 3 的概率是
。得到 7 的概率是
。两者都得不到的概率是
。那么我们的答案就是无限几何级数:
①
这一系列可以用预先计算公式求解:
如果你想不起公式了怎么办?一个简单的想法是将级数视为函数方程(1):
而现在只要求解 x: 。因此,如果你知道函数方程,那么你永远不需要记住无穷几何级数公式。,除了|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 箱燃油的行驶距离也可以以封闭形式给出:
注意:这不是一般第 n 种情况的证明(“我们可以看到模式”),也不是最优性证明。贝尔曼的书包含了许多页的 DP 定理的存在性和唯一性证明,这些证明只有数学专家才能理解。
我们现在将考察四个经典的动态规划问题。最理想的方法是在考虑我的解决方案之前,试着在每个问题上取得一些进展。其他解决方案和问题可以在互联网上找到。
问题 1。最短路径。
在下面的(无环有向) 10 图中我们求从任意节点到节点 9 的最短路径。
还是我们?难道我们不寻求一个函数(最优策略)给定一个节点,告诉我们下一个节点移动到最优路径吗?这不符合 DP 的精神吗?既有也有。产生一个函数来指导我们选择下一个节点是贝尔曼最初如何描述 DP 的。这就是工业所需要的。然而,要从任何当前节点找到下一个最佳节点,我们必须首先从当前节点找到整个最佳路径。所以,我们不能缺一不可。 11
这个数字摘自 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 之间的最小(最佳)距离。显然,。然而
,【贝尔曼方程】
其中 d ij 是从节点 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
来调用fb
、fc
或fa
,并返回最短路径和该路径的长度。
九个方程式
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 快。然而,当我第一次运行这个测试时,fc
比fa
快了许多倍。我知道我在比较时间时犯了一些错误,但是是什么错误呢?也许读者在看脚注之前就能猜到。 13
代码注释:
- 当主函数中的
for
循环实际上简化了代码的阅读时,这是一种罕见的情况。但是,这段代码的目的是演示这三个功能,而不是解决一个问题。 - 函数
determineMinimumPathAndDistance
包括抛弃的下划线变量(' _ ')。 - 为什么我在图中把距离放在邻居之前,而不是相反?答:
min
函数只检查元组或列表中的第一个元素。当用 Python 调用带有元组或列表的min
或max
函数时,这是一个有用的设计技巧。 - 请注意,我使用了所谓的幻数,而不是将这些数字分配给标识符——例如
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 (i)是从节点 I 回到节点 1 的距离,
。哪种形式比较好?据我所知,都不是。如果您感兴趣,以下是三种表单的代码:
#---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]
下面的表格里了。然后我们决定:插入物品(可能会推出一些其他物品)是否会增加背包的价值(与不插入相比)?
将使这种编程更容易的是向数据集w
和v
附加两个零。
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]
#-----------------------------
编写迭代函数来返回任何数据集的最大值。我的迭代函数如下。它引用先前给出的数据集w
、v
和C
中的任何一个。
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)
#--------------------------------------------------------------
(knapsackR
和knapsackRR
)哪种形式更好?我更喜欢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)是前面单元格的乘积之和。没有涉及最大值或最小值,但它仍然被认为是动态规划,就像斐波纳契函数和吉普问题。你的工作是写一个递归函数(无记忆)来返回这个数字。我的代码如下:
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 版本。今天,整本书都在讨论货物装载问题及其变体。