密西根大学-EECS281-数据结构算法笔记-全-

密西根大学 EECS281 数据结构算法笔记(全)

001:课程介绍 🎓

在本节课中,我们将学习EECS 281课程的总体介绍,包括课程结构、教学团队、评分标准、学习工具以及成功完成本课程的关键策略。


课程概述

大家好,我是Paletti博士,还有其他几位讲师也通过Google Meet在线。我是Daren教授,很高兴与大家见面。本学期我们将以这种方式进行教学,这与上学期的情况完全相同。我们将在281课程中尽最大努力,你们也会在281课程中度过愉快的时光。请记住,大多数告诉你281课程有多难的人,其实都已经通过了这门课,你们也可以做到。

我是Eto Garta,我已经教授这门课程几年了,与Marcus和David一起工作了两年。我相信你们会发现这门课程现在和不久的将来都非常有用。很高兴来到这里,并期待在整个学期中与大家见面。

我是Brian Noble。我在教职岗位上已经有一段时间了,曾在2001年教过281课程,但很多事情已经发生了变化。我真的很期待重新回到这门课程,并完全赞同其他教员的说法。这门课程对在场的每个人来说都是可以完成的,并且充满乐趣,我非常期待。


教学团队与课程安排

关于我自己,这大概是我在过去几年里第23次教授281课程。在硕士和博士期间,我曾为几家公司工作过,从试图将想法转化为技术的小型初创公司,到拥有自己硬件产品并内置约75万行C++代码的公司。我参与过非常小的项目,也参与过非常大的项目。我希望帮助你们弥合这一差距。

今天我们将看到教员名单,以及我们的其他工作人员,包括几位GSI(研究生助教)。我们还有Potato Bot,你们可能在Piazza上见过它。Potato Bot是一个半智能的人工程序,它会尝试建议可能已回答你问题的帖子,有时它能猜对,有时则完全错误。

关于我,你们需要接受的一个事实是,我是色盲。所以如果我提到紫色,你们可以在聊天中输入蓝色,配合一下。我尽量提前知道我要做什么,以便知道在不同时间该说什么颜色的词,但我并不总是能说对。另一个关于我的事情是,我不太擅长记名字,当然在线教学使这一点变得更困难。即使我在办公时间经常见到你,之后也可能记不住你的名字,但我会尽力。

你们可能遇到过其他教授告诉你们“没有愚蠢的问题”。我对此有不同的看法。我认为确实存在一类愚蠢的问题,那就是那些你永远得不到答案的问题,因为你从未问过它们。所以,愚蠢的问题就是那些你从未问过的问题。如果你有问题,那对我来说是好事,因为这意味着你已经发现了自己知识上的空白,并希望填补这个空白,将你所知与你试图理解的内容联系起来。所以,请提出问题。我们今天有教员,接下来的讲座中也会有工作人员。我们将安排好日程,监控聊天窗口,并安排办公时间和实验课。我们希望你们提出问题。你可能觉得只有你不理解,但事实并非如此。有很多其他学生也不理解,只是他们可能没有你那么勇敢去提问。

我们的每周日程是周二和周四的12:00到13:20。我们可能会超时几分钟,有时会尽量准时结束。课程主题日程表以及每个视频的链接都在Canvas上。说到Canvas,让我们一起看看那里。


Canvas平台与课程资源

让我们一起来看Canvas。前面有教学大纲,你们可以阅读。这里有主题日程表,我已经在另一个窗口中加载了它。

我们的主题日程表列出了每个主题,直播和录播的链接是相同的,而播放列表则是Daren教授为每节课录制的较小片段视频。引言和考试复习没有单独的播放列表视频,但其他所有课程都已经有了。你们可以查看“讲座”列,看看当天是谁授课。基本上是按块进行的,比如我先讲前六节,然后Hector讲一部分,Brian Noble讲一部分,Daren教授再讲一部分。然后Daren教授和我分担考试部分,因为我们在这方面经验最丰富。

你们还可以查看项目何时分配、实验课内容以及截止日期。在Canvas的“作业”部分,有所有内容的截止日期,我相信它们都是正确的。如果发现任何错误,请在Piazza上发帖告诉我们,但我花了一些时间来修正它们。

“成绩”部分我们暂时不看,因为如果我打开成绩,它会显示其他人的成绩。“日历”基本上是链接到我们的Google日历,目前内容还不多。到目前为止,Daren教授和我已经添加了我们的办公时间,其他讲师很快也会添加他们的,我们也会让工作人员添加他们的时间。

“文件”是一个非常有用的地方。“考试练习”部分尚未填充内容,它是可见的,但其中的各个文件夹我还没有设置为可见。我会在那里放一些练习题,但需要先修正日期,使其与本学期的日期一致。“公开考试图片”我们绝对不想看。“实验课”大部分材料将在GitLab上,我们在左侧某处有链接。“讲座材料”部分,你们可以看到前七八次讲座已经发布。Daren教授正在更新其余的讲座,因为他在上学期更新了很多内容,我们会尽快发布。

“项目”部分,目前唯一可见的是makefile.txt,它包含我们GitLab主Makefile的链接。你也可以直接复制这部分内容。这与左侧的GitLab链接相同,那就是我们的Makefile。“项目零”现在已经发布,它有一个项目零标识符,你必须将其放入源代码和Makefile中。它不在PDF里,但从现在开始学习:不要从PDF文件中复制粘贴。PDF文件有不可见的字符来使字体和间距正确并看起来美观。如果你从PDF文件复制任何内容,它可能无法工作。如果你复制代码,可能会有长减号导致编译错误。如果你复制要放入输出的内容,可能会有隐藏字符使你的输出出错。所以,始终从TXT文件复制。

我们有关于项目零的PDF。项目零通知说项目零不计分。项目零只是为了帮助你习惯使用自动评分器MTAC,以及如何在你的IDE(如Visual Studio、Xcode等)中使用一些工具。如果你查看它,有关于在Mac和Xcode、Windows和Visual Studio上使用和设置的视频链接,你可以观看其中一个。Canvas上还有一个关于VS Code的文件夹,我们稍后会看。有一个项目零教程和一个关于使用Makefile的内容。

这里还有起始文件。PDF中有一个部分解释了为什么有两组不同的起始文件,以及何时使用哪一组。“资源”部分,目前最重要的是“优化技巧”。这里还有一个关于文件格式以及为什么有不同的输入文件的部分。最后有一个部分你应尽快阅读,关于“优化你的考试成绩”。你应该现在就开始按照其中的建议去做,而不是在考试前一天。其中有一些建议,不会大幅增加你在这门课程上的时间,但会让你为考试做好更充分的准备。

中间有一个部分,实际上就在“考试成绩优化”之前,是关于“读取输入和正确率”的部分。其中一些内容也在项目零视频中。这份文档的其余部分是关于内存优化(确保不使用过多内存)和速度优化的内容,其中有很多非常有用的信息,你将在整个学期中需要参考。

Canvas上最重要的内容就是这些。下面还有Visual Studio安装和使用PDF、Xcode安装和使用PDF、Xcode文件文件夹、Visual Studio文件文件夹,以及一个装满VS Code相关内容的文件夹。

Piazza链接到Piazza,非常简单明了。链接到我们的E281 YouTube频道。“测验”部分,我们没有任何测验,这只是Canvas对工具的称呼。但测验将包括诸如“实验4测验”之类的内容,但这并不是真正的测验,而是你实验的多选题部分。考试也将在这里进行。请注意,我们还没有进行考试,这里仍然有秋季的考试内容。有一个准备情况调查和一些旧内容。所以,我们没有测验,但我们将使用测验工具进行实验的多选题部分和考试的多选题部分。

“办公时间帮助队列”是链接到办公时间排队的链接。“GitLab服务器”很简单。“自动评分器1和2”有链接,它们是相同的硬件。我们希望你们进行负载均衡。如果你看到这里说队列大小是20,也许你最好去检查另一个自动评分器,可能那里只有五个人,你会更快得到答案。

自动评分器将允许六个人同时运行。它们有八个CPU,你们不会看到所有这些内容,基本上只会看到主页、队列和FAC(我认为是你们可以看到的内容)。但当你进入主页时,你会看到类似这样的内容。你不会看到快速调试框,但会看到项目/实验/全部。我们有项目零(不计分),看,我还没有完全完成就提交了,实际上是故意的。我的项目零(不计分的项目零)现在对你们可见了。项目一“从城堡中拯救伯爵夫人”将在我们完成项目后不久发布。

有两个自动评分器。GradeScope我们将用于实验的部分,有时是实验的书面部分,你将在那里提交。考试的部分也将在GradeScope上提交,所以本学期你将使用它。“替代考试请求”如果你有冲突,我们还没有确定考试时间,但我们有日期,并且在考试日至少会有两个时间供你选择。当我们确定时间后,如果你与这两个时间都有冲突,你可以在那里提交考试请求。“教学评估”你可以稍后进行。“媒体库”基本上包含Daren教授录制的分段视频的播放列表。它们的顺序有点奇怪,是倒序的。哦,需要加载更多。对了,第一讲没有分段视频,所以我们有这些播放列表。这些基本上是从主题日程表链接的播放列表。


Piazza与重要通知

这就是Canvas上的内容。同时也要关注Piazza。Piazza帖子编号6告诉你在需要帮助时,将你的唯一名称放在括号里。这样我们更容易在自动评分器上查找你。偶尔会有学生姓名相同,没有这个信息就无法区分谁是谁。此外,如果你没有在信息中填写唯一名称,Potato Bot会抱怨。它还会告诉你如何每小时关闭电子邮件通知,你应该这样做。你不应该将Piazza的邮件标记为垃圾邮件,这很糟糕,因为我们会在Piazza上发布对你至关重要的公告,你需要确保能看到它们。

“速成课程”这个链接已经过时了,我忘了更新幻灯片。你们不想看那个速成课程。你们应该去项目零的PDF,查看那里的四个链接。实际上你只需要看其中三个,因为你可能没有同时拥有Mac和PC,不需要同时看Visual Studio和Xcode的内容。所以看那个,不要看速成课程,那是上学期的,大约两小时长。我们把它分成了更易管理的块,并且更新了。

期中考试和期末考试是这些日期。就像我说的,我们至少会在这些日期安排两个时间,以适应不同的时区。再次强调,速成课程已经过时,但其中一些内容会在那些视频中。教学大纲,就像我说的,在Canvas上阅读教学大纲,里面有评分标准之类的信息。有很多有用的信息。

先修课程是EECS 203和280。如果你没有通过203或280,你将不得不退课。对于203,我们也为双学位学生计算一些数学课程。根据系里的政策,研究生不能注册281,然而,本学期我们有研究生正在上403,但你们都不必担心,因为到了评分的时候,你们将被完全分开评分。本科生有一个评分标准,研究生有另一个不同的评分标准。这些不是CS研究生,而是统计学、数据科学的研究生。

“准备情况自我调查”我提到过,当时我们在看Canvas测验。所以看看那个,通过它帮助你意识到你可能需要回去复习203和280的哪些内容。


学术诚信与协作

协作:项目和考试必须是你的代码,或者我们给你的东西。如果我们给了你一些有用的东西,就使用它。我们给你的任何东西都是供你使用的。如果你正在重修281,你可以重用你自己的代码,那是你自己的。不要在Piazza上发布代码,不要创建公共代码仓库。你的测试文件,你应该提交你自己的测试文件。我们稍后会讨论测试文件是什么意思。

荣誉守则:Canvas上有一个链接,我想是在“工程学生支持”下面。我们对此非常认真,我们不喜欢这样做,但我们有检测作弊的强大工具,我们讨厌使用它们,也讨厌将人交给荣誉委员会。

可以使用其他来源吗?当然可以,用于理解事物。但我们不必找出你复制代码的来源,因为我们有900名其他学生为我们做这件事。所以,如果你在网上找到一些代码并使用它,而其他人也找到了相同的代码,我们会一起抓住你们,或者三个,或者无论多少人。所以,如果你在看视频来理解事物,那很好。但要明白,其他来源的做法可能与我们不完全相同。它们也可能展示从1开始索引的代码,这会使事情变得有点困难。所以,作为理解事物的资源是好的,但不要开始复制它。

获取帮助:我们有电子邮件E281admin@umich.edu,这封信会发送给所有四位讲师,上面写着TAs,但并不是所有TA,我们有两名TA帮助我们处理邮件。如果你需要一对一帮助,你可以来办公时间,当我们打开办公时间队列时,就加入进来,我们会单独与你交谈。如果我们的办公时间与你的日程安排不符,你也可以联系E281admin,我们会尝试找另一个时间。

CPP参考和C++网站:两者都可以。看看它们两个,找出你最喜欢哪一个,然后使用其中一个。它们以不同的格式提供等效的信息。关于发布代码等,当我们有办公时间时,请准备好问题,这很好。你可以来参加我们的办公时间,我们有一个公开会议,希望人们加入,你可以只听其他人的问题。只要问题不是个人性质的,我们会将个人问题带到单独的在线会议中。

当我们有办公时间时,我们有一个会议,你可以加入进行一对多提问。我们会有一两位教授在那里回答问题,很多学生提问。你会发现,听其他人的问题真的会帮助你,因为很多其他学生会有和你相同的问题。我们还有办公帮助队列,也从Canvas侧边栏链接。关于我们的工作人员,就像这里说的,在第一周结束时,当他们安排好日程后,他们会开始将他们的办公时间添加到那个办公时间日历中,这又在Canvas的“日历”下。请记住,工作人员、GSI和IAA也是学生,所以如果他们时间到了必须离开,可能是因为他们有自己的课要上,或者要去做自己的项目,或者去参加小组会议。所以,如果他们能晚点走,那很好,感谢他们;如果不能,那么你必须尊重他们的时间。我认为我们的门……哦,这条线已经过时了,抱歉,这条线没有通过COVID过滤器。

所以,来办公时间,加入一对多会议,在那里提问。你可以使用麦克风,可以使用摄像头,也可以都不用,只需在Google Meet聊天窗口中输入问题。我们已经看到,Daren教授,如果你想让他不高兴,这里有一个方法:当我们在一个只有七个人的一对多会议中时,你去加入一个有80人的队列,其中一些人已经等了五个小时,而同时,在一对多会议中的七个人,我已经回答了那里每个人的三个问题,并且正在准备回答第四个问题,而这只是在最初的20分钟内。所以,加入一对多会议,你想让问题得到提问和回答,这是最快的方式。你可能会听到我说:“哦,是的,就在你加入前五分钟我刚回答过那个问题。”但这并不是抱怨。这是我让自己保持愉快的方式,与其对一天要回答五次同样的问题感到沮丧,我宁愿在一对多会议中一天回答五次,也不愿在一对一会议中回答80次。所以,总会有人在刚回答完问题后出现,我很乐意再回答一次。我可能会在我的平板电脑上回滚到之前的例子,擦掉一点,然后重新开始。


评分标准与通过要求

评分:你成绩的20%来自实验课,共有10次实验。40%来自四个项目,其余来自考试。

要通过这门课需要做什么?我们知道你想通过这门课,你需要:在考试中获得大于或等于50%的分数,在项目中获得大于或等于55%的分数,在实验课中获得大于或等于75%的分数,这样你就能通过这门课程。如果你略低于这些标准,并不意味着你不能通过,这只是保证。所以,如果你略低于标准,我们会非常仔细地查看每个处于该类别的人。所以,不要因为略低于标准就放弃。来和我们谈谈。

此外,在期中考试后,我们会发送期中成绩估计,告诉你如果课程现在结束,你会得到什么成绩。如果你没有通过,确切地告诉你需要做什么来满足这些不同的标准。我已经计算过这个数学,我认为数字是正确的。如果你的项目是30%,实验课是100%,考试是90%,你是不及格的,而且你离55%的项目分差得太远,我们很可能不会为你破例。但如果你是,比如说,52%的项目分和190%的其他分,那很可能没问题。特别是如果你的项目分数持续上升,比如你在第一个项目上搞砸了,但你吸取了教训,并在后续项目中做得越来越好,即使你没有完全达到55%,但很接近,并且你通过了其他部分,我们很可能会让你通过。所以,如果你不确定,来和我们谈谈,来办公时间,让我们知道你需要一对一帮助。


实验课安排与要求

实验课:你将与其他学生一起工作。我们有一个调查,请确保你阅读……哦,现在我得看看编号。我相信是15号。是的,15号。所以在Piazza的15号帖子里,有一个关于实验课部分的内容,顶部有一个链接的表单,不太容易看到,上面写着“填写此表单”,所以这个表单就是你需要点击的。我们要做的是,我们将坚持我们预定的实验课时间,但不是你注册的那个。所以,你注册了哪个实验课并不重要,你可以选择一个不同的,或者选择多个,然后我们会根据你的日程安排将你分配到一个你能参加的实验课,并将你分配到小组中。小组规模可能在4到6人左右,取决于该部分有多少人。

我们希望你们一起工作,我们希望你们利用那个时间。在实验课期间,你需要做一些事情来获得分数,你必须出席。所以,我们有了这个,我们会发送出去……好的,已经发送了。所以,不一定是你注册的那个,但我们确实希望你注册并每周参加同一个实验课。我们有一个关于职业博览会日的问题,所以那一周我们可能会允许你参加任何其他实验课,但对于普通的周,我们希望你能持续参加你注册的那个实验课。

我们将有分组讨论室,你和与你一起工作的其他学生可以去那里讨论实验。我们将有一位实验课讲师也在Zoom中,并轮流进入分组讨论室回答问题。他们还会在实验课期间记录出勤情况。现在,书面问题在COVID之前的日子里,这真的是在纸上完成的,这也是你在真实考试中的做法。现在是在线完成并在线评分,就像你考试时一样,所以这仍然是很好的考试练习。

你必须参加实验课。当你在实验课Zoom中时,你需要直接向实验课讲师发送一条私人消息,包含你的唯一名称,这就是他们记录当天谁在场的方式。这些书面问题是根据努力程度评分的。所以,你提交到Canvas的代码(他们这样做的方式是:谁向实验课讲师发送了私人消息,那位讲师就负责评分。如果没有实验课讲师收到你的私人消息,那么就没有人给你评分。)你提交到GradeScope的代码将根据努力程度评分。它不一定要编译,也不一定要得到正确答案,但它应该接近问题。如果它只包含一条评论说“我这周没时间”,那不行。所以,你不必在实验课期间完成它。在实验课期间,你必须向实验课讲师发送一条消息,然后在本周末之前,你必须去GradeScope提交文件。

所以,不需要注册这些,我们会分配。如果你必须参加一个不同的实验课,那也没关系。重要的是,每个人都必须单独提交每个部分。所以,如果Canvas上有测验部分,你必须提交你的测验部分(Canvas上的选择题)。如果每个实验都有书面部分,你必须自己在Canvas(或GradeScope)上提交。如果有自动评分器部分,你必须在自动评分器上提交。


项目要求与截止日期

项目占你成绩的40%,全是个人工作,提交到自动评分器。我们稍后会看这个。每个项目大约有两周时间,你可以从主题日程表中看到它们何时分配和截止。我们在学期中给你延迟提交天数,你可以自行选择使用。

不要在第一个项目上使用它们。尽早开始第一个项目,来办公时间提问,开始编码。如果你是那种在280课程中可以在截止日期前两天开始项目的人,好吧,在900名注册学生中,可能有一个你能做到,也可能没有。但不会有800个你能做到。如果你在281项目的截止日期前两天才开始,很可能它们无法完成。这是你的中等水平学生的情况。

所以,不要把它们留到最后一刻。我们稍后会有一个其他学生的视频来告诉你同样的事情。

延迟提交天数:每学期有两个(实际上研究生有三个,而且研究生每天有额外的提交次数。所以,如果你看到有人说他们今天还有五次提交机会,那是因为他们是研究生。再次强调,他们的评分标准与你完全不同。他们获得更多提交次数和一个额外延迟天的原因是,他们不是计算机科学专业的学生,他们需要在这方面多下点功夫。)

关于延迟天数的一个例外是项目零。你可以在项目零上使用延迟天数,事实上,我敦促你在项目零上使用延迟天数,因为在每个人都可以使用延迟天数的时间到期后,我会退还它们。所以,如果你在项目零上使用延迟天数,你将看到延迟天数系统是如何工作的。

延迟天数如何工作:当你使用它们时,你必须为截止日期后的每一天使用一个延迟天数。例如,如果项目在周二截止。在周三,我可以使用一个延迟天数,我将获得一整天的提交机会。在周四,我可以使用一个延迟天数,获得周四一整天的提交机会。但是,如果我在周三没有提交,而是直接从周二跳到周四,那么要在周四提交并获得一天的提交机会,需要两个延迟天数。在自动评分器中,没有简单的方法根据跳过的天数给你额外的提交机会。这需要大量的重新编码,我们不会那样做。为什么在周四提交需要两个延迟天数而不是一个?因为你可以说:“嘿,学期末了,我还没用过任何延迟天数,我用一个延迟天数来提交两个半月前截止的项目一。”不,你需要大约75个延迟天数来提交那个两个半月前的项目。所以,如果你周三没有提交,要在周四提交,就需要两个延迟天数。


编程环境与自动评分器

我们将使用C++17,但实际上我们只有C++14编译器,所以不要使用17特有的功能。我们使用GCC 6.2.0,这是我们的自动评分器支持的版本。我们必须获得新机器、新软件、新操作系统才能升级到17编译器,所以我们实际上有一个符合14标准的编译器供你使用。

如果你在另一个环境中进行开发,比如Xcode或Visual Studio,在那里进行开发很好。它可能在自动评分器上无法编译,这就是为什么你想提交到CAEN,因为CAEN的编译器与自动评分器完全相同。所以,如果它在CAEN上编译,它就会在自动评分器上编译。但如果它在PC上编译,它可能在自动评分器上编译;如果在Mac上编译,也可能在自动评分器上编译。所以,如果你遇到编译问题,你可以在CAEN上进行比自动评分器更多的交互式调试。

我们的自动评分器不仅测试正确性,还测试时间和内存。如果你超时,你的分数会开始下降,超时越多,分数下降越快。内存也是如此。如果两者都超过,你将按比例失去两者的分数。我们的大多数测试用例会立即给出反馈。有一些是隐藏的,它们通常不是棘手的那些。我们为项目最终评分保留的那些,基本上是由生成大多数输入文件的同一程序生成的更多文件。

你每天大约有三次提交机会。你可以通过发现足够多的错误来赚取额外的每日提交机会,我们稍后会讨论。当我们完成并且每个人都用完了所有可能的延迟天数后,我们将运行最终评分。项目会告诉你一个表单的链接,自动评分器也有一个链接到一个表单,上面写着:“嘿,不要用我最好的那次,用最近的一次,因为我确定那是正确的。”


寻求帮助前的准备

在请求调试帮助之前,你应该先做这些事情。所以,在来办公时间说“哇,我实在搞不懂这个”之前,先做一些这些事情:提交到自动评分器;包含你自己的测试文件——如果你的测试文件揭示了一个错误,我们第一次看到时会给你正确的输出和你的输出;使用Valgrind测试我们给你的任何示例;尝试找到尽可能小的示例。比如,我们的第一个项目是迷宫搜索,如果是一个3x3的迷宫,你可以说:“嘿,这个3x3的迷宫我搞对了,但这个只有一个字符不同的3x3迷宫我搞错了。”现在,这比“嘿,你知道那个10000x10000有10个房间的迷宫吗?是的,我没搞对那个”要容易得多。这对我们来说真的很难调试,这就是为什么你应该制作更小的输入文件。


