UCSC-C---编程笔记-全-
UCSC C++ 编程笔记(全)
001:概述与课程组织 🚀
在本节课中,我们将学习C++编程课程的整体安排,并探讨C++语言的基本概念及其与C语言的关系。我是加州大学圣克鲁兹分校的Ira Pohl教授,将在接下来的几周里担任你们的C++课程讲师。
课程组织与目标
本课程分为两个为期四周的模块。
第一个模块将带您学习C++的基础知识。今天,我们将从“C++作为更好的C”这一概念开始,并尝试理解“更好”的含义。课程结束后,我将布置第一份作业,要求您将发布的一些C代码转换为C++代码,您可以在今天的讨论后立即开始。
接下来的两周将涉及图论程序。我将解释这些算法,一个是最短路径算法,另一个是最小生成树算法。本课程期望学员已具备至少一年的C语言(或同等语言)编程经验,大致相当于计算机科学专业大一大二的水平。这意味着您需要理解基本的数据结构(如列表和栈),并具备算法学习的基础,包括我们即将实现的一些内容。因此,对许多学员来说,这可能是在重温学生时代已学过的知识。但在前四周结束时,如果您已经掌握了C语言,您应该能在专业水平上熟练使用C++。
为了完成课程学习,我们将在一些录播段落的末尾设置测验,最后还会有一个期末考试。
何为“更好”?

“一个事物比另一个事物更好”意味着什么?这并非一个简单的概念。
我们可以从多个角度思考这个问题。例如,在棒球史上,Willie Mays和Mickey Mantle谁更优秀?在纽约的酒吧里,人们曾整日争论不休。同样,在烹饪中,黄油和人造黄油在特定情境下可以互换,但哪个更好?这取决于标准:热量、健康因素(如胆固醇含量)、风味或维生素含量。答案因上下文而异,并不简单。
现在,让我们回到我们的主题:C++。
C与C++的渊源
本课程假设您已具备至少一年的C语言(或同等语言)编程经验,并且能力合格。C语言有着深厚的历史。
传统的C语言由Dennis Ritchie发明。他与Ken Thompson一起工作,共同发明了Unix操作系统,并因此获得了计算机科学界的最高荣誉——图灵奖。这个始于1972年贝尔实验室的双人项目,最初用一种称为B的语言编写操作系统,而B语言又源自BCPL等,可追溯至1968年由国际计算机科学家团体发明的算法语言标准——Algol。Algol可能是编程语言历史上最具影响力的单一语言。
Dennis和Ken为他们的特定项目创造了C语言,我们称这类语言为系统实现语言。它最初并非为广泛使用而设计,更像是个人语言。他们的指导原则之一是“尽可能少打字”,因此语言极其简洁,包含许多奇怪的运算符,这些运算符符合他们当时使用的PDP小型微处理器的操作系统和架构。
这个项目非常成功,人们希望扩展它。随后,Brian Kernighan和Ritchie编写了《The C Programming Language》(即K&R C),这成为了我们专业领域最具影响力的书籍之一。相当原始的K&R C类型安全性较低,在80年代演变为ANSI C。随着C语言,尤其是专业用途的扩展,标准化委员会成立并进行了重大改进。1985年左右出现的ANSI C至今仍被广泛使用。后来,委员会继续工作,推出了更新的标准。但可以说,工业界的大部分C语言工作仍基于1985年的标准,该标准记载于许多学员可能拥有的《K&R C》第二版中。
为何转向C++?
既然C语言如此成功和重要,为何还要转向C++?
C++语言由Bjarne Stroustrup创造,他当时也在贝尔实验室。Stroustrup来自丹麦的面向对象编程传统,他最初在一种由挪威计算机科学家开发的称为Simula 67的语言中学习编程,并认为那是更好的方法论。但这在贝尔实验室并不受支持;在贝尔实验室工作,你需要使用C语言。不过,扩展C语言是被允许的,因此存在许多预处理器,它们将其他编程范式中的思想添加到C语言中。例如,有一种包含大量并发概念的C语言变体。
Bjarne遵循这一传统,发明了一种最初称为“带类的C”的语言。他通过预处理器将其添加进来,并且像C语言一样取得了巨大成功。因此,它脱离了其根源——即充满活力的贝尔实验室和AT&T的基础研究环境。贝尔实验室决定将其作为工业产品进行支持,允许Bjarne Stroustrup发布和支持C++ 1.0版本。那是在1985年。随后,C++语言像C语言一样发展,得到了工业界的支持和ANSI标准委员会的修改、构建和改进。到1996年出现主要ANSI标准时,C++已发展得相当庞大。
C与C++的核心区别
以下是C与C++的一些核心区别:
- 规模与范式:C语言小巧,主要是一种系统实现语言。C++则庞大,堪称编程的“瑞士军刀”,为各种场合提供工具。
- 编程范式支持:C语言支持命令式编程(即经典编程,通过编写小函数来划分程序模块)。C++除了支持命令式编程,还支持两种非常重要的编程范式:
- 面向对象编程(可类比Simula)。
- 泛型编程,这将是本课程重点强调的内容。您可能对泛型编程经验不多,而这正是接下来几周学习材料的主要益处之一。
C++比C更好吗?
现在,请根据您目前对C和C++(可能还知之甚少)的了解思考一下:是什么让C++比C更好?毕竟,这是我们的第一个主题。
以下是几个选项:
A. 它发明得更晚(大约晚10年)。
B. Bjarne Stroustrup是比Dennis Ritchie更好的语言设计师(两人都来自贝尔实验室)。
C. C++比C拥有更多特性。
D. C++更简单。

您的选择是什么?
实际上,编程语言领域并非总是“后来者更好”。例如,Algol 60或Algol 68就被一些人认为是有史以来设计最优雅的语言之一。Dennis和Bjarne都是优秀的计算机科学家(Dennis主要因Unix获得了图灵奖)。我期待的、也是我认为的最佳答案,是一种语义上的答案:C是C++的一个子集,C++包含更多内容并拥有更好的库。如果您认为“更多就是更好”,这将是普遍论点。当然,并非所有人都这么认为。而且C++绝不是说更简单,C++要难得多,即使是C++的支持者也不会声称学习C++比学习C更容易。

C++与Java的对比
一个更有趣的比较是C++与Java。Java出现在1995年,由Sun公司发明。Sun公司的人员对另一种古老的语言Smalltalk有深厚感情。Smalltalk是帕洛阿尔托研究中心(Xerox PARC)发明的一种语言,该中心非常著名,是个人电脑、图形用户界面等诸多概念的灵感来源。因此,作为一种纯粹的面向对象语言,Smalltalk启发了Java。Java也采用了类C的语法(这与Smalltalk不同)。
然而,C++继承了来自C语言系统实现语言的、固有的命令式方法论,偏向底层。而Java旨在纯粹面向对象且高层。其发明者(主要是Sun公司的James Gosling)认为他们可以做得更好。Java拥有JVM(虚拟机),并提供统一的语义。其经典口号是“一次编写,到处运行”,这在工业界是好事,可以大幅降低开发成本,无需为每个平台单独定制。C++最初编译为C代码,因此C++是非常高效的系统实现语言。它有不同的编译器,依赖于平台,并且实际上根据平台略有不同的语义,因此可以针对平台进行调优。所以,C++经常是平台上最高效的高级语言,并希望保持这一优势,因此没有隐藏的开销。
Java的一个好处是垃圾回收,您无需管理内存,内存泄漏问题基本消失。而在C++中,您必须具备复杂的内存和内存管理概念,但不会在运行时产生性能损耗。因此,Java和C++之间形成了非常有趣的对比,并且这仍然是编程语言社区中一个激烈的争论话题。
总结

本节课中,我们一起学习了C++编程课程的整体结构,探讨了“更好”这一概念的相对性,回顾了C语言的历史及其与C++的渊源,并分析了C++与C、Java在核心特性和设计哲学上的主要区别。理解这些背景和差异,将为我们后续深入学习C++的具体特性打下坚实的基础。
002:面向对象基础与核心概念 🧠
在本节课中,我们将学习面向对象编程(OO)的基础知识,并探讨其在C++中的核心概念。我们将重点理解面向对象的本质,以及它如何帮助我们构建更强大、更灵活的软件。
上一节我们介绍了课程的基本要求,本节中我们来看看面向对象编程的核心思想。我期望你了解一些面向对象的基础知识,但不必精通Java。虽然有些学员具备Java背景,这对学习C++是有益的,因为C++中的许多面向对象特性与Java非常相似。

然而,你必须保持谨慎,因为某些看似相同的特性在C++中具有重要的差异。在学习C++版本时,即使语法结构看起来相似,你也必须确保理解C++构造的运行时语义。
面向对象知识是我期望你掌握的。如果你对此有些生疏,可以阅读维基百科的相关文章,它提供了关于面向对象基础的详细解释。

以下是面向对象编程的核心思想:
- 数据与操作绑定:对象是一种数据类型,其数据与操作紧密绑定在一起。
- 类型可扩展性:面向对象语言允许你自然地扩展类型系统,创建新的数据类型。
- 软件构建方法:面向对象是我们在C++社区中用于开发软件的关键方法之一。
现在,让我们通过一个小测验来巩固理解。根据你对面向对象的了解,以下哪个想法最符合其本质?
- 面向对象意味着使用递归。
- 面向对象意味着数据与操作绑定在一起。
- 面向对象意味着类型安全至关重要。
- 面向对象意味着自动垃圾回收可用。
请花一点时间思考并选择你的答案。
我们来讨论一下答案。面向对象与控制流无关,但你应该对使用递归感到自如。第二个答案是我认为的最佳答案:对象是一种数据类型,并且与其操作绑定在一起。
考虑基础语言中的数据类型,例如整数(int)。在基础语言中,你有像加法(+)和乘法(*)这样的操作。在像C++这样的面向对象语言中,你有一组基本的内置类型,而使其强大的关键特性是类型可扩展性。这允许你自然地使用新类型进行编程。
以C++为例,标准库中没有内置的复数类型。但是,通过标准库,你可以获得一个复数类型(std::complex)。一旦这个复数类型被正确构建并包含在语言中,它就能支持像加法和乘法这样的自然操作。物理学家或工程师等使用复数的社区成员可以非常有效地常规使用这个复数包。
一个面向对象的类型也可以是一个窗口系统。你可以让某人编写一个窗口类,然后定义窗口上的自然操作,例如放大、缩小、置于后台、添加文本模式或菜单等。这是一种构建软件的良好方式。
在编程语言历史上,当人们试图创建通用语言时,他们常常陷入两难:要么使语言过于庞大而难以管理或学习,要么效率低下,要么无法涵盖所有有用的功能。早期的通用语言尝试,如IBM在20世纪60年代中期发明的PL/1,虽然是非常出色的语言和系统,但基本上是失败的。这随后催生了面向对象语言,以及早期的数据抽象语言(如Modula和Ada),它们也非常成功,并与面向对象有许多共同之处。
这些语言让你拥有一个连贯的内核。在C++中,这个连贯的内核是C语言。在Simula 67中,这个内核是Algol 60。你可以成为C语言或Algol 60的熟练程序员,然后轻松地将语言扩展到新的领域,例如复数运算。这就是面向对象的含义。
类型安全通常是许多语言(包括非面向对象语言)的良好特性,但它并不定义面向对象。同样,垃圾回收可以内置在面向对象语言中,它非常有用,也存在于Lisp、Common Lisp和Scheme等语言中,但它不是面向对象的必要条件。面向对象的本质特性实际上是类型可扩展性。

本节课中我们一起学习了面向对象编程的核心概念。我们明确了面向对象的本质在于将数据与操作绑定在一起,并通过类型可扩展性来构建强大且灵活的软件。理解这些基础对于后续深入学习C++的面向对象特性至关重要。
003:将C程序转换为C++ 🎲
在本节课中,我们将学习如何将一个用C语言编写的掷骰子概率模拟程序,转换为一个功能相同的C++程序。我们将从分析一个典型的C程序结构开始,逐步了解C++的改进之处,并最终完成转换。
概述:C程序结构分析
上一节我们介绍了课程目标。本节中,我们来看看我们将要转换的C程序示例。这是一个模拟掷两个骰子并统计各点数出现概率的程序,类似于赌场游戏“Craps”的概率计算。
以下是该C程序的核心组成部分:

- 预处理指令:程序开头使用
#include引入了必要的标准库,如输入输出库<stdio.h>和随机数库<stdlib.h>。 - 宏定义:使用
#define定义了常量,例如骰子的面数SIDES和模拟次数TRIALS。 - 主函数:程序入口是
main()函数,它返回一个int类型值。通常,返回0表示程序正常结束。 - 随机数生成:程序使用
srand()函数,以系统时间time(NULL)为种子初始化随机数生成器,确保每次运行结果不同。然后使用rand() % SIDES + 1来模拟掷出1到6的点数。 - 模拟与统计:通过循环进行大量(
TRIALS次)试验,将两个骰子的点数相加,并在数组outcomes中累加对应点数的出现次数。 - 结果输出:最后,程序遍历
outcomes数组,打印出每个点数及其出现的频率。
C++的改进理念

我们已经了解了C程序的基本结构。接下来,本节中我们来看看C++语言引入的一些核心改进理念,这些理念将指导我们的转换工作。

C++社区对优秀程序员有明确的期望,即遵循社区标准和最佳实践。其中一个重要方向是减少对预处理器的依赖。在C++中,我们倾向于使用语言本身提供的更安全、更强大的特性来替代C中的宏和某些预处理指令。
从C到C++的关键转换步骤
上一节我们介绍了C++的改进理念。本节中,我们来看看具体的转换步骤,将我们的掷骰子程序“现代化”为C++风格。
以下是转换过程中需要关注的主要方面:
- 头文件:将C风格的头文件(如
<stdio.h>)转换为C++风格(如<cstdio>)。更常见的是,直接使用C++特有的流库<iostream>来替代C的输入输出函数。 - 常量定义:使用C++的
const或constexpr关键字来定义常量,取代#define宏。这提供了类型安全和作用域。 - 随机数生成:C++11引入了更强大、更易用的
<random>库来替代C的rand()和srand()。我们可以使用std::uniform_int_distribution来生成指定范围内的整数。 - 输入输出:使用
std::cout和std::cin替代printf()和scanf(),它们更安全,且支持自定义类型的输出。 - 主函数返回:虽然在C++中
main函数隐式返回0也是允许的,但显式地写出return 0;是更清晰、更符合良好风格的作法。
核心概念示例
让我们用公式或代码来描述上述转换中的两个核心概念。
1. 常量定义(C宏 vs C++ const)
// C 风格 (宏)
#define SIDES 6
#define TRIALS 10000
// C++ 风格 (类型安全常量)
const int sides = 6;
constexpr int trials = 10000; // C++11, 编译期常量

2. 随机数生成(C rand() vs C++
// C++ 风格 (更均匀的分布)
#include <random>
std::random_device rd; // 非确定性随机数种子
std::mt19937 gen(rd()); // 梅森旋转算法引擎
std::uniform_int_distribution<> dis(1, 6); // 生成1到6的均匀分布整数
int dice_roll = dis(gen); // 掷一次骰子
总结

本节课中,我们一起学习了如何将一个C语言程序转换为C++程序。我们分析了典型C程序的结构,理解了C++旨在减少预处理器使用、增强类型安全和提供更现代库的设计理念。通过将头文件、常量定义、随机数生成和输入输出等部分转换为C++风格,我们不仅使程序更符合现代C++标准,也使其更安全、更易于维护。记住,采用社区认可的最佳实践是成为一名优秀C++程序员的重要一步。
004:使用C++代码
在本节课中,我们将学习如何编写第一个C++程序,并了解C++语言的一些基础特性,包括注释、标准库、命名空间和常量定义。我们将对比C语言中的传统做法,理解C++如何通过语言规则来增强代码的安全性和可读性。
注释风格
上一节我们介绍了编程的基本概念,本节中我们来看看C++中的注释风格。C++引入了行尾注释,这与C语言最初的多行注释风格有所不同。
以下是关于C++注释的说明:
- C++社区通常使用行尾注释(
//),这种注释方式更为灵活和安全。 - 虽然现代C标准也支持这种注释风格,但它最初是C++的新特性。
- 在使用GNU等编译器时,C和C++的特性界限可能模糊,但本教程主要讨论经典的C与C++区别。
标准库与头文件
在C++中,我们使用标准库的方式与C语言有所不同。
以下是C++标准库的使用要点:
- C++标准库通常不包含
.h后缀。例如,输入输出包是iostream,而不是iostream.h。 - C++可以完全使用C语言的库。如果需要使用经典的C库,可以在库名前加
c来引入。例如,C的数学库在C++中写作cmath。
命名空间
命名空间是C++引入的一个重要概念,用于提供代码的封装和上下文。
以下是关于命名空间的解释:
- 命名空间是一种封装机制。例如,IBM公司可以将其软件封装在名为
IBM的命名空间中。 - 标准库的命名空间是
std。 - 在同一个程序开发中,可以混合使用多个命名空间,例如同时使用Oracle库、IBM库和标准库。
using 指令是一个便利性声明,它告诉程序我们将使用 std 命名空间,这样在代码中就不必每次都显式地写出 std:: 前缀。
常量定义
C++使用语言关键字来定义常量,取代了C语言中通过预处理器进行文本替换的宏定义。
以下是常量定义的对比:
- 在C语言中,我们使用宏定义:
#define SIZE 6 - 在C++中,我们使用常量声明:
const int size = 6;
公式/代码表示:
// C风格宏定义
#define SIZE 6
// C++风格常量定义
const int size = 6;
使用 const 关键字定义常量具有类型信息,编译器可以根据语言规则进行检查。而宏定义只是简单的文本替换,独立于语言规则,更容易导致错误。

内联函数
对于简短的代码片段,C++使用内联函数来替代C语言中的宏函数。
以下是内联函数的说明:
inline是C++的一个新关键字,它是一个编译器指令。- 它用于建议编译器将函数调用处用函数体代码直接替换,以避免函数调用的开销(如栈操作、机器指令执行时间)。
公式/代码表示:
// C++内联函数
inline int area_sq(int s) {
return s * s;
}
与宏函数相比,内联函数遵循所有的函数规则,由编译器进行处理,因此更加安全可靠。
总结

本节课中我们一起学习了C++代码的基础写法。我们了解了C++特有的行尾注释风格,认识了不带头文件后缀的标准库引用方式,理解了命名空间如何帮助组织代码,并掌握了使用 const 定义常量和 inline 定义内联函数来取代C语言中对应的宏定义,这些特性都使得C++程序在类型安全和可维护性上更胜一筹。
005:C++的改进
在本节课中,我们将学习C++语言相对于C语言的一些核心改进。我们将探讨这些改进如何使代码更安全、更易读、更易维护,并通过一个具体的程序示例来展示这些改进的实际应用。
C++的改进概述
上一节我们介绍了C++的背景,本节中我们来看看C++语言本身的一些具体改进。这些改进旨在解决C语言中的一些常见问题,并提供更强大的编程工具。

以下是C++引入的一些关键改进:
- 向后兼容性:几乎所有旧的C语言库在C++中仍然可用,通常带有
c前缀(例如cstdio)。 - 内联函数:
inline关键字用于替换代码宏(#define)。宏是程序中常见的错误来源,内联函数提供了类型安全且更可靠的替代方案。 - 单行注释:C++引入了以
//开头的单行注释,作为对C语言/* */块注释的补充。 - 灵活的变量声明:变量声明不必局限于代码块的开头,可以在首次使用前任何位置声明,这提高了代码的可读性。
- 输入/输出流:C++提供了
cin和cout流对象,用于替代C语言的scanf和printf函数。它们更类型安全、更直观。 - 命名空间:
namespace和using指令帮助管理不同库中的标识符,避免命名冲突。 - 运算符重载:允许为运算符(如
<<和>>)赋予新的含义,使其能用于自定义类型(如I/O流)。
程序示例分析
现在,让我们通过一个具体的程序来观察这些改进。这个程序看起来与C程序有些相似。
srand(time(0));


这段代码用于初始化随机数生成器。srand 函数使用当前时间(通过 time(0) 获取)作为种子,以确保每次程序运行时都能获得不同的随机数序列。
接下来,我们看到一个重要的改进。程序使用 cout 进行输出,而不是C语言中的 printf。
cout << "Enter number of trials: \n";
cout 代表标准输出流,通常指向你的屏幕。<< 是输出运算符。字符串 "Enter number of trials: \n" 会被发送到屏幕上显示。\n 是换行符,与C语言中相同。
这里有一个关键概念:运算符重载。在C语言中,<< 是位左移运算符。但在C++中,当 << 的左操作数是一个输出流(如 cout)时,它的含义被重载为“插入”或“输出”操作。编译器能根据操作数的类型(这里是字符串)自动选择正确的行为来打印它。稍后我们给它整数或浮点数时,它同样知道如何打印。
另一个新特性是变量声明的位置。
int trials;
cin >> trials;
变量 trials 的声明与输入语句 cin >> trials; 交织在一起,而不是必须放在代码块的最开始。这提高了代码的清晰度。
输入语句 cin >> trials; 可以类比C语言的 scanf。它表示从标准输入(通常是键盘)读取数据。用户将在键盘上输入模拟所需的试验次数。这种方案更易于使用,它是类型安全的、方便的、易于理解的,并且符合直觉。
小测验与总结


让我们做一个简单的小测验。我们已经提到过,为什么需要调用 srand 函数?
srand 用于初始化随机数生成器。在模拟中,我们不希望每次运行都得到相同的结果。time(0) 返回一个基于系统时钟的大无符号整数,它非常精细,并且程序每次开始运行时都不同,因此适合作为随机种子。
在本课程中,我将展示许多不同的模拟程序,因此期望你能对这种蒙特卡洛处理方式有相当的熟悉度。这种方法非常重要,并且非常有用。

本节课总结:在本节课中,我们一起学习了C++语言相对于C语言的多项重要改进。我们了解了内联函数、灵活的变量声明、更安全的I/O流(cin/cout)、命名空间以及运算符重载等概念。通过分析一个具体的程序,我们看到了这些改进如何使代码更简洁、更安全、更易于编写和理解。掌握这些基础改进是有效使用C++进行编程的关键第一步。
006:C++优势概述
在本节课中,我们将学习C++相较于C语言的一些核心优势,包括类型安全、内存管理、循环声明以及安全的类型转换等特性。这些改进旨在编写更安全、更易读、更高效的代码。
类型安全与输入/输出
上一节我们提到了C++的声明灵活性,本节中我们来看看C++在类型安全方面的具体体现。C++的输入/输出流(如 cin 和 cout)提供了内置的类型安全检查。
例如,当使用 cin >> trials; 从标准输入(通常是键盘)读取数据时,编译器期望输入能被解释为一个整数。如果输入不匹配,程序会在运行时捕获错误,而无需程序员手动指定格式化字符。这种隐式的类型安全检查有助于在运行时发现错误。
变量声明的位置
虽然C++允许将变量声明与可执行语句混合编写,但这并不意味着应该完全摒弃在代码块头部集中声明的习惯。对于较短的代码,将声明集中在块首并加以注释,仍然是清晰易读的风格。
然而,在编写较长的程序时,将变量声明靠近其使用位置,可以使代码更易于理解和维护。这种做法能降低代码的复杂性。
动态内存管理
在C语言中,使用 malloc 和 free 函数从堆(Heap)上动态分配和释放内存。在C++中,这两个函数被关键字 new 和 delete 取代。

new:用于从堆上分配内存。delete:用于将内存释放回堆。
由于C++更强的类型安全性,使用 new 和 delete 运算符能为编译器提供更多隐式信息,从而带来改进。它们是语言内置的关键字,而非标准库函数。
循环语句的改进
以下是C++在 for 循环语句中的一个实用改进。
for (int j = 2; j < dice * sides; ++j) {
// 循环体
}
这个 for 循环的初始化部分允许声明一个局部迭代变量 j。这种做法很好,因为如果变量仅用于迭代,我们通常不关心其是否为全局变量。将变量作用域局部化有助于降低代码的复杂性。这是C++允许在 for 语句中进行局部声明的一个小改进。

安全的类型转换
C++用四种不同的类型转换操作符取代了C语言中不安全的旧式转换。C风格的转换已被弃用,意味着它已过时,不应再使用。
double probability = static_cast<double>(outcomes) / trials;
这里使用的是 static_cast,它是一种安全转换。它只允许编译器认为安全的、基于规则的转换。如果转换不被认为是安全的,则会产生错误。这可以保护你免受非标准转换的影响,是一个重要的改进。
为什么需要这个转换?因为如果 outcomes 和 trials 都是整数,且 outcomes 小于 trials,那么整数除法结果将为0,而概率值显然不为0。通过转换为 double,我们执行的是浮点数除法,从而得到正确的小数结果(例如,5/20 = 0.25)。


输入/输出操纵符
endl 是C++输入/输出流标准库中的一个IO操纵符。它可以被放入输出流中,指示输出跳转到下一行。

cout << "Some text" << endl;
C++优势总结
本节课中我们一起学习了C++相较于C语言的多个优势,让我们来总结一下:
- 安全转换:如
static_cast。C++还有另外三种转换,例如非常危险的reinterpret_cast(它强制按位模式重新解释数据)。在大多数情况下,应避免使用这种转换。 for语句初始化器中的声明:常见写法如for (int i = 0; ...)。这通常用于从0开始循环,是一个小但实用的改进。endl作为IO操纵符:用于输出换行。
这些是对C语言的小改进,你可以立即使用它们,并开始以C++的风格进行编程。
C++语言的主要设计师Bjarne Stroustrup教授认为,C++的优势在于它不追求“纯粹性”。他认为,支持多种有效的编程风格(范式)以及风格的组合,才是C++的单一最大优势。通常,最优雅、最高效、最易维护的解决方案会涉及不止一种风格。可以将C++想象成一把“瑞士军刀”,功能多样且实用。
本节课总结

本节课中,我们一起学习了C++在类型安全、内存管理、循环控制和安全类型转换等方面相对于C语言的改进。这些特性旨在帮助你编写更健壮、更清晰的代码。你的首次作业将涉及运用这些知识来转换一个C语言程序。
007:C++优于C
在本节课中,我们将探讨C++相较于C语言的主要优势。我们将了解C++如何通过增强类型安全、提供丰富的库、减少预处理器依赖以及引入面向对象编程等特性,来改进编程实践。对于有C语言背景的程序员来说,这些改进是自然而易于掌握的。
更强的类型安全 🛡️


上一节我们介绍了C++的整体优势,本节中我们来看看其核心改进之一:类型安全。类型是编程语言的基石,定义了语言操作的数据域。在C++中,类型系统更为严格,这有助于编译器在编译时捕获更多错误,从而提高代码的可靠性。
在C语言中,由于历史原因和追求底层效率,类型规则相对宽松。例如,不同平台上的int类型大小可能不同。C++则致力于提供更一致和安全的类型系统,减少了因类型混淆(如将double错误解释为指针)而导致的潜在错误。
示例:类型声明
int count; // 整型变量
double price; // 双精度浮点变量
char initial; // 字符变量
int* pointer; // 整型指针变量
丰富的标准库 📚
C++提供了比C语言更广泛、更强大的标准库。这些经过充分测试的库组件保证了正确性,并提供了即时的代码复用能力。代码复用是软件开发中提升效率的关键手段。
后续课程中,我们将重点介绍标准模板库,它是C++语言的一个重要补充,极大地扩展了编程能力。
减少预处理器使用 ⚙️
在C++中,预处理器的主要用途是包含库文件。而像#define定义全局常量或宏的做法,在C++社区中已不再被视为良好实践。

预处理器进行的是文本替换,不遵守语言本身的上下文规则,这比使用语言内置的、能被编译器检查的等效功能更容易引入错误。因此,C++通过语言特性(如const和inline)来减少对预处理器的依赖,这是一项重要改进。
支持面向对象编程 🧩

C语言并非为面向对象编程而设计,它是一种小型且支持命令式编程的语言,这在当时是合理的。然而,在开发跨领域的大型软件时,人们发现面向对象范式对于使用同一核心语言开发大规模软件至关重要。
C++在C语言的基础上增加了对面向对象编程的支持,这使得它能够更好地应对现代大型软件项目的挑战。
已接触到的C++改进特性 ✨
到目前为止,我们在程序中已经使用了一些C++的改进特性。
inline 与 const



inline和const关键字取代了预处理器宏#define。
inline修饰函数,提示编译器在保持函数语义的同时,尝试避免函数调用开销。const修饰类型,C++将其深度集成到类型系统中,以便编译器帮助检查常量的正确性。通过明确哪些变量是不可修改的,来支持程序的正确性。
安全的类型转换
C++提供了更安全的类型转换操作符。


示例:静态转换
double d = 3.14;
int i = static_cast<int>(d); // 明确的类型转换

命名空间封装
在编程方法论的发展中,每一代语言设计都提供了更强的封装工具。封装意味着可以以自包含的方式构建、测试和调试代码块,然后将其作为模块集成到更大的系统中。有效的隔离能带来更好的设计和更少的错误,这对于编写可能多达上亿行代码的现代系统至关重要。
流式输入输出
C++的iostream库提供了便捷且类型安全的输入输出方式,避免了C语言中printf和scanf需要匹配特定格式字符串的麻烦。
示例:流式IO
#include <iostream>
int main() {
int age;
std::cout << "Enter your age: ";
std::cin >> age;
std::cout << "You are " << age << " years old." << std::endl;
return 0;
}
变量声明位置灵活

C++允许在程序的任何位置声明变量(例如在for循环内)。这样做的好处是局部化,让变量在靠近其使用的地方定义,提高了代码的可读性和可维护性。
示例:for循环中的变量声明
for (int i = 0; i < 10; ++i) { // i的作用域仅限于此循环
std::cout << i << std::endl;
}

小测验与解析 ❓
以下是关于const的一个小测验,请思考答案。


问题:
const double pi = 3.14159;
- 这条语句是否创建了一个不可变的变量
pi?(真/假) - 它是否等价于预处理器指令
#define PI 3.14159?(真/假) - 以上两个陈述是否都为真?(真/假)
解析:
- 真。
const double pi = 3.14159;是一个声明。它创建了一个类型为double的变量pi,但在其作用域内,其值不能被改变,因此是安全且不可变的。 - 假。它并不完全等价。
#define创建的是一个宏,会在整个文件范围内进行文本替换。如果代码中在其他 unintended 的上下文里出现了PI,它也会被替换,这可能导致错误。而const变量的作用域由声明它的位置(如函数内、类内、命名空间内)决定,受语言规则保护。 - 假。因为第二个陈述为假,所以并非两者都为真。
总结 📝

本节课中,我们一起学习了C++相较于C语言的主要优势。我们了解到C++通过更强的类型安全、丰富的标准库、减少预处理器依赖以及引入面向对象编程等特性,显著改进了软件开发的体验和质量。对于C程序员而言,迁移到C++并利用这些改进是非常自然的过程。在接下来的课程中,我们将深入探索这些特性,特别是强大的标准模板库。
008:C++交换函数 📚
在本节课中,我们将学习一个在编程中非常基础且重要的概念:如何编写一个交换两个变量值的函数。我们将从C语言的传统实现方法开始,理解其原理,并初步了解C++将如何提供更便捷的解决方案。
C语言中的交换函数 🔄
上一节我们介绍了函数参数传递的基本概念。本节中我们来看看一个具体的例子:交换两个变量的值。在C语言中,由于它是“按值调用”的语言,函数内部对参数的修改不会影响外部的原始变量。因此,我们需要使用指针来模拟“按引用调用”的效果。
以下是实现交换功能的关键步骤:
- 函数参数使用指针:函数接收两个指向整数的指针(
int*),这样我们就能操作原始内存地址中的数据。 - 使用解引用操作符:在函数内部,通过星号(
*)操作符来访问或修改指针所指向地址中的值。 - 借助临时变量:需要一个临时变量来暂存其中一个值,以完成交换过程。
核心的交换逻辑可以用以下代码表示:
void swap_int(int* i, int* j) {
int temp = *i; // 将i指向地址中的值存入temp
*i = *j; // 将j指向地址中的值赋给i指向的地址
*j = temp; // 将temp的值赋给j指向的地址
}


这个过程可以形象地理解为:我们有两个“信箱”(内存地址),i和j分别知道这两个信箱的位置。交换时,我们先把i信箱里的信件(值)暂时放到一个temp袋子里,然后把j信箱里的信件放到i信箱里,最后再把temp袋子里的信件放到j信箱里。
C语言实现的局限性 ⚠️



现在,如果我们想交换两个double类型的变量呢?在C语言中,我们必须为不同类型的变量编写不同的交换函数。


以下是C语言处理此问题的方式:



- 必须创建新函数:由于C语言要求函数名在作用域内必须唯一,我们不能简单地重载
swap函数名。 - 修改类型声明:新函数(例如
swap_double)的参数和内部临时变量的类型需要从int改为double。 - 调用时仍需使用地址:在调用
swap_double时,同样需要使用地址操作符(&)来传递变量的地址。
对应的函数实现如下:
void swap_double(double* i, double* j) {
double temp = *i;
*i = *j;
*j = temp;
}
这意味着,对于每种需要交换的数据类型,我们都需要重复编写逻辑几乎完全相同的代码,这不利于代码的复用和维护。

在程序中使用交换函数 💻
让我们看看如何在主程序中调用这些交换函数。理解调用方式对于掌握指针概念至关重要。
以下是调用swap_int函数的示例代码:
int m = 5, n = 10;
printf("Before swap: m = %d, n = %d\n", m, n);
swap_int(&m, &n); // 传递m和n的地址
printf("After swap: m = %d, n = %d\n", m, n);
在这段代码中:
&m和&n分别获取了变量m和n的内存地址。- 将这两个地址传递给
swap_int函数。 - 函数通过指针修改了
m和n所在地址的内容,从而实现了值的交换。 - 输出结果将从“5, 10”变为“10, 5”。
对于double类型,调用方式类似,但需要使用%lf等正确的格式说明符进行输入输出。

本节总结 📝

本节课中我们一起学习了在C语言中实现变量交换的标准方法。我们了解到,由于C语言严格的“按值调用”语义,要修改函数外部的变量,必须通过传递指针(即变量的地址)来间接操作。我们分析了交换函数的实现步骤,并指出了这种方法的局限性:每增加一种需要交换的数据类型,就必须编写一个几乎完全相同的新函数,这导致了代码冗余。

这为我们下一节学习C++中的解决方案做好了铺垫。在C++中,我们将看到如何利用更强大的特性,只编写一个交换函数就能处理多种数据类型,从而大大提高代码的复用性和简洁性。
009:C++中的交换函数与函数重载 🚀
在本节课中,我们将学习C++中实现交换函数的更优方法,并与C语言版本进行对比。我们将重点介绍C++的引用传递、函数重载以及类型安全的I/O操作,这些特性使得代码更简洁、高效且易于阅读。
上一节我们回顾了C语言中通过指针实现交换函数的方法。本节中,我们来看看C++如何通过其内置特性更优雅地解决这个问题。
C++直接支持引用传递机制,它是对值传递的补充。因此,我们无需像在C语言中那样“人工”创建引用传递。观察C++版本,我们会发现几个有趣的特点。
首先,在C++版本中,我们将使用C++的I/O库。这个I/O库是类型安全且便捷的。类型安全意味着编译器能检查类型,避免错误;便捷性体现在其直观的语法上。
我们还会使用namespace来限定作用域。这里使用了std命名空间,因为像cout这样的对象定义在其中。std::被称为作用域解析运算符,这是C语言中没有的新特性,它用于处理更复杂的名称。通过using namespace std;语句,我们无需在每个标识符前都加上std::前缀,编译器会自动查找。
以下是C++版本交换函数的核心代码示例:
inline void swap(int &i, int &j) {
int temp = i;
i = j;
j = temp;
}
代码分析:
inline:这是一个新特性,用于告知编译器尝试将函数内联,以提高执行效率。- 引用传递:参数
int &i应理解为“对i的引用”,而非“i的值”。对于j也是如此。当参数以引用方式传递时,函数内部直接操作原始变量,无需创建副本。因此,函数内对内容的任何修改在函数返回后都会保留。这避免了值传递中创建本地副本的开销,对于处理大型数据结构时提升效率尤为重要。 - 简化操作:我们无需使用解引用(
*)和取地址(&)运算符,代码看起来更简洁。交换后,变量的值确实会被改变。

接下来,一个更有趣的特性是,在C++中我们不需要为不同类型的交换函数起不同的名字。C++支持函数重载。
这意味着在同一个作用域内,可以存在多个同名函数。那么,编译器如何区分它们而不产生歧义呢?像Algol 60或C这样的早期语言不允许同名函数,会报语法错误。
C++通过签名匹配机制来解决。编译器提供了一种自动区分不同函数的方法。例如,如果我要交换两个double类型,就使用接收double参数的swap函数;如果要交换int类型,则使用另一个。只要能够通过参数类型或数量进行区分即可。
这种区分依据被称为函数的签名。因此,重载的本质就是通过签名进行区分。函数的签名由其参数的类型和数量决定。
让我们看看这是如何工作的。以下是C++版本的main函数示例:
#include <iostream>
using namespace std;

int main() {
int m = 5, n = 10;
double x = 5.5, y = 10.5;
cout << "inputs: " << m << ", " << n << endl;
swap(m, n);
cout << "outputs: " << m << ", " << n << endl;
cout << "inputs: " << x << ", " << y << endl;
swap(x, y);
cout << "outputs: " << x << ", " << y << endl;
return 0;
}

代码执行流程分析:
- 输出:使用
cout进行输出。可以将其想象成数据“流向”屏幕。首先输出字符串"inputs: ",然后是变量m的值5,接着是字符串", ",最后是变量n的值10。 - 类型安全I/O:与C语言的
printf不同,C++的I/O流了解数据的类型,并会应用该类型的默认格式化规则。你无需指定%d或%f等格式符,这简化了操作并避免了格式不匹配的错误。 - 调用
swap:这里只是简单地传入变量m和n,但它们是通过引用传递的。因此,函数内部直接操作原始变量m和n的内容。之后再次打印时,会看到输出变为10, 5。 - 重载解析:当调用
swap(x, y)时,由于x和y是double类型,编译器通过签名匹配算法,自动选择接收double&参数的swap版本,没有任何歧义。
这减少了对命名空间的“污染”,我们无需为一堆功能相似但操作类型不同的函数起不同的名字。如果“交换”这个操作在概念上是相同的,只是处理的数据类型不同,我们可以保留相同的函数名,这使得代码更易读、更易处理。

在C++中,通过重载和签名匹配算法的神奇之处,我们实现了编译器自动选择合适函数的能力。为了避免歧义,两个同名函数必须具有不同的参数类型或不同的参数数量。在本例中,参数类型不同,因此编译器能够正确选择。


总结 📝

本节课中我们一起学习了C++中实现交换函数的优势。
- 引用传递:通过
&符号实现,避免了指针的繁琐语法和值传递的拷贝开销,使代码更简洁高效。 - 函数重载:允许同一作用域内存在多个同名函数,通过函数签名(参数类型和数量)进行区分。这为概念相同的操作使用相同的名字,极大地提升了代码的可读性。可读性是编写优秀代码最重要的标志之一。
- 类型安全I/O:使用
cout进行输出,编译器自动处理类型,无需格式符,更安全便捷。 inline关键字:建议编译器将函数内联,适用于小型函数以提升效率,但需注意可能引起的代码膨胀。- 命名空间:使用
namespace和using声明来管理标识符,避免命名冲突。
为概念相同的活动使用相同的名称(即重载)能促进代码可读性。可读性是优秀代码的绝对标志。在未来,结合基于签名的重载以及泛型编程(我们将深入讨论的概念,与C++中强大的模板特性相关),将使代码复用更强大,产生概念上更简单、代码行数更少、功能却更强大的代码库。
小测验 ✏️
- 标准作用域解析运算符
std::cout定义在哪个头文件中? inline关键字可以在声明什么时使用?- 类型
long、long long和int之间有什么区别?

答案提示:
std::cout定义在<iostream>头文件中,同时定义的还有移位运算符<<的重载语义。inline可用于声明函数,是一种提升速度的优化建议。但请注意,编译器不一定会内联复杂函数,并且过度内联可能导致代码膨胀。通常只建议对非常小的函数使用。- C++允许比早期标准C更长的整数类型。基本要求是:在类型链中,每种更“长”的类型(如
long之于int,long long之于long)其长度至少与前者相同。因此,在某些编译器实现中,long和long long的长度可能相同,这是符合标准的。
010:泛型编程入门
概述

在本节课中,我们将要学习C++中一个强大的特性——泛型编程。通过使用模板,我们可以编写出能够处理多种数据类型的通用代码,从而极大地提高代码的复用性和开发效率。
从具体到通用:泛型编程的思想
上一节我们介绍了函数重载等概念,本节中我们来看看如何通过泛型编程来进一步提升代码的通用性。
泛型编程的核心思想是编写不依赖于特定数据类型的代码。在日常生活中,我们也有类似的概念。例如,一个“煎鱼”的食谱,其基本步骤(加热油、放入食材、控制时间)适用于多种鱼类(鳟鱼、鲈鱼等),而不仅仅是某一种特定的鱼。我们只需要一个通用的“煎鱼”食谱,而不是为每种鱼都写一个单独的食谱。
在编程领域,早期的语言(如Lisp)通过牺牲类型安全和运行效率来获得这种通用性。而C++的设计目标之一,就是在保持高效率的同时,实现代码的通用性。这一思想深受Alex Stepanov的影响,他作为逻辑学家,致力于寻找最通用的规则,并将其应用于编程语言设计中。
C++模板:实现泛型的工具
为了实现泛型编程,C++引入了模板这一机制。模板允许我们定义函数或类时,使用一个占位符(通常称为类型参数)来代替具体的类型。
以下是定义一个通用交换函数模板的语法:
template <class T>
inline void swap(T& x, T& y) {
T temp = x;
x = y;
y = temp;
}

让我们分解一下这个定义:
template <class T>:这行代码声明了一个模板,其中T是一个类型参数(或称为“元变量”)。按照惯例,我们通常使用大写字母(如T、T1、T2)来表示类型参数。- 函数体内部的代码与之前针对
int类型编写的swap函数完全一样,只是将具体的int类型替换成了通用的类型参数T。
通过添加这一行模板声明,我们就获得了一个可以交换任意类型数据的通用函数。编译器会在编译时,根据实际调用时传入的参数类型,自动将T替换成具体的类型(如int、double等),并生成对应的机器代码。这个过程称为模板实例化。
模板的使用与实例化

现在,让我们看看如何在主函数中使用这个泛型的swap函数。
#include <iostream>
#include <complex>

int main() {
int i1 = 1, i2 = 2;
double d1 = 3.14, d2 = 2.71;
std::complex<double> c1(1.0, 2.0), c2(3.0, 4.0); // 复数也是模板类
swap(i1, i2); // 编译器实例化 swap<int>
swap(d1, d2); // 编译器实例化 swap<double>
swap(c1, c2); // 编译器实例化 swap<std::complex<double>>
std::cout << i1 << ", " << i2 << std::endl;
std::cout << d1 << ", " << d2 << std::endl;
std::cout << c1 << ", " << c2 << std::endl;
return 0;
}
以下是代码执行时编译器的工作流程:
- 当编译器看到
swap(i1, i2)时,它识别出参数类型是int。于是,它将模板中的T全部替换为int,生成一个专用于int的swap函数并编译。 - 同理,对于
swap(d1, d2),生成swap<double>。 - 对于
swap(c1, c2),生成swap<std::complex<double>>。
虽然这可能会导致生成多份函数代码(代码膨胀),但这正是我们原本需要手动完成的工作。模板自动化了这个过程,并且保证了类型安全,减少了人为错误。
模板的约束与特化
泛型并不意味着“万能”。模板代码对其所操作的类型是有隐含要求的。
以我们即将看到的求和函数为例,它的模板参数名被有意地命名为Summable,而不仅仅是T:
template <class Summable>
Summable sum(const Summable data[], int size) {
Summable total = 0;
for (int i = 0; i < size; ++i) {
total += data[i]; // 关键操作:要求类型支持 +=
}
return total;
}

这个名称Summable是一种文档提示,它告诉我们:能够替换这个类型参数的数据类型,必须支持+=操作符。如果用一个不支持+=操作的类型(例如某个没有重载该操作符的自定义字符串类)来实例化这个模板,编译器会报错。
然而,更需要注意的情况是:如果某个类型恰好定义了+=操作,但这个操作的含义并非数学意义上的“求和”(例如,它可能是字符串连接),那么代码虽然能编译运行,却会产生非预期的结果。因此,理解模板代码对类型的隐含语义要求至关重要。

有时,对于某些特定的类型,通用的模板实现可能并不合适。这时,我们可以使用模板特化。例如,如果针对std::string类型的swap需要特殊的拷贝语义,我们可以专门为它写一个非模板的、具体的函数:
void swap(std::string& a, std::string& b) {
// 特殊的字符串交换实现
}

当调用swap并传入字符串时,编译器会优先选择这个更具体的、非模板的版本,而不是从通用模板生成一个。这实际上是函数重载规则的一部分。
实践练习:将具体函数泛化

为了巩固对模板的理解,我们来进行一个实践。以下是创建一个泛型函数的推荐步骤:
- 编写具体函数:首先,为一个你熟悉的、确定可用的具体类型(例如
double)编写一个能正确运行的函数。 - 添加模板声明:在函数定义前添加
template <class T>。 - 替换类型:将函数签名和函数体中所有该具体类型出现的地方,替换为类型参数
T。
现在,请尝试完成以下练习:
编写一个函数,用于计算一个C风格数组中所有元素的和。先为
double类型实现,测试无误后,将其改造成一个通用的模板函数,并确保它能用于int等其他类型。

总结
本节课中我们一起学习了C++泛型编程的基础知识。我们了解到:
- 泛型编程旨在编写不依赖特定类型的通用代码,以提高复用性。
- C++通过模板机制实现泛型,使用
template <class T>语法定义。 - 编译器在编译时根据实际使用的类型进行模板实例化,生成具体的代码。
- 模板代码对其操作的类型有隐含的语义要求,需要开发者注意。
- 可以通过模板特化为特定类型提供定制化的实现。
- 将具体函数改造为模板函数是一个简单的过程:添加模板声明,并用类型参数替换具体类型。
掌握泛型编程是迈向编写强大、灵活且可复用C++代码的关键一步。
011:C++泛型与函数


在本节课中,我们将学习C++函数和泛型编程。我们还将探讨图算法,这是计算机科学中关键的数据结构之一。本课程的核心作业将涉及图算法,我们将通过它来学习C++。
📚 概述
我们将讨论C++中的新特性,包括默认参数、可变参数列表、const关键字在函数签名中的使用,以及更复杂的多类型泛型。此外,我们还将开始讲解如何实现自定义的操作符重载。
上一节我们回顾了C语言中的函数,本节中我们来看看C++如何通过泛型等特性增强这些功能。
🔧 C++函数与泛型基础
在C语言中,对一个双精度浮点数数组求和的函数可能如下所示:
double sumArray(double arr[], int size) {
double sum = 0.0;
for (int i = 0; i < size; i++) {
sum += arr[i];
}
return sum;
}
或者使用指针表示法:
double sumArray(double *arr, int size) {
double sum = 0.0;
for (int i = 0; i < size; i++) {
sum += arr[i];
}
return sum;
}
这是一种处理数组的标准模式:传入数组和其大小,通过循环进行累加。
🧬 泛型编程概念
“泛型”一词意味着与整个群体或类别相关,具有通用性。在编程中,泛型编程指的是一种通用的编程风格,允许编写能够处理任意类型的代码。在C++中,这是通过模板实现的。
⚙️ C++泛型求和函数
以下是一个使用C++模板实现的通用数组求和函数:
template <typename T>
T sumArray(const T arr[], int size, T s = T()) {
for (int i = 0; i < size; i++) {
s += arr[i];
}
return s;
}
这个模板引入了元变量T。函数包含以下C++特性:
- 默认参数:
T s = T()。这表示求和变量s有一个默认值,即类型T的默认构造值(对于数值类型通常是0)。在通常情况下,调用者可以省略此参数。 const正确性:const T arr[]。这表示数组arr的内容是不可变的,编译器会确保函数内部不会修改数组元素。这是一种良好的实践,为代码增加了安全性和文档性。- 循环内局部变量:可以在
for循环的初始化部分声明局部计数变量i。
默认参数允许函数有不同的调用签名。例如:
sumArray(myArr, 10, 58):求和从58开始。sumArray(myArr, 10):求和从默认值0开始。
编译器使用签名匹配算法来选择合适的函数版本。需要注意的是,如果参数列表中有多个默认参数,不能跳过中间的参数来为后面的参数指定值,因为编译器无法通过位置来确定意图。
🧪 使用示例
以下是如何在主程序中使用上述泛型模板:
int main() {
int intArr[] = {1, 2, 3, 4, 5};
double doubleArr[] = {1.1, 2.2, 3.3};
int intSum = sumArray(intArr, 5); // 使用默认起始值0
double doubleSum = sumArray(doubleArr, 3); // 使用默认起始值0.0
return 0;
}
编译器会根据数组类型实例化相应的sumArray版本,分别处理整数和双精度浮点数,并应用对应的默认值。
💡 练习与扩展
为了加深对模板力量的理解,可以进行以下练习:
以下是两个练习建议:
- 修改上述代码,将循环内的加法操作改为减法操作。
- 编写一个模板函数,其功能不是进行算术运算,而是输出数组的所有元素。
答案的核心在于修改泛型函数内部对数组元素的操作。例如,将s += arr[i];改为s -= arr[i];即可实现减法。对于输出功能,则需将累加操作替换为输出语句,如std::cout << arr[i] << " ";。
通过使用处理数组的标准模式,并在其中定义需要对数组内容执行的操作,模板变得非常强大。如果某些操作是处理任意数组元素的通用方法,那么模板允许我们为一种标准类型编写一次代码,然后将其存入库中供随时使用。
📝 总结
本节课中我们一起学习了C++中函数与泛型编程的核心概念。我们了解了如何通过模板编写处理任意类型的通用代码,探讨了默认参数、const正确性等C++特性如何使代码更安全、更灵活。我们还通过示例看到了泛型函数的具体应用,并提出了扩展练习来巩固理解。掌握这些知识是运用C++进行高效、通用编程的基础。


012:多个模板参数

概述
在本节课中,我们将要学习C++模板的一个进阶特性:使用多个模板参数。我们将探讨其适用场景、潜在风险,并通过一个具体的例子来理解如何安全地使用它。
模板参数的限制与警告
上一节我们介绍了单参数模板的基本用法。本节中我们来看看当模板拥有多个参数时的情况。
目前我们接触的模板都只有一个元参数,但实际上模板参数的数量并没有限制。不过请记住,大多数情况下,单参数模板就能满足需求。一旦开始使用多参数模板,你可能会陷入可怕的复杂性之中。在大多数情况下,这是不必要的复杂性,并且可能导致一些难以预料的、相当危险的操作。尽管如此,在某些特定场景下,多参数模板确实有用,因此了解其使用方法和时机是必要的。
再次警告:请务必小心。你必须真正理解何时需要多个模板参数。通常这些是元参数,但让我们看一个可能合理的应用场景。
一个合理的多参数模板示例:复制函数
以下是一个使用两个模板参数的代码示例。它是一个复制例程,目的是将源数据复制到目标数据中。
我们不一定希望源类型和目标类型必须相同。如果只使用一个模板参数 T,那就意味着复制操作必须是同类型的,例如整型数组复制到整型数组。让我们注意这段代码的几个关键点:
首先,在函数签名中,源参数被声明为 const。这体现了常量正确性,我们向用户、读者和编译器表明:不要修改源数据。显然,目标数组不受此限制,因为我们希望目标数组获得源数组的副本。
其次,我们有一个变量 size 来指定要复制的元素数量。目前看来,为它设置一个默认值可能并不实用,因为很难找到一个能适用于大多数情况的“标准默认值”(比如设为100就很不寻常)。
最后,也是这里最关键的一点,我们使用了安全转换。在我看来,正是这一点使得这个例程变得合理。
template <typename T1, typename T2>
void copy(const T1 source[], T2 destination[], int size) {
for (int i = 0; i < size; ++i) {
destination[i] = static_cast<T2>(source[i]); // 安全转换
}
}
如果类型 T1 的对象无法安全地转换为类型 T2,那么这段代码在运行时将会出错。或者,编译器会识别出不存在安全转换的机会,从而提供一个编译时错误。无论如何,代码都无法正常工作,并且会以一种易于检测的方式失败。
在代码能够工作的情况下,我们知道我们有一种安全的方法将源内容“重塑”为目标内容。例如,将整型数据转换为双精度浮点型数据,或者反过来,在这里都是完全合理的。这两种转换,一种称为提升(将整型域的数据转为双精度型),另一种称为降级,但两者都是常规且易于理解的,可以由安全转换例程处理,并且很大程度上是可移植的。程序应该能在任何计算机系统上运行。
这就是一个多模板参数的不算不合理的用法。
关于类型转换的深入讨论
这引出了关于类型转换的问题。类型安全是C++世界相对于C世界的一大改进。C世界在类型上非常松散,例如你可以写 (double) some_expression。无论这个表达式是什么,C编译器都会强制将其解释为 double 类型,而不管这种转换是否可移植。
C++的编程范围更广,包括面向对象编程。在面向对象社区中,某些类型和类型层次结构(称为多态类型、继承结构)提供了其他种类易于理解的转换机会。在C++世界中,我们面临着更丰富的转换可能性,因此必须更加小心。
C++定义了四种基本类型的转换:
以下是四种C++转换操作符的简要说明:
static_cast:用于安全的、明确定义的转换,如数值类型转换和具有继承关系的指针/引用向下转换。reinterpret_cast:用于低级的、依赖于实现的重新解释,如将指针转换为整数,或在不同类型的指针间转换。高度不安全。const_cast:用于添加或移除const和volatile属性。dynamic_cast:用于在具有多态性的类层次结构中进行安全的向下转换或交叉转换,会在运行时检查转换是否有效。
安全转换类型是 static_cast。而高度不安全的是 reinterpret_cast,这大致对应了C语言中那种强制转换的思路。但它提醒你一个事实:这通常是由于某些原因(很可能是一种“黑客”行为)而进行的。在我看来,这是你不应该做的事情。如果我作为你的经理,这会是一个“禁止”项。你必须说服我你有充分的理由使用 reinterpret_cast,而我对此会极其不情愿。
同样,在大多数情况下,const_cast 对我来说也是一个“禁止”项。你可以通过它移除常量性。有时你可能希望将声明为常量的东西变得可修改,但在我看来,你混淆了两个特性:你最初为什么要把它设为常量呢?你实际上在说:“哦,让那个东西大部分时间是常量,我不希望π值突然改变,但可能有时我想调整它。”这不是我的思维方式。
所以,再次强调,对于其他几种转换,你基本上应该将它们从你的技能库中剔除。安全转换是可以的。旧式C风格转换虽然在许多编译器中仍然可用,但已被弃用。弃用意味着不要使用它,编译器编写者实际上有权禁止它的使用。
还有一些特殊情况,涉及类层次结构的 dynamic_cast,当你进行一些非常复杂的面向对象编程时,这可能是有必要的。但同样,我不是它的粉丝。
因此,我的建议是:安全转换(static_cast),可以。其他类型,基本不行。
总结

本节课中我们一起学习了C++中多模板参数的使用。我们了解到,虽然模板可以接受多个参数,但这通常会引入不必要的复杂性,应谨慎使用。我们通过一个类型安全的数组复制函数示例,看到了合理使用双模板参数的场景,其核心在于利用 static_cast 进行安全类型转换。最后,我们回顾并强调了C++中四种类型转换操作符的区别,重申了坚持使用安全转换(static_cast)以保障代码健壮性和可维护性的最佳实践。
013:图论算法入门
在本节课中,我们将学习图论算法的基础知识。图论是计算机科学中许多离散问题的基石,广泛应用于人工智能、路径规划(如GPS)和网络通信等领域。由于图并非编程语言的内置数据类型,我们将探讨如何在C++中将其实现为一种数据类型,这有助于我们理解C++的面向对象特性。
图论基础概念
上一节我们介绍了图论的重要性,本节中我们来看看图的一些基本定义和属性。
一个图由节点(或顶点)和连接节点的边组成。边可以是有向的(单向)或无向的(双向)。本课程主要讨论无向边,它们类似于双向道路。
完全图

一个完全图是指图中每对不同的节点之间都恰好有一条边相连。
以下是绘制一个4节点完全图(称为K4)的步骤:
- 画出四个节点,可以标记为1、2、3、4。
- 在每对节点之间都画上一条边。
最终,每个节点都与其他三个节点相连。
节点的度
在无向图中,一个节点的度是指连接到该节点的边的数量。
在K4中,每个节点的度都是3。推广开来,在具有n个节点的完全图Kn中,每个节点的度是 n-1。

图论的起源:柯尼斯堡七桥问题
了解了图的基本概念后,我们来看看图论的历史起源。这源于一个著名的实际问题——柯尼斯堡七桥问题。
18世纪,数学家莱昂哈德·欧拉研究了这个难题。柯尼斯堡城有一条河,河中有两个岛,七座桥连接着不同的陆地与岛屿。问题是:能否从某地出发,恰好经过每座桥一次,并最终回到起点?
欧拉将陆地抽象为节点,将桥抽象为边,从而将实际问题转化为一个图论问题。通过数学推理,他证明这样的路径(后来被称为“欧拉路径”)对于柯尼斯堡的桥是不存在的。
这个故事展示了图论如何通过抽象来分析和解决复杂的现实问题。在20世纪,随着计算机的出现,图论在优化、搜索、匹配和数据库查询等众多计算领域中变得至关重要。
总结
本节课中我们一起学习了图论算法的入门知识。我们定义了图、节点、边、完全图和节点的度等核心概念,并用公式 度 = n-1 描述了完全图中节点的属性。我们还回顾了图论起源于欧拉解决柯尼斯堡七桥问题的历史,理解了将现实问题抽象为图模型的重要性。这些基础为我们后续在C++中实现图数据结构并应用各种图算法奠定了基础。
014:作为数据结构的图
概述
在本节课中,我们将学习如何在计算机中表示图这种数据结构。我们将介绍两种主要的表示方法:邻接矩阵和邻接表,并分析它们各自的优缺点和适用场景。
图的计算机表示
当我们在计算机上使用图时,需要创建一种内部数据结构来表示它。例如,尝试表示一个完全图K4。

在计算机科学中,有两种主要的表示方法。一种称为邻接矩阵,另一种称为邻接表。这两种方法各有优劣。对于数据结构而言,总是存在权衡。对于某些算法和某些图,一种表示方法可能比另一种具有显著优势。典型的权衡涉及需要存储多少数据以及算法的效率如何。因此,确实可能存在一种表示方法对于特定问题需要太多存储空间,而另一种表示方法可能需要太多的处理时间。我们发现这两种方法都非常有用,有时在同一个问题中,我们可能希望同时使用两者。
总体而言,如果你主要熟悉邻接表表示法,那么邻接表通常在图不是完全图、边数不多、节点度数不高的情况下表现更好。
稀疏图与稠密图
假设你有一个大小为100的图,意味着有100个顶点或节点。如果该图的平均度数大约是4,那么对于100个节点,你大约会有400条边。这被认为是稀疏图。
但是,如果你有一个100个节点的图,平均度数为50或60,那么你将拥有大量的边,这将是稠密图。
通常的经验法则是:对于稠密图,矩阵表示法通常更好;对于稀疏图,邻接表表示法更好。大多数现实世界的问题都是相对稀疏的。
邻接表表示
一个有n个顶点的有向图可以用一个列表来表示。例如,一个包含n个列表的数组,其中列表i表示标记为数字i的节点i所能直接连接的每个顶点,即从节点i可以到达的每个顶点j。
此外,在图论术语中,你可能不仅需要边,还需要边上的权重或成本。例如,从旧金山到圣何塞大约60英里,这可能是一个成本。而旧金山到马林县可能只有2英里。在路径规划中,你的车载计算机通常会尝试为你找到基于时间或距离的成本最低的路线。这就是加权图。

无向图
对于无向图,例如我们之前讨论的K4,任何时候你有一条从旧金山到圣何塞的路,你都可以使用同一条路返回。许多问题我们只考虑无向图以避免有向图的复杂性,但处理有向图并不难得多。我们将双向道路视为无向边,而单向道路则是有向边。我将讨论的所有算法都可以很容易地适应有向图的情况。
矩阵与列表对比
以下是同一个图的矩阵表示与列表表示。
这是一个包含四个节点的图:节点1、节点2、节点3、节点4。
在矩阵表示中:
- 条目
(1,1)表示一个自环(通常我们不关心自环)。 - 条目
(1,2)、(1,3)、(1,4)表示从节点1到节点2、3、4有边。 - 节点2只有一条边连接到节点1。
- 节点3有两条边,分别连接到节点2和节点4。
- 节点4有两条边,分别连接到节点2和节点3。
在邻接表(边列表)表示中,我们有四个列表,对应四个节点:
- 节点1的列表包含:1(自环)、2、3、4。
- 节点2的列表包含:1。
- 节点3的列表包含:2、4。
- 节点4的列表包含:2、3。
你应该能够做到我刚才所做的事情:从一个给定的图生成这两种表示中的任何一种。这是一项重要的技能。
小测验
以下是一个无向图,意味着每条边可以双向通行。它有5个节点和4条边。请为其生成邻接表表示和邻接矩阵表示。

以下是答案。

邻接矩阵表示:
矩阵是对称的,因为图是无向的。
- 节点1连接到节点3。
- 节点2连接到节点3。
- 节点3连接到节点1、2、4、5。
- 节点4连接到节点3。
- 节点5连接到节点3。
- 没有自环。
邻接表表示:
由于这是一个稀疏图(大多数节点度数为1),邻接表很简洁。
- 节点1的列表:3
- 节点2的列表:3
- 节点3的列表:1, 2, 4, 5
- 节点4的列表:3
- 节点5的列表:3
注意,这个图是连通图,这是一个当我们开始设计算法时会感兴趣的性质。节点3的度数为4,其他所有节点的度数均为1。

总结
本节课中,我们一起学习了图的两种核心计算机表示方法:邻接矩阵和邻接表。我们了解了它们的区别,并分析了在稀疏图和稠密图中如何根据存储空间和算法效率进行选择。我们还通过实例练习了如何根据给定的图生成这两种表示,并接触了加权图、有向/无向图以及连通图等基本概念。掌握这些表示方法是理解和实现图算法的基础。
015:迪杰斯特拉最短路径算法详解 🧭
在本节课中,我们将学习计算机科学中最重要的算法之一:迪杰斯特拉最短路径算法。我们将了解其发明者、核心思想,并通过一个手算示例来演示其工作原理。最后,我们将讨论如何将其应用于编程作业中。
算法背景与发明者
上一节我们提到了迪杰斯特拉算法的重要性,本节中我们来认识一下它的发明者。

艾兹赫尔·迪杰斯特拉是一位荷兰计算机科学家,也是现代计算机科学领域最重要的人物之一。他参与了计算机语言设计、编译思想、编程语义和正确性等领域的研究,并发明了许多非常巧妙且重要的算法。
迪杰斯特拉的职业生涯始于荷兰的数学中心,后来成为美国德克萨斯大学的教授。他也是最早获得图灵奖的人之一。图灵奖在计算机科学领域的地位,类似于麦克阿瑟奖或诺贝尔奖。
算法核心概念与问题
了解了发明者后,我们来看看这个算法要解决什么问题。
在迪杰斯特拉最短路径算法中,我们使用带有权值(成本)的无向图,且所有权值均为非负数。其核心概念可以类比为寻找地图上的最短路线。
一个典型的问题是:找到从芝加哥到洛杉矶的最短行车路线。你有一张包含许多道路的大地图。如今,车载导航电脑在某种程度上就嵌入了我们将要学习的迪杰斯特拉算法。
算法的目标是:给定一个源点(如芝加哥)和一个目标点(如洛杉矶),在它们之间找到一条成本最低的路径。

算法核心步骤
明确了问题,接下来我们深入算法的核心机制。
迪杰斯特拉算法的关键步骤是维护两个集合:
- 封闭节点集:其中的节点,我们已经确知了从源点到该节点的最短距离。
- 开放节点集:其中的节点,我们已经找到了从源点到达该节点的一条路径及其距离,但这不一定是最短距离。
算法开始时,源点 S 被放入封闭集,其距离值为 0。然后,算法查看源点的所有直接后继节点,将它们及其通过 S 到达的成本放入开放集。

以下是算法的主要迭代步骤:
-
选择:从开放集中选取成本最低的节点
N。 -
终止检查:如果选中的节点
N就是目标节点D,则算法结束。此时,D的成本值就是最短距离。 -
扩展:如果
N不是目标节点,则将其从开放集移至封闭集。然后,检查N的所有直接后继节点J。 -
更新后继节点:对于每个后继节点
J,计算一条经由N到达J的新路径成本:cost_to_J = cost_to_N + cost_of_edge(N, J)。根据J的当前状态,分三种情况处理:- 如果
J已在封闭集,则忽略它,因为到J的最短路径已知。 - 如果
J不在开放集也不在封闭集,则将J加入开放集,并记录其成本为cost_to_J。 - 如果
J已在开放集,但新路径成本cost_to_J小于其当前记录的成本,则用更小的成本值更新J。
- 如果
-
循环:重复步骤1-4,直到目标节点被选入封闭集,或者开放集为空(表示目标不可达)。
在计算过程中,算法会维护一个“最短路径树”,使我们能在算法结束时,不仅知道最短距离的值,还能回溯出具体的路径。
手算示例演示
理论步骤可能有些抽象,让我们通过一个具体的手算例子来巩固理解。
假设我们有以下有向图(算法同样适用于无向图),目标是找到从源点 S 到目标点 T 的最短路径。图中边上的数字代表成本。

(此处应有图示,描述节点 S, A, B, C, D, E, F, G, T 及连接边的权重)
初始状态:
- 封闭集:
{ S(0) } - 开放集:
{ A(4), B(3), D(7) }(从S可直接到达)
迭代过程:
-
开放集中最小成本节点是
B(3)。将其移入封闭集。- 扩展
B:后继D的成本为3+4=7,不比开放集中已有的D(7)更优,无需更新。 - 封闭集:
{ S(0), B(3) } - 开放集:
{ A(4), D(7) }
- 扩展
-
开放集中最小成本节点是
A(4)。将其移入封闭集。- 扩展
A:后继C的成本为4+1=5。C是新节点,加入开放集。 - 封闭集:
{ S(0), B(3), A(4) } - 开放集:
{ D(7), C(5) }
- 扩展
-
开放集中最小成本节点是
C(5)。将其移入封闭集。- 扩展
C:后继E成本5+1=6(新节点,加入);后继D成本5+3=8,比现有的D(7)差,不更新。 - 封闭集:
{ S(0), B(3), A(4), C(5) } - 开放集:
{ D(7), E(6) }
- 扩展
-
开放集中最小成本节点是
E(6)。将其移入封闭集。- 扩展
E:后继G成本6+2=8(新节点,加入);后继T成本6+4=10(新节点,加入)。注意:虽然发现了到T的路径,但成本10并非当前开放集中最小,不能保证是最短,故继续。 - 封闭集:
{ S(0), B(3), A(4), C(5), E(6) } - 开放集:
{ D(7), G(8), T(10) }
- 扩展
-
开放集中最小成本节点是
D(7)。将其移入封闭集。- 扩展
D:后继F成本7+5=12(新节点,加入);后继T成本7+3=10,与开放集中T(10)相同,不更新;后继E在封闭集,忽略。 - 封闭集:
{ S(0), B(3), A(4), C(5), E(6), D(7) } - 开放集:
{ G(8), T(10), F(12) }
- 扩展
-
开放集中最小成本节点是
G(8)。将其移入封闭集。- 扩展
G:后继T成本8+3=11,比现有的T(10)差,不更新。 - 封闭集:
{ S(0), B(3), A(4), C(5), E(6), D(7), G(8) } - 开放集:
{ T(10), F(12) }
- 扩展
-
开放集中最小成本节点是
T(10)。这就是我们的目标节点。算法终止。
结果:从 S 到 T 的最短路径成本为 10。通过回溯路径树(本例中路径为 S -> A -> C -> E -> T),可以得到具体路径。注意,最短路径可能不唯一(例如 S -> D -> T 成本也是10),但算法保证能找到其中一条。
你应该用其他图进行练习以加深理解。
编程作业与应用
掌握了算法原理,最后我们来看看如何将其付诸实践。
接下来布置的作业,就是要求你实现迪杰斯特拉最短路径算法。这意味着你需要决定图的表示方式。
我的建议是使用邻接表表示法,它更灵活。但使用邻接矩阵表示法也完全可以,甚至可以根据你的积极性两者都实现。这将是下次需要提交的作业。

总结

本节课中我们一起学习了迪杰斯特拉最短路径算法。我们了解了其发明者迪杰斯特拉教授的贡献,明确了算法解决的是带权图中的单源最短路径问题。我们深入剖析了算法通过维护开放集和封闭集来逐步确定最短路径的核心步骤,并通过一个详细的手算示例演示了整个过程。最后,我们指出了在编程实现中需要考虑的图表示方法。这个算法是许多实际应用(如网络路由、地图导航)的基础,理解其原理至关重要。
016:C++创建类型
概述
在本节课中,我们将学习C++面向对象编程的一个核心主题:类型扩展。我们将从回顾C语言的基础类型开始,理解类型的重要性,然后探讨C++如何通过引入“类”的概念来扩展语言的内置类型,从而实现面向对象编程范式。
从C到C++:类型的演进
上一节我们回顾了C++作为“更好的C”的特性。本节中,我们来看看C++如何超越C,成为一个支持面向对象编程的语言。
C++最初被设计为“更好的C”。作为一个系统实现语言,它保留了C语言的所有能力,同时增加了许多改进。到目前为止,我们主要关注了这些改进。然而,从本节课开始,我们将探讨如何使用C++进行面向对象编程。这种方法论最初由施乐帕克研究中心在80年代的Smalltalk语言中开创,随后由贝尔实验室的比雅尼·斯特劳斯特鲁普在1985年推出的C++中推广。C++的出现立即改变了工业界对使用单一语言有效解决大多数问题领域的看法。
C++允许我们极大地扩展C语言的可用性。我们将以一个非常简单的例子来说明这一点:二维平面上的一个点。我们从小学就开始接触点,因此这是一个非常自然的讨论对象。
如果你正在阅读教材,现在应该阅读第4章和第5章,这两章开始讨论类、类型扩展以及C++的面向对象特性。
C语言基础类型回顾

作为C程序员,在转向面向对象编程时需要牢记一些基础概念。让我们先回顾一下C语言中的原生类型。
以下是C语言(以及C++语言)中的一些基本原生类型:
shortintdoublecharlonglong doubleint*(以及所有基本指针类型)
任何涉及这些类型的变量,你只需使用相应的关键字声明即可,然后就可以相对轻松高效地使用语言,前提是这些类型能够表达问题域。

类型的重要性:一个关键例子
类型在编程中至关重要,其影响方式非常关键。我们必须非常小心地使用类型,因为类型的使用也会定义运算符的行为。
考虑以下两个表达式及其值:
3 / 4的值是 0。3.0 / 4的值是 0.75。
第一个表达式 3 / 4 的结果是零,这常常让人困惑。原因是:当除法运算符 / 看到两个整数参数时,它会执行整数除法。结果的小数部分会被直接舍弃。
第二个表达式 3.0 / 4 中,3.0 是一个双精度浮点数字面量。当你用一个双精度数除以一个整数时,运算会在双精度域中进行,因此你会得到可能更直观的答案:四分之三。
这个例子告诉我们:类型至关重要。类型不仅决定了数据的存储方式,还决定了对其进行的操作(如这里的除法)的行为。
C++的类型扩展:面向对象的核心
在C++中,类型的重要性变得更加复杂,也更为强大。因为C++在原生类型的基础上进行了扩展。这正是面向对象编程的核心。
C++允许我们创建新的类型,这些新类型可以像内置类型一样被使用。这是通过 class 关键字实现的。通过定义类,我们可以将数据(属性)和对这些数据的操作(方法)捆绑在一起,形成一个完整的、可重用的抽象单元。
例如,我们可以创建一个 Point 类来表示二维平面上的点,这个类可以包含 x 和 y 坐标数据,以及移动点、计算距离等方法。这样,Point 就成为了语言中一个全新的、功能完整的类型。

总结

本节课中,我们一起学习了C++面向对象编程的起点:类型扩展。我们首先回顾了C语言中原生类型的重要性,并通过整数和浮点数除法的例子,深刻理解了类型如何决定运算行为。最后,我们引出了C++最强大的特性之一——允许程序员通过定义类来创建新的自定义类型,从而将数据和操作封装在一起,这正是面向对象编程的基石。在接下来的课程中,我们将深入学习如何定义和使用类。
017:枚举与运算符重载
概述
在本节课中,我们将学习C++中的枚举类型。这是一种扩展语言类型系统的方式,允许我们为特定领域(例如颜色)创建自定义的类型。我们将探讨枚举的基本概念、如何定义枚举、以及为何使用枚举优于其他定义常量的方法。
枚举类型简介
面向对象编程让我们能够自然地在新领域中编程。这些领域可能没有完全匹配的原生语言类型。例如,我们可能希望编写涉及颜色操作的程序。想象一个窗口系统,在菜单选项中,你可能希望使用颜色来表达不同的层次。因此,你可能需要一个颜色类型。
这是一种简单的类型扩展形式。通常,颜色可以被定义为一小组离散的项。下面我们看到一个枚举类型,这是C++语言中类型扩展的一种形式。
enum Color { RED, WHITE, GREEN };
这里定义了一个名为Color的枚举类型,它包含三个枚举器:RED、WHITE和GREEN。这本质上是一种简单的整数类型。我们使用大写字母RED是因为它是一个标识符,一个枚举器。大写是我们的约定,用于强调我们正在表达一种整数常量形式。
当没有指定值时,RED会自动被赋值为0。这是C语言社区的传统,一切从0开始。WHITE将是1,GREEN将是2。我们通常这样做。
我们也有能力通过显式赋值来覆盖这些值。
enum Color { RED, WHITE = 3, GREEN };
这意味着RED从0开始,WHITE从3开始,然后序列从3继续到4(GREEN为4)。

为何使用枚举?
你们中有些人有使用枚举的经验,枚举甚至被添加到了C语言中,许多其他编程语言也有枚举类型。这里有一个多选题,请花点时间思考。
你可以使用命名的整数常量来代替枚举类型吗?你可以使用类似C语言旧风格的预处理器宏定义(#define)来定义像RED和WHITE这样的枚举常量吗?或者,对于一组小的相关常量,使用枚举是最好的吗?那么,使用枚举的原因是什么?
枚举的优势

确实,你可以只使用常量声明。例如,如果我们想要true和false作为两个相关常量,我们可以说const int TRUE = 1;和const int FALSE = 0;。在旧的C语言中也可以这样做,但这存在安全问题。
然而,枚举类型比上述两种方法都要好。当存在一组相关的常量,并且你希望将其视为类型扩展,而不仅仅是任意的命名整数常量时,枚举是更好的选择。
枚举提供了更强的类型安全性,编译器可以更好地检查错误,并且使代码的意图更加清晰。
总结

本节课中,我们一起学习了C++中的枚举类型。我们了解了如何定义枚举来为特定领域(如颜色)创建自定义类型,探讨了枚举常量的默认赋值和显式赋值。最重要的是,我们讨论了为何使用枚举优于使用宏定义或普通整数常量,原因在于枚举提供了更好的类型安全性和代码可读性,尤其适用于表示一组逻辑上相关的常量。
018:枚举类型与运算符重载

概述
在本节课中,我们将学习C++中的枚举类型以及如何通过运算符重载,使自定义类型具备与内置类型相似的行为和直观的使用体验。这是面向对象编程的核心思想之一。
枚举类型
枚举类型允许你定义一组相关的常量。在C++中,我们可以通过运算符重载,为已存在的C语言运算符赋予新的含义,从而使这些扩展的新类型具备与内置类型相似的外观和感觉。这是优秀面向对象编程的秘诀:让你的扩展类型看起来自然,仿佛它们本身就是语言的一部分。
创建新类型的关键原则
创建新类型时,应遵循一个核心范式:模仿内置类型的行为。内置类型如int和float,我们希望创建的类型能尽可能地模仿它们。我们将尝试使用一个自然的类型——星期几——来创建一个枚举类型。
这是面向对象编程的核心课程。面向对象是一种扩展编程语言的方法,旨在将语言核心未内置的新类型融入其中。早期编程语言(如PL/1)试图将所有能想到的类型都塞进语言中,这导致语言变得笨拙。C++改变了这一点,它提供了一些技术,让我们能够以无缝的方式扩展现有类型,使其看起来和用起来都像内置类型。这正是我们在此要达成的目标。
运算符重载
实现上述目标的关键在于能够重新定义运算符,为新的类型赋予新的定义。这就是我们将要重点学习的内容。
我们有一个枚举类型days,它包含Sunday、Monday、Tuesday、Wednesday等值。这意味着枚举值Sunday的显式值为0,而Saturday的显式值为6。
同时,请注意我们在这个类型中为自增运算符++定义了新的含义。
为何选择自增运算符?
自增运算符作用于整数类型时,会将值加一。例如,一个整数100自增后变为101。在这里,我们希望实现相同的行为:能够获取一周中的某一天,并得到它的下一天。例如,如果今天是星期三,那么下一天就是星期四。我们正在为这种日历对象创建一种直观的运算逻辑。
自增运算符重载定义分析
以下是自增运算符重载的定义:
inline days operator++(days& d) {
d = static_cast<days>((static_cast<int>(d) + 1) % 7);
return d;
}
inline:我们使用inline是因为预计会频繁使用这个运算,希望避免函数调用的效率开销。它告诉编译器尝试内联这段代码。- 返回类型
days:我们希望接收一个如Tuesday这样的days类型,并返回Wednesday这样的days类型,而不是返回int或其他类型。 - 一元参数:自增运算符是一元运算符,因此重载时需要一个参数。
- 运算符重载的限制:在C++中,只能重载一元和二元运算符。不能重载三元运算符(如
? :)。不能为运算符的现有类型(如int的+)重载新含义,否则会造成混乱。同时,不能改变运算符的优先级和结合性。 - 语义实现:代码将枚举值安全地转换为
int,加1,然后对7取模。这是因为星期六(值为6)的下一天是星期日(值为0)。7 % 7等于0,这正好符合日历的循环逻辑。最后,返回类型是days,因此它创建的是一个days类型的值,而不是整数。
输入/输出(I/O)重载
任何时候创建一个新类型,作为面向对象设计习惯的一部分,我们通常也希望为其提供熟悉的I/O方式。
在C++中,位左移运算符<<被用于输出(类似于C语言中的printf)。直观上,箭头方向暗示了输出。你应该习惯这种用法。
以下是输出运算符重载的示例:
std::ostream& operator<<(std::ostream& out, const days& d) {
switch(d) {
case Sunday: out << "Sun"; break;
case Monday: out << "Mon"; break;
// ... 其他 cases
case Saturday: out << "Sat"; break;
}
return out;
}
需要指出的几点:
- 返回类型
std::ostream&:返回输出流的引用是为了支持链式调用(如cout << a << b)。 - 二元运算符:输出运算符是二元运算符,需要两个参数:一个输出流和一个要输出的自定义类型对象。
- 参数传递:输出流通常以引用方式传递,以避免拷贝,提高效率。自定义类型对象使用
const引用,因为我们通常不希望在输出时修改它。 - 语义实现:使用
switch语句根据枚举值输出对应的字符串(如"Mon")。 - 返回值:返回输出流本身,以支持链式操作。
对于任何新创建的类型,重载<<运算符以提供适当的输出形式是一种常见的期望。
综合应用示例
现在,让我们在main函数中使用所有这些特性。
int main() {
days d = Monday; // d 是 Monday
days e; // e 未初始化
e = ++d; // 调用重载的 ++ 运算符,d 变为 Tuesday,e 被赋值为 Tuesday
std::cout << d << '\t' << e << std::endl;
// 输出: Mon Tue
return 0;
}
代码分析:
days d = Monday;:d被初始化为Monday。days e;:声明一个未初始化的days变量e。e = ++d;:这实际上调用了我们重载的operator++(days& d)函数。d(Monday,值1)自增后变为Tuesday(值2),然后赋值给e。std::cout << d << '\t' << e << std::endl;:这是一个链式调用。- 首先计算
std::cout << d。由于我们重载了<<,这会输出"Mon",并返回std::cout的引用。 - 接着计算
返回的cout << '\t',输出一个制表符,再次返回cout。 - 然后计算
返回的cout << e,输出"Tue",再次返回cout。 - 最后计算
返回的cout << std::endl,输出换行并刷新缓冲区。
- 首先计算
这样,我们就为新的数据类型实现了自然的语义。这是通过重载两个原本对该类型没有意义的运算符来实现的。理解这个简单类型的正确做法至关重要,你可以将这种模式应用到更复杂的类型扩展中。这是面向对象的一个原则,也是C++强大能力之一(例如Java就不允许重载大多数运算符)。
思考与回顾
根据我们刚刚学到的内容,可以思考以下问题:
<<运算符的本质是什么? 它本质上是左位移运算符。- 能为新类型改变运算符的优先级吗? 不能。在C++中,运算符的优先级和结合性是固定的,无法通过重载改变。
- 当
<<被重载用于多种不同类型时,如何确定使用哪个重载版本? 这基于函数重载的规则,编译器会根据操作数的类型(签名)来选择最匹配的重载版本。例如,std::cout << 5调用的是为int定义的内置版本,而std::cout << myDay调用的是我们为days类型重载的版本。
如果你来自电气工程或化学等领域,使用的运算符在专业领域有特定的优先级或结合性,而这与C++语言内置的不同,那么你需要特别注意这一点。
对于输入,我们将使用右位移运算符>>进行类似的重载,以实现输入功能。
总结

本节课我们一起学习了C++中枚举类型的定义,以及如何通过运算符重载(特别是自增运算符++和输出运算符<<)来扩展自定义类型,使其具备与内置类型相似、直观且自然的行为。掌握这种为简单类型正确重载运算符的模式,是理解和运用C++强大类型扩展能力的基础。
019:构建部件的自然方式
概述
在本节课中,我们将学习面向对象编程的核心思想:如何通过高效地扩展类型,让编程语言变得通用,从而适应任何特定领域。我们将从C语言的结构体(struct)入手,理解其作为类型扩展的原始形式,然后探讨C++如何通过引入类(class)和运算符重载等特性,为类型赋予更自然、更强大的操作语义。
从C语言的结构体说起
上一节我们介绍了面向对象语言通过类型扩展实现通用性的核心理念。本节中,我们来看看在C语言中,是如何进行初步的类型扩展的。
在C语言中,创建新类型的典型方式是使用关键字 struct。
struct Point {
int x;
int y;
};
以上代码定义了一个名为 Point 的新类型,它包含两个数据成员(x 和 y)。这可以看作是在二维平面上的一个点。

C语言中操作结构体的方式
定义了结构体后,我们自然希望对其进行操作。例如,我们有两个点,希望将它们相加。
在几何意义上,两个点相加意味着其x坐标和y坐标分别相加。例如,点(2, 5)和点(7, 2)相加,结果是(9, 7)。
然而,在C语言中,没有为struct Point定义“+”运算符的自然语义。我们必须编写独立的函数来操作它。
以下是C语言中实现点相加功能的方式:
struct Point addPoints(struct Point p1, struct Point p2) {
struct Point result;
result.x = p1.x + p2.x;
result.y = p1.y + p2.y;
return result;
}
在C语言中:
- 结构体(struct) 仅包含数据成员(字段)。
- 操作结构体的函数具有文件作用域,它们通常接受结构体指针或结构体本身作为参数。
- 这是一种原始的、功能有限的类型扩展方式,操作与数据是分离的,缺乏封装性和自然的语法。
C++的进化:从struct到class
C++的设计者Bjarne Stroustrup有一个关键见解:他保留了C语言的struct,但为其添加了至关重要的新功能。
在C++中,struct不仅可以包含数据成员,还可以包含函数成员(方法)。这些函数被限定在结构体的作用域内,与数据紧密关联。

为了更清晰地表达“面向对象”的意图,并与更简单的C风格struct区分,C++引入了class关键字。class提供了完整的面向对象特性(如访问控制)。而struct在C++中默认成员是公开的(public),通常用于主要包含数据的简单结构。
class Point {
public:
int x;
int y;
// 可以在这里添加操作点的函数
};
C++的强大之处:自然的操作语义
回到我们“点相加”的例子。C++的强大之处在于,它允许我们为自定义类型赋予更自然的操作语义。
通过运算符重载,我们可以直接为Point类型定义“+”运算符的行为,就像为内置类型(如int)定义的那样。
Point operator+(const Point& p1, const Point& p2) {
Point result;
result.x = p1.x + p2.x;
result.y = p1.y + p2.y;
return result;
}
定义之后,我们就可以像下面这样直观地使用:
Point a{2, 5};
Point b{7, 2};
Point c = a + b; // c 现在是 (9, 7)

与C语言的方式相比,C++的实现具有以下优势:
- 更好的封装性:数据与操作它的函数可以组织在一起。
- 更自然的语法:使用
a + b而非addPoints(a, b),代码意图更清晰,更符合直觉。 - 更强的表现力:使得自定义类型能够像内置类型一样被使用,降低了领域专家(如化学家、生物信息学家)为其领域创建专用“语言”(即类型库)的门槛。
总结
本节课我们一起学习了构建部件(即自定义类型)的自然方式。
我们从C语言使用struct进行原始类型扩展开始,了解了其通过独立函数操作数据的局限性。接着,我们探讨了C++如何在保留struct的同时,通过引入class和作用域内的成员函数,实现了更好的封装。最后,我们看到了C++运算符重载这一强大特性,它允许我们为自定义类型定义直观的操作符(如+),从而让代码更自然、更易读。

这种能力正是面向对象语言“通用性”的关键:它允许各个领域的专家创建适合其领域的、易于使用的类型,让程序员能够以符合领域逻辑的自然方式编写程序。
020:C++中的点类

概述
在本节课中,我们将学习如何在C++中创建一个名为Point(点)的用户自定义类型。我们将重点介绍类的概念、访问控制(public和private)、成员函数、运算符重载以及构造函数。目标是让这个新类型的使用方式尽可能接近C++内置的原始类型(如int、double),使其直观且高效。
从C到C++:使用class关键字
上一节我们介绍了在C语言中创建新类型的局限性。本节中,我们来看看C++如何通过class关键字更好地实现这一点。
首先,我们选择使用新的关键字class来定义Point类型。在C++中,类名自动成为类型名,我们不再需要C语言中typedef那样的技巧。
接下来,你会看到一个不同之处:访问控制关键字。这个关键字可以是以下三个之一:public、private、protected。目前,我们暂时忽略protected,它是继承机制的一部分,我们改天再讨论。对于大多数编程任务,你真正需要了解的是public和private。
这体现了编程中的一个“90%规则”:掌握一些简单的核心理念,就能解决90%的问题。你应该专注于学习这些广泛适用的基础知识,剩下的10%可以在需要时再学习。
public:声明在此区域的内容对所有代码可见。例如,public: double x, y;意味着世界上任何人都可以查看和修改x和y。private:与public相反,它限制访问,只有这个类自身的作用域内的代码(如类的成员函数)才能访问。我喜欢将private部分称为“黑盒原则”。隐藏在这里的是内部的私有内容。
这就像一个iPhone:制造商只希望授权专家处理其内部构造(黑盒)。普通用户看到的是表面的几个控制按钮(公共功能)。他们不需要知道内部运行着什么处理器或操作系统,只需要知道如何使用电话、地图或查看电影评论的应用程序。这是一个重要的设计原则:尽可能隐藏非专家或普通用户不需要知道的细节,只向他们提供公共的控制接口。
类的结构与成员函数
这里,我们正在尝试创建一个新的用户自定义对象。使用面向对象技术时,我们需要理解其一些关键要素。
面向对象技术的首要原则是让你新的数据对象看起来自然,与现有的编程语言数据类型(如int、float)兼容。我们遵循的原则之一就是数据隐藏。因此,我们在private区域隐藏数据的内部表示。
为了保证效率,请注意:在面向对象术语中,成员函数有时被称为“方法”。当成员函数在类定义内部实现时,它会自动成为内联函数,这意味着它非常高效。
例如,getX()函数返回x的值,这实际上并不涉及真正的函数调用开销,它只是直接获取与该Point实例关联的变量x的内部值。
同样,setX()函数(也称为更改器方法,因为它可以改变隐藏表示的值)也是高效的。在代码中调用成员函数setX的地方,编译器可能会将其替换为简单的赋值操作。
如果你想回顾面向对象的概念,可以参考维基百科等资料。遵循这个模板:使用public来定义你希望对象具有的行为,使用private来隐藏内部表示。这样,通过提供一组丰富且自然的类函数,你就能在常规编程中轻松使用Point类型。
以下是Point类的基本结构示例:
class Point {
private:
double x, y; // 私有数据成员,隐藏内部表示
public:
// 成员函数(方法)
double getX() const { return x; } // 访问器方法,高效内联
void setX(double newX) { x = newX; } // 更改器方法,高效内联
// ... 其他成员函数
};
运算符重载:让类型更自然
我们继续探讨面向对象范式,看看C++允许我们做什么。C++是最强大的面向对象语言之一(实际上它是多范式语言),它允许运算符重载,这是许多其他面向对象语言(甚至一些基于C的语言)所不具备的特性。这使我们能够将运算符的语义扩展到我们新的对象上,从而让新数据类型与旧数据类型之间的交互方式符合直觉。
这里我们有一个叫Point的对象。在高中数学中,点是一个数学对象。在解析几何中,你可以将两个点相加得到第三个点,方法是将它们的X和Y坐标分别相加。这是一个很自然的操作,对应的运算符是二元加法运算符+。
你只应在有现成语义的情况下重载运算符,这通常意味着对象是数学对象。你不想为一个普通的“小部件”重载+,因为没人知道两个小部件相加是什么意思。但在数学领域,+有非常明确的含义。如果我需要在编程中频繁处理点,我就会希望使用算术和数学符号(包括这些运算符)。
我们期望的语义正是你从高中数学中能想象到的。同时,我们也想重载输出运算符<<,这非常重要。请仔细研究这一点,因为这是一个关键惯用法:每当你创建了一个新的数据类型或对象,你通常都需要重载它。这是一种社区期望。如果你是这个新对象的设计者,并要把它交给团队中的其他人,他们会期望IO操作(输入输出)能对它生效。你需要定义这些IO语义。
这里的语义是:我们打印一个左括号(,打印点的x值,打印一个逗号,,打印点的y值,然后打印一个右括号)。因此,在屏幕上对某个点执行此操作,我们可能会看到(3, 4),这代表X-Y平面中坐标为(3, 4)的点。
关键之处在于查看函数签名。返回类型是ostream&(输出流引用)。运算符重载是一个二元运算符(左移运算符<<)。尽管在C++中我们习惯将其视为IO操作,但其本质是位左移运算符。然而,当第一个参数是ostream(输出流)时(正如我们这里所做的),第二个参数是我们的Point对象,编译器使用的签名匹配算法就会选择这个重载版本。如果我们没有提供可用的定义,尝试这样做会导致编译错误;但如果我们提供了合适的定义(即用于ostream和Point),我们就会看到这里定义的行为。
至关重要的是,ostream是通过引用传递的,并且返回值也是ostream的引用。这避免了任何拷贝操作,是高效的。我们返回这个流引用是因为在典型的IO操作中,我们希望链式使用这个运算符。例如:cout << "a = " << a << ", b = " << b << endl;。这个表达式从左到右求值,每个<<操作都返回流本身,以便进行下一个操作。
另外,Point参数也使用常量引用传递(const Point& p)。这是因为Point对象可能非常复杂,通过引用传递可以避免昂贵的拷贝操作,而const表明这个函数不会修改传入的Point对象。
这是一个非常重要的惯用法,理解后你可以将其应用到任何你创建的新类中。记住,你返回out是为了支持链式调用。
在main函数中测试时,我们声明两个点并输出它们。然后计算两个点的和并输出。由于运算符+的优先级高于<<,表达式cout << a + b << endl;会先计算a + b,然后将结果输出。你应该在家尝试这个代码,添加其他运算符,运行并扩展它,这是真正掌握它的唯一方法。
构造函数:对象的创建
当我们去构建一个对象时,面向对象编程需要对象构造。内置对象(如原生类型double、固定数组)都是由编译器构建的。但掌握C++的一个重要部分是获得编写代码、编写类、编写成员函数的技能,以便你能构建对象。
构建对象的关键是一个特殊的方法,它有一个特殊的名字:构造函数。这很直观,构造(constructor)用于构建(construction)。
当你想要构建一个对象时,你需要某种东西来构建它。编译器知道如何构建原生对象,但对于这些新对象(你将要使用的领域特定对象或小部件,即你在扩展语言到新领域),编译器需要你提供构建指令。
关键思想是:构造函数的名称与类名相同。所有其他方法必须有不同的名字,如getX和setX。但当你在类内部看到一个函数,其名称就是类名时,你就知道它是一个构造函数。
构造函数可以有多个,接受不同的参数列表。其中有几种特殊的构造函数类型。
标准的特殊构造函数称为默认构造函数。之所以叫默认构造函数,是因为它定义了在你不提供任何参数时如何构建对象。例如,一个不接受任何参数的Point构造函数,可能会将点初始化为X-Y平面中两条轴的交点,即特殊点(0, 0)。这是直观的,对于原生类型,如果你不初始化它们,编译器也经常将它们初始化为零。这里你必须提供适当的语义,否则实例的秘密数据成员x和y可能不会被初始化。
我展示了两种不同的方式来实现这个语义。第二种方式使用了一个特殊的关键字:this。这被称为自引用指针。自指涉在逻辑或计算机科学中有时会令人困惑。为了让成员函数(方法)访问它自己的成员,它需要某种特殊机制。要么x和y在该类内部是已知的,要么可以通过这个特殊的this指针自指涉地指向它们。这个指针对于任何创建的对象都是始终存在的,我们将会看到它的重要性。
这只是构造函数的一个初步了解。还有一个类似的特殊方法叫做析构函数,用于销毁对象。因此,任何时候我们想要创建东西,我们也希望能够销毁它们,将资源返还给操作系统。这对于保持环境清洁、仔细管理我们的环境非常重要。这是一个主要话题,对于跟随教材学习的同学,阅读第5章非常重要。
总结

本节课中,我们一起学习了如何在C++中创建和使用Point类。我们了解了class关键字、public和private访问控制对数据封装的重要性,以及如何通过成员函数(访问器和更改器)安全地操作数据。我们重点探讨了运算符重载,特别是重载+和<<运算符,这使我们自定义的类型能像内置类型一样自然、直观地使用。最后,我们介绍了构造函数的概念,它是创建和初始化对象的特殊方法,是对象生命周期的起点。通过遵循这些原则,我们可以创建出高效、易用且符合C++社区期望的用户自定义类型。
021:面向对象编程深入
在本节课中,我们将深入学习面向对象编程风格。我们将探讨更多C++类的知识,以及如何以面向对象的方式构建它们。我们将再次审视构造函数和析构函数,这些是构建和释放新扩展数据类型的关键方法。最后,我们将讨论下一个重要的作业,这是一个基于图论的作业。
对象与设计思想
上一节我们介绍了面向对象的基本概念,本节中我们来看看如何具体思考对象设计。
对象可以是任何事物。在这个小例子中,我们考虑的是形状。形状可以是盒子、多边形、技术图形等。可以对它们执行操作,比如删除,或者涉及统计形状数量的操作。


如果我们想构建一个窗口环境,我们的控件集合应该是什么?应该有哪些操作?这个程序包的用户会期望什么?我们如何正确地设计事物并保持内部细节的隐藏?所有这些都是在成为面向对象C++程序员时必须思考的问题。
再次强调,进行这种思考过程的最佳指导原则是了解原生类型会发生什么。对于原生类型,我们必须完全理解声明某些内容时会发生什么,即使是一个简单的int对象。
原生类型的行为回顾
为了理解用户定义类型,我们首先需要回顾原生类型在代码块中的行为。
以下是一个包含嵌套代码块的示例:
int main() {
int i = 9;
int j = 3;
// 打印 i 和 j
{
int i = 5;
// 打印 i 和 j
}
// 再次打印 i 和 j
}
以下是具体分析:
- 在外部代码块中,
i被声明并初始化为9,j被声明并初始化为3。 - 在内部嵌套代码块中,重新声明了
i并初始化为5。此时,外部代码块的i被隐藏。 - 内部代码块中的
j没有重新声明,因此使用的是外部代码块的j。 - 当退出内部代码块时,内部声明的
i被销毁。 - 回到外部代码块后,打印的
i值恢复为9,j值仍为3。
从内存分配的角度看,i和j被放置在栈上。进入内部块时,i_inner被放置在栈上,i_outer被隐藏。退出内部块时,i_inner被销毁。然后我们返回到外部块,栈上仍然存在值为9的i和值为3的j。


构造函数与析构函数
对于简单的原生类型,所有这些(声明、分配、初始化)都是自动发生的。在栈上,我们必须为用户定义的类型(即我们的对象)复制这种行为。
正如我们上次开始看到的,我们有一个特殊的方法来完成这个任务,称为构造函数。

构造函数负责对象的构建和初始化。相应地,析构函数负责在对象生命周期结束时释放资源。它们是管理对象生命周期的关键。
图论算法与作业介绍
接下来,我们将讨论基于图论的作业。所有同学都具备大约大二水平的计算机科学背景,可能已经见过标准的图论算法,因此这对你们来说可能很熟悉。
无论如何,我将复习所有这些材料。所以,即使你忘记了,或者过去没有足够的基础,我们也将真正理解迪杰斯特拉最短路径算法,这将是我们第二次作业的基础。

本节课中我们一起学习了面向对象编程的深入概念,回顾了变量在代码块中的作用域和生命周期,强调了构造函数和析构函数在管理用户定义类型中的关键作用,并介绍了即将进行的基于迪杰斯特拉最短路径算法的图论作业。理解这些基础对于构建健壮、可维护的C++程序至关重要。
022:点及其构造函数


在本节课中,我们将学习如何为Point类编写构造函数,特别是默认构造函数。我们将探讨构造函数的签名、初始化列表的语法、this指针的用法,以及为什么初始化优于赋值。通过本课,你将掌握创建和初始化对象的核心方法。
返回Point对象
让我们回到Point对象,并再次审视其构造函数逻辑。我们将学习如何编写默认构造函数。
构造函数方法
在面向对象编程中,构建新对象的关键是编写称为构造函数方法的特殊方法。在构造方法中,默认构造函数始终非常重要。默认构造函数是其签名为空的构造函数。
请注意,构造函数总是与类名相同,这是判断其为构造函数的方式。
class Point {
public:
Point(); // 默认构造函数
};
带默认参数的构造函数
这里有一个空的签名。请注意,这里看起来有两个参数x和y,但实际上这两个参数都有默认值。因此,这个签名本身涵盖了很多情况,它是一个非常有用的缩写,因为它覆盖了以下情况:无参数调用Point、一个参数调用Point和两个参数调用Point。
在无参数的情况下,创建的Point对象内部表示x等于0,y等于0。
Point p; // x=0, y=0
如果有一个参数的Point对象Q,它将调用此构造函数,这意味着数据值x为传入值,y为默认值0。
Point q(10); // x=10, y=0
如果是两个参数的情况,数据点x和y将分别为传入的两个值。
Point r(12, 2); // x=12, y=2
其他重要构造函数
我们将看到并更多地讨论构造函数,还有其他非常重要的构造函数。几乎在构建新对象时,都必须使用默认构造函数,但也需要构建像拷贝构造函数这样的构造函数,并且经常有一个参数的构造函数,这些构造函数也可用于转换可能性,甚至可能称它们为转换构造函数。所有这些都非常关键,在创建像Point这样的新对象时,应始终考虑。
初始化列表语法
另一个关键点是你以前可能没有见过的初始化列表语法的使用。请注意,在这个构造函数的实际语义中,代码体是空的,看起来没有执行任何操作,但事实并非如此,因为初始化发生了。
因此,不是在这里面进行赋值,你可以进行这些赋值,我们将在本段后面的讲座中展示,你将能够使用自引用指针this,能够说出类似this->x = x的话。
但你不能总是这样做,因为存在特殊情况,例如隐藏表示是不可变的,可能是一个常量值,你不能赋值给常量值。因此,你不能使用这里的赋值语义,而必须使用初始化语义。因此,理解初始化语义在何处更可取以及在何处必须使用非常重要。我们将继续讨论这个问题。
再次记住,this指针是自引用的,我们已经讨论过这一点,有一个关键字this具有特殊含义,我们将在下一张幻灯片中看到所有这些。
三种不同的构造函数
我们现在为Point定义了三种不同的构造函数。其中两种,实际上所有三种都可以用来定义默认构造函数。但最后一种是最佳使用C++的方式。
以下是三种方式的对比:
第一种,我们只是使用普通赋值。
Point() {
x = 0;
y = 0;
}
第二种,我们使用this指针。this指针获取成员,并对成员进行赋值。
Point(double x, double y) {
this->x = x;
this->y = y;
}
第三种,我们使用初始化列表。初始化列表意味着这些值是初始化,而不是赋值,而前两种是赋值。事实证明,使用这最后一种最佳方式,我们可以做更多的事情。
初始化优于赋值
因此,这是我的首选。例如,如果我们有一个Widget,其中一个需要初始化的隐藏成员是const类型。我们有一个const int,称之为special,它隐藏在private中。使用第一种或第二种构造风格将是非法的,因为这涉及赋值。赋值意味着我们正在改变存储在此项中的值。但这是初始化。
初始化,就像原生类型一样,如果我们有一个const int special,并为其赋值5,这意味着在该声明的整个范围内,它都将是5。我们告诉编译器它不能被修改,这有助于我们并防止我们编写有错误的代码。
对于对象也是如此,记住,在构建对象时,我们希望复制原生语言中的所有内容,并且我们可能还希望有能力拥有const成员,这意味着我们必须有能力进行初始化。
初始化语义的适用范围
正如我所说,初始化优于赋值。并且只有在构造函数中,才允许使用初始化语法?这是有道理的,构造函数的职责就是构建对象并初始化它。
任何时候我们调用Point p, q, r;,都会调用签名为空的构造函数。当然,你必须编写那个默认构造函数,它们都会调用默认构造函数。你只能选择其中一种,不能编写所有三种,因为会产生歧义。它们会调用构造函数,即签名为空的构造函数。
多种初始化方式
仅仅拥有一个空构造函数并不能提供所有机会。拥有多种初始化对象的方式,多种方式,也是有用的。
让我们看看用这个签名初始化Point。这表示参数列表中有两个double类型。参数名将是x,或者说参数名是x。但x将被赋值给成员。所以这是参数x,而这是成员x。用于消除歧义。
否则,我可以说Point(double a, double b),那么我就不需要消除歧义,我可以说x = a; y = b;。因为我知道那是方法范围内唯一的东西,那么x就必须是自引用的x。但是一旦我有一个参数,该参数名优先于自引用名,消除歧义的方法是通过使用指针表示法和this指针。
因此,记住这一点非常重要,有时使用this指针是不可避免的。
总结
总结一下,this指针可以解决歧义,因为x = x不会起作用,它只会将参数x赋值给参数x,而自引用对象的x部分不会发生任何变化。但更好的方法是使用初始化语法。
Point(double x, double y) : x(x), y(y) {}
更好的原因是,我们没有相同的歧义问题。在初始化语法中,x和y是明确的。在初始化语法中,这必须是一个成员,而这必须是一个表达式,可以是参数。因此,这自动提供了消除歧义的功能,我们不必,可以避免使用this。它们在语义上是等价的。


本节课中,我们一起学习了如何为Point类编写构造函数,重点探讨了默认构造函数、带默认参数的构造函数以及初始化列表的语法。我们了解了this指针在消除歧义中的作用,并强调了初始化优于赋值的原因。掌握这些概念对于编写健壮且高效的C++代码至关重要。
023:更多构造函数

概述
在本节课中,我们将深入学习C++构造函数和析构函数的更多细节,特别是它们如何管理动态内存(堆内存)。我们将探讨构造函数的多重角色、new和delete操作符的使用,以及如何为包含动态内存的类编写正确的析构函数来避免内存泄漏。
构造函数的多重角色
上一节我们介绍了构造函数的基本概念。本节中我们来看看构造函数承担的几种重要功能。
构造函数非常重要。它们承担着多个功能。
初始化:构造函数负责初始化对象的状态。我们一直在讨论初始化。
分配内存:特别是当我们处理非常复杂的“部件”(widgets)时,那些需要从堆上获取内存的部件。我们将会看到,在这些更复杂的部件中会使用new。
类型转换:单参数构造函数提供了一种转换规则,本质上相当于一个静态类型转换(static_cast)。例如,如果有一个从int类型到Point类型的构造函数,它允许我将一个整数值转换为一个Point对象,或者将一个double值进行转换。因此,任何具有单参数签名的构造函数,只要其参数类型与正在构造的对象类型不同,在C++术语中,就同时提供了一种转换操作。
正确性检查:优秀的程序员在编写构造函数时,尤其是在调试阶段(尽管出于效率考虑可能最终会避免这样做),会加入正确性检查。这意味着构造函数可以确保在初始化状态、为成员赋值时,这些值代表的是合法且在其期望范围内的值。例如,如果你在处理液态水的实验,需要初始化其温度。我们知道,在华氏度下,液态水在32度到212度之间,低于此范围会结冰,高于此范围会沸腾。因此,如果我们给液态水部件分配一个超出此范围的温度,我们就知道进行了一次非法的初始化。在面向对象编程中,构造函数还提供了测试一致性、连贯性以及初始化值是否合法的可能性。
动态内存管理:从C到C++
你来自C语言社区。在C社区中,当你想要管理堆内存时(希望这对你来说是复习),你会使用malloc,有时是calloc。malloc是C标准库中的内存分配器。当你从malloc分配内存时,你从堆上获得一定数量的字节,这是操作系统提供给你的。你为什么要这样做?你可能在计算中需要动态的东西。使用calloc或malloc的典型原因是:我不知道要处理多少数据。我有一个很大的文件,文件的第一个条目告诉我文件中有多少数据。例如,它告诉我需要处理40万个社会保险号。那么我需要一个能存储40万个社会保险号的容器,这意味着我需要一个非常大的数组。因此,在计算过程中,通过调用malloc或calloc,我可以动态地从堆上分配内存,将其地址赋给一个指针(我们可以将其视为基指针),然后使用这个动态分配的对象。优秀的程序员不允许内存泄漏。所以当我不再需要那块内存时,我会释放它。在C语言中,释放的标准方式是使用free。
free、malloc和calloc在C++中已被取代。除非你使用非常古老的风格代码(你会有特殊理由这样做),否则它们被关键字new和delete所取代。new和delete关键字提供了内置在C++系统中的内存管理操作符,用于堆管理。这个堆是动态分配的内存,但与Java不同,它不会自动进行垃圾回收。因此你必须非常小心,相比其他具有自动垃圾回收机制的语言,你需要更复杂的编码。
它的缺点很明显:你可能会造成内存泄漏,并且必须进行更复杂的编码。它的优点是:你可以更高效。
new和delete的基本用法
以下是使用堆内存的一些简单例子。我们基本上有两种关键形式来使用new,以及与之对称的delete用法。
一种是获取一个项目数组,这将是常见情况。让我们看看这个例子:
char* text = new char[size];
我们有一个指针类型,其基指针是char*(指向字符的指针)。我想要一些动态分配的值。size可能来自计算的某个其他部分。例如,我可能有一个包含一百万个字符的文本,我需要处理这一百万个字符,size的值就是一百万。new会去堆上分配,给我一个地址,这个地址将成为这一百万个字符的基地址,并赋值给text。
如果我只想要该类型的单个元素并进行初始化,那么我可以这样做:
int* ip = new int(9);
在这种情况下,9就是存储的值。
所以,第一种是多个项目(数组),第二种是单个项目。相应地,如果我释放一个数组,我需要使用特殊的括号符号来释放数组;如果是单个元素,则不需要括号。
delete[] text; // 释放数组
delete ip; // 释放单个元素
相当简单。但需要记住的是,你必须确保一致地使用这两种形式。
析构函数的重要性
我们已经讨论了很多关于构造函数的内容。几乎任何类型的部件都需要某种构造过程,至少需要初始化。然而,析构则简单得多。通常,析构与“反初始化”无关,而主要与回收对象的内存有关。对象的内存可能是在栈上自动分配的,这种情况下我们可能不需要编写自己的析构函数。但是,任何时候我们使用new,我们都应该立即考虑是否需要析构函数。任何时候我们使用系统有限的资源(如堆内存),我们都希望调用析构函数。析构函数也有特殊的语法,其关键部分是波浪号~。
例如,如果我们的类是Point,那么析构函数就是~Point()。我们不能像重载构造函数那样重载析构函数的签名。我们可以有多个构造函数,但不能有多个析构函数。析构函数调用时不带参数,因此析构函数总是具有空的或void参数列表。然后你编写它的语义,这通常涉及某种形式的delete操作。
如果你忘记了析构函数会怎样?很多人会忘记。当我最初在20世纪80年代使用C++时,有一位来自其他大学的同事来做研究报告,他也决定使用C++(他之前用C),并且喜欢所有的新特性。他有一套非常复杂的例程,在整个过程中动态分配了大量内存。我当时还是个相对的新手,很担心内存泄漏,所以我问他:“你如何处理编写析构函数并确保一切正常工作?”他说:“哦,我从不担心析构函数,我不写它们。”如果你不担心内存泄漏,这也许可以。记住,内存泄漏通常不会导致你的程序功能失常。内存通常在进程结束时由操作系统重新分配,大多数系统(如Unix系统)都内置了此功能。然而,如果你使用大量内存并且频繁地向操作系统申请,很容易减慢你的操作速度。因此,如果你想成为一名高质量的C++程序员,你真的应该养成正确编写析构函数的习惯。这是一项非常重要且有用的技能。
示例:单链表的内存管理
让我们看一个内存分配很重要的部件例子。在我们定义Point类的方式中,内存分配并不重要。但这里有一个经典的、在本幻灯片和教材章节中出现的数据结构。如果你想参考教材,请查阅第168页的单链表元素部分。当然,你们在背景知识中都有过单链表的编程经验。
回顾一下单链表的情况。我们通常有一个头指针。我们通常把它画成像一列小火车的盒子,带有箭头,最后以一个我们称为空指针的东西结束。这是计算机科学中的经典动态可扩展数据结构之一——单链表。当然,你还会发现其他结构,例如,你也可以有双向链表,它提供了其他的导航可能性。当我们学习标准模板库(STL)时,会看到STL中的list确实是一个双向链表。
以下是默认构造函数:
SList::SList() : head(0) { }
默认构造函数让head指向0,这实际上就是空链表,没有“小车厢”可以指向。0就是空指针。
现在,如果我们开始构建一个链表,结果是我们需要调用析构函数来删除这些“车厢”。因此,析构函数会调用一个特殊的方法(你可以称之为辅助方法),这个辅助方法将遍历整个结构,在遍历过程中将每个元素释放回堆。
从概念上讲,希望你已经见过这种表示法,并且以前做过这种编码。
以下是标准的“前插”操作:
void SList::prepend(char c) {
SLink* new_link = new SLink(c);
new_link->next = head;
head = new_link;
}
“前插”做什么?它把一个元素放在链表的前端。这里的基础元素将是一个字符。我们首先构建一个新的SLink元素,这是从堆上分配的。当我们使用new操作符时,大部分情况下这会正常工作,但如果由于某种原因内存耗尽,将会抛出一个异常。在现代C++11中,这个异常将是一个标准异常bad_alloc。除非被处理,否则这将导致程序中止。所以你可以选择处理它或导致中止,通常这是相当安全的。
现在,为了执行前插,我们必须创建一个新的头节点,通过将新字符放入新链表的数据位置来更新头节点,然后我们更新head指针。如果我们有一个链表头head,它概念上指向存储了字符'a'的某个地方,然后这是空链表(空指针)。那么我们有一个单元素链表。现在,我们想要插入或前插字符'b'。我们最终会让head指向一个新的SLink元素,其中存储的数据对象是字符'b',而这个新元素指向了实际上是旧的链表(其中包含'a')。这就是前插。如果我画的小盒子正确,前插就会正确工作。我想我已经测试过这个,你可以测试它,看看它是否确实有效。
现在让我们看看析构函数:
SList::~SList() {
std::cout << "Destructor called" << std::endl;
clear();
}
好的,我们有一种特殊的命名析构函数的方式,使用~。~只用于析构函数。其他情况下我们使用类名。我们假设这是在类外部(例如在.cpp文件中)编写的,所以它使用了作用域解析运算符::。只是为了试验这些东西并了解其工作原理,我将在调试期间使用这个输出,这样我就知道析构函数何时被调用。如果你对这种情况如何发生缺乏良好的直觉,这是非常有用的。写出这个非常值得,这样当你运行程序时,你可以动态地看到析构函数何时被调用,这将被打印到你的屏幕上。然后你可以尝试弄清楚这将在何时被调用。之后还有一个我还没有展示如何编写的辅助函数,它应该遍历链表执行删除操作。
总结

本节课中我们一起学习了C++构造函数和析构函数的进阶知识。我们明确了构造函数的三个核心角色:初始化对象、分配动态内存以及提供隐式类型转换。我们对比了C语言的malloc/free与C++的new/delete在堆内存管理上的区别,强调了后者与构造/析构函数的集成性。通过单链表的实例,我们深入探讨了如何为管理动态内存的类编写构造函数和至关重要的析构函数,以防止内存泄漏。记住,对于使用了new的类,编写对应的delete逻辑的析构函数是高质量C++编程的基本要求。
024:使用迪杰斯特拉算法
概述
在本节课中,我们将学习图论算法的基础知识,特别是迪杰斯特拉算法。课程将涵盖图的基本概念、图的计算机表示方法,以及如何生成随机图。我们还将探讨图的连通性,并介绍一个用于检测图是否为单一连通分量的算法。
图论基础

上一节我们介绍了本课程将涉及的图论算法背景。本节中,我们来看看图的基本构成。
图由节点(或称为顶点)和连接节点的边组成。在日常生活中,地图就是一个常见的图例子,其中城市是节点,道路是边。
以下是一个不连通图的示例,它包含七个节点。由于节点之间没有路径相连,我们无法从任一节点到达其他所有节点。

现在,我们来看一个连通图。在相同节点的基础上,我们添加了边(蓝色线条)。边代表节点之间的连接,例如城市之间的道路。


在这个连通图中,任意两个节点之间都存在一条路径。例如,从节点“旧金山”到节点“圣地亚哥”就存在一条路径。因此,整个图是连通的,并且构成一个单一的连通分量。


图的计算机表示与生成
上一节我们了解了图的可视化表示。本节中,我们来看看如何在计算机中表示和生成图。
用计算机绘制图的核心是使用一种内部数据结构来表示图。我们将使用一个二维数组(矩阵)来存储图的连接信息。
以下是生成随机图的关键步骤:
- 确定图的大小:即节点的数量。
- 创建二维布尔数组:数组的每个元素
graph[i][j]表示节点i和节点j之间是否存在边。true表示有边,false表示无边。 - 根据密度插入边:密度是一个介于0和1之间的概率值,表示任意两个节点之间存在边的可能性。
生成随机图的代码逻辑如下:


// 假设 graph 是一个指向布尔类型二维数组的指针
// n 是节点数量
// density 是边存在的概率
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
if (i == j) {
graph[i][j] = false; // 对角线表示自身到自身,设为 false
} else {
// 根据密度概率随机决定是否添加边
graph[i][j] = (probability() < density);
// 如果是无向图,还需设置对称位置
graph[j][i] = graph[i][j];
}
}
}


密度对图连通性的影响

上一节我们介绍了如何用密度控制随机图的生成。本节中,我们来分析密度如何影响图的属性。
密度定义了图中边的稀疏程度。以下是几个关键问题:
- 密度为 0:意味着永远不会插入任何边。生成的图将没有边。
- 密度为 1:意味着总是插入所有可能的边。生成的图将成为完全图。
- 固定密度,增加图的大小:对于给定的密度(例如0.1),当图的节点数量增加时,图是连通的可能性会发生变化。
考虑以下情况:
- 对于一个只有 5个节点、密度为 0.1 的小图,每个节点平均只有0.5条边,图极有可能是不连通的。
- 对于一个有 50000个节点、密度仍为 0.1 的大图,每个节点平均有5000条边。图中存在大量路径,因此几乎可以肯定是连通的。

因此,在节点数量很大的图中,即使一个相对较小的密度值,也能以极高的概率保证图的连通性。这个直觉对于理解后续算法和完成作业非常重要。

总结

本节课中我们一起学习了图论算法的入门知识。我们首先了解了图的基本组成部分:节点和边,并区分了连通图与不连通图。接着,我们探讨了如何在计算机中使用二维布尔数组来表示图,并编写了根据给定密度生成随机图的逻辑。最后,我们分析了密度与图大小对图连通性的综合影响,认识到在大规模图中,较低的密度也足以保证高概率的连通性。这些概念是后续实现迪杰斯特拉算法等更复杂图算法的基础。
025:3-5 “is-connected”算法
在本节课中,我们将学习一个用于判断图(Graph)是否连通(connected)的算法。这个算法与著名的迪杰斯特拉(Dijkstra)最短路径算法有思想上的关联,但更为基础。我们将从算法的核心思想讲起,并探讨其实现方式。
算法背景与思想
上一节我们介绍了图的基本概念。本节中,我们来看看如何判断一个图是否连通。一个连通图意味着从任意一个节点出发,都存在路径可以到达图中的所有其他节点。
这个算法的思想可以追溯到广度优先搜索(Breadth-First Search)算法。其核心思路是:从一个起始节点开始,逐步探索所有可以到达的节点,并检查最终是否访问了图中的所有节点。

算法步骤详解
以下是判断图是否连通的核心算法步骤:
- 初始化:选择任意一个节点作为起始点(例如节点0)。创建两个集合:
- 开放集(Open Set):存放已发现但尚未探索的节点。初始时,只包含起始节点。
- 闭合集(Closed Set):存放已探索完毕的节点。初始时为空。
- 迭代探索:只要开放集不为空,就重复以下过程:
a. 从开放集中取出一个节点(例如编号最小的节点)。
b. 将该节点放入闭合集。
c. 检查该节点的所有邻接节点。对于每一个邻接节点,如果它既不在闭合集中,也不在开放集中,则将其加入开放集。 - 终止条件:算法在以下两种情况下终止:
- 成功(图连通):当闭合集中的节点数量等于图的总节点数时,说明所有节点都从起始点可达,图是连通的。
- 失败(图不连通):如果某次迭代后开放集为空,但闭合集中的节点数仍小于图的总节点数,说明存在无法从起始点到达的节点,图是不连通的。
基于邻接矩阵的实现
假设我们使用布尔型邻接矩阵 graph[][] 来表示图,size 表示图的节点总数。我们可以用布尔数组 closed[] 和 open[] 来分别表示闭合集和开放集。

算法的核心循环逻辑可以用以下伪代码描述:

// 初始化
open[0] = true; // 起始节点(例如0)放入开放集
int closed_size = 0; // 闭合集当前大小
// 主循环,最多迭代 size 次
for (int iteration = 0; iteration < size; ++iteration) {
// 1. 从开放集中找到一个节点 current(例如第一个为true的节点)
int current = -1;
for (int i = 0; i < size; ++i) {
if (open[i]) {
current = i;
break;
}
}
// 如果开放集已空,跳出循环
if (current == -1) {
break;
}
// 2. 将 current 移出开放集,加入闭合集
open[current] = false;
closed[current] = true;
closed_size++;
// 3. 将 current 的所有未访问邻接节点加入开放集
for (int j = 0; j < size; ++j) {
// 如果 current 到 j 有边,且 j 不在闭合集中
if (graph[current][j] && !closed[j]) {
open[j] = true; // 加入开放集(如果已在,则保持不变)
}
}
// 4. 检查是否已访问所有节点
if (closed_size == size) {
// 图是连通的
return true;
}
}
// 循环结束但未访问所有节点,图不连通
return false;
总结

本节课中,我们一起学习了一个基础的图遍历算法,用于判断图的连通性。我们理解了开放集(Open Set)和闭合集(Closed Set)这两个核心概念,并掌握了算法从起始节点开始、逐步扩散探索直至覆盖所有可达节点或无法继续前进的过程。这个算法是许多更复杂图算法(如最短路径)的基础。在接下来的课程中,我们将更深入地探讨如何具体实现这个算法并完成相关的作业。
026:复杂聚合列表
概述
在本节课中,我们将学习复杂聚合类型,重点探讨列表数据结构。列表是一种自引用的数据结构,其定义中包含对自身的引用。这种结构在某种程度上是无界的,可以持续添加元素,因此需要动态内存管理。通过学习列表的实现,我们将深入理解构造函数、析构函数以及堆内存管理,这些是C++高级主题的核心基础。
列表数据结构简介
列表是一种标准的数据结构,我们有时称之为自引用数据结构。列表的定义包含列表头部和指向另一个列表的指针,因此在定义中使用了自身。这种结构更有趣,因为它本质上是无界的,可以不断添加元素,这就需要能够访问动态分配的内存。
这种内存称为堆内存,与普通变量使用的栈内存相对。学习列表有多个好处。首先,我们今天要看到的单指针列表在标准模板库中已经存在。虽然我们不需要自己创建,但为了理解这些基于库的复杂聚合是如何构建、使用以及其局限性,自己实现一个简化版本将非常有帮助。其次,我们将看到更复杂的构造函数,这些构造函数使用堆进行内存分配。由于正确管理资源、避免内存泄漏至关重要,我们还将学习如何使用析构函数来正确释放资源。因此,本节课是后续高级主题的核心和基础。
使用结构体定义列表元素
在C语言中,结构体是定义聚合类型的传统方式。当Bjarne Stroustrup扩展C语言创建C++时,结构体演变为类。结构体默认所有成员都是公有的,因此这里我们没有使用public关键字,它是隐式的。

struct ListElement {
int data;
ListElement* next;
ListElement(int d = 0, ListElement* n = 0) : data(d), next(n) {}
};
在上面的代码中,我们定义了一个ListElement结构体。它包含一个整数data用于存储数据,以及一个ListElement*类型的指针next,用于指向下一个元素。构造函数有两个默认参数:整数d默认为0,指针n默认为0。这个构造函数允许使用多种签名,甚至可以作为默认构造函数使用。

理解空指针

在之前的列表元素构造函数中,指针被初始化为0。这个0值代表空指针。在现代C++实践中,更推荐使用nullptr关键字来代替0或NULL宏。
nullptr是对普通0的改进。0本质上是一个整数值,虽然可以转换,但历史上它被视为整数。将其用作其他类型的基类型需要进行转换。此外,还有一个名为NULL的宏,也常见于传统代码中。nullptr是对这两种旧做法的改进。
// 旧做法
ListElement* next = 0;
ListElement* next = NULL;

// 现代C++推荐做法
ListElement* next = nullptr;

使用nullptr可以提高类型安全性,避免一些潜在的转换错误。
总结

本节课我们一起学习了复杂聚合类型中的列表数据结构。我们介绍了列表的自引用特性,并使用结构体定义了一个简单的列表元素。我们重点讨论了构造函数中指针的初始化,并对比了使用0、NULL和nullptr表示空指针的区别,强调了现代C++中推荐使用nullptr的做法。理解这些基础概念是后续学习动态内存管理、标准模板库容器等高级主题的关键。
027:列表基础概念与操作


在本节课中,我们将学习链表(List)这一数据结构的基础概念。我们将了解链表的基本构成,包括头指针和游标,并学习如何在链表前端插入新元素。理解这些概念对于掌握更复杂的数据结构和算法至关重要。



链表的基本结构
上一节我们介绍了链表的重要性,本节中我们来看看链表的具体构成。
链表通常包含一个头指针,它指向链表的第一个元素。此外,链表还可能包含一个游标,用于指示当前在链表中的位置。游标可以帮助我们高效地进行遍历和搜索操作。
链表在概念上可以这样表示:
头指针 -> [数据 | 下一个指针] -> [数据 | 下一个指针] -> ... -> [数据 | nullptr]
有时,我们会用一个类似电气工程中的接地符号来表示链表的末尾(空指针)。
游标与迭代器
如果我们需要对链表进行遍历或搜索,通常会通过迭代或递归的方式“爬过”链表。在这个过程中,游标用来告诉我们当前的位置。
例如,假设我们想在一个存储朋友信息的链表中查找“John”。我们会从链表头部开始,逐个元素检查数据,直到找到“John”。此时,游标就指向了包含“John”数据的节点,我们可以停止搜索。之后,我们也可以从这个游标位置继续后续操作,而不是每次都从链表头部重新开始,这能提高操作效率。

游标的概念在C++标准模板库中被推广为迭代器。STL广泛使用迭代器来操作各种容器。例如,vector是STL中一个非常重要的容器类,它本质上替代了普通的C风格数组,并且其操作严重依赖于迭代器。我们将在后续课程中详细讨论迭代器。


链表类的基本方法
现在,我们来看看如何实现一个链表类,并为其定义一些基本的成员函数(方法),以实现链表的功能。


以下是链表类可能包含的一些核心方法:
- 默认构造函数:其签名为
void,用于创建一个空的游标(即空链表)。在较旧的代码中,可能使用0或NULL,但在新标准中,更推荐使用nullptr以获得更好的类型安全。 prepend(前端插入):这是一个标准操作,用于在链表头部插入一个新元素。get_element(获取元素):当游标指向某个元素时,此方法返回该元素的数据。数据可以是int等类型。在STL中,这通常通过模板实现,以便能容纳任意类型的数据。advance(前进):这是链表的基本操作。如果游标当前指向一个节点,此操作将游标移动到下一个节点。其核心逻辑是:cursor = cursor->next。print(打印):用于遍历并打印链表中的所有元素。

实现前端插入操作


让我们重点看看prepend操作的实现。理解这个操作的模式非常重要,因为许多涉及聚合数据结构的复杂操作都基于类似的思想。
实现prepend时,通常需要考虑两种情况:
- 链表为空的情况:这是最简单的情况。一个空链表意味着头指针指向
nullptr。我们可以通过测试head == nullptr来判断(在C++11及以后,使用nullptr更安全)。- 如果链表为空,我们需要创建链表。使用
new关键字在堆上分配一个新的链表元素,并初始化其数据值。 - 然后,将新元素的
next指针指向原来的头指针(此时是nullptr)。 - 接着,更新头指针,使其指向这个新创建的元素。
- 最后,将游标也设置到这个新元素上。
- 经过这些操作,一个空链表就变成了一个包含一个元素的链表。
- 如果链表为空,我们需要创建链表。使用

- 链表非空的情况:这是更一般的情况。假设已存在一个链表,并且游标可能指向链表中的任意位置。
- 我们同样使用
new创建一个新的链表元素,并初始化其数据值。 - 将这个新元素的
next指针指向当前的头指针(即原链表的第一个元素)。 - 然后,更新头指针,使其指向这个新元素。这样,新元素就被插入到了链表的最前端。
- 在这个过程中,游标保持不变,仍然指向它原来指向的节点。
- 我们同样使用


为了加深理解,请尝试自己画图分析:在一个已包含两个元素(例如值分别为7和3)的链表上,执行prepend(5)操作,每一步指针是如何变化的。


本节课中我们一起学习了链表的基础概念,包括其头指针和游标的构成,以及如何在链表前端插入新元素。我们还初步了解了游标与STL迭代器的联系。掌握这些基础是理解更高级数据结构和算法的关键。在接下来的课程中,我们将继续探索链表的其他操作和应用。
001:欢迎来到面向C程序员的C++课程B部分 🎬




在本节课中,我们将介绍《面向C程序员的C++》课程B部分的基本信息,包括课程目标、预期背景、学习方式以及最终项目。本节旨在帮助你了解课程全貌,为后续深入学习做好准备。

我是加州大学圣克鲁兹分校计算机科学系的Ira Pohl教授。



我将担任《面向C程序员的C++》课程B部分的讲师。



这门课程是为加州大学圣克鲁兹分校计算机科学专业的大二和大三学生设计的。


课程期望你已经具备相当于A部分课程的基础。A部分课程引导你从C程序员过渡到能够熟练运用C++,并让你掌握了一些图算法的专业知识。

本课程将继续深入。


进一步探讨C++的高级特性,特别是C++11及之后版本的标准。


C++11引入了许多非常有趣且高级的语言特性。

例如,Lambda表达式:
auto func = [](int x) { return x * x; };

以及元组库:
std::tuple<int, std::string, double> myTuple(1, "Hello", 3.14);

我们将在本课程中学习这些内容。如果你以认证课程的形式学习本课程,你将从中获得更大的收益,因为它允许你提交作业并获得证明你已完成学习材料的认证证书。如果你不参与认证,你仍然可以学习所有材料,包括视频讲座和论坛,但无法参与同伴互评或获得作业评分。


本课程的最终成果将是一个大型项目,你需要开发一个电脑游戏——Hex游戏。
Hex游戏是一个非常有趣的游戏,因为它至今尚未被完全破解,仍然在被玩家和计算机挑战。事实上,目前最好的玩家是计算机。我们将理解计算机玩好这个游戏的核心思想之一,即蒙特卡洛方法。


如果你坚持完成本课程,最终将完成这个项目。


你将不仅对现代C++、C++11及更新的库有很好的理解,还会对人工智能和游戏玩法中的一些前沿思想有所了解。
你将完成的作业将是编程作业,最终作业的目标是开发一个能够智能地进行Hex游戏的程序。

如果你参与了认证项目,你可以提交作业并进行同伴互评。同伴互评是MOOC社区开创的一项伟大举措,因为在MOOC世界中,我们依赖同学来完成在小班教学中通常由研究生助教完成的工作。

这是一件好事,因为在评审他人作品的过程中,你能理解其优缺点。事实上,如果你未来成为一名计算机科学专业人士,你会发现,在大多数标准良好的公司中,你都需要进行代码评审。因此,这甚至是你将从本课程中获得的一项相关技能。你的作业将被评审,你也会评审他人的作业。


MOOC的另一大益处是讨论论坛中汇聚的广泛专业知识。

我们发现我们的讨论论坛非常文明礼貌,我们希望保持这种氛围,这样新手不会受到不尊重,而经验丰富的学员也会尝试分享一些新颖或深奥的知识,帮助他人进步。C++语言中确实存在许多深奥的工具和类型,因此讨论论坛是我们课程中一个极具价值的方面。


最后,本课程参考了我的教材《C++ for C Programmers》。但你并非必须拥有它,其他更现代的教材也可以使用。事实上,你可以通过我们发布的大量补充材料以及互联网上关于最新库和技术的资料,很好地完成学习。

我希望你能参加B部分的学习。即使你没有学习A部分,如果你具备一定的C++基础并希望从更高级的级别开始,也可以直接跳入B部分。但对于大多数人来说,你可能应该同时学习A部分和B部分。




本节课中,我们一起学习了《面向C程序员的C++》课程B部分的概览。我们明确了课程面向有C++基础的学习者,将深入讲解C++11的高级特性(如Lambda表达式和元组),并通过一个开发Hex游戏的最终项目来实践蒙特卡洛方法等人工智能概念。课程提供了认证学习与自由学习两种路径,并强调了同伴互评和论坛讨论的重要性。接下来,我们将正式开启C++高级特性的探索之旅。
002:概述
在本节课中,我们将开始学习更高级的C++编程。我们将重点介绍C++11标准引入的新特性,并深入探索标准模板库(STL)的扩展功能。如果你已经完成了前四周的基础学习,掌握了标准C++编程的核心概念,那么你已经为接下来的内容做好了准备。
课程背景与演进
上一节我们回顾了C++的基础,本节中我们来看看C++语言的发展历程。C++由Bjarne Stroustrup博士发明,并于1985年首次发布。从1985年到1998年,语言增加了大量新内容,例如在1990年左右引入了异常处理和模板。随后,标准模板库(STL)被构建并纳入实践,最终整合到了1998年的标准中。我们之前的学习主要基于这个1998年的标准。
然而,C++11标准带来了许多使编程更高效的新特性。本课程将重点介绍这些新特性,并同时深入讲解标准模板库。新标准的许多工作正是为了使STL更高效、功能更强大。
已掌握知识与本课程目标
在课程的第一部分,你已经学会了:
- 基本的C++语法。
- 如何编写模板。
- 如何使用
iostream进行输入输出。 - 如何创建和使用抽象数据类型(例如图)。
- 一些图算法,如Prim或Kruskal的最小生成树算法,以及Dijkstra的最短路径算法。
如果你跟随教材学习,大致相当于完成了前六章的内容。
在课程的第二部分,我们将更深入地学习标准模板库。库是极大的便利,是优秀的代码复用工具,也是专业社区的实践结晶。如果库中已有你需要的功能,就不应该自己编写,这既能节省时间,也能避免不必要的错误。除非有至关重要的理由需要替换库的某些部分,否则库应该是你寻找代码的首选来源。
C++11核心新特性介绍
以下是本课程将涵盖的一些C++11核心新特性:
移动语义
这是一种新颖的概念,允许许多例程变得更加高效。它使得复杂聚合体的复制和赋值操作开销极低。当你使用STL中的vector、list、deque等复杂数据结构时,移动语义可以避免昂贵的复制成本对程序效率产生影响。
Lambda表达式
Lambda表达式是一种未命名函数的形式。它允许你在代码中任何地方“注入”并操作函数,为编程带来了极大的灵活性。
实践项目:Hex游戏
最后,我们将通过构建一个有趣的游戏——Hex,来综合运用所学知识。Hex游戏最初由一位丹麦数学家于20世纪40年代发明,后来由著名的诺贝尔奖得主、应用数学家约翰·纳什重新发现。这是一个组合游戏。
构建Hex游戏将充分利用我们对图论的理解,图论对于构建许多游戏都非常有用。随着项目的推进,我们将为游戏的电脑玩家添加人工智能(AI),使其成为一个智能对手。人工智能是我的研究领域,我将向你展示如何构建可以应用于几乎所有需要智能对手的游戏中的AI技术。

编译器支持说明
为了测试这些新特性,你需要一个支持C++11的编译器。我目前使用的是最新版本的GNU编译器(如g++ 4.8或更高版本)。实际上,许多新特性并非在最近一两年才突然加入,GNU编译器早在2003年左右就开始逐步支持其中的许多功能。你可以查阅GNU手册,了解如何通过编译标志启用这些特性。较新版本的Microsoft Visual C++编译器也支持大部分特性。
如果你的编译器暂时不完全支持C++11,也无需担心。我们所有的作业和学习内容都可以使用所谓的“传统”或“复古”C++(即1998年以来广泛可用的C++)来完成。当然,目前大多数编译器厂商都在尽快添加并测试这些新特性,因此你应该能够广泛获得支持。

本节课中,我们一起学习了C++11新标准课程的概述,回顾了C++的发展历史,明确了本课程的学习目标,并简要介绍了移动语义、Lambda表达式等核心新特性以及最终的Hex游戏实践项目。接下来,我们将逐步深入这些主题。
003:C++11新特性之枚举类 🆕
在本节课中,我们将要学习C++11引入的一个重要新特性:枚举类。我们将了解它如何改进传统的枚举类型,并带来更强的类型安全性。
什么是枚举类?

上一节我们介绍了C++11带来的新特性,本节中我们来看看其中一个具体特性:枚举类。枚举类是一种比传统普通枚举更好的枚举数据结构形式。
传统普通枚举可以用于枚举类能使用的几乎所有场景,但枚举类具有关键优势,特别是类型安全性。
枚举类的优势与示例
以下是枚举类的一个示例。假设我正在编写一个绘图程序,需要一个颜色调色板。
enum class Color {
red,
green,
blue
};
同时,由于某些原因,我还需要一个表示交通信号灯的类,信号灯有红、黄、绿三种状态。
enum class Stoplight {
red,
yellow,
green
};

可以看到,两个枚举类中都出现了名为 red 的枚举成员。如果使用旧式的简单枚举来定义它们,这些同名成员会发生冲突,导致编译器错误,因为如果它们处于同一个块或作用域中,就会产生歧义。
在C++11中,它们构成了独立的、不同的类型。我们使用作用域解析运算符 :: 来访问它们。
Color myColor = Color::red;
Stoplight myLight = Stoplight::red;
这两个枚举成员可以在同一作用域中使用,因为它们不会冲突,实际上是独立的类型。因此,你获得了相当程度的类型安全性,Stoplight 和 Color 是两种不同的东西。

枚举类的语法细节
接下来,我们看看更多的语法细节。通常,我们会这样编写枚举类。
enum class MyEnum {
value1, // 默认为0
value2, // 默认为1
value3 // 默认为2
};
此外,我还被允许指定一个底层整型类型。默认情况下,它和普通枚举一样是 int,但我可以将其指定为任何整型,包括 short、unsigned int、long 等。
enum class MyEnum : unsigned short {
value1,
value2,
value3
};
在枚举成员列表中,我们也可以为序列中的某些特定成员赋值。
enum class MyEnum : int {
value1 = 10,
value2, // 自动为11
value3 = 20
};

这种枚举类型风格显著地增强和泛化了枚举类的功能。

小测验
现在,我们来完成一个小测验。
定义一个枚举类,用于建模三个逻辑值:是、否、可能。你可以联想到模糊逻辑中的概念。其底层类型应为 unsigned int,并且要求“是”的值大于“可能”,“可能”的值大于“否”。由于某种原因,你作为模糊逻辑学家,希望“是”的值是“可能”的两倍。

请花一点时间思考。
以下是答案,我们遵循语法规则。
enum class FuzzyLogic : unsigned int {
no = 0, // 默认即为0
maybe = 1, // “可能”大于“否”
yes = 2 // “是”的值是“可能”的两倍
};

总结
本节课中我们一起学习了C++11的枚举类。如果你一直在编程中使用枚举,并且可以使用这个特性,建议你转向使用枚举类,这是更好的编程实践。枚举类通过提供独立的类型和作用域,有效避免了命名冲突,并增强了代码的类型安全性。
004:标准模板库概述 🏛️
在本节课中,我们将要学习C++标准模板库(STL)的核心概念和基本结构。STL是C++标准库中一个功能强大且重要的组成部分,它提供了一系列可复用的通用组件,极大地简化了编程工作。
标准模板库的三条腿 🪑
标准模板库的结构可以类比为一个三条腿的凳子,它由三个核心部分组成。这三个部分是容器、迭代器和算法。它们相互独立又紧密协作,共同构成了STL的基石。
容器 📦
容器是用于存储和管理数据的对象。它们主要分为两大类。
以下是两种主要的容器类型:
- 序列容器:这类容器中的元素有明确的顺序。你可以说容器中“有一个元素”。例如
vector和list。 - 关联容器:这类容器不强调元素的顺序,而是建立了一种查找关系。你可以通过一个键(如名字)来查找对应的值。例如
map和set。

迭代器 👉
迭代器是STL中的导航工具。它们类似于指针或游标,为算法访问容器中的元素提供了统一的方法。
以下是迭代器的核心概念:
- 它们是指针或地址的概念性抽象。
- 它们是遍历容器元素的导航工具。
算法 ⚙️
算法是作用于容器上的一系列操作函数。STL提供了大量现成的、高效的通用算法。
由于STL是一个模块化的库,算法也进行了分类。以下是主要的算法类别:
- 数值算法:例如
accumulate(累加)。 - 排序相关算法:例如
sort(排序)和merge(合并)。 - 非修改性算法:这类算法在应用于容器时,不会改变容器本身的内容,例如
find(查找)。 - 修改性算法:这类算法会改变容器的内容,例如
random_shuffle(随机打乱)或copy(复制)。
上一节我们介绍了STL的三大核心组件,本节中我们来看看C++11/14标准为STL带来的一些重要新特性。
STL的新增内容 🆕
接下来的课程中,我会讨论这些新内容。其中一些非常复杂,在这个基础课程中我们无法深入探讨所有细节,但通过提供一个路线图,当你需要时,可以知道如何去查找和使用。STL库非常庞大,有大量的文档,关键在于理解其基本概念,然后根据需求去查阅和使用。
以下是基于现有实践被纳入C++标准的一些新内容:
- 正则表达式:正则表达式在Unix和许多语言(如Perl)中已存在很久。现在通过标准库,
regex允许你检查字符串是否符合特定的语法模式。 - 多线程:我们处于多核和多处理器的时代。为了更高效地并行化程序,C++现在提供了一个基础的线程库。当然,这不是并发编程涉及的唯一库,例如还有支持原子操作的库,但本课程无法详细展开。
- 无序容器:
unordered_map和unordered_set被添加到STL中。普通的STLmap基于红黑树实现,其基本操作通常需要 O(log N) 的时间复杂度。而一个优秀的哈希表可以实现类似的关联数组(映射)功能,但能在 O(1) 的常数时间内完成,效率更高。虽然哈希库早已被广泛使用,但现在它们已被标准化。 - 数组容器:回想一下我经常提到的“90%规则”,即你最应该习惯使用的标准模板类是
vector。但在某些情况下,可以特化使用现在称为array的容器。vector可以动态增长大小,而array的大小是固定的。不过,array和vector之间有很多共同点,非常相似。但array的定义中包含了一个固定的大小。 - 单向链表:STL中原始的
list是一个双向链表,可以向前或向后遍历,但这需要额外的存储空间。因此,新增了一个纯粹的单向链表forward_list。
STL中还存在大量其他内容,我们将在示例中看到其中一些,并做进一步描述。

了解了STL的现代扩展后,让我们联系一下C语言的基础,看看其中的对应概念。

与C语言的对比 🤔
思考一下你的C语言经验。你们都是经验丰富的C程序员,这正是你们参加本课程的原因。
在C语言中,什么是容器?什么是迭代器?什么是标准算法?这里还有一个更高级的问题:在C语言中如何实现泛型编程?或者,可以实现吗?
以下是这些问题的答案:
- C语言中的容器:是标准的数组。C语言中的数组本质上是指针。
- C语言中的迭代器:是指针。操纵或遍历数组的方式就是使用指针声明,因此指针就是游标。
- C语言中的标准算法:有一系列标准的C算法,我们甚至C++社区也一直在使用它们。例如,你可以使用C库中的
sqrt函数求平方根,或者使用伪随机数生成器rand来产生随机整数。 - C语言中的泛型编程:这属于更高级的问题。C语言中有特殊的指针类型
void*,它可以指向任何类型的数据。如果你足够小心并且真正懂得如何编写高级C代码,你可以用它来实现类似泛型memcpy的功能。标准库中确实有memcpy函数,它将内存中的一块数据复制到另一块,这都可以通过void*来完成,基本上实现了一种泛型的复制。

本节课中我们一起学习了C++标准模板库的核心架构,包括容器、迭代器和算法这“三条腿”。我们还简要了解了STL在现代C++标准中的一些重要扩展,如无序容器、多线程支持和正则表达式。最后,我们通过对比C语言中的类似概念,加深了对STL抽象和便利性的理解。掌握这些基本概念是有效使用STL强大功能的第一步。
005:迭代器类别
概述
在本节课中,我们将要学习C++中迭代器的不同类别。我们已经知道迭代器是访问容器元素的重要工具,但并非所有迭代器都拥有相同的能力。理解迭代器的类别对于高效地使用标准模板库(STL)算法和编写STL风格的代码至关重要。我们将从最基础的类别开始,逐步介绍更强大的迭代器类型,并了解它们各自适用的场景。
迭代器类别概览
C++中的迭代器分为五个主要类别。我们已经接触过输入和输出迭代器,它们是能力最弱的类别。
输入和输出迭代器的特点是它们通常只支持单次遍历算法。一个典型的单次遍历算法是find。例如,如果我想在一个vector中查找特定的值(比如6),我会从容器起始处开始,顺序搜索直到末尾,一旦找到6就可以停止。这个过程只遍历容器一次。

接下来,我们将介绍三种更强大、功能更丰富的迭代器类别。从最弱到最强,它们分别是:输入迭代器、输出迭代器、前向迭代器、双向迭代器和随机访问迭代器。更强的迭代器意味着能支持更复杂的操作。

前向迭代器
上一节我们介绍了最基础的迭代器类别,本节中我们来看看前向迭代器。
前向迭代器比输入/输出迭代器更强大,它支持多次遍历算法。这意味着我们可以反复遍历同一个容器。顾名思义,前向迭代器允许我们沿着序列或容器向前移动。
你可以把迭代器想象成在埃舍尔阶梯上行走。当然,埃舍尔阶梯是一个基于视觉错觉的无限循环阶梯。埃舍尔以其自指性和有趣的物理描绘而闻名,是一位杰出的画家。这形象地说明了迭代器在序列中移动的概念。
以下是前向迭代器支持的核心操作:
- 递增操作符 (
++): 用于将迭代器向前推进一个位置。在代码中通常表现为it++,表示获取下一个元素。 - 解引用操作符 (
*): 用于访问迭代器当前指向的元素。 - 相等/不等比较 (
==,!=): 用于比较两个迭代器是否指向相同位置。 - 拷贝构造: 可以复制迭代器。
一个使用前向迭代器的经典算法是STL中的replace算法。理解这个算法很重要,因为我们不会详细描述STL库中的所有内容——那更像是参考书(如超过1300页的《C++ Primer》第4版)的任务。我们只需要记住最常用的部分,并学会查阅和理解文档。
在这个幻灯片中,我们看到replace算法的一种形式。它有四个参数,遵循STL处理顺序容器的典型模式:我们使用first和last迭代器来指定一个范围,以便向前遍历容器。然后,算法参数指定我们要将范围内所有等于x的值替换为y。
这个算法的时间复杂度是线性的,即与容器的大小N成正比。它只需要前向迭代器,因为我们不需要随机访问元素或向后移动。这是一个非常传统且高效的算法,也代表了STL中一整类算法的典型特征。
理解支持某个算法的最优迭代器类别强度至关重要。这不仅有助于你理解STL,也能指导你编写类似STL风格的代码。专业的C++开发者若想添加STL中尚未包含但风格一致的新功能,也必须遵循STL的设计理念。
通常,你会看到使用迭代器范围[first, last),在大多数情况下,你可以使用begin()和end()来获取这些值。在这个例子中,有两个通过引用传递的常量值x和y。使用引用是为了保证函数调用的高效性。该算法将在指定范围内将所有出现的x替换为y,因此它也被称为可变序列算法。这是前向迭代器的一个典型用例:你需要向前遍历,并且能够进行这种替换操作,而不仅仅是扫描。
前向迭代器示例
以下是运用这些概念的一个代码示例。
首先,我需要包含必要的头文件。我不会一股脑地包含所有库,那样会使代码过于臃肿,我只选择将要使用的部分。在这个例子中,我需要使用一些流、特定的迭代器和向量。
#include <iostream>
#include <vector>
#include <iterator>
// 一个使用前向迭代器的算法
template <typename ForwardIterator>
void square(ForwardIterator first, ForwardIterator last) {
while (first != last) {
*first = (*first) * (*first); // 解引用并计算平方
++first; // 递增迭代器
}
}
int main() {
std::vector<int> w = {3, 4, 7};
// 使用我们的 square 算法
square(w.begin(), w.end());
// 使用 C++11 的范围 for 循环打印结果
for (auto i : w) {
std::cout << i << '\t';
}
std::cout << '\n';
return 0;
}
我定义了一个使用前向迭代器的算法square。它有一个while循环:只要first不等于last,我就对first指向的元素进行操作,然后递增first。这是惯用的写法。
在循环体内,我解引用first指向的元素,计算其平方(*first * *first),然后将结果赋值回该元素。这样,容器中的每个元素都被原地修改为其平方值。
例如,如果原始元素是3、4和7,执行后它们将变为9、16和49。
接着,对于还没见过这种写法的读者,这里使用了C++11的基于范围的for循环。它使用了新的auto关键字,声明是:无论w是什么类型,都为i提供一个合适的类型。因此,如果w是一个vector<int>,那么i就是int。:表示这个循环将遍历容器w的范围。它本质上会从w.begin()迭代到w.end()。
在每次迭代中,它会执行循环体内的操作:打印出i的值和一个制表符。因此,它会依次打印出9、16和49,中间用制表符分隔。
更强的迭代器类别
我们刚刚详细探讨了前向迭代器。除了前向迭代器,还有更强大的类别:
- 双向迭代器: 在拥有前向迭代器所有能力的基础上,还支持向后移动(使用
--操作符)。 - 随机访问迭代器: 这是功能最强大的迭代器。它支持在容器中任意位置进行常数时间的跳转。这通常能实现非常高效的算法,因为可以避免顺序搜索(对于N个元素,顺序搜索平均需要O(N)次计算)。随机访问允许我们像使用数组索引一样直接访问元素。
总结

本节课中,我们一起学习了C++迭代器的五大类别。我们从最基础的单次遍历的输入/输出迭代器开始,重点介绍了支持多次遍历的前向迭代器,了解了其核心操作(如++、*)和典型应用(如replace算法)。我们还通过一个具体的代码示例,展示了如何编写和使用基于前向迭代器的算法。最后,我们简要提及了更强大的双向迭代器和随机访问迭代器。理解这些迭代器类别的能力和适用场景,是有效使用STL和编写高效、现代C++代码的关键。
006:示例-扑克牌概率 🃏
在本节课中,我们将学习如何运用C++中的抽象数据类型和STL库来构建一个实用的示例:计算五张牌梭哈游戏中特定牌型(如顺子和同花)出现的概率。我们将通过蒙特卡洛模拟来近似这些概率,这对于初学者理解概率计算和C++编程实践非常有帮助。

构建扑克牌抽象数据类型 🎴

上一节我们介绍了课程背景,本节中我们来看看如何表示扑克牌。为了进行概率计算,我们首先需要创建代表扑克牌和扑克牌组的抽象数据类型。
一张扑克牌包含两个属性:点数和花色。点数可以是A、2-10、J、Q、K,花色则是黑桃、红心、方块、梅花。
我们使用C++11的枚举类来定义花色,这样可以指定底层数据类型以节省内存。

enum class Suit : short { SPADE, HEART, DIAMOND, CLUB };
点数也类似定义,其中A为1,J为11,Q为12,K为13。
enum class Pips : int {
ACE = 1, TWO, THREE, FOUR, FIVE, SIX, SEVEN,
EIGHT, NINE, TEN, JACK, QUEEN, KING
};
基于以上枚举,我们可以构建Card类。这个类包含一个构造函数和两个访问器函数来获取牌的花色和点数。
class Card {
public:
Card(Suit s, Pips v) : suit(s), pips(v) {}
Suit getSuit() const { return suit; }
Pips getPips() const { return pips; }
// ... 其他成员函数,如重载输出运算符
private:
Suit suit;
Pips pips;
};
注意,访问器函数getSuit和getPips被声明为const,表明它们不会修改对象状态。同时,我们通常会为自定义类型重载输出运算符<<,以便于打印。
创建与操作牌组 🃏
有了单张牌的表示,接下来我们创建一副完整的牌组。一副标准扑克牌有52张牌,即13种点数与4种花色的所有组合。
以下是初始化牌组的代码思路:我们使用一个std::vector<Card>来存储牌组,并通过嵌套循环生成所有牌。

std::vector<Card> deck;
for (int s = 0; s < 4; ++s) { // 遍历四种花色
for (int v = 1; v <= 13; ++v) { // 遍历13种点数
deck.emplace_back(static_cast<Suit>(s), static_cast<Pips>(v));
}
}
为了进行模拟,我们需要洗牌。C++ STL提供了std::shuffle函数,配合随机数引擎可以轻松实现。
std::random_device rd;
std::mt19937 g(rd());
std::shuffle(deck.begin(), deck.end(), g);
打印牌组时,我们可以使用C++11的范围for循环,让代码更简洁。
for (const auto& card : deck) {
std::cout << card << ' ';
}

判断牌型:同花与顺子 🎯

现在我们已经有了牌组,接下来需要编写函数来判断一手牌(5张牌)是否是特定的牌型。我们先从同花开始。
一手牌成为同花的条件是:五张牌的花色全部相同。判断逻辑是:获取第一张牌的花色,然后检查剩余四张牌的花色是否与之相同。
bool isFlush(const std::vector<Card>& hand) {
Suit firstSuit = hand[0].getSuit();
for (size_t i = 1; i < hand.size(); ++i) {
if (hand[i].getSuit() != firstSuit) {
return false;
}
}
return true;
}
接下来判断顺子。顺子的条件是:五张牌的点数连续。这里有一个复杂情况:A既可以作为高点(10-J-Q-K-A),也可以作为低点(A-2-3-4-5)。
判断逻辑是:首先将手牌按点数排序,然后检查排序后的点数是否连续递增(差值均为1)。同时需要单独处理A作为高点和低点的两种特殊情况。
bool isStraight(std::vector<Card> hand) {
// 1. 按点数排序
std::sort(hand.begin(), hand.end(),
[](const Card& a, const Card& b) { return a.getPips() < b.getPips(); });
// 2. 检查普通连续情况 (如 4-5-6-7-8)
bool normalStraight = true;
for (size_t i = 1; i < hand.size(); ++i) {
if (static_cast<int>(hand[i].getPips()) != static_cast<int>(hand[i-1].getPips()) + 1) {
normalStraight = false;
break;
}
}
if (normalStraight) return true;
// 3. 检查特殊顺子:A作为高点(10-J-Q-K-A)或低点(A-2-3-4-5)
// ... (此处省略具体特殊逻辑代码)
}
有了判断同花和顺子的函数,判断同花顺就非常简单了:一手牌必须同时满足既是顺子又是同花。
bool isStraightFlush(const std::vector<Card>& hand) {
return isFlush(hand) && isStraight(hand);
}


代码的复用性在这里得到了很好的体现。

实施蒙特卡洛模拟 🔢
理论概率计算可能很复杂,但我们可以通过蒙特卡洛模拟来近似估算概率。其核心思想是:通过大量随机实验(这里指发牌),统计特定事件(如出现同花顺)发生的频率,以此作为概率的估计。

以下是模拟的核心步骤:
- 初始化一副牌。
- 询问用户希望模拟的次数(例如100万次)。
- 循环执行以下操作:
- 洗牌。
- 从牌堆顶部抽取5张牌作为一手牌。
- 使用
isFlush、isStraight、isStraightFlush函数判断牌型,并相应增加计数器。
- 循环结束后,用计数器除以总模拟次数,得到各牌型出现的近似概率。

int main() {
// 初始化牌组
std::vector<Card> deck = initializeDeck();
long long numSimulations;
std::cout << "输入模拟次数: ";
std::cin >> numSimulations;
int flushCount = 0, straightCount = 0, straightFlushCount = 0;
std::random_device rd;
std::mt19937 g(rd());
for (long long i = 0; i < numSimulations; ++i) {
std::shuffle(deck.begin(), deck.end(), g);
// 取前5张牌作为一手牌
std::vector<Card> hand(deck.begin(), deck.begin() + 5);
if (isFlush(hand)) flushCount++;
if (isStraight(hand)) straightCount++;
if (isStraightFlush(hand)) straightFlushCount++;
}
// 输出概率结果
std::cout << "同花概率: ~" << (double)flushCount / numSimulations << std::endl;
std::cout << "顺子概率: ~" << (double)straightCount / numSimulations << std::endl;
std::cout << "同花顺概率: ~" << (double)straightFlushCount / numSimulations << std::endl;
return 0;
}
通过运行足够多次数的模拟(如100万次),我们可以得到与理论值接近的结果:
- 同花的概率大约为 1/500。
- 同花顺的概率大约为 1/70,000。
扩展练习 💡
为了加深理解,你可以尝试以下扩展练习:
- 实现其他牌型判断:例如,编写
isFourOfAKind函数来判断一手牌是否为四条(四张相同点数的牌)。 - 扩展到七张牌游戏:在德州扑克等七张牌游戏中,玩家需要从7张牌中选出最好的5张牌组成最终手牌。修改你的模拟程序,计算在七张牌中形成同花顺的概率。这需要你从7张牌的所有5张牌组合中,找出可能的最佳牌型。

本节课中我们一起学习了如何利用C++的抽象数据类型和STL容器来构建一个扑克牌模拟程序。我们定义了Card类,实现了同花、顺子等牌型的判断逻辑,并最终通过蒙特卡洛模拟估算出了这些牌型在五张牌梭哈中出现的罕见概率。这个项目综合运用了类设计、算法逻辑和概率统计,是一个很好的编程实践。
007:双向迭代器

在本节课中,我们将要学习一种更复杂、功能更强的迭代器类型——双向迭代器。我们将了解它的特性、一个需要用到它的标准库算法,以及如何利用它高效地解决问题。
双向迭代器的定义
上一节我们介绍了前向迭代器,本节中我们来看看双向迭代器。双向迭代器类型必须同时支持前向迭代器的操作(如 ++)和反向迭代器的操作(如 --)。
核心操作:
++:向前移动迭代器。--:向后移动迭代器。
需要双向迭代器的算法

接下来,我们研究一个标准库中的典型算法,它要求使用双向迭代器类型。在阅读库文档时,你会看到参数类型标注为 bidirectional_iterator,这表明它确实需要这种类型的迭代器。

这个算法就是 std::reverse。它接受一个迭代器范围 [first, last)(通常是 begin() 和 end(),也可以是范围的子部分)。reverse 是一个修改算法,它会反转指定范围内的元素顺序。
例如,如果我们有一个序列 [1, 4, -3, 6],执行 reverse 后,序列将变为 [6, -3, 4, 1]。
算法效率与迭代器选择
STL 算法设计的一个核心逻辑是:选择能满足算法最高效实现所需的最弱迭代器类型。效率是首要考虑因素。
对于 reverse 算法,我们可以通过同时从两端向中间遍历并交换元素,在一次遍历内完成反转。这需要迭代器既能前进也能后退。
以下是该算法的思路:
我们同时维护两个“光标”:一个从前端(first)开始,一个从后端(last)开始。进行交换,然后前端光标前进,后端光标后退,继续交换,直到两者在中间相遇。
算法步骤:
- 初始化
front = first,back = last - 1(因为last指向末尾之后)。 - 当
front < back时:- 交换
*front和*back指向的元素。 front向前移动 (++front)。back向后移动 (--back)。
- 交换
- 当
front与back相遇或交错时,反转完成。
应用:判断回文
利用双向迭代器的特性,我们可以高效地编写一个判断序列是否为回文的算法。回文是指正读和反读都一样的序列,例如 “auto” 或 “Dalmatian na it am lad”。
以下是使用双向迭代器的 is_palindrome 算法实现思路:
template <typename BidirectionalIterator>
bool is_palindrome(BidirectionalIterator first, BidirectionalIterator last) {
if (first == last) return true; // 空序列是回文
--last; // 将 last 移动到最后一个元素
while (first != last) {
if (*first != *last) {
return false; // 不对称,不是回文
}
++first;
if (first == last) { // 在中间相遇,是回文
break;
}
--last;
}
return true;
}
算法逻辑:
- 算法从两端向中间移动,测试每个对称位置的值。
- 如果发现任何一对对称值不相同,则立即返回
false。 - 如果迭代器在中间相遇,说明所有对称值都相同,则返回
true。

为何选择双向迭代器?
你可能会想,是否能用更弱的前向迭代器实现这个算法?答案是肯定的,但效率会很低。
如果只使用前向迭代器,你需要多次遍历:先移动一个迭代器到末尾,进行比较,然后重置,再移动到第二个位置,再移动另一个迭代器到倒数第二个位置……这将导致时间复杂度变为 O(n²) 的计算。
我们也可以使用更强的随机访问迭代器来实现,但这属于“杀鸡用牛刀”,我们并不需要随机跳转的能力。
因此,再次体现了 STL 的设计原则:为实现最高效的算法,选择所需功能最弱的迭代器。对于回文判断和反转算法,双向迭代器就是那个“恰到好处”的选择。
总结

本节课中我们一起学习了双向迭代器。我们了解到它扩展了前向迭代器的能力,支持向前 (++) 和向后 (--) 移动。我们分析了需要双向迭代器的 std::reverse 算法,并学习了如何利用双向遍历的特性,高效地实现序列反转和回文判断算法。最重要的是,我们理解了 STL 算法设计中关于迭代器选择的核心原则:在保证算法最高效率的前提下,使用功能最弱的迭代器类型。
008:随机访问迭代器 🎯
在本节课中,我们将要学习C++标准模板库(STL)中最强大的迭代器类别——随机访问迭代器。我们将了解它的核心概念、典型应用场景以及它与其他迭代器的区别。
随机访问迭代器的核心概念
上一节我们介绍了迭代器的不同类别,本节中我们来看看功能最强大的随机访问迭代器。
随机访问迭代器的设计灵感来源于索引数组。在索引数组中,我们可以通过简单的地址计算直接访问任何元素。例如,对于一个包含37,000个元素的数组,我们可以通过以下公式计算任意元素的地址:
地址 = 基指针 + (元素大小 × 索引)

假设每个元素占用4字节,那么访问第i个元素的地址计算公式为:
基指针 + 4 × i
这种计算是固定时间的,意味着我们可以进行通用的地址算术运算。为了在vector、array或deque等容器中实现类似索引数组的功能,迭代器必须支持随机访问。
排序算法与随机访问
随机访问迭代器的典型应用是排序算法,特别是STL中的sort算法。
sort算法基于快速排序实现,速度极快。你可以将其与自己手写的快速排序版本进行比较。快速排序也称为分区交换排序。其工作原理如下:
- 从容器中选择一个基准值(例如3)
- 将容器分为两部分:小于基准值的元素和大于等于基准值的元素
- 基准值找到其正确位置
- 递归地对两个子分区重复此过程
分区交换过程中,元素需要在容器中移动位置,这要求迭代器支持随机访问,以便高效地进行元素交换。
随机访问示例:随机选择元素

以下是使用随机访问迭代器从范围内随机选择一个元素的简单示例:
// 假设first和last是随机访问迭代器
auto temp = last - first; // 计算范围大小
int random_index = rand() % temp; // 生成随机索引
auto random_element = *(first + random_index); // 访问随机元素
这段代码展示了随机访问迭代器的关键特性:支持迭代器的减法运算(计算范围)和加法运算(通过索引访问元素)。
指针差类型:ptrdiff_t

在C标准库中,ptrdiff_t是一个已定义的类型,它表示两个指针相减的结果。
需要注意的是,指针差不是简单的整数差。它考虑了底层类型的大小。例如,两个long double类型指针的差可能本质上是8字节的差异,而不仅仅是1、2或3。
ptrdiff_t让我们能够考虑类型的大小因素。以下是使用它打印范围内随机元素的示例:
ptrdiff_t range = end - begin;
ptrdiff_t random_offset = rand() % range;
cout << *(begin + random_offset);
总结

本节课中我们一起学习了随机访问迭代器的核心概念和应用。我们了解到:
- 随机访问迭代器支持类似数组索引的直接元素访问
- 它通过地址算术实现高效的元素定位
sort等算法依赖随机访问迭代器实现高效操作- 指针差类型
ptrdiff_t用于正确计算迭代器之间的距离
随机访问迭代器是STL中功能最全面的迭代器类别,为vector、array和deque等容器提供了高效的随机访问能力。
009:STL容器概述 🗂️
在本节课中,我们将要学习C++标准模板库(STL)的核心组成部分之一:容器。我们将了解STL的设计哲学、容器的基本分类以及它们共有的接口特性。
概述
C++自1985年诞生以来,经历了数次重大革新。最初的创新是将C语言转变为面向对象语言,引入了“类”和“数据隐藏”的概念。大约在1989年,Bjarne Stroustrup引入了模板,这是泛型编程的基础。基于模板,标准模板库(STL)得以创建,并于1994年被采纳为标准。这极大地扩展了C++程序员可用的工具集。2011年,该库再次得到重大扩展,C++语言本身也引入了许多新特性。
STL的设计像一个三脚凳,其三个支柱分别是:
- 迭代器
- 容器
- 算法
对于用户而言,算法是最终要使用的关键部分。STL遵循几个重要原则:高效性、参数化(通过模板实现)和正交性(设计简洁而全面,覆盖所有可能性)。
容器分类
STL的核心元素之一是标准容器。它们为使用STL代码提供了大量机会。根据“90%规则”,学习vector将带来最大收益,因为它是使用最广泛的容器。
容器主要分为两大类:
以下是序列容器的例子:
- vector:动态数组,支持高效随机访问。
- deque:双端队列,支持首尾高效插入/删除。
- list:双向链表。
- forward_list (C++11):单向链表,只能向前遍历。

以下是关联容器的例子:
- set / multiset:有序集合,基于键值存储,键即值。
- map / multimap:有序映射,存储键值对。
- unordered_set / unordered_multiset (C++11):基于哈希表的无序集合。
- unordered_map / unordered_multimap (C++11):基于哈希表的无序映射。
C++11极大地扩展了标准库,新增了如单向链表(forward_list)和基于哈希的容器(如 unordered_map)。
容器通用接口
一个容器类要变得有用,需要提供一系列通用接口。了解这些接口有助于你查阅手册并学习如何使用各种容器。

以下是典型的容器接口成员:
- 构造函数:包括默认构造函数、拷贝构造函数以及指定初始值的构造函数。C++11还引入了移动构造函数。
- 元素访问:方式因容器而异。例如,映射(
map)通过键访问,向量(vector)支持随机访问。 - 元素插入:如
insert方法。C++11新增了emplace方法,用于原地构造元素。 - 元素删除:如
erase方法。 - 迭代器:用于遍历容器中的元素。
容器的共同属性
尽管容器种类不同,但它们拥有许多共同的属性集,这使得学习曲线变得平缓。
以下是容器的一些常见属性:
- 序列容器通常提供访问第
i个元素的能力。 - 随机访问容器支持像随机访问排序这样的操作。
- 所有容器都提供构造函数、析构函数和分配器,方便在堆上动态分配内存。
- 它们都提供了特定的访问和删除元素的方法。

学习 vector 背后的思想,能帮助你理解 deque 的许多特性;学习 set 的思想,也能帮助你掌握 map 的许多用法。
总结

本节课中,我们一起学习了C++标准模板库(STL)中容器的基本概念。我们了解了STL以高效、参数化、正交性为核心的设计哲学。我们探讨了容器的两大主要分类:序列容器(如 vector, list)和关联容器(如 set, map),并注意到C++11引入的新容器类型。最后,我们介绍了容器类共有的通用接口和属性,这些是有效使用任何STL容器的基础。掌握这些核心概念,将为后续深入学习迭代器和算法打下坚实的基础。
010:关联容器
在本节课中,我们将要学习C++标准模板库(STL)中的关联容器。我们将了解它们与序列容器的区别,并重点介绍两种关键的关联容器:map和unordered_map。我们将探讨它们背后的数据结构原理、性能特点以及基本使用方法。
序列容器回顾
上一节我们介绍了序列容器。序列容器的原型可以看作是数组。在C语言中,数组就是最基础的序列容器原型,功能有限但至今仍在广泛使用。
实际上,STL中现在有一个新的容器类,它比vector更直接地模拟了数组。这就是C++11引入的array类,它允许你指定固定的大小,并将这个大小直接绑定到类型上。
你可能会想,既然有了可以动态扩展的vector,为什么还需要固定大小的array?这是因为vector的动态扩展能力会带来一定的性能开销。而一个不允许扩展的array可以针对硬件架构进行更精细的优化,从而获得更高的效率。
关联容器简介
本节中我们来看看基于键值访问的关联容器,这是我们之前尚未深入探讨的内容。在基于键值的访问中,存在一种排序关系。
与序列容器按插入顺序排列不同,关联容器没有“第零个元素”、“第一个元素”的概念。相反,你提供了一种映射关系,例如:
- 姓名到社保号
- 姓名到电话号码
- 书籍到ISBN号
这种关系不一定总是数字,也可以是姓名到家庭住址。这是一种基于键来查找数据的映射关系。
在这种数据结构中,元素的位置内在地取决于某种比较操作,我们将在set和map中看到这一点。

底层数据结构与性能
为了实现高效操作,关联容器底层通常使用平衡树数据结构。这是一个非常有趣的计算机科学主题,我们在此不深入探讨。常见的平衡树类型包括红黑树和AVL树。
平衡树的特性使得插入和删除操作的时间复杂度为O(log n),查找操作也因此是O(log n)。相比之下,如果使用不平衡的普通树,在最坏情况下,查找时间可能会大得多。平衡树强制所有主要操作都保持在对数级别,这是一个巨大的性能优势。
哈希与无序容器
C++11新增了基于哈希的map和set实现,它们被称为unordered_map和unordered_set。在许多情况下,这些无序容器将比普通的map和set更具优势。
当然,仍然存在需要使用普通map和set的场景。但在大多数情况下,无序容器有一个关键优势:常数时间查找。
哈希的基本思想是:将键转换为一个唯一的数字(哈希值),这个数字允许你进行随机访问,从而实现常数时间的查找。如果你有合理的哈希函数,这种方法几乎总是有效的。虽然有时会出现哈希冲突,并有相应的处理方案,但总的来说,对于大多数应用,使用哈希函数构建的关联容器能提供O(1)的查找效率。

因此,在大多数情况下,较新的unordered_map会比旧式的map更有用。这是C++11社区带来的一大优势。
代码示例:map与unordered_map
让我们通过一个程序来具体了解这些关联容器。我们将同时使用map和unordered_map两种类型。
以下是map的声明:
std::map<int, std::string> worker;
以下是unordered_map的声明:
std::unordered_map<int, std::string> worker;
这只是一个演示,我本可以都用map或都用unordered_map。但我想向你展示它们看起来多么相似。你可以自己尝试使用它们,事实上,如果你进行一些计时测试,应该能在自己的系统上发现,对于大量数据,unordered_map通常比基于平衡树的map更高效。
map和unordered_map都是模板。第一个模板参数是键的类型,第二个是与之关联的数据的类型。
我们还可以进一步指定比较关系,比如std::less,这是默认的比较器。对于unordered_map,我们同样有这两个参数,但不会有比较关系,因为我们使用的是哈希。
在哈希中,常数时间查找是基于将键转换为哈希值并直接定位,而不是比较树中的节点,因此不需要比较操作。
在这个例子中,我们可以将键视为工号,将其与姓名关联。另一个映射将工号与薪水关联。
在普通的map中,底层是红黑树。而在unordered_map中,底层是哈希表。

容器使用演示
现在让我们看看如何使用它们。再次回忆我们使用的auto关键字,这是C++11的结构,它能根据上下文推导类型。

这里的上下文是,它需要是一个迭代器类型。
我们遍历迭代器范围,从开始到结束。这是我们的惯用语法。
我们查看worker映射,获取姓名(p.second)和工号(p.first)。注意我们使用了两个成员:second和first。这些实际上是pair内部的成员。
关联数组信息的基本存储方式就是作为一个pair。要获取姓名,就是pair的第二个部分(second);要获取我们用于查找的工号,就是pair的第一个部分(first)。我们解引用迭代器,查看second得到工人姓名,查看first得到工号。
在另一个循环中,我们计算总薪资。同样是使用迭代器的惯用语法。我们只是计算总薪资,然后将其打印出来。
总结

本节课中我们一起学习了C++ STL中的关联容器。我们回顾了序列容器与关联容器的区别,重点介绍了map和unordered_map。我们了解了map底层基于平衡树(如红黑树),提供O(log n)的操作复杂度;而unordered_map基于哈希表,在理想情况下能提供O(1)的常数时间查找,在多数情况下性能更优。我们还通过代码示例学习了如何声明、迭代和访问这两种容器中的数据。对于现代C++开发,unordered_map通常是关联容器的首选。
011:STL算法库概述与排序算法
在本节课中,我们将要学习标准模板库(STL)中算法部分的核心概念与分类,并重点介绍排序算法。我们将了解STL的设计原则、迭代器的重要性,以及如何在实际编程中高效地使用这些算法。
上一节我们详细介绍了关联容器的使用。STL库的一个重要新特性是提供了更多遵循最佳实践的可能性。我们可能希望用新的无序版本(unordered_map和unordered_set)来替代旧的关联容器(map和set),因为哈希查找通常比对数搜索和平衡树更加高效。
STL库的组织结构
STL库拥有自己的组织架构,将具有共同特性的算法进行分类。这种分类有助于我们理解和使用这个庞大的库。因为库非常庞大,我们不可能记住所有细节,通常需要在使用时查阅手册。对于当前项目,你可能会重点学习并使用某一种特定的数据结构。STL包含多个算法类别,我们将介绍其中一些,并查看各类别中的典型算法。
请记住STL的“三条腿凳子”原则:容器、迭代器和算法。它们共同构成了STL的核心。
STL的设计原则
STL的一个核心设计原则是正交性和高效性。这意味着库被设计成可以在尽可能多的场景中以参数化的方式使用,而无需扩展库本身。高效性则来自于选择合适的迭代器类别。
迭代器共有5种类型,从最弱到最强依次是:输入迭代器、输出迭代器、前向迭代器、双向迭代器和随机访问迭代器。STL的设计原则是:使用能让算法最高效运行的最弱迭代器类型。
算法的主要类别
以下是STL算法库的几个主要类别:
- 排序算法:顾名思义,用于对序列进行排序。
- 非变易序列算法:这类算法不会改变容器中元素的值。
- 变易序列算法:这类算法可能会改变容器中的元素。
- 数值算法:用于执行传统的数值计算操作。
在STL库中,我们大量使用迭代器来访问容器。由此产生的代码效率非常出色,甚至可以与特殊用途的专用代码相媲美,这一点非常了不起,你可以自行测试验证。
排序算法详解

现在,让我们具体看看排序算法类别中的几个重要成员。
快速排序
计算历史上最著名的算法之一就是快速排序(Quicksort),由Tony Hoare发明,有时也称为分区交换排序。每个程序员都应该知道如何编写自己的快速排序。
STL中提供了快速排序的模板实现。需要注意的是,快速排序要求随机访问迭代器,这在其函数原型中声明起始和结束迭代器的方式上可以看出来。
代码示例:快速排序原型
template <class RandomAccessIterator>
void sort(RandomAccessIterator first, RandomAccessIterator last);
我建议你尝试一个有趣的练习:编写一个你认为高效的快速排序版本(即使只是针对整数的简单C语言版本),然后使用随机数生成器生成大量数据(例如一千万个键值),最后对比你的快速排序和STL库中std::sort的性能。
稳定排序
另一个重要的排序算法是稳定排序(stable sort)。稳定排序与非稳定排序的区别在于:在排序后,相等元素的相对位置保持不变。
如果所有元素都是唯一的,那么这一点并不重要,因为排序结果只有一种。但是,如果存在重复元素,例如序列 [1_a, 2, 1_b](这里用下标区分两个相同的“1”),稳定排序能保证第一个“1”(1_a)最终一定在第二个“1”(1_b)之前。这种特性在某些数据库应用中非常重要。
公式:稳定排序性质
对于输入序列中任意两个相等元素 a_i 和 a_j,若 i < j,则排序后 a_i 的新位置仍小于 a_j 的新位置。

本节课中我们一起学习了STL算法库的基本框架和设计哲学,重点探讨了排序算法,包括快速排序和稳定排序的原理与区别。理解迭代器的类别和选择原则,是高效运用STL算法的关键。在后续课程中,我们将继续探索其他类别的算法。
012:非变动算法
在本节课中,我们将要学习C++标准模板库(STL)中的非变动算法。这些算法用于在容器中搜索或处理元素,但不会修改容器本身的内容。理解这些算法是掌握STL高效数据处理能力的基础。
非变动算法概述
非变动算法不修改它们所操作的容器或类。它们的典型操作是搜索特定元素并返回其位置。

想象一下,你的桌面上有一堆东西,比如一堆朋友及其电话号码的列表。你想在其中搜索一个特定的电话号码。这就是搜索操作的含义,它通常是一个线性操作。
以下是部分非变动算法。它们不改变任何值,但用于查找某些内容。同样,如果你查看函数原型,会发现一些常见的模式。
find 算法:按值查找
find 算法的原型如下:
template <class InputIterator, class T>
InputIterator find (InputIterator first, InputIterator last, const T& val);
这个签名包含一个迭代器范围 [first, last),这是STL算法中非常常见的部分。它告诉你,该算法对迭代器类型的要求非常弱,在这里输入迭代器就足够了。参数 val 是要搜索的值。你将在范围 [first, last) 中寻找值 T。
这通常是一个线性时间算法,只需要单向遍历一次,因此输入迭代器就足以满足要求。
find 算法:按谓词查找
find 算法还有一个重载版本,签名如下:
template <class InputIterator, class UnaryPredicate>
InputIterator find_if (InputIterator first, InputIterator last, UnaryPredicate pred);
注意,在这个重载版本中,我们寻找的不是一个具体的值,而是一个谓词。
回忆一下,谓词是能得出 true 或 false 结果的函数对象。例如,在我们的一堆数据中,我们想找到第一个大于1000的值,这就是一个谓词。我们不是在寻找1000,而是在寻找第一个满足“大于1000”这个条件的值。
这意味着我们可以在这里传入一个函数对象(仿函数),它返回布尔值。这极大地增强了算法的能力。实际上,按值查找可以看作是谓词查找的一个特例(谓词为“值等于T”)。STL中经常出现这种签名,它允许我们使用函数而非简单值,这体现了库设计的正交性,让我们能在更多场景下使用库的功能。


for_each 算法:对每个元素应用函数
另一个非变动算法是 for_each。它的原型如下:
template <class InputIterator, class Function>
Function for_each (InputIterator first, InputIterator last, Function fn);
这个算法同样使用迭代器范围和一个函数。它会对范围 [first, last) 内的每一个元素应用函数 fn。

例如,我们可以让这个函数执行输出操作,那么 for_each 就会输出范围内的每一个元素。for_each 是一个非常强大的算法,本质上,它提供了一种控制结构。
代码示例分析
让我们通过一个例子来理解这些算法:
#include <algorithm>
#include <iostream>
#include <string>
int main() {
std::string words[5] = {"my", "hop", "mop", "hope", "cope"};
// 使用 find 查找 “hop”
std::string* where = std::find(words, words + 5, "hop");
std::cout << *where << std::endl; // 输出: hop
// 指针自增,移动到下一个元素
++where;
std::cout << *where << std::endl; // 输出: mop
// 使用 sort 对数组排序
std::sort(words, words + 5);
// 排序后,数组顺序变为: "cope", "hop", "hope", "mop", "my"
// 因此,排序后 “hop” 的下一个元素是 “hope”
where = std::find(words, words + 5, "hop");
++where;
std::cout << *where << std::endl; // 输出: hope
return 0;
}
在这个例子中:
- 我们有一个字符串数组
words,初始化为"my","hop","mop","hope","cope"。 - 我们使用
std::find在数组中找到"hop"的位置,并输出它。 - 将找到的指针自增,移动到下一个元素(
"mop")并输出。 - 使用
std::sort对整个数组进行排序(内部使用快速排序)。排序后,数组按字典序变为:"cope","hop","hope","mop","my"。 - 再次查找
"hop"并移动指针,此时下一个元素变为"hope"。
这个例子展示了迭代器范围的强大之处,我们可以对整个序列或子序列进行操作。
总结
本节课中我们一起学习了C++ STL中的非变动算法。我们重点介绍了:
find算法:用于在序列中查找特定值或满足特定条件的元素。for_each算法:用于对序列中的每个元素应用一个函数。- 这些算法的核心特点是不修改容器内容,主要用于搜索和处理数据。
- 我们理解了迭代器范围
[first, last)的概念以及谓词函数如何增强算法的灵活性。 - 通过代码示例,我们看到了
find和sort算法的实际应用。

掌握这些基础算法是有效使用STL进行数据操作的关键第一步。
013:for_each函数的Lambda表达式
在本节课中,我们将要学习C++中一个非常强大且现代的特性:Lambda表达式。我们将从回顾非变异序列算法开始,探讨为何需要向这些算法传递函数或谓词作为参数,并最终学习如何使用Lambda表达式在调用处直接定义匿名函数,从而简化代码。
从命名函数到匿名函数
上一节我们介绍了如何使用for_each等非变异序列算法,并看到它们有时需要一个函数或谓词作为参数。本节中,我们来看看如何利用Lambda表达式,直接在算法调用处编写一个匿名的、临时的函数。
Lambda表达式是一个源自Lisp语言的概念。Lisp是一种基于丘奇λ演算的逻辑语言,其设计理念与追求效率和系统实现的C语言截然不同。因此,Lambda表达式被引入到现代C++标准中,是一个有趣且实用的融合。
回顾我们之前看到的for_each用法,它需要一个函数。Lambda表达式提供了一种在调用处直接编写无名函数的方法。
以下是使用传统命名函数的“旧式”风格示例:

void increment(int& i) {
static int n = 1;
i = n++;
}
void print(int i) {
std::cout << i << " ";
}
int main() {
std::vector<int> vec(6);
std::for_each(vec.begin(), vec.end(), increment);
std::for_each(vec.begin(), vec.end(), print);
// 输出:1 2 3 4 5 6
}
在这个例子中,increment函数通过静态变量n来记录状态,每次调用都会递增并赋值给容器元素。print函数则负责输出。for_each算法依次对容器中的每个元素应用这两个函数。
Lambda表达式的语法与使用

现在,我们可以用Lambda表达式替代上述的命名函数。首先,需要了解如何编写一个Lambda表达式。

Lambda表达式的基本语法以方括号[]开始,这可以理解为“lambda”的象征。虽然这个语法可能初看有些晦涩,但它遵循了C/C++语言复用符号、由上下文决定含义的传统。
Lambda表达式的结构如下:
[]:捕获列表,用于指定Lambda函数体中使用的外部变量(本例为空)。():参数列表,与普通函数一致。{}:函数体,包含要执行的代码。
编译器通常可以自动推导返回类型,无需显式声明,这带来了便利和类型安全。

以下是一些简单的Lambda表达式示例:
// 自动推导返回类型为 double
auto f1 = [](int i, double d) { return i * d; };
// 显式指定返回类型为 double
auto f2 = [](int i) -> double { return i * 2.5; };
在第二个例子中,我们使用-> double来显式指明返回类型。这里的箭头->符号在C++中也被重用于此目的,体现了语言的简洁性。
在算法中应用Lambda表达式
了解了Lambda表达式的基本写法后,我们可以用它来重写之前的for_each示例。
以下是使用Lambda表达式实现相同功能的代码:

int main() {
std::vector<int> vec(6);
int n = 1; // 状态变量移到外部
// 使用Lambda表达式进行“递增”赋值
std::for_each(vec.begin(), vec.end(), [&n](int& i) { i = n++; });
// 使用Lambda表达式进行打印
std::for_each(vec.begin(), vec.end(), [](int i) { std::cout << i << " "; });
// 输出:1 2 3 4 5 6
}
在这个版本中,第一个Lambda表达式[&n](int& i) { i = n++; }通过捕获列表[&n]以引用方式捕获了外部变量n,从而在函数体内修改并保持其状态。第二个Lambda表达式[](int i) { std::cout << i << " "; }则简单地打印每个元素。代码变得更加紧凑和局部化。
过渡到变异序列算法
我们刚刚学习了如何在非变异算法for_each中使用Lambda表达式。接下来,让我们简要展望下一类算法。
变异序列算法会修改容器中的元素或容器本身。一个典型的原型是copy函数。
copy算法需要三个迭代器:
- 源序列的起始 (
begin1) 和结束 (end1) 迭代器。 - 目标序列的起始 (
begin2) 迭代器。

其功能是将源区间的元素复制到以begin2开始的目标区间。它返回指向目标区间中最后一个被复制元素之后位置的迭代器。由于它直接向目标容器(如vector或deque)写入数据,因此属于变异操作。

std::vector<int> source = {1, 2, 3, 4, 5};
std::vector<int> destination(5); // 预分配空间
// 将source的全部内容复制到destination
std::copy(source.begin(), source.end(), destination.begin());
理解copy这类基础变异算法,有助于我们后续学习更复杂的、可以与Lambda表达式结合使用的变异算法(如transform、replace_if等)。

总结


本节课中我们一起学习了C++的Lambda表达式。我们从回顾for_each算法需要函数参数入手,引出了Lambda表达式作为定义匿名函数解决方案的概念。我们详细讲解了Lambda表达式[](){}的基本语法、自动类型推导以及显式返回类型的指定方法,并通过实例演示了如何用Lambda表达式替换传统的命名函数,使代码更简洁。最后,我们简要介绍了变异算法(如copy)作为后续内容的铺垫。Lambda表达式是现代C++编程中极其重要的工具,它能极大地提高代码的表达能力和灵活性。
014:数值算法
在本节课中,我们将要学习C++标准模板库(STL)中的最后一类算法——数值算法。这类算法主要用于执行数学运算,例如求和、求积和计算内积。我们将了解它们的基本用法、灵活性以及在实际编程中的应用。
数值算法概述
数值算法是STL中用于处理数学运算的函数。它们包括求和、求积以及相邻元素求和等操作。这些函数的行为可以根据所使用的数值运算(如加法或乘法)以及底层数据类型是否定义了相应的操作而有所不同。
数值算法并非只能用于严格的数学类型。只要数据类型定义了相应的操作(例如,对于求和运算,需要定义了 + 操作符),就可以使用这些算法。一个常见的例子是字符串:将两个字符串“相加”实际上是进行拼接。虽然可以将数值算法用于非数值类型,但这可能使代码难以理解且存在风险。数值算法的核心用途是产生数值结果,因此建议避免将其用于其他“巧妙”但非常规的用途。
上一节我们介绍了数值算法的基本概念,本节中我们来看看具体的函数示例。
求和与内积算法
以下是两个基本的数值算法示例:accumulate(求和)和 inner_product(内积)。它们通常作用于容器(如数组或向量)的元素上。

// 示例:使用 accumulate 求和
#include <numeric>
#include <vector>
#include <iostream>
int main() {
std::vector<double> vec = {1.0, 2.5, 0.6};
double sum = std::accumulate(vec.begin(), vec.end(), 0.0);
std::cout << "Sum: " << sum << std::endl; // 输出:Sum: 4.1
return 0;
}
在上面的代码中,std::accumulate 函数接受一个范围(由迭代器 begin 和 end 指定)和一个初始值(这里是 0.0)。它会将范围内的所有元素依次加到初始值上,并返回最终结果。
// 示例:使用 inner_product 计算内积
#include <numeric>
#include <vector>
#include <iostream>
int main() {
std::vector<double> v = {1.0, 2.5, -3.5};
std::vector<double> u = {1.0, 2.0, 4.46};
double inner_prod = std::inner_product(v.begin(), v.end(), u.begin(), 0.0);
std::cout << "Inner Product: " << inner_prod << std::endl; // 输出:Inner Product: -11.61
return 0;
}
std::inner_product 函数计算两个序列的内积。在数学上,对于两个向量 v 和 u,其内积定义为 Σ (v_i * u_i)。该函数接受第一个序列的范围、第二个序列的起始迭代器以及一个初始值。
算法的灵活性与正交性
STL数值算法的一个强大特性是其灵活性和正交性。通过允许指定自定义的二元操作符,我们可以极大地扩展这些算法的用途。
以下是 accumulate 函数的原型,它展示了如何接受一个自定义的二元操作符:

template <class InputIt, class T, class BinaryOperation>
T accumulate(InputIt first, InputIt last, T init, BinaryOperation op);
在这个原型中:
first,last:定义了输入序列的范围。init:累积操作的初始值,通常为0。op:一个二元操作符(函数对象、函数指针或Lambda表达式),用于替代默认的加法操作。

通过提供一个自定义的 op,累积操作将不再执行 A + B + C ...,而是执行 op(op(init, A), B)...。这种设计使得算法不再局限于简单的加法,可以执行任何符合逻辑的二元累积操作,例如乘法、字符串连接或更复杂的自定义操作。

这种正交性设计极大地扩展了STL库的效用。例如,我们可以先对一个向量 V1 使用 accumulate,然后将结果作为初始值,再对另一个向量 V2 进行累积,从而实现跨多个范围的复杂计算。
核心概念总结
本节课中我们一起学习了C++ STL中的数值算法。我们了解到:
- 数值算法主要用于执行如求和、求积等数学运算。
- 它们不仅可用于内置数值类型,也可用于任何定义了相应操作符的类型(如字符串拼接),但应谨慎用于非数值目的。
- 我们重点介绍了两个函数:
std::accumulate:用于计算序列中所有元素的累积和(或使用自定义操作符的累积结果)。std::inner_product:用于计算两个序列的内积。
- 这些算法的强大之处在于其正交性和灵活性。通过接受自定义的二元操作符(如Lambda表达式),它们的功能得到了极大扩展,可以适应各种复杂的计算场景。

建议你尝试编写小程序,使用向量测试这些算法,以加深对它们工作原理的理解。
015:函数对象
概述
在本节课中,我们将要学习C++标准模板库(STL)中的函数对象。函数对象,有时也被称为仿函数,是一种行为类似函数的对象。我们将了解它们的基本概念、如何使用内置的函数对象,以及如何创建自定义的函数对象,并会通过一个数值积分的例子来展示其实际应用。

函数对象基础
传统的STL就已经具备了使用函数对象的能力,例如二元操作符。STL内置了一些函数对象。你可以直接使用诸如 plus 和 minus 这样的对象,它们都是STL模板库的一部分。在一些旧式的C++实现中,这些函数对象可能定义在名为 functional 或 function.h 的头文件中。
这些函数对象是通过类来定义的。这些类重载了 operator()。重载函数调用操作符 operator() 本质上是重载函数语法的一种方式。正是这种方式催生了函数对象,也就是我所说的仿函数。它是通过类的机制实现的,并且STL模板中预定义了一些可以直接包含使用的函数对象。

这些函数对象通常是内联的,能够生成非常高效的代码。如今,除了使用类,我们还可以使用 Lambda表达式 来创建函数对象。

以下是使用内置函数对象 minus 的一个例子。minus 是一个对整数进行减法操作的函数对象。我们使用 accumulate 算法,但这里不是使用普通的加法函数,而是使用减法操作。
// 示例:使用 minus<int>() 函数对象进行累减
int result = accumulate(begin, end, initial_value, minus<int>());
在这个例子中,如果序列是 {1, -2, -1, -2, -4},初始值为0,由于整数运算,最终结果会是 -7。
生成器函数对象
除了二元操作符,还有其他重要的函数对象类型,它们极大地扩展了STL的能力。其中一种就是生成器函数对象。
接下来,我将展示如何使用它来编写一个用于数值积分的程序。对于那些在微积分上遇到困难、总是算不对答案的同学,你可以用自己的程序来替代计算,让程序为你算出答案。
让我们具体来看一下。这里的关键是重载函数调用操作符 operator()。我们重载了一个参数列表为 void 的函数。在这个例子中,返回值类型是 double。

class SquareGenerator {
private:
double x;
double increment;
public:
SquareGenerator(double start, double inc) : x(start), increment(inc) {}
double operator()() {
double value = x * x;
x += increment;
return value;
}
};
类中的 x 和 increment 是私有变量,通过构造函数进行初始化。例如,如果我们想从1.0积分到2.0,增量设为0.001,那么这个生成器就会计算从1.0开始,每次增加0.001,直到接近2.0的每个点的 x 的平方值。它会生成一系列数值,作为我们数值积分的来源。
数值积分示例
现在,我们来看积分程序。我们向 integrate 函数传入这个生成器对象。虽然它是一个类对象,但其真正的作用是产生一系列值。从本质上讲,它是一个生成序列的生成器。
double integrate(SquareGenerator gen, int n) {
vector<double> fx(n);
generate(fx.begin(), fx.end(), gen);
double sum = accumulate(fx.begin(), fx.end(), 0.0);
return sum / n; // 近似于平均值乘以区间长度(此处为1)
}
这个函数会生成 n 个值(例如1000个),将它们存入向量 fx 中,然后累加这些值并求平均。这本质上是一种类似辛普森法则的数值积分方法。我们通过累加许多小矩形来近似计算积分,每个矩形的宽度是 1/n。
这是一种数值近似方法,虽然不一定超级精确,但对于平滑函数通常能给出很好的结果。我们可以实际调用这个函数来看看效果。
int main() {
SquareGenerator gen(1.0, 0.0001); // 从1.0开始,增量为0.0001
double integral_approx = integrate(gen, 10000);
cout << "Approximate integral of x^2 from 1.0 to 2.0: " << integral_approx << endl;
return 0;
}
在这个例子中,我们使用10000个值来近似计算函数 x^2 从1.0到2.0的积分。如果你懂微积分,可以尝试心算或笔算来验证。如果你想对其他函数进行积分,只需要修改生成器类中 operator() 返回的计算逻辑即可。


总结
本节课中,我们一起学习了C++中的函数对象。我们首先了解了函数对象的基本概念,它是通过重载 operator() 使得类对象能像函数一样被调用。然后,我们看到了STL中内置的函数对象(如 plus, minus)以及如何使用它们。接着,我们深入探讨了生成器这种特殊的函数对象,并通过一个具体的数值积分实例,演示了如何创建和使用自定义的函数对象来解决实际问题。函数对象是STL算法强大和灵活性的重要组成部分,理解和掌握它们对编写高效的C++代码至关重要。
016:已定义函数对象类与函数适配器 🧩
在本节课中,我们将学习C++标准库中预定义的函数对象类以及函数适配器的概念。这些工具极大地增强了算法的灵活性和表达能力,是理解STL设计哲学的关键。
函数对象类的强大功能 💪

上一节我们介绍了函数对象的基本概念。现在,我们来看看标准库提供的强大功能。我们可以使用 plus、minus 等算术对象,以及二元对象、谓词对象。所有这些都存在于预定义的函数类对象中。
这些函数对象主要分为三大类:
- 算术对象:如
plus,minus。 - 比较对象:如
less(常用于map等容器中)。 - 逻辑对象(谓词):可以用于实现
and、or等逻辑操作。

算术与比较对象示例 📊

以下是部分算术对象的样子:plus、minus、multiplies。它们都被指定为模板,必须根据其类型进行定义。如果某个类型定义了 + 运算符(即二元加号运算符),那么当你调用这个函数对象时,就会使用该运算符。例如,在使用这些算术函数对象进行 accumulate 操作时就是如此。
更多的算术对象包括 negate、modulus、divides。
类似地,还有 less、greater、less_equal 等比较对象。

函数适配器介绍 🔧
除了基本的函数对象,还有一些特殊类型的函数适配器。
以下是几种适配器:
- 取反器:用于对谓词对象的结果进行逻辑取反。
- 绑定器:用于绑定函数的某个参数。
绑定器的典型应用场景是:假设我们有一个接收两个参数 A 和 B 的函数,但我们实际需要一个单参数函数。我们定义了一个需要单参数函数的算法,但手头只有双参数函数。绑定适配器允许我们将双参数函数转换为单参数函数。

一个简单的例子是:让第二个参数恒为1。这样,适配器会将 A + B 这样的函数,在B恒为1的情况下,变成 A + 1。现在我们只需要参数A,因为第二个参数B被绑定为常量1。

绑定器使用示例 🧪
接下来,我们通过一个简单案例看看它的用法。我写了一个 print 方法。


它需要一个前向迭代器,我会先打印一个标题,然后打印从 first 到 last 迭代器范围内的值。我认为这些值存储在该序列中,然后换行并打印制表符。所以,我会打印一个标题,打印一系列用制表符分隔的值,最后换行。


这是一个使用绑定器的例子。初始值序列是:9, 10, 11。
如果我打印原始值,当然会输出 9, 10, 11。
现在,我将通过绑定函数来转换这些值。
转换函数将如何工作呢?给定一个起始范围,这就是我要操作的数据。这是一个会改变数据的函数。
这里是我要使用的“炸弹”(指函数对象)。我接收一个整数参数,使用乘法操作,将参数乘以2。基本上,就是 n * 2。所以,9乘以2,10乘以2,11乘以2。

这个绑定函数将一个原本是二元操作(需要两个参数)的函数,转换成一个单值函数,并将其应用到 data 中的值上,从而得到新值。这些新值会替换原值。这本质上是类似复制的操作,但带有转换,所以是改变性的。最终我们将得到:18, 20, 22。


STL设计理念与最佳实践 🏗️
STL基于模板,理解STL的关键是迭代器逻辑。STL大量使用了迭代器逻辑。

因此,如果你想编写STL函数或类STL函数,你的参数通常应该是迭代器范围。当你尝试像STL一样编写代码时,你需要保持与现有库实践的一致性。
仅有通用性和广泛性是不够的。你必须确保效率,因此要在适当的地方使用内联。你还需要保持一致性,例如,你可能想要一个算法的版本,它只是遍历一些迭代器并执行一个隐含的操作;或者你可能想让该操作成为一个函数对象,以允许一个特殊的函数,因为这将再次增加你所做事情的适用范围。
通过研究STL的标准元素,你应该能把握其设计特点。希望上一系列讲座中的评论已经让你很好地理解了这一点。
下节课预告 🎮

下次课我将教你们如何玩井字棋(Tic Tac Toe),可能你们很多人已经会了,但我们有明确的目的。
井字棋非常简单,我们都理解它。我们将以非常重要的方式对其进行推广,主要的作业是看看如何将我们在C++和C++11中教授的所有思想,以及AI,应用到一个非常有趣的组合游戏上,即Hex游戏。这个游戏将是你们学期作业、最终作业的基础,你们将在这个相当独特的作业中运用所有这些C++思想和算法思想。
好的,我们下次课见。

总结 📝

本节课我们一起学习了C++标准库中预定义的函数对象类,包括算术、比较和逻辑对象。我们还深入探讨了函数适配器,特别是绑定器的原理与用法,它能够将多参数函数适配为算法所需的参数形式。理解这些工具对于高效、灵活地使用STL算法至关重要。最后,我们了解了STL的设计理念,并预告了下节课将结合游戏案例来综合应用这些C++概念。
017:项目介绍与游戏规则

在本节课中,我们将学习如何实现一个名为“六边形棋”的游戏项目。我们将探讨如何创建一个具有挑战性的、运用人工智能的机器程序,并学习如何将其嵌入到C++程序中。课程将继续深入C++中的继承概念,这是面向对象编程的核心思想,并强调C++11新标准中与继承相关的一些特性。
从井字游戏谈起
我们小时候都学过如何玩井字棋。下图展示了一个井字棋的局面,轮到X方走棋(X方先手)。X方应该走在哪里?游戏的结果会是什么?请思考一下,如果你是X方玩家,你会如何决定?

井字棋是一种完全信息游戏。与之相对,扑克就不是完全信息游戏。在完全信息游戏中,对手能看到所有用于决策的信息。而在扑克中,每位对手都拥有隐藏信息,例如牌堆的顺序和每个人手中的牌。
在井字棋中,如果双方都完美地下棋,游戏总会以平局告终。这很容易证明,可能也是你们大多数人五六岁后就不再玩它的原因——它变得太无聊了。如果你小时候决定最佳着法,你很可能很快就知道,先手玩家(X方)的最佳着法是将X放在棋盘中央。这是因为中央位置能为你提供最多的获胜可能性,同时给对手最多的失败机会。最差的着法是放在边线的中点,而质量居中的着法是放在角落。
然而,井字棋并不太有趣。它是一个非常小的游戏,很容易穷举,即使是一个孩子也能很快找出最佳策略。但人们仍然在玩类似井字棋的游戏。你可以将棋盘变大,例如在19x19的围棋棋盘上尝试连成五子而非三子。这大大增加了复杂性,并削弱了中央方格的重要性,因为实际上有很多“中央”方格。
另一种让游戏更有趣的方法是增加维度。井字棋通常在二维的纸上进行,但如果在三维空间玩呢?现在你有一个由3个棋盘组成的立体棋盘,就像一层、二层和三层,每层是3x3,总共有27个方格。现在,你不仅可以在任何一层上通过一行或一列获胜,还可以在第三维度上连成一线。这增加了复杂性:更多方格、更多获胜方式、更多失败方式。
最后一种增加趣味性的方法是让棋盘结构更复杂。在井字棋中,简单的获胜方式是行、列和对角线。在接下来要介绍的“六边形棋”中,棋盘由六边形的格子组成,每个六边形格子有六个相邻的格子。而在方形棋盘上,一个格子最多只有四个邻居。当你有六个连接的邻居时,本质上你就有更多地方可以延伸你的“三连子”、“五连子”或“N连子”。
六边形棋介绍
六边形棋是由专业数学家发明的游戏。它由荷兰数学家皮特·海恩于1942年首次发明,后来由约翰·纳什(如果你记得奥斯卡获奖电影《美丽心灵》,他获得了诺贝尔奖)于1947年在普林斯顿大学攻读博士学位时独立发明。
下图展示了一个7x7的六边形棋盘。你可以看到白色的六边形、黑色的棋子和空的格子。白方和黑方轮流走棋。黑方的目标是在南北方向之间形成一条由己方棋子组成的连通路径。白方的目标是在东西方向之间形成一条连通路径。首先完成路径的一方获胜。

游戏规则详解
以下是六边形棋的具体规则:
- 每位玩家分配一种颜色(通常是红与蓝,或白与黑)。
- 玩家轮流在棋盘的一个空单元格中放置一颗己方颜色的棋子。
- 目标是形成一条由己方棋子组成的连通路径,连接棋盘上标记为己方颜色的两条对边(一方连接南北,另一方连接东西)。
- 首先完成连通路径的玩家获胜。
研究发现,先手玩家拥有巨大优势,类似于井字棋中先手占据中央的情况。实际上,纳什证明了在通用尺寸的棋盘上,如果先手玩家完美行棋,则必须获胜。这同样是一个完全信息游戏,没有虚张声势,没有隐藏信息。如果先手玩家将棋子放在中央,并且之后双方都完美行棋,先手将获胜。
有一种方法可以避免这种巨大的先手优势,即采用“切饼规则”。在这个规则下,先手玩家走第一步棋。然后,后手玩家仅在此回合可以做出两个决定之一:
- 说:“我喜欢你这步棋。所以我将成为先手玩家,而你自动成为后手玩家。”
- 说:“我不喜欢你这步棋。所以我将继续作为后手玩家,并走出我认为更好的着法。”
为什么叫“切饼规则”?想象一下,当你还是个孩子时,你的妈妈有一块馅饼或冰淇淋,她说:“我来把饼分成两块。我会让约翰尼先选一块,然后梅布尔选第二块,但梅布尔可以选择约翰尼的那块。” 这是一种确保公平的方式。实际上,负责切饼的人有动机尽可能公平地切分,因为如果他们切得不好,对方就可以拿走他们切的那块。有了这个规则,游戏变得非常公平。你会想走一步棋,它不至于坏到让你最终输掉,但又足够好,以至于如果你变成后手玩家,你仍然有机会获胜。
关于游戏的一些事实
纳什证明了在没有“切饼规则”的情况下,先手玩家必胜。证明思路大致如下:假设我先手走了一步棋。我怎么可能受到损害呢?如果后手玩家在我走棋后,通过某种完美行棋的方式获胜,那么为什么我不能扮演后手玩家的角色呢?我可以在棋盘上多出我这颗棋子的情况下,走出后手玩家那些获胜的着法。这颗额外的棋子不可能损害我。因此,如果后手玩家有办法获胜,那么完美行棋的先手玩家也一定能使用同样的方法获胜。这告诉我们先手玩家必须获胜。
游戏不可能出现平局。总有一方会形成一条路径,切断另一方的路径。不可能出现双方都无法从一边连接到另一边的僵局,这与井字棋可能平局不同。因此,完美行棋的先手玩家必胜。但这个证明并没有揭示先手玩家的具体着法应该是什么,它只是证明了先手必胜,因为先手是一种优势,并且游戏必须分出胜负。
在较小的棋盘上,例如7x7,计算机和数学家已经能够穷举分析游戏。事实上,他们已经知道如何获胜。据我所知,目前可能已经解决了最多8x8的棋盘,我不确定9x9是否已完全解决。在进行人类和计算机比赛时,他们通常从11x11或更大的棋盘(有时是13x13或19x19)开始。在这些更大的棋盘上,最佳着法仍然是未知的。事实上,从计算角度看,在N x N的棋盘上,六边形棋通常被认为是NP空间完全问题,这意味着预计不存在任何简单的多项式时间解决方案。
项目使用的棋盘
在我们的作业项目中,我将以11x11的棋盘为例。我希望你们能够在一个任意尺寸的棋盘上进行游戏,但调试阶段应该使用小得多的棋盘。当你们去表示这个棋盘时,请记住我们在课程初期做了很多图论相关的工作,所以将六边形棋视为一个图。
在六边形棋的图中:
- 每个单独的六边形格子是一个节点。
- 节点之间的边表示格子相邻。
- 一个内部的六边形格子有六个相邻的格子。
- 位于边缘的节点可能只有两到三个相邻的格子。
在这个11x11的棋盘上,有121个节点。角落的格子连接度可能是2或3(左上和右下为2,右上和左下为3,尽管这些都是相对较弱的着法)。棋盘中心的格子拥有最多的连接(6个),而边缘上的格子连接数居中。
小测验:一个内部的六边形格子连接多少个邻居?我们已经告诉过你了。那么,连接度最低的格子位置是哪里?如果每个节点是一个六边形格子,其“度”表示边的数量,那么节点度的范围是多少?
让我来回答一下。在六边形棋盘上,一个角落的格子连接度可以是2或3。因此,构成六边形棋盘的节点的连接度范围是从最低的2到最高的6。如果我们回头看方形棋盘,一个像这样的边缘格子连接度是4。
总结

本节课我们一起学习了从简单的井字游戏过渡到更复杂的六边形棋的过程。我们介绍了六边形棋的基本规则、先手优势以及用于平衡游戏的“切饼规则”。我们还了解了将棋盘抽象为图模型的方法,这是后续用C++实现游戏AI的重要基础。在接下来的课程中,我们将开始探讨如何利用继承等面向对象编程技术来构建这个游戏程序。
018:作业 - 基本六边形棋程序
在本节课中,我们将要学习本课程的主要作业项目:构建一个六边形棋程序。我们将从构建一个简单的版本开始,并最终实现一个使用蒙特卡洛策略的智能玩家。
课程主要任务 🎯


本课程的主要任务是构建一个六边形棋程序。
你的程序的第一个版本将是一个简单的版本。在这个版本中,你不会做太多我称之为人工智能的工作。关于作业的详细说明会在课程网站上发布。
但你的程序的第一个版本应该基本上能够做出合法且合理的移动。
什么是合理移动? 🤔
一个合法且合理的移动,可以回想一下你在玩井字棋时尝试的移动。在井字棋中,什么是合理的移动?一个合理的移动是,你试图连接你最长的X序列,使其变得更长。
如果你有一个单独的X,你会尝试在它附近再放一个X,以便最终能连成三个。或者,如果你在玩五子棋,如果你有三个连成一线,你会希望尝试将其变成四个。

因此,在井字棋中,合理的移动是:延长你自己的最长路径,以及阻挡对手的最长路径。这是任何孩子在玩了一段时间游戏或阅读了相关资料后都会想到的策略。
我希望你在六边形棋中做类似的事情:尝试创建从你的边界出发的更长的路径,或者阻挡对手从边界出发的更长路径。
迈向智能玩家:蒙特卡洛策略 🎲
接下来,我们将构建一个非常聪明的六边形棋玩家。我们将使用一种蒙特卡洛策略。蒙特卡洛是一个非常著名的赌场度假胜地,詹姆斯·邦德总是在蒙特卡洛赌场玩,当然,他选择的游戏是百家乐。
我们选择的游戏将是一个掷骰子游戏。我们将使用伪随机数生成器来获取概率,通过这种方式,我们将随机选择我们的移动。我们将利用随机性和模拟来尝试构建一个聪明的玩家。这听起来有些矛盾:我们使用随机掷骰子,随机寻找移动,但它们将引导我们构建一个非常聪明的玩家。这本身在人工智能领域就是一个非常有趣的结果。
所以,蒙特卡洛程序是概率模拟。当你进行模拟时,这里有一个小测验。

C++中的随机数生成 📝
在C/C++社区中,标准的伪随机数函数是什么?在C++社区中,srand函数为你做什么,以及应该如何和为什么使用它?请花点时间思考一下。
标准的函数,我们应该已经使用过,是 int rand(void),它在C标准库中。它返回一个伪随机数,一个介于0和预定义常量 RAND_MAX 之间的正数。RAND_MAX 很重要,因为如果我们进行浮点数运算,我们可以用它来获得一个概率:如果我们用 rand() 的返回值除以 RAND_MAX,使用 double 类型,我们将得到一个介于0和1之间的概率。
srand(time(NULL)) 是一种为随机数生成器提供种子的方法,这样每次调用伪随机数生成器时,不会从列表中的同一点开始。这很重要:我们不希望每次重启程序时都持续模拟同一组事件,我们希望使用序列的不同部分。

总结 📚

本节课中我们一起学习了本课程的核心作业——构建六边形棋程序。我们从理解“合法且合理的移动”这一基本概念开始,将其类比为井字棋中的策略。接着,我们展望了未来将使用蒙特卡洛策略来构建一个智能玩家,并回顾了在C++中生成随机数的关键函数 rand()、srand(time(NULL)) 以及常量 RAND_MAX 的用法。我们的最终目标是开发出一个优秀的六边形棋程序。
019:继承基础

概述
在本节课中,我们将要学习C++中一个核心的面向对象编程概念——继承。继承是一种代码重用机制,也是构建类型体系结构的关键。我们将通过一个具体的例子来理解其语法、用途以及“公有继承”这一最常用的形式。
继承:一种重用机制
在软件开发中,重用是黄金法则。你不希望编写全新的代码,而是希望重用经过充分测试的现有代码。继承就是一种强大的代码重用机制。
从术语上讲,继承会用到所谓的“纯多态”,这依赖于虚函数机制。这与Java等语言不同,在Java中,纯多态是默认行为。此外,继承也是构建类型结构的一种方式。C++强调强类型,这有助于编译器检查和确保代码的正确性。
面向对象编程的核心就是继承。早期的语言如Simula 67和Smalltalk率先引入了继承,展示了如何用它来创建对象、构建类型结构、实现代码重用,并提供构成纯多态核心的动态函数调用机制。这些机制对于构建大型软件非常有用。
面向对象设计方法论
当你采用面向对象编程的设计方法时,会有一个设计步骤。你需要首先决定一套适合问题领域的类型。你不再局限于预定义的类型(如int、double、char),而是尝试构建与你的问题域相匹配的类型。
例如,在“六边形棋”游戏中,问题域类似于图论。因此,你可能希望将“图”作为一种基础类型。你有一个六边形棋盘,所以可能定义HexBoard或HexGame这样的类型,其部分实现可能涉及图结构。在设计像六边形棋编程作业这样的复杂问题时,设计方法论的一部分就是决定如何让实现变得简单便捷。
一旦确定了一组类型,你就可以设计它们之间的关系。继承是类之间共享代码的一种方式,我们将在接下来的例子中看到这一点。
继承的语法
在C++中,任何类都可以作为可以被派生的基类。
以下是派生类的语法:
class DerivedClass : public BaseClass {
// ... 成员定义 ...
};
: 表示“派生自”,BaseClass是基类名称,public是典型的可访问性模式。
这意味着你可以重用基类的所有代码。公有继承意味着基类是派生类的超类型。任何派生类的成员元素也属于基类类型,这对类型结构很重要。
这里有一个“90%规则”:在继承相关的知识中,你需要重点掌握公有继承。你所学的关于公有继承的一切都将适用于你的大部分工作。私有继承等其他特性的使用频率低于10%。在私有继承中,是一种“类似”的关系,不涉及子类型化;而公有继承是一种子类型化方法。私有继承只是纯粹的代码重用,它不像公有继承那样具有动态性和面向对象特性。
示例:从Duo到Point
我们将通过一个例子来深入理解。建议你将其写入编译器并尝试运行,这是一个直观且重要的例子。在现实世界中,它实际上是标准库的一部分(通常称为pair,在C++11中泛化为tuple)。我们这里称其为Duo,以避免与标准库名称冲突。
我们的Duo类将拥有first和second两个元素。为了通用化,本应使用模板,但为了示例清晰,我们暂时使用double类型。
以下是Duo类的定义:
class Duo {
protected:
double first;
double second;
public:
Duo(double f = 0.0, double s = 0.0) : first(f), second(s) {}
double getFirst() const { return first; }
double getSecond() const { return second; }
void setFirst(double f) { first = f; }
void setSecond(double s) { second = s; }
};
注意,这里使用了protected而非private。protected为继承提供了访问权限。受保护的成员仍然在“家族”内部可见,但对于继承层次结构之外的人来说,它们就像私有成员一样。当你开始大量使用继承时,你可能会在架构上改变编写代码的方式,将原本private的成员改为protected。
现在,我们通过继承来创建Point类。一个点可以看作是Duo的一种形式,但我们现在将其视为XY轴上的一个点,而不仅仅是随机的数值对。
以下是Point类的定义:
class Point : public Duo {
public:
Point(double x = 0.0, double y = 0.0) : Duo(x, y) {}
double length() const {
return sqrt(first * first + second * second);
}
};
由于Point是Duo的公有派生类,它继承了Duo的所有成员,包括构造函数。现在,Point是一种Duo,但并非所有Duo都是Point。这体现了类型关系。此外,Point还添加了length方法,用于计算点到原点的距离,这是Duo类所没有的功能。
一个小测验
考虑以下代码:
Point q(0.0, 0.0);
q.setFirst(3.0);
q.setSecond(4.0);
cout << q.getFirst() << ", " << q.getSecond() << ", " << q.length() << endl;
这段代码会输出什么?
我们先将q的first设为3.0,second设为4.0。length方法计算的是sqrt(3^2 + 4^2),即sqrt(9 + 16) = sqrt(25) = 5。
因此,输出结果是:
3, 4, 5
总结

本节课我们一起学习了C++中继承的基础知识。我们了解到继承是一种强大的代码重用和类型构建机制。重点掌握了公有继承的语法(class Derived : public Base)及其意义——它建立了“是一个”的子类型关系。我们还通过Duo和Point的示例,看到了如何通过继承扩展现有类的功能,并理解了protected访问修饰符在继承中的作用。记住,在大多数情况下,你会使用公有继承,它遵循“90%规则”,是你面向对象编程工具箱中的核心工具。
020:特质与继承

在本节课中,我们将要学习C++中一个核心概念——继承。继承是面向对象编程的基石,它允许我们基于已有的类创建新的类,从而实现代码的重用和扩展。
继承的概念与现实世界类比
上一节我们介绍了继承的基本思想,本节中我们来看看它与现实世界的联系。

我们知道现实世界中的继承关系:你有父母,你是后代,你有祖先。你从祖先那里继承了特征。

在编程中,继承机制是一种从现有类派生新类的手段。现有类被称为基类。它重用现有代码,从而消除了测试和开发新代码的需要,这是其重要性的原因之一。
通常,派生类会添加更多信息或修改行为。例如,通过从 duo 类派生 point 类,我们添加了长度计算功能。
这样创建的相关类型的层次结构可以共享代码和接口,从而可以开发越来越大的系统,同时保持代码相当紧凑。

C++中的继承类型
C++允许两种继承方式:单继承和多继承。一些语言通常只允许单继承,但在C++中,一个派生类可以从多个基类派生。
以下是两种类型的简要说明:
- 单继承:假设我们有一个信用卡的概念,但存在不同种类的信用卡,每种卡可能有一些不同的功能。我们可以决定从某个原型信用卡派生,然后让派生出的信用卡类型拥有不同的方案和特征。
- 多继承:我们可能有两种独立的事物,例如邮件(具有邮件的特征)和电话(具有电话的特征)。那么新事物可能是电话邮件,因此我们得到一个派生类,它同时拥有邮件和电话的特征。两者都变得可用。
分类学与软件重用
所有这些都属于分类学范畴,这是科学上的重大突破之一。其核心在于理解事物可以被归入大的类别。
你在元素周期表中就能看到这一点。在没有元素周期表之前,化学是一门伪科学。一旦开始有了分类法,你就可以开始看到诸如惰性气体、碱金属等类别,这种分类法使化学成为一门科学。
生物学也是如此,一旦你开始有了脊椎动物/无脊椎动物、哺乳动物/爬行动物等分类概念,你就能开始看到共享的特征和原始概念,从而能够科学地理解非常大的群体,而不是将所有事物都视为独特的信息。
例如,我们有一个像“哺乳动物”这样的概念:温血、高等脊椎动物、用乳汁哺育幼崽。然后我们看到在哺乳动物内部,像啮齿动物和大象这样的生物共享许多特征。从表面上看,大象和蟾蜍看起来完全不同,然而大象实际上是老鼠的近亲,而不是蟾蜍的近亲。在现代DNA谱系中,这一点甚至更加明显。
老鼠是哺乳动物,大象是哺乳动物,这是一种获得特征描述的方式。特征描述允许共享。请记住,软件重用和共享,与分类学是同一回事。这就是我们以面向对象方式构建时所做的事情。当我们构建类型层次结构时,我们就是在构建让我们能够共享的东西。当我们想要构建一个大型系统时,这种共享将成为一项非常强大的资产。
在C++术语中,像 elephant 和 mouse 这样的类是从基类 mammal 派生而来的。
“是一个”与“有一个”关系
这里有一些术语需要区分。
- “是一个”关系:描述一种类型关系。例如,“大象是一种哺乳动物”。
- “有一个”关系:描述一种组成部分关系。例如,“马戏团有大象”。马戏团对象可能拥有类型为
elephant的成员。但马戏团不是一种大象,大象也不是一种马戏团。这是两种非常不同的关系。在circus类中,这是一种组成部分关系。
小测验

以下是一个小测验,看看你是否理解了这些基本术语。再次强调,阅读面向对象的基础知识是值得的,这也是学习本课程的先决条件。
问题如下:
- Zoo(动物园)与 Mammal(哺乳动物)是什么关系?
- Cell phone(手机)与 Telephone(电话)是什么关系?
- Platypus(鸭嘴兽)是一种哺乳动物,这是真的还是假的?
以下是答案和分析:
- Zoo has a mammal:就像马戏团可能有大象一样。这最好表达为一种成员关系(“有一个”)。
- Cell phone is a telephone:存在一种最好通过公有继承(“是一个”)来表达的关系。顺便说一下,如果你想构建一个手机模型,你可能会说大多数现代手机也有摄像头。所以突然间,如果你想用软件构建现代手机,你可以使用多继承,因为手机也是一个个人数字助理(PDA,管理你的日历)。因此,在许多情况下,今天的手机也是一台相当通用的计算机。所以,在重用方面,手机可以引发有效地使用多继承,尽管我们将避开多继承中的许多棘手问题。
- Platypus is a mammal:这是真的。鸭嘴兽是一种半水生哺乳动物。鸭嘴兽给了我一个借口从维基百科上获取那张图片。
总结

本节课中我们一起学习了C++中继承的核心概念。我们了解了继承如何模拟现实世界的关系,以及它如何通过代码重用和构建类型层次结构来促进软件开发和维护。我们区分了“是一个”和“有一个”这两种重要的类关系,并通过小测验巩固了理解。掌握这些基础知识是进行更复杂的面向对象C++编程的关键。
021:虚成员函数与纯多态 🧬
在本节课中,我们将学习C++中实现纯多态的核心机制——虚函数。我们将探讨虚函数的声明、重写,以及如何通过基类指针实现运行时的动态方法选择。

概述
为了构建支持纯多态的继承体系,我们需要引入虚函数的概念。虚函数是在基类中声明,并在派生类中重写的成员函数。这种机制允许程序在运行时根据对象的实际类型来调用相应的方法,从而实现多态行为。
虚函数与重写
上一节我们介绍了继承的基本概念,本节中我们来看看实现多态的关键——虚函数。
一个虚函数在基类中声明,并在派生类中重写。这里的“重写”与我们之前熟悉的、通过不同函数签名实现的“重载”不同。
- 重写:保持函数签名完全相同,但改变函数的具体实现。触发使用新实现的关键因素在于对象在类型层次结构中的位置,这通常由
this指针在运行时决定。 - 重载:为同一个函数名提供多个具有不同参数的版本,编译器在编译时根据调用时提供的参数来决定使用哪个版本。

通过公有继承定义的类层次结构创建了相关的类型,所有这些类型的对象都可以用一个基类指针来指向。这就形成了一个简单的模式来决定如何运行程序:程序中的所有对象都来自同一个类型层次结构,并且拥有被重写的虚方法。运行时,通过指向基类的指针,根据该指针在运行时动态感知到的具体子类型,来选择调用恰当的函数。这个机制就是纯多态,也称为运行时多态或动态绑定。

由于是动态的,这个机制会带来少量的运行时开销。这也是为什么在C++中,并非所有成员函数都是虚函数。相比之下,像Java这样的语言,其方法默认就是虚函数。C++为了保持极高的运行效率,允许开发者在不需动态选择的情况下,使用非虚成员函数,从而避免这部分开销。这使得语言更复杂,但也为追求效率提供了更多工具。
代码示例:从2D点到3D点
让我们回到具体的例子,看看进一步的继承能做什么。
在这个例子中,我们从表示二维点的 Point 类开始,它继承自 Duo 类。然后,我们在这个层次结构中继续扩展。
类层次结构如下:顶层的 Duo,Point 是一种 Duo,而 Point3D 则是一种增加了另一个维度的 Point。
以下是 Point3D 构造函数的一个示例:
Point3D::Point3D(double x, double y, double z) : Point(x, y), z(z) {}
观察这个构造函数,它使用了基类 Point 的构造函数来初始化 x 和 y,然后额外初始化了 z 值。除非我们提供特定的二维坐标值,否则初始化结果通常是一个标准初始化的三维点 (0, 0, 0)。强烈建议你动手实践,编写 Duo、Point 和 Point3D 类,并尝试各种操作来深入理解继承的工作方式。
现在,我们将之前非虚的成员函数 length 改为了虚函数,以便它能够被重写。
请注意我们所做的:在 Point3D 的 length 方法中,我们计算了所有三个坐标值(x, y, z)平方和的平方根,并添加了相应的私有成员 z。
在代码的主体部分,如果我们有一个 Point3D 对象,将其坐标设置为 (5, 6, 7),然后询问这个 Point3D 对象的长度,我们将会调用虚函数机制。此时调用的 length 方法不是基类中的那个,而是被重写后的版本。通过作用域解析,我们实际上调用了被重写的 length 方法。计算其平方和 (5² + 6² + 7²) 再开方,最终会得到结果 √110 ≈ 10.488,即三维空间中的长度。
所有这些都可以通过指针逻辑来完成。因为 Point3D 和 Point 都是 Point 类型(通过继承),所以这种指针选择就是所谓的纯多态。它会根据指针实际指向的对象是 Point 还是 Point3D,自动选择正确的 length 计算方法(二维或三维)。这样,我们就可以将不同类型的对象混合在一起,运行同一段代码,并允许在运行时动态地执行正确的操作。
纯多态的意义与特点
被指向的对象必须携带类型信息,以便能够在运行时做出区分。这是典型的面向对象编程代码,无论是在Java、现代Python还是其他主流的面向对象语言中都是如此。


每个对象都知道应该如何被操作,这在某种程度上是一种封装。对象“知道”某个操作对其自身的具体含义,从而能够正确执行。这也便于进行测试,实际上是单元测试的基础——将测试紧密关联到正在开发的单个元素上。
这种机制通过分而治之,使得扩展大型软件成为可能。它最大化了代码的可重用性,同时也允许对问题域进行自然的、分类学式的建模。
总结

本节课中我们一起学习了C++中实现纯多态的核心机制。我们了解了虚函数的声明与重写,及其与重载的区别。通过基类指针指向派生类对象,可以在运行时动态选择调用正确的函数版本,这是实现多态行为的关键。虽然这会引入少量运行时开销,但C++通过允许使用非虚函数,为开发者提供了在灵活性与效率之间进行权衡的工具。最后,我们通过一个从2D点到3D点的继承示例,具体演示了虚函数和纯多态的应用及其在软件设计中的重要意义。
022:C++11 final关键字 🚫

在本节课中,我们将要学习C++11标准引入的一个新特性:final关键字。这个关键字用于在继承体系中明确地阻止进一步的派生,是控制软件设计和提高编译器效率的有力工具。



上一节我们介绍了继承和多态的基本概念,本节中我们来看看如何限制继承。
C++11引入了一个在特定上下文中使用的术语 final,用于表明你不希望某个类再被继承。这是一种声明设计意图、维持对软件控制权的方式。


当然,你总是可以撤销这个决定。但在某些情况下,你可能希望设计一个类仅用于其自身目的,而不需要任何进一步的开发。明确禁止继承有时还能让编译器在某些方面进行更高效的优化,因为它知道这个类没有能力被继承。
以下是 final 关键字的语法展示:


class Point3D final : public Point {
// ... 类成员定义 ...
};

这段代码声明 Point3D 类本身是 final 的。这意味着没有任何类可以从 Point3D 派生。它是一个独立的终点。

你可以将其类比于生物分类学中的“终点”物种。例如,鸭嘴兽(platypus)可以被视为一个终点分类,不存在鸭嘴兽的亚种或派生类别。
需要再次强调的是,final 是一个上下文关键字。这意味着,虽然可能不是好的编程实践,但在程序的其他地方,final 仍然可以被用作普通的标识符(如变量名)。不过,我们不推荐这样做。最好避免使用具有特殊含义的词汇作为普通标识符。





本节课中我们一起学习了C++11的 final 关键字。我们了解到,通过在类声明后添加 final,可以明确禁止该类被其他类继承。这有助于表达清晰的设计意图、增强对代码结构的控制,并可能为编译器优化提供机会。虽然 final 是上下文关键字,但为了代码清晰,应避免将其用于其他用途。
C++编程:P23:继承专题:核心概念与高级议题 🧬
在本节课中,我们将深入探讨C++继承机制中的核心概念与即将学习的高级议题。我们将回顾已学知识,并展望后续课程中会遇到的更复杂的设计模式和语言特性。

上一节我们介绍了子类型化、代码复用、类型转换以及可见性控制(如公有继承与私有继承)等基础概念。本节中,我们来看看继承在软件设计中的其他关键议题和C++的现代特性。
以下是后续课程中将涵盖的几个重要主题:
- 抽象基类:我们将学习如何定义不能实例化、仅作为接口蓝图的类。
- 多重继承的复杂性:我们将探讨当一个类从多个基类继承时可能引发的歧义和设计挑战。
- 基于继承的软件设计:我们将讨论如何有效地运用继承来构建健壮、可维护的软件架构。
- C++的现代特性:我们将深入了解一些新的关键字和语义,例如:
final关键字(用于防止类被继承或虚函数被重写)。default和delete关键字(用于控制编译器自动生成的特殊成员函数)。- 移动语义:这是一种优化资源管理的新实践,非常有趣且重要。




本节课中我们一起回顾了继承的基础,并预览了后续将深入学习的抽象基类、多重继承的陷阱、基于继承的软件设计方法,以及C++11及之后版本引入的 final、default、delete 和移动语义等现代特性。掌握这些内容对于编写高效、现代的C++代码至关重要。
024:六边形棋项目概述与实现基础
在本节课中,我们将深入探讨本学期的课程项目:实现一款名为“六边形棋”的游戏,并运用AI技术来创造一个智能对手。我们将重点介绍在C++世界中实现这一目标所需的核心技术,特别是继承,这是面向对象编程的标志性特性。

项目目标与核心概念

上一节我们介绍了项目的总体目标,本节中我们来看看实现过程中的一些核心软件和方法论技术。在C++这类语言中,一个关键技术是多态和通过继承实现的代码共享。
继承的公式化描述如下:
class BaseClass {
// 基类成员...
};
class DerivedClass : public BaseClass {
// 派生类成员,继承并可能扩展基类的功能...
};
六边形棋游戏简介
接下来,我们将详细讨论六边形棋游戏。
六边形棋是一个关于连接性的游戏,可以看作是井字棋的一个更复杂的变体。在连接性游戏中,玩家需要在地图上建立一条路径,同时阻止对手建立他们的路径。
第一阶段任务:实现合法游戏逻辑

在你们的第一个作业中,首要任务是编写一个能够进行合法六边形棋对弈的程序。这意味着程序需要能够:
- 检查玩家(包括对手)的落子是否合法。
- 能够自主做出合法的落子。
- 知道游戏如何判定胜负。


在代码的第一个版本中,你们可以先使用一些相当基础的策略。两个关键策略是:
- 进攻:扩展自己的路径。
- 防守:阻止对手建立路径。
因此,实现智能对弈的一个思路是,检测如何为自己建立更长的路径,以及如何阻断对手的更长路径。
以下是关于六边形棋的一些核心思路,网上有很多可以游玩或了解该游戏的网站,建议你们去体验一下。
关键策略:桥梁
在游戏中,一个非常值得融入程序的关键概念是“桥梁”。
桥梁是指棋盘上的两组棋子可以安全连接。例如,蓝色玩家(从北向南连接)可能会暂时跳过某一行。如果红色玩家试图通过在此处落子来阻挡,蓝色玩家可以在下一回合立即通过连接形成桥梁来应对。因此,蓝色玩家可以暂时跳过这一行,只在红色玩家试图阻挡时才做出回应。这种策略被称为“桥梁”,允许玩家在棋盘上跳跃式前进。
棋盘表示:图论方法

在编写程序时,一个自然的想法是利用我们在课程中学到的图论知识来表示六边形棋盘。
一个典型的11x11六边形棋棋盘可以表示为一个图。通常,锦标赛使用11x11或更大的棋盘。有趣的是,如果棋盘小于等于9x9,计算机目前可以做到完美对弈;但当棋盘足够大时,游戏的可能性组合会变得极其庞大,即使超级计算机也难以计算出最佳走法。
在我们的作业中,我们将用图来表示棋盘,因为我们需要大量探索路径,而之前学过的迪杰斯特拉最短路径算法正是智能探索棋盘移动的基础。
在六边形棋中,有两个玩家(例如红色和蓝色),蓝色玩家试图连接东西边界,红色玩家试图连接南北边界。游戏不可能出现平局,因为一方建立路径的同时必然会阻断另一方的路径。
对于一个11x11的棋盘,我们有121个节点。一个有用的表示方法是将其视为一个二维空间,其中左上角坐标为 (0,0),右下角坐标为 (10,10)。我们遵循C语言的索引惯例,从0开始。
坐标与节点编号的映射
我们需要实现从二维坐标 (i, j) 到一维节点编号的映射,并且这个映射应该适用于任意 N x N 的棋盘。
实现这种二维映射的关键是使用取余运算符。这是一个二元整数算术运算符。例如,3 % 10 = 3,35 % 10 = 5(因为35除以10,整数部分是3,余数是5)。
对于一个节点编号,我们可以使用以下公式计算其坐标:
- 行坐标 i = 节点编号 / N (整数除法)
- 列坐标 j = 节点编号 % N (取余运算)
反之,对于坐标 (i, j),其节点编号为:
- 节点编号 = i * N + j
例如,在11x11的棋盘上:
- 坐标 (0,0) 对应节点 0。
- 坐标 (10,10) 对应节点 10*11 + 10 = 120。
- 坐标 (3,5) 对应节点 3*11 + 5 = 38。
- 节点 21 对应坐标 (21/11=1, 21%11=10),即 (1,10)。
理解并实现这个映射非常重要。在程序中,你们可能需要用二维表示来绘制棋盘(因为需要显示棋子位置),同时用图(一维节点表示)来进行内部计算,比如运行路径查找算法。
总结

本节课中我们一起学习了六边形棋学期项目的概述。我们明确了第一阶段的目标是实现一个能进行合法对弈的程序,并引入了基础的进攻和防守策略,特别是“桥梁”概念。我们重点讨论了如何利用图论知识来表示棋盘,并详细讲解了二维坐标与一维节点编号之间相互转换的公式,这是后续实现游戏逻辑和AI算法的基础。接下来,你们将开始动手实现这个映射和基本的游戏框架。
025:11x11六边形棋图的构建与策略
概述
在本节课中,我们将学习如何为11x11的六边形棋游戏构建图数据结构,并探讨游戏结束判定与简单策略的实现。我们将从构建图的节点和边开始,逐步深入到游戏逻辑的实现。

构建图数据结构
上一节我们介绍了六边形棋的基本概念,本节中我们来看看如何为11x11的棋盘构建图数据结构。
如果我们要处理11x11的情况并尝试构建图,可以看到我们将有121个节点。我们将使用自然的二维映射。然后,我们将创建一个节点列表。每个节点将连接一对IJ坐标。我们将把这个数据结构私有地存储在一个向量中。实际上,这也可以是一个向量。
以下是创建内部节点边的关键部分。回想一下,当我们有角落节点时,例如(0, 0)。如果这是一个(0, 0)节点,它只连接到两个节点。但如果我有一个内部节点(I, J),其中I和J不等于0,也不等于10(即不在边界上),那么因为它是一个六边形,它有六个可能的连接:上方的两个、同一行的两个以及下方的两个。这就是所有这些索引所展示的,这是连接的方式。这是将IJ坐标映射到节点编号的方法。这段代码的意思是,将一个特定的边连接推送到节点上。我提供这样一段代码,以便你在实现自己的版本时可以使用它。
// 示例:为内部节点(I, J)添加边连接
// 假设节点编号通过函数 getNodeIndex(i, j) 获得
int currentNode = getNodeIndex(i, j);
// 添加上方连接
if (i > 0) {
adjList[currentNode].push_back(getNodeIndex(i-1, j));
adjList[currentNode].push_back(getNodeIndex(i-1, j+1));
}
// 添加同一行连接
adjList[currentNode].push_back(getNodeIndex(i, j-1));
adjList[currentNode].push_back(getNodeIndex(i, j+1));
// 添加下方连接
if (i < 10) {
adjList[currentNode].push_back(getNodeIndex(i+1, j-1));
adjList[currentNode].push_back(getNodeIndex(i+1, j));
}

你需要处理特殊情况。一般情况是中间的节点有六个连接,而特殊情况不会涉及所有六个连接。回想一下,如果你有角落节点,只有两个相邻节点;如果你有一个非角落的边缘节点,那么你有四个相邻节点。边缘节点,假设你在边上,你有一个上方节点、一个下方节点以及两个内部节点。
游戏逻辑实现
从我们刚才查看的代码中,你将能够获得完整的图表示。接下来,你需要能够测试游戏何时结束以及谁赢了。
以下是你可以使用自己的迪杰斯特拉算法的地方。谁赢是指有人创建了一条从东到西的路径。例如,东边界是所有位于这个边界上的节点的集合。在11x11的情况下,这里有11个东节点和11个西节点。你想看看是否存在从任何东节点到任何西节点的路径。如果你找到一条路径,它甚至不必是最短路径,实际上它将是唯一的路径,因为当这种情况发生时,游戏就结束了。所以,它将是某个东节点到某个西节点的最短路径,这就是你可以使用迪杰斯特拉算法的地方。
然后,我想要的另一件事是像扩展最长路径或进行阻挡这样的简单策略。
节点类型判断
这是你的任务。在你应该已经能够思考的事情中,如何测试一个节点是否为角落节点?花点时间想想。
以下是左上角角落节点的答案。它只是一系列布尔表达式。
bool isUpperLeftCorner = (i == 0) && (j == 0);
现在我们还漏掉了两个。让我看看是否能立刻想起一个。所以还会有另一个像这样的表达式,这同样是逻辑与运算。这将是另一个角落。而最后一个角落将是:

bool isUpperRightCorner = (i == 0) && (j == 10);
bool isLowerLeftCorner = (i == 10) && (j == 0);
bool isLowerRightCorner = (i == 10) && (j == 10);
这样就给出了所有四个角落。
总结

本节课中我们一起学习了如何为11x11六边形棋构建图数据结构,包括内部节点和边缘节点的连接方式。我们还探讨了如何利用图算法(如迪杰斯特拉算法)来判断游戏胜负,并思考了实现简单游戏策略(如扩展路径或阻挡对手)的基础。理解节点坐标映射与邻接关系是实现游戏逻辑的核心。
026:继承机制
概述

在本节课中,我们将要学习C++面向对象编程中的一个核心概念——继承。我们将探讨继承如何实现代码复用,如何创建类之间的层次关系,以及如何使用protected访问修饰符。
继承的概念
上一节我们介绍了面向对象编程的基本思想,本节中我们来看看继承。
继承是编程,特别是C++中的一个主要主题。我们都很熟悉“继承”这个概念。它是生物学的基本信条之一。我们都通过DNA、基因和父母进行遗传和复制。下图展示了某种疾病基因是如何传播及其传播的可能性。

理解生物学中的遗传至关重要,这个概念也出现在面向对象编程中。像Java、C++这类语言从80年代末开始成为主流的编程方式,正是因为继承提供了一种代码共享和实现多态性的方式。多态性能够根据底层对象的类型特征,动态地选择正确的方法来响应行为。如果你使用Smalltalk的术语,就是方法接收消息,并根据类型特征选择正确的方法。所有这些都属于面向对象编程中继承的一部分。
我期望你在学习本课程前已经了解了这些基本概念。
继承的作用
那么,继承能为我们带来什么?它提供了一种从现有类派生出新类的方法。在术语上,那些现有的类被称为基类。
这种派生是实现代码复用的方式。在软件世界中,代码复用是降低成本的“灵丹妙药”。如何降低软件开发成本?答案就是避免编写独特的代码。每当需要为下一个产品或实现编写独特代码时,都必须进行详尽的测试以确保其正确性。如果你有预先存在且经过仔细测试的代码,它就可以像乐高积木一样,成为构建更大系统的组件,从而极大地节省成本。开发新代码容易出错,因此代码复用是一项关键技术。
继承关系就像父母与后代。后代代码通常是父代码的扩展,在原有基础上增加了一些新内容。如果我们不需要任何新东西,就没有理由使用继承,直接使用旧类即可。因此,创建新类通常是因为需要修改某些代码、改变某些行为或增加一些新特性。在接下来的课程中,我们将通过示例来深入了解其工作原理。
当你完成这种数据结构的设计后,你会得到一个继承树。你可以回溯,就像在人类遗传结构中查看不同特征是如何从先辈那里继承下来的一样。这样就创建了一个共享代码和接口的相关类型的层次结构。


示例:大学人员系统
接下来我们将看到一个示例。我们将以一个Student(学生)类作为基类。我们可以从大学的角度来思考这个问题。大学校园里有各种类别的人,有普通学生,也有特殊类别的学生,比如研究生,甚至还可以进一步细分。可能还有像在线学生这样的特殊类别学生,他们具有不同的特征。
实际上,如果你在为大学构建一个大型数据库,你可能会从一个更基础的Person(人)类开始。这样,你不仅可以有Student,还可以有Employee(雇员)。在雇员中,又可以有不同的类别。这为你提供了一个开发模型,用于构建一个能够反映和模拟校园内所有人并实现相关功能的系统。例如,雇员中可能有学术人员(如终身制和非终身制教职员工)和行政人员(如专业职员、业务职员,甚至可能是警察和消防服务人员)。如果一个类别具有显著意义,它就需要自己的类,而这个类会从更基础的类继承而来。
以下是一个Student类的示例。学生的一个特征可能是年级:大一、大二、大三或研究生。学生会有姓名、学生ID(可能类似社保号)、GPA以及所在年级等信息。
这是一个构造函数。假设我们想打印一个学生实例的信息。顺便说一下,我在这里放了三个问号。你应该知道const在这个位置的含义,对吗?这意味着这个指针,也就是这个实例,不会被这个成员函数所改变。
class Student {
public:
// 构造函数和其他成员函数...
void print() const; // const 成员函数,不修改对象状态
private:
// 私有数据成员...
};
Protected访问修饰符
当我们使用继承时,通常不再(或尽量避免)使用private,而是将原本私有的成员改为protected。
protected意味着,当你继承一个类时,这些成员对派生类是可见的。派生类的方法可以直接使用这些信息。
因此,C++中整体的数据隐私方案是:
- public:对所有人可见。
- protected:对派生类可见。
- private:仅对自身类可见,这是限制最严格的。
当然,你也可以通过friend(友元)机制来访问私有部分。但在继承体系中,protected将在家族内部被大量使用。

回顾Const成员函数
回顾一下我刚刚提到的内容。const在那个位置(成员函数末尾)的作用是什么?
它的作用是表明这是一个const成员函数。记住,它通过类似a.print()的方式被调用。这意味着编译器可以检查const的正确性,即对象a的状态不会被这个函数改变。


总结

本节课中我们一起学习了C++继承机制的核心概念。我们了解到继承是实现代码复用和创建类层次结构的关键。通过Student类的例子,我们看到了如何从基类开始设计。我们还重点介绍了protected访问修饰符,它允许派生类访问基类的部分成员,这是在继承体系中实现数据共享的重要方式。最后,我们回顾了const成员函数如何保证对象状态不被修改。理解这些概念是掌握面向对象编程中多态性和大型系统构建的基础。
027:派生类
概述
在本节课中,我们将学习C++中继承的核心概念,特别是公有继承。我们将探讨继承如何实现代码复用,建立子类型关系,并最终支持多态。课程将解释继承的三种形式,但重点在于理解占主导地位的公有继承及其在面向对象设计中的关键作用。
代码复用与“90%规则”
上一节我们介绍了继承的基本思想,本节中我们来看看它是如何具体实现代码复用的。
在C++中,有时完成一件事有多种方法,但通常有一种方法是压倒性的重要方式。这就是所谓的“90%规则”。在继承中,这一规则同样适用。
继承有三种形式:
- 公有继承
- 保护继承
- 私有继承
其中,公有继承就是那“90%规则”。理解公有继承至关重要,因为它是最常用、最有用的形式。私有继承大约占10%,而保护继承作为一种中间形式,通常并不特别有用。

公有继承与子类型关系
当我们使用公有继承时,我们建立了一种子类型关系。这一点非常关键,因为它正是实现多态的基础。
任何使用公有继承的地方,都意味着派生类可以被视为基类。例如,GradStudent(研究生)可以被视为Student(学生)。在一个更大的系统中,可能还有LawStudent(法律学生)、BusinessStudent(商科学生)等,它们都公有继承自Student。这意味着它们都是Student,因此任何可以处理Student的地方,都可以处理这些派生类。
在C++原生语言中也能看到这种关系。例如,整型类型之间:任何可以使用int的地方,也可以使用short或long(尽管可能需要考虑精度和效率)。这同样是一种允许一定程度多态性的类型关系。
派生类的构成

继承结构意味着所有与Student相关的内容都被公开地“导入”到GradStudent中。这就像它们原本就存在于GradStudent中一样。
然后,派生类可以添加新内容或重新定义某些行为。例如,GradStudent类有一个构造函数,并且通常包含额外的信息。因为Student的信息可能不够,几乎所有研究生都有某种形式的资助(TA、RA、奖学金等),或者他们可能有工作。此外,他们还有论文标题、所属院系等特性。这些都是新增到从基类导入的信息之上的。
因此,派生类最终拥有一个更大的信息集合。
class Student {
// ... 成员如 GPA, studentID, name, year, print() ...
};
class GradStudent : public Student { // 公有继承
public:
// 继承了 Student 的所有公有和保护成员
// 新增成员
std::string department;
std::string thesisTitle;
std::string supportLevel;
// 可以重新定义基类函数
void print() const;
};
派生类修改了基类,并继承了其公有和保护成员。私有成员不可见。这里有一个重要的编程要点:当你设计一个可能被用于继承层次的类时,应该考虑将一些成员设为protected而非private。protected对于非派生类来说就像private,但如果这个类被用作基类,派生类就能访问这些成员,而不会损失封装性。
在GradStudent中,所有Student的成员都被继承。例如,Student中的GPA、学号、print函数都被GradStudent继承。但是,正如我们所见,GradStudent重新定义了print函数。因此,现在有两个print函数可用:Student::print()和GradStudent::print()。理解这一点对于掌握面向对象编程至关重要。
继承的好处
我们通过继承获得了什么?毕竟,我们可以从头开始编写一个不继承自Student的GradStudent类。
以下是继承带来的几个关键好处:
- 代码复用:我们无需重写
Student已有的代码。虽然可以通过复制粘贴实现类似效果,但继承提供了更强大的链接。如果未来Student类有错误修复或效率改进,这些更改会自动流入GradStudent,实现了真正的、可维护的代码复用。 - 问题域关系映射:研究生就是学生。这是一种分类学(Taxonomic)关系。好的面向对象设计的关键在于将现实世界的情况映射到软件中。通过继承,我们的代码结构反映了真实世界的概念关系,这有助于划分编码职责,实现最大程度的复用和有效的代码设计。
- 多态机制:这允许将派生类(子类型)视为基类类型进行处理。当你开始大量使用它时,你会发现:一个指向基类(如
Student*)的指针,不仅可以指向Student对象,还可以指向任何公有继承自Student的派生类对象(如GradStudent)。通过这个基类指针,你可以动态地调用适用于不同具体学生类型的行为。这极大地简化了代码,使我们能够维护子类型区别,同时获得更高效、简单、模块化的编码方案。
类型层次、转换与可见性

要深入理解继承,我们还需要了解类型层次、类型转换和可见性问题。
重申“90%规则”:公有派生类是基类的子类型(私有和保护派生则不是)。这意味着存在自动转换的可能性:
- 派生类类型的变量可以被视为基类类型。
- 指向基类类型的指针可以指向整个继承层次中的任何对象。
这对于多态分发至关重要。基类和派生类之间会发生一些隐式转换,需要注意,这有时可能导致问题。
此外,当引入继承后,我们获得了重写基类方法的能力(如GradStudent重写print)。重写的方法通过this指针调用。同时,C++中还存在重载(同一作用域内函数名相同,参数不同)。在具有多重名称和签名的类层次结构中,重载和重写可能同时发生,这可能导致一些困难。在编码时必须非常小心,避免因签名匹配和类型转换的交互作用而引入难以发现的错误。
总结


本节课中,我们一起学习了C++继承的核心,特别是公有继承。我们理解了它如何通过建立子类型关系来实现代码复用,并作为多态性的基石。我们探讨了派生类如何继承并扩展基类的功能,以及继承在映射真实世界关系、简化代码结构方面带来的巨大好处。最后,我们指出了在类型层次结构中需要注意的类型转换和函数重载/重写交互可能带来的复杂性。掌握这些概念是进行有效面向对象编程的关键。
028:打印链式调用
概述
在本节课中,我们将学习如何在链表数据结构中实现“打印”操作。这是一种在链表处理中反复出现的惯用操作,其核心是遍历链表并输出每个节点的值。我们将重点介绍迭代遍历的方法,并简要提及递归实现和运算符重载的概念。
链表打印的迭代方法
上一节我们介绍了链表的基本结构,本节中我们来看看如何遍历并打印链表中的所有元素。
迭代方法的核心是使用一个循环,从链表的头节点开始,依次访问每个节点,直到遇到表示链表末尾的空指针(nullptr)为止。
以下是实现迭代打印的关键步骤:
-
初始化游标:将当前指针设置为指向链表的头节点(
head)。这标志着遍历的开始。Node* current = head; -
循环遍历:使用
while循环,条件是当前指针不为空。这是遍历链表的标准模式。while (current != nullptr) { // 处理当前节点 // ... // 移动到下一个节点 current = current->next; } -
处理与移动:在循环体内,首先处理当前节点(例如打印其存储的值),然后将当前指针更新为指向下一个节点(
current->next)。
通过这种设置,利用空指针作为结束信号,头指针作为起点,我们可以按顺序遍历整个链表容器。如果链表有 n 个元素,时间复杂度是 O(n),这与遍历一个数组的效率相同。因此,当需要搜索整个链表或对其所有元素执行操作时,这种方法非常高效。
其他实现方式
现在我们已经掌握了迭代方法,接下来简要了解其他两种实现链表打印的思路。


- 递归实现:递归方法同样基于链表的结构。其基本情况是检查当前指针是否为空(
nullptr),如果是则返回。一般情况则是:打印当前节点的值,然后对下一个节点递归调用打印函数。其核心思想是“打印并递归推进游标”。 - 运算符重载:在C++中,你还可以通过重载流插入运算符(
<<),使得链表对象能像标准类型一样直接使用cout进行输出。例如cout << myList;。这能提供更符合C++习惯的、直观的输出方式。

这两种方法可以作为练习,帮助你更深入地理解链表和C++特性。
代码示例与分析
为了巩固理解,让我们看一个简单的链表使用示例,并分析其输出。

首先,声明两个链表对象 a 和 b,并使用默认构造函数初始化。我们知道,默认构造函数会将链表的头指针(head)初始化为空指针。
List a, b; // a.head 和 b.head 此时都是 nullptr

接着,我们向链表 a 的前端添加(prepend)两个值:9 和 8。根据 prepend 的逻辑,后添加的 8 会成为新的头节点。
a.prepend(9);
a.prepend(8);
// 此时链表 a 的结构为:8 -> 9 -> nullptr
打印链表 a,我们将看到输出:8, 9,。
然后,我们通过一个循环向链表 b 的前端添加一系列值,这些值是 0 到 39 的平方。
for (int i = 0; i < 40; ++i) {
b.prepend(i * i);
}
由于是向前添加,最后添加的 39*39 会成为链表的头节点。因此,链表 b 最终包含从 1521 (39²) 递减到 0 (0²) 的所有平方数。

打印链表 b,输出将类似于:1521, 1444, ..., 4, 1, 0,。

你应该尝试运行或模拟这段代码,并绘制出链表 a 和 b 在每一步操作后的结构图,以确认你的理解。



总结
本节课中我们一起学习了链表的核心操作之一——打印遍历。我们详细介绍了使用 while 循环和空指针检查的迭代遍历方法,这是处理链表的基础惯用法。我们还简要探讨了递归实现和通过重载 << 运算符来美化输出的可能性。最后,通过一个具体的代码示例,我们观察了 prepend 操作如何构建链表,并预测了其输出结果。掌握这些遍历技术是进行任何复杂链表操作的前提。
029:更精细的列表
在本节课中,我们将学习如何设计一个功能更完善的列表类。我们将探讨标准模板库(STL)中常见数据结构的特性,并了解如何为我们的列表类添加一系列构造函数、析构函数以及赋值操作符。这些功能是构建健壮且高效的数据结构的基础。
上一节我们介绍了简单列表的实现,本节中我们来看看如何为其添加更多标准库中常见的功能。
构造函数
在标准模板库中,数据结构通常会提供多种构造函数,以满足不同的初始化需求。以下是几种常见的构造函数类型。
默认构造函数
默认构造函数不接受任何参数,用于创建一个空列表或具有默认状态的列表。

class List {
public:
List(); // 默认构造函数
};

拷贝构造函数
拷贝构造函数接受一个同类型对象的常量引用,用于创建一个新对象作为原对象的副本。


class List {
public:
List(const List& other); // 拷贝构造函数
};

移动构造函数(高级话题)
移动构造函数是C++11引入的新特性,它通过“移动”资源(而非复制)来高效地初始化新对象。这对于基础编程并非必需,我们将在课程后期深入讨论。
class List {
public:
List(List&& other); // 移动构造函数
};
从其他容器构造的构造函数

有时我们需要从其他容器(如数组)中转移数据来初始化列表。以下构造函数接受一个常量数组及其大小。
class List {
public:
List(const int* arr, size_t size); // 从数组构造
};
参数 arr 被声明为 const 是因为在此构造函数中,我们不会修改数组元素的值。
析构函数

良好的编程实践要求我们为管理资源的类提供析构函数,以确保资源被正确释放。析构函数的名称是在类名前加上波浪号 ~。
class List {
public:
~List(); // 析构函数
};
赋值操作符

接下来,我们将讨论两种复制方式:拷贝赋值和移动赋值。这有助于我们理解在构建复杂聚合类型时,如何在健壮性和效率之间进行权衡。
拷贝赋值操作符
拷贝赋值操作符用于将一个对象的状态复制给另一个已存在的对象。

class List {
public:
List& operator=(const List& other); // 拷贝赋值操作符
};
移动赋值操作符(高级话题)

与移动构造函数类似,移动赋值操作符通过转移资源来实现高效赋值。
class List {
public:
List& operator=(List&& other); // 移动赋值操作符
};
关于深拷贝与浅拷贝的详细讨论,我们将在本讲座的下一部分重点展开。


本节课中我们一起学习了如何为一个列表类设计更完善的接口,包括多种构造函数、析构函数以及赋值操作符。理解这些成员函数是使用和模仿标准模板库风格的关键第一步。
030:深拷贝与浅拷贝
在本节课中,我们将要学习C++中一个非常重要的概念:对象的拷贝。具体来说,我们将探讨两种主要的拷贝方式——深拷贝与浅拷贝(或称引用拷贝),理解它们的区别、适用场景以及如何通过拷贝构造函数来实现它们。


拷贝的概念

上一节我们介绍了拷贝的重要性,本节中我们来看看拷贝的两种主要形式。
在计算中,我们发现的两种主要拷贝形式被称为深拷贝与引用拷贝(或浅拷贝)。

深拷贝是一个相对深刻的概念。我们可以将其与我称之为浅拷贝的概念进行比较。浅拷贝也可以是好的,我并不是说深拷贝比浅拷贝更好,它们是不同的东西。
现实世界的类比
为了更好地理解,让我们思考一下过去在实体图书馆使用《大英百科全书》的日子。
图书馆将《大英百科全书》保存在馆内,并允许用户前来使用。这是因为复制《大英百科全书》非常昂贵。由于它是一种超级昂贵的资源,图书馆只保留一份资源,并允许人们以不损坏、不破坏的方式使用它,试图保持其原始状态。许多人可以同时使用它,毕竟它有很多卷。只要他们使用不同的部分,就可以同时有多个用户,因为他们影响或阅读的是那一份拷贝的不同部分。
另一方面,如果我有足够的资金或资源,并且我需要——比如说——关于艾伦·图灵的条目,我可能会通过深拷贝来获取。我的意思是,我可能会复印它。我可能会复制那份拷贝。现在,我有了自己的拷贝。有了我自己的拷贝,我就可以在上面划线、做笔记,而不会影响必须保持原始状态的原版拷贝。
因此,对于深拷贝,我们拥有一个昂贵的过程,我们实际上是制造了第二个版本。对于浅拷贝,我们有多个用户,他们可以共享对同一资源的引用。在列表的例子中,我们就有游标,所以每个人都可以有一个指向《大英百科全书》的游标,在不同的点使用它,因为他们都不会进行修改。
所以,深拷贝与浅拷贝的问题是使用、成本和修改的问题。
深拷贝详解
在本次讲座的剩余部分,我们将讨论深拷贝。
深拷贝是最安全的。我可以提供一个全新的拷贝,我可以制造另一个你需要的数字版本,然后你可以随心所欲地以任何方式使用它,因为你对它的使用,即使你修改了它,也不会影响我对它的使用。这是高层次的视角。
现在,当我们进行拷贝时,我们需要关注的是拷贝构造函数。拷贝构造函数将决定我们进行的是浅拷贝(引用拷贝)还是深拷贝。
在引用拷贝的概念中,假设我们有一个列表。假设我创建了一个很大的列表,也许它包含数万个列表元素。现在我想在两个不同的地方使用它。只要我不打算修改它,我可以通过创建另一个指向该列表的指针来进行浅拷贝,并用两个独立的游标或指针来遍历列表。然而,这两个“拷贝”(伪拷贝,即我们拥有另一个指向它的指针)共享同一份数据。进行引用拷贝的成本非常低,实际上它只需要一个指针,这通常是一个4字节的简单变量和一个地址值。
但是,如果我要求几个人使用它,并且他们可能会修改各自的拷贝,那么我就必须复制那数万个元素并重新生成它们。这时,拷贝构造函数就必须涉及堆内存操作。
还记得我们在打印方法中使用的那个while循环惯用法吗?之前我们看过打印方法,你可以回顾一下。你会看到我们使用while循环遍历,对于新列表中的每个元素,我们都会为旧列表中所需的每个元素创建一个新元素。这就是一个拷贝构造函数的例子。
实现深拷贝的拷贝构造函数

以下是这样一个拷贝构造函数的示例。首先是一个简单的情况,即列表为空,复制一个空列表非常简单。
// 假设的链表节点结构
struct ListNode {
int data;
ListNode* next;
// ... 其他成员
};
// 拷贝构造函数示例(深拷贝)
LinkedList::LinkedList(const LinkedList& other) {
if (other.head == nullptr) {
head = nullptr;
return;
}
// 复制第一个节点
head = new ListNode;
head->data = other.head->data;
ListNode* current = head;
ListNode* otherCurrent = other.head->next;
// 循环复制剩余节点
while (otherCurrent != nullptr) {
current->next = new ListNode;
current = current->next;
current->data = otherCurrent->data;
otherCurrent = otherCurrent->next;
}
current->next = nullptr; // 设置尾节点
}
你可以看到,拷贝的代价与列表的大小成正比。再次提醒,如果你有C++11编译器,也可以在适当的地方使用nullptr进行初始化。
这也可以用while循环等价实现。我们有一个游标,一个列表头。当游标不等于nullptr时,我们链接并创建新节点。我们需要使用new操作符。我将让你来填写具体的代码。这同样是深拷贝。因为对于这个列表,现有的列表头,新的列表头head2将只是遍历原列表。这里会有一个游标,它将依次复制每个元素,直到完成。
以下是内部代码的框架:
ListNode* cursor = other.head;
ListNode* newHead = nullptr;
ListNode* tail = nullptr;
while (cursor != nullptr) {
ListNode* newNode = new ListNode; // 创建新节点
newNode->data = cursor->data; // 复制数据
newNode->next = nullptr;
if (newHead == nullptr) {
newHead = newNode; // 如果是第一个节点,设为头节点
tail = newNode;
} else {
tail->next = newNode; // 链接到链表尾部
tail = newNode;
}
cursor = cursor->next; // 移动到原列表的下一个节点
}
head = newHead; // 将新链表的头赋值给当前对象的head
这个内部循环让我们创建了整个列表。所以,这仅仅是填充了拷贝构造函数的代码,你可以跟着它走一遍,并且应该测试它以确保理解。你也可以用其他方式实现,比如用while循环练习,或者用递归形式练习。
总结

本节课中我们一起学习了C++中的深拷贝与浅拷贝。我们了解到,浅拷贝(引用拷贝) 仅复制指针或引用,多个对象共享同一份底层数据,成本低但存在修改冲突的风险。而深拷贝会复制所有底层数据,创建一个完全独立的新对象,成本高但安全,修改不会相互影响。拷贝构造函数是实现这两种拷贝行为的关键。选择哪种方式取决于具体需求:是否需要独立修改数据,以及对性能的要求。理解并正确实现拷贝对于管理内存和避免程序错误至关重要。
031:析构函数与内存管理
在本节课中,我们将学习C++中关于内存管理的重要概念,特别是析构函数的作用,以及如何使用new和delete运算符来避免内存泄漏。我们还将通过一个链表类的例子,来观察构造函数和析构函数在实际程序中的调用时机。
聚合对象与良好实践

上一节我们介绍了构造函数,本节中我们来看看与对象生命周期结束时相关的析构函数。当我们处理聚合对象时,良好的编程实践至关重要。
对于聚合对象而言,良好的实践意味着:任何我们从堆上创建的东西,任何使用new运算符分配的内存,我们都必须负责释放。
new运算符会从堆上为我们分配大量内存。

我们也需要相应地释放这些内存。C++的一个优点是,它的分配/释放模型与C语言不同。
如果你熟悉C语言编程,你会记得在C中,分配内存使用malloc,释放内存使用free。
在C++中,我们使用new进行分配,使用delete进行释放。基本上,我们有:
new+ 类型:例如new Typenew+ 类型数组:例如new Type[n]
以及对应的释放操作:
- 对于单个对象:
delete p; - 对于数组:
delete [] p;
系统会在两种情况下正确处理,因此非常方便。所以,实际上没有理由造成内存泄漏。
顺便提一下,对于习惯使用Unix和Linux的开发者,有一个非常好的开源程序叫做valgrind。


我强烈建议你使用这个程序(如果它适用于你的环境),并确保它报告你的程序没有内存泄漏。这也会极大地提升你的编程实践水平,因为你越关注分配和释放,并对其运行机制有恰当的理解,你在进行C++编程时就会越谨慎。
示例:链表类的构造与析构
让我们使用下面的代码示例。这段代码会调用你的默认构造函数。
这里有一个普通的数组:


我们在这个更复杂的链表类中调用构造函数,它接收数据并将其转换到我们的链表中。
在这个转换过程中,我们期望看到类似 3, 4, 6, 7, -3, 5 的输出。注意,没有指针,那是你的第六个元素。这是一个单链表。
在这个转换中,对于列表E,它有10个元素。但我们看不到10个值。
记住当你使用这样的初始化列表时会发生什么:剩余的值实际上会被初始化为零。
对于列表A,我们进行了几次prepend(前插)操作,然后开始打印输出。你应该能够:

跟得上所有这些情况下打印输出的逻辑。因此,你应该能够知道会打印出什么,以及:


析构函数在何时被调用。
总结

本节课中我们一起学习了C++内存管理的核心。我们了解了使用new和delete进行堆内存分配与释放的配对关系,这是避免内存泄漏的关键。通过一个链表类的实例,我们观察了构造函数如何构建对象,并强调了析构函数在对象生命周期结束时释放资源的重要性。记住,良好的编程习惯包括总是为你new分配的内存配对相应的delete,并利用工具如valgrind来验证程序没有内存泄漏。
032:STL中的动态数据结构
在本节课中,我们将要学习C++标准模板库(STL)中的两种核心动态数据结构:vector和list。我们将探讨它们的工作原理、适用场景以及如何在实际编程中有效地使用它们。
理解和使用列表(List)
上一节我们介绍了链表的基本概念,本节中我们来看看如何实际操作一个列表结构。

你可以对一个列表结构进行多种操作。你需要真正理解它的工作原理,特别是要理解删除操作何时发生。


以下是操作列表的一些关键点:
- 你可以尝试在堆上动态创建列表。例如,使用
new list,然后原则上必须使用delete list来释放内存。 - 或者,当列表对象超出作用域时,其析构函数会被自动调用。
- 如果你希望,可以在析构函数中添加打印语句,以便实际观察其何时被调用。如果这个概念对你来说是全新的,这将非常有用。

我建议你对此进行大量练习。另一件非常有用的练习是,回忆我要求大家具备基础的计算机科学和数据结构知识。所有具备该背景的同学,都应该能够轻松地将之前提到的单链表转换为双链表。

标准模板库(STL)中的容器
现在,我们进入下一个主题:标准模板库。STL确实包含列表容器。实际上,在C++11及以后的版本中,它同时包含了单链表(forward_list)和双链表(list)。
但STL中最主要的数据结构,也是我希望大家重点学习的,是向量(vector)。
向量(Vector):最常用的容器
从我的角度来看,向量(vector) 能为你提供90%的价值。所以,如果你学会了使用 vector,你就掌握了容器类(或者更准确地说,顺序容器类)90%的精髓。
在传统的C语言中,我们使用非常原始的原生数组来处理一切。原生数组非常基础。请记住,C语言是一种系统实现语言,并非用于大量数据处理,因此里奇(Ritchie)并未投入太多精力让数组变得易于使用。所以,数组本质上只是一个指针,指向一块分配好的内存。这块内存可以通过 calloc 或 malloc 从堆上分配,也可以通过基于栈的特定声明(在栈入口处已知一个整数常量大小的内存)来分配。这是一个非常简单的方案,因为作为系统实现语言,它不需要更复杂的功能,但对于进行非常复杂、数据密集的计算来说,这远远不够且容易出错。
因此,最早也是最成功的容器类型之一,就是对数组的一种抽象。在这种抽象中,你不仅拥有动态调整数组大小的能力,还拥有确保不会发生任何索引错误的方法,因为你可以知道向量的起始和结束位置。


所以,成为一个真正的C++程序员的建议是:放弃使用基础的C语言数组,并将所有相关的处理转换为使用 vector。
列表(List)与向量(Vector)的对比
STL中也有列表(list),它是另一种顺序容器。当你选择 list 而不是 forward_list 时,你得到的是双链表。双链表看起来就像我们刚才查看的代码,区别在于每个节点会包含一个数据元素、一个指向前驱节点的指针(previous pointer)以及一个指向后继节点的指针(next pointer)。这使得可以双向遍历链表,让许多算法变得更加方便,因为有时你可能需要停止然后回退,而不希望总是必须从链表的开头重新开始。
因此,列表(list) 非常适合于插入和删除等操作。但是,当你需要获取链表中深处第N个元素时,它的效率就不高了,因为这是一个O(n)复杂度的计算。相比之下,向量(vector) 进行此类查找是O(1)复杂度,效率非常高。
所以,在一般的数据处理领域,向量(vector) 往往占据主导地位,因为它具有随机访问的能力。但是,列表(list) 在需要频繁进行插入和删除操作,并且数据集可能扩张也可能收缩的场景中,有着非常重要的用途。在这种情况下,效率的天平会从 vector 向 list 倾斜。实际上,更复杂的操作通常会同时使用这两种类型的容器类。这些在STL中都是可用的,你应该逐渐习惯使用它们。
关于课程项目的讨论


如果你在论坛上,可能想讨论一下我们如何完成作业二。记住,作业二是实现迪杰斯特拉(Dijkstra)最短路径算法,其中你需要随机生成图。这些图不仅是随机生成的,还具有一个“密度”参数。密度指的是平均边数占完全图可能边数的比例。
在一个大小为 n 的完全图中,从任何节点出发最多可以有 n-1 条边。如果你想要10%的密度,那么平均每个节点将拥有10%的边数。例如,如果你有101个节点,10%的密度意味着平均每个节点大约有10条边。这将作为我们用于测试迪杰斯特拉算法的图生成算法的一个输入参数。

总结
本节课中我们一起学习了C++ STL中的两种核心动态数据结构。我们了解了向量(vector) 作为动态数组的抽象,提供了高效的随机访问和动态大小调整,是大多数场景下的首选。我们也探讨了列表(list) 作为双链表的实现,在频繁插入和删除操作中表现出色。理解它们各自的优势和适用场景,并学会在编程实践中合理选择,是掌握C++高效数据处理的关键一步。
033:最小生成树(MST)


概述
在本节课中,我们将要学习最小生成树算法。这是继Dijkstra算法之后的下一个重要算法,也是第三次作业的基础。我们将继续使用图这一抽象数据类型,并在此基础上添加新的功能。同时,我们也会探讨C++中的面向对象编程,特别是如何通过自定义类型(如class Point)和枚举类型来增强代码的结构性和安全性。
上一节我们完成了Dijkstra算法的实现,对图的处理有了一定的熟悉度。本节中我们来看看如何构建一个连接图中所有节点的、总成本最小的树状结构。
最小生成树定义
首先,我们需要明确什么是生成树。一个生成树是图中一个边的子集,它满足两个条件:
- 它连接了图中的所有节点。
- 它不包含任何环。
对于一个有 n 个节点的图,其生成树恰好包含 n-1 条边。
在此基础上,最小生成树 是指在所有可能的生成树中,其所有边的权重(或成本)之和最小的那一棵。每条边都有一个正的成本值。

在本次作业中,我们还会为边添加一个额外的属性:颜色。一条边可以是红色、蓝色或绿色。这意味着问题将变为:寻找满足特定颜色要求(例如,所有边都是绿色,或包含红蓝两色)的最小生成树。这增加了问题的复杂性,也更能体现C++编程中类型处理的能力。
关于枚举类型的测验
为了处理边的颜色,我们需要使用C++的枚举类型。让我们先通过一个小测验来回顾相关知识。
假设我们有一个简单的枚举类型定义:
enum Color {RED, BLUE, GREEN};
以下是几个问题:
- 为什么枚举值(如
RED)通常用大写字母? - 在这个定义中,
GREEN的内部值是多少? - 如何重载输出操作符
<<来打印Color类型的值(例如打印出“RED”)?
现在,我们来逐一解答。
首先,使用大写字母是编程社区的一种约定,用于标识常量。这继承了C语言的风格,有助于在代码中快速识别常量。
其次,枚举值默认从0开始顺序赋值。因此,RED是0,BLUE是1,GREEN是2。
最后,重载输出操作符<<有一个固定的惯用格式。我们需要返回一个ostream&的引用,以支持链式输出。
以下是重载<<操作符打印Color类型的示例代码:
std::ostream& operator<<(std::ostream& os, const Color& c) {
switch(c) {
case RED: os << "RED"; break;
case BLUE: os << "BLUE"; break;
case GREEN: os << "GREEN"; break;
default: os << "Unknown Color";
}
return os;
}
C++11中的强类型枚举
在传统的C++枚举中,不同的枚举类型在底层都是整数,它们之间可能发生意外的比较和转换,这可能导致错误。
C++11引入了枚举类,它提供了更强的类型安全。使用enum class定义的枚举,其枚举值是独立的类型,不能隐式转换为整数,也不能与不同枚举类型的值进行比较。
以下是一个例子,展示了传统枚举与枚举类的区别:
// 传统枚举 - 类型不安全
enum Color {RED, BLUE, GREEN};
enum TrafficLight {RED, YELLOW, GREEN}; // 错误!RED和GREEN重定义
// C++11 枚举类 - 类型安全
enum class Color {RED, BLUE, GREEN};
enum class TrafficLight {RED, YELLOW, GREEN}; // 正确,作用域不同
Color c = Color::RED;
TrafficLight t = TrafficLight::RED;
// if (c == t) ... // 错误!无法比较不同的枚举类类型
// int i = c; // 错误!不能隐式转换为int
int j = static_cast<int>(c); // 正确,需要显式转换
使用enum class可以避免命名冲突,并使代码的意图更清晰、更安全。在实现本次作业的颜色功能时,推荐使用枚举类。
总结
本节课中我们一起学习了最小生成树的基本概念,它是一棵连接图中所有节点且总边权最小的树。我们明确了生成树必须包含n-1条边且无环。本次作业的特别之处在于引入了边的颜色属性,这要求我们在寻找MST时考虑额外的约束条件。
为了处理颜色,我们回顾了C++中的枚举类型,并重点介绍了C++11提供的、更安全的enum class(枚举类)。枚举类能防止不同枚举类型之间的意外比较和转换,提升了代码的健壮性。我们还学习了如何为自定义枚举类型重载输出操作符<<,以便进行方便的打印。

这些知识将为完成第三次作业——实现一个考虑颜色约束的最小生成树算法——打下坚实的基础。接下来,你就可以开始构思如何将颜色作为边的一个属性,并修改经典的MST算法(如Prim或Kruskal算法)来满足新的要求了。
034:Jarnik-Prim算法详解
在本节课中,我们将详细讨论最小生成树算法。我们将重点介绍Jarnik-Prim算法,并将其与Kruskal算法进行比较,以便理解它们的工作原理和适用场景。
算法起源与背景
最小生成树算法最早由捷克数学家Jarnik在1930年的一篇论文中提出。然而,由于该论文在当时较为冷门,工程师和计算机科学家通常将首次应用此方法的算法归功于Prim,他在1957年重新发现并发表了该算法。
我们将把这个算法与另一个由离散数学家和计算机科学家Kruskal在大致同一时期发现的算法进行比较。虽然这两种算法都能给出正确的结果,但在某些情况下,其中一种可能比另一种更高效。它们各有优势,因此同时掌握两种算法的实现是有益的。
Prim算法核心思想
Prim算法从一个单个顶点开始构建生成树。例如,你可以选择顶点A(或在C语言风格的表示中,顶点0)作为起点。
算法的核心步骤是:从当前已构建的树(称为“闭合集”)出发,寻找连接该树与外部顶点(“开放集”)的权重最小的边,并将该边及其连接的顶点加入树中。
算法公式化描述:
- 初始化:选择任意顶点
v加入树T。 - 循环,直到
T包含所有顶点:- 在连接
T内顶点与T外顶点的所有边中,找到权重最小的边e = (u, w),其中u在T内,w在T外。 - 将边
e和顶点w加入树T。
- 在连接
- 最终,
T即为最小生成树。
重要注意事项:如果从起始顶点出发没有边(即图不连通),则图不存在最小生成树。在实现算法时,需要处理这种情况,例如通过返回一个特殊值(如极大整数、负数或NaN)来报告图是不连通的。

算法图解与步骤分析
上一节我们介绍了Prim算法的核心思想,本节中我们通过一个具体例子来一步步分析其执行过程。
假设我们有以下带权图,并选择顶点A作为起点:

以下是算法的执行步骤:
- 起点:从顶点A开始。闭合集 = {A}。
- 第一步:从A出发,可达的边有A-E(权重2)和A-F(权重2)。我们选择A-E(可任意打破平局)。闭合集更新为 {A, E},累计成本 = 2。
- 第二步:现在,连接闭合集{A, E}与外部顶点的边有:E-G(4)、E-D(6)、E-I(4)、A-F(2)。最小权重为2,选择边A-F。闭合集更新为 {A, E, F},累计成本 = 2 + 2 = 4。
- 第三步:连接闭合集{A, E, F}与外部顶点的边有:E-G(4)、E-D(6)、E-I(4)、F-I(5)。最小权重为4,选择边E-G(或E-I,可打破平局)。闭合集更新为 {A, E, F, G},累计成本 = 4 + 4 = 8。
- 第四步:连接闭合集{A, E, F, G}与外部顶点的边有:E-D(6)、E-I(4)、F-I(5)、G-H(3)。最小权重为3,选择边G-H。闭合集更新为 {A, E, F, G, H},累计成本 = 8 + 3 = 11。
- 第五步:连接闭合集{A, E, F, G, H}与外部顶点的边有:E-D(6)、E-I(4)、F-I(5)、H-D(5)、H-I(4)。最小权重为4,选择边H-I(或E-I)。闭合集更新为 {A, E, F, G, H, I},累计成本 = 11 + 4 = 15。
- 第六步:连接闭合集{A, E, F, G, H, I}与外部顶点的边有:E-D(6)、H-D(5)、I-D(4)。最小权重为4,选择边I-D。闭合集更新为 {A, E, F, G, H, I, D},累计成本 = 15 + 4 = 19。
此时,我们得到了 n-1 = 6 条边,构成了一个完整的最小生成树,总成本为19。
算法特性总结
通过以上过程,我们可以总结Prim算法的几个关键特性:
- 贪心策略:算法每一步都选择当前看来最优的边(权重最小),这是一种典型的贪心算法,与Dijkstra最短路径算法类似。
- 避免环路:算法只考虑从已构建的树(闭合集)连接到外部顶点的边,永远不会回头连接树内部的顶点,因此自然避免了环路的产生。
- 构建单一组件:Prim算法从一个顶点开始,逐步扩展成一个单一的连通树。这与Kruskal算法不同,后者可能同时维护多个子树(森林),最后再合并。
理解测验
为了检验你对算法的理解,请思考以下问题:
如果一个具有10个顶点的连通图,其所有边的权重均为1,那么它的最小生成树的总成本是多少?

根据最小生成树的定义,一个包含n个顶点的树有 n-1 条边。因此,对于10个顶点的图,最小生成树需要9条边。由于每条边成本为1,总成本就是 9。
将其推广:对于一个具有 N 个顶点、边权均为1的连通图,其最小生成树的总成本为 N-1。


课程总结

本节课中我们一起学习了Jarnik-Prim最小生成树算法。我们了解了它的历史背景、核心思想以及逐步执行过程。关键点在于,Prim算法从一个顶点出发,以贪心的方式不断选择连接当前树与外部顶点的最小权重边,从而逐步构建出最小生成树。我们还通过一个具体例子和测验加深了对算法原理和结果的理解。在后续课程中,我们将学习另一种构建最小生成树的方法——Kruskal算法。
035:Jarnik-Prim最小生成树算法再探
在本节课中,我们将深入学习Jarnik-Prim最小生成树算法。我们将通过一个具体的例子,手动画出算法的执行过程,理解其核心的“贪心”思想,并探讨其与其它复杂问题(如旅行商问题)的区别。掌握手动画图分析的能力,对于正确理解和编写算法代码至关重要。

算法执行过程演示

上一节我们介绍了最小生成树的概念,本节中我们来看看Prim算法在一个具体图上的执行步骤。我们将从顶点A开始。


首先,从顶点A出发。可以选择连接H或B。AB的权重为4,小于AH的权重8,因此选择边AB。

现在,最小生成树包含了A和B。此时,可以从集合{A, B}连接到外部顶点的边有:AC(权重11)、AH(权重8)、BC(权重8)、BD(权重2)。其中权重2的边BD是最小的,因此选择边BD。

将顶点D加入树中。现在可选的边有:AC(11)、AH(8)、BC(8)、DF(4)、DE(7)。权重4的边DF最小,选择边DF。

将顶点F加入。可选边更新为:AC(11)、AH(8)、BC(8)、DE(7)、FE(1)。权重1的边FE最小,选择边FE。
将顶点E加入。可选边更新为:AC(11)、AH(8)、BC(8)、DE(9)、EC(10)。权重7的边DE(此处应为从已访问集合到E的边已选,这里指从D到E的边权重7,但E已加入,实际应看从{E,D,F}出发的新边)实际上,此时从已访问集合{A,B,D,F,E}出发,可连接的新边是:AC(11)、AH(8)、BC(8)、EC(10)。权重8的边AH或BC最小(出现平局)。我们选择边AH。
将顶点H加入。最后,连接剩下的顶点C,通过边HC(权重7)或BC(8)或AC(11)。选择权重7的边HC。
至此,算法结束。我们得到了一个最小生成树。计算总权重:AB(4) + BD(2) + DF(4) + FE(1) + AH(8) + HC(7) = 26。注意:此处根据描述和图示,计算总和应为4+2+4+1+8+7=26,但原文中计算为37,可能存在口误或图示与描述不完全对应的情况,我们以当前推导的26为准。关键在于理解过程。
算法的关键特性
在刚才的演示中,我们在第一个选择后遇到了平局(边AH和BC权重都是8)。算法的选择顺序会影响最终生成的树形,但不会影响总权重。以下是关于Prim算法的一些重要说明:
- 起点选择:算法可以从任意顶点开始,最终得到的最小生成树总权重相同。
- 平局处理:当有多条边权重相同时,任意选择其中一条,这可能导致生成不同的最小生成树。
- 解的唯一性:最小生成树的总成本是唯一的,但构成树的边可能不唯一,即可能存在多个不同的最小生成树。
算法伪代码与分析
理解了手动执行过程后,我们来看看如何用结构化的伪代码来描述Prim算法。这有助于我们后续将其转化为C++代码。
以下是Prim算法的一个典型伪代码框架:
输入:图 G = (V, E),边权值
输出:最小生成树 T
1. 初始化树 T 为空集
2. 选择一个起始顶点 s,将其加入 T 的顶点集
3. 将与 s 相连的所有边加入一个优先队列(或待选边集合)Q
4. WHILE T 中的顶点数 < |V|:
a. 从 Q 中取出权重最小的边 e = (u, v),其中 u 在 T 中,v 不在 T 中
b. 将边 e 和顶点 v 加入 T
c. 对于顶点 v 的每条邻接边 (v, w),如果 w 不在 T 中,则将边 (v, w) 加入 Q
5. 返回 T
核心概念:
- 贪心策略:算法在每一步都选择当前可用的、权重最小的边。这种“局部最优”的选择策略被称为贪心算法。
- 数据结构:高效实现的关键是使用优先队列(最小堆)来快速获取权重最小的边。其操作复杂度通常为 O(log n)。
- 算法复杂度:使用优先队列的Prim算法时间复杂度约为 O(E log V),其中E是边数,V是顶点数。
与旅行商问题的对比
Prim算法作为一种高效的贪心算法,总能找到全局最优解(最小生成树)。然而,并非所有贪心策略都能保证全局最优。
一个著名的例子是旅行商问题:要求找到一个访问所有城市恰好一次并回到起点的、总距离最短的环路。

- 如果对TSP使用类似的“最近邻”贪心法(总是前往最近的未访问城市),通常无法得到全局最优解。
- 旅行商问题是NP-Hard问题,目前没有已知的在多项式时间内总能找到最优解的算法(除非P=NP)。
- 因此,研究重点在于设计既能高效运行,又能给出最优或近似最优解的启发式算法。
本节课中我们一起学习了Jarnik-Prim最小生成树算法的详细执行步骤,理解了其贪心本质和伪代码实现,并认识了它能保证全局最优的特性。同时,我们也了解了它与旅行商问题的根本区别,后者通常无法用简单的贪心法完美解决。掌握这种手动画图和分析算法的能力,是独立编写正确、高效代码的基础。
036:Kruskal算法 🧩

在本节课中,我们将要学习Kruskal算法。这是一种用于在加权连通图中寻找最小生成树的贪心算法。我们将了解它的工作原理、它与Prim算法的区别,并通过一个例子来演示其执行过程。
算法核心思想 🌲
上一节我们介绍了Prim算法,它从一个顶点开始逐步扩展生成树。本节中我们来看看Kruskal算法,它的思路有所不同。
Kruskal算法并不计算单一的连通分量。Prim算法是计算一个单一的连通分量,其贪心思想是选择连接当前生成树和外部顶点的最小权重边。Kruskal算法则构建一个森林,即一组树。初始时,每个顶点都被视为一个独立的单节点树,它们尚未连接。当你添加一条边时,你选择森林中的两棵树,并将它们合并成一个连通分量。
算法步骤 📝
以下是Kruskal算法的基本步骤:
- 初始化:将图中的所有边按权重从小到大排序。
- 创建森林:将每个顶点初始化为一个独立的树(即一个独立的集合)。
- 迭代添加边:按顺序检查排序后的边列表。对于每条边,检查它连接的两个顶点是否属于森林中不同的树。
- 如果属于不同的树,则添加这条边,将两棵树合并。
- 如果属于同一棵树(即添加后会形成环路),则丢弃这条边。
- 终止条件:当添加的边数达到
顶点数 - 1时,算法结束,此时森林合并为一棵树,即最小生成树。
算法复杂度与关键点 ⚙️
Kruskal算法的核心操作是排序和检查环路。对 E 条边进行排序的时间复杂度为 O(E log E)。检查两个顶点是否属于同一棵树(即是否形成环路)通常使用并查集数据结构来实现,其单次操作的均摊时间复杂度接近常数。因此,算法的总时间复杂度主要由排序步骤决定。
为什么不能简单地选择排序后前 n-1 条最小的边来构成生成树?因为这些边可能会在已形成的连通分量内部形成环路,从而破坏树的结构。因此,算法必须包含检查环路的步骤。
实例演示 🎯
让我们通过一个具体的图来演示Kruskal算法的执行过程。假设我们有以下带权图,边已按权重排序:
- AD (5)
- CE (5)
- DF (6)
- AB (7)
- BE (7)
- EF (8)
- BC (8)
- BD (9)
- EG (9)
- FG (11)
- DE (15)
执行过程:
- 初始时,所有顶点都是独立的树。
- 选择最小边 AD (5)。添加,森林中A和D合并。
- 选择下一条边 CE (5)。添加,C和E合并(与AD所在的树无关)。
- 选择下一条边 DF (6)。添加,D(已与A连接)和F合并。
- 选择下一条边 AB (7)。添加,A(已与D、F连接)和B合并。
- 选择下一条边 BE (7)。添加,B(已与A、D、F连接)和E(已与C连接)合并。此时,森林中大部分顶点已连通。
- 选择下一条边 EF (8)。检查发现,E和F已经属于同一棵树(通过B-A-D-F路径连通)。添加此边会形成环路,因此丢弃。
- 选择下一条边 BC (8)。检查发现,B和C已经属于同一棵树(通过B-E-C路径连通)。添加此边会形成环路,因此丢弃。
- 选择下一条边 BD (9)。检查发现,B和D已经属于同一棵树。添加此边会形成环路,因此丢弃。
- 选择下一条边 EG (9)。添加,E(已在树中)和G合并。
- 此时,已添加的边数为 6(AD, CE, DF, AB, BE, EG),恰好等于顶点数7减1。算法结束。
最终得到的最小生成树总权重为 5 + 5 + 6 + 7 + 7 + 9 = 39。
与Prim算法的对比 🔄
使用Prim算法从顶点A开始,最终也会得到总权重为39的生成树,但边的添加顺序不同。Prim的添加顺序可能是:AD(5), DF(6), AB(7), BE(7), CE(5), EG(9)。这体现了两种算法构建过程的差异:Prim是“由点及面”的扩展,而Kruskal是“由边及体”的合并。
算法的变体:最大生成树 ❓
如何修改Kruskal算法来寻找最大生成树(即权重和最大的生成树)?思路非常简单:只需在第一步排序时,将边按权重从大到小排序即可。算法的其余逻辑(检查环路、合并树)完全不变。这样,算法就会优先添加权重最大的有效边,最终得到最大生成树。

总结 📚

本节课中我们一起学习了Kruskal算法。我们了解到它是一种基于边的贪心算法,通过排序所有边并利用并查集高效检查环路,来构建最小生成树。它与Prim算法目标一致,但策略不同:Kruskal维护一个不断合并的森林,而Prim维护一个不断生长的单树。理解这两种经典算法,能帮助我们更深入地掌握图论中最小生成树问题的求解思路。
037:重载和函数选择


概述 📖
在本节课中,我们将学习C++中的两个核心概念:运算符重载和友元函数。我们将探讨如何通过运算符重载让自定义类型像内置类型一样工作,以及如何使用友元函数来访问类的私有成员以实现更灵活的运算符重载。
运算符重载与类型转换 🔄
上一节我们介绍了图算法作业的扩展。本节中,我们来看看C++中一个非常巧妙但颇具争议的特性:运算符重载。
运算符重载允许我们为现有的C++运算符赋予新的含义。我们已经在IO库中见过这个特性,例如使用左移运算符 << 进行输出。一个高级原则是:用户定义的类型应该与内置类型难以区分。运算符重载和类型转换是实现这一原则的机制。
例如,对于内置的整数类型,a + b 的含义是明确的。如果我们定义了一个表示有理数的类 Rational,我们也希望能够使用 c + d 来对两个有理数进行加法运算。更进一步,我们可能还希望 a + c 能够工作,其中 a 是整数,c 是有理数。这就需要整数到有理数的隐式转换。
转换构造函数
我们以一个简单的 Point 类为例。Point 表示二维空间中的一个点,其内部表示为一对 double 值。
class Point {
private:
double x, y;
public:
Point(double x_val = 0, double y_val = 0) : x(x_val), y(y_val) {}
};
这个构造函数 Point(double x_val, double y_val = 0) 也是一个转换构造函数。它允许将单个 double 值隐式转换为一个 Point 对象(y坐标默认为0)。
Point s; // s 是 (0, 0)
double d = 3.5;
s = d; // 隐式转换:d 被转换为 Point(3.5, 0.0),然后赋值给 s
禁止隐式转换
有时我们不希望发生隐式转换,因为这可能导致意外的函数调用匹配。我们可以使用 explicit 关键字来关闭隐式转换。
explicit Point(double x_val = 0, double y_val = 0) : x(x_val), y(y_val) {}
添加 explicit 后,s = d; 将导致编译错误,必须使用显式转换:s = Point(d);。
类型转换运算符
我们如何将 Point 转换为 double 呢?我们不能修改内置的 double 类型。C++的解决方案是在 Point 类中定义一个类型转换运算符。
class Point {
// ... 其他成员
public:
operator double() const {
return sqrt(x*x + y*y); // 例如,转换为到原点的距离
}
};
现在,Point 对象可以在需要 double 的上下文中被隐式转换。但请注意,这种转换的语义(例如,点到原点的距离)必须清晰且符合该类型在应用领域中的自然含义,否则应使用一个具有明确名称的成员函数(如 length())来代替。
函数重载与选择规则 🎯
C++支持函数重载,即多个函数可以共享同一个名字,只要它们的参数列表(签名)不同。编译器需要一套规则来决定调用哪个函数。
以下是编译器选择重载函数的优先级顺序:
- 精确匹配:参数类型与函数签名完全一致。
- 标准提升匹配:例如,
int可以提升为double。 - 标准转换匹配:包括更宽泛的数值转换。
- 用户定义转换匹配:例如,我们为
Point定义的转换。 - 匹配省略号(...):这是最差的匹配。
当存在多个可行的匹配时,如果编译器无法确定一个“最佳”匹配,就会产生“歧义”错误。用户定义的转换和隐式构造函数使得重载解析变得复杂,因此需要谨慎使用 explicit 关键字来避免意外的转换。
友元函数与运算符重载 🤝
上一节我们介绍了类型转换。本节中,我们来看看如何通过友元函数来实现更通用的运算符重载。
为什么需要友元?
考虑为 Point 类重载输出运算符 <<。我们希望这样使用:
Point q(1.0, 2.5);
std::cout << q; // 期望输出: (1.0, 2.5)
如果我们将其实现为 Point 的普通成员函数:
// 在类内声明
std::ostream& operator<<(std::ostream& os);
调用时将是 q.operator<<(std::cout),这与我们期望的 std::cout << q 语法顺序不符。此外,成员函数的第一个隐式参数是 this 指针,这限制了重载的灵活性。
友元函数的定义
友元函数不是类的成员,但它被授予访问该类私有和保护成员的权限。这打破了数据封装的严格性,因此应谨慎使用。
以下是使用友元函数重载 << 的正确方式:
class Point {
// ... 其他成员
// 声明友元函数
friend std::ostream& operator<<(std::ostream& os, const Point& p);
};
// 在类外定义友元函数
std::ostream& operator<<(std::ostream& os, const Point& p) {
os << "(" << p.x << ", " << p.y << ")"; // 可以访问私有成员 x, y
return os;
}
成员函数重载 vs 友元函数重载
对于二元运算符(如 +),有两种重载方式:
1. 成员函数重载:
class Point {
public:
Point operator+(const Point& rhs) const {
return Point(x + rhs.x, y + rhs.y);
}
};
// 调用: Point c = a + b; // 等价于 a.operator+(b)
这种方式下,只有运算符右侧的参数(rhs)可以享受隐式类型转换。左侧的操作数(this 指向的对象)必须是 Point 类型。
2. 友元函数重载:
class Point {
friend Point operator+(const Point& lhs, const Point& rhs);
};
Point operator+(const Point& lhs, const Point& rhs) {
return Point(lhs.x + rhs.x, lhs.y + rhs.y);
}
这种方式下,两个参数是对称的,都通过参数列表传递,因此两侧的操作数都可以享受隐式类型转换。这通常是我们期望的行为,以保持操作的对称性。例如,如果定义了 double 到 Point 的转换,double d; Point p; 那么 d + p 和 p + d 都能通过友元重载正常工作,而成员函数重载则无法处理 d + p 的情况。
运算符重载的注意事项与限制 ⚠️
虽然运算符重载功能强大,但必须谨慎使用。
以下是使用运算符重载的一些准则:
- 符合直觉:重载的运算符行为应符合该操作在数学或该问题领域的常规含义。例如,
+应该表示加法,而不是其他操作。 - 保持一致性:如果重载了
+,通常也应该重载相关的运算符,如+=、-等。 - 避免滥用:不要仅仅因为“有趣”而重载运算符。在团队或公共项目中,晦涩的运算符重载会严重降低代码可读性和可维护性。
并非所有C++运算符都可以重载。以下是一些不可重载的运算符:
- 作用域解析运算符
:: - 成员访问运算符
. - 成员指针访问运算符
.* - 条件运算符
?: sizeof和typeid运算符- 预处理符号
#和##
关于条件运算符 ?: 的思考:它是一个三元运算符,语法为 条件 ? 表达式1 : 表达式2。它不可重载的原因主要是其使用频率不高,在大多数代数领域没有对应的自然语义,并且重载它会带来复杂的语法和语义问题。
总结 🎓
本节课中我们一起学习了C++中两个紧密相关的核心特性:
- 运算符重载:允许我们为用户自定义类型定义运算符的行为,使其能够像内置类型一样被直观地使用。
- 友元函数:通过授予外部函数访问私有成员的权限,解决了某些运算符(如流插入
<<)无法作为成员函数重载的问题,并实现了二元运算符的对称性重载。
我们还探讨了与之相关的转换构造函数、类型转换运算符以及编译器进行重载函数选择的复杂规则。记住,这些强大的工具应当用于让代码更清晰、更符合领域逻辑,而不是制造晦涩的“黑魔法”。


038:STL架构与C++11新特性 🏗️
在本节课中,我们将深入探讨标准模板库(STL)的架构,并简要介绍C++11标准带来的新特性。理解STL的架构能帮助我们更高效地使用它,甚至扩展它。同时,了解语言的新发展对于编写现代C++代码至关重要。
STL的三足鼎立架构
上一节我们介绍了STL的基本概念,本节中我们来看看其核心架构。STL的架构可以形象地比喻为一个三足凳,它由三个相互协作的核心组件支撑:容器、算法和迭代器。

容器(Containers)
容器用于存储数据。STL提供了多种容器,主要分为两类:
以下是顺序容器的例子:
vector:动态数组,支持快速随机访问。list:双向链表,支持高效的插入和删除。
顺序容器中的元素有明确的顺序,我们可以通过索引(如第一个、第三个元素)来访问它们。
以下是关联容器的例子:
set:集合,存储唯一键。map:映射,存储键值对。

关联容器不通过数字索引访问,而是通过键与值之间的关联进行查找。例如,通过朋友的名字(键)来查找其地址(值)。
迭代器(Iterators)
为了在容器中遍历和访问元素,我们需要迭代器。迭代器在概念上非常接近C语言中的指针。指针可以访问数组或结构体的成员,而迭代器是这一概念的泛化,它提供了更系统、更便捷的方式来遍历各种容器。
最终,STL定义了五种主要类别的迭代器,它们为算法的通用性奠定了基础。
算法(Algorithms)
算法是对数据进行操作的函数,例如排序、查找、累加等。STL提供了一系列通用的算法,它们通过迭代器与容器协作,从而避免了程序员重复“发明轮子”。


算法效率示例:排序
为了理解STL算法带来的好处,让我们比较两种经典的排序算法。
以下是两种常见排序算法的对比:
- 冒泡排序(Bubble Sort):一种简单的排序算法。
- 平均时间复杂度:
O(n²) - 其工作原理是反复交换相邻的未按序排列的元素。
- 平均时间复杂度:
- 快速排序(Quick Sort):由托尼·霍尔发明的高效排序算法。
- 平均时间复杂度:
O(n log n) - 最坏情况时间复杂度:
O(n²)
- 平均时间复杂度:
O(n log n) 的效率远高于 O(n²)。例如,对1000个元素排序,n log n 约为 1000 * 3 ≈ 3000 次操作,而 n² 则是 1,000,000 次操作,效率差异巨大。


快速排序采用分治策略。它选择一个基准元素进行分区,将小于基准的元素移到其左侧,大于基准的元素移到其右侧。

然后对左右两个子序列递归地进行相同操作。如果每次分区都能大致均分,就能获得 O(n log n) 的效率。
最坏情况发生在分区极度不平衡时,例如每次分区后都有一个子序列为空。


不过,通过合理的基准选择策略(如随机选择),在实际应用中几乎可以避免最坏情况。
C++11标准与STL的扩展
C++语言自1985年诞生以来不断演进。上一次主要标准化是在1998年。而2011年的C++11标准为语言带来了重大改进和扩展,其中就包括对STL的极大扩充。
这些新特性允许我们直接使用标准库,而无需自己创建基础库。一个重要的例子是线程库。随着多核处理器和云计算的发展,并发编程变得日益重要。C++11将线程支持作为标准库的一部分,为多线程编程提供了统一的社区标准。
在本课程的后半部分,我们将重点讨论一些C++11的新特性。

本节课中我们一起学习了STL的三支柱架构:容器、算法和迭代器,并通过排序算法对比了算法效率的重要性。我们还了解到C++11标准为STL和语言本身带来了强大的新特性,例如原生的线程支持,这将是我们后续学习的重点。掌握这些基础知识是有效使用和扩展STL的关键。
039:STL示例与迭代器概念
在本节课中,我们将学习标准模板库(STL)的一个具体示例,重点理解vector容器的使用以及迭代器(iterator)这一核心概念。我们将通过对比C风格和C++风格的代码习惯用法,来展示STL的设计理念和优势。
使用STL的vector示例
首先,我们来看一个使用STL中vector的示例代码。这段代码包含了标准输入输出流库,并使用了std命名空间,这表明我们使用的是标准库中的组件。
#include <iostream>
#include <vector>
using namespace std;
vector本身是一个模板,这体现了STL作为标准模板库的核心——泛型编程。STL广泛使用模板和内联技术,旨在实现接近手写代码的高效性。
两种初始化与遍历习惯用法
以下是两种常见的代码习惯用法,用于初始化和遍历容器。
第一种是更偏向C风格的数组初始化方式:
// C风格习惯用法示例
for (int i = 0; i < 100; ++i) {
v[i] = i; // 假设v是一个vector<int>
}
第二种则是更地道的C++ STL风格,它引入了迭代器的概念:
// C++ STL风格习惯用法示例
vector<int>::iterator p = v.begin();
while (p != v.end()) {
cout << *p << endl;
++p;
}
在这个例子中,p是一个迭代器,可以理解为指针的泛化,有时也被称为“游标”。v.begin()指向容器的第一个元素,而v.end()则是一个“哨兵”,它指向容器最后一个元素的下一个位置,用于标识序列的结束。
迭代器逻辑的核心地位
迭代器逻辑是使用标准模板库的基础。正如之前所述,迭代器p在概念上类似于指针。

它的操作范围由两个端点定义:
begin():指向容器中实际的起始元素。end():作为哨兵,指向容器末尾的“下一个”位置。我们通常使用p != v.end()这样的不等逻辑来判断是否到达终点。
这种设计使得遍历代码更加通用和安全,是STL代码中广泛依赖的核心习惯用法。
总结

本节课中,我们一起学习了STL中vector的基本使用。我们对比了C风格和C++风格的容器操作习惯用法,并重点介绍了迭代器这一核心概念。迭代器作为泛化的指针,配合begin()和end()哨兵,构成了STL遍历和操作容器的通用、高效模式。理解并掌握这种基于迭代器的编程风格,是有效使用C++标准库的关键。
040:C++11 auto关键字 🚀
在本节课中,我们将要学习C++11标准引入的一个重要新特性:auto关键字。我们将了解它的作用、用法以及它如何简化我们的代码。
从C++11开始
现在,请注意,C++11标准带来了许多新特性,它允许我们以更便捷的方式改变编程习惯。我将开始向你展示这一点。在接下来的学习中,我们会看到新旧风格的结合。
你需要记住,在未来几年,我们将看到越来越多的编译器兼容新标准。你应该能够开始真正利用它。目前,GCC编译器的当前版本(我相信是4.8)已经开始广泛使用,并且它实现了新标准的大部分特性。
简化复杂声明
以下是auto如何简化一个相当繁琐、需要大量输入的声明。这里的“大量输入”不仅指击键次数,也指确保类型正确。在这种复杂的语法中,很容易出错或遗漏某些内容。
现在,auto关键字有了新的含义。它在原始C语言中的含义已被弃用(deprecated)。在编程语言标准委员会的术语中,“弃用”意味着不再被支持。
因此,auto的含义已被完全改变。现在,auto意味着推断类型。这意味着类型必须是可推断的,如果无法理解类型,auto将无法工作。但如果存在理解类型的方法,它就能工作。
例如,对于表达式 vector<int>::iterator,编译器知道这个表达式的类型。在编译器知道类型的地方,你无需明确指定它,只需说 auto。这方便得多,让编译器安全地完成这项工作。
auto的历史与现在
在非常早期的C语言中,auto被称为存储说明符(storage specifier)。在C的早期,它表示“自动将此变量放在栈上”。实际上,在大多数情况下,没有人使用它,因为它是冗余的。你会直接写 int i 而不是 auto int i。
所以旧的用法从未被真正使用。你可以回溯到Kernighan和Ritchie的原始C语言编程书籍,尝试在书中找到auto的任何实际用例。据我所知,他们在定义该语言的原始书中没有在任何真实代码中包含auto。
auto的使用示例
以下是一些关于auto如何使用的更多示例,它们相当直接。当然,还有更复杂的例子,你可以看到类型推断是如何工作的。
- 替代
int i:auto i = 42; // 推断为 int

-
替代
double d:auto d = 3.14; // 推断为 double -
替代复杂类型声明:
auto it = myVector.begin(); // 推断为 vector<int>::iterator
让我们更详细地看看第三个例子。通常,无论 myVector.begin() 返回什么,它都有一个类型。如果这个类型指定起来相当复杂(例如某种模板迭代器类型),那么使用 auto 就可以避免重新指定这个可能很复杂的类型。
当然,我们不能只说 auto i;。因为没有信息来进行类型推导,这会导致语法错误。
因此,auto应该成为你技能库的一部分,因为它能让事情对你来说更简单。
一个小测验
为了让你检验理解,这里有一个非常简单的auto使用示例。你需要告诉我变量c被推断为什么类型。

auto c = 'A';
答案:在上面的例子中,c当然被推断为 char 类型。




本节课中我们一起学习了C++11的auto关键字。我们了解到它的核心作用是进行类型推断,从而简化变量声明,特别是对于复杂类型。它取代了C语言中已弃用的旧含义,成为了现代C++编程中提高代码简洁性和安全性的重要工具。记住,auto让编译器为我们工作,同时保持了类型安全。
041:向量方法

概述
在本节课中,我们将要学习C++标准模板库(STL)中最重要的顺序容器之一:vector。我们将了解为何要使用vector替代传统的C风格数组,并学习其核心方法、构造函数以及如何使用迭代器来遍历容器。
从数组到向量
上一节我们介绍了容器类的基本概念。本节中我们来看看为何要使用vector。
当我们从标准模板库中获得一个容器类时,我们也获得了大量附加功能。我们正在用一个更复杂的对象——vector,来替代原始的容器——数组。这个对象将提供更大的灵活性,并且使用起来更安全。使用vector可能会带来微小的效率损失,但在很大程度上不会。实际上,在新标准中,我们还会看到一个固定大小的数组类型容器,它在某些情况下效率更高,同样可用于替代C语言中的简单数组类型。
以下是使用vector的两个主要优势:
- 灵活性:容器可以在运行时动态调整大小。
- 安全性:相比原始数组,
vector提供了更好的边界检查和内存管理。
核心方法与构造函数
现在,让我们看看vector提供的一些核心方法和构造函数。构造函数对于方便地初始化数据类型至关重要,它们能满足各种需求。
以下是vector的一些关键构造函数,这些构造函数在几乎所有序列容器中都很常见:
vector<T> v;:创建一个空的vector容器。vector<T> v(n);:创建一个包含n个元素的vector,元素进行默认初始化。vector<T> v(n, value);:创建一个包含n个元素的vector,每个元素都初始化为value。
一个非常重要的方法是:
v.size():获取容器当前的大小。这很重要,因为容器可以在运行时调整大小。
一个简单的向量示例
理解了构造函数和方法后,本节我们通过一个具体示例来看看vector的实际应用。
vector是最重要的容器类,也是我们使用顺序容器时的首选。我们必须从标准库中获取它,因此其完整名称是std::vector。人们普遍用vector替代传统C风格数组,因为它在效率上损失很小,却获得了许多安全优势和灵活性。
让我们分析以下代码,看看它如何工作:
#include <iostream>
#include <vector>
int main() {
int how_many;
std::cout << "How many elements? ";
std::cin >> how_many;
std::vector<int> data(how_many); // 使用构造函数动态创建指定大小的vector
std::vector<int>::iterator it; // 声明一个迭代器
for (it = data.begin(); it != data.end(); ++it) {
std::cin >> *it; // 使用迭代器从标准输入初始化vector
}
// 扩展练习:可以添加另一个循环,使用迭代器将vector内容打印到标准输出
return 0;
}
这段代码首先询问用户需要多少元素(例如1000个),然后使用单参数构造函数动态创建一个包含相应数量整数的vector。程序的核心功能是使用迭代器来填充这个vector。
理解迭代器
上一节代码中出现了迭代器,它是使用STL的关键。本节我们来深入理解迭代器的概念。
使用标准模板库时,关键要理解其容器都可以与迭代器一起使用。迭代器是一种指针类型。在旧版C中,只有非常简单的原始指针。迭代器扩展了这个概念,但保留了指向事物位置的思想。
以下是关于迭代器的要点:
- 声明:迭代器的类型与其迭代的容器类型绑定。例如,对于
std::vector<int>,迭代器类型是std::vector<int>::iterator。 - 位置指示:容器类提供了
begin()和end()方法。begin()返回指向容器第一个元素的位置,end()返回一个“哨兵”,表示容器末尾的下一个位置。 - 遍历:迭代器支持自增操作(
++),用于移动到下一个位置。for循环从begin()开始,到end()结束,确保每个位置被访问一次且仅一次。
在上面的代码中,我们使用迭代器it遍历vector,并通过解引用*it从标准输入读取值来初始化每个元素。
值得注意的是,迭代器的概念在后续学习中可以被隐藏。例如,可以使用基于范围的for循环或C++11的auto关键字来简化迭代器的声明,从而隐藏其复杂性。
总结

本节课中我们一起学习了C++标准模板库中的vector容器。我们了解了使用vector相比传统数组在灵活性和安全性上的优势。我们学习了vector的核心构造函数(如创建空容器、指定大小、指定大小和初始值)和size()方法。最后,我们通过一个示例代码深入探讨了迭代器的概念,它是遍历和操作STL容器的通用且强大的工具。记住,vector是处理顺序容器需求时的首选,除非有特殊理由(如需要频繁在中间插入删除),否则应优先考虑它。
042:更多代码示例

在本节课中,我们将学习C++中auto关键字和迭代器(iterator)的用法,以及如何利用标准模板库(STL)安全高效地处理数据,例如从文件中读取数据并计算平均值。我们将重点关注如何避免常见的编程错误,如数组越界。
auto关键字与类型推断 🔍
上一节我们介绍了迭代器的基本概念。本节中我们来看看如何简化迭代器的声明。
在C++11中,auto关键字被重新定义为一种类型推断机制。它允许编译器根据初始化表达式自动推断变量的类型。这与旧版C语言中auto作为存储类说明符的用途完全不同。
例如,在遍历一个存储整数的向量(vector<int>)时,我们可以这样声明迭代器:
auto it = data.begin();
编译器会推断出it的正确类型是std::vector<int>::iterator。这样,程序员就无需手动写出冗长的类型声明,代码变得更加清晰和简洁。
迭代器模式与安全性 🛡️
使用迭代器模式遍历容器(如vector)是STL的核心思想之一。它能有效避免使用传统索引时可能出现的“差一错误”(off-by-one error)。
以下是使用迭代器遍历向量的安全方式:
for (auto it = data.begin(); it != data.end(); ++it) {
// 处理 *it
}
这种方法通过begin()和end()函数明确界定范围,完全消除了索引越界的可能性。相比之下,传统的C风格循环:
for (int i = 0; i <= upper_bound; ++i) // 潜在错误:使用了 <=
如果错误地使用了<=,就会访问upper_bound之后的内存,导致运行时错误(如段错误)。
实战:从文件读取数据并计算平均值 📊
为了展示STL的强大功能,我们将结合文件流和迭代器,从一个文本文件中读取一系列整数(例如海豹体重数据),并计算它们的平均值。
以下是实现此功能的核心步骤:
首先,我们需要包含必要的头文件并打开数据文件:
#include <fstream>
#include <vector>
#include <iterator>
// ...
std::ifstream data_file("data.txt");
接着,利用STL的强大特性,我们可以直接用文件流迭代器来初始化一个向量:
std::vector<int> data((std::istream_iterator<int>(data_file)), std::istream_iterator<int>());
这行代码的精妙之处在于:std::istream_iterator<int>(data_file)指向文件的开头,而默认构造的std::istream_iterator<int>()则作为结束哨兵。STL会读取文件中的所有整数,并动态扩展vector来存储它们,我们无需预先知道数据的数量。
最后,我们使用迭代器安全地遍历向量并计算总和与平均值:
double sum = 0.0;
for (auto it = data.begin(); it != data.end(); ++it) {
sum += *it;
}
double average = sum / data.size();
注意,在计算平均值时,我们使用1.0或static_cast<double>来确保进行浮点数除法,从而得到精确的结果。
总结 🎯

本节课中我们一起学习了C++中两个重要的现代特性:auto关键字和迭代器。auto通过类型推断简化了复杂类型的声明。迭代器模式配合begin()和end()函数,提供了一种安全遍历容器的标准方法,能有效防止数组越界错误。最后,我们通过一个从文件读取数据并计算平均值的完整示例,见证了STL组件(如vector、istream_iterator)协同工作带来的简洁性与强大功能。掌握这些知识将帮助你写出更安全、更易维护的C++代码。
043:基于范围的for循环

概述
在本节课中,我们将要学习C++11标准引入的一个新特性:基于范围的for循环。这个特性旨在简化遍历数组、容器等聚合数据结构的代码编写,使其更直观、更易于使用。
新特性的引入
上一节我们介绍了C++11中的auto关键字。本节中我们来看看另一个能显著简化代码的C++11特性。
C++11标准声称,新版本的语言比旧版本更易于使用。这初看可能有些矛盾,因为新标准增加了约60%的内容,描述文档从约800页扩展到了约1300页。虽然新语言中确实包含一些复杂的部分,但也引入了许多像auto这样的特性,能让代码编写变得更自然、更直观。基于范围的for循环就是其中之一。
基于范围的for循环语法
以下是基于范围的for循环的基本语法结构:
for (declaration : expression) statement
其中,declaration是循环内的一个声明。expression是某个表达式。statement是要执行的语句,当然,它可以是一个用花括号包裹的代码块,以实现复杂的逻辑。
简单示例:求和
让我们通过一个简单的例子来理解它的用法。
以下是一个使用基于范围的for循环对数组元素求和的示例:
for (double d : data) sum += d;
在这个例子中,double d是一个声明,它定义了一个局部变量d。data是一个表达式,例如,它可以是一个double类型的数组。假设这个数组被声明为double data[UPPER_BOUND];,其中UPPER_BOUND是100,那么数组中就存放了100个double类型的数据。
这个for循环的作用是遍历数组data中的所有元素。变量d会依次被赋值为数组中的每个元素(可以想象成d = data[i++],其中i是一个隐式的索引游标),然后将d的值累加到sum中。
与传统迭代方式的对比
这种写法甚至比使用begin()和end()迭代器更简单。begin和end是隐式调用的。它进一步简化了标准模板库的迭代惯用法。原本你需要写:
for (auto p = data.begin(); p != data.end(); ++p) { ... }
而现在,你有了一个简洁得多的语句,甚至无需显式提及迭代过程。
适用范围
那么,哪些类型的表达式可以用于基于范围的for循环呢?
以下是适用于基于范围的for循环的数据结构类型:
- 数组
- 普通的序列容器(如
vector,list) - 字符串(
string)。字符串本质上是一系列字符元素,是一种特殊的容器。
从概念上讲,这些数据结构都需要有begin()和end()成员函数(对于内置数组,编译器会进行特殊处理)。所有具备这些特性的序列容器、字符串或普通数组,都可以使用基于范围的for循环进行遍历。
进阶示例:修改元素
让我们看另一个例子,这次我们将修改容器中的元素。
以下示例演示了如何使用基于范围的for循环和引用声明来修改容器内的值:
for (auto& x : data) x *= 2;
这里,声明部分auto& x是一个引用声明。请注意,我们再次使用了auto来自动推断类型,这结合了多个C++11的新思想。本质上,编译器会推断出x应该是double&类型。使用auto的好处在于,无论data容器中存储的是什么类型,这段代码都能正常工作。
与之前的求和例子不同,这里我们是在改变(mutating)容器中的值。正因为要修改元素,我们才需要使用引用声明&,这样x就是容器中元素的别名,对x的修改会直接作用到容器元素上。

总结
本节课中我们一起学习了C++11的基于范围的for循环。我们了解了它的基本语法for (declaration : expression) statement,并通过求和与修改元素两个例子看到了它如何简化对数组和容器的遍历操作。我们还讨论了它的适用范围,并对比了其与传统迭代器写法的区别。掌握这个特性将使你的C++代码更加简洁和易读。
044:STL文件输入与排序示例

在本节课中,我们将学习如何使用C++标准模板库(STL)从文件中读取一系列单词,将其存储到向量中,并进行排序。通过这个具体的例子,我们将看到STL如何帮助我们以简洁、高效且不易出错的方式完成复杂的文本处理任务。
从示例中学习
上一节我们介绍了STL的基本概念。本节中,我们来看看一个具体的应用实例。通过实践和观察示例代码,我们能更好地理解和掌握编程技术。这个例子将展示如何读取文件、处理数据以及应用算法。
示例目标:分析文本词汇
假设你是一位人文学科的教授,需要验证一部戏剧是否由莎士比亚所著。一种常见的方法是分析词汇的使用模式和频率。每个作者都有其独特的用词风格,通过统计文本中的词汇,可以推断作者身份。类似的技术也用于分析《圣经》等文献,以判断是否存在多位作者。我们将演示如何使用STL高效地实现这类文本分析的基础部分。
所需的STL组件
为了实现目标,我们需要使用几个关键的STL库。以下是本示例将用到的组件:
<fstream>:用于文件输入输出操作。<vector>:作为存储单词的动态数组容器。<algorithm>:提供排序等通用算法。<iterator>:提供迭代器,用于遍历容器和文件。
代码实现步骤解析
现在,我们逐步解析实现该功能的代码逻辑。
1. 打开文件并读取单词
首先,我们需要打开存储文本的文件(例如 word.txt),并将单词读入一个向量中。
std::ifstream in_file("word.txt");
std::vector<std::string> words((std::istream_iterator<std::string>(in_file)),
std::istream_iterator<std::string>());
这段代码利用std::istream_iterator从文件中读取数据。迭代器会自动以空白字符(空格、换行等)为分隔符,将文件内容分割成独立的字符串(单词),并用它们来初始化words向量。std::string的动态特性使其能够容纳任意长度的单词。
2. 遍历并打印原始单词
为了查看读取的内容,我们使用基于范围的for循环来遍历向量。
for (const auto& word : words) {
std::cout << word << '\t';
}
auto关键字让编译器自动推断word的类型(此处为std::string)。这个循环会安全地遍历所有元素,并打印每个单词,用制表符分隔。如果文件内容是“The quick Brown Fox”,屏幕将原样输出这些单词。
3. 对单词进行排序
接下来,我们使用STL的sort算法对向量中的单词进行排序。
std::sort(words.begin(), words.end());
std::sort函数接受两个迭代器参数,定义需要排序的范围(这里是整个向量)。它使用高效的排序算法(通常是快速排序的变体)。排序后,单词将按字典序排列。
4. 遍历并打印排序后的单词
排序完成后,我们再次使用基于范围的for循环来输出结果。
for (const auto& word : words) {
std::cout << word << '\t';
}
由于字典序基于ASCII值,大写字母排在小写字母之前。因此,“The quick Brown Fox”排序后可能输出为“Brown Fox The quick”。
核心概念与深入探讨
迭代器范围
STL算法普遍使用迭代器来操作数据范围。std::sort(words.begin(), words.end())中的words.begin()和words.end()定义了一个“前闭后开”的区间,这是STL中表示范围的典型方式。
算法要求
std::sort算法要求迭代器提供随机访问能力,这意味着它能像指针一样快速跳转到任意位置。std::vector的迭代器满足此要求,因此可以用于排序。
扩展练习建议
为了深化理解,你可以尝试以下练习:
- 大小写归一化:在排序前,先将所有单词转换为小写,使得排序不区分大小写。
- 频率统计:对排序后的单词进行计数,找出每个单词的出现频率。
- 自定义排序:尝试自己实现一个排序算法,并与
std::sort在效率和正确性上进行比较。
总结

本节课中我们一起学习了如何利用C++ STL的强大功能来处理文本文件。我们通过一个具体的例子,演示了如何使用ifstream和istream_iterator读取文件,用vector存储数据,用基于范围的for循环进行遍历,以及用algorithm中的sort函数进行排序。STL通过提供经过充分测试的高效组件,让我们能够用简洁、清晰的代码完成复杂任务,显著提高了开发效率和代码可靠性。掌握这些工具是编写现代C++程序的关键。
045:迭代器类别
概述

在本节课中,我们将要学习C++标准模板库(STL)中一个核心的架构概念——迭代器类别。我们将了解迭代器的不同分类,从最弱到最强,并探讨为什么在设计算法时,应优先选择能满足算法需求的最弱迭代器类别。
迭代器基础与类别总览
上一节我们介绍了迭代器的基本概念。本节中我们来看看迭代器是如何被分类的。
迭代器是访问容器(如list、vector)中元素的通用方法。例如,一个典型的双向链表迭代器允许你向前(++)或向后(--)移动,并使用begin()和end()来指定一个范围。
STL将迭代器分为5个主要类别,可以按功能从强到弱排序:
- 最强:随机访问迭代器
- 最弱:输入迭代器
以下是这五个类别的简要列表:
- 输入迭代器:最弱。允许单向、单次读取元素。
- 输出迭代器:与输入迭代器类似,但用于单向、单次写入。
- 前向迭代器:允许单向读取和写入,并且支持多次遍历容器。
- 双向迭代器:在前向迭代器基础上,增加了向后移动(
--)的能力。 - 随机访问迭代器:最强。允许任意位置的跳转(如
+ n),是像vector或数组这类可索引容器的典型迭代器。
算法设计原则:使用最弱的迭代器

当我们设计算法时,一个重要的原则是:使用能满足算法最高效实现的最弱迭代器类别。
以快速排序算法为例。快速排序的核心操作是分区和交换,它需要能够快速访问任意位置的元素来进行高效的交换。这要求随机访问能力。
// 伪代码示意:快速排序中的交换需要随机访问
swap(container[i], container[j]); // i和j可以是任意索引
如果一个容器(如list)不支持随机访问迭代器,那么每次交换操作的成本将变为O(n),导致整个快速排序算法效率低下。因此,标准库中的std::sort算法要求随机访问迭代器。对于list,标准库提供了一个专门的list::sort成员函数,它使用不同的(效率相对较低的)算法来适应双向迭代器的特性。

这个设计原则确保了算法在尽可能多的容器类型上保持高效。
深入迭代器类别:输入迭代器
让我们更仔细地看看最弱的迭代器类别——输入迭代器。
输入迭代器必须支持单向顺序访问元素。它要求定义++运算符来推进迭代器,并允许对迭代器进行解引用(*)来读取它指向的元素。它通常用于表示输入流(如从文件读取数据),并且只支持单次遍历。
标准库中的std::find算法就是一个很好的例子。它在一个范围内线性搜索特定元素,只需要顺序查看每个元素一次,因此它完全可以基于输入迭代器来实现。
以下是使用输入迭代器的一个典型例子——std::accumulate算法(位于 <numeric> 头文件中):
#include <iostream>
#include <numeric>
#include <iterator>
int main() {
// 假设文件"data.txt"中包含:6 4 -3 19
std::ifstream data_file("data.txt");
std::istream_iterator<int> data_begin(data_file); // 输入迭代器起点
std::istream_iterator<int> data_end; // 输入迭代器终点(默认构造表示文件尾)
// 使用accumulate求和,初始值为0
int sum = std::accumulate(data_begin, data_end, 0);
std::cout << "Sum is: " << sum << std::endl; // 输出:Sum is: 26
return 0;
}
std::accumulate 函数遍历由输入迭代器指定的范围(从data_begin到data_end),将所有元素累加到初始值(这里是0)上。它只需要单向、单次遍历,因此使用输入迭代器就是最高效的选择。
它的函数签名大致如下,体现了对通用输入迭代器类型的要求:
template< class InputIt, class T >
T accumulate( InputIt first, InputIt last, T init );
小测验:如果初始值是5,要累加的序列是{1, 2, 3, 4},结果是多少?
答案是 15。计算过程是:5 + 1 + 2 + 3 + 4 = 15。
总结

本节课中我们一起学习了STL迭代器的类别体系。我们了解了从输入、输出、前向、双向到随机访问这五种迭代器类别,以及它们各自的能力。最重要的是,我们掌握了STL算法设计的核心原则:为算法选择能满足其最高效实现的最弱迭代器类别。这一原则保证了泛型算法既灵活又高效。理解迭代器类别有助于我们正确选择和使用STL中的算法与容器。
046:输出随机图

概述
在本节课中,我们将学习如何使用C++生成一个随机图。我们将探讨图密度的概念,并编写一个程序来生成具有指定密度、边成本和颜色的随机图。这个程序将使用矩阵来表示图,并演示如何将随机生成的数据输出到文件。
图密度与随机生成
上一节我们介绍了随机图生成的应用背景,本节中我们来看看其核心概念——图密度。
图密度是一个介于0和1之间的值,它粗略地表示每个节点平均拥有的边数。我们可以用以下公式来理解:
- 密度 = 1:表示完全图,即每对节点之间都存在边。
- 密度 = 0:表示完全不连通的图,只有节点,没有边。
- 密度 = 0.5:对于一个有100个节点的图,平均每个节点大约有50条边。
在实际应用中,例如网络设计或算法测试,获取真实世界的大规模图数据非常困难。因此,使用具有特定密度的随机图进行模拟是一种常用且有效的方法。
程序设计与实现
理解了图密度的概念后,我们现在来构建生成随机图的程序。我们将使用一个布尔矩阵来表示图,并为边添加成本和颜色属性。
以下是程序的核心步骤:
- 获取用户输入:程序需要从用户那里获取两个参数:图的
size(节点数量)和图的density(密度)。 - 初始化随机数生成器:为了避免每次运行程序都得到相同的随机序列,我们需要用当前时间作为种子来初始化随机数生成器。
- 构建数据结构:我们将创建三个矩阵来完整描述这个图:
graph:一个布尔矩阵,graph[i][j]为true表示节点i和j之间存在边。color:一个整数矩阵,color[i][j]表示连接节点i和j的边的颜色(例如,0、1、2分别代表不同的道路类型)。cost:一个整数矩阵,cost[i][j]表示通过边(i, j)的成本(例如,距离或开销)。
- 生成边:通过比较一个随机概率值与目标密度来决定是否在两个节点间创建边。其逻辑可以用以下代码描述:
这段代码生成一个0到1之间的随机数,如果它小于密度值if ( (rand() / (double)RAND_MAX) < density ) { // 创建边 }density,则创建边。这确保了边被创建的概率与密度成正比。 - 为边分配属性:如果边被创建,我们为它随机分配一个成本(例如,0到29之间的整数)和一个颜色(0、1或2)。
- 输出到文件:最后,程序将生成的图信息(存在的边及其成本和颜色)写入一个输出文件,以便后续使用或分析。
关键代码解析
在生成边的过程中,有几个细节值得注意:
- 随机概率生成:表达式
rand() / (double)RAND_MAX是关键。rand()返回一个整数,RAND_MAX是它能返回的最大值。通过将rand()强制转换为double类型再进行除法运算,我们得到了一个介于0.0到1.0之间的浮点数,模拟了一个均匀分布的概率。 - 对称性处理:由于我们生成的是无向图,因此边
(i, j)和(j, i)是相同的。在实现时,我们通常只处理i < j的情况,或者确保矩阵是对称的。 - 颜色与成本生成:
使用取模运算符color[i][j] = rand() % 3; // 生成0, 1, 2三种颜色 cost[i][j] = rand() % 30; // 生成0到29之间的成本%可以方便地将随机数限制在指定的范围内。

总结
本节课中我们一起学习了如何使用C++生成随机图。我们首先了解了图密度的定义及其在模拟中的重要性。然后,我们逐步构建了一个程序,该程序能够根据用户指定的节点数和密度,生成一个用矩阵表示的随机图,并为每条边随机分配成本和颜色。最后,我们将图的数据结构输出到文件中。这个练习综合运用了随机数生成、数组(矩阵)操作和文件I/O等C++核心概念,并为后续学习更复杂的图算法奠定了基础。
028:继承与派生类详解
在本节课中,我们将学习C++中继承与派生类的核心概念,通过学生和研究生这个具体的例子,理解如何构建类层次结构、初始化派生类对象以及方法重写的机制。
概述

我们将分析一个包含Student(学生)基类和GraduateStudent(研究生)派生类的简单层次结构。通过对比这两个类,我们将理解为何需要创建特殊的派生类别,以及如何通过继承实现代码复用和扩展功能。
继承层次结构的设计动机
上一节我们介绍了继承的基本概念,本节中我们来看看为何需要设计类层次结构。
学生和研究生在许多方面是相似的对象或实例,但它们也存在差异。这种差异类似于老鼠和人类都是哺乳动物,但我们仍会创建“老鼠”和“人类”这两个特殊类别。原因在于,这些派生类别足够丰富和重要,需要添加额外的信息,以便我们能明确地称某物为“老鼠”,而不仅仅是“一个碰巧是老鼠的哺乳动物”。

因此,让我们更详细地查看学生和研究生之间的代码架构。
基类:Student 构造函数
以下是我们的学生构造函数。其中进行了一些简单的初始化操作。
Student::Student(const string& name, int id, float gpa, Year year)
: name(name), studentID(id), GPA(gpa), studentYear(year) {
// 构造函数体
}

我们有一个Student类,它包含学生姓名、整型ID、平均绩点(GPA)以及一个表示年级的Year枚举类(例如,学生是大一新生)。这里再次使用了初始化列表语法来初始化这些成员变量。这构成了Student类的基本构造函数。
派生类:GraduateStudent 构造函数
现在来看我们的派生类型GraduateStudent,它更有趣,因为包含了一些新的元素。
GraduateStudent::GraduateStudent(const string& name, int id, float gpa,
Support support, const string& dept,
const string& thesis)
: Student(name, id, gpa, Year::GRAD), // 调用基类构造函数
supportLevel(support),
department(dept),
thesisTopic(thesis) {
// 构造函数体
}
研究生仍然需要姓名、ID、GPA和年级这些信息,但现在新增了support(资助类型)、department(院系)和thesis(论文题目)。初始化列表中的新元素是:我们可以调用基类的构造函数。这是初始化过程的一部分。
这完全合乎逻辑。一个研究生是一个学生,这意味着学生的所有元素都必须内置于研究生对象中。因此,我们必须有一种方法来初始化这些元素。通过调用基类构造函数,我们实现了代码复用,本质上是用它所需的参数来构建基类部分。
对象创建与指针转换
以下是使用这些信息的一些方式。
GraduateStudent gs("Mars Pole", 200, 3.25, Support::TA,
"Pharmacy", "Retail Pharmacies");
Student* ps = &gs; // 基类指针指向派生类对象
gs是一个被初始化的研究生对象。ps是一个指向该研究生对象的基类指针。从这个Student指针ps的视角来看,它现在指向的是一个Student类型。因此,任何可以应用于Student的操作,实际上都可以应用于这个特定的实例gs(即Mars Pole)。这里发生了一个适当的转换:这个研究生对象被转换成了一个学生对象的地址。
方法重写(Override)与作用域解析
现在让我们看看重写的方法,这里我们能观察到一些关键区别:派生类GraduateStudent中的print方法与基类Student中的print方法。
基类的print方法只打印姓名、学ID、平均绩点等信息。派生类的print方法通过作用域解析运算符(::)调用基类的print方法,然后添加额外的信息。

void GraduateStudent::print() const {
Student::print(); // 调用基类的print方法
cout << "Support: " << supportLevelToString(supportLevel) << endl;
cout << "Department: " << department << endl;
cout << "Thesis: " << thesisTopic << endl;
}
这就是重写方法的意义。实际上,如果省略Student::,将会导致严重错误——无限递归。因此,当你进行方法重写时,会看到作用域解析运算符的使用,以避免通过递归反复调用被重写的函数。
思考题:如果GraduateStudent::print方法中去掉了作用域解析,如下所示,会发生什么?
void GraduateStudent::print() const {
print(); // 错误:缺少作用域解析,导致递归调用自身!
}
这段代码可以编译,但会创建一个递归例程并且不会终止。如果你写了这样的代码,将不得不按Ctrl+C来强制终止程序。
使用类层次结构:静态绑定
现在让我们看看如何使用这个简单的层次结构。
Student mayPole("May Pole", 100, 3.8, Year::FRESHMAN);
GraduateStudent morrisPole("Morris Pole", 200, 3.25,
Support::TA, "Pharmacy",
"Retail Pharmacies");
Student* ps = &mayPole;
ps->print(); // 调用 Student::print()

ps = &morrisPole; // ps 现在指向派生类对象
ps->print(); // 仍然调用 Student::print()! (非virtual情况下)
GraduateStudent* pgs = &morrisPole;
pgs->print(); // 调用 GraduateStudent::print()
当我们通过基类指针ps调用print时,无论ps实际指向的是本科生(mayPole)还是研究生(morrisPole),都会调用基类Student的print方法,输出有限的信息。
而通过派生类指针pgs调用print时,则会调用GraduateStudent的print方法,输出包括论文和院系在内的完整信息。
这里的关键在于,我们尚未使用virtual关键字。在C++中,与Java等语言不同,非虚函数的重写遵循静态绑定(或早期绑定)规则。函数调用在编译时根据指针的类型(而非指向对象的实际类型)决定。

指针转换规则与访问控制
我们需要理解指针转换规则:指向公有派生类的指针可以隐式转换为指向其公有基类的指针。这正是我们刚才所做的(ps = &morrisPole)。
ps(Student*)可以指向两种类的对象实例。pgs(GraduateStudent*)只能指向GraduateStudent的实例,否则会产生类型错误。
因此,当我们使用两个不同的指针调用print函数时,会得到不同的行为,因为它们在尝试调用时具有不同的“视角”。
ps->print()调用的是基类Student的print函数。pgs->print()调用的是派生类GraduateStudent的print函数。理解这种机制对于掌握C++的多态性至关重要。

最后,在设计类层次结构时,需要仔细考虑public、protected和private访问权限的边界。通常,为了正确性,尽可能将成员设为private是一个好规则,但这有时可能会牺牲一些效率。因此,你需要更多地思考哪些成员应该公开,哪些应该被保护。friend(友元)是另一种突破这些访问类别限制的方式。
总结
本节课中我们一起学习了:
- 继承的动机:通过创建派生类来扩展和特化基类,添加重要的额外信息。
- 派生类构造函数:如何使用初始化列表调用基类构造函数来复用初始化代码。
- 基类与派生类指针:基类指针可以指向派生类对象,这是实现多态的基础。
- 方法重写:如何在派生类中重写基类方法,并使用作用域解析运算符(
::)调用基类版本以避免递归。 - 静态绑定:在未使用
virtual关键字时,函数调用根据指针的编译时类型决定,这是C++的默认行为。 - 访问控制:在设计类层次结构时,需要权衡
public、protected和private访问权限对代码封装和效率的影响。

理解这些基础概念是掌握C++面向对象编程和后续学习动态多态(虚函数)的关键。建议通过编写和运行简单的示例代码来加深理解。
029:虚函数选择
在本节课中,我们将学习C++中虚函数的概念、工作原理及其在实现多态性中的关键作用。我们将通过对比非虚函数的行为,理解动态派发机制,并探讨其背后的实现原理与性能考量。
回顾上一节内容
上一节我们介绍了非虚函数print的行为。在那种情况下,被调用的重写函数是基于指针的类型,而不是指针所指向的实例的实际类型来选择的。
理解多态与动态派发
如果你熟悉Java等语言,你会习惯于多态响应,即由实际实例来决定调用哪个函数。这有时被称为动态派发。
在C++中,这一机制更为复杂和精密。一些其他面向对象语言默认就具有虚函数行为,因为根据“90%规则”,这几乎总是你期望发生的情况。因此,在C++中,你通常需要在基类中将函数声明为virtual,然后在派生类中重写这些虚函数。
这样,函数的选择将不再依赖于指针的类型,而是取决于指针所指向的实例。例如,一个指向GraduateStudent的指针将调用GraduateStudent的print函数,而不是Student的print函数。

指向基类的指针至关重要,因为它允许你以统一的方式访问类型层次结构中的任何对象。但成员函数将被动态选择。
动态派发的实现与效率
这里存在一个效率问题:如何实现动态派发?动态派发是通过在运行时查询一个表来实现的。这意味着函数调用需要额外的操作。换句话说,你无法在编译时直接选择正确的函数,必须推迟到运行时。
运行时系统会查看对象的类型(该类型信息必须与对象一起存储),然后访问虚函数表,并从中选取正确的重写函数。基类中的虚函数将作为默认实现,但你通常需要在派生类中重写它。
核心机制:当一个函数被声明为virtual后,其“虚”属性会在整个继承链中持续存在。在派生类中重写时,无需再次使用virtual关键字(但可以使用,以增加清晰度)。
区分重载与重写

重载与重写之间可能存在很多混淆:
- 重载:指在编译时根据函数签名选择成员函数。这也影响模板,模板同样是在编译时进行签名匹配。
- 重写:特指在继承关系中,派生类重新定义基类的虚函数。

模板、重载和重写这些概念混合在一起时,可能会变得相当复杂,容易令人困惑,也是测试中常见的难点。
虚函数的返回类型
虚函数可以拥有不同的返回类型。这通常是合理的,例如,你可能希望Student类的某个成员函数返回一个Student对象,而在GraduateStudent类中,则希望返回一个GraduateStudent对象。返回类型的差异通常与基类/派生类对象本身相关,而不是像int与double这样的基本类型差异。
代码示例:虚函数选择

以下是一个简单的实例,用于演示虚函数的选择行为。你可以运行此代码来观察其语义。

class B {
public:
int i = 1;
virtual void printI() { std::cout << i << " inside B\n"; } // 声明为虚函数
};

class D : public B {
public:
int i = 2;
void printI() override { std::cout << i << " inside D\n"; } // 重写虚函数
};
以下是调用这些对象的代码:

B b_obj;
D d_obj;
B* pb = &b_obj; // 指向基类对象的指针

pb->printI(); // 输出: 1 inside B
pb = &d_obj; // 指针现在指向派生类对象
pb->printI(); // 因为printI是虚函数,输出: 2 inside D
在这个例子中,两次printI调用执行了不同的函数。它们是在运行时根据指针所指向的对象动态选择的。
用面向对象的术语来说,对象接收了printI消息,并选择了该方法的正确重写版本。基类指针的类型不再决定调用哪个方法。
你可以回到GraduateStudent和Student的例子,将print函数改为虚函数,观察行为的差异。根据运行时的决策,你将获得不同的行为,这也会导致虚函数调用产生轻微的性能开销。
总结

本节课我们一起学习了C++中虚函数的核心概念。我们了解到,通过将基类函数声明为virtual,并在派生类中进行重写,可以实现多态性,使得函数调用在运行时根据对象的实际类型动态决定。我们还探讨了动态派发通过虚函数表实现的机制,并区分了重载与重写的不同。抽象数据类型、继承以及对象实例的动态方法绑定,这些都是面向对象编程的基石。希望本节能帮助你巩固对C++多态性和面向对象编程的理解。
030:与重载的混淆 🔄
在本节课中,我们将探讨C++中函数重载与虚函数重写可能产生的混淆情况。我们将通过具体的代码示例,分析当基类中存在重载的虚函数,而派生类只重写其中一个时,编译器如何选择调用哪个函数。理解这一点对于设计清晰、可维护的类层次结构至关重要。
重载与重写的基础回顾 📚
上一节我们介绍了虚函数和动态绑定的概念。本节中我们来看看当虚函数与函数重载机制交织时会发生什么。

成员函数可以被重载。这意味着可以为同一个函数名定义多个具有不同参数列表(即不同签名)的版本。编译器在编译时根据调用时提供的参数类型来决定调用哪个版本。这是一个典型的函数重载示例:
class B {
public:
virtual void f(int); // 签名: f(int)
virtual void f(double); // 签名: f(double)
};

当调用 obj.f(5) 时,编译器会选择 f(int) 版本;当调用 obj.f(2.5) 时,则会选择 f(double) 版本。这种选择完全基于函数签名,在编译时完成。
重载与重写的混淆场景 ⚠️
现在,我们引入继承和虚函数重写。假设有一个派生类 D 继承自基类 B。
class D : public B {
public:
virtual void f(double); // 只重写了 f(double)
};

这里就产生了潜在的混淆。派生类 D 从基类 B 继承了两个虚函数:f(int) 和 f(double)。然而,D 只显式地重写了其中一个,即 f(double)。


因此,在 D 的对象中:
- 对于
f(double),使用的是派生类D中重写的新版本(覆盖/重写)。 - 对于
f(int),使用的仍然是继承自基类B的原始版本(未被重写)。


这种不一致性会导致代码行为难以预测,特别是当通过基类指针或引用来操作派生类对象时。
混淆行为示例与分析 🔍

让我们通过具体的指针类型来看看这种混淆如何导致不同的行为。

B* bp = new D; // 基类指针指向派生类对象
D* dp = new D; // 派生类指针指向派生类对象

bp->f(1); // 调用 B::f(int) (静态绑定?动态绑定?)
bp->f(1.0); // 调用 D::f(double) (动态绑定)
dp->f(1); // 调用 B::f(int) (通过派生类指针访问继承的基类函数)
dp->f(1.0); // 调用 D::f(double)
以下是关键点分析:
bp->f(1.0)会进行动态绑定,因为f(double)是虚函数。由于bp实际指向D对象,所以调用的是D::f(double)。bp->f(1)的情况则有些微妙。虽然f(int)也是虚函数,但D类并没有提供自己的f(int)版本。因此,即使通过基类指针调用,最终执行的仍然是基类B::f(int)的实现。这里没有发生“重写”,但虚函数机制仍然存在。- 通过
dp直接调用f(1),会调用继承而来的B::f(int)。
这种设计使得代码阅读者必须仔细追踪整个继承链中每个重载版本是否被重写,极易出错。

最佳实践与规避建议 ✅
为了避免这种混淆,建议遵循以下设计原则:
- 保持重载集合的完整性:如果一个派生类需要重写基类中一组重载函数里的任何一个,那么最好重写这一整组函数(即使只是简单地调用基类版本),以明确行为意图。
- 使用
override关键字 (C++11及以后):在现代C++中,使用override关键字可以让编译器帮助你检查是否成功重写了虚函数,如果签名不匹配会报错,这有助于提前发现此类问题。 - 重新考虑设计:如果出现只需要重写部分重载函数的情况,或许意味着类的接口设计可以改进,例如将不同功能的函数分开命名。
虚函数使用的其他限制 🚫
在讨论重载混淆之后,我们还需要了解虚函数机制本身的一些限制。
首先,静态成员函数不能是虚函数。静态函数属于类本身,而非类的某个特定对象实例。它没有隐藏的 this 指针参数。虚函数动态绑定的核心在于通过对象的 this 指针在运行时确定调用哪个版本,既然静态函数没有 this 指针,自然无法实现动态绑定。

其次,构造函数不能是虚函数。创建对象时,必须明确知道要构造的确切类型,才能分配正确的内存空间和调用相应的构造函数。因此,从语义上讲,虚构造函数没有意义。对象的构造过程是从基类子对象到派生类子对象自下而上进行的,在构造函数执行期间,对象的动态类型正在逐步构建,此时使用虚函数机制是不安全且未定义的。

然而,析构函数可以是且常常应该是虚函数。当通过基类指针删除一个派生类对象时,如果基类析构函数不是虚函数,则只会调用基类的析构函数,导致派生类部分的资源无法正确释放,造成内存泄漏。将基类析构函数声明为虚函数可以确保通过基类指针删除对象时,能正确调用整个继承链上的析构函数。

最后,虽然语言本身不支持虚构造函数,但有一种称为工厂模式的常见设计模式可以模拟“根据输入动态创建不同类型对象”的行为,这在一定程度上实现了虚构造函数的概念。我们将在课程后续部分介绍这个模式。

总结 📝


本节课中我们一起学习了C++中重载与重写可能带来的混淆。我们了解到,当派生类只重写基类中一组重载虚函数的部分时,会使得函数调用行为变得复杂且不直观,应尽量避免这种设计。我们还回顾了虚函数的关键限制:静态成员函数不能为虚,构造函数不能为虚,但析构函数通常应该为虚。理解这些细微差别,将帮助你编写出更健壮、更清晰的面向对象C++代码。
031:创建C++11类

在本节课中,我们将学习C++11标准中影响类设计的关键新特性,并探讨一个基础的人工智能算法——Minimax算法。我们将重点关注移动语义如何提升代码效率,并通过构建一个简单的固定长度数组容器类来演示其应用。


上一节我们介绍了课程的整体目标,本节中我们来看看移动语义这一核心新特性。
移动语义
移动语义是C++11引入的全新概念,旨在帮助开发者编写更高效的代码。C++的设计哲学是为专业人士提供最广泛的工具集,以构建高效的程序。与Python、Lisp或Java等基于解释器的语言不同,C++源自C语言,是一种系统实现语言,其目标是不隐藏硬件细节,允许开发者充分利用计算资源。移动语义正是为了进一步提升效率而引入的。
虽然许多开发者可能不会直接使用移动语义,但它已被深度整合到标准模板库(STL)中,极大地改善了库的性能。移动语义尤其适用于处理大型聚合数据的场景,例如容器类。
为了理解移动语义,我们将尝试设计自己的顺序容器类。C++11 STL中已经提供了std::array类,它是一个固定长度的数组模板。你可能会问,既然已经有了std::vector,为什么还需要固定长度的数组?std::vector是一个可扩展长度的顺序容器,提供了类似数组的语义和更多功能,但这种灵活性是有代价的。相比之下,原生的C风格数组虽然高效,但非常原始,容易导致“差一错误”或寻址错误。固定长度的数组类(如std::array)则提供了一个折中方案:它保持了原生数组的高效性,同时增加了一些额外的安全功能。
我们将构建一个自己的简化版本,称之为MyContainer。它是一个类模板,有两个模板参数:一个是存储的类型T,另一个是固定的大小N。
以下是该类的部分代码框架:
template <typename T, int N>
class MyContainer {
private:
T* data; // 指向数组基地址的指针
public:
// 构造函数应接收大小N,但注意:N是模板参数,而非构造函数参数。
MyContainer() : data(new T[N]) {}
// 析构函数
~MyContainer() { delete[] data; }
// ... 其他成员函数
};
请注意,构造函数本应接收大小N,但这里N是模板参数,因此它直接用于new T[N]的分配。析构函数使用delete[]来正确释放数组内存。
基于这段代码,声明MyContainer<int, 5> data;会产生什么?答案是它会创建一个包含5个int类型元素的数组,data是指向该数组基地址的指针。
这里有一个关键点:模板参数N(一个整型值)是类型的一部分。这意味着一个大小为10的整型数组(MyContainer<int, 10>)与一个大小为20的整型数组(MyContainer<int, 20>)是不同的类型。它们也不同于std::vector<int>和原生的C风格指针类型。

本节课中我们一起学习了C++11的移动语义概念及其对效率提升的意义,并通过设计一个固定大小的容器类模板,了解了模板非类型参数的使用。下一节我们将深入探讨Minimax算法及其在AI中的应用。
032:一些进一步的构造函数
在本节课中,我们将学习C++中构造函数的几个高级概念,包括如何抑制隐式转换、委托构造函数的使用以及传统的拷贝构造函数。这些知识对于编写健壮且高效的C++类至关重要。
抑制隐式转换的构造函数
上一节我们介绍了构造函数的基本概念,本节中我们来看看一个带有特殊关键字 explicit 的构造函数。
这是一个构造函数。这个构造函数的作用是什么?它展示了C++11中引入的几个新特性。
这是一个单参数构造函数。如果我们有一个单参数的构造函数,通常它会提供一个隐式转换的机会。使用 explicit 关键字可以关闭这种隐式转换。请记住,转换机会对C++语言非常重要,它们是C++语言多态性的重要组成部分。但我们不希望转换在任何地方静默地发生。
我们并不打算让一个任意的数组通过这种方式来初始化对象。这个构造函数的真正意图是从一个已存在的数组 B 来初始化一个 my_container 对象,其底层内容将由指针 a 指向。我们假设有足够的值,并且我们希望抑制自动转换。
以下是这个构造函数的关键点:
explicit关键字:用于抑制单参数构造函数带来的隐式类型转换。- 构造意图:该构造函数旨在从一个已存在的数组进行显式初始化。
委托构造函数
现在,这个构造函数另一个真正新颖的地方是使用了被称为“委托构造”的特性。这在旧的C++11之前的版本中是无法实现的。
这看起来像是基类派生类的概念。现在,这个旧的概念可以用于初始化列表中。这意味着,作为构造函数的一部分,我们可以利用该类已经定义的另一个构造函数,即构建类默认状态的构造函数。在默认构造函数中,我们使用 new 分配了 int 类型的元素数组。这样我们实现了代码重用,这是非常好的实践,实现了代码共享和代码重构。
所以,这就是委托构造的思想,它在C++11中是被允许的。
// 示例:委托构造函数
MyContainer::MyContainer(int* B, int size) : MyContainer() // 委托给默认构造函数
{
// ... 从数组B拷贝数据到a ...
}

拷贝构造函数
现在来看拷贝构造函数。这是一个普通的拷贝构造函数,回想一下,它应该是怎样的?
当我们构建类时,标准的构造函数有哪些?默认构造函数,其签名是空的。单参数构造函数,它可能提供转换。然后是拷贝构造函数,这就是这里展示的,其参数是 const 类型引用。
这个对象 B 被拷贝过来。这些通常是我们构建新类时需要考虑的通用事项。
其语义可能如下所示:我们获取已存在的 B.a[i],并将其复制到 this 指针所指向的 a[i] 中。这就是我们进行拷贝的地方。所以,这是传统的拷贝构造函数。
// 示例:拷贝构造函数
MyContainer::MyContainer(const MyContainer& other)
{
// 分配内存并拷贝 other.a 的内容到 this->a
}
构造函数类型总结

一个类典型的构造函数有哪些?同样,我已经给出了答案。

如果你一直在认真听讲,现在只需要告诉我我们刚刚讨论了什么。
以下是典型的构造函数类型:
- 默认构造函数:签名中无参数。
- 转换构造函数:单参数构造函数。我们可以使用
explicit关键字来抑制隐式转换。 - 拷贝构造函数:参数为
const类型引用。

本节课中我们一起学习了C++中构造函数的三个重要进阶主题:使用 explicit 关键字防止意外的隐式转换,利用C++11的委托构造函数实现代码复用,以及传统拷贝构造函数的实现方式。理解这些概念有助于你设计出更安全、更清晰的类接口。
033:移动构造函数
概述
在本节课中,我们将要学习C++11引入的一个新概念——移动构造函数。我们将探讨其语法、工作原理、效率优势以及适用场景,帮助你理解如何利用移动语义来编写更高效的程序。
移动构造函数的基本概念
上一节我们介绍了传统的拷贝构造函数。本节中我们来看看移动构造函数,这是一种新的、能显著提升效率的构造方式。
C++11引入了一个被称为“移动构造函数”的新特性。它使用双与号 && 作为语法标记。这个符号不再表示普通的引用,而是表示“右值引用”。
传统的单与号 & 用于“左值引用”语义。在表达式 A = B 中,A 是左值,B 是右值,它们具有不同的含义。移动构造函数与普通的拷贝构造函数不同,它旨在高效地“移动”资源,而非“复制”资源。
移动构造函数通常使用 noexcept 关键字进行修饰,承诺不会抛出异常。这是因为在移动过程中如果发生异常,资源清理会变得非常复杂。
以下是移动构造函数的核心操作逻辑:
// 浅拷贝(或引用拷贝)
new_A.data_ptr = old_A.data_ptr; // 新对象指向旧对象的数据
old_A.data_ptr = nullptr; // 旧对象的数据指针置空
通过这种“引用拷贝”,原本在对象B中的数据被“移动”到了对象A中,而对象B中的内容则消失了。这就是所谓的“浅拷贝”。对象B被置为空(nullptr),对象A则保留了信息。
移动语义的效率优势
这种移动操作的效率非常高。假设对象B中有一百万个元素,移动操作需要执行多少步骤呢?
它只执行了一个简单的地址赋值操作。因此,总共只有两个非常简单的赋值操作,在机器层面通常只需要几条存储指令,在几纳秒内即可完成。
这意味着,无论要移动的聚合体有多大,移动操作都只需要恒定的、极短的时间。其时间复杂度是常数阶 O(1)。

无论聚合体的大小如何,移动操作的成本都是固定的。相比之下,传统的拷贝构造函数中的 for 循环,其执行时间与聚合体的大小成正比。
因此,移动语义的效率是极高的。它是一种平凡的、常数时间的算法。你需要付出的代价是:必须清楚旧名称(被移动的对象)不再有效。这就像给一个东西起了新名字,是“移动”而非“克隆”。传统的拷贝构造函数是“克隆”,会保留旧数据。

理解“值语义”是掌握这一新知识的关键。如果你能真正理解这一点,对于想要开发类似STL库的公司来说,你将变得非常有价值。这是有效创建下一代STL风格聚合实体的必备知识。


理解声明与赋值
为了真正理解移动语义,我们需要回顾一些基础概念,特别是地址和声明。
在C或C++中,当你写 A = B 时,必须理解其含义:
- 左边的
A实际上代表一个地址。 - 右边的
B代表存储在该地址的值。 - 操作是将存储在
B地址的值,赋值到A所指向的地址。
因此,在赋值操作中,A 和 B 是不对称的。这不是一个数学运算,而是计算机术语中的“副作用”操作,这也是此类语言难以调试的原因之一。

现在,我们需要理解各种声明方式。以下是三种重要的声明:


int A; // 整型变量
int* A; // 指向整型的指针
int& A; // 左值引用(传统引用)
int&& B; // 右值引用(C++11新特性)


这个新的右值引用 int&&,可以将其理解为绑定到一个临时对象。这个对象即将消失,不会被再次使用。这正是我们希望使用移动语义的场景。否则,我们应继续使用C语言和早期C++世界中的传统语义。
移动赋值运算符
当我们为类实现了移动构造函数后,通常还需要重载赋值运算符,为其添加移动语义。

这是一个非拷贝的移动赋值操作,右侧操作数会被“销毁”。其操作逻辑与移动构造函数类似:
- 将当前对象(
*this)的数据指针指向右侧对象(rhs)的数据。 - 将右侧对象的数据指针置为
nullptr。 - 返回
*this的引用以支持链式赋值。
同样,这些操作的成本是固定的。用技术术语说,其效率是常数阶 O(1)。无论 D.data[n] 有多大,我们只是赋值了一个基础指针值,然后通过置空指针来“清除”右侧对象。

核心要点:如果我为一个大型聚合体构建移动语义,那么移动构造和移动赋值在本质上提供了相同的高效率。但对于非聚合体(例如单个 int),使用移动语义没有意义,因为不会获得任何效率提升。

移动语义的应用:高效交换
让我们看看移动语义的高效性在何处发挥作用。一个经典案例是实现高效的交换操作。
交换是算法中的基础操作,例如快速排序中需要不断交换两个元素。通常,交换两个简单值(如 int 类型的 a 和 b)需要一个临时变量,成本是固定的:
- 将第一个对象的值赋给临时变量。
- 将第二个对象的值赋给第一个对象。
- 将临时变量的值(即原第一个对象的值)赋给第二个对象。
但如果交换的是两个巨大的聚合体呢?移动语义在这里大显身手,因为它允许我们摆脱对临时“盒子”的依赖。我们知道放入临时“盒子”的数据没有最终用途,因此可以无成本地将其“移动”走。
以下是利用移动语义实现的高效交换:
// 使用 std::move 触发移动语义
std::swap(a, b); // 内部可能利用移动语义实现
通过使用 std::move,operator= 将使用重载的移动赋值语义。对象 b 的内容被移动到临时位置(或直接与 a 交换),然后原 a 的内容再被移动到 b。所有操作都通过浅拷贝完成。
这意味着,对于包含数百万个元素的对象,交换操作避免了与聚合体大小 N 成正比的成本。交换中的每一步都是 O(1) 操作。
这是一个巨大的效率提升。std::move 本质上是一种类型转换,用于获得正确的右值引用类型。所有的赋值都是引用式的,时间复杂度都是 O(1)。这实现了一个 O(1) 的交换例程,对于需要频繁操作大型聚合体的STL库和大数据处理场景来说,这是极佳的优化。

练习建议
为了测试这些新颖的概念,我推荐你进行以下练习:
以下是你可以尝试的具体任务:
- 修改容器类:拿一个你自己的容器类(例如
my_container_array),尝试修改其代码。 - 重载下标运算符:尝试重载下标运算符
[],以便能够选择容器中的元素。请注意,这必须重载为非静态类成员函数。 - 添加迭代器逻辑:将迭代器逻辑集成到你的
my_container类中。这将让你更好地理解如何构建一个STL风格的类。
我建议你独立尝试完成这些任务,以加深对移动语义和类设计的理解。



总结
本节课中我们一起学习了C++11的移动构造函数和移动语义。我们了解了其通过右值引用 && 实现的独特语法,以及它如何通过浅拷贝和资源所有权的转移来替代深拷贝,从而实现常数时间 O(1) 的高效操作。我们还探讨了移动赋值运算符的必要性,以及移动语义在实现高效交换等算法中的关键作用。掌握移动语义对于编写高性能的现代C++代码至关重要。
034:前瞻与Minimax算法
在本节课中,我们将学习如何为井字棋等游戏程序构建人工智能。核心在于创建一个能够出色对弈的自动化对手。对于无法完全求解的复杂游戏,实现某种形式的“前瞻”至关重要。我们将重点研究一种基础的前瞻算法——Minimax算法。
前瞻与Minimax算法
上一节我们提到了前瞻的重要性,本节中我们来看看其标准模型——Minimax算法。这是许多策略游戏(如国际象棋、跳棋、双陆棋)的标准模型。
游戏通常涉及两名玩家:玩家A和玩家B。玩家A走一步,玩家B回应,如此交替直到游戏结束。那么,人工智能(或人类)如何决定玩家A应该走哪一步呢?
玩家A在棋盘上有若干合法走法可供选择。这个数量变化很大:美国跳棋通常少于20步;国际象棋中局平均约40步;围棋中局则超过200步。由于无法轻易地调查或评估所有走法,人工智能需要方法来确定“合理的走法”,思考对手的回应,并评估最终结果。这就是策略游戏的标准思路,整个过程都涉及前瞻。

前瞻在树状图中进行,并通过Minimax算法处理。该算法的终点(即树的叶节点)通过以下方式评估:
- 如果游戏能像简单的井字棋一样走到终局,则使用已知的游戏结果。
- 对于国际象棋或跳棋等游戏,可能需要对某个局面应用良好的判断,给出静态评估值,然后通过这些评估值回溯以选择最佳走法。
Minimax算法原理
理解了前瞻的基本概念后,我们来深入Minimax算法的核心机制。该概念可追溯到20世纪中叶冯·诺依曼等人的研究。
算法假设存在一个最大化玩家和一个最小化玩家。最大化玩家是理性的,试图走出能获得最大收益的着法;最小化玩家则试图让对手输掉游戏。因此,最大化玩家追求更高的分数,最小化玩家追求更低的分数,形成一种不对称性。
以下是一个Minimax树示例:

在此树中,最大化玩家在顶层有三个分支。每个分支下又有进一步的走法(叶节点),底部标有评估值。
分析过程如下:
- 在左侧分支,最大化玩家可在值3和1中选择,最小化玩家显然会选择3。
- 在中间分支,最小化玩家别无选择,结果为4。
- 在右侧分支,最大化玩家在值5、6、7中选择,会选择7。

因此,如果分析正确,我们会预期最大化玩家选择第三个分支,最小化玩家则选择第一个分支。游戏将按此进行。每当机器需要走棋时,都会调用此算法。
算法深度与复杂度
我们已经了解了Minimax的基本流程,现在讨论一个关键约束:搜索深度。我们希望尽可能深入树中(即增加深度),以接近能进行准确评估的终局。如果能走到终局,就能确定输赢或平局,这是最优情况。
如果游戏是有限且足够小的(如井字棋),我们可以穷举所有可能,在叶节点根据游戏规则确定最终结果,然后使用Minimax回溯选择。这样就能得到一个完美的游戏玩家。例如,黑白棋(Reversi/Othello)虽然有一定复杂度,但借助数学方法减少需要调查的局面,计算机在约30年前就已能穷举其空间。
然而,对于国际象棋这样的游戏,两位世界冠军间的典型对局约有40-50步。由于一步棋实际包含白方走棋和黑方回应,因此平均对局约有100层深度。如果每层平均有40种合法走法,那么需要检查的节点数是 40^100,这是一个天文数字,无法通过朴素方法穷举。
因此,无论何种游戏,树的规模通常呈指数级增长,我们必须实际限制搜索的深度。
Minimax算法练习
为了巩固理解,我们来做一个小测验。以下是另一个Minimax树:

底部是静态评估值为2、5、6、1、3、9的叶节点。树的结构是:最大化玩家 -> 最小化玩家 -> 最大化玩家。如何评估这棵树?
以下是解答过程:
- 在最底层的最大化玩家选择中:
- 在2和5之间,选择5。
- 在6和1之间,选择6。
- 在单个值3和9处,没有选择,值分别为3和9。
- 上一层的最小化玩家选择中:
- 在5和1之间,选择1。
- 在6和3之间,选择3(因为3 < 6)。
- 在单个值9处,没有选择,值为9。
- 顶层的最大化玩家最终在值1、3、9之间选择,会选择9。
因此,这棵树对先手玩家有利,其预期收益值为+9。
注意,在这棵树中,并非所有叶节点都在同一层。例如在国际象棋中,当完成一系列子力交换后,局面会变得“静止”,此时可以在较浅的层进行静态评估;而对于存在大量吃子或将军的动态局面,则需要搜索得更深。这就是为什么叶节点可能出现在不同深度。
你应该能够将这一算法实现为你游戏AI的一部分。
总结

本节课中,我们一起学习了为游戏程序构建AI的核心——前瞻技术,并深入探讨了标准的Minimax算法。我们了解了最大化玩家与最小化玩家的对抗思想,学习了如何通过静态评估和回溯来选择最佳走法。同时,我们也认识到由于组合爆炸问题,在实际应用中必须限制搜索深度。掌握Minimax算法是编写策略游戏人工智能的重要基础。
035:合理的移动生成器
在本节课中,我们将探讨如何为复杂的棋盘游戏(如国际象棋和六边形棋)设计一个“合理的移动生成器”。由于这类游戏的分支因子极大,我们无法穷举所有可能的走法,因此需要一种智能的方法来筛选出最值得考虑的几步棋。
游戏规模与挑战
上一节我们介绍了游戏树搜索的基本概念。本节中我们来看看实际应用中的挑战。六边形棋将是一个非常庞大的游戏。为什么这么说呢?
我们通常在11x11的棋盘上进行六边形棋比赛。一个11x11的棋盘有121个位置。
这意味着在游戏开始时,一位玩家有121步合法走法,并且这个数字会逐一减少。因此,在游戏的大部分时间里,你的分支率——即游戏中的合法分支率——都超过100步。
这意味着即使是相对较短的搜索深度,比如10层(即向前看10步),可能性也将达到10的100次方,这是一个极其庞大的数字。所以在大多数情况下,我们无法展开这样一棵完整的树,然后应用某种评估标准。即使是简单的评估标准,走法和可能性也太多了,因此我们必须限制它们。
通常,我们使用一种称为“合理的移动生成器”的方法来限制搜索范围。
合理的移动生成器:历史与理念
以下是早期国际象棋程序中的做法。
早期在计算机上编程的国际象棋游戏可以追溯到20世纪50年代,甚至更早。在50年代编写的程序会下国际象棋,但下得不是很好。甚至在冯·诺依曼、香农、图灵等一些最著名的计算机科学和信息科学家之前,就已经有了下国际象棋的纸上算法。所有这些创造性思想家——他们实际上是计算机科学的奠基人,如图灵——都深入思考过如何创造一个智能的国际象棋棋手。
国际象棋的平均分支率约为40步。早期国际象棋程序中,合理的移动生成器通常只考虑大约5到7步棋。

你如何决定挑选哪些走法呢?你会去查阅文献,做国际象棋棋手所做的事。荷兰大师兼认知理论家德格鲁特的一项著名研究发现,即使是国际象棋大师在评估局面时,可能也只考虑几百种走法。因此,他们通常在合理的走法上非常有选择性。
开局手册中提到的合理走法包括:
- 吃子:这在跳棋中也适用。所以你会检查吃子的可能性。
- 将军:国际象棋有句老话:“有机会将军,为什么不试试?”因此,即使是初学者偶尔也能通过使用这些非常简单的想法击败更好的棋手,因为人类下棋会犯很多错误,他们可能会忽略局面中存在将军的可能性。
- 特殊规则:在游戏的早期阶段,通常有特殊规则。例如,在开局阶段,特殊规则包括“将兵走到中心”。为什么?因为控制中心是国际象棋对弈的重要理论要素之一。当你把兵走到中心时,不仅获得了控制权,还为你的后方棋子提供了更多空间。
在游戏的不同阶段,可能有不同的规则来生成合理的走法。在游戏的后期,即残局阶段,会非常注重推进兵(称为“通路兵”)。因此,在那个阶段,如果你有一个受保护的通路兵,推进它可能是一步合理的走法,因为将兵升变到对面的底线格(称为“升变格”)将具有非常高的价值,并能改变游戏局势。
应用于六边形棋
对于六边形棋,你必须审视类似的事情。那么,在六边形棋中什么是有用的呢?这同样可以作为你作业的一部分来思考。互联网上已经有很多六边形棋程序。
如果你不了解这个游戏,可以尝试玩一下那些程序,感受一下程序是如何下棋的。其中一些程序非常厉害,可能会让你感到挫败,它们会轻松击败你,但也许你能从中获得一些关于它们在做什么的启发,并可以发展出你自己关于合理走法的想法。
类似于在扩展的井字棋游戏中,合理的走法是走向棋盘中心。在六边形棋盘的中心,让你有更多机会或更多方式来扩展路径并阻挡对手的路径。
实例分析:国际象棋局面
那么,让我们尝试研究一下合理的走法。这是2011年加里和霍尔之间的一盘棋,是我在网上随机找到的一个对局。这里的问题是:白方走棋。什么是好棋?
同样,如果你试图查看所有合法走法,数量会很多。例如,移动这个兵(走一格或两格),移动那个兵(走一格或两格),这已经是4步棋了。这里有两个兵,所以是8步兵着法。然后象有大约八步走法。接着,后有很多条斜线可走,大约有14步后的走法。车大约有5步走法,另一辆车大约有10步。所以,粗略地说,在这个局面下你大约有30到40步棋要看。
早期国际象棋风格的合理走法生成器会说:看吃子,看将军。如果你会下国际象棋,你会认出这是一个非常动态的局面。后正受到威胁(这不是好事),象也受到威胁,尽管对王有一个牵制。但白方有一些攻击形式。
因此,一个合理的尝试是:吃子并将军。
这步吃子并将军实现了几个目的。它实现了什么?首先,它是强制性的,这使得评估变得容易。一旦将军,王必须要么垫将,要么吃掉将军的棋子,要么移出将军。
如果我们看这步车吃兵将军:
- 王不能移开,因为后保护着王唯一可以移开的格子,这是这个组合的关键。
- 我们可以把对手的后放在这里垫将,但那样我们只需要后吃后,就将死了。所以我们不会那样做。
- 因此,明显的应对是吃子,因为这将导致立即被将死。
唯一的吃子机会是兵吃兵。这里,如果你是国际象棋棋手,会知道一个标准主题:一旦发生这个吃子,并且这样回吃,这个格子就对象开放了。象走到这里,再次对王形成将军。

所以,再次注意,将军和吃子是非常有力的着法。
此时,只有一种方法可以挽救对局,那就是把后移过来挡住那个格子。然后象可以吃后将军,并且仍然是将军。现在,唯一的走法是强制性的:王吃象。现在我们有一个相对简单的获胜过程,我们可以直接走后吃车。随着这个车被吃掉(这是对方唯一有分量的棋子),象在这里,然后车吃后,车吃车,现在这位棋手多一个车、一个象和一个健康的兵,所以游戏结束了。
因此,这是一个非常简单的动态局面,几乎任何从70年代中期开始的计算机程序都能正确应对。在此之前,早期程序是否会这样下可能更具变数。如果你回顾一些国际象棋程序的历史,它们在80年代达到了大师水平,在70年代早期达到了普通俱乐部棋手水平,然后在90年代通过IBM深蓝与卡斯帕罗夫那场著名的百万美元比赛,达到了世界冠军水平。
设计你的六边形棋AI
现在转向六边形棋。我将如何思考六边形棋?这是你的作业。你必须为六边形棋手制作这种人工智能。
以下是一些你可能会问的、用于生成合理走法的问题:
- 制胜着法:是否存在一步棋能让你完成一条路径(如果你是南北向玩家)?
- 阻挡着法:如果我没有制胜着法,但对手在下一回合有制胜着法,我需要走一步阻挡着法。
- 扩展路径:如果没有那么关键,那么我是否有办法扩展我最长的局部路径?
- 阻挡对手路径:同样,我能阻挡对手的最长路径吗?
- 特殊着法(桥):如果你看过任何六边形棋文献,你会看到有一种称为“桥”位置的着法。你可能想尝试通过一步桥着来建立或扩展一条潜在路径。
因此,通过尝试生成这类走法,将是为你第一个版本的六边形棋程序构建一些人工智能的方法。
同样,在这类游戏中,另一个总体规则是:中心的着法比边缘的着法更有价值,因为它们能带来更多的可能性。
局面评估
关于评估,在某些游戏中,游戏相当简单。例如在井字棋中,你只需看谁赢了。所以,如果你能识别出游戏结束,那是一种评估方式,但并不总是可行,因为如果搜索树足够深,你可能无法到达这样一个终点,那么你可能需要一些其他的评估函数。
例如,在国际象棋中,一个典型的教给初学者的评估函数是累加子力价值。我最初学国际象棋时有一本著名的书叫《计点国际象棋》,书中教到:兵值1分,马或象值3分,车值5分,后值9分。你不能给王定价,因为失去王(即被将死)意味着游戏结束。所以,你只需累加你的棋子价值,减去对手的棋子价值。如果你子力平衡为零,那么根据这个经验法则,用象换马保持平衡,但如果我能用你的象换一个车,我就赚了2分,或者如果我能直接吃掉一个像后这样的棋子,我就赚了9分,这在几乎所有情况下都是获胜优势。
如果我们玩西洋双陆棋,西洋双陆棋是一种棋子绕棋盘赛跑并试图在棋盘一端移离的游戏。一个典型的衡量谁领先的方法是计算完成赛跑还需要多少格,在简单层面上,通常认为在赛跑中领先的一方正在赢得游戏。但这些都非常简单。当你进入更高级的西洋双陆棋或更高级的国际象棋时,会有其他因素起作用,比如在国际象棋中,有主动权因素、空间优势因素;在西洋双陆棋中,有棋盘上的阻挡局面因素。
所以,这些非常简单的想法虽然让你对如何下棋有一些了解,但如果你要创造越来越好的AI来玩这些游戏,就必须进行修改。
课程模型与后续内容
以下是通常的模型:你有新一代的走法生成,你有一种称为“前瞻搜索”的东西。这是我在本课程中要重点关注的。
我们已经看到了极小化极大算法。在下一节中,我将向你展示一种更精细、算法效率更高的方法,它实现了与极小化极大算法等效的功能,称为Alpha-Beta剪枝。Alpha-Beta剪枝就像排序,有几种方法可以得到相同的结果。Alpha-Beta是一种你可以执行极小化极大搜索,但效率高得多的方式。因此,它更受青睐。极小化极大算法理解起来稍微简单一些。
然后,我们将看一种截然不同的方法,称为蒙特卡洛方法。我们已经见过蒙特卡洛算法,这些算法中我们使用某种概率集来模拟情况。事实证明,我们可以在棋盘上使用蒙特卡洛模拟来获得评估,这实际上令人惊讶。当我们讲到蒙特卡洛方法时,会进一步讨论。
总结与作业
所以,通常的模型是,你必须有一些方法来评估静态局面。这当然取决于游戏,你可以从文献中获得。在六边形棋中,评估可能基于谁拥有跨越六边形棋盘的最长潜在路径?拥有更长路径的人可能领先,如果你无法阻挡那条路径,我就会赢。因此,更长的路径是评估六边形棋局面的关键方式。在跳棋或国际象棋中是子力计数,在西洋双陆棋中,我们刚刚讨论过,有某种关于谁在赛跑中领先的概念,或者更复杂的“据点”概念。
现在,在实现你第一个版本的六边形棋程序时,我希望你思考一些有效的方法来进行合理的移动生成。可以说,阅读关于极小化极大和Alpha-Beta剪枝的资料,获取更多关于如何进行评估、实现算法以及合理移动生成的信息。你也可以阅读关于六边形棋的资料,查看桥着法。因此,桥着法和中心格、路径扩展,所有这些都应该为你提供一些生成合理走法的方法。
然后,你需要对产生的静态节点(可称为叶节点)进行一些评估。在六边形棋中,这可能基于查看你与对手之间最长的潜在路径是什么。
本节课中我们一起学习了为何需要“合理的移动生成器”来应对复杂游戏庞大的分支因子,探讨了其在国际象棋中的历史应用和核心思想(如关注吃子、将军),并将其思路迁移到六边形棋的AI设计中。我们还分析了具体的国际象棋局面,并为你设计六边形棋程序提供了评估函数和走法生成的具体思路。

下一节我们将深入Alpha-Beta剪枝算法的细节,并展示如何实现一个比简单极小化极大算法更高效的算法。
036:Alpha-Beta算法预览与C++多态性应用
在本节课中,我们将学习两个核心主题。首先,我们将介绍一种名为Alpha-Beta剪枝的计算效率更高的Minimax算法变体。其次,我们将深入探讨C++中的纯多态性,通过一个精彩的示例来展示如何利用继承和虚函数来评估波兰表达式。本节课旨在阐明现代面向对象技术的强大能力。

Alpha-Beta剪枝算法简介
上一节我们讨论了基础的Minimax算法。本节中,我们来看看一种能显著提升其计算效率的优化版本——Alpha-Beta剪枝算法。
Alpha-Beta剪枝算法拥有悠久的历史,至少可以追溯到20世纪50年代。关于其发明者,存在多种说法,可能有三到五个不同的个人或团体。对于任何深入研究使用Minimax算法的游戏编程人员来说,这似乎是一种自然而然的发现。Minimax算法本身主要由冯·诺依曼和摩根斯坦为完全信息博弈而系统阐述。
当这些算法开始在跳棋、国际象棋等程序中实现时,一些早期的实践者,例如在IBM实现了著名跳棋程序的Arthur Samuel,也被认为是可能的发明者之一。可以说,许多人可能都独立发现了它,因为它本质上是一种计算上的捷径,而非某种基础性的理论突破。
然而,该算法在计算上的重要性及其所能带来的指数级性能提升,在数学上得到了我们当代最伟大的算法专家之一——斯坦福大学的Donald Knuth的详细论证。Knuth同时也是一位真正杰出的程序员。

Donald Knuth的贡献
为了让您了解他的一些成就,以下是Donald Knuth的主要贡献领域:
- 他因《计算机程序设计艺术》系列丛书而闻名于世,这些书深入剖析了快速排序、随机数生成等诸多算法。
- 他发明了排版系统TeX。当时他因对自己著作的排版效果不满,从而创造了这一工具。如今,TeX及其衍生系统LaTeX已成为理论计算机科学和数学论文排版的行业标准。
- 他在语法分析领域贡献卓著,发明了称为LR分析的高效基础分析方法之一。
- 他对Alpha-Beta剪枝、排序算法等进行了大量最坏情况分析,并运用了对手论证等方法。
探索他的任何工作成果都极具价值,而Alpha-Beta剪枝算法正是其研究领域的一部分。
总结

本节课中,我们一起学习了Alpha-Beta剪枝算法的背景及其在提升Minimax算法效率方面的重要性。同时,我们也简要介绍了算法大师Donald Knuth的相关贡献,为后续深入理解算法细节奠定了基础。接下来,我们将转向C++多态性的实际应用部分。
037:Alpha-Beta剪枝对Min-Max算法的改进
概述
在本节课中,我们将学习一种名为Alpha-Beta剪枝的优化技术,它能够显著提升Min-Max算法的效率。我们将通过具体的例子,理解其工作原理以及如何在游戏树搜索中节省计算资源。
Min-Max算法回顾
上一节我们介绍了Min-Max算法,它通过递归地评估游戏树来决定最佳走法。算法中有两种角色:最大化者(Maximizer)和最小化者(Minimizer)。最大化者试图获得尽可能高的分数,而最小化者则试图获得尽可能低的分数。

在诸如国际象棋或Hex的游戏中,我们可以为棋盘局面设定一个评估分数。例如,一个大的正分可能意味着白方即将获胜,而一个大的负分则意味着黑方即将获胜。我们可以将分数归一化,例如白方胜为1,黑方胜为-1,平局为0。但在实际中,我们常使用更自然的评分尺度,例如在国际象棋中根据棋子价值和局面优势来评分。
Min-Max算法的核心是分数回溯。从叶子节点(即游戏结束或达到搜索深度的局面)的静态评估开始,分数在最大化层和最小化层之间交替向上传递。
最大化节点公式:V = max(children)
最小化节点公式:V = min(children)
最终,树根节点的值就代表了当前局面对先手玩家而言的游戏价值。
Alpha-Beta剪枝原理
本节中我们来看看Alpha-Beta剪枝如何改进Min-Max。其核心思想是:在搜索过程中,如果发现某条分支不可能影响根节点的最终决策,就停止对该分支的深入搜索(即“剪枝”),从而节省大量计算。
关键在于理解“至少值”这个概念:
- 对于一个最大化节点,它知道自己至少能获得某个分数(称为 α)。
- 对于一个最小化节点,它知道自己至多会让对手获得某个分数(称为 β,即对手至少能获得β)。
当搜索到一个节点时,如果发现当前节点的α值已经大于或等于其父节点的β值(对于最小化节点),或者当前节点的β值已经小于或等于其父节点的α值(对于最大化节点),那么剩余的分支就无需再搜索了。

Alpha-Beta剪枝实例分析
以下是Alpha-Beta剪枝在一个具体游戏树上的工作过程。我们将一步步跟踪算法的逻辑。
首先,我们从根节点(最大化者)开始搜索。初始时,α = -∞, β = +∞。

-
搜索左子树(路径A):我们首先评估最左边的分支。叶子节点值为5和4。在它们的最小化父节点上,会选取最小值4。这个值4被传递到上一层的最大化节点。此时,该最大化节点知道它至少能获得4分(α更新为4)。
-
发生第一次剪枝:现在,算法开始搜索中间子树(路径B)。它首先评估得到叶子节点值7。在中间子树的最小化节点处,它看到对手(最大化者)在这个分支上至少能得7分。然而,根节点处的最大化者已经知道在左分支(路径A)上自己至少能得4分。对于根节点这个最小化者(注意:根节点的父视角是最大化者,但当前节点是根节点的子节点,角色交替)来说,如果它选择路径B,对手将得到至少7分,这比路径A给对手的4分更差(对最小化者而言)。因此,路径B中剩余未搜索的分支(值为0的叶子节点)不再需要评估,因为最小化者绝不会主动选择这条让对手得分更高的路径。这就是一次Alpha-Beta剪枝。

- 继续搜索并发生更多剪枝:算法回到根节点,其当前最佳值(对最小化者而言)是4(来自路径A)。接着搜索右子树(路径C)。
- 在路径C的第一个分支,评估得到8和4,最大化节点选8,传递到其父节点(最小化者)。
- 该最小化者看到下一个分支的叶子节点值是3,它会将值更新为3(因为3 < 8)。
- 此时,对于根节点这个最大化者来说,路径C目前只能提供3分,而它已经拥有一个至少4分的选项(路径A)。因此,路径C中剩余的所有分支被剪枝。
- 同理,在评估其他分支时,一旦发现某个分支的临时值不如已知的最佳选项,就会触发剪枝。


通过这个过程,大量灰色的节点无需进行静态评估,从而节省了计算。剪枝的效率高度依赖于子节点(走法)的搜索顺序。如果能优先搜索可能最优的走法(即“最佳优先”),就能触发更多、更早的剪枝,从而最大化节省计算量。

算法特性与要点
以下是关于Alpha-Beta剪枝的几个关键点:
- 历史:该算法在20世纪50年代被多位研究者独立发现。
- 核心变量:算法维护两个值——α(最大化者至少能保证的分数下界)和β(最小化者至少能让对手得到的分数上界)。初始化时,α = -∞, β = +∞。
- 剪枝条件:
- 在最小化节点,如果当前β ≤ 父节点的α,则剪枝。
- 在最大化节点,如果当前α ≥ 父节点的β,则剪枝。
- 搜索顺序的重要性:为了达到最优剪枝效果,应该尽量将估计较好的走法优先进行搜索。这在理论上可以将搜索深度加倍,即用相同的计算时间,Alpha-Beta剪枝可以搜索到Min-Max算法两倍深的局面。

练习与自测
为了巩固理解,请尝试对下面这个游戏树进行Alpha-Beta剪枝搜索,并标出所有被剪枝的节点。

提示:按照深度优先、左优先的顺序模拟搜索过程,并时刻更新每个节点的α和β值,在满足剪枝条件时停止搜索该节点的剩余分支。
参考答案思路:
- 从左子树开始,最大化节点在5和4中选5,传递5。
- 进入中间子树,第一个叶子节点为7,导致该分支的β值变为7。由于根层级的最小化者已经有一个≤5的选择,而7>5对最小化者不利,因此中间子树的其余部分被剪枝。
- 进入右子树,在第一个分支得到8,随后在下一个分支看到3,最小化者将值更新为3。
- 此时根节点的最大化者已经有一个≥5的选项,而右子树只能提供3,因此右子树的其余部分被剪枝。
最终,游戏值为5,并且只评估了部分叶子节点就得到了结论。
总结

本节课中我们一起学习了Alpha-Beta剪枝算法。它是Min-Max算法的一种优化,通过维护α(下界)和β(上界)值,在搜索过程中识别并剪除那些不可能影响最终决策的分支,从而极大地提升了搜索效率。记住,实现Alpha-Beta剪枝时,走法排序是影响其性能的关键因素。在你的Hex游戏或其他博弈程序中应用此算法,可以有效增加搜索深度,做出更智能的决策。
038:波兰表示法
概述
在本节课中,我们将学习如何评估波兰表达式。波兰表示法是一种无需括号即可明确运算顺序的数学表达式表示方法,它是某些计算器架构和编译器解析技术的基础。我们将通过一个具体的例子,理解其工作原理,并学习如何用C++相关的面向对象思想来解析和计算这类表达式。
波兰表示法简介
上一节我们介绍了表达式计算的基本概念,本节中我们来看看波兰表示法。我们通常使用的表达式格式称为“中缀表示法”,例如 (A + B) * C,它依赖括号来确定运算顺序。
波兰表示法则通过操作符和操作数的特定排列顺序来消除对括号的依赖。它分为前缀(波兰表示法)和后缀(逆波兰表示法)两种形式。本教程主要关注逆波兰表示法。
在逆波兰表示法中,操作符位于其操作数之后。例如,中缀表达式 3 + 4 在逆波兰表示法中写作 3 4 +。这意味着:对两个参数 3 和 4 应用二元加法运算符 +。两者计算结果都是 7。
逆波兰表示法如何工作
为了理解逆波兰表示法的计算过程,我们需要引入栈数据结构的概念。计算过程遵循一个简单的规则:从左到右扫描表达式,遇到操作数则压入栈;遇到操作符则从栈顶弹出所需数量的操作数进行计算,并将结果压回栈中。
以下是逆波兰表达式 9 6 + 3 2 - * 的计算步骤分解:

- 遇到
9:压入栈。栈:[9] - 遇到
6:压入栈。栈:[9, 6] - 遇到
+:弹出栈顶两个元素6和9,计算9 + 6 = 15,将结果15压入栈。栈:[15] - 遇到
3:压入栈。栈:[15, 3] - 遇到
2:压入栈。栈:[15, 3, 2] - 遇到
-:弹出栈顶两个元素2和3,计算3 - 2 = 1,将结果1压入栈。栈:[15, 1] - 遇到
*:弹出栈顶两个元素1和15,计算15 * 1 = 15,将结果15压入栈。栈:[15]
表达式扫描完毕,栈中剩余的 15 即为最终结果。
表达式树
理解了计算流程后,我们来看看表达式在计算机内部的另一种表示形式——表达式树。表达式树是一种二叉树,它能直观地反映表达式的结构和运算优先级。
在表达式树中:
- 叶子节点 存放操作数(数值)。
- 内部节点 存放操作符。
计算表达式树的值,本质上就是按照树的结构,从叶子节点向上,在内部节点执行相应的运算。
例如,中缀表达式 (6 * (4 + 5) - 25) / (2 + 3) 对应的表达式树和逆波兰表示法如下:
表达式树结构:
[/]
/ \
[-] [+]
/ \ / \
[*] 25 2 3
/ \
6 [+]
/ \
4 5
计算步骤:
- 计算
4 + 5 = 9 - 计算
6 * 9 = 54 - 计算
54 - 25 = 29 - 计算
2 + 3 = 5 - 计算
29 / 5 = 5.8
对应的逆波兰表达式: 6 4 5 + * 25 - 2 3 + /
可以看到,对表达式树进行后序遍历(左子树 -> 右子树 -> 根节点),得到的节点序列正好就是逆波兰表达式。这揭示了逆波兰表示法与表达式树遍历之间的深刻联系。
面向对象的表达式解析方法
本节我们将探讨一种优雅的、基于面向对象编程思想的表达式解析和求值方法。这个方法由C++领域的杰出贡献者Andrew Koenig提出,它巧妙地利用了多态和虚函数,展示了面向对象设计的强大能力。
Andrew Koenig是C++发展过程中仅次于Bjarne Stroustrup的重要贡献者,他的著作深入浅出,非常值得学习。
该方法的核心理念是:将表达式中的每个元素(无论是数字还是操作符)都视为一个对象,这些对象都属于一个共同的基类(例如 Expr_Node)。每个对象都有一个 evaluate() 虚函数。
- 对于数字节点,
evaluate()直接返回其存储的数值。 - 对于操作符节点,
evaluate()会调用其左右子节点(也是Expr_Node)的evaluate()方法,获取它们的值,然后执行相应的运算并返回结果。
通过这种方式,整个表达式的求值过程可以通过从根节点开始,递归调用 evaluate() 来完成。这种方法干净地将表达式的结构与求值逻辑分离开,易于扩展新的操作符。
以下是该设计模式的简化代码框架描述:
class Expr_Node {
public:
virtual double evaluate() const = 0; // 纯虚函数
virtual ~Expr_Node() {}
};
class Number_Node : public Expr_Node {
double value;
public:
Number_Node(double v) : value(v) {}
double evaluate() const override { return value; }
};
class Addition_Node : public Expr_Node {
Expr_Node* left;
Expr_Node* right;
public:
Addition_Node(Expr_Node* l, Expr_Node* r) : left(l), right(r) {}
double evaluate() const override {
return left->evaluate() + right->evaluate();
}
};
// 类似地定义 Subtraction_Node, Multiplication_Node 等
总结

本节课中我们一起学习了波兰表示法,特别是逆波兰表示法。我们从其基本概念和手动计算步骤入手,理解了它如何利用栈来无歧义地求值表达式。接着,我们探讨了表达式树这一重要数据结构,并发现了逆波兰表达式与表达式树后序遍历之间的等价关系。最后,我们介绍了一种由Andrew Koenig提出的、基于多态和虚函数的面向对象表达式解析方法,这种方法结构清晰、扩展性强,体现了C++面向对象设计的精髓。掌握这些内容,不仅有助于理解某些计算器的工作原理,也为学习编译原理中的语法解析打下了坚实的基础。
039:引用计数垃圾回收
概述

在本节课中,我们将学习如何编写一个能自动管理内存的程序。这个程序的核心是引用计数垃圾回收机制。我们将看到如何利用这个机制,以一种通用的、多态的方式来求值表达式。这个例子虽然简洁,但包含了多个强大的编程思想。
引用计数垃圾回收的核心思想
上一节我们介绍了表达式求值的概念,本节中我们来看看如何高效地管理表达式树这类大型聚合对象。
我们并不希望程序运行时无限制地复制整个表达式树。相反,我们需要一种高效的机制来表示多棵树。当出于某种目的需要克隆一棵树时,我们希望这种克隆是浅拷贝或引用拷贝。
在引用计数垃圾回收中,我们不进行完整的复制,而是复制指向聚合对象的指针。理解指针和解引用是精通C/C++编程的关键。在C中,你需要掌握malloc和free;在C++中,则是new和delete。
可以这样理解:假设你在图书馆需要使用《大英百科全书》。这本百科全书价格昂贵(例如1000美元),图书馆不会外借,通常也只有一份副本。但这唯一的一份副本可以服务许多人,因为书的内容不会被修改,每个人都可以指向他们需要使用的部分。同样地,在我们的程序中,当不需要制造一个新副本,而仅仅是指向一个现有副本时,我们就可以进行引用拷贝——只需建立一个新的指针指向聚合对象中的正确位置。
然而,我们可能会修改这些聚合对象。在修改时,可能需要丢弃一些对象。这就需要知道何时删除一个对象,这通常通过使用计数或引用计数来实现。
在每个聚合对象中,会有一个特殊的变量来记录“有多少指针指向我”。只要还有人在使用这个对象(引用计数大于0),我们就不能销毁它。只有当引用计数降为0时,我们才真正删除这个聚合对象。任何时候我们创建一个新的实例(例如修改一个旧实例时),我们会创建一个新实例并将其引用计数设为1。这就是引用计数垃圾回收的核心。
程序结构设计
现在,让我们看看这个程序的具体结构。我们将通过一个表达式求值的例子来展示这些思想。
抽象基类:Node

表达式树的基本元素是节点。节点将由一个我们称之为抽象基类的东西来表示。
以下是Node类的关键设计要点:
Node类将与Tree类紧密协作,它们需要互相访问对方的私有或保护成员以实现高效操作,这是一种紧密的“友元”关系。- 为了实现多态行为,我们将使用一个继承层次结构,因此成员需要被声明为
protected。 - 类中包含虚函数。特别需要注意的是,析构函数被声明为虚函数。通过本示例的学习,你需要理解为什么这必须是一个虚函数。
- 函数声明后的
= 0表示这是一个纯虚函数。纯虚函数没有具体的实现,必须在派生类中重写。 - 包含纯虚函数的类称为抽象基类,这意味着不能创建该类的实例对象。这是因为如果存在实例,就可以调用这些没有意义的纯虚函数,这是不允许的。
以下是Node类的代码框架:
class Node {
protected:
// 引用计数器,记录有多少指针指向此节点
int use;
public:
Node() : use(1) {}
// 虚析构函数至关重要
virtual ~Node() {}
// 纯虚函数,使Node成为抽象基类
virtual int eval() const = 0;
virtual void print(std::ostream& os) const = 0;
};
聚合类:Tree
回想之前我们通过遍历树来求值波兰表达式。这里的Tree类就代表这样一棵表达式树。
Tree类与Node类存在紧密的“友元”关系。注意,Node是一个抽象基类,因此不能有具体的Node实例,但可以有指向Node的指针。这个指针是基类类型,这使得它具有多态性。
这意味着,根据Tree中实际指向的节点类型(例如,后面会定义的执行二元加法的BinaryNode),通过这个基类指针调用求值函数时,多态机制(动态绑定)会自动调用类层次结构中正确的成员函数。正是这些精妙思想的相互作用,为我们提供了一个强大、简洁且易于扩展的解决方案。

Tree类包含多种构造函数,用于创建不同类型的树:
- 包含整数的树(如叶子节点5)。
- 包含变量(用简单字符表示)的树。
- 包含一元运算符的树。
- 包含二元运算符并作用于两棵子树的树。
此外,还有一个关键的拷贝构造函数,它实现了引用拷贝。析构函数和赋值运算符重载也都会利用use引用计数。求值函数eval()通过基类指针调用,由于eval()是虚函数,因此会进行多态调用。
以下是Tree类的代码框架:
class Tree {
// 多态指针,指向抽象的Node
Node* p;
public:
// 各种构造函数
Tree(int); // 整数常量树
Tree(char); // 变量树
Tree(const std::string&, const Tree&); // 一元运算符树
Tree(const std::string&, const Tree&, const Tree&); // 二元运算符树
// 拷贝构造函数(实现引用拷贝)
Tree(const Tree& t) : p(t.p) { ++p->use; }
// 析构函数
~Tree() { if (--p->use == 0) delete p; }
// 赋值运算符重载
Tree& operator=(const Tree& t);
// 求值函数(通过多态调用)
int eval() const { return p->eval(); }
};
引用拷贝与删除的具体实现
让我们聚焦于上一张幻灯片中的两个关键元素,它们展示了引用拷贝和删除的具体工作方式。
拷贝构造函数:
如果已经存在一个《大英百科全书》(一个聚合对象),现在有另一个人也需要它,并且我们不打算修改它,那么我们只需给这个人一个新的指针,并增加引用计数。
如果原来有1个人在使用,拷贝后就有2个人在使用。引用计数可以持续增加。
注意,拷贝的时间复杂度是常数O(1)。因为无论聚合对象(百科全书或树)有多大,我们只需要增加引用计数和进行一次指针赋值。
析构函数:
如果图书馆中的某个人要离开了,不再需要访问《大英百科全书》,那么这个人对应的对象就被销毁。我们会调用析构函数,它执行以下操作:减少引用计数,并检查引用计数是否变为0。只有当引用计数变为0时,我们才执行真正的、具体的删除操作。
这保护了我们,直到确实不再需要这个聚合对象时才将其删除。

多态打印例程
程序中还有一个多态的打印例程。同样,我们将基于指针进行操作。p是指向Node的指针,它是节点层次结构的顶层。因此,它在运行时动态地调用适当的print函数。然后我们重载输出操作符<<,使得打印操作能够在整个树中多态地进行。
以下是打印功能的代码示例:
std::ostream& operator<<(std::ostream& os, const Tree& t) {
t.p->print(os); // 多态调用print
return os;
}
总结
本节课中,我们一起学习了引用计数垃圾回收机制在C++表达式求值程序中的应用。我们深入探讨了以下核心概念:
- 引用计数:通过
use计数器跟踪对象被引用的次数,仅在计数为0时删除对象,实现了高效的浅拷贝(引用拷贝)。 - 抽象基类与纯虚函数:使用包含纯虚函数(如
eval() = 0)的Node类作为抽象基类,强制派生类实现特定接口,并使得类本身无法实例化。 - 多态与虚函数:利用基类指针(
Node*)和虚函数(特别是虚析构函数),在运行时动态调用正确的派生类方法,实现了优雅的表达式求值。 - 程序结构:设计了
Tree类与Node类层次结构协同工作,通过友元关系实现高效访问,并通过多态机制使解决方案简洁且易于扩展。

这个例子综合展示了如何通过结合这些高级C++特性,构建出内存高效、行为正确且易于维护的程序。
040:抽象基类与表达式树
在本节课中,我们将学习如何利用抽象基类来构建一个表达式树。我们将看到如何定义抽象的节点类,并从中派生出具体的节点类型,如常量、变量和运算符节点,最终实现一个可以求值和打印的表达式系统。
抽象基类:LeafNode

上一节我们介绍了抽象基类的概念,本节中我们来看看一个具体的抽象基类 LeafNode。它继承自另一个抽象基类 Node,这展示了抽象类可以从另一个抽象类派生。
LeafNode 之所以是抽象的,是因为它的 print 和 evaluation 函数仍然没有具体的实现定义。
class LeafNode : public Node {
public:
virtual void print() = 0; // 纯虚函数
virtual int eval() = 0; // 纯虚函数
};
具体的叶节点类型(如常量节点、变量节点)需要从 LeafNode 派生,并重写这些函数以提供实际的功能。

具体叶节点类型
以下是几种具体的叶节点类型,它们都继承自 LeafNode 并实现了具体的功能。
常量节点 (IntNode)
常量节点是最简单的情况。它代表一个固定的整数值,例如数字 6。该表达式的值就是它本身。

class IntNode : public LeafNode {
private:
int value;
public:
IntNode(int v) : value(v) {} // 构造函数
void print() override { std::cout << value; }
int eval() override { return value; }
};
对于值为 7 的节点,调用 print() 会输出 7,调用 eval() 会返回值 7。
变量节点 (VarNode)
变量节点代表一个变量,例如字母 a。我们采用一个简化的模型:变量名是一个 ASCII 字符,其值存储在一个数组中。
class VarNode : public LeafNode {
private:
char name;
public:
VarNode(char n) : name(n) {} // 构造函数
void print() override { std::cout << name; }
int eval() override { return lookupValue(name); } // 假设有一个查找函数
};
对于名为 a 的节点,print() 会输出 a,而 eval() 会返回存储在 a 中的当前值(例如 3)。


一元运算符节点 (UnaryNode)
现在,我们来看看更复杂的节点类型。一元运算符节点,例如一元负号 -,它作用于一个操作数。

class UnaryNode : public Node {
private:
char op;
Node* operand;
public:
UnaryNode(char o, Node* opnd) : op(o), operand(opnd) {}
void print() override {
std::cout << "(" << op << " ";
operand->print();
std::cout << ")";
}
int eval() override {
int val = operand->eval();
switch(op) {
case '-': return -val;
case '+': return val; // 一元正号,值不变
default: return 0; // 错误处理
}
}
};
对于表达式 -a,print() 会输出 (- a),eval() 会先计算 a 的值,然后取其相反数。
二元运算符节点 (BinaryNode)
最有趣的情况是二元运算符节点,例如加法 +、减法 -、乘法 *。它包含左子树和右子树两个操作数。
class BinaryNode : public Node {
private:
char op;
Node* left;
Node* right;
public:
BinaryNode(char o, Node* l, Node* r) : op(o), left(l), right(r) {}
void print() override {
std::cout << "(";
left->print();
std::cout << " " << op << " ";
right->print();
std::cout << ")";
}
int eval() override {
int lval = left->eval();
int rval = right->eval();
switch(op) {
case '+': return lval + rval;
case '-': return lval - rval;
case '*': return lval * rval;
default: return 0; // 错误处理
}
}
};
这个设计的关键在于递归和多态。print() 和 eval() 函数会递归地遍历整个表达式树。eval() 会先计算左子树的值,再计算右子树的值,最后根据运算符组合它们。


系统的可扩展性
这个表达式求值方案很容易扩展。如果你有兴趣,可以轻松地添加更多运算符(如除法 /、取模 %)或更复杂的功能。
整个系统通过多态和递归无缝协作。求值过程最终会递归到叶节点(常量或变量),从而获取基础值。

本节课中我们一起学习了如何用抽象基类构建一个表达式树。我们定义了抽象的 Node 和 LeafNode 类,并从中派生了具体的常量节点、变量节点、一元和二元运算符节点。通过重写虚函数和利用递归,我们实现了一个可以打印和求值任意复杂算术表达式的灵活系统。这个模式是 C++ 中利用多态处理树形结构的经典范例。
041:树构造函数与表达式求值

在本节课中,我们将学习如何通过构造函数构建表达式树,并利用多态性来遍历和求值。我们将分析一个具体的代码示例,该示例综合运用了面向对象设计、继承、垃圾回收和解析等多个核心概念。
表达式树的构建 🏗️

上一节我们介绍了树结构的基本概念,本节中我们来看看如何通过一系列构造函数调用构建出具体的表达式树。程序会调用相应的节点构造函数。
以下是构建过程的核心步骤:
- 创建表示常量和变量的叶子节点(如
-5,a)。 - 使用二元运算符节点(如
*,+)将子树组合起来。 - 通过复制构造函数高效地复用已有的子树。

最终,我们得到一个由基类指针 Expr* 管理的树形结构。
表达式树的遍历与输出 🔄
构建好树之后,我们需要遍历它以输出表达式并计算结果。这里使用了操作符重载和多态性。
以下是遍历与输出的关键机制:
- 重载的
<<操作符会隐式调用树的print方法。 print方法执行一次树遍历,将波兰表示法(无括号)的表达式转换为完全括号化的中缀表示法。- 同时,
eval方法会递归计算整个表达式树的值。
示例分析与逐步求值 📝
现在,让我们通过一个具体例子来理解整个过程。假设有两个变量:a = 3, b = 4。
我们首先构建表达式树 t1,其对应的表达式为 (-5) * (a + 4)。
以下是 t1 的构建与求值步骤:
- 构建叶子节点
-5和4。 - 构建变量节点
a。 - 用
+运算符节点组合a和4,形成子树。 - 用
*运算符节点组合-5和上一步的子树,形成t1。 - 输出
t1:((-5)*(a+4))。 - 求值
t1:(-5) * (3 + 4) = -35。
接着,我们构建更复杂的表达式树 t2,其表达式为 (t1 + b) + (a + (-1))。
以下是 t2 的构建与求值步骤:
- 通过复制构造函数复用
t1的树结构。 - 构建叶子节点
b和-1。 - 用
+运算符节点组合t1和b。 - 用
+运算符节点组合a和-1。 - 用最外层的
+运算符节点组合上述两棵子树,形成t2。 - 输出
t2:((((-5)*(a+4))+b)+(a+(-1)))。 - 求值
t2:(-35 + 4) + (3 - 1) = (-31) + 2 = -29。
因此,程序的完整输出将是:
a = 3 b = 4
((-5)*(a+4)) = -35
((((-5)*(a+4))+b)+(a+(-1))) = -29
核心价值与总结 🎯

本节课中我们一起学习了表达式树的构造、遍历与求值。这个示例虽然代码量不大,但极具教学价值,它巧妙地将多个高级编程主题融合在一起:


- 面向对象设计:通过继承体系(
Expr,IntExpr,VarExpr,AddExpr等)实现多态。 - 内存管理:利用智能指针或引用计数实现高效的垃圾回收,避免内存泄漏。
- 解析(Parsing):将线性表达式转换为树形结构。
- 数据结构:深入理解树结构的应用。
理解这个示例对于掌握C++中的面向对象思想和复杂系统设计至关重要。建议你在自己的计算机上运行并修改此示例,以加深理解。
下一次课程,我们将回归人工智能领域,探讨一个影响深远的技术——蒙特卡洛方法。它不仅用于棋类游戏(如六边形棋)的局势评估,还广泛应用于机器翻译、自然语言处理等各种复杂问题求解领域。
042:断言与异常处理

概述
在本节课中,我们将学习一种全新的方法来生成高质量的人工智能,并将其应用于我们的Hex游戏中。这种方法的核心是蒙特卡洛模拟技术。同时,我们还将深入探讨如何确保代码的正确性,重点学习断言、静态断言以及异常处理的使用方法。
蒙特卡洛方法简介
上一节我们介绍了经典AI中基于前瞻搜索的决策模型。本节中,我们来看看一种截然不同的方法:蒙特卡洛方法。
蒙特卡洛方法本质上是一种利用概率模拟来理解现象的技术。在计算机上,你可以用它来模拟天气预测等现象。这种方法大约在2006年研究围棋时被发现,当时在棋盘上随机落子,意外地找到了一种更好的方式来探索如何产生好的走法,从而解决了一个在当时看来相当棘手的问题。
经典AI的局限性
在深入蒙特卡洛方法之前,我们先回顾一下经典AI方法的局限性。经典AI可以追溯到冯·诺依曼和摩根斯坦在20世纪40年代的工作,它构成了许多决策领域的基础,不仅限于AI,还包括运筹学等。
在经典观点中,我们假设存在一个理性的对手,他能够选择最佳走法。因此,你需要从大量可能的走法中进行选择。例如,在国际象棋中,一个典型局面下大约有40种走法。由于人类计算能力有限,40种可能性会呈指数级快速增加,仅仅经过两步(你走一步,对手回应一步),可能性就超过了1600种,几步之后就会达到数百万种不同的局面。
因此,棋手通常只考虑其中一小部分,他们使用所谓的“合理走法准则”。在国际象棋中,合理走法可能包括将军、吃子、推进兵等。类似地,在Hex游戏中,优秀的玩家会学习构建“桥梁”和进行阻挡。所以,Hex玩家不太可能考虑棋盘边缘的走法,他们会关注棋盘中部或能够扩展自己连接能力的走法。
在我们的11x11 Hex棋盘上,很长一段时间内,每一步都需要考虑超过100种走法。两步之后就是一万种,四步之后就是一亿种。因此,在极小化极大算法中,你无法在Hex游戏中深入搜索太多步,你必须迅速缩减搜索范围。
在典型的AI场景中,你需要决定合理走法,然后进行搜索,直到达到一个局面评估相对清晰的节点,这些节点被称为“叶节点”。在理想情况下,叶节点是已知胜负的终局。但在人类感兴趣的复杂游戏中,我们通常无法达到已知结果的终局,只能达到一个我们可以很好猜测的局面。例如,在Hex中,谁拥有最长的潜在路径,谁就更可能是赢家。
经典前瞻模型的问题
以下是经典前瞻模型存在的一些问题:
- 合理走法可能不可靠:在早期国际象棋程序中,如果只考虑7到8种合理走法,程序下得并不好。象棋大师经常会走出一些“古怪”的、出人意料的走法,例如弃子。如果程序有一个僵化的合理走法生成器,它可能会错过这些关键走法。后来,像“深蓝”这样的超级计算机配备了专门的象棋硬件,至少在初期会检查所有合法走法,以避免遗漏,这是一个巨大的改进。
- 存在“地平线效应”:有些局面下,你审视棋盘,可能看不到40或50步之后游戏会获胜。在国际象棋和围棋中,某些兵形结构或局面会形成非常静态的态势。专家棋手知道,某些局面带来的优势是独立于你需要前瞻多少步才能利用它的。早期的象棋程序经常会停下来进行静态评估,而评估结果可能低于专家棋手所知的、由深层结构局面带来的巨大优势。因此,即使程序平均能达到大师水平,世界级棋手仍能通过“反计算机策略”来应对,即确保游戏保持静态,使计算机无法利用其组合优势,并建立计算机不擅长评估的长期战略优势。
- 指数爆炸问题依然存在:无论采用何种优化,前瞻模型仍然面临指数级增长的问题。在某些足够大的棋盘上,计算机无法在合理时间内达到完美对弈。
总结

本节课我们一起学习了蒙特卡洛方法作为一种替代经典AI搜索技术的新途径,并回顾了经典前瞻模型在合理走法可靠性、地平线效应以及指数爆炸等方面的局限性。在接下来的课程中,我们将具体探讨如何将蒙特卡洛方法应用到Hex游戏中,并学习C++中用于确保代码健壮性的断言和异常处理机制。
043:蒙特卡洛方法在Hex游戏中的应用
在本节课中,我们将学习蒙特卡洛方法的核心概念,并探讨如何将其应用于评估Hex游戏中的棋盘位置。我们将通过模拟大量随机对局来评估每一步棋的优劣,而无需依赖复杂的静态评估函数或深度搜索。
蒙特卡洛方法是一种基于大量随机试验的模拟技术。它通过生成大量伪随机数来模拟事件,从而预测未来结果或评估概率。这种方法特别适用于那些难以用常规数学方法直接求解的复杂问题。
蒙特卡洛方法的核心价值
上一节我们介绍了蒙特卡洛方法的基本概念,本节中我们来看看它的核心价值和应用场景。
蒙特卡洛方法是一种模拟技术,它大量依赖于生成合理的伪随机数的能力。显然,这些随机数并非真正随机,但足以支撑大量试验的进行。

这些大量的试验是预测未来事件的基础。例如,你可以使用蒙特卡洛方法来预测未来一百年内小行星撞击地球的概率。你需要知道一些概率数据,以及太阳系的一些事实和数字,然后才能做出估计。人们也使用蒙特卡洛方法进行风险管理,例如计算百年一遇的洪水或十年一遇的飓风的概率。我们现在发现这些事件发生的可能性越来越大,因为我们对气候变化的了解越来越深入。
概率性试验让我们能够触及那些普通数学方法无法处理的问题。因此,它可以用来测量现实世界的事件,也可以用来预测赔率。我们早期曾尝试用蒙特卡洛试验来预测扑克游戏中同花顺的几率。如果你在五张牌梭哈游戏中运行足够多的试验,你会发现同花顺的概率大约是七万分之一。

如果你问大多数扑克玩家这个数字是多少,他们可能并不熟悉。我实际上非正式地尝试过,他们的猜测千差万别。当然,你可以查表或计算,这在数学上并不难。但只需很少的计算经验,你不需要了解概率知识,就能轻松估算各种事情。因此,你可以将其大量应用于投资领域。这是一种非常有用的技术。
蒙特卡洛方法与Hex游戏
上一节我们了解了蒙特卡洛方法的通用价值,本节中我们来看看它如何具体应用于Hex游戏。
这是我们的Hex棋盘。我们展示的是一个5x5的棋盘,这是一个相对较小的棋盘。实际上,当你开始编写代码时,希望我已经说过这一点:不要一开始就使用更大的棋盘。使用一个小棋盘。确保所有功能在小棋盘上都能正常工作。小棋盘在调试代码时会容易得多。
棋盘的大小应该是一个参数。因此,扩展棋盘大小并不困难,但用小棋盘来调查走棋质量以及程序中所有功能是否正常会容易得多。这是一个5x5的棋盘。
我们想要研究,在5x5棋盘上,什么是好棋?因此,我们在那个5x5棋盘上尝试所有可能的走法。本质上,我们有25个位置可以落子。
我们假设白棋是先手玩家。我们有25个位置需要评估。我们不会只评估看似合理的走法,而是评估所有走法。所以,如果是11x11的棋盘,你需要评估121个位置。当然,你可以更巧妙一些,假设其中一些走法彼此非常相似。因此,一个角部走法与另一个角部走法相比应该没有优势,在棋盘北侧上部走棋与在南侧下部走棋相比也应该没有优势。所以你可以限制一些走法。这种优化可能是有价值的。但目前,让我们先忘记优化,因为一旦棋盘上有了棋子,所有方格相对于棋盘上已有的棋子来说都变得独一无二。实际上,只有在第一步棋时,你才能利用对称性的数学特性来说明这一步棋和那一步棋是相同的。
我们尝试所有这些走法。现在,这里有一个出乎意料的发现,源于那些研究围棋的人。棋盘上剩余的走法将由随机生成。没有看似合理的走法,没有Alpha-Beta剪枝检查,什么都没有。我们将通过抛硬币来决定接下来的24步棋。所以黑棋下一步,黑棋在棋盘上随机走一步。白棋在棋盘上随机走一步。我们填满棋盘的剩余部分。在填满棋盘剩余部分后,我们就知道谁赢了这局游戏。这里有一条获胜路径。在这局游戏的最后,这条白棋路径,白棋在这里获胜。我们本可以更早停止,比如这里显示还有一些空位可以落子时。但今天我要解释为什么不值得费心在每一步都停下来检查是否有人赢了。我现在就解释,以后还会重复:事实证明,你不如直接填满棋盘,因为一旦有人赢了,就没有办法改变那个结果。填满棋盘的其余部分无关紧要。为什么?因为一旦有人从他们的一边到另一边形成了一条路径,他们也同时形成了一个阻挡。所以另一方不可能再形成一条路径。这就是Hex游戏的特点。因此,随机填满棋盘是一个非常简单的计算,而决定谁赢了则不是一个简单的计算。为什么决定谁赢不是简单的计算?因为这本质上涉及一个类似Dijkstra的算法,我们之前讨论过。这是一个复杂的计算,用于在每一步判断谁赢了。所以在这里,你有一个非常基础的操作:只需几步操作来填满棋盘,然后通过执行一次且仅一次Dijkstra算法(那个大计算)来得到结果。因此,你不如直接进行到棋盘结束,判断谁赢了。然后你重复这个过程。
对于这个位置,假设你重复了5000次。有时白棋会赢,有时黑棋会赢。这是你所期望的。你会得到一个比率:白棋获胜次数除以5000次试验。这个比率现在将成为对该位置的一种评估,也就是你评估该棋盘的方式。你不需要知道其他任何东西,你不需要在叶节点上进行静态评估(比如检查最长路径是什么),你将非常简单地完成:你的评估函数仅仅是运行尽可能多次的蒙特卡洛模拟,这取决于你编写算法的效率以及计算机硬件的速度。
这将是对你C++编程技巧的一个考验,因为你确实希望获得最高的效率。然后,如果你得到一个相对较高的数字,你基本上可以说:哦,从这个走法开始,两个傻瓜对弈,其中一个傻瓜似乎比另一个傻瓜表现好得多,也许这隐含地意味着这是一个更可取的走法。

这就是洞见所在。洞见在于,你不需要两个象棋大师或两个精通复杂桥梁策略、检查策略的Hex大师,或者精通围棋地域特殊模式的围棋大师。相反,棋盘位置的特性将通过让两个傻瓜从该位置开始对弈来揭示,而获胜次数更多的一方,本质上是从一个更好的位置开始对弈的。这就是洞见。
随机数的生成与使用
上一节我们探讨了蒙特卡洛模拟的流程,本节中我们来看看如何具体生成和使用随机数来驱动模拟。
好的,花点时间,让我们再思考一下使用随机数。rand()函数给你一个整数伪随机数,这是基础库中rand()函数为你做的。
你如何将这个整数转化为一个概率?这应该是复习内容。
以下是实现方法。我们通过调用double probability()函数来生成一个概率。实际上你也可以从标准库中直接获取概率值。你只需要返回rand()除以最大范围RAND_MAX,这是一个库定义的最大随机数。在本例中,我使用了1.0这个双精度数,这会将这个表达式转换为双精度类型。我必须注意,为什么我必须记得为什么需要在双精度域中?我必须在双精度域中是因为我希望这个结果是双精度除法。这是整数除法。
所以如果我漏掉了这个,概率将总是返回0。这不是你想要的。另一种方法是使用静态类型转换。所以如果我们进行静态转换,我们可以做到。这只是另一种可识别的改变数据类型域的方法。


以下是生成0到1之间(包含0,不包含1)随机概率的代码示例:
double probability() {
return rand() / (RAND_MAX + 1.0);
}
或者使用静态类型转换:
double probability() {
return static_cast<double>(rand()) / RAND_MAX;
}
总结

本节课中我们一起学习了蒙特卡洛方法在Hex游戏AI中的应用。我们了解到,蒙特卡洛方法通过大量随机模拟来评估棋盘位置的优势,其核心洞见在于:即使由“两个傻瓜”进行随机对弈,从更好位置出发的一方也会赢得更多对局。这种方法避免了对复杂静态评估函数或深度搜索的依赖。我们还回顾了如何在C++中生成用于模拟的随机概率。在实现时,关键是要编写高效的代码以运行尽可能多的模拟试验,从而获得可靠的评估结果。
044:蒙特卡洛基本思想

在本节课中,我们将要学习蒙特卡洛方法在游戏AI(特别是围棋程序)中的核心思想。我们将探讨如何通过随机模拟来评估棋步,并理解这种现代AI方法与传统专家系统方法的区别。同时,我们也会讨论在C++中实现此类算法时需要注意的性能和数据结构问题。
核心思想:随机模拟与胜率评估
上一节我们介绍了游戏AI的背景,本节中我们来看看蒙特卡洛方法的关键洞察。
这个非凡的洞察力在于:仅仅通过在棋盘上进行随机走子,然后计算“玩家”与“随机走子者”的胜率比值,就能准确评估走出某一步棋的价值。这是2006年一个取得重大突破的围棋程序中体现出的深刻思想,并且它构成了许多现代AI思考的基础。
传统AI与现代AI的范式转变
在传统AI中,核心思想是模仿人类专家的行为模式。例如,在卡内基梅隆大学纽厄尔和西蒙的时代,人们认为可以通过获取人类专家的规则来构建强大的系统。
相比之下,现代的谷歌和亚马逊等公司则依赖海量信息(大数据),进行大量随机尝试,然后通过海量试验和巨大计算量发现同样深刻的关系。因此,AI领域经历了一次巨大的转变。
旧的认知建模策略并未被完全抛弃,但在许多情况下,当问题过于复杂或我们尚不清楚如何正确建模人类思维过程时,我们仍然可以通过计算机模型(处理海量信息甚至使用随机试验)来达到或超越人类水平的结果。
编程实现中的关键考量
现在,我们来看看在编程实现中需要牢记的要点。你将需要处理庞大的树结构和数据表。请记住,你有一个11x11的棋盘,但潜在的可能是一个NxN的棋盘,人们经常使用更大的棋盘进行游戏。因此,你的程序应该能够支持大到围棋棋盘尺寸的棋盘。
这可能需要大量的分析和庞大的树与表结构。如果你打算完全用C++实现,你需要确保不会创建大量额外的对象。如果你在基于图的数据结构中完成所有操作,你的构造函数可能会进行大量工作。你可能需要专门优化图结构,以利用六边形图这种非常特殊的图所带来的效率优势。
路径查找算法的优化
对于Dijkstra算法也是如此。没有理由使用通用的、任意权重的Dijkstra算法,因为我们在六边形图中寻找的是从棋盘一侧到另一侧的最短路径。这意味着我们需要判断某一方是否已经连通,即找到一条从一侧到另一侧的路径。
如果你有一个大小为11的棋盘,这条路径至少需要跨越10条边才能从一侧到达另一侧。但实际上可能需要更多边,因为路径可以在棋盘上蜿蜒。探索在游戏中可能的最长路径是一个有趣的练习。
高效查找路径的方法要么是专门优化Dijkstra算法,要么是使用经典的并查集算法。我鼓励你到互联网上查找并查集算法的描述。
性能优化的重要性

因此,你希望进行大量的试验,同时又不希望因为实现低效而陷入困境。从这个项目中你学到的另一点是:在处理大规模聚合数据时要非常小心。
内存不是无限的资源。编写一个正确但低效的程序非常容易,然后你会困惑为什么程序运行得如此之慢。别人如何能做到每步棋进行10万次模拟?他们有120步棋要走,现在你谈论的是1200万次试验和评估。如果你的实现效率低下,在你的计算机上可能需要数小时才能完成一次评估。
本节课中我们一起学习了蒙特卡洛方法在游戏AI中的基本思想,即通过大量随机模拟来评估棋步价值。我们对比了传统基于规则的AI与现代基于数据和随机试验的AI范式。最后,我们重点讨论了在C++中实现此类算法时,关于数据结构选择(如专用图结构)、算法优化(如并查集替代通用Dijkstra)以及程序性能(避免内存浪费和低效计算)的关键考量,这对于处理大规模模拟至关重要。
045:生成棋盘的简单想法
在本节课中,我们将学习如何为Hex游戏生成一个初始棋盘。核心思路是:首先创建一个空棋盘,然后随机、均匀地放置红黑两色的棋子。我们将探讨实现这一目标的高效方法,并介绍如何利用标准库工具来简化流程。
棋盘初始化与棋子放置
上一节我们介绍了Hex游戏的基本规则,本节中我们来看看如何构建一个初始的游戏棋盘。
一个简单的想法是:取一个空棋盘,在其空位上均匀地放置数量相等的红色和黑色棋子。
如果你熟练使用标准库,你会发现其中有一个名为 std::shuffle 的例程。我们可以利用它来高效地完成随机放置。
以下是实现随机放置的一种方法:
- 准备一个数据结构,记录棋盘上所有节点的坐标。
- 使用
std::shuffle随机打乱这些节点的顺序。 - 将打乱后序列的前一半节点填充为白色棋子,后一半节点填充为黑色棋子。
这样,你就完成了棋子上在棋盘上的随机放置。我建议你使用这个方法。当然,你也可以使用其他方法,例如:在剩余的空位中随机选择一个位置放置白子,再随机选择一个位置放置黑子,如此交替进行。但那样你就必须持续跟踪哪些坐标仍然是空的。虽然这也能实现,但正如我所说,本课程的一个益处是希望你更好地理解和运用标准库中那些能让你高效完成任务的现成例程。
优化策略:一次性测试

在完成整个棋盘的填充后,再进行一次性的胜负测试,这是一个重要的优化。
因此,与其在11x11的棋盘上进行120次测试,不如在最后只测试一次。尽管你需要走更多的“步”(即填充所有棋子),但“走步”的操作成本很低,非常低。这是一个巨大的计算优势,一个突破性的计算优化,虽然它并不那么显而易见。
迪杰斯特拉算法的特化应用
我们已经详细讨论过迪杰斯特拉算法。该算法是计算机科学中许多应用的基础关键路径查找算法,也是一个经典的贪心算法。理解它以及类似算法,我希望这是你从本课程中获得的有真正价值的收获。

在Hex游戏中,判断胜负就是看是否有一方玩家获胜。在一个已填满的棋盘上,你可以假设:如果南北向的玩家(我们称其为玩家1)没有赢,那么赢家就一定是东西向的玩家。这是由Hex游戏的特性决定的。
因此,我们需要做的就是检查是否存在一条从北边界到南边界的完整路径。
代码结构建议
以下是一个基础的Hex图代码结构建议,你可以尝试使用。这不是强制要求,如果你能写出与之相当或更好的、属于你自己的代码,那也很好。但对于那些需要更多指导的同学,这可能会有帮助。
这是我为解决问题而生成的代码框架。我定义了一个通用图类,但会有一个专门的构造函数,它接收棋盘尺寸作为参数。这个尺寸是单边的长度,例如对于11x11的棋盘,这个值就是11。
对于一个5x5的棋盘,你有25个节点。因此,图的节点数实际上是边长 size 的平方。
我们的接口除了Hex棋盘的构造函数,还有一个判断谁赢的函数 who_won。这是我们特化的路径查找算法。
在内部实现上,我选择使用一个“向量的向量”来存储图结构。对于每个节点,都有一个存储其边的向量。
此外,我们用一个简单的表示法来记录每个棋盘格子(对应图节点)的状态:0代表空,1代表白子,2代表黑子(或任何你喜欢的颜色)。一个建议是将其实现为一个枚举类型。如果我在自己的代码中不那么“懒”,我可能会将其定义为一个单独的类,包含 EMPTY、WHITE、BLACK 三个值。
为了实现 who_won 算法,我们可以使用特化版的迪杰斯特拉算法来查找南北或东西方向的路径,或者经典地使用并查集算法。这两种方法都适用于本项目。

本节课中我们一起学习了生成Hex游戏初始棋盘的策略。核心内容包括:利用标准库的 std::shuffle 实现棋子的随机均匀分布;通过“先填充,后一次性测试”的策略获得显著的计算优化;以及如何将迪杰斯特拉算法特化用于判断棋盘胜负。我们还探讨了可能的代码结构,为实际编程实现提供了清晰的思路。
046:蒙特卡洛方法在棋盘游戏中的应用 🎲
在本节课中,我们将探讨人工智能在棋盘游戏中的发展历史,并重点介绍蒙特卡洛方法如何解决传统AI方法难以攻克的复杂游戏,如围棋。我们将通过一个具体的编程作业——实现一个基于蒙特卡洛方法的Hex游戏AI——来实践这一概念。
人工智能在棋盘游戏中的发展历程
回顾人工智能的发展历史,棋盘游戏一直是重要的测试平台。早期取得显著成功的游戏包括黑白棋。
黑白棋是一种在8x8棋盘上进行的放置游戏,共有64个格子。玩家通过放置棋子来翻转对手的棋子行。这是一个组合性很强的游戏。
在20世纪80年代初,黑白棋程序的水平已经超越了人类玩家。不久之后,程序甚至能在该尺寸的棋盘上实现完美对弈。
国际象棋方面,程序在80年代中后期达到了大师级水平,90年代达到了特级大师水平,并在90年代末达到了世界冠军水平。如今,程序的对弈能力已完全超越人类。
美国跳棋也实现了完美对弈。早期著名的AI程序之一是由IBM的Art Samuel开发的跳棋程序。该程序最初只是用于测试IBM生产的机器,但它采用了经典的AI学习方法,因此在“学习”能力方面非常著名。后来,研究团队通过数学、组合和计算的方法,也实现了跳棋的完美对弈。
此外,存在概率成分的游戏也达到了很高水平。例如,双陆棋程序被认为已经超越了普通人类玩家的水平。阿尔伯塔大学在开发高质量的扑克机器人方面也享有盛誉。
然而,围棋一直是个例外。
围棋对传统AI方法的挑战
传统的AI方法论被应用于围棋,但效果有限。例如,加州大学圣克鲁兹分校的Charlie McDowell开发了一个名为“Slugo”的程序。大约在2005年,该程序偶尔能在计算机围棋比赛中获胜,是当时采用经典方法论的最佳程序之一。
然而,这些程序的水平仅略高于新手或俱乐部级别的玩家。当面对严肃的俱乐部玩家或低段位职业棋手时,这些程序毫无胜算。程序的改进曲线非常缓慢。虽然计算机速度在提升,可以进行更多评估、利用多线程和并行计算(例如Slugo使用了64个处理器的机架),但标准方法结合改进的评估和更强的计算能力,并未像在国际象棋或黑白棋中那样带来对弈质量的显著提升。
围棋过于复杂,职业棋手使用的判断标准并非纯粹的组合计算,而是基于模式的深度判断,这很难被复制。
蒙特卡洛方法的突破

大约在2006年,欧洲的一个团队决定尝试组合性方法(指蒙特卡洛树搜索)。这一方法立即使程序性能实现了数量级的提升,立即被公认为当时最好的计算机围棋程序。
很快,该程序的对弈强度就达到了普通围棋俱乐部的平均水平。到2010年,其质量已经足够高,以至于在合理的让子条件下(例如让4到5子),可以与人类棋手进行有意义的对局。在此之前,围棋大师即使让计算机10到15子也能轻松获胜。蒙特卡洛方法的引入改变了这一局面。
Hex游戏与蒙特卡洛方法
Hex游戏与围棋有相似之处。它也是在棋盘上用黑白棋子进行的静态连接游戏,其组合特性不像黑白棋、国际象棋或跳棋那样涉及棋子的捕获和位置变化。
我们刚刚解释的蒙特卡洛方法——通过随机模拟到终局来评估位置优劣——为判断哪个位置更好提供了深刻的洞察,正如它在围棋棋盘上所做的那样。

编程作业:实现Hex游戏的蒙特卡洛AI

在本次作业(也是本课程最后一次主要编程作业)中,你需要用C++编写代码,实现基于蒙特卡洛方法的Hex游戏AI。
你的程序进行一步蒙特卡洛选择的时间应控制在一到两分钟左右,以避免玩家等待过久。因此,你需要努力实现高效率。
以下是实现高效模拟的关键点:

- 模拟次数:对于每个合法走法,你应该能够进行1000次或更多的随机模拟。如果模拟次数太少,评估结果的质量将不会很好。
- 性能表现:你会发现,如果评估次数太少,一个懂得如何下Hex的人类玩家就能击败你的程序。但如果棋盘足够小,并且你有足够高的模拟次数,程序将变得非常难以被人类玩家击败。

进阶思考:并行蒙特卡洛计算
虽然这超出了本课程大多数学生的基础,并且我不会在课堂上详细演示,但一些学有余力或希望尝试新想法的同学可以考虑以下内容。
现在在C++中,可以利用新标准进行并行计算。
假设你有两台可用的计算机,它们独立使用蒙特卡洛方法来估计获胜概率。那么,结合这两台计算机的结果会对你有益吗?
答案是肯定的,但前提是它们之间没有“秘密依赖”。所谓“秘密依赖”,指的是如果两台计算机运行完全相同的程序,并且随机数生成器的起始序列相同,那么两台程序将产生完全相同的结果。结合这样的结果并不会增加置信度,你只是得到了两次完全相同的复制。
为了让两台计算机的结果可以结合,它们的伪随机数生成序列必须是不同的。如果序列不同,你就有更多的试验可以结合,评估结果确实会得到改善。
因此,如果你实现独立的线程模型,或者有两台计算机(甚至是你个人电脑中常见的四核处理器),并且能够正确地进行线程处理,试验就可以相当独立。关键是要确保它们使用不同的随机数序列或生成器。使用独立的随机数生成器可能更好,因为你要进行大量的试验。
这样,你就能获得更好的评估结果,或者在更短的时间内完成计算。
所以,蒙特卡洛方法非常适合并行处理。这也是为什么围棋程序开始使用大型计算机机架的原因——通过并行计算获得大量额外的计算能力,每项独立计算仍然有助于做出不错的评估。

相比之下,在Minimax等算法所需的连续评估或检查中,虽然Minimax也可以并行化,但仍然存在更多不同的序列点,无法做到完全独立。
你可以尝试使用C++11的线程包,在多核计算机上工作,看看能否改进这个程序的单线程版本。但这不属于本课程教学或要求的范围。

关于Hex游戏“Pie规则”的理论思考
另一个小测验问题:还记得我们最初描述Hex时提到的内容吗?
我们知道Hex的发明者Nash也证明了,如果玩家一完美对弈,他总是能赢得Hex游戏,尽管他不知道具体如何赢。
因此,人们引入了所谓的“Pie规则”。回忆一下,Pie规则是指,在看到玩家一的第一步棋后,玩家二可以说:“我喜欢那步棋,我来当玩家一。” 那么,原来的玩家一就有义务走一步“垃圾棋”。
现在,如果原来的玩家一能走一步非常糟糕的棋,以至于玩家二说:“哦,我要那步垃圾棋。” 然后原来的玩家一就能走一步非常好的棋,重新获得优势。但风险在于,原来的玩家一走不出非常糟糕的棋。
对于两个高水平的玩家来说,游戏的一部分就是走出一步让双方获胜机会大致相等的棋。
我的理论问题是:在Pie规则下,游戏是否仍然保证有一方必胜?Nash的证明是否仍然成立?如果仍然必胜,现在是否保证是玩家二获胜?
思考一下,这不是一个计算问题,而是关于游戏逻辑的一点思考。
答案是,游戏仍然保证有一方必胜。在Hex中,如果双方交替走棋,不存在和棋局面,一方必须赢,另一方必须输。这一点没有改变。
但现在,总是玩家二获胜。为什么?
如果玩家一走了一步棋,从客观完美对弈的角度看,玩家一现在将获胜。那么,聪明的玩家二会说:“啊哈,玩家一走了一步会导致他赢的棋。我来当玩家一。” 所以,原来的玩家一必须走一步不会导致他赢的棋。如果存在这样一步棋(这一点尚不明确),假设存在,玩家二会意识到:“原来的玩家一现在走了一步会导致他输的棋。我让他继续当玩家一。”
因此,如果双方都能完美对弈,并且Pie规则生效,玩家二拥有明显的优势。

总结
本节课中,我们一起学习了人工智能在棋盘游戏中的演进,重点了解了传统方法在应对围棋等复杂游戏时的局限性,以及蒙特卡洛树搜索方法带来的革命性突破。我们通过Hex游戏的编程作业实践了蒙特卡洛模拟的核心思想,即通过大量随机试验来评估棋局位置。我们还探讨了并行计算如何加速这一过程,并分析了Hex游戏中“Pie规则”对必胜方的影响。希望这些知识能帮助你更好地完成最终作业,并理解现代AI解决复杂决策问题的一种强大思路。
047:断言与异常 🧠
在本节课中,我们将深入探讨C++中两个至关重要的概念:断言与异常。这两个工具在C/C++社区中常常未被充分利用,但如果能有效运用,将能帮助你编写出更健壮、更高质量的代码。
从理想世界到现实实践
上一节我们介绍了课程主题,本节中我们来看看断言与异常背后的思想渊源。在计算机科学的理想世界中,像迪克斯特拉这样的应用数学家认为,最好的情况是能够证明程序的正确性。如果能证明程序正确,那么它就不会进入错误状态,也无需在运行时进行错误测试。
然而在现实世界中,大多数程序员并非通过数学证明,而是通过调试来确保代码正确性。我们编写代码,然后用不同的数据进行测试,从简单案例开始,逐步过渡到复杂情况和压力测试,以检查所有可能的逻辑路径。甚至有些方案会将测试模块内置于代码中,以便在代码变更时复用。
这种对“证明正确性”的理想化追求,引导了迪克斯特拉等教授发展出一种包含断言的证明逻辑。
断言:运行时检查 🛡️

断言的核心思想是:在执行某段代码之前,某些参数必须处于特定值域内;执行之后,结果应满足特定条件。这分别被称为前置条件和后置条件。
以下是断言的一个典型应用场景:
- 前置条件示例:计算平方根
sqrt(x)前,必须确保x >= 0。- 代码表示:
assert(x >= 0);
- 代码表示:
- 后置条件示例:计算得到平方根结果
y后,应满足y * y == x。- 代码表示:
assert(y * y == x);
- 代码表示:
如果前置和后置条件都为真,我们就对代码的正确性有了信心。由于为复杂程序编写完整的数学证明非常困难,C/C++社区决定让计算机在代码实际运行和测试时来检查这些断言。
一个常见的错误示例
考虑以下 for 循环代码,它可能导致错误:

for (int i = 0; i > n; ++i) { ... }
这里存在两个潜在问题:
- 如果
n为正数(例如n = 3),条件i > n初始即为假,循环根本不会执行。这很可能是一个笔误,本意可能是i < n。 - 如果
n为负数(例如n = -1),条件i > n将始终为真,i不断递增,导致一个无限循环。

如果在循环前使用断言进行前置条件检查,例如 assert(n > 0);,就可以在早期避免此类错误。
C语言中的断言

断言在C语言中被广泛使用,并被视为良好的工业实践。C++社区继承了这一特性。传统的断言是一个运行时测试:它评估一个逻辑表达式,如果该表达式在程序运行时结果为假(即0),程序将中止并打印一条消息。
传统断言本质上是一个宏,通过包含 <cassert> 头文件来使用。它的一个关键特性是可以通过编译选项(如 -DNDEBUG)来禁用,这样在发布最终产品时就不会产生运行时开销。
尽管增加断言看似多余,但它就像写注释一样,是一种重要的编程纪律。断言能提醒你代码应有的行为,帮助你发现诸如将 < 误写为 > 的简单错误,从而产出更高质量、更易读的代码。
断言使用示例
以下是一个使用断言检查指针非空的简单例子:

#include <cassert>
#include <iostream>
void print_value(const int* ptr) {
assert(ptr != nullptr); // 前置条件:指针不能为空
std::cout << *ptr << std::endl;
}
int main() {
int i = 5;
int* p = &i;
int* q = nullptr;
print_value(p); // 正确,打印 5
print_value(q); // 触发断言失败,程序中止
return 0;
}

思考练习
假设你正在为自己实现的 my_sort 函数(对 vector<T> 排序)编写测试。你应该如何设计前置条件和后置条件的断言,以确保排序函数工作正确?请将此作为一个思考题。
静态断言:编译时检查 ⚙️
C++11 标准引入了 static_assert,这实际上回到了迪克斯特拉和霍尔最初的想法:为何不将某种程度的证明检查嵌入到编译器中呢?
与运行时断言不同,静态断言在编译时进行评估。如果断言条件不满足,编译将失败并给出错误信息。你可以将其视为一个更强大的、内置的语法检查器。
静态断言的优势在于零运行时开销。但它也有局限性:它只能检查那些在编译时就能确定的常量表达式或类型特征。尽管如此,它仍然是一个非常强大的工具,尤其在模板元编程和泛型编程中,用于约束模板参数或检查类型属性时极其有用。

static_assert(sizeof(int) == 4, “int must be 4 bytes on this platform.”);
static_assert(false, “This code path should not be instantiated.”);
异常:处理意外情况 🚨

上一节我们介绍了用于检查逻辑正确性的断言,本节中我们来看看用于处理运行时意外情况的异常机制。
首先必须明确:编写高质量专业代码的核心是避免制造错误。异常处理是一种错误处理机制,而非鼓励制造错误的借口。断言主要用于开发调试阶段,而异常处理则常被用于发布的代码中。
异常适用于处理那些并非代码逻辑错误,而是由外部环境或资源限制导致的问题,例如:
- 内存不足:尝试分配超出系统可用范围的堆内存。
- I/O 问题:文件无法打开、网络连接中断。
- 非法输入:在棋盘游戏中,试图在已有棋子的位置落子。
关于异常的争议
在编程社区中,关于异常的使用存在很大争议。一种观点认为,异常是另一种形式的 goto 语句,它改变了程序正常的控制流。在早期的编程中,滥用 goto 会导致“面条式代码”,使得程序逻辑混乱、难以测试和维护。因此,许多组织甚至编程语言都禁止或取消了 goto。
异常通过 throw 关键字,同样可以突然跳转到程序的另一部分(catch 块),因此批评者认为这会带来类似的结构化问题。
最佳实践建议
我个人倾向于支持这种审慎的观点:应当最小化异常的使用,并将其限制在非常明确的情况下,例如处理I/O失败、资源耗尽等确实无法在本地处理的、真正的“异常”状况。对于可以通过参数检查、状态判断在本地处理的错误,应优先使用返回值或错误码等更清晰、可控的方式。
总结 📝

本节课中我们一起学习了C++中断言与异常这两个关键工具。
- 断言(特别是运行时断言
assert)是一种用于在开发阶段验证代码逻辑假设(前置/后置条件)的调试工具,能有效提升代码质量。 - 静态断言(
static_assert)将检查提前到编译时,用于验证常量表达式和类型约束,无运行时开销。 - 异常是一种处理程序运行时意外错误状态(如资源不足、I/O失败)的机制,但应谨慎使用,避免滥用导致控制流混乱。
合理运用这些机制,将帮助你构建出更可靠、更易于维护的C++程序。
048:静态断言(C++11新特性)

在本节课中,我们将学习C++11引入的一个非常巧妙的新特性——静态断言。这是一种在编译时进行检查的断言机制,尤其在与模板编程结合时,能极大地提升代码的健壮性和错误信息的清晰度。
静态断言的概念与动机
上一节我们介绍了C++11的一些新特性,本节中我们来看看静态断言。静态断言是一个经过多年测试的特性,其思想最初来源于Boost库,许多C++新标准中的想法都在那里进行了测试。
静态断言是一种在编译时可检查的断言。这个想法非常巧妙,其主要的动机来源于编写模板代码。
模板代码与普通代码不同,它被设计为通用的、与类型无关的代码。当你编写一个模板时,你希望这段代码可以复用,因为它可能适用于整型、双精度浮点型、复数类型、字符串类型,甚至是用户自定义的类。
然而,这样的代码不太可能适用于所有可以想象的类型。一个简单的例子是标准模板库中的数值算法 accumulate。当你对一个标准容器进行累加时,你需要将容器中的每个元素相加求和。这段模板代码允许你对 short、long、long long、复数等类型进行操作,前提是这些类型必须有一个“零值”并且支持加法运算。
换句话说,模板代码预设它只会被实例化在一种可称为“可求和”的类型上。但在旧式C++中,无法直接检查这一点。你只能在模板参数中使用一个能暗示所需特性的标识符,例如 class Summable。
静态断言的作用与语法
在C++11中,标准库引入了“类型特征”的概念。结合静态断言,你可以让编译器检查类型是否具备所需的特性。这非常有用,主要有两个原因:
- 提供清晰的错误信息:如果你编译的模板代码因为类型不支持某些操作(例如,不支持加法)而失败,你会得到一个编译时错误。但C++编译器产生的模板错误信息通常非常晦涩难懂。使用静态断言,你可以输出一个自定义的、针对该模板代码的清晰错误信息,例如“此类型不可求和”。
- 防止运行时错误:更糟糕的情况是,代码可以编译并运行,但类型并不具备你期望的特性,只是碰巧有某种含义。这会导致更隐蔽的错误,代码会运行并产生某种不正确的行为,你只能在调试时发现它。静态断言可以在编译时就捕获这类问题。



静态断言的语法如下:
static_assert(布尔常量表达式, “错误信息字符串”);
你提供一个在编译时可求值的布尔常量表达式,并设计当断言失败时显示的错误信息。这比编译器生成的通用错误信息要清晰得多。

以下是静态断言的一个典型示例:
template <typename T>
void swap(T& a, T& b) {
auto c = b; // C++11的auto,类型从b推断
b = a;
a = c;
static_assert(std::is_copy_constructible<T>::value,
“Swap requires copying”);
}
在这个交换模板函数中,我们使用了一个静态断言。它利用标准库 <type_traits> 头文件中的 std::is_copy_constructible 来检查类型 T 是否可复制构造。
让我们思考一个典型情况。如果我们用 int 来实例化这个模板,auto c = b; 中的 auto 会被推断为 int,代码就是简单的赋值和交换,没有问题。
但是,如果你尝试用一个禁用了复制构造的自定义类型来实例化这个模板,那么执行 auto c = b; 这行代码就是非法的,因为它期望进行复制构造。此时,静态断言会触发,并显示你自定义的错误信息:“Swap requires copying”。这对开发者来说非常清晰。
总结


本节课中我们一起学习了C++11的静态断言特性。静态断言结合类型特征库,是一个非常巧妙的想法。它允许你在编译时对模板代码施加规则并进行检查,从而管理潜在的bug,并强制编译器为你执行类型约束。这是一个非常强大且实用的工具,能显著提升模板代码的安全性和可维护性。
049:异常处理
在本节课中,我们将要学习C++中的异常处理机制。异常是程序运行时可能发生的意外或错误情况。我们将了解如何抛出异常、捕获异常,以及如何利用标准库中的异常类来构建健壮的程序。

异常概述

上一节我们介绍了异常的基本概念,本节中我们来看看异常在编程中的具体应用。
异常在现实世界中普遍存在。在编程中,异常处理机制用于在运行时监控资源管理,并确保程序能从非预期的错误中恢复。其核心哲学是:不要滥用异常来制造复杂的控制流,而应在真正需要的地方使用它。
例如,在核电站或太空任务等关键系统中,计算机不能随意关闭。这些系统需要有能力从任何错误中恢复,即使是非预期的错误。阿波罗任务就使用了三冗余硬件和投票机制来确保即使一个系统失效,任务也能继续。
异常也是一种处理用户输入错误的机制。虽然不使用异常也能实现错误处理,但使用异常是一种更常见的编程范式。

基本异常机制
现在,我们来看看C++中异常处理的基本代码结构。
C++使用 throw 语句来抛出一个异常。你可以抛出任意类型的值。
throw "This will abort";
完整的异常处理机制涉及 try 块和 catch 块。try 块定义了被监控的代码区域,catch 块用于捕获并处理抛出的异常。

try {
// 被监控的代码
throw "This will abort";
} catch (...) {
// 捕获所有类型的异常
std::cout << "An exception was caught." << std::endl;
}
在上面的例子中,catch (...) 中的省略号是通用签名,可以捕获任何类型的异常。捕获异常后,程序不会立即终止,而是执行 catch 块中的代码。
Catch 块的特性
接下来,我们详细了解一下 catch 块的特性。

catch 块看起来像一个函数,但它不是函数。它必须且只能有一个参数(尽管该参数可以是通用类型 ...)。catch 块没有返回类型,它只是一段代码,可以包含任意复杂的逻辑,甚至可以作为主计算失败后的备用计算路径。
当 throw 抛出一个带有特定类型的值时,程序会尝试将其与一系列 catch 块的签名进行匹配。匹配规则遵循最精确原则。以下是匹配顺序的要点:
- 首先尝试匹配精确类型。
- 然后尝试匹配派生类的公共基类类型。
- 也可以匹配可转换为
catch参数指针类型的异常。
因此,通常会将更具体的 catch 块放在前面,将通用的 catch (...) 块放在最后作为默认处理程序。
标准库异常与自定义异常
C++标准库提供了一组预定义的异常类型,它们构成了一个继承层次结构,基类是 std::exception。C++11引入了更多新的异常类型,用于处理运行时错误、内存错误、I/O错误等。

如果你想为自己的类创建专门的异常,建议基于继承来构建。通常的做法是创建一个继承自 std::exception 或其派生类的新异常类。
标准异常类中有一个可以重载的虚函数 what(),它通常返回一个描述异常信息的字符串。

class MyException : public std::exception {
public:
virtual const char* what() const noexcept override {
return "My custom exception occurred!";
}
};
以下是如何抛出和捕获自定义异常的示例:
try {
throw MyException();
} catch (const std::exception& e) { // 可以捕获派生类
std::cout << "Caught: " << e.what() << std::endl;
}

因为 MyException 是 std::exception 的派生类,所以可以被基类引用捕获。在 catch 块中,我们可以调用 e.what() 来获取异常信息。
总结

本节课中我们一起学习了C++的异常处理机制。我们了解了如何使用 throw 抛出异常,如何使用 try-catch 块来监控代码和捕获异常。我们还探讨了异常匹配的规则,以及如何利用标准库中的异常基类来创建自定义的异常类型。正确使用异常可以帮助我们编写出更健壮、容错性更强的程序。
050:C++11设计模式与语言演进
概述

在本节课中,我们将探讨C++语言的发展历程,从C语言的起源到C++11标准的引入,并介绍一些高级特性与设计模式的基本概念。我们将了解C++如何从一个系统实现语言演变为一个支持多范式编程的通用语言。
C语言的起源与特点
C语言由丹尼斯·里奇发明,最初作为Unix操作系统的实现语言。它是一种系统级语言,设计简洁高效,最初只有29个关键字。
C语言的核心特点包括:
- 所有函数调用均采用传值方式。
- 模块化基于文件实现。
- 语言语义的许多方面留给编译器实现者决定,以便针对特定机器架构进行高度优化,从而在很大程度上替代机器语言级别的编程。
随着Unix操作系统的流行,C语言的应用范围从系统编程扩展到更广泛的用途,并开始在教学领域取代Pascal语言。

从C到C++的演进
比雅尼·斯特劳斯特鲁普在20世纪80年代初加入贝尔实验室。他借鉴了Simula语言中的类概念,通过预处理器为C语言添加了类,创造了“带类的C”,这后来演变为C++。
C++成功的关键在于它对C程序员的友好性:它被视为一个“更好的C”。与当时其他面向对象语言(如Smalltalk、Objective-C)相比,C++由AT&T这样的大公司支持,并且可以免费获取,这降低了用户的采用风险。
C++的发展遵循了“瑞士军刀”式的哲学,旨在成为一个支持多种编程范式的专业工具集:
- 低级系统编程
- 高级面向对象多态编程
- 泛型编程

C++标准的演进与关键特性
C++通过ISO委员会进行标准化,不断引入新特性。1989年的关键新增内容包括模板和异常处理。
模板的实用性在亚历山大·斯捷潘诺夫的工作中得到充分体现。他利用C++模板实现了泛型编程思想,其成果最终发展为标准模板库。
与此同时,Java语言的出现带来了新的竞争,两者相互促进,共同发展。
2011年的C++11标准是一个重大更新,引入了许多源自实践(如Boost库)和函数式编程语言的新特性。
以下是C++11中一些我们已讨论过的重要特性:
- 移动语义
- Lambda表达式
auto关键字从存储概念转变为类型推断概念- 基于范围的for循环
- 更好的初始化方式
nullptr关键字
在这些特性中,使用auto进行类型推断被认为是以较小代价获得最大回报的特性之一。
编程语言的发展趋势
历史上,编程语言通常较小,且针对特定领域(如系统编程用C,商业编程用COBOL,科学计算用Fortran)。然而,现代趋势是语言的通用化。
企业发现,培训程序员掌握多种语言并整合不同平台的成本非常高昂。因此,出现了让单一语言变得更通用、以覆盖更广泛编程需求的努力。例如:
- 微软主要使用 C#
- Oracle和许多Web公司使用 Java
- Python 在过去二十年取得了显著成功,覆盖了广泛领域
C++无疑是当今最重要、使用最广泛的编程语言之一,位居前列。

总结

本节课我们一起回顾了C++从C语言起源到C++11标准的发展历程。我们了解了C++如何通过添加类、模板等特性,从一个系统语言演变为支持面向对象、泛型编程的多范式语言。我们还探讨了C++11引入的一些关键新特性,如auto类型推断和lambda表达式,并了解了编程语言向通用化发展的整体趋势。理解这些背景有助于我们更好地掌握和运用C++这门强大的工具。
051:C++11标准新特性概述
在本节课中,我们将学习C++11标准引入的一些关键新特性。我们将重点了解新增的标准库组件,并通过一个正则表达式的具体示例来展示其用法。
概述
C++11标准引入了大量新特性,其范围之广,足以再开设两门同等规模的课程来详细讲解。本课程无法涵盖所有细节,但会为你指明方向,帮助你了解在深入学习新标准能力时,可以探索哪些领域。
新增的关键库
上一节我们介绍了C++11标准的背景,本节中我们来看看它具体新增了哪些重要的库组件。许多库(如Boost库)在过去10到15年间经过了大量实验。C++社区认为其中一些组件非常有效,决定将其纳入新标准。
以下是部分新增库的简要介绍:
- 固定长度数组容器类:为什么需要它?
vector固然更灵活,但这种灵活性是有代价的。这些库被广泛采用的一个主要原因是其高效性。有时,你并不想为vector的灵活性付出额外开销,但也不想使用C语言中原始的、基于指针的数组。基于模板的固定长度数组可以满足这一需求,其长度本身是类型的一部分。 - 单向链表:原始STL中的
list是双向链表。现在新增的forward_list是单向链表,它更廉价,因为它不需要向后的指针,只保留向前指针。 - 无序容器:
unordered_map和unordered_set具有重要价值。我们已经见过相关示例。无序版本的map和set使用哈希而非基于对数复杂度的查找方法,因此通常能获得性能提升。对于关联数组,如果需要高性能,这通常是首选。普通map通常用红黑树实现,基本操作是对数时间复杂度O(log n),而非固定时间O(1)。 - 并发库:一个重大的新增内容是线程库以及其他如
Atomic、Mutex等库,它们允许你在C++标准内进行并发编程、基于线程的编程。过去,你总是可以使用当前平台的库(如在Unix上可能是Pthread库)。现在,标准以更高层次定义了并发原语,并由实现来支持该标准,从而让你能编写跨平台更统一的代码。 - 正则表达式库:另一个非常有价值的库是正则表达式库。这可能是人们最初就希望拥有但标准中未曾包含的首批库之一,因为正则表达式在计算机科学和编程中一直使用,是解析字符串的极其有用的方式,因此是自然而然的补充。
- 类型特征库:最后,有趣的新增库之一是类型特征库。它可用于静态断言,对于编写更具可移植性、在实例化模板后可进行静态检查的代码非常有帮助。这对于现代泛型编程尤为重要。
所有这些内容非常庞大。我只是为你提供最粗略的路线图。针对你的特定目的,你需要去深入学习和研究。好消息是,有大量关于此的学习材料,网络上已有许多资料可供阅读。

正则表达式库示例
接下来,让我们具体看看如何使用其中一个新库:正则表达式库。

正则表达式是形式语言规则编写中最低的层次。通常,正则表达式用于描述像一系列A和B的重复序列。任何A和B的序列都可以用正则表达式表示。上下文无关语言需要更严格的规则且更难解析。正则表达式规则简单,涵盖多种不同语言,且非常易于解析。正则表达式包为你提供了表达这些规则的基础。
例如,这是一个正则表达式:[a-z]+\\.txt
它的含义是:允许使用任何小写字符序列。这意味着 abxxtu 是允许的,但 abx3tu 不允许,因为 3 不在该字母表中。方括号表示允许的字符集,加号表示可以尽可能重复。最后一部分明确要求字符串中必须有 .txt。因此,这个正则表达式将匹配任意小写字母序列后跟 .txt 扩展名,例如文件名 myfile.txt。
以下是一段使用该正则表达式的示例代码:
#include <iostream>
#include <string>
#include <regex>
int main() {
std::string filenames[] = {"ia.txt", "data2.txt", "ia2.txt", "data.out"};
std::regex pattern("[a-z]+\\.txt");
for (int i = 0; i < 4; ++i) {
std::cout << filenames[i] << ": ";
if (std::regex_match(filenames[i], pattern)) {
std::cout << "匹配" << std::endl;
} else {
std::cout << "不匹配" << std::endl;
}
}
return 0;
}

与标准库中的任何内容一样,我们导入头文件 <regex>。它通常与 string 一起使用。代码中有一个四元素字符串数组,并定义了正则表达式。循环检查每个字符串是否与正则表达式匹配,匹配则输出1,不匹配则输出0。
对于这四个文件名:
ia.txt:符合规则,匹配。data2.txt:不符合,因为包含数字2。ia2.txt:不符合,因为包含数字2。data.out:不符合,因为扩展名是.out而非.txt。

这样,我们就可以区分特定类型的文本文件以进行进一步处理,这正体现了该功能的强大之处。
对于熟悉正则表达式的开发者,可以查阅该库以了解表达各种正则表达式的所有方式。如果你曾在使用Perl(一种重度基于正则表达式的语言)或Unix工具(如grep)时用过正则表达式,那么你将能直接在C++中使用它。
总结

本节课中,我们一起学习了C++11标准引入的部分关键新特性,重点概述了新增的标准库组件,如固定长度数组、单向链表、无序容器、并发库、正则表达式库和类型特征库。我们还通过一个具体的代码示例,演示了如何使用正则表达式库来匹配和验证字符串格式。这些新工具极大地增强了C++的表达能力和效率,为现代软件开发提供了强大支持。
052:线程基础与并发编程
在本节课中,我们将要学习C++中的线程与并发编程。这是一个非常重要但同时也非常复杂的主题。我们将探讨其基本概念、挑战以及C++11标准如何提供支持。
线程与并发:一个复杂但重要的主题
线程与并发编程是一个困难的课题。即使在大学本科阶段,也少有课程完全专注于并发编程。通常,课程中只会提及一些并发概念,但很难达成足够的共识。这可能是因为并发编程本身仍存在许多不同的方法,并且常常依赖于特定的平台。
正因为其复杂性和平台依赖性,线程支持在很长一段时间内未被纳入C++标准。然而,C++11标准最终引入了对线程的原生支持,并提供了相关的库。
C++11中的并发支持库
C++11标准库提供了一系列工具来支持并发编程:
- 互斥量库:提供了互斥锁等机制,其思想可追溯到Dijkstra等人关于互斥与信号量的讨论。
- Future库:允许你等待一个计算完成并获取其结果。
- 原子操作库:提供了进一步的同步原语。
- 条件变量库:用于管理并发进程间的内存交互。
这些工具之所以必要,是因为并发编程的核心挑战在于:我们很难确定哪些任务可以并行运行,而哪些任务必须等待其他任务完成后才能执行。
理解并发:现实世界的类比
为了更好地理解并发,我们可以考虑一个现实世界的例子:在厨房做饭。
- 你可以一边切沙拉,一边烧开水准备煮玉米。这是可以并发执行的任务。
- 但是,你不能在给玉米去壳之前就把它扔进锅里。这是必须顺序执行的任务。
在现实世界中,我们通常能直观地看到这些约束。然而,在计算机世界中,我们会遇到竞态条件等问题。

计算机世界中的挑战:竞态条件

当多个线程试图使用一个共享变量,且彼此没有协调时,就可能发生竞态条件。两个线程可能同时修改同一个变量,导致该变量的最终值变得不确定,取决于修改发生的具体时机。
为了解决这个问题,我们需要一种机制,让不同的进程能够对变量“上锁”,以确保结果的可预测性。这非常复杂,因此不建议普通用户轻易尝试。但随着多核处理器的普及,并发编程正变得越来越重要。
为什么并发编程日益重要?
并发编程的重要性与日俱增,原因如下:
- 硬件发展趋势:未来的计算机,甚至现在的计算机,都朝着大规模并行的方向发展。超级计算机通过将大量廉价设备组合在一起来获得每秒千万亿次甚至百亿亿次的运算能力。
- 典型应用场景:模拟星系形成、天气预报等复杂计算任务,都可以通过将计算分解成许多可以并发执行的部分来加速。
- 本地计算设备:即使在你本地的电脑店购买的PC或Mac,现在也至少配备了四核CPU。甚至在新的智能手机中,也有多线程程序在运行。
一个简单的C++线程示例
下面是一个非常基础的C++多线程程序示例。要使用GCC 4.8(支持C++11标准)编译此示例,你需要在编译命令中加入 -pthread 标志。
#include <iostream>
#include <thread>
// 一个经典的函数
void classic_function() {
std::cout << "这是一个经典函数线程。\n";
}
int main() {
// 创建一个线程,执行经典函数
std::thread t1(classic_function);
// 创建一个线程,执行一个lambda表达式(C++11中的无名函数)
std::thread t2([](){
std::cout << "这是一个lambda表达式线程。\n";
});
// 使用join()等待线程结束,实现同步
t1.join();
t2.join();
std::cout << "主线程结束。\n";
return 0;
}
以下是代码的关键点解析:

std::thread t1(classic_function);:声明一个线程t1,并将其与一个经典函数关联。std::thread t2([](){...});:声明一个线程t2,并将其与一个lambda表达式关联。这是C++11中创建匿名函数的常见方式。t1.join();和t2.join();:join()命令用于确保主线程等待t1和t2这两个并发执行流完成后再继续。这是实现线程同步的基本方法,其思想可以追溯到Dijkstra等人的工作。
总结

本节课中我们一起学习了C++并发编程的基础。我们了解到这是一个复杂但至关重要的领域,C++11通过标准库提供了原生支持。我们探讨了并发的概念、面临的挑战(如竞态条件),以及如何使用 std::thread 和 join() 来创建和管理简单的多线程程序。随着硬件全面进入多核时代,掌握并发编程的基本思想将变得越来越重要。
053:元组-C++11新库
在本节课中,我们将学习C++11标准库中一个非常实用且易于使用的组件——元组(tuple)。元组是std::pair的泛化,它允许我们将多个(通常是不同类型的)值组合成一个单一对象,这在需要从函数返回多个值或聚合少量数据时非常方便。

元组的概念与用途
上一节我们介绍了C++11中一些复杂的库(如线程库),本节我们来看看一个简单得多的库——元组。
元组的基本思想是将少量元素链接在一起。它是对已存在一段时间的pair的泛化。在编程中,我们经常需要包含多个值的东西。例如,你可能想从一个函数返回多个聚合在一起的值。虽然可以用数组实现,但当元素数量较少时,元组提供了一种更自然的方式来聚合它们。
元组的一个关键特性是它不需要元素类型相同。元组中的每个参数可以来自完全不同的类型集合,这些类型可以各自独立实例化。
以下是元组的一个典型应用场景:考虑在三维空间中绘图。一个三维点包含X、Y和Z坐标。我们可以分别处理这些坐标,但元组提供了一种自然的功能,使得实现这样的三维点变得非常容易。
元组的实现与使用
以下是使用元组实现一个简单3D点类的示例。实现方式有很多种,元组并非唯一选择,也不一定带来显著的效率优势,但它展示了如何自然地整合这一功能。
#include <tuple>


class Point3D {
private:
std::tuple<double, double, double> p; // 元组存储三个double值

public:
// 默认构造函数:将点设置在原点 (0,0,0)
Point3D() : p(std::make_tuple(0.0, 0.0, 0.0)) {}
// 带参数的构造函数
Point3D(double x, double y, double z) : p(std::make_tuple(x, y, z)) {}
// 友元函数,用于输出点的坐标
friend std::ostream& operator<<(std::ostream& os, const Point3D& point) {
os << "(" << std::get<0>(point.p) << ", "
<< std::get<1>(point.p) << ", "
<< std::get<2>(point.p) << ")";
return os;
}
};
在这个类中:
p是一个包含三个double类型参数的元组。- 构造函数使用
std::make_tuple来创建元组并存储值。 - 输出操作符使用
std::get<N>来访问元组中特定位置(索引)的值。get<0>获取X值,get<1>获取Y值,get<2>获取Z值。
元组深深植根于泛型编程的概念中,它本身就是一个模板类型,使得泛型编程变得更加容易。

示例代码运行


让我们看看如何使用这个Point3D类:

int main() {
Point3D p1; // 使用默认构造函数,坐标为 (0, 0, 0)
Point3D p2(1.2, 2.3, 3.4); // 使用带参构造函数
std::cout << "P1 is " << p1 << std::endl;
std::cout << "P2 is " << p2 << std::endl;
return 0;
}
这段代码的输出将是:
P1 is (0, 0, 0)
P2 is (1.2, 2.3, 3.4)
C++11 其他新特性示例
为了更全面地理解C++11带来的改进,我们最后再看一段代码,它集中体现了多个新特性:
class Simple {
int p, q;
public:
Simple() = default; // 指示编译器生成默认构造函数
explicit Simple(int x) : p(x), q(x) {} // 显式构造函数,防止隐式转换
Simple(const Simple&) = delete; // 禁止拷贝构造函数(抑制转换)
// 委托构造函数:一个构造函数复用另一个构造函数的代码
Simple(int x, int y) : Simple(x) { q = y; }
// 移动构造函数(右值引用),实现移动语义
Simple(Simple&& other) noexcept : p(other.p), q(other.q) {
other.p = other.q = 0;
}
// 成员默认初始化
int a = 10;
double b = 20.5;
};
class Derived : public Simple {
public:
// 使用声明,从基类继承构造函数
using Simple::Simple;
};
这段代码展示了以下C++11特性:
= default: 指示编译器为类生成默认的特殊成员函数(如构造函数)。= delete: 禁止编译器使用某个特定的函数(如拷贝构造函数),常用于防止不希望的隐式类型转换,避免潜在的运行时错误。- 委托构造函数: 一个构造函数可以调用同一个类中的另一个构造函数,避免代码重复。
- 移动语义与右值引用: 通过
Simple(Simple&&)实现高效的资源转移,这是C++11的一个重要改进。 - 类内成员初始化: 在声明类成员时直接提供默认值。
- 继承构造函数: 使用
using Base::Base;语句让派生类自动继承基类的构造函数,无需重写。
这些特性大多是实用性的改进(移动语义相对更激进),它们共同使C++代码更安全、更简洁、更高效。

本节课中我们一起学习了C++11标准库中的元组(tuple),了解了它如何将多个可能类型不同的值组合成单一对象,并通过一个3D点类的例子掌握了其基本用法。此外,我们还回顾了C++11中其他一些重要的新特性,如默认/删除函数、委托构造函数、移动语义和类内初始化等。这些特性代表了C++语言的现代化改进,有助于编写更清晰、更健壮的代码。
054:设计模式
在本节课中,我们将要学习软件工程中的一个重要概念——设计模式。我们将了解什么是设计模式,它们为何重要,以及如何将它们应用于构建优雅、可维护的大型C++代码。
概述
上一节我们介绍了C++语言本身的能力与局限。本节中我们来看看如何将这些能力组织起来,构建出结构良好、优雅的大型软件。这涉及到软件架构的思想。
语言本身功能强大,能完成许多任务。但它并不保证你能以良好的方式完成任务。如何编写大型代码以获得优雅的结果?我们可以在建筑领域找到类比。建筑师不是土木工程师,土木工程师也不是建筑师。然而,世界上既有美丽的桥梁,也有非常实用的桥梁;既有简单的仓库,也有兼具艺术与美学价值的伟大建筑。编写代码也应如此。大型代码应当具有美感。我们应该对其感到满意,能够评价它“优雅、合理、易于理解”。因此,在宏观层面,我们试图教授如何将所有部分整合在一起,即软件架构。
那么,我们如何将大型代码组合在一起呢?

设计模式的起源与要素
在20世纪80年代,一些编写大型代码的优秀程序员(通常使用Ada、C++或Smalltalk等新语言)开始思考一个问题:是什么让我们能够结构性地复用代码,并出色地完成任务?他们最终将其表述为“设计模式”。
这些思想在20世纪80年代后半期逐渐成型。设计模式领域的先驱们认为,一个设计模式应包含四个基本要素。他们的灵感部分来源于真正的建筑学。他们注意到,在建筑学文献中,人们也思考类似的问题:可以有独特的建筑,但也存在一些反复出现且非常有用的模式,这些模式需要特定的上下文,并且需要有方法来引用它们。
以下是设计模式的四个基本特征:
- 名称:模式必须有一个名字。例如,“迭代器”就是一个名称。迭代器在设计大型软件(如STL容器类)时至关重要,它是使用这些容器的一种组织方式。
- 问题:模式必须解决一个反复出现的问题。我们不关心那些独特、可能极少用到的模式。
- 解决方案:仅仅识别问题还不够,我们需要一个行之有效的解决方案。
- 后果:我们需要了解使用该模式通常会带来的结果。有时模式非常通用但效率不高;有时它易于使用,但需要为易用性牺牲一些东西。
模式的分类与示例
模式有更广泛的分类。例如,一类模式是“创建型”模式。
顺便提一下,设计模式的概念因“四人组”(Gang of Four)而广为人知。这四位作者在20世纪90年代初撰写的设计模式书籍引起了巨大轰动。许多人突然有了“顿悟”时刻,意识到自己一直在使用类似的思想,但不知道它们是通用的,也不知道应该将其系统化。
“四人组”在一本优秀的书中组织了许多模式(大约30到40个),并将它们分为不同的组。其中一个组就是“创建型”模式。
以下是两种创建型模式的简要介绍:
- 工厂方法:我们稍后会从C++的角度详细探讨这个模式。它也被称为“虚拟构造”,其实现方式是将对象的构造延迟到子类中进行。
- 单例模式:这种模式需要确保无论构造什么,都只有一个实例。
我们将详细学习工厂方法的工作原理。你也可以想象如何用C++编写一个单例模式。在C++中,单例模式意味着,如果创建实例(通常通过类的构造函数),则必须在其中加入某种保护机制,声明“实例已经创建”,并禁止创建更多实例。这就是管理单例模式的方式。单例模式相当简单,但有一些重要用途。例如,在系统中获取唯一资源(如向屏幕写入内容)时,不能有多个副本,因此必须禁止创建多个实例。
再次强调,工厂方法解决了虚拟构造函数的问题。请记住,在C++中可以有虚拟析构函数,但不能有虚拟构造函数。然而,多态构造的想法是很自然的。为什么不能有多态构造和多态析构呢?这个模式解决了这个常见问题。
此外,还有属于“结构型”模式的类别。
结构型模式包括:
- 外观模式:提供一个统一的接口。
- 代理模式:提供一个替身或代理(可能指向另一台机器),这种模式在多线程和并发系统中非常常见。
这些结构型模式被大量使用,是构建某些系统时的重要组成部分。再次注意这些名称的特点,如“外观”、“代理”或“工厂方法”,它们为你提供了一种生动的方式来思考问题。
总结

本节课中,我们一起学习了软件设计模式的基本概念。我们了解了设计模式的四个核心要素:名称、问题、解决方案和后果。我们还探讨了模式的分类,特别是创建型模式(如工厂方法和单例模式)和结构型模式(如外观模式和代理模式)。理解并应用这些模式,能帮助我们将C++代码组织得更加优雅、可复用和易于维护,从而构建出更优秀的软件架构。
055:工厂方法模式
在本节课中,我们将学习如何在C++代码中实现工厂方法模式。我们将探讨其动机、适用场景以及具体的实现方式。
动机与概念
上一节我们介绍了工厂方法模式的基本概念。本节中,我们来看看其具体的动机和为什么它会频繁出现。
代码通常知道一个抽象基类,但不知道具体的子类。因此,需要将对象的创建推迟到具体的子类中。这意味着创建过程被延迟到运行时,当实际知道需要什么时才进行。
一个替代方案是使用该类型的通用对象,但这可能过于庞大和昂贵,并且缺乏多态性。

模式名称与适用性
“四人帮”在设计模式命名上非常巧妙。他们没有使用“虚拟构造函数”这个名称,因为它与C++的virtual关键字绑定,而该概念不应局限于特定语言。他们选择了“工厂方法”这个易于记忆和检索的名称。
该模式的适用性在于,它支持在运行时创建对象,并由子类负责创建对象,而不是在基类中创建。
结构蓝图
如果你查阅“四人帮”的原著,你会看到以UML风格绘制的图表,展示了设计中不同代码块之间的关系。
以下是参与工厂方法模式的典型角色:
- 具体产品:需要被创建的对象。
- 具体创建者:重写工厂方法以构建具体产品。
在面向对象社区中,使用Rational Rose等工具绘制此类文档非常普遍。你可以从代码中抽象出这些关系。设计图中会指定工厂方法、创建者、具体创建者以及返回新产品之间的所有关系。

实现方式
那么,我们如何实现它呢?

创建者类需要一个多态的创建方法。虽然我们不能有虚拟构造函数,但可以有一个方法来模拟虚拟构造函数。
一种实现方式是通过使用标签或枚举器来确定要创建的类型。
总结

本节课中,我们一起学习了工厂方法模式在C++中的实现。我们了解了其核心动机是将对象创建延迟到子类,探讨了其命名缘由和适用场景,并概述了其结构组成和一种通过多态方法和类型标识来实现的基本方式。这种模式有助于构建可扩展的系统,例如在不知道未来所有窗口类型的情况下设计一个窗口系统。
C++编程:P56:工厂方法模式C++11代码示例
在本节课程中,我们将学习一个具体的C++代码示例,它展示了工厂方法模式的实际实现。
上一节我们介绍了工厂方法模式的概念,本节中我们来看看它的具体C++实现。这段代码展示了一个实际的工厂方法实现。
请注意,这里的create方法是虚函数。它的返回类型是基类指针。然后,create方法将通过多态性被选择调用。

接着,我们将执行测试。每个测试将决定创建何种对象。
以下是代码示例:
// 基类定义
class Base {
public:
virtual Base* create(int id) = 0; // 虚工厂方法
virtual ~Base() {}
};

// 派生类1
class Derived1 : public Base {
public:
Base* create(int id) override {
if (id == 1) {
return new Derived1();
}
return nullptr; // 错误情况返回空对象
}
};

// 派生类2
class Derived2 : public Base {
public:
Base* create(int id) override {
if (id == 2) {
return new Derived2();
}
return nullptr;
}
};
所以,对于对象一,测试ID:如果ID匹配,则返回相应的对象。
我们也可以重写行为,并为其他不同的行为设置标签。
如果遇到错误结果,我们也可以返回一个空对象。
虚拟构造函数与工厂代码
这个工厂代码是通过人工构建的。构建方式是:拥有一个返回基类指针的虚create方法。
为什么使用基类指针?因为基类指针是通用的。因此,无论工厂想要生产什么,都可以用基类指针指向它。所以,这不是一个显式的具体实例,而是一个基类指针实例,这让我们能够以多态方式处理一切。


在运行时,标签被用来调用我们想要的不同构造函数。create行为可以被重写,以进一步决定运行时的行为。
关于标识符类型的思考
再次强调,你之前已经看到过,但你认为这里的ID应该是什么类型?这是一个非常简单的问题,我们在之前的代码中已经见过。
只是为了检查你是否在注意听讲。答案是:我们很可能希望将其设为枚举类型。
它也可以是一个集合类型,或者一组简单的整数。但最有可能的是,它将是一个经过适当设计的枚举类型。

例如,如果工厂方法是在某个窗口系统中创建完全不同风格的窗口,那么枚举类型将包含适当的标识符名称。

总结
本节课中,我们一起学习了工厂方法模式在C++中的具体实现。我们看到了如何通过虚函数和基类指针来实现多态的对象创建,讨论了使用枚举类型作为对象标识符的合理性,并理解了工厂方法在运行时根据输入参数动态决定对象类型的能力。
057:适配器模式 🧩
在本节课中,我们将要学习设计模式中的适配器模式。我们将了解它的基本概念、在C++标准模板库(STL)中的具体应用,以及它在构建大型、可复用软件架构中的重要性。
适配器模式简介
适配器模式是一种结构型设计模式。它的核心目的是将一个类的接口转换成客户期望的另一个接口。这种模式也被称为包装器(Wrapper)类。

上一节我们介绍了设计模式的重要性,本节中我们来看看适配器模式的具体应用。当现有软件的接口可以被修改以适应新环境时,适配器模式能促进代码的复用。

适配器模式的工作原理
以下是适配器模式中各个角色的协作方式:
- 目标(Target): 定义客户所期望的接口。
- 适配者(Adaptee): 拥有一个需要被适配的现有接口。
- 适配器(Adapter): 对适配者的接口进行包装,并将其转换成目标接口。
其协作流程是:客户端调用适配器对象上的操作,然后适配器调用适配者的操作来执行请求。
C++ STL中的适配器模式
在C++标准模板库中,适配器模式有非常直接的应用,就像迭代器模式一样。一个显著的例子是std::stack的声明。
std::stack是一个容器适配器。它本身并非独立的容器实现,而是通过适配(包装)另一个支持特定操作的底层容器来实现其功能。这个底层容器必须支持栈操作,例如push_back、pop_back和back。
std::vector是能够轻松支持栈操作的容器之一。这种适配器(或外观)的概念意味着:与其使用vector的全部通用功能,不如约束它,使其看起来像一个栈。栈需要执行push操作,而vector提供了push_back操作,只要这种能力存在,就可以进行适配。
如果你使用过stack这个适配器类,现在应该能理解其背后的宏观设计思想了。
知识巩固与延伸
为了加深理解,我们可以思考一个简短的问题:哪些STL容器可以被适配成栈?
std::stack本身不是独立的代码,它实际上是包装了标准库或你的库中支持特定操作集的某个其他容器。
答案是:deque和list都支持push_back和pop_back操作,因此可以被适配为栈。而map不支持这些操作,因此不能。
设计模式的普遍性与价值
模式无处不在。自“四人帮”(Gang of Four)撰写了包含大约40个模式的经典著作以来,人们已经在代码库中发现了数百甚至数千种模式。
专业程序员乐于发现新的模式。其中一些模式被广泛接受。例如,智能指针(Smart Pointers)就是一种可以在许多地方使用的模式,并且已被包含在C++的标准库中。
模式在面向对象编程中尤其有用,但并不局限于面向对象。在拥有辅助函数、类和协作类等元素的大型架构构建中,模式至关重要。它极大地推动了整个软件开发方法论的发展,使模式方法成为程序员的常规工具。

关于C++中设计模式,有一本非常经典且讨论深入的书籍:James O. Coplien的《Advanced C++ Programming Styles and Idioms》。这本书提供了优秀的示例代码,例如包装器代码,清晰地展示了这些思想的强大之处。
课程总结 🎯


本节课中我们一起学习了适配器模式。我们了解了它的定义是将一个接口转换成另一个接口的包装器类,并探讨了其在C++ STL(如std::stack)中的具体实现。我们认识到,掌握设计模式是迈向精通C++等语言、编写更大规模、更可复用代码的关键一步。设计模式是构建健壮软件架构的重要工具,值得每一位开发者深入学习和运用。

浙公网安备 33010602011771号