程序员思维-全-
程序员思维(全)
原文:Think Like a Programmer
译者:飞龙
引言

你是否在编写程序时感到困难,尽管你认为你理解编程语言?你是否能够阅读编程书籍中的一章,一路点头,但无法将你所读到的应用到自己的程序中?你是否能够理解你在线阅读的程序示例,甚至到能够向别人解释代码每一行的作用,然而面对编程任务和文本编辑器中的空白屏幕时,你感到你的大脑变得僵化?
你并不孤单。我教授编程已经超过 15 年了,我的大多数学生在他们的学习过程中某个时刻都会符合这种描述。我们将缺失的技能称为问题解决能力,即能够接受一个给定的问题描述并编写一个原创程序来解决它。并非所有编程都需要广泛的问题解决。如果你只是在修改现有的程序,调试,或者添加测试代码,编程可能非常机械,以至于你的创造力从未得到考验。但所有程序在某个时候都需要问题解决,而且所有优秀的程序员都能解决问题。
问题解决是困难的。确实,有些人让它看起来很容易——他们是“天生的”,编程界的天才运动员,就像迈克尔·乔丹。对于这少数人来说,高级思想可以毫不费力地转化为源代码。用 Java 的比喻来说,就好像他们的大脑能够原生地执行 Java,而其他人则不得不运行一个虚拟机,边走边解释。
不是天生的并不意味着成为程序员是不可能的——如果这是不可能的,世界上将很少有程序员。然而,我见过太多有价值的学习者长时间在挫败中挣扎。在最糟糕的情况下,他们完全放弃了编程,确信自己永远不能成为程序员,只有那些天生具有天赋的人才是优秀的程序员。
为什么学习解决编程问题如此困难?
在一定程度上,这是因为问题解决是一种与学习编程语法不同的活动,因此使用的是不同的一套心理“肌肉”。学习编程语法,阅读程序,记忆应用程序编程接口的元素——这些大多是分析性的“左脑”活动。使用之前学到的工具和技能编写一个原创程序是一种创造性的“右脑”活动。
假设你需要从你家房子的雨水槽中移除一根掉落的树枝,但你的梯子不够长,无法触及树枝。你走进车库,寻找一些东西,或者是一些组合工具,以便你能从槽中移除树枝。有没有办法延长梯子的长度?有没有什么你可以握在梯子顶部来抓住或移除树枝的东西?也许你可以从另一个地方上屋顶,从上面取下树枝。这就是问题解决,它是一种创造性的活动。信不信由你,当你设计一个原创程序时,你的思维过程与那个试图从槽中移除树枝的人非常相似,而与调试现有for循环的人的思维过程则截然不同。
然而,大多数编程书籍都将注意力集中在语法和语义上。学习编程语言的语法和语义是必要的,但它是学习如何用该语言编程的第一步。本质上,大多数针对初学者的编程书籍教授的是如何阅读程序,而不是如何编写程序。那些专注于编写的书籍通常实际上就像“食谱集”,它们教授特定情况下使用的特定“食谱”。这样的书籍可以作为节省时间的工具非常有价值,但不是学习编写原创代码的途径。想想原始意义上的食谱集。虽然伟大的厨师拥有食谱集,但依赖食谱集的人不可能成为伟大的厨师。一个伟大的厨师了解原料、准备方法和烹饪方法,并知道如何将它们结合起来制作出美味的菜肴。一个伟大的厨师只需要一个库存充足的厨房就能制作出美味的饭菜。同样,一个伟大的程序员了解语言语法、应用程序框架、算法和软件工程原则,并知道如何将它们结合起来制作出优秀的程序。给一个伟大的程序员一份规格列表,让他在一个库存充足的编程环境中自由发挥,就会产生伟大的成果。
通常,当前的编程教育在问题解决领域并没有提供很多指导。相反,人们认为,如果程序员能够接触到所有编程工具并被要求编写足够多的程序,他们最终会学会编写这样的程序,并且编写得很好。这一点是正确的,但“最终”可能是一个很长的时间。从启蒙到领悟的旅程可能会充满挫折,太多开始这段旅程的人从未到达目的地。
与通过试错学习相比,你可以以系统的方式学习问题解决。这正是本书的主题。你可以学习组织思想的技术、发现解决方案的程序以及应用于某些问题类别的策略。通过研究这些方法,你可以激发你的创造力。不要误解:编程,尤其是问题解决,是一种创造性的活动。创造力是神秘的,没有人能确切地说创造性思维是如何运作的。然而,如果我们能够学习音乐创作、获得创意写作的建议或被展示如何绘画,那么我们也可以学会以创造性的方式解决编程问题。这本书不会告诉你确切该做什么;它将帮助你开发你的潜在问题解决能力,以便你知道你应该做什么。这本书是关于帮助你成为你注定要成为的程序员。
我的目的是让你和其他这本书的读者学会系统地处理每一个编程任务,并对你最终能够解决给定问题充满信心。当你完成这本书时,我希望你能够像程序员一样思考,并相信自己是一名程序员。
关于本书
解释了这本书的必要性之后,我需要就这本书是什么以及它不是什么发表几点评论。
前置条件
本书假设你已经熟悉 C++语言的基本语法和语义,并且已经开始编写程序。大多数章节都会期望你知道特定的 C++基础知识;这些章节将从这些基础知识的回顾开始。如果你仍在吸收语言基础知识,不要担心。关于 C++语法的优秀书籍有很多,你可以在学习语法的同时学习问题解决。只需确保在尝试解决章节的问题之前,你已经学习了相关的语法。
选择的主题
本书涵盖的主题代表了我最常看到的新程序员遇到困难的地方。它们也展示了早期和中级编程中不同领域的广泛横截面。
然而,我必须强调,这并不是一个“食谱”或解决特定问题的算法或模式的“手册”。尽管后面的章节讨论了如何使用众所周知的算法或模式,但你不应将这本书作为“应急指南”来应对特定问题,或者只关注与你当前困境直接相关的章节。相反,我会通读整本书,只有在你缺乏跟随讨论所需的前置条件时,才会跳过材料。
编程风格
关于本书中使用的编程风格的一个简短说明:这本书不是关于高性能编程或运行最紧凑、高效的代码。我选择的源代码示例风格首先是为了可读性。在某些情况下,我会采取多个步骤来完成本可以一步完成的事情,只是为了清楚地展示我试图证明的原则。
本书将涵盖一些编程风格的方面——但更大的问题,比如一个类中应该包含什么或不应该包含什么,而不是像代码应该如何缩进这样的小问题。作为一名正在发展的程序员,你当然希望在你所做的所有工作中都采用一致、可读的风格。
练习
本书包含一些编程练习。这不是教科书,你不会在书的后面找到任何练习的答案。这些练习为你提供了将章节中描述的概念应用到实践中的机会。当然,你是否选择尝试任何练习取决于你,但将这些概念付诸实践是至关重要的。仅仅阅读这本书是不会有任何成果的。记住,这本书不会告诉你每种情况下应该做什么。在应用本书中展示的技术时,你将发展出自己发现应该做什么的能力。此外,增强信心,这是本书的另一个主要目标,需要成功。事实上,这是了解你在特定问题领域做了足够练习的好方法:当你自信可以解决该领域中的其他问题时。最后,编程练习应该是有趣的。虽然可能会有你更愿意做其他事情的时刻,但解决编程问题应该是一个有回报的挑战。
你应该将这本书视为你大脑的障碍赛。障碍赛可以增强力量、耐力和敏捷性,并给训练者带来信心。通过阅读章节并将概念应用到尽可能多的练习中,你将建立信心并发展在任何编程环境中都能使用的解决问题的技能。在未来,当你面临一个难题时,你会知道你是否应该尝试绕过、穿过或解决它。
为什么选择 C++?
这本书中的编程示例使用 C++编写。话虽如此,这本书是关于用程序解决问题的,而不是专门关于 C++的。你在这里不会找到很多针对 C++的特定技巧和窍门,本书中教授的通用概念可以应用于任何编程语言。尽管如此,不讨论程序就无法讨论编程,而必须选择一种特定的语言。
C++ 被选用的原因有很多。首先,它在各种问题领域中都很受欢迎。其次,由于它起源于严格的过程式编程语言 C,C++ 代码可以使用过程式和面向对象两种范式编写。面向对象编程现在如此普遍,以至于在讨论问题解决时不能省略它,但许多基本的问题解决概念可以在严格的过程式编程术语中进行讨论,这样做既简化了代码也简化了讨论。第三,作为一种底层语言但拥有高级库的语言,C++ 允许我们讨论编程的这两个层面。最好的程序员在需要时可以“手工接线”解决方案,并利用高级库和应用编程接口来减少开发时间。最后,部分原因是由于前面列出的其他原因,C++ 是一个很好的选择,因为一旦你学会了在 C++ 中解决问题,你就学会了在任何编程语言中解决问题。许多程序员已经发现,在一种语言中学到的技能很容易应用到其他语言中,但这一点对于 C++ 来说尤其如此,因为它采用了跨范式的方法,而且坦白说,因为它难度较大。C++ 是真正的编程——没有辅助轮的编程。一开始这可能会让人感到害怕,但一旦你开始在 C++ 中取得成功,你就会知道你不会只是一个能做一点编码的人——你将是一名程序员。
第一章. 解决问题的策略

这本书是关于解决问题的,但究竟什么是解决问题呢?当人们在日常对话中使用这个术语时,他们通常意味着与我们在这里所说的非常不同的事情。如果你的 1997 年款本田思域从排气管冒出蓝色烟雾,怠速不稳定,并且燃油效率降低,这是一个可以用汽车知识、诊断、更换设备和常见的商店工具解决的问题。然而,如果你向你的朋友们提起你的问题,他们中的一个可能会说,“嘿,你应该把那辆旧的本田换成一辆新的。问题解决了。”但你的朋友的建议并不是真正解决问题的解决方案——那是一种避免问题的方法。
问题包括约束,关于问题或解决问题方式的不可打破的规则。对于损坏的本田车,一个约束是你想修理现有的车,而不是购买一辆新车。约束还可能包括维修的整体成本、维修所需的时间,或者不允许购买仅为此维修的新工具的要求。
当用程序解决问题时,你也有约束。常见的约束包括编程语言、平台(它是否在 PC 上运行,或在 iPhone 上运行,或是什么?)、性能(游戏程序可能需要每秒至少更新 30 次图形,商业应用程序可能对用户输入的最大响应时间有限制),或内存占用。有时约束涉及你可以引用的其他代码:可能程序不能包含某些开源代码,或者相反——可能它只能使用开源代码。
因此,对于程序员来说,我们可以将解决问题定义为编写一个执行特定任务集并满足所有声明的约束的原创程序。
初学者程序员往往急于完成定义中的第一部分——编写一个程序来执行特定任务——以至于他们在定义的第二部分,即满足既定约束方面失败了。我把这样的程序称为“小野马”,它看起来能产生正确的结果,但违反了其中一条或多条规则。如果你对这个名字不熟悉,这意味着你对极客文化的基石之一——电影《星际迷航 II:怒火中烧》——不够熟悉。这部电影包含一个关于星舰学院有志成为军官的学员的副故事。学员们被安置在模拟的星舰舰桥上,并被要求在涉及不可能选择的任务中担任船长。无辜的人将在受伤的船上死去,即“小野马”,但要到达那里,就需要与克林贡人开始战斗,而这场战斗只能以船长舰船的毁灭结束。这个练习的目的是测试学员在炮火下的勇气。没有获胜的方法,所有选择都导致不良结果。在电影的结尾,我们发现柯克船长修改了模拟,使其实际上可以赢得胜利。柯克很聪明,但他并没有解决“小野马”的困境;他避免了它。
幸运的是,你作为程序员将面临的问题是可以解决的,但许多程序员仍然求助于柯克的方法。在某些情况下,他们这样做是偶然的。(“哦,糟糕!我的解决方案只适用于有一百个或更少数据项的情况。它应该适用于无限的数据集。我必须重新思考这个问题。”)在其他情况下,去除约束是故意的,是为了满足老板或导师强加的最后期限。在其他情况下,程序员不知道如何满足所有约束。在我见过的最糟糕的情况下,编程学生支付了别人来编写程序。无论动机如何,我们都必须始终勤奋地避免“小野马”。
经典谜题
随着你在这本书中的进步,你会注意到,尽管源代码的细节从一个问题领域到另一个问题领域都在变化,但我们的方法中会出现某些模式。这是一个好消息,因为这最终使我们能够自信地面对任何问题,无论我们是否在该问题领域有丰富的经验。专家问题解决者会迅速识别出一种“类比”,即已解决问题与未解决问题之间的可利用相似性。如果我们认识到问题 A 的特征与问题 B 的特征类似,并且我们已经解决了问题 B,那么我们对解决问题 A 就有了一个宝贵的洞察。
在本节中,我们将讨论来自编程世界之外的经典问题,这些问题的教训我们可以应用到编程问题中。
狐狸、鹅和玉米
我们将要讨论的第一个经典问题是关于一个需要过河的农夫的谜题。你可能以前以某种形式遇到过这个问题。
问题:如何过河?
一个农夫带着一只狐狸、一只鹅和一袋玉米需要过河。农夫有一只小船,但只能容纳他和他的三个物品中的一个。不幸的是,狐狸和鹅都很饿。狐狸不能单独和鹅留在同一岸,否则狐狸会吃掉鹅。同样,鹅也不能单独和玉米袋留在同一岸,否则鹅会吃掉玉米。农夫如何才能把所有东西都运过河?
这个问题的设置如图图 1-1 所示。如果你以前从未遇到过这个问题,请在这里停下来,花几分钟时间尝试解决它。如果你曾经遇到过这个谜题,尝试回忆一下解决方案,以及你是否能独立解决这个谜题。

图 1-1. 狐狸、鹅和玉米袋。船一次只能携带一个物品。狐狸不能和鹅留在同一岸,鹅也不能和玉米袋留在同一岸。
很少有人能够解决这个谜题,至少在没有提示的情况下。我知道我做不到。通常的推理是这样的。由于农夫一次只能携带一个物品,他需要多次往返才能把所有东西带到对岸。在第一次旅行中,如果农夫带狐狸过去,鹅就会和玉米袋留在同一岸,鹅会吃掉玉米。同样,如果农夫第一次带玉米袋过去,狐狸就会和鹅留在同一岸,狐狸会吃掉鹅。因此,农夫第一次必须带鹅过去,结果如图 1-2 所示。

图 1-2. 解决狐狸、鹅和玉米袋问题的第一步。然而,从这个步骤开始,所有后续步骤似乎都以失败告终。
到目前为止,一切顺利。但在第二次旅行中,农夫必须带狐狸或玉米。然而,无论农夫拿什么,都必须在鹅和另一件物品留在远岸的同时,农夫返回近岸取剩余的物品。这意味着狐狸和鹅将一起留在远岸,或者鹅和玉米将一起留在远岸。由于这两种情况都不被接受,问题看起来似乎无法解决。
再次,如果你之前见过这个问题,你可能记得解决方案的关键要素。正如之前解释的那样,农夫必须第一次带鹅。在第二次旅行中,让我们假设农夫带狐狸。不过,农夫并没有把狐狸留在鹅那里,而是把鹅带回到近岸。然后农夫带着玉米袋过河,把狐狸和玉米留在远岸,并在第四次旅行时带着鹅回来。完整的解决方案在图 1-3 中展示。
这个谜题很难,因为大多数人从未考虑过将其中一件物品从远岸带回近岸。有些人甚至会建议这个问题是不公平的,说些像“你没有说我可以拿回东西!”这样的话。这是真的,但同样,问题描述中没有任何内容表明拿回东西是被禁止的。

图 1-3. 狐狸、鹅和玉米谜题的逐步解决方案
想想如果明确指出将其中一件物品带回近岸的可能性,这个谜题会容易解决多少:农夫有一只小船,可以用来在两个方向上转移物品,但船上只能容纳农夫和他的三件物品中的一件。有了这个建议在眼前,更多的人会想出这个问题。这说明了问题解决的一个重要原则:如果你不知道所有可能采取的行动,你可能无法解决问题。我们可以将这些行动称为操作。通过列举所有可能的操作,我们可以通过测试所有操作的组合来解决问题,直到找到一种可行的方案。更普遍地说,通过用更正式的术语重新表述问题,我们通常可以揭示出我们可能错过的解决方案。
让我们忘记我们已经知道解决方案,尝试更正式地表述这个特定的谜题。首先,我们将列出我们的约束条件。这里的关键约束是:
-
农夫一次只能从船上拿一件物品。
-
狐狸和鹅不能单独留在同一岸上。
-
鹅和玉米不能单独留在同一岸上。
这个问题是一个很好的例子,说明了约束的重要性。如果我们移除这些约束中的任何一个,谜题就变得简单了。如果我们移除第一个约束,我们可以简单地一次把所有三个物品带过去。即使我们只能带两个物品上船,我们也可以先带狐狸和玉米过去,然后回来接鹅。如果我们移除第二个约束(但保留其他约束),我们只需要小心,先带鹅过去,然后是狐狸,最后是玉米。因此,如果我们忘记或忽略任何约束,我们最终会陷入一个“无解”的局面。
接下来,让我们列出操作。对于这个谜题,有各种表述操作的方式。我们可以列出我们认为可以采取的具体行动列表:
-
操作:将狐狸带到河的对岸。
-
操作:将鹅带到河的对岸。
-
操作:将玉米带到河的对岸。
记住,然而,正式重述问题的目的是为了获得解决方案的洞察力。除非我们已经解决了问题并发现了“隐藏”的可能操作,即把鹅带回到河的近岸,否则我们不会在我们的行动列表中找到它。相反,我们应该尝试使操作通用化或参数化。
-
操作:划船从一岸到另一岸。
-
操作:如果船是空的,从岸上取一个物品放到船上。
-
操作:如果船不是空的,将物品卸到岸上。
通过以最一般的方式思考问题,这个操作列表将使我们能够在不需要关于鹅返回近岸的“啊哈!”时刻的情况下解决问题。如果我们生成所有可能的移动序列,一旦序列违反了我们的任何一个约束或达到我们之前见过的配置,我们最终会找到图 1-3 的序列并解决这个谜题。通过正式重述约束和操作,我们将绕过谜题固有的难度。
经验教训
我们能从狐狸、鹅和玉米中学到什么?
以更正式的方式重述问题是一种深入了解问题的优秀技术。许多程序员寻求与其他程序员讨论问题,不仅因为其他程序员可能有答案,而且还因为大声阐述问题通常会引发新的和有用的想法。重述问题就像与另一个程序员进行讨论一样,只是你扮演了两个角色。
更广泛的教训是,思考问题可能和思考解决方案一样有效,或者在某些情况下更有效。在许多情况下,解决问题的正确方法本身就是解决方案。
滑动拼图
滑动拼图有多种尺寸,正如我们稍后将要看到的,它提供了一种特定的解决机制。以下描述适用于 3×3 版本的拼图。
问题:滑动八块
一个 3×3 的网格填充了八块拼图,编号为 1 到 8,还有一个空白空间。最初,网格处于混乱的配置。一块拼图可以滑入相邻的空白空间,使其原来的位置变为空白。目标是滑动拼图,将网格放置在有序的配置中,从左上角的拼图 1 开始。
这个问题的目标在图 1-4 中显示。如果你以前从未尝试过这样的谜题,现在就花点时间试试。网上可以找到许多滑动拼图模拟器,但为了我们的目的,如果你使用扑克牌或索引卡在桌面上自己制作游戏会更好。一个建议的起始配置在图 1-5 中显示。

图 1-4. 滑动拼图八块版本的目标配置。空白方块代表相邻的拼图可以滑入的空位。

图 1-5. 滑动拼图的特定起始配置
这个谜题与农夫、狐狸、鹅和玉米的问题大不相同。那个问题的难度来自于忽略了一种可能的操作。在这个问题中,这种情况不会发生。从任何给定的配置中,最多有四个拼图可以与空白空间相邻,并且任何这些拼图都可以滑入空白空间。这完全列举了所有可能的操作。
这个问题的难度实际上来自于解决所需的长期操作链。一系列滑动操作可能会将一些拼图移动到正确的最终位置,同时将其他拼图移出位置,或者它可能会将一些拼图移动到更接近正确位置,同时将其他拼图移动得更远。正因为如此,很难判断任何特定的操作是否会使我们朝着最终目标迈进。如果不能衡量进度,就很难制定策略。许多尝试滑动拼图的人只是随意移动拼图,希望找到一种可以从其中看到通往目标配置路径的配置。
尽管如此,滑动拼图还是有策略的。为了说明一种方法,让我们考虑一个较小的矩形网格的拼图,而不是正方形网格。
问题:滑动五格
一个 2×3 的网格中填充了五个编号为 4 到 8 的格子,以及一个空格。最初,网格处于混乱的配置。一个格子可以滑入相邻的空格,留下格子原来的位置为空。目标是滑动格子,将网格放置在有序的配置中,从左上角的 4 号格子开始。
你可能已经注意到,我们的五个格子编号为 4 到 8,而不是 1 到 5。这个原因很快就会变得清楚。
尽管这个问题与滑动八格问题基本相同,但只有五个格子,所以要容易得多。尝试一下图 1-6 中所示的配置。
如果你只是玩几分钟这些瓷砖,你可能会找到解决方案。通过玩小数量瓷砖拼图,我开发了一种特定的技能。正是这种技能,加上我们很快就会讨论的观察,我用来解决所有的滑动拼图。

图 1-6. 一个简化的 2×3 滑动拼图的特殊起始配置
我把我的技术称为“火车”。这是基于观察,包括空格的瓷砖位置电路形成了一个可以旋转的瓷砖火车,在任何电路上的相对顺序保持不变。图 1-7 展示了四个位置的最小火车。从第一个配置开始,1 号格子可以滑入空格,2 号格子可以滑入 1 号格子留下的空格,最后 3 号格子可以滑入 2 号格子留下的空格。这留下了空格紧邻 1 号格子,这使得火车可以继续,因此,瓷砖可以有效地在火车路径上的任何地方旋转。

图 1-7. 一个“火车”,一个从空格旁边开始的瓷砖路径,可以像火车一样在拼图中滑动
使用列车,我们可以在保持磁砖相对关系的同时移动一系列磁砖。现在让我们回到之前的 2×3 网格配置。尽管这个网格中的磁砖没有一个处于其正确的最终位置,但一些磁砖与它们在最终配置中需要相邻的磁砖相邻。例如,在最终配置中,4 将位于 7 的上方,而目前这些磁砖是相邻的。如图 图 1-8 所示,我们可以使用一个六位置列车将 4 和 7 移动到它们的正确最终位置。当我们这样做时,剩余的磁砖几乎正确;我们只需要将 8 滑动过去。

图 1-8. 从配置 1 开始,沿着轮廓的“列车”旋转两次将我们带到配置 2。从那里,单个磁砖滑动就实现了目标,配置 3。
那么这种技术是如何让我们解决任何滑动拼图的呢?考虑我们的原始 3×3 配置。我们可以使用一个六位置列车来移动相邻的 1 和 2 磁砖,使 2 和 3 相邻,如图 图 1-9 所示。

图 1-9. 从配置 1 开始,磁砖沿着轮廓的“列车”旋转以到达配置 2。
这样就将 1、2 和 3 放在了相邻的方格中。使用一个八位置列车,我们可以将 1、2 和 3 磁砖移动到它们的正确最终位置,如图 图 1-10 所示。

图 1-10. 从配置 1 开始,磁砖旋转以到达配置 2,其中磁砖 1、2 和 3 处于正确的最终位置。
注意 4-8 号拼图的位置。拼图处于我给出的 2×3 网格的配置中。这是关键观察。在将 1-3 号拼图放置到正确的位置后,我们可以将剩余的网格作为一个单独的、更小、更简单的拼图来解决。请注意,我们必须解决整个行或列,这种方法才能奏效;如果我们把 1 号和 2 号拼图放到正确的位置,但 3 号拼图仍然不在位,就没有办法在不移动其他上方行的一个或两个拼图的情况下将某物移动到右上角。
这种相同的技术也可以用来解决更大的滑动拼图。最大的常见尺寸是 15 块拼图,一个 4×4 的网格。可以通过首先将 1-4 号拼图移动到正确的位置,留下一个 3×4 的网格,然后移动最左边列的拼图,留下一个 3×3 的网格来逐步解决这个问题。到那时,问题已经简化为一个 8 块拼图。
经验教训
我们可以从滑动拼图中学到什么教训?
拼图移动的数量很大,以至于很难或不可能从初始配置中规划出完整的解决方案。然而,我们无法规划出完整的解决方案并不能阻止我们制定策略或采用技术来系统地解决拼图。在解决编程问题时,我们有时会遇到无法看到清晰路径来编写解决方案的情况,但我们绝不能以此为借口放弃规划和系统方法。制定策略比通过试错攻击问题更好。
我是从摆弄一个小拼图发展出我的“训练”技术的。我经常在编程中使用类似的技术。面对一个艰巨的问题时,我会尝试问题的简化版本。这些实验通常会产生有价值的见解。
另一个教训是,有时问题可以通过不明显的方式分割。因为移动拼图不仅影响那个拼图,还影响下一步可以进行的移动,有人可能会认为滑动拼图必须一步解决,而不是分阶段解决。寻找分割问题的方法通常是值得花费时间的。即使你无法找到清晰的分割,你也可能学到一些关于问题的知识,这有助于你解决问题。在解决问题时,有具体目标地工作总是比随机努力更好,无论你是否实现了那个具体目标。
数独
数独游戏通过在报纸和杂志上的出现,以及作为基于网页和手机的游戏而变得极为流行。存在不同的变体,但我们将简要讨论传统版本。
问题:完成数独方阵
一个 9×9 的网格部分填充了单个数字(从 1 到 9),玩家必须在满足某些约束的条件下填写空格:在每一行和每一列中,每个数字必须恰好出现一次,而且进一步地,在由粗边框标记的每个 3×3 区域中,每个数字也必须恰好出现一次。
如果你之前玩过这个游戏,你可能已经有一套完成方格的最短时间的策略。让我们通过查看图 1-11 中显示的样本方格来关注关键起始策略。

图 1-11. 一个简单的数独方格谜题
数独谜题的难度各不相同,其难度由需要填充的方格数量决定。按照这个标准,这是一个非常简单的谜题。由于已经有 36 个方格被编号,因此只需要填充 45 个方格来完成谜题。问题是,我们应该尝试先填充哪些方格?
记住谜题的约束。每个九个数字必须在每个行、每个列以及由粗边框标记的每个 3×3 区域中各出现一次。这些规则决定了我们应该从哪里开始努力。谜题中间的 3×3 区域已经有八个方格的数字。因此,位于非常中心的方格只能有一个可能的值,这个值不已经在该 3×3 区域的另一个方格中出现过。那就是我们应该开始解决这个谜题的地方。该区域缺失的数字是 7,所以我们会将其放在中间的方格中。
在这个值确定之后,请注意,中间的列现在在其九个方格中有七个方格的值,这留下了两个剩余的方格,每个方格都必须有一个不在该列中的值:两个缺失的数字是 3 和 9。对这个列的限制允许我们将任何一个数字放在任何一个位置,但请注意,3 已经存在于第三行,9 已经存在于第七行。因此,行限制规定 9 必须放在中间列的第三行,3 必须放在中间列的第七行。这些步骤在图 1-12 中进行了总结。

图 1-12. 解决样本数独谜题的第一步
我们在这里不会解决整个谜题,但这些第一步说明了我们寻找具有可能值数量最少的方格——理想情况下只有一个。
经验教训
数独的主要教训是我们应该寻找问题中最受限制的部分。虽然限制通常是使问题一开始就难以解决的原因(记得那个狐狸、鹅和玉米的故事),但它们也可能简化我们对解决方案的思考,因为它们消除了选择。
尽管我们在这本书中不会具体讨论人工智能,但有一种用于解决某些类型问题的规则,称为“最受限变量”。这意味着在试图为不同的变量分配不同的值以满足限制条件的问题中,你应该从具有最多限制条件的变量开始,或者换句话说,从具有可能值数量最少的变量开始。
这里有一个这种思考方式的例子。假设一群同事想要一起吃午餐,他们已经让你找到一个大家都喜欢的餐厅。问题是,每个同事都对集体决策施加了一些限制:Pam 是素食主义者,Todd 不喜欢中餐,等等。如果你的目标是最大限度地减少找到餐厅所需的时间,你应该先与受限制最严重的同事交谈。例如,如果 Bob 有许多广泛的食品过敏,那么从找到他知道可以吃的餐厅列表开始,而不是从 Todd 开始,Todd 对中餐的厌恶很容易得到缓解。
同样的技术通常可以应用于编程问题。如果问题的某个部分受到严重限制,那是一个很好的起点,因为你可以不必担心你正在花费时间在将来会被取消的工作上。一个相关的推论是,你应该从最明显的那部分开始。如果你能解决部分问题,就去做你能做的。从看到你自己的代码中,你可能学到一些东西,这会激发你的想象力来解决剩余的问题。
Quarrasi 锁
你可能以前见过每个之前的谜题,但除非你以前读过这本书,否则你不应该看到本章的最后一个谜题,因为我自己编造了这个谜题。仔细阅读,因为这个问题的措辞有点复杂。
问题:打开外星锁
一个敌对外星种族,Quarrasi,已经登陆地球,你被他们俘虏了。尽管他们巨大且长有触手,但你设法制服了你的守卫。为了逃离(仍然在地面上)的飞船,你必须打开那扇巨大的门。奇怪的是,打开门的说明是用英语打印的,但这仍然不是一件容易的事情。要打开门,你必须将三个条形的 Kratzz 滑动到从右侧接收器到左侧接收器的轨道上,而左侧接收器位于门的末端,距离 10 英尺。
这很简单,但你必须避免触发警报,警报的工作原理如下。每个 Kratzz 上都有一个或多个被称为 Quinicrys 的星形晶体宝石。每个受体都有四个传感器,如果上方列中的 Quinicrys 数量为偶数,则传感器会亮起。如果亮起的传感器数量恰好为一个是,则会响起警报。请注意,每个受体的警报是独立的:你不可能让左侧受体或右侧受体的传感器恰好亮起一个。好消息是每个警报都配备了抑制器,只要按下按钮,就可以阻止警报响起。如果你能同时按下两个抑制器,问题就简单了,但你不能,因为你只有短人类手臂,而不是长 Quarassi 触手。
考虑到所有这些,你该如何滑动 Kratzz 而不触发任何警报来打开门?
起始配置如图图 1-13 所示,三个 Kratzz 棒都在右侧受体。为了清晰起见,图 1-14 显示了错误的做法:将最上面的 Kratzz 滑到左侧受体会导致右侧受体进入警报状态。你可能认为我们可以用抑制器避免警报,但记住我们刚刚将最上面的 Kratzz 滑到左侧受体,所以我们离右侧受体的抑制器有 10 英尺远。

图 1-13. Quarrasi 锁问题的起始配置。你必须将当前位于右侧接收器中的三个 Kratzz 条滑动到左侧接收器,而不触发任何警报。当上方列中出现偶数个星形 Quinicrys 时,传感器会亮起,如果恰好有一个连接的传感器亮起,则会响起警报。抑制器可以阻止警报响起,但仅限于你站立的接收器。

图 1-14. Quarrasi 锁处于警报状态。你刚刚将上方的 Kratzz 滑向左侧接收器,因此右侧接收器无法触及。右侧警报的第二传感器亮起,因为上方列中出现了偶数个 Quinicrys,当其传感器中恰好有一个亮起时,警报响起。
在继续之前,花些时间研究这个问题,并尝试找到一个解决方案。根据你的观点,这个问题并不像看起来那么难。认真想想,然后再继续!
你想过这个问题吗?你能否提出一个解决方案?这里有两条可能的答案路径。第一条路径是试错法:有系统地尝试各种 Kratzz 移动,并在达到警报状态时退回到之前的步骤,直到找到一系列成功的移动。
第二条路径是意识到这个谜题是一个陷阱。如果你还没有看到这个陷阱,这里就是:这其实就是狐狸、鹅和玉米问题的一个复杂伪装。尽管警报的规则写得比较宽泛,但这个特定锁的组合只有这么多。只有三个 Kratzz,我们只需要知道哪些 Kratzz 组合在接收器中是可接受的。如果我们把三个 Kratzz 标记为 top(顶部)、middle(中间)和 bottom(底部),那么会触发警报的组合是“top and middle”(顶部和中间)以及“middle and bottom”(中间和底部)。如果我们把 top 重命名为 fox(狐狸)、middle 重命名为 goose(鹅)和 bottom 重命名为 corn(玉米),那么麻烦的组合与另一个问题相同,“fox and goose”(狐狸和鹅)以及“goose and corn”(鹅和玉米)。
因此,这个问题是以与狐狸、鹅和玉米问题相同的方式解决的。我们滑动中间的 Kratzz(鹅)到左侧的接收器。然后,我们滑动顶部的(狐狸)到左侧,同时握住左侧警报的抑制器,将顶部的(狐狸)放置到位。接下来,我们开始将中间的(鹅)滑回右侧的接收器。然后,我们滑动底部的(玉米)到左侧,最后,我们再次将中间的(鹅)滑动到左侧,打开锁。
经验教训
这里的主要教训是认识到类比的重要性。在这里,我们可以看到 Quarrasi 锁问题与狐狸、鹅和玉米问题相似。如果我们能够尽早发现这个类比,我们就可以通过将我们的解决方案从第一个问题中转换过来,而不是创建一个新的解决方案,来避免大部分问题的努力。在问题解决中,大多数类比不会如此直接,但它们的发生频率会越来越高。
如果你发现这个问题与狐狸、鹅和玉米问题之间的联系有困难,那是因为我故意加入了尽可能多的无关细节。建立 Quarrasi 问题的故事是不相关的,所有外星技术的名称也是如此,它们的作用是增强陌生感。此外,警报器的奇偶机制使得问题看起来比实际上更复杂。如果你看看 Quinicrys 的实际位置,你可以看到顶部和底部的 Kratzz 是相反的,所以它们在警报系统中不相互作用。然而,中间的 Kratzz 与其他两个相互作用。
再次,如果你没有看到类比,不要担心。在你开始警惕它们之后,你会开始更多地认识到它们。
通用问题解决技术
我们所讨论的例子展示了在问题解决中使用的许多关键技术。在本书的剩余部分,我们将探讨具体的编程问题并找出解决它们的方法,但首先我们需要一套通用的技术和原则。一些问题领域有特定的技术,正如我们将看到的,但以下规则几乎适用于任何情况。如果你将这些规则作为你问题解决方法的常规部分,你将始终有一个解决问题的方法。
总是有一个计划
这可能是最重要的规则。你必须始终有一个计划,而不是进行无目的的活动。
到目前为止,你应该明白制定计划总是可能的。确实,如果你还没有在脑海中解决这个问题,那么你无法为在代码中实现解决方案制定计划。那将在以后发生。然而,即使在开始时,你也应该有一个计划,说明你将如何找到解决方案。
公平地说,计划可能需要在旅途中进行修改,或者你可能不得不放弃你的原始计划,制定另一个。那么,为什么这条规则如此重要呢?德怀特·D·艾森豪威尔将军因说过,“我总是发现计划是没有用的,但规划是必不可少的。”他的意思是战斗如此混乱,不可能预测可能发生的一切并为每一个结果都有预定的反应。从这个意义上说,那么,计划在战场上是没有用的(另一位军事领导人,普鲁士的赫尔穆特·冯·莫尔特克,著名地说过,“没有计划能在与敌人的第一次接触中存活”)。但没有任何军队可以在没有计划和组织的条件下取得成功。通过规划,将军了解他的军队的能力,了解军队的不同部分是如何协同工作的,等等。
同样,你必须始终有一个解决问题的计划。这个计划可能无法在第一次接触敌人时就存活下来——你可能在你开始将代码输入到源代码编辑器时就会放弃它——但你必须有一个计划。
没有计划,你只是在寄希望于幸运的突破,这相当于随机打字的猴子能创作出莎士比亚的戏剧。幸运的突破并不常见,即使发生了,也可能仍然需要一个计划。许多人听说过青霉素的发现故事:一位名叫亚历山大·弗莱明的研究员那天晚上忘记关闭一个培养皿,第二天早上发现培养皿中的霉菌抑制了细菌的生长。但弗莱明并没有坐等幸运的突破;他一直在以彻底和有序的方式进行实验,因此认识到了他在培养皿中看到的东西的重要性。(如果我发现我前一天晚上留下的东西上长出了霉菌,这并不会导致对科学的重大贡献。)
计划还允许你设定中间目标并实现它们。没有计划,你只有一个目标:解决整个问题。直到你解决了问题,你才觉得自己完成了什么。正如你可能已经体验到的,许多程序直到接近完成时才做些有用的事情。因此,只朝着主要目标工作不可避免地会导致挫折,因为直到最后,你的努力都没有得到积极的反馈。相反,如果你制定了一个包含一系列小目标的计划,即使其中一些似乎与主要问题无关,你也会朝着解决方案取得可衡量的进步,并觉得你的时间被有效地利用了。在每个工作会话结束时,你将能够从你的计划中勾选项目,增强信心,相信你会找到解决方案,而不是越来越沮丧。
重新陈述问题
尤其是通过狐狸、鹅和玉米问题所展示的,重新表述一个问题可以产生有价值的结果。在某些情况下,一个看似非常困难的问题,如果用不同的方式或不同的术语来表述,可能会显得容易得多。重新表述一个问题就像环绕着你必须攀登的山丘的基础;在你开始攀登之前,为什么不从每个角度检查一下,看看是否有更简单的方法上去呢?
重新表述有时会显示我们的目标并非我们想象的那样。我曾读过一篇关于一位祖母在编织的同时照看她的婴儿外孙女的报道。为了完成编织,祖母将婴儿放在她旁边的便携式游戏围栏里,但婴儿不喜欢被关在围栏里,一直在哭。祖母尝试了各种玩具来让围栏对婴儿更有趣,直到她意识到把婴儿关在围栏里只是达到目的的手段。目标是祖母能够平静地编织。解决方案:让婴儿在地板上快乐地玩耍,而祖母则在围栏里编织。重新表述可以是一种强大的技术,但许多程序员会跳过它,因为它并不直接涉及编写代码,甚至设计解决方案。这也是为什么有一个计划是至关重要的另一个原因。没有计划,你的唯一目标就是有可工作的代码,而重新表述是在浪费时间。有了计划,你可以将“正式重新表述问题”作为第一步;因此,完成重新表述正式算作是进步。
即使重新表述没有立即带来任何洞察力,它也可以以其他方式帮助。例如,如果一个问题已经分配给你(由主管或讲师),你可以将你的重新表述带给分配问题的那个人,并确认你的理解。此外,重新表述问题可能是使用其他常见技术(如简化或分解问题)的必要前提步骤。
更广泛地说,重新表述可以改变整个问题领域。我在后面的章节中分享的递归解决方案技术,是一种重新表述递归问题的方法,这样我就可以像处理迭代问题一样处理它们。
分解问题
找到一种方法将问题分解成步骤或阶段可以使问题变得容易得多。如果你能将一个问题分成两部分,你可能认为每部分解决起来会比原来的整体难一半,但通常,这甚至比那还要容易。
这里有一个如果你已经看过常见的排序算法就会熟悉的类比。假设你有 100 个文件需要按字母顺序放入一个盒子中,你的基本排序方法是所谓的插入排序:你随机选择一个文件,放入盒子中,然后按正确的顺序将下一个文件放入盒子中,与第一个文件的关系,然后继续,总是将新文件放入相对于其他文件的正确位置,这样在任何给定时间,盒子中的文件都是按字母顺序排列的。假设有人最初将文件分成 4 组,大小大致相等,A–F,G–M,N–S,和 T–Z,并告诉你分别对这 4 组进行排序,然后依次将它们放入盒子中。
如果每个组包含大约 25 个文件,那么人们可能会认为对 4 组 25 个文件进行排序的工作量与对单个 100 个文件的组进行排序的工作量大致相同。但实际上,工作量要少得多,因为插入单个文件所需的工作量会随着已归档文件数量的增加而增长——你必须查看盒子中的每个文件,才能知道新文件应该放在哪里。(如果你对此表示怀疑,想想一个更极端的版本——比较一下对 50 组每组 2 个文件进行排序的想法,你可能在一分钟内就能完成,与对单个 100 个文件的组进行排序相比。)
同样,将问题分解通常可以降低一个数量级的难度。组合编程技术比单独使用技术要复杂得多。例如,一个在for循环内部嵌套while循环的代码段中使用了多个if语句的部分,将比一个使用所有相同控制语句按顺序执行的代码段更难编写——也更难阅读。
我们将在接下来的章节中讨论具体的问题分解方法,但你应该始终保持警惕,注意这种可能性。记住,有些问题,比如我们的滑动拼图问题,通常隐藏着它们的潜在分解。有时,找到问题分解的方法是减少问题,正如我们很快将要讨论的。
从你所知开始
第一次写作小说的人经常得到这样的建议:“写你所知。”这并不意味着小说家应该只尝试围绕他们在自己生活中直接观察到的事件和人来创作作品;如果是这样的话,我们就永远不会有幻想小说、历史小说或许多其他流行体裁。但它的意思是,作家离自己的经历越远,写作可能就越困难。
同样,在编程时,你应该尝试从你已知如何做的事情开始,并从这里向外扩展。一旦你将问题分解成几个部分,例如,你可以先完成你已知如何编码的部分。拥有一个可工作的部分解决方案可能会激发对剩余问题的想法。此外,正如你可能已经注意到的,问题解决中的一个共同主题是通过有用的进展来建立信心,相信你最终会完成任务。通过从你所知开始,你建立信心并积累动力,朝着目标前进。
“从你所知开始”的原则也适用于你没有分解问题的情形。想象一下,有人列出了编程中所有技能的完整清单:编写 C++类、排序数字列表、在链表中找到最大值等等。在你作为程序员的每个发展阶段,你都会有很多你做得好的技能,一些你努力就能使用的技能,以及你还不了解的技能。特定的问题可能完全可以用你已有的技能来解决,也可能不行,但在你四处寻找答案之前,你应该充分利用你头脑中的技能来彻底调查这个问题。如果我们把编程技能看作是工具,把编程问题看作是家庭维修项目,你应该在前往五金店之前,先尝试使用车库里的工具来完成维修。
这种技术遵循我们之前讨论的原则。它遵循一个计划,并为我们的努力提供秩序。当我们开始通过应用我们已有的技能来调查问题时,我们可能会对问题及其最终解决方案有更多的了解。
简化问题
使用这种技术,当你面对一个无法解决的问题时,你可以通过添加或移除约束来缩小问题范围,从而产生一个你知道如何解决的问题。我们将在后面的章节中看到这个技术的实际应用,但这里有一个基本的例子。假设你被给出了一系列三维空间中的坐标,你必须找到彼此最接近的坐标。如果你立刻不知道如何解决这个问题,你可以用不同的方法来简化问题以寻求解决方案。例如,如果这些坐标是在二维空间而不是三维空间中呢?如果这还不行,如果这些点沿一条直线排列,那么坐标就只是单个数字(比如说 C++的 double 类型)呢?现在问题本质上变成了,在数字列表中找到两个绝对差值最小的数字。
或者,你可以通过保持坐标在三维空间中,但只有三个值,而不是任意大小的系列来简化问题。所以,不是找到一个坐标对之间最小距离的算法,而只是比较坐标 A 与坐标 B,然后是 B 与 C,最后是 A 与 C。
这些简化方法以不同的方式简化问题。第一种简化方法消除了计算三维点之间距离的需要。也许我们还没有学会如何做这件事,但直到我们弄清楚这一点,我们仍然可以朝着解决方案取得进展。相比之下,第二种简化方法几乎完全集中在计算三维点之间的距离上,但消除了在任意大小的值序列中找到最小值的问题。
当然,为了解决原始问题,我们最终需要两种简化方法中涉及的技能。即便如此,简化方法仍然允许我们在无法找到将问题分解为步骤的方法时,对更简单的问题进行工作。实际上,这就像是一个故意但暂时的 Kobayashi Maru。我们知道我们并没有在处理完整的问题,但简化问题与完整问题有足够的共同点,这样我们就能朝着最终解决方案取得进展。很多时候,程序员会发现他们拥有解决该问题所需的所有个别技能,通过编写代码来解决问题的每个个别方面,他们可以看到如何将各种代码片段组合成一个统一的整体。
简化问题还允许我们精确地确定剩余困难所在的位置。初学者程序员经常需要寻求经验丰富的程序员的帮助,但如果遇到困难的程序员无法准确描述所需帮助,这可能会对所有人都是一个令人沮丧的经历。没有人愿意降低到说,“这是我的程序,它不起作用。为什么?”使用问题简化技术,一个人可以精确地确定所需帮助,比如说,“这里有一些我写的代码。正如你所看到的,我知道如何找到两个三维坐标之间的距离,也知道如何检查一个距离是否小于另一个。但我似乎找不到一个通用的解决方案来找到具有最小距离的坐标对。”
寻找类比
在我们的目的中,类比是指当前问题与已经解决的问题之间的相似性,这种相似性可以被利用来帮助解决当前问题。这种相似性可以采取多种形式。有时这意味着两个问题实际上是同一个问题。这就是我们处理狐狸、鹅和玉米问题以及 Quarrasi 锁问题的情形。
大多数类比并不那么直接。有时相似性只涉及问题的部分。例如,两个数字处理问题可能在所有方面都不同,除了它们都处理需要比内置浮点数据类型提供的精度更高的数字;你将无法使用这个类比来解决整个问题,但如果你已经找到了处理额外精度问题的方法,你可以以相同的方式再次处理相同的问题。
虽然识别类比是提高你解决问题速度和技能的最重要方式,但它也是最难培养的技能。之所以一开始这么困难,是因为你必须在参考之前有一个先前解决方案的仓库来寻找类比。
这就是开发中的程序员常常试图走捷径的地方,他们寻找与所需代码相似的代码,并从那里进行修改。然而,由于几个原因,这实际上是一个错误。首先,如果你没有自己完成解决方案,你就不会完全理解和内化它。简单来说,很难正确修改一个你不完全理解的程序。你不需要亲自编写代码就能完全理解,但如果你不能编写代码,你的理解必然是有限的。其次,你编写的每一个成功的程序都不仅仅是对当前问题的解决方案;它也是解决未来问题的类比潜在来源。你现在越依赖其他程序员的代码,将来你就越需要依赖它。我们将在第七章([第七章](ch07.html "第七章. 使用代码复用来解决问题"))深入讨论“好的复用”和“坏的复用”。
实验
有时候,取得进展的最佳方式是尝试并观察结果。请注意,实验与猜测不同。当你猜测时,你输入一些代码并希望它能够工作,但你并没有强烈的信念认为它会成功。实验是一个受控的过程。你假设当执行某些代码时会发生什么,然后尝试它,看看你的假设是否正确。从这些观察中,你获得的信息将帮助你解决原始问题。
在处理应用程序编程接口或类库时,实验可能特别有帮助。假设你正在编写一个使用表示向量的库类的程序(在这个上下文中,是一个随着添加更多项目而自动增长的二维数组),但你以前从未使用过这个向量类,你不确定从向量中删除项目会发生什么。与其在心中充满不确定性时继续解决原始问题,不如创建一个简短的、独立的程序来与向量类玩耍,并特别尝试你关心的情况。如果你在“向量演示”程序上花一点时间,它可能会成为未来使用该类的工作的参考。
其他形式的实验与调试类似。假设某个程序产生的输出与预期相反——例如,如果输出是数值的,数字是预期的,但顺序相反。如果你在审查你的代码后仍然不明白为什么会这样,作为一个实验,你可能尝试修改代码以故意使输出相反(例如,以相反方向运行循环)。输出结果的变化或变化不足可能会揭示你原始源代码中的问题,或者揭示你理解上的差距。无论如何,你离解决方案更近了。
不要感到沮丧
最后一种技巧与其说是一种技巧,不如说是一条格言:不要感到沮丧。当你感到沮丧时,你不会思考得那么清晰,你不会工作得那么高效,一切都会花费更长的时间,看起来更困难。更糟糕的是,挫败感往往会自我滋养,所以一开始可能是轻微的不满,最终会变成直接的愤怒。
当我向新程序员提出这些建议时,他们经常反驳说,虽然他们在原则上同意我的观点,但他们无法控制自己的挫败感。要求程序员在失败时不要感到沮丧,这不就像要求一个小男孩踩到刺上时不要喊叫一样吗?答案是否定的。当有人踩到刺上时,强烈的信号会立即通过中枢神经系统传递,大脑的底层会做出反应。除非你知道你即将踩到刺上,否则你不可能及时反应来阻止大脑的自动反应。所以,我们让这个小男孩因为喊叫而免受责备。
程序员并不处于同样的境地。冒着听起来像自我帮助大师的风险,一个沮丧的程序员并不是对外部刺激做出反应。沮丧的程序员并不是对显示器上的源代码感到愤怒,尽管程序员可能会用这种方式表达挫败感。相反,沮丧的程序员是在生自己的气。挫败感的来源也是目的地,程序员的思维。
当你让自己感到沮丧——我故意使用“允许”这个词——实际上你是在给自己一个继续失败的理由。假设你正在解决一个难题,你感到自己的挫败感在上升。几个小时后,你回想起一个下午咬紧牙关、愤怒地折断铅笔的画面,告诉自己,如果你能冷静下来,你本可以取得真正的进展。事实上,你可能已经决定,屈服于愤怒比面对难题更容易。
最终,避免挫败感是一个你必须做出的决定。然而,有一些想法你可以采用,这将有助于你。首先,永远不要忘记第一条规则,那就是你应该始终有一个计划,并且虽然编写解决原始问题的代码是这个计划的目标,但这并不是计划的唯一步骤。因此,如果你有一个计划并且正在遵循它,那么你正在取得进步,你必须相信这一点。如果你已经完成了原始计划上的所有步骤,但你仍然没有准备好开始编码,那么是时候制定另一个计划了。
此外,当涉及到挫败感或休息时,你应该选择休息。一个技巧是同时处理多个问题,这样如果这个问题让你感到困惑,你就可以把精力转向其他地方。注意,如果你成功地分解了问题,你可以在单个问题上使用这个技巧;只需屏蔽让你卡住的部分,然后做其他事情。如果你没有其他问题可以解决,就离开椅子去做其他事情,做一些能让你的血液流动但不会让你的大脑受伤的事情:散步,洗衣服,进行拉伸练习(如果你打算成为一名程序员,整天坐在电脑前,我强烈建议你发展一个拉伸练习的习惯!)。直到休息结束之前,不要思考问题。
练习
记住,要真正学会某样东西,你必须将其付诸实践,所以尽可能多地做练习。当然,在这一章中,我们还没有讨论编程,但即便如此,我也鼓励你尝试一些练习。想想这些问题是我们开始演奏真正的音乐之前为你的手指做热身。
-
尝试一个中等难度的数独谜题(你可以在网上找到这些,也许在当地报纸上也能找到),尝试不同的策略并记录结果。你能为解决数独制定一个通用计划吗?
-
考虑一个滑动拼图变体,其中瓷砖上覆盖的是图片而不是数字。这增加了多少难度,为什么?
-
找到一个与我不同的滑动拼图策略。
-
寻找传统的狐狸、鹅和玉米品种的谜题,并尝试解决它们。许多伟大的谜题都是由萨姆·洛伊德(Sam Loyd)原创或普及的,所以你可能需要搜索他的名字。此外,一旦你发现了(或放弃并阅读)解决方案,想想你如何可以制作一个更容易的谜题版本。你需要改变什么?是限制条件还是只是措辞?
-
尝试为其他传统的纸笔游戏编写一些明确的策略,比如填字游戏。你应该从哪里开始?当你卡住时应该做什么?即使是像“Jumble”这样的简单报纸游戏,对于思考策略也是很有用的。
第二章. 纯粹的谜题

在本章中,我们将开始处理实际的代码。虽然中级编程知识将在后面的章节中需要,但本章所需的编程技能尽可能简单。这并不意味着所有这些谜题都会很容易,只是说你应该能够专注于问题解决,而不是编程语法。这是最纯粹的问题解决。一旦你弄清楚自己想要做什么,将你的想法转化为 C++ 代码将会很直接。记住,仅仅阅读这本书本身提供的益处是有限的。你应该在我们讨论时,尝试解决任何对你来说非平凡的问题,尝试在阅读我的方法之前自己解决。在本章结束时,尝试一些练习,其中许多将是我们在讨论中讨论的问题的扩展。
本章节中使用的 C++ 代码回顾
本章使用的是你应已熟悉的 C++ 基础,包括控制语句 if、for、while 和 do-while,以及 switch。你可能还不习惯用这些语句编写代码来解决原创问题——毕竟,这正是本书的主题。然而,你应该理解这些语句的语法,或者手头有一本好的 C++ 参考书。
你还应该知道如何编写和调用函数。为了保持简单,我们将使用标准流 cin 和 cout 进行输入和输出。要使用这些流,在你的代码中包含必要的头文件 iostream,并为两个标准流对象添加 using 语句:
#include <iostream>
using std::cin;
using std::cout;
为了简洁,这些语句将不会在代码列表中展示。它们的存在被假定为任何使用它们的程序中。
输出模式
在本章中,我们将解决三个主要问题。由于我们将广泛使用问题分解和缩减技术,每个主要问题都将产生几个子问题。在这个第一部分,让我们尝试一系列产生规则形状图案输出的程序。这样的程序可以培养编写循环的技能。
问题:正方形的一半
编写一个程序,只使用两个输出语句 cout << "#" 和 cout << "\n",以产生一个类似完美 5 × 5 正方形(或直角三角形)的哈希符号图案:
#####
####
###
##
#
这又是约束重要性的一个很好的例子。如果我们忽略只能使用两个输出语句的要求,一个产生单个哈希符号,另一个产生行尾,我们可以写一个 Kobayashi Maru 并轻易解决这个问题。然而,有了这个约束,我们就必须使用循环来解决这个问题。
你可能已经在脑海中看到了解决方案,但让我们假设你没有。一种好的武器是简化。我们如何将这个问题简化到容易解决的程度?如果模式是一个完整的正方形而不是正方形的一半呢?
问题:一个正方形(正方形的一半简化)
编写一个程序,使用仅两个输出语句 cout << "#" 和 cout << "\n" 来生成一个形状为完美 5x5 正方形的井号符号图案:
#####
#####
#####
#####
#####
这可能足以让我们开始,但假设我们也不知道如何解决这个问题。我们可以进一步简化问题,只制作一条井号符号的行而不是正方形。
问题:一条线(正方形的一半进一步简化)
编写一个程序,使用仅两个输出语句 cout << "#" 和 cout << "\n" 来生成一行五个井号符号:
#####
现在我们有一个可以用 for 循环解决的问题:
for (int hashNum = 1; hashNum <= 5; hashNum++) {
cout << "#";
}
cout << "\n";
从这里,返回到之前的全平方形状。全平方只是五次重复的五条井号符号的行。我们知道如何编写重复的代码;我们只需写一个循环。因此,我们可以将我们的单循环转换为双循环:
for (int row = 1; row <= 5; row++) {
for (int hashNum = 1; hashNum <= 5; hashNum++) {
cout << "#";
}
cout << "\n";
}
我们将之前列表中的所有代码放入一个新的循环中,以便它重复五次,产生五行,每行是五条井号符号。我们越来越接近最终解决方案。我们如何修改代码,使其产生半平方图案?如果我们查看最后的列表并将其与我们的所需半平方输出进行比较,我们可以看到问题在于条件表达式 hashNum <= 5。这个条件在每个行上产生相同的五条井号符号的行。我们需要的是一个机制来调整每行产生的符号数量,以便第一行得到五个符号,第二行得到四个,以此类推。
为了了解如何做到这一点,让我们进行另一个简化程序实验。同样,总是最容易单独处理问题的麻烦部分。让我们暂时忘记井号符号,只谈论数字。
问题:通过计数向上计数向下
在下面的列表中,编写一行代码放入循环指定的位置。程序按顺序显示数字 5 到 1,每个数字占一行。
for (int row = 1; row <= 5; row++) {
cout << *`expression`* << "\n";
}
我们必须找到一个表达式 ![http://atomoreilly.com/source/no_starch_images/1273182.png],当 row 为 1 时为 5,当 row 为 2 时为 4,以此类推。如果我们想要一个随着 row 增加而减少的表达式,我们首先可能会想到在 row 的值前加上一个负号,通过将 row 乘以 -1 来实现。这会产生递减的数字,但不是我们想要的数字。尽管如此,我们可能比我们想象的更接近。所需值与将 row 乘以 -1 得到的值之间的差异是什么?表 2-1 总结了这一分析。
表 2-1. 从行变量计算所需值
| 行 | 所需值 | 行 * −1 | 与所需值的差异 |
|---|---|---|---|
| 1 | 5 | −1 | 6 |
| 2 | 4 | −2 | 6 |
| 3 | 3 | −3 | 6 |
| 4 | 2 | −4 | 6 |
| 5 | 1 | −5 | 6 |
差值是一个固定值,6。这意味着我们需要的表达式是 row * −1 + 6。通过一点代数,我们可以简化这个表达式到 6 - row。让我们试试:
for (int row = 1; row <= 5; row++) {
cout << 6 - row << "\n";
}
太好了——它工作了!如果这没有工作,我们的错误可能很小,因为我们已经采取了谨慎的步骤。再次强调,实验一个小而简单的代码块非常容易。现在让我们用这个表达式,并使用它来限制内循环:
for (int row = 1; row <= 5; row++) {
for (int hashNum = 1; hashNum <= 6 - row; hashNum++) {
cout << "#";
}
cout << "\n";
}
使用缩减技术需要更多步骤从描述到完成程序,但每一步都更容易。想象一下使用一系列滑轮来提升重物:你必须拉绳子更远才能获得相同的提升量,但每次拉动对肌肉的压力都小得多。
在继续之前,让我们解决另一个形状问题。
问题:侧向三角形
编写一个程序,仅使用两个输出语句 cout << "#" 和 cout << "\n",来生成一个像侧向三角形形状的井号符号图案:
#
##
###
####
###
##
#
我们不会走完之前问题中使用的所有步骤,因为我们不需要。这个“侧向三角形”问题与“一半的方形”问题类似,因此我们可以将后者学到的知识应用到前者中。还记得“从已知开始”的原则吗?让我们先列出可以从“一半的方形”问题应用到这个问题中的技能和技术。我们知道如何:
-
使用循环显示特定长度的符号行
-
使用嵌套循环显示一系列行
-
使用代数表达式而不是固定值来创建每行不同的符号数量
-
通过实验和分析发现正确代数表达式
图 2-1 总结了我们的当前位置。第一行显示了之前的“一半的方形”问题。我们看到所需的井号符号图案(a)、线条图案(b)、方形图案(c)以及将方形图案转换为半方形图案的数字序列(d)。第二行显示了当前的“侧向三角形”问题。我们再次看到所需的图案(e)、线条(f)、矩形图案(g)和数字序列(h)。
到这一点,我们不会有困难产生(f),因为它几乎和(b)一样。我们也应该能够产生(g),因为它只是(c)增加了行数,每行符号减少一个。最后,如果有人给我们一个会产生数字序列(h)的代数表达式,我们将没有困难创建所需的图案(e)。
因此,为“侧向三角形”问题创建解决方案所需的大部分心智工作已经完成。此外,我们确切地知道还剩下哪些心智工作:找出一个表达式来生成数字序列(h)。所以我们应该把注意力集中在这里。我们可以要么使用“一半的平方”问题的完成代码进行实验,直到我们能够生成所需的数字序列,要么猜测并制作一个像表 2-1 那样的表格,看看是否能够激发我们的创造力。

图 2-1. 解决形状问题所需的各个组件
让我们这次尝试实验。在“一半的平方”问题中,从较大的数字中减去行数效果很好,所以让我们看看通过将row在 1 到 7 之间循环运行并从 8 中减去row,我们会得到什么数字。结果如图图 2-2(b)所示。这不是我们想要的。接下来怎么办?在前一个问题中,我们需要一个递减的数字而不是递增的,所以我们从较大的数字中减去了循环变量。在这个问题中,我们需要先上升再下降。从中间的数字中减去是否合理?如果我们把之前的代码中的8 - row替换为4 - row,我们就会得到图 2-2(c)中的结果。这也不正确,但它看起来可能是一个有用的模式,如果我们不关注最后三个数字上的减号的话。如果我们使用绝对值函数来去除这些减号会怎样?表达式abs(4 - row)产生了图 2-2(d)中的结果。现在我们非常接近了——我几乎能尝到它了!只是我们现在是在先下降再上升,而我们需要先上升再下降。但我们如何从我们已有的数字序列转换到我们需要的数字序列呢?
让我们尝试以不同的方式查看图 2-2(d)中的数字。如果我们像图 2-2(e)所示的那样,计算空格而不是井号,会怎样呢?如果我们计算空格,列(d)就是正确的值模式。为了得到正确的井号数量,想象每一行有四个方框,然后减去空格的数量。如果每一行有四个方框,其中abs(4 - row)是空格,那么带有井号的方框数量将由4 - abs(4 - row)给出。这行得通。把它代入,试试看。

图 2-2. 解决“侧向三角形”问题所需的各个组件
我们通过类比避开了这个问题的大部分工作,并通过实验解决了其余部分。当一个新的问题与另一个你可以解决的问题非常相似时,这种一石二鸟的方法是非常好的。
输入处理
之前的程序只产生输出。让我们改变一下,尝试一些主要处理输入的程序。这些程序都有一个共同的限制:输入将逐字符读取,程序必须在读取下一个字符之前处理每个字符。换句话说,程序不会将字符存储在数据结构中以供后续处理,而是边走边处理。
在这个问题中,我们将执行身份证号码验证。在现代社会,几乎所有东西都有一个身份证号码,比如 ISBN 或客户号码。有时这些号码需要手动输入,这引入了出错的可能性。如果输入错误且不匹配任何有效的身份证号码,系统可以轻松拒绝它。但如果号码错误,却是有效的呢?例如,如果收银员在尝试为产品退货给您的账户时输入了另一个客户的账户号码,其他客户会收到您的信用。为了避免这种情况,已经开发出检测身份证号码错误的系统。它们通过将身份证号码通过一个公式运行来工作,该公式生成一个或多个额外的数字,这些数字成为扩展身份证号码的一部分。如果任何数字被更改,原始号码部分和额外数字将不再匹配,该号码可以被拒绝。
问题:Luhn 校验和验证
Luhn 公式是一个广泛用于验证识别号的系统。使用原始号码,将每隔一个数字的值翻倍。然后将各个数字的值相加(如果一个翻倍的值现在是一个两位数,则单独相加这些数字)。如果总和能被 10 整除,则识别号有效。
编写一个程序,该程序接受任意长度的识别号并确定该号码是否根据 Luhn 公式有效。程序必须处理每个字符,然后再读取下一个字符。
这个过程听起来有点复杂,但一个例子会让一切变得清晰。我们的程序将只验证识别号,而不是创建校验位。让我们来梳理一下这个过程的两端:计算校验位和验证结果。这个过程在图 2-3 中展示。在部分(a)中,我们计算校验位。原始识别号 176248 显示在虚线框中。从原始号码的最右边数字开始(在添加校验位后,它将成为第二右边的数字),每隔一个数字的值翻倍。然后,将每个数字的值相加。注意,当翻倍一个数字得到一个两位数时,这些数字要单独考虑。例如,当 7 翻倍得到 14 时,加到校验和中的不是14,而是1和4分别。在这种情况下,校验和是 27,所以校验位是 3,因为这是使总和为 30 的数字值。记住,最终号码的校验和应该能被 10 整除;换句话说,它应该以 0 结尾。

图 2-3. Luhn 校验和公式
在部分(b)中,我们验证了包含校验位的号码 1762483。这是我们将在这个问题中使用的过程。和之前一样,我们从校验位右边的数字开始,每隔一个数字翻倍,并将所有数字的值(包括校验位)相加来确定校验和。因为校验和能被 10 整除,所以这个号码是有效的。
问题分解
将要解决这个问题的程序有几个独立的问题我们需要处理。一个问题就是数字的加倍,这很棘手,因为加倍后的数字是从识别号码的右端确定的。记住,我们不会读取和存储所有的数字,然后才处理。我们将边走边处理。问题是我们将从左到右获取数字,但我们实际上需要从右到左,以便知道哪些数字需要加倍。如果我们知道识别号码中有多少位数字,我们就会知道哪些数字需要加倍,但我们不知道,因为问题说明识别号码的长度是任意的。另一个问题是,10 及以上的加倍数字必须根据它们的各个数字来处理。此外,我们必须确定何时已经读取了整个识别号码。最后,我们必须找出如何逐位读取数字。换句话说,用户将输入一个长数字,但我们希望像输入单独的数字一样读取它。
因为我们总是想要有一个计划,所以我们应该列出这些问题,并逐一解决:
-
知道哪些数字需要加倍
-
根据各个数字处理 10 及以上的加倍数字
-
知道我们已经到达数字的末尾
-
分别读取每个数字
为了解决问题,我们将在编写最终解决方案之前先处理各个部分。因此,没有必要按任何特定顺序处理这些问题。从看起来最容易的问题开始,或者如果你想要挑战,从看起来最困难的问题开始。或者,你也可以从最有趣的问题开始。
让我们先解决 10 及以上的加倍数字。这是一个问题约束使事情变得更容易而不是更困难的情况。计算任意整数的数字之和本身可能是一项相当多的工作。但这里的可能值范围是多少?如果我们从单个数字 0-9 开始加倍,最大值是 18。因此,只有两种可能性。如果加倍后的值是一个单独的数字,那么就没有更多的事情要做。如果加倍后的值是 10 或更大,那么它必须在 10-18 的范围内,因此第一位数字总是 1。让我们做一个快速的代码实验来确认这种方法:
int digit;
cout << "Enter a single digit number, 0-9: ";
cin >> digit;
int doubledDigit = digit * 2;
int sum;
if (doubledDigit >= 10) sum = 1 + doubledDigit % 10;
else sum = doubledDigit;
cout << "Sum of digits in doubled number: " << sum << "\n";
备注
%运算符被称为模运算符。对于正整数,它返回整数除法的余数。例如,12 % 10 将是 2,因为将 10 除入 12 后,剩下的 2。
这是一段简单的代码:程序读取数字,将其加倍
,然后计算加倍数字的各位数
,最后输出总和
。实验的核心是计算大于 10 的加倍数字的总和
。就像我们在形状问题中计算特定行所需的井号数量一样,将这个计算单独放在一个简短的程序中使得实验变得容易。即使我们一开始没有得到正确的公式,我们也会很快找到它。
在我们从我们的问题列表中划掉这个问题之前,让我们把这个代码转换成一个简短的功能,我们可以用它来简化未来的代码列表:
int doubleDigitValue(int digit) {
int doubledDigit = digit * 2;
int sum;
if (doubledDigit > 10) sum = 1 + doubledDigit % 10;
else sum = doubledDigit;
return sum;
}
现在,让我们来处理读取识别号的各个数字。同样,如果我们想的话,我们可以解决下一个不同的问题,但我认为这个问题是一个不错的选择,因为它将允许我们在测试问题的其他部分时自然地输入识别号。
如果我们将识别号作为数值类型(例如int)读取,我们只会得到一个很长的数字,而且我们还有很多工作要做。此外,我们能够读取的整数大小是有限的,而问题说明识别号是任意长度的。因此,我们必须逐个字符地读取。这意味着我们需要确保我们知道如何读取表示数字的字符并将其转换为我们可以进行数学运算的整数类型。为了看看如果我们直接使用字符值并在整数表达式中使用它会发生什么,请看以下列表,其中包含示例输出。
char digit;
cout << "Enter a one-digit number: ";
digit = cin.get();
int sum = digit;
cout << "Is the sum of digits " << sum << "? \n";
Enter a one-digit number: 7
Is the sum of digits 55?
注意,我们使用get方法
是因为基本的提取操作符(如cin >> digit)会跳过空白字符。这里没有问题,但正如你将看到的,这会在以后造成麻烦。在示例输入和输出
中,你可以看到这个问题。所有计算机数据本质上都是数字的,所以单个字符由整数字符代码表示。不同的操作系统可能使用不同的字符代码系统,但在这篇文章中,我们将关注常见的 ASCII 系统。在这个系统中,字符 7 存储为字符代码值 55,因此当我们将其视为整数时,我们得到 55。我们需要一种机制将字符 7 转换为整数 7。
问题:将字符数字转换为整数
编写一个程序,从用户那里读取一个表示数字(0 到 9)的字符。将字符转换为 0 到 9 范围内的等效整数,然后将该整数输出以展示结果。
在上一节中的形状问题中,我们有一个具有一个值域的变量,我们希望将其转换为另一个值域。我们制作了一个表格,包含原始值和目标值的列,然后检查两者之间的差异。这是一个类似的问题,我们可以再次使用表格的想法,如表 2-2。
表 2-2. 字符代码和目标整数值
| 字符 | 字符代码 | 目标整数 | 差值 |
|---|---|---|---|
| 0 | 48 | 0 | 48 |
| 1 | 49 | 1 | 48 |
| 2 | 50 | 2 | 48 |
| 3 | 51 | 3 | 48 |
| 4 | 52 | 4 | 48 |
| 5 | 53 | 5 | 48 |
| 6 | 54 | 6 | 48 |
| 7 | 55 | 7 | 48 |
| 8 | 56 | 8 | 48 |
| 9 | 57 | 9 | 48 |
字符代码与目标整数之间的差值始终为 48,所以我们只需要减去这个值。你可能已经注意到,这是零字符的字符代码值,0。这始终是正确的,因为字符代码系统总是按照顺序存储数字字符,从 0 开始。因此,我们可以通过减去字符0而不是使用预定的值(如 48)来创建一个更通用、更易读的解决方案:
char digit;
cout << "Enter a one-digit number: ";
cin >> digit;
int sum = digit - '0';
cout << "Is the sum of digits " << sum << "? \n";
现在我们可以继续确定哪些数字需要加倍。这部分问题可能需要几个步骤才能解决,所以让我们尝试一个问题简化。如果我们最初限制自己为一个固定长度的数字,这将确认我们对通用公式的理解,同时朝着最终目标迈进。让我们尝试将长度限制为六位;这足以很好地代表整体挑战。
问题:Luhn 校验和验证,固定长度
编写一个程序,该程序接受一个长度为六位的识别号(包括其校验位),并确定该号码是否在 Luhn 公式下有效。程序必须在读取下一个字符之前处理每个字符。
如前所述,我们可以进一步简化,使开始尽可能容易。如果我们改变公式,使得没有任何一个数字被重复,那么程序只需要读取数字并将它们相加。
问题:简单的校验和验证,固定长度
编写一个程序,该程序接受一个长度为六位的识别号(包括其校验位),并确定该号码是否在简单公式下有效,其中每个数字的值相加,然后检查结果是否能被 10 整除。程序必须在读取下一个字符之前处理每个字符。
因为我们知道如何将单个数字作为字符读取,所以我们可以轻松解决这个固定长度的简单校验和问题。我们只需要读取六个数字,将它们相加,然后判断和是否能被 10 整除。
char digit;
int checksum = 0;
cout << "Enter a six-digit number: ";
for (int position = 1; position <= 6; position ++) {
cin >> digit;
checksum += digit - '0';
}
cout << "Checksum is " << checksum << ". \n";
if (checksum % 10 == 0) {
cout << "Checksum is divisible by 10\. Valid. \n";
} else {
cout << "Checksum is not divisible by 10\. Invalid. \n";
}
从这里开始,我们需要添加实际 Luhn 验证公式的逻辑,这意味着从最右边第二个数字开始,每隔一个数字进行加倍。由于我们目前限制自己使用六位数,因此我们需要从左边开始对位置一、三和五的数字进行加倍。换句话说,如果位置是奇数,我们就加倍这个数字。我们可以使用取模运算符(%)来识别奇数和偶数位置,因为偶数的定义是它能被 2 整除。所以如果表达式 position % 2 的结果是 1,那么 position 是奇数,我们应该加倍。重要的是要记住,这里的“加倍”不仅意味着加倍单个数字,如果加倍的结果是 10 或更大,还要将加倍数字的各位数相加。这正是我们之前函数真正发挥作用的地方。当我们需要根据 Luhn 公式加倍一个数字时,我们只需将其发送到我们的函数并使用结果。将这一切结合起来,只需将之前列表中的 for 循环内的代码进行更改:
for (int position = 1; position <= 6; position++) {
cin >> digit;
if (position % 2 == 0) checksum += digit - '0';
else checksum += doubleDigitValue(digit - '0');
}
到目前为止,我们在这个问题上已经取得了很大的进展,但在我们可以编写任意长度识别号码的代码之前,还有几个步骤要走。为了最终解决这个问题,我们需要分而治之。假设我要求你修改之前的代码以处理 10 或 16 位数的数字。那将是微不足道的——你只需将用作循环上界的 6 改成另一个值。但假设我要求你验证七位数的数字。那将需要一些小的额外修改,因为如果数字的位数是奇数,并且我们从最右边第二个数字开始加倍每个数字,那么最左边的第一个数字就不再加倍了。在这种情况下,你需要加倍偶数位置:2、4、6 等等。暂时把这个问题放在一边,让我们来看看如何处理任何偶数长度的数字。
我们面临的第一问题是确定何时到达数字的末尾。如果用户输入一个多数字并按下回车键,而我们正在逐个字符读取输入,那么在最后一个数字之后读取的是什么字符?这实际上会根据操作系统而变化,但我们将只写一个实验:
cout << "Enter a number: ";
char digit;
while (true) {
digit = cin.get();
cout << int(digit) << " ";
}
这个循环会无限运行,但它完成了工作。我输入了数字 1234 并按下了回车键。结果是 49 50 51 52 10(基于 ASCII;这会根据操作系统而变化)。因此,10 是我要找的数字。有了这个信息,我们可以用 while 循环替换之前代码中的 for 循环:
char digit;
int checksum = 0;
int position = 1;
cout << "Enter a number with an even number of digits: ";
digit = cin.get();
while (digit != 10) {
if (position % 2 == 0) checksum += digit - '0';
else checksum += 2 * (digit - '0');
digit = cin.get();
position++;
}
cout << "Checksum is " << checksum << ". \n";
if (checksum % 10 == 0) {
cout << "Checksum is divisible by 10\. Valid. \n";
} else {
cout << "Checksum is not divisible by 10\. Invalid. \n";
}
在这段代码中,position不再是for循环中的控制变量,因此我们必须单独初始化!
和递增它!
。循环现在由条件表达式!
控制,该表达式检查表示行结束的字符代码值。因为我们需要一个值来检查我们第一次进入循环时的情况,所以我们读取循环开始前的第一个值!
,然后在处理代码之后读取循环中的每个后续值!
。
再次,这段代码将处理任何偶数长度的数字。要处理任何奇数长度的数字,我们只需修改处理代码,反转if语句条件的逻辑!
,以便将偶数位置的数字加倍,而不是奇数位置的数字。
至少,这已经穷尽了所有可能性。身份证号码的长度必须是奇数或偶数。如果我们事先知道长度,我们就会知道在-号码中是加倍奇数位置还是偶数位置。然而,我们并没有这样的信息,直到我们读完整个数。在这些约束条件下,解决方案是不可能的吗?如果我们知道如何解决奇数位数字和偶数位数字的问题,但我们不知道数字中有多少位,直到我们完全读取它,我们如何解决这个问题?
你可能已经看到了这个问题的答案。如果你没有,那不是因为答案很难,而是因为它隐藏在细节中。我们在这里可以使用类比,但我们还没有看到类似的情境。相反,我们将自己创造一个与这种情境明确相关的问题,看看直面问题是否有助于我们找到解决方案。清除你基于迄今为止的工作的先入之见,阅读以下问题。
问题:正数或负数
编写一个程序,从用户那里读取 10 个整数。在所有数字都输入完毕后,用户可以要求显示正数的数量或负数的数量。
这是一个简单的问题,一个看起来没有任何复杂性的问题。我们只需要一个变量来计算正数,另一个变量来计算负数。当用户在程序结束时指定请求时,我们只需查询适当的变量以获得响应:
int number;
int positiveCount = 0;
int negativeCount = 0;
for (int i = 1; i <= 10; i++) {
cin >> number;
if (number > 0) positiveCount++;
if (number < 0) negativeCount++;
}
char response;
cout << "Do you want the (p)ositive or (n)egative count? ";
cin >> response;
if (response == 'p')
cout << "Positive count is " << positiveCount << "\n";
if (response == 'n')
out << "Negative count is " << negativeCount << "\n";
这显示了我们需要用于 Luhn 校验和问题的方法:同时跟踪两种方式的运行校验和,就像识别号是奇数长度一样,然后再像它是偶数长度一样。当我们到达数字的末尾并发现真正的长度时,我们将在一个变量或另一个变量中拥有正确的校验和。
拼接碎片
我们现在已经完成了原始“待办”列表上的所有事项。现在是时候将所有东西放在一起并解决这个问题了。因为我们已经单独解决了所有子问题,所以我们确切地知道我们需要做什么,并且可以使用我们之前的程序作为参考,快速产生最终结果:
char digit;
int oddLengthChecksum = 0;
int evenLengthChecksum = 0;
int position = 1;
cout << "Enter a number: ";
digit = cin.get();
while (digit != 10) {
if (position % 2 == 0) {
oddLengthChecksum += doubleDigitValue(digit - '0');
evenLengthChecksum += digit - '0';
} else {
oddLengthChecksum += digit - '0';
evenLengthChecksum += doubleDigitValue(digit - '0');
}
digit = cin.get();
position++;
}
int checksum;
if ((position - 1) % 2 == 0) checksum = evenLengthChecksum;
else checksum = oddLengthChecksum;
cout << "Checksum is " << checksum << ". \n";
if (checksum % 10 == 0) {
cout << "Checksum is divisible by 10\. Valid. \n";
} else {
cout << "Checksum is not divisible by 10\. Invalid. \n";
}
注意,当我们检查输入数字的长度是奇数还是偶数时 ![http://atomoreilly.com/source/no_starch_images/1273182.png],我们从position中减去 1。我们这样做是因为在循环中读取的最后一个字符将是终止的换行符,而不是数字的最后一位。我们也可以将测试表达式写成(position % 2 == 1),但这更难以阅读。换句话说,最好说“如果position - 1是偶数,则使用偶数校验和”,而不是“如果position是奇数,则使用偶数校验和”,并且需要记住为什么这样做是有意义的。
这是我们迄今为止看到的代码列表中最长的,但我不需要注释代码中的每一部分并描述每个部分是如何工作的,因为你们已经单独看过每一部分。这正是有计划的好处。不过,需要注意的是,我的计划并不一定是你的计划。我在原始问题描述中看到的问题以及我解决这些问题的步骤可能与你看到和做的事情不同。你的编程背景以及你成功解决的问题将决定问题的哪些部分是微不足道的或困难的,以及你需要采取哪些步骤来解决问题。在前一节中,我可能已经走过了一个看似不必要的弯路,去弄清楚对你来说已经很明显的某件事。相反,也可能有某个地方我轻巧地跳过了对你来说棘手的部分。此外,如果你自己处理过这个问题,你可能会想出一个同样成功的程序,但看起来与我的完全不同。对于一个问题来说,没有“正确”的解决方案,因为任何满足所有约束条件的程序都可以算作解决方案,而且对于任何解决方案,都没有“正确”的方法来达到它。
看到我们为了达到解决方案所采取的所有步骤,以及最终代码的相对简短,你可能会试图在你自己的问题解决过程中减少步骤。我警告你这种冲动。总是做更多的步骤比试图一次做太多更好,即使有些步骤看起来很 trivial。记住问题解决的目标。当然,主要目标是找到一个程序来解决所陈述的问题并满足所有约束。次要目标是找到这个程序所需的时间最少。最小化步骤数量不是目标,而且没有人需要知道你采取了多少步骤。考虑尝试到达一个陡峭的山峰,它有一条浅而长且蜿蜒的小径。忽略小径,直接从山脚到山顶攀登,当然会比沿着小径走要少走很多步——但这更快吗?直接攀登最可能的结果是你会放弃并倒下。
还要记住我关于解决问题的最后一条通用规则:避免挫败感。你试图在每一步做的越多,你邀请的潜在挫败感就越多。即使你退回到一个困难的步骤并将其分解成子步骤,伤害也已经造成,因为从心理上讲,你会觉得自己是在后退而不是在进步。当我以逐步的方法指导初学者程序员时,我有时会有学生抱怨,“嘿,那个步骤太简单了。”对此,我会回答,“你在抱怨什么?”如果你已经将一个最初看起来很难的问题分解成非常小的部分,以至于每个部分都很容易完成,我说:恭喜你!这正是你应该希望的。
跟踪状态
我们在本章中要解决的最后一个问题也是最困难的。这个问题有很多不同的部分和复杂的描述,这将说明分解复杂问题的重要性。
问题:解码信息
一条信息已被编码为一个要逐个字符读取的文本流。该流包含一系列以逗号分隔的整数,每个整数都是可以由 C++ int表示的正数。然而,特定整数所代表的字符取决于当前的解码模式。有三种模式:大写、小写和标点。
在大写模式下,每个整数代表一个大写字母:整数除以 27 的余数表示字母表中的字母(其中 1 = A,以此类推)。因此,在大写模式下,输入值 143 将产生字母H,因为 143 除以 27 的余数是 8,而H是字母表中的第八个字母。
小写模式的工作方式相同,但使用小写字母;将整数除以 27 的余数代表小写字母(1 = a,以此类推)。因此,在小写模式下,输入值 56 将产生字母b,因为 57 除以 27 的余数是 2,而b是字母表中的第二个字母。
在标点符号模式下,整数被视为模 9,其解释如下表 2-3。因此,19 会产生一个感叹号,因为 19 模 9 等于 1。
每条消息的开头,解码模式为大写字母。每次模运算(取决于模式,27 或 9)的结果为 0 时,解码模式会切换。如果当前模式是大写,则模式切换为小写字母。如果当前模式是小写,则模式切换为标点符号,如果当前模式是标点符号,则切换回大写。
表 2-3. 标点符号解码模式
| 数字 | 符号 |
|---|---|
| 1 | ! |
| 2 | ? |
| 3 | , |
| 4 | . |
| 5 | (空格) |
| 6 | ; |
| 7 | " |
| 8 | ' |
与 Luhn 验证公式一样,我们将通过一个具体的例子来确保我们所有的步骤都是正确的。图 2-4 演示了一个示例解码。原始输入流显示在顶部。处理步骤从上到下进行。列(a)显示输入中的当前数字。列(b)是当前模式,从大写(U)到小写(L)再到标点(P)循环。列(c)显示当前模式的除数。列(d)是列(c)中当前除数除以列(a)中当前输入的余数。结果显示在列(e)中,要么是一个字符,要么如果列(d)中的结果是 0,则切换到循环中的下一个模式。
与前一个问题一样,我们可以先明确考虑我们需要构建解决方案所需的技能。我们需要读取一串字符,直到达到行尾。这些字符代表一系列整数,因此我们需要读取数字字符并将它们转换为整数以进行进一步处理。一旦我们有了整数,我们需要将整数转换为单个字符以输出。最后,我们需要某种方式来跟踪解码模式,以便我们知道当前整数应该解码为小写字母、大写字母还是标点符号。让我们将其转化为正式列表:
-
逐字符读取,直到达到行尾。
-
将表示数字的字符序列转换为整数。
-
将整数 1-26 转换为大写字母。
-
将整数 1-26 转换为小写字母。
-
根据表 2-3 将整数 1-8 转换为标点符号。
-
跟踪解码模式。
第一项是我们已经从上一个问题中知道如何做的。此外,尽管我们在 Luhn 验证公式中只处理了单个数字,但我怀疑我们在那里所做的某些事情也将对我们列表的第二项有所帮助。Luhn 算法的完成代码可能仍然在你的脑海中,但如果你在那个问题和这个问题之间放下这本书,你将想要回去复习那段代码。一般来说,当当前问题的描述“引起共鸣”时,你将想要从你的存档中挖掘出任何类似的代码进行研究。

图 2-4. “解码信息”问题的示例处理
让我们着手处理剩余的项目。你可能已经注意到,我把每个转换都作为一个单独的项目。我怀疑将数字转换为小写字母将与将数字转换为大写字母非常相似,但将转换为标点符号可能需要不同的方法。无论如何,将列表切得太细并没有真正的缺点;这仅仅意味着你将更频繁地勾掉列表上的项目。
让我们从整数到字符的转换开始。从 Luhn 公式程序中,我们知道读取字符数字 0–9 并将其转换为 0–9 范围内的整数的代码。我们如何扩展这种方法来处理多位数呢?让我们考虑最简单的情况:两位数。这看起来很简单。在一个两位数中,第一位是十位,所以我们应该将这个单独的数字乘以 10,然后加上第二位数字的值。例如,如果数字是 35,在将单独的数字 3 和 5 作为字符读取,并将它们转换为整数 3 和 5 之后,我们就可以通过表达式 3 * 10 + 5 得到所需的整体整数。让我们用代码来确认这一点:
存储代码以供以后重用
当前问题的元素与上一个问题的相似性表明了以方便以后审查的方式存储源代码的重要性。软件开发者经常谈论代码重用,这发生在你使用旧软件的片段来构建新软件的时候。这通常涉及到使用封装的组件或直接重用源代码。然而,轻松访问你以前编写过的解决方案同样重要。即使你并没有直接复制旧代码,这也允许你重用以前学到的技能和技术,而无需重新学习。为了最大限度地发挥这一优势,努力保持你编写的所有源代码(当然,要考虑到你可能与客户或雇主签订的任何知识产权协议)。
然而,你是否能从之前编写的程序中获得全部好处,很大程度上取决于你存储它们的细心程度;找不到的代码就是无法使用的代码。如果你采用逐步方法,并单独编写程序来测试想法,然后再将它们整合到整体中,确保你也保存这些中间程序。你可能会发现,当你的当前程序与旧程序相似之处在于你编写测试程序的一个区域时,这些中间程序将非常方便可用。
cout << "Enter a two-digit number: ";
char digitChar1 = cin.get();
char digitChar2 = cin.get();
int digit1 = digitChar1 - '0';
int digit2 = digitChar2 - '0';
int overallNumber = digit1 * 10 + digit2;
cout << "That number as an integer: " << overallNumber << "\n";
这方法是有效的——程序输出了我们输入的两个相同数字。然而,当我们尝试扩展这种方法时,我们会遇到问题。这个程序使用两个不同的变量来存储两个字符输入,虽然在这里没有问题,但我们当然不希望将其扩展为一个通用解决方案。如果我们这样做,我们需要与数字数量一样多的变量。这将变得混乱,如果输入流中可能数字的范围发生变化,修改起来也会很困难。我们需要一个更通用的解决方案来解决这个问题,即字符到整数的转换子问题。找到这个通用解决方案的第一步是将之前的代码简化为仅使用两个变量——一个char和一个int:
cout << "Enter a two-digit number: ";
char digitChar = cin.get();
int overallNumber = (digitChar - '0') * 10;
digitChar = cin.get();
overallNumber += (digitChar - '0');
cout << "That number as an integer: " << overallNumber << "\n";
我们通过在读取第二个数字之前先对第一个数字进行所有计算来完成这项任务。在一步中读取第一个字符数字
,将其转换为整数,乘以 10,并存储结果
。在读取第二个数字
后,我们将它的整数值加到当前总和中
。这相当于之前的代码,但只使用了两个变量,一个用于存储最后读取的字符,另一个用于存储整数的整体值。下一步是考虑将这种方法扩展到三位数。一旦我们做到了这一点,我们很可能会看到一种模式,这将使我们能够为任何位数的数字创建一个通用解决方案。
尽管如此,当我们尝试这样做时,我们会遇到一个问题。对于两位数,我们乘以左边的数字 10,因为左边的数字在十位上。三位数中最左边的数字会在百位上,所以我们需要将这个数字乘以 100。然后我们可以读取中间的数字,乘以 10,将其加到运行总和中,然后读取最后一个数字并加到总和中。这应该可以工作,但它并没有引导我们走向一个通用的解决方案。你看到问题了吗?考虑前面的陈述:“三位数中最左边的数字会在百位上”。对于通用的解决方案,我们直到遇到下一个逗号之前都不会知道每个数字有多少位。一个未知数量的数字的最左边的数字不能标记在百位或其他任何位置上。那么我们如何在将数字加到运行总和中之前知道每个数字应该使用什么乘数?或者我们需要完全不同的方法?
总是,当我们陷入困境时,创建一个简化的、可以工作的简单问题是好主意。这里的问题是我们不知道数字将有多少位。处理这个问题的最简单问题就是只有一个可能的数字计数。
问题:读取三位或四位数的数字
编写一个程序,逐个读取数字字符并将其转换为整数,只使用一个char变量和一个int变量。这个数字将有三或四位。
这种问题,即直到最后才知道字符的数量,但需要从一开始就需要知道数量,与 Luhn 公式的类似问题。在 Luhn 公式中,我们不知道识别号是奇数长度还是偶数长度。在这种情况下,我们的解决方案是两种不同的方式计算结果,并在最后选择合适的一种。我们能否在这里做类似的事情?如果数字是三位或四位,只有两种可能性。如果数字是三位,最左边的数字是百位。如果数字是四位,最左边的数字是千位。我们可以假设我们有一个三位数和一个四位数来计算,然后在最后选择正确的数字,但问题描述允许我们只有一个数字变量。让我们放宽这个限制,以便取得一些进展。
问题:读取三位或四位数的数字,进一步简化
编写一个程序,逐个读取数字字符并将其转换为整数,只使用一个char变量和两个int变量。这个数字将有三或四位。
现在,我们可以将“两种方式计算”的方法付诸实践。我们将以两种不同的方式处理前三位数字,然后看看是否有一个第四位数字:
cout << "Enter a three-digit or four-digit number: ";
char digitChar = cin.get();
int threeDigitNumber = (digitChar - '0') * 100;
int fourDigitNumber = (digitChar - '0') * 1000;
digitChar = cin.get();
threeDigitNumber += (digitChar - '0') * 10;
fourDigitNumber += (digitChar - '0') * 100;
digitChar = cin.get();
threeDigitNumber += (digitChar - '0');
fourDigitNumber += (digitChar - '0') * 10;
digitChar = cin.get();
if (digitChar == 10) {
cout << "Numbered entered: " << threeDigitNumber << "\n";
} else {
fourDigitNumber += (digitChar - '0');
cout << "Numbered entered: " << fourDigitNumber << "\n";
}
在读取最左边的数字后,我们将它的整数值乘以 100,并将其存储在我们的三位变量中!。我们还将整数值乘以 1,000,并将其存储在我们的四位变量中!。这种模式会延续到下两个数字。第二个数字既被视为三位数中的十位数字,也视为四位数中的百位数字。第三个数字被视为个位和十位数字。在读取第四个字符后,我们通过将其与数字 10!(如前一个问题中所述,此值可能因操作系统而异)进行比较来检查它是否是行尾。如果是行尾,则输入是一个三位数。如果不是,我们仍然需要将个位数加到总和中!。
现在我们需要找出如何去掉一个整数变量。假设我们完全删除了变量fourDigitNumber。threeDigitNumber的值仍然会被正确分配,但当我们需要fourDigitNumber时,我们就没有它了。使用threeDigitNumber中的值,我们能否确定fourDigitNumber将有的值?假设原始输入是1234。在读取前三个数字后,threeDigitNumber中的值将是 123;fourDigitNumber将有的值是 1230。一般来说,由于fourDigitNumber的乘数是threeDigitNumber的 10 倍,前者总是后者的 10 倍。因此,只需要一个整数变量,因为如果需要,另一个变量可以乘以 10:
cout << "Enter a three-digit or four-digit number: ";
char digitChar = cin.get();
int number = (digitChar - '0') * 100;
digitChar = cin.get();
number += (digitChar - '0') * 10;
digitChar = cin.get();
number += (digitChar - '0');
digitChar = cin.get();
if (digitChar == 10) {
cout << "Numbered entered: " << number << "\n";
} else {
number = number * 10 + (digitChar - '0');
cout << "Numbered entered: " << number << "\n";
}
现在我们有一个可利用的模式。考虑将此代码扩展以处理五位数。在计算前四位数字的正确值后,我们将重复我们用于读取第四个字符而不是立即显示结果的过程:读取第五个字符,检查它是否是行尾,如果是,则显示之前计算出的数字——否则,乘以 10,并加上当前字符的数字值:
cout << "Enter a number with three, four, or five digits: ";
char digitChar = cin.get();
int number = (digitChar - '0') * 100;
digitChar = cin.get();
number += (digitChar - '0') * 10;
digitChar = cin.get();
number += (digitChar - '0');
digitChar = cin.get();
if (digitChar == 10) {
cout << "Numbered entered: " << number << "\n";
} else {
number = number * 10 + (digitChar - '0');
digitChar = cin.get();
if (digitChar == 10) {
cout << "Numbered entered: " << number << "\n";
} else {
number = number * 10 + (digitChar - '0');
cout << "Numbered entered: " << number << "\n";
}
}
在这个阶段,我们可以轻松地扩展代码以处理六位数或位数更少的数字。模式很清晰:如果下一个字符是另一个数字,则在将字符的整数值添加到运行总和中之前,将运行总乘以 10。有了这个理解,我们可以编写一个循环来处理任何长度的数字:
cout << "Enter a number with as many digits as you like: ";
char digitChar = cin.get();
int number = (digitChar - '0');
digitChar = cin.get();
while (digitChar != 10) {
number = number * 10 + (digitChar - '0');
digitChar = cin.get();
}
cout << "Numbered entered: " << number << "\n";
在这里,我们读取第一个字符
,并确定其数字值
。然后我们读取第二个字符
并进入循环,检查最近读取的字符是否是行尾
。如果不是,我们在循环中将运行总乘以 10,并在读取下一个字符之前将当前字符的数字值
加上。一旦我们到达行尾,运行总变量 number 就包含了我们要输出的整数值
。
这处理了将一系列字符转换为它的整数等价的过程。在最终的程序中,我们将读取一系列由逗号分隔的数字。每个数字都必须单独读取和处理。像往常一样,最好先考虑一个简单的情况,以展示这个问题。让我们考虑输入 101,22[EOL],其中 [EOL] 明确标记行尾以增强清晰度。修改循环的测试条件以检查行尾字符或逗号就足够了。然后我们需要将处理一个数字的所有代码放置在一个更大的循环中,该循环会一直持续到读取所有数字。因此,内循环应该在遇到 [EOL] 或逗号时停止,但外循环只应该在遇到 [EOL] 时停止:
char digitChar;
do {
digitChar = cin.get();
int number = (digitChar - '0');
digitChar = cin.get();
while ((digitChar != 10) && (digitChar != ',')) {
number = number * 10 + (digitChar - '0');
digitChar = cin.get();
}
cout << "Numbered entered: " << number << "\n";
} while (digitChar != 10);
这又是小步骤重要性的一个绝佳例子。尽管这是一个简短的程序,但如果我们从零开始编写,双循环的嵌套特性会让代码变得复杂。然而,当我们从上一个程序逐步过渡到这个代码时,它就变得简单直接了。digitChar 的声明
被移动到单独一行,这样声明的范围就覆盖了整个代码。其余的代码与之前的列表相同,只是它被放置在一个 do-while 循环中,该循环会一直持续到我们到达行尾
。
在解决方案的这一部分就绪后,我们可以专注于处理单个数字。我们列表中的下一个项目是将数字 1–26 转换为字母 A–Z。如果你这么想,这实际上是我们用来将单个数字字符转换为它们整数等价的过程的逆过程。如果我们从 0 的字符代码中减去,以将 0–9 的字符代码范围转换为 0–9 的整数范围,我们应该能够添加一个字符代码,以将 1–26 转换为 A–Z。如果我们添加 'A' 会怎样?这里有一个尝试,包括一个示例输入和输出:
cout << "Enter a number 1-26: ";
int number;
cin >> number;
char outputCharacter;
outputCharacter = number + 'A';
cout << "Equivalent symbol: " << outputCharacter << "\n";
Enter a number 1-26: `5`
Equivalent letter: `F`
这并不完全正确。字母表的第五个字母是 E,而不是 F。问题发生是因为我们在从 1 开始的范围内添加一个数字。当我们从字符数字转换为其整数等效值时,我们处理的是一个从 0 开始的范围。我们可以通过将计算方式从 number + 'A' 改为 number + 'A' - 1 来解决这个问题。注意,我们可以查找字母 A 的字符代码值(在 ASCII 中是 65)并简单地使用该值减一(例如,ASCII 中的 number + 64)。尽管如此,我更喜欢第一种版本,因为它更易读。换句话说,如果你稍后回来看这段代码,你更容易记住 number + 'A' - 1 做了什么,而不是 number + 64 做了什么,因为前者中的 'A' 会让你想起转换为大写字母。
解决了这个问题后,我们可以轻松地将这个想法适应为转换为小写字母,只需将计算方式从 number + 'A' 改为 number + 'a' - 1。标点符号表转换并不那么简洁,因为表中的标点符号在 ASCII 或任何其他字符代码系统中并不按那个顺序出现。因此,我们不得不通过暴力方法来处理这个问题:
cout << "Enter a number 1-8: ";
int number;
cin >> number;
char outputCharacter;
switch (number) {
case 1: outputCharacter = '!'; break;
case 2: outputCharacter = '?'; break;
case 3: outputCharacter = ','; break;
case 4: outputCharacter = '.'; break;
case 5: outputCharacter = ' '; break;
case 6: outputCharacter = ';'; break;
case 7: outputCharacter = '"'; break;
case 8: outputCharacter = '\''; break;
}
cout << "Equivalent symbol: " << outputCharacter << "\n";
在这里,我们使用了一个 switch 语句  来输出正确的标点符号字符。注意,已经使用反斜杠作为“转义”来显示单引号 。
在将所有内容整合在一起之前,我们还有一个子问题需要解决:当最新的值解码为 0 时,我们需要在模式之间切换。记住,问题描述要求我们将每个整数值除以 27(如果我们目前处于大写模式或小写模式)或 9(如果我们处于标点模式)。当结果是 0 时,我们切换到下一个模式。我们需要一个变量来存储当前模式,并在“读取和处理下一个值”的循环中添加逻辑以在必要时切换模式。跟踪当前模式的变量可以是一个简单的整数,但使用枚举会更易读。一个好的经验法则是:如果一个变量只跟踪一个状态,并且没有任何特定值具有固有的意义,那么使用枚举是一个好主意。在这种情况下,我们可以有一个变量int mode任意指定值为 1 表示大写,2 表示小写,3 表示标点。然而,选择这些值并没有固有的理由。当我们稍后回过头来看代码时,我们需要重新熟悉系统才能理解像if (mode == 2)这样的语句。如果我们使用枚举——就像在语句(mode == -LOWERCASE)中一样——我们不需要记住任何东西,因为所有内容都明确地写出来了。以下是这个想法产生的代码,以及一个示例交互:
enum modeType {UPPERCASE, LOWERCASE, PUNCTUATION};
int number;
modeType mode = UPPERCASE;
cout << "Enter some numbers ending with −1: ";
do {
cin >> number;
cout << "Number read: " << number;
switch (mode) {
case UPPERCASE:
number = number % 27;
cout << ". Modulo 27: " << number << ". ";
if (number == 0) {
cout << "Switch to LOWERCASE";
mode = LOWERCASE;
}
break;
case LOWERCASE:
number = number % 27;
cout << ". Modulo 27: " << number << ". ";
if (number == 0) {
cout << "Switch to PUNCTUATION";
mode = PUNCTUATION;
}
break;
case PUNCTUATION:
number = number % 9;
cout << ". Modulo 9: " << number << ". ";
if (number == 0) {
cout << "Switch to UPPERCASE";
mode = UPPERCASE;
}
break;
}
cout << "\n";
} while (number != −1);
Enter some numbers ending with −1: `2 1 0 52 53 54 55 6 7 8 9 10 −1`
Number read: 2\. Modulo 27: 2.
Number read: 1\. Modulo 27: 1.
Number read: 0\. Modulo 27: 0\. Switch to LOWERCASE
Number read: 52\. Modulo 27: 25.
Number read: 53\. Modulo 27: 26.
Number read: 54\. Modulo 27: 0\. Switch to PUNCTUATION
Number read: 55\. Modulo 9: 1.
Number read: 6\. Modulo 9: 6.
Number read: 7\. Modulo 9: 7.
Number read: 8\. Modulo 9: 8.
Number read: 9\. Modulo 9: 0\. Switch to UPPERCASE
Number read: 10\. Modulo 27: 10.
Number read: −1\. Modulo 27: −1.
我们已经从清单上划掉了所有的事项,现在到了将这些单独的代码列表整合成一个整体程序解决方案的时候了。我们可以用不同的方法来处理这个整合。我们可能只是将两块代码放在一起,然后从那里开始构建。例如,我们可以将读取和转换逗号分隔数字的代码与从最新列表中切换模式的代码结合起来。然后我们可以测试这个整合,并添加将每个数字转换为相应的字母或标点符号的代码。或者我们可以从另一个方向开始构建,将数字到字符的列表转换成一系列从主程序中调用的函数。在这个阶段,我们基本上已经从问题解决过渡到了软件工程,这是一个不同的主题。我们制作了一系列的模块——这是难点所在——现在我们只需要将它们组装起来,如图图 2-5 所示。
这段程序中的几乎每一行都是从本节之前的代码中提取出来的。大部分代码
。核心处理循环
。最后,你会认出将整数转换为大写字母、小写字母和标点符号的代码
。少量新代码用
。当最后一个输入是模式切换命令时,continue 语句会跳过循环的下一个迭代,跳过循环末尾的 cout << outputCharacter。

图 2-5. “解码信息”问题的解决方案
虽然这是一项剪切和粘贴的工作,但这是一种 好的 剪切和粘贴工作,因为你重用了你刚刚编写的代码,因此完全理解它。就像之前一样,想想在这个过程中每一步有多容易,与从头开始编写最终列表相比。毫无疑问,一个优秀的程序员可以在不经过中间步骤的情况下生成最终的列表,但会有错误的步骤,代码看起来很丑陋,以及被注释掉然后又放回的代码行。通过采取较小的步骤,所有脏活都早早完成,代码永远不会太丑陋,因为我们目前正在处理的代码永远不会太长或太复杂。
结论
在本章中,我们探讨了三个不同的问题。从某种意义上说,我们必须采取三条不同的路径来解决它们。从另一种意义上说,我们每次都采取了相同的路线,因为我们使用了相同的基本技术,即将问题分解成组件;编写代码来解决这些组件;然后利用编写程序获得的知识,甚至直接使用程序中的代码行来解决原始问题。在接下来的章节中,我们不会针对每个问题明确使用这种方法,但基本思想始终存在:将问题分解成可管理的部分。
根据你的背景,这些问题最初可能看起来在难度谱上从极其困难到极其简单都有可能。无论一个问题最初看起来有多困难,我都建议你在面对每个新问题时都使用这种技术。你不想等到遇到一个令人沮丧的困难问题之前才尝试新方法。记住,这本书的一个目标就是让你对自己的问题解决能力充满信心。在“简单”问题上练习使用这些技术,当你遇到困难问题时,你会有很多动力。
练习
如前所述,我强烈建议你尽可能多地尝试练习。现在我们已经完全进入实际的编程阶段,通过练习来发展你的问题解决能力是至关重要的。
-
使用本章前面形状程序相同的规则(只有两个输出语句——一个输出井号,另一个输出行尾),编写一个生成以下形状的程序:
######## ###### #### ## -
或者如何:
## #### ###### ######## ######## ###### #### ## -
这里有一个特别棘手的问题:
# # ## ## ### ### ######## ######## ### ### ## ## # # -
设计你自己的:想出一个由井号标记组成的对称图案,然后看看你是否能编写一个遵循形状规则的程序来生成它。
-
如果你喜欢 Luhn 公式问题,尝试编写一个程序来处理不同的校验位系统,比如 13 位的 ISBN 系统。程序可以接受一个识别号并验证它,或者接受一个不带校验位的数字并生成校验位。
-
如果你已经学过二进制数以及如何将十进制数转换为二进制数和反向转换,尝试编写程序以无限长度数字(但你可以假设数字足够小,可以存储在标准的 C++
int中)来进行这些转换。 -
你已经学过十六进制了吗?尝试编写一个程序,让用户指定输入为二进制、十进制或十六进制,并以任意一种输出。
-
想要一个额外的挑战?将前面练习的代码泛化,制作一个可以将任何 16 进制或更小的数制转换为任何其他数制的程序。例如,程序可以将 9 进制转换为 4 进制。
-
编写一个程序,读取一行文本,计算单词数量,识别最长单词的长度,一个单词中最大的元音数量,以及/或你可以想到的任何其他统计数据。
第三章. 使用数组解决问题

在上一章中,我们限制了自己只使用标量变量,即一次只能存储一个值的变量。在本章中,我们将探讨使用最常用的聚合数据结构——数组的问题。尽管数组是具有基本限制的简单结构,但它们的使用极大地增强了我们程序的功能。
在本章中,我们将主要处理实际的数组,即使用内置的 C++语法声明的数组,例如:
int tenIntegerArray[10];
然而,我们讨论的技术同样适用于具有类似属性的数据结构。其中最常见的是向量。术语向量通常用作任何一维数组的同义词,但在这里我们将使用更具体的意义,即具有数组属性但没有指定最大元素数量的结构。因此,在我们的讨论中,数组是固定大小的,而向量可以自动根据需要增长或缩小。本章讨论的每个问题都包括一些限制,允许我们使用具有固定元素数量的结构。然而,没有这种限制的问题可以适应使用向量。
此外,与数组一起使用的技巧通常也可以用于不具有上述所有属性的某些数据结构。例如,有些技巧不需要随机访问,因此它们可以与链表等结构一起使用。由于数组在编程中非常常见,并且数组技巧经常在非数组环境中使用,因此数组是研究使用数据结构解决问题的良好训练场。
数组基础回顾
你应该已经知道数组是什么,但为了清晰起见,让我们回顾一下数组的属性。一个数组是由同一类型的变量组成的集合,这些变量在同一个名称下组织,其中各个变量由一个数字表示。我们称这些单个变量为数组的元素。在 C++和大多数其他语言中,第一个元素的编号为 0,但在某些语言中,这会有所不同。
数组的主要属性直接来源于其定义。数组中存储的每个值都是同一类型的,而其他聚合数据结构可以存储混合类型的值。单个元素通过一个称为下标的数字来引用;在其他数据结构中,单个元素可能通过名称或键值来引用。
从这些基本属性中,我们可以推导出几个次要属性。因为每个元素都由从 0 开始的序列中的数字指定,所以我们很容易检查数组中的每个值。在其他数据结构中,这可能很困难、效率低下,甚至不可能。此外,与一些数据结构(如链表)只能按顺序访问不同,数组提供随机访问,这意味着我们可以在任何时间访问数组的任何元素。
这些基本和次要属性决定了我们可以如何使用数组。在处理任何聚合数据结构时,考虑问题时最好有一个基本操作集。将这些基本操作视为常用工具——数据结构中的锤子、螺丝刀和扳手。并不是每个机械问题都可以用常用工具解决,但在去五金店之前,你应该考虑问题是否可以用常用工具解决。以下是我为数组列出的基本操作列表。
存储
这是最基本的操作。数组是一组变量的集合,我们可以为这些变量中的每一个分配一个值。要将整数 5 分配给之前声明的数组中的第一个元素(元素 0),我们只需说:
tenIntegerArray[0] = 5;
就像任何变量一样,我们数组内部元素的值在特定值被分配之前将是随机的“垃圾”数据,因此在使用数组之前应该对其进行初始化。在某些情况下,尤其是在测试时,我们可能希望将特定值分配给数组中的每个元素。我们可以在声明数组时使用初始化器来完成这个操作。
int tenIntegerArray[10] = {4, 5, 9, 12, −4, 0, −57, 30987, −287, 1};
我们很快就会看到数组初始化器的良好应用。有时,我们不想为每个元素分配不同的值,而只想将数组中的每个元素初始化为相同的值。根据情况或使用的编译器(例如,Microsoft Visual Studio 中的 C++编译器),为数组中的每个元素分配零有一些快捷方式。然而,在这个阶段,我总是会在需要初始化的地方显式初始化数组,以提高可读性,就像以下代码所示,它将一个 10 元素数组中的每个元素设置为-1:
int tenIntegerArray[10];
for (int i = 0; i < 10; i++) tenIntegerArray[i] = −1;
复制
我们可以复制数组。有两种常见情况可能很有用。首先,我们可能想要对数组进行大量操作,但仍然需要保留数组原始形式以供后续处理。如果我们更改了任何值,将数组放回原始形式可能很困难,甚至不可能。通过复制整个数组,我们可以操作副本而不会干扰原始数组。我们只需要一个循环和一个赋值语句就可以复制整个数组,就像初始化的代码一样:
int tenIntegerArray[10] = {4, 5, 9, 12, −4, 0, −57, 30987, −287, 1};
int secondArray[10];
for (int i = 0; i < 10; i++) secondArray[i] = tenIntegerArray[i];
该操作适用于大多数聚合数据结构。第二种情况更具体于数组。有时我们想要将部分数据从第一个数组复制到第二个数组,或者我们想要将元素从第一个数组复制到第二个数组,作为重新排列元素顺序的方法。如果你研究过归并排序算法,你已经在实际操作中看到了这个想法。我们将在本章后面看到复制的示例。
检索与搜索
由于我们能够将值放入数组,因此我们还需要能够从数组中取出它们。从特定位置检索值是直接的:
int num = tenIntegerArray[0];
搜索特定值
通常情况并不那么简单。通常我们不知道需要的位置,而必须 搜索 数组以找到特定值的定位。如果数组中的元素没有特定的顺序,我们能做的最好的就是顺序搜索,即从数组的这一端看到另一端,直到找到所需值。这里有一个基本版本。
const int ARRAY_SIZE = 10;
int intArray[ARRAY_SIZE] = {4, 5, 9, 12, −4, 0, −57, 30987, −287, 1};
int targetValue = 12;
int targetPos = 0;
while ((intArray[targetPos] != targetValue) && (targetPos < ARRAY_SIZE))
targetPos++;
在此代码中,我们有一个常量用于存储数组的大小
,数组本身
,一个变量用于存储我们在数组中要查找的值
,以及一个变量用于存储找到值的位置
。在此示例中,我们使用我们的 ARRAY_SIZE 常量来限制对数组的迭代次数
,这样当 targetValue 在数组元素中未找到时,我们不会超出数组的末尾。你可以将数字 10 “硬编码”到常量中,但使用常量可以使代码更通用,因此更容易修改和重用。在本章的大部分代码中,我们将使用 ARRAY_SIZE 常量。请注意,如果 targetValue 在 intArray 中未找到,则循环结束后 targetPos 将等于 ARRAY_SIZE。这足以表示该事件,因为 ARRAY_SIZE 不是一个有效的元素编号。然而,检查这一点将由后续的代码来完成。另外,请注意,代码没有努力处理目标值出现多次的可能性。目标值第一次出现时,循环就会结束。
基于标准的搜索
有时候我们寻找的值不是一个固定的值,而是基于数组中其他值的相对关系的值。例如,我们可能想要找到数组中的最大值。完成这个任务的机制就是我所说的“山丘之王”,这个名称来源于操场上的游戏。有一个变量代表数组中迄今为止看到的最高值。通过循环遍历数组中的所有元素,每次遇到一个比之前最高值更高的值时,新的值就会将之前的“国王”从山上赶下来,取而代之:
const int ARRAY_SIZE = 10;
int intArray[ARRAY_SIZE] = {4, 5, 9, 12, −4, 0, −57, 30987, −287, 1};
int highestValue = intArray[0];
for (int i = 1; i < ARRAY_SIZE; i++) {
if (intArray[i] > highestValue) highestValue = intArray[i];
}
变量 highestValue 存储了迄今为止在数组中找到的最大值。在其声明时,它被分配为数组的第一个元素的值 ![http://atomoreilly.com/source/no_starch_images/1273182.png],这允许我们从数组的第二个元素开始循环(它允许我们从 i 为 1 而不是 0 开始)。在循环内部,我们比较当前位置的值与 highestValue,如果适当的话替换 highestValue ![http://atomoreilly.com/source/no_starch_images/1273191.png]。请注意,找到最小值而不是最大值,只需将“大于”比较 ![http://atomoreilly.com/source/no_starch_images/1273195.png] 转换为“小于”比较(并更改变量的名称,以免混淆自己)。这种基本结构可以应用于所有各种情况,在这些情况下我们想要查看数组中的每个元素以找到最能体现特定质量的值。
排序
排序意味着将数据按照指定的顺序排列。你可能已经遇到了用于数组的排序算法。这是一个经典的分析性能的领域,因为存在如此多的竞争排序算法,每个算法的性能特征都取决于底层数据的特征。对不同排序算法的研究本身就可以成为一本书的主题,所以我们不会深入探讨这个领域。相反,我们将专注于实际应用。在大多数情况下,你可以使用工具箱中的两种排序方法:一种快速、易于使用的排序方法,以及一种相当好理解、易于修改的排序方法,当需要时你可以有信心进行修改。对于快速和简单,我们将使用标准库函数 qsort,当我们需要调整时,我们将使用插入排序。
使用 qsort 进行快速简单的排序
C/C++程序员的默认快速排序是标准库中的qsort函数(名称暗示底层排序使用快速排序,但库的实现者并不需要使用该算法)。要使用qsort,我们必须编写一个比较函数。这个函数将由qsort在需要比较数组中的两个元素以确定哪个应该出现在排序顺序中时被调用。该函数接收两个void指针。我们在这本书中还没有讨论指针,但你需要知道的是,你应该将这些void指针转换为你的数组中元素类型的指针。然后该函数应该返回一个int,根据第一个元素是大于、小于还是等于第二个元素,返回正数、负数或零。返回的确切值并不重要,只有它是正数、负数还是零。让我们用一个快速示例来澄清这个讨论,即使用qsort对 10 个整数的数组进行排序。我们的比较函数:
int compareFunc(const void * voidA, const void * voidB) {
int * intA = (int *)(voidA);
int * intB = (int *)(voidB);
return *intA - *intB;
}
参数列表由两个const void指针组成
。同样,对于比较器来说,这始终是这种情况。函数内部的代码首先声明两个int指针
并将两个void指针转换为int指针类型。我们可以不使用这两个临时变量来编写函数;我在这里包括它们是为了清晰。关键是,一旦我们完成了这些声明,intA和intB将指向我们的数组中的两个元素,而*intA和*intB将是必须比较的两个整数。最后,我们返回从第一个整数减去第二个整数的结果
。这将产生我们想要的结果。例如,如果intA > intB,我们希望返回一个正数,并且如果intA > intB,intA – intB将是正数。同样,如果intB > intA,intA – intB将是负数,当两个整数相等时,它将是零。
在比较函数就绪后,qsort的一个示例用法如下:
const int ARRAY_SIZE = 10;
int intArray[ARRAY_SIZE] = {87, 28, 100, 78, 84, 98, 75, 70, 81, 68};
qsort(intArray, ARRAY_SIZE, sizeof(int), compareFunc);
如你所见,qsort的调用需要四个参数:要排序的数组
;该数组中的元素数量
;数组中一个元素的大小,通常由sizeof运算符确定,就像这里一样
;最后,比较函数
。如果你没有多少经验将函数作为参数传递给其他函数,请注意最后一个参数使用的语法。我们传递的是函数本身,而不是调用函数并传递调用结果。因此,我们只需声明函数名,不带参数列表或括号。
使用插入排序进行易于修改的排序
在某些情况下,你可能需要编写自己的排序代码。有时内置的排序方法可能无法满足你的需求。例如,假设你有一个数据数组,你希望根据另一个数组中的数据对其进行排序。当你不得不自己编写排序代码时,你将希望有一个简单直接的排序程序,你相信它并且可以随时使用。一个合理的建议是使用插入排序。插入排序的工作方式类似于许多人打桥牌时排序卡片的方式:他们一次拿起一张卡片,将其插入到手中的适当位置以保持整体顺序,同时将其他卡片向下移动以腾出空间。以下是针对我们的整数数组的基本实现:
int start = 0;
int end = ARRAY_SIZE - 1;
for (int i = start + 1; i <= end; i++) {
for (int j = i; j > start &&
intArray[j-1] > intArray[j]; j--) {
int temp = intArray[j-1];
intArray[j-1] = intArray[j];
intArray[j] = temp;
}
}
我们首先声明两个变量,start 和 end,表示数组中第一个和最后一个元素的下标。这提高了代码的可读性,同时也允许代码容易地修改以仅对数组的部分进行排序,如果需要的话。外层循环选择下一个要插入到我们不断增长的有序手中的“卡片”。注意,循环将 i 初始化为 start + 1。记得在“找到最大值”的代码中,我们将最高值变量初始化为数组的第一个元素,并从数组的第二个元素开始循环。这是同样的想法。如果我们只有一个值(或“卡片”),那么根据定义,它已经是“有序”的,我们可以从考虑第二个值是否应该在第一个值之前或之后开始。内层循环通过反复交换当前值与其前驱值,直到它到达正确的位置,将当前值放入其正确的位置。循环计数器 j 从 i 开始,循环递减 j,直到我们没有达到数组的下限,并且还没有找到这个新值的正确停止点。在此之前,我们使用三个赋值语句将当前值在数组中向下移动一个位置。换句话说,如果你有一副 13 张的牌,并且已经排序了最左边的 4 张牌,你可以通过反复将第 5 张牌向下移动一张牌,直到它不再比其左侧的牌小,来将其放入正确的位置。这就是内层循环所做的。外层循环从最左边开始对每张牌做同样的事情。所以当我们完成时,整个数组就被排序了。
插入排序并不是在大多数情况下最有效的排序方法,说实话,之前的代码甚至都不是执行插入排序最有效的方法。然而,对于小到中等大小的数组来说,它是相当高效的,而且足够简单,以至于可以被记住——把它想象成一个心理宏。无论你选择这种排序还是其他排序,你应该有一个自己可以自信编码的 decent 或更好的排序程序。仅仅能够访问你不完全理解的别人的排序代码是不够的。如果你不确定一切是如何工作的,你不想去摆弄这些机器。
计算统计数据
最终操作与检索操作类似,因为我们需要在返回值之前查看数组中的每个元素。它与检索操作的不同之处在于,值不仅仅是数组中的一个元素,而是从数组中所有值计算出的某个统计量。例如,我们可能会计算平均值、中位数或众数,我们将在本章后面进行所有这些计算。我们可能计算的一个基本统计量可以是学生成绩的平均值:
const int ARRAY_SIZE = 10;
int gradeArray[ARRAY_SIZE] = {87, 76, 100, 97, 64, 83, 88, 92, 74, 95};
double sum = 0;
for (int i = 0; i < ARRAY_SIZE; i++) {
sum += gradeArray[i];
}
double average = sum / ARRAY_SIZE;
作为另一个简单的例子,考虑数据验证。假设一个名为 vendorPayments 的双精度值数组代表了向供应商的付款。只有正值是有效的,因此负值表示数据完整性问题。作为验证报告的一部分,我们可能会写一个循环来计算数组中负值的数量:
const int ARRAY_SIZE = 10;
int countNegative = 0;
for (int i = 0; i < ARRAY_SIZE; i++) {
if (vendorPayments[i] < 0) countNegative++;
}
使用数组解决问题
一旦你理解了常见的操作,解决数组问题与解决上一章中我们使用简单数据解决的问题并没有太大的不同。让我们拿一个例子,并使用上一章的技术以及我们可能需要的任何数组常见操作来运行它。
问题:寻找众数
在统计学中,一组值的众数是出现次数最多的值。编写代码处理一个调查数据数组,其中调查者用一个 1 到 10 范围内的数字回答了一个问题,以确定数据集的众数。对于我们的目的,如果存在多个众数,可以选择任何一个。
在这个问题中,我们被要求从一个数组中检索一个值。使用寻找类比和从已知信息开始的技术,我们可能希望应用我们已经看到的检索技术的某种变体:找到数组中的最大值。该代码通过将迄今为止看到的最大值存储在一个变量中来工作。然后代码将每个后续值与此变量进行比较,并在必要时替换它。这里类似的方法将是说我们将存储迄今为止出现频率最高的值在一个变量中,然后每当我们在数组中发现一个更常见的值时,就替换变量中的值。当我们这样用英语说出来时,这似乎可以工作,但当我们实际思考代码时,我们发现问题。让我们看看这个问题的示例数组和大小常量:
const int ARRAY_SIZE = 12;
int surveyData[ARRAY_SIZE] = {4, 7, 3, 8, 9, 7, 3, 9, 9, 3, 3, 10};
这组数据的模式是 3,因为 3 出现了四次,比任何其他值出现的频率都要高。但是,如果我们像处理“最高值”问题那样按顺序处理这个数组,我们会在什么时候决定 3 是众数呢?当我们遇到数组中 3 的第四次也是最后一次出现时,我们如何知道这确实是第四次和最后一次出现呢?似乎没有一种方法可以通过单次顺序处理数组数据来发现这种信息。
因此,让我们转向我们的另一种技术:简化问题。如果我们把相同数字的所有出现放在一起,事情会变得简单一些吗?例如,如果我们的样本数组调查数据看起来像这样:
int surveyData[ARRAY_SIZE] = {4, 7, 7, 9, 9, 9, 8, 3, 3, 3, 3, 10};
现在,所有的 7 都在一起,所有的 9 都在一起,所有的 3 也都在一起。以这种方式将数据分组后,似乎我们可以按顺序处理数组来找到众数。手动处理数组时,很容易计算每个值的出现次数,因为你只需继续数下去,直到找到第一个不同的数字。然而,将我们可以在脑海中完成的事情转换为编程语句可能会很棘手。因此,在我们尝试编写这个简化问题的代码之前,让我们先写一些伪代码,这是一种类似编程的语句,它既不是完全的英语或 C++,而是在两者之间。这将提醒我们,我们需要用每个语句尝试做什么。
int mostFrequent = ?;
int highestFrequency = ?;
int currentFrequency = 0;
for (int i = 0; i < ARRAY_SIZE; i++) {
currentFrequency++;
if (surveyData[i] IS LAST OCCURRENCE OF A VALUE) {
if (currentFrequency > highestFrequency) {
highestFrequency = currentFrequency;
mostFrequent = surveyData[i];
}
currentFrequency = 0;
}
}
编写伪代码没有对错之分,如果你使用这种技术,你应该采用自己的风格。当我写伪代码时,我倾向于写合法的 C++语句,对于我已经有信心的地方,然后在英语中详细说明我还需要思考的地方。在这里,我们知道我们需要一个变量(mostFrequent)来保存迄今为止最频繁出现的值,在循环结束时,这将是我们正确写入后的众数。我们还需要一个变量来存储该值出现的频率(highestFrequency),以便进行比较。最后,我们需要一个变量,我们可以用它来计数我们正在跟踪的值的出现次数(currentFrequency),在我们顺序处理数组时。我们知道我们需要初始化我们的变量。对于currentFrequency,它逻辑上必须从 0 开始,但还没有其他代码的情况下,我们不清楚如何初始化其他变量。所以让我们简单地插入问号
来提醒我们稍后再次查看这一点。
循环本身是我们已经见过的相同数组处理循环,所以它已经处于最终形式
。在循环内部,我们增加计数当前值出现的变量
,然后我们到达关键语句。我们知道我们需要检查是否达到了特定值的最后一个出现
。伪代码允许我们暂时跳过逻辑的推理,并勾勒出其余的代码。不过,如果这是值的最后一个出现,我们知道该怎么做,因为这就像“最高值”代码:我们需要查看这个值的计数是否高于迄今为止看到的最高值。如果是,这个值就变成了新的最频繁值
。然后,因为接下来读取的值将是新值的第一个出现,我们重置我们的计数器
。
让我们回到我们跳过的if语句逻辑。我们如何知道这是数组中值的最后一个出现?因为数组中的值是分组的,所以当数组中的下一个值是不同的时候,我们知道一个值是最后一个出现:用 C++的话说,当surveyData[i]和surveyData[i + 1]不相等时。此外,数组的最后一个值也是某个值的最后一个出现,即使没有下一个值。我们可以通过检查i == ARRAY_SIZE - 1来检查这一点,在这种情况下,这是数组的最后一个值。
在弄清楚所有这些之后,让我们考虑一下我们变量的初始值。记得在使用“最高值”数组处理代码时,我们将“迄今为止最高”变量初始化为数组的第一个值。在这里,“最频繁出现的”值由两个变量表示,mostFrequent用于值本身,highestFrequency用于出现次数。如果能将mostFrequent初始化为数组中首次出现的值,将highestFrequency初始化为其频率计数,那就太好了,但在进入循环并开始计数之前,我们无法确定第一个值的频率。在这个时候,我们可能会想到,无论第一个值的频率是多少,它都将是大于零的。因此,如果我们把highestFrequency设为零,一旦我们到达第一个值最后一次出现的位置,我们的代码无论如何都会用第一个值的数字替换mostFrequent和highestFrequency。完整的代码看起来是这样的:
int mostFrequent;
int highestFrequency = 0;
int currentFrequency = 0;
for (int i = 0; i < ARRAY_SIZE; i++) {
currentFrequency++;
// if (surveyData[i] IS LAST OCCURENCE OF A VALUE)
if (i == ARRAY_SIZE - 1 || surveyData[i] != surveyData[i + 1]) {
if (currentFrequency > highestFrequency) {
highestFrequency = currentFrequency;
mostFrequent = surveyData[i];
}
currentFrequency = 0;
}
}
在这本书中,我们不会过多地讨论纯风格问题,例如文档(注释)风格,但既然我们在这个问题上使用伪代码,我想提一个建议。我注意到,我在伪代码中留下的“普通英语”行,在最终代码中受益最多的地方就是注释,而普通英语本身就是一个很好的注释。我在这里的代码中展示了这一点。你可能会忘记if语句中的条件表达式背后的确切含义
,但前一行
上的注释很好地澄清了这些问题。
关于代码本身,它完成了任务,但请记住,它需要我们的调查数据被分组。分组数据可能本身就是一项工作,除非——如果我们对数组进行排序会怎样?实际上我们并不需要数据被排序,但排序将完成我们需要的分组。因为我们不打算进行任何特殊的排序,所以让我们将这个qsort调用添加到我们代码的开头:
qsort(surveyData, ARRAY_SIZE, sizeof(int), compareFunc);
注意,我们正在使用之前为qsort编写的相同的compareFunc。有了排序步骤,我们就有了解决原始问题的完整解决方案。所以我们的工作完成了。是吗?
重构
一些程序员谈论着“有坏味道”的代码。他们指的是那些没有错误但以某种方式仍然存在问题的可工作代码。有时这意味着代码过于复杂或包含过多的特殊情况,使得程序员难以修改和维护程序。在其他情况下,代码的效率不如预期,尽管它在测试用例中运行良好,但程序员担心性能会在更大的用例中崩溃。这就是我这里关心的问题。对于我们的小型测试用例,排序步骤几乎是瞬间的,但如果我们处理的数组非常大呢?此外,我知道快速排序算法(qsort可能正在使用)在数组中有大量重复值时性能最低,而这个问题的全部要点是所有我们的值都在 1 到 10 的范围内。因此,我建议对代码进行重构。重构意味着改进可工作代码,而不是改变它所做的事情,而是改变它执行的方式。我希望找到一个解决方案,即使对于非常大的数组也非常高效,假设值在 1 到 10 的范围内。
让我们再次思考我们已知如何使用数组的操作。我们已经探索了“找到最高值”代码的几个版本。我们知道直接将“找到最高值”代码应用于我们的surveyData数组不会产生有用的结果。我们能否找到一个数组,可以应用“股票”版本的“找到最高值”并得到调查数据的众数?答案是肯定的。我们需要的是surveyData数组的直方图。直方图是一个显示不同值在基础数据集中出现频率的图表;我们的数组将是此类直方图的数据。换句话说,我们将在一个 10 个元素的数组中存储 1 到 10 每个值在surveyData中出现的频率。下面是创建我们直方图的代码:
const int MAX_RESPONSE = 10;
int histogram[MAX_RESPONSE];
for (int i = 0; i < MAX_RESPONSE; i++) {
histogram[i] = 0;
}
for (int i = 0; i < ARRAY_SIZE; i++) {
histogram[surveyData[i] - 1]++;
}
在第一行,我们声明数组来存储我们的直方图数据
。你会注意到我们声明了一个包含 10 个元素的数组,但我们的调查响应范围是 1–10,而这个数组的下标范围是 0–9。因此,我们需要进行调整,将 1 的计数放在histogram[0]中,依此类推。(一些程序员会选择声明一个包含 11 个元素的数组,留出位置[0]未使用,以便每个计数都能进入其自然位置。)我们使用循环显式地将数组值初始化为零
,然后我们就可以使用另一个循环来计数surveyData中每个值的出现次数
。循环内的语句
需要仔细阅读;我们正在使用surveyData当前位置中的值来告诉我们应该增加histogram中的哪个位置。为了使这一点更清晰,让我们举一个例子。假设i是 42。我们检查surveyData[42]并找到(比如说)值 7。因此,我们需要增加我们的 7 计数器。我们从 7 中减去 1 得到 6,因为 7 的计数器在histogram中的位置是[6],并且histogram[6]被增加。
在直方图数据就绪后,我们可以编写其余的代码。请注意,直方图代码是单独编写的,以便可以单独测试。在问题可以轻松分解为可以单独编写和测试的部分的情况下,一次性编写所有代码并不会节省时间。在测试了上述代码后,我们现在在histogram数组中搜索最大值:
int mostFrequent = 0;
for (int i = 1; i < MAX_RESPONSE; i++) {
if (histogram[i] > histogram[mostFrequent]) mostFrequent = i;
}
mostFrequent++;
虽然这是一个“查找最大值”代码的改编版本,但存在差异。虽然我们在直方图数组中寻找最大值,但最终我们并不想要这个值本身,而是它的位置。换句话说,使用我们的样本数组,我们想知道 3 在调查数据中出现的频率比任何其他值都要高,但实际上 3 出现的次数并不重要。因此,mostFrequent将是histogram中最高值的位置,而不是最高值本身。因此,我们将其初始化为0
而不是location[0]中的值。这也意味着在if语句中,我们比较的是histogram[mostFrequent]
而不是mostFrequent本身,当我们找到一个更大的值时,我们将i而不是histogram[i]赋值给mostFrequent
。最后,我们增加mostFrequent
。这与我们在早期循环中所做的是相反的,通过减去 1 来获取正确的数组位置。如果mostFrequent告诉我们最高数组位置是 5,例如,这意味着调查数据中出现频率最高的值是 6。
直方图解决方案与我们的surveyData数组中的元素数量成线性比例,这是我们所能期望的最好的结果。因此,它比我们的原始方法更好。这并不意味着第一种方法是一个错误或浪费时间。当然,我们可以编写这个代码而不经过之前的版本,我们可以原谅我们希望我们能够直接到达目的地而不是走更长的路。然而,我警告你,当第一个解决方案最终不是最终解决方案时,不要拍打自己的额头。编写一个原始程序(记住,这意味着对于编写程序的程序员来说是原始的)是一个学习过程,不能期望它总是直线前进。此外,通常情况下,在一个问题上走更长的路可以帮助我们在后来的问题上走更短的路。在这个特定的情况下,请注意,我们的原始解决方案(虽然对于我们的特定问题扩展性不好)如果调查响应没有严格限制在 1-10 的小范围内,可能是正确的解决方案。或者假设你后来被要求编写一个查找一组整数值的中位数(中位数是中间的值,使得集合中一半的其他值更高,另一半的其他值更低)的代码。直方图方法对于中位数没有任何帮助,但我们的第一个方法对于众数是有帮助的。
这里的教训是,如果你从漫长的旅程中学到了一些你通过短途旅行无法学到的东西,那么漫长的旅程并不是浪费时间。这也是为什么系统地存储你编写的所有代码很有帮助的原因,这样你可以轻松地找到并稍后重用它。即使是最终成为“死胡同”的代码也可以成为宝贵的资源。
固定数据数组
在大多数数组问题中,数组是程序外部数据的存储库,例如用户输入的数据、本地磁盘上的数据或来自服务器的数据。然而,为了充分利用数组工具,你需要认识到其他可以使用数组的情况。通常,创建一个初始化后值永远不会改变的数组是有用的。这样的数组可以允许使用简单的循环,甚至直接使用数组查找来替换整个控制语句块。
在跟踪状态章节中,问题:解码信息的最终代码中,我们使用了一个switch语句将解码后的输入数字(范围在 1-8 之间)在“标点符号模式”下转换为相应的字符,因为数字和字符之间的联系是任意的。尽管这样做效果不错,但它使得该段代码比大写和小写模式的等效代码更长,而且如果标点符号的数量增加,代码的可扩展性不会很好。我们可以使用一个数组来解决这个问题,而不是使用switch语句。首先,我们需要将标点符号永久地分配到一个数组中,其顺序与编码方案中出现的顺序相同:
const char punctuation[8] = {'!', '?', ',', '.', ' ', ';', '"', '\''};
注意,这个数组已经被声明为const,因为其内部值永远不会改变。有了这个声明,我们可以用一个引用数组的单一赋值语句来替换整个switch语句:
outputCharacter = punctuation[number - 1];
因为输入数字的范围是 1-8,但数组元素是从 0 开始编号的,所以在引用数组之前,我们必须从输入数字中减去 1;这与我们在“寻找众数”程序的直方图版本中所做的调整相同。你可以使用相同的数组来反向操作。假设我们不是在解码信息,而是需要编码信息——也就是说,我们被给了一系列字符,需要将它们转换为可以使用原始问题规则解码的数字。要将一个标点符号转换为它的数字,我们必须在数组中定位该符号。这是一个检索操作,使用的是顺序搜索技术。假设要转换并存储在char变量targetValue中的字符,我们可以将顺序搜索代码调整为以下形式:
const int ARRAY_SIZE = 8;
int targetPos = 0;
while (punctuation[targetPos] != targetValue && targetPos < ARRAY_SIZE)
targetPos++;
int punctuationCode = targetPos + 1;
注意,就像我们在上一个例子中必须从number中减去 1 以获得正确的数组位置一样,在这个例子中我们必须将数组位置加 1 以获得我们的标点符号代码,从数组的 0-7 范围转换为我们的标点符号代码范围 1-8。尽管这段代码不如单行简单,但它仍然比一系列switch语句简单得多,并且具有良好的可扩展性。如果我们想要将我们的编码方案中的标点符号数量加倍,那么数组中的元素数量也会加倍,但代码的长度将保持不变。
因此,一般来说,const数组可以用作查找表,取代一系列繁重的控制语句。假设你正在编写一个程序来计算一个州中商业许可证的费用,其中许可证费用随商业的毛销售额变化而变化。
表 3-1. 商业许可证费用
| 商业类别 | 销售门槛 | 许可费用 |
|---|---|---|
| I | $0 | $25 | |
| II | $50,000 | $200 | |
| III | $150,000 | $1,000 | |
| IV | $500,000 | $5,000 |
对于这个问题,我们可以使用数组来确定基于公司的毛销售额的商业类别,并根据商业类别分配许可证费用。假设一个double变量grossSales存储了商业的毛销售额,根据销售额,我们想要为int category和double cost分配适当的值:
const int NUM_CATEGORIES = 4;
const double categoryThresholds[NUM_CATEGORIES ] =
{0.0, 50000.0, 150000.0, 500000.0};
const double licenseCost[NUM_CATEGORIES ] =
{50.0, 200.0, 1000.0, 5000.0};
category = 0;
while (category < NUM_CATEGORIES &&
categoryThresholds[category] <= grossSales) {
category++;
}
cost = licenseCost[category - 1];
此代码使用两个固定值的数组。第一个数组存储每个商业类别的毛销售额门槛!。例如,年毛销售额为 65,000 美元的商业属于类别 II,因为这个金额超过了类别 II 的 50,000 美元门槛,但低于类别 III 的 150,000 美元门槛。第二个数组存储每个类别的商业许可证费用!。有了这些数组,我们将category初始化为 0!,并搜索categoryThresholds数组,直到门槛超过毛销售额或我们用完类别!。在两种情况下,当循环结束时,category将根据毛销售额正确分配为 1-4。最后一步是使用category从licenseCost数组中引用许可证费用!。和之前一样,我们必须对商业类别的 1-4 范围和我们的数组 0-3 范围进行小的调整。
非标量数组
到目前为止,我们只是处理了简单数据类型的数组,如int和double。然而,程序员经常必须处理复合数据类型的数组,无论是结构体还是对象(struct 或class)。尽管使用复合数据类型必然会使代码变得复杂一些,但这并不需要使我们对数组处理的思考变得复杂。通常,数组处理只涉及struct或class的一个数据成员,我们可以忽略数据结构的其他部分。有时,使用复合数据类型需要我们对方法进行一些调整。
例如,考虑寻找一组学生成绩中最高的成绩的问题。假设我们不是有一个int类型的数组,而是一个包含数据结构的数组,每个数据结构代表一个学生的记录:
struct student {
int grade;
int studentID;
string name;
};
与数组一起工作的一个好处是,可以轻松地使用字面值初始化整个数组,以便于测试,即使是struct数组:
const int ARRAY_SIZE = 10;
student studentArray[ARRAY_SIZE] = {
{87, 10001, "Fred"},
{28, 10002, "Tom"},
{100, 10003, "Alistair"},
{78, 10004, "Sasha"},
{84, 10005, "Erin"},
{98, 10006, "Belinda"},
{75, 10007, "Leslie"},
{70, 10008, "Candy"},
{81, 10009, "Aretha"},
{68, 10010, "Veronica"}
};
这个声明意味着studentArray[0]的grade是 87,studentID是 10001,name是“Fred”,数组中的其他九个元素以此类推。至于代码的其他部分,可能就像复制本章开头的代码,然后将所有形式为intArray[subscript]的引用替换为studentArray[subscript].grade。这将导致以下结果:
int highest = studentArray[0].grade;
for (int i = 1; i < ARRAY_SIZE; i++) {
if (studentArray[i].grade > highest) highest = studentArray[i].grade;
}
假设现在我们为每个学生有了额外的信息,我们想要找到成绩最好的学生的名字,而不是成绩本身。这将需要额外的修改。当循环结束时,我们唯一拥有的统计数据是最佳成绩,这并不允许我们直接确定它属于哪个学生。我们不得不再次遍历数组,寻找具有匹配grade的struct,这似乎是额外的、我们不应该做的额外工作。为了避免这个问题,我们应该额外跟踪与当前highest值匹配的学生名字,或者,而不是跟踪最高成绩,跟踪最高成绩在数组中的位置,就像我们在histogram中做的那样。后一种方法是最通用的,因为跟踪数组位置允许我们稍后检索该学生的任何数据:
int highPosition = 0;
for (int i = 1; i < ARRAY_SIZE; i++) {
if (studentArray[i].grade > studentArray[highPosition].grade) {
highPosition = i;
}
}
在这里,变量highPosition
取代了highest。因为我们没有直接跟踪最接近平均分的成绩,所以在需要将最接近平均分的成绩与当前成绩进行比较时,我们使用highPosition作为studentArray
的引用。如果当前数组位置的成绩更高,则将当前处理循环的位置分配给highPosition
。一旦循环结束,我们可以使用studentArray[highPosition].name访问成绩最接近平均分的学生姓名,我们还可以访问与该学生记录相关的任何其他数据。
多维数组
到目前为止,我们只讨论了一维数组,因为它们是最常见的。二维数组不常见,三维或更多维度的数组更罕见。这是因为大多数数据在本质上是一维的。此外,本质上多维的数据可以表示为多个单维数组,因此使用多维数组始终是程序员的选项。考虑表 3-1 的商业许可数据,这显然是多维数据。我的意思是,看看它——它是一个网格!然而,我将这个多维数据表示为两个单维数组,categoryThresholds和licenseCost。我本可以将数据表表示为二维数组,如下所示:
const double licenseData[2][numberCategories] = {
{0.0, 50000.0, 150000.0, 500000.0},
{50.0, 200.0, 1000.0, 5000.0}
};
将两个数组合并为一个很难看出任何优势。我们的代码并没有简化,因为没有理由一次性处理表格中的所有数据。然而,很明显,我们降低了表格数据的可读性和易用性。在原始版本中,两个单独数组的名称清楚地表明了存储在每个数组中的数据。而在合并后的数组中,我们程序员将不得不记住,形式为licenseData[0][]的引用指的是不同商业类别的总销售额阈值,而形式为licenseData[1][]的引用则指的是商业许可费用。
虽然有时使用多维数组是有意义的。假设我们正在处理三位销售代理的月销售数据,其中一项任务是找出任何代理的最高月销售额。将所有数据放在一个 3×12 的数组中意味着我们可以一次性处理整个数组,使用嵌套循环:
const int NUM_AGENTS = 3;
const int NUM_MONTHS = 12;
int sales[NUM_AGENTS][NUM_MONTHS] = {
{1856, 498, 30924, 87478, 328, 2653, 387, 3754, 387587, 2873, 276, 32},
{5865, 5456, 3983, 6464, 9957, 4785, 3875, 3838, 4959, 1122, 7766, 2534},
{23, 55, 67, 99, 265, 376, 232, 223, 4546, 564, 4544, 3434}
};
int highestSales = sales[0][0];
for (int agent = 0; agent < NUM_AGENTS; agent++) {
for (int month = 0; month < NUM_MONTHS; month++) {
if (sales[agent][month] > highestSales)
highestSales = sales[agent][month];
}
}
虽然这是一个基本的“找到最大数”代码的直接改编,但还有一些小问题。当我们声明二维数组时,请注意初始化器是按 agent 组织的,也就是说,是 3 组 12 个,而不是 3 组 12 个
。正如你将在下一个问题中看到的那样,这个决定可能会产生后果。我们像往常一样将 highestSales 初始化为数组的第一个元素
。你可能觉得在嵌套循环的第一次迭代中,我们的两个循环计数器都将为 0,因此我们将比较 highestSales 的初始值与自身。这不会影响结果,但有时新手程序员会试图通过在内部循环体中放入第二个 if 语句来避免这种微小的低效:
if (agent != 0 || month != 0)
if (sales[agent][month] > highestSales)
highestSales = sales[agent][month];
然而,这比之前的版本效率低得多,因为我们将进行 50 次额外的比较,而只避免了一次。
注意,我已经为循环变量使用了有意义的名称:agent 用于外部循环
和 month 用于内部循环
。在处理一维数组的单循环中,描述性标识符带来的好处很小。然而,在处理二维数组的双循环中,有意义的标识符有助于我保持维度和下标的清晰,因为我可以查看并确认我在使用 agent 的维度与在数组声明中使用 numAgents 的维度相同。
即使我们有多维数组,有时一次处理一个维度可能是最好的方法。假设,使用与之前代码相同的 sales 数组,我们想要显示每个 agent 的月销售平均最高值。我们可以像之前一样使用双循环,但如果我们将整个数组视为三个单独的数组并分别处理它们,代码将更容易阅读和编写。
记得我们反复使用的计算整数数组平均值的代码吗?让我们将其制作成一个函数:
double arrayAverage(int intArray[], int ARRAY_SIZE) {
double sum = 0;
for (int i = 0; i < ARRAY_SIZE; i++) {
sum += intArray[i];
}
double average = (sum + 0.5) / ARRAY_SIZE;
return average;
}
函数就位后,我们可以再次修改基本的“找到最大数”代码,以找到月销售平均最高的 agent:
int highestAverage = arrayAverage(sales[0], 12);
for (int agent = 1; agent < NUM_AGENTS; agent++) {
int agentAverage = arrayAverage(sales[agent], 12);
if (agentAverage > highestAverage)
highestAverage = agentAverage;
}
cout << "Highest monthly average: " << highestAverage << "\n";
这里的大新观点体现在对arrayAverage函数的两次调用中。这个函数接受一个一维的int数组作为第一个参数。在第一次调用中,我们传递了sales[0]作为第一个参数 ![http://atomoreilly.com/source/no_starch_images/1273182.png],而在第二次调用中,我们传递了sales[agent] ![http://atomoreilly.com/source/no_starch_images/1273191.png]。因此,在这两种情况下,我们都为我们的二维数组sales的第一个维度指定了一个下标,但没有为第二个维度指定。由于 C++中数组和地址之间的直接关系,这个引用表示了指定行的第一个元素的地址,然后我们可以将这个地址作为函数使用的一个只包含该行的单维数组的基址。
如果这听起来很困惑,请再次查看sales数组的声明,特别是初始化器。值按照程序执行时在内存中布局的相同顺序排列在初始化器中。因此,sales[0][0],即 1856,将首先出现,然后是sales[0][1],498,以此类推,直到第一个代理的最后一个月,sales[0][11],32。然后,第二个代理的值将从sales[1][0],5865 开始。因此,尽管数组在概念上是 3 行 12 个值,但在内存中它被布局为一个包含 36 个值的连续序列。
重要的是要注意,这种技术之所以有效,是因为我们放置数据到数组中的顺序。如果数组是沿着另一个轴组织,即按月份而不是按代理组织,我们就无法做我们现在所做的事情。好消息是,有一个简单的方法可以确保你适当地设置了数组——只需检查初始化器。如果你想要单独处理的数据在数组初始化器中不是连续的,你就错误地组织了数据。
关于这段代码的最后一点是临时变量agentAverage的使用。由于当前代理的平均月销售额可能被在if语句的条件表达式中和然后在语句体中的赋值语句中两次引用,临时变量消除了对同一代理数据调用arrayAverage两次的可能性。
将多维数组视为数组数组的这种技术直接源于我们分解问题为更简单组件的核心原则,并且通常使得多维数组问题更容易概念化。即便如此,你可能认为这种技术看起来有点难以应用,如果你像大多数新的 C++程序员一样,你可能对地址和背后的地址运算有些谨慎。我认为,通过在一个struct或class中放置数组的一个级别,甚至可以更加强化维度的分离,这是克服这些感觉的最好方法。假设我们创建了一个agentStruct:
struct agentStruct {
int monthlySales[12];
};
既然我们已经费心创建了一个struct,我们可能会考虑添加其他数据,比如代理识别号,但这将有助于简化我们的思维过程。有了struct,我们不再创建一个二维销售数组,而是创建一个一维代理数组:
agentStruct agents[3];
现在我们调用数组平均函数时,我们并没有使用 C++特有的技巧;我们只是传递了一个一维数组。例如:
int highestAverage = arrayAverage(agents[1].monthlySales, 12);
决定何时使用数组
数组只是一个工具。就像任何工具一样,学习如何使用数组的一个重要部分是学习何时使用它——以及何时不使用它。到目前为止讨论的示例问题假设了在描述中使用数组。然而,在大多数情况下,我们不会有人详细说明这一点,我们必须自己决定是否使用数组。我们必须做出这种决定的常见情况是我们被提供了聚合数据,但没有被告知如何内部存储。例如,在找到众数的问题中,假设开始于“编写代码处理调查数据数组……”的行被改为“编写代码处理调查数据集合……”。现在,是否使用数组的选择就取决于我们了。我们如何做出这个决定?
记住,一旦数组被创建,我们无法改变其大小。如果我们空间不足,程序将会失败。因此,首要考虑的是,在我们程序中需要聚合数据结构的地方,我们是否知道将要存储多少值,或者至少对最大大小的可靠估计。这并不意味着我们在编写程序时必须知道数组的大小。C++,以及大多数其他语言,都允许我们在运行时创建大小可变的数组。假设模式问题被修改,以至于我们事先不知道会有多少调查响应,但这个数字作为用户输入传递给程序。然后我们可以动态声明一个数组来存储调查数据。
int ARRAY_SIZE;
cout << "Number of survey responses: ";
cin >> ARRAY_SIZE;
int *surveyData = new int[ARRAY_SIZE];
for(int i = 0; i < ARRAY_SIZE; i++) {
cout << "Survey response " << i + 1 << ": ";
cin >> surveyData[i];
}
我们使用指针表示法声明数组,并通过调用new运算符对其进行初始化 ![http://atomoreilly.com/source/no_starch_images/1273182.png]。由于 C++中指针和数组类型之间的灵活性,元素可以通过数组表示法访问 ![http://atomoreilly.com/source/no_starch_images/1273191.png],即使surveyData被声明为指针。请注意,因为这个数组是动态分配的,所以在程序结束时,当我们不再需要数组时,我们必须确保释放它:
delete[] surveyData;
对于数组,我们使用的是delete[]运算符,而不是通常的delete运算符。虽然对于整数数组来说这不会造成任何区别,但如果您创建了一个对象数组,delete[]运算符确保在删除数组本身之前删除数组中的各个对象。因此,您应该养成始终使用delete[]与动态分配的数组一起使用的习惯。
承担清理动态内存的责任是 C++程序员的噩梦,但如果你使用这种语言编程,这是一件你必须做的事情。初学者往往逃避这个责任,因为他们的程序很小,执行时间很短,所以他们从未看到内存泄漏(程序不再使用的内存但从未被释放,因此无法供系统其他部分使用)的有害影响。不要养成这种坏习惯。
注意,我们之所以能够使用动态数组,仅仅是因为用户事先告诉我们调查响应的数量。考虑另一种情况,用户开始输入调查响应,但没有告诉我们响应的数量,通过输入-1(一种称为哨兵的数据输入方法)来表示没有更多的响应。我们还能使用数组来解决这个问题吗?
这是一个灰色地带。如果我们有一个保证的最大响应数量,我们仍然可以使用数组。在这种情况下,我们可以声明一个该大小的数组,并假设我们是安全的。尽管如此,我们可能仍然会长期担忧。如果未来调查池的大小增加怎么办?如果我们想用不同的调查者使用相同的程序怎么办?更普遍地说,如果我们能避免,为什么还要构建一个具有已知限制的程序呢?
那么,最好使用一个没有固定大小的数据收集。如前所述,C++标准模板库中的向量类就像一个数组,但会根据需要增长。一旦声明和初始化,向量就可以像数组一样被处理。我们可以使用标准数组符号给向量元素赋值或检索值。如果向量已填满其初始大小,我们需要添加另一个元素,我们可以使用push_back方法来实现。使用向量解决修改后的问题看起来是这样的:
vector<int> surveyData;
surveyData.reserve(30);
int surveyResponse;
cout << "Enter next survey response or −1 to end: ";
cin >> surveyResponse;
while (surveyResponse != −1) {
surveyData.push_back(surveyResponse);
cout << "Enter next survey response or −1 to end: ";
cin >> surveyResponse;
}
int vectorSize = surveyData.size();
const int MAX_RESPONSE = 10;
int histogram[MAX_RESPONSE];
for (int i = 0; i < MAX_RESPONSE; i++) {
histogram[i] = 0;
}
for (int i = 0; i < vectorSize; i++) {
histogram[surveyData[i] - 1]++;
}
int mostFrequent = 0;
for (int i = 1; i < MAX_RESPONSE; i++) {
if (histogram[i] > histogram[mostFrequent]) mostFrequent = i;
}
mostFrequent++;
在这个代码中,我们首先声明了向量 ![http://atomoreilly.com/source/no_starch_images/1273182.png] 并为 30 个调查响应预留了空间 ![http://atomoreilly.com/source/no_starch_images/1273191.png]。第二步不是严格必要的,但预留一小部分超出预期元素数量的空间可以防止向量在我们添加值时频繁调整大小。我们在数据输入循环之前读取了第一个成绩 ![http://atomoreilly.com/source/no_starch_images/1273193.png],这是一种我们在上一章中首次使用的技术,允许我们在处理之前检查每个输入的值。在这种情况下,我们想要避免将哨兵值-1 添加到我们的向量中。调查结果使用push_back方法 ![http://atomoreilly.com/source/no_starch_images/1273195.png] 添加到向量中。数据输入循环完成后,我们使用size方法 ![http://atomoreilly.com/source/no_starch_images/1273197.png] 获取向量的大小。我们也可以在数据输入循环中自己计数元素数量,但由于向量已经跟踪其大小,这避免了重复的工作。其余的代码与使用数组和固定数量响应的上一版本相同,只是我们更改了变量的名称。
然而,所有关于向量的讨论都忽略了一个重要的观点。如果我们直接从用户那里读取数据,而不是被告知我们从一个数组或其他数据集合开始,我们可能不需要为调查数据使用数组,只需要为直方图使用一个。相反,我们可以在读取调查值时处理它们。我们只需要数据结构在我们需要读取所有值在处理之前或需要多次处理值时。在这种情况下,我们不需要做这两件事:
const int MAX_RESPONSE = 10;
int histogram[MAX_RESPONSE];
for (int i = 0; i < MAX_RESPONSE; i++) {
histogram[i] = 0;
}
int surveyResponse;
cout << "Enter next survey response or −1 to end: ";
cin >> surveyResponse;
while (surveyResponse != −1) {
histogram[surveyResponse - 1]++;
cout << "Enter next survey response or −1 to end: ";
cin >> surveyResponse;
}
int mostFrequent = 0;
for (int i = 1; i < MAX_RESPONSE; i++) {
if (histogram[i] > histogram[mostFrequent]) mostFrequent = i;
}
mostFrequent++;
尽管这个代码编写起来很简单,鉴于有之前的版本作为指导,只需将用户数据读入数组并直接使用之前的处理循环即可,这样做会更加简单。这种边做边走的过程的好处在于效率。我们避免了在只需要存储一个响应时,不必要地存储每个调查响应。我们的基于向量的解决方案在空间上效率低下:它占用的空间比所需的更多,而没有提供相应的利益。此外,将所有成绩读入向量需要单独的循环,这个循环与处理所有调查响应和找到直方图中的最大值的循环是分开的。这意味着向量版本做了比上面版本更多的工作。因此,向量版本在时间上也是低效的:它做了比所需更多的工作,而没有提供相应的利益。在某些情况下,不同的解决方案会提供权衡,程序员必须在空间效率和时间效率之间做出决定。然而,在这种情况下,使用向量使得程序在各方面都变得低效。
在这本书中,我们不会花费大量时间去追踪每一个低效之处。程序员有时必须进行性能调优,这是对程序在时间和空间上的效率进行系统分析和改进的过程。对程序进行性能调优就像对赛车进行性能调优:这是一项精确的工作,其中微小的调整可以产生巨大的影响,并且需要了解“引擎盖下”机制如何工作的专业知识。即使我们没有时间、欲望或知识来完全调优程序的性能,我们仍然应该避免导致严重低效的决定。不必要地使用向量或数组,就像发动机燃料与空气混合比例过稀一样;这就像当你可以把所有东西都塞进一辆本田思域时,却开着公交车去海滩度假。
如果我们确定需要多次处理数据,并且我们对数据集的最大大小有很好的把握,那么决定是否使用数组的最后一个标准就是随机访问。稍后,我们将讨论其他数据结构,如列表,它们像向量一样可以按需增长,但与向量和数组不同,元素只能按顺序访问。也就是说,如果我们想访问列表中的第 10 个元素,我们必须运行前 9 个元素才能到达它。相比之下,随机访问意味着我们可以在任何时间访问数组或向量中的任何元素。所以最后一个规则是,当我们需要随机访问时应该使用数组。如果我们只需要顺序访问,我们可能需要考虑不同的结构。
你可能会注意到,本章中的许多程序都未能满足最后一个标准;我们按顺序访问数据,而不是随机访问,但我们却在使用数组。这导致了所有这些规则的一个伟大的、常识性的例外。如果数组很小,那么之前的所有反对意见都不会有很大的分量。“小”的定义可能因平台或应用程序而异。关键是,如果你的程序需要 1 个或 10 个物品的集合,每个物品需要 10 个字节,你必须考虑从分配最大所需大小的数组中可能产生的 90 个字节的潜在浪费是否值得寻找更好的解决方案。明智地使用数组,但不要让完美成为优秀的敌人。
练习
总是如此,我敦促你们尽可能多地尝试练习。
-
你们对我们没有做更多关于排序的事情感到失望吗?我在这里就是为了帮助你们。为了确保你们对
qsort函数感到舒适,编写使用该函数对学生的struct数组进行排序的代码。首先让它按成绩排序,然后再次尝试使用学生 ID 进行排序。 -
重新编写查找具有最佳月销售平均值的代理的代码,使其查找具有最高中位数销售的代理。如前所述,一组值的中位数是“中间的值”,即其他一半的值更高,另一半的值更低。如果有偶数个值,中位数是中间两个值的简单平均值。例如,在集合 10, 6, 2, 14, 7, 9 中,中间的值是 7 和 9。7 和 9 的平均值是 8,所以 8 是中位数。
-
编写一个
bool函数,该函数接受一个数组和该数组中的元素数量,并确定数组中的数据是否已排序。这应该只需要一次遍历! -
这里是
const值数组的另一种变体。编写一个程序来创建一个替换密码问题。在替换密码问题中,所有消息都由大写字母和标点符号组成。原始消息称为明文,你通过将每个字母替换为另一个字母(例如,每个 C 可以变成 X)来创建密文。对于这个问题,硬编码一个包含 26 个char元素的const数组作为密码,并让程序读取明文消息并输出相应的密文。 -
让前面的程序将密文转换回明文以验证编码和解码。
-
为了使密文问题更具挑战性,让程序随机生成密码数组而不是硬编码的
const数组。实际上,这意味着在每个数组元素中放置一个随机字符,但请记住,你不能用字母替换自己。所以第一个元素不能是 A,并且你不能为两次替换使用相同的字母——也就是说,如果第一个元素是 S,则其他元素不能是 S。 -
编写一个程序,该程序接受一个整数数组,并确定其众数,即数组中出现频率最高的数字。
-
编写一个程序,该程序处理
student对象数组,并确定成绩四分位数——也就是说,一个人需要达到或超过 25%的学生、50%的学生和 75%的学生的成绩。 -
考虑对
sales数组的这种修改:由于销售人员一年中会有变动,我们现在用-1 标记销售代理招聘前的月份,或销售代理最后一个月之后的月份。重新编写你的最高销售平均数或最高销售中位数代码以进行补偿。
第四章. 使用指针和动态内存解决问题

在本章中,我们将学习如何使用指针和动态内存解决问题,这将使我们能够编写灵活的程序,以适应程序运行时才知道的数据大小。指针和动态内存分配是“核心”编程。当你能够动态地获取内存块,将它们链接成有用的结构,并在结束时清理一切,以确保没有残留物时,你不仅仅是一个会写点代码的人——你是一个程序员。
由于指针很复杂,并且许多流行的语言,如 Java,似乎放弃了指针的使用,一些初学者程序员会说服自己可以完全跳过这个主题。这是一个错误。指针和间接内存访问将在高级编程中始终被使用,即使它们可能被高级语言机制所隐藏。因此,为了真正像程序员一样思考,你必须能够通过指针和基于指针的问题进行思考。
在我们着手解决指针问题之前,我们将仔细检查指针工作的所有方面,包括表面和幕后。这项研究提供了两个好处。首先,这些知识将使我们能够最有效地使用指针。其次,通过消除指针的神秘感,我们可以有信心地使用它们。
指针基础回顾
与前面章节中介绍的主题一样,你应该已经接触过基本指针的使用,但为了确保我们处于同一页面上,这里有一个快速回顾。
C++中的指针用星号(*)表示。根据上下文,星号表示正在声明一个指针,或者我们指的是被指向的内存,而不是指针本身。要声明一个指针,我们在类型名和标识符之间放置星号:
int * intPointer;
这将变量intPointer声明为一个指向int的指针。请注意,星号与标识符绑定,而不是与类型绑定。在下面的例子中,variable1是一个指向int的指针,但variable2只是一个int:
int * variable1, variable2;
变量前的符号“&”充当“地址-of”操作符。因此,我们可以将variable2的地址赋给variable1,如下所示:
variable1 = &variable2;
我们还可以直接将一个指针变量的值赋给另一个指针变量:
intPointer = variable1;
也许是更重要的是,我们可以在运行时分配内存,并且只能通过指针访问。这是通过new操作符实现的:
double * doublePointer = new double;
访问指针另一端的内存称为“解引用”,并且通过在指针标识符左侧使用星号来实现。同样,这也是我们用于指针声明的相同位置。上下文使意义不同。以下是一个例子:
*doublePointer = 35.4;
double localDouble = *doublePointer;
在将值从该内存位置复制到变量localDouble之前,我们给之前代码分配的double变量赋值
。
为了释放使用new分配的内存,一旦我们不再需要它,我们使用关键字delete:
delete doublePointer;
这个过程的机制在内存问题中详细描述,在内存问题中。
指针的好处
指针赋予我们静态内存分配所不具备的能力,同时也提供了高效使用内存的新机会。使用指针的三个主要好处是:
-
运行时大小的数据结构
-
可变大小的数据结构
-
内存共享
让我们更详细地看看这些内容。
运行时大小的数据结构
通过使用指针,我们可以创建一个在运行时确定大小的数组,而不是在构建我们的应用程序之前就必须选择大小。这使我们免于在数组可能空间不足和使数组尽可能大之间做出选择,从而在平均情况下浪费大量数组空间。我们第一次在何时使用数组中看到了运行时数据大小,在何时使用数组中。我们将在本章后面使用这个概念,在可变长度字符串和解决指针问题中。
可变大小的数据结构
我们也可以创建在运行时根据需要增长或缩小的基于指针的数据结构。最基本的可变大小数据结构是链表,你可能已经见过。尽管结构中的数据只能按顺序访问,但链表总是有与数据本身一样多的数据位置,没有浪费的空间。你将在后面看到的其他更复杂基于指针的数据结构,它们具有排序和“形状”,可以比数组更好地反映底层数据的关系。正因为如此,尽管数组提供了指针结构无法提供的完全随机访问,但检索操作(我们在结构中找到最符合一定标准的元素)在基于指针的结构中可以更快。我们将在本章后面利用这个优势创建一个按需增长的学生记录数据结构。
内存共享
指针可以通过允许内存块共享来提高程序效率。例如,当我们调用一个函数时,我们可以传递一个内存块的指针而不是使用引用参数传递该块的副本。你很可能之前见过这些;它们是形式参数列表中类型和名称之间出现&符号的参数:
void refParamFunction (int & x) {
x = 10;
}
int number = 5;
refParamFunction(number);
cout << number << "\n";
注意
在&符号前后显示的空格不是必需的——我只是为了美观而包括它们。在其他开发者的代码中,你可能看到int& x、int &x,甚至可能是int&x。
在此代码中,形式参数x
并不是number
参数的副本;相反,它是对存储number的内存的引用。因此,当x被修改
时,number的内存空间也会改变,并且代码片段末尾的输出是10
。引用参数可以用作将值从函数中传出的机制,如本例所示。更广泛地说,引用参数允许被调用的函数和调用函数共享相同的内存,从而降低开销。如果一个作为参数传递的变量占用 1 千字节内存,那么以引用传递变量意味着复制一个 32 位或 64 位指针而不是 1 千字节。我们可以通过使用const关键字来表示我们正在使用引用参数以性能为目的,而不是输出:
int anotherFunction(const int & x);
通过在引用参数x的声明前加上const,anotherFunction将接收调用中传递的参数的引用,但将无法修改该参数中的值,就像任何其他const参数一样。
通常,我们可以这样使用指针,允许程序的不同部分或程序内部的不同数据结构访问相同的数据,而无需复制带来的开销。
何时使用指针
正如我们讨论数组时提到的,指针有潜在的缺点,并且只有在适当的情况下才应使用。我们如何知道何时使用指针是合适的呢?在刚刚列出了指针的好处之后,我们可以说,指针应该只在需要其一个或多个好处时使用。如果你的程序需要一个结构来存储数据聚合,但你无法在运行时之前准确估计数据量;或者你需要一个在执行过程中可以增长和缩小的结构;或者如果你有大型对象或其他数据块在程序中传递,指针可能是可行的。然而,在没有这些情况的情况下,你应该对指针和动态内存分配持谨慎态度。
考虑到指针臭名昭著的声誉,作为 C++中最难用的特性之一,你可能会认为没有程序员会在不必要的时候尝试使用指针。然而,我多次惊讶地发现并非如此。有时程序员只是欺骗自己认为需要指针。假设你正在调用一个由其他人编写的函数,可能是从库或应用程序编程接口中,其原型如下:
void compute(int input, int* output);
我们可能会想象这个函数是用 C 语言编写的,而不是 C++,这就是为什么它使用指针而不是引用(&)来传递“输出”参数。在调用这个函数时,程序员可能会粗心大意地做如下操作:
int num1 = 10;
int* num2 = new int;
compute(num1, num2);
这段代码在空间上效率低下,因为它在不需要指针的地方创建了一个指针。它不是使用两个整数的空间,而是使用了两个整数和一个指针的空间。代码在时间上的效率也低下,因为不必要的内存分配需要时间(如下一节所述)。最后,程序员现在必须记住要delete分配的内存。所有这些都可以通过使用&操作符的另一个方面来避免,它允许你获取静态分配变量的地址,如下所示:
int num1 = 10;
int num2;
compute(num1, &num2);
严格来说,我们在第二个版本中仍在使用指针,但我们是在隐式地使用它,没有使用指针变量或动态内存分配。
内存的重要性
要理解动态内存分配如何为我们提供运行时大小调整和内存共享,我们必须稍微了解一下内存分配在一般情况下是如何工作的。这是我认为新程序员学习 C++会受益的一个领域。所有程序员最终都必须理解现代计算机中内存系统的工作方式,而 C++迫使你直面这个问题。其他语言隐藏了内存系统的大部分脏细节,新程序员会说服自己这些细节无关紧要,这显然是不对的。相反,只要一切正常,这些细节确实无关紧要。然而,一旦出现问题,对底层内存模型的缺乏了解就会在程序员和解决方案之间形成难以逾越的障碍。
栈和堆
C++ 在两个地方分配内存:栈 和 堆。正如其名所示,栈是有序且整洁的,而堆是零散且混乱的。栈 这个名字尤其具有描述性,因为它有助于你可视化内存分配的连续性。想象一下一堆箱子,就像图 4-1(a)中所示。当你有箱子要存放时,你将其放在栈顶。要从栈中移除特定的箱子,你必须首先移除其上方的所有箱子。在实际编程术语中,这意味着一旦你在栈上分配了一块内存(一个箱子),就无法调整其大小,因为任何时间你可能有其他内存块紧随其后(在其上方的其他箱子)。
在 C++ 中,你可能会为特定的算法显式创建自己的栈,但无论如何,你的程序总是会使用一个栈,称为程序的 运行时栈。每次调用函数(包括 main 函数),都会在运行时栈的顶部分配一块内存。这块内存称为 激活记录。对其内容的全面讨论超出了本文的范围,但为了你作为问题解决者的理解,激活记录的主要内容是变量的存储空间。所有局部变量的内存,包括函数的参数,都在激活记录内分配。让我们来看一个例子:
int functionB(int inputValue) {
return inputValue - 10;
}
int functionA(int num) {
int localVariable = functionB(num * 10);
return localVariable;
}
int main()
{
int x = 12;
int y = functionA(x);
return 0;
}
在此代码中,main 函数调用 functionA,而 functionA 又调用 functionB。图 4-1(b)展示了在执行 functionB 的返回语句之前,运行时栈的简化排列方式!
。所有三个函数的激活记录将按连续内存的顺序排列,其中 main 函数位于栈底。(为了使事情更加复杂,栈可能从内存中的最高点开始,向下构建到较低的内存地址,而不是向上构建到较高的内存地址。不过,忽略这种可能性并不会对你造成伤害。)从逻辑上讲,main 函数的激活记录位于栈底,functionA 的激活记录位于其上方,而 functionB 的激活记录位于 functionA 的上方。在 functionB 的激活记录被移除之前,下面两个较低的激活记录都不能被移除。

图 4-1. 一堆箱子以及一堆函数调用
虽然栈非常有序,但与之相比,堆的组织性很小。假设你再次用板条箱存储东西,但这些板条箱很脆弱,你不能把它们堆叠在一起。你有一个大房间,最初是空的,用来存放板条箱,你可以把它们放在地板上的任何地方。然而,板条箱很重,所以一旦放下,你宁愿把它放在那里,直到你准备好把它从房间里拿出来。这个系统与栈相比有优点也有缺点。一方面,这种存储系统很灵活,允许你在任何时间获取任何板条箱的内容。另一方面,房间很快就会变得杂乱无章。如果板条箱大小不一,那么充分利用地板上的所有空间将特别困难。你会在板条箱之间留下很多无法用另一个板条箱填充的空隙。由于板条箱不能轻易移动,移除几个板条箱只会产生几个难以填充的空隙,而不是提供我们最初空旷地板的宽敞存储空间。在实际编程术语中,我们的堆就像那个房间的地板。一块内存是一系列连续的地址;因此,在具有许多内存分配和释放的程序的生命周期中,我们最终会在剩余的分配内存块之间留下很多空隙。这个问题被称为内存碎片化。
每个程序都有自己的堆,内存在这里是动态分配的。在 C++中,这通常意味着调用new关键字,但你也会看到对旧 C 函数的调用,例如malloc。每次调用new(或malloc)都会在堆中预留一块内存并返回该块的指针,而每次调用delete(或如果内存是用malloc分配的,则返回free)都会将块返回到可用的堆内存池中。由于碎片化,池中的内存并不都同样有用。如果我们程序一开始在堆内存中分配变量 A、B 和 C,我们可能会期望这些块是连续的。如果我们释放 B,它留下的空隙只能被另一个大小为 B 或更小的请求填充,直到 A 或 C 也被释放。
图 4-2 阐明了这种情况。在部分(a)中,我们看到我们房间的地板上散落着箱子。在某个时刻,房间可能组织得很好,但随着时间的推移,布局变得杂乱无章。现在有一个小箱子(b)无法放入地板上的任何开放空间,尽管整体未使用的地板面积远远超过了箱子的占地面积。在部分(c)中,我们表示一个小堆。虚线方框是内存的最小(不可分割)块,可能是单个字节、内存字或更大的块,具体取决于堆管理器。阴影区域表示连续内存的分配;为了清晰起见,一个分配的部分块被编号。与碎片化的地板一样,碎片化的堆将未分配的内存块分开,这降低了其可用性。总共有 85 个未使用的内存块,但最大的连续未使用内存块,如箭头所示,只有 17 个块长。换句话说,如果每个块都是一个字节,这个堆无法满足new调用超过 17 字节的任何请求,尽管堆有 85 字节空闲。

图 4-2. 一个碎片化的地板,一个无法放置的箱子,以及碎片化的内存
内存大小
记忆的第一个实际问题是要限制其使用范围在必要范围内。现代计算机系统拥有如此多的内存,很容易将其视为一种无限资源,但实际上每个程序都有有限的内存。此外,程序需要高效地使用内存,以避免整体系统变慢。在多任务操作系统(这意味着几乎所有的现代操作系统)中,一个程序浪费的每一个字节都会使整个系统趋向于这样一个点:当前运行程序集没有足够的内存来运行。在这个点上,操作系统会不断地将一个程序的块交换到另一个程序中,从而使得系统变得极其缓慢。这种情况被称为颠簸。
注意,除了希望将整个程序的内存占用尽可能小之外,栈和堆还有最大大小限制。为了证明这一点,让我们每次从堆中分配 1 千字节大小的内存,直到出现故障:
const int intsPerKilobyte = 1024 / sizeof(int);
while (true) {
int *oneKilobyteArray = new int[intsPerKilobyte];
}
让我强调,这段代码纯粹是为了演示某个观点而编写的糟糕代码。如果你想在你的系统上尝试这段代码,请先保存所有的工作,以确保安全。应该发生的情况是程序停止运行,你的操作系统会抱怨代码生成了一个但未处理的 bad_alloc 异常。这个异常是在堆中没有足够大的未分配内存块来满足请求时由 new 抛出的。堆内存耗尽被称为 堆溢出。在某些系统中,堆溢出可能很常见,而在其他系统中,程序在产生 bad_alloc 之前就会导致系统崩溃(在我的系统中,new 调用失败直到我在之前的调用中分配了两个吉字节)。
运行时栈也存在类似的情况。每个函数调用都会在栈上分配空间,即使是没有任何参数或局部变量的函数,每个激活记录也有一些固定的开销。最容易演示这一点的方法是使用一个失控的递归函数:
int count = 0;
void stackOverflow() {
count++;
stackOverflow();
}
int main()
{
stackOverflow();
return 0;
}
这段代码有一个全局变量
,在大多数情况下这是不良的编程风格,但在这里我需要一个在整个递归调用中持续存在的值。因为这个变量是在函数外部声明的,所以在函数的激活记录中不会为其分配内存,也没有其他局部变量或参数。这个函数所做的只是增加 count 并进行递归调用
。递归在 第六章 中有详细的讨论,但在这里只是简单地用来尽可能延长函数调用的链。函数的激活记录会一直留在栈上,直到该函数结束。所以当第一次从 main 调用 stackOverflow 时,会在运行时栈上放置一个激活记录,直到第一个函数调用结束之前无法移除。这永远不会发生,因为函数会再次调用 stackOverflow,在栈上放置另一个激活记录,然后它又会进行第三次调用,以此类推。这些激活记录会一直堆叠,直到栈空间耗尽。在我的系统中,当程序崩溃时 count 大约是 4,900。我的开发环境 Visual Studio 默认的栈分配是 1MB,这意味着即使没有局部变量或参数,每个函数调用也会创建一个超过 200 字节的激活记录。
生命周期
变量的生命周期是从分配到释放的时间跨度。对于基于栈的变量,意味着局部变量或参数,其生命周期是隐式处理的。变量在函数调用时分配,在函数结束时释放。对于基于堆的变量,意味着使用new动态分配的变量,其生命周期在我们手中。管理动态分配变量的生命周期是每个 C++程序员的噩梦。最明显的问题是可怕的内存泄漏,这是一种从堆中分配内存但从未释放且未由任何指针引用的情况。这里有一个简单的例子:
int *intPtr = new int;
intPtr = NULL;
在这段代码中,我们声明了一个指向整数的指针
,通过从堆中分配一个整数来初始化它。然后在第二行,我们将我们的整数指针设置为NULL
(它只是零的别名)。然而,我们使用new分配的整数仍然存在。它孤独地坐在堆中的位置,等待着永远无法到来的释放。我们无法释放这个整数,因为释放内存块,我们使用delete后跟指向该块的指针,而我们不再有指向该块的指针。如果我们尝试使用delete intPtr来跟随上面的代码,我们会得到一个错误,因为intPtr是零。
有时,我们遇到的问题不是从未释放的内存,而是试图两次释放相同的内存,这会产生运行时错误。这看起来像是一个容易避免的问题:只是不要对同一个变量两次调用delete。使这种情况变得棘手的是,我们可能有多个变量指向相同的内存。如果有多个变量指向相同的内存,并且我们在其中任何一个变量上调用delete,我们就有效地释放了所有这些变量的内存。如果我们没有明确地将变量清除为NULL,它们将被称为悬垂引用,在它们上调用delete将产生运行时错误。
解决指针问题
到目前为止,你可能已经准备好解决一些问题,所以让我们看看几个问题,看看我们如何使用指针和动态内存分配来解决它们。首先,我们将处理一些动态分配的数组,这将展示如何在所有操作中跟踪堆内存。然后,我们将真正地尝试一个动态结构。
可变长度字符串
在这个第一个问题中,我们将创建函数来操作字符串。在这里,我们使用这个术语的最一般意义:字符序列,无论这些字符是如何存储的。假设我们需要支持我们字符串类型上的三个函数。
问题:可变长度字符串操作
为三个必需的字符串函数编写基于堆的实现:
append
这个函数接受一个字符串和一个字符,并将该字符追加到字符串的末尾。
concatenate
这个函数接受两个字符串,并将第二个字符串的字符追加到第一个字符串上。
characterAt
这个函数接受一个字符串和一个数字,并返回字符串中该位置的字符(字符串中的第一个字符编号为零)。
编写代码时,假设characterAt函数将被频繁调用,而其他两个函数相对较少调用。操作的相对效率应该反映调用的频率。
在这种情况下,我们想要选择一种字符串表示方式,允许快速characterAt函数,这意味着我们需要一种快速定位特定字符的方法。你可能还记得上一章,这正是数组做得最好的事情:随机访问。因此,让我们使用char数组来解决这个问题。append和concatenate函数会改变字符串的大小,这意味着我们会遇到我们之前讨论过的所有数组问题。由于这个问题中字符串的大小没有内置限制,我们不能选择一个大的初始大小来希望一切顺利。相反,我们将在运行时调整数组的大小。
首先,让我们为我们的字符串类型创建一个typedef。我们知道我们将动态创建我们的数组,因此我们需要将字符串类型定义为指向char的指针。
typedef char * arrayString;
在此基础上,让我们开始编写函数。使用从我们已知如何开始的原则,我们可以快速编写characterAt函数。
char characterAt(arrayString s, int position) {
return s[position];
}
回想一下第三章,如果指针被分配了数组的地址,我们可以使用正常的数组表示法访问数组中的元素 ![httpatomoreillycomsourcenostarchimages1273182.png]。然而,请注意,如果position实际上不是数组s的有效元素编号,那么会发生不好的事情,并且此代码将验证第二个参数的责任放在调用者身上。我们将在练习中考虑这种情况的替代方案。现在,让我们继续到append函数。我们可以想象这个函数大致会做什么,但要得到具体的细节,我们应该考虑一个例子。这是一个我称之为通过示例案例解决的技术。
从函数或程序的非平凡样本输入开始。写下该输入的所有细节以及输出的所有细节。然后当你编写代码时,你将针对一般情况进行编写,同时也要双重检查每个步骤如何将样本转换为所需的输出状态。这种技术在处理指针和动态分配的内存时特别有帮助,因为程序中发生的大部分事情都在直接视野之外。在纸上追踪一个案例迫使你必须跟踪内存中所有变化的值——不仅包括变量直接表示的值,还包括堆中的值。
假设我们从字符串test开始,也就是说我们有一个在堆中的字符数组,包含t、e、s和t,顺序排列,并且我们想要使用我们的函数append添加一个感叹号。图 4-3 和“之后”(b)状态")显示了此操作之前(a)和之后(b)内存的状态。在这些图中,虚线垂直线左侧的是栈内存(局部变量或参数),右侧的是堆内存,使用new动态分配。

图 4-3。append函数的“之前”(a)和“之后”(b)状态
看着这个图,我立刻就看到了我们函数可能存在的问题。根据我们对字符串的实现方法,函数将创建一个比原始数组大一个元素的新数组,并将第一个数组中的所有字符复制到第二个数组中。但我们如何知道第一个数组有多大呢?从上一章我们知道,我们必须自己跟踪数组的大小。所以有些东西缺失了。
如果我们有过在标准 C/C++库中处理字符串的经验,我们就会知道缺失的成分,但如果没有,我们可以快速推理出来。记住,我们的问题解决技巧之一是寻找类比。也许我们应该考虑其他一些问题,其中某物的长度是未知的。在第二章中,我们处理了用于“Luhn 校验和验证”问题的具有任意数字位的识别码。在那个问题中,我们不知道用户会输入多少位数字。最后,我们编写了一个while循环,直到读取到最后一个字符为止。
不幸的是,在我们数组的末尾并没有等待我们的换行符。但如果我们把换行符放在所有字符串数组的最后一个元素中会怎样呢?那么我们就可以像发现识别码中有多少数字一样,发现我们数组的长度。这种方法的唯一缺点是我们不能再在我们的字符串中使用换行符,除非作为字符串的终止符。这并不一定是一个巨大的限制,这取决于字符串的用途,但为了最大的灵活性,最好选择一个不会与任何人可能实际想要使用的任何字符混淆的值。因此,我们将使用零来终止我们的数组,因为零在 ASCII 和其他字符代码系统中代表空字符。这正是标准 C/C++库所使用的方法。
在解决这个问题之后,让我们更具体地了解一下append函数将如何处理我们的样本数据。我们知道我们的函数将有两个参数,第一个是一个arrayString,它是堆中字符数组的指针,第二个是要附加的char。为了保持清晰,让我们先写出append函数的轮廓和测试它的代码。
void append(arrayString& s, char c) {
}
void appendTester() {
arrayString a = new char[5];
a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0;
append(a, '!');
cout << a << "\n";
}
appendTester函数在堆中分配我们的字符串 ![http://atomoreilly.com/source/nostarch/images/1273191.png]。请注意,数组的大小是五个,这是必要的,以便我们可以分配单词test的所有四个字母以及我们的终止空字符 ![http://atomoreilly.com/source/nostarch/images/1273193.png]。然后我们调用append ![http://atomoreilly.com/source/nostarch/images/1273195.png],此时它只是一个空壳。当我编写这个壳时,我意识到arrayString参数必须是一个引用(&) ![http://atomoreilly.com/source/nostarch/images/1273182.png],因为函数将在堆中创建一个新的数组。毕竟,使用动态内存的整个目的就是在字符串大小调整时创建一个新的数组。因此,当变量a传递给append时,它所具有的值与函数完成时它应该具有的值并不相同,因为它需要指向一个新的数组。请注意,因为我们的数组使用标准库期望的空字符终止,我们可以直接将指针a所引用的数组发送到输出流以检查其值 ![http://atomoreilly.com/source/nostarch/images/1273197.png]。
图 4-4 展示了我们对我们测试用例的新理解。数组终止符已经就位,为了清晰起见,显示为NULL。在(b)状态之后,很明显s指向了一个新的内存分配。之前的数组现在在一个阴影框中;在这些图中,我使用阴影框来表示已释放的内存。在我们的图中包括分配的内存有助于提醒我们实际执行释放操作。

图 4-4. 在 append 函数前后更新和详细展示的内存状态
在一切都被正确可视化后,我们可以编写这个函数:
void append(arrayString& s, char c) {
int oldLength = 0;
while (s[oldLength] != 0) {
oldLength++;
}
arrayString newS = new char[oldLength + 2];
for (int i = 0; i < oldLength; i++) {
newS[i] = s[i];
}
newS[oldLength] = c;
newS[oldLength + 1] = 0;
delete[] s;
s = newS;
}
这段代码中有很多事情在进行,所以让我们逐个检查。在函数开始时,我们有一个循环来定位终止我们的数组的空字符 ![http://atomoreilly.com/source/nostarch/images/1273182.png]。当循环完成后,oldLength将是数组中合法字符的数量(即不包括终止的空字符)。我们使用oldLength + 2的大小从堆中分配新数组 ![http://atomoreilly.com/source/nostarch/images/1273191.png]。这是那些如果你在脑海中全部想清楚会变得很棘手但如果你有图示就会很容易做对的一些细节。按照图 4-5 中的示例代码进行跟踪,我们可以看到在这种情况下oldLength将是四。我们知道oldLength将是四,因为 test 有四个字符,而(b)部分的新数组需要六个字符,因为我们需要为附加的字符和空终止符留出空间。
在为新数组分配空间后,我们将旧数组中的所有合法字符复制到新数组 ![http://atomoreilly.com/source/nostarch/images/1273193.png],然后我们将附加的字符 ![http://atomoreilly.com/source/nostarch/images/1273195.png] 和空字符终止符 ![http://atomoreilly.com/source/nostarch/images/1273197.png] 分配到新数组中相应的位置。再次强调,我们的图示帮助我们理清思路。为了使事情更加清晰,图 4-5 展示了oldLength的值是如何计算的,以及该值在新数组中的位置。有了这个视觉提示,我们很容易在这两个赋值语句中正确地得到下标。

图 4-5. 展示在append函数前后局部变量、参数和分配的内存之间的关系
append函数的最后三行都与图中(b)部分所示的那个阴影框有关。为了避免内存泄漏,我们必须在堆中释放我们的参数s最初指向的数组!。最后,我们让函数中的s指向新的、更长的数组!。不幸的是,C++编程中内存泄漏如此普遍的一个原因是,直到内存泄漏的总量变得很大,程序和整个系统都不会显示出任何不良影响。因此,在测试期间,程序员可能完全注意不到这些泄漏。因此,作为程序员,我们必须勤奋,并且始终考虑我们堆内存分配的寿命。每次使用关键字new时,都要考虑相应的delete将在哪里和何时发生。
注意这个函数中的所有内容是如何直接来自我们的图表的。有了好的图表,复杂的编程就会变得简单得多,我希望更多的初学者在编码之前花时间绘制图表。这回到了我们最基本的问题解决原则:始终有一个计划。对于问题示例的绘制良好的图表就像在长途驾车度假之前就已经规划好了路线。这虽然是一点点额外的努力,但可能会在最后避免更多的努力和挫败感。
创建图表
画图只需要铅笔和纸。如果你有时间的话,我建议使用绘图程序。有一些绘图工具专门针对编程问题提供模板,但任何通用的基于矢量的绘图程序都可以帮助你开始(这里的矢量意味着程序使用线条和曲线,而不是像 Photoshop 这样的画图程序)。我为这本书制作原始插图时使用了名为 Inkscape 的程序,它是免费可用的。在电脑上创建图表可以让你将它们组织在存储图表所展示代码的同一位置。如果你在一段时间后再次查看这些图表,它们也更有可能更整洁,因此更容易理解。最后,复制和修改电脑创建的图表很容易,就像我创建图 4-5 时从图 4-4 中提取的那样,如果你想要做一些快速的临时笔记,你总是可以打印出来涂鸦。
回到我们的append函数,代码看起来很稳固,但请记住,我们基于特定的样本案例编写了这段代码。因此,我们不应该过于自信地假设代码对所有有效情况都适用。特别是,我们需要检查特殊情况。在编程中,特殊情况是指有效数据会导致正常代码流程产生错误结果的情况。
注意,这个问题与不良数据(如超出范围的数据)的问题是不同的。在本书的代码中,我们假设程序和单个函数的输入数据是良好的。例如,如果程序期望得到一系列由逗号分隔的整数,我们假设程序得到的就是这些,而不是多余的字符、非数字等。这样的假设是必要的,以保持代码长度合理,并避免反复重复相同的数据检查代码。然而,在现实世界中,我们应该采取合理的预防措施来应对不良输入。这被称为鲁棒性。一个鲁棒的程序即使在不良输入的情况下也能良好地运行。例如,这样的程序可以向用户显示错误消息而不是崩溃。
检查特殊情况
让我们再次查看 append 函数,检查特殊情况——换句话说,确保在可能的良好输入值中没有任何奇怪的情况。最常见的问题通常出现在极端情况,比如最小或最大的可能输入。对于 append 函数来说,我们的字符串数组没有最大大小限制,但有一个最小大小。如果字符串没有合法字符,它实际上对应于一个只有一个字符的数组(这个字符是空终止字符)。像之前一样,让我们画一个图来保持事情清晰。假设我们将感叹号添加到一个空字符串中,如图 图 4-6 所示。

图 4-6. 测试 append 函数的最小情况
当我们查看图表时,这似乎不是一个特殊情况,但我们应该运行这个案例通过我们的函数来检查。让我们在我们的 appendTester 代码中添加以下内容:
arrayString b = new char[1];
b[0] = 0;
append(b, '!');
cout << b << "\n";
这也行得通。现在我们相当确信 append 函数是正确的,我们喜欢它吗?代码看起来很简单,我没有闻到任何“坏味道”,但它对于一个简单操作来说似乎有点长。当我展望 concatenate 函数时,我突然想到,就像 append 一样,concatenate 函数也需要确定字符串数组的大小——或者可能是两个字符串数组的大小。因为这两个操作都需要一个找到终止字符串的空字符的循环,我们可以将这段代码放入自己的函数中,然后根据需要从 append 和 concatenate 中调用它。让我们继续这样做并相应地修改 append。
int length(arrayString s) {
int count = 0;
while (s[count] != 0) {
count++;
}
return count;
}
void append(arrayString& s, char c) {
int oldLength = length(s);
arrayString newS = new char[oldLength + 2];
for (int i = 0; i < oldLength; i++) {
newS[i] = s[i];
}
newS[oldLength] = c;
newS[oldLength + 1] = 0;
delete[] s;
s = newS;
}
length 函数中的代码 ![http://atomoreilly.com/source/no_starch_images/1273182.png] 与之前开始 append 函数时使用的代码基本相同。在 append 函数本身中,我们已经用对 length 的调用替换了那段代码 ![http://atomoreilly.com/source/no_starch_images/1273191.png]。length 函数被称为 辅助函数,是一种封装了多个其他函数共同操作的函数。除了减少代码长度外,消除冗余代码意味着我们的代码更加可靠且易于修改。它还有助于我们的问题解决,因为辅助函数将我们的代码分割成更小的块,使我们更容易识别代码复用的机会。
复制动态分配的字符串
现在是时候解决那个 concatenate 函数了。我们将采取与 append 相同的方法。首先,我们将编写一个空壳版本的函数,以在我们的脑海中清晰地了解参数及其类型。然后,我们将绘制一个测试用例的图表,最后我们将编写与图表匹配的代码。以下是函数的空壳,以及额外的测试代码:
void concatenate(arrayString& s1, arrayString s2) {
}
void concatenateTester() {
arrayString a = new char[5];
a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0;
arrayString b = new char[4];
b[0] = 'b'; b[1] = 'e'; b[2] = 'd'; b[3] = 0;
concatenate(a, b);
}
记住,这个函数的描述说明第二个字符串(第二个参数)中的字符会被追加到第一个字符串的末尾。因此,concatenate 函数的第一个参数将是一个引用参数
,原因与 append 函数的第一个参数相同。然而,第二个参数
不应该被函数修改,所以它将是一个值参数。现在,对于我们的示例情况:我们正在连接字符串 test 和 bed。前后状态图显示在 图 4-7。
图表的细节应该与 append 函数的细节相似。在这里,对于 concatenate,我们开始时在堆中有两个动态分配的数组,分别由我们的两个参数 s1 和 s2 指向。当函数完成后,s1 将指向堆中的一个新数组,该数组长度为九个字符。s1 之前指向的数组已经被释放;s2 及其数组保持不变。虽然在我们试图避免编码错误时,在图表中包含 s2 和 bed 数组可能看起来没有意义,但跟踪不变的部分与跟踪变化的部分一样重要。我还对旧数组和新数组的元素进行了编号,因为在 append 函数中这很有用。现在一切就绪,让我们编写这个函数。

图 4-7. 显示连接方法的前后状态(a)和(b)
void concatenate(arrayString& s1, arrayString s2) {
int s1_OldLength = length(s1);
int s2_Length = length(s2);
int s1_NewLength = s1_OldLength + s2_Length;
arrayString newS = new char[s1_NewLength + 1];
for(int i = 0; i < s1_OldLength; i++) {
newS[i] = s1[i];
}
for(int i = 0; i < s2_Length; i++) {
newS[s1_OldLength + i] = s2[i];
}
newS[s1_NewLength] = 0;
delete[] s1;
s1 = newS;
}
首先,我们确定我们要连接的两个字符串的长度 ![http://atomoreilly.com/source/nostarch/images/1273182.png],然后我们将这些值相加,以得到我们完成时连接字符串的长度。记住,所有这些长度都是指有效字符的数量,不包括空终止符。因此,当我们创建堆中的数组来存储新的字符串 ![http://atomoreilly.com/source/nostarch/images/1273191.png] 时,我们分配一个比总长度多一个的空间来放置终止符。然后我们将两个原始字符串中的字符复制到新的字符串 ![http://atomoreilly.com/source/nostarch/images/1273193.png]。第一个循环很简单,但请注意第二个循环中的下标计算 ![http://atomoreilly.com/source/nostarch/images/1273195.png]。我们是从s2的开始复制到newS的中间;这又是将一个值范围转换为另一个值范围的另一个例子,我们自从第二章以来一直在做这件事。通过查看我的图上的元素编号,我能够看到我需要组合哪些变量来计算正确的目标下标。函数的其余部分将空终止符放置在新字符串的末尾 ![http://atomoreilly.com/source/nostarch/images/1273197.png]。与append一样,我们释放由我们的第一个参数指向的原始堆内存 ![http://atomoreilly.com/source/nostarch/images/1273199.png],并将第一个参数重新指向新分配的字符串 ![http://atomoreilly.com/source/nostarch/images/1273203.png]。
这段代码看起来是可行的,但就像之前一样,我们想要确保我们没有无意中创建了一个只在测试用例中成功但不是所有情况下都成功的函数。最可能的问题情况是当其中一个或两个参数是零长度字符串(仅包含空终止符)时。在继续之前,我们应该明确检查这些情况。请注意,当你检查使用指针的代码的正确性时,你应该注意查看指针本身,而不仅仅是它们引用的堆中的值。以下是一个测试用例:
arrayString a = new char[5];
a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0;
arrayString c = new char[1];
c[0] = 0;
concatenate(c, a);
cout << a << "\n" << c << "\n";
cout << (void *) a << "\n" << (void *) c << "\n";
我想确保concatenate的调用结果使得a和c都指向字符串test——也就是说,它们指向具有相同值的数组。同样重要的是,它们指向的是不同的字符串,如图图 4-8,而不是两个交叉链接的指针(b)")(a)所示。我在第二个输出语句中通过将变量的类型更改为void *来检查这一点,这迫使输出流显示指针的原始值
。如果指针本身具有相同的值,那么我们就会说指针已经交叉链接,如图图 4-8,而不是两个交叉链接的指针(b)")(b)所示。当指针无意中交叉链接时,会引发微妙的问题,因为改变堆中一个变量的内容神秘地改变了另一个变量——实际上它是同一个变量,但在大型程序中,这可能很难发现。此外,记住如果两个指针交叉链接,当其中一个通过delete释放时,剩余的指针就变成了悬空引用。因此,我们在审查代码时必须非常小心,并始终检查潜在的交叉链接。

图 4-8. concatenate应产生两个不同的字符串(a),而不是两个交叉链接的指针(b)。
实现了所有三个函数——charAt、append和concatenate——我们就完成了这个问题。
链表
现在我们将尝试一些更复杂的事情。指针操作将会更复杂,但现在我们已经知道如何绘制图表,所以我们会保持一切井然有序。
问题:跟踪未知数量的学生记录
在这个问题中,你需要编写函数来存储和操作学生记录的集合。学生记录包含一个学生编号和一个成绩,都是整数。以下函数需要实现:
addRecord
此函数接受一个指向学生记录集合(学生编号和成绩)的指针,并将包含此数据的新的记录添加到集合中。
averageRecord
此函数接受一个指向学生记录集合的指针,并返回集合中学生成绩的简单平均值,作为double类型。
集合可以是任何大小。预期addRecord操作会被频繁调用,因此它必须高效实现。
有多种方法可以满足这些规范,但我们将选择一种帮助我们练习基于指针的问题解决技巧的方法:链表。你可能已经见过链表,如果没有,请知道链表的引入代表了从我们在本文中迄今为止讨论的内容的一个重大转变。一个优秀的解决问题者,如果给予足够的时间和仔细的思考,可能会开发出任何前述解决方案。然而,大多数程序员在没有帮助的情况下,可能不会想到链表的概念。一旦你看到了它并掌握了基础知识,其他链式结构就会浮现在脑海中,然后你就可以开始行动了。链表确实是一种动态结构。我们的字符串数组存储在动态分配的内存中,但一旦创建,它们就是静态结构,永远不会变大或变小,只是被替换。相比之下,链表随着时间的推移逐渐增长,就像一串花环。
构建节点列表
让我们构建一个学生记录的示例链表。为了创建链表,你需要一个struct,它包含指向相同struct的指针,以及你想要存储在链表表示的集合中的任何数据。对于我们的问题,struct将包含学生编号和成绩。
struct listNode {
int studentNum;
int grade;
listNode * next;
};
typedef listNode * studentCollection;
我们的struct名称是listNode
。用于创建链表的struct始终被称为节点。这个名字可能是对植物学术语的类比,意味着茎上的一个点,从这里长出新枝。节点包含学生编号
和成绩,这些构成了节点的真实“有效负载”。节点还包含一个指向我们正在定义的struct类型的指针
。大多数程序员第一次看到这个时,可能会觉得困惑,甚至可能是语法上的不可能:我们如何用自身来定义一个结构?但这在语法上是合法的,其含义很快就会变得清晰。请注意,节点中的自引用指针通常被命名为next、nextPtr或类似名称。最后,此代码为我们的节点类型声明了一个typedef
。这将有助于提高我们函数的可读性。现在让我们使用这些类型构建我们的示例链表:
studentCollection sc;
listNode * node1 = new listNode;
node1->studentNum = 1001; node1->grade = 78;
listNode * node2 = new listNode;
node2->studentNum = 1012; node2->grade = 93;
listNode * node3 = new listNode;
node3->studentNum = 1076; node3->grade = 85;
sc = node1;
node1->next = node2;
node2->next = node3;
node3->next = NULL;
node1 = node2 = node3 = NULL;
我们首先声明一个studentCollection,即sc
,这最终将成为我们链表的名字。然后我们声明node1
,一个指向listNode的指针。同样,studentCollection与node *同义,但为了可读性,我只使用studentCollection类型来表示将引用整个结构体的变量。在声明node1并将其指向堆中新分配的listNode
之后,我们给该节点的studentNum和grade字段赋值
。此时,next字段尚未分配。这不是一本关于语法的书,但如果您之前没有见过->符号,它用于表示指向的struct(或class)的字段。所以node1->studentNum表示“由node1指向的struct中的studentNum字段”,等同于(*node1).studentNum。然后我们对node2和node3重复相同的步骤。在将字段值赋给最后一个节点后,内存的状态如图图 4-9 所示。在这些图中,我们将使用之前用于数组的分割框符号来表示节点struct。

图 4-9. 构建示例链表的一半
现在我们已经拥有了所有的节点,我们可以将它们串联起来形成一个链表。这正是之前代码列表中剩余部分所做的事情。首先,我们将我们的studentCollection变量指向第一个节点
,然后我们将第一个节点的next字段指向第二个节点
,接着我们将第二个节点的next字段指向第三个节点
。在下一步中,我们将第三个节点的next字段赋值为NULL(再次强调,这只是一个零的同义词)
。我们这样做的原因和我们在上一个问题中在数组末尾放置空字符的原因相同:为了终止结构。正如我们需要一个特殊字符来显示数组的结束一样,我们也在链表最后一个节点的next字段中需要一个零,这样我们就能知道它确实是最后一个节点。最后,为了清理并避免潜在的交叉链接问题,我们将每个单独的节点指针赋值为NULL
。内存的最终状态如图图 4-10 所示。

图 4-10. 完成的示例链表
在我们面前有这个视觉图,很明显为什么这个结构被称为链表:列表中的每个节点都链接到下一个。你经常会看到链表被线性绘制,但我实际上更喜欢这个图在内存中分散的外观,因为它强调这些节点之间除了链接外没有其他关系;它们中的每一个都可以在堆的任何地方。确保你跟踪代码,直到你确信你同意这个图。
注意,在最终状态中,只有一个基于栈的指针仍在使用,即我们的studentCollection变量sc,它指向第一个节点。指向链表中第一个节点的外部指针(即不是链表中节点的next字段)被称为头指针。在符号层面上,这个变量代表整个列表,但当然它直接引用的只是第一个节点。要到达第二个节点,我们必须通过第一个节点,要到达第三个节点,我们必须通过前两个节点,依此类推。这意味着链表只能提供顺序访问,而不是数组提供的随机访问。顺序访问是链表结构的弱点。正如之前提到的,链表结构的优势在于,我们可以通过添加或删除节点来增长或缩小结构的大小,而无需创建一个全新的结构并复制数据,就像我们处理数组那样。
向链表中添加节点
现在我们来实现addRecord函数。这个函数将创建一个新的节点并将其连接到现有的链表中。我们将使用之前问题中使用的相同技术。首先:一个函数外壳和一个示例调用。为了测试,我们将代码添加到上一个列表中,因此sc已经存在,作为指向三个节点列表的头指针。
void addRecord(studentCollection& sc, int stuNum, int gr) {
}
addRecord(sc, 1274, 91);
再次,
的调用将在上一个列表的末尾进行。使用函数 shell 定义参数,我们可以绘制这个调用的“之前”状态,如图图 4-11 所示。

图 4-11. addRecord 函数的“之前”状态
然而,关于“之后”的状态,我们有一个选择。我们可以猜测我们将在堆中创建一个新节点,并将参数stuNum和gr的值复制到新节点的studentNum和grade字段中。问题是这个节点在逻辑上将在我们的链表中去哪里。最明显的选择是在末尾;next字段中的NULL值正等着指向一个新节点。这对应于图 4-12。

图 4-12. 为addRecord函数提出的“之后”状态
但如果我们假设记录的顺序不重要(即我们不需要按照记录添加到集合中的顺序来保留记录),那么这就不对了。为了理解原因,考虑一个包含 3,000 条学生记录的集合,而不是 3 条。为了按顺序到达链表的最后一个记录以修改其next字段,需要遍历所有 3,000 个节点。这是不可接受的低效,因为我们可以在不遍历任何现有节点的情况下将新节点放入列表中。
图 4-13 展示了如何实现。新节点创建后,它被链接到列表的开头,而不是末尾。在“之后”状态中,我们的头指针sc指向新节点,而新节点的next字段指向列表中之前的第一节点,即学生编号为 1001 的那个节点。请注意,虽然我们为新节点的next字段赋值,但唯一改变的现有指针是sc,且现有节点中的任何值都没有改变或被检查。根据我们的图,以下是代码:
void addRecord(studentCollection& sc, int stuNum, int gr) {
listNode * newNode = new listNode;
newNode->studentNum = stuNum;
newNode->grade = gr;
newNode->next = sc;
sc = newNode;
}

图 4-13. 为addRecord函数提出的可接受的“之后”状态。虚线箭头表示存储在sc中的指针的先前值。
再次强调,翻译图表和代码要比试图在脑海中保持事物清晰容易得多。代码直接来自插图。我们创建一个新节点并从参数中分配学生编号和成绩。然后我们将新节点链接到列表中,首先通过将新节点的next字段指向前一个第一个节点(通过分配给它sc的值),然后通过将sc本身指向新节点。注意,最后两个步骤必须按那个顺序发生;在我们改变它之前,我们需要使用sc的原始值。还要注意,因为我们改变了sc,它必须是一个引用参数。
总是如此,当我们从一个示例案例构建代码时,我们必须检查潜在的特殊情况。这里,这意味着检查函数是否能够与空列表一起工作。在我们的字符串数组中,空字符串仍然是一个有效的指针,因为我们仍然有一个数组可以指向,一个只包含空终止字符的数组。然而,这里的节点数量与记录数量相同,一个空列表将是一个NULL头指针。如果我们尝试在传入的头指针为NULL时插入示例数据,我们的代码还能保持有效吗?图 4-14 显示了“之前”状态和期望的“之后”状态。
将这个示例通过我们的代码走一遍,我们看到它很好地处理了这个情况。新节点就像之前一样被创建。因为“之前”状态下sc是NULL,当被复制到我们新节点的next字段时,这正是我们想要的,并且我们的单节点列表被正确终止。注意,如果我们继续使用其他实现想法——在链表末尾而不是在开头添加新节点——一个最初为空的列表将会是一个特殊情况,因为那时它将是唯一一个sc被修改的情况。

图 4-14. 最小addRecord案例的“之前”和“之后”状态
列表遍历
现在是时候弄清楚averageRecord函数了。和之前一样,我们将从一个 shell 和一个图表开始。这是函数 shell 和示例调用。假设示例调用发生在我们原始示例列表创建之后,如图图 4-10 所示。
double averageRecord(studentCollection sc) {
}
int avg = averageRecord(sc);
如您所见,我选择将平均值计算为一个int,就像我们在上一章处理数组时做的那样。然而,根据问题的不同,可能最好将其计算为一个浮点数。现在我们需要一个图表,但我们几乎已经有了“之前”的状态,即图 4-9。我们不需要“之后”状态的图表,因为这个函数不会改变我们的动态结构,只是报告它。我们只需要知道预期的结果,在这种情况下大约是 85.3333。
我们实际上是如何计算平均值的呢?从我们计算数组中所有值平均值的经验中,我们知道一般概念。我们需要将集合中的每个值加起来,然后将这个总和除以值的数量。使用我们的数组平均值代码,我们使用for循环从 0 到数组大小减一来检查每个值,使用循环计数器作为数组索引。我们在这里不能使用for循环,因为我们事先不知道链表中有多少数字;我们必须继续进行,直到我们到达节点next字段中的NULL值,这表示列表结束。这表明我们需要一个while循环,类似于我们在本章早期用于处理未知长度数组的循环。以这种方式遍历链表,从开始到结束,被称为链表遍历。这是链表上的基本操作之一。让我们将遍历的想法付诸实践来解决这个难题:
double averageRecord(studentCollection sc) {
int count = 0;
double sum = 0;
listNode * loopPtr = sc;
while (loopPtr != NULL) {
sum += loopPtr->grade;
count++;
loopPtr = loopPtr->next;
}
double average = sum / count;
return average;
}
我们首先声明一个变量count来存储我们在列表中遇到的节点数量
;这也会是集合中的值数,我们将用它来计算平均值。接下来,我们声明一个变量sum来存储列表中成绩值的累计总和 ![http://atomoreilly.com/source/no_starch_images/1273191.png]。然后我们声明一个名为listNode *的变量loopPtr,我们将用它来遍历列表 ![http://atomoreilly.com/source/no_starch_images/1273193.png]。这相当于数组处理for循环中的整数循环变量;它跟踪我们在链表中的位置,不是通过位置数字,而是通过存储我们正在处理的节点的指针。
在这一点上,遍历本身开始。遍历循环会一直持续到我们的遍历跟踪指针达到终止的NULL
。在循环内部,我们将当前引用的节点中grade字段的值添加到sum
。我们增加count
,然后我们将当前节点的next字段赋值给我们的遍历跟踪指针
。这相当于将我们的遍历移动到下一个节点。这是代码中的难点部分,所以让我们确保这一点是正确的。在图 4-15 中,我展示了节点变量随时间的变化。字母(a)到(d)标记了代码在样本数据上执行的不同点,显示了loopPtr的生命周期中的不同点和loopPtr值被获取的位置。点(a)是循环开始时;loopPtr刚刚被初始化为sc的值。因此,loopPtr指向列表中的第一个节点,就像sc一样。在循环的第一次迭代中,第一个节点的成绩值78被添加到sum。第一个节点的next值被复制到loopPtr,因此现在loopPtr指向列表的第二个节点;这是点(b)。在第二次迭代中,我们将93添加到sum,并将第二个节点的next字段复制到loopPtr;这是点(c)。最后,在第三次也是最后一次迭代中,我们将85添加到sum,并将第三个节点的next字段的NULL赋值给loopPtr;这是点(d)。当我们再次到达while循环的顶部时,循环结束,因为loopPtr是NULL。因为我们每次迭代都会增加count,所以count是三。

图 4-15. 在averageRecord函数中循环迭代期间局部变量loopPtr如何变化
一旦循环全部完成,我们只需将总和除以计数并返回结果
。
代码在我们的样本案例中是有效的,但就像往常一样,我们需要检查潜在的特殊情况。再次强调,对于列表来说,最明显的特殊情况是空列表。如果函数开始时sc是NULL,我们的代码会发生什么?
猜猜看?代码崩溃了。(我不得不让其中一个特殊情况结果不好;否则,你们就不会认真对待我了。)对于处理链表本身的循环没有问题。如果sc是NULL,则loopPtr初始化为NULL,循环一开始就结束,sum保持在零,这似乎是合理的。问题是当我们执行计算平均值的除法操作时,count也是零,这意味着我们在除以零,这将导致程序崩溃或产生垃圾结果。为了处理这个特殊情况,我们可以在函数末尾检查count是否为零,但为什么不一开始就处理这种情况并检查sc呢?让我们在averageRecord函数的新第一行添加以下内容:
if (sc == NULL) return 0;
正如这个例子所示,处理特殊情况通常很简单。我们只需要确保我们花时间来识别它们。
结论和下一步
本章只是刚刚触及使用指针和动态内存进行问题解决的一角。你将在本书的其余部分看到指针和堆分配。例如,面向对象编程技术,我们将在第五章中讨论,在处理指针时特别有用。它们允许我们将指针封装起来,这样我们就不必担心内存泄漏、悬垂指针或任何其他常见的指针陷阱。
尽管在这个领域还有更多关于问题解决要学习的内容,但如果你遵循本章的基本思想,你将能够通过指针结构的递增复杂性来发展你的技能:首先,应用问题解决的一般规则。然后,应用指针的特定规则,并在开始编码之前使用图表或类似工具来可视化每个解决方案。
练习
我不是在开玩笑,关于做练习。你不是只是阅读章节然后继续前进,对吧?
-
设计你自己的:取一个你已经知道如何使用数组解决的问题,但受限于数组大小。将代码重写为使用动态分配的数组来移除这种限制。
-
对于我们动态分配的字符串,创建一个名为
substring的函数,该函数接受三个参数:一个arrayString,一个起始位置整数,以及一个字符长度的整数。该函数返回一个指向新动态分配的字符串数组的指针。这个字符串数组包含原始字符串中的字符,从指定的位置开始,长度为指定的长度。原始字符串不受此操作的影响。所以如果原始字符串是abcdefg,位置是 3,长度是 4,那么新字符串将包含cdef。 -
对于我们的动态分配的字符串,创建一个名为
replaceString的函数,该函数接受三个参数,每个参数的类型为arrayString:source、target和replaceText。该函数将source中target的每个出现替换为replaceText。例如,如果source指向一个包含abcdabee的数组,target指向ab,而replaceText指向xyz,那么当函数结束时,source应该指向一个包含xyzcdxyzee的数组。 -
修改我们字符串的实现,使得数组中的
location[0]存储数组的大小(因此location[1]存储字符串中的第一个实际字符),而不是使用空字符终止符。实现每个三个函数,append、concatenate和charactertAt,尽可能利用存储的大小信息。因为我们不再使用标准输出流期望的空终止符约定,你需要编写自己的output函数,该函数遍历其字符串参数,显示字符。 -
编写一个名为
removeRecord的函数,该函数接受一个指向studentCollection的指针和一个学生编号,并从集合中删除具有该学生编号的记录。 -
让我们为字符串创建一个实现,它使用字符链表而不是动态分配的数组。因此,我们将有一个链表,其数据负载是单个字符;这将允许字符串在不重新创建整个字符串的情况下增长。我们将从实现
append和characterAt函数开始。 -
在上一个练习的基础上,实现
concatenate函数。注意,如果我们调用concatenate(s1, s2),其中两个参数都是它们各自链表第一个节点的指针,该函数应该创建s2中每个节点的副本并将它们追加到s1的末尾。也就是说,该函数不应该简单地将s1链表中最后一个节点的next字段指向s2链表中的第一个节点。 -
向链表字符串实现添加一个名为
removeChars的函数,用于根据位置和长度从字符串中删除一段字符。例如,removeChars(s1, 5, 3)将删除从第五个字符开始的三个字符。确保已正确释放被删除节点的内存。 -
想象一个链表,其中节点存储的不是字符,而是数字:一个范围在 0 到 9 之间的
int。我们可以使用这样的链表来表示任何大小的正数;例如,数字 149 将是一个链表,其中第一个节点存储一个 1,第二个节点存储一个 4,最后一个节点存储一个 9。编写一个名为intToList的函数,该函数接受一个整数值并生成这种类型的链表。提示:你可能发现从后向前构建链表更容易,所以如果值是 149,你首先创建 9 节点。 -
对于前一个练习的数字列表,编写一个函数,该函数接受两个这样的列表并生成一个表示它们和的新列表。
第五章:用类解决问题

在本章中,我们将讨论类和面向对象编程。和之前一样,假设你已经看到了 C++ 中的 class 声明并理解了创建类、调用类的方法等基本语法。我们将在下一节进行快速回顾,但我们将主要讨论类的问题解决方面。
这又是一个我认为 C++ 比其他语言有优势的情况。因为 C++ 是一种混合语言,C++ 程序员可以在适当的时候创建类,但不必这样做。相比之下,在 Java 或 C# 这样的语言中,所有代码都必须出现在类声明的范围内。在专家程序员的手中,这不会造成不必要的伤害,但在新手程序员的手中,可能会导致不良习惯。对于 Java 或 C# 程序员来说,一切都是对象。虽然这些语言编写的所有代码都必须封装到对象中,但结果并不总是反映合理的面向对象设计。一个对象应该是一个有意义的、紧密相关的数据集合和代码集合,该代码操作这些数据。它不应该是任意的一堆剩余物。
因为我们在使用 C++ 编程,因此可以选择过程式编程和面向对象编程,所以我们将讨论良好的类设计,以及何时应该以及不应该使用类。识别一个类将是有用的场景对于达到更高层次的编程风格至关重要,但同样重要的是识别那些将使事情变得更糟的场景。
类基础回顾
如同往常,这本书假设你已经接触过基础知识以及 C++ 语法的相关参考,但让我们回顾一下类语法的 fundamentals,以便我们在术语上保持一致。一个 类 是构建特定代码和数据包的蓝图;根据类的蓝图创建的每个变量都被称为该类的 对象。在类外部创建并使用该类对象的代码被称为该类的 客户端。一个 类声明 命名了类并列举了该类中的所有 成员,或内部的项目。每个项目要么是一个 数据成员——在类内部声明的变量——要么是一个 方法(也称为 成员函数),它是在类内部声明的函数。成员函数可以包括一个特殊类型,称为 构造函数,它与类的名称相同,并在声明类对象时隐式调用。除了变量或函数声明的正常属性(如类型,对于函数,参数列表)之外,每个成员还有一个 访问修饰符,它表示哪些函数可以访问该成员。一个 公开成员 可以被任何使用该对象的代码访问:类内部的代码、类的客户端或 子类 中的代码,子类是“继承”现有类所有代码和数据的类。一个 私有成员 只能被类内部的代码访问。受保护成员,我们将在本章中简要介绍,与私有成员类似,但子类中的方法也可以引用它们。然而,私有和受保护成员都无法从客户端代码中访问。
与返回类型等属性不同,类声明内部的访问修饰符一直保持,直到被不同的修饰符替换。因此,每个修饰符通常只出现一次,成员根据访问分组。这导致程序员将类的“公开部分”或“私有部分”称为,“我们应该把这个方法放在私有部分。”
让我们看看一个微小的示例类声明:
class sample {
public:
sample();
sample(int num);
int doesSomething(double param);
private:
int intData;
};
这个声明首先命名了类 ![http://atomoreilly.com/source/no_starch/images/1273182.png],因此之后 sample 就变成了一个类型名。声明以一个 public 访问修饰符 ![http://atomoreilly.com/source/no_starch/images/1273191.png] 开始,所以直到我们遇到 private 修饰符 ![http://atomoreilly.com/source/no_starch/images/1273199.png],之后的所有内容都是公开的。许多程序员首先包含公开声明,期望公开接口对其他读者最有兴趣。这里的公开声明有两个构造函数 ![http://atomoreilly.com/source/no_starch/images/1273193.png] 和 ![http://atomoreilly.com/source/no_starch/images/1273195.png],分别命名为 sample 和另一个方法,doesSomething ![http://atomoreilly.com/source/no_starch/images/1273197.png]。当声明这个类的对象时,构造函数会被隐式调用。
sample object1;
sample object2(15);
在这里,object1将调用第一个构造函数 ![http://atomoreilly.com/source/no_starch_images/1273193.png],称为默认构造函数,它没有参数,而object2将调用第二个构造函数 ![http://atomoreilly.com/source/no_starch_images/1273195.png],因为它指定了一个单个整数值,因此与第二个构造函数的参数签名相匹配。
声明以一个私有数据成员intData结束 ![http://atomoreilly.com/source/no_starch_images/1273199.png]。请记住,一个类声明以一个闭合花括号和一个分号结束 ![http://atomoreilly.com/source/no_starch_images/1273203.png]。这个分号可能看起来有点神秘,因为我们不使用分号来结束函数、if语句块或任何其他闭合花括号。实际上,分号的存在实际上表明类声明也可以选择性地作为对象声明;我们可以在闭合花括号和分号之间放置标识符,并像创建类一样创建对象。然而,在 C++中,这并不常见,尤其是考虑到许多程序员将它们的类定义放在使用它们的程序之外单独的文件中。这个神秘的分号也出现在struct的闭合花括号之后。
说到struct,你应该知道在 C++中,struct和class表示几乎相同的东西。这两个之间的唯一区别在于在第一个访问修饰符之前声明的成员(数据或方法)。在struct中,这些成员将是公共的,而在class中,它们将是私有的。然而,优秀的程序员使用这两种结构的方式不同。这类似于任何for循环都可以写成while循环,但优秀的程序员可以通过在更直接的计数循环中使用for循环来使代码更易于阅读。大多数程序员将struct保留用于更简单的结构,要么是除了构造函数之外没有数据成员的结构,要么是打算用作较大类的方法参数的结构。
类使用的目标
为了识别类使用的正确和错误情况以及构建类的正确和错误方式,我们首先必须决定我们使用类时的目标是什么。在考虑这一点时,我们应该记住,类始终是可选的。也就是说,类不会像数组或基于指针的结构那样给我们带来新的能力。如果你有一个使用数组对 10,000 条记录进行排序的程序,没有数组就无法编写相同的程序。如果你有一个程序依赖于链表随时间增长和缩小的能力,没有使用链表或类似的基于指针的结构,你就无法以相同的效率创建相同的效果。然而,如果你从一个面向对象的程序中移除类并重新编写它,程序的外观将不同,但程序的能力和效率不会降低。实际上,早期的 C++编译器作为预处理器工作。C++编译器会读取 C++源代码,并即时输出合法的 C 语法的新源代码。然后,修改后的源代码将被发送到 C 编译器。这告诉我们,C++对 C 语言的主要补充并不是关于语言的函数能力,而是关于源代码对程序员的可读性。
因此,在选择我们的通用类设计目标时,我们是在选择帮助程序员完成任务的目标。特别是,因为这本书是关于解决问题的,我们应该考虑类如何帮助我们解决问题。
封装
“封装”这个词是一种说法,意思是类将多个数据和代码组合成一个单一的包。如果你曾经见过装满小药丸的明胶药丸,那是一个很好的类比:病人服用一颗胶囊,吞下里面所有的单个成分药丸。
封装是允许我们列出的许多其他目标成功的机制,但它本身也是一个好处,因为它组织了我们的代码。在一个纯过程式代码的长程序列表中(在 C++中,这意味着有函数但没有类的代码),找到一种良好的函数和编译器指令顺序以使我们能够轻松记住它们的位置可能很困难。相反,我们被迫依赖我们的开发环境来为我们找到函数。封装将相关的东西放在一起。如果你正在编写一个类方法,并且意识到你需要查看或修改其他代码,那么其他代码很可能出现在同一类的另一个方法中,因此它们就在附近。
代码复用
从解决问题的角度来看,封装使我们能够更容易地重用之前问题的代码来解决当前问题。通常,即使我们处理过与当前项目类似的问题,重用之前学到的知识仍然需要大量工作。一个完全封装的类可以像外部 USB 驱动器一样工作;你只需插入它就能使用。然而,为了实现这一点,我们必须正确设计类,确保代码和数据真正封装,并且尽可能独立于类外部的任何事物。例如,引用全局变量的类不能在不复制全局变量的情况下复制到新项目中。
除了将一个程序中的类重用到下一个程序之外,类还提供了代码重用的更直接形式:继承。回想一下,在第四章中,我们讨论了使用辅助函数来“提取”两个或多个函数中共同代码的方法。继承将这个想法扩展到更大的规模。使用继承,我们创建具有两个或多个子类共同方法的父类,从而“提取”不仅是一行代码,而且是整个方法。继承是一个很大的主题,我们将在本章后面探讨这种代码重用形式。
分解问题
我们一次又一次回归的一种技术是将复杂问题分解成更小、更易于管理的部分。类在将程序分解成功能单元方面做得很好。封装不仅将数据和代码捆绑在一起形成一个可重用的包;它还隔离了这些数据和代码,使其与程序的其他部分隔离开来,从而允许我们单独对这个类以及所有其他部分进行工作。在一个程序中创建的类越多,问题分解的效果就越大。
因此,在可能的情况下,我们应该让类成为我们分解复杂问题的方法。如果类设计得很好,这将强制执行功能分离,问题将更容易解决。作为次要影响,我们可能会发现,为一个问题创建的类可以在其他问题中重用,即使在我们创建它们时没有完全考虑到这种可能性。
信息隐藏
有些人将术语信息隐藏和封装互换使用,但在这里我们将区分这两个概念。正如本章之前所述,封装是将数据和代码打包在一起。信息隐藏意味着将数据结构的接口——操作的定义及其参数——与数据结构的实现或函数内部的代码分离。如果一个类是以信息隐藏为目标编写的,那么可以更改方法的实现,而无需对客户端代码(使用该类的代码)进行任何更改。再次强调,我们必须清楚理解术语接口;这不仅仅是指方法的名称及其参数列表,还包括对不同方法功能的说明(可能以代码文档的形式表达)。当我们谈论在不改变接口的情况下更改实现时,我们的意思是改变类方法的工作方式,而不是它们的功能。一些编程作者将这种做法称为类与客户端之间的一种隐式合同:类同意永远不改变现有操作的效果,而客户端同意严格根据其接口使用类,并忽略任何实现细节。想象一下,有一个通用的遥控器可以控制任何电视,无论是老式的显像管模型还是使用液晶或等离子屏幕的电视。你按 2,然后按 5,然后按 Enter,任何屏幕都会显示频道 25,尽管实现这一功能的技术机制大相径庭。
没有封装就无法实现信息隐藏,但根据我们对这些术语的定义,可以在没有信息隐藏的情况下实现封装。这种情况最明显的方式是,如果一个类的数据成员被声明为public。在这种情况下,该类仍然是一个封装,因为它是一组属于一起的代码和数据。然而,客户端代码现在可以访问一个重要的类实现细节:类用来存储其数据的变量和类型。即使客户端代码没有直接修改类数据,只是检查它,客户端代码也需要特定的类实现。任何更改类名或类型,从而更改客户端代码访问的任何变量的实现,都需要对客户端代码进行更改。
你的第一个想法可能是,只要所有数据都被设置为私有,并且我们花费足够的时间设计成员函数及其参数列表,确保它们永远不会改变,信息隐藏就能得到保证。虽然所有这些对于信息隐藏都是必要的,但它们并不充分,因为信息隐藏的问题可能更加微妙。记住,类是同意在任何情况下都不改变任何方法的行为。在之前的章节中,我们不得不决定函数将处理的最小情况或者如何处理异常情况,比如当存储数组大小的参数为零时,如何计算数组的平均值。即使是对于异常情况,改变方法的结果也代表了对接口的改变,应该避免。这就是为什么在编程中明确考虑特殊情况如此重要的另一个原因。许多程序在底层技术或应用程序编程接口(API)更新时崩溃,一些曾经可靠地返回-1 作为错误代码的系统调用现在返回一个看似随机的、但仍然是负数的数字。避免这种问题的最好方法之一是在类或方法文档中声明特殊情况的结果。如果你的文档说明在某种情况下返回-1 错误代码,你将三思而后行,不会让方法返回其他任何内容。
那么信息隐藏是如何影响问题解决的?信息隐藏的原则告诉程序员在编写客户端代码时忽略类的实现细节,或者更广泛地说,只有在类内部工作时才关注特定类的实现。当你能够将实现细节置之脑后,你就可以消除干扰思绪,专注于解决手头的难题。
然而,我们应该意识到信息隐藏在问题解决方面的局限性。有时实现细节对客户端来说很重要。在之前的章节中,我们看到了基于数组和基于指针的数据结构的优缺点。基于数组的结构允许随机访问,但不能轻易地增长或缩小,而基于指针的结构只提供顺序访问,但可以在不重新创建整个结构的情况下添加或删除部分。因此,以基于数组的结构为基础构建的类将具有与基于指针的结构不同的特性。
在计算机科学中,我们经常讨论抽象数据类型的概念,这是信息隐藏的纯粹形式:仅通过其操作定义的数据类型。在第四章中,我们讨论了栈的概念,并描述了程序栈是一个连续的内存块。但作为一个抽象数据类型,栈是任何可以添加和删除单个项目的数据类型,并且项目以相反的顺序被移除。这被称为后进先出(LIFO)顺序。没有任何东西要求栈必须是连续的内存块,我们可以使用链表来制作栈。因为连续的内存块和链表具有不同的属性,所以使用一种实现或另一种实现的栈也将具有不同的属性,这可能会对使用栈的客户产生重大影响。
所有这些的要点是,信息隐藏对于作为问题解决者来说将是一个有用的目标,只要它允许我们将问题分解并分别处理程序的不同部分。然而,我们却不能完全忽略实现细节。
可读性
一个好的类可以增强其出现程序的可读性。对象可以对应于我们看待现实世界的方式,因此方法调用通常具有类似英语的可读性。此外,对象之间的关系通常比简单变量之间的关系更清晰。提高可读性可以增强我们解决问题的能力,因为我们可以在开发过程中更容易地理解自己的代码,而且当旧代码易于遵循时,重用也得到了增强。
为了最大限度地提高类的可读性效益,我们需要考虑我们的类方法在实际应用中的使用方式。方法名称应谨慎选择,以反映方法效果的最具体含义。例如,考虑一个表示金融投资的类,其中包含一个计算未来价值的方法。名称compute并不像computeFutureValue那样传达很多信息。甚至选择正确的词性对于名称也是有帮助的。名称computeFutureValue是一个动词,而futureValue是一个名词。看看以下代码示例中名称的使用方式:
double FV;
investment.computeFutureValue(FV, 2050);
if (investment.futureValue(2050) > 10000) { ...
如果你仔细想想,前者对于独立调用的调用更有意义,也就是说,一个将未来值通过引用参数发送回调用者的void函数!。后者对于在表达式中使用的调用更有意义,也就是说,未来值作为函数的值返回!。
我们将在本章后面看到具体的例子,但最大化可读性的指导原则是,在编写类的任何部分接口时,始终考虑客户端代码。
表达性
一个设计良好的课程的最终目标是表达性,或者说更广泛地称为可写性——代码编写 ease 的便利性。一个编写良好的类,一旦编写完成,就会使其余的代码编写变得更加简单,就像一个编写良好的函数可以使代码编写更加简单一样。类有效地扩展了语言,成为基本低级特性(如循环、if 语句等)的高级对应物。在 C++ 中,即使是输入输出这样的核心功能也不是语言语法的固有部分,而是作为一组必须显式包含在使用的程序中的类提供的。有了类,原本需要许多步骤才能完成的编程操作现在只需几步或一步即可完成。作为问题解决者,我们应该将这个目标作为一个特殊的优先事项。我们应该始终思考,“这个类将如何使这个程序以及可能使用这个类的未来程序更容易编写?”
构建简单类
现在我们知道了我们的类应该追求的目标,是时候将理论付诸实践并构建一些类了。首先,我们将分阶段开发我们的类,用于以下问题。
问题:班级名单
设计一个或一组类,用于在维护班级名单的程序中。对于每个学生,存储学生的姓名、ID 和最终成绩分数(范围 0–100)。程序将允许添加或删除学生记录;显示特定学生的记录,通过 ID 识别,成绩以数字和字母形式显示;并显示班级的平均分。特定分数的适当字母等级显示在 表 5-1 中。
表 5-1. 字母等级
| 分数范围 | 字母等级 |
|---|---|
| 93–100 | A |
| 90–92 | A– |
| 87–89 | B+ |
| 83–86 | B |
| 80–82 | B– |
| 77–79 | C+ |
| 73–76 | C |
| 70–72 | C– |
| 67–69 | D+ |
| 60–66 | D |
| 0–59 | F |
我们将首先查看一个基本的类框架,它是大多数类的基础。然后我们将查看扩展基本框架的方法。
基本类框架
探索基本类框架的最佳方式是通过一个示例类。为此示例,我们将从 第三章 中的学生 struct 开始,将其构建成一个完整的类。为了便于参考,以下是原始的 struct:
struct student {
int grade;
int studentID;
string name;
};
即使是这种形式的简单struct,我们至少得到了封装。记住,在第三章中,我们使用这个struct构建了一个学生数据数组,如果没有使用struct,我们就必须构建三个并行数组,每个数组分别用于成绩、ID 和姓名——这太丑了!然而,我们通过这个struct肯定得不到的是信息隐藏。基本类框架通过将所有数据声明为私有,然后添加公共方法来允许客户端代码间接访问或更改这些数据,从而实现了信息隐藏。
class studentRecord {
public:
studentRecord();
studentRecord(int newGrade, int newID, string newName);
int grade();
void setGrade(int newGrade);
int studentID();
void setStudentID(int newID);
string name();
void setName(string newName);
private:
int _grade;
int _studentID;
string _name;
};
正如承诺的那样,这个类声明被分为一个公共部分,包含成员函数
和一个私有部分
,其中包含与原始struct
相同的数据。这里有八个成员函数:两个构造函数
和每个数据成员的一对成员函数。例如,_grade数据成员有两个相关的成员函数,grade
和 setGrade
。这些方法中的第一个将由客户端代码用于检索特定studentRecord的成绩,而第二个方法用于为这个特定的studentRecord存储一个新的成绩。
与数据成员相关联的检索和存储方法如此常见,以至于通常用简写术语获取和设置来指代。正如你所见,我将单词设置纳入了将新值存储到数据成员的方法中。许多程序员也会将获取纳入其他名称中,例如,getGrade而不是grade。为什么我没有这样做呢?因为那样的话,我就将动词名称用于一个作为名词使用的函数。然而,有些人可能会争辩说,获取这个术语如此普遍为人所理解,其含义因此非常明确,其使用可以超越其他关注点。最终,这还是一个个人风格的问题。
尽管在这本书中我迅速指出了 C++相较于其他语言的优点,但我必须承认,在获取和设置方法方面,较新的语言,如 C#,已经超越了 C++。C#有一个内置机制,称为属性,它既充当获取也充当设置方法。一旦定义,客户端代码可以像访问数据成员一样访问属性,而不是函数调用。这对可读性和表达性是一个很大的提升。在 C++中,如果没有内置机制,我们决定一些方法命名约定并一致使用就很重要。
注意,我的命名约定也扩展到了数据成员,它们与原始的 struct 不同,所有都以下划线开头。这使我能够用与它们检索的数据成员(几乎)相同的名称来命名 get 函数。这也使得在代码中识别数据成员引用变得容易,从而增强了可读性。一些程序员使用关键字 this 来引用所有数据成员,而不是使用下划线前缀。因此,而不是这样的语句:
return _grade;
他们会有:
return this.grade;
如果你之前没有见过关键字 this,它是对它出现的对象的一个引用。所以如果上面的语句出现在一个类方法中,并且该方法还声明了一个名为 grade 的局部变量,那么表达式 this.grade 就会引用数据成员 grade,而不是具有相同名称的局部变量。在具有自动语法补全的开发环境中使用关键字这种方式有优势:程序员只需键入 this,然后按点键,从列表中选择数据成员,避免额外的输入和可能的拼写错误。但无论哪种技术,都会突出数据成员引用,这是重要的。
既然我们已经看到了类的声明,让我们看看方法的实现。我们将从第一个 get/set 对开始。
int studentRecord::grade() {
return _grade;
}
void studentRecord::setGrade(int newGrade) {
_grade = newGrade;
}
这是 get/set 对的最基本形式。第一个方法 grade 返回关联数据成员 _grade 的当前值 ![http://atomoreilly.com/source/nostarch/images/1273182.png]。第二个方法 setGrade 将参数 newGrade 的值赋给数据成员 _grade ![http://atomoreilly.com/source/nostarch/images/1273191.png]。然而,如果我们只做这些,我们的类就不会有任何成果。尽管这段代码提供了信息隐藏,因为它在两个方向上传递数据而不做任何考虑或修改,但它比将 _grade 声明为公共的更好,因为它为我们保留了更改数据成员名称或类型的权利。setGrade 方法至少应该执行一些基本的验证;它应该防止将没有意义的成绩值赋给 _grade 数据成员。但是,我们必须小心遵循问题规范,不要基于自己的经验对数据进行假设,而不考虑用户。例如,限制成绩在 0-100 范围内可能是合理的,但如果学校允许加分使分数超过 100 或使用 -1 作为退课的代码,则可能不合理。在这种情况下,因为我们从问题描述中获得了一些指导,我们可以将这方面的知识纳入验证中。
void studentRecord::setGrade(int newGrade) {
if ((newGrade >= 0) && (newGrade <= 100))
_grade = newGrade;
}
这里,验证只是一个守门人。然而,根据问题的定义,方法产生错误消息、写入错误日志或以其他方式处理错误可能是合理的。
其他获取/设置对将以完全相同的方式工作。无疑,关于特定学校学生 ID 号码构建的规则可以用于验证。然而,对于学生姓名,我们最好的做法是拒绝包含奇特的字符,如%或@,而如今甚至可能无法做到这一点。
完成我们类的最后一步是编写构造函数。在基本框架中,我们包括两个构造函数:一个没有参数的默认构造函数,它将数据成员设置为合理的默认值,以及一个为每个数据成员提供参数的构造函数。第二个构造函数形式对于我们的表达性目标很重要,因为它允许我们在一步中创建我们的类对象并初始化其内部值。一旦你编写了其他方法的代码,第二个构造函数几乎就会自己写出来。
studentRecord::studentRecord(int newGrade, int newID, string newName) {
setGrade(newGrade);
setStudentID(newID);
setName(newName);
}
如您所见,构造函数只是为每个参数调用适当的设置方法。在大多数情况下,这是一种正确的方法,因为它避免了代码的重复,并确保构造函数将利用设置方法中的任何验证代码。
默认构造函数有时有点棘手,不是因为代码复杂,而是因为没有总是明显的默认值。在选择数据成员的默认值时,请记住使用默认构造函数创建的对象将用于哪些情况,特别是该类是否有合法的默认对象。这将告诉你是否应该用有用的默认值填充数据成员,或者用表示对象未正确初始化的值。例如,考虑一个表示值集合的类,它封装了一个链表。确实有一个有意义的默认链表,那就是空链表,所以我们会设置数据成员来创建一个合法但概念上为空的列表。但是,在我们的示例基本类中,没有有意义的默认学生的定义;我们不会想给默认的studentRecord对象分配一个有效的 ID 号码,因为这可能会与合法的studentRecord造成混淆。因此,我们应该为_studentID字段选择一个显然是非法的默认值,例如-1:
studentRecord::studentRecord() {
setGrade(0);
setStudentID(-1);
setName("");
}
我们使用setGrade来分配成绩,它验证其参数。这意味着我们必须分配一个有效的成绩,在这种情况下,是 0。因为 ID 被设置为无效值,整个记录可以很容易地被识别为非法。因此,有效的成绩不应该有问题。如果这成为问题,我们可以直接将无效值分配给_grade数据成员。
这就完成了基本类框架。我们有一组私有数据成员,它们引用同一个逻辑对象的属性,在这种情况下,是一个学生的班级记录;我们有成员函数来检索或修改对象的数据,根据需要执行验证;我们还有一组有用的构造函数。我们有一个良好的类基础。问题是,我们是否需要做更多?
支持方法
支持方法 是一个类中的方法,它不仅仅是检索或存储数据。一些程序员可能将这些方法称为辅助方法、辅助方法或其他名称,但无论它们被称为什么,它们都是使类超越基本类框架的关键。一套精心设计的支持方法通常是一个类真正有用的关键。
为了确定可能的支持方法,考虑一下类将如何被使用。我们是否期望客户端代码在我们的类数据上执行一些常见操作?在这种情况下,我们被告知,我们最初为我们的类设计的程序将不仅显示学生的数值分数,还将显示字母等级。因此,让我们创建一个支持方法,该方法返回学生的等级作为字母。首先,我们将把方法声明添加到我们的类声明公共部分。
string letterGrade();
现在我们需要实现这个方法。这个函数将把存储在 _grade 中的数值转换为基于问题中显示的等级表的适当 string。我们可以通过一系列的 if 语句来完成这个任务,但是有没有更干净、更优雅的方法呢?如果你刚刚想到,“嘿,这听起来很像我们在第三章中如何将收入转换为营业执照类别的方法,”恭喜你——你已经发现了一个合适的编程类比。我们可以通过使用并行 const 数组来存储字母等级和与这些等级相关的最低数值分数,通过循环来转换数值分数。
string studentRecord::letterGrade() {
const int NUMBER_CATEGORIES = 11;
const string GRADE_LETTER[] = {"F", "D", "D+", "C-",
"C", "C+", "B-", "B", "B+", "A-", "A"};
const int LOWEST_GRADE_SCORE[] = {0, 60, 67, 70, 73, 77, 80, 83, 87, 90, 93};
int category = 0;
while (category < NUMBER_CATEGORIES && LOWEST_GRADE_SCORE[category] <= _grade)
category++;
return GRADE_LETTER[category - 1];
}
这个方法是对第三章中函数的直接改编,所以关于代码如何工作没有新的解释。然而,将其改编为类方法确实引入了一些设计决策。首先要注意的是,我们没有创建一个新的数据成员来存储字母等级,而是为每个请求即时计算适当的字母等级。另一种方法是将 _letterGrade 数据成员,并重写 setGrade 方法以在 _grade 旁边更新 _letterGrade。然后这个 letterGrade 方法将变成一个简单的 get 方法,返回已计算数据成员的值。
这种方法的缺点是 数据冗余,这是一个描述数据存储为其他数据的直接重复或可以直接从其他数据中确定的情况的术语。这个问题在数据库中最常见,数据库设计者遵循详细的过程来避免在他们的表中创建冗余数据。然而,如果我们不小心,任何程序都可能出现数据冗余。为了看到危险,考虑一个存储一组患者年龄和出生日期的医疗记录程序。出生日期为我们提供了年龄没有的信息。因此,这两个数据项不相等,但年龄并没有告诉我们任何我们可以从出生日期中得知的信息。如果这两个值不一致(除非年龄自动更新,否则最终会发生这种情况),我们信任哪个值?我想起了哈里发欧玛尔下令焚烧亚历山大图书馆时的著名(尽管可能是伪托的)宣言。他宣称,如果图书馆的书与《古兰经》一致,它们就是冗余的,不需要保存,但如果它们与《古兰经》不一致,它们就是有害的,应该被销毁。冗余数据是麻烦在等待发生。唯一的理由可能是性能,如果我们认为对 _grade 的更新很少,而对 letterGrade 的调用很频繁,但很难想象程序会有显著的性能提升。
然而,这种方法可以改进。在测试这个方法时,我注意到一个问题。尽管这个方法对 _grade 的有效值产生正确的结果,但当 _grade 是负值时,该方法会崩溃。当达到 while 循环时,_grade 的负值会导致循环测试立即失败;因此,category 保持为零,而 return 语句试图引用 GRADE_LETTER[-1]。我们可以通过将 category 初始化为 1 而不是 0 来避免这个问题,但这意味着负等级会被分配“F”等级,而实际上它根本不应该分配任何字符串,因为作为一个无效的等级值,它不适合任何类别。
相反,我们可以在将其转换为字母等级之前验证 _grade。我们已经在 setGrade 方法中验证了等级值,因此,我们不应该在 letterGrade 方法中添加新的验证代码,而应该“提取”这些方法中可能共有的代码,以创建第三个方法。(你可能会想知道,如果我们正在验证分配时的等级,我们怎么可能有一个无效的等级,但请记住,我们的默认构造函数将 -1 分配给信号尚未分配任何合法等级。)这是一种另一种支持方法,它是前几章中引入的一般辅助函数概念的类等效。让我们实现这个方法并修改我们的其他方法以使用它:
bool studentRecord::isValidGrade(int grade) {
if ((grade >= 0) && (grade <= 100))
return true;
else
return false;
}
void studentRecord::setGrade(int newGrade) {
if (isValidGrade(newGrade))
_grade = newGrade;
}
string studentRecord::letterGrade() {
if (!isValidGrade(_grade)) return "ERROR";
const int NUMBER_CATEGORIES = 11;
const string GRADE_LETTER[] = {"F", "D", "D+",
"C-", "C", "C+", "B-", "B", "B+", "A-", "A"};
const int LOWEST_GRADE_SCORE[] = {0, 60, 67, 70, 73, 77, 80, 83, 87, 90, 93};
int category = 0;
while (category < NUMBER_CATEGORIES && LOWEST_GRADE_SCORE[category] <= _grade)
category++;
return GRADE_LETTER[category - 1];
}
新的评分验证方法的数据类型为bool ![http://atomoreilly.com/source/nostarch/images/1273182.png],由于这是一个是或否的问题,我选择了名称isValidGrade ![http://atomoreilly.com/source/nostarch/images/1273191.png]。这给调用此方法的英语阅读带来了最自然的体验,例如在setGrade ![http://atomoreilly.com/source/nostarch/images/1273195.png]和letterGrade ![http://atomoreilly.com/source/nostarch/images/1273197.png]方法中。此外,请注意,该方法将验证的评分作为参数 ![http://atomoreilly.com/source/nostarch/images/1273193.png]。虽然letterGrade正在验证已经存在于_grade数据成员中的值,但setGrade正在验证我们可能或可能不会分配给数据成员的值。因此,isValidGrade需要将评分作为参数,以便对其他两个方法都有效用。
尽管已经实现了isValidGrade方法,但关于它的一个决定仍然悬而未决:我们应该给它分配什么访问级别?也就是说,我们应该将它放在类的公共部分还是私有部分?与基本类框架的get和set方法总是放在公共部分不同,支持方法可能是公共的或私有的,这取决于它们的使用情况。将isValidGrade设置为公共访问级别会有什么影响?最明显的是,客户端代码可以访问该方法。由于拥有更多公共方法似乎使类更有用,许多新手程序员将所有可能被客户端使用的每个方法都设置为公共。然而,这忽略了公共访问指定带来的另一个影响。记住,公共部分定义了我们类的接口,一旦我们的类集成到一个或多个程序中,我们就应该不愿意更改方法,因为这种更改很可能会导致级联并需要更改所有客户端代码。因此,将方法放在公共部分,因此锁定方法的接口及其影响。在这种情况下,假设某些客户端代码基于isValidGrade的原始公式,依赖于它作为一个 0-100 范围的检查器,但后来,可接受的评分规则变得更加复杂。客户端代码可能会失败。为了避免这种情况,我们可能不得不在类内部创建第二个评分验证方法,并保留第一个方法不变。
假设我们预计isValidGrade对客户端的用途有限,并决定不将其公开。我们可以将方法设为私有,但这并非唯一的选择。因为该函数没有直接引用任何类的数据成员或任何其他方法,我们可以在类外声明该函数。然而,这样做不仅会带来与公开访问相同的可修改性问题,而且还会降低封装性,因为现在这个类所需的函数不再是其一部分。我们也可以将方法留在类中,但将其改为受保护的而不是私有的。这种差异将在任何子类中体现出来。如果isValidGrade是受保护的,则子类的方法可以调用该方法;如果isValidGrade是私有的,则只能由studentRecord类中的其他方法使用。这在较小规模上与公开和私有相同。我们是否期望子类的方法会大量使用我们的方法,以及我们是否期望该方法的效果或其接口在未来可能会改变?在许多情况下,最安全的方法是将所有辅助方法设为私有,只公开那些旨在为客户端提供帮助的支持方法。
具有动态数据的类
创建类的一个最好的理由是封装动态数据结构。正如我们在第四章中讨论的那样,程序员面临着跟踪动态分配、指针赋值和释放的真正困难,以避免内存泄漏、悬垂引用和非法内存引用。将所有指针引用放入类中并不能消除这项困难的工作,但它确实意味着一旦我们做对了,我们就可以安全地将代码放入其他项目中。这也意味着我们动态数据结构的问题将被隔离在类内部的代码中,简化了调试。
让我们构建一个具有动态数据的类,看看它是如何工作的。对于我们的示例问题,我们将使用第四章中的主要问题的修改版。
问题:跟踪未知数量的学生记录
在这个问题中,你需要编写一个类,其中包含存储和操作学生记录集合的方法。学生记录包含学生编号和成绩,都是整数,以及一个表示学生姓名的字符串。以下函数需要实现:
addRecord
此方法接受学生编号、姓名和成绩,并将包含这些数据的新的记录添加到集合中。
recordWithNumber
此函数接受学生编号,并从集合中检索具有该学生编号的记录。
removeRecord
此函数接受学生编号,并从集合中删除具有该学生编号的记录。
集合可以有任何大小。预期addRecord操作会被频繁调用,因此它必须高效实现。
与原始版本相比,这个描述的主要区别是我们增加了一个新的操作recordWithNumber,并且没有任何操作引用指针参数。这是使用类封装链表的关键好处。客户端可能知道该类将学生记录集合实现为链表,甚至可能依赖这一点(记住我们之前关于信息隐藏局限性的讨论)。然而,客户端代码将不会与链表或类中的任何指针进行直接交互。
因为这个问题与上一个问题存储相同的学生信息,所以我们在这里有机会进行班级重用。在我们的链表节点类型中,我们不会为每三个学生数据分别设置单独的字段,而是将有一个studentRecord对象。在一个类的对象作为另一个类的数据类型时,这被称为组合。
现在我们已经有了足够的信息来做出初步的类声明:
class studentCollection {
private:
struct studentNode {
studentRecord studentData;
studentNode * next;
};
public:
studentCollection();
void addRecord(studentRecord newStudent);
studentRecord recordWithNumber(int idNum);
void removeRecord(int idNum);
private:
typedef studentNode * studentList;
studentList _listHead;
};
之前我说程序员倾向于从公共声明开始创建类,但在这里我们必须做出例外。我们以一个私有的节点struct声明studentNode开始,如图所示 ![httpatomoreillycomsourcenostarchimages1273182.png],我们将用它来创建我们的链表。这个声明必须放在公共部分之前,因为我们的几个公共成员函数引用了这个类型。与第四章中的节点类型不同,这个节点没有为有效载荷数据设置单独的字段,而是包含了一个studentRecord struct类型的成员 ![httpatomoreillycomsourcenostarchimages1273191.png]。公共成员函数 ![httpatomoreillycomsourcenostarchimages1273193.png] 直接来自问题描述;此外,我们始终有一个构造函数。在第二个私有部分,我们声明了一个typedef ![httpatomoreillycomsourcenostarchimages1273195.png],用于我们的节点类型的指针,就像我们在第四章中所做的那样。然后我们声明了我们的列表头指针,巧妙地称为_listHead ![httpatomoreillycomsourcenostarchimages1273197.png]。
此类声明了两种私有类型。类不仅可以声明成员函数和数据成员,还可以声明类型。与其他成员一样,类中出现的类型可以使用任何访问修饰符进行声明。然而,与数据成员一样,你应该将类型定义视为默认私有,除非你有明确的理由使其更宽松。类型声明通常是类在幕后操作的核心,因此它们对于信息隐藏至关重要。此外,在大多数情况下,客户端代码对你在类中声明的类型没有用处。一个例外是,当类中定义的类型用作公共方法的返回类型或公共方法参数的类型时。在这种情况下,该类型必须是公共的,否则公共方法无法被客户端代码使用。studentCollection类假设struct类型studentRecord将单独声明,但我们可以将其作为类的一部分。如果我们这样做,我们必须在public部分声明它。
现在我们准备实现我们的类方法,从构造函数开始。与之前的例子不同,这里我们只有默认构造函数,没有接受参数以初始化数据成员的构造函数。我们类的整个目的就是隐藏我们的链表细节,因此我们不希望客户端甚至去考虑我们的_listHead,更不用说操作它了。在我们的默认构造函数中,我们只需要将头指针设置为NULL:
studentCollection::studentCollection() {
_listHead = NULL;
}
添加节点
我们继续到addRecord。由于问题描述中没有要求我们以任何特定的顺序保存学生记录,因此我们可以直接将第四章中的addRecord函数修改后用于此处。
void studentCollection::addRecord(studentRecord newStudent) {
studentNode * newNode = new studentNode;
newNode->studentData = newStudent;
newNode->next = _listHead;
_listHead = newNode;
}
与我们的蓝图函数相比,这段代码只有两个不同之处。在这里,我们只需要在我们的参数列表中有一个参数 ![http://atomoreilly.com/source/nostarch/images/1273182.png],这是我们打算添加到我们的集合中的studentRecord对象。这封装了一个学生的所有数据,从而减少了所需的参数数量。我们也不需要传递一个列表头指针,因为那已经存储在我们的类中作为_listHead,并在需要时直接引用。就像第四章中的addRecord函数一样,我们创建一个新的节点 ![http://atomoreilly.com/source/nostarch/images/1273191.png],将新的学生数据复制到新的节点 ![http://atomoreilly.com/source/nostarch/images/1273193.png],将新节点的下一个字段指向列表中的前一个第一个节点 ![http://atomoreilly.com/source/nostarch/images/1273195.png],最后将_listHead指向新节点 ![http://atomoreilly.com/source/nostarch/images/1273197.png]。通常我建议为所有的指针操作绘制一个图表,但由于这是我们已经做过的相同操作,我们可以参考之前绘制的图表。
现在,我们可以将注意力转向这三个成员函数中的最后一个,recordWithNumber。这个名字有点长,一些程序员可能会选择retrieveRecord或类似的名称。然而,根据我之前提出的命名规则,我决定使用名词,因为这个方法会返回一个值。这个方法将与averageRecord类似,因为它需要遍历列表;在这个情况下,不同之处在于一旦找到匹配的学生记录,我们就可以停止遍历。
studentRecord studentCollection::recordWithNumber(int idNum) {
studentNode * loopPtr = _listHead;
while (loopPtr->studentData.studentID() != idNum) {
loopPtr = loopPtr->next;
}
return loopPtr->studentData;
}
在这个函数中,我们将循环指针初始化为列表的头部 ![http://atomoreilly.com/source/nostarch/images/1273182.png] 并遍历列表,直到我们没有看到期望的 ID 号码 ![http://atomoreilly.com/source/nostarch/images/1273191.png]。最后,到达期望的节点,我们将整个匹配的记录作为函数的值返回 ![http://atomoreilly.com/source/nostarch/images/1273193.png]。这段代码看起来不错,但像往常一样,我们必须考虑潜在的特殊情况。当我们处理链表时,我们总是考虑的一个情况是初始的NULL头指针。在这里,这确实会引发问题,因为我们没有检查这一点,当我们在循环第一次进入时尝试解引用loopPtr时,代码将会崩溃。更普遍地说,我们还要考虑客户端代码提供的 ID 号码实际上并不匹配我们集合中的任何记录的可能性。在这种情况下,即使_listHead不是NULL,当到达列表的末尾时,loopPtr最终也会变成NULL。
所以一般问题是,我们需要在 loopPtr 变为 NULL 时停止循环。这不难,但接下来,在这种情况下我们应该返回什么?我们当然不能返回 loopPtr->studentData,因为 loopPtr 将会是 NULL。相反,我们可以构建并返回一个带有明显无效值的虚拟 studentRecord。
studentRecord studentCollection::recordWithNumber(int idNum) {
studentNode * loopPtr = _listHead;
while (loopPtr != NULL && loopPtr->studentData.studentID() != idNum) {
loopPtr = loopPtr->next;
}
if (loopPtr == NULL) {
studentRecord dummyRecord(−1, −1, "");
return dummyRecord;
} else {
return loopPtr->studentData;
}
}
在这个方法版本中,如果循环结束时循环指针是 NULL ![http://atomoreilly.com/source/nostarch/images/1273191.png],我们将创建一个带有空字符串名称和成绩以及学生 ID 为 −1 的虚拟记录,并返回它。回到循环中,我们正在检查那个 NULL loopPtr 条件,这又可能是因为没有列表可以遍历,或者因为我们遍历了但没有成功。这里的一个关键点是循环的条件表达式 ![http://atomoreilly.com/source/nostarch/images/1273182.png] 是一个复合表达式,其中 loopPtr != NULL 是第一个。这是绝对必要的。C++ 使用一种称为 短路求值 的机制来评估复合布尔表达式;简单地说,当表达式的整体值已知时,它不会评估复合布尔表达式的右侧。因为 && 代表逻辑布尔 和,如果 && 表达式的左侧评估为假,则整体表达式也必须是假的,无论右侧的评估结果如何。为了提高效率,C++ 利用这个事实,当左侧为假时,跳过 && 表达式右侧的评估(对于 ||,逻辑 或,当左侧为真时,右侧不评估,原因相同)。因此,当 loopPtr 为 NULL 时,表达式 loopPtr != NULL 评估为假,&& 的右侧永远不会被评估。如果没有短路求值,右侧 将会 被评估,我们将会取消对 NULL 指针的引用,导致程序崩溃。
这种实现避免了第一个版本可能发生的崩溃,但我们需要意识到它对客户端代码有很大的信任。也就是说,调用这个方法的函数负责检查返回的 studentRecord,并确保在进一步处理之前它不是虚拟记录。如果你像我一样,这会让你感到有些不安。
异常
另外还有一个选择。C++ 以及许多其他编程语言都提供了一种称为 异常 的机制,它允许一个函数,无论是方法还是一般函数,能够明确地向调用者信号错误状态。它是为处理我们在这个方法中遇到的情况而设计的,当输入数据不正确时,没有好的返回值。异常语法超出了这里可以讨论的范围,而且不幸的是,C++ 中实现异常的方式并没有解决前一段中提到的信任问题。
重新排列列表
removeRecord 方法与 recordWithNumber 方法类似,我们必须遍历列表以找到要从列表中删除的节点,但这其中还有很多细节。从列表中删除节点需要小心地保持列表中剩余节点的链接。最简单的方法是将被删除节点之前的节点链接到被删除节点之后的节点。我们不需要函数轮廓,因为我们已经在类声明中有一个函数原型,所以我们只需要一个测试用例:
studentCollection s;
studentRecord stu3(84, 1152, "Sue");
studentRecord stu2(75, 4875, "Ed");
studentRecord stu1(98, 2938, "Todd");
s.addRecord(stu3);
s.addRecord(stu2);
s.addRecord(stu1);
s.removeRecord(4875);
在这里,我们创建了一个 studentCollection 对象 s,以及三个 studentRecord 对象,每个对象都添加到我们的集合中。请注意,我们可以在 addRecord 调用之间重用相同的记录,改变值,但这样做简化了我们的测试代码。测试的最后一行是调用 removeRecord
,在这种情况下,它将删除第二个记录,即名为“Ed”的学生记录。使用与 第四章 中相同的指针图风格,图 5-1 显示了调用之前的内存状态和调用之后的内存状态。
在 图 5-1 (a) 中,我们看到由我们的测试代码创建的链表。请注意,因为我们使用了一个类,所以我们的图例约定有点倾斜。在我们的栈/堆划分的左侧,我们有 _listHead,这是 studentCollection 对象 s 内部的私有数据成员,以及 idNum,这是 removeRecord 的参数。在右侧是列表本身,位于堆中。记住,addRecord 将新记录放在列表的开头,所以记录的顺序与测试代码中添加的顺序相反。中间的节点 "Ed" 拥有与参数匹配的 ID 号码 4875,因此它将从列表中删除。图 5-1 (b) 显示了调用结果。列表中的第一个节点 "Todd" 现在指向原来列表中的第三个节点 "Sue"。"Ed" 节点不再链接到更大的列表中,并且已被删除。

图 5-1. removeRecord 测试用例的“之前”和“之后”状态
现在我们知道了代码应该产生什么效果,我们可以开始编写它。由于我们知道我们需要找到具有匹配 ID 号的节点,我们可以从recordWithNumber中的while循环开始。当这个循环完成后,我们就会得到指向所需删除的节点的指针。不幸的是,我们需要的不仅仅是这个来完成删除。看看图 5-1;为了关闭空隙并保持链表的完整性,我们需要更改"Todd"节点的next字段。如果我们只有"Ed"节点的指针,就没有办法引用"Todd"节点,因为链表中的每个节点都引用其后续节点,而不是其前驱节点。(由于这种情况,一些链表在两个方向上都有链接;这些被称为双向链表,但它们很少需要。)因此,除了指向要删除的节点(如果我们从上一个函数中调整代码,它将被称为loopPtr)的指针之外,我们还需要一个指向立即前一个节点的指针:让我们称这个指针为trailing。图 5-2 展示了这一概念在我们示例中的应用。

图 5-2. 删除指定idNum的节点所需的指针
使用loopPtr引用我们要删除的节点,而trailing引用前一个节点,我们可以删除所需的节点并保持列表的完整性。
void studentCollection::removeRecord(int idNum) {
studentNode * loopPtr = _listHead;
studentNode * trailing = NULL;
while (loopPtr != NULL && loopPtr->studentData.studentID() != idNum) {
trailing = loopPtr;
loopPtr = loopPtr->next;
}
if (loopPtr == NULL) return;
trailing->next = loopPtr->next;
delete loopPtr;
}
这个函数的前一部分类似于recordWithNumber,除了我们声明了我们的trailing指针
,并且在循环内部,我们在将loopPtr推进到下一个节点之前,将loopPtr的旧值赋给trailing
。这样,trailing总是比loopPtr落后一个节点。由于我们处理了上一个函数,我们已经对一种特殊情况有所防范。因此,当循环结束时,我们检查loopPtr是否为NULL。如果是这样,这意味着我们从未找到具有所需 ID 号的节点,我们立即return
。我把函数中间出现的return语句称为“逃离困境”。一些程序员反对这样做,因为具有多个退出点的函数可能更难阅读。但在这个情况下,另一种选择是if语句的另一个嵌套级别,我宁愿只是逃离困境。
确定要删除的节点后,就是删除它的时候了。从我们的图中可以看出,我们需要将 trailing 节点的 next 字段设置为指向 loopPtr 节点的 next 字段当前指向的节点
。然后我们可以安全地 delete 由 loopPtr 指向的节点
。
这在我们的测试用例中是有效的,但就像往常一样,我们需要检查潜在的特殊情况。我们已经处理了 idNum 不出现在我们集合中的任何记录中的可能性,但还有其他可能的问题吗?查看我们的测试用例,如果我们尝试删除第一个或第三个节点而不是中间节点,会发生什么变化?测试和手动检查显示第三个(最后一个)节点没有问题。然而,第一个节点却会导致问题,因为在这种情况下,没有前一个节点供 trailing 指向。相反,我们必须操作 _listHead 本身。 图 5-3 显示了 while 循环结束后的情况。

图 5-3. 删除列表中第一个节点之前的情况
在这种情况下,我们需要将 _listHead 重置为列表中的前第二个节点,即 "Ed" 的节点。让我们重写我们的方法来处理特殊情况。
void studentCollection::removeRecord(int idNum) {
studentNode * loopPtr = _listHead;
studentNode * trailing = NULL;
while (loopPtr != NULL && loopPtr->studentData.studentID() != idNum) {
trailing = loopPtr;
loopPtr = loopPtr->next;
}
if (loopPtr == NULL) return;
if (trailing == NULL) {
_listHead = _listHead->next;
} else {
trailing->next = loopPtr->next;
}
delete loopPtr;
}
如您所见,条件测试
和处理特殊情况的代码
都很简单,因为我们编写代码之前已经仔细分析了情况。
析构函数
问题中指定的三种方法实现后,我们可能会认为我们的 studentCollection 类已经完整。然而,现状是它存在严重的问题。第一个问题是类缺少一个 析构函数。这是一个在对象超出作用域时(当声明对象的函数完成时)被调用的特殊方法。当一个类没有动态数据时,通常不需要析构函数,但如果你有前者,你肯定需要后者。记住,我们必须用 delete 删除所有用 new 分配的内存,以避免内存泄漏。如果我们的 studentCollection 类有三个节点,那么每个节点都需要被释放。幸运的是,这并不太难。我们只需要遍历我们的链表,边走边删除。不过,我们不是直接这样做,而是写一个辅助方法来删除 studentList 中的所有节点。在我们的类的私有部分,我们添加了以下声明:
void deleteList(studentList &listPtr);
该方法本身的代码如下:
void studentCollection::deleteList(studentList &listPtr) {
while (listPtr != NULL) {
studentNode * temp = listPtr;
listPtr = listPtr->next;
delete temp;
}
}
遍历操作将当前节点的指针复制到一个临时变量
,然后前进当前节点指针
,最后删除临时变量指向的节点
。有了这段代码,我们可以非常简单地编写析构函数。首先,我们将析构函数添加到我们的类声明的公共部分:
˜studentCollection();
注意,就像构造函数一样,析构函数也是通过类名来指定的,并且没有返回类型。名称前的波浪线将析构函数与构造函数区分开来。实现方式如下:
studentCollection::˜studentCollection() {
deleteList(_listHead);
}
这些方法中的代码很简单,但测试析构函数很重要。虽然编写不良的析构函数可能会导致程序崩溃,但许多析构函数问题不会导致崩溃,只会造成内存泄漏,或者更糟糕的是,不可解释的程序行为。因此,使用你的开发环境的调试器测试析构函数很重要,这样你可以看到析构函数实际上正在对每个节点调用 delete。
深拷贝
另一个严重的问题仍然存在。回到第四章,我们简要讨论了交叉链接的概念,其中两个指针变量具有相同的值。尽管变量本身是不同的,但它们指向相同的数据结构;因此,修改一个变量的结构会同时修改它们。这个问题很容易出现在包含动态分配内存的类中。为了了解为什么这会成为一个问题,考虑以下基本的 C++代码序列:
int x = 10;
int y = 15;
x = y;
x = 5;
假设我问你最后一条语句
对变量 y 的值有什么影响。你可能想知道我是否说错了。最后一条语句根本不会对 y 产生任何影响,只会影响 x。但现在考虑这一点:
studentCollection s1;
studentCollection s2;
studentRecord r1(85, 99837, "John");
s2.addRecord(r1);
studentRecord r2(77, 4765, "Elsie");
s2.addRecord(r2);
s1 = s2;
s2.removeRecord(99837);
假设我问你最后一条语句
对 s1 产生了什么影响。不幸的是,它确实产生了影响。尽管 s1 和 s2 是两个不同的对象,但它们不再是完全独立的对象。默认情况下,当一个对象被赋值给另一个对象时,就像我们这里将 s2 赋值给 s1
一样,C++ 执行的是所谓的 浅拷贝。在浅拷贝中,一个对象的所有数据成员直接赋值给另一个对象。所以如果 _listHead,我们的唯一数据成员是公开的,s1 = s2 就等同于 s1._listHead = s2._listHead。这使得两个对象的数据成员 _listHead 都指向内存中的同一位置:指向 "Elsie" 的节点,该节点指向另一个节点,即 "John" 的节点。因此,当删除 "John" 的节点时,它似乎从两个列表中都被删除了,因为实际上只有一个列表。 显示了代码结束时的状态。

图 5-4. 浅拷贝导致交叉链接;从列表中删除 "John" 节点会同时从两个列表中删除。
尽管这听起来很奇怪,但实际上可能会更糟。如果代码的最后一条语句删除了第一条记录,即 "Elsie" 节点呢?在这种情况下,s2 内部的 _listHead 会更新为指向 "John",而 "Elsie" 节点会被删除。然而,s1 内部的 _listHead 仍然会指向被删除的 "Elsie" 节点,这是一个危险的悬挂引用,如图 所示。

图 5-5. 从 s2 中删除导致 s1 中出现悬挂引用
解决这个问题的方法是 深拷贝,这意味着我们不仅复制结构体的指针,而是复制结构体中的所有内容。在这种情况下,这意味着复制列表中的所有节点以创建一个真正的列表副本。和之前一样,让我们先创建一个私有辅助方法,在这种情况下,是一个复制 studentList 的方法。类私有部分的声明如下:
studentList copiedList(const studentList original);
和之前一样,我选择了一个名词作为返回值的函数名。该方法的实现如下:
studentCollection::studentList
studentCollection::copiedList(const studentList original) {
if (original == NULL) {
return NULL;
}
studentList newList = new studentNode;
newList->studentData = original->studentData;
studentNode * oldLoopPtr = original->next;
studentNode * newLoopPtr = newList;
while (oldLoopPtr != NULL) {
newLoopPtr->next = new studentNode;
newLoopPtr = newLoopPtr->next;
newLoopPtr->studentData = oldLoopPtr->studentData;
oldLoopPtr = oldLoopPtr->next;
}
newLoopPtr->next = NULL;
return newList;
}
这个方法中有很多操作,所以让我们一步一步来分析。在语法方面,当在实现中指定返回类型时,我们必须在类名前加上前缀
。否则,编译器将不知道我们在谈论什么类型。(在方法内部,这并不是必需的,因为编译器已经知道这个方法属于哪个类——有点令人困惑!)我们检查输入列表是否为空。如果是,我们就退出循环
。一旦我们知道有一个要复制的列表,我们就在循环之前
复制第一个节点的数据,因为对于那个节点,我们必须修改我们新列表的头指针。
然后,我们设置了两个指针来跟踪两个列表。old-LoopPtr
遍历输入列表;它始终指向我们即将复制的节点。newLoopPtr
遍历新的、复制的列表,并且始终指向我们刚刚创建的最后一个节点,即我们添加下一个节点之前的位置。就像在removeRecord方法中一样,我们在这里也需要一个尾指针。在循环
内部,我们创建一个新的节点,将newLoopPtr向前移动以指向它,从旧节点复制数据到新节点,并移动oldLoopPtr。循环结束后,我们通过将NULL赋值给最后一个节点的next字段
来终止新列表,并返回新列表的指针
。
那么,这个辅助方法是如何解决我们之前看到的问题的呢?它本身并不能解决。但是,有了这段代码,我们现在可以重载赋值运算符。运算符重载是 C++的一个特性,允许我们改变内置运算符对某些类型的作用。在这种情况下,我们想要重载赋值运算符(=),这样它就不再是默认的浅拷贝,而是调用我们的copiedList方法来执行深拷贝。在我们的类公共部分,我们添加了以下内容:
studentCollection& operator=(const studentCollection &
rhs);
我们要重载的运算符是通过使用关键字 operator 后跟我们要重载的运算符来命名的
。我为参数选择的名称(rhs
)是重载运算符的一个常见选择,因为它代表 右侧。这有助于程序员保持清晰。因此,在引发这次讨论的赋值语句 s2 = s1 中,对象 s1 将是赋值操作的右侧,而 s2 将是左侧。我们通过参数引用右侧,通过直接访问类成员来引用左侧,就像我们使用类中的任何其他方法一样。因此,我们在这个案例中的任务是创建一个由 _listHead 指向的列表,它是 rhs 的 _listHead 指向的列表的副本。这将使 s2 = s1 调用中的 s2 成为 s1 的真正副本。
参数的类型始终是对所涉及类的常量引用
;返回类型始终是对类的引用
。你很快就会明白为什么参数是一个引用。你可能想知道为什么这个方法返回任何东西,因为我们正在方法中直接操作数据成员。这是因为 C++ 允许链式赋值,如 s3 = s2 = s1,其中一次赋值的返回值成为下一次赋值的参数。
一旦理解了所有语法,赋值运算符的代码就相当直接:
studentCollection& studentCollection::operator=(const studentCollection &rhs) {
if (this != &rhs) {
deleteList(_listHead);
_listHead = copiedList(rhs._listHead);
}
return *this;
}
为了避免内存泄漏,我们首先必须从左侧列表中移除所有节点
。(正是出于这个目的,我们将 deleteList 写作辅助方法,而不是直接将其代码包含在析构函数中。)删除了之前的左侧列表后,我们使用另一个辅助方法
复制右侧列表。然而,在执行这些步骤之前,我们需要检查右侧的对象是否与左侧的对象不同(即,它不是像 s1 = s1 这样的东西)通过检查指针是否不同
。如果指针相同,则不需要做任何事情,但这不仅仅是效率问题。如果我们对相同的指针执行深拷贝,当我们删除左侧列表中的节点时,我们也会删除右侧列表中的节点。最后,我们返回左侧对象的指针
;这无论我们实际上是否复制了任何内容都会发生,因为尽管像 s2 = s1 = s1 这样的语句很混乱,但我们仍然希望如果有人尝试这样做,它仍然可以工作。
只要我们有了我们的列表复制辅助方法,我们也应该创建一个复制构造函数。这是一个接受另一个相同类的对象作为对象的构造函数。复制构造函数可以在我们需要创建现有studentCollection的副本时显式调用,但每当将此类对象作为值参数传递给函数时,也会隐式调用复制构造函数。正因为如此,除非接收对象的函数需要修改副本,否则你应该考虑将对象参数作为const引用而不是值参数传递。否则,你的代码可能会做很多不必要的操作。例如,考虑一个包含 10,000 条记录的学生集合。该集合可以作为一个引用、一个指针传递。或者,它可以调用复制构造函数进行长时间遍历和 10,000 次内存分配,然后这个局部副本在函数结束时调用析构函数,进行另一次长时间遍历和 10,000 次释放。这就是为什么赋值运算符重载的右侧参数使用const引用参数的原因。
要将复制构造函数添加到我们的类中,首先我们需要在公共部分将它的声明添加到类声明中。
studentCollection(const studentCollection &original);
与所有构造函数一样,没有返回类型,并且与重载的赋值运算符一样,参数是我们类的const引用。实现起来很简单,因为我们已经有了辅助方法。
studentCollection::studentCollection(const studentCollection &original) {
_listHead = copiedList(original._listHead);
}
现在我们可以做出这样的声明:
studentCollection s2(s1);
这个声明的作用是声明s2并将s1中的节点复制到它里面。
动态内存类的大图景
自从完成问题描述中指定的方法以来,我们对这个类做了很多工作,所以让我们花点时间来回顾一下。现在我们的类声明看起来是这样的。
class studentCollection {
private:
struct studentNode {
studentRecord studentData;
studentNode * next;
};
public:
studentCollection();
˜studentCollection();
studentCollection(const studentCollection &original);
studentCollection& operator=(const studentCollection &rhs);
void addRecord(studentRecord newStudent);
studentRecord recordWithNumber(int idNum);
void removeRecord(int idNum);
private:
typedef studentNode * studentList;
studentList _listHead;
void deleteList(studentList &listPtr);
studentList copiedList(const studentList original);
};
这里的教训是,在创建具有动态内存的类时,需要添加新的部分。除了我们基本类框架的功能——私有数据、默认构造函数以及将数据发送到对象内部和从对象中取出的方法之外,我们还需要添加处理动态内存分配和清理的额外方法。至少,我们应该添加一个复制构造函数和一个析构函数,如果有可能有人使用它,还要重载赋值运算符。创建这些额外方法通常可以通过创建复制或删除底层动态数据结构的辅助方法来简化。
这可能看起来像很多工作,确实如此,但重要的是要注意,你添加到类中的每一件事都是你需要处理的。换句话说,如果我们没有为我们的学生记录链表集合创建一个类,我们仍然需要在处理完它们时负责删除列表中的节点。我们仍然需要警惕交叉链接,如果我们想要原始列表的真正副本,我们仍然需要遍历列表并逐个复制节点,等等。将一切放入类结构只是前期多了一点工作,一旦一切正常工作,客户端代码就可以忽略所有的内存分配细节。最终,封装和信息隐藏使得动态数据结构更容易处理。
需要避免的错误
我们已经讨论了如何创建一个优秀的 C++类,那么让我们通过讨论一些你应该避免的常见陷阱来结束这次讨论。
假设类
正如我在本章开头提到的,我认为 C++作为一种包含过程式和面向对象范式的混合语言,是一个学习面向对象编程的绝佳语言,因为创建一个类总是程序员的一个积极选择。在像 Java 这样的语言中,问题从来不是“我应该创建一个类吗?”而是“我将如何将这个放入类中?”将一切放入类结构的要求导致了我所说的“假类”,这是一个在语法上正确但没有任何实际意义的没有连贯设计的类。在编程中使用的“类”一词来源于英语单词的意义,即具有共同属性的一组事物,一个好的 C++类符合这个定义。
假设类可能由几个原因产生。一种类型是因为程序员真的想使用全局变量,并不是出于任何可以辩护的理由(尽管这样的理由很少,但确实存在),而是出于懒惰——只是为了避免在函数之间传递参数。虽然程序员知道广泛使用全局变量被认为是一种糟糕的风格,但他或她认为找到了一个漏洞。程序的所有或大多数功能都被塞进了一个类中,原本应该是全局的变量现在变成了类的数据成员。程序的主函数简单地创建了一个假类的对象,并在类中调用了一些“主”方法。技术上,程序没有使用任何全局变量,但假类意味着程序具有与使用全局变量的程序相同的缺陷。
另一种类型的虚假类是由于程序员仅仅假设面向对象编程总是“更好”,并将其强加在不适用的情况下。在这些情况下,程序员通常会创建一个封装了非常特定功能的类,这种功能仅在编写该类的原始程序上下文中才有意义。有两种方法可以测试你是否在编写这种类型的虚假类。第一种是问自己,“我能给这个类起一个具体且相对简短的名字吗?”如果你发现自己有一个像工资报表管理器和打印队列这样的名字,你可能有问题。另一种测试是问,“如果我要写另一个具有类似功能的项目,我能想象这个类如何经过少量修改后重用吗?或者它是否需要彻底重写?”
即使在 C++中,也难免会有一些虚假的类,例如,因为我们必须封装数据以用于集合类。然而,这些类通常都很小且基础。如果我们能避免复杂的虚假类,我们的代码质量将会提高。
单任务者
如果你曾经看过电视节目《好胃口》,你就会知道主持人艾尔顿·布朗花了很多时间讨论如何为厨房配备以实现最大效率。他经常批评那些被称为单任务者的厨房小工具,他的意思是这些工具擅长一项任务,但其他什么也不做。在编写我们的类时,我们应该努力使它们尽可能通用,同时符合包括我们程序所需的所有特定功能。
实现这一目标的一种方法是通过模板类。这是一个具有某种晦涩语法的先进主题,但它允许我们创建在创建类对象时指定一个或多个数据成员类型的类。模板类允许我们“提取”通用功能。例如,我们的studentCollection类包含大量任何封装链表的类都通用的代码。我们本可以制作一个通用链表的模板类,这样在创建模板类对象时,列表节点中的数据类型就会被指定,而不是像studentRecord那样硬编码。然后我们的studentCollection类将有一个模板链表类对象作为数据成员,而不是列表头指针,并且不再直接操作链表。
模板类超出了本书的范围,但当你作为一个类设计者的能力不断发展时,你应该始终努力使类能够多任务处理。当你发现一个当前问题可以使用你之前编写的类来解决,而这个问题的存在你之前并不知道,这种感觉是非常棒的。
练习
你知道我接下来要说什么,对吧?那就试试看吧!
-
让我们尝试使用基本框架实现一个类。考虑一个用于存储汽车数据的类。我们将有三条数据:制造商名称和型号名称,都是字符串,以及一个型号年份,一个整数。为每个数据成员创建一个带有 get/set 方法的类。确保你在成员名称等细节方面做出良好的决策。你不必遵循我特定的命名约定。重要的是你要思考你所做的选择,并在你的决策中保持一致。
-
对于之前练习的汽车类,添加一个支持方法,该方法返回汽车对象的完整描述,作为一个格式化的字符串,例如,“1957 年雪佛兰 Impala”。添加第二个支持方法,该方法返回汽车以年为单位的老龄。
-
将 第四章 中的可变长度字符串函数(
append、concatenate和characterAt)取出来,用来创建一个可变长度字符串的类,确保实现所有必要的构造函数、析构函数和重载赋值运算符。 -
对于之前练习的可变长度字符串类,用重载的
[]运算符替换characterAt方法。例如,如果myString是我们类的对象,那么myString[1]应该返回与myString.characterAt(1)相同的结果。 -
对于之前练习的可变长度字符串类,添加一个
remove方法,该方法接受一个起始位置和字符数,并从字符串中间删除这么多字符。所以myString.remove(5,3)将从第五个位置开始删除三个字符。确保你的方法在任一参数值无效时也能正确行为。 -
审查你的可变长度字符串类,看看是否有可以重构的可能。例如,是否有任何共同的功能可以被分离成一个私有的支持方法?
-
将 第四章 中的学生记录函数(
addRecord和averageRecord)取出来,用来创建一个表示学生记录集合的类,就像之前一样,确保实现所有必要的构造函数、析构函数和重载赋值运算符。 -
对于之前练习的学生记录集合类,添加一个名为
RecordsWithinRange的方法,该方法接受一个低分和一个高分作为参数,并返回一个新集合,其中包含该范围内的记录(原始集合不受影响)。例如,myCollection.RecordsWithinRange(75, 80)将返回一个包含 75 至 80 分(包括两端)的所有记录的集合。
第六章:使用递归解决问题

本章是关于递归的,即函数直接或间接调用自身。递归编程看起来应该很简单。确实,一个好的递归解决方案通常看起来简单、几乎优雅。然而,通往那个解决方案的道路往往并不简单。这是因为递归要求我们以不同于其他类型编程的方式思考。当我们使用循环处理数据时,我们是在考虑按顺序处理,但当我们使用递归处理数据时,我们的正常顺序思维过程不会有所帮助。许多初出茅庐的程序员在递归上挣扎,因为他们看不到将他们学到的解决问题的技能应用到递归问题上的方法。在本章中,我们将讨论如何系统地攻击递归问题。答案是使用我们将称之为大递归思想的概念,简称 BRI。这是一个如此直截了当的想法,以至于它看起来像是一个技巧,但它确实有效。
递归基础回顾
关于递归的语法没有太多要了解的;困难在于你试图使用递归解决问题。递归发生在函数调用自身的时候,所以递归的语法只是函数调用的语法。最常见的形式是直接递归,即函数调用发生在同一函数体中。例如:
int factorial(int n) {
if (n == 1) return 1;
else return n * factorial(n - 1);
}
这个函数,它是一个常见但效率非常低的递归示例,计算n的阶乘。例如,如果n是 5,那么阶乘就是从 5 到 1 的所有数字的乘积,即 120。注意,在某些情况下不会发生递归。在这个函数中,如果参数是 1,我们直接返回一个值,而不进行任何递归
,这被称为基准情况。否则,我们进行递归调用
。
递归的另一种形式是间接递归——例如,如果函数 A 调用函数 B,而函数 B 后来又调用函数 A。间接递归很少用作解决问题的技术,所以我们在这里不会涉及它。
头递归和尾递归
在我们讨论 BRI 之前,我们需要理解头递归和尾递归之间的区别。在头递归中,递归调用,当它发生时,在函数中的其他处理之前(想想它在函数的顶部或头部发生)。在尾递归中,情况相反——处理发生在递归调用之前。在两种递归风格之间进行选择可能看起来是随意的,但选择可以产生很大的差异。为了说明这种差异,让我们看看两个问题。
问题:有多少只鹦鹉?
热带天堂铁路(TPR)的乘客期待着从火车窗户看到成打的五彩鹦鹉。因此,铁路对当地鹦鹉群体的健康表现出浓厚的兴趣,并决定对主线路沿线每个站台可见的鹦鹉数量进行统计。每个站台都配备了一名 TPR 员工(见图图 6-1), 他们当然能够数鹦鹉。不幸的是,这项工作被原始的电话系统复杂化了。每个站台只能与其直接邻居通话。我们如何得到主线路终端的鹦鹉总数?

图 6-1. 五个站点的员工只能与他们的直接邻居通信。
假设艺术在主终端有 7 只鹦鹉,贝琳达有 5 只,科尔里有 3 只,黛比有 10 只,而在最后一个站点,伊万有 2 只鹦鹉。因此,鹦鹉的总数是 27 只。问题是,员工们将如何一起向艺术传达这个总数?解决这个问题的任何方案都将需要从主终端到线路末端的通信链,然后再返回。每个站台的员工将被要求数鹦鹉,然后报告他们的观察结果。即便如此,这里有两种不同的通信链方法,这些方法对应于编程中的头递归和尾递归技术。
方法 1
在这种方法中,我们在进行外发通信的过程中保持鹦鹉的累计总数。每个员工在向线路下方的下一个员工提出请求时,都会传递到目前为止看到的鹦鹉数量。当我们到达线路的末端时,伊万将是第一个发现鹦鹉总数的人,他将把这个总数传递给黛比,黛比再传递给科尔里,依此类推(如图图 6-2 所示)。

图 6-2. 在方法 1 中解决鹦鹉计数问题所采取的步骤编号
-
艺术首先开始数他平台周围的鹦鹉。他数了 7 只鹦鹉。
-
艺术对贝琳达说:“主终端这里有 7 只鹦鹉。”
-
贝琳达在她平台周围数了 5 只鹦鹉,累计总数为 12。
-
贝琳达对科尔里:“前两个站点周围有 12 只鹦鹉。”
-
科尔里数了 3 只鹦鹉。
-
科尔里对黛比说:“前三个站点周围有 15 只鹦鹉。”
-
黛比数了 10 只鹦鹉。
-
DEBBIE 对 EVAN 说:“前四个站点周围有 25 只鹦鹉。”
-
EVAN 数了 2 只鹦鹉,发现鹦鹉的总数是 27。
-
EVAN 对 DEBBIE 说:“鹦鹉的总数是 27。”
-
DEBBIE 对 CORY 说:“鹦鹉的总数是 27。”
-
CORY 对 BELINDA 说:“鹦鹉的总数是 27。”
-
BELINDA 对 ART 说:“鹦鹉的总数是 27。”
这种方法类似于尾递归。在尾递归中,递归调用发生在处理之后——递归调用是函数中的最后一步。在上面的通信链中,请注意,员工的“工作”——鹦鹉计数和求和——发生在他们向下一站发出信号之前。所有的工作都发生在出站通信链上,而不是入站链上。以下是每位员工遵循的步骤:
-
从站台平台上数可见的鹦鹉。
-
将这个数量加到上一站给出的总数上。
-
打电话给下一站,传递鹦鹉数量的累计总和。
-
等待下一站打电话报告总鹦鹉数量,然后将这个总数传递给上一站。
方法 2
在这种方法中,我们从另一端累加鹦鹉的数量。每位员工在联系下一站时,都会请求从该站开始的总鹦鹉数量。员工然后将自己站点的鹦鹉数量加上,并将这个新的总数向上传递(如图 Figure 6-3 所示)。

图 Figure 6-3
-
ART 对 BELINDA 说:“从你的站点到线尾的总鹦鹉数量是多少?”
-
BELINDA 对 CORY 说:“从你的站点到线尾的总鹦鹉数量是多少?”
-
CORY 对 DEBBIE 说:“从你的站点到线尾的总鹦鹉数量是多少?”
-
DEBBIE 对 EVAN 说:“从你的站点到线尾的总鹦鹉数量是多少?”
-
EVAN 是线尾。他数了 2 只鹦鹉。
-
EVAN 对 DEBBIE 说:“这里末尾的总鹦鹉数量是 2。”
-
DEBBIE 在她的站点数了 10 只鹦鹉,因此从她的站点到末尾的总数是 12。
-
DEBBIE 对 CORY 说:“从这里到末尾的总鹦鹉数量是 12。”
-
CORY 数了 3 只鹦鹉。
-
CORY 对 BELINDA 说:“从这里到末尾的总鹦鹉数量是 15。”
-
BELINDA 数了 5 只鹦鹉。
-
BELINDA 对 ART 说:“从这里到末尾的总鹦鹉数量是 20。”
-
ART 在主终端数了 7 只鹦鹉,总数达到 27。
这种方法类似于头递归。在头递归中,递归调用发生在其他处理之前。在这里,调用下一个车站发生在计数鹦鹉或求和之前。工作被推迟到下游车站报告它们的总数之后。以下是每个员工遵循的步骤:
-
呼叫下一个车站。
-
计算从车站平台可见的鹦鹉数量。
-
将这个计数添加到下一个车站给出的总数中。
-
将得到的总和传递给上一个车站。
你可能已经注意到了不同方法产生的两个实际效果。在第一种方法中,最终所有车站员工都将学会整体鹦鹉总数。在第二种方法中,只有主终端的 Art 学会了完整总数——但请注意,Art 是唯一需要完整总数的员工。
当我们将讨论过渡到实际的编程代码时,另一个实际效果将对我们分析变得更加重要。在第一种方法中,每个员工在提出请求时将“累计总数”传递给下一个车站。在第二种方法中,员工只是从下一个车站请求信息,而不在沿线传递任何数据。这种效果是头递归方法的典型特征。因为递归调用首先发生,在所有其他处理之前,没有新的信息可以提供给递归调用。一般来说,头递归方法允许将最小数据集传递给递归调用。现在让我们看看另一个问题。
问题:谁是我们的最佳客户?
DelegateCorp 的经理需要确定八位客户中哪位为公司创造了最多的收入。两个因素使这个本应简单的工作变得复杂。首先,确定客户的总收入需要查看该客户的整个档案,并统计数十个订单和收据上的数字。其次,正如其名称所暗示的,DelegateCorp 的员工喜欢委派工作,并且每当可能时,每个员工都会将工作传递给更低级别的员工。为了防止情况失控,经理实施了一项规则:当你委派工作时,你必须自己完成部分工作,并且你必须给委派给员工的任务少于你原本得到的。
表 6-1 和 表 6-2 识别了 DelegateCorp 的员工和客户。
表 6-1. DelegateCorp 员工职位和职级
| 职位 | 职级 |
|---|---|
| 经理 | 1 |
| 副经理 | 2 |
| 副经理 | 3 |
| 助理经理 | 4 |
| 初级经理 | 5 |
| 实习生 | 6 |
表 6-2. DelegateCorp 客户
| 客户编号 | 收入 |
|---|---|
| #0001 | $172,000 |
| #0002 | $68,000 |
| #0003 | $193,000 |
| #0004 | $13,000 |
| #0005 | $256,000 |
| #0006 | $99,000 |
根据公司关于委派工作的规定,以下是六个客户文件将发生的情况。经理将取一个文件,并确定该客户为公司创造了多少收入。经理将把其他五个文件委派给副经理。副经理将处理一个文件,并将其他四个文件转交给助理经理。这个过程一直持续到第六个员工,实习生,他将获得一个文件,并必须简单地处理它,没有进一步的委派可能。
图 6-4 描述了沟通线路和劳动分工。然而,与前面的例子一样,存在两种不同的沟通链方法。

图 6-4. 寻找最高收入客户的方法 1(a)和方法 2(b)中的步骤编号
方法 1
在这种方法中,在委派剩余文件时,员工也会传递迄今为止看到的最高的收入金额。这意味着员工必须统计一个文件的收入,并将其与之前看到的最高的金额进行比较,然后再将剩余的文件委派给另一个员工。以下是如何在实际中进行的例子。
-
经理统计了客户#0001 的收入,为 172,000 美元。
-
经理对副经理说:“迄今为止我们看到的最高的收入是 172,000 美元,客户#0001。请取这五个文件并确定整体最高的收入。”
-
副经理统计了客户#0002 的收入,为 68,000 美元。迄今为止看到的最高的收入仍然是 172,000 美元,客户#0001。
-
副经理对助理经理说:“迄今为止我们看到的最高的收入是 172,000 美元,客户#0001。请取这四个文件并确定整体最高的收入。”
-
助理经理统计了客户#0003 的收入,为 193,000 美元。迄今为止看到的最高的收入现在是 193,000 美元,客户#0003。
-
助理经理对助理经理说:“迄今为止我们看到的最高的收入是 193,000 美元,客户#0003。请取这三个文件并确定整体最高的收入。”
-
助理经理统计了客户#0004 的收入,为 13,000 美元。迄今为止看到的最高的收入仍然是 193,000 美元,客户#0003。
-
助理经理对初级经理说:“迄今为止我们看到的最高的收入是 193,000 美元,客户#0003。请取这两个文件并确定整体最高的收入。”
-
初级经理统计了客户#0005 的收入,为 256,000 美元。迄今为止看到的最高的收入现在是 256,000 美元,客户#0005。
-
初级经理对实习生说:“迄今为止我们看到的最高的收入是 256,000 美元,客户#0005。请取这个剩余的文件并确定整体最高的收入。”
-
实习生计算客户编号#0006 的收入,为$99,000。迄今为止看到的最高收入仍然是$256,000,客户编号#0005。
-
实习生对初级经理说:“所有客户的最高收入是$256,000,客户编号#0005。”
-
初级经理对助理经理说:“所有客户的最高收入是$256,000,客户编号#0005。”
-
助理经理对助理经理说:“所有客户的最高收入是$256,000,客户编号#0005。”
-
助理经理对副经理说:“所有客户的最高收入是$256,000,客户编号#0005。”
-
副经理对经理说:“所有客户的最高收入是$256,000,客户编号#0005。”
这种方法,如图图 6-4(a)所示,使用了尾递归技术。每位员工处理一份客户文件,并将该客户的计算收入与迄今为止看到的最高收入进行比较。然后员工将比较结果转交给下属员工。递归——工作的转交——在其他处理之后发生。每位员工的过程如下:
-
计算一份客户文件中的收入。
-
将这个总额与上级在其他客户文件中看到的最高收入进行比较。
-
将剩余的客户文件及其迄今为止看到的最高收入金额转交给下属员工。
-
当下属员工返回所有客户文件中的最高收入时,将其转交给上级。
方法 2
在这种方法中,每位员工首先留下一份文件,然后将其他文件转交给下属。在这种情况下,下属不需要确定所有文件的最高收入,只需确定他们所拥有的文件的最高收入。与第一个示例问题一样,这简化了请求。使用与第一种方法相同的数据,对话将如下所示:
-
经理对副经理说:“拿这五份客户文件,告诉我最高收入。”
-
副经理对助理经理说:“拿这四份客户文件,告诉我最高收入。”
-
助理经理对助理经理说:“拿这三份客户文件,告诉我最高收入。”
-
助理经理对初级经理说:“拿这两份客户文件,告诉我最高收入。”
-
初级经理对实习生说:“拿这份客户文件,告诉我最高收入。”
-
实习生计算客户编号#0006 的收入,为$99,000。这是实习生看到的唯一文件,因此这是最高收入。
-
实习生对初级经理说:“我文件中的最高收入是$99,000,客户编号#0006。”
-
初级经理计算客户编号#0005 的收入,为$256,000。这位员工知道的最高收入是$256,000,客户编号#0005。
-
从初级经理晋升为助理经理:“我文件中的最高收入是$256,000,客户编号#0005。”
-
助理经理统计客户#0004 的收入,为$13,000。这位员工知道的最高收入是$256,000,客户编号#0005。
-
助理经理到副经理:“我文件中的最高收入是$256,000,客户编号#0005。”
-
助理经理统计客户#0003 的收入,为$193,000。这位员工知道的最高收入是$256,000,客户编号#0005。
-
助理经理到副经理:“我文件中的最高收入是$256,000,客户编号#0005。”
-
副经理统计客户#0002 的收入,为$68,000。这位员工知道的最高收入是$256,000,客户编号#0005。
-
副经理到经理:“我文件中的最高收入是$256,000,客户编号#0005。”
-
经理统计客户#0001 的收入,为$172,000。这位员工知道的最高收入是$256,000,客户编号#0005。
这种方法,如图图 6-4(b)所示,使用了头递归技术。每个员工仍然需要统计一个客户文件的收入,但这个动作是在下属员工确定剩余文件中的最高收入之后才进行的。每个员工采取的过程如下:
-
将所有客户文件(除了一个)传递给下属员工。
-
从下属员工那里获取那些文件中的最高收入。
-
在一个客户文件中统计收入。
-
将两个收入中较大的一个传递给上级。
与“数鹦鹉”问题一样,头递归技术允许每个员工向下属传递最小量的信息。
大递归思想
现在,我们来到了大递归思想。实际上,如果你已经阅读了示例问题的步骤,你已经在行动中看到了 BRI(大递归思想)。
怎么做到的?这两个示例问题都遵循递归解决方案的形式。通讯链中的每个人都在越来越小的原始数据子集上执行相同的步骤。然而,需要注意的是,这些问题根本不涉及递归。
在第一个问题中,每个铁路员工都会向线路下方的下一个车站提出请求,并在满足该请求时,下一个员工会遵循与上一个员工相同的步骤。但是,请求的文字中没有任何内容要求员工遵循这些特定的步骤。例如,当 Art 使用方法 2 给 Belinda 打电话时,他要求她从她的车站数到线路末端的鹦鹉总数。他没有规定发现这个总数的方法。如果他考虑过这个问题,他可能会意识到 Belinda 必须遵循与他本人相同的步骤,但他不必考虑这一点。为了完成他的任务,Art 需要的只是 Belinda 提供他问问题的正确答案。
同样,在第二个问题中,管理链中的每个员工都将尽可能多的工作转交给下属。例如,助理经理可能很了解初级经理,并期望初级经理将所有文件(除了一个)交给实习生。然而,助理经理没有理由关心初级经理是否处理了所有剩余的文件或将其中一些转交给下属。助理经理只关心初级经理是否给出了正确的答案。因为助理经理不会重复初级经理的工作,助理经理只是假设初级经理返回的结果是正确的,并使用这些数据来解决助理经理从副经理那里接收到的整体任务。
在这两个问题中,当员工向其他员工提出请求时,他们关心的是什么,而不是如何。一个问题被传递;一个答案被接收。这就是大递归思想:如果你在编码中遵循某些约定,你可以假装没有发生递归。你甚至可以使用一个简单的技巧(如下所示)从迭代实现转换为递归实现,而不必明确考虑递归实际上是如何解决问题的。随着时间的推移,你将发展出对递归解决方案如何工作的直观理解,但在这种直觉形成之前,你可以编写递归实现并对你的代码有信心。
让我们通过一个代码示例来将这个概念付诸实践。
问题:计算整数数组的和
编写一个递归函数,该函数接受一个整数数组和数组的大小作为参数。该函数返回数组中整数的和。
你可能首先想到这个问题通过迭代方式解决是微不足道的。确实,让我们从这个问题的迭代解决方案开始:
int iterativeArraySum(int integers[], int size) {
int sum = 0;
for (int i = 0; i < size; i++) {
sum += integers[i];
}
return sum;
}
你在第三章中看到了非常类似的代码,所以这个函数应该很容易理解。下一步是编写介于迭代解决方案和最终所需的递归解决方案之间的代码。我们将保留迭代函数,并添加一个我们将称之为“调度器”的第二个函数。调度器将大部分工作交给之前编写的迭代函数,并使用这些信息来解决整体问题。要编写调度器,我们必须遵循两个规则:
-
调度器必须完全处理最简单的情况,而不调用迭代函数。
-
调度器在调用迭代函数时,必须传递一个更小的问题版本。
在将第一条规则应用于这个问题时,我们必须决定最简单的情况是什么。如果size为 0,那么函数在概念上已经接收了一个“空”数组,其总和为 0。也有人可能会争辩说,最简单的情况应该是size为 1。在这种情况下,逻辑数组中只有一个数字,我们可以将其作为总和返回。这两种解释都可以工作,但选择第一种允许函数处理一个特殊情况。请注意,原始的迭代函数在size为零时不会失败,因此最好保持这种灵活性。
要将第二条规则应用于这个问题,我们必须找出一种方法,将问题的较小版本从调度器传递给迭代函数。没有简单的方法可以传递较小的数组,但我们可以轻松地传递较小的size值。如果调度器将size的值设为 10,那么函数被要求计算数组中 10 个值的总和。如果调度器将size的值传递为 9 给迭代函数,它请求的是数组中前 9 个值的总和。调度器然后将数组中剩余的一个值(第 10 个)的值加起来,以计算所有 10 个值的总和。请注意,在调用迭代函数时减少 1 可以最大化迭代函数的工作量,从而最小化调度器的工作量。这始终是期望的方法——就像 DelegateCorp 的管理者一样,调度器函数避免尽可能多的工作。
将这些想法结合起来,这里是一个针对这个问题的调度器函数:
int arraySumDelegate(int integers[], int size) {
if (size == 0) return 0;
int lastNumber = integers[size - 1];
int allButLastSum = iterativeArraySum(integers, size - 1);
return lastNumber + allButLastSum;
}
第一条语句强制执行调度器的第一条规则:它检查一个简单的情况并完全处理它,在这种情况下,通过返回 0 ![http://atomoreilly.com/source/no_starch_images/1273182.png]。否则,控制权传递到剩余的代码,该代码强制执行第二条规则。数组中的最后一个数字存储在一个名为lastNumber的局部变量中 ![http://atomoreilly.com/source/no_starch_images/1273191.png],然后通过调用迭代函数计算数组中所有其他值的总和 ![http://atomoreilly.com/source/no_starch_images/1273193.png]。这个结果存储在另一个局部变量allButLastSum中,最后函数返回这两个局部变量的总和 ![http://atomoreilly.com/source/no_starch_images/1273195.png]。
如果我们正确地创建了一个调度器函数,那么我们已经有效地创建了一个递归解决方案。这就是大递归思想在起作用。要将这个迭代解决方案转换为递归解决方案,只需要再进行一个简单步骤:让委托函数在之前调用迭代函数的地方调用自己。然后我们可以完全删除迭代函数。
int arraySumRecursive(int integers[], int size) {
if (size == 0) return 0;
int lastNumber = integers[size - 1];
int allButLastSum = arraySumRecursive(integers, size - 1);
return lastNumber + allButLastSum;
}
之前的代码只做了两项更改。函数的名称已被更改,以更好地描述其新的形式
,并且函数现在在之前调用迭代函数
的地方调用自身。两个函数 arraySumDelegate 和 arraySumRecursive 的逻辑是相同的。每个函数都会检查一个已知和的简单情况——在这种情况下,大小为 0 的数组其和为 0。否则,每个函数通过调用一个函数来计算数组中所有值的和(除了最后一个值)。最后,每个函数将最后一个值加到返回的总和中。唯一的区别是,函数的第一个版本调用另一个函数,而递归版本调用自身。BRI 告诉我们,如果我们遵循上述编写派发器的规则,我们可以忽略这种区别。
您不需要字面地遵循上述所有步骤来遵循 BRI。特别是,您通常不会在实现递归解决方案之前实现迭代解决方案。将迭代函数作为垫脚石是额外的工作,最终会被丢弃。此外,递归最适合应用于迭代解决方案困难的情况,如后文所述。然而,您可以在不实际编写迭代解决方案的情况下遵循 BRI 的提纲。关键是把递归调用看作是对另一个函数的调用,而不考虑该函数的内部细节。这样,您就从递归解决方案中移除了递归逻辑的复杂性。
常见错误
如上所示,使用正确的方法,递归解决方案通常很容易编写。但同样容易得出一个错误的递归实现或一个“工作”但笨拙的递归解决方案。递归实现中的大多数问题都源于两个基本错误:对问题思考过度或在没有明确计划的情况下开始实现。
对递归问题思考过度是新手程序员常见的错误,因为有限的经验和对递归的不自信使他们认为问题比实际情况更难。过度思考产生的代码可以通过其过于细致的外观来识别。例如,一个递归函数可能有几个只需要一个的特殊情况。
过早开始实现可能导致过于复杂的“鲁布·戈尔巴乔夫”代码,其中未预见的交互导致了对原始代码的修复。
让我们看看一些具体的错误以及如何避免它们。
参数过多
如前所述,头递归技术可以减少传递给递归调用的数据量,而尾递归技术可能会导致向递归调用传递额外的数据。程序员常常陷入尾递归模式,因为他们过度思考并且过早开始实现。
考虑我们递归计算整数数组总和的问题。编写这个问题的迭代解决方案时,程序员知道需要一个“运行总和”变量(在提供的迭代解决方案中,我称这个变量为sum),并且数组将从第一个元素开始求和。考虑到递归解决方案,程序员自然会想象一个最直接反映迭代解决方案的实现,有一个运行总和变量,第一个递归调用处理数组的第一个元素。然而,这种方法要求递归函数传递运行总和和下一个递归调用应该开始处理的位置。这样的解决方案看起来像这样:
int arraySumRecursiveExtraParams(int integers[], int size, int sum,
int currentIndex) {
if (currentIndex == size) return sum;
sum += integers[currentIndex];
return arraySumRecursiveExtraParameters(integers, size, sum, currentIndex + 1);
}
这段代码与其他递归版本一样短,但由于额外的参数sum
和currentIndex
,语义上要复杂得多。从客户端代码的角度来看,这些额外的参数是没有意义的,并且调用时总是为零,如这个示例所示:
int a[10] = {20, 3, 5, 22, 7, 9, 14, 17, 4, 9};
int total = arraySumRecursiveExtraParameters(a, 10, 0, 0);
使用包装函数可以避免这个问题,正如下一节所描述的,但由于我们无法完全消除这些参数,这并不是最佳解决方案。这个问题的迭代函数和原始递归函数回答了这样一个问题:具有这么多元素的数组的总和是多少?相比之下,这个第二个递归函数被问到的是,如果数组有这么多元素,我们从这个特定元素开始,这是所有先前元素的总和?
通过在选择递归函数参数之前考虑“参数过多”的问题,可以避免这个问题。换句话说,强迫自己使用如果解决方案是迭代的,你将使用的相同参数列表。如果你使用完整的 BRI 过程并且首先实际编写迭代函数,你将自动避免这个问题。如果你跳过正式使用整个过程,尽管如此,如果你根据你期望的迭代函数编写参数列表,你仍然可以在概念上使用这个想法。
全局变量
避免过多的参数有时会让程序员犯另一个错误:使用全局变量从一个递归调用传递数据到另一个递归调用。虽然出于性能原因有时是允许的,但使用全局变量通常是一种糟糕的编程实践。在可能的情况下,递归函数中应始终避免使用全局变量。让我们看看一个具体的问题,看看程序员是如何说服自己犯这个错误的。假设我们被要求编写一个递归函数,用来计算整数数组中出现的零的数量。这是一个简单的问题,可以使用迭代来解决:
int zeroCountIterative(int numbers[], int size) {
int sum = 0;
int count = 0;
for (int i = 0; i < size; i++) {
if (numbers[i] == 0) count ++;
}
return count;
}
这段代码的逻辑很简单。我们只是从数组的第一个位置遍历到最后一个位置,在遍历的过程中计数零的数量,并使用一个局部变量count
作为跟踪器。如果我们写递归函数时心里有这样一个函数,我们可能会认为在那个版本中也需要一个跟踪变量。但我们不能简单地在递归版本中声明count为局部变量,因为那样的话,它就会在每次递归调用中成为一个新的变量。因此,我们可能会倾向于将其声明为全局变量:
int count;
int zeroCountRecursive(int numbers[], int size) {
if (size == 0) return count;
if (numbers[size - 1] == 0) count++;
zeroCountRecursive(numbers, size - 1);
}
这段代码能正常工作,但全局变量完全是多余的,并且导致了全局变量通常引起的问题,比如可读性差和代码维护更困难。一些程序员可能会尝试通过将变量设置为局部但静态的方式来减轻这个问题,但这样做:
int zeroCountStatic(int numbers[], int size) {
static int count = 0;
if (size == 0) return count;
if (numbers[size - 1] == 0) count++;
zeroCountStatic(numbers, size - 1);
}
在 C++中,声明为静态的局部变量会保留其值,从一次函数调用到下一次;因此,局部静态变量count
会与上一个版本中的全局变量表现相同。那么问题是什么?变量的初始化为零
只发生在函数第一次被调用时。这对于static声明有任何用途是必要的,但这意味着函数只有在第一次被调用时才会返回正确答案。如果这个函数被调用两次——第一次是一个有三个零的数组,然后是一个有五个零的数组——由于count会从上次停止的地方开始,函数会对第二个数组返回八个作为答案。
在这种情况下,避免使用全局变量的解决方案是使用 BRI(尾递归改进)。我们可以假设一个size值较小的递归调用将返回正确的结果,并从那里计算出整个数组的正确值。这将导致一个头递归解决方案:
int zeroCountRecursive(int numbers[], int size) {
if (size == 0) return 0;
int count = zeroCountRecursive(numbers, size - 1);
if (numbers[size - 1] == 0) count++;
return count;
}
在这个函数中,我们仍然有一个局部变量count
,但在这里没有尝试从一次调用保持其值到下一次调用。相反,它存储了我们的递归调用的返回值;在返回之前,我们可以选择性地增加变量
。
将递归应用于动态数据结构
递归通常应用于动态结构,如链表、树和图。结构越复杂,代码从递归解决方案中受益越多。处理复杂结构通常就像在迷宫中找到出路一样,递归允许我们在处理过程中回溯到之前的步骤。
递归与链表
然而,让我们从最基本的动态结构开始,即链表。在本节讨论中,让我们假设我们有一个最简单的节点结构用于我们的链表,仅有一个int用于数据。以下是我们的类型声明:
struct listNnode {
int data;
listNode * next;
};
typedef listNode * listPtr;
将 BRI 应用于单链表遵循相同的一般概述,无论具体任务如何。递归要求我们分解问题,以便能够将原始问题的简化版本传递给递归调用。将单链表分解只有一个实际的方法:列表中的第一个节点和其余的列表。
在图 6-5 中,我们看到一个示例列表被分为不等的部分:首节点和所有其他节点。从概念上讲,我们可以将“其余的”原始列表视为其自身的列表,从原始列表中的第二个节点开始。正是这种观点使得递归能够顺利工作。

图 6-5. 将列表分为首节点和“其余列表”
再次强调,我们不需要想象递归的所有步骤来使递归工作。从编写递归函数处理链表的人的角度来看,它可以被概念化为第一个节点,我们必须处理它,以及其余的列表,我们不需要关心,因此不关心。这种态度在图 6-6 中有所体现。

图 6-6. 程序员使用递归时应该想象链表的样子:一个第一个节点和链表的其余部分作为一个需要传递给递归调用的模糊形状
在工作分配固定的情况下,我们可以这样说,单链表的递归处理将按照以下一般计划进行。给定一个链表 L 和一个问题 Q:
-
如果 L 是最小的,我们直接分配一个默认值。否则……
-
使用递归调用为“剩余”的链表 L(从 L 的第二个节点开始的链表)对 Q 给出答案。
-
检查 L 的第一个节点的值。
-
使用前两个步骤的结果为整个 L 对 Q 给出答案。
如你所见,这只是在将链表拆分时的实际限制下对 BRI 的直接应用。现在让我们将这个蓝图应用到具体的问题上。
问题:在单链表中计数负数
编写一个递归函数,该函数接受一个整数类型的数据的单链表。该函数返回链表中负数的计数。
我们想要回答的问题是,链表中有多少个负数?因此,我们的计划可以表述为:
-
如果链表没有节点,则默认计数为 0。否则……
-
使用递归调用来计数“剩余”的列表中有多少个负数。
-
检查链表第一个节点的值是否为负。
-
使用前两个步骤的结果来确定整个链表中有多少个负数。
这里有一个直接从这个计划中得出的函数实现:
int countNegative(listPtr head) {
if (head == NULL) return 0;
int listCount = countNegative(head->next);
if (head->data < 0) listCount++;
return listCount;
}
注意,这段代码遵循与之前示例相同的原理。它将“向后”计数负数,从列表的末尾到前面。此外,注意代码使用了头递归技术;我们在处理“剩余”的列表之前先处理第一个节点。和之前一样,这允许我们避免在递归调用中传递额外的数据或使用全局变量。
还要注意,如何将链表规则 1,“如果列表 L 是最小的”,在特定问题的具体实现中解释为“如果列表没有节点”。这是因为说一个没有节点的列表有零个负值是有意义的。然而,在某些情况下,对于没有节点的列表,我们的问题 Q 可能没有有意义的答案,最小的情况是只有一个节点的列表。假设我们的问题是,这个列表中最大的数是什么?对于没有值的列表,这个问题是无法回答的。如果你不明白为什么,假设你是一名小学教师,你的班级碰巧都是女生。如果你的校长问你教室里有多少男生是男童合唱团的成员,你可以简单地回答零,因为你没有男生。如果你的校长要求你指出你班上最高的男生是谁,你无法对这个问题给出有意义的答案——你必须至少有一个男生才能有最高的男生。同样,如果一个关于数据集的问题需要至少一个值才能有意义的回答,最小数据集就是一个项目。然而,你可能仍然希望为“大小为零”的情况返回某些东西,只是为了函数使用的灵活性,以及防止崩溃。
递归与二叉树
我们迄今为止所探讨的所有示例都不超过一次递归调用。然而,更复杂的数据结构可能需要多次递归调用。为了了解这是如何工作的,让我们考虑一种称为二叉树的结构,其中每个节点包含指向其他节点的“左”和“右”链接。以下是我们将使用的类型:
struct treeNode {
int data;
treeNode * left;
treeNode * right;
};
typedef treeNode * treePtr;
因为树中的每个节点都指向两个其他节点,所以递归树处理函数需要两次递归调用。我们将链表概念化为有两个部分:一个第一个节点和列表的其余部分。为了应用递归,我们将树概念化为有三个部分:顶部的节点,称为根节点;从根的左链接到达的所有节点,称为左子树;以及从根的右链接到达的所有节点,称为右子树。这种概念化在图 6-7 中显示。与链表一样,作为递归解决方案的开发者,我们只关注左右子树的存在,而不考虑它们的内部结构。这如图图 6-8 所示。

图 6-7. 一棵被分为根节点和左右子树的二叉树
像往常一样,在递归解决涉及二叉树的问题时,我们希望采用 BRI。我们将进行递归函数调用,并假设它们返回正确的结果,而不必担心递归过程如何解决整体问题。与链表一样,我们将与二叉树的自然划分一起工作。这产生了以下一般计划。为了回答关于树 T 的问题 Q:

图 6-8. 程序员使用递归时应该想象二叉树的样子:一个根节点,其左右子树的结构未知且未考虑
-
如果树 T 的大小是最小的,则直接分配一个默认值。否则……
-
递归调用以回答 T 的左子树的问题 Q。
-
递归调用以回答 T 的右子树的问题 Q。
-
检查 T 的根节点中的值。
-
使用前三个步骤的结果来回答 T 的所有问题 Q。
现在,让我们将一般计划应用于一个具体问题。
问题:在二叉树中找到最大值
编写一个函数,当给定一个每个节点都包含整数的二叉树时,返回树中的最大整数。
将一般计划应用于这个具体问题,结果如下步骤:
-
如果树的根没有子节点,则返回根节点中的值。 -否则……
-
递归调用以找到左子树中的最大值。
-
递归调用以找到右子树中的最大值。
-
检查根节点中的值。
-
返回前三个步骤中值最大的一个。
在这些步骤的指导下,我们可以直接编写解决方案的代码:
int maxValue(treePtr root) {
if (root == NULL) return 0;
if (root->right == NULL && root->left == NULL)
return root->data;
int leftMax = maxValue(root->left);
int rightMax = maxValue(root->right);
int maxNum = root->data;
if (leftMax > maxNum) maxNum = leftMax;
if (rightMax > maxNum) maxNum = rightMax;
return maxNum;
}
注意,这个问题的最小树是一个单独的节点
(尽管为了安全起见也涵盖了空树的情况
)。这是因为我们提出的问题只能用至少一个数据值有意义的回答。考虑如果我们尝试将空树作为基本情况的实际问题。我们可以返回什么值?如果我们返回零,则隐含地要求树中有一些正值;如果树中的所有值都是负值,则零将被错误地返回为树中的最大值。我们可能通过返回可能的最小(最负)整数来解决此问题,但那时我们必须小心地调整代码以适应其他数值类型。通过将单个节点作为基本案例,我们完全避免了这一决定。
代码的其余部分很简单。我们使用递归在左
和右子树
中找到最大值。然后我们使用本书中一直使用的“山丘之王”算法的变体来找到这三个值中的最大值(根节点值、左子树中的最大值、右子树中的最大值)
。
包装函数
在本章前面的示例中,我们只讨论了递归函数本身。然而,在某些情况下,递归函数需要通过第二个函数来“设置”。最常见的情况是我们将递归函数写在类结构内部。这可能导致递归函数所需的参数与类公共方法所需的参数不匹配。由于类通常强制信息隐藏,类客户端代码可能无法访问递归函数所需的数据或类型。这个问题及其解决方案将在下一个示例中展示。
问题:找到二叉树中的叶子数
对于实现二叉树的类,添加一个公开可访问的方法,该方法返回树中的叶子数(没有子节点的节点)。叶子计数应使用递归进行。
在尝试实现这个问题的解决方案之前,让我们先概述一下这个类可能的样子。为了简单起见,我们将只包括类中的相关部分,忽略构造函数、析构函数,甚至允许我们按顺序构建树的那些方法,以便专注于我们的递归方法。
class binaryTree {
public:
int countLeaves();
private:
struct binaryTreeNode {
int data;
binaryTreeNode * left;
binaryTreeNode * right;
};
typedef treeNode * treePtr;
treePtr _root;
};
注意,我们的叶子计数函数不接收任何参数
。从接口角度来看,这是完全正确的。考虑一个对先前构建的 binaryTree 对象 bt 的示例调用:
int numLeaves = bt.countLeaves();
事实上,如果我们询问树有多少叶子,我们能为对象提供什么信息,它可能不知道关于自己的信息?尽管这对于接口来说是正确的,但对于递归实现来说却是完全错误的。如果没有参数,从一次递归调用到下一次递归调用会有什么变化?在这种情况下,除了全局变量之外,没有任何东西可以改变,而全局变量,如前所述,应避免使用。如果没有变化,递归就无法进行或终止。
解决这个问题的方法是首先编写递归函数,将其概念化为类外的函数。换句话说,我们将以与编写在二叉树中查找最大值函数相同的方式编写这个函数,来计算二叉树中的叶子数。我们需要传递的一个参数是我们节点结构的指针。
这为我们提供了另一个机会来应用 BRI。在这种情况下,问题 Q 是什么?它是,树中有多少个叶子节点?将递归处理二叉树的一般计划应用于这个具体问题,结果如下:
-
如果树的根节点没有子节点,那么树总共只有一个节点。根据定义,这个节点是一个叶子节点,因此返回 1。否则 ...
-
对左子树进行递归调用以计数叶子。
-
对右子树进行递归调用以计数叶子。
-
在这种情况下,没有必要检查根节点,因为我们到达这一步时,根节点不可能是叶子节点。所以 ...
-
返回步骤 2 和 3 的总和。
将此计划转换为代码如下:
struct binaryTreeNode {
int data;
treeNode * left;
treeNode * right;
};
typedef binaryTreeNode * treePtr;
int countLeaves(treePtr rootPtr) {
if (rootPtr == NULL) return 0;
if (rootPtr->right == NULL && rootPtr->left == NULL)
return 1;
int leftCount = countLeaves(rootPtr->left);
int rightCount = countLeaves(rootPtr->right);
return leftCount + rightCount;
}
如您所见,代码是计划的直接翻译。问题是,我们如何从这个独立函数转换到可以在类中使用的东西?这就是粗心的程序员可能会轻易陷入麻烦的地方,认为我们需要使用全局变量或使根指针公开。但我们需要做的不是这样;我们可以将一切保持在内。诀窍是使用包装函数。首先,我们将具有treePtr参数的独立函数放在我们类的私有部分。然后,我们编写一个公共函数,即包装函数,它将“包装”私有函数。因为公共函数可以访问私有数据成员root,它可以将其传递给递归函数,然后像这样将结果返回给客户端:
class binaryTree {
public:
int publicCountLeaves();
private:
struct binaryTreeNode {
int data;
binaryTreeNode * left;
binaryTreeNode * right;
};
typedef binaryTreeNode * treePtr;
treePtr _root;
int privateCountLeaves(treePtr rootPtr);
};
int binaryTree::privateCountLeaves(treePtr rootPtr) {
if (rootPtr == NULL) return 0;
if (rootPtr->right == NULL && rootPtr->left == NULL)
return 1;
int leftCount = privateCountLeaves(rootPtr->left);
int rightCount = privateCountLeaves(rootPtr->right);
return leftCount + rightCount;
}
int binaryTree::publicCountLeaves() {
return privateCountLeaves(_root);
}
虽然 C++允许两个函数具有相同的名称,但为了清晰起见,我使用了不同的名称来区分公共和私有的“计数叶子”函数。privateCountLeaves中的代码
与我们的上一个独立函数countLeaves完全相同。包装函数publicCountLeaves![http://atomoreilly.com/source/nostarch/images/1273191.png]很简单。它调用privateCountLeaves,传递私有的数据成员root,并返回结果![http://atomoreilly.com/source/nostarch/images/1273193.png]。本质上,它“启动”了递归过程。包装函数在编写类内部的递归函数时非常有帮助,但它们可以在任何函数所需的参数列表与调用者期望的参数列表不匹配时使用。
何时选择递归
新程序员常常想知道为什么有人要处理递归。他们可能已经了解到任何程序都可以使用基本控制结构构建,例如选择(if 语句)和迭代(for 和 while 循环)。如果递归比基本控制结构更难应用且不必要,那么递归可能应该被忽略。
有几个反驳。首先,递归编程有助于程序员以递归的方式思考,递归思维在计算机科学的各个领域,如编译器设计中都得到了应用。其次,一些语言由于缺乏一些基本控制结构,因此需要递归。例如,Lisp 语言的纯版本几乎在所有非平凡函数中都需要递归。
然而,问题仍然存在:如果一个程序员已经足够了解递归以至于“理解”了它,并且正在使用像 C++、Java 或 Python 这样的功能齐全的语言,那么递归是否应该被使用?在这些语言中,递归是否有实际用途,或者它仅仅是一种心智练习?
反对递归的论点
为了探讨这个问题,让我们列举递归的缺点。
概念复杂性
对于大多数问题,普通程序员使用递归解决问题更困难。即使你理解了“大递归思想”,在大多数情况下,使用循环编写代码仍然更容易。
性能
函数调用会产生显著的开销。递归涉及大量的函数调用,因此可能会很慢。
空间需求
递归不仅使用了许多函数调用,而且还嵌套了它们。也就是说,你可能会得到一个等待其他调用完成的函数调用长链。每个已经开始但尚未结束的函数调用都会在系统栈上占用额外的空间。
乍一看,这个功能列表构成了对递归作为困难、缓慢和空间浪费的强烈指控。然而,这些论点并不普遍适用。因此,决定递归和迭代之间的最基本规则是,在这些论点不适用时选择递归。
考虑我们用来计算二叉树中叶子节点数量的函数。你将如何在不使用递归的情况下解决这个问题?这是可能的,但你需要一个显式的机制来维护“面包屑路径”,即那些左子节点已被访问但右子节点尚未访问的节点。这些节点需要在某个时候被重新访问,以便我们可以沿着右侧移动。你可能会将这些节点存储在动态结构中,例如栈。为了比较,这里是一个使用 C++标准模板库中的栈类的函数实现:
int binaryTree::stackBasedCountLeaves() {
if (_root == NULL) return 0;
int leafCount = 0;
stack<binaryTreeNode *> nodes;
nodes.push(_root);
while (!nodes.empty()) {
treePtr currentNode = nodes.top();
nodes.pop();
if (currentNode->left == NULL && currentNode->right == NULL)
leafCount++;
else {
if (currentNode->right != NULL) nodes.push(currentNode->right);
if (currentNode->left != NULL) nodes.push(currentNode->left);
}
}
return leafCount;
}
这段代码遵循与原始代码相同的模式,但如果您之前从未使用过栈类,那么一些注释是有序的。栈类的工作方式与我们讨论过的系统栈类似,第三章;您只能在顶部添加和删除项目。请注意,我们可以使用任何没有固定大小的数据结构来执行我们的叶子计数操作。例如,我们可以使用向量,但使用栈最直接地反映了原始代码。当我们声明栈
时,我们指定了我们将存储那里的项目类型。在这种情况下,我们将存储指向我们的 binaryTreeNode 结构的指针
。在这段代码中,我们使用了四个栈类方法。push 方法
将一个项目(在这种情况下是一个节点指针)放置在栈的顶部。empty 方法
告诉我们栈上是否还有任何项目。top 方法
给我们栈顶项目的副本,而 pop 方法
从栈中移除顶部项目。
该代码通过将第一个节点的指针放置在栈上,然后反复从栈中移除节点的指针,检查它是否是叶子节点,如果是,则增加我们的计数器,如果存在子节点,则将指针放置在栈上。因此,栈跟踪我们已发现但尚未处理的节点,就像递归版本中的递归调用链跟踪我们必须重新访问的节点一样。在将这个迭代版本与递归版本进行比较时,我们看到在这种情况下,对递归的标准反对意见并没有多大的作用。首先,这段代码比递归版本更长、更复杂,因此没有理由基于概念复杂性反对递归版本。其次,看看 stackBasedCountLeaves 函数调用了多少次函数——对于每次访问内部节点(即不是叶子节点),这个函数会调用四个函数:每个 empty 和 top 各调用一次,以及两次 push。递归版本为每个内部节点只进行两次递归调用。(注意,我们可以通过在函数中包含栈的逻辑来避免对栈对象的函数调用。然而,这会进一步增加函数的复杂性。)第三,虽然这个迭代版本没有使用额外的系统栈空间,但它明确使用了私有栈。公平地说,这比递归调用的系统栈开销要少,但它仍然是我们正在遍历的二叉树最大深度的系统内存消耗。
由于在这种情况下对递归的反对意见得到了缓解或最小化,因此递归是解决该问题的好选择。更普遍地说,如果一个问题简单到可以通过迭代解决,那么迭代应该是你的首选。当迭代会变得复杂时,应该使用递归。通常这涉及到这里显示的“面包屑路径”机制的必要性。遍历分支结构,如树和图,本质上是递归的。处理线性结构,如数组和链表,通常不需要递归,但也有一些例外。使用迭代首次尝试解决问题永远不会出错。作为一个最后的例子,考虑以下链表问题。
问题:以顺序显示链表
编写一个函数,该函数接收单链表的头指针,其中每个节点的数据类型为整数,并按列表中出现的顺序,每行显示一个整数。
问题:以逆序显示链表
编写一个函数,该函数接收单链表的头指针,其中每个节点的数据类型为整数,并按列表中出现的顺序,每行显示一个整数。
由于这些问题是彼此的镜像,因此自然地假设它们的实现也会是镜像。对于递归实现来说,情况确实如此。使用之前给出的listNode和listPtr类型,以下是解决这两个问题的递归函数:
void displayListForwardsRecursion(listPtr head) {
if (head != NULL) {
cout << head->data << "\n";
displayListForwardsRecursion(head->next);
}
}
void displayListBackwardsRecursion(listPtr head) {
if (head != NULL) {
displayListBackwardsRecursion(head->next);
cout << head->data << "\n";
}
}
如您所见,这些函数中的代码除了if语句中两个语句的顺序不同之外,都是相同的。这造成了所有区别。在第一种情况下,我们在递归调用以显示列表的其余部分之前,显示了第一个节点中的值
。在第二种情况下,我们在显示第一个节点中的值之前,先调用以显示列表的其余部分 ![http://atomoreilly.com/source/no_starch_images/1273193.png]。这导致整体显示顺序是倒序的。
由于这两个函数同样简洁,人们可能会认为递归正确地用于解决这两个问题,但实际上并非如此。为了证明这一点,让我们看看这两个函数的迭代实现。
void displayListForwardsIterative(listPtr head) {
for (listPtr current = head; current != NULL; current = current->next)
cout << current->data << "\n";
}
void displayListBackwardsIterative(listPtr head) {
stack<listPtr> nodes;
for (listPtr current = head; current != NULL; current = current->next)
nodes.push(current);
while (!nodes.empty()) {
nodePtr current = nodes.top();
nodes.pop();
cout << current->data << "\n";
}
}
按顺序显示列表的函数只是一个简单的遍历循环
,就像我们在第四章中看到的那样。然而,按反向顺序显示列表的函数更复杂。它面临着与我们的二叉树问题相同的“面包屑路径”要求。按定义,在链表中按反向顺序显示节点需要回到先前的节点。在单链表中,没有使用列表本身的方法来实现这一点,因此需要另一个结构。在这种情况下,我们需要另一个栈。在声明栈
之后,我们使用 for 循环
将链表中的所有节点推入栈中。因为这是一个栈,每个项目都是添加在先前项目之上,所以链表中的第一个项目将在栈的底部,最后一个项目将在栈的顶部。我们进入一个循环,直到栈为空
,重复获取栈顶节点的指针
,从栈中移除该节点指针
,然后显示引用节点中的数据
。因为栈顶的数据是链表中的最后一个数据,这会产生按反向顺序显示链表数据的效果。
与前面展示的迭代二叉树函数一样,可以不使用栈(在函数内部构建一个与原始列表相反的第二个列表)来编写这个函数。然而,无论如何也无法使第二个函数像第一个那样简单,或者避免实际上遍历两个结构而不是一个。比较递归和迭代实现,很容易看出迭代“正向”函数非常简单,使用递归实际上没有实际优势,而且存在几个实际缺点。相比之下,递归“反向”函数比迭代版本简单,应该期望其性能与迭代版本相当。因此,“反向”函数是递归的合理使用,而“正向”函数,虽然是一个好的递归编程练习,但不是递归的实用应用。
练习
和往常一样,尝试本章中提出的思想是强制性的!
-
编写一个函数来计算整数数组中正数的总和。首先,使用迭代方法解决这个问题。然后,使用本章中展示的技术,将你的迭代函数转换为递归函数。
-
考虑一个表示二进制字符串的数组,其中每个元素的数据值是 0 或 1。编写一个
bool函数来确定二进制字符串是否具有奇校验(奇数个 1 位)。提示:记住递归函数将返回 true(奇数)或 false(偶数),而不是 1 位的计数。首先使用迭代解决问题,然后使用递归。 -
编写一个函数,该函数接收一个整数数组和“目标”数字,并返回目标在数组中的出现次数。首先使用迭代解决问题,然后使用递归。
-
设计自己的:找到一个你已经解决过或在你当前技能水平下很容易解决的问题,使用递归来解决该问题(或再次解决)。
-
再次使用链表而不是数组解决练习 6-1。
-
再次解决练习 6-2,使用链表而不是数组。
-
再次使用链表而不是数组解决练习 6-3。
-
设计自己的:尝试发现一个使用迭代难以解决但可以直接使用递归解决的问题,关于链表处理。
-
编程中的某些单词有不止一个常见的含义。在第四章中,我们学习了堆,我们从其中使用
new分配内存。术语堆也描述了一种二叉树,其中每个节点的值都高于其左子树或右子树中的任何值。编写一个递归函数来确定一个二叉树是否是堆。 -
二叉搜索树 是一种二叉树,其中每个节点的值都大于该节点左子树中的任何值,但小于该节点右子树中的任何值。编写一个递归函数来确定一个二叉树是否是二叉搜索树。
-
编写一个递归函数,该函数接收一个二叉搜索树的根指针和一个要插入的新值,并创建一个具有新值的新节点,将其放置在正确的位置以保持二叉搜索树的结构。提示:考虑将根指针参数作为引用参数。
-
设计自己的:考虑你可以对一组数值提出的基本统计问题,例如平均值、中位数、众数等。尝试编写递归函数来计算整数的二叉树中的这些统计数据。有些比其他更容易编写。为什么?
第七章. 使用代码复用来解决问题

本章与之前的内容有很大不同。在之前的章节中,我强调了找到自己解决问题的方法的重要性。毕竟,这本书的主题就是:编写原创的编程问题解决方案。即使在之前的章节中,我们也讨论了如何从你之前写过的内容中学习,这就是为什么你应该保留你写的所有代码以供将来参考。在本章中,我们将更进一步,讨论如何使用其他程序员的代码和想法来解决我们的问题。
如果你记得这本书是如何开始的,这个话题可能看起来有些奇怪。一开始,我谈到了试图通过修改别人的代码来解决复杂问题是一个错误。这不仅成功的可能性很低,即使成功了,也不会给你提供任何学习经验。而且,如果你一直这样做,你实际上永远不会成为一个程序员,在软件开发中作用有限。话虽如此,一旦任何编程问题达到一个可尊敬的大小,期望程序员完全从头开始开发解决方案是不合理的。这是对程序员时间的低效利用,并且过于依赖程序员对所有事物的精通。此外,它更有可能导致一个有缺陷的或难以维护的程序。
优秀复用与糟糕复用
因此,我们必须区分优秀复用和糟糕复用,优秀复用使我们能够编写更好的程序,更快地编写它们,而糟糕复用可能使我们能够模仿程序员一段时间,但最终会导致代码和程序开发质量低下。表 7-1 总结了这些差异。左列显示了优秀复用的特性,右列显示了糟糕复用的特性。在考虑是否尝试代码复用时,问问自己你更有可能产生左列还是右列的特性。
表 7-1. 优秀与糟糕的代码复用
| 优秀复用 | 糟糕复用 |
|---|---|
| 按照蓝图进行 | 复制别人的工作 |
| 放大并扩展你的能力 | 伪造你的能力 |
| 帮助你学习 | 帮助你避免学习 |
| 在短期和长期内节省时间 | 可能短期节省时间但可能延长长期时间 |
| 导致一个可工作的程序 | 可能导致一个无论如何都不工作的程序 |
重要的是要注意,良好重用和不良重用的区别不在于你重用什么样的代码或你如何重用代码,而在于你对所借代码和概念的关系。有一次,在文学课上写学期论文时,我发现我在之前的一门课程中学到的东西与我的论文主题相关,所以我将其包括在内。当我将论文草稿提交给教授时,她告诉我需要为那条信息提供引用。感到沮丧的我问我的教授在什么时候我可以在论文中简单地陈述我的知识而不需要提供参考文献。她的回答是,当我成为别人引用的对象时,我就可以停止为头脑中的东西引用他人。
在编程术语中,当你自己根据阅读某人描述的一般概念编写代码或使用你自己可以编写的代码时,发生良好的重用。在本章中,我们将讨论如何拥有编码概念,以确保你的重用能帮助你成为一个更好的程序员,而不是一个更懒惰的程序员。
让我也提醒大家注意表 7-1 中的最后一行。尝试进行不良重用往往完全失败。这并不令人惊讶,因为它涉及到一个程序员使用他或她实际上并不理解的代码。在某些情况下,借来的代码最初可能能工作,但当程序员试图修改或扩展借来的代码库时,缺乏深入理解排除了有组织方法的可能性。然后程序员只能求助于乱试和试错,从而违反了我们最基本和最重要的通用问题解决规则:始终要有计划。
组件基础回顾
既然我们已经知道了我们追求的重用类型,让我们来分类代码可以重用的不同方式。在这本书中,我将使用术语组件来指代任何可以被另一个程序员重用来帮助解决编程问题的由一个程序员创建的东西。组件可以存在于从抽象到具体、从想法到完全实现的代码的连续体上的任何地方。如果我们把解决编程问题比作处理一个手艺人项目,我们解决问题的技术就像工具,组件就像专用零件。以下每个组件都是重用程序员先前工作的不同方式。
代码块
代码块就是那样:从程序列表中复制到另一个程序列表的代码块。更通俗地说,我们会称这为复制粘贴工作。这是组件使用的最低形式,通常是不良的重用,并带来所有这些问题。当然,如果你复制的代码是你自己的,实际上并没有造成真正的伤害,除了你可能考虑将现有代码打包成类库或其他结构,以便以更干净、更易于维护的方式重用。
算法
算法是一种编程配方;它是一种实现目标的具体方法,可以用普通语言或如图形流程图那样直观地表达。例如,在第三章中,我们讨论了数组的排序操作和实现这种排序的不同方法。排序数组的一种方法是插入排序算法,我展示了算法的一个示例实现。需要注意的是,给出的代码是插入排序的一个实现,但插入排序本身是算法——即排序数组的方式——而不是特定的代码。插入排序通过重复取数组中下一个未排序的值,并将排序好的值“向上”移动一个位置,直到我们在正确位置为当前插入的值腾出空间。任何使用这种方法对数组进行排序的代码都是插入排序。
算法是高级形式的重用,通常导致良好的重用特性。算法本质上只是想法,而你,作为程序员,必须实现这些想法,调用你的编程技能和对算法本身的深入理解。你将常用到的算法已经被充分研究,在各种情况下都有可预测的性能。有了算法作为蓝图,你可以对代码的正确性和性能有信心。
尽管基于算法编写代码有一些潜在缺点。当你使用算法时,你从概念层面开始。因此,你需要一段很长的路程才能完成该程序部分的代码。算法确实可以节省时间,因为问题解决方面基本上已经完成,但根据算法及其在编程中的特定应用,算法的实现可能并不简单。
模式
在编程中,模式(或设计模式)是特定编程技术的模板。这个概念与算法相关,但可以区分。算法像是解决特定问题的食谱,而模式是在特定编程场景中使用的通用技术。模式解决的问题通常在代码的结构内部。例如,在第六章第六章中,我们讨论了在链表类中递归函数提出的问题:递归函数需要一个指向列表第一个节点的“头”指针作为参数,但该数据需要保持私有。解决方案是创建一个包装器,一个将一个参数列表适配到另一个的函数。包装器技术是一种设计模式。我们可以使用这个模式来解决类中递归函数的问题,但它也可以以其他方式使用。例如,假设我们有一个linkedList类,它允许在任何位置插入或删除项目,但我们需要的只是一个栈类——即只允许在一边插入和删除的列表。我们可以创建一个新的stack类,它有公共方法用于典型的栈操作,如push和pop。这些方法将只调用我们的stack类的私有数据成员linkedList对象的成员函数。这样,我们就可以重用链表类的功能,同时提供栈类的接口。
与算法一样,模式是组件使用的高级形式,学习模式是构建你的编程工具箱的绝佳方式。然而,模式也共享一些算法的潜在问题。知道存在一个模式并不意味着你知道如何在为编程解决方案选择的特定语言中实现该模式,并且模式通常很难正确实现或以最佳性能实现。例如,有一个称为单例的模式,这是一个只允许创建一个类对象的类。创建一个单例类很简单,但创建一个直到实际需要时才创建一个允许的实例对象的单例类可能会出人意料地困难,而且最佳技术可能因语言而异。
抽象数据类型
正如我们在 第五章 中讨论的,抽象数据类型 是通过其操作定义的类型,而不是通过这些操作是如何实现的。我们在本书中多次使用的栈类型就是一个很好的例子。抽象数据类型就像模式一样,它们定义了操作的效果,但并不具体定义这些操作是如何实现的。然而,与算法一样,这些操作有众所周知的实现技术。例如,栈可以使用任何数量的底层数据结构来实现,如链表或数组。一旦我们决定使用特定的数据结构,实现决策有时已经确定。假设我们使用链表实现了栈,但无法绕过现有的链表,我们必须编写自己的列表代码。由于栈是后进先出结构,我们只在链表的一端插入和删除项是有意义的。此外,只在列表的前端插入和删除是有意义的。理论上,你可以在末端插入和删除,但这会导致每次插入或删除时对整个列表的低效遍历。为了避免这些遍历,需要一个双链表,并有一个指向列表最后一个节点的单独指针。在列表的开始处插入和删除允许最简单、最有效的实现,因此链表实现的栈几乎都是按照相同的方式进行。
因此,尽管 抽象数据类型 中的 abstract 意味着类型是概念性的且没有实现细节,但在实践中,当你选择在代码中实现一个抽象数据类型时,你不会从头开始考虑实现。相反,你将会有该类型的现有实现作为指导。
库
在编程中,库 是一系列相关代码的集合。库通常包括编译后的代码以及所需的源代码声明。库可以包括独立的函数、类、类型声明或代码中可以出现的任何其他内容。在 C++ 中,最明显的例子是标准库。我们在前几章中使用的 strcmp 函数来自旧的 C 库 cstring,容器类如 vector 来自 C++ 标准模板库,甚至我们所有基于指针的代码中使用的 NULL 也不是 C++ 语言本身的一部分,而是在库头文件 stdlib.h 中定义的。由于库中包含了许多核心功能,因此在现代编程中,库的使用是不可避免的。
通常,库的使用是良好的代码重用。代码被包含在库中,因为它提供了在多种程序中普遍需要的功能——库代码帮助程序员避免“重新发明轮子”。然而,作为正在发展的程序员,当我们使用库代码时,我们必须努力从经验中学习,而不仅仅是走捷径。我们将在本章后面看到这个例子。
注意,虽然许多库是通用目的的,但其他库被设计为应用程序编程接口(API),为高级语言程序员提供了一个简化或更连贯的底层平台视图。例如,Java 语言包括一个名为 JDBC 的 API,它提供了允许程序以标准方式与关系数据库交互的类。另一个例子是 DirectX,它为 Microsoft Windows 游戏程序员提供了丰富的声音和图形功能。在这两种情况下,库在高级程序和基础级硬件和软件之间提供了一个连接——在 JDBC 的情况下是数据库引擎,在 DirectX 的情况下是图形和声音硬件。此外,在这两种情况下,代码重用不仅很好——在所有实际意义上,它是必需的。Java 数据库程序员或为 Windows 编写 C++代码的图形程序员将使用 API——如果不是这些 API,那么就是其他东西,但程序员不会从头开始构建一个新的平台连接。
构建组件知识
组件非常有用,程序员尽可能地在可能的情况下使用它们。然而,为了使用组件来帮助解决问题,程序员必须知道它的存在。根据你如何精细地定义它们,可用的组件可能多达数百甚至数千,而初学者程序员将只接触到其中的一小部分。因此,优秀的程序员必须始终不断地将组件知识添加到他们的工具箱中。这种知识积累以两种不同的方式发生:程序员可以明确为学习新组件分配时间,将其作为一个一般任务,或者程序员可以寻找组件来解决特定问题。我们将第一种方法称为探索性学习,第二种方法称为按需学习。要成为一名程序员,你需要采用这两种方法。一旦你掌握了所选编程语言的语法,发现新的组件就是你作为程序员自我提升的主要方式之一。
探索性学习
让我们从探索性学习的例子开始。假设我们想要了解更多关于设计模式的知识。幸运的是,关于哪些设计模式最有用或最常使用,人们普遍达成共识,因此我们可以从任何数量的关于这个主题的资源开始,并且可以相当确信我们没有错过任何重要的内容。通过简单地找到设计模式列表并研究它,我们会受益匪浅,但如果我们实现了其中的一些模式,我们会获得更多的洞察力。
在典型的列表中,我们会发现一种称为 策略 或 策略模式 的模式。这是允许算法或算法的一部分在运行时被选择的想法。在最纯粹的形式,即策略形式,这种模式允许改变函数或方法的工作方式,但不会改变结果。例如,一个类的方法,它对其数据进行排序,或者涉及排序数据,可能允许选择排序方法(例如快速排序或插入排序)。在任何情况下结果都是相同的——排序后的数据——但允许客户端选择排序方法可能会提供性能优势。例如,客户端可以避免在具有高重复率的数据上使用快速排序。在策略形式中,客户端的选择会影响结果。例如,假设一个类代表一副扑克牌。排序策略可以确定是否将 A 视为高牌(高于国王)或低牌(低于 2)。
将学习付诸实践
阅读那一段,你现在知道策略/策略模式是什么,但你还没有将其内化为自己的知识。这就像是浏览五金店的工具和真正购买并使用一个工具之间的区别。所以,让我们把这个设计模式从架子上拿下来,并付诸实践。尝试新技术的最快方式是将它融入到你已经写过的代码中。让我们创建一个问题,这个问题可以用这个模式来解决,并且是基于我们已经写过的代码构建的。
问题:第一位学生
在某所学校,每个班级都有一个指定的“第一位学生”,如果老师必须离开教室,这位学生负责维持课堂秩序。最初,这个头衔是授予成绩最高的学生,但现在一些老师认为第一位学生应该是资历最老的学生,这意味着学生 ID 号码最低,因为它们是按顺序分配的。另一部分老师认为第一位学生的传统很愚蠢,并打算通过简单地选择在字母顺序班级名单中名字排在第一位的学生来抗议。我们的任务是修改学生集合类,添加一个方法来从集合中检索第一位学生,同时满足各个教师群体的选择标准。
如您所见,这个问题将采用策略模式的格式。我们希望返回第一个学生的方法能够根据选定的标准返回不同的学生。为了在 C++中实现这一点,我们将使用函数指针。我们曾在第三章中简要地看到这个概念在qsort函数中的应用,该函数接受一个指向比较要排序的数组中两个元素的函数的指针。我们在这里将做类似的事情;我们将有一组比较函数,这些函数接受我们的studentRecord对象中的两个,并通过对学生的成绩、ID 号码或姓名进行比较来确定第一个学生是否比第二个学生“更好”。
要开始,我们需要为我们的比较函数定义一个类型:
typedef bool (* firstStudentPolicy)(studentRecord r1, studentRecord r2);
这个声明创建了一个名为firstStudentPolicy的类型,它是一个返回bool并接受两个studentRecord类型参数的函数指针。* firstStudentPolicy
周围的括号是必要的,以防止声明被解释为返回bool指针的函数。有了这个声明,我们可以创建我们的三个策略函数:
bool higherGrade(studentRecord r1, studentRecord r2) {
return r1.grade() > r2.grade();
}
bool lowerStudentNumber(studentRecord r1, studentRecord r2) {
return r1.studentID() < r2.studentID();
}
bool nameComesFirst(studentRecord r1, studentRecord r2) {
return strcmp(r1.name().c_str(), r2.name().c_str())
< 0;
}
前两个函数非常简单:higherGrade在第一个记录的成绩更高时返回true,而lowerStudent number 在第一个记录的学生号码更小时返回true。第三个函数nameComesFirst基本上是相同的,但它需要strcmp
库函数,该函数期望两个“C 风格”字符串——即空终止的字符数组而不是string对象。因此,我们必须在两个学生记录的name字符串上调用c_str()
方法。strcmp函数在第一个字符串在字母顺序上位于第二个字符串之前时返回一个负数,因此我们检查返回值是否小于零
。现在我们准备好修改studentCollection类本身:
class studentCollection {
private:
struct studentNode {
studentRecord studentData;
studentNode * next;
};
public:
studentCollection();
˜studentCollection();
studentCollection(const studentCollection ©);
studentCollection& operator=(const studentCollection &rhs);
void addRecord(studentRecord newStudent);
studentRecord recordWithNumber(int IDnum);
void removeRecord(int IDnum);
void setFirstStudentPolicy(firstStudentPolicy f);
studentRecord firstStudent();
private:
firstStudentPolicy _currentPolicy;
typedef studentNode * studentList;
studentList _listHead;
void deleteList(studentList &listPtr);
studentList copiedList(const studentList copy);
};
这是我们在第五章中看到的类声明,新增了三个成员:一个私有数据成员_currentPolicy
,用于存储指向我们的策略函数之一;一个setFirstStudentPolicy
方法来更改此策略;以及firstStudent方法本身
,该方法将根据当前策略返回第一个学生。setFirstStudentPolicy的代码很简单:
void studentCollection::setFirstStudentPolicy(firstStudentPolicy f) {
_currentPolicy = f;
}
我们还需要修改默认构造函数以初始化当前策略:
studentCollection::studentCollection() {
_listHead = NULL;
_currentPolicy = NULL;
}
现在我们准备好编写firstStudent:
studentRecord studentCollection::firstStudent() {
if (_listHead == NULL || _currentPolicy == NULL) {
studentRecord dummyRecord(−1, −1, "");
return dummyRecord;
}
studentNode * loopPtr = _listHead;
studentRecord first = loopPtr->studentData;
loopPtr = loopPtr->next;
while (loopPtr != NULL) {
if (_currentPolicy(loopPtr->studentData, first)) {
first = loopPtr->studentData;
}
loopPtr = loopPtr->next;
}
return first;
}
该方法首先检查特殊情况。如果没有要审查的列表或没有实施的政策 ![http://atomoreilly.com/source/no_starch_images/1273182.png],我们返回一个虚拟记录。否则,我们遍历列表以找到最符合当前政策的学生的记录,使用我们在本书中一直使用的基本搜索技术。我们将列表开头的记录分配给 first ![http://atomoreilly.com/source/no_starch_images/1273191.png],将循环变量从列表中的第二个记录开始 ![http://atomoreilly.com/source/no_starch_images/1273193.png],并开始遍历。在遍历循环内部,对当前政策函数的调用 ![http://atomoreilly.com/source/no_starch_images/1273195.png] 告诉我们,我们目前正在查看的学生是否根据当前标准“优于”我们迄今为止找到的最佳学生。当循环结束时,我们返回“第一个学生” ![http://atomoreilly.com/source/no_starch_images/1273197.png]。
首个学生解决方案分析
使用策略/政策模式解决问题后,我们更有可能识别出可以使用该技术的情况,而不是如果我们只是阅读过一次该技术却从未使用过。我们还可以分析我们的样本问题,开始形成自己对技术价值的看法,包括何时可以正确使用,何时可能是一个错误,或者至少比它值得的麻烦更多。你可能对这个特定模式的一个想法是,它削弱了封装和信息隐藏。例如,如果客户端代码提供政策函数,它需要访问通常保留在类内部的数据类型,在这种情况下,是 studentRecord 类型。(我们将在练习中考虑解决这个问题。)这意味着如果修改该类型,客户端代码可能会出错,我们必须在应用该模式到其他项目之前权衡这种担忧与模式的好处。在前面的章节中,我们讨论了知道何时使用一种技术——或者何时不使用它——与知道如何使用它一样重要。通过检查自己的代码,你可以深入了解这个关键问题。
为了进一步练习,你可以回顾你完成的项目的库,寻找可以使用此技术重构的代码。记住,许多“现实世界”的编程涉及补充或修改现有的代码库,因此这除了提高你对特定组件的技能外,也是这种修改的极好实践。此外,良好的代码重用的一个好处是我们从中学习,这种实践最大化了学习。
需求驱动学习
前一节描述了我们可能称之为“漫步式学习”的内容。虽然这样的旅程对程序员来说很有价值,但还有其他时候我们必须朝着特定的目标前进。如果你正在解决某个特定的问题,尤其是如果你面临任何形式的截止日期,并且你认为某个组件可能对你非常有帮助,你不想在编程的世界中随机漫步并希望偶然发现你需要的东西。相反,你希望尽快找到直接适用于你情况的组件或组件。这听起来非常棘手——当你不知道你具体在找什么时,你怎么能找到你需要的东西呢?考虑以下示例问题:
问题:高效遍历
一个编程项目将使用你的 studentCollection 类。客户端代码需要能够遍历集合中的所有学生。显然,为了保持信息隐藏,客户端代码不能直接访问列表,但有一个要求,遍历必须是高效的。
因为描述中的关键词是 高效,让我们来精确地说明这个案例中这意味着什么。假设我们的 studentCollection 类的一个特定对象有 100 名学生。如果我们能直接访问链表,我们可以编写一个循环来遍历列表,循环 100 次。这是任何列表遍历可能达到的最高效率。任何需要我们循环超过 100 次来确定结果的解决方案都是低效的。
如果没有对效率的要求,我们可能会通过在我们的类中添加一个简单的 recordAt 方法来尝试解决这个问题,该方法将返回集合中特定位置的学生记录,第一个记录编号为 1:
studentRecord studentCollection::recordAt(int position) {
studentNode * loopPtr = _listHead;
int i = 1;
while (loopPtr != NULL && i < position) {
i++;
loopPtr = loopPtr->next;
}
if (loopPtr == NULL) {
studentRecord dummyRecord(−1, −1, "");
return dummyRecord;
} else {
return loopPtr->studentData;
}
}
在这种方法中,我们使用循环
遍历列表,直到我们达到期望的位置或达到列表的末尾。在循环结束时,如果达到列表的末尾,我们创建并返回一个虚拟记录
,或者返回指定位置的记录
。问题是,我们仅仅为了找到一条学生记录而进行遍历。这并不一定是一个完整的遍历,因为我们会在达到期望位置时停止,但无论如何它仍然是一个遍历。假设客户端代码正在尝试计算学生成绩的平均值:
int gradeTotal = 0;
for (int recNum = 1; recNum <= numRecords; recNum++) {
studentRecord temp = sc.recordAt(recNum);
gradeTotal += temp.grade();
}
double average = (double) gradeTotal / numRecords;
对于这段代码,假设sc是一个先前声明并填充的studentCollection,recNum是一个存储记录数的int。假设recNum是 100。如果你只是浏览这段代码,可能会觉得计算平均值只需要通过循环 100 次,但由于每次调用recordAt本身就是一个部分列表遍历,这段代码涉及 100 次遍历,每次遍历在平均情况下都会循环大约 50 次。所以,而不是 100 步,这将很高效,这可能会需要大约 5,000 步,这非常低效。
何时寻找组件
我们现在已经到达了真正的问题。提供客户端对集合成员的访问以进行遍历是容易的;提供这种高效访问则不是。当然,我们可以尝试仅使用我们自己的问题解决能力来解决这个问题,但如果我们能使用组件,我们会更快地找到解决方案。找到可以协助我们解决方案的先前未知的组件的第一步是假设这样的组件确实存在。换句话说,除非你开始寻找,否则你不会找到组件。因此,为了最大限度地发挥组件的益处,你需要留意它们可能有所帮助的情况。当你发现自己卡在问题的某个方面时,尝试以下方法:
-
以通用方式重新表述问题。
-
问问自己:这可能是常见问题吗?
第一步很重要,因为如果我们把问题表述为“允许客户端代码高效地计算封装在类中的记录链表的平均学生成绩”,它听起来像是特定于我们的情况。然而,如果我们把问题表述为“允许客户端代码高效地遍历链表而不提供对列表指针的直接访问”,那么我们开始理解这可能是常见问题。当然,我们可能会问自己,既然程序经常在类中存储链表和其他顺序访问的结构,其他程序员肯定已经找到了允许高效访问结构中每个元素的方法?
寻找组件
既然我们已经同意寻找组件,现在是时候找到我们的组件了。为了使事情更清晰,让我们将原始的编程问题重新表述为一个研究问题:“找到一个我们可以使用的组件,以修改我们的studentCollection类,允许客户端代码高效地遍历内部列表。”我们如何解决这个问题?我们可以从查看任何我们的组件类型开始:模式、算法、抽象数据类型或库。
假设我们从查看标准 C++ 库开始。我们不一定是在寻找一个可以“插入”到我们的解决方案中的类,而是我们可以挖掘一个类似于我们的 studentCollection 类的库类,从中获取灵感。这采用了我们用来解决编程问题的类比策略。如果我们找到一个具有类似问题的类,我们可以借鉴其类似解决方案。我们之前对 C++ 库的了解使我们接触到了其容器类,例如 vector,我们应该寻找最像我们的学生集合类的容器类。如果我们去查阅一个喜欢的 C++ 参考书,无论是书籍还是网络上的网站,并回顾 C++ 容器类,我们会看到有一个名为“序列容器”的 list 类符合要求。list 类是否允许客户端代码高效遍历?是的,它使用一个称为 迭代器 的对象来实现。我们看到列表类提供了 begin 和 end 方法,这些方法生成迭代器,这些迭代器可以引用列表中的特定项,并通过递增来使迭代器引用列表中的下一个对象。如果 integerList 是一个 list<int>,其中填充了整数,并且 iter 是一个 list<int>::iterator,那么我们可以使用以下方式显示列表中的所有整数:
iter = intList.begin();
while (iter != intList.end()) {
cout << *iter << "\n";
iter++;
}
通过使用迭代器,list 类解决了为客户端提供一个机制以高效遍历列表的问题。在这个时候,我们可能会想到将 list 类本身放入我们的 studentCollection 类中,以替换我们自建的链表。然后我们可以为我们的类创建 begin 和 end 方法,这些方法将包装嵌入列表对象中的相同方法,问题就会得到解决。然而,这直接遇到了好与坏的重用问题。一旦我们完全理解了迭代器概念,并且能够在自己的代码中自行实现它,将标准模板库中的现有类插入到我们的代码中将成为一个好的选择——也许是最好的选择。如果我们做不到这一点,使用 list 类就变成了一种捷径,这并不能帮助我们作为程序员成长。当然,有时我们必须利用我们无法复制的组件,但如果我们养成依赖其他程序员解决问题的习惯,我们就有可能永远无法成为问题解决者。
那么,让我们自己实现迭代器。不过,在我们这样做之前,让我们简要地看看我们可能到达同一位置的其他方法。我们开始是在标准模板库中搜索,但我们也可以从其他地方开始。例如,我们可以搜索常见设计模式列表。在“行为模式”标题下,我们会找到迭代器模式,其中客户端可以按顺序访问一组项目,而不暴露集合的底层结构。这正是我们所需要的,但我们只能通过搜索模式列表或从之前对模式的调查中记住它才能找到。我们本可以从抽象数据类型开始搜索,因为通常的列表,尤其是链表,是常见的抽象数据类型。然而,许多关于列表抽象数据类型的讨论和实现都没有将客户端列表遍历视为基本操作,因此迭代器概念从未出现。最后,如果我们从算法领域开始搜索,我们不太可能找到任何有用的东西。算法通常描述的是棘手的代码,而创建迭代器的代码相当简单,正如我们很快就会看到的。在这种情况下,那么,类库是我们到达目的地的最快途径,其次是模式。然而,作为一个一般规则,在搜索有用的组件时,你必须考虑所有组件类型。
应用组件
我们现在知道我们将为我们的studentCollection类制作一个迭代器,但所有list标准库类向我们展示的只是迭代器方法的外部工作方式。如果我们卡在实现上,我们可能会考虑回顾list及其祖先类的源代码,但鉴于阅读大量不熟悉的代码的难度,这是一个最后的手段。相反,让我们只通过思考来解决这个问题。使用之前的代码示例作为指南,我们可以这样说,迭代器由四个核心操作定义:
-
集合类中的一个方法,它提供了一个引用集合中第一个项目的迭代器。在
list类中,这个方法是begin。 -
一种测试迭代器是否已前进到集合中最后一个项目的机制。在上一个示例中,这是
list类中一个名为end的方法,它产生一个特殊的迭代器对象进行测试。 -
迭代器类中的一个方法,它将迭代器移动到引用集合中的下一个项目。在上一个示例中,这是重载的
++运算符。 -
迭代器类中的一个方法,它返回集合中当前引用的项目。在上一个示例中,这是
list类中重载的*(前缀)运算符。
在编写代码方面,这里看起来没有什么困难。这只是把所有东西放在正确的位置的问题。所以,让我们开始吧。根据上面的描述,我们的迭代器,我们将称之为scIterator,需要存储对studentCollection中一个项目的引用,并且需要能够前进到下一个项目。因此,我们的迭代器应该存储一个指向studentNode的指针。这将允许它返回包含在内的studentRecord,以及前进到下一个studentNode。因此,迭代器类的私有部分将包含以下数据成员:
studentCollection::studentNode * current;
立刻,我们遇到了一个问题。studentNode类型是在studentCollection的私有部分声明的,因此上面的行不会工作。我们的第一个想法可能是studentNode不应该被声明为私有,但这不是正确的答案。节点类型本质上是私有的,因为我们不希望随机的客户端代码依赖于节点类型的特定实现,从而创建在修改我们的类时可能会损坏的代码。尽管如此,我们仍然需要允许scIterator访问我们的私有类型。我们通过friend声明来实现这一点。在studentCollection的公共部分,我们添加:
friend class scIterator;
现在scIterator可以访问studentCollection中的私有声明,包括studentNode的声明。我们还可以声明一些构造函数:
scIterator::scIterator() {
current = NULL;
}
scIterator::scIterator(studentCollection::studentNode * initial) {
current = initial;
}
让我们暂时跳转到studentCollection,并编写我们的*begin*方法——一个返回引用我们集合中第一个项目的迭代器的begin方法。按照我在本书中使用的命名方案,这个方法应该有一个名词作为名称,例如firstItemIterator:
scIterator studentCollection::firstItemIterator() {
return scIterator(_listHead);
}
如您所见,我们在这里需要做的只是将链表的头指针放入一个scIterator对象中,并返回它。如果您像我一样,看到指针在这里飞来飞去可能会让您有些紧张,但请注意,scIterator只是会保留对studentCollection列表中一个项目的引用。它不会分配任何自己的内存,因此我们不需要担心深度复制和重载赋值运算符。
让我们回到scIterator并编写我们的其他方法。我们需要一个方法来将迭代器前进到下一个项目,以及一个方法来确定我们是否已经超过了集合的末尾。我们应该同时考虑这两个问题。在前进迭代器时,我们需要知道迭代器在通过列表中的最后一个节点时应该具有什么值。如果我们不进行特殊处理,迭代器会自然地得到NULL的值,所以这将是 easiest value to use。请注意,我们在默认构造函数中已经初始化了我们的迭代器为NULL,所以当我们使用NULL来表示超出末尾时,我们失去了这两种状态之间的任何区别,但就当前问题而言,这不是问题。方法的代码如下:
void scIterator::advance() {
if (current != NULL)
current = current->next;
}
bool scIterator::pastEnd() {
return current == NULL;
}
记住,我们只是使用迭代器概念来解决原始问题。我们并不是试图复制 C++标准模板库迭代器的确切规范,因此我们不需要使用相同的接口。在这种情况下,我并没有重载++运算符,而是有一个名为advance的方法
,它在将指针向前移动到下一个节点之前会检查current指针是否为NULL
。同样,我发现需要创建一个特殊的“结束”迭代器来进行比较很麻烦,所以我只提供了一个名为pastEnd的bool方法
,它用来确定我们是否已经用完了节点。
最后,我们需要一种方法来获取当前引用的studentRecord对象:
studentRecord scIterator::student() {
if (current == NULL) {
studentRecord dummyRecord(−1, −1, "");
return dummyRecord;
} else {
return current->studentData;
}
}
如我们之前所做的那样,为了安全起见,如果我们的指针是NULL,我们将创建并返回一个虚拟记录
。否则,我们返回当前引用的记录
。这样,我们就完成了studentCollection类中迭代器概念的实现。为了清晰起见,以下是scIterator类的完整声明:
class scIterator {
public:
scIterator();
scIterator(studentCollection::studentNode * initial);
void advance();
bool pastEnd();
studentRecord student();
private:
studentCollection::studentNode * current;
};
代码全部就绪后,我们可以通过一个样本遍历来测试我们的代码。让我们实现平均成绩计算以供比较:
scIterator iter;
int gradeTotal = 0;
int numRecords = 0;
iter = sc.firstItemIterator();
while (!iter.pastEnd()) {
numRecords++;
gradeTotal += iter.student().grade();
iter.advance();
}
double average = (double) gradeTotal / numRecords;
这个列表使用了我们所有的迭代器相关方法,因此它是我们代码的一个很好的测试。我们调用firstItemIterator来初始化我们的scIterator对象
。我们调用pastEnd作为我们的循环终止测试
。我们调用迭代器对象的student方法来获取当前的studentRecord,以便我们可以提取成绩
。最后,为了将迭代器移动到下一个记录,我们调用advance方法
。当这段代码正常工作时,我们可以合理地确信我们已经正确实现了各种方法,而且不仅如此,我们对迭代器概念有了深入的理解。
高效遍历解决方案分析
和之前一样,仅仅因为代码能工作并不意味着从这次事件中学习的潜力已经结束。我们应该仔细考虑我们所做的事情,它的积极和消极影响,并思考我们对刚刚实施的基本想法的扩展。在这种情况下,我们可以这样说,迭代器概念确实解决了我们集合客户端遍历效率低下的原始问题,一旦实现,迭代器的使用既优雅又易于阅读。然而,不可否认的是,基于recordAt方法的低效方法写起来要容易得多。在决定迭代器的实现是否对特定情况有价值时,我们必须问自己遍历会发生的频率,列表中通常有多少项,等等。如果遍历不频繁且列表较小,低效可能并不重要,但如果我们预计列表会变得很大或者不能保证它不会变大,那么迭代器可能就是必需的。
当然,如果我们决定使用标准模板库中的list对象,我们就不再需要担心实现迭代器的难度,因为我们自己不再需要去实现它。下次遇到类似的情况,我们可以使用list类,而不用担心自己占了便宜或者给自己以后带来困难,因为我们已经对列表和迭代器进行了深入的研究,以至于我们理解了幕后必须发生的事情,即使我们从未审查过实际的源代码。
进一步思考,我们可以考虑迭代器的更广泛应用及其可能的局限性。例如,假设我们需要一个迭代器,它不仅能够高效地移动到studentCollection中的下一个项目,还能移动到上一个项目。现在我们知道了迭代器的工作原理,我们可以看到,使用我们当前的studentCollection实现,实际上根本无法做到这一点。如果迭代器维护到列表中特定节点的链接,那么移动到下一个节点只需跟随节点中的链接。然而,退回到前一个节点则需要再次遍历列表到那个点。相反,我们需要一个双向链表,其中节点在两个方向上都有指针,指向下一个节点和前一个节点。我们可以将这种想法推广,并开始考虑不同的数据结构以及可以高效提供给客户端的遍历或数据访问类型。例如,在前一章关于递归的章节中,我们简要遇到了二叉树结构。是否有某种方法允许以标准形式高效地遍历这种结构?如果没有,我们该如何修改它以允许高效的反转?二叉树中节点的遍历顺序是什么?思考这些问题有助于我们成为更好的程序员。我们不仅会教授自己新技能,还会更多地了解不同组件的优缺点。了解组件的优缺点将使我们能够明智地使用它们。未能考虑特定方法的局限性可能导致死胡同,而我们了解的组件越多,这种情况发生的可能性就越小。
选择组件类型
正如我们在这些例子中所看到的,同一个问题可以使用不同类型的组件来解决。一个模式可能表达了解决方案的想法,一个算法可能概述了这个想法或另一个将解决相同问题的实现,一个抽象数据类型可能封装了这个概念,而库中的一个类可能包含了这个抽象数据类型的完全测试过的实现。如果这些每个都是我们需要解决我们问题的相同概念的表述,我们如何知道从我们的工具箱中拉出哪种组件类型?
一个主要的考虑因素是整合组件到我们的解决方案可能需要多少工作量。将类库链接到我们的代码通常是一个快速解决问题的方法,而将伪代码描述的算法实现可能需要花费大量时间。另一个重要的考虑因素是提议的组件提供的灵活性有多大。通常,组件会以一个很好的、预先包装的形式出现,但当它集成到项目中时,程序员会发现,尽管组件已经完成了他或她需要的多数功能,但它并没有做到一切。例如,可能某个方法的返回值格式不正确,需要额外的处理。如果仍然使用该组件,可能会在组件最终被完全丢弃并从头开始开发该部分问题的代码之前,发现更多的问题。如果程序员选择了一个更高概念层次的组件,比如一个模式,那么生成的代码实现将完美地适应问题,因为它是为了那个特定问题而创建的。
图 7-1 总结了这两个因素之间的相互作用。一般来说,库中的代码可以直接使用,但不能直接修改。它只能通过使用 C++模板或如果相关代码实现了我们本章前面看到的类似策略模式的方式进行间接修改。在另一端,一个模式可能仅仅是一个想法(“只能有一个实例的类”),提供最大的实现灵活性,但需要程序员做大量的工作。
当然,这只是一个一般性指南,具体情况会有所不同。也许我们从库中使用的类在我们的程序中处于一个非常低级的层次,这样灵活性就不会受到影响。例如,我们可能在我们自己设计的集合类周围包装一个基本的容器类,如list,它具有足够的能力,即使我们必须扩展容器类功能,我们也可以期待list类能够处理它。在使用模式之前,也许我们之前已经实现了一个特定的模式,所以我们不是在创建新的代码,而是在适应之前编写的代码。

图 7-1. 组件类型的工作需求与灵活性对比
你使用组件的经验越多,你就越有信心认为你从正确的起点开始。在你积累经验之前,你可以将灵活性与工作需求之间的权衡作为一个粗略的指南。对于每种具体情况,你可以问自己以下问题:
-
我可以直接使用这个组件,还是需要额外的代码将其集成到我的项目中?
-
我是否确信我理解了问题的全部范围,或者与这个组件相关的那部分,并且它将来不会改变?
-
选择这个组件是否会增加我的编程知识?
你对这些问题的回答将帮助你估计涉及的工作量以及从每个可能的方法中获得的收益。
组件选择在行动
既然我们已经理解了总体思路,让我们快速通过一个例子来展示具体细节。
问题:部分排序,部分保持原样
一个项目要求你根据成绩对studentRecord对象数组进行排序,但有一个特殊情况。程序的其他部分正在使用特殊的成绩值-1 来表示无法移动的学生记录。因此,尽管所有其他记录都必须移动,但带有-1 成绩的记录应该保持原位,结果是一个除了-1 成绩穿插其中的数组。
这是一个棘手的问题,我们有很多方法可以尝试解决它。为了简化问题,让我们将选择减少到两种:要么我们选择一个算法——即像插入排序这样的排序例程——并修改它以忽略带有-1 成绩的studentRecord对象,要么我们找出一种方法来使用qsort库例程解决这个问题。这两种选择都是可能的。因为我们熟悉插入排序的代码,所以加入一些if语句来显式检查并跳过带有-1 成绩的记录应该不会太难。让qsort为我们做这项工作需要一些变通。我们可以将带有真实成绩的学生记录复制到一个单独的数组中,使用qsort对其进行排序,然后再复制回来,确保不复制任何带有-1 成绩的记录。
让我们通过这两种选项来了解组件类型的选择如何影响生成的代码。我们将从算法组件开始,编写我们自己的修改过的插入排序来解决该问题。像往常一样,我们将分阶段解决这个问题。首先,让我们通过移除整个-1 成绩问题来简化问题,只对没有任何特殊规则的studentRecord对象数组进行排序。如果sra是一个包含arraysize个studentRecord类型对象的数组,生成的代码如下:
int start = 0;
int end = arraySize - 1;
for (int i = start + 1; i <= end; i++) {
for (int j = i; j > start && sra[j-1].grade() > sra[j].grade(); j--) {
studentRecord temp = sra[j-1];
sra[j-1] = sra[j];
sra[j] = temp;
}
}
这段代码与整数插入排序非常相似。唯一的区别是,比较需要调用 grade 方法
,而我们用于交换空间的临时对象类型已经改变
。这段代码运行良好,但有一个需要注意的地方:在测试本节中接下来的代码块时:我们的 studentRecord 类验证数据,并且如前所述,它不会接受 -1 分数,所以请确保进行必要的更改。现在我们准备完成这个解决方案的版本。我们需要插入排序忽略带有 -1 分数的记录。这不像听起来那么简单。在基本的插入排序算法中,我们总是在数组中交换相邻的位置,如上面的代码中的 j 和 j - 1。如果我们保留带有 -1 分数的记录不变,那么下一个要交换的记录的位置可能相隔任意距离。
图 7-2 通过一个示例说明了这个问题。如果这显示了数组的原始配置,那么箭头指示了第一个要交换的记录的位置,它们不是相邻的。此外,最终最后一个记录(对于 Art)将不得不从位置 [5] 交换到 [3],然后从 [3] 交换到 [0],所以对排序这个数组(就像我们正在排序它一样)所需的所有交换都涉及非相邻的记录。

图 7-2. 修改后的插入排序中要交换的记录之间的任意距离
在考虑如何解决这个问题时,我寻找了一个类比,并在链表的处理中找到了一个。在许多链表算法中,我们不仅要维护我们列表遍历中的当前节点指针,还要维护前一个节点的指针。因此,在循环体结束时,我们通常在前进当前指针之前将当前指针赋值给前一个指针。这里也需要做类似的事情。我们需要在通过数组线性前进以找到下一个“真实”记录的过程中跟踪最后一个“真实”学生记录。将这个想法付诸实践,结果如下代码:
for (int i = start + 1; i <= end; i++) {
if (sra[i].grade() != −1) {
int rightswap = i;
for (int leftswap = i - 1;
leftswap >= start
&& (sra[leftswap].grade() > sra[rightswap].grade()
|| sra[leftswap].grade() == −1);
leftswap--)
{
if(sra[leftswap].grade() != −1) {
studentRecord temp = sra[leftswap];
sra[leftswap] = sra[rightswap];
sra[rightswap] = temp;
rightswap = leftswap;
}
}
}
}
在基本的插入排序算法中,我们反复将未排序的项目插入到数组中不断增长的已排序区域。外循环选择下一个要按顺序放置的未排序项目。在这个版本的代码中,我们首先检查外循环体内位置i的等级是否不是-1
。如果是,我们将直接跳到下一个记录,保留这个记录的位置。一旦我们确定位置i的学生记录可以移动,我们就将rightswap初始化为这个位置 ![http://atomoreilly.com/source/no_starch_images/1273191.png]。然后我们开始内循环。在基本的插入排序算法中,内循环的每次迭代都会将一个项目与其邻居交换。在我们的版本中,由于我们保留带有-1 等级的记录,我们只有在位置j不包含-1 等级时才进行交换 ![http://atomoreilly.com/source/no_starch_images/1273195.png]。然后我们在leftswap和rightswap之间进行交换,并将leftswap赋值给rightswap ![http://atomoreilly.com/source/no_starch_images/1273197.png],为内循环中的下一次交换设置条件。最后,我们必须修改我们的内循环条件。通常,插入排序的内循环会在我们到达数组的开头或找到小于我们要插入的值的值时停止。在这里,我们必须使用逻辑或来创建一个复合条件,以便循环可以越过-1 等级 。
这段代码解决了我们的问题,但它可能散发出一些“坏味道”。标准的插入排序代码易于阅读,特别是如果你理解它所做的大致内容,但这个修改版本对眼睛来说很累,如果我们想以后理解它,可能需要一些注释行。也许需要进行重构,但让我们尝试解决这个问题的另一种方法,看看它读起来如何。
我们首先需要为qsort使用一个比较函数。在这种情况下,我们将比较两个studentRecord对象,我们的函数将从一个等级中减去另一个等级:
int compareStudentRecord(const void * voidA, const void * voidB) {
studentRecord * recordA = (studentRecord *) voidA;
studentRecord * recordB = (studentRecord *) voidB;
return recordA->grade() - recordB->grade();
}
现在我们已经准备好对记录进行排序。我们将分三个阶段来完成这项工作。首先,我们将所有没有-1 等级的记录复制到一个二级数组中,不留任何空隙。然后,我们将调用qsort来对二级数组进行排序。最后,我们将从二级数组中复制记录回原始数组,跳过带有-1 等级的记录。生成的代码如下:
studentRecord sortArray[arraySize];
int sortArrayCount = 0;
for (int i = 0; i < arraySize; i++) {
if (sra[i].grade() != −1) {
sortArray[sortArrayCount] = sra[i];
sortArrayCount++;
}
}
qsort(sortArray,
sortArrayCount, sizeof(studentRecord), compareStudentRecord);
sortArrayCount = 0;
for (int i = 0; i < arraySize; i++) {
if (sra[i].grade() != −1) {
sra[i] = sortArray[sortArrayCount];
sortArrayCount++;
}
}
虽然这段代码的长度与其他解决方案大致相同,但它更直接,更容易阅读。我们首先声明一个与原始数组大小相同的辅助数组sortArray ![http://atomoreilly.com/source/no_starch_images/1273182.png],变量sortArrayCount初始化为 0 ![http://atomoreilly.com/source/no_starch_images/1273191.png];在第一个循环中,我们将使用它来跟踪我们复制到辅助数组中的记录数量。在循环内部,每次我们遇到没有-1 成绩的记录 ![http://atomoreilly.com/source/no_starch_images/1273193.png],我们就将它分配给sortArray中的下一个可用槽位,并增加sortArrayCount。当循环结束时,我们排序辅助数组 ![http://atomoreilly.com/source/no_starch_images/1273195.png]。变量sortArrayCount重置为 0 ![http://atomoreilly.com/source/no_starch_images/1273197.png];我们将在第二个循环中使用它来跟踪我们从辅助数组复制回原始数组的记录数量。请注意,第二个循环遍历的是原始数组 ![http://atomoreilly.com/source/no_starch_images/1273199.png],寻找需要填充的槽位 ![http://atomoreilly.com/source/no_starch_images/1273203.png]。如果我们以另一种方式来做,尝试遍历辅助数组并将记录推送到原始数组,我们就需要一个双重循环,内循环在原始数组中搜索下一个真实成绩的槽位。这是另一个例子,说明了问题可以根据我们对它的概念化变得简单或困难。
比较结果
两种解决方案都可行,并且是合理的途径。对于大多数程序员来说,第一种解决方案,即我们在排序时修改插入排序以保留一些记录在原位,更难编写和阅读。然而,第二种解决方案似乎引入了一些低效性,因为它需要将数据复制到辅助数组并再次复制回来。这就是一点算法分析知识派上用场的地方。假设我们正在排序 10,000 条记录——如果我们排序的记录很少,我们实际上不会关心效率。我们无法确定qsort调用背后的算法是什么,但通用排序的最坏情况可能需要 10 亿条记录交换,而最佳情况大约是 130,000 条。无论我们最终落在哪个范围内,与排序相比,复制 10,000 条记录来来回回不会是一个主要的性能瓶颈。此外,我们必须考虑qsort使用的算法可能比我们简单的插入排序更高效,从而抵消了我们可能从避免将数据复制到辅助数组中获得的任何好处。
因此,在这种情况下,第二种方法,使用qsort,似乎更胜一筹。它更容易实现,更容易阅读,因此更容易维护,我们预计其性能将与第一种解决方案相当,甚至可能更好。我们可以说第一种方法最好的地方可能就是我们可能已经学到了可以应用于其他问题的技能,而第二种方法,由于其简单性,没有提供这样的见解。一般来说,当你处于试图最大化学习效果的编程阶段时,你应该优先考虑高级组件,如算法和模式。当你处于试图最大化编程效率的阶段(或面临硬性截止日期)时,你应该优先考虑低级组件,并在可能的情况下选择预构建的代码。当然,如果时间允许,尝试几种不同的方法,就像我们在这里所做的那样,可以提供最好的结果。
练习
尽可能多地尝试不同的组件。一旦你掌握了如何学习新的组件,你的编程能力将开始迅速增长。
-
对策略/策略模式的一个常见批评是它要求暴露类的某些内部信息,例如类型。修改本章前面提到的“第一个学生”程序,使得策略函数都存储在类中,并且通过传递一个代码值(例如,一个新枚举类型的值)来选择,而不是传递策略函数本身。
-
将第四章(第四章
,然后稍后sr1.retrieveField("Title")`将返回“Problems of Unconditional Branching。” -
设计自己的:选择一个你已经解决的问题,并使用不同的组件再次解决它。记住,将结果与你的原始解决方案进行比较分析。
第八章. 像程序员一样思考

是时候将前几章中我们所经历的一切整合起来,完成从新手程序员到问题解决程序员的转变之旅。
在前几章中,我们在各个领域解决了问题。我相信这些领域对正在发展的程序员来说最有益,但当然还有更多东西要学习,而且许多问题将需要本书未涵盖的技能。因此,在本章中,我们将回到一般问题解决概念,结合我们在旅程中获得的知识,制定一个攻击任何编程问题的宏伟蓝图。虽然我们可能称之为一个一般计划,但在某种程度上,它实际上是一个非常具体的计划:它将是您的计划,而不是别人的。我们还将探讨您作为程序员可以增加知识和技能的许多方式。
制定您的个人宏伟蓝图
充分发挥您的优势和劣势
在多年的教学过程中,我见过各种能力水平的学生。我说的不仅仅是有些程序员的能力比其他人强,虽然这当然是真的。即使在能力水平相同的情况下,程序员之间也存在很大的差异。我记不清有多少次被一个曾经努力的学生迅速掌握一项特定技能或一个有才华的学生在新领域表现出弱点而感到惊讶。正如没有两个人的指纹是完全相同的,也没有两个人的大脑是完全相同的,对某个人来说容易的课程对另一个人来说可能就很难。
假设您是一名足球教练,正在为下一场比赛制定进攻策略。由于受伤,您不确定哪位四分卫能够首发。这两位四分卫都是能力出众的专业球员,但就像任何领域的个人一样,他们都有自己的优势和劣势。为一位四分卫制定的比赛策略可能对另一位来说却是个糟糕的选择。
在制定您的宏伟蓝图时,您是教练,您的技能组合就是您的四分卫。为了最大限度地提高成功的机会,您需要一个既能认识到您的优势也能认识到您的劣势的计划。
在第一章中,我们了解到解决问题的第一规则是您应该始终有一个计划。一个更精确的表述可能是说您应该始终遵循您的计划。您应该构建一个能够最大限度地发挥您的优势并最小化您的劣势的宏伟蓝图,然后将这个宏伟蓝图应用于您必须解决的每个问题。
因此,制定自己的主计划的关键步骤是识别你的优势和弱点。这并不困难,但需要努力和相当程度的诚实自我评估。为了从你的错误中受益,你不仅必须在出现错误的程序中纠正它们,而且你必须在心理上或最好是在文档中记录它们。这样,你可以识别出你否则可能会错过的行为模式。
我将描述两种不同类别的弱点:编码和设计。编码弱点是在你实际编写代码时容易重复犯错的领域。例如,许多程序员经常编写迭代次数过多或过少的循环。这被称为栅栏桩错误,源自一个古老的难题,即需要多少根栅栏桩才能用 10 英尺长的横梁建造一个 50 英尺的栅栏。大多数人的直接反应是五根,但如果你仔细思考,答案是六根,如图图 8-1 所示。
大多数编码弱点是程序员在编码过快或准备不足时创建的语义错误的情况。相比之下,设计弱点是在问题解决或设计阶段常见的问题。例如,你可能会发现你很难开始或者很难将之前编写的子程序集成到完整的解决方案中。

图 8-1. 栅栏桩难题
虽然这两个类别之间有一些重叠,但两种类型的弱点往往会引起不同类型的问题,并且必须以不同的方式来防御。
针对编码弱点的规划
在编程中,最令人沮丧的活动之一可能是花费数小时追踪一个语义错误,一旦确定,其实很容易修复。因为没有人是完美的,所以无法完全消除这些情况,但一个好的程序员会尽其所能避免一次又一次地犯同样的错误。
我认识一个程序员,他已经厌倦了犯可能是 C++编程中最常见的语义错误:将赋值运算符(=)替换为相等运算符(==)。因为 C++中的条件表达式是整数,而不是严格的布尔值,所以以下这样的语句在语法上是合法的:
if (number = 1) flag = true;
在这种情况下,整数值 1 被分配给number,然后值 1 被用作条件语句的结果,C++将其评估为true。当然,程序员本意是想做的是:
if (number == 1) flag = true;
对于反复犯这种错误感到沮丧,程序员自学了总是以另一种方式编写相等测试,即左侧使用数值字面量,例如:
if (1 == number) flag = true;
通过这样做,如果程序员不小心使用了相等运算符,表达式1 = number就不再是合法的 C++语法,并且会在编译时产生语法错误。原始错误是合法的语法,所以它只是一个语义错误,可能会在编译时被捕获,也可能根本不会被捕获。由于我自己也犯过这样的错误(并且试图追踪这个错误让自己疯狂),我采用了这种方法,将数值字面量放在相等运算符的左边。在这个过程中,我发现了一些有趣的事情。因为这与我的常规风格相反,将字面量放在左边迫使我写条件语句时暂时停下来。我会想,“我需要记住把字面量放在左边,这样我就能在用到赋值运算符时提醒自己。”正如你所预期的,通过让这个想法在我的脑海中运行,我实际上从未使用过赋值运算符,而总是正确地使用了相等运算符。现在,我不再把字面量放在相等运算符的左边,但我仍然会停下来,让这些想法在我的脑海中运行,这使我避免了使用错误的运算符。
这里的教训是,意识到自己的编码弱点通常就足以避免它们。这是好消息。坏消息是,你仍然需要付出努力来首先意识到自己的编码弱点。关键技巧是问自己为什么犯了一个特定的错误,而不仅仅是修复错误然后继续前进。这将帮助你识别你未能遵循的一般原则。例如,假设你编写了以下函数来计算整数数组中正数的平均值:
double averagePositive(int array[ARRAYSIZE]) {
int total = 0;
int positiveCount = 0;
for (int i = 0; i < ARRAYSIZE; i++) {
if (array[i] > 0) {
total += array[i];
positiveCount++;
}
}
return total / (double) positiveCount;
}
乍一看,这个函数看起来没问题,但仔细检查后会发现一个问题。如果数组中没有正数,那么循环结束时positiveCount的值将为零,这将在函数结束时导致除以零的错误
。因为这是浮点数除法,程序可能实际上不会崩溃,而是产生奇怪的行为,这取决于这个函数在整个程序中的使用方式。
如果你急于让代码运行,并发现了这个问题,你可能会添加一些代码来处理positiveCount为零的情况,然后继续。但如果你想作为一个程序员成长,你应该问问自己你犯了什么错误。具体问题当然是,你没有考虑到除以零的可能性。但如果分析就到这里,那么在将来它对你帮助不大。当然,你可能会遇到另一个除数可能为零的情况,但这并不常见。相反,我们应该问一下违反了哪个一般原则。答案是:我们应该总是寻找可能使我们的代码崩溃的特殊情况。
通过考虑这个一般原则,我们更有可能看到我们错误中的模式,因此更有可能在将来捕捉到这些错误。问自己“这里有可能除以零吗?”并不像问自己“这个数据有哪些特殊情况?”那么有用。通过问更广泛的问题,我们会提醒自己不仅要检查除以零,还要检查空数据集、超出预期范围的数据等等。
针对设计弱点的规划
设计上的弱点需要不同的方法来规避。但第一步是相同的:你识别出弱点。很多人在这个步骤上遇到困难,因为他们不喜欢对自己如此苛刻。我们习惯于隐藏个人的失败。就像当面试官问你最大的弱点是什么时,你被期望给出一些关于你过于关心工作质量的废话,而不是提供一个实际的弱点。但就像超人有他的氪石一样,即使是最好的程序员也有真正的弱点。
这里有一份(当然不是详尽的)程序员弱点的样本列表。看看你是否在这些描述中找到了自己。
设计复杂
具有这种弱点的程序员创建的程序部分太多或步骤太多。虽然程序可以工作,但它们不会让人有信心——就像看起来一扯就会破的破旧衣服——而且它们显然效率低下。
无法开始
这个程序员有很大的惰性。无论是由于对解决问题的信心不足还是简单的拖延,这个程序员在问题上的初始进展总是花费太长时间。
未能测试
这个程序员不喜欢正式测试代码。通常代码对一般情况有效,但对特殊情况无效。在其他情况下,代码可能运行良好,但不会“扩展”到程序员未测试的更大的问题集。
过度自信
信心是一件好事——这本书的目的是增加读者的信心——但过度的自信有时可能和缺乏自信一样成问题。过度自信会以各种方式表现出来。过度自信的程序员可能会尝试比必要的更复杂的解决方案,或者给项目留的时间太少,结果导致一个匆忙、充满错误的程序。
薄弱环节
这个类别有点像是一个大杂烩。一些程序员在遇到某些概念之前工作得很顺利。考虑一下本书前面章节中讨论的主题。大多数程序员,即使完成了练习,也会在某些我们覆盖的领域比其他领域更有信心。例如,程序员可能在指针程序中迷失方向,或者递归让程序员的头脑变得混乱。也许程序员在设计复杂的类时遇到困难。并不是程序员不能勉强解决问题,但这是一项艰难的工作,就像在泥地里开车一样。
有不同的方法可以面对你的大规模薄弱环节,但一旦你认识到它们,规划起来就容易多了。例如,如果你是那种经常跳过测试的程序员,那么请将测试明确纳入你编写每个模块的计划中,并且不要在检查那个框之前进入下一个模块。或者考虑一种称为测试驱动开发的设计范式,在这种范式中,测试代码首先编写,然后编写代码来填充那些测试。如果你有困难开始,使用分解或简化问题的原则,并尽快开始编写代码,理解你可能需要稍后重写那段代码。如果你的设计通常过于复杂,在你的总体规划中添加一个明确的重构步骤。重点是,无论你作为程序员有什么弱点,如果你认识到它们,你就可以规划绕过它们。那么,你的弱点就不再是弱点——只是你在通往成功项目完成道路上的障碍,你可以绕过它们。
规划您的优势
规划您的薄弱环节主要是为了避免错误。然而,良好的规划并不仅仅是避免错误。它是在你当前的能力和可能面临的任何限制下,朝着最佳可能结果努力。这意味着你必须在你的总体规划中融入你的优势。
你可能认为这一部分不是为你准备的,或者至少还不是。毕竟,如果你在读这本书,那么你还在成为程序员的路上。你可能想知道在目前的发展阶段你是否真的有任何优势。我在这里告诉你,你确实有,即使你还没有意识到。以下是一份常见的程序员优势列表,绝非详尽无遗,每个优势都有描述以及帮助你识别这些优势是否适用于你的提示:
注重细节
这种类型的程序员可以预见特殊情况,在问题出现之前就能看到潜在的性能问题,并且永远不会让大局模糊了必须处理的重要细节,这对于程序成为一个完整且正确的解决方案至关重要。具有这种优势的程序员倾向于在编码前在纸上测试他们的计划,缓慢编码,并频繁测试。
快速学习者
快速学习者能够迅速掌握新技能,无论是学习已知语言中的新技术还是使用新的应用程序框架。这种类型的程序员喜欢学习新事物的挑战,并可能根据这种偏好选择项目。
快速编码者
快速编码者不需要花很多时间在参考书上就能敲出一个函数。一旦开始输入,代码就会从快速编码者的指尖流淌出来,几乎不需要努力,且语法错误很少。
永不放弃
对于一些程序员来说,一个讨厌的 bug 就像是对他们个人的侮辱,不能被忽视。这就像程序用皮手套打在程序员的嘴上,程序员必须做出回应。这种类型的程序员似乎总是保持冷静,坚定但从不非常沮丧,并确信只要付出足够的努力,胜利就一定属于自己。
超级问题解决者
很可能你在购买这本书时并不是一个超级问题解决者,但现在你已经得到了一些指导,也许一切开始变得容易起来。具有这种特性的程序员在阅读问题时已经开始构想可能的解决方案。
动手实践者
对于这种程序员来说,一个能工作的程序就像一个美妙的玩具箱。动手实践者从未失去让计算机按照自己的意愿工作的兴奋感,喜欢不断为计算机找到新的任务。也许这种动手实践意味着给一个能工作的程序添加越来越多的功能——这是一种被称为渐进式特性主义的症状。也许程序可以被重构以提高性能。也许程序只是为了程序员或用户而变得更漂亮。
很少有程序员会展现出超过两种这些优势——实际上,其中一些优势可能会相互抵消。但每个程序员都有自己的优势。如果你在上述任何一项中找不到自己的影子,那只是意味着你还没有足够了解自己,或者你的优势不属于我的任何一个类别。
一旦你确定了你的优势,你需要将它们纳入你的总体规划中。假设你是一个编程速度快的人。显然这有助于让任何项目顺利完成,但你如何以系统化的方式利用这个优势呢?在正式的软件工程中,有一种称为“快速原型设计”的方法,其中程序最初在没有广泛规划的情况下编写,然后通过连续迭代进行改进,直到结果满足问题要求。如果你是一个编程速度快的人,你可能尝试采用这种方法,一旦有基本想法就立即编码,让你的粗糙原型指导最终程序代码的设计和开发。
如果你是一个快速学习者,也许你应该从寻找新的资源或技术来解决当前问题开始每一个项目。如果你不是一个快速学习者,但你是那种不容易沮丧的程序员,也许你应该从你认为最困难的领域开始项目,给自己更多的时间去解决它们。
所以无论你有什么优势,确保你在编程中充分利用它们。设计你的总体规划,以便尽可能多地做你最擅长的事情。这样不仅会产生最好的结果,而且你也会玩得最开心。
制定总体规划
让我们看看构建一个示例总体规划。配料包括我们开发的所有解决问题的技巧,以及我们对优势和劣势的分析。在这个例子中,我将使用我自己的优势和劣势。
在解决问题的技巧方面,我使用这本书中分享的所有技巧,但我特别偏爱“简化问题”的技巧,因为使用这个技巧让我感觉我总是在朝着目标取得具体进展。如果我现在无法想出一种编写满足完整规范代码的方法,我就会先抛出一部分规范,直到我获得动力。
我最大的编程弱点是过于急切。我喜欢编程,因为我喜欢看到计算机按照我的指令运行。有时这会让我想,“让我们试试看这个,看看会发生什么,”而此时我应该还在分析我刚刚写下的代码的正确性。这里的危险不在于程序会失败——而在于程序可能会看似成功但未涵盖所有特殊情况,或者成功但不是我能写出的最佳解决方案。
我喜欢优雅的程序设计,这种设计易于扩展和重用。通常当我编写较大的项目时,我会花费大量时间开发替代设计方案。总体来说,这是一个好的特性,但有时这会导致我在设计阶段花费过多时间,没有留出足够的时间来实际实施所选的设计。此外,有时这可能会导致过度设计的解决方案。也就是说,有时解决方案比实际需要的更加优雅、可扩展和健壮。因为每个项目在时间和金钱上都是有限的,所以最佳解决方案必须在追求高软件质量与节约资源的需求之间取得平衡。
我认为我最好的编程优势是,我能够很好地掌握新概念,并且我喜欢学习。当一些程序员喜欢反复使用相同的技能时,我喜欢那些可以让我学到新东西的项目,而且我总是被这样的挑战所激励。
考虑到所有这些,以下是我对新项目的总体规划。
为了克服我的主要设计弱点,我将严格限制我在设计阶段花费的时间,或者,在继续前进之前,限制我要考虑的不同设计的数量。这可能会让一些读者觉得这是一个危险的想法。我们不应该在设计阶段投入尽可能多的时间,然后再开始编码吗?大多数项目不都是因为前端投入的时间不足而导致后端出现一系列妥协吗?这些担忧是合理的,但请记住,我并不是在编写一个软件开发的一般指南。我正在创建我自己的个人总体规划,以应对编程问题。我的弱点是过度设计,而不是设计不足,因此限制设计时间对我来说是有意义的。对于另一位程序员来说,这样的规则可能是灾难性的,有些程序员可能需要一个规则来迫使他们花更多的时间在设计上。
在完成初步分析之后,我将考虑项目是否提供了学习新技术、库等的机会。如果确实如此,我将在尝试将其纳入我的开发解决方案之前,编写一个小型测试程序来尝试这些新技能。
为了对抗过度的急切心情,我可以在完成每个模块的编码后加入一个微型的代码审查步骤。然而,这需要我自己的意志力——当我完成每个模块时,我肯定会想要尝试它。仅仅希望每次都能说服自己放弃,就像在饥饿的人旁边放一袋薯片,然后惊讶地看着袋子被清空。更好的办法是制定一个计划,让程序员不必与其本能作斗争。那么,如果我创建两个版本的项目:一个粗糙的、无拘无束的版本和一个经过打磨的交付版本呢?如果我允许自己随意玩弄第一个版本,但直到它被彻底审查后才允许将其代码合并到打磨版本中,我更有可能克服我的弱点。
解决任何问题
一旦我们有了总计划,我们就准备好应对任何情况。这正是这本书的最终目的:从一个问题开始,任何问题,找到一条通往解决方案的道路。在前面的所有章节中,问题描述推动我们朝着特定的初始方向前进,但在现实世界中,大多数问题并不要求使用数组或递归,或者将程序功能的一部分封装到类中。相反,程序员在解决问题的过程中做出这些决定。
起初,更少的约束可能看起来会使问题更容易解决。毕竟,设计要求是一种约束,而约束不是会使问题更难吗?虽然这是真的,但所有问题都有约束——只是有些情况下,它们比其他情况下更明确地表述出来。例如,没有被告知特定问题是否需要动态分配的结构并不意味着这个决定没有影响。如果我们的设计选择错误,问题的广泛约束——无论是性能、可修改性、开发速度还是其他什么——可能更难满足,或者可能根本无法满足。
想象一下,一群朋友请你为他们挑选一部电影观看。如果其中一个朋友肯定想看喜剧,另一个不喜欢老电影,还有一个列出她刚刚看过的五部电影,不想再看,这些限制会使选择变得困难。然而,如果没有人有任何建议,只是“随便挑个好的”,你的工作会更难,你很可能挑选出至少一个成员根本不喜欢的电影。
因此,更大、定义较宽泛、约束较弱的难题是最难解决的。然而,它们同样容易受到我们在本书中使用的相同问题解决技术的攻击;只是需要更多的时间来解决。有了这些技术的知识和手中的总计划,你将能够解决任何问题。
为了说明我所说的内容,我将带你了解一个玩猜字谜程序的初步步骤,这是一个经典的儿童游戏,但有一个转折点。
在我们到达问题描述之前,让我们回顾一下游戏的基本规则。第一个玩家选择一个单词,并告诉第二个玩家单词中有多少个字母。然后第二个玩家猜测一个字母。如果这个字母在单词中,第一个玩家会显示这个字母在单词中的位置;如果这个字母出现多次,所有出现的位置都会被指出。如果这个字母不在单词中,第一个玩家会在一个被绞死的人的 stick-figure 画上添加一个部件。如果第二个玩家猜对了单词中的所有字母,第二个玩家获胜,但如果第一个玩家完成了画,第一个玩家获胜。关于组成被绞死的人的部件数量有不同的规则,所以更普遍地说,玩家们事先同意第一个玩家获胜需要多少“失误”。
现在我们已经了解了基本规则,让我们来看一个具体的问题,包括具有挑战性的转折点。
问题:猜字谜作弊
编写一个程序,该程序将是猜字谜(hangman)文本版本的玩家 1(也就是说,你实际上不需要画一个被绞死的人——只需记录错误的猜测次数即可)。玩家 2 将通过指定猜测单词的长度以及导致游戏失败的错误猜测次数来设置游戏的难度。
转折点是程序将作弊。而不是在游戏开始时实际选择一个单词,只要玩家 2 失败,程序就可以显示一个与玩家 2 给出的所有信息匹配的单词。正确猜测的字母必须出现在正确的位置,而且任何错误的猜测字母都不能出现在单词中。当游戏结束时,玩家 1(程序)将告诉玩家 2 所选择的单词。因此,玩家 2 永远无法证明游戏在作弊;这只是玩家 2 获胜的可能性很小。
这不是一个按照现实世界标准的大问题,但它足够大,可以展示我们在处理指定结果但没有指定方法论的编程问题时面临的问题。根据问题描述,你可以在开发环境中启动并开始编写代码,可以在数十个不同的地方开始。当然,这将是错误的,因为我们总是希望有计划地编程,所以我需要将我的总体规划应用于这个具体的情况。
我的大计划的第一部分是限制我在设计阶段花费的时间。为了使这成为现实,我需要在编写生产代码之前仔细思考设计。然而,我相信在这种情况下进行一些实验对于我找到问题的解决方案是必要的。我的大计划还允许我创建两个项目,一个粗糙的原型和最终的、完善的解决方案。因此,我将允许自己在任何实际设计工作之前开始为原型编码,但在我相信设计已经确定之前,不允许对最终解决方案进行任何编码。这不能保证我对第二个项目的整个设计完全满意,但它提供了最好的机会。
现在是时候开始分析这个问题了。在之前的章节中,我们有时会列出完成问题所需的子任务,所以我想要列出这些子任务。然而,目前这很困难,因为我不知道程序实际上会如何实现作弊。我需要进一步调查这个领域。
找到作弊的方法
在猜字谜游戏中作弊是足够具体的,我不期望在组件的正常来源中找到任何帮助;没有邪恶策略模式。在这个阶段,我对如何作弊有一个模糊的想法。我想我会选择一个初始谜题单词,只要玩家 2 选择的字母实际上不在那个单词中,我就保留那个单词。然而,一旦玩家 2 猜中了一个实际上在单词中的字母,如果可能找到一个不包含迄今为止所选字母的单词,我就会切换到另一个单词。换句话说,我会尽可能长时间地拒绝玩家 2 匹配。这是我的想法,但我需要的不仅仅是想法——我需要我能实现的东西。
为了使我的想法更加明确,我将在纸上通过一个例子来工作,扮演玩家 1 的角色,从一个单词列表开始。为了简化问题,我将假设玩家 2 请求了一个三字母单词,并且我知道的所有三字母单词的完整列表显示在表 8-1 的第一列。我将假设我的第一个选择的“谜题单词”是列表上的第一个单词,bat。如果玩家 2 猜的字母不是b、a或t,我会说“不”,然后我们就会更接近完成绞刑架。如果玩家 2 猜中了一个单词中的字母,那么我会选择另一个单词,一个不包含那个字母的单词。
然而,当我查看我的列表时,我不太确定这种策略是否是最好的。在某些情况下,这可能是有意义的。假设玩家 2 猜测b。列表中没有任何其他单词包含b,所以我可以将谜题单词切换到其中任何一个。这也意味着我已经最小化了损害;我只从我的列表中消除了一个可能的单词。但是,如果玩家 2 猜测a会发生什么?如果我只是说“没有”,我将消除包含a的所有单词,这只会留下表 8-1 第二列中的三个单词供我选择。如果相反,我决定承认谜题单词中存在字母a,我将剩下五个单词可供选择,如第三列所示。但请注意,这种扩展的选择仅因为所有五个单词的a都在相同的位置。一旦我宣布一个猜测是正确的,我就必须准确地显示字母在单词中的位置。如果我在游戏中剩余的时间里还有更多的单词选择来应对未来的猜测,我会感到更加安心。
表 8-1. 样本单词列表
| All Words | Words Without a | Words with a |
|---|---|---|
| bat | dot | bat |
| car | pit | car |
| dot | top | eat |
| eat | saw | |
| pit | tap | |
| saw | ||
| tap | ||
| top |
此外,即使我设法在游戏早期避免了揭示字母,我也必须预计玩家 2 最终会做出正确的猜测。例如,玩家 2 可能一开始就猜测所有元音字母。因此,在某个时候,我必须决定在字母被揭示时该做什么,并且根据我对样本列表的实验,看起来我必须找到字母出现最频繁的位置(或位置)。从这个观察中,我意识到我一直在错误地思考作弊。我实际上永远不应该选择一个谜题单词,即使只是暂时,而只是跟踪如果我必须选择时所有可能的单词。
带着这个想法,我现在可以以不同的方式定义作弊:尽可能多地保留候选谜题单词列表中的单词。对于玩家 2 的每一次猜测,程序都需要做出一个决定。我们声称这个猜测是未命中还是匹配?如果是匹配,猜测的字母出现在哪些位置?我的程序将保持一个不断减少的候选谜题单词列表,并在每次猜测后做出决定,以保留列表中尽可能多的单词。
悖我猜单词游戏所需的操作
现在我已经足够了解这个问题,可以创建我的子任务列表。在这样一个规模的问题中,在早期阶段制作的列表可能会遗漏一些操作。这是可以接受的,因为我的主要计划预计我不会第一次就创建一个完美的设计。
存储并维护一个单词列表。
这个程序必须有一个有效的英语单词列表。因此,程序将不得不从文件中读取单词列表,并以某种格式将它们存储在内部。在游戏过程中,随着程序的作弊,这个列表将被缩减或提取。
创建给定长度的单词子列表。
由于我的意图是维护候选谜底单词列表,我必须以玩家 2 指定的长度单词列表开始游戏。
跟踪所选字母。
程序需要记住哪些字母已被猜出,其中有多少是不正确的,以及对于被认为正确的字母,它们在谜底单词中的位置。
计算不包含字母的单词数量。
为了便于作弊,我需要知道列表中有多少单词不包含最近猜出的字母。请记住,程序将决定最近猜出的字母是否出现在谜底单词中,目的是在候选单词列表中留下尽可能多的单词。
根据字母和位置确定单词数量最多的情况。
这看起来是最棘手的操作。假设玩家 2 刚刚猜出字母d,当前游戏谜底单词长度为三个。也许当前的候选单词列表总共包含 10 个包含d的单词,但这并不重要,因为程序必须声明字母在谜底单词中的位置。让我们称单词中字母的位置为模式。所以d??是一个三字母模式,指定第一个字母是d,而其他两个字母可以是任何不是d的字母。考虑表 8-2。假设第一列中的列表包含程序所知的每个包含d的三字母单词。其他列根据模式分解这个列表。最频繁出现的模式是??d,有 17 个单词。这个数字,17,将与候选列表中不包含d的单词数量进行比较,以确定猜测是匹配还是失误。
创建匹配特定模式的单词子列表。
当程序宣布玩家 2 的猜测是匹配时,它将创建一个新的候选单词列表,其中只包含与所选字母模式匹配的单词。在先前的例子中,如果我们宣布d为匹配,表 8-2 的第三列将变成新的候选单词列表。
继续玩游戏直到游戏结束。
在所有其他操作就绪之后,我需要编写将一切粘合在一起并实际玩游戏的代码。程序应该反复请求玩家 2(用户)猜测,确定通过接受或拒绝该猜测来决定候选单词列表是否会变长,相应地减少单词列表,并显示结果谜题单词,包括任何正确猜测的字母,以及所有先前猜测的字母的回顾。这个过程将继续,直到游戏结束,由一方玩家获胜——我还需要弄清楚这些条件。
表 8-2. 三字母单词
| 所有单词 | ?dd | ??d | d?? | d?d |
|---|---|---|---|---|
| add | add | aid | day | did |
| aid | odd | and | die | |
| and | bad | doe | ||
| bad | bed | dog | ||
| bed | bid | dry | ||
| bid | end | due | ||
| day | fed | |||
| did | had | |||
| die | hid | |||
| doe | kid | |||
| dog | led | |||
| dry | mad | |||
| due | mod | |||
| end | old | |||
| fed | red | |||
| had | rid | |||
| hid | sad | |||
| kid | ||||
| led | ||||
| mad | ||||
| mod | ||||
| odd | ||||
| old | ||||
| red | ||||
| rid | ||||
| sad |
初始设计
尽管看起来之前的操作列表只是列出了一些原始事实,但设计决策正在被做出。考虑操作“创建一个匹配模式的单词子列表。”这个操作将出现在我的解决方案中,至少是这个初始版本,但严格来说,这根本不是一个必需的操作。同样,“创建给定长度的单词子列表”也不是。与其维护一个不断缩小的候选谜题单词列表,我可以在整个游戏中保持原始的单词主列表。但这将使大多数其他操作变得复杂。对于“计算不包含指定字母的单词数量”的操作,不能仅仅遍历候选谜题单词列表并计算所有不包含指定字母的单词。因为它将搜索主列表,所以它还必须检查每个单词的长度以及单词是否与谜题单词中到目前为止揭示的字母匹配。我认为我选择的路径总体上更容易,但我必须意识到,即使是这些早期的选择也在影响着最终的设计。
虽然在将问题分解为子任务之后,我还有其他决定要做。
如何存储单词列表
程序的关键数据结构将是单词列表,程序将在整个游戏中减少这个列表。在选择结构时,我观察到以下几点。首先,我相信我不需要随机访问列表中的单词,而是一直从前往后整体处理列表。第二,我不知道我需要的初始列表的大小。第三,我将会频繁地减少列表。第四,也是最后一点,标准string类的方法在这个程序中可能会很有用。将这些观察结果综合起来,我决定我的初始选择将是标准的模板list类,项目类型为string。
如何跟踪猜测的字母
被选中的字母在概念上是一个集合——也就是说,一个字母要么被选中,要么没有被选中,一个字母不能被选中超过一次。因此,这实际上是一个特定字母是否是“选中”集合的成员的问题。因此,我将选中的字母表示为一个大小为 26 的bool数组。如果数组命名为guessedLetters,那么guessedLetters[0]在游戏到目前为止被猜中时为 true,否则为 false;guessedLetters[1]用于b,以此类推。我将使用我们在整本书中一直在使用的范围转换技术,在大小写字母及其在数组中的对应位置之间进行转换。如果letter是一个表示小写字母的 char,那么guessedLetters[letter - 'a']就是相应的位置。
如何存储模式
我将要编写的操作之一,“创建匹配模式的单词子列表”,将使用单词中字母的位置模式。这个模式将由另一个操作产生,“根据字母和位置确定最大单词数”。那么我将使用什么格式来存储这些数据?模式是一系列代表特定字母出现位置的数字。我有许多方法可以存储这些数字,但我将保持简单,使用另一个list,这个list的项目类型为int。
我是在写一个类吗?
因为我在用 C++编写这个程序,我可以根据我的意愿使用面向对象编程,也可以不使用。我的第一个想法是,我列表中的许多操作可以自然地合并成一个类,比如叫做wordList,它有根据指定标准(即长度和模式)删除单词的方法。然而,因为我正在尝试避免做出我现在会后悔的设计决策,所以我将我的第一个、粗略而实用的程序完全做成过程式的。一旦我解决了程序的所有棘手方面,并且实际上为列表中的所有操作编写了代码,我将处于一个很好的位置来决定面向对象编程对于最终版本的可适用性。
初始编码
现在有趣的部分开始了。我启动了我的开发环境并开始工作。这个程序将使用标准库中的许多类,为了清晰起见,让我先设置好所有这些类:
#include <iostream>
using std::cin;
using std::cout;
using std::ios;
#include <fstream>
using std::ifstream;
#include <string>
using std::string;
#include <list>
using std::list;
using std::iterator;
#include <cstring>
现在我已经准备好开始编写我列表中的操作代码了。在某种程度上,我可以按任何顺序编写这些操作,但我打算从一个函数开始,该函数将读取一个包含单词的纯文本文件到我所选择的list<string>结构中。在这个时候,我意识到我需要找到一个现有的单词主文件——我不想自己输入。幸运的是,搜索“单词列表”揭示了许多网站,它们提供了以纯文本格式排列的英语单词列表,每个单词占文件的一行。我已经熟悉了在 C++中读取文本文件,但如果我不熟悉,我会编写一个小型测试程序,先练习这项技能,然后再将其集成到作弊的猜字游戏程序中,这种练习我在本章后面会讨论。
拿到文件后,我可以编写这个函数:
list<string> readWordFile(char * filename) {
list<string> wordList;
ifstream wordFile(filename, ios::in);
if (wordFile == NULL) {
cout << "File open failed. \n";
return wordList;
}
char currentWord[30];
while (wordFile >> currentWord) {
if (strchr(currentWord, '\'') == 0) {
string temp(currentWord);
wordList.push_back(temp);
}
}
return wordList;
}
这个函数很简单,所以我只会做几个简要的注释。如果你以前从未见过,一个ifstream对象
是一个输入流,它的工作方式就像cin一样,只不过它从文件而不是标准输入读取。如果构造函数无法打开文件(通常这意味着文件未找到),对象将是NULL,我会明确检查这一点
。如果文件存在,它将在循环
中被处理,循环读取文件的每一行到一个字符数组中,将数组转换为string对象,并将其添加到list中。我最终使用的英语单词文件包含了带有撇号的单词,这些单词在我们的游戏中是不合法的,所以我明确排除了它们
。
接下来,我编写了一个函数来显示我list<string>中的所有单词。这不在我的必需操作列表中,我也不会在游戏中使用它(毕竟,这只会帮助玩家 2,而我正试图欺骗他),但这是一个测试我的readWordFile函数是否正确工作的好方法:
void displayList(const list<string> & wordList) {
list<string>::const_iterator iter;
iter = wordList.begin();
while (iter != wordList.end()) {
cout << iter->c_str() << "\n";
iter++;
}
}
这实际上是上一章中介绍的基本列表遍历代码。请注意,我已经将参数声明为const引用
。因为列表在开始时可能相当大,所以将引用参数声明为const可以减少函数调用的开销,而值参数将不得不复制整个列表。将这个引用参数声明为const表示函数不会更改列表,这有助于代码的可读性。一个const列表需要一个const迭代器
。cout流不能输出字符串对象,所以这个方法使用c_str()生成等效的空终止char数组
。
我使用这个相同的基本结构来编写一个函数,该函数计算列表中不包含指定字母的单词数量:
int countWordsWithoutLetter(const list<string> & wordList, char letter) {
list<string>::const_iterator iter;
int count = 0;
iter = wordList.begin();
while (iter != wordList.end()) {
if (iter->find(letter) == string::npos) {
count++;
}
iter++;
}
return count;
}
如您所见,这是一个相同的基本遍历循环。在内部,我调用了string类的find方法
,它返回其char参数在string对象中的位置,当字符未找到时返回特殊值npos。
我使用这个相同的基本结构来编写一个函数,该函数会移除我的单词列表中所有与指定长度不匹配的单词:
void removeWordsOfWrongLength(list<string> & wordList,
int acceptableLength)
{
list<string>::iterator iter;
iter = wordList.begin();
while (iter != wordList.end()) {
if (iter->length() != acceptableLength) {
iter = wordList.erase(iter);
} else {
iter++;
}
}
}
这个函数是每个你编写的程序都是加深你对程序工作方式理解的机会的一个很好的例子。这个函数对我来说写起来很简单,因为我已经从之前编写的程序中理解了“幕后”发生了什么。这个函数使用了之前函数的基本遍历代码,但代码在循环内部变得有趣。erase()方法从一个list对象中移除由iterator指定的项。但根据我们在第七章中实现链表迭代器模式的经验,我知道iterator几乎肯定是一个指针。根据我们在第四章中的指针经验,我知道当它是一个指向已删除对象的悬挂引用时,指针是无用的,并且通常是危险的。因此,我知道我需要在这次操作后给iter赋一个有效值。幸运的是,erase()的设计者已经预料到这个问题,并有一个方法返回一个指向我们刚刚删除的项之后的项的新iterator,所以我可以将这个值赋回iter
。此外,请注意,我只在未从列表中删除当前字符串时显式前进iter
,因为erase()返回值的赋值实际上会前进iterator,我不想跳过任何项。
现在是困难的部分:在剩余的单词列表中找到指定字母的最常见模式。这是使用分解问题技术的一个机会。我知道这个操作的子任务之一是确定一个特定的单词是否与特定的模式匹配。记住,一个模式是一个list<int>,其中每个int代表字母在单词中出现的位置,并且为了使单词与模式匹配,不仅字母必须出现在单词中指定的位置,而且字母不能出现在单词的任何其他位置。有了这个想法,我将通过遍历字符串来测试字符串是否匹配;对于字符串中的每个位置,如果指定的字母出现,我将确保该位置在模式中,如果出现其他字母,我将确保该位置不在模式中。
为了使事情更加简单,我首先将编写一个单独的函数来检查特定的位置数字是否出现在模式中:
bool numberInPattern(const list<int> & pattern, int number) {
list<int>::const_iterator iter;
iter = pattern.begin();
while (iter != pattern.end()) {
if (*iter == number) {
return true;
}
iter++;
}
return false;
}
基于之前的函数,这段代码相当简单编写。我只是遍历list,寻找number。要么我找到它并返回true,要么我到达列表的末尾并返回false。现在我可以实现通用的模式匹配测试:
bool matchesPattern(string word, char letter, list<int> pattern) {
for (int i = 0; i < word.length(); i++) {
if (word[i] == letter) {
if (!numberInPattern(pattern, i)) {
return false;
}
} else {
if (numberInPattern(pattern, i)) {
return false;
}
}
}
return true;
}
如您所见,这个函数遵循了之前概述的计划。对于字符串中的每个字符,如果它与letter匹配,代码将检查当前位置是否在模式中。如果字符不匹配letter,代码将检查该位置是否不在模式中。如果单个位置不匹配模式,则单词将被拒绝;否则,单词的末尾将被达到,单词将被接受。
在这个阶段,我突然想到,如果列表中的每个单词都包含指定的字母,那么找到最频繁出现的模式将会更容易。因此,我快速编写了一个函数来删除不包含该字母的单词:
void removeWordsWithoutLetter(list<string> & wordList,
char requiredLetter) {
list<string>::const_iterator iter;
iter = wordList.begin();
while (iter != wordList.end()) {
if (iter->find(requiredLetter) == string::npos) {
iter = wordList.erase(iter);
} else {
iter++;
}
}
}
这段代码只是之前函数中用到的想法的组合。现在我想起来,我还需要一个相反的函数,一个可以删除所有包含指定字母的单词的函数。当程序在调用最新的猜测失败时,我将使用这个函数来减少候选单词列表。
void removeWordsWithLetter(list<string> & wordList, char forbiddenLetter) {
list<string>::const_iterator iter;
iter = wordList.begin();
while (iter != wordList.end()) {
if (iter->find(forbiddenLetter) != string::npos) {
iter = wordList.erase(iter);
} else {
iter++;
}
}
}
现在,我准备找到给定字母在单词列表中最频繁出现的模式。我考虑了多种方法,并选择了我认为最容易实现的方法。首先,我将调用上面的函数来删除所有不包含指定字母的单词。然后,我将取列表中的第一个单词,确定其模式,并计算列表中有多少其他单词具有相同的模式。在计数时,所有这些单词都将从列表中删除。然后,这个过程将再次使用现在列表头部的单词重复进行,等等,直到列表为空。结果看起来像这样:
void mostFreqPatternByLetter(list<string> wordList, char letter,
list<int> & maxPattern,
int & maxPatternCount) {
removeWordsWithoutLetter(wordList, letter);
list<string>::iterator iter;
maxPatternCount = 0;
while (wordList.size() > 0) {
iter = wordList.begin();
list<int> currentPattern;
for (int i = 0; i < iter->length(); i++) {
if ((*iter)[i] == letter) {
currentPattern.push_back(i);
}
}
int currentPatternCount = 1;
iter = wordList.erase(iter);
while (iter != wordList.end()) {
if (matchesPattern(*iter, letter, currentPattern)) {
currentPatternCount++;
iter = wordList.erase(iter);
} else {
iter++;
}
}
if (currentPatternCount > maxPatternCount) {
maxPatternCount = currentPatternCount;
maxPattern = currentPattern;
}
currentPattern.clear();
}
}
list作为值参数传入
,因为这个函数在处理过程中会将列表缩减为空,我不想影响调用代码传递的参数。注意,maxPattern
和maxPatternCount
仅作为输出参数;这些将被用来将最常出现的模式和它的出现次数发送回调用代码。我移除了所有没有letter
的单词。然后我进入函数的主循环,只要列表不为空
,循环就会继续。循环内的代码有三个主要部分。首先,一个for循环构建列表中第一个单词的模式
。然后,一个while循环计算列表中有多少单词与该模式匹配
。最后,我们看到这个计数是否大于迄今为止看到的最高的计数,采用“山丘之王”策略,这是在第三章
中首次出现的。
我还需要最后一个实用函数来显示迄今为止猜测的所有字母。记住,我将它们存储为一个包含 26 个bool值的数组:
void displayGuessedLetters(bool letters[26]) {
cout << "Letters guessed: ";
for (int i = 0; i < 26; i++) {
if (letters[i]) cout << (char)('a' + i) << " ";
}
cout << "\n";
}
注意,我在一个范围的基值(在这种情况下是字符a)和一个来自另一个范围的值
上添加了基值,这是一种我们在第二章中首次使用的技巧。
现在我已经完成了所有的关键子任务,我准备尝试解决整个问题,但我这里有很多函数还没有经过完全测试,我希望它们能尽快得到测试。所以,与其一步解决剩余的问题,我打算简化这个问题。我会通过将一些变量,比如拼图单词的大小,变成常量来实现这一点。
因为我会丢弃这个版本,所以我愿意把整个游戏逻辑放入main函数中。尽管如此,由于结果很长,我打算分阶段展示代码。
int main () {
list<string> wordList = readWordFile("wordlist.txt");
const int wordLength = 8;
const int maxMisses = 9;
int misses = 0;
int discoveredLetterCount = 0;
removeWordsOfWrongLength(wordList, wordLength);
char revealedWord[wordLength + 1] = "********";
bool guessedLetters[26];
for (int i = 0; i < 26; i++) guessedLetters[i] = false;
char nextLetter;
cout << "Word so far: " << revealedWord << "\n";
这段代码的第一部分设置了我们玩游戏所需的常量和变量。大部分代码都是自解释的。单词列表是从文件
中创建的,然后根据指定的单词长度进行了缩减,在这个例子中是常量值 8
。变量misses
存储玩家 2 的错误猜测次数,而discoveredLetterCount
跟踪单词中已揭示的位置数(如果d出现两次,猜测d会使此值增加两)。revealedWord变量存储玩家 2 目前所知的谜题单词,未猜测的字母用星号表示
。guessedLetters数组中的bool
跟踪到目前为止猜测的特定字母;一个循环将所有值设置为false。最后,nextLetter
存储玩家 2 的当前猜测。我输出了初始的revealedWord,然后我就准备好进入主游戏循环了。
while (discoveredLetterCount < wordLength && misses < maxMisses) {
cout << "Letter to guess: ";
cin >> nextLetter;
guessedLetters[nextLetter - 'a'] = true;
int missingCount = countWordsWithoutLetter(wordList, nextLetter);
list<int> nextPattern;
int nextPatternCount;
mostFreqPatternByLetter(wordList, nextLetter, nextPattern, nextPatternCount);
if (missingCount > nextPatternCount) {
removeWordsWithLetter(wordList, nextLetter);
misses++;
} else {
list<int>::iterator iter = nextPattern.begin();
while (iter != nextPattern.end()) {
discoveredLetterCount++;
revealedWord[*iter] = nextLetter;
iter++;
}
wordList = reduceByPattern(wordList, nextLetter, nextPattern);
}
cout << "Word so far: " << revealedWord << "\n";
displayGuessedLetters(guessedLetters);
}
游戏结束有两种条件。要么玩家 2 发现了单词中的所有字符,使得discoveredLetterCount达到wordLength,要么玩家 2 的错误猜测完成了吊死人的游戏,在这种情况下misses将等于maxMisses。因此,只要这两个条件中的任何一个没有发生,循环就会继续
。在循环内部,在从用户那里读取下一个猜测之后,guessedLetters中的相应位置会被更新
。然后就开始作弊了。程序会确定如果猜测被宣布为失误,单词列表中会剩下多少候选词,使用countWordsWithoutLetter
,并且确定如果猜测被宣布为命中,最多会剩下多少,使用mostFreqPatternByLetter
。如果前者更大,就会淘汰包含猜测字母的单词,并将misses增加
。如果后者更大,我们会采用mostFreqPatternByLetter给出的模式,并更新revealedWord,同时从列表中移除所有不匹配该模式的单词
。
if (misses == maxMisses) {
cout << "Sorry. You lost. The word I was thinking of was '";
cout << (wordList.cbegin())->c_str() << "'.\n";
} else {
cout << "Great job. You win. Word was '" << revealedWord << "'.\n";
}
return 0;
}
代码的其余部分是我所说的“循环后事分析”,其中循环后的动作由“杀死”循环的条件决定。在这里,要么我们的程序成功地欺骗了胜利,要么玩家 2 在所有不利的情况下迫使程序揭示整个单词。请注意,当程序获胜时,列表中至少必须保留一个单词,所以我只显示第一个单词
并声称这是我一直在想的。一个更狡猾的程序可能会随机选择剩余单词中的一个,以减少对手发现作弊的机会。
初始结果分析
我已经把这些代码组合起来并测试了,它确实工作,但显然还有很多改进的空间。除了任何设计考虑之外,程序缺少很多功能。它不允许用户指定谜题单词的大小或允许的错误猜测次数。它不检查猜测的字母是否已经被猜测过。就这一点而言,它甚至不检查输入字符是否是小写字母。它缺少很多界面上的礼貌,比如告诉用户还有多少次未命中。我认为如果程序能提供再次玩的机会,而不是让用户重新运行程序,那也会很好。
关于设计,当我开始思考程序的最终版本时,我会认真考虑面向对象的设计。现在看起来wordlist类似乎是一个自然的选择。在我看来,主函数看起来太大。我喜欢模块化、易于维护的设计,这应该会导致一个短小且仅指导子程序之间交通的主函数。因此,我的主函数需要分解成几个函数。我的一些初始设计选择可能需要重新思考。例如,事后看来,将模式存储为list<int>看起来很繁琐。也许我可以尝试一个bool数组,类似于guessedLetters?
或者,也许我应该寻找另一种完全不同的结构。现在也是我退后一步看看是否有机会学习解决这个问题的新的技术的时候了。我在想是否有我尚未考虑的专门的数据结构可能是有帮助的。即使我最终坚持我的原始选择,我也可以从调查中学习很多。
尽管所有这些决定仍然悬而未决,但我感觉我在这个项目上已经取得了很大的进展。拥有一个满足问题基本要求的可工作程序是一个很好的起点。我可以轻松地在这个粗糙版本中尝试不同的设计想法,因为我已经知道我有一个解决方案,我只是在寻找更好的解决方案。
创建一个恢复点
微软 Windows 操作系统在安装或修改系统组件之前会创建它所说的恢复点。恢复点包含关键文件的备份副本,例如注册表。如果安装或更新导致严重问题,可以通过从恢复点复制文件来有效地“回滚”或撤销。
我强烈建议你对自己的源代码采取同样的方法。当你有一个你预期以后要修改的工作程序时,复制整个项目,只修改副本。这样做很快,如果你的修改出了问题,可以节省你大量的时间。程序员很容易陷入这样的陷阱,认为:“我完成过这个;因此,我可以再次完成它。”这通常是正确的,但知道你可以再次做某事和能够立即引用旧源代码之间有很大的区别。
你也可以使用版本控制软件,它自动复制和存储项目文件。版本控制软件的功能不仅限于“恢复点”功能;它还可能允许多个程序员独立地在同一文件上工作,例如。虽然这些工具超出了本书的范围,但作为程序员的发展过程中,你应该调查这些工具。
问题解决的艺术
你是否认识到了我在迄今为止的解决方案中使用的所有问题解决技巧?我有一个解决问题的计划。一如既往,这是所有问题解决技巧中最关键的。我决定从我的解决方案的第一版开始,使用我非常熟悉的一些数据结构,即数组和list类。我将功能简化,以便更容易编写我的草稿版本,并允许我比其他情况下更早地测试我的代码。我将问题分解为操作,并将每个操作变成一个不同的函数,这样我可以单独处理程序的各个部分。当我不确定如何作弊时,我会进行实验,这样我可以将“作弊”重新定义为“最大化候选单词列表的大小”,这对我来说是一个具体的编码概念。在具体编码操作时,我使用了本书中使用的类似技术。
我也成功地避免了感到沮丧,尽管我想你可能得相信我的话。
在我们继续之前,让我明确一点,我已经展示了我在解决这个问题的过程中所采取的步骤我达到这个阶段。这些步骤不一定是你解决这个问题的步骤。上面显示的代码并不是解决问题的最佳方案,也不一定比你想到的方案更好。我希望它展示的是,任何问题,无论大小,都可以使用本书中贯穿始终的基本技术的变体来解决。如果你面临的是一个比这个大两倍或十倍的问题,这可能会考验你的耐心,但你仍然可以解决它。
学习新的编程技能
还有另一个话题需要讨论。在掌握本书的问题解决技巧的过程中,你正在走上程序员生涯的关键一步。然而,与大多数职业一样,这是一条没有终点的路,因为你必须始终努力成为一个更好的程序员。与编程中的其他一切一样,你应该有一个计划,说明你将如何学习新的技能和技术,而不仅仅是相信你会在路上点点滴滴地学到新东西。
在本节中,我们将讨论你可能想要掌握的新技能的一些领域,以及每个领域的系统方法。贯穿所有这些领域的共同线索是,你必须将你想要学习的内容付诸实践。这就是为什么本书的每一章都以练习结束——你一直在做这些练习,对吧?阅读有关编程的新思想是真正学习它们的必要第一步,但仅仅是一步。为了达到能够自信地在一个现实世界问题的解决方案中应用新技术的水平,你应该首先在一个较小的、合成的练习中尝试这个技术。记住,我们基本的问题解决技术之一是将复杂问题分解,通过分割问题或暂时减少问题,使得我们处理的每个状态只有一个非平凡元素。你不想在学习将是你解决方案核心的技能的同时尝试解决一个非平凡问题,因为那样你的注意力将分散在两个困难的问题上。
新的语言
我认为 C++是一种非常适合生产代码的编程语言,我在第一章中解释了为什么我认为它也是一种非常适合学习的语言。话虽如此,没有一种编程语言在所有情况下都是优越的;因此,优秀的程序员必须学习几种。
投资时间学习
在可能的情况下,你应该在尝试用一种新语言编写生产代码之前给自己留出时间来学习这种新语言。如果你尝试解决一个你从未使用过的语言中的非平凡问题,你很快就会违反一个重要的解决问题的规则:避免挫败感。给自己设定一个学习一种语言的任务,并在你给自己分配任何“真实”程序之前完成这个任务。
当然,在现实世界中,有时我们并不完全控制我们被分配项目的时间。在任何时候,都可能有人要求我们用特定的语言编写程序,并且这个请求可能伴随着一个截止日期,这将阻止我们在解决实际问题之前悠闲地学习这种语言。最好的防御措施是在你绝对需要知道它们之前就开始学习其他编程语言。调查那些引起你兴趣的语言或那些你预期在职业生涯中编程的领域的语言。这是另一种情况,在短期内看似浪费时间的行为,在长期内会带来巨大的回报。即使最终你不需要你近期学习过的语言,学习另一种语言也可以提高你对其他已知语言的技能,因为它迫使你以新的和不同的方式思考,帮助你摆脱旧习惯,并从新的角度看待你的技能和技术。把它看作是编程的交叉训练。
从你所知开始
当你开始学习一种新的编程语言时,按照定义,你对它一无所知。如果你不是第一次学习编程语言,那么你对编程的了解就很多了。因此,学习新语言的一个好步骤是理解你已经在另一种语言中知道如何编写的代码如何在新的语言中编写。
如前所述,你想要通过实践来学习,而不仅仅是通过阅读。将你在其他语言中编写的程序重写在新语言中。系统地调查单个语言元素,例如控制语句、类、其他数据结构等。目标是尽可能地将你之前的知识转移到新语言中。
调查不同之处
下一步是研究新语言的不同之处。虽然两种高级编程语言可能有很多相似之处,但新语言中肯定有一些不同之处,否则就没有理由选择这种语言而不是其他语言。再次强调,通过实践来学习。例如,仅仅阅读一个语言的多个选择语句允许范围(而不是 C++ switch语句的单独值)并不如实际编写代码来有意义地使用这种能力对你有所帮助。
这一步对于明显不同的语言来说显然很重要,但对于有共同祖先的语言来说同样重要,例如 C++、C#和 Java,它们都是 C 的面向对象的后代。语法相似性可能会让你误以为你对新语言了解得比你实际了解的要多。考虑以下代码:
integerListClass numberList;
numberList.addInteger(15);
如果这些行被展示给你作为 C++代码,你会理解第一行创建了一个名为numberList的对象,属于integerListClass类,而第二行在对象上调用了一个addInteger方法。如果这个类实际上存在并且有一个同名的方法接受int参数,那么这段代码是完美的。现在假设我告诉你这段代码是用 Java 而不是 C++编写的。从语法上讲,这两行没有非法之处。然而,在 Java 中,仅仅声明一个类对象并不实际构造对象,因为对象变量实际上是引用——也就是说,它们的行为类似于指针。要在 Java 中执行等效步骤,正确的代码将是:
integerListClass numberList = new integerListClass;
numberList.addInteger(15);
你可能会很快注意到 Java 和 C++之间的这个特定差异,但许多其他差异可能相当微妙。如果你不花时间去发现它们,它们会在新语言中使调试变得非常困难。当你扫描代码时,你的内部编程语言解释器会给你提供关于你所阅读内容的错误信息。
研究编写良好的代码
我在这本书中一直强调,你不应该通过修改别人的代码来学习编程。然而,有时候研究别人的代码是至关重要的。虽然你可以通过编写一系列原创程序来提高你在新语言中的技能,但要达到精通的水平,你将需要寻找由精通该语言的程序员编写的代码。
你不是在寻找“抄袭”这段代码;你不是要借用这段代码来解决特定问题。相反,你是在查看现有代码,以发现该语言的“最佳实践”。看看专家程序员的代码,并问问自己,程序员不仅在做什么,而且在做为什么。如果代码附有程序员的解释,那就更好了。区分风格选择和性能优势。通过完成这一步,你将避免一个常见的陷阱。程序员往往只学会足够的新语言知识来生存,结果是代码薄弱,没有使用语言的所有功能。例如,如果你是一个需要用 Java 编写代码的 C++程序员,你不想满足于用 pidgin C++编写代码;相反,你想要学会像 Java 程序员一样编写实际的 Java 代码。
就像其他所有事情一样,将你学到的知识付诸实践。将原始代码修改成做些新的事情。将代码放在一边,尝试重新生成它。目标是让你对代码足够熟悉,以至于你可以回答其他程序员关于它的问题。
需要强调的是,这一步骤是在其他步骤之后进行的。在我们达到在新的语言中研究他人代码的阶段之前,我们已经在新的语言中学习了语法和语法,并将我们在另一种语言中学到的解决问题的技能应用到新的语言中。如果我们试图通过从研究长程序样本和修改这些样本开始学习新的语言来缩短这个过程,那么我们真的有风险只能做到这一点。
已知语言的新技能
只因为你达到了可以说你“知道”一门语言的程度,并不意味着你了解这门语言的所有内容。即使你已经掌握了这门语言的语法,也总会有新的方法来组合现有的语言特性以解决问题。这些新方法中的大多数都将属于前一章中提到的“组件”标题下的某个类别,我们在其中讨论了如何构建组件知识。重要的因素是努力。一旦你擅长以某种方式解决问题,就很容易依赖你已经知道的知识,停止作为程序员的成长。到了那个阶段,你就像一个只会投掷强力快球的棒球投手,却不知道如何投掷其他类型的球。有些投手只凭借一个球就能在职业生涯中取得成功,但那些想要从替补投手转变为首发投手的投手则需要更多。
要成为最好的程序员,你需要寻求新的知识和新的技术,并将它们付诸实践。寻找挑战并克服它们。研究你选择的语言的专家程序员的成果。
记住,需求是发明之母。寻找那些无法用你当前技能集满意解决的问题。有时你可以修改你已经解决的问题,以提供新的挑战。例如,你可能已经编写了一个当数据集较小时运行良好的程序,但当你允许数据增长到巨无霸规模时会发生什么?或者如果你已经编写了一个将数据存储在本地硬盘上的程序,但你希望数据被远程存储呢?或者当你需要程序的多重执行,这些执行可以同时访问和更新远程数据时会发生什么?通过从工作程序开始并添加新功能,你可以专注于编程的新方面。
新的库
现代编程语言与它们的内核库密不可分。当你学习 C++时,你不可避免地会了解一些关于标准模板库的知识,例如,当你学习 Java 时,你会了解标准 Java 类。然而,除了语言捆绑的库之外,你还需要学习第三方库。有时这些是通用应用程序框架,如微软的.NET 框架,它可以与几种不同的高级语言一起使用。在其他情况下,库是特定于某个特定领域的,如 OpenGL 用于图形,或者它是第三方专有软件包的一部分。
就像学习一门新语言一样,你不应该在需要该库的重大项目中学习一个新的库。相反,在将它们用于实际项目之前,你应该在一个零重要性的测试项目中单独学习库的主要组件。为自己设定一个越来越困难的解决问题的进度。记住,目标不一定是要完成任何这些问题,而是要从过程中学习,因此你不需要完善解决方案,甚至在你成功将库的这部分应用于你的程序后,也不需要完成它们。这些程序可以作为以后工作的参考。当你发现自己因为无法记住如何,比如说,在 OpenGL 中将 2D 显示叠加到 3D 场景中而陷入困境时,没有什么比打开一个专门为了演示那种技术而创建的旧程序更好了,因为这个程序是用你自己的风格编写的,因为它是由你编写的。
此外,就像学习一门新语言一样,一旦你熟悉了一个库的基本用法,你应该回顾一下那些在该库使用方面有经验的专家所编写的代码。大多数大型库都有一些官方文档没有暴露的特性和注意事项,这些只有在长期经验中才能从其他程序员那里发现。实际上,要深入理解某些库,可能需要使用其他程序员提供的框架。重要的是不要过度依赖他人的代码,而应尽快达到重新创建最初展示给你的代码的阶段。你可能会惊讶于从重新创建他人现有代码的过程中学到了多少东西。你可能会在原始代码中看到对库函数的调用,并理解这个调用中传递的参数产生了某种结果。然而,当你把这段代码放一边,并试图在自己的程序中重现那种效果时,你将不得不调查该函数的文档、所有可能的参数值以及为什么它们必须是这样的才能得到期望的效果。
上课
作为一名长期的教育工作者,我觉得我必须通过谈论课程来结束这一部分——不是在面向对象编程的意义上,而是在学校课程的意义上。无论你想学习编程的哪个领域,你都会找到有人愿意教你,无论是在传统的教室里还是在某种在线环境中。然而,课程是学习的催化剂,而不是学习本身,尤其是在编程这样的领域。无论编程讲师多么博学或热情,当你真正学习新的编程技能时,这将在你坐在电脑前发生,而不是你坐在讲堂里。正如我在整本书中反复强调的那样,你必须将编程思想付诸实践,你必须使它们成为你自己的,才能真正学会它们。
这并不是说课程没有价值——因为它们通常具有巨大的价值。编程中的一些概念本质上是难以理解或令人困惑的,如果你有机会接触到擅长解释复杂概念的讲师,这可能为你节省大量时间和挫折。此外,课程提供了对你学习的评估。如果你有幸遇到一位优秀的讲师,你可能可以从对你的代码的评估中学到很多东西,这将简化学习过程。最后,课程的顺利完成为当前或未来的雇主提供了一些证据,表明你理解所教授的主题(如果你不幸遇到一位糟糕的讲师,你至少可以从中得到安慰)。
只需记住,你的编程教育是你的责任,即使你参加了一门课程。课程会提供一个框架,让你在学期结束时获得成绩和学分,但这个框架并不限制你的学习。把你在课堂上的时间看作是一个极好的机会,尽可能多地了解这门学科,而不仅仅是课程大纲中列出的任何目标。
结论
我深情地回忆起我的第一次编程经历。我编写了一个简短的基于文本的弹球机模拟,不,这对我来说也没有什么意义,但当时肯定有。那时我并没有电脑——1976 年谁有呢?——但在我的父亲的办公室里有一台电传打字机终端,本质上是一个巨大的点阵打印机,带有咔哒咔哒的键盘,通过声学调制解调器与当地大学的计算机主机通信。(你用手拨打电话,当你听到电子尖叫时,你就把听筒放入连接到终端的特殊托架上。)尽管我的弹球机模拟既原始又无意义,但当程序运行起来,计算机按照我的指令行动时,我就上瘾了。
那天我有的感觉——一台计算机就像一个无限的乐高积木堆、Erector Sets 和林肯积木,所有这些都可以用来构建我能想象到的一切——这就是推动我对编程热爱的原因。当我的开发环境宣布构建成功,我的手指触摸到按键开始执行我的程序时,我总是既兴奋又焦虑,期待着成功或失败的结果,渴望看到我的努力成果,无论是编写一个简单的测试项目,还是在大型解决方案上完成最后的润色,或者我是在创建美丽的图形,还是在构建数据库应用程序的前端。
我希望你在编程时也有类似的感受。即使你还在这本书涵盖的一些领域挣扎,我也希望你现在明白,只要编程让你如此兴奋,你总是想坚持下去,就没有你解决不了的问题。所需要的只是愿意付出努力,并且以正确的方式处理这个过程。时间会解决剩下的问题。
你是否已经像程序员一样思考了?如果你已经解决了这些章节末尾的练习题,那么你应该像程序员一样思考,并且对自己的问题解决能力有信心。如果你没有解决很多练习题,那么我有一个建议给你,我敢打赌你能猜到是什么:解决更多的练习题。如果你在之前的章节中跳过了一些练习,不要从这一章的练习开始——回到你停止的地方,从那里继续前进。如果你不想做更多的练习,因为你不喜欢编程,那么我帮不上忙。
一旦你开始像程序员一样思考,就为你的技能感到自豪。如果有人称你为码农而不是程序员,可以说一个受过良好训练的鸟儿可以被训练出来敲代码——你不仅仅是写代码,你使用代码来解决问题。当你坐在面试桌对面,面对未来的雇主或客户时,你会知道无论工作需要什么,你都能找到解决办法。
练习
你肯定知道会有最后一组练习。当然,这些练习比之前章节中的任何练习都更具挑战性和开放性。
-
为作弊猜字游戏问题编写一个比我的更好的完整实现。
-
扩展你的猜字游戏程序,让用户可以选择成为玩家 1。用户仍然可以选择单词中的字母数量和未猜中的猜测次数,但程序负责猜测。
-
将你的猜字游戏程序用另一种你目前几乎一无所知或知之甚少的语言重写。
-
使你的“猜字谜”游戏具有图形化,实际上显示绞刑架和正在被建造的绞刑犯。你试图像程序员一样思考,而不是像艺术家一样,所以不要担心艺术的质量。你必须制作一个真正的图形程序。不要用 ASCII 文本绘制绞刑犯——那太简单了。你可能想调查 C++的 2D 图形库,或者选择一个一开始就更具图形导向的平台,比如 Flash。拥有一个图形化的“猜字谜”可能需要限制错误猜测的数量,但可能有一种方法可以至少提供这个数字的选择范围。
-
设计你自己的练习:运用你在“猜字谜”问题中学到的技能来解决一些完全不同的问题,这些问题涉及到操作单词列表,例如另一个使用单词的游戏——比如拼字游戏、拼写检查器,或者你想得到的任何其他东西。
-
设计你自己的练习:寻找一个 C++编程问题,其规模或难度如此之大,以至于你确信你曾经认为凭借你的技能解决它是不可行的,然后解决它。
-
设计你自己的练习:寻找一个你感兴趣但尚未在程序中使用过的库或 API。然后调查那个库或 API,并在一个有用的程序中使用它。如果你对通用编程感兴趣,可以考虑 Microsoft .NET 库或开源数据库库。如果你喜欢底层图形,可以考虑 OpenGL 或 DirectX。如果你想尝试制作游戏,可以考虑像 Ogre 这样的开源游戏引擎。考虑你想编写的程序类型,找到一个合适的库,然后着手去做。
-
设计你自己的练习:为一个新的平台(对你来说是新的)编写一个有用的程序——例如,移动或网页编程。


浙公网安备 33010602011771号