自动评分器界面与测试文件

这里有一个截图,一个旧的截图,但显然,它仍然与自动评分器相关。它有项目截止日期、你今天使用了多少次提交、剩余多少延迟天数。这个截图是在截止日期后几天拍摄的,所以即使这个人还有剩余的延迟天数,它们也无法使用,因为已经过了截止日期好几天了。如果它们可以使用,那里会有一个链接,上面写着“嘿,你可以点击这里使用一个延迟天数”。有些项目会有排行榜,我想我们正在取消这个,我们正在尝试更新,也许改变一些统计数据。你点击选择文件,然后点击“上传提交”按钮,然后你在这里看到的是每次提交的时间戳。

时间戳基本上是你提交的日期和时间。如果你点击时间戳,你会看到自动评分器的一堆反馈。左边那个看起来像箭头和硬盘的按钮是下载你的提交。所以,如果你不小心弄乱了代码,而自动评分器上只有剩余的副本,你总是可以从自动评分器下载它。除此之外,还有你通过了多少个测试用例,你发现了多少个错误。这些列是测试用例名称。从底部看,WA是错误答案,任何蓝色的都是稍微超时或超内存。如果有SIG,会有信号,比如段错误或其他问题。查看时间戳,它会告诉你是什么信号,不要只假设是段错误。你可能会因为超过允许的磁盘空间而收到信号,比如你的程序试图填满我们的硬盘,我们在200兆字节后切断了你。所以,查看时间戳,点击它,它会显示每个测试用例的反馈。然后你说:“嘿,L2B我搞错了,所以我去点击时间戳,搜索L2B,看看我在那里搞错了什么。”它会给你一些反馈。

“发现的错误”是我们做的:我们拿我们完美的解决方案,在代码中制造了一些错误。这些是我们创建的错误,我们试图创建我们认为学生会犯的错误。所以,当你提交测试文件时……测试文件是你提交的。这些基本上是输入文件。这不像280,这不是代码,这是一个输入文件。所以,这个格式会随着每个项目而变化。对于项目一,你的输入文件将包含一个有效的2D迷宫描述文件,或者3D迷宫(抱歉,如果只有一个房间,它基本上是2D的;一个房间的3D迷宫基本上是2D的)。但你基本上会有一个正确格式的输入文件,描述一个3D迷宫。你可能会给我们一个没有解决方案的输入文件,也可能给我们一个有解决方案的输入文件。我们会告诉你哪些你搞错了。我们发现的第一个你搞错的,我们会给你看正确答案。

这就是我们所说的测试文件。这些是测试用例。我尽量严格遵守这个术语:测试用例是我们给你的,测试文件是你提交的输入文件。


项目提交时间与成绩关系

这是一个图表。你可以看到这是2013年秋季,这是我第一次教这门课的那个学期,但我可以为任何学期重现这个图表,它几乎总是一样的。

这个图表显示的是:X轴是学生首次提交的日期,Y轴是该学生的最终分数。所以,这并不意味着在第一天提交的人得了100分,而是意味着他们的第一次提交是在第一天,并且他们最终得了100分。所以,除了这个奇怪的离群点(我想是退课了),所有在这里之前提交的人都得到了98到100分左右。然后,你可以看到它开始下降。那些在截止前三小时首次提交的人?你基本上可以掷骰子来找到他们的分数。在最后几小时提交的人没有一个得了100分。

这是鼓励。当我们面对面教这门课时,我能听到你们在笑,这对你们来说很有趣。然后,三周后项目一截止时,你们又会做同样的事情。所以,请听我们说,请尽早提交,尽早提交,经常提交。如果你不使用自动评分器的提交机会,你就是在浪费时间。即使你没有代码,你也可以提交测试文件。并尝试发现错误。目标其实并不是真的发现我们的错误。是的,你发现错误会得到分数,但真正的目标是,如果你的测试文件发现了我们的错误,希望它们也能发现你的错误,它们将帮助你调试你的项目。这就是测试文件背后的真正目标。


考试结构与评分调整

考试:就像我说的,期中期末各占20%,包括理解和问题解决。它们将涉及分析复杂度、分析代码、回答问题(如选择题)、编码问题等。就像我说的,我会很快发布那些练习题,你可以看看以前的真实考试是什么样子。格式会略有不同,因为我们现在是电子的,但结构相同,选择题和编码问题的比例相同。

评分调整:如果必须,我们会进行调整。作为讲师,我试图承担责任。所以,如果考试平均分太低,那是我们的错。我们会帮助你回到更容易通过的状态。就像我们说的,50分是保证线,如果你考试得了50分,你保证能通过。但如果考试太难,比如期中考试太难,也许47分就能通过期中考试。当我们发送期中成绩估计时,我们会宣布期中考试的通过线是多少。如果你没有达到那个线,我们会告诉你:“哦,你知道你差三分,所以你得了44分。这意味着如果你期末考试得53分,你保证能通过。”如果期末考试太难,我们降低了标准,那么也许51分就能通过。但如果你期中得了44分,而期中通过线是47分,如果你期末考试得53分,弥补了那三分差距,你就能通过考试。

我们也经常调整总成绩。所以我们可能会说,让我们把分数线降低一点,也许下一个等级不是87分,而是86.5分。我们会在学期末分配成绩时这样做。如果……这在281中很少发生,但如果考试太容易,平均分是79分,那也是我的错,我们不会让通过变得更难。我们遵守我们说过的话。最坏的情况……我遇到过这种情况。我向很多教授学习如何教学。我上过新生化学实验课,我想我得了93分,但我得了个C,因为我的实验讲师不知道每个实验部分在学期末都必须符合钟形曲线。所以必须有人不及格。我知道有人得了88分却没有得到C,没有通过,那真是太蠢了。实验讲师是第一次教这门课,他对评分非常松懈,他不知道他这样是在伤害我们,所以我学会了不这样做。


学习材料与课堂参与

会发布解决方案吗? 对于实验课,是的,我想我的工作人员说他们会把解决方案放在Canvas上,那是在截止之后。对于项目,不发布。对于考试,期中考试的书面问题我们可能会讨论书面问题解决方案的必要部分。我们也会让你单独查看它们,但我们不会在这里展示编码解决方案。不过,我们很乐意进行个别会议,和你一起复习选择题等。

讲座:就像我们说的,我们有讲义。你应该在课前阅读讲义,不是今天,问题不大,但从周四开始,在讲座前阅读讲义。观看讲座或参加直播讲座更好。准备好问题。你不必为了准备讲座而阅读和理解幻灯片的每一个字,但你不应该去听讲座2是栈和队列,然后说“好了,我准备好了”,那有点太少了。所以你不需要觉得:“哦,天哪,我没理解幻灯片43上的那个例子,我还没准备好。”不,你准备得很充分。但你希望处于一个愉快的中间状态:你希望在讲座前阅读幻灯片,知道你理解了什么,知道你不理解什么,并且知道如果讲座没有让你更清楚,你需要问什么问题。

讲座期间记笔记:手写,无论是用铅笔在纸上,还是用触控笔在平板上,都比打字能建立更好的长期记忆连接。研究证明了这一点。所以,手写东西能建立更好的长期记忆连接。对于期中考试,因为是线上,它将开卷、开笔记。但如果你手写一张小抄,你会比打字更好。你现在就可以开始准备你的小抄,即使是电子版的,写下“这些是我想要放在小抄上的东西”。因为:“嘿,我在讲座中很难记住这个,我想在期中考试时可能也很难记住。”打出那个列表,然后在考试前几天手写几遍,会有帮助。

并非我们在讲座中做的所有事情都会在幻灯片中。我可能会在平板上做额外的例子,可能会做额外的问题来让事情更清楚。如果你不跟着讲座学习,如果你说“我就在考试前一周狂看”,这不是一个好计划。你希望随着时间的推移理解这些内容。我们努力投入了大量精力来构建这门课程,以便讲座引导你进行实验,讲座和实验帮助你为项目做准备,而一切都有助于你为考试做准备。


成功策略与时间管理

不仅仅是过去,为了成功你需要做什么?你不想只是得个C,你想做得更好。那么,认真对待这门课。学生们认为这门课工作量很大。确实如此。我们知道。

你从这门课中得到什么,取决于你投入了什么。如果你在这门课上努力学习并认真对待,你会收获很多。这门课为你准备那些工作面试。

分配足够的时间:我不知道你每周需要多少小时,但至少,每小时的讲座,你需要在课外花几个小时。你将在编码上花费大量时间。

积极主动:这意味着不要等到最后一刻。当你不理解时,就提出问题。等到考试前一天才问那些问题。尽早获得关于项目的答案,不要在项目截止前一天才问。如果你在项目截止前还有问题,那没关系,但不要等到截止前一天才说“我不理解这个项目”,那你有点晚了。但如果你说:“嘿,我有一个测试用例一直搞不定,我需要一点帮助来理解哪里出错了。”当然,当然,我很乐意帮助,我们可以在“一对多”会议中做很多这样的事情。如果你有一个测试用例没通过,但不知道为什么,我可能“一对多”会议中的其他人也有同样的问题。你不需要“一对一”会议来获得帮助,比如“我找不到几个错误”或“我不知道为什么这个测试用例错了”。

优先处理事情:不要浪费时间。我在这里说的是,不要卡住。如果你卡住了,你知道现在是晚上10点,今天没有办公时间了,嘿,我有一个计划:好吧,我在项目上卡住了,但我会去处理一会儿我的实验,或者去阅读我明天的材料,或者去处理另一门课。所以,如果你确实卡住了,准备好备用计划,直到你能获得帮助。

练习手写代码:在正常学期,这是在纸上,现在意味着在你的IDE中。但手写代码,看看如何优化你的考试成绩,里面有一些关于如何不写很多额外代码,而是如何利用你已有的内容来为考试做准备的技巧。


学生经验分享与建议

好的,“计算关怀”,我得在这里切换一下输入,然后给你们播放一个来自一些学生反馈的小视频。我切换输入时可能会有一瞬间的无限递归。不,它根本不值得重复。我认为它名声不好,因为人们在上课时抱怨它,但当你回头看时,我认为它并不像人们说的那么糟糕,或者像我在做的时候想的那么糟糕。所以我想它在这方面有点奇怪。如果你有坏习惯,你会得到坏成绩,但我所有的朋友都喜欢这门课。学习……挺好的。我觉得它确实有那种奇怪的一面,但我不认为人们应该像大家说的那样认真对待它。调试肯定是,但这总是最令人沮丧的部分,也是最有益的部分。有一次,我有一个Makefile无法编译,因为我复制时用了空格而不是制表符。我花了整整一周才修好。我没有为项目的时间投入做好准备。也没有准备好向他人寻求帮助。对于项目一,我基本上决定我要自己解决这个问题。在Haven进行了几次10小时的会议后,我意识到那是不可能的。所以我想说,要习惯与……其他讲师或类似的人交谈,因为那是我学到最多的地方,不是通过……我的意思是,这门课的很多概念都相当难,向别人寻求帮助是可以的。

我在期中考试中表现不佳。然后……最终发生的是,当我们转向新内容时,我真的开始理解了,并且能够真正理解它,这真的很好。即使我在期中考试时不知道,我从期末考试中知道了。当我长时间努力的东西终于正确时,我觉得……这真的很令人振奋。比如,输入diffchecker.com或其他什么,把两个输出放进去,然后它说两个文件完全相同。这非常令人兴奋。有一次我写了代码,相当多,第一次尝试就编译了。我的意思是,它没有运行正确,但它编译了。哦,天哪,通常是午夜。所以我上281时还住在宿舍,没什么可庆祝的,但因为Pizza House营业到很晚,我通常会买一袋薯片和Hacitos,然后看Netflix放松一下。最好的餐点可能是Pizza House的深夜特惠,因为你可以得到芝士面包或小披萨,随便你,加上任何口味的奶昔,超级棒。不,不,我必须为这门课投入大量时间,比我本学期上的其他课都多。但同时,我知道我会使用……在这门课上学到的工具和东西,贯穿我的整个职业生涯。我实际上真的很喜欢这门课,所以就像……我认为很多人以某种方式害怕这门课,但你可以从中学到很多,我认为如果你以正确的态度去对待,比如每天想去上课学习,它实际上真的很有趣,你能学到很多。我想我肯定……现在更开心了,因为已经过了一段时间,因为我觉得我能够使用在那门课上学到的东西,并应用到其他课程中,有机会创建自己的数据结构,并以真正属于自己的创造性方式从头到尾完成一个项目。我认为这很好地转化到了我为其他课程,尤其是高级EECS课程需要做的事情上。我觉得现在上完281后,我真的知道计算机科学是什么了。我觉得以前我并不知道对我有什么期望。现在我知道了。

尽早开始项目,就在项目分配时开始。去办公时间。绝对是好的办公时间,不要害怕提问。写作……有意义。特别是……编写函数,以便当你后来分析代码时,你能分辨出哪些部分在拖慢速度,哪些部分在……去听讲座,观看讲座,至少浏览一下项目上的东西。尽早开始有很多好处,尤其是当你去办公时间时。我知道有时我去办公时间,在那里待了三个小时,只前进了两个位置。这很令人沮丧,但是……如果我早点开始,我可能只需要等五分钟。我知道每个人都说尽早开始项目,但当他们说尽早开始项目时,并不意味着第一天就开始编码。他们的意思是阅读规范,弄清楚你想如何设置你的类,如何设置你所有的代码。这不一定意味着你需要马上开始编码,但我觉得尽早开始项目意味着对接下来四周要做什么有一个概念。


项目规划与工具使用

好的,这是来自“计算关怀”小组的视频,所以281的工作人员没有参与制作这个视频。你可以查看他们网站的链接,我们只是观看了那里链接的视频。

就像他们说的,尽早开始项目并不意味着第一天就开始编码,而是意味着开始理解项目,开始规划项目。我以前有个学生,我想大概是两年前,在项目三中期右手被门夹伤,需要手术,而他是右撇子。那是他的惯用手和主要打字手。所以我们显然给了他项目三一些额外时间,他完成了。然后

002:栈、队列与优先队列抽象数据类型

在本节课中,我们将学习三种重要的抽象数据类型:栈、队列和优先队列。我们将探讨它们的基本概念、操作、实现方式以及在实际中的应用。

抽象数据类型与数据结构设计

上一节我们介绍了数据结构的基本概念。本节中,我们来看看如何设计一个数据结构。

抽象数据类型定义了数据结构的接口,即它支持哪些操作。而具体实现则涉及如何在内存中组织数据。

以下是设计数据结构时需要考虑的关键因素:

  • 内存组织方式:数据是连续存储(如数组、向量)还是通过指针连接(如链表)。
  • 运行时性能:执行操作所需的时间。
  • 空间占用:数据结构本身及其操作所需的内存。
  • 复杂度分析:数据规模(元素数量)如何影响时间和空间性能。

例如,在链表末尾插入一个值。假设链表有 n 个节点,我们需要遍历到末尾,然后创建新节点并链接。具体操作步骤可能包括:

  1. 跟随头指针。
  2. 检查当前节点指针是否为空。
  3. 如果不是,则移动到下一个节点,重复步骤2。
  4. 到达末尾后,创建新节点。
  5. 将值存入新节点。
  6. 将原末尾节点的指针指向新节点。

粗略计算,这大约需要 2n + 3 次操作。在复杂度分析中,我们关注其数量级,即 O(n)

选择数据结构时,我们需要考虑:

  • 正确的操作:容器是否支持所需的添加、删除和访问操作。
  • 正确的行为:例如,栈需要后进先出,队列需要先进先出。
  • 合理的复杂度权衡:最常执行的操作是否具有可接受的复杂度。
  • 内存开销:除了用户数据外,数据结构本身占用的额外内存(如指针)。

有时,我们需要限制接口以避免问题。例如,要维护一个始终排序的容器,就不能允许在任意位置插入元素。

上一节我们讨论了数据结构设计的一般原则。本节中,我们来看看第一种具体的数据结构:栈。

栈支持 后进先出 的顺序。最后放入的元素将最先被取出。

栈的主要操作如下(与C++标准库std::stack一致):

  • push:将元素添加到栈顶。
  • pop:移除栈顶元素(不返回其值)。
  • top:查看栈顶元素(不移除)。
  • size:返回栈中元素数量。
  • empty:检查栈是否为空。

栈的典型应用包括文本编辑器的撤销功能、网页浏览器的后退功能以及程序执行时的函数调用栈。

栈的实现

栈可以通过数组/向量或链表来实现。

使用数组/向量实现
我们维护一个底层数组(或向量)、一个指向栈底的指针(base)和一个指向栈顶下一个位置的指针(top)。还需要记录容量(capacity)。

  • push:如果栈已满(size == capacity),则需要分配更大的数组,复制数据,删除旧数组,然后添加新元素并移动top指针。否则,直接添加元素并移动top指针。
  • pop:只需将top指针减1。
  • top:返回top - 1位置的元素。
  • size:返回 top - base
  • empty:检查 base == top

在数组实现中,push 操作在需要扩容时是 O(n),否则是 O(1)。其他操作都是 O(1)

使用链表实现
我们维护一个头指针和一个记录大小的变量。

  • push:在链表头部插入新节点,并更新大小。复杂度为 O(1)
  • pop:删除链表头部节点,并更新大小。复杂度为 O(1)
  • top:访问头节点的数据。复杂度为 O(1)
  • size:返回存储的大小变量。复杂度为 O(1)
  • empty:检查头指针是否为空或大小是否为0。复杂度为 O(1)

对比
两种实现的时间复杂度相似。数组/向量实现的常数因子通常更低,内存开销可能更小(链表有指针开销)。C++标准库中的std::stack默认使用deque作为底层容器。

队列

了解了栈的后进先出特性后,我们来看看队列,它遵循先进先出的原则。

队列支持 先进先出 的顺序。最先放入的元素将最先被取出。

队列的主要操作如下(与C++标准库std::queue一致):

  • push:将元素添加到队列后端。
  • pop:移除队列前端的元素。
  • front:查看队列前端的元素。
  • size:返回队列中元素数量。
  • empty:检查队列是否为空。

队列的典型应用包括排队系统、任务调度和播放列表。

队列的实现

队列可以通过循环缓冲区或链表来实现。

使用循环缓冲区(基于数组)实现
我们维护一个数组、一个front索引(指向队首)、一个back索引(指向队尾下一个位置)、当前大小和容量。

  • push:如果队列已满,需要分配更大数组,将元素“展开”复制到新数组头部,然后添加新元素。否则,在back位置添加元素,并更新back索引(如果到达数组末端则绕回开头)。复杂度在扩容时为 O(n),否则为 O(1)
  • pop:移除front位置的元素,并更新front索引(绕回)。复杂度为 O(1)
  • front:返回front位置的元素。复杂度为 O(1)
  • size:返回存储的大小变量。复杂度为 O(1)
  • empty:检查大小是否为0。复杂度为 O(1)

使用链表实现
我们需要维护头指针、尾指针和大小变量。

  • push:在链表尾部添加新节点,并更新尾指针和大小。复杂度为 O(1)
  • pop:删除链表头部节点,并更新头指针和大小。复杂度为 O(1)
  • front:访问头节点的数据。复杂度为 O(1)
  • size:返回存储的大小变量。复杂度为 O(1)
  • empty:检查头指针是否为空或大小是否为0。复杂度为 O(1)

对比
两种实现的时间复杂度相似。循环缓冲区实现更节省内存,但逻辑稍复杂。C++标准库中的std::queue默认使用deque作为底层容器。注意,不能使用vector作为底层容器,因为vector没有高效的pop_front操作。

双端队列

栈和队列分别限制了在一端或两端进行操作。双端队列则更加灵活,允许在两端进行高效的添加和删除。

双端队列 是“双端队列”的缩写。它支持在队列的前端和后端进行添加和移除操作。

C++标准库中的std::deque主要操作如下(均为 O(1) 复杂度):

  • push_front, pop_front, front:在前端添加、移除和访问。
  • push_back, pop_back, back:在后端添加、移除和访问。
  • size, empty:获取大小和判断是否为空。
  • operator[]:通过索引随机访问元素(O(1))。

std::deque的内部实现通常是一个“块数组”:一个顶层的数组(如向量)存储指向固定大小内存块的指针。这种结构使得在两端添加元素和随机访问都非常高效。对于课程学习,重点是掌握其提供的操作和复杂度。

优先队列

前面介绍的数据结构都依赖于元素的插入顺序。优先队列则不同,元素的处理顺序由其优先级决定。

优先队列 中的元素具有优先级。出队时,总是优先级最高的元素先被移除(“最高”的定义可以通过比较函数定制)。

优先队列的主要操作:

  • push:插入一个元素。
  • pop:移除优先级最高的元素。
  • top:查看优先级最高的元素(只读,不可修改)。
  • size:返回元素数量。
  • empty:检查是否为空。

应用包括医院急诊分诊、任务调度(如最短作业优先)等。

优先队列的实现选择

有多种方式可以实现优先队列,其复杂度差异很大:

  1. 无序容器:插入简单(O(1)),但查找和删除最高优先级元素需要遍历(O(n))。
  2. 有序容器:保持容器始终有序。插入需要找到正确位置(可二分查找,O(log n))并可能移动元素(O(n)),故整体 O(n)。删除最高优先级元素(如在尾部)为 O(1)
  3. 二叉堆:一种树形数据结构。插入和删除均为 O(log n),查看顶部为 O(1)。这是最常用的高效实现。
  4. 固定优先级数量的桶数组:如果优先级数量固定且较少,可以使用一个数组,每个元素是一个队列(或链表)。所有操作都可达到 O(1)。但这属于特殊情况。

C++标准库中的std::priority_queue默认使用std::vector作为底层容器,并利用算法库中的堆操作函数来维护一个最大堆(优先级最高者为最大值)。可以通过自定义比较函数来创建最小堆或定义对象间的优先级。

应用示例:生成排列

为了综合运用栈和队列(或向量),我们来看一个生成所有排列的递归算法示例。

目标:给定n个元素,输出其所有可能的排列。

算法思路(使用两个容器)
我们使用两个容器:perm(向量,模拟栈,存储当前已排列的部分)和unused(队列,存储尚未使用的元素)。
通过递归,将元素从unused移动到perm,当unused为空时,perm中就是一个完整的排列,输出它。然后通过回溯,将元素放回unused,尝试其他可能性。

以下是核心递归函数的简化框架:

template <typename T>
void generatePermutations(vector<T>& perm, queue<T>& unused) {
    if (unused.empty()) {
        // 基础情况:输出排列 perm
        printVector(perm);
        return;
    }
    size_t numUnused = unused.size();
    for (size_t i = 0; i < numUnused; ++i) {
        // 从 unused 取一个元素加入 perm
        perm.push_back(unused.front());
        unused.pop();
        // 递归调用
        generatePermutations(perm, unused);
        // 回溯:将元素从 perm 放回 unused
        unused.push(perm.back());
        perm.pop_back();
    }
}

优化版本
更高效的实现是使用单个容器,并通过交换元素位置和标记“已排列”的边界来避免数据在容器间移动。C++标准库中也提供了 std::next_permutation 算法来生成下一个排列。

