杜克大学-Java-编程与软件工程基础笔记-全-
杜克大学 Java 编程与软件工程基础笔记(全)
001:课程概览 🚀

在本节课中,我们将要学习杜克大学《Java编程和软件工程基础》专项课程的第一门课。这门课程专为编程初学者设计,旨在教授如何使用JavaScript、HTML和CSS进行编程和网页开发。我们将从基础开始,逐步学习如何像程序员一样思考、分析和解决问题。
课程介绍与目标

大家好,我是Susan。我很高兴能与杜克大学的团队合作,为大家介绍这门使用JavaScript、HTML和CSS的计算机编程基础课程。这门课程专为没有任何编程经验、但希望开始探索编程职业的初学者设计。
在本课程中,你将开始学习如何像程序员一样思考。这包括分析问题、设计被称为“算法”的解决方案,以及将你的算法转化为程序。
你将学习的内容
以下是本课程的核心学习路径:
-
第一周:网页结构基础
在第一周,你将学习使用HTML创建自己的网页。HTML是定义网页结构的语言。同时,你将学习CSS,这种语言可以让你轻松改变网页的外观。 -
第二周:编程与图像处理
接下来在第二周,我们将解决“绿幕”问题。我和Drew(你稍后会认识他)将带着恐龙一起进入外太空。为了实现这个目标,我们将使用我们设计的特殊JavaScript库,学习一些重要的JavaScript编程概念,重点是操作图像。你将获得的编程概念和技能,无论你将来使用JavaScript还是任何其他编程语言,都将使你受益匪浅。 -
第三周:创建交互式网页
在第三周,我们将把HTML、CSS和JavaScript技能结合起来,使你的网页具有交互性。到本课程结束时,你将设计出一个允许用户上传图像文件并应用你创建的图像滤镜的网页。
核心编程思维

大家好,我是Drew。你将学习的最重要的概念之一是如何解决编程问题。这将为你理解计算机科学家在编写程序时的所思所为打下基础。无论你是继续深入学习编程,还是需要与计算机科学家合作共同创建程序,这些知识对你都很有用。
你将学习的技能适用于任何编程语言,而不仅仅是JavaScript。虽然其他语言的语法可能略有不同,但你将学到相同的基本原则是相通的。
后续学习路径
大家好,我是Owen。完成这门课程后,你将准备好制作网页,并能够使用JavaScript以及其他语言进行编程。
如果你决定继续学习我们的Java编程专项课程,你将运用本课程中学到的编程基础,学习如何使用Java解决问题和编写代码。
此外,你在本课程中学到的网页开发技能,将在我们的毕业设计项目中派上用场。在那里,你将学习创建一个网页来托管你构建和开发的推荐系统,类似于亚马逊或网飞根据用户偏好推荐书籍或电影的方式。
总结
本节课中,我们一起学习了这门编程基础课程的整体概览。我们了解到,课程将从零开始,通过HTML、CSS和JavaScript,带领我们学习分析问题、设计算法、编写程序,并最终创建交互式网页。这些技能是编程的通用基础,为我们后续学习Java或其他编程语言铺平了道路。

现在你已经了解了这门课程的内容,让我们开始学习吧!
002:编程学习技巧 📚

在本节课中,我们将学习一些高效学习编程的技巧。这些建议旨在帮助你更好地掌握课程内容,克服编程中常见的挑战,并建立持续进步的学习习惯。
为了帮助你取得最佳学习效果,我们提供一些关于如何学习本课程的建议。
首先,每天学习一点。试图一次性学完所有编程知识非常困难。如果你每天完成几项课程任务,而不是试图在一两天内完成所有内容,你将能更好地记住知识,保持更高的学习动力,并有更多时间解决代码中的问题。
谈到代码中的问题,也就是常说的“Bug”。
在编程时犯错是正常的,因此我们的下一个建议是不要放弃。
每个人的程序中都会出现Bug。编程的一部分就是找出问题所在并修复它。
当你编程时,我们强烈建议遵循七步流程法。
这意味着在开始编写任何代码之前,你应该先规划如何解决问题。
七步流程法很重要,因为它为你提供了一种解决问题的方法。
当你构思出解决方案后,便可以开始编写代码。
一旦你准备好开始编写程序,请确保阅读了相关文档。
这样你就能了解存在哪些其他方法以及如何使用它们。
根据需要随时查阅文档。
接下来,充分利用实时编码视频和配套的练习测验。
这是一个与讲师同步编程的绝佳机会。
对于练习测验,尽管它们不计入最终成绩,但它们仍然是测试代码的好机会。
在进入计分测验之前,利用练习测验来发现和修复问题。
最后,如果你在编程中仍然遇到困难,请在课程讨论区向教学团队和同学寻求帮助。
成为一名优秀程序员的一部分,就是知道如何有效地寻求帮助。
我们将在下一个视频中更详细地讨论这一点。
本节课总结
本节课我们一起学习了高效学习编程的五个核心技巧:坚持每日学习、不畏惧错误和Bug、遵循七步流程法规划代码、充分利用课程资源(如文档、视频和练习),以及在需要时积极寻求帮助。掌握这些技巧将为你的编程学习之路奠定坚实的基础。
003:助你成功的资源 🛠️

在本节课中,我们将介绍本课程的重要资源,并为你提供一些成功学习的建议。了解这些工具和材料将帮助你更有效地学习编程。
我是杜克大学教学团队的伊丽莎白。在开始本课程之前,我希望确保你了解一些重要的资源,并为你提供一些取得好成绩的建议。
课程结构与资源 📚
在本课程中,你会看到标注为“试一试”的阅读材料和编程练习。“试一试”阅读材料鼓励你亲自尝试视频中看到的内容,以便获得更多编写代码的练习机会。

编程练习包含指导说明,帮助你编写自己的程序。此外,还有测验,以确保你理解视频中的所有内容,并检查你的代码是否正确运行。
课程网站导航 🌐
接下来,我想向你展示课程网站:learn to program dot com。你可以看到,我们为每门课程都设有专属页面,还有一个关于专项课程的常见问题解答页面。这个页面涵盖了从证书到课程中使用的软件等所有内容。
如果你回到主页并选择你正在学习的课程,你将进入该课程的主页。
杜克JavaScript环境
首先,让我们看看杜克JavaScript环境。你将在课程后期详细了解它的功能。目前,只需记住你可以在Course1 Duke Learner Program网站上找到它。
项目资源包含视频中的示例,你可以更详细地查看这些代码或进行实验。
文档部分总结了你在本课程中将学习的HTML、CSS和JavaScript知识。如果你想复习HTML元素的语法或查看可以使用的JavaScript方法,这将非常有用。请注意,这不是所有HTML、CSS和JavaScript的完整文档,但它是本课程的有用参考。
常见问题解答页面
此外,我们还有常见问题解答页面。当你对作业或测验有疑问时,首先应该查看FAQ页面,看看我们是否已经为你提供了答案。这个页面包含关于Course1的问题。由于我们处于网站的第一部分,关于整个专项课程的问题,这里有一个链接可以带你回到之前看到的通用FAQ页面。
总结与反馈 💡
本节课中,我们一起学习了本课程的结构以及你需要了解的资源。希望这个视频让你对课程结构有了初步了解,并知道了需要掌握哪些资源。如果你对我们如何使这些资源对你更有用有任何反馈,请在Coursera的讨论论坛中告诉我们。
004:使用CodePen 🖥️

在本节课中,我们将学习如何使用一个名为CodePen的在线工具来创建和预览网页。掌握这个工具后,你就能跟随后续的HTML和CSS课程示例进行实践,并自由地探索和创建自己的网页。
工具介绍与访问
上一节我们介绍了课程目标,本节中我们来看看我们将要使用的具体工具。有许多工具可以用来制作网页,我们将使用一个叫做CodePen的工具。
首先,在浏览器中访问 codepen.io。这是你将用来创建网页的工具。在首页上,你可以看到其他人分享的“Pens”(即他们的项目),你可以随意浏览。我们的操作是点击顶部的“New Pen”来创建自己的项目,从而制作自己的网页。
界面布局与功能
默认情况下,CodePen界面顶部有三个编辑框。我们将关闭最右侧的框,因为暂时用不到它。这样,我们就剩下两个主要区域:一个用于编写HTML,另一个用于编写CSS。你很快会学习HTML,稍后也会学习CSS。
以下是CodePen界面的核心区域介绍:
- HTML编辑框:在此处编写网页的结构和内容代码。
- CSS编辑框:在此处编写控制网页样式和格式的代码。
- 实时预览窗口:位于底部,会即时显示你所编写代码的渲染效果。
如果你在顶部的HTML框中编写代码(现在不必担心还未学习其语法),你会看到,随着我的输入,内容会实时显示在底部,其格式与实际网页中的效果一致。
实践与展望
当你稍后学习CSS时,你将能够使用右侧的CSS编辑框来改变你所创建网页的样式和格式。例如,这里我将列表项的文字颜色从黑色改为了蓝色。
当然,这个网页目前还不太有趣。随着学习的深入,你将能够制作复杂得多的网页,包含图像和更复杂的版式。这里有一个包含大量HTML和CSS代码的例子,在接下来的几个模块学习结束后,你将有能力实现类似的效果,从而设计出你想要的网页。

现在你已经了解了CodePen,知道了在掌握HTML后如何创建网页。我们接下来就准备深入学习HTML,以便你能在网页中表达任何你想要的内容。

本节课中我们一起学习了如何使用CodePen这个在线编辑工具。你了解了其界面布局、核心的HTML/CSS编辑区与实时预览功能,并看到了它如何支持我们后续的网页开发学习。准备好工具后,我们就可以正式开始HTML语言的学习了。
005:什么是HTML


概述
在本节课中,我们将要学习网页的基础知识,特别是用于创建网页的语言和概念。你将了解什么是HTML,它如何工作,以及如何开始创建你自己的网页。
什么是网页
你很可能对网页非常熟悉。你浏览过许多网页,例如维基百科的页面。你会发现,有些网页易于阅读,有些则包含有趣的链接、文字、图片、视频等元素。
这里有一个描述网页的维基百科页面。这个页面里包含了一张网页的图片,所以我们是在讨论一个包含网页图片的网页,其中充满了信息和趣味。
你将学习创建网页的基础知识。你自己的想象力和创造力将帮助你应用这些知识来创建属于你自己的页面。
网页的构成要素
以下是网页中常见的几种元素:
- 强调文本:网页上可能有加粗或强调的文本。你将学习如何让网页的某些部分比其他部分更突出。
- 超链接:网页上包含指向其他页面的链接。链接彻底改变了人们使用互联网的方式,通过跟随链接,你可以学习到惊人的知识。
- 多媒体:网页可以包含图片。你也可以在网页上放置视频和音频,但我们将首先学习图片,因为它们的使用和获取相对简单。
网页地址:URL
要访问一个特定的网页,你需要该网页的地址。这个地址本身不是网页的一部分,而是用于在线访问网页。一旦你分享了地址,世界上的任何人都可以访问你的网页。在本课程中,你将创建可以与全世界任何人分享的网页。
这个地址被称为URL,即统一资源定位符。

超文本标记语言
为了编写网页,你将学习超文本标记语言。这是用于创建网页的语言。大多数人使用HTML作为超文本标记语言的缩写,这样说起来更容易,在网上搜索信息时也更方便。

请注意,学习HTML并不是在学习一门编程语言,而是在学习一种标记语言。HTML不像编程语言那样在计算机上运行,而是由网络浏览器用来显示网页。
你可能写过文档,在文档中选中文本并将其设置为加粗、下划线或斜体。这是一种标记文本以特定方式显示的方法。HTML使用称为标签的结构化标记,网络浏览器使用这些标签来显示网页。
当你编写HTML时,你可以描述你希望在创建的网页中出现什么。浏览器使用HTML来渲染网页,使其可以在计算机、手机或任何运行浏览器的地方被查看。
因为HTML是一种标记语言,所以这种描述不仅包括文本和图像,还包括描述你所需格式的标记。例如,你刚才看到的加粗文本、下一课中将看到的表格,或者其他多种显示信息的方式。
你将使用HTML来指定含义,比如“加粗”或“链接”,但不会用HTML来指定如何显示加粗文本(例如使用什么颜色)。为此,你将使用另一种语言,稍后也会学到一点。
样式与标准
有不同的方式来显示项目。稍后你将学习CSS,它提供了增强网页显示效果的方法。
你可以指定想要强调某些文本,但具体如何显示强调文本呢?CSS让你可以描述这一点。可能是斜体,可能是加粗,也可能是巨大且红色的字体。你将在后续课程中学习CSS。
为了让不同的浏览器能够解释HTML和网页,必须存在标准。HTML5是HTML的当前版本。它是一个协作标准,由世界各地许多人共同制定。
HTML的第一个标准在1993年制定。那时大多数人使用非常慢的调制解调器连接互联网,网页简单得多,图片加载缓慢。如今,我们拥有图像、视频、音频等更多内容,但我们仍然拥有HTML标准,并且这个标准会随着互联网和网络能力的发展而更新。
HTML的最新标准在2014年制定,它随着时代的变化而发展。HTML5支持的多媒体功能在1993年是难以想象的。
HTML示例解析
下面是一个网页的HTML代码示例及其显示效果:
<!DOCTYPE html>
<html>
<head>
<title>Hello World Page</title>
</head>
<body>
<p>Hello world</p>
</body>
</html>
你会看到HTML标记包含许多标签。每个标签都用尖括号与标签之间的内容区分开。
我们将查看此页面中使用的标签,同时你也会注意到,显示的网页并不展示标签本身,它只显示短语“Hello world”。你可以在浏览器的标签页上看到网页的标题“Hello World Page”。
- 首先,你会看到
<!DOCTYPE html>声明,它指示我们正在使用HTML来定义网页的组成部分。 - 网页内容定义在起始的
<html>标签和结束的</html>标签之间。所有使用HTML的有效网页都包含这些标签。正如我们将看到的,有一个开始标签和一个结束标签,它们相互匹配,但结束标签带有一个斜杠/表示结束。 - 接下来,所有头部信息定义在起始的
<head>标签和结束的</head>标签之间。同样,结束标签是</head>,匹配的开始标签是<head>。 - 然后,你会看到
<title>标签。请注意,起始<title>和结束</title>标签之间的所有内容都将显示为网页的标题,即“Hello World Page”。 - 接着,高亮显示的是页面的
<body>标签。你在起始<body>和结束</body>标签之间放置的任何内容都将显示为网页的主体部分。 - 最后,我们看到在起始
<p>和结束</p>标签之间有一个简短的段落。你可以看到该内容“Hello world”显示在网页中。
我们经常会看到这种HTML结构:位于 <body> 标签之间的内容,就是用户查看网页时实际看到的部分。
总结
本节课中,我们一起学习了网页的基础构成和创建网页的核心语言——HTML。我们了解到HTML是一种标记语言,使用标签来描述网页的结构和内容,如文本、链接和图片。浏览器读取这些HTML标签并将其渲染成可视化的网页。我们还简单了解了网页地址(URL)的作用以及HTML标准(HTML5)的重要性,并通过一个简单的“Hello World”示例,直观地看到了HTML代码与最终网页显示效果之间的关系。掌握这些基础知识,是迈向创建你自己网页的第一步。
006:元数据与分区元素 📄
在本节课中,我们将更深入地了解HTML中不同类型的标签或元素。我们将重点介绍两大类元素:元数据元素和分区元素。理解这两类元素是构建网页结构的基础。

元数据元素 🔍
上一节我们提到了HTML的基本结构,本节中我们来看看元数据元素。元数据元素包含了关于网页本身的信息,它们不直接显示在页面上,但对浏览器和搜索引擎至关重要。
以下是几个关键的元数据元素及其作用:
<html>标签:这是一个元数据元素。所有其他元素都必须定义在<html>开始标签和结束标签之间。它告诉浏览器应使用HTML标准来显示该网页。<head>标签:这也是一个元数据元素。它包含了关于网页的通用信息,例如标题、脚本信息以及使用CSS显示页面的信息。<title>标签:这同样是一个元数据元素。它指定了网页的标题文本,并且必须嵌套在<head>的开始和结束标签之间。
例如,以下是 Dukelearntoprogram.com 网页HTML的开头部分,展示了元数据元素:
<html>
<head>
<title>页面标题</title>
<!-- 其他元数据 -->
</head>
分区元素 📐
了解了定义页面信息的元数据后,我们来看看定义页面内容区域的分区元素。分区元素用于划分网页的不同部分。
以下是几个常见的分区元素:
<body>标签:这是一个分区元素。其开始和结束标签定义了整个网页的主体部分。页面上看到的所有文本和其他内容都将位于<body>标签内部。<h1>到<h6>标签:这些是分区元素,用于定义标题区域。<h1>定义最重要、通常字号也最大的标题区域(H代表节标题)。<h2>也是节标题,但通常比<h1>稍小。最小的标题标签是<h6>。<div>标签:这也是一个分区标签。它定义了网页的一个部分或分区,对于将元素分组以便应用CSS样式非常有用。
同样以 Dukelearntoprogram.com 的HTML为例,我们可以在元数据之后找到分区元素:
<body>
<h1>这是一个H1标题元素</h1>
<div class="title-bar">
<!-- 这是页面上的第一个div,它使用了CSS类“title-bar” -->
</div>
<!-- 更多页面内容 -->
</body>
(注:原页面的结束 </body> 标签未显示,因为该页面包含大量HTML代码。)
总结 📝
本节课中我们一起学习了HTML的两类核心元素。元数据元素(如 <html>、<head>、<title>)提供了关于网页的描述性信息。分区元素(如 <body>、<h1>-<h6>、<div>)则定义了网页内容的具体区域和结构。希望现在你对网页HTML的布局结构有了更清晰的理解。
007:文本格式化与标签嵌套 📝
在本节课中,我们将学习如何使用简单的样式标签来改变网页上文本的外观。我们将探讨加粗和强调标签的用法,并了解如何嵌套这些标签以实现更复杂的文本样式。
简单样式标签的应用
我们将看到一些简单的样式标签如何改变页面上的文本外观。
以下是一个使用加粗标签的例子。<b> 标签通常使文本加粗,或使其从其他文本中突出显示。
请注意,这里同时有一个开始标签 <b> 和一个结束标签 </b>。这是另一个加粗的例子。
正如我们稍后将看到的,加粗文本的显示方式可能有所不同。例如,如果网页被屏幕阅读器使用,加粗的语义或含义有助于屏幕阅读器改变向盲人用户朗读文本的方式。
强调标签与标签嵌套
以下是一个使用强调标签 <em> 的例子。
这里是嵌套不同标签的一个例子。让我们看看所有这些内容将如何在网页上显示。
请注意,有些单词是加粗的,有些单词是通过斜体文本进行强调的。
这两行文本以加粗形式显示。这一行文本则使用斜体显示。
嵌套标签的效果
看看当你嵌套标签时会发生什么。这里,我们将加粗标签嵌套在强调标签内部。
最外层的标签是强调标签 <em>,最内层的标签是加粗标签 <b>。
最内层的标签应用于“example of nested”这些词,而强调标签内的其余单词则通过斜体文本进行强调。
标记语言与所见即所得的区别
你可能习惯于编辑Word文档,在那里你输入文本,所见即所得。
但对于标记语言,你所写的并非你直接得到的。你编写的HTML将以一种更美观的方式显示,即你指定它要显示的方式。
本节课中,我们一起学习了HTML中文本格式化的基本方法。我们了解了如何使用 <b> 标签使文本加粗,以及如何使用 <em> 标签强调文本。我们还探讨了如何通过嵌套标签来组合这些样式,并理解了标记语言与所见即所得编辑器在原理上的根本区别。
008:添加图像与链接 📸🔗

概述
在本节课中,我们将学习如何在网页中添加图像和创建超链接。这是使网页内容变得丰富和互联的两个基本技能。
在网页中添加图像 🖼️
上一节我们介绍了HTML的基本结构,本节中我们来看看如何向网页中嵌入图像。
你可以在网页上放置图像、视频和音频。例如,这里有一张希拉里·克林顿欢迎索尼娅·甘地的图片。

不同类型的媒体有不同的标签,但我们将向你展示如何使用图像标签,因为它是最直观的。
任何时候你想在网页上放置一张图片,你都需要使用图像标签。
以下是一个用于显示图片的图像标签示例。
<img src="image_url.jpg">
这是另一个例子。
<img src="picture.png" width="50">
这两个例子看起来略有不同,并且都包含我们之前未见过的新语法。
在图像标签的尖括号内,我们有一些“选项”,它们为我们正在做的事情提供额外信息。
在图像标签的例子中,我们需要指定要显示哪张图片。事实上,尽管它们被称为“选项”听起来是可选的,但有些选项是必需的。例如,我们必须指定我们想要显示哪张图像,所以这个选项是必需的。
请注意,图像标签没有单独的结束标签。图像标签在标签内指定了实际的图像位置,因此它不需要一个结束标签。它在标签内使用斜杠来表示不需要额外的结束标签。
正如我们刚才所说,你必须指定你想要什么图像,所以源或SRC选项是必需的。它指定了要加载的图像的网址。
width是一个可选标签。它可以用来减小显示图像的尺寸。否则,将显示图像的原始尺寸,这可能非常大。
你还可以指定其他选项,但当我们学习CSS时,会看到处理图像的更好方法。
在网页中添加链接 🔗
了解了如何添加静态图像后,现在让我们看看如何创建可点击的链接,将用户带到其他页面或网站。
以下HTML代码演示了如何链接到另一个网页,下方展示了此HTML在浏览器中的显示效果。
<a href="https://www.example.com">访问示例网站</a>
锚标签或A标签用于指定指向另一个网站的链接。
href属性指定了该网页的URL,并且是必需的。
你必须在开始和结束锚标签之间指定一些文本。这段文本将可以点击,并将你带到其他网站。
网页设计的挑战与基础 🎨
现在,让网页看起来非常漂亮并不容易。设计师能够制作出优雅的网页,是因为他们非常了解HTML。
设计师懂得如何使用工具来获得那些精美的网页。
现在你正在学习HTML的基础知识,这样你就可以开始创建简单的网页了。你正在学习一个可以在此基础上不断发展的基础。
请注意,网页可以在不同类型的设备上显示,比如你的笔记本电脑和手机。创建一个在大屏幕和小屏幕上都能良好显示的网页更具挑战性。
与网页进行交互,例如从亚马逊网页订购一本书,则更加复杂。
目前,让我们从基础开始,这样你就可以创建一个网页了。
总结
本节课中我们一起学习了HTML的两个重要功能:使用<img>标签添加图像,以及使用<a>标签创建超链接。我们了解了src和href等必需属性的用法,并认识到创建适配多设备且具有交互性的精美网页需要更深入的知识。掌握这些基础是构建更复杂网页的第一步。
009:图像与存储


在本节课中,我们将讨论在您创建的网页中使用图像或照片时可能面临的一些问题或注意事项。
概述
在本节课中,我们将要学习在网页中使用图像时涉及的两个核心问题:图像的使用权和图像的存储与托管。我们将了解如何寻找可合法使用的图像,以及理解引用外部图像资源可能带来的影响。
图像的使用权
上一节我们提到了在网页中插入图像的基本方法。本节中,我们来看看使用图像时首先需要考虑的法律问题:使用权。
当您在网上找到一张图片并希望将其用于您创建的网站时,您需要找到该图片的URL作为IMG标签的源。但在某些情况下,图片的创作者(个人、组织或团体)可能拥有特定的权利,即版权,您需要尊重这些权利。
版权法因国家/地区而异。如果您要制作可能具有商业价值的网页,您应该对您所在国家/地区的版权法有基本了解。通常,如果您仅为个人用途制作网页(例如为本课程创建的页面),则无需过多担心版权法。然而,了解使用权的基本概念仍然有益。
许多图像属于公共领域,没有版权限制。例如,本页显示的巴西国旗图像在美国、巴西和许多其他国家都属于公共领域。公共领域的图像您可以自由使用。
以下是寻找和使用图像时的一些关键点:
- 使用搜索引擎:您可以使用Google图片搜索来查找图像,并通过搜索工具筛选出允许“重用”的图片。
- 利用免费资源库:Wikimedia Commons 是一个网站和存储库,可以找到许多更自由使用的图像。
- 了解知识共享许可:一些图像采用知识共享许可,规定了您如何使用这些图像。例如,有些许可仅允许非商业用途,有些则要求您以类似方式许可您的作品(称为“相同方式共享”许可)。
在本课程中,我们尽量使用公共领域的图像。
图像的存储与托管
除了使用权,在您创建的网页中使用图像还可能涉及图像存储和托管的问题。
例如,假设您想在网页中加入中国国旗的图像。您通过Google图片搜索找到了一个属于公共领域的图片URL,并将其用于IMG标签的src属性。您做得很好,既考虑了使用权,也创建了一个很棒的网页。
但是,假设有100万人浏览了您的网页。这意味着国旗图像被展示了100万次,同时也意味着该图像从其存储的网站通过互联网传输给了遍布全球的100万用户。有人需要为托管该图像并将其提供给所有人而支付费用,即使这个人不是您。这是您需要理解的一个潜在问题。
当您在创建的网页中使用一个URL作为IMG标签的一部分时,您实际上创建了一个从您的页面到另一个网页的内联链接。

内联链接也称为热链接,这意味着图像存储在另一个站点上,但视觉上显示在您创建的站点中。

通常,对于您个人的页面,您无需担心版权和使用问题。然而,如果您创建的网页访问量很大,可能会产生存储成本或服务器成本,这是您应该了解的。不过,对于课程中或您个人使用的页面,这通常不是需要担心的问题。
实践指南:在网页中添加图像
当您想在创建的页面中包含图像时,使用Google图片搜索在线查找图像很容易。您需要找到图像的URL,以便在IMG标签的内联链接中使用。
以下是操作步骤:
- 查找图像URL:通常,您可以在Chrome或Firefox等网页浏览器中,通过右键单击(有时在Mac上是Control+单击)图像,然后选择“复制图片地址”来获取URL。
- 粘贴到HTML中:将复制的URL粘贴到您正在创建的页面的HTML代码中,作为IMG标签的
src属性值。代码格式如下:<img src="此处粘贴图片URL" alt="图片描述"> - 注意网站限制:有些网站不允许您热链接其存储的图像。例如,Pixabay托管许多公共领域图像,但不允许您在创建的网页中直接链接它们。
- 测试网页:您应该测试创建的网页以确保其正常工作。可以使用浏览器的“无痕/隐私浏览模式”来确保您像匿名用户一样访问页面,而不是以登录状态查看,这有助于发现权限问题。最好也能请他人帮忙查看您创建的网页,以确保他们能看到图像。
总结
本节课中,我们一起学习了在网页中使用图像的两个重要方面。首先,我们了解了图像的使用权,包括版权、公共领域和知识共享许可,并学会了如何寻找可合法使用的图像资源。其次,我们探讨了图像的存储与托管,明白了通过内联链接引用外部图像时可能涉及的带宽与成本问题,以及如何在实际操作中获取并正确使用图像URL。记住,对于个人和非商业项目,虽然问题不大,但建立这些基本认知对您未来的Web开发工作非常有帮助。
010:列表与表格 📋

在本节课中,我们将学习如何使用HTML来组织网页内容,特别是通过列表和表格这两种结构。你已经掌握了HTML的基础知识,能够使用段落和标题等标签。现在,我们将探索如何更清晰地展示信息。
无序列表
上一节我们介绍了HTML的基本文本内容,本节中我们来看看如何创建项目符号列表,即无序列表。
无序列表使用 <ul> 标签创建,列表中的每一项则使用 <li> 标签定义。列表项会默认以圆点符号标记。
以下是创建无序列表的代码示例:
<ul>
<li>苹果</li>
<li>香蕉</li>
<li>橙子</li>
</ul>
在 <ul> 标签内部,所有直接子元素都必须是 <li> 标签。然而,在 <li> 标签内部,你可以放置更多内容,例如文本、图片、链接,甚至是另一个列表。
有序列表

了解了无序列表后,有时你可能需要一个带编号的列表,即有序列表。
有序列表使用 <ol> 标签创建,列表项同样使用 <li> 标签定义。浏览器会自动为列表项生成编号(1, 2, 3...)。
以下是创建有序列表的代码示例:
<ol>
<li>第一喜欢的食物</li>
<li>第二喜欢的食物</li>
<li>第三喜欢的食物</li>
</ol>
与无序列表类似,你可以在 <li> 标签内放置各种元素。如果你在HTML中添加或删除列表项,编号会自动更新。
组合与嵌套
我们提到可以在列表项中放入多种元素,这种将元素组合在一起的能力称为组合。组合是计算机科学中的一个重要概念,它允许我们将简单易懂的部分组合成更复杂、更强大的整体。
一个常见的组合例子是嵌套列表,即一个列表位于另一个列表的列表项内部。
以下是创建嵌套列表的代码示例:
<ul>
<li>水果
<ul>
<li>苹果</li>
<li>香蕉</li>
</ul>
</li>
<li>蔬菜</li>
</ul>
在编写HTML时,每当一个元素嵌套在另一个元素内部,进行缩进是很好的习惯。这有助于你清晰地看到代码结构,方便后续编辑。
表格
除了列表,另一种组织信息的方式是使用表格,它允许你将内容按行和列排列。

表格使用 <table> 标签定义。表格中的每一行使用 <tr> 标签定义。行内的单元格分为两种:表头单元格使用 <th> 标签,数据单元格使用 <td> 标签。
以下是创建一个简单表格的代码示例:
<table>
<tr>
<th>食物</th>
<th>口味</th>
</tr>
<tr>
<td>柠檬</td>
<td>酸</td>
</tr>
<tr>
<td>糖果</td>
<td>甜</td>
</tr>
</table>
根据组合原则,你同样可以在 <td> 或 <th> 单元格内放置图片、列表等其他元素。
本节课中我们一起学习了如何使用HTML创建无序列表、有序列表、嵌套列表以及表格。这些结构能帮助你更有效地组织和展示网页信息。组合这一核心概念贯穿始终,它不仅是HTML的重要特性,也是整个计算机科学领域的关键思想,将在你未来构建更复杂的网页或程序时持续提供帮助。
011:CSS如何用于网页设计 🎨
在本节课中,我们将要学习CSS(层叠样式表)在网页设计中的核心作用。我们将探讨网页如何适应不同的用户和设备,理解HTML与CSS如何分工协作,以及CSS如何帮助开发者高效地创建可访问且可扩展的网页。
网页的显示体验会根据多种标准而有所不同。
设备的种类和屏幕尺寸会影响内容的显示方式和显示内容本身。
网页设计通常需要适应具有不同能力和残障情况的不同用户。
用户对网页的体验各不相同。有些用户是色盲,有些用户视力不佳,有些用户则难以点击细小的链接。
用户可能在电脑显示器上浏览网页,但如今,许多用户通过智能手机和平板电脑等移动设备访问网络。网页甚至可能被显示在巨大的屏幕上。


网页设计师在创建网页时必须将用户和设备因素考虑在内。
即使在学习网页创建和设计的基础知识时,您在创建网页时也可以并且应该牢记一些要点,努力使网页具有可访问性。


您将通过消除障碍来实现这一点,以帮助每个人都能访问您的网页。
有些人难以分辨某些颜色,有些人完全看不见,有些人则难以操作鼠标。
视力不佳的用户可能会使用屏幕阅读器来帮助浏览和体验网页。某些颜色和字体比其他颜色和字体更容易阅读。

某些字母表需要特殊字体才能良好显示。如果您开发更多网页,您会希望确保良好的用户体验。这意味着在可能的情况下,页面应该快速加载。
我们已经使用HTML(超文本标记语言)来创建全世界任何人都可以查看的网页。

HTML指定了文档的内容,即网页中出现的文字和图像。
一些格式化是通过HTML指定的。例如,不同大小的标题在HTML中由<h1>、<h2>或<h3>标签表示。
不同类型的列表在HTML中通过使用<ol>(有序列表)和<ul>(无序列表)标签来表示。它们显示方式不同,是因为浏览器解释HTML,并将标题和列表的样式更改为匹配HTML标记。
表格也使用HTML进行格式化,正如您所见,使用了许多标签来构建用于显示数据和其他信息的表格。
图像使用HTML显示,您已经看到,HTML图像或<img>标签的宽度可以通过width属性指定,或者通过向HTML标签添加样式来指定宽度。
这些样式和宽度的修改就是使用CSS来设计网页样式的例子。CSS是“层叠样式表”的缩写或首字母缩略词。
CSS指定了网页的外观和格式,而HTML指定了内容。
这使得网页设计师能够将内容与其呈现方式分离开来,从而适应不同的用户和不同的显示设备。
以确保即使在其他国家,网页也能在不改变内容的情况下显示。例如,<h1>标题标签应该多大?<h1>标题标签中的文本应该使用什么颜色?
颜色或大小是否应取决于用户以及用户如何体验网页(例如用户是否使用移动设备)?这些标签属性可以使用CSS来指定和更改。
如果您正在为一个网站创建数千个网页,更改HTML元素的颜色、字体或大小可以集中在一个地方进行,而不是在数千个地方重复修改,后者会使更改变得困难。
为规模而设计是计算机科学的一个关键部分,而CSS和HTML的结合使之成为可能。
本节课中,我们一起学习了CSS在网页设计中的重要性。我们了解到,CSS负责控制网页的视觉表现和布局,与定义内容的HTML相辅相成。通过将样式与内容分离,CSS不仅提升了网页对不同用户和设备的适应性,还极大地提高了大型网站开发和维护的效率。记住,良好的设计始于对内容和表现形式的清晰划分。
012:CSS基础 🎨


概述
在本节课中,我们将要学习CSS的基础知识。我们将了解CSS的语法结构,学习如何为网页元素应用样式,并探索如何通过类(Class)和ID来更精确地控制样式,以实现样式的复用。
上一节我们介绍了为什么需要使用CSS来分离网页的样式与内容,这涉及到计算机科学中可复用性和可维护性等重要主题。本节中,我们来看看如何编写自己的CSS来为网页添加样式。
CSS的编写位置
在学习编写CSS之前,我们先了解在哪里编写它。
在CodePen工具中,你一直在左侧面板编写HTML。右侧是CSS面板,其左上角标有“CSS”字样。如果你不使用类似CodePen的工具,从头开始编写网页,可以通过两种方式引入CSS:使用<style>标签并在其中编写CSS代码,或者使用<link>标签链接到一个外部样式表。这两种标签都应放在HTML文档的<head>部分。
CSS基础语法
我们将从一个描述美食的小型示例网页开始。左侧是无CSS的网页,其<h1>标签生成的标题是黑色文本且左对齐。假设你想让这个标题变成蓝色并居中,如右侧所示。
右侧的页面拥有相同的HTML,但我们使用了CSS来改变<h1>标签的格式。
以下是我们用来将<h1>标签样式设置为蓝色和居中的CSS代码。让我们详细分析它,以便你能编写自己的CSS来随心所欲地设计页面。
h1 {
text-align: center;
color: blue;
}
首先需要编写的是选择器,即你想要样式化的元素名称。在这个例子中,我们想样式化<h1>标签,所以在这里写h1。
接着,你编写花括号{},其中包含你想应用于<h1>标签的样式信息。在CodePen中,当你输入一个左花括号时,它会自动添加右花括号并将光标置于两者之间。
在花括号内的每一行,你先写属性,即你想要改变的样式方面。这里我们想改变文本对齐方式,其属性名是text-align。在属性名之后,你写一个冒号:。
冒号后面是你想给该属性设置的值。在这个例子中,我们想将text-align设置为center。在行末,你写一个分号;。
然后,你可以用相同的语法编写更多行。例如,我们写了color: blue;来将颜色属性设置为蓝色。CSS中有许多可以设置的属性,我们不会在此一一列举,而是建议你在需要时在线查阅更多资料。对于许多知识,你不应试图死记硬背,而应在需要时查找。如果你最终编写了大量CSS,你会逐渐熟悉那些经常使用的属性。
选择特定元素进行样式化
在你的NVIo测验中,你刚刚思考了这段CSS如何将列表项样式化为绿色。然而,这段CSS会使你整个网页中的所有列表项都变成绿色。
如果你想只让其中一些变成绿色,而用另一种方式样式化另一些,该怎么办?
我们将向你展示三种仅样式化特定元素中一部分的方法。
方法一:使用类(Class)
第一种方法是使用类,即一种命名的样式。要使用CSS类,你需要修改HTML,在想要样式化的标签中写入class=和你想要的类名。
现在在你的CSS中,选择器不再是HTML标签名,而是一个点.后面跟上类名。这个点表示你正在命名一个类。紧接在点之后,你应该写上你想给类起的名字。这个名字几乎可以是任何你想要的,但必须遵循一些规则,例如名称中不能包含花括号或空格。然而,你应该使名称具有描述性。
在这个例子中,我们选择了food-li,因为我们使用这个类来样式化描述食物的列表项。green会是这个类的好名字吗?尽管它描述了当前如何样式化列表项,但命名为green并不是一个很好的选择。如果我们后来决定将食物列表项样式化为紫色,这个样式名称就会产生误导。相反,我们最好根据页面部分的含义来命名它,我们想要样式化的是食物列表项。回顾HTML代码,你可以看到我们选择的名字的来源,它与我们在CSS中选取的类名相匹配。
以下是使用类的示例:
<!-- HTML -->
<li class="food-li">Pizza</li>
/* CSS */
.food-li {
color: green;
}
方法二:使用ID(ID)
另一种仅样式化特定元素类型中一部分的方法是使用ID。ID用于命名一个特定的元素。请注意类(可应用于多个元素)和ID(只能应用于一个元素)之间的区别。
在这个例子中,网页有一张蛋糕的图片,我们想以特定的方式样式化它。我们在<img>标签内指定了id="cake-img"。现在在CSS中,我们可以描述cake-img的样式。请注意,ID的选择器以井号#开头。
以下是使用ID的示例:
<!-- HTML -->
<img id="cake-img" src="cake.jpg" alt="A cake">
/* CSS */
#cake-img {
border: 2px solid brown;
}
方法三:组合器(Combinators)
我们将提及但不深入探讨的最后一种方法称为组合器。它们允许你指定标签之间的关系。例如,你可以指定想要以特定方式样式化<ul>内部的<li>,你可以通过将选择器写为ul li来实现。
还有更高级的关系,例如兄弟选择器。组合器是一个更高级的主题,你不需要掌握,但我们为那些想进一步探索的人提及它。
命名与复用样式
类和ID都允许你命名一种样式化元素的方式。
命名样式使你可以根据需要复用该样式。对于类,你可以在同一页面中以相同方式样式化多个元素。对于ID和类,你都可以在多个页面间复用同一样式。例如,如果你有一个徽标,想显示在网站每个页面的角落,你可以为它编写一次样式,然后在每个页面上复用该样式。
命名和复用是计算机科学中的一个常见主题。随着你更深入地学习编程,你会发现命名常量、算法或数据以便复用它们通常非常有用。
总结
本节课中,我们一起学习了CSS的基础知识。你了解了在CodePen中编写CSS的位置、CSS的基本语法,以及如何创建类和ID来命名和复用样式。掌握这些基础将帮助你开始为自己的网页设计独特而一致的视觉效果。
013:CSS中的颜色与命名 🎨

在本节课中,我们将深入学习CSS,特别是关于颜色的知识。上一节我们介绍了CSS的基础语法,以及如何使用类和ID来命名样式。本节中,我们将探讨如何在CSS中指定颜色,不仅可以通过名称,还可以通过数值,这是下一课“计算机中一切皆为数字”这一重要概念的一个实例。
颜色名称的使用
还记得上节课我们如何将H1标签的样式设置为蓝色并居中吗?我们使用了 blue 作为 color 属性的值来实现这个样式。
如果你想要使用其他颜色呢?我们在另一个例子中见过绿色,因此你可能猜到可以使用各种基本颜色,如 red 和 yellow,这是正确的。但你可能希望设计一个具有更复杂颜色选择的网页。
CSS支持更广泛的颜色名称,让你能够选择不同颜色的漂亮色调。以下是一些例子:
- Blue violet 是一种漂亮的紫色。
- Gold 当然是金色。
- Light blue steel 是一种好看的蓝灰色。
- Fire brick 是一种深红色。

实际上,有140种标准颜色名称,为你提供了丰富的选择。

你如何记住所有这些颜色呢?和许多其他事情一样,没有人会死记硬背这些内容。重要的是知道如何查找你需要的东西。你可以找到像这样的网站,它会向你展示各种颜色及其名称。
数值表示颜色
然而,即使140种颜色也不算多。如果你想要某种没有标准名称的颜色色调怎么办?我们似乎需要更多颜色,但需要多少呢?人类实际上可以感知数百万种颜色。
例如,这里有12种非常细微的蓝色色调,从左侧的非常深蓝到右侧的中等浅蓝。这些只是蓝色的几种色调,其中中间的这个条是杜克蓝。当然,还有更多蓝色色调以及其他颜色色调。
那么,我们如何处理数百万种颜色呢?给它们全部命名有些难以管理。一个问题是需要有人想出数百万个颜色名称并将其标准化。另一个问题是你需要浏览数百万个名称才能找到你想要的颜色。
限制可用颜色的选择并不是一个吸引人的选项。如果你真的想要一种特定的颜色,比如精确的杜克蓝,如果无法获得,你会感到不满意。
另一个选择是给每种颜色一个数字,这实际上是采用的方法。事实上,正如你稍后将学到的,对于计算机来说,一切皆为数字。因此,这种选择实际上是处理数百万种颜色的自然方式。
颜色作为数字的工作方式是,它们通过组合红色、绿色和蓝色的多少来指定,每个分量的值在0到255之间。这个方案足以指定大约1600万种颜色,这超过了人类能够区分的数量。
在CSS中,你可以通过指定其红、绿、蓝分量来指定颜色,方法是写 RGB,然后一个左括号,接着是红、绿、蓝的数值,每个用逗号分隔,最后是一个右括号。这个语法接收红、绿、蓝数字,然后将它们组合成一个单一的数值。
我们之前看到的每种颜色都在这里写有其RGB值。
你也可以通过其完整的数值来指定颜色,方法是写一个井号 #,后跟6个十六进制数字。十六进制意味着基数为16,因此每个数字的值从0到15。十六进制便于编写RGB值,因为每种颜色有256个可能的值,正好对应两个十六进制数字。这里的左边两位数字指定红色,中间两位指定绿色,右边两位指定蓝色。
你不需要能够进行十六进制转换,但对于好奇的人,我们可以仔细看看。你习惯了十进制数字,其中每一位是前一位的10倍,有个位、十位、百位等。在十六进制中,每一位是前一位的16倍,所以有个位、十六位、256位等。
如果我们看每种颜色的两位数字,我们有个位和十六位。这个颜色的红色是 8a,即十六位的8加上个位的a(即10),得到138。对于绿色,你得到十六位的2加上个位的B(即11),得到43。对于蓝色,你有十六位的E(即14)和个位的2,得到226。
使用颜色选择器工具
许多人发现通过图形方式选择颜色并让工具给出数字更容易。让我们用Mozilla的颜色选择器看一个例子。
这是Mozilla提供的颜色选择器工具,你可以在此处网页浏览器的顶部看到此工具的URL,但你也可以在本课的阅读材料中找到它的链接。
当我们查看此工具的主要部分时,你会看到一个彩色框和一个彩色滑块。


移动这个滑块可以调整颜色的色调,你可以看到红色、黄色、绿色、蓝色、紫色,再到红色。也许你想要一种色调,比如紫色。然后你可以在左侧的框中调整特定的色调。你可以得到灰色、或更亮、或更浅或更深。我选择这个颜色。
一旦找到你想要的颜色,此工具会在右侧显示这种紫色色调的数值信息。红色是232,绿色是73,蓝色是227。你也可以从底部的这个框中读取整个十六进制数字,它是 #E849E3。
还有一些更高级的功能,比如alpha,它允许你调整透明度,以防你正在分层对象。如果你想使用它,这个其他滑块可以让你改变透明度。






这个工具还显示HSL,这只是用数字表示颜色的另一种方式,但我们不担心这个。因此,如果你想以图形方式选择特定颜色,像这样的工具会非常棒。
总结
本节课关于CSS颜色的内容到此结束。我们学习了有许多标准名称,你可以在需要时查找。并且你可以通过数值指定数百万种颜色,要么通过写 RGB 和你想要的红、绿、蓝值,要么通过写一个井号和颜色的十六进制数值。我们还看到了一个颜色选择器工具,它让你查找想要使用的颜色,然后为你提供该颜色的数值,以便写入你的CSS中。
014:引言


在本节课中,我们将要学习计算思维的基本概念,以及如何开始使用Java进行编程。我们将探讨编程的用途,并介绍一个具体的图像处理问题作为学习案例。
到目前为止,你一直在学习制作网页。现在我们将转换方向,开始学习计算思维。计算思维是使你能够编写计算机程序的那种思维方式。

你将使用Java进行编程,以便最终可以用你的程序来增强你的网页。在这个模块中,你将学习一些基本的编程概念和Java语法。你将学习一个设计算法或编程问题解决方案的流程,以及如何使用我们为本课程开发的一些图像处理库。

上一节我们介绍了课程的整体方向,本节中我们来看看为什么要学习编程,以及编程能做什么。
编程非常适合解决那些涉及大量计算或重复操作的问题。例如,假设你想处理网页上的图像。由于图像由像素组成,要处理网页上的图像,你需要查看其中的所有像素。如果你有一张100x100像素的图像(这是一个相对较小的图像),你仍然需要查看10000个像素。这对人类来说很困难,但对计算机来说很容易。如果你尝试手动查看所有10000个像素,你可能会感到无聊,可能会出错,而且肯定会花费你很长时间。计算机可以在几秒钟内完成查看所有10000个像素的工作。😊
以下是编程在图像处理中的一个常见应用:
- 使用绿幕来改变图像的背景。如果你在绿幕前拍摄一张照片,你可以编写一个程序来改变图像中的每一个绿色像素,从而用另一张图像替换绿幕背景。
这是你将在本模块中重点关注的例子。你将学习我们解决问题的七步流程、JavaScript的基础知识以及解决绿幕问题所需的编程概念。
现在,让我们从编程背后的一个重要理念开始:万物皆数。
015:万物皆数字


欢迎回来。在本节课中,你将学习计算机科学的一个重要原则:万物皆数字。或者换一种说法:计算机只处理数字。
如果我们深入研究硬件,会发现它只处理比特,即0和1。我们不会深入探讨硬件的细节,但需要知道的重要一点是,计算机只能处理数字,并且实际上只能进行数学运算。事实上,计算机能做的任何事情,你也能做。计算机只是运算速度非常快,因此它们能比你或我手动操作更快地处理大量数据,速度可达数十亿倍。
幸运的是,由于一个称为“抽象”的绝妙原则,你通常不需要考虑比特的细节。在我们进一步讨论“万物皆数字”之前,我们先花点时间谈谈抽象。
什么是抽象?🤔
抽象是将接口(一个事物做什么或你如何使用它)与实现(它如何做到这一点,或它如何工作)分离开来。你可以通过一个与计算机无关的例子来理解抽象及其用处。
想一下开车。抽象让一个人可以在不知道汽车如何工作的情况下驾驶它。我知道如果我踩下油门踏板,我的车会加速。然而,我并不了解使这一切发生的、汽车内部复杂的运作机制。
抽象通常以层级形式存在,你需要工作在哪个层级取决于你需要做什么。对于一个开车的人来说,适合思考的抽象层级是汽车的控制装置做什么。引擎盖下的内部运作是隐藏的,并不重要。然而,对于一个机械师来说,引擎盖下的细节很重要,是她日常工作的内容。但即使在那里,也存在不同的抽象层级。机械师关心的是发动机的各个部件如何组合在一起并协同工作,但可能不关心设计发动机所涉及的物理学原理。
设计汽车的工程师会在那个抽象层级上工作,应用物理学来制造一辆正常工作的汽车。当然,还有更低的抽象层级,比如更理论化的物理学,他们并不关心。我们可以一直向下追溯,直到触及人类知识边界最理论化的物理学。所有这些,都是你开车不需要知道的事情。
这些相同的概念也适用于编程。你需要的抽象层级取决于你正在做什么。在上这门课之前,你可能在使用计算机时对程序如何工作一无所知。现在你将学习程序如何工作,但还会有更深层次的抽象,你通常不需要了解。
万物皆数字:窥探内部🔢
现在你了解了抽象,让我们回到“万物皆数字”的原则,给你一点窥探内部的机会。你可能会对这个想法感到有点惊讶,因为你习惯于使用计算机处理那些看起来不像数字的东西,比如字母。然而,这些东西相对容易编码为数字。我们可以设定 A=1,B=2,依此类推。
计算机实际做的是表示字符,即可以是字母、数字、标点符号等的符号。编码字符的一种方式是ASCII码,其中大写A是65,小写a是97,感叹号是33,其他各种字符也有其他数值。如果你需要查找一个字符的数值,你可以在ASCII码表中找到它们。然而,由于抽象带来的便利,你通常不需要担心具体的数值。
一旦我们有了字符,我们也可以有字符串,即字符的序列,例如“Hello!”。字符串在计算机科学中经常出现,因为程序员经常需要处理文本。事实上,你已经在HTML中见过字符串。字符串是抽象的另一个绝佳例子。你可以写下“Hello!”,它会被转换成你的计算机可以处理的数字。你通常不需要考虑数字表示形式,但如果你的编程任务需要,你可以操作它。
为什么“万物皆数字”如此重要?🤔
那么,为什么知道“万物皆数字”如此重要呢?首先,有时你想对那些看起来不像数字的东西进行数学运算。密码学,即保护信息安全的科学,依赖于对字符串进行数学运算的能力。当你使用HTTPS访问一个网站时,你的计算机和网络服务器之间来回发送的信息是加密的,这样其他人就无法读取。这个过程涉及对这些数据进行数学运算。
其次,理解“万物皆数字”很重要的另一个原因是,这就是为什么编程语言有“类型”,类型告诉它如何对这些数字进行操作。尽管万物皆数字,但程序员希望以不同的方式解释这些值。这些数字是代表字母吗?它们代表图片吗?它们实际上只是代表普通的数字吗?数据的类型告诉了我们这些数字的含义,从而决定了如何对它们进行操作。
以下是两种不同类型数据相加的例子:
- 字符串相加:如果我们把两个字符串加在一起,我们可能想要连接它们,将一个放在另一个后面以形成更长的字符串。在这种情况下,字符串“1”加上字符串“1”会是字符串“11”。
- 数字相加:如果我们只有数字1,并将它与1相加,我们会得到2。
第三,“万物皆数字”原则很重要的另一个原因是,每当你想要处理数据时,你都需要将其表示为数字。你可以利用现有的类型,比如字符串(它已经将信息表示为数字)来帮助你,这要归功于抽象带来的便利。
程序本身也是数字💻
另一件可能让你惊讶的事情是,程序本身也是数字。不过,既然你刚刚学到万物皆数字,这可能也不会让你太惊讶。

正如你已经看到的,你通过编写文本来向计算机表达你的算法。你输入的代码就是一个字符串。另一个程序接收这个字符串,并找出如何将其转换为计算机可以执行的数字指令。
程序是数字这一事实实际上非常强大。这意味着你可以在计算机上下载并运行新程序,它们就像其他所有东西一样,只是数据。程序也像数据一样,这一事实是许多安全问题的核心。黑客提供程序输入,这些输入包含他们想要执行的指令的数字编码,然后诱骗程序运行它。
当然,当你编写程序时,你不需要担心数字编码的细节,这都归功于抽象这个绝妙的思想。
总结📝
在本节课中,我们一起学习了“万物皆数字”这一重要原则,因为计算机只能进行数学运算。我们还学习了“抽象”,即接口与实现的分离,以及这意味着你并不总是需要考虑事物如何表示为数字才能用计算机处理它们。
016:数字的表示原理 🖥️


在本节课中,我们将要学习计算机如何用数字来表示图像等复杂信息。我们将从像素的概念入手,理解颜色如何被分解为数字,并探索如何通过对这些数字进行数学运算来编辑和处理图像。
从像素到数字 🎨
上一节我们介绍了计算机用数字表示文字和逻辑的原理。本节中我们来看看图像是如何被数字化的。
计算机屏幕上的图像由无数微小的点组成,这些点被称为像素。每个像素都是一种单一的颜色。
每个像素的颜色在计算机中由三个数字分量来共同表示:
- 红色分量
- 绿色分量
- 蓝色分量
每个分量的数值范围是 0 到 255。其中,0 表示完全不包含该颜色,255 表示该颜色的最大强度。因此,一个像素可以表示为 (R, G, B) 的形式,例如纯红色是 (255, 0, 0)。
由于整幅图像由成千上万个像素组成,因此整幅图像就对应着成千上万个这样的数字组合。
对颜色进行数学运算 ➕
既然颜色被表示为数字,我们就可以对它们进行数学运算。以下是颜色加法的一个例子。


如果我们取洋红色(Magenta)和绿色(Green)相加:
- 洋红色的 RGB 值为:
(255, 0, 255) - 绿色的 RGB 值为:
(0, 255, 0)



将它们的每个分量分别相加:
红色分量:255 + 0 = 255
绿色分量:0 + 255 = 255
蓝色分量:255 + 0 = 255
得到的结果 (255, 255, 255) 代表白色(White)。
图像处理的应用实例 🛠️
通过对构成图像的像素数字进行数学运算,我们可以解决许多实际问题。
以下是几种常见的图像处理操作:
- 调整亮度:使图像变亮或变暗。
- 调整色调:使图像更红或更蓝。
- 图像压缩:例如生成JPEG文件,在几乎不影响人眼观看效果的前提下,减少文件大小,加快网络传输速度。
- 视频编解码:电影编解码器软件通过对构成视频每一帧的图像进行大量数学运算,来实现视频的编码和解码。
绿幕算法实践 💚
一个经典的应用是“绿幕”(或蓝幕)技术。其核心算法是遍历图像中的所有像素,识别出特定颜色(如绿色)的像素,并将其替换为另一幅图像的对应像素。

例如,我们可以实现一个算法,将视频中所有绿色的背景替换成恐龙或外太空的场景。这正是许多电影特效和虚拟演播室所采用的技术。
本节课中我们一起学习了计算机用数字表示图像的基本原理。我们了解到图像由像素构成,每个像素的颜色通过红、绿、蓝三个数字分量来定义。正因为颜色是数字,我们可以对其进行数学运算,从而实现亮度调整、压缩、绿幕合成等多种强大的图像处理功能。理解这一原理是进行更高级编程和软件工程开发的重要基础。
017:算法开发 🧠

在本节课中,我们将学习如何通过一个具体问题——绿幕(色度键)问题——来理解算法开发的一般过程。我们将从手动解决一个小规模问题开始,逐步推导出通用算法,并最终为编写代码做好准备。
理解问题 🎬
绿幕问题,也称为色度键问题,因为它基于颜色的色度或色调。就像在闪光灯拍摄的照片中去除红眼一样,这在影视制作中很常见。它允许演员在摄影棚中被录制,然后被放置在一个由另一张图片或视频构成的背景前。这个算法就是让我和Drew能在太空中与恐龙对话的原因。
现在,我们将通过解决这个问题,作为学习如何应对一般编程问题的示例。
第一步:手动解决问题 ✋
在解决编程问题之前,我们需要做的第一件事是,自己完全弄清楚如何解决这个问题。在你完全理解如何以精确的、一步一步的方式完成任务之前,你无法编写程序、向计算机解释它需要做什么。事实上,这通常是编程中最难的部分。
现在,尝试解决这个特定的绿幕问题示例会非常困难,因为这些图像每张大约有200万个像素。手动操作它们将花费不可能完成的时间。相反,手动解决一个较小规模的问题实例是一个好主意,以便深入理解如何解决问题。

在本例中,我们将查看一个2像素乘2像素的图像。

我们将首先选择一个2x2的图像作为前景,即要放在顶层的图像。然后选择一个2x2的图像作为背景。
此外,我们还需要一个图像来保存最终结果或输出。这也应该是2x2的。
现在示例已经选定,让我们来弄清楚输出图像的每个像素应该是什么颜色。

很好,现在我们手动解决了一个问题实例。

第二步:写下步骤 📝
下一步是以一步一步的方式准确写下我们所做的事情。
- 我从前景图像(我称之为FG图像)开始。
- 以及背景图像(我称之为BG图像)。
- 然后我创建了一个相同大小的空白图像,称之为输出图像。
- 我查看了FG图像中的第一个像素,它是红色的。因此,我将输出图像中对应的像素也设置为红色。
- 我查看了FG图像中的第二个像素,它是绿色的。因此,我查看了BG图像中相同位置的像素,并将输出图像中对应的像素设置为BG图像的像素颜色。
- 我查看了FG图像中的第三个像素,它是绿色的。因此,我查看了BG图像中对应的像素,它是蓝色的。于是我将输出设置为相同的颜色。
- 然后我查看了FG图像中的第四个像素,它是蓝色的。因此,我将输出图像中对应的像素设置为蓝色。
很好,现在我们有一套一步一步的指令,准确地描述了我们如何为这一对特定图像解决问题。但是,要编写程序,我们需要能够为任何大小、任何图像解决这个问题。
第三步:泛化与模式识别 🔍
现在,如果你仔细观察这些步骤,你会发现有很多相似之处。我们对图像中的每个像素所做的操作几乎相同,但又不完全一样。
当FG图像的像素是绿色时,我们使用BG图像的像素;当FG图像的像素不是绿色时,我们直接使用FG图像的像素。
回到我们一步一步的指令,我们将重写每一步,使其更通用一些,考虑到我们刚刚观察到的这种条件行为。
你可以为每个像素的步骤都这样做,现在每一步都更通用、更相似,它们将适用于任何2x2的图像,但仅限于2x2的图像。
在这里,我们改进了逐步指令,以表达对每个像素的重复操作。现在,这些步骤已经足够通用,可以适用于任何尺寸的图像。事实上,我们所做的是设计一个算法。算法是一套清晰的、一步一步的指令,用于解决你想要解决的任何问题实例。你可以用英语表达算法,就像我们在这里所做的那样,或者用代码表达,就像我们需要让计算机运行它那样。
第四步:测试算法 🧪
每个人都会犯错。在设计算法时,有很多不同的犯错方式。例如,你可能没有正确识别模式,或者可能没有正确地泛化每个步骤。为了防范这类错误,让我们在一个不同的示例上测试我们的算法,看看它是否按预期工作。如果有效,我们将更有信心认为我们做对了。如果无效,我们就在编写代码之前及早发现了错误。当然,一旦我们编写了代码,我们将希望更彻底地测试它,但这我们稍后再谈。
当你逐步执行算法时,你需要跟踪你正在执行哪一步。对我们来说,我们将画一个绿色箭头来显示我们在算法中的位置。
然后,你需要完全按照所写的方式执行每一步。
在这里,我选择了一个3x1的图像作为前景,这个3x1的图像作为背景,以及一个3x1的图像作为输出。
我将在上面的绘图中用蓝色箭头跟踪哪个像素是当前像素。
第一个像素是绿色的,所以算法说查看背景图像中的相同像素,它是黄色的。并将输出图像中的相同像素也设置为黄色。
这就是该像素的所有步骤。所以现在算法说转到下一个像素并重复这个过程。
我们将继续按照这些步骤执行。完全按照所写的方式。直到我们完成所有步骤。
此时,我们想看看输出的图像是否符合预期。在本例中,输出是正确的。
总结 📚
本节课中,我们一起学习了算法开发的核心流程。我们从手动解决一个小规模绿幕问题实例开始,然后精确记录步骤。接着,我们识别并泛化了操作模式,将其转化为一个适用于任意尺寸图像的通用算法。最后,我们通过另一个示例测试了算法,验证其正确性。这个过程——理解问题、手动求解、记录步骤、识别模式、泛化算法、测试验证——是解决任何编程问题的通用方法。现在,既然我们的算法看起来有效,是时候编写代码了。
018:解决编程问题的七步法 🧩

在本节课中,我们将学习一个系统性的方法来解决编程问题。我们将详细介绍一个包含七个步骤的流程,它能帮助你从问题描述出发,最终得到可运行的代码。这个方法不仅适用于本课程,也能成为你未来解决任何编程问题的有效工具。
在上一课中,我们通过“绿幕”问题的实例,一步步地将问题描述转化为可工作的代码。本节中,我们将更深入地审视解决编程问题的过程,并正式介绍这个七步法。
第一步:手动解决一个小规模实例
首先,你需要手动解决一个具体且小规模的问题实例。这能帮助你理解问题的本质。
- 目的:避免一开始就处理复杂情况(例如包含数百万像素的真实图像),而是从一个可管理的规模入手。
- 难点:如果这一步遇到困难,可能有两个原因。
- 一是问题描述本身不清晰,你需要寻求澄清(例如询问老师、技术负责人或客户)。
- 二是你可能缺乏相关的领域知识(例如物理公式),这时你需要先补充相关知识。
第二步:写下解决该实例的确切步骤
在成功手动解决一个小实例后,接下来需要将你的思考过程精确地记录下来。
以下是你在这一步需要做的事情:
- 具体化:写下你为解决那个特定小实例(例如四像素图像)所采取的每一步操作。
- 精确性:计算机没有常识,因此你必须非常精确。我们人类许多下意识的思考过程,都需要被明确地表述出来。
第三步:寻找模式
现在,我们需要将解决特定实例的步骤,推广为能解决所有同类问题的通用算法。
上一节我们记录了具体步骤,本节中我们来看看如何从中抽象出通用模式。
- 寻找循环:观察哪些步骤被重复执行,以及执行的次数。这将引导你使用循环结构。
- 识别条件:观察在什么情况下执行某些操作,而在其他情况下不执行。这将引导你使用条件结构,如
if-else。 - 分析数值:思考你使用的特定数字是输入的一部分,还是与输入相关。你需要理解使用它的原因。
- 应对困难:如果在此步骤遇到困难,可以返回第一步和第二步,用不同的输入重新操作,以收集更多信息来寻找模式。
第四步:手动检查算法
在得出你认为的通用算法后,不要急于编码,先用手动计算来验证它。
- 目的:检查算法中可能存在的错误,例如忽略了某些特殊情况,或无意中使用了特定于测试参数的值。
- 方法:使用一个或多个不同的、易于手动计算的小规模输入来测试你的算法。
第五步:将算法转化为代码
当你对算法有信心后,就可以将其转化为具体的编程语言代码了。
到目前为止,我们都在抽象地设计算法。现在,我们将进入实现阶段。
- 实现:根据你使用的编程语言(例如JavaScript)的语法,将算法表达出来。
- 公式/代码示例:例如,一个简单的条件判断在代码中可能表现为:
if (pixel.getGreen() > threshold) { ... }。
第六步:运行测试用例
代码编写完成后,需要通过运行测试来验证其正确性。
- 执行与验证:运行你的程序,检查输出结果是否符合你对问题解决方案的预期。
- 结果处理:如果测试通过,则增加你对程序的信心。如果测试失败,则进入下一步。
第七步:调试失败的测试用例
当测试失败时,你需要使用科学的方法来定位和修复问题。
- 科学方法:这是一个系统化的调试过程,我们将在下一课详细讨论。
- 问题定位:调试过程将帮助你理解问题所在。
- 修复路径:
- 如果问题是算法性的(逻辑错误),你需要返回第三步修正算法。
- 如果问题是实现性的(代码编写错误),你需要返回第五步修正代码。
本节课中,我们一起学习了解决编程问题的七步法:从手动解决小实例开始,到精确记录步骤、寻找通用模式、手动验证算法、翻译成代码、运行测试,最后系统化地调试。掌握这个系统性的方法,将为你应对未来各种编程挑战打下坚实的基础。
019:变量
概述
在本节课中,我们将要学习编程中的核心概念之一:变量。变量是代码中用于存储和命名数据的容器,是将算法(例如我们之前讨论的绿幕算法)转化为实际代码的基础。我们将了解如何声明、初始化和更新变量,以及如何使用它们来存储不同类型的数据,例如数字和图像。
从算法到代码
上一节我们介绍了绿幕算法的基本思路。本节中我们来看看如何将这些思路转化为代码。要实现这个算法,我们需要掌握几个关键的编程构件。
以下是实现算法所需的几个核心代码构件:
- 变量:为算法中产生的值(如前景图像、背景图像)提供名称。
- 图像操作:创建图像(读取现有图像或创建空白图像),并检查和设置像素的颜色。
- 循环:重复对图像中的每个像素执行某些步骤,这需要通过
for循环 来实现。 - 条件判断:根据像素是否为绿色来决定下一步操作,这需要通过
if-else语句 来实现。
变量的声明与初始化
变量是存储数据的“盒子”。在JavaScript中,我们使用 var 关键字来声明一个新变量。
以下是一个声明并初始化变量的JavaScript语句示例:
var x = 3;
- 声明变量:告诉JavaScript你想要创建一个新变量。
- 初始化变量:在创建变量时赋予其第一个值。虽然有些语言不要求初始化,但始终进行初始化是一个好习惯。
让我们分解这个语句的语法:
var:关键字,表示要声明一个新变量。JavaScript区分大小写,必须全部小写。x:变量的名称。你可以为变量取任何名字,但使用描述性的名称(如fgImage)会使代码更易读。=:赋值运算符,表示将右侧的值赋予左侧的变量。3:要赋予变量的初始值。;:语句结束符。在许多编程语言中,分号的作用类似于英语句子中的句号。
变量的语义(含义)
理解了语法,我们来看看语义——代码的实际含义,即你告诉计算机做什么。
想象一个“绿色箭头”指向代码行之间,表示当前执行的位置。执行 var x = 3; 语句的效果是:创建一个标签为 x 的盒子,并将数字 3 放入其中。
使用变量进行计算
变量可以参与更复杂的表达式。表达式是值和操作符的组合,用于计算出一个新值。
请看以下包含三个变量的例子:
var x = 3;
var y = 4;
var z = x + 2 * y;
执行过程如下:
- 创建盒子
x,放入3。 - 创建盒子
y,放入4。 - 计算
z的初始值:查找x的值(3)和y的值(4),计算3 + 2 * 4,结果是11。然后创建盒子z,放入11。
更新变量的值
你可以使用赋值语句来更新已有变量的值。注意,更新时不需要 var 关键字。
x = z - 1;
y = y * 2;
执行过程如下:
x = z - 1;:计算z - 1(11 - 1 = 10),然后将x盒子中的值更新为10。y = y * 2;:这不是代数方程。它遵循赋值规则:查找y的当前值(4),计算4 * 2 = 8,然后将y盒子中的值更新为8。
存储非数字数据:对象
变量不仅可以存储数字,还可以存储图像、文本等。在底层,一切数据都是数字,但通过不同的数据类型(如图像、字符串),这些数字会被以特定的方式解释。
以下是如何创建存储图像的变量:
var fgImage = new SimpleImage(“drewRobert.png”);
var bgImage = new SimpleImage(“dinos.png”);
执行效果是创建两个盒子 fgImage 和 bgImage,它们的值不是直接放在盒子里,而是指向由 new 关键字创建的新图像对象。你可以把盒子想象成保存着一个“箭头”,箭头指向实际的数据。
让我们分析这个表达式 new SimpleImage(“drewRobert.png”):
new:关键字,告诉JavaScript创建一个新的对象。对象是包含数据以及可对其进行操作的方法(函数)的实体。SimpleImage:要创建的对象的类型,这是Duke编程入门库的一部分,提供了简单的图像处理方式。(“drewRobert.png”):参数,放在括号内,为对象的创建提供更多信息。这里是一个字符串,指定了要加载的图像文件名。
关于对象和类的更多细节将在后续课程中深入探讨。
总结
本节课中我们一起学习了编程的基础构件——变量。
- 我们了解了变量是用于保存值的命名盒子。
- 我们学会了如何使用
var关键字声明和初始化变量,以及如何使用赋值语句更新变量。 - 我们认识了表达式,它是变量、常量和操作符(如
+,-,*)的组合,用于计算新值。 - 我们初步接触了
new SimpleImage,它允许我们通过加载现有图像文件来创建一个新的图像对象。
掌握这些概念是将绿幕算法转化为可运行代码的第一步。在接下来的课程中,我们将学习如何操作这些图像对象及其像素。
020:方法
在本节课中,我们将要学习Java中一个核心概念——方法。我们将了解如何调用方法来操作对象,例如获取图像的尺寸或像素信息。
什么是方法?
上一节我们介绍了变量,但你也需要知道如何执行特定操作,例如查看特定像素或改变其颜色。如何做到这些呢?答案是你可以调用一些内置于SimpleImage类中的方法,这些方法已经包含了执行这些操作的代码。
总的来说,方法让你能够对一个对象执行某些操作,这些操作可能相当复杂。
方法调用的语法
以下是调用两个方法getWidth和getHeight来分别获取图像宽度和高度的例子。
让我们分解一下语法:
- 首先,是对象的名称。我们想要在这个对象上调用方法。
- 接下来是一个点
.。点运算符表示“在...内部”。我们想要fgImage内部的getHeight方法。 - 然后是我们想要调用的方法名称,在这个例子中是
getHeight。 - 最后是括号
()。名称后面的括号表明它是一个方法或函数。
如果方法需要任何参数,你需要在括号内指定它们。
方法调用的语义
现在你已经看到了方法调用的语法,让我们看看它的语义。在我们逐步分析其行为之前,我们先描述一下语义。
以下是方法调用时发生的步骤:
- 执行进入方法。你停止执行当前位置的语句,进入方法的代码内部,然后执行那里的所有代码。
- 你通过遵循执行代码的所有常规规则来执行那些代码。无论代码是否在方法内部,其语义都是相同的。
- 在某个时刻,方法会计算出它想要返回的答案,这被称为返回值。
- 方法调用(实际上是一个表达式)的结果就是方法体返回的那个值。
- 然后你继续执行方法调用之后的代码。
方法调用示例
让我们看一个具体的例子来理解这些规则。
SimpleImage fgImage = new SimpleImage("flower.jpg");
int w = fgImage.getWidth();
int h = fgImage.getHeight();
第一行代码我们之前见过。它创建了一个图像,并将fgImage初始化为指向它。
第二行是调用fgImage.getWidth()。getWidth的代码在Duke Learn to Program库中,我们实际上不知道代码是什么,但只要我们知道它的作用就没关系。计算机会进入那段代码并开始执行。该代码会计算出fgImage所指向图像的宽度是480像素,并决定480就是它的答案。由于这个方法的返回值是480,调用fgImage.getWidth()的结果就是480。你可以把它想象成一个结果为480的数学运算。现在,执行返回到它被调用的地方,也就是这个赋值语句的中间。我们通过创建一个名为w的盒子并将480放入其中来完成这个赋值语句。
第三行,对fgImage.getHeight()的调用也发生类似的过程。计算机执行Duke Learn to Program库中的代码,计算出答案是270,并将该值返回到方法被调用的地方。然后执行返回到方法被调用的地方,赋值语句像往常一样完成。
如何了解方法的功能?
你如何知道一个方法是做什么的呢?如果你有代码,你可以通过逐行跟踪代码的语义来了解它的作用。
但如果你没有代码,就像本例中这样,该怎么办?你应该阅读文档,即关于方法功能的书面描述。对于SimpleImage,你可以在DukeLearnToProgram.com上找到相关文档。
如果你访问DukeLearnToProgram.com网站,你会看到一个文档链接。点击它,在左侧你会找到一个记录的主题列表。其中之一就是SimpleImage。点击它,你会进入一个列出SimpleImage中各种方法的页面,包括getHeight。如果你查看这个条目,你会看到它描述了方法的行为:它以像素为单位给出图像的高度。
方法作用于特定对象
我们说过方法是在对象上调用的,但这到底意味着什么?如果你要获取图像的高度,你想要的是哪个图像的高度?答案是你在哪个对象上调用方法,就获取哪个对象的高度。
为了在实践中理解这一点,让我们看一个包含两个图像的例子。
SimpleImage fgImage = new SimpleImage("flower.jpg"); // 宽度 480
SimpleImage bgImage = new SimpleImage("beach.jpg"); // 宽度 140
int w1 = fgImage.getWidth();
int w2 = bgImage.getWidth();
我们的第一行代码创建了一个名为fgImage的图像。第二行代码创建了另一个名为bgImage的图像。
现在我们要执行fgImage.getWidth()。由于我们是在fgImage上调用getWidth方法,它将处理fgImage所指向的图像并给出其宽度。执行跳转到Duke Learn to Program库中的getWidth方法,并执行那里的代码。注意,该方法查看的是fgImage并找到其宽度,得出答案为480。方法调用的结果是480,赋值语句随之执行。
下一行是bgImage.getWidth()。由于这个方法是在bgImage上调用的,该方法将操作bgImage所指向的图像。同样,执行跳转到Duke Learn to Program库的代码中并执行那里的操作。然而,这次它处理的是不同的图像,因此得出了不同的答案:140,这就是该方法调用的结果。最后,我们返回到方法调用处并完成赋值语句。
带参数的方法
有些方法需要参数。例如,如果你想在图像上调用getPixel方法来获取一个特定的像素,它可能看起来像这样:
Pixel p = fgImage.getPixel(0, 0);
注意,我们在括号里有0, 0。这些是什么意思?这些是方法的参数。它们为方法提供了关于它应该做什么的更具体的信息。
在getPixel这个具体例子中,参数通过给出所需像素的X和Y坐标来指定它应该从图像中获取哪个像素。参数的具体含义对每个方法都是特定的,但应该在文档中描述。
总结
本节课中我们一起学习了Java中的方法。方法将多个可能复杂的步骤组合在一起,这些步骤作用于一个特定的对象。我们了解了方法调用的语法和语义,如何通过文档了解方法的功能,以及方法如何根据被调用的对象产生不同的结果。我们还看到了带参数的方法如何接收额外信息来执行更具体的任务。接下来,我们将讨论一个类似但不作用于对象的概念——函数。
021:函数
在本节课中,我们将要学习Java编程中的一个核心概念——函数。我们将了解函数是什么,它与之前学过的方法有何不同,以及如何声明和调用一个简单的函数。通过一个具体的例子,我们将一步步解析函数的语法和执行过程,并理解函数在编程中为何如此重要。
函数与方法
上一节我们介绍了方法,它是一系列操作特定对象的复杂步骤的组合。
本节中我们来看看一个相似但不操作特定对象的概念,称为函数。例如,print(x) 是对 print 函数的调用,它会打印出变量 x 的值。这是一个函数,因为它不属于任何特定的对象。
请注意,在 print 这个词前面没有 对象. 的语法,但除此之外,调用语法是相同的。
你可以编写自己的函数,这非常有用,可以避免代码重复,并使测试和调试更容易。你也可以编写自己的方法,但本课程中我们不会讨论如何编写类和方法。我们将在后续的Java课程中讨论它们。
编写一个函数
为了看一个编写自己函数的例子,让我们来看一个相对简单的示例:一个接收一个数字并将其平方的函数。
function square(x) {
var ans = x * x;
return ans;
}
var y = square(4);
我们首先定义了函数,然后在后面调用了它。
语法解析
现在让我们分解语法,然后看看其语义。
以下是函数声明的组成部分:
- 关键字
function:表示我们即将定义一个新函数。 - 函数名:与变量类似,你可以选择几乎任何你想要的名称,但它应该具有描述性。在本例中,
square是一个好名字,因为它描述了函数的功能:对一个数字进行平方。 - 参数列表和括号:这里我们声明参数的名称。它们将像变量一样工作,其值在函数被调用时由传入的值初始化。它们告诉函数具体应该做什么。在本例中,参数
x指明了要对哪个特定数字进行平方。如果有多个参数,我们可以用逗号分隔它们的名称。 - 函数体:这是指定函数应执行什么操作的代码。函数体总是位于花括号
{}内。 - 返回语句:函数体中有一个新类型的语句:
return ans;。这是一个返回语句,它是函数说明其答案的方式。返回语句的语法是关键字return,后跟一个表达式,该表达式的值就是函数应返回的答案,最后以分号结束。
在后面的代码中,我们调用了这个函数:var y = square(4);。这是我们使用它的方式。这里我们对 4 进行平方,并使用该计算的结果来初始化变量 y。
执行过程
现在你已经看到了声明和调用函数的语法,让我们逐步分析其行为。
函数声明本身不会被执行。它只是告诉计算机这个函数的含义,以供后续使用。
所以,我们从变量声明之前开始执行。
- 创建栈帧:调用函数时,我们需要做的第一件事是为它创建一个栈帧。这是函数拥有自己的参数和变量的空间。正如你很快将看到的,当函数执行完毕后,它们就会消失。
- 初始化参数:接下来,我们需要为
square函数期望的每个参数创建一个“盒子”。在本例中,square期望一个名为x的参数,所以我们将创建一个名为x的盒子。我们用值4初始化参数x,因为这是函数调用时传入的值。 - 记录调用位置:我们将在栈帧的左上角以及代码中调用位置的上方做一个标记(例如数字1)。在这个例子中,这看起来可能微不足道,但如果
square函数更复杂并且在许多地方被调用,我们可能会忘记返回到哪里。 - 跳入函数体:现在栈帧已设置好,我们跳转到
square函数的代码中。执行箭头移动到函数体内第一行代码之前。 - 执行函数体:现在我们开始根据所有常规规则执行这里的代码。我们为
ans创建一个盒子,并将其初始化为4 * 4,即16。 - 执行返回语句:现在我们位于返回语句之前。返回语句是一个新概念,它告诉计算机从这个函数返回一个答案。在本例中,要返回的答案是
16,因为我们返回的是ans,而ans的值是16。这意味着我们正在执行的square调用将计算出值16。 - 返回调用点:现在我们的执行返回到我们进行调用的地方,并正常完成赋值语句
var y = 16;。
函数的重要性
好了,现在你知道了如何调用方法和函数,以及如何编写自己的函数的基础知识。但为什么这些如此重要呢?
它们是抽象的一个绝佳例子。它们将一个可能相当复杂、或者可能需要了解你不应关心的细节的计算打包起来,并为该计算提供一个简单的接口。
例如,简单图像方法 getWidth 的接口是:你在一个图像上调用它,它会给你图像的宽度。其实现隐藏在 Duke Learn to Program 库中,你从未见过它,也不需要为了使用它而看到它。
总结
本节课中我们一起学习了Java编程中的函数概念。我们区分了函数与方法,了解了函数的基本语法,包括如何声明一个带有参数和返回值的函数。通过一个“平方”函数的例子,我们详细跟踪了其执行过程,从栈帧创建到参数传递,再到函数体执行和返回值。最后,我们理解了函数作为抽象工具的重要性,它隐藏了复杂实现的细节,为程序员提供了清晰易用的接口。掌握函数是构建模块化、可维护代码的关键一步。
022:类型


在本节课中,我们将要学习编程中的一个核心概念——类型。我们将了解什么是类型,为什么它很重要,以及它在JavaScript中是如何工作的。
类型的重要性
上一节我们介绍了处理图像和数字的代码。但如果我们尝试做一些没有意义的事情,比如对一个数字调用获取宽度的方法,会发生什么呢?
例如,以下代码会引发错误:
let w = 480;
w.getWidth(); // 这行代码会导致程序崩溃
程序会在执行到这行代码时崩溃。那么,为什么对图像调用.getWidth()方法可以,而对数字调用就会使程序崩溃呢?
如果我们回顾描述程序状态的图示,会发现我们处理的是两种不同类型的数据。fgImage引用的是一个简单的图像,它拥有getWidth方法。而w是一个数字,这种类型的数据没有getWidth方法。
什么是类型?
请记住,计算机中一切数据本质上都是数字。但我们希望这些数字能代表许多不同的事物:字母、图像、声音,或者就是纯粹的数字。
类型告诉我们这些数字的含义,即数字所代表的是哪一种事物。这一点至关重要,因为类型不仅规定了程序应如何解释数字(即数值的意义),还规定了如何对其进行操作。
同一个操作在不同类型的数据上可能具有不同的行为。这听起来可能有些令人困惑,让我们来看一个例子。
操作符的行为取决于类型
以下是包含两个加法操作的代码。正如你将看到的,由于操作的数据类型不同,这两个加法操作将执行不同的功能。
let n1 = 26;
let n2 = 16;
let s1 = "A"; // s1是一个字符串,即字母序列。这个序列只有一个字母“A”。
let s2 = "B";
let n3 = n1 + n2; // 第一个加法操作
let s3 = s1 + s2; // 第二个加法操作
让我们逐步执行这段代码:
n1等于26,n2等于16。s1是字符串“A”。s2是字符串“B”。- 现在执行第一个加法操作:
n1 + n2。两个操作数都是数字,所以我们执行的是数值加法。26加16等于42。 - 接下来执行第二个加法操作:
s1 + s2。然而,这里是对两个字符串“A”和“B”进行加法操作。对于字符串,+不代表数值相加,它代表连接。
连接是一个术语,指将两个字符串首尾相接拼在一起。因此,s3的结果是字符串“AB”。
请注意,+操作符根据其操作数据的类型不同,表达了两种不同的含义。
JavaScript如何管理类型?
JavaScript在将每个值存储到计算机内存时,会显式地将类型信息与值一起保存。每当需要处理一个值时,它就从内存中读取该值的类型和数值,以确定该做什么。
因为JavaScript在内存中保存了所有值的类型信息,即在程序运行时跟踪并处理类型,所以它被称为动态类型语言。
静态类型语言简介
如果你继续学习第二门及以后的课程,你将学习Java,其类型的工作方式有所不同。Java是一种静态类型语言,这意味着每个变量和每个表达式都有且仅有一个类型,并且这个类型必须在程序运行之前就确定。
为了实现这一点,在Java中,你作为程序员需要在代码中明确写出变量的类型。这样,你的代码就可以在运行之前被检查出某些类型的错误,例如尝试对数字调用getWidth方法。在程序运行前发现这类错误非常有帮助。
你将在课程2中学习更多关于Java类型的知识。不过现在,我们还需要学习一些JavaScript知识,以便你能实现绿屏算法。
总结
本节课中我们一起学习了类型的概念。我们了解到类型定义了数据的含义以及可对其执行的操作。同一个操作符(如+)在不同类型的数据上可能产生不同的行为。JavaScript是一种动态类型语言,它在运行时跟踪值的类型。相比之下,Java是一种静态类型语言,要求类型在代码编写时就被明确声明,这有助于在程序运行前发现错误。理解类型是编写正确、高效程序的基础。
023:DukeLearnToProgram环境介绍 🚀
在本节课中,我们将学习如何开始编写和运行JavaScript代码,并重点介绍杜克大学提供的DukeLearnToProgram编程环境。这个环境专为初学者设计,能帮助我们更轻松地编写、调试和理解代码。

编写JavaScript代码的位置
现在你开始学习JavaScript,可能会想知道在哪里编写或如何运行它。
在CodePen中,你可以在右侧框架中编写JavaScript,我们已在此处高亮显示。

如果你使用其他工具编写网页,通常也可以轻松地在网页中编写JavaScript代码,尽管具体方法取决于所使用的工具。
DukeLearnToProgram环境介绍
为了帮助你入门,我们提供了一个对新手更友好的环境,希望能让你更轻松地编写和调试代码。
如果你想在其他地方为网页编写JavaScript,可以始终使用此环境来开发代码,然后再将其复制到你的网页中。
以下是DukeLearnToProgram.com网络环境的截图,用于开发JavaScript代码。这是一个绿屏算法页面,右侧的代码框位于左侧,已预加载了我们在之前视频中共同开发的算法。

如果点击底部的此按钮,它将在此处运行代码。代码尚未完成,因此不会发生太多变化。但如果我们很快完成此代码,输出将显示在右侧。
实际上,如果我们在点击运行代码之前完成了此代码,你会看到Drew和Robert与他们的恐龙一起出现。
为什么使用DukeLearnToProgram.com
那么,为什么使用DukeLearnToProgram.com而不是直接在CodePen中编写代码?
首先,我们设置了DukeLearnToProgram环境,以提供更友好的错误消息。这将使你更容易修复代码中的语法错误或查找和修复问题。
我们还设置了它,以便你可以打印各种内容,例如简单图像和简单像素。这些功能使你更容易查看输出,并可以帮助你进行调试。
说到简单图像和简单像素,这些不是标准的JavaScript库。相反,我们为你设置了这些库,以便你可以在学习的早期阶段解决有趣的问题。
这些库使操作图像变得更容易,无需大量复杂的JavaScript知识。当然,如果你想在别处制作的网页中使用它们,可以导入这些库并在你构建的任何网页中使用它们。
在本课程后期,我们将研究如何在CodePen中使用它们。这些页面还预加载了图像和伪代码,供你解决问题时使用,以便你有一个良好的起点。
最后,大多数程序员使用这样的环境来使编程任务更容易,无论是CodePen、BlueJ(将在下一门课程学习Java时介绍)还是DukeLearnToProgram.com。
环境实际操作演示
好的,让我们看看它的实际效果。欢迎来到DukeLearnToProgram环境。这是我们为你创建的,旨在帮助你学习JavaScript编程。我们这样做是因为我们相信它提供了一个更好的环境。
让我们开始编写一些代码并尝试一下,看看会发生什么。
创建变量和打印输出
我要做的第一件事是简单地创建一个变量。将其命名为x并赋予初始值。
var x = 3;


然后创建另一个变量,并赋予一个依赖于x的值。
var y = x * 3;
现在我想查看该计算的结果,看看我是否做了预期的事情,因此我可以打印出y的值。
print(y);
当我运行它时,当我按下此处的运行代码按钮时,我在此处的输出窗口中看到结果。所以3乘以3的结果是9,正如我们所期望的那样。


编写自定义函数
让我们做一些更复杂的事情。编写我们自己的函数,以function关键字开头,然后可以给它任意名称。
我将编写一个非常小的函数,它只是对传入的任何值进行平方。
function square(x) {
return x * x;
}
你会注意到,当我输入初始大括号时,它会自动为我补全一个。这再次是为了帮助你填充内容。你还会注意到,我在这里使用变量名x与上面这里的变量名x没有任何关系。这只是一个通用名称,我选择它来引用调用square时传入的任何值。
现在让我们尝试实际使用它,我们将为y进行新的赋值,并说,哦,我们希望y是调用square(5)的结果。
y = square(5);
print(y);
当我运行该代码并想查看结果时,我将再次打印出y。运行该代码后,你会看到第一个打印仍然被调用。所以这些行被执行了,我得到了结果,即9。然后我定义了我的函数。
然后我用值5调用该函数,将5乘以5并将该值赋给y,然后打印出该值,得到25。所以两个打印语句都执行了,我可以看到它们的结果。
如果愿意,我也可以直接打印调用函数的结果。我不必将其赋值给一个变量。如果我只想看到它,我可以说打印square(4),同样,你会看到它为我填充了那些括号,我想在行尾放一个分号来标记我已完成该行。
print(square(4));
然后我说运行代码。再次,我所有的打印语句都按照代码中看到的顺序执行。所以首先我得到9,和之前一样,然后我得到25,和之前一样。现在我得到16,这是直接调用它的结果。
使用注释
如果我想,我可以选择不执行任何这些打印,通常的做法是,如果我不完全准备好删除代码,我可以在它前面加上注释。我可以放那两个斜杠,再次,你会注意到环境会像你在CodePen环境中看到的那样进行颜色编码。这是大多数开发环境的常见功能。
所以它将其变为绿色以表示那是注释。通常你会使用像我们第一行这样的注释来描述你的代码或解释发生了什么,但你也可以用它来注释掉一段代码,表示在这次程序运行期间我还没有准备好执行它。
所以现在你只会看到两个打印语句:9和16。
处理图像
好的,让我们再尝试一件事,让我们创建一个表示图像的变量。
如果我创建一个新的简单图像。
var myImage = new SimpleImage("chapel.png");
然后我可以继续打印出该结果,但我应该用什么值来初始化我的简单图像呢?在我们的环境中,我们为你提供了一些标准图像,这些图像已经上传并准备就绪,不同的问题可能关联不同的图像集。


所以在这种情况下,我们可以看到这里的第一个图像是chapel.png,所以如果我只把那个字符串放在那里chapel.png,那么它将引用这里已经加载的图像。


然后我可以继续说打印我的图像。
print(myImage);
当我运行该代码时。

你会看到我仍然得到前两个:9和16,这些打印仍然被执行,然后我还在那里打印出一张图像,这是我加载的图像。
如果我想,我可以继续更改它。所以在这里的另一端是Roger教授的图像。所以我要继续把Roger放在那里而不是Chapel,当我运行该代码时,我得到一张不同的图像。

如果你愿意,你也可以拖放自己的图像。所以这里我有一些图像,我可以继续将图像拖到那个空间。你可以看到它加载了brownhorse.jpeg。它还包括该图像的大小,以便我可以基于此进行计算或检查是否正确。
所以我要继续将其更改为brownhorse.jpeg,除了打印图像外,我还要打印该图像的宽度。
print(myImage.getWidth());
当我运行该代码时,你可以看到我得到1280作为宽度,这在此处有注明,你可以看到我得到一张非常大、在输出区域占用大量空间的图像,但如果我想,我可以看到该图像。

数据安全与代码保存
我想花点时间向你保证,你上传到浏览器以显示和处理图像的任何内容都将保留在你的本地机器上。所以通过此环境发生的所有工作都在你本地机器的本地浏览器中进行。没有任何内容发送到Coursera。没有任何内容发送到杜克大学。没有任何内容从你的计算机发送出去,因为你在这样做。
为了确保在使用网页浏览器时不会丢失代码,如果你尝试转到其他页面但尚未保存代码,你会收到一个对话框,询问你是否确定要这样做,然后你可以选择留在此页面,然后将代码保存到计算机,以确保你有一个满意的版本,现在我可以离开页面,因为我有一个已保存的版本。
总结
在本节课中,我们一起学习了DukeLearnToProgram编程环境的基本使用方法。我们了解了如何在该环境中创建变量、定义函数、打印输出结果,以及如何处理图像。这个环境提供了友好的错误提示和丰富的库支持,非常适合JavaScript初学者上手实践。记住,你编写的代码和上传的图像都安全地保存在本地,同时要养成及时保存代码的好习惯。
024:For循环 🔄
在本节课中,我们将要学习JavaScript中的一个核心概念——For循环。你已经了解了JavaScript的一些基础知识,并熟悉了Duke Learn编程环境。为了能够实现绿幕算法,你还需要掌握几个关键部分,其中之一就是重复执行算法的某些步骤,例如对每个像素执行一次操作。重复步骤在算法中极其常见。随着编程经验的增加,你会发现大多数编写的算法都包含某种形式的重复。
那么,如何在JavaScript中编写重复执行的代码呢?答案是使用循环。具体来说,你将使用For循环,它可以为每个像素重复执行一系列步骤。虽然下面示例循环中的步骤并非绿幕算法所需,但我们将通过它来学习For循环的语法和语义,为你后续实现算法打下基础。
解析For循环语法 📝
以下是For循环的基本语法结构。让我们逐一分解:
for (var pixel of image.values()) {
// 循环体:要重复执行的语句
}
以下是For循环语法的组成部分:
- 关键字
for:这表示你正在编写一个For循环。 - 括号内的循环信息:这部分定义了如何进行重复。
- 变量声明
var pixel:创建一个新变量来引用当前的数据项。循环在每次重复时会自动更新这个变量,将其值设置为下一个数据片段。在这个例子中,每个数据片段就是一个像素。 - 关键字
of:表示接下来要指定要遍历的数据集合。 - 数据集合表达式
image.values():这个表达式会计算出你想要遍历的数据。这里,image.values()是SimpleImage对象的一个方法,它会返回图像中的所有像素,以便在循环体中通过变量pixel依次访问每一个。
- 变量声明
- 循环体
{ ... }:大括号内的语句集合,这些语句会为每一个数据片段重复执行。
理解For循环的语义 🧠
上一节我们介绍了For循环的语法,本节中我们来看看它的执行过程,即语义。假设我们即将开始执行一个For循环,并且变量 img 之前已被声明并初始化为引用一个2x2的图像。
我们首先在图像周围画了一个浅蓝色框,并将 img 变量框中的箭头颜色与之匹配,以便在开始绘制其他箭头时,能清楚地知道这个箭头指向什么。img 的箭头指向整个图像。
- 初始化:For循环的第一部分创建了一个名为
pixel的新变量。循环将从这个变量开始,让它引用图像中的第一个像素。请注意,这个箭头指向图像中一个特定的像素。关键点在于,我们是在引用图像中已存在的像素,而不是创建一个新的像素。这一点很重要,因为稍后我们将改变这个像素的颜色,而这个改变会直接反映在图像中。 - 进入循环体:现在,我们进入循环体并开始执行其中的语句。第一条语句声明了一个变量
newG并将其初始化为255 - pixel.getGreen()。查看这个像素的数值数据,你会发现它的绿色值是0,因此newG将被初始化为255。 - 修改像素:接下来,执行
pixel.setGreen(newG)。由于newG是255,这将把该像素的绿色值设置为255,从而将其颜色从洋红色变为白色。 - 循环迭代:代码中的下一个符号是闭合大括号
},它标志着For循环体的结束。当到达循环体末尾时,程序会回到循环的顶部,并移动到下一个数据片段。对于这个循环,这意味着将pixel变量更新为图像中的下一个像素。 - 重复过程:现在,你将使用下一个数据片段再次执行For循环中的步骤,或者用程序员的话说,准备进行下一次迭代。这个过程会一直持续,直到所有像素都被处理完毕。
- 循环结束:当处理完最后一个像素,再次回到循环顶部时,已经没有剩余的像素了。此时,循环没有其他可遍历的数据,因此程序会跳出循环体,继续执行循环之后可能存在的任何代码。
总结 📚
本节课中我们一起学习了JavaScript中的For循环。我们首先解析了它的语法结构,包括 for 关键字、变量声明、of 关键字以及循环体。接着,我们深入探讨了它的执行语义,通过一个遍历图像像素的示例,一步步展示了循环如何初始化、迭代、修改数据,并最终结束。理解For循环是掌握编程中重复执行任务的关键,它将帮助你实现像绿幕算法这样需要对大量数据(如每个像素)进行相同操作的复杂程序。
025:条件执行 🎯
在本节课中,我们将要学习Java编程中一个核心概念:条件执行。通过条件执行,程序可以根据特定条件决定执行不同的代码路径,从而实现更复杂的逻辑。
上一节我们介绍了如何使用for循环重复执行步骤。现在,我们来看看如何基于条件做出决策。
条件执行的基本语法
决策是通过if-else语句实现的。以下是一个if-else语句的示例,它检查条件x < y,然后根据该条件的真假决定执行哪一组语句。
if (x < y) {
// 条件为真时执行的语句
} else {
// 条件为假时执行的语句
}
这个示例假设变量x和y已经声明并初始化。让我们分解一下语法。
首先,有关键字if,它告诉Java你正在编写一个if-else语句。
其次,是用于决策的条件,此处是x < y。这个条件是一个写在圆括号()内的表达式。
在条件表达式之后,是then子句。then子句是当条件为真时要执行的一组语句,它们写在大括号{}内。
在then子句之后是关键字else,然后是else子句。else子句是当条件为假时要执行的一组语句,同样写在大括号{}内。
有时你可能会看到没有else子句的if语句,这种情况下会省略关键字else和else子句。这相当于一个空的else子句,用于程序员在条件为假时不想执行任何操作的情况。
条件执行的语义
现在你已经了解了基本语法,让我们看看它的执行语义。假设我们刚执行到这个if语句,并且变量具有右侧所示的值。
首先发生的是评估条件表达式:x < y。为了评估这个表达式,我们查看这些变量的值,即它们“盒子”里存储的值,并检查2是否小于7。2小于7,所以这个表达式评估为true。
由于条件为真,我们进入then子句并开始执行其中的语句。我们按照正常规则执行这里的语句。下一条语句是赋值语句z = 2,因此我们将z的盒子更新为2。
现在我们到达then子句的闭合大括号}。我们完成了这里的语句,并且需要跳过else子句,因为那些语句只在条件为假时执行。所以我们跳转到else子句的末尾,然后继续执行后续的任何代码。
现在让我们用不同的变量值再次查看这个例子。我们再次从评估条件表达式x < y开始。然而,这次x是42,y是7,所以我们评估的是42是否小于7。这个表达式评估为false,因为42不小于7。
由于条件表达式评估为false,我们跳入else子句并开始执行其中的语句。同样,我们按照正常规则执行语句。首先,a = y + 1,将a更新为8。然后,y = x - 3,将y更新为39。
现在我们到达else子句的末尾,所以我们可以直接越过else子句的闭合大括号},开始执行后续的任何代码。
组合概念:循环与条件
你已经分别看到了几个重要的概念,但将它们组合在一起看是很有启发性的,就像这个例子一样。
将各个部分组合在一起时,要记住的一个重要点是,无论它们如何组合,它们通常遵循相同的规则。这个概念被称为组合性。
让我们看看这段代码做了什么。
首先,我们声明一个名为img的变量,并将其初始化为一个新的SimpleImage,这是你在之前例子中看到的2x2图像。
SimpleImage img = new SimpleImage("example.jpg");
接下来,有一个for循环,它遍历img.values()中的每个像素。你已经知道这是如何工作的,我们将再次遵循相同的规则。我们为pixel变量创建一个“盒子”,将其初始化为图像中的第一个像素,然后进入循环体。
现在,我们有一个if语句。这个if语句在for循环内部并不重要,它仍然遵循相同的规则。
条件表达式是:pixel.getX() >= img.getWidth() / 2。对于第一个像素,其X坐标是0,图像宽度是2,所以条件是0 >= 1,结果为false。
因此,我们进入else子句并执行其中的语句。这将把像素的蓝色值设置为像素的红色值(255),使其从红色变为洋红色。
现在我们到达else子句的末尾,所以我们离开它,这使我们到达for循环的闭合大括号}。因此,我们转到下一个像素并跳回循环顶部。
我们再次进入循环体,但现在像素的X坐标是1。所以我们再次评估条件:1 >= 1,结果为true。因此,我们进入then子句并执行其中的语句。pixel.getRed() / 2是0 / 2,结果为0。所以我们将像素的红色值设置为0,由于它已经是0,所以没有改变任何东西。
现在我们到达then子句的末尾,所以我们跳过else子句的闭合大括号}。我们再次到达for循环的末尾,因此我们转到下一个像素并跳回循环顶部。
这个过程对剩余的像素重复进行,根据每个像素的X坐标决定是执行then子句还是else子句,从而修改图像的颜色。
总结
本节课中我们一起学习了Java中的条件执行。我们介绍了if-else语句的基本语法和语义,学习了如何根据条件表达式的真假来执行不同的代码块。我们还通过一个结合了for循环和if语句的例子,看到了如何将不同的编程概念组合起来解决实际问题,例如处理图像像素。掌握条件执行是编写具有决策能力程序的关键一步。
026:代码翻译


在本节课中,我们将学习如何将绿幕抠图功能的伪代码翻译成实际的JavaScript代码。我们将从简单的条件判断开始,逐步优化,最终实现一个更健壮的“绿色”定义,以完成图像合成。
从伪代码到代码
上一节我们介绍了绿幕抠图的算法思路。本节中,我们来看看如何将这些思路转化为可运行的JavaScript代码。
我们已经创建了前景图像、背景图像和与前景图像尺寸相同的输出图像。现在,我们将开始编写处理像素的核心代码。
以下是处理前景图像中每个像素的标准循环结构:
for (var pixel of fgImg.values()) {
// 处理每个像素的代码将写在这里
}
定义“绿色”
现在,我们遇到了伪代码中第一个特定于此问题的新步骤:判断当前像素是否为绿色。
在规划阶段,我们并未精确定义“绿色”。现在我们需要为计算机定义它。一个简单的起点是检查绿色通道的值是否为最大值。
if (pixel.getGreen() == 255) {
// 这是绿色的情况
}
从简单开始是一个好的编码策略,可以确保我们有一个可以运行的基础版本。
处理绿色像素
如果条件为真,我们需要执行以下操作:获取背景图像中相同位置的像素,并将其设置到输出图像的对应位置。
以下是实现这一步骤的代码:
if (pixel.getGreen() == 255) {
var x = pixel.getX();
var y = pixel.getY();
var bgPixel = bgImg.getPixel(x, y);
output.setPixel(x, y, bgPixel);
}
这三行代码对应了伪代码中的一行,展示了将高级思路转化为具体代码的过程。
处理非绿色像素
伪代码的最后一行以“否则”开头。这意味着只有当第12行的条件为假时,才执行这部分代码。
在JavaScript中,我们使用 else 语句来处理这种情况。
else {
output.setPixel(pixel.getX(), pixel.getY(), pixel);
}
当像素不是绿色时,我们将输出图像中对应位置的像素设置为当前的前景像素。
关于获取X和Y坐标,这里展示了一种内联的写法,与之前先存入变量的写法功能相同。选择哪种方式取决于个人对代码可读性的偏好。
测试与迭代优化
此时,我确信代码与伪代码逻辑一致,于是运行了它。但结果并不正确——输出图像看起来完全是前景图,背景并未合成进来。
问题在于我们对“绿色”的定义过于严格(== 255)。现实中很少有纯绿色的像素。
因此,我尝试放宽定义,使用 >= 240 作为条件。这次运行结果好了很多,恐龙和太空背景都出现了。
然而,仔细观察图像,会发现人物周围有一圈绿色的光晕。这说明我们的范围仍然不够精确。
我尝试将阈值进一步降低到 >= 200。这次光晕消失了,但太空背景的颜色却渗入了人物的衬衫,导致人物仿佛融入了背景。
显然,为特定图像手动调整一个颜色阈值并非最佳解决方案。我们需要一个更通用、更健壮的方法来定义“绿色”。
实现更健壮的绿色检测
我决定采用一个新的定义:一个颜色中绿色分量大于红色和蓝色分量之和时,才被认为是“绿色”。
用公式表示即: G > (R + B)
以下是实现此定义的代码:
if (pixel.getGreen() > (pixel.getRed() + pixel.getBlue())) {
// 这是绿色的情况
var x = pixel.getX();
var y = pixel.getY();
var bgPixel = bgImg.getPixel(x, y);
output.setPixel(x, y, bgPixel);
} else {
output.setPixel(pixel.getX(), pixel.getY(), pixel);
}
运行这段代码后,光晕消失了,太空背景也回到了正确的位置,合成效果令人满意。
总结
本节课中我们一起学习了将绿幕抠图伪代码翻译成JavaScript的完整过程。我们首先实现了一个简单的版本,然后通过测试发现了问题,并最终采用了一个更健壮的逻辑(G > R + B)来检测绿色像素,从而实现了更好的合成效果。
虽然当前代码对示例图像工作良好,但要确保代码的可靠性,还需要使用更多样化的图像进行测试。我鼓励你使用自己的图像进行实验,以验证和巩固所学知识。
027:批判性思考程序 🧠
在本节课中,我们将学习如何通过修改图像像素的颜色,将一个蓝色的“恶魔”图标变成绿色的。我们将探讨几种不同的方法,分析它们为何成功或失败,并最终理解如何通过条件判断来精确地修改目标像素。
上一节我们介绍了图像处理的基本概念,本节中我们来看看如何通过编程改变图像中特定区域的颜色。
现在,你将思考如何将蓝色的“恶魔”图标变成绿色的,例如,为了庆祝校园的环保倡议。具体来说,我们希望最终图像的背景保持白色,而前景(恶魔图标)的颜色变为红色0、绿色255、蓝色100。
花点时间思考如何实现这一点。
一个最初的想法可能是编写如下代码:
pixel.setRed(0);
pixel.setGreen(255);
pixel.setBlue(100);
在这段代码中,我们直接将每个像素的红、绿、蓝分量设置为我们想要的值。然而,当循环遍历每一个像素时,它会把所有像素都变成绿色。
如果你只将每个像素的绿色设置为255,蓝色设置为100,你会得到一张看起来像这样的图像。这段代码使前景变成了正确的颜色,但现在背景变成了黄色。
花点时间思考为什么会发生这种情况。
你是否意识到背景变成黄色是因为它的红色值为255,使得背景的整体颜色值为红255、绿255、蓝100?因为我们没有改变它的红色值,它保留了原始值。而由于背景原本是白色的,那个值就是255。
为了让这个程序正常工作,我们需要使用像之前见过的条件语句来编写能做出决策的代码,以便它能改变前景,但不改变背景。
以下是一个可能的if语句想法:
if (pixel.getBlue() == 255) {
pixel.setGreen(255);
pixel.setBlue(100);
}
这似乎是一个合乎逻辑的选择,因为前景是蓝色的,而我们只想把蓝色的像素变成绿色的像素。
花点时间思考这段代码做了什么。你认为它能完成我们想要的任务吗?
这段代码实际上把背景变成了绿色,但前景却保持蓝色。这种行为与我们想要的相反。这看起来是不是很令人惊讶?
重要的是要记住,计算机只做你告诉它做的事情。任何时候你的程序行为看起来很奇怪,背后都有很好的原因。我们将更仔细地观察以理解发生了什么。
图像中的白色像素有红255、绿255、蓝255。因为每个白色像素的蓝色都等于255,所以这些像素被这段代码设置为绿色。
然而,这张图像中的蓝色像素并不是纯蓝色。它们的红色是45,绿色是39,蓝色是225,而不是255。因此,对于蓝色像素,条件不成立,它们保持不变。
为了修复这个程序,你需要一个能区分前景像素(红45,绿39,蓝225)和背景像素(红255,绿255,蓝255)的条件。我们可以写出许多可能的条件,例如检查红色是否小于200,绿色是否等于150,蓝色是否等于255,或者其他许多可能性。
这里我们做了这样一个修改,将代码改为检查红色是否小于200:
if (pixel.getRed() < 200) {
pixel.setGreen(255);
pixel.setBlue(100);
}
这段代码生成了我们想要的绿色“恶魔”图像。



本节课中我们一起学习了如何通过条件判断来精确修改图像中的特定像素。当你的代码没有按预期运行时,思考它为何以特定方式行为是修复问题的重要部分。我们很快会更多地讨论调试代码,并教你如何应用类似科学方法的方式来修复有问题的代码。我们还将教你如何在编程任务开始时投入一些规划时间,以便你第一次就能写出更好的代码,并在最后花更少的时间去修复它。
028:代码调试


在本节课中,我们将深入学习编程七步法中的第七步:调试失败的测试用例。我们将探讨如何系统地应用科学方法来定位和修复程序中的错误。
上一节我们介绍了编写程序的七步流程。本节中,我们来看看其中的第七步——调试失败的测试用例。这是一个至关重要的步骤,因为程序通常不会在第一次就完全正确。每个人都会犯错,程序中很容易出现导致错误行为的小问题。我们需要一个有效的方法来进行调试,而科学方法正是我们的工具。
科学方法回顾
你可能在小学或中学学过科学方法,或者学过略有不同的版本。我们在此简要回顾一下。
第一步是观察现象。对于程序而言,这通常意味着在测试时程序崩溃或表现出某些错误行为。
第二步是提出问题。在其他科学领域,问题可能是“为什么苹果会落到地上?”或“为什么这些鸟的喙略有不同?”。在编程中,问题通常是“为什么我的程序出错了?”以及“我的程序是如何出错的,以便我能修复它?”。
第三步是收集信息并应用专业知识。你可能想直接跳到形成假设,但形成一个好的假设通常非常困难。如果你是牛顿,你不会仅仅因为看到一个苹果落地就推导出万有引力理论。你可能需要进行多次实验,用许多不同的苹果或其他物体,才能得出他所发现的物理方程。
对于编程,我们需要收集关于程序内部运作的信息。我们可能需要进行多次实验来观察内部发生了什么。我们还将应用专业知识,即编程领域的知识,来帮助我们思考收集到的数据,分析并理解问题所在。
这个过程是迭代的。随着你收集信息,你会意识到需要收集的其他信息以及需要思考的其他方面。你会不断重复这一步,直到准备好形成假设。
第四步是形成假设。你需要做出一个能预测程序行为的陈述,例如“我认为当我的程序执行这个操作时,会发生以下情况”。
第五步是测试假设。一旦形成假设,你就可以进行测试。你将进行一些实验,如果程序的行为与假设相矛盾,则拒绝该假设。在这种情况下,你需要回到第三步,再次收集更多信息并应用专业知识。此时,你从假设错误的事实中学到了新东西,因此有了更多知识基础,可以形成更好的假设。
另一种可能是,我们确信我们的假设是正确的,因为所有证据都支持它。在这种情况下,我们接受假设,理解了程序的问题所在,并准备采取行动修复程序。
深入探讨信息收集
由于信息收集是过程中最耗时的步骤之一,我们来深入探讨一下具体如何操作。
我们需要查看程序的内部运作,以了解其每一步操作的情况。
以下是几种收集信息的方法:
- 添加打印语句:打印变量的值、打印到达特定代码行的时间,或在此过程中可能发现有用的任何其他信息。
- 使用调试工具:有专门编写的程序来帮助人们调试。它们允许你逐行执行代码、检查变量的值,甚至可以更改变量的值或观察变量何时会改变。具体可用的工具取决于你使用的编程语言,但掌握这些工具会非常强大和有用。
- 手动执行代码:在某些情况下,手动逐行执行代码、写下每一行的效果并弄清楚发生了什么,也很有指导意义。这能让你确切地看到程序中正在发生什么,从而可能让你对其行为有所洞察。
在进行所有这些操作时,你将应用专业知识。随着你成为一名更有经验的程序员和调试者,你会通过经验获得更多专业知识。事情对你来说会变得更自然,因为你会见过类似的情况,能识别程序某些症状的含义,并能更轻松地进行调试。
关于假设
我们来进一步谈谈假设。
一个好的假设应该是怎样的?
- 可测试的:即我们应该对程序的行为做出具体的预测,如果程序行为与该预测不符,我们可以反驳它;或者我们可以确信情况就是如此。
- 可操作的:即一旦我们确信它是真的,我们就可以去修复程序,因为我们现在知道问题所在。
- 具体的:尽可能具体地表述假设有助于实现以上两点。
让我们看几个例子。
首先是一个非常糟糕的假设例子,它没有提供任何有用信息:
我的假设是:我的程序坏了。
虽然这可能是真的(事实上,如果我们观察到一个失败的测试用例,很可能就是如此),但这并没有告诉我们任何关于如何修复程序的有用信息。
一个稍好一点的假设是:
问题出在第5行。
这告诉了我们代码中出错的大致位置。我们或许可以采取一些行动,比如去查看第5行,看看能发现什么问题,但它并没有真正告诉我们具体是什么错了。
一个更好的假设是:
问题是我们程序第5行出现了除以零的情况。
这具体地告诉了我们错误是什么以及在哪里。我们可以测试这一点,看看是否真的除以零,并且我们或许可以采取行动,但我们还可以做得更好。
这里有一个非常好的假设:
问题是在第5行除以零,特别是对于满足特定条件的输入(例如,
red < 30且green > 245)。
这个假设是可测试的。我们可以去设计特定的输入,来验证是否正是这些条件和这种行为。它也是高度可操作的。一旦我们确信这个假设是真的,我们就确切地知道该如何修复程序。我们回到七步流程的第1、2、3步,看看这类特定输入有什么特别之处,它们如何需要作为特殊情况融入我们的算法,然后调整算法并修复代码。
测试假设
当我们想要测试假设时,我们将运行程序。我们会选择我们认为合适的测试用例,将其作为程序的输入并运行,然后观察结果。程序行为是否符合我们的预测,将是测试用例的结果。
如果行为不匹配,我们将拒绝该假设。任何时候得到矛盾的信息,都表明我们的假设不正确,我们需要去形成一个新的假设。
如果行为与我们预测的相同,我们不会立即接受假设,而只是对假设稍微更有信心。我们会继续测试,直到我们有足够的信心接受假设。
检查假设与收集信息非常相似。我们可能只需要运行并查看输出,但有时我们需要更多信息,可能希望打印内部变量的状态或检查程序行为的其他方面。
避免临时修改
现在,我们已经教你了一个调试程序的好方法。然而,许多程序员常常会陷入一种诱惑,即进行临时性的修改。“也许我只要改一下这里,在这里加个1或减个1,也许就能行。”“如果我稍微调整一下代码,希望它能修复问题。”“我可能会走运,节省一些时间,因为收集信息和形成假设可能是项艰苦的工作。”
这很诱人,但确实是个糟糕的主意。我们想用一个看医生的比喻来说明这一点。

假设你生病了去看医生。“医生,我咳嗽,感觉不舒服。”医生的职责是诊断你,这与你的职责是诊断和修复你的程序非常相似。
你的医生会随机尝试方法吗?“吃这个药,看看会发生什么?”如果是这样,我会换个医生。不,你的医生会使用科学方法。他或她会收集信息,进行一些测试,应用专业知识(所有在医学院学到的以及从其他病人工作中获得的经验),想出一个好的假设,测试那个假设,然后一旦他或她确信你的问题所在,就会根据诊断采取纠正措施。
总结
本节课中,我们一起学习了科学方法,并讨论了将其作为调试程序的基础。我们回顾了科学方法的步骤,深入探讨了如何收集程序内部信息,学习了如何形成具体、可测试、可操作的假设,并强调了避免临时修改、坚持系统性调试的重要性。
029:创建交互式网页入门

在本节课中,我们将学习如何结合HTML、CSS和JavaScript来创建交互式网页。我们将从事件驱动编程的基础概念开始,并动手实践,制作一个能让用户上传图片并应用绿幕合成算法的网页应用。
你已经学习了创建网页的基础知识以及Java编程的一些核心概念。现在,你已准备好探索如何使用HTML和CSS创建交互式网页。
通过之前的学习和实践,你能够创建并美化简单的基础网页。然而,你可能见过许多可以以多种方式改变的交互式网页。
交互的核心在于事件驱动编程。你可以创建一个事件(如点击按钮)来触发各种响应动作,例如上传文件、切换视图、排序信息、显示图片或在页面中选择特定功能。
新的HTML5标准支持各种交互功能。虽然我们在这里只探索其中一部分,但你将学习使用JavaScript进行简单事件驱动编程的基础,从而创建交互式网页。
我们将基于你已掌握的JavaScript知识进行扩展。具体来说,你将学习如何创建一个交互式网页,来支持你之前用JavaScript完成的绿幕图像处理。
通过结合HTML、CSS和JavaScript的知识,你将能够制作一种新型网页——一个你可以或任何用户可以与之互动的网页。
编程将隐藏在幕后,你编写的代码将通过点击网页上创建的按钮来调用。你将能够点击按钮,将图片上传到网页的不同部分,如下图所示,这两张图片将用于创建合成的绿幕图像。
以下是一个示例,它使用按钮让用户可以尝试你的绿幕处理流程。你可以从计算机上传图片作为前景和背景图像。
你将能够选择计算机或设备上的图片,并将它们上传到你创建的网页中。


当你点击“创建合成图”按钮时,你为绿幕算法编写的JavaScript代码将被调用来创建合成图像。

通过一些练习和研究,你甚至可以在最终合成图显示之前,创建一个进度条来展示图像已处理的进度。

你将学习创建类似histography.io网站页面的基础知识。这个网站以复杂的方式运用HTML、CSS和JavaScript,创建了一个有趣的交互式网页,用于探索信息。
你通过HTML元素和JavaScript与页面进行交互。按钮允许你改变显示信息的年份范围,例如查看400年间的各类信息,或仅选择1956年至1981年这25年间的音乐事件,甚至可以筛选特定年份的少数特定事件,比如20世纪70年代披头士乐队录制《Let it be》的时刻。
掌握HTML、CSS和JavaScript的基础知识,将为持续提升你的技能奠定坚实的基础。让我们开始吧。
本节课中,我们一起学习了事件驱动编程的基本概念,并了解了如何将HTML、CSS和JavaScript结合,通过按钮等交互元素来创建动态的网页应用。我们从绿幕合成的具体例子出发,看到了交互如何让用户控制网页行为,最后展望了这些基础知识在构建更复杂交互体验(如历史时间线浏览器)中的应用。
030:Div按钮实现 🖱️

在本节课中,我们将学习如何使用JavaScript与网页内容进行交互。我们将从简单的HTML和CSS示例开始,然后向这个简单的网页添加JavaScript。通过从一个基础网页入手,你将能够理解如何为其添加交互性。
概述
我们将首先创建一个非常简单的网页,然后为其添加JavaScript。网页的第一个版本将只使用HTML和CSS,包含两个通过CSS类ID进行样式化的<div>元素。接着,我们将引入一个新的HTML元素——按钮,并将这个按钮连接到最终能与网页元素交互的功能上。点击按钮将触发由你(程序员)决定的不同操作。
从HTML和CSS开始
首先,我们来看一个基础的网页结构。以下是代码示例,包含HTML和CSS部分。
<!-- HTML -->
<div id="d1">这是第一个div</div>
<div id="d2">这是第二个div</div>
/* CSS */
#d1 {
width: 200px;
font-size: 16px;
background-color: lightblue;
}
#d2 {
width: 200px;
font-size: 16px;
background-color: yellow;
}
在这个简单的网页中,我们有两个<div>元素。ID为d1的<div>背景色被设置为浅蓝色,ID为d2的<div>背景色被设置为黄色。这就是我们将要使其变得具有交互性的起点。
创建HTML按钮
上一节我们介绍了基础的网页结构,本节中我们来看看如何创建一个HTML按钮,并为其编程,使其在被按下时产生效果。
我们将使用一个新的HTML标签:<input>标签。它类似于你之前见过的用于显示图像的<img>标签,因为这个标签没有独立的开始和结束标签,而是一个内部包含选项的单标签。
以下是创建按钮的关键属性:
type: 指定输入元素的类型。在我们的第一个例子中,只使用"button"作为输入类型。稍后我们将看到,你可以使用颜色选择器作为输入类型,让用户可以选择颜色作为与网页交互的一部分。我们还会看到其他类型的输入,包括滑块、文件上传器等。value: 指定按钮上显示的文本。onclick: 这是一个事件属性,用于告诉按钮在发生特定事件(本例中是用户点击鼠标)时该做什么。其他常见的HTML事件示例包括网页加载完成、用户鼠标悬停在文本或图像上、或输入字段发生改变(例如输入密码时)。
onclick关键字是一个HTML事件属性,表示其后的JavaScript代码应在点击事件发生时做出反应。这会调用一个事件处理程序,它规定了按钮被按下或点击时会发生什么。
让我们看一个直接在HTML中内联使用alert的简单例子:
<input type="button" value="点击我" onclick="alert('按钮被点击了!')">
当用户点击这个按钮时,会弹出一个包含指定消息的警告框。用户需要关闭这个警告框才能处理更多事件。
使用JavaScript函数处理事件
但是,如果我们需要执行比简单弹窗更复杂的指令集呢?与其将alert直接写在<input>标签内,我们更希望能够使用JavaScript来处理按钮生成的事件。
我们可以通过编写一个JavaScript函数来实现,就像你已经做过的那样。我们将这个函数命名为doChange,在函数体内,我们将显示一个文本为“按钮被点击”的警告。
然后,我们可以在onclick事件中调用这个函数。
- HTML的
<input>创建了用户与之交互的元素。 - 当用户点击按钮时,与
onclick事件关联的事件处理程序(即JavaScript函数)被触发或调用。 - 在这里,事件处理程序通过调用我们刚刚编写的
doChange函数连接到JavaScript代码。
让我们看看具体的代码实现:
// JavaScript 函数
function doChange() {
alert('这是由JavaScript函数触发的警告!');
}
<!-- 更新后的HTML按钮 -->
<input type="button" value="点击我" onclick="doChange()">

现在,当按钮被按下时,调用的是我们定义的JavaScript函数doChange,从而弹出相应的警告框。这是一个开始学习事件处理的好方法。

总结
本节课中我们一起学习了如何使用HTML元素让用户与网页交互。我们创建了一个按钮(稍后还会看到其他类型的输入)。我们学习了如何为用户点击按钮的事件创建事件处理程序。由于JavaScript命令有时可能很复杂,我们还学习了如何创建一个可以被事件处理程序调用的函数。
你将在CodePen的JS(JavaScript)面板中编写JavaScript,但你所获得的关于HTML、CSS和JavaScript的知识,将使你能够创建可以部署在网络上任何地方的页面,而不仅仅是在CodePen内。你将能够运用关于循环、变量和if语句的知识来交互和修改网页。
祝你学习愉快!
031:交互式页面切换

概述
在本节课中,我们将学习如何使用JavaScript来交互式地改变网页内容。这是为网页编程绿屏算法以及创意设计网页的基础。
访问网页元素
要改变一个网页,你需要编写JavaScript代码来访问页面中的元素,例如H1标签、div元素或其他带标签的元素。
以下是访问元素的几种主要方式:
- 可以通过标签名访问所有同类元素,例如所有H1标签或所有li标签。
- 可以通过ID访问单个元素。
- 可以通过类名访问一组元素。
我们主要使用ID来访问HTML元素,因为ID是唯一的,每个元素必须有一个不同的ID。相比之下,许多元素可以共享同一个类名。
改变元素样式
在我们的示例中,你将看到如何向HTML元素(如div)添加CSS类。这允许你改变元素的背景颜色、宽度或高度。
也可以改变元素中显示的文本或其他特性。
一个简单的交互示例
让我们看一个使用交互式JavaScript改变网页的简单例子。我们将使用两个CSS类,每个类为div指定不同的背景颜色。通过点击按钮,你将使用JavaScript为每个div分配一个CSS类,使div的外观发生变化,从无背景色变为不同的背景色。
基本思路是通过ID以编程方式访问每个div,并更改与该div关联的CSS。我们将先概览,然后查看细节。
示例解析:改变颜色
你刚才看到了div改变颜色。你可能记得每个div都有不同的ID:显示“hello”的div的ID是D1,显示“goodbye”的div的ID是D2。
onclick事件属性告诉页面创建一个事件处理器来执行其后的JavaScript代码。你将能够使用JavaScript来访问带有ID“D1”的div或其他任何元素,这使得改变元素的特性成为可能。
在这个例子中,JavaScript函数名为changeColor。让我们通过查看JavaScript代码来了解这个函数。
JavaScript函数详解
changeColor函数如下所示。记住,当用户点击与网页交互的按钮时,会调用这个函数。该函数被onclick事件处理器调用或触发。
JavaScript方法getElementById用于通过其关联的ID访问HTML元素。你可能记得,方法是对对象执行的操作,前面有一个点号。在这种情况下,getElementById方法使用HTML文档来访问其元素。标签document指的是整个HTML网页。
getElementById方法有一个参数,这里是“D1”,即与特定HTML元素关联的ID标签。该方法返回具有作为参数传递的ID标签的HTML元素,也就是带有单词“hello”的div元素,因为关联的ID标签是“D1”。
我们需要创建一个变量来存储这个返回的HTML元素,这里用var dd1表示。现在,这个变量可以用来访问元素并改变其颜色。
下一行创建了一个类似的变量dd2,它存储了当参数为“D2”时,document.getElementById返回的HTML元素。
接下来,要改变此页面中使用的背景颜色,我们需要为每个div设置背景颜色。我们将通过设置div的CSS类来实现。
代码行dd1.className = "blueback"改变了ID为D1的div的颜色。记住,这个div存储在变量dd1中。属性className是HTML元素的一个特性,可以在JavaScript中访问。正如我们将在后面的例子中看到的,HTML元素还有许多其他属性,你也可以通过JavaScript访问来改变它们。这些属性有时被称为字段,在JavaScript程序中使用点号表示法访问。
该代码将类名“blueback”分配为存储在变量dd1中的div的类。正如你在我们展示的CodePen页面的CSS面板中看到的,类blueback具有特定的背景颜色。如果其他CSS特性是此类blueback的一部分,那么这些特性也将成为标签为D1的div的一部分,这是将类blueback分配给JavaScript中变量dd1的结果。
下一行类似地将类yellowback分配给变量dd2,该变量存储着ID为D2的HTML元素。
再次演示,使用onclick事件处理器调用JavaScript函数changeColor,将这些CSS类分配给选定的HTML元素,我们就看到了背景的变化。
改变元素文本
也可以改变HTML元素的其他属性,例如与元素关联的文本。函数changeText使用了与我们刚才在changeColor中看到的非常相似的方法。
前两行创建变量来存储HTML元素,这些行与我们在changeColor中看到的完全相同。
与之前一样,要访问HTML元素的属性,你将使用点语法和将在JavaScript中使用的属性或字段的名称。这里显示的属性innerHTML访问元素内的HTML内容。在这种情况下,就是div内部的所有内容,即文本。
代码行dd1.innerHTML = "Bonjour"改变了由变量dd1访问的HTML元素的文本。这是ID为D1的同一个HTML元素,在代码执行前显示单词“hello”,在代码运行后将显示“Bonjour”。
值得指出的是,虽然dd1是一个HTML元素(在本例中特指一个div),但一般来说,它只是一个对象,就像CSS样式或简单图像都是对象一样。你总是可以使用文档来了解哪些方法可以对不同的对象进行操作。
在操作中,你可以看到点击“change text”按钮时会发生什么。onclick事件处理器调用我们刚才看到的JavaScript函数changeText,该函数访问div内部的HTML并将其设置为新文本“Bonjour”和“Sayonara”。
总结
本节课中,我们一起学习了如何使用JavaScript交互式地改变网页。我们掌握了通过ID访问HTML元素、修改元素的CSS类以改变样式(如背景色),以及使用innerHTML属性来动态更新元素内的文本内容。这些技术是实现网页动态交互功能的基础。
032:使用HTML5画布 🎨

在本节课中,我们将学习如何使用HTML5的<canvas>元素来创建图形,并编写JavaScript代码与之交互,从而替代之前使用的<div>元素。我们将创建一个包含按钮的简单网页,点击按钮可以改变画布背景色、绘制图形以及添加文字。
画布与Div元素的区别
上一节我们介绍了使用<div>元素来动态改变网页背景色。本节中我们来看看<canvas>元素。
<canvas>是一个HTML元素,用于通过JavaScript绘制图形。它与<div>的功能不同。<div>主要用于布局和样式化内容区块,而<canvas>则是一个图形容器,允许你绘制路径、形状、文本和图像。
在之前的例子中,我们通过document.getElementById获取<div>元素并改变其背景色。使用<canvas>时,虽然改变背景色的原理类似,但绘制文本和图形则需要使用特定的方法。
画布的基本用法
<canvas>元素专门用于绘制图形,通常与JavaScript结合使用。你可以将其视为一个图形容器,需要通过脚本来填充内容。

以下是使用<canvas>时常用的几种方法:
- 绘制路径和形状:例如线条、矩形、圆形。
- 添加文本:在画布上绘制文字。
- 操作图像:在画布上绘制或处理图像。
我们将创建一个简单的交互式页面,其中包含两个按钮,分别用于将画布背景变为酸橙色(lime)以及在画布上绘制黄色矩形和黑色文字。


编程实例:创建交互式画布
让我们通过CodePen平台,结合HTML、CSS和JavaScript来创建一个使用<canvas>的交互式网页。

HTML结构
首先,我们定义画布和两个按钮。
<canvas id="d1"></canvas>
<button onclick="doLime()">Make Lime</button>
<button onclick="doYellow()">Make Yellow</button>
- 画布的
id为"d1"。 - 每个按钮都通过
onclick属性调用对应的JavaScript函数。
CSS样式
接着,我们为画布添加一些基本样式,定义其尺寸和边框。
#d1 {
width: 200px;
height: 100px;
border: 1px solid black;
}
JavaScript交互逻辑
现在,我们编写JavaScript代码来实现交互功能。
1. 改变背景色 (doLime 函数)
这个函数与操作<div>时类似,直接改变画布元素的背景色。
function doLime() {
var d1 = document.getElementById("d1");
d1.style.backgroundColor = "lime";
}
2. 绘制图形和文字 (doYellow 函数)
这是使用<canvas>的核心部分。要在画布上绘图,我们需要先获取其绘图上下文(context)。
function doYellow() {
var d1 = document.getElementById("d1");
// 首先,将画布背景设为白色,为绘制黄色矩形做准备
d1.style.backgroundColor = "white";
// 关键步骤:获取2D绘图上下文
var ctx = d1.getContext("2d");
// 设置填充颜色为黄色
ctx.fillStyle = "yellow";
// 绘制第一个黄色矩形
// 参数:起始点X坐标, 起始点Y坐标, 矩形宽度, 矩形高度
ctx.fillRect(10, 10, 40, 40);
// 绘制第二个黄色矩形
ctx.fillRect(60, 10, 40, 40);
// 设置填充颜色为黑色,用于绘制文字
ctx.fillStyle = "black";
// 设置字体样式
ctx.font = "30px Arial";
// 绘制文字
// 参数:要绘制的文本, 文本起始点X坐标, 文本起始点Y坐标
ctx.fillText("Hello", 10, 80);
}

代码解析:
getContext("2d"):获取画布的2D渲染上下文,这是所有绘图操作的基础。fillStyle:设置或返回用于填充绘画的颜色、渐变或模式。fillRect(x, y, width, height):绘制一个填充的矩形。坐标(0, 0)位于画布的左上角。font:设置或返回文本内容的当前字体属性。fillText(text, x, y):在画布上绘制填充的文本。


总结
本节课中我们一起学习了HTML5 <canvas>元素的基本用法。我们了解到:
**<canvas>**是一个用于绘制图形的HTML元素。- 与
<div>不同,在<canvas>上绘制图形和文字需要使用JavaScript通过其绘图上下文(Context) 来实现。 - 核心步骤包括:获取上下文 (
getContext("2d"))、设置样式 (fillStyle,font),然后调用绘图方法 (fillRect,fillText)。 - 通过一个简单的例子,我们实现了改变画布背景色、绘制矩形和添加文字的功能。
你可以利用提供的资源进一步探索<canvas>的更多图形功能,享受编码的乐趣。
033:输入与事件 🖱️

在本节课中,我们将学习新的HTML输入类型和事件。你已经见过按钮输入类型及其onclick事件,现在你将学习如何使用额外的输入类型和事件,使你的网页更具交互性。


HTML输入元素概述
正如你所见,HTML的<input>标签用于创建像按钮这样的HTML元素,它从用户那里获取输入,并通过JavaScript代码处理这些输入。
你已经使用过按钮来获取用户输入,但你也可以创建要求文本输入的网页。你可能见过需要输入姓名、密码或URL的网页。你可以向用户展示颜色选择器工具,或者一个通过滑块获取信息的范围控件,这两种控件都将在本节课中介绍。你可以在HTML输入元素的文档中找到许多其他选项。
对于按钮,输入是用户点击按钮的动作,即onclick事件。但正如你将看到的,还有其他事件可以连接到按钮或其他类型的输入上。
鼠标事件示例
例如,这是杜克大学网站上的一个文章页面。当鼠标悬停在内容上时,内容会高亮显示。onmouseenter和onmouseleave事件用于让浏览器在鼠标进入或离开一个对象时,执行你连接的任何函数。
输入变化事件
字段或输入的变化也是有用的事件。这里有一个杜克大学网站搜索功能的例子,当用户输入内容以查找关于杜克昆山大学的文章时,搜索功能会实时执行。
颜色选择器
颜色选择器允许用户选择颜色,作为与网页交互的一部分。颜色可以通过名称、使用滑块或选择RGB值等方式指定。请注意,颜色选择器是HTML5引入的,不被旧版网页浏览器支持的新输入类型将简单地表现为文本输入类型。
让我们更仔细地看看颜色选择器是如何工作的。
以下是之前见过的<input>标签。颜色选择器的类型是color。这里我们指定了默认值为深蓝色。
<input type="color" id="colorPicker" value="#00008b">
这个颜色选择器元素有一个ID,以便我们可以在JavaScript代码中引用它,并用一个变量表示其输入。
许多输入需要连接除鼠标点击或移动之外的事件。颜色选择器使用onchange事件。这可能与鼠标有关,但并非基于点击,而是当颜色选择器的值发生变化时触发。
这个事件通过onchange事件处理程序连接到JavaScript。
现在,让我们看看doColor函数。这个函数可以任意命名,但必须与你在HTML输入元素中指定的名称匹配。
function doColor() {
var dd1 = document.getElementById("canvas1");
var colorInput = document.getElementById("colorPicker");
var color = colorInput.value;
dd1.style.backgroundColor = color;
}
在JavaScript代码中访问颜色选择器的值使用了你可能熟悉的函数和概念。首先,画布元素存储在变量dd1中。然后,使用相同的技术在文档中查找元素,将颜色选择器元素存储在colorInput变量中。你可以为变量选择任何名称,但字符串中给出的ID必须与HTML元素中给出的ID匹配。
颜色选择器的值使用存储在变量colorInput中的颜色选择器HTML元素的.value属性或字段来访问。接下来,我们使用.style属性将画布的背景颜色设置为颜色选择器当前设置的颜色。
这里的基本原理与按钮输入相同:使用HTML输入元素和适当的事件处理程序将JavaScript代码连接到事件。
让我们看看颜色选择器的实际效果。“画线”按钮仍然有效。现在,点击或在颜色选项上移动按下的鼠标按钮将改变颜色选择器对话框中显示的颜色。按下鼠标将改变颜色选择器中的活动颜色并生成一个onchange事件。如果你保持鼠标按下并在彩色铅笔上移动,也会生成此事件。当鼠标按下时,可以生成颜色更改事件。
比较onchange与onclick事件
如果我们将响应的事件从onchange改为onclick会发生什么?现在,点击默认值为深蓝色的颜色选择器会改变画布背景,而对于onchange事件则不会,因为元素尚未被更改。在这种情况下,更改颜色选择器不会改变画布颜色,直到我们点击颜色选择器元素本身。这就是HTML输入元素的onchange和onclick事件之间的区别。
滑块输入
让我们再看一种HTML输入类型及其使用的事件:范围或滑块输入。在这里,滑块是水平的,并有一个文本标签,表明其用途是使用滑块绘制一个正方形。
当移动滑块时,会绘制一个正方形,其边长由滑块的值决定。用户向右或向左拖动滑块时,可以使正方形变大或变小。
创建此滑块的HTML代码具有range类型的输入,并在创建HTML元素时指定三个参数:范围的最小值(这里是10)、范围的最大值(这里是100)以及滑块首次在网页中呈现时的当前值(这里是10,恰好是最小值)。因此,滑块将首先呈现在最左侧。
<input type="range" id="sizeSlider" min="10" max="100" value="10">
输入元素的ID对于访问其值以在doSquare函数中使用非常重要。当你点击并拖动滑块时,会生成一个oninput事件,你可以用它来连接网页和你的JavaScript。
滑块处理函数
现在让我们更仔细地看看doSquare函数。这是doSquare函数,它根据范围输入给出的边长绘制一个正方形。
function doSquare() {
var dd1 = document.getElementById("canvas1");
var sizeInput = document.getElementById("sizeSlider");
var size = sizeInput.value;
var context = dd1.getContext("2d");
context.clearRect(0, 0, dd1.width, dd1.height); // 清除画布
context.fillStyle = "yellow";
context.fillRect(10, 10, size, size);
}
和之前一样,我们将对画布的引用存储在一个变量中。然后,我们使用滑块元素的ID创建一个表示滑块的变量sizeInput。我们获取滑块元素的值并将其存储在变量size中。正如之前所见,我们需要获取画布的上下文才能在其中绘制。最后,我们使用来自滑块的size输入,从坐标(10,10)开始绘制一个黄色矩形,其边长由size变量决定。
让我们看看这段代码做了什么。正方形可以通过滑块变大,但似乎不会变小。这是因为每次oninput事件处理程序调用doSquare函数时,它都会绘制另一个正方形,一个叠在另一个上面,但旧的正方形没有被清除。
幸运的是,上下文有一个属性可以清除之前的绘图。.clearRect()将清除一个矩形,需要四个参数:矩形左上角的两个坐标,然后是它的宽度和高度。为简单起见,我们将在每次调用doSquare时清除整个画布。这样好多了。现在,正方形会随着用户通过滑块的输入而调整大小。
总结
本节课中,我们一起学习了如何使用新的输入类型:颜色选择器和滑块,以及如何与它们配合使用的新事件类型:onchange和oninput。希望你在继续制作交互式网页的过程中获得乐趣。
034:上传与显示图像 📤🖼️

在本节课中,我们将学习几个核心概念,这些概念将允许你上传图像文件并在HTML画布中显示它们。
随着我们朝着为绿屏算法创建交互式网页的目标迈进,我们需要一些新的编程概念和JavaScript工具。通过这些新概念和工具,你将能够创建更多网页,从一个非常简单的页面逐步过渡到更复杂、更具创意的绿屏和迷你项目页面。
我们将从一个原型开始,这是一个我们想要实现的网页模型,但更易于理解。我们需要一种新型的输入元素来上传图像文件,但在这个第一个原型中,我们将从一个简单的想法开始,以便我们可以专注于概念,而不是新HTML元素的细节。
一个更简单的输入类型是文本输入,它将显示一个文本框,就像你在这里看到的那样,允许用户输入文本。我们将用这个原型来测试和理解处理这个以及任何其他HTML元素的JavaScript概念。
然后,我们将把输入类型改为文件,并介绍上传图像文件并在HTML画布中显示它所需的概念。
让我们看看这个原型,以理解网页和JavaScript。
理解原型
HTML文本输入元素允许用户输入任何文本。正如你在这个简单页面中看到的,用户输入了“Lion.jpg”并即将点击按钮。
这里我们将使用一个按钮来处理事件。当用户按下回车键时处理文本是可能的,但我们将使用按钮,因为它们更常见。像往常一样,当用户点击按钮生成事件时,我们将编写JavaScript来处理该事件。这里使用onclick事件处理程序。
以下是处理用户输入内容的JavaScript代码:
function handleUpload() {
var textInput = document.getElementById('textInput');
var userText = textInput.value;
alert("你输入的文件名是: " + userText);
}
我们使用熟悉的JavaScript来访问文本输入元素的值,并在警告框中显示该结果。因此,当用户点击“上传”时,警告框会弹出。
请记住,这是一个文本输入的例子。输入元素不关心我们是否输入了文件名。我们本可以输入任何文本。当我们把输入类型改为文件时,我们将确保获得的值代表一个实际的文件,不过,正如我们很快将看到的,我们需要做一些工作来确保该文件是一个图像。
从文本到文件上传
上一节我们介绍了使用文本输入的原型,本节中我们来看看如何将其扩展为允许用户选择图像文件、上传并显示的网页。
我们将用一个类型为file的单一输入,替换原来的文本输入和按钮输入。我们将从用户选择并上传的文件创建一个图像。这将是一个来自Duke Learn to Program库的简单图像,你之前已经使用过。
通过扩展我们在原型中看到的功能,你将创建这个网页。从熟悉的东西开始有助于理解新概念,在本例中,是文件输入和一个JavaScript代码库。从熟悉的东西开始也有助于调试,因为我们从一个正常工作的网页和JavaScript开始。
让我们看看HTML和JavaScript如何结合,允许你上传和显示图像。
文件输入HTML元素
以下是用于指定输入元素的HTML代码,该元素允许用户选择文件并将其上传到网站。
<input type="file" id="fileInput" multiple="false" accept="image/*" onchange="uploadImage()">
你将使用file而不是button或color作为输入的类型。
你将确保用户只上传一个文件,而不是多个,通过使用multiple属性并将其设置为false,表示不能选择多个文件。
你将只允许用户选择图像文件,例如,不允许选择文本文件或音频文件。你通过为accept属性指定一个值来实现这一点。该值是image/*,如上所示,*表示所有图像类型,而不仅仅是JPEG。
像往常一样,你将提供一个id,以便可以在你的JavaScript代码中找到这个元素。
你将使用onchange作为事件处理程序。当用户通过点击输入元素上显示的按钮选择文件时,会触发此事件。
请注意,你不需要添加按钮元素;它已包含在文件输入类型中。
处理上传的JavaScript
当用户点击“选择文件”时,会出现一个文件选择器(这个只允许图像文件;你的可能看起来不同,因为不同的浏览器处理此限制的方式不同)。一旦选择了图像“Lion.jpg”,文件输入的内容就会改变。这会触发onchange事件处理程序。
那么,让我们看看upload函数应该是什么样子,以实现将图像显示在我们的画布元素中的目标。
function uploadImage() {
// 1. 获取画布和文件输入元素
var canvas = document.getElementById('myCanvas');
var fileInput = document.getElementById('fileInput');
// 2. 从文件输入创建SimpleImage对象
var image = new SimpleImage(fileInput);
// 3. 将图像绘制到画布上
image.drawTo(canvas);
}
upload函数通过HTML中指定的ID获取画布元素和文件输入元素。
然后,你将从HTML文件输入本身创建一个简单的图像变量。
等等,SimpleImage不是标准的JavaScript。它是为本课程创建的JavaScript代码库,托管在dukelearntoprogram.com。当你使用我们的自定义JavaScript环境时,代码会自动包含。但对于你创建的网页,你需要指定这个库的来源。
引入SimpleImage库
让我们看看在CodePen中如何操作。在CodePen页面的编辑视图中,JavaScript的源代码放在HTML面板中,而不是JavaScript面板中,包含在带有src(源)属性的开放和闭合的<script>标签内。
<script src="https://www.dukelearntoprogram.com/course1/common/js/image/SimpleImage.js"></script>
你可以看到URL的最后一部分是simpleimage.js,因为这是我们希望页面能够使用的库。在CodePen之外,你也会使用<script>标签。这是从我们的Duke Learn to Program站点包含simpleimage.js库的代码。
现在我们知道了如何告诉网页在哪里找到我们的JavaScript代码,让我们完成将图像放入画布的过程。
我们刚刚从文件输入HTML元素创建了一个新的SimpleImage。
最后一步是使用drawTo方法,这是包含在SimpleImage库中的一个方法。我们将调用image.drawTo()并使用画布元素作为参数,以指示应将SimpleImage绘制到我们选择的特定画布上。
一如既往,你不应该试图记住所有这些方法,你可以随时查阅文档,比如来自dukelearntoprogram.com的这个文档。
总结
本节课中我们一起学习了如何创建一个允许用户上传并显示图像的网页。
我们从一个简单的文本输入原型开始,理解了事件处理的基本流程。然后,我们引入了HTML的<input type="file">元素,它内置了文件选择功能。通过设置accept="image/*"属性,我们限制了用户只能选择图像文件。
在JavaScript部分,我们学习了如何使用onchange事件来响应用户的文件选择操作。最关键的一步是使用SimpleImage这个专为课程设计的库,它能方便地从文件输入创建图像对象,并通过drawTo(canvas)方法将图像绘制到指定的HTML画布上。
最后,我们还了解了如何在网页的HTML头部通过<script>标签引入外部的JavaScript库(如SimpleImage.js),这是扩展网页功能的重要方式。
掌握了这些概念,你现在可以创建允许用户上传图像的网页了,这是实现更复杂图像处理应用(如绿屏算法)的基础。
035:图像转灰度教程 🖼️➡️⚫⚪

概述
在本节课中,我们将学习如何通过编程方式将彩色图像转换为灰度图像。我们将运用之前模块中修改图像像素的概念,并将其应用于一个交互式网页。课程将涵盖从算法设计到代码实现的完整过程,并引入全局变量这一新概念。
从修改像素到创建交互式网页
上一节我们介绍了通过修改图像像素来创建条纹和绿幕合成图像。本节中,我们来看看如何运用相同的像素修改概念,来创建使用简单图像库的交互式网页。
我们将以实现一个常见的图像滤镜——灰度转换为例。
创建灰度图像:算法设计
编写程序的前四个步骤是设计算法。我们知道,一个颜色要成为灰色调,其红、绿、蓝(RGB)值必须相等。我们希望灰度图像能保留原始图像的亮度变化,而不仅仅是黑白两色。
实现方法之一是计算RGB值的平均值,并将新的RGB值都设置为这个平均值。
以下是实现灰度转换的步骤:
- 从目标图像开始。
- 对于图像中的每个像素:
- 获取其R、G、B值。
- 计算这些值的平均值。
- 将像素的R、G、B值都设置为这个平均值。
- 显示最终图像。
手动演算示例
在将算法转化为代码前,我们先通过手动计算来理解这个过程。
- 绿色像素:RGB值为 (0, 255, 0)。
- 平均值 = (0 + 255 + 0) / 3 = 85。
- 新的灰度像素RGB值为 (85, 85, 85)。
- 洋红色像素:RGB值为 (255, 0, 255)。
- 平均值 = (255 + 0 + 255) / 3 = 170。
- 新的灰度像素RGB值为 (170, 170, 170)。
你可以尝试手动计算栗色、天蓝色和橙色的灰度值进行更多练习。
将算法转化为代码
现在,我们已经完成了手动演算和步骤归纳。接下来,我们看看如何在网页的JavaScript函数中实现这个算法。
我们将在一个已有图片上传功能的网页基础上,添加一个“转为灰度”按钮。该按钮的onclick事件处理器将调用名为makeGray的JavaScript函数。

以下是makeGray函数的JavaScript源代码,我们将逐行分析其如何将图像转换为灰度:
function makeGray() {
// 步骤2:遍历图像中的所有像素
for (var pixel of image.values()) {
// 获取RGB值并计算平均值
var avg = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3;
// 将RGB值设置为平均值
pixel.setRed(avg);
pixel.setGreen(avg);
pixel.setBlue(avg);
}
// 步骤3:在画布上显示灰度图像
var canvas = document.getElementById("can");
image.drawTo(canvas);
}
代码解释:
for (var pixel of image.values()): 这行代码循环遍历图像image中的每一个像素。pixel.getRed(),getGreen(),getBlue(): 这些方法获取当前像素的红、绿、蓝分量值。avg: 计算三个颜色分量的平均值。pixel.setRed(avg), 等: 将当前像素的红、绿、蓝分量都设置为计算出的平均值avg。document.getElementById("can"): 获取HTML页面中ID为“can”的<canvas>画布元素。image.drawTo(canvas): 将处理后的image绘制到指定的画布上,从而在网页中显示。
引入全局变量
上面的代码存在一个问题:makeGray函数中的变量image是从哪里来的?它需要在upload函数(处理文件上传的函数)中被初始化,但又要在makeGray中被访问。
这就需要使用全局变量。全局变量定义在所有函数之外,因此可以被所有函数访问。
定义全局变量:
var image; // 在所有函数之外定义
在函数中使用全局变量:
function upload() {
// 注意:这里没有使用‘var‘关键字,因此是对全局变量‘image‘赋值
image = new SimpleImage(fileInput);
// ... 其他上传逻辑
}
function makeGray() {
// 现在可以直接使用全局变量‘image‘
for (var pixel of image.values()) {
// ... 灰度处理逻辑
}
}
关键点:
- 在
upload函数中为image赋值时,不能使用var关键字(如var image = ...),否则会在upload函数内部创建一个新的局部变量,而不是修改全局变量。 - 在
makeGray函数中访问image时,直接使用即可。 - 应谨慎使用全局变量,过度依赖会使代码难以理解和维护。
扩展:保留原始图像
有时我们可能希望在不改变原始图像的情况下显示灰度版本。这可以通过创建新的图像对象并存储在另一个全局变量中来实现。
例如:
var originalImage;
var grayImage;
function upload() {
originalImage = new SimpleImage(fileInput);
// 显示原始图像
}
function makeGray() {
// 基于原始图像创建新图像进行处理
grayImage = new SimpleImage(originalImage);
for (var pixel of grayImage.values()) {
var avg = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3;
pixel.setRed(avg);
pixel.setGreen(avg);
pixel.setBlue(avg);
}
// 显示灰度图像
}
总结
本节课中我们一起学习了如何通过编程将彩色图像转换为灰度图像。我们回顾了修改像素的核心概念,设计了灰度转换算法,并手动进行了演算。接着,我们将其转化为JavaScript代码,在交互式网页中实现了一个“转为灰度”按钮的功能。在此过程中,我们引入了全局变量这一重要概念,它允许在不同的函数间共享数据(如图像对象)。最后,我们还探讨了如何通过创建图像副本来保留原始图像。记住要谨慎使用全局变量,并享受编码的乐趣!
036:迁移至CodePen

概述

在本节课中,我们将学习如何将您在Duke Learn to Program环境中开发的绿幕代码,迁移到一个交互式网页中。我们将使用HTML、CSS和JavaScript来创建一个允许用户上传图片、应用绿幕效果并查看合成结果的网页应用。
网页组件分析
上一节我们介绍了课程目标,本节中我们来看看构成这个交互式网页的各个组件。
首先,我们分析HTML元素,然后将它们与JavaScript代码连接起来。
页面上有两个标准的Canvas元素。每个Canvas都带有CSS定义的边框。一个Canvas用于显示前景图像,另一个用于显示背景图像。
您还会看到四个输入元素。其中两个是用于上传图片的文件输入框,另外两个是按钮,用于改变Canvas中的显示内容。文件输入框在网页上标明了用途:“上传前景图像”和“上传背景图像”。两个按钮分别用于“创建绿幕合成图”和“清空画布”。
HTML文件输入元素
让我们具体查看HTML文件中的元素定义。

以下是两个文件输入框的HTML代码,一个用于上传前景图像,另一个用于背景图像。
<input type="file" id="fgFile" onchange="loadForegroundImage()">
<input type="file" id="bgFile" onchange="loadBackgroundImage()">
我们创建文件输入框,允许用户上传并显示图像文件。前景和背景的文件输入按钮都有唯一的ID(fgFile 和 bgFile),这便于在与之交互的JavaScript代码中识别它们。
我们使用 onchange 事件处理器来调用相应的JavaScript函数。这里,我们调用 loadForegroundImage() 和 loadBackgroundImage()。
JavaScript:加载并显示图像
现在,让我们看看用户上传图像时被调用的JavaScript代码。上传和显示图像使用了我们之前学过的概念。
以下是加载前景图像的JavaScript函数:
var fgImage = null; // 全局变量
function loadForegroundImage() {
var imgFile = document.getElementById("fgFile");
fgImage = new SimpleImage(imgFile);
var canvas = document.getElementById("fgCanvas");
fgImage.drawTo(canvas);
}
背景图像的加载函数与此非常相似。我们使用全局变量(如 fgImage)来引用每个图像。将全局变量初始化为 null 是一个好习惯,这允许我们在用户点击“创建合成图”按钮时判断图像是否已加载。
null 是JavaScript(及其他语言)中用于表示“空”或“无”的特殊值。
请注意,访问 fgImage 的代码没有使用 var 关键字,因为 fgImage 是全局变量。该变量被赋予一个从文件输入元素创建的新SimpleImage对象。
我们还使用了许多局部变量,例如 var imgFile,它引用了前景图像的文件输入HTML元素。在多个函数中需要访问的值,应设为全局变量;否则,应尽量使用局部变量,以避免在项目变量增多时失去跟踪。
JavaScript:创建绿幕合成图
接下来,我们看看创建绿幕合成图的步骤。这需要用到您在Duke Learn to Program环境中学到的一些图像处理基本思想,我们已将其适配用于网页。
在用户上传图像并点击“创建合成图”按钮之前,我们需要检查用户是否已上传了两张图像。
我们通过检查全局变量 fgImage 是否已准备就绪来实现这一点。如果 fgImage 为 null(其初始化值),则用户尚未点击上传前景图像的按钮。此外,即使用户已上传,大图像也需要时间创建,image.complete 属性允许我们判断图像是否已完全加载。
function createComposite() {
// 检查前景图像
if (fgImage == null || !fgImage.complete()) {
alert("前景图像未就绪。");
return;
}
// 检查背景图像(类似代码)
// ... 创建合成图的代码 ...
}
如果图像未就绪(无论是 null 还是未完成加载),我们需要通过弹出警告框来告知用户,代码将无法工作。我们在创建绿幕合成图之前,同时检查前景和背景图像。
合成图核心算法
让我们更仔细地查看创建合成图的核心代码。
以下是适配自Duke Learn to Program环境的JavaScript代码,用于创建绿幕合成图:
function createComposite() {
// ... 之前的检查代码 ...
// 第一步:创建一个与前景图尺寸相同的新图像
var output = new SimpleImage(fgImage.getWidth(), fgImage.getHeight());
// 循环遍历前景图的每个像素
for (var pixel of fgImage.values()) {
var x = pixel.getX();
var y = pixel.getY();
// 如果当前像素的绿色值大于阈值,则使用背景图的对应像素
if (pixel.getGreen() > greenThreshold) {
var bgPixel = bgImage.getPixel(x, y);
output.setPixel(x, y, bgPixel);
} else {
// 否则,使用前景图的像素
output.setPixel(x, y, pixel);
}
}
// 将新合成的图像绘制到Canvas上
var canvas = document.getElementById("outputCanvas");
output.drawTo(canvas);
}

第一步是创建一个具有适当宽度和高度的新图像对象,它将是前景和背景图像的合成。新图像初始为全黑。
我们需要通过循环遍历前景图像的所有像素来设置新图像的每个像素。根据当前循环像素的绿色程度,决定是复制前景像素还是背景像素。
如果像素的绿色值大于某个阈值(greenThreshold),我们就在创建的新图像中使用背景像素;否则,就使用前景像素。
请注意,阈值变量 greenThreshold 必须是全局变量,因为它在此函数内没有用 var 关键字定义。
最终步骤:显示与交互
最后,我们需要显示或绘制新的合成图像。我们将使用 .drawTo() 方法将其绘制到Canvas元素中。
在确保图像显示之前,我们知道它会被加载,因为我们已经检查过。我们还会在绘制前清空Canvas元素,以确保只显示一张图像,即绿幕合成图。
一个正常工作的绿幕页面允许用户通过上传图像进行交互:一张作为前景,一张作为背景。我们必须等待这些图像加载并显示。然后点击“创建合成图”按钮,观察绿幕效果的生成。合成过程可能需要一些时间。完成后,我们就能看到有趣的合成结果,例如人物与恐龙同框。我们也可以使用“清空”按钮清除画布。
总结
本节课中,我们一起学习了如何将Duke Learn to Program环境中的绿幕代码迁移到一个交互式网页。我们涵盖了以下关键步骤:
- 使用HTML输入元素(文件上传和按钮)构建用户界面。
- 编写JavaScript函数来加载图像并使用全局变量进行管理。
- 实现核心的绿幕合成算法,通过循环像素并比较绿色通道值与阈值来组合图像。
- 添加必要的检查(如检查图像是否加载完成)以确保代码健壮性。
- 使用Canvas的
.drawTo()方法在网页上显示最终结果。

通过结合编程与网页技术,您将能够使用这个绿幕程序创建属于自己的有趣合成图像。祝您编程愉快!
037:图像滤镜网站项目介绍 🎨

在本节课中,我们将要学习如何结合HTML、CSS以及Duke编程环境中学到的技能,创建一个交互式网页。这个网页将允许用户上传图片并应用不同的滤镜效果。
项目概述
我们将制作一个简单的网站,其核心功能是让用户上传一张图片,然后通过点击不同的按钮来改变这张图片的视觉效果。这类似于Snapchat或Instagram等应用中的滤镜功能。我们将使用CodePen平台从头开始构建这个网站。
基础项目功能演示
以下是基础版本网站的功能流程:

首先,用户点击“选择文件”按钮上传一张图片。例如,上传一张棕色的马匹图片。

上传后,图片会显示在网页上。接着,用户可以点击不同的滤镜按钮来改变图片。
- 点击“红色”滤镜按钮,图片会整体变为红色调。
- 点击“重置”按钮,图片会恢复到上传时的原始状态。
- 点击“灰度”滤镜按钮,图片会变成黑白效果。
基础项目的目标,就是创建一个具备上述功能的网站:允许用户选择文件、上传图片,并实现红色滤镜、灰度滤镜以及重置功能。当然,你可以使用CSS自由地设计网页样式。
挑战项目功能扩展
上一节我们介绍了基础功能,本节中我们来看看如何为项目增加挑战性的功能。
作为挑战,我们鼓励你考虑添加更多滤镜和功能。让我展示一个功能更丰富的最终网页示例。
这个页面看起来相似,但提供了不同的滤镜选项:
- 彩虹滤镜:此滤镜不会直接改变图片颜色,而是为图片添加一个彩虹色的条纹背景。你需要研究红、橙、黄、绿、蓝、靛、紫这些颜色的RGB值来实现它。
- 模糊滤镜:此滤镜会使用我们在Duke编程环境中学到的一些技术,将图片变得模糊。
- 图片尺寸显示:你可能会注意到,当我点击这些滤镜时,网页上会显示我上传图片的尺寸(例如 250 x 188)。如果上传一张新图片(比如更大的棕色马图片),这里的尺寸信息也会随之更新。




实现图片尺寸显示并将其添加到网页中,是另一个挑战活动。这些图像处理功能会非常有趣,你不必局限于这四种,完全可以创造更多滤镜。
总结
本节课中我们一起学习了如何规划一个图像滤镜网站项目。我们从基础功能入手,了解了如何实现图片上传、红色与灰度滤镜以及重置功能。随后,我们探讨了扩展挑战,包括添加彩虹背景、模糊滤镜以及动态显示图片尺寸。这个迷你项目将充分融合你已掌握的HTML、CSS和JavaScript知识,期待看到大家创造出精彩的作品。
038:隐写术第一部分


在本节课中,你将学习如何使用函数来实现一种称为“隐写术”的技术。隐写术是指将数据隐藏在一张图片或其他数字载体(如音频文件、软件程序或任何由0和1组成的文件)中的方法。由于隐写术的任务规模比之前解决的问题稍大,将代码分解为函数是一个很好的思路。随着课程的推进,你将看到一些例子,其中部分代码被抽象成独立的函数,以简化整体解决方案并避免代码重复。
概述:什么是隐写术?🔍
上一节我们介绍了课程目标,本节中我们来看看隐写术的核心概念。
隐写术背后的想法是,拿一张图片(例如这张短跑运动员尤塞恩·博尔特的图片),并将其他数据(如另一张图片)隐藏在其中。具体做法是改变尤塞恩·博尔特图片像素的数值,以编码隐藏的图像。隐写术的关键在于,要以一种不易被人察觉的方式隐藏数据,使他人难以发现原始图片已被修改。
右边这张图片是在第一张图片中隐藏了一条秘密信息的结果。你能看出它被修改过吗?如果仔细观察,可以看到背景阴影的一些细微差别。然而,如果单独看这第二张图片,并没有什么可疑之处,它看起来就是一张尤塞恩·博尔特的照片。这个想法由来已久,早于计算机出现。在历史上,发送不被察觉的信息一直很重要。一个重要的现代用途是规避压迫性政府实施的审查。
你可以将任何数字信息隐藏在图片中。例如,你可以将文本或HTML文件隐藏在图片中,但这需要更多的数学知识和更深入的理解“万物皆数”的原理。为了入门简单,你首先将学习如何将一张图片隐藏到另一张尺寸相同的图片中。
完成本节课后,你将能够“在宇宙中发现隐藏的意义”。也就是说,我们不仅会引导你理解概念,还会讲解实现隐写术隐藏的代码。然后,你将编写代码从图片中提取隐藏的信息。例如,你可以从这张星系图片中提取我们隐藏在内的信息。
隐写术的原理:利用像素的微小差异 🎨
那么,具体如何实现呢?你已经知道像素有红、绿、蓝三个分量,它们用数值代表颜色。一个红色分量的值是240还是255,有很大区别吗?它们在数值上是不同的,但如果你观察它们,两者看起来非常相似。
正是这种轻微改变数值后不易察觉的特性,成为了在图片中隐藏数据的关键。你可以将隐藏数据存储在颜色值的最低有效位中,而不会导致最终颜色发生明显变化。最低有效位就像三位数中的个位和十位。因此,你可以通过将240改为255,在240中隐藏一个15。
为了实现这一点,你需要一些数学运算。别担心,只是乘法、除法和加法,你只需要以正确的方式组合它们。
从十进制(Base 10)理解概念 🔢
为了理解如何进行这些数学运算,我们将从你每天使用的十进制(以10为基数)数字系统开始。我们将在十进制中解释概念,然后学习二进制(以2为基数),这是计算机用来存储数字的系统。所有原理在二进制中和在十进制中是一样的,你只需要使用2的幂次方,而不是10的幂次方。
为了在熟悉的十进制中理解这个想法,假设红、绿、蓝分量的值范围是0到9999,而不是0到255。这样,颜色的每个分量就有四位十进制数字。
现在假设你想将下面这个红色像素(RGB值为:红=8274,绿=0,蓝=1098)隐藏到这个蓝色像素(RGB值为:红=3568,绿=5686,蓝=7450)中。我们将把结果放在右边。
对于红色分量,你想取用于隐藏数据的像素(即蓝色像素)的最高两位有效数字,并将它们用作结果像素的最高两位有效数字。然后,你想取要被隐藏的像素(即红色像素)的最高两位有效数字,并将它们用作结果像素的最低两位有效数字。
请注意,3582与3568非常相似,看起来几乎一样,但你已轻微改变了它,从而将秘密信息存储在了它的最低有效位中。
现在你对绿色分量做同样的事情,从这个蓝色像素中取最高两位有效数字,并与这个红色像素的最高两位有效数字组合。同样,5600与5686非常相似。
现在你对蓝色分量做同样的事情,组合两个像素蓝色分量的最高有效数字。得到的数字7410同样与原始的7450非常相似。
如果你观察这个结果像素的颜色,很难看出它与原始蓝色像素的区别,但正如你将看到的,我们已经将一个红色像素隐藏在其中了。
如何提取隐藏的信息?🔓
现在信息已经隐藏,你如何提取秘密呢?你知道,你想要这个像素红色分量的最低两位有效数字,成为隐藏的(即将被提取的)像素红色分量的最高两位有效数字。所以我们希望82成为R(红色)值的最高有效数字。但最低有效数字应该是什么呢?这其实不太重要,所以我们直接选择0。然后你对绿色和蓝色分量做同样的事情。
如果你观察得到的颜色,它是这种红色调。这种红色调与我们想要隐藏的原始颜色非常接近。即使你没有得到完全相同的颜色,提取出的图像看起来也会非常相似。
核心概念总结与过渡到二进制 💡
至此,你了解了隐写术的核心思想:将数据隐藏在其他数据中。具体来说,你将学习如何将一张图片隐藏在另一张图片中。现在你理解了其中涉及的基本数学原理:从每个数字中取出指定位数并进行组合。
然而,为了在代码中实现这一点,你需要学习一点关于二进制(以2为基数的数字系统)的知识。计算机使用二进制,这就是为什么颜色值的范围是0到255,而不是我们刚才使用的0到9999。
在下一部分,我们将深入探讨二进制表示,并学习如何用代码实现这些操作。
039:隐写术第二部分

概述
在本节课中,我们将学习二进制的基础知识,以便对像素的数值进行数学运算。我们将探讨如何利用二进制表示法来隐藏和提取图像信息,这是实现隐写术的关键步骤。
二进制数字系统回顾
上一节我们介绍了隐写术的基本概念,本节中我们来看看如何用二进制数字系统来处理像素值。
计算机使用二进制系统,因为它基于电路和晶体管工作,而两种电压电平最容易实现。因此,计算机自然地使用只有两个值(1和0)的数字系统。
在二进制中,数位代表的是2的幂次,而不是10的幂次。例如,二进制数 10111 的计算方式如下:
1*1 + 1*2 + 1*4 + 0*8 + 1*16 = 23(十进制)。
像素的红、绿、蓝分量范围是0到255,因为它们是用8个二进制位(bit)存储的。8位二进制数的最小值是 00000000(十进制0),最大值是 11111111(十进制255)。
隐写术中的数学操作
了解了每种颜色是一个8位二进制数后,让我们重新审视隐写术问题。
假设你想将左边的8位数 10110010 隐藏到右边的数 01110101 中。我们将使用每个数的一半位数(4位)进行操作。
以下是核心操作步骤:
提取高位数字(以十进制为例)
首先,我们用一个更熟悉的十进制场景来理解如何提取数字的高位部分。
例如,如何从数字 8274 中提取出蓝色的前两位数字 82?
你可以将 8274 除以 100,得到 82.74,然后丢弃小数部分。在JavaScript中,这通过 Math.floor(8274 / 100) 实现,结果是 82。
这个原则同样适用于从 3568 中提取 35:Math.floor(3568 / 100) 得到 35。实际上,你可以通过除以适当的10的幂次并丢弃小数部分,来去掉任何十进制数的最低有效位。
组合数字
现在你有了 82 和 35,你想把它们组合成 3582。这可以通过将 35 乘以 100 再加上 82 来实现:35 * 100 + 82 = 3582。
这个策略适用于将任意两个两位数组合成一个四位数。如果数字的位数不同,你需要乘以不同的10的幂次。
应用于二进制数字
现在让我们回到用二进制表示的数字。你可以应用相同的原理,但需要使用2的幂次而不是10的幂次。
由于我们想一次处理4位数字,我们将使用 2^4,即 16。
- 提取高位:
Math.floor(10110010 / 16)会得到1011。请注意,这本质上就是十进制178 / 16 = 11,我们只是用二进制书写数字。 - 提取另一个数的高位:同样,除以16并取
Math.floor。 - 组合数字:使用之前看到的原理,但这次是乘以16然后相加。例如,将两个4位数组合成一个8位数:
(firstFourBits * 16) + secondFourBits。
这就是将一个图像隐藏在另一个图像中所需要的数学运算。
提取隐藏图像
那么,提取隐藏图像的数学运算是什么呢?如何从右边的数字(蓝色部分)中提取出最低有效的4位数字,并使它们成为左边数字的最高有效的4位?
让我们再次回到更熟悉的十进制场景。当你将 3582 除以 100 时,你得到商 35 和余数 82。这个余数 82 正是你想要的。
在JavaScript中,你如何使用运算符来获取除法后的余数呢?
你将使用百分号 % 运算符,它被称为 模运算(mod),是除法余数的正式数学名称。因此,3582 % 100 的结果是 82。
一旦你得到了这两个数字,你可以简单地将其乘以 100,使它们成为结果四位数的前两位最高有效数字。
同样的原理也适用于二进制数字,但再次强调,你需要使用2的幂次而不是10的幂次。由于你想要4位数字,你将使用 2^4,即 16,而不是 100。
- 提取低位:对一个数取模
16(number % 16)会得到其四个最低有效位(最右边的二进制位)。 - 提升为高位:如果你将该数字乘以
16,这些位就会成为一个八位数中最高有效的位。
这就是提取隐藏图像所需的数学运算。
总结
本节课中我们一起学习了:
- 二进制数字系统的原理,以及像素值如何用8位二进制数表示。
- 如何通过除法和
Math.floor操作来提取数字的高位部分。 - 如何通过乘法和加法将数字组合起来。
- 如何使用模运算
%来提取数字的低位部分(余数)。 - 这些概念在十进制和二进制中是相通的,关键是将10的幂次替换为2的幂次。
记住这个策略:用十进制来思考数字,但通过使用2的幂次(而不是10的幂次)来进行二进制数学运算。
040:隐写术第三部分 🔍

在本节课中,我们将学习如何将隐写术的理论与二进制数学知识结合起来,通过编写具体的函数,实现将一幅图像隐藏到另一幅图像中的完整过程。
上一节我们介绍了隐写术的基本概念和二进制数学基础,本节中我们来看看如何将这些知识整合成可执行的代码。
核心算法概述
假设你想将一幅包含秘密信息的图像(顶部图像)隐藏到一幅银河系图片(底部图像)中。你已经知道如何获取代表颜色分量(红、绿或蓝)的8位数字,并将一幅图像的最高有效位隐藏到另一幅图像中。具体做法是:提取最终要显示的图像的最高有效位,并将其与要隐藏的图像的最高有效位组合。你已了解实现此功能的数学计算,但这不仅仅是处理一对数字的运算。
你需要对图像中的每个像素执行此运算。遍历图像中的每个像素现在应该很熟悉了,并且你需要对红、绿、蓝三个颜色分量分别进行此运算。由于需要对三个分量做相同的事情,你可能会考虑将计算提取到一个函数中,以便调用函数而不是重复编写代码。
分解任务:三个核心函数
鉴于这个任务较为复杂,将其分解为几个函数会很有帮助。我们将引导你了解我们在实现隐藏功能时分解出的几个函数。
1. 函数 clearLow:处理载体图像
我们创建的第一个函数叫做 clearLow。它的作用是处理最终要显示的图像,生成一幅看起来非常相似的新图像。该函数会对每个像素的每个颜色分量执行数学运算,保留最高有效位,并将最低有效位清零。
以下是该函数的算法描述:
- 遍历图像中的每个像素。
- 对红、绿、蓝分量执行以下运算:
- 将原始值除以16。
- 取该结果的
Math.floor(向下取整)。 - 将结果乘以16。
你可能会想将这个重复的数学运算提取到它自己单独的函数中,然后在每个需要的地方调用它。生成的图像应该与原始图像看起来几乎相同,如下图所示。

如果你在实现、测试和调试这段代码,像这样将其分解成小块会很有帮助。你可以在继续下一步之前,检查这一部分的结果并确保其正常工作。
2. 函数 shift:处理秘密图像
你可能要编写的第二个函数将作用于你想要隐藏的图像。我们称之为 shift,因为它会将最高有效位移到最低有效位的位置。也就是说,你需要将一个颜色值(如 10110010)转换为 00001011。这里的 1011 已经从最高有效位移到了最低有效位。
以下是该函数的算法描述:
- 遍历图像中的每个像素。
- 将红、绿、蓝分量设置为原始值除以16后的
Math.floor结果。
如果你查看这里的结果图像,它会看起来是全黑的。这实际上是一件好事,因为所有信息都在最低有效位中,而最高有效位应该全是零。同样,在继续之前,你可以单独测试这个函数。
3. 函数 combine:合并图像
最后一个函数叫做 combine。它接收两幅图像,并将红对红、绿对绿、蓝对蓝相加,以生成结果图像的颜色值。它看起来应该基本上与原始载体图像相同。
高层算法与代码实现
一旦你有了这三个函数的思路,隐藏一幅图像到另一幅图像的高层算法如下所示:
载体图像_处理后 = clearLow(载体图像)
秘密图像_处理后 = shift(秘密图像)
最终图像 = combine(载体图像_处理后, 秘密图像_处理后)
你只需调用我们讨论过的这些函数,它们就会完成所有工作。
了解了每个函数的算法后,你可以将它们转化为代码。例如,这是 clearLow 函数的代码示例。你可以看到,我们决定将实际的数学运算提取到它自己的辅助函数中,并为红、绿、蓝每个分量调用它。
// 示例辅助函数
public static int clearLowBits(int colorValue) {
return (colorValue / 16) * 16; // 等价于 Math.floor(colorValue / 16) * 16
}
// 在 clearLow 函数中调用
for (Pixel p : image) {
p.setRed(clearLowBits(p.getRed()));
p.setGreen(clearLowBits(p.getGreen()));
p.setBlue(clearLowBits(p.getBlue()));
}
隐藏功能的其他函数(shift 和 combine)也类似。正如我们讨论的,你遍历图像的像素,并执行我们之前描述的数学运算。
如何提取隐藏图像?
那么,如何将隐藏的图像取出来呢?我们将把这个问题留给你。你已经看到了所需的数学运算。现在,你可以使用“七步法”来开发算法并将其转化为代码。
以下是提取隐藏图像的步骤建议:
- 从第一步开始:使用一个只有两个像素的图像进行工作。
- 写下数值:为这两个像素的红、绿、蓝写下具体的数值。
- 手动提取:尝试提取出隐藏的两个像素图像。
- 开发算法:完成手动提取后,继续第2、3、4步,开发出你的通用算法。
- 转化为代码:将算法翻译成代码。
- 测试与调试:测试你的代码并进行调试。
祝你编码愉快!
总结
本节课中我们一起学习了隐写术的完整实现流程。我们首先将复杂的隐藏任务分解为三个核心函数:clearLow(清理载体图像的低位)、shift(移位秘密图像)和 combine(合并图像)。每个函数都有明确的算法和代码实现思路。最后,我们探讨了提取隐藏图像的方法,并鼓励你运用“七步法”来自行完成提取算法的设计与实现。通过这个过程,你将更深入地理解位运算在图像处理中的应用。
041:隐写术编码示例 🖼️➡️🔒
在本节课中,我们将学习如何通过代码实现一个简单的图像隐写术,即把一张图片隐藏到另一张图片中。我们将分步讲解三个核心函数:clearBits、shift 和 combine,并最终将它们组合起来完成隐藏过程。
概述
我们将编写代码,将一张“隐藏”图片嵌入到一张“展示”图片中。其核心原理是:修改“展示”图片每个像素颜色值的最低有效位,并用“隐藏”图片颜色值的最高有效位来填充。这样,人眼几乎察觉不到变化,但数据已被嵌入。
第一步:清除低位比特 🧹
上一节我们介绍了隐写术的基本概念,本节中我们来看看如何实现第一步:清除“展示”图片像素的低位比特。
我们首先实现 clearBits 函数。这个函数的作用是将一个颜色值(如红色、绿色或蓝色通道的值)的最低4位清零,只保留高4位。其数学公式如下:
新颜色值 = Math.floor(原颜色值 / 16) * 16
以下是该函数的代码实现:
function clearBits(colorValue) {
return Math.floor(colorValue / 16) * 16;
}
在 chompToHide 函数中,我们需要遍历“展示”图片的每一个像素,并对其红、绿、蓝通道分别应用 clearBits 函数。
以下是 chompToHide 函数的实现步骤:
- 遍历图片中的每一个像素。
- 获取当前像素的红色值,使用
clearBits函数清零其低4位,然后设置回去。 - 对绿色和蓝色通道重复步骤2。
- 返回修改后的图片。
运行此部分代码后,“展示”图片(例如博尔特的照片)看起来会与之前非常相似,但背景色可能会有细微变化,因为颜色值已被修改。
第二步:移位操作 ⬅️
在清除了“展示”图片的低位后,我们需要处理“隐藏”图片。这一步的目标是将“隐藏”图片每个像素颜色值的最高4位移到最低4位的位置。
我们将在 shift 函数中完成这个操作。其原理是通过除以16(即2的4次方)来实现右移4个二进制位。
以下是 shift 函数的实现步骤:
- 遍历“隐藏”图片的每一个像素。
- 将当前像素的红色值设置为
原红色值 / 16。 - 对绿色和蓝色通道重复步骤2。
- 重要:确保函数返回修改后的图片对象。

运行 shift 函数后,“隐藏”图片会变得几乎全黑,因为其有效颜色信息现在都存储在最低的4个比特位中,人眼难以分辨。
注意:在编码时,一个常见的错误是忘记 return 语句,这会导致“undefined”错误。务必在函数末尾返回结果图像。
第三步:合并图像 🔗
现在,我们有了处理好的“展示”图片(低位已清零)和“隐藏”图片(高位已移位)。最后一步是将它们合并起来。
我们将在 combine 函数中完成合并。其核心操作是将两个图片对应像素的颜色值相加。因为一个图片的颜色信息在高4位,另一个在低4位,相加后就能得到一个完整的8位颜色值。
以下是 combine 函数的实现步骤:
- 创建一个新的空白图片,尺寸与输入图片相同(代码中假设两张图片尺寸一致,实际应用中需要处理尺寸不同的情况)。
- 遍历新图片的每一个像素。
- 获取该像素在“展示”图片和“隐藏”图片中对应位置的颜色值。
- 将两个图片对应通道(红、绿、蓝)的颜色值相加,并设置为新图片当前像素的颜色值。
新红色值 = 展示图片红色值 + 隐藏图片红色值
- 对绿色和蓝色通道重复步骤4。
- 返回合并后的新图片。
当我们将所有函数组合起来并运行最终代码时,就能得到一张看起来是博尔特,但内部隐藏了另一张图片的新图像。
总结
本节课中我们一起学习了隐写术编码的完整流程:
- 清除低位 (
clearBits和chompToHide):为隐藏数据准备空间。 - 移位操作 (
shift):将待隐藏图片的数据移动到低位。 - 合并图像 (
combine):将处理后的两张图片相加,合成最终图像。

通过这三个步骤,我们成功地将一张图片的信息嵌入到了另一张图片之中。虽然本示例没有进行严格的测试,但它清晰地展示了基于最低有效位(LSB)的图像隐写术基本原理和实现方法。你可以尝试用不同的图片进行实验,例如将一张暴龙图片隐藏到风景图中。
055:在多日数据中寻找最高温度 🌡️

在本节课中,我们将学习如何扩展之前的功能,从分析单日数据扩展到分析多日数据,并找出指定日期范围内的最高温度。我们将创建一个新的方法,并利用目录资源来批量处理多个文件。
概述
上一节我们介绍了如何在单个CSV文件中找到最高温度。本节中,我们来看看如何遍历多天的数据文件,找出这些天中的最高温度记录。我们将使用目录资源来选择多个文件,并循环处理每个文件。

实现 hottestInManyDays 方法
为了实现这个功能,我创建了一个名为 hottestInManyDays 的新方法。这个方法将使用目录资源,允许我们一次性选择并比较任意数量的文件。
以下是实现该方法的步骤:

- 创建文件资源:首先,我们通过目录资源获取选中的文件列表,并遍历这个列表。
DirectoryResource dr = new DirectoryResource(); for (File f : dr.selectedFiles()) { // 处理每个文件 }


-
处理单个文件:对于列表中的每一个文件,我们将其转换为文件资源,然后调用上一节中创建的
hottestHourInFile方法来获取该文件中的最高温度记录。FileResource fr = new FileResource(f); CSVRecord currentRow = hottestHourInFile(fr.getCSVParser());这与我们之前在测试中编写的代码完全相同,但现在我们将它放在一个循环中,以便可以按需处理任意数量的文件。
-
比较并更新最高记录:与之前的例子类似,我们需要追踪目前找到的最高温度记录。为此,我们创建一个
CSVRecord类型的变量largestSoFar,并初始化为null。CSVRecord largestSoFar = null;在循环内部,我们将检查
largestSoFar是否为空(即是否还未被赋值)。如果是,则直接将当前记录赋值给它。if (largestSoFar == null) { largestSoFar = currentRow; }否则,我们需要比较当前记录的温度与目前最高记录的温度。

- 温度比较逻辑:这段比较代码与之前非常相似。我们将从当前记录和
largestSoFar记录中分别提取温度值,并进行比较。else { double currentTemp = Double.parseDouble(currentRow.get("TemperatureF")); double largestTemp = Double.parseDouble(largestSoFar.get("TemperatureF")); if (currentTemp > largestTemp) { largestSoFar = currentRow; } }

- 返回结果:循环结束后,
largestSoFar变量中存储的就是多日数据中的最高温度记录,我们将其返回。return largestSoFar;

测试方法
编写完代码后,我编译了程序以确保没有语法错误。接着,我创建了一个测试方法来验证其功能。

-
创建测试:我创建了一个名为
testHottestInManyDays的测试方法。在这个方法中,我调用了hottestInManyDays方法,它不需要参数,但会返回一个CSVRecord对象。public void testHottestInManyDays() { CSVRecord largest = hottestInManyDays(); System.out.println("hottest temperature was " + largest.get("TemperatureF") + " at " + largest.get("DateUTC")); }我打印出最高温度及其发生的日期(这里使用
DateUTC字段,因为可能涉及不同天数)。 -
小规模测试:为了初步验证,我选择了2015年的头两天数据进行测试。根据已知结果,1月1日的最高温度是51.1度,1月2日是54度。因此,我期望程序的输出是54度,发生在1月2日。运行测试后,结果符合预期。


- 大规模测试:在有了初步信心后,我进行了更大规模的数据集测试。我尝试处理2014年全年的数据(这是我们拥有完整数据的最后一年)。程序运行后,结果显示最高温度为98.1度,发生在7月8日晚上10点51分。
通过先在小样本(两天)上测试成功,再扩展到大规模数据集(全年),我确信代码能够正确工作。这种从小到大的测试方法,使得验证大规模数据处理的正确性成为可能,而无需手动检查海量数据。


总结
本节课中,我们一起学习了如何扩展单日最高温度查找功能,使其能够处理多日数据。我们创建了 hottestInManyDays 方法,利用循环和目录资源遍历多个文件,并通过比较逻辑持续更新找到的最高温度记录。最后,我们通过从两天到全年的分层测试,验证了代码的正确性。
001:课程介绍 🎯



在本节课中,我们将要学习杜克大学《Java编程和软件工程基础》课程的总体介绍。课程团队将向您介绍课程的核心目标、您将学习的内容以及课程的设计理念。
我是Drew,我和我的同事们欢迎您学习“使用软件解决Java编程问题”这门课程。


杜克大学的我们非常高兴您能迈出这第一步,学习使用Java解决实际问题。在本课程中,您将学习一个七步法,旨在帮助您理解如何应对任何编程问题。您将使用这个方法来解决实际问题,并且您将了解到,计算机科学远不止像Java这样的编程语言的语法。

您将有机会处理诸如分析DNA、操作CSV文件和处理图像等问题。这些都是工程师、科学家、程序员等人在现实生活中需要解决的真实问题。随着您开始学习Java,您也将能够应对这些问题。
我是Susan。在本课程中,您将学习使用Java编程,这些技术既可用于简单程序,也能扩展到更大的程序和更复杂的问题。我们介绍的库和API使处理多种格式的数据变得容易。您将能够使用这些相同的技术、工具和库来解决我们为您设计的问题。这些问题的解决方案需要您在此学习的编程知识。

我是Robert。当您学习Java程序的语法和语义时,您将在一个专门设计并已被证明能有效帮助像您这样的编程初学者的编程环境中进行练习。这个编程环境将让您使用软件工程师、科学家和程序员在设计和创建程序、使用Java及其他语言解决问题时所应用的技术,来设计、测试、运行和调试您的程序。这个编程环境可以扩展到大型问题,是您学习掌握日益复杂概念的一个绝佳起点。
我是Owen,我对我们为本课程创建的问题感到非常兴奋。我们运用了多年的集体经验来简化问题,并为您提供机会,在您处理真实问题时展示您对Java编程的掌握。这些问题仅从那些在日常工作中使用计算和编程方法的许多领域所面临的问题中做了轻微简化。

我们以类似的方式设计了我们的Java库,使用了标准的Java惯用法。如果您继续学习编程,您将会看到这些惯用法,但对于Java初学者来说,它们更容易使用。
再次欢迎您学习“使用软件解决Java编程问题”这门课程。期待在课程中与您相见。



本节课中我们一起学习了本课程的总体介绍。我们了解到,本课程旨在通过一个七步法教授Java编程,以解决实际问题,如分析DNA和处理图像。课程将使用专门设计的编程环境和库,帮助初学者掌握可扩展的编程技术,为处理更复杂的问题打下基础。
002:助您成功的资源 🛠️

在本节课中,我们将介绍课程的重要资源,并提供一些帮助你取得成功的建议。了解这些资源将帮助你更有效地完成编程练习和课程学习。
我是杜克大学教学团队的伊丽莎白。在开始本课程之前,我希望确保你了解一些重要的资源,并为你提供一些取得好成绩的建议。
本课程的作业将是编程练习,因此你将练习编写代码。课程内容中任何标记为“编程练习”的部分都是一项作业,其中包含帮助你编写自己程序的说明。完成代码编写后,会有一个练习测验,你可以通过将自己的结果与教师提供的答案进行比较,来检查你的程序是否正常运行。



课程网站介绍 🌐

我还想向你展示课程网站 Duke learntoprogram.com。
你可以看到,我们为每门课程都设有一个页面,还有一个关于专项课程的常见问题页面。这个网站包含了从证书到课程中使用的软件等所有信息。
如果你返回主页并选择你正在学习的课程,你将进入该课程的主页。这里我以第二门课程为例。现在我想指出的是项目资源、文档和常见问题页面。


核心资源详解 📚

以下是本课程中你需要了解的几个关键资源部分。
- 项目资源:在这里,你可以下载代码,以便跟随视频讲座学习或开始完成作业。
- 文档:这里汇总了你在本课程中将学到的Java方法。如果你忘记了某个方法的名称,或者想了解是否有Java方法可以完成特定任务,这将非常有用。请注意,这不是所有Java方法的详尽列表,只是本课程中最有用方法的摘要。
- 常见问题页面:此页面包含针对本课程的具体问题。对于关于整个专项课程的问题,请点击页面顶部的链接。
总结与反馈 💡
本节课中,我们一起学习了课程的核心结构和必备资源。希望这个视频让你对课程结构以及需要了解的资源有了清晰的认识。如果你对我们如何改进这些资源以使其对你更有用有任何反馈,请在Coursera的讨论论坛中告诉我们。
003:编程学习技巧 🧠

在本节课中,我们将学习一些高效学习编程的技巧。这些建议旨在帮助你更好地掌握课程内容,克服编程中遇到的挑战,并最终成为一名更出色的程序员。
为了帮助你取得最佳学习效果,我们提供一些关于如何学习本课程的建议。
首先,每天学习一点。试图一次性学完所有编程知识非常困难。如果你每天完成几项课程内容,而不是试图在一两天内完成所有任务,你将能更好地记住知识,保持更高的学习动力,并有更多时间来解决代码中的问题。
上一节我们提到了代码中的问题,也就是所谓的“Bug”。
在编程时犯错是正常的,因此我们的下一个建议是:不要放弃。每个人的程序中都会出现Bug。编程的一部分就是找出问题所在并修复它。
在编程时,我们强烈建议遵循七步流程法。这意味着在开始编写任何代码之前,你应该先规划如何解决问题。如果你没有学习我们的第一门课程,不用担心,稍后会有机会回顾七步流程法。七步流程法很重要,因为它为你提供了一种解决问题的方法。当你构思出解决方案后,就可以开始编写代码了。
一旦你准备好开始编写程序,请确保阅读了相关文档。这样你就能了解存在哪些其他方法以及如何使用它们。根据需要,随时查阅文档。

接下来,充分利用实时编码视频和随堂练习。对于实时编码视频,这是一个与讲师一起编程的绝佳机会。你也可以从视频中下载代码并自己运行。尝试做一些小的改动,以确保你真正理解程序的每个部分是如何工作的。

最后,对于随堂练习,即使它们不计入最终成绩,它们仍然是测试你代码的好机会。在进入计分测验之前,利用随堂练习来发现和修复问题。
最后,如果你在编程中仍然遇到困难,请在课程讨论区向讲师团队和同学寻求帮助。成为一名优秀程序员的一部分,就是知道如何有效地寻求帮助。我们将在下一个视频中更详细地讨论这一点。
本节课中,我们一起学习了高效学习编程的几个关键技巧:坚持每日学习、不畏惧错误和Bug、遵循七步流程法、善用文档、利用视频和练习进行实践,以及在需要时积极寻求帮助。掌握这些技巧将帮助你更顺利地进行编程学习。
Java编程与软件工程基础:2-5:有效使用论坛求助

在本节课中,我们将学习如何在课程讨论论坛中有效地提问和回答问题。掌握这些技巧能帮助你更快地获得帮助,也能让你在帮助他人时更有效率。


学习编程时遇到困难是完全正常的。当这种情况发生时,课程的教学团队和你的同学们会在这里提供帮助。获得帮助的最佳途径是通过讨论论坛,你可以在这里找到它。
你也可以在讨论论坛中回答问题。我们非常鼓励这样做,因为解释编程概念是学习的绝佳方式,同时你也能帮助到你的同学。
以下是关于在论坛提问的一些通用建议。
在提问前进行搜索
首先,在开始一个新话题前,你应该检查 Duke learntoprogram.com 上的常见问题页面以及现有的论坛帖子,看看你的问题是否已经被解答过。当你对编程作业或测验有疑问时,第一件事就是检查该课程的常见问题页面。
发起新话题
其次,如果你有一个新问题,请发起一个新帖子。除非你的问题与现有帖子高度相关,否则不要将你的问题作为现有帖子的回复发布。这样,其他人更容易看清你的问题是什么,从而更快地帮助你。


使用代码格式化框
第三,如果你需要发布代码,请使用代码格式化框。

这个框上有这个符号:</>。
这比直接将代码复制粘贴到帖子中要易于阅读得多。
我们还想给你一些关于如何提出好问题的建议,以便他人能最轻松地帮助你。
清晰描述问题
如果你在编程作业中遇到问题,请说明你正在处理的作业名称并提供链接。如果你的程序抛出异常,你可以发布错误信息的截图以及发生错误的代码行。
如果你的程序产生的结果与你预期的不符,请务必说明你运行代码时使用的输入、你期望得到的输出以及实际得到的输出。
例如,假设我正在尝试编写一个程序,将图像中的每个绿色像素改为蓝色。我应该分享我运行程序的图像(我的输入),我应该解释我期望每个绿色像素都变成蓝色(我的预期输出),我还应该解释实际上每个绿色像素都变成了红色(我的实际输出)。这样,论坛中的其他人就能更好地理解我遇到的问题。
有选择地分享代码
如果因为仅凭程序行为的描述无法提供帮助而需要分享一些代码,可以分享几行代码,但不要分享你的整个程序。找出你认为程序中出错的部分,并分享该方法中的几行代码。不要将整个程序复制粘贴到你的帖子中。
如果你有一个通用的编程问题,例如“如何编写 For 循环”或“如何向列表中添加项目”,发布这些代码是可以的,因为它们非常通用。
最后,如果你有概念性问题,请务必注明并链接到你参考的任何课程材料。
如何有效回答问题
当你回答问题时,分享通用编程概念(如“如何编写 for 循环”)的代码是可以的。然而,如果某人的代码有问题,不要直接给出解决方案。尝试通过给出提示来引导他们自己修复代码。如果你不知道问题所在,可以建议他们接下来可以做什么来调试代码。

让我们看一些示例帖子。
在这个帖子中,我并没有很好地寻求帮助。我只是说我的代码不工作,并问是否有人知道可能出了什么问题,但我没有真正解释代码试图做什么、它实际在做什么,或者我迄今为止尝试了哪些故障排除步骤。我还发布了大量代码。
因此,我编辑了我的帖子,现在它好多了。你可以看到我解释了我正在处理哪个作业,并提供了它的链接。

我还解释了当我运行程序时发生了什么(我的实际输出),以及运行它时应该发生什么(我的预期输出)。最后,我只发布了几行我认为有问题的代码。
总结
现在你已经学会了如何就代码进行提问和回答,你已经准备好开始学习如何编程了。希望你享受这门课程,并期待在论坛中与大家互动。祝你好运。
005:面向对象编程与Java专项课程


在本节中,我们将向您介绍一门由杜克大学和加州大学圣地亚哥分校的讲师共同创建的专项课程。这门课程名为“面向对象编程与Java”。
我是杜克大学的讲师之一,欧文·阿斯特拉坎。我将帮助您学习这门专项课程。
我们将从Java的基础开始,学习如何使用它编写程序来解决各种各样的问题。
我是苏珊·罗杰,也是您来自杜克大学的另一位讲师。我们将从Java的基础知识开始。虽然我们希望您已经有一些编程经验,但我们会假设您对Java一无所知,并且渴望学习。
这门专项课程的下一门课程是“Java编程:数组列表与结构化数据”。我是罗伯特·杜瓦尔,很高兴能教您关于Java的知识。
在我们的下一门课程中,您将更深入地学习Java,并学习以更复杂的方式存储数据,从而解决更有趣、更激动人心的问题。我想我们的示例中甚至有一只恐龙。
我是德鲁·希尔顿,是您来自杜克大学的第四位讲师。在您与我们学习了这些Java基础知识之后,我们在加州大学圣地亚哥分校的朋友们将接手,教您更多关于Java和面向对象编程的精彩内容。
现在,我们将让他们向您做自我介绍,几个月后您将再次见到他们。

大家好,我是米娅·明尼斯。我将是您加州大学圣地亚哥分校的讲师之一。您将在第三门课程“面向对象编程”中见到我们。在这门课程中,我们将建立在您从杜克大学的朋友那里学到的编程概念之上,然后我们还将更多地讨论Java的面向对象特性,以及如何利用它来构建更大的程序以解决更复杂的问题。

大家好,我是莉亚·波特,是加州大学圣地亚哥分校的讲师。您将在专项课程的第三门课程中学到的另一个主题是图形用户界面和事件处理。掌握了这些技能,您将能够构建易于使用且直观的交互式应用程序。
我是克里斯汀·阿尔瓦拉多,是这门专项课程加州大学圣地亚哥分校团队的最后一位讲师。我想向您简要介绍一下专项课程的第四门课程。在那门课程中,您将学习如何更高效地存储数据,以便您的程序能够快速地对大型数据集执行操作。当您完成那门课程时,您将成为一名相当出色的Java程序员。
那么,让我们开始吧。😊
Java编程入门:01:为什么使用Java
在本节课中,我们将介绍Java编程以及本课程中用于设计、开发、测试和运行Java程序的BlueJ环境。我们将了解Java为何被广泛使用,并亲身体验使用BlueJ编写和运行第一个Java程序的基本流程。

Java是一种流行且应用广泛的语言。它是Android操作系统的基石,该操作系统驱动的智能设备数量超过世界上任何其他系统。Java是一种极其有用且功能强大的语言,在几乎所有类型的计算机上都得到了广泛支持。
同时,Java对初学者也很友好,这正是我们在课程中使用它的原因。
在本课结束时,你将获得使用BlueJ(我们用于Java编程的开发环境)的经验。你将能够使用BlueJ来编译和运行Java程序。你将了解基本的设计、编辑、编译和执行周期,这是使用数百种不同语言进行编程的组成部分。你将亲眼看到Java在访问信息方面是多么简单和强大,我们将用它来打印多种语言的“Hello World”。这是我们对于传统第一个程序的诠释,随着你开始探索Java编程,我们将对此进行更多解释。
那么,Olamundo, Koichiwa, Zroven, Neha and Hello world。让我们开始吧。
课程目标
在本节结束时,你将能够:
- 使用BlueJ环境。
- 编译并运行一个Java程序。
- 理解编程的基本周期:设计、编辑、编译、执行。
- 初步感受Java的简洁与强大。
为什么选择Java?
上一节我们概述了本课内容,现在我们来具体看看选择Java作为入门语言的几个关键原因。
以下是Java的主要优势:
- 应用广泛与平台支持:Java是一种非常流行且被广泛采用的语言。它几乎在所有类型的计算机上都有广泛的支持,这意味着用Java编写的程序可以在多种操作系统上运行。
- 强大的生态系统:Java是Android操作系统的基石。全球数量最多的智能手机都运行在基于Java的Android系统之上,这体现了Java在移动开发领域的核心地位。
- 对初学者友好:尽管功能强大,但Java的语法相对清晰、结构规范,非常适合作为编程初学者的第一门语言。它有助于建立良好的编程思维习惯。
- 功能强大:Java不仅用于移动开发,还广泛应用于企业级后端服务、大数据处理、云计算等领域,是一门极具实用价值的语言。
认识开发环境:BlueJ
了解了Java的优势后,我们需要一个工具来编写和运行Java代码。本节我们将介绍本课程使用的集成开发环境(IDE)——BlueJ。
BlueJ是一个专门为Java教学设计的开发环境。它的界面直观,能帮助初学者清晰地理解对象、类等概念,而不被复杂的企业级功能干扰。
在BlueJ中,编程的基本周期通常包含以下几个步骤:
- 设计:规划程序的结构和功能。
- 编辑:在编辑器中编写或修改Java源代码(
.java文件)。 - 编译:将人类可读的源代码转换为计算机可执行的字节码。在BlueJ中,这通常通过点击“编译”按钮完成。编译成功的标志是代码区域没有红色错误提示。
- 执行:运行编译后的程序,查看结果。在BlueJ中,你可以通过右键点击类并选择相关方法来运行程序。
第一个程序:Hello World
现在,让我们将理论付诸实践。本节我们将使用BlueJ创建并运行一个经典的入门程序——输出“Hello World”以及多种语言的问候。
这个程序虽然简单,但它完整地展示了编辑、编译、执行的完整流程。通过它,你将首次亲眼见证Java代码如何变成屏幕上的输出。
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World!");
System.out.println("Olamundo!"); // 世界语
System.out.println("Koichiwa!"); // 日语
// 可以继续添加更多语言的问候
}
}
代码解释:
public class HelloWorld:定义了一个名为HelloWorld的公共类。public static void main(String[] args):这是Java程序的入口点。程序从这里开始执行。System.out.println(...):这行代码用于向控制台输出文本。每调用一次,就会打印一行内容。
总结
本节课中,我们一起学习了Java编程的入门知识。

我们首先探讨了为什么使用Java,因为它是一门应用广泛、功能强大且对初学者友好的语言。接着,我们认识了为本课程量身定制的开发环境BlueJ,并了解了在其中进行编程的基本周期:设计、编辑、编译和执行。最后,我们动手创建并运行了第一个Java程序——“Hello World”,直观地体验了Java代码从编写到输出的全过程。
通过本课,你已经迈出了Java编程的第一步。接下来,我们将深入Java的语法和核心概念,构建更复杂的程序。
007:使用BlueJ进行Java编程

在本节课中,我们将学习Java代码的组织方式、计算机如何执行程序,并演示如何在BlueJ环境中运行你的第一个Java程序。
概述:Java代码的组织与执行
上一节我们介绍了编程的基本概念,本节中我们来看看Java语言的具体特点。Java是一种面向对象的语言。这意味着你将在编写代码时使用类和对象。
类是组织程序的一种方式,而对象是在程序运行时使用类创建的。我们将在后续课程中深入学习对象和面向对象编程。
类文件具有 .java 扩展名。在一个Java类中,你将编写一个或多个Java方法。这些方法是当你运行程序时,计算机要执行的指令。
你编写的代码被称为源代码。源代码是高级代码,人类可读,但机器不可读。因此,当我打开这个Java类时,我可以阅读我的同事讲师在其中编写的Java程序。
为了让计算机运行我的程序,我的源代码必须被翻译成低级的字节码,这是机器可读的。字节码文件具有 .class 扩展名。

将类中的源代码翻译成字节码的过程称为编译。当你编写Java程序时,在运行程序之前,你需要先编译它。
那么,你最终由计算机运行的代码写在哪里呢?程序员在编程环境中编写代码。在这些课程中,我们将使用一个名为BlueJ的特定环境。
我们选择BlueJ,因为它是一个非常适合初学者的编程环境。它允许你开始编程,而无需担心编辑器的复杂性,并且我们添加了一些特殊功能,供你在为本课程开发Java程序时使用。
下载并运行第一个Java程序

接下来,你将运行你的第一个Java程序。我们将向你展示如何从Duke Learner Program网站下载它,然后打开BlueJ并运行该程序。
以下是具体步骤:
- 访问课程网站:打开Duke Learn to Program网站,进入Course2页面。
- 获取项目资源:点击“Project Resources”。你可以看到我们的第一个程序,名为“Hello World BlueJ Project”。
- 下载项目:点击该项目链接。你将获得一个包含Java程序和数据文件的ZIP压缩包。下载后,解压该文件即可。
- 启动BlueJ:启动你已经安装好的BlueJ环境。
- 打开项目:在BlueJ中,点击菜单栏的“Project” -> “Open Project...”,然后导航到你解压项目文件的文件夹,选择并打开“Hello World”项目。
打开项目后,你将看到名为“HelloWorld”的Java文件。点击它即可查看其中的代码。
让我们简要分析一下这段代码:
// 这是一个名为HelloWorld的类
public class HelloWorld {
// 类中包含一个名为runHello的方法
public void runHello() {
// 创建一个文件资源,关联到名为"hello_unicode.txt"的文件
FileResource res = new FileResource("hello_unicode.txt");
// 使用for循环遍历文件的每一行
for (String line : res.lines()) {
// 打印每一行内容
System.out.println(line);
}
}
}
这是一个非常简单的程序。它的作用是:打开一个文件,并打印文件中的每一行。文件中的每一行是来自某个国家的问候语。
编译与运行程序
在运行程序之前,你需要先编译它。
在BlueJ的主窗口中,右键单击“HelloWorld”类图标。你会看到图标上有许多斜线,这表示程序尚未编译。你需要编译程序,以便计算机能够理解它。
右键单击后,选择“Compile”。如果一切顺利,你会看到除了右上角的两条线外,其他斜线都消失了。这意味着它已编译成功,并创建了计算机可以理解的机器可读的 .class 文件。
现在,我们可以创建该类的实例(即对象)。再次右键单击“HelloWorld”类图标,选择“new HelloWorld()”。你可以为对象指定任意名称,这里我们使用默认名称。
对象创建后,我们就可以运行它了。右键单击新创建的对象(下方带有红色长方形的图标),你会看到 runHello() 方法。点击它来运行。
运行后,程序将打印出文件中的所有行。你可以看到各种美妙的问候语,例如:Hallo, hello, Bonjour, Guten tag, Aloha 等。

这些问候语来自你下载项目时附带的数据文件 hello_unicode.txt。你可以用文本编辑器打开这个文件,会发现其中包含的内容与程序打印出的内容完全一致。
让我们回顾一下程序的工作流程:
- 打开文件
hello_unicode.txt。 - 使用
for循环逐行遍历该文件。 - 在循环中,获取每一行并将其打印出来。

总结
本节课中我们一起学习了Java代码的基本组织单元——类和方法,了解了源代码需要编译成字节码才能被计算机执行。我们重点实践了如何在BlueJ编程环境中下载、打开、编译并运行一个简单的Java程序。这个程序通过读取文件并打印其内容,向你展示了程序的基本输入输出操作。希望你已经成功运行了你的第一个Java程序,并感受到了编程的乐趣。
Java编程和软件工程基础:2-5:形状:点的集合


在本节课中,我们将学习如何使用Java计算形状的周长。我们将通过一个名为Shape的Java类来建模几何中的多边形,并理解其作为点的集合这一核心概念。
上一节我们介绍了编程的基本概念,本节中我们来看看如何用代码表示几何形状。




我们将使用一个Java类来表示点的集合。这种Shape类用于表示多边形,例如三角形就是最简单的多边形,它由三个点构成。在计算机图形学和电子游戏中,复杂的彩色形状或线框模型的基础通常就是由许多三角形组合而成的。


我们使用Point类和Shape类来理解适用于多种编程语言的编程概念。

我们需要通过逐个添加点的方式来构造形状。这允许我们创建像这个六边形一样的形状,它由6个点构成。


以下是构造形状时需要注意的要点:
- 点的添加顺序非常重要,它决定了形状的最终轮廓。
- 一个五边形的示例。
- 另一个六边形的示例。


当我们查看Java Shape类的代码时,还需要能够访问这些点的方法,可以逐个访问,也可以使用其他方式。我们可能还希望创建使用形状的操作或方法,例如绘制形状或计算形状的周长。
我们已经看到形状可以包含许多点。我们的Shape类虽然简单,但可以扩展它来创建由非常多点构成的复杂形状,比如这只蝴蝶。仔细观察会发现,蝴蝶实际上只是许多彩色三角形的集合,就像我们之前看到的线框绘图一样。我们将要研究的Shape类最初会非常简单。
然而,并非所有形状都容易用有限点集来建模。


圆并不是一个有限的点集合,因此圆和其他一些形状更难用我们的Java类来建模。在几何学中,圆是无限多个点的集合,这些点到圆心的距离都相等。而我们的Java类只是一个有限的点集合。


不过,即使使用我们简单的类,也可以通过许多点来近似模拟一个圆,如下图所示。因此,即使是我们简单的类也将被证明非常强大。

在我们介绍了Java编程的一些基础知识之后,我们将更详细地探讨Point和Shape类。


本节课中我们一起学习了如何使用Shape类作为点的集合来建模多边形,理解了构造形状时点的顺序的重要性,并认识了这种简单模型在表示复杂图形(如用三角形模拟蝴蝶)和近似模拟圆方面的潜力。
009:阅读代码的动机

在本节课中,我们将探讨理解代码语义的重要性,以及如何通过手动执行代码来深入理解其含义。
理解代码的语义
上一节我们介绍了Java类Shape和Point的基本概念。本节中我们来看看如何理解这些代码的具体含义。
你将使用Java类Shape和Point。但你能用这些类做什么?在某些方面,你已经知道答案。你将能够绘制形状或计算形状的周长。但仅仅知道Java类或脚本能做什么,并不意味着你理解了它。因此,我们现在要回答的问题是:你如何自己理解这段代码的作用?也就是说,代码每个部分的语义或含义是什么?理解代码的精确含义很重要,因为你无法在不精确表达意图的情况下编写代码。
手动执行代码的意义

当我们谈论理解代码的语义时,具体指什么?我们指的是,你如何仅用纸和笔手动执行代码。这项技能非常重要,原因有几个。
以下是掌握这项技能的两个主要原因:
- 首先,这是你充分理解代码以便准确表达意图的方式。
- 其次,当你的代码行为不符合预期时,你需要弄清楚哪里出了问题。你需要理解代码做了什么。这为你提供了解决问题的技能。
总结
本节课中我们一起学习了理解代码语义的重要性。我们认识到,仅仅知道代码的功能是不够的,必须深入理解其精确含义。通过练习手动执行代码,我们可以更好地理解代码逻辑,从而更准确地编写代码,并在出现问题时有效地进行调试。
Java编程和软件工程基础:2-5:变量声明与初始化 📦

在本节课中,我们将要学习Java中最基础的语句——变量声明与赋值,并以此为基础逐步构建更复杂的代码执行逻辑。


上一节我们介绍了代码执行的基本概念,本节中我们来看看如何具体地声明和初始化变量。
变量声明

以下是一个简单的变量声明示例,它声明了两个整型变量 X 和 Y:

int X;
int Y;
执行这两条语句后,程序会创建两个分别标记为 X 和 Y 的“盒子”,用于存放将来要赋予这些变量的值。



未初始化的变量
在上述例子中,我们并未为变量提供明确的初始值。在某些编程语言(如C语言)中,声明变量时不会提供默认值。这意味着如果你使用了未初始化的变量,程序将产生未定义的行为。

这是一个常见且严重的问题,因此Java提供了两种解决方案:
- 对于实例变量,Java会提供一个默认的初始值(例如,整型为
0)。 - 对于局部变量,如果在初始化前使用它,Java编译器会给出明确的错误提示。

我们将在后续的视频中详细探讨这些不同类型的变量。
声明时初始化

现在让我们看另一个例子,它在声明变量的同时进行初始化:

int X = 4;
int Y = 6;

执行第一条语句时,程序会为 X 创建一个“盒子”,并立即将值 4 放入其中。我们通过将 4 放入 X 的盒子中来展示这一点。
执行下一条语句时,程序会为 Y 创建一个“盒子”,并将值 6 放入其中。



将变量的声明和初始化合并到同一条语句中,在任何语言中都不会产生问题。任何阅读你代码的人都能清楚地理解你的意图,因此这是一个值得养成的好习惯。
在表达式中使用变量


当然,变量只有在使用其存储的值时才有意义。请看以下代码:
int X = 4;
int Y = X + 2;
int Z = Y - X;


第一条代码声明了 X 并将其初始化为 4。
接下来,Y 被声明并初始化为 X + 2。要执行这条语句,我们必须计算等号右边的表达式。X 的值是 4,所以 X + 2 等于 4 + 2,即 6。我们通过为 Y 创建一个盒子并放入 6 来展示这个过程。
最后一条语句创建变量 Z 并将其初始化为 Y - X。因此,你需要取 Y 的值(6)和 X 的值(4),相减得到 2。然后为 Z 创建一个盒子并将其初始化为 2。

总结


本节课中我们一起学习了如何执行包含变量声明和赋值语句的代码。我们了解了简单的变量声明、未初始化变量的风险、在声明时进行初始化的最佳实践,以及如何在表达式中使用现有变量的值。掌握这些基础是理解更复杂Java编程概念的关键。
011:数学运算符
在本节课中,我们将要学习Java中数学运算符的使用,包括如何计算表达式、理解运算符优先级以及变量赋值的过程。

表达式实践
上一节我们介绍了表达式的基本概念,本节中我们来看看它们在实际代码中如何运作。
以下代码示例首先声明了一个名为X的整数类型变量。
int x;
接下来,我们将x初始化为4 + 3 * 2。
根据数学规则,乘法比加法拥有更高的优先级,因此这个表达式先计算3 * 2得到6,再计算4 + 6,最终结果为10。
x = 4 + 3 * 2; // x 的值为 10
所以,我们将数值10放入变量x的“盒子”中。
变量赋值与计算
然后,我们声明另一个int类型的变量Y,并将其初始化为x - 6。由于x的值是10,所以10 - 6的结果是4。
int y = x - 6; // y 的值为 4
我们为Y创建一个“盒子”,并将4放入其中。
最后一条语句是x = x * y。有时新手程序员会误以为这类语句像代数方程一样,可以通过等号求解x。然而,实际情况并非如此。我们需要遵循已学习的规则:先计算等号右边的表达式。
右边表达式x * y计算为10 * 4,结果是40。然后,我们将40放入x的盒子中,覆盖之前的值。
x = x * y; // x 的新值为 40
综合练习
现在,让我们看另一个例子。在逐步分析之前,请暂停视频,尝试自己推断这段代码片段执行后X、Y和Z的值。
int x = 2;
int y = x * 3;
int z = y / 2;
x = 2 + z % 2;
好的,让我们逐步分析。
首先,我们声明并初始化x为2。
接着,我们计算x * 3,即2 * 3,得到6,并用这个值初始化y。
然后,我们计算y / 2,即6 / 2,得到3,并用这个值初始化z。
最后一条语句是x = 2 + z % 2。由于(2 + z)被括号括起,我们首先计算这部分,得到5。
接下来,我们计算5 % 2。根据阅读材料,5 % 2表示将5除以2,然后取余数而非商数,因此这个表达式的结果是1。
所以,我们将x盒子中的值更新为1。
// 最终结果:
// x = 1
// y = 6
// z = 3
核心概念总结

以下是本课涉及的几个核心运算规则:
- 运算符优先级:乘法和除法在加法和减法之前计算。
- 取模运算符:
a % b返回a除以b后的余数。 - 赋值操作:
=是赋值操作符,先计算右边的表达式,然后将结果存入左边的变量。
课程总结

本节课中我们一起学习了Java数学运算符的实际应用。我们通过代码示例,练习了如何根据运算符优先级和括号来逐步计算表达式,并理解了变量赋值是如何更新存储值的。现在,你应该能够评估涉及各种数学表达式的代码了。
012:函数调用详解 🧠

在本节课中,我们将要学习Java中函数调用的核心概念。函数(在Java中称为方法)是编程中用于抽象计算、避免代码重复的重要工具。我们将通过一个具体的例子,一步步拆解函数调用的执行过程,理解参数传递、返回值以及执行栈帧的工作原理。
概述:什么是函数? 📦
函数将一段计算过程抽象出来,赋予其名称和参数。之后,你可以通过调用函数来执行这段计算,而无需重写代码。从技术角度讲,你可以关注函数“做什么”,而非“如何做”。
严格来说,Java没有“函数”,只有“方法”,因为所有Java代码都位于对象内部。然而,在学习更复杂的对象和方法行为之前,我们先掌握函数调用的基本原理。这些概念将为理解方法调用打下基础。
函数调用执行流程 🧭
我们通过三个函数 myFunction、F 和 G 来演示。假设程序从 G 函数开始执行(例如在BlueJ环境中调用)。后续你将学习 main 方法,那是程序在BlueJ之外运行的起点。


第一步:进入G函数
执行始于 G 函数。首先,系统为 G 创建一个“栈帧”,用于存放其局部变量,并且执行箭头指向 G 函数的开始。
第一行代码声明了变量 a。因此,我们在 G 的栈帧中为 a 创建一个存储框。
接下来,代码将 a 设置为函数调用 myFunction(3, 7) 的返回值。为了计算这个表达式,我们需要为被调用的函数 myFunction 创建一个新的栈帧。


第二步:调用myFunction
这个新栈帧将保存 myFunction 的参数和变量。
以下是参数传递过程:
- 根据函数声明中的参数名
x和y,在栈帧中为它们创建存储框。 - 通过复制传入的表达式值(3和7)来初始化这些参数。
然后,我们需要记录当 myFunction 执行完毕后应返回的位置,这个位置称为“调用点”。我们在代码中用标记 1 标注此位置,并将同样的标记写在栈帧的角落。
最后,我们将执行箭头移入 myFunction 函数内部,开始执行那里的代码。
第三步:在myFunction内部执行
在 myFunction 内部,代码声明并初始化变量 z,计算表达式 2 * x - y。x 和 y 的值分别来自 myFunction 栈帧中的3和7,因此 z 被赋值为 -1。
现在,我们遇到了一个 return 语句。return 语句指示我们离开当前函数,返回到栈帧中记录的调用点,并告知需要返回给调用方的值。
以下是返回过程:
- 首先,计算
return后的表达式z * x以获取返回值。这里计算-1 * 3,得到-3。 - 接着,找到栈帧中记录的调用点。
- 然后将返回值
-3复制回调用点。此时,函数调用myFunction(3, 7)的值就等价于-3。 - 最后,将执行箭头移回调用点,并销毁刚刚返回的
myFunction的栈帧。
第四步:回到G函数并调用F



现在,我们回到了 G 函数中。对 myFunction 的调用求值得出 -3,因此这行代码相当于 a = -3。我们完成这个赋值操作,将 -3 放入 a 的存储框。
下一行代码再次声明变量 b 并进行函数调用 F(a * a)。我们为 b 创建存储框,并重复相同的过程来调用 F 函数。
我们为 F 创建栈帧并传递参数。这次只有一个参数 n,其值为 a * a,即 9。我们记录返回位置(使用标记 2),然后开始执行 F 内部的代码。
第五步:在F函数内部执行及嵌套调用
F 函数中的下一个语句是 return 语句,但其表达式 3 + myFunction(n, n+1) 中包含另一个函数调用。因此,在返回之前,我们必须先计算这个调用。
我们再次启动流程:为 myFunction 创建栈帧并传递参数。x 获得 n 的值(9),y 获得 n + 1 的值(10)。我们记录此次调用的返回点(使用标记 3),然后将执行箭头移到 myFunction 的开始处并执行代码。
在 myFunction 内部,我们声明 z 并将其初始化为 8。现在准备从 myFunction 返回。


我们计算 z * x,即 8 * 9,得到 72。然后找到栈帧中记录的调用点(标记 3),将返回值 72 复制到那里。最后,将执行箭头移回调用点,并销毁 myFunction 的栈帧。



第六步:完成F函数的执行
现在,我们在 F 函数中继续执行,使用 72 作为 myFunction 调用的值。我们计算 3 + 72 得到 75。由于我们正在计算 return 语句,这个 75 就是 F 函数的返回值。
我们找到 F 栈帧中记录的调用点(标记 2),将返回值 75 复制到那里。然后返回到那个调用点,并销毁 F 的栈帧。



第七步:完成G函数的执行
现在,我们可以完成 b 的初始化:b 获得值 75。
最后,我们到达 G 函数的 return 语句,也就是我们开始的地方。当我们从这个起始函数返回时,整个程序执行完毕。
总结 📝

本节课我们一起学习了Java中函数调用的详细执行机制。我们了解到:
- 函数通过栈帧来管理其局部变量和参数。
- 调用函数时,会创建新栈帧,传递参数值,并记录返回点。
- 执行流程通过执行箭头在函数间跳转。
return语句用于结束函数执行,携带返回值回到调用点,并清理当前栈帧。- 函数可以嵌套调用,形成调用链,其执行遵循“后进先出”的栈原则。



理解这些底层原理对于后续学习更复杂的面向对象编程和调试程序至关重要。
013:条件语句执行流程详解 🧠
在本节课中,我们将学习Java中条件语句(if和if-else)的执行流程。我们将通过一个具体的代码示例,一步步跟踪程序的执行,理解变量如何被赋值、条件如何被评估,以及程序的控制流如何根据条件结果进行跳转。
概述
我们将分析两个函数:F和G。函数F包含条件语句,函数G会调用F。我们将模拟Java虚拟机的执行过程,使用“执行箭头”和“变量框”的概念来可视化每一步操作,从而清晰地理解条件逻辑是如何工作的。
执行流程逐步解析
上一节我们概述了学习目标,本节中我们来看看具体的代码执行步骤。我们假设通过BlueJ界面调用了方法G来启动程序。
步骤1:调用G并声明变量a
程序从执行G方法开始。第一条语句声明了一个变量a。
int a = F(3, 4);
尽管这条语句初始化了a,但需要多个步骤来计算其值。因此,我们暂时将a的值标记为0,直到完成对F(3, 4)的调用和计算。
步骤2:评估函数调用 F(3, 4)
为了评估这次对F的调用,我们创建一个新的栈帧(frame),传入参数值(x=3, y=4),并记录返回位置。然后将执行箭头移动到函数F的第一行。
步骤3:执行F中的第一个if语句
F中的下一行代码是一个if语句,其条件表达式为x < y。
if (x < y) {
System.out.println("x less than y");
return y + x;
}
我们评估该表达式,发现3 < 4的结果为true。因此,我们将执行箭头移入该if语句的then子句(即大括号内的代码块)并继续执行。
步骤4:执行then子句
接下来的语句是一个System.out.println调用,这是在Java中打印内容的方式。它会在输出中打印“x less than y”。
然后,我们遇到return y + x;语句。返回值的计算遵循您已学过的规则:我们评估表达式y + x,得到返回值7。这个值就是此次函数调用的结果。
步骤5:返回并销毁栈帧
返回值7被传回调用者G中记录的位置。同时,F的栈帧被销毁。现在,我们准备好完成变量a的初始化,因为我们已经知道方法F的调用结果为7。所以,a的值现在被设置为7。
步骤6:在G中声明变量b并再次调用F
现在,我们准备执行G中的下一行代码。我们为变量b创建一个框。
int b = F(7, 5);
我们再次调用F,传入参数7和5。同样,创建一个新的栈帧,参数为x=7, y=5。
步骤7:再次评估条件 x < y
在F中,我们再次评估条件x < y。此时,x是7,y是5,所以7 < 5的结果为false。
步骤8:进入else子句
我们找到这个if语句的闭合花括号,并看到该if语句有一个else子句。
else {
System.out.println("x is greater than or equal to y");
if (x >= 8) {
return 100;
}
return x - 2;
}
由于条件为false,我们将执行箭头移动到else子句的开头,并从那里继续执行。
步骤9:执行else子句中的语句
首先,遇到System.out.println语句,这会打印一行:“x is greater than or equal to y”。
然后,我们到达另一个if语句。这个if语句嵌套在else内部,但这不影响我们评估它的规则。
if (x >= 8) {
return 100;
}
我们看到条件表达式x >= 8为false,因为7并不大于或等于8。这个if语句没有else子句。
步骤10:跳过嵌套的if语句
因此,我们立即将执行箭头移过其闭合花括号,并继续执行。else子句内部没有更多语句了,所以我们将执行箭头移出else子句并继续。
步骤11:执行F中的返回语句
接下来,我们遇到return x - 2;语句。我们评估表达式x - 2,得到返回值5。
步骤12:再次返回并销毁栈帧
这个值5被返回到我们记录的位置(即G中对b的赋值处)。我们销毁F的栈帧并返回到G。至此,我们完成了对b的赋值语句,b的值现在是5。
步骤13:从G返回
现在,我们准备好执行G中的return语句。
return a + b;
我们执行该语句,计算a + b(即7 + 5),得到返回值12。方法G执行完毕。
总结
本节课中,我们一起学习了Java条件语句的详细执行流程。我们通过跟踪一个具体示例,理解了:

- 方法调用与栈帧:每次调用方法都会创建新的栈帧,用于存储参数和局部变量,调用结束后栈帧被销毁。
- 条件评估:
if语句的条件表达式会被评估为true或false。 - 控制流跳转:根据条件结果,程序执行流会跳转到相应的
then子句或else子句(如果存在)。 - 嵌套语句:条件语句可以嵌套,但执行规则不变,总是从最内层开始评估和跳转。
- 返回值:
return语句会结束当前方法的执行,并将表达式的值传回调用处。

通过这种逐步跟踪的方式,您可以清晰地可视化任何包含条件逻辑的Java代码的执行路径。
Java编程与软件工程基础:2-5:类与对象入门

在本节课中,我们将要学习Java编程中两个核心概念:类与对象。它们是面向对象编程的基石,能帮助我们将数据和操作数据的代码组织成逻辑单元,从而更清晰地构建程序。
随着你继续学习Java的工作原理,有必要花些时间讨论一下对象和类。
在深入具体的语义之前,我们先来谈谈高层次的概念。
当你编写程序时,通常会以变量的形式存储数据,这些变量保存着你正在计算的值,同时还有代码。
这些代码根据你设计的算法来操作这些数据。面向对象编程是一种编程语言的范式,它将数据和操作数据的代码组合在一起,形成称为对象的逻辑单元。
这种语言设计旨在通过将代码和数据组合成一个逻辑单元,来帮助程序员思考他们的程序。
当你编写越来越大的程序时,这个原则会变得越来越有帮助。
随着你的进步和对Java的深入了解,你将学习到许多与这些思想相关的重要原则。
然而,现在,我们只是从基础开始。
你可以看到一个类声明的例子。类是一个模板,它规定了如何创建对象。
让我们看看这个声明的每一部分。第一行告诉Java我们正在声明一个名为 Point 的类。
与变量类似,我们在命名自己创建的类时有很大的自由度,但我们应该用描述性的方式命名它们。
这里,我们正在创建一个表示二维点的类。所以 Point 是一个好名字。
接下来,我们声明两个字段:int x 和 int y。字段是对象内部变量的名称。
它们也被称为实例变量,因为它们是每个由该类创建的对象实例中的变量。
这些看起来像变量声明,只是前面有 private 关键字。
private 意味着只有这个类内部的代码才能直接操作这些字段。
随着你Java技能的提升,你会了解更多为什么这很重要。但现在,我们只需将所有字段设为 private。
接下来是类构造函数的声明。
构造函数规定了如何创建该类的对象。
它是在创建对象时运行的代码,用于初始化该对象。
请注意,构造函数看起来像一个函数,但没有返回类型,并且名称与类相同。
这些是构造函数声明的标志。
在构造函数前面,我们有 public 这个词,这意味着任何代码都可以使用这个构造函数来创建一个 Point 对象。
在构造函数之后,我们定义了三个方法:getX、getY 和 distance。方法是类内部的函数。在Java中,所有东西都在一个类内部。因此,从技术上讲,Java中的所有函数都是方法。
这些方法在特定对象上被调用,并隐式地作用于该对象。
你可以在这里看到一个方法调用,代码是 otherPoint.getX()。
这会在 otherPoint 对象上调用 getX 方法。
它将获取那个特定 Point 对象的 x 值。
最后,我们有一个静态方法的声明。
它们的行为与常规方法略有不同。
它们不作用于类的任何特定实例。它们通常只属于类本身。
这个概念有点棘手,我们稍后会详细解释。
这个方法叫做 main,它是一个特殊的方法。如果你在BlueJ之外运行你的程序,main 是起点。在任何对象被创建之前,执行就从 main 开始。
上一节我们介绍了类和对象的基本构成,本节中我们来看看如何实际使用它们。
对象的概念旨在帮助程序员从逻辑上有意义的角度,以对象的形式思考他们的数据。
例如,如果我们创建一个新的 Point,我们就是在创建一个对象,它代表我们可以具体思考的东西。
在这个例子中,就是平面上的一个点。
然后我们可以创建另一个点,它有自己的 x 和 y,代表同一类型事物的不同实例。
即平面上的另一个点。当然,你可以根据算法的需要创建任意数量的对象。
一旦你拥有了一些对象,你就可以在它们身上调用方法,例如 p1.distance(p3)。
你可以将其理解为要求 p1 计算到 p3 的距离。
也就是说,你可以把这行代码理解为:p1,去计算你离 p3 有多远。
你可以认为,为这个被调用的方法执行的代码,在逻辑上属于 p1 对象。

本节课中我们一起学习了Java中类和对象的基础知识。我们了解到,类是创建对象的蓝图,它封装了数据(字段)和行为(方法)。对象是类的实例,代表程序中的具体实体。通过将相关的数据和操作捆绑在一起,面向对象编程帮助我们构建更清晰、更模块化的代码结构,这对于开发大型复杂程序至关重要。
015:new关键字详解 🧱

在本节课中,我们将深入学习Java中new关键字的具体执行步骤。我们将通过一个Point类的实例化过程,详细拆解从声明变量到对象创建和初始化的每一步,帮助你理解Java如何在内存中创建和管理对象。
上一节我们介绍了对象创建的高级概念,本节中我们来看看示例Point代码的逐步执行过程。
我们从main方法开始。args参数提供了对命令行参数的访问,但暂时不需要,因此忽略它。
第一行代码声明了一个Point类型的变量p1,并将其初始化为一个新的Point对象。请注意,类本身就是一种类型,因此我们可以使用自定义的类类型来声明变量。我们希望p1成为一个Point。
在完成对右侧表达式求值之前,我们用一个平头箭头来表示这个变量。这种表示法用于指示变量尚未引用任何对象。这与int等数值变量不同,后者的初始值是0。我们将这个平头箭头标为红色,以提醒自己该变量尚未显式初始化。
现在,让我们对初始化表达式的右侧进行求值。我们使用了new关键字,它将创建一个新对象。我们要创建什么类型的对象呢?查看new之后的内容,可知我们要创建一个新的Point。因此,我们将绘制一个代表Point的方框,它包含x和y字段。
请注意,我们刚刚绘制的方框并不在main方法的栈帧内,而是在一个称为堆的不同区域。任何时候使用new,都会在堆中创建数据。一个重要的区别是,堆中的数据在函数返回并销毁其栈帧时不会消失。
我们在这个新Point的字段中放入了0,并将它们标为红色,因为它们尚未被显式初始化。
说到初始化,请记住,构造函数的全部意义就在于初始化一个新创建的对象。接下来发生的事情就是调用构造函数来初始化这个Point。
与任何函数调用一样,我们会设置一个栈帧并传递参数。然而,构造函数和方法还接收一个额外的隐式参数,该参数告诉它们正在操作哪个对象。
这个参数称为this,它的值是一个箭头,指向正在被操作的对象。在本例中,this指向我们正在创建的对象,以告知构造函数要初始化哪个对象。现在,我们进入构造函数的代码并开始执行其中的语句。
第一行代码是x = startX;。当前栈帧中没有x变量。那么,我们把startX的值存储在哪里呢?这里的x指的是this对象内部的字段。也就是说,我们要将startX的值放入正在初始化的对象的x字段中。为了找到正确的方框,我们沿着this的箭头找到对象,然后在该对象中寻找x字段,并将值3存入该方框。
下一行代码y = startY;中的y同样指的是该对象内部的字段,因此我们将该字段更新为4。
至此,我们完成了构造函数内部的代码,准备返回到main方法中的调用点。
回到main方法,我们需要完成这个赋值语句。为此,我们需要将右侧表达式的值存储到p1变量中。new表达式的求值结果是一个指向它所创建对象的箭头。因此,我们将让p1指向这个新创建的Point对象。
下一行代码做了类似的事情。它声明了p2并将其初始化为一个新的Point对象。
以下是创建p2的步骤:
- 再次为
p2变量创建一个方框。由于尚未显式初始化,它有一个默认值——平头箭头,意味着它尚未指向任何对象。我们将其标为红色以记住这是默认值。 - 然后,我们创建一个新的
Point对象,其x和y字段被设置为默认值0。 - 接着调用构造函数来初始化这个
Point。注意this如何指向新创建的Point。在我们的程序世界中存在多个Point对象时,能够跟踪正在操作哪个对象至关重要。 - 我们将这个
Point内部的x字段初始化为6。和之前一样,我们通过跟随this的箭头找到了正确的方框。 - 我们将这个
Point内部的y字段初始化为8。
现在,我们完成了构造函数,再次准备返回到main方法。在main方法中,我们完成了赋值语句,将p2设置为指向新创建的对象。
本节课中我们一起学习了new关键字在Java中的具体执行流程。我们详细追踪了从变量声明、对象在堆中创建、构造函数调用(包括隐式参数this的传递)到最终变量指向对象的完整过程。理解这些步骤对于掌握Java面向对象编程的内存模型至关重要。在下一个视频中,我们将继续执行方法调用部分。
016:方法调用详解 🧠

在本节课中,我们将学习Java中方法调用的执行过程。我们将通过一个具体的例子,详细拆解从方法调用到返回的每一步,理解this参数、参数传递以及静态方法调用的概念。
在上一节中,我们执行了Point对象P1和P2的声明与初始化,并学习了new关键字和构造函数。本节中,我们来看看方法调用的具体执行机制。
方法调用与函数调用非常相似,但有一个关键区别:我们必须传递一个隐式的this参数,以告知方法它正在操作哪个对象。
让我们从上次中断的地方继续。代码P1.distance(P2)将调用P1的distance方法,并打印其返回值。
我们需要为distance方法设置一个栈帧,它将接收两个参数:
- 隐式的
this参数,用于指明方法作用于哪个对象。 - 显式传递的
otherPoint参数。
this参数的值与点号(.)前的变量值相同。在本例中,是P1.distance,因此this的值与P1相同,是一个指向同一个Point对象的引用箭头。图中我们为这个箭头使用了不同颜色,这只是为了帮助你在图表中区分不同的箭头,并无特殊含义。
对于otherPoint参数,我们直接复制传入的值,即P2。因此,otherPoint的值是一个指向与P2相同的Point对象的引用箭头。
现在,我们进入distance方法内部并开始执行其中的代码。
执行第一行代码时,我们声明变量dx并将其初始化为x - otherPoint.getX()。为了计算这个表达式,我们需要调用otherPoint.getX()。因此,我们需要为这个方法调用设置一个新的栈帧。
同样,这个方法接收一个隐式的this参数,以告知它对哪个对象执行getX操作。那么,this应该指向哪个对象呢?我们调用的是otherPoint.getX(),因此我们复制otherPoint的值,即指向那个Point对象的引用箭头。
现在我们进入getX方法内部并开始执行代码。这里我们遇到return x;,因此需要计算表达式x的值并返回。
如何获取x的值?我们跟随this指针找到正在操作的对象,然后在该对象内部查找x字段。该字段的值是6,这就是此处表达式x的值,它将被返回到调用点2。
返回到调用点2后,我们需要计算x - 6。这里的x如何计算?我们再次查看this指针,它指向当前的Point对象。我们获取该Point对象内部的x字段,其值为3。因此,我们将计算3 - 6,并将dx初始化为-3。
接下来,我们将通过一个非常相似的过程来声明和初始化dy。首先,为dy创建一个存储框。
然后,我们在otherPoint上调用getY。请注意,这里的this是otherPoint值的一个副本,即指向第二个Point对象的引用箭头。我们从该对象中获取y字段的值,即8,并将其返回到调用点2,执行箭头也返回到此处。
我们通过查看当前对象(由this指向)并找到其y字段来计算y的值,该值为4。现在,我们可以完成dy的初始化,计算4 - 8,得到-4。
下一行代码包含一些数学运算。让我们详细看一下。
我们调用Math.sqrt(dx * dx + dy * dy)。首先,我们可以计算参数的值:(-3)^2 + (-4)^2 = 9 + 16 = 25。因此,我们需要计算Math.sqrt(25)。
这看起来像一个方法调用,但Math对象从何而来?实际上,Math是一个类,而不是一个对象,它是Java库的一部分。这是一个静态方法调用。该方法是在类本身上调用的,而不是在任何特定的对象上。Math类只是一个方便存放一系列数学函数的地方。
由于我们没有Math.sqrt的源代码,我们必须知道它的作用。如果我们不知道,可以查阅Java文档。不过,你可能已经猜到,这个方法就是计算其参数的平方根。因此,Math.sqrt(25)将返回5.0。
我们的distance函数将这个值返回给它的调用者。因此,在main方法中调用distance的返回值将是5.0。
然后,我们返回到执行域,准备完成print语句,它将打印出5.0。现在,main方法执行完毕,我们将从中返回,销毁其栈帧并退出程序。
本节课中,我们一起学习了Java方法调用的完整执行流程。我们详细探讨了:
- 方法调用时隐式
this参数的传递和作用。 - 参数值的复制与传递过程。
- 如何通过
this指针在对象内部访问字段。 - 静态方法(如
Math.sqrt)的调用方式及其与实例方法调用的区别。


通过逐步跟踪代码执行,我们清晰地看到了从方法调用、参数计算、内部执行到最终返回的每一个步骤,这对于理解面向对象编程中方法的运作原理至关重要。
017:数据类型详解 🧱

在本节课中,我们将要学习Java中一个核心概念:数据类型。我们将探讨类型的定义、作用、不同类型之间的转换,以及Java中两种主要的类型分类。
什么是数据类型?


到目前为止,你已经见过多种类型,例如 int、Point 和 FileResource。
但类型究竟是什么?类型规定了数据应如何被表示、解释和操作,以及你可以用它执行哪些操作。


计算领域的一个重要规则是:一切皆是数字。如果你在之前的入门课程中没有了解过“一切皆是数字”原则,我们会在后续提供相关视频链接。
具体来说,这意味着计算机内存中的所有内容都以比特(bits),即1和0的形式存储。
但并非所有数字都代表相同含义。有些数字可能代表普通数值,有些可能代表字母,还有些可能代表数据在计算机内存中的位置。

类型如何解释数据?
因此,值的类型规定了如何解释这些数字。它告诉Java如何为存储在内存中的1和0赋予意义。
类型还规定了你可以对数据执行哪些操作。类型不仅告诉Java你能做什么,还告诉它应该如何做。
让我们更详细地讨论这两点。
我们刚刚提到,类型告诉Java如何解释内存中的1和0。让我们进一步探讨。在左侧,是我们一直使用的程序状态的概念性表示。
有一个名为 x 的变量框。在右侧,是计算机内存中的一串比特。蓝色的比特对应变量 x。

但它们对于 x 的值意味着什么?如果 x 是 int 类型,那么这些比特意味着它的值是 1234567890。我们在此不深入探讨如何得出这个结论的细节,对于Java编程初学者也无需了解。但如果你学习计算机组成原理课程,你会学到更多关于数据表示的知识。
我们想强调的是,如果 x 是其他类型,比如 float,那么相同的比特将具有不同的含义。这些相同的1和0将代表 1228890.25。


如果 x 是 String 类型,那么这些比特将是实际字符串对象在计算机内存中的位置,该对象包含一系列字符。这些字符本身也以比特形式存储,由于它们的类型是 char,所以会被解释为字母。


类型如何规定操作?

我们还提到,类型告诉我们能做什么操作以及如何执行。考虑这段简单的代码:x + y。它合法吗?如果合法,它执行什么操作?要回答这个问题,你需要知道 x 和 y 的类型。
以下是不同情况:
- 如果
x和y都是int类型,那么这段代码合法并执行整数算术加法。 - 如果
x和y都是String类型,那么这段代码也合法,但执行的是字符串连接。它会生成一个新字符串,内容是x的字母紧接y的字母。 - 请注意,即使
+操作对两种不同类型都合法,我们对一种类型执行该操作的方式可能与另一种类型不同。 - 如果
x和y都是Point类型,那么这段代码不合法。


在讨论类型时,你可能想知道如何在类型之间进行转换。答案是:视情况而定。
类型转换
以下是三种主要的类型转换方式:

1. 隐式转换
对于某些类型转换,它们可以隐式发生。如果你有一个 int 类型的值,需要将其转换为双精度浮点数 double,编译器会自动为你插入转换,无需额外声明。
规则是:只要编译器认为转换是安全的,就可以使用隐式转换。 例如,这里我们将整数 3 转换为浮点数 3.0,这没有问题。请注意,编译器在决定隐式转换是否可行时,只考虑类型,而不考虑具体的值。
2. 显式转换(强制类型转换)
对于某些类型转换,你可以进行显式转换。这意味着你告诉编译器,即使你正在做的事情可能有风险,但你确定要这样做。例如,这里我们将 double 类型的 3.14 转换为 int 类型,这将丢弃小数部分,得到 x = 3。编译器需要确认我们确实想这样做,因此我们通过书写 (int) 来进行显式转换。
3. 调用方法转换
其他转换需要调用方法来计算出转换后的值。例如,如果我们有字符串 "3" 并想将其转换为整数,我们不能直接强制转换,因为这种转换实际上有些复杂。相反,我们必须调用像 Integer.parseInt() 这样的方法来执行转换。
Java中的类型分类


关于类型,我们要提到的最后一点是:Java中的类型主要分为两大类:基本类型和对象类型。
基本类型
Java有八种基本类型:int、double、char、boolean、long、float、byte、short。我们主要使用前四种。基本类型的变量直接在它们的“盒子”中保存值。基本类型没有方法,因此你不能在基本类型上使用 .method() 调用,并且它们不能为 null。不过,每种基本类型都有一个对应的包装类,它为你提供了一个可以容纳该基本类型的对象。
对象类型
其他所有类型都是对象类型。有些是Java内置的,如 String;有些是你可能使用的库的一部分,如 Point 或 FileResource;还有一些是你自己创建的类。每当你创建一个类,你创建的类就是一种新的类型。与基本类型不同,对象类型变量的值是一个指向对象的箭头。这个箭头称为引用。你可以使用 .methodName() 在对象上调用方法,并且引用可以为 null,意味着它不指向任何对象。如果你在两个对象上使用 ==,你是在检查这两个箭头是否指向完全相同的对象。
总结

本节课中,我们一起学习了Java数据类型的基础知识。我们了解到类型定义了数据的解释和操作方式,探讨了隐式转换、显式转换和方法调用转换三种类型转换机制,并区分了基本类型和对象类型这两大类别。虽然这些概念一开始可能很多,但随着你进行更多的Java实践,你会更好地掌握这些思想。
018:for-each循环 🚀

在本节课中,我们将学习for-each循环的工作原理。这是一种用于遍历序列(如文件中的行)的便捷循环结构。我们将通过一个具体的代码示例,逐步解析其执行过程,并理解相关的核心概念,如对象创建、方法调用和抽象原则。
概述
我们将分析一段使用for-each循环读取文件并打印其内容的Java代码。此过程涉及创建对象、调用方法以及理解迭代器(Iterable)的概念。我们将手动模拟代码执行,以清晰地展示每一步发生了什么。


代码结构与初始设置
上一节我们介绍了循环的基本概念,本节中我们来看看for-each循环的具体应用。首先,我们需要在代码顶部添加一个import语句,以告知JavaFileResource类的位置。
import edu.duke.FileResource;
FileResource类位于我们提供的edu.duke包中。在学习更高级的Java技术之前,这个类库可以帮助我们操作数据和文件。
要运行此代码,可以创建一个HelloWorld对象并调用其runHello方法。或者,也可以添加一个main方法,以便在BlueJ或其他环境中直接运行。
public class HelloWorld {
public void runHello() {
// ... 后续代码将放在这里
}
public static void main(String[] args) {
HelloWorld hw = new HelloWorld();
hw.runHello();
}
}
逐步执行代码
现在,让我们开始手动执行代码。我们从main方法开始。
第一步:创建对象
main方法中的第一行声明并初始化了一个HelloWorld对象。
HelloWorld hw = new HelloWorld();


由于HelloWorld类没有显式定义构造函数,Java会提供一个默认的无参数构造函数。因此,这行代码会创建一个HelloWorld对象。该对象没有字段,因此不包含任何可见的状态信息。
第二步:调用runHello方法
接下来,我们调用hw.runHello()方法。在runHello方法内部,首先声明了一个FileResource类型的变量fr。
FileResource fr = new FileResource("file.txt");
这行代码引发了两个问题:
FileResource对象内部是什么样子的?- 它的构造函数做了什么?
对于第一个问题,我们无需确切知道其内部细节。我们只需要知道它能做什么。这体现了抽象这一重要的编程原则。
对于第二个问题,我们需要查阅FileResource类的文档。文档显示,传入文件名字符串的构造函数会在计算机上查找该文件。
因此,执行这行代码后,变量fr将引用一个知道如何操作file.txt文件的FileResource对象。
理解for-each循环

下一行代码是一个for-each循环。

for (String line : fr.lines()) {
System.out.println(line);
}
以下是该循环的组成部分:
String line:声明一个String类型的循环变量line。::for-each循环的语法,常读作“in”。fr.lines():这是一个表达式,其计算结果是一个可迭代对象。可迭代对象能提供一个值的序列。

为了理解fr.lines()的作用,我们再次查阅文档。文档指出,fr.lines()返回一个可迭代对象,其值按顺序对应文件中的每一行。
假设我们的file.txt文件包含两行内容:
Hello
World
那么,fr.lines()将生成一个包含字符串"Hello"和"World"的序列。
模拟循环执行过程
现在,我们来模拟for-each循环的执行。
- 第一次迭代:循环变量
line被设置为序列中的第一个值,即"Hello"。然后进入循环体,执行System.out.println(line),打印出"Hello"。执行到循环体末尾后,流程返回到循环开始处。 - 第二次迭代:
line被更新为序列中的下一个值,即"World"。再次进入循环体,打印出"World"。执行再次到达循环体末尾。 - 循环结束:当流程再次返回到循环开始处,试图为
line获取下一个值时,发现序列中已没有更多元素。此时,循环终止。程序跳出循环,继续执行后面的代码。同时,循环变量line因其作用域结束而被销毁。
循环结束后,runHello方法执行完毕并返回。由于这是在main方法中的最后一步,程序也随之结束。
总结
本节课中我们一起学习了for-each循环。我们通过一个读取文件的例子,详细分析了其语法for (Type var : iterable)和执行流程。关键点包括:
- for-each循环用于便捷地遍历可迭代对象中的每个元素。
- 抽象原则允许我们使用对象的功能而无需了解其内部实现细节。
- 循环变量在每次迭代中自动更新,并在循环结束后离开其作用域。

掌握for-each循环将极大地简化遍历数组或集合等数据结构的代码编写。
019:七步法 🧩

在本节课中,我们将要学习如何使用一个七步法来解决编程问题。这个方法将贯穿本课程的后续部分,旨在将复杂问题分解为可管理的步骤,帮助你从问题描述逐步推导出可运行的代码。


从问题描述直接跳跃到编写代码是一个巨大的跨越,需要大量的思考和工作。因此,我们将其分解为七个步骤,为你提供一个清晰的路径,让你知道接下来该做什么。

随着编程技能的提高,许多问题会变得简单到可以在脑中解决。然而,总会遇到一些更困难的问题,这时一个循序渐进的系统性方法将对你大有裨益。


让我们更详细地了解每一个步骤。

第一步:手动解决一个具体实例

第一步是亲自手动解决该问题的一个小实例。这个实例应该规模较小,例如只涉及四到五个数据点进行处理。你不应该尝试手动处理海量数据,那将耗费大量时间。
如果你在手动解决问题时遇到困难,那么你也无法编写程序来解决它。
如果你在此步骤卡住,可能有两个原因。一是问题描述本身不清晰,没有提供足够的信息。在课堂环境中,你可以咨询老师或助教;在专业环境中,你可能需要与技术负责人或客户澄清需求,或者自行完善问题描述。
另一个潜在问题是你缺乏领域知识,即问题所属领域的专业知识。例如,如果你要编写一个计算物理运动的程序,却不知道所需的物理公式,这就是领域知识的缺乏。遇到这种情况,你需要先查找相关的领域知识,然后再继续。


第二步:记录解决步骤

在第二步,你需要尽可能精确地写下你刚才解决这个具体实例的步骤。不要遗漏任何细节,并以一步一步的方式记录下来。
在这个阶段,你只是记录针对你解决的那个特定实例的步骤,还不是更通用的解决方案。

这一步的难点在于,我们常常不假思索地完成某些操作。如果你含糊带过或记录不精确,将会使后续步骤变得更加困难。

第三步:从具体实例归纳出通用算法

在第三步,你需要从第一步解决的具体实例出发,推导出一个能解决该问题任何实例的算法。也就是说,你需要设计一个算法,对于你给出的任何输入,它都能正确解决问题。
实现这一点的方法是,在你所做的操作中寻找模式,并基于这些模式,用更通用的行为替换特定的行为。寻找模式的重要工具包括:识别重复性行为、识别有时执行但并非总是执行的行为(并确定在什么条件下执行),以及弄清楚你所使用的具体数值与你选择的参数之间的关系。


如果你在这一步遇到困难,应该返回并重新尝试第一步和第二步。使用不同的输入,这将为你提供另一个可供分析的实例。观察问题不同实例的解决步骤,能为你提供更多信息来帮助你发现模式。
第四步:在编码前检查算法
在第四步,在将算法转化为代码之前,你需要检查你的算法。
如果你在第三步错误地识别了模式,或者在其他地方犯了错误,你希望现在就发现它。检查算法的方法是,至少选择一个不同的输入(同样,规模要小),然后按照你的算法步骤执行。如果你的算法给出了正确答案,那么你就可以继续前进。如果没有,你应该先返回修正它。


第五步:将算法转化为代码
现在你已经设计出解决问题的算法,可以准备将算法转化为代码了。
这一步涉及到特定编程语言的语法。你需要用该语言的语法写下你的步骤。
第六步:运行测试用例

编写完代码后,你需要确保它能正确工作,因此要对其运行测试用例。

运行测试用例包括在特定输入上执行代码,并检查它是否产生了正确答案。


你的代码通过的测试用例越多,你就越有信心认为它是正确的。然而,无论进行多少测试,都无法百分之百保证代码完全正确。
第七步:调试
当你的程序未能通过某个测试用例时,你就知道出了问题。这时,就到了调试程序的时候。如果需要更多信息,你可以观看关于调试的复习视频。概括来说,你将应用科学方法来理解程序的问题所在,并确定如何修复它。
使用科学方法定位问题后,你需要回到之前的某个步骤进行修正。如果问题出在你设计的算法上,你需要回到第三步重新思考你的算法。如果你的算法是正确的,但在代码中实现有误,那么你需要回到第五步去修正你的代码。


本节课中,我们一起学习了解决编程问题的七步法。在下一个视频中,我们将通过一个实例来演示这个过程。这个方法不仅能指导你完成本课程后续需要解决的编程问题,也能在你今后需要解决任何困难问题时提供帮助。
020:算法开发 🧮

在本节课中,我们将通过一个具体实例,完整地演练如何使用“七步法”来解决编程问题。我们将解决的问题是:给定一个形状,计算其周长。本节将重点讲解前三个步骤:手动求解、记录步骤和归纳模式。



概述
我们将遵循“七步法”来开发一个计算任意形状周长的算法。本节课将完成前三步:首先,我们手动计算一个具体形状的周长;其次,详细记录下计算步骤;最后,从具体步骤中寻找模式,将其归纳为适用于任何形状的通用算法。
第一步:手动求解问题实例

第一步要求我们亲自解决一个问题的具体实例。这意味着我们需要画出一个形状并计算其周长。

我们需要一些领域知识:周长是一个形状所有边长的总和。同时,我们需要知道,一个形状由其点定义,这些点按照它们在周长上出现的顺序排列。

- 首先,我们绘制一个坐标网格,以便精确地绘制形状。
- 然后,在网格上绘制一个形状。在绘制时,我们记录下形状中每个点的坐标。这将使我们能够轻松地进行数学计算,以求出形状各条边的长度。

一旦有了形状,我们就可以开始计算其周长。


- 左边的边长是 4。
- 底边的边长是 5。将它们相加,得到当前周长的累计值 9。


- 下一条边是对角线,需要进行一些数学计算。x坐标的差是3,y坐标的差是4。根据勾股定理,其长度为
√(3² + 4²) = √(9 + 16) = √25 = 5。 - 将9加上5,当前累计周长变为 14。
- 最后一条边的长度是 2。将14加上2,得到最终结果 16。
因此,对于这个具体的形状实例,其周长是16。





第二步:详细记录所做步骤
在手动求解之后,第二步是具体写下我们刚刚所做的每一步。这有助于将思路转化为清晰的逻辑。
以下是针对我们刚才所画形状的计算步骤:
- 计算从第一个点到第二个点的距离,结果是4。
- 计算从第二个点到第三个点的距离,结果是5。
- 将4和5相加,得到当前累计值9。
- 计算从第三个点到第四个点的距离,结果是5。
- 将9和5相加,当前累计值变为14。
- 计算从第四个点回到第一个点的距离,结果是2。
- 将14和2相加,得到最终结果16。

第三步:寻找模式并归纳通用算法
第三步的目标是从具体步骤中发现模式,并将其推广,以找到计算任何形状周长的通用算法,而不仅仅是我们刚刚看到的那个。
观察第二步的步骤列表,你可能会注意到我们几乎在重复做同一件事。在归纳时,我们希望找到相似的步骤,并将其表达为重复。
使步骤模式化
为了使步骤完全匹配,我们可以从“0 + 4 = 4”开始。这样做的逻辑是,我们总是将当前计算出的边长累加到总周长上,因此从0开始累计是合理的。
接下来,在归纳算法时,我们需要为计算出的量命名,因为它们不会总是固定的值。
- 我们将当前计算出的两点间距离命名为
curDist。 - 将累计的总周长命名为
totalPeri。
在命名后,我们需要在所有使用这些值的地方替换为对应的名称。这让我们得到一个初步的算法描述。
优化重复结构
然而,上述步骤看起来仍然不完全一致。关键在于,我们需要让每一步都遵循相同的模式。
一个聪明的办法是重新排列计算顺序。由于加法满足交换律,我们可以先计算从最后一个点到第一个点的距离。这样调整后,每一步就变成了:对于形状中的每一个点,计算它到上一个点的距离,然后累加。
为了实现这一点,我们需要一个变量 prevPoint 来记住上一个点。在每一步循环开始时,prevPoint 存储着上一个点的坐标,我们用它和当前点计算距离。计算完成后,再将当前点赋值给 prevPoint,为下一次循环做准备。
对于第一次循环,我们需要将 prevPoint 初始化为形状的最后一个点。同时,totalPeri 初始化为0。
经过这些调整,我们得到了一套清晰、重复的步骤模式。这个模式可以概括为:
- 初始化
totalPeri = 0。 - 将
prevPoint设为形状的最后一个点。 - 对于形状中的每一个点
currentPoint:- 计算
prevPoint到currentPoint的距离curDist。 - 更新
totalPeri = totalPeri + curDist。 - 将
prevPoint更新为currentPoint。
- 计算
- 循环结束后,
totalPeri即为形状的周长。

这个算法现在非常通用,因为它依次遍历形状的所有点,并且每一步的操作完全相同。在后续课程中,我们将把这个算法翻译成具体的Java代码。
总结


本节课中,我们一起学习了“七步法”的前三步在实际问题中的应用。我们从一个具体的形状周长计算出发,通过手动求解、详细记录步骤,最终归纳出了一个可以计算任意多边形周长的通用算法。这个算法的核心在于遍历有序的点序列,并累加相邻点之间的距离。在接下来的课程中,我们将学习如何将这个算法转化为高效的Java程序。
021:算法测试

在本节课中,我们将学习如何测试一个已设计好的算法,以确保其逻辑正确,然后再将其转化为代码。我们将通过一个计算任意形状周长的算法实例,手动执行测试步骤来验证其准确性。

上一节我们介绍了如何设计一个通用算法。本节中我们来看看如何通过手动模拟来测试这个算法,确保它能正确工作。
我们已经开发出一个用于计算任意形状周长的算法。但是,在将这个算法转化为代码之前,我们是否已经准备好了?我们可以直接转化,但我们更希望在这样做之前,能确信算法是正确的。毕竟,为了将步骤通用化,我们做了很多工作,完全有可能犯错,或者没有考虑到所有特殊情况。因此,在转化为代码之前,我们应该先测试它。
为了测试算法,我们需要一个不同于设计算法时所用的问题实例。事实上,如果测试实例与我们设计算法时使用的实例有很大不同,那会更好。这里,我们展示了一个三角形,而不是我们开发算法时使用的四边形。在继续之前,请花点时间计算出正确答案:这个形状的周长是多少?测试完成后,你需要检查模拟算法的结果是否正确,为此你需要知道正确答案。
现在,我们将为这个特定的输入手动执行算法。在测试算法时,请注意代码和英语描述之间的相似性。我们将手动执行这个用英语描述的算法,就像我们手动执行代码一样。两者的工作方式基本相同,这并非巧合。当你将这个算法转化为代码时,你希望写出的代码与英语描述具有相同的语义和含义。代码应该以与英语描述转换图表相同的方式来转换程序状态。
以下是手动执行算法的步骤:
- 我们从
totalPerm开始,将其设置为0。 - 将
prevPoint设置为形状中的最后一个点。但最后一个点是什么?我们规定形状中的点从顶部开始,逆时针方向排列。因此,右下角的点是最后一个点,我们将prevPoint初始化为该点。 - 简要说明:如果这是实际的 Java 对象,
prevPoint可能是一个指向对象的引用。但为了保持图表简洁易读,我们在这里只写下坐标。 - 接下来,我们对每个点执行步骤。因此,我们需要从第一个点开始,即图表顶部的点。这将是我们进入每次循环重复时
curPoint的初始值。 - 然后,我们计算这两个点之间的距离,结果是
10。 - 更新
totalPerm为0 + 10,即10。 - 更新
prevPoint为当前的curPoint。 - 现在,我们到达了每次循环重复的末尾,因此我们将
curPoint更新为形状中的下一个点,即(-3, -4)。 - 更新
curPoint后,我们回到循环顶部重复这些步骤。 - 我们再次重复步骤,计算那两个点之间的距离,结果是
8。 - 然后更新
totalPerm为10 + 8,即18。 - 接着更新
prevPoint为当前的curPoint。 - 然后我们再次回到循环顶部,更新
curPoint。 - 我们对形状中的最后一个点重复这些步骤。
- 处理完所有点后,我们跳过循环,执行循环后的步骤。此时,我们可以说
totalPerm就是我们的答案。
totalPerm 是 24。这是你之前得出的答案吗?是的,这就是这个形状的周长。我们的算法在这里得出了正确答案,这让我们更有信心相信我们正确地进行了通用化。
我们完成了手动执行算法,并且对它的正确性有了很大的信心。因此,我们准备好将算法转化为代码了。
本节课中我们一起学习了如何通过手动模拟来测试算法。我们使用了一个新的问题实例,逐步执行了算法的每个步骤,验证了其输出与预期结果一致。这个过程增强了我们对算法正确性的信心,为将其转化为可靠的代码奠定了坚实基础。
022:代码翻译 📝➡️💻

在本节课中,我们将学习如何将一个已设计好的算法,逐步翻译成可运行的Java代码。我们将使用一个计算图形周长的具体例子,演示从算法描述到代码实现的完整过程。
算法回顾与代码框架
上一节我们介绍了计算图形周长的算法。现在,我们来看看如何在一个名为 PerimeterRunner 的Java类中实现它。
这个类包含一个 getPerimeter 方法,它接收一个 Shape 对象并返回一个 double 类型的周长值。类中还有其他代码,例如一个 main 方法用于测试。
我们将把算法作为注释写在代码中,然后逐句将其翻译为Java语句。
逐步翻译算法
以下是算法翻译的具体步骤,我们将为每一步编写对应的Java代码。
第一步:初始化总周长变量
算法描述是“从总周长等于0开始”。这表示我们需要一个变量,并将其初始化为0。
- 我们将其命名为
totalPerim。 - 考虑到计算可能涉及小数,我们选择
double类型而非int。 - 对应的代码是:
double totalPerim = 0;
第二步:初始化“前一个点”变量
算法描述是“设前一个点等于最后一个点”。这意味着我们需要另一个变量来追踪上一个点。
- 我们将其命名为
prevPoint。 - 它的类型是
Point。 - 通过查阅
Shape类的文档,我们知道可以使用getLastPoint()方法来获取最后一个点。 - 对应的代码是:
Point prevPoint = shape.getLastPoint();
第三步:遍历图形中的所有点
算法描述是“对于形状中的每个点(当前点)”。这提示我们使用一个 for-each 循环来遍历所有点。
- 循环将遍历
shape.getPoints()返回的所有点。 - 我们将循环体用花括号
{}括起来。 - 对应的代码结构是:
for (Point currPoint : shape.getPoints()) { // 循环体内的步骤将放在这里 }
第四步:计算当前距离
在循环体内,算法描述是“计算从前一个点到当前点的距离,并将其命名为当前距离”。
- 任何需要命名的量都需要一个变量。
- 我们将其命名为
currDist。 - 通过查阅
Point类的文档,我们知道可以使用distance()方法来计算两点间的距离。 - 对应的代码是:
double currDist = prevPoint.distance(currPoint);
第五步:更新总周长
算法描述是“更新总周长为总周长加上当前距离”。
- 这是一个累加操作。
- 对应的代码是:
totalPerim = totalPerim + currDist;
第六步:更新“前一个点”
算法描述是“更新前一个点为当前点”。
- 为下一次循环迭代做准备。
- 对应的代码是:
prevPoint = currPoint;

第七步:返回结果
算法描述是“总周长就是我的答案”。当我们得到最终答案时,需要将其返回给调用者。
- 使用
return语句。 - 对应的代码是:
return totalPerim;
编译与测试
完成代码编写后,我们点击编译。编译器可能会提示找不到符号 shape,这是因为方法参数名是 sh,我们需要将代码中的 shape 统一改为 sh。修正后,代码编译成功,没有语法错误。
接下来进行测试。我们运行 main 方法,程序会要求指定一个包含图形点坐标的输入文件。
- 首先使用
example1.txt文件,其坐标点与我们设计算法时使用的例子一致。程序计算出周长为 16,这与我们手动计算的结果相符。 - 为了进一步验证,我们使用另一个测试文件。程序计算出周长为 24,经手动验算,这也是正确的结果。
通过多个测试用例得到预期结果,我们越来越有信心确认刚刚编写的代码是正确的。

总结

本节课中,我们一起学习了将算法翻译成Java代码的完整流程。我们通过一个计算周长的实例,演示了如何:
- 根据算法步骤声明和初始化变量。
- 使用 for-each 循环结构进行遍历。
- 调用对象的方法(如
getLastPoint(),distance())来完成计算。 - 通过编译和多个测试用例来验证代码的正确性。
这个过程的核心是逐步对应:将算法中的每一个自然语言步骤,严谨地转化为具有相同语义的Java代码。
023:什么是字符串 🧬

在本节课中,我们将学习“字符串”这一核心编程概念,并了解它在基因组科学等实际领域中的应用。
大家好,我是Alua Gordon博士。我是杜克大学基因组与计算生物学中心以及生物统计与生物信息学系的教授。
我的工作主要基于设计和应用计算算法、程序和工具。我想简要介绍一下这方面的工作。
从“字符串”一词说起 🎻
首先,请思考“字符串”这个词。当一位管弦乐队指挥听到“字符串”时,她会想到什么?一位水手听到“字符串”会想到什么?一位钢琴家又会想到什么?
我是一名基因组科学家,因此我将告诉你们当我听到“字符串”时想到的是什么——那就是基因组字符串。
基因组:生命的字符串 🧬
一个生物体的基因组存储了构建和维持该生物体所需的所有遗传信息。这些遗传信息以一条长长的列表或“字符串”形式存储,其字母表仅由四个字母组成:A、T、C、G。
这四个字符对应着四种DNA碱基:
- A 代表腺嘌呤
- T 代表胸腺嘧啶
- C 代表胞嘧啶
- G 代表鸟嘌呤
分析基因组的挑战 🔍
基因组的庞大规模使得手动分析变得极其困难,甚至不可能。例如,人类基因组包含30亿个字符,这比此处显示的字符数量多出百万倍。
因此,在基因组中寻找任何信息都需要借助计算方法。
基因组的复杂性与计算方法 💻
此外,基因组结构复杂,包含不同类型的信息。我们需要计算方法来寻找这些信息,包括寻找基因,正如你在此处所见。
寻找基因不仅仅是简单地查找标识基因起始和终止的标签或密码子。除了此处以红色显示的基因外,我们还需要寻找此处显示的调控元件。我们通过计算工具和技术来完成这项工作。
这些调控元件在此处显示为表示核苷酸的简单字母。但重要的是要记住,这些区域实际上会被称为转录因子的蛋白质结合,这些蛋白质帮助开启或关闭基因。
课程目标与你的未来 🚀
我的研究重点是利用各种计算方法识别人类基因组中的此类调控元件。在本课程中,你们将完成类似的任务。这将为你们未来成为计算机科学家或基因组科学家做好准备,具体方向取决于你们的个人偏好。
总结 📝
本节课中,我们一起学习了“字符串”这一基础概念如何从日常语境延伸到编程和科学领域,特别是在基因组学中。我们了解到基因组本质上是由A、T、C、G四个字符组成的超长字符串,其分析必须依赖计算方法和编程工具。这为我们后续深入学习字符串的操作和应用奠定了重要的认知基础。
024:理解字符串
在本节课中,我们将学习如何在数据中查找信息和模式。我们将通过使用Java字符串来具体实践这个广泛的主题。字符串是由字母、数字、标点符号或任何你可以输入的字符组成的序列。
概述:为什么学习字符串?
你之前已经了解到,计算机中的一切都是数字。这确实是事实。正如你在这里看到的,我截取了三个不同文件的开头部分。这些文件可能存储在内存、闪存盘或计算机硬盘上。第一个文件是视频文件,后缀为.M4。第二个文件是图像文件,后缀为.PNG。第三个文件是纯文本文件,后缀为.TXT。你能仅通过观察0和1来判断哪一组比特对应哪个文件吗?有些人或许可以,但大多数人不能。
尽管存储在计算机上的一切都是数字,但计算机存储的信息通常是可读的。我们使用字符串来组织数据,以便我们能够阅读它,并且能够编写程序来读取和处理这些数据。
以下是三个数据文件的部分内容,这些数据都是以字符串形式存储的。数据对你来说是可读的,而不仅仅是对计算机可读,这一点很重要。虽然即使一切都是数字,我们也能编写程序,但当数据以字符串形式存储时,编写程序来查找模式、知识和信息会更容易。
第一个文件是基因组数据,以FAA格式存储。在本课中,你将编写程序在基因组数据中查找蛋白质和基因。第二个文件来自一个网页。你将编写程序在网页中查找链接和其他信息,这类似于谷歌等搜索引擎在网页排名中所做的工作,但规模较小。第三个文件是来自CSV文件的数据,记录了2008年加利福尼亚州萨克拉门托的犯罪情况。CSV文件是一种特殊格式的文件,CSV代表逗号分隔值。在后续课程中,你将编写代码来处理CSV文件。
学习目标
我们为你设定了几个本课的学习目标。
- 你将学习Java的
String类。 - 你将学习许多关于如何使用这个
String类编写程序的细节,以及最常见的做法。 - 你将学习常见的字符串函数,以及如何阅读文档以了解更多关于字符串的信息。
- 你将学习Java类型和运算符。在这里,你将了解更多关于Java数值类型及其运算符的知识,对你而言主要是
int(整数)和double(浮点数)。 - 你将学习通过搜索字符串的特定部分来编程查找数据中的模式。
上一节我们介绍了本课的整体目标,本节中我们来看看具体的实践场景。
你将通过重复搜索来查找信息和模式,例如查找网页上的所有链接或DNA链中的所有基因。
让我们开始解决问题吧。

总结

本节课中,我们一起学习了Java字符串的基础知识及其在数据处理中的重要性。我们了解到,虽然计算机底层存储的是数字,但字符串使我们能够以可读的方式组织和处理信息。我们还明确了本课程的目标,包括掌握String类、常见字符串函数、Java数据类型,以及如何编写程序来搜索和识别数据中的特定模式。
025:算法开发

在本节课中,我们将学习如何开发算法,特别是处理代表DNA的字符串,并从中搜索基因。这是一个绝佳的学习案例,因为即使我们从高度简化的版本开始,它也是一个具有实际应用价值的重要问题。
当然,您将学到的关于处理字符串和一般编程的知识,其价值将远超这个特定问题领域。无论您想解决何种问题,字符串都可能以某种形式出现,例如HTML、电子邮件或任何其他以字符串表示的文本。

在解决这些问题的过程中,您还将学习其他重要课程,例如如何在Java中进行数学运算。数学在编程中无处不在,因为一切最终都是数字。最重要的是,您将通过“七步法”获得更多开发和实现算法的实践。
在深入探讨DNA相关问题之前,我们需要先了解一些领域知识,即与处理DNA相关的一些术语和概念。
DNA基础知识
以下是一个可以代表某段DNA的字符串:
ATGGATTTACTATGACTAGCATGACATAA
您会看到它由四个字母组成:A、T、C和G。
每个字母代表一个核苷酸,它们是DNA的基本构建单元。
三个核苷酸在一起组成一个密码子,每个密码子描述一种氨基酸。
这里显示的ATG密码子很特殊,因为它指示基因的开始,因此被称为起始密码子。
TAA密码子也很特殊,因为它指示基因的结束,所以被称为终止密码子。还有其他几种终止密码子,但目前我们只考虑TAA。
包含在这两个密码子之间(包括它们自身)的所有内容构成一个基因。
第一个问题:在DNA字符串中寻找基因
您要解决的第一个问题是在代表DNA的字符串中找到一个基因。
也就是说,您需要编写一个程序,接收像上面这样的字符串,并给出位于起始密码子ATG和终止密码子TAA之间(包括它们)的所有文本。
您将从这个问题的一个高度简化版本开始。
只需找到这些字母以及它们之间的所有文本。
您暂时无需担心真实的基因长度必须是3的倍数(因为它们由密码子组成),也无需担心存在其他终止密码子或其他一些复杂性。
随着您掌握更多字符串和算法概念,您将为程序添加功能,使其每一步都更接近现实。
算法开发实践
与往常一样,您要做的第一件事是自己动手解决一个具体问题实例。
让我们以这个DNA序列为例,找到其中的第一个基因。
ATGGATTTACTATGACTAGCATGACATAA
- 找到起始密码子。找到了,就在这里(第一个ATG)。
- 从它之后开始寻找终止密码子TAA,我们在这里找到了它(最后的TAA)。
- 这意味着我们要提取代表该区域核苷酸的所有文本作为答案,即我们找到的基因。
现在我们已经完成了一个示例,应该把我们刚才所做的步骤写下来。
以下是我们在具体实例中采取的步骤:
- 我找到了第一个出现的ATG。
- 然后我开始在ATG之后寻找TAA。
- 最后,我将它们之间(包括它们自身)的所有字母作为我的答案:ATG、GAT、TTA、CTA、TGA、CTA、GCA、TGA、CATAA。
将具体步骤泛化为通用算法
既然我们已经写下了针对那个具体问题的做法,现在需要将其泛化。
- 为什么我们寻找ATG?我们总是要寻找它,因为那是起始密码子。
- 在ATG之后寻找TAA呢?我们也总是要这样做,因为那是终止密码子。
- 提取它们之间(包括它们自身)的所有字母?我们同样总是要这样做。
这里唯一不具普遍性的,是我们写下的具体答案字符串,它更像是一个给自己的描述性笔记,而非算法本身。
现在,我们有了一个通用算法,我们希望将其转化为代码。
但在那之前,我们需要先学习一些新的Java概念。
转化为代码前需要掌握的概念
为了将算法转化为代码,我们需要知道:
- 如何在一个字符串中找到ATG?
- 我们如何表示或谈论字符串中某个内容的位置?
- 我们如何获取字符串中特定范围内的所有字母?
您将在接下来的课程中学习这些概念,然后就可以准备将这个算法转化为代码了。


总结

本节课中,我们一起学习了算法开发的过程。我们从理解DNA相关的基本术语(如核苷酸、密码子、起始密码子和终止密码子)开始,定义了一个具体问题:在DNA字符串中寻找基因。接着,我们通过一个具体实例手动解决了问题,并将步骤记录下来。最后,我们将这些具体步骤抽象、泛化,形成了一个通用的算法框架。虽然我们尚未编写代码,但已经明确了实现算法所需掌握的关键Java字符串操作概念,为下一阶段的学习做好了准备。
026:字符串中的位置 🔍


在本节课中,我们将学习如何在字符串中表示位置,以及如何使用Java内置的字符串方法来获取特定范围内的字符。这些知识对于后续实现基因查找程序至关重要。


字符串位置的表示
上一节我们介绍了基因查找程序的背景,本节中我们来看看如何表示字符串中的位置。

为了回答这个问题,我们回到一个反复出现的核心概念:一切皆数字。也就是说,我们将为字符串中的每个位置分配一个数字索引。

请注意,这些数字从第一个位置的0开始,而不是1。这看起来可能有点奇怪,因为我们通常从1开始计数。然而,许多编程语言(包括Java)从0开始对字符串等序列进行编号,因为这样可以使某些任务更容易,我们将在后面看到这一点。
这些描述字符串中位置的数字被称为索引。例如,如果我们想谈论字符串中的字母E,我们可以说“索引3处的字母是E”。


获取特定范围内的字符

我们已经回答了第一个问题,即可以用数字表示位置。现在,是时候回答第二个问题:如何获取特定范围内的所有字符?
我们可以更精确地说,如何获取两个特定索引之间的字符。
一种选择是自己编写算法来完成这个任务。然而,你也可以使用字符串类的一个内置方法。

每当有内置方法可以完成特定任务时,最好使用它而不是自己编写。这不仅节省了你的工作,而且内置方法已经由专业程序员进行了大量测试,因此你可以非常确信它能正确工作。
对于这个特定任务,你需要使用内置的substring方法。但在展示如何使用之前,让我们以这个示例字符串为例,将其创建为一个实际的Java字符串并赋值给一个变量。

以下是声明和赋值语句:
String s = "Duke programming";
这里我们声明了一个名为s、类型为String的变量。等号用于赋值语句,行尾的分号用于结束语句。
然而,这还不完全正确。我们还需要将字符串字面量放在引号中,如上所示。如果不加引号,Java会认为Duke programming是一个变量名,并给出“未定义”的错误。通过将文本放在引号中,Java知道我们想要一个具有该字面量文本的字符串。
现在,我们有了一个有效的语句,使变量s成为字符串“Duke programming”。
使用 substring 方法
接下来,你可以看到一个使用substring方法的例子。这里我们声明了另一个类型为String的变量x,并将其赋值为s.substring(4, 7)的结果。


这些数字是什么意思?这个方法调用是做什么的?
- 第一个数字指定了在
s中我们想要开始创建子字符串的索引。此索引处的字母将包含在结果字符串中。 - 第二个数字指定了在
s中我们想要停止创建子字符串的索引。此索引处的字母将排除在结果字符串之外。该方法会在到达该字母之前停止。

这看起来可能很奇怪。为什么你要指定停止位置之后的索引呢?这个方法以及许多其他方法这样设计有多种原因,但一个很好的原因是:结果字符串的长度将是两个数字之差。7 - 4 = 3,所以我们将得到一个长度为3的字符串作为答案。
具体来说,你将得到由s中索引4、5和6处的字母组成的这个三字母字符串。所以x将是字符串“Pro”。
其他有用的字符串方法
在学习这个内置的字符串方法时,让我们花点时间谈谈其他一些有用的方法及其功能。你刚刚看到了substring,并学习了它如何获取特定索引范围内的字母。
以下是其他几个重要的字符串方法:

length():这个方法告诉你字符串中有多少个字符。例如,字符串s的长度是15。请注意,对于长度为15的字符串,有效索引是0到14。如果你尝试访问此范围之外的索引,你的程序将出现“字符串索引越界”错误。indexOf(String str):你向这个方法传递另一个字符串,它会尝试在你调用该方法的字符串中找到该字符串的第一次出现。例如,s.indexOf("program")会返回4,因为“program”第一次出现是从索引4开始的。如果找不到字符串,例如s.indexOf("F"),它会返回-1。indexOf(String str, int fromIndex):你也可以给indexOf一个第二个参数,指定开始搜索的索引。例如,s.indexOf("g", 8)会忽略索引0到7的字符,然后从索引8开始搜索“g”,并找到索引14处的“g”,因此这个方法调用会得到14。startsWith(String prefix):这个方法告诉你一个字符串是否以另一个字符串开头。例如,s.startsWith("Duke")会返回true。endsWith(String suffix):这个方法检查一个字符串是否以另一个字符串结尾。例如,s.endsWith("king")会返回false。
如何学习更多方法
哇,这讲了很多方法和一大堆信息。如果我们不告诉你字符串类中的每一个方法,你怎么会知道所有这些?你应该记住所有这些细节吗?


当然不是。编程不是关于记忆的。尽管随着你大量编程,你常用的方法自然会变得熟悉。


相反,你应该学习如何利用语言文档,它描述了所有内置类及其方法。
如果你在互联网上搜索“Java string”,你的第一个结果可能来自docs.oracle.com。Oracle是制造Java的公司,docs.oracle.com是他们托管语言文档的网站。如果你点击这个链接,你会得到一个页面,告诉你关于String类的一切。如果你向下滚动一点,你会发现一个相当长的列表,列出了字符串中所有内置的方法。其中包括你刚刚学到的几个方法,如indexOf和length。这些条目给出了方法的简要描述。如果你点击其中一个方法名,你会得到关于该方法功能的更详细描述。
课程网站DukeLearnToProgram.com上也有一个文档页面。该页面简化了一些重要方法的文档,供快速参考。
总结

本节课中我们一起学习了:
- 字符串中的位置使用从0开始的数字索引表示。
- 使用
substring(startIndex, endIndex)方法可以获取字符串中特定索引范围内的字符,结果包含起始索引的字符,但不包含结束索引的字符。 - 字符串类还提供了其他有用的方法,如
length()、indexOf()、startsWith()和endsWith(),用于获取字符串信息和进行搜索。 - 学习编程的关键是学会查阅官方语言文档(如
docs.oracle.com)来了解可用的类和方法,而不是死记硬背。

现在,你不仅了解了字符串中的索引和一些有用的内置方法,还知道了在需要时如何学习其他方法。
027:在DNA链中查找基因 🧬

在本节课中,我们将学习如何在一个DNA链中查找基因。我们将实现一个非常简单的算法来完成这个任务。
为了测试我们将要编写的代码,这里已经准备了几条DNA链。我们将打印出DNA链,调用查找基因的方法,然后打印出找到的基因。这个过程会使用四条不同的DNA链进行测试。
现在,让我们开始编写代码。
算法实现
首先,我们初始化一个名为result的变量来存储找到的基因,并将其设置为空字符串null。
根据视频中的知识,我们需要在DNA链中寻找起始密码子。起始密码子是字符串ATG。我们可以使用新学的字符串函数来实现。
以下是实现步骤:
-
定位起始密码子:我们创建一个变量
startIndex来存储起始密码子ATG在DNA链中的索引位置。使用indexOf函数在DNA链中搜索ATG。该函数会遍历字符串,在找到ATG时停止,并返回其起始位置的索引。int startIndex = dna.indexOf("ATG"); -
定位终止密码子:接下来,我们需要寻找终止密码子
TAA。我们创建变量stopIndex来存储其位置。同样使用indexOf函数,但这次我们从起始密码子之后开始搜索。为此,我们给indexOf函数添加第二个参数,指定从startIndex + 3的位置开始查找(因为ATG的长度是3)。int stopIndex = dna.indexOf("TAA", startIndex + 3); -
提取基因:现在我们有了起始密码子
ATG的索引startIndex和终止密码子TAA的索引stopIndex。我们想要提取包含这两者及其之间所有内容的部分。我们使用字符串函数substring来实现。substring函数需要起始索引和结束索引(不包含结束索引处的字符)。因此,我们从startIndex开始,到stopIndex + 3结束(以包含整个TAA)。String result = dna.substring(startIndex, stopIndex + 3);
现在,让我们编译并测试这段代码。
代码编译成功。运行我们编写的测试方法,可以看到结果:第一条DNA链成功找到了基因,从ATG开始,到第一个位于其后的TAA结束。后续几个例子也都能正确找到基因。

处理边界情况

上一节我们实现了基本的查找逻辑,本节中我们来看看当DNA链中缺少关键部分时会发生什么。
如果DNA链中没有ATG或者没有TAA,我们的代码可能会出错。让我们通过修改测试数据来检查。
首先,测试一条没有ATG的DNA链。运行程序后,我们遇到了一个错误:String index out of bounds exception: -1。这是因为indexOf函数在找不到目标字符串时会返回-1。当startIndex为-1时,我们试图从索引-1开始构建子字符串,这导致了错误。
为了解决这个问题,我们需要在查找ATG之后立即添加检查。以下是修复步骤:
- 检查起始密码子:在获取
startIndex后,检查其值是否为-1。如果是,则意味着DNA链中没有ATG,也就没有基因。此时,我们直接返回空字符串。if (startIndex == -1) { return ""; }
编译并再次运行测试。对于没有ATG的DNA链,现在程序不会报错,而是正确地返回空字符串(表示未找到基因)。

接下来,测试另一种情况:DNA链中有ATG,但没有TAA。修改测试数据后运行程序,发现对于这样的链,也没有输出基因。这是因为虽然找到了起始密码子,但indexOf函数找不到终止密码子TAA,stopIndex的值也是-1。
- 检查终止密码子:与检查起始密码子类似,在获取
stopIndex后,我们也需要检查其值是否为-1。如果是,则意味着在起始密码子之后没有找到TAA,同样没有完整的基因。我们也返回空字符串。if (stopIndex == -1) { return ""; // 没有TAA的情况 }
再次编译代码并运行测试。现在,我们的程序能够正确处理所有情况:
- 第一条DNA链(包含
ATG和TAA)成功找到基因。 - 第二条DNA链(包含
ATG但没有TAA)返回空字符串。 - 第三条DNA链(不包含
ATG但包含TAA)也返回空字符串,因为没有起始密码子。
总结

本节课中,我们一起学习了如何在DNA链中查找一个简单的基因。我们实现的核心算法是:首先在DNA链中寻找起始密码子ATG;如果找到了起始密码子,则从它之后开始寻找终止密码子TAA;如果两者都能找到,我们就返回起始密码子、终止密码子以及它们之间的所有内容作为找到的基因。此外,我们还通过添加条件检查,使程序能够稳健地处理缺少起始密码子或终止密码子的边界情况。
028:Java数学运算

在本节课中,我们将学习如何利用Java的数学运算来改进基因查找算法,使其更符合生物学事实。我们将重点关注如何判断一个DNA序列的长度是否为3的倍数,这是识别有效基因的关键条件。
从简化版算法到更现实的版本

上一节我们实现了一个简化的基因查找程序,它仅查找起始密码子ATG和终止密码子TAA,并返回两者之间的所有内容。然而,真实的基因必须由密码子组成,每个密码子包含三个核苷酸,因此基因的长度必须是3的倍数。
例如,以下字符串是一个有效的基因,因为它可以被划分为以ATG开始、以TAA结束的密码子序列:
ATGAAATAA
然而,这个字符串是无效的。虽然它包含ATG和TAA,但两者之间的序列无法被划分为完整的密码子:
ATGATAA

现在,让我们使我们的算法更贴近现实。我们将修复算法的这一方面。它仍然是一个简化版本,但会更加真实。从简单版本开始并逐步添加功能,不仅是一种有用的学习技巧,让我们一次引入几个概念,而且在编写真实、大型、复杂的问题时也同样重要。
识别有效基因的模式

你刚刚看到了两个DNA序列的例子,一个包含有效基因,另一个则不包含。如何从算法上判断其有效性呢?
展示索引位置可能会有帮助,如下所示,并高亮起始和终止密码子的位置。你是否看出了如何通过算法来区分?如果还没看出来,这很正常。发现模式可能很困难,但通过练习你会做得更好。
以下是发现模式的一个绝佳技巧:制作一个表格。你可能记得我们在一些例子中这样做过。
我们可以向表格中添加更多示例,如下所示,以帮助我们看清模式。一些标记为“是”,一些标记为“否”。也许你现在看到了模式,或者仍然难以看清。如果模式仍然不清晰,你可以做什么?也许添加更多行会有帮助,或者也可能没有。
相反,我们可能开始探索表格中各项之间的关系。这里我们添加了另一列,用于表示终止密码子索引与起始密码子索引之间的差值。

标记为“是”的示例,其差值为6和12。标记为“否”的示例,其差值为4和11。12和6有什么共同点是11和4所没有的?我们可能会想到很多。6和12都是6的倍数,但这在这个问题的背景下没有意义。如果我们做更多示例,可能会发现差值为3、9或15的“是”答案。然而,6和12,以及3、9和15,都是3的倍数。这个关系是有意义的,因为我们知道长度必须是3的倍数。
在Java中应用数学运算
既然你知道了需要寻找的关系,你需要在Java中进行一些数学运算。你需要一种方法来判断两个数字的差是否是3的倍数。
如果你和我们一起学习了课程一,你可能记得取模运算符(%),它在你进行除法运算时给出余数。x % y 表示用x除以y,但给我余数,而不是商。这可以帮助你解决手头的问题,因为一个数是3的倍数意味着它除以3的余数为0。也就是说,如果 x % 3 == 0,那么x是3的倍数。
你可以在Java中使用其他数学运算符:+(加)、-(减)、*(乘)、/(除),它们都是有效的。你也可以使用 == 来判断两个数字是否相等,使用 != 判断是否不等,以及 <、<=、>、>= 来检查不等式。
你还可以将简单的表达式组合成更复杂的表达式。以下表达式检查 (a - b) % 3 是否等于0:
(a - b) % 3 == 0
它的计算过程是:首先计算 a - b,然后取该结果并对3取模,最后检查该结果是否等于零。这几乎正是你手头问题所需要的,用于判断两个事物之间的差是否是3的倍数。
Java中的数字类型与运算规则
在学习Java数学运算时,需要了解几种不同的数字类型。实际上,这些类型有一些变体,但你现在不需要担心。
int:代表整数,如-2、-1、0、1、2等。int不能有小数部分。double:代表实数,即带有小数部分的数字,如1.2或3.457。当然,你也可以用double表示3(即3.0)。只有在需要时才应使用double,因为它们有一些行为可能会让新手程序员感到困惑。
关于整数的一个注意事项是:整数的数学运算总是产生整数结果。那么,5除以2得到什么?如果你认为是2.5,请记住你只能得到一个整数结果,所以得到的是2。
另一个需要了解的关于Java数学运算的知识是,它像数学一样有运算顺序规则。在程序员术语中,这些规则称为优先级和结合性。与数学一样,a + b * c 意味着先做 b * c,然后将结果加到 a 上。
取模运算符 % 与除法具有相同的优先级,意味着它在运算顺序中处于相同的位置。所以 a - b % 3 意味着先做 b % 3,然后用 a 减去该结果。这就是为什么我们之前想在减法运算先进行时,给 (a - b) 加上括号。
相等性比较在运算顺序中发生得非常晚。a + b == c - d 意味着先计算 a + b,然后计算 c - d,最后比较两个结果是否相等。
这些规则通常与数学中类似:乘法和除法先进行,然后是加法和减法。
也像数学一样,你可以使用括号来对事物进行分组,使它们优先计算。如果你不确定运算顺序,可以随时使用括号来明确表达,并确保得到你想要的结果。

总结
本节课中,我们一起学习了如何利用Java的数学运算来改进基因查找算法。我们了解到,真实的基因长度必须是3的倍数,并学会了使用取模运算符 % 来判断一个数是否是3的倍数(x % 3 == 0)。我们还介绍了Java中的基本数字类型(int 和 double)以及运算符的优先级规则。现在,是时候运用这些知识去改进你的基因查找算法了。
029:引言

在本节课中,我们将扩展您在DNA链中寻找基因时已掌握的编程和问题解决能力。

我很高兴能与您一同学习。您将使用一种更接近基因组学和计算科学家实际工作的终止密码子和起始密码子模型来寻找基因,这类工作涉及个性化医疗和理解多种物种的遗传问题等领域。
上一节我们介绍了基础的基因查找概念,本节中我们将学习新的工具和方法。
您将学习来自Edu.Duke库的新类,并练习使用新的编程结构。这些结构允许您重复执行程序语句,直到问题解决,例如在DNA中找到所有基因,或在网页上找到所有链接。
以下是本课程将涵盖的核心内容要点:
- 使用更精确的起始与终止密码子模型进行基因查找。
- 学习并应用
Edu.Duke库中的新类。 - 掌握能实现重复执行直至问题解决的编程结构,例如循环。
让我们开始吧。希望您能享受学习过程。
本节课中我们一起学习了如何通过更先进的模型和新的编程工具来扩展基因查找的能力,为处理更复杂的生物学信息学问题打下基础。
030:理解概念



在本节课中,我们将学习一个强大的新编程结构——不定循环。我们还将学习来自Edu.duke库的另一个可迭代对象。



我们已经使用过Edu.duke库中的FileResource和URLResource类来解决问题。使用可迭代对象使我们能够重复访问存储在计算机文件中或通过万维网URL获取的数据。



我们将运用这种重复的概念,在一条DNA链中寻找所有基因。这是基因组科学家工作的一部分,也是一个可以类比为在网页中寻找所有链接,或寻找所有关于猫、龙或其他任何你想看的YouTube视频的问题。
这里的核心思想是,我们将使用之前课程中开发的、用于在DNA链中寻找单个基因的算法。这是一个我们已经测试过并充满信心的算法。

我们将重复应用这个算法到整条DNA链上,以找到所有基因,而不仅仅是一个。我们还将学习一个新的可迭代对象,它允许我们存储中间结果,而不是直接打印找到的所有基因。通过存储结果,我们可以在找到可能是基因的字符串后,再去寻找特定的基因。
例如,通过存储基因搜索或其他类型搜索的结果,我们可以编写独立的方法来处理这些基因,而不是将处理逻辑与查找逻辑混在一起。
这种关注点分离——查找基因、处理基因、筛选具有特定特征的基因——是良好软件工程的标志。编写一个只做一件事的方法,而不是做几件事。这种分离使你更容易复用代码,也更容易开发代码。
我们将使用的存储对象是一个可迭代对象,你可以在程序运行时向其中添加内容。
更具体地说,我们将解决一个之前解决过的问题的变体:在DNA链中寻找基因的问题。基因位于DNA链的不同位置。在之前的课程中,我们开发了寻找单个基因的代码,就像这里用红色显示的区域。
我们通过寻找特定的标记——起始密码子和终止密码子——来找到这个基因。这些标记用于识别字符串中可能是基因的部分。你也可以使用类似的算法在网页的HTML文本中寻找链接的位置,例如寻找<a href=而不是ATG。
然而,DNA通常携带不止一个基因。因此,我们不仅要找到这个用红色标出的基因,还要使用编程技术来找到DNA链中所有可以编码基因的区域。这些区域以起始密码子开始,并以三个终止密码子之一结束。
在接下来的课程中,我们将在练习解决这个基因查找问题的过程中,学习许多关于Java和编程的知识。
我们将学习如何重复一个过程很多次,即使我们不知道具体要重复多少次。我们将通过使用while循环来实现这一点。这是一种新的循环,它补充了我们之前与可迭代对象一起使用的for循环。
我们将学习Edu.duke库中的StorageResource类。使用StorageResource对象将允许我们将选定的值添加到存储中,然后使用我们之前用过的、标准的for循环和可迭代对象来访问它们。




这种存储方式也为我们未来学习使用数组存储值的技术做了铺垫。
我们还将练习开发使用“短路求值”的if语句和布尔表达式。这将成为你迈向更优秀程序员和问题解决者过程中,所获得实践和知识的重要组成部分。
谢谢。😊
031:while循环 🧬

在本节课中,我们将学习如何改进DNA序列中查找基因的算法。我们将重点探讨一个关键问题:当找到的第一个终止密码子不符合条件时,如何让算法继续寻找下一个。为此,我们将引入一种新的循环结构——while循环。
算法改进的需求
在之前的学习中,你已经了解了字符串,并改进了在DNA中查找基因的算法。然而,让我们思考一下你的算法在下面这个字符串上的表现。
ATGATCTCGTAAATCTAA
算法会找到索引0处的起始密码子ATG。然后,它会找到索引8处的终止密码子TAA。接着,它会检查两者之间的距离(8)是否是3的倍数。由于8不是3的倍数,算法会得出结论:ATG和这个TAA之间不存在有效的基因。这里有一个完整的密码子ATC和另一个密码子的三分之二GC。
但是,如果你继续越过这个TAA寻找,你会在索引15处找到另一个TAA。现在,ATG和TAA之间的距离是15,这是3的倍数,因此这是一个有效的基因。我们找到的第一个TAA实际上不是一个密码子,而是两个相邻密码子的片段(来自GCT的T和来自AAT的AA)。
因此,对基因查找算法的下一个改进就是增加这个功能:让算法持续寻找,直到找到一个与起始密码子距离为3的倍数的终止密码子。

步骤分解与抽象
基于刚才的例子,让我们按照七步法的第二步,写下我们刚刚做了什么。
以下是我们的操作步骤:
- 找到
ATG。 - 找到
ATG之后第一个出现的TAA(在索引8处)。 - 检查它们之间的距离是否是3的倍数。在本例中,不是。
- 找到第一个
TAA之后的下一个TAA(第二个在索引15处)。 - 检查这个
TAA与起始密码子之间的距离是否是3的倍数。是的。 - 因此,从索引0到18的子字符串就是我们的答案。
在这个特定的步骤序列中,我们在两个地方检查了距离是否是3的倍数。
从特例到通用情况

如果这在一般情况下都有效,你可以用熟悉的if-else语句来实现这个算法。然而,我们是否总是只需要检查两次?让我们看一个不同的DNA字符串。
对于这个DNA字符串,我们需要检查三次。前两个TAA与起始密码子的距离都不是3的倍数,但第三个是。
那么检查三次就够了吗?我们可能需要检查四次、五次、十次甚至五十次吗?这就引出了一个普遍性问题:我们到底需要检查多少次?
答案是:我们无法预先确定一个具体的检查次数。即使你写了50个if-else语句,我们也能构造出一个DNA字符串,它在找到一个有效的TAA之前,包含了超过50个与起始密码子距离不是3的倍数的TAA。

因此,我们需要编写算法,让它能够重复检查任意多次。
引入循环:while循环
正如你之前所见,当你将算法转化为代码时,算法中的重复会变成一个循环。为了用重复来表达你的算法,你需要使重复的步骤相同,并弄清楚要循环什么。
之前,你已经见过for循环,它可以遍历某个可迭代对象(如图像中的像素)中的元素。现在,你将学习一种新的循环,称为while循环,它允许你在某个条件成立时持续迭代。
在我们尝试通过寻找重复来概括这些步骤之前,让我们更精确地描述一下我们做了什么。
我们首先在索引0处找到了第一个ATG。对于第一个TAA,我们从索引3开始寻找,并在索引8处找到它。检查索引8是否是3的倍数,结果不是。于是我们从索引9开始寻找第二个TAA,并在索引15处找到它。检查索引15是否是3的倍数,结果是。因此,两者之间的所有内容就是我们的答案。
通用化算法步骤
现在,让我们将这些步骤通用化。
- 寻找
ATG。我们总是要寻找ATG,因为它是起始密码子。我们找到它的位置(例如索引0)很重要,我们将其赋值给一个变量,称为startIndex。 - 寻找
TAA。我们总是要寻找TAA,因为它是终止密码子。我们开始寻找的位置不是固定的索引3,而是startIndex + 3。我们找到它的位置(例如索引8)也很重要,我们将其赋值给一个变量,称为currIndex。 - 计算距离。距离是
currIndex - startIndex。 - 检查条件。检查距离是否是3的倍数。
- 如果是,则找到基因(从
startIndex到currIndex + 3)。 - 如果不是,则从
currIndex + 1开始寻找下一个TAA,并更新currIndex为这个新位置,然后重复步骤3和4。
- 如果是,则找到基因(从
现在这些步骤看起来是重复的。重复可能有点难以察觉,因为它只发生了两次,但如果你为一个有更多无效TAA的字符串写下步骤,你会看到这些步骤被一遍又一遍地执行。
为了使这个过程可重复,我们将其写成循环形式。注意,步骤4、5和6(寻找下一个TAA、计算距离、检查条件)是我们将要重复的部分。
确定循环条件
然而,我们在这里留空了重复这些步骤的条件。我们如何知道何时停止重复?另外,在停止循环后你会做什么?
我们会在以下情况停止:
- 我们找到了一个有效的
TAA(距离是3的倍数)。循环停止,我们输出基因。 - 我们用完了所有的
TAA(即再也找不到TAA)。在这种情况下,currIndex会变成-1(正如你所知,当在字符串中找不到内容时,会返回-1)。如果遇到这种情况,意味着字符串中没有有效的基因,你应该返回空字符串""作为答案。
因此,循环继续的条件是:currIndex不等于-1 并且 (currIndex - startIndex) % 3 != 0(即距离不是3的倍数)。用伪代码表示:
while (currIndex != -1 && (currIndex - startIndex) % 3 != 0) {
// 寻找下一个TAA,从 currIndex + 1 开始
// 更新 currIndex
}
总结
本节课中,我们一起学习了如何改进基因查找算法以处理无效的终止密码子。我们通过分析具体例子,将操作步骤抽象和通用化,最终识别出需要重复执行的部分。为了解决“重复次数未知”的问题,我们引入了while循环的概念。while循环允许我们在特定条件(如“未找到有效基因且还有候选密码子”)为真时,持续执行一段代码块。这为我们下一节将算法翻译成实际的Java代码奠定了坚实的基础。
032:while循环语法与语义 🔄

在本节课中,我们将要学习while循环的语法与语义。为了改进基因查找算法,你需要掌握while循环。当算法中存在“只要某个条件为真,就重复执行某一步骤”的逻辑时,通常会用到这种循环结构。


语法结构 📝
所有while循环都以关键字 while 开始。


紧接着,在圆括号内有一个条件,有时也称为“守卫条件”。

这个条件用于判断是否应该继续执行循环。


然后,在花括号内是循环体,这些语句将在循环的每次迭代中执行。
以下是while循环的基本语法结构:

while (condition) {
// 循环体:每次迭代执行的语句
}

语义与执行流程 🧠
让我们通过一个例子来深入理解循环的语义,即它的含义。


假设变量 x 先前已声明并初始化为0,变量 y 初始化为7。
当程序执行到while循环时,发生的第一件事是Java评估条件:x < y。
在这个特定情况下,即判断 0 < 7。0小于7,所以条件评估为 true。

由于条件为真,执行将进入循环体并继续执行其中的语句。
循环体内的第一条语句打印 x 的值,即0,所以我们会输出一行 0。

第二条语句将 x 设置为 x + 3,这将把 x 的值更新为3。

现在,执行到达了循环体的末尾。闭合的花括号标记着它的结束。

然后,执行流程返回到循环的开始处。我们又回到了起点,但变量的值已经不同了:x 现在是3而不是0。我们继续遵循相同的规则。
评估条件:3 < 7。条件为真。因此我们再次进入循环体,打印 x,即3,并将 x 更新为6。

现在,我们再次到达循环体的末尾。所以我们回到循环的开始。再次遵循相同的规则:检查条件,条件为真。进入循环体,打印 x(现在是6),并将 x 更新为9。

再次到达循环体末尾后,我们回到循环开始处。又一次遵循相同的规则。


现在,当我们评估条件时,情况发生了变化:判断 9 < 7。

不成立,所以条件为 false。此时,程序不会进入循环体,而是跳过它。

然后,程序将继续执行循环之后可能存在的任何语句。
总结 📚



本节课中我们一起学习了while循环的基础知识,包括它们的语法(编写它们的语法规则)和语义(它们的含义)。现在,你已经准备好去编写改进后的基因查找算法代码了。
033:while循环编码 🧬

在本节课中,我们将学习如何将一个寻找特定DNA序列的算法,使用while循环转化为实际的Java代码。我们将从一个具体的算法步骤开始,逐步构建代码,并最终验证其正确性。
从算法到代码

上一节我们讨论了寻找基因序列的算法逻辑。本节中,我们来看看如何将这些逻辑步骤用Java代码实现。
我们的目标是:在DNA字符串中,找到第一个“ATG”起始密码子,然后寻找一个与之距离为3的倍数的“TAA”终止密码子,即使中间存在其他“TAA”序列。
以下是算法的主要步骤,我们将逐一实现:
-
找到第一个“ATG”的索引。
我们使用indexOf方法来实现这一步。int startIndex = dna.indexOf("ATG"); -
从
startIndex + 3的位置开始,寻找“TAA”的索引。
同样使用indexOf方法,并指定起始搜索位置。int currIndex = dna.indexOf("TAA", startIndex + 3);
实现while循环逻辑
现在,我们进入核心部分。我们需要持续搜索,直到找到符合条件的“TAA”或搜索完整个字符串。这正是while循环的用武之地。
我们将设置一个循环,条件是当前找到的“TAA”索引currIndex不等于-1(-1表示未找到)。
while (currIndex != -1) {
// 循环体内的步骤
}
在循环体内,我们需要做两件事:
- 检查距离是否为3的倍数。
通过计算(currIndex - startIndex) % 3是否等于0来判断。 - 根据检查结果采取行动。
- 如果是3的倍数,则找到目标基因,返回子字符串。
- 如果不是,则更新
currIndex,继续寻找下一个“TAA”。
以下是循环体内的代码结构:
if ((currIndex - startIndex) % 3 == 0) {
// 找到基因,返回结果
return dna.substring(startIndex, currIndex + 3);
} else {
// 未找到符合条件的TAA,继续搜索下一个
currIndex = dna.indexOf("TAA", currIndex + 1);
}
完成方法并测试
如果while循环结束(即currIndex变为-1),意味着没有找到符合条件的终止密码子,此时我们应该返回空字符串。
整合所有步骤,完整的方法代码如下:
public String findGene(String dna) {
int startIndex = dna.indexOf("ATG");
if (startIndex == -1) {
return "";
}
int currIndex = dna.indexOf("TAA", startIndex + 3);
while (currIndex != -1) {
if ((currIndex - startIndex) % 3 == 0) {
return dna.substring(startIndex, currIndex + 3);
} else {
currIndex = dna.indexOf("TAA", currIndex + 1);
}
}
return "";
}
为了验证代码正确性,我们编写了测试用例。测试字符串中包含故意放置的、不符合3倍数距离的“TAA”,以检验while循环是否能跳过它们,找到正确的基因。
运行测试后,代码成功输出了预期的基因序列,并且在找不到基因时返回了空字符串,证明了逻辑的正确性。
总结
本节课中我们一起学习了如何将算法转化为代码,并重点实践了while循环的应用。我们实现了以下关键点:

- 使用
indexOf方法定位字符串中的特定模式。 - 利用
while循环进行条件性重复搜索。 - 在循环中使用
if-else语句进行条件判断和流程控制。 - 使用取模运算符
%来验证距离是否为3的倍数。 - 通过具体的测试案例验证了代码的健壮性。
通过这个例子,你不仅掌握了while循环的编码技巧,也加深了对字符串处理和算法实现的理解。
034:三个终止密码子

在本节课中,我们将学习如何改进基因查找算法,使其能够识别三种不同的终止密码子(TAA、TAG、TGA),并从中选择第一个符合阅读框规则的密码子。
到目前为止,我们已经开发了一个算法,能够找到起始密码子和终止密码子,验证其长度是3的倍数,并返回该基因的DNA字符串。现在,是时候增加一层现实性和复杂性了。实际上存在三种不同的终止密码子:TAA、TGA和TAG。之前的算法只寻找TAA,现在我们需要让它寻找这三种密码子中,第一个距离起始密码子索引为3的倍数的那个。
那么,哪一个终止密码子是我们需要的呢?例如,第一个TGA距离起始密码子的索引不是3的倍数,所以不是我们想要的。第二个TGA同样不是3的倍数,也不是我们想要的。剩下的TAG和TAA,它们距离起始密码子的索引都是3的倍数。我们需要选择最先出现的那个,在这个例子中,是索引为12的TAG密码子。
设计解决方案

现在我们已经明确了问题,让我们来解决它。为此,让我们回顾一下之前的算法。之前算法中唯一与寻找TAA相关的部分,是步骤3和7中出现的“TAA”。我们能否基于这个算法,通过修改来完成大部分工作呢?
最好的方法是将问题分解。我们希望将搜索终止密码子的部分抽象成一个独立的方法。我们将其命名为 findStopCodon。这个方法将接收要搜索的DNA字符串、起始索引以及特定的终止密码子字符串(如“TAA”、“TGA”或“TAG”)作为参数。算法需要进行一些调整,例如返回找到的索引而不是文本,并且要能搜索三种终止密码子中的任意一种,而不仅仅是“TAA”。然而,算法搜索距离起始索引为3的倍数的密码子的基本机制保持不变。我们稍后会讨论这些修改。现在,我们假设已经有了一个可用的 findStopCodon 方法。
一旦我们将这个功能提取到独立的函数中,我们就可以用它来分别寻找三种终止密码子。
整合三种终止密码子
以下是使用 findStopCodon 方法的基本思路:
- 调用该方法寻找“TAA”终止密码子。
- 再次调用该方法寻找“TAG”终止密码子。
- 第三次调用该方法寻找“TGA”终止密码子。
请注意,这对应于我们手动操作的过程:我们分别识别了TAA、TAG和TGA终止密码子的位置。这与我们手动的例子略有不同,因为 findStopCodon 方法只会返回一个距离起始索引为3的倍数的位置,而我们在图示中展示了一个非3倍数的TGA,仅用于说明。

现在我们有了这三个位置,我们想要最先出现的那个。因此,我们只需要取这三个值中的最小值,我们将其称为 minIndex。
最后,我们的答案就是从起始索引到 minIndex + 3 的子字符串。
实现 findStopCodon 方法
现在,让我们看看需要对抽象出来的 findStopCodon 算法做哪些修改。
首先,算法中硬编码的“TAA”需要被替换。它们将变成方法参数 stopCodon,这个参数告诉我们正在寻找的特定终止密码子。
另一个变化是,我们希望返回找到终止密码子的索引,而不是起始和终止密码子之间的文本。因为我们的主算法需要这些索引来进行比较,以确定使用哪一个,然后才获取文本。
每当我们在步骤4找到一个有效的终止密码子时,我们可以直接返回当前索引 currIndex 作为答案。
然而,在步骤6,当没有找到有效索引时,我们应该返回什么来表示呢?通常,返回 -1 是一个不错的选择,表示没有找到有效索引。但让我们看看这个返回值将如何被使用。
如果我们返回 -1,我们也可以让它工作,但我们必须修改主算法的代码,进行比简单地取这三个值的最小值更复杂的比较。你将在后续课程中看到这种方法,并在此过程中学习一个新概念。
但现在,为了让主算法能直接取最小值,我们可以返回DNA字符串的长度,因为这个值比任何有效的索引都要大。

当然,既然我们这样做,就应该显式地检查没有找到有效终止密码子的情况。如果 minIndex 等于DNA字符串的长度,就意味着三个终止密码子都没有被找到。
总结
本节课中,我们一起学习了如何扩展基因查找算法以处理三种终止密码子。我们通过将搜索功能抽象成独立的 findStopCodon 方法来分解问题,然后分别调用该方法寻找三种密码子,并从中选择最先出现的有效密码子。我们还讨论了如何设计方法的返回值,以便于在主算法中进行简单的比较。现在,我们已经有了完整的算法设计,接下来就可以将其转化为代码了。
035:三个终止密码子编码第一部分 🧬

在本节课中,我们将学习如何编写一个Java方法来在DNA字符串中寻找三个不同的终止密码子(TAA、TAG、TGA)。我们将基于之前寻找单个终止密码子的代码进行扩展,并遵循一个清晰的七步方法来构建和测试我们的程序。
概述
在之前的编码练习中,我们已经见过寻找单个终止密码子的代码,并确保它距离起始密码子的位置是3的倍数。本节课,我们将升级这段代码,使其能够寻找三个不同的终止密码子:TAA、TAG 和 TGA。为此,我们将编写一个名为 findStopCodon 的新方法。
编写 findStopCodon 方法
上一节我们介绍了寻找单个终止密码子的逻辑,本节中我们来看看如何将其通用化,以接受任何指定的终止密码子作为参数。
我们的方法 findStopCodon 将接收三个参数:
dnaStr:要搜索的DNA字符串。startIndex:开始搜索的索引位置。stopCodon:要寻找的特定终止密码子。
以下是该方法的实现步骤:
- 初始化当前索引:我们从
startIndex + 3的位置开始,在DNA字符串中寻找第一个出现的stopCodon。int currIndex = dnaStr.indexOf(stopCodon, startIndex + 3);

-
进入循环:只要
currIndex不等于-1(表示找到了终止密码子),我们就继续检查。while (currIndex != -1) { // 检查逻辑将放在这里 } -
检查是否为3的倍数:在循环内部,我们计算找到的终止密码子索引与起始索引的差值,并检查这个差值是否是3的倍数。
int diff = currIndex - startIndex; if (diff % 3 == 0) { // 找到符合条件的密码子 return currIndex; } -
更新搜索位置:如果差值不是3的倍数,说明这个密码子不在正确的阅读框内。我们需要从
currIndex + 1的位置开始继续搜索同一个终止密码子。else { currIndex = dnaStr.indexOf(stopCodon, currIndex + 1); } -
处理未找到的情况:如果循环结束(即
currIndex变为-1),说明没有找到符合条件的终止密码子。我们返回DNA字符串的长度。这个设计是为了在后续寻找多个终止密码子中的最小值时提供便利。return dnaStr.length();
将以上步骤组合起来,完整的 findStopCodon 方法代码如下:
public int findStopCodon(String dnaStr, int startIndex, String stopCodon) {
int currIndex = dnaStr.indexOf(stopCodon, startIndex + 3);
while (currIndex != -1) {
int diff = currIndex - startIndex;
if (diff % 3 == 0) {
return currIndex;
} else {
currIndex = dnaStr.indexOf(stopCodon, currIndex + 1);
}
}
return dnaStr.length();
}
测试方法
编写完代码后,编译程序确保没有语法错误是第一步。但编译成功并不代表逻辑正确,因此我们必须进行测试。
以下是测试 findStopCodon 方法的代码示例。我们创建一个DNA字符串,并测试寻找“TAA”密码子的功能:
public void testFindStop() {
String dna = “ATGCGATAAATAA”; // 示例DNA序列
// 在索引上方标出位置以便理解
// 0 2 4 6 8 10...
// A T G C G A T A A A T A A
int result = findStopCodon(dna, 0, “TAA”);
if (result != 9) { // 我们预期在索引9找到TAA
System.out.println(“Error on test 1.”);
}
// ... 可以添加更多测试用例
System.out.println(“Tests finished.”);
}
运行测试时,如果没有打印错误信息,并且最后显示了“Tests finished”,则说明所有测试都通过了。如果预期结果与实际结果不符,错误信息会被打印出来,帮助我们定位问题。

总结
本节课中我们一起学习了如何编写一个通用的 findStopCodon 方法来在DNA序列中寻找指定的终止密码子,并确保其位置符合阅读框规则(即与起始密码子的距离是3的倍数)。我们实现了从指定位置开始搜索、循环检查、以及返回结果的核心逻辑,并强调了通过编写测试代码来验证程序正确性的重要性。
现在,我们已经拥有了一个可靠的工具方法来寻找单个终止密码子。在接下来的课程中,我们将利用这个方法,进一步编写能够寻找三个不同终止密码子(TAA、TAG、TGA)中最早出现的那一个的代码。
036:三个终止密码子编码第二部分

在本节中,我们将学习如何扩展之前的基因查找方法,使其能够处理三个不同的终止密码子(TAA、TAG、TGA)。我们将利用之前定义的抽象方法 findStopCodon 来实现这一功能。
上一节我们介绍了如何为单个终止密码子查找基因,本节中我们来看看如何同时处理多个终止密码子。
实现步骤
以下是实现支持三个终止密码子的 findGene 方法的具体步骤。
首先,我们需要在DNA序列中找到起始密码子“ATG”的位置。
int startIndex = dna.indexOf("ATG");
如果找不到起始密码子,则方法应返回一个空字符串。
if (startIndex == -1) {
return "";
}
接下来,我们需要分别查找三个终止密码子(TAA, TAG, TGA)在起始密码子之后首次出现的位置。我们将使用之前定义的 findStopCodon 方法。
int taaIndex = findStopCodon(dna, startIndex, "TAA");
int tagIndex = findStopCodon(dna, startIndex, "TAG");
int tgaIndex = findStopCodon(dna, startIndex, "TGA");
然后,我们需要从这三个索引值中找到最小的一个,这代表最早出现的有效终止密码子。
int temp = Math.min(taaIndex, tagIndex);
int minIndex = Math.min(temp, tgaIndex);
或者,也可以将两步合并为一行代码:
int minIndex = Math.min(taaIndex, Math.min(tagIndex, tgaIndex));
如果找到的最小索引值等于DNA序列的长度(意味着没有找到任何有效的终止密码子),则返回空字符串。
if (minIndex == dna.length()) {
return "";
}
最后,如果找到了有效的基因序列,则使用 substring 方法提取从起始索引到终止密码子(包含其三个碱基)的字符串。
return dna.substring(startIndex, minIndex + 3);
测试与验证
编译代码没有语法错误并不意味着程序逻辑正确。我们必须编写测试方法来验证 findGene 方法在各种情况下的行为,例如包含不同终止密码子的DNA序列。测试方法的结构与之前视频中展示的类似。
总结

本节课中我们一起学习了如何编写一个能够处理多个终止密码子的基因查找方法。核心在于利用 findStopCodon 抽象方法和 Math.min 函数来找到最早出现的有效终止点,从而准确地提取基因序列。记住,编写完代码后,进行充分的测试是确保其正确性的关键步骤。

祝您编码愉快。😊
037:逻辑与或运算

在本节课中,我们将学习如何在Java中使用逻辑运算符“与”(AND)和“或”(OR),并理解“短路求值”这一重要概念。我们将通过修改一个基因查找算法的例子,来具体应用这些逻辑运算符。
算法修改需求
上一节我们介绍了如何查找基因序列中的终止密码子。现在,我们来看看如何修改算法,使其在找不到有效终止密码子时返回-1,而不是字符串的长度。这是一个有效的设计选择,与Java中indexOf方法的行为一致。
为了实现这个修改,我们需要改变算法中的第6行。我们不能简单地取最小值,因为-1比任何有效索引都小。我们需要选择“不是-1的最小值”。
以下是几个例子,说明我们期望的选择逻辑:
- 当TAA索引为-1,TGA索引为3,TAG索引为6时,应选择3。
- 当索引为5,-1,8时,应选择5。
- 当索引为10,4,-1时,应选择4。
- 当索引为-1,-1,11时,应选择11。
决策逻辑分析
现在,让我们思考如何用算法表达这个决策过程。我们每次只比较两个值,然后将结果与第三个值比较。

以下是两个值之间的选择逻辑:
- 如果TAA索引等于-1,则选择TGA索引。
- 否则,如果TGA索引不等于-1并且TGA索引小于TAA索引,则选择TGA索引。
- 否则,选择TAA索引。
注意,我们使用了“或”和“与”的逻辑连接词,将简单的条件组合成了复杂的条件判断。

在Java中实现逻辑运算
现在,让我们将上述逻辑应用到我们的算法中。我们将使用该逻辑在TAA索引和TGA索引之间做出选择,并将结果存储在变量minIndex中。然后,我们用同样的逻辑在minIndex和TAG索引之间做出最终选择。
在Java中,我们可以这样表达“与”和“或”:
- 与运算使用两个
&符号表示:&&。 - 或运算使用两个竖线符号表示:
||。
例如:
if (x < y && y < z) { ... }表示如果x小于y并且y小于z。if (a > b || c < d) { ... }表示如果a大于b或者c小于d。
在我们的算法中,选择逻辑可以这样表达:
if (taaIndex == -1 || (tgaIndex != -1 && tgaIndex < taaIndex)) {
minIndex = tgaIndex;
} else {
minIndex = taaIndex;
}
短路求值
“与”和“或”运算符有一个重要的特性,叫做短路求值。这意味着,如果Java通过计算第一个操作数就能确定整个表达式的结果,它将跳过对第二个操作数的计算。

例如:
- 在表达式
x < y && y < z中,如果x < y为false,则无论y < z是什么,整个表达式都为false。因此,Java不会计算y < z。 - 在表达式
a > b || c < d中,如果a > b为true,则无论c < d是什么,整个表达式都为true。因此,Java不会计算c < d。
短路求值非常重要,尤其是在第二个操作数的计算可能导致程序崩溃时。例如:
if (x < str.length() && str.charAt(x) == ‘A’) { ... }
如果 x < str.length() 为false(即x不是有效索引),那么由于短路求值,str.charAt(x) 将永远不会被执行,从而避免了StringIndexOutOfBoundsException异常。依赖短路求值是防御性编程的一个绝佳例子,也是你Java编程工具箱中的重要工具。

总结
本节课中,我们一起学习了Java中的逻辑“与”和“或”运算符(&& 和 ||)。我们通过修改基因查找算法的案例,理解了如何用它们构建复杂的条件判断。更重要的是,我们探讨了“短路求值”机制,它不仅能提升效率,更是编写健壮、安全代码的关键技术。请在实践中善用这些工具。
038:基因查找程序中的与或运算编码 🧬

在本节课中,我们将学习如何修改基因查找程序,使其在未找到终止密码子时返回 -1 而非字符串长度。这一改动要求我们使用复杂的布尔表达式,结合逻辑“与”(&&)和“或”(||)运算符,来确保代码逻辑正确运行。
修改 findStopCodon 方法
上一节我们介绍了基因查找程序的基本结构。本节中,我们来看看对 findStopCodon 方法的一个关键修改。
我们决定将“未找到终止密码子”的返回值从 DNA.length() 改为 -1。这样做是为了与 Java 标准库(例如 String 类的 indexOf 方法)的惯例保持一致,它们也使用 -1 来表示“未找到”。
这一修改意味着我们原有的测试函数将无法正常工作,因为它期望的是字符串长度。因此,我们需要更新测试逻辑,将判断条件从 != 26 改为 != -1。

以下是更新后的测试代码片段:
// 原测试逻辑:if (result != 26) { ... }
// 新测试逻辑:
if (result != -1) {
// 处理找到终止密码子的情况
} else {
// 处理未找到的情况
}
完成此修改后,findStopCodon 方法就能正确返回 -1 来表示搜索失败了。
重构 findGene 方法

既然 findStopCodon 的返回值发生了变化,我们的 findGene 方法也需要相应调整。我们不再使用 Math.min 来比较索引,而是需要编写复杂的布尔表达式来逻辑判断哪个终止密码子是最先出现的有效密码子。
以下是实现这一逻辑的核心步骤:
-
初始化变量:首先,我们需要一个变量来跟踪找到的最小有效索引。
int minIndex = 0; -
比较 TAA 和 TGA:我们需要判断是 TAA 还是 TGA 是更早出现的有效终止密码子。这里的逻辑是:
- 如果根本没找到 TAA(索引为 -1),或者
- 找到了 TGA(索引不为 -1)并且 TGA 的索引小于 TAA 的索引。
if (taaIndex == -1 || (tgaIndex != -1 && tgaIndex < taaIndex)) { minIndex = tgaIndex; } else { minIndex = taaIndex; } -
引入 TAG 进行比较:接下来,我们需要用 TAG 与当前找到的
minIndex进行比较。逻辑类似:- 如果当前还没找到任何有效的终止密码子(
minIndex == -1),或者 - 找到了 TAG(索引不为 -1)并且 TAG 的索引小于当前的
minIndex。
if (minIndex == -1 || (tagIndex != -1 && tagIndex < minIndex)) { minIndex = tagIndex; } - 如果当前还没找到任何有效的终止密码子(
-
返回最终结果:最后,检查
minIndex。如果它仍然是-1,说明没有找到任何有效的终止密码子,应返回空字符串。否则,截取从起始密码子到minIndex+3(包含终止密码子)的子串作为基因。if (minIndex == -1) { return ""; } else { return dna.substring(startIndex, minIndex + 3); }
通过以上步骤,我们成功地将注释中的逻辑描述转换为了使用 && 和 || 运算符的 Java 代码。

测试与验证

编写完代码后,测试至关重要。我们应运行 testFindGene 方法,并尝试使用不同的 DNA 序列(包含不同的终止密码子 TAA、TAG 或 TGA)进行验证,以确保在各种情况下方法都能返回正确的结果。

记住,充分的测试和编写代码本身同等重要,它能确保我们方法的正确性和健壮性。

本节课中我们一起学习了如何利用逻辑“与”和“或”运算符来构建复杂的条件判断,以重构基因查找程序的核心逻辑。我们掌握了当方法返回值约定改变时,如何系统地调整相关代码和测试用例,这是编程中一项非常实用的技能。
039:查找多个基因 🧬

在本节课中,我们将学习如何扩展之前编写的基因查找方法,使其能够在一个DNA字符串中查找并打印出所有的基因,而不仅仅是第一个。我们将探讨如何使用循环和break语句来实现这一目标。
到目前为止,你已经编写了一个查找基因的方法,并逐步改进了它。这个方法比你最初开始时复杂得多,尽管它仍然是对实际操作的简化。与其继续完善这个方法,不如让我们思考搜索基因的另一个方面。到目前为止,你只在字符串中寻找第一个基因。然而,字符串可能包含许多基因。如果你想找到它们全部并打印出来,该怎么办呢?
你已经可以找到一个基因。尽管你可能需要对方法做一些微小的调整,以便可以从字符串的中间开始查找。

从查找一个到查找多个 🔄
既然你可以找到一个基因,并且想要找到多个,你就需要使用循环来重复步骤。循环现在对你来说应该相当熟悉了,因为你可能想重复操作,只要还有更多的基因。你可以利用最近学到的while循环。
然而,这里有一点困难。在我们开始搜索之前,我们并不知道是否还有更多的基因。这似乎使得编写循环条件变得困难。
在代码中处理这种情况有很多方法,但我们将教你的是如何使用break语句来跳出循环体。
我们将通过一个比通常开发算法时稍短的例子,来演示如何打印所有基因。如果我们讲完后你还没有完全理解,请暂停视频,自己完成步骤一、二和三。在编写代码时,我们将向你展示它如何在这个DNA字符串上运行。
算法设计思路 💡
首先,我们将startIndex设置为零。startIndex将代表我们开始寻找下一个基因的位置。
然后,只要在startIndex之后还有更多的基因,我们就重复一些步骤。我们想找到startIndex之后的下一个基因,打印出那个基因,然后将startIndex设置到我们找到的基因的末尾之后。
为了向你展示算法将如何在字符串上继续工作,我们回到步骤二,并不断重复这些步骤。只要在startIndex之后还有更多的基因。
请注意,这就是我们之前提到的困难所在。我们需要知道是否会找到更多的基因,但我们还没有开始寻找它们。我们会找到下一个基因,打印它,更新startIndex,然后意识到我们应该停止重复步骤,因为没有更多的基因了。然而,我们遇到了这个困难:我们需要在步骤2中知道是否还有更多的基因,但我们直到步骤3才去寻找基因,这使得算法的实现有点尴尬。
实际上,我们希望在步骤3和步骤4之间就决定是继续还是停止。
改进的算法版本 🛠️
以下是算法的一个稍作修改的版本,它正好解决了这个问题。
请注意,我们的重复指令不再有任何条件,它只是说“重复这些步骤”。我们稍后会弄清楚何时停止。同样地,我们现在在循环中间有了另一个步骤,内容是:“如果没有找到任何基因,则离开循环”。一旦我们学会了如何将这类步骤转化为代码,这将更容易实现。
对于步骤二,即在不检查任何特定条件的情况下重复步骤,你可以简单地写while (true)。如果while循环的条件仅仅是true,那么当我们到达循环顶部时,代码将总是进入循环体,因为true总是评估为真。
我们需要的另一个新的Java语法是break语句。这就是你用来表达“离开这个循环”的方式。在这个例子中,我们将“如果没有找到基因”翻译成了一个if语句,内容是if (gene.isEmpty())。字符串的isEmpty()方法在字符串为空时返回true,否则返回false。请记住,我们的基因查找方法在找不到基因时返回空字符串。
在if语句内部,我们看到“离开这个循环”被翻译成了break语句。break语句在Java中简单地用关键字break后跟分号来编写,它会导致Java离开当前循环,无论它是while循环、for循环还是你可能学到的任何其他类型的循环(如do-while循环)。基本上,Java会跳过结束循环的右大括号。
将算法转化为代码 💻
现在我们知道如何实现这些步骤了,让我们把算法转化为代码并尝试一下。我们将找到所有存在的基因。祝你编码愉快!

总结 📝
本节课中,我们一起学习了如何扩展基因查找功能,从一个基因到查找多个基因。我们探讨了使用while (true)循环和break语句来处理未知循环次数的情况。关键点包括:
- 设置一个
startIndex来跟踪搜索的起始位置。 - 使用
while (true)创建一个无限循环,在循环内部决定何时跳出。 - 利用
break语句在满足特定条件(如未找到基因)时立即终止循环。 - 每次找到基因后,更新
startIndex以继续搜索剩余部分。

通过这种方法,你可以有效地遍历整个DNA字符串,识别并处理其中包含的所有基因。
040:基因查找算法与循环控制


在本节课中,我们将学习如何结合while循环和break语句,编写一个能够打印出DNA序列中所有基因的方法。我们将把之前视频中讨论的算法或伪代码转化为实际的Java代码。
上一节我们介绍了基因查找的基本概念,本节中我们来看看如何实现一个能遍历整个DNA序列并找出所有基因的算法。

我们有一个名为printAllGenes的方法,它接收一个代表DNA的字符串参数,并基于我们之前视频中设计的算法来工作。
我们之前已经和Owen一起开发了一个基因查找算法。现在,我们将对它做一个小改动:让它多接收一个参数,用于指定搜索的起始位置。这个改动允许我们从DNA序列的中间位置开始查找基因。这样,当我们找到一个基因后,就可以通过传入新的起始索引来继续查找下一个基因。
完成这个小改动后,我们将回到主方法,开始将我们的算法翻译成代码。
以下是实现步骤:
首先,我们需要将起始索引设置为零。这意味着我们需要声明一个名为startIndex的整型变量,用于记录在字符串中的位置。
接下来,我们需要重复执行一系列步骤。由于我们需要在循环体内部判断何时停止,而不是在循环开始时,因此我们将使用while (true)来创建一个无限循环,并用大括号包裹我们的步骤。
现在,我们想要在startIndex之后找到下一个基因。查找基因是一个复杂的步骤,但幸运的是,我们已经将其抽象成了一个独立的方法findGene。我们可以直接调用它,让它为我们完成所有工作。
findGene方法接收一个DNA字符串和一个起始索引,然后返回一个基因字符串。我们这样调用它:
String currentGene = findGene(dna, startIndex);
我们将返回的基因字符串命名为currentGene。
如果未找到基因,我们需要跳出循环。我们可以通过检查currentGene是否为空字符串来判断。字符串有一个.isEmpty()方法可以用于此目的。如果基因为空,我们就使用break语句来终止循环。
如果我们没有跳出循环,说明找到了一个基因,我们需要将其打印出来:
System.out.println(currentGene);
打印基因后,我们需要更新startIndex,使其指向当前基因结束之后的位置,以便开始寻找下一个基因。这需要两个步骤:
- 找到当前基因在DNA字符串中的起始位置(从当前的
startIndex开始查找)。 - 将这个起始位置加上当前基因的长度,得到新的
startIndex。
代码如下:
startIndex = dna.indexOf(currentGene, startIndex) + currentGene.length();
让我们通过一个测试案例来理解这个过程。假设DNA序列是"ATGxxxTAAATGyyyTAG",startIndex初始为0。
- 第一次调用
findGene可能找到基因"ATGxxxTAA",其长度为9。 - 打印这个基因。
- 计算新的
startIndex:dna.indexOf("ATGxxxTAA", 0)返回0,加上长度9,得到新的startIndex为9。 - 下一次循环从索引9开始查找,找到下一个基因
"ATGyyyTAG"。

现在,我们来编译并运行测试代码。测试会打印出在给定DNA序列中找到的所有基因。我们测试了多种情况,包括空字符串和包含多个基因的序列,以确保代码在各种情况下都能正确工作,不会崩溃。

本节课中我们一起学习了如何利用while (true)循环和break语句来控制复杂的查找流程。关键在于在循环体内部(而不是循环条件中)判断是否应该继续执行,并在适当的时候使用break跳出循环。通过将查找单个基因的复杂逻辑封装成findGene方法,我们使主循环的逻辑变得清晰且易于管理。
041:关注点分离

在本节课中,我们将要学习一个重要的编程设计原则——关注点分离。我们将从一个具体的例子出发,分析为何简单的复制粘贴代码会导致问题,并探讨如何通过分离不同的任务来构建更灵活、更易维护的程序。
从迭代基因到发现问题
上一节我们介绍了如何遍历DNA字符串中的所有基因并打印它们。其核心算法结构如下:

// 遍历DNA字符串并打印所有基因的算法框架
while (还有基因可找) {
String currentGene = 找到下一个基因();
System.out.println(currentGene); // 这是可以改变的部分
}
如果你想对DNA字符串中的基因进行其他操作,算法会非常相似。实际上,无论你想对每个基因做什么,代码结构都基本如此。蓝色标注的行是唯一需要根据具体需求更改的部分。
以下是可能的需求示例:
- 打印那些满足特定条件的基因。
- 统计基因的数量。
- 将基因保存到文件中。
- 用所有基因构建一个网页。
如果你想实现这些不同的功能,一个直接的方法是复制现有的算法,粘贴到一个新方法中,然后修改那一行代码。这种方法虽然可行,但通常是一个坏主意。
为何要避免复制粘贴
复制粘贴的方法存在几个主要问题。
- 容易出错:你可能会忘记修改某些必须更改的部分。更糟糕的是,如果在复制之后发现原始实现中存在错误,你需要去修复每一个副本。
- 繁琐耗时:你需要找到方法、复制、粘贴并修改。如果只需要一个变体,这可能还不算太糟,但如果你需要五种不同的功能,这会非常枯燥。
- 设计不佳:每当你发现自己想要复制粘贴代码时,几乎总意味着存在更好的设计方法。

深入分析问题
让我们花点时间看看这个算法中可以改进的地方,理解如果保持现状并进行复制、粘贴、修改会带来多少工作量,从而找到改进的动机。
这是打印字符串中所有基因的算法。我们将其简化为底部的简短描述。
算法:打印DNA字符串中的所有基因
1. 当DNA字符串中还有基因时:
2. 找到下一个基因
3. 打印该基因

然后我们复制、粘贴并修改第3行,改为“打印具有高CG比例的基因”。我们再次将其简化为简短描述。

算法:打印具有高CG比例的基因
1. 当DNA字符串中还有基因时:
2. 找到下一个基因
3. if (基因的CG比例 > 阈值) { 打印该基因 }
现在,我们继续复制、粘贴和编辑,以创建其他几种算法,对DNA字符串中的基因进行各种操作。
以下是可能创建的算法列表:
- 用HTML格式打印基因。
- 将基因写入输出文件。
- 统计包含密码子CGA的基因。

所有这些算法基本相同,区别仅在于它们对DNA字符串的具体操作细节。起初,复制粘贴似乎没什么大不了的。
问题如何扩大化
后来,我们获得了另一种DNA数据,它在一个文件中列出了所有基因,每行一个基因。我们也需要对这份数据执行同类型的操作,但算法会略有不同。它将包含一个“对文件中的每一行”的循环。
如果采用复制粘贴的方法,我们现在需要编写和测试六种算法。它们彼此非常相似,所以可能不算太难,但这是繁琐且容易出错的工作。
然后,如果我们又获得了其他数据源,我们将不得不为该数据源再次创建所有六种算法。同样地,如果我们需要执行一个新的操作,我们将不得不为每个数据源编写它的三个副本。这真是一团糟。
解决方案:关注点分离
我们真正需要做的是重新设计算法,运用关注点分离的原则。
我们最初的算法承担了两项任务:
- 从某个数据源获取所有基因。
- 打印它们,或对它们进行任何我们想做的操作。

我们希望将它们分离开来:让负责查找基因的算法将基因放入某个能够容纳基因列表的结构中。然后,让负责打印基因、统计基因或进行其他任何操作的算法,都基于这个列表进行操作。
这样,如果你需要添加新的数据源,只需编写一个方法将其基因放入我们的列表,它就能自动与你已编写的每一个处理算法协同工作。同样地,如果你需要编写新的处理算法,它也能自动与你已编写的每一个数据源协同工作。完全不需要任何复制粘贴。
实现分离的工具
那么,这个能够容纳所有基因供算法使用的“东西”是什么呢?
我们将从使用edu.duke包中的一个名为StorageResource的类开始,这是一个实现此功能的简化方式。
// 使用StorageResource作为基因的存储容器
StorageResource geneList = new StorageResource();
// 查找基因的算法将基因添加到列表中
geneList.add(foundGene);
// 处理基因的算法从列表中读取
for (String gene : geneList.data()) {
// 对每个基因进行操作
}

以后,当你学习了更多概念,你将过渡到使用标准的Java util包中的ArrayList类,它具有类似的功能,但更加强大和复杂。

// 过渡到使用标准Java ArrayList
ArrayList<String> geneList = new ArrayList<>();
geneList.add(foundGene);
for (String gene : geneList) {
// 对每个基因进行操作
}
总结

本节课中我们一起学习了关注点分离这一核心编程原则。我们分析了为何对相似代码进行复制粘贴会导致代码冗余、维护困难且容易出错。通过将“数据获取”和“数据处理”这两个关注点分离开,并使用一个中间存储结构(如StorageResource或ArrayList)来连接它们,我们可以构建出更加模块化、灵活和易于扩展的程序。这种设计使得新增数据源或处理逻辑时,无需修改大量现有代码,极大地提高了代码的可维护性和复用性。
042:StorageResource类 📦

在本节课中,我们将要学习如何使用 StorageResource 类来存储和管理数据集合。这是一种将数据存储在列表中的简化方法,有助于分离程序中的不同关注点。
什么是StorageResource类? 🤔
StorageResource 是一个用于存储字符串集合的类。你可以使用 .add 方法将字符串放入其中,也可以使用 .data 方法获取一个可迭代对象,以便遍历你存入的所有字符串。

如何使用StorageResource? 🛠️
以下是使用 StorageResource 类的基本步骤。
首先,我们需要声明一个 StorageResource 类型的变量并初始化它。
StorageResource sr = new StorageResource();
初始化后,sr 变量引用一个空的字符串列表。
接下来,我们可以使用 .add 方法向这个资源中添加字符串。
sr.add("Hello");
sr.add("World");
现在,sr 中包含了两个字符串:“Hello” 和 “World”。
为了处理这些数据,我们可以使用 .data 方法获取一个可迭代对象,并通过 for-each 循环来遍历所有字符串。
for (String s : sr.data()) {
System.out.println(s);
}
这段代码会依次打印出 “Hello” 和 “World”。
代码执行过程详解 🔍
上一节我们介绍了基本用法,本节中我们来看看代码是如何一步步执行的。
- 声明与初始化:第一行代码
StorageResource sr = new StorageResource();创建了一个名为sr的变量,它指向一个新的、空的StorageResource对象。 - 添加数据:
sr.add("Hello");将字符串 “Hello” 添加到sr内部的列表中。接着,sr.add("World");将 “World” 也添加进去。 - 遍历数据:当执行到
for (String s : sr.data())时,循环开始。- 第一次迭代:变量
s被赋值为列表中的第一个字符串 “Hello”,然后执行循环体System.out.println(s);,打印出 “Hello”。 - 第二次迭代:变量
s被更新为列表中的下一个字符串 “World”,再次执行循环体,打印出 “World”。
- 第一次迭代:变量
- 循环结束:当列表中所有字符串都被遍历后,循环终止,程序继续执行后续代码。
通过这个过程,我们清晰地看到了数据是如何被存储和访问的。

在基因查找算法中的应用 🧬
了解了 StorageResource 的基本操作后,我们来看看如何将它应用到一个实际场景中,比如之前讨论过的基因查找算法。
以下是修改后的算法核心步骤,主要发生了三处变化:
- 创建存储容器:在算法开始时,我们创建一个空的
StorageResource对象,用于存放找到的所有基因字符串。StorageResource geneList = new StorageResource(); - 存储而非打印:在算法循环中,每找到一个基因,我们不再直接打印它,而是将其添加到
StorageResource中。geneList.add(foundGene); - 返回结果:在算法结束时,我们不再输出结果,而是将包含所有基因的
StorageResource对象返回给调用者。return geneList;
这样修改后,调用此方法的代码就可以自由地使用这些基因数据——无论是直接打印,还是进行进一步的分析处理,实现了更好的功能分离。
更多资源与总结 📚
本节课中我们一起学习了 StorageResource 类的用途和基本操作方法。
StorageResource类提供了一个简单的方式来存储和遍历字符串集合。- 核心操作包括使用
new StorageResource()创建对象、使用.add(String)添加数据以及使用.data()进行遍历。 - 在复杂的程序中,使用此类可以帮助我们分离数据存储和数据处理逻辑,使代码更清晰、更易维护。
如果你想了解更多关于 StorageResource 类的其他方法,或者忘记了我们讨论过的细节,你可以在 Duke Learn to Program 网站的文档页面找到这个类的完整说明。

祝你使用 StorageResource 愉快!
043:StorageResource类编码 📦

在本节课中,我们将学习如何将“查找基因”和“打印基因”这两个功能分离开来。我们将通过创建一个名为 StorageResource 的存储资源类来实现这一目标,从而让代码更加模块化和可重用。
概述
在之前的课程中,我们编写了直接查找并打印基因的代码。然而,为了提升代码的灵活性和可重用性,我们希望将“查找”和“处理(如打印)”这两个步骤分开。本节我们将创建一个 getAllGenes 方法,它负责查找所有基因并将其存储在一个 StorageResource 对象中。之后,我们可以遍历这个存储资源,根据需要进行打印或其他操作,而无需重复编写查找基因的代码。
代码重构:从打印到存储
上一节我们介绍了直接打印基因的算法。本节中,我们来看看如何修改代码,使其返回一个包含所有基因的存储集合,而不是直接打印。
首先,我们需要将原有的 printAllGenes 方法重命名为 getAllGenes,并修改其返回类型为 StorageResource<String>。这个方法将执行查找逻辑,但不再进行打印,而是将找到的每个基因添加到存储资源中。
以下是重构后的 getAllGenes 方法的核心步骤:
- 创建一个空的
StorageResource对象,用于存储基因。 - 在DNA字符串中循环查找起始密码子 “ATG”。
- 对于每个找到的起始点,查找下一个终止密码子(“TAA”, “TAG”, 或 “TGA”)。
- 如果找到的基因长度是3的倍数,则将其视为有效基因。
- 将这个有效的基因字符串添加到
StorageResource中。 - 循环结束后,返回这个包含了所有找到的基因的
StorageResource对象。
对应的关键代码如下所示:
public StorageResource<String> getAllGenes(String dna) {
StorageResource<String> geneList = new StorageResource<>();
int startIndex = 0;
while (true) {
startIndex = dna.indexOf(“ATG”, startIndex);
if (startIndex == -1) {
break;
}
// 查找终止密码子的逻辑...
String currentGene = dna.substring(startIndex, stopIndex + 3);
if (currentGene.length() % 3 == 0) {
geneList.add(currentGene); // 将基因加入存储资源
}
startIndex = stopIndex + 3;
}
return geneList; // 返回存储资源
}
使用存储资源
现在,我们已经有了一个可以返回基因集合的方法。接下来,我们看看如何在主程序或测试方法中使用它。
我们可以调用 getAllGenes 方法,得到一个 StorageResource 对象。然后,我们可以遍历这个对象中的所有基因,并对每个基因执行我们想要的操作,例如打印。
以下是遍历 StorageResource 并打印其中所有基因的示例代码:
public void testGetAllGenes() {
String dna = “ATGCGATACGCTGAATAGATGTAG”;
StorageResource<String> genes = getAllGenes(dna); // 获取存储资源
for (String g : genes.data()) { // 遍历存储资源中的每个基因
System.out.println(g);
}
}
在这段代码中,genes.data() 返回一个可迭代的集合,允许我们使用 for-each 循环来访问其中的每一个基因字符串。
总结

本节课中我们一起学习了如何利用 StorageResource 类来改进我们的基因查找程序。关键点在于,我们将查找功能(getAllGenes)和处理功能(如打印)解耦。现在,getAllGenes 方法只负责查找并返回结果,而如何处理这些结果(打印、筛选、分析等)则由调用它的代码来决定。这种设计使我们的代码更加清晰、灵活,也更容易在未来进行扩展和维护。
044:逗号分隔值 📊

在本节课中,我们将学习如何使用Java来分析数据,发现其中的趋势、模式,并基于数据信息得出结论。我们将重点介绍一种常见的数据格式——逗号分隔值(CSV),并学习如何利用Java程序来处理和分析这种格式的数据。

电子表格程序的历史与作用 📈
上一节我们介绍了数据分析的目标,本节中我们来看看用于数据分析的经典工具——电子表格程序。
以下是两个电子表格程序的截图,它们多年来一直被用于分析数据。
- 左侧是当今可以使用的Google文档电子表格的截图。该程序在云端分析数据并运行,可通过浏览器或移动应用程序在全球各种设备上访问。
- 右侧是Visicalc的截图,这是第一个电子表格程序,于1979年发布,并且只能在Apple2计算机上运行。

表格数据与CSV格式 📋
电子表格程序通常处理以行和列格式化的数据,即表格数据。你也将能够编写Java代码来分析此类数据。
电子表格程序通过花费数秒来模拟以前需要数天才能执行的“假设”场景,彻底改革了许多行业并催生了新行业。此处的链接指向一个描述Visicalc开发以及被这些程序所变革的行业的播客。
如今,可以被软件程序分析的数据通常通过政府和非营利网站公开提供。
典型情况下,数据以CSV文件的形式产生。在这种文件中,每一行中的不同数据值由逗号分隔,因此得名“逗号分隔值”。你将学习如何编写Java程序来分析以CSV格式存储的数据。
为什么需要Java分析CSV数据? 💡
使用电子表格软件是发现模式、信息、趋势以及可视化数据的好方法,但有时仅凭电子表格程序不足以轻松解决所有问题。
存在许多不同的电子表格程序,因此一个通用的格式非常有用。CSV格式使得数据能够在用于分析数据的不同类型软件之间可移植。此外,你可以编写自己的Java程序,使用CSV格式来分析数据。
数据格式标准 📜
通用格式通常有标准。例如,互联网协议(IP)标准决定了在互联网上传输信息的数据包格式。制定IP标准的IETF(互联网工程任务组)也为CSV文件创建了一个标准。
其他组织为不同软件程序中使用的格式制定了不同但相关的标准。
使用Apache CSV解析库 🛠️

在本课中,你将学习如何使用一个开源软件库——Apache CSV解析器,同时更深入地了解Java编程。这个软件库将使你能够解决那些仅使用电子表格难以解决的问题。
让我们开始吧。
本节课中我们一起学习了:CSV(逗号分隔值)作为一种通用、便携的数据格式的重要性;回顾了电子表格程序的历史和作用;理解了在某些场景下,使用Java程序配合专门的解析库(如Apache CSV)来分析CSV数据,比单纯使用电子表格更强大、更灵活。
045:使用CSV库 📊
在本节课中,我们将学习如何使用Java读取和处理逗号分隔值文件。我们将通过一个具体的例子,展示如何利用课程提供的CSV库来解析文件、提取数据,并理解其基本工作原理。
概述

CSV文件是一种常见的数据存储格式,其数据以逗号分隔。我们将使用Java程序读取一个名为Foods.csv的文件,并从中提取信息。为了更好地理解程序,我们先从三个角度查看Foods.csv文件中的数据。
文件本身的内容如下,第一行是作为每列标签的表头。你可以看到每列数据的标签,以及每行数据由逗号分隔。

使用电子表格程序(如Microsoft Excel)查看的视图如下。有些同学可能使用过这个程序。在网页浏览器中运行的Google Sheets是免费的软件,同样可以操作电子表格。以下是该程序中Foods.csv的视图。可以看到第一列的标签是name,第二列的标签是favorite food,第三列的标签是favorite color。

现在,让我们开始编写代码。
代码示例解析
我将通过一个简单的例子,演示如何使用我们课程中的CSV库,以便你理解如何创建CSV解析器及其最基本的使用方法。更复杂的使用方式将在课程后续部分学习,你也可以在研究API时阅读相关内容。

我有一个简单的第一个CSV示例。
我将打开代码,以便我们能查看一些内容。与其现在详细研究,不如先快速运行它,以理解其工作原理。然后,我们将逐步分析每一部分,并进行一个小修改。
我的类已经编译完成,因为这里没有任何阴影部分。我将右键点击并在对象工作台上创建一个新对象。我已经有一个对象,现在我有两个。我将从中读取食物信息。这会弹出一个文件对话框,这是我们的目录资源示例,我选择foods.csv文件。
程序开始读取并打印出Drew、Owen、Susan和Robert。我想理解为什么它打印这些名字,然后看看能否在这个CSV文件中找到除名字之外的更多信息。
再次查看源代码,我注意到几点。首先,我导入了edu.duke库,这在我们的许多示例中很常见,因为我正在使用CSV解析器。我还需要一个导入,这是一个非常复杂的导入,但你可以直接复制粘贴:org.apache.commons.csv。我们使用一个开源库作为我们的CSV解析器,并以一种更便捷的方式使用它,稍后我会解释。
我有一个方法readFood。我创建了一个文件资源对象,它使用我们的标准库,因为没有参数。文件资源对象将弹出一个对话框,允许我导航到要使用的文件,我刚才展示了使用Foods.csv。然后,我要求文件资源对象fr给我解析器getCSVParser。这是一个新类,CSVParser类,它是Apache库的一部分,你可以在屏幕上看到高亮显示。
我现在遍历可迭代对象,即每次从解析器获取一个CSV记录。这里有两个新类:CSVParser类和CSVRecord类。CSVRecord类有一个我正在使用的方法get,它允许我获取该CSV文件行上的一个记录元素。你可能记得,CSV文件由多个用逗号分隔的数据元素组成。其中一个元素名为name。由于我研究过CSV文件,我知道另一个元素名为favorite food。因此,如果我要求记录获取字段favorite food,它会做到。我将打印这个,后面只加一个空格。

注意,我将println改为print,这使输出保持在同一行。println会结束一行。我将展示输出如何工作。我的类已编译,没有错误。我将在工作台上通过右键点击创建一个新对象,这是我通常的做法。
创建新对象后,在对象工作台上,我将读取。导航到foods.csv文件,现在注意到我得到了:Drew最喜欢的食物是巧克力,Owen最喜欢的食物是菠萝,Susan非常喜欢蛋糕,Robert喜欢披萨。
还有一个例子。在这个CSV文件中,除了最喜欢的食物,还有最喜欢的颜色。因此,我将打印最喜欢的颜色,我们简要查看一下,然后最后进行总结。
我将编译它。当我创建新对象时,它出现在我的对象工作台上。我运行它,导航到foods.csv文件。看,Drew最喜欢的颜色是绿色。令人惊讶的是,Robert最喜欢的颜色也是绿色。如果你仔细观察我迄今为止所做的课程,这可能会让你觉得合理。Susan喜欢紫色,我喜欢蓝色。
让我再次回顾一下这个CSV文件。这个程序读取的文件有三个字段:name、favorite color和favorite food。如果你尝试获取另一个字段,例如,我决定说我最喜欢的数字是get favorite number。那么我会说get favorite number。
这将编译。但当我尝试运行这个例子时,通过创建一个新对象并运行,右键点击,有时我点击得不太好,打开foods.csv,我会得到各种非法参数异常:favorite number not found。我的CSV文件没有favorite number字段,因此无法打开。当我们更详细地研究这一点,以及当你阅读API时,你会看到有方法可以避免尝试访问不存在的CSV元素。
但现在,我们已经看到了如何适当地使用一个库,即org.apache.commons.csv,从文件资源对象获取CSV解析器,然后遍历解析器(它是可迭代的)以一次获取一个记录。
总结

本节课中,我们一起学习了如何使用Java和Apache Commons CSV库来读取和解析CSV文件。我们了解了如何从文件资源获取解析器,如何遍历记录以提取特定字段的数据,以及如何处理可能出现的异常情况。希望你能享受使用CSV并从数据中寻找信息的乐趣。
046:算法开发

在本节课中,我们将学习如何开发一个算法,用于分析CSV文件中的数据,以找出出口特定商品的所有国家。
上一节我们介绍了处理CSV文件的基础知识。本节中我们来看看如何利用这些知识解决一个实际问题。
问题定义


我们有一个CSV文件,其中包含了218个国家的出口数据。文件包含以下列:国家名称、主要出口商品以及2014年以美元计价的出口总值。
面对如此大量的数据,手动查找既繁琐又容易出错。因此,我们希望编写一个程序来自动完成这项分析。例如,我们可能想找出所有出口龙虾或铁矿石的国家。
算法开发七步法
我们将遵循七步法来编写这个程序。
第一步:手动解决一个小规模实例
首先,我们通过一个简化的例子来手动解决问题。以下是一个包含四个国家及其部分出口商品的表格:
| 国家 | 出口商品 |
|---|---|
| 马达加斯加 | 香草, 咖啡, 糖 |
| 马拉维 | 烟草, 咖啡, 茶 |
| 马其顿 | 葡萄酒, 烟草, 水果 |
| 马来西亚 | 橡胶, 棕榈油, 可可 |

问题: 哪些国家出口咖啡?
通过观察表格,我们可以发现:马达加斯加和马拉维出口咖啡,而马其顿和马来西亚不出口。
第二步:记录手动解决步骤
仅仅说“我看了看就找到了答案”对后续步骤没有帮助。我们需要以更逐步的方式记录思考过程。
以下是针对这个具体实例的详细步骤:
- 查看第一行(马达加斯加)的“出口商品”列。
- 发现其中不包含“咖啡”。
- 查看第二行(马拉维)的“出口商品”列。
- 发现其中包含“咖啡”。
- 写下“马达加斯加”。
- 查看第三行(马其顿)的“出口商品”列。
- 发现其中不包含“咖啡”。
- 查看第四行(马来西亚)的“出口商品”列。
- 发现其中不包含“咖啡”。
第三步:寻找模式并归纳
观察上述步骤,我们发现对每一行的操作非常相似。这种相似性表明,我们最终需要对每一行进行循环处理。

然而,在能用“对每一行”来表达算法之前,我们需要仔细分析各行步骤中的差异。
第一个差异: 在第5步我们写下了“马达加斯加”。这个名称来自哪里?它来自当前查看行的“国家”列的值。
第二个差异: 我们没有写下第一行和第四行的国家名称,但写下了第二行和第三行的。我们是如何决定是否写下国家名称的?这个决定是基于该行的“出口商品”列是否包含“咖啡”。
现在,我们已经弄清楚了如何以相同的方式处理每一行,可以归纳出通用步骤。
以下是归纳后的通用算法(适用于任意行数的CSV文件):
- 对CSV文件中的每一行:
2. 检查该行的“出口商品”列是否包含“咖啡”。
3. 如果包含,则打印该行的“国家”列的值。
第四步:测试你的算法
现在,我们用一个新数据表来测试这个通用算法。下表包含两个国家:
| 国家 | 出口商品 |
|---|---|
| 安哥拉 | 石油, 钻石, 龙虾 |
| 巴西 | 铁矿石, 大豆, 咖啡 |
问题: 哪些国家出口龙虾?
根据我们的算法:
- 检查安哥拉:出口商品包含“咖啡”吗?不包含。不打印。
- 检查巴西:出口商品包含“咖啡”吗?包含。打印“巴西”。

结果: 算法输出“巴西”。但这是错误的,正确答案应该是“安哥拉”。
测试暴露了算法中的一个关键缺陷:我们的算法总是检查是否包含“咖啡”,而不是检查我们感兴趣的任何出口商品。在归纳步骤中,我们遗漏了这一点。
这正是“第四步:测试你的算法”旨在在编写代码之前发现的问题。
我们可以通过引入一个参数(我们称之为exportOfInterest)来修复这个算法,该参数表示我们要查找的出口商品。
修正后的算法如下:
- 对CSV文件中的每一行:
2. 检查该行的“出口商品”列是否包含exportOfInterest。
3. 如果包含,则打印该行的“国家”列的值。
用这个修正后的算法重新测试龙虾的案例,现在能得到正确答案“安哥拉”了。
后续步骤
至此,我们已经完成了一个健壮算法的开发。接下来的步骤将是:
- 第五步: 用Java代码实现这个算法。
- 第六步: 测试你编写的代码。
- 第七步: 调试代码(如果需要)。
总结

本节课中我们一起学习了如何系统地开发一个数据处理算法。我们从手动解决一个小问题实例开始,逐步记录思考过程,寻找操作中的模式并将其归纳为通用步骤。最关键的一步是使用不同的测试用例来验证我们的通用算法,这帮助我们发现并修正了一个重要的逻辑错误,即算法被硬编码为查找特定商品(咖啡),而不是一个可变的查询目标。最终,我们得到了一个清晰、正确的算法,为下一步的代码实现打下了坚实的基础。
047:代码翻译

概述
在本节课中,我们将学习如何将之前设计的“查找出口特定商品的国家”算法转化为实际的Java代码。我们将使用CSV解析器来读取数据文件,并实现一个方法来筛选和打印出符合条件的国家。
从算法到代码实现
上一节我们介绍了查找出口特定商品的算法。本节中,我们来看看如何在Java中实现这个算法。
我们已经在BlueJ中创建了一个类,并导入了必要的库:edu.duke.* 和 org.apache.commons.csv.*。后者提供了我们需要的CSVParser类。
这里有一个名为listExporters的方法。它接收两个参数:一个已打开数据文件的CSVParser对象,和一个表示我们感兴趣的商品名称的字符串exportOfInterest。方法注释中记录了我们之前设计的算法步骤。
以下是实现该算法的代码步骤:
第一步:遍历CSV文件的每一行。
我们知道,可以使用CSVRecord类型来遍历CSV文件中的每一行数据。
for (CSVRecord record : parser) {
// 后续步骤将放在这个循环体内
}
第二步:获取“exports”列的值。
我们可以使用record.get("exports")来获取当前行中“exports”列的内容。
String export = record.get("exports");
第三步:检查该列是否包含目标商品。
我们需要检查export字符串是否包含exportOfInterest。一种方法是使用indexOf,如果找不到则返回-1。但更清晰、更易读的方法是使用contains方法。
if (export.contains(exportOfInterest)) {
// 如果包含,则执行下一步
}
第四步:如果包含,则记录该行对应的国家。
我们从“country”列获取国家名称,并将其打印出来。
String country = record.get("country");
System.out.println(country);
将以上步骤组合起来,完整的listExporters方法代码如下:
public void listExporters(CSVParser parser, String exportOfInterest) {
for (CSVRecord record : parser) {
String export = record.get("exports");
if (export.contains(exportOfInterest)) {
String country = record.get("country");
System.out.println(country);
}
}
}
编译这段代码,确保没有语法错误。
创建测试方法
在BlueJ的对象工作台中直接创建CSV解析器有些复杂。因此,我们创建一个辅助测试方法。
这个方法名为whoExportsCoffee,它不接受参数,目的是从一个特定数据集中找出所有出口咖啡的国家。
以下是测试方法的实现步骤:
第一步:创建文件资源。
使用FileResource类,它允许我们通过对话框选择要读取的数据文件。
FileResource fr = new FileResource();
第二步:从文件资源中获取CSV解析器。
FileResource对象可以提供一个CSVParser来解析文件数据。
CSVParser parser = fr.getCSVParser();
第三步:调用listExporters方法。
使用上一步得到的解析器和目标商品“coffee”作为参数,调用我们编写的方法。
listExporters(parser, "coffee");
完整的测试方法代码如下。注意,该方法不返回任何值,因此返回类型为void。

public void whoExportsCoffee() {
FileResource fr = new FileResource();
CSVParser parser = fr.getCSVParser();
listExporters(parser, "coffee");
}
再次编译,确保代码无误。
测试与验证
现在,我们通过创建一个对象并调用whoExportsCoffee方法来测试代码。
如果使用完整的大型数据文件(如exportdata.csv),手动验证所有结果将非常繁琐。这正是我们编写程序的目的。

因此,更有效的测试方法是使用一个我们已知结果的小型数据文件。例如,使用幻灯片中用过的小文件exportsmall.csv进行测试。

调用方法后,程序输出“Madagascar”和“Malawi”。这与我们预期的正确结果一致,从而增强了我们对代码正确性的信心。这样,我们就可以确信在大型文件上运行也能得到正确结果。

总结
本节课中我们一起学习了如何将“查找出口特定商品的国家”算法转化为可运行的Java代码。我们实现了遍历CSV数据、检查字符串包含关系以及输出结果的核心方法,并创建了测试方法来验证代码的正确性。你现在可以运用类似的思路,处理其他基于数据筛选的任务了。
048:CSV导出总结 📊
在本节课中,我们将学习如何使用Apache Commons CSV库来解析和分析CSV文件,并实现一个算法来筛选出符合特定条件的数据行。

概述
现在,你已经了解了如何解决分析CSV数据以找出出口特定商品的国家的问题。
在本节中,我们将学习使用Apache Commons CSV包的具体操作。这是一个用于处理CSV数据的库,我们将使用其CSV解析器和CSV记录类来操作CSV文件中的数据。

使用Apache Commons CSV库
上一节我们介绍了分析CSV数据的目标,本节中我们来看看实现这一目标所需的工具。你将学习Apache Commons CSV库的机制。

这个库的核心是CSVParser和CSVRecord类。CSVParser用于读取和解析CSV文件,而CSVRecord则代表文件中的一行数据。
以下是使用该库解析CSV文件的基本步骤:
- 添加依赖:首先,需要在项目中引入Apache Commons CSV库。
- 创建解析器:使用
CSVFormat类定义CSV文件的格式(例如,是否包含表头),然后创建CSVParser对象。 - 遍历记录:遍历
CSVParser对象,获取每一行的CSVRecord。 - 访问数据:通过列名或索引从
CSVRecord中获取具体的单元格数据。
核心代码片段如下:
Reader in = new FileReader("path/to/your/file.csv");
Iterable<CSVRecord> records = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(in);
for (CSVRecord record : records) {
String country = record.get("Country");
String exports = record.get("Exports");
// ... 处理数据
}
设计筛选算法
掌握了操作CSV数据的基本方法后,我们需要设计一个算法来找出符合条件的数据行。
你设计了一个算法来分析CSV文件,找出所有满足特定条件的行。在本例中,条件是“出口某种特定商品”,但相同的基本思路也适用于更广泛的其他条件。
算法的核心逻辑是遍历CSV文件的每一行记录,并检查目标列(例如“Exports”列)的值是否包含我们正在查找的商品。如果包含,则将该行信息(如国家名称)记录下来或输出。
以下是实现该筛选逻辑的关键步骤列表:
- 定义查询条件:明确要搜索的商品名称。
- 遍历数据行:使用循环遍历解析后的所有
CSVRecord。 - 检查条件:在循环体内,从当前记录中获取“Exports”列的值。
- 执行匹配:使用字符串方法(如
.contains())判断该值是否包含目标商品。 - 收集结果:如果匹配成功,则记录或打印该行相关的信息(例如国家名称)。

在Java中实现
当然,你在Java中实现了它,运用了新学到的CSV库知识。
将库的使用方法和算法逻辑结合起来,就构成了完整的解决方案。你需要编写一个Java程序,该程序能够读取指定的CSV文件,根据用户输入或预设的商品名称进行筛选,并最终输出所有符合条件的国家列表。

总结
本节课中,我们一起学习了如何使用Apache Commons CSV库来解析和处理CSV文件。我们探讨了CSVParser和CSVRecord类的用法,并设计并实现了一个用于从CSV数据中筛选出符合特定条件(如出口某商品)的数据行的算法。通过这个实践,你掌握了处理结构化文本数据的一项基本而重要的技能。
049:逗号分隔值


欢迎回来。现在你已经对处理CSV文件有了一些了解,是时候学习如何分析这些文件中的数值数据了。
例如,这里我们有一个关于2014年1月1日罗利-达勒姆机场天气的CSV数据文件。我们拥有连续几年的每日数据,每天一个CSV文件。每一行包含一个小时的天气信息,并且有多个列,例如华氏温度、露点、湿度等。如果你在研究天气模式,你可能想要分析这些数据并提出各种问题。当然,你将学到的技术也适用于其他类型的数据,因此你可能会发现它们在许多其他领域也很有用。😊
你可能会问的一个问题是:最高温度是多少?也就是说,什么时候最热?
如果你只是针对某一天的数据做这件事,你可以直接查看(只有24条记录)或在电子表格中使用最大值函数。然而,如果你想在许多天(例如一整年)中找到最高温度,你该怎么办?你肯定不想手动查看所有数据,并且将365个文件导入电子表格会非常繁琐。对于这类任务,你会希望编写一个程序来为你完成工作。在本课中,你将解决的正是这个问题:找到一年中最热的一天。
为了本示例的目的,我们将定义一年中最热的一天是最高温度最高的那一天。一个相关但略有不同的问题是找到平均温度最高的一天。我们不会详细讲解那个问题,但你在本课之后肯定可以完成它。
编写此程序的计划是:首先学习处理数值数据。CSV解析器会将数据读取为字符串,这些字符串必须转换为数字。一旦你知道如何将字符串转换为数字,我们将从一个较小的问题开始:仅找出一天中的最高温度。我们将与你一起逐步讲解算法和代码开发。
在继续之前,你需要测试你的代码以确保其正确性。一旦你对找出一天中最高温度的代码有信心,你将希望在此基础上进行扩展,找出许多天中的最高温度。

这将让你找到一年中的最高温度。那么,让我们开始吧。
概述
在本节课中,我们将要学习如何从CSV文件中提取和分析数值数据,特别是找出一年中的最高温度。我们将从字符串到数字的转换开始,逐步构建一个能够处理单日和多日数据的程序。
处理数值数据
上一节我们介绍了CSV文件的基本结构。本节中我们来看看如何处理其中的数值数据。CSV解析器默认将所有数据读取为字符串。为了进行数值比较(如找最大值),我们需要将这些字符串转换为数字类型,例如整数或浮点数。
在Java中,可以使用以下方法进行转换:
String temperatureStr = "75";
int temperature = Integer.parseInt(temperatureStr);
或者对于小数:
String humidityStr = "45.6";
double humidity = Double.parseDouble(humidityStr);
找出单日最高温度
现在我们已经知道如何转换数据,让我们专注于解决一个更小的问题:找出单日CSV文件中的最高温度。这将是构建年度解决方案的基础。
以下是解决此问题的基本算法步骤:
- 初始化一个变量来存储当前找到的最高温度(例如,
maxTemp),可以将其设置为一个非常低的值。 - 打开并逐行读取CSV文件。
- 对于每一行,提取温度列(假设我们知道它的索引)。
- 将温度字符串转换为数字。
- 将转换后的温度与
maxTemp比较。如果它更高,则更新maxTemp。 - 处理完所有行后,
maxTemp中存储的就是当日的最高温度。
扩展到多日数据
一旦我们能够可靠地找出单日的最高温度,下一步就是将其扩展到处理一整年的数据。这意味着我们需要遍历代表每一天的多个CSV文件。
以下是实现思路:
- 初始化一个变量来存储年度最高温度(
yearMaxTemp)以及对应的日期。 - 遍历包含每日CSV文件的目录。
- 对每个文件(代表一天),调用我们之前编写的“找出单日最高温度”的函数。
- 将得到的单日最高温度与
yearMaxTemp比较。如果更高,则更新yearMaxTemp和对应的日期。 - 处理完所有文件后,我们就得到了年度最高温度及其发生的日期。

总结
本节课中我们一起学习了如何分析CSV文件中的数值数据。我们从将字符串转换为数字的基础开始,然后实现了找出单日最高温度的算法。最后,我们探讨了如何通过遍历多个文件将这个解决方案扩展到找出年度最高温度。这些技能是数据分析的基础,可以应用于天气数据之外的许多领域。
050:字符串转数字 📝

在本节课中,我们将要学习如何处理CSV文件中的数值数据。具体来说,我们将探讨为什么从CSV读取的数值数据是字符串类型,以及如何将其转换为整数或浮点数以便进行数值计算。


为什么需要转换? 🤔

上一节我们介绍了CSV文件的基本结构。在处理CSV文件时,你可能会遇到各种数值数据,例如年份。然而,用于读取CSV文件的库会将所有数据作为字符串读入。这意味着像“1493”这样的数据会被视为字符序列,而不是整数1493。如果你希望对数据进行数值操作,例如相加或寻找最大值,就需要将数据作为int或double类型来处理。
类型不兼容错误 ⚠️

如果你尝试编写类似 int value = rh.get("year"); 的代码,在编译时会遇到错误。错误信息会指出类型不兼容:你正尝试将一个字符串赋值给一个整型变量。
查看CSV阅读器的文档,你会发现get方法的返回类型是String。因此,表达式rh.get("year")的类型是String,而变量value被声明为int类型。Java无法自动将字符串转换为整数,这引出了两个问题:为什么这是一个问题,以及如何解决它。

为什么不能自动转换? 🔍
Java不能自动将字符串转换为整数,原因有几个。首先,字符串可能不包含数字字符。例如,如果CSV阅读器读取到字符串“hello”,Java无法将其有意义地转换为数字。其次,字符串“1493”和数字1493在计算机内部的表示方式完全不同。类型描述了数据的表示和解释方式,因此在这两种表示之间转换需要执行特定的算法。
如何转换字符串到数字? 🛠️

为了解决这个问题,我们需要显式地调用代码来执行转换算法。Java已经内置了这样的功能。以下是转换字符串到数字的方法:
- 转换为整数:使用
Integer.parseInt(String s)方法。你传入想要转换的字符串,它会返回对应的整数值。int number = Integer.parseInt("1493");

- 转换为浮点数:使用
Double.parseDouble(String s)方法。它可以处理带有小数部分的字符串。double number = Double.parseDouble("42.56");
处理无效输入 🚨
使用这些方法时需要注意,如果传入的字符串无法表示一个有效的数字(例如“hello”),方法会抛出一个异常(Exception)。异常表明发生了错误,通常会导致程序崩溃。虽然可以通过更高级的技术来处理异常,防止程序崩溃,但本课程暂不深入讨论。

总结 📚

本节课中我们一起学习了如何处理CSV中的数值数据。你现在知道了不能直接将字符串赋值给整型变量。如果需要将字符串转换为整数,应该使用 Integer.parseInt() 方法;对于实数,则使用 Double.parseDouble() 方法。掌握这些知识后,你就可以自如地处理包含数字的CSV数据了。
051:算法开发

在本节课中,我们将学习如何开发一个算法,用于从数据文件中找出最高温度。我们将从一个具体的手动计算例子开始,逐步抽象出通用的算法步骤,并最终为编写代码做好准备。

从具体例子开始
上一节我们介绍了寻找一年中最高温度的整体问题。本节中,我们来看看如何解决其中的一个子问题:从单日数据文件中找出最高温度。
我们首先通过一个手动计算的小例子,一步步地理解这个过程。这里我们有六行数据可供处理。

以下是处理这六行数据的步骤:
- 查看第一行,特别是其温度值(30华氏度)。这是我们目前看到的最高温度,需要记录下来。
- 查看第二行,其温度值(29.1)不大于我们目前记录的最高温度。
- 查看第三行,其温度值(30.9)大于目前记录的最高温度,因此将最高温度更新为第三行的值。
- 查看第四行,其温度值(32)大于目前记录的最高温度,因此将最高温度更新为第四行的值。
- 查看第五行,其温度值(31.2)不大于目前记录的最高温度。
- 查看第六行,其温度值(33)大于目前记录的最高温度,因此将最高温度更新为第六行的值。
处理完所有行后,我们找到了答案:第六行数据。

抽象与归纳算法

现在我们已经为这个具体问题实例写下了所有步骤,接下来可以寻找模式并进行归纳,形成通用算法。
你可能会注意到,对于CSV文件的每一行,我们执行的操作相似但不完全相同。可以预见,最终我们将编写循环遍历行的代码来解决这个问题。但在那之前,我们需要思考这些操作的差异,并找到方法使它们统一。
第一个差异在于处理第一行时,我们只是简单地将其记录为当前最高温度;而对于后续行,我们需要将当前行与之前记录的最高温度进行比较。第一行比较特殊,因为没有其他行可供比较。我们隐含地执行了一个检查:判断当前记录的最高温度是否为空。我们需要将这个隐含步骤纳入通用算法。
另一个差异是,有时我们会更新记录的最高温度,有时则不会。我们标记了第一行的更新(紫色),以及未更新(红色)和更新(绿色)的步骤。其中的规律是:当当前行的温度高于当前记录的最高温度时,我们就进行更新。
通过思考这些模式,我们得出以下关于何时更新最高温度的判断逻辑:
- 如果
largestSoFar为空(即我们还没有记录任何值),那么当前行就是largestSoFar。 - 否则,如果当前行的温度大于
largestSoFar的温度,那么当前行就是largestSoFar。
基于以上思考,我们可以用算法术语来表达:
- 将
largestSoFar初始化为空。 - 对于CSV文件中的每一行(我们称之为
currentRow):- 根据上述判断逻辑决定如何更新
largestSoFar变量。
- 根据上述判断逻辑决定如何更新
- 在处理完所有行之后,
largestSoFar就是答案。
测试算法逻辑
在尝试编写代码之前,我们应该测试一下这个算法。尝试在下面这四行数据上运行该算法,看看它是否能给出正确答案。
以下是测试数据行:
- 行1:温度 25
- 行2:温度 28
- 行3:温度 26
- 行4:温度 30

算法给出的正确答案是第四行(温度30)。通过这个测试,我们更有信心确认算法逻辑是正确的,现在可以准备将其转化为代码了。

本节课中我们一起学习了如何从具体实例出发,通过手动计算、步骤记录、模式归纳和逻辑抽象,逐步开发出一个用于寻找最高温度的通用算法。这个过程是算法设计的基础,为我们下一步编写实际的Java代码做好了充分准备。
052:无对象时的null 🧩

在本节课中,我们将要学习Java中一个非常重要的概念:null。我们将探讨null的含义、它的用途、如何检查它,以及在使用它时需要注意的陷阱。理解null对于编写健壮的算法至关重要,尤其是在处理可能不存在的数据时。

什么是 null? 🤔
在编写从CSV文件中查找最高温度的算法时,我们写下了一些步骤,这些步骤对应着Java中一个尚未介绍的概念:“无” 的概念。如何将“没有东西”这个想法转化为代码呢?
这引出了Java中一个重要的新概念:null。在Java以及许多其他编程语言中,null意味着“无”或“没有对象”。这个概念非常重要,因为在算法中,经常需要引用一个“不存在”的值。
null 的用途 🛠️

null主要有两种用途。
1. 初始化变量
你可以用null来初始化一个变量。例如,代码 CSVRecord largestSoFar = null; 意味着将 largestSoFar 初始化为“不存在任何东西”。

2. 表示不存在的答案
当算法需要表示“答案不存在”或“没有这样的东西”时,从方法中返回null是一种恰当的表示方式。

检查 null ✅

在算法中,检查一个表达式是否等于null非常有用。你可以使用 == 操作符进行检查。

在正在编写的算法中,你需要检查 largestSoFar 是否为空,即 if (largestSoFar == null)。
null 的陷阱:空指针异常 ⚠️

有一件事你不能对null值做:调用它的方法。因为null意味着没有对象,尝试在一个“不存在的东西”上调用方法是毫无意义的。
例如,以下代码虽然可以编译通过,但存在问题:
CSVRecord record = null;
String value = record.get("Temperature");
第二行代码在运行时会导致程序崩溃。

你会收到类似这样的错误信息:
Exception in thread "main" java.lang.NullPointerException
这个错误信息告诉你程序出了问题。

Exception通常意味着你的程序出现了错误。java.lang.NullPointerException表示你试图对null执行需要一个实际对象才能完成的操作,在本例中就是尝试调用它的方法。
null 的类型之谜 🔍
在讨论null时,请记住所有表达式都有类型。之前我们学过,在编写和思考代码时,了解表达式的类型非常重要。这就引出了一个重要问题:null是什么类型?
我们写了 CSVRecord largestSoFar = null; 并告诉你这是合法的。那么,null的类型似乎是CSVRecord。但这真的合理吗?Java会被设计成“无”的类型是CSVRecord吗?我们难道不应该也能为其他类型表示“无”吗?
实际上,Java有一个特殊的null类型。与其他类型不同,你无法在程序中写出这个类型的名称。你不能声明这种null类型的变量,也不能创建返回类型是这种特殊类型的方法。

字面量null是一个特殊类型,并且这个类型可以转换为任何对象类型。也就是说,Java允许你将它赋值给任何对象类型的变量,从返回类型是对象类型的方法中返回它,或者将它与其他任何类型的对象进行比较。



原始类型与对象类型 📦

你可能注意到我们刚才说的是“任何对象类型”,而不是“任何类型”。这是因为Java的类型分为两大类:原始类型和对象类型。
原始类型不能为null。
到目前为止,你已经见过四种原始类型:int、double、char和boolean。还有另外四种你没见过的:byte、short、long和float。这些类型是Java内置的,它们只是纯数据,没有任何与之关联的方法,并且不能为null。
另一类是对象类型,它可以为null。
到目前为止,你已经见过许多对象类型,例如FileResource、String、CSVRecord和Pixel等等。通常,任何包含方法的类型都是对象类型。同样,你编写的任何类也都是对象类型。

原始类型和对象类型之间还有其他一些区别,但目前与我们无关,你将在以后学习它们。
总结 📝
本节课中,我们一起学习了Java中的null值。我们了解到:
null表示“无对象”或“不存在”。- 它常用于初始化变量或表示方法没有有效返回值。
- 可以使用
==操作符来检查一个变量是否为null。 - 在
null上调用方法会导致NullPointerException运行时错误。 null本身有一个特殊的类型,它可以被赋值给任何对象类型的变量。- Java的类型分为原始类型(如
int,double,不能为null)和对象类型(如String,CSVRecord,可以为null)。

理解并正确使用null是避免常见编程错误、编写更可靠代码的关键一步。
053:最高温度算法代码实现 🧑💻

在本节课中,我们将把上一节设计的“寻找最高温度”算法转化为实际的Java代码。我们将逐步构建一个方法,该方法能解析CSV文件,并找出温度最高的那条记录。
上一节我们介绍了寻找最高温度的逻辑算法,本节中我们来看看如何用Java代码来实现它。
我已经为大家设置好了类的基本结构,包括常用的导入语句、类名以及我们将要编写的方法 hottestHourInFile。我们将从上一视频结束时开发的伪代码开始编写。
首先,伪代码指出“将‘目前最大’初始化为空”。在Java中,“空”用 null 表示。
CSVRecord largestSoFar = null;
因此,我将 largestSoFar 的初始值设为 null。它是一个 CSVRecord 类型的变量,这也正是我们计划最终要返回的结果。
接下来,伪代码说“对于CSV文件中的每一行”。这又是我们本课程中一直使用的熟悉的迭代模式。
for (CSVRecord currentRow : parser) {
// 处理每一行的逻辑将放在这里
}
我们将使用作为参数传入的 parser 对象作为迭代遍历的CSV数据源。我先在这里把循环结构搭建好,以便明确在遍历每条记录时需要做什么。
以下是循环内部需要处理的核心逻辑。
首先,检查“目前最大”是否为空。这是我们第一次遇到这种情况。

if (largestSoFar == null) {
largestSoFar = currentRow;
}
我们检查 largestSoFar 是否为 null。如果是,我们就假设当前记录(currentRow)的温度是最高的,因此将 largestSoFar 赋值为 currentRow。这个 if 语句的处理就完成了。
否则(在Java中即 else ),我们需要直接比较两个温度值。
这意味着我需要从记录中提取出温度值。我知道这些值是数字,但我需要决定它们应该是整数(int)还是双精度浮点数(double)。没错,应该是 double,因为在我们之前的例子中有一个温度是30.9,这是一个实数,更适合用 double 表示。
else {
double currentTemp = Double.parseDouble(currentRow.get("TemperatureF"));
double largestTemp = Double.parseDouble(largestSoFar.get("TemperatureF"));
// 比较逻辑将放在这里
}
我通过 currentRow.get("TemperatureF") 获取当前行的温度字符串,并使用 Double.parseDouble() 将其转换为 double 类型的 currentTemp。对于 largestSoFar 也进行同样的操作,得到 largestTemp。
现在,我有了两个 double 值,可以直接进行比较。
if (currentTemp > largestTemp) {
largestSoFar = currentRow;
}
}
我检查 currentTemp 是否大于 largestTemp。如果是,就用 currentRow 替换 largestSoFar 中保存的记录。
这样,我们就处理了循环内伪代码中提到的两种情况。接下来,移动到循环外的下一行伪代码:“‘目前最大’即为答案”。

return largestSoFar;
因此,我返回 largestSoFar。
现在,我将编译我的代码,以确保没有犯任何低级语法错误。编译器提示没有语法错误,这意味着代码可以准备进行测试了。


本节课中我们一起学习了如何将“寻找最高温度”的算法伪代码逐步翻译成可运行的Java代码。我们实现了初始化变量、遍历CSV记录、处理首次比较和后续直接比较的逻辑,并最终返回结果。代码已经编译通过,为接下来的测试做好了准备。
054:代码测试

在本节课中,我们将学习如何测试用于查找CSV文件中最高温度的Java代码。我们将查看数据在文件系统中的组织方式,并运行测试来验证我们的程序是否正确工作。
数据文件结构

上一节我们介绍了如何编写查找最高温度的方法。本节中,我们来看看数据文件是如何组织的,以便更好地理解我们的代码在处理什么。

数据被组织在一个文件夹中。该文件夹按年份划分,每个年份文件夹内包含该年每一天的天气数据文件。
以下是数据组织的示例:
data/
├── 2014/
│ ├── weather-2014-01-01.csv
│ ├── weather-2014-01-02.csv
│ └── ...
├── 2015/
│ ├── weather-2015-01-01.csv
│ ├── weather-2015-01-02.csv
│ └── ...
└── ...
每年有365或366个文件,具体取决于是否为闰年。
单个数据文件示例

了解了整体结构后,我们来看看单个文件的内容。这有助于我们理解代码解析的数据格式。
这里以2015年1月1日的文件为例。CSV文件顶部是列信息,下方是每列对应的数据行。这些文件可以方便地在电子表格中打开和查看,以便直观地理解数据结构。
测试方法 testHottestInDay
为了确保我们的代码正确,我们将使用一个预先编写好的测试方法。这个方法封装了测试单个文件最高温度查找功能的流程。
CSVMax 类中已经包含了一个名为 testHottestInDay 的方法。以下是该方法的核心步骤:

- 第一行代码创建一个指向2015年1月1日数据文件的
FileResource对象。FileResource fr = new FileResource("data/2015/weather-2015-01-01.csv"); - 第二行代码调用我们刚刚编写的
hottestHourInFile方法,并传入为该数据集创建的解析器。CSVRecord largest = hottestHourInFile(fr.getCSVParser()); - 该方法返回温度最高的那条
CSVRecord。 - 最后,我们打印出最高温度及其出现的时间,以验证结果。
System.out.println("hottest temperature was " + largest.get("TemperatureF") + " at " + largest.get("TimeEST"));
运行这个测试可以让我们判断程序是否正确。
执行测试与验证
现在,让我们实际编译并运行代码,看看测试结果是否与文件中的数据一致。

首先编译代码。然后在BlueJ环境中创建一个新的 CSVMax 对象,并调用 testHottestInDay 方法。

测试结果显示,2015年1月1日的最高温度是 51.1°F,出现在 下午2:51。我们打开对应的数据文件查看温度列,发现温度值在20多度到50多度之间变化,其中确实有一个 51.1 的数值,证实了代码结果的正确性。
测试第二个文件

为了进一步确保代码的健壮性,我们不应该只满足于一次测试。接下来,我们更换一个数据文件进行测试。
将测试方法中的文件路径从1月1日改为1月2日。重新编译代码,创建一个新的 CSVMax 对象,并再次运行 testHottestInDay。
这次,程序输出最高温度为 54°F,出现在 下午12:51。我们检查1月2日的数据文件,发现温度列中确实出现了多个54度的记录。我们的代码正确地返回了第一个出现54度的记录时间(12:51 PM),而不是最后一次出现的时间(3:51 PM)。
总结

本节课中我们一起学习了如何测试查找CSV文件最高温度的Java代码。我们首先了解了数据文件的组织结构和格式。然后,我们使用 testHottestInDay 方法对代码进行了测试,并分别在2015年1月1日和1月2日的数据文件上验证了结果的正确性。通过两次成功的测试,我们可以对代码的正确性有合理的信心。建议你尝试测试更多的文件,以进一步巩固理解并确保代码在各种情况下的可靠性。
055:多数据集最高温度分析 🔥

在本节课中,我们将学习如何从多个数据文件中找出最高温度记录。我们将扩展之前单日最高温度查找的功能,使其能够处理一个日期范围内的多个CSV文件。
上一节我们介绍了如何从单个CSV文件中找出最高温度。本节中,我们将编写一个方法,循环处理多个文件,并找出所有文件中的最高温度记录。
概述

我们将创建一个名为 hottestInManyDays 的新方法。该方法使用 DirectoryResource 来允许用户一次性选择多个文件进行分析。核心思路是遍历每个选中的文件,调用之前编写的单文件最高温度查找方法,并在循环中比较和更新找到的最高温度记录。
方法实现步骤
以下是实现 hottestInManyDays 方法的具体步骤。
首先,我们创建方法并使用 DirectoryResource 选择文件。

public CSVRecord hottestInManyDays() {
DirectoryResource dr = new DirectoryResource();
CSVRecord largestSoFar = null;


接下来,我们遍历选中的每一个文件。
for (File f : dr.selectedFiles()) {
FileResource fr = new FileResource(f);
CSVRecord currentRow = hottestHourInFile(fr.getCSVParser());

在循环内部,我们需要将当前文件中的最高温度与迄今为止找到的最高温度进行比较。以下是处理比较的逻辑。

- 如果
largestSoFar为null(即这是第一个被处理的文件),则直接将当前记录赋值给它。 - 否则,我们需要比较当前记录的温度与
largestSoFar记录的温度。

if (largestSoFar == null) {
largestSoFar = currentRow;
} else {
double currentTemp = Double.parseDouble(currentRow.get("TemperatureF"));
double largestTemp = Double.parseDouble(largestSoFar.get("TemperatureF"));
if (currentTemp > largestTemp) {
largestSoFar = currentRow;
}
}
}
return largestSoFar;
}
代码测试
现在我们已经完成了方法的编写,接下来对其进行测试以确保其正确性。

我们首先创建一个测试方法,使用一个小的数据集(例如2015年的头两天)进行验证。我们知道这两天的最高温度分别是51.1和54,因此方法应该返回54以及对应的日期(1月2日)。
public void testHottestInManyDays() {
CSVRecord largest = hottestInManyDays();
System.out.println("hottest temperature was " + largest.get("TemperatureF") +
" at " + largest.get("DateUTC"));
}


运行测试后,如果结果符合预期(输出54和1月2日),则证明代码在小数据集上工作正常。
在获得初步信心后,我们可以将其应用于更大的数据集,例如2014年全年的数据。运行程序后,它成功找出了该年的最高温度记录(例如98.1度,发生于7月8日)。


总结
本节课中我们一起学习了如何扩展程序功能以处理多个数据文件。我们实现了 hottestInManyDays 方法,它通过循环调用单文件处理函数并结合比较逻辑,最终找出多个文件中的全局最高温度记录。我们遵循了先在小规模数据上验证、再扩展到大规模数据的测试方法,确保了代码的可靠性。
056:重构版本 🛠️

在本节课中,我们将学习一个重要的编程实践:代码重构。我们将通过一个具体的例子,了解如何识别并消除代码中的重复部分,从而提高代码的可维护性和健壮性。
概述
上一节我们实现了寻找最高温度的功能。虽然代码功能已经完成,但其中存在一个常见问题:代码重复。本节中,我们将学习如何通过提取公共代码到一个独立的方法中来解决这个问题,这个过程称为重构。
识别问题:代码重复
从功能上看,代码已经完成。但令人不适的是,在两个不同的方法中出现了大量重复的代码。
我知道代码重复是因为我在两个方法之间进行了复制和粘贴。在编程中,这不是一种好的实践。

因为如果那段代码存在问题,问题现在会在多个地方出现。你将不得不在所有不同的地方尝试修复它。
解决方案:提取公共方法


因此,我想做的是提取出那段公共代码,并将其放入一个独立的方法中。这里我将其命名为 getLargestOfTwo。这样我就可以在所有其他方法中重用那段代码。

我复制粘贴的部分是出现在“多日最高温”方法和“文件中最高温”方法中的同一个 if 语句。
以下是需要重构的重复逻辑的核心概念,它比较两个值并返回较大的一个:
if (largestSoFar == null || currentTemp > largestSoFar) {
largestSoFar = currentTemp;
}
我将从原方法中剪切这段代码,并将其放入 getLargestOfTwo 方法中,包括在最后返回“目前最大值”。
这样我就能得到正确的结果。我不需要对代码做任何修改,因为在我的特定案例中,所有变量命名都是一致的。两个方法都称当前值为 currentRow,最大值称为 largestSoFar。


largestSoFar 应该保持不变,除非它为 null,或者除非 currentTemp 更大。在这两种情况下,我应该更新 largestSoFar,否则 largestSoFar 就是正确的值。
实现重构方法

所以,我的方法将如下所示。它只包含那个 if-else 语句,以及它自己的返回语句。
private double getLargestOfTwo(double currentVal, double largestSoFar) {
if (largestSoFar == null || currentVal > largestSoFar) {
return currentVal;
} else {
return largestSoFar;
}
}

应用重构方法
现在,我可以在原方法中使用它。将原来的 if 语句替换为一行代码:

largestSoFar = getLargestOfTwo(currentRow, largestSoFar);

这行代码将完成 if 语句的工作,并将结果存储在那里。
同样地,我现在可以将这一行代码复制并粘贴到“多文件最高温”方法中,再次替换掉相同的 if-else 逻辑。
我从文件中获取当前行,然后获取两者中的较大值。
编译与测试

现在让我们继续编译,以确保一切正常。哦,我是不是拼错了什么?没有大写?

然后,因为我复制粘贴了它,所以我必须修复两次,这正是我之前警告过你的问题。所以现在,希望在我修复了这两处之后,它能编译通过。

现在我们必须返回去测试它,以确保一切都是正确的。我将创建一个新的 CSV 文件进行测试,因为即使我移动了代码,它也不应该改变功能,但我想实际确认这一点。

我将测试“单日最高温”。果然,它给出了 2015年1月2日的 54 度。

我将调用“多日最高温”方法,并返回去选择我们一直用于测试的那两天。果然,它仍然认为是 54 度。
总结
通过这次测试,我对移动代码所做的更改没有影响功能感到满意。但现在我对我的代码更有信心了,因为那段被重复的大段代码,现在我已经处理好了,它只出现在一个地方。所以,如果我以后发现那段代码有问题,我可以回到那一个地方进行修复。

本节课中,我们一起学习了代码重构的核心价值:通过消除重复代码,将公共逻辑提取为独立方法,我们提升了代码的可维护性和可读性。记住,避免复制粘贴,时刻思考代码的复用性,是成为优秀程序员的重要一步。
057:CSV最大值总结 📊

在本节课中,我们将总结如何从多个CSV文件中找出最高温度值。我们将回顾涉及的核心概念,包括字符串到数字的转换、问题分解、算法设计以及Java中的null概念。

核心概念回顾
上一节我们介绍了如何从单个文件中找到最大值,本节中我们来看看如何将这些知识应用到多个文件中,并总结所学的关键技能。

字符串到数字的转换
在处理CSV文件时,数据通常以字符串形式读取。为了进行数值比较(如找最高温度),我们需要将这些字符串转换为数字。Java提供了两种主要方法:
- 使用
Integer.parseInt()将字符串转换为整数。 - 使用
Double.parseDouble()将字符串转换为双精度浮点数。
以下是转换的代码示例:
String numberStr = “42”;
int intValue = Integer.parseInt(numberStr); // 转换为整数 42
double doubleValue = Double.parseDouble(numberStr); // 转换为浮点数 42.0

问题分解策略
解决复杂问题的有效策略是将其分解为更小、更易管理的子问题。在本例中,我们遵循了以下步骤:
- 首先,解决从单个CSV文件中找出最大值的问题。
- 然后,在此基础上构建解决方案,实现从多个CSV文件中找出最大值。

寻找最大值的算法
我们设计并实现了一个算法来在一组数据中寻找最大元素。该算法的核心思路是:
- 初始化一个变量(如
maxSoFar)来存储当前找到的最大值。 - 遍历数据集中的每一个元素。
- 将每个元素与
maxSoFar进行比较。 - 如果当前元素大于
maxSoFar,则更新maxSoFar的值为当前元素。 - 遍历完成后,
maxSoFar中存储的就是最大值。

理解 null 概念
在Java中,null是一个特殊的值,用于表示“无”或“不存在此类对象”。在我们的程序中,当初始化一个可能暂无值的对象引用时,可能会使用null。例如,在开始读取文件前,用于存储最大温度值的变量可以初始化为null,表示尚未找到任何有效值。
总结


本节课中我们一起学习了从CSV文件中处理和分析数据的关键技能。我们掌握了如何将字符串转换为数字,实践了通过分解大问题来简化解决过程的策略,设计并实现了寻找数据最大值的算法,同时也理解了null在Java中代表“空”或“无”的含义。这些是进行数据处理和算法实现的基础。
058:婴儿姓名迷你项目概览 👶

在本节课中,我们将学习本课程迷你项目的背景知识和所需编写的代码。你将编写程序来回答一些使用电子表格很难解答的问题,但通过运用本课程中学到的实践、技能和库,你将能够以直接的方式处理这些问题。
项目目标:你的名字今年是什么?🤔

你将回答的问题是:根据你出生那年的名字,你今年的名字是什么? 换句话说,在今年,哪个名字的流行度排名与你出生那年你的名字的排名相同?
你可以为任何人的名字回答这个问题,无论是朋友的名字、你喜欢的歌手还是任何人。我们将使用一些图片来描述你将要做的事情,这些图片取自一个提供类似功能的网站。
假设你是一位名叫Jennifer的女性,出生于1994年。Jennifer在1994年是第21位最受欢迎的女孩名字。因此,你需要完成的一项任务是编写代码,找出给定年份中某个名字的排名。
从排名到新名字 🔄

如果你是1994年的Jennifer,那么你今天的名字会是什么?

通过编码,你会发现Grace是今天第21位最受欢迎的名字。这里的“今天”指的是2014年,这是我们拥有的关于美国婴儿命名数据的最新年份。
所以,如果你出生于1994年并取名Jennifer,那么你今天的名字将是Grace。

探索不同年代的名字 📅

但你也可以查看你在任何给定年份的名字。
这里我们看到一个摘要,显示了你在几个不同年代的名字。1994年的Jennifer,在20世纪70年代会是Barbara。这意味着Barbara在20世纪70年代这十年间(数据合并计算)是第21位最受欢迎的名字。

利用历史数据 📊

因为我们拥有美国追溯到19世纪80年代(130多年前)的数据,我们可以确定你很久以前的名字。

这里我们看到,如果你是1994年的Jennifer,你在20世纪初的名字会是Sarah。
数据处理与编程任务 💻

你需要编写程序来对我们在此概述的名字数据得出结论,将所有数据转化为信息。美国政府每年都会发布婴儿姓名数据。我们已收集这些数据,并将其作为本课程的一部分提供给你。我们很乐意得到你的帮助,为世界其他国家收集类似的数据。
我们拥有多年来的男性和女性数据,每年对应一个不同的数据文件。这些文件遵循一个命名约定,这在编写程序打开和读取文件时非常方便。在编写代码访问这数百个数据文件时,你将利用这个通用的命名约定。
文件内容也采用相似的格式,这将帮助你编写更通用的代码来解决这些问题。我们将简要查看2014年的数据文件,这是提供给你使用的最新数据。

文件中的行号按拥有特定名字的婴儿数量排序,因此最受欢迎的婴儿名字排在第一行,然后是第二受欢迎的,依此类推,正如你在这里看到的。2014年有20799名婴儿被命名为Emma,使其成为当年最受欢迎的女性婴儿名字。
文件结构解析 📁
在数据文件中,所有女性名字都排在男性名字之前。这意味着最受欢迎的男孩名字(2014年是Noah,有19144名男孩取名Noah)紧接在拥有最少女孩数的女孩名字之后(2014年是Ziona和Ziaah)。我们将概述访问单个文件中数据的高级概念,但你需要完成全部七个步骤来解决本迷你项目中要求你处理的问题。

在开发此问题的解决方案时,你将使用来自 edu.duke 和 org.apache.commons.csv 包中的几个类。例如,你需要一个 FileResource 对象来访问特定年份文件中的数据。
你需要通过调用 FileResource 类的 getCSVParser 方法来获取一个CSV解析器,并确保请求一个没有标题行的解析器。
FileResource fr = new FileResource("data/yob2014.csv");
CSVParser parser = fr.getCSVParser(false);
由于没有标题行,你需要通过索引来访问每条记录中的数据:索引0是文件中婴儿的名字,婴儿的性别是第二个数据元素。
for (CSVRecord record : parser) {
String name = record.get(0); // 获取名字
String gender = record.get(1); // 获取性别
// ... 处理数据
}

一旦掌握了这些,你就可以开始思考问题,并使用我们的七步流程来解决整个问题。


祝你编程愉快!😊
本节课总结:在本节课中,我们一起学习了“婴儿姓名”迷你项目的背景和目标。我们了解到,项目核心是通过编程分析历史婴儿姓名数据,找出与给定名字在出生年份排名相同的当前年份名字。我们预览了数据文件的结构和格式,并介绍了访问这些数据所需的关键Java类和方法,为后续动手编码奠定了基础。
059:婴儿姓名迷你项目数据概览 👶

在本节课中,我们将要学习如何为婴儿姓名迷你项目处理数据文件。我们将查看数据文件的结构,并编写一些初始代码来读取和探索这些数据,以便更好地理解我们将要解决的问题。
上一节我们介绍了迷你项目要解决的问题,本节中我们来看看具体的数据文件以及用于处理这些文件的代码。
数据文件初探
首先,我们将编写一个简单的程序来打印数据文件的基本信息,以便熟悉其内容并确保我们理解数据的结构。
以下是创建文件资源并解析CSV数据的基本步骤:
- 创建一个文件资源对象,并选择要打开的数据文件。
- 创建一个CSV记录解析器。我们将使用之前见过的CSV解析器,但有一个新的变化:在创建解析器时,我们将传入参数
false。这表示该CSV文件没有标题行。换句话说,文件的第一行就是我们要使用的实际数据,而不是列名。 - 遍历文件中的所有记录。目前,我们只是以一种清晰的格式打印出每条记录的基本信息,以便阅读和验证。我们在名字之间添加了空格以提高可读性。
与之前不同的是,我们将使用数字索引而非列名来访问信息。这是因为文件没有标题行,所以我们通过位置来获取值:索引 0 对应第一列,1 对应第二列,2 对应第三列。这些列包含了我们数据中的所有字段。
// 示例代码结构
FileResource fr = new FileResource("data/example.csv");
CSVParser parser = fr.getCSVParser(false); // false 表示无标题行
for (CSVRecord record : parser) {
String name = record.get(0);
String gender = record.get(1);
String numBorn = record.get(2);
System.out.println(name + " " + gender + " " + numBorn);
}

运行示例程序

这就是我们程序的第一个版本。接下来,我们将编译并运行它。我们将使用一个专门创建的简单示例数据文件来开始。
运行程序后,输出结果如下:
Emma female 500
Olivia female 400
Ava female 300
Sophia female 200
Eva female 100
Noah male 100
Liam male 90
Mason male 80
Jacob male 70
William male 60
从输出中可以看到:
- Emma 是第一个名字,性别为女性,在这个例子中有500个女婴被命名为Emma。
- 第二个是 Olivia,女性,有400个。
- 以此类推,直到 Eva,她是排名最后的女孩,有100个。
- Noah 是第一个出现的男性名字,有100个出生。因此他是出生最多的男婴,在男性出生排名中位列第一,尽管他在文件中是第六个名字。
所以,在示例文件中,所有女性名字排在前面,然后是所有男性名字,并且都按照各自的排名排序。在我们的示例文件中有5个女孩名字和5个男孩名字。

查看实际数据文件
你可以在电子表格中查看我创建的实际数据文件。同样,文件中有5个女性名字和5个男性名字。这个小规模的数据文件为我们提供了一些可以测试的数字。
这是一个2014年出生的实际数据文件示例。可以看到,Emma仍然是最受欢迎的,但实际数字是 20,799 个出生,而不是我编造的500个。
在这个具体的例子中,我计算了总数,以便了解这个文件有多大:
- 文件中总共有 33,044 个不同的名字,这意味着文件有33,044行,因为每个名字单独占一行。
- 其中 19,067 个是女孩名字,这意味着文件的前19,067行是女孩名字。
- 文件的第19,068个名字是 Noah,他是该年度排名最高的男孩,也是文件中后续出现的 13,977 个男孩名字中的第一个。
为了便于测试和理解,在我们的示例中,我们将使用前面提到的那个小文件。因为处理33,000行这样的大数字不太方便进行简单的测试。
一个简单的数据过滤尝试
接下来,我们尝试一个简单的操作:只打印出那些出生数量低于某个特定值的名字。

我们将从“出生数量”字段(字符串类型)中获取数值,并将其转换为整数。然后检查这个数值是否小于或等于某个阈值(例如100)。最后,只打印出那些满足条件的名字。

int threshold = 100;
for (CSVRecord record : parser) {
String name = record.get(0);
int numBorn = Integer.parseInt(record.get(2)); // 将字符串转换为整数
if (numBorn <= threshold) {
System.out.println(name);
}
}

现在,当我们用同一个小的测试数据文件编译并运行这个修改后的程序时,输出如下:
Ava
Noah
Liam
Mason
Jacob
William
可以看到,我们得到了 Ava(排名最后的女孩)和文件中所有男孩的名字,因为所有男孩名字的出生数量都小于100。


本节课中我们一起学习了如何开始处理婴儿姓名项目的CSV数据文件。我们了解了文件没有标题行的特点,学会了如何使用数字索引访问字段,并编写了代码来打印文件内容和根据出生数量过滤名字。这为我们后续解决更复杂的迷你项目问题打下了基础。
060:总出生数 👶

在本节课中,我们将学习如何从婴儿姓名数据文件中计算总出生数、男孩总出生数和女孩总出生数。我们将通过编写一个Java方法,遍历文件中的记录,并根据性别对出生数进行累加。
概述
我们已经了解了处理这些数据文件的基础知识。现在,我们将解决一个实际问题:计算婴儿的总出生数、男孩总出生数和女孩总出生数。为此,我们创建了一个名为 totalBirths 的新方法。
方法设计与实现
我们创建一个方法,它接收一个文件资源作为参数,而不是通过对话框选择文件,这便于后续测试。方法的基本思路与之前相同:遍历文件中的所有CSV记录。
以下是方法的核心代码结构:
public void totalBirths (FileResource fr) {
int totalBirths = 0;
int totalBoys = 0;
int totalGirls = 0;
for (CSVRecord record : fr.getCSVParser(false)) {
// 处理每条记录
}
// 打印结果
}

在循环开始前,我们声明并初始化了三个整型变量 totalBirths、totalBoys 和 totalGirls,初始值均为0,表示尚未统计到任何出生记录。

遍历记录与累加
在循环体内,我们需要从每条记录中获取出生数,并将其累加到总出生数中。同时,我们需要根据性别,将出生数分别累加到男孩或女孩的计数中。
以下是处理每条记录的逻辑:

int numBorn = Integer.parseInt(record.get(2));
totalBirths += numBorn;
if (record.get(1).equals("M")) {
totalBoys += numBorn;
} else {
totalGirls += numBorn;
}
这段代码首先从记录的第三个字段(索引2)获取出生数,并将其转换为整数。然后,将该数加到 totalBirths 中。接着,检查第二个字段(索引1)的性别信息:如果是“M”,则加到 totalBoys;否则,加到 totalGirls。
测试与验证
为了测试我们的代码,我们使用一个小的示例文件。我们编写一个测试方法来调用 totalBirths 并传入该文件资源。

运行测试后,程序输出总出生数为1700。通过手动计算示例文件中的数据(500+400+300+200+100+140+30+20+10),我们确认结果也是1700,这验证了代码的基本功能是正确的。

扩展测试与结果确认


为了进一步确认代码的准确性,我们使用一个更大的数据文件(例如2014年的数据)进行测试。


运行程序后,我们得到总出生数、女孩出生数和男孩出生数分别为3,671,516、1,768,775和1,901,376。通过在电子表格中进行相同的求和计算,我们得到了完全相同的数字,这使我们确信解决方案是正确的。
关于数据组织的说明


一个有趣的问题是:文件中男孩和女孩姓名的排列顺序是否影响我们的代码?答案是不影响。因为我们的代码在每次迭代时都会检查性别,所以无论男孩名在前、女孩名在后,还是交错排列,计算结果都是正确的。然而,在原始数据文件中,通常是所有女孩名在前,所有男孩名在后,这在处理排名相关的问题时会有所不同,但本程序不涉及排名。
总结

本节课中,我们一起学习了如何从婴儿姓名数据文件中计算总出生数、男孩总出生数和女孩总出生数。我们实现了一个Java方法,通过遍历CSV记录、累加出生数并根据性别进行分类,最终成功验证了计算结果的准确性。
061:多文件处理


欢迎回来。在本节课中,你将学习如何将图像转换为灰度图。这是一个通过编写两个程序并将它们组合成一个程序来解决实际问题的例子。
概述

在本节课中,我们将要学习如何批量处理图像文件,将它们转换为灰度图。我们将把这个大任务分解为几个小步骤,最终构建一个能选择多个文件、进行灰度转换并保存结果的高效程序。
为什么要进行灰度转换?
将图像转换为灰度图有几个原因。

你可能想看看图像在灰度打印时的效果。灰度打印比彩色打印便宜得多,并且一些出版物要求所有图像都转换为灰度图。
或者,你可能计划进行其他更复杂的图像处理。使用灰度图像可以简化甚至加速这些处理过程。

单文件转换 vs. 批量转换
如果你只需要转换一张图像,最简单的方法可能是使用图像编辑软件。你会打开想要转换的图像,然后使用软件创建一个灰度副本。
但是,如果你需要转换许多图像呢?

打开每张图像、将其转换为灰度然后保存,这个过程可能相当繁琐和耗时。对于少量图像,这可能不是大问题。但如果你需要对1000张图像进行操作,手动完成可能需要数天时间,并且很难坚持完成这种重复性任务。
相反,你可以编写一个程序来批量转换图像。具体来说,你可以让用户选择一组要转换的图像,对每个选中的图像执行灰度转换,然后使用相似的文件名保存结果。在我们的例子中,我们将在每个图像文件名前添加“gray-”前缀,以区分新的灰度副本和原始文件。
任务分解
这正是我们将在本节课中与你一起完成的任务。具体来说,我们将把这个大任务分解为几个较小的任务。
问题的一个方面是允许用户选择一组文件并对每个选中的文件执行操作。虽然最终目标是转换图像文件为灰度版本,但我们将从简单地打印选中的文件名开始,这是解决更大问题的一小步。
接下来,你将使用我们的七步流程来学习如何转换一张图像为灰度图。

之后,你将把前两个想法和程序组合成一个单一的程序,该程序允许用户选择多个文件并将每个文件转换为灰度图。

最后,你将使你的程序将结果保存到具有适当命名的新文件中。
总结
本节课中我们一起学习了如何将批量图像处理任务分解为可管理的步骤。我们从理解需求开始,然后学习了如何选择文件、处理单张图像,最终将这些功能组合成一个完整的批量灰度转换程序。通过这种方法,你可以高效地处理大量图像,而无需手动重复操作。
062:七步法 🖼️➡️⚫⚪

在本节课中,我们将学习如何将彩色图像转换为灰度图像。这是一个重要的图像处理过程,其核心在于理解并应用一个系统化的七步法来解决问题。我们将通过一个具体的例子,逐步拆解这个过程,最终得到一个通用的算法。
第一步:手动处理一个实例 🖐️
与所有编程问题一样,我们首先需要手动解决一个小规模的问题实例。这里,我们选择一个2x2像素的彩色图像作为输入。

我们需要创建一个同样大小的2x2图像作为输出。但关键问题是:如何确定输出图像中每个像素的灰度值?
在解决这个具体问题之前,你需要一些领域知识,即关于颜色或图形的知识。
首先,我们需要明确:什么是灰度?
一个颜色是灰度,当且仅当它的红色、蓝色和绿色分量值完全相同。
然而,仅凭这个知识不足以告诉我们如何为一个特定的颜色计算出对应的灰度值,它只告诉我们结果需要让红、绿、蓝分量相等。
一种方法是取红、绿、蓝三个分量的平均值。或者,你也可以决定使用加权平均,因为人眼对不同颜色的感知并不相同。当然,也可能有更复杂的替代方案。但简单地取平均值效果不错且简单。
现在,你具备了手动解决这个问题所需的知识。你可以查看一个像素的RGB值,计算其平均值,然后为输出像素填充相应的颜色。接着,对每个输入像素重复此过程:查看其RGB值,计算平均值,并相应地为输出像素着色。
当你为所有像素都填充了颜色后,你就完成了问题实例的手动求解,第一步也就完成了。
第二步:精确记录操作步骤 📝
接下来,你需要精确地写下你刚才所做的操作。
- 我从想要处理的图像开始,我们称之为输入图像。
- 我创建了另一个相同大小的图像,我们称之为输出图像。
- 我计算了 (255 + 0 + 0) / 3,结果是 83。
- 我将输出图像的第一个像素的红、绿、蓝值都设置为 83。
- 然后,我对其他每个像素,计算其红、绿、蓝的平均值,并相应地设置输出像素的颜色。
完成这些后,我们得到了解决这个特定问题实例的10个步骤。
第三步:寻找模式和重复 🔍
现在,你已准备好进入第三步:寻找模式和重复。
你可以看到,我们对每个像素做了非常相似的事情,但它们并不完全相同。我们需要在数字中找到模式。
为了将这些步骤推广到任何图像,让我们看看需要泛化的具体数字。为什么我们在这里使用255,在那里使用0?这些数字都是输入图像中对应像素的红色分量。那么这些数字呢?同样,这些是输入图像中对应像素的绿色分量。最后,这些数字是对应像素的蓝色分量。

接下来,你应该为这个数学计算的结果起个名字。它不会总是这些特定的数字,你需要能够精确地引用它。我们称之为 average。
第四步:编写通用算法 📋
好的,经过上述思考,你现在可以编写通用算法了。
请注意我们是如何思考对每个像素做什么,并写下通用步骤的。现在,我们可以将其写成针对输出图像中每个像素要执行的步骤。这个算法将适用于任何尺寸、任何颜色的图像。
以下是通用算法的步骤:
- 对于输出图像中的每一个像素 (i, j):
- 获取输入图像中相同位置 (i, j) 像素的红色分量值,记为
red。 - 获取输入图像中相同位置 (i, j) 像素的绿色分量值,记为
green。 - 获取输入图像中相同位置 (i, j) 像素的蓝色分量值,记为
blue。 - 计算平均值:
average = (red + green + blue) / 3。 - 将输出图像中位置 (i, j) 的像素的红、绿、蓝分量都设置为
average。
- 获取输入图像中相同位置 (i, j) 像素的红色分量值,记为

第五步:测试通用算法 ✅
在编写代码之前,你应该做的最后一件事是在另一个小型输入上测试你的通用算法。
这里有一个小图像及其每个像素的RGB值。花点时间执行算法,看看是否能得到正确答案。
测试结果正确,所以你已经准备好用代码实现它了。
总结 📚

本节课中,我们一起学习了应用“七步法”来解决将彩色图像转换为灰度图像的问题。我们从手动处理一个小实例开始,精确记录步骤,然后寻找模式并将其泛化为一个通用算法,最后对算法进行了测试验证。这个过程的核心是计算每个像素红、绿、蓝分量的平均值,公式为 average = (red + green + blue) / 3,并用这个平均值设置输出像素的颜色。掌握了这个方法,你就为用代码实现任何尺寸图像的灰度转换打下了坚实的基础。
063:灰度处理 🖼️➡️⚫⚪

在本节课中,我们将学习如何将彩色图像转换为灰度图像。我们将基于之前设计的算法,在BlueJ环境中编写具体的Java代码来实现这一功能。
上一节我们介绍了灰度转换的算法思路,本节中我们来看看如何将这些步骤转化为实际的Java代码。
我们已经创建了一个名为 GrayscaleConverter 的类,并导入了必要的库。在编写代码前,我们已经将算法步骤以注释的形式写在了代码中,作为编写指南。
我们算法的第一步是获取要处理的图像。在代码中,这将是我们传递给函数的一个参数,以便该函数可以处理任何我们指定的图像。
public ImageResource makeGray(ImageResource inImage) {
接下来,我们需要创建一个与输入图像尺寸相同的空白图像。
ImageResource outImage = new ImageResource(inImage.getWidth(), inImage.getHeight());

我们算法的第三步是遍历输出图像中的每一个像素。这需要一个 for 循环。
以下是循环的框架,我们将把对每个像素执行的操作放在花括号 {} 内。
for (Pixel pixel : outImage.pixels()) {
// 对每个像素执行的操作将写在这里
}
在循环体内,我们首先要找到输入图像中对应位置的像素。
Pixel inPixel = inImage.getPixel(pixel.getX(), pixel.getY());
然后,我们需要计算该对应像素的红、绿、蓝三个颜色通道值的平均值。
int average = (inPixel.getRed() + inPixel.getGreen() + inPixel.getBlue()) / 3;
计算出平均值后,我们将这个值同时设置为输出图像当前像素的红、绿、蓝通道值。

pixel.setRed(average);
pixel.setGreen(average);
pixel.setBlue(average);
以上步骤完成了对每个像素的处理。循环结束后,我们的最后一步是返回处理好的图像。

return outImage;
}
现在,我们可以点击编译按钮。如果底部显示“类编译完成,无语法错误”,则说明代码在语法上是正确的。

通常,我们通过在BlueJ主窗口创建对象并调用其方法来测试代码。但直接创建 ImageResource 对象进行测试有些复杂,因此我们将编写一个辅助测试方法。
public void testGray() {
ImageResource ir = new ImageResource(); // 这会弹出一个对话框让我们选择图片
ImageResource gray = makeGray(ir);
gray.draw();
}
再次编译代码,确保没有错误。遵守语言规则只是第一步,我们还需要验证代码是否能正确运行。
现在,我们转到BlueJ的主窗口。创建一个新的 GrayscaleConverter 对象。

然后,调用该对象的 testGray() 方法。程序会弹出一个对话框,让我们选择一张图片。例如,我们可以选择一张色彩鲜艳的图片进行测试。
运行后,我们将看到原图被成功转换成了灰度图像。通过这个测试用例,我们对代码的正确性更有信心了。通常,运行的测试用例越多,我们对代码正确性的信心就越足。

本节课中我们一起学习了如何在BlueJ中实现图像的灰度转换。我们首先回顾了算法步骤,然后将其逐句转化为Java代码,包括遍历像素、计算颜色平均值以及设置新像素值。最后,我们编写了一个测试方法来验证代码功能。通过实践,我们巩固了将算法思想转化为可执行代码的能力。
064:批量处理灰度转换 🖼️➡️⚫⚪
在本节课中,我们将学习如何将之前编写的单张图片灰度转换代码扩展为批量处理功能。我们将结合文件选择和循环迭代,实现对多张图片的自动灰度转换。
上一节我们实现了单张图片的灰度转换算法。本节中,我们来看看如何批量处理多张图片。
我们从一个已编写好的类开始,这个类包含了上一视频中实现的算法。我在底部添加了一个名为 selectAndConvert 的新方法,它将允许我们选择多个文件并全部进行转换。
以下是实现批量转换的核心步骤:
- 选择文件:使用
DirectoryResource类让用户选择多个图片文件。 - 循环处理:使用
for循环遍历选中的每一个文件。 - 转换与显示:对每个文件,读取为图片,应用
makeGray方法转换为灰度图,并绘制出来。
让我们看看具体的代码实现。首先,我们需要导入必要的包来处理文件操作:
import java.io.File;
接着,在 selectAndConvert 方法中,我们构建循环结构:
DirectoryResource dr = new DirectoryResource();
for (File f : dr.selectedFiles()) {
// 处理每个文件 f
}
在循环体内,我们将之前处理单张图片的步骤整合进来:
// 1. 从文件创建图片资源
ImageResource inImage = new ImageResource(f);
// 2. 调用灰度转换方法
ImageResource grayImage = makeGray(inImage);
// 3. 绘制转换后的图片
grayImage.draw();
这里,makeGray 方法就是我们之前实现的、用于计算灰度值的函数,其核心公式是像素灰度的平均值:
newRed = newGreen = newBlue = (red + green + blue) / 3
编译并运行此代码后,程序会弹出一个对话框,允许我们导航到图片目录并选择多个文件。选中后,程序会自动遍历每一张图片,将其转换为灰度图并依次显示在屏幕上。

通过本节课的学习,我们成功地将两个核心概念——文件批量选择和图片像素处理算法——结合在了一起。我们编写了一个能够自动将用户选定的任意数量彩色图片转换为灰度图的程序。
总结来说,本节课我们一起学习了:
- 如何使用
DirectoryResource和循环来批量处理文件。 - 如何在循环中调用已封装好的图片处理函数(
makeGray)。 - 如何将理论算法(灰度转换公式)应用于实践,实现自动化批量操作。

下一步,我们将学习如何将这些生成的灰度图片保存到文件中,而不仅仅是显示在屏幕上。
065:使用新名称保存图像 📸

在本节课中,我们将学习如何编写一个Java程序来读取计算机上的图像文件,并创建它们的副本。我们将使用特定的库来选择和操作图像文件,最终实现以新名称保存图像副本的功能。

概述
我们将创建一个名为 ImageSaver 的Java类。这个类将包含一个 doSave 方法,用于从指定目录选择图像文件,为每个文件生成一个带有“copy-”前缀的新文件名,然后保存这个新文件。整个过程涉及文件选择、字符串操作和文件保存。
创建 ImageSaver 类
首先,我们需要创建一个新的Java类。我们将其命名为 ImageSaver。
public class ImageSaver {
// 类的内容将在这里编写
}
实现 doSave 方法
上一节我们创建了类的基本结构,本节中我们来看看如何实现核心的 doSave 方法。这个方法将完成选择文件和保存副本的主要逻辑。
在 ImageSaver 类中,我们创建一个名为 doSave 的方法。
public void doSave() {
// 方法的具体实现将在这里编写
}
选择图像文件
为了选择文件,我们将使用 DirectoryResource 类。这个类允许我们通过图形界面从目录中选择文件。

以下是实现选择文件功能的步骤:
- 创建一个
DirectoryResource类型的变量。 - 使用
selectedFiles方法获取用户选择的文件列表。 - 使用
for循环遍历每一个选中的文件。
DirectoryResource dr = new DirectoryResource();
for (File f : dr.selectedFiles()) {
// 对每个文件 f 进行处理
}

加载和显示图像
在成功获取文件列表后,我们需要将每个文件加载为图像并显示它,以验证程序运行正常。
以下是加载和显示图像的步骤:
- 为当前文件
f创建一个ImageResource对象。 - 调用
draw方法在屏幕上显示图像。
ImageResource image = new ImageResource(f);
image.draw();
生成新的文件名
在确认可以成功加载图像后,下一步是为副本生成一个新的文件名。我们希望新文件名在原文件名前加上“copy-”前缀。
以下是生成新文件名的步骤:

- 使用
getFileName方法获取原始图像的文件名。 - 使用字符串拼接,创建以“copy-”开头的新文件名。
String fname = image.getFileName();
String newName = "copy-" + fname;

保存图像副本
最后一步是使用新文件名保存图像。这需要两个操作:设置图像对象的新文件名,然后将其保存到磁盘。

以下是保存图像副本的步骤:
- 使用
setFileName方法为ImageResource对象设置新的文件名。 - 调用
save方法将图像保存为新文件。
image.setFileName(newName);
image.save();
将以上所有步骤整合到 for 循环中,完整的 doSave 方法就完成了。

完整代码示例

以下是整合了所有步骤的 ImageSaver 类的完整代码。请确保已正确导入所需的库。
import edu.duke.*;
import java.io.File;
public class ImageSaver {
public void doSave() {
DirectoryResource dr = new DirectoryResource();
for (File f : dr.selectedFiles()) {
ImageResource image = new ImageResource(f);
image.draw(); // 可选:显示原图
String fname = image.getFileName();
String newName = "copy-" + fname;
image.setFileName(newName);
image.save(); // 保存为新文件
}
}
}


运行程序
要运行此程序,请执行以下步骤:
- 编译
ImageSaver.java文件。 - 创建一个
ImageSaver对象。 - 调用该对象的
doSave方法。 - 在弹出的对话框中选择一个包含图像的目录,并选择一个或多个图像文件。
- 程序将为每个选中的图像创建一个名为“copy-原文件名”的新文件。
总结
本节课中我们一起学习了如何编写一个Java程序来复制图像文件。我们掌握了以下核心技能:
- 使用
DirectoryResource类交互式地选择文件。 - 使用
ImageResource类加载和操作图像。 - 通过字符串操作生成新的文件名,格式为
"copy-" + originalFileName。 - 使用
setFileName和save方法将图像保存为新文件。

通过这个简单的程序,你可以轻松地为任何图像文件创建副本。
066:多文件处理
在本节课中,我们将一起回顾如何编写一个Java程序,以批量处理的方式将多张图片文件转换为灰度图。我们将学习如何将复杂问题分解为更小的、可逐步开发的模块,并最终将它们组合成一个完整的程序。

概述
上一节我们介绍了图像处理的基本概念,本节中我们来看看如何将单张图片的处理逻辑扩展到批量处理多张图片。我们将总结在开发过程中学到的关键技能,包括问题分解、迭代编程以及文件命名处理。
核心学习内容
在本次课程中,我们主要掌握了以下三个核心编程技能。
1. 问题分解与增量开发
将一个大问题分解成多个小部分,并编写对应的独立程序,这是一种非常有效的开发策略。我们可以先让每个小部分程序正确运行,然后再将它们组合起来。
以下是实施增量开发的典型步骤:
- 首先,明确程序的最终目标(例如:批量转换图片为灰度)。
- 其次,将目标分解为独立的子任务(例如:1. 选择多个文件;2. 转换单张图片;3. 保存新图片)。
- 然后,为每个子任务编写并测试独立的代码模块。
- 最后,将所有通过测试的模块整合到主程序中。
2. 数据迭代实践
我们通过两种场景练习了迭代代码的编写。
首先,我们使用 DirectoryResource 类来遍历用户选择的所有文件。这允许程序一次性处理多个输入文件。
其次,在单张图片处理模块中,我们编写了遍历图像中每一个像素的代码,并应用灰度转换公式。计算灰度值的核心公式如下:
gray = (red + green + blue) / 3
或者更精确的加权公式:
gray = 0.299 * red + 0.587 * green + 0.114 * blue
3. 系统化解决问题与细节完善
通过将图像转换为灰度,我们再次实践了使用“七步法”来解决编程问题。这有助于我们逻辑清晰、步骤明确地构建程序。
最后,我们通过让程序以与原文件名相似的新文件名保存处理后的图片,为批量灰度转换程序添加了收尾工作。例如,如果原文件名为 cat.jpg,新文件可能被保存为 gray-cat.jpg。这个细节提升了程序的实用性和用户体验。
总结

本节课中我们一起学习了如何构建一个功能完整的批量图片处理程序。这是一个相对复杂的程序,你成功完成了从问题分解、迭代编码到细节完善的全过程,值得为自己感到骄傲。掌握这种系统化的开发方法,将为你未来解决更复杂的编程挑战打下坚实的基础。
Java编程与软件工程基础:1:欢迎 👋
在本课程中,我们将学习Java编程、数组列表和结构化数据。课程结合了真实世界的数据分析,并引入了安全和密码学的相关知识。无论这是你与我们的第一次课程,还是作为Java编程与软件工程专业课程的一部分继续学习,你都将使用一个七步流程来设计和实现Java程序,以解决问题并磨练技能。



我们设计了一系列结合了真实世界数据分析的编程问题,并引入了安全和密码学的课程。
无论这是你第一次参加我们的课程,还是作为“Java编程与软件工程导论”专业课程的一部分继续学习,你都将使用一个七步流程来帮助设计和实现Java程序,以解决问题并磨练成为一名高效软件工程师所需的技能。
在解决我们设计的问题时,你将学习数组和映射。
这是两种标准的数据结构,用于创建高效、健壮的程序来解决问题。作为密码学课程的一部分,你将学习单词“melon”和“cubed”如何通过数字16联系起来。
你好,我是Drew。
在本课程中,你将使用我们设计的Edu Duke类库来编写程序,解决有趣的问题,例如分析网络博客和从模板生成随机故事。
同时,你也将使用标准的Java U类库,这将有助于你在使用Java为这些问题创建解决方案时增长知识和技能。
理解API以便使用库中的代码是本课程的重要组成部分。
同样重要的是开始培养对面向对象编程的理解。在本课程中,你将学习类的结构以及如何通过策略性地组合类来创建程序。
你还将学习单词“fusion”和“layout”如何通过数字20联系起来。
你好,我是Robert。
我们为本课程设计了一个激动人心的迷你项目,以帮助你更深入地了解类、面向对象和数据结构。


你将使用几乎所有为解决大规模问题而设计的Java程序中都包含的标准技术和库。
我们构建了本课程的模块,先介绍主题,然后通过更详细、更健壮、可扩展的解决方案来探索它们,这些方案改进了初始程序。
这使你能在解决熟悉问题的同时学习新技术。
我们希望这种学习方法能促进所有学习者的成功。此外,在本课程中,你将学习数字19如何连接单词“jolly”和“cheer”。

你好,我是Owen。
我对本课程中引入两种重要编程结构的方法感到兴奋:数组和映射。

这些不仅仅是Java中的结构,它们在每种编程语言中都被用来为编程问题创建高效的解决方案。
通过探索这些结构之间的关系,并在熟悉的语境中遇到它们,你将能够在掌握概念和支撑这些概念的Java库的同时,练习使用它们。
你还将了解为什么拥有14个假玩具会是一个巨大的密码学巧合。

欢迎来到数组列表与结构化数据。

课程总结

在本节课中,我们一起了解了本课程的整体介绍、教学目标以及各位讲师对课程核心内容的概述。我们明确了课程将围绕Java编程、数组、映射等数据结构展开,并结合数据分析与密码学等实际应用。课程将通过一系列精心设计的问题和项目,帮助你掌握使用Java库、理解API以及面向对象编程的基础,为后续深入学习打下坚实基础。
068:密码学简史 🔐

在本节课中,我们将要学习密码学的历史背景及其在现代安全中的重要性。通过了解从古埃及到二战时期的加密方法,我们将理解为什么安全通信至关重要,并为后续学习凯撒密码和维吉尼亚密码打下基础。

在线购物的安全问题 💳
上一节我们介绍了本模块的学习目标,本节中我们来看看一个具体的应用场景:在线购物。
假设你想在线购买商品。你使用连接到互联网的计算机,以便与在线商店的服务器通信。为了完成购买,你需要将信用卡或其他支付信息输入计算机。

当你结算购物车中的商品时,你的计算机会将这些包含信用卡信息的数据通过互联网发送给在线商店。但如果有一个窃贼正在窥探互联网上传输的数据呢?这个窃贼可能会拦截你的信用卡信息,并利用它进行欺诈性消费。这显然是你和在线商店都不希望发生的情况。
加密如何保障安全 🔒
那么,是什么让在线购物变得安全呢?实际发生的情况是,你的计算机在将信息发送给在线商店的Web服务器之前,会先对其进行加密。
以下是加密过程的核心步骤:
- 协商密钥:两台计算机商定一个称为“密钥”的特殊数据片段。
- 加密数据:使用一个加密算法配合密钥来转换数据,使得只有拥有密钥的另一台计算机才能解密数据。
- 传输密文:你的计算机通过互联网发送加密后的数据,任何潜在的窃贼都会被挫败。窃贼只能看到加密数据,无法理解信息内容。
- 解密数据:拥有密钥的接收计算机可以解密数据,理解原始信息。
现代网络加密实践 🌐
当你在网上进行任何操作时,你的网页浏览器会告诉你是否拥有安全连接。例如,Chrome浏览器会以绿色显示“HTTPS”并在旁边显示一个绿色的锁形图标。HTTPS中的“S”代表“安全”,它是一种与标准HTTP不同的、连接到Web服务器的方式。
如果你点击锁形图标,它会告诉你用于保护连接安全的加密技术细节。保护你的互联网连接涉及许多算法。
以下是建立安全连接的关键算法:
- AES算法:连接建立后,通常使用一种名为AES的算法来加密发送到服务器的数据。
- 密钥交换算法:在发送任何信息之前,你的计算机和服务器必须以安全的方式商定密钥。这听起来可能很困难,但有算法可以实现这一点。例如,你的计算机可能使用椭圆曲线迪菲-赫尔曼或RSA这两种算法来与服务器安全地建立连接。

这些算法对于互联网的安全运行至关重要,但其中涉及的数学原理较为高深,实现这些加密算法可能需要花费数天或数月来学习相关数学知识,这并非本课程的重点。
古典密码学的价值 📜
尽管现代密码学需要一些高等数学,但通过回顾历史,你仍然可以学到很多关于密码学的知识。古典密码学,即过去几个世纪使用的加密算法,涉及简单的数学,甚至在计算机出现之前就已存在。
这些算法在今天并不安全,计算机可以轻易破解它们。但学习它们的工作原理并实现它们,将教会你一些重要的经验。更重要的是,学习如何破解它们将给你一个关键教训:不要试图自己发明加密方案。如果你需要安全性,请使用经过充分测试的现代密码学库实现。
密码学历史纵览 🕰️

那么,我们需要回溯到多远的过去才能找到密码学的首次使用呢?
以下是密码学发展史上的几个关键节点:
- 古埃及(约4000年前):已知最早的类似密码学的使用来自古埃及。然而,历史学家认为,当时隐藏信息并非严肃的保密尝试。
- 美索不达米亚(约公元前1500年):向前几百年到美索不达米亚,你会发现有工匠在石板上记录时使用简单加密方案来保护其秘密的记录。
- 罗马帝国:凯撒密码以尤利乌斯·凯撒命名,他曾广泛使用它。你将在本模块的其余部分学习这种密码。
- 16世纪:再向前1500年,你会找到维吉尼亚密码。乔万·巴蒂斯塔·贝拉索实际上在1553年描述了这个算法,但它以19世纪的布莱斯·德·维吉尼亚命名。这个算法在历史上非常重要,因为它长期以来被认为是不可破解的。然而,在迷你项目中,你将编写一个程序来破解它。
- 20世纪40年代:密码学是第二次世界大战的关键部分。盟军投入大量资源破解德国密码,其核心工作发生在英国的布莱切利园。艾伦·图灵是这项密码破译工作的领导者,并为计算机科学做出了许多重要贡献。事实上,他非常重要,以至于计算机科学的最高荣誉被称为“图灵奖”。
本模块的学习内容 🎯
现在你已经对密码学历史有了一些了解。那么你将要做什么呢?
在本模块中,你将学习凯撒密码,实现它,然后破解它。在本课程结束时的迷你项目中,你将学习维吉尼亚密码,同样也将实现并破解它。当然,所有这些问题都将教会你几项重要的技能,这些技能可以帮助你解决各种各样的其他问题。
总结 📝
本节课中我们一起学习了密码学的基本概念及其历史发展。我们了解到,从古埃及的简单记录隐藏到现代互联网中复杂的AES和RSA算法,密码学的核心目标始终是保护信息的安全传输。虽然现代加密技术非常复杂,但通过研究古典密码(如凯撒密码和维吉尼亚密码),我们可以掌握加密、解密的基本原理,并深刻理解使用经过验证的现代加密方案的重要性。这为我们后续动手实现和破解这些古典密码奠定了坚实的基础。
069:简介 🔐

在本节课中,我们将学习凯撒密码的基本概念,这是密码学中一个经典且简单的加密算法。我们将了解其工作原理,并探讨如何在后续课程中实现它。

欢迎回来。既然你已经了解了密码学的重要性,现在是你进一步学习凯撒密码概念的时候了,你将在本课中实现它。
假设你身处战场,想向你的副指挥官发送一条消息,命令第一军团攻击东翼。你不希望敌人知道你的计划,即使他们截获了这条消息。因此,你需要用你的密码加密它,如第二行所示。
凯撒密码算法以尤利乌斯·凯撒命名,这位著名的罗马皇帝曾使用过它。
算法核心思想 🧠
凯撒密码的基本思想是,将字母表中的每个字母替换为通过将字母表移动一个固定量(即字母表中特定数量的字母之后)所得到的字母。你移动字母表的这个量,就是该密码的密钥。
尤利乌斯·凯撒使用了向前移动三个字母的加密方式。如果你从向后移动字母的角度来思考这个算法,这等同于向后移动23个字母。
加密过程示例 📝
为了了解这个算法如何工作,我们将通过加密一条消息的例子来逐步说明。我们将使用字母表来展示字母是如何被加密的。
以下是加密“FIRST LEGION ATTACK EAST FLANK”消息的步骤,使用密钥3(即向后移动3个字母):
- 第一个字母是 F。在字母表中找到 F,然后向后移动三个字母:E, D, C。因此,你会在加密消息中写下 C 作为第一个字母。
- 下一个字母是 I。在字母表中找到 I,然后向后移动三个字母:H, G, F。写下 F 作为下一个字母。
- 下一个字母是 R。在字母表中找到 R,然后向后移动三个字母:Q, P, O。写下 O 作为下一个字母。
- 继续以同样的方式处理第一个单词的其余部分。
- 当你遇到空格时,最简单的方法是保持空格不变,在加密消息中写下空格。
- 以同样的方式处理空格后的下一个单词。
- 然而,当你遇到字母 a 时会发生什么?在字母表中找到 a,它是字母表的第一个字母。如何向后移动三个字母?你必须回绕到字母表的末尾。从那里,向后移动三个字母:Z, Y, X。写下 X 作为下一个字母。
- 以同样的方式继续处理消息的其余部分,最终你会得到一个在粗略检查下无法理解的文本。
然而,如果你知道或能推断出密钥,你就可以解密消息。解密过程与使用 26 - n 作为密钥进行加密的过程相同。
在计算机中实现 💻
那么,实际上如何做到这一点呢?一种方法是对数字进行运算。如果你上过我们的 Coursera 课程《面向初学者的编程与网络》,你应该记得一切都是数字。如果你不熟悉这个概念,它在计算机科学中非常重要,因为计算机只能处理数字。在这种情况下,该原则意味着这些字母实际上被表示为数字,因此你可以对它们进行数学运算。

具体来说,你可以告诉 Java 从字母 F 中减去 3,它会计算出字母 C。但是,如果你从字母 A 中减去 3 呢?Java 不会知道你想要回绕并保持在字母表内,因此你必须包含更多的数学运算或条件语句来实现回绕并得到 X。
另一种实现方式,可以使回绕的情况更清晰,就是预先移动整个字母表。也就是说,在尝试加密消息中的任何内容之前,先计算每个字母的移位。例如,你可以获取字母表,并针对向左(向后)移动3位的情况,计算出一个像这样的字符串。
我们将在未来的视频中看到如何做到这一点的细节。然而,一旦你计算出了这些字符串,你就可以使用它们来查找每个字母的加密结果。
对于消息开头的 F,你需要在原始字母表中找到 F。回想一下你过去学过的关于字符串的知识,你可能会使用什么方法来找到 F?一旦你找到了 F,你就查看移位字母表中相同位置的字母,即 C。然后你在加密消息中写下那个字母。对于需要回绕的 a,你不需要任何特殊情况处理。同样,你只需在原始字母表中找到 a,查看移位字母表中相同位置的字母。在这个例子中,那个字母是 X。因此,你在加密消息中写下 X。
实现前的准备 🛠️
很好,现在你知道了凯撒密码背后的基本思想。然而,在实现这个算法之前,你需要学习一些新的 Java 概念。
你将学习一些操作字符串的新方法,以及用于在数字范围内计数的 for 循环。
在数字范围内计数的 for 循环尤其重要,因为你将使用你计数的数字来索引数据,从而操作序列中的特定位置。你已经熟悉了字符串(字符序列),但在本课程的其余部分,你将学习新类型的序列。因此,你会经常使用 for 循环。

在本节课中,我们一起学习了凯撒密码的基本原理,包括其加密、解密过程以及如何在计算机中实现的核心思想。我们还了解到,为了实现这个算法,我们需要掌握 Java 中的字符串操作和 for 循环等新概念。
070:字符串的创建与操作 🧵



在本节课中,我们将要学习如何在Java中创建和操作字符串。这是实现凯撒密码等加密算法的基础。我们将重点学习如何通过拼接来构建新的字符串,并理解字符串的不可变性。最后,我们将介绍一个更高效的字符串构建工具——StringBuilder类。
上一节我们介绍了凯撒密码的基本概念,本节中我们来看看如何在Java中操作字符串以实现它。

如果你学习过我们之前的课程《Java编程:用软件解决问题》,那么你应该已经对字符串有所了解。你学习了如何操作字符串。如果你对字符串不熟悉,我们建议你在继续之前先复习一下相关内容。


然而,为了实现加密,你需要对字符串进行一些在之前课程中没有做过的操作。在之前的课程中,你分析了字符串的内容并对现有字符串的片段进行了操作。但是,你没有构建过新的字符串。

如果你仔细回顾一下凯撒密码的工作原理,你会发现,你是通过一次添加一个字符来构建一个新字符串的。
构建字符串的一种方法是使用拼接。在Java中,你可以使用加号 + 运算符进行字符串拼接,前提是至少有一个操作数是字符串。如果一个操作数是字符串而另一个不是,那么拼接运算符会找出非字符串操作数的字符串表示形式,并将其拼接起来。

以下是拼接操作的示例:




String result = "Hello" + " World";


例如,如果你用加号运算符连接这两个字符串,你就是在告诉Java执行字符串拼接。
结果将是你在这里看到的字符串,它是通过将这些字符串粘在一起形成的,这正是拼接的含义。
为了说明这个操作的实用性,请思考你正在处理的凯撒密码。你可能希望将原始字母表作为一个字符串,并根据密钥生成一个重排的字母表。




你可以通过使用你已经熟悉的 substring 方法获取两个部分,然后将它们拼接在一起来实现。
以下是构建重排字母表的步骤:
- 首先,获取从索引23开始的子字符串。请记住,只有一个参数的
substring方法,会返回从指定位置开始一直到字符串末尾的子字符串。 - 然后,将索引0到23的子字符串拼接到那个字符串的末尾。
现在你有了两个字符串,它们描述了从每个明文字母到相应密文字母的映射关系。


我们这是针对密钥23进行的操作,但你应该思考一下如何将其推广到其他密钥。


随着你对字符串操作的了解加深,重要的是要知道字符串是不可变的。一旦一个字符串被创建,你就无法更改它。

相反,如果你想要一个不同的字符串,你必须创建一个包含该更改的新字符串。这个概念可能看起来有点令人困惑和微妙,所以通过图示来理解会有所帮助。


这里我们声明了一个字符串并将其初始化为“Hello”。这里没有什么新的或令人惊讶的。


一个常见的做法是通过为变量s画一个方框来图示这条语句的效果。
并画一个箭头指向组成字符串“hello”的字母。




如果你声明另一个字符串x并用s初始化它,你会得到一张看起来像这样的图。
x和s都指向同一个字符串。
现在,假设你执行了 s = s + " World"。即,你计算s与字符串“ World”的拼接。你并没有改变现有的字符串,而是创建了另一个字符串,这涉及到像这样复制现有的字符串。
请注意,x仍然是“hello”,因为你没有改变现有的字符串,你创建了一个不同的字符串并将其赋值给了s。



如果你对大型字符串进行大量的字符串拼接操作,特别是,所需的复制可能会相当缓慢和低效。尽管我们目前并不特别关注最高效的做事方式,但养成好习惯仍然是一种良好的实践。
如果你正在通过添加许多小片段来构建大型字符串,你可能希望使用不同的方法。事实上,Java有一个专门用于此目的的StringBuilder类。它提供了一个可变的字符序列,这意味着它类似于字符串,但你可以修改它,以一种高效的方式更改和插入字符。

当你创建一个新的StringBuilder时,你可以传入一个字符串来指定其初始内容。还有许多有用的方法。我们将只列举其中最重要的几个,但建议你查阅API文档以获取完整列表和更多详细信息。
以下是StringBuilder的一些核心方法:
append:一个有用的方法是append,它允许你将一个字符串放在末尾。你也可以传入其他类型的数据,这些数据将在被添加到末尾之前转换为字符串。insert:你可以在任何你想要的位置插入一个字符串或其他类型数据的字符串表示形式。charAt/setCharAt:你可以通过索引(它们在字符序列中的数字位置)获取或设置单个字符。toString:当你完成对字符串缓冲区的操作后,通常会希望使用toString方法来获取你构建的字符串。
和之前一样,通过图示来了解这些方法的操作方式会有所帮助。这里,我们首先创建了一个StringBuilder并传入了字符串“Hello”。我们用sb画了这张图,箭头指向StringBuilder中的字符序列。



现在,如果你调用sb.append(" World"),你将修改现有的字符序列。请注意我们是如何改变现有序列,而不是将它们复制到一个新序列中的。

你也可以在中间插入或放置字符,这仍然会修改同一个字符序列,就像这样。

很好,现在你知道了如何从较小的片段构建字符串。



本节课中我们一起学习了Java中字符串的创建与操作。我们了解了如何使用拼接运算符+来构建新字符串,并理解了字符串的不可变性。对于需要大量修改字符串的场景,我们介绍了更高效的StringBuilder类及其核心方法(append, insert, toString等)。这些知识是编写像凯撒密码这样的程序的基础。
071:循环计数 🔄

在本节课中,我们将要学习一种新的循环结构——计数循环(for 循环)。我们将通过实现一个字符串反转功能的例子,来理解这种循环的工作原理和语法结构。掌握计数循环将帮助你更高效地处理需要按索引访问元素的任务,例如处理字符串中的每个字符。


循环回顾 🔁

在解决更复杂的问题之前,我们先回顾一下你已经使用过的循环类型。

你之前已经使用过不同类型的循环来解决问题。
例如,在DNA中寻找密码子或在网页中寻找标签时,你使用了 while 循环。
你也多次使用了 for-each 循环来处理可迭代对象,例如读取文件中的行或处理图像中的像素。
此外,你还使用过索引来访问和引用字符串的各个部分。你知道在Java和许多其他语言中,索引从0开始。因此,当你使用 indexOf 方法时,你知道如果在字符串的第一个字符找到匹配项,它会返回0。
indexOf 和 substr 方法都使用索引来访问字符串的元素或字符。
字符串反转与回文 🔄

我们将通过查看字符串反转的代码来学习如何访问字符串中的单个字符。
字符串的反转是指将字符顺序颠倒。例如,“CGA TTA”的反转是“ATT AGC”,“TIP”的反转是“PIT”。
研究基因组的科学家经常需要反向查看字符串来分析DNA。查看回文(正读反读都一样的短语)也可以是一种乐趣或文字游戏。

以下是一些不同语言中的回文例子:
- 俄语中有一个句子,如果你懂西里尔字母,会发现它正读反读都一样,意思是“野猪压扁了茄子”。
- 西班牙语中有一个回文句,意思是“他们慢跑做玉米饼”。
- 法语中,“A Saavlavash”这个句子意思是“奶牛怎么样了?”。
- 英语中,“Draw O Caesar erase a coward”这个句子特别适合,因为我们在本模块中要实现凯撒密码。
我们将使用一种新的循环——计数循环,来反转一个字符串。

理解 for 循环 🧠
在循环中索引字符串可以通过多种方式完成,但我们将看一种非常常见的方法。

我们必须理解一个由三部分组成的循环。每部分之间用分号分隔,正如你在幻灯片代码中看到的那样。
for 循环的第一部分称为初始化。在这里,变量 k 被赋值为0。这个操作只发生一次,在循环守卫和循环体执行之前。


循环守卫在每次循环体可能执行之前被评估。当循环守卫为 true 时,循环体执行;当它为 false 时,循环结束。有时循环守卫也被称为循环测试。

这里的增量操作发生在循环体中的所有语句执行完毕之后。增量操作完成后,再次评估循环守卫,以决定循环是继续还是退出。

我们将更仔细地研究这个过程。为了理解 for 循环,我们会将其与你之前见过的 while 循环进行比较。


for 循环与 while 循环 🔄
for 循环并不比 while 循环提供更多功能,也不能让你解决不同的问题。for 循环只是 while 循环的语法糖或美化形式。for 循环将所有部分放在一个地方,许多程序员认为这使得循环更容易编写和阅读。
正如我们讨论过的,初始化在循环守卫被测试之前只发生一次。你可以在下面的比较中看到,初始化在 while 循环之前也会发生。

循环守卫被评估以决定循环体是否执行。当循环守卫为 false 时,循环结束。无论是 while 循环还是 for 循环,都会在这个循环守卫或测试为 false 时退出。

当守卫为 true 时,循环体执行。作为循环体中的最后一条语句,我们看到增量语句将会执行。
为了更好地理解循环,我们将通过一个反转函数的具体例子来跟踪 for 循环的执行过程。
跟踪 for 循环执行 🕵️

为了跟踪这个过程,我们来看调用 reverse("pit")。这意味着参数 s 的值是字符串 “PIT”。
局部变量 r(或 rev)将累积反转后的字符串。它在循环之前被初始化为空字符串。在跟踪代码时,绿色箭头指示接下来要执行的语句。
循环索引或控制变量 k 被初始化为0。请记住,循环初始化在 for 循环中只发生一次,并且变量 k 只能在循环内部访问,不能在循环之后访问——它的作用域是有限的。

当检查循环守卫时,k 的值是0,它小于 s.length()(即3,因为 “pit” 有三个字符)。循环守卫评估为 true。循环守卫始终是布尔表达式。
现在循环体执行。字符串方法 charAt 用于访问特定索引处的字符。需要指出的是,有些人读作 “care-at”,有些人读作 “char-at”,两种都可以。由于 k 是0,表达式 s.charAt(0) 评估为 ‘P’。我们用单引号表示字符 ‘P’,这在Java中用于表示基本类型 char。
变量 r 的值显示为双引号中的空字符串,因为Java中的双引号表示字符串字面量。将字符 ‘P’ 连接到空字符串会产生一个新的字符串 “P”。变量 rev 将被赋值为 “P”,这个字符串变量改变了,它不再指向原来的空字符串。请记住,Java中的字符串是不可变的。我们可以创建新的字符串,但不能改变一个字符串。

循环体执行后,增量操作执行。这将 k 的值改为1。增量语句之后,我们准备跟踪循环的下一次迭代。
局部变量 rev 的值为 “P”,循环控制变量 k 的值为1,我们准备继续跟踪。k 的值为1。字符串 “pit” 的长度 s.length() 是3,因此循环守卫评估为 true,循环体执行。

记住,charAt 方法访问指定索引处的字符。这里索引为1的字符是 ‘I’。字符 ‘I’ 通过字符串连接被前置到 r(即 “P”)前面,这创建了一个新的字符串 “IP”。赋值语句改变了 rev,使其引用这个新字符串。

现在循环增量将执行。k 的值变为2,循环将继续执行。
我们现在将跟踪循环的最后一次迭代。局部变量 rev 的值为 “IP”,循环变量 k 的值为2。正如我们在这里看到的,循环守卫将被评估。

由于2小于3,守卫为 true,循环体执行。s.charAt(2) 评估为 ‘T’。字符 ‘T’ 被前置到字符串 “IP” 前面,形成字符串 “TIP”。变量 r 现在引用 “TIP”。循环继续。增量语句执行,将 k 的值改为3。

现在循环守卫将再次被评估。当在这里评估循环守卫时,你可以看到 k 的值是3,而 s 的长度也是3。由于3不小于3,循环守卫为 false。程序的控制流继续到循环之后的语句。rev 的值是 “TIP”,即字符串参数 s 的反转,因此将返回 “TIP”。

编程习惯与变体 📝

了解你将看到其他人编写的代码,并且你应该理解这些写法,这是很好的。


许多程序员使用 i 作为循环控制或索引变量。有些程序员认为字母 i 很难与数字 1 区分开,但 i 在阅读他人代码时比 k 更常见。

许多程序员使用后置增量运算符 i++ 而不是 i += 1。我们不会在这里解释 i++ 的细微差别,但它在使用循环时是一个非常常见的习惯用法,在循环增量中单独使用 i++ 是可以的。

有时在循环之前定义循环索引变量,而不是在循环的括号内定义,是有用的。这允许在循环结束后引用或访问 i 的值。当变量在循环的括号内定义时,该循环控制变量只能在循环体内引用,而不能在循环之后引用。

总结 📚
本节课中我们一起学习了计数循环(for 循环)。我们通过构建一个字符串反转函数的详细示例,深入理解了 for 循环的三个组成部分:初始化、循环守卫和增量操作。我们还将 for 循环与熟悉的 while 循环进行了比较,认识到 for 循环是一种更紧凑、更专注于计数的语法形式。最后,我们了解了一些常见的编程习惯,例如使用 i 作为索引变量和 i++ 进行增量。掌握 for 循环是处理数组、字符串以及其他需要按顺序或按索引访问元素的数据结构的关键一步。
072:字符类 🆎
在本节课中,我们将学习Java中的Character类。这个类提供了多种方法,用于判断字符值的属性,例如判断一个字符是否为数字或字母,以及进行大小写转换。
概述 📋
char是Java中的一种基本数据类型,类似于int、boolean和double。字符值使用单引号指定,例如 'a'、'1' 和 ' '。而双引号,如 "a",则表示字符串值。

Character类提供了许多有用的静态方法。你可能还记得Integer.parseInt和Double.parseDouble方法,它们分别属于Integer和Double类。Character类也以类似的方式工作。
核心方法与概念 🔧
以下是Character类中一些常用的方法。
大小写转换方法
Character.toLowerCase方法将其参数转换为小写形式并返回。例如,Character.toLowerCase('G') 会返回 'g'。如果传入的字符已经小写,则返回原值。同样地,Character.toUpperCase方法用于转换为大写。
代码示例:
char lowerG = Character.toLowerCase('G'); // 结果为 'g'
char upperA = Character.toUpperCase('a'); // 结果为 'A'
判断方法
Character类还包含一系列返回布尔值的方法,用于判断字符的属性。
以下是几个关键的判断方法:
Character.isLowerCase(char ch): 判断字符是否为小写字母。Character.isUpperCase(char ch): 判断字符是否为大写字母。Character.isDigit(char ch): 判断字符是否为数字。Character.isLetter(char ch): 判断字符是否为字母。

实践演示 💻

上一节我们介绍了Character类的核心方法,本节中我们通过具体代码来看看这些方法如何应用。
示例一:判断字符属性
我们有一个digitTest方法。它创建了一个包含大写字母、小写字母、数字和标点符号的测试字符串。然后,它遍历字符串中的每个字符,并调用Character.isDigit和Character.isLetter方法进行判断。
运行此方法后,控制台会清晰地显示:大写和小写字母被标记为字母,数字字符被标记为数字,而标点符号既不是字母也不是数字。
此外,代码中还演示了如何直接比较字符:
if (ch == '#') {
System.out.println("It's a hashtag.");
}
这提醒我们,字符使用单引号,而字符串使用双引号。
示例二:字符转换
在conversionTest方法中,我们创建了类似的测试字符串。遍历时,我们为每个字符调用Character.toUpperCase和Character.toLowerCase方法,并打印原始字符、大写形式和小写形式。
运行结果会显示三列:原始字符、转换后的大写字符和转换后的小写字符。可以看到,数字和标点符号在转换前后保持不变;字母则会被正确转换。

代码片段回顾:
char ch = testString.charAt(i);
char upperCh = Character.toUpperCase(ch);
char lowerCh = Character.toLowerCase(ch);
System.out.println(ch + "\t" + upperCh + "\t" + lowerCh);

总结 🎯
本节课中我们一起学习了Java的Character类。我们了解了char基本类型,并掌握了如何使用Character类的方法来判断字符属性(如是否为数字或字母)以及进行大小写转换。熟练查阅Java官方文档中关于Character类的说明,将有助于你在处理字符时编写出更流畅、健壮的代码。
073:算法开发 🧠
在本节课中,我们将学习如何为一个具体问题——凯撒密码——开发算法。我们将从手动解决一个实例开始,逐步抽象出通用步骤,并最终形成可转化为代码的算法。
欢迎回来。现在,你已经掌握了实现凯撒密码所需的所有概念。
让我们开始开发算法。和往常一样,你应该从第一步开始:自己动手解决一个问题的实例。尽管我们已经看过一些实例,但亲自解决一个小实例仍然有益,因为你可以写下所有步骤并仔细思考。
让我们用密钥17来加密消息“I am”(实际上是“I 空格 A M”),这意味着你将每个字母在字母表中向右移动17位。
你可以从写下字母表开始。
然后在它下面写下移动了17个字符的字母表。例如,a下面对应R,其中R是原字母表中a右边第17个字符。
接下来,你将遍历消息中的每个字符,并用移位字母表中对应的字母替换它。
完成后,你就得到了加密后的消息“Z 空格 R D”。
很好,你已经完成了第一步。
现在该进行第二步了:准确写下你刚才所做的操作。
你做的第一件事是写下字母表。然后你计算了移位后的字母表。
你做的第三件事是查看消息的第0个字母。别忘了,当你索引序列(如字符串和StringBuilder)时,第一个元素的索引是0。
那个字母是I。于是你在字母表中查找I。
然后你在移位字母表的相同位置找到了对应的字母,即Z。
因此你将消息的第0个字符替换为Z。
接下来,你查看了消息的第一个字母,它是一个空格。
如果你在字母表中查找空格,你将找不到它,因此你不会更改消息索引1处的字符。
接着,第二个字符是A,你对其执行了与第0个字符非常相似的过程,最终将其更改为R。
最后,你对第三个字符执行相同的操作,即M,你将其更改为D。
现在你已经仔细思考了整个过程,你得到了针对这条特定消息和这个特定密钥所执行的17个步骤的列表。然而,在继续之前,还有一点值得注意。
请注意,你的算法要求替换消息中的字符。如果你的消息是一个字符串,你无法做到这一点。正如你最近学到的,字符串是不可变的,意味着你无法更改它们。
如果你现在认识到这个问题,你可以调整你的算法,以反映你希望在这里使用StringBuilder的事实。我们在开头添加了一个步骤:从字符串消息创建一个StringBuilder。
然后我们更新了算法,使其在StringBuilder上工作。
如果你现在没有意识到需要这样做,你会在后面的步骤中发现。但越早弄清楚你需要做的一切,就越好。
观察这个算法,你可以看到前几个步骤是在开始对消息中的每个字母执行重复步骤之前的初始设置。
如果你专注于初始设置之后的步骤,你会发现你对消息中的每个字符所做的操作几乎相同,但又不完全一样。
一个显著的区别在于,你根据是否在字母表中找到该字母来决定做什么:如果找到就替换当前字符;如果没找到就什么也不做。
如果你观察针对一个特定字符(该字符在字母表中)的步骤,你会注意到:你在字母表中查找的字符是字符串中的当前字符;而你用来替换当前字符的字母,是你在移位字母表相同位置找到的字母。

现在你已经仔细思考了这一切,你可以写下一个更通用的算法。
请注意,这里的第2步需要一些思考和几个语句。但你已经知道如何做了。
当你寻找模式时,你应该检查任何常量,比如这里的0,并问自己是否总是使用这个常量,或者是否需要寻找一个更通用的模式。在这里,你总是想从0开始。
那么3呢?你是总是想数到3,还是总是想停止在3计数?不,你数到多高取决于消息的长度。这里我们写的是你想数到encrypted的长度,但要注意你想数到小于它,而不是小于或等于它。在我们的例子中,encrypted是4,而你只想数到3。

现在是测试这些步骤的时候了。请暂停视频,尝试用密钥19加密消息“a 空格 bat”。
你是否发现了这个算法中一个微妙的问题?尽管它计算出了你想要的一切,但我们从未说过最终答案是什么。你需要确保明确说明这一点,以便在将算法转化为代码时,知道从方法中返回什么。
你的答案是名为encrypted的StringBuilder内部的字符串。
现在你修正了算法中的这个细节,就可以准备将其转化为Java代码了。谢谢。


在本节课中,我们一起学习了凯撒密码算法的开发过程。我们从手动加密一个简单实例开始,逐步记录具体步骤,识别出使用不可变字符串的限制并引入StringBuilder,最终抽象并完善出一个清晰、通用、可转化为代码的算法框架。这个过程强调了从具体到抽象、从手动操作到形式化描述的算法思维。
074:凯撒密码算法到代码的转换

在本节课中,我们将学习如何将之前设计的凯撒密码算法转化为实际的Java代码。我们将逐步构建一个CaesarCipher类,并实现其加密方法。
概述
上一节我们介绍了凯撒密码的算法设计。本节中,我们来看看如何用Java代码实现这个算法。我们将从一个包含encrypt方法的CaesarCipher类开始,该方法接收待加密的文本和用于加密的密钥整数。
代码实现步骤
以下是实现凯撒密码加密功能的具体步骤。
1. 初始化字符串构建器与字母表
首先,我们创建一个StringBuilder对象来构建加密后的字符串,并定义一个包含所有字母的字符串。
StringBuilder encrypted = new StringBuilder(input);
String alphabet = "ABCDEFGHIJKLMNLMNOPQRSTUVWXYZ";

2. 计算移位后的字母表
接下来,我们需要根据密钥计算移位后的字母表。这可以通过字符串的substring方法实现。
String shiftedAlphabet = alphabet.substring(key) + alphabet.substring(0, key);
3. 遍历输入字符
现在,我们需要遍历输入字符串中的每一个字符。为此,我们使用一个for循环。
for (int i = 0; i < encrypted.length(); i++) {
// 处理每个字符的代码将放在这里
}
4. 处理每个字符
在循环内部,我们按以下步骤处理每个字符:
- 获取当前字符。
- 在标准字母表中查找该字符的索引。
- 如果字符存在于字母表中(即索引不为-1),则从移位字母表中获取对应的新字符。
- 在
StringBuilder中用新字符替换原字符。
char currChar = encrypted.charAt(i);
int idx = alphabet.indexOf(currChar);
if (idx != -1) {
char newChar = shiftedAlphabet.charAt(idx);
encrypted.setCharAt(i, newChar);
}
5. 返回加密结果
循环结束后,我们将StringBuilder转换为字符串并返回,作为加密结果。
return encrypted.toString();
测试加密方法
代码编写完成后,必须进行测试以验证其正确性。我们编写一个testCaesar方法,它能够:
- 读取一个文件中的消息。
- 使用我们的加密方法对其进行加密并打印结果。
- 对加密结果进行解密(使用反向密钥)并打印,以验证是否能恢复原始消息。
看到加密后的乱码和成功解密回原文,能让我们对代码的正确性更有信心。当然,彻底验证还需要手动核对几个测试用例。
总结

本节课中我们一起学习了将凯撒密码算法转化为Java代码的完整过程。我们从初始化变量开始,逐步实现了字符遍历、查找、替换等核心逻辑,并最终完成了加密方法。通过编写测试代码进行验证,我们确保了加密和解密功能的正确性。这个过程清晰地展示了从算法设计到功能实现的关键步骤。
075:测试与调试 🧪
在本节课中,我们将学习软件测试与调试的核心概念。我们将通过一个凯撒密码类的实例,理解为何单一测试用例不足以保证代码正确性,并探讨如何通过更全面的测试来增强信心。
概述
我们已经编写并测试了我们的凯撒密码类。我们曾在一个消息上进行了测试,并且看起来运行正常。然而,这是否意味着我们可以确信代码是正确的?答案当然是否定的。
单一测试的局限性
上一节我们介绍了代码初步测试通过的情况。本节中我们来看看为何这还不够。
请记住,在测试软件时,每一个测试用例都会让你对代码更有信心,但无论进行多少次测试,都无法百分之百保证代码的正确性。你总是希望进行越来越多的测试,以获得越来越高的信心,而一个测试用例通常是不够的。
新的测试用例与问题暴露
以下是一个不同的消息内容:
Dear Owen, no matter what you may have heard, there is no cake in the conference room. The cake is a lie. Please keep working on Coursera videos.
我想加密这条消息,并将加密后的信息发送给Owen,以防他截获我发给Robert的消息,从而让他远离蛋糕。
我使用我的测试程序testCaesar来处理message2.txt。观察输出结果,我发现:
- “Dear”中的‘D’变成了‘U’,这很好。
- 但随后“EA”和“R”却保持不变。
- “Owen”中的‘O’变成了‘F’,但“WEN”这几个字母又都保持不变。
- 事实上,这条消息中的大部分内容都未被改变。

问题诊断与原因分析

由此可见,我的代码并不完全正确。回顾代码,我发现它只能处理大写字母,而无法处理小写字母。
这正是我们第一个测试用例中隐藏的问题。因为那个测试用例只包含大写字母,没有包含小写字母,所以我们并未对所有情况进行足够充分的测试。
任务与总结
我们将把修复这个问题的任务留给你来完成。有几种不同的方法可以实现,希望你能够找出其中一种。
本节课中我们一起学习了测试的重要性与局限性。我们了解到,即使代码通过了初始测试,也可能存在未覆盖的边界情况(例如本例中的小写字母)。通过暴露问题、分析原因(代码仅处理大写字母),我们明确了调试和增强代码鲁棒性的方向。
祝你调试顺利,编码愉快!


076:密码学入门与凯撒密码实现总结 🔐

在本节课中,我们学习了密码学的基础知识、字符串操作、循环计数,并最终实现了一个经典的凯撒密码。本节将对所学内容进行总结。
课程内容回顾
首先,我们了解了密码学的历史背景及其在现代社会中的重要性。
接着,我们学习了更多关于字符串的概念,以及如何使用 StringBuilder 来高效地构建字符串。
然后,我们学习了计数循环,为你的编程工具箱增添了另一个重要工具。
最后,我们实现了一个凯撒密码,这是一种可以追溯到数千年前的古典密码。
总结与展望
本节课中我们一起学习了密码学的基本概念、字符串的高效处理、循环控制结构,并动手实践了凯撒密码的编码与解码过程。
正如你将在下一课中学到的,按照现代标准,这种密码并不安全,但它是理解密码学思想的良好起点。
077:使用数组破解凯撒密码 🗝️

在本节课中,我们将学习如何使用“暴力破解”方法,通过尝试所有可能的密钥来解密一段由凯撒密码加密的文本。我们将理解凯撒密码的基本原理,并编写一个简单的程序来演示这一过程。
我是杰夫·福布斯,杜克大学的计算机科学教授,也是苏珊、欧文、罗伯特和德鲁的朋友。我的研究方向是计算机科学教育和学习分析,同时我也使用Java教授数据结构和算法课程。我很高兴能为大家带来一堂关于使用数组破解凯撒密码的客座讲座,我知道你们一直在学习这种加密方法。
你们或其他人可能已经实现过使用凯撒密码加密文本的程序。这是一种非常基础且具有历史意义的加密形式,但考虑到耐心、计算机的访问权限以及编程技能,它并不安全。破解这种密码所涉及的概念,对于解决其他问题也很有用。

加密时,会使用一个密钥来移位消息中的所有字母。那么,我们如何解密呢?我们知道解密必须是可行的,因为预期的接收者必须能够解密并阅读发送的加密消息。
因为移位26次等同于移位0次。用移位7进行加密,再用移位19进行解密,将得到原始消息,就像移位26次一样。了解这一点如何帮助我们破解密码呢?
窃贼或黑客可以找到密钥,密钥通常是一个数字。无论是在凯撒密码还是许多其他加密形式中,密钥通常都是数字。黑客只需从26中减去加密密钥,就能解密消息。

如果黑客没有密钥,是否有可能使用暴力破解或其他方法来破解密码呢?暴力破解意味着在人的帮助下尝试每一个可能的密钥。对于凯撒密码,使用暴力破解相对容易解密消息。



假设我们截获了这条消息,它很难发音。我们仅凭观察就能看出这条消息在说什么吗?这似乎不太可能。

如果我们知道用于加密这条消息的密钥,我们就可以轻松地解密它。
但是有多少个密钥呢?也许我们可以简单地尝试所有密钥,这就是暴力破解方法的基本思想:尝试每一个密钥。
我们已经有了加密消息的代码。我们将使用从1到26(或0到25)的每一个密钥来加密我们试图解密的消息。由于解密移位量就是26减去原始加密移位量,如果我们尝试所有26种移位,我们就会找到原始消息。我们可以使用这种暴力破解方法尝试每一个密钥,因为密钥数量很少,而且尝试每个密钥的速度很快。同样的方法不适用于其他形式的加密,因为可能的密钥数量可能太多,或者使用每个密钥加密可能需要很长时间。
在讨论比暴力破解更复杂的方法之前,我们先来理解一下我们称之为“肉眼解密”的暴力破解方法。我们的目标是解锁或解密一条加密消息。


我们没有用于解密的密钥,我们没那么幸运。
然而,我们确实有来自凯撒密码课程的加密密钥,利用它我们可以尝试所有26个密钥。为了使用人工或“肉眼”方法解密,我们将创建一个凯撒密码对象。我们将尝试从0到25的所有26个密钥。我们将使用名为cipher的凯撒密码对象来移位消息,对每个密钥都操作一次,然后打印移位的结果。正如我们将看到的,如果我们能识别出单词,我们就能解密消息。

当我们运行刚刚讨论的代码时,我们将能够查看或“肉眼观察”加密26次的结果。
我们将系统地扫描由26个不同密钥产生的26个字符串。当我们用肉眼观察每个字符串时,我们会仔细查看该字符串是否可以被识别为英语,因为我们在寻找一条英语消息。这一行无法识别。这一行看起来不像英语,但让我们仔细看看。不,它不是英语。我们看下一行。让我们仔细检查这一行。
这一行很容易被识别为英语文本,我们看到“加密和安全是当今互联网的基本组成部分”。
078:数组 📚

在本节课中,我们将要学习一个强大的编程结构——数组的基础知识。几乎所有广泛使用的编程语言都采用类似的结构,以便用一个变量来表示多个项目。

从实际问题引入 🧬


当你编写代码帮助基因组科学家解决问题时,你需要计算DNA链中每个C、G、T和A的出现次数。这是一个真实的问题,有助于寻找富含CG内容的蛋白质编码区域。
作为学习加密和解密的程序员,你需要计算A、B、C以及从X、Y到Z的每个字母的出现次数,总共需要26个计数器。这是为了能够破解凯撒密码或解密用凯撒密码加密的信息。
虽然你可以使用26个变量来完成这个任务,但我们将学习一个新概念来简化编码。你将学习数组,它是一种同类型值的集合。
数组的概念:类比邮箱 📬
你可以看到邮局的邮箱。它们看起来都一样,但每个都有不同的编号。你可以访问344号邮箱或345号邮箱,你可以在邮箱中存放信件和包裹,也可以从中取出。数组在编程中是类似的概念。一个数组可以代表26个甚至1026个计数器,如果你的字母表很大,或者你在解决不同的问题。
从简单计数到数组的演进

让我们先看看帮助基因组科学家的代码。我们将查看计算C、G、T和A出现次数的代码。这是为了理解并引出计算26个字母的问题。

// 使用四个独立变量的DNA计数代码
int countC = 0;
int countG = 0;
int countT = 0;
int countA = 0;
// ... 处理每个核苷酸,递增相应的计数器
这个解决方案有效,但很难扩展到拥有26个计数器的情况,而这正是解密凯撒密码信息所需要的。

从概念上讲,这并不难。我们可以使用cA、cB、cC等变量,一直到cY和cZ,总共26个变量,并且可以使用26个if语句来递增相应的变量。但是,编写代码以及改变我们对这26个值的处理方式非常耗时,并且如果我们想打印不同的内容(例如,查看输出时)也很难更改。
数组的引入与优势

我们将使用数组,一个索引集合,用一个变量代替26个变量。我们将通过计算加密信息中每个字符的出现次数来破解凯撒密码。在英语信息中,字母E通常是出现频率最高的字符。因此,一旦我们找到加密信息中每个字符的出现次数,出现最多的那个字符很可能就是E,这样我们就可以确定加密时使用的移位,从而使解密过程变得容易。
总的来说,计数和收集值是编写程序的重要工具。因此,在破解凯撒密码的背景下学习数组,将有助于解决许多其他问题。
数组与字符串的相似性

你之前见过StorageResource类,它有助于存储字符串值。那个类很有用,但用途有限。我们稍后会扩展数组和StorageResource类的概念。现在,我们需要一个索引集合,就像我们看到的邮箱集合一样,用一个数字来访问特定的位置。
这与你在字符串中看到的概念相同,在字符串中,使用索引通过.charAt或.substring方法来访问字符串中的特定字符。数组可以存储任何类型的值,而不仅仅是字符串中使用的char类型。
我们将学习使用数组的概念和代码,并看看它们与你之前见过的字符串有何相似之处。

定义数组


你定义数组的方式与定义字符串类似。
// 定义字符串变量
String message = “Hello”;

当定义字符串变量时,你指定字符串中的字符,并将这些字符赋值给一个变量。

对于数组,你必须指定有多少个存储位置,并使用方括号和变量名来表示该变量是一个数组。

// 定义并初始化一个整数数组
int[] counters = new int[256];

这段代码分配了256个内存位置,每个位置都持有一个值为0的int。这是数组中整数的默认值。


索引与访问
索引的概念用于访问字符串和数组的元素。


对于字符串,使用.charAt方法和一个索引来访问特定字符。第一个字符的索引是0,因为我们使用基于0的索引。
char firstChar = message.charAt(0); // 访问第一个字符
对于数组,使用方括号[]操作符来访问特定元素,同样使用基于0的索引。
int firstElement = counters[0]; // 访问数组的第一个元素


获取长度
在编写代码时,你经常需要知道字符串中的字符数或数组中的元素数。

对于字符串,你使用.length()方法来确定字符串中有多少个字符。

int strLength = message.length();

对于数组,你使用存储在.length中的值来访问为数组分配的存储位置数量。请注意,对于数组,.length不是一个方法,而是一个值。这有时是编写代码时混淆的来源。
int arrLength = counters.length; // 注意:没有括号
实战:使用数组计数A-Z
我们将查看计算每个字母A到Z出现次数的代码。你会看到这段代码及其中的概念将帮助你在编程时解决许多问题。
以下是核心代码示例:
// 1. 定义并初始化数组
int[] counters = new int[26];
// 数组的26个位置初始值都是0


// 2. 假设我们有一个字符串 `encryptedMessage`
String alpha = “abcdefghijklmnopqrstuvwxyz”;
for (int k = 0; k < encryptedMessage.length(); k++) {
char ch = encryptedMessage.charAt(k);
// 将字符转换为小写,并找到它在字母表中的索引
int index = alpha.indexOf(Character.toLowerCase(ch));
if (index != -1) { // 确保是字母
counters[index]++; // 递增对应的计数器
}
}
// 3. 打印结果
for (int k = 0; k < counters.length; k++) {
System.out.println(“Number of “ + alpha.charAt(k) + “’s: “ + counters[k]);
}

在这段代码中,你会看到一个名为counters的变量,它将代表26个不同的计数器。代码将把A的出现次数存储在counters[0]。我们使用sub作为下标(一个来自数学的术语)的简写。


我们将看到counters[k]是第k个小写字母的出现次数。这意味着B的数量在counters[1],Z的数量在counters[25]。

正如你查看代码时所看到的,有三个部分,就像在计算DNA流中C、G、A和T出现次数的代码中一样。

- 定义与初始化:在那段代码中,定义了四个计数器并初始化为零。这里定义了26个计数器并初始化为0。由变量
counters引用的数组取代了26个不同的变量。 - 处理与计数:在DNA计数代码中,我们使用了一系列四个if语句来确定递增哪个计数器。在这里,我们使用字符在字符串
alpha中的位置作为要递增的适当计数器的索引。注意A的索引是零。我们甚至通过使用Character.toLowerCase方法来处理大写和小写A,以便alpha.indexOf返回的索引值帮助我们递增counters数组中的适当存储位置。 - 输出结果:最后,为了打印每个结果,我们使用循环索引
k来同时访问存储在alpha中的字母值和存储在counters数组中的计数值。
数组要点总结 📝

以下是刚刚介绍内容的快速总结:

- 定义:数组是值的索引集合。
- 声明与初始化:定义数组时,通常需要提供一个整数值,指示数组中可以存储多少个元素。也可以像这里看到的这样定义一个像
x这样的变量,没有为它分配存储空间,只是为了定义变量的类型。例如,这可以作为方法中的参数使用。int[] x; // 声明,未初始化 int[] y = new int[10]; // 声明并初始化一个大小为10的数组 - 默认值:如果你通过调用
new来定义数组,必须为数组元素的数量提供一个整数值。在int数组中,所有位置都将被初始化为零。对于字符串数组,所有数组位置都被初始化为null。这是我们之前见过的值,表示没有引用任何对象。 - 读写操作:使用索引读取和写入数组位置。你可以在数组中存储一个值,如下所示,
s[3]获得字符串“hello”,这是向数组位置写入一个值。你也可以访问或读取一个数组位置,如下所示,在赋值语句的右侧,我们看到x[3]被用来赋值,或者在赋值语句的左侧,x[2]被写入。s[3] = “hello”; // 写 int value = x[3]; // 读 x[2] = value * 2; // 写 - 长度固定:一旦为数组分配了存储空间,数组的大小就不会改变。这可能就是为什么
.length不是一个方法,而是一个值。 - 方法参数:当数组传递给方法时,数组引用的位置的内容可以改变。这很微妙,当我们使用数组解决问题时,你会看到它的例子。

结束语
本节课中,我们一起学习了数组的基本概念。我们了解到数组是一种强大的数据结构,它允许我们使用单个变量名和索引来管理大量同类型的数据。我们从简单的计数器例子出发,看到了使用独立变量的局限性,进而引入了数组作为解决方案。我们比较了数组与字符串在定义、索引和长度属性上的异同,并通过一个计算字母频率的实战例子巩固了数组的声明、初始化和遍历操作。掌握数组是解决许多编程问题的基础,例如数据分析、加密解密等。祝你编码愉快!
079:随机数与数组 🎲

在本节课中,我们将学习如何使用Java中的数组来模拟随机事件,具体是通过模拟掷骰子来统计各个点数出现的次数。我们将看到数组如何极大地简化代码,并验证Java随机数生成器的“随机性”。

计算机非常擅长建模和模拟,部分原因在于计算机每秒可以执行数十亿次数学运算。模拟和建模通常依赖于生成随机数。在这个编码示例中,我们将看到一个使用数组的简单例子,它有助于确定Java的Random类生成的随机数有多“真随机”。


计算机不使用自然界的随机现象,而是通过所谓的伪随机性来模拟随机性,即用数学来模拟自然界中可能发生的随机事件。在这个例子中,我们将通过生成随机数来模拟多次掷一对骰子。我们想知道掷出2点的次数有多少,掷出7点的次数有多少。我们将统计每种点数出现的次数。
初始代码分析

初始代码名为SimpleSimulate,它可以运行并统计掷出2点和12点的次数。以下是运行示例:
// 初始代码片段:仅统计2点和12点
int count2 = 0;
int count12 = 0;
for (int i = 0; i < rolls; i++) {
int d1 = randomGenerator.nextInt(6) + 1;
int d2 = randomGenerator.nextInt(6) + 1;
if (d1 + d2 == 2) {
count2++;
} else if (d1 + d2 == 12) {
count12++;
}
}
System.out.println("2's: " + count2);
System.out.println("12's: " + count12);
运行程序,输入10000次模拟,可能会得到类似“2点出现298次,12点出现271次”的结果。为了统计所有可能的点数(2到12),我们需要改进代码。
引入数组进行优化

上一节我们看到了仅统计两个点数的局限性。本节中,我们将使用数组来同时统计所有11种可能的点数(2到12),从而让代码更简洁、更高效。
我们将创建一个大小为13的整型数组(索引0到12,实际使用索引2到12)。这样,当掷出点数为n时,我们只需在数组索引n的位置加1即可。
以下是修改后的核心代码结构:

// 使用数组统计所有点数
int[] counts = new int[13]; // 索引0和1不使用
for (int i = 0; i < rolls; i++) {
int d1 = randomGenerator.nextInt(6) + 1;
int d2 = randomGenerator.nextInt(6) + 1;
counts[d1 + d2]++; // 关键步骤:用点数作为索引
}
这个简单的 counts[d1 + d2]++ 语句替代了原先一长串的if-else判断,极大地简化了逻辑。
输出统计结果
在统计完所有点数后,我们需要一个清晰的方式来输出结果。我们将使用一个for循环来遍历数组的有效部分(索引2到12),并打印每个点数及其出现的次数和百分比。

以下是输出部分的代码:


// 循环输出2到12点的统计结果
for (int k = 2; k <= 12; k++) {
System.out.println(k + ":\t" + counts[k] + "\t" + 100.0 * counts[k] / rolls + "%");
}



这段代码会为每个点数k输出一行,包含点数、出现次数以及占总投掷次数的百分比。
测试与验证
为了确保我们的数组计数器工作正常,最好先用小规模数据进行测试。我们可以在投掷循环内添加一个打印语句,输出每次投掷的具体结果,然后与最终的统计数组进行比对。

以下是添加的测试代码:

// 在投掷循环内添加测试输出(仅用于调试)
for (int i = 0; i < rolls; i++) {
int d1 = randomGenerator.nextInt(6) + 1;
int d2 = randomGenerator.nextInt(6) + 1;
int sum = d1 + d2;
System.out.println("Roll is " + d1 + " + " + d2 + " = " + sum); // 调试语句
counts[sum]++;
}


运行10次模拟,观察控制台输出的每次投掷结果,并核对最终counts数组中的数值是否与之匹配。例如,如果输出显示有3次掷出7点,那么counts[7]的值应该正好是3。这种小规模测试是验证逻辑正确性的有效方法。


总结

本节课中我们一起学习了如何利用Java数组来高效地模拟和统计随机事件。我们从一个仅统计两个点数的简单程序开始,通过引入数组,将其扩展为能统计所有可能点数(2到12)的完整模拟程序。核心改进在于使用 counts[d1 + d2]++ 这一行代码替代了复杂的条件判断,并利用循环来简化结果的输出。最后,我们还介绍了通过小规模数据测试来验证程序逻辑正确性的方法。这个例子展示了数组在组织和管理批量数据时的强大能力。
080:使用数组计数 📊
在本节课中,我们将学习如何使用数组来统计文本中特定单词的出现次数。我们将通过分析莎士比亚戏剧文本来实践这一概念,并编写代码来找出其中最常见单词的使用频率。

概述
英语中最常见的单词是什么?我们可以使用搜索工具或现成的数据来找到答案。例如,已有研究表明,“the”是英语中使用频率最高的单词。
那么,莎士比亚在他的戏剧中是否也大量使用了这些常见单词呢?我们可以利用杜克大学的课程资源、莎士比亚戏剧的公共领域版本以及一些简单的数组代码来回答这个问题。
在本示例中,我们将检查莎士比亚的几部戏剧,并统计他使用最常见单词的次数。
代码实现
以下是统计莎士比亚戏剧中最常见单词的初始代码。我们首先来看一下整体结构。
public void countShakespeare() {
// 初始化包含戏剧文件名的数组
String[] plays = {"caesar.txt", "errors.txt", "hamlet.txt", "likeit.txt", "macbeth.txt", "romeo.txt"};
// 获取常见单词列表
String[] common = getCommon();
// ... 后续统计逻辑
}
我们定义了一个方法 countShakespeare。这里展示了一种初始化数组的新方式:使用花括号 {} 直接列出数组中的元素。plays 数组包含了六个莎士比亚戏剧的文本文件名。此外,我们还有另一个字符串数组 common,它通过 getCommon 方法获取。
获取常见单词列表
接下来,我们看看 getCommon 方法是如何工作的。
public String[] getCommon() {
// 从文件 common.txt 中读取20个最常见单词
String[] common = new String[20];
// ... 读取文件并填充数组的循环逻辑
return common;
}
getCommon 方法读取一个名为 common.txt 的数据文件,该文件包含了由他人确定的20个最常见的英语单词。因为我们事先知道文件中有20个单词,所以可以创建一个大小为20的字符串数组 common。然后,通过一个循环逐个读取单词并存入数组。
统计单词出现次数
回到 countShakespeare 方法。在获取了戏剧列表和常见单词列表后,我们使用一个循环来逐个处理每部戏剧。
for (int i = 0; i < plays.length; i++) {
String playName = "data/" + plays[i]; // 假设文件存储在data文件夹下
countWords(playName, common, counts); // 统计该戏剧中的单词
System.out.println("完成处理:" + plays[i]);
}
对于每部戏剧,我们调用 countWords 方法。该方法会检查戏剧中的每个单词,看它是否属于常见单词列表。如果是,则相应地在 counts 数组中增加该单词的计数。
处理完所有戏剧后,我们遍历常见单词列表,并打印每个单词在所有六部戏剧中的总出现次数。
实现关键方法:indexOf
然而,上述代码中缺少一个关键方法 indexOf 的实现。这个方法用于在一个单词列表中查找特定单词,并返回其位置(索引)。
以下是 indexOf 方法的实现步骤:
- 遍历传入的单词列表(数组)。
- 将列表中的每个单词与目标单词进行比较。
- 如果找到匹配的单词,则返回其索引。
- 如果遍历完整个列表仍未找到,则返回
-1表示未找到。
public int indexOf(String[] list, String word) {
for (int k = 0; k < list.length; k++) {
if (list[k].equals(word)) {
return k; // 找到单词,返回其索引
}
}
return -1; // 未找到单词
}
indexOf 方法在统计中的应用
现在,让我们看看 indexOf 方法如何在 countWords 方法中被使用。
public void countWords(String filename, String[] common, int[] counts) {
// ... 读取文件,获取每个单词的逻辑
for (每个从文件中读取的单词) {
int index = indexOf(common, word); // 查找单词在常见列表中的位置
if (index != -1) { // 如果找到了
counts[index]++; // 在对应的计数数组位置加1
}
}
}
对于从文件中读取的每个单词,我们调用 indexOf(common, word) 来检查它是否在常见单词列表中。如果返回值不是 -1(即找到了),我们就使用这个索引值来更新 counts 数组中对应位置的计数器。例如,每次找到单词 “the”,我们就会增加 counts 数组中与 “the” 在 common 数组中位置相对应的那个计数器的值。
运行与验证
编译并运行完整的程序后,我们得到了类似以下的结果:
处理文件:caesar.txt
处理文件:errors.txt
...
常见单词统计结果:
the: 4237
of: 1071
and: 980
...
结果显示,在分析的六部戏剧中,单词 “the” 出现了4237次,“of” 出现了1071次,等等。这表明莎士比亚确实大量使用了常见单词。
为了验证我们统计的准确性,我们可以用一个内容已知的小文件进行测试。例如,创建一个 small.txt 文件,里面只包含几个单词。
// 临时修改 countShakespeare 方法,只处理测试文件
String[] plays = {"small.txt"}; // 替换原来的plays数组
再次运行程序,将输出与 small.txt 文件中的实际单词进行手动比对。如果计数结果一致,就能增强我们对统计莎士比亚戏剧单词次数准确性的信心。
总结
本节课中,我们一起学习了如何使用数组来统计文本中单词的出现频率。我们通过一个具体的项目——分析莎士比亚戏剧中的常见单词——实践了以下核心技能:
- 数组的初始化与使用:学习了直接使用
{}初始化数组元素。 - 文件读取与处理:通过循环读取多个文件内容。
- 关键算法实现:编写了
indexOf方法在数组中线性查找元素。 - 数据关联:使用两个平行的数组(
common和counts)来关联单词和其出现次数。 - 程序测试与验证:通过小规模测试用例来验证程序的正确性。


通过这个练习,你不仅掌握了数组的基本操作,也了解了如何将编程应用于实际的文本分析问题中。
081:算法开发


在本节课中,我们将学习如何自动化破解凯撒密码。为了实现这一目标,我们将依赖英语文本中字母的出现频率。


如果你要加密另一种语言的消息,你需要使用该语言的字母频率,但方法相同。
我们将编写代码来寻找待解密消息中出现频率最高的字符。
我们假设这个字符是字母 E,因为在英语文本中,E 的出现频率高于任何其他字母。在俄语中,例如,字母 O 的出现频率高于 E。如果我们关于 E 的假设是错误的,我们将无法解密原始消息。
也可以不仅仅依赖 E,而是依赖所有字母的频率,并使用统计方法来破解凯撒密码。在某些情况下,这些方法也能破解其他加密方法,尽管不是用于在线购物和安全交易的数据加密方法。

让我们分两步来看解密代码。
我们需要统计待加密消息中每个字母 A 到 Z 的出现次数。

我们将编写代码来扫描文本的每个字母,并为 26 个不同的字母分别增加一个计数器。
最初,所有计数器的值都是零,因为我们还没有开始逐个字母地扫描文本。
每个计数器从 0 到 25 编号,因为计数器是数组元素。

一个包含 26 个字母的字符串将帮助我们找到正确的索引,随着我们扫描文本,相应的计数器会增加。

当我们扫描消息时,查看每个字符,例如遇到 H,我们将增加索引 7 处的计数器。
然后扫描到 I 时,我们将增加索引 8 处的计数器,这是 I 在我们字母表字符串中的索引。
遇到逗号或空格时,我们不会增加任何计数器。
接着,遇到 D 时,我们将增加索引 3 处的计数器。遇到 O 时,增加索引 14 处的计数器。
遇到空格时不会增加,因为空格不在字母表中。
遇到 y 时,我们将增加索引 24 处的计数器。
当扫描到消息中的第二个 O 时,我们将把索引 14 处的计数器值设为 2。
扫描完每个字符后,每个计数器将拥有这些值。
如果你仔细观察这些值,会发现我们的解密方法很可能会失败。索引 4 处的计数器值为 0,意味着消息中没有字母 E,但这是一种非常罕见的情况。
现在,我们来看看实现这个想法的代码。


我们使用一个标准的 for 循环逐个字符地扫描消息。

for (int k = 0; k < message.length(); k++) {
// 处理每个字符
}

我们查找字符在字母表字符串中出现的位置,例如 E 会在索引 4 处找到。注意,我们将待解密消息中的字符转换为了小写。
我们使用字母表中的索引来增加相应的计数器,作为解密消息的一部分。
如果字符不在字母表中,indexOf 方法会返回 -1,我们就不增加任何计数器。
String alphabet = "abcdefghijklmnopqrstuvwxyz";
int index = alphabet.indexOf(Character.toLowerCase(ch));
if (index != -1) {
counts[index]++;
}

基于 E 出现频率最高这一想法编写的代码,是从你刚才看到的思路、算法和代码中直接发展而来的。

正如你所见,代码并不长。我们创建了两个辅助方法,并依赖这个凯撒密码类来帮助解密。
我们调用了一个 countLetters 方法,这个方法我们刚刚讨论过。它会统计字符串中每个字符的出现次数,其中 A 的出现次数存储在数组的第一个位置(索引 0)。该方法返回的数组在这里由变量 freqs 引用。
int[] freqs = countLetters(encrypted);


然后我们调用 maxIndex 方法,它将返回 freqs 数组中值最大的那个条目的索引。我们假设这个位置就是 E 被移位后的位置。
我们将计算从这个位置到 E 的距离。E 的索引是 4,因为我们从 A 为 0 开始计数,然后 B、C、D、E 分别是 1、2、3、4。
如果最大值的索引小于 4,我们需要从 26 开始回绕,以找到用于 E 的移位值。
如果加密时使用的密钥是 dkey,那么解密时使用的密钥就是 26 - dkey,然后我们返回解密后的字符串。
int maxDex = maxIndex(freqs);
int dkey = maxDex - 4;
if (maxDex < 4) {
dkey = 26 - (4 - maxDex);
}
return decrypt(encrypted, 26 - dkey);
你将准备好运用你的编程知识来完成解密任务,然后在迷你项目中将这些知识应用到另一种密码上。但有一些细节我们想强调一下。


我们刚才看到的代码中,数组 freqs 的索引和数组中的值存在对应关系。例如,freqs[8] 表示字母 I 出现的频率,因为 I 是第九个字母,索引为 8。记住,我们从索引 0 开始计数。


在寻找最大值时,就像我们调用的 maxIndex 方法(其实现如下所示),我们返回的是最大值的索引,而不是最大值本身。我们使用这个索引来计算到 E 的距离。
public int maxIndex(int[] vals) {
int maxDex = 0;
for (int k = 0; k < vals.length; k++) {
if (vals[k] > vals[maxDex]) {
maxDex = k;
}
}
return maxDex;
}
使用现有的凯撒密码类使得解密过程更加直接。总的来说,使用已经开发和测试过的代码,而不是重新发明轮子,是一个好主意。
本节课总结
在本节课中,我们一起学习了如何通过分析字母频率来自动化破解凯撒密码。我们了解了算法的核心思想:假设密文中出现频率最高的字母对应明文中出现频率最高的字母 E。我们分步实现了统计字母频率、寻找最高频字母索引以及计算解密密钥的代码。最后,我们强调了重用现有、经过测试的代码库的重要性。
082:数组总结 🧩

在本节课中,我们将要学习Java中数组的核心概念、基本操作以及如何利用数组解决实际问题,例如破解凯撒密码。数组是Java中一种强大的数据结构,允许我们通过一个变量名管理多个值。

数组简介 📦
上一节我们介绍了数组的基本概念,本节中我们来看看数组的具体定义和特性。

数组是Java中的索引集合。使用方括号来声明一个数组变量。数组可以存储字符串、整数,甚至其他资源。几乎任何类型的数据都可以存储在数组中。
数组的强大之处在于,一个变量名可以代表两个、十个甚至一百万个不同的值,并且每个值都可以被单独访问。


数组的访问与索引 🔢
这种访问是通过数字索引完成的。索引值从0开始,对应数组中的第一个元素,这与字符串的访问方式类似。就像通过编号可以快速找到一组邮箱一样,通过数字索引访问数组元素有助于高效地存储和访问值。
以下是Java中数组工作原理的快速概述:
- 在Java中,数组使用
new关键字创建。 - 数组一旦创建,其大小就固定不变。
- 但是,存储在数组每个索引单元中的值是可以改变的。正是这一点使得数组既实用又强大。
数组使用 new 创建,方括号既用于指示变量(如 names)是一个字符串数组,也作为 new 语法的一部分来指定数组中的元素数量。
数组的类型与初始化 📝
在Java中,你可以定义整型数组,就像定义字符串数组一样。


以下是不同类型数组的初始化规则:
- 整型数组中的值初始化为
0。 - 字符串和其他对象数组中的值初始化为
null。



数组的赋值与访问 ✏️
你可以使用索引为数组赋值。同样,你也可以通过访问数组来更新其内容。

使用循环遍历数组 🔄

你已经使用索引来访问数组元素,而循环通常用于访问数组中的所有元素。

以下是一个典型的循环代码,它从第一个索引 0 开始,遍历到最后一个有效索引(即数组长度减一)。

for (int k = 0; k < list.length; k++) {
// 循环体
}
在循环体中,循环控制变量通常用于访问数组中的每个元素。


例如,在这个循环中,k 的值用于指示在数组参数 list 中找到某个单词的索引位置。

for (int k = 0; k < list.length; k++) {
if (list[k].equals(word)) {
// 找到单词
}
}

总的来说,这种使用索引遍历所有元素的模式在使用数组解决问题时非常常见。

数组的应用实例:破解凯撒密码 🔐
我们使用数组解决了几个问题,包括破解密码。


你看到了数组是如何被用于破解凯撒密码这种加密消息的方法的。

通过利用索引和数组从消息中获取频率,使得破解凯撒密码成为可能。

索引在加密、解密以及破解密码的过程中都被使用。

需要知道的是,如今互联网上用于保护你交易的加密方式远比凯撒密码安全得多。科学家和数学家们认为,当今的互联网加密无法通过暴力破解甚至智能算法被攻破。
尽管如此,在任何情况下,你都应该在网上谨慎处理你的个人信息。
总结 📚
本节课中我们一起学习了Java数组的核心知识。我们了解了数组是一种索引集合,可以存储多种类型的数据,并通过索引高效访问。我们掌握了数组的声明、初始化和遍历方法,特别是如何使用循环处理数组元素。最后,我们探讨了数组在解决实际问题(如密码分析)中的应用,并认识到现代加密技术的安全性。数组是编程中不可或缺的基础工具,希望你能够熟练运用它。
083:面向对象编程简介

在本节课中,我们将要学习Java面向对象编程的基本概念。我们将探讨什么是对象和类,以及它们如何将代码和数据封装在一起,从而帮助我们解决更复杂的问题。
什么是面向对象编程?🤔
上一节我们介绍了使用Java解决问题的基本方法。本节中,我们来看看Java作为一门面向对象语言的核心特性。
你可能听说过Java是一门面向对象语言,但这具体意味着什么?顾名思义,你的代码将与对象一起工作。事实上,你已经在使用多种类型的对象了,例如字符串、图像资源和CSV记录等。
对象的一个重要特性是它们封装了代码和数据,将它们结合成一个逻辑单元。你已经编写过方法,这些方法就是对象中的代码部分。然而,你还没有创建过自己的字段,字段用于描述对象内部的数据。
以一个熟悉的例子来思考:字符串。字符串是一个对象,它将代码和数据封装在一起。对于字符串,其数据就是它所代表的字符序列,这些字符是字符串对象内部的数据。你也可以在字符串上调用许多不同的方法来操作它,从而操作其内部的数据。你已经熟悉字符串的许多方法,例如 indexOf 和 substring。
类与对象:术语解析 📖
随着对面向对象编程的进一步学习,精确使用术语是有益的。

- 类定义了一种类型,具体规定了该类型对象内部包含哪些字段和方法。
- 对象是类的实例。你可以从同一个类创建许多不同的对象。
你之前已经见过 new 并学过用它来创建东西,现在可以更精确地理解:new 用于创建一个对象的新实例。
为什么使用面向对象语言?🎯
那么,为什么要使用面向对象语言?类和对象的意义何在?

很久以前,编程语言的设计者意识到,让程序员能够以对象的方式思考是有帮助的,因为这更自然地对应了你对世界的思考方式。他们围绕这个理念设计了面向对象语言,并附带了一系列特性,以帮助程序员设计和编写大型程序。
本课程的学习目标 🚀
接下来,你将在这里学习面向对象编程的一些基本特性,以便能够创建包含代码和数据的自定义类。
如果你在本课程之后继续学习我们的“软件设计Java编程原则”课程,你将学到更多面向对象编程的原则和技巧。

现在,让我们开始深入学习吧。
本节课中我们一起学习了:面向对象编程的基本概念,包括对象和类的定义、封装的重要性,以及使用面向对象方法解决问题的优势。我们明确了类是蓝图,对象是类的实例,并理解了 new 关键字的作用是创建对象实例。
084:使用封装重写凯撒密码 🛡️

在本节课中,我们将学习面向对象编程中的一个核心概念:封装。我们将通过重写一个已有的凯撒密码程序,来展示如何将代码组织得更具面向对象特性,使其更易于管理和理解。
课程概述
上一节我们介绍了面向对象编程的基本思想。本节中,我们来看看如何将一段过程式的代码,通过封装数据和方法,改造成一个更清晰、更易于复用的对象。
原有代码回顾
首先,回顾一下之前课程中编写的用于凯撒密码加密的代码。这段代码是一个方法,它接收两个参数:待加密的信息和加密密钥。
public String encrypt(String input, int key) {
// 基于密钥计算移位后的字母表
String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
String shiftedAlphabet = alphabet.substring(key) + alphabet.substring(0, key);
// ... 其余加密逻辑
}
如你所见,该方法首先根据传入的密钥 key 来计算移位后的字母表 shiftedAlphabet。
面向对象方式的重写
现在,我们来看一种不同的、更具面向对象特性的凯撒密码类实现方式。它完成相同的功能,但更好地利用了Java的对象特性。
这个类包含两个字段。字段是一种特殊的变量,它存在于对象内部,而不是方法内部。
public class CaesarCipher {
// 字段:存在于对象内部
private String alphabet;
private String shiftedAlphabet;
// ... 其他代码
}
在这里,两个字段分别是原始字母表 alphabet 和移位后的字母表 shiftedAlphabet。它们被移到了 encrypt 方法之外。注意,它们现在被声明在类内部,但在任何方法外部。这些数据现在被封装在你的对象中。当你创建一个 CaesarCipher 对象时,它将拥有这两个字段,类内部的任何代码都可以通过名称引用它们。
接下来,注意一段看起来像方法的代码,它没有写返回类型,并且名字与类名相同。
public class CaesarCipher {
private String alphabet;
private String shiftedAlphabet;
// 构造函数
public CaesarCipher(int key) {
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
shiftedAlphabet = alphabet.substring(key) + alphabet.substring(0, key);
}
// ... 其他代码
}
这段代码实际上是一个构造函数。构造函数是在使用 new 关键字创建对象时,用于初始化对象的代码。这个构造函数接收密钥 key 作为参数,并使用与你之前相同的方法来初始化 alphabet 和 shiftedAlphabet 字段。
如果你继续往下看这个凯撒密码的实现,encrypt 方法看起来和之前大部分相同,但它不再将密钥 key 作为参数,也不再计算移位字母表。

public String encrypt(String input) {
StringBuilder encrypted = new StringBuilder(input);
for (int i = 0; i < encrypted.length(); i++) {
char currChar = encrypted.charAt(i);
// 使用对象内部的 alphabet 和 shiftedAlphabet 字段进行加密
int idx = alphabet.indexOf(Character.toUpperCase(currChar));
if (idx != -1) {
char newChar = shiftedAlphabet.charAt(idx);
// ... 大小写处理逻辑
encrypted.setCharAt(i, newChar);
}
}
return encrypted.toString();
}

方法的其余部分保持不变。实际上,这段代码使用了对象中的 alphabet 和 shiftedAlphabet 字段,尽管它们没有在方法内部声明。因为该方法位于对象内部,所以允许使用对象内的任何字段。
两种方式的对比图解
一个图示有助于理解这两种方法之间的区别。

在旧代码中,一个凯撒密码对象不持有任何数据。当你执行 new CaesarCipher() 时,你不传递任何参数,创建的是一个空对象。当你调用 encrypt 时,你需要传递信息和密钥,然后方法返回加密后的信息。
旧方式:
对象: [空]
调用: encrypt(信息, 密钥) -> 返回加密信息
在新方式中,每个凯撒密码对象内部都封装了一个密钥。现在,当你执行 new CaesarCipher(密钥) 时,你传入密钥,创建的对象会将这个密钥存储在其内部。当你在此类对象上调用 encrypt 时,你只需传入信息。密钥已经存在于对象中,它仍然会返回与之前相同的加密信息。

新方式:
对象: [存储了密钥]
调用: encrypt(信息) -> 返回加密信息
封装的优势
既然这两种实现方式产生相同的结果,那么面向对象方法有什么好处呢?
当你将密钥封装在密码对象内部时,你就得到了一个能够接收信息并对其进行加密的独立实体。这构成了一个思考“执行任务的事物”的良好逻辑单元。你不需要单独跟踪密钥并传递它。
对于你目前编写的小型程序来说,这可能看起来没什么大不了的。然而,当你解决代码更复杂的大型程序时,这种设计思想将对你大有裨益。
本节总结

本节课中,我们一起学习了如何通过封装来改进代码结构。我们通过将凯撒密码的密钥和移位字母表作为对象的字段,并使用构造函数进行初始化,使得加密逻辑更清晰,对象职责更单一。这种设计使得代码模块化程度更高,更易于维护和扩展,是面向对象编程的核心优势之一。
085:深入理解字段(实例变量)💡

在本节课中,我们将深入学习面向对象编程中的一个核心概念:字段,也称为实例变量。我们将探讨字段是什么,它们如何工作,以及如何根据设计原则在类中合理地使用它们。
上一节我们介绍了面向对象的基本概念,本节中我们来看看字段的具体含义和作用。
什么是字段?🔍
字段,或称实例变量,是在类内部但在任何方法外部声明的变量。它们属于对象,并在使用 new 关键字创建对象时被创建。这些字段是对象的一部分,只要对象存在,它们就存在。

例如,在我们重新设计的凯撒密码类中,我们创建了两个字段:一个用于存储原始字母表,另一个用于存储移位后的字母表。

public class CaesarCipher {
private String alphabet;
private String shiftedAlphabet;
// ... 构造函数和方法
}

这意味着你创建的每一个凯撒密码对象,都拥有其自己独立的 alphabet 和 shiftedAlphabet。这就是为什么字段被称为“实例变量”——每个对象实例都拥有自己的一套变量。
实例变量的工作原理⚙️
让我们更深入地看看字段和实例变量意味着什么。

每个凯撒密码对象都拥有其字母表的一个副本。
以及移位后字母表的一个副本。
你可以创建不同的凯撒密码对象,每个对象都将拥有自己的实例变量。这些变量是特定于该实例的。
如果你用三个不同的密钥创建三个凯撒密码,每个密码都拥有这些字段的副本,并且可能具有不同的值。

例如,一个密码的移位字母表可能是 "QRS...",这是基于传递给构造函数的整型移位值。
而另一个对象的移位值可能对应字母表 "MNO..."。
第三个对象的实例变量在构造时可能被设置为 "HIJ..."。
方法与数据的封装📦
要在对象上调用像 encrypt 这样的方法,你通常会使用一个变量名,例如 cc,然后写 cc.encrypt()。在BlueJ的对象工作台上调用方法时也是如此。

当你调用 encrypt 方法时,该方法将使用你用来调用方法的那个对象(例如 cc)中的字段值。
在这个凯撒密码对象上调用 encrypt 将使用其 QRS... 移位字母表。
因此,当我们调用 encrypt 方法并提供消息 "Leion attack East flank" 作为参数时,将使用 QRS... 字母表来创建你在此处看到的加密版本。

同样地,在这个凯撒密码对象上调用 encrypt 将使用其 MNO... 移位字母表。

这就是封装的原则。方法和数据在逻辑上位于对象内部,并且方法对其所在对象内部的数据进行操作。
正如你在这里看到的,调用 encrypt 使用了这个移位字母表,并且我们看到了同一消息的不同加密版本,因为此处使用了以 MNO... 开头的 shiftedAlphabet 字段。

最后,使用这个字段为 HIJ... 的对象调用 encrypt 方法时,将产生不同的加密消息。加密代码使用此实例的移位字母表,并创建你在此处看到的加密消息。

字段或实例变量是设计和使用类时非常重要的概念,因为它们可以被类中的每个方法(如这里看到的 encrypt 方法以及构造函数)访问。
类的设计原则📐
当你开始设计自己的类,并思考要在这些类中放入哪些字段和方法时,应该牢记以下几个设计原则。
以下是几个关键的设计原则:

- 类名应对应名词。类描述事物。你为特定类创建的每个对象都是该事物的一个实例。例如,字符串、像素、CSV记录,这些都是名词,它们描述了一个事物。一个类可以是“汽车”,这是一个名词。那么方法和字段就对应于汽车可以做的事情或拥有的东西。
- 方法对应动词。方法是你对对象执行的操作,例如
getPixel、setColorAt或encrypt。有时方法名听起来不像动词,例如substr或indexOf,但它们描述的是动作(获取子字符串或查找索引),程序只是缩短了名称。对于汽车,方法可能是accelerate(加速)或brake(刹车)。这些都是汽车可以做的事情。调用一个方法会使汽车加速或突然停止。 - 字段也是名词或形容词。字段描述类所拥有的东西。字符串类可能有一个用于字符序列的字段。字符序列是一个事物,字符串拥有这样一个事物。类似地,一张图像可能拥有许多像素。字段也可以是形容词,因为它们描述对象的属性。例如,一个像素可能有一个或多个描述其颜色的字段,这可以是一个形容词,提供关于像素属性的更多信息。对于汽车,字段可以包括汽车拥有的东西(名词),如具有特定气缸数的发动机或特定尺寸的轮胎。形容词可以描述汽车的颜色、发动机的种类或轮胎的类型。


随着你开始创建更复杂的类,我们将为你提供关于应创建哪些字段和方法的指导。但在编写代码时,请思考这些设计原则。随着经验的积累,你将能够基于这些思想开始自主设计类。
本节课中我们一起学习了Java中字段(实例变量)的核心概念。我们了解到字段是对象的组成部分,每个对象实例都拥有自己独立的字段副本。我们还探讨了封装原则,即方法操作其所属对象内部的数据。最后,我们学习了类、方法和字段的设计原则:类名对应名词,方法对应动词,字段对应名词或形容词。掌握这些原则是进行有效面向对象编程设计的基础。
祝你编码愉快,加速进步!🚀
Java编程和软件工程基础:2-5:可见性修饰符详解 🔒
在本节课中,我们将要学习Java中的可见性修饰符,特别是public和private。我们将探讨它们的含义、用途,以及如何在设计类时应用它们来创建清晰、健壮的代码。
在面向对象版本的凯撒密码程序中,你已经见到了两种可见性修饰符。一种是public,另一种是private。实际上,Java中还有其他可见性修饰符,但解释它们需要更高级的概念,因此本节课我们只聚焦于这两种。
理解 public 修饰符
当你将一个类、字段、方法或构造函数声明为public时,意味着程序中的任何代码都可以访问它。任何类中的代码都可以调用一个public方法、读取或更新一个public字段、使用public构造函数创建对象,以及使用一个public类。
代码示例:
public class MyClass {
public int publicField;
public void publicMethod() {
// 任何代码都可以调用此方法
}
}
理解 private 修饰符
与public相反,当你声明某个元素为private时,你是在告诉Java,只有这个特定类内部的代码才能看到它。例如,在凯撒密码类中,某些字段被声明为private,因此只有该类内部的代码可以读写它们,类外部的代码则完全不允许访问。
代码示例:
public class CaesarCipher {
private int shiftKey;
private String alphabet;
// 只有这个类内部的代码可以访问 shiftKey 和 alphabet
}
如果你尝试从类外部访问private字段或方法,Java编译器会报错,明确指出不允许这样做。这种错误通常意味着你错误地使用了一个类(尤其是来自现有库的类),或者你设计的类中,某个本应公开的元素被错误地设为了私有。
为何使用 private?
你可能会想,把所有东西都设为public不是更方便吗?这样就能在任何地方访问任何内容了。这涉及到抽象的概念。抽象的原则是将接口与实现分离。限制实现细节的可见性有助于强制执行你设计的抽象。
你可以将类中供其他类调用的所有方法(即接口)设为public,而将实现细节设为private。这样,其他类就不应直接了解实现细节,通过声明为private,你可以在代码中强制执行这一规则。
以凯撒密码为例,你希望其他类能够调用encrypt方法,但它们不应该知道具体的实现细节,比如你使用了一个名为shiftAlphabet的变量。将这些细节保持为私有,意味着你可以修改它们,并确保没有其他代码依赖于这些私有的实现细节。
如何选择 public 或 private?
当你开始设计自己的类时,以下是一些通用的指导原则,帮助你选择使用public还是private。
以下是关于字段、方法和类的一些指导原则:

- 字段:字段通常是对象实现的一部分,因此通常应设为
private。 - 方法:这取决于方法的用途。
- 如果方法是类接口的一部分(即你希望类为其他代码提供的行为),则应将其声明为
public。 - 另一方面,有些方法是辅助性的。你编写它们是为了抽象出特定的复杂任务,并不打算让其他类调用。它们只是帮助完成公共接口。这些方法应设为
private,以便只有你类中的代码可以调用它们。
- 如果方法是类接口的一部分(即你希望类为其他代码提供的行为),则应将其声明为
- 类:目前,你应始终将类声明为
public。随着Java技能的提高,你会学到一些更高级的主题,那时可能会出现需要使用非公共类的情况,但现在请一律使用public。 - 构造函数:目前,你也应始终将构造函数设为
public。通常,构造函数是类公共接口的一部分,它们指定了如何创建实例。虽然也存在适合非公共构造函数的情况,但这同样只在你学习了一些更高级的主题后才会遇到。
总结

本节课中,我们一起学习了public和private这两个可见性修饰符的含义和用途。我们了解到,使用private有助于封装实现细节,强制执行抽象,从而创建出更清晰、更易于维护的代码。记住,字段通常设为private,公共接口的方法设为public,辅助方法设为private,而目前类和构造函数通常都应设为public。
087:构造函数 🏗️

在本节课中,我们将要学习面向对象编程中的一个核心概念:构造函数。我们将了解构造函数的定义规则、它的作用,以及它是如何被自动调用来初始化新创建的对象的。

构造函数简介
上一节我们介绍了面向对象编程的基本概念,本节中我们来看看如何初始化一个对象。构造函数是一种特殊的方法,用于在创建对象时初始化该对象。
构造函数在我们之前编写的面向对象的凯撒密码程序中已经出现过。它接收一个密钥作为参数,并用它来初始化对象的字段。
构造函数的声明规则
当你想要编写一个构造函数时,需要遵循特定的声明规则。
以下是构造函数的声明规则:
- 名称必须与类名完全相同。例如,在名为
CaesarCipher的类中,构造函数的名称必须是CaesarCipher。 - 构造函数没有返回类型,甚至没有
void。在普通方法中,你会在public和方法名之间写上返回类型,但对于构造函数,你不需要写任何东西。 - 和普通方法一样,构造函数有自己的参数列表和括号。
- 你可以根据需要定义任意数量和类型的构造函数参数。构造函数可以没有参数,也可以有多个参数。在我们的例子中,有一个类型为
int的参数key。 - 构造函数有一个方法体,就像普通方法一样。你可以在构造函数体中编写任何代码,以指定如何初始化对象。
构造函数的调用方式
构造函数与普通方法不太一样。你不能直接调用它们。相反,它们会在创建对象时自动作为对象创建过程的一部分被调用。
当你创建一个新对象时,构造函数会在新对象创建后立即被调用来初始化该对象。这是构造函数的一大优点。它允许你指定对象应如何初始化,并且你可以确保这段代码会在每个对象被创建时立即运行。你无需担心因忘记调用某些初始化代码而导致的程序错误。

默认构造函数
如果你没有为你的类编写任何构造函数会发生什么?到目前为止,你编写的类都没有显式定义构造函数。
在这种情况下,Java编译器会为你提供一个默认构造函数。
编译器提供的默认构造函数看起来像这样:
public ClassName() {}
它是 public 的,这意味着任何代码都可以使用它来初始化对象。它不接受任何参数,因此在创建新对象时不需要传递参数。它不执行任何操作,不会对对象进行特殊的初始化。
构造函数的工作流程
现在你已经了解了构造函数的规则,让我们看看构造函数是如何工作的。
假设你在程序的其他地方有这样一行代码:
CaesarCipher cc = new CaesarCipher(22);
这行代码指示Java创建一个 CaesarCipher 类的新实例,并通过将 22 传递给构造函数来初始化它。
让我们看看执行这行代码时会发生什么:
- Java首先创建一个新的变量
cc。 - 然后,它创建
CaesarCipher类的一个新实例。这意味着你有了一个新对象,它拥有该类字段(alphabet和shiftedAlphabet)的独立副本。 - 接着,它调用构造函数,并将
22作为key参数传入。 - 进入构造函数内部后,Java开始执行你编写的用于初始化对象的代码。这段代码会初始化
alphabet,然后初始化shiftedAlphabet。 - 构造函数执行完毕后,Java返回到创建对象的那行代码。
key只是构造函数的一个参数,因此它只在该次调用期间存在。然而,字段是对象的一部分,因此它们会继续存在于对象中。 - 最后,Java通过将新创建的对象赋值给变量
cc来完成赋值语句。
总结
本节课中我们一起学习了构造函数。我们了解了构造函数的声明规则,包括其名称必须与类名相同、没有返回类型等。我们探讨了构造函数在对象创建时被自动调用的特性,这确保了对象的正确初始化。我们还学习了当没有显式定义构造函数时,Java编译器会提供一个默认构造函数。最后,我们通过一个例子详细分析了构造函数从调用到执行完毕的完整工作流程。掌握构造函数是理解Java对象生命周期的重要一步。
面向对象编程入门:2.5:核心概念总结

在本节课中,我们将学习面向对象编程的一些基本概念。
上一节我们介绍了面向对象编程的初步应用,本节中我们来总结其核心概念。
🧠 封装
你学习了封装的概念。其核心思想是将代码和数据组合在一个对象中。例如,对象的方法可以操作同一对象内部的数据。
📦 字段(实例变量)
你学习了字段,也称为实例变量。它们允许你声明应存在于对象内部的数据。
🔒 可见性修饰符
你学习了可见性修饰符:private 和 public。它们允许你公开或限制对字段和方法的访问,从而可以强制执行抽象并提供你想要的接口。
以下是主要修饰符的作用:
public:允许从任何其他类访问。private:仅允许在定义它的类内部访问。
🏗️ 构造函数
最后,你学习了构造函数。它允许你编写代码来指定如何初始化所创建的对象。构造函数在创建对象时自动调用。
以下是一个简单的构造函数示例:
public class MyClass {
private int value;
// 构造函数
public MyClass(int initialValue) {
value = initialValue; // 初始化字段
}
}
🎯 总结

本节课中我们一起学习了面向对象编程的四个核心概念:封装、字段(实例变量)、可见性修饰符(public/private)以及构造函数。掌握这些概念是进行面向对象编程(OOP)的基础,它赋予你构建更模块化、更安全代码的能力。
089:随机故事生成器简介 🎲

在本节课中,我们将学习如何通过创建一个随机故事生成器,来探索和掌握Java编程中的核心概念。我们将从一个故事模板出发,通过替换其中的特定部分来生成有趣且多样的故事。


欢迎来到本模块。我们将使用创建随机且有趣的故事这一想法,来激发兴趣并探索重要的Java概念。
上一节我们介绍了本模块的目标,本节中我们来看看一个随机生成的故事示例。我们将用这些故事来引入Java概念。
请看第一个故事。
我的名字是阿尔伯特,我住在法国。有一天,我想去墨西哥旅行,因为我从未见过老虎。我读到那里有巨大且滑溜溜的老虎。然而,因为55个橙色的大手推车使得旅行变得困难,我可能不得不改去厄瓜多尔。那也可以,但到达那里可能需要305分钟。

接下来,我们看另一个具有相同模式的故事。或许你能发现它与前一个故事的相似之处。

我的名字是薇薇安。我住在印度。有一天,我想去美国旅行,因为我从未见过穿山甲。我读到那里有愤怒且有趣的穿山甲。然而,因为95个紫色的滑溜溜的房子使得旅行变得困难,我可能不得不改去中国。那也可以,但到达那里可能需要445小时。
这些故事有许多相似之处,这并不奇怪,因为它们是由一个共同的模板生成的。

了解了故事的基本模式后,本节我们将探讨其背后的机制。你将探索并修改一个Java程序,该程序读取故事模板,在需要的地方找到替换词,并生成故事。


这些故事的相似性体现在模板中那些被替换的部分。这些部分会被从不同类型词汇列表中随机选出的词所替换。
以下是替换过程的几个关键点:
- 名字:你的程序可能会选择一个像“阿尔伯特”或“薇薇安”这样的名字。
- 国家:程序会从国家列表中选择,例如印度、法国、中国、美国等。
- 动物:选择的动物可能包括老虎、穿山甲,或者你想象中的任何龙。
- 名词与形容词:程序会选择名词和形容词,正如你将看到的,这些选择可以创造出有趣的故事。
在探索这些新Java概念时,你将能够与其他学习者分享随机选择的词汇。
本节课中,我们一起学习了随机故事生成器的基本概念。我们看到了如何通过一个固定的模板和可替换的词汇来生成多样的故事。在接下来的课程中,我们将深入Java代码,学习如何实现这个模板读取、词汇替换和故事生成的过程。
090:高层设计概念 🧠

在本节课中,我们将学习如何为一个生成随机故事的程序进行高层设计。我们将思考如何设计一个类,包括其所需的方法、数据以及它们如何协同工作。通过一个具体的例子,我们将把一个大问题分解为更小、更易管理的部分。
上一节我们介绍了设计一个类的基本思路,本节中我们来看看如何手动分析一个具体问题。
你可以自己或与朋友一起,用纸笔来使用一个故事模板。这个模板与你之前见过的类似,它使用单词和标签来创建一个有趣的故事。
让我们看看如何从这个模板创建一个故事。这个模板以“我的名字是。”开头,然后需要一个名字。记住,尖括号中的标签需要被替换。这里,我们需要一个名字。我选择自己的名字“Drew”。

之后,模板继续:“我的工作是。”然后我们需要一个动词。我让朋友选一个动词:“骑”。接着需要一个名词:“恐龙”。如果你见过这样的故事,你会知道这个工作真的很“形容词”,比如“有趣”,因为它们非常“形容词”,比如“毛茸茸”。是的,这听起来像是我退休后想做的完美工作:骑毛茸茸的恐龙。😊
现在,如果你开始为这个随机故事程序开发算法,可能会得到类似下面的步骤。
以下是算法的主要步骤:
- 读取模板中的每一个单词。
- 检查单词是否被尖括号包围。
- 如果是,则从该类别中随机选取一个单词进行替换。
- 如果没有尖括号,则保留原单词。

然而,和往常一样,你需要小心那些对你来说自然而然、但容易被忽略的细节。特别是,我们为每个类别随机选取了单词,但我们具体是怎么做的?更重要的是,如何让计算机程序做到这一点?
如果让你想一个动物,你可以立刻做到。但要解释“想一个动物”的算法似乎很难。作为人类,你只是知道动物是什么。你脑海中隐含地有一个动物列表,可以随口说出一个。这可能不是真正的随机,也许你最近刚看到一只猫,或者正在想你的宠物狗。但对计算机来说,挑选一个动物并不容易。你需要一个算法,并且它需要有数据来操作。程序需要一个明确的动物列表以供选择,这个列表可以写在程序源代码中,也可以从文件或互联网读取。
因此,思考这些步骤时,你会发现有一个步骤对你来说是隐含的,但对计算机必须是明确的:制作一个动物列表。更普遍地说,是为每个模板标签制作一个列表,而不仅仅是动物。



你还应该考虑“读取故事模板中的每个单词”这一步。这些单词从哪里来?这应该是某种输入,比如文件或网站。你的程序需要读取那个文件或网站,这会用到熟悉的类,如 FileResource 和 URLResource。
你可能还会注意到,其中一些步骤有点复杂。为每个类别制作单词列表可能需要不止几行代码,尽管使用 FileResource 或 URLResource 会有所帮助。“选取一个随机单词”也可能需要一些规划和编程。

你的算法,以及由此产生的程序,最终包含复杂的步骤是完全正常的。这些步骤可能就是你将要编写的其他方法的名称。

例如,你可以编写一个从类别中随机选取单词的方法。假设这个方法名为 pickRandomWord。如果你有了这个方法,算法中对应的步骤现在就只是一行代码。你只需调用 pickRandomWord 方法,它就会为你完成工作。
通过算法开发,可以帮助你理清需要编写哪些方法。当你编写这些方法时,可能又会发现需要更多的方法。不必为此担心,因为你正在将大程序分解为许多更小的问题。你发现需要编写的方法,通常会比你开始时面对的问题更简单。
为了制作单词列表,你需要一些变量来保存数据。但是应该如何存储这些数据?每个模板标签对应的单词列表应该是什么类型?
你已经见过两种可行的类型:字符串数组 (String[]) 和 StorageResource。但两者对于这个问题都不是最理想的。每种结构都有其优点和缺点。
StorageResource 类使用起来相对简单。你的代码可以向 StorageResource 添加元素,而无需知道将要添加多少个元素。也就是说,无需知道会添加多少颜色、名词或名字。访问 StorageResource 中的元素需要使用 for 循环遍历所有元素。这使得随机选择一个元素在编码上有点棘手。
另一方面,字符串数组 (String[]) 几乎具有相反的优点和缺点。随机选择一个元素很简单:选取一个小于数组大小的随机索引,然后返回该索引处的元素,例如索引 2 或 7 处的元素。然而,声明一个数组变量需要知道要存储多少个元素。这使得数组并不总是正确的选择。
我们可以使用 StorageResource 或字符串数组来实现这个程序,但我们将在新概念中看到,ArrayList 结合了数组和 StorageResource 的最佳特性。
祝你编程愉快。😊

本节课中,我们一起学习了如何为一个随机故事生成器进行高层设计。我们从手动分析问题开始,逐步推导出算法步骤,并识别出其中隐含的、需要为计算机明确化的部分(如数据列表)。我们讨论了如何将复杂步骤分解为独立的方法,并比较了不同数据结构(StorageResource 和数组)在此场景下的优缺点,为后续学习更强大的 ArrayList 做好了铺垫。核心在于将大问题分解,并仔细考虑数据表示和方法设计。
091:ArrayList

在本节课中,我们将要学习一个名为 ArrayList 的新类。它是Java中一个非常重要的部分,它结合了存储资源类(StorageResource)和数组(Array)的核心特性。事实上,ArrayList 是 StorageResource 类实现的基础。
问题引入
上一节我们介绍了如何使用数组和存储资源类进行计数。本节中我们来看看一个更复杂的问题:如何统计一个文件或网页中有多少个不同的单词?这个问题同样出现在统计一天内访问网站的不同IP地址数量中,这是在线广告计费的关键部分。



你已经见过如何统计数字化DNA中每种核苷酸的数量,也使用过数组来统计凯撒密码中每个字母字符的出现次数。这些都是统计文件中每个单词出现频率的第一步。解决该问题的一个重要部分是找出有多少个不同的单词,这样像“the”这样的单词只会被计为一个,而不是它在文档中出现的573次。


如下图所示,虽然显示了数百个数字,但只有三个不同的数字:4、6和7。我们将首先探讨如何使用存储资源类来解决这个问题。

使用StorageResource统计单词总数
StorageResource 类使得统计文件或网页中的单词总数变得容易。以下是实现步骤:
以下是统计单词总数的基本代码框架:


// 初始化StorageResource对象
StorageResource myWords = new StorageResource();
// 遍历文件或URL资源
for (String word : resource.words()) {
// 将每个单词添加到StorageResource中
myWords.add(word);
}
// 获取单词总数
int totalWords = myWords.size();
如高亮代码所示,使用 FileResource 或 URLResource 进行迭代的代码几乎完全相同。调用 .add() 方法可以将每个单词添加到 StorageResource 的实例变量 myWords 中。完成后,.size() 方法将提供读取的单词总数。


统计不同单词的数量



正如你将看到的,修改代码来统计不同(唯一)单词的数量,而不仅仅是单词总数,也很简单。

字段 myWords 的类型是 StorageResource,可以存储所有单词。.add() 方法会将读取的每个字符串添加到 myWords 中。但我们可以通过一个简单的守卫条件来确保只在单词第一次出现(即尚未存储在 myWords 中)时才调用 .add() 方法。

以下是修改后的代码逻辑:

if (!myWords.contains(word)) {
myWords.add(word);
}

.contains() 方法返回一个布尔值。此处的代码利用该值确保仅当 StorageResource 对象 myWords 中不包含某个单词时,才调用 .add() 方法。

StorageResource的局限性

然而,StorageResource 类并不适合随机选择元素,而这正是我们编写故事生成代码(GladLibs)所需的关键部分。
为了随机选择一个元素,我们必须使用 StorageResource 提供的 Iterable 接口。这意味着我们需要使用循环来访问 StorageResource 对象 myWords 中的每个元素。在下面的循环中,我们实际上希望迭代次数等于变量 choice 中存储的值,因为我们想从 StorageResource 中随机选择一个元素。
以下是尝试随机选择的代码示例:
int choice = random.nextInt(myWords.size());
int counter = choice;
for (String s : myWords) {
if (counter == 0) {
return s;
}
counter--;
}
// 编译器会提示此处需要返回语句
当 counter 的值达到0时,此处的代码会返回一个随机字符串。如代码所示,choice 必须能减到0,因为它初始值小于 myWords 的大小,并且每次循环递减1。然而,Java编译器分析带有if语句的循环时,并不知道if语句在某个时刻必然为真。编译器会指出循环后缺少返回语句是一个错误,即使该部分代码永远不会被执行。

引入ArrayList
使用字符串数组来获取随机元素会更简单,速度也快得多,如下面的代码所示:
String[] wordsArray = ...; // 假设数组已填充数据
int index = random.nextInt(wordsArray.length);
String randomWord = wordsArray[index];
我们只需生成一个随机整数,并将其用作数组的索引。但不幸的是,在声明数组时必须指定其容量。数组不能像 StorageResource 对象那样动态增长。
ArrayList 类提供了一个解决方案,它结合了 StorageResource 和数组的最佳特性。ArrayList 类来自 java.util 包,这个包也包含我们使用过的 Random 类。
ArrayList 在调用其 .add() 方法时,会根据需要自动扩展容量,就像 StorageResource 对象一样。同时,ArrayList 也支持通过索引访问,因此可以像数组一样,无需遍历所有元素就能直接访问第0个或第10个元素。
StorageResource 类在内部就是使用 ArrayList 实现的。事实上,它只是比 ArrayList 稍微容易使用一点。但随着你经验越来越丰富,现在可以直接使用 ArrayList,它可以存储任何类型的对象,而不仅仅是字符串。
ArrayList基本语法

ArrayList 类的基本语法如下所示,在下一课中你将看到它在编码示例中的使用。

以下是声明和使用 ArrayList 的基本步骤:
- 声明:声明
ArrayList变量时,必须使用尖括号指定存储在列表中的对象类型。ArrayList<String> words;

-
创建与初始化:像任何对象一样,创建
ArrayList需要调用new并使用类名作为构造函数。words = new ArrayList<String>(); -
添加元素:可以调用
.add()方法向ArrayList对象添加字符串。words.add("hello"); -
访问元素:可以调用
.get()方法通过索引访问特定元素,类似于数组中的方括号表示法。String firstWord = words.get(0); -
修改元素:
.set()方法可以更改或设置ArrayList对象中特定索引处的元素。words.set(0, "goodbye");
我们将通过一个使用 ArrayList 的编码示例看到更多例子。ArrayList 类比 StorageResource 更强大,可以对任何类型的对象进行排序。它将成为使用Java解决许多问题的重要工具。
总结
本节课中我们一起学习了 ArrayList。我们首先回顾了使用 StorageResource 统计单词总数和不同单词数量的方法,并指出了它在随机访问元素时的局限性。接着,我们引入了 ArrayList 类,它结合了数组的快速索引访问和 StorageResource 的动态增长能力。我们介绍了 ArrayList 的基本语法,包括声明、初始化、添加、访问和修改元素的方法。ArrayList 是一个功能强大的工具,将在你未来的Java编程中发挥重要作用。
092:使用ArrayList统计唯一单词 📊

在本节课中,我们将学习如何使用Java的ArrayList数据结构来统计一个文本文件中每个单词出现的次数。我们将通过一个具体的例子来理解如何动态地存储和更新数据,特别是当数据量未知时。

概述
我们将编写一个程序,用于读取一个文件(例如莎士比亚的《罗密欧与朱丽叶》全文),并统计其中每个单词出现的频率。由于我们无法预先知道文件中有多少个不同的单词,因此不能使用固定大小的数组。我们将使用两个ArrayList结构来解决这个问题:一个用于存储唯一的单词(字符串),另一个用于存储每个单词对应的出现次数(整数)。
核心概念与数据结构

我们将使用两个并行的ArrayList来建立单词与其频率之间的映射关系。

myWords:一个ArrayList<String>,用于存储所有遇到过的唯一单词。myFreqs:一个ArrayList<Integer>,用于存储myWords中对应位置单词的出现次数。
核心关系公式:
对于任意索引 i,myFreqs.get(i) 的值等于单词 myWords.get(i) 在文件中出现的次数。

代码实现步骤解析
上一节我们介绍了使用两个并行ArrayList的核心思路,本节中我们来看看具体的代码实现步骤。
以下是实现单词频率统计的主要步骤:
- 初始化:在类的构造函数中,初始化
myWords和myFreqs两个ArrayList。 - 读取与处理单词:遍历文件中的每一个单词,将其转换为小写以统一计数。
- 检查单词是否存在:使用
myWords.indexOf(currentWord)方法检查当前单词是否已经存在于列表中。该方法返回单词的索引位置,如果不存在则返回-1。 - 更新频率列表:
- 如果单词不存在(
index == -1),则将其添加到myWords的末尾,并在myFreqs的对应位置添加初始值1。 - 如果单词已存在(
index >= 0),则在myFreqs中获取该索引位置的当前值,将其加1后,再设置回原位置。
- 如果单词不存在(
- 输出结果:循环遍历
myWords,打印每个单词及其在myFreqs中对应的频率。

关键代码段说明
让我们仔细看看更新频率列表的核心代码逻辑。
// 假设 word 是当前处理的单词(已转换为小写)
int index = myWords.indexOf(word);
if (index == -1) {
// 单词第一次出现
myWords.add(word);
myFreqs.add(1); // 初始频率为1
} else {
// 单词已存在,更新频率
int value = myFreqs.get(index); // 获取当前频率
myFreqs.set(index, value + 1); // 将频率加1后存回
}
注意:ArrayList<Integer>存储的是Integer对象,而不是基本类型int。因此,我们通过get(index)获取值,通过set(index, newValue)更新值。这与操作int[]数组直接使用array[index]++有所不同。
运行示例与结果
运行程序并处理《罗密欧与朱丽叶》文本文件后,程序会输出所有唯一单词及其出现次数。

例如,输出片段可能如下:
677 the
48 romeo
23 juliet
86 from
...

这表明单词“the”出现了677次,“romeo”出现了48次,以此类推。注意,此示例未处理标点符号,因此“juliet”和“juliet.”会被视为不同的单词。


总结
本节课中我们一起学习了如何利用ArrayList的动态特性来解决一个实际问题——统计文本中的单词频率。我们掌握了以下关键点:
- 使用两个并行的
ArrayList(一个存String,一个存Integer)来建立映射关系。 - 使用
indexOf()方法来判断元素是否已存在于列表中。 - 使用
get()和set()方法来操作ArrayList<Integer>中的整数值。 - 理解了当数据规模未知时,
ArrayList相比数组的优势。
通过这个例子,你应该对ArrayList的基本操作(如add, get, set, indexOf, size)有了更直观的认识,并能够将其应用于需要动态数据管理的场景中。
093:ArrayList的优势与问题


在本节课中,我们将通过一个具体的编程示例,对比数组和ArrayList的使用。我们将探讨数组在某些场景下的局限性,并理解为何ArrayList在处理动态数据时更具优势。
你已经见识过ArrayList的强大和实用性。数组同样也极其有用。接下来,我们将通过一段代码演示,来展示数组在哪些情况下表现不佳。
数组的语法优势 😊
从语法角度看,创建数组比创建ArrayList更简单。例如,需要输入的字符更少。访问数组中的值也更容易,因为 a[k] 既可以用于读取数组位置的值,也可以用于向该位置写入值,其中 k 是索引。相比之下,对于ArrayList,你需要分别使用 .get() 和 .set() 方法进行读取和写入。

对于 int 类型的值,数组在某些方面比ArrayList更有优势。尽管 int 和 Integer 之间的转换大多是自动进行的,但如果你不完全理解 int 和 Integer 的转换机制,偶尔这些转换可能会导致难以发现的错误。
给定索引,递增数组中的值很容易。然而,在ArrayList中,你必须调用 .get() 和 .set() 方法,因为简单地递增 .get() 返回结果的代码是无效的。
数组的致命缺陷:无法动态增长 😔

然而,数组无法动态增长,这是一个非常严重的问题。让我们编写一些代码来演示这一点。

现在,我们想要统计一个文件中不同单词的数量,但我们打算尝试使用数组来实现。这就是我们要在这里做的事情。
我已经开始编写这个程序,类名为 WordsWithArrays。我们遇到的第一个问题是:我们想从文件中读取所有单词,但我们不知道文件中有多少个单词,因此我们不知道应该将数组设置为多大。所以,我们无法真正为此使用数组。因此,我们将使用一个存储资源来完成程序的这一部分。
我已经在这里开始了,我们有一个名为 myWords 的存储资源。我们在构造函数中创建了这个存储资源。

然后,我们有一个 readWords 方法,它将从文件中读取所有单词并将其放入我们的存储资源 myWords 中。请注意,它还会将所有单词转换为小写。
我们有一个名为 contains 的方法,我们将传入一个 String 类型的数组和一个单词,我们想知道这个单词是否在我们的数组中。contains 方法将遍历数组,检查我们传入的单词是否与任何元素匹配。如果匹配,则返回 true。如果遍历完整个数组都没有找到,则返回 false。
现在,我们有一个 numberOfUniqueWords 方法。我们首先要做的是创建一个数组来存储所有唯一的单词。
你可以看到我在这里开始了,我声明了 words 作为一个 String 类型的数组。我必须创建一个新数组,所以我这样做了。然后我遇到了关于大小的问题,我不知道应该把它设为多大。我不知道会有多少个不同的单词。因此,我唯一能做的就是让它和我的存储资源一样大。所以我将大小设置为 myWords.size()。这是唯一安全的做法,因为所有单词都可能是唯一的。
现在,我们将遍历 myWords,并检查每一个单词是否已经在 words 数组中(words 数组只存储唯一的单词)。如果它不在里面,我们就找到了一个新的唯一单词,并将它放入数组中。你可以在这里看到,在这一行我们添加了它。同时,我们还在跟踪我们有多少个不同的单词,因为这个方法将返回不同单词的数量。所以每次我们找到一个新的唯一单词,我们都会给这个计数加一。
接下来,我们有一个测试方法,以便我们可以在这里测试它。所以我们将调用它并测试。让我们编译一下。编译看起来没问题。然后我们运行它。我们必须创建我们的对象,然后调用测试类。


我们必须选择一个文件,所以我选择 confucius.txt。哦,天哪。我们遇到了一个错误。
它在这里显示,我们得到了一个空指针异常。另外在这里,这是我们的输出。你可以看到它确实从文件中读取了所有单词。它说读入了34582个单词,但你也看到它得到了一个空指针异常,并且可以看到异常的位置。它说在 WordsWithArrays 的测试方法第45行,然后在 numberOfUniqueWords 方法中,接着是 contains 方法的第23行。最上面那个可能就是错误发生的地方。如果我们点击它,它会跳转到错误被高亮显示的位置。

你可以看到我们的错误在 contains 方法中。那么问题是什么?问题是,我们使用这个数组来存放所有唯一的单词,但里面还没有任何单词。然后我们实际上遍历了整个数组,而整个数组都是空的,它被初始化为 null。所以我们正在检查一个值为 null 的元素是否等于一个单词,这就是它崩溃的原因,因为你不能将 null 与一个 String 进行比较。

所以,我们需要修复这个问题。我们真正想做的是跟踪我们在数组中放入了多少个唯一的单词,因为我们只想检查那些我们已经实际放入数组的唯一单词。
为了修复这个问题,我们必须做的是:首先,我们必须添加另一个参数,这样我们就知道我们实际放入了多少个单词。所以我将在这里添加一个名为 number 的参数。我们必须给它一个类型,所以它是一个 Integer。然后当我们遍历时,我们只想遍历我们已经放入的那些单词。我们想用 number 替换 list.length,而不是查看整个庞大的数组,我们只想查看那些已经在里面的元素。number 告诉我们当前有多少个单词在里面。
现在我们还必须修复调用 contains 的地方,也就是下面这里。我们必须在这里放一个值。那个值,记住,我们正在跟踪我们放入了多少个唯一的单词,也就是变量 numStored。所以我们将在这里传入 numStored。
让我们编译一下,看看是否有效。我们没有语法错误。让我们试着运行它。

它成功了。所以你可以看到,我们尝试使用数组遇到了很多麻烦。这个问题真的应该使用ArrayList,因为这里发生的情况是:这个文件有34000个单词,其中只有6558个是唯一的单词。这意味着我们使用的数组大小是34000,但只有6500个唯一单词,所以我们有很多额外的空间。这是ArrayList更适合这个问题的另一个原因。
好了,编程愉快。


总结
本节课中,我们一起学习了数组和ArrayList的对比。我们通过一个统计文件中不同单词数量的例子,具体分析了数组的局限性:
- 数组无法动态增长:在创建时必须指定固定大小,这在数据量未知时非常不便。
- 管理逻辑复杂:需要手动跟踪数组中实际存储的元素数量,容易引入错误(如空指针异常)。
- 内存可能浪费:为了确保容量,往往需要分配比实际需求更大的空间。

相比之下,ArrayList可以动态调整大小,自动管理容量,并且提供了更简洁的方法(如 .add())来操作元素,使得代码更简洁、更健壮,更适合处理动态或大小未知的数据集合。
094:ArrayList类总结 📚

在本节课中,我们将总结Java中ArrayList类的核心概念和用法。ArrayList是一种动态数组,它比传统数组更灵活,可以在运行时自动调整大小。我们将回顾其创建、基本操作以及遍历方法。


概述 📋
ArrayList类似于数组,两者都是可索引的集合,允许你使用整数索引访问元素。ArrayList的独特之处在于,它可以在添加元素时动态增长,这意味着你无需像使用数组那样预先知道需要分配多少空间。


ArrayList与数组的异同 🔄
ArrayList和数组都是可索引的集合,你可以使用整数索引访问其中的元素。然而,ArrayList可以在添加元素时自动增长,而数组的大小在创建时是固定的。
与数组和单个字符串元素一样,ArrayList的索引也是从0开始的。访问ArrayList的第一个元素与访问第1000个元素所需的时间是相同的。你可以将列表想象成一系列带有编号的盒子。
导入与创建ArrayList 📦
要使用ArrayList类,你必须从java.util包中导入它。你可以只导入ArrayList类,也可以使用星号导入整个包中的所有类,例如Random类。

创建ArrayList时,你需要使用尖括号语法指定列表中存储的元素类型,这是Java用于泛型或通用元素的语法。

以下是一个存储String对象的ArrayList示例:

import java.util.ArrayList;

ArrayList<String> stringList = new ArrayList<String>();
列表也可以存储Integer对象:


ArrayList<Integer> integerList = new ArrayList<Integer>();

请注意,一个列表必须统一存储整数或字符串,不能在同一列表中混合存储不同类型。
Integer类允许将int值(如0、57或-352)作为Integer对象存储在ArrayList中。Integer类会自动将int值(如57)转换为Integer对象。

ArrayList的常用方法 🛠️


你已经了解了ArrayList对象的几种常用方法。
.add方法:向ArrayList的末尾添加一个元素。ArrayList会根据需要自动增长。.size方法:返回ArrayList中存储的元素数量。这通常等于通过.add方法添加的元素数量。.get方法:使用整数索引访问ArrayList中的单个元素。.set方法:更改存储在特定索引处的值。

遍历ArrayList 🔁

ArrayList通常使用循环进行处理和访问。

使用索引循环遍历

以下是一个典型的索引循环,用于处理ArrayList的每个元素:

for (int k = 0; k < myList.size(); k++) {
String value = myList.get(k);
// 处理 value
}
这种循环通常从0开始,循环条件为小于ArrayList的大小(即.size()返回的值)。在循环内部,使用.get方法和循环索引变量访问每个数组元素。

重要提示:在这种循环中访问数组元素时,不要调用.add或.remove方法,因为这些方法会在循环迭代时改变列表的大小,通常会导致算法出现问题,因为你可能会跳过某些元素或访问无效元素。
使用迭代循环遍历

你也可以使用迭代循环(即我们用于Edu.Duke可迭代类的同类型循环)来访问ArrayList中的元素。

for (String value : myList) {
// 处理 value
}
在迭代循环中,你的代码需要指明ArrayList中存储的值的类型。循环会依次取出ArrayList中存储的每个值,就像处理FileResource或ImageResource类一样。

当你不需要每个ArrayList元素的索引,而只需要元素本身时,可以使用这种循环。

重要提示:与索引循环一样,在迭代循环中不要调用.add或.remove方法。在这种情况下,如果你尝试这样做,Java会产生运行时错误。


总结 🎯

本节课我们一起学习了Java中ArrayList类的核心知识。我们了解到ArrayList是一种动态、可增长的集合,比传统数组更灵活。我们学习了如何导入和创建指定类型的ArrayList,掌握了.add、.size、.get和.set等基本操作方法。最后,我们重点探讨了两种遍历ArrayList的方式:基于索引的for循环和更简洁的迭代for-each循环,并强调了在遍历过程中避免修改列表结构的重要性。正确使用ArrayList是Java编程中一项非常有用的技能。
095:GladLib程序解析与修改指南 🧩

在本节课中,我们将学习如何理解、分析和修改一个名为GladLib的Java程序。我们将探索其内部结构,学习如何为其添加新的功能,例如支持新的故事标签。通过这个过程,你将掌握阅读现有代码、理解类与方法设计,并进行有效修改的软件工程技能。
程序概览与设计理念
上一节我们介绍了数组列表,本节中我们来看看如何将其应用于实际的GladLib程序中。
你已经学习了数组列表,现在是时候研究GladLib Java程序了。你将学习它的工作原理,以及如何修改它以使用新型数据创建故事。你需要理解这个程序,以便作为一名程序员或软件工程师来修改它。有时你需要从头创建程序,有时则需要修改程序、增强它们,使其更健壮。在这个程序中,有许多组件和方法,每一个你都可以自己编写。这意味着你将能够理解每个方法,并能以不同的方式修改程序。


由于你从一个可运行的程序开始,你将能够在测试程序的同时进行改进和添加,以确保它继续正确工作。你将能够理解类设计、方法设计以及初始GladLib Java程序的局限性,并在改进代码的过程中积累经验。

当你努力理解软件设计和工程的这些方面时,你可以讲述关于它的故事。在修改程序时,你将创建可重用的程序组件和可重用的想法,这些将在你积累软件经验和专业知识的过程中发挥作用。
程序结构与工作流程
在开始进行增强和改进之前,我们先来浏览一下这个程序。
与所有Java类一样,构造函数将初始化一个GladLib对象。构造函数将创建用于存放名词、颜色等替换词的数组列表实例变量。它还将创建用于随机选择替换词的java.util.Random对象。
初始化后,程序的一般控制流程是读取故事模板并处理每个单词。如果单词是一个标签(如<country>或<timeframe>,由尖括号表示),代码将随机找到一个替换词。故事创建完成后,程序会将其打印出来。
让我们更详细地看看这些部分。
核心方法:makeStory
首先,快速看一下如何从你刚才看到的模板创建故事。
该类中有一个公共方法makeStory。调用此方法将使用模板生成一个故事,正如我们在更仔细地查看代码时将看到的那样。
以下是调用makeStory方法生成的故事示例:
在肯尼亚,很久以前,大约245个十年前,住着一只粉红色、有趣的老虎。它非常喜欢唱歌和跳舞。但有一只名叫兰斯的愤怒、巨大的狮子,把它吓坏了。
在厄瓜多尔,很久以前,大约105个月前,住着一只快乐、黄色的北极熊。它非常喜欢唱歌和跳舞,但有一只名叫阿尔伯特的狂怒、愤怒的兔子,把它吓坏了。
从模板读取单词和打印故事的代码你可能不需要修改。调用makeStory将从文件或URL读取模板,并循环处理模板中的每个单词。如果单词是带有尖括号的标签,它将被替换。
标签处理与故事输出
在私有方法processWord中,查找标签是indexOf和substring方法的直接应用。我们使用这些方法来确保保留尖括号前后的标点符号或字母。

打印故事将在BlueJ或不同编程环境的控制台窗口中显示最终结果。私有方法printOut有一个参数用于指定行宽,因此你可以创建故事并使用80或40个字符或任何其他数字。你也可以修改printOut方法,使用edu.duke.FileResource类将故事写入文件。
实例变量与数据存储
修改GladLib Java程序需要理解数组列表实例变量的使用方式。对于故事模板中的每个可能标签(如名词或颜色),都有一个对应的数组列表。
这些实例变量应适当命名,以便程序员能够轻松理解它们在阅读和修改代码时的用途。与所有实例变量或字段一样,它们将在调用GladLib构造函数时(无论是使用new还是在BlueJ中创建对象时)被创建和初始化。
程序在替换单词作为讲故事的一部分时,可能会使用所有字段。字段adjectiveList将保存标签adjective的替换词,字段nounList用于名词,实例变量colorList将保存要随机选择的颜色。每个字段都保存其名称对应标签的替换词。这是程序中的一个约定,并非Java所要求,但遵循此约定将使创建新标签(如verbList)的新实例变量变得更简单。
替换逻辑:getSubstitute与randomFrom
我们将逐一查看这些字段的用途,其中之一是为像color这样的标签寻找替代词。
基于作为标签一部分的单词(如color或noun),私有方法getSubstitute将访问相应的实例变量以找到随机替换词。例如,如果标签是color,则将从字段colorList中选择替换词;如果标签是noun,则使用nounList。你可以在getSubstitute方法中看到,添加新标签将需要添加新的if语句来访问相应的数组列表。
使用私有方法randomFrom随机选择一个值。randomFrom和getSubstitute都是私有方法。它们作为调用公共makeStory方法的结果而被调用。当getSubstitute调用randomFrom时,getSubstitute总是传递你创建的实例变量之一(例如adjectiveList、nounList等)作为参数source的值。
数据初始化与读取
初始化数组列表很简单,但你需要理解它才能为新标签创建新字段。所有数组列表都必须在调用构造函数时创建和初始化。构造函数将调用一个私有辅助方法initializeFromSource。颜色、名词等数据的来源可以是URL或文件。调用initializeFromSource将导致读取文件或URL,以便将字符串存储在每个数组列表中。如果initializeFromSource的参数以“http”开头,那么最终将使用URLResource对象来读取数据。否则,将使用FileResource对象。你在这里的代码中看不到这一点,因为参数source只是简单地传递给辅助方法readIt。读取代码位于别处。
修改指南:添加新标签
让我们总结一下用于替换标签的实例变量是如何使用的。你需要理解这一点,以便通过添加新标签来增强程序。
例如,要创建一个像verb这样的标签,你需要一个新的实例变量。你需要适当地命名它,比如verbList。你需要在两个方法中修改代码:添加verbList后,你将修改构造函数调用的私有方法initializeFromSource中的代码;你还将修改方法getSubstitute中的代码(这也是一个私有方法,由公共makeStory方法通过私有方法fromTemplate和processWord调用)。
程序文档应包含类似这样的信息来帮助你,软件工程师,进行修改和增强。

总结
本节课中,我们一起学习了GladLib程序的核心结构。我们了解了其构造函数如何初始化实例变量,makeStory方法如何控制故事生成流程,以及私有方法如何处理标签替换和随机选择。最重要的是,我们掌握了为程序添加新功能(如新的故事标签)的具体步骤:需要创建新的实例变量,并在initializeFromSource和getSubstitute两个关键方法中添加相应的逻辑。通过理解和修改现有代码,你正在积累宝贵的软件工程实践经验。享受讲故事和编写代码的乐趣吧!
096:脆弱代码与软件设计原则 🧩
在本节课中,我们将分析GladLib类的设计特点,并探讨软件设计的一般概念。我们将了解什么是“脆弱”的代码,以及如何通过更好的设计原则来改进它。
软件设计的挑战
上一节我们介绍了GladLib类的基本功能。本节中,我们来看看扩展此类时会遇到的设计挑战。
以添加一个新的标签(例如verb动词)为例,这意味着需要在GladLib类的多个不同位置进行修改,并且必须遵循类中使用的命名约定。
以下是需要完成的步骤列表:
- 创建实例变量:需要创建一个新的
ArrayList实例变量来存储动词示例,如run、think或swim。 - 初始化列表:需要在构造函数中调用
initializeFromSource方法来初始化这个ArrayList。 - 修改获取逻辑:需要修改
getSubstitute方法中的代码,以便在需要时能随机获取一个动词。
你应该遵循类中使用的命名约定。例如,标签noun与实例变量nounList关联,标签country与countryList关联。因此,用于存储动词字符串的ArrayList字段应命名为verbList。
从经验中学习
当你修改和扩展程序和类时,你将获得许多编程和设计方面的经验。这些经验将帮助你在编程时做出正确的决策,并创建出良好的设计。
然而,有时经验也来自于欠佳的判断或设计,这些经历能让你思考不同实现方式之间的权衡。你可能会意识到,一个可行的选择可能会导致代码难以维护。
脆弱性与灵活性
某些软件设计被称为“脆弱”的,这意味着当你试图以与最初意图略有不同的方式扩展或使用该软件时,软件或设计就会“破裂”。
另一方面,灵活的设计能更好地应对软件的变化。在学习面向对象设计时,你可能会遇到一个原则,即代码应对扩展开放,但对修改封闭。这被称为开闭原则。
其核心思想是,你应该能够扩展软件功能,而无需对现有代码进行大量修改。虽然本课程无法深入探讨面向对象的设计思想来实现这一点,但你仍然可以创建比其他设计更“开放”的设计。
评估与改进GladLib类
在学习和实践了这个设计与实现之后,你将能够理解什么是更好的设计。
GladLib类确实有一些优点。每个方法都相对容易理解,并且代码可以运行。正如你将看到的,扩展这段代码是可能的,即使扩展需要在多个地方修改代码。

在学习了我们即将介绍的一些新的Java概念后,你将能够创建出更好的设计。正是因为你拥有了这段代码和这个类的实践经验,这些新概念才会变得更加清晰,你也会理解为什么新的Java特性可以使代码更简洁。

新的Java特性将允许你将更改集中在一处,而不是分散在类的三个不同部分。这些新特性还将最大限度地减少当前实现中存在的重复代码。
总结
本节课中,我们一起学习了“脆弱代码”的概念及其在GladLib类中的体现。我们探讨了扩展代码时面临的挑战,并介绍了“开闭原则”这一重要的软件设计思想。通过当前的实践,你为后续学习更高级的Java特性(如接口、继承和多态)以创建更灵活、更易维护的设计打下了基础。
祝你创作新故事愉快!


097:添加新标签

在本节课中,我们将学习如何为Gladlib程序添加一个新的标签类别,例如“动词”。我们将通过修改源代码、创建新的数据文件和模板文件来完成这一过程。
上一节我们介绍了Gladlib程序的基本结构,本节中我们来看看如何为其添加一个全新的标签类别。


首先,我们需要一个新的模板文件。我使用了之前的标准模板文件 madtemplate.txt,并将其中的“S”和“dance”替换为“verb”和“verb”。这样,我就在原始故事中添加了“verb”标签来替换原有的占位符。这个新文件仍然命名为 madtemplate.txt。你可以使用任何文本编辑器来创建这个文件,例如Mac上的TextEdit或Windows上的Notepad++。
现在,我将使用之前的Gladlib程序,并逐步指出需要修改的位置。

我需要创建一个新的ArrayList来存储动词。我将遵循之前使用的命名约定,确保使用 verbList 来与 adjectiveList、nounList 等保持一致。
浏览源代码时,我发现这些ArrayList在 initializeFromSource 方法中被初始化,该方法由类的构造函数调用。因此,我需要初始化 verbList。它将通过调用 readIt 方法来初始化,传入数据源(可以是本地文件路径或URL)和文件名 verb.txt。
我还需要进行另一处修改。在判断标签类型的代码块中,除了已有的形容词、动物、名字等,我还需要添加对“verb”标签的处理。我将复制并粘贴现有的代码段,然后将标签名称替换为“verb”。我需要确保语法正确:如果标签是“verb”,我将从 verbList 中获取替换词。

以下是修改的核心代码部分:

// 1. 声明实例变量
private ArrayList<String> verbList;
// 2. 在 initializeFromSource 方法中初始化
verbList = readIt(source + "/verb.txt");
// 3. 在 getSubstitute 方法或类似逻辑中添加处理分支
if (label.equals("verb")) {
return getRandom(verbList);
}
接下来,我将编译我的程序。我知道这个程序会从 madtemplate.txt 中读取模板,而我已经修改了这个模板以包含 <verb> 标签。
编译完成后,我将在对象工作台上创建一个新的Gladlib对象,然后运行 makeStory 方法。第一次运行时,输出中出现了“unknown”,这表明程序找到了“verb”标签,但未能成功替换。回顾源代码,我发现 verbList 实例变量已声明,并在 initializeFromSource 中从 verb.txt 初始化。问题出在标签判断的代码中,我拼写有误。修正拼写错误后,我重新创建Gladlib对象并生成故事。
现在,输出变成了:“It's so loved to think and contemplate...”。为了确认程序确实在读取动词,我多运行了几次,得到了“ride and surrender”、“surrender and surrender”等不同的动词组合。请注意,我们尚未实现防止同一动词被重复使用的逻辑,所以有时会出现重复。

总结一下我们所做的步骤:
- 在模板文件中添加新标签。
- 在源代码中声明并初始化对应的ArrayList(
verbList)。 - 在标签处理逻辑中添加对新标签(“verb”)的判断和替换。
- 创建包含新词汇的数据文件(
verb.txt)。 - 编译并测试程序。
本节课中我们一起学习了为Gladlib程序扩展新标签的完整流程。你掌握了如何修改模板、更新源代码以声明和初始化新的词汇列表,并在核心逻辑中集成对新标签的处理。通过遵循清晰的命名约定和结构,你可以轻松地为程序添加更多标签类别,如“副词”或“地点”。现在,你可以尝试添加自己的新标签了。
098:HashMap数据结构详解 🗺️

在本节课中,我们将要学习一种新的数据结构——HashMap。我们将了解它如何帮助我们更高效地组织数据,并改进之前课程中提到的GladLib类的设计。HashMap不仅能让代码更简洁,还能在处理大量数据时显著提升性能。
回顾与引入

上一节我们介绍了使用并行数组列表来统计单词频率的方法。虽然这种方法可行,但存在设计上的缺陷,尤其是在程序规模扩大时,修改和维护会变得困难。
现在,我们来看看如何使用Java的HashMap类来替代并行数组列表,从而获得更优的解决方案。

理解HashMap的核心概念
HashMap是一种将键与值关联起来的数据结构。在许多编程语言中,这被称为“映射”。

- 键: 可以看作是地图上的图例。通过查找图例,你能理解地图上颜色的含义。
- 值: 是与键相关联的具体数据。
在编程中,这个概念更偏向于数学中的“函数”或“映射”。一个键(定义域中的元素)被映射到一个值(值域中的元素)。
在统计单词频率的例子中:
- 键是单词本身(例如
"rainbow")。 - 值是该单词出现的次数(例如
41)。
因此,调用 map.get("rainbow") 将返回整数值 41。


核心公式/概念:
value = map.get(key)
这表示通过键key,可以从映射map中获取其关联的值value。

从并行数组列表到HashMap
以下是使用两个ArrayList(一个存单词,一个存次数)来统计词频的关键代码逻辑:

int index = myWords.indexOf(currentWord);
if (index == -1) {
// 新单词
myWords.add(currentWord);
myFreqs.add(1);
} else {
// 已存在的单词
int value = myFreqs.get(index);
myFreqs.set(index, value + 1);
}
使用HashMap后,相同的逻辑可以这样实现:
HashMap<String, Integer> map = new HashMap<String, Integer>();
// ...
if (!map.containsKey(currentWord)) {
// 新单词
map.put(currentWord, 1);
} else {
// 已存在的单词
int value = map.get(currentWord);
map.put(currentWord, value + 1);
}
可以看到,HashMap用一个对象就替代了两个并行列表,代码意图更清晰。


HashMap的基本操作
要熟练使用HashMap,你需要掌握以下几个核心方法:
以下是HashMap的常用操作:
put(key, value): 将指定的键值对存入映射。如果键已存在,则更新其对应的值。get(key): 返回指定键所映射的值。如果键不存在,则返回null。containsKey(key): 判断映射中是否包含指定的键,返回布尔值。keySet(): 返回一个包含映射中所有键的Set集合。这是遍历HashMap的关键。
如何遍历HashMap
使用并行数组列表时,我们通过索引循环来打印所有单词和频率:
for (int k=0; k < myWords.size(); k++) {
System.out.println(myFreqs.get(k) + "\t" + myWords.get(k));
}


使用HashMap时,我们需要遍历其所有键,然后通过键获取对应的值:

for (String key : map.keySet()) {
System.out.println(map.get(key) + "\t" + key);
}
这里,map.keySet()返回所有键的集合,for-each循环遍历每个键,然后通过map.get(key)获取对应的值。
HashMap的效率优势

当处理的数据量很大时,效率变得至关重要。HashMap在查找速度上具有巨大优势。

其原理是,通过键查找值所花费的时间,大致上与映射中键的数量无关。也就是说,在一个有100万个键的映射中查找,和在一个只有10个键的映射中查找,速度几乎一样快。

相比之下,使用ArrayList的indexOf方法,在最坏情况下可能需要遍历整个列表的所有元素。
让我们看一些实际数据对比(处理不同大小的文本文件):
- 莎士比亚戏剧《尤利乌斯·凯撒》: 两种方法都很快。
- 小说《红字》: HashMap代码比ArrayList代码快大约10倍。
- 《圣经》英王钦定版(超过80万单词): ArrayList代码耗时超过20秒,而HashMap代码仅需不到0.5秒,快了40多倍。

这种效率提升使得HashMap成为处理大型数据集的理想选择。
总结

本节课中我们一起学习了Java的HashMap数据结构。
我们首先回顾了使用并行数组列表的局限性,然后引入了HashMap作为更优的替代方案。我们理解了HashMap键值对的核心概念,并学习了其基本操作put、get、containsKey和keySet。通过代码对比,我们看到了HashMap如何让统计词频的逻辑更简洁。最后,我们探讨了HashMap在处理大数据量时惊人的效率优势,其查找时间几乎不随数据量增长而增加。

掌握HashMap将极大地提升你处理关联数据的能力,并帮助你写出更高效、更易维护的Java程序。在后续课程和项目中,你会经常用到它。
099:使用HashMap统计单词出现次数 📊

在本节课中,我们将学习如何使用Java中的HashMap数据结构来统计一个文件中每个单词出现的次数。我们将从一个统计单词总数的程序开始,逐步将其改造为能够统计每个单词具体出现次数的程序。
概述
我们将编写一个程序,读取一个文本文件,并计算文件中每个单词出现的频率。例如,统计单词“the”或“wonderful”出现了多少次。我们将使用HashMap来存储单词(作为键)和其对应的出现次数(作为值)。


从统计单词总数开始
我们从一个已经可以工作的程序开始。这个程序包含一个名为countWords的方法,它的功能是读取文件,遍历所有单词,并统计单词的总数。
public void countWords() {
FileResource resource = new FileResource();
int total = 0;
for (String word : resource.words()) {
total = total + 1;
}
System.out.println("Total words: " + total);
}
运行这个程序,例如输入文件名“Confucius”,它会输出文件中的总单词数,例如34582个。
引入HashMap统计每个单词
上一节我们介绍了如何统计单词总数,本节中我们来看看如何统计每个单词的具体出现次数。为了实现这个功能,我们需要使用HashMap。
我们需要在方法中添加一个局部变量:一个HashMap<String, Integer>。在这个映射中,键(Key)是文件中出现的每个单词(字符串),值(Value)是该单词出现的次数(整数)。

首先,我们需要创建这个HashMap。确保已经导入了java.util.HashMap包。
import java.util.HashMap;
public void countWords() {
FileResource resource = new FileResource();
HashMap<String, Integer> map = new HashMap<String, Integer>();
// ... 后续代码
}
填充HashMap的逻辑
现在,当我们读取每个单词时,不再只是增加总数,而是需要检查这个单词是否已经在map中出现过。
以下是填充HashMap的核心逻辑步骤:
- 将读取的单词转换为小写,以确保统计不区分大小写。
- 检查这个单词是否已经是
map中的一个键。 - 如果单词已存在(即我们之前见过它),则获取其当前的计数值,加1,然后更新
map。 - 如果单词不存在(即第一次见到),则将其作为新键放入
map,并将值设为1。
以下是实现上述逻辑的代码:
for (String w : resource.words()) {
w = w.toLowerCase(); // 转换为小写
if (map.containsKey(w)) {
// 单词已存在,计数加1
map.put(w, map.get(w) + 1);
} else {
// 单词第一次出现
map.put(w, 1);
}
}
输出统计结果
在将所有单词及其计数添加到map之后,我们需要将结果打印出来。我们将遍历map的键集合(即所有不同的单词),获取每个单词对应的出现次数,并选择性地打印那些出现频率较高的单词。
以下是输出结果的步骤:
- 使用
keySet()方法获取map中所有键的集合。 - 遍历这个集合,对于每个键(单词),通过
map.get(key)获取其出现次数。 - 设置一个阈值(例如500次),只打印出现次数超过这个阈值的单词。
for (String word : map.keySet()) {
int occurrences = map.get(word);
if (occurrences > 500) { // 设置阈值
System.out.println(occurrences + "\t" + word);
}
}
运行修改后的程序,输入“Confucius”文件,我们可以看到出现次数超过500次的单词,例如“the”出现了2000多次,“and”出现了762次。
如果想看到更多单词,可以降低阈值,例如改为200次。再次运行程序,我们会看到更多单词及其出现次数,例如“master”出现了484次。
总结
本节课中我们一起学习了如何使用Java的HashMap来高效地统计文本文件中单词的出现频率。我们掌握了以下关键步骤:
- 创建
HashMap:使用HashMap<String, Integer>来建立单词到计数的映射。 - 填充映射:通过遍历单词,使用
containsKey()检查单词是否存在,并使用put()和get()方法来更新或添加计数。 - 遍历输出:使用
keySet()遍历所有单词,并根据阈值筛选和输出结果。


通过将键映射到值,HashMap成为了解决此类计数问题的强大工具。随着你更多地使用映射来解决问题,你将成为编程的大师。享受编码的乐趣吧!
100:使用HashMap实现灵活设计

在本节课中,我们将学习如何使用HashMap类来改进Gladlib类,使其更易于扩展、代码行数更少,并成为一个展示如何成为更熟练、更有经验的软件设计师的优秀范例。



概述

上一节我们介绍了Gladlib程序的基本结构。本节中,我们将探讨如何利用HashMap来重构程序,以实现更灵活、更易于维护的设计。

原始设计的局限性
作为回顾,在原始设计中,要扩展类以使用一个新的标签(例如 <verb>),需要在三个地方修改代码。
以下是需要修改的步骤:
- 创建一个ArrayList实例变量。
- 正确地初始化它。
- 将其用作随机替换的源。
此外,还需要遵循一个命名约定,例如为标签verb使用字段名verbList。这使得除非所有数据源都遵循相同的命名约定(例如,为标签noun使用文件名noun.txt,为字段colorList使用color.txt),否则很难使用文本文件或URL作为单词替换的源。

引入HashMap的概念
让我们深入探讨扩展Gladlib类背后的需求概念,并寻找一种在类中构建数据的新方法。


每个标签都与一个ArrayList实例变量相关联。例如,标签noun与nounList关联,标签color与colorList关联,依此类推。这些命名的实例变量导致了较差的设计。
添加一个新标签(如verb)需要按名称定义实例变量、按名称初始化它以及按名称使用它。这意味着程序必须在三个地方进行修改。
相反,我们将使用HashMap来帮助创建一个更好、更灵活的设计。HashMap将允许我们将标签与一个ArrayList关联起来,而无需为ArrayList本身命名。
给定一个标签,代码将在HashMap结构中查找或找到关联的ArrayList。这类似于indexOf方法在ArrayList中查找值或在字符串中查找字符的方式。获取与标签关联的值将返回一个ArrayList。
HashMap如何实现灵活设计
让我们更仔细地看看使用HashMap如何创建更灵活的设计。

一个HashMap将替换七个或更多的实例变量。HashMap将引用所需数量的数组,而不是我们必须定义单独的实例变量并遵循命名约定。
代码将使用一个单一的实例变量:一个名为myMap的HashMap。这将把每个标签与一个ArrayList关联起来。因此,Map中的键是字符串(即Gladlib程序中的标签)。与每个键关联的值是该标签的替换单词的ArrayList。
这意味着要添加一个新标签和一个新的ArrayList,我们不必添加新的实例变量。我们只需要在名为myMap的单一HashMap实例中存储新值即可。
重构getSubstitute方法
让我们看看getSubstitute方法如何与HashMap一起工作。
在原始程序中,使用了一系列if语句来识别与特定标签关联的实例变量。使用countryList对应country的命名约定允许程序员扩展代码,但getSubstitute方法中的if语句数量将始终与标签和实例变量的数量一样多。

最后一个if语句有所不同。你可以看到标签<number>会生成一个随机数,而不是在数字列表中查找一个。
当使用HashMap时,getSubstitute方法要简单得多。HashMap将标签与替换词的ArrayList关联起来。通过使用hashmap.get方法获取与字符串标签(如country、noun或color)关联的ArrayList,可以访问该标签的ArrayList。
添加新标签完全不需要修改此方法。这是我们之前视频中讨论过的开闭原则的一个例子。使用HashMap可以创建一个更灵活、更易于扩展的类,但使用HashMap还有更多改进空间。
进一步改进:使用属性文件
原始程序读取文件或URL,将信息存储在每个命名的实例变量中,即该标签的替换值ArrayList。这是通过一系列调用辅助方法readIt的语句完成的。
HashMap版本仍然将标签与文件名关联。该文件名必须在程序中指定,但代码有所不同,因为我们使用循环将每个标签与一个文件名关联。这在原始程序中是不可能的。请注意,私有辅助方法readIt仍然被调用。

如果我们想添加一个新标签(如verb),程序仍然会将替换值文件的名称(例如verb.txt)与新标签关联。我们可以在局部字符串数组变量labels中存储一个新的字符串,例如verb。不幸的是,我们仍然存在一个限制,即代码对文件使用命名约定,例如为标签verb使用verb.txt。

我们可以以不同的方式使用HashMap,在不修改程序的情况下将文件名与标签关联起来。
程序可以被设计为读取一个信息文件,该文件指定在哪里可以找到替换标签的单词,而不是要求修改代码、编译、测试和运行,只是为了在不同的文件或网站中查找名词。
这种文件通常被称为.properties或属性文件。它只是简单地将标签与该标签的替换源关联起来。在.properties文件中使用冒号分隔值的约定很常见,但等号也可以分隔值。可以使用FileResource对象读取.properties文件,并将属性文件中的信息存储在HashMap中。


假设使用一个名为myLabelSource的HashMap实例变量,将标签(如noun)与单词源(如gladlibs.com/nounsFunny.txt)关联起来。initializeFromSource方法将简单地循环遍历作为键存储在HashMap中的值(通过keySet方法访问键)。使用.get方法从Map中检索指定每个标签源的字符串。然后使用该源将值读入该标签的ArrayList。
你可以将此功能添加到Gladlib程序中,使其更具可扩展性。
总结
本节课中,我们一起学习了如何使用HashMap来重构Gladlib程序,从而显著提高其灵活性和可扩展性。我们了解到,通过将标签与数据源动态关联,可以避免硬编码的实例变量和复杂的if-else逻辑,使代码更简洁、更易于维护。这体现了良好的软件设计原则,如开闭原则,为构建更复杂的应用程序奠定了基础。
101:GladLib项目总结 🎯



在本节课中,我们将总结通过GladLib创意故事生成项目所学到的核心知识。我们将回顾ArrayList和HashMap这两个关键数据结构,并理解如何利用它们来构建灵活、可扩展的程序。

项目概述 📖
GladLib是一个Java程序,它从文件或URL读取模板,并根据用户选择的主题生成有趣的故事。这个项目不仅展示了编程的趣味性,更重要的是,它引出了对ArrayList和HashMap类的学习需求。



学习ArrayList类 📚

上一节我们介绍了GladLib项目,本节中我们来看看ArrayList类。我们使用GladLib项目来激发对ArrayList类的研究需求。

ArrayList类似于数组,它支持通过索引访问单个元素。但ArrayList对象可以根据需要动态增长,而不是固定大小。

一个ArrayList对象是一个可索引的元素集合。ArrayList存储的是对象类型,因此你可以存储Integer对象,但不能直接存储int基本类型值。这意味着你通常需要分两步更新ArrayList中的整数值:首先获取并更新该值,然后将该值放回列表中。

要使用ArrayList类,你必须从java.util包中导入它。相比之下,使用数组则不需要指定包。
以下是ArrayList的一些实用方法:
add:在ArrayList末尾添加一个新元素。size:确定ArrayList中的元素数量。get:通过索引访问元素。set:使用索引更新元素。indexOf:通过索引确定元素在ArrayList中的存储位置。
你可以编写代码来遍历ArrayList中的所有元素,方法有两种:一是将ArrayList对象作为可迭代对象使用,二是使用int类型的for循环,从0开始循环,直到但不包括ArrayList的大小,通过索引访问每个元素。

学习HashMap类 🗺️
在了解了ArrayList之后,我们再来研究HashMap类。我们同样使用GladLib项目来激发对HashMap类的研究。


一个HashMap对象是键值对的集合。键作为访问值的映射,因此得名HashMap。键和值都是对象,因此你会使用Integer而不是int,就像在ArrayList中一样。

键最好是像String或Integer这样的不可变对象。键必须是唯一的。值可以是任何对象类型,包括你在示例中看到的String或ArrayList。
你需要从java.util导入HashMap,就像导入ArrayList类一样。
在示例中,你看到了以下方法:
put:向映射中添加一个键值对。size:确定映射中键值对或键的数量。get:通过键访问值。keySet:用于遍历所有元素。containsKey:确定一个键是否在映射中。


遍历所有元素需要对映射的键集合进行迭代。你不能像使用ArrayList那样通过索引逐个访问单个元素。这是两种具有不同优势的集合。
可扩展性设计 💡

我们还使用GladLib类作为一个小的案例研究,来理解创建可扩展的代码和程序是一个好主意,但这需要思考、规划和经验。

课程总结 🏁
本节课中我们一起学习了通过GladLib项目掌握的核心概念。我们深入探讨了ArrayList类,它是一种可动态增长、支持索引访问的集合。我们也研究了HashMap类,它是一种高效的键值对映射结构。两者都是Java集合框架中不可或缺的部分,各有其适用的场景和优势。希望你能享受使用它们构建程序的乐趣。
Java编程和软件工程基础:2-5:Web服务器日志分析简介

在本节课中,我们将学习如何编写程序来分析Web服务器的日志文件。我们将了解日志文件的结构、分析日志的目的,并开始编写读取日志文件内容的代码。
现在,你将编写一些程序来分析Web服务器日志文件。
大多数主要的Web服务器都会将每次访问记录到一个文件中。该文件记录了谁发出了请求、请求的时间、请求的内容以及服务器的响应。


那么,为什么需要分析Web服务器日志呢?Web服务器的日志文件能让你深入了解网站的使用情况。

你可能想知道有多少人访问了你的网站,它是否受欢迎。
如果你有很多不同的页面,它们是否都获得了流量,还是只有少数页面有流量。

了解网站的使用情况尤其重要,特别是当你试图通过它盈利时。受欢迎的页面能带来收入,而无人访问的页面则对你的业务没有帮助。
日志文件对于诊断问题也很有用,因为它会告诉你服务器何时出现错误。
如果某个页面因为链接损坏而没有获得流量,你需要知道以便修复它。
在本课的剩余部分,你将编写代码来读取日志文件的内容。能够读取日志文件的内容将为你解决各种问题奠定基础,例如计算有多少不同的访问者访问过网站,或者每个访问者访问过多少次。


本节课中,我们一起学习了分析Web服务器日志文件的重要性。我们了解到日志文件记录了访问者的请求和服务器响应,分析这些数据有助于了解网站流量、诊断问题并优化业务。接下来,我们将开始编写代码来实际读取和处理这些日志数据。
103:理解日志文件 📄

在本节课中,我们将学习如何处理Web服务器日志文件。我们将了解日志条目的含义,并学习如何在Java中创建类来表示这些信息,为后续编写处理日志的代码打下基础。
日志条目的含义
上一节我们介绍了Web服务器日志的重要性,本节中我们来看看如何理解日志文件中的具体条目。

下图展示了一条来自Web服务器日志的条目。它包含大量信息,但每部分的含义并不直观。


要理解其含义,你需要查阅相关Web服务器的文档。这些特定数据来自Apache 2.4 Web服务器的访问日志。因此,你可以通过谷歌搜索“Apache 2.4 web server log file format”来获取更多信息。


搜索结果会提供许多链接,第一个通常会指向Apache官方文档网站。向下滚动页面,你会找到关于访问日志的信息,即记录对Web服务器访问的日志,这正是我们正在处理的日志类型。文档会解释条目中每一部分的含义。

以下是日志条目各部分的解释:

- IP地址:发出本次Web请求的设备在互联网上的地址。
- 接下来的两个短横线:表示缺失的信息。第一个短横线代表请求者身份信息(文档指出此信息不可靠,用户的计算机可能提供虚假信息)。第二个短横线代表用户名,如果用户通过HTTP身份验证(即在网站上输入了用户名和密码)登录的话。

- 日期和时间:请求发出的具体时间。
- 请求内容:包括请求类型(本例中是
GET,表示请求获取一个特定网页)以及请求的页面路径。 - 状态码:本例中是
200,表示成功。状态码有很多种,用于表示成功或失败。你可能熟悉404,这是一个广为人知的状态码,表示请求的页面未找到。 - 字节数:服务器为完成此请求而回复的数据量大小。
在Java类中表示日志条目
现在你已经阅读了文档并理解了每条信息的含义,是时候思考如何在Java类中表示它们了。
首先需要考虑的是每条信息的数据类型。
- 对于IP地址,可以使用
String,因为我们只关心该字段的文本内容。Java本身有一个用于IP地址的内置类,如果我们想实际连接到该地址,它会提供更多功能。但目前我们不需要那些功能,也无需引入其复杂性。 - 我们不需要表示那两个没有有用信息的缺失字段。
- 对于日期,我们需要表示它。你可以使用
String来存储其文本,也可以使用Java内置的Date类。Date类理解日期和时间的概念以及它们之间的关系,因此你可以检查一个时间是否在另一个时间之前或之后。 - 对于请求内容,可以直接使用
String。 - 对于状态码和字节数,两者都是数字,因此可以使用
int。
设计LogEntry类
思考完数据类型后,是时候将其转化为Java代码了。下图展示了LogEntry类的开始部分。我们声明了一个公共类LogEntry,并根据刚才讨论的类型编写了字段。

现在你应该思考:这些字段应该是public还是private?请记住,如果一个字段是public,任何代码都可以访问它;如果一个字段是private,则只有这个特定类内部的代码可以访问它。
在这个特定案例中,将每个字段设为private并设计类为不可变的是合理的。回想之前我们学习字符串时提到的,不可变意味着对象一旦创建就不能被修改。因此,你将编写这个类,使得每个字段在其构造函数中设置,但只能被读取。
为了让外部代码能够读取这些字段,你需要编写公共的getter或访问器方法,如下所示。这些方法只返回该字段的值,但一旦对象构造完成,就没有办法再设置字段的值。
说到构造,你需要为这个类编写一个构造函数。有两种方法可以实现:
- 构造函数接收整个日志字符串,然后由构造函数将其拆分成各个独立的部分,并填充类的字段(或实例变量)。
- 构造函数分别接收每一部分信息,并简单地初始化对象的字段。
我们将采用第二种方法,即创建一个如下所示的构造函数,根据分别传入的每一部分信息来填充字段。
为什么选择这种方式?这给了你更多的灵活性。如果你想从其他信息来源创建这样一个对象,也可以做到。实际上,将整行日志字符串拆分开来有点棘手,因此我们会提供相关代码。这段代码有点复杂,我们会将其打包成一个好用的方法供你使用,以便你只需用它来读取文件。
完整的LogEntry类
下图展示了完整的LogEntry类,包含了刚才讨论的所有内容。你将使用这个类来表示一条日志条目。


因此,你的下一个任务是使用这个类,以及我们提供的用于将日志行拆分成独立部分的代码,将它们组合起来,编写能够读取整个日志文件的程序。
总结


本节课中,我们一起学习了如何解读Web服务器日志条目的结构,并设计了一个Java类(LogEntry)来封装这些信息。我们确定了每个字段的合适数据类型,决定将类设计为不可变的,并通过私有字段和公共getter方法来实现数据封装。这为后续实际读取和处理日志文件中的数据奠定了基础。
104:带toString的LogEntry类
在本节课中,我们将学习Java中一个非常重要的概念:toString方法。我们将通过一个LogEntry类的例子,了解如何自定义对象的字符串表示形式,以及Java如何自动调用这个方法。
概述
每个Java对象都继承自Object类,而Object类中有一个默认的toString方法。默认情况下,这个方法返回的是对象的内存地址信息,这通常不是我们想要的。我们可以通过在自己的类中重写toString方法,来定义当打印对象时应该显示什么内容。本节我们将通过实践来理解其工作原理。
初始的LogEntry类与toString方法
首先,我们有一个LogEntry类,它包含五个字段:IP地址等。我们为它编写了一个构造函数和一些方法。其中,我们特别编写了一个toString方法。
public String toString() {
// 返回包含五个字段信息的一个长字符串
return ipAddress + " " + ... ; // 示例返回格式
}
为了测试,我们创建了一个Tester类。它创建了两个LogEntry对象le和le2,并为它们填充了不同的信息。然后,它直接打印这两个对象。
System.out.println(le);
System.out.println(le2);
当我们运行测试程序时,控制台会打印出每个LogEntry对象的五个字段信息,这正是我们toString方法中定义的内容。这是因为Java在打印对象时,会自动寻找并调用该对象的toString方法。
重命名方法后的影响
上一节我们看到了toString方法如何工作。现在,我们来做一个实验:将toString方法的名字改为getLogInfo。
public String getLogInfo() {
// 返回包含五个字段信息的一个长字符串
return ipAddress + " " + ... ;
}
然后我们再次运行测试程序。这次,控制台打印的不再是字段信息,而是类似LogEntry@1b6d3586这样的内存地址。
这是因为Java在打印对象时,只会自动寻找名为toString的方法。由于我们将方法名改成了getLogInfo,Java找不到toString方法,于是便回退到使用从Object类继承来的默认toString方法,该方法就是返回对象的内存地址。
为了验证,我们可以在测试代码中显式调用getLogInfo方法:
System.out.println(le.getLogInfo()); // 打印字段信息
System.out.println(le2); // 打印内存地址
运行后,第一行会调用我们自定义的getLogInfo并打印信息,第二行则因为找不到toString而打印内存地址。
恢复toString方法
从上面的实验可以看出,方法的名字至关重要。现在,我们把方法名改回toString,并移除测试代码中的显式调用。
public String toString() {
// 返回包含五个字段信息的一个长字符串
return ipAddress + " " + ... ;
}
测试代码恢复为直接打印对象:
System.out.println(le);
System.out.println(le2);
再次运行程序,两个对象都会打印出我们定义的五个字段信息。即使我们没有在代码中显式写出le.toString(),Java运行时环境也会自动去查找并调用这个方法。
方法名必须完全匹配
toString方法的拼写必须完全正确,包括大小写。这是Java语言的规定。如果我们将其改为tostring(小写‘s’):
public String tostring() { // 注意,这里是‘tostring’,不是‘toString’
// 返回包含五个字段信息的一个长字符串
return ipAddress + " " + ... ;
}
那么当我们打印对象时,Java会寻找toString方法。由于找不到(我们写的是tostring),它就会再次使用默认的实现,打印出对象的内存地址。
总结
本节课我们一起学习了Java中toString方法的核心机制:
- 默认行为:所有Java对象都从
Object类继承了一个默认的toString()方法,该方法返回类名和对象的内存地址。 - 自定义输出:我们可以在自己的类中重写
toString()方法,以返回任何我们想要的字符串,从而定制对象的打印输出。 - 自动调用:当使用
System.out.println()打印一个对象,或进行字符串连接时,Java会自动调用该对象的toString()方法。 - 命名规则:方法名必须严格为
toString,拼写(包括大小写)必须完全正确,否则Java将无法识别它作为默认的字符串表示方法。


掌握toString方法对于调试和日志记录非常有用,它能让我们快速了解对象的状态。
105:解析日志文件
在本节课中,我们将学习如何解析Web服务器日志文件,以创建日志条目类的实例。我们将编写一个日志分析器类,其构造函数和读取文件的方法,为后续的数据分析工作做好准备。
解析日志行
上一节我们创建了日志条目类。本节中,我们来看看如何解析Web服务器日志的每一行,以便能够创建该类的实例。
此任务的核心是将字符串分割成适当的字段,以便将值传递给日志条目类的构造函数。虽然可以通过多次调用 indexOf 和 substring 方法来完成,但代码会非常繁琐。例如,处理时间部分需要将字符串转换为Java内置的 Date 对象。尽管Java提供了 Date 类和解析字符串的方法,但其接口复杂,且服务器日志中的日期格式并非Java默认格式。
因此,课程提供了现成的代码来简化这一过程。以下是使用该解析器的方法:
LogEntry entry = WebLogParser.parseEntry(logLineString);
调用 WebLogParser.parseEntry 并传入要解析的字符串,该方法将返回一个 LogEntry 对象。
编写日志分析器类
了解了如何解析单行日志后,现在我们将注意力转向开始编写日志分析器类。目前,我们将在构造函数中编写初始化对象的代码,然后编写 readFile 方法。在后续课程中,我们将编写其他方法来对已读取的日志文件执行实际分析。
构造函数
首先,填写构造函数的代码。构造函数应将记录字段初始化为一个空的 ArrayList。以下是具体步骤:
public LogAnalyzer() {
records = new ArrayList<LogEntry>();
}
readFile 方法
其次,填写 readFile 方法的代码。此方法将确定要读取的文件名,然后根据打开的文件信息,将日志条目添加到 records 字段中。以下是实现此任务的步骤:
- 为请求的文件创建一个
FileResource对象。 - 遍历
FileResource的每一行。 - 对每一行,使用
WebLogParser.parseEntry方法将文本行转换为LogEntry对象。 - 将该
LogEntry对象添加到records字段(这是一个ArrayList)中。
以下是代码示例:
public void readFile(String filename) {
FileResource fr = new FileResource(filename);
for (String line : fr.lines()) {
LogEntry entry = WebLogParser.parseEntry(line);
records.add(entry);
}
}
测试代码
编写完构造函数和 readFile 方法后,需要测试代码。课程提供了一个名为 printAll 的便捷方法,它将打印存储在实例变量 records 中的所有日志条目。该方法会利用 LogEntry 类的 toString 方法,将每个日志条目表示为字符串进行输出。
当一切运行正常后,就可以开始分析已读入的数据了。
总结


本节课中,我们一起学习了如何解析Web服务器日志文件。我们利用提供的 WebLogParser 工具将日志行转换为 LogEntry 对象,并成功构建了 LogAnalyzer 类的框架,包括初始化数据结构的构造函数和从文件读取数据并填充列表的 readFile 方法。这为后续进行实际的日志数据分析奠定了坚实的基础。
106:Web服务器日志解析与总结
在本节课中,我们将学习Web服务器日志的基础知识。日志文件能提供关于网站访问情况的丰富信息。我们将了解日志的构成、其标准格式,并学习如何根据日志信息创建一个Java类。最后,我们将编写代码来读取并解析日志文件。
🧐 了解Web服务器日志
Web服务器日志是记录网站服务器活动的文件。它包含了每次访问请求的详细信息,是分析网站流量和排查问题的重要工具。
📄 日志记录的格式
上一节我们介绍了Web服务器日志的作用,本节中我们来看看它的具体格式。我们以Apache Web服务器的日志文档为参考,了解其标准记录格式。
每条日志记录通常包含以下核心字段:
- IP地址:发起请求的客户端地址。
- 时间戳:请求发生的日期和时间。
- HTTP请求:客户端请求的方法(如GET、POST)和资源路径。
- 状态码:服务器对请求的响应状态(如200表示成功,404表示未找到)。
- 用户代理:客户端使用的浏览器或设备信息。
🛠️ 创建日志条目类
基于日志文件中的信息,我们可以创建一个Java类来映射和表示单条日志记录。这个类将封装日志的各个字段。
以下是创建此类时涉及的核心步骤:
- 定义与日志字段对应的私有属性(如
ipAddress、timestamp)。 - 提供构造方法来初始化这些属性。
- 为每个属性提供公共的Getter方法。
🔄 重写toString方法
在创建类的过程中,我们学习并应用了toString方法的重写。这是一个重要的Java概念,在你创建的许多类中,都需要编写自定义的toString方法。
toString方法返回对象的字符串表示形式。重写它便于我们调试和输出对象信息。其标准格式如下:
@Override
public String toString() {
return "LogEntry{ip='" + ipAddress + "', time=" + timestamp + "}";
}
📖 编写代码读取日志文件
最后,我们利用提供的解析代码,编写程序来读取日志文件。此步骤将文件中的原始文本行转换为我们之前定义的LogEntry对象列表,为后续的数据分析做好准备。
核心读取与解析逻辑通常包含以下步骤:
- 使用
BufferedReader逐行读取文件。 - 对每一行,调用解析方法或使用正则表达式提取各字段。
- 用提取的字段创建新的
LogEntry对象,并添加到集合(如ArrayList)中。

🚀 下一步:数据分析
现在,你已经准备好编写一些代码,来分析刚才读入的数据了。你可以统计访问量、分析热门资源或追踪特定IP的活动等。

📝 课程总结
本节课中我们一起学习了Web服务器日志的组成与格式,创建了对应的Java数据模型类,实践了重写toString方法,并最终完成了日志文件的读取与解析。这些是处理和分析真实世界数据的基础技能。
107:日志数据分析入门

在本节课中,我们将学习如何分析从网络服务器日志文件中读取的数据。具体来说,我们将解决一个核心问题:如何统计访问网站的不同用户数量。
上一节中,我们已经编写了读取日志文件、解析每一行并创建LogEntry对象数组列表的代码。本节中,我们来看看如何对这些数据进行实际分析。
分析目标:统计独立访问者数量

仅仅查看数组列表中的元素总数是不够的,因为同一个用户可能多次访问网站。因此,我们需要一种方法来区分来自不同来源的请求。
以下是解决此问题的关键思路:
- 我们可以利用日志文件中记录的IP地址来判断请求的来源。
- 使用IP地址并非完美的方法,因为我们无法区分使用同一台计算机的不同用户。
- 然而,统计所看到的不同IP地址的数量,是估算网站独立访问者数量的一个非常有效的指标。
核心概念:IP地址
正如在《编程与网络入门》课程中提到的,IP地址是互联网上设备的地址。无论该设备是传统计算机、手机还是其他设备,都通过IP地址进行标识。
其核心作用可以用以下公式表示:
独立访问者数量 ≈ 日志中不同的IP地址数量
任务分解
要解决这个问题,我们需要对已读入日志条目的数组列表进行操作,找出其中包含的不同IP地址的数量。
具体步骤如下:
- 遍历包含所有
LogEntry对象的数组列表。 - 从每个
LogEntry对象中提取IP地址字段。 - 使用一种数据结构(例如
HashSet)来存储和自动去重这些IP地址。 - 最终,该去重集合的大小即为不同IP地址的数量。
总结
本节课中,我们一起学习了如何开始分析服务器日志数据。我们明确了通过统计不同IP地址来估算网站独立访问者数量的目标,并理解了IP地址作为网络设备标识符的核心概念。接下来,你就可以动手编写代码,实现从日志数据中提取并统计不同IP地址的功能了。
108:算法开发_1

在本节课中,我们将学习如何开发一个算法,用于统计一个列表中唯一值的数量。我们将从一个简单的颜色列表例子开始,逐步过渡到处理Web服务器日志中的IP地址问题。
概述
我们已经编写了读取Web服务器日志全部内容的代码,现在需要找出其中包含多少个唯一的IP地址。我们将遵循解决问题的七个步骤,从第一步开始,并使用颜色名称代替IP地址来简化问题。本质上,这是一个统计字符串数组中唯一值数量的问题。

从颜色列表理解问题
上一节我们介绍了问题的背景。本节中,我们来看看如何通过一个简单的例子来理解核心算法。
假设我们有一个包含10个颜色名称的列表。通过观察,你可能能直接看出其中只有4个唯一值。但如果列表包含一百万个元素,我们就需要开发一个通用的方法。
有多种方法可以解决这个问题。其中一种我们不会采用的方法是:在遍历列表时,划掉之前见过的值。在Java编程中,修改参数列表的值会产生副作用,这是我们应尽可能避免的。
因此,我们将采用另一种思路:依次访问列表中的每个值(这是处理数组问题的典型方法)。如果我们之前没有见过某个值,就将其复制到一个新列表中。
以下是该过程的步骤说明:
- 访问“pink”。它是第一个值,新列表为空,所以复制到新列表。
- 访问“green”。新列表中不存在,所以复制到新列表。
- 访问“pink”。新列表中已存在,所以不复制。
- 访问“green”。新列表中已存在,所以不复制。
- 访问“pink”。新列表中已存在,所以不复制。
- 访问“pink”。新列表中已存在,所以不复制。
- 访问“orange”。新列表中不存在,所以复制到新列表。
- 访问“blue”。新列表中不存在,所以复制到新列表。
- 访问“pink”。新列表中已存在,所以不复制。
最终,我们创建的新列表包含四个值。因此,我们编写的方法将返回参数数组中唯一值的数量:4。
算法模式与伪代码
开发这个算法遵循了许多你之前见过的模式。你会注意到,有时需要向副本列表添加元素,有时则不需要。你需要仔细思考添加元素的条件。
一旦理清了条件,你就可以用针对输入列表中每个元素的处理步骤来表达算法的主要部分。
我们希望你能完成步骤二和步骤三的思考。

以下是该算法可能的伪代码:
初始化一个空的“副本”列表。
对于“原始”列表中的每一个“元素”:
如果“元素”不在“副本”列表中:
将“元素”添加到“副本”列表中。
返回“副本”列表的大小作为唯一值的数量。
应用到日志分析问题
对于处理Web服务器日志这个具体问题,做法需要稍作调整。

请记住,你有一个LogEntry对象的records列表,你需要使用getIpAddress方法从每个LogEntry对象中获取IP地址字符串。
有几种方法可以处理这个差异。最简单的是使用相同的算法,但稍作调整以反映以下事实:你需要处理records列表中的对象,并从每个records元素中获取IP地址,检查该IP地址是否已在copy列表中,如果不在,则将该IP地址添加到copy中。
调整后的伪代码如下:
初始化一个空的“副本”列表。
对于“records”列表中的每一个“logEntry”对象:
从logEntry中获取“ipAddress”字符串。
如果“ipAddress”不在“副本”列表中:
将“ipAddress”添加到“副本”列表中。
返回“副本”列表的大小作为唯一IP地址的数量。
此时,你应该测试你的伪代码逻辑,然后就可以准备将其转化为Java代码了。
总结

本节课中,我们一起学习了如何开发一个统计唯一值的算法。我们从简单的颜色列表入手,理解了通过创建副本列表来筛选唯一值的核心思路。随后,我们将这个通用算法适配到了具体的Web服务器日志分析场景中,即从LogEntry对象中提取IP地址并进行去重统计。这个过程体现了算法设计从抽象到具体的应用。
109:从算法到代码实现 🧑💻

在本节课中,我们将学习如何将之前设计的、用于统计网络服务器日志中唯一IP地址数量的算法,转化为实际的Java代码。我们将遵循伪代码的步骤,逐步构建方法,并进行测试以确保其正确性。
算法回顾与代码框架
上一节我们介绍了统计唯一IP地址的算法。本节中,我们来看看如何将其转换为Java代码。我们从一个方法框架开始,其中包含了之前开发的伪代码。
public int countUniqueIPs() {
// 伪代码步骤将在此转换为实际代码
}
初始化数据结构
首先,我们需要创建一个列表来存储已发现的唯一IP地址。我们将使用一个ArrayList,并初始化为空列表。
ArrayList<String> uniqueIPs = new ArrayList<String>();
遍历日志记录
接下来,我们需要遍历records列表中的每一条日志条目。records是我们类中的一个实例变量。我们将使用for-each循环来完成这个任务。
for (LogEntry le : records) {
// 处理每条日志
}
提取并检查IP地址
在循环体内,我们需要从每条日志条目中提取IP地址,并检查它是否已经存在于我们的uniqueIPs列表中。
以下是处理每条日志的具体步骤:
- 从日志条目
le中获取IP地址。 - 检查该IP地址是否不在
uniqueIPs列表中。 - 如果不在,则将其添加到列表中。
String ipAddr = le.getIpAddress();
if (!uniqueIPs.contains(ipAddr)) {
uniqueIPs.add(ipAddr);
}
返回结果

当所有日志条目处理完毕后,uniqueIPs列表的大小就是唯一IP地址的数量。我们需要返回这个值。
return uniqueIPs.size();
整合完整代码
现在,让我们将所有部分整合到一起,并调整代码格式以确保清晰可读。
public int countUniqueIPs() {
ArrayList<String> uniqueIPs = new ArrayList<String>();
for (LogEntry le : records) {
String ipAddr = le.getIpAddress();
if (!uniqueIPs.contains(ipAddr)) {
uniqueIPs.add(ipAddr);
}
}
return uniqueIPs.size();
}
编译与测试
代码编写完成后,首要步骤是编译它,以确保没有语法错误。编译成功后,我们需要通过测试来验证其逻辑是否正确。


我们已准备了一个测试类UniqueIPTester。它会执行以下操作:
- 创建一个
LogAnalyzer对象。 - 读取一个简短的测试日志文件(
short-test-log)。 - 调用刚编写的
countUniqueIPs方法。 - 打印出统计到的唯一IP地址数量。

测试日志文件包含多个IP地址,其中一些是重复的。根据设计,我们预期该方法能正确识别并统计出唯一的IP地址数量。
运行测试后,控制台输出了预期的结果,这增加了我们对代码正确性的信心。

本节课中我们一起学习了如何将伪代码算法系统地转化为可运行的Java代码。我们经历了从初始化数据结构、遍历集合、使用条件逻辑进行判断,到最终返回结果的完整流程,并通过测试验证了代码的功能。这个过程是软件开发中从设计到实现的关键一步。
110:相等性 🧩

在本节课中,我们将要学习Java中关于对象“相等性”的核心概念。理解这个概念对于正确使用ArrayList的contains方法至关重要,尤其是在处理自定义对象时。
问题引入:为何代码会出错?

上一节我们介绍了如何从Web服务器日志中找出唯一的IP地址。现在,让我们来看一个非常相似但无法正常工作的代码版本。
以下是您之前为“唯一IP地址”问题编写的正确代码。它使用了一个存储IP地址字符串的ArrayList。
// 正确的代码:ArrayList存储字符串(IP地址)
ArrayList<String> uniqueIPs = new ArrayList<>();
for (LogEntry le : logEntries) {
String ipAddr = le.getIpAddress();
if (!uniqueIPs.contains(ipAddr)) {
uniqueIPs.add(ipAddr);
}
}

但是,如果您编写了下面这段代码,会发生什么呢?这段代码与您写的代码类似,但ArrayList存储的是整个LogEntry对象,并且检查的是列表中是否已存在当前的日志条目对象,而不是其IP地址。

// 错误的代码:ArrayList存储LogEntry对象
ArrayList<LogEntry> uniqueEntries = new ArrayList<>();
for (LogEntry le : logEntries) {
if (!uniqueEntries.contains(le)) { // 检查整个LogEntry对象
uniqueEntries.add(le);
}
}
如果您运行这段代码,将会得到错误的答案。实际上,它会告诉您日志文件中总共有多少条日志记录,而不是其中有多少个唯一的IP地址。
这是为什么呢?
理解相等性:== 与 .equals()
要理解这个问题,我们需要思考contains方法是如何工作的。具体来说,contains如何判断两个对象是相同还是不同的?
contains方法会检查它们是否“相等”。那么,“相等”到底是什么意思呢?
Java有两种不同的“相等”概念。为了说明这一点,假设您有三个字符串变量A、B和C,它们引用了两个不同的字符串对象。
- A和B引用的是完全相同的字符串对象,所以它们显然是相等的。
- A和C引用的是不同的字符串对象,但这两个对象具有相同的逻辑内容(即字符序列相同)。
一方面,您可以说A和C相等,因为它们代表的字符串意义相同。
另一方面,您也可以说它们不相等,因为它们是不同的对象。
这就是Java中存在的两种不同的相等性概念。
- 引用相等:意味着是完全相同的对象。这通过
==运算符实现。if (A == B):Java检查A和B是否引用完全相同的对象。如果是,表达式结果为true。if (A == C):Java检查A和C是否引用完全相同的对象。因为它们不是,表达式结果为false。

- 逻辑相等:意味着具有相同的逻辑含义。这通过
.equals()方法实现。if (A.equals(C)):Java会调用String类中的.equals()方法,该方法检查两个字符串是否具有相同的字符序列。因为这两个字符串字符序列相同,所以A.equals(C)的结果为true。
那么,Java如何知道两个对象是否具有相同的逻辑含义呢?每个类都可以通过定义自己的.equals()方法来指定该类型的对象在何种情况下被视为“相同”。 如果您没有显式地编写一个.equals()方法,其默认行为就是使用==来检查两个对象是否为同一个对象(即引用相等)。
应用于LogEntry类
现在您了解了==和.equals(),您应该为LogEntry类编写一个.equals()方法吗?
首先,您应该思考:两个LogEntry对象在什么情况下算是逻辑上相同?
- 如果它们具有相同的IP地址?虽然这能修复当前这个特定问题中的错误代码,但这不是一个好的通用方法。它并不真正符合“两个日志条目含义相同”的概念。两个不同的请求(即使来自同一台计算机)也不是相同的。
- 如果检查更多信息,比如相同的IP地址和相同的请求字符串?即使这样,也并不意味着两个日志条目是相同的,因为同一台计算机可以多次请求同一个页面。
对于LogEntry对象,合理的做法是:仅当它们实际上是同一个对象时,才认为它们在逻辑上相同。 由于您希望的行为正是.equals()方法的默认行为,因此您不需要显式地重写它。
因为不需要为这个类编写.equals()方法,我们暂时不深入探讨如何编写它。完全理解如何编写一个健壮的.equals()方法需要一些软件设计原则的知识,这将在后续课程中学习。
回顾与总结

然而,现在您已经理解了相等性的概念,并且知道contains方法依赖于检查两个对象是否“相同”,您就能理解为什么之前的代码无法正常工作。
- 错误代码:
ArrayList存储LogEntry对象,并使用默认的.equals()(即==)进行比较。由于每条日志记录都是不同的对象,contains总是返回false,导致所有条目都被添加,最终得到的只是条目总数。 - 正确代码:
ArrayList存储String对象(IP地址)。String类已经重写了.equals()方法,使其比较字符串内容。因此,相同的IP地址字符串会被正确识别为已存在,从而计算出唯一IP地址的数量。


本节课中,我们一起学习了Java中==和.equals()的区别,理解了对象相等性的两种概念,并分析了依赖对象相等性的方法(如contains)在实际编程中可能产生的陷阱。掌握这些概念是写出正确、高效Java代码的基础。
111:Web服务器日志分析总结
在本节课中,我们将总结分析Web服务器日志文件数据的核心过程与关键概念。我们学习了如何读取数据、统计独立访问者数量,并深入理解了Java中两种不同的相等性概念。

分析过程回顾
上一节我们介绍了如何读取Web服务器日志文件。本节中,我们来看看整个分析过程的总结。
你已经完成了对读取的Web服务器日志文件数据的首次分析。
你编写了代码来统计有多少个独立的IP地址。
这让你很好地了解了有多少人访问了你的网站。
关键技术实践
以下是完成此任务所练习和应用的核心技术:
- 数组列表的运用:完成这项任务让你很好地练习了数组列表的使用。数组列表对于解决各种各样的问题都非常有用。
- Java相等性概念:你还学习了Java中两种不同的相等性概念。
Java相等性详解
以下是两种相等性概念的详细说明:
-
==操作符:==测试其操作数是否引用完全相同的对象。这是一个引用比较。- 公式/代码示例:
if (object1 == object2) { ... }
- 公式/代码示例:
-
.equals()方法:.equals()方法检查两个对象是否具有相同的逻辑含义。这是一个内容比较。- 代码示例:
if (string1.equals(string2)) { ... } - 每个类都可以定义此方法,但它需要被正确定义,以便提供关于“具有相同逻辑含义”的恰当解释。
- 代码示例:
课程总结

本节课中,我们一起学习了如何通过分析Web服务器日志来统计网站独立访客。我们实践了数组列表的操作,并掌握了Java中==与.equals()方法的根本区别,这对于编写正确的Java程序至关重要。
112:Web服务器日志分析入门

在本节课中,我们将学习如何编写一个程序来分析Web服务器日志,以统计每位用户访问网站的次数。这项技能对于理解用户行为、评估网站吸引力以及发现潜在问题至关重要。
问题背景与目标
上一节我们介绍了处理数据文件的基本方法。本节中,我们来看看一个具体的应用场景:分析Web服务器日志。
网站服务器会记录每一次访问的详细信息,这些信息存储在日志文件中。我们的目标是编写一个程序,从这些日志中提取数据,并计算出每个用户访问网站的总次数。通过分析访问频率,我们可以推断用户对网站的粘性:频繁回访可能意味着网站内容有价值,而单次访问后不再回访则可能提示存在可用性问题。
以下是分析用户访问行为能带来的主要洞察:
- 识别忠实用户:反复访问网站的用户。
- 发现潜在问题:如果大多数用户只访问一次,可能意味着网站内容、导航或用户体验有待改进。
- 应用广泛:此处学习的计数技术同样适用于其他需要统计频率的场景,如分析文本中的词汇、统计交易记录等。
核心实现思路
现在,让我们深入探讨如何实现这个分析程序。其核心逻辑是使用一种称为“映射”的数据结构来关联用户与其访问次数。
程序的基本流程可以分为以下几个步骤:
- 读取日志文件:逐行读取服务器日志文件。
- 提取用户标识:从每一行日志中解析出代表用户的字段(例如IP地址或用户名)。
- 计数:为每个用户维护一个计数器。当程序遇到一个用户时,就将其对应的计数器加1。
- 输出结果:最后,输出每个用户及其对应的访问次数。
实现计数的关键技术是使用 HashMap(哈希映射)。在Java中,HashMap 允许我们将一个“键”(如用户名)与一个“值”(如访问次数)关联起来。
以下是使用 HashMap 进行计数的核心代码模式:
HashMap<String, Integer> visitCounts = new HashMap<>();
// 假设 `user` 是从日志行中提取出的用户名
if (visitCounts.containsKey(user)) {
// 如果用户已存在,获取当前次数并加1
int currentCount = visitCounts.get(user);
visitCounts.put(user, currentCount + 1);
} else {
// 如果用户是第一次出现,将次数初始化为1
visitCounts.put(user, 1);
}
总结

本节课中我们一起学习了Web服务器日志分析的基础。我们明确了通过统计用户访问次数来评估网站使用情况的目标,并介绍了实现这一功能的核心思路——利用 HashMap 数据结构来为每个用户进行计数。掌握这一方法后,你就能将同样的计数逻辑应用于众多需要频率统计的实际问题中。
113:算法开发(第二部分) 🧮
在本节课中,我们将学习如何应用七步问题解决法来开发一个算法,具体任务是统计列表中每个元素出现的次数。我们将从一个简单的例子开始,逐步抽象出通用算法,并最终将其转化为处理特定数据结构的Java代码。
上一节我们介绍了问题解决的基本框架,本节中我们来看看如何将这个框架应用到一个具体的计数问题上。
从具体例子出发 🐾
与之前的问题类似,我们首先观察到一个核心模式:无论处理什么字符串,问题的本质是相同的。为了便于讨论,我们使用比IP地址更直观的动物名称列表作为例子。
我们需要统计以下列表中每个动物名称出现的次数:Cat, snake, T Rex, snake, cat。
以下是解决此问题的步骤:
- 逐步检查列表中的每个动物。
- 记录你看到每个动物名称的次数。
- 检查完列表中所有动物后,你就得到了答案。
在这个例子中,Cat和snake各出现两次,T Rex出现一次。
抽象化具体步骤 📝
既然已经通过一个例子实践了,现在需要仔细思考并写下你刚才具体做了什么。
第一步是创建一个空白的“表格”,用于记录每个名称及其出现的次数。说“创建表格”可以,但最好思考一下这在程序中对应哪种实际的数据类型。
哪种数据类型适合表示这类信息?答案是:一个将字符串(名称)映射到整数(计数)的哈希映射(HashMap)。
一旦意识到这是哈希映射,就可以用其技术术语来称呼列:键(key)和值(value)。最后,给这个哈希映射起个名字以便引用,我们称之为 counts。
接下来,你查看了列表中的第一个字符串“Cat”。你在 counts 中查找“Cat”,发现它不存在,于是将其放入 counts 并设置值为1。
你对第二个字符串“snake”执行了类似步骤,它同样不在 counts 中。对第三个字符串“T Rex”也是如此。
处理第四个字符串“snake”时,情况略有不同。你在 counts 中查找,发现“snake”已经存在且计数为1,于是将其计数更新为2。
类似地,对于最后一个字符串“cat”,你发现它已存在且计数为1,于是将其更新为2。
完成所有步骤后,整个哈希映射 counts 就是你的答案。
这导出了针对此问题具体实例的步骤。
寻找模式并泛化 🧠
现在是时候寻找模式并将其泛化,以适用于问题的任何实例。
请注意,有几个步骤中你在哈希映射中没有找到当前字符串。在这些情况下,你都将其放入哈希映射并设置值为1。为什么是1?你总是想要1吗,还是应该寻找其他模式?思考一下你会发现,这里你总是想要1,因为这是该名称的第一次出现,所以你看到了它一次。
还有一些情况是,该特定名称已存在于哈希映射中。在这些情况下,你将值更新为2。同样,你应该问自己:为什么我用2?我总是想要2吗,还是有其他模式?在这种情况下,你并不总是想要2。相反,你想要的是旧值加一。在这个特定例子中,旧值加一碰巧总是等于2。
考虑到所有这些,你应该能够将这些步骤泛化,并得出一个如下所示的算法。
通用算法描述
该算法创建一个空的哈希映射。然后遍历输入中的每个字符串,并检查该字符串是否已在哈希映射中。
如果不在,算法将该字符串放入哈希映射,其值为1。
如果已在,算法将其值更新为旧值加一。
处理完所有字符串后,答案就是哈希映射 counts。
测试算法 ✅
现在你想测试这个算法。在输入 fish, dog, fish, fish 上测试它。
算法得出了正确答案,因此你可以更有信心地认为它是正确的。
适配到具体问题并转化为代码 💻
在将其转化为代码之前,我们需要记住,我们的输入实际上不是一个字符串列表,而是一个日志条目列表,我们需要处理其中的IP地址。
这个稍作调整的算法基本相同,但我们已将变量名改为 l 以代表日志条目,并遍历 LogAnalyzer 类中实例变量 records(一个ArrayList)的内容。然后,你需要从该日志条目中获取IP地址,并将其用作更新哈希映射的字符串。
现在是时候将其转化为代码了。
以下是实现该算法的Java代码框架:
import java.util.HashMap;
public class LogAnalyzer {
private ArrayList<LogEntry> records;
public HashMap<String, Integer> countVisitsPerIP() {
// 创建一个空的哈希映射来存储IP地址和访问次数
HashMap<String, Integer> counts = new HashMap<String, Integer>();
// 遍历所有的日志记录
for (LogEntry l : records) {
// 从日志条目中获取IP地址
String ip = l.getIpAddress();
// 检查该IP是否已在映射中
if (counts.containsKey(ip)) {
// 如果在,则计数加一
counts.put(ip, counts.get(ip) + 1);
} else {
// 如果不在,则放入映射,计数设为1
counts.put(ip, 1);
}
}
// 返回包含计数的哈希映射
return counts;
}
}
总结 📚


本节课中我们一起学习了如何系统地开发一个算法。我们从分析一个具体的计数例子开始,明确了每一步操作。接着,我们将这些具体步骤抽象化,识别出“首次出现则设值为1,再次出现则旧值加1”的核心模式,从而形成了通用算法。最后,我们考虑了实际问题的数据结构差异,并将通用算法适配、转化为可工作的Java代码。这个过程清晰地展示了从具体问题到通用解决方案,再到最终代码实现的完整思维链条。
114:使用HashMap统计网站访问次数


在本节课中,我们将学习如何编写一个Java程序来分析网站日志文件。具体目标是统计每个IP地址访问网站的次数。我们将使用HashMap这一数据结构来高效地存储和计算访问次数。

分析日志文件

我们想要了解某人访问网站的次数。因此,我们将查看一个包含IP地址的日志文件。例如,对于一个IP地址,我们需要知道它在文件中出现了多少次。这个次数就代表了该用户访问网站的次数。
我们这里有一个程序,一个名为LogAnalyzer的类。我们将编写一个名为countVisitsPerIP的方法,用于统计每个IP地址访问页面的次数。
初始化数据结构


我们将日志条目存储在一个名为records的ArrayList中。我们有一个构造函数来初始化这个ArrayList,还有一个readFile方法。该方法接收日志条目的文件名作为参数,允许你选择一个日志文件,然后逐行读取所有内容并将其放入records中。
编写核心统计方法
我们将重点编写countVisitsPerIP方法,并使用HashMap来实现。
首先,我们需要创建一个空的HashMap。我们将把字符串映射到整数。对于每个IP地址(字符串类型),我们将其映射到一个计数(整数类型),即该IP地址在文件中出现的次数。
以下是创建HashMap的代码:
HashMap<String, Integer> counts = new HashMap<String, Integer>();
现在我们已经有了HashMap,接下来需要遍历所有记录。我们将使用一个for循环来迭代records中存储的所有日志条目。
以下是遍历记录的代码:
for (LogEntry le : records) {
// 处理每个日志条目
}
在循环中,我们将逐个查看每个日志条目。
处理每个IP地址

首先,我们需要从日志条目中获取IP地址。我们创建一个字符串类型的变量来存储它。


以下是获取IP地址的代码:
String ip = le.getIpAddress();
获取到IP地址后,我们需要判断它是否已经存在于我们的HashMap中。我们将使用containsKey方法来询问。
以下是判断IP是否存在的逻辑:
if (!counts.containsKey(ip)) {
// 如果不存在,则首次添加,计数为1
counts.put(ip, 1);
} else {
// 如果已存在,则获取旧值,加1后更新
int oldCount = counts.get(ip);
counts.put(ip, oldCount + 1);
}
当我们处理完所有日志条目后,HashMap中将包含每个IP地址及其出现的次数。最后,我们返回这个HashMap作为结果。
测试程序
为了测试这个方法,我们创建另一个名为CountTester的类。
在这个类中,我们首先创建一个LogAnalyzer对象。
以下是创建对象的代码:
LogAnalyzer la = new LogAnalyzer();
然后,我们需要选择一个文件来读取。这里我们使用一个非常小的测试文件来验证程序是否正确工作。


以下是读取文件并调用统计方法的代码:
la.readFile("short-test_log");
HashMap<String, Integer> counts = la.countVisitsPerIP();
System.out.println(counts);
运行测试程序后,我们得到了一个HashMap的输出,其中列出了每个IP地址及其对应的访问次数。例如,IP地址“152.3.135.44”出现了三次,这与测试文件中的记录相符。

总结

本节课中,我们一起学习了如何使用HashMap来统计网站日志中每个IP地址的访问次数。我们创建了一个LogAnalyzer类来读取日志文件,并编写了countVisitsPerIP方法来处理数据。通过遍历日志条目,我们检查每个IP地址是否已存在于HashMap中,并相应地更新其计数。最后,我们通过一个简单的测试验证了程序的正确性。
115:使用HashMap统计唯一IP地址

在本节课中,我们将学习如何利用HashMap数据结构来解决一个常见的编程问题:统计网络服务器日志中唯一IP地址的数量。我们将看到,通过解决一个更复杂的问题(统计每个IP地址的出现次数),可以轻松地推导出更简单问题(统计唯一IP地址的数量)的答案。
回想一下之前的问题:你需要找出网络服务器日志中有多少个唯一的IP地址。
那个问题的本质是找出有多少个不同的字符串。
观察你刚刚完成的任务——找出每个字符串出现了多少次——你可能会意识到你已经解决了一个更广泛的问题。
统计唯一字符串数量的问题,其解决方案已经蕴含在此过程中。
在这个HashMap中,每一个唯一的字符串都作为一个键存在。我们只需要一种方法,将其转化为我们想要的答案。你的计数算法已经完成了最困难的部分。你只需要能够从HashMap中提取出答案。
这种情况在编程中很常见。你可能编写代码来解决一个更复杂的问题,然后通过利用这个更复杂的算法来完成繁重的工作,从而轻松地解决一个更简单的问题。
识别这类情况对于成为一名高效的程序员非常有帮助。
在这个案例中,使用第二个问题中的HashMap来解决第一个问题非常简单。
HashMap有一个 .size() 方法。这个方法会告诉你HashMap中有多少个键值对。
由于每个键在HashMap中只出现一次, .size() 方法返回的结果正好告诉你输入数据中有多少个唯一的键。
如果你已经先编写了 countVisitsPerIP 函数,那么你只需要下面这两行代码就可以写出 countUniqueIPs 函数。

以下是实现步骤:
- 首先,调用
countVisitsPerIP函数来解决那个更复杂的问题(统计每个IP的访问次数)。 - 接着,使用HashMap的
.size()方法,将上一个问题的答案转化为当前问题的答案。
HashMap的大小正好等于唯一键的数量,这就是当前问题的答案。
在编程时,请始终尝试思考如何利用你已经编写并测试过的代码。这不仅能够提高效率,还能减少错误,并使你的代码库更加模块化和可重用。

本节课中,我们一起学习了如何巧妙地运用HashMap的 .size() 方法来统计唯一元素的数量。我们认识到,通过先解决“统计频率”这个更通用的问题,可以轻而易举地得到“统计唯一性”这个特定问题的答案。这是一种重要的编程思维:构建通用的解决方案,以简化特定任务的实现。
116:哈希映射应用总结 🎯
在本节课中,我们将总结如何利用哈希映射解决网站访问次数统计问题,并探讨如何将同一解决方案灵活应用于其他场景。
概述
你已经成功解决了统计每个用户访问网站次数的问题。解决此问题的核心是运用了之前学习的哈希映射数据结构。通过本练习,你对哈希映射的掌握应已相当熟练,这种熟练度非常宝贵,因为哈希映射是解决众多不同问题的实用数据结构。
哈希映射解决方案回顾
上一节我们介绍了如何使用哈希映射来统计访问次数。本节中,我们来看看这个解决方案的更多可能性。
你通过哈希映射记录了每个IP地址(代表用户)对应的访问次数。以下是该解决方案的核心代码逻辑示例:
HashMap<String, Integer> visitCounts = new HashMap<>();
// 假设 ipAddress 是从日志中读取的IP地址
if (visitCounts.containsKey(ipAddress)) {
// 如果IP已存在,访问次数加1
visitCounts.put(ipAddress, visitCounts.get(ipAddress) + 1);
} else {
// 如果IP不存在,初始化访问次数为1
visitCounts.put(ipAddress, 1);
}
解决方案的扩展应用
更重要的是,你看到了如何将此问题的解决方案轻松应用于另一个不同的问题。
以下是该解决方案的一个直接扩展应用:
- 统计独立IP地址数量:你只需获取结果哈希映射的大小,即可计算出日志文件中存在多少个唯一的IP地址。其核心公式为:
独立IP数量 = visitCounts.size()。
能够意识到一个问题的解决方案可以用于解决另一个问题,是你在成长为程序员过程中需要培养的一项非常有用的技能。

总结

本节课中我们一起学习了哈希映射在统计问题中的强大应用。我们不仅回顾了如何统计访问次数,还探讨了如何将同一数据结构(哈希映射)的解决方案进行扩展,用于快速计算独立访问者的数量。掌握这种举一反三的思维,将极大地提升你解决问题的效率与灵活性。
117:维吉尼亚密码简介与破解 🗝️

在本节课中,我们将要学习维吉尼亚密码的工作原理,并了解如何利用编程来破解它。维吉尼亚密码在历史上曾被认为数百年内无法被破解,但借助计算机,其破解过程将变得相对简单。
维吉尼亚密码的工作原理
回想本课程开始时,我们学习了一点密码学知识并实现了凯撒密码。现在,我们将学习维吉尼亚密码。
维吉尼亚密码的密钥传统上是一个单词,例如,我们选择“dice”作为密钥。你需要重复书写这个单词,使其长度与待加密消息的长度匹配。
每个字母代表一个位移量。因此,“dice”意味着重复地按3、8、2、4进行位移。在Java程序中,将密钥表示为一个整数数组会非常方便。
密钥数组表示:int[] key = {3, 8, 2, 4};

加密过程
现在,让我们看看如何加密。加密时,每个字母都按照其下方写好的数字进行位移,这类似于凯撒密码的操作,但每个字母的位移量都不同。
第一个字母是‘M’,加上3后得到‘P’。第二个字母是‘E’,加上8后得到‘M’。然后在整个消息中重复这个过程。
与处理凯撒密码时一样,我们必须跳过任何非字母字符。

加密公式:密文字符 = (明文字符 + key[i % key.length]) % 26 (需处理字母边界)
与凯撒密码的关联

请注意,从概念上讲,这个密码就像四个不同的凯撒密码:一个位移量为3(蓝色显示),一个位移量为8(红色显示),另一个位移量为2(绿色显示),第四个位移量为4(紫色显示)。
这种相似性意味着,已经编写过凯撒密码实现的程序员可以利用它来帮助实现维吉尼亚密码。实际上,你可以创建一个凯撒密码对象的数组,每个对象使用密钥中指定的位移量,并用它们进行加密。
如果你这样做,可以使用模运算符(%)将计数包装成模式0,1,2,3,0,1,2,3...
代码思路:
CaesarCipher[] ciphers = new CaesarCipher[key.length];
for (int i = 0; i < key.length; i++) {
ciphers[i] = new CaesarCipher(key[i]);
}
// 加密时,对每个字符使用 ciphers[i % key.length] 进行加密
项目任务:破解维吉尼亚密码
在这个迷你项目中,我们将提供维吉尼亚密码的代码,而你的任务是编写代码来破解它。你的目标是获取我们用维吉尼亚密码加密的消息,并在不知道所用密钥的情况下找到解密后的消息。
你将从破解一个已知是英文的消息开始。然后,扩展你的程序,以便尝试破解多种语言的加密。



以下是破解思路的关键步骤:
- 确定密钥长度:通过分析密文中字母的重现模式来推测。
- 分割密文:根据推测的密钥长度,将密文分成若干组。
- 对每组进行频率分析:每一组相当于一个凯撒密码,使用字母频率分析来破解每一组的位移量。
- 组合密钥:将破解出的每组位移量组合成完整的密钥。
- 解密:使用得到的密钥对密文进行解密。

总结

本节课中,我们一起学习了维吉尼亚密码的加密原理,认识到它本质上是多个不同位移量的凯撒密码的组合。我们探讨了其在程序中的实现方式,并概述了如何通过确定密钥长度和频率分析来系统地破解该密码。接下来,你将有机会在实践中应用这些概念,编写程序来破解加密消息。
118:已知语言与密钥长度 🔑

在本节课中,我们将学习如何编写代码来破解维吉尼亚密码,但仅限于已知密钥长度和语言(例如英语)的情况。这是构建完整破解程序的第一步,遵循“先实现一个功能并彻底测试”的软件开发原则。
核心概念回顾

上一节我们介绍了维吉尼亚密码的基本原理。本节中我们来看看如何利用已知信息进行破解。
维吉尼亚密码的行为类似于多个凯撒密码的组合,每个密码作用于消息的不同切片。例如,下图展示了一个用长度为4的密钥加密的消息:


我们根据加密时使用的密钥部分为字母着色。如果只取蓝色字母,它们本质上就是一个用凯撒密码加密的消息,只是分散在整个消息中。因此,你可以使用之前编写的凯撒密码破丨解丨器来找到密钥的这一部分。对红色、绿色和紫色字母重复此过程,即可破解整个密钥。这就是在已知密钥长度时,破解维吉尼亚密码的概念性思路:将字符串切片,然后使用凯撒密码破解每个切片。
方法实现步骤
以下是实现此破解思路需要编写的三个核心方法。
1. 编写 sliceString 方法
首先,你需要编写 sliceString 方法,它接收三个参数:待切片的消息、你想要的切片索引以及切片总数。
例如,调用此方法时,若切片总数为4,切片索引为0,你将获得所有蓝色字母组成的字符串。切片索引为1则获得红色字母,以此类推。注意,换行符也被视为字符串的一部分。
我们提供一些编写建议:
- 使用
StringBuilder类来构建最终返回的字符串,通过append方法添加字符。 - 巧妙使用
for循环。循环可以从任意数字开始,而不仅仅是0,并且可以按任意步长递增。
以下是一个 for 循环示例,它从4开始,以7为步长计数:
for (int i = 4; i < 30; i += 7) {
System.out.println(i); // 将打印 4, 11, 18, 25
}
在编写 sliceString 时,使用变量或参数来控制循环的起始点和步长将非常有用。

2. 编写 tryKeyLength 方法
接下来,编写 tryKeyLength 方法。该方法用于在假设密钥长度为 keyLength 的前提下,寻找加密消息的维吉尼亚密钥。它还需要一个参数 mostCommon,代表目标语言中最常见的字母(目前我们传入 'E')。
在编写此方法时,你需要:
- 利用刚刚讨论的
sliceString方法。 - 使用我们提供的
CaesarCracker类。这个类与你之前编写的类似,但有两处改动:1) 将寻找密钥的代码与解密的代码分离;2) 其构造函数接收你所处理语言的最常见字母作为参数。
该方法应返回一个长度为 keyLength 的整型数组,其中包含 CaesarCracker 为消息的每个切片找到的移位值。

3. 编写 breakVigenere 方法
最后,为这部分程序编写 breakVigenere 方法。这是你将从 BlueJ 环境调用的主方法。
以下是该方法的步骤:
- 使用
FileResource对象读取你想要解密的文件。FileResource有一个有用的方法asString(),可将整个文件内容读入一个字符串。 - 调用
tryKeyLength方法,传入刚读取的消息、给定的密钥长度以及字母'E'。 tryKeyLength将返回一个代表密钥的整型数组。你只需将此数组传递给VigenereCipher的构造函数。- 利用
VigenereCipher对象的.decrypt方法来解密加密的消息。 - 最后,打印出解密结果。

至此,任务完成。
总结

本节课中我们一起学习了在已知密钥长度和语言(英语)的情况下破解维吉尼亚密码的方法。我们分三步实现了核心功能:首先编写 sliceString 来对密文进行切片;然后利用 CaesarCracker 和 tryKeyLength 方法破解每个切片对应的凯撒密码,从而得到完整密钥;最后在 breakVigenere 方法中整合文件读取、密钥破解和解密流程,输出明文结果。这是构建完整、通用破丨解丨器的重要基础步骤。
119:未知密钥长度的破解方法 🔑
在本节课中,我们将学习当不知道加密密钥长度时,如何破解维吉尼亚密码。我们将探讨一种通过尝试不同密钥长度并自动判断解密结果是否正确的策略。
概述
上一节我们介绍了在已知密钥长度的情况下如何破解维吉尼亚密码。本节中我们来看看当密钥长度未知时,我们应该如何应对。
尝试不同密钥长度
如果你不知道密钥长度,可以尝试一些不同的密钥长度。你可以使用刚刚编写的代码,传入一个密钥长度,然后查看结果。例如,如果尝试密钥长度为1,输出结果将是不可理解的,因此1很可能不是正确的密钥长度。尝试密钥长度2,结果似乎也不正确。尝试密钥长度3时,输出看起来可能是英语,包含可读的单词,这表明该消息是用密钥长度3加密的。
自动化尝试过程
你可以编写一个循环来尝试许多不同的密钥长度,从1开始向上计数。计算机可以在几分之一秒内尝试一个特定的密钥长度。因此,即使一条数千行长的消息是用密钥长度92加密的,程序也能在短时间内完成尝试。
但如何判断密钥长度是否正确?你真的需要查看每次迭代的输出吗?我们刚才所做的就是查看输出以判断它是否为有意义的文本。然而,如果我们仔细思考刚才的做法,或许能找到一种自动化的方法。
判断解密正确性的方法
观察不正确的解密结果,思考你是如何知道它不正确的。例如,三个字母的组合可能形成一个单词,但“J.O.W”不是一个真正的单词。同样,“Y T”也不是一个真正的单词,“Y, O B”也不是。这条消息中的所有单词都不是实际的英语单词。
对比正确的解密结果:“H O W”是单词“how”,“D O”是“do”,“Y O U”是“you”。正确解密中的所有单词都是实际存在的单词。
这一观察引出了如何找出正确密钥长度的思路:统计输出中真实单词的数量。
以下是实现步骤:
- 从文件中读取英语单词列表。
- 将单词列表存储在数据结构中。
- 尝试不同密钥长度的解密。
- 对每个密钥长度,统计解密文本中有多少单词是文件中的真实单词。
- 选择产生最多真实单词的密钥长度、密钥和解密文本。
选择数据结构:HashSet
正如我们刚才提到的,使用ArrayList完全可以。读取文件时,你可以使用.add方法将每个单词放入列表。当你想查看一个潜在单词是否真的是列表中的真实单词时,可以使用.contains方法。
一个更好的选择是使用HashSet类。与ArrayList和HashMap类似,你可以使用包含不同类型数据的HashSet,因此你需要在HashSet后面加上尖括号并放入String,表示你需要一个字符串的HashSet。
对于这个问题,你将使用HashSet的方式与使用ArrayList非常相似。你可以在它上面调用.add和.contains方法。你不能像对ArrayList那样通过索引访问HashSet,但如果你想遍历它,可以使用for-each循环。当你遍历HashMap的.keySet时,背后发生的就是这种情况。
主要优势在于,.contains方法会快得多。它不需要搜索HashSet中的每个单词,而只需查看少数几个单词就能判断是否包含所请求的信息。
分割字符串为单词
在开始编码之前,我们要提到的最后一件事是如何将字符串分割成单个单词。你已经看到String有一个.split方法。你可以使用它,传入\\W。这要求split方法在每个非单词字符(空格、标点符号或数字)处分割字符串。完成这一步后,你可以使用如下的for-each循环遍历这些单词:
for (String word : decryptedText.split("\\W")) {
// 处理每个单词
}
总结


本节课中我们一起学习了在未知密钥长度的情况下破解维吉尼亚密码的方法。我们探讨了通过循环尝试不同密钥长度、利用HashSet高效判断真实单词数量,从而自动确定正确密钥长度的完整策略。关键在于将解密输出与已知的英语单词列表进行比较,并选择产生最多有效单词的解密结果。
120:破解未知语言的维吉尼亚密码 🔐

在本节课中,我们将要学习如何扩展我们的维吉尼亚密码破解程序,使其能够处理未知语言的密文。我们将修改现有方法,并编写新的方法来支持多语言字典分析。


概述
上一节我们介绍了如何破解已知为英语的维吉尼亚密码。本节中我们来看看,当密文可能来自其他未知语言时,我们该如何应对。核心思路是:为每种潜在语言尝试破解,并选择解密后包含最多真实单词的结果。
多语言破解策略

为了处理未知语言,我们需要一个包含多种语言单词列表的字典。对于每种语言,我们需要知道其最常见的字母(并非所有语言都是‘E’)。然后,我们可以对每种语言尝试使用相同的破解技术,找出能产生最多真实单词的密钥长度和明文。
以下是实现此策略的关键步骤:
- 读取多语言字典:使用已有的
readDictionary方法,为每种语言读取单词列表。 - 存储字典数据:将每种语言的名称与其对应的单词集合(HashSet)关联起来,存储在一个HashMap中。
- 计算最常见字母:为每种语言的单词集合,编写方法计算其出现频率最高的字母。
- 尝试所有语言:遍历HashMap中的所有语言,对每种语言调用破解方法,并记录最佳结果。
- 修改现有方法:调整主破解方法
breakVigenere和单语言破解方法breakForLanguage,以支持多语言逻辑。

数据结构设计
我们需要一个复杂但强大的数据结构来组织多语言字典。其概念模型如下:
HashMap<String, HashSet<String>>
- 键(Key):语言名称(例如 “English”, “French”)。
- 值(Value):该语言对应的单词集合,由
readDictionary方法读取。
这个嵌套了尖括号的类型看起来有些复杂,但它完美体现了组合这一重要的编程原则。通过将简单的数据结构(如HashMap和HashSet)组合在一起,我们可以构建出功能强大且易于理解的复杂结构。

需要编写的新方法
为了实现上述策略,我们需要编写两个新方法。


1. 计算单词集合中的最常见字母

此方法接收一个 HashSet<String> 参数(即一种语言的单词列表),并返回该集合中出现频率最高的字符。
核心任务:
- 遍历单词集合中的所有单词。
- 统计每个字母出现的总次数。
- 找出出现次数最多的字母。

你已多次练习过计数和寻找最大值的算法,现在正是运用这些技能的时候。

2. 尝试所有语言


此方法接收密文和一个 HashMap<String, HashSet<String>> 参数(即多语言字典)。它会遍历字典中的每种语言,找出解密效果最好的那一种。
算法流程:
- 初始化变量,用于记录最佳解密文本和对应的单词数量。
- 遍历HashMap的键集(即所有语言名称)。
- 对于每种语言:
- 使用
.get()方法从HashMap中获取该语言的单词集合。 - 调用修改后的
breakForLanguage方法尝试破解。 - 使用已有的
countWords方法计算解密文本中包含多少真实单词。
- 使用
- 比较所有语言的结果,保留单词数最多的解密文本及其语言。
- 将最佳结果(语言和解密文本)打印输出。
需要修改的现有方法

除了新方法,我们还需要对两个现有方法进行修改。
1. 修改 breakVigenere 方法
这是从BlueJ调用的主方法。修改点如下:
- 读取字典:不再只读取一种语言的字典,而是读取多种语言并存入我们设计的HashMap中。
- 调用方法:不再直接调用
breakForLanguage,而是改为调用新的breakForAllLanguages方法,让它尝试所有已读取的语言。
2. 修改 breakForLanguage 方法

这是针对单一语言进行破解的核心方法。需要做一个小但关键的改动:
- 动态最常见字母:之前我们固定传入字母‘E’作为最常见字母。现在,我们需要在方法内部,使用新编写的
mostCommonCharIn方法,根据传入的单词集合动态计算出该语言的最常见字母,然后将这个字母传递给tryKeyLength方法。
总结


本节课中我们一起学习了如何将维吉尼亚密码破解程序升级为支持多语言版本。我们设计了使用 HashMap<String, HashSet<String>> 来组织多语言字典,并规划了编写 mostCommonCharIn 和 breakForAllLanguages 两个新方法的任务。同时,我们也明确了需要修改 breakVigenere 和 breakForLanguage 两个现有方法以适应新的逻辑。

现在,你已经了解了完整的计划,是时候设计你的算法并开始编写代码了。
121:欢迎 🎉
在本节课中,我们将要学习本课程的核心主题与学习方法。课程将结合Java编程与软件设计原则,通过分析全球地震数据等实际案例,深入探讨排序、搜索以及面向对象编程的核心概念。
杜克大学在思考如何描述这门课程时,一句源自孔子的、令人深思的话语浮现在脑海中。
但随后意识到,孔子并非精确地写下了这句话,尽管句中每对相邻的词语确实都出现在他的言论里。
我们可以使用一种与预测文本相关的算法过程来生成这个句子,即马尔可夫文本生成过程。这很有趣。
当时我想到了莎士比亚的《罗密欧与朱丽叶》,其中有一句“地震了,现在,罗密欧必须死”。这是另一个相邻词语被发现在一起,但并非原句的例子。
如果我们有1000个有趣的短语,我们可能希望通过搜索和排序来更容易地找到有趣的短语。好主意。
巧合的是,孔子那句话里有“颤抖”这个词,而“地震”则来自《罗密欧与朱丽叶》。如果我们能对这些短语进行筛选。
这可能类似于我的邮件程序区分垃圾邮件和普通邮件的方式。

能够根据短语包含的单词数量、可读性或趣味性对其进行排序处理,将有助于理解随机文本、甚至罗密欧所提及的地震中的趋势和模式。
你一定已经参与了这门关于Java编程和软件设计原则的课程,因为你所讨论的主题和思想是课程的重要组成部分。
我们使用全球地震的实时和存档数据来理解排序和搜索,同时也学习诸如继承、接口和抽象类等面向对象的概念。
没错。我们是你一起创建这门课程的团队的一部分,我们非常高兴能为我们的学习者带来这门专业课程中的第四门课。

我们使用软件设计原则来阐释Java编程。
并且我们使用Java编程来阐释软件设计原则。
这种相互强化有助于使概念易于理解,并能迁移到其他问题甚至其他编程语言中。

尽管我们致力于培养你在Java方面的经验和专业知识,但我很高兴我们能阐释实践中广泛使用的软件设计原则,例如开闭原则,以及作为软件设计和工程基础的“是一个”和“有一个”等面向对象概念。


在Java和其他同样是面向对象的语言中,我们设计的问题引人入胜、可以完成,并且具有恰到好处的挑战性。
我们解释如何使用面向对象的概念设计你自己的类,但也解释如何使用标准Java库中的类和API。
例如,如果你想在不使用Edu Duke库的情况下读取文件或URL,你将看到解释如何做到这一点的课程,并且你将能够研究该库中的类,以了解我们如何使用你将学习的相同概念来设计和实现库中的类。
这将是一门很棒的课程。你将窥见我们希望你在此课程之后,使用BlueJ以外的其他编程环境继续学习Java的前景,尽管BlueJ是我最喜欢的、适合Java编程初学者的环境。
我们希望你会喜欢我们创建的课程。我们努力使它们准备就绪。

这让我们每个人都有些饿了。所以,我们要用饼干和气泡苹果酒来为你在课程中的未来,以及我们完成课程创建的工作而干杯。
嘿,罗伯特,你能选那个最大的饼干吗?谢谢,没错,你从剩下的饼干中选择了最大的那个。
然后我将从剩下的饼干中选择最大的那块。苏珊,你得到那块。嘿,看,它们按照大小被排序了。我想我们使用了选择排序。
现在,我们每人来点气泡苹果酒吧。我们得用...冒泡排序。干杯。
本节课中我们一起学习了本课程的引入,了解了它将如何通过实际案例(如地震数据分析)来结合教授Java编程与核心软件设计原则,并预览了即将学习的关键概念,如排序算法和面向对象设计。
122:实时地震数据分析入门


在本节课中,我们将学习如何编写Java程序来分析和处理实时地震数据。我们将探讨如何访问实时数据、解析标准格式的数据、设计类与类之间的关系,并最终将这些数据可视化到地图上。这些核心编程概念是计算机科学中搜索、排序以及数据处理的基础。
实时数据与程序
实时数据意味着你的程序能够访问在程序运行前几秒或几分钟记录的数据。程序也能读取已记录的数据文件,这有助于在开发和测试代码时验证程序的正确性。
这些程序将展示如何设计使用其他类的类,并最终引入Java的新概念——接口。这些编程思想将用于研究计算机科学和编程中的基础概念:搜索和排序,它们有助于理解数据并快速访问所需数据。
数据格式与解析


数据来源于美国地质调查局(USGS)网站 earthquake.usgs.gov 的实时数据流。数据以XML格式下载,这是一种广泛使用的结构化数据标准格式。XML代表可扩展标记语言。JSON是另一种日益普及的数据传输和存储标准格式,代表JavaScript对象表示法。
解析这些格式的数据可能有些复杂。所有现代编程语言都提供了用于解析格式化数据的API。在本课程中,我们将提供一个API来隐藏部分细节,使数据解析更符合我们对集合和可迭代对象的使用习惯。


数据转换与应用
我们的程序能够解析数据并将其转换为其他格式。例如,创建Google地图需要CSV格式。转换数据是帮助解决问题和解释数据的常见应用。


以下是将XML转换为CSV的代码示例。我们创建了一个API,使你更容易编写处理地震数据的程序。
// 示例:创建解析器对象处理XML数据
EarthquakeParser parser = new EarthquakeParser();
String source = "http://earthquake.usgs.gov/feed/v1.0/summary/2.5_day.atom";
ArrayList<QuakeEntry> list = parser.read(source);
该API可以从文件或URL获取数据。这便于使用文件中存储的相同数据测试程序,或使用每次运行时可能不同的实时数据。
QuakeEntry与Location类
我们将使用两个核心类:QuakeEntry 和 Location。
QuakeEntry 类保存与地震相关的数据,这些数据来自USGS的XML数据流。它包含四个我们用于理解地震数据的实例字段:

- 位置:一个
Location对象,用于编写代码查找彼此接近或靠近你的地震。 - 震级:衡量地震强度的指标。
- 深度:确定地震发生在地下或海下的距离。
- 描述:来自USGS数据流的、与地震位置相关的描述。
Location 是一个独立的类。我们本可以直接使用 double 值存储纬度和经度,但之后你会看到使用独立类的好处。一个类经常使用或包含另一个类,正如 QuakeEntry 类包含一个 Location 对象。
Location 对象改编自Android标准库。Android代码基于Java,是全球运行在最多智能手机上的软件。手机通常可以帮助你找到方向,因为它们基于GPS传感器知道你的位置。Android的 Location 类是一个健壮且经过充分测试的类,你可以在程序中使用它。
项目成果展示

我们使用本课中将学习的程序,记录了2015年11月11日的实时地震数据。


我们利用地震数据创建了Google地图。一张快照显示了美国加利福尼亚州的低震级地震。图例显示,黄色星标代表震级小于1.5的地震。震级小于2.0的地震通常被认为是微震。可以看到加州有很多这样的地震。
我们也可以使用该程序的数据来显示世界各地的地震。例如,一张地图显示了东南亚、日本和印度尼西亚附近地区的较高震级地震。地图使地震群更容易被观察到。
我们通过将实时数据转换为CSV文件来创建这些地图,CSV代表逗号分隔值。这种格式的数据可以直接加载到Google地图中。在本课开发Java程序时,你将看到如何转换数据以及如何使用它。
总结
本节课中,我们一起学习了处理实时地震数据的Java编程基础。我们了解了实时数据的概念,探讨了XML和JSON等标准数据格式及其解析方法。我们介绍了用于封装地震数据的 QuakeEntry 和 Location 类,并看到了如何将数据转换为CSV格式以进行可视化。这些核心概念和技能是进行更深入软件工程和计算机科学探索的基础,也将应用于后续的顶点项目中。
123:类之间的关系

在本节课中,我们将探讨面向对象编程中类如何协同工作的几个核心概念,特别是结合Java语言以及你将看到的与地震相关的程序和类。

概述


我们将学习两种主要的类关系:“拥有”关系和“使用”关系。理解这些关系对于构建模块化、可维护的Java程序至关重要。我们将通过分析地震数据处理程序中的几个关键类来具体说明这些概念。
什么是POJO(简单Java对象)?
上一节我们介绍了课程背景,本节中我们来看看什么是POJO。
QuakeEntry类(定义于QuakeEntry.java文件中)封装了一次地震的基本特征。这些特征包括地震发生的地点、震级、深度以及一个描述标题。这使得QuakeEntry成为一个所谓的POJO,即“简单Java对象”。


这意味着一个QuakeEntry对象本质上就是地震特征的集合。创建一个QuakeEntry对象需要提供所有这些特征。例如,如果不知道地震发生的地点或强度,讨论一次地震是没有意义的。
以下是创建QuakeEntry对象的关键点:
- 必须提供所有参数:如果不向构造函数提供所有参数,就无法创建
QuakeEntry对象。这使得它与某些人认为的POJO略有不同。 - 数据来源:
QuakeEntry对象是在读取和解析地震数据时创建的。 - 数据完整性:创建一个
QuakeEntry对象需要提供一次地震的全部信息:纬度、经度、震级、标题和深度。 - 不可变性:
QuakeEntry对象代表世界上某处发生的一次地震的数据,因此它是不可变的,一旦构造完成就不会改变。 - 访问方式:通过
getter方法访问QuakeEntry对象的状态。例如:getLocation()返回地震的位置。getDepth()返回地震的深度。
- 字符串表示:
QuakeEntry对象还有一个合理的toString()方法,有助于打印地震信息。
Location类:不仅仅是POJO
既然我们已经了解了QuakeEntry这个简单的数据容器,本节中我们来看看一个功能更丰富的类——Location。
地震发生在特定地点,因此我们将使用一个Location类来表示位置。
Location类在QuakeEntry类之外也有许多用途。智能手机使用位置,网络浏览器使用位置,许多其他应用程序也是如此。

我们面临一个选择:是为本课程创建一个简单易用的Location类,还是使用一个经过工业级测试、在其他场景中也很有用的类。
我们采用了来自Android平台的Location类。这为我们带来了几个好处:我们可以确信这个类经过了充分测试,并且其源代码的许可允许我们根据需要来使用和修改代码。
以下是Location类的关键特性:

- 创建方式:
Location对象由纬度和经度创建。这两个值指定了地球上的一个位置。这些值通常可能来自你的手机(从GPS卫星获取数据)或你的网络浏览器(根据你的IP地址确定位置)。 - 核心行为:Android的
Location类有一个distanceTo方法,用于确定任意两个位置A和B之间的距离。这将允许你查找离你居住地最近的地震。 - 超越POJO:这使得
Location类不仅仅是一个POJO。它拥有状态(纬度和经度),但也拥有行为(计算位置之间距离的方法)。
类之间的关系:“拥有”与“使用”
理解了单个类之后,现在我们来探讨它们如何相互关联。Java中的类可以同时具有“拥有”和“使用”关系。

EarthquakeParser类在读取和解析数据时会创建QuakeEntry对象。下面的类图展示了解析器如何通过创建QuakeEntry对象来“使用”它们。
(此处应有类图,显示EarthquakeParser指向QuakeEntry的“使用”关系箭头)
“拥有”关系
从图中也可以看到,一个QuakeEntry对象也会创建一个Location对象。
QuakeEntry类通过将纬度和经度传递给Location对象来创建一个Location对象。这会使用这些数据创建一个新的Location对象。创建出的Location对象被存储在实例字段myLocation中。
当一个类“拥有”或“包含”另一个类时,我们称之为“拥有”关系。

Location类实现了Comparable接口,我们将在另一节课中讨论这一点。

“使用”关系
正如你在类图中所见,EarthquakeClient类也“使用”一个Location对象。位置用于确定哪些地震离你(或我)居住的地方最近。我们将在编码演示中看到这一点。Location.distanceTo方法有助于编写这段代码。
EarthquakeClient类也“使用”QuakeEntry对象。例如,这允许客户端程序利用QuakeEntry类的getMagnitude方法来查找所有高震级的地震。



总结
在本节课中,我们一起学习了面向对象编程中类之间的两种核心关系。
- “拥有”关系:指一个类将另一个类的对象作为其自身状态的一部分。例如,
QuakeEntry类“拥有”一个Location对象。 - “使用”关系:指一个类在其方法中创建、操作或以其他方式调用另一个类的对象。例如,
EarthquakeParser和EarthquakeClient类都“使用”QuakeEntry和Location对象。
我们还区分了简单的数据容器(如QuakeEntry这样的POJO)和既包含数据又包含行为(方法)的类(如Location)。理解这些关系是设计清晰、高效Java程序的基础。

编程愉快!🚀
124:许可与API 📚


在本节课中,我们将讨论如何在我们的程序中使用经过充分测试的 Android Location 类。我们将了解其 API 文档、使用方法以及相关的开源许可协议。
理解 Location 类 API 🔍
上一节我们介绍了使用现有代码库的重要性,本节中我们来看看如何具体理解和使用 Location 类。
Location 类中的 distanceTo 方法附带注释,说明了该方法的功能。阅读文档是创建程序的重要组成部分。该方法返回两个位置之间以米为单位的距离。我们在后续编写代码时需要记住距离单位是米。
你还可以看到,该方法使用了 WGS84 椭球体标准。遵循标准是一个好做法,它有助于确保我们的代码健壮、被广泛社区接受,并且其正确性可被验证。这里使用的是 1984 年确立的世界大地测量系统。我们应该庆幸可以依赖这个标准,而不是试图自己去计算像地球这样的三维椭球体上两点间的距离。


Android 系统与 Location 类 📱
我们正在使用的 Location 类来自 Android 系统。Android 是目前智能手机和设备上使用最广泛的操作系统。
与许多现代 API 一样,你可以在网上阅读文档并理解 Location API。这个 API 在开发众多运行于 Android 下的位置感知应用程序时非常有用。
要使用 Location 类,你需要做以下几件事:
- 阅读文档以了解如何使用该类以及 API 对使用该类有何说明。
- 根据你在 API 中读到的内容创建
Location对象。 - 遵循 API 文档的说明调用像
distanceTo这样的方法,并使用 API 中的概念来测试你的代码。
开源许可:Apache 2.0 ⚖️

Android 的 Location 类被授权允许复用。Location 类使用了 Apache 2.0 开源软件许可。你可以在线阅读此许可允许事项的详细信息。来自 Android 系统的 Location.java 附带的文档指明了所使用的许可。
Apache 2.0 许可明确允许复用 Location 类中的代码,并允许对代码进行修改。这非常棒,因为我们已获取该代码并做了一些修改。我们的改编移除了 Location 类对 Android 系统的依赖,使其能在 Android 系统之外使用。我们仍然受益于该类经过的健壮测试和开发,因此我们确信它能正常工作。
我们同样采用了相同的 Apache 许可,正如你在这里看到的。因为我们选择了 Apache 2.0 许可,所以你也可以改编代码并进行修改。它在我们的示例中按原样工作,因此如果你愿意,也可以直接使用而无需更改。这种社区共享编程是创建软件的强大工具。
本节课中,我们一起学习了如何查阅和使用 Android Location 类的 API 文档,了解了遵循标准的重要性,并认识了允许我们复用和修改代码的 Apache 2.0 开源许可。理解 API 和许可协议是有效利用现有代码库、进行协作开发的关键步骤。
125:编写震级过滤器

在本节课中,我们将学习如何使用Java处理和筛选地震数据。我们将从一个现有的地震客户端类开始,学习如何读取和打印地震数据,并最终编写一个方法来筛选出震级大于特定值的地震。
我们将使用CSV格式的数据,这些数据可以来自实时的美国地质调查局数据源,也可以来自本地文件。通过编写筛选逻辑,你将获得处理和分析任何类型数据的通用技能。

读取并打印地震数据
首先,我们使用现有的 createCSV 方法来处理数据。这个方法可以从文件或网络链接读取地震数据,并将其以CSV格式打印出来。CSV格式便于你将数据复制到电子表格中进行查看和调试。
以下是读取数据并打印的基本代码结构:
EarthquakeClient client = new EarthquakeClient();
client.createCSV();
运行此代码后,控制台会输出CSV格式的地震数据。例如,从我们的示例文件中读取了1518次地震的信息。
筛选“大地震”
上一节我们介绍了如何读取和打印原始数据。本节中,我们来看看如何从中筛选出震级较大的地震。
我们的目标是编写一个名为 bigQuakes 的方法,它能找出所有震级大于5.0的地震。
以下是实现此功能的初步代码:
public void bigQuakes() {
EarthQuakeParser parser = new EarthQuakeParser();
String source = "data/nov20quakedatasmall.atom";
ArrayList<QuakeEntry> list = parser.read(source);
System.out.println("read data for " + list.size() + " quakes");
for (QuakeEntry qe : list) {
if (qe.getMagnitude() > 5.0) {
System.out.println(qe);
}
}
}
在这段代码中,我们遍历了地震条目列表,并使用 getMagnitude() 方法获取每个地震的震级。如果震级大于5.0,我们就将其打印出来。
创建通用的震级过滤器
虽然上述方法有效,但为了代码的复用性和灵活性,我们可以创建一个更通用的筛选方法。
以下是创建一个名为 filterByMagnitude 的通用筛选方法的步骤。该方法接收一个地震列表和一个最小震级值作为参数,并返回一个包含所有符合条件地震的新列表。
public ArrayList<QuakeEntry> filterByMagnitude(ArrayList<QuakeEntry> quakeData, double magMin) {
ArrayList<QuakeEntry> answer = new ArrayList<QuakeEntry>();
for (QuakeEntry qe : quakeData) {
if (qe.getMagnitude() > magMin) {
answer.add(qe);
}
}
return answer;
}
在 bigQuakes 方法中使用过滤器
现在,我们可以在 bigQuakes 方法中调用这个新编写的通用过滤器,而不是直接在里面编写筛选逻辑。
以下是更新后的 bigQuakes 方法:
public void bigQuakes() {
EarthQuakeParser parser = new EarthQuakeParser();
String source = "data/nov20quakedatasmall.atom";
ArrayList<QuakeEntry> list = parser.read(source);
System.out.println("read data for " + list.size() + " quakes");
// 使用通用过滤器
ArrayList<QuakeEntry> listBig = filterByMagnitude(list, 5.0);
for (QuakeEntry qe : listBig) {
System.out.println(qe);
}
}

运行此方法,你将得到与之前相同的结果:所有震级大于5.0的地震列表。我们利用了 QuakeEntry 类的 toString 方法,使输出格式清晰易读。
总结
本节课中我们一起学习了如何用Java处理地震数据。我们从读取和打印CSV格式的数据开始,然后编写了直接筛选“大地震”的代码。为了提高代码质量,我们进一步创建了一个通用的 filterByMagnitude 方法,它可以根据任意给定的最小震级来筛选数据。最后,我们重构了 bigQuakes 方法来使用这个通用过滤器。这些筛选和遍历数据的技巧,可以广泛应用于其他类型的数据处理任务中。
126:编写最近地震查询 🌍

在本节课中,我们将学习如何编写一个Java程序,用于查找距离指定位置最近的若干次地震。我们将以印度尼西亚雅加达为例,但代码适用于任何城市。我们将通过一个七步流程,从查找单次最近地震开始,逐步扩展到查找前十次最近地震,并确保不修改原始数据。
概述 📋

你可能想知道某个特定地点是否处于地震危险区。在本编程演示中,我们将描述如何找到距离你居住地最近的10次地震,或者距离朋友所在城市最近的10次地震。我们将为特定地点(如纽约市或印度尼西亚雅加达)编写代码,但该代码适用于任何地点。我们的代码将找到最近的10次地震,但我们可以将10替换为任何数字。
你之前解决过一个类似的问题:在元素列表中查找最小或最大元素。这个问题类似,我们将找到距离特定位置最近的地震。然后,我们将重复这个过程,但首先,我们需要从数据集中移除最近的地震。我们需要移除最近的地震,但我们不希望改变地震列表。因此,我们需要制作一个副本,以避免删除地震数据。在演示中,我们将使用10和印度尼西亚雅加达,但你可以找到距离你选择的任何城市最近的57次地震。

查找单次最近地震 🔍

上一节我们介绍了问题的背景和目标,本节中我们来看看如何实现查找单次最近地震的核心算法。这个算法与你之前见过的在列表中查找最小或最大元素的算法类似。

我们将跟踪一个数组列表中最近地震的索引,即距离雅加达最近的那个。每次检查一个地震条目时,如果需要,我们会更新最近索引的值。
以下是实现查找单次最近地震的代码步骤:
public ArrayList<QuakeEntry> getClosest(ArrayList<QuakeEntry> quakeData, Location current, int howMany) {
ArrayList<QuakeEntry> copy = new ArrayList<QuakeEntry>(quakeData);
ArrayList<QuakeEntry> ret = new ArrayList<QuakeEntry>();
for(int j=0; j < howMany; j++) {
int minIndex = 0;
for(int k=1; k < copy.size(); k++) {
QuakeEntry quake = copy.get(k);
Location loc = quake.getLocation();
if (loc.distanceTo(current) < copy.get(minIndex).getLocation().distanceTo(current)) {
minIndex = k;
}
}
ret.add(copy.get(minIndex));
copy.remove(minIndex);
}
return ret;
}
扩展到查找多次最近地震 🔄
上一节我们成功实现了查找单次最近地震,本节中我们来看看如何扩展这个逻辑以查找多次最近地震。核心思想是将查找单次最近地震的代码放入一个循环中,重复执行指定次数。
但是,有一个关键问题:在找到最近的地震后,如果不将其从数据集中移除,下一次查找会再次找到同一个地震。因此,在每次找到最近地震后,我们需要将其从用于查找的副本列表中移除。
以下是实现查找多次最近地震的完整代码逻辑:
- 创建数据副本:首先,创建传入的
quakeData列表的副本,以避免修改原始数据。 - 初始化返回列表:创建一个空的
ArrayList<QuakeEntry>用于存储结果。 - 外层循环:循环
howMany次,以找到指定数量的最近地震。 - 内层循环(查找单次最近):在每次外层循环中,执行与查找单次最近地震相同的算法,但在副本数据
copy上操作。 - 记录并移除:找到最近地震后,将其添加到返回列表
ret中,并从副本copy中移除,确保下次查找不会重复找到它。 - 返回结果:循环结束后,返回包含指定数量最近地震的列表。

代码实现与测试 🧪
现在,让我们将上述逻辑整合到getClosest方法中,并进行测试。我们将使用从美国地质调查局(USGS)源读取的1,584次地震数据,并查找距离雅加达最近的10次地震。
运行程序后,我们将看到输出显示了从雅加达出发,距离从最近到第十近的地震信息,验证了我们的代码能够正确工作。
总结 🎯

本节课中我们一起学习了如何编写一个Java程序来查找距离指定位置最近的多次地震。我们回顾了查找最小元素的基本算法,并将其扩展为重复查找并移除已找到元素的过程。关键步骤包括:


- 算法基础:使用与查找列表中最小元素相同的逻辑来查找单次最近地震。
- 循环扩展:通过外层循环重复查找过程,以获取指定数量的最近地震。
- 数据保护:通过创建原始数据列表的副本来进行操作,确保不修改输入参数。
- 避免重复:在每次找到最近地震后,将其从当前搜索的副本中移除,以确保下次能找到“下一个”最近的地震。
通过这个方法,你可以轻松地修改位置(current)和数量(howMany)参数,来查询世界上任何城市附近的最近地震情况。
127:课程总结 🎓


在本节课中,我们将回顾和总结在开发、使用类来处理地震数据过程中所涉及的代码与核心概念。
类的关系与面向对象设计 🧩
上一节我们介绍了如何利用类来处理数据,本节中我们来看看类之间的交互关系。在面向对象设计中,类之间普遍存在“有一个”(HAS-A)和“使用”(USES)的关系。这通常通过以下方式实现:

- 将对象存储为类的实例变量。
- 在方法中将对象作为局部变量使用。
这些是标准的面向对象设计概念,其应用并不仅限于Java,而是适用于所有面向对象的编程语言。
数据处理与外部依赖 🔄
从处理流式数据的实践中,我们接触了几个关键点。我们处理的数据是流式的,这意味着程序每次运行时数据都可能发生变化。在解析数据时,我们将其视为一个“黑盒”,依赖Java库来解析XML格式的数据。
以下是我们在开发中依赖外部代码的方式:
- 我们开发的XML解析器依赖于标准的Java API。
- 你们编写的地震数据处理代码,既依赖于标准Java库的API,也依赖于我们提供的API。

为了便于调试,我们使用了存储在本地而非实时流式传输的数据。这种方法允许我们进行更小规模、可重复的调试运行。
软件开发与开源许可 📜
我们简要提及了软件开发中的一个重要部分:软件许可。在课程中,我们使用了来自Android平台的 Location.java 类。使用经过充分测试的成熟代码有助于确保我们程序的健壮性。
Location 类采用了 Apache 2.0 许可证,这是一个标准的开源软件许可证。该许可证允许我们在Android平台之外修改代码以满足自身需求。我们也可以选择其他许可证来授权修改后的代码,但在此我们同样选择了 Apache 2.0 许可证。
在其他课程中,我们还使用了 Apache Commons CSV 类来解析逗号分隔值文件。该代码库同样采用 Apache 2.0 许可证,尽管我们并未修改CSV库本身。


编程实践与数据搜索 🗺️
在处理地震数据的过程中,你们练习了编程和设计技能。所见所写的程序主要用于搜索地震数据,即寻找满足特定震级和位置属性的数据。

在搜索过程中,过滤数据意味着返回一个满足特定条件的 QuakeEntry 对象列表。例如,返回距离你居住地较近的地震数据。

当搜索最近的10次或20次地震,而非所有1000公里范围内的地震时,我们采用了不同但相似的技术。一个关键点是,我们复制了被搜索的数据,因为我们编写的代码在搜索过程中会修改原始数据。
课程总结 📚


本节课中我们一起学习了面向对象设计中类的关系、处理流式数据的方法、开源软件许可的重要性,以及实现数据搜索与过滤的具体编程技巧。这些概念和技能为未来进一步学习数据的搜索与排序奠定了坚实的基础。
祝你编程愉快!
128:地震数据过滤问题介绍 🧭

在本节课中,我们将要学习如何利用地震数据解决一个常见问题:数据过滤。当数据包含大量信息时,我们常常需要根据特定条件筛选出符合要求的数据子集。
数据过滤问题概述 📊
上一节我们介绍了处理地震数据的基本方法。本节中我们来看看一个具体的应用场景:数据过滤。
使用地震数据时,你可能希望解决的另一类问题,即当数据包含大量信息时,一个非常常见的问题类型,是将数据进行过滤。具体来说,就是输入一个地震数据的数组列表,然后输出一个新的数组列表,其中只包含符合某些特定条件的地震记录。
以下是几个常见的过滤条件示例:
- 例如,你可能只对震级达到某一特定阈值的地震感兴趣。
- 或者,你可能只想检查发生在你或其他特定地点一定距离范围内的地震集合。
- 当然,也可以是任何其他数量的条件。

解决思路与代码重复问题 ⚙️
上一节我们定义了过滤问题,本节中我们来看看如何解决它,并思考更优的编程实践。
如果你要针对上述任何一个条件来解决这个问题,你会发现这并不需要新的算法概念。你应该能够应用“七步法”,并使用你已经学过的知识将算法转化为代码。
然而,如果你需要针对不止一个条件来解决这个问题,例如编写一个按震级过滤的方法和另一个按距离过滤的方法,你会发现针对这两种条件的算法和代码非常相似。
如果你有很多过滤条件,你会发现自己基本上是在反复解决同一个问题,并且编写看起来非常相似的代码。在编程时,你应该避免重复代码,即避免一遍又一遍地重写相似的代码。
这不仅浪费你的时间,还会引入更多出错的机会,并使你的代码在未来更难维护。
引入通用过滤方法 🚀
前面我们看到了为每个条件单独编写方法会导致代码重复。一个更好的方法是编写一个通用的方法来过滤地震数据。
更好的方法是编写一个通用的方法来过滤地震数据,该方法接受一个参数,用于指定过滤条件是什么。编写这种通用方法需要一个我们将要学习的新概念。
本节课中我们一起学习了地震数据过滤的需求、为每个条件单独编写方法的局限性,以及引入通用方法来解决代码重复问题的必要性。在接下来的课程中,我们将深入探讨实现这个通用方法所需的新概念。
129:避免重复的接口

概述
在本节课中,我们将要学习如何通过使用接口来避免代码重复。我们将通过比较两个功能相似但细节不同的方法开始,然后介绍如何创建一个通用的过滤方法,并最终理解接口如何作为类型来帮助我们实现这一目标。
从两个相似的方法开始
为了开始,我们先来看两个你可能会编写的过滤方法,并观察它们之间的相似之处。
以下是按震级过滤地震的方法,它只将震级达到特定最小值的地震放入结果中。
public ArrayList<QuakeEntry> filterByMagnitude(ArrayList<QuakeEntry> quakeData, double magMin) {
ArrayList<QuakeEntry> answer = new ArrayList<QuakeEntry>();
for(QuakeEntry qe : quakeData) {
if(qe.getMagnitude() >= magMin) {
answer.add(qe);
}
}
return answer;
}
方法名public ArrayList<QuakeEntry>写在单独一行只是为了在屏幕上显示更清晰,这对Java或大多数编程语言并不重要。
这个方法以直接的方式进行。它首先为结果创建一个空的ArrayList,然后使用for循环检查输入参数ArrayList中的每个地震。对于每个满足条件(震级至少为magMin)的地震,将其添加到结果ArrayList中。如果你用七步法解决这个问题,你可能会写出完全相同的代码。
以下是另一个按与特定位置的距离过滤地震的方法。
public ArrayList<QuakeEntry> filterByDistanceFrom(ArrayList<QuakeEntry> quakeData, double distMax, Location from) {
ArrayList<QuakeEntry> answer = new ArrayList<QuakeEntry>();
for(QuakeEntry qe : quakeData) {
if(qe.getLocation().distanceTo(from) < distMax) {
answer.add(qe);
}
}
return answer;
}
请注意它与之前代码的相似程度。实际上,只有三个不同之处:
- 方法名已更改,以反映此方法执行的任务。
- 参数不同。这个方法接受最大距离和一个位置,而震级过滤器只接受最小震级。
- 决定是否将当前迭代的地震添加到结果
ArrayList的条件不同。
寻找避免重复的方法
每当代码中有如此多的相似之处时,你可能希望找到一种方法来避免代码重复。特别是,当你发现自己想要复制粘贴代码然后进行修改时,通常应该考虑是否有不同的方法。
这里你可以看到一个不同且更好的方法。这是一个通用的过滤方法,它接受一个参数filter f,该参数指定如何确定特定地震是否应包含在输出列表中。
public ArrayList<QuakeEntry> filter(ArrayList<QuakeEntry> quakeData, Filter f) {
ArrayList<QuakeEntry> answer = new ArrayList<QuakeEntry>();
for(QuakeEntry qe : quakeData) {
if(f.satisfies(qe)) {
answer.add(qe);
}
}
return answer;
}
然后,代码在每个被迭代的地震上调用f.satisfies(),并使用satisfies方法的返回值来决定是否包含该地震。
这个方法看起来很棒,但你可能想知道如何制作具有不同satisfies方法的过滤器?
理解接口
答案是,Filter是一个接口,而不是一个类。你可以在这里看到它的声明。

public interface Filter {
public boolean satisfies(QuakeEntry qe);
}
请注意声明中写的是interface,而你通常写的是class。在这里写interface而不是class,是告诉JavaFilter不是一个类,而是一个接口。接口不定义方法的行为,它只是一种类型。这种类型承诺所有实现该接口的类中都将存在某些方法。
在这里,Filter接口承诺了这样一个方法:public boolean satisfies(QuakeEntry qe)。
实现接口
一旦你声明了一个接口,你就可以编写实现它的类。当你编写一个实现接口的类时,你必须定义接口规范中承诺的所有方法。然后,该类的对象就可以被视为接口类型。
如果你编写了一个实现Filter的类,那么从该类创建的对象可以赋值给Filter类型的变量,或者作为参数传递给期望Filter类型的方法。
让我们看一个例子。以下是MinMagFilter,一个检查地震是否具有最小震级的类。

public class MinMagFilter implements Filter {
private double magMin;
public MinMagFilter(double min) {
magMin = min;
}
@Override
public boolean satisfies(QuakeEntry qe) {
return qe.getMagnitude() >= magMin;
}
}
你可以看到这个类的声明写着implements Filter。当你这样写时,Java会检查并确保该类拥有Filter接口承诺的所有方法。如果缺少一个,该类将无法编译。这允许你在编写的代码中将MinMagFilter对象视为过滤器。

在这里,你可以看到这个类拥有承诺的satisfies方法。该方法简单地检查传入地震的震级是否大于等于存储在对象实例变量中的magMin(最小震级)。请注意,这个方法的主体与我们之前看到的按震级过滤的代码中的条件是多么相似。
类的其他成员
这个类除了指定的方法外,还可以有其他成员。这里有一个实例变量来保存最小震级,以及一个从其参数初始化该实例变量的构造函数。这些在Filter的规范中并未承诺。如果需要,你也可以在类中编写其他方法,但你必须编写satisfies方法。
使用通用过滤方法
在顶部,你可以看到我们之前为通用过滤方法编写的代码。在底部,你可以看到如何将MinMagFilter对象赋值给Filter变量并将其传递给此方法。
Filter f = new MinMagFilter(4.0);
ArrayList<QuakeEntry> largeQuakes = filter(quakeData, f);
这个第一个赋值语句的右侧创建了一个新的MinMagFilter,传入了4.0。它创建了一个对象,其satisfies方法将测试地震的震级是否至少为4.0。该对象的类型是MinMagFilter。赋值语句的左侧是一个类型为Filter的变量。即使类型不同,这个赋值也是合法的,因为MinMagFilter实现了Filter接口。


下一行将f(这是一个按震级至少为4进行过滤的对象)传递给你之前看到的通用过滤方法。如果你有另一个实现了Filter的类,例如DistanceFilter,你也可以将DistanceFilter的实例赋值给变量f。


总结
本节课中,我们一起学习了如何利用接口来避免编写重复的代码。我们首先分析了两个功能相似的方法,然后引入了通用的过滤方法。接着,我们理解了接口作为一种类型,如何通过定义规范来允许不同的类实现相同的行为。最后,我们看到了如何创建实现接口的类,并将其实例传递给通用方法,从而实现代码的复用和灵活性。通过这种方式,你可以轻松扩展新的过滤条件,而无需修改核心的过滤逻辑。
130:深入理解接口

在本节课中,我们将要学习Java接口的一个核心机制——动态分派。我们将通过一个具体的例子,来理解Java如何在运行时确定调用哪个具体的方法实现。
概述
上一节我们介绍了接口的基本概念和用法。本节中,我们来看看Java如何实现接口的多态性,即当通过接口类型的变量调用方法时,Java如何知道应该执行哪个具体类中的方法。这个过程被称为动态分派。
动态分派的工作原理

当您使用 new 关键字创建一个对象时,Java会在对象内部记录下该对象的实际类型。之后,无论您使用什么类型的变量来引用这个对象,当您调用该对象的方法时,Java都会去查找它记录的实际类型,并根据这个类型来决定调用哪个方法。
让我们看一个例子。
代码示例分析
以下代码声明了两个 Filter 接口类型的变量 f1 和 f2。
Filter f1 = new MinMagFilter(4.0);
Filter f2 = new DepthFilter(0.0, 10.0);
这两个变量分别被初始化为两个实现了 Filter 接口的不同类型的对象:MinMagFilter 和 DepthFilter。代码中的省略号表示我们不关心的其他部分。
我们关心的是对 f1.satisfies() 和 f2.satisfies() 的调用,以及Java如何知道在每个地方调用哪个方法。
以下是Java执行此代码的步骤:
-
创建
f1对象:
当Java执行第一行代码时,它让f1引用一个新创建的MinMagFilter对象,该对象内部存储了值4.0。这个对象还包含一些数据,表明无论用什么类型的变量引用它,它的实际类型都是MinMagFilter。 -
创建
f2对象:
同理,当Java执行第二行代码时,它让f2引用一个新创建的DepthFilter对象。这个对象不仅存储了指定的最小和最大深度值,也记住了它的实际类型是DepthFilter。

方法调用过程
现在,我们到达了 f1.satisfies() 的调用处。

当Java准备执行这个调用时,它会查看 f1 所引用的实际对象。在这里,Java会发现 f1 实际上是一个 MinMagFilter 对象。因此,这个调用指向的是 MinMagFilter 类内部的 satisfies 方法。
Java随后会像往常一样调用这个方法。该方法会检查传入的 QuakeEntry 对象的震级是否大于或等于对象内部 magMin 字段的值,然后返回相应的布尔值(true 或 false)给调用者,程序继续执行。
接下来,当执行到 f2.satisfies() 调用时,会发生一个类似的过程。

这次,当Java查看 f2 对象的实际类型时,会发现它是一个 DepthFilter 对象。因此,它会调用 DepthFilter 类内部的 satisfies 方法。该方法会检查地震的深度是否在对象字段存储的最小和最大深度之间,并将 true 或 false 返回给其调用者,然后程序正常继续执行。
总结

本节课中我们一起学习了动态分派。您现在已经了解到,Java会在您使用 new 创建对象时,记住每个对象的实际类型。这个类型被用来确定在通过接口引用调用方法时,应该执行哪个具体实现类中的方法。这种机制是Java实现多态性的核心,它允许我们编写更灵活、可扩展的代码。
131:MatchAll接口 🧩
在本节课中,我们将要学习如何创建一个名为 MatchAllFilter 的复合过滤器。这个过滤器可以将多个独立的过滤条件组合在一起,用于筛选地震数据。我们将探讨为何需要这种设计,以及如何通过代码实现它。
假设你想为地震数据创建一个过滤器,它需要结合多个其他筛选条件。
例如,你想找到那些位于特定深度范围内、靠近某个特定地点、并且震级至少达到某个最小值的地震。你当然可以直接编写一个庞大而复杂的过滤器。
这个过滤器会有一个巨大的条件语句,将所有标准加在一起。它还会有许多实例变量和一个包含许多参数的构造函数(这里未展示)。这种方法虽然可行,但并不是解决此问题的最佳方式。
为什么不好?因为它重复了代码。你已经为每个标准编写并测试了过滤器。更好的方法是复用这些已有的过滤器。所以,如果你已经有了检查每个标准的过滤器,能否编写另一个过滤器来将它们组合在一起,并复用它们现有的代码呢?
为了让结果更理想,能否使这个组合过滤器具有通用性,以便它能组合任意一组过滤器,并检查某个地震是否满足所有这些过滤器的条件?
如果你能做到这一点,你就可以编写出如下所示的代码。
你可以创建一个 MatchAllFilter(即我们想要编写的类),然后向其中添加一个最小震级过滤器,接着添加一个深度范围过滤器,最后再添加一个距离特定地点过滤器。
然后,你就可以使用这个 MatchAllFilter 来筛选你的地震数据。这种方法看起来非常棒。
你可以复用已经制作好的过滤器,并且如果你想组合其他过滤器,也可以使用 MatchAllFilter 来实现。但是,如何编写 MatchAllFilter 呢?
以下是 MatchAllFilter 的一半代码。我们马上会看到 satisfies 方法,只是代码无法一次性全部显示在屏幕上。
这里你可以看到,我们声明了 MatchAllFilter 类,并说明它实现了 Filter 接口。这与你为其他任何过滤器编写的代码一样。
这个类有一个字段,用于存储过滤器的 ArrayList。你能创建一个过滤器的 ArrayList 吗?答案是肯定的。这是组合性的又一个绝佳例子。Filter 是一种类型,你可以创建任何类型的 ArrayList。所以,这完全符合你的预期。
接下来是一个构造函数,它将 ArrayList 初始化为一个新的 ArrayList,以及一个方法,该方法接收一个过滤器并将其添加到此 ArrayList 中。
现在,让我们看看这个类中 satisfies 方法的代码。
与我们见过的所有过滤器一样,这个 satisfies 方法接收一个 QuakeEntry 并返回一个布尔值。
然而,这个过滤器做出决定的方式与你目前见过的不同。它并非直接检查地震数据本身,而是遍历其 ArrayList 中的每一个过滤器。
对于其中的每一个过滤器,它会检查这个 QuakeEntry 是否满足该过滤器的条件。请记住,这个调用将通过动态分派进行,具体取决于实际创建的过滤器类型中的 .satisfies 方法。
如果那个过滤器返回 false(记住,感叹号 ! 表示逻辑非),那么这个过滤器就返回 false。
在遍历完所有过滤器之后,如果没有任何一个过滤器拒绝这个地震条目,那么这个过滤器就返回 true。
现在你已经了解了如何创建 MatchAllFilter,它可以组合任意一组过滤器,并检查地震是否满足它们的所有条件。
它的 .satisfies 方法是通过使用 for-each 循环来检查其存储在 ArrayList 中的所有过滤器来实现的。
你也可以编写其他组合方式。例如,如果你想编写一个 MatchAnyFilter,用于测试地震是否满足一组过滤器中任意一个的条件,你可以运用相同的原则来实现。

在本节课中,我们一起学习了如何设计和实现一个通用的复合过滤器 MatchAllFilter。我们理解了通过组合现有过滤器来避免代码重复的重要性,并掌握了使用 ArrayList 存储过滤器、通过遍历和动态分派来组合判断逻辑的方法。这种设计模式提高了代码的复用性和灵活性。

祝你愉快地筛选地震数据,寻找那个终极的地震!
Java编程和软件工程基础:2-5:接口总结

在本节课中,我们将总结关于Java接口的核心知识。你将了解接口的定义、实现方式以及如何利用接口编写更通用、可复用的代码。
你已经学习了接口及其如何使代码更通用,从而避免代码重复。
接口的定义
上一节我们介绍了接口的概念,本节中我们来看看如何具体定义一个接口。接口的定义与类相似,但使用关键字 interface 而非 class,并且不提供方法的具体实现(即方法体)。
代码示例:
public interface Filter {
boolean satisfies(String id);
}
接口的实现
接下来,我们学习如何让一个类实现某个接口。这需要在类声明中使用 implements 关键字,并完整定义接口中声明的所有方法。
代码示例:
public class MinmFilter implements Filter {
@Override
public boolean satisfies(String id) {
// 方法的具体实现逻辑
return id.contains("minm");
}
}
接口的使用
在代码中使用接口时,你可以将接口类型用于变量声明和方法参数。你可以安全地调用接口中承诺的方法。
以下是使用接口的关键点:
- 可以将实现接口的类对象,赋值给该接口类型的变量。
- 可以将实现接口的类对象,传递给声明为该接口类型的方法参数。
- 可以调用接口中定义的方法。
动态绑定
你还需要理解实现接口的类与接口类型之间的兼容性关系。例如,可以将一个 MinmFilter 对象赋值给 Filter 类型的变量,因为 MinmFilter 实现了 Filter 接口。
在这个过程中,Java会记住对象的实际类型,并据此调用正确的方法实现。这一机制被称为动态绑定。


本节课中我们一起学习了Java接口。你掌握了如何定义接口、如何让类实现接口、如何在代码中使用接口类型,以及理解动态绑定的工作原理。运用这些知识,你可以设计出更灵活、更少重复的代码结构。
133:排序算法简介

在本节课中,我们将要学习排序的概念。排序是将一组数据按照特定顺序重新排列的过程,以便于后续的分析和处理。

上一节我们介绍了数据处理的基本概念,本节中我们来看看排序的具体应用和重要性。
排序的应用与重要性
排序在许多领域都有广泛的应用,尤其是在处理大规模数据集时。对于某些问题,将数据排序作为第一步,可以使问题更容易解决。
例如,假设你需要在一个数组或数组列表中找到中间的元素。如果数据已经排序,你可以直接查看中间位置的元素。如果数据未排序,你需要设计一个算法来解决这个问题。
排序还能提高某些问题的解决效率。在本课程中,我们主要关注编写能正常工作的代码,对效率的探讨不多。然而,当你处理数十亿条数据时,算法的效率差异可能导致运行时间从几秒变为几年。
以下是排序带来的两个主要好处:
- 简化问题:排序后的数据使查找、比较等操作更直观。
- 提升效率:高效的排序算法能显著减少处理海量数据所需的时间。
本节课中我们一起学习了排序的基本概念、其在实际问题中的应用以及它对算法效率的重要影响。理解排序是掌握更复杂数据处理和算法设计的基础。
134:排序算法开发入门 🧠

在本节课中,我们将学习如何开发一个排序算法。我们将从一个简单的数字排序例子开始,逐步推导出通用的算法步骤,并最终将其转化为代码。这个过程将展示如何运用系统性的七步法来解决编程问题。
从具体例子开始


上一节我们介绍了算法开发的重要性,本节中我们来看看如何为一个具体问题设计算法。排序数据的方法有很多,效率各不相同。和解决所有问题一样,你可以使用七步法来设计解决方案。这里我们从一个使用数字的小例子开始。


对数字 56, 17, 4, 33 进行排序。尽管你最终想排序的是地震数据,但你可以先用数字来设计算法,然后再调整它以提取特定的数据(如震级、距离、深度等)进行排序。

手动处理这个小例子相当简单,因为列表很小,很容易看出如何将元素按顺序排列。然而,你必须小心地以一步一步的方式进行,而不是直接写下答案。
记录并分析步骤



现在我们已经手动完成了排序,让我们回过头来仔细思考我们做了什么,并写下这些步骤。
首先,让我们为输入和输出命名,以便更容易清晰地引用它们。


- 输入列表:
in - 输出列表:
out
你还应该明确地注意到,out 最初是一个空的 ArrayList。这是一个在脑海中容易忽略的步骤,但在转化为代码时,包含它很重要。
以下是手动排序 in = [56, 17, 4, 33] 的步骤:
out初始化为空列表。- 在
in中找到最小的元素4。 - 从
in中移除4。 - 将
4添加到out的右端。 - 在
in中找到新的最小元素17。 - 从
in中移除17。 - 将
17添加到out的右端。 - 在
in中找到新的最小元素33。 - 从
in中移除33。 - 将
33添加到out的右端。 - 在
in中找到新的最小元素56。 - 从
in中移除56。 - 将
56添加到out的右端。 - 完成。
out中的数据已按你希望的方式排序。
将具体步骤泛化为通用算法
现在你有了排序这个特定数据集的14个步骤。当然,你希望能够排序任何数据集,所以你需要将这些步骤泛化为一个适用于任何大小数据集的算法。

像往常一样,你可以看到你所做的事情中存在重复,但同样典型的是,相似步骤之间也存在一些差异。在继续之前,你需要找出其中的模式。
以下是重复的模式组:

- 第一组:步骤 2, 3, 4
- 第二组:步骤 5, 6, 7
- 第三组:步骤 8, 9, 10
- 第四组:步骤 11, 12, 13
这些相似步骤之间的差异在于每组中的具体数字。在每组的第一步中找到的最小元素,就是在该组其他两步中从 in 移除并添加到 out 的数字。像往常一样,你应该给这个元素命名,并使用该名称使重复的步骤完全一致。

这里我们将这个元素(这个值)命名为 minElement,并使步骤统一。
我们几乎准备好将这些步骤表达为重复结构了,但还需要考虑最后一个细节:我们如何知道何时停止重复这些步骤?

与我们之前见过的许多算法不同,我们不是对输入中的每个元素执行操作。我们不仅不按顺序遍历它们,而且在从 ArrayList 中移除元素的同时尝试对每个元素进行操作通常不是一个好主意。
让我们回到手动解决这个问题的地方来思考一下。回顾这个过程,你可以清楚地看到,我们并没有执行“对每个元素”的重复操作,因为我们没有按顺序处理每个元素。
确定循环终止条件
现在我们已经完成了,很明显我们完成了。但你怎么知道完成了呢?是因为 in 变空了。既然这是你能判断完成的方式,它也应该是你在算法中表达的内容。

有了这个观察结果,你可以写出一个如下所示的算法。注意我们如何根据刚才的思考来表达重复:只要 in 不为空,就重复这些步骤。
以下是通用排序算法的步骤:

- 初始化
out为一个空的ArrayList。 - 只要
in不为空,就重复以下步骤:
a. 在in中找到最小的元素,称之为minElement。
b. 从in中移除minElement。
c. 将minElement添加到out的右端。 - 返回
out作为排序后的列表。
当你将其转化为代码时,这会是什么类型的循环?希望你记得之前学过的 while 循环,它们是表达这种重复的最佳方式。
测试算法并转化为代码
像往常一样,在转化为代码之前测试你的算法是一个好主意。尝试用这个输入 [9, -3, 0] 来测试这个算法。算法运行正确吗?
很好,是时候把它变成代码了。在后续课程中,我们将具体实现这个选择排序算法。
本节课中我们一起学习了如何从一个具体例子出发,通过记录步骤、寻找模式、泛化并确定循环条件,最终开发出一个通用的排序算法框架。这个过程是算法设计的基础,可以应用于解决各种编程问题。
135:实现地震数据按震级排序


在本节课程中,我们将学习如何将按震级排序地震数据的算法转化为实际的Java代码。我们将实现一个名为 sortByMagnitude 的方法,该方法接收一个地震条目列表,并返回一个按震级从小到大排序的新列表。
方法声明与初始化
首先,我们声明 sortByMagnitude 方法。该方法接收一个 ArrayList<QuakeEntry> 类型的输入参数 in,并返回一个同样类型的列表 out。
public ArrayList<QuakeEntry> sortByMagnitude(ArrayList<QuakeEntry> in) {
ArrayList<QuakeEntry> out = new ArrayList<QuakeEntry>();
// 后续排序逻辑将写在这里
return out;
}
方法的第一行代码创建了一个新的空列表 out,用于存放排序后的结果。
实现排序循环逻辑
上一节我们介绍了排序算法,本节中我们来看看如何在代码中实现它。算法的核心是循环地从输入列表中找到震级最小的条目,将其移除并添加到输出列表中,直到输入列表为空。
我们使用 while 循环来实现这个过程,因为循环次数在开始时并不确定,取决于输入列表的大小。
while (!in.isEmpty()) {
// 在循环体内执行查找最小值和转移条目的操作
}
循环的条件是 !in.isEmpty(),这表示只要输入列表 in 不为空,循环就会继续执行。
查找并转移最小条目

在循环的每一步,我们需要完成以下操作:
以下是每一步需要执行的具体任务:
- 查找最小条目:调用一个辅助方法
getSmallestMagnitude来从当前输入列表in中找到震级最小的QuakeEntry对象。 - 移除最小条目:使用
ArrayList的remove方法将找到的最小条目从输入列表in中删除。 - 添加至输出列表:使用
ArrayList的add方法将最小条目添加到输出列表out的末尾。
对应的代码如下:
while (!in.isEmpty()) {
QuakeEntry minElement = getSmallestMagnitude(in);
in.remove(minElement);
out.add(minElement);
}
这里,getSmallestMagnitude 是一个我们已经实现过的方法,它遍历列表并返回震级最小的地震条目。in.remove(minElement) 会找到并删除列表中第一个与 minElement 相等的条目。out.add(minElement) 则将这个条目追加到 out 列表的尾部。
完整方法与测试
将以上部分组合起来,就得到了完整的 sortByMagnitude 方法。
public ArrayList<QuakeEntry> sortByMagnitude(ArrayList<QuakeEntry> in) {
ArrayList<QuakeEntry> out = new ArrayList<QuakeEntry>();
while (!in.isEmpty()) {
QuakeEntry minElement = getSmallestMagnitude(in);
in.remove(minElement);
out.add(minElement);
}
return out;
}
编写完代码后,我们进行编译。如果没有错误,就可以运行测试。课程中提供了一个测试方法,它会读取一些地震数据,调用 sortByMagnitude 方法进行排序,然后打印结果。
运行测试后,观察输出。可以看到地震条目按照震级从小到大排列:列表顶部的条目震级非常小,随着向下浏览,震级逐渐增大,直到列表底部出现震级最大的地震。这证实了我们的排序方法是正确的。
总结
本节课中我们一起学习了如何实现选择排序算法来对地震数据按震级进行排序。我们完成了以下关键步骤:
- 声明方法并初始化输出列表。
- 使用
while循环处理输入列表,直到其为空。 - 在循环中,利用
getSmallestMagnitude方法查找最小元素,并将其从输入列表移至输出列表。 - 最终返回排序好的输出列表。
通过将算法步骤转化为具体的Java代码,并运行测试验证结果,我们成功实现了数据的排序功能。
136:原地排序算法 🧩

在本节课中,我们将学习一种称为“原地排序”的算法。这种算法会直接修改输入的数组列表,使其变得有序,而无需创建新的数组列表来存放结果。我们将通过一个对地震数据按震级排序的例子来理解其工作原理。
算法原理概述
你刚刚编写的排序算法会破坏原始输入,并在一个新的数组列表中产生输出。然而,程序员通常希望进行原地排序,即修改输入的数组列表使其有序,而不创建任何新的数组列表来存放输出。
你可以使用与你刚才所做类似的原则进行原地排序,区别在于你将在数组内部交换元素。让我们进一步了解。
分步解析算法
上一节我们介绍了原地排序的概念,本节中我们来看看它的具体实现步骤。
以下是算法的核心过程:
- 首先,我们将从最低索引到最高索引遍历每个元素,并找到应该放在该位置的元素。我们将用这个箭头来指示当前位置。
- 应该放在这个位置的元素是整个输入数组列表中最小的元素,因此我们交换它们,然后移动到下一个位置。
- 应该放在这里的元素是剩余输入部分(此处用虚线框表示)中最小的元素。在本例中,它是17,已经处于正确位置。我们可以将其留在原处,尽管如果我们将其与自身交换,也不会造成任何问题,并且可以减少代码中的一个判断条件。
- 此过程的其余部分类似地进行,直到最终,输入被原地排序完成。
代码实现:按震级排序地震数据
现在,我们将按震级对地震进行排序。我已经在这里开始了我们的方法,它被称为 sortByMagnitude,但我们将首先编写一个辅助函数,因为我们需要找到数组列表一部分中最小震级的索引位置。
辅助函数:寻找最小震级索引
我已经写好了这段代码,让我快速过一遍。
public int getSmallestMagnitude(ArrayList<QuakeEntry> quakes, int from) {
int minIdx = from;
for (int i = from+1; i < quakes.size(); i++) {
if (quakes.get(i).getMagnitude() < quakes.get(minIdx).getMagnitude()) {
minIdx = i;
}
}
return minIdx;
}
我们有一个方法 getSmallestMagnitude,我们传入类型为 QuakeEntry 的数组列表,同时还传入第二个参数 from。这是我们希望开始查找的索引位置,我们希望从该点开始在数组列表中找到最小的震级。
我们假设最小的那个是起始的第一个(因为那是我们目前看到的全部),我们称之为 minIdx。然后,我们在这个 for 循环中遍历其余的地震条目。
接下来,我们将进行比较。比较我们当前正在查看的(即位置 i 的条目)与我们已找到的当前 minIdx 位置的条目。每当我们找到一个更小的,我们就将 minIdx 重置为那个 i 位置。
一旦我们查看了从这里开始的所有条目,我们就找到了具有最小震级的索引位置。因此,我们返回它,如你所见的 return minIdx。
所以,getSmallestMagnitude 将返回从 from 开始的最小震级的索引位置。
主排序函数
现在我们想进行排序。我们希望按震级对它们进行排序,并且我们将使用这个辅助函数。
以下是排序函数的主体逻辑:
public void sortByMagnitude(ArrayList<QuakeEntry> in) {
for(int i = 0; i < in.size(); i++) {
int minIdx = getSmallestMagnitude(in, i);
QuakeEntry qi = in.get(i);
QuakeEntry qmin = in.get(minIdx);
in.set(i, qmin);
in.set(minIdx, qi);
}
}
我们要做的第一件事是,我们有一个 QuakeEntry 的数组列表,变量名为 in。因此,我们将遍历该列表。我们在这里创建一个 for 循环。
我们将查看所有元素,从 0 到 in.size(),每次递增 1。
然后,我们将通过调用辅助函数来找到 minIdx。第一次它将遍历整个数组列表并告诉我们最小值的索引。
一旦我们知道最小值在哪里,我们需要把它放在它应该在的位置。第一次,最小值应该放在槽位 0。所以我们找到最小值所在的位置,我们称之为 minIdx。然后我们将它与槽位 0 的元素交换。我们需要为此编写代码。
我们创建一个 QuakeEntry 类型的临时变量 qi,用于临时存储在 i 槽位的 QuakeEntry。再次说明,第一次执行时,i 是 0,所以它获取那里的元素,因为我们需要与它交换。
我们为最小震级所在的元素创建一个 QuakeEntry 变量 qmin,它位于我们刚刚找到的 minIdx 位置。
既然我们已经临时存储了它们,我们现在就可以将位置 i 的元素设置为 qmin,然后将位置 minIdx 的元素设置为 qi。这样我们就交换了 i 位置和 minIdx 位置的两个元素。
让我们回顾一下我们刚刚做了什么。我们有一个循环,将一直进行下去。第一次 i 是 0,它通过调用我们的辅助方法找到 minIdx(从槽位 0 开始找),然后我们交换槽位 0 和 minIdx。第二次循环时 i 是 1,所以现在我们查看从 1 往后的部分,因为我们知道最小的已经在槽位 0。在那一刻,我们将找到从 1 往后的最小值,将其设为 minIdx,然后将其与位置 1 交换,依此类推。
测试排序功能
现在我们需要测试这个功能。我这里已经启动了一个测试方法。
我们将进行常规操作,从文件获取地震数据。目前,我们只是打印出所有的地震条目,但我们希望在打印之前先对它们进行排序。
因此,我们将调用我们刚刚编写的方法,它叫做 sortByMagnitude。我们将在这里输入它,并传递地震条目列表(在本例中称为 list)。然后我们将它们打印出来。这样,当我们测试时,我们的地震数据应该按震级排序了。
让我们编译并运行测试。看起来它成功了。我们得到了大量数据,可以看到它停止了,但你可以看到它们按震级排序了,因为最大的是 7,然后是 6.5,5.9,5.8 等等。看起来它起作用了。
总结
本节课中我们一起学习了原地排序算法。我们了解了其核心思想是直接在原数组列表内通过比较和交换元素来实现排序,而无需额外空间。我们通过实现一个 getSmallestMagnitude 辅助函数来查找最小元素的索引,并在 sortByMagnitude 主函数中利用它和元素交换操作,完成了对地震数据按震级的原地排序。最后,我们通过测试验证了算法的正确性。
137:算法效率 🚀

在本节课中,我们将要学习不同排序算法的效率差异,理解为什么某些算法在处理大数据集时表现不佳,以及Java标准库如何通过高效的排序实现来解决这个问题。

选择排序算法
上一节我们介绍了排序的基本概念,本节中我们来看看一个具体的排序算法。
这个排序算法名为选择排序,因为它选择最小的元素,然后将其添加到输出序列的末尾。

算法的简单性与效率
选择排序算法的一大优点是概念简单。许多人通过七个步骤就能自己想到它,并且实现起来也相对简单。

然而,就排序算法而言,它的效率相当低。如果你在小型数据集上使用它,这不是大问题,因为计算机速度很快。但是,如果你有一个非常大的数据集,它会相当慢。
那么有其他方法吗?当然有。事实上,有几十种排序算法。它们通常分为两类。
以下是第一类算法的特点:
- 易于理解和用代码实现,但速度慢。

选择排序就是这样的算法,还有冒泡排序和插入排序。它们的运行时间与输入大小成二次方关系。如果你将输入大小加倍,算法运行时间将变为原来的四倍。
高效排序算法
另一类算法是那些理解起来更复杂,但速度要快得多的算法。这类算法的例子包括快速排序、归并排序等。Java库中Collections.sort使用的算法是归并排序的一个变体,称为Tim排序。
这些算法的运行时间接近线性。如果是线性的,输入大小加倍只会使运行时间加倍。这些高效算法的增长略高于线性,但非常接近线性。
对于简单但慢速的方法,运行时间呈二次方增长。因此这些被称为 n² 排序。选择排序和冒泡排序都是n²排序。它们具有大致相同的形状,并且算法易于理解。


运行时间对比
这里你看到的是两个n²排序算法的运行时间图,取自SortTiming.java。


如图所示,这些算法对于20,000个字符串是合理的,分别需要2秒和4秒。对于小列表,这或许可以接受,但对于大列表,另一类排序算法要好得多。
让我们看看排序更多元素所需的时间,以理解为什么n²排序被称为低效。
此图显示了从10,000到70,000个字符串的n²排序。Y轴标记的是时间(秒),X轴是被排序的元素数量。那么排序多得多、多得多元素需要多长时间呢?
我们可以使用冒泡排序的二次拟合(图中显示的第一个方程)来推断排序一百万个或十亿个元素所需的时间。
那么差异有多大呢?使用冒泡排序一百万个字符串需要6.4小时,而使用Collections.sort的Tim排序函数排序同样一百万个字符串需要不到一秒。




使用冒泡排序十亿个元素将需要738年。我们实际上可以计时Collections.sort,会发现排序十亿个字符串只需要不到20分钟。


Java内置排序的通用性
幸运的是,许多语言在其标准库中都内置了高效的排序。
当然,这样的排序应该适用于多种类型,以便程序员能最大限度地利用它。事实上,它必须能够处理排序作者没有专门考虑的数据类型。例如,你想对地震进行排序。你认为Java排序库的作者在编写那个排序时考虑过地震吗?很可能没有。


而且可以肯定,他没有考虑过你写的特定地震类。
那么这是如何工作的呢?还记得接口吗?你已经见过它们作为编写通用代码的一种方式。接口是一种承诺了特定方法的类型。像排序库这样的代码,就可以使用接口类型并调用它承诺的方法。
其他代码可以创建实现该接口的类的实例,并将它们传递给排序库。
对于排序,有两个重要的接口:Comparable和Comparator,你很快就会学到。在Java中,这个内置排序称为Collections.sort,它非常高效。任何时候你需要对数据进行排序,都应该使用它。

总结 📝
本节课中我们一起学习了算法效率的核心概念。我们了解到,像选择排序和冒泡排序这样的简单算法虽然易于理解,但其O(n²)的时间复杂度使其在处理大规模数据时效率极低。相比之下,Java标准库提供的Collections.sort方法基于高效的Tim排序算法,其时间复杂度接近O(n log n),能够以惊人的速度处理海量数据。关键在于,通过Comparable和Comparator接口,这种高效排序可以灵活应用于各种自定义数据类型。因此,在实际编程中,应优先使用这些经过高度优化的内置工具。
Java编程和软件工程基础:2-5:排序算法总结

在本节课中,我们将总结排序的基础知识,回顾选择排序算法的开发过程,并了解内置排序工具的重要性。
排序是将数据按特定顺序排列的过程,通常是解决问题的第一步,它能使后续操作更简单或更高效。

上一节我们介绍了排序的基本概念,本节中我们来看看如何系统地开发一个排序算法。

以下是使用七步法开发排序算法的过程:
- 理解问题:明确排序的目标和规则。
- 设计测试用例:包括典型、边界和特殊情况的输入。
- 思考解决方案:构思算法逻辑。
- 编写算法步骤:将思路分解为具体、可执行的步骤。
- 编写代码:将算法步骤转化为编程语言。
- 测试代码:使用设计的测试用例验证正确性。
- 调试与优化:修复错误并改进性能。
通过以上步骤,我们开发出了选择排序算法。这是一个概念上简单但广为人知的算法,其核心思想是反复从未排序部分选择最小(或最大)元素,放到已排序部分的末尾。
其基本逻辑可以用以下伪代码描述:
for i from 0 to n-1:
minIndex = i
for j from i+1 to n:
if array[j] < array[minIndex]:
minIndex = j
swap(array[i], array[minIndex])
我们首先实现了将数据排序到一个新数组列表中的版本,随后又实现了在原地(即原数组)进行排序的版本。
最后,我们了解到大多数编程语言都提供了内置的排序函数。这些内置排序通常经过高度优化,效率远高于选择排序这类基础算法,并且可以通用地应用于各种场景。
本节课中我们一起学习了排序的基础、通过七步法开发选择排序的过程,以及利用语言内置高效排序工具的重要性。接下来,你将学习如何在Java中使用这种内置排序。
139:排序算法与效率分析 🚀

在本节课中,我们将学习如何使用选择排序算法对字符串等对象进行排序,并分析不同排序方法的效率。我们将看到,对于非基本数据类型(如字符串),需要使用 compareTo 方法进行比较,而不是简单的 < 运算符。同时,我们将对比选择排序与Java内置的 Collections.sort 方法在性能上的巨大差异。
使用选择排序对字符串排序 🔤
上一节我们介绍了选择排序算法对地震条目按震级排序。本节中,我们来看看如何用同样的算法对字符串进行排序。
以下是演示代码,我们创建了一个包含几个猫科动物名称的小型数组:
String[] cats = {"lion", "cheetah", "puma", "leopard"};
我们尝试将之前用于地震条目的选择排序代码直接应用于这个字符串数组。然而,编译时遇到了错误。
遇到的问题:无法使用 < 运算符
错误信息是:bad operand types for binary operator '<'。这是因为在Java中,<、> 等比较运算符只能用于基本数据类型(如 int、double)。对于 String、QuakeEntry 等对象类型,不能直接使用这些运算符进行比较。
解决方案:使用 compareTo 方法
为了解决这个问题,我们需要使用 compareTo 方法。我们将代码中的比较部分从:
if (list[j] < list[minIndex])
修改为:
if (list[j].compareTo(list[minIndex]) < 0)
compareTo 方法返回一个整数:
- 如果返回值 < 0,表示第一个对象“小于”第二个对象(在字符串中,指字典序靠前)。
- 如果返回值 == 0,表示两个对象相等。
- 如果返回值 > 0,表示第一个对象“大于”第二个对象。
修改后,代码成功编译并运行,将字符串数组按字母顺序正确排序。
排序算法效率分析 ⏱️
现在我们已经知道如何使用 compareTo 方法对字符串进行排序,接下来我们分析一下排序算法的效率。

我们创建了一个 SortTimings 类,用于比较选择排序和Java内置的 Collections.sort 方法在不同数据量下的性能。以下是核心的计时逻辑:
long startTime = System.nanoTime();
// 执行排序算法(例如 selectionSort 或 Collections.sort)
long endTime = System.nanoTime();
long duration = (endTime - startTime) / 1000000; // 转换为毫秒
System.out.println("Time taken: " + duration + " milliseconds");
我们分别对包含10,000、20,000直至100,000个随机字符串的列表进行排序测试。


性能对比结果
以下是测试结果的总结:
- 10,000个元素:选择排序耗时约350毫秒,而
Collections.sort仅耗时约10毫秒。 - 随着数据量增加:选择排序的耗时呈平方级增长。例如,对100,000个元素排序,选择排序需要近一分钟。
Collections.sort的表现:即使对100,000个元素排序,Collections.sort也仅需约40毫秒,效率极高。
这个对比清晰地表明,Collections.sort 使用了一种比选择排序(时间复杂度为 O(n²))高效得多的算法(如归并排序,时间复杂度为 O(n log n))。

Collections.sort 的工作原理


Collections.sort 能够对 ArrayList<String> 进行排序,正是因为它内部调用了字符串的 compareTo 方法来比较元素的大小。这验证了 compareTo 方法是实现对象间可比较性的关键。
总结 📝
本节课中我们一起学习了两个核心内容:
- 对象排序的比较方法:对于非基本数据类型的对象(如
String),不能使用<、>等运算符进行比较,而必须实现或使用compareTo方法。该方法定义了对象之间的自然顺序。 - 排序算法的效率:选择排序简单但效率较低,其时间复杂度为 O(n²),不适合处理大规模数据。Java内置的
Collections.sort方法采用了更高效的算法(如 O(n log n) 的归并排序),在实际应用中应优先考虑使用此类优化过的工具方法。
理解 compareTo 方法并认识到不同算法之间的效率差异,是编写高效、可扩展Java程序的重要基础。
140:Comparable接口

在本节课中,我们将要学习Java中的Comparable接口。这个接口定义了一种“自然排序”的约定,允许我们比较和排序实现了该接口的类的对象。我们将通过具体的例子来理解其工作原理和实际应用。
认识Comparable接口
上一节我们介绍了compareTo方法在QuakeEntry类中的使用。本节中,我们来看看定义这个方法的Comparable接口。
实现了Comparable接口的类具有一种“自然排序”方式。这意味着存在一种内在合理的方式将它们按顺序排列。Comparable接口所承诺的compareTo方法,正是用来定义这种排序规则的。
以下是Comparable接口的定义。它与你之前见过的接口略有不同,因为它使用了尖括号<T>。
public interface Comparable<T> {
int compareTo(T o);
}
接口顶部的<T>指定了该接口的类型参数。它指明了可以与什么类型进行比较。例如,QuakeEntry implements Comparable<QuakeEntry>意味着你可以将一个QuakeEntry对象与另一个QuakeEntry对象进行比较。
这个类型参数可以在接口定义的其余部分使用,并且会替换为传入的实际类型。对于Comparable<QuakeEntry>,compareTo方法就会接受一个QuakeEntry对象作为其参数。

泛型设计的目的
你可能会问,为什么Comparable接口要使用<T>,而不仅仅是针对QuakeEntry设计?实际上,Comparable是Java内置的一部分,而QuakeEntry不是。Java的创建者在设计时并没有考虑QuakeEntry,而是希望创建一个足够通用的接口,可以用于任何具有全序关系的类型。
Java中有许多类型都实现了Comparable接口,包括一些你已经见过的类型。例如,字符串可以与字符串比较,整数可以与整数比较,等等。
以String为例理解自然排序
为了更好地理解自然排序,让我们花点时间看看String类,因为它的排序规则非常直观。
String类实现了Comparable<String>。你可以比较任意两个字符串来对它们进行排序。那么,字符串的自然排序是什么呢?是字母顺序。如果你按字母顺序排列,“Apple”会排在“bear”之前。因此,“Apple”小于“bear”,“bear”小于“cards”,“cards”小于“dno”。
你可能还记得,字符串可以包含任何字符,而不仅仅是字母。那么如何对这样的字符串进行字母排序呢?如果它们包含相同的字母但大小写不同,又会发生什么?String类的compareTo方法会处理所有这些情况。它使用的排序在技术上称为字典序。
字典序是一种比较算法:从第一个字符开始逐个比较,只要字符相同,就继续比较下一个。当找到第一个不同的字符时,就根据这个字符来决定两个字符串的顺序。对于字母来说,这就是字母顺序。字典序只是这个更通用算法(适用于任何字符)的技术术语。
以下是几个比较示例:
- 比较“What!”和“What?”:首先比较‘W’,相同;然后比较‘h’,相同;接着比较‘a’,相同;再比较‘t’,相同;最后比较‘!’和‘?’。这两个字符串的顺序就取决于‘!’和‘?’哪个更大或更小。
- 比较“What”和“what”:Java会在第一个字符就发现差异。事实证明,大写字母的数值比小写字母小,所以“What”小于“what”。
- 比较“what!”和“what?”:比较过程与前两个例子类似,从第一个字符开始,直到遇到‘!’和‘?’的差异。
compareTo方法的返回值规则
那么,compareTo方法如何表示“小于”、“等于”或“大于”呢?它返回一个整数。
以下是compareTo方法的返回值规则:
- 如果当前对象小于参数对象,则返回一个负数。例如:
"Apple".compareTo("bear")返回-1。 - 如果当前对象等于参数对象,则返回 0。例如:
"bear".compareTo("bear")返回0。 - 如果当前对象大于参数对象,则返回一个正数。例如:
"dno".compareTo("cards")返回1。
重要提示:你不应该依赖特定的负数值或正数值(比如-1或+1)。该方法只承诺返回一个负数或正数。例如,"Apple".compareTo("dno") 可能返回 -3,而 "what".compareTo("What") 可能返回 32。

返回值背后的原理
这些返回值看起来可能有些奇怪,但实际上很有道理。compareTo方法通常通过减法来实现,String类就是如此。当它发现字符有差异时,它会将这两个字符的数值相减,并返回结果。这再次体现了“一切皆数字”的原则在起作用。
本节课中我们一起学习了Comparable接口。我们了解到,实现该接口的类具有自然排序,其核心是compareTo方法,该方法通过返回负整数、零或正整数来定义对象之间的顺序。我们还以String类为例,深入理解了字典序和compareTo方法的实现原理。掌握Comparable接口是理解Java集合排序和自定义对象比较的关键一步。
141:按震级排序地震数据 📊

在本节课中,我们将学习如何对地震数据进行排序,以便更好地理解数据的含义,例如哪些地震震级高,哪些震级低,以及地震发生的位置。
概述
我们将通过一个简单的程序来演示如何排序地震数据。首先,我们会创建一个解析器来读取数据文件,并将数据填充到一个列表中。然后,使用Java内置的快速排序方法对列表进行排序,最后打印出排序后的结果。
初始排序尝试
首先,我们运行一个名为 QuakeSorterDemo 的简单程序。该程序创建了一个解析器,打开已保存的数据文件,读取数据并填充到地震条目列表中,接着调用 Collections.sort 方法进行排序,并打印条目。
// 示例:初始排序代码结构
public class QuakeSorterDemo {
public void testSort() {
// 创建解析器,读取文件,填充列表
// 调用 Collections.sort(list)
// 打印排序后的列表
}
}
运行程序后,我们得到了大量地震数据。然而,在浏览数据时,我们发现排序结果并不符合预期。例如,一个震级4.4的地震出现在震级5.3的地震之前。仔细观察后发现,数据实际上是按照纬度从低到高(即从南到北)排序的,而不是按照震级。
修改排序逻辑
上一节我们发现排序依据是纬度,但我们的目标是按震级排序。为了实现这一点,我们需要修改 QuakeEntry 类,因为被排序的对象需要实现 Comparable 接口。
分析现有代码
查看 QuakeEntry 类,可以看到它已经实现了 Comparable 接口。其 compareTo 方法当前是根据纬度和经度进行排序的。
// 示例:原始的 compareTo 方法(按纬度/经度排序)
public int compareTo(QuakeEntry other) {
// 比较纬度、经度的逻辑
// 返回 -1, 0, 或 1
}
实现按震级排序
我们需要修改 compareTo 方法,使其根据震级进行比较。以下是修改步骤:
- 注释掉原有的按纬度/经度排序的代码。
- 编写新的逻辑:比较当前对象的震级(
this.getMagnitude())与另一个对象(other.getMagnitude())的震级。 - 根据
Comparable接口的约定:- 如果当前震级更小,返回一个负数(例如 -1)。
- 如果当前震级更大,返回一个正数(例如 1)。
- 如果两者相等,返回 0。
以下是修改后的 compareTo 方法代码:
public int compareTo(QuakeEntry other) {
double myMag = this.getMagnitude();
double otherMag = other.getMagnitude();
if (myMag < otherMag) {
return -1;
} else if (myMag > otherMag) {
return 1;
} else {
return 0;
}
}
注意:在编写代码时,务必正确调用 getMagnitude() 方法(带括号),而不是直接写 getMagnitude。
测试与优化
编译并运行修改后的 QuakeSorterDemo 程序。现在,数据输出显示震级最小的地震(例如-0.0)在最前面,而震级最大的地震(例如7.0)在最后面。这表明我们的按震级排序功能已成功实现。
代码简化
实际上,对于 double 类型的比较,我们可以利用Java已有的方法进行简化,避免重复编写比较逻辑。我们可以使用 Double.compare(double d1, double d2) 方法,它会根据两个双精度数值的大小关系返回负数、0或正数。
因此,我们可以将 compareTo 方法简化为一行代码:
public int compareTo(QuakeEntry other) {
return Double.compare(this.getMagnitude(), other.getMagnitude());
}
这种方法更加简洁,并且利用了Java标准库中已经过充分测试的代码,是更好的编程实践。
总结
本节课中,我们一起学习了如何对地震数据按震级进行排序。我们首先发现了初始程序是按地理位置排序的,然后通过修改 QuakeEntry 类的 compareTo 方法,将其改为按震级排序。最后,我们还学习了如何利用 Double.compare() 方法来简化比较逻辑,使代码更简洁、更健壮。在后续课程中,我们将探索如何同时按多种方式对数据进行排序。
142:Comparator接口

在本节课中,我们将要学习Java中的Comparator接口。我们将了解它与Comparable接口的区别,学习如何编写自己的比较器,以及如何使用它来对集合进行自定义排序。
概述
我们经常需要对对象列表进行排序。有时,一个对象可能有多种排序方式。例如,地震数据可以按震级、时间或距离排序。如果每次需要新的排序方式都去修改对象本身的compareTo方法,代码会变得混乱且难以维护。Comparator接口提供了一种解决方案,它允许我们定义独立于对象本身的外部排序规则。
上一节我们介绍了Comparable接口,它允许对象定义自己的“自然顺序”。本节中我们来看看Comparator接口,它如何提供更灵活的排序方式。
Comparator 与 Comparable 的区别
在查看Comparator的例子之前,理解它与Comparable的区别很有帮助。它们看起来功能相似,但实现方式不同。
假设有两个地震对象:quake1和quake2。

- 使用 Comparable:当你调用
quake1.compareTo(quake2)时,你是在要求quake1对象将自己与quake2进行比较。compareTo方法位于被比较的某个对象(这里是quake1)内部。该方法会从自身(quake1)和参数(quake2)中获取所需信息(例如震级)并进行比较。 - 使用 Comparator:现在,我们创建另一个专门用于比较的对象,例如
comparatorA。当你调用comparatorA.compare(quake1, quake2)时,你是在要求这个comparatorA对象来比较这两个地震。compare方法的代码位于这个第三方对象内部,而不是被比较的任何一个对象中。comparatorA内部的代码会从两个地震对象中获取所需信息(例如位置),并根据它们到某个特定位置的距离进行比较。
Comparator的好处在于,你可以创建另一个不同的比较器,例如comparatorB,并要求它来比较地震。comparatorB可以是一个与comparatorA类型不同的类,其compare方法中的代码也不同。例如,它可以查看地震的日期,并根据发生时间的远近进行排序。
这就是我们解决“每个人都想用不同方式排序地震”问题的方法。每个人都可以创建一个实现Comparator接口的不同类并使用它。

如何编写一个 Comparator
那么,这样的类具体是什么样子呢?以下是一个按震级排序地震的Comparator示例。
请注意,这个类实现了Comparator<QuakeEntry>。这个类可以用来比较两个QuakeEntry对象。你可以看到接口所承诺的compare方法:正如接口所承诺的,它是public的并返回一个int。
由于这个类实现了Comparator<QuakeEntry>,接口保证了该方法的参数将是QuakeEntry类型。该方法的主体通过比较两个地震的震级来实现排序。
public class MagnitudeComparator implements Comparator<QuakeEntry> {
public int compare(QuakeEntry q1, QuakeEntry q2) {
return Double.compare(q1.getMagnitude(), q2.getMagnitude());
}
}
这段代码与我们之前修改的、按震级比较的compareTo方法非常相似。区别在于,这个compare方法从它的两个参数中获取震级,而compareTo方法是从它的一个参数以及它所在的对象自身中获取震级。
如何使用 Comparator 进行排序
现在你已经看到了如何编写一个Comparator,让我们来看看如何使用Comparator来对集合进行排序。
Collections.sort方法可以接受第二个参数,即一个Comparator。当你向Collections.sort传递这第二个参数时,它将使用该比较器来确定列表中对象的排序顺序。


Collections.sort(quakeList, new MagnitudeComparator());
这里我们传入了一个新的MagnitudeComparator对象。请记住,当一个方法的参数类型是接口时,你可以传入任何实现了该接口的类的实例。在这里,MagnitudeComparator实现了Comparator,因此将其传入作为此参数是完全可以的。
正如你之前学到的,Collections.sort内部的代码会根据传入的Comparator类型,通过动态分派调用正确的compare方法。
总结

本节课中我们一起学习了Comparator接口。我们了解到,Comparator提供了一种定义对象外部排序规则的灵活方式,与定义对象自身自然排序的Comparable接口形成互补。通过创建实现Comparator接口的不同类,我们可以轻松地为同一类对象定义多种排序标准(如按震级、时间或距离),并通过将其传递给Collections.sort等方法来应用这些排序规则。这解决了因需求变化而频繁修改对象内部compareTo方法的问题,使代码更加模块化和可维护。
143:基于距离的Comparator实现


在本节课中,我们将学习如何编写一个基于距离的Comparator,用于根据地震事件与特定位置的距离来对它们进行排序。
我们将创建一个名为DistanceComparator的类,它实现了Comparator<QuakeEntry>接口。这个类将包含一个表示参考位置的变量fromWhere,并通过构造函数初始化。核心任务是实现compare方法,该方法会比较两个地震事件到参考位置的距离。
实现DistanceComparator类
上一节我们介绍了本课程的目标,本节中我们来看看如何具体实现DistanceComparator类。
首先,我们需要获取两个地震事件q1和q2各自的位置信息。接着,计算每个位置到参考位置fromWhere的距离。最后,使用Double.compare方法比较这两个距离值,并返回比较结果。
以下是compare方法的具体实现步骤:
- 获取第一个地震事件
q1的位置。 - 计算该位置到参考位置
fromWhere的距离,并存储在变量distance1中。 - 获取第二个地震事件
q2的位置。 - 计算该位置到参考位置
fromWhere的距离,并存储在变量distance2中。 - 使用
Double.compare(distance1, distance2)比较两个距离,并返回结果。
对应的核心代码如下:
public int compare(QuakeEntry q1, QuakeEntry q2) {
double distance1 = q1.getLocation().distanceTo(fromWhere);
double distance2 = q2.getLocation().distanceTo(fromWhere);
return Double.compare(distance1, distance2);
}
使用DistanceComparator进行排序
我们已经完成了比较器的编写,现在来看看如何在程序中使用它来对地震数据进行排序。
假设我们有一个DistSorter类,它读取了一系列地震数据,并希望根据它们到北卡罗来纳州达勒姆市的距离进行排序。在打印列表之前,我们需要使用Collections.sort方法并传入我们自定义的DistanceComparator。
以下是实现排序的关键步骤:
- 创建一个
DistanceComparator实例,并将参考位置(例如达勒姆市)作为参数传入构造函数。 - 调用
Collections.sort方法,传入地震数据列表和上一步创建的DistanceComparator实例。
对应的核心代码如下:
Collections.sort(list, new DistanceComparator(where));
运行程序后,输出结果将按照地震发生地距离达勒姆市的远近进行排序,从最近到最远依次显示。
总结
本节课中我们一起学习了如何创建和使用一个基于自定义规则的Comparator。我们首先实现了DistanceComparator类,通过计算并比较地震事件到指定位置的距离来定义排序逻辑。然后,我们在主程序中利用Collections.sort方法和这个自定义比较器,成功地将地震数据按照与达勒姆市的距离进行了排序。通过这个练习,你掌握了实现Comparator接口来满足特定排序需求的方法。
Java编程和软件工程基础:2-5:两种排序方式的总结 🎯
在本节课中,我们将总结Java中定义对象排序顺序的两种核心方法:Comparable接口和Comparator接口。理解这两种方式的区别与适用场景,是掌握Java集合排序的关键。
你已经学习了两种不同的方式来为一个类型定义排序顺序。

Comparable接口:定义自然顺序
Comparable接口允许一个类通过实现其承诺的 compareTo 方法来定义自身的自然排序。
这意味着,如果一个类实现了Comparable接口,它就拥有了一个默认的、与生俱来的比较规则。例如,String类实现了Comparable,所以字符串可以根据字典序自动排序。

其核心方法是:
public int compareTo(T o);
该方法将当前对象与指定对象进行比较,返回负整数、零或正整数,分别表示当前对象小于、等于或大于指定对象。
Comparator接口:定义外部比较器
而Comparator接口允许一个类为其他类型定义一种排序规则,通过实现其承诺的 compare 方法。
当你无法修改类的源代码(例如使用第三方库的类),或者需要为同一个类提供多种不同的排序方式时,Comparator就非常有用。它是一个独立的“比较器”。
其核心方法是:
int compare(T o1, T o2);
该方法比较两个参数对象o1和o2,同样返回负整数、零或正整数来表示它们的大小关系。


如何选择使用
这两种方式都可以用来指定集合(如ArrayList)在进行排序时应使用的排序标准。
上一节我们介绍了两种接口的定义,本节中我们来看看如何在实际的集合排序中应用它们。
以下是使用Collections.sort方法时的两种典型场景:
-
使用自然排序(
Comparable):当列表中的元素类已经实现了Comparable接口时,可以直接排序。Collections.sort(list); // list中的元素必须实现Comparable -
使用自定义比较器(
Comparator):可以传入一个Comparator对象来指定排序规则,这不会影响元素类本身的compareTo方法。Collections.sort(list, customComparator); // 使用自定义的比较器 // 或者在Java 8+中使用Lambda表达式 Collections.sort(list, (o1, o2) -> o1.getField() - o2.getField());
总结
本节课中我们一起学习了Java中实现对象排序的两种核心机制。
Comparable(可比较的):用于定义对象内部的、默认的自然排序顺序。通过实现compareTo方法实现。Comparator(比较器):用于定义外部的、灵活的排序规则,尤其适用于为无法修改的类或多个排序标准提供支持。通过实现compare方法实现。
掌握这两种接口,你就能灵活地控制Java集合中元素的排列顺序,满足各种复杂的排序需求。
145:马尔可夫文本生成介绍



在本节课中,我们将学习如何设计实用的Java程序,并在一个有趣且有用的程序背景下,锻炼软件设计和工程技能。


我们将开发和扩展一个使用马尔可夫文本生成技术的程序。该程序首先在训练文本上进行“学习”,然后根据学习到的数据生成随机文本。
虽然使用马尔可夫技术识别文本会涉及统计和概率方法,但生成文本的过程可以简单地实现,我们即将解释的设计方案就能做到。这些思想的清晰阐述,很大程度上归功于数学家兼计算机科学家克劳德·香农,他常被认为是信息论背后的主要思想家之一。

这将使您能够在一个具有实际应用价值的程序背景下,练习Java编程和设计概念。例如,谷歌的PageRank算法和许多人工智能机器学习算法,都依赖于我们将要探索的相同马尔可夫概念。




核心模型
我们将使用的模型非常简单。假设您或一只猴子在打字机或键盘上随机按键。会生成什么样的文本?那将是一堆乱码。
然而,如果这个键盘的按键是根据训练文本设计的呢?例如,如果训练文本是英文,那么键盘上“E”键的数量就会远多于“Z”键。这样生成的文本可能就不会那么随机了。
我们可以扩展这个模型:如果按下了“A”键,那么将使用一个新的键盘,这个键盘上“T”键比“B”键多,因为在训练文本中,以“AT”开头的单词远多于以“AB”开头的单词。第二个按键将基于训练文本中,在第一个字母为“A”的条件下,出现各个字母的概率。
我们还可以引入第三个键盘,它基于已按下的两个字母。例如,如果我们按下了“T”然后“H”,那么接下来按“E”的可能性就非常高,按“R”的可能性中等,而按“Z”的可能性则极低。
我们将在您即将学习的马尔可夫文本生成程序中使用这些思想。
程序输出示例


让我们看看您将开发的早期程序的输出示例。
基于训练数据生成文本
在本例中,训练数据是德国总理安格拉·默克尔于2015年10月7日在欧洲议会发表的一篇演讲。
- 零阶文本 仅基于原始文本中字符的分布生成。例如,文本中有很多“e”,因为这是一个非常常见的字符。空格字符也很常见,但生成的文本看起来并不像真实的文本。
- 一阶文本 使用一个字母来预测下一个字母。例如,字母“A”后面很可能跟着“N”,但“P”后面跟着“N”的可能性就较小。您看到的单词有时是可发音的,但它们并不是真正的单词,例如“pen”、“Wpplets”和“brunt”。
- 二阶文本 使用两个字符来预测第三个字符。这意味着“T H”后面很可能会出现“E”,出现“A”的可能性稍小,而出现“G”的可能性极低,因为序列“T H G”在文本中不常出现。您可以看到许多单词和类似单词的序列,例如“red”、“wordy”、“hand”和“muitions”。
虽然我们是在生成文本,但您或许也能通过生成的文本来识别其训练数据。这就是马尔可夫文本识别的工作原理。
例如,当前的垃圾邮件过滤算法通常依赖贝叶斯概率和马尔可夫过程,作为基于训练数据识别垃圾邮件的一部分。


以下是一个零阶文本示例。您能仅凭出现的字符就识别出它可能不是英文吗?某些字母上的重音符号暗示它可能是法文。
在一阶文本中(用一个字符预测下一个字符),文本看起来确实像是法文。

在二阶文本中(用两个字符预测第三个字符),文本看起来非常像法文。
在三阶文本中,您甚至可能认出法国国歌《马赛曲》。
在您编写的程序中,您将看到生成三阶乃至一般n阶文本是完全可以实现的。
本模块学习内容概述


以下是您将在本模块中学到的内容概览。


- 算法泛化:您将学习如何泛化一个概念和算法,以使用马尔可夫过程生成随机文本。
- 代码实现:您将看到零阶和一阶文本生成的代码,并将其泛化到任意n阶。
- 接口设计:您将开发一个Java接口,以在代码中实际地捕捉不同的概念和抽象。
- 程序设计与测试:这将帮助您设计和测试程序,这是积累程序员和软件工程师经验的重要部分。
- 效率提升:您还将看到,接口允许您在不一次性改动太多内容的情况下提高程序效率,通过分离设计和实现来促进效率优化。
- 技能实践:在开发这些程序的过程中,您将同时练习软件设计和工程技能。这将为您提供实用且可迁移到其他编程领域的经验。

总结
本节课中,我们一起学习了马尔可夫文本生成的基本概念。我们了解了如何通过分析训练文本中字符或字符序列的概率分布来生成新的、看似合理的文本。从简单的零阶模型(仅考虑字符频率)到更复杂的高阶模型(考虑字符上下文),我们看到了模型阶数如何影响生成文本的连贯性。此外,我们还预览了本模块将涵盖的核心内容:算法泛化、Java接口的设计与应用,以及通过此项目实践软件工程的重要技能,为后续的实际编程工作打下坚实基础。
146:零阶与一阶马尔可夫模型

在本节课中,我们将学习如何创建一个使用零阶马尔可夫算法随机生成文本的类。我们还将描述,为了创建一阶或更高阶的马尔可夫类,你需要做出哪些修改。零阶模型编程起来很直接,并且将成为其他马尔可夫类的模型。
所有马尔可夫模型都将使用一个训练文本作为随机生成文本的基础。



理解零阶与一阶模型

上一节我们介绍了马尔可夫模型的基本概念,本节中我们来看看零阶和一阶模型的具体区别。
在零阶模型中,我们不使用任何字符来预测下一个字符。我们直接从整个训练文本中随机选择每一个字符。你可以在这里看到一个零阶模型生成的文本。单词通常很长,字母组合常常没有意义。例如,像“tima”或“Dmo”这样的词很难发音。
在一阶马尔可夫模型中,我们根据前一个字符来选择下一个字符。这使得字母组合比零阶模型更常见,正如你在这里看到的,像“best”这样的词被随机生成出来,并且单词更容易发音,即使它们可能是像“Stard”或“Anco”这样的词。
设计 MarkovZero 类
我们将快速概述开发 MarkovZero 类的过程,你将能够用它来随机生成文本。
我们首先考虑方法。有时这被称为类的行为。思考方法将有助于我们思考需要哪些状态或实例变量。
我们需要能够为 MarkovZero 类设置训练文本,并且需要能够随机生成文本。这是两个不同的方法。我们可以将它们合并为一个方法,但通常来说,让方法保持单一职责是一个好主意。在这种情况下,我们可能希望从同一个训练文本中生成多个随机文本。因此,将方法分开非常有意义。

设置训练文本
首先,我们来看看如何在 MarkovZero 中设置训练文本。
训练文本在生成随机文本时使用。正如我们之前提到的,我们可能希望从同一个训练文本中创建多个随机文本。这意味着我们需要将训练文本存储在一个实例变量中。

以下是设置训练文本的方法:
public void setTraining(String text) {
myText = text;
}

实例变量在调用 setTraining 方法时被赋值,然后在生成随机文本时被访问。

生成随机文本
我们将设计和实现的另一个方法是随机生成文本。
getRandomText 方法将从训练文本中随机选择一个字符。我们将使用 java.util.Random 类中的 nextInt 方法来创建一个随机索引。然后,我们将使用这个索引从训练文本中访问一个随机字符。
我们将创建一个 StringBuilder 对象来存储随机文本,因为向 StringBuilder 添加或连接字符串是高效的。我们将字符追加到 StringBuilder 对象,并在完成后使用 toString 方法返回一个字符串。

以下是生成随机文本的方法:
public String getRandomText(int numChars) {
if (myText == null) {
return "";
}
StringBuilder sb = new StringBuilder();
for (int k = 0; k < numChars; k++) {
int index = myRandom.nextInt(myText.length());
sb.append(myText.charAt(index));
}
return sb.toString();
}
构造函数与其他方法
MarkovZero 类还需要一个构造函数,可能还有其他方法。
构造函数通常用于初始化字段或实例变量。在 MarkovZero 类中,我们有一个实例字段,用于存储来自 java.util 包的 Random 对象。我们在构造函数中创建一个新的 Random 对象。
为了帮助调试,生成可重现的随机数序列通常很有用。我们可以通过设置随机数生成器的种子来实现这一点,就像我们在这里创建新的 Random 对象时所做的那样。这在你调试马尔可夫类时可能会有所帮助。
还有一个实例字段 myText,它没有在构造函数中初始化,而是由我们之前讨论过的 setTrainingText 方法进行初始化。
向 MarkovOne 模型过渡
以上背景知识足以编写和测试 MarkovZero 类,但我们也会为 MarkovOne 提供一些指导。
我们有一个名为 MarkovRunner 的测试程序来测试 MarkovZero 类。用户选择一个文件作为训练文本。MarkovRunner 中的代码将每个换行符替换为空格。这保留了可能由空格分隔的单词,但不将换行符视为特殊字符。MarkovRunner 类从同一个训练文本中创建多个随机文本的示例。
当你开发和使用 MarkovOne 时,什么会改变?你将使用相同的方法名和相同的状态。通过使用相同的方法名,MarkovRunner 测试类也可以用来测试 MarkovOne 以及 MarkovZero。你需要更改 getRandomText 方法,因为在一阶马尔可夫文本生成中,使用一个字符来预测下一个字符。
我们将快速概述 MarkovOne 中的概念,你将在后续课程中看到更多算法开发的细节。
一阶模型的核心概念
在一阶马尔可夫模型中,使用一个字符来随机预测下一个字符。在这个图表中,我们看到字母 A 在字母 T 之后出现的概率是 12%,但字母 Y 出现的概率是 7%。这意味着如果我们生成了一个 T,那么我们接下来选择 E 的可能性比选择 R 大。根据图表中显示的概率,选择这两者的可能性都比选择 A 大。

训练数据用于创建这些概率。但我们实际上并不创建概率表,虽然这是可能的,但与你将使用的方法相比,这更困难且不必要。
你将编写代码来遍历训练文本中的每一个 T。每次找到一个 T 时,下一个字符会被添加到一个列表中,该列表代表了跟在 T 后面的字符。例如,在训练文本的前七个 T 之后,我们可能会找到字母 A, E, A, R, A, E, 和 Y。从这个列表中选择将与使用概率相同,因为列表中 E 的数量会比 Y 多。
我们将使用我们的七步流程来开发这个算法。
总结
本节课中我们一起学习了如何实现零阶马尔可夫模型来随机生成文本。我们了解了零阶与一阶模型在预测逻辑上的根本区别:零阶完全随机,而一阶则基于前一个字符进行预测。我们设计了 MarkovZero 类,包括设置训练文本和生成随机文本的方法,并讨论了构造函数和实例变量的作用。最后,我们预览了如何将零阶模型扩展为一阶模型,其核心在于根据前驱字符动态构建后续字符的列表,并从中进行随机选择,这为后续的算法实现奠定了基础。
147:27_04_04_寻找跟随集 👨💻



在本节课中,我们将学习如何为高阶马尔可夫模型(如马尔可夫一阶、二阶模型)开发核心算法。具体来说,我们将专注于一个关键任务:寻找“跟随字符集”。我们将使用一个七步流程来推导这个算法,并通过一个简单的例子来理解其工作原理。
概述 📋
我们已经学习了如何为马尔可夫零阶模型(Markov Zero)编写Java程序来生成随机文本。在该模型中,每个字符都是完全随机地从训练文本中选取的。现在,我们将开发一个更高级模型的核心算法。在马尔可夫一阶模型中,如果我们生成了字符 T,我们需要从训练文本中所有紧跟在 T 后面的字符中,随机选择一个作为下一个字符。这个“所有紧跟在后面的字符”的集合,就是我们需要寻找的“跟随集”。本节将详细讲解如何找到这个集合。

七步流程:推导算法 🔄
我们将使用一个结构化的七步流程来设计和理解这个算法。
第一步:实例演练

我们使用一段简单的训练文本来进行实例演练:
this is an attempt to illustrate the algorithm
我们的目标是:找出所有紧跟在字母 T 后面的字符。我们将从这段文本中推导出通用算法。

- 第一个
T出现在索引0,它后面的字符是索引1的H。 - 下一个
T出现在索引12,它后面的字符是索引13的T。 - 再下一个
T出现在索引13,它后面的字符是索引14的E。 - 最后一个
T出现在索引17,它位于文本末尾,后面没有字符。
第二步:记录操作步骤
我们将手动寻找跟随字符的过程一步步写下来。
- 初始化一个空的
follows列表。 - 从索引
0开始搜索第一个T。在索引0处找到。 - 将索引
0后面的字符(索引1的H)加入follows列表。 - 从索引
1(即上一步找到的跟随字符的位置)开始,搜索下一个T。在索引12处找到。 - 将索引
12后面的字符(索引13的T)加入follows列表。 - 从索引
13开始,搜索下一个T。在索引13处找到。 - 将索引
13后面的字符(索引14的E)加入follows列表。 - 从索引
14开始,搜索下一个T。在索引17处找到。 - 索引
17是文本末尾,后面没有字符,因此无法添加,停止搜索。

最终,follows 列表包含三个字符:H, T, E。
第三步:寻找模式并归纳
观察第二步的记录,我们可以发现一个清晰的重复模式:
- 模式A(找到并添加):
找到键(key) -> 添加其后的字符 -> 从新位置开始下一次搜索。这个模式在步骤(2,3)、(4,5)、(6,7)中重复。 - 模式B(找不到或无法添加):当搜索找不到键,或者找到的键位于文本末尾时,循环终止。如步骤(8,9)。
我们还注意到一个关键关系:下一次搜索的起始位置(pos),就是上一次找到的跟随字符的索引。
第四步:编写通用算法
基于以上模式,我们可以用伪代码描述通用算法:
- 初始化一个空的
follows列表。 - 设置搜索起始位置
pos = 0。 - 当
true时循环:- 从位置
pos开始,在训练文本中寻找键(key)的下一个出现位置,结果存入index。 - 如果 没有找到
key(index == -1),则 跳出循环。 - 如果
index已经是文本的最后一个字符(index >= 文本长度 - 1),则 跳出循环。 - 否则:
- 获取
index位置后面的字符(即index + 1位置的字符)。 - 将该字符加入
follows列表。 - 将下一次搜索的起始位置
pos更新为index + 1。
- 获取
- 从位置
- 循环结束后,返回
follows列表。
核心逻辑的伪代码表示:
ArrayList<Character> follows = new ArrayList<>();
int pos = 0;
while (true) {
int index = trainingText.indexOf(key, pos);
if (index == -1 || index >= trainingText.length() - 1) {
break;
}
follows.add(trainingText.charAt(index + 1));
pos = index + 1;
}
return follows;
第五步:测试算法


让我们用另一个键(例如字母 A)和同一段训练文本来测试这个算法。训练文本中 A 出现在:
- 索引8(
ain “an”),后面是空格。 - 索引10(
ain “attempt”),后面是t。 - 索引20(
ain “algorithm”),后面是l。

根据算法,follows 列表应包含:空格, t, l。你可以手动模拟算法步骤来验证。
第六步:实现代码
将上述伪代码翻译成具体的Java代码。确保正确处理字符串边界和返回值。
第七步:调试与优化
运行代码,使用不同的训练文本和键进行测试,确保其正确性。思考如何将其扩展以支持马尔可夫二阶模型(此时键是双字符,如 TH)。
总结 🎯
本节课中,我们一起学习了为高阶马尔可夫模型生成文本的核心算法——寻找跟随集。我们通过一个具体的七步流程,从实例演练开始,逐步记录、归纳模式,最终推导出通用算法并准备将其转化为代码。这个算法的核心思想是:通过循环,在训练文本中不断寻找指定键的下一个出现位置,并收集其后的字符,同时更新搜索起点,直到搜索完整个文本。掌握这个算法是理解马尔可夫链文本生成的关键一步。
148:二阶马尔可夫模型实现


在本节课中,我们将学习如何改进一阶马尔可夫模型,通过观察前两个字符来生成更逼真的随机文本。我们将基于已有的 MarkovOne 类,创建一个新的 MarkovTwo 类,并理解其核心实现逻辑。
你已经编写了一个 MarkovOne 类,它根据最近一个字符来生成随机文本。这种方法生成的文本看起来更真实一些,但效果还不够好。如果让它观察前两个字符,效果可能会更好。你将基于 MarkovOne.java 类来实现 MarkovTwo.java 以完成这项改进。你可以轻松地将这个 MarkovTwo 类扩展为 MarkovThree 或更通用的 MarkovN 类。
回顾一阶模型
之前开发的 MarkovOne 类在 getRandomText 方法的主循环中调用了一个 getFollows 辅助方法。我们将在下一张幻灯片看到完整的方法。
getFollows 方法使用了我们之前通过七步流程开发的算法。它返回一个列表,包含所有跟随在“键”后面的字符。这里我们使用的是单字符字符串,而不是字符类型。
根据 getFollows 返回的 ArrayList 的值,循环在找不到后续字符时退出。或者,返回的列表被用作随机选择字符的来源,作为马尔可夫过程生成随机文本的一部分。这里的 nextInt 方法从 ArrayList 中随机选择一个字符串。
这个随机字符串被添加到我们正在构建的随机文本字符串中,每次一个字符。然后,循环继续,将这个随机字符串作为下一个单字符“键”,它的跟随列表将在下一次循环中被找到。
分析 getRandomText 方法细节

为了开发 MarkovTwo 以及更通用的马尔可夫模型类,我们先来看一下 getRandomText 方法的一些细节。以下是完整的 getRandomText 方法,上一张幻灯片中的 for 循环未显示。
“键”是一个从训练文本的所有有效索引中随机选择的单字符字符串。我们不将文本的最后一个字符视为有效,因为它后面没有跟随字符。这就是为什么我们使用 myText.length() - 1 作为生成随机索引的值。

索引用于创建一个单字符字符串作为“键”。这就是为什么我们使用 index 和 index + 1 作为 substring 方法的参数。
准备编写二阶模型代码

在准备为 MarkovTwo 编写代码时,思考一下我们需要做哪些修改,才能使这段代码适用于双字符“键”,而不是单字符“键”。让我们开始编码。
我使用 Markov Basics 项目,将从 MarkovOne 过渡到 MarkovTwo。我们将看到这在生成的随机文本质量上会产生相当大的差异。
这里我在 MarkovRunner 程序中,它创建了一个 MarkovOne 对象,然后从中生成三种不同的随机文本。我编译这个类,它运行正常。现在我要创建一个新的 MarkovRunner 对象。
然后,我将调用其中的 runMarkov 方法。我将选择一些随机文本并从其生成。我想我会使用莎士比亚的《罗密欧与朱丽叶》。

当我生成那个随机文本时,我们可以看到这看起来不太像《罗密欧与朱丽叶》。从 “Liwaa and Gong am Aman Anf Bcastle” 很难看出它像《罗密欧与朱丽叶》。这是一阶马尔可夫文本生成,意味着每个字符被用来预测下一个。
你可能还记得课程内容,我在我的 MarkovRunner 类中,简单地将 MarkovOne 改为 MarkovZero。编译并运行这个程序,它没有语法错误。
现在当我通过创建一个新对象来运行它。使用 runMarkov 方法并生成另一个随机的罗密欧文本。这看起来更不像罗密欧了,它只是 “from from So to Nma”。这确实有很多 E 和 A,因为它是根据训练文本中出现的频率随机选择文本的。
我们设计的一个特点是,使用 MarkovZero 代替 MarkovOne 仍然允许我的客户端程序工作。因为 MarkovZero 和 MarkovOne 都依赖于 getRandomText 方法和 setTraining 方法。这就是它们所依赖的全部。在后面的课程中,我们将能够用一个接口来捕捉这种共性。
现在,我们将创建 MarkovTwo 类。使用两个字符进行预测并运行它。当我编译时,它说找不到符号方法 setTraining。所以 MarkovTwo 类没有这些方法。让我打开它看看发生了什么。哇,MarkovTwo 只是一个程序框架。它里面没有任何方法。实际上,注释写着“复制 MarkovOne 并对其进行修改”。所以我要这么做。
我将删除这个注释。我将打开我的 MarkovOne 类。我将通过拖拽到底部来复制 MarkovOne 的完整内容。复制它,然后粘贴到 MarkovTwo 中。现在,我知道构造函数的名称必须是 MarkovTwo 而不是 MarkovOne。
现在当我编译它时,这个类编译正常,我不再需要 MarkovOne 了。当我转到 MarkovRunner,它现在使用 MarkovTwo 作为类名。当我编译这个类时,它工作得很好,因为我将 MarkovOne 的内容复制到了 MarkovTwo 中。一般来说,从一个类复制代码到另一个类并不是一个很好的软件设计技术,但这是我们目前在没有开发接口来捕捉公共代码之前可用的方法。
修改代码以适应二阶模型
当我浏览这段代码时,我想理解 MarkovOne 和 MarkovTwo 之间的区别。setTraining 方法是相同的。getRandomText 方法略有不同,因为我要用两个字符来预测下一个,而不是只用一个字符来预测下一个。所以正如我们在这里看到的,它选择一个子字符串作为我的初始“键”,那个子字符串的长度是1,从 index 到 index + 1。我将把它改为从 index 到 index + 2。
这将意味着,一个有效索引的值最多只能到末尾前两个位置。我需要能够选择任何允许有两个字符子字符串的索引。所以我需要把这个 1 改成 2,这个 1 改成 2,我也需要把这个 1 改成 2,因为这个循环已经生成了两个字符并在这里存储为“键”,所以我只需要生成 numChars - 2 个字符。
当我编译这个程序时,它运行正常,没有错误。我将使用 MarkovRunner,编译它。创建一个新的 MarkovRunner 对象。运行 runMarkov 方法,使用《罗密欧与朱丽叶》。


我们可以看到,这里的文本看起来更像真实的英语了。我可以看到像 “Tid” 这样的词,可能是 “the Montagues and the capt”, “somehow home is here”, “panda is here”, “thiscade” 这些词看起来更……我甚至听到了 “Roman” 而不是 “Romeo”。所以通过简单地把 1 改成 2,我的文本有了更多英语特质。我可能会更进一步,把 2 改成 3 看看效果。
如果那能工作,我将对我的修改正确性有相当好的信心。我希望这看起来更像《罗密欧与朱丽叶》。“Once Fars Herman Rosar Suiz”,这似乎更像罗密欧,“wherefore art Thou Romeo”,我可以想象这有点像那样。所以我对我的修改按预期工作感到相当满意和满足。
因为我写的是 MarkovTwo 而不是 MarkovThree,我要把我的 3 改回 2,那只是我为了验证我的修改是否正确。
总结与展望
作为一个提醒,因为你在这里看到的 getFollows 辅助方法无论我使用的“键”的大小如何都能工作,因为它使用了像 key.length() 这样的东西。我在使用中将 1 改为 2 的修改已经足够好了。我有了 MarkovOne、MarkovTwo,并且我可以轻松地制作 MarkovN 用于 N 个字符。


对于更通用的马尔可夫生成,我将等到我们学习接口时再处理。编程愉快!




在本节课中,我们一起学习了如何将一阶马尔可夫模型扩展为二阶模型。我们通过复制 MarkovOne 的代码并修改关键参数(如将子字符串长度从 1 改为 2),成功创建了 MarkovTwo 类。这个改动显著提升了生成文本的连贯性和真实感。核心在于 getFollows 方法的通用性,它能够处理任意长度的“键”,这为我们未来实现更高阶或通用的 MarkovN 类奠定了基础。下一阶段,我们将学习使用接口来更好地组织这些具有共同行为的类。
149:测试与调试 🐛

在本节课中,我们将学习如何通过测试与调试来发现和修复程序中的错误。我们将以一个具体的例子——修复一个马尔可夫文本生成器中的逻辑错误——来演示这一过程。
上一节我们介绍了马尔可夫模型的基本概念,本节中我们来看看如何通过调试来修正一个实现上的错误。
在观察程序输出时,我发现随机生成的文本看起来不太对劲。特别是当使用 Markov3 模型时,输出结果仍然像乱码。理论上,使用 Markov3 意味着我们基于三个字符来预测下一个字符,因此输出中的每一个四字符子串都应该来自训练文本。但实际结果并非如此。
为了找出问题所在,我决定调试这个程序。首先,我需要缩小测试范围,以便更容易地观察程序行为。

以下是调试步骤:
- 简化输入数据:为了避免处理大文件的复杂性,我回到
MarkovRunner类,将训练文本替换为一个简短的测试字符串,例如"This is a test. Yes, a test."。 - 添加打印语句:在
MarkovTwo类的getRandomText方法中,我添加了打印语句来输出当前的“键”(key)和对应的“后续字符集合”(follows)。这有助于验证程序内部的数据是否正确。System.out.println("Key: " + key + " Follows: " + follows); - 运行并观察:使用简化的测试数据运行程序。观察控制台打印的信息,我发现了一个关键问题:除了第一个键是预期的两个字符长度外,后续所有的键都变成了单个字符。这与
MarkovTwo模型要求键长始终为2的设定不符。
通过分析代码,我找到了错误的根源。在 getRandomText 方法中,更新键(key)的代码逻辑是错误的。
原来的错误代码是:
key = "" + nextChar; // 错误:将键直接设置为新字符
正确的逻辑应该是:将旧的键去掉第一个字符,然后在末尾追加新找到的字符,从而实现滑动窗口的效果。

修正后的代码应为:
key = key.substring(1) + nextChar; // 正确:滑动窗口,保持键长不变
修复这个错误后,我重新用简化的测试数据运行程序。这次,所有的键都正确地保持了两个字符的长度,生成的文本也看起来合理了。

确认基本逻辑正确后,我移除了调试用的打印语句,并将训练数据恢复为完整的文件(如《罗密欧与朱丽叶》的文本)。再次运行 MarkovTwo 模型,生成的文本质量有了明显提升。
最后,我将模型从 MarkovTwo 升级到 MarkovThree。这需要做三处修改:将所有代表键长的数字 2 改为 3。修改后运行程序,生成的文本中出现了更多有意义的单词和短语,效果显著改善。
本节课中我们一起学习了调试程序的基本方法:通过简化测试用例、添加打印语句来观察程序内部状态,从而定位逻辑错误。我们发现,程序员对代码行为的预期与实际运行结果可能存在差异,而系统性的调试是发现并修正这些差异的关键。最终,我们成功修复了马尔可夫文本生成器中的键更新逻辑错误,并验证了 MarkovThree 模型能产生更连贯的文本。
150:接口与抽象类 🧩


在本节课中,我们将学习如何使用接口和抽象类来捕获Markov程序中多个类共享的公共特性。我们将探讨如何通过接口实现代码的通用性和灵活性,以及如何利用抽象类来避免代码重复。

使用接口捕获共性
上一节我们介绍了Markov程序的基本结构。本节中,我们来看看如何通过接口来形式化类之间的共同点。
我们曾在MarkovRunner类的runMarkov方法中开发代码来生成随机文本。我们首先使用MarkovZero类,然后将其变量更改为MarkovOne类,而runMarkov中的代码仍然有效。这是因为MarkovZero和MarkovOne类使用了相同的方法名,例如setTraining和getRandomText。
我们希望用Java接口来捕获这些共性,以便能以多种方式使用这些类。你可能还记得排序时使用的Comparable和Comparator接口,以及我们为搜索地震数据而开发的Filter接口。现在,我们将设计并实现一个新的接口来捕获这里的共性。


以下是接口开发的核心步骤:

- 定义接口:我们通过将方法的签名包含在接口中来捕获Markov类中的公共方法。
- 命名惯例:接口名称通常以“I”开头,这是一种常见做法。因此我们创建了
IMarkovModel接口。 - 实现接口:每个实现该接口的类都使用Java关键字
implements来声明,例如MarkovOne implements IMarkovModel。 - 方法要求:每个类中已经存在具有所需名称的必需方法。
使用接口将提供实用性和灵活性。

接口的实用性与灵活性
接口的一个主要优势是能够编写通用的方法。让我们看看这是如何实现的。
我们可以编写一个方法,其参数类型为IMarkovModel接口,如下面runModel方法所示。第一个参数markov的类型是IMarkovModel。这意味着我们可以调用runModel并传递一个MarkovZero对象或一个MarkovTwo对象作为第一个参数。
- 可以传递名为
mz的对象,因为它的类型MarkovZero实现了IMarkovModel接口。 - 可以传递名为
m2的对象,因为MarkovTwo也实现了IMarkovModel接口。
调用markov.setTraining(...)将调用MarkovZero、MarkovTwo或任何其他实现了IMarkovModel接口的类中的相应方法。对markov.getRandomText(...)的调用同样会执行作为第一个参数传入的对象所属类中的特定代码。

这个例子阐释了所谓的“开闭软件设计原则”。其思想是:类应该对扩展开放,但对修改关闭。

使用接口以及我们即将看到的其他概念,你可以创建一个新类,并用它来替代一个已经过测试和验证的类。你不应该为了扩展代码功能而去修改已经正常工作的代码。
IMarkovModel接口提供了灵活性。正如你所见,我们可以开发一个新的通用MarkovModel类来替代MarkovOne、MarkovTwo等。如果这个类实现了IMarkovModel接口,我们就可以在现有代码中使用它。这意味着我们不需要为了使用新类而去修改例如runModel这样的代码。

我们可以开发一个使用哈希映射的更高效实现,并用它来替代MarkovModel。例如,在你编写的代码中,辅助函数getFollows可能会被调用数百次来查找跟在“TH”后面的字符。每次都会重新扫描整个文本来查找。通过存储和重用这些后续字符,你的代码可能会更高效。并且由于接口的存在,你可以在runModel和其他代码中使用它。

引入抽象类避免重复
IMarkovModel接口提供了极大的灵活性,但有些共享代码我们可以通过开发所谓的“抽象类”来避免重复。
我们开发的每个Markov类都共享状态和代码,有时这些代码在每个类中是重复的。
例如,每个类都有一个Random对象和用于建模随机文本的文本,它们分别存储在实例变量myRandom和myText中。许多类共享完全相同的getFollows辅助方法,这些方法被复制粘贴到每个.java文件中。

我们希望避免这种重复,方法是将公共状态和代码捕获到一个称为“抽象基类”的结构中。这将依赖于继承,这是一个极其重要的面向对象概念。我们在这里简要提及,你可以在Coursera的UCSD专项课程中了解更多关于此概念及其他面向对象概念的知识。

抽象基类在Java的util包中被广泛使用,例如AbstractList和AbstractMap。像ArrayList和HashMap这样的类就是这些抽象类的子类。子类可以继承状态和代码(或行为)。
让我们来看看抽象基类。
抽象基类AbstractMarkovModel被标记为abstract。我们很快就会明白这意味着什么。在所有类(如MarkovOne、MarkovTwo和MarkovModel)之间共享的状态被标记为protected,而不是private。这些实例变量将在每个扩展此抽象基类的子类中可访问。我们接下来讨论extends关键字。
抽象类与共享方法

让我们更仔细地看看抽象方法和共享方法。
该类是抽象的,因为在AbstractMarkovModel类中有一个方法被标记为abstract。那个方法就是getRandomText方法,它在每个扩展AbstractMarkovModel的类(称为子类)中以不同的方式实现。
辅助函数getFollows被标记为protected。它可以在每个子类中被调用,就像受保护的实例变量可以被访问一样。

扩展基类
你可以看到MarkovModel类继承了我们在看的AbstractMarkovModel类的状态和行为。关键字extends意味着这个类从超类(或父类)获取实例变量和getFollows代码。
因为这个超类或基类实现了IMarkovModel接口,所以这个MarkovModel类也实现了从超类继承的相同接口。这个类有自己的实例变量myOrder,被标记为private,就像你在之前的编码示例中所做的那样。
扩展抽象类的类必须实现抽象方法。
AbstractMarkovModel类有一个抽象方法getRandomText。这意味着MarkovModel类必须实现这个方法,正如你在这里看到的。MarkovModel子类从超类继承了受保护的状态和行为。这包括受保护的实例变量myRandom和myText(训练文本)。子类也可以调用继承的方法,比如getFollows。

总结
让我们总结一下接口和继承的例子。
抽象基类的关键思想是实现接口,并在可能时提供默认功能,以避免在每个子类中重复状态或行为。

像MarkovZero或MarkovOne这样的子类将扩展基类。扩展类意味着子类继承了父类或超类的接口,因此子类也实现了IMarkovModel。这意味着客户端代码无需更改,依赖该接口的代码仍然可以工作。

抽象基类中的一些方法被标记为abstract,子类必须提供其实现。例如,我们在MarkovOne和MarkovTwo中看到了getRandomText的不同实现。
在本节课中,我们一起学习了如何利用接口定义类之间的契约以实现灵活替换,以及如何使用抽象基类来封装共享的代码和状态,从而减少重复并建立清晰的继承层次结构。这些是构建可维护、可扩展Java应用程序的重要工具。
祝编程愉快!
151:预测与随机文本生成总结 🧠


在本节课中,我们将总结通过一系列相关程序和类所学到的新Java概念,这些概念是在预测和随机文本生成这一实用想法的背景下引入的。

通过开发一系列相关的类和程序,我们得以在熟悉的程序背景下介绍新的Java和设计思想。


在我们的示例中,我们使用了马尔可夫文本生成,但这些思想有助于形成机器学习算法的基础,例如用于垃圾邮件检测以及搜索引擎和移动智能手机中的预测和自动完成功能。
上一节我们介绍了马尔可夫模型的应用背景,本节中我们来看看在实现过程中遇到的具体设计问题及其解决方案。

我们研究了相关的类,这引导我们设计接口和抽象基类,以克服在多个类之间复制和粘贴代码的问题。熟悉的背景有助于促进对这些新的Java和面向对象概念的探索。我们还通过查看用于排序的Comparable接口,更深入地研究了Java接口。
以下是我们在设计过程中遵循的第一个关键原则:

- 首先,我们在许多类中使用相同的方法名。 这使我们能够在新类中重用客户端或测试代码。测试代码之所以能与不同的类一起编译,是因为方法名是相同的。我们首先开发了
MarkovZero,但通过重用已经测试过的MarkovZero类中的思想和方法名,促进了MarkovTwo和MarkovOne的设计。
在确立了统一方法名的重要性之后,我们进一步将这一思想形式化。
我们将创建通用方法名的思想扩展到了创建一个名为IMarkovModel的接口。接口是Java和其他面向对象语言中一个强大的概念。接口在java.util、java.io和其他包与库中被广泛使用。


以下是接口定义的一个简单示例:
public interface IMakrovModel {
void setTraining(String text);
String getRandomText(int numChars);
}

最后,我们将这些思想进一步扩展,为马尔可夫类创建了一个抽象基类。这使我们能够捕获公共代码,而不仅仅是方法名,正如我们通过创建接口所捕获的那样。
以下是抽象基类可能包含的公共代码示例:
public abstract class AbstractMarkovModel implements IMakrovModel {
protected String myText;
protected Random myRandom;
public void setTraining(String s) {
myText = s;
}
public void setRandom(int seed) {
myRandom = new Random(seed);
}
// getRandomText 方法保持抽象,由子类实现
public abstract String getRandomText(int numChars);
}
本节课中我们一起学习了如何通过构建一系列相关的马尔可夫文本生成器,来引入和掌握Java的核心设计概念。我们从统一方法名开始,进而设计了IMarkovModel接口以实现多态,最后创建了AbstractMarkovModel抽象基类来消除代码重复。这个过程展示了接口和抽象类在构建灵活、可扩展和可维护的面向对象程序中的强大作用,这些原则是许多复杂算法和现代软件工程实践的基础。
152:基于马尔可夫程序的Java类设计 🧠
在本节课中,我们将以马尔可夫程序为基础,学习Java的类设计,这是Java面向对象概念应用的一部分。我们将利用之前使用马尔可夫过程生成文本的程序中已建立的概念,但会对其进行扩展:不再基于字母选择字母,而是基于单词选择单词。
从字母到单词的转变 🔄
上一节我们介绍了基于字母的马尔可夫模型。本节中,我们来看看如何将其思想应用到单词上。
在零阶程序中,我们将随机选择单词,就像打字机上的按键是训练文本中的单词而非字母一样。在一阶程序中,我们将使用一个单词来预测或选择下一个单词。例如,根据训练文本,单词“cat”更可能跟在“the”后面,而跟在“dinosaur”后面的可能性则较小。
我们将使用上一课中开发的Java接口,但会扩展其思想,通过生成我们希望是有趣的故事来学习新的Java概念。
设计类似String的Word类 📝
我们将重点研究如何在Java中设计一个类,使其能够像String类一样被使用。我们将设计一个与String等效的Word类,因此它将支持诸如 .length() 和 .substr() 之类的方法。
然而,我们会发现需要开发代码以使 .equals() 方法正常工作,并且使对象能够作为哈希映射(HashMap)中的键。这些是适用于许多类的标准Java概念,不仅仅是我们将要设计和实现的Word类。
程序输出对比 📊
让我们来看一下单词马尔可夫程序的输出。我们将比较使用字母的一阶示例和使用单词的一阶示例的输出。
以下是两种输出的特点对比:
- 字母一阶程序:输出难以辨认,单词难以发音(例如“Hingngebo, Owap”),通常没有意义。
- 单词一阶程序:所有单词都是训练文本中的真实单词,但单词的顺序常常不合逻辑(例如“I beg your tongue”)。不过,你或许能认出一些单词并推断出训练文本是刘易斯·卡罗尔的《爱丽丝梦游仙境》。

当你使用二阶或三阶单词程序时,输出在语法上会更有意义。
接口的威力与程序运行 ⚙️

我们将要开发的 MarkovWord 类可以通过与使用字母的程序相同的 MarkovRunner 类来运行。这正是Java接口所实现的功能之一。
根据你的兴趣,你可能更容易识别出生成这些单词的训练文本。如果你认不出来,也没关系,这并不重要。此处的训练文本是阿黛尔的歌曲《Hello》。这在今天可能容易识别,但几年后呢?希望到那时你仍在编程,可以亲自验证。

你可以用任何数据训练你的程序,而不仅仅是古老的儿童书籍。
总结 ✨

本节课中,我们一起学习了如何将马尔可夫模型从字母层面扩展到单词层面,并以此为契机探索Java类设计。我们了解了设计一个类似String的Word类需要考虑的关键点,如实现 .equals() 方法和使其可作为HashMap的键。我们还对比了不同模型的输出效果,并体会了Java接口在实现程序灵活性和可扩展性方面的作用。
153:一阶模型概念 🧠



在本节课中,我们将要学习如何实现一个基于单词的马尔可夫模型(Markov Word One),用于根据一个单词来预测下一个单词,从而生成随机文本。我们将复用之前基于字符的马尔可夫模型的设计和代码,理解抽象和接口如何帮助我们重用客户端程序。

复用设计理念 🔄
上一节我们介绍了基于字符的马尔可夫模型。本节中我们来看看如何将相同的概念应用到基于单词的模型上。





我们将使用在生成随机字符文本的马尔可夫程序中已经开发并测试过的相同概念。我们已经开发并测试了 IMarkovModel 接口,我们将继续使用它。在可能的情况下,复用已经测试过的代码是一个好主意。如果我们有一个好的设计,那么像 MarkovRunner 这样的客户端程序,即使面对一个全新的、基于单词而非字符的文本生成模型,也能继续工作。
一个好的设计意味着实现会改变,但接口(包括使用该接口的客户端程序)不会改变。这就是抽象的最佳体现。我们能够重用客户端程序,是因为这些程序依赖于类的接口,而不是其具体实现。
状态与行为的改变 🛠️
我们将把实例变量 myText 从字符串(String)改为字符串数组(String Array)。这需要修改一些代码。我们将搜索单词而非字符。这意味着我们需要修改辅助方法 getFollows,并实现一些新的私有辅助方法。



设计类通常意味着思考类的状态和行为,即实例变量和方法。


我们将以 MarkovOne 中经过测试的设计和代码为基础,来构建 MarkovWordOne。两者都实现了 IMarkovModel 接口,这意味着两者都将拥有 setTraining 和 getRandomText 方法。
在 MarkovWordOne 中,我们将遍历一个单词数组(即训练文本),这类似于在字符串中搜索字符。实际上,字符串在其内部实现中也使用了字符数组。

我们将一次一个单词地构建随机文本。这与一次一个字符地构建文本几乎相同,只有一个小的区别:我们需要在单词之间添加空格。当一次一个字符地构建文本时,我们利用了空格本身也是一个字符这一事实,因此空格是随机生成的。

初始化 MarkovWordOne 对象 🏗️
我们需要创建并初始化实例变量。我们将一个随机对象和一个单词数组存储为实例变量。这与 MarkovOne 几乎相同,但我们使用的是字符串数组而不是单个字符串。
我们在构造函数中初始化字段,创建随机数生成器。实例变量 myText 在调用 setTraining 方法时被赋值,就像 MarkovOne 中的代码一样。这里我们使用字符串方法 split,通过正则表达式 \\s+ 将一个字符串分割成单词,该表达式代表一个或多个任意空白字符。我们之前使用过相同的习惯用法来将字符串分割成单词。
实现 getRandomText 方法 📝

现在,我们转向 getRandomText,这是 IMarkovModel 接口要求的第二个方法。该接口要求我们实现此方法以返回随机生成的文本。
接口中的方法签名使用了一个名为 numChars 的整型参数。因为在之前的马尔可夫类中,我们设计并实现了 getRandomText 方法,该方法基于随机生成指定数量的字符来返回一个字符串。
在 Java 中,方法签名取决于参数的类型,而不是参数名。因此,在 MarkovWordOne 中,我们可以将参数名改为 numWords,因为我们是一次生成一个单词,而不是一个字符。
我们将单词逐个添加到 StringBuilder 中,这里展示了初始键是随机选择的情况。我们还必须在每个单词后显式地添加一个空格。因此,在将每个单词追加到 StringBuilder 后,我们追加一个空格字符串。这意味着末尾会有一个多余的空格,但我们可以使用 String.trim() 方法将其移除。
MarkovWordOne 中的 getRandomText 方法几乎完成了。我们没有在上一个幻灯片中展示的 for 循环在这里展示。它与 MarkovOne 中的 for 循环几乎相同。

我们将方法中的 numChars 改为此版本 getRandomText 中使用的名称 numWords。我们还显式地在追加到 StringBuilder 的单词后添加了一个空格。除此之外,代码是相同的。
修改辅助方法 getFollows 🔍


我们准备编码和测试 MarkovWordOne。我们将从 MarkovOne 复制 getFollows 私有方法。


那段代码搜索一个字符串实例变量 myText。我们将复制的代码使用了 .length() 和 .indexOf() 方法,我们需要更改这些。我们复制的代码还使用 .substring() 来创建跟随键(key)之后添加到 ArrayList 值列表中的单字符字符串。我们也将替换 .substring()。


这些变更是必要的,因为 myText 从字符串变成了字符串数组。替换 .length() 和 .substring() 将非常简单,但我们需要编写一个辅助方法来替代字符串方法 .indexOf()。

Java 没有为数组或 ArrayList 提供同样类型的、返回索引的通用搜索方法。ArrayList 确实有返回布尔值的 .contains() 方法,以及返回值的第一个或最后一个索引的方法,但没有像字符串的 indexOf 方法那样从特定索引开始搜索的方法。我们将把它写成辅助方法,从而得到一个可运行的程序。
总结 📚
本节课中我们一起学习了如何将基于字符的一阶马尔可夫模型扩展为基于单词的模型。我们理解了通过良好的接口设计(IMarkovModel)可以实现代码的复用,客户端程序无需修改。我们探讨了核心变更点:将训练文本从 String 改为 String[],相应地修改了 getFollows 等辅助方法的实现逻辑,并在生成文本时处理了单词间的空格问题。这体现了面向对象编程中抽象和封装的力量。
154:一阶马尔可夫词模型辅助函数

在本节课中,我们将要学习如何为一阶马尔可夫词模型(Markov Word 1)开发和测试代码。我们将重点关注如何修改和实现一个关键的辅助函数 getFollows,该函数用于在单词数组中查找特定“键”之后出现的所有单词。我们将通过复制并调整现有代码来完成这个任务,虽然这不是最佳实践,但在学习更高级的抽象方法之前,这是一个有效的步骤。
概述与目标

上一节我们介绍了基于字符的一阶马尔可夫模型。本节中我们来看看如何将其思想应用到单词上,构建一个基于单词的一阶马尔可夫模型。我们的核心任务是修改 getFollows 方法,使其能够处理字符串数组(单词)而非单个字符串(文本)。

初始代码与问题
首先,我们运行现有的 MarkovRunner 类来观察基于字符的模型输出。当我们尝试将其改为使用 MarkovWordOne 类并运行时,发现它只生成了一个随机单词,而不是我们期望的200个单词。
问题根源在于 MarkovWordOne 类中的 getFollows 方法尚未正确实现。该方法目前返回一个空的 ArrayList,因为它还没有被填充数据。
复制并修改 getFollows 方法
为了解决这个问题,我们将从 MarkovOne 类中复制 getFollows 方法的代码到 MarkovWordOne 类中。然后,我们需要进行几处关键修改,以适应从“字符”到“单词”的转变。
以下是需要修改的核心部分:
-
获取长度:在字符模型中,我们使用
myText.length()。在单词模型中,myText是一个字符串数组,因此我们应使用myText.length(没有括号)。// 字符模型 int len = myText.length(); // 单词模型 int len = myText.length; -
查找索引:在字符模型中,我们使用
myText.indexOf(key, pos)来查找子串。字符串数组没有内置的indexOf方法,因此我们需要自己编写一个辅助函数。// 字符模型 int index = myText.indexOf(key, pos); // 单词模型 int index = indexOf(myText, key, pos); // 调用自定义的辅助函数 -
获取后续字符/单词:在字符模型中,我们使用
myText.substring(start+1, start+2)来获取下一个字符。在单词模型中,我们只需要直接获取start+1位置的单词。// 字符模型 String next = myText.substring(start+1, start+2); // 单词模型 String next = myText[start+1];
实现自定义的 indexOf 辅助函数
由于字符串数组没有 indexOf 方法,我们需要自己实现一个。这个函数的作用是:在给定的字符串数组 words 中,从指定位置 start 开始,查找目标字符串 target 第一次出现的位置。
以下是该函数的实现逻辑:
private int indexOf(String[] words, String target, int start) {
for (int k = start; k < words.length; k++) {
if (words[k].equals(target)) {
return k;
}
}
return -1;
}

代码解释:
- 函数接收三个参数:要搜索的单词数组
words、要查找的目标单词target、以及开始搜索的位置start。 - 使用一个
for循环从start位置开始遍历数组。 - 在循环中,使用
equals方法(而不是==)来比较字符串是否相等。 - 如果找到目标单词,立即返回其索引
k。 - 如果循环结束仍未找到,则返回
-1,表示未找到。
测试修改后的代码
完成上述修改后,我们重新编译并运行 MarkovRunner 类。这次,程序成功生成了包含200个随机单词的文本。虽然文本内容可能没有实际意义(因为每个单词都是根据前一个单词随机选择的),但它证明了我们的 getFollows 方法和自定义的 indexOf 函数工作正常。
总结
本节课中我们一起学习了如何为一阶马尔可夫词模型实现核心的 getFollows 辅助函数。我们通过复制并修改基于字符的模型代码,将其适配到单词场景。关键步骤包括:
- 将文本视为字符串数组而非单个字符串。
- 将字符串的
length()方法调用改为数组的length属性访问。 - 用自定义的
indexOf函数替代字符串的indexOf方法,以在数组中查找单词。 - 直接通过数组索引获取下一个单词,而非使用
substring。
虽然通过复制代码来避免重复不是长期的最佳实践,但它帮助我们理解了模型从字符到单词转换的核心逻辑。在后续课程中,我们将学习使用抽象和继承来更优雅地处理这类代码复用问题。
155:WordGram类设计与实现 🧩
在本节课中,我们将学习如何设计和实现一个名为 WordGram 的类。这个类将使我们能够基于单词(而非单个字符)来创建马尔可夫随机文本和预测文本。我们将探讨从处理字符过渡到处理单词时面临的挑战,并理解如何构建一个能够表示字符串序列的类。
从字符到单词的挑战
上一节我们介绍了基于字符的马尔可夫模型。本节中我们来看看将其扩展到基于单词的模型时会遇到哪些问题。
在基于字符和字符串的版本中,从 MarkovOne 升级到 MarkovTwo,再到创建接口和抽象类,过程相对直接。例如,将模型从一阶(MarkovOne)改为二阶(MarkovTwo)只需将代码中三处的常量 1 改为 2。若要支持任意阶数 N,也只需进行类似的修改。代码中的 myOrder 变量用于获取正确长度的子串,而不仅仅是长度1或2,并且用于指示在开始循环前已经生成并存储的字母数量。
然而,辅助方法 getFollows 的代码在从一阶改为二阶,乃至任意阶时,完全不需要改动。
但是,对于使用单词生成随机预测文本的 MarkovWordOne 类,进行类似的扩展就不那么简单直接了。
字符串方法与单词方法的类比
当我们使用字符和字符串时,我们依赖 String.substring 方法来获取字符串的任何子序列。为了将 MarkovWordOne 扩展为能使用任意数量单词来预测新单词的类,我们需要为字符串数组找到一个类似 substring 的方法。
让我们先看看字符马尔可夫类中的 getFollows 方法,然后将其扩展到单词版本。
以下是字符和字符串版本马尔可夫类的 getFollows 方法核心思路:
- 变量
myText存储代表训练数据的字符串。 - 随机文本的“键”(
key)也是一个字符串。 - 我们使用这个键来查找所有后续字符,以预测随机文本并扩展我们返回的随机序列。
- 该方法依赖
String.indexOf和String.substring来完成工作。
代码示例(概念):
// 在训练文本 myText 中,从位置 pos 开始查找键 key
int index = myText.indexOf(key, pos);
// 获取 key 之后的一个字符作为后续字符
String follow = myText.substring(index + key.length(), index + key.length() + 1);
String.indexOf 返回从位置 pos 开始搜索时,key 首次出现的索引。String.substring 返回从特定位置开始的字符序列。
我们如何将这些思想从“字符内”扩展到“单词内”?
设计 WordGram 类
基于字符的代码使得从 Markov1 改为 Markov2 乃至任意阶数变得 straightforward。正如我们所说,字符串可以被视为字符序列,并且可以轻松地成为一、二或三个字符长。我们看到了字符串变量 key 以及使用字符序列的 indexOf 方法。
在从已经测试过的 MarkovWordOne 类过渡到一个能使用两个或 N 个单词生成文本的通用类时,我们需要仔细思考。我们需要从可以表示为单个字符串的单单词键,过渡到无法轻易表示为单个字符串的 N 单词键。
我们需要在创建 follows 辅助方法时,搜索 N 个单词,而不仅仅是一个。
因此,我们将设计并实现一个 单词序列类 来处理基于单词的二阶、三阶,甚至二十阶随机文本。它将是一个字符串序列,就像字符串是字符序列一样。它将代表存储在数组中的字符串序列。这类似于将字符串视为字符序列的马尔可夫类。
这个新类将被称为 WordGram。
深入理解 WordGram
让我们更仔细地看看这个想法。我们的 WordGram 类将表示一个字符串序列,而不是字符序列。
- 字符串可以是字母序列,例如 “P”, “L”, “A”, “N”, “E”, “T” 组成 “PLANET”,或者序列 “dinosaur” 组成字符串 “dinosaur”。
- 一个
WordGram对象将是一个字符串序列,例如序列 [“the”, “dinosaur”, “eats”, “plants”]。
设计这个类需要一些思考,因为字符串的构建块是基本类型 char,而 WordGram 的构建块是 String(不是基本类型)。在内部,WordGram 类将是一个字符串引用的数组,如下图所示。

后续步骤
现在我们已经理清了核心概念,接下来要做的事情就是思考如何设计这个类。我们将在下一个视频中详细探讨。


总结

本节课中我们一起学习了:
- 目标: 为了创建基于单词的马尔可夫模型,我们需要一个能表示固定长度单词序列的类,即
WordGram。 - 挑战: 与处理字符时可直接使用
String类的方法(如substring和indexOf)不同,处理单词序列需要我们自己定义类似的操作。 - 核心设计:
WordGram类的内部将使用一个 字符串数组 来存储单词序列。这相当于为单词世界创建了一个类似String的抽象,用于表示和操作连续的单词组。 - 意义: 成功实现
WordGram类后,我们就能构建支持任意阶数(N-gram)的、基于单词的马尔可夫文本生成器,使其预测能力更强,生成的文本更连贯。
156:WordGram类实现

在本节课中,我们将学习如何设计和实现一个名为WordGram的类。这个类将用于处理字符串序列,类似于字符序列的处理方式,但针对的是单词。我们将从设计思路开始,逐步讲解其状态、行为以及关键方法的实现。
概述:类的设计思路与用例
上一节我们介绍了从字符到单词的马尔可夫模型转换。本节中,我们来看看如何为单词序列设计一个专门的类。
作为类设计的一部分,通常需要考虑这个类将如何被使用。这通常被称为生成用例,既可以针对整个程序,也可以针对单个类进行。我们将思考WordGram对象在我们的马尔可夫程序中如何被使用。我们有一些经验可以借鉴。
以下是WordGram类的主要使用场景:
- 我们将从一个字符串数组中创建一个
WordGram对象。这类似于使用substring方法从字符中创建字符串。 - 我们需要向一个
WordGram的末尾添加一个新的字符串。这类似于在生成随机文本时,添加一个后续字符以形成新的键。
我们准备好总结WordGram的初步设计了。其状态将是一个字符串数组。我们将把它存储在一个实例变量中,就像在字符马尔可夫程序中myText是一个字符串一样。
核心行为设计

我们先看一些简单的行为。我们需要一个get方法来获取WordGram的长度,就像字符串有.length()方法一样。这被称为get方法,因为它只是获取一个值。
我们需要一个类似于字符串的.charAt()的方法,但用于获取特定索引处的单词。我们希望程序员能够像使用其他类一样使用WordGram。这意味着我们需要一个.toString()方法用于打印和其他用途,并且我们还需要一个.equals()方法用于查找后续词,这一点我们很快就会看到。
我们可能会考虑一个.compareTo()方法,并让WordGram类实现Comparable接口,这可能会使其更通用。但在我们分析WordGram将如何被使用时,我们没有涉及到排序。我们不会设计一个不会被使用的功能。我们可以在需要时再添加功能。
构造函数与状态初始化
现在,我们来设计WordGram的构造函数。构造函数用于初始化对象的状态。
以下是构造函数的代码示例:
public WordGram(String[] source, int start, int size) {
myWords = new String[size];
System.arraycopy(source, start, myWords, 0, size);
}
在这种情况下,状态是一个我们命名为myWords的字符串数组。该数组中的字符串是从作为参数传递给构造函数的源数组中复制而来的。要复制的字符串数量是构造函数的另一个参数,开始复制的起始索引也是。System.arraycopy方法将值从源数组复制到目标数组。在这里,目标就是实例变量myWords。
核心方法实现
我们将查看WordGram中简单行为的代码。这些方法是字符串方法的直接类比。
.wordAt方法返回指定索引处的字符串,就像.charAt返回字符串中指定索引处的字符一样。如果索引无效(过低或过高),该方法会抛出一个异常。在访问数组、ArrayList或字符串值时,你可能已经遇到过这样的异常。在这里,我们的代码像其他类一样抛出异常。如果你继续学习Java,你会学到更多关于异常的知识。目前,我们希望我们的类能像其他类一样运作。
单词的数量由.length方法返回,就像字符串类中字符的数量由.length方法返回一样。
我们还有两个方法,以确保WordGram能与其他类良好协作。就像我们的.wordAt方法在索引错误时像相关类一样抛出异常,我们需要这些方法,因为程序员期望它们。
我们将创建一个.toString方法,正如你在其他例子中看到的,这将有助于打印WordGram,无论是用于输出还是作为调试帮助。在getRandomText方法中,我们还需要.toString来将值追加到StringBuilder对象。
我们将创建一个.equals方法。这在确定一个WordGram对象何时等于另一个对象时非常重要,例如在编写getFollows辅助方法时。如果两个WordGram对象的长度相等,并且对应索引处的字符串相等,那么它们就是相等的。
我们需要使用.equals而不是==来检查两个字符串是否相等。

从字符到单词的代码转换

我们简要看一下从字符模型转换到单词模型后代码的变化。一旦WordGram类完成,你将需要自己并在后续课程中做更多工作。
在字符马尔可夫模型的getRandomText方法中,你需要生成随机文本,随机文本将是训练文本中出现的两个字符序列,如“T H”和“E”。这些是使用.substring方法获得的。在单词马尔可夫模型中,随机文本是像“how long”和“no such”这样的序列。这些序列是通过创建一个新的WordGram对象形成的,使用的参数与字符类中相同:myText、index和myOrder。
总结
本节课中我们一起学习了如何为单词序列设计和实现WordGram类。我们明确了其设计用例,定义了以字符串数组为核心的状态,并实现了包括构造函数、.wordAt、.length、.toString和.equals在内的关键方法。这个类封装了单词序列的操作,使得从基于字符的马尔可夫模型过渡到基于单词的模型变得更加清晰和模块化。
157:equals与hashCode方法


在本节课中,我们将学习如何为WordGram类开发几个关键方法,使其能在我们的马尔可夫程序中正常运作。我们将重点探讨equals和hashCode方法的实现原理与必要性,并简要介绍toString方法。这些方法是构建健壮Java类的基础。
我们将使用WordGramTester.java程序来演示技术、思路并测试方法。这些实现技巧不仅适用于WordGram类,也适用于任何其他Java类。


我们将展示如何测试toString方法和WordGram构造函数,尽管它们已经编写完成。

我们将探讨为什么需要equals方法。虽然我们在之前的课程中讨论过equals,但本节将简要讨论如何实现equals方法,而不仅仅是调用它。
equals方法对于MarkovWord的follows方法正确工作是必需的。这将足以让MarkovRunner类与MarkovWord一起工作并生成随机文本。
我们还将简要介绍一个高级方法。我们将看到如何实现hashCode方法,以便WordGram对象可以被添加到哈希映射中。
掌握了toString、equals和hashCode的知识,你将准备好应对大量的类和程序设计挑战。
实现 equals 方法

首先,让我们看看equals方法。在之前的程序中,你已经见过为什么需要使用equals来比较字符串。


使用双等号==进行比较是无效的,因为它测试的是两个对象是否是内存中的同一个对象,而不是它们是否包含相同的信息。
在其他程序中,你调用过equals方法。现在,我们来看看编写它需要什么。
我们必须遵守Java对编写equals方法的要求。
第一个要求是参数类型为Object。这是Java中每个类的基类或父类。如果你学习更高级的面向对象编程课程,将会探讨要求此类型的原因。
我们不会用WordGram以外的任何类型来调用equals,所以我们要做的第一件事是将参数o进行类型转换,以便编译器将其视为WordGram对象。
通过将类型放在括号中进行强制转换,可以使编译器将参数o引用的对象视为WordGram类型。我们使用名为other的变量来完成此操作,但可以使用任何名称。
然后,我们将当前对象的长度与other引用的WordGram对象的长度进行比较,如果长度不同则返回false。这是实现equals方法的第一步。
有了equals和toString方法,我们就可以编写马尔可夫词类了。但我们将提前了解一下你将在后续课程中学习的内容,以及作为对我们一直使用的马尔可夫类的一个增强。
哈希映射与 hashCode 方法

我们可以将所有用于生成文本的键存储在一个哈希映射中。我们将键映射到跟随该键的所有字符的列表。

这种技术适用于字母或单词马尔可夫模型。我们将用这个训练文本和一个双字母马尔可夫模型来展示。
训练文本以“he”开头,然后是“a”,并继续包含更多未显示的字母。其核心思想是避免为像“he”这样的键多次扫描文本。
在我们的模型中,我们找到“he”和一个跟随字符。然后找到“he”的下一次出现,再下一次出现。如果我们再次看到键“he”,就需要重新扫描文本以寻找跟随字符,重复我们之前已经做过的工作。这可能会在我们每次生成“he”作为键并需要找到跟随字符时发生。

与其反复扫描,不如在哈希映射中查找键并检索存储在列表中的跟随字符。这可能会更高效。但为了实现这一点,我们需要实现hashCode方法。
我们将提供哈希映射的高层概述,足以获得一些理解,但不会涉及太多细节。其核心思想是将对象转换为一个整数哈希码。
这个哈希码作为哈希映射中的索引。对于字符串,对象“Ho”可能被转换为哈希码3217。这个数字告诉我们在哈希映射中哪里可以找到“Ho”。

最简单的哈希码想法是让每个对象都有相同的索引,比如数字17。如果我们这样做,只要equals方法正确,我们的代码就能正确工作。但性能会非常差,因为每个对象都会存储在同一个“桶”中。“桶”是哈希映射中存储对象的位置。
理想情况下,每个对象都有一个不同的数字。这样,在桶中找到一个对象就很容易,因为它是桶中唯一的对象。
如果你学习另一门Java课程,比如紧随本课程之后的UCSD专项课程,你将看到这是如何工作的细节。
一个具有更好性能的简单想法是,将WordGram中每个字符串的哈希码简单地相加。


享受将对象哈希到许多不同桶中的乐趣,这样你的哈希操作就会很快。
总结
本节课中,我们一起学习了如何为WordGram类实现关键的equals和hashCode方法。我们了解了equals方法对于对象内容比较的必要性,以及hashCode方法对于在哈希映射等数据结构中高效存储和检索对象的重要性。掌握这些方法,是进行有效Java类设计和程序开发的基础。
158:equals方法实现


在本节课中,我们将学习如何以及为何需要编写自定义的 equals 方法。我们将通过一个具体的例子——比较 WordGram 对象——来理解当默认的 == 运算符和 Object 类的 equals 方法不能满足需求时,如何实现我们自己的逻辑来判断两个对象是否“相等”。
问题引入:默认比较的局限性
上一节我们介绍了 WordGram 类的基本结构。本节中我们来看看如何比较两个 WordGram 对象是否包含相同的单词序列。
我们有一个测试方法 testWordGramEquals。它从一个长字符串中创建多个长度为4的 WordGram 对象,并将它们存入一个 ArrayList。然后,它尝试找出列表中所有与第一个 WordGram 对象相等的其他对象。
以下是测试代码的核心逻辑:
WordGram first = list.get(0);
for (int k=0; k < list.size(); k++) {
if (first == list.get(k)) {
System.out.println("matched at " + k);
}
}
运行此代码,发现它只匹配了索引0处的对象(即 first 与自身比较)。即使索引4和8处的 WordGram 对象包含完全相同的单词序列(“this is a test”),也没有被匹配到。这是因为 == 运算符比较的是两个对象引用是否指向内存中的同一个对象,而不是它们的内容是否相同。
尝试使用默认的 equals 方法
接下来,我们尝试使用 Object 类提供的 equals 方法,将 if 条件改为:
if (first.equals(list.get(k))) {
System.out.println("matched at " + k);
}
再次运行测试,结果依然只匹配了索引0处的对象。这是因为 Object 类中的默认 equals 方法实现与 == 运算符的行为相同,它并不了解 WordGram 对象内部的数据结构。
实现自定义的 equals 方法
因此,我们需要在 WordGram 类中重写(override)equals 方法,定义我们自己的相等性逻辑。
首先,我们在 WordGram 类中添加方法签名。equals 方法必须接受一个 Object 类型的参数并返回一个 boolean 值。
public boolean equals(Object o) {
WordGram other = (WordGram) o; // 将传入的对象转换为 WordGram 类型
return true; // 临时返回 true 以测试方法是否被调用
}

编译并运行测试,此时程序输出了所有匹配项(因为始终返回 true),这证明我们自定义的 equals 方法已被成功调用。接下来,我们需要完善其内部逻辑。
一个合理的 WordGram 相等性判断应包含以下两个步骤:
- 检查两个
WordGram对象的长度是否相同。 - 如果长度相同,则逐个比较对应位置的单词是否完全相同。
以下是完善后的 equals 方法实现:
public boolean equals(Object o) {
// 1. 检查传入的对象是否是 WordGram 类型
if (! (o instanceof WordGram)) {
return false;
}
WordGram other = (WordGram) o;
// 2. 比较两个 WordGram 的长度
if (this.length() != other.length()) {
return false;
}
// 3. 逐个比较单词
for (int k=0; k < this.length(); k++) {
if (! this.wordAt(k).equals(other.wordAt(k))) {
return false; // 发现一个不匹配的单词,立即返回 false
}
}
// 4. 所有检查都通过,返回 true
return true;
}
代码解释:
instanceof运算符用于确保传入的对象o是WordGram类型或其子类的实例,这是进行安全类型转换的前提。- 首先比较长度,如果长度不同,则两个
WordGram肯定不相等。 - 使用一个
for循环遍历每个索引位置,并使用字符串的equals方法比较this对象和other对象在该位置的单词。 - 一旦发现任何位置上的单词不匹配,方法立即返回
false。 - 如果循环顺利完成,说明所有单词都匹配,方法返回
true。
测试与验证
现在,我们再次运行 testWordGramEquals 方法。这次,程序正确地找到了索引0、4和8处的三个相等的 WordGram 对象,并打印出相应的匹配信息。
总结
本节课中我们一起学习了如何为自定义类实现 equals 方法。我们了解到:
==运算符和Object类的默认equals方法比较的是对象引用(内存地址),而非对象内容。- 当需要根据对象的内部状态(如
WordGram中的单词序列)来判断相等性时,必须重写equals方法。 - 一个健壮的
equals方法实现通常包括:类型检查、关键属性(如长度)的比较,以及深层内容(如数组或集合中的元素)的逐一比较。
通过实现 WordGram 的 equals 方法,我们掌握了让对象支持“逻辑相等”比较的核心技能,这是构建复杂、可维护Java程序的重要基础。
Java编程和软件工程基础:2-5:基于单词的马尔可夫模型总结


在本节课中,我们将总结如何设计和实现 WordGram 类,以支持基于单词而非字母的随机文本生成。我们将回顾从一阶模型扩展到高阶模型的过程,并探讨在实现过程中遇到的关键概念和最佳实践。

我们刚刚完成了 WordGram 类的设计和实现,这使得我们能够基于单词而非字母来生成随机文本。
这种扩展了基于预测的、有时很有趣的文本生成技术,该技术是垃圾邮件检测、预测性文本输入和搜索引擎等应用的基础。

我们选择一次处理一个单词而非一个字母,是为了阐释新的概念。但得益于最初基于 IMarkovModel 接口的良好设计,我们的新类能够与现有的、经过测试的客户端程序协同工作。

我们首先开发了 MarkovWordOne。它使用一个单词来预测下一个单词。这是从基于字母的马尔可夫程序过渡到使用 WordGram 的一个简单步骤。
在开发包含新想法和设计变更的新程序时,采取小步快跑的策略通常是个好主意。在一个小步骤成功运行并通过测试后,再迈出扩展设计的下一步。在推进每一步时,务必在必要时使用算法设计的七步流程。
我们研究了 WordGram 类及其内部的行为表示。我们基于字符串(而非字符)设计和实现的 MarkovWordOne 是成功的,但为了将我们的一阶单词马尔可夫程序扩展到二阶、三阶或更高阶,我们必须设计和实现 WordGram 类。
然而,我们可以利用熟悉的设计、测试、程序和接口来开发 WordGram 类。




我们在马尔可夫文本生成的上下文之外测试了 WordGram,但我们也利用马尔可夫模型开发了用例,以帮助指导 WordGram 的设计。

我们首先测试了 toString 方法和构造函数,因为如果无法创建和打印一个对象,测试和调试将变得非常困难。
当需要实现 wordAt 和 length 方法时,它们与 String 类中类似方法的相似性提供了很大帮助。
我们还必须理解如何实现 equals 和 hashCode 方法。但在我们的代码中,只需要 equals 方法。hashCode 方法将在 WordGram 更高级的用法中需要。
在实现 WordGram 时,我们遇到了一些新想法。wordAt 方法在索引越界时会抛出一个异常。
你不能使用像 -1 或数组长度这样的值来索引数组或字符串。

我们让 WordGram 的行为与 String 类似,在出现错误索引时抛出一个异常,这可能在程序员犯错时提供帮助。

通常,在发布的软件中不会出现错误的索引,但在新软件的开发和测试过程中确实会发生。理解异常是成为一名软件工程师或程序员的一部分。

我们编写了一些我们自己没有调用的函数,其他方法可能会调用它们,就像打印对象时会调用 toString 方法一样。


正如我们所见,有时会显式调用 toString 方法,例如在向 StringBuilder 追加内容时。
当插入到哈希映射中时,hashCode 方法会提供一个我们称之为“桶”的索引,对象就存储在其中。equals 方法则有助于区分那些可能最终落入同一个桶中的对象。
在本节课中,我们一起学习了如何通过设计和实现 WordGram 类,将马尔可夫文本生成模型从基于字母扩展到基于单词。我们回顾了从一阶模型到高阶模型的渐进式开发过程,并探讨了关键方法的实现、测试策略以及异常处理的重要性。通过遵循小步快跑和良好接口设计的原则,我们能够构建出健壮且可扩展的代码。
160:Java杂项与后续步骤 🚀
在本节课中,我们将探讨Java编程中一些超出简化环境的核心概念,并了解如何从初学者环境过渡到更专业的开发流程。我们将学习main方法、高级编辑器、异常处理以及文件读取等主题,为后续的深入学习打下基础。

从简化环境到标准Java程序
到目前为止,你已经学习了许多关于编程的通用知识,特别是关于Java的知识。对于很多问题,你现在已经能够独立解决了。然而,为了帮助你顺利完成本课程,我们之前进行了一些简化设置,例如使用BlueJ编程环境和提供Edu.Duke类库。这些设置帮助你专注于需要学习的关键内容,而不会陷入Java语言的复杂性中。
现在,是时候超越这些简化设置,了解接下来的发展方向了。
main方法:Java程序的起点
首先,你将学习main方法,它标志着Java程序的开始,尤其是在不使用BlueJ集成开发环境时。
BlueJ非常适合初学者,但随着你成为一名更高级的程序员,你可能会考虑使用更高级的编辑器。我们将简要讨论其中一些编辑器。
main方法的典型结构如下:
public class MyProgram {
public static void main(String[] args) {
// 你的程序代码从这里开始执行
}
}
探索更高级的代码编辑器
上一节我们介绍了main方法作为程序的入口点。本节中,我们来看看除了BlueJ之外,还有哪些工具可以提升你的开发效率。
以下是几种常见的、功能更强大的代码编辑器或集成开发环境:
- IntelliJ IDEA:一款非常智能且功能全面的IDE,尤其适合Java开发。
- Eclipse:另一个流行的、开源的Java IDE,拥有庞大的插件生态系统。
- Visual Studio Code:一款轻量级但功能强大的源代码编辑器,通过安装插件可以完美支持Java。
异常处理:应对程序中的错误

在编程过程中,错误和意外情况是不可避免的。接下来,我们将学习更多关于异常的知识,了解为什么需要它们以及如何处理它们。

异常处理允许你优雅地管理运行时错误,防止程序意外崩溃。基本结构如下:
try {
// 可能抛出异常的代码
} catch (ExceptionType e) {
// 处理异常的代码
}
脱离Edu.Duke库读取文件
之前我们借助FileResource这样的类来简化文件操作。现在,我们将了解如何在不使用Edu.Duke库的情况下读取文件。
Java标准库提供了多种文件读取方式,例如使用Scanner类或Files工具类。以下是使用Scanner的一个简单示例:
import java.io.File;
import java.util.Scanner;
public class ReadFile {
public static void main(String[] args) {
try {
File file = new File("myfile.txt");
Scanner scanner = new Scanner(file);
while (scanner.hasNextLine()) {
String line = scanner.nextLine();
System.out.println(line);
}
scanner.close();
} catch (Exception e) {
System.out.println("读取文件时发生错误。");
}
}
}
课程总结与后续方向
最后,我们将以简要讨论你接下来可能要做的事情来结束本课程。
我们希望你对Java编程、通用编程以及计算机科学感到非常兴奋,并且非常渴望学习更多知识。
在本节课中,我们一起学习了如何从初学者的简化环境过渡到标准的Java开发流程。我们了解了main方法的核心作用,探索了更强大的开发工具,学习了如何使用try-catch块处理程序异常,并掌握了使用标准库读取文件的基本方法。这些知识将为你后续探索更广阔的Java世界和软件开发领域奠定坚实的基础。
161:main方法详解 🚀

在本节课中,我们将要学习Java程序独立运行的起点——main方法。我们将了解它的作用、标准语法结构,以及如何通过它接收外部输入。
在BlueJ这样的集成开发环境中,你可以通过点击菜单来创建对象和运行方法,无需指定程序从哪里开始。然而,当程序需要独立运行时,Java虚拟机需要一个明确的入口点来启动程序。


这个入口点就是一个名为main的特殊方法。当你运行程序时,需要指定包含此main方法的类。
main方法的签名 📝
main方法必须遵循一个特定的签名格式,其标准定义如下:

public static void main(String[] args)
接下来,我们将逐一解析这个签名中的每个关键字。

访问修饰符:public
你已经熟悉public关键字,它意味着类外部的代码可以调用此方法。main方法必须是public的,因为启动程序的代码(Java虚拟机)需要从外部调用它。

关键字:static


下一个词是static。它表示该方法不属于类的某个特定实例,而是属于类本身,整个类只有一份。你很快就会学到更多关于static的知识,现在只需知道main方法必须是static的。
返回类型:void
接下来是void,这是main方法的返回类型,表示该方法不返回任何值。
方法名:main


紧接着是方法名main。

参数:String[] args
参数域是一个字符串数组(String[] args)。你已经熟悉字符串和数组,这与其他接收字符串数组参数的方法工作原理相同。这个参数代表了传递给程序的命令行参数。
当你运行程序时,可以指定哪些字符串会被传入这个数组。



上一节我们介绍了main方法的标准签名,本节中我们来看看一个具体的例子。
一个main方法示例 🔍

让我们看一个使用main方法编写的程序示例。这个程序看起来熟悉吗?它是“Java编程:用软件解决问题”课程开头的“Hello Around the World”程序,但被重写为使用main方法。
public class HelloWorld {
public static void main(String[] args) {
// 程序主体:读取文件并打印每一行
// ... (文件读取和打印逻辑)
}
}

请注意,它完全符合我们在上一张幻灯片中描述的签名。

main方法的主体执行你想要的任何计算。在这个例子中,它读取文件Hello_Unicode.txt,并打印出文件中的每一行。

使用命令行参数 🛠️
如果你想利用main方法的命令行参数,以便在不重新编译代码的情况下改变程序读取的文件,你可以这样做。
以下是修改后的代码示例:


public class HelloWorld {
public static void main(String[] args) {
// 检查是否提供了参数
if (args.length < 1) {
System.out.println("错误:请提供文件名作为参数。");
System.exit(1); // 非零值表示失败
}
String fileName = args[0]; // 获取第一个参数
// 使用fileName变量读取文件...
}
}
代码使用args[0]来获取传入参数数组中的第一个字符串。现在,对数组进行索引操作应该很熟悉了。
当然,检查运行程序的人是否提供了正确数量的参数通常是一个好习惯。这样,如果他们没提供,你可以给出有用的错误信息,而不是让程序因“数组索引越界异常”而崩溃。
在这段代码中,我们使用了System.exit(),它告诉Java完全退出程序。这里我们这样做是因为如果不知道要处理什么输入,程序无法做任何事情。
System.exit()在适当的时候非常有用,但请仅在希望程序完全退出时使用它。它接收一个int型参数,表示程序运行的成功或失败。按照惯例,传入0表示成功,非零值表示失败。


本节课中我们一起学习了Java程序的入口——main方法。我们了解了其必须遵循的public static void main(String[] args)标准签名,每个部分的作用,以及如何通过args参数接收和处理命令行输入。现在,你已经知道如何编写main方法,让你的程序能够在BlueJ之外独立运行了。
162:static关键字详解 🧠


在本节课中,我们将要学习Java中一个重要的概念——static关键字。我们将探讨它如何应用于字段和方法,理解它与实例成员的区别,并通过一个银行账户的示例来掌握其核心用途。
静态的含义

上一节我们介绍了main方法,它是静态的。本节中我们来看看static具体意味着什么。


static意味着该方法属于类本身,而不属于类的任何特定实例。为了理解这一点,我们首先需要了解非静态字段,即每个类实例都拥有自己独立副本的字段。

非静态字段示例

以下是银行账户类中非静态字段的一个典型例子。

假设你正在编写银行软件,决定创建一个银行账户类,并声明账户余额和账户号码字段。

public class BankAccount {
private double balance;
private int accountNumber;
}
这些是银行账户类的理想字段,因为每个实例(即每个不同的银行账户)都拥有自己的账户号码和自己的余额。
如果你创建了这个类的三个实例来存储三个账户的数据,它们可能看起来像这样。你可以看到每个实例都有自己的账户号码和余额。这些字段不是静态的,它们存在于每个实例中。
引入静态字段的需求
现在假设在编写软件时,你想跟踪下一个要分配的账户号码。这样,当你创建一个新账户时,你就知道该给它什么号码。
为此创建一个实例变量(如下所示)效果并不好。
public class BankAccount {
private double balance;
private int accountNumber;
private int nextAccountNumber; // 这是一个错误的设计
}
声明这个字段会使每个银行账户都拥有自己的“下一个账户号码”,但这并不是你真正想要的。让我们通过一个你可能想写的构造函数来看看这个问题,该构造函数使用这个nextAccountNumber来初始化正在构建的银行账户的账户号码,然后递增该nextAccountNumber。
public BankAccount() {
this.accountNumber = this.nextAccountNumber;
this.nextAccountNumber++;
}
当你创建第一个银行账户对象时,所有字段的初始值都是0。然后执行构造函数中的代码。第一行将从这个对象的nextAccountNumber初始化该对象的账户号码,将其设置为0。下一行将这个对象的nextAccountNumber递增到1。到目前为止,这个行为是正常的,但当你创建第二个银行账户对象时,问题就出现了。
这个新创建的对象拥有每个字段的独立副本。现在,当你执行构造函数的第一行时,它使用该对象的nextAccountNumber来初始化这个对象的账户号码。然后你将对象内部的nextAccountNumber递增到1。结果,你最终得到了两个号码相同的账户,这会导致你的银行软件出现问题。实际上,你创建的每个账户的号码都将是0。
这里的问题是,“下一个账户号码”并不是每个不同银行账户的属性,而是所有银行账户共享的东西。
静态字段的解决方案
这正是static关键字的用途:当某个事物不属于某个特定类型的每个对象,而是由该类型的所有对象共享时使用。
在这里,你可以看到我们将nextAccountNumber声明为静态。
public class BankAccount {
private double balance;
private int accountNumber;
private static int nextAccountNumber = 0; // 静态字段
}
现在,nextAccountNumber数据不是存储在每个银行账户中,而是有一个副本被所有银行账户共享。
当你想从类外部引用静态字段或方法时,你可以在点号前加上类名,因为它不属于任何特定对象。对于这个字段,你可以写BankAccount.nextAccountNumber。

静态字段的特点

所以现在你对静态字段有了一些了解:对于静态字段,只有一个副本,而不是每个对象实例都有一个副本。

这些(静态字段)往往比实例字段少见得多。通常,你会希望用每个实例都拥有的属性来描述对象。

然而,有些时候使用static是合适的,因此了解它很有好处。

静态方法
你也可以声明静态方法。对于常规方法,你可以认为它们存在于每个实例内部。但对于静态方法,就像静态字段一样,你可以认为它们只有一个,由类的所有实例共享。

以下是静态方法的关键特性。


- 静态方法只能访问它们所属类的静态字段和其他静态方法。
- 然而,它们不能直接访问非静态字段或方法。
- 如果你想从静态方法访问非静态字段或方法,你需要指定要操作哪个对象实例的字段或方法,使用
对象.字段或对象.方法的语法。
总结
本节课中我们一起学习了Java中的static关键字。我们了解到,静态成员(字段和方法)属于类本身,而不是类的任何特定实例。静态字段在内存中只有一个共享副本,而静态方法只能操作静态数据或通过对象引用操作实例数据。通过银行账户的例子,我们看到了使用静态字段来管理所有账户共享信息(如下一个账户号码)的正确方式。理解static是掌握Java面向对象编程中类级与实例级概念区别的重要一步。
163:编辑器使用指南 🛠️

在本节课中,我们将探讨编程编辑器的选择与使用,并重点介绍集成开发环境Eclipse的基本功能。我们将了解不同编辑器之间的权衡,以及如何利用高级工具提升编程效率。

编辑器选择:从新手到专家
在本课程中,你一直使用BlueJ,因为它对学习编程的新手而言是一个优秀的工具。它操作简便,无需学习过多功能。然而,随着编程技能提升,你可能希望探索其他编辑器。
以下是选择编辑器时需要考虑的因素。
图形界面与键盘操作
一个重要考量是选择使用图形用户界面还是仅使用键盘操作。图形界面可能让你感觉更熟悉舒适。仅使用键盘操作起初可能令人畏惧,但它允许你通过肌肉记忆工作。当你能通过肌肉记忆进行编辑时,编辑过程不会打断你对算法设计和实现的思考,从而显著提高效率。
编辑器谱系
考虑到这些因素,编辑器谱系广泛,从对新手友好到对专家友好的都有。新手友好型编辑器如BlueJ基于图形界面,注重简洁而非强大功能。沿着谱系向下,你会发现提供更多高级功能的编辑器。一个流行的编辑器是Eclipse。还有两种非常常见的、专为专家设计的编辑器:Emacs和Vim。使用这些编辑器的程序员主要使用键盘,并享受它们提供的高级功能。然而,它们的功能强大也伴随着学习曲线的代价。😊
Eclipse是Drew在课堂上教学生使用的工具。
为何学习专家级工具?
既然专家友好型工具,无论是编辑器还是其他工具,都更难学习,为何还要学习它们?学习它们是一项长期投资。观察使用工具所能完成的工作与学习它所付出的努力之间的关系:对于新手友好型工具,你可以直接上手使用并完成相当多的工作。随着使用深入,你能做更多,但能力很快会达到平台期。对于专家友好型工具,开始时使用更困难,在投入精力学习之前可能无法用它做太多事情。然而,当你投入精力掌握工具后,你将超越使用新手工具所能达到的水平,并从高级功能的强大中受益。
打个比方,考虑录制视频。对大多数人来说,新手友好型工具很棒。如果我要录制视频,我拿出手机按下录制按钮。这非常简单,我无需学习任何东西。😊 但专业摄像师使用更复杂的工具。高端专业摄像机有一系列复杂功能,如果不投入时间学习,我就不知道如何使用。如果想使用任何高级功能,则需要付出更多努力。我可以坚持用手机,因为我不需要那些功能,而且我不是该领域的专业人士。然而,对于专业人士来说,学习使用高级工具是一项值得的投资。
同样的原则适用于编辑器。如果你计划成为一名休闲程序员,只编写小程序,一个简单的编辑器可能是不错的选择。然而,如果你计划成为一名严肃的专业程序员,你将希望投入精力学习一个编辑器,其高级功能从长远来看将使你的工作更轻松。
Eclipse集成开发环境入门 👨💻
接下来,我将演示如何使用Eclipse,这是一个集成开发环境,是许多Java开发者的首选IDE。我在开发本课程大部分材料时都使用它(当不使用BlueJ时),并且在我们专项课程之后的UCSD专项课程也要求学习者使用Eclipse。如你所知,还有其他一些IDE,但由于我最熟悉Eclipse,我将演示它的一些功能,让你了解它的特点。
我将使用之前用过的MarkovRunner类,并且我已经创建了Markov0、Markov1和Markov2。我们在之前的课程中已经讲解过这些。我将使用Eclipse来创建Markov3,这是一个我们尚未完成的类。在之前的课程中,我们直接从Markov2跳到了通用马尔可夫模型。
创建新类并实现接口
在Eclipse中,我像在BlueJ中一样操作,声明我需要一个新的Java类。我得到一个菜单,我将把这个类命名为Markov3。Eclipse的一个有趣之处在于,我可以声明要添加一个接口,并且我想添加IMarkovModel接口。我点击确定。


完成后,现在我有了我的Markov3类,你可以看到Eclipse已经为我需要实现的、属于IMarkovModel接口的所有方法填充了存根。提醒一下,这是IMarkovModel接口。它有两个我必须添加的方法:setTraining和getRandomText。我们之前见过这个。Eclipse在Markov3中为这些方法提供了存根实现。它甚至有一个返回值,虽然这个返回值不正确。它在这里也实现了setTraining方法。
代码辅助与错误提示
Eclipse在这里展示的优点是,对于接口,Eclipse会填充存根。然后我可以将Markov2的代码复制到这里并确保它能运行。我现在不打算完全复制,只是想让你看到对于接口,这是一个很好的功能。如果我开始复制代码,我会稍微复制一点,让你看看会发生什么。这是getRandomText函数。😊 我只将前几行复制到我的MarkovThree类中。
如果我忘记了,例如,在这个例子中,MarkovTwo将被替换为MarkovThree(我们在之前的课程中讲解过)。sb.append,当我输入sb.时,我会看到一个弹出菜单,显示该方法的选项,包括append。现在,BlueJ也有相同的功能,如果你按Ctrl+空格或右键点击空格的话。在这个例子中,我只是要确保我正确调用了append并放入current。
BlueJ也有这个功能。假设我忘记了return语句,会出现一些红叉,Eclipse会报错。在这个例子中,它告诉我它不知道我的random在哪里,也不知道我的myText是什么(通过这些小红叉显示)。我进行弹出操作,它可以说“创建局部变量”或“创建字段”。所以我将创建一个字段myText,Eclipse据此设置了myText变量,知道它在这里是如何使用的。我可以在这里为myRandom做同样的事情。我将添加字段myRandom。它添加在上面。它认为它是一个对象。它无法知道它是Random类型。我将把类型改为Random。我又得到一个红叉。
自动导入与错误修正

从java.util导入Random类。Eclipse现在知道这个类在哪里。所以它为我填充了所有这些内容。我快完成了,但最后这里还有一个红叉,因为正如它所说(如果你仔细看):“添加返回语句或将返回类型更改为void”。我缺少返回语句。所以Eclipse做了一个猜测,说也许我应该返回current。这不正确,但足以让我的程序编译。正如你所发现的,一旦程序编译完成,我就可以运行它,这意味着我可以测试它并返回进行修正。
重构功能
Eclipse还有一个有趣的功能。我可以说我不喜欢变量名myText。我想右键点击“重构”,然后重命名这个变量。我认为myText不太合适,我想称它为myTrainingText。😊

所以当我在那里进行更改时,Eclipse已经遍历并找到了所有myText的出现位置,你可以看到它们被高亮显示并在那里被更改。Eclipse中的重构有很多强大的功能,我们在这里不深入探讨。但如果你继续学习更高级的面向对象Java编程,你会看到它们。
总结 🎯

本节课中,我们一起学习了编程编辑器的选择策略,从新手友好的BlueJ到功能强大的Eclipse。我们探讨了图形界面与键盘操作的权衡,理解了学习专家级工具是一项值得的长期投资。通过实际操作,我们体验了Eclipse在创建类、实现接口、代码辅助、错误提示和重构方面的核心功能,这些功能能显著提升编程效率。记住,选择合适的工具并熟练掌握它,是成为高效程序员的重要一步。
祝你编程愉快!
164:Java入门知识总结 🎯

在本节课中,我们将总结在BlueJ环境之外需要掌握的Java编程基础知识。我们将回顾程序的入口点、static关键字的含义,并探讨不同代码编辑器的选择。
程序入口点:main方法
上一节我们介绍了Java程序的基本结构,本节中我们来看看所有Java程序的起点——main方法。
Java程序的执行始于main方法,其标准声明格式如下:
public static void main(String[] args)
以下是关于main方法声明的关键点:
public:表示该方法可以被公开访问。static:表示该方法属于类本身,而非类的某个特定实例。void:表示该方法不返回任何值。String[] args:这是一个字符串数组参数,可用于接收命令行传入的参数。
理解static关键字
了解了程序如何启动后,我们深入探讨一下main方法声明中出现的static关键字。
static关键字表示一个方法或变量属于整个类,而不是属于该类的每个具体对象实例。这意味着无需创建类的对象,就可以直接通过类名来访问static成员。
代码编辑器的选择
掌握了Java的核心语法概念后,选择合适的工具来编写代码同样重要。本节我们来看看BlueJ之外的代码编辑器。
市面上存在多种代码编辑器,其功能范围广泛,从适合新手的友好型编辑器到为专家设计的高效型编辑器。

以下是选择编辑器时需要考虑的因素:
- 你未来的编程计划是什么。
- 投入时间学习一款高效的专业工具能带来多少收益。
- 你对使用简单易上手的新手工具的偏好程度。

选择哪款编辑器取决于你的具体需求,需要在学习成本与易用性之间找到平衡。


本节课中我们一起学习了Java程序的基础知识:程序从固定的main方法开始执行;static关键字定义了属于类本身的成员;并且了解了根据自身技能水平和未来规划选择合适的代码编辑器的重要性。
Java编程和软件工程基础:4.5.1:Java IO库简介 🚀

在本节课中,我们将学习如何在不依赖Edu Duke工具包的情况下,直接使用Java的IO库来读取文件。我们将介绍两个核心新概念:异常处理和NIO库。
到目前为止,你一直在使用Edu Duke工具包来简化各种任务。
我们提供了一些类,例如FileResource和URLResource。这些类使你能够轻松地遍历文件或网站的内容。此外,还有一些用于处理图像和范围的类。
然而,在某些没有Edu Duke工具包的环境中,你可能需要读取文件。一种选择是直接下载并使用这个工具包。它是开源的,因此你可以在任何项目中自由使用。
但是,如果无法使用该工具包,你可以直接使用Java的IO库。使用Java的IO库需要一些我们尚未涉及的新概念。之所以等到现在才介绍这些主题,是因为你现在已经具备了理解它们所需的Java技能。这也是我们在课程初期为你提供Edu Duke库的原因。
这些新概念中的第一个是异常。你可能已经见过一些异常情况。也许在某些时候,我们的程序因此崩溃过,但我们尚未深入探讨它们。

另一个概念是NIO库。实际上,有两种不同的方式可以进入Java的IO库。我们将重点介绍NIO库。
顺便提一下,如果你想知道如何弹出那个文件选择对话框(就像你在无参数调用new FileResource()时看到的那样),那要复杂得多,我们在此不深入讨论。当然,由于它是开源的,你可以自行查看代码。
本节课中,我们一起学习了直接使用Java IO库的必要性及其两个核心前提:异常处理和NIO库。这为你处理文件操作提供了更底层和灵活的方法。
166:理解异常

在本节课中,我们将要学习Java中的异常。异常是程序运行时出现问题的信号。我们将了解什么是异常、为什么程序会因此崩溃,以及如何在程序中主动使用异常来处理错误情况。
什么是异常?
上一节我们介绍了课程目标,本节中我们来看看异常的基本概念。
异常意味着程序无法完成它尝试执行的某项操作。有时这表明代码中存在错误,例如访问字符串或数组的无效索引。然而,有时异常表示程序员无法控制的情况,例如用户要求程序打开一个不存在的文件,或者程序在下载信息时网络连接中断。实际的程序必须处理这些有问题的状况,而异常实际上是处理此类问题的一种较好方式。
栈追踪
当程序遇到一个未处理的异常时,程序会崩溃并打印一个栈追踪信息。这个信息你可能之前见过类似的形式。
以下是栈追踪信息的组成部分:
- 异常类型:首先,信息会告诉你发生了哪种类型的异常,即程序遇到了什么问题。例如,
StringIndexOutOfBoundsException表示字符串索引越界。 - 详细信息:接着,信息会提供关于问题的更多细节。例如,它可能告诉你尝试访问的无效索引是哪个。
- 调用栈:最后,也是最重要的部分,是栈追踪本身。它列出了在问题发生时,已被调用但尚未返回的方法列表。
栈追踪从问题发生的最内层方法开始,一直回溯到程序的入口点(通常是 main 方法)。你通常应该从调用栈中第一个出现在你自己代码中的方法开始查找问题。
处理异常:try, catch, finally
你的程序并非只能崩溃。它可以捕获并处理异常,指定如何处理问题。
处理异常涉及三个新的核心概念:try、catch 和 finally。
以下是它们的基本用法:
try {
// 尝试执行可能抛出异常的代码
riskyOperation();
} catch (SpecificExceptionType e) {
// 如果捕获到 SpecificExceptionType 类型的异常,则执行这里的代码
System.out.println("处理异常: " + e.getMessage());
} finally {
// 无论是否发生异常,finally 块中的代码都会执行
cleanupResources();
}
异常传播
如果一个方法不知道如何处理某个异常,它会将异常传播给它的调用者。调用者可以处理这个异常,或者继续传播给它的调用者,依此类推。
你不需要做任何显式操作来传播异常,这是基于异常的错误处理机制的一个关键优点。异常会自动沿调用栈向上传递。
声明异常


但是,你有时需要在方法签名中声明该方法可能抛出某种异常。这使用 throws 关键字。

public void readFile(String filename) throws IOException {
// 方法内部可能抛出 IOException
// ...
}
抛出异常
最后,当你的代码发现出现了问题状况时,你可以主动抛出自己的异常。这使用 throw 关键字。


if (input < 0) {
throw new IllegalArgumentException("输入不能为负数");
}

总结
本节课中我们一起学习了Java异常处理的核心知识。我们了解了异常是程序运行时错误的信号,认识了栈追踪的组成和作用。我们学习了如何使用 try-catch-finally 结构来捕获和处理异常,理解了异常会自动在调用栈中传播的机制。我们还知道了如何通过 throws 声明方法可能抛出的异常,以及如何使用 throw 关键字主动抛出异常。掌握这些概念对于编写健壮、可维护的Java程序至关重要。
Java编程和软件工程基础:2-5:异常处理 🛡️

在本节课中,我们将要学习Java中的异常处理机制。异常处理是编写健壮程序的关键,它允许程序在遇到错误时优雅地恢复,而不是直接崩溃。我们将通过一个具体的例子来理解如何使用try、catch和finally块。
假设你有一段代码,它可能会遇到问题。例如,如果你使用Java内置的URL类来处理URL,其构造函数可能会抛出一个MalformedURLException异常。如果你传入一个无效的URL字符串,就会发生这种情况。

也许这个字符串是从用户那里读取的,你并不希望因为用户输入错误而导致程序崩溃。那么,你该如何处理这个异常呢?
使用 Try-Catch 块

处理异常的第一步,是将可能抛出异常的代码放入一个try块中。

一个try块以关键字try开始,然后将可能有问题的代码用花括号包裹起来。

紧接着try块,你需要编写一个catch块。catch块声明了你想要处理的异常类型(在本例中是MalformedURLException),并包含了当问题发生时要执行的代码。
你为异常命名,就像声明一个参数一样,这让你可以访问异常对象。你可以调用它的方法,如果需要从中获取更多信息。
catch块中的代码可以做任何你想做的事情,例如,告诉用户他们的URL无效,并要求他们重新输入。
当字符串是一个有效的URL时,不会抛出任何异常。在这种情况下,Java会正常执行try块内的代码。当执行到try块的末尾时,它会跳过catch块,并继续执行其后的代码。
然而,如果字符串不是一个有效的URL,那么在这个构造函数内部的某个地方,将会抛出一个MalformedURLException异常。Java随后会进入catch块,并开始执行你在那里编写的用于处理错误的代码。
执行完catch块后,Java会继续执行紧随其后的代码。
使用 Finally 块
你还可以使用一个finally块。无论是否抛出了异常,finally块中的代码总是会被执行。
finally块通常用于清理已分配且需要释放的资源,无论发生了什么情况。Java 7还引入了try-with-resources结构来简化某些类型资源的释放,不过我们在这里不深入讨论。
代码示例
以下是上述概念的一个简单代码示例:
import java.net.MalformedURLException;
import java.net.URL;
public class ExceptionHandlingExample {
public static void main(String[] args) {
String userInput = "https://www.example.com"; // 可以尝试改为无效URL,如 “htp://”
try {
// 可能抛出 MalformedURLException 的代码
URL url = new URL(userInput);
System.out.println("URL created successfully: " + url);
} catch (MalformedURLException e) {
// 处理异常的代码
System.out.println("The URL you entered is not valid: " + userInput);
System.out.println("Error message: " + e.getMessage());
// 可以在这里提示用户重新输入
} finally {
// 无论是否发生异常都会执行的代码
System.out.println("This block always executes.");
}
System.out.println("Program continues after try-catch-finally.");
}
}
总结
本节课中,我们一起学习了Java异常处理的基础知识。我们了解到,通过使用try块包裹可能出错的代码,并用catch块来捕获和处理特定的异常,可以防止程序因意外错误而终止。此外,finally块确保了无论是否发生异常,某些清理代码都能得到执行。掌握这些概念是构建稳定、可靠应用程序的重要一步。
168:声明异常


在本节中,我们将学习当代码中可能发生异常,但当前方法不知如何处理时,应如何声明异常,以便让调用者来处理。
概述
编写代码时,有时会遇到可能发生异常的情况,但当前方法并不适合处理这个异常。本节将介绍如何通过声明异常,将处理责任传递给方法的调用者。
异常传播机制
上一节我们介绍了如何处理异常。但在某些情况下,你编写的代码可能会发生异常,而你却不知道该如何在当前方法中处理它。
这可能是因为你正在编写一个会被多种不同方式使用的方法,而如何处理异常取决于它具体被如何使用。或者,可能是因为处理异常所需的信息不在当前方法中。
无论是哪种情况,你都需要让调用此方法的方法来处理问题。或者,可能需要由调用者的调用者,甚至更上层的方法来处理。实际上,如果你回想一下URL构造函数会抛出异常的例子,这正是因为它检测到了问题,但自身并不知道如何处理。
这正是异常机制的核心价值所在。
幸运的是,当你没有处理一个异常时,它会自动传播给调用者。
声明受检异常
然而,对于某些特定类型的异常,即“受检异常”,你需要明确声明该方法可能会抛出它们。
你可以在参数列表之后、花括号之前,通过书写 throws 关键字以及可能抛出的异常类型名称来实现这一点,如下所示。
public void myMethod() throws IOException, MyCustomException {
// 方法体
}
我们不会深入探讨哪些异常是受检异常的细节,但如果你有兴趣,可以在Java官方文档中阅读更多相关信息。
总结
本节课中我们一起学习了异常声明。我们了解到,当方法内部发生异常但不知如何应对时,可以通过 throws 关键字声明可能抛出的受检异常,将处理责任向上传递给方法的调用链。这体现了Java异常处理机制的灵活性,允许在最适合的上下文环境中处理错误。
169:抛出异常

在本节课中,我们将要学习如何在Java代码中主动抛出异常。当程序遇到无法自行处理的问题时,抛出异常是一种重要的错误处理机制。
🚨 何时抛出异常
上一节我们介绍了异常的基本概念,本节中我们来看看如何主动抛出异常。你可以在自己的代码中,每当遇到无法处理的问题时抛出异常。
你在编写马尔可夫链代码时已经见过这个原则。以下代码片段来自该课程,用于从单词语法类中获取特定单词。
public String getWordAtIndex(int index) {
if (index < 0 || index >= words.size()) {
throw new IllegalArgumentException("索引无效: " + index);
}
return words.get(index);
}
如你所见,当请求的索引无效时,它会抛出一个异常。代码遇到了一个它无法处理的问题。除非调用此方法的代码捕获了这个异常,否则程序将会崩溃。
📝 抛出异常的语法
以下是抛出异常的基本语法。其核心是使用 throw 关键字,后跟一个表达式,该表达式的结果是你想要抛出的异常对象。
throw new ExceptionType("错误信息");

正如这里的例子所示,这个表达式通常会创建一个所需异常类型的新对象,并将任何有用的信息传递给构造函数。
🧱 可以抛出什么?
从技术上讲,你可以抛出任何继承自Java内置 Throwable 类的对象。然而,更常见的做法是抛出一个继承自 Exception 类的对象,而 Exception 类本身也继承自 Throwable。
Java拥有大量内置的异常类型。虽然可能没有570亿个,但确实有很多。其中一些你可能很熟悉,例如:
ArrayIndexOutOfBoundsException:数组索引越界。NullPointerException:尝试访问空对象的成员。IOException:指示读取或写入数据时出现问题,这也是理解如何自行读取文件时异常处理很重要的原因。
大多数情况下,内置的异常类型足以满足你的需求。你可以在Java API文档中了解更多关于它们的信息。
🛠️ 自定义异常
如果你编写的程序需要超出内置范围的异常,你随时可以创建自己的异常类。你可以通过编写自己的类并让它继承现有的、合适的异常类型来实现。
我们不会深入探讨创建自定义异常的主题,但作为异常讨论的一部分,我们想提及这一点,以防你在未来的编程工作中需要它。
📚 总结

本节课中我们一起学习了如何主动抛出异常。我们了解到,当代码遇到无法处理的问题时,可以使用 throw 关键字抛出异常。我们回顾了抛出异常的语法,探讨了可以抛出的异常类型(主要是内置异常),并简要提及了创建自定义异常的可能性。掌握抛出异常是构建健壮、可维护Java程序的关键技能。
170:使用Java NIO读取文件 📖



在本节课中,我们将解释为何在课程初期隐藏了关于Java如何处理文件和URL的细节,并展示如何使用标准的Java库来编写程序。我们将通过对比简化库与标准库的代码,来理解底层机制的复杂性及其设计目的。


概述


理解文件和URL在编程中的使用细节,很容易妨碍解决更高层次的问题。因此,我们提供了edduu.duke库和FileResource类来帮助你更快地通过编程解决实际问题。


所有语言和库都会将程序员与可能妨碍问题解决的复杂细节隔离开来。我们提供的库为Java初学者提供了很好的隔离和保护,但即使是常规的Java类也提供了这种隔离和保护。


我们的目标是使你能够解决问题,创造性地思考如何编写程序,从而将编程和问题解决结合起来学习。现在,我们将展示如何在不使用FileResource或URLResource类的情况下编写“Hello World”程序。了解如何做到这一点,可以帮助你实现自己的类,以隔离编程中遇到的常见问题。
从简化库到标准库
让我们回顾一下最初用于打印多种语言“Hello World”的程序之一。
以下代码使用了edduu.duke库中的FileResource类和runHello方法。
// 示例:使用简化库
FileResource fr = new FileResource("hello.txt");
for (String line : fr.lines()) {
System.out.println(line);
}



此方法通过从包含多种语言文本的文件中读取并打印单词,来输出不同口语和书面语中的“Hello World”。代码首先创建一个FileResource对象来引用和读取包含要打印单词的文件。然后,该方法使用一个for-each循环遍历文件中的每一行,在循环体中读取并打印字符串。

现在,让我们看看仅使用标准Java库实现相同功能的代码。此代码使用了三个包中的七个类。两个程序都使用了System类。
// 示例:使用标准Java库
import java.nio.file.*;
import java.io.*;
public class ReadFileStandard {
public static void main(String[] args) throws IOException {
Path p = Paths.get("hello.txt");
try (BufferedReader reader = Files.newBufferedReader(p)) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
}

实现方式有很多种,但我们展示的这种将突出这些额外类为减少重复代码而提供的一些灵活性。
核心概念与步骤解析
上一节我们对比了两种实现,本节中我们来详细看看标准Java库实现中的核心步骤和概念。
第一步是创建一个Path对象。 在代码中,我们将其命名为p。它表示文件系统中文件的路径。这里我们使用了Paths.get()方法。注意Paths中额外的s。路径的概念对于文件或URL是相同的,都是从文件夹到文件夹,最终指向实际文件系统中的文件。
在Java中读取文件的一种标准方法是使用java.io包中的BufferedReader。 Paths和Path类则在另一个不同的包中,我们很快就会看到。要创建一个BufferedReader,我们首先请求Files对象(再次注意Files中的s)为我们创建一个在程序中使用的读取器。
我们使用一个循环和reader.readLine()方法来读取打开的文件的每一行,直到readLine返回null引用。这意味着文件已完全读取,循环退出。这段代码和使用我们的FileResource对象的代码都使用了循环,但循环方式不同。



Java I/O 包简介


在Java中用于读取文件的两个主要包是java.io和java.nio。NIO中的N代表“New”,尽管这个包实际上已经存在一段时间了,但它比java.io包要新。
I代表输入(读取),O代表输出(写入)。换句话说,这些是输入输出包。




Paths、Files和Path类位于java.nio包中。这些类(Files和Paths)使用静态方法,这是你最近学到的知识。你知道这些方法不属于类的特定实例,而是属于类整体。因此,你不需要创建新对象来使用它们。
许多用于读写的方法,以及一些构造函数,都可能抛出异常。既然你已经学习了异常的基础知识,你就知道应该使用try-catch块来处理这些异常,并且如果你不处理它们,则必须在方法声明中添加throws相应的异常类型。



java.io包提供了其他用于读写的类。BufferedReader类在读取源时经常使用,它对读取的内容进行缓冲以获得良好的性能。java.io.IOException类被许多输入输出方法抛出。


使用这些类需要阅读文档并查看示例,因为读取文件、原始字节或对象或解析信息的方法有很多。我们创建FileResource类就是为了让这些事情变得更简单。
读取URL的示例
让我们再看一个例子:如何在不使用URLResource的情况下,使用java.net包读取URL。

使用这个包读取URL与我们Eduu.duke库中的URLResource类的工作方式类似。首先,通过提供一个代表网站的字符串来创建一个URL对象。
然后,你将使用这个URL对象从java.io类创建一个BufferedReader,但这需要几个步骤。第一步是打开到相关URL的连接,并获取一个代表其数据的流。然后使用这个流创建一个InputStreamReader,再使用InputStreamReader创建一个BufferedReader。这是三个步骤,取代了我们创建URLResource对象时的一个步骤。
这些额外步骤的目的是提供不同的方式来创建实现Reader接口的对象,以便你可以使用相同的代码从文件或URL读取数据。因此,你可以使用与上一个示例中相同的BufferedReader循环进行读取。这种重用在我们简化的资源类中更难实现,这也是花时间学习这些复杂类有用的原因之一。
总结
本节课中,我们一起学习了为何在入门阶段使用简化库,以及如何转向使用标准的Java java.nio和java.io包进行文件操作。我们了解了Path、Files、BufferedReader等核心类的用法,并对比了简化库与标准库在代码复杂性和灵活性上的差异。理解这些底层机制有助于你编写更强大、可复用的代码,并为处理更复杂的I/O场景做好准备。
171:Eclipse环境下的HelloWorld 🌍

在本节课中,我们将学习如何在Eclipse集成开发环境中编写并运行一个“Hello World”程序。这个程序会读取一个包含多种语言问候语的文件,并将它们打印出来。我们将重点了解Eclipse如何帮助我们管理代码、处理错误以及运行程序。
上一节我们介绍了课程目标,本节中我们来看看具体的代码实现过程。首先,我们有一段代码,其功能是打开一个名为 hello_unicode.txt 的文件,使用 BufferedReader 逐行读取内容,并打印出“Hello World”的各种语言版本。
Eclipse通过红色“X”标记提示代码存在问题。第一个问题是它不知道 Path 类。Eclipse可以自动建议导入正确的包。
以下是解决 Path 类问题的步骤:
- 将光标置于
Path上。 - 使用Eclipse的快速修复功能(通常是
Ctrl+1)。 - 选择导入
java.nio.file.Path。
导入 Path 后,仍然存在错误,因为代码中使用了 Paths 类。同样地,我们需要导入它。
以下是解决 Paths 类问题的步骤:
- 将光标置于
Paths上。 - 使用快速修复功能。
- 选择导入
java.nio.file.Paths。


接下来,Eclipse提示不认识 BufferedReader 和 Files 类。我们需要导入它们。

以下是导入 BufferedReader 和 Files 的步骤:
- 分别将光标置于
BufferedReader和Files上。 - 使用快速修复功能。
- 选择导入
java.io.BufferedReader和java.nio.file.Files。
现在,代码中还有一个关于“未处理异常”的错误。在Java中,执行文件操作可能抛出 IOException,代码必须处理它。
Eclipse提供了两种处理方式:
- 添加
throws声明:将异常抛给方法的调用者处理。 - 使用
try-catch块:在方法内部捕获并处理异常。

目前,我们选择添加 throws 声明。Eclipse会自动在方法签名后添加 throws IOException。至此,从文件读取并打印的方法就完成了。

上一节我们完成了从本地文件读取的方法,本节中我们来看看如何从网络URL读取数据。代码中已有一个从URL读取的方法,但被注释掉了。我们将其取消注释。
取消注释后,Eclipse会标记出新的错误,主要是缺少必要的类导入。
以下是解决URL相关导入问题的步骤:
- 将光标置于
URL上,使用快速修复导入java.net.URL。 - 将光标置于
InputStreamReader上,使用快速修复导入java.io.InputStreamReader。
代码现在提示有两个未处理的异常:MalformedURLException 和 IOException。我们可以分别处理,但注意到 MalformedURLException 是 IOException 的子类。
因此,我们只需在方法声明处抛出更通用的 IOException,Eclipse会自动替换掉 MalformedURLException。这样,从URL读取的方法也完成了。
最后,我们需要在程序的入口 main 方法中调用我们编写的方法。我们将调用 readAndPrintURL() 方法来从网络获取数据。
点击Eclipse的“运行”按钮执行程序。结果会显示在控制台窗口中,打印出从指定URL读取到的各种语言的“Hello World”问候语。

本节课中我们一起学习了在Eclipse中编写、调试和运行一个Java程序的全过程。我们实践了如何使用Eclipse的自动导入和错误修复功能,如何处理文件I/O操作可能抛出的异常,以及如何让程序分别从本地文件和网络URL读取数据。掌握这些基础操作是进行更复杂Java编程的重要第一步。
Java编程和软件工程基础:2-5:异常处理与数据访问总结 🎯
在本节课中,我们将总结关于Java异常处理以及数据访问的核心知识。你将回顾如何抛出和处理异常,并了解如何通过Java的I/O包来访问数据。
上一节我们介绍了异常处理的基本概念,本节中我们来总结一下关键点。
你已经学习了关于异常的知识。当程序出现问题时,你可以抛出它们。你可以使用 try 和 catch 来处理那些可能出错的代码中的异常。或者,如果你的代码不知道如何处理某种情况,你可以声明你的方法会抛出可能发生的特定类型的异常。
以下是关于异常处理的关键操作:
- 抛出异常:使用
throw关键字。throw new IllegalArgumentException("输入参数无效"); - 捕获异常:使用
try-catch块。try { // 可能出错的代码 } catch (ExceptionType e) { // 处理异常 } - 声明异常:在方法签名中使用
throws关键字。public void readFile() throws IOException { // 方法体 }
同时,你也学习了如何访问数据。你可以使用 java.nio 包或 java.io 包,或者混合使用两者。这些包包含许多类,因此在你熟悉它们的过程中,经常需要查阅相关文档。
当然,在尝试访问数据时,经常可能出现问题,例如文件可能丢失,或者网络可能断开。因此,这些操作可能会抛出异常,幸运的是,你现在已经知道如何处理它们了。
以下是数据访问的要点:
- 核心包:
java.io用于传统I/O操作,java.nio用于更高效的非阻塞或通道I/O。 - 常见类:
File,FileReader,FileWriter,Path,Files等。 - 处理异常:I/O操作必须妥善处理
IOException等异常。




本节课中我们一起学习了Java异常处理的机制,包括如何抛出、捕获和声明异常。我们还回顾了通过 java.io 和 java.nio 包进行数据访问的基本方法,并认识到在这些操作中处理异常的重要性。掌握这些知识将帮助你编写更健壮、更能应对意外情况的Java程序。
173:引言与动机 🎬



在本节课中,我们将介绍本专项课程的毕业设计项目,并探讨推荐系统背后的基本动机和现实应用。我们将了解推荐系统如何工作,以及你将如何构建一个自己的电影推荐引擎。
概述
毕业设计项目要求你构建一个“自己动手”的推荐引擎,用于寻找当前电影的推荐和信息。我们将从理解推荐系统的现实应用场景开始,例如Coursera、Yelp和Netflix,然后探讨你将如何利用来自Twitter的众包电影评论和评分数据来实现类似的功能。

现实世界中的推荐系统
上一节我们概述了项目目标,本节中我们来看看推荐系统在现实世界中的几个例子。
当你首次访问Coursera网站时,你会看到类似这里的推荐。这些推荐可能基于课程的受欢迎程度,也可能根据你的浏览历史或访问过的网站进行个性化推荐。网站背后的引擎需要决定展示哪些课程,这可能基于评分或其他标准。
同样,你可以使用Yelp应用或网站获取餐厅推荐。用户可以按位置、价格或其他标准进行筛选。来自世界各地的食客可以通过Yelp贡献评分,其他用户则可以利用这些评分来寻找从旧金山到荷兰海牙等城市的餐厅信息。
有时,这些评分会按除星级以外的其他标准排序。例如,第一个餐厅的星级可能低于第三个餐厅,但你可以按距离远近或近期评分而非总评分进行搜索。
毕业设计项目:电影推荐引擎
谈完餐厅推荐,我们来看看电影推荐,这正是你将在毕业设计项目中编写代码实现的功能。
你将使用的网站Twitflix.com会挖掘包含对当前电影评论的推文。这些评论被转化为评分,并作为你程序的输入数据。为了使用这些评分,你需要获取它们、解析它们,并编写程序来确定某人应该观看哪部电影。
我们将使用另一个基于Twitter、更易于解析的数据源。你将能够利用这些收集到的推文来探索推荐。
除了依赖同行,你还可以选择依赖经验更丰富的影评人。Rotten Tomatoes网站会汇总这些专业评论和评分,并向所有人开放。该网站使用平均评分作为向用户推荐的基础。你将能够在本次毕业设计中复制部分此类功能。
你也可以通过类型、共同主演或任何其他标准进行筛选来生成推荐。
构建你自己的推荐引擎
以上是基于影评人而非普通观众的推荐。你可能想知道与你相似的用户观看了什么,因为“与你相似”意味着这些人可能与你分享某些电影品味。例如,Netflix通过展示与你相似的用户正在观看的内容来简化这一过程。
你将设计和编写类来实现一个推荐引擎,该引擎能按照我们刚刚讨论的思路进行推荐。你的程序可以从多种来源(食物、电影、书籍等)进行推荐,这完全取决于你读入的数据。
正如我们所说,你将基于来自Twitter帖子的众包电影评论和评分进行推荐。你的推荐将采取多种形式。
以下是提供我们正在使用的实时数据的Web服务的URL,但你也可以从我们的专项课程网站Dukelearntoprogram.com获取所有评分。
你将编写代码来解析推荐评分和电影数据,以便你的程序能够进行推荐。你的推荐可以类似于Twitter或Yelp上基于评分及其平均值的推荐。这是一个有用且直接的编码练习。

此外,评分和推荐可以基于Netflix和亚马逊的做法:找到与你相似的用户或买家,并根据这些买家的购买行为提供推荐。在这种情况下,作为你将设计和实现的代码的一部分,你将能够找到与你或另一位观众相似的用户。
总结
本节课中,我们一起学习了推荐系统的基本概念及其在Coursera、Yelp和Netflix等平台上的应用。我们明确了毕业设计项目的目标:构建一个基于Twitter数据的电影推荐引擎。该引擎将能够解析评分、按不同标准筛选,并找到相似用户以生成个性化推荐。现在,让我们开始动手实现它。
174:读取与存储数据 📊



在本节课中,我们将学习如何为创建电影推荐程序而读取和存储评分数据。这是毕业设计项目的第一步,你将编写一系列程序来读取数据,并将其存储在程序可以访问和处理的特定结构中,以便后续生成推荐。
数据来源与格式 🎬
上一节我们介绍了推荐程序的目标,本节中我们来看看具体使用哪些数据。
你将使用来自Twitter帖子的电影推荐数据,这些数据通过一个名为“Movie Tweetings”的项目收集。我们已对数据进行了整理,以便为本次毕业设计提供良好的学习体验。

你可以通过相关网站获取所有数据,这些数据会随着新推文的发布而频繁更新。

你将使用的数据存储在CSV文件中,因此需要使用Edu.Duke库和Apache CSV项目中的包来访问这些数据。

电影数据与POJO类 🎥

你将读取数据,并将电影评分数据存储在集合中,供程序访问以创建推荐。


你将从简单的存储技术开始,随着创建更复杂的推荐,你将运用在本专项课程中学到的软件设计原则,使用更高效的数据结构。


你将使用普通的Java对象(POJO)来存储电影数据。

Movie.java类镜像了存储电影数据的CSV文件。该CSV文件为每部电影包含一行逗号分隔值数据。


以下是CSV文件中一行的示例文本:
tt0119217,Good Will Hunting,1997,USA,"Comedy,Drama",Gus Van Sant,126,https://...


CSV文件的每一行存储了关于每部电影的八项信息:
- 电影的IMDB(互联网电影数据库)ID号。
- 电影标题。
- 电影制作年份。
- 电影制作国家(有时可能不止一个)。
- 电影类型(如喜剧、剧情、冒险、恐怖等),一部电影也可能有多个类型。
- 电影导演。
- 电影时长(以分钟为单位)。
- 电影海报图片的URL。
你将读取CSV文件,并使用我们创建的POJO类来存储每部电影的数据。
Movie.java类有一个构造函数和几个用于访问电影数据的getter方法。一旦创建了电影对象,它就不会改变。这些get方法包括getTitle()、getID()、getYear()等,用于访问你读取的每部电影的信息。你将使用edu.duke.FileResource类和Apache CSV解析器来读取这些数据。
评分数据与Rater类 ⭐
除了电影数据,你还需要对电影进行评分的数据。
另一个CSV文件将存储许多电影的评分。这些评分是从Twitter帖子中整理出来的。

CSV文件中的每一行存储一个评分的数据,即Twitter上关于某部特定电影的一条评分。每行存储评分者的ID、被评分电影的IMDB电影ID,以及1到10分制下对该电影的评分。

CSV文件还存储了日期和时间信息,但在你将创建的推荐中不会使用该数据。

电影ID是获取被评分电影信息的关键。它可以用于你在读取我们讨论过的电影CSV文件时将创建的数据结构中。



Rater类支持多种操作,因此它比仅支持简单get操作的POJO类更复杂。


以下是Rater类支持的一些操作:
hasRating(String movieID):一个布尔方法,用于判断评分者是否对特定电影(作为参数传入)提供了评分。getRating(String movieID):返回一个double值,用于获取由电影ID指定的电影的评分。addRating(String movieID, double rating):添加一个评分,例如在读取评分数据CSV文件时可能会用到。getItemsRated():获取所有已评分电影的ID,以便你可以编写代码遍历所有有评分的电影。


详情请参阅Rater.java类文件。

核心类总结 📝
让我们总结一下你将用于读取和存储数据以创建推荐的三个类。
在毕业设计项目的第一部分,你将使用Movie.java、Rater.java和Rating.java,通过读取CSV文件并将数据存储在列表中来创建程序化推荐。

Rater Java类为一个评分者存储电影评分,这可能包含对多部电影的评分。

每个Rating对象在Rating Java类的实例中存储电影ID和该电影的评分。


这使得Movie.java和Rating.java都成为POJO类。每个类都有一个用于创建对象的构造函数,以及用于访问电影或评分信息的get方法。


Rater.java类支持关于一个评分者所做评分的查询,例如哪些电影已被评分,以及特定电影的评分是多少。

课程总结
本节课中我们一起学习了毕业设计项目第一部分的数据处理基础。我们了解了推荐程序将使用的数据来源(Movie Tweetings项目的Twitter数据)和格式(CSV文件)。我们详细介绍了用于存储电影信息的Movie POJO类,以及用于存储用户评分的Rater类和Rating POJO类。这些类构成了后续构建推荐算法的基础数据结构。下一节,我们将开始学习如何具体读取这些数据文件并构建初始的数据集合。
175:平均评分计算教程 🎬

在本节课中,我们将学习如何从一个电影评分列表中,筛选出有足够评分数据的电影,并计算它们的平均评分。这是一个结合了数据筛选、迭代和计算的经典编程问题。
问题概述
首先,我们需要处理一个电影列表。目标是检查列表中的每一部电影是否拥有足够数量的评分,以便计算出一个有意义的平均分。如果某部电影的评分数量达到要求,我们就计算它的平均评分。
由于您现在已经熟练掌握了解决问题的七个步骤,我们将不再逐步引导您开发算法。您应该能够独立完成。不过,我们会通过一个具体例子来确保您完全理解需要做什么。

示例分析 🎥
我们将使用以下几部精彩的电影作为示例:
- My blocklockbuster Cosera spinoff
- Dude, Where‘s My Code
- The Comedic Adventures of Owen and Robert Go to White Castle
- The Thrilling Tale of Susan Roger and the Goblet of Java
- 以及我个人的最爱:Snow White and the Seven Step
现在,让我们查看列表中的第一部电影《Dude, Where‘s My Code》的评分情况:
- Justin 给了 2 星。
- Quinin 给了 3 星。
- Genevieve 没有评分。
- Nick 给了 4 星。
- Mitch 给了 2 星。
因此,计算平均分时,我们有 (2 + 3 + 4 + 2) / 4 = 11 / 4 = 2.75。
接下来看下一部电影《The Comedic Adventures of Owen and Robert Go to White Castle》,只有 Genevieve 一个人评分,她给了 5 星。但是,如果只有一条评分记录,我们可能认为其数据不足以作为可靠的推荐依据。
这就是您将要编写的方法中 minimumRater 参数的作用。它定义了需要多少条评分才能认为数据是有意义的。在本例中,我们设定至少需要两条评分,因此《Owen and Robert Go to White Castle》将不会包含在结果中。这很遗憾,因为我听说那是部好电影,而且它唯一的评分相当高。
有三个人为《Susan Roger and the Goblet of Java》评分,平均分是 3.33。
每个人都喜爱《Snow White and the Seven Steps》,它的平均评分是 4.0。这几乎是最高分了。
所以,针对这个问题,答案将是包含以下三部电影及其平均评分的列表:
- Dude, Where‘s My Code: 2.75
- Susan Roger and the Goblet of Java: 3.33
- Snow White and the Seven Steps: 4.0
核心模式与实现思路
您可能注意到了许多熟悉的编程模式:
- 遍历每部电影:使用循环迭代电影列表。
- 遍历每条评分:对于每部电影,循环遍历其评分列表。
- 计算平均值:对有效评分求和并除以数量。
- 将结果存入列表:将符合条件的电影及其平均分添加到结果列表中。
当您为每部电影计算平均分时,您的代码将反映这些模式。伪代码逻辑如下:
// 伪代码示例
for (每一部电影 movie in 电影列表) {
int 评分数量 = 0;
double 评分总和 = 0.0;
for (每一个评分 r in movie的评分列表) {
if (r 不为空) {
评分总和 += r.getValue();
评分数量++;
}
}
if (评分数量 >= 要求的最小数量 minimumRater) {
double 平均分 = 评分总和 / 评分数量;
将 (movie, 平均分) 加入结果列表;
}
}
总结
本节课中,我们一起学习了如何根据最小评分数量要求来筛选电影,并计算其平均评分。您需要运用迭代、条件判断和数值计算等核心编程技能。现在您已经理解了问题,准备好去解决它吧。
请注意:在编写代码时,请不要被电影本身分散了注意力。专注于实现逻辑。
176:推荐结果过滤 🎬


在本节课中,我们将学习如何通过Java接口和重构技术来改进我们的电影推荐系统,使其更高效、更通用。我们将重点介绍如何过滤推荐结果,例如只推荐2012年后的电影或特定类型的电影。

上一节我们介绍了如何为所有电影生成推荐。本节中我们来看看如何根据特定条件过滤这些推荐结果。


概述:提升效率与通用性

目前,我们的代码可以为所有电影生成推荐,虽然可以指定用于计算平均分的评分者数量,但使用所有电影过于宽泛。假设我们只想推荐2012年之后的新电影。

或者,我们可能只想推荐动作片、爱情片或喜剧片。甚至可能只想推荐史蒂文·斯皮尔伯格执导的、片长小于两小时的电影。


在本次项目实践中,我们将能够获取此类特定推荐。同时,我们将优化代码,使其在访问电影信息和电影评分信息时更加高效。
我们将使用哈希映射(HashMap)替代数组列表(ArrayList)。这将允许程序根据电影ID立即找到电影,而无需遍历数组列表。为了使程序更通用、更高效,我们将使用Java接口,并实践“开闭原则”——在扩展程序功能的同时,确保修改尽可能对外部不可见。
重构Rater类 🔧

我们将要进行的第一个修改是重构Rater.java类。
重构意味着我们不会添加新功能,并且会最小化或完全不改变现有类的外部接口(即API)。

重构是使代码更具可读性、更高效和更易维护的重要部分。Rater.java中的当前代码将评分对象存储在数组列表中。为了查找ID为“1201607”的电影的评分,甚至只是确认该电影是否有评分,Rater.java中的代码需要遍历评分对象的数组列表。
当有数千名评分者,且每人可能为数百部电影评分时,这种方法效率低下。因此,我们将努力改变这种方法,同时最小化对使用Rater对象的程序其他部分的可见更改。

重构的第一步是创建一个接口,而不是直接修改类。
为了创建一个高效的Rater类并最小化其他更改,我们将首先创建这个接口。我们将复制Rater.java并命名为PlainRater.java。我们需要更改该类中的代码,以便构造函数命名得当。

然后,我们将通过移除方法实现,将PlainRater.java转换为一个接口,只留下方法声明。创建Rater接口后,我们将看看推荐框架中还有哪些地方需要更改。

程序依赖Rater作为类型来读取数据和创建推荐,这些代码大部分仍将正常工作。

例如,进行推荐评分的代码会调用hasRating和getRating,这些调用仍然有效。


读取评分数据的代码需要更改。创建Rater对象时需要使用PlainRater来创建,但需要将新创建对象的引用赋值给一个类型为Rater的变量。

以下是需要更改的代码行示例:
// 重构前
PlainRater rater = new PlainRater("1");
// 重构后
Rater rater = new PlainRater("1");

这一行是使代码正常工作所需的唯一更改。通过创建接口,我们最小化了对客户端程序的更改。程序以最小的修改实现了扩展。



创建高效Rater类与使用过滤器 🚀
现在我们可以创建一个高效的EfficientRater.java类,并在推荐框架中使用这个新版本。
接口将保持不变,但hasRating和getRating方法将变得高效,因为它们将在哈希映射中根据评分ID进行搜索,而不是遍历数组列表。

我们需要更改的唯一一行是创建高效Rater对象的代码,该对象被赋值给这里看到的Rater类型变量。我们已经使用接口使代码变得高效。
接下来,我们将使用过滤器接口来获得更好的推荐。


你在处理地震数据时见过类似Filter.java的接口。我们希望过滤电影,例如获取2012年后制作的电影、片长超过三小时的电影,或者是动作冒险类电影。
我们将如何创建像这里展示的YearAfterFilter这样的过滤器?或者像这里展示的GenreFilter?
这些构造函数看起来很简单,但我们需要思考satisfies方法在您实现的过滤器类中如何工作。satisfies方法将如何根据给定的电影ID查找电影,以确定该电影是否符合过滤器规范(例如,电影是否在2012年后制作)?这里的年份是一个实例变量,但如何访问具有给定ID的电影?
对电影的访问必须通过过滤器的构造函数提供,或以其他方式访问。为了保持简单和高效,我们将创建并使用一个MovieDatabase类,该类允许根据电影ID进行高效查询。这将在我们的过滤器类以及推荐框架中的其他类中既高效又易于使用。
MovieDatabase类是一个高效而简单的类,有助于编写访问电影信息的代码。

该类设计得易于使用,同时在以下方面非常高效,类似于一个简单的数据库。

在EfficientRater中使用了相同的概念,其中电影ID是哈希映射中的键,用于访问MovieDatabase类中使用的电影信息。


电影ID是键,但现在对应的值是电影对象,而不是像在EfficientRater.java中那样的评分。


MovieDatabase类中的所有方法都是静态的,因此不会使用new创建MovieDatabase对象。相反,对电影的访问是通过静态方法提供的,就像使用Math.sqrt和其他数学函数时无需创建Math对象一样。
从概念上讲,这个类就像一个真实的数据库,支持基于电影ID作为键的查询,并返回一部电影或所有满足或通过过滤器的电影。

当您完成所有这些推荐时,您将已经使用了过滤器、接口,并创建了高效的数据库类。

在新的推荐框架中,电影将不再像以前那样通过作为电影对象数组列表的实例变量来访问。

相反,使用静态方法MovieDatabase.filterBy来获取所有满足过滤器的电影ID。该方法返回一个电影ID的数组列表。要获取所有电影,您需要传递一个始终返回true的过滤器,这样每部电影都满足此过滤器。


在您开始的代码中,这是TrueFilter类。
您将能够通过查找类似2012年或之后制作的浪漫电影的平均分来获得其他类型的推荐。
例如,您可以创建一个YearAfterFilter,如下所示。以及一个GenreFilter,如下所示,用于查找浪漫电影。然后,这些过滤器可以添加到一个AllFilters对象中,该对象将多个过滤器组合成一个过滤器。
添加流派过滤器和年份过滤器后,平均推荐结果显示2012年或之后的浪漫电影,如本推荐列表所示。这些推荐是通过利用Java接口的力量和重构代码生成的,以确保已经测试过的程序即使在新的上下文中也能继续工作。添加更高效的代码是额外的收获。
总结 🎉
本节课中,我们一起学习了如何通过重构Rater类和使用Java接口来提升推荐系统的效率和通用性。我们引入了MovieDatabase类来高效访问电影数据,并利用过滤器接口(如YearAfterFilter和GenreFilter)实现了对推荐结果的灵活过滤。这些实践不仅应用了“开闭原则”,使系统易于扩展,还通过使用哈希映射等数据结构显著提升了程序性能。
177:计算加权平均值 📊

在本节课中,我们将学习如何通过计算加权平均值来改进电影推荐系统。我们将探讨简单平均值的局限性,并引入协同过滤的概念,以生成针对特定用户的个性化推荐。

概述

简单平均值在推荐时,平等地对待所有评分者。然而,对于特定用户而言,某些评分者的品味可能更接近该用户,他们的评分应具有更高的权重。本节课将介绍如何计算加权平均值,以实现更精准的个性化推荐。
简单平均值的局限性
上一节我们介绍了基于简单平均值的推荐方法。本节中我们来看看它的局限性。

使用所有评分者的平均分进行推荐,意味着每个评分者的意见被平等对待。但对于“我”的个性化推荐,摩根的评价可能比杰西的更相关,因为“我”喜欢的电影类型可能更接近摩根。同理,对于“你”的推荐,萨姆的评分可能更具参考价值。
协同过滤的基本思想
为了解决上述问题,我们引入一种不同的推荐方法,称为协同过滤。其核心思想是为特定用户生成推荐,而非为所有用户提供相同的推荐列表。
为了实现这一点,我们需要在计算平均值时,为不同的评分者分配不同的权重,更重视那些与目标用户品味相似的评分者。


要创建协同推荐,我们需要对已编写的平均分计算方法进行一些修改。

计算加权平均值
以下是计算加权平均值需要进行的两个概念性修改。假设下表是三位评分者(克里斯、萨姆、摩根)的评分。
第一个修改是只使用与“我”或目标用户相近的评分者的评分。相近评分者的数量是一个参数,例如可以设置 n = 10 来使用10位最相近的评分者。

第二个修改是根据评分者与目标用户的接近程度来加权他们的评分。让我们更详细地探讨这个想法。
假设我们要为“我”推荐电影,以下是四部电影的平均分:
- 《苍蝇》:平均分 7
- 《蜘蛛侠》:平均分 6
- 《蝴蝶效应》:平均分 7
- 《甲壳虫汁》:平均分 7.5
根据这些平均分,我应该看《甲壳虫汁》,因为它平均分最高。但是,克里斯的品味可能比摩根更接近“我”,因此我应该更重视克里斯的评分。这将改变我们计算平均值以获得推荐的方式。
让我们更仔细地看看如何计算加权平均值。

权重的应用
我们将使用每个评分者的“接近度权重”来计算电影评分的平均值。如下表所示,克里斯的权重是20,萨姆是10,摩根是5。我们稍后会展示如何计算这些权重,现在先用它们来计算加权平均推荐。

在计算平均值时,我们将每个评分乘以对应的接近度权重。不是每部电影都会被所有评分者评分。我们将使用加权平均来获得针对“我”或任何目标用户的推荐,计算中使用了这些接近度权重。
以电影《苍蝇》为例:
- 克里斯的权重是20,评分是8,贡献值为
8 * 20 = 160。 - 萨姆没有评分。
- 摩根的权重是5,评分是6,贡献值为
6 * 5 = 30。 - 加权总和为
160 + 30 = 190。假设总权重为20 + 5 = 25,则加权平均值为190 / 25 = 7.6。这与未加权的平均值7不同。
同理计算其他电影:
- 《蜘蛛侠》的加权平均值约为 6.67。
- 《蝴蝶效应》的加权平均值约为 8.33。
- 《甲壳虫汁》的加权平均值约为 6.0。
根据这些加权平均值,看起来我们应该看《蝴蝶效应》。值得注意的是,基于未加权平均分最好的电影《甲壳虫汁》,在使用加权平均后变成了评分最低的电影。
为了计算这个加权平均值,我们需要先计算权重,即一个评分者与“我”或某个特定评分者的接近程度。
计算相似度权重
我们将每个评分者表示为一个评分向量,以便讨论如何计算接近度。向量在概念上就是每个电影的评分列表。
例如,为了便于解释,我们假设有7部电影。评分者萨姆的7个评分如下(未评分的电影用0表示):
[5, 7, 0, 8, 0, 1, 0]
克里斯的评分如下:
[0, 6, 7, 0, 9, 0, 0]
“我”的评分向量显示我评了6部电影:
[6, 4, 0, 4, 0, 6, 0]
让我们看看如何使用这些向量来计算“我”和萨姆之间的相似度权重。
我们遍历每部电影,计算“我”和萨姆都评过分电影的评分乘积之和:
- 电影1:萨姆评5,“我”评6,乘积为
5 * 6 = 30。 - 电影2:萨姆评7,“我”评4,乘积为
7 * 4 = 28。 - 电影4:萨姆评8,“我”评4,乘积为
8 * 4 = 32。 - 电影6:萨姆评1,“我”评6,乘积为
1 * 6 = 6。
“我”和萨姆的相似度权重就是这些值的总和:30 + 28 + 32 + 6 = 96。

用同样的方法计算“我”和克里斯的相似度权重。我们有三部共同评分的电影,计算总和:0*6 + 6*4 + 7*0 + 0*4 + 9*0 + 0*6 + 0*0 = 0 + 24 + 0 = 24。(注意:根据向量,实际共同电影是第2部(6*4=24)和第4部?原例数据需核对,此处演示逻辑)
所以在这个计算中,“我”与萨姆更接近,因为权重是接近度的度量。
点积与评分中心化
上面的计算实际上是一个点积,它是向量空间中数学接近度的一种度量。了解我们计算加权平均值的方法有数学基础是很好的。

在这种情况下,我们简单地计算两个评分者对每部共同评分电影评分的乘积之和。

在实际计算中,我们需要调整评分以解决1到10的评分尺度问题,其中1分表示非常不喜欢,10分表示非常喜欢。

当我们通过计算点积来确定接近度时,我们希望1到10尺度上的评分能有效工作。我们希望相近的评分者对电影的评分也相似(例如都喜欢或都不喜欢),因为这种接近度是相似性的度量。
如果我们简单地相乘,两个给电影评1分和2分的评分者,与评8分和9分的评分者相比如何?
如果直接相乘,我们会比较 1*2=2 和 8*9=72,差异巨大。但这两组评分者的品味非常相似:给1分和2分都表示非常不喜欢,给8分和9分都表示非常喜欢。这两对评分应该对相似性度量的贡献相等,但直接相乘的结果却不相等。
我们将通过从每个评分中减去中间值5来进行中心化处理。
- 对于评分1和2,使用
1-5=-4和2-5=-3。 - 对于评分8和9,使用
8-5=3和9-5=4。 - 中心化后的乘积分别为
(-4)*(-3)=12和3*4=12。 - 这样我们就得到了两组评分者同样相似的结论。
让我们看一个中心化评分的例子。原始评分为0的用星号表示,我们在计算相似度得分时不会使用它们。
萨姆的7个原始评分及中心化后评分:
- 原始:
[5, 7, 0, 8, 0, 1, 0] - 中心化:
[0, 2, *, 3, *, -4, *]

克里斯的评分:
- 原始:
[0, 6, 7, 0, 9, 0, 0] - 中心化:
[*, 1, 2, *, 4, *, *]
“我”的评分:
- 原始:
[6, 4, 0, 4, 0, 6, 0] - 中心化:
[1, -1, *, -1, *, 1, *]
现在重新计算“我”和萨姆的相似度权重(使用中心化评分):
- 电影1:萨姆0,“我”1,乘积
0*1=0。 - 电影2:萨姆2,“我”-1,乘积
2*(-1)=-2。 - 电影4:萨姆3,“我”-1,乘积
3*(-1)=-3。 - 电影6:萨姆-4,“我”1,乘积
(-4)*1=-4。
总和为 0 + (-2) + (-3) + (-4) = -9。
计算“我”和克里斯的相似度权重:
- 电影2:克里斯1,“我”-1,乘积
1*(-1)=-1。 - 电影3:克里斯2,“我”0?根据向量“我”未评分,应为,不计算。原例数据可能不一致,此处假设共同电影为第2部(已算)和第5部?克里斯4,“我”0?不计算。实际应根据共同评分电影计算。
(注:根据提供的中心化向量,“我”和克里斯共同评分的电影似乎只有第2部(1-1=-1)。原文本描述有矛盾,我们理解其核心思想即可。)
所以在这个中心化后的计算中,“我”可能更接近克里斯而不是萨姆,因为与萨姆的乘积和为负数,表明我们的喜好相反。记住,在原始非中心化评分中,“我”更接近萨姆。所以中心化处理带来了差异。从评分中可以看出,萨姆和“我”并不一致:当“我”喜欢一部电影时,萨姆不喜欢,反之亦然,因为所有乘积都是负数。
这项技术在计算相似度时是标准的,但很容易被忽略。如果像1到10这样的全正尺度上的评分不以这种方式中心化,相似度权重将会改变。
Java代码实现
让我们看看用于计算这些加权相似度以找到“我”或任何评分者附近评分者的Java代码。我们将调用 getSimilarities 方法,这是你需要在本核心项目中完成的方法。
public ArrayList<Rating> getSimilarities(String id)
参数 id 是为其计算相似评分的评分者。
RaterDatabase 类将提供给定评分者ID的访问权限,类似于你已经使用过的 MovieDatabase 类。RaterDatabase 类也提供对所有评分者的访问,就像 MovieDatabase 类提供对所有电影的访问一样(尽管电影可以被过滤)。
在这个循环中,你将调用另一个方法来计算“我”和评分者 r 之间的点积。这个点积值将与评分者 r 的ID配对在一个 Rating 对象中,并添加到要返回的 ArrayList 中。
在返回 ArrayList 之前,代码将对列表进行排序,以便第一个 Rating 是具有最高权重(最接近“我”)的评分者。我们可以通过调用 Collections.sort 并传递一个比较器来实现,该比较器是Java Collections 类的一部分。这个比较器反转了 Rating 对象的默认排序顺序,以便列表将最高值存储在前面。
一旦你获得了每个评分者的权重,你就能够计算加权平均值来获得推荐。
getSimilarRatings 方法与 getAverageRatings 类似,但是针对一个特定的评分者,其ID是参数。

首先,通过调用我们刚刚讨论的 getSimilarities 方法来计算所有评分者的权重。
与 getAverageRatings 方法一样,此代码循环遍历评分者。在 getAverageRatings 方法中,循环遍历所有评分者,并检查每个评分者是否对正在计算平均分的电影进行了评分。
在这里,我们只循环遍历那些与“我”接近的评分者,即那些权重存储在名为 list 的 ArrayList 中的评分者。我们只使用 list 中的前 numSimilarRaters 个条目,其中 numSimilarRaters 是一个参数。其思想是仅使用最接近“我”的前10、20或100个评分者。
在从 list 中获取评分时,需要注意确保索引有效,避免越界错误。
在累积加权总和后,加权平均值将被添加到要返回的 ArrayList 中,就像未加权的 getAverageRatings 方法中的代码一样。你将返回电影评分的列表,你可能希望先对其进行排序。
在获得协同过滤推荐之后,你需要编写代码将其呈现给用户。
呈现推荐结果
你需要决定是否应该为已经看过的电影提供推荐。
这是一个为“我”生成的推荐列表,基于“我”今年看过的电影给出的10个评分。在输出中,“我”评过分的电影用星号标出。这些评分可能有助于“我”校准结果,因为“我”喜欢这些电影,在top15列表中看到它们对“我”来说是有意义的,尽管“我”不需要推荐去看它们,因为已经看过了。

我们应该打印超过top15吗?我们应该打印所有推荐吗?我们应该打印加权平均值吗?
你还将决定是否应该打印比电影标题更多的信息。你可以打印年份、流派或更多信息。你可以生成HTML输出,在网页上显示推荐。
享受寻找推荐的乐趣吧!
总结
在本节课中,我们一起学习了如何通过协同过滤和加权平均值来改进电影推荐系统。我们探讨了简单平均值的不足,引入了根据评分者相似度进行加权平均的思想,并详细讲解了计算相似度权重(点积)的方法及其关键步骤——评分中心化。最后,我们概述了在Java中实现这些概念的代码框架。通过这种方法,我们可以为每个用户生成更加个性化的电影推荐。
178:教学团队告别 🎓

在本节课中,我们将回顾整个专项课程的学习历程,并展望未来的编程之旅。

非常感谢您完成了我们的专项课程和顶点项目。

感觉就像昨天一样,我们还在用恐龙演示“万物皆数字”,并开始学习编程七步法。
现在,我们的学习者已经能够编写推荐引擎了。
这些引擎甚至可能用七步法来推荐《白雪公主》。欧文,这算是个玩笑。
虽然不会有关于《白雪公主与七步法》的电影。
但这会是一个很酷的续集:她学习编程,在谷歌获得一份很棒的工作,并从此开发有用的软件,幸福地生活下去。
尽管这很有趣,但更重要的是,所有完成本专项课程的学习者,已经对编程(特别是Java)有了深入的了解。
然而,尽管他们学到了很多,但这并非他们编程旅程的终点。
相反,这仅仅是一个开始。
如果他们在此课程之后继续学习,还有更多可以学习和实践的内容,无论是深入钻研Java,还是学习另一门语言。
不过,这确实标志着我们专项课程的结束。
所以,再见,祝你好运,无论你的编程冒险将带你走向何方。

在本节课中,我们一起回顾了整个专项课程的成就,并鼓励大家将此次学习视为未来广阔编程之旅的起点。

浙公网安备 33010602011771号