通过解决问题学习编程-全-
通过解决问题学习编程(全)
原文:
zh.annas-archive.org/md5/776fffc55af3496b6b163cc05128df68译者:飞龙
序言

我们使用计算机来完成任务和解决问题。例如,也许你曾使用文字处理器写作论文或信件。也许你曾使用电子表格程序整理财务。也许你曾使用图像编辑器修饰图片。如今,很难想象没有计算机我们如何完成这些任务。我们从文字处理器、电子表格程序和图像编辑器中获得了很多帮助。
这些程序是作为通用工具编写的,用来完成各种任务。然而,归根结底,它们是别人写的程序,而不是我们写的。当现成的软件无法完全满足我们的需求时,我们该怎么办呢?
在本书中,我们的目标是学习如何控制我们的计算机,超越使用现有程序的最终用户所能做到的范围。我们将编写自己的程序。我们不会编写文字处理器、电子表格或图像编辑器。这些是庞大的任务,幸运的是,别人已经完成了。相反,我们将学习如何编写小程序,解决我们原本无法解决的问题。我想帮助你学习如何与计算机沟通指令;这些指令将告诉计算机如何执行你解决问题的计划。
为了给计算机指令,我们使用编程语言编写代码。编程语言规定了我们编写代码的规则,并决定了计算机如何响应这些代码。
我们将学习如何使用 Python 编程语言编程。这是你从本书中获得的一项具体技能,你可以将其添加到简历中。然而,除了 Python 之外,你还将学习用计算机解决问题所需的思维方式。编程语言时兴时退,但我们解决问题的方式不会改变。我希望这本书能帮助你从最终用户转变为程序员,并在探索可能性时享受乐趣。
在线资源
本书的补充资源,包括可下载的代码和额外的练习,均可在nostarch.com/learn-code-solving-problems/获取。
本书适用对象
本书适合任何想学习如何编写计算机程序来解决问题的人。我有三种特别类型的人群在心中。
首先,你可能听说过 Python 编程语言,并且想学习如何编写 Python 代码。我将在下一节解释为什么 Python 是学习编程语言的一个很好的选择。在本书中,你将学到很多关于 Python 的知识,如果你下一步想深入学习 Python,你也能阅读更高级的 Python 书籍。
其次,如果你从未听说过 Python,或者只是想了解编程到底是怎么回事,别担心:这本书也是为你准备的!本书将教你如何思考编程。程序员有一种特定的方式来将问题拆解成可以管理的小块,并以代码的形式表达解决方案。在这个层面上,使用什么编程语言并不重要,因为程序员的思维方式并不依赖于某一种特定的语言。
最后,你可能有兴趣学习其他编程语言,如 C++、Java、Go 或 Rust。学习 Python 的副产品将对你学习这些其他编程语言大有帮助。而且,Python 本身就值得学习。接下来我们就来看看为什么。
为什么学习 Python?
多年的入门编程教学让我深刻体会到,Python 是作为第一门编程语言的一个极佳选择。与其他语言相比,Python 代码通常更具结构性和可读性。一旦你习惯了它,你可能会同意,它的部分代码几乎像英文一样容易理解!Python 还具有许多其他语言所没有的特性,包括强大的数据处理和存储工具。我们将在本书中使用这些特性。
Python 不仅是一个优秀的教学语言,而且是全球最受欢迎的编程语言之一。程序员使用它来编写网页应用、游戏、可视化、机器学习软件等等。
就这样:一种非常适合教学的语言,同时也能为你带来职业优势。我再也不能要求更多了!
安装 Python
在我们开始使用 Python 编程之前,我们需要先安装它。现在就来安装吧。
Python 有两个主要版本:Python 2 和 Python 3。Python 2 是 Python 的旧版本,现已不再支持。本书使用的是 Python 3,因此你需要在计算机上安装 Python 3。
Python 3 是从 Python 2 进行的重大演化,但即使在 Python 3 版本中,Python 仍在不断变化。Python 3 的第一个版本是 Python 3.0。接着发布了 Python 3.1,随后是 Python 3.2,以此类推。在写这本书时,Python 3 的最新版本是 Python 3.9。即使是 Python 3.6 版本,也足以应付本书的内容,但我还是鼓励你安装并使用最新版本的 Python。
根据你的操作系统,按照这些步骤安装 Python。
Windows
Windows 默认没有安装 Python。要安装它,请访问 www.python.org/ 并点击 Downloads。这将为你提供下载适用于 Windows 的最新版本 Python 的选项。点击链接下载 Python,然后运行安装程序。在安装过程的第一个屏幕上,点击 Add Python 3.9 to PATH 或 Add Python to environment variables;这样可以让运行 Python 更加方便。(如果是升级 Python,可能需要点击“Customize installation”来找到这个选项。)
macOS
macOS 默认没有安装 Python 3。要安装它,请访问 www.python.org/ 并点击 Downloads。这将为你提供下载适用于 macOS 的最新版本 Python 的选项。点击链接下载 Python,然后运行安装程序。
Linux
Linux 默认安装了 Python 3,但可能是较旧的版本。安装说明会根据你使用的 Linux 发行版有所不同,但你应该能够使用你最喜欢的包管理器安装最新版本的 Python。
如何阅读本书
一次性从头到尾阅读本书可能教给你的东西非常有限。这就像试图通过邀请某人到家里弹钢琴几个小时然后把他赶走,关灯并自弹自唱来学习钢琴。练习型技能并不是这样学习的。
这是我给你阅读本书时的建议:
分散你的工作时间。 将练习集中在少数几次会比分散练习效果差。当你感到疲倦时,休息一下。没人能告诉你在休息之前应该工作多久。也没人能告诉你完成本书需要多长时间。这完全取决于你自己的身心状态。
暂停来测试你的理解。 阅读某些内容可能让我们产生一种错觉,认为我们比实际理解得更透彻。应用这些材料将我们所知道的和我们认为知道的东西进行对照。因此,在每一章的关键点处,我设置了多个选择题的“概念检查”问题,要求你做出预测。请认真对待这些问题!阅读每一个问题,并在不查阅任何计算机资料的情况下做出回答。然后,阅读我的答案和解释。这是一个确认你是否走在正确道路上的机会。如果你回答错误,或者虽然回答正确但原因错误,请花时间在继续之前纠正你的理解。你可以通过进一步练习相关的 Python 特性、重新阅读书中的内容,或在线搜索额外的解释和示例来帮助自己。
练习编程。在阅读时进行预测有助于巩固你对关键概念的理解。但要成为一个熟练的问题解决者和程序员,仅仅这样还不够。你需要通过使用 Python 解决新问题来进行更多的练习,这些问题的解决方案你在书中还没有看到过。每一章最后都会列出一些练习题。请尽量完成这些练习。
学习编程需要时间。如果进展缓慢或经常犯错,不要灰心。不要被网上那些自吹自擂的“孔雀”吓到。把自己置身于能够帮助你学习的人和资源中。
使用编程评测平台
我决定将本书的结构围绕编程评测网站的问题来安排。编程评测网站提供了一个编程问题库,全球的程序员都可以解答这些问题。你提交你的解决方案——你的 Python 代码——网站会对你的代码进行测试。如果你的代码能为每个测试用例产生正确的答案,那么你的解决方案可能是正确的。如果你的代码为一个或多个测试用例产生错误答案,那么你的代码是错误的,需要进行修改。
我认为编程评测平台特别适合学习编程的原因有几个:
快速反馈 快速、针对性的反馈对于编程学习的初期阶段至关重要。编程评测平台会在你提交代码后立即提供反馈。
高质量的问题 我认为编程评测平台上的问题质量很高。许多问题最初来源于编程竞赛。其他问题则由与编程评测平台相关的个人或仅仅想帮助他人学习的人编写。请参阅《问题来源》附录,了解我们将要学习的每个问题的来源。
问题数量 编程评测平台上有成百上千个问题。我只为本书挑选了一小部分。如果你需要更多的练习,相信我:编程评测平台能提供更多。
社区功能 编程评测平台允许用户阅读并回复评论。如果你在某个问题上遇到困难,可以浏览评论,看看别人是否留下了提示。如果仍然卡住,考虑发布自己的评论请求帮助。一旦你成功解决了一个问题,学习并没有结束!许多编程评测平台允许你查看别人提交的代码。浏览一些这样的提交,看看它们与你的解决方案有何不同。总有多种方式可以解决一个问题。也许你现在觉得你的方式最直观,但开放自己去尝试其他可能性,是走向编程精通的重要一步。
创建编程评测平台账户
本书将使用多个编程评测网站。这是因为每个编程评测网站都有一些其他网站找不到的题目;我们需要多个编程评测网站来覆盖我选择的所有题目。
以下是我们将使用的编程评测网站:
| 评测网站 | 网址 |
|---|---|
| DMOJ | dmoj.ca/ |
| Timus | acm.timus.ru/ |
| USACO | usaco.org/ |
每个编程评测网站都要求在提交代码之前先创建账户。我们现在就来完成创建账户的过程,并在此过程中了解一些评测网站的特点。
DMOJ 评测网站
DMOJ 评测网站是我们在本书中使用最频繁的评测网站。比起其他任何评测网站,值得你花时间浏览 DMOJ 网站,了解它所提供的功能。
要在 DMOJ 评测网站上创建账户,访问dmoj.ca/并点击Sign up。在弹出的注册页面中,输入你的用户名、密码和电子邮件地址。该页面还允许你设置默认的编程语言。我们在本书中只会使用 Python 编程语言,因此建议在这里选择Python 3。点击Register!以创建账户。注册完成后,你可以使用用户名和密码登录 DMOJ。
本书中的每道题目都会首先指明题目所在的评测网站和你应该使用的题目代码。例如,我们将在第一章中解决的第一道题目位于 DMOJ 网站,题目代码为 dmopc15c7p2。要在 DMOJ 上找到这个题目,点击Problems,在搜索框中输入dmopc15c7p2,然后点击Go。你应该能看到这道题目作为唯一的搜索结果。如果点击题目标题,你应该能看到具体的题目内容。
当你准备好为某道题目提交 Python 代码时,找到该题目并点击Submit solution。在弹出的页面中,将你的 Python 代码粘贴到文本框中,然后点击Submit!。你的代码会被评测,结果会显示出来。
Timus 评测网站
要在 Timus 评测网站上创建账户,访问acm.timus.ru/并点击Register。在弹出的注册页面中,输入你的姓名、密码、电子邮件地址以及其他要求的信息。点击Register以创建账户。然后,检查你的电子邮件,Timus 会发送一封包含你的评测 ID 的邮件。你在提交 Python 代码时需要用到这个评测 ID。
目前没有设置默认编程语言的方式,因此每次提交 Python 代码时,请确保选择可用的 Python 3 版本。
我们只在第六章使用一次 Timus 评测网站,所以我在这里不再赘述。
USACO 评测网站
要在 USACO 评测系统上创建账户,请访问usaco.org/并点击注册新账户。在弹出的注册页面上,输入你的用户名、电子邮件地址以及其他请求的信息。点击提交以创建账户。然后,检查你的邮箱,找到 USACO 发送的包含密码的邮件。一旦你获得密码,就可以使用用户名和密码登录 USACO 了。
目前没有办法设置默认的编程语言,因此请确保每次提交 Python 代码时选择可用的 Python 3 版本。你还需要选择包含 Python 代码的文件,而不是将代码粘贴到文本框中。
我们将在第七章才开始使用 USACO 评测系统,因此这里不再多说。
关于本书
本书的每一章都以来自编程评测网站的两到三个问题为驱动。事实上,我在开始每一章时都会先提出第一个问题,然后再讲解任何新的 Python 知识!我这样做的目的是激励你学习解决问题所需的 Python 特性。如果在阅读问题描述后,你不确定如何解决问题,也不用担心。(如果你还不能解决问题,那说明你选对书了!)只要你理解问题要求做什么,那就已经准备好了。接下来,我们将一起学习 Python 并解决问题。本章的后续问题可能会介绍更多的 Python 特性,或者要求我们扩展第一个问题中学到的内容。每章的最后会有一些练习,你应该独立解决这些练习,来巩固刚刚学到的知识。
下面是我们将在每一章中学习的内容概述:
第一章:开始入门 在我们能用 Python 解决问题之前,有很多基础概念需要先学习。在这一章中,我们将学习这些概念,包括输入 Python 代码、处理字符串和数字、使用变量、读取输入和输出结果。
第二章:做决策 在这一章中,我们将学习if语句,它让我们的程序根据特定条件的真假来决定执行的操作。
第三章:重复代码:确定性循环 许多程序在有工作要做时会继续运行。在这一章中,我们将学习for循环,它让我们的程序处理每一条输入,直到任务完成。
第四章:重复代码:不确定性循环 有时我们无法提前知道程序应该重复多少次某个特定的行为。for循环不适用于这种问题。在这一章中,我们将学习while循环,它会在特定条件为真时重复执行代码。
第五章:使用列表组织值 Python 列表允许我们用一个名字来表示一整个数据序列。使用列表可以帮助我们组织数据并利用 Python 提供的强大列表操作(如排序和搜索)。在本章中,我们将全面学习列表。
第六章:使用函数设计程序 如果我们不善于组织,包含大量代码的大型程序可能会变得难以处理。在本章中,我们将学习关于函数的知识,函数帮助我们设计由小而独立的代码块组成的程序。使用函数可以使程序更容易理解和修改。我们还将学习自顶向下设计,这是一种使用函数设计程序的方法。
第七章:读写文件 文件在为我们的程序提供数据或从程序中获取数据时非常方便。在本章中,我们将学习如何从文件中读取数据和向文件中写入数据。
第八章:使用集合和字典组织值 随着我们开始解决越来越具有挑战性的问题,思考数据如何存储变得尤为重要。在本章中,我们将学习两种使用 Python 存储数据的新方法:使用集合和使用字典。
第九章:使用完全搜索设计算法 程序员在解决每个问题时并不是从零开始。相反,他们会思考是否可以使用一种通用的解决方案模式——一种算法——来解决问题。在本章中,我们将学习完全搜索算法,它可以用来解决广泛的问题。
第十章:大 O 符号与程序效率 有时候我们能写出做对的事情的程序,但执行速度太慢,无法在实际中使用。在本章中,我们将学习如何讨论程序的效率,并学习一些工具,帮助我们编写更高效的代码。
第一章:入门指南

编程涉及编写代码来解决问题。因此,我希望从一开始就和你一起解决问题。也就是说,我们不会一项一项地学习 Python 的概念再去解决问题,而是通过问题来指导我们需要学习的概念。
在这一章中,我们将解决两个问题:确定一行中的单词数量(类似于文字处理软件中的字数统计功能)和计算圆锥的体积。解决这些问题需要我们遍历许多 Python 概念。你可能觉得需要更多的细节才能完全理解我在这里介绍的一些内容,以及这些内容如何在 Python 程序设计中协同工作。别担心:我们将在后面的章节中回顾并详细讲解最重要的概念。
我们将做的事情
如引言中所述,我们将使用 Python 编程语言解决竞争性编程问题。这些竞争性编程问题可以在在线评测网站上找到。我假设你已经按照引言中的指示安装了 Python,并创建了评测账户。
对于每个问题,我们将编写一个程序来解决它。每个问题都指定了我们的程序将接受的输入类型,以及期望的输出(或结果)类型。如果我们的程序能够接受任何有效的输入并产生正确的输出,那么它就成功地解决了问题。
通常,会有数百万或数十亿种可能的输入。每种输入被称为一个问题实例。例如,在我们将要解决的第一个问题中,输入是一行文本,比如 hello there 或 bbaabbb aa abab。我们的任务是输出这行文本中的单词数。在编程中,最强大的理念之一是,通常一小段通用代码可以解决看似无穷无尽的多种问题实例。无论这一行有 2 个单词、3 个单词还是 50 个单词,程序都会每次都正确输出。
我们的程序将执行三个任务:
读取输入 我们需要确定我们正在解决的问题的具体实例,因此我们首先读取提供的输入。
过程 我们处理输入以确定正确的输出。
写出输出 解决问题后,我们产生所需的输出。
这些步骤之间的界限可能并不总是那么明确——例如,我们可能需要将一些处理和输出结合起来——但记住这三个大致的步骤将非常有帮助。
你可能每天都在使用遵循这种输入—处理—输出模型的程序。举个例子,一个计算器程序:你输入一个公式(输入),程序计算你的数字(处理),然后程序显示答案(输出)。或者考虑一个网页搜索引擎:你输入搜索查询(输入),搜索引擎确定最相关的结果(处理),然后显示它们(输出)。
将这类程序与交互式程序进行对比,后者将输入、处理和输出融合在一起。例如,我正在使用文本编辑器编写本书。当我输入一个字符时,编辑器会响应并将该字符添加到我的文档中。它不会等我输入完整个文档后才显示给我,而是会随着我的输入实时显示内容。在本书中,我们不会编写交互式程序。如果你在学习完本书后有兴趣编写此类程序,你会很高兴地知道,Python 完全能够胜任这项任务。
每个问题的文本都可以在这里和在线评测平台上找到。然而,文本内容可能有所不同,因为我已经为确保整本书的一致性对其进行了改写。别担心:我所写的内容传达的信息和官方问题陈述是一样的。
Python Shell
对于书中的每个问题,我们都需要编写一个程序并将其保存在文件中。但这前提是我们知道要写什么程序!对于书中的许多问题,我们需要先学习一些新的 Python 特性,才能解决这些问题。
尝试 Python 功能的最佳方式是使用 Python shell。这是一个交互式环境,你输入一些 Python 代码并按下 ENTER 键,Python 会显示结果。一旦我们学到足够的知识来解决当前问题,我们就会停止使用 shell,而开始在文本文件中输入解决方案。
首先,在桌面上创建一个名为programming的文件夹。我们将使用这个文件夹来存储本书中所有的工作文件。
现在,我们将导航到programming文件夹并启动 Python shell。每当你想要启动 Python shell 时,请根据你的操作系统遵循这些步骤。
Windows
在 Windows 上,执行以下操作:
-
按住 SHIFT 键,然后右键点击你的programming文件夹。
-
从弹出的菜单中,点击在此处打开 PowerShell 窗口。如果没有这个选项,点击在此处打开命令窗口。
-
在弹出的窗口底部,你会看到一行以大于号(
>)结尾。这是你的操作系统提示符,它在等待你输入命令。在这里,你输入的是操作系统命令,而不是Python 代码。每输入一个命令后,记得按下 ENTER 键。 -
你现在已经进入了programming文件夹。你可以输入 dir(表示目录)来查看当前文件夹中的内容。如果你还看不到任何文件,那是因为我们还没有创建任何文件。
-
现在,输入 python 来启动 Python shell。
启动 Python shell 时,你应该看到类似这样的界面:
Python 3.9.2 (tags/v3.9.2:1a79785, Feb 19 2021, 13:30:23)
[MSC v.1928 32 bit (Intel)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>>
这里需要注意的是,在第一行你应该看到至少为 3.6 版本的 Python。如果你的版本较旧,特别是 2.x版本,或者 Python 根本没有加载,请按照引言中的说明安装一个更新的 Python 版本。
在窗口底部,你会看到一个 >>> 的 Python 提示符。这是你输入 Python 代码的地方。切勿自己输入 >>> 符号。一旦编程完成,你可以按 CTRL-Z,然后按 ENTER 退出。
macOS
在 macOS 上,执行以下操作:
-
打开终端。你可以通过按下 COMMAND + 空格键,输入 terminal,然后双击结果来打开。
-
在弹出的窗口中,你会看到一行以美元符号(
$)结尾。这是你的操作系统 提示符,它在等待你输入命令。你在这里输入的是操作系统命令,而不是 Python 代码。每输入完一条命令后,一定要按下 ENTER 键。 -
你可以输入 ls 命令来获取当前文件夹中的文件列表。你的 桌面 应该会出现在列表中。
-
输入 cd Desktop 进入你的 桌面 文件夹。cd 命令表示 更改目录;目录 是文件夹的另一种说法。
-
输入 cd programming 来进入你的 编程 文件夹。
-
现在,输入 python3 来启动 Python Shell。(你也可以尝试输入
python,不带3,但那可能会启动旧版本的 Python 2。Python 2 不适合本书的学习。)
当你启动 Python Shell 时,你应该看到类似这样的内容:
Python 3.9.2 (default, Mar 15 2021, 17:23:44)
[Clang 11.0.0 (clang-1100.0.33.17)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
这里重要的是你看到第一行显示的 Python 版本至少为 3.6。如果你的版本较旧,尤其是 2.x,或者 Python 根本没有加载,请根据引言中的说明安装最新版本的 Python。
在窗口底部,你会看到一个 >>> 的 Python 提示符。这是你输入 Python 代码的地方。切勿自己输入 >>> 符号。一旦编程完成,你可以按 CTRL-D 来退出。
Linux
在 Linux 上,执行以下操作:
-
右键点击你的 编程 文件夹。
-
在弹出的菜单中,点击 在终端中打开。(如果你更习惯那样,也可以直接打开终端并进入你的 编程 文件夹。)
-
在弹出的窗口底部,你会看到以美元符号(
$)结尾的一行。这是你的操作系统 提示符,它在等待你输入命令。你在这里输入的是操作系统命令,而不是 Python 代码。每输入完一条命令后,一定要按下 ENTER 键。 -
你现在位于你的 编程 文件夹内。你可以输入
ls来查看文件夹中的内容。如果你什么都没看到,那是因为我们还没有创建任何文件。 -
现在,输入 python3 来启动 Python Shell。(你也可以尝试输入
python,不带3,但那可能会启动旧版本的 Python 2。Python 2 不适合本书的学习。)
当你启动 Python Shell 时,你应该看到类似这样的内容:
Python 3.9.2 (default, Feb 20 2021, 20:57:50)
[GCC 7.5.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
这里重要的是你看到第一行显示的 Python 版本至少为 3.6。如果你的版本较旧,尤其是 2.x,或者 Python 根本没有加载,请根据引言中的说明安装最新版本的 Python。
在这个窗口的底部,你会看到一个>>>的 Python 提示符。这是你输入 Python 代码的地方。请不要自己输入>>>符号。编程完成后,你可以按 CTRL-D 退出。
问题 #1:单词计数
现在是时候解决我们的第一个问题了!我们将使用 Python 编写一个小型的单词计数程序。我们将学习如何从用户获取输入,处理输入以解决问题,并输出结果。我们还将学习如何在程序中操作文本和数字,利用 Python 内置操作,并在解决问题的过程中存储中间结果。
这是 DMOJ 问题dmopc15c7p2。
挑战
计算提供的单词数。对于这个问题,单词是任何由小写字母组成的序列。例如,hello是一个单词,但像bbaabbb这样的非英语“单词”也是有效的。
输入
输入是一行文本,由小写字母和空格组成。每对单词之间有且只有一个空格,且第一个单词之前和最后一个单词之后没有空格。
每行的最大长度为 80 个字符。
输出
输出输入行中的单词数量。
字符串
值是 Python 程序的基本构建块。每个值都有一个类型,类型决定了可以对该值执行的操作。在词频统计问题中,我们处理的是一行文本。文本在 Python 中作为字符串值存储,因此我们需要了解字符串。为了解决这个问题,我们输出文本中的单词数量,因此我们还需要了解数值类型。让我们从字符串开始。
字符串表示
字符串是 Python 类型,用于存储和操作文本。要写一个字符串值,我们将其字符放在单引号之间。在 Python shell 中跟随操作:
>>> 'hello'
'hello'
>>> 'a bunch of words'
'a bunch of words'
Python shell 会回显我输入的每个字符串。
当我们的字符串包含一个单引号作为字符时会发生什么?
>>> 'don't say that'
File "<stdin>", line 1
'don't say that'
^
SyntaxError: invalid syntax
单引号在单词don't中结束了字符串。因此,剩下的部分t say that'是无意义的,这就导致了语法错误。语法错误意味着我们违反了 Python 的规则,写出了无效的 Python 代码。
为了修正这个问题,我们可以利用双引号也可以用来定界字符串这一事实:
>>> "don't say that"
"don't say that"
除非字符串中包含单引号,否则我在本书中不会使用双引号。
字符串操作符
我们可以使用字符串来保存我们想要统计单词的文本。要计算单词数——或者对字符串做其他操作——我们需要学习如何使用字符串。
字符串有丰富的操作可供我们执行。其中一些操作符在其操作数之间使用特殊符号。例如,+操作符用于字符串连接:
>>> 'hello' + 'there'
'hellothere'
哎呀——我们需要在这两个单词之间加个空格。让我们在第一个字符串的末尾加一个空格:
>>> 'hello ' + 'there'
'hello there'
还有*操作符,用于将字符串复制指定次数:
>>> '-' * 30
'------------------------------'
那个30是一个整数值。稍后我会详细讲解整数。
概念检查
以下代码的输出是什么?
>>> '' * 3
A. ''''''
B. ''
C. 这段代码会产生语法错误(无效的 Python 代码)
答案:B. ''是空字符串——一个零字符的字符串。重复三次空字符串仍然是空字符串!
字符串方法
一个方法是特定类型值的操作。字符串有许多方法。例如,有一个叫做upper的方法,它可以生成一个字符串的大写版本:
>>> 'hello'.upper()
'HELLO'
我们从一个方法得到的信息被称为该方法的返回值。例如,在之前的示例中,我们可以说upper返回了字符串'HELLO'。
对一个值执行方法称为调用该方法。调用方法时需要在值和方法名之间放置点操作符(.)。方法名后还需要加上圆括号。对于一些方法,我们可以不传递任何参数,就像调用upper一样。
对于其他方法,我们可以选择性地在其中包含信息。还有一些方法需要信息,且没有信息将无法正常工作。当我们调用一个方法时所包含的信息被称为该方法的参数。
例如,字符串有一个strip方法。如果不带参数调用,strip会去掉字符串的所有前导和尾随空格:
>>> ' abc'.strip()
'abc'
>>> ' abc '.strip()
'abc'
>>> 'abc'.strip()
'abc'
但我们也可以用一个字符串作为参数来调用它。如果这样做,那个参数决定了从字符串的开始和结尾去除哪些字符:
>>> 'abc'.strip('a')
'bc'
>>> 'abca'.strip('a')
'bc'
>>> 'abca'.strip('ac')
'b'
让我们再讨论一个字符串方法:count。我们传递给它一个字符串参数,它会告诉我们该参数在字符串中出现了多少次:
>>> 'abc'.count('a')
1
>>> 'abc'.count('q')
0
>>> 'aaabcaa'.count('a')
5
>>> 'aaabcaa'.count('ab')
1
如果参数的出现是重叠的,只有第一个算数:
>>> 'ababa'.count('aba')
1
与我之前描述的其他方法不同,count对我们的单词计数问题直接有用。
想象一个字符串,比如'this is a string with a few words'。注意到每个单词后面都有一个空格。事实上,如果你需要手动计算单词的数量,你可能会利用空格来判断每个单词的结束位置。如果我们计算字符串中的空格数量会怎样呢?为此,我们可以将一个仅包含一个空格字符的字符串传递给count。它看起来是这样的:
>>> 'this is a string with a few words'.count(' ')
7
我们得到一个值7。这不是准确的单词数量——字符串有八个单词——但我们接近了。为什么我们得到的是7而不是8呢?
这是因为每个单词后面都有一个空格,除了最后一个单词。因此,计算空格的数量无法考虑最后一个单词。为了解决这个问题,我们需要学习如何处理数字。
整数和浮动点数
一个表达式由值和操作符组成。接下来我们将看到如何写出数字值并将它们与操作符结合。
有两种不同的 Python 类型代表数字:整数(没有小数部分)和浮动点数(有小数部分)。
我们将整数值写成没有小数点的数字。这里有一些例子:
>>> 30
30
>>> 7
7
>>> 1000000
1000000
>>> -9
-9
单独的一个值是最简单的表达式类型。
熟悉的数学运算符可以作用于整数。我们有 + 用于加法,- 用于减法,* 用于乘法。我们可以使用这些运算符来编写更复杂的表达式。
>>> 8 + 10
18
>>> 8 - 10
-2
>>> 8 * 10
80
注意运算符周围的空格。虽然 8+10 和 8 + 10 对 Python 来说是一样的,但后者使得表达式对我们人类更易读。
Python 有两个除法运算符,而不是一个!// 运算符执行整数除法,会丢弃任何余数并将结果向下舍入:
>>> 8 // 2
4
>>> 9 // 5
1
>>> -9 // 5
-2
如果你想得到除法的余数,使用模运算符 %。例如,将 8 除以 2 没有余数:
>>> 8 % 2
0
将 8 除以 3 会剩余 2:
>>> 8 % 3
2
/ 运算符与 // 不同,它不会进行任何四舍五入:
>>> 8 / 2
4.0
>>> 9 / 5
1.8
>>> -9 / 5
-1.8
这些结果值不是整数!它们有一个小数点,并属于另一种 Python 类型,称为 浮点数(用于“浮动小数点数”)。你可以通过包含小数点来编写浮点值:
>>> 12.5 * 2
25.0
我们现在先集中讨论整数,稍后在本章解决圆锥体体积时再回到浮点数的内容。
当我们在一个表达式中使用多个运算符时,Python 使用优先级规则来确定运算符应用的顺序。每个运算符都有一个优先级。就像在纸面上计算数学表达式一样,Python 会先执行乘法和除法(优先级更高),然后才执行加法和减法(优先级较低):
>>> 50 + 10 * 2
70
再次和纸面上一样,括号内的操作具有最高的优先级。我们可以利用这一点来强制 Python 按照我们期望的顺序执行操作:
>>> (50 + 10) * 2
120
程序员经常在不需要的情况下也添加括号。这是因为 Python 有很多运算符,正如我们将看到的那样,追踪它们的优先级容易出错,而且这通常不是程序员做的事。
如果你在想整数值和浮点值是否像字符串一样有方法,那答案是有的!但是它们并不太有用。例如,有一个方法可以告诉我们一个整数占用了多少计算机内存。整数越大,占用的内存就越多:
>>> (5).bit_length()
3
>>> (100).bit_length()
7
>>> (99999).bit_length()
17
我们需要在整数周围加上括号;否则,点运算符会与小数点混淆,从而导致语法错误。
变量
我们现在知道如何编写字符串和数值类型的值。我们也会发现能够存储它们以便稍后访问是非常有用的。在词频统计中,将一行单词存储在某个地方,然后统计单词数是非常方便的。
赋值语句
变量 是一个引用值的名称。每当我们稍后使用变量的名称时,它会被替换成该变量所引用的值。为了让一个变量引用一个值,我们使用 赋值语句。赋值语句由一个变量、一个等号(=)和一个表达式组成。Python 会计算表达式并使变量引用结果。以下是一个赋值语句的例子:
>>> dollars = 250
现在,每当我们使用dollars时,它就被替换为250:
>>> dollars
250
>>> dollars + 10
260
>>> dollars
250
一个变量一次只能指向一个值。一旦我们用赋值语句让一个变量指向另一个值,它就不再指向旧的值:
>>> dollars = 250
>>> dollars
250
>>> dollars = 300
>>> dollars
300
我们可以有任意多个变量。大型程序通常使用成百上千个变量。这是一个使用两个变量的例子:
>>> purchase_price1 = 58
>>> purchase_price2 = 9
>>> purchase_price1 + purchase_price2
67
注意,我选择的变量名有助于表达它们所存储的内容。例如,这两个变量与两笔购买的价格有关。如果使用变量名p1和p2会更容易输入,但过几天我们可能就会忘记这些名字的含义了!
我们也可以让变量指向字符串:
>>> start = 'Monday'
>>> end = 'Friday'
>>> start
'Monday'
>>> end
'Friday'
就像指代数字的变量一样,我们可以在更大的表达式中使用这些变量:
>>> start + '-' + end
'Monday-Friday'
Python 的变量名应该以小写字母开头,之后可以包含其他字母、下划线分隔单词,以及数字。
改变变量值
假设我们有一个变量dollars,它表示值250:
>>> dollars = 250
现在我们想增加值,使得dollars表示 251。这样做是行不通的:
>>> dollars + 1
251
结果是 251,但该值已经消失,没有存储在任何地方:
>>> dollars
250
我们需要的是一个赋值语句,它捕获dollars + 1的结果:
>>> dollars = dollars + 1
>>> dollars
251
>>> dollars = dollars + 1
>>> dollars
252
学习者常常把赋值符号=误认为是等号。但不要这样做!赋值语句是一个命令,用来让变量指向一个表达式的值,而不是声称两个实体相等。
概念检查
以下代码执行后,y的值是多少?
>>> x = 37
>>> y = x + 2
>>> x = 20
A. 39
B. 22
C. 35
D. 20
E. 18
答案:A. 只有一个对y的赋值操作,它让y指向值39。x = 20赋值语句改变了x指向的值,从37变为20,但这对y指向的值没有影响。
使用变量计数单词
让我们回顾一下在解决单词计数问题上取得的进展:
-
我们已经了解了字符串,并且可以使用字符串来存储一行文字。
-
我们已经了解了字符串
count方法,可以用它来计算文字行中空格的数量。这给我们一个比所需输出值少 1 的结果。 -
我们已经了解了整数,我们可以使用其
+运算符将1加到一个数字上。 -
我们了解了变量和赋值语句,它们帮助我们保存值,以便我们不会丢失它们。
将所有这些内容结合起来,我们可以让一个变量指向一个字符串,然后计算单词的数量:
>>> line = 'this is a string with a few words'
>>> total_words = line.count(' ') + 1
>>> total_words
8
line和total_words这两个变量在这里不是必须的;我们可以不用它们来实现:
>>> 'this is a string with a few words'.count(' ') + 1
8
但是使用变量来捕捉中间结果是保持代码可读性的好习惯。一旦我们的程序超过几行,变量将变得不可或缺。
读取输入
我们编写的代码存在一个问题,那就是它只能在我们输入的特定字符串上工作。它告诉我们字符串 'this is a string with a few words' 中有八个单词,但它只能做这些。如果我们想知道另一个字符串中有多少个单词,我们就得把当前字符串替换成新的字符串。然而,要解决单词计数问题,我们需要让程序能够处理任何作为输入提供的字符串。
为了读取一行输入,我们使用 input 函数。函数类似于方法:我们调用它,可能会传递一些参数,它会返回一个值给我们。方法和函数之间的一个区别是,函数不使用点操作符。所有传递给函数的信息都是通过参数传递的。
这是一个调用 input 函数并输入内容的示例——在这种情况下,输入的内容是 testing:
>>> input()
testing
'testing'
当你输入 input() 并按下 ENTER 键时,你不会看到 >>> 提示符。相反,Python 会等待你在键盘上输入内容并按下 ENTER 键。然后,input 函数返回你输入的字符串。像往常一样,如果我们没有将这个字符串保存到任何地方,那么它就会丢失。我们可以使用赋值语句来保存我们输入的内容:
>>> result = input()
testing
>>> result
'testing'
>>> result.upper()
'TESTING'
注意到在最后一行,我对 input 返回的值使用了 upper 方法。这是允许的,因为 input 返回的是一个字符串,而 upper 是字符串方法。
输出内容
你已经看到,在 Python shell 中输入表达式会导致其值被显示:
>>> 'abc'
'abc'
>>> 'abc'.upper()
'ABC'
>>> 45 + 9
54
这只是 Python shell 提供的一种便利。它假设如果你输入一个表达式,那么你可能想看到它的值。但在 Python 程序外运行时,这种便利就不复存在了。相反,当我们想输出内容时,我们必须显式地使用 print 函数。print 函数在 shell 中也能工作:
>>> print('abc')
abc
>>> print('abc'.upper())
ABC
>>> print(45 + 9)
54
注意,print 输出的字符串没有引号。这是好的——我们可能并不希望在与程序用户的交互中包含引号!
print 的一个优点是你可以提供任意多个参数,它们会用空格分开一起输出:
>>> print('abc', 45 + 9)
abc 54
解决问题:一个完整的 Python 程序
我们现在准备通过编写一个完整的 Python 程序来解决单词计数问题。退出 Python shell,你将回到操作系统的命令提示符。
启动文本编辑器
我们将使用文本编辑器来编写代码。根据你的操作系统,按照步骤操作。
Windows
在 Windows 上,我们将使用 Notepad,一个简单的文本编辑器。在操作系统的命令提示符下,如果你还没有在 编程 文件夹中,请导航到该文件夹。然后输入 notepad word_count.py 并按 ENTER 键。由于 word_count.py 文件不存在,Notepad 会问你是否希望创建一个新的 word_count.py 文件。点击 是,然后你就可以开始输入你的 Python 程序了。
macOS
在 macOS 上,你可以使用任何你喜欢的文本编辑器。你可能已经安装的一个编辑器是 TextEdit。在操作系统命令提示符下,如果你还不在编程文件夹中,请导航到该文件夹。然后依次输入以下两个命令,并在每个命令后按下 ENTER 键:
$ touch word_count.py
$ open -a TextEdit word_count.py
touch命令会创建一个空文件,以便你的文本编辑器可以打开它。现在,你可以开始编写 Python 程序了。
Linux
在 Linux 上,你可以使用任何你喜欢的文本编辑器。你可能已经安装的一个编辑器是 gedit。在操作系统命令提示符下,如果你还不在编程文件夹中,请导航到该文件夹。然后输入gedit word_count.py并按 ENTER 键。现在你可以开始编写 Python 程序了。
程序
打开文本编辑器后,你可以开始编写我们的 Python 程序代码。代码位于列表 1-1。
❶ line = input()
❷ total_words = line.count(' ') + 1
❸ print(total_words)
列表 1-1:解决单词计数问题
在输入代码时,不要输入❶、❷或❸。这些符号是为了帮助我们逐步讲解代码,它们不是代码的一部分。
我们首先从输入中获取一行文本并将其赋值给变量❶。这将给我们一个字符串,接着我们可以使用count方法。在计算空格数量时,我们加 1 以考虑字符串中的最后一个单词,并使用变量total_words来引用这个结果❷。最后一步是输出total_words所引用的值❸。
在完成代码输入后,记得保存文件。
运行程序
要运行程序,我们将在操作系统命令提示符下使用python命令。如前所述,输入python本身会启动 Python 交互式环境,但这次我们不想这样做。相反,我们希望告诉 Python 去运行word_count.py中的程序。为此,导航到你的编程文件夹,输入python word_count.py。在本书中,如果需要,请使用python3命令代替python命令。
你的程序现在在input提示符下等待你输入内容。输入几个单词,按下 ENTER,你应该看到程序正确运行。例如,输入以下内容:
this is my first python program
你应该看到程序输出6。
如果你看到 Python 错误,请仔细检查代码,确保输入的完全正确。Python 要求精确输入,哪怕是缺少一个括号或单引号也会导致错误。
如果程序运行需要一些时间,不要感到沮丧。第一次让程序运行可能需要很多工作。我们必须能够将程序输入到文件中,调用 Python 来运行该程序,并修复因程序错误而产生的任何错误。但不管程序有多复杂,运行程序的过程都不会改变,因此你在这里花费的时间会非常值得,特别是在你完成本书后续章节时。
提交给评测系统
恭喜!希望在你的电脑上运行第一个 Python 程序让你感到满意。但我们如何知道这个程序是正确的呢?它能处理所有可能的字符串吗?我们可以在更多的字符串上测试它,但我们获得更多信心的方式是将它提交给在线评测系统。评测系统会自动运行多个测试,告诉我们是否通过了测试,或者是否有问题。
访问 dmoj.ca/ 并登录。(如果你没有 DMOJ 账户,请根据介绍中的说明创建一个。)点击问题,搜索单词计数问题代码 dmopc15c7p2。点击搜索结果加载问题—它叫做 "Not a Wall of Text" 而不是 "Word Count"。
然后你应该看到问题的文本,按照问题作者的写法。点击提交解答,并将我们的代码粘贴到文本区域。确保选择 Python 3 作为编程语言。最后,点击提交按钮。
DMOJ 会对我们的代码进行测试,并展示结果。对于每个测试用例,你将看到一个状态码。AC 代表 通过,是你希望在每个测试用例上看到的状态。其他状态码包括 WA(错误答案)和 TLE(超时)。如果你看到其中之一,检查一下你粘贴的代码,确保它与文本编辑器中的代码完全一致。
假设所有测试用例都被接受,我们应该看到我们的分数是 100/100,并且我们已经为我们的工作赚取了 3 分。
对于每个问题,我们将遵循解决单词计数问题时使用的方法。首先,我们将探索使用 Python shell,根据需要学习新的 Python 特性。然后,我们将编写一个解决问题的程序。在电脑上通过输入我们自己的测试用例来测试这个程序。最后,我们将代码提交给评测系统。如果有任何测试用例失败,我们会再次检查代码并修复问题。
问题 #2:圆锥体积
在单词计数问题中,我们需要从输入中读取一个字符串。在这个问题中,我们需要从输入中读取整数。这样做需要额外的步骤,将字符串转换为整数。我们还将学习更多关于如何在 Python 中进行数学计算的内容。
这是 DMOJ 问题 dmopc14c5p1。
挑战
计算直圆锥的体积。
输入
输入包括两行文本。第一行包含整数 r,表示圆锥的半径。第二行包含整数 h,表示圆锥的高度。r 和 h 都在 1 到 100 之间。(也就是说,r 和 h 的最小值是 1,最大值是 100。)
输出
输出直圆锥的体积,已知半径 r 和高度 h。计算体积的公式是 (πr²h)/3。
更多 Python 数学运算
假设我们有 r 和 h 两个变量,分别代表半径和高度:
>>> r = 4
>>> h = 6
现在我们想要计算(πr²h)/3。将半径4和高度6代入,公式变为(π * 4² * 6)/3。使用3.14159作为π的值,计算器给出的结果是100.531。我们该如何在 Python 中实现这个呢?
访问 Pi
为了访问π的值,我们将使用一个合适的变量。这里是一个赋值语句,将PI的值设定为具有高精度的值:
PI = 3.141592653589793
这更像是一个常量而不是变量,因为我们在代码中永远不希望更改PI的值。按照 Python 的惯例,像这种变量使用大写字母,就像我在这里做的那样。
指数运算
回顾我们的公式,(πr²h)/3,我们还没有讨论的是如何进行r²的运算。由于r²等同于r * r,我们可以使用乘法而不是指数运算。
>>> r
4
>>> r * r
16
但直接使用指数运算更为直观。我们总是希望编写尽可能清晰的代码。此外,总有一天你可能需要计算更大的指数,在那时,重复的乘法会变得越来越难以处理。Python 的指数运算符是**:
>>> r ** 2
16
这是完整的公式:
>>> (PI * r ** 2 * h) / 3
100.53096491487338
太棒了——这接近我们预期的100.531结果!
请注意,我们在这里得到的是一个浮点数。正如我们在本章的“整数与浮点数”部分讨论过的,/除法运算符会产生浮点结果。
字符串与整数的转换
最终,我们需要将半径和高度作为输入来读取。然后,我们将使用这些值来计算体积。我们来试试看:
>>> r = input()
4
>>> h = input()
6
input函数始终返回一个字符串,即使用户输入的是整数:
>>> r
'4'
>>> h
'6'
单引号确认了这些值是字符串。字符串不能用于进行数学计算。如果我们尝试,程序会报错:
>>> (PI * r ** 2 * h) / 3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for ** or pow(): 'str' and 'int'
当我们使用错误类型的值时,会生成TypeError。Python 拒绝我们在字符串r和整数2之间使用**运算符。**运算符是纯数学运算,不能用于字符串。
为了将字符串转换为整数,我们可以使用 Python 的int函数:
>>> r
'4'
>>> h
'6'
>>> r = int(r)
>>> h = int(h)
>>> r
4
>>> h
6
现在我们可以再次将这些值代入公式:
>>> (PI * r ** 2 * h) / 3
100.53096491487338
每当你有一个字符串,其中的字符表示一个整数时,你可以使用int函数将其转换为整数类型的值。它可以处理前导和尾随空格,但无法处理非数字字符:
>>> int(' 12 ')
12
>>> int('12x')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '12x'
当将input返回的字符串转换为整数时,我们可以分两步进行,首先将input的返回值赋给一个变量,然后将该值转换为整数:
>>> num = input()
82
>>> num = int(num)
>>> num
82
或者我们可以将input和int函数结合使用:
>>> num = int(input())
82
>>> num
82
这里,传递给int的参数是input返回的字符串。int函数将该字符串转换并返回一个整数。
如果我们需要进行反向转换,即从整数转换为字符串,我们可以使用str函数:
>>> num = 82
>>> 'my number is ' + num
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str
>>> str(num)
'82'
>>> 'my number is ' + str(num)
'my number is 82'
我们不能将字符串和整数连接在一起。str函数将82转换为字符串'82',使其可以用于字符串拼接。
解决问题
我们准备解决圆锥体积问题。创建一个名为 cone_volume.py 的文本文件,并输入列表 1-2 中的代码。
❶ PI = 3.141592653589793
❷ radius = int(input())
❸ height = int(input())
❹ volume = (PI * radius ** 2 * height) / 3
❺ print(volume)
列表 1-2:解决圆锥体积问题
我在代码中加入了空行,以将其分成逻辑块。Python 会忽略这些空行,但这些空行可以让我们更容易阅读和组织代码。
请注意,我使用了描述性的变量名:radius 代替 r,height 代替 h,以及 volume。单字母变量名在数学公式中是常见的,但在编写代码时,我们可以使用更具信息性的变量名。
我们首先创建一个名为 PI 的变量,表示 pi 的近似值 ❶。然后我们从输入中读取半径 ❷ 和高度 ❸,将两者从字符串转换为整数。我们使用圆锥体积的公式计算体积 ❹。最后,我们输出体积 ❺。
保存你的 cone_volume.py 文件。
运行你的程序,输入 python cone_volume.py,然后输入半径和高度的值。使用计算器验证你的程序是否输出正确结果!
如果你输入无效的半径或高度会发生什么?例如,运行你的程序并输入以下内容:
xyz
你应该会看到一个错误:
Traceback (most recent call last):
File "cone_volume.py", line 3, in <module>
radius = int(input())
ValueError: invalid literal for int() with base 10: 'xyz'
这肯定一点也不友好。但为了学习编程,我们不需要担心这个问题。裁判的所有测试用例都符合问题的输入规范,因此我们不需要担心如何处理无效输入。
说到裁判,DMOJ 应该给我们三分,因为我们已经完成了这个问题的正确代码。赶紧提交你的作业吧!
总结
我们开始了!我们通过编写 Python 代码解决了前两个问题。我们学习了编程的基础,包括值、类型、字符串、整数、方法、变量、赋值语句以及输入和输出。
一旦你熟悉了这些内容——也许通过做一些以下的练习——我们就可以继续学习第二章。在那里,我们将学习如何让程序做出决策。我们将不再编写从上到下始终执行的程序。它们将更加灵活,根据特定问题的实例进行调整。
章节练习
每一章的结尾都会有一些练习题供你尝试。我鼓励你尽可能多地完成这些练习。
有些练习可能需要很长时间。你可能会因为反复出现 Python 错误而感到沮丧。就像任何值得学习的技能一样,需要有针对性的练习。当你开始做一个练习时,我建议先手动解决几个例子。这样你就能知道问题在问什么,以及你的程序应该做什么。否则,你可能会在没有计划的情况下编写代码,一边组织思路一边编写程序。
如果你的代码不工作,问问自己:你到底想要的行为是什么?哪些代码行可能是你遇到的错误的罪魁祸首?你是否可以尝试另一种,也许更简单的方法?
我已经在书籍网站上提供了练习的解答 (nostarch.com/learn-code-solving-problems/)。但在你诚实尝试完所选的练习之前,不要偷看那些解答。尝试一次,两次,或者三次。如果你查看了解答,并了解了一个可能的解决方案,休息一下,然后从头开始自己尝试解决。解决问题的方式通常不止一种。如果你的解法做对了事情,但与我的解法不同,这并不意味着我们其中有一个是错的。相反,这为你提供了一个机会,可以将你的代码与我的代码进行对比,也许你能在过程中学习到不同的技巧。
-
DMOJ 题目
wc16c1j1,一个恐怖的季节 -
DMOJ 题目
wc15c2j1,新的希望 -
DMOJ 题目
ccc13j1,排队等待 -
DMOJ 题目
wc17c1j2,天气怎么样?(注意转换方向!) -
DMOJ 题目
wc18c3j1,诚实的一天工作(提示:你如何确定瓶盖的数量以及这些瓶盖所需的总油漆量?)
注意事项
字数统计最初来自 DMOPC ’15 年 4 月比赛。圆锥体体积最初来自 DMOPC ’14 年 3 月比赛。
第二章:做出决策

大多数我们日常使用的程序会根据其执行过程中发生的事情表现得不同。例如,当文字处理软件问我们是否保存工作时,它会根据我们的回答做出决策:如果我们回答“是”,它就保存工作;如果回答“否”,它就不保存工作。在这一章中,我们将学习if语句,它让我们的程序能够做出决策。
我们将解决两个问题:确定篮球比赛的结果和确定一个电话号码是否属于推销员。
问题 #3:获胜队伍
在这个问题中,我们需要输出一个根据篮球比赛结果不同而变化的消息。为此,我们将学习所有关于if语句的内容。我们还将学习如何在程序中存储和操作真假值。
这是 DMOJ 问题ccc19j1。
挑战
在篮球比赛中,有三种方式得分:三分球、两分球和一分罚球。
你刚刚观看了苹果队和香蕉队之间的篮球比赛,并记录了每支队伍成功的三分球、两分球和一分罚球数。请判断比赛是由苹果队获胜、香蕉队获胜还是平局。
输入
有六行输入。前三行给出苹果队的得分,后面三行给出香蕉队的得分。
-
第一行给出苹果队成功投中的三分球数。
-
第二行给出苹果队成功投中的两分球数。
-
第三行给出苹果队成功投中的一分罚球数。
-
第四行给出香蕉队成功投中的三分球数。
-
第五行给出香蕉队成功投中的两分球数。
-
第六行给出香蕉队成功投中的一分罚球数。
每个数字是 0 到 100 之间的整数。
输出
输出是一个单一字符。
-
如果苹果队得分高于香蕉队,输出
A(A代表苹果队)。 -
如果香蕉队得分高于苹果队,输出
B(B代表香蕉队)。 -
如果苹果队和香蕉队得分相同,输出
T(T代表平局)。
条件执行
我们可以利用第一章学到的知识在这里取得很大进展。我们可以使用input和int读取输入中的六个整数。我们可以使用变量保存这些值。我们可以将成功的三分球数乘以 3,将成功的两分球数乘以 2。我们可以使用print输出A、B或T。
我们还没有学习的是,如何让程序根据比赛结果做出决策。我可以通过两个测试用例演示我们为什么需要这个。
首先,考虑这个测试用例:
5
1
3
1
1
1
苹果队得分为 5 * 3 + 1 * 2 + 3 = 20 分,香蕉队得分为 1 * 3 + 1 * 2 + 1 = 6 分。苹果队赢得了比赛,所以这是正确的输出:
A
其次,考虑这个测试用例,其中苹果队和香蕉队的得分已被交换:
1
1
1
5
1
3
这一次,香蕉队赢得了比赛,因此这是正确的输出:
B
我们的程序必须能够比较苹果队和香蕉队的总得分,并利用这个比较结果来决定输出 A、B 或 T。
我们可以使用 Python 的if语句来做这些决定。一个条件是一个真假表达式,if语句利用条件来决定做什么。if语句导致条件执行,这个名字的由来是程序的执行受到条件的影响。
我们将首先了解一种新类型,允许我们表示真假值,以及如何构建这种类型的表达式。然后,我们将使用这些表达式编写 if 语句。
布尔类型
将一个表达式传递给 Python 的type函数,它会告诉你该表达式值的类型:
>>> type(14)
<class 'int'>
>>> type(9.5)
<class 'float'>
>>> type('hello')
<class 'str'>
>>> type(12 + 15)
<class 'int'>
我们还没有遇到的 Python 类型是布尔(bool)类型。与整数、字符串和浮点数不同,它们有数十亿个可能的值,布尔类型只有两个值:True 和 False。这正是我们用来表示条件结果的值。
>>> True
True
>>> False
False
>>> type(True)
<class 'bool'>
>>> type(False)
<class 'bool'>
我们能用这些值做什么呢?对于数字,我们有像 + 和 - 这样的数学运算符,允许我们将值组合成更复杂的表达式。我们需要一组新的运算符来处理布尔值。
关系运算符
5 是否大于 2?4 是否小于 1?我们可以使用 Python 的关系运算符来进行这些比较。它们返回 True 或 False,因此用于编写布尔表达式。
>运算符接受两个操作数,如果第一个大于第二个则返回True,否则返回False:
>>> 5 > 2
True
>>> 9 > 10
False
同样,我们有 < 运算符表示小于:
>>> 4 < 1
False
>>> -2 < 0
True
>= 表示大于或等于,<= 表示小于或等于:
>>> 4 >= 2
True
>>> 4 >= 4
True
>>> 4 >= 5
False
>>> 8 <= 6
False
为了判断相等,我们使用==运算符。那是两个等号,而不是一个。记住,一个等号(=)用于赋值语句;它与检查相等无关。
>>> 5 == 5
True
>>> 15 == 10
False
对于不等式,我们使用 != 运算符。如果操作数不相等,它返回 True,如果相等,它返回 False:
>>> 5 != 5
False
>>> 15 != 10
True
真实程序不会评估我们已经知道其值的表达式。我们不需要 Python 告诉我们 15 不等于 10。更常见的是,我们在这些表达式中使用变量。例如,number != 10 是一个表达式,它的值取决于 number 代表的内容。
关系运算符也适用于字符串。在检查相等性时,大小写是重要的:
>>> 'hello' == 'hello'
True
>>> 'Hello' == 'hello'
False
一个字符串比另一个字符串小,如果它在字母顺序中排在前面:
>>> 'brave' < 'cave'
True
>>> 'cave' < 'cavern'
True
>>> 'orange' < 'apple'
False
但是当同时涉及大小写字母时,事情可能会变得出人意料:
>>> 'apple' < 'Banana'
False
很奇怪吧?这与字符在计算机中存储的方式有关。通常,大写字母按字母顺序排在小写字母之前。看看这个:
>>> '10' < '4'
True
如果这些是数字,那么结果将是False。但是字符串是逐个字符地从左到右比较的。Python 比较'1'和'4',因为'1'较小,所以<运算符返回True。确保你的值具有你认为它们应该有的类型!
一个对字符串有效但对数字无效的关系运算符是in。如果第一个字符串至少出现在第二个字符串中一次,它返回True,否则返回False:
>>> 'ppl' in 'apple'
True
>>> 'ale' in 'apple'
False
概念检查
以下代码的输出是什么?
a = 3
b = (a != 3)
print(b)
A. True
B. False
C. 3
D. 这段代码会产生语法错误
答案:B. 表达式a != 3的值为False;然后b被赋值为这个False值。
if语句
现在我们来探讨 Python 的if语句的几种变体。
单独使用if
假设我们有两个变量apple_total和banana_total存储最终分数,我们希望如果apple_total大于banana_total时输出A。下面是实现方法:
>>> apple_total = 20
>>> banana_total = 6
>>> if apple_total > banana_total:
... print('A')
...
A
Python 输出A,正如我们所预期的那样。
if语句以关键字if开始。关键字是 Python 中特别有意义的单词,不能作为变量名使用。关键字if后跟布尔表达式,接着是冒号,然后是一个或多个缩进的语句。缩进的语句通常被称为if语句的代码块。当布尔表达式为True时,代码块执行;当布尔表达式为False时,代码块被跳过。
注意到提示符从>>>变成了...。这是一个提醒,表示我们在if语句的代码块内部,必须缩进代码。我选择了使用四个空格进行缩进,因此按空格键四次来缩进代码。某些 Python 程序员会按 TAB 键来缩进,但在本书中我们将仅使用空格。
一旦你输入print('A')并按下 ENTER 键,你应该看到另一个...提示符。由于我们在这个if语句中没有其他内容需要写,再按一次 ENTER 键来取消此提示符并返回到>>>提示符。这个额外的 ENTER 按键是 Python Shell 的一个特点;当我们在文件中编写 Python 程序时,不需要这些空白行。
让我们看一个例子,在if语句的代码块中放入两个语句:
>>> apple_total = 20
>>> banana_total = 6
>>> if apple_total > banana_total:
... print('A')
... print('Apples win!')
...
A
Apples win!
两个print调用都执行,输出两行内容。
让我们试试另一个if语句,这次使用一个布尔表达式,其值为False:
>>> apple_total = 6
>>> banana_total = 20
>>> if apple_total > banana_total:
... print('A')
...
这次没有调用print函数:apple_total > banana_total为False,所以if语句的代码块被跳过。
使用elif的if语句
让我们使用三个连续的if语句来打印A(如果苹果获胜),B(如果香蕉获胜),以及T(如果是平局):
>>> apple_total = 6
>>> banana_total = 6
>>> if apple_total > banana_total:
... print('A')
...
>>> if banana_total > apple_total:
... print('B')
...
>>> if apple_total == banana_total:
... print('T')
...
T
第一个和第二个if语句的代码块被跳过,因为它们的布尔表达式是False。但是第三个if语句的代码块执行了,输出了T。
当你将一个if语句放在另一个后面时,它们是独立的。每个布尔表达式都会被评估,无论前面的布尔表达式是True还是False。
对于任何给定的apple_total和banana_total的值,只有一个if语句能够运行。例如,如果apple_total > banana_total为True,那么第一个if语句将会运行,而其他两个则不会。可以编写代码突出显示只有一个代码块被允许执行。以下是我们可以如何实现:
❶ >>> if apple_total > banana_total:
... print('A')
❷ ... elif banana_total > apple_total:
... print('B')
... elif apple_total == banana_total:
... print('T')
...
T
这现在是一个单独的if语句,而不是三个独立的if语句。因此,不要在...提示符下按回车键;而是直接输入elif行。
为了执行这个if语句,Python 首先评估第一个布尔表达式 ❶。如果它为True,则输出A,并跳过其余的elif语句。如果它为False,Python 会继续,评估第二个布尔表达式 ❷。如果它为True,则输出B,并跳过剩下的elif语句。如果它为False,Python 会继续,评估第三个布尔表达式 ❸。如果它为True,则输出T。
关键字elif代表“else-if”。可以用它来提醒自己,elif表达式只有在之前的if语句没有执行时才会被检查。
这版代码等同于我们之前使用了三个独立的if语句的版本。如果我们希望允许执行多个代码块,就必须使用三个独立的if语句,而不是一个带有elif块的单个if语句。
带有else的if
我们可以使用else关键字来在if语句中的所有布尔表达式为False时执行代码。以下是一个示例:
>>> if apple_total > banana_total:
... print('A')
... elif banana_total > apple_total:
... print('B')
... else:
... print('T')
...
T
Python 会从上到下评估布尔表达式。如果其中任何一个为True,Python 会执行相关的代码块并跳过其余的if语句。如果所有布尔表达式都为False,Python 会执行else块。
请注意,代码中不再测试apple_total == banana_total。只有当apple_total > banana_total为False且banana_total > apple_total为False时,才能进入else部分,即当两个值相等时。
是否应该使用独立的if语句?使用带有elif的if语句?还是使用带有else的if语句?这通常取决于个人偏好。如果你希望最多只有一个代码块执行,可以使用一系列的elif语句。else可以帮助使代码更清晰,并且去除了编写兜底布尔表达式的需求。比起if语句的具体写法,编写正确的逻辑才是最重要的!
概念检查
以下代码执行后,x的值是多少?
x = 5
if x > 2:
x = -3
if x > 1:
x = 1
else:
x = 3
A. -3
B. 1
C. 2
D. 3
E. 5
答案:D。因为x > 2为True,所以第一个if语句的代码块会执行。赋值x = -3使得x指向-3。接下来是第二个if语句。这里,x > 1为False,因此执行else代码块,x = 3使得x指向3。我建议将if x > 1改为elif x > 1并观察程序行为如何变化!
概念检查
这两个代码片段执行的是否完全相同?假设temperature已经是一个数字。
代码片段 1:
if temperature > 0:
print('warm')
elif temperature == 0:
print('zero')
else:
print('cold')
代码片段 2:
if temperature > 0:
print('warm')
elif temperature == 0:
print('zero')
print('cold')
A. 是的
B. 不是
答案:B。代码片段 2始终将cold作为最终输出行打印,因为print('cold')没有缩进!它不与任何if语句相关联。
解题步骤
现在是时候解决“胜利团队”问题了。在本书中,我通常会先展示完整代码,然后进行讨论。但由于我们的解决方案比第一章的要长,我决定先分三部分展示代码,再将其合并。
首先,我们需要读取输入。这需要进行六次input调用,因为我们有两个队,每个队有三项信息。我们还需要将每项输入转换为整数。以下是代码:
apple_three = int(input())
apple_two = int(input())
apple_one = int(input())
banana_three = int(input())
banana_two = int(input())
banana_one = int(input())
第二,我们需要确定“苹果队”和“香蕉队”各自的得分。对于每个队,我们将三分、两分和一分的得分相加。可以按以下方式进行:
apple_total = apple_three * 3 + apple_two * 2 + apple_one
banana_total = banana_three * 3 + banana_two * 2 + banana_one
第三,我们生成输出。如果“苹果队”获胜,则输出A;如果“香蕉队”获胜,则输出B;否则,我们知道比赛是平局,因此输出T。我们使用if语句来实现这一点,如下所示:
if apple_total > banana_total:
print('A')
elif banana_total > apple_total:
print('B')
else:
print('T')
这就是我们需要的全部代码。请参见代码清单 2-1 获取完整解决方案。
apple_three = int(input())
apple_two = int(input())
apple_one = int(input())
banana_three = int(input())
banana_two = int(input())
banana_one = int(input())
apple_total = apple_three * 3 + apple_two * 2 + apple_one
banana_total = banana_three * 3 + banana_two * 2 + banana_one
if apple_total > banana_total:
print('A')
elif banana_total > apple_total:
print('B')
else:
print('T')
代码清单 2-1:解决胜利团队问题
如果您将我们的代码提交给评测系统,您应该会看到所有测试用例都通过了。
概念检查
以下版本的代码是否正确解决了问题?
apple_three = int(input())
apple_two = int(input())
apple_one = int(input())
banana_three = int(input())
banana_two = int(input())
banana_one = int(input())
apple_total = apple_three * 3 + apple_two * 2 + apple_one
banana_total = banana_three * 3 + banana_two * 2 + banana_one
if apple_total < banana_total:
print('B')
elif apple_total > banana_total:
print('A')
else:
print('T')
A. 是的
B. 不是
答案:A。操作符和代码的顺序不同,但代码仍然是正确的。如果“苹果队”输掉了比赛,则输出B(因为“香蕉队”获胜);如果“苹果队”获胜,则输出A;否则,我们知道比赛是平局,因此输出T。
在继续之前,您可能想尝试解决“章节练习”中的第 1 题,见第 45 页。
问题 #4:电话推销员
有时我们需要编码比我们目前看到的更复杂的布尔表达式。在这个问题中,我们将学习有助于此的布尔运算符。
这是 DMOJ 问题ccc18j1。
挑战
在这个问题中,我们假设电话号码是四位数。如果一个电话号码的四个数字满足以下三项条件之一,那么它就属于电话推销员:
-
第一个数字是
8或9。 -
第四位数字是
8或9。 -
第二和第三位数字相同。
例如,一个电话号码8119属于电话推销员。
确定一个电话号码是否属于电话推销员,并指示我们是否应该接电话或忽略它。
输入
输入有四行。这些行分别给出了电话号码的第一位、第二位、第三位和第四位数字。每个数字都是介于 0 和 9 之间的整数。
输出
如果电话号码属于电话推销员,输出ignore;否则,输出answer。
布尔运算符
一个电话号码如果属于电话推销员,必须满足什么条件?它的第一位数字必须是8 或 9。并且,它的第四位数字也必须是8 或 9。并且,第二位和第三位数字必须相同。我们可以使用 Python 的布尔运算符来编码这个“或”和“与”的逻辑。
or 运算符
or运算符接受两个布尔表达式作为操作数。如果至少有一个操作数为True,则返回True,否则返回False:
>>> True or True
True
>>> True or False
True
>>> False or True
True
>>> False or False
False
唯一能让or运算符返回False的情况是它的两个操作数都为False。
我们可以使用or来判断一个数字是8还是9:
>>> digit = 8
>>> digit == 8 or digit == 9
True
>>> digit = 3
>>> digit == 8 or digit == 9
False
请记住,在第一章的“整数和浮点数”中,Python 使用运算符优先级来决定运算符应用的顺序。or的优先级低于关系运算符的优先级,这意味着我们通常不需要在操作数周围加上括号。例如,在digit == 8 or digit == 9中,or的两个操作数是digit == 8和digit == 9。这与我们写成(digit == 8) or (digit == 9)是一样的。
用英语表达,“如果数字是 8 或 9”是合理的。但这样写在 Python 中不起作用:
>>> digit = 3
>>> if digit == 8 or 9:
... print('yes!')
...
yes!
注意我(错误地!)将第二个操作数写成了9,而不是digit == 9。Python 输出了yes!,这显然不是我们希望的结果,因为digit指的是3。原因是 Python 将非零数字视为True。由于9被视为True,这使得整个or表达式为True。在将自然语言翻译成 Python 时,请仔细检查你的布尔表达式,以避免这类错误。
and 运算符
and运算符返回True,当它的两个操作数都为True时,否则返回False:
>>> True and True
True
>>> True and False
False
>>> False and True
False
>>> False and False
False
唯一能让And运算符返回True的情况是它的两个操作数都为True。
and的优先级高于or。以下是为什么这很重要的一个示例:
>>> True or True and False
True
Python 会这样解析该表达式,首先执行and:
>>> True or (True and False)
True
结果为True,因为or的第一个操作数为True。
我们可以通过括号强制让or先发生:
>>> (True or True) and False
False
结果为False,因为and的第二个操作数为False。
not 运算符
另一个重要的布尔运算符是not。与or和and不同,not只接受一个操作数(而不是两个)。如果其操作数为True,则not返回False,反之亦然:
>>> not True
False
>>> not False
True
not的优先级高于or和and。
概念检查
这是一个表达式及其带括号的不同版本。哪个版本的值为 True?
A. not True and False
B. (not True) and False
C. not (True and False)
D. 以上都不是
答案:C。表达式 (True and False) 的值为 False;因此,not 会使整个表达式的值为 True。
概念检查
考虑表达式 not a or b。
以下哪项使得表达式的值为 False?
A. a False,b False
B. a False,b True
C. a True,b False
D. a True,b True
E. 以上多个选项
答案:C。如果 a 为 True,则 not a 为 False。由于 b 也为 False,所以 or 运算符的两个操作数都是 False,因此整个表达式的值为 False。
解决问题
使用布尔运算符,我们可以解决电话推销员问题。我们的解决方案在 清单 2-2 中。
num1 = int(input())
num2 = int(input())
num3 = int(input())
num4 = int(input())
❶ if ((num1 == 8 or num1 == 9) and
(num4 == 8 or num4 == 9) and
(num2 == num3)):
print('ignore')
else:
print('answer')
清单 2-2:解决电话推销员问题
如同“获胜团队”章节一样,我们首先读取输入并将其转换为整数。
我们的 if 语句的高级结构 ❶ 由三个用 and 运算符连接的表达式组成;它们每一个都必须为 True,整个表达式才为 True。我们要求第一个数字是 8 或 9,第四个数字是 8 或 9,第二个和第三个数字相等。如果这三个条件都满足,那么我们知道该电话号码属于电话推销员,我们输出 ignore。否则,电话号码不属于电话推销员,我们输出 answer。
我将布尔表达式拆分为三行。这要求将整个表达式用一对额外的括号括起来,正如我所做的那样。(如果没有这些括号,你会遇到语法错误,因为 Python 不知道该表达式将在下一行继续。)
Python 风格指南建议一行代码长度不超过 79 个字符。带有完整布尔表达式的一行代码长度为 76 个字符,正好符合要求。但我认为三行版本更清晰,将每个条件单独列出。
我们这里有一个很好的解决方案。为了进一步探索,让我们讨论一些其他的方法。
我们的代码使用布尔表达式来检测电话号码是否属于电话推销员。我们也可以选择编写代码来检测电话号码是否不属于电话推销员。如果电话号码不属于电话推销员,我们应该输出 answer;否则,我们应该输出 ignore。
如果第一个数字不是 8 且不是 9,则电话号码不属于电话推销员。或者,如果第四个数字不是 8 且不是 9,则电话号码不属于电话推销员。或者,如果第二和第三个数字不相等,则电话号码不属于电话推销员。如果这些表达式中有任何一个为 True,则电话号码不属于电话推销员。
请参见 清单 2-3 了解捕获此逻辑的代码版本。
num1 = int(input())
num2 = int(input())
num3 = int(input())
num4 = int(input())
if ((num1 != 8 and num1 != 9) or
(num4 != 8 and num4 != 9) or
(num2 != num3)):
print('answer')
else:
print('ignore')
清单 2-3:解决推销员问题,替代方法
要正确使用所有这些!=、or和and运算符可不容易!例如,注意我们已经将所有的==运算符改为!=,所有的or运算符改为and,所有的and运算符改为or。
另一种方法是使用not运算符来一次性否定“是推销员”的表达式。可以查看清单 2-4 中的代码。
num1 = int(input())
num2 = int(input())
num3 = int(input())
num4 = int(input())
if not ((num1 == 8 or num1 == 9) and
(num4 == 8 or num4 == 9) and
(num2 == num3)):
print('answer')
else:
print('ignore')
清单 2-4:解决推销员问题,使用非运算符
你认为哪一种解决方案最直观?通常有不止一种方式来构建if语句的逻辑,我们应该选择最容易正确实现的方式。对我来说,清单 2-2 是最自然的,但你可能有不同的看法!
选择你最喜欢的版本并提交给评测系统。你应该看到所有的测试用例都通过了。
注释
我们应该始终努力使我们的程序尽可能清晰。这有助于避免在编程时引入错误,并使在错误发生时更容易修复代码。具有意义的变量名、运算符周围的空格、分割程序逻辑部分的空行、简单的if语句逻辑:所有这些做法都能提高我们编写代码的质量。另一个好习惯是为我们的代码添加注释。
注释是通过#字符引入的,并且会一直持续到行末。Python 会忽略注释,因此它们对程序的执行没有任何影响。我们添加注释是为了提醒自己或他人关于我们所做的设计决策。假设阅读代码的人懂 Python,所以避免写那些只是简单重复代码功能的注释。下面是一个带有不必要注释的代码:
>>> x = 5
>>> x = x + 1 # Increase x by 1
这个注释除了我们已经知道的赋值语句之外,并没有提供任何额外的信息。
可以查看清单 2-5 中的版本,那里是清单 2-2 的注释版。
❶ # ccc18j1, Telemarketers
num1 = int(input())
num2 = int(input())
num3 = int(input())
num4 = int(input())
❷ # Telemarketer number: first digit 8 or 9, fourth digit 8 or 9,
# second digit and third digit are same
if ((num1 == 8 or num1 == 9) and
(num4 == 8 or num4 == 9) and
(num2 == num3)):
print('ignore')
else:
print('answer')
清单 2-5:解决推销员问题,已添加注释
我添加了三行注释:顶部的那一行❶提醒我们问题代码和名称,而if语句前的两行❷提醒我们如何识别推销员的电话号码。
不要过度使用注释。尽可能编写不需要注释的代码。如果代码比较复杂,或者需要记录你为何以某种方式编写代码,那么现在添加一个适当的注释将能为以后节省时间和减少挫败感。
输入和输出重定向
当你将 Python 代码提交给评测系统时,它会运行许多测试用例来确定代码是否正确。难道有人在那里,忠实地等待新代码,并且疯狂地从键盘上敲下测试用例?
不可能!这一切都是自动化的。没有人在键盘上输入测试用例。那么,如果我们通过键盘输入某些内容来满足input的调用,评测系统是如何测试我们的代码的呢?
事实上,input不一定是从键盘读取输入。它是从一个名为标准输入的输入源读取,默认情况下,标准输入就是键盘。
可以更改标准输入,使其指向文件而不是键盘。这种技术称为输入重定向,它是评测系统用来提供输入的方式。
我们也可以自己尝试输入重定向。对于输入较小的程序——比如一行文本或几个整数——输入重定向可能不会节省太多时间。但对于那些测试用例可能有几十行甚至上百行的程序,输入重定向能大大简化测试工作。我们可以将测试用例保存在文件中,然后多次运行程序,而不需要一遍遍地手动输入。
让我们尝试在电话营销程序上使用输入重定向。进入你的programming文件夹,创建一个名为telemarketers_input.txt的新文件。在该文件中输入以下内容:
8
1
1
9
该问题要求我们每行提供一个整数,所以我们在这里按行写下它们。
保存文件后,输入python telemarketers.py < telemarketers_input.txt来使用输入重定向运行程序。你的程序应该输出ignore,就像你从键盘输入测试用例时一样。
<符号指示操作系统使用文件而不是键盘提供输入。<符号后面是包含输入的文件名。
要在不同的测试用例上尝试你的程序,只需修改telemarketers_input.txt文件并重新运行程序。
我们也可以改变输出的去向,尽管在本书中我们不需要这样做。print函数默认输出到标准输出,即屏幕。我们可以改变标准输出,使其指向文件。通过使用输出重定向,它是一个>符号后跟一个文件名。
输入python telemarketers.py > telemarketers_output.txt来使用输出重定向运行程序。输入四个整数后,你应该回到操作系统的提示符。但你不应该看到来自电话营销程序的任何输出!这是因为我们将输出重定向到了文件telemarketers_output.txt。如果你在文本编辑器中打开telemarketers_output.txt,应该能在那里看到输出。
小心输出重定向。如果你使用已经存在的文件名,旧的文件将被覆盖!请始终仔细检查你使用的文件名是否是你预期的。
总结
在本章中,我们学习了如何使用if语句来控制程序的行为。if语句的关键部分是布尔表达式,它是一个值为True或False的表达式。为了构建布尔表达式,我们使用关系运算符,如==和>=,以及布尔运算符,如and和or。
根据 True 和 False 来决定做什么,使我们的程序更加灵活,能够根据实际情况做出调整。但我们的程序仍然局限于处理少量的输入和输出——无论是使用单独的 input 和 print 调用能做到什么。下一章,我们将开始学习循环,它让我们能够重复代码,从而处理任意多的输入和输出。
想要处理 100 个值吗?那 1,000 个怎么样?而且只需要一点点 Python 代码?我知道现在挑衅你有点早,因为你还需要完成以下练习。但当你准备好时,继续往下读吧!
章节练习
这里有一些练习供你尝试。
-
DMOJ 问题
ccc06j1,加拿大卡路里计算 -
DMOJ 问题
ccc15j1,特别的日子 -
DMOJ 问题
ccc15j2,快乐还是悲伤 -
DMOJ 问题
dmopc16c1p0,C.C. 和 Cheese-kun -
DMOJ 问题
ccc07j1,谁在中间
备注
《获胜团队》原本来自 2019 年加拿大计算机竞赛,初级水平。《电话推销员》原本来自 2018 年加拿大计算机竞赛,初级水平。
第三章:重复代码:确定性循环

计算机在重复执行一个过程时表现得非常出色。它们会不知疲倦地按照我们的要求执行,无论是执行 10 次、100 次,还是 10 亿次。在这一章中,我们将学习循环,它是指示计算机重复执行程序部分代码的语句。
我们将使用循环来解决三个问题:追踪球在杯子下的位置,计算占用的停车位数,以及确定手机套餐上的可用数据量。
问题 #5:三只杯子
在这个问题中,我们将追踪球在杯子下的位置,随着杯子移动而变化。但杯子可能会移动多次,因此我们不能为每次移动单独编写代码。相反,我们将学习并使用 for 循环,它可以让我们更轻松地为每次移动执行代码。
这是 DMOJ 问题 coci06c5p1。
挑战
Borko 有一排三只不透明的杯子:一个在左边(位置 1),一个在中间(位置 2),一个在右边(位置 3)。球在左边的杯子下。我们的任务是随着 Borko 交换杯子的顺序,追踪球的位置。
Borko 可以进行三种类型的交换:
A 交换左杯和中杯
B 交换中杯和右杯
C 交换左杯和右杯
例如,如果 Borko 的第一次交换是 A 类型,那么他交换左杯和中杯;因为球最初在左边,这个交换将球移动到中间。如果他的第一次交换是 B 类型,那么他交换中杯和右杯;左杯保持原位,所以球的位置不变。
输入
输入是一行最多 50 个字符的字符串。每个字符表示 Borko 进行的一种交换类型:A、B 或 C。
输出
输出球的最终位置:
-
1如果球在左边 -
2如果球在中间 -
3如果球在右边
为什么要用循环?
考虑以下测试案例:
ACBA
这里有四次交换。为了确定球的最终位置,我们需要执行每一次交换。
第一次交换是 A 类型,交换左杯和中杯。因为球最初在左边,这将球移动到中间。第二次交换是 C 类型,交换左杯和右杯。由于球目前在中间,所以这次交换对球的位置没有影响。第三次交换是 B 类型,交换中杯和右杯。这个交换将球从中间移动到右边。第四次交换是 A 类型,交换左杯和中杯。这个交换对球没有影响。因此,正确的输出是 3,因为球最终在右边。
请注意,对于每次交换,我们需要做出决策,判断球是否移动,如果移动了,如何正确地移动球。做决策是我们在第二章中学过的内容。例如,如果交换类型是A,且球在左侧,则球移动到中间。看起来是这样的:
if swap_type == 'A' and ball_location == 1:
ball_location = 2
我们可以为每个其他球移动的情况添加一个elif分支:交换类型A且球在中间,交换类型B且球在中间,交换类型B且球在右侧,等等。这个大的if语句可以处理一次交换。但这还不足以解决三杯问题,因为我们可能会有最多 50 次交换的测试案例。我们需要为每次交换重复if语句逻辑。而且我们当然不希望复制粘贴相同的代码 50 次。想象一下如果你写错了一个地方,需要修正 50 次。或者如果你突然对最多有百万次交换的测试案例产生兴趣。不会的,我们到目前为止学到的东西还远远不够。我们需要一种方法,能够遍历这些交换,为每个交换执行相同的逻辑。我们需要一个循环。
for 循环
Python 的for语句生成for 循环。for循环让我们可以处理序列中的每个元素。到目前为止,我们所见的唯一序列类型是字符串。我们将会学习其他类型;for循环适用于所有这些类型。
这是我们第一个for循环的示例:
>>> secret_word = 'olive'
>>> for char in secret_word:
... print('Letter: ' + char)
...
Letter: o
Letter: l
Letter: i
Letter: v
Letter: e
在关键字for后,我们写上一个循环变量的名字。循环变量是在循环进行时,引用不同值的变量。在字符串的for循环中,循环变量指向字符串中的每个字符。
我选择了变量名char(表示“字符”),提醒我们这个变量指代字符串中的一个字符。有时候,如果使用一个有上下文的变量名会更清晰。例如,在“三杯”问题中,我们可以使用swap_type这个名字,提醒我们它代表的是一种交换类型。
在变量名后面,我们有关键字in,然后是我们想要循环的字符串。在我们的例子中,我们正在循环遍历由secret_word表示的字符串,值为'olive'。
像if、elif和else语句中的if行一样,for行以冒号(:)结束。而且,和if语句类似,for语句也有一个缩进的语句块,里面可以包含一个或多个语句。
执行这个缩进语句块的过程称为迭代。以下是我们循环在每次迭代时的执行步骤:
-
在第一次迭代时,Python 将
char设置为指向'olive'的第一个字符'o'。然后它执行循环块,循环块中只有对print的调用。由于char指向'o',所以输出结果是Letter: o。 -
在第二次迭代时,Python 将
char设置为指向'olive'的第二个字符'l'。然后它调用print,输出Letter: l。 -
这个过程会再重复三次,每次处理
'olive'中的剩余字符。 -
然后循环终止。我们在循环后没有其他代码,所以程序已经运行完毕。如果循环后有额外的代码,执行将继续进行。
你可以在 for 循环的代码块中放入多个语句。这里有一个示例:
>>> secret_word = 'olive'
>>> for char in secret_word:
... print('Letter: ' + char)
... print('*')
...
Letter: o
*
Letter: l
*
Letter: i
*
Letter: v
*
Letter: e
*
现在我们在每次迭代时有两个语句执行:一个输出字符串的当前字母,另一个输出 * 字符。
for 循环遍历序列的元素,因此序列的长度告诉我们会有多少次迭代。len 函数接受一个字符串并返回其长度:
>>> len('olive')
5
我们对 'olive' 的 for 循环因此会进行五次迭代:
>>> secret_word = 'olive'
❶ >>> print(len(secret_word), 'iterations, coming right up!')
>>> for char in secret_word:
... print('Letter: ' + char)
...
5 iterations, coming right up!
Letter: o
Letter: l
Letter: i
Letter: v
Letter: e
我调用了带有多个参数的 print ❶,而不是使用字符串拼接,目的是避免将长度转换为字符串。
for 循环是所谓的确定性循环,即迭代次数是预定的。还有不确定性循环,它们的迭代次数依赖于程序运行时发生的随机情况。我们将在下一章学习这些。
概念检查
以下代码的输出是什么?
s = 'garage'
total = 0
for char in s:
total = total + s.count(char)
print(total)
A. 6
B. 10
C. 12
D. 36
答案:B。对于 'garage' 中的每个字符,我们将其计数加到 total 中。这里有两个 g,两个 a,一个 r,两个 a(再次出现!),两个 g(再次出现!),以及一个 e。
嵌套
for 循环块是一个或多个语句。这些语句可以包括一行语句,如函数调用和赋值语句。但它们也可以包含多行语句,如 if 语句和循环。
让我们从一个 for 循环中的 if 语句示例开始。假设我们只想输出字符串中的大写字符。字符串有一个 isupper 方法,我们可以用它来判断一个字符是否是大写字母:
>>> 'q'.isupper()
False
>>> 'Q'.isupper()
True
我们可以在 if 语句中使用 isupper 来控制每次 for 循环的迭代过程:
>>> title = 'The Escape'
>>> for char in title:
... if char.isupper():
... print(char)
...
T
E
注意这里的缩进。我们需要一个缩进层级来表示 for 循环,另一个额外的缩进层级用来表示嵌套的 if 语句。
在第一次迭代时,char 表示 'T'。由于 'T' 是大写字母,isupper 测试返回 True,于是 if 语句块执行。这导致输出 T。在第二次迭代时,char 表示 'h'。这次,isupper 测试返回 False,所以 if 语句块不执行。总体来说,for 循环遍历字符串中的每个字符,但嵌套的 if 语句只会在两个地方触发:一个是字符串开头的 'T',另一个是 'Escape' 开头的 'E'。
那么,for 循环嵌套在另一个 for 循环中怎么办?我们可以这么做!这里有一个示例:
>>> letters = 'ABC'
>>> digits = '123'
>>> for letter in letters:
... for digit in digits:
... print(letter + digit)
...
A1
A2
A3
B1
B2
B3
C1
C2
C3
这段代码生成所有由两个字符组成的字符串,第一个字符来自 letters,第二个字符来自 digits。
在外循环(letters)的第一次迭代中,letter 为 'A'。这次迭代完全运行内循环(digits)。内循环运行的整个时间,letter 都为 'A'。在内循环的第一次迭代中,digit 为 1,这就解释了 A1 的输出。在内循环的第二次迭代中,digit 为 2,并输出 A2。在内循环的第三次也是最后一次迭代中,digit 为 3,并输出 A3。
我们还没完成!我们只走了一遍外循环。在外循环的第二次迭代中,letter 变为 'B'。现在,内循环的三次迭代再次运行,这时 letter 指向 'B'。这就解释了 B1、B2 和 B3 的输出。最后,在外循环的第三次迭代中,letter 指向 'C',内循环产生 C1、C2 和 C3。
概念检查
以下代码的输出是什么?
title = 'The Escape'
total = 0
for char1 in title:
for char2 in title:
total = total + 1
print(total)
A. 10
B. 20
C. 100
D. 这段代码会产生语法错误,因为两个嵌套的循环不能同时使用 title。
答案:C. total 最初为 0,并在每次内循环迭代时增加 1。'The Escape' 的长度为 10。因此外循环有 10 次迭代。每次迭代时,内循环都会有 10 次迭代。因此内循环一共会有 10*10 = 100 次迭代。
解决问题
回到三杯问题。我们需要的结构是一个 for 循环遍历每个交换,并嵌套一个 if 语句来跟踪球的位置:
for swap_type in swaps:
# Big if statement to keep track of the ball
有三种类型的交换(A、B 和 C)和三种可能的位置,因此我们可能会得出结论,必须编写一个包含 3 * 3 = 9 个布尔表达式的 if 语句(一个在 if 后面,另外八个在每个 elif 后面)。实际上,我们只需要六个布尔表达式。九个表达式中有三个根本不移动球:当球在右侧时交换类型 A,当球在左侧时交换类型 B,当球在中间时交换类型 C。
列表 3-1 提供了三杯问题的解决方案。
swaps = input()
ball_location = 1
❶ for swap_type in swaps:
❷ if swap_type == 'A' and ball_location == 1:
❸ ball_location = 2
elif swap_type == 'A' and ball_location == 2:
ball_location = 1
elif swap_type == 'B' and ball_location == 2:
ball_location = 3
elif swap_type == 'B' and ball_location == 3:
ball_location = 2
elif swap_type == 'C' and ball_location == 1:
ball_location = 3
elif swap_type == 'C' and ball_location == 3:
ball_location = 1
print(ball_location)
列表 3-1:解决三杯问题
我使用 input 将交换字符串赋值给 swaps 变量。for 循环❶遍历这些交换。每个交换都由嵌套的 if 语句 ❷ 处理。if 和 elif 分支分别编码了给定交换类型和给定球位置时发生的情况,并根据情况移动球。例如,如果交换类型是 A 且球的位置是 1 ❷,则球最终会到达位置 2 ❸。
这是一个代码示例,展示了我们使用多个 elif(一个大的 if 语句)或多个 if(多个 if 语句)时的区别。如果我们将 elif 改为 if,那么我们的代码就不再正确了。列表 3-2 显示了错误的代码。
# This code is incorrect
swaps = input()
ball_location = 1
for swap_type in swaps:
❶ if swap_type == 'A' and ball_location == 1:
ball_location = 2
❷ if swap_type == 'A' and ball_location == 2:
ball_location = 1
if swap_type == 'B' and ball_location == 2:
ball_location = 3
if swap_type == 'B' and ball_location == 3:
ball_location = 2
if swap_type == 'C' and ball_location == 1:
ball_location = 3
if swap_type == 'C' and ball_location == 3:
ball_location = 1
print(ball_location)
列表 3-2:错误地解决三杯问题
如果我们说代码不正确,那么我们声称它至少在一个测试用例中失败了。你能找到一个测试用例,其中这个代码输出了错误的答案吗?
这里有一个这样的测试用例:
A
我们可能认为球每次交换最多只能移动一次。但 Python 会机械地执行你编写的代码,无论它是否符合我们的预期。在这种情况下,我们只有一次交换,所以球最多应移动一次。在for循环的第一次也是唯一一次迭代中,Python 检查表达式❶。它为True,因此 Python 将ball_location设置为2。然后,Python 检查表达式❷。因为我们刚刚将ball_location设置为2,这个表达式也为True!因此,Python 将ball_location设置为1。程序的输出是1,而它应该是2。
这是一个逻辑错误的例子:导致程序遵循错误逻辑并产生错误答案的错误。逻辑错误的常见术语是bug。当程序员修复代码中的 bug 时,这个过程称为调试。
通常只需要一个简单的测试用例,就能演示程序何时不正确。当你试图缩小问题范围时,不要从长的测试用例开始。这类测试用例的结果很难手动验证,并且常常会触发复杂的执行路径,我们可能从中学到的东西很少。相反,一个小的测试用例不会让程序做太多事;如果它做的事情是错误的,那么我们查找错误的范围就不大了。设计小而有针对性的测试用例并不总是容易的。这是一个可以通过练习来磨练的技能。
提交我们正确的代码给评测系统,然后继续。
在继续之前,你可以尝试解决“章节练习”中第 1 和第 2 题,见第 67 页。
问题 #6:占用的停车位
我们知道如何遍历字符串的字符。但有时我们需要知道在字符串中的位置,而不仅仅是存储在那里字符。这就是一个这样的例子。
这是 DMOJ 问题ccc18j2。
挑战
你管理一个有n个停车位的停车场。昨天,你记录了每个停车位是否被车占用,或者是空的。今天,你再次记录了每个停车位是否被车占用,或者是空的。请指出在两个日期中都被占用的停车位数量。
输入
输入由三行组成。
-
第一行包含整数n,表示停车位的数量。n的范围是 1 到 100 之间。
-
第二行包含一个长度为n的字符串,表示昨天的信息,每个字符对应一个停车位。
C表示占用的停车位(C 代表车),.表示空的停车位。例如,CC.表示前两个停车位被占用,第三个停车位为空。 -
第三行包含一个长度为n的字符串,表示今天的信息,格式与第二行相同。
输出
输出两个日期中被占用的停车位数量。
一种新的循环方式
我们最多可以有 100 个停车位,所以你可能不会惊讶地发现这里会有一个循环。我们在解决“三杯问题”时学到的那种 for 循环当然可以遍历停车位信息的字符串:
>>> yesterday = 'CC.'
>>> for parking_space in yesterday:
... print('The space is ' + parking_space)
...
The space is C
The space is C
The space is .
这告诉我们每个停车位昨天是否被占用。但我们还需要知道每个停车位今天是否也被占用。
考虑这个测试用例:
3
CC.
.C.
第一个停车位昨天被占用了。那个停车位在两天都有被占用吗?为了回答这个问题,我们需要查看今天字符串中对应的字符。它是一个 .(空的),所以这个停车位并没有在两天都被占用。
那么第二个停车位呢?那个昨天也被占用了。而且,看看今天字符串中的第二个字符,今天它也被占用了。所以这个确实是一个在两天都被占用的停车位。(这是唯一一个这样的停车位;该测试用例的正确输出是1。)
遍历一个字符串中的字符并不能帮助我们找到另一个字符串中对应的字符。但如果我们能够追踪我们在字符串中的位置——我们在第一个停车位,我们在第二个停车位,依此类推——我们就可以查找每个字符串中的对应字符。到目前为止我们学过的 for 循环并不是这种操作的方式。正确的方式是使用索引和一种新的 for 循环。
索引
字符串中的每个字符都有一个索引,它表示字符的位置。第一个字符的索引是 0,第二个字符的索引是 1,以此类推。在自然语言中,我们通常从 1 开始计数。在英语中,没有人会说“hello 中位置 0 的字符是 h。”但大多数编程语言,包括 Python,都从 0 开始计数。
要使用索引,我们在字符串后面加上方括号中的索引。以下是一些索引的示例:
>>> word = 'splore'
>>> word[0]
's'
>>> word[3]
'o'
>>> word[5]
'e'
如果我们愿意,可以在索引中使用变量:
>>> where = 2
>>> word[where]
'l'
>>> word[where + 2]
'r'
我们在非空字符串上可以使用的最高索引是其长度减去 1。(空字符串没有有效的索引。)例如,'splore'的长度是 6,因此索引 5 是它的最高索引。再大就会报错:
>>> word[len(word)]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: string index out of range
>>> word[len(word) - 1]
'e'
如何访问字符串从右数的第二个字符呢?这样就可以做到:
>>> word[len(word) - 2]
'r'
但是有一个更简单的方法。Python 支持负数索引作为访问字符的另一种选项。索引-1表示最右边的字符,索引-2表示从右数第二个字符,以此类推:
>>> word[-2]
'r'
>>> word[-1]
'e'
>>> word[-5]
'p'
>>> word[-6]
's'
>>> word[-7]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: string index out of range
计划是使用索引来访问昨天和今天停车信息的对应位置。我们可以使用每个字符串的索引 0 来访问第一个停车位的信息,索引 1 来访问第二个停车位的信息,依此类推。但在我们可以实施这个计划之前,我们需要学习一种新的 for 循环。
概念检查
以下代码的输出是什么?
s = 'abcde'
t = s[0] + s[-5] + s[len(s) - 5]
print(t)
A. aaa
B. aae
C. aee
D. 这段代码会产生错误
答案:A. 三个索引中的每一个都指向 'abcde' 中的第一个字符。首先,s[0] 指向 'a',因为 'a' 在字符串中的索引是 0。其次,s[-5] 指向 'a',因为 'a' 是从右数第五个字符。第三,s[len(s) - 5] 指向 'a',因为索引计算结果为 0:5(字符串的长度)减去 5。
范围循环
Python 的 range 函数生成整数范围,我们可以使用这些范围来控制 for 循环。与其循环遍历字符串的字符,不如使用范围 for 循环来遍历整数。如果我们向 range 提供一个参数,我们将得到一个从 0 到比该参数小 1 的范围:
>>> for num in range(5):
... print(num)
...
0
1
2
3
4
注意,5 没有被输出。
如果我们向 range 提供两个参数,我们将得到一个从第一个参数到但不包括第二个参数的序列:
>>> for num in range(3, 7):
... print(num)
...
3
4
5
6
我们可以通过包含第三个参数来按不同的 步长 递增。默认的步长是 1,即每次递增 1。让我们试试其他几个步长:
>>> for num in range(0, 10, 2):
... print(num)
...
0
2
4
6
8
>>> for num in range(0, 10, 3):
... print(num)
...
0
3
6
9
我们也可以倒数,但 不是这样:
>>> for num in range(6, 2):
... print(num)
...
这样行不通,因为默认情况下,range 是递增的。步长为 -1 让我们可以逐步递减:
>>> for num in range(6, 2, -1):
... print(num)
...
6
5
4
3
为了从 6 递减到 0(包括 0),我们需要将第二个参数设置为 -1:
>>> for num in range(6, -1, -1):
... print(num)
...
6
5
4
3
2
1
0
有时快速查看范围中的数字而不编写循环是很有帮助的。不幸的是,range 函数并不会直接显示这些数字:
>>> range(3, 7)
range(3, 7)
我们可以将该结果传递给 list 函数来获取我们想要的结果:
>>> list(range(3, 7))
[3, 4, 5, 6]
当与范围一起调用时,list 函数会生成一个包含该范围内整数的列表。我们稍后会详细学习列表;现在,请将 list 保留在脑海中,作为诊断范围错误的辅助工具。
概念检查
以下循环执行了多少次迭代?
for i in range(10, 20):
# Some code here
A. 9
B. 10
C. 11
D. 20
答案:B. 范围遍历了数字 10、11、12、13、14、15、16、17、18 和 19,共有 10 个数字,因此执行了 10 次迭代。
通过索引的范围循环
假设我们有表示昨天和今天停车位信息的字符串:
>>> yesterday = 'CC.'
>>> today = '.C.'
给定一个索引,我们可以查看昨天和今天的该索引信息:
>>> yesterday[0]
'C'
>>> today[0]
'.'
我们可以通过索引使用范围 for 循环来处理每一对对应的字符。我们知道 yesterday 和 today 的长度是相同的。但是这个长度可以是从 1 到 100 之间的任何值,所以我们不能写类似 range(3) 的东西。我们想要的迭代索引是 0、1、2,依此类推,一直到字符串长度减去 1。我们可以通过使用其中一个字符串的长度作为 range 的参数来实现:
>>> for index in range(len(yesterday)):
... print(yesterday[index], today[index])
...
C .
C C
. .
我已经将循环变量命名为 index。其他常用的名称包括 i(index 的首字母)和 ind。从现在起,我将使用 i。
不要将这个循环变量命名为 status 或 information。这些名称暗示它取值 'C' 和 '.',而实际上它取值的是整数。
解决问题
使用我们的 for 循环,我们准备好解决占用空间问题。我们的策略是从字符串的开头循环到结尾。我们可以检查每个索引在昨天和今天的停车位信息中对应的内容。使用嵌套的 if 语句,我们将判断该停车位在两天内是否都被占用。
清单 3-3 是我们的解决方案。
n = int(input())
yesterday = input()
today = input()
❶ occupied = 0
❷ for i in range(len(yesterday)):
❸ if yesterday[i] == 'C' and today[i] == 'C':
❹ occupied = occupied + 1
print(occupied)
清单 3-3:解决占用空间问题
程序首先读取三行输入:n 表示停车位数量;yesterday 和 today 分别表示昨天和今天的停车位信息。
注意,我们没有再次提到停车位的数量(n)。我们本可以利用它来告诉我们字符串的长度,但我选择忽略它,因为在实际情况中它通常没有提供。
我们使用 occupied 变量来统计昨天和今天都被占用的停车位数量。我们将这个变量初始化为 0 ❶。
现在我们进入 for 循环,循环遍历 yesterday 和 today 的有效索引 ❷。对于每个这样的索引,我们检查该停车位是否在昨天和今天都被占用 ❸。如果是,那么我们通过将 occupied 增加 1 来把这个停车位计入总数 ❹。
当 for 循环结束时,我们将已经遍历了所有停车位。昨天和今天被占用的停车位总数可以通过 occupied 变量来访问。剩下的就是输出这个总数。
这就是本题的解决方法。现在是时候将你的代码提交给评测系统了。
问题 #7:数据计划
我们已经学会了 for 循环对于处理从输入读取的数据非常有用。它们通常也非常适合用于读取数据本身。在这个问题中,我们将处理分布在多行的数据,并使用 for 循环帮助我们读取所有数据。
这是 DMOJ 问题 coci16c1p1。
挑战
Pero 与他的手机服务商有一个数据计划,每月提供 x 兆字节的数据。此外,任何未使用的数据会转到下个月。例如,如果 x 是 10 而 Pero 只使用了 4MB,那么剩余的 6MB 将会转到下个月(那时他将有 10 + 6 = 16MB 可用)。
我们给定了 Pero 在前 n 个月中每个月使用的兆字节数据。我们的任务是确定下个月可用的数据量。
输入
输入包含以下几行:
-
一行包含整数 x,表示 Pero 每月获得的兆字节数。x 在 1 到 100 之间。
-
一行包含整数 n,表示 Pero 拥有数据计划的月数。n 在 1 到 100 之间。
-
n 行,每行代表 Pero 在那个月使用的兆字节数。每个数字至少为 0,并且永远不会超过可用的兆字节数。(例如,如果 x 是 10,且 Pero 当前有 30MB 可用,那么下一个数字最多为 30。)
输出
输出下个月可用的兆字节数。
循环读取输入
在我们至今处理的所有问题中,我们都确切知道需要从输入中读取多少行。例如,在“三个杯子”问题中,我们读取了一行;在“被占用的空间”问题中,我们读取了三行。在数据计划问题中,我们无法提前知道需要读取多少行,因为这取决于我们从第二行读取的数字。
我们可以读取第一行的输入:
monthly_mb = int(input())
(我用了变量名 monthly_mb,而不是 x,以便赋予它一些意义。)
我们可以读取第二行的输入:
n = int(input())
但我们不能不使用循环而继续读取。一个 for 循环在这里非常合适,因为我们可以用它循环读取恰好 n 次:
for i in range(n):
# Process month
解题过程
我解决问题的策略是追踪从前几个月结转过来的兆字节数。我称之为 结转。
考虑这个测试用例:
10
3
4
12
1
每个月,Pero 会得到 10MB 的数据,我们需要处理他在提供的三个月内使用的数据。在第一个月,Pero 得到 10MB 并使用了 4MB,因此结转的剩余数据为 6MB。第二个月,Pero 又得到 10MB,总共有 16MB。他这月使用了 12MB,因此结转的剩余数据为 16 - 12 = 4MB。第三个月,Pero 再得到 10MB,总共有 14MB。他这月使用了 1MB,因此结转的剩余数据为 14 - 1 = 13MB。
我们需要知道 Pero 下个月(即第四个月)可用的兆字节数。他有 13MB 的结转数据,再加上本月常规的 10MB,因此他总共有 13 + 10 = 23MB 可用。
当我根据这个解释编写代码时,我忘记加上最后的 10,所以输出是 13,而不是 23。我专注于结转数据,忘记了我们需要的不是进入下个月的结转数据,而是可用的总兆字节数。总数应该是结转数据加上每个月给予的 10MB。
请参阅 清单 3-4 查看(修正过的!)代码。
monthly_mb = int(input())
n = int(input())
excess = 0
❶ for i in range(n):
used = int(input())
❷ excess = excess + monthly_mb - used
❸ print(excess + monthly_mb)
清单 3-4:解决数据计划
excess 变量初始化为 0。在每次 for 循环迭代中,我们根据每个月给定的兆字节数和该月使用的兆字节数来更新 excess。
for 循环会循环 n 次,每次对应 Pero 使用数据计划的每个月 ❶。i 的值——0、1 等等——我们并不关心,因为我们不需要关心我们正在处理的是哪个月。因此,我们在程序中并没有使用 i 的值。你可以将 i 替换成 _(下划线),以明确表示该变量的“无关”状态,但为了与其他示例保持一致,我会保留 i。
在 for 循环中,我们读取本月使用的兆字节数。然后,我们更新剩余的多余兆字节数 ❷:它等于之前的值,再加上 Pero 每月获得的兆字节数,减去 Pero 本月使用的兆字节数。
计算出 n 个月后剩余的多余兆字节数,我们报告下个月可用的兆字节数 ❸。
解决问题的方式总是有多种。编程是创造性的,我喜欢观察人们提出的各种解决策略。即使你已经成功解决了问题,你也可以通过 Google 查找该问题,了解其他人是如何解决的。另外,一些在线评测平台,比如 DMOJ,允许你在解决问题后查看其他人的提交。对于通过所有测试用例的提交:那些程序员是否做了不同的处理?对于某些测试用例失败的提交:代码出了什么问题?阅读他人的代码是提高自己编程技能的好方法!
你能想到另一种解决数据计划问题的方法吗?
这里有个提示:你可以先计算 Pero 获得的总兆字节数,然后减去他使用的兆字节数。我鼓励你在继续之前,花点时间想想如何做这道题!
给 Pero 的总兆字节数,包括下个月的,是 x * (n + 1),其中 x 是每月分配的兆字节数。为了确定下个月可用的兆字节数,我们可以从这个总数开始,减去 Pero 每月使用的兆字节数。这个策略在 列表 3-5 中有代码实现。
monthly_mb = int(input())
n = int(input())
total_mb = monthly_mb * (n + 1)
for i in range(n):
used = int(input())
total_mb = total_mb - used
print(total_mb)
列表 3-5:解决数据计划,另一种方法
选择你最喜欢的解法,并提交给评测系统。
对一个人来说直观的东西,对另一个人可能不直观。你可能会读到某个解释或代码,完全无法理解。这并不意味着你不够聪明,而是意味着你需要一种不同的展示方式,更贴近你当前的思维方式。你还可以标记那些难懂的解释和例子,留待以后复习。一旦你积累了更多的实践,它们可能会出乎意料地变得有用。
总结
在本章中,我们学习了 for 循环。标准的 for 循环用于遍历一个序列中的字符;范围 for 循环用于遍历一个范围内的整数。我们解决的每一个问题都需要处理许多输入,如果没有循环,我们是无法完成这些操作的。
for 循环是当你需要重复执行指定次数的代码时的首选循环。Python 还有另一种类型的循环,我们将在下一章学习如何使用它。为什么除了 for 循环之外,我们还需要其他的循环?for 循环做不了什么呢?好问题!现在告诉你的是:练习 for 循环是为接下来的内容做准备的一个绝佳方式。
章节习题
这里有一些习题供你尝试。
-
DMOJ 问题
wc17c3j3,无法破解 -
DMOJ 问题
coci18c3p1,Magnus -
DMOJ 问题
ccc11s1,英语或法语 -
DMOJ 问题
ccc11s2,选择题 -
DMOJ 问题
coci12c5p1,Ljestvica -
DMOJ 问题
coci13c3p1,Rijeci -
DMOJ 问题
coci18c4p1,Elder
注意事项
“三杯”最初出自 2006/2007 年克罗地亚信息学公开赛,第 5 题。占用空间最初出自 2018 年加拿大计算机竞赛,初级组。数据计划最初出自 2016/2017 年克罗地亚信息学公开赛,第 1 题。
第四章:重复代码:无限循环

在第三章中你学到的for循环和范围for循环对于循环遍历字符串或索引范围非常方便。但如果我们没有字符串,或者索引不遵循固定的模式,该怎么办?我们使用while循环,这是本章的主题。while循环比for循环更通用,能够处理for循环无法处理的情况。
我们将解决三个for循环不适用的问题:确定老丨虎丨机可以玩多少次,组织一个歌曲播放列表直到用户想停止,以及解码一个编码消息。
问题 #8:老丨虎丨机
老丨虎丨机最多能玩多少次,直到我们没有钱了?这是一个微妙的问题,它不仅取决于我们最初的资金,还取决于我们玩游戏时的中奖模式。我们会看到,在这种情况下,我们需要使用while循环,而不是for循环。
这是 DMOJ 题目ccc00s1。
挑战
玛莎去赌场并带了n个硬币。赌场有三台老丨虎丨机,她按顺序玩它们,直到她没有硬币为止。也就是说,她先玩第一台老丨虎丨机,然后是第二台,再是第三台,然后回到第一台,接着是第二台,依此类推。每次游戏消耗一个硬币。
老丨虎丨机的规则如下:
-
第一台老丨虎丨机每玩第 35 次就会支付 30 个币。
-
第二台老丨虎丨机每玩第 100 次就会支付 60 个币。
-
第三台老丨虎丨机每玩第 10 次就会支付 9 个币。
-
没有其他的游戏会支付任何奖金。
确定玛莎在没有硬币之前玩了多少次。
输入
输入由四行组成。
-
第一行包含一个整数n,表示玛莎带到赌场的硬币数量。n在 1 和 1000 之间。
-
第二行包含一个整数,表示第一台老丨虎丨机自上次支付以来已经被玩了多少次。这些游戏发生在玛莎到达之前,玛莎的游戏从那里开始。例如,假设第一台老丨虎丨机自上次支付以来已经被玩了 34 次。那么玛莎第一次玩时将获得 30 个币。
-
第三行包含一个整数,表示第二台老丨虎丨机自上次支付以来已经被玩了多少次。
-
第四行包含一个整数,表示自上次支付以来第三台老丨虎丨机已经被玩了多少次。
输出
输出以下句子,其中 x 是玛莎在没有硬币之前玩了多少次:
Martha plays x times before going broke.
探索测试用例
让我们通过一个例子来确保这个问题中的所有内容都清楚。以下是我们将使用的测试用例:
7
28
0
8
为了仔细追踪玛莎的游戏,我们需要记录六个信息。使用表格来做这件事非常方便,因为每一行都可以告诉我们每次游戏后的状态。下面是我们的列:
游戏次数 是指玛莎已经玩过多少台老丨虎丨机
硬币数量 是指玛莎拥有的硬币数
下一次游戏 是玛莎将要玩的老丨虎丨机
第一次游戏 是指自上次支付以来,第一台老丨虎丨机被玩了多少次
第二次游戏 是指自上次支付以来,第二台老丨虎丨机被玩了多少次
第三次游戏 是指自上次支付以来,第三台老丨虎丨机被玩了多少次
一开始,玛莎没有玩过任何老丨虎丨机,她有七枚硬币,接下来她将玩第一台老丨虎丨机。第一台老丨虎丨机自上次支付以来已经被玩了 28 次,第二台已经被玩了 0 次,第三台已经被玩了 8 次。我们的状态如下:
| 游戏次数 | 硬币数量 | 下一次游戏 | 第一次游戏 | 第二次游戏 | 第三次游戏 |
|---|---|---|---|---|---|
| 0 | 7 | first | 28 | 0 | 8 |
玛莎开始玩第一台老丨虎丨机,花费一枚硬币。因为这是自上次支付以来,这台机器已经被玩了 29 次,而不是第 35 次,所以下这台老丨虎丨机并不会给玛莎任何奖励。接下来,玛莎将玩第二台老丨虎丨机。这是我们的新状态:
| 游戏次数 | 硬币数量 | 下一次游戏 | 第一次游戏 | 第二次游戏 | 第三次游戏 |
|---|---|---|---|---|---|
| 1 | 6 | second | 29 | 0 | 8 |
玩第二台老丨虎丨机需要花费一枚硬币。因为这是自上次支付以来,这台机器第一次被玩,而不是第 100 次,所以下这台老丨虎丨机并不会给玛莎任何奖励。接下来,玛莎将玩第三台老丨虎丨机。这是我们的新状态:
| 游戏次数 | 硬币数量 | 下一次游戏 | 第一次游戏 | 第二次游戏 | 第三次游戏 |
|---|---|---|---|---|---|
| 2 | 5 | third | 29 | 1 | 8 |
玩第三台老丨虎丨机需要花费一枚硬币。因为这是自上次支付以来,这台机器已经被玩了 9 次,而不是第 10 次,所以下这台老丨虎丨机并不会给玛莎任何奖励。接下来,玛莎将返回到第一台老丨虎丨机。 这是我们的新状态:
| 游戏次数 | 硬币数量 | 下一次游戏 | 第一次游戏 | 第二次游戏 | 第三次游戏 |
|---|---|---|---|---|---|
| 3 | 4 | first | 29 | 1 | 9 |
现在玛莎玩第一台老丨虎丨机:
| 游戏次数 | 硬币数量 | 下一次游戏 | 第一次游戏 | 第二次游戏 | 第三次游戏 |
|---|---|---|---|---|---|
| 4 | 3 | second | 30 | 1 | 9 |
然后玛莎玩第二台老丨虎丨机:
| 游戏次数 | 硬币数量 | 下一次游戏 | 第一次游戏 | 第二次游戏 | 第三次游戏 |
|---|---|---|---|---|---|
| 5 | 2 | third | 30 | 2 | 9 |
玛莎快没硬币了!但好消息来了,因为她接下来要玩第三个老丨虎丨机。自从上次支付以来,这台机器已经被玩了九次。因此,下次游戏是它的第十次,这次会支付玛莎九个硬币。她原本有两个硬币,支付一个硬币来玩这台机器,然后获得九个硬币,所以这次游戏后她将有 2 – 1 + 9 = 10 个硬币:
| 游戏次数 | 投币次数 | 下次游戏 | 第一次游戏 | 第二次游戏 | 第三次游戏 |
|---|---|---|---|---|---|
| 6 | 10 | 第一次 | 30 | 2 | 0 |
注意,第三台老丨虎丨机自上次支付以来,已经玩了零次。
到目前为止已经进行了六次游戏。我鼓励你继续追踪。你应该能看到玛莎再也没有拿到任何报酬,而且在再进行 10 次游戏(共 16 次)后,玛莎破产了。
for 循环的局限性
在 第三章中,我们学习了 for 循环。标准的 for 循环是遍历一个序列,比如字符串。但在老丨虎丨机问题中,我们显然没有字符串。
Range for 循环遍历一系列整数,并且可以用于循环指定次数。但我们应该为老丨虎丨机循环多少次呢?十次?五十次?谁知道呢。这取决于玛莎在破产前能玩多少次。
我们没有字符串,也不知道需要多少次迭代。如果我们只有 for 循环,那就会卡住。
进入 while 循环,Python 提供的最通用的循环结构。我们可以编写与字符串或整数序列无关的 while 循环。为了获得这种灵活性,我们需要更加小心,并且在编写循环时承担更多责任。让我们深入了解吧!
while 循环
要编写一个 while 循环,我们使用 Python 的 while 语句。while 循环由一个布尔表达式控制。如果布尔表达式为 True,Python 就会执行一次 while 循环。如果该表达式仍然为 True,Python 将执行另一次循环,以此类推,直到布尔表达式为 False。如果一开始布尔表达式为 False,则循环根本不会执行。
while 循环是 不定次循环:循环的次数可能事先无法知道。
使用 while 循环
让我们从以下 while 循环的例子开始:
❶ >>> num = 0
❷ >>> while num < 5:
... print(num)
❸ ... num = num + 1
...
0
1
2
3
4
在 for 循环中,循环变量是由 Python 自动创建的;我们不需要在循环之前使用赋值语句来创建变量。但在 while 循环中,我们什么也得不到。如果我们需要一个变量来遍历 while 循环中的值,那么我们必须自己创建这个变量。我们通过在循环之前让 num 指向 0 来做到这一点 ❶。
while 循环本身由布尔表达式 num < 5 ❷ 控制。如果 num < 5 为 True,那么循环体中的代码将执行。现在,num 为 0,所以布尔表达式为 True。因此,我们运行循环体,输出 0,然后将 num 增加到 1 ❸。
我们跳回循环的顶部,再次评估num < 5的布尔表达式。由于num为1,表达式为True。因此我们再次执行循环块,输出1,并将num增加到2。
回到循环的开始:num < 5还为True吗?是的,因为num现在是2。这会启动循环的另一次迭代,输出2,并将num增加到3。
这种模式会继续下去,循环还有两次迭代:一次当num为3时,一次当num为4时。当num为5时,num < 5的布尔表达式最终为False,循环结束。
我们必须记得增加num ❸。for循环会自动将我们的循环变量推进到适当的值。然而,在while循环中,我们没有免费的“进展”,必须自己更新变量,以便逐渐接近循环终止。如果忘记增加num,会发生如下情况:
>>> num = 0
>>> while num < 5:
... print(num)
...
0
0
0
0
0
0
0
0
... forever
如果你在电脑上运行这段代码,屏幕将被零填满,程序会一直运行下去,直到你终止它。你可以按 CTRL-C 或关闭 Python 窗口来终止程序。
问题是num < 5永远为True;循环中的任何东西都无法让它变为False。这种循环永不终止的情况叫做无限循环。意外地产生无限while循环其实非常容易。如果你看到相同的值不断重复,或者程序似乎什么都没做,那可能是你陷入了一个无限循环。仔细检查while循环的布尔表达式,确保循环块正在朝着终止的方向推进。
我们可以对num变量进行任何操作。以下是一个while循环,每次增加三:
>>> num = 0
>>> while num < 10:
... print(num)
... num = num + 3
...
0
3
6
9
这里是一个while循环,从4倒数到0:
>>> num = 4
❶ >>> while num >= 0:
... print(num)
... num = num - 1
...
4
3
2
1
0
请注意,我在这里使用了>=而不是> ❶。这样,while循环会在num为0时执行,正是我们想要的效果。
概念检查
以下代码的输出是什么?
n = 3
while n > 0:
if n == 5:
n = -100
print(n)
n = n + 1
A.
3
4
B.
3
4
5
C.
3
4
-100
D.
3
4
5
-100
答案:C。while循环的布尔表达式只在每次迭代开始时检查。即使在迭代过程中某个时刻它变为False,迭代的剩余部分仍会完成。
由于3大于0,循环执行一次。if语句块被跳过(因为它的布尔表达式为False),所以这次迭代输出3,并将n设为4。由于4大于0,我们再次执行循环,这次输出4并将n设为5。由于5大于0,我们再次执行循环。这时,if语句块执行,将n设为-100。接下来,输出-100,并将n设为-99。我们在这里停止,因为n > 0为False。
概念检查
以下代码的输出是什么?
x = 6
while x > 4:
x = x - 1
print(x)
A.
6
5
B.
6
5
4
C.
5
4
D.
5
4
3
E.
6
5
4
3
答案:C. 许多while循环会执行一些操作然后更新循环变量,但这个循环不是这样。这个循环首先递减循环变量x,然后输出它。由于6大于4,循环执行了一次,x被赋值为5并输出5。接着,5大于4,所以我们又进行了一次迭代,这次将x赋值为4并输出4。就这样:4不大于4,所以循环终止。
循环中的嵌套循环
我们可以在while循环中嵌套循环,就像我们可以在for循环中嵌套循环一样。在第三章的“嵌套”中,我提到过,内部for循环在外部循环的下一次迭代开始之前完成所有的迭代。while循环也一样。这里有一个例子:
>>> i = 0
>>> while i < 3:
... j = 8
... while j < 11:
... print(i, j)
... j = j + 1
... i = i + 1
...
0 8
0 9
0 10
1 8
1 9
1 10
2 8
2 9
2 10
每个i值涉及到三行输出,每行对应内部j循环的一次迭代。
概念检查
以下嵌套循环会输出多少行?
x = 0
y = 1
while x < 3:
while y < 3:
print(x, y)
y = y + 1
x = x + 1
A. 2
B. 3
C. 6
D. 8
E. 9
答案:A. 外部循环的布尔表达式x < 3为True,所以我们执行一次外部循环的迭代。这导致内部循环进行了两次迭代:一次当y为1时,另一次当y为2时,每次都会打印一行输出。所以到目前为止,输出了两行。
但代码中没有任何内容重置y的值!因此,y < 3将再也不会为True,也不会有更多的内部循环迭代。
忘记重置循环变量是处理嵌套while循环时常见的错误。
添加布尔运算符
为了解决老丨虎丨机问题,我们希望在玛莎至少有一枚硬币的情况下继续循环。像这样:
while quarters >= 1:
这个简单的布尔表达式足以解决这个问题。但就像if语句一样,跟在while后面的布尔表达式可以包含关系运算符或布尔运算符。这里有一个例子:
>>> x = 4
>>> y = 10
>>> while x <= 10 and y <= 13:
... print(x, y)
... x = x + 1
... y = y + 1
...
4 10
5 11
6 12
7 13
while循环由布尔表达式x <= 10 and y <= 13控制。与任何and运算符一样,只有当两个操作数都为True时,整个表达式才为True。当x为8,y为14时,循环终止,因为y <= 13的操作数为False。
解决问题
为了解决老丨虎丨机问题,我们知道我们需要一个while循环,而不是for循环,因为我们无法预先预测迭代次数。每次循环将玩当前的老丨虎丨机。当循环终止时,玛莎将没有任何硬币,我们将输出她玩过的次数。
我们需要在每次迭代中执行以下操作:
-
将玛莎的硬币减去一个(因为玩老丨虎丨机需要一个硬币)。
-
如果玛莎当前在第一个老丨虎丨机上,玩这个机器。这涉及到增加该机器的游戏次数。如果这是第 35 次游戏,那么支付玛莎并将该机器的游戏次数重置为 0。
-
如果玛莎当前在第二个老丨虎丨机上,玩这个机器(与我们玩第一个机器的方式类似)。
-
如果玛莎目前在第三个老丨虎丨机上,就玩这个机器(类似于我们玩第一个机器的方式)。
-
增加玛莎的游戏次数(因为我们刚刚玩了一个机器)。
-
转到下一个机器。如果玛莎刚刚玩了第一个老丨虎丨机,我们要转到第二个;如果她刚刚玩了第二个,我们要转到第三个;如果她刚刚玩了第三个,我们要重新回到第一个。
我们的程序现在变得更长了,所以像我刚才那样列出计划是一个有用的技巧,可以帮助我们保持复杂性可控,并引导我们编写正确的代码。我们可以使用这个大纲来确保我们遵循计划,并没有遗漏任何步骤。
我们的代码在清单 4-1 中。
quarters = int(input())
first = int(input())
second = int(input())
third = int(input())
plays = 0
❶ machine = 0
❷ while quarters >= 1:
❸ quarters = quarters - 1
❹ if machine == 0:
first = first + 1
❺ if first == 35:
first = 0
quarters = quarters + 30
elif machine == 1:
second = second + 1
if second == 100:
second = 0
quarters = quarters + 60
elif machine == 2:
third = third + 1
if third == 10:
third = 0
quarters = quarters + 9
❻ plays = plays + 1
❼ machine = machine + 1
❽ if machine == 3:
machine = 0
print('Martha plays', plays, 'times before going broke.')
清单 4-1:解决老丨虎丨机问题
quarters变量跟踪玛莎拥有的硬币数量。first、second和third变量分别跟踪第一个、第二个和第三个老丨虎丨机自上次支付以来的游戏次数。
machine变量跟踪玛莎接下来将玩的老丨虎丨机。第一个老丨虎丨机用数字0表示,第二个用数字1表示,第三个用数字2表示。因此,将machine设置为0意味着接下来会玩第一个老丨虎丨机 ❶。
我们本可以用1、2和3来表示老丨虎丨机,而不是0、1和2。或者我们可以使用字符串:'first'、'second'和'third'。但是从零开始编号是惯例,因此我这里使用的是这种方式。
本程序中的最后一个变量是plays,它跟踪玛莎玩过的老丨虎丨机数量。当玛莎的硬币用完后,我们将输出这个变量。
程序的主体部分是一个while循环,循环条件是玛莎还有硬币 ❷。
循环的每次迭代都会玩一个老丨虎丨机。因此,首先我们要做的事情是减少玛莎的硬币数量 ❸。接下来,我们玩当前的老丨虎丨机。
我们是在老丨虎丨机 0 上吗?老丨虎丨机 1?老丨虎丨机 2?我们需要一个if语句来回答这个问题。
我们首先检查是否在老丨虎丨机 0 上 ❹。如果是,那么我们增加这个老丨虎丨机的游戏次数,因为它已经支付给玛莎。接着,我们检查这个机器自上次支付以来是否已经玩了恰好 35 次 ❺。如果是,那么我们将该机器的游戏次数重置为0,并增加玛莎的硬币数量 30。
这里有好几层嵌套,所以请花些时间确保代码的逻辑是正确的。特别是要注意,每次我们玩第一个老丨虎丨机时,我们都会将其游戏次数增加 1。但我们只有在每玩 35 次后才会支付玛莎——这就是为什么我们有内层的if语句 ❺!
我们处理第二和第三个老丨虎丨机的方式与处理第一个老丨虎丨机相同。唯一的区别是每个老丨虎丨机在其自有的游戏次数后支付给玛莎,并支付给她相应的硬币数量。
玩过老丨虎丨机后,我们将玛莎的游戏次数增加 1 ❻。现在只剩下移动到下一个机器,以确保如果循环有下一次迭代,我们能在正确的机器上。
要移动到下一个机器,我们将 machine 增加 1 ❼。如果我们在机器 0 上,这将使我们移到机器 1。如果我们在机器 1 上,这将使我们移到机器 2。如果我们在机器 2 上,这将使我们移到机器 3。
... 机器 3?没有机器 3!如果我们刚玩过机器 2,那么我们希望从机器 0 开始重新开始。为此,我们添加了一个检查:如果我们刚移到机器 3 ❽,那么我们知道我们刚玩过机器 2,所以我们将 machine 重置为机器 0。
当循环终止时,我们知道玛莎已经没有硬币了。最后一步,我们输出所需的句子,包括玛莎玩了多少次。
这段代码包含了很多内容:在玛莎没有硬币时停止,跟踪当前机器,适时支付玛莎,并计算玛莎的游戏次数。现在可以提交这段代码,但也可以考虑是否有其他方式编写部分代码。如果你将 plays 在循环顶部增加 1,而不是底部,会发生什么?将 quarters 在循环顶部或底部减 1 有区别吗?你是否会使用新变量来跟踪玛莎玩过每台老丨虎丨机的次数,而不是修改 first、second 和 third?我强烈建议你尝试不同的变体。如果你做了修改,代码不再通过测试,太好了!现在你有了一个新的学习机会来修复代码,并了解为什么你的修改导致了不期望的行为。
接下来的两节将进一步完善代码。我们将使用 % 运算符来减少需要的变量数量,并学习 f-string 以简化字符串构建。
模运算符
在第一章的“整数与浮点数”中,我介绍了用于计算整数除法余数的模运算符(%)。例如,16 除以 5 的余数是 1:
>>> 16 % 5
1
15 除以 5 的余数是 0(因为 5 恰好能整除 15):
>>> 15 % 5
0
第二个操作数决定了 % 操作符可能返回的值的范围。可能返回的值是从 0 到但不包括第二个操作数。例如,如果第二个操作数是 3,那么 % 只能返回 0、1 和 2。此外,当我们增加第一个操作数时,我们会依次循环所有可能的返回值。这里有一个例子:
>>> 0 % 3
0
>>> 1 % 3
1
>>> 2 % 3
2
>>> 3 % 3
0
>>> 4 % 3
1
>>> 5 % 3
2
>>> 6 % 3
0
>>> 7 % 3
1
注意这个模式:0, 1, 2, 0, 1, 2,以此类推。
这种行为对于计算到指定次数并且然后循环回 0 非常有用。这正是我们玩老丨虎丨机时所需的行为:我们先玩老丨虎丨机 0,然后是 1,然后是 2,然后是 0,再是 1,再是 2,然后是 0,再是 1,依此类推。(这也是为什么我使用 0、1 和 2 来表示老丨虎丨机,而不是其他值的原因。)
假设变量 plays 表示玛莎已玩游戏的次数。为了确定下一个要玩的机器(0、1或2),我们可以使用%运算符。例如,假设玛莎至今已经玩了一个老丨虎丨机,我们想知道她接下来会玩哪个。她将接着玩老丨虎丨机 1,而%运算符告诉我们:
>>> plays = 1
>>> plays % 3
1
如果玛莎迄今为止玩了六次,那么她玩了老丨虎丨机 0、1、2、0、1、2。接下来她要玩的老丨虎丨机是机器 0。而且,由于她已经玩了三台机器两次,并且没有其他额外的游戏,% 运算符给我们带来了 0:
>>> plays = 6
>>> plays % 3
0
作为最后一个例子,假设玛莎已经玩了 11 次。她完成了三次完整的循环:0、1、2、0、1、2、0、1、2。那是九次游戏。剩下的两次游戏使得玛莎接下来的游戏是老丨虎丨机 2:
>>> plays = 11
>>> plays % 3
2
也就是说,我们可以在不显式维护 machine 变量的情况下确定要玩的老丨虎丨机。
我们还可以使用%来简化逻辑,判断当前老丨虎丨机的下一次游戏是否支付玛莎。考虑第一个老丨虎丨机。在清单 4-1 中,我们计算了自上次老丨虎丨机支付以来的游戏次数。如果这个数字是 35,那么我们就支付玛莎并将计数重置为 0。但是,如果我们使用%运算符,就不需要重置计数。我们只需检查老丨虎丨机是否已经玩了 35 的倍数次,如果是,就支付玛莎。为了测试一个数字是否是 35 的倍数,我们可以使用%运算符。如果一个数字能被 35 除尽,且没有余数,那么它就是 35 的倍数:
>>> first = 35
>>> first % 35
0
>>> first = 48
>>> first % 35
13
>>> first = 70
>>> first % 35
0
>>> first = 175
>>> first % 35
0
我们只需检查first % 35 == 0来确定是否支付玛莎。
我已经更新了清单 4-1,使用了%运算符。新代码见清单 4-2。
quarters = int(input())
first = int(input())
second = int(input())
third = int(input())
plays = 0
while quarters >= 1:
❶ machine = plays % 3
quarters = quarters - 1
if machine == 0:
first = first + 1
❷ if first % 35 == 0:
quarters = quarters + 30
elif machine == 1:
second = second + 1
if second % 100 == 0:
quarters = quarters + 60
elif machine == 2:
third = third + 1
if third % 10 == 0:
quarters = quarters + 9
plays = plays + 1
print('Martha plays', plays, 'times before going broke.')
清单 4-2:使用 % 解决老丨虎丨机问题
我已经在本节中以两种方式使用了%:一种是根据已玩次数确定当前机器❶,另一种是确定玛莎是否在某次游戏中获得奖励(例如,在❷处)。
将%与返回除法余数的功能联系起来掩盖了它的灵活性。每当你需要按周期计数(0, 1, 2, 0, 1, 2)时,可以考虑是否可以使用%来简化代码。
F-字符串
我们在解决老丨虎丨机问题时的最后一步是输出所需的句子,如下所示:
print('Martha plays', plays, 'times before going broke.')
我们必须记得结束第一个字符串,这样我们就可以输出播放次数,然后开始新的字符串来表示句子的后半部分。此外,我们使用多个参数来调用print,以避免将plays转换为字符串。如果我们是存储结果字符串而不是直接打印出来,我们将需要进行str转换:
>>> plays = 6
>>> result = 'Martha plays ' + str(plays) + ' times before going broke.'
>>> result
'Martha plays 6 times before going broke.'
将字符串和整数拼接在一起对于像这样的简单句子是可以的,但它不能扩展。当我们尝试嵌入三个整数而不是一个时,它会变成这样:
>>> num1 = 7
>>> num2 = 82
>>> num3 = 11
>>> 'We have ' + str(num1) + ', ' + str(num2) + ', and ' + str(num3) + '.'
'We have 7, 82, and 11.'
我们不想一直跟踪那些引号、加号和空格。
构建包含字符串和数字的字符串最灵活的方式是使用f-string。以下是使用 f-string 的前一个示例:
>>> num1 = 7
>>> num2 = 82
>>> num3 = 11
>>> f'We have {num1}, {num2}, and {num3}.'
'We have 7, 82, and 11.'
注意字符串开头的f。f代表格式化,因为 f-strings 允许你格式化字符串的内容。在 f-string 内部,我们可以将表达式放在大括号中。随着字符串的构建,每个表达式都会被其值替换并插入到字符串中。结果仍然是一个普通的字符串——这里没有新的类型:
>>> type(f'hello')
<class 'str'>
>>> type(f'{num1} days')
<class 'str'>
大括号中的表达式可以比单纯的变量名更复杂:
>>> f'The sum is {num1 + num2 + num3}'
'The sum is 100'
我们可以在“老丨虎丨机”问题的最后一行中使用 f-strings。以下是它的表现方式:
print(f'Martha plays {plays} times before going broke.')
即使在这个最简单的字符串格式化上下文中,我认为 f-strings 也能增加清晰度。随时记住它们,当你发现自己在从小块拼接字符串时,可以使用它们。
关于 f-strings 的一个警告:它们是在 Python 3.6 中添加的,而在写作时,Python 3.6 仍然是一个相对较新的版本。在旧版本的 Python 中,f-strings 会导致语法错误。
如果你使用 f-strings,请确保检查你提交的评测系统是否使用 Python 3.6 或更高版本来测试你的代码。
在继续之前,你可能想尝试解决“章节练习”中的练习 1,位于第 99 页。
问题#9:歌曲播放列表
有时候我们无法预先知道会提供多少输入。我们将在这个问题中看到,while循环正是我们在这种情况下需要的。
这是 DMOJ 问题ccc08j2。
挑战
我们有五首最喜欢的歌曲,分别是 A、B、C、D 和 E。我们已经创建了一个包含这些歌曲的播放列表,并使用一个应用程序来管理这个播放列表。歌曲的顺序是 A、B、C、D、E。这个应用程序有四个按钮:
-
按钮 1:将播放列表中的第一首歌移动到播放列表的末尾。例如,如果当前的播放列表是 A、B、C、D、E,那么它将变为 B、C、D、E、A。
-
按钮 2:将播放列表中的最后一首歌移动到播放列表的开头。例如,如果当前的播放列表是 A、B、C、D、E,那么它将变为 E、A、B、C、D。
-
按钮 3:交换播放列表中的前两首歌曲。例如,如果当前的播放列表是 A、B、C、D、E,那么它将变为 B、A、C、D、E。
-
按钮 4:播放播放列表!
我们得到用户的按钮按压信息。当用户按下按钮 4 时,输出播放列表中歌曲的顺序。
输入
输入由成对的行组成,其中每对的第一行给出一个按钮的编号(1、2、3或4),第二行给出用户按下该按钮的次数(介于 1 和 10 之间)。也就是说,第一行是按钮编号,第二行是按下次数,第三行是按钮编号,第四行是按下次数,依此类推。输入以这两行结束:
4
1
表示用户按下按钮4一次。
输出
输出播放列表中所有按钮按下后的歌曲顺序。输出必须在一行内,歌曲对之间用空格隔开。
字符串切片
我们解决“歌曲播放列表”问题的高层次方案将是一个while循环,只要我们没有找到按下按钮4,循环就会继续进行。每次循环,我们会读取两行输入并处理它们。这导致了如下结构:
❶ button = 0
while button != 4:
# Read button
# Read number of presses
# Process button presses
在while循环之前,我们创建变量button并将其初始化为数字0 ❶。没有这个,button变量将不存在,我们在while循环的布尔表达式中会出现NameError。除了4以外的任何数字都可以触发循环的第一次迭代。
在这个while循环中,我们将使用for循环来处理按钮按下的操作。对于每一次按下,我们将使用if语句来检查按下的是哪个按钮。我们需要在if语句中为四个按钮分别创建四个缩进的语句块。
让我们讨论如何处理每个按钮。按钮1将播放列表中的第一首歌移到播放列表的末尾。由于我们知道歌曲的数量很小且固定,我们可以通过字符串索引来连接每个字符。记住,字符串的第一个字符的索引是 0,而不是 1。我们可以像这样将这个字符移到字符串的末尾:
>>> songs = 'ABCDE'
>>> songs = songs[1] + songs[2] + songs[3] + songs[4] + songs[0]
>>> songs
'BCDEA'
这种做法比较繁琐,而且仅适用于正好有五首歌的情况。我们可以使用字符串切片来编写更加通用且不易出错的代码。
切片是 Python 的一项特性,允许我们引用字符串的子字符串。(实际上,它也适用于任何序列类型,稍后在本书中我们将看到。)切片需要两个索引:开始的索引和结束索引右边的一个位置。如果我们使用索引 4 和 8,举例来说,我们将获得索引 4、5、6 和 7 的字符。切片使用方括号,两个索引之间用冒号分隔:
>>> s = 'abcdefghijk'
>>> s[4:8]
'efgh'
切片操作不会改变s所指向的内容。我们可以通过赋值语句使s指向切片:
>>> s
'abcdefghijk'
>>> s = s[4:8]
>>> s
'efgh'
在这里很容易犯下越界错误,以为s[4:8]包含了索引 8 处的字符。但实际上并不包括,就像range(4, 8)不包含8一样。所以虽然这个行为可能有点反直觉,但它在range和切片中是一致应用的。
在进行字符串切片时,我们必须始终包括冒号,但起始和结束索引是可选的。如果我们省略起始索引,Python 会从索引 0 开始切片:
>>> s = 'abcdefghijk'
>>> s[:4]
'abcd'
如果省略结束索引,Python 会一直切片到字符串的末尾:
>>> s[4:]
'efghijk'
如果省略两个索引呢?这会给我们一个包含整个字符串的切片:
>>> s[:]
'abcdefghijk'
我们甚至可以在切片中使用负索引。这里有一个示例:
>>> s[-4:]
'hijk'
起始索引指的是从右侧数第四个字符,即 'h',并且省略了结束索引。因此,我们得到一个从 'h' 到字符串末尾的切片。
与索引不同,切片永远不会引发索引错误。如果我们使用超出字符串范围的索引,Python 会切片到字符串的适当末端:
>>> s[8:20]
'ijk'
>>> s[-50:2]
'ab'
我们将使用字符串切片来实现按钮 1、2 和 3 的行为。以下是按钮 1 的代码:
>>> songs = 'ABCDE'
>>> songs = songs[1:] + songs[0]
>>> songs
'BCDEA'
该切片给我们除了索引 0 处的字符以外的整个字符串。(这里没有特定于长度为 5 的字符串的内容;这段代码适用于任何非空字符串。)如果补上丢失的字符,第一个歌曲就会移到播放列表的末尾。其他按钮的切片类似;你将在下一段代码中看到。
概念检查
以下代码的输出是什么?
game = 'Lost Vikings'
print(game[2:-6])
A. st V
B. ost V
C. iking
D. st Vi
E. Viking
答案:A. 索引 2 处的字符是 'Lost' 中的 's'。索引 -6 处的字符是 'Vikings' 中的第一个 'i'。由于我们从索引 2 开始,到但不包括索引 -6,所以我们得到切片 'st V'。
概念检查
哪个密码可以让我们退出以下循环?
valid = False
while not valid:
s = input()
valid = len(s) == 5 and s[:2] == 'xy'
A. xyz
B. xyabc
C. abcxy
D. 以上多个密码可以让我们退出循环
E. 无;循环从未执行,且没有获得任何密码
答案:B. while 循环在 valid 为 True 时终止(因为此时 not valid 为 False)。给定的密码中,唯一一个长度为 5 且前两个字符为 'xy' 的密码是 xyabc。因此,这个密码是唯一一个将 valid 设置为 True 并结束循环的密码。
解决问题
现在我们已经练习了如何使用 while 循环来处理多个按钮,并利用切片进行字符串操作,接下来我们准备解决歌曲播放列表问题。请参见 清单 4-3 了解代码。
songs = 'ABCDE'
button = 0
❶ while button != 4:
button = int(input())
presses = int(input())
❷ for i in range(presses):
if button == 1:
❸ songs = songs[1:] + songs[0]
elif button == 2:
❹ songs = songs[-1] + songs[:-1]
elif button == 3:
❺ songs = songs[1] + songs[0] + songs[2:]
❻ output = ''
for song in songs:
output = output + song + ' '
❼ print(output[:-1])
清单 4-3:解决歌曲播放列表
while 循环会一直继续,直到按钮 4 被按下 ❶。在每次执行 while 循环时,我们读取按钮编号,然后读取该按钮被按下的次数。
现在,在外部的 while 循环中,我们需要在每次按钮按下时执行一次循环。在选择使用哪种循环时,请记住所有循环类型。这里,使用 for 循环范围是最好的选择 ❷,因为它是以我们指定的次数精确循环的最简单方法。
for 循环中的行为取决于按下的按钮。我们因此使用 if 语句来检查按钮号码并相应地修改播放列表。如果按下的是按钮 1,我们使用切片将第一首歌移到播放列表的末尾 ❸。如果按下的是按钮 2,我们使用切片将最后一首歌移到播放列表的开头 ❹。为了做到这一点,我们从字符串的右端开始,然后使用切片将所有其他字符追加上去。对于按钮 3,我们需要修改播放列表,使得前两首歌交换位置。我们构建一个新的字符串,其中包含索引 1 处的字符,然后是索引 0 处的字符,再加上从索引 2 开始的所有字符 ❺。
一旦我们跳出 while 循环,我们需要输出歌曲,每对歌曲之间用一个空格分隔。我们不能仅仅输出 songs,因为那没有空格。相反,我们构建一个输出字符串,里面有适当的空格。为了做到这一点,我们从空字符串 ❻ 开始,然后使用 for 循环将每首歌和一个空格连接起来。有一个小麻烦是,这会在字符串的末尾添加一个空格,在最后一首歌后面,而我们不想要那个空格。因此,我们使用切片来去掉最后的空格字符 ❼。
你现在准备好提交给评委了。
在继续之前,你可能想试着解决“章节练习”中第 99 页的第 3 题。
问题 #10:秘密句子
即使我们有一个字符串,甚至知道将提供多少输入,while 循环仍然可能是所需的循环类型。这个问题演示了为什么会是这种情况。
这是 DMOJ 问题coci08c3p2。
挑战
卢卡在课堂上写下了一句秘密句子。他不希望老师能读懂它,因此他没有写下原始句子,而是写下了一个编码版本。在句子中的每个元音字母(a, e, i, o, 或 u)后面,他都会加上字母 p,然后再加上那个元音字母。例如,他不会写下句子 i like you,而是写成 ipi lipikepe yopoupu。
老师获得了卢卡的编码句子。为老师恢复卢卡的原始句子。
输入
输入是一行文本,卢卡的编码句子。它由小写字母和空格组成。每对单词之间有且只有一个空格。该行的最大长度为 100 个字符。
输出
输出卢卡的原始句子。
for 循环的另一个限制
在第三章中,我们学习了如何使用 for 循环处理字符串。for 循环逐个字符地遍历字符串,从头到尾。在许多情况下,这正是我们想要的。例如,在“三杯”问题中,我们需要从左到右查看每次交换,所以我们在交换字符串上使用了 for 循环。
在其他情况下,这样的限制太严苛了,for 循环可能会更合适。for 循环让我们能够访问索引,而不是字符。它还允许我们根据需要选择步长来跳跃遍历序列。例如,我们可以使用 for 循环访问字符串中的每三个字符:
>>> s = 'zephyr'
>>> for i in range(0, len(s), 3):
... print(s[i])
...
z
h
我们还可以使用 for 循环从右到左处理字符串,而不是从左到右:
>>> for i in range(len(s) - 1, -1, -1):
... print(s[i])
...
r
y
h
p
e
z
这一切都假设我们希望在每次迭代中步长固定。
如果有时我们希望向右移动一个字符,而其他时候我们希望向右移动三个字符呢?这完全不是不可能的。事实上,如果我们能做到这一点,那么我们就能很接近解决“秘密句子”问题了。
为了说明这一点,考虑以下测试案例:
ipi lipikepe yopoupu
假设我们正在通过复制字符来重构 Luka 的原始句子。编码句子的第一个字符是元音字母 i。这也是 Luka 原始句子的第一个字符。根据 Luka 编码句子的方式,我们知道接下来的两个字符将是 p 和 i。我们不希望将它们包含在 Luka 的原始句子中,所以我们需要跳过它们。也就是说,在处理完索引 0 后,我们要跳到索引 3。
索引 3 是一个空格字符。由于它不是元音字母,我们将这个字符原样复制到 Luka 的原始句子中,然后跳到索引 4。索引 4 是 l,另一个非元音字母,所以我们也复制它并跳到索引 5。这里在索引 5 是一个元音字母;复制它后,我们要跳到索引 8。
这里的步长是多少?有时我们跳跃三个字符,但并不总是如此。有时我们跳跃一个字符,但也不总是如此。它是三和一的混合。for 循环并不适合这种处理方式。
使用 while 循环,我们可以随心所欲地跳跃遍历字符串,不受预定义步长的限制。
while 循环遍历索引
写一个 while 循环来遍历字符串索引,与写任何其他类型的 while 循环没有区别。我们只需要结合字符串的长度。以下是我们如何从左到右遍历字符串的每个字符:
>>> s = 'zephyr'
>>> i = 0
❶ >>> while i < len(s):
... print('We have ' + s[i])
... i = i + 1
...
We have z
We have e
We have p
We have h
We have y
We have r
变量 i 允许我们访问字符串的每个字符。它从 0 开始,每次循环增加 1。
我在循环的布尔表达式 ❶ 中使用了 <,以便在没有达到字符串长度时继续。如果我使用的是 <= 而不是 <,我们会收到一个 IndexError 错误:
>>> i = 0
>>> while i <= len(s):
... print('We have ' + s[i])
... i = i + 1
...
We have z
We have e
We have p
We have h
We have y
We have r
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
IndexError: string index out of range
字符串的长度是 6。我们之所以会遇到这个错误,是因为循环试图访问 s[6],而这是一个无效的索引。
想要每次跳跃三个字符而不是一个字符吗?没问题;只需将 i 增加 3 而不是 1:
>>> i = 0
>>> while i < len(s):
... print('We have ' + s[i])
... i = i + 3
...
We have z
We have h
我们也可以从右往左遍历,而不是从左往右。我们必须从len(s) - 1开始,而不是从0开始,并且在每次迭代时减少i,而不是增加它。我们还必须改变循环的布尔表达式,以便检测到我们到达字符串的开头,而不是结尾。以下是如何从右往左遍历,每次处理一个字符:
>>> i = len(s) - 1
>>> while i >= 0:
... print('We have ' + s[i])
... i = i - 1
...
We have r
We have y
We have h
We have p
We have e
We have z
在字符串上使用while循环的一个最终应用:在满足某些条件时停止在第一个索引处。
该策略是使用布尔and运算符,在仍有字符需要检查并且我们还没有满足条件的情况下继续循环。例如,下面是如何找到字符串中第一个'y'的索引:
>>> i = 0
>>> while i < len(s) and s[i] != 'y':
... i = i + 1
...
>>> print(i)
4
如果字符串中没有'y',循环会在i等于字符串长度时停止:
>>> s = 'breeze'
>>> i = 0
>>> while i < len(s) and s[i] != 'y':
... i = i + 1
...
>>> print(i)
6
当i为6时,and的第一个操作数为False,因此循环终止。你可能会想,为什么and的第二个操作数不会导致错误,因为索引6在字符串中不是一个有效的索引。原因是布尔运算符使用短路求值,这意味着它们在结果已经确定时会停止评估其操作数。对于and,如果第一个操作数为False,我们就知道无论第二个操作数是什么,and都会返回False;因此,Python 不会评估第二个操作数。同样地,对于or,如果第一个操作数为True,那么or保证返回True,因此 Python 不会评估第二个操作数。
解决问题
现在我们知道如何使用while循环来遍历一个字符串。
对于密句(Secret Sentence),我们需要根据我们查看的是元音(vowel)还是非元音(nonvowel)来采取不同的处理方式。如果我们查看的是元音,那么我们需要复制该字符,并跳过三个字符(跳过p和该元音的第二次出现)。如果我们查看的是非元音,那么我们需要复制该字符并移动到下一个字符。因此,我们总是复制当前字符,然后根据当前字符是否为元音,跳过三个字符或一个字符。我们可以在while循环中使用if语句来为我们看到的每个字符做出决定。
密句的解决方案在清单 4-4 中。
sentence = input()
❶ result = ''
i = 0
❷ while i < len(sentence):
result = result + sentence[i]
❸ if sentence[i] in 'aeiou':
i = i + 3
else:
i = i + 1
print(result)
清单 4-4:解决密句问题
result变量❶用于逐个字符地构建原始句子。
while循环的布尔表达式是用于遍历字符串直到到达末尾的标准表达式❷。在这个循环中,我们首先将当前字符连接到结果的末尾。然后,我们检查当前字符是否为元音❸。请回顾第二章中的“关系运算符”部分,in运算符可以用来检查第一个字符串是否出现在第二个字符串中。如果当前字符出现在元音的字符串中,我们就跳过三个字符;如果没有,我们就移动到下一个字符。
一旦循环终止,我们已经遍历了整个编码后的句子,并将正确的字符复制到result中。因此,最后要做的就是输出这个变量。
你已经准备好将我们的代码提交给评审了。Grepeapat wopork!
break 和 continue
在这一节中,我将向你展示 Python 支持的另外两个循环关键字:break和continue。根据我的经验,引入这些关键字会导致学习者过度使用它们,从而影响循环的清晰度,因此我决定在书中的其他部分避免使用它们。尽管如此,它们偶尔还是有用的,你很可能会在其他 Python 代码中看到它们,所以让我们简要讨论一下。
break
break关键字会立即终止一个循环,不做任何询问。
当我们解决歌曲播放列表问题时,我们使用了一个while循环,条件是按钮没有按下4。我们也可以使用break来解决这个问题;请参见列表 4-5 中的代码。
songs = 'ABCDE'
❶ while True:
button = int(input())
❷ if button == 4:
❸ break
presses = int(input())
for i in range(presses):
if button == 1:
songs = songs[1:] + songs[0]
elif button == 2:
songs = songs[-1] + songs[:-1]
elif button == 3:
songs = songs[1] + songs[0] + songs[2:]
output = ''
for song in songs:
output = output + song + ' '
print(output[:-1])
列表 4-5:使用 break 解决歌曲播放列表问题
循环的布尔表达式❶看起来很可疑:True总是True,所以乍一看似乎这个循环永远不会终止。(这就是break的弊端。我们不能仅通过布尔表达式来理解循环何时终止。)但是它是可以终止的,因为我们使用了break。如果按钮4被按下❷,那么我们会遇到一个break❸,从而终止循环。
让我们再看一个使用break的例子。在本章的“while循环通过索引”中,我们编写了代码来查找字符串中第一个'y'的索引。以下是使用break的实现:
>>> s = 'zephyr'
>>> i = 0
>>> while i < len(s):
... if s[i] == 'y':
... break
... i = i + 1
...
>>> print(i)
4
再次注意,循环的布尔表达式具有误导性:它暗示循环总是一直运行直到字符串的末尾,但仔细审查后会发现有一个break存在,它可以影响终止条件。
break只会终止它自身的循环,而不会影响外部的循环。这里有一个例子:
>>> i = 0
>>> while i < 3:
... j = 10
... while j <= 50:
... print(j)
... if j == 30:
❶ ... break
... j = j + 10
... i = i + 1
...
10
20
30
10
20
30
10
20
30
注意break❶是如何缩短j循环的。但它不会影响i循环:该循环有三个迭代,正如没有break❶时的情况一样。
continue
continue关键字结束当前的循环迭代,而不会运行更多的代码。与break不同,它不会终止整个循环。如果循环条件为True,那么循环将继续进行下一次迭代。
这是一个使用continue打印每个元音字母及其在字符串中的索引的例子:
>>> s = 'zephyr'
>>> i = 0
>>> while i < len(s):
❶ ... if not s[i] in 'aeiou':
... i = i + 1
❷ ... continue
❸ ... print(s[i], i)
... i = i + 1
...
e 1
如果当前字符不是元音字母❶,那么我们不想打印它。因此,我们将i增加1,跳过这个字符,然后使用continue❷结束当前的迭代。如果我们进入if语句之后❸,那就意味着我们看到的是元音字母(否则continue会阻止我们到达这里)。因此,我们输出这个字符,并将i增加1,跳过这个字符。
continue 关键字具有诱惑力,因为它看似提供了一种方法,让我们退出我们不想参与的迭代。“这不是元音,我走!”但也可以使用 if 语句实现相同的行为,并且逻辑通常更清晰:
>>> s = 'zephyr'
>>> i = 0
>>> while i < len(s):
... if s[i] in 'aeiou':
... print(s[i], i)
... i = i + 1
...
e 1
当当前字符不是元音时,if 语句会在字符 是 元音时进行处理,而不是跳过该次迭代。
总结
本章问题的共同特点是我们事先不知道循环需要迭代多少次。
老丨虎丨机 迭代次数取决于初始的硬币数量和老丨虎丨机的支付比例。
歌曲播放列表 迭代次数取决于按下了多少个按钮。
秘密句子 迭代次数及每次迭代要做的操作,取决于元音在字符串中的位置。
当迭代次数未知时,我们使用 while 循环,它会根据需要执行。使用 while 循环比使用 for 循环的代码更容易出错,但它也更灵活,因为我们不再受制于 for 循环必须依次遍历序列的限制。
在下一章,我们将学习列表,它可以让我们存储大量的数字或字符串数据。你猜我们如何处理这些数据呢?没错:使用循环!通过做以下练习来锻炼你的循环技能。在用列表解决问题时,你会频繁用到它们。
章节练习
现在,你可以使用三种类型的循环:for 循环、范围 for 循环和 while 循环。解决问题时的一个挑战是知道使用哪种循环!在接下来的练习中,试着使用不同类型的循环,找到你最喜欢的解决方案。
-
DMOJ 问题
ccc20j2,流行病学 -
DMOJ 问题
coci08c1p2,Ptice -
DMOJ 问题
ccc02j2,美加之战 -
DMOJ 问题
ecoo13r1p1,取一个数字 -
DMOJ 问题
ecoo15r1p1,当你吃掉你的巧克力豆时 -
DMOJ 问题
ccc19j3,冷敷
备注
老丨虎丨机最初来自 2000 年加拿大计算机竞赛,初级/高级水平。歌曲播放列表最初来自 2008 年加拿大计算机竞赛,初级水平。秘密句子最初来自 2008/2009 年克罗地亚信息学公开赛,第 3 场比赛。
第五章:使用列表组织值

我们已经看到,字符串可以用来处理字符序列。在本章中,我们将学习列表,它们帮助我们处理其他类型值的序列,如整数和浮点数。我们还将学习如何将列表嵌套在列表中,这使得我们可以处理数据的网格。
我们将通过使用列表来解决三个问题:找到一组村庄中最小邻里的大小,判断是否筹集到了足够的资金进行学校旅行,以及计算面包店提供的奖金数量。
问题 #11:村庄邻里
在本问题中,我们将找到一组村庄中最小邻里的大小。我们会发现,存储所有邻里大小非常有用。不过,可能会有多达 100 个村庄,单独为每个村庄使用一个变量会很麻烦。我们将看到,列表允许我们将原本是分开的变量聚集到一个集合中。我们还将学习 Python 强大的列表操作,用于修改、查找和排序列表。
这是 DMOJ 问题 ccc18s1。
挑战
有 n 个村庄,位于一条直路上的不同位置。每个村庄用一个整数表示,表示它在路上的位置。
一个村庄的左邻居是位置最小的下一个村庄;一个村庄的右邻居是位置最大的下一个村庄。一个村庄的 邻里 包含该村庄与其左邻居之间空间的一半,加上该村庄与其右邻居之间空间的一半。例如,如果一个村庄位于位置 10,左邻居在位置 6,右邻居在位置 15,那么这个村庄的邻里从位置 8(6 和 10 之间的中点)开始,到位置 12.5(10 和 15 之间的中点)结束。
最左边和最右边的村庄只有一个邻居,因此它们的邻里定义没有意义。在本问题中,我们将忽略这两个村庄的邻里。
邻里的 大小 计算方法是:邻里的最右端位置减去邻里的最左端位置。例如,从 8 到 12.5 的邻里,大小为 12.5 – 8 = 4.5。
确定最小邻里的大小。
输入
输入包含以下几行:
-
一行包含整数 n,表示村庄的数量。n 的值在 3 到 100 之间。
-
n 行,每行给出一个村庄的位置。每个位置是一个介于 -1,000,000,000 和 1,000,000,000 之间的整数。位置不必按从左到右的顺序给出;一个村庄的邻居可能出现在这些行中的任何位置。
输出
输出最小邻里的大小。输出时保留一位小数。
为什么选择列表?
作为读取输入的一部分,我们需要读取 n 个整数(表示村庄位置的整数)。当我们在第三章解决 Data Plan 时,就已经处理过这个问题。在那里,我们使用了一个 for 循环,精确地循环了 n 次。在这里我们也会这样做。
Data Plan 和 Village Neighborhood 之间有一个关键的区别。在 Data Plan 中,我们读取一个整数,使用它,然后再也不引用它了。我们不需要将其保留。但在 Village Neighborhood 中,单次看到每个整数是不够的。一个村庄的邻里关系取决于它的左邻和右邻。如果没有访问这些邻居,我们就无法计算该村庄的邻里大小。我们需要存储所有村庄的位置以备后用。
以一个例子说明为什么我们需要存储所有村庄位置,考虑这个测试案例:
6
20
50
4
19
15
1
这里有六个村庄。为了找出一个村庄的邻里大小,我们需要该村庄的左邻和右邻。
输入中的第一个村庄位于位置 20。那么该村庄的邻里大小是多少?为了回答这个问题,我们需要访问所有村庄的位置,以便找到它的左邻和右邻。通过扫描位置,你可以识别出左邻在位置 19,右邻在位置 50。因此,该村庄的邻里大小为 (20 – 19)/2 + (50 – 20)/2 = 15.5。
输入中的第二个村庄位于位置 50。那么该村庄的邻里大小是多少呢?我们仍然需要查看位置来计算。这个村庄恰好是最右侧的村庄,因此我们忽略该村庄的邻里关系。
输入中的第三个村庄位于位置 4。左邻位于位置 1,右邻位于位置 15,所以该村庄的邻里大小为 (4 – 1)/2 + (15 – 4)/2 = 7。
输入中的第四个村庄位于位置 19。左邻位于位置 15,右邻位于位置 20,所以该村庄的邻里大小为 (19 – 15)/2 + (20 – 19)/2 = 2.5。
唯一剩下需要考虑的村庄位于位置 15。如果你计算它的邻里大小,你应该得到答案 7.5。
比较我们计算的所有邻里大小,我们发现最小的邻里大小——也就是此测试案例的正确答案——是 2.5。
我们需要一种方法来存储所有的村庄位置,以便找到每个村庄的邻居。字符串不行,因为字符串只存储字符,而不存储整数。Python 列表来帮忙!
列表
列表 是一种 Python 类型,用于存储一系列值。(你有时会看到列表的值被称为 元素。)我们用开方括号和闭方括号来界定列表。
我们只能在字符串中存储字符,但我们可以在列表中存储任何类型的值。这个整数列表包含了上一部分的村庄位置。
>>> [20, 50, 4, 19, 15, 1]
[20, 50, 4, 19, 15, 1]
这是一个字符串列表:
>>> ['one', 'two', 'hello']
['one', 'two', 'hello']
我们甚至可以创建一个值类型不同的列表:
>>> ['hello', 50, 365.25]
['hello', 50, 365.25]
你对字符串的很多了解也适用于列表。例如,列表支持+操作符进行拼接,支持*操作符进行复制:
>>> [1, 2, 3] + [4, 5, 6]
[1, 2, 3, 4, 5, 6]
>>> [1, 2, 3] * 4
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
我们甚至有in操作符,它可以告诉我们一个值是否在列表中:
>>> 'one' in ['one', 'two', 'hello']
True
>>> 'n' in ['one', 'two', 'three']
False
我们有len函数来获取列表的长度:
>>> len(['one', 'two', 'hello'])
3
列表是一个序列,我们可以使用for循环遍历它的值:
>>> for value in [20, 50, 4, 19, 15, 1]:
... print(value)
...
20
50
4
19
15
1
我们可以像使变量指向字符串、整数和浮动数一样让变量指向列表。让我们让两个变量指向列表,然后将它们拼接起来,生成一个新列表。
>>> lst1 = [1, 2, 3]
>>> lst2 = [4, 5, 6]
>>> lst1 + lst2
[1, 2, 3, 4, 5, 6]
虽然我们展示了拼接后的列表,但我们并没有存储它,正如我们通过再次查看列表所看到的那样:
>>> lst1
[1, 2, 3]
>>> lst2
[4, 5, 6]
为了让一个变量指向拼接后的列表,我们使用赋值:
>>> lst3 = lst1 + lst2
>>> lst3
[1, 2, 3, 4, 5, 6]
当不需要具体说明列表包含什么内容时,可以使用像lst、lst1和lst2这样的名称。
但不要使用list本身作为变量名。它已经是一个我们可以用来将序列转换为列表的名称:
>>> list('abcde')
['a', 'b', 'c', 'd', 'e']
如果你创建一个名为list的变量,你将失去这种有价值的行为,并且会让读者感到困惑,他们会期望list不被篡改。
最后,列表支持索引和切片。索引返回一个单一值,而切片返回一个值的列表:
>>> lst = [50, 30, 81, 40]
>>> lst[1]
30
>>> lst[-2]
81
>>> lst[1:3]
[30, 81]
如果我们有一个字符串列表,我们可以通过两次索引来访问其中一个字符串的字符,第一次选择字符串,第二次选择字符:
>>> lst = ['one', 'two', 'hello']
>>> lst[2]
'hello'
>>> lst[2][1]
'e'
概念检查
以下代码会将什么存储到total变量中?
lst = [a list of numbers]
total = 0
i = 1
while i <= len(lst):
total = total + i
i = i + 1
A. 列表的和
B. 列表的和,不包括其第一个值
C. 列表的和,不包括其第一个和最后一个值
D. 这段代码会报错,因为它访问了列表的一个无效索引
E. 以上都不是
答案:E. 这段代码将1、2、3等数字加到列表的长度,直到列表的末尾。它并没有加列表中的数字,也没有索引列表!
列表的可变性
字符串是不可变的,这意味着它们不能被修改。当看起来我们在修改字符串时(例如,使用字符串拼接),我们实际上是在创建一个新字符串,而不是修改已经存在的字符串。
另一方面,列表是可变的,这意味着它们可以被修改。
我们可以通过使用索引来观察这个区别。如果我们尝试改变一个字符串的字符,就会得到一个错误:
>>> s = 'hello'
>>> s[0] = 'j'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
错误信息说字符串不支持项赋值,这意味着我们不能改变它们的字符。
但由于列表是可变的,我们可以改变它们的值:
>>> lst = ['h', 'e', 'l', 'l', 'o']
>>> lst
['h', 'e', 'l', 'l', 'o']
>>> lst[0] = 'j'
>>> lst
['j', 'e', 'l', 'l', 'o']
>>> lst[2] = 'x'
>>> lst
['j', 'e', 'x', 'l', 'o']
如果没有对赋值语句的准确理解,可变性可能会导致看似令人困惑的行为。这里有一个例子:
>>> x = [1, 2, 3, 4, 5]
❶ >>> y = x
>>> x[0] = 99
>>> x
[99, 2, 3, 4, 5]
目前没有什么意外。但你可能会对这个感到惊讶:
>>> y
[99, 2, 3, 4, 5]
99是怎么进到y中的呢?
当我们将x赋值给y ❶时,y被设置为引用与x相同的列表。赋值语句并不会复制列表。实际上只有一个列表,它恰好有两个名称(或别名)指向它。所以,如果我们对该列表进行更改,无论是通过x还是y来引用它,我们都能看到这个更改。
可变性很有用,因为它直接模拟了我们可能希望在列表中执行的操作。如果我们想更改一个值,我们只需修改它。如果没有可变性,修改一个值是不可能的。我们必须创建一个新的列表,内容与旧列表相同,除了我们希望更改的那个值。这样也是可行的,但它是一个迂回且不太透明的方式来改变一个值。
如果你确实想要一个列表的副本,而不仅仅是另一个名称,你可以使用切片。省略起始和结束索引,这样就会得到整个列表的副本:
>>> x = [1, 2, 3, 4, 5]
>>> y = x[:]
>>> x[0] = 99
>>> x
[99, 2, 3, 4, 5]
>>> y
[1, 2, 3, 4, 5]
这次请注意,当x列表改变时,y列表没有变化。它们是独立的列表。
概念检查
以下代码的输出是什么?
lst = ['abc', 'def', 'ghi']
lst[1] = 'wxyz'
print(len(lst))
A. 3
B. 9
C. 10
D. 4
E. 这段代码会产生一个错误
答案:A. 改变列表的值是允许的(因为列表是可变的)。但是,将索引 1 处的值更改为更长的字符串,并不会改变列表有三个值这一事实。
学习方法
像字符串一样,列表也有许多有用的方法。我将在下一节中展示一些它们,但首先我想教你如何自行学习方法。
你可以使用 Python 的dir函数获取特定类型的方法列表。只需用一个值作为参数调用dir,你就会得到该值类型的所有方法。
这是我们用字符串值作为参数调用dir时得到的结果:
>>> dir('')
['__add__', '__class__', '__contains__', '__delattr__',
<more stuff with underscores>
'capitalize', 'casefold', 'center', 'count', 'encode',
'endswith', 'expandtabs', 'find', 'format',
'format_map', 'index', 'isalnum', 'isalpha', 'isascii',
'isdecimal', 'isdigit', 'isidentifier', 'islower',
'isnumeric', 'isprintable', 'isspace', 'istitle',
'isupper', 'join', 'ljust', 'lower', 'lstrip',
'maketrans', 'partition', 'replace', 'rfind', 'rindex',
'rjust', 'rpartition', 'rsplit', 'rstrip', 'split',
'splitlines', 'startswith', 'strip', 'swapcase', 'title',
'translate', 'upper', 'zfill']
请注意,我们用空字符串调用了dir。我们本可以用任何字符串值来调用dir;空字符串只是最快打出来的。
忽略顶部带有下划线的名称;这些名称是供 Python 内部使用的,一般对程序员没有兴趣。其余的名称是你可以调用的字符串方法。在那个列表中,你会发现一些你已经知道的字符串方法,比如isupper和count,以及我们尚未遇到的许多其他方法。
要学习如何使用某个方法,你可以在help中调用该方法的名称。这是我们在字符串count方法上的帮助信息:
>>> help(''.count)
Help on built-in function count:
count(...) method of builtins.str instance
❶ S.count(sub[, start[, end]]) -> int
Return the number of non-overlapping occurrences of
substring sub in string S[start:end]. Optional
arguments start and end are interpreted as in
slice notation.
帮助信息告诉我们如何调用该方法 ❶。
方括号表示可选的参数。如果你只想在字符串的某一部分内统计sub的出现次数,可以使用start和end。
浏览方法列表是值得的,看看是否有方法可以帮助你完成当前的编程任务。即使你之前使用过某个方法,查看帮助也能让你发现你以前不知道的功能!
要查看哪些列表方法可用,请调用dir([])。要了解它们,可以调用help([].xxx),其中 xxx 是列表方法的名称。
概念检查
这是center方法的帮助文档:
>>> help(''.center)
Help on built-in function center:
center(width, fillchar=' ', /) method of builtins.str instance
Return a centered string of length width.
Padding is done using the specified fill character
(default is a space).
以下代码生成的字符串是什么?
'cave'.center(8, 'x')
A. 'xxcavexx'
B. ' cave '
C. 'xxxxcavexxxx'
D. ' cave '
答案:A. 我们在调用center时,width是8,fillchar是'x'。(如果我们只提供一个参数,fillchar会使用空格。)因此,生成的字符串的长度将是 8。字符串'cave'有四个字符,因此我们需要再加四个字符来达到长度 8。所以 Python 会在字符串的开头和结尾各加两个空格,使得字符串居中。
列表方法
现在该在村庄邻里问题上取得进展了。我能想到两个对列表进行操作的方式,这将有助于我们解决它。
首先,向列表添加元素。我们从没有任何村庄位置开始,然后一次从输入中读取一个村庄位置。因此,我们需要一种方法来将每个位置添加到一个增长的列表中:一开始列表什么都没有,然后它会有一个村庄位置,再然后两个,以此类推。
第二,排序一个列表。在读取完所有村庄位置后,我们需要找到最小的邻里。这涉及到查看每个村庄的位置以及它左右邻居的距离。村庄的位置可能是任意顺序,因此通常很难找到某个村庄的邻居。回想一下我们在本章“为什么使用列表?”中的工作。对于每个村庄,我们必须扫描整个列表来找出它的邻居。如果我们能够按位置对村庄进行排序,那就容易多了。那样我们就能确切知道邻居在哪里:它们将恰好位于村庄的左边和右边。
例如,以下是我们按顺序读取的示例村庄:
20 50 4 19 15 1
这太乱了!在真实的街道上,它们会按位置顺序排列,像这样:
1 4 15 19 20 50
想知道位置为 4 的村庄的邻居吗?只需查看它左边和右边的位置:1 和 15。位置为 15 的村庄的邻居呢?嗖,它们就在那儿——4 和 19。再也不需要到处找了。我们将排序村庄位置的列表,以简化代码。
我们可以使用append方法向列表添加元素,并使用sort方法对列表进行排序。我们将学习这两种方法,以及其他一些你在继续处理列表时可能会觉得有用的方法,然后我们会回到村庄邻里问题上。
向列表添加元素
append方法追加到一个列表中,这意味着它会将一个值添加到已经存在的值的末尾。下面是append向一个最初为空的列表添加三个村庄位置:
>>> positions = []
>>> positions.append(20)
>>> positions
[20]
>>> positions.append(50)
>>> positions
[20, 50]
>>> positions.append(4)
>>> positions
[20, 50, 4]
请注意,我们在使用append时并没有使用赋值语句。append方法不会返回一个列表;它会修改现有的列表。
在方法改变列表时使用赋值语句是一个常见的错误。犯这个错误会导致列表丢失,像这样:
>>> positions
[20, 50, 4]
>>> positions = positions.append(19)
>>> positions
什么都没有!从技术上讲,positions现在引用的是一个None值;你可以通过print查看到这一点:
>>> print(positions)
None
None 值用于表示没有可用的信息。这里绝对不应该是这种情况——我们希望得到四个村庄的位置!——但是由于一个错误的赋值语句,我们丢失了列表。
如果你的列表丢失了,或者你收到与 None 值相关的错误消息,确保你没有使用赋值语句与一个只修改列表的方法一起使用。
extend 方法与 append 方法相关。当你想将一个列表(而不是单一的值)连接到现有列表的末尾时,可以使用 extend。这是一个示例:
>>> lst1 = [1, 2, 3]
>>> lst2 = [4, 5, 6]
>>> lst1.extend(lst2)
>>> lst1
[1, 2, 3, 4, 5, 6]
>>> lst2
[4, 5, 6]
如果你想在列表中的某个位置插入元素,而不是插入到末尾,可以使用 insert 方法。它接受一个索引和一个值,并将该值插入到指定位置:
>>> lst = [10, 20, 30, 40]
>>> lst.insert(1, 99)
>>> lst
[10, 99, 20, 30, 40]
排序列表
sort 方法 排序 一个列表,将其值按顺序排列。如果我们调用时不传递任何参数,它会按从小到大的顺序排序:
>>> positions = [20, 50, 4, 19, 15, 1]
>>> positions.sort()
>>> positions
[1, 4, 15, 19, 20, 50]
如果我们使用 reverse 参数并将其设置为 True,它会从大到小排序:
>>> positions.sort(reverse=True)
>>> positions
[50, 20, 19, 15, 4, 1]
我使用的语法,reverse=True,是新的。根据我们在本书中到目前为止调用方法和函数的方式,你可能会期待单独的 True 就能工作。但事实并非如此:sort 需要完整的 reverse=True 参数,原因我将在第六章中解释。
从列表中移除值
pop 方法通过索引移除一个值。如果没有提供参数,pop 会移除并返回最右边的值。
>>> lst = [50, 30, 81, 40]
>>> lst.pop()
40
我们可以将要移除的值的索引作为参数传递给 pop 方法。在这里,我们移除并返回索引为 0 的值:
>>> lst.pop(0)
50
由于 pop 会返回值——与 append 和 sort 等方法不同——将其返回值赋给一个变量是有意义的:
>>> lst
[30, 81]
>>> value = lst.pop()
>>> value
81
>>> lst
[30]
remove 方法通过值移除,而不是通过索引。传递给它一个值,它会移除该值在列表中的最左侧出现。如果值不存在,remove 会产生错误。如下所示,列表中有两个 50,因此 remove(50) 会执行两次,直到产生错误:
>>> lst = [50, 30, 81, 40, 50]
>>> lst.remove(50)
>>> lst
[30, 81, 40, 50]
>>> lst.remove(50)
>>> lst
[30, 81, 40]
>>> lst.remove(50)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: list.remove(x): x not in list
概念检查
以下代码运行后,lst 的值是什么?
lst = [2, 4, 6, 8]
lst.remove(4)
lst.pop(2)
A. [2, 4]
B. [6, 8]
C. [2, 6]
D. [2, 8]
E. 这段代码会产生错误
答案:C. remove 调用移除了值 4,剩下 [2, 6, 8]。现在 pop 调用移除了索引为 2 的值,即值 8。这留下了最终的列表 [2, 6]。
解决问题
假设我们已经成功读取并排序了村庄位置。此时我们的列表将如下所示:
>>> positions = [1, 4, 15, 19, 20, 50]
>>> positions
[1, 4, 15, 19, 20, 50]
要找到最小邻里的大小,我们首先要找到索引为 1 的村庄的邻里大小。(注意,我们没有从索引 0 开始:索引 0 处的村庄是最左边的,根据问题描述,我们可以忽略它。)我们可以这样找到该邻里的大小:
>>> left = (positions[1] - positions[0]) / 2
>>> right = (positions[2] - positions[1]) / 2
>>> min_size = left + right
>>> min_size
7.0
left 变量存储邻里左侧部分的大小,而 right 存储右侧部分的大小。然后,我们将它们相加得到邻里的总大小。我们得到的值是 7.0。
那就是我们需要超越的值。我们怎么知道其他村庄是否有更小的邻里呢?我们可以使用一个循环来处理这些其他村庄。如果我们找到一个比当前最小邻里还小的邻里,我们就将当前最小邻里更新为这个更小的大小。
我们解决方案的代码在清单 5-1 中。
n = int(input())
❶ positions = []
❷ for i in range(n):
❸ positions.append(int(input()))
❹ positions.sort()
❺ left = (positions[1] - positions[0]) / 2
right = (positions[2] - positions[1]) / 2
min_size = left + right
❻ for i in range(2, n - 1):
left = (positions[i] - positions[i - 1]) / 2
right = (positions[i + 1] - positions[i]) / 2
size = left + right
❼ if size < min_size:
min_size = size
print(min_size)
清单 5-1:解决村庄邻里问题
我们首先从输入中读取n,即村庄的数量。我们还将positions设置为空列表❶。
第一个范围for循环的每次迭代❷负责读取一个村庄的位置,并将其添加到positions列表中。它通过使用input读取下一个村庄的位置,使用int将其转换为整数,并使用列表方法append将该整数添加到列表❸。这一行代码❸等价于以下三行代码:
position = input()
position = int(position)
positions.append(position)
在读取了村庄位置之后,我们接着按升序对它们进行排序❹。然后,我们找到索引为1的村庄的邻里大小,并使用min_size存储❺。
接下来,在第二个循环中,我们遍历其他村庄,这些村庄的邻里大小需要计算❻。这些村庄的索引从2开始,到n - 2结束。(我们不想考虑索引为n - 1的村庄,因为那是最右边的村庄。)因此,我们使用range,第一个参数为2(即从2开始),第二个参数为n - 1(即到n - 2结束)。
在循环内部,我们计算当前村庄邻里的大小,正如我们为第一个村庄所做的那样。到目前为止我们找到的最小邻里大小由min_size表示。当前村庄的邻里是否比我们找到的最小邻里更小?为了回答这个问题,我们使用if语句❼。如果当前村庄的邻里比min_size小,我们就将min_size更新为当前村庄的邻里大小。如果当前村庄的邻里不小于min_size,我们就什么也不做,因为这个村庄不会改变最小邻里的大小。
遍历完所有村庄后,min_size一定是最小的邻里大小。因此,我们输出min_size的值。
该问题描述的“输出”部分要求“保留小数点后正好一位数字”。如果最小的大小是像 6.25 或 8.33333 这样的数值呢?我们是不是应该对这个问题做些什么?
不,按照我们所做的已经很安全了。我们能获得的邻域大小只有类似3.0(小数点后有0)和3.5(小数点后有.5)这样的数字。原因如下。当我们计算邻域的左部分时,我们先减去两个整数,然后将得到的整数除以 2。如果除以 2 之前是偶数,那么结果将是.0数(没有余数)。如果除以 2 之前是奇数,那么结果将是.5数。邻域的右部分也是如此:其大小要么是.0,要么是.5。因此,将左部分和右部分相加得到总大小,必然会得到一个.0或.5的数字。
避免代码重复:另外两种解决方案
有一点令人失望的是,我们在第二个范围的for循环之前和其中都包含了“计算邻域大小”的代码。通常来说,重复的代码意味着我们有可能改善代码的设计。我们希望避免重复代码,因为它增加了我们需要维护的代码量,并且如果重复的代码存在缺陷,修复起来也更加困难。在这里,重复的代码我认为是可以接受的(只有三行),但我们可以讨论两种避免重复的方式。这些是你可以应用于其他类似问题的通用方法。
使用一个巨大值
我们之所以在循环之前计算村庄邻域的大小,是为了让循环能够有一个值来与其他邻域大小进行比较。如果我们没有给min_size赋初值就进入循环,当代码尝试将其与当前村庄的大小进行比较时,就会出错。
如果我们在循环之前将min_size设置为0.0,那么循环将永远找不到更小的值,我们将错误地输出0.0,无论测试用例是什么。使用0.0会是一个错误!
但是一个非常大的值,至少大到能够覆盖所有可能的邻域大小,是可行的。我们只需要让它变得足够大,确保第一次循环时就能找到一个大小不再增大的值,保证我们的伪巨大值不会被输出。
从问题描述中的“输入”部分我们知道,每个位置的范围在-1,000,000,000 到 1,000,000,000 之间。那么我们能拥有的最大邻域就是当一个村庄位于-1,000,000,000,另一个村庄位于 1,000,000,000,并且中间有一个村庄时。这个中间的村庄将拥有一个大小为 1,000,000,000 的邻域。因此,我们可以将min_size初始化为1000000000.0或更大的值。这个替代方法见列表 5-2。
n = int(input())
positions = []
for i in range(n):
positions.append(int(input()))
positions.sort()
min_size = 1000000000.0
❶ for i in range(1, n - 1):
left = (positions[i] - positions[i - 1]) / 2
right = (positions[i + 1] - positions[i]) / 2
size = left + right
if size < min_size:
min_size = size
print(min_size)
列表 5-2:用一个巨大值解决村庄邻域问题
小心!现在我们需要从索引1开始计算大小❶(而不是从2);否则,我们会忘记包括索引1处村庄的邻域。
构建大小列表
另一个避免代码重复的方法是将每个邻里大小存储在一个大小列表中。Python 有一个内建的 min 函数,它可以接受一个序列并返回其中的最小值:
>>> min('qwerty')
'e'
>>> min([15.5, 7.0, 2.5, 7.5])
2.5
(Python 也有一个 max 函数,可以返回序列中的最大值。)
请参见清单 5-3,它展示了如何在邻里大小列表上使用min。
n = int(input())
positions = []
for i in range(n):
positions.append(int(input()))
positions.sort()
sizes = []
for i in range(1, n - 1):
left = (positions[i] - positions[i - 1]) / 2
right = (positions[i + 1] - positions[i]) / 2
size = left + right
sizes.append(size)
min_size = min(sizes)
print(min_size)
清单 5-3:使用 min 解决村庄邻里问题
随时可以提交这些解答给评审,任选你最喜欢的一个!
在继续之前,你可能想尝试解决“章节练习”中的第 1 题,见第 134 页。
问题 #12:学校旅行
许多问题中,输入行会包含多个整数或浮动数字。到现在为止我们避免了这些问题,但它们随处可见!接下来我们将学习如何使用列表处理这类问题的输入。
这是 DMOJ 问题ecoo17r1p1。
挑战
学生们希望在年底进行一次学校旅行,但他们需要资金来支付旅行费用。为了筹集资金,他们组织了一次早午餐活动。参加早午餐的学生,第一年级学生支付$12,第二年级学生支付$10,第三年级学生支付$7,第四年级学生支付$5。
在早午餐筹集的所有资金中,50%的资金可以用来支付学校旅行的费用(剩下的 50%用于支付早午餐本身的费用)。
我们知道学校旅行的费用、每年级学生的比例,以及学生的总人数。确定学生们是否需要为学校旅行筹集更多的资金。
输入
输入包括 10 个测试用例,每个测试用例有三行(共 30 行)。以下是每个测试用例的三行:
-
第一行包含学校旅行的费用,单位是美元,整数范围在 50 到 50,000 之间。
-
第二行包含四个数字,分别表示参加早午餐的学生在第一、第二、第三和第四年级的比例。每两个数字之间有一个空格。每个数字介于 0 和 1 之间,它们的和为 1(即 100%)。
-
第三行包含整数 n,表示参加早午餐的学生人数。n 介于 4 到 2,000 之间。
输出
对于每个测试用例:如果学生们需要为学校旅行筹集更多的钱,输出YES;否则,输出NO。
一个陷阱
假设有 50 名学生,其中 10%(即 0.1 的比例)是四年级学生。然后我们可以计算出 50 * 0.1 = 5 名四年级学生。
现在假设有 50 名学生,其中 15%(即 0.15 的比例)是四年级学生。如果我们相乘,得到 50 * 0.15 = 7.5 名四年级学生。
拥有 7.5 个学生没有任何意义,而且我还没告诉你在这种情况下应该怎么做。完整的问题描述指出,我们需要将数字向下取整——所以这里我们需要将其取整到 7。这样可能会导致一、二、三、四年级的学生总数与所有学生总数不符。对于没有被计算到的学生,我们需要将他们添加到学生最多的那一年级。可以保证,只有一个年级的学生数最多(不会出现多个年级平分的情况)。
我们首先解决问题时忽略这个限制条件,然后再将这个限制条件纳入进来,提供完整的解决方案。
拆分字符串和连接列表
每个测试用例的第二行包含四个比例,像这样:
0.2 0.08 0.4 0.32
我们需要一种方法,从字符串中提取出这四个数字以进行进一步处理。我们将学习字符串的split方法,来将一个字符串拆分为它的各个部分。同时,我们还将学习字符串的join方法,它让我们可以反过来,将一个列表合并成一个单一的字符串。
将字符串拆分为列表
记住,不管输入是什么样的,input函数总是返回一个字符串。如果输入应该被解释为整数,我们需要将字符串转换为整数。如果输入应该被解释为浮点数,我们需要将字符串转换为浮点数。如果输入应该被解释为四个浮点数?那么,在转换任何东西之前,我们最好先将它拆分成单独的浮点数!
字符串的split方法将字符串拆分为它的各个部分。默认情况下,split会以空格为分隔符,这正好适用于我们的四个浮点数:
>>> s = '0.2 0.08 0.4 0.32'
>>> s.split()
['0.2', '0.08', '0.4', '0.32']
split方法返回一个字符串列表,在此时我们可以独立访问其中的每个值:这里,我保存了split返回的列表,然后访问了其中的两个值:
>>> proportions = s.split()
>>> proportions
['0.2', '0.08', '0.4', '0.32']
>>> proportions[1]
'0.08'
>>> proportions[2]
'0.4'
现实中的数据通常是以逗号分隔,而不是空格分隔。小菜一碟:我们可以传递一个参数给split,告诉它使用什么作为分隔符:
>>> info = 'Toronto,Ontario,Canada'
>>> info.split(',')
['Toronto', 'Ontario', 'Canada']
将列表连接成字符串
要实现从列表到字符串的转换,而不是从字符串到列表,我们可以使用字符串的join方法。join方法调用的字符串将作为列表值之间的分隔符。以下是两个示例:
>>> lst = ['Toronto', 'Ontario', 'Canada']
>>> ','.join(lst)
'Toronto,Ontario,Canada'
>>> '**'.join(lst)
'Toronto**Ontario**Canada'
从技术上讲,join可以连接任何序列中的值,不仅仅是列表。这是一个连接字符串中字符的示例:
>>> '*'.join('abcd')
'a*b*c*d'
修改列表值
当我们对一个由四个部分组成的字符串使用split时,我们会得到一个字符串列表:
>>> s = '0.2 0.08 0.4 0.32'
>>> proportions = s.split()
>>> proportions
['0.2', '0.08', '0.4', '0.32']
在第一章的《字符串与整数之间的转换》中,我们了解到,看起来像数字的字符串不能用于数值计算。因此,我们需要将这个字符串列表转换为浮点数列表。
我们可以使用float将字符串转换为浮点数,像这样:
>>> float('45.6')
45.6
那只是一个浮点数。我们如何将整个字符串列表转换为浮点数列表呢?很容易诱惑我们通过以下循环来实现:
>>> for value in proportions:
... value = float(value)
逻辑是,程序应该遍历列表中的每个值并将其转换为浮点数。
可惜,它没有奏效。列表仍然指向字符串:
>>> proportions
['0.2', '0.08', '0.4', '0.32']
可能哪里出了问题?float没有起作用吗?我们可以通过查看转换后value的类型来确认float正常工作:
>>> for value in proportions:
... value = float(value)
... type(value)
...
<class 'float'>
<class 'float'>
<class 'float'>
<class 'float'>
四个浮点数!但列表顽固地保持字符串形式。
这里发生的情况是,我们并没有改变列表中所指向的值。我们改变了变量value所指向的内容,但这并没有改变列表仍然指向原来的字符串值。要真正改变列表引用的值,我们需要在列表的索引位置赋予新值。下面是如何做的:
>>> proportions
['0.2', '0.08', '0.4', '0.32']
>>> for i in range(len(proportions)):
... proportions[i] = float(proportions[i])
...
>>> proportions
[0.2, 0.08, 0.4, 0.32]
for 循环遍历每个索引,并通过赋值语句修改该索引所指向的值。
解决大部分问题
现在我们已经准备好解决问题,只剩下那个特殊情况。
我们将从一个示例开始,突出我们代码需要做的事情。然后我们会进入代码本身。
探索一个测试用例
这个问题的输入包括 10 个测试用例,但我这里只展示一个。如果你从键盘输入这个测试用例,你将看到答案。但程序不会在这里终止,因为它在等待下一个测试用例。如果你使用输入重定向来处理这个测试用例,你仍然会看到答案。但接下来你会遇到一个EOFError。EOF 代表“文件结束”;该错误是因为程序试图读取更多输入,而可用的输入已经用尽。一旦你的代码在一个测试用例中正常工作,你可以尝试在输入中添加更多测试用例,以确保它们也能正常工作。当你有了 10 个测试用例后,程序应该能运行到完成。
这是我想要和你一起追踪的测试用例:
504
0.2 0.08 0.4 0.32
125
学校旅行的费用是 504 美元,共有 125 名学生参加早午餐。
为了计算早午餐筹集的资金,我们计算每年学生筹集的资金。第一年有 125 * 0.2 = 25 名学生参加,每个学生支付 12 美元。所以,第一年学生筹集了 25 * 12 = 300 美元。我们可以同样计算第二、三、四年学生筹集的资金。有关这部分的更多内容,请参见表 5-1。
表 5-1: 学校旅行示例
| 学年 | 该学年的学生数 | 每个学生的费用 | 筹集的资金 |
|---|---|---|---|
| 第一学年 | 25 | 12 | 300 |
| 第二学年 | 10 | 10 | 100 |
| 第三学年 | 50 | 7 | 350 |
| 第四学年 | 40 | 5 | 200 |
每年学生所筹集的资金通过将该年学生人数乘以该年每个学生的费用来计算;请参见表格最右列。对于所有学生筹集的总资金,我们可以将最右列中的四个数字相加。这样得到 300 + 100 + 350 + 200 = 950 美元。只有 50%的资金可以用于学校旅行。所以剩下的 950 / 2 = 475 美元,仍然不足以支付 504 美元的旅行费用。因此,正确的输出是YES,因为还需要筹集更多的钱。
代码
这个部分解决方案将正确处理任何输入,其中将比例乘以学生人数得到一个整数学生人数(例如我们刚才做的测试用例)。请参见清单 5-4 以获取代码。
❶ YEAR_COSTS = [12, 10, 7, 5]
❷ for dataset in range(10):
trip_cost = int(input())
❸ proportions = input().split()
num_students = int(input())
❹ for i in range(len(proportions)):
proportions[i] = float(proportions[i])
❺ students_per_year = []
for proportion in proportions:
❻ students = int(num_students * proportion)
students_per_year.append(students)
total_raised = 0
❼ for i in range(len(students_per_year)):
total_raised = total_raised + students_per_year[i] * YEAR_COSTS[i]
❽ if total_raised / 2 < trip_cost:
print('YES')
else:
print('NO')
清单 5-4:解决大部分学校旅行问题
首先,我们使用变量YEAR_COSTS来表示参加早午餐的费用列表:包括第一、第二、第三和第四年学生的费用 ❶。一旦我们确定了每年学生的人数,就会将这些人数乘以这些费用值来计算筹集到的资金。费用是固定不变的,因此我们永远不会改变该变量指代的内容。对于这样的“常量”变量,Python 的约定是将其名称写成大写字母,正如我在这里所做的那样。
输入包含 10 个测试用例,因此我们循环 10 次 ❷,每次处理一个测试用例。程序的其余部分在这个循环内,因为我们希望重复执行 10 次。
对于每个测试用例,我们读取三行输入。第二行是包含四个比例的那一行,因此我们使用split将其拆分为四个字符串的列表 ❸。然后,我们使用for循环遍历范围,将每个字符串转换为浮点数 ❹。
利用这些比例,我们的下一个任务是确定每年学生的人数。我们从一个空列表开始 ❺。然后,对于每个比例,我们将总学生人数乘以该比例 ❻,并将结果附加到列表中。注意在❻处,我使用了int来保证我们仅添加整数。int用于浮点数时,通过向 0 舍入来丢弃小数部分。
现在我们有了两个列表,可以用来计算已筹集的资金。在students_per_year中,我们有一个每年学生人数的列表,大致如下所示:
[25, 10, 50, 40]
而在YEAR_COSTS中,我们有每年学生的早午餐费用:
[12, 10, 7, 5]
这些列表中索引为 0 的每个值告诉我们第一年学生的情况,索引为 1 的每个值告诉我们第二年学生的情况,依此类推。这样的列表被称为平行列表,因为它们并行工作,以告诉我们每个列表单独无法提供的信息。
我们使用这两个列表来计算总筹款金额,通过将每个学生人数与相应的学生费用相乘,并将所有这些结果相加 ❼。
是否筹集了足够的钱用于学校旅行?为了找出答案,我们使用if语句❽。早午餐筹集的一半钱可以用于学校旅行。如果这笔钱少于学校旅行的费用,那么我们需要筹集更多的钱(YES);否则,我们不需要(NO)。
我们编写的代码非常通用。唯一能知道有四个年级学生的线索在❶。如果我们想为不同年数的问题解决类似的问题,我们所需要做的就是修改这一行(并提供期望比例的输入)。这就是列表的强大之处:它们帮助我们编写灵活的代码,能够适应我们正在解决问题的变化。
如何处理捕获
现在让我们看看为什么我们当前的程序在一些测试用例中会做出错误的处理,以及我们将使用哪些 Python 功能来修复它。
探索一个测试用例
这是我们当前代码错误处理的一个测试用例:
50
0.7 0.1 0.1 0.1
9
这次,学校旅行的费用是 50 美元,九个学生参加了早午餐。对于第一年的学生人数,我们当前的程序会计算 9 * 0.7 = 6.3,然后向下四舍五入为 6。必须向下四舍五入是我们需要小心这个测试用例的原因。要查看我们当前的程序在四个年级的表现,请参见表 5-2。
表 5-2: 当前程序处理错误的学校旅行示例案例
| 学年 | 该年学生人数 | 每个学生的费用 | 筹集的金额 |
|---|---|---|---|
| 第一学年 | 6 | 12 | 72 |
| 第二学年 | 0 | 10 | 0 |
| 第三年 | 0 | 7 | 0 |
| 第四学年 | 0 | 5 | 0 |
除第一年外,每年都没有学生,因为 9 * 0.1 = 0.9 向下四舍五入为 0。所以看起来我们筹集的全部金额只有 72 美元。72 美元的一半是 36 美元,无法支付 50 美元的学校旅行费用。我们当前的程序输出YES。我们需要筹集更多的钱。
. . . 或者不。我们应该有九个学生在这里,而不是六个!我们损失了三个学生,因为四舍五入。问题描述中指定我们应该将这些学生加入到学生最多的年级,而在本例中是第一年。如果我们这么做,我们会看到我们实际上筹集了 9 * 12 = 108 美元。108 美元的一半是 54 美元,因此实际上我们不需要再为 50 美元的学校旅行筹集更多的钱!正确的输出是NO。
更多列表操作
为了修复我们的程序,我们需要做两件事:计算因四舍五入而丢失的学生人数,并将这些学生加到学生最多的年级。
求和一个列表
为了确定因四舍五入而丢失的学生人数,我们可以将students_per_year列表中的学生人数加起来,然后从总学生人数中减去这个值。Python 的sum函数接收一个列表并返回其值的总和:
>>> students_per_year = [6, 0, 0, 0]
>>> sum(students_per_year)
6
>>> students_per_year = [25, 10, 50, 40]
>>> sum(students_per_year)
125
查找最大值的索引
Python 的max函数接收一个序列并返回其最大值:
>>> students_per_year = [6, 0, 0, 0]
>>> max(students_per_year)
6
>>> students_per_year = [25, 10, 50, 40]
>>> max(students_per_year)
50
我们需要的是最大值的索引,而不是最大值本身,这样我们才能增加该索引处的学生数量。给定最大值后,我们可以使用index方法找到它的索引。它返回提供值所在的最左边的索引,若值不在列表中,则会产生错误:
>>> students_per_year = [6, 0, 0, 0]
>>> students_per_year.index(6)
0
>>> students_per_year.index(0)
1
>>> students_per_year.index(50)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: 50 is not in list
我们将搜索一个我们知道在列表中的值,因此不需要担心出现错误。
解决问题
我们到了!现在我们可以更新我们的部分解决方案,来处理任何有效的测试用例。新程序见列表 5-5。
YEAR_COSTS = [12, 10, 7, 5]
for dataset in range(10):
trip_cost = int(input())
proportions = input().split()
num_students = int(input())
for i in range(len(proportions)):
proportions[i] = float(proportions[i])
students_per_year = []
for proportion in proportions:
students = int(num_students * proportion)
students_per_year.append(students)
❶ counted = sum(students_per_year)
uncounted = num_students - counted
most = max(students_per_year)
where = students_per_year.index(most)
❷ students_per_year[where] = students_per_year[where] + uncounted
total_raised = 0
for i in range(len(students_per_year)):
total_raised = total_raised + students_per_year[i] * YEAR_COSTS[i]
if total_raised / 2 < trip_cost:
print('YES')
else:
print('NO')
列表 5-5:解决学校旅行问题
唯一的新代码是从❶开始的五行。我们使用sum来计算迄今为止我们已经统计了多少学生,然后从总学生数中减去这一数值,得到未统计学生的数量。接着,我们使用max和index来识别应当添加未统计学生数量的年份索引。最后,我们将未统计的学生数添加到这个索引上❷。(将0加到一个数字上不会改变该数字,因此不必担心当uncounted为0时需要编写特殊的行为代码。这个代码在这种情况下是安全的。)
这就是这个问题的全部内容。去提交给评审吧!然后再回来——我们即将探索更多一般的列表结构。
在继续之前,你可能想尝试解决“章节练习”中第 5 题,见第 134 页。
问题 #13:面包师奖金
在这个问题中,我们将看到列表如何帮助我们处理二维数据。这种数据在实际的程序中经常出现。例如,电子表格中的数据由行和列组成;处理这样的数据需要像我们即将学习的技巧。
这是 DMOJ 问题ecoo17r3p1。
挑战
面包师布里有若干特许经营商,每个特许经营商将烘焙商品销售给消费者。布里已经达到了 13 年的营业里程碑,她将通过根据销售额发放奖金来庆祝。这些奖金取决于每天的销售额和每个特许经营商的销售额。奖金的分配方式如下:
-
对于每一天,如果所有特许经营商的总销售额是 13 的倍数,那么那个倍数将作为奖金发放。例如,一个特许经营商当天共售出了 26 件烘焙商品,那么他将获得 26 / 13 = 2 个奖金。
-
对于每个在所有天数中总销售额是 13 的倍数的特许经营商,那个倍数将作为奖金发放。例如,一个特许经营商总共售出了 39 件烘焙商品,则他将获得 39 / 13 = 3 个奖金。
确定总共发放的奖金数。
输入
输入包含 10 个测试用例。每个测试用例包含以下几行:
-
一行包含特许经营商的整数数量f和天数d,它们之间由空格分隔。f的值在 4 到 130 之间,d的值在 2 到 4,745 之间。
-
d 行,每行包含 f 个由空格分隔的整数。每个整数表示一个销售量。这些行中的第一行给出了第一天每个加盟商的销售额,第二行给出了第二天的销售额,依此类推。每个整数在 1 到 13,000 之间。
输出
对于每个测试用例,输出奖励的奖金总数。
表格表示
这个问题的数据可以通过表格来表示。我们将从一个示例开始,然后看看如何将表格表示为一个列表。
探索一个测试用例
如果我们有 d 天数和 f 加盟商,我们可以将数据排成一个有 d 行和 f 列的表格。
这是一个示例测试用例:
6 4
1 13 2 1 1 8
2 12 10 5 11 4
39 6 13 52 3 3
15 8 6 2 7 14
对应这个测试用例的表格在 表 5-3 中。
表 5-3: 烘焙商奖金表
| 0 | 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|---|
| 0 | 1 | 13 | 2 | 1 | 1 | 8 |
| 1 | 2 | 12 | 10 | 5 | 11 | 4 |
| 2 | 39 | 6 | 13 | 52 | 3 | 3 |
| 3 | 15 | 8 | 6 | 2 | 7 | 14 |
我已经给行和列编号,从 0 开始,以便与我们稍后将数据存储到列表中的方式一致。
在这个测试用例中,奖励了多少奖金?首先,让我们看看表格的行,它们对应的是天数。第 0 行的销售总和是 1 + 13 + 2 + 1 + 1 + 8 = 26. 由于 26 是 13 的倍数,这一行给我们带来了 26 / 13 = 2 个奖金。第 1 行的总和是 44. 这不是 13 的倍数,所以这一行没有奖金。第 2 行的总和是 116——同样,没有奖金。第 3 行的总和是 52,给我们带来了 52 / 13 = 4 个奖金。
现在让我们看一下与加盟商对应的列。第 0 列的总和是 1 + 2 + 39 + 15 = 57. 这不是 13 的倍数,所以没有奖金。实际上,唯一给我们带来奖金的列是第 1 列。它的总和是 39,给我们带来了 39 / 13 = 3 个奖金。
奖金总数是 2 + 4 + 3 = 9. 所以,9 是这个测试用例的正确输出。
嵌套列表
到目前为止,我们已经看过了整数、浮点数和字符串的列表。我们还可以创建列表的列表,称为 嵌套列表。每个嵌套列表中的值本身就是一个列表。通常会使用类似 grid 或 table 的变量名来表示一个嵌套列表。这里是一个对应于 表 5-3 的 Python 列表:
>>> grid = [[ 1, 13, 2, 1, 1, 8],
... [ 2, 12, 10, 5, 11, 4],
... [39, 6, 13, 52, 3, 3],
... [15, 8, 6, 2, 7, 14]]
每个列表的值对应一行。如果我们索引一次,就能得到一行,它本身就是一个列表:
>>> grid[0]
[1, 13, 2, 1, 1, 8]
>>> grid[2]
[39, 6, 13, 52, 3, 3]
如果我们索引两次,就能得到一个单一的值。这里是第 1 行第 2 列的值:
>>> grid[1][2]
10
处理列比处理行稍微复杂一些,因为每列的值分布在多个列表中。要访问一列,我们需要从每一行聚合一个值。我们可以通过一个循环来实现,它逐步构建出代表某一列的新列表。在这里,我获取了第 1 列:
>>> column = []
>>> for i in range(len(grid)):
❶ ... column.append(grid[i][1])
...
>>> column
[13, 12, 6, 8]
请注意,第一个索引(行)在变化,而第二个索引(列)保持不变 ❶。这会选出每个值,其列索引相同。
那么,如何计算行和列的和呢?为了求一行的和,我们可以使用 sum 函数。这里是第 0 行的和:
>>> sum(grid[0])
26
我们也可以使用类似这样的循环:
>>> total = 0
>>> for value in grid[0]:
... total = total + value
...
>>> total
26
使用 sum 是更简便的选项,因此我们将使用它。
要计算一列的和,我们可以构建一个 column 列表并对其使用 sum,或者我们可以直接计算而不创建新的列表。下面是对第 1 列的后者方法:
>>> total = 0
>>> for i in range(len(grid)):
... total = total + grid[i][1]
...
>>> total
39
概念检查
以下代码的输出是什么?
lst = [[1, 1],
[2, 3, 4]]
x = 0
for i in range(len(lst)):
for j in range(len(lst[0])):
x = x + lst[i][j]
print(x)
A. 2
B. 7
C. 11
D. 这段代码会产生错误(使用了无效的索引)
答案:B. 变量 i 遍历值 0 和 1(因为 lst 的长度是 2);变量 j 也遍历值 0 和 1(因为 lst[0] 的长度是 2)。因此,列表中求和的值是那些索引为 0 或 1 的项,特别是这不包括位于 lst[1][2] 的 4。
概念检查
以下代码包含两个 print 调用。输出是什么?
lst = [[5, 10], [15, 20]]
x = lst[0]
x[0] = 99
print(lst)
lst = [[5, 10], [15, 20]]
y = lst[0]
y = y + [99]
print(lst)
A.
[[99, 10], [15, 20]]
[[5, 10], [15, 20]]
B.
[[99, 10], [15, 20]]
[[5, 10, 99], [15, 20]]
C.
[[5, 10], [15, 20]]
[[5, 10], [15, 20]]
D.
[[5, 10], [15, 20]]
[[5, 10, 99], [15, 20]]
答案:A. x 指的是 lst 的第一行;它是引用 lst[0] 的另一种方式。因此,当我们执行 x[0] = 99 时,这一改变也会反映在通过 lst 查看列表时。
接下来,y 也指向 lst 的第一行。但然后我们将一个新列表赋值给 y——而是那个列表,而不是 lst 的第一行,才会在末尾附加 99。
解决问题
我们解决此问题的代码见清单 5-6。
for dataset in range(10):
❶ lst = input().split()
franchisees = int(lst[0])
days = int(lst[1])
grid = []
❷ for i in range(days):
row = input().split()
❸ for j in range(franchisees):
row[j] = int(row[j])
❹ grid.append(row)
bonuses = 0
❺ for row in grid:
❻ total = sum(row)
if total % 13 == 0:
bonuses = bonuses + total // 13
❼ for col_index in range(franchisees):
total = 0
❽ for row_index in range(days):
total = total + grid[row_index][col_index]
if total % 13 == 0:
bonuses = bonuses + total // 13
print(bonuses)
清单 5-6: 解决面包师奖金问题
与“学校旅行”类似,输入包含 10 个测试用例,因此我们将所有代码放在一个循环中,这个循环执行 10 次。
对于每个测试用例,我们读取输入的第一行并调用 split 将其拆分为一个列表 ❶。该列表将包含两个值——特许经营者的数量和天数——我们将它们转换为整数并赋值给适当命名的变量。
grid 变量开始时是一个空列表。它最终会指向一个行列表,每行是给定日期的销售数据。
我们使用 for 循环遍历每一天 ❷。然后,我们读取输入中的一行并调用 split 将其拆分为一个销售值列表。这些值现在是字符串类型,因此我们使用嵌套循环将它们全部转换为整数 ❸。接着,我们将该行添加到网格 ❹ 中。
我们现在已经读取了输入并存储了网格。接下来是计算奖金总数。我们分两步进行:首先是按行计算奖金,然后是按列计算奖金。
为了从行中找到奖金,我们在 grid 上使用 for 循环❺。与任何在列表上的 for 循环一样,它会一次给我们一个值。在这里,每个值都是一个列表,所以 row 在每次迭代时指代一个不同的列表。sum 函数适用于任何数字列表,因此我们在这里使用它来计算当前行❻的值总和。如果总和能被 13 整除,我们就加上奖金数。
我们不能像处理行一样遍历列表的列,因此我们必须依靠通过索引来循环。我们通过使用 for 循环遍历列的索引❼来实现这一点。使用 sum 来求当前列的和并不可行,因此我们需要一个嵌套循环。这个嵌套循环会遍历行❽,把目标列中的每个值加起来。然后我们检查该总和是否能被 13 整除,如果能,就加上奖励。
我们通过打印总奖金数来结束。
判断时间!如果你提交我们的代码,你应该看到所有测试用例都通过了。
小结
在本章中,我们学习了列表,它帮助我们处理我们选择的任何类型的集合。数字列表、字符串列表、列表的列表:Python 支持我们需要的任何类型。我们还学习了列表方法,以及为什么排序列表可以让处理列表中的值变得更加容易。
与字符串不同,列表是可变的,这意味着我们可以更改它们的内容。这帮助我们更容易地操作列表,但我们必须小心修改我们想要修改的列表。
我们已经进入学习的阶段,现在可以编写包含多行代码的程序。我们可以通过 if 语句和循环来控制程序的行为。我们可以使用字符串和列表来存储和操作信息。我们可以编写程序来解决具有挑战性的问题。这样的程序可能变得难以设计和阅读。幸运的是,我们有一个工具可以帮助我们组织程序,以保持复杂度在可控范围内,我们将在下一章学习这个工具。完成以下的一些练习可能会加深你对编写大量代码的困难的理解。然后你就可以准备继续学习了!
本章练习
这里有一些练习供你尝试。
-
DMOJ 问题
ccc07j3,交易还是不交易计算器 -
DMOJ 问题
coci17c1p1,凯撒 -
DMOJ 问题
coci18c2p1,Preokret -
DMOJ 问题
ccc00s2,喋喋不休的溪流(看看 Python 的round函数。) -
DMOJ 问题
ecoo18r1p1,柳树的疯狂之旅 -
DMOJ 问题
ecoo19r1p1,免费 T 恤 -
DMOJ 问题
dmopc14c7p2,潮汐 -
DMOJ 问题
wac3p3,韦斯利玩 DDR -
DMOJ 问题
ecoo18r1p2,Rue 的戒指(如果你在这里使用 f-strings,你需要一种方法来包含{和}符号本身。你可以通过{{来包含{,通过}}来包含}。) -
DMOJ 问题
coci19c5p1,Emacs -
DMOJ 问题
coci20c2p1,Crtanje(你需要支持从 –100 到 100 的行。但当 Python 列表从索引 0 开始时,我们如何支持负索引的行呢?这里有一个小技巧:每次需要访问行x时,使用索引x + 100。这样就将行号从 –100 到 100 转换为 0 到 200。此外,关于字符串的一个小烦恼:\是一个特殊字符,所以如果你想要一个\字符,必须使用'\\'而不是'\'。) -
DMOJ 问题
dmopc19c5p2,Charlie 的疯狂征服(你需要小心索引和游戏规则!)
备注
Village Neighborhood 最初来自 2018 年加拿大计算机竞赛,高级组。School Trip 最初来自 2017 年安大略省教育计算组织编程竞赛,第一轮。Baker Bonus 最初来自 2017 年安大略省教育计算组织编程竞赛,第三轮。
第六章:使用函数设计程序

在编写大型程序时,组织代码为更小的逻辑单元非常重要,每个单元都为实现整体目标做出贡献。这样,我们就能单独考虑每个单元,而不用担心其他单元在做什么。然后我们将这些单元组合在一起。这些单元被称为函数。
在本章中,我们将使用函数来分解并解决两个问题:计算双人卡牌游戏的得分以及判断动作人偶盒子是否能够合理组织。
问题 #14:卡牌游戏
在这个问题中,我们将实现一个双人卡牌游戏。在思考问题的过程中,我们会发现相同的逻辑会多次出现。我们将学习如何将这部分代码封装成一个 Python 函数,以避免代码重复并提高代码的清晰度。
这是 DMOJ 问题 ccc99s1。
挑战
两名玩家,A 和 B,正在进行一场卡牌游戏。(你不需要了解扑克牌或卡牌游戏才能理解这个问题。)
游戏开始时有一副 52 张牌。玩家 A 从牌堆中抽取一张牌,然后玩家 B 抽取一张牌,然后是玩家 A,再然后是玩家 B,直到牌堆中没有牌为止。
牌堆中有 13 种类型的牌。这些类型如下:二、三、四、五、六、七、八、九、十、杰克、皇后、国王、王牌。每种类型的牌在牌堆中都有四张。例如,有四张二、四张三,以此类推,一直到四张王牌。(这就是为什么牌堆中有 52 张牌:13 种类型乘以每种类型 4 张牌。)
高牌是指杰克、皇后、国王或王牌。当玩家抽到一张高牌时,他们可能会得分。以下是得分规则:
-
如果玩家抽到一张杰克,且此时牌堆中至少还有一张牌,并且下一张牌不是高牌,那么该玩家得 1 分。
-
如果玩家抽到一张皇后,且此时牌堆中至少还有两张牌,并且接下来的两张牌中没有高牌,那么该玩家得 2 分。
-
如果玩家抽到一张国王,且此时牌堆中至少还有三张牌,并且接下来的三张牌中没有高牌,那么该玩家得 3 分。
-
如果玩家抽到一张王牌,且此时牌堆中至少还有四张牌,并且接下来的四张牌中没有高牌,那么该玩家得 4 分。
我们需要在每次玩家得分时输出信息,并在游戏结束时输出每个玩家的总分。
输入
输入由 52 行组成。每一行包含一张牌的类型。这些行是从牌堆中抽取的顺序;也就是说,第一行是从牌堆中抽取的第一张牌,第二行是第二张牌,以此类推。
输出
每当玩家得分时,输出以下内容:
Player p scores q point(s).
其中,p 代表玩家 A 为A,玩家 B 为B,q 代表他们刚得的分数。
游戏结束时,输出以下两行:
Player A: m point(s).
Player B: n point(s).
其中,m 是玩家 A 的总分,n 是玩家 B 的总分。
探索一个测试用例
如果你仔细考虑如何解决这个问题,你可能会想,是否可以在不学习任何新知识的情况下,直接解决它。事实上,我们可以!我们处于非常有利的状态。我们可以用列表表示牌堆。我们知道如何使用列表的append方法将一张牌添加到牌堆中。我们可以访问列表中的值来查找高牌。我们甚至可以使用 f-strings 来帮助我们输出玩家和分数的信息。
不过,在深入探讨之前,我们先通过一个小例子来演示。这样做将突显出我们缺少一个 Python 中至关重要的特性,这个特性将使得我们更容易组织解决方案并解决这个问题。
如果我们用一副 52 张牌的例子来演示,那我们得忙到明年了。所以我们使用一个只有 10 张牌的小例子。这个例子不是一个完整的测试用例,所以我们写的程序无法在其上运行,但它足以帮助我们理解游戏的机制以及我们的解决方案需要做些什么。以下是测试用例:
queen
three
seven
king
nine
jack
eight
king
jack
four
玩家 A 拿到了第一张牌,那是一个皇后牌。皇后牌是高牌,玩家 A 可能会得到 2 分。首先,我们确认这张皇后之后,牌堆中至少剩下两张牌。接着,我们需要检查这两张牌,希望其中没有高牌。这两张牌不是高牌——分别是三号牌和七号牌——所以玩家 A 得到了 2 分。
玩家 B 现在拿到第二张牌,那是三号牌。三号牌不是高牌,所以玩家 B 没有得分。
玩家 A 现在拿到七号牌,没有得分。
玩家 B 现在拿到国王牌,因此有机会得到 3 分。国王牌之后,牌堆中至少剩下三张牌。我们需要检查这三张牌,希望其中没有高牌。不幸的是,其中有一张高牌——杰克——所以玩家 B 没有得分。
玩家 A 现在拿到九号牌,没有得分。
玩家 B 现在拿到第一张杰克牌。在这张杰克牌之后,牌堆中至少剩下一张牌。我们需要检查这张牌,希望它不是高牌。好消息:它不是高牌——是八号牌——所以玩家 B 得到了 1 分。
只剩下最后一分了,这一分由玩家 A 获得,当他们从牌堆中拿到倒数第二张牌(杰克)时。
因此,这是这个测试用例的输出:
Player A scores 2 point(s).
Player B scores 1 point(s).
Player A scores 1 point(s).
Player A: 3 point(s).
Player B: 1 point(s).
注意,每次玩家拿到一张高牌时,我们需要检查两件事:第一,剩余的牌堆中是否至少还有一定数量的牌;第二,剩下的牌中是否没有高牌。第一个问题我们可以通过一个变量来管理,它会告诉我们已经拿了多少张牌。第二个问题则更复杂。我们需要一些代码来检查给定数量的牌中是否有高牌。更糟糕的是,如果我们不小心,我们会重复写四次非常相似的代码:一次检查拿到一张杰克后的牌,一次检查拿到一张皇后的两张牌,一次检查拿到一张国王后的三张牌,最后一次检查拿到一张王牌后的四张牌。如果我们后来发现逻辑有问题,我们就必须在多达四个不同的地方进行修正。
是否有 Python 特性可以让我们将这段“没有高牌”的逻辑仅封装一次,然后调用四次呢?有的。这就是所谓的函数,它是一个命名的代码块,用来执行一个小任务。函数对于我们代码的组织和清晰度至关重要。所有程序员都会使用它们。如果没有函数,编写像游戏和文字处理器这样的庞大软件系统将变得不可行。让我们学习如何使用函数。
定义和调用函数
我们已经学习了如何调用 Python 自带的函数。例如,我们使用了input函数来读取输入。这里是没有参数的input函数调用:
>>> s = input()
hello
>>> s
'hello'
我们还使用了 Python 的print函数来输出文本。这里是带有一个参数的print函数调用:
>>> print('well, well')
well, well
内建的 Python 函数是通用的,旨在在各种不同的场景中使用。当我们需要一个函数来解决特定任务时,我们就得自己定义一个。
没有参数的函数
要定义或创建一个函数,我们使用 Python 的def关键字。这里是一个输出三行的函数定义:
>>> def intro():
... print('*********')
... print('*WELCOME*')
... print('*********')
...
函数定义的结构类似于if语句或循环。def后面的名称是我们正在定义的函数名;在这里,我们定义了一个名为intro的函数。在函数名后面是一个空的圆括号()。稍后我们会看到,我们可以在这些括号中包含信息来传递参数给函数。这个intro函数没有接受任何参数,所以括号是空的。括号后面是一个冒号;就像if语句或循环一样,省略冒号会导致语法错误。在接下来的行中,我们提供了一段缩进的语句块,每次调用该函数时,这些语句将被执行。
当你定义intro函数时,你可能希望看到如下的输出:
*********
*WELCOME*
*********
但是不行:到目前为止我们只是定义了函数,并没有调用它。定义函数不会产生任何可见的效果;它只是将函数存储在计算机的内存中,以便我们稍后调用。我们调用自定义函数的方式就像调用 Python 的内置函数一样。由于这个intro函数不需要任何参数,我们在调用时使用一个空的括号:
>>> intro()
*********
*WELCOME*
*********
你可以根据需要多次调用这个函数。它会在我们需要时随时存在。
带参数的函数
我们的intro函数不够灵活,因为每次调用时它做的事情都是一样的。我们可以修改这个函数,使得我们可以传入参数,并且传入的参数可以影响函数的行为。这里是一个新的intro函数版本,它允许我们传入一个参数:
>>> def intro2(message):
... line_length = len(message) + 2
... print('*' * line_length)
... print(f'*{message}*')
... print('*' * line_length)
...
要调用这个函数,我们提供一个字符串参数:
>>> intro2('HELLO')
*******
*HELLO*
*******
>>> intro2('WIN')
*****
*WIN*
*****
我们不能在没有参数的情况下调用intro2函数——如果尝试调用,将会报错:
>>> intro2()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: intro2() missing 1 required positional argument: 'message'
错误提醒我们没有为message提供参数。message是一个函数参数。当我们调用intro2时,Python 首先让message指向我们传入的参数;也就是说,message成为了我们参数的别名。
我们可以创建具有多个参数的函数。这里是一个需要两个参数的函数,一个是要打印的消息,另一个是打印的次数:
>>> def intro3(message, num_times):
... for i in range(num_times):
... print(message)
...
要调用这个函数,我们提供两个参数。Python 从左到右工作,将第一个参数赋给第一个参数,第二个参数赋给第二个参数。在以下调用中,'high'被赋值给message参数,5被赋值给num_times参数:
>>> intro3('high', 5)
high
high
high
high
high
一定要提供正确数量的参数。对于intro3,我们需要两个参数。任何其他情况都会导致错误:
>>> intro3()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: intro3() missing 2 required positional arguments: 'message'
and 'num_times'
>>> intro3('high')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: intro3() missing 1 required positional argument: 'num_times'
我们还必须确保提供正确类型的值。错误的类型不会阻止我们调用函数,但会导致函数内部出错:
>>> intro3('high', 'low')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 2, in intro3
TypeError: 'str' object cannot be interpreted as an integer
这个TypeError错误是由于intro3使用了for循环遍历num_times变量。如果我们为num_times提供的参数不是整数,for循环就会失败。
关键字参数
在调用函数时,实际上可以覆盖参数和参数值从左到右的对应关系。为了做到这一点,我们可以按照任何顺序使用参数的名称。使用参数名称的参数被称为关键字参数。下面是它的工作方式:
>>> def intro3(message, num_times):
... for i in range(num_times):
... print(message)
...
>>> intro3(message='high', num_times=3)
high
high
high
>>> intro3(num_times=3, message='high')
high
high
high
这里的每个函数调用都使用了两个关键字参数。关键字参数的写法是参数名、等号和对应的参数值。
你甚至可以先使用普通参数,然后再使用关键字参数:
>>> intro3('high', num_times=3)
high
high
high
但是一旦你使用了关键字参数,就不能再回到普通参数了:
>>> intro3(message='high', 3)
File "<stdin>", line 1
SyntaxError: positional argument follows keyword argument
在第五章的“排序列表”中,我们在调用sort方法时使用了reverse关键字参数。Python 的设计者决定将reverse设置为仅限关键字的参数,这意味着在没有使用关键字参数的情况下,无法为它填充值。Python 也允许我们在函数中使用这种方式,但在本书中我们不需要这种级别的控制。
局部变量
参数的名称就像普通变量一样工作,但它们是局部的,仅限于定义它们的函数。也就是说,函数参数在其函数外部是不存在的:
>>> def intro2(message):
... line_length = len(message) + 2
... print('*' * line_length)
... print(f'*{message}*')
... print('*' * line_length)
...
>>> intro2('hello')
*******
*hello*
*******
>>> message
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'message' is not defined
那个line_length变量怎么样,是局部变量吗?是的:
>>> line_length
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'line_length' is not defined
如果你有一个变量,并且调用一个使用相同名称的参数或局部变量的函数,会发生什么?你的值会丢失吗?让我们来看一下:
>>> line_length = 999
>>> intro2('hello')
*******
*hello*
*******
>>> line_length
999
呼——它仍然是999,就像我们离开时一样。局部变量在函数被调用时创建,在函数结束时销毁,而不会影响其他具有相同名称的变量。
一个函数可以访问在函数外部创建的变量。然而,依赖于这种做法并不明智,因为这样会导致该函数不具备自我封装性,而是依赖于期望存在的变量是否真的存在。在本书中,我们将编写只使用局部变量的函数。函数所需的所有信息都会通过参数提供给它。
可变参数
由于参数是对应参数的别名,它可以用来改变一个可变值。这里有一个函数,用来从列表lst中移除所有出现的value:
>>> def remove_all(lst, value):
... while value in lst:
... lst.remove(value)
...
>>> lst = [5, 10, 20, 5, 45, 5, 9]
>>> remove_all(lst, 5)
>>> lst
[10, 20, 45, 9]
注意,我们通过使用变量传递了一个列表给remove_all。如果你直接传递一个列表值(而不是一个引用该列表的变量),这个函数将不会完成任何有用的操作:
>>> remove_all([5, 10, 20, 5, 45, 5, 9], 5)
该函数移除了列表中的所有 5,但由于我们没有使用一个变量,我们再也无法引用这个列表。
概念检查
以下代码的输出是什么?
def mystery(s, lst):
s = s.upper()
lst = lst + [2]
s = 'a'
lst = [1]
mystery(s, lst)
print(s, lst)
A. a [1]
B. a [1, 2]
C. A [1]
D. A [1, 2]
答案:A. 当调用mystery时,它的s参数被赋值为s参数所指向的内容,也就是'a'字符串。类似地,它的lst参数被赋值为lst参数所指向的内容,也就是[1]列表。在mystery函数内部,s和lst是局部变量。
现在,让我们研究一下该函数本身的两个语句。
首先,s = s.upper()。这将使局部变量s指向'A'(大写)。但它并没有改变函数外部s所指向的内容。外部的s仍然指向'a'(小写)。
其次,lst = lst + [2]。使用+运算符和列表会创建一个新列表(它不会改变现有的列表!),所以这会让局部变量lst指向新的列表:[1, 2]。但同样,它并没有改变函数外部lst所指向的内容;它仍然是[1]。
怎么回事——我之前不是告诉过你函数可以改变可变参数吗?我说过;但是为了实现这一点,你真的需要改变值本身,而不是改变局部变量指向的内容。比较一下前一个程序和下一个程序,后者的输出是不同的:
def mystery(s, lst):
s.upper() # upper creates a new string
lst.append(2) # append changes the list
s = 'a'
lst = [1]
mystery(s, lst)
print(s, lst)
返回值
让我们回到卡牌游戏问题。我们的目标是定义一个函数,告诉我们列表中是否没有高牌。我们将这个函数命名为no_high。我们还没有编写no_high,但我们仍然可以指定我们希望实现的目标。我们想要的是:
>>> no_high(['two', 'six'])
True
>>> no_high(['eight'])
True
>>> no_high(['two', 'jack', 'four'])
False
>>> no_high(['queen', 'king', 'three', 'queen'])
False
我们希望前两个调用返回True,因为在这些牌列表中没有高牌。而我们希望第三个和第四个调用返回False,因为这些牌列表中至少有一张高牌。
我们如何定义一个返回这些True和False值的函数?那是函数谜题的最后一块拼图。
为了从函数返回一个值,我们使用 Python 的 return 关键字。一旦遇到return,函数的执行就会终止,并将指定的值返回给调用者。
下面是我们如何编写no_high函数:
>>> def no_high(lst):
... if 'jack' in lst:
... return False
... if 'queen' in lst:
... return False
... if 'king' in lst:
... return False
... if 'ace' in lst:
... return False
... return True
...
我们首先检查列表中是否有任何'jack'卡牌。如果有,那么我们知道列表中包含一张或多张高牌,因此我们立即返回False。
如果我们还在这里,那就说明没有任何杰克牌。但可能会有其他高牌,所以我们需要检查它们。剩下的if语句分别检查皇后、国王和王牌,如果列表中包含任何一张,则返回False。
如果我们没有命中任何四个return语句,那么列表中就没有高牌。在这种情况下,我们返回True。
一个单独的return,没有给定值时,返回None。如果你写的函数不返回任何有用的东西,并且需要在到达代码底部之前终止函数,这是非常有用的。
如果在循环中遇到return,函数仍然会立即终止,不管它有多深嵌套。下面是一个示例,展示了return如何让我们跳出嵌套的循环:
>>> def func():
... for i in range(10):
... for j in range(10):
... print(i, j)
... if j == 4:
... return
...
>>> func()
0 0
0 1
0 2
0 3
0 4
return就像是一个超级break!有些人不喜欢在循环中使用return,原因与不喜欢使用break相同:它可能会模糊循环的目的和逻辑。当方便时,我会在循环中使用return。与break可以出现在任何地方不同,return仅限于出现在函数内部,且与其他代码分开。如果我们保持函数小巧,那么在循环中使用return可以帮助我们编写清晰的代码,而不干扰周围的代码。
概念检查
以下版本的no_high是否正确?也就是说,如果列表中至少有一张高牌,它是否返回True,否则返回False?
def no_high(lst):
for card in lst:
if card in ['jack', 'queen', 'king', 'ace']:
return False
else:
return True
A. 对
B. 不对;例如,对于['two', 'three'],它返回了错误的值。
C. 不对;例如,对于['jack'],它返回了错误的值。
D. 不;例如,它返回了错误的值 ['jack', 'two']
E. 不;例如,它返回了错误的值 ['two', 'jack']
答案:E. if-else语句导致循环在第一次迭代时总是终止。如果第一张卡片是高牌,函数终止并返回False;如果第一张卡片不是高牌,函数终止并返回True。它不会查看其他卡片!这就是为什么它在['two', 'jack']上失败的原因:第一张卡片不是高牌,所以函数返回True。返回True意味着列表中没有高牌。但这错了:里面有一张杰克!函数做错了,它应该返回False。
函数文档
现在我们可以清楚地知道no_high函数的作用以及如何调用它。但几个月后,当我们不再立即记得旧代码的目的时会怎么样呢?如果我们积累了大量自己的函数,难以记住每个函数的作用,又该怎么办呢?
对于我们编写的每个函数,我们会添加文档说明,指定每个参数的含义以及函数返回的内容。这种文档称为文档字符串,即“documentation string”的缩写。文档字符串应从函数块的第一行开始编写。以下是no_high函数,这次带有文档说明:
>>> def no_high(lst):
... """
... lst is a list of strings representing cards.
...
... Return True if there are no high cards in lst, False otherwise.
... """
... if 'jack' in lst:
... return False
... if 'queen' in lst:
... return False
... if 'king' in lst:
... return False
... if 'ace' in lst:
... return False
... return True
...
文档字符串以三个双引号(""")开头和结束。像单引号(')或双引号(")一样,三个双引号也可以用来开始和结束任何字符串。用三个引号创建的字符串称为三引号字符串。(三个单引号也可以使用,但 Python 的惯例是使用三个双引号。)它们的优点是,允许我们通过在每一行后按下 ENTER 键来添加多行文本;用'或"创建的字符串则不能像这样跨越多行。我们使用三引号字符串作为文档字符串,这样我们就可以包含任意多的行。
这里的文档字符串告诉我们lst是什么:它是一个表示卡片的字符串列表。它还告诉我们,函数返回一个True或False值,并说明每个返回值的含义。这些信息足以让任何人调用此函数,而不必查看其代码。只要有人知道一个函数的作用,他们就可以直接使用它。我们一直在使用 Python 函数,而从未查看过它们的代码。print是如何工作的?input是如何工作的?我们不知道!但这并不重要:我们知道这些函数的作用,因此可以专注于调用它们。
对于具有多个参数的函数,文档字符串应列出每个参数并给出预期的类型。以下是本章《可变参数》中的remove_all函数,并附上了合适的文档字符串:
>>> def remove_all(lst, value):
... """
... lst is a list.
... value is a value.
...
... Remove all occurrences of value from lst.
... """
... while value in lst:
... lst.remove(value)
...
请注意,这个文档字符串没有提到返回任何内容。这是因为这个函数不返回任何有用的东西!它从lst中移除内容,文档字符串就是这么说的。
解决问题
我们刚刚学习了定义和调用函数的基础知识。在本书接下来的部分,每当我们面临一个大问题时,我们将能够将问题的解决方案拆分为更小的任务,每个任务将由一个函数来解决。
让我们在卡牌游戏的解决方案中使用我们的no_high函数。代码见清单 6-1。
❶ NUM_CARDS = 52
❷ def no_high(lst):
"""
lst is a list of strings representing cards.
Return True if there are no high cards in lst, False otherwise.
"""
if 'jack' in lst:
return False
if 'queen' in lst:
return False
if 'king' in lst:
return False
if 'ace' in lst:
return False
return True
❸ deck = []
❹ for i in range(NUM_CARDS):
deck.append(input())
score_a = 0
score_b = 0
player = 'A'
❺ for i in range(NUM_CARDS):
card = deck[i]
points = 0
❻ remaining = NUM_CARDS - i - 1
❼ if card == 'jack' and remaining >= 1 and no_high(deck[i+1:i+2]):
points = 1
elif card == 'queen' and remaining >= 2 and no_high(deck[i+1:i+3]):
points = 2
elif card == 'king' and remaining >= 3 and no_high(deck[i+1:i+4]):
points = 3
elif card == 'ace' and remaining >= 4 and no_high(deck[i+1:i+5]):
points = 4
❽ if points > 0:
print(f'Player {player} scores {points} point(s).')
❾ if player == 'A':
score_a = score_a + points
player = 'B'
else:
score_b = score_b + points
player = 'A'
print(f'Player A: {score_a} point(s).')
print(f'Player B: {score_b} point(s).')
清单 6-1:解决卡牌游戏问题
我引入了常量NUM_CARDS来表示52❶。我们将在代码中多次使用它,记住NUM_CARDS的含义比记住52的含义更容易。
接下来我们定义no_high函数,包括我们已深入讨论过的文档字符串❷。我们总是把函数放在程序的顶部。这样,函数可以被后续的代码调用。
程序的主要部分从创建一个列表开始,这个列表将包含牌堆中的卡片❸。然后我们从输入中读取卡片❹,并将每张卡片添加到牌堆中。你会注意到,卡片从未被字面上移除或取出(整个程序执行过程中,牌堆保持不变)。我们本可以那样做,但我选择了跟踪我们在牌堆中的位置,这样我们就知道下一张卡片会被移除。
还有三个其他关键变量我们需要维护:score_a,玩家 A 当前的总分;score_b,玩家 B 当前的总分;以及player,当前玩家的名字。
接下来的任务是查看牌堆中的每一张卡片,以便为玩家打分。一个普通的for循环可以让我们查看当前的卡片。但这还不够:如果当前卡片是高牌,那么我们还必须能够查看后面的卡片。为此,我们使用了一个范围for循环❺。
在每次循环迭代中,我们根据当前玩家从牌堆中拿到的卡片来确定该玩家获得的积分。每个得分规则都依赖于牌堆中剩余卡片的数量。remaining变量❻告诉我们剩余卡片的数量。当i为0时,剩余卡片的数量是51,因为我们刚刚拿了第一张卡片。当i为1时,剩余卡片的数量是50,因为我们刚刚拿了第二张卡片。一般来说,剩余卡片的数量可以通过总卡片数减去i再减去1来表示。
现在我们有了四个测试,每个测试对应一种得分方式❼。每个测试检查当前卡牌和剩余的卡牌数量。如果这两个条件都为True,则调用我们的no_high函数,并传入包含适当数量卡牌的卡组切片。例如,如果当前卡牌是'jack',并且至少剩余1张卡牌,则我们将长度为1的列表传递给no_high❼。如果no_high返回True,则说明切片中没有高卡,当前玩家得分。points变量决定将要获得的得分;它在每次循环迭代时从0开始,并根据需要设置为1、2、3或4。
如果玩家得分❽,那么我们将输出一条消息,指明得分的玩家以及他们所获得的分数。
当前迭代剩下的任务就是将得分加到当前玩家的分数上,并轮到另一个玩家。我们通过if-else语句❾来完成这两个任务。(如果当前迭代中的points为0,则会向玩家的分数中添加一个无害的0,不需要专门测试和避免这种情况。)
最后的两个print语句输出每个玩家的总得分。
就这样:我们使用一个函数解决了这个问题,组织了我们的代码,使其更易于阅读。可以自由将我们的代码提交给裁判,你会看到所有测试用例都通过了。
问题 #15:动作人物模型
为了解决卡牌游戏问题,我们首先通过一个例子来理解,这个例子突出了函数可能有用的地方。现在,我们将使用函数解决另一个问题,但我们会通过更系统化的方法来发现所需的函数。
这是 Timus 的第2144号问题。这是书中唯一来自 Timus 裁判的题目。要找到这个问题,请访问acm.timus.ru/,点击Problem set,点击Volume 12,然后找到 2144 号问题(在裁判系统中叫做 Cleaning the Room)。
挑战
Lena 有n个未开封的动作人物模型盒子。盒子不能打开(否则动作人物模型的价值会降低),因此盒子中的人物模型顺序不能更改。此外,盒子不能旋转(否则人物模型会朝向错误的方向)。
每个动作人物模型都由其高度来表示。例如,一个盒子可能有三个人物模型,分别是高度 4、5 和 7。从左到右排列。当我提到动作人物模型盒子时,我总是会从左到右列出人物模型的高度。
Lena 想要整理这些盒子,即将盒子排列成从左到右人物模型的高度逐渐增加或保持不变。
是否可以整理这些盒子取决于盒子中人物模型的高度。例如,如果第一个盒子的高度是 4、5 和 7,第二个盒子的高度是 1 和 2,那么她可以通过先放第二个盒子来整理这些盒子。但如果我们保持第一个盒子不变,第二个盒子的高度改为 6 和 8,那么就无法整理这两个盒子。
判断 Lena 是否可以整理这些盒子。
输入
输入包括以下几行:
-
一行包含整数 n,即盒子的数量。n 的范围是 1 到 100。
-
n 行,每行对应一个盒子。每行的开头是整数 k,表示这个盒子中的人物模型数量。k 的范围在 1 到 100 之间。(因为 k 至少为 1,所以我们不需要担心空盒子。)紧接着 k 后面,是 k 个整数,表示这个盒子中人物模型的高度,从左到右排列。每个高度是一个介于 1 到 10,000 之间的整数。每对整数之间有一个空格。
输出
如果 Lena 可以整理这些盒子,输出 YES;否则,输出 NO。
代表盒子
这个问题由几个较小的问题组成,我们可以通过编写函数来解决每个问题。首先,让我们看看如何在 Python 中表示这些盒子,然后我们将设计我们需要的函数。
在第五章中,当我们解决贝克奖金问题时,我们学到了列表可以将其他列表作为其值。这使得我们可以将列表嵌套在列表中。我们可以使用这种结构来表示人物模型的盒子。例如,这里有一个列表,表示两个盒子:
>>> boxes = [[4, 5, 7], [1, 2]]
第一个盒子里有三个人物模型,第二个盒子里有两个。我们可以单独访问每个盒子:
>>> boxes[0]
[4, 5, 7]
>>> boxes[1]
[1, 2]
我们将从输入中读取盒子的内容,并将这些信息放入一个嵌套列表,就像我所展示的那样。然后我们将使用这个嵌套列表来判断这些盒子是否能够整理。
自顶向下设计
我们将使用一种程序设计方法来解决这个问题,称为 自顶向下设计。自顶向下设计将一个大问题分解为多个较小的问题。这很有用,因为每个较小的问题都更容易解决。然后,我们可以将这些子问题的解决方案组合起来,解决原始问题。
做自顶向下设计
这就是自顶向下设计的工作方式。我们从编写一个不完整的 Python 程序开始,它捕捉了问题解决方案中的主要任务。其中一些任务不需要太多代码,所以我们可以直接解决它们。其他任务则需要更多代码,我们会将每个任务转换成一个函数来调用。我们也可能通过编写一些代码 并且 调用函数来解决某些任务。不过,这些函数现在还不存在,我们必须编写它们!
要写一个所需的函数,我们对该函数的任务重复这个相同的过程。也就是说,我们首先写下该函数的任务。如果我们能直接为某个任务编写代码,那就直接编写;否则,我们调用另一个函数(我们稍后会编写)来处理该任务。
我们不断重复这个过程,直到没有更多的函数需要编写。到那时,我们就能得出问题的解决方案。
这就是所谓的自顶向下设计,因为我们从问题的最顶层或最高层开始,逐步向下,深入问题的核心,直到每个任务都被完全编写成代码。我们现在将使用这种方法来解决动作人物问题。
顶层
在开始设计之前,我们专注于我们需要解决的主要任务。
我们肯定需要读取输入,因此这将是我们的第一个任务。
现在,假设我们已经读取了输入。我们应该做什么来确定箱子是否可以组织起来?一个重要的步骤是检查每个箱子,确保它的动作人物的身高是按顺序排列的。例如,假设我们有一个箱子[18, 20, 4]。这个箱子的身高乱序,意味着我们没有机会整理所有箱子。我们甚至不能整理这个箱子!
所以,这就是我们的第二个任务:确定每个箱子本身是否有序。如果任何一个箱子的动作人物顺序错乱,那么我们知道这些箱子无法组织。如果所有的箱子都没问题,那么我们还有更多的检查。
如果每个箱子本身都没有问题,接下来的问题是我们是否能够组织所有的箱子。我们在这里可以做出一个重要的观察:从现在开始,我们关心的唯一动作人物是每个箱子左右两侧的动作人物。箱子中间的动作人物不再重要。
这里有一个例子,我们有三个箱子:
[[9, 13, 14, 17, 25],
[32, 33, 34, 36],
[1, 6]]
第一个箱子以身高 9 的动作人物开始,以身高 25 的动作人物结束。放在这个箱子左边的动作人物的身高必须都不超过 9;例如,我们可以将第三个箱子放在这个箱子左边。放在这个箱子右边的动作人物的身高必须都不低于 25;例如,我们可以将第二个箱子放在这个箱子右边。身高为 13、14 和 17 的动作人物没有任何影响;它们可以不存在。
那么,第三个任务就是:忽略所有动作人物,除了那些在箱子两端的动作人物。
在第三个任务之后,我们会得到如下的任务列表:
[[9, 25],
[32, 36],
[1, 6]]
如果我们先对这些箱子进行排序,那么判断它们是否可以组织起来就容易多了,就像这样:
[[1, 6],
[9, 25],
[32, 36]]
现在很容易看出一个箱子的邻近箱子是什么样子的。(我们在解决第五章的村庄邻里问题时也使用了类似的方法。)所以,我们的第四个任务是对箱子进行排序。
我们的第五个也是最后一个任务是判断这些排序后的盒子是否已经被整理好。只有当动作人物的高度从左到右是有序时,盒子才算整理好。高度为 1、6、9、25、32 和 36 的动作人物已经按正确的顺序排列,因此前面的盒子可以被整理好。但考虑下面这个例子:
[[1, 6],
[9, 50],
[32, 36]]
这些盒子无法排序,因为第二个盒子里有个巨大的动作人物。第二个盒子占据了 9 到 50 的高度;第三个盒子不能放在第二个盒子的右边,因为它的高度太小。
我们现在已经完成了问题的设计,并决定了五个主要任务:
-
读取输入。
-
检查所有盒子是否正常。
-
从每个盒子中获取一个新的盒子列表,只包含左右动作人物的高度。
-
对这些新盒子进行排序。
-
判断这些排序后的盒子是否已被整理好。
你可能会好奇,为什么我们有一个“读取输入”的任务,但没有“写入输出”的任务。对于这个问题,写输出仅仅是根据需要输出YES或NO;不会有太多复杂的操作。此外,我们会在知道答案的第一时间就输出YES或NO,所以输出会与其他任务交替进行。基于这些原因,我决定不把它作为一个主要任务。在进行自上而下的设计时,不用担心如果后来发现漏掉了任务,可以随时添加并继续设计。
这是我们如何在代码中捕捉所需任务的方式:
❶ # Main Program
# TODO: Read input
# TODO: Check whether all boxes are OK
# TODO: Obtain a new list of boxes with only left and right heights
# TODO: Sort boxes
# TODO: Determine whether boxes are organized
我将这个称为主程序❶。我们写的任何函数都应该包含在这个注释之前。
每个任务目前仅作为注释书写。TODO标记用来突出这些任务是我们需要从英语转换为 Python 的任务。每当我们完成一个任务时,我们会移除它的TODO。这样,我们就能追踪哪些任务已经完成,哪些还没完成。让我们开始吧!
任务 1:读取输入
我们需要读取包含n(盒子数量)的那一行,然后读取盒子。读取整数是一行代码就能完成的任务,所以我们直接读取n。而读取盒子则是一个定义良好的任务,需要几行代码来解决,因此我们将通过一个函数来完成;我们称之为read_boxes。在主程序中,这就是我们当前的进度:
# Main Program
❶ # Read input
n = int(input())
boxes = read_boxes(n)
# TODO: Check whether all boxes are OK
# TODO: Obtain a new list of boxes with only left and right heights
# TODO: Sort boxes
# TODO: Determine whether boxes are organized
我已从注释❶中移除了TODO,因为从主程序的角度来看,我们已经解决了这个任务。当然,我们还需要编写read_boxes函数,接下来我们就来做这个。
read_boxes函数接受一个整数n作为参数,读取并返回n个盒子。以下是代码:
def read_boxes(n):
"""
n is the number of boxes to read.
Read the boxes from the input, and return them as a
list of boxes; each box is a list of action figure heights.
"""
boxes = []
❶ for i in range(n):
box = input().split()
❷ box.pop(0)
for i in range(len(box)):
box[i] = int(box[i])
boxes.append(box)
return boxes
我们需要读取n个盒子,因此我们循环n次❶。在每次循环中,我们读取当前行并将其拆分成单独的动作人物高度。行的开头是一个整数,表示这一行的高度数量,所以我们在继续之前会将该值从列表中移除(它在索引0)。然后,我们将每个高度转换为整数,并将当前盒子添加到盒子列表中。最后,我们返回盒子列表。
我们没有将read_boxes的任何部分推迟到一个尚未编写的函数中,所以我们完成了这个任务!我们会将这个函数与其他编写的函数一起放在# 主程序注释之前。
任务 2:检查所有盒子是否合格
每个盒子是否从最短的动作人物到最高的动作人物依次排列?好问题,这不是我们能在一两行代码中回答的。我们依赖一个新的函数all_boxes_ok来告诉我们。如果该函数返回False,说明至少有一个盒子的高度有问题,我们无法整理这些盒子。在这种情况下,我们应该输出NO。如果all_boxes_ok返回True,那么我们应该继续执行剩余任务,判断这些盒子是否能够整理。让我们也将这个if-else逻辑添加到我们的程序中。以下是我们得到的:
# Main Program
# Read input
n = int(input())
boxes = read_boxes(n)
# Check whether all boxes are OK
❶ if not all_boxes_ok(boxes):
print('NO')
else:
# TODO: Obtain a new list of boxes with only left and right heights
# TODO: Sort boxes
# TODO: Determine whether boxes are organized
现在我们需要编写我们调用的all_boxes_ok函数 ❶。我们可以检查每个盒子是否按顺序排列。如果没有,我们立即返回False。如果按顺序排列,我们检查下一个盒子。如果检查每个盒子且它们都按顺序排列,我们就返回True。
啊哈,所以我们需要能够检查一个单独的盒子!听起来像是另一个函数。我们叫它box_ok。
这是我们为all_boxes_ok写的:
def all_boxes_ok(boxes):
"""
boxes is a list of boxes; each box is a list of action figure heights.
Return True if each box in boxes has its action figures in
nondecreasing order of height, False otherwise.
"""
for box in boxes:
if not box_ok(box):
return False
return True
我在注释中使用了nondecreasing这个词,而不是increasing,因为动作人物的高度可以相等。例如,盒子[4, 4, 4]是完全可以的;如果说这个盒子是“递增”的,那就是不正确的。
我们将all_boxes_ok任务的一部分推到box_ok函数中,所以接下来我们来编写这个函数。开始吧:
def box_ok(box):
"""
box is the list of action figure heights in a given box.
Return True if the heights in box are in nondecreasing order,
False otherwise.
"""
for i in range(len(box)):
if box[i] > box[i + 1]:
return False
return True
如果某个高度大于它右边的高度,我们返回False,因为高度顺序不对。如果通过了for循环,那么就没有高度违规,返回True。
使用自顶向下设计的一个好处是,我们得到了一小块块的代码,以函数的形式封装起来,可以单独测试。例如,将box_ok的代码输入到 Python shell 中。然后我们可以测试它:
>>> box_ok([4, 5, 6])
我们希望这里返回True,因为盒子的高度是从小到大排列的。我们当然不希望得到我们实际得到的结果:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 9, in box_ok
IndexError: list index out of range
错误从来都不好玩,当我们需要翻遍页面的代码才能找到它们时,更加令人沮丧。但在这里,我们知道错误局限于这个小函数,因此我们找出它的工作量大大减少。问题在于,我们最终会将最右边的高度与右边的高度进行比较——当然,右边的高度是不存在的!所以我们需要提前一轮停止,比较倒数第二个高度和最后一个高度。以下是更新后的代码:
def box_ok(box):
"""
box is the list of action figure heights in a given box.
Return True if the heights in box are in nondecreasing order,
False otherwise.
"""
❶ for i in range(len(box) - 1):
if box[i] > box[i + 1]:
return False
return True
唯一的变化是在对range ❶的调用。如果你测试这个版本的函数,你会看到它按要求工作。任务 2 完成!
任务 3:获得只有左右高度的新盒子列表
现在我们已经掌握了自上而下的设计方法。在这个任务中,我们需要一种方法,将包含所有动作人物的盒子,转变为只包含最左侧和最右侧动作人物的盒子。我将把最左侧和最右侧的动作人物称为盒子的端点。
一种方法是创建一个新的仅包含端点的盒子列表,这也是我在这里要做的。你也可以考虑从原始盒子中实际删除高度,虽然这稍微复杂一些。
我已经为这个任务将函数命名为boxes_endpoints。这是程序的主要部分,已经更新为调用该函数:
# Main Program
# Read input
n = int(input())
boxes = read_boxes(n)
# Check whether all boxes are OK
if not all_boxes_ok(boxes):
print('NO')
else:
# Obtain a new list of boxes with only left and right heights
❶ endpoints = boxes_endpoints(boxes)
# TODO: Sort boxes
# TODO: Determine whether boxes are organized
当我们用包含盒子的列表❶调用boxes_endpoints时,我们期望返回一个仅包含盒子端点的新列表。这是满足此描述的boxes_endpoints代码:
def boxes_endpoints(boxes):
"""
boxes is a list of boxes; each box is a list of action figure heights.
Return a list, where each value is a list of two values:
the heights of the leftmost and rightmost action figures in a box.
"""
❶ endpoints = []
for box in boxes:
❷ endpoints.append([box[0], box[-1]])
return endpoints
我们创建一个新的列表❶,用来存放每个盒子的端点。然后我们遍历盒子。对于每个盒子,我们使用索引来找到盒子中的最左侧和最右侧的高度,并将它们添加到我们的端点列表❷中。最后,我们返回端点列表。
等一下:如果一个盒子里只有一个动作人物会怎么样?我们的boxes_endpoints函数会怎么处理它?根据它的文档字符串,它会为任何有效的盒子返回一个包含两个值的列表。所以这里最好也发生这种情况,否则函数就没有做到它承诺的功能。我们来测试一下。将boxes_endpoints函数输入到 Python 终端,并尝试使用一个只包含一个动作人物的盒子的列表:
>>> boxes_endpoints([[2]])
[[2, 2]]
成功了!最左侧的高度是2,最右侧的高度是2,所以我们得到了一个包含两个2的列表。我们的函数在这个案例中工作正常,因为当box只有一个值时,box[0]和box[-1]都指向相同的值。(不必担心空盒子的情况,问题描述禁止了空盒子。)
任务 4:排序盒子
到了这个时候,我们有了一个端点列表——大概是这样的:
>>> endpoints = [[9, 25], [32, 36], [1, 6]]
>>> endpoints
[[9, 25], [32, 36], [1, 6]]
我们想对它们进行排序。我们需要另一个函数吗?比如sort_endpoints函数?
这次不是!sort方法正是我们需要的:
>>> endpoints.sort()
>>> endpoints
[[1, 6], [9, 25], [32, 36]]
当对包含两个值的列表调用sort时,它会使用第一个值进行排序。(如果有并列情况,它会进一步使用第二个值进行排序。)
我们可以立即通过调用sort来更新程序的主要部分,再解决一个TODO。这是更新后的代码:
# Main Program
# Read input
n = int(input())
boxes = read_boxes(n)
# Check whether all boxes are OK
if not all_boxes_ok(boxes):
print('NO')
else:
# Obtain a new list of boxes with only left and right heights
endpoints = boxes_endpoints(boxes)
# Sort boxes
endpoints.sort()
# TODO: Determine whether boxes are organized
我们快完成了。只剩下一个TODO。
任务 5:确定盒子是否有序
我们的最终任务是检查端点。它们可能是有序的,像这样:
[[1, 6],
[9, 25],
[32, 36]]
或者它们也可能不是有序的,像这样:
[[1, 6],
[9, 50],
[32, 36]]
在前一种情况下,我们应该打印YES;在后一种情况下,我们应该打印NO。我们需要一个函数来告诉我们端点是否有序。最后一次更新程序的主要部分,结果是这样的:
# Main Program
# Read input
n = int(input())
boxes = read_boxes(n)
# Check whether all boxes are OK
if not all_boxes_ok(boxes):
print('NO')
else:
# Obtain a new list of boxes with only left and right heights
endpoints = boxes_endpoints(boxes)
# Sort boxes
endpoints.sort()
# Determine whether boxes are organized
❶ if all_endpoints_ok(endpoints):
print('YES')
else:
print('NO')
我们和问题的完整解决方案之间只差一个 all_endpoints_ok 函数❶。它接收一个列表,每个值都是一个端点列表,如果端点顺序正确,返回 True,否则返回 False。
让我们通过一个示例来理解如何实现这个函数。以下是我们将使用的端点列表:
[[1, 6],
[9, 25],
[32, 36]]
第一个盒子的右端点高度是 6。因此,第二个盒子的左端点高度必须至少是 6。如果不是,我们就返回 False,表示端点没有按照顺序排列。但在这里没问题,因为第二个盒子的左端点高度是 9。
现在我们使用第二个盒子的右端点 25 重复检查。第三个盒子的左端点是 32,所以没问题,因为 32 至少是 25。
一般来说,如果一个盒子的左端点小于前一个盒子的右端点,我们就返回 False。否则,如果所有检查都通过,我们返回 True。
以下是代码:
def all_endpoints_ok(endpoints):
"""
endpoints is a list, where each value is a list of two values:
the heights of the leftmost and rightmost action figures in a box.
❶ Requires: endpoints is sorted by action figure heights.
Return True if the endpoints came from boxes that can be
put in order, False otherwise.
"""
❷ maximum = endpoints[0][1]
for i in range(1, len(endpoints)):
if endpoints[i][0] < maximum:
return False
❸ maximum = endpoints[i][1]
return True
我在文档字符串中添加了一些信息,提醒我们在调用此函数时需要注意的事项❶。特别是,我们必须记住在调用此函数之前,端点应该是排序好的。否则,函数可能会返回错误的值。
endpoints 的每个值是一个包含两个值的列表:索引 0 是最左边(最小)的高度,索引 1 是最右边(最大)的高度。代码使用 maximum 变量来追踪盒子的最大高度。在 for 循环之前,它引用第一个盒子的最大高度❷。for 循环比较下一个盒子的最小值与最大值。如果下一个盒子的最小值太小,我们返回 False,因为这两个盒子无法正确组织。在每次迭代的最后,我们更新 maximum,使其引用下一个盒子的最大值❸。
将所有部分整合在一起
我们已经编写了所有任务的代码,包括作为设计一部分出现的函数,现在我们准备将它们整合成一个完整的解决方案。是否保留程序主部分的注释由你决定。我保留了它们,但实际上这可能会是过度文档化的做法,因为函数名称本身已经足够说明代码在做什么。完整代码请参见 清单 6-2。
def read_boxes(n):
"""
n is the number of boxes to read.
Read the boxes from the input, and return them as a
list of boxes; each box is a list of action figure heights.
"""
boxes = []
for i in range(n):
box = input().split()
box.pop(0)
for i in range(len(box)):
box[i] = int(box[i])
boxes.append(box)
return boxes
def box_ok(box):
"""
box is the list of action figure heights in a given box.
Return True if the heights in box are in nondecreasing order,
False otherwise.
"""
for i in range(len(box) - 1):
if box[i] > box[i + 1]:
return False
return True
def all_boxes_ok(boxes):
"""
boxes is a list of boxes; each box is a list of action figure heights.
Return True if each box in boxes has its action figures in
nondecreasing order of height, False otherwise.
"""
for box in boxes:
if not box_ok(box):
return False
return True
def boxes_endpoints(boxes):
"""
boxes is a list of boxes; each box is a list of action figure heights.
Return a list, where each value is a list of two values:
the heights of the leftmost and rightmost action figures in a box.
"""
endpoints = []
for box in boxes:
endpoints.append([box[0], box[-1]])
return endpoints
def all_endpoints_ok(endpoints):
"""
endpoints is a list, where each value is a list of two values:
the heights of the leftmost and rightmost action figures in a box.
Requires: endpoints is sorted by action figure heights.
Return True if the endpoints came from boxes that can be
put in order, False otherwise.
"""
maximum = endpoints[0][1]
for i in range(1, len(endpoints)):
if endpoints[i][0] < maximum:
return False
maximum = endpoints[i][1]
return True
# Main Program
# Read input
n = int(input())
boxes = read_boxes(n)
# Check whether all boxes are OK
if not all_boxes_ok(boxes):
print('NO')
else:
# Obtain a new list of boxes with only left and right heights
endpoints = boxes_endpoints(boxes)
# Sort boxes
endpoints.sort()
# Determine whether boxes are organized
if all_endpoints_ok(endpoints):
print('YES')
else:
print('NO')
清单 6-2:解决动作人物问题
这是我们目前为止在本书中编写的最大程序。但看看程序的主部分是多么简洁和最小化:它主要是函数调用,只有少量的 if-else 逻辑将它们连接起来。
我们这里只调用了每个函数一次。与我们四次调用的 no_high 卡牌游戏函数相比,即使一个函数只调用一次,它仍然能帮助代码更有组织且易于阅读。
是时候提交给 Timus 判定器了。你应该看到所有测试用例都通过了。
概念检查
在任务 2 中,我们编写了函数box_ok来判断单个盒子中的高度是否按顺序排列。它使用了一个for循环。以下是box_ok的while循环版本,它是否正确?
def box_ok(box):
"""
box is the list of action figure heights in a given box.
Return True if the heights in box are in nondecreasing order,
False otherwise.
"""
ok = True
i = 0
while i < len(box) - 1 and ok:
if box[i] > box[i + 1]:
ok = False
i = i + 1
return ok
A. 是的
B. 否;它可能导致IndexError错误
C. 否;它不会引发任何错误,但可能返回错误的值
答案:A。这与我们之前使用for循环的范围版本等价。ok变量初始为True,意味着我们已经检查过的所有高度都是合格的(因为我们还没有检查过任何高度!)。while循环会继续执行,只要
因为有更多的盒子需要检查,并且没有高度违规的情况。如果一个动作人物的顺序不正确,ok将被设置为False,这会终止循环。如果所有动作人物都按顺序排列,那么ok的值将从True保持到False。因此,当我们在函数的底部执行return ok时,如果所有动作人物都按顺序排列,我们将返回True,否则返回False。
总结
在这一章中,我们学习了函数。函数是一个自包含的代码块,解决了一个较大问题的一个小部分。我们学会了如何将信息传递给函数(通过参数)并获取返回值(通过返回值)。
为了确定首先要编写哪些函数,我们可以使用自顶向下设计方法。自顶向下设计帮助我们将一个大问题的解决方案拆解为多个较小的任务;对于每个任务,如果可以直接解决,就直接解决;如果无法直接解决,就为其编写函数。如果某个任务过于繁琐,我们可以进一步对其进行自顶向下设计。
在下一章中,我们将学习如何使用我们选择的文件,而不是使用标准输入和标准输出。随着我们不断拓展所知的边界,我们将在下一章及本书的其余部分找到很多函数的应用。通过一些以下的练习来增加你使用函数的信心。
章节练习
这里有一些练习供你尝试。对于每个练习,使用自顶向下设计方法来识别一个或多个函数,以帮助你组织代码。每个函数中都要包含文档字符串!
-
DMOJ 问题
ccc13s1,从 1987 到 2013 -
DMOJ 问题
ccc18j3,我们到了吗? -
DMOJ 问题
ecoo12r1p2,解码 DNA -
DMOJ 问题
crci07p1,Platforme -
DMOJ 问题
coci13c2p2,Misa -
回顾一下第五章中的一些练习,并通过使用函数来改进你的解法。我特别建议你回顾一下 DMOJ 问题
coci18c2p1(Preokret)和 DMOJ 问题ccc00s2(Babbling Brooks)。
备注
纸牌游戏最初来源于 1999 年加拿大计算机竞赛。动作人物最初来源于 2019 年乌拉尔学校编程竞赛。
许多现代编程语言,包括 Python,支持两种不同的编程范式。一种是基于函数的;这就是我们在本章中学习的内容。另一种是基于对象的,导致了一种被称为面向对象编程(OOP)的范式。OOP 涉及定义新类型并为这些类型编写方法。我们在全书中使用 Python 类型(如整数和字符串),但不会进一步讨论 OOP。关于 OOP 的入门以及 OOP 实践案例,我推荐由 Eric Matthes 编写的《Python Crash Course》第二版(No Starch Press,2019)。
第七章:读取和写入文件

到目前为止,我们已经使用input函数读取所有输入,并使用print函数输出所有结果。这些函数分别从标准输入(默认为键盘)读取,并将结果写入标准输出(默认为屏幕)。尽管我们可以通过输入和输出重定向改变这些默认值,但有时程序需要更好地控制文件。例如,你的文字处理器允许你打开任何文档文件并保存任何你喜欢的文件,而无需处理标准输入和标准输出。
本章我们将学习如何编写处理文本文件的程序。我们将通过文件解决两个问题:正确格式化文章和播种农场以喂养奶牛。
问题 #16:文章格式化
本问题与我们之前解决的所有问题有一个重要区别:这个问题要求我们从特定的文件中读取并写入数据!在阅读问题描述时要特别注意这一点。
这是 USACO 2020 年 1 月青铜组比赛问题《文字处理器》。这是 USACO(美国计算机奥林匹克)评委书中的第一个问题。要查看该问题,请访问usaco.org/,点击Contests,点击2020 January Contest Results,然后点击View problem下的《文字处理器》。
挑战
贝西(Bessie)奶牛正在写一篇文章。文章中的每个单词仅包含小写字母或大写字母。她的老师规定了每行的最大字符数(不包括空格)。为了满足这一要求,贝西使用以下规则写下文章中的单词:
-
如果下一个单词适合当前行,则将其添加到当前行。在每对单词之间包括一个空格。
-
否则,将该单词放到新的一行;这一行成为新的当前行。
输出文章,并确保每行包含正确的单词。
输入
从名为word.in的文件中读取输入。
输入由两行组成。
-
第一行包含两个由空格分隔的整数。第一个整数是n,文章中的单词数;它介于 1 和 100 之间。第二个整数是k,每行允许的最大字符数(不包括空格);它介于 1 和 80 之间。
-
第二行包含n个单词,每对单词之间有一个空格。每个单词最多有k个字符。
输出
将输出写入名为word.out的文件中。
输出格式化正确的文章。
与文件打交道
《文章格式化》问题要求我们从文件word.in中读取,并写入文件word.out。但是,在进行这些操作之前,我们需要学习如何在程序中打开文件。
打开文件
使用你的文本编辑器创建一个名为word.in的新文件。将该文件放在与.py Python 程序相同的目录中。
这是我们第一次创建一个不以.py结尾的文件。相反,它以.in结尾。确保将文件命名为word.in,而不是word.py。in是“输入”的缩写,你将经常看到它用于包含程序输入的文件。
在这个文件中,让我们为“论文格式化”问题放入有效的输入。将以下内容输入文件中:
12 13
perhaps better poetry will be written in the language of digital computers
保存文件。
要在 Python 中打开文件,我们使用open函数。我们传递两个参数:第一个是文件名,第二个是打开文件的模式。模式决定了我们如何与文件进行交互。
下面是我们如何打开word.in:
>>> open('word.in', 'r')
❶ <_io.TextIOWrapper name='word.in' mode='r' encoding='cp1252'>
在这个函数调用中,我们提供了一个模式'r'。r代表“读取”,并以此模式打开文件,供我们从中读取。模式是一个可选的参数,其默认值为'r',所以我们可以选择省略它。但为了保持一致性,我将在全书中明确地包括'r'。
当我们使用open时,Python 会给我们一些关于文件如何打开的信息❶。例如,它会确认文件名和模式。关于encoding的部分表示文件是如何从磁盘上的状态解码成我们可以读取的形式。文件可以使用多种编码方式进行编码,但在本书中我们不需要担心编码问题。
如果我们尝试打开一个不存在的文件进行读取,就会出现错误:
>>> open('blah.in', 'r')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'blah.in'
如果在打开word.in时遇到此错误,请仔细检查文件是否正确命名,并且位于你启动 Python 时的目录中。
除了用于读取的模式'r',还有用于写入的模式'w'。如果我们使用'w',那么我们就是在打开一个文件以便往其中写入文本。
小心使用模式'w'。如果你用'w'打开一个已经存在的文件,该文件的内容将被删除。我刚刚不小心在我的word.in文件上做了这件事。没关系,因为重新创建它很容易。但如果我们不小心覆盖了一个重要文件,没人会开心。
如果你使用'w'打开一个不存在的文件,它会创建一个空文件。
让我们使用模式'w'来创建一个名为blah.in的空文件:
>>> open('blah.in', 'w')
<_io.TextIOWrapper name='blah.in' mode='w' encoding='cp1252'>
现在blah.in已存在,我们可以打开它进行读取而不会出错:
>>> open('blah.in', 'r')
<_io.TextIOWrapper name='blah.in' mode='r' encoding='cp1252'>
那个我们一直看到的_io.TextIOWrapper是什么?那是open返回的值的类型:
>>> type(open('word.in', 'r'))
<class '_io.TextIOWrapper'>
把这个类型想象成文件类型。它的值表示打开的文件,而且你很快就会看到它有我们可以调用的方法。
和任何函数一样,如果我们不将open返回的内容赋值给一个变量,那么它的返回值就会丢失。到目前为止,我们调用open的方式并没有提供任何方式来引用我们打开的文件!
下面是我们如何让一个变量指向一个已打开的文件:
>>> input_file = open('word.in', 'r')
>>> input_file
<_io.TextIOWrapper name='word.in' mode='r' encoding='cp1252'>
我们将能够使用input_file从'word.in'中读取数据。
在解决论文格式化问题时,我们还需要一种方法来写入文件'word.out'。下面是一个有助于我们做到这一点的变量:
>>> output_file = open('word.out', 'w')
>>> output_file
<_io.TextIOWrapper name='word.out' mode='w' encoding='cp1252'>
从文件中读取
要从打开的文件中读取一行,我们使用文件的readline方法。该方法返回一个包含文件下一行内容的字符串。这样,它类似于input函数。然而,与input不同的是,readline是从文件中读取,而不是从标准输入中读取。
让我们打开word.in并读取其中的两行:
>>> input_file = open('word.in', 'r')
>>> input_file.readline()
'12 13\n'
>>> input_file.readline()
'perhaps better poetry will be written in the language of digital computers\n'
这里的意外之处在于每个字符串末尾的\n。当我们使用input读取一行时,显然没有看到这个符号。字符串中的\符号是一个转义字符。它从字符的标准解释中跳脱出来,改变它们的含义。我们并不将\n视为两个独立的字符\和n。相反,\n是一个字符:换行符。文件中的所有行(可能除了最后一行)都以换行符结尾。如果没有换行符,那么所有内容都会显示在同一行!readline方法实际上返回了整个行,包括它的结束换行符。
这是我们如何在自己的字符串中嵌入换行符:
>>> 'one\ntwo\nthree'
'one\ntwo\nthree'
>>> print('one\ntwo\nthree')
one
two
three
Python shell 不处理转义字符的效果,但print会处理。
\n序列在字符串中很有用,因为它帮助我们添加多行。但我们很少希望在从文件读取的行中出现这些换行符。为了去除它们,我们可以使用字符串的rstrip方法。这个方法类似于strip,不过它只删除字符串右侧的空白字符(而不是左侧)。在它看来,换行符就像空格一样,都是空白字符:
>>> 'hello\nthere\n\n'
'hello\nthere\n\n'
>>> 'hello\nthere\n\n'.rstrip()
'hello\nthere'
让我们再试着从文件中读取,这次去掉换行符:
>>> input_file = open('word.in', 'r')
>>> input_file.readline().rstrip()
'12 13'
>>> input_file.readline().rstrip()
'perhaps better poetry will be written in the language of digital computers'
到此为止,我们已经读取了两行,所以文件中没有剩余的内容可读。readline方法通过返回一个空字符串来表示这一点。
>>> input_file.readline().rstrip()
''
空字符串意味着我们已经到达了文件末尾。如果我们想再次读取这些行,我们必须重新打开文件,从头开始。
让我们这么做,这次使用变量保存每一行:
>>> input_file = open('word.in', 'r')
>>> first = input_file.readline().rstrip()
>>> second = input_file.readline().rstrip()
>>> first
'12 13'
>>> second
'perhaps better poetry will be written in the language of digital computers'
如果我们需要读取文件中的所有行,不论有多少行,我们可以使用for循环。Python 中的文件就像行的序列,因此我们可以像遍历字符串和列表一样遍历文件:
>>> input_file = open('word.in', 'r')
>>> for line in input_file:
... print(line.rstrip())
...
12 13
perhaps better poetry will be written in the language of digital computers
然而,与字符串或循环不同的是,我们不能第二次循环读取文件,因为第一次循环已经把文件读取到了末尾。如果我们尝试这样做,就什么也得不到:
>>> for line in input_file:
... print(line.rstrip())
...
概念检查
我们想要使用while循环输出打开的文件input_file中的每一行。(该文件可以是任何文件;我并不假设它与论文格式化有关。)下面哪段代码正确地完成了这项任务?
A.
while input_file.readline() != '':
print(input_file.readline().rstrip())
B.
line = 'x'
while line != '':
line = input_file.readline()
print(line.rstrip())
C.
line = input_file.readline()
while line != '':
line = input_file.readline()
print(line.rstrip())
D. 以上所有
E. 以上都不是
在查看答案之前,我建议你创建一个包含四五行文本的文件,并在文件上尝试每一段代码。你还可以考虑在每一行输出的开头添加一个字符,比如*,这样你就能看到任何原本为空的行。
答案:E。每一段代码都有一个微妙的错误。
代码 A 只输出文件中的每隔一行。例如,while循环的布尔表达式导致第一行被读取……并丢失,因为它没有被赋值给变量。因此,循环的第一次迭代输出的是文件的第二行。
代码 B 非常接近正确的做法。它输出了文件的所有行,但在末尾多输出了一个空白行。
代码 C 未能打印文件的第一行。这是因为第一行在循环前就已被读取,但之后循环读取了第二行,且没有打印第一行。它同样在末尾产生了多余的空白行,就像代码 B 一样。
这是正确的代码,用来读取并打印每一行:
line = input_file.readline()
while line != '':
print(line.rstrip())
line = input_file.readline()
写入文件
要向一个打开的文件写入一行,我们使用文件的write方法。我们传递给它一个字符串,这个字符串会被添加到文件的末尾。
为了解决作文格式化问题,我们将写入word.out。但我们还没有准备好解决这个问题,所以我们暂时写入blah.out。以下是我们如何向该文件写入一行:
>>> output_file = open('blah.out', 'w')
>>> output_file.write('hello')
5
那个5在那里干什么?write方法返回的是写入的字符数。这是一个很好的确认,表示我们已经写入了预期的文本数量。
如果你在文本编辑器中打开blah.out,你应该看到文件中有hello这个文本。
让我们尝试向文件写入三行。开始吧:
>>> output_file = open('blah.out', 'w')
>>> output_file.write('sq')
2
>>> output_file.write('ui')
2
>>> output_file.write('sh')
2
根据我到目前为止告诉你的内容,你可能会期望blah.out看起来像这样:
sq
ui
sh
但是,如果你在文本编辑器中打开blah.out,你应该看到如下内容:
squish
字符之所以显示在同一行,是因为write方法不会为我们自动添加换行符!如果我们想要单独的行,我们需要明确指定,就像这样:
>>> output_file = open('blah.out', 'w')
>>> output_file.write('sq\n')
3
>>> output_file.write('ui\n')
3
>>> output_file.write('sh\n')
3
注意,在每种情况下,write方法写入的是三个字符,而不是两个。换行符也算作一个字符。现在,如果你在文本编辑器中打开blah.out,你应该看到文本分布在三行中:
sq
ui
sh
与print不同,write只有在你用字符串调用它时才有效。要向文件写入数字,首先需要将其转换为字符串:
>>> num = 7788
>>> output_file = open('blah.out', 'w')
>>> output_file.write(str(num) + '\n')
5
关闭文件
在完成文件操作后关闭文件是一个好习惯。这向阅读你代码的人表明,文件不再被使用。
关闭文件还可以帮助操作系统管理计算机的资源。当你使用write方法时,你写入的内容可能不会立即写入文件。相反,Python 或者操作系统可能会等到有多个write请求时,再将它们一次性写入。关闭你写入的文件可以确保你写入的内容已安全地存储在文件中。
要关闭文件,调用其close方法。以下是打开文件、读取一行并关闭文件的示例:
>>> input_file = open('word.in', 'r')
>>> input_file.readline()
'12 13\n'
>>> input_file.close()
一旦你关闭了文件,就不能再从该文件读取或写入:
>>> input_file.readline()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: I/O operation on closed file.
解决问题
回到文章格式化问题。现在我们知道如何从word.in读取并写入到word.out。这样就完成了输入输出要求。接下来是解决问题本身。
让我们首先通过探索一个测试用例来确保我们知道如何解决这个问题。然后我们会看到代码。
探索一个测试用例
这是我一直在使用的word.in文件:
12 13
perhaps better poetry will be written in the language of digital computers
共有 12 个单词,每行最大字符数(不包括空格)是 13 个。只要单词能放下,我们就将它添加到当前行;一旦单词放不下,我们就用这个单词开始新的一行。
单词perhaps包含七个字符,因此可以放在第一行。单词better包含六个字符。我们也可以把它放在第一行;由于perhaps已经在那,两个单词加起来总共有 13 个字符(不包括单词间的空格)。
单词poetry不能放在第一行,所以我们以poetry作为第一单词开始新的一行。接着,单词will适合放在第二行与poetry并排。类似地,be可以放在will后面。到目前为止,我们已经有了 12 个非空格字符。现在我们有了单词written,由于第二行只剩下一个字符的空间,我们只能在新的一行开始时把written作为第一个单词。
按照这个过程继续直到结束,我们需要写入word.out的完整文章是:
perhaps better
poetry will be
written in the
language of
digital
computers
代码
我们的解决方案在清单 7-1 中。
❶ input_file = open('word.in', 'r')
❷ output_file = open('word.out', 'w')
❸ lst = input_file.readline().split()
n = int(lst[0]) # n not needed
k = int(lst[1])
words = input_file.readline().split()
❹ line = ''
chars_on_line = 0
for word in words:
❺ if chars_on_line + len(word) <= k:
line = line + word + ' '
chars_on_line = chars_on_line + len(word)
else:
❻ output_file.write(line[:-1] + '\n')
line = word + ' '
chars_on_line = len(word)
❼ output_file.write(line[:-1] + '\n')
input_file.close()
output_file.close()
清单 7-1: 解决文章格式化问题
首先,我们打开输入文件❶和输出文件❷。注意文件模式:我们用模式'r'(读取模式)打开输入文件,用模式'w'(写入模式)打开输出文件。我们本可以稍后再打开输出文件,在需要使用它之前,但为了简化程序结构,我选择在这里同时打开两个文件。同样,我们本可以在不再需要文件时立刻关闭它,但在本书中,我选择在程序的最后一起关闭所有文件。对于操作多个文件的长时间运行程序,你可能希望只在需要时才保持文件打开。
接下来,我们读取输入文件的第一行❸。这一行包含两个用空格分隔的整数:n,单词的数量,以及k,每行允许的最大字符数(不包括空格)。像处理空格分隔的值一样,我们使用split来分割它们。然后我们读取第二行,它包含文章的单词。我们同样使用split,这次将单词字符串分割成一个单词列表。这样就处理好了输入。
两个变量驱动程序的主要部分:line和chars_on_line。line变量表示当前行;我们一开始将它设置为空字符串❹。chars_on_line变量表示当前行上字符的数量,不包括空格。
你可能会想,为什么我要维持chars_on_line变量呢?我们不能直接使用len(line)吗?如果我们这么做,会把空格也算入总数,而空格不算在每行允许的字符数内。如果你觉得用len(line)会更直观,我鼓励你自己尝试一下,通过减去空格的数量来解决这个问题。
现在是时候遍历所有单词了。对于每个单词,我们需要确定它是放在当前行还是下一行。
如果当前行上的非空字符数加上当前单词的字符数不超过k,则当前单词适合当前行❺。在这种情况下,我们将单词和一个空格添加到当前行,并更新当前行上的非空字符数。
否则,当前单词不适合当前行。当前行已结束!因此,我们将当前行写入输出文件❻,并更新line和chars_on_line变量,以反映当前行只有这个单词。
关于write调用❻有两点需要注意。首先,[:-1]切片的存在是为了防止我们输出行末单词后的空格。其次,你可能会期待我在这里使用 f-string,像这样:
output_file.write(f'{line[:-1]}\n')
然而,在写作时,USACO 评测系统正在运行一个较旧版本的 Python,该版本不支持 f-strings。
为什么我们在循环结束后❼要输出line?原因是每次迭代的for循环都会确保line中包含一个或多个我们尚未输出的单词。考虑一下我们处理每个单词时发生的情况。如果当前单词适合当前行,我们就不输出任何内容。如果当前单词不适合当前行,我们就输出当前行,但不输出下一行的单词。因此,我们需要在循环结束后❼将line写入输出文件;否则,文章的最后一行将会丢失。
最后,我们做的事情是关闭两个文件。
写入文件而不是屏幕的一个令人烦恼的方面是,当我们运行程序时,看不到输出。为了查看输出,我们必须在文本编辑器中打开输出文件。
这里有一个小技巧:在开发程序时,使用print调用而不是write调用,这样所有的输出都将显示在屏幕上。这样可以更容易地找到程序中的错误,并避免在代码和输出文件之间来回切换。一旦你对代码满意了,可以将print调用改回write调用。然后一定要再做一些测试,以确保所有内容都按照预期写入了文件。
我们准备好提交给 USACO 评测了。把我们的代码发过去吧!所有测试用例都应该通过。
问题 #17:农场播种
我们可以使用循环从文件中读取指定数量的行。我们将在这个问题中做到这一点,并且会发现它与使用input从标准输入读取数据非常相似。
在第六章中,当我们解决《动作人物》问题时,我们学习了使用函数的自上而下设计。这是一个重要的技能,能够通过组合多个函数来解决一个问题。由于关于文件没有更多要说的内容,我选择了一个既能作为解决问题的场景,又能作为自上而下设计的练习问题。
这是一个具有挑战性的问题。我们首先需要准确理解我们被要求做的事情。之后,我们需要制定解决问题的方法,并仔细思考为什么我们的解决方案是正确的。
这是 USACO 2019 年 2 月青铜级竞赛问题——《大规模重新植草》。
挑战
农夫约翰有n个牧场,他想要为这些牧场播种草。牧场编号为 1、2、...、n。
农夫约翰有四种不同的草种子,编号为 1、2、3 和 4。他将为每个牧场选择其中一种草种类型。
农夫约翰还有m只牛。每只牛有两个最喜欢的牧场,它在这些牧场中吃草。每只牛只关心它的两个最喜欢的牧场,其他牧场无关紧要。为了保持健康饮食,每只牛要求它的两个牧场有不同的草种类型。例如,对于某只给定的牛,如果一个牧场是草种类型 1,另一个是草种类型 4,那么是可以接受的。但如果两个牧场都是草种类型 1,那就不行。
一个牧场可能是多个牛的最喜欢牧场,但保证每个牧场最多只会是三只牛的最喜欢牧场。
确定每个牧场使用的草种类型。每个牧场必须使用 1 到 4 之间的草种类型,并且每只牛的两个最喜欢的牧场必须有不同的草种类型。
输入
从名为revegetate.in的文件中读取输入。
输入包括以下几行:
-
一行包含两个由空格分隔的整数。第一个整数是n,即牧场的数量;它介于 2 和 100 之间。第二个整数是m,即牛的数量;它介于 1 和 150 之间。
-
m行,每行给出一只牛的两个最喜欢的牧场编号。这些牧场编号是 1 到n之间的整数,且用空格分隔。
输出
将输出写入名为revegetate.out的文件。
输出一种有效的牧场播种方式。输出是一行n个字符,每个字符为'1'、'2'、'3'或'4'。第一个字符是牧场 1 的草种类型,第二个字符是牧场 2 的草种类型,依此类推。
我们可以将这n个字符解释为一个整数,包含n个数字。例如,如果我们有五个草种类型'11123',那么我们可以将其解释为整数11123。
当我们有多种输出选择时,这种整数解释法就派上用场了。如果有多种有效的播种方法,我们必须输出解释为整数时最小的那一种。例如,如果 '11123' 和 '22123' 都是有效的,我们输出字符串 '11123',因为 11123 小于 22123。
探索测试用例
我们将使用自上而下的设计方法来找到这个问题的解决方案。通过处理一个测试用例,我们可以帮助自己梳理任务。
下面是测试用例:
8 6
5 4
2 4
3 5
4 1
2 1
5 2
测试用例的第一行告诉我们有八个牧场,它们从 1 到 8 编号。第一行还告诉我们有六头牛。问题并没有指定牛的编号,所以我从 0 开始给它们编号。每头牛最喜欢的两个牧场在表 7-1 中可以方便查看。
表 7-1: 农场播种示例,牛
| 牛 | 牧场 1 | 牧场 2 |
|---|---|---|
| 0 | 5 | 4 |
| 1 | 2 | 4 |
| 2 | 3 | 5 |
| 3 | 4 | 1 |
| 4 | 2 | 1 |
| 5 | 5 | 2 |
在这个问题中,我们需要做出 n 个决策。我们应该为牧场 1 选择什么草种?牧场 2 呢?牧场 3 呢?牧场 4 呢?依此类推,一直到牧场 n。解决这类问题的一种策略是一次做一个决策,不犯任何错误。如果我们能够完成第 n 个决策,并且途中没有犯错,那么我们的解决方案必定是正确的。
让我们从牧场 1 到牧场 8 依次查看,看看能否为每个牧场分配一个草种。我们需要优先选择编号较小的草种,以便最终得到解释为数字时最小的草种。
我们应该为牧场 1 选择什么草种呢?唯一关心牧场 1 的牛是牛 3 和牛 4,所以我们只需要关注这两头牛。如果我们已经为某些牛的牧场选择了草种,那么我们就需要小心选择牧场 1 的草种。我们不希望给同一头牛安排两个草种相同的牧场,因为那样会违反规则!由于我们还没有选择任何草种,所以不管选择什么草种,都不会出错。不过,由于我们希望选择最小的草种,因此我们会选择草种 1。
我将把我们的草种决策记录在表格中。以下是我们刚刚做出的决策,为牧场 1 选择草种 1:
| 牧场 | 草种 |
|---|---|
| 1 | 1 |
让我们继续前进。我们应该为牧场 2 选择什么草种呢?关心牧场 2 的牛是牛 1、4 和 5,所以我们集中在这些牛上。牛 4 的一个牧场是牧场 1,我们为那个牧场选择了草种 1,所以草种 1 被排除为牧场 2 的草种。如果我们为牧场 2 选择草种 1,那么我们就会给牛 4 两个牧场分配相同的草种,这违反了规则。然而,牛 1 和牛 5 并不会排除其他草种,因为我们还没有为它们的牧场选择草种。因此,我们选择草种 2,最小的可用草种。现在的情况如下:
| 牧场 | 草种 |
|---|---|
| 1 | 1 |
| 2 | 2 |
我们应该为牧场 3 选择什么草种呢?关心牧场 3 的唯一牛是牛 2。牛 2 的牧场是牧场 3 和 5。然而,这头牛不会排除任何草种,因为我们还没有为牧场 5 分配草种!为了选择最小的数字,我们将为牧场 3 选择草种 1。现在我们的进展如下:
| 牧场 | 草种 |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 1 |
我能看到我们从自顶向下设计中逐步清晰化的三项任务。首先,我们需要获取关心当前牧场的牛。其次,我们需要确定这些牛排除的草种。第三,我们需要选择未被排除的最小编号草种。每一项任务都是函数的理想候选。
继续前进。我们有三头牛关心牧场 4:牛 0、1 和 3。牛 0 不会排除任何草种,因为我们还没有为它的牧场分配草种。牛 1 排除了草种 2,因为我们将草种 2 分配给了牧场 2(它的另一个牧场)。牛 3 排除了草种 1,因为我们将草种 1 分配给了牧场 1(它的另一个牧场)。因此,最小的可用草种是 3,所以我们为牧场 4 选择了这个草种:
| 牧场 | 草种 |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 1 |
| 4 | 3 |
接下来是牧场 5。关心牧场 5 的牛是牛 0、2 和 5。牛 0 排除了草种 3;牛 2 排除了草种 1;牛 5 排除了草种 2。所以草种 1、2 和 3 都被排除了。我们唯一的选择是草种 4。
那真是险些出事!我们差点就用完草种了。幸好,没有其他牛关心牧场 5 并排除了草种 4。
等等。其实这并不是运气,因为问题描述中有这么一句:“保证每个牧场最多只会是三头牛的最爱。”这意味着每个牧场最多只能排除三种草种。我们永远不会陷入困境!我们甚至不需要担心过去的选择对下一次决策的影响。无论我们之前做了什么,至少总会有一种可用的草种。
让我们把牧场 5 添加到表格中:
| 牧场 | 草种 |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 1 |
| 4 | 3 |
| 5 | 4 |
有三个草场要处理。但没有牛关心它们,所以我们可以在每种情况下使用草种类型 1。这样我们得到的是:
| 草场 | 草种类型 |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 1 |
| 4 | 3 |
| 5 | 4 |
| 6 | 1 |
| 7 | 1 |
| 8 | 1 |
我们可以从上到下读取草种类型,得到这个示例的正确输出。输出如下:
12134111
自上而下设计
通过充分理解我们需要完成的任务,我们将转向问题的自上而下设计。
顶层设计
我们在上一节通过测试用例发现了三个任务。在我们的程序能够解决任何这些任务之前,我们需要先读取输入,因此这是第四个任务。我们还需要写输出。这将需要一些思考和几行代码,所以我们将其定为第五个任务。
这是我们的五个主要任务:
-
读取输入。
-
确定关心当前草场的牛。
-
去除当前草场的草种类型。
-
选择当前草场的最小编号草种类型。
-
写输出。
就像我们在解决《行动人物》时在 第六章 所做的那样,我们将从一个包含 TODO 注释的框架开始,并在解决每个问题时去掉对应的 TODO。
我们开始时主要是注释。由于我们在开始时需要打开文件,在结束时需要关闭文件,我还添加了相关代码。
这里是我们开始的地方:
# Main Program
input_file = open('revegetate.in', 'r')
output_file = open('revegetate.out', 'w')
# TODO: Read input
# TODO: Identify cows that care about pasture
# TODO: Eliminate grass types for pasture
# TODO: Choose smallest-numbered grass type for pasture
# TODO: Write output
input_file.close()
output_file.close()
任务 1:读取输入
读取输入的第一行,其中包含整数 n 和 m,是我们已经知道如何做的事情。它足够直接,我认为我们不需要为此编写一个函数,所以我们直接处理它。接下来,我们需要读取 m 头牛的草场信息,在这里一个函数似乎是必要的。让我们去掉 TODO 注释中的内容,处理第一行输入,并调用 read_cows 函数,我们稍后会编写这个函数:
# Main Program
input_file = open('revegetate.in', 'r')
output_file = open('revegetate.out', 'w')
# Read input
lst = input_file.readline().split()
num_pastures = int(lst[0])
num_cows = int(lst[1])
❶ favorites = read_cows(input_file, num_cows)
# TODO: Identify cows that care about pasture
# TODO: Eliminate grass types for pasture
# TODO: Choose smallest-numbered grass type for pasture
# TODO: Write output
input_file.close()
output_file.close()
我们正在调用的 read_cows 函数 ❶ 将接受一个已经打开的文件并读取每头牛的两个最爱草场。它将返回一个列表的列表,每个内部列表包含一头牛的两个草场编号。以下是代码:
def read_cows(input_file, num_cows):
"""
input_file is a file open for reading; cow information is next to read.
num_cows is the number of cows in the file.
Read the cows' favorite pastures from input_file.
Return a list of each cow's two favorite pastures;
each value in the list is a list of two values giving the
favorite pastures for one cow.
"""
favorites = []
for i in range(num_cows):
❶ lst = input_file.readline().split()
lst[0] = int(lst[0])
lst[1] = int(lst[1])
❷ favorites.append(lst)
return favorites
这个函数将牛的最爱草场收集到 favorites 列表中。它使用一个范围 for 循环来循环 num_cows 次,每次处理一头牛。我们需要这个循环,因为要读取的行数取决于文件中牛的数量。
在每次循环中,我们读取下一行并将其拆分成两个部分 ❶。然后,我们使用 int 将这些部分从字符串转换为整数。当我们将这个列表添加到 favorites ❷ 时,我们实际上是将一个包含两个整数的列表添加进去了。
我们做的最后一件事是返回最爱的草场列表。
在继续之前,让我们确保我们知道如何调用这个函数。我们将单独练习调用它,而不依赖于我们正在构建的更大程序。测试这样的函数非常有用,因为我们可以在过程中修复任何发现的错误。
使用你的文本编辑器创建一个名为revegetate.in的文件,内容如下(与我们之前学习的测试用例相同):
8 6
5 4
2 4
3 5
4 1
2 1
5 2
现在,在 Python shell 中输入我们read_cows函数的代码。
这是我们调用read_cows的方法:
>>> input_file = open('revegetate.in', 'r')
❶ >>> input_file.readline()
'8 6\n'
❷ >>> read_cows(input_file, 6)
[[5, 4], [2, 4], [3, 5], [4, 1], [2, 1], [5, 2]]
read_cows函数只读取奶牛的信息。由于我们在程序外单独测试这个函数,因此我们需要在调用它之前自己读取文件的第一行❶。当我们调用read_cows时,它会返回一个列表,给出每只奶牛的最爱牧场。还要注意,我们在调用read_cows时传入的是打开的文件,而不是文件名❷。
请确保在# Main Program注释之前包含我们的read_cows函数,以及我们为其他任务编写的函数。然后我们可以继续进行任务 2。
任务 2:识别奶牛
我们解决这个问题的总体策略是逐个考虑每个牧场,决定使用哪种草类型。我们将在一个循环中组织这项工作,每次循环的迭代负责播种一个牧场。对于每个牧场,我们需要识别关心该牧场的奶牛,排除已使用的草类型,并选择编号最小的可用草类型。这三项任务必须对每个牧场执行,因此我们将它们缩进到循环内部。
我们将编写一个名为cows_with_favorite的函数,用于告诉我们哪些奶牛关心当前的牧场。
这是我们当前的主程序:
# Main Program
input_file = open('revegetate.in', 'r')
output_file = open('revegetate.out', 'w')
# Read input
lst = input_file.readline().split()
num_pastures = int(lst[0])
num_cows = int(lst[1])
favorites = read_cows(input_file, num_cows)
for i in range(1, num_pastures + 1):
# Identify cows that care about pasture
❶ cows = cows_with_favorite(favorites, i)
# TODO: Eliminate grass types for pasture
# TODO: Choose smallest-numbered grass type for pasture
# TODO: Write output
input_file.close()
output_file.close()
我们正在调用的cows_with_favorite函数❶接收一个奶牛最喜欢的牧场列表和一个牧场编号,并返回关心该牧场的奶牛。以下是代码:
def cows_with_favorite(favorites, pasture):
"""
favorites is a list of favorite pastures, as returned by read_cows.
pasture is a pasture number.
Return list of cows that care about pasture.
"""
cows = []
for i in range(len(favorites)):
if favorites[i][0] == pasture or favorites[i][1] == pasture:
cows.append(i)
return cows
该函数遍历favorites,查找关心牧场编号pasture的奶牛。每一只关心该牧场的奶牛都会被添加到cows列表中,最后该列表会被返回。
让我们做一个小测试。在 Python shell 中输入我们的cows_with_favorite函数。这里是我们将尝试的调用:
>>> cows_with_favorite([[5, 4], [2, 4], [3, 5]], 5)
这里有三只奶牛,我们要找出哪些奶牛关心牧场5。位于索引0和2的奶牛关心牧场5,这正是该函数告诉我们的内容:
[0, 2]
任务 3:排除草类型
现在我们知道了哪些奶牛关心当前的牧场。我们的下一步是弄清楚这些奶牛会排除哪些草类型,无法用于当前牧场。我们将排除与这些奶牛相关的牧场中已经使用的草类型。我们将编写一个名为types_used的函数,告诉我们哪些草类型已经被使用(因此不能用于当前牧场)。
这是我们更新后的主程序,包含了对这个函数的调用:
# Main Program
input_file = open('revegetate.in', 'r')
output_file = open('revegetate.out', 'w')
# Read input
lst = input_file.readline().split()
num_pastures = int(lst[0])
num_cows = int(lst[1])
favorites = read_cows(input_file, num_cows)
❶ pasture_types = [0]
for i in range(1, num_pastures + 1):
# Identify cows that care about pasture
cows = cows_with_favorite(favorites, i)
# Eliminate grass types for pasture
❷ eliminated = types_used(favorites, cows, pasture_types)
# TODO: Choose smallest-numbered grass type for pasture
# TODO: Write output
input_file.close()
output_file.close()
除了调用types_used函数❷,我还添加了一个名为pasture_types的变量❶。该变量所引用的列表将跟踪每个牧场的草类型。
请记住,牧场的编号是从 1 开始的,而 Python 的列表是从 0 开始索引的。我不喜欢这种差异;如果我们只是简单地开始将草种添加到pasture_types中,那么牧场 1 的草种会在索引 0 的位置,牧场 2 的草种会在索引 1 的位置,以此类推,始终相差一个位置。这就是为什么我在列表开头添加了一个虚假的0 ❶;当我们后续为牧场 1 添加草种时,它会被放置在索引 1 的位置,以便与之匹配。
假设我们已经弄清楚了前四个牧场的草种。这时pasture_types可能看起来是这样的:
[0, 1, 2, 1, 3]
如果我们想要牧场 1 的草种,我们查看索引 1;如果我们想要牧场 2 的草种,我们查看索引 2;以此类推。如果我们想要牧场 5 的草种?嗯,不行,因为我们还没有弄清楚。如果pasture_types的长度是5,这意味着我们只弄清楚了前四个牧场的草种。一般来说,我们弄清楚的草种数量比列表的长度少一个。
现在我们准备使用types_used函数了。它接受三个参数:每头牛最喜欢的牧场列表、关心当前牧场的牛、以及目前已选定的牧场草种。它返回当前牧场已经使用并因此被排除的草种列表。接下来是:
def types_used(favorites, cows, pasture_types):
"""
favorites is a list of favorite pastures, as returned by read_cows.
cows is a list of cows.
pasture_types is a list of grass types.
Return a list of the grass types already used by cows.
"""
used = []
for cow in cows:
pasture_a = favorites[cow][0]
pasture_b = favorites[cow][1]
❶ if pasture_a < len(pasture_types):
used.append(pasture_types[pasture_a])
❷ if pasture_b < len(pasture_types):
used.append(pasture_types[pasture_b])
return used
每头牛有两个最喜欢的牧场,我称之为pasture_a和pasture_b。对于这些牧场,我们检查在❶和❷的位置,是否已经为其选择了草种。如果该牧场已经是pasture_types中的一个索引,那么草种就已经被选择。这些草种都被添加到used列表中,函数将在遍历所有相关牛后返回该列表。
如果多头牛使用同一片牧场——我们的代码会怎么处理呢?让我们提出一个简单的测试用例来回答这个问题。
在 Python shell 中输入我们的types_used函数。以下是对该函数的调用;我们来预测它的返回值:
>>> types_used([[5, 4], [2, 4], [3, 5]], [0, 1], [0, 1, 2, 1, 3])
我们要小心,以免迷失。第一个参数给出了三头牛的最喜欢的牧场。第二个参数给出了关心某个特定牧场的牛,这些是牛0和牛1。第三个参数给出了我们目前为止决定的草种。
那么,牛0和牛1已经使用并因此被排除的草种有哪些呢?牛0关心牧场4,而牧场4使用草种3,所以草种3被排除。牛1关心牧场2,而牧场2使用草种2,因此草种2被排除。牛1还关心牧场4——但我们已经从牛0那里知道,牧场4的草种3已经被排除。
我们函数的返回值是这样的:
[3, 2, 3]
有两个3,一个来自牛0,另一个来自牛1。
或许只有一个3看起来更整洁,但现在这种带重复值的方式其实也没问题。如果某个草类型出现在列表中,那它就被排除,无论它出现了一次、两次,还是三次。
任务 4:选择最小编号的草类型
在得到已经被排除的草类型后,我们可以进行下一个任务:选择当前牧场的最小编号可用草类型。为了解决这个问题,我们将调用一个新的函数smallest_available。它将返回我们应该为当前牧场使用的草类型。
以下是主程序,更新后调用了smallest_available函数:
# Main Program
input_file = open('revegetate.in', 'r')
output_file = open('revegetate.out', 'w')
# Read input
lst = input_file.readline().split()
num_pastures = int(lst[0])
num_cows = int(lst[1])
favorites = read_cows(input_file, num_cows)
pasture_types = [0]
for i in range(1, num_pastures + 1):
# Identify cows that care about pasture
cows = cows_with_favorite(favorites, i)
# Eliminate grass types for pasture
eliminated = types_used(favorites, cows, pasture_types)
# Choose smallest-numbered grass type for pasture
❶ pasture_type = smallest_available(eliminated)
❷ pasture_types.append(pasture_type)
# TODO: Write output
input_file.close()
output_file.close()
一旦我们获得了当前牧场的最小编号草类型❶,我们将其添加到已选择的草类型列表中❷。
这是smallest_available函数本身:
def smallest_available(used):
"""
used is a list of used grass types.
Return the smallest-numbered grass type that is not in used.
"""
grass_type = 1
while grass_type in used:
grass_type = grass_type + 1
return grass_type
该函数从草类型1开始。然后它循环,直到找到一个未被使用的草类型,每次循环时将草类型加 1。一旦找到一个空闲的草类型,函数就返回它。请记住,最多只有四种草类型,其中已经使用了最多三种,因此该函数一定能成功。
任务 5:写入输出
我们已经得到了答案,就在pasture_types中!现在我们只需输出它。以下是最终的主程序:
# Main Program
input_file = open('revegetate.in', 'r')
output_file = open('revegetate.out', 'w')
# Read input
lst = input_file.readline().split()
num_pastures = int(lst[0])
num_cows = int(lst[1])
favorites = read_cows(input_file, num_cows)
pasture_types = [0]
for i in range(1, num_pastures + 1):
# Identify cows that care about pasture
cows = cows_with_favorite(favorites, i)
# Eliminate grass types for pasture
eliminated = types_used(favorites, cows, pasture_types)
# Choose smallest-numbered grass type for pasture
pasture_type = smallest_available(eliminated)
pasture_types.append(pasture_type)
# Write output
❶ pasture_types.pop(0)
❷ write_pastures(output_file, pasture_types)
input_file.close()
output_file.close()
在写入输出之前,我们首先去除pasture_types开头的无效0❶。我们不想输出那个0,因为它并不是真正的草类型。然后,我们调用write_pastures函数,实际上写入输出❷。
现在我们所需要的就是write_pastures函数。它接受一个打开用于写入的文件和一个草类型列表,并将草类型输出到文件中。以下是代码:
def write_pastures(output_file, pasture_types):
"""
output_file is a file open for writing.
pasture_types is a list of integer grass types.
Output pasture_types to output_file.
"""
pasture_types_str = []
❶ for pasture_type in pasture_types:
pasture_types_str.append(str(pasture_type))
❷ output = ''.join(pasture_types_str)
❸ output_file.write(output + '\n')
现在,pasture_types是一个整数列表。正如我们稍后会看到的,在这里使用字符串列表会更加方便,因此我创建了一个新的列表,将每个整数转换为字符串❶。我没有修改pasture_types列表本身,因为那样会影响到调用此函数的代码。调用者调用此函数时,只期望输出结果写入output_file,并不希望pasture_types列表被修改。函数没有理由修改它的列表参数。
为了输出结果,我们需要使用write函数传入一个字符串,而不是一个列表。而且我们需要将列表中的字符串输出,且它们之间不能有空格。join方法在这里非常有效。正如我们在第五章的“将列表连接成字符串”部分所学,调用join的字符串作为分隔符,插入到列表中的值之间。由于我们不想在值之间加任何分隔符,因此我们使用空字符串作为分隔符❷。join方法只能作用于字符串列表,而不能作用于整数列表,这就是为什么我在这个函数开始时将整数列表转换为字符串列表❶。
现在,输出已经是一个单一的字符串,我们可以将其写入文件❸。
将所有内容整合起来
完整的程序在清单 7-2 中。
def read_cows(input_file, num_cows):
"""
input_file is a file open for reading; cow information is next to read.
num_cows is the number of cows in the file.
Read the cows' favorite pastures from input_file.
Return a list of each cow's two favorite pastures;
each value in the list is a list of two values giving the
favorite pastures for one cow.
"""
favorites = []
for i in range(num_cows):
lst = input_file.readline().split()
lst[0] = int(lst[0])
lst[1] = int(lst[1])
favorites.append(lst)
return favorites
def cows_with_favorite(favorites, pasture):
"""
favorites is a list of favorite pastures, as returned by read_cows.
pasture is a pasture number.
Return list of cows that care about pasture.
"""
cows = []
for i in range(len(favorites)):
if favorites[i][0] == pasture or favorites[i][1] == pasture:
cows.append(i)
return cows
def types_used(favorites, cows, pasture_types):
"""
favorites is a list of favorite pastures, as returned by read_cows.
cows is a list of cows.
pasture_types is a list of grass types.
Return a list of the grass types already used by cows.
"""
used = []
for cow in cows:
pasture_a = favorites[cow][0]
pasture_b = favorites[cow][1]
if pasture_a < len(pasture_types):
used.append(pasture_types[pasture_a])
if pasture_b < len(pasture_types):
used.append(pasture_types[pasture_b])
return used
def smallest_available(used):
"""
used is a list of used grass types.
Return the smallest-numbered grass type that is not in used.
"""
grass_type = 1
while grass_type in used:
grass_type = grass_type + 1
return grass_type
def write_pastures(output_file, pasture_types):
"""
output_file is a file open for writing.
pasture_types is a list of integer grass types.
Output pasture_types to output_file.
"""
pasture_types_str = []
for pasture_type in pasture_types:
pasture_types_str.append(str(pasture_type))
output = ''.join(pasture_types_str)
output_file.write(output + '\n')
# Main Program
input_file = open('revegetate.in', 'r')
output_file = open('revegetate.out', 'w')
# Read input
lst = input_file.readline().split()
num_pastures = int(lst[0])
num_cows = int(lst[1])
favorites = read_cows(input_file, num_cows)
pasture_types = [0]
for i in range(1, num_pastures + 1):
# Identify cows that care about pasture
cows = cows_with_favorite(favorites, i)
# Eliminate grass types for pasture
eliminated = types_used(favorites, cows, pasture_types)
# Choose smallest-numbered grass type for pasture
pasture_type = smallest_available(eliminated)
pasture_types.append(pasture_type)
# Write output
pasture_types.pop(0)
write_pastures(output_file, pasture_types)
input_file.close()
output_file.close()
清单 7-2:解决农场播种问题
我们做到了!一个让人望而生畏的问题,通过自顶向下的设计变得更容易处理。可以放心地将我们的工作提交给 USACO 评审。
初次阅读一个问题时,可能会感到不知所措。但记住,你不需要一次性完成所有的步骤。把问题分解开,解决你能解决的每个小任务,你就能为解决整体问题迈出重要的一步。你在 Python 知识和程序设计与问题解决能力方面已经取得了巨大的进展。解决这些问题触手可及!
概念检查
让我们考虑一个新的农场播种版本,在这个版本中,牧场不再有限制,允许多只奶牛关心一个牧场。一个牧场可能是四只奶牛、五只奶牛甚至更多的最爱。我们仍然不能给同一只奶牛分配两块草类型相同的牧场。
假设我们正在解决这个新版本的问题,并且有一个测试用例,其中一个牧场是超过三只奶牛最喜欢的。在这个测试用例中,以下哪项是正确的?
A。可以保证仅使用四种草类型是无法解决这个问题的。
B。可能有一种解决方法。如果有的话,可能我们的原始解决方案(清单 7-2)会做到这一点。
C。可能有一种解决方法。如果有的话,可以保证我们的原始解决方案(清单 7-2)会做到这一点。
D。可能有一种解决方法。如果有的话,可以保证我们的原始解决方案(清单 7-2)无法做到这一点。
答案:B。我们可以找到一个能被我们的程序正确解决的测试用例,也可以找到一个能被解决但无法通过我们程序解决的测试用例。前者排除了 A 和 D 为正确答案;后者排除了 C 为正确答案。
这是一个能被我们程序正确解决的测试用例:
2 4
1 2
1 2
1 2
1 2
每个牧场都有四只奶牛最喜欢。尽管如此,我们可以仅使用两种草的类型来解决这个测试用例。试试我们的程序,你应该会看到它能够正确解决这个测试用例。
现在,这里有一个能解决的问题,但不是通过我们的程序:
6 10
2 3
2 4
3 4
2 5
3 5
4 5
1 6
3 6
4 6
5 6
我们程序的错误在于将草类型 1 分配给了牧场 1。这样,它被迫将草类型 5——这是不允许的!——分配给牧场 6。我们的程序失败了,但不要因此得出没有办法解决这个测试用例的结论。特别是,可以将草类型 2 分配给牧场 1,你应该能够找到只使用四种草类型来解决这个测试用例的方法。通过更复杂的程序来解决这类问题是可行的,如果你感兴趣,鼓励你自己思考一下。
总结
在本章中,我们学习了如何打开、读取、写入和关闭文件。文件在你需要存储信息并稍后作为输入使用时非常有用。它们也在向用户传递信息时非常有用。我们还学到了如何像处理标准输入和标准输出一样处理文件。
在下一章中,我们将学习如何在 Python 集合或字典中存储一组值。存储一组值——听起来像是列表的功能。不过我们会发现,集合和字典可以帮助我们更轻松地解决某些问题。
章节练习
下面是一些练习,供你尝试。它们都是来自 USACO 的评审,要求你读取和写入文件。它们还会要求你复习前面章节中的一些知识点。
-
USACO 2018 年 12 月铜奖竞赛问题 混合牛奶
-
USACO 2017 年 2 月铜奖竞赛问题 为什么牛要过马路
-
USACO 2017 年美国公开赛铜奖竞赛问题 失踪的牛
-
USACO 2019 年 12 月铜奖竞赛问题 牛体操
-
USACO 2017 年美国公开赛铜奖竞赛问题 牛基因组学
-
USACO 2018 年美国公开赛铜奖竞赛问题 团队井字棋
-
USACO 2019 年 2 月铜奖竞赛问题 昏昏欲睡的牛群
注释
文章格式化最初来自 USACO 2020 年 1 月的铜奖竞赛。农场播种最初来自 USACO 2019 年 2 月的铜奖竞赛。
除了文本文件,还有许多其他类型的文件。你可能想处理 HTML 文件、Excel 电子表格、PDF 文件、Word 文档或图像文件。Python 可以帮忙!欲了解更多信息,请参阅《用 Python 自动化无聊的事》,第二版,作者:Al Sweigart(No Starch Press 出版社,2019 年)。
这句“或许更好的诗歌”出自 J. C. R. Licklider 的名言,引用自《计算机与未来的世界》,由马丁·格林伯格(Martin Greenberger)编辑(MIT 出版社,1962 年):
但有些人用我们说的语言写诗。或许将来用数字计算机的语言写出的诗,比用英语写的更好。
第八章:使用集合和字典组织值

当我们需要存储一系列值时,例如动作人物的高度或文章中的单词时,Python 列表是非常有用的。列表使我们能够轻松保持值的顺序,并根据索引访问某个值。然而,正如我们将在本章中看到的那样,列表并不适合执行一些操作,包括确定一个特定的值是否在集合中以及在一对值之间建立关联。
在本章中,我们将学习 Python 集合和字典,它们是存储值集合的两种替代方案。我们将看到,当我们需要查找特定的值并且不关心它们的顺序时,集合是首选工具;而当我们需要处理一对值时,字典是首选工具。
我们将使用这些新的集合解决三个问题:确定唯一电子邮件地址的数量、在一组单词中找到公共单词,以及确定一对城市和州的特殊组合数量。
问题 #18:电子邮件地址
在这个问题中,我们将存储一组电子邮件地址。我们不关心每个电子邮件地址出现的次数,也不关心电子邮件地址的顺序。这些宽松的存储要求意味着我们可以用集合来替代列表——集合是一种 Python 类型,它的速度远远超过列表。我们将学习有关集合的一切。
这是 DMOJ 问题 ecoo19r2p1。
挑战
你知道有很多种方式可以写一个人的 Gmail 地址吗?
我们可以在某人的 Gmail 地址中,在 @ 符号前加上一个加号(+)符号和一个字符串,这样他们就能收到我们发送到那个新地址的任何邮件。也就是说,针对 Gmail 地址,所有从 + 符号到 @ 符号之前的字符都会被忽略。例如,我告诉别人我的 Gmail 地址是 daniel.zingaro@gmail.com,但这只是写法之一。如果你发送邮件到 daniel.zingaro+book@gmail.com 或 daniel.zingaro+hi.there@gmail.com,我也会收到。(选择你喜欢的方式,打个招呼!)
在 Gmail 地址中,@ 符号前的点也会被忽略。例如,如果你发送邮件到 danielzingaro@gmail.com(没有点),daniel..zingaro@gmail.com(两个点连在一起),da.nielz.in.gar.o..@gmail.com(混乱的点),daniel.zin.garo+blah@gmail.com 等等,我都会收到。
最后一件事:地址中的大小写差异会被忽略。我希望到此时你没有对我发起一阵攻击,但无论如何,任何发到 Daniel.Zingaro@gmail.com、 DAnIELZIngARO+Flurry@gmAIL.COM 等的邮件,我都会收到。
在这个问题中,我们被提供了电子邮件地址,并要求我们确定其中有多少是唯一的。这个问题中电子邮件地址的规则与 Gmail 中讨论的规则相同:从 + 符号到 @ 符号前的字符会被忽略,@ 符号前的点会被忽略,整个地址中的大小写会被忽略。
输入
输入包含 10 个测试用例。每个测试用例包含以下行:
-
一行包含整数 n,表示电子邮件地址的数量。n 在 1 和 100,000 之间。
-
n 行,每行给出一个电子邮件地址。每个电子邮件地址由
@符号前至少一个字符和@符号本身,以及@符号后至少一个字符组成。@符号前的字符可以是字母、数字、点和加号。@符号后的字符可以是字母、数字和点。
输出
对于每个测试用例,输出唯一电子邮件地址的数量。
解决测试用例的时间限制是 30 秒。
使用列表
你已经学习了本书的七个章节。在每一章中,我提出了一个问题,并教你一些新的 Python 特性,以便你能解决这个问题。因此,你可能会期待我在解决电子邮件地址问题之前,先教你一些新的 Python 知识。
你可能会反对这一点:我们难道已经拥有我们需要的东西了吗?毕竟,我们可以写一个函数,接收一个电子邮件地址并返回清理后的版本,去掉 + 部分,去掉 @ 符号前的点,并且全部小写。我们还可以维护一个清理后的电子邮件地址列表。对于我们看到的每个电子邮件地址,我们可以将其清理并检查它是否已经在清理后的电子邮件地址列表中。如果没有,我们可以将其添加进去;如果已经有了,则什么也不做(因为它已经被计数)。一旦我们处理完所有电子邮件地址,列表的长度将给我们唯一电子邮件地址的数量。
是的。我们可能已经拥有我们需要的东西。让我们试着解决这个问题。
清理电子邮件地址
考虑电子邮件地址 DAnIELZIngARO+Flurry@gmAIL.COM。我们将清理这个电子邮件地址,使其变成 danielzingaro@gmail.com。去掉 +Flurry,去掉 @ 符号前的点,并且全部转为小写。我们可以将清理后的版本视为真实的电子邮件地址。任何其他表示相同真实电子邮件地址的邮件地址,在清理后也会匹配 danielzingaro@gmail.com。
清理邮箱地址是一个小而独立的任务,所以我们来为此写一个函数。这个 clean 函数将接受一个表示邮箱地址的字符串,清理它并返回清理后的邮箱地址。我们将执行三个清理步骤:移除从 + 符号到 @ 符号之前的字符、去掉 @ 符号前的点(.),以及转换为小写。这个函数的代码在 Listing 8-1 中。
def clean(address):
"""
address is a string email address.
Return cleaned address.
"""
# Remove from '+' up to but not including '@'
❶ plus_index = address.find('+')
if plus_index != -1:
❷ at_index = address.find('@')
address = address[:plus_index] + address[at_index:]
# Remove dots before @ symbol
at_index = address.find('@')
before_at = ''
i = 0
while i < at_index:
❸ if address[i] != '.':
before_at = before_at + address[i]
i = i + 1
❹ cleaned = before_at + address[at_index:]
# Convert to lowercase
❺ cleaned = cleaned.lower()
return cleaned
Listing 8-1:清理邮箱地址
第一步是移除从 + 符号到 @ 符号前的字符。find 字符串方法在这里很有用。它返回其参数在字符串中最左侧出现的索引,如果找不到该参数,则返回 -1:
>>> 'abc+def'.find('+')
3
>>> 'abcdef'.find('+')
-1
我使用 find 方法来确定最左侧的 + 符号的索引 ❶。如果没有 + 符号,就不需要做这一步。如果有,则我们再查找 @ 符号的索引 ❷,并从 + 符号到 @ 符号之间的字符进行删除。
第二步是去掉 @ 符号前的点(.)。为此,我使用一个新字符串 before_at 来积累 @ 符号前的部分地址。每个在 @ 符号前且不是 . 的字符都会被添加到 before_at ❸。
before_at 字符串不包含 @ 符号或其后的任何字符。我们不想丢失邮箱地址的这一部分,所以我使用一个新变量 cleaned 来表示整个邮箱地址 ❹。
第三步是将整个邮箱地址转换为小写 ❺。完成后,邮箱地址就被清理干净了,因此我们可以返回它。
让我们稍微测试一下。将 clean 函数的代码输入到 Python shell 中。下面是该函数清理几个邮箱地址的过程:
>>> clean('daniel.zingaro+book@gmail.com')
'danielzingaro@gmail.com'
>>> clean('da.nielz.in.gar.o..@gmail.com')
'danielzingaro@gmail.com'
>>> clean('DAnIELZIngARO+Flurry@gmAIL.COM')
'danielzingaro@gmail.com'
>>> clean('a.b.c@d.e.f')
'abc@d.e.f'
如果邮箱地址已经是干净的,clean 函数就直接返回它:
>>> clean('danielzingaro@gmail.com')
'danielzingaro@gmail.com'
主程序
我们可以使用 clean 函数清理任何邮箱地址。现在的策略是保持一个清理后的邮箱地址列表。只有当某个清理后的邮箱地址还没有被添加到列表中时,我们才会将其加入。这样,我们就能避免添加重复的邮箱地址。
程序的主要部分在 Listing 8-2 中。在这个代码之前,请确保输入我们的 clean 函数代码(Listing 8-1),以完整解决问题。
# Main Program
for dataset in range(10):
n = int(input())
❶ addresses = []
for i in range(n):
address = input()
address = clean(address)
❷ if not address in addresses:
addresses.append(address)
❸ print(len(addresses))
Listing 8-2:主程序,使用列表
我们有 10 个测试用例需要处理,因此将程序的其余部分放入一个循环中,循环 10 次。
对于每个测试用例,我们读取邮箱地址的数量,并从一个空的清理邮箱地址列表开始 ❶。
然后我们使用一个内部的 for 循环来遍历每个邮箱地址。我们读取每个邮箱地址并清理它。如果这个清理后的邮箱地址之前没有出现过 ❷,我们就把它添加到清理过的邮箱地址列表中。
当内层循环结束时,我们将建立一个所有干净电子邮件地址的列表。这个列表中没有重复项。因此,唯一的电子邮件地址数量就是这个列表的长度,这就是我们输出的内容 ❸。
不错吧?几乎就像我们在第六章学到函数后就能解决这个问题。或者,实际上,在我们学习第五章的列表后就能解决。
差不多,但还差一点。因为如果你提交给裁判,你应该会注意到事情并没有按计划进行。
麻烦的第一个迹象是裁判花了一些时间才显示我们的结果。例如,我刚才在这里等待了一分钟,才看到我的结果。相比之前我们解决的其他问题,那里反馈速度非常快。
麻烦的第二个迹象是,当我们的结果显示出来时,我们没有为这个问题获得满分!我得到了 3.25 分(满分 5 分)。你可能得到更多或更少的分数,但你不应该得到满分 5 分。
我们丢分的原因不是因为程序有错误。我们的程序是正确的。无论测试用例是什么,它都会输出正确的唯一电子邮件地址数量。
那么,如果我们的程序是正确的,问题出在哪里呢?
问题在于我们的程序太慢了。裁判通过在每个测试用例前面加上 TLE 来告诉我们这一点。TLE 代表超出时间限制。对于这个问题,裁判为每批 10 个测试用例分配了 30 秒的时间。如果我们的程序超过 30 秒没有完成,裁判会终止程序,剩下的测试用例将无法继续执行。
这可能是你第一次遇到超出时间限制的错误,尽管在完成之前章节的练习时,你可能也遇到过类似的错误。
当你收到这个错误时,首先要检查的是程序是否陷入了无限循环。如果是的话,不管时间限制是多少,它都永远不会结束。裁判会在分配的时间到期时终止程序。
如果没有无限循环,那么可能的罪魁祸首就是我们程序本身的效率。当程序员谈论效率时,他们指的是程序运行所需的时间。一个运行得更快(耗时更少)的程序比一个运行得更慢(耗时更多)的程序更高效。为了在时间限制内解决测试用例,我们将使程序更加高效。
列表搜索的效率
向 Python 列表添加元素是非常快的。无论列表中只有几个值还是成千上万个值,添加元素所花的时间都差不多。
然而,使用in操作符则是另一回事。我们的程序使用in操作符来判断一个清洁的电子邮件地址是否已经存在于我们的清洁电子邮件地址列表中。一个测试用例可能包含多达 100,000 个电子邮件地址。在最坏的情况下,我们的程序可能需要使用in操作 100,000 次。事实证明,当在一个包含许多值的列表上使用in时,它非常慢,这最终会影响我们程序的效率。为了判断一个值是否在列表中,in会从头到尾逐个值地搜索列表。它会一直搜索,直到找到它正在寻找的值,或者没有更多的列表值可供检查。in需要查看的值越多,它的速度就越慢。
让我们了解一下随着列表长度增加,in操作如何变慢。我们将使用一个函数,该函数接受一个列表和一个值,并使用in来搜索列表中的该值。它会搜索 50,000 次;如果我们只搜索一次,那会太快,我们根本无法看到发生了什么。
这个函数位于清单 8-3 中。将其代码输入到 Python shell 中。
def search(collection, value):
"""
search many times for value in collection.
"""
for i in range(50000):
found = value in collection
清单 8-3:多次搜索一个集合
让我们创建一个从 1 到 5,000 的整数列表,并搜索5000。通过搜索列表中的最右边的值,我们使得in在该列表上花费尽可能多的时间。别担心我们用整数列表而不是电子邮件地址列表来进行探索。效率会类似,而且数字比电子邮件地址要容易生成得多!
开始吧:
>>> search(list(range(1, 5001)), 5000)
在我的笔记本上,这大约需要三秒钟来运行。我们不需要精确的计时;我们只是想了解随着列表长度增加,发生了什么情况。
现在让我们创建一个从 1 到 10,000 的整数列表,并搜索10000:
>>> search(list(range(1, 10001)), 10000)
在我的笔记本上,这大约需要六秒钟。到目前为止的总结是,对于一个长度为5000的列表,耗时三秒;将列表长度加倍到10000,时间也翻倍,达到了六秒。
一个长度为20000的列表?试试看:
>>> search(list(range(1, 20001)), 20000)
在我的笔记本上,这大约需要 12 秒。
时间再次翻倍。试试一个长度为50000的列表。你需要等一会儿。我刚在我的笔记本上运行了这个:
>>> search(list(range(1, 50001)), 50000)
它花了超过 30 秒。记住,我们的search函数正在搜索 50,000 次列表。所以,它花了 30 秒来总共搜索一个长度为50000的列表 50,000 次。
我们可能会有一个需要进行如此多次搜索的测试用例。例如,假设我们将 100,000 个独特的电子邮件地址一个个添加到我们的列表中。在一半的过程中,我们将有一个包含 50,000 个值的列表;从那时起,剩下的 50,000 次in操作将会作用于一个至少包含 50,000 个值的列表。
这仅仅是 10 个测试用例中的一个!我们需要在 30 秒内完成所有 10 个测试用例。如果一个测试用例单独就能花费大约 30 秒,那我们就没有机会了。
查找列表实在是太慢了。Python 列表并不是适合这个工作的类型。我们需要一个更适合的类型。我们需要一个 Python 集合。你简直不敢相信查找集合是多么的快速。
集合
一个集合是一个 Python 类型,它存储一组值,其中不允许重复的值。我们使用大括号来限定集合。
与列表不同,集合可能不会保持你指定的顺序。这是一个整数集合:
>>> {13, 15, 30, 45, 61}
{45, 13, 15, 61, 30}
注意到 Python 打乱了值的顺序。你可能在你的计算机上看到不同的顺序。关键点是你不能依赖值的任何特定顺序。如果顺序对你很重要,集合就不是合适的类型。
如果我们尝试包含一个值的多个出现,只有一个会被保留:
>>> {1, 1, 3, 2, 3, 1, 3, 3, 3}
{1, 2, 3}
集合是相等的,如果它们包含完全相同的值,即使我们以不同的顺序书写它们:
>>> {1, 2, 3} == {1, 2, 3}
True
>>> {1, 1, 3, 2, 3, 1, 3, 3, 3} == {1, 2, 3}
True
>>> {1, 2} == {1, 2, 3}
False
我们可以创建一个字符串集合,像这样:
>>> {'abc@d.e.f', 'danielzingaro@gmail.com'}
{'abc@d.e.f', 'danielzingaro@gmail.com'}
我们不能创建一个包含列表的集合:
>>> {[1, 2], [3, 4]}
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
集合中的值必须是不可变的,这解释了为什么我们不能将列表放入集合中。这个限制与 Python 如何在集合中查找值有关。当 Python 向集合中添加一个值时,它使用该值本身来确定它应该存储的位置。后来,Python 可以通过查看应该存放值的位置来找到它。如果集合中的值可能会改变,Python 可能会在错误的位置查找,导致找不到该值。
虽然我们不能创建一个包含列表的集合,但创建一个包含集合的列表没有问题:
>>> lst = [{1, 2, 3}, {4, 5, 6}]
>>> lst
[{1, 2, 3}, {4, 5, 6}]
>>> len(lst)
2
>>> lst[0]
{1, 2, 3}
你可以使用 len 函数来确定集合中值的数量:
>>> len({2, 4, 6, 8})
4
你也可以遍历集合中的值:
>>> for value in {2, 4, 6, 8}:
... print('I found', value)
...
I found 8
I found 2
I found 4
I found 6
然而,你不能对集合进行索引或切片。集合中的值没有索引。
要创建一个空集合,你可能会期待使用一个空的大括号{}。但在 Python 语法的不一致性中,这样是行不通的:
>>> type({2, 4, 6, 8})
<class 'set'>
>>> {}
{}
>>> type({})
<class 'dict'>
使用 {} 会给我们错误的类型:一个 dict(字典),而不是一个 set(集合)。我们将在本章稍后讨论字典。
要创建一个空集合,我们使用 set(),像这样:
>>> set()
set()
>>> type(set())
<class 'set'>
集合方法
集合是可变的,因此我们可以添加和删除值。我们可以通过使用方法来执行这些任务。
你可以通过使用 dir(set()) 获取集合方法的列表。你也可以使用 help 获取某个特定集合方法的帮助,类似于我们用 help 学习字符串或列表方法。例如,要了解 add 方法,可以输入 help(set().add)。
add 方法是用来向集合添加一个值的。它类似于列表的 append 方法:
>>> s = set()
>>> s
set()
>>> s.add(2)
>>> s
{2}
>>> s.add(4)
>>> s
{2, 4}
>>> s.add(6)
>>> s
{2, 4, 6}
>>> s.add(8)
>>> s
{8, 2, 4, 6}
>>> s.add(8)
>>> s
{8, 2, 4, 6}
要删除一个值,我们使用 remove 方法:
>>> s.remove(4)
>>> s
{8, 2, 6}
>>> s.remove(8)
>>> s
{2, 6}
>>> s = {2, 6}
>>> s.remove(8)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 8
概念检查
使用 help 来了解集合的 update 和 intersection 方法。
以下代码中 print 的输出是什么?
s1 = {1, 3, 5, 7, 9}
s2 = {1, 2, 4, 6, 8, 10}
s3 = {1, 4, 9, 16, 25}
s1.update(s2)
s1.intersection(s3)
print(s1)
A. {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
B. {1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
C. {1, 4, 9}
D. {1, 4, 9, 16, 25}
E. {1}
答案:A. update 方法将集合 s2 中存在但 s1 中缺少的元素添加到集合 s1。在调用 update 后,s1 就是集合 {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}。
现在来看看 intersection 的调用。两个集合的交集是一个包含同时存在于两个集合中的值的新集合。在这里,s1 和 s3 的交集是 {1, 4, 9}。然而,intersection 方法不会修改集合;它会生成一个新集合!因此,它对 s1 没有影响。
搜索集合的效率
回到解决电子邮件地址的问题。
我们关心清理后的电子邮件地址的顺序吗?不关心!我们只关心一个电子邮件地址是否已经存在于其中。
我们需要在清理后的电子邮件地址中允许重复吗?当然不需要!实际上,我们明确地希望避免存储重复的电子邮件地址。
顺序不重要,且不允许重复。这两点表明集合可能是合适的类型。
我们在尝试使用列表时遇到了困难,因为列表的搜索太慢了。集合将是我们改进的方案,因为我们可以比搜索列表更快地搜索集合。
我们已经在 清单 8-3 中使用了 search 函数来搜索一个列表。但这个函数并没有做任何特别需要列表的操作!它使用了 in 操作符,而 in 同样适用于列表和集合。所以我们可以不做任何修改地将这个函数用来搜索集合。
在 Python shell 中输入来自 清单 8-3 的 search 函数。跟着你的电脑操作,感受一下在长列表和大集合中搜索的区别:
>>> search(list(range(1, 50001)), 50000)
❶ >>> search(set(range(1, 50001)), 50000)
在 ❶ 处,我使用 set 生成了一个整数集合,而不是列表。
在我的笔记本上,搜索列表大约需要 30 秒。相比之下,搜索集合速度飞快,几乎是瞬间完成。
集合是不可阻挡的。不要在列表上尝试这种操作,但我们来试试,在一个包含 50 万个值的集合中搜索某个值:
>>> search(set(range(1, 500001)), 500000)
哗啦!小菜一碟。
Python 以某种方式管理列表,允许我们在任何时候使用任何索引。Python 在值的顺序上没有灵活性:第一个值必须在索引 0,第二个值在索引 1,依此类推。但对于集合,Python 可以以任何方式存储它,因为它没有承诺保持顺序。这种增加的灵活性使得 Python 能够优化集合中的搜索速度。
出于类似的原因,某些操作在大列表上非常慢,但在大集合上非常快。例如,从列表中移除一个值非常慢,因为 Python 必须更新该值右侧所有值的索引。相比之下,从集合中移除一个值非常快:因为没有索引需要更新!
解决问题
我们已经有了一个清理电子邮件地址的函数(清单 8-1),并将在基于集合的解决方案中使用它。至于主程序,清单 8-2 基本上解决了大部分问题。我们只需要用集合替换列表。
新的主程序在 Listing 8-4 中。要完整解决此问题,请在此代码之前包含 Listing 8-1。
# Main Program
for dataset in range(10):
n = int(input())
❶ addresses = set()
for i in range(n):
address = input()
address = clean(address)
❷ addresses.add(address)
print(len(addresses))
Listing 8-4:主程序,使用集合
请注意,我们现在使用的是一组电子邮件地址 ❶,而不是列表。在清理每个电子邮件地址后,我们使用集合的 add 方法 ❷ 将其添加到集合中。
在 Listing 8-2 中,我们使用 in 操作符检查电子邮件地址是否已经存在于列表中,以避免添加重复项。在我们的基于集合的解决方案中,没有相应的 in 检查。它去哪儿了?似乎我们正在将每个电子邮件地址直接添加到集合中,而没有确保它已经存在。
使用集合时,我们可以省略 in 检查,因为集合从不包含重复元素。add 方法会为我们处理 in 检查,确保不会添加重复元素。你可以认为 add 已经执行了自己的 in 检查。这里没有时间问题,因为查找集合中的元素非常快。
如果你将此解决方案提交给评审员,你应该能够在时间限制内顺利通过所有测试用例。
正如你在这里看到的,选择合适的 Python 类型可以决定一个不令人满意的解决方案和一个令人满意的解决方案之间的差异。在开始编写代码之前,问问自己将频繁执行哪些操作,哪些 Python 类型最适合这些操作。
在继续之前,你可能想尝试解决《章节练习》中的第 1 和第 2 题,见 第 236 页。
问题 #19:常见单词
在这个问题中,我们需要将单词与其出现次数关联起来。这超出了集合所能做到的范围,因此我们不会在这里使用集合。相反,我们将学习并使用 Python 字典。
这是 DMOJ 问题 cco99p2。
挑战
我们给定了 m 个单词。这些单词不一定是唯一的;例如,单词 brook 可能会出现多次。我们还给定了一个整数 k。
我们的任务是找到 k 最常见的单词。如果一个单词 w 是第 k 最常见的单词,则恰好有 k - 1 个不同的单词比 w 出现得更频繁。根据数据集的不同,k 最常见的单词可能没有单词,可能有一个单词,也可能有多个单词。
让我们确保对 k 最常见单词的定义有清晰的理解。如果 k = 1,那么我们被要求找出那些没有比它出现次数更多的单词;也就是说,我们要找出最常见的单词。如果 k = 2,那么我们被要求找出那些恰好有一个单词比它出现次数更多的单词。如果 k = 3,那么我们被要求找出那些恰好有两个不同单词比它出现次数更多的单词,以此类推。
输入
输入包含一行,给出测试用例的数量,接着是测试用例本身的各行。每个测试用例包含以下几行:
-
一行包含整数m(测试用例中的单词数)和k,中间用空格隔开。m在 0 到 1,000 之间;k至少为 1。
-
m行,每行给出一个单词。每个单词最多包含 20 个字符,并且所有字符都是小写字母。
输出
对于每个测试用例,输出以下行:
-
一行包含以下内容:
p most common word(s):当p是
1st时,如果k是 1;2nd时,如果k是 2;3rd时,如果k是 3;4th时,如果k是 4,以此类推。 -
每个k最常见单词的一行。如果没有这样的单词,就没有输出行。
-
空行。
解决测试用例的时间限制为 1 秒。
探索一个测试用例
让我们从探索一个测试用例开始。这将有助于我们更好地理解问题,并促使我们使用新的 Python 类型。
假设我们对所有单词中最常见的单词感兴趣。这意味着k是 1。以下是测试用例:
1
14 1
storm
cut
magma
cut
brook
gully
gully
storm
cliff
cut
blast
brook
cut
gully
出现次数最多的单词是cut。cut出现了四次,其他单词的出现次数没有这么多。因此,正确的输出是:
1st most common word(s):
cut
❶
注意末尾需要空行 ❶。
现在,如果k是 2,我们该怎么做呢?我们可以通过再次扫描单词并计算出现次数来回答这个问题,但还有一种不同的方式来组织单词,这将使我们的任务变得更容易。与其查看单词列表,不如查看每个单词及其出现次数。见表 8-1。
表 8-1: 单词及其出现次数
| 单词 | 出现次数 |
|---|---|
| cut | 4 |
| gully | 3 |
| storm | 2 |
| brook | 2 |
| magma | 1 |
| cliff | 1 |
| blast | 1 |
我已根据单词的出现次数对它们进行了排序。从第一行看,我们可以确认cut是k = 1 时的输出单词。从第二行看,我们看到gully是k = 2 时的输出单词。单词gully是唯一一个有一个单词出现次数更多的单词。
现在,对于k = 3。此时,有两个单词需要输出,storm和brook,因为它们都有相同的出现次数。每个单词都有恰好两个出现次数更多的单词。这表明有时我们需要输出多个单词。
也有可能我们需要输出零个单词!例如,考虑k = 4 时。没有单词恰好有三个单词出现次数更多。从表格中往下看,你可能会想,为什么我们不在k = 4 时输出magma?我们不输出magma,因为magma有恰好四个单词(而不是恰好三个单词)出现次数更多。
当k = 5 时,我们有三个单词需要输出:magma、cliff和blast。在继续之前,请自行验证对于其他任何k的值(如k = 6,k = 7,k = 8,k = 9,k = 100 等)没有要输出的单词。
表 8-1 简化了我们的任务。接下来,我们将学习如何在 Python 中组织这种信息。
字典
字典 是一种 Python 类型,用于存储从一组元素(称为 键)到另一组元素(称为 值)的映射。
我们使用开括号和闭括号来界定字典。这些符号与集合的符号相同,但 Python 可以通过括号内的内容来区分集合和字典。对于集合,我们列出值;对于字典,我们列出 key:value 键值对。
这里有一个将一些字符串映射到数字的字典:
>>> {'cut':4, 'gully':3}
{'cut': 4, 'gully': 3}
在这个字典中,键是 'cut' 和 'gully',值分别是 4 和 3。键 'cut' 映射到值 4,键 'gully' 映射到值 3。
根据我们对集合的了解,你可能会想知道字典是否会按我们输入的顺序保留键值对。例如,你可能会想知道是否会发生以下情况:
>>> {'cut':4, 'gully':3}
{'gully': 3, 'cut': 4}
从 Python 3.7 开始,答案是否定的:字典会保留你添加键值对的顺序。在早期版本的 Python 中,字典并不保持顺序,因此你可以按某种顺序添加键值对,但返回时却是另一种顺序。不过,还是建议编写不依赖于 Python 3.7 行为的代码,因为旧版本的 Python 可能会在可预见的未来继续使用。
如果字典包含相同的 key:value 键值对,即使顺序不同,它们也是相等的:
>>> {'cut':4, 'gully':3} == {'cut':4, 'gully':3}
True
>>> {'cut':4, 'gully':3} == {'gully': 3, 'cut': 4}
True
>>> {'cut':4, 'gully':3} == {'gully': 3, 'cut': 10}
False
>>> {'cut':4, 'gully':3} == {'cut': 4}
False
字典的键必须是唯一的。如果你尝试多次包含相同的键,那么只有与该键相关的一个键值对会被保留:
>>> {'storm': 1, 'storm': 2}
{'storm': 2}
相反,重复的值是可以的:
>>> {'storm': 2, 'brook': 2}
{'storm': 2, 'brook': 2}
键必须是不可变的值,如数字和字符串。值可以是不可变或可变的。这意味着我们不能使用列表作为键,但可以使用列表作为值:
>>> {['storm', 'brook']: 2}
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>> {2: ['storm', 'brook']}
{2: ['storm', 'brook']}
len 函数返回字典中 key:value 键值对的数量:
>>> len({'cut':4, 'gully':3})
2
>>> len({2: ['storm', 'brook']})
1
要创建一个空字典,我们使用 {}。这也是为什么我们只能使用那个二流的 set() 语法来创建集合——字典获得了更漂亮的语法:
>>> {}
{}
>>> type({})
<class 'dict'>
该类型被称为 dict,而不是 dictionary。
在 Python 资源和代码中,你会看到“字典”和“dict”交替使用,但在本书中我将坚持使用“字典”。
概念检查
以下哪个更适合用字典而不是列表或集合?
A. 人们完成比赛的顺序
B. 一个食谱所需的食材
C. 国家名称及其首都城市
D. 50 个随机整数
答案:C。这是唯一一个包含键值对映射的选项。这里,键可以是国家名,值可以是它们的首都城市。
概念检查
以下字典中,忽略键后,值的类型是什么?
{'MLB': {'Bluejays': [1992, 1993],
'Orioles': [1966, 1970, 1983]},
'NFL': {'Patriots': ['too many']}}
A. 整数
B. 字符串
C. 列表
D. 字典
E. 以上多个选项
答案:D。字典中每个键的值本身就是一个字典。例如,键 'MLB' 映射到一个字典;该字典有两个 key:value 键值对。
索引字典
我们可以使用方括号查找一个键对应的值。这与我们索引列表的方式类似,只不过键作为有效的“索引”:
>>> d = {'cut':4, 'gully':3}
>>> d
{'cut': 4, 'gully': 3}
>>> d['cut']
4
>>> d['gully']
3
使用不存在的键是一个错误:
>>> d['storm']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'storm'
我们可以通过首先使用in来检查一个键是否存在于字典中,从而防止出现错误。当in用于字典时,它只检查键,而不检查值。以下是我们如何在尝试查找其值之前检查键是否存在:
>>> if 'cut' in d:
... print(d['cut'])
...
4
>>> if 'storm' in d:
... print(d['storm'])
...
索引和在字典上使用in是非常快速的操作。它们不需要搜索任何类型的列表,无论字典中有多少个键。
有时使用get方法而不是索引来查找键的值会更方便。即使键不存在,get方法也永远不会产生错误:
>>> print(d.get('cut'))
4
>>> print(d.get('storm'))
None
如果键存在,get返回其值。否则,它返回None,表示该键不存在。
除了查找键的值,我们还可以使用方括号向字典添加键或更改键所对应的值。以下是一些代码,展示了如何做到这一点,从一个空字典开始:
>>> d = {}
>>> d['gully'] = 1
>>> d
{'gully': 1}
>>> d['cut'] = 1
>>> d
{'gully': 1, 'cut': 1}
>>> d['cut'] = 4
>>> d
{'gully': 1, 'cut': 4}
>>> d['gully'] = d['gully'] + 1
>>> d
{'gully': 2, 'cut': 4}
>>> d['gully'] = d['gully'] + 1
>>> d
{'gully': 3, 'cut': 4}
概念检查
使用help({}.get)了解更多关于字典get方法的信息。
以下代码的输出是什么?
d = {3: 4}
d[5] = d.get(4, 8)
d[4] = d.get(3, 9)
print(d)
A. {3: 4, 5: 8, 4: 9}
B. {3: 4, 5: 8, 4: 4}
C. {3: 4, 5: 4, 4: 3}
D. get引起的错误
答案:B。第一次调用get返回8,因为键4在字典中不存在。因此,这一行会添加键5,值为8。
第二次调用get返回4:键3已经存在于字典中,因此第二个参数9被忽略。因此,这一行会添加键4,值为4。
遍历字典
如果我们在字典上使用for循环,我们将得到字典的键:
>>> d = {'cut': 4, 'gully': 3, 'storm': 2, 'brook': 2}
>>> for word in d:
... print('a key is', word)
...
a key is cut
a key is gully
a key is storm
a key is brook
我们可能还希望访问与每个键相关联的值,我们可以通过使用每个键作为字典中的索引来做到这一点。下面是一个既访问键又访问其值的循环:
>>> for word in d:
... print('key', word, 'has value', d[word])
...
key cut has value 4
key gully has value 3
key storm has value 2
key brook has value 2
字典有一些方法,可以让我们访问键、值或两者。
keys方法给我们提供键,而values方法给我们提供值:
>>> d.keys()
dict_keys(['cut', 'gully', 'storm', 'brook'])
>>> d.values()
dict_values([4, 3, 2, 2])
这些不是列表,但我们可以将它们传递给list以进行转换:
>>> keys = list(d.keys())
>>> keys
['cut', 'gully', 'storm', 'brook']
>>> values = list(d.values())
>>> values
[4, 3, 2, 2]
有了作为列表的键,我们可以对这些键进行排序,然后按排序顺序遍历它们:
>>> keys.sort()
>>> keys
['brook', 'cut', 'gully', 'storm']
>>> for word in keys:
... print('key', word, 'has value', d[word])
...
key brook has value 2
key cut has value 4
key gully has value 3
key storm has value 2
我们还可以遍历值:
>>> for num in d.values():
... print('number', num)
...
number 4
number 3
number 2
number 2
遍历键通常比遍历值更受欢迎。因为从键到值很容易。然而,正如我们在下一小节中将看到的,从值回到键就没有那么容易了。
另一个相关的方法是items。它让我们可以访问键和值:
>>> pairs = list(d.items())
>>> pairs
[('cut', 4), ('gully', 3), ('storm', 2), ('brook', 2)]
这为我们提供了另一种遍历字典key:value对的方法:
>>> for pair in pairs:
... print('key', pair[0], 'has value', pair[1])
...
key cut has value 4
key gully has value 3
key storm has value 2
key brook has value 2
仔细查看pairs的值:
>>> pairs
[('cut', 4), ('gully', 3), ('storm', 2), ('brook', 2)]
这里有些可疑:每个内部值周围是圆括号,而不是方括号。事实证明,这不是一个列表的列表,而是一个元组的列表:
>>> type(pairs[0])
<class 'tuple'>
元组与列表相似,它们存储一系列的值。元组和列表之间最重要的区别在于,元组是不可变的。你可以遍历元组、索引它、切片它,但你不能修改它。如果你试图修改元组,会发生错误:
>>> pairs[0][0] = 'river'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
你可以使用圆括号创建自己的元组。对于一个只有一个值的元组,我们需要一个尾随的逗号。对于多个值的元组,则不需要:
>>> (4,)
(4,)
>>> (4, 5)
(4, 5)
>>> (4, 5, 6)
(4, 5, 6)
元组有一些方法——但只有少数几个,因为不允许改变元组的操作。如果你感兴趣,我鼓励你了解更多关于元组的知识,但在本书中我们不再使用元组。
反转字典
我们已经接近使用字典解决常见单词问题了。计划如下:我们维护一个字典,将单词映射到它们的出现次数。每次处理一个单词时,我们检查该单词是否已经存在于字典中。如果不存在,我们将它加入字典,并赋值为 1。如果存在,则将其值加 1。
这是一个例子,展示了如何添加两个单词,一个我们之前见过,另一个我们没见过:
>>> d = {'storm': 1, 'cut': 1, 'magma': 1}
>>> word = 'cut' # 'cut' is already in the dictionary
>>> if not word in d:
... d[word] = 1
... else:
... d[word] = d[word] + 1
...
>>> d
{'storm': 1, 'cut': 2, 'magma': 1}
>>> word = 'brook' # 'brook' is not in the dictionary
>>> if not word in d:
... d[word] = 1
... else:
... d[word] = d[word] + 1
...
>>> d
{'storm': 1, 'cut': 2, 'magma': 1, 'brook': 1}
字典使得从键到值的查找变得容易。例如,给定键 'brook',我们可以轻松查找其值 1:
>>> d['brook']
1
参考表格 8-1,这就像是从左列的单词到右列中它们的出现次数。这并不能直接告诉我们出现次数为特定数字的单词,然而。我们真正需要的是能够从右列反向查找,从出现次数到单词。这样我们就可以按出现次数从多到少排序,找到我们需要的单词。
也就是说,我们需要从这种类型的字典开始:
{'storm': 2, 'cut': 4, 'magma': 1, 'brook': 2,
'gully': 3, 'cliff': 1, 'blast': 1}
对于这种类型,反转字典:
{2: ['storm', 'brook'], 4: ['cut'], 1: ['magma', 'cliff', 'blast'],
3: ['gully']}
原始字典是从字符串映射到数字。反转后的字典则是从数字映射到字符串。嗯,其实不完全是:反转字典是从数字映射到字符串的列表。记住,字典中的每个键只允许出现一次。在反转字典中,我们需要将每个键映射到多个值,因此我们将所有这些值存储在一个列表中。
要反转字典,每个键变成一个值,每个值变成一个键。如果一个键在反转字典中尚不存在,我们就为其值创建一个列表。如果键已经存在于反转字典中,那么我们将其值添加到该键的列表中。
我们现在可以编写一个函数,返回一个字典的反转版本。查看列表 8-5 中的代码。
def invert_dictionary(d):
"""
d is a dictionary mapping strings to numbers.
Return the inverted dictionary of d.
"""
inverted = {}
❶ for key in d:
❷ num = d[key]
if not num in inverted:
❸ inverted[num] = [key]
else:
❹ inverted[num].append(key)
return inverted
列表 8-5:反转字典
我们使用for循环遍历字典d❶,这会给我们每个键。我们索引d以获取该键对应的值❷。然后,我们将这个key:value对添加到反转字典中。如果num还不是反转字典中的一个键,那么我们就添加它,并将其映射到d中的相应键❸。如果num已经是反转字典中的一个键,那么它的值已经是一个列表。因此,我们可以使用append将d中的键作为另一个值添加❹。
将我们的invert_dictionary函数代码输入 Python shell 中,试试看:
>>> d = {'a': 1, 'b': 1, 'c': 1}
>>> invert_dictionary(d)
{1: ['a', 'b', 'c']}
>>> d = {'storm': 2, 'cut': 4, 'magma': 1, 'brook': 2,
... 'gully': 3, 'cliff': 1, 'blast': 1}
>>> invert_dictionary(d)
{2: ['storm', 'brook'], 4: ['cut'], 1: ['magma', 'cliff', 'blast'],
3: ['gully']}
现在我们准备通过反转字典来解决常见词问题。
解决问题
如果你想更多地练习自顶向下设计,或许可以在继续之前自行尝试解决这个问题。为了节省篇幅,我在这里不会遵循自顶向下设计的步骤,而是直接展示完整的解决方案,然后我们再讨论每个函数及其使用方法。
代码
解决方案见清单 8-6。
def invert_dictionary(d):
"""
d is a dictionary mapping strings to numbers.
Return the inverted dictionary of d.
"""
inverted = {}
for key in d:
num = d[key]
if not num in inverted:
inverted[num] = [key]
else:
inverted[num].append(key)
return inverted
❶ def with_suffix(num):
"""
num is an integer >= 1.
Return a string of num with its suffix added; e.g. '5th'.
"""
❷ s = str(num)
❸ if s[-1] == '1' and s[-2:] != '11':
return s + 'st'
elif s[-1] == '2' and s[-2:] != '12':
return s + 'nd'
elif s[-1] == '3' and s[-2:] != '13':
return s + 'rd'
else:
return s + 'th'
❹ def most_common_words(num_to_words, k):
"""
num_to_words is a dictionary mapping number of occurrences to
lists of words.
k is an integer >= 1.
Return a list of the kth most-common words in num_to_words.
"""
nums = list(num_to_words.keys())
nums.sort(reverse=True)
total = 0
i = 0
done = False
❺ while i < len(nums) and not done:
num = nums[i]
❻ if total + len(num_to_words[num]) >= k:
done = True
else:
total = total + len(num_to_words[num])
i = i + 1
❼ if total == k - 1 and i < len(nums):
return num_to_words[nums[i]]
else:
return []
❽ n = int(input())
for dataset in range(n):
lst = input().split()
m = int(lst[0])
k = int(lst[1])
word_to_num = {}
for i in range(m):
word = input()
if not word in word_to_num:
word_to_num[word] = 1
else:
word_to_num[word] = word_to_num[word] + 1
❾ num_to_words = invert_dictionary(word_to_num)
ordinal = with_suffix(k)
words = most_common_words(num_to_words, k)
print(f'{ordinal} most common word(s):')
for word in words:
print(word)
print()
清单 8-6: 解决常见词问题
第一个函数是invert_dictionary。我们在本章“反转字典”部分已经讨论过它。接下来我们将逐一讲解程序中的其他部分。
添加后缀
with_suffix函数❶接受一个数字并返回添加了正确后缀的字符串。我们需要这个函数,是因为有一个恼人的要求:输出k时需要带上后缀。例如,如果k = 1,那么我们就必须生成这一行作为输出的一部分:
1st most common word(s):
如果k = 2,我们将需要生成这行作为输出的一部分:
2nd most common word(s):
依此类推。我们的with_suffix函数确保我们为数字添加正确的后缀。我们首先将数字转换为字符串❷,以便可以轻松访问其各个数字。然后,我们使用一系列测试来判断后缀是st、nd、rd还是th。例如,如果最后一位数字是1,但最后两位数字不是11❸,那么正确的后缀是st。这会给我们1st、21st和31st,但不会是11st(这是错误的)。
查找第 k 个最常见的单词
most_common_words函数❹是实际找到我们需要的单词的函数。它接受一个反转字典(该字典将出现次数映射到单词列表)和一个整数k,并返回出现次数第k多的单词列表。
为了查看它是如何工作的,让我们看一个反转字典的示例。我已经将它的键按出现次数从多到少排序,因为most_common_words就是按照这个顺序遍历键的。以下是字典:
{4: ['cut'],
3: ['gully'],
2: ['storm', 'brook'],
1: ['magma', 'cliff', 'blast']}
假设k是3。因此,恰好必须有两个单词比我们返回的单词更常见。我们需要的单词并没有由第一个字典键提供。那个键只给我们一个单词(cut),所以它不能是第三常见的单词。同样,我们需要的单词也没有由第二个字典键提供。那个键给我们另一个单词(gully)。到目前为止,我们已经处理了两个单词,但还没有找到第三常见的单词。然而,我们需要的单词确实由第三个字典键提供。那个键给我们两个更多的单词;每个单词(storm和brook)都有恰好两个比它们更常见的单词,因此这些就是当k为3时的单词。
如果k是4呢?这次,恰好必须有三个单词比我们返回的单词更常见。候选单词仍然是来自第三个键(storm和brook),但是只有两个单词比这两个单词出现得更频繁。因此,当k = 4时,没有单词。
总结来说,我们需要在遍历键时统计我们看到的单词,直到找到可能包含我们需要的单词的键。如果恰好有k - 1个单词出现得更频繁,那么我们就有k的单词;否则,我们没有要输出的单词。
现在让我们来看看代码本身。我们首先获取字典的键的列表,并按从大到小的顺序对它们进行排序。然后我们以该倒排顺序❺遍历这些键。done变量告诉我们是否已经查看过k个或更多的单词。一旦我们有了❻,我们就退出循环。
当循环完成时,我们检查是否有任何单词满足k。如果恰好有k - 1个单词出现得更频繁,并且我们没有超过键的末尾❼,那么我们确实有要返回的单词。否则,没有单词可以返回,所以我们返回空列表。
主程序
现在我们来到了程序的主要部分❽。我们构建了字典word_to_num,它将每个单词映射到它的出现次数。接着我们构建了反向字典num_to_words❾,它将每个出现次数映射到相应的单词列表。注意这些字典的名称如何传达映射的方向:word_to_num从单词到数字,num_to_words从数字到单词。
剩下的代码调用了我们其他的辅助函数,并输出了相应的单词。
有了这些,你就准备好提交给评审了。干得好:这是你用字典解决的第一个问题。每当你需要在两种类型的值之间进行映射时,思考一下是否可以使用字典来组织信息。如果可以,很可能你已经走上了高效解决方案的道路!
问题 #20:城市与州
这里是另一个我们能够使用字典的问题。当你阅读问题描述时,思考一下我们可以用什么作为键,什么可以作为值。
这是 USACO 2016 年 12 月银奖比赛的题目——城市与州。
挑战
美国被划分为称为州的地理区域,每个州包含一个或多个城市。每个州都有一个两字符的缩写。例如,宾夕法尼亚州的缩写是 PA,南卡罗来纳州的缩写是 SC。我们将以全大写字母书写城市名称和州的缩写。
考虑城市对SCRANTON PA和PARKER SC。这对城市是特殊的,因为每个城市的前两个字符代表另一个城市的州的缩写。也就是说,SCRANTON的前两个字符给我们提供了 SC(PARKER的州),而PARKER的前两个字符给我们提供了 PA(SCRANTON的州)。
如果一对城市满足这个条件且不在同一州,则该对城市为特殊的。
确定提供的输入中有多少个特殊的城市对。
输入
从名为citystate.in的文件中读取输入。
输入包含以下几行:
-
一行包含n,表示城市的数量。n的范围是 1 到 200,000 之间。
-
n行,每行一个城市。每行给出一个城市的名称(全大写),一个空格,及其州的缩写(全大写)。每个城市的名称长度在 2 到 10 个字符之间;每个州的缩写恰好为两个字符。相同的城市名称可以出现在多个州中,但在同一州中不会重复出现。该问题中城市或州的名称可以是符合这些要求的任何字符串;它可能不是一个实际存在的美国城市或州的名称。
输出
将输出写入名为citystate.out的文件中。
输出特殊城市对的数量。
每个测试用例的时间限制为四秒。
探索一个测试用例
也许你认为可以用一个列表来解决这个问题。这是一个不错的想法!如果你有兴趣,在继续之前我建议先尝试这个方法。策略是使用两个嵌套循环来考虑每一对城市,并检查每一对是否特殊。使用这种方法是有可能得到正确解决方案的。
一个正确的解决方案,没错,但它也很慢。城市的列表可能非常庞大——最多可达 200,000 个——任何涉及在列表中查找匹配城市的解决方案注定会太慢。让我们探索一个测试用例,看看字典如何帮助解决这个问题。
这是我们的测试用例:
12
SCRANTON PA
MANISTEE MI
NASHUA NH
PARKER SC
LAFAYETTE CO
WASHOUGAL WA
MIDDLEBOROUGH MA
MADISON MI
MILFORD MA
MIDDLETON MA
COVINGTON LA
LAKEWOOD CO
第一个城市是SCRANTON PA。要找到与这个城市相关的特殊城市对,我们需要找到其他以PA开头且州为SC的城市。符合此描述的唯一其他城市是PARKER SC。
请注意,对于SCRANTON PA,我们关心的是它的名称以SC开头,且其州为PA。它也可以被称为SCMERWIN PA、SCSHOCK PA或SCHRUTE PA,但它仍然会与PARKER SC形成特殊对。
我们将城市名称的前两个字符与城市所在州的简称组合称为组合(combo)。例如,SCRANTON PA的组合是SCPA,而PARKER SC的组合是PASC。
我们可以不再搜索特殊城市对,而是寻找特殊组合对。让我们试试看。
有两个城市的组合是MAMI。它们分别是MANISTEE MI和MADISON MI,但我们关心的是它们的数量是两个。MAMI城市以MA开头,且位于MI州。为了统计涉及MAMI城市的特殊城市对,我们需要知道那些以MI开头、且位于MA州的城市。也就是说,我们需要知道MIMA城市的数量。共有三个MIMA城市。它们分别是MIDDLEBOROUGH MA,MILFORD MA,和MIDDLETON MA,但我们关心的是它们的数量是三个。好——所以我们有两个MAMI城市和三个MIMA城市。因而,这些组合的总特殊城市对为 2 * 3 = 6,因为对于每一个MAMI城市,我们都有三种选择的MIMA城市。
如果你还不信服,以下是这些组合的六对特殊城市:
-
MANISTEE MI和MIDDLEBOROUGH MA -
MANISTEE MI和MILFORD MA -
MANISTEE MI和MIDDLETON MA -
MADISON MI和MIDDLEBOROUGH MA -
MADISON MI和MILFORD MA -
MADISON MI和MIDDLETON MA
如果我们能够将组合——SCPA,PASC,MAMI,MIMA等——映射到出现次数,我们就可以遍历这些组合来找出特殊城市对的数量。字典是存储这种映射的完美工具。
这是我们希望为测试用例创建的字典:
{'SCPA': 1, 'MAMI': 2, 'NANH': 1, 'PASC': 1, 'LACO': 2,
'MIMA': 3, 'COLA': 1}
使用这个字典,我们可以计算出特殊城市对的数量。让我们一步步做这个过程。
第一个键是'SCPA';它的值是1。为了查找涉及'SCPA'的特殊城市对,我们需要查找'PASC'的值。该值也是1。我们将这两个值相乘,得到 1 * 1 = 1 对涉及这些组合的特殊城市。我们需要对字典中的其他每个键执行相同的操作。
下一个键是'MAMI';它的值是2。为了查找涉及'MAMI'的特殊城市对,我们需要查找'MIMA'的值。该值是3。我们将这两个值相乘,得到 2 * 3 = 6 对涉及这些组合的特殊城市。加上之前找到的 1,我们现在一共有 7 对。
下一个键是'NANH';它的值是1。为了查找涉及'NANH'的特殊城市对,我们需要查找'NHNA'的值。但是'NHNA'不是字典中的键!因此没有涉及这些组合的特殊城市对。我们仍然总共有 7 对。
请注意接下来的这一点。下一个关键字是 'PASC';它的值是 1。要找到涉及 'PASC' 的特殊城市配对,我们需要查找 'SCPA' 的值。它的值也是 1。我们将这两个值相乘,得到 1 * 1 = 1 对涉及这些组合的特殊城市配对。但是等一下:我们在处理 'SCPA' 关键字时已经计算过这对配对。如果我们再加 1,那么我们会重复计算这对配对。事实上,通过处理每个关键字,我们将重复计算每一对特殊城市配对。别担心:当我们准备输出最终答案时,我们会做调整。我们就先把这个 1 加进去。再加上之前找到的 7 个,我们现在总共有 8 个。
下一个关键字是 'LACO';它的值是 2。'COLA' 的值是 1,因此这两个组合涉及的城市特殊配对数量为 2 * 1 = 2。再加上我们之前找到的 8 个,现在总共有 10 个。
还有两个关键字,'MIMA' 和 'COLA'。第一个让我们在总数上加上 6,第二个让我们加上 2。加上之前找到的 10,现在总共有 18 个。
记住,我们已经重复计算了每一对特殊城市配对。因此,我们并不拥有 18 对独特的特殊城市配对。我们只有 18 / 2 = 9 对特殊城市配对。我们只需要除以 2 来消除重复计算的结果。
如果你将我们刚才分析的字典与测试用例中的城市进行比较,你会发现字典中缺少了一些内容。那就是城市 WASHOUGAL WA!它的组合是 WAWA,但在我们的字典中并没有 'WAWA' 这个关键字。我们没有考虑到这个城市,需要弄清楚为什么。
WASHOUGAL WA 的前两个字符是 WA。这意味着 WASHOUGAL WA 要成为特殊配对城市的一部分,唯一的方法是找到另一个州为 WA 的城市。请注意,WASHOUGAL WA 也位于 WA 州。然而,问题规定,特殊配对城市的两个城市必须来自不同的州。因此,无法找到涉及 WASHOUGAL WA 的特殊配对城市。为了确保我们不会错误地计算伪特殊配对,我们甚至不会将 WASHOUGAL WA 包含在字典中。
解决问题
我们准备好了!我们可以使用字典来快速且简洁地解决城市和州问题。代码见 清单 8-7。
input_file = open('citystate.in', 'r')
output_file = open('citystate.out', 'w')
n = int(input_file.readline())
❶ combo_to_num = {}
for i in range(n):
lst = input_file.readline().split()
❷ city = lst[0][:2]
state = lst[1]
❸ if city != state:
combo = city + state
if not combo in combo_to_num:
combo_to_num[combo] = 1
else:
combo_to_num[combo] = combo_to_num[combo] + 1
total = 0
❹ for combo in combo_to_num:
❺ other_combo = combo[2:] + combo[:2]
if other_combo in combo_to_num:
❻ total = total + combo_to_num[combo] * combo_to_num[other_combo]
❼ output_file.write(str(total // 2) + '\n')
input_file.close()
output_file.close()
清单 8-7:解决城市和州问题
这是一个 USACO 问题,需要使用文件而不是标准输入和标准输出。
我们将构建的字典叫做 combo_to_num ❶。它将四个字符的组合,如 'SCPA',映射到具有该组合的城市数量。
对于输入中的每个城市,我们使用变量来表示城市名称的前两个字符 ❷ 以及它的州。然后,如果这些值不同 ❸,我们将它们组合并将组合添加到字典中。如果该组合尚不存在于字典中,我们将其添加并赋值为 1;如果已存在,则将其值加 1。
字典现在已经建立。我们遍历它的键 ❹。对于每个键,我们构造需要查找的另一组,以找到涉及该键的特殊城市对。例如,如果键是'SCPA',那么我们希望另一组是'PASC'。为此,我们取键的最右两个字符,然后跟随左侧两个字符 ❺。如果另一组也在字典中,那么我们将两个键的值相乘并添加到我们的总数 ❻。
现在,我们只需将特殊城市对的总数输出到输出文件中。如前一节所述,我们需要将总数除以 2 ❼,以消除处理字典中每个键时导致的重复计数。
就是这样:用适当的字典部署解决问题的另一个例子。随时提交我们的代码!
摘要
在本章中,我们学习了 Python 集合和字典。集合是一组没有顺序且没有重复值的数据。字典是一组键:值对。正如我们在本章的问题中看到的那样,有时这些集合比列表更合适。例如,确定值是否在集合中的操作比在列表上进行同样的操作要快得多。如果我们不关心值的顺序或想要消除重复值,我们应该认真考虑使用集合。
同样地,字典使得确定由键映射到的值变得容易。如果我们在维护从键到值的映射,那么我们应该认真考虑使用字典。
在集合和字典混合使用的情况下,你现在有更多的灵活性来存储你的值。然而,这种灵活性意味着你需要做出选择。不要再默认使用列表!使用一种类型还是另一种类型可能是解决问题与否的关键。
我们已经达到了一个重要的里程碑,因为我们现在已经涵盖了本书中我将教给你的大部分 Python 知识。这并不意味着你的 Python 之旅结束了。关于 Python,书中未包含的内容还有很多。不过,这意味着我们已经达到了一个可以用我们的 Python 技能解决各种问题的地步,无论是在竞技编程中还是其他方面。
在书的下一章中,我们将换个角度:从学习新的 Python 特性转向提升我们的问题解决能力。我们将专注于通过搜索所有候选解决方案来解决一类特定的问题。
章节练习
这里有一些练习供你尝试。对于每个练习,使用一个集合或字典。有时,使用集合或字典可以帮助你编写运行更快的代码;其他时候,它可以帮助你编写更有组织、更易于阅读的代码。
-
DMOJ 问题
crci06p1,巴德 -
DMOJ 问题
dmopc19c5p1,显眼的神秘清单 -
DMOJ 问题
coci15c2p1,Marko -
DMOJ 问题
ccc06s2,密文攻击 -
DMOJ 问题
dmopc19c3p1,找众数 -
DMOJ 问题
coci14c2p2,Utrka(尝试通过三种不同方式解决此问题:使用字典、使用集合和使用列表!) -
DMOJ 问题
coci17c2p2,ZigZag(提示:维护两个字典。第一个字典将每个起始字母映射到其单词列表;第二个字典将每个起始字母映射到其下一个将要输出的单词的索引。这样,我们可以循环遍历每个字母的单词,而无需显式更新出现次数或修改列表。)
备注
Email Addresses 最初来自 2019 年安大略省教育计算组织编程竞赛第二轮。Common Words 最初来自 1999 年加拿大计算奥林匹克竞赛。Cities and States 最初来自 2016 年美国计算机奥林匹克银奖竞赛。
如果你想深入了解 Python,我推荐Python Crash Course,第二版,由 Eric Matthes 编写(No Starch Press,2019)。当你准备好提升到下一个层次时,你可能会喜欢阅读Effective Python,第二版,由 Brett Slatkin 编写(Addison-Wesley Professional,2020),它提供了一些技巧,帮助你编写更好的 Python 代码。
第九章:使用完全搜索设计算法

算法是解决问题的一系列步骤。本书中的每个问题我们都是通过编写 Python 代码形式的算法来解决的。在本章中,我们将专注于算法设计。当面对一个新问题时,有时很难知道该做什么来解决它。我们应该编写什么样的算法呢?幸运的是,我们不必每次都从头开始。计算机科学家和程序员已经确定了几种通用的算法类型,至少有一种可能能够解决我们的问题。
一种算法被称为完全搜索算法;它涉及尝试所有候选解并选择最优解。例如,如果问题要求我们找到最大值,我们就尝试所有解并选择最大的;如果问题要求我们找到最小值,我们就尝试所有解并选择最小的。完全搜索算法也被称为暴力算法,但我将避免使用这个术语。确实,计算机正在全力处理,逐一检查每个解,但作为算法设计师,我们所做的并没有暴力的成分。
我们使用了完全搜索算法来解决第五章中的“村庄邻里”问题。我们的任务是找到最小的邻里大小,我们通过查看每个邻里并记住最小的那个来实现这一目标。在本章中,我们将使用完全搜索算法来解决其他问题。我们将看到,确定具体要搜索什么,可能需要相当的巧妙构思。
我们将通过完全搜索来解决两个问题:确定解雇哪个救生员和识别满足滑雪训练营要求的最低成本。接着我们将看到一个第三个问题,计算满足给定观察条件的奶牛三元组,这个问题要求我们更进一步。
问题#21:救生员
在这个问题中,我们需要确定解雇哪个救生员,以使我们能获得最大覆盖时间的游泳池。我们将分别尝试解雇每个救生员并观察结果——这就是一个完全搜索算法!
这是 USACO 2018 年 1 月铜奖比赛中的问题:救生员。
挑战
农夫约翰为他的奶牛购买了一个游泳池。该游泳池的开放时间为从时间 0 到时间 1000。
农夫约翰雇佣了n个救生员来监控游泳池。每个救生员在一个给定的时间段内监控池塘。例如,一个救生员可能从时间 2 开始,到时间 7 结束。我将把这个时间段表示为 2-7。一个时间段所覆盖的时间单位数是结束时间减去开始时间。例如,时间段为 2-7 的救生员覆盖了 7-2=5 个时间单位。那些时间单位分别是从时间 2 到 3,3 到 4,4 到 5,5 到 6,以及 6 到 7。
不幸的是,农夫约翰只有足够的钱支付n – 1 个救生员的薪水,而不是n个救生员,因此他必须解雇一个救生员。
确定在解雇一名救生员后,仍然可以覆盖的最大时间单位数。
输入
从名为 lifeguards.in 的文件中读取输入。
输入由以下几行组成:
-
一行包含 n,表示被雇佣的救生员数量。n 的范围在 1 到 100 之间。
-
n 行,每行一个救生员。每行给出救生员的开始时间、一个空格和结束时间。开始和结束时间都是介于 0 到 1000 之间的整数,且各不相同。
输出
将输出写入名为 lifeguards.out 的文件中。
输出 n – 1 名救生员能够覆盖的最大时间单位数。
每个测试用例的解答时间限制为四秒。
探索一个测试用例
让我们探索一个测试用例,帮助说明为什么完整搜索算法对于这个问题是合理的。以下是测试用例:
4
5 8
10 15
17 25
9 20
你可以尝试使用的一个简单规则是解雇时间间隔最短的救生员。这在直觉上有些道理,因为似乎那个救生员对覆盖泳池的贡献最小。
这个规则给出的算法正确吗?让我们来看看。它告诉我们解雇第 5–8 名救生员,因为那名救生员的时间间隔最短。这样剩下的三个救生员的时间间隔是 10–15、17–25 和 9–20。这三个剩余的救生员恰好覆盖了 9–25 的区间,包含 25 – 9 = 16 个时间单位。那么 16 是正确答案吗?
不幸的是,不是的。事实上,我们应该解雇的是 10–15 名救生员。如果我们这么做,那么剩下的三个救生员的时间区间将是 5–8、17–25 和 9–20。这三个剩余的救生员覆盖了 5–8 和 9–25 的区间。(小心:它们不覆盖从 8 到 9 这一单位时间。)这两个区间分别覆盖 8 – 5 = 3 个时间单位,和 25 – 9 = 16 个时间单位,总共是 19 个时间单位。
正确答案是 19,而不是 16。解雇时间间隔最短的救生员并没有奏效。
设计一个始终有效的简单规则来解决这个问题并不容易。不过我们不需要担心:通过完整搜索算法,我们可以完全绕过这一要求。
下面是我们的完整搜索算法如何解决测试用例的过程:
-
首先,它将忽略第一个救生员,确定其余三个救生员所覆盖的时间单位数。它将得到一个 16 的答案,并将其记住为需要超越的分数。
-
接下来,它将忽略第二个救生员,确定其余三个救生员所覆盖的时间单位数。它将得到一个 19 的答案。由于 19 大于 16,它会将 19 记住为超越的分数。
-
接下来,它将忽略第三个救生员,确定其余三个救生员所覆盖的时间单位数。它将得到一个 14 的答案,超越的分数依然是 19。
-
最后,它将忽略第四个救生员,并确定其余三个救生员所覆盖的时间单位数量。它将得到 16 的答案。要打破的分数仍然是 19。
在考虑了解雇每个救生员的后果之后,算法得出结论,19 是正确答案。因为我们尝试了所有的选项,所以没有比这更好的答案了!我们进行了完整的解决方案搜索。
解决问题
为了使用完整搜索,通常有助于首先编写一个函数,解决某个特定候选解决方案的问题。然后,我们可以多次调用该函数,每次使用一个候选解决方案。
解雇一个救生员
让我们编写一个函数来确定解雇某个特定救生员时所覆盖的时间单位数。列表 9-1 展示了代码。
def num_covered(intervals, fired):
"""
intervals is a list of lifeguard intervals;
each interval is a [start, end] list.
fired is the index of the lifeguard to fire.
Return the number of time units covered by all lifeguards
except the one fired.
"""
❶ covered = set()
for i in range(len(intervals)):
if i != fired:
interval = intervals[i]
❷ for j in range(interval[0], interval[1]):
❸ covered.add(j)
return len(covered)
列表 9-1:解雇某个特定救生员时的解决方案
第一个参数是救生员时间间隔的列表;第二个参数是被解雇的救生员的索引。将代码输入 Python shell。以下是两个函数调用的示例:
>>> num_covered([[5, 8], [10, 15], [9, 20], [17, 25]], 0)
16
>>> num_covered([[5, 8], [10, 15], [9, 20], [17, 25]], 1)
19
这些调用确认,如果我们解雇救生员 0,我们可以覆盖 16 个时间单位;如果我们解雇救生员 1,我们可以覆盖 19 个时间单位。
现在,让我们理解这个函数是如何操作的。我们首先创建一个集合,用来存储覆盖的时间单位❶。每当有时间单位被覆盖时,代码会将该时间单位的起始时间添加到集合中。例如,如果从 0 到 1 的时间单位被覆盖,代码会将0添加到集合中;如果从 4 到 5 的时间单位被覆盖,它将添加4到集合中。
我们遍历救生员时间间隔。如果某个救生员没有被解雇,那么我们会遍历该救生员的时间间隔❷,考虑每一个被覆盖的时间单位。我们将这些时间单位添加到集合中❸,如承诺的那样。回想一下,集合不会保留重复的值;如果我们尝试多次添加相同的时间单位,也不用担心。我们已经遍历了所有没有被解雇的救生员,并将所有覆盖的时间单位添加到了集合中。因此,我们只需返回集合中的值的数量。
主程序
我们程序的主要部分在列表 9-2 中。它使用num_covered函数来确定分别解雇每个救生员时所覆盖的时间单位数。请确保在这段代码之前输入我们的num_covered函数(列表 9-1),以便完整解决这个问题。
input_file = open('lifeguards.in', 'r')
output_file = open('lifeguards.out', 'w')
n = int(input_file.readline())
intervals = []
for i in range(n):
❶ interval = input_file.readline().split()
interval[0] = int(interval[0])
interval[1] = int(interval[1])
intervals.append(interval)
max_covered = 0
❷ for fired in range(n):
❸ result = num_covered(intervals, fired)
if result > max_covered:
max_covered = result
output_file.write(str(max_covered) + '\n')
input_file.close()
output_file.close()
列表 9-2:主程序
我们在这里处理的是文件,而不是标准输入和标准输出。
程序开始时读取救生员的数量,然后使用for循环读取每个救生员的时间间隔。我们从输入❶中读取每个时间间隔,将其各个组成部分转换为整数,并将其作为一个包含两个值的列表附加到我们的时间间隔列表中。
我们使用max_covered变量来跟踪可以覆盖的最大时间单位数。
现在,我们通过一个 for 循环 ❷ 分别开火每个救生员。我们调用 num_covered ❸ 来确定一个救生员开火后能覆盖的时间单位数。每当我们能够覆盖更多的时间单位时,就会更新 max_covered。
当那个循环完成时,我们将检查每个救生员的开火所能覆盖的时间单位,并记住其中的最大值。我们输出这个最大值来解决问题。
随时可以将我们的代码提交给 USACO 判定器。对于 Python 代码,判定器每个测试用例的时间限制是四秒,但我们的解决方案应该不会接近这个限制。例如,我刚刚运行了这段代码,每个测试用例的执行时间都不超过 130 毫秒。
我们程序的效率
我们的代码之所以如此快速,是因为救生员的数量非常少——最多只有 100 名。如果救生员的数量很多,那么我们的代码就无法在时间限制内解决问题。如果只有几百名救生员,应该没问题。如果有 3,000 或 4,000 名救生员,我们也许还能勉强完成。再多的话,代码就太慢了。例如,如果有 5,000 名救生员,我们可能无法按时完成。我们需要设计一种新算法,可能是采用比完全搜索更快的方法。
你可能认为 5,000 名救生员是一个非常庞大的数字,既然我们的算法无法处理这么多,也没关系。但其实不然!回想一下第八章中的电子邮件地址问题。那时,我们要处理最多 100,000 个电子邮件地址。再想想同一章中的城市和州问题。那时,我们需要处理最多 200,000 个城市。相比之下,5,000 名救生员其实并不算多。
完全搜索的解决方案通常在输入量较小的情况下效果良好。然而,大规模的测试用例往往是完全搜索解决方案失效的地方。
我们的完全搜索解决方案之所以在大规模测试用例下对救生员问题不太适用,是因为它做了大量重复的工作。假设我们正在解决一个包含 5,000 名救生员的测试用例。我们会开火救生员 0 并调用 num_covered 来确定其余救生员所能覆盖的时间单位数。然后,我们开火救生员 1 并再次调用 num_covered。这次 num_covered 所做的事情和之前调用时差不多。毕竟,情况没有发生太大变化。唯一的不同是救生员 0 回来了,救生员 1 被开火了。其余的 4,998 名救生员和之前一样!但是 num_covered 并不知道这一点。它会重新计算所有救生员。每次我们开火救生员 2、3 等等时,num_covered 都会从头开始做所有工作,完全没有记住它之前做了什么。
请记住,尽管完全搜索算法有其用处,但它们也有局限性。对于我们想要解决的新问题,完全搜索算法是一个有用的起点,即使它最终证明效率太低。这是因为设计该算法的过程可能会加深我们对问题的理解,并激发出新的解决思路。
在接下来的部分,我们将看到另一个可以使用完全搜索的方法来解决的问题。
概念检查
以下版本的 num_covered 是否正确?
def num_covered(intervals, fired):
"""
intervals is a list of lifeguard intervals;
each interval is a [start, end] list.
fired is the index of the lifeguard to fire.
Return the number of time units covered by all lifeguards
except the one fired.
"""
covered = set()
intervals.pop(fired)
for interval in intervals:
for j in range(interval[0], interval[1]):
covered.add(j)
return len(covered)
A. 是
B. 否
答案:B. 这个函数将被解雇的救生员从救生员列表中移除。这样是不允许的,因为文档字符串中没有说明该函数会修改列表。使用这个版本的函数时,我们的程序会在多个测试用例中失败,因为救生员信息会随着时间丢失。例如,当我们测试解雇救生员 0 时,救生员 0 会从列表中被移除。之后,当我们测试解雇救生员 1 时,遗憾的是救生员 0 已经消失了!如果你想使用一个版本的函数,其中被解雇的救生员会从列表中移除,你需要操作列表的副本,而不是原始列表。
问题#22:滑雪山丘
有时候,问题描述会明确指出我们应该在完全搜索的解决方案中搜索什么。例如,在救生员问题中,我们被要求解雇一名救生员,所以尝试解雇每一名救生员是有意义的。而其他时候,我们需要更有创意地确定应该搜索什么内容。当你阅读下一个问题时,想一想在完全搜索的解决方案中,你会搜索什么。
这是 USACO 2014 年 1 月的铜奖比赛题目:滑雪道设计。
挑战
农民约翰的农场上有 n 座山丘,每座山丘的高度在 0 到 100 之间。他希望将他的农场注册为滑雪训练营。
只有当最高山丘和最低山丘之间的高度差不超过 17 时,一块农场才能注册为滑雪训练营。因此,农民约翰可能需要增加一些山丘的高度并减少其他山丘的高度。他只能以整数值改变山丘的高度。
改变一座山丘的高度* x *单位的成本是 x²。例如,将一座山丘从高度 1 改为高度 4 的成本是(4 – 1)² = 9。
确定农民约翰需要支付的最小金额,以改变山丘的高度,从而能够将他的农场注册为滑雪训练营。
输入
从名为skidesign.in的文件中读取输入。
输入包括以下几行:
-
包含整数 n 的一行,表示农场上的山丘数量。n 的值介于 1 和 1000 之间。
-
n 行,每行给出一座山丘的高度。每个高度是介于 0 和 100 之间的整数。
输出
将输出写入名为skidesign.out的文件。
输出农民约翰需要支付的最小金额,以改变山丘的高度。
每个测试用例的时间限制为四秒。
探索一个测试用例
让我们看看能否将从 Lifeguards 中学到的内容应用到这个问题中。为了解决 Lifeguards 问题,我们分别试图解雇每个救生员,找出应该解雇的救生员。要解决滑雪山丘问题,也许我们能对每个山丘做类似的处理?例如,也许我们可以将每个山丘的高度作为允许高度范围的低端?
我们将使用以下测试用例进行尝试:
4
23
40
16
2
这四个山丘中的最小高度是 2,最大高度是 40。40 和 2 之间的差是 38,大于 17。Farmer John 需要支付费用来修正这些山丘!
第一个山丘的高度是 23。如果我们将 23 作为范围的低端,那么高端就是 23 + 17 = 40。我们需要计算将所有山丘都带入范围 23–40 的成本。有两个山丘不在这个范围内,分别是高度为 16 和 2 的山丘。将它们提升到高度 23 的成本是 (23 – 16)² + (23 – 2)² = 490。490 的成本仍然是需要打破的成本。
第二个山丘的高度是 40。这个范围的高端是 40 + 17 = 57,因此我们需要将所有山丘都包含在 40–57 的范围内。其他三个山丘不在这个范围内,因此每个山丘都会增加总成本。这个总成本是 (40 – 23)² + (40 – 16)² + (40 – 2)² = 2,309。这个值大于 490,我们当前的最小成本,所以 490 仍然是要打破的成本。(记住,在这个问题中,我们的目标是最小化 Farmer John 的成本,而在 Lifeguards 中,我们的目标是最大化覆盖率。)
第三个山丘的高度是 16,这给我们提供了范围 16–33。有两个山丘不在这个范围内,分别是高度为 40 和 2 的山丘。因此,这个范围的总成本是 (40 – 33)² + (16 – 2)² = 245。新的最小成本是 245!
第四个山丘的高度是 2,这给我们提供了范围 2–19。如果你计算这个范围的成本,你应该得到 457 的成本。
我们使用这个算法得到的最小成本是 245。245 是答案吗?我们完成了吗?
不对,完全不对!结果显示,最小成本是 221。我们有两个范围可以得到这个最小成本:12–29 和 13–30。没有一个山丘的高度是 12。同样,也没有一个山丘的高度是 13。因此,我们不能将山丘的高度作为范围的可能低端。
想想看,一个正确的完整搜索算法应该是什么样子,能够保证不会漏掉任何范围。
这里有一个计划,保证能帮我们得到正确的答案。我们从计算范围 0–17 的成本开始。然后我们计算范围 1–18 的成本。接着是 2–19。然后是 3–20,再是 4–21,依此类推。我们逐个测试每个可能的范围,并记住我们得到的最小成本。我们测试的范围与山丘的高度无关。由于我们测试了所有可能的范围,因此不可能错过找到最优解。
我们应该测试哪些范围?要测试多高的范围?我们应该测试范围 50–67 吗?是的。那范围 71–88 呢?再次是的。那 115–132 呢?不!不是那个。
我们要检查的最后一个区间是 100–117。原因在于问题描述中有一个保证,即任何山丘的高度最多为 100。
假设我们算出了区间 101–118 的成本。即使不知道山丘的高度,我们也能确定这个区间内没有任何山丘。毕竟山丘的最大高度是 100,而我们的区间从 101 开始。现在将我们的区间从 101–118 滑动到 100–117。这个 100–117 区间的成本比 101–118 低!这是因为 100 比 101 离山丘更近。例如,考虑一座高度为 80 的山丘。将这座山丘的高度提高到 101,需要花费 21² = 441,但将其提高到 100 只需要 20² = 400。由此可见,101–118 不能是最优的区间,尝试它是没有意义的。
类似的逻辑也解释了为什么尝试更高的区间,比如 102–119、103–120 等,毫无意义。我们总是可以将这些区间滑动下来,从而减少其成本。
总结来说,我们将测试正好 101 个区间:0–17、1–18、2–19,以此类推,一直到 100–117。我们会记住最优区间的成本。让我们开始吧!
解决问题
我们将分两步来解决问题,就像解决 Lifeguards 问题时那样。我们首先写一个函数来计算单个区间的成本。然后编写一个主程序,为每个区间调用该函数一次。
计算一个区间的成本
清单 9-3 给出了计算给定区间成本的函数代码。
MAX_DIFFERENCE = 17
MAX_HEIGHT = 100
def cost_for_range(heights, low, high):
"""
heights is a list of hill heights.
low is an integer giving the low end of the range.
high is an integer giving the high end of a range.
Return the cost of changing all heights of hills to be
between low and high.
"""
cost = 0
❶ for height in heights:
❷ if height < low:
❸ cost = cost + (low - height) ** 2
❹ elif height > high:
❺ cost = cost + (height - high) ** 2
return cost
清单 9-3:求解某一特定区间
我已经包含了我们稍后会用到的两个常量。MAX_DIFFERENCE 常量记录了最高山丘和最低山丘之间允许的最大高度差。MAX_HEIGHT 常量记录了山丘的最大高度。
现在让我们来看看 cost_for_range 函数。它接受一个山丘高度的列表和一个由低端和高端指定的目标区间。它返回将山丘高度调整到目标区间所需的成本。我建议你在 Python 解释器中输入这个函数的代码,以便在继续之前进行测试。
该函数遍历每个山丘的高度 ❶,计算将该山丘调整到目标区间所需的成本。我们需要考虑两种情况。首先,当前山丘的高度可能低于 low,即超出了下限 ❷。表达式 low - height 给出我们需要为这座山丘增加的高度,我们将这个结果平方来得到成本 ❸。其次,当前山丘的高度可能高于 high,即超出了上限 ❹。表达式 height - high 给出我们需要为这座山丘减少的高度,我们将这个结果平方来得到成本 ❺。请注意,如果山丘的高度已经在低高区间内,我们不会做任何操作。遍历完所有山丘后,我们返回总成本。
主程序
我们程序的主要部分在 清单 9-4 中。它使用 cost_for_range 函数来确定每个范围的成本。确保在这段代码之前输入我们的 cost_for_range 函数(清单 9-3),以便完整解决问题。
input_file = open('skidesign.in', 'r')
output_file = open('skidesign.out', 'w')
n = int(input_file.readline())
heights = []
for i in range(n):
heights.append(int(input_file.readline()))
❶ min_cost = cost_for_range(heights, 0, MAX_DIFFERENCE)
❷ for low in range(1, MAX_HEIGHT + 1):
result = cost_for_range(heights, low, low + MAX_DIFFERENCE)
if result < min_cost:
min_cost = result
output_file.write(str(min_cost) + '\n')
input_file.close()
output_file.close()
清单 9-4:主程序
我们首先读取山丘的数量,然后将每个高度读入 heights 列表中。
我们使用 min_cost 变量来记录迄今为止发现的最小成本。我们将 min_cost 设置为范围 0–17 的成本 ❶。然后,在一个范围 for 循环 ❷ 中,我们尝试其他范围的成本,每当找到更小的成本时,就更新 min_cost。当这个循环结束时,我们输出我们找到的最小成本。
现在是时候将我们的代码提交给评审了。我们的完全搜索解决方案应当能够在时间限制内良好地解决问题。
在下一个问题中,我们将看到一个示例,其中直接的完全搜索解决方案效率不足。
概念检查
这是对 清单 9-4 中代码的提议更改。请看这一行:
for low in range(1, MAX_HEIGHT + 1):
并将其更改为以下内容:
for low in range(1, MAX_HEIGHT - MAX_DIFFERENCE + 1):
代码仍然正确吗?
A. 是
B. 否
答案:A。代码现在检查的最后一个范围是 83–100,所以我们必须证明我们不再检查的范围——84–101、85–102 等——并不重要。
考虑范围 84–101。如果我们可以证明范围 83–100 至少与 84–101 一样好,那么我们就没有理由检查范围 84–101。
范围 84–101 包括高度 101。但是,这毫无意义:最高的山丘高度是 100,所以 101 的高度就不需要存在了。我们可以去掉 101,而不会使范围变差。如果去掉它,剩下的范围是 84–100。哈——但 100–84 只有 16,而我们允许的差值是 17。所以我们可以将范围从左边扩展一个单位,得到 83–100 的范围。显然,这样扩大范围不会让范围变差,甚至可能使范围更好,因为它现在距离任何高度为 83 或更低的山丘更近了。
我们从范围 84–101 开始,并证明范围 83–100 至少与之同样有效。我们可以对范围 85–102、86–103 等做相同的推理。没有必要去考虑比 83–100 更大的范围!
在继续之前,你可以尝试解决“章节练习”中的第 1 和第 2 题,见 第 263 页。
问题 #23:牛球赛
为了结束这一章,我选择了一个我们需要提升算法设计技能的题目,超越完全搜索的范畴。当你阅读这个问题时,请注意输入并不多。这通常意味着完全搜索算法的有效性。但这一次并非如此,因为这种算法需要在输入中进行大量搜索。困难的关键在于有太多嵌套循环。为什么嵌套循环在这里会给我们带来麻烦?我们能做些什么呢?继续阅读!
这是 USACO 2013 年 12 月铜级竞赛题目“牛球赛”。
挑战
农场主约翰有 n 只牛。它们排成一排,每只牛处在一个独特的位置。它们正在玩传球棒球的游戏。
农场主约翰正在观察这些牛的举动。他观察到牛 x 将球投给其右侧的牛 y,然后牛 y 又将球投给其右侧的牛 z。他还知道,第二次投球的距离至少是第一次投球的距离,并且最多是第一次投球距离的两倍。(例如,如果第一次投球的距离是 5,那么第二次投球的距离至少是 5,最多是 10。)
确定满足农场主约翰观察条件的 (x, y, z) 牛三元组的数量。
输入
从名为 baseball.in 的文件中读取输入。
输入包含以下几行:
-
一行包含 n,即牛的数量。n 的值在 3 到 1,000 之间。
-
n 行,每行给出一只牛的位置。所有位置都是唯一的,且每个位置都在 1 到 100,000,000 之间。
输出
将输出写入名为 baseball.out 的文件。
输出满足农场主约翰观察条件的牛三元组的数量。
每个测试用例的时间限制为四秒。
使用三重嵌套循环
我们可以使用三重嵌套循环来考虑所有可能的三元组。我们先来看一下代码,然后再讨论其效率。
代码
在 第三章中的“嵌套”一节里,我们学到了如何使用两重嵌套循环遍历所有的值对。这样做的代码如下所示:
>>> lst = [1, 9]
>>> for num1 in lst:
... for num2 in lst:
... print(num1, num2)
...
1 1
1 9
9 1
9 9
我们可以通过使用三重嵌套循环,类似于这样遍历所有的三元组:
>>> for num1 in lst:
... for num2 in lst:
... for num3 in lst:
... print(num1, num2, num3)
...
1 1 1
1 1 9
1 9 1
1 9 9
9 1 1
9 1 9
9 9 1
9 9 9
使用三重嵌套循环的方式为我们解决牛棒球问题提供了一个起点。对于每个三元组,我们可以检查它是否符合农场主约翰的观察条件。具体代码见 列表 9-5。
input_file = open('baseball.in', 'r')
output_file = open('baseball.out', 'w')
n = int(input_file.readline())
positions = []
for i in range(n):
❶ positions.append(int(input_file.readline()))
total = 0
❷ for position1 in positions:
❸ for position2 in positions:
first_two_diff = position2 - position1
❹ if first_two_diff > 0:
low = position2 + first_two_diff
high = position2 + first_two_diff * 2
❺ for position3 in positions:
if position3 >= low and position3 <= high:
total = total + 1
output_file.write(str(total) + '\n')
input_file.close()
output_file.close()
列表 9-5:使用三重for循环
我们将所有牛的位置读取到 positions 列表❶中。然后,我们用一个 for 循环❷遍历列表中的所有位置。对于这些位置中的每一个,我们用一个嵌套的 for 循环❸遍历列表中的所有位置。此时,position1 和 position2 分别代表列表中的两个位置。我们需要一个第三个嵌套循环,没错,但还不急。我们首先需要计算 position1 和 position2 之间的差值,因为这决定了我们要查找的 position3 的范围。
根据题目描述,我们要求 position2 在 position1 的右侧。如果满足这一条件❹,我们就计算 position3 的范围,分别用 low 和 high 来存储。例如,如果 position1 是 1,position2 是 6,那么我们会计算 6 + 5 = 11 作为 low,并计算 6 + 5 * 2 = 16 作为 high。接着,我们用第三个嵌套的 for 循环❺遍历列表,寻找位于 low 和 high 之间的位置。对于每一个符合条件的 position3,我们就将总数加 1。
跟随三个嵌套的循环,我们计算出了三元组的总数。最后,我们将这个数字输出到输出文件。
让我们在一个小的测试用例上运行程序,确保没有出现奇怪的情况。测试用例如下:
7
16
14
23
18
1
6
11
这个测试用例的正确答案是 11。满足条件的 11 个三元组如下:
-
14, 16, 18
-
14, 18, 23
-
1, 6, 16
-
1, 6, 14
-
1, 6, 11
-
1, 11, 23
-
6, 14, 23
-
6, 11, 16
-
6, 11, 18
-
11, 16, 23
-
11, 14, 18
好消息:我们的程序对于这个测试用例输出了11!它之所以能够输出这个结果,是因为程序最终找到了每个满足条件的三元组。例如,在某个时刻,position1会是14,position2会是16,position3会是18。这个三元组满足距离要求,因此程序会将其计入总数。不要担心,当position1是18,position2是16,position3是14时会发生什么。我们肯定不希望将那个三元组计入,因为这些投掷并没有朝着正确的方向进行。不过没关系:if语句❹会防止这些三元组被处理。
我们的程序是正确的。但正如你在提交给判题系统后看到的,它的效率不够高。对于这个问题以及许多竞争编程问题,前几个测试用例都比较小——只有几只牛、几位救生员或几座滑雪山。我们的程序应该能够在规定时间内解决这些问题。剩下的测试用例则测试我们的程序是否能处理接近最大输入量的情况。我们的程序无法在规定时间内解决这些问题,速度太慢了。
我们程序的效率
为了理解为什么我们的程序如此缓慢,我们可以考虑一下它必须检查的三元组数量。回想一下我们刚才研究过的测试用例,那里有 7 只牛。我们的程序将检查多少个三元组呢?对于第一只牛,有七个选择:16、14、23 等等。第二只牛也有七个选择,第三只牛也有七个选择。将这些数相乘,我们可以得出程序检查了 7 * 7 * 7 = 343 个三元组。
如果我们有 8 只牛,而不是 7 只呢?那么程序将检查 8 * 8 * 8 = 512 个三元组。
我们可以给出一个表达式,来表示适用于任意数量牛的三元组数。假设牛的数量是n;它可以是 7、8、50、1000 等等,具体取决于测试用例。然后我们可以说,程序检查的三元组数量是n * n * n,即n³。
我们可以替换任意数量的牛为n来确定我们检查的三元组数量。例如,我们可以验证 7 只牛的三元组数量是 7³ = 343,而 8 只牛的三元组数量是 8³ = 512。这些数字——343 和 512——是微不足道的。任何计算机检查这些三元组都不会超过几毫秒。作为保守估计,你可以认为一个 Python 程序每秒钟大约能够检查或处理 500 万项任务。这个问题的时间限制是每个测试用例 4 秒,因此我们大约能检查 2000 万个三元组。
让我们用更大的数字代替n,看看会发生什么。对于 50 头牛,我们有 50³ = 125,000 个三元组。没问题:检查 125,000 个东西对今天的计算机来说不算什么。对于 100 头牛,我们有 100³ = 1,000,000 个三元组。同样,没问题。我们可以在不到一秒钟的时间内检查一百万个东西。对于 200 头牛,我们有 200³ = 8,000,000 个三元组。我们在四秒钟内还行,但我希望你已经开始有些担心了。三元组的数量增长得相当快,而我们只考虑了 200 头牛。记住,我们需要支持最多 1,000 头牛。
对于 400 头牛,我们有 400³ = 64,000,000 个三元组。这个数量太多,四秒钟内处理不过来。更糟的是,让我们试试 1,000 头牛,这是我们可能遇到的最大值。对于 1,000 头牛,我们有 1,000³ = 1,000,000,000 个三元组。那就是十亿。不行。我们永远不可能在四秒钟内检查这么多三元组。我们需要让程序更加高效。
先排序
排序在这里很有帮助。让我们看看如何使用排序,并讨论我们最终解法的效率。
代码
我们的牛的位置可以按任何顺序排列——从问题描述中并没有保证它们是排序的。不幸的是,这导致我们的程序会检查许多根本不可能满足要求的三元组。例如,检查三元组 18、16、14 是没有意义的,因为这些数字不是按递增顺序排列的。如果我们一开始就对牛的位置进行排序,那么就可以避免检查这些不按顺序的三元组。
排序还有另一个好处。假设position1表示某个牛的位置,position2表示另一个牛的位置。对于这对位置,我们知道我们关心的position3的最小值和最大值。我们可以利用位置已排序的事实来减少我们需要检查的值的数量。在继续之前,想一想为什么会这样。我们如何利用位置已经排序这一事实,来查看更少的值呢?
准备好后,查看 Listing 9-6,其中有使用排序的代码。
input_file = open('baseball.in', 'r')
output_file = open('baseball.out', 'w')
n = int(input_file.readline())
positions = []
for i in range(n):
positions.append(int(input_file.readline()))
❶ positions.sort()
total = 0
❷ for i in range(n):
❸ for j in range(i + 1, n):
first_two_diff = positions[j] - positions[i]
low = positions[j] + first_two_diff
high = positions[j] + first_two_diff * 2
left = j + 1
❹ while left < n and positions[left] < low:
left = left + 1
right = left
❺ while right < n and positions[right] <= high:
right = right + 1
❻ total = total + right - left
output_file.write(str(total) + '\n')
input_file.close()
output_file.close()
Listing 9-6: 使用排序
在我们开始查找三元组之前,先对位置进行排序❶。
我们的第一个循环使用循环变量i ❷遍历所有位置。这次是一个范围for循环,而不是一个普通的for循环,这样我们就能跟踪当前的索引。这样做很有用,因为我们可以使用i + 1的值作为第二个循环的起始索引❸。这样,第二个循环就不会浪费时间查看第一个位置左边的那些位置。
接下来,我们计算第三个位置的值范围的低端和高端。
我们可以通过找到适合的第三个位置的左右边界来增加total,而不是每次找到合适的第三个位置时就将total加 1。我们之所以能够这样做,是因为位置列表已经排序。我们通过while循环找到每个边界。第一个while循环找到左边界❹。它会一直执行,直到位置大于等于low。当它完成时,left将是第一个大于或等于low的索引。第二个while循环找到右边界❺。它会一直执行,直到位置小于等于high。当它完成时,right将是第一个大于high的索引。从left到right之间的每个位置(不包括right)都可以作为包含索引i和j的三元组中的第三个位置。我们通过right - left将这些位置加到total中❻。
本程序中的两个while循环相当复杂。让我们通过一个例子来确保我们完全理解它们的作用。我们将使用以下位置列表;这些位置与我们在前一节中使用的相同,只是经过了排序:
[1, 6, 11, 14, 16, 18, 23]
假设i为1,j为2,那么预期三元组中的两个位置分别是6和11。因此,第三个位置应该是大于或等于16且小于或等于21的索引。第一个while循环将left设置为4,即第一个大于或等于16的位置的索引。第二个while循环将right设置为6,即第一个大于21的位置的索引。从right中减去left,得到 6 – 4 = 2,这意味着有两个三元组涉及到位置6和11。在继续之前,我建议你验证一下这些while循环在“特殊”情况下是否能够正常工作,比如没有合适的第三个位置,或者只有一个合适的第三个位置时。
在这一节中我们取得了显著进展。我们的代码无疑比我们在清单 9-5 中的代码更高效。然而,它仍然不够高效。如果你将其提交给评测系统,你会发现它并没有比上次更进一步。它仍然会在大多数测试用例上超时。
我们程序的效率
我们程序中的问题是找到第三个位置仍然需要很长时间。这些while循环仍然存在一些低效之处。我可以通过一个新的位置列表来演示这一点,即位置从 1 到 32 的列表。
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]
让我们关注当i为0,j为7时;这分别是位置1和位置8。对于第三个位置,我们需要寻找大于或等于 15 且小于或等于 22 的位置。为了找到15,第一个while循环从右向左扫描,一次一个位置。它依次扫描9,然后是10,然后是11,然后是12,然后是13,然后是14,最后找到15。然后第二个while循环接管,进行类似的大量扫描,一次一个位置,一直扫描到找到23。
每个while循环实现了我们所说的线性搜索。线性搜索是一种逐个查找集合中每个值的技术。这是一项繁重的工作,得逐一扫描所有这些值!而且对于不同的i和j值,工作量也差不多。例如,试着追踪一下当i为0,j为8,或者当i为1,j为11时会发生什么。
我们如何改进这个方法呢?如何避免扫描一大段列表,寻找合适的left和right索引?
假设我给你一本有一千个排序整数的书,每行一个整数。我让你找出第一个大于或等于 300 的整数。你会逐个查看这些数字吗?会先看1,然后是3,然后是4,然后是7吗?路还很长——接下来你会看8,然后是12,然后是17?大概不会吧!如果你直接翻到书的中间会快得多。也许你在中间找到了数字450。既然450大于300,你就知道数字在书的前半部分。它不可能在后半部分,因为那里的数字比450还要大。你通过检查一个数字就减少了一半的工作量!你现在可以在书的前半部分重复这个过程,从书的开头和中间翻找。你可能在那找到数字200。现在你知道300在后面的某一页,可能是在书的第二四分之一部分。你可以继续重复这个过程,直到找到300——而且这不会花太多时间。这个技巧——反复将问题一分为二——就叫做二分查找。它惊人地快速,比逐个查找的线性搜索方法要快得多。Python 有一个二分查找函数,它将为牛仔棒球项目画上圆满的句号。不过,这个函数在一个叫做模块的东西里;我们需要先讨论模块。
Python 模块
一个模块是一个自包含的 Python 代码集合。一个模块通常包含几个我们可以调用的函数。
Python 提供了各种各样的模块,可以用来为我们的程序添加功能。有处理随机数、日期和时间、统计、电子邮件、网页、音频文件等的模块,种类繁多,甚至可以专门写一本书来讲解它们!如果 Python 中没有你需要的模块,你还可以下载额外的模块。
本节我将专注于一个模块——random模块。我们将使用它来了解模块的使用方法。然后,我们就能为下一节中的二分查找模块做好准备。
你是否曾经想过,人们是如何制作那些包含随机事件的计算机游戏的?也许这是一个抽卡的游戏,或者是一个掷骰子的游戏,或者是敌人随机生成的游戏。关键在于使用随机数。Python 通过其random模块为我们提供了随机数生成的功能。
在我们使用模块中的内容之前,我们必须导入它。导入整个模块的一种方法是使用import关键字,像这样:
>>> import random
里面有什么?要查找,可以使用dir(random):
>>> dir(random)
[stuff to ignore
'betavariate', 'choice', 'choices', 'expovariate',
'gammavariate', 'gauss', 'getrandbits', 'getstate',
'lognormvariate', 'normalvariate', 'paretovariate',
'randint', 'random', 'randrange', 'sample', 'seed',
'setstate', 'shuffle', 'triangular', 'uniform',
'vonmisesvariate', 'weibullvariate']
random模块提供的一个函数是randint。我们传递给它一个范围的低端和高端,Python 会返回该范围内的一个随机整数(包括两个端点)。
然而,我们不能像普通函数那样直接调用它。如果尝试这样做,会出现错误:
>>> randint(2, 10)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'randint' is not defined
我们需要告诉 Python,randint函数位于random模块中。为此,我们需要在randint前加上模块名和一个点,像这样:
>>> random.randint(2, 10)
7
>>> random.randint(2, 10)
10
>>> random.randint(2, 10)
6
要获取有关randint函数的帮助,可以输入help(random.randint):
>>> help(random.randint)
Help on method randint in module random:
randint(a, b) method of random.Random instance
Return random integer in range [a, b], including both end points.
random模块中另一个有用的函数是choice。我们传递给它一个序列,它会随机返回其中的一个值:
>>> random.choice(['win', 'lose'])
'lose'
>>> random.choice(['win', 'lose'])
'lose'
>>> random.choice(['win', 'lose'])
'win'
如果我们经常使用模块中的少量函数,输入模块名和点每次都显得很麻烦。还有另一种导入这些函数的方法,可以让我们像调用任何其他非模块函数一样调用它们。以下是我们只导入randint函数的方式:
>>> from random import randint
现在我们可以直接调用randint,而不需要加上random.:
>>> randint(2, 10)
10
如果我们需要randint和choice,我们可以同时导入它们:
>>> from random import randint, choice
本书中我们不会这么做,但我们可以创建自己的模块,包含我们喜欢的任何函数。例如,如果我们设计了一些与玩游戏相关的 Python 函数,我们可以将它们都放在一个名为game_functions.py的文件中。然后,我们可以使用import game_functions导入该模块,并访问其中的函数。
本书中我们编写的 Python 程序并不是为了作为模块被导入的。原因是它们在开始运行时就会读取输入,而模块不应该这样做。一个模块应该等待函数被调用之后再执行任何操作。random模块是一个表现良好的模块:它只有在我们请求时才开始给我们提供随机内容。
bisect 模块
现在我们准备好进行二分查找的练习了。在示例 9-6 中,我们有两个while循环。它们比较慢,所以我们想要去掉它们。为此,我们将用一个二分查找函数来替换每个循环:第一个while循环用bisect_left,第二个用bisect_right。
这两个函数都在bisect模块中。让我们导入它们:
>>> from bisect import bisect_left, bisect_right
让我们先讨论bisect_left。我们通过提供一个从小到大排序的列表和一个值x来调用它。它返回列表中第一个大于或等于x的左侧值的索引。
如果值在列表中,我们会得到它最左侧出现位置的索引:
>>> bisect_left([10, 50, 80, 80, 100], 10)
0
>>> bisect_left([10, 50, 80, 80, 100], 80)
2
如果值不在列表中,我们会得到第一个大于该值的索引:
>>> bisect_left([10, 50, 80, 80, 100], 15)
1
>>> bisect_left([10, 50, 80, 80, 100], 81)
4
如果我们查找的值大于列表中的所有值,我们将得到列表的长度:
>>> bisect_left([10, 50, 80, 80, 100], 986)
5
让我们在本章前面“首先排序”的七个位置的列表上使用bisect_left。我们将找到第一个大于或等于 16 的左侧位置的索引:
>>> positions = [1, 6, 11, 14, 16, 18, 23]
>>> bisect_left(positions, 16)
4
完美:这正是我们需要的,用来替换清单 9-6 中第一个while循环的内容。
为了替换第二个while循环,我们将使用bisect_right而不是bisect_left。我们像调用bisect_left一样调用bisect_right:传入一个排序好的列表和一个值x。它不会返回列表中左侧第一个大于或等于x的值的索引,而是返回第一个大于x的值的索引。
让我们比较一下bisect_left和bisect_right。对于在列表中的值,bisect_right返回的索引大于bisect_left返回的索引:
>>> bisect_left([10, 50, 80, 80, 100], 10)
0
>>> bisect_right([10, 50, 80, 80, 100], 10)
1
>>> bisect_left([10, 50, 80, 80, 100], 80)
2
>>> bisect_right([10, 50, 80, 80, 100], 80)
4
对于不在列表中的值,bisect_left和bisect_right返回相同的索引:
>>> bisect_left([10, 50, 80, 80, 100], 15)
1
>>> bisect_right([10, 50, 80, 80, 100], 15)
1
>>> bisect_left([10, 50, 80, 80, 100], 81)
4
>>> bisect_right([10, 50, 80, 80, 100], 81)
4
>>> bisect_left([10, 50, 80, 80, 100], 986)
5
>>> bisect_right([10, 50, 80, 80, 100], 986)
5
让我们在本章前面“首先排序”的七个位置的列表上使用bisect_right。我们将找到第一个大于 21 的左侧位置的索引:
>>> positions = [1, 6, 11, 14, 16, 18, 23]
>>> bisect_right(positions, 21)
6
就是这样:这正是我们可以用来替换清单 9-6 中第二个while循环的内容。
二分查找的惊人速度,通过这些小示例是难以体会的。是时候展示真正的速度了。我们将在长度为1000000的列表中,进行一百万次查找,寻找最右侧的值。运行这段代码时不要移开眼睛,你可能错过它。
>>> lst = list(range(1, 1000001))
>>> for i in range(1000000):
... where = bisect_left(lst, 1000000)
...
在我的电脑上,这大约需要一秒钟。你可能会想,如果用列表的index方法代替二分查找会发生什么。如果你尝试这样做,你将真的等上好几个小时才等到代码运行完成。这是因为index像in操作符一样,进行的是线性搜索(有关此更多内容,请参见第八章中的“搜索列表的效率”)。它没有保证列表是排序的,因此无法执行快速的二分查找。它必须逐个比对每个值,找到与我们要查找的值相等的项。如果你有一个排序好的列表,并且想要在其中查找值,二分查找是无法阻挡的。
解决问题
我们准备好使用二分查找解决牛棒球问题了。查看清单 9-7 以获取代码。
❶ from bisect import bisect_left, bisect_right
input_file = open('baseball.in', 'r')
output_file = open('baseball.out', 'w')
n = int(input_file.readline())
positions = []
for i in range(n):
positions.append(int(input_file.readline()))
positions.sort()
total = 0
for i in range(n):
for j in range(i + 1, n):
first_two_diff = positions[j] - positions[i]
low = positions[j] + first_two_diff
high = positions[j] + first_two_diff * 2
❷ left = bisect_left(positions, low)
❸ right = bisect_right(positions, high)
total = total + right - left
output_file.write(str(total) + '\n')
input_file.close()
output_file.close()
清单 9-7:使用二分查找
首先,我们从bisect模块中导入bisect_left和bisect_right函数,以便可以调用它们❶。与清单 9-6 相比,唯一的不同之处是我们现在使用bisect_left❷和bisect_right❸,而不再使用while循环。
如果你现在将我们的代码提交给评测系统,你应该能够在时间限制内通过所有测试用例。
本节中的思路弧线是解决难题时常见的思路。我们可能从一个正确但效率较低的完全搜索解决方案开始,然而,这个方案也过于慢,无法满足评测系统的时间限制。然后我们进行改进,最终抛弃完全搜索,转向更精细的方法。
概念检查
假设我们从清单 9-7 开始,并用bisect_left替代bisect_right。也就是说,我们修改这一行:
right = bisect_right(positions, high)
然后我们将其改为以下内容:
right = bisect_left(positions, high)
程序仍然能产生正确的答案吗?
A. 它始终产生正确的答案,就像之前一样。
B. 它有时产生正确答案,具体取决于测试用例。
C. 它从不产生正确的答案。
答案:B。有些测试用例中,修改后的代码确实会产生正确的答案。以下是一个例子:
3
2
4
9
正确的答案是 0,这也是我们程序产生的结果。
但要小心,因为还有其他一些测试用例,修改后的代码会产生错误的答案。以下是一个反例:
3
2
4
8
正确答案是 1,但我们程序产生了0。当i是0并且j是1时,程序应该将left设置为2,并将right设置为3。不幸的是,使用bisect_left会导致right被设置为2,因为索引 2 的位置是大于或等于8的最左侧位置。
看到这个反例,你可能会惊讶地发现,其实是有一种方法可以使用bisect_left而不是bisect_right。为了做到这一点,我们需要改变在调用bisect_left时要搜索的内容。如果你感兴趣,不妨试试看!
总结
在本章中,我们学习了完全搜索算法,这些算法通过遍历所有选项来找到最佳解。为了确定我们应该解雇的救生员,我们尝试解雇每一个救生员并选择最合适的一个。为了确定修复滑雪坡的最低成本,我们尝试所有有效的区间并选择最佳的一个。为了确定相关的牛的三元组数量,我们检查每一个三元组并添加符合要求的三元组。
有时候,完全搜索算法本身就足够高效。我们用简单的完全搜索代码解决了救生员和滑雪坡的问题。然而,有时我们需要让完全搜索算法更加高效。我们在解决牛棒球问题时就做到了这一点,通过将完全搜索的while循环替换为更快速的二分查找。
程序员和计算机科学家如何讨论效率?你怎么知道一个算法是否足够高效?如何避免实现那些太慢的算法?第十章 等待着你。
章节练习
这里有一些练习供你尝试。对于每个练习,使用完全搜索。如果你的解决方案效率不够高,思考如何在保证正确答案的前提下提高效率。
对于每个练习,务必核实问题来源的判题系统:有些是在 DMOJ 判题系统上,其他则是在 USACO 判题系统上。
-
USACO 2019 年 1 月青铜奖竞赛问题 Shell Game
-
USACO 2016 年美国公开赛青铜奖竞赛问题 Diamond Collector
-
DMOJ 问题
coci20c1p1,Patkice -
DMOJ 问题
ccc09j2,Old Fishin’ Hole -
DMOJ 问题
ecoo16r1p2,Spindie -
DMOJ 问题
cco96p2,SafeBreaker -
USACO 2019 年 12 月青铜奖竞赛问题 Where Am I
-
USACO 2016 年 1 月青铜奖竞赛问题 Angry Cows
-
USACO 2016 年 12 月银奖竞赛问题 Counting Haybales
-
DMOJ 问题
crci06p3,Firefly
注意事项
Lifeguards 最初来自 USACO 2018 年 1 月的青铜奖竞赛。Ski Hills 最初来自 USACO 2014 年 1 月的青铜奖竞赛。Cow Baseball 最初来自 USACO 2013 年 12 月的青铜奖竞赛。
除了完全搜索外,还有其他类型的算法,如 贪心算法 和 动态规划算法。如果一个问题不能通过完全搜索解决,那么值得思考是否可以使用这些其他类型的算法来解决。
如果你有兴趣了解更多使用 Python 进行的算法相关内容,我推荐 Magnus Lie Hetland 编写的《Python Algorithms》第二版(Apress,2014)。
我还写了一本关于算法设计的书:《Algorithmic Thinking: A Problem-Based Introduction》(No Starch Press,2021)。它采用与本书相同的问题导向格式,因此它的风格和节奏你应该会很熟悉。然而,它使用的是 C 编程语言,而不是 Python 编程语言,因此为了更好地理解这本书,你可能需要先学习一些 C 语言。
在本章中,我们调用了预先存在的 Python 函数来执行二分查找。如果愿意,我们可以编写自己的二分查找代码,而不是依赖这些函数。将列表一分为二,直到找到我们想要的值,这个思路是直观的,但实现这一功能的代码出乎意料地复杂。同样令人惊讶的是,使用二分查找的变种可以解决的广泛问题。我之前提到的书籍《Algorithmic Thinking》中有一整章专门讲解二分查找及其应用。
第十章:大 O 符号与程序效率

在本书的前七章中,我们专注于编写正确的程序:对于任何有效的输入,我们希望程序能够输出期望的结果。然而,除了正确的代码,我们通常还希望代码高效,能够在大量输入下仍然快速运行。你可能在阅读前七章时偶尔遇到过时间超限的错误,但我们正式进入程序效率的讨论是在第八章,当时我们解决了电子邮件地址的问题。我们在那时看到,有时我们需要提高程序的效率,以便它们能在规定的时间内完成。
在本章中,我们首先将学习程序员如何思考和沟通程序效率。然后,我们将研究两个需要编写高效代码的问题:确定围巾的最理想部分和绘制丝带。
对于每个问题,我们会发现最初的想法会导致一个效率不够高的算法。但我们会坚持下去,直到我们为同一个问题设计出一个更快的算法,比之前高效得多。这 exemplifies(体现)了程序员的常见工作流程:首先,提出一个正确的算法;然后,只有在需要时,再让它变得更快。
定时问题
本书中我们解决的每一个竞赛编程问题,都有一个时间限制,规定了我们的程序运行的最长时间。(从第八章开始,我就将时间限制包括在问题描述中,那时我们开始遇到效率成为严重问题的编程题。)如果我们的程序超过时间限制,评测系统将终止程序并给出时间超限的错误提示。时间限制旨在防止过慢的解决方案通过测试用例。例如,可能我们提出了一个完全搜索的解决方案,但问题的作者已经想出了一个更快的解决方案。那个更快的解决方案可能是完全搜索的变体,正如我们在第九章解决《牛的棒球问题》时那样,或者可能是完全不同的方法。不管怎样,时间限制的设置可能使得我们的完全搜索解法无法按时完成。因此,除了保证正确性,我们的程序可能还需要足够快。
我们可以运行程序来探索它是否足够高效。例如,回想一下在第八章中“搜索列表的效率”部分,我们尝试使用列表来解决电子邮件地址的问题。我们运行了使用越来越大的列表的代码,以了解列表操作所需的时间。这种测试可以帮助我们理解程序的效率。如果我们的程序太慢,超过了问题的时间限制,那么我们就知道需要优化当前的代码或找到一种全新的方法。
程序所需的时间取决于运行它的计算机。我们无法得知评测机使用的是什么类型的计算机,但在我们自己的计算机上运行程序仍然有参考价值,因为评测机可能至少和我们的计算机一样快。假设我们在笔记本上运行程序,某个小的测试用例需要 30 秒。如果问题的时间限制是三秒钟,我们可以确定我们的程序明显不够快。
然而,单纯专注于时间限制是有限的。想一想我们在第九章中对牛棒球问题的第一个解法。我们并不需要运行代码来确定它的执行速度。这是因为我们能够通过程序需要执行的工作量来描述程序的效率,就算我们没有真正运行它。例如,在第 253 页的“程序效率”部分,我们提到,对于n头牛,我们的程序会处理n³个牛的三元组。请注意,我们关注的不是程序运行所需的秒数,而是它在处理输入n时所需的工作量。
与运行程序并记录执行时间相比,这种分析方法有显著的优势。以下是五个优点:
执行时间依赖于计算机。对我们的程序进行计时,只能告诉我们程序在某一台计算机上运行需要多长时间。这是非常具体的信息,对于了解程序在其他计算机上的运行表现帮助不大。当你在阅读本书时,可能也注意到,即使在同一台计算机上,程序的执行时间也会有所不同。例如,你可能会在一个测试用例上运行程序,发现它需要三秒钟;然后你再运行同一个测试用例,可能发现它需要两秒半或三秒半。这种差异的原因在于,操作系统正在管理你的计算资源,并根据需要将它们分配给不同的任务。操作系统的决策会影响程序的运行时间。
执行时间依赖于测试用例 对程序进行计时只会告诉我们它在某个测试用例上运行了多长时间。假设我们的程序在一个小型测试用例上运行需要三秒钟。这看起来很快,但关于小型测试用例的真相是:每个合理的解决方案都能很快解决这些问题。如果我让你告诉我 10 个电子邮件地址中有多少个不同的地址,或者在 10 头牛中有多少组三个牛的组合,你可以通过第一个正确的想法迅速解决。真正有趣的是大型测试用例。它们是算法创新得以体现的地方。我们的程序在大型测试用例或巨型测试用例上需要多久?我们不知道。我们还需要在这些测试用例上运行程序。即使我们这么做了,也可能会有特定类型的测试用例导致程序性能较差。我们可能会误以为程序比实际更快。
程序需要实现 我们无法对一个尚未实现的程序进行计时。假设我们正在思考一个问题,并想到一个解决方案。它快吗?尽管我们可以通过实现它来找出答案,但如果能提前知道这个想法是否可能导致一个快速的程序,那将会更好。你不会实现一个你一开始就知道会出错的程序。同样,知道一个程序一开始就会太慢也是很有用的。
计时并不能解释程序的慢速 如果我们发现程序太慢,那么接下来的任务就是设计一个更快的程序。然而,单纯地计时程序并不能让我们深入了解程序为何慢。它只是慢而已。更进一步地,如果我们想到可能的改进方案,还需要实际实现它,以确定是否能提升性能。
执行时间不容易传达 由于许多原因,使用执行时间与他人讨论算法的效率是困难的。执行时间过于具体:它依赖于计算机、操作系统、测试用例、编程语言以及所使用的具体实现。我们必须提供所有这些信息,才能让其他人了解我们算法的效率。
不用担心:计算机科学家已经设计了一种符号来解决计时的这些不足之处。它独立于计算机、独立于测试用例,并且独立于特定的实现。它能指示为什么一个程序会慢。它易于传达。这就是大 O 表示法,接下来就会介绍。
大 O 表示法
大 O 表示法是计算机科学家用来简洁描述算法效率的符号。这里的关键概念是效率类,它告诉你算法的运行速度,或者更确切地说,它告诉你算法做了多少工作。算法越快,做的工作就越少;算法越慢,做的工作就越多。每个算法都属于一个效率类;效率类告诉你该算法在处理必须处理的输入时,所做的工作量。为了理解大 O,我们需要了解这些效率类。我们现在将学习七种最常见的效率类。我们将看到那些做最少工作的算法——你希望你的算法能符合这些效率类。我们还将看到那些做更多工作的算法——这些算法可能会让你遇到“超时限制”错误。
常数时间
最理想的算法是那些随着输入量增加而不增加工作量的算法。无论问题实例如何,这种算法所需要的步骤数大致相同。这样的算法被称为常数时间算法。
这很难相信,对吧?一个做大致相同工作量的算法,无论输入是什么?的确,能用这种算法解决问题是很少见的。但当你能做到时,值得高兴:你无法做得比这更好了。
我们已经通过常数时间算法解决了这本书中的一些问题。回想一下在第二章中,我们需要确定提供的电话号码是否属于推销员。我在这里重新展示了我们在清单 2-2 中的解决方案:
num1 = int(input())
num2 = int(input())
num3 = int(input())
num4 = int(input())
if ((num1 == 8 or num1 == 9) and
(num4 == 8 or num4 == 9) and
(num2 == num3)):
print('ignore')
else:
print('answer')
我们的解决方案做的工作量相同,无论电话号码的四个数字是什么。代码首先读取输入。然后它会与num1、num2、num3和num4做一些比较。如果电话号码属于推销员,我们输出某些内容;如果不属于推销员,我们输出其他内容。没有任何输入能让我们的程序做比这更多的工作。
在第二章的前面,我们解决了“获胜团队”问题。我们也用了常数时间来解决它吗?是的!这里是我们在清单 2-1 中的解决方案:
apple_three = int(input())
apple_two = int(input())
apple_one = int(input())
banana_three = int(input())
banana_two = int(input())
banana_one = int(input())
apple_total = apple_three * 3 + apple_two * 2 + apple_one
banana_total = banana_three * 3 + banana_two * 2 + banana_one
if apple_total > banana_total:
print('A')
elif banana_total > apple_total:
print('B')
else:
print('T')
我们读取输入,计算苹果的总分,计算香蕉的总分,比较这两个总分,并输出一条信息。苹果或香蕉的分数多少并不重要——我们的程序始终做相同量的工作。
等一下——如果苹果队投进了无数个三分球怎么办?难道计算机处理这么巨大的数字不会比处理像 10 或 50 这样的小数字需要更长的时间吗?虽然这是事实,但我们在这里不需要担心这个问题。问题描述中说明每支队伍每种类型的得分最多是 100 次。因此,我们处理的是小数字,可以公平地说计算机可以在固定的步骤数内读取或操作这些数字。一般来说,你可以将几亿以下的数字视为“小”数字。
在大 O 表示法中,我们说一个常数时间算法是 O(1)。这里的 1 并不意味着你只能在常数时间算法中执行一步操作。如果你执行固定次数的步骤,比如 10 步或甚至 10,000 步,它仍然是常数时间。但不要写 O(10) 或 O(10000)—所有常数时间算法都表示为 O(1)。
线性时间
大多数算法不是常数时间算法。相反,它们的工作量取决于输入量。例如,它们处理 1,000 个值时要做的工作比处理 10 个值时要做的工作多。区分这些算法的特点在于输入量与算法所做的工作量之间的关系。
线性时间 算法是指输入量与工作量之间存在线性关系的算法。假设我们在一个包含 50 个值的输入上运行一个线性时间算法,然后再在一个包含 100 个值的输入上运行它。与处理 50 个值相比,算法在处理 100 个值时将大约多做一倍的工作。
作为一个例子,让我们来看一下 第三章中的三杯问题。我们在 清单 3-1 中解决了这个问题,我在这里重现了我们的解决方案:
swaps = input()
ball_location = 1
❶ for swap_type in swaps:
if swap_type == 'A' and ball_location == 1:
ball_location = 2
elif swap_type == 'A' and ball_location == 2:
ball_location = 1
elif swap_type == 'B' and ball_location == 2:
ball_location = 3
elif swap_type == 'B' and ball_location == 3:
ball_location = 2
elif swap_type == 'C' and ball_location == 1:
ball_location = 3
elif swap_type == 'C' and ball_location == 3:
ball_location = 1
print(ball_location)
有一个 for 循环 ❶,它所做的工作量与输入量成线性关系。如果有 5 次交换需要处理,那么循环就迭代 5 次。如果有 10 次交换需要处理,那么循环就迭代 10 次。每次循环迭代都执行固定数量的比较,并可能改变 ball_location 所指的内容。因此,这个算法所做的工作量与交换次数成正比。
我们通常用 n 来表示问题输入的数量。在这里,n 是交换的次数。如果我们需要执行 5 次交换,那么 n 就是 5;如果我们需要执行 10 次交换,那么 n 就是 10。
如果有 n 次交换,那么我们的程序大约做 n 次工作。这是因为 for 循环执行了 n 次迭代,每次迭代都执行固定数量的步骤。我们不关心每次迭代执行多少步骤,只要它是固定数量的。不管算法总共执行了 n 步、10n 步还是 10,000n 步,它都是一个线性时间算法。在大 O 表示法中,我们说这个算法是 O(n)。
在使用大 O 表示法时,我们不包括n前面的数字。例如,一个需要 10n 步骤的算法应该写成 O(n),而不是 O(10n)。这帮助我们集中注意算法是线性时间,而不是线性关系的具体细节。
如果一个算法需要 2n + 8 步——这是什么类型的算法?这仍然是线性时间!原因是,当n足够大时,线性项(2n)将会主导常数项(8)。例如,如果n是 5000,那么 8n 就是 40000。数字 8 相比于 40000 是如此微不足道,以至于我们可以忽略它。在大 O 表示法中,我们忽略除了主导项之外的所有项。
许多 Python 操作执行工作时需要常数时间。例如,向列表添加元素、向字典中添加元素或索引序列或字典都需要常数时间。
但是有些 Python 操作执行工作时需要线性时间。请小心将它们计算为线性时间,而不是常数时间。例如,使用 Python 的input函数读取一个长字符串需要线性时间,因为 Python 必须逐个字符地读取输入行。任何检查字符串中每个字符或列表中每个值的操作也需要线性时间。
如果一个算法读取 n 个值,并且以常数步骤处理每个值,那么它就是线性时间算法。
我们不用走得很远就能看到另一个线性时间算法——我们在第三章中解决的占用空间问题就是另一个例子。我在这里复现了我们在示例 3-3 中的解决方案:
n = int(input())
yesterday = input()
today = input()
occupied = 0
for i in range(len(yesterday)):
if yesterday[i] == 'C' and today[i] == 'C':
occupied = occupied + 1
print(occupied)
我们让 n 为停车位的数量。模式与三杯问题相同:我们读取输入,然后为每个停车位执行常数数量的步骤。
概念检查
在示例 1-1 中,我们解决了单词计数问题。这里是该解决方案的代码。
line = input()
total_words = line.count(' ') + 1
print(total_words)
我们的算法的大 O 效率是多少?
A. O(1)
B. O(n)
答案:B. 很容易认为这个算法是O(1)。毕竟,代码中没有任何循环,看起来算法只执行了三步:读取输入,调用count来计算单词数量,输出单词数量。
但这个算法是 O(n),其中 n 是输入字符的数量。input函数读取输入需要线性时间,因为它必须逐个字符地读取输入。使用count方法也需要线性时间,因为它必须处理字符串中的每个字符以找到匹配项。所以,这个算法执行的工作量是线性的,既有读取输入的线性工作量,也有计数单词的线性工作量。总的来说,这是线性的工作量。
概念检查
在示例 1-2 中,我们解决了圆锥体体积问题。这里是我复现的解决方案:
PI = 3.141592653589793
radius = int(input())
height = int(input())
volume = (PI * radius ** 2 * height) / 3
print(volume)
我们的算法的大 O 效率是多少?(回忆一下,半径和高度的最大值是 100。)
A. O(1)
B. O(n)
答案:A。我们处理的是小数字,因此从输入中读取它们需要常数时间。计算体积也需要常数时间:这只是几个数学操作。因此,我们所做的只是一些常数时间的步骤。总体而言,这是一个常数量的工作。
概念检查
在 Listing 3-4 中,我们解决了数据计划问题。我已经在这里复现了该解决方案:
monthly_mb = int(input())
n = int(input())
excess = 0
for i in range(n):
used = int(input())
excess = excess + monthly_mb - used
print(excess + monthly_mb)
我们的算法的大 O 效率是什么?
A. O(1)
B. O(n)
答案:B。这个算法的模式类似于我们解决“三个杯子”或“占用空间”问题的解决方案,不同之处在于它将读取输入与处理输入交替进行。我们让 n 代表每月的兆字节数值。程序对这些 n 个输入值执行一个常数数量的步骤。因此,这是一个 O(n) 算法。
二次时间
到目前为止,我们讨论了常数时间算法(即随着输入量增加,工作量不变的算法)和线性时间算法(即随着输入量增加,工作量线性增加的算法)。像线性时间算法一样,二次时间 算法随着输入量的增加而做更多的工作;例如,它处理 1,000 个值所做的工作比处理 10 个值时要多。虽然我们可以在相对较大的输入量上使用线性时间算法,但在二次时间算法上,我们将受到更小的输入量限制。接下来我们将看到原因。
典型形式
一个典型的线性时间算法如下所示:
for i in range(n):
<process input i in a constant number of steps>
相比之下,典型的二次时间算法如下所示:
for i in range(n):
for j in range(n):
<process inputs i and j in a constant number of steps>
对于一个包含 n 个值的输入,每个算法处理多少个值?线性时间算法处理 n 个值,每次在 for 循环的迭代中处理一个值。相比之下,二次时间算法在外部 for 循环的每次迭代中处理 n 个值。
在外部 for 循环的第一次迭代中,处理 n 个值(每次迭代处理一个值);在外部 for 循环的第二次迭代中,处理 n 个值(每次迭代处理一个值);依此类推。随着外部 for 循环迭代 n 次,处理的值的总数是 n * n,即 n²。两个嵌套循环,每个循环都依赖于 n,产生了一个二次时间算法。在大 O 记法中,我们说二次时间算法是 O(n²)。
让我们比较一下线性时间算法和二次时间算法的工作量。假设我们处理一个包含 1,000 个值的输入,这意味着 n 等于 1,000。一个需要 n 步的线性时间算法将执行 1,000 步。一个需要 n² 步的二次时间算法将执行 1,000² = 1,000,000 步。一百万比一千多得多。但是谁在乎呢:计算机真的非常非常快,对吧?嗯,是的,对于一个包含 1,000 个值的输入,如果我们使用二次时间算法,可能也没什么大问题。在 第 253 页的《我们程序的效率》一节中,我给出了一个保守的规则,认为我们每秒可以执行大约五百万步。因此,一百万步应该在除最严格的时间限制外都能完成。
但是对于二次时间算法的乐观期待是短暂的。看看当我们将输入值的数量从 1,000 增加到 10,000 时会发生什么。线性时间算法仅需 10,000 步。二次时间算法需要 10,000² = 100,000,000 步。嗯……如果我们使用二次时间算法,计算机的速度就不那么快了。虽然线性时间算法仍然以毫秒为单位运行,但二次时间算法至少要花几秒钟。肯定会超时了,毫无疑问。
概念检查
以下算法的时间复杂度是多少?
for i in range(10):
for j in range(n):
<process inputs i and j in a constant number of steps>
A. O(1)
B. O(n)
C. O(n^(2))
答案:B。这里有两个嵌套循环,所以你可能会直觉地认为这是一个二次时间算法。不过要小心,因为外层 for 循环仅执行 10 次,与 n 的值无关。因此,这个算法的总步骤数是 10n。这里并没有 n^(2),10n 是线性的,就像 n 一样。所以,这是一个线性时间算法,而不是二次时间算法。我们可以将其效率写作 O(n)。
概念检查
以下算法的时间复杂度是多少?
for i in range(n):
<process input i in a constant number of steps>
for j in range(n):
<process input j in a constant number of steps>
A. O(1)
B. O(n)
C. O(n^(2))
答案:B。这里有两个循环,它们都依赖于 n。那么这不就是二次时间复杂度吗?
不!这两个循环是顺序执行的,而不是嵌套的。第一个循环执行 n 步,第二个也执行 n 步,总共是 2n 步。因此,这是一个线性时间算法。
备用形式
当你看到两个嵌套循环,每个循环都依赖于 n 时,可以合理推测你可能面对的是一个二次时间算法。但即便没有这样的嵌套循环,二次时间算法也有可能出现。我们可以在我们解决电子邮件地址问题的第一个解法中找到这样的例子,示例 8-2。我在这里重现了该解法:
# clean function not shown
for dataset in range(10):
n = int(input())
addresses = []
for i in range(n):
address = input()
❶ address = clean(address)
❷ if not address in addresses:
addresses.append(address)
print(len(addresses))
假设 n 是我们在 10 个测试用例中看到的最大电子邮件地址数量。外层 for 循环执行 10 次;内层 for 循环最多执行 n 次。因此,我们最多处理 10n 个电子邮件地址,这在 n 上是线性的。
清理一个电子邮件地址 ❶ 需要恒定的步骤数,所以我们不需要担心这个。但这仍然不是线性时间算法,因为内层 for 循环的每次迭代都需要超过恒定步骤数的操作。具体来说,检查电子邮件地址是否已经在我们的列表中 ❷ 需要与列表中已经存在的电子邮件地址数量成比例的操作,因为 Python 必须遍历列表。这本身就是一个线性时间的操作!所以我们处理了 10n 个电子邮件地址,每个电子邮件地址需要 n 次操作,总共是 10n² 次操作,或者说是二次方时间复杂度的操作。正因为这段代码的二次方时间复杂度,我们才遇到了超时错误,最终我们使用了集合而不是列表。
三次方时间
如果一个循环能导致线性时间,两个嵌套循环能导致二次方时间,那么三个嵌套循环会怎样呢?三个嵌套的循环,每个都依赖于 n,就会导致一个 三次方时间 的算法。在大 O 记法中,我们说一个三次方时间算法是 O(n³)。
如果你认为二次方时间复杂度的算法很慢,那等你看到三次方时间复杂度的算法有多慢吧。假设 n 是 1,000。我们已经知道线性时间算法大约需要 1,000 步,而二次方时间算法大约需要 1,000² = 1,000,000 步。三次方时间算法则需要 1,000³ = 1,000,000,000 步。十亿步!但事情更糟糕。例如,如果 n 是 10,000,这仍然是一个较小的输入量,那么三次方时间算法将需要 1,000,000,000,000(也就是一万亿)步。一万亿步将需要几分钟的计算时间。不是开玩笑:三次方时间复杂度的算法几乎永远不够好。
当我们尝试使用三次方时间复杂度的算法来解决示例 9-5 中的牛棒球问题时,显然它并不够好。我在这里重新展示了那个解决方案:
input_file = open('baseball.in', 'r')
output_file = open('baseball.out', 'w')
n = int(input_file.readline())
positions = []
for i in range(n):
positions.append(int(input_file.readline()))
total = 0
❶ for position1 in positions:
❷ for position2 in positions:
first_two_diff = position2 - position1
if first_two_diff > 0:
low = position2 + first_two_diff
high = position2 + first_two_diff * 2
❸ for position3 in positions:
if position3 >= low and position3 <= high:
total = total + 1
output_file.write(str(total) + '\n')
input_file.close()
output_file.close()
你会在这段代码中看到三次方时间的特征:三个嵌套的循环 ❶ ❷ ❸,每个循环的迭代次数都依赖于输入的数量。正如你记得的那样,这个问题的时间限制是四秒,并且我们最多可以有 1,000 头牛。三次方时间复杂度的算法,处理十亿次迭代,显然太慢了。
多个变量
在第五章中,我们解决了贝克奖金问题。我在这里重新展示了我们在示例 5-6 中的解决方案:
for dataset in range(10):
lst = input().split()
franchisees = int(lst[0])
days = int(lst[1])
grid = []
❶ for i in range(days):
row = input().split()
for j in range(franchisees):
row[j] = int(row[j])
grid.append(row)
bonuses = 0
❷ for row in grid:
total = sum(row)
if total % 13 == 0:
bonuses = bonuses + total // 13
❸ for col_index in range(franchisees):
total = 0
for row_index in range(days):
total = total + grid[row_index][col_index]
if total % 13 == 0:
bonuses = bonuses + total // 13
print(bonuses)
这个算法的时间复杂度是什么?这里有一些嵌套循环,所以第一反应是这个算法的时间复杂度是 O(n²)。但是 n 代表什么呢?
在本章迄今为止讨论的问题中,我们使用单一变量 n 来表示输入的量:n 可能是交换的次数、停车位的数量、电子邮件地址的数量或奶牛的数量。但在贝克奖金问题中,我们处理的是二维输入,因此我们需要 两个 变量来表示其数量。我们将第一个变量称为 d,表示天数;第二个变量称为 f,表示加盟商的数量。更正式地说,由于每个输入包含多个测试案例,我们将 d 定义为天数的最大值,f 定义为加盟商的最大值。我们需要在 d 和 f 的基础上给出大 O 效率。
我们的算法由三个主要部分组成:读取输入、根据行计算奖金数量和根据列计算奖金数量。让我们分别来看一下每个部分。
为了读取输入 ❶,我们执行 d 次外循环。在每次迭代中,我们读取一行并调用 split,这个过程大约需要 f 步。然后我们再花 f 步遍历这些值并将它们转换为整数。总的来说,每次 d 次迭代都执行与 f 成比例的步骤。因此,读取输入需要 O(df) 时间。
现在来看行奖金 ❷。这里的外循环执行 d 次。每次迭代都调用 sum,该操作需要 f 步,因为它必须加总 f 个值。因此,与读取输入一样,这部分算法是 O(df)。
最后,让我们看一下列奖金 ❸ 的代码。外循环执行 f 次。每次迭代都导致内循环执行 d 次。这里的总时间复杂度同样是 O(df)。
该算法的每个组件是 O(df)。将三个 O(df) 组件加在一起,整体算法是 O(df)。
概念检查
以下算法的大 O 效率是多少?
for i in range(m):
<do something that takes one step>
for j in range(n):
<do something that takes one step>
A. O(1)
B. O(n)
C. O(n^(2))
D. O(m+n)
E. O(mn)
答案:D。第一个循环依赖于 m,第二个循环依赖于 n。这些循环是顺序执行的,而不是嵌套的,因此它们的工作量是相加而非相乘。
对数时间
在第 255 页的《程序效率》中,我们讨论了线性搜索与二分搜索的区别。线性搜索通过从头到尾搜索列表来查找值。这是一个 O(n) 算法,无论列表是否已排序都能工作。相比之下,二分搜索仅适用于已排序的列表。但如果你有一个已排序的列表,二分搜索会非常迅速。
二分搜索通过将我们要搜索的值与列表中间的值进行比较来工作。如果列表中间的值大于我们要搜索的值,我们继续在列表的左半部分进行搜索。如果列表中间的值小于我们要搜索的值,我们继续在列表的右半部分进行搜索。我们一直这样做,每次忽略列表的一半,直到找到我们要找的值。
假设我们使用二分搜索在一个包含 512 个值的列表中查找某个值。需要多少步?嗯,经过第一步后,我们忽略了一半的列表,所以剩下大约 512 / 2 = 256 个值。(无论我们的值是大于列表中一半的值,还是小于列表中一半的值,都会忽略列表的一半。)第二步后,剩下 256 / 2 = 128 个值。第三步后,剩下 128 / 2 = 64 个值。继续下去,第四步后剩下 32 个值,第五步后剩下 16 个值,第六步后剩下 8 个值,第七步后剩下 4 个值,第八步后剩下 2 个值,第九步后只剩下 1 个值。
九步——就这么简单!这比使用线性搜索最多需要 512 步要好得多。二分搜索比线性时间算法要少做很多工作。但它是什么样的算法呢?它不是常数时间:虽然它需要的步骤很少,但随着输入量的增加,步骤数确实会略微增加。
二分搜索是一个 对数时间 或 对数时间 算法的例子。在大 O 符号中,我们说一个对数时间算法是 O(log n)。
对数时间是指数学中的对数函数。给定一个数字,这个函数告诉你需要将该数字除以一个基数多少次,才能得到 1 或更小。我们在计算机科学中通常使用的基数是 2,所以我们要找的是将一个数字除以 2 多少次才能得到 1 或更小。例如,要将 512 除到 1,需要 9 次除法。我们写作 log[2] 512 = 9。
对数函数是指数函数的反函数,后者可能对你来说更加熟悉。另一种计算 log[2] 512 的方法是找到指数 p,使得 2^p = 512。由于 2⁹ = 512,我们确认 log[2] 512 = 9。
对数函数增长得如此缓慢,令人吃惊。例如,考虑一个包含一百万个值的列表。二分搜索要搜索这个列表需要多少步?它需要 log[2] 1,000,000 步,这大约是 20 步。对数时间比常数时间要接近得多,而不是线性时间。每当你能用对数时间算法代替线性时间算法时,这都是一次巨大的胜利。
n log n 时间
在 第五章中,我们解决了《村庄邻里问题》。我在这里重新展示了我们在 列表 5-1 中的解法:
n = int(input())
positions = []
❶ for i in range(n):
positions.append(int(input()))
❷ positions.sort()
left = (positions[1] - positions[0]) / 2
right = (positions[2] - positions[1]) / 2
min_size = left + right
❸ for i in range(2, n - 1):
left = (positions[i] - positions[i - 1]) / 2
right = (positions[i + 1] - positions[i]) / 2
size = left + right
if size < min_size:
min_size = size
print(min_size)
看起来像是线性时间算法,对吧?我的意思是,这里有一个线性时间的循环来读取输入❶,还有另一个线性时间的循环来找最小值❸。那么,这段代码是O(n)吗?
现在判断还为时过早!原因是我们还没有考虑到我们排序的位置❷。我们不能忽略这一点;我们需要了解排序的效率。正如我们将看到的那样,排序比线性时间要慢。因此,由于排序是这里最慢的步骤,无论排序的效率如何,它将决定整体的效率。
程序员和计算机科学家们设计了许多排序算法,这些算法大致可以分为两组。第一组包括需要O(n²)时间的算法。这些排序算法中最著名的三种是冒泡排序、选择排序和插入排序。如果你愿意,可以自行学习这些排序算法的更多内容,但在这里我们不需要了解它们的任何细节。我们要记住的只是,O(n²)可能非常慢。例如,要对一个包含 10,000 个值的列表进行排序,O(n²)排序算法需要大约 10,000² = 100,000,000 步。正如我们所知道的,这至少需要几秒钟的时间。这个结果相当令人失望:排序 10,000 个值似乎是计算机应该能够几乎瞬间完成的任务。
进入第二组排序算法。这一组包括那些只需要O(n log n)时间的算法。这个组里有两个著名的排序算法:快速排序和归并排序。同样的,如果你愿意,可以自己查找这些算法,但在这里我们不需要了解具体细节。
O(n log n)是什么意思?不要让符号弄混淆了你。这只是n和 log n的乘积。我们来尝试一下处理一个包含 10,000 个值的列表。在这里,我们有 10,000 * log 10,000 步,这只有大约 132,877 步。这是一个非常小的步数,特别是和需要 100,000,000 步的O(n²)排序算法相比。
现在我们可以问我们真正关心的问题:当我们要求 Python 对一个列表进行排序时,它使用的是哪种排序算法?答案是:O(n log n)的算法!(它叫 Timsort。如果你想了解更多,建议从归并排序开始,因为 Timsort 是一个加强版的归并排序。)这里没有慢的O(n²)排序算法。通常,排序是如此快速——几乎接近线性时间——我们可以使用它而不会对效率产生太大的影响。
回到“村庄邻里”这个例子,现在我们看到它的效率不是O(n),而是由于排序的原因,变成了O(n log n)。实际上,O(n log n)算法所做的工作只比O(n)算法多一点,而远少于O(n²)算法。如果你的目标是设计一个O(n)算法,那么设计一个O(n log n)的算法应该已经足够好了。
处理函数调用
从第六章开始,我们编写了自己的函数,帮助我们设计更大的程序。在我们的大 O 分析中,我们需要小心地包括调用这些函数时所做的工作。
让我们回顾一下第六章中的卡片游戏问题。我们在清单 6-1 中解决了这个问题,我们的解决方案的一部分是调用了我们的no_high函数。我在这里重新展示了该解决方案:
NUM_CARDS = 52
❶ def no_high(lst):
"""
lst is a list of strings representing cards.
Return True if there are no high cards in lst, False otherwise.
"""
if 'jack' in lst:
return False
if 'queen' in lst:
return False
if 'king' in lst:
return False
if 'ace' in lst:
return False
return True
deck = []
❷ for i in range(NUM_CARDS):
deck.append(input())
score_a = 0
score_b = 0
player = 'A'
❸ for i in range(NUM_CARDS):
card = deck[i]
points = 0
remaining = NUM_CARDS - i - 1
if card == 'jack' and remaining >= 1 and no_high(deck[i+1:i+2]):
points = 1
elif card == 'queen' and remaining >= 2 and no_high(deck[i+1:i+3]):
points = 2
elif card == 'king' and remaining >= 3 and no_high(deck[i+1:i+4]):
points = 3
elif card == 'ace' and remaining >= 4 and no_high(deck[i+1:i+5]):
points = 4
if points > 0:
print(f'Player {player} scores {points} point(s).')
if player == 'A':
score_a = score_a + points
player = 'B'
else:
score_b = score_b + points
player = 'A'
print(f'Player A: {score_a} point(s).')
print(f'Player B: {score_b} point(s).')
我们将使用 n 来表示卡片的数量。no_high函数 ❶ 需要一个列表并对其使用in操作,因此我们可能会得出它是 O(n) 时间复杂度的结论(毕竟,in 可能需要搜索整个列表来找到它要找的东西)。然而,我们只会在常数大小的列表——最多四张卡片——上调用no_high,所以我们可以将每次调用no_high视为 O(1) 时间复杂度。
现在我们理解了no_high的效率后,我们可以确定完整程序的大 O 效率。我们从一个循环开始,这个循环花费 O(n) 时间来读取卡片 ❷。然后我们进入另一个循环,它迭代 n 次 ❸。每次迭代仅需常数步骤,可能包括调用no_high,该函数也只需要常数步骤。因此,这个循环花费 O(n) 时间。因此,程序由两个 O(n) 部分组成,总的时间复杂度是 O(n)。
小心准确判断函数调用时执行的工作量。正如你刚刚看到的no_high,这可能涉及到同时观察函数本身以及它被调用的上下文。
概念检查
以下算法的大 O 效率是多少?
def f(lst):
for i in range(len(lst)):
lst[i] = lst[i] + 1
# Assume that lst refers to a list of numbers
for i in range(len(lst)):
f(lst)
A. O(1)
B. O(n)
C. O(n^(2))
答案:C. 主程序中的循环迭代了 n 次。在每次迭代中,我们调用函数f,而f本身也有一个循环,迭代 n 次。
总结
执行工作最少的算法是 O(1),接着是 O(log n),然后是 O(n),再接着是 O(n log n)。你曾经用其中一个算法解决过问题吗?如果是,那么你可能已经完成。如果不是,那么根据时间限制,你可能还有更多工作要做。
现在我们将来看两个问题,在这些问题中,直接的解决方案效率不足——它在时间限制内无法运行。利用我们刚刚学到的大 O 表示法,我们即使不实现代码,也能够预测出这种低效!然后我们将尝试一个更快速的解决方案,并实现它,以在时间限制内解决问题。
问题 #24:最长围巾
在这个问题中,我们将确定通过剪裁初始围巾,可以生产出最长的目标围巾。阅读完以下描述后,停下来想一想:你会如何解决它?你能提出多个算法并分析它们的效率吗?
这是 DMOJ 问题dmopc20c2p2。
挑战
你有一条长度为 n 英尺的围巾,每一英尺都有一个特定的颜色。
你还有 m 个亲戚。每个亲戚通过指定围巾的第一英尺和最后一英尺的颜色,来表示他们想要的围巾样式。
你的目标是将原始围巾剪裁成一种方式,以便为某个亲戚制作出最长的所需围巾。
输入
输入包括以下几行:
-
一行包含整数围巾长度n和整数亲戚人数m,二者由空格分隔。n和m的值都在 1 到 100,000 之间。
-
一行包含n个整数,整数之间由空格分隔。每个整数表示围巾的一个脚的颜色,顺序从第一个脚到最后一个脚。每个整数的值在 1 到 1,000,000 之间。
-
m行,每行一个亲戚,包含两个整数,中间用空格分隔。这两个数描述了亲戚所需的围巾:第一个整数是所需围巾的第一个脚的颜色,第二个整数是所需围巾的最后一个脚的颜色。
输出
输出通过剪裁原始围巾能制作的最长所需围巾的长度。
解决测试用例的时间限制是 0.4 秒。
探索一个测试用例
让我们确保通过处理一个小的测试用例来准确理解所问的问题。以下是测试用例:
6 3
18 4 4 2 1 2
1 2
4 2
18 4
我们有一条 6 英尺长的围巾和三个亲戚。围巾的每个脚的颜色依次是 18、4、4、2、1 和 2。那么我们能做出最长的所需围巾是什么?
第一个亲戚想要一条围巾,其第一个脚是颜色 1,最后一个脚是颜色 2。我们能做的最好是给这个亲戚一条 2 英尺长的围巾:围巾的最后两脚(颜色 1 和颜色 2)。
第二个亲戚想要一条围巾,其第一个脚是颜色 4,最后一个脚是颜色 2。我们可以给他们一条 5 英尺长的围巾:4, 4, 2, 1, 2。
第三个亲戚想要一条围巾,其第一个脚是颜色 18,最后一个脚是颜色 4。我们可以给他们一条 3 英尺长的围巾:18, 4, 4。
我们可以制作的最长围巾长度为 5,因此这是该测试用例的答案。
算法 1
我们刚才处理这个测试用例的方法可能立刻给你启发,帮助你想到一种可以用来解决这个问题的算法。也就是说,我们应该能逐个检查亲戚,找出每个亲戚所需围巾的最大长度。例如,第一个亲戚的最大长度可能是 2,因此我们记下这个值。第二个亲戚的最大长度可能是 5。这个比 2 长,所以我们记下 5。第三个亲戚的最大长度可能是 3。但这并不超过 5——所以没有变化。如果这让你想到了一个完全搜索的算法(第九章):很好,因为这正是一个!
有m个亲戚。如果我们知道处理每个亲戚所需的时间,那么我们就能计算出我们需要处理的时间复杂度。
这里有一个想法:对于每个亲戚,让我们找到第一个脚的颜色的最左边索引和最后一个脚的颜色的最右边索引。一旦找到了这些索引,无论围巾多长,我们都可以利用这些索引快速确定该亲戚所需的最长围巾的长度。例如,如果第一个脚的颜色的最左边索引是 100,最后一个脚的颜色的最右边索引是 110,那么他们所需的最长围巾是 110 – 100 + 1 = 11。
根据我们寻找这些索引的方法,我们可能会很幸运地迅速找到它们。例如,我们可能从左边扫描以找到第一个脚的颜色的最左边索引,并从右边扫描以找到最后一个脚的颜色的最右边索引。然后,如果第一个脚的颜色靠近围巾的开头,最后一个脚的颜色靠近围巾的结尾,我们就能非常快速地发现这些索引。
然而,我们可能并不幸运。找到一个或两个索引可能需要多达 n 步。例如,假设某个亲戚想要的围巾第一个脚的颜色出现在围巾的最后,或者根本没有出现在围巾上。我们将不得不检查围巾的整个 n 个脚,一次一个,才能搞清楚这一点。
所以,对于每个亲戚,约 n 步。那是线性时间,我们知道线性时间是很快的。我们可以接受吗?不,因为在这种情况下,线性时间的工作远比它看起来更具威胁。记住,我们将对每个 m 个亲戚执行 O(n) 的工作。因此,我们的整体算法是 O(mn)。m 和 n 的值可能高达 100,000。这样,mn 可能高达 100,000 *** 100,000 = 10,000,000,000。这是 100 亿!考虑到我们每秒能进行约五百万次操作,而我们的时间限制是 0.4 秒……是的,我们远远不够。没有必要实现这个算法。我们确信它会在大规模测试用例中超时。我们不如继续前进,花时间实现其他东西。(如果你仍然对代码感兴趣,请查看与书籍相关的在线资源。只要记住,即使不看代码,我们已经意识到它会太慢。大 O 分析的强大之处在于,它能帮助我们在实现之前就了解一个算法是否注定失败。)
算法 2
我们必须以某种方式处理每个亲戚——这是无法避免的。那么,我们将专注于优化每个亲戚所需的工作量。不幸的是,以我们在上一节中所做的方式处理亲戚可能导致我们检查围巾的很大一部分。正是这种每处理一个亲戚就要扫描一遍围巾的搜索过程,压垮了我们。我们需要控制住这个搜索过程。
假设我们只能在一开始就看一次围巾,在了解任何亲戚想要什么之前。我们可以记住围巾中每种颜色的两件事:它的最左索引和最右索引。然后,不管每个亲戚想要什么,我们都可以通过我们已经存储的左索引和右索引来计算他们所需围巾的最大长度。
例如,假设我们有这条围巾:
18 4 4 2 1 2
我们会为它存储以下信息:
| 颜色 | 最左索引 | 最右索引 |
|---|---|---|
| 1 | 4 | 4 |
| 2 | 3 | 5 |
| 4 | 1 | 2 |
| 18 | 0 | 0 |
假设某个亲戚想要一条围巾,第一只脚是颜色 1,最后一只脚是颜色 2。我们查找颜色 1 的最左索引,它是 4,查找颜色 2 的最右索引,它是 5。然后我们计算 5 – 4 + 1 = 2,这就是这个亲戚所需围巾的最大长度。
真棒:无论围巾多长,我们只需要为每个亲戚做一次快速计算。再也不需要反复浏览围巾。唯一棘手的是如何计算所有颜色的最左和最右索引,并且仅通过查看围巾一次来完成。
代码呈现在列表 10-1 中。在继续阅读我接下来的解释之前,尝试理解leftmost_index和rightmost_index字典是如何构建的。
lst = input().split()
n = int(lst[0])
m = int(lst[1])
scarf = input().split()
for i in range(n):
scarf[i] = int(scarf[i])
❶ leftmost_index = {}
❷ rightmost_index = {}
❸ for i in range(n):
color = scarf[i]
❹ if not color in leftmost_index:
leftmost_index[color] = i
rightmost_index[color] = i
❺ else:
rightmost_index[color] = i
max_length = 0
for i in range(m):
relative = input().split()
first = int(relative[0])
last = int(relative[1])
if first in leftmost_index and last in leftmost_index:
❻ length = rightmost_index[last] - leftmost_index[first] + 1
if length > max_length:
max_length = length
print(max_length)
列表 10-1:解决最长围巾问题,算法 2
该解决方案使用了两个字典:一个用于跟踪每种颜色的最左索引❶,另一个用于跟踪每种颜色的最右索引❷。
如承诺,我们每次只看围巾的一只脚❸。下面是我们如何保持leftmost_index和rightmost_index字典实时更新的方法:
-
如果当前脚的颜色以前从未见过❹,那么当前索引将既是该颜色的最左索引,也是最右索引。
-
否则,当前脚的颜色已经出现过❺。我们不想更新该颜色的最左索引,因为当前索引在旧索引的右侧。然而,我们确实想要更新最右索引,因为我们找到了一个位于旧索引右侧的索引。
现在来说说回报:对于每个亲戚,我们可以简单地从这些字典❻中查找最左索引和最右索引。所需围巾的最大长度是最后一只脚的颜色的最右索引减去第一只脚的颜色的最左索引,再加一。
正如我现在要论证的,这个算法比算法 1 要好得多。读取围巾的操作需要 O(n) 时间,处理围巾的脚也需要 O(n) 时间。到目前为止,总共是 O(n) 时间。接下来,我们对每个相对进行常数次数的处理(不像之前那样是 n 步!),所以这是 O(m) 时间。总的来说,我们得到了一个 O(m + n) 的算法,而不是一个 O(mn) 的算法。鉴于 m 和 n 最大为 100,000,我们只需进行大约 100,000 + 100,000 = 200,000 步,完全可以在时间限制内完成。你可以把我们的代码提交给评测系统来验证!
问题 #25:绸带绘制
这是另一个问题,其中我们可能首先想到的算法太慢了。不过我们不会浪费太多时间在这个算法上,因为我们的大 O 分析会在我们考虑实现代码之前告诉我们所有需要知道的内容。然后我们将花时间设计一个更快的算法。
这是 DMOJ 问题 dmopc17c4p1。
挑战任务
你有一条紫色绸带,长度为 n 单位。第一单位从位置 0 开始,直到但不包括位置 1,第二单位从位置 1 开始,直到但不包括位置 2,依此类推。然后你进行 q 次油漆笔画,每次涂抹绸带的一段蓝色。
你的目标是确定仍然是紫色的绸带单位数和现在是蓝色的单位数。
输入
输入包含以下几行:
-
一行包含整数绸带长度 n 和油漆笔画的整数数量 q,两者用空格分隔。n 和 q 都在 1 到 100,000 之间。
-
q 行,每行一个油漆笔画,包含两个整数,用空格分隔。第一个整数给出油漆笔画的起始位置;第二个整数给出油漆笔画的结束位置。起始位置保证小于结束位置;每个整数都在 0 和 n 之间。油漆笔画从起始位置涂抹直到但不包括结束位置。这里给出一个简单的例子,如果油漆笔画的起始位置是 5,结束位置是 12,那么涂抹的区域是从位置 5 到但不包括位置 12。
输出
输出绸带仍然是紫色的单位数,一个空格,和现在是蓝色的单位数。
解决测试用例的时间限制为 2 秒。
探索一个测试用例
我们来看一个小的测试用例。这个用例不仅能确保我们正确理解了问题,还能展示朴素算法的危险。这里是:
20 4
18 19
4 16
4 14
5 12
我们的绸带长度是 20,油漆笔画次数是四次。我们的油漆笔画将绸带的多少部分涂成蓝色?
第一次油漆笔画涂抹的是起始位置为 18 的单个单位蓝色。
第二次油漆笔画涂抹的绸带单位是从位置 4、5、6、7 等等,一直到位置 15。这次笔画涂抹了 12 个单位,总共涂抹了 13 个蓝色单位。
第三次涂漆涂了 10 个蓝色单元,但这些单元都已经被第二次涂漆涂成蓝色!如果我们还花时间“涂漆”这些单元,那将是极大的时间浪费。无论我们提出什么算法,都最好避免这种时间浪费的陷阱。
第四次涂漆涂了 7 个蓝色单元。但同样:这些单元都已经是蓝色了!
现在我们完成了涂漆,得到了 13 个蓝色单元。剩余的紫色单元为 20 – 13 = 7 个,所以该测试用例的正确输出是:
7 13
解决问题
丝带的最大长度为 100,000,最大涂漆次数为 100,000。回想一下我们在解决《最长围巾》问题时使用的算法 1,当时我们发现O(mn)算法在这些边界下运行太慢。类似地,在这里,O(nq)算法也不合适,因为在大规模测试用例上,它无法在时间限制内完成。
这意味着我们不能逐个处理每个涂漆单元。假如我们能更容易地专注于仅由涂漆涂成蓝色的新单元就好了。这样我们就可以遍历每个涂漆,计算它所贡献的蓝色单元数量。
说得对,但我们如何确定每次涂漆的贡献呢?这很棘手,因为下一次涂漆的一部分可能已经被前面的涂漆覆盖成了蓝色。
然而,如果我们先对涂漆进行排序,这种情况会变得简单得多。还记得本章早些时候提到的“n log n 时间”吗?排序是极其快速的,仅需O(n log n)时间。使用排序没有效率上的问题,所以让我们理解为什么排序在这里能帮助我们。
对上一节测试用例中的涂漆进行排序后,我们得到以下涂漆列表:
4 14
4 16
5 12
18 19
现在涂漆已经排序好了,我们可以高效地处理它们。在处理的过程中,我们会存储目前为止已处理的涂漆中最右端的位置。我们从 0 开始设置这个最右端位置,表示我们尚未涂漆任何地方。
我们的第一次涂漆涂了 14 – 4 = 10 个蓝色单元。现在我们存储的最右端位置是 14。
我们的第二次涂漆涂了 12 个蓝色单元,没错,但其中有多少单元是从紫色变成蓝色的呢?毕竟,它与之前的涂漆有重叠,因此有一些单元已经是蓝色的了。我们可以通过将 14(我们存储的最右端位置)从 16(当前涂漆的结束位置)中减去,来计算出新的蓝色单元数量。这样,我们就可以忽略之前涂漆过的蓝色单元。所以,新的蓝色单元为 16 – 14 = 2 个,总共有 12 个蓝色单元。关键是,我们在没有处理该涂漆单元的具体细节情况下就解决了这个问题。在继续之前,别忘了更新我们存储的最右端位置为 16。
我们的第三次涂漆和第二次类似,它的起始位置在我们已存储的最右位置之前。然而,不同的是,它的结束位置并没有超过我们已存储的最右位置。因此,这次涂漆不会增加任何新的蓝色单位,我们已存储的最右位置仍然是 16。再次强调,我们通过不逐个计算每个涂漆位置就得出了这个结论!
小心处理第四次涂漆。它并没有增加 19 – 16 = 3 个新的蓝色单位。我们必须以不同的方式处理这次涂漆,因为它的起始位置位于我们已存储的最右位置的右侧。在这种情况下,我们根本不使用存储的最右位置,而是计算 19 – 18 = 1 个新的蓝色单位,总共得到 13 个蓝色单位。同时,我们更新存储的最右位置为 19。
唯一的问题是如何在 Python 代码中对涂漆进行排序。我们需要按它们的起始位置进行排序;如果多个涂漆的起始位置相同,则需要按它们的结束位置进行排序。
也就是说,我们希望处理一个像这样的列表:
[[18, 19], [4, 16], [4, 14], [5, 12]]
并生成如下内容:
[[4, 14], [4, 16], [5, 12], [18, 19]]
高兴的是,正如我们在第六章的“任务 4:排序盒子”中发现的那样,sort 方法正是这样工作的。当给定一个包含列表的列表时,sort 会使用每个列表中的第一个值进行排序;如果这些值相同,则会根据第二个值进一步排序。请查看:
>>> strokes = [[18, 19], [4, 16], [4, 14], [5, 12]]
>>> strokes.sort()
>>> strokes
[[4, 14], [4, 16], [5, 12], [18, 19]]
算法:检查。排序:检查。我们准备得很好!在看到代码之前,还有一个问题我们想知道:它的时间复杂度是多少?我们需要读取 q 个查询;这需要 O(q) 的时间。然后,我们需要对查询进行排序;这需要 O(q log q) 的时间。最后,我们需要处理查询;这需要 O(q) 的时间。最慢的操作是排序所需的 O(q log q) 时间,因此这是我们的整体时间复杂度。
现在我们拥有了快速解决问题所需的一切。在列表 10-2 中查看它。
lst = input().split()
n = int(lst[0])
q = int(lst[1])
strokes = []
for i in range(q):
stroke = input().split()
❶ strokes.append([int(stroke[0]), int(stroke[1])])
❷ strokes.sort()
rightmost_position = 0
blue = 0
for stroke in strokes:
stroke_start = stroke[0]
stroke_end = stroke[1]
❸ if stroke_start <= rightmost_position:
if stroke_end > rightmost_position:
❹ blue = blue + stroke_end - rightmost_position
rightmost_position = stroke_end
❺ else:
❻ blue = blue + stroke_end - stroke_start
rightmost_position = stroke_end
print(n - blue, blue)
列表 10-2:解决 Ribbon Painting 问题
我们读取每次涂漆,将其作为包含两个值的列表添加到我们的 strokes 列表中 ❶。然后,我们对所有涂漆进行排序 ❷。
接下来,我们需要从左到右处理每一次涂漆。有两个关键变量控制着这一过程:变量 rightmost_position 存储我们到目前为止涂漆的最右位置,变量 blue 存储我们到目前为止涂漆的蓝色单位数。
要处理一次涂漆,我们需要知道它是从已存储的最右位置之前开始,还是之后开始。让我们逐一考虑这两种情况。
首先:当涂漆开始位置在我们已存储的最右位置之前时 ❸,我们该怎么办?这次涂漆可能会给我们一些新的蓝色单位,但只有当它超出我们已存储的最右位置时,才会增加新的蓝色单位。如果是这样,那么新增的蓝色单位就是从存储的最右位置到涂漆结束位置之间的单位 ❹。
第二:当涂漆操作从我们存储的最右位置 ❺ 开始时,我们该怎么办?这次,涂漆操作与我们迄今为止的涂漆完全分开;这整个涂漆操作是一个新的蓝色段。因此,新的蓝色单位是从这次涂漆的结束位置到起始位置之间的部分 ❻。
注意,在每个情况下,我们也正确地更新了我们存储的最右位置,以便我们准备好处理任何进一步的涂漆操作。
完成了!在我们的大 O 分析指导下,我们能够淘汰一个我们知道会太慢的算法实现。然后,我们考虑了第二个算法——在实现之前,我们就知道它会足够快。现在是时候将我们的代码提交给评判,享受我们的成功了。
总结
在这一章中,我们学习了大 O 分析。大 O 是进一步研究算法设计的一个重要效率构建块。你将会在各个地方看到它:教程中、书籍中,也许在你下一次的工作面试中!
我们还解决了两个问题,在这些问题中,我们需要设计非常高效的算法。我们不仅能够做到这一点,还能利用大 O 符号清楚地理解我们的代码为何如此高效。
章节练习
这里有一些练习供你尝试。对于每个练习,使用大 O 来判断你提出的算法是否足够高效,能够在时间限制内解决问题。你也可以实现那些你知道会太慢的算法,这将帮助你巩固 Python 知识,并确认你的大 O 分析是否准确!
这些问题有些非常具有挑战性。原因有二。首先,基于你在本书中的工作,你可能会同意,想出任何一个算法都很困难。而想出一个更快的算法可能会更难。其次,这是我们共同学习的结束,但算法研究才刚刚开始。如果你想继续深入,这些问题将帮助你既欣赏自己所取得的成就,又能证明在本书之外,还有很多内容等待你去探索。
-
DMOJ 问题
dmopc17c1p1,Fujo Neko(这个问题讲的是使用快速输入/输出。不要忽视这一点!) -
DMOJ 问题
coci10c1p2,Profesor -
DMOJ 问题
coci19c4p1,Pod starim krovovima(提示:为了最大化空玻璃杯的数量,你需要将尽可能多的液体倒入最大的玻璃杯中。) -
DMOJ 问题
dmopc20c1p2,Victor 的道德困境 -
DMOJ 问题
avocadotrees,鳄梨树! -
DMOJ 问题
coci11c5p2,Eko(提示:树木的最大数量远小于高度的最大数量。从最高的树开始考虑。) -
DMOJ 问题
wac6p2,廉价的圣诞灯(提示:不要尝试每秒都切换一次开关——你怎么知道该切换哪个呢?相反,先把它们都存起来,并在能关掉所有亮着的灯时一次性使用它们。) -
DMOJ 问题
ioi98p3,派对灯(提示:每个按钮的关键是它被按下的次数是偶数次还是奇数次。)
注释
最长围巾最初来自 DMOPC ’14 三月竞赛。丝带绘画最初来自 DMOPC ’20 十月竞赛。
第十一章:后记

在你进入下一个阶段之前,我想花一分钟祝贺你已经取得的成就。也许在拿起这本书之前你还没有做过任何编程。或者你已经做过一些编程,想要提高自己的问题解决能力。不管怎样,如果你已经通过了这本书,并花了必要的时间完成练习,现在你知道如何使用计算机解决问题了。你学会了如何理解问题描述,设计解决方案,并用代码编写解决方案。你学到了关于if语句、循环、列表、函数、文件、集合、字典、完全搜索算法和大 O 分析的核心工具。这些是编程的核心工具,你将一次又一次地依赖于它们。现在你也可以自称为 Python 程序员了!
或许你的下一步是更多地了解 Python。如果是这样的话,请查看第八章末尾的笔记。
或许你的下一步是学习另一种编程语言。我个人特别喜欢 C 语言。与 Python 相比,它让你更接近程序运行时计算机内部的实际情况。如果你想学习 C 语言,没有比 K.N. King 的《C 程序设计:现代方法》,第二版(W.W. Norton & Company, 2008)更好的书了。我认为你现在已经准备好读这本书了。你还可以考虑学习其他语言,比如 C++、Java、Go 或 Rust,这取决于你想要编写的程序类型(或者只是因为你听说过这些语言的好处)。
或许你的下一步是学习更多关于设计算法的知识。如果是这样,请查看第九章末尾的笔记。
或许你的下一步是暂时休息一下。做些其他事情。解决可能与计算机无关的其他问题。
祝你解决问题时愉快!
第十二章:问题来源

我非常感谢每一位通过竞赛编程帮助人们学习的专家和志愿者。在本书的每个问题中,我都尽力确定其作者及来源。如果您对以下任何问题有更多信息或归属,请告诉我。更新将会发布在本书的网站上。
以下是表格中使用的缩写:
CCC: 加拿大计算机竞赛
CCO: 加拿大计算机奥林匹克
COCI: 克罗地亚信息学公开竞赛
DMOPC: DMOJ 每月开放编程竞赛
ECOO: 安大略省教育计算组织编程竞赛
Ural: 乌拉尔学校编程竞赛
USACO: 美国计算机奥林匹克
| 章节 | 小节 | 原始标题 | 竞赛/作者 |
|---|---|---|---|
| 1 | 字数统计 | 不是文字墙 | 2015 DMOPC/FatalEagle |
| 1 | 圆锥体积 | 钻孔机 | 2014 DMOPC/FatalEagle |
| 2 | 获胜队伍 | 获胜分数 | 2019 CCC |
| 2 | 电话销售员 | 电话销售员还是非销售员? | 2018 CCC |
| 3 | 三个杯子 | Trik | 2006/2007 COCI |
| 3 | 占用空间 | 占用停车位 | 2018 CCC |
| 3 | 数据计划 | 费率 | 2016/2017 COCI |
| 4 | 老丨虎丨机 | 老丨虎丨机 | 2000 CCC |
| 4 | 歌单 | 做个随机播放 | 2008 CCC |
| 4 | 秘密句子 | Kemija | 2008/2009 COCI |
| 5 | 村庄邻里 | Voronoi 村庄 | 2018 CCC |
| 5 | 学校旅行 | Munch ’n’ Brunch | 2017 ECOO/Andrew SeidelReyno Tilikaynen |
| 5 | 面包师奖金 | 面包师布里 | 2017 ECOO/Andrew SeidelReyno Tilikaynen |
| 6 | 卡牌游戏 | 卡牌游戏 | 1999 CCC |
| 6 | 动作人物 | 打扫房间 | 2019 Ural/Ivan Smirnov |
| 7 | 文章格式 | 文本处理器 | 2020 USACO/Nathan Pinsker |
| 7 | 农场播种 | 大规模恢复植被 | 2019 USACO/Dhruv RohatgiBrian Dean |
| 8 | 电子邮件地址 | 电子邮件 | 2019 ECOO/Andrew SeidelReyno TilikaynenTongbo Sui |
| 8 | 常见词汇 | 常见词汇 | 1999 CCO |
| 8 | 城市与州 | 城市与州 | 2016 USACO/Brian Dean |
| 9 | 救生员 | 救生员 | 2018 USACO/Brian Dean |
| 9 | 滑雪坡道 | 滑雪课程设计 | 2014 USACO/Brian Dean |
| 9 | 牛仔棒球 | 牛仔棒球 | 2013 USACO/Brian Dean |
| 10 | 最长围巾 | 糟糕的圣诞礼物 | 2020 DMOPC/Roger Fu |
| 10 | 彩带画 | 彩带上色乐趣 | 2017 DMOPC/Jiayi Zhang |
CCC 和 CCO 的问题归滑铁卢大学数学与计算机教育中心(CEMC)所有。


浙公网安备 33010602011771号