总结

本节课中我们一起学习了三种基础的抽象数据类型:

  1. :后进先出,支持在栈顶进行添加、移除和查看操作。
  2. 队列:先进先出,支持在队尾添加、队首移除和查看操作。
  3. 优先队列:按优先级出队,最高优先级元素先被处理。
    我们还简要介绍了双端队列,它支持在两端进行高效操作。

我们讨论了它们的概念、操作、使用场景,并分析了基于数组和链表的实现及其时间复杂度。理解这些容器的特性和适用场景,对于在解决实际问题时选择合适的数据结构至关重要。

003:复杂度分析与数学基础 🧮

在本节课中,我们将学习如何分析算法的复杂度,理解算法运行时间如何随输入规模增长而变化,并掌握使用大O记法描述这种趋势的方法。我们还将探讨一些数学基础,如对数性质,并了解平摊分析的概念。


复杂度分析概述

当我们谈论复杂度分析时,我们想要描述一个算法:对于给定的输入规模,该算法需要运行多长时间?需要多少步骤?在讨论步骤时,我们假设每一步都花费恒定的时间,然后关注需要多少步骤。我们真正想看到的是,随着输入规模的增长,步骤数量如何变化,其总体趋势是什么?衡量复杂度时,我们关注某种函数的增长率,并使用大O记法来描述这种趋势。这很重要,因为在开始实现之前,我们可以评估所提出的算法对不同规模输入的扩展能力,从而在做大量工作之前做出决策。

有时,在投入时间之前了解复杂度非常有用。例如,如果老板要求你运行数据来证明某个产品声明,而三周后仍在运行,那么如果三周前就能说明这个问题过于复杂、无法在合理时间内解决,情况会好得多。也许可以通过解决一个非常相似的问题或稍微放宽约束来大幅加快运行速度。我们将在学期末回到这个如何通过改变条件来大幅提速的大问题。


最佳、最坏与平均情况分析

分析算法时,我们可能需要考虑最佳情况分析、最坏情况分析和平均情况分析。假设我们有一个包含n个项目的数组,我们对其进行线性搜索。线性搜索的复杂度将取决于n,但记住,我们不能控制n。n将是某个任意的大数。我们可以控制的是数据的排列,或者我们搜索的目标。最佳情况是搜索的目标恰好是第一个元素。最坏情况是搜索的目标恰好是最后一个元素,或者根本不在数组中,需要检查所有元素。平均情况是,如果对所有可能性取平均,大约需要检查到中间位置。因此,当我们谈论分析时,通常会明确指出我们关注的是哪种情况:是最佳情况分析、平均情况分析还是其他。


影响运行时间的因素

影响运行时间的因素包括算法本身、实现细节(这取决于程序员的技能)、CPU速度、内存速度、编译器选项以及同时运行的其他程序。最后,显然还有输入规模,这是我们之前讨论过的。对于任何给定的程序,我们可以固定所有这些因素。我们可以尝试最小化同时运行的其他程序。如果在自己的计算机上运行,可以关闭浏览器和其他程序以获得最准确的结果。虽然每次不会完全相同,但会更接近。如果我们消除了其他所有因素,那么剩下的运行时间就是我们有时可以控制、有时无法控制的因素。有时我们只需要运行更大的输入。

如果我们假设增长率独立于CPU速度、内存、编译器等因素,那么改变输入规模对运行时间有什么影响?如果输入规模加倍,运行时间是否也会加倍?这取决于情况。从图表上看,蓝色线看起来是O(n)。对于那条线,如果输入规模加倍,时间应该加倍。但另一条较深的线看起来可能是O(n²)。对于那条线,如果输入规模加倍,我预计运行时间将变为原来的四倍。我们再次有了一个概念,但这里有点先有鸡还是先有蛋的问题,我们稍后会讨论大O记法,但首先如何衡量输入规模?


衡量输入规模与复杂度类别

我们可以查看位数,但这有点过于底层。可以查看整数或双精度浮点数的数量,它们可能是32位或64位。也可以查看项目的数量。但什么构成一个项目?一个整数?一个整数数组?字符串中的一个字符?整个字符串?因此,我们必须为特定问题选择我们的输入单位,即我们衡量的项目是什么,n代表什么?选择了项目是什么之后,我们可以说有n个项目,并选择某个函数f(n),表示算法在此规模n上运行所需的最大步骤数,这是一个方程。比方程更好的是复杂度类别,即O(f(n))。

让我们进一步看看复杂度类别。以下是一些常见的类别,虽然不是全部,但我们会经常遇到:

  • O(1):常数时间,例如将两个数字相加。
  • O(log n):对数时间,例如二分查找。
  • O(n):线性时间,例如打印数据结构中的所有内容或线性搜索。
  • O(n log n):我们将在几周后学习更高级的排序算法时看到。
  • O(n²):二次时间,今天稍后我们将看到一个例子。
  • 除此之外,还有多项式时间、指数时间(如cⁿ)、阶乘时间(如n!)等。指数时间包括像nⁿ这样的形式。还有双重指数时间,例如2(2n)。这些是常见的类别,最后一个(双重指数)并不常见,但其他都很常见。

我们有一个快速图表。线性很容易看出。对数增长比线性慢,常数时间非常快,指数时间在n=4时就超出了图表顶部。随着学期深入,当我们学习图算法时(大约在课程最后三周),我们可能会遇到依赖于两个变量的复杂度,例如E log V 或 E × V 或 V² log E。在大部分时间里,我们会先尝试使用一个变量,但最终学习图算法时,必须考虑具有两个不同变量的复杂度。


算法比较与常数因子

从分析问题到实现,我们希望进行某种算法比较。在担心实现之前,先预测我考虑的算法对于我预期的输入规模是否足够快?如果我们能获得更好的时间复杂度,并且如果我们不能随心所欲地控制n,那么拥有更快的算法就非常重要。常数因子在大O复杂度中并不重要。当n变得非常大时,我们关注趋势。当n非常小时,常数因子可能产生很大影响。例如,20n在n=1时比10ⁿ花费的时间更长。但当n=2时,线性突然快得多。因此,我们不太担心常数因子,因为我们不关心小的n,主要关注大的n。稍后我们将看看为什么可以忽略这些常数。


程序中的操作计数

以下是一个不完整的原始操作列表,我们经常执行的操作包括:

  • 变量赋值:更改一个值,计为1次操作。
  • 算术运算:如加、减、乘、除。
  • 比较:如 i < size。
  • 数组索引或指针解引用。
  • 函数调用(不计入数据)。
  • 函数返回(不计入返回值)。

我们将所有这些都计为恒定的时间量。因此,这些是我们最常见的操作,每个大约计为1步或1个单位时间。

当计算像for循环这样的结构时,记住for循环有三个部分:初始化(执行一次)、测试循环是否继续运行(测试n+1次,循环运行n次,结束时再测试一次)、以及更新(循环体每次运行时执行)。因此,运行整个循环的时间基本上是2n+2或2(n+1)。我们可以争论一下常数,但我们不担心这些,因为我们希望在长期中摆脱它们。

我们有一些代码,可以争论常数,但我们不担心这些,因为我们想摆脱它们。我们有一个循环,循环前有一个步骤(将sum初始化为0)。循环本身是1+1+2n。注意第4行缩进了一步,但如果循环运行n次,那么第4行将发生n次。然后是返回。我没有计算函数调用,但计算了返回。我们需要小心,如果计算了某人调用函数,又计算了被调用的函数,就会重复计算第1行。在这个例子中,我只想计算函数大括号内的代码。

我们有1步用于初始化sum,1+1+2n用于第3行,然后第4行是1步。你可能会说,等等,sum += i 不是一次算术运算和一次赋值吗?这不应该是1步,而是2步?我们可以这样做,那么就会变成4+4n而不是4+3n。这没关系。我们可以整天争论这些常数,但我们真正想做的是摆脱常数。因此,我们倾向于不在考试中出这样的问题,因为很容易差一,但在给出大O时仍然有正确的复杂度类别。我们不太喜欢在考试中出这样的问题,我们更关注大O复杂度,这将是O(n)。

我们可以用函数2进行同样的分析。记住,这个函数内部有两个循环,而另一个函数内部有一个循环。因此,在累加所有步骤时必须考虑到这一点。当我们这样做时,得到3n² + 7n + 6,其复杂度类别应该是O(n²)。这就是我们想做的,我们如何做到呢?哦,等等,抱歉,我们还有另一个例子,顺序有点乱。


对数时间示例

如果我们有一个对数时间的算法呢?我们无法在一张幻灯片中完全展示二分查找及其分解。我们这里有一个例子。第2行是1步。第3行,初始化会发生一次。测试会发生一次加上循环运行的次数。这就是为什么我们说大约是log n,循环将运行大约log n次,我们稍后会解释。如果循环运行大约log n次,那么测试和更新必须发生,这就是2的由来。然后第4行,1步还是2步并不重要,第6行是1步,所以我们得到log n。我们是如何得到大约log n的呢?

如果我一开始有n个项目,而n恰好等于2^k呢?如果n恰好是2的幂呢?那么循环将运行。我将从n开始。循环运行,i变成n/2,循环运行,i变成n/4,循环运行,最终当i变成1时,循环停止运行,i的最后一个值是1。如果n恰好是2的幂,那么2的幂就是k,循环体将恰好运行k次。如果n不是2的幂呢?让我们先举个例子,假设n是16,循环将在i为16、8、4、2时运行,所以它将运行4次。i变成1,然后停止。看,它运行了4次。这是正确的,log₂16 = 4。如果n恰好是2的幂,它将恰好运行log n次。如果n不是2的幂呢?假设n是20。如果n是20,我们将在n等于20、10、5时运行,然后由于整数除法变成2,然后变成1,我们不会运行。它仍然运行。log₂20大约是4.3,所以大约是log n。看起来循环运行的次数大约是log n的向下取整,但这仍然是大约log n,这给出了正确的复杂度类别O(log n)。

这里还有另外两个对数时间的例子。右下角是二分查找,与你习惯的略有不同,因为它使用指针。如果没有找到,我们返回空指针。我们有低指针和高指针,只要高指针大于或等于低指针,看起来这是一个包含范围。只要它们相等,我们继续查找。当它们相等时,意味着那里有一个元素。这是包含性的指针范围和一个要搜索的值。当还有地方可以查找时,我们找出中点,检查中点处的值是否是我们要找的,如果是,返回该指针。如果我们看到的比要找的大,将高指针下移到mid-1。否则,将低指针上移到mid+1。如果我们退出循环,即没有地方可查了,返回空指针。另一个例子,log₂函数在注释中说明,它通过重复除法找到数字n的二进制对数并向上取整。第5行进行重复除法,第6行计算在得到1之前必须除多少次。这给出了二进制对数。这是另外几个对数时间的例子。


大O记法定义

大O记法的定义如下:函数f(n)是O(g(n)),当且仅当存在常数c > 0和n₀ ≥ 0,使得对于所有n ≥ n₀,有f(n) ≤ c × g(n)。我们试图说明的是,g(n)是f(n)的一个上界。乘以某个常数的g(n)在超过某个固定点后总是大于等于f(n)。例如,问题是,n是O(n²)吗?为了证明这一点,我们必须找到那些常数。我们必须找到一个常数n₀,超过该点后,某个常数乘以n²大于n。如果我们能找到两条线相交的确切点,那个n₀就有效。我们不必找到它们相交的点,我们可以说n₀ = 2。超过该点后,我可以选择c = 1。因此,从n₀ = 2开始,1 × n² ≥ n。在2及以上,这是真的,在1及以上也是真的。但如果我选择常数等于1,我不能说n₀ = 0,否则我需要一个更大的常数,但常数是存在的。我们只需要找到任何一对有效的常数,不必找到完美的一对,找到任何使其成立的一对常数就足够了。

还有另一种方法。如果当n趋于无穷时,f(n)/g(n)的极限等于某个常数d,且d < ∞,那么f(n)是O(g(n))。这是一个充分但不必要条件。这意味着如果这些条件成立,那么极限存在。例如,我们问log₂(n)是否有上界O(2n)?我们取f(n) = log n,g(n) = 2n,当n趋于无穷时取极限,得到∞/∞。根据洛必达法则,我们得到1/(2n),当n趋于无穷时极限为0,所以我们有一个常数d=0,小于无穷,因此通过这种方法证明了。下一个问题是,sin(n)/100是否有上界O(100)?我们尝试使用这个充分但不必要条件,但条件不成立。这种方法没有给出答案。但这是真的。基本上,如果我取一个在这里的波浪线,并用值100作为上界,那是真的。所以图形方法有效。我可以取n₀=0,c=1,这种方法成立。这种方法总是有效。而极限方法有时有效,有时无法给出答案。如果极限不存在,你什么也没学到。


简化复杂度分析

我们想从之前的函数2的方程中得到f(n) = 3n² + 7n + 42,我们想知道它是否是O(n²),即f(n)是否有上界n²?我们要么使用极限方法,极限方法有效,极限等于3,小于无穷。因此,右边的极限方法有效。然而,图形方法也有效。在图形方法中,这两条图有一个交点,但我不知道确切是什么,从图形上看大约在3和4之间,可能在3.7左右。谁在乎呢?我可以取一个常数,然后说,n₀=4对我来说就足够了。我不必知道它们确切在哪里相交,我只需要有一个点,超过该点后,某个常数乘以g(n)总是大于f(n)。如果我选择n₀=4,选择常数c=5,那么对于任何n≥4,5n² ≥ 3n² + 7n + 42。如果我找到了一个有效的n₀和c,那么我就证明了3n² + 7n + 42是O(n²)。这告诉我们,是的,我们可以去掉常数。我们不仅可以去掉常数,还可以去掉低阶项。如果我有像n² + n + 1这样的东西,我可以去掉低阶项,去掉n,去掉1,最高阶项告诉我复杂度类别是什么。此外,我们已经看到我们可以去掉前面的常数。所以对于3n² + 7n + 42,我不仅可以去掉7n和42,还可以去掉最高阶项前面的3。这给了我复杂度类别。这是获得复杂度类别的另一种简单方法,而不是寻找n₀和c。既然我们知道为什么有效,我们可以采取这种捷径,消除低阶项,消除最高阶项的常数,然后得到我们的复杂度类别。


对数性质

幻灯片上有一堆对数性质。其中一些非常有用。缺少等号的那个非常有用。它说的是logₐ(x) = log(x) / log(a) = ln(x) / ln(a)。当你看到没有底数的log时,可以理解为任何底数。例如,我想计算log₂(1000),但我的计算器上没有log₂按钮。那么我可以取log₁₀(1000)除以log₁₀(2),这等价于ln(1000)除以ln(2)。这大约是9.9左右,大约等于10。2¹⁰ = 1024,log₂(1024) = 10,log₂(1000)应该略小于10。如果你计算器上没有某个对数按钮,这个关系非常有用。

另一个非常有用的性质是logₐ(xʳ) = r × logₐ(x)。所以如果我们有log(n²),我可以说log(n²) = 2 × log(n)。我可以将n的指数从对数中取出,放在前面。


复杂度关系判断

幻灯片顶部有一些性质,左边有一些问题需要你判断真假,还有一个寻找f(n)和g(n)使得两者都不是对方的上界的问题。假设你还没看下一页,花一分钟思考一下,然后我们回来讨论。

对于这些关系,判断真假:

  1. 10¹⁰⁰ 有常数上界。当然,如果我选择n₀=0,并选择比它大的常数,比如10¹⁰¹,那成立。所以这个为真。
  2. 3n⁴ + 45n³ 有上界O(n⁴)。我们去掉了低阶项,去掉了高阶项的常数,为真。
  3. 3ⁿ 有上界O(2ⁿ)。3² 有常数倍2²的上界。3² 有10×2²的上界。如果是立方,3³=27,2³=8,乘以10仍然可以。但如果我取更大的n,比如n=10,我需要一个大于10的常数。如果n=20,我需要更大的常数。每次增大n,可能需要更大的常数。如果增大n需要更大的常数,那么它就不再是常数了,所以这个为假。
  4. 2ⁿ 有上界O(3ⁿ)。只要n₀=1,超过n₀=1,3ⁿ 总是大于2ⁿ。所以这个为真,我只需要找到n₀=1和c=1,就成立。
  5. 45 log n + 45n 是O(log n)。这涉及到不能只保留第一项,必须去掉除最高阶项外的所有项。如果有人乱序书写,不能被这种诡计欺骗。我们划掉低阶项。45n 有对数上界吗?没有,所以为假。
  6. log(n²) 有上界O(log n)。这就是为什么规则在上面。规则说我们可以将指数移到对数外。所以log(n²)等于2 log n,是O(log n)。是的,我可以去掉最高阶项的常数,这个为真。
  7. (log n)² 有上界O(log n)。我觉得不是。我们如何得到?上面的公式不是好的解决方案。我们真正需要的是一个简单的替换。假设x = log n。那么我问的是x² 是否等于O(x),x² 是否有上界x?不,这显然是假的。所以一个简单的变量替换让这更清晰。
  8. 最后一个问题,找一个f(n)和g(n),使得两者都不是对方的上界。这让你思考,我能得到什么反例?如果一个比另一个增长更快,那么它将是上界。所以我必须有一个不是单调递增的东西。幻灯片上给出的例子是余弦和正弦。两者都不是对方的上界,因为在任何给定点,它们要么相等,要么在这个区域正弦更高,在那个区域余弦更高,它们不断切换。我见过有人用反正切解决这个问题,我认为反正切和几乎任何东西,因为反正切会重复那种垂直摆动,没有东西能界定它,因为在某些区域它较低,在某些区域较高,在某些区域基本上达到无穷。所以像三角函数这样的东西是很好的反例。

大O、大Ω与大Θ

我们还有一页幻灯片有一些图形公式错误。大O、大Ω、大Θ。大O是我们的渐近上界,定义和我们刚才给出的一样。数学上,我们可以说这些常数存在,它们是可数数或实数集的某个子集。对于所有n ≥ n₀,f(n) ≤ c × g(n)。然后底部有一些例子。我说f1(n) = 2n + 1。我们想知道它的大O是什么。大O(n),大O(n²),大O(n³)。另一个上界可能是O(n log n)。然后是f2,f2 = n² + n + 5。上界是n²和n³等。阶乘是另一个例子。

让我们转到另一端,大Ω。大Ω是渐近下界。定义说f(n) = Ω(g(n))当且仅当仍然存在整数n₀(n₀不必是整数,可以是实数,但最容易选择整数)和实数c,使得对于所有n ≥ n₀,f(n) ≥ c × g(n)。这是一个下界。我们说存在n₀和c,使得对于所有n ≥ n₀,f(n) ≥ c × g(n)。渐近下界意味着只要我们超过某个n₀,这个函数就是一个下界。

例如,如果f1(n) = 2n + 1,那么大Ω的例子是Ω(n),Ω(1)。我们还可以再加一个,Ω(log n)是2n + 1的下界,因为它在渐近上更小。对于f2(n) = n² + n + 5,Ω(n²),Ω(n),Ω(1),Ω(log n),Ω(n log n)。这是我们常见复杂度类别列表中的所有。

大Θ在中间,是渐近紧确界。这表示f(n) = Θ(g(n))当且仅当存在整数n₀和实数常数c₁和c₂,使得对于所有n ≥ n₀,c₁ × g(n) ≤ f(n) ≤ c₂ × g(n)。我们只有一个函数g(n),但一个常数乘以g(n)使其大于等于f(n),另一个常数乘以g(n)使其小于等于f(n)。因此,根据常数的不同,它同时是上界和下界。

在底部,如果f1(n) = 2n + 1,那么唯一有效的复杂度类别是Θ(n)。如果f2(n) = n² + 2n + 5,那么Θ(n²)是唯一渐近紧确界。关于大O和大Ω,我们不想偷懒。当我问你这段代码的大Ω是什么时,你不想只是说,哦,是Ω(1),因为这是真的,它不能比常数快。或者大O是什么,可能是O(n(nn)),因为我想不出更大的了,所以可能是真的。虽然技术上正确,但这些答案有点偷懒,我们不会让你蒙混过关。在考试中,我们通常会要求大Θ,或者要求最紧的可能大O。谈论大O仍然是合理的,因为如果我说线性搜索,我想要线性搜索的复杂度。如果我说我想要大Θ,你不能只给我一个线性搜索的大Θ,因为最坏情况和最佳情况不同。如果我要求线性搜索的最坏情况大Θ,最坏情况是Θ(n),但最佳情况是Θ(1)。但它总是O(n),这是最紧的可能上界。所以我不在乎,如果我说线性搜索是O(n),那么我不必具体说明最佳、最坏、平均。但如果谈论大Θ,我必须小心谈论我要求的是什么的大Θ,是最坏情况的大Θ还是最佳情况的大Θ。在实验和考试中,我们会尽量具体说明我们要求的是什么,我们要么要求最坏情况的大Θ或平均情况的大Θ,要么说最紧的可能大O。


平摊复杂度

改变部分,平摊复杂度,我们上次提到过。我们上周四提到过。平摊复杂度是一种最坏情况复杂度。它用于工作与时间分布有尖峰的情况,我将在下一页绘制。我们稍后有一个例子。平摊复杂度是指时间分布在不同的地方变化很大的情况,有时非常昂贵,但大多数时候是小的,通常是恒定的开销。对于平摊复杂度,我们对一系列操作取平均。如果这些操作一个接一个发生,该序列的平均值是多少?这与平均情况不同,在平均情况下,我们看如果这个发生或那个发生或其他发生,哪一个是最常见的平均值?如果只发生其中一个,哪一个能代表平均?所以平均情况是,哪一个例子是所有其他例子的良好平均代表?平摊是如果这些一个接一个发生,该事件序列的平均值是多少?这就是为什么我们称之为平摊成本,因为它是随时间、随序列平均的成本。当我们讨论STL向量、向量如何增长以及哈希表如何增长时,这将很重要。

现在看一个平摊复杂度的例子。假设你有一个手机账单,你支付无限通话和短信,为了简化计算,假设你每月支付100美元。然后每次通话和短信没有额外费用。你每月支付一次固定费用,然后就可以打电话和发短信了。假设你这个月打了1000个电话和发了短信。那么每个电话或短信的平均成本是10美分。但钱从你口袋流出的速度是非常不均衡的。如果我们看这个图表,假设是一月的第一天,1月1日。很多钱离开我的口袋,100美元离开我的口袋。但在1月2日、3日、4日、5日,0美元离开我的口袋。所以有很多天没有成本,然后2月1日到来,100美元离开我的口袋。这就是我们需要平摊分析的地方。钱离开我口袋的速度非常不均衡,但我们可以将其视为平均。如果我取那个月离开我口袋的总金额100美元,除以我拨打和发送的电话和短信数量,每个电话或短信的平摊成本是恒定的。但从另一个角度看,第一个短信花了我100美元,其他都是免费的。我不这么觉得,我觉得我每个花了大约10美分,这就是平摊成本。当我们看向量push操作时,会再次回到这个问题。

当我们向向量push时,如果需要,我们会分配一个更大的数组,将数据复制到其中,然后添加新元素。让我们看看这里的分析。我要告诉你平摊成本是O(1),但让我们看看它是如何发生的,我们是如何得到这个结果的。假设向量现在有n个元素,现在是n/n。如果现在是n/n,我想push一个东西进去,我必须增长它。一旦我完成增长,我可以完成那个push并做更多push。所以对于一次大小加倍,我可以完成n次push。我有一点成本,我必须分配新的内存块。然后我复制所有东西,所以这里的常数是向量大小加倍的成本。有一个常数,我分配新的内存块,然后复制n个元素,那是Θ(n),然后我有另一个小的常数成本,将指针从旧内存块更改为新内存块,删除旧内存块,这些都是常数。所以都在这里,更改指针和删除都是常数,Θ(n)是复制现有的n个元素。现在大小已经加倍,我可以完成n次push,每次push都有常数成本。

如果我查看这个图表,我基本上处于n/n。我支付一个大的成本,支付成本n。然后我支付成本1来完成一堆push。我可以完成多少次push?我可以完成n次push。然后现在我达到了2n/2n的点。所以我对从刚需要加倍之前到刚需要再次加倍之前进行平摊分析。当我达到2n/2n时,我有另一个大的常数成本2n。然后我可以做2n次,每次成本为1。如果我分析下一个窗口,将是2n + 2n除以2n,是O(1)。所以无论我是对从n/n到刚达到2n/2n之前的窗口进行分析,还是对从刚达到2n/2n之前到刚达到4n/4n之前的窗口进行分析,工作量都是Θ(n),但我做了n次操作,因此push的平摊成本是Θ(1)。所以其中一次push非常昂贵,第一次,之后很多次都很便宜。但如果我打算做一堆push,那么push的平摊成本是常数。如果你查看像cppreference或C++标准,查找标准向量,查找push操作,查看复杂度,它会说平摊O(1)或平摊Θ(1)。这就是为什么我们通过加倍来增长。如果我们以常数大小增长会发生什么?假设我增长,假设比原来大10,好吧,我满了,所以我以常数增加大小。我必须复制n个元素,我复制n个元素,然后我可以做常数次push。我取n + 常数除以常数,是Θ(n)。这就是为什么向量不是通过增加大小来增长,而是通过乘以大小来增长。如果我通过乘以大小来增长,平摊成本是Θ(1)。如果我通过增加大小来增长,我得到Θ(n)。这里有一个小技巧,当大小是0/0时,我必须增加到1,当我在0/0时不能加倍,所以有一个点是空的,我增加到1,我们通过增加大小1来增长。但一旦我们达到大于零的任何值,它总是通过加倍增长。我看过很多STL向量的实现,很多版本都是加倍,我见过一个版本乘以1.5。我想他们试图通过乘以1.5来节省一点内存。它仍然给你平摊成本Θ(1)。我认为他们试图节省一点内存,而不是,如果你不小心,如果你不调整大小或保留,你增长到1024/1024,然后你push一个东西,你会有几乎50%的浪费。你将是1025/2048,大约49%的空间浪费。而如果你以1.5增长,你永远不会浪费超过三分之一的总空间。但一般来说,我见过的大多数STL向量实现都是通过加倍增长。


思考练习:寻找较重的台球

现在我们最后有一个思考练习。假设有一堆台球,这不是COVID时代,我们可以去酒吧打台球。我击球,但有一个球似乎没有移动我预期的那么远。我打了很多台球,我认为其中一个球比其他球重。我没有带秤去酒吧,但我从服务员那里偷了一个托盘。我把一支铅笔放在托盘一端下面,在两端各放一个大球,使其精确居中,然后我可以在一端放几个球,在另一端放几个球,称重它们。我有一个天平。我可以称一组球与另一组球,或者一个对一个,我可以发现哪边重。我要这样做,我想让你描述一个O(n²)算法。O(n²)算法对我来说很难,太复杂了,似乎很浪费时间。它有一个优势,我们稍后会讨论,所以想出一个O(n²)算法,想出一个O(n)算法,想出一个O(log n)算法和另一个不同的O(log n)算法。花几分钟思考一下,也可以在聊天窗口输入,我们一会儿回来。

我们将按顺序进行,按照幻灯片上列出的顺序。O(n²)算法是什么?如果我取一个球,称它与其他n-1个球,取第二个球,称它与其他n-2个球,等等。那基本上是组合数C(n,2)。如果我不重复,我不比较1和3然后3和1。我不重复比较任何球,也不比较自己,所以我得到n×(n-1)/2,是O(n²)。即使我得到一个不平衡,我也继续。我记下这些,比如1和2相等,1和3相等,1和4,4更重,1和5相等。我做完所有,然后回头看我的表格,决定哪个有问题。O(n²)算法的轻微优势是,如果我不太正确,如果有两个球重量不同,这会找到所有重量不同的球。所以如果有两个或三个球重量不同,这会找到它们所有,而我的其他方法可能提前停止。另一个方法可能在我找到一个重量不同的球时提前停止。

O(n)算法呢?我们可以做类似于O(n²)的,我选一个球,把它留在左边,然后把其他n-1个球一个一个放在另一边。所以1对2,1对3,1对4,但我从不进行5对7或8对9。我只留一个球在那里,一个一个地与其他每个球比较。我做n-1次比较,如果有一个重量不同,我会找到它。

对数算法呢?我看到很多输入,我想我看到了对数算法。如果我称一半的球。我把一半放在每一边。如果一边下沉,哦,那边有更重的球,所以我取下沉的那一边,分成两堆,再分成两堆,再分成两堆,等等。我一直分成两半,直到得到单个球。这个方法有一个小问题。大多数台球游戏开始时球数是奇数,比如9或15。所以我有时必须留一个球出来。如果我以15开始,我放一个球 aside,7和7。如果7和7重量相同,那么我留出的那个球一定是不同的那个,所以仍然有效。我只需要跟踪,如果两边相等,那么不同的球是我留出的那个。如果有两个重量不同,这个技术可能会失败。如果我做1到7对8到14,每个都有一个重球。那么,两边相等,一定是15号球,但实际不是,是2号和13号球。所以这只有在我是正确的,并且真的只有一个球比其他球重时才有效。如果一边下沉,我说,1到7更重。现在我把1到7分成1到3,4到6,留出7号球,称重它们。如果它们相等,一定是我留出的那个。

另一个不同的对数算法呢?我想要O(log n),但想要不同的算法。如果我取所有球,比如标准台球架上的15个球,我分成三堆。如果我分成三个相等的组,称其中两组。如果这两组重量相同,一定在我留出的那组,我把那组分成三份。是的。如果重量不同,我就知道哪组更重,我取那组并分成三份。这有效。那么log₄呢?我能分成四堆吗?问题是,对于四堆,一次称重可能什么也告诉我。一次称重告诉我这两堆相同,一定是被我留出的两堆之一。如果有一个不同的球呢?如果我分成五堆,每堆三个,剩下三个。我真的什么也没学到。我学到它不在这两堆三个中,是另外三堆三个中的一堆,第一堆三个,第二堆三个和剩下的。所以用天平分成四堆或更多堆不行。所以分成两堆有效,分成三堆有效,四堆或更多堆不行。

最后一件事,这两个对数解,它们是一个的上界吗?log₂是log₃的上界吗?log₃是log₂的上界吗?是或不是。我们可以回到我们的一个关系。记住我们有logₐ(x) = log任何底(x) / log任何底(a)。那么我可以说logₓ(x) = lg(a) × logₐ(x)。所以从一个底数转换到另一个底数相差一个常数倍。从一个底数转换到另一个底数相差一个常数倍,因为底数a是常数。所以我们可以用这个规则看到,所有对数类别在理论上是渐近相同的,它们都是对数类别。如果我做一个真实的实现,当我们做真实实现时,常数可能很重要。所以我期望如果n真的非常大,log₃会运行得快一点,比log₂方法快一个常数因子。但这个常数因子是否重要?有时是,有时不是。如果n足够大,可能是。这是你在项目中可能遇到的事情之一,你可能拥有正确的复杂度类别,但你做了一些不需要的操作,项目一的讲座视频中有一个例子。我说当我们处理命令行时,我们必须知道命令行说的是stack还是queue。但我不想将其保存为字符串。我不想后来检查一百万次我的容器类型是否等于字符串"stack"。因为如果我的容器类型是字符串"stack",比较"stack"和"stack",它实际上首先检查长度,哦,它们长度相等,所以可能相等。然后我比较第一个字符,然后递增,比较下一个字符,递增,比较下一个字符,递增,比较字符,递增,比较字符,递增,我想我然后发现我到了末尾。我做了大约18次操作,而不是一次,那个常数可能产生影响。这就是为什么当我们处理命令行时,我们必须处理选项,但我们想将其保存为布尔值,如stack模式真或假,后来我不想做if-else if-else,因为后来我知道是其中之一,我不意味着if-else if-else。在我运行搜索时,if-else。


总结

本节课中,我们一起学习了复杂度分析的基本概念,包括如何使用大O、大Ω和大Θ记法描述算法的渐近行为。我们探讨了最佳、最坏和平均情况分析,以及影响运行时间的各种因素。我们还学习了如何对程序中的基本操作进行计数,并理解了常数因子和低阶项在复杂度分析中的处理方式。通过对数性质的复习,我们能够更好地分析涉及对数的算法。最后,我们介绍了平摊复杂度的概念,并通过向量增长的例子理解了其应用。这些知识将帮助我们预测算法性能,并在实现前做出更明智的选择。

004:性能测量与分析工具 📊

在本节课中,我们将学习如何测量程序的运行时性能,并介绍几种实用的分析工具。我们将探讨如何在代码内部进行计时,如何使用外部工具(如 timeperfValgrind)来评估程序效率,并理解如何解读这些工具的输出结果。此外,我们还将通过解决递归关系来分析算法的时间复杂度。


概述

性能分析是算法设计和优化的关键步骤。仅仅通过理论分析(如大O表示法)可能不足以揭示代码中的实际性能瓶颈或隐藏错误。因此,我们需要结合实验测量来验证理论预测,并找出改进代码的方法。

上一节我们介绍了时间复杂度的概念和计算方法。本节中,我们来看看如何通过编程和外部工具来实际测量程序的运行时间。


程序内计时 ⏱️

我们可以在程序中添加代码来测量特定部分的执行时间。在C++中,可以使用 <chrono> 库来实现一个简单的计时器类。

以下是一个计时器类的示例代码:

#include <chrono>
#include <iostream>

class Timer {
private:
    std::chrono::time_point<std::chrono::high_resolution_clock> start_time;
    double elapsed_time;

public:
    Timer() : elapsed_time(0) {}

    void start() {
        start_time = std::chrono::high_resolution_clock::now();
    }

    void stop() {
        auto end_time = std::chrono::high_resolution_clock::now();
        elapsed_time += std::chrono::duration<double>(end_time - start_time).count();
    }

    void reset() {
        elapsed_time = 0;
    }

    double seconds() const {
        return elapsed_time;
    }
};

使用该计时器的示例:

int main() {
    Timer timer;
    timer.start();
    // 执行任务1
    timer.stop();
    std::cout << "任务1耗时: " << timer.seconds() << " 秒" << std::endl;

    timer.reset();
    timer.start();
    // 执行任务2
    timer.stop();
    std::cout << "任务2耗时: " << timer.seconds() << " 秒" << std::endl;

    return 0;
}

需要注意的是,在循环内部频繁启动和停止计时器会引入显著的开销,因此应确保计时操作不会影响被测代码的性能。


外部测量工具 🛠️

除了在代码内计时,我们还可以使用操作系统提供的外部工具来测量程序性能。

time 命令

time 命令可以测量程序的执行时间,并提供以下信息:

  • 用户时间:程序在用户模式下运行的时间。
  • 系统时间:操作系统代表程序执行系统调用所花费的时间。
  • 实际时间:从程序启动到结束所经过的“墙上时钟”时间。

使用示例:

/usr/bin/time ./my_program arg1 arg2

输出示例:

0.26 user 0.65 system 0:00.92 elapsed 99% CPU

perf 性能分析器

perf 是一个强大的性能分析工具,可以统计程序中各函数的执行时间占比,帮助定位性能瓶颈。

使用示例:

perf stat ./my_program

在实验室环境中,可能需要先加载特定模块:

module load gcc/6.2.0

perf 对于优化代码非常有用,但需要足够大的输入数据才能获得有意义的采样结果。


内存分析工具 🧠

Valgrind

Valgrind 是一个内存调试和性能分析工具,主要用于检测内存泄漏和未初始化内存的使用。

使用示例:

valgrind --leak-check=full --show-leak-kinds=all ./my_program_debug

为了获得行号信息,需要使用调试版本的可执行文件(使用 -g 编译选项)。

Valgrind 的输出会指出内存泄漏发生的位置,帮助开发者修复问题。


实验数据与理论预测的对比 📈

在测量程序性能时,可能会遇到实验数据与理论预测不符的情况。以下是几种常见原因及解决方法:

以下是可能的原因及解决方法:

  1. 实验数据比预测差

    • 检查复杂度分析是否正确。
    • 检查代码是否存在错误(如不必要的拷贝)。
    • 确保使用了正确的编译优化选项(如 -O3)。
  2. 实验数据比预测好

    • 可能未测试最坏情况输入。
    • 复杂度分析可能过于悲观。
    • 输入规模可能不足以体现渐进行为。
  3. 数据匹配但程序过慢

    • 可能存在性能“小问题”,如使用低效的库函数或I/O操作。
    • 检查是否启用了编译器优化。

递归关系求解 🔍

递归算法的时间复杂度通常通过递归关系来描述和求解。我们通过“代入法”来解递归式。

线性递归示例

考虑以下递归关系(如线性搜索的递归版本):

T(n) = T(n-1) + C1, 当 n > 0
T(0) = C0

求解过程:

  1. 反复代入展开:
    T(n) = T(n-1) + C1
    = [T(n-2) + C1] + C1 = T(n-2) + 2*C1
    = [T(n-3) + C1] + 2*C1 = T(n-3) + 3*C1
    = ...
  2. 重复 k 次后:T(n) = T(n-k) + k * C1
  3. k = n 时,到达基例:T(n) = T(0) + n * C1 = C0 + n * C1
  4. 因此,时间复杂度为 Θ(n)

对数递归示例

考虑以下递归关系(如二分搜索):

T(n) = T(n/2) + C1, 当 n > 1
T(1) = C0

求解过程(假设 n 是2的幂):

  1. 代入展开:
    T(n) = T(n/2) + C1
    = [T(n/4) + C1] + C1 = T(n/4) + 2*C1
    = [T(n/8) + C1] + 2*C1 = T(n/8) + 3*C1
    = ...
  2. 重复 k 次后:T(n) = T(n / 2^k) + k * C1
  3. n / 2^k = 1,即 k = log₂n 时,到达基例:T(n) = T(1) + log₂n * C1 = C0 + C1 * log₂n
  4. 因此,时间复杂度为 Θ(log n)

对于更复杂的递归式(如分治算法),我们将使用主定理来求解,这将在下一讲中介绍。


总结

本节课中我们一起学习了多种测量和分析程序性能的方法:

  1. 我们学会了如何在C++程序内部使用计时器类来测量代码段的执行时间。
  2. 我们介绍了外部工具 timeperf 的使用,它们可以测量整体运行时间和分析性能瓶颈。
  3. 我们使用 Valgrind 来检测内存泄漏和其他内存错误。
  4. 我们讨论了实验数据与理论预测出现差异时的可能原因和排查思路。
  5. 我们通过代入法练习了求解简单递归关系,以确定递归算法的时间复杂度。

掌握这些工具和技术对于开发高效、可靠的程序至关重要。在接下来的课程中,我们将继续深入算法分析,并学习更强大的分析工具(如主定理)。

005:递归与主定理

在本节课中,我们将要学习递归的概念、递归函数的编写方式,以及如何分析递归算法的时间复杂度。我们还将介绍一个强大的工具——主定理,它可以帮助我们快速求解特定形式的递归关系式。

递归概述

上一节我们介绍了算法分析的基础。本节中,我们来看看一种重要的编程范式:递归。

一个递归函数是调用自身的函数。编写递归函数时,必须满足几个基本条件:

  • 必须至少有一个基准情形,以确保递归能够终止。
  • 必须至少有一个递归情形,即函数调用自身。
  • 通常,每次递归调用处理的问题规模应该比上一次调用更小或更简单。

递归可以分为单递归和多递归:

  • 单递归:函数中只包含一次递归调用。例如,可用于遍历链表。
  • 多递归:函数中包含多次递归调用。例如,可用于遍历二叉树。

递归示例:计算幂

让我们通过计算 xn 次幂(x^n)的例子来理解递归。我们希望找到比线性时间 O(n) 更高效的算法。

一个高效的递归定义如下:

  • 基准情形:如果 n == 0,则 x^n = 1
  • 递归情形
    • 如果 n 是偶数:x^n = (x^(n/2))^2
    • 如果 n 是奇数:x^n = x * (x^((n-1)/2))^2

关键在于,我们只计算一次 x^(n/2),将其结果存储,然后进行平方或乘以 x 的操作,而不是进行两次相同的递归调用。这能将时间复杂度从 O(n) 降低到 O(log n)

对应的递归关系式为:
T(n) = T(n/2) + Θ(1)

尾递归

在深入分析递归复杂度之前,我们需要理解尾递归的概念,因为它会影响算法的空间效率。

当一个函数中所有递归调用都是该函数中最后执行的操作时,我们称其为尾递归。这意味着递归调用返回后,当前函数没有其他工作要做。

编译器可以优化尾递归函数,使其重用当前的栈帧,而不是为每次递归调用创建新的栈帧。这使得尾递归的空间复杂度从 O(n) 降低到 O(1),与迭代循环等效。

以下是一个计算阶乘的非尾递归与尾递归版本对比:

非尾递归版本(空间复杂度 O(n))

int factorial(int n) {
    if (n == 0) return 1;
    return n * factorial(n - 1); // 递归调用后还有乘法操作
}

尾递归版本(空间复杂度 O(1),经编译器优化后)

int factorial_tail(int n, int result = 1) {
    if (n == 0) return result;
    return factorial_tail(n - 1, n * result); // 递归调用是最后一步操作
}

在尾递归版本中,我们将中间结果 result 作为参数传递,使得递归调用后无需进行额外计算。

递归关系式与求解方法

上一节我们介绍了递归的概念和尾递归优化。本节中,我们来看看如何形式化地分析递归算法的时间复杂度。

我们使用递归关系式来描述递归算法的运行时间。例如:

  • 线性递归(如遍历链表):T(n) = T(n-1) + Θ(1) 解得 T(n) = Θ(n)
  • 对数递归(如二分查找):T(n) = T(n/2) + Θ(1) 解得 T(n) = Θ(log n)
  • 二叉树遍历:T(n) = 2T(n/2) + Θ(1) 解得 T(n) = Θ(n)

求解递归关系式主要有两种方法:

  1. 代入法:通过反复展开递归式,观察规律,然后证明其解。
  2. 主定理:一种“食谱”式的方法,适用于特定形式的递归式,可以快速求解。

对于更复杂的递归式,例如归并排序 T(n) = 2T(n/2) + Θ(n),使用代入法求解过程繁琐。这时,主定理就显示出其威力。

主定理

主定理提供了一种快速求解形如下式的递归关系式的方法:
T(n) = aT(n/b) + f(n)
其中:

  • a ≥ 1,表示子问题的数量。
  • b > 1,表示问题规模缩小的倍数。
  • f(n) 是一个渐近正函数,表示分解和合并步骤的成本。

主定理将解分为三种情况,取决于递归部分 aT(n/b) 与非递归部分 f(n) 的增长率对比。我们定义 c = log_b(a)

以下是主定理的三种情况:

  1. 如果 f(n) = O(n^(c-ε))(对于某个常数 ε > 0),即非递归部分增长慢于递归部分,则 T(n) = Θ(n^c)
  2. 如果 f(n) = Θ(n^c * log^k n),即非递归部分与递归部分增长速率相当,则 T(n) = Θ(n^c * log^(k+1) n)。最常见的是 k=0,即 T(n) = Θ(n^c * log n)
  3. 如果 f(n) = Ω(n^(c+ε))(对于某个常数 ε > 0),且满足正则条件 af(n/b) ≤ kf(n)(对于某个常数 k < 1 和所有足够大的 n),即非递归部分增长快于递归部分,则 T(n) = Θ(f(n))

简单记忆:比较 f(n)n^(log_b a)

  • f(n) 更小,属于情况一,解由递归部分主导。
  • f(n) 相当,属于情况二,解为两者相乘并多一个 log n
  • f(n) 更大,属于情况三,解由非递归部分主导。

主定理应用示例

让我们通过几个例子来应用主定理:

示例 1T(n) = 3T(n/2) + Θ(n)

  • a = 3, b = 2, f(n) = Θ(n)
  • 计算 c = log_b(a) = log_2(3) ≈ 1.585
  • 比较 f(n) = Θ(n^1)Θ(n^1.585)。因为 n^1 增长慢于 n^1.585,属于情况一。
  • 因此,T(n) = Θ(n^(log_2 3))

示例 2T(n) = 2T(n/4) + Θ(√n)

  • a = 2, b = 4, f(n) = Θ(n^(1/2))
  • 计算 c = log_b(a) = log_4(2) = 1/2
  • 比较 f(n) = Θ(n^(1/2))Θ(n^(1/2))。两者增长率相同,属于情况二(k=0)。
  • 因此,T(n) = Θ(√n * log n)

示例 3T(n) = T(n/2) + Θ(n^2)

  • a = 1, b = 2, f(n) = Θ(n^2)
  • 计算 c = log_b(a) = log_2(1) = 0
  • 比较 f(n) = Θ(n^2)Θ(n^0)。因为 n^2 增长快于常数,属于情况三(需检查正则条件,通常成立)。
  • 因此,T(n) = Θ(n^2)

案例研究:在行列均有序的二维数组中搜索

为了综合运用递归和复杂度分析,我们研究一个经典问题:在一个 m x n 的二维数组中进行搜索,该数组的每一行从左到右递增,每一列从上到下递增。

我们有多种策略:

  1. 四分法

    • 查看矩阵中心元素。
    • 根据目标值与中心元素的大小关系,可以排除四个象限中的一个。
    • 在剩余的三个象限中递归搜索。
    • 递归式:T(n) = 3T(n/2) + Θ(1)。根据主定理,a=3, b=2, c=0,属于情况一,解得 T(n) = Θ(n^(log_2 3)) ≈ Θ(n^1.585)
  2. 二分划分法(线性搜索中轴)

    • 线性搜索中间列,找到目标值应处的位置。
    • 根据比较结果,可以排除两个象限。
    • 在剩余的两个象限中递归搜索。
    • 递归式:T(n) = 2T(n/2) + Θ(n)。根据主定理,a=2, b=2, c=1,属于情况二,解得 T(n) = Θ(n log n)
  3. 二分划分法(二分搜索中轴)

    • 用二分搜索代替线性搜索来查找中间列。
    • 递归式:T(n) = 2T(n/2) + Θ(log n)。此形式不符合主定理标准形式,需用代入法求解,最终得到 T(n) = Θ(n)
  4. 步进线性搜索法

    • 从矩阵的右上角(或左下角)开始。
    • 如果当前元素等于目标值,则找到。
    • 如果当前元素大于目标值,则目标值不可能在当前元素的下方(因为列有序),故向左移动一列。
    • 如果当前元素小于目标值,则目标值不可能在当前元素的左侧(因为行有序),故向下移动一行。
    • 重复直到找到目标或越界。
    • 在最坏情况下,从右上角走到左下角,时间复杂度为 Θ(m + n)。若矩阵为 n x n,则复杂度为 Θ(n)

性能比较与深入思考
理论上,二分划分法(二分搜索中轴)步进线性搜索法都具有 Θ(n) 的渐近复杂度。但在实际运行时,后者通常更快。原因涉及计算机体系结构中的局部性原理步进线性搜索法在内存访问上(通常按行存储)具有更好的空间局部性,能更有效地利用CPU高速缓存,从而减少数据访问的延迟。这提醒我们,算法在实际系统中的性能不仅取决于渐近复杂度,还受底层硬件特性的影响。

总结

本节课中我们一起学习了递归的核心概念。我们了解了如何编写递归函数,特别是利用尾递归来优化空间使用。更重要的是,我们掌握了分析递归算法复杂度的关键工具——递归关系式,并学习了如何使用主定理来快速求解一大类递归式。最后,通过一个二维数组搜索的案例,我们综合运用了这些知识,并认识到在实际编程中,除了理论复杂度,还需要考虑诸如缓存局部性等系统级因素。掌握这些内容,将为设计和分析更复杂的算法打下坚实基础。

006:容器数据结构;基于数组的容器

在本节课中,我们将学习容器数据结构,特别是基于数组的容器。我们将探讨内置数组、动态数组、指针操作以及如何设计和实现自己的容器类。课程内容旨在帮助初学者理解这些核心概念,并应用于实际编程中。


数组与指针

上一节我们介绍了课程概述,本节中我们来看看数组与指针的基本关系。

数组和指针在C++中紧密相关。数组名本质上是一个指向数组首元素的常量指针。

以下代码展示了数组与指针的相似性:

double a[3] = {1.1, 2.2, 3.3};
int i = 1;
cout << a[i]; // 输出 a[1] 的值
cout << i[a]; // 同样输出 a[1] 的值,因为编译器将其转换为 *(i + a)

a[i]i[a] 是等价的,因为编译器会将它们转换为指针表示法 *(a + i)。加法满足交换律,即使是指针与整数相加。

数组与指针的主要区别在于,指针可以被重新赋值以指向其他内存地址,而数组名是常量,不能改变其指向。


一维与多维数组

上一节我们讨论了数组与指针的关系,本节中我们来看看一维与多维数组在内存中的布局。

内存是线性寻址的,因此多维数组在内存中也是线性存储的。例如,一个3x3的二维数组在内存中与一个大小为9的一维数组布局相同。

访问数组元素时,循环的顺序会影响程序性能,因为这与局部性引用有关。为了提高缓存效率,应让最内层循环遍历变化最快的维度(通常是列)。

以下是如何将二维索引转换为一维索引的公式:

  • 一维索引 = 行号 * 列数 + 列号
  • 行号 = 一维索引 / 列数
  • 列号 = 一维索引 % 列数

虽然可以手动进行这些计算,但在实际项目(如Project 1)中,直接使用std::vector等标准容器更为清晰和高效。


固定大小数组与动态数组

上一节我们了解了数组的内存布局,本节中我们来比较固定大小数组和动态数组。

固定大小数组

固定大小数组在编译时确定尺寸,并分配在栈上。

以下是固定大小二维数组的示例:

const size_t ROWS = 3, COLS = 3;
int arr[ROWS][COLS];
for (size_t r = 0; r < ROWS; ++r) {
    for (size_t c = 0; c < COLS; ++c) {
        arr[r][c] = r * COLS + c;
    }
}

优点

  • 无需手动管理内存(new/delete)。
  • 访问元素只需一次内存操作(编译器处理索引计算)。

缺点

  • 大小必须在编译时已知。
  • 栈空间有限,无法分配非常大的数组。
  • 传递给函数时语法繁琐。

动态数组

动态数组使用new在堆上分配内存,大小可以在运行时确定。

以下是动态分配二维数组的示例:

size_t rows, cols;
cin >> rows >> cols;
int** arr = new int*[rows]; // 分配行指针数组
for (size_t r = 0; r < rows; ++r) {
    arr[r] = new int[cols]; // 为每一行分配列
}
// 使用数组...
// 释放内存
for (size_t r = 0; r < rows; ++r) {
    delete [] arr[r];
}
delete [] arr;

优点

  • 大小可在运行时决定。
  • 可创建“锯齿状”数组(各行长度不同)。
  • 交换行只需交换指针,效率高。

缺点

  • 必须手动管理内存,否则会导致内存泄漏或重复释放。
  • 访问元素需要两次内存访问(先找行指针,再找元素)。
  • 需要额外内存存储行指针。

基于范围的for循环

上一节我们比较了两种数组,本节中我们来看看一种遍历容器的现代语法:基于范围的for循环。

基于范围的for循环(C++11引入)提供了一种简洁的方法来遍历容器。

以下是使用示例:

std::vector<int> vec = {1, 2, 3, 4, 5};
// 仅打印元素(无需修改,使用值传递或const引用)
for (int x : vec) {
    cout << x << " ";
}
// 需要修改容器内元素时,必须使用引用
for (int& x : vec) {
    x *= 2; // 将每个元素加倍
}
// 对于大型对象(如std::string),只读访问时使用const引用以避免拷贝
std::vector<std::string> words = {"hello", "world"};
for (const std::string& w : words) {
    cout << w << " ";
}

使用规则总结如下:

  • 需要修改元素:使用引用 (&)。
  • 仅需读取,且元素为小型内置类型:使用值传递。
  • 仅需读取,且元素为大型对象:使用常量引用 (const &)。

容器设计概念

上一节我们学习了遍历容器的便捷方法,本节中我们来看看设计一个容器类需要考虑的核心概念。

容器是用于存储多个(通常类型相同的)对象的对象。标准模板库提供了如vectordequelist等容器。

当设计自己的容器时,需要考虑以下基本操作:

  • 构造与析构:分配和释放内存。
  • 拷贝控制:拷贝构造函数、拷贝赋值运算符,实现深拷贝。
  • 元素访问:如何让用户安全地读取或修改数据(通过值、指针还是引用?)。
  • 容量查询:获取容器大小。
  • 修改操作:插入、删除元素。

容器内部数据的组织方式决定了其提供的访问方式:

  • 随机访问:允许在常数时间O(1)内访问任意位置(如数组、vector)。
  • 顺序访问:必须从起点开始逐个访问元素(如链表)。

实现一个简单的Array类

上一节我们探讨了容器设计的理念,本节中我们动手实现一个简单的、基于动态数组的Array类,来实践这些概念。

我们将实现一个管理double类型数组的类,展示“大三法则”(Rule of Three)。

1. 基本结构与构造/析构

class Array {
private:
    size_t m_size;
    double* m_data;
public:
    // 构造函数
    Array(size_t size = 0) : m_size{size}, m_data{nullptr} {
        if (m_size > 0) {
            m_data = new double[m_size]{};
        }
    }
    // 析构函数
    ~Array() {
        delete [] m_data;
        m_data = nullptr; // 安全措施
    }
    // 返回大小(const成员函数,承诺不修改对象)
    size_t size() const { return m_size; }
};

2. 拷贝构造函数

实现深拷贝,避免两个对象共享同一块内存。

// 拷贝构造函数
Array(const Array& other) : m_size{other.m_size}, m_data{nullptr} {
    if (m_size > 0) {
        m_data = new double[m_size];
        for (size_t i = 0; i < m_size; ++i) {
            m_data[i] = other.m_data[i];
        }
    }
}

3. 拷贝赋值运算符(拷贝-交换技法)

这是实现赋值操作的安全、高效且优雅的方法。

// 拷贝赋值运算符
Array& operator=(const Array& other) {
    if (this != &other) { // 自赋值检查(可省略,见下文解释)
        Array temp(other); // 1. 用other创建临时副本(调用拷贝构造函数)
        // 2. 交换当前对象和临时对象的内容
        std::swap(m_size, temp.m_size);
        std::swap(m_data, temp.m_data);
        // 3. 临时对象temp离开作用域,其析构函数会清理掉旧的资源
    }
    return *this; // 4. 返回当前对象的引用
}

拷贝-交换技法的精妙之处

  • 代码复用:赋值运算符复用了拷贝构造和析构函数的逻辑。
  • 强异常安全:如果在创建临时副本时发生异常,当前对象状态保持不变。
  • 无需显式自赋值检查:即使进行自赋值x = x,也会创建副本然后交换,最终结果正确。虽然效率略低(O(n)),但保证了正确性和代码简洁。好的代码通常不需要自赋值检查。

4. 元素访问操作符

提供安全的元素访问,包括常量版本和非常量版本。

// 非常量版本,允许修改元素
double& operator[](size_t index) {
    if (index >= m_size) {
        throw std::out_of_range("Index out of range");
    }
    return m_data[index];
}
// 常量版本,用于const对象,只允许读取
const double& operator[](size_t index) const {
    if (index >= m_size) {
        throw std::out_of_range("Index out of range");
    }
    return m_data[index];
}

需要两个版本的原因是:当函数接受一个const Array&参数时,只能调用其const成员函数。提供const版本的operator[]保证了此类只读访问的合法性。


内存管理要点

上一节我们完成了Array类的核心实现,本节中我们总结一下C++内存管理的关键点。

  • 配对使用new 对应 deletenew[] 对应 delete[]。不匹配会导致未定义行为。
  • 所有权明确:谁分配(new),谁就负责释放(delete)。对于持有动态内存的类,必须实现析构函数、拷贝构造函数和拷贝赋值运算符(即“大三法则”)。
  • 避免内存泄漏:即使程序结束操作系统会回收内存,长期运行的程序或代码被集成到更大系统中时,内存泄漏会逐渐耗尽资源。使用工具如Valgrind来检测内存泄漏。
  • 使用现代C++特性:在实际项目中,应优先使用标准库容器(如vectorstring)和智能指针,它们能自动管理内存,避免手动管理的诸多陷阱。

总结

本节课中我们一起学习了容器数据结构,特别是基于数组的实现。我们从数组与指针的基础关系开始,探讨了一维与多维数组的内存布局,比较了固定大小数组与动态数组的优缺点。随后,我们介绍了基于范围的for循环这一现代遍历语法。课程的核心部分是容器设计理念以及通过实现一个简单的Array类,深入实践了构造/析构、深拷贝、拷贝赋值运算符(及其高效的拷贝-交换实现)以及元素访问等关键概念。最后,我们回顾了C++手动内存管理的核心原则。理解这些内容是有效使用C++标准库容器和设计自定义数据结构的基础。

007:堆与堆排序

概述

在本节课中,我们将要学习一种重要的数据结构——堆,以及基于堆的排序算法——堆排序。我们将从堆的基本概念开始,逐步深入到其实现细节和核心操作,并探讨堆如何用于实现优先队列。通过本节课的学习,你将掌握堆的原理、实现方法及其应用。

堆的基础知识

上一节我们回顾了树的基本概念,本节中我们来看看堆的具体定义和性质。

堆是一种特殊的完全二叉树,它满足堆序性质。堆主要分为两种类型:最大堆和最小堆。在最大堆中,对于树中的任意节点,其所有子节点的值都小于或等于该节点的值。这意味着根节点存储的是整个堆中的最大值。最小堆的性质则相反。

堆通常使用数组来实现,而不是使用指针连接的节点结构。这种实现方式利用了完全二叉树的特性,使得我们可以通过简单的数学运算在数组中找到任意节点的父节点或子节点。

以下是堆中节点索引关系的公式:

  • 父节点索引:parent(i) = i / 2 (整数除法)
  • 左子节点索引:left_child(i) = i * 2
  • 右子节点索引:right_child(i) = i * 2 + 1

注意,上述公式基于数组索引从1开始。如果索引从0开始,公式需要相应调整。

堆的核心操作

理解了堆的结构后,我们来看看如何维护堆的性质。当堆中某个节点的值被修改,可能破坏堆序性质时,我们需要通过两个核心操作来修复它。

上浮操作

当堆中某个节点的值增大(在最大堆中)时,它可能变得比其父节点还大,从而违反堆序性质。上浮操作通过不断将该节点与其父节点进行比较和交换,使其“上浮”到正确的位置。

以下是上浮操作的伪代码描述:

function fix_up(heap, k):
    while k > 1 and heap[k] > heap[k/2]:
        swap(heap[k], heap[k/2])
        k = k / 2

该操作的时间复杂度为 O(log n),其中n是堆中元素的数量。

下沉操作

当堆中某个节点的值减小(在最大堆中)时,它可能变得比其某个子节点还小,从而违反堆序性质。下沉操作通过不断将该节点与其较大的子节点进行比较和交换,使其“下沉”到正确的位置。

以下是下沉操作的伪代码描述:

function fix_down(heap, k, n):
    while 2*k <= n:
        j = 2*k
        if j < n and heap[j] < heap[j+1]:
            j = j + 1
        if heap[k] >= heap[j]:
            break
        swap(heap[k], heap[j])
        k = j

该操作的时间复杂度同样为 O(log n)

基于堆实现优先队列

掌握了修复堆的操作后,我们可以利用它们来实现优先队列的基本操作:插入和删除。

插入元素

向堆中插入一个新元素时,我们首先将其添加到数组的末尾(即完全二叉树的下一个空闲位置),然后对这个新元素执行上浮操作,以恢复堆序性质。

插入操作的步骤非常简单:

  1. 将新元素置于数组末尾。
  2. 对该元素的索引执行 fix_up 操作。

删除最大元素

从最大堆中删除元素通常指的是删除并返回根节点(即最大值)。我们首先将数组末尾的元素移动到根节点的位置,然后对根节点执行下沉操作,以恢复堆序性质。

删除操作的步骤如下:

  1. 将根节点(最大值)取出。
  2. 将数组末尾元素移至根节点位置。
  3. 对新的根节点索引(1)执行 fix_down 操作。
  4. 堆的大小减一。

堆排序与堆化

堆不仅可以用于实现优先队列,还可以用于高效的排序,即堆排序。堆排序的第一步是将一个无序数组转化为一个堆,这个过程称为“堆化”。

堆化

堆化是指将一个无序数组重新排列,使其满足堆的性质。一种高效的方法是自底向上地对每个非叶子节点执行下沉操作。

以下是堆化过程的描述:

function heapify(arr, n):
    for i from n/2 down to 1:
        fix_down(arr, i, n)

令人惊讶的是,这个看似简单的循环可以在 O(n) 的线性时间内完成堆的构建,而不是直观上的 O(n log n)。我们将在后续课程中详细分析其原因。

堆排序算法

一旦数组被堆化,堆排序算法就变得非常直接:

  1. 将数组堆化(构建最大堆)。
  2. 此时,最大元素位于 arr[1]。将其与数组末尾元素 arr[n] 交换。
  3. 堆的大小减一(忽略末尾已排序的最大值),并对新的根节点 arr[1] 执行 fix_down 操作,以恢复最大堆性质。
  4. 重复步骤2和3,直到堆中只剩下一个元素。

堆排序是一种原地的、不稳定的排序算法,其时间复杂度为 O(n log n)

总结

本节课我们一起学习了堆数据结构和堆排序算法。我们首先定义了堆作为一种完全二叉树,并介绍了其堆序性质。然后,我们深入探讨了如何使用数组高效地实现堆,并详细讲解了维护堆性质的核心操作:上浮和下沉。基于这些操作,我们实现了优先队列的插入和删除功能。最后,我们介绍了堆化过程以及如何利用堆进行排序。堆是一个功能强大且高效的数据结构,在优先队列和排序等场景中有着广泛的应用。

008:有序数组及相关算法

概述

在本节课中,我们将学习有序数组的概念、相关算法及其实现。我们将探讨有序容器与排序容器的区别,分析基于数组和链表的容器实现复杂度,并深入学习二分查找算法及其优化。最后,我们将简要介绍集合的表示方法。


有序容器与排序容器

上一节我们介绍了堆排序算法,本节中我们来看看容器的不同类型。容器是存储对象的通用结构,例如向量、双端队列、映射和列表。

有序容器和排序容器是两个不同的概念。有序容器中的元素保持其相对位置,除非被显式移动。排序容器则遵循一个预定义的顺序约束,例如升序排列。

以下是两者的关键区别:

  • 有序容器:元素插入或移除不会改变其他元素的相对顺序。例如,一个按颜色排列的书架,拿走一本书不会改变其他书的顺序。
  • 排序容器:元素必须始终满足特定的排序规则(如字母顺序)。插入操作必须找到正确位置以维持排序。

选择排序容器通常是因为其高效的查找能力。如果应用程序需要频繁查询而较少插入,排序容器是理想选择,因为它可以利用二分查找等算法实现快速查找。


容器实现的复杂度分析

了解不同实现的性能特征对于选择合适的容器至关重要。以下是基于数组和链表实现有序容器与排序容器的操作复杂度比较。

有序容器的复杂度

操作 数组实现 (单)链表实现 (双)链表实现
添加元素 O(1) O(1) O(1)
按值移除 O(N) O(N) O(N)
按迭代器移除 O(N) O(N) O(1)
查找 O(N) O(N) O(N)
迭代器解引用 O(1) O(1) O(1)
随机访问 O(1) O(N) O(N)
在迭代器后插入 O(N) O(1) O(1)
在迭代器前插入 O(N) O(N) O(1)

说明

  • 数组的“按迭代器移除”和插入操作为 O(N),是因为需要移动元素以保持数组紧凑。
  • 链表的“按迭代器移除”在双向链表中为 O(1),因为可以直接调整前后指针;在单向链表中仍需 O(N) 来找到前驱节点。
  • 链表不支持随机访问。

排序容器的复杂度

操作 数组实现 链表实现
添加元素 O(N) O(N)
按值移除 O(N) O(N)
按迭代器移除 O(N) O(N) / O(1)
查找 O(log N) O(N)
迭代器解引用 O(1) O(1)
随机访问 O(1) O(N)
插入(前/后) 不适用 不适用

说明

  • 排序容器的添加和移除操作通常为 O(N),因为需要找到正确位置并(对于数组)可能移动元素。
  • 查找操作在排序数组中可以利用二分查找优化至 O(log N),但在链表中仍需 O(N)
  • 插入(前/后)操作通常不适用于排序容器,因为它们可能破坏排序约束。

二分查找算法

对于排序数组,二分查找是一种高效的搜索算法。其核心思想是不断将搜索范围减半。

基本算法

假设我们在一个排序数组中查找值 val

  1. 初始化指针 left = 0, right = n-1
  2. left <= right 时:
    • 计算中点 mid = left + (right - left) / 2
    • 如果 A[mid] == val,返回 mid
    • 如果 val < A[mid],设置 right = mid - 1
    • 否则,设置 left = mid + 1
  3. 如果循环结束仍未找到,返回 -1

复杂度分析

二分查找每次迭代将搜索范围缩小一半。假设有 N 个元素,最坏情况下需要比较的次数 K 满足 N ≈ 2^K,因此 K ≈ log₂ N。算法的时间复杂度为 O(log N)

算法优化与STL实现

基本的二分查找在循环中首先检查是否找到元素,但这个条件很少为真。一个优化是延迟相等性检查。

STL中的 binary_search 函数只返回布尔值,通常不如返回位置的函数实用。更常用的是 lower_boundupper_bound

  • lower_bound: 返回第一个不小于 val 的元素的迭代器。
  • upper_bound: 返回第一个大于 val 的元素的迭代器。

使用 lower_bound 实现查找的伪代码如下:

iterator lower_bound(iterator first, iterator last, const T& val) {
    while (first != last) {
        iterator mid = first + (last - first) / 2;
        if (*mid < val) first = mid + 1;
        else last = mid;
    }
    return first;
}
// 使用示例
auto it = lower_bound(vec.begin(), vec.end(), target);
if (it != vec.end() && *it == target) {
    // 找到目标
}

lower_bound 的优点是循环内只需一次比较,效率更高。调用者需检查返回的迭代器是否指向目标值。


集合的表示与操作

支持快速查找的容器通常称为集合。除了查找,集合还支持标准数学操作,如并集、交集、差集和对称差集。

有序范围的并集操作

并集操作合并两个已排序的集合,生成一个包含所有元素(无重复)的新排序集合。

算法使用三个指针(迭代器):

  1. it1: 遍历集合1。
  2. it2: 遍历集合2。
  3. it_out: 指向输出集合的当前位置。

过程如下:

  • 比较 *it1*it2
  • 将较小的元素插入输出集合,并移动对应的输入迭代器。
  • 如果元素相等,只插入一个,然后同时移动 it1it2
  • 当任一输入集合遍历完毕,将剩余集合的所有元素追加到输出中。

该算法的时间复杂度是 O(M + N),其中 M 和 N 是两个输入集合的大小。


总结

本节课我们一起学习了有序数组及相关算法。我们明确了有序容器与排序容器的区别,并分析了基于数组和链表实现的复杂度。我们深入探讨了二分查找算法,了解了其基本原理、复杂度分析以及STL中 lower_boundupper_bound 的优化实现。最后,我们介绍了集合的表示方法,并详细讲解了两个有序范围求并集的算法。理解这些概念和算法对于在具体应用中选择和实现高效的数据结构至关重要。

009:基础排序算法

概述

在本节课中,我们将学习并集查找(Union-Find)数据结构的实现,并开始探讨一系列基础排序算法,包括冒泡排序和选择排序。我们将分析这些算法的复杂度、稳定性以及它们在不同输入情况下的表现。


并集查找(Union-Find)

上一节我们讨论了使用迭代器范围实现集合操作。本节中,我们来看看一种用于管理不相交集合的数据结构——并集查找(Union-Find)。

并集查找在图的算法中尤其有用。其核心是维护一系列不相交的集合,并支持两种主要操作:

  • 查找(Find):确定一个元素属于哪个集合。
  • 合并(Union):将两个集合合并为一个。

基础实现:代表元法

一种直观的实现方式是让每个元素直接存储其所在集合的“代表元”。查找操作是常数时间,但合并操作在最坏情况下需要线性时间,因为它可能需要更新一个集合中所有元素的代表元。

核心操作伪代码

Find(x):
    return representative[x]

Union(x, y):
    rep_x = Find(x)
    rep_y = Find(y)
    for each element in set(rep_y):
        representative[element] = rep_x

优化实现:父指针法

为了加速合并操作,我们可以让元素存储其“父节点”的引用,而非直接的代表元。这样,合并操作只需修改一个指针(常数时间),但查找操作可能需要沿着父链向上追溯(线性时间)。

高效实现:路径压缩

路径压缩是对父指针法的优化。在查找操作追溯父链找到根代表元的过程中,同时将路径上所有经过的节点直接指向根代表元。这虽然增加了单次查找的开销,但极大地扁平化了树结构,使得后续操作非常快。

通过平摊分析(Amortized Analysis),可以证明经过路径压缩的并集查找操作(包括查找和合并)的平摊时间复杂度接近常数,具体由反阿克曼函数(inverse Ackermann function)界定,其值非常小(通常不超过5)。

带路径压缩的查找操作伪代码

Find(x):
    if parent[x] != x:
        parent[x] = Find(parent[x]) // 递归查找并压缩路径
    return parent[x]

排序算法简介

在介绍了并集查找之后,我们将焦点转向排序算法。排序是计算机科学中的基础问题,高效的排序算法是许多应用的核心。

排序算法主要分为两类:

  • 基础排序算法:时间复杂度通常为 O(n²),如冒泡排序、选择排序、插入排序。它们简单,适用于小规模数据或特定情况。
  • 高效排序算法:时间复杂度通常为 O(n log n),如快速排序、归并排序、堆排序。

分析排序算法时,我们关注以下特性:

  • 时间复杂度:最好、平均、最坏情况。
  • 空间复杂度:是否为原地排序(In-place,使用 O(1) 额外空间)。
  • 稳定性:相等元素的相对顺序在排序后是否保持不变。
  • 自适应性:算法是否能利用输入数据已有的部分有序性来提高效率。

冒泡排序

首先,我们来看冒泡排序。它的思想是反复遍历列表,比较相邻元素,如果顺序错误就交换它们,直到列表有序。

算法描述

冒泡排序的工作方式类似于气泡上浮:每一轮遍历都将当前未排序部分中的最大(或最小)元素“冒泡”到其正确位置。

以下是基础的非自适应版本代码:

for (int i = 0; i < n - 1; i++) {
    for (int j = n - 1; j > i; j--) {
        if (array[j] < array[j - 1]) {
            swap(array[j], array[j - 1]);
        }
    }
}

算法分析

  • 时间复杂度:平均和最坏情况为 O(n²)。最好情况(输入已完全有序)下,基础版本仍需 O(n²) 次比较。
  • 空间复杂度:原地排序,O(1)
  • 稳定性:是稳定排序。
  • 自适应性:基础版本非自适应。但可以添加一个标志位来记录本轮是否发生交换,若一轮遍历无交换,则说明数组已有序,可提前终止。优化后的版本对近乎有序的输入效率较高。

自适应冒泡排序的优化

bool swapped;
for (int i = 0; i < n - 1; i++) {
    swapped = false;
    for (int j = n - 1; j > i; j--) {
        if (array[j] < array[j - 1]) {
            swap(array[j], array[j - 1]);
            swapped = true;
        }
    }
    if (!swapped) break; // 本轮无交换,提前结束
}

选择排序

接下来我们看选择排序。它的思路非常直观:每次从未排序部分中找到最小(或最大)元素,将其放到已排序部分的末尾。

算法描述

算法维护两个子数组:已排序部分和未排序部分。初始时已排序部分为空。每一轮,它从未排序部分选出最小元素,与未排序部分的第一个元素交换,从而将其纳入已排序部分。

以下是选择排序的代码:

for (int i = 0; i < n - 1; i++) {
    int min_index = i;
    for (int j = i + 1; j < n; j++) {
        if (array[j] < array[min_index]) {
            min_index = j;
        }
    }
    if (min_index != i) {
        swap(array[i], array[min_index]);
    }
}

算法分析

  • 时间复杂度:无论输入数据如何,比较次数恒定为 Θ(n²)。交换次数很少,为 O(n)
  • 空间复杂度:原地排序,O(1)
  • 稳定性不是稳定排序。考虑数组 [5a, 2, 5b, 1](用下标区分相同值5)。第一轮选择最小元素1与第一个5交换,得到 [1, 2, 5b, 5a],两个5的相对顺序改变了。
  • 自适应性:较差。即使输入完全有序,它仍然需要进行 Θ(n²) 次比较。但它交换次数少,对于移动成本高的元素有一定优势。

选择排序的一个优点是实现简单,并且对于很小的 n,其常数因子可能使得它比更复杂的 O(n log n) 算法更快。


总结

本节课中我们一起学习了:

  1. 并集查找(Union-Find):用于管理不相交集合的数据结构,核心操作是查找和合并。我们讨论了从基础代表元法到高效的带路径压缩的父指针法的演进,后者能使操作平摊时间接近常数。
  2. 冒泡排序:通过不断交换相邻逆序元素来排序。简单且稳定,可通过标志位优化使其具有自适应性,但对大规模数据效率低下。
  3. 选择排序:每次选择未排序部分的最小元素放到正确位置。实现直观,交换次数少,但时间复杂度恒为 Θ(n²) 且不稳定。

下一节课,我们将继续学习另一个重要的基础排序算法——插入排序,并探讨计数排序。

010:快速排序,包括平均情况分析 🚀

在本节课中,我们将学习快速排序算法,这是一种高效的排序方法。我们将从回顾插入排序的改进开始,然后深入探讨快速排序的原理、实现细节,特别是其分区过程。最后,我们将分析快速排序的时间复杂度,包括其平均情况下的表现。


回顾:插入排序的改进 🔧

上一节我们介绍了基础的插入排序算法。本节中,我们来看看如何通过一些优化来提升其性能。

以下是插入排序的几个关键改进点:

  1. 移动代替交换:在将元素插入到已排序部分时,使用移动操作代替交换操作。交换通常需要三次赋值(使用临时变量),而移动只需一次赋值,从而减少了操作次数。

    // 交换操作示例(三次赋值)
    void swap(int &a, int &b) {
        int temp = a;
        a = b;
        b = temp;
    }
    // 移动操作(一次赋值)
    a[j] = a[j-1];
    
  2. 将内层循环改为while循环:这可以使代码逻辑更清晰,便于后续优化。循环条件直接检查当前元素是否小于其左侧元素。

  3. 使用哨兵值:首先找出数组中的最小值,并将其放在数组首位。这个最小值作为“哨兵”,可以确保内层循环在比较时不会越界,从而省去一个边界检查条件。

这些改进使得插入排序在处理小规模或接近有序的数据时非常高效,但其最坏情况和平均情况时间复杂度仍然是 O(n²)


计数排序简介 📊

在讨论更高效的排序算法之前,我们先简要了解一种特殊的线性时间排序算法——计数排序。

计数排序适用于键值范围有限且较小的情况。它的基本思想是:

  1. 第一遍:统计每个唯一键值出现的频率。
  2. 第二遍:根据频率计算每个键值在输出数组中的起始偏移量。
  3. 第三遍:根据偏移量将元素复制到输出数组的正确位置。

由于其需要额外的数组来存储频率和偏移量,空间复杂度为 O(n + k),其中 k 是唯一键值的数量。当 k 远小于 n 时,这可以近似看作线性空间。

注意:计数排序是稳定的排序算法,因为它按照输入顺序处理元素,保持了相等元素的相对位置。


快速排序:分而治之的典范 ⚡

现在,我们进入本节课的核心——快速排序。这是一种基于“分治法”的高效排序算法。

算法框架

快速排序的递归框架非常简洁:

  1. 基准情况:如果当前要排序的数组段长度小于等于1,则直接返回(已排序)。
  2. 归纳步骤
    a. 分区:从当前数组段中选取一个“枢轴”元素,然后重新排列数组段,使得所有小于枢轴的元素都在其左侧,所有大于枢轴的元素都在其右侧。
    b. 递归:对枢轴左侧和右侧的子数组段分别递归调用快速排序。

其伪代码如下:

void quicksort(vector<int>& A, int left, int right) {
    if (left + 1 >= right) return; // 基准情况
    int pivot = partition(A, left, right); // 分区,返回枢轴位置
    quicksort(A, left, pivot);   // 递归排序左半部分
    quicksort(A, pivot + 1, right); // 递归排序右半部分
}

分区函数详解

分区是快速排序的关键。一个简单的分区策略是选择当前子数组的最后一个元素作为枢轴。

以下是分区函数的一种实现思路:

  1. 选择最右侧元素 A[right-1] 作为枢轴值。
  2. 初始化两个指针 i(从左向右扫描)和 j(从右向左扫描)。
  3. 移动 i,直到找到第一个大于等于枢轴的元素。
  4. 移动 j,直到找到第一个小于枢轴的元素。
  5. 如果 ij 尚未交叉,则交换 A[i]A[j],然后重复步骤3-4。
  6. ij 交叉时,将枢轴元素交换到位置 i,此时 i 就是枢轴的最终位置。

这个分区过程的时间复杂度是 O(n),因为它只对数组进行了一次线性扫描。

枢轴选择策略

枢轴的选择直接影响快速排序的效率:

  • 理想情况:每次都能选中中位数,这样每次都能将数组均匀分成两半,递归深度为 O(log n),总时间复杂度为 O(n log n)
  • 最坏情况:每次选择的枢轴都是当前子数组的最小或最大值,导致分区极度不平衡(一边有 n-1 个元素,另一边为0)。这将导致递归深度为 O(n),总时间复杂度退化为 O(n²)
  • 平均情况:在随机输入数据下,即使使用简单的枢轴选择(如随机选择或固定选择首/尾元素),快速排序的平均时间复杂度仍然是 O(n log n)。这是因为糟糕的分区发生的概率很低。

关键点:虽然快速排序的最坏情况是 O(n²),但在实际应用中,由于其平均性能优异且对缓存友好,它仍然是实践中最快的通用排序算法之一。C++标准库中的 std::sort 通常就基于快速排序的变体。


总结 📝

本节课中我们一起学习了:

  1. 插入排序的优化:通过移动代替交换、使用while循环和哨兵值来提升性能,使其在小规模或部分有序数据上表现更佳。
  2. 计数排序:一种在键值范围有限时的线性时间排序算法,但需要额外的空间。
  3. 快速排序的核心思想:基于分治法,通过分区操作将数组分为小于枢轴和大于枢轴的两部分,然后递归排序。
  4. 分区过程与枢轴选择:详细了解了分区函数的实现,并讨论了枢轴选择对算法性能(最坏情况 O(n²) 与平均情况 O(n log n))的关键影响。

快速排序因其在实践中的高效性而广受欢迎。下节课我们将完成对快速排序的讨论,并开始学习另一种高效的排序算法——归并排序。

011:归并排序 🧩

在本节课中,我们将要学习归并排序算法。我们将回顾快速排序的分析,然后深入探讨归并排序的工作原理、实现方式及其优缺点。课程内容将涵盖归并排序的递归与迭代版本,并解释其稳定性和内存使用情况。


快速排序回顾 🔄

上一节我们介绍了快速排序,本节中我们来看看其分区函数的一个具体例子,并回顾其潜在问题。

分区函数示例

以下是使用“选取最右元素”作为基准策略的分区过程示例。初始数组为 [2, 9, 3, 4, 7, 8, 5, 6],选取 6 作为基准。

  1. 设置左指针在起始位置,右指针在基准前一位。
  2. 移动左指针,直到找到大于基准的元素 9
  3. 移动右指针,直到找到小于基准的元素 5
  4. 交换 95,数组变为 [2, 5, 3, 4, 7, 8, 9, 6]
  5. 继续移动左指针到 7,右指针到 4,此时左右指针交叉。
  6. 最后,交换左指针位置元素 7 与基准 6,得到分区结果 [2, 5, 3, 4, 6, 8, 9, 7]。左侧元素均小于6,右侧元素均大于6。

基准策略的问题

这种简单的基准策略存在一个主要问题:如果输入数组已经排序,快速排序将退化为最坏情况,时间复杂度达到 O(n²)

内存使用与优化

快速排序是原地排序算法,但作为递归算法,它会消耗栈帧内存。其中一个递归调用无法进行尾递归优化。一个小的优化是优先对较小的分区进行递归调用,以减少递归深度。最坏情况下,栈帧内存使用为 O(log n)

快速排序总结

以下是快速排序的优缺点总结:

  • 优点:平均情况下为 O(n log n),内部循环紧凑,内存使用高效。
  • 缺点:不稳定,最坏情况性能差,分区函数的指针移动容易出错。

选择更好的基准策略 🎯

为了避免最坏情况,我们需要更好的基准选择策略,且策略本身必须是常数时间复杂度。

以下是两种常见的采样策略:

  1. 三数取中法:选取当前分区左端、中间和右端三个元素,取它们的中值作为基准。
    • 公式pivot = median(array[left], array[mid], array[right])
  2. 随机采样法:从当前分区中随机选取三个(或五个)元素,取它们的中值作为基准。

这些策略在实践中能有效改善性能,防止针对特定输入的退化。


排序算法总结 📊

在深入归并排序之前,我们先总结已学过的排序算法。

以下是各类排序算法的关键特性对比:

  • 初级排序(冒泡、插入、选择):最坏情况 O(n²)。插入和冒泡排序是稳定的。
  • 堆排序:最坏和平均情况均为 O(n log n),非递归、原地排序,但不稳定。
  • 快速排序:平均情况 O(n log n),性能通常优于堆排序,但不稳定且存在最坏情况。

归并排序介绍 🤝

归并排序是一种采用分治思想的高效排序算法,但其工作方式与快速排序不同。快速排序是“先分后治”(分割后递归),而归并排序是“先治后合”(递归到底层后合并)。

核心思想:合并

归并排序依赖于一个核心操作:合并两个已排序的数组。这与之前讨论的有序集合并操作逻辑相似。

工作流程示例

考虑数组 [5, 2, 4, 7, 1, 3, 6, 1]

  1. 递归分解:算法递归地将数组分割成更小的子数组,直到每个子数组只有一个元素(视为已排序)。
  2. 递归合并:在递归返回的过程中,开始合并相邻的已排序子数组。
    • 首先合并单元素对:[5][2] 变成 [2, 5][4][7] 变成 [4, 7],以此类推。
    • 然后合并两元素数组:[2, 5][4, 7] 合并为 [2, 4, 5, 7][1, 3][1, 6] 合并为 [1, 1, 3, 6](注意重复键 1 的顺序保持不变)。
    • 最后合并两个四元素数组,得到最终排序结果。

这个过程是稳定的,即相等元素的相对顺序在排序后保持不变。


合并函数实现 🔧

合并两个已排序数组 AB 到输出数组 C 的逻辑如下。这里使用了三元运算符 ? : 使代码更简洁。

// 使用三元运算符的紧凑写法
C[k++] = (A[i] < B[j]) ? A[i++] : B[j++];

// 等价于以下if-else逻辑
if (A[i] < B[j]) {
    C[k] = A[i];
    i++;
    k++;
} else {
    C[k] = B[j];
    j++;
    k++;
}

以下是完整的合并函数伪代码,假设数组 C 已分配足够空间:

function merge(A, B, C):
    i = 0, j = 0, k = 0
    while i < size(A) and j < size(B):
        if A[i] <= B[j]:
            C[k++] = A[i++]
        else:
            C[k++] = B[j++]
    // 将剩余元素复制到C
    while i < size(A):
        C[k++] = A[i++]
    while j < size(B):
        C[k++] = B[j++]

递归式归并排序 📝

基于合并操作,我们可以实现递归版本的归并排序。

void mergeSort(vector<int>& arr, int left, int right) {
    if (left >= right) return; // 基础情况:0或1个元素
    int mid = left + (right - left) / 2;
    mergeSort(arr, left, mid);      // 递归排序左半部分
    mergeSort(arr, mid + 1, right); // 递归排序右半部分
    merge(arr, left, mid, right);   // 合并两个已排序部分
}

其中 merge 函数可能需要一个临时数组来辅助合并操作。

递归归并排序的特点

  • 时间复杂度:最好、最坏、平均情况均为 O(n log n)
  • 空间复杂度:需要 O(n) 的额外空间用于合并。
  • 稳定性:是稳定的排序算法。
  • 适用场景:特别适合外部排序(数据量太大无法全部放入内存),例如在大数据框架(如Hadoop)中排序海量数据。

迭代式(自底向上)归并排序 ⬆️

归并排序也可以非递归地实现,即自底向上的迭代版本。它通过循环控制合并子数组的大小,从1开始,每次翻倍。

void iterativeMergeSort(vector<int>& arr) {
    int n = arr.size();
    vector<int> temp(n);
    for (int size = 1; size < n; size *= 2) { // 子数组大小
        for (int left = 0; left < n - size; left += 2 * size) {
            int mid = left + size - 1;
            int right = min(left + 2 * size - 1, n - 1);
            merge(arr, temp, left, mid, right);
        }
    }
}

工作方式

  1. 第一轮:将数组视为n个长度为1的已排序子数组,两两合并成长度为2的已排序子数组。
  2. 第二轮:将长度为2的子数组两两合并成长度为4的已排序子数组。
  3. 如此反复,直到整个数组合并完毕。

迭代版本的特点

  • 避免了递归调用带来的栈帧开销。
  • 时间复杂度同样为 O(n log n)
  • 空间复杂度仍需 O(n) 额外空间。
  • 逻辑上属于“自底向上”的过程。

总结 🎓

本节课中我们一起学习了归并排序算法。我们首先回顾了快速排序的分区细节及其基准选择策略的优化。然后,我们深入探讨了归并排序的核心思想——合并两个有序序列,并分别介绍了其递归实现和迭代实现。

归并排序是一种稳定的、时间复杂度恒为 O(n log n) 的排序算法,其主要代价是需要 O(n) 的额外空间。它在处理大规模数据的外部排序场景中具有不可替代的优势。相比之下,快速排序平均性能更优且是原地排序,但不稳定且存在理论上的最坏情况。理解这些算法的内在机制和权衡,对于在实际问题中选择合适的工具至关重要。

012:字符串与序列 🧵

在本节课中,我们将学习字符串和序列的基本概念、相关算法及其应用。我们将从字符串的定义开始,探讨如何比较字符串,最后介绍一种高效的字符串搜索算法。


什么是字符串?🔤

字符串是由零个或多个字符组成的序列,其中每个字符都来自一个有限的字母表。例如,英语句子就是字符串,因为它们由26个字母(大写和小写)、标点符号和空格组成。

序列算法处理这些结构,虽然数据结构本身不复杂,但某些算法可能非常微妙,需要我们深入理解细节。

字符串算法之所以重要,是因为它们非常常见,全球大量的计算能力都用于解决这类问题。任何与人可读文本相关的操作,其核心都是序列算法。


字符串的典型问题 ❓

以下是处理字符串时常见的几个基本问题:

  • 相等性检查:判断两个序列是否相等。
  • 字典序比较:给定两个序列和一个顺序定义,判断哪个序列在字典中排在前面(也称为字典序)。
  • 查找子串:判断两个序列是否有共同的子串,这在DNA分析中非常常见。
  • 排序:给定一组字符串,按字典序对它们进行排序。有序的数据便于使用二分查找等高效算法。

文本与语料库 📚

我们有时会讨论“文本”,它是由特定分隔符(如空格、标点)分隔的一个或多个字符串的集合。一个“语料库”则是文档的集合,每个文档包含文本及其他元数据(如标题、URL)。

例如,搜索引擎(如Google)会预处理网页文本,以便快速响应多个独立的查询。专利搜索工具也是一个庞大的产业,依赖于高效的文本搜索算法。

其他应用还包括文本压缩(如GZIP算法)和DNA序列分析。DNA由A、C、G、T四个字符组成,整个生命的信息都编码在这些序列中,字符串算法在基因比对、突变追踪等方面至关重要。


字符串的数据结构 💾

在C和C++中,最常用的字符串数据结构有所不同:

  • C风格字符串:以空字符(\0)结尾的字符数组。表示一个字符串只需要一个指向起始字符的指针。计算长度需要线性时间。
  • C++ std::string:一个更面向对象的类,通常包含指向缓冲区起始和结束的指针,可能还存储大小等信息。计算长度可以在常数时间内完成。

C++字符串支持迭代器,begin() 指向第一个有效字符,end() 指向字符串末尾之后的位置。标准模板库(STL)提供了丰富的算法来处理字符串和序列。


判断序列是否相等 ⚖️

STL提供了 std::equal 算法来判断两个序列是否相等。它的函数原型大致如下:

template <class InputIterator1, class InputIterator2>
bool equal (InputIterator1 first1, InputIterator1 last1, InputIterator2 first2);

该算法比较由 [first1, last1) 定义的第一个序列和从 first2 开始的第二个序列。它假设第二个序列至少与第一个序列一样长。

算法逐个比较元素。如果所有对应元素都相等,则返回 true;如果在任何位置发现不匹配,则立即返回 false

设计哲学:STL倾向于性能优先,因此 std::equal 不检查第二个序列的长度是否足够,这需要调用者自己保证。


应用示例:判断回文 🔄

回文是正读和反读都相同的序列。我们可以利用 std::equal 和反向迭代器轻松判断一个字符串是否是回文。

bool is_palindrome(const std::string& s) {
    return std::equal(s.begin(), s.begin() + s.size()/2, s.rbegin());
}

这段代码将字符串的前半部分与后半部分(反向)进行比较。如果相等,则是回文。


上一节我们介绍了如何判断序列相等,本节中我们来看看如何比较序列的顺序。


字典序比较 📖

字典序比较用于判断一个序列是否在另一个序列之前。规则可以递归定义如下:

  1. 两个空范围相等。
  2. 空范围总是小于非空范围。
  3. 逐个比较元素。在第一个不匹配的位置,元素较小的序列整个序列较小。
  4. 如果所有比较的元素都相等,但一个序列是另一个序列的前缀,则较短的序列较小。

例如:

  • "" < "A"
  • "A" < "AB""A""AB" 的前缀)
  • "AB" < "B" (在第一个字符处,'A' < 'B'
  • "BC" < "BC0""BC""BC0" 的前缀)

实现与优化 ⚡

实际上,只需要实现 等于小于 两个操作,其他比较操作(不等于、大于、小于等于、大于等于)都可以通过它们推导出来。

一种常见的实现方式是先编写一个三路比较函数 compare(x, y),它返回:

  • 0:如果 x == y
  • 负数:如果 x < y
  • 正数:如果 x > y

然后基于此函数实现所有比较运算符。

STL实现细节

  • C++ std::string 可能使用“短字符串优化”,将短字符串直接存储在对象内部,避免堆内存分配。
  • 对于不等比较,如果两个字符串长度不同,可以直接判断为不相等,这是一个快速的短路优化。
  • 现代CPU支持单指令多数据(SIMD)操作,可以并行比较多个字符,极大提升性能。

STL提供了 std::lexicographical_compare 算法来执行字典序比较,它也允许自定义比较函数和执行策略(用于并行化)。


应用示例:获取唯一字符串列表 🗃️

假设我们有一个包含重复字符串的列表,想要获取唯一的字符串列表。可以结合排序和 std::unique 算法实现:

std::vector<std::string> names = // ... 可能包含重复项
std::sort(names.begin(), names.end());
auto last = std::unique(names.begin(), names.end());
names.erase(last, names.end());

首先对字符串排序,使相同的字符串相邻。然后 std::unique 将重复项移到范围末尾,并返回指向唯一范围末尾的迭代器,最后删除重复项。


上一节我们讨论了字符串的比较,本节中我们将探讨一个核心问题:如何在字符串中高效搜索。


字符串搜索:Rabin-Karp算法 🎣

在长字符串(“干草堆”)中搜索子串(“针”)是一个常见问题。朴素算法需要 O(干草堆长度 * 针长度) 的时间,在最坏情况下(如DNA序列,字符集小,容易产生长前缀匹配)效率很低。

Rabin-Karp算法的核心思想是使用“指纹”函数。我们为字符串计算一个整数“指纹”(哈希值)。如果两个字符串的指纹不同,那么它们一定不同;如果指纹相同,则很可能相同(需要进一步确认,因为可能存在哈希碰撞)。

大思路:先计算“针”的指纹。然后在“干草堆”上滑动一个与“针”等长的窗口,计算每个窗口的指纹。只比较指纹:

  • 指纹不同 => 窗口与“针”肯定不同,跳过。
  • 指纹相同 => 可能匹配,需要逐个字符验证。

关键在于,滑动窗口时,我们可以根据旧窗口的指纹常数时间地计算出新窗口的指纹,无需重新计算整个窗口的哈希值。


指纹函数与滑动窗口 🧮

一个简单的指纹函数是将字符串视为一个多位数。例如,字符串 "TOM",字符TOM的ASCII码分别为84, 79, 77。指纹可以计算为:
((84 * base + 79) * base + 77),其中 base 是一个选定的基数(如128)。

滑动窗口时,例如从 "TOM" 移动到 "OMM"

  1. 减去最高位字符 T 的贡献:T * base^(n-1)
  2. 将剩余结果乘以 base(相当于左移一位)。
  3. 加上新字符 M 的贡献。

为了避免整数溢出,我们使用模运算。选择一个大的质数 P 作为模数,所有运算结果都对 P 取模。模运算下,加法和乘法的结合律、交换律依然成立。


算法步骤与复杂度 📊

  1. 预处理:计算“针”的指纹和用于滑动窗口计算的幂值 Z = base^(len(针)-1) mod P
  2. 初始化:计算“干草堆”中第一个窗口的指纹。
  3. 滑动搜索
    • 如果窗口指纹与“针”指纹匹配,则进行逐字符验证。
    • 否则,滑动窗口:减去旧字符贡献,乘以基数,加上新字符贡献,所有操作取模 P
    • 重复直到找到匹配或遍历完“干草堆”。

时间复杂度

  • 理想情况(无碰撞):O(干草堆长度)。绝大多数窗口指纹比较都是常数时间。
  • 最坏情况(每次指纹都碰撞):O(干草堆长度 * 针长度)。但通过精心选择 base 和质数 P,可以使得碰撞在实际中极其罕见。
  • 平均性能通常接近 O(干草堆长度 + 针长度)。

参数选择

  • base:常选择2的幂(如128),因为乘法相当于位移操作,速度快。
  • P:选择一个足够大的质数(如 2^31 - 1),且与 base 互质。


总结 🎯

本节课中我们一起学习了:

  1. 字符串与序列的基础:定义、数据结构和常见问题。
  2. 序列比较:如何使用 std::equal 判断相等,以及字典序比较的规则和实现。
  3. 高效字符串搜索:介绍了Rabin-Karp指纹算法,它通过哈希值和滑动窗口,在平均情况下能显著提升在长文本中搜索子串的效率。

字符串算法是计算机科学的基础,广泛应用于文本处理、生物信息学、数据压缩和搜索引擎等领域。理解这些基本算法将为你解决更复杂的问题奠定坚实的基础。

013:期中考试复习指南 📚

在本节课中,我们将一起回顾期中考试的结构、内容以及高效的备考策略。我们将涵盖考试形式、复习重点、答题技巧,并帮助你制定一个成功的应考计划。

考试结构与政策 📝

上一节我们介绍了课程的整体情况,本节中我们来看看期中考试的具体安排。

考试总时长为 140分钟,分为两个部分:

  1. 选择题部分:通过Canvas平台完成,共24题,每题2.5分。
  2. 自由作答部分:通过Gradescope平台完成,共2道编程题。

重要提醒:所有题目内容(包括自由作答题的描述)都将在Canvas的测验中提供。请勿在完成两部分题目之前提交Canvas测验,否则你将无法阅读自由作答题目。

考试期间如有疑问,请通过Piazza以私密帖子的形式提问,教学团队会尽快回复。请勿公开发布问题。

关于考试资料,允许使用课程提供的所有材料(如讲义PDF、实验资料)。虽然不强制要求,但强烈建议手写一份“小抄”。整理小抄的过程能有效激活记忆,帮助你聚焦核心概念。

考试内容概览 🧠

了解了考试形式后,我们来看看具体会考察哪些知识点。

考试内容涵盖第1至13讲的所有主题,重点是:

  • 复杂度分析:包括递归关系及主定理(Master Theorem)的应用。
    • 主定理公式:对于递归式 T(n) = aT(n/b) + f(n),比较 ab^{log_b a} 的关系来确定主导项。
  • 容器对比:数组/向量(连续存储)与链表(指针连接)的优劣。
  • 栈、队列与优先队列:理解其特性和操作复杂度。
  • 排序算法:包括插入排序、快速排序、归并排序、堆排序等,掌握其适用场景。
  • 二叉堆:理解其结构(数组表示)及插入、删除操作。
  • 字符串与序列:重点理解指纹(Hashing)的概念和应用。

每个主题大致对应1-2道选择题。两道自由作答题中,一道侧重于迭代器机制,另一道则综合性更强,分值也更高。

成功应考策略 🏆

现在我们已经明确了考试内容,接下来学习如何高效地应对考试。

以下是帮助你取得好成绩的关键策略:

  • 保持冷静:焦虑不会带来分数。确保考前休息充分,饮食得当,以最佳状态应考。
  • 克服自我怀疑:你能进入这门课程并坚持到现在,已经证明了你的能力。相信你自己。
  • 像专业人士一样备考:运动员和音乐家都有周密的计划。作为“专业考生”,你也需要制定考试计划。
  • 通读试卷:开考后,先用5-10分钟快速浏览全部题目。这能让你的大脑在后台开始处理所有问题,缓解面对新题时的紧张感,并帮助你规划答题顺序。
  • 制定答题计划:根据题目分值和自身强弱项,提前规划时间分配。例如:
    • 计划A(均衡型):选择题每题2分钟 → 编程题1(30分钟)→ 编程题2(30分钟)→ 回顾难题(10分钟)。
    • 计划B(侧重编程):先攻编程题(各20分钟)→ 选择题每题3分钟 → 剩余时间检查编程题。
  • 做好笔记:在答题过程中,及时标记存疑的题目或已排除的错误选项,方便后续快速回顾。

核心概念复习与例题 💡

掌握了策略,我们通过一些例题来巩固核心概念。

复杂度分析

分析以下代码段的时间复杂度。

// 代码段1:二分查找变体
while (high >= low) {
    size_t mid = low + (high - low) / 2;
    if (array[mid] == x) return mid;
    else if (array[mid] < x) low = mid + 1;
    else high = mid - 1;
}
// 复杂度:O(log n)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-eecs281-dast-algo/img/728011c10b3af4cfb5b51971f9495418_1.png)

// 代码段2:嵌套循环
for (size_t i = 0; i < size; ++i) {
    for (size_t j = 0; j < size; ++j) {
        // 常数时间操作
    }
}
// 复杂度:O(n^2)

容器选择

根据场景选择最合适(或最不合适)的容器(单向链表、双向链表、向量)。

  • 场景1:需要频繁在特定已知元素前插入新元素。
    • 分析:链表在已知节点处的插入是O(1),而向量需要O(n)移动元素。由于需要“在前插入”,双向链表更优。
  • 场景2:存储大量1字节对象,且内存极度紧张。
    • 分析:链表每个节点都有指针开销(8或16字节),内存利用率低。向量(连续存储)开销最小,最合适;反之,双向链表最不合适。

用栈实现队列

以下是如何使用两个栈(S1, S2)实现队列的pop操作:

void pop() {
    if (S2.empty()) {
        while (!S1.empty()) {
            S2.push(S1.top()); // 将S1元素逆序转入S2
            S1.pop();
        }
    }
    if (!S2.empty()) {
        S2.pop(); // 从S2弹出,即队列的队首
    }
}
// push操作始终将元素压入S1。
// 摊还复杂度分析:每个元素最多被压入S1一次、弹出S1一次、压入S2一次、弹出S2一次,因此摊还复杂度为O(1)。

排序算法选择

根据数据特征选择最佳排序算法:

  • 几乎已排序的数组插入排序(接近O(n))。
  • 内存大小的大型数组堆排序(原地排序,O(n log n))。
  • 快速排序在大型输入上异常慢:可能重复选择了糟糕的枢轴,导致复杂度退化为O(n²)。

二叉堆操作

给定一个最大堆,插入新元素47并执行fixUp操作,需要能够画出调整后的堆树形图及其底层数组表示。

优先队列实现复杂度

回顾不同底层容器实现优先队列核心操作的复杂度:

操作 无序数组 有序数组 二叉堆
创建 O(n) O(n log n) O(n)
push O(1) 摊还 O(n) O(log n)
top O(n) O(1) O(1)
pop O(n) O(1) O(log n)

字符串指纹

理解字符串指纹(哈希)的核心概念:

  • 如果两个字符串的指纹不同,则它们一定不同
  • 如果两个字符串的指纹相同,它们可能相同,也可能不同(存在哈希碰撞可能)。


本节课中我们一起学习了期中考试的结构、核心考点以及一系列高效的备考和应考策略。记住,保持冷静、制定计划、相信自己的准备。考试很快就会结束。祝大家考试顺利!

014:哈希表简介 🗂️

在本节课中,我们将要学习一种名为“哈希表”的强大数据结构。哈希表是实现“字典”这一抽象数据类型的一种方式,它能够让我们以非常快的速度进行数据的插入和查找操作。

字典抽象数据类型 📖

上一节我们提到了哈希表是实现字典的一种方式。那么,什么是字典呢?

字典抽象数据类型是一个存储项目的容器。这些项目通常是键值对,就像单词和它的定义一样。这个容器支持两个基本操作:

  • 插入:向容器中插入一个新的键值对。
  • 搜索:给定一个键,检索出与该键关联的值或键值对。

字典主要有两种应用场景:

  • 集合追踪:检查某个元素是否在集合中(例如,根据学号检查学生是否注册了课程)。
  • 键值存储:通过键查找对应的值(例如,根据学号查找学生的实验和项目分数)。

除了插入和搜索,字典通常还支持其他操作,如删除指定键的项目、排序、选择第K大的项目以及合并两个字典等。

为什么需要哈希表?⚡

我们已经学习过多种数据结构,那么哪种结构能快速实现字典的插入和搜索呢?

  • 有序向量:搜索快(对数时间),但插入慢(线性时间,因为需要移动元素)。
  • 无序向量:插入快(常数时间),但搜索慢(线性时间)。
  • 链表:插入简单,但搜索也是线性时间。
  • 二叉搜索树(如STL中的map:平均情况下插入和搜索都是对数时间,但在最坏情况下(如输入顺序不当导致树退化成链表)会变成线性时间。

哈希表(在STL中实现为unordered_map)则提供了更优的平均性能:插入和搜索在平均情况下都是常数时间。虽然最坏情况下它也可能是线性的,但在精心设计和许多常见应用中,它都能保持接近常数时间的性能。

哈希表的基本思想 🧠

哈希表的核心思想是:将一个可能非常大的键空间,映射到一个更小、更易于管理大小的表中。

这个过程涉及三个关键部分:

  1. 翻译:将任意类型的键转换成一个整数。公式表示为:整数 = 翻译函数(键)
  2. 压缩:将这个(可能很大的)整数限制到哈希表有效索引的范围内(例如,0 到 M-1,其中 M 是表的大小)。公式表示为:索引 = 压缩函数(整数)
  3. 冲突处理:由于我们将一个大空间映射到小空间,冲突(即两个不同的键被映射到同一个索引)不可避免。我们需要机制来处理这种情况(本节课稍作介绍,下节课深入讨论)。

哈希函数就是翻译和压缩这两个步骤的组合:哈希值 = 压缩(翻译(键))

翻译:将键转换为整数 🔢

翻译步骤的目标是将任何类型的键(字符串、浮点数、自定义对象等)转换成一个整数。

对于整数:翻译非常简单,整数本身就是整数。
对于浮点数:如果已知其范围 [s, t),可以使用公式:floor((key - s) / (t - s) * M) 直接得到索引(这里结合了翻译和压缩)。
对于字符串:简单的翻译(如将字符ASCII码相加)效果不好,因为“stop”和“tops”会得到相同的值。更好的方法是考虑字符位置,类似于构建一个“数字”,例如使用类似 ((c1 * p + c2) * p + c3) ... 的方法(其中 p 是一个质数),这能有效区分字符顺序不同的字符串。

C++标准库为大多数基本类型和容器提供了 std::hash 函数来完成翻译步骤。

压缩:将整数映射到表范围 🔧

翻译得到的整数可能很大,我们需要将其压缩到哈希表索引的范围 [0, M-1) 内。

最常用的方法是取模运算索引 = 整数 % M
为了使分布更均匀,M 最好选择一个质数,这样可以减少键与 M 有公因数导致的分布不均。

如果我们不能控制 M 的选择(例如,使用别人提供的哈希表库),为了降低与未知 M 的关联性,可以使用以下公式进行压缩:索引 = ((a * 整数) + b) % M,其中 a 和 b 是质数,且确保 a % M != 0

哈希函数的设计要点 🎯

一个好的哈希函数必须满足以下几点:

  • 必须快速计算
  • 必须完备:对所有可能的键都能计算出哈希值。
  • 必须具有确定性:相同的键总是产生相同的哈希值。

此外,它应该能将键均匀地分布在哈希表中,以最小化冲突。

哈希表的性能分析 📊

完美哈希(无冲突)的理想情况下,插入、搜索和删除都只需要计算哈希函数(常数时间)和索引访问(常数时间),因此是 O(1) 复杂度。

然而,冲突是不可避免的(就像“生日悖论”所揭示的)。在最坏情况下,如果所有键都哈希到同一个值,性能会退化为 O(n)。但在平均情况下,通过良好的哈希函数和合适的表大小 M,我们可以将冲突控制在很低的水平,从而实现接近常数时间的操作。

C++ STL 中的哈希表 🛠️

C++标准模板库提供了基于哈希表的容器:

  • unordered_set:只存储键的集合。
  • unordered_map:存储键值对的映射。
  • 它们对应的“multi”版本(unordered_multiset, unordered_multimap)允许重复键。

以下是一个使用 unordered_map 统计单词频率的简单示例代码框架:

#include <iostream>
#include <unordered_map>
#include <string>

int main() {
    std::unordered_map<std::string, int> word_count; // 键:单词,值:频率
    std::string word;

    // 模拟读入单词
    while (std::cin >> word) {
        // 清理单词(转小写、去标点等)...
        // clean_word = cleanup(word);

        if (!clean_word.empty()) {
            ++word_count[clean_word]; // 插入或递增计数
        }
    }

    // 查找示例
    std::string query = "hello";
    auto it = word_count.find(query);
    if (it != word_count.end()) {
        std::cout << query << " appears " << it->second << " times.\n";
    } else {
        std::cout << query << " not found.\n";
    }

    return 0;
}

使用 map[key] 访问不存在的键会创建该键(值初始化为0),而 find(key) 则更安全,它返回一个迭代器,需要检查是否等于 end()

哈希表的应用与权衡 ⚖️

哈希表常用于需要快速查找的场景,例如:

  • 数据库索引
  • 编译器中的符号表
  • 缓存实现
  • 统计频率(如单词计数)

然而,哈希表并非万能,在以下情况可能其他结构更合适:

  • 键空间很小且连续:直接使用数组(桶数组)即可,例如用数组索引表示一年中的天数。
  • 不需要频繁动态插入,但需要多次查找:可以先将所有数据插入向量,然后排序,用二分查找。
  • 需要有序遍历键:哈希表本身是无序的。如果经常需要有序输出,二叉搜索树(map)或额外维护一个有序结构可能更好。
  • 空间开销敏感:哈希表有一定的空间开销(包括未使用的桶)。
  • 哈希函数计算成本高:如果键本身很简单,直接索引或比较可能更快。

对于复合键(如经纬度对),需要组合其组成部分的哈希值。一种有效的方法是使用类似“Boost库哈希组合”的技术:seed ^= hash_value(v) + 0x9e3779b9 + (seed << 6) + (seed >> 2)

总结 📝

本节课我们一起学习了哈希表的基础知识。我们了解了字典抽象数据类型,探讨了哈希表如何通过哈希函数将键映射到数组索引来实现平均常数时间的插入和搜索。我们深入分析了哈希函数的两个关键步骤——翻译和压缩,并讨论了设计良好哈希函数的要点。最后,我们通过实例看到了C++ STL中哈希表容器的用法,并探讨了其应用场景以及与其他数据结构的权衡。

下节课,我们将解决哈希表的核心挑战:冲突处理,学习当两个键哈希到同一位置时该如何解决。

015:哈希与冲突解决 🔑

在本节课中,我们将学习哈希表的核心概念——冲突解决。当不同的键通过哈希函数映射到哈希表的同一个位置时,就会发生冲突。我们将探讨两种主要的冲突解决方法:分离链接法和开放寻址法,并了解如何通过动态哈希和摊销分析来维持哈希表的高效性能。


分离链接法

上一节我们介绍了哈希表的基本概念。本节中,我们来看看第一种冲突解决方法:分离链接法。

分离链接法的核心思想是,哈希表的每个桶(bucket)不再直接存储一个键值对,而是存储一个指向链表的指针。当多个键哈希到同一个索引时,它们会被存储在同一个链表中。

以下是分离链接法的关键点:

  • 结构:哈希表是一个大小为 M 的数组,每个元素是一个指向链表的指针。
  • 负载因子:定义为 α = N / M,其中 N 是键的数量,M 是表的大小。在分离链接法中,α 可以大于1,它代表每个链表的平均长度。
  • 操作复杂度
    • 插入(允许重复键)O(1),直接插入链表头部。
    • 插入(不允许重复键)/ 查找 / 删除O(α),需要遍历链表查找特定键。
  • 优化:可以使用有序向量或平衡二叉搜索树代替链表,将查找复杂度降至 O(log α),但会增加内存开销。

分离链接法简单直观,是许多场景下的默认选择。


开放寻址法

了解了基于链表的冲突解决方法后,本节我们来看看另一种思路:开放寻址法。

开放寻址法将所有键值对直接存储在哈希表数组本身中。当发生冲突时,它会按照某种探测序列在表中寻找下一个可用的空桶。

探测时可能遇到三种情况:

  1. 空桶:键不存在(查找时)或可以在此插入(插入时)。
  2. 命中:找到了要查找的键。
  3. 占用:该桶已被其他键占用,需要继续探测。

接下来,我们将介绍三种常见的探测方法。

线性探测

线性探测是最简单的开放寻址方法。如果目标桶已被占用,就顺序检查下一个桶(索引加1),直到找到空桶或目标键。到达数组末尾时,循环回到开头。

线性探测的主要问题是容易形成主集群,即长的连续占用块。这会导致性能严重下降:

  • 成功查找的平均探测次数~ (1 + 1/(1-α)) / 2
  • 失败查找的平均探测次数~ (1 + 1/(1-α)^2) / 2
    当负载因子 α 接近1时,探测次数会急剧增加。

二次探测

为了缓解线性探测的集群问题,我们可以使用二次探测。

二次探测的增量是探测次数的平方。即,第 j 次探测的索引为:(hash(key) + j^2) mod M
这有助于让探测位置更分散,减少主集群的形成,性能优于线性探测。

双重哈希

最有效的探测方法之一是双重哈希。

它使用两个哈希函数:h1(key) 计算初始索引,h2(key) 计算探测步长。第 j 次探测的索引为:(h1(key) + j * h2(key)) mod M
由于步长依赖于键本身,不同的键会产生不同的探测序列,能最大程度地减少集群。


删除操作与桶状态管理

在开放寻址法中,删除操作需要特别处理。不能简单地将桶置空,因为这可能会切断其他键的探测路径。

解决方案是引入一个“已删除”标记。删除键时,将其桶标记为“已删除”。在查找时,将“已删除”桶视为占用并继续探测;在插入时,可将其视为空桶并重用。

因此,每个桶需要维护三种状态:

  • 占用
  • 已删除

在代码中,可以使用枚举类型来清晰地表示这些状态:

enum class BucketStatus { EMPTY, OCCUPIED, DELETED };

动态哈希与摊销分析

无论采用哪种冲突解决方法,随着元素不断插入,哈希表的性能都会因负载因子升高而下降。解决方案是动态调整哈希表大小。

常见策略是:当负载因子 α 超过某个阈值(例如0.5)时,将哈希表大小加倍,并将所有现有键重新哈希到新表中。

重新哈希整个表的操作成本很高,是 O(M) 的。但因为它发生的频率很低(每次表扩容后才发生一次),我们可以使用摊销分析来评估其平均成本。

摊销分析思想:将一次昂贵操作的成本分摊到许多次廉价操作上。

  • 每次插入时,我们支付一次插入的成本,并“储存”一点额外的成本。
  • 当需要进行昂贵的扩容操作时,我们使用之前储存的“余额”来支付它。
  • 这样,每个插入操作的平均(摊销)成本仍然是 O(1)


代码示例:一个简单的链式哈希表

最后,我们通过一个简单的代码示例来理解分离链接法的实现。以下是一个简化哈希表接口的核心部分:

template <typename Key, typename Value, typename Hasher>
class HashTable {
public:
    void add(const Key& k, const Value& v); // 插入或更新
    void remove(const Key& k);              // 删除
    Value& get(const Key& k);               // 查找,不存在则创建
    Value& operator[](const Key& k);        // 下标运算符,同 get
    void clear();                           // 清空
private:
    size_t compress(const Key& k) {
        return hasher(k) % TABLE_SIZE; // 哈希与压缩
    }
    LinkedList<Key, Value> table[TABLE_SIZE]; // 链表数组
    Hasher hasher;
};

其中,get 操作在对应链表中查找键,若找到则返回值引用;若未找到,则创建新节点(使用默认值)插入链表头部并返回其值引用。


本节课中我们一起学习了哈希表冲突解决的两种主要方法:分离链接法和开放寻址法(包括线性探测、二次探测和双重哈希)。我们还探讨了如何通过标记删除处理开放寻址中的删除操作,以及如何利用动态哈希和摊销分析来维持哈希表在长期操作中的高效性能。理解这些原理是有效使用和实现哈希数据结构的基础。

016:树抽象数据类型、树中搜索、二叉树 🌳

在本节课中,我们将学习树这种数据结构的基本概念、定义和性质。我们将重点介绍二叉树,包括其定义、实现方式以及如何在树中进行搜索。课程内容将涵盖树的基本术语、遍历算法以及二叉搜索树的核心性质。


概述

树是一种非常重要的数据结构,用于表示具有层次关系的数据。在本节中,我们将首先介绍树的基本定义和术语,然后深入探讨一种特殊的树——二叉树。我们将学习如何实现二叉树,以及如何对其进行遍历和搜索。


树的定义与基本概念

树是图的一种特殊形式。图由一组节点(或称为顶点)和连接这些节点的边组成。树是一个连通无环的图。这意味着树中的任意两个节点之间都存在一条唯一的路径。

另一种定义是:树是一个图,其中任意两个节点之间都通过一条唯一的最短路径相连。这两种定义是等价的。

在树中,我们通常指定一个方向,从而可以识别父节点子节点的关系。在一个有向树中,有一个特定的节点被称为根节点

二叉树

二叉树中,每个节点最多有两个子节点。这意味着一个节点可以有零个、一个或两个子节点。

简单树是指任何无环连通图,它没有指定的方向。而有根树则是指定了一个节点作为根的树。在树中,任何节点都可以被选为根,但这可能会影响性能。

更多定义

  • 根节点:树的顶端节点。
  • 父节点:一个节点的直接前驱节点。例如,A 是 B 的父节点。
  • 子节点:一个节点的直接后继节点。例如,I 是 C 的子节点。
  • 祖先节点:一个节点的父节点、祖父节点等。例如,H 的祖先是 D、B 和 A。
  • 后代节点:一个节点的子节点、孙子节点等。例如,A 的后代是树中所有其他节点。
  • 内部节点:拥有子节点的节点。
  • 外部节点/叶节点:没有子节点的节点。

树的一个非常酷的特性是它们是递归结构。以节点 2 为根的树是一棵树,而以节点 6 为根的子树也是一棵树。如果整个结构是二叉树,那么它的所有子结构也都是二叉树。这个递归性质非常有价值,因为许多树算法都是递归的。

树的度量

  • 高度
    • 空树的高度为 0。
    • 任何节点的高度是其左子树高度和右子树高度的最大值加 1。
  • 大小:树中节点的总数。大小 = 1 + 左子树大小 + 右子树大小。
  • 深度:从根节点到该节点的路径长度。根的深度为 1,其子节点的深度为 2,依此类推。

二叉树实现

上一节我们介绍了树的基本概念,本节中我们来看看如何具体实现二叉树。

二叉树是一种有序树,每个节点的子节点之间存在线性顺序(例如小于关系)。二叉树是有序树,且每个节点最多有两个子节点。

数组实现

当我们实现堆时,我们使用了完全二叉树,并用数组来表示它。在完全二叉树中,除了最后一层,其他层都是满的,并且最后一层的节点都集中在左边。

在数组实现中:

  • 根节点在索引 1。
  • 索引 i 处节点的左子节点在索引 2*i
  • 索引 i 处节点的右子节点在索引 2*i + 1

然而,对于有序二叉树(二叉搜索树),其约束是:左子树中的所有节点都小于父节点,右子树中的所有节点都大于或等于父节点。由于这个约束,新节点插入的位置不一定能保持树的完全性,可能导致树变得稀疏(即有很多“空洞”)。

数组实现的优缺点:

  • 最佳情况(树是完全的):空间效率高,为 O(n)
  • 最坏情况(树是线性的,像一根“棍子”):空间效率极低,可能高达 O(2^n)
  • 查找父节点和子节点的操作很快(常数时间)。
  • 插入和删除操作在最坏情况下可能是线性的。

由于许多二叉树是稀疏的,我们通常不使用数组来实现通用的二叉搜索树。

指针实现

更常见的是使用基于指针的结构来实现二叉树。我们已经在项目二的配对堆中见过这种实现。

一个典型的树节点类模板可能包含:

  • 数据(key
  • 指向左子节点的指针(left
  • 指向右子节点的指针(right

指针实现的优缺点:

  • 最佳和最坏情况的空间复杂度都是 O(n),因为每个节点需要固定数量的指针。
  • 向下移动(访问子节点)是常数时间。
  • 向上移动(查找父节点)在最坏情况下是线性的,需要遍历整个树。但通常我们不需要频繁查找父节点,因为递归算法可以在下降过程中传递父节点信息或在返回时获取。
  • 插入和删除操作在最坏情况下仍然是线性的(对于“棍子”形树)。

将最坏情况空间复杂度从指数级 (O(2^n)) 降低到线性 (O(n)) 是一个巨大的优势。

可选:父指针
如果某些应用需要快速访问父节点,可以在节点结构中显式添加一个 parent 指针。但这并不常见,因为递归通常提供了替代方案。

一般树到二叉树的转换

有趣的是,任何一般树都可以通过一个机械的转换过程变成二叉树。其核心思想是:对于一个有多个子节点 C1, C2, C3, C4 的节点,让 C1 成为新节点的左子节点,而 C1 的兄弟节点(C2, C3, C4)则成为 C1右子节点链。然后对这个结构递归地应用相同的转换。这样,向左移动代表进入下一代,向右移动代表在兄弟节点间移动。


树的遍历算法 🌐

了解了树的结构后,我们需要一种方法来访问和处理树中的所有节点。这就是遍历算法。

遍历算法主要有四种:三种是深度优先的递归遍历,一种是广度优先的迭代遍历。

深度优先遍历(递归)

这三种遍历的区别在于访问当前节点的时机与递归访问左右子树的时机的相对顺序。

以下是递归实现的核心思想:

  1. 前序遍历

    • 先访问当前节点。
    • 然后递归遍历左子树。
    • 最后递归遍历右子树。
    • 代码模式visit(node); preorder(node->left); preorder(node->right);
  2. 中序遍历

    • 先递归遍历左子树。
    • 然后访问当前节点。
    • 最后递归遍历右子树。
    • 代码模式inorder(node->left); visit(node); inorder(node->right);
  3. 后序遍历

    • 先递归遍历左子树。
    • 然后递归遍历右子树。
    • 最后访问当前节点。
    • 代码模式postorder(node->left); postorder(node->right); visit(node);

其中,visit(node) 代表对节点的操作,例如打印节点值。

广度优先遍历(迭代)

  1. 层序遍历
    • 从根节点开始,逐层访问节点。
    • 先访问第一层(根),然后是第二层(根的所有子节点),接着是第三层,以此类推。
    • 这需要使用一个队列来辅助实现。

层序遍历算法步骤

  1. 如果树为空,直接返回。
  2. 创建一个队列,并将根节点入队。
  3. 当队列不为空时循环:
    a. 取出队首节点并出队。
    b. 访问该节点(例如打印)。
    c. 如果该节点有左子节点,将左子节点入队。
    d. 如果该节点有右子节点,将右子节点入队。

这个算法类似于项目一中寻找公主时使用的队列搜索算法。

关于代码习惯的说明:在循环中,常见的习惯是 curr = queue.front(); queue.pop();。这样做的原因是:1) 避免多次调用 front() 函数;2) 立即将已处理的节点移出队列,逻辑清晰,不易忘记。


二叉搜索树 🔍

前面我们讨论了普通的二叉树,现在我们将目光聚焦到一种具有特定顺序约束的二叉树——二叉搜索树。

定义与性质

二叉搜索树是一种有序二叉树,它满足以下二叉搜索树性质
对于树中的任意节点,其左子树中所有节点的值都小于该节点的值;其右子树中所有节点的值都大于或等于该节点的值。

这个性质带来了一个强大的特性:中序遍历二叉搜索树,会得到一个有序的序列。因此,二叉搜索树可以用于排序。

搜索操作

在二叉搜索树中搜索一个键值 k 的逻辑直接源于其定义:

  1. 从根节点开始。
  2. 如果当前节点为空,说明未找到,返回空指针。
  3. 如果 k 等于当前节点的键值,则找到,返回该节点指针。
  4. 如果 k 小于当前节点的键值,则 k 只可能存在于左子树中,因此在左子树中递归搜索。
  5. 如果 k 大于当前节点的键值,则 k 只可能存在于右子树中,因此在右子树中递归搜索。

这个算法可以递归或迭代实现。其运行时间取决于树的高度

  • 最佳情况(树是平衡、茂密的):高度为 O(log n),搜索时间为对数级。
  • 最坏情况(树退化成线性“棍子”):高度为 O(n),搜索时间为线性级。

重要提示:树的形状(茂密还是棍状)取决于元素插入的顺序。第一个插入的元素成为根节点,后续元素根据与根节点的大小关系被放置在左或右子树,并递归地确定其位置。

插入操作

插入操作与搜索非常相似。为了插入一个新键值 k,我们执行搜索操作,寻找 k 本应存在的位置。当我们到达一个空的子节点指针(即 nullptr)时,我们就在这个位置创建并链接一个新节点。因此,插入的复杂度也与树高相同。


总结

本节课中我们一起学习了树数据结构的基础知识。我们首先定义了树、二叉树及其相关术语(根、叶、父节点、子节点、高度、深度等)。然后,我们探讨了二叉树的两种实现方式:数组实现(适用于完全二叉树,如堆)和更通用的指针实现。

接着,我们介绍了四种重要的树遍历算法:前序、中序、后序(深度优先、递归)和层序(广度优先、迭代),并理解了它们的访问顺序和代码模式。

最后,我们深入研究了二叉搜索树,其核心性质是左子树节点值小于根节点,右子树节点值大于等于根节点。我们学习了如何在 BST 中进行搜索,其效率取决于树的高度,而树的高度又受元素插入顺序的影响。我们还了解到,对 BST 进行中序遍历可以得到有序的数据序列。

017:二叉搜索树与AVL树

在本节课中,我们将学习两种重要的数据结构:二叉搜索树和AVL树。我们将从基础的二叉搜索树开始,了解其定义、操作以及性能特点。随后,我们将深入探讨AVL树,这是一种自平衡的二叉搜索树,能够保证在最坏情况下也能维持对数级别的操作性能。

二叉搜索树

上一节我们介绍了树的基本概念。本节中,我们来看看一种特殊的树结构——二叉搜索树。

二叉搜索树是一种二叉树,其中每个节点都满足一个关键性质:对于任意节点,其左子树中所有节点的值都严格小于该节点的值;其右子树中所有节点的值都大于或等于该节点的值(如果允许重复值,则使用“大于或等于”;否则使用“严格大于”)。这个性质被称为二叉搜索树的不变性。

搜索操作

在二叉搜索树中搜索一个值,我们从根节点开始,将目标值与当前节点值进行比较。根据比较结果,我们决定向左子树或右子树递归搜索。

以下是递归版本的搜索算法代码:

Node* search(Node* x, Key k) {
    if (x == nullptr || k == x->key) {
        return x; // 未找到或找到目标节点
    }
    if (k < x->key) {
        return search(x->left, k); // 在左子树中搜索
    } else {
        return search(x->right, k); // 在右子树中搜索
    }
}

该算法的时间复杂度取决于树的高度。在平均情况下(节点以随机顺序插入),树是近似平衡的,高度为 O(log n),因此搜索时间为对数级。在最坏情况下(例如,所有节点按顺序插入形成一条“链”),树的高度为 O(n),搜索时间退化为线性。

插入操作

插入操作与搜索类似。我们从根节点开始,沿着树向下寻找新节点应该插入的合法位置(一个空指针),然后将新节点插入该处。

以下是插入操作的代码,注意指针是通过引用传递的,以便修改父节点的子指针:

void insert(Node*& x, Key k) {
    if (x == nullptr) {
        x = new Node(k); // 找到空位,创建新节点
        return;
    }
    if (k < x->key) {
        insert(x->left, k); // 应插入左子树
    } else {
        insert(x->right, k); // 应插入右子树(允许重复时,>= 的放右边)
    }
}

查找最小节点

要找到树中具有最小键值的节点,只需从根节点开始,持续向左子节点移动,直到没有左子节点为止。

Node* findMin(Node* x) {
    if (x == nullptr) return nullptr;
    while (x->left != nullptr) {
        x = x->left;
    }
    return x;
}

删除操作

删除节点是二叉搜索树中最复杂的操作,需要处理四种情况:

  1. 目标节点是叶子节点(无子节点):直接删除。
  2. 目标节点只有一个左子节点:用其左子节点替换它。
  3. 目标节点只有一个右子节点:用其右子节点替换它。
  4. 目标节点有两个子节点:这是最复杂的情况。我们需要找到该节点的中序后继(即其右子树中的最小节点),用这个后继节点的值替换目标节点的值,然后递归地删除那个后继节点(此时它最多只有一个右子节点,属于情况2或3)。

以下是删除操作的代码框架:

void remove(Node*& tree, Key val) {
    if (tree == nullptr) return; // 未找到要删除的节点
    if (val < tree->key) {
        remove(tree->left, val);
    } else if (tree->key < val) {
        remove(tree->right, val);
    } else { // 找到要删除的节点 tree
        // 情况1 & 2: 无左子节点
        if (tree->left == nullptr) {
            Node* toDelete = tree;
            tree = tree->right; // 用右子节点替换
            delete toDelete;
        }
        // 情况3: 无右子节点
        else if (tree->right == nullptr) {
            Node* toDelete = tree;
            tree = tree->left; // 用左子节点替换
            delete toDelete;
        }
        // 情况4: 有两个子节点
        else {
            // 找到右子树中的最小节点(中序后继)
            Node* successor = tree->right;
            while (successor->left != nullptr) {
                successor = successor->left;
            }
            // 用后继节点的值替换当前节点的值
            tree->key = successor->key;
            // 递归删除右子树中的那个后继节点
            remove(tree->right, successor->key);
        }
    }
}

AVL树 🌳

我们已经看到,二叉搜索树在最坏情况下性能会退化。为了解决这个问题,我们引入AVL树,这是一种自平衡二叉搜索树,由Adelson-Velsky和Landis提出。

AVL树在二叉搜索树的基础上增加了一个额外的平衡条件:对于树中的每个节点,其左子树的高度右子树的高度之差(称为平衡因子)的绝对值不超过1。即对于任意节点 N,满足:
|height(N.left) - height(N.right)| ≤ 1

这个条件确保了树始终保持大致平衡,从而将最坏情况下的操作时间复杂度也控制在 O(log n)

旋转操作

当插入或删除节点导致平衡因子超出范围(即变为+2或-2)时,我们需要通过旋转操作来重新平衡树。旋转是一种局部调整,只涉及少数几个节点和指针,可以在常数时间内完成。

主要有两种基本旋转:

  • 右旋:用于修正左子树过高的情况。
  • 左旋:用于修正右子树过高的情况。

有时,单次旋转不足以解决问题,需要进行双旋转(先对子节点进行一次旋转,再对当前节点进行一次旋转)。

根据不平衡节点及其较重子树的平衡因子符号,可以确定需要哪种旋转:

不平衡类型 (节点平衡因子) 较重子树平衡因子 所需操作
左左型 ( +2 ) +1 单次右旋
右右型 ( -2 ) -1 单次左旋
左右型 ( +2 ) -1 先左旋(子节点),再右旋
右左型 ( -2 ) +1 先右旋(子节点),再左旋

AVL树插入

AVL树的插入分为两步:

  1. 像普通二叉搜索树一样,递归地找到位置并插入新节点。
  2. 在递归返回(回溯)的过程中,更新沿途每个节点的高度,并检查其平衡因子。如果发现某个节点不平衡(平衡因子为+2或-2),则根据上述规则进行相应的旋转操作。

由于插入只会使一条路径上的子树高度增加1,而旋转操作可以修复不平衡且不改变修复节点以上的高度,因此插入操作最多只需要一次旋转修复

AVL树删除

删除操作比插入更复杂,因为删除一个节点可能使多个祖先节点变得不平衡。删除步骤类似:

  1. 执行标准二叉搜索树删除。
  2. 从被删除节点的位置开始向上回溯,更新节点高度并检查平衡。
  3. 对每一个发现不平衡的节点,进行必要的旋转操作。

与插入不同,删除可能需要在回溯路径上的多个节点处进行旋转,因为旋转修复一个节点的不平衡后,可能会使其父节点变得不平衡。尽管如此,由于树的高度为 O(log n),且每次旋转是常数时间,所以删除的总时间复杂度仍然是 O(log n)

总结

本节课中我们一起学习了两种关键的树形数据结构。

  • 二叉搜索树 提供了一种基于排序的高效数据组织方式,支持快速的搜索、插入和删除(平均情况 O(log n))。但其性能依赖于树的平衡性,最坏情况下(输入有序)会退化为 O(n)
  • AVL树 通过强制实施平衡条件(每个节点左右子树高度差 ≤ 1)和旋转操作,解决了二叉搜索树的不平衡问题。它保证了所有字典操作(搜索、插入、删除)在最坏情况下也能在 O(log n) 时间内完成,是一种强大的自平衡二叉搜索树。

理解这些树结构的操作、平衡机制以及性能特征,对于设计和分析高效算法至关重要。

018:-19-W21 Lab 11 (Bonus)

概述

在本节课中,我们将学习C++中一些更高级的主题,包括移动语义、智能指针、RAII、Lambda表达式、变参模板、Trie树、多态性的底层实现以及结构化绑定。这些概念对于进行高级C++编程非常有帮助。

移动语义 🚚

上一节我们介绍了课程概述,本节中我们来看看移动语义。移动语义基于“三法则”的概念。如果你在EECS 281之前上过EECS 280,你会学到“三法则”:如果你定义了一个自定义类或结构体,并且需要定义以下任何一个:拷贝构造函数、拷贝赋值运算符或析构函数,那么你可能需要定义所有这三个。这通常涉及管理动态分配的内存。

三法则回顾

如果一个类在构造函数中调用了new来分配内存,那么在拷贝构造函数和拷贝赋值运算符中,我们通常希望对分配的数据进行深拷贝,而不仅仅是浅拷贝指针本身。在析构函数中,如果我们在构造函数中调用了new,我们很可能需要在析构函数中调用delete

基于三法则,我们有时会遇到效率问题,存在可以优化的场景。例如,如果我们知道另一个对象不再需要其数据,我们可以直接“窃取”它,而不是进行完整的深拷贝。

移动语义的动机

窃取数据通常比复制数据快得多。对于我们的自定义向量类,深拷贝是O(N)操作,而窃取操作可以认为是仅复制指针本身,是O(1)操作。移动语义允许我们在确定不需要复制时窃取内部数据。

好的编译器在某些情况下(例如从函数返回临时对象时)会自动避免不必要的拷贝,这称为返回值优化(RVO)。但编译器并不总是足够智能,这时就需要我们显式地使用移动语义。

移动构造函数和移动赋值运算符

为了支持移动语义,我们引入了“五法则”,即在三法则的基础上增加移动构造函数和移动赋值运算符。

移动构造函数的语法是使用双引用&&,这表示参数是一个右值引用。在移动构造函数中,我们通常执行浅交换(shallow swap),即交换指针等成员,而不是深拷贝。

以下是移动构造函数的示例代码:

MyVector(MyVector&& other) noexcept
    : size_(0), capacity_(0), data_(nullptr) {
    swap(*this, other);
}

移动赋值运算符类似:

MyVector& operator=(MyVector&& other) noexcept {
    swap(*this, other);
    return *this;
}

在移动数据时,我们需要确保被移动的对象在析构时是安全的,因此通常先将其成员初始化为默认值(如nullptr),然后再进行交换。

std::move 函数

std::move 用于指示一个对象可以被移动,即允许高效地将资源从一个对象转移到另一个对象。它本质上等同于将对象静态转换为右值引用类型。

使用 std::move 时需要注意,一旦对象被移动,其状态是未定义的,不应再被使用。

规则是:仅当你确定在移动后不再需要该变量的值时,才使用 std::move

通用引用和完美转发

通用引用(使用 T&& 在模板中)可以同时接受左值和右值引用,并保留其原始值类别。结合 std::forward 可以实现完美转发,将参数以原始的值类别传递给其他函数。

智能指针 🤖

管理动态分配的内存很困难,容易导致悬空指针、双重释放和内存泄漏等问题。智能指针是一类对象,可以自动管理内存,避免显式使用 newdelete

std::unique_ptr

std::unique_ptr 独占对象的所有权。当 unique_ptr 被销毁时,它会自动删除所管理的对象。它不可复制,但可以移动。

std::shared_ptr

std::shared_ptr 允许多个指针共享同一个对象的所有权。它通过引用计数来跟踪有多少个 shared_ptr 指向同一对象。当引用计数变为零时,对象被自动删除。

shared_ptr 的实现通常包含一个控制块,其中存储引用计数和指向对象的指针。

std::weak_ptr

std::weak_ptr 是对 shared_ptr 所管理对象的弱引用。它不增加引用计数,因此不会阻止对象被销毁。它可用于解决 shared_ptr 的循环引用问题。通过 lock() 方法可以获取一个指向对象的 shared_ptr(如果对象还存在)。

make_sharedmake_unique

推荐使用 make_sharedmake_unique 来创建智能指针,而不是直接使用 new。它们更安全、更高效。

RAII(资源获取即初始化)🔒

RAII 是一种编程理念,将资源(如内存、文件句柄、锁)的生命周期与对象的生命周期绑定。在构造函数中获取资源,在析构函数中释放资源。这确保了即使发生异常,资源也能被正确释放。

一个常见的例子是管理输出流格式的类:

class CoutFormatSaver {
public:
    CoutFormatSaver() : old_flags_(std::cout.flags()), old_precision_(std::cout.precision()) {}
    ~CoutFormatSaver() {
        std::cout.flags(old_flags_);
        std::cout.precision(old_precision_);
    }
private:
    std::ios::fmtflags old_flags_;
    std::streamsize old_precision_;
};

Lambda表达式与函数式库 🔧

Lambda表达式是匿名函数,可以在需要函数对象的地方内联定义,特别适用于定义只使用一次的简单函数,例如比较器。

Lambda的基本语法是:

[capture-list] (parameters) -> return-type { body }

捕获列表允许Lambda访问其所在作用域中的变量。Lambda可以按值或按引用捕获变量。

如果Lambda体包含多个返回语句或返回类型不明确,需要使用尾置返回类型来显式指定返回类型。

Lambda可以存储在 auto 类型的变量中,像普通函数一样调用。

变参模板 📦

变参模板允许函数接受任意数量的参数。在函数体内,我们处理部分参数,然后递归处理剩余的参数。

一个典型的变参模板函数如下:

template<typename T, typename... Args>
void print(T first, Args... args) {
    std::cout << first << " ";
    print(args...);
}

// 基础情况
template<typename T>
void print(T last) {
    std::cout << last << std::endl;
}

需要定义一个处理单个参数的基础情况函数来终止递归。

变参模板在编写通用代码(如日志函数)时非常有用。

Trie树(前缀树) 🌳

Trie树是一种用于存储字符串的节点型数据结构。它通过共享前缀来高效存储字符串集合,插入、查找和删除操作的时间复杂度为O(k),其中k是字符串长度。

每个Trie节点包含一个子节点指针数组(例如,26个字母)和一个标志位,指示该节点是否是某个单词的结尾。

虽然Trie树在理论上与哈希表有相同的时间复杂度下限,但在实践中,由于避免了哈希计算,它可能更快。缺点是内存开销较大。

多态性的底层实现 🧬

在C++中,派生类对象也是基类对象。这是因为派生类对象在内存中包含基类子对象,指向派生类对象的指针与指向其基类子对象的指针指向相同的地址。

虚函数通过虚函数表(vtable)实现。每个包含虚函数的类都有一个vtable,其中存储了指向其虚函数的指针。每个对象包含一个指向其类vtable的指针(vptr)。调用虚函数时,通过vptr找到vtable,再通过偏移调用正确的函数。

这种实现方式非常高效,开销仅为一个指针解引用和数组访问。

结构化绑定 🎁

结构化绑定允许从元组或结构体中一次性解包多个值,类似于其他语言中的多返回值。

语法如下:

auto [var1, var2, ...] = tuple_like_object;

例如,从std::unordered_map::insert的返回值中解包:

auto [iter, success] = my_map.insert({"key", value});

结构化绑定提高了代码的可读性。

总结

本节课我们一起学习了C++中多个高级主题:移动语义允许我们高效转移资源;智能指针自动管理内存;RAII将资源生命周期与对象绑定;Lambda表达式提供内联匿名函数;变参模板处理可变数量参数;Trie树高效存储字符串;多态性通过虚函数表实现;结构化绑定简化了多值返回。掌握这些概念将有助于你进行更高效、更安全的C++编程。

posted @ 2026-03-29 09:41  布客飞龙I  阅读(3)  评论(0)    收藏  